💬 Project

[이길어때] 이벤트 기반으로 파일 업로드 기능 구현하기

date
Dec 21, 2023
slug
yigil-upload-file-with-event-driven
author
status
Public
tags
Java
비동기
Kernel360
summary
이길어때 프로젝트를 진행하면서 이벤트 기반으로 파일 업로드 기능을 설계한 경험을 기록합니다
type
Post
thumbnail
제목을 입력해주세요_-001 (9).png
category
💬 Project
updatedAt
Dec 21, 2023 02:30 AM

 
지난 번 로그인 파트 개발에 이어서, 멤버 도메인 기반의 CRUD 기능을 만들어야 했습니다.
notion image
사용자 정보 업데이트시, 사용자의 닉네임과 사용자 프로필 사진이 변동되어야 하는 상황이었습니다.
사용자가 업로드하는 이미지를 서버가 저장소에 잘 업로드 하도록 기존과 같이 서비스 기반의 구조로 기능을 설계하였습니다.

서비스 기반의 구조

MemberService.java
@Service @RequiredArgsConstructor public class MemberService { private final MemberRepository memberRepository; public MemberUpdateResponse updateMemberInfo(final Long memberId, MemberUpdateRequest req) { Member member = memberRepository.findById(memberId) .orElseThrow(() -> new BadRequestException(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 라는 메서드의 파일 업로드 로직을 다른 private 메서드로 분리해서 작성하였습니다.
이렇게 될 경우 문제가 발생합니다.
바로 다른 로직에서 파일 업로드 기능을 만들어야 할 경우 계속해서 같은 역할을 하는 코드가 재생성 된다는 것 이었습니다…!
이를 해결하기 위한 가장 간단한 방법은, 서비스를 분리하는 것 입니다.
 
FileService.java
@Service public class FileService { public String uploadFile(MultipartFile file) { // 파일 업로드 로직 // ... return uploadedFileUrl; } }
이 서비스는 MemberService 와 같은 서비스 클래스나 컨트롤러 클래스에서 직접 호출되어 사용됩니다.
하지만 이렇게 될 경우, 서비스 간의 결합도가 강해집니다.
예를 들어 FileService 에 문제가 생긴 경우에는 FileService 를 참조하는 모든 다른 서비스에 장애가 생기게 됩니다.
이는 멀티 모듈로 진행되는 프로젝트에서는 더욱 문제가 심해집니다.
각 모듈의 독립적인 실행을 보장받지 못하고 common 모듈 과 같은 다른 모듈에 대한 의존성이 강해지는 문제가 발생할 수 있습니다.
또한 파일 업로드와 같은 기능은 동기 처리보다 비동기 처리가 유리합니다. >> 비동기 처리에 관한 블로그 글
파일 업로드의 결과를 동기적으로 기다림으로써 실행 시간이 느려지는 문제 보다, 비동기 처리를 함으로써 결과에 대한 관심을 지우고, 콜백 함수를 통해 결과를 통한 작업을 따로 실행하면 실행 시간이 단축되는 등, 더 효율적으로 작업이 이루어질 수 있습니다.

이벤트 기반의 구조

그럼 이벤트 기반의 구조로 함께 작성을 해봅시다.
우선 파일 업로드는 AWS S3 에 업로드 되도록 하겠습니다.
 
FileUploadEvent.java
@Getter public class FileUploadEvent extends ApplicationEvent { private static final long MAX_IMAGE_SIZE = 10485760 //10MB; private 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 를 상속받는 이벤트 객체를 생성해야합니다.
저는 file과 그 file이 유효한지를 검증하는 validation 로직을 추가하였습니다.
 
FileUploadEventListener.java
@Service @RequiredArgsConstructor public class FileUploadEventListener { private final AmazonS3Client amazonS3Client; private 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()); amazonS3Client.putObject(bucketName, filePath, file.getInputStream(), metadata); } private String generateUniqueFileName(String originalFileName) { return UUID.randomUUID() + "_" + originalFileName; } private String getS3Path(String fileName) { return "images/" + fileName; } }
그리고 그 이벤트의 발생을 구독하고 있는, 이벤트 리스너 클래스를 생성합니다.
그리고 이 클래스 안에 파일 업로드 로직을 구현합니다.
 
TestController.java
@RestController @RequiredArgsController 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().body(file.getOriginalFilename()); } }
이런식으로 이벤트 객체를 생성하고 발행하면, 자동으로 이벤트 리스너가 그 이벤트를 감지 및 리스너 하위의 로직이 실행되는 형태입니다.
 

그래서 뭐가 다른데?

서비스 기반일 때

서비스 기반으로 작성했을 때에는 이벤트 기반으로 작성했을 때 보다 더 익숙한 구조 입니다.
실제로도 서비스 기반의 아키텍처는 비즈니스 로직이 서비스 내에 직접 구현되어 있기 때문에 컨트롤러로 요청이 들어왔을 때 부터 코드의 흐름을 쉽게 파악할 수 있습니다.
또한 동기적으로 실행되어야 하는 코드에서는 호출과 결과 반환 사이의 흐름이 명확하기 때문에 동기 처리에 유리합니다.
하지만 위에서 살펴보았듯, 서비스가 다른 서비스나 컴포넌트에 의존하는 경우가 생기고 결합도가 높아지게 됩니다.
이렇게 결합도가 높아지면 그에 따라 기능 추가나 변경 시에 다른 부분에 로직을 수정해야하는 경우가 발생하게 됩니다.

이벤트 기반일 때

이벤트 기반으로 작성했을 때에는 이벤트 발행과 수신 사이에 직접적인 의존성이 없기 때문에 시스템 간의 결합도를 낮출 수 있습니다.
그리고 새로운 이벤트 리스너를 추가하거나 변경하는 것 만으로도 기능을 확장하거나 수정할 수 있습니다.
또한 비동기 처리를 통해 효과적으로 작업을 수행할 수 있습니다.
하지만 서비스 기반일 때에 비해 이벤트의 흐름을 추적하기가 어렵고 디버깅에도 어려움이 생기게 됩니다.
 
하지만 이벤트 기반일 때의 결합도와 유연성에서의 장점이 있고, 확장성 뿐만 아니라 유지보수 및 테스트에도 용이하다고 판단하여 이벤트 기반에서의 파일 업로드 기능을 적용할 예정입니다.