본문 바로가기
개발 이야기

MultiPart와 @Async 사용 시 주의점

by 신재권 2024. 11. 16.

우리 회사는 마트와 소비자를 연결해주는 중개 플랫폼을 운영하고 있다. 소비자가 앱을 통해 주문하면 마트에서 물건을 배송해주는 형태다. 마트 관리자들을 위한 관리 페이지가 있는데, 여기서 환불 처리할 때 간헐적으로 문제가 발생했던 경험을 공유하고자 한다.

 

문제 상황

주문은 하나의 환불 정보만 가질 수 있는데, 동일한 주문에 대해 환불 정보가 두 개씩 생성되는 문제가 발생했다. 환불 처리는 다음 세 가지 엔티티를 저장하는 과정을 포함한다:

  • Refund: 환불 기본 정보
  • RefundProduct: 환불 상품 정보
  • RefundImage: S3에 업로드된 이미지 URL 정보

문제 해결 과정

1차 시도: 네임드 락(Named Lock) 적용

처음에는 동시성 문제로 판단하여 주문 ID에 대해 네임드 락을 걸었다. 하지만 문제는 계속되었다.

2차 시도: 락 타임아웃 증가 + 로깅 강화

락 시간을 늘리고 로깅을 강화했지만, 여전히 해결되지 않았다.

원인 파악

데이터독 도입 이후 detailed 로깅을 통해 실제 원인을 찾을 수 있었다:

  1. 실제로 요청이 2번 들어온 것이 맞았음
  2. S3 이미지 업로드 시간이 락 타임아웃보다 길어져서 락이 해제됨
  3. 락이 해제된 상태에서 두 번째 요청이 처리되면서 중복 저장 발생

3차 시도: @Async를 활용한 이미지 처리 분리

환불 정보 저장과 이미지 업로드를 분리했다:

  1. 환불 정보만 먼저 저장
  2. 이미지 업로드는 @Async + 트랜잭션 리스너 이벤트로 처리
  3. 이벤트 객체에 MultipartFile 데이터를 담아서 전달

하지만 이 방식에서도 새로운 문제가 발생했다. 이미지가 간헐적으로 저장되지 않는 현상이 나타난 것이다.

 

진짜 원인과 해결책

MultipartFile의 생명주기 제약

  • MultipartFile은 요청 동안만 유효한 임시 파일로 저장된다
  • 요청 스레드가 종료되면 임시 파일이 자동으로 삭제된다
  • 다른 스레드(@Async)에서는 이미 삭제된 임시 파일에 접근할 수 없다
  • 이미지 크기가 작을 때는 임시 파일 삭제 전에 업로드가 완료되어 간헐적으로 성공하는 것처럼 보였던 것이다

최종 해결 방안

  1. 이벤트 발행 시 MultipartFile 대신 ByteArray로 변환하여 전달
  2. 비동기 처리 시 ByteArray를 MockMultipartFile로 변환하여 S3 업로드

예시 코드

// 이벤트 발행 시
val byteArray = multipartFile.bytes
eventPublisher.publishEvent(ImageUploadEvent(byteArray))

// 이벤트 핸들러에서
val mockMultipartFile = MockMultipartFile(
    "file",
    originalFilename,
    contentType,
    byteArray
)

 

배운 점

  1. 프레임워크 동작 원리 이해의 중요성
    • MultipartFile의 임시 파일 처리 방식
    • DispatcherServlet의 요청 처리 생명주기
    • cleanupMultipart 동작 방식
  2. 문제 해결을 위한 점진적 접근
    • 로깅 강화를 통한 정확한 원인 파악
    • 문제 해결 후에도 발생할 수 있는 부작용 고려
  3. 테스트의 중요성
    • 다양한 상황(파일 크기, 처리 시간 등)에 대한 테스트 필요
    • 실제 운영 환경과 유사한 조건에서의 테스트 중요성

 

이번 문제를 해결하며 MultiPart가 임시 파일로 저장된다는 것은 알고 있는데, 생명 주기가 요청 스레드 한정이라는 제약 조건도 처음 알게되었다. 사실 MultiPart 클래스 설명에도 잘 써있더라.

DispatcherServlet에서 요청 종료 후 finally 블록에서 하위 코드를 호출한다.

이후 MultipartResolver#cleanupMultipart가 호출되며, 저장된 임시 파일이 삭제된다.

if (asyncManager.isConcurrentHandlingStarted()) {
    if (mappedHandler != null) {
        mappedHandler.applyAfterConcurrentHandlingStarted(processedRequest, response);
    }
} else if (multipartRequestParsed) {
    this.cleanupMultipart(processedRequest);
}
public void cleanupMultipart(MultipartHttpServletRequest request) {
    if (request instanceof AbstractMultipartHttpServletRequest abstractMultipartHttpServletRequest) {
        if (!abstractMultipartHttpServletRequest.isResolved()) {
            return;
        }
    }

    try {
        Iterator var6 = request.getParts().iterator();

        while(var6.hasNext()) {
            Part part = (Part)var6.next();
            if (request.getFile(part.getName()) != null) {
                part.delete();
            }
        }
    } catch (Throwable var5) {
        Throwable ex = var5;
        LogFactory.getLog(this.getClass()).warn("Failed to perform cleanup of multipart items", ex);
    }

}

 

항상 느끼는 것이지만, 모든 문제는 기능을 제대로 알지 못한채로 사용할 때 발생하는 것 같다.

이번 경험을 통해 새로운 것들을 배웠고, 프레임워크의 동작 원리를 항상 사용 전 파악하도록 습관을 들여야 할 것 같다.

 

 

https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/multipart/MultipartFile.html

 

MultipartFile (Spring Framework 6.2.0 API)

getContentType Return the content type of the file. Returns: the content type, or null if not defined (or no file has been chosen in the multipart form)

docs.spring.io