1. 파일 업로드, 쉽지 않았던 이유
저희가 만들던 스터디 플랫폼에서는 스터디 자료를 공유하거나, 스터디 모집 글에 이미지를 첨부하는 등 다양한 파일 업로드 기능이 필수적이었습니다. 처음에는 단순하게 구현했지만, 프로젝트가 고도화되면서 몇 가지 해결해야 할 과제들이 생겼습니다.
- 대용량 파일 처리 문제: 스터디 자료 같은 경우 파일 크기가 클 수 있는데, 동기 방식으로 S3에 업로드하면 요청 스레드가 응답을 받기까지 오랫동안 대기해야 했습니다. 이는 시스템 전체의 성능 저하로 이어질 수 있는 문제였습니다.
- 강한 의존성 문제: 파일 업로드가 끝나면 DB에 파일 정보를 저장하고 사용자에게 알림도 보내야 했습니다. 초기에는 파일 업로드 로직 내에서 NotificationService나 StudyMaterialService 같은 다른 서비스들을 직접 호출했는데, 이렇게 하니 FileUploader가 너무 많은 책임을 가지게 되고 다른 서비스들과 강하게 결합되어 테스트나 변경이 어려워졌습니다. (이 문제는 이전 글 "Spring Boot 서비스 강한 의존성 탈출 : Spring 내부 이벤트 기반 아키텍처 도입" 에서 자세히 다뤘습니다.)
- 스터디 모집 이미지의 특수성: 스터디 모집 글을 작성할 때는 사용자가 글을 쓰는 중간중간 이미지를 에디터에 넣고 바로 확인할 수 있어야 했습니다. 하지만 최종적으로 '저장' 버튼을 누르기 전까지는 S3에 파일이 영구적으로 남아서는 안 되는, 즉, 임시로 저장했다가 실제 사용된 이미지만 골라서 영구 저장 위치로 옮기는, 다소 복잡한 처리 과정이 필요했습니다.
Spring Boot 서비스 강한 의존성 탈출 : Spring 내부 이벤트 기반 아키텍처 도입
이 글을 쓰는 이유프로젝트가 진행되며 서비스가 점점 고도화 되었습니다. 이런 과정에서 여러 서비스 간의 강한 의존성이 만들어지게 되었습니다. 특히 저희 프로젝트는 Study라는 핵심 도메인
shoon95.tistory.com
이런 문제들을 해결하기 위해 저희는 몇 가지 기술적인 결정을 내렸습니다. 바로 AWS SDK v2의 S3 Transfer Manager를 사용해 비동기 업로드로 성능을 개선하고, 임시 저장 후 영구화하는 전략을 도입했으며, Spring 이벤트를 통해 파일 업로드 후의 부가 작업들을 분리하여 서비스 간 결합도를 낮추는 것이었습니다.
2. S3 Transfer Manager, 왜 선택했나?
S3 파일 업로드에는 기본 S3Client의 putObject를 사용할 수도 있지만, 저희는 AWS SDK v2의 S3 Transfer Manager를 선택했습니다.
가장 큰 이유는 비동기 처리와 자동 멀티파트 업로드 기능 때문이었습니다. upload() 메소드는 CompletableFuture를 반환하여 요청 스레드를 차단하지 않고 백그라운드에서 업로드를 진행할 수 있게 해줍니다. 덕분에 대용량 파일 업로드 중에도 서버가 다른 요청을 원활하게 처리할 수 있습니다.
3. 파일 업로드 구현: 동기, 비동기, 그리고 임시 저장
저희는 S3FileUploader라는 컴포넌트를 만들어 파일 관련 로직을 캡슐화했습니다. 여기에는 크게 세 가지 핵심 기능이 들어갔습니다.
3.1. 동기 업로드 (uploadFileSync)
스터디 모집 이미지 임시 업로드처럼, 파일을 올리고 그 URL을 즉시 반환받아 프론트엔드에서 바로 사용해야 할 때를 위해 동기 방식의 업로드 메소드를 만들었습니다. S3Client와 RequestBody.fromInputStream을 사용했습니다.
// uploadFileSync 메소드 일부 (개념 설명용)
@Override
public String uploadFileSync(String UUIDFileName, Long folderName, UploadType type, MultipartFile file) {
String filePath = generateFileObjectKey(type, folderName, UUIDFileName);
PutObjectRequest putObjectRequest = prepareRequest(file, filePath);
try {
s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(file.getInputStream(), file.getSize()));
return generateFileUrl(filePath); // 즉시 URL 반환
} catch (Exception e) {
log.error("동기 S3 업로드 실패. Key: {}", filePath, e);
throw new MosException(UploaderErrorCode.FILE_UPLOAD_EXCEPTION);
}
}
3.2. 비동기 업로드 (uploadFileAsync)
스터디 자료 업로드처럼 시간이 걸려도 괜찮고, 완료 후 다른 작업(DB 저장, 알림 등)이 필요한 경우 S3 Transfer Manager를 이용한 비동기 방식을 사용했습니다.
1. S3 객체 키 생성: generateFileObjectKey 메소드로 S3에 저장될 경로와 파일명을 생성합니다. UUID를 사용하여 파일명 중복을 방지했습니다.
// generateFileObjectKey 메소드 예시
@Override
public String generateFileObjectKey(UploadType type, Long folderName, String fileName) {
// 예시: study-materials/10/uuid_filename.pdf
return type.getFolderPath() + "/" + folderName + "/" + fileName; // UUID 포함된 이름 사용 가정
}
2. 임시 파일 생성: 비동기 처리 시 MultipartFile 객체가 유효하지 않을 수 있는 문제를 피하기 위해, 전달받은 MultipartFile의 내용을 서버 파일 시스템에 임시 파일로 먼저 저장했습니다(createTempFile). 이 임시 파일 경로를 AsyncRequestBody.fromFile()에 전달하여 안정적으로 비동기 업로드를 진행했습니다.
private static final String TEMP_FILE_PATH_PREFIX = "upload-async-";
private static final String TEMP_FILE_PATH_SUFFIX_PREFIX = "-";
@Override
public Path createTempFile(MultipartFile file) {
Path tempFilePath = null;
try {
tempFilePath = Files.createTempFile(TEMP_FILE_PATH_PREFIX, TEMP_FILE_PATH_SUFFIX_PREFIX + file.getOriginalFilename());
Files.copy(file.getInputStream(), tempFilePath, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
if (tempFilePath != null) {
deleteTemporaryFile(tempFilePath);
}
throw new MosException(UploaderErrorCode.FILE_UPLOAD_EXCEPTION);
}
return tempFilePath;
}
3. S3 업로드 요청 준비 및 실행: PutObjectRequest와 AsyncRequestBody로 UploadRequest를 만들고, s3TransferManager.upload()를 호출하여 비동기 업로드를 시작합니다.
s3TransferManager.upload(uploadRequest)
.completionFuture()
.whenComplete(((completedUpload, throwable) -> {
}));
4. 완료 처리 및 이벤트 발행 (whenComplete): 업로드 완료 후 콜백에서 성공/실패 여부를 판단하고, 그 결과를 담은 이벤트를 ApplicationEventPublisher를 통해 발행합니다. 이를 통해 파일 업로드 서비스는 후속 처리(DB 저장, 알림 등)에 대한 직접적인 의존성을 갖지 않게 됩니다.
// whenComplete 내부 로직
if (throwable != null) {
// 실패 처리 및 이벤트 발행
eventPublisher.publishEvent(Event.create(EventType.FILE_UPLOAD_FAILED, new FileUploadFailedEventPayloadWithNotification(userId, folderName, filePath, file.getOriginalFilename())));
log.error("비동기 S3 업로드 실패. Key: {}", filePath, throwable);
} else {
// 성공 처리 및 이벤트 발행
log.info("비동기 S3 업로드 성공 완료. Key: {}", filePath);
eventPublisher.publishEvent(Event.create(EventType.FILE_UPLOADED, new FileUploadedEventPayloadWithNotification(userId, folderName, file.getOriginalFilename())));
}
5. 임시 파일 삭제: 마지막으로, 성공/실패 여부와 관계없이 서버에 생성했던 임시 파일을 반드시 삭제(deleteTemporaryFile)하여 디스크 공간을 확보합니다.
s3TransferManager.upload(uploadRequest)
.completionFuture()
.whenComplete(((completedUpload, throwable) -> {
if (throwable != null) {
eventPublisher.publishEvent(Event.create(EventType.FILE_UPLOAD_FAILED, new FileUploadFailedEventPayloadWithNotification(userId, folderName, filePath, file.getOriginalFilename())));
log.error("비동기 S3 업로드 실패. Key: {}", filePath, throwable);
} else {
log.info("비동기 S3 업로드 성공 완료. Key: {}", filePath);
eventPublisher.publishEvent(Event.create(EventType.FILE_UPLOADED, new FileUploadedEventPayloadWithNotification(userId, folderName, file.getOriginalFilename())));
}
deleteTemporaryFile(finalTempFilePath);
}));
} catch (Exception e) {
if (tempFilePath != null) {
deleteTemporaryFile(tempFilePath);
}
eventPublisher.publishEvent(Event.create(EventType.FILE_UPLOAD_FAILED, new FileUploadFailedEventPayloadWithNotification(userId, folderName, filePath, file.getOriginalFilename())));
throw new MosException(UploaderErrorCode.FILE_UPLOAD_EXCEPTION);
}
3.3. 스터디 모집 이미지 처리: 임시 저장 및 영구화
스터디 모집 공고 이미지의 경우, 조금 더 복잡한 처리가 필요했습니다.
- 프론트엔드: 사용자가 에디터에 이미지를 드래그 앤 드롭하면, 즉시 백엔드의 임시 업로드 API 를 호출합니다. 이 API는 위에서 설명한 uploadFileSync를 사용하여 파일을 S3 임시 경로 (예: temp/{userId}/{uuid_filename})에 저장하고 해당 URL을 반환합니다. 프론트엔드는 이 URL로 에디터에 이미지를 표시합니다.
- 스터디 생성 요청: 사용자가 글 작성을 완료하고 '스터디 생성'을 요청하면, HTML 본문 전체를 백엔드로 전달합니다.
- 백엔드 :
- 전달받은 HTML에서 임시 S3 URL을 가진 <img> 태그들을 파싱하여 추출합니다.
- 추출된 각 임시 이미지에 대해:
- 영구 S3 경로 (예: {studyId}/{uuid_filename})를 생성합니다.
- S3FileUploader의 moveFile 메소드를 호출하여 임시 파일을 영구 경로로 복사 후 원본(임시) 파일을 삭제합니다. moveFile은 내부적으로 s3Client.copyObject()와 deleteFile()을 사용합니다.
- HTML 내용 문자열에서 임시 URL을 새로운 영구 URL로 치환합니다.
public void moveFile(String sourceKey, String destinationKey) {
CopyObjectRequest copyRequest = CopyObjectRequest.builder()
.sourceBucket(bucketName)
.sourceKey(sourceKey)
.destinationBucket(bucketName)
.destinationKey(destinationKey)
.build();
CopyObjectResponse copyObjectResponse = s3Client.copyObject(copyRequest);
deleteFile(generateFileUrl(sourceKey));
}
// html 경로 수정 포함 전체 로직
@Transactional
public void permanentUpload(Long userId, StudyCreateRequestDto requestDto, Long studyId) {
User user = entityFacade.getUser(userId);
Study study = entityFacade.getStudy(studyId);
String content = requestDto.getContent();
List<StudyRecruitmentImage> studyRecruitmentImageList = studyRecruitmentImageRepository.findAllByUser(user);
Map<Boolean, List<StudyRecruitmentImage>> dividedRecruitImageList = divideRecruitImageList(studyRecruitmentImageList, content);
List<StudyRecruitmentImage> imageToDelete = dividedRecruitImageList.getOrDefault(false, Collections.emptyList());
imageToDelete.forEach(this::delete);
List<StudyRecruitmentImage> imageToProcess = dividedRecruitImageList.getOrDefault(true, Collections.emptyList());
imageToProcess.forEach( image -> {
process(image, studyId);
image.changeToPermanent(userId, study);
studyService.changeImageToPermanent(userId, studyId);
});
}
private void process(StudyRecruitmentImage studyRecruitmentImage, Long studyId) {
String filePath = studyRecruitmentImage.getFilePath();
String sourceKey = uploader.uriToFileObjectKey(filePath);
String uuidFileName = filePathToUUIDString(filePath);
String destinationKey = uploader.generateFileObjectKey(UploadType.STUDY, studyId, uuidFileName);
uploader.moveFile(sourceKey, destinationKey);
}
- 최종적으로 수정된 HTML 내용을 Study 엔티티에 저장합니다.
4. 결론
AWS S3 Transfer Manager를 이용한 비동기 업로드와 Spring 이벤트 발행, 그리고 임시 저장/영구화 전략을 통해 파일 업로드 기능을 요구사항에 맞게 구현하고 다른 서비스와의 의존성을 효과적으로 분리할 수 있었습니다. 특히 비동기 처리와 이벤트 발행은 시스템의 응답성과 확장성을 높이는 데 크게 기여했으며, 임시 저장 로직은 불필요한 파일 저장을 막고 스토리지 비용을 절약하는 데 도움이 되었습니다.
'백엔드' 카테고리의 다른 글
| Spring 이벤트와 핸들러 패턴으로 유연한 FCM 알림 시스템 구축하기 (0) | 2025.04.14 |
|---|---|
| Spring Boot 서비스 강한 의존성 탈출 : Spring 내부 이벤트 기반 아키텍처 도입 (0) | 2025.04.14 |
| Redis SortedSet을 사용한 인기글 구현 (0) | 2025.03.16 |
| Redis + Ip 기반 조회 수 어뷰징 방지 (0) | 2025.03.16 |
| 목록 조회 시 페이징 쿼리 최적화를 통한 최소 10배 성능 개선 (0) | 2025.03.12 |