들어가며
Spring Boot로 파일 업로드 기능을 만들다 보면 비동기 이벤트 사용할 때 예상치 못한 에러를 만나게 되는 경우가 있다. 특히 @Async랑 ApplicationEventPublisher를 같이 쓸 때 MultipartFile 관련 예외가 발생하는데, 이게 사실 톰캣의 임시 파일 관리 방식 때문이었다. 이 글에서는 이 문제가 왜 생기는지, 그리고 어떻게 해결하는지 알아보려고 한다.
문제 상황
파일 업로드 API에서 이벤트를 발행하고 비동기로 처리하는 코드를 작성했는데 다음과 같은 에러가 간헐적으로 발생했다.
java.io.FileNotFoundException: /tmp/tomcat.xxx/work/Tomcat/localhost/ROOT/upload_xxx.tmp (No such file or directory)파일 처리 로직을 비동기로 분리하려고 할 때 특히 이런 문제가 자주 생긴다.
원인 분석
Tomcat의 MultipartFile 처리 방식
Spring에서 MultipartFile로 전달되는 파일은 실제로는 톰캣의 임시 디렉토리에 저장된다. 보통 이런 경로에 임시 파일이 생성된다.
/tmp/tomcat.{포트번호}/work/Tomcat/localhost/ROOT/upload_{랜덤값}.tmp임시 파일의 생명주기
임시 파일은 HTTP 요청이 완료되면 자동으로 삭제된다. 톰캣은 요청 처리가 끝나면 cleanup 작업으로 생성된 임시 파일들을 제거해버린다.
비동기 처리와의 충돌
@Async 어노테이션을 사용한 비동기 이벤트 처리에서 문제가 생기는 시나리오는 이렇다.
- 클라이언트가 파일 업로드 요청을 보냄
- 컨트롤러에서 요청을 받고 이벤트를 발행
- HTTP 응답이 클라이언트에게 즉시 반환됨 (요청 완료)
- 톰캣이 임시 파일을 삭제함
- 비동기 스레드가 이벤트를 처리하려고 시도
- 이미 삭제된 임시 파일에 접근하려다 예외 발생
@RestController
public class FileController {
@Autowired
private ApplicationEventPublisher eventPublisher;
@PostMapping("/upload")
public ResponseEntity<String> uploadFile(@RequestParam("file") MultipartFile file) {
// 이벤트 발행
eventPublisher.publishEvent(new FileUploadEvent(file));
// 즉시 응답 반환 -> 요청 완료 -> 임시 파일 삭제 예약
return ResponseEntity.ok("Upload started");
}
}
@Component
public class FileEventListener {
@Async
@EventListener
public void handleFileUpload(FileUploadEvent event) {
// 이 시점에 이미 임시 파일이 삭제되었을 수 있음
MultipartFile file = event.getFile();
file.getInputStream(); // FileNotFoundException 발생!
}
}
해결 방법
1. 이벤트 발행 전에 파일을 영구 저장소에 저장
가장 추천하는 방법이다. 비동기 처리 전에 파일을 영구 저장소에 먼저 저장하고, 파일 경로만 이벤트로 전달하는 방식이다.
@PostMapping("/upload")
public ResponseEntity<String> uploadFile(@RequestParam("file") MultipartFile file) throws IOException {
// 1. 파일을 영구 저장소에 저장
String savedFilePath = fileStorageService.save(file);
// 2. 파일 경로만 이벤트로 전달
eventPublisher.publishEvent(new FileUploadEvent(savedFilePath));
return ResponseEntity.ok("Upload started");
}
@Async
@EventListener
public void handleFileUpload(FileUploadEvent event) {
// 저장된 파일 경로를 사용
String filePath = event.getFilePath();
File file = new File(filePath);
// 파일 처리 로직
}
2. MultipartFile의 바이트 배열을 미리 추출
파일 크기가 그렇게 크지 않다면 바이트 배열로 변환해서 전달할 수도 있다.
@PostMapping("/upload")
public ResponseEntity<String> uploadFile(@RequestParam("file") MultipartFile file) throws IOException {
// 파일 내용을 바이트 배열로 추출
byte[] fileBytes = file.getBytes();
String fileName = file.getOriginalFilename();
eventPublisher.publishEvent(new FileUploadEvent(fileBytes, fileName));
return ResponseEntity.ok("Upload started");
}
@Async
@EventListener
public void handleFileUpload(FileUploadEvent event) {
byte[] fileBytes = event.getFileBytes();
String fileName = event.getFileName();
// 바이트 배열을 사용한 처리
}
3. 동기 방식으로 처리 후 결과만 비동기로 전송
파일 처리는 동기로 수행하고, 후속 작업(알림 전송 등)만 비동기로 처리하는 방법도 괜찮다.
@PostMapping("/upload")
public ResponseEntity<String> uploadFile(@RequestParam("file") MultipartFile file) throws IOException {
// 동기로 파일 처리
FileProcessResult result = fileService.processFile(file);
// 처리 결과만 비동기로 전달
eventPublisher.publishEvent(new FileProcessedEvent(result));
return ResponseEntity.ok("Upload completed");
}
4. @TransactionalEventListener 사용 고려
트랜잭션이랑 연계된 작업이라면 @TransactionalEventListener를 사용해서 커밋 전에 파일을 처리할 수 있다.
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handleFileUpload(FileUploadEvent event) {
// 트랜잭션 커밋 전에 실행되므로 요청이 아직 완료되지 않음
MultipartFile file = event.getFile();
// 파일 처리
}
다만 이 방법은 @Async랑 같이 쓸 수 없어서 동기 처리만 가능하다.
가장 좋은 방법
- 컨트롤러 레이어: 파일을 검증하고 임시 저장소 또는 영구 저장소에 저장
- 서비스 레이어: 파일 경로 또는 식별자를 DB에 저장
- 이벤트 발행: 파일 경로/식별자만 포함된 이벤트 발행
- 비동기 처리: 저장된 파일에 접근해서 추가 처리(썸네일 생성, 바이러스 검사 등)
@Service
@Transactional
public class FileUploadService {
public FileUploadResult uploadFile(MultipartFile file) throws IOException {
// 1. 파일 검증
validateFile(file);
// 2. 영구 저장소에 저장
String storedPath = storageService.store(file);
// 3. DB에 메타데이터 저장
FileMetadata metadata = fileRepository.save(
new FileMetadata(file.getOriginalFilename(), storedPath)
);
// 4. 이벤트 발행 (파일 ID만 전달)
eventPublisher.publishEvent(new FileStoredEvent(metadata.getId()));
return new FileUploadResult(metadata.getId(), storedPath);
}
}
@Component
public class FileEventListener {
@Async
@EventListener
public void handleFileStored(FileStoredEvent event) {
// 저장된 파일 정보를 조회
FileMetadata metadata = fileRepository.findById(event.getFileId())
.orElseThrow();
// 실제 파일에 접근
File file = new File(metadata.getStoredPath());
// 비동기 후처리 (썸네일, 바이러스 검사 등)
postProcessFile(file);
}
}
정리
Spring에서 MultipartFile을 비동기로 처리할 때는 톰캣의 임시 파일 관리 메커니즘을 이해하는 게 정말 중요하다. HTTP 요청이 완료되면 임시 파일이 삭제되기 때문에, 비동기 처리 전에 반드시 파일을 영구 저장소에 저장하거나 바이트 배열로 변환해야 한다.
가장 안전하고 확장 가능한 방법은 파일을 먼저 저장하고 파일 경로나 식별자만 이벤트로 전달하는 것이다. 이렇게 하면 파일 삭제 문제를 방지할 수 있을 뿐만 아니라, 재시도 로직이나 장애 복구 측면에서도 훨씬 유리하다.
'개발 > Java' 카테고리의 다른 글
| Spring @TransactionalEventListener 실행 순서와 예외 처리 (0) | 2025.10.17 |
|---|---|
| Spring Boot - ObjectMapper 생성시 초기 configuration (0) | 2024.04.23 |
| Failed to start bean 'documentationPluginsBootstrapper' (0) | 2024.01.30 |