실무에서 배운 파일 업로드 기능 설계
들어가며
파일 업로드 기능을 처음 구현할 때는 간단하다고 생각했다.
“파일을 받아서 저장하면 되지 않나?”
하지만 실무에서 파일 업로드를 구현하면서, 단순히 파일을 저장하는 것 이상의 고민이 필요하다는 것을 깨달았다.
이 글에서는 공지사항 첨부파일 기능을 설계하고 구현하며 고민했던 내용을 정리한다.
1. 파일 업로드 설계 개요
전체 흐름 (공지사항 예시)
시나리오: 첨부파일이 포함된 공지사항 작성
[사용자가 첨부파일 버튼 클릭]
↓
파일 다이얼로그에서 파일 선택
↓
━━━━━━━━━━━━━━━━━━━━━━━
1단계: 파일 임시 저장 API
━━━━━━━━━━━━━━━━━━━━━━━
파일 검증
↓
스토리지 업로드
↓
DB에 TEMP 상태로 저장
↓
fileGroupId 응답
━━━━━━━━━━━━━━━━━━━━━━━
↓
[사용자가 공지사항 내용 작성]
↓
[공지사항 등록 버튼 클릭]
↓
━━━━━━━━━━━━━━━━━━━━━━━
2단계: 공지사항 저장 API
━━━━━━━━━━━━━━━━━━━━━━━
@Transactional 시작
↓
공지사항 엔티티 저장
↓
파일 상태 TEMP → SUCCESS
↓
트랜잭션 커밋
━━━━━━━━━━━━━━━━━━━━━━━
핵심: 임시저장 → 확정 2단계 분리
1단계: 파일 임시 저장 (TEMP)
- 스토리지 업로드 (외부 시스템)
- DB에 TEMP 저장 (별도 트랜잭션)
- 실패 시 즉시 피드백
2단계: 글 저장 시 확정 (SUCCESS)
- 공지사항 저장 + 파일 상태 변경
- 단일 DB 트랜잭션으로 처리
영속성 경계:
- 1단계: 스토리지와 DB가 각각 독립
- 2단계: DB 작업만 하나의 트랜잭션으로 묶음
2. 트랜잭션과 정합성 문제
문제 인식
파일 업로드의 가장 큰 문제는 스토리지 업로드와 DB 저장이 서로 다른 영속성 영역이라는 점이다.
만약 공지사항 저장 시 파일을 함께 업로드한다면:
- 파일 업로드 성공 → 공지사항 저장 실패 → 고아 파일 발생
- 대용량 파일을 다시 업로드해야 함 (UX 저하)
- DB 커넥션을 오래 잡고 있어 리소스 낭비
하나의 트랜잭션으로 묶으려 할 때의 난관
1. 트랜잭션 생명 주기 문제
- 파일 업로드는 네트워크 상태에 따라 시간이 오래 걸림
- DB 커넥션을 파일 업로드 동안 잡고 있으면 리소스 고갈
2. 원자성 확보의 어려움
- 이기종 시스템(스토리지 + DB) 간 완전한 트랜잭션(2PC) 구현은 복잡하고 비용이 큼
3. 브라우저 메모리 부하
- 대용량 파일을 메모리에 보유하면 브라우저 성능 저하
해결 방향: 임시저장 → 확정 방식
나의 상황에서는 하나의 트랜잭션으로 묶는 것보다 분리하는 것이 성능과 가용성 면에서 훨씬 유리하다고 판단했다.
그래서 임시저장 → 확정 방식으로 설계하였다.
선택 이유:
데이터 무결성과 사용자 경험(UX) 측면에서 이점이 있다고 생각했다.
이 설계의 장점:
| 구분 | 설명 |
|---|---|
| 사용자 경험 | 게시글을 쓰는 동안 백그라운드에서 파일이 올라가므로, 등록 버튼 클릭 시 대기 시간 최소화 |
| 안정성 | 대용량 파일 업로드 중 오류가 나도 게시글 본문 데이터는 안전함 |
| 확장성 | 파일 서버와 API 서버를 분리하기 쉬움 (브라우저 → S3 직접 업로드) |
| 리소스 효율 | DB 트랜잭션은 짧게 유지되어 커넥션 풀 고갈 방지 |
3. FileStorage 인터페이스 설계
목적
확장성:
- 스토리지 교체 용이 (Local → S3 → NAS)
- 테스트 환경 분리
인터페이스
public interface FileStorage {
/**
* 파일 업로드
*/
void upload(InputStream input, String directory, String fileName)
throws IOException;
/**
* 파일 삭제
*/
void delete(String directory, String fileName)
throws IOException;
/**
* 파일 URL 생성
*/
String resolveUrl(String directory, String fileName);
/**
* 파일 다운로드 스트림
*/
InputStream download(String directory, String fileName)
throws IOException;
}
구현체 예시
LocalFileStorage:
@Component
@Profile("local")
public class LocalFileStorage implements FileStorage {
@Value("${file.upload.path}")
private String basePath;
@Override
public void upload(InputStream input, String directory, String fileName)
throws IOException {
Path path = Paths.get(basePath, directory, fileName);
Files.createDirectories(path.getParent());
Files.copy(input, path, StandardCopyOption.REPLACE_EXISTING);
}
@Override
public void delete(String directory, String fileName) throws IOException {
Path path = Paths.get(basePath, directory, fileName);
Files.deleteIfExists(path);
}
@Override
public String resolveUrl(String directory, String fileName) {
return "/files/" + directory + "/" + fileName;
}
@Override
public InputStream download(String directory, String fileName)
throws IOException {
Path path = Paths.get(basePath, directory, fileName);
return Files.newInputStream(path);
}
}
S3FileStorage:
@Component
@Profile("prod")
public class S3FileStorage implements FileStorage {
private final AmazonS3 s3Client;
private final String bucketName;
@Override
public void upload(InputStream input, String directory, String fileName)
throws IOException {
String key = directory + "/" + fileName;
ObjectMetadata metadata = new ObjectMetadata();
s3Client.putObject(bucketName, key, input, metadata);
}
@Override
public String resolveUrl(String directory, String fileName) {
String key = directory + "/" + fileName;
// Presigned URL 생성 (1시간 유효)
Date expiration = new Date(System.currentTimeMillis() + 3600000);
return s3Client.generatePresignedUrl(bucketName, key, expiration).toString();
}
// ...
}
4. FileValidator 인터페이스 설계
설계 의도
도메인마다 요구하는 파일 정책이 다르다 (사이즈, 형식, 개수 등). Strategy 패턴으로 검증 로직을 독립적으로 관리하도록 설계했다.
인터페이스
public interface FileValidator {
/**
* 파일 업로드 상태 검증
*/
UploadState validateUploadState(MultipartFile file);
}
UploadState Enum
public enum UploadState {
SUCCESS("성공"),
TEMP("임시 업로드"),
INVALID_TYPE("지원하지 않는 파일 형식"),
SIZE_EXCEEDED("파일 크기 초과"),
INVALID_NAME("잘못된 파일명"),
EMPTY_FILE("빈 파일"),
DELETED("삭제됨");
private final String message;
UploadState(String message) {
this.message = message;
}
public boolean isSuccess() {
return this == SUCCESS;
}
public String getMessage() {
return message;
}
}
도메인별 검증 구현
공지사항 검증:
@Component
public class NoticeFileValidator implements FileValidator {
private static final long MAX_SIZE = 2L * 1024 * 1024; // 2MB
private static final Set<String> ALLOWED_TYPES = Set.of(
"image/jpeg", "image/png"
);
@Override
public UploadState validateUploadState(MultipartFile file) {
if (file.isEmpty()) {
return UploadState.EMPTY_FILE;
}
String contentType = file.getContentType();
if (contentType == null || !ALLOWED_TYPES.contains(contentType)) {
return UploadState.INVALID_TYPE;
}
if (file.getSize() > MAX_SIZE) {
return UploadState.SIZE_EXCEEDED;
}
return UploadState.SUCCESS;
}
}
프로필 검증:
@Component
public class ProfileFileValidator implements FileValidator {
private static final long MAX_SIZE = 5L * 1024 * 1024; // 5MB
private static final Set<String> ALLOWED_TYPES = Set.of(
"image/jpeg", "image/png", "image/gif", "image/webp"
);
@Override
public UploadState validateUploadState(MultipartFile file) {
if (file.isEmpty()) {
return UploadState.EMPTY_FILE;
}
String contentType = file.getContentType();
if (contentType == null || !ALLOWED_TYPES.contains(contentType)) {
return UploadState.INVALID_TYPE;
}
if (file.getSize() > MAX_SIZE) {
return UploadState.SIZE_EXCEEDED;
}
return UploadState.SUCCESS;
}
}
5. 상세 구현
1단계: 파일 임시 저장
@Service
@RequiredArgsConstructor
public class FileUploadService {
private final FileStorage fileStorage;
private final UploadFileRepository uploadFileRepository;
private final FileValidator fileValidator;
/**
* 파일 선택 시 즉시 업로드 (임시 상태)
*/
@Transactional
public FileUploadResponse uploadTemp(
String refTable,
List<MultipartFile> files) {
String fileGroupId = FileGroupIdUtil.generate();
List<UploadFile> uploadedFiles = new ArrayList<>();
for (MultipartFile file : files) {
// 1. 파일 검증
UploadState state = fileValidator.validateUploadState(file);
if (!state.isSuccess()) {
throw new InvalidFileException(state.getMessage());
}
// 2. 스토리지 업로드
String saveFileName = generateSaveFileName(file);
String directory = buildDirectory(refTable, fileGroupId);
try {
fileStorage.upload(
file.getInputStream(),
directory,
saveFileName
);
} catch (IOException e) {
throw new FileUploadException("파일 업로드 실패", e);
}
// 3. DB에 TEMP 상태로 저장
UploadFile uploadFile = UploadFile.of(
refTable,
fileGroupId,
directory,
file.getOriginalFilename(),
saveFileName,
file.getSize(),
file.getContentType()
);
uploadFile.setUploadState(UploadState.TEMP);
uploadFileRepository.save(uploadFile);
uploadedFiles.add(uploadFile);
}
return FileUploadResponse.of(fileGroupId, uploadedFiles);
}
private String generateSaveFileName(MultipartFile file) {
String extension = getExtension(file.getOriginalFilename());
return System.currentTimeMillis() + "_" +
UUID.randomUUID().toString() + extension;
}
private String buildDirectory(String refTable, String fileGroupId) {
LocalDate now = LocalDate.now();
return String.format("%s/%d/%02d/%02d/%s",
refTable,
now.getYear(),
now.getMonthValue(),
now.getDayOfMonth(),
fileGroupId
);
}
}
실패 시:
- 사용자에게 즉시 에러 응답
- 사용자는 파일을 다시 선택하여 재시도
2단계: 공지사항 저장 + 파일 상태 확정
@Service
@RequiredArgsConstructor
public class NoticeService {
private final NoticeRepository noticeRepository;
private final UploadFileRepository uploadFileRepository;
/**
* 공지사항 저장 + 파일 상태 확정
*/
@Transactional
public void saveNotice(NoticeRequest request) {
// 1. 공지사항 저장
Notice notice = Notice.of(request.getTitle(), request.getContent());
noticeRepository.save(notice);
// 2. 파일 상태를 TEMP → SUCCESS로 변경
if (request.getFileGroupId() != null) {
uploadFileRepository.updateStateByFileGroupId(
request.getFileGroupId(),
UploadState.TEMP,
UploadState.SUCCESS
);
}
}
}
Repository 메서드:
@Repository
public interface UploadFileRepository extends JpaRepository<UploadFile, Long> {
@Modifying
@Query("UPDATE UploadFile f " +
"SET f.uploadState = :newState, f.updatedAt = CURRENT_TIMESTAMP " +
"WHERE f.fileGroupId = :fileGroupId AND f.uploadState = :oldState")
int updateStateByFileGroupId(
@Param("fileGroupId") String fileGroupId,
@Param("oldState") UploadState oldState,
@Param("newState") UploadState newState
);
List<UploadFile> findByUploadStateAndCreatedAtBefore(
UploadState uploadState,
LocalDateTime createdAt
);
}
핵심:
- 1단계 실패: 파일만 다시 업로드
- 2단계 실패: 공지사항만 재시도 (파일은 이미 업로드됨)
이 방식을 선택한 이유
데이터 무결성과 사용자 경험(UX) 측면에서 이점이 있다고 생각했다.
UX 관점:
- 대용량 파일 업로드 후 글 저장 실패 시, 파일은 이미 업로드되어 있어 글만 재시도하면 됨
트랜잭션 경계 분리:
- 파일 업로드: 스토리지 작업 (통제 불가능)
- 공지사항 저장: DB 작업만 (통제 가능)
실패 확률 최소화:
- 글 저장 시점에는 DB 작업만 수행 (빠르고 안정적)
TEMP/DELETED 파일 정리 전략
상황 1: 파일만 첨부하고 글을 저장하지 않은 경우 (TEMP)
상황 2: 사용자가 게시글 수정 중 첨부파일을 삭제한 경우 (DELETED)
상황 3: 원본 글(공지사항)을 삭제한 경우 (연결된 파일 정리)
이 모든 상황에서 스토리지에는 파일이 남아있지만, 실제로는 사용되지 않는 파일이다.
@Component
@RequiredArgsConstructor
public class FileCleanupScheduler {
private final UploadFileRepository uploadFileRepository;
private final FileStorage fileStorage;
/**
* 불필요한 파일 일괄 정리
*/
@Scheduled(cron = "0 0 3 * * *") // 매일 새벽 3시
public void cleanupOrphanFiles() {
LocalDateTime threshold = LocalDateTime.now().minusHours(24);
// 1. TEMP 파일 정리 (24시간 이상 확정되지 않은 파일)
List<UploadFile> tempFiles = uploadFileRepository
.findByUploadStateAndCreatedAtBefore(UploadState.TEMP, threshold);
cleanupFiles(tempFiles, "TEMP");
// 2. DELETED 파일 정리 (24시간 이상 지난 삭제 파일)
List<UploadFile> deletedFiles = uploadFileRepository
.findByUploadStateAndUpdatedAtBefore(UploadState.DELETED, threshold);
cleanupFiles(deletedFiles, "DELETED");
// 3. 원본 글이 삭제된 경우의 파일 정리
cleanupFilesWithDeletedNotice();
}
/**
* 공통 파일 정리 로직
*/
private void cleanupFiles(List<UploadFile> files, String type) {
for (UploadFile file : files) {
try {
// 스토리지에서 삭제
fileStorage.delete(file.getDirectory(), file.getSaveFileName());
// DB에서 물리 삭제
uploadFileRepository.delete(file);
log.info("{} 파일 정리 완료: id={}, fileName={}",
type, file.getId(), file.getSaveFileName());
} catch (Exception e) {
log.error("{} 파일 정리 실패: id={}, fileName={}",
type, file.getId(), file.getSaveFileName(), e);
}
}
log.info("{} 파일 정리 작업 완료: {}개 처리", type, files.size());
}
/**
* 원본 글이 삭제된 경우의 파일 정리
* (notice 테이블에 존재하지 않는 공지사항의 파일들)
*/
private void cleanupFilesWithDeletedNotice() {
// SUCCESS 상태이지만 원본 공지사항이 없는 파일 조회
List<UploadFile> orphanFiles = uploadFileRepository
.findOrphanFilesByRefTable("notice");
for (UploadFile file : orphanFiles) {
try {
// 스토리지에서 삭제
fileStorage.delete(file.getDirectory(), file.getSaveFileName());
// DB에서 물리 삭제
uploadFileRepository.delete(file);
log.info("원본 글 삭제된 파일 정리 완료: id={}, fileName={}",
file.getId(), file.getSaveFileName());
} catch (Exception e) {
log.error("원본 글 삭제된 파일 정리 실패: id={}, fileName={}",
file.getId(), file.getSaveFileName(), e);
}
}
log.info("원본 글 삭제된 파일 정리 작업 완료: {}개 처리", orphanFiles.size());
}
}
Repository 메서드 추가:
@Repository
public interface UploadFileRepository extends JpaRepository<UploadFile, Long> {
// ...existing code...
/**
* TEMP 파일 조회 (생성 시간 기준)
*/
List<UploadFile> findByUploadStateAndCreatedAtBefore(
UploadState uploadState,
LocalDateTime createdAt
);
/**
* DELETED 파일 조회 (수정 시간 기준)
*/
List<UploadFile> findByUploadStateAndUpdatedAtBefore(
UploadState uploadState,
LocalDateTime updatedAt
);
/**
* 원본 글이 삭제된 고아 파일 조회
* (notice 테이블에 존재하지 않는 파일)
*/
@Query("SELECT f FROM UploadFile f " +
"WHERE f.refTable = :refTable " +
"AND f.uploadState = 'SUCCESS' " +
"AND NOT EXISTS (" +
" SELECT 1 FROM Notice n WHERE n.fileGroupId = f.fileGroupId" +
")")
List<UploadFile> findOrphanFilesByRefTable(@Param("refTable") String refTable);
}
정리 정책:
| 상태 | 기준 시간 | 정리 대상 | 조건 |
|---|---|---|---|
| TEMP | created_at | 파일만 업로드되고 글 저장 안 됨 | 24시간 이상 |
| DELETED | updated_at | 사용자가 첨부파일 삭제 | 24시간 이상 |
| SUCCESS | - | 원본 글(공지사항)이 삭제됨 | 원본 미존재 |
왜 24시간인가?
- 사용자가 글을 작성하다가 중단한 경우를 고려
- 다음 날 이어서 작성할 가능성 제공
- 24시간 후에는 작성 의도가 없다고 판단
원본 글 삭제 시 즉시 처리 vs 배치 처리:
나는 배치 처리 방식을 선택했다.
- 즉시 처리: 원본 글 삭제 시 파일도 함께 삭제 (트랜잭션 묶음)
- 배치 처리: 원본 글 삭제는 빠르게 처리, 파일은 스케줄러가 정리
배치 처리를 선택한 이유:
- 공지사항 삭제 트랜잭션에 스토리지 삭제 작업을 포함하지 않음
- 스토리지 장애 시에도 공지사항 삭제는 성공
- 스케줄러가 일괄 처리하여 효율적
상황 4: 사용자가 첨부파일을 직접 삭제
@Service
@RequiredArgsConstructor
public class FileDeleteService {
private final UploadFileRepository uploadFileRepository;
private final FileStorage fileStorage;
@Transactional
public void deleteFile(Long fileId) {
UploadFile file = uploadFileRepository.findById(fileId)
.orElseThrow(() -> new FileNotFoundException("파일을 찾을 수 없습니다"));
// 스토리지에서 삭제 시도
try {
fileStorage.delete(file.getDirectory(), file.getSaveFileName());
} catch (Exception e) {
log.warn("스토리지 파일 삭제 실패 (배치에서 재시도): {}", file.getId());
}
// DB 상태를 DELETED로 변경 (논리 삭제)
file.setUploadState(UploadState.DELETED);
uploadFileRepository.save(file);
}
}
핵심:
- 스토리지 삭제 실패해도 DB는 DELETED로 변경
- 스케줄러가 24시간 후 재시도하여 정합성 확보
이 방식의 장단점
장점:
- 재시도에 강함 (파일은 이미 업로드되어 있음)
- 사용자 경험 우수 (대용량 파일도 한 번만 업로드)
- 장애 영향 범위 최소화 (트랜잭션 경계가 명확)
- 정합성 확보 (TEMP/DELETED/원본 삭제된 파일 모두 배치로 정리)
- 스토리지 장애 시에도 DB 작업은 성공
단점:
- 상태 관리 필요 (TEMP, SUCCESS, DELETED)
- 배치 작업 필요 (고아 파일 정리)
- 일시적으로 고아 파일 존재 가능 (최종 일관성)
6. 테이블 구조 설계
DDL
CREATE TABLE upload_file (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
ref_table VARCHAR(64) NOT NULL COMMENT '참조 테이블명 (notice, user_profile, board 등)',
file_group_id VARCHAR(64) NOT NULL COMMENT '파일 그룹 ID',
directory VARCHAR(512) COMMENT '디렉토리',
original_file_name VARCHAR(255) COMMENT '원본 파일명',
save_file_name VARCHAR(255) NOT NULL COMMENT '저장 파일명',
upload_state VARCHAR(32) COMMENT '업로드 상태',
size BIGINT COMMENT '파일 크기(byte)',
content_type VARCHAR(128) COMMENT 'Content-Type',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_upload_file_file_group_id
ON upload_file(file_group_id);
CREATE INDEX idx_upload_file_ref_table
ON upload_file(ref_table);
CREATE INDEX idx_upload_file_state_created
ON upload_file(upload_state, created_at);
핵심 필드:
ref_table: 어떤 테이블에서 사용 중인 파일인지 구분file_group_id: 여러 파일을 논리적으로 묶음directory: 전체 디렉토리 경로 저장upload_state: 업로드 상태 추적 (TEMP, SUCCESS, DELETED)
ref_table 예시:
notice: 공지사항 첨부파일user_profile: 사용자 프로필 이미지board: 게시판 첨부파일product: 상품 이미지
7. 업로드 디렉터리 구조
경로 패턴
/{env}/{refTable}/{yyyy}/{MM}/{dd}/{fileGroupId}/{saveFileName}
예시:
/prod/notice/2023/06/15/a1b2c3d4-e5f6-7890/1686825600000_abc123.jpg
/prod/user_profile/2023/06/15/b2c3d4e5-f6a7-8901/1686825700000_def456.png
/prod/board/2023/06/16/c3d4e5f6-a7b8-9012/1686912000000_ghi789.pdf
장점
1. 테이블별 파일 관리
- 참조 테이블별로 파일 분리 저장
- 특정 테이블의 파일만 빠르게 조회/관리
- 파일과 엔티티의 명확한 연관 관계
2. 날짜 기반 파티셔닝
- 파일 시스템 부하 분산
- 특정 날짜 파일 빠른 접근
3. 검색/정리 용이
- 대량 파일에서도 효율적 관리
- 테이블별 정리 정책 차등 적용 가능
8. FileGroupId 설계
역할
- 여러 파일을 하나의 논리 단위로 묶음
- 클라이언트 재시도 시 멱등성 보장
- 파일 관리 및 삭제 단위
FileGroupIdUtil 구현
public final class FileGroupIdUtil {
private static final Pattern ALLOWED =
Pattern.compile("^[A-Za-z0-9\\-_.]{1,64}$");
private FileGroupIdUtil() {
throw new AssertionError("유틸리티 클래스는 인스턴스화할 수 없습니다");
}
/**
* fileGroupId 보장
* - null이거나 비어있으면 새로 생성
* - 유효하지 않은 형식이면 새로 생성
* - 유효하면 그대로 사용
*/
public static String ensure(String fileGroupId) {
if (fileGroupId == null || fileGroupId.isBlank()) {
return generate();
}
String trimmed = fileGroupId.trim();
if (!ALLOWED.matcher(trimmed).matches()) {
return generate();
}
return trimmed;
}
/**
* 새 fileGroupId 생성
*/
public static String generate() {
return UUID.randomUUID().toString();
}
}
9. 정리
핵심 요약
구조는 단순:
검증 → 임시저장 → 확정
확장은 유연:
- 검증: FileValidator 인터페이스
- 스토리지: FileStorage 인터페이스
가장 중요했던 포인트:
- 정합성: 임시저장 → 확정 방식으로 스토리지와 DB 불일치 최소화
- UX: 파일 재업로드 방지
- 트랜잭션: DB 작업만 트랜잭션으로 묶어 리소스 효율화
설계 과정에서 배운 것
- 정합성 전략을 먼저 정했다
- 임시저장 → 확정 2단계 분리
- TEMP 파일 정리 배치
- 디렉터리 구조를 함께 설계했다
- 테이블별, 날짜별 파티셔닝
- 접근 제어 단위 고려
- 재시도를 고려한 API를 만들었다
- fileGroupId 기반 파일 그룹 관리
- 타임스탬프 + UUID 파일명으로 충돌 방지
- 운영을 함께 고민했다
- TEMP 파일 정리 스케줄러
- 논리 삭제 vs 물리 삭제
마지막으로
파일 업로드는 단순 기능이 아니라, 운영 비용과 장애 포인트를 함께 설계해야 하는 영역이었다.
“파일 저장”이라는 기능 하나에도:
- 영속성 경계
- 트랜잭션 정합성
- 사용자 경험
- 확장성
이 모든 것을 함께 고려해야 했다.
댓글남기기