문제상황
최근 진행하고 있는 프로젝트에는 회원정보 수정 API가 있습니다. 해당 로직은 다음과 같은 흐름으로 동작합니다.
- 회원 사진을 제외한 나머지 회원정보를 먼저 업데이트합니다.
- Event 를 발행합니다.
- 비동기적으로 EventListener 에서 회원 사진을 AWS 에 업로드 하고, 반환된 URL 을 새롭게 회원 이미지 정보에 업데이트합니다.
하지만 비동기로 처리되는 EventListener 에서 MultipartFile 에 대해 Exception 이 발생했고, 회원 사진이 정상적으로 수정되지 않는 것을 확인했습니다. 이번 포스팅에서는 예외가 일어난 이유와 그 해결방법에 대해 알아보고자 합니다.
왜 발생했을까?
이유는 정말 간단했습니다. MultipartFile
의 생명주기를 잘 모르고있었기 때문이었습니다. MultipartFile은 요청 스레드가 종료되면 임시 파일이 자동으로 삭제되는데, 별도의 Thread 를 할당받아 사용하는 비동기 EventListener 에서는 Thread 가 달라 Multipart 의 데이터를 참조할 수 없어 예외가 났던 것
이었습니다.
EventListener 를 비동기로 처리하지 않으면 요청에서 사용되는 Thread 와 EventListener 에서 사용되는 Thread 가 동일해 MulipartFile 에서 임시 저장된 정보를 참조할 수 있어, AWS 에 사진이 정상적으로 올라가지만
EventListener 를 비동기로 처리하게 되면 요청에서 사용되는 Thread 와 EventListener 에서 사용되는 Thread 가 달라 MultipartFile 에서 임시 저장된 정보를 참조할 수 없어, 예외가 발생한 것입니다.
Multipart 정보는 어디서 사라지나
Multipart 정보는 Spring MVC 흐름의 큰 뼈대를 담당하는 DispatcherServlet
의 doDispatch
메서드 내부 finally
구문에서 사라지게 됩니다.
해당 메서드 안에서는 MultipartResolver 의 구현체인 StandardServletMultipartResolver
가 MultipartHttpServletRequest 의 구현체인 StandardMultipartHttpServletRequest
의 Multipart 정보를 삭제시킵니다.
최종적으로 요청에서 사용된 Multipart 정보가 사라지게 됩니다.
그래서 해결책은?
해결책은 매우 간단합니다. EventListener 에게 Event 를 넘겨줄 때, Event 에 MultipartFile 자체를 넘기지말고 Byte 정보를 넣어주면 해결 가능합니다.
// MemberService 일부
private void publishEventIfMultipartAvailable(MultipartFile file, Member member) {
if (file == null || file.isEmpty()) {
return;
}
try {
applicationEventPublisher.publishEvent(
new MemberUpdateEvent(member.getId(), file.getBytes(), file.getOriginalFilename())
);
} catch (IOException ignored) {
}
}
// EventListener
@Slf4j
@RequiredArgsConstructor
@Component
public class MemberUpdateEventListener {
private final MemberRepository memberRepository;
private final S3StorageManager memberS3StorageManager;
@Async
@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener
public void handleMemberUpdateEvent(MemberUpdateEvent event) {
try {
Member member = memberRepository.getById(event.memberId());
String uploadUrl = memberS3StorageManager.updateFile(
member.getProfileImage(), event.fileContent(), event.originalFilename()
);
member.updateProfileImage(uploadUrl);
log.debug("[사용자 프로필 이미지 업데이트 성공] : member_id = {}", event.memberId());
} catch (Exception e) {
log.error("[사용자 프로필 이미지 업데이트 실패] : member_id = {}", event.memberId());
}
}
}
마치며
Spring MVC 흐름의 큰 뼈대를 담당하는 DispatcherServlet
의 doDispatch
의 흐름에 대해서는 굉장히 자신있었습니다. finally 구문에서 실행되는 무언가를 “그냥 리소스 정리겠지~” 라고 넘어갔었는데, 그 내용이 Multipart 관련인줄은 정말 상상조차 못했습니다. 정말.. 더 꼼꼼히 공부해야겠습니다.