
로그인 기능을 개발한 뒤 멤버 도메인의 CRUD를 구현하면서, 마이페이지에 프로필 이미지를 업로드하는 기능이 필요해졌습니다. 초기에는 서비스 클래스에 파일 업로드 로직을 직접 포함했지만, 곧 여러 문제가 드러났습니다. 서비스 간 결합도가 높아지고, 멀티 모듈 환경에서 공통 모듈 의존성이 커졌으며, 파일 업로드를 동기적으로 처리해야 하는 부담도 생겼습니다.
오늘은 해당 파일 업로드 기능을 서비스 기반 구조에서 이벤트 기반 비동기 구조로 리팩터링하는 과정을 작성해보았어요. 이를 통해 서비스 중심 파일 업로드 방식의 한계를 이해하고, Spring의 ApplicationEvent와 @EventListener를 활용해 결합도를 낮춘 비동기 파일 업로드 구조를 설계하고 구현하는 방법을 설명해볼게요.
문제 상황: 마이페이지에서 프로필 이미지 변경하기

마이페이지에서는 사용자가 닉네임과 프로필 이미지를 수정할 수 있어야 합니다. 이를 위해 사용자가 이미지를 업로드하면 서버가 이를 저장소(S3 등)에 저장하고, 저장된 URL을 사용자 정보에 반영하는 기능이 필요했습니다.
기존 구현: 서비스 기반으로 파일 업로드 처리
초기 구현은 자연스럽게 서비스 내부에서 직접 파일을 업로드하는 구조였습니다.
@Service
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
public MemberUpdateResponse updateMemberInfo(final Long memberId, MemberUpdateRequest req) {
Member member = memberRepository.findById(memberId)
.orElseThrow(() -> new BadRequestExcpetion(NOT_FOUND_MEMBER_ID));
String fileUrl = uploadProfileImageAndGetFileUrl(req.getProfileImage());
Member updateMember = new Member(
memberId,
req.getNickname(),
fileUrl
);
memberRepository.save(updateMember);
}
private String uploadProfileImageAndGetFileUrl(MultipartFile file) {
file.transferTo(new File(IMAGE_FILE_PATH));
return IMAGE_FILE_PATH + "/" + file.getOriginalFileName();
}
}
updateMemberInfo() 메서드는 프로필 이미지를 업로드하고, 반환된 파일 URL을 멤버 정보에 반영하는 방식으로 동작합니다. 파일 업로드 자체는 uploadProfileImageAndGetFileUrl()로 분리했지만, 업로드 로직이 여전히 서비스 내부에 포함된 구조입니다.
서비스 기반 구조의 한계
서비스 내부에서 파일 업로드를 처리하는 방식은 코드 흐름이 직관적이고, 비즈니스 로직을 한곳에서 파악하기 쉽다는 장점이 있습니다. 하지만 실제로는 다음과 같은 문제가 발생합니다.
중복 코드 증가
여러 도메인(예: 게시글 이미지, 첨부 파일 등)에서 파일 업로드가 필요해질 때마다 비슷한 로직이 각 서비스에 반복됩니다.
서비스 간 강한 결합
중복을 줄이기 위해 FileService 같은 공용 서비스를 분리하면, 모든 서비스가 이 공용 서비스에 의존하게 됩니다.
@Service
public class FileService {
public String uploadFile(MultipartFile file) {
// 파일 업로드 로직
// ...
return uploadedFileUrl;
}
}
이 구조에서는 MemberService, PostService, CommentService 등이 모두 FileService에 종속됩니다. 따라서 FileService에 장애가 발생하면, 이를 참조하는 서비스 전반에 영향이 전달됩니다.
멀티 모듈 환경에서의 의존성 문제
파일 업로드 로직을 common 모듈에 두고 여러 모듈에서 공유하면, 모듈 간 독립성이 떨어지고 공통 모듈 변경의 영향 범위가 커집니다.
동기 처리로 인한 성능 부담
파일 업로드는 I/O 중심 작업이기 때문에, 요청 흐름 내부에서 동기적으로 처리하면 응답 시간이 불필요하게 길어집니다. 비동기 처리로 분리하는 편이 더 효율적입니다.
이런 이유로, 결합도를 낮추고 비동기적으로 확장 가능한 구조가 필요했어요.
이벤트 기반으로 전환하기

