경기요 - 경기대학교 주변 맛집 모음
[ 배포 링크 ] [ 프로젝트 깃허브 ]
이용자들이 직접 추천하고 싶은 맛집 정보 등록을 요청하고 등록된 맛집에 대한 평가가 이루어질 수 있는 맛집 지도 플랫폼입니다.
개요
이전 포스팅에서 이어지는 내용입니다.
현재 리뷰 생성 API의 목표 TPS는 20 ~ 60이지만, 마지막 테스트에서 11.3이라는 결과를 얻었으며 추가적인 개선을 진행하기 위해 리뷰 생성 과정에서 이미지 생성에 대한 동작을 분리하기로 결정하였었습니다.
이를 최근에 관심을 갖고 공부 중인 이벤트 주도형 개발로부터 아이디어를 얻어 해결해 나간 과정을 다뤄보도록 하겠습니다.
문제 인식 과정
@Override
@Transactional
public void createReview(UserInfo userInfo,
Long restaurantId,
ReviewCreateRequest request,
List<MultipartFile> multipartFiles) {
User user = loadUserPort.getById(userInfo.userId());
Restaurant restaurant = loadRestaurantPort.getById(restaurantId);
Review review = Review.builder()
.rating(request.rating())
.content(request.content())
.restaurant(restaurant)
.reviewerId(user.getId())
.reviewerNickname(user.getNickname())
.build();
updateRestaurantWithoutDirtyChecking(restaurant);
Review savedReview = saveReviewPort.save(review);
if (Objects.isNull(multipartFiles) || multipartFiles.isEmpty()) return;
imageService.createImage(multipartFiles, ImageType.REVIEW, savedReview.getId());
}
private void updateRestaurantWithoutDirtyChecking(Restaurant restaurant) {
restaurant.updateAverageRating();
entityManager.flush();
}
이전 포스팅을 통해 변경된 코드는 위와 같습니다. 크게 `리뷰 생성 -> 이미지 생성` 흐름으로 구성되어 있는 것을 볼 수 있습니다. 막상 별 문제가 없어 보이지만 사실은 다음과 같은 문제점이 있습니다.
1. 외부 리소스를 수행하는 작업까지 같은 트랜잭션에 묶여있다
먼저 트랜잭션을 처리하기 위해서는 데이터베이스와 통신하기 위한 커넥션이 필요합니다. 매번 커넥션을 생성하는 것은 비효율적이기 때문에 Spring boot는 Hikari Connection Pool 로부터 미리 생성되어 있는 커넥션 객체를 받아 사용하게 됩니다.
이때, 커넥션 풀의 개수는 제한되어 있기 때문에 커넥션 풀 이상의 요청이 들어오게 된다면 커넥션을 획득하지 못한 요청들은 다른 요청에서 커넥션을 반납할 때까지 기다리게 되고, 이로 인해 병목현상이 발생하게 됩니다.
현재 코드에서는 트랜잭션이 필요하지 않은 이미지 업로드 로직이 같은 트랜잭션으로 묶여 있어 커넥션이 불필요하게 낭비되고 있습니다.
커넥션 풀의 최대 크기를 보다 늘려줌으로써 성능 개선을 이룰 수도 있겠지만, 적절한 해결방법은 아닌 것 같습니다.
2. 리뷰와 이미지가 강하게 결합되어 있다.
리뷰 내부에 이미지가 포함되어 있기 때문에 하나의 도메인이라고 볼 수도 있겠지만, 리뷰라는 도메인 이외에도 여러 곳에서 이미지를 필요하게 되는 경우 이미지 업로드 로직이 리뷰뿐만 아니라 이곳저곳에 강결합되게 됩니다. 이는 이미지 도메인의 변경 사항이 여러 곳으로 전파되는 상황을 불러올 수 있습니다.
3. 데이터 원자성을 보장할 수 없다.
위 API를 호출했을 때 메서드 구현부의 마지막에 도달할 때까지 예외가 발생하지 않으면 COMMIT 작업이 수행되게 됩니다. 하지만, COMMIT 과정에서 DB 제약조건을 만족하지 않아 `DataIntegrityViloationException` 과 같은 예외가 발생하면 COMMIT이 실패하게 됩니다.
이때 이미지 업로드는 외부 리소스이기 때문에 예외와 상관없이 정상적으로 업로드가 수행되어버리는 상황이 발생하여 데이터 원자성을 보장할 수 없게 됩니다.
4. 동기적인 방식의 성능 문제.
하나의 쓰레드에서 모든 요청을 처리하고 있기 때문에 리뷰 생성에 200ms가 걸리고 이미지 업로드에 700ms가 걸린다면 사용자가 응답을 받기까지 대략 900ms가 소요됩니다. 즉, 리뷰 생성 자체는 빠르게 처리가 가능한데 외부 리소스를 다루는 작업이 오래 걸리기 때문에 요청 처리 속도가 루즈해지는 상황입니다.
문제 해결 과정
저는 위 문제들을 모두 해결하기 위해 Spring Event를 사용하기로 하였습니다.
Spring Event는 옵저버 패턴의 구현체입니다. 디자인 패턴 중 하나인 옵저버 패턴은 대상자의 상태에 변화가 있을 때마다 관찰자(옵저버)에게 통지하고, 관찰자들이 알림을 받아 조치를 취하는 행동 패턴입니다. 다만, 실제로는 이름처럼 능동적으로 `관찰`을 하는 것이 아니라 수동적으로 대상자들로부터 정보를 전달 받기를 기다립니다.
ApplicationEventListener
@FunctionalInterface
public interface ApplicationEventPublisher {
default void publishEvent(ApplicationEvent event) {
publishEvent((Object) event);
}
void publishEvent(Object event);
}
스프링 4.2 이전에는 `ApplicationEvent`를 파라미터로 받는 deafault 메서드만 존재했었기 때문에 Event로 사용할 객체가 ApplicationEvent를 상속했어야 했지만, 이후에는 일반 Object를 파라미터로 받는 메서드가 추가되어 일반 객체로도 이벤트 생성이 가능하게 되었습니다.
이벤트를 발행하기 위해서는 이벤트를 발행할 곳에서 `ApplicationEventPublisher`를 주입받아 publishEvent(Object event) 메서드를 사용하기만 하면 됩니다.
이를 이용하면 다음과 같은 흐름을 만들어 낼 수 있습니다.
리뷰 생성이 완료되면 바로 이미지를 업로드 하는 것이 아니라 리뷰 생성 완료에 대한 이벤트만을 발행하고, 해당 이벤트를 이벤트 리스너가 감지하여 이미지 업로드 동작을 실행합니다. 이를 통해 두 번째 문제였던 강한 결합을 해소할 수 있게 됩니다. 또한 Spring Event는 `@TransactionalEventListener`를 제공하는데 pahse 속성을 지정하면 트랜잭션에 따른 이벤트 실행 시점을 변경할 수 있습니다.
1. @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
이벤트 발행 로직이 커밋된 이후에 실행 (기본값)
2. @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
이벤트 발행 로직이 커밋되기 이전에 실행
3. @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
이벤트 발행 로직이 롤백된 이후에 실행
4. @TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION)
이벤트 발행 로직이 커밋 또는 롤백된 이후에 실행
기본 값인 `AFTER_COMMIT` 옵션을 사용하면 리뷰 생성 로직의 트랜잭션이 COMMIT된 이후에 이미지 업로드 작업이 실행되기 때문에 첫 번째 문제였던 트랜잭션 분리와 세 번째 문제였던 데이터 원자성 문제를 해결할 수 있게 됩니다.
✅주의 사항
단순히 `AFTER_COMMIT` 을 사용한다고 트랜잭션이 분리되진 않습니다. 이를 이해하기 위해서는 Spring Boot가 트랜잭션을 어떻게 관리하고 있는지를 알아야 합니다.
트랜잭션은 쓰레드 단위로 관리된다
데브코스 4기 활동을 진행하면서 트랜잭션과 관련된 내용에 대해서 발표를 진행한 적이 있습니다.
당시에는 "트랜잭션이 시작됨에 따라 영속성 컨텍스트가 생성된다."라는 부분에 초점을 맞추었기 때문에 `AbastractTransactionManager`의 트랜잭션 시작에 대한 부분만을 다뤘었는데, 여기서 조금 더 나아가 트랜잭션을 가져오는 getTransaction() 메서드를 타고 들어가다 보면 트랜잭션이 어떻게 관리되고 있는지를 확인할 수 있습니다.
getTransaction() 메서드를 타고 들어가다 보면 위 이미지의 `doGetTransaction()` 메서드를 호출하는 것을 볼 수 있는데, 여기서 `TransactionSynchronizationManager`가 트랜잭션 (JpaTransactionObject) 에게 EntityManagerHodler와 ConnectionHolder를 할당해주는 부분을 확인할 수 있습니다.
해당 클래스 내부에 들어가면 다음과 같은 설명이 있습니다.
Central delegate that manages resources and transaction synchronizations per thread.
스레드당 리소스 및 트랜잭션 동기화를 관리하는 중앙 대리자.
또한, 해당 클래스의 필드를 보면 다음과 같습니다.
트랜잭션에 관한 정보들을 모두 `ThreadLocal`로 관리하고 있습니다. 위 정보들을 종합해보면 트랜잭션은 ThreadLocal로 관리되고 있기 때문에 같은 쓰레드에서 사용하는 트랜잭션은 동일하다는 것을 알 수 있습니다.
따라서 `@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)` 을 사용하더라도 이벤트를 발행한 쓰레드와 이벤트를 구독하는 쓰레드는 동일하기 때문에 같은 트랜잭션 자원을 바라보고 있으며, 이벤트 발행 쓰레드에서 이미 트랜잭션을 COMMIT 하였기 때문에 이벤트를 구독하는 쓰레드에서는 해당 트랜잭션 재사용이 불가능하게 됩니다.
이를 해결하기 위해서는 두 가지 방법을 사용할 수 있습니다.
1. `Transacatinoal(propagation = Propagation.REQUIRES_NEW)`를 통해 트랜잭션을 새로 만든다.
2. `@Async` 어노테이션을 통해 독립적인 쓰레드를 생성하여 쓰레드를 구분함으로써 처리한다.
위 두 가지 방법 중 어느 것을 택하더라도 트랜잭션 분리는 정상적으로 이루어지지만, `@Async` 어노테이션 방식은 네 번째 문제였던 동기적인 처리 방식의 성능 문제도 같이 해결할 수 있기 때문에 해당 방법을 적용하기로 결정하였습니다.
정리하면 다음과 같습니다.
- Spring Event 구조를 통해 두 번째 문제였던 도메인 강결합 문제를 해결
- @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) 를 통해 세 번째 문제였던 데이터 원자성 문제 해결
- @TrnasactionalEventListener와 함께 @Async를 사용함으로써 첫 번째 문제였던 트랜잭션 분리와 네 번째 문제였던 동기적인 처리 방식의 성능 문제 해결
이제 문제들을 해결할 수 있게 되었으니 적용 과정에서 겪은 문제들에 대해서 다뤄보도록 하겠습니다.
트러블 슈팅
@Async("EVENT_HANDLER_TASK_EXECUTOR")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void createImage(ImageCreateEvent imageCreateEvent) {
imageService.createImages(imageCreateEvent.multipartFiles(),
imageCreateEvent.imageType(),
imageCreateEvent.referenceId());
}
문제 해결 과정에서 정리한 것처럼 이벤트 리스너 + @Async를 통해 이미지 업로드 과정을 구현하였는데, 테스트를 해보니 뜬금없이 `FileNotFoundException`을 마주하게 되었습니다.
로그
`/work/Tomcat/localhost/ROOT/upload_72649361_9318_475e_ac23_968976e74536_00000000.tmp (No such file or directory)`
로그를 살펴보면 이미지와 관련된 임시 파일이 해당 경로에 존재해야 하는데 찾지 못하여 발생하고 있음을 알 수 있었습니다. 위 로그를 토대로 좀 더 찾아보니 MultipartFile에 대해 다음과 같은 정보를 찾을 수 있었습니다.
MultipartFile
- 서블릿은 클라이언트가 multipart form data로 파일을 전송할 때 특정 temp 위치에 파일을 쓰는 IO 작업이 일어난다. 이를 IO 작업 대신 스트림 형태로 메모리에 올려도 되지만 파일 자체가 여러 개일 가능성이 크기 때문에 임시 폴더에 파일을 쓰고 요청이 종료될 시 자동으로 삭제되는 스프링의 MultipartFile을 사용했다. 출처: https://chinggin.tistory.com/771
즉, 저의 경우는 @Async로 분리된 쓰레드 (이미지 업로드) 에서 MutlipartFile에 접근하려고 하지만, 기존 쓰레드 (리뷰 생성) 의 요청은 이미 종료되어 파일을 삭제했기 때문에 발생하는 문제였습니다.
`StandardServletMultipartResolver` 의 `cleanupMultipart` 메서드에 브레이크 포인트를 걸고 디버깅을 해본 결과 이미지 업로드 작업에 진입하기 전에 해당 부분에 먼저 도달하여 파일이 삭제되고 있는 것을 확인할 수 있었습니다. 즉, 이를 해결하려면 다시 이미지 업로드가 동기적으로 수행되게끔 변경해야 합니다..ㅜㅠ
MutlipartFile을 메모리로 변환하여 전달하면 해결이 가능한 것 같긴한데, 이미지 파일 용량 자체가 일반적인 API 요청에 비하면 훨씬 네트워크 부하가 크기도 하고, 서버 메모리에 계속 적대하다보면 메모리 부족으로 서버가 다운될 수도 있다고 생각하여 최종적으로는 이미지 업로드 방식을 `Presigned Url`로 변경하기로 하였습니다. 이렇게 한다면 파일을 서버에 임시로 저장하지 않고 클라이언트가 직접 S3 버킷으로 파일을 업로드 하게 되므로 서버의 부하를 줄일 수 있으며 비동기 방식도 유지할 수 있습니다.
최종 결과
최종적으로 작성된 코드는 다음과 같습니다.
ReviewCommandService
@Override
@Transactional
public void createReview(UserInfo userInfo,
Long restaurantId,
ReviewCreateRequest request) {
User user = loadUserPort.getById(userInfo.userId());
Restaurant restaurant = loadRestaurantPort.getById(restaurantId);
Review review = Review.builder()
.rating(request.rating())
.content(request.content())
.restaurant(restaurant)
.reviewerId(user.getId())
.reviewerNickname(user.getNickname())
.build();
updateRestaurantWithoutDirtyChecking(restaurant);
Review savedReview = saveReviewPort.save(review);
if (Objects.isNull(request.imageUrls()) || request.imageUrls().isEmpty())
return;
ImageCreateEvent imageCreateEvent = ImageCreateEvent.of(request.imageUrls(), ImageType.REVIEW, savedReview.getId());
eventPublisher.publishEvent(imageCreateEvent);
}
ImageCreateEventListener
@Async("EVENT_HANDLER_TASK_EXECUTOR")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void createImage(ImageCreateEvent imageCreateEvent) {
// PresignedUrl로 변경되었기 때문에 MultiparFile이 아닌 이미지 URL을 받음
imageService.createImages(imageCreateEvent.imageUrls(),
imageCreateEvent.imageType(),
imageCreateEvent.referenceId());
}
위에서 제시했던 모든 문제를 해결했으니 이제 성능을 비교해볼 시간입니다.
테스트 결과 TPS가 88.1로 기존(11.3)보다 `86%` 개선되었으며 목표 TPS를 달성하고도 남은 것을 확인할 수 있었습니다.
마무리
Spring Event를 사용함으로써 성능 개선 뿐만 아니라 도메인간의 강한 의존성을 해소함으로써 더 좋은 프로젝트 구조를 만들 수 있었습니다. 이전 포스팅에서 Deadlock 문제도 해결했고 목표 TPS도 달성하였으니 이제 정말 완벽한 API를 갖게 되었습니다ㅎㅎ 라고 하고 싶지만, 아직 고려해야할 내용이 남아있습니다...
바로 이벤트 리스너를 통해 실행되는 로직이 실패하는 경우에 대한 것입니다. 이미 리뷰는 생성되어 이벤트 발행까지 마쳤는데, 이미지 업로드가 실패하는 상황이 올 수 있습니다. 트랜잭션은 물론 쓰레드도 물리적으로 분리되어있기 때문에 롤백은 불가능합니다. 어떻게 하면 좋을까요??
다음 포스팅에서는 이런 상황을 어떻게 대처하면 좋을지에 대해서 다뤄보도록 하겠습니다!
Reference
https://dkswnkk.tistory.com/704
https://ksh-coding.tistory.com/111
'Spring' 카테고리의 다른 글
이벤트를 좀 더 제대로 다뤄보자 (feat. MSA & EDA) (2) | 2024.04.22 |
---|---|
조회 성능 최적화(3) - QueryDsl : SQL (0) | 2024.03.16 |
조회 성능 최적화 (1) - 쿼리 발생 줄이기 (1) | 2024.02.13 |
테스트 객체 생성 라이브러리 Instancio, Fixture Monkey (feat. Test Fixture) (0) | 2024.02.05 |