예전에 했던 `J-Toon`팀 프로젝트에서 다른 사람이 작성했던 웹툰 이미지 업로드 부분을 비동기 처리하면서 Transactional Outbox Pattern을 도입하며 리팩토링했던 부분에 대해 공유하고자 작성했습니다.
목표
- 웹툰 생성과 이미지 업로드
- 요청에 대한 응답 시간을 빠르게 하는 것이 목표
기존
@Transactional
public void createWebtoon(Long memberId, MultipartFile thumbnailImage, CreateWebtoonReq request) {
Member member = memberService.findById(memberId);
validateDuplicateTitle(request.title());
UploadImageDto uploadImageDto = request.toUploadImageDto(WEBTOON_THUMBNAIL, thumbnailImage);
String thumbnailUrl = s3Service.uploadImage(uploadImageDto);
try {
Webtoon webtoon = request.toWebtoonEntity(member, thumbnailUrl);
List<DayOfWeekWebtoon> dayOfWeekWebtoons = request.toDayOfWeekWebtoonEntity(webtoon);
List<GenreWebtoon> genreWebtoons = request.toGenreWebtoonEntity(webtoon);
webtoonRepository.save(webtoon);
dayOfWeekWebtoonRepository.saveAll(dayOfWeekWebtoons);
genreWebtoonRepository.saveAll(genreWebtoons);
} catch (RuntimeException e) {
s3Service.deleteImage(thumbnailUrl);
throw new InvalidRequestException(WEBTOON_CREATE_FAIL);
}
}
웹툰 생성에 관한 기능입니다.
웹툰의 썸네일을 먼저 업로드하고, 웹툰을 생성하고 있습니다.
현재 코드에서 느꼈던 문제점
- 이미지 업로드가 많은 리소스를 소모하기에 지연의 문제
- 웹툰 생성에 대한 책임을 가지는 메서드인데, 이미지 업로드까지 존재
아마 이미지 업로드를 먼저하고 웹툰 생성하여 db에 넣고, 예외 발생시 이미지 삭제 요청을 보낸것은 db connection을 추가로 여는 것 보다 s3에 업로드 하는게 더 낫다 판단한건가. 정확히는 모르겠지만 리팩토링할 부분이 보이긴 한다.
EventListener로 이미지 업로드 비동기로 분리
이미지 업로드 부분을 비동기로 분리하였다.
분리 이유
- 웹툰 생성에서 이미지 업로드에 대한 역할까지 가져갈 필요없다 생각하여 `의존도를 낮추기 위함`
- 최종 기능 요구사항 상 웹툰 생성과 이미지 업로드의 최종적인 일관성만 맞으면 됐다.
- 즉, 실시간성이 그렇게 중요하지는 않았고 어느정도의 delay는 가능했습니다.
변경 방식
Spring에서 제공하는 EventListener를 사용하여 분리하였습니다.
- TransactionalEventListener를 publisher로 사용하여 이미지 업로드에 대한 이벤트를 push
- 비동기 이벤트를 처리하는 메서드에서 upload를 진행하는 방식
public void createWebtoon(Long memberId, MultipartFile thumbnailImage, CreateWebtoonReq request) {
ImageUploadEvent imageUploadEvent = request.toUploadImageDto(WEBTOON_THUMBNAIL, thumbnailImage)
.toImageUploadEvent();
String thumbnailUrl = webtoonClientService.parseUrl(imageUploadEvent);
webtoonDomainService.validateDuplicateTitle(request.title());
webtoonDomainService.createWebtoon(memberId,
request.toWebtoonInfo(thumbnailUrl),
request.toWebtoonGenres(),
request.toWebtoonDayOfWeeks());
publisher.publishEvent(imageUploadEvent);
}
@AsyncEventListener
public void uploadImage(ImageUploadEvent imageUploadEvent) {
webtoonClientService.upload(imageUploadEvent);
}
와 같이 이벤트 발행을 분리할 수 있다.
Spring의 TransactionalEventListener를 사용하게 되면 이벤트 관리로 Application Context(ThreadLocal) 를 사용한다.
주의점
- MultipartFile의 경우 dispatcher servlet에서 MultipartFile을 만들 때 파일을 임시 디렉토리에 저장합니다.
- 이 임시 디렉토리는 힙메모리가 아닌 temp라는 서블릿 컨테이너 디스크에 저장되고 `요청이 끝나면 삭제`됩니다.
따라서 MultipartFile의 경우 비동기인 상황에서는 사용하지 못하게 되는 것입니다. 그렇기에 getByte로 모든 데이터를 가져와야 했습니다.
단점
- 메모리에 모든 데이터가 올라가 OOM이 발생한다는 것입니다. 따라서 대용량 이미지 처리는 사실상 힘들어집니다.
- 위와같은 이벤트 처리에서 실패했을 경우 일관성을 유지할 수 없습니다.
@Retryable나 try-catch로 실패했을 경우 다시 이벤트를 발행하여 무한 반복하는 방법이 있지만 위 방법까지만 한 이유는, 어짜피 OOM이 발생하기 때문에 더이상 진행하지 않았습니다.
Transactional Outbox Pattern 사용
위와 같은 단점이 존재했기 때문에 분산된 환경에서 사용하는 `Transactional Outbox Pattern`을 사용하게 되었습니다.
즉, RDB로 이벤트 처리를 하려고 했습니다.
`sns-sqs`, `message queue`와 같은 추가적인 외부 리소스는 전부 `비용이 발생하기 때문에 사용하지 않으려 했습니다`.
따라서 이벤트 테이블을 추가했습니다.
이벤트 테이블
- event_id : 이벤트의 순서를 보장하기 위해서 create_at같은 필드보다 pk로 이미 지정된 값을 사용하면 되었다.
- create_at: 이벤트 발행 시간
- status: ready / ok
- payload : json타입의 바이너리값, 이벤트 객체가 들어가게 된다.
- JSON타입을 사용하기 위해 추가적인 라이브러리 사용
public void createWebtoon(Long memberId, MultipartFile thumbnailImage, CreateWebtoonReq request) {
ImageEvent imageEvent = request.toUploadImageDto(WEBTOON_THUMBNAIL, thumbnailImage)
.toImageEvent();
String thumbnailUrl = webtoonClientService.parseUrl(imageEvent.toImageUpload());
webtoonDomainService.validateDuplicateTitle(request.title());
webtoonDomainService.createWebtoon(memberId,
request.toWebtoonInfo(thumbnailUrl),
request.toWebtoonGenres(),
request.toWebtoonDayOfWeeks(),
imageEvent.toImagePayload());
}
@Transactional
public void createWebtoon(Long memberId, WebtoonInfo info, WebtoonGenres genres, WebtoonDayOfWeeks dayOfWeeks, ImagePayload imagePayload) {
Member member = memberReader.read(memberId);
Webtoon webtoon = info.toWebtoonEntity(member);
List<DayOfWeekWebtoon> dayOfWeekWebtoons = dayOfWeeks.toDayOfWeekWebtoonEntity(webtoon);
List<GenreWebtoon> genreWebtoons = genres.toGenreWebtoonEntity(webtoon);
webtoonWriter.createWebtoon(webtoon, dayOfWeekWebtoons, genreWebtoons);
eventWriter.write(imagePayload.toEvent());
}
위 createWebtoon에서 이제 웹툰 생성뿐 아니라 이벤트를 같이 등록시켜주게 됩니다.
Message relay
이후 발행되지 않은 이벤트를 주기적으로 발행시켜줘야하는 message relay를 구현해야 합니다.
mysql binary log에서 변경사항을 가져와서 데이터를 프로세싱하고 사용하는 transaction log tailing는 debezium과 같은 오픈소스를 사용한다고도 듣기는 했는데 자세히 몰라서 구현히 쉬운 polling publisher를 구현했다.
@Scheduled(cron = "0/10 * * * * *")
@Transactional
public void publish() {
LocalDateTime now = LocalDateTime.now();
List<ImagePublish> publishes = eventDomainService.readRecentEvent(now).stream()
.map(imagePublish -> {
webtoonClientService.upload(ImageEvent.toImageEvent(imagePublish.getImagePayload()).toImageUpload());
imagePublish.updateStatus();
return imagePublish;
})
.toList();
eventDomainService.update(publishes);
}
위와 같이 업로드하고 상태를 변경후 다시 db에 업데이트 쿼리를 날리면 됩니다.
단점
- DB를 Message queue로 대신 사용하다 보니 부하가 생기기 때문에 성능이 좋아야 한다.
- 이미지 데이터를 db에 그대로 옮긴다는 것 자체가 좋지 않다고 생각하고, s3에도 이미지에 대해 저장하고, rdb에도 이미지에 대한 값이 저장되어 중복으로 저장되고 있다.
- db 데이터가 크기 때문에 돈이 많이 들고 디스크에서 읽어올 때도 성능상 문제가 발생할 수 있다.
- 대용량 데이터 처리를 하기에는 힘들다. 이 상황에서 적용하려면 message relay를 transaction log tailing으로 변경해야할 것 같다. 하지만 결국 db에 부하가 심해지긴 한다.
캐싱과 Transactional Outbox Pattern과 Polling publisher 방식 함께 사용
기존 Transactional Outbox Pattern적용한 상태에서는 모든 이미지 업로드에 대한 업로드를 db에 업로드 하다 보니 io가 많다. 따라서 DB의 io를 최대한 줄이기 위해 캐싱을 적용했다. 다만 local cache의 경우 ram을 사용하게 되므로 redis를 사용하였다.
Redis Cache
- redis의 List 자료구조를 message queue처럼 사용하여 1차적으로 모든 요청을 비동기적으로 가져가게 하였다.
- 이를 Scheduler를 통해 redis의 mq에서 pop을 하여 실행 후 실패한 건에 대해서만 DB에 이벤트 처리로 일관성을 유지하였다.
public void createWebtoon(Long memberId, MultipartFile thumbnailImage, CreateWebtoonReq request) {
ImageEvent imageEvent = request.toUploadImageDto(WEBTOON_THUMBNAIL, thumbnailImage)
.toImageEvent();
String thumbnailUrl = webtoonClientService.parseUrl(imageEvent.toImageUpload());
webtoonDomainService.validateDuplicateTitle(request.title());
Long webtoonId = webtoonDomainService.createWebtoon(memberId,
request.toWebtoonInfo(thumbnailUrl),
request.toWebtoonGenres(),
request.toWebtoonDayOfWeeks());
eventRedisService.publish(imageEvent.imagePublishData(webtoonId));
}
먼저 redis에 이벤트를 push한다.
@Scheduled(cron = "0/10 * * * * *")
@Transactional
public void eventExecute() {
List<Long> webtoonIds = new ArrayList<>();
List<ImagePublish> publishes = eventRedisService.consume().stream()
.parallel()
.map(imagePublishData -> updateEvent(imagePublishData, webtoonIds))
.filter(Objects::nonNull)
.map(ImageEvent::toImagePublish)
.toList();
eventDomainService.update(publishes);
webtoonDomainService.updateWebtoonStatus(webtoonIds);
}
private ImageEvent updateEvent(ImagePublishData imagePublishData, List<Long> wetoonIds) {
ImageEvent imageEvent = ImageEvent.toImageEvent(imagePublishData);
try {
webtoonClientService.upload(imageEvent.toImageUpload());
wetoonIds.add(imagePublishData.id());
return null;
} catch (Exception e) {
return imageEvent;
}
}
이후 실패한 경우에 대해서만 값을 가져와서 db에 이벤트를 발행시켜주고, db에 올라간 이벤트만 io작업을 하면 되고, 디스크에서 읽을 때도 적은 양의 데이터를 읽게 된다.
나머지 이벤트를 소모하는 부분은 위와 동일하다.
추가적으로 대용량 데이터에 대해서는 따로 batch를 돌리고, 만약 더 많은 리소스가 들어가게 되면 mq를 사용해서 consumer가 계속 받게 하는 방법도 가능하긴 할 것 같다. 다만 비용을 적게 사용하기 위해 위와 같이 하게 되었다.
그 외에 생각했던 방법
- api 2개 날릴까? => API 요청도 비용이고, 커넥션도 2번사용하고, 가장 중요한 데이터의 일관성이 맞지 않는다.
- 동영상과 같이 대용량 업로드가 함께 진행되는 경우 네트워크 대역폭 낭비가 있기 때문에 분리도 생각해볼 만하다.
- 서버로부터 이미지 식별자와 업로드주소 받고, 대용량 파일을 서버에 분산하여 업로드하는 방식으로 하면, 실패에 대해서만 다시 업로드가 가능하여 생각보다 준수한 성능이 나올 것 같다.
- 메세지 큐로 kafka사용? => 사실 kafka가 내부적으로 이벤트 저장소가 있으므로 날려주기만 하면 된다.
- 이미지 업로드에 대한 consumer를 따로 두는 방안.
- producter가 요청할 때 네트워크 실패와 consumer가 publish된 값 가져올때 실패하는 것만 처리해주면 된다.
- 사실상 비용이 많이 들지만 그만큼 효과가 좋을 것 같다.
- 단, 해당 서비스에서 이미지 업로드가 그 만큼 중요하다 생각이 되어야하는데, 웹툰 서비스다 보니 괜찮을 것 같다.
- stream으로 이미지 들어온 값 바로 넣는 방법? => stream도 하나의 방법이라고 생각하긴 했는데 계속 connection을 열어두며 사용하다보니 성능이 안좋다는 글을 봤다. 좀 더 고려해봐야 할듯
- presigned-url방식:
- front에서 back으로 api요청을 보내 s3의 preSignedURL을 대신 요청 및 반환
- 서버가 브라우저로 preSignedURL을 전달하면, 브라우저는 preSignedURL을 받고 해당 url에 이미지 upload
- 단, Presigned url에 대한 쓰기 공격 이 있다고 하여, 보안적인 해결방법보단, 아키텍처적으로 생각하여 해결해보는 것이 맞다고 생각하여 사용하지는 않았습니다.
- 또한, presignedUrl의 경우 유효기간이 존재하기 때문에 업로드에도 유효기간이 들어가기에 OOM에 대한 문제는 없지만 선택하기에는 고려가 됩니다.
결국 서비스 규모와 중요도에 따라 다르다.
아직 OOM과 관련한 문제도 보이고 마음에 안들어서 고치고 싶은 부분도 많이 보인다. 최대한 빨리 다른 방법을 생각해서 리팩토링 해봐야할 것 같다.