파일 업로드를 하나의 이벤트로 바라보면 전체 구조가 단순해집니다.
서비스나 컨트롤러는 파일 업로드 이벤트를 발행하고,
실제 업로드 로직은 이를 처리하는 이벤트 리스너가 담당합니다.
리스너는 @Async로 비동기 실행할 수 있어 요청 흐름을 차단하지 않습니다.
이 구조에서 얻을 수 있는 장점은 명확해요.
- 서비스는 업로드 구현을 몰라도 됩니다.
- 업로드 방식이 바뀌어도(예: S3 → 다른 스토리지) 리스너만 교체하면 전체 구조는 그대로 유지됩니다.
즉, 파일 업로드를 이벤트로 분리하면 결합도가 낮아지고 확장성이 높아집니다.
구현 단계
1. 파일 업로드 이벤트 정의
@Getter
public class FileUploadEvent extends ApplicationEvent {
private static final long MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB
private final MultipartFile file;
public FileUploadEvent(Object source, MultipartFile file) {
super(source);
this.file = file;
validateFileSize(file.getSize());
}
private void validateFileSize(long size) {
if (size > MAX_IMAGE_SIZE) {
throw new FileException(EXCEED_FILE_CAPACITY);
}
}
}
ApplicationEvent를 상속한 파일 업로드 이벤트입니다.
생성자에서 파일 용량 검증까지 처리해, 이벤트 발행 측에서 반복 검증할 필요가 없도록 했습니다.
2. 파일 업로드 이벤트 리스너 구현 (AWS S3 업로드)
@Service
@RequiredArgsConstructor
public class FileUploadEventListener {
private final AmazonS3Client amazonS3Client;
private final String bucketName = "amazon-bucket";
@Async
@EventListener
public void handleFileUpload(FileUploadEvent event) throws IOException {
MultipartFile file = event.getFile();
String fileName = generateUniqueFileName(file.getOriginalFilename());
String filePath = getS3Path(fileName);
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentLength(file.getSize());
metadata.setContentType(file.getContentType());
metadata.setContentDisposition("inline");
amazonS3Client.putObject(bucketName, filePath, file.getInputStream(), metadata);
}
private String generateUniqueFileName(String originalFileName) {
return UUID.randomUUID() + "_" + originalFileName;
}
private String getS3Path(String fileName) {
return "images/" + fileName;
}
}
@EventListener로 FileUploadEvent를 구독하고, @Async로 비동기 업로드를 수행합니다.
파일 이름 생성, S3 경로 구성, 메타데이터 설정 등 업로드와 관련된 모든 로직을 리스너 내부에 모아두었기 때문에, 다른 서비스는 S3 설정이나 업로드 구현을 전혀 알 필요가 없습니다.
3. 이벤트 발행하기
@RestController
@RequiredArgsConstructor
public class TestController {
private final ApplicationEventPublisher applicationEventPublisher;
@PostMapping("/test")
public ResponseEntity<String> testUploadFile(@RequestParam("file") MultipartFile file) {
FileUploadEvent event = new FileUploadEvent(this, file);
applicationEventPublisher.publishEvent(event);
// 업로드 완료를 기다릴 필요 없이 즉시 응답
return ResponseEntity.ok(file.getOriginalFilename());
}
}
컨트롤러는 단순히 이벤트를 생성하고 발행합니다.
실제 업로드는 백그라운드에서 비동기적으로 실행됩니다.
실제 서비스 환경에서는, 업로드 후 생성된 URL을 이벤트 리스너에서 반환하지 않고
콜백 / 메시지 전송 / DB 저장 등 후처리 방식으로 연결하는 구조도 고려할 수 있습니다.
서비스 기반 vs 이벤트 기반 비교하기
| 관점 | 서비스 기반 | 이벤트 기반 |
| 구조 이해 난이도 | 호출 흐름이 직관적이고 한눈에 파악 가능 | 이벤트 흐름을 따라가야 해 상대적으로 복잡 |
| 결합도 | 서비스 간 의존이 커지기 쉬움 | 발행자-리스너 간 직접 의존이 없어 결합도 낮음 |
| 재사용·확장성 | 공용 서비스가 점점 비대해질 위험 | 리스너 추가·교체만으로 손쉽게 확장 가능 |
| 비동기 처리 | 기본적으로 동기 처리 | @Async로 자연스럽게 비동기 처리 가능 |
| 멀티 모듈 구조 | 공통 모듈 의존도가 커짐 | 이벤트만 공유하면 되고 구현은 모듈별로 분리 가능 |
| 디버깅 | 스택 트레이스만 따라가면 흐름 파악 쉬움 | 이벤트 체인을 따라가야 해 디버깅 난도가 상승 |
서비스 기반 구조는 단순한 흐름과 동기적 처리가 필요한 경우 여전히 적합합니다.
하지만 파일 업로드처럼 여러 도메인이 공통으로 사용하며, 비동기 처리와 확장성이 중요한 기능이라면 이벤트 기반 구조의 장점이 훨씬 크게 체감됩니다.
언제 이벤트 기반 업로드를 선택할까?
이벤트 기반 파일 업로드는 다음과 같은 상황에서 특히 효과적입니다.
- 파일 업로드가 여러 도메인(멤버, 게시글, 첨부파일 등)에서 공통 기능일 때
- 멀티 모듈 환경에서 공통 모듈 의존성을 최소화하고 싶을 때
- 업로드를 비동기로 처리해 응답 시간을 줄이고 싶을 때
- S3 외 다른 스토리지로의 변경 가능성을 고려해야 할 때
반대로 다음 조건이라면 굳이 이벤트 기반 구조를 도입할 필요는 없습니다.
- 프로젝트 규모가 작고
- 업로드가 일부 기능에서만 사용되며
- 동기 처리로도 충분할 때
저는 결합도, 유연성, 멀티 모듈 환경에서의 확장성을 고려해
파일 업로드 기능을 이벤트 기반 구조로 전환하는 방식을 선택했습니다.
이벤트 기반 업로드 구조는 처음에는 다소 복잡해 보일 수 있지만, 한 번 구축해 두면 장기적으로 훨씬 유연한 아키텍처를 제공합니다. 특히 여러 도메인이 파일 업로드를 공유하거나, 비동기 처리와 확장성을 중요하게 생각한다면 충분히 투자할 가치가 있습니다.
이번 글에서 소개한 방식이 비슷한 문제를 겪고 있는 분들께 실질적인 선택지를 제공할 수 있기를 바랍니다 :)
'Backend > Spring 🌱' 카테고리의 다른 글
| 스프링 컨테이너 이해하기: BeanFactory와 ApplicationContext (0) | 2024.03.12 |
|---|---|
| Spring MVC에서 HandlerMapping과 HandlerAdapter를 나눈 이유 (0) | 2024.02.07 |
| 전략 패턴으로 확장성 있게 소셜 로그인 설계하기 (0) | 2024.02.07 |
| Spring Boot 프로젝트에 PostGIS 적용하기: 위치 정보 관리를 위한 DB (0) | 2024.02.07 |
| 스프링부트 프로젝트 시작하는 법 (0) | 2024.02.02 |
안녕하세요, 저는 주니어 개발자 박석희 입니다. 언제든 하단 연락처로 연락주세요 😆