우리 회사는 마트와 소비자를 연결해주는 중개 플랫폼을 운영하고 있다. 소비자가 앱을 통해 주문하면 마트에서 물건을 배송해주는 형태다. 마트 관리자들을 위한 관리 페이지가 있는데, 여기서 환불 처리할 때 간헐적으로 문제가 발생했던 경험을 공유하고자 한다.
문제 상황
주문은 하나의 환불 정보만 가질 수 있는데, 동일한 주문에 대해 환불 정보가 두 개씩 생성되는 문제가 발생했다. 환불 처리는 다음 세 가지 엔티티를 저장하는 과정을 포함한다:
- Refund: 환불 기본 정보
- RefundProduct: 환불 상품 정보
- RefundImage: S3에 업로드된 이미지 URL 정보
문제 해결 과정
1차 시도: 네임드 락(Named Lock) 적용
처음에는 동시성 문제로 판단하여 주문 ID에 대해 네임드 락을 걸었다. 하지만 문제는 계속되었다.
2차 시도: 락 타임아웃 증가 + 로깅 강화
락 시간을 늘리고 로깅을 강화했지만, 여전히 해결되지 않았다.
원인 파악
데이터독 도입 이후 detailed 로깅을 통해 실제 원인을 찾을 수 있었다:
- 실제로 요청이 2번 들어온 것이 맞았음
- S3 이미지 업로드 시간이 락 타임아웃보다 길어져서 락이 해제됨
- 락이 해제된 상태에서 두 번째 요청이 처리되면서 중복 저장 발생
3차 시도: @Async를 활용한 이미지 처리 분리
환불 정보 저장과 이미지 업로드를 분리했다:
- 환불 정보만 먼저 저장
- 이미지 업로드는 @Async + 트랜잭션 리스너 이벤트로 처리
- 이벤트 객체에 MultipartFile 데이터를 담아서 전달
하지만 이 방식에서도 새로운 문제가 발생했다. 이미지가 간헐적으로 저장되지 않는 현상이 나타난 것이다.
진짜 원인과 해결책
MultipartFile의 생명주기 제약
- MultipartFile은 요청 동안만 유효한 임시 파일로 저장된다
- 요청 스레드가 종료되면 임시 파일이 자동으로 삭제된다
- 다른 스레드(@Async)에서는 이미 삭제된 임시 파일에 접근할 수 없다
- 이미지 크기가 작을 때는 임시 파일 삭제 전에 업로드가 완료되어 간헐적으로 성공하는 것처럼 보였던 것이다
최종 해결 방안
- 이벤트 발행 시 MultipartFile 대신 ByteArray로 변환하여 전달
- 비동기 처리 시 ByteArray를 MockMultipartFile로 변환하여 S3 업로드
예시 코드
// 이벤트 발행 시
val byteArray = multipartFile.bytes
eventPublisher.publishEvent(ImageUploadEvent(byteArray))
// 이벤트 핸들러에서
val mockMultipartFile = MockMultipartFile(
"file",
originalFilename,
contentType,
byteArray
)
배운 점
- 프레임워크 동작 원리 이해의 중요성
- MultipartFile의 임시 파일 처리 방식
- DispatcherServlet의 요청 처리 생명주기
- cleanupMultipart 동작 방식
- 문제 해결을 위한 점진적 접근
- 로깅 강화를 통한 정확한 원인 파악
- 문제 해결 후에도 발생할 수 있는 부작용 고려
- 테스트의 중요성
- 다양한 상황(파일 크기, 처리 시간 등)에 대한 테스트 필요
- 실제 운영 환경과 유사한 조건에서의 테스트 중요성
이번 문제를 해결하며 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);
}
}
항상 느끼는 것이지만, 모든 문제는 기능을 제대로 알지 못한채로 사용할 때 발생하는 것 같다.
이번 경험을 통해 새로운 것들을 배웠고, 프레임워크의 동작 원리를 항상 사용 전 파악하도록 습관을 들여야 할 것 같다.
'개발 이야기' 카테고리의 다른 글
스케일 아웃 환경에서의 서킷브레이커 동기화 (0) | 2025.01.01 |
---|---|
Kotlin Value Class와 Mangling 문제 해결기 (0) | 2024.12.21 |
슬랙 알림 최적화 - 배치처리, 버퍼링 (0) | 2024.08.25 |
데이터 대량 등록/수정 성능 개선 with JDBC Batch Update (0) | 2024.08.17 |
효율적인 개발과 유지보수를 위한 문서화의 힘 (0) | 2024.08.11 |