경기요 - 경기대학교 주변 맛집 모음
[ 배포 링크 ] [ 프로젝트 깃허브 ]
이용자들이 직접 추천하고 싶은 맛집 정보 등록을 요청하고 등록된 맛집에 대한 평가가 이루어질 수 있는 맛집 지도 플랫폼입니다.
개요
이전 포스팅에서 리뷰 생성 API가 목표 TPS를 달성하지 못하는 것에 대해 아래와 같은 문제들이 있음을 확인하고 그 해결 과정에 대해서 다루었었습니다.
문제
- 외부 리소스를 수행하는 작업(이미지 s3 업로드)이 같은 트랜잭션에 묶여있다 -> 비효율적인 커넥션 사용
- 리뷰와 이미지가 강하게 결합되어 있다 -> 변경 사항이 전파되어 유지보수에 좋지 않음
- 데이터 원자성을 보장할 수 없다 -> 리뷰 생성 Commit 실패하여 롤백되더라도 이미지 생성 동작은 수행됨
- 하나의 쓰레드에서 동기적으로 수행되어 요청 처리 속도가 루즈함
해결
- Spring Event를 사용한 이벤트 발행과 구독 구조를 통해 두 번째 문제였던 도메인 강결합 문제를 해결
- @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) 를 통해 세 번째 문제였던 데이터 원자성 문제 해결 -> 항상 리뷰 생성 로직이 정상적으로 Commit된 것을 확인하고 동작하도록
- @Async를 사용하여 쓰레드를 분리함으로써 첫 번째 문제였던 트랜잭션 분리와 네 번째 문제였던 동기적인 처리 방식의 성능 문제 해결
모든 문제가 해결된 듯 보였지만, 구독한 이벤트를 처리하는 과정에서 문제가 발생하는 경우에 대한 대비가 되어있지 않다는 문제가 있었습니다 (리뷰 생성에는 성공했지만, 이미지 생성에 실패하는 경우). 현재 리뷰 생성 API는 리뷰 생성 로직과 이미지 생성 로직의 트랜잭션은 물론 쓰레드도 물리적으로 분리되어있기 때문에 이미지 생성 트랜잭션에서 리뷰 생성 트랜잭션을 롤백하는 것이 불가능합니다.
저는 해당 문제가 이벤트를 다루는 분산 환경 구조에서도 동일하게 발생할 것이라 생각하였고, MSA와 EDA에 대해서 학습하면 문제 해결을 위한 힌트 또는 해답을 얻을 수 있으리라 생각하였습니다.
따라서 이번 포스팅에서는 위 상황을 어떻게 해결하면 좋을지에 대해 MSA와 EDA에 대해서도 같이 알아보며 다뤄보도록 하겠습니다.
MSA(Microservice Acritecture)
2000년대에 들어와 서비스들이 대규모로 성장해가며, 유지 관리 및 확장이 어려워져 개발 주기가 길어지고 다운타임이 증가하면서 모놀리식 구조의 한계를 깨닫기 시작하였습니다. 그리하여 기존 애플리케이션을 독립적으로 개발, 배포 및 유지 관리할 수 있도록 더 작고 독립적인 서비스로 분해하는 마이크로서비스 아키텍처와 같은 분산 환경 구조가 점차 주목 받게 되었습니다.
마이크로서비스는 모놀리식과 달리 각각의 서비스가 개별적으로 독립적인 단위의 애플리케이션으로 구성되어있습니다. 개별 프로세스에서 실행되기 때문에 기술 유연성이 높아 서비스별 독립적인 스택을 선정하여 사용이 가능하고 데이터베이스도 독립적으로 사용할 수 있습니다. 이렇게 서비스들이 독립적으로 데이터베이스를 사용하는 분산된 데이터베이스 환경에서는 데이터 간의 일관성을 유지하기 위해 분산 트랜잭션 관리가 정말 중요합니다.
물리적으로 떨어진 혹은 서로 다른 종류의 데이터베이스들간의 트랜잭션을 하나의 논리적인 단위로 다뤄야 하기 때문입니다. 어떻게 하나로 관리할 수 있을까요?
분산 트랜잭션 관리
Two Phase Commit
분산된 데이터베이스에서의 전통적인 트랜잭션 관리 방법으로는 Two Phase Commit (2PC) 이 있습니다. 2PC는 분산된 트랜잭션의 원자성을 보장하기 위하여 코디네이터가 연결된 데이터베이스에 트랜잭션이 문제 없이 Commit 가능한 상태인지 확인하고 모두 참인 경우에 Commit을 수행시키는 방식입니다.
하지만, 2PC 방식은 코디네이터에 장애가 발생하는 경우 정상적으로 요청을 수행할 수 없으며 NoSQL을 포함한 대부분의 최근 기술들이 2PC를 지원하지 않는다는 문제점이 있습니다. 이는 MSA와 같이 다양한 종류의 데이터베이스를 사용하는 구조에서는 사용에 제약이 있음을 의미합니다. 또한, 위 그림을 보면 알 수 있듯이 데이터베이스별로 최소 세 번의 통신이 발생하고, 락이 걸리는 구간이 길어지게 되어 성능이 떨어지게 됩니다.
위와 같은 이유로 2PC 없이도 분산 데이터베이스의 트랜잭션을 다룰 수 있는 Event Driven Architecture (이벤트 주도 아키텍처) 가 고안되었습니다. 이벤트 주도 아키텍처는 각각의 시스템들이 이벤트의 발행 및 구독을 통해 트랜잭션이나 연산 처리를 할 수 있도록 만들어진 구조입니다.
EDA(Event Driven Architecture)
우선, 이벤트 기반 아키텍처에 대해서 간단하게 알아보도록 하겠습니다.
MSA와 같은 분산 아키텍처에서는 서비스들간의 협업을 위해 내부 프로세스 통신 - IPC (Inter Process Communication)가 중요한 요소가 됩니다. REST API, 공유 메모리, 이벤트 기반 통신 등 다양한 방법으로 구현될 수 있는데, 여기서 이벤트 기반 통신의 경우 비동기 방식이기 때문에 동기 방식보다 느슨한 결합을 맺을 수 있다는 장점이 있습니다.
이벤트 기반 아키텍처는 이러한 장점을 실린 아키텍처로 MSA에서 EDA로 나아가는 이유는 느슨한 결합을 가져가기 위함입니다. MSA에 이벤트를 도입하면 서비스간에 결합을 느슨하게 유지할 수 있기 때문에 신규 서비스 확장에 더욱 열려있는 구조를 취할 수 있게 되는 것이죠.
주의 사항
한 가지 주의해야할 점은 이벤트에 담긴 의도에 따라 느슨한 결합을 가져가지 못할 수도 있다는 것입니다. 저희가 발행해야할 이벤트는 이벤트로 달성하려는 목적이 아닌 도메인 이벤트 그 자체입니다. 이 부분을 간과하고 그저 이벤트 사용했으니 느슨한 결합을 갖게 되었구나 라고 생각하면 안된다는 것이죠. (제가 그랬어요..흑흑)
위 그림은 개인 회원의 본인 인증 초기화 작업에서 `가족 계정 탈퇴 이벤트`를 발행하고 이를 구독하는 가족 계정 서비스에서 탈퇴 작업을 진행하는 흐름입니다. 이벤트를 기반으로 통신하기 때문에 물리적인 의존성은 제거되었다고 볼 수 있지만, 개인 회원에서 `가족 계정 탈퇴` 기대하는 이벤트를 발행했기 때문에 여전히 가족 계정의 비지니스를 알고 있기 때문에 논리적으로 강하게 결합을 맺고 있다고 볼 수 있습니다.
이렇게 어떤 일을 해야 하는 지를 발행자가 알려주는 경우, 해야하는 일이 변경될 때 발행자와 수신자 양쪽 모두의 코드가 변경되어야 하기 때문에 높은 결합도가 존재하게 됩니다. 이벤트로 달성하려는 목적을 발행했기 때문인 것이죠.
반면에 위 그림은 `인증 초기화` 라는 도메인 이벤트 그 자체를 발행함으로써 더 이상 가족 계정 시스템의 정책을 알지 못합니다. 즉, 가족 회원 서비스에 변경이 발생하더라도 개인 회원 서비스는 더 이상 영향을 받지 않는 느슨한 결합 구조를 취하게 되는 것입니다.
이렇듯 이벤트에 담는 의도에 따라 그 결과가 매우 달라지기 때문에 이벤트를 다룰 때는 항상 주의를 기울여야 하겠습니다.
EDA에서의 분산 트랜잭션 관리
위 그림과 같이 사용자 A가 물품 i에 대하여 주문을 요청하게 되면 주문 서비스는 신규 주문에 대한 레코드를 기록하고 이에 해당하는 이벤트를 발행합니다. 이 이벤트는 메세지 브로커에 전달되어 해당 이벤트를 구독하고 있던 결제 서비스에게 전달되어 결제 및 출금 트랜잭션을 수행하게 됩니다. 결제 및 출금 작업이 성공하게 되면 결제 서비스는 다시 결제 성공에 대한 이벤트를 발행하고, 이를 주문 서비스가 수신하여 주문 생성을 완료하게 됩니다.
다만, 위 그림대로만 구현하는 경우 이벤트 발행에 대한 보장을 할 수 없다는 문제점이 있습니다. 메세지 브로커의 장애로 이벤트 발행이 보장되지 않는다면 이벤트 발행이라는 비관심사에 의해 주요 관심사인 비지니스 로직이 실패하게 되는 상황이 발생하게 됩니다.
또 다른 문제점으로는 이벤트 재발행이 불가능하다는 문제점이 있습니다. 메세지 브로커의 구독자들이 이벤트를 정상적으로 수신하였더라도, 이벤트 처리를 잘못할 수 있기 때문에 언제든 이벤트를 재발행 할 수 있어야 합니다. 하지만, 해당 구조에서 이벤트는 지속성을 지니지 못합니다. 특정 메세지 브로커는 DLQ (Dead Letter Queue) 와 같은 기능을 이용하여 실패한 이벤트의 재시도를 수행하도록 할 수 있지만, 메세지 브로커가 다운된다면 이벤트 자체가 유실되는 것이기 때문에 이벤트 재발행이 불가능하다는 문제점은 그대로 남아있게 됩니다.
이러한 문제점을 EDA에서는 Transactional Outbox Pattern을 통해 해결하고 있습니다.
Transactional Outbox Pattern
해당 패턴은 로컬 트랜잭션을 통한 이벤트 관리라고 요약할 수 있습니다.
해당 방식은 로컬 트랜잭션이 ACID 보장이 가능하다는 점을 이용해서 특정 테이블의 레코드가 갱신될 때 이벤트 저장소에 해당 이벤트 정보를 기록하는 방식입니다. 쉽게 말하면 이벤트 엔티티를 구성하고 비지니스 트랜잭션에 포함시켜 저장한다는 것입니다. 기록된 이벤트 정보는 이벤프 퍼블리셔에 의해서 메세지 브로커로 발행되며 이벤트가 성공적으로 발행되면 저장된 이벤트의 상태를 갱신합니다. 이로써 이벤트 발행에 대한 보장이 가능하게 되고 저장된 이벤트를 언제든 조회할 수 있기 때문에 이벤트 재발행에도 자유롭게 되는 것입니다.
저는 이 Transcational Outbox Pattern에서 아이디어를 얻어 이벤트 수신, 처리 과정에서 발생하는 문제들을 해결할 수 있겠다고 생각하였는데, 리뷰 생성 트랜잭션에서 이벤트 정보를 같이 기록함으로써 Spring Event를 통한 이벤트 수신, 처리 과정이 실패하더라도 저장된 이벤트를 다시 가져와 처리할 수 있기 때문입니다.
Transactional Outbox Pattern 흉내내기
우선 기존 코드를 확인해보도록 하겠습니다
// 서비스 로직
@Override
@Transactional
public void createReview(UserInfo userInfo,
Long restaurantId,
ReviewCreateRequest request) {
// 로직 생략
Review savedReview = saveReviewPort.save(review);
ImageCreateEvent imageCreateEvent = ImageCreateEvent.of(request.imageUrls(), ImageType.REVIEW, savedReview.getId());
eventPublisher.publishEvent(imageCreateEvent);
}
// 이벤트 리스너 로직
@Async("EVENT_HANDLER_TASK_EXECUTOR")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void createImage(ImageCreateEvent imageCreateEvent) {
imageService.createImages(imageCreateEvent.imageUrls(),
imageCreateEvent.imageType(),
imageCreateEvent.referenceId());
}
앞서 이벤트 기반 아키텍처 주의 사항에서 이벤트로 달성하려는 목적이 아닌 도메인 이벤트 그 자체를 발행해야 한다고 했습니다. 따라서 기존 코드의 이벤트 `ImageCreateEvent`는 잘못된 부분이니 수정이 `ReviewCreateEvent`의 같은 형태로 수정하고 서비스 로직의 트랜잭션에 이벤트가 저장되도록 해줍시다.
변경 후
// 서비스 로직
@Override
@Transactional
public void createReview(UserInfo userInfo,
Long restaurantId,
ReviewCreateCommand command) {
// 로직 생략
Review savedReview = saveReviewPort.save(review);
Long eventId = TsidCreator.getTsid().toLong(); // (1)
ReviewCreateEvent reviewCreateEvent = ReviewCreateEvent.of(eventId, command.imageUrls(), ImageType.REVIEW, savedReview.getId());
eventPublisher.publishEvent(reviewCreateEvent);
}
// 이벤트 리스너 로직
@EventListener
public void createEvent(ReviewCreateEvent reviewCreateEvent) {
String imageUrls = reviewEventService.convertImageUrlsToString(reviewCreateEvent.imageUrls());
reviewEventService.createEvent(reviewCreateEvent.toImageEvent(imageUrls));
}
@Async("EVENT_HANDLER_TASK_EXECUTOR")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleCreateEvent(ReviewCreateEvent reviewCreateEvent) {
imageService.createImages(reviewCreateEvent.imageUrls(),
reviewCreateEvent.imageType(),
reviewCreateEvent.referenceId());
reviewEventService.successEvent(reviewCreateEvent.id()); // (2)
}
변경 후에는 이벤트 리스너에서 `@EventListener`를 사용하여 동일 쓰레드에서 동일 트랜잭션으로 이벤트를 저장할 수 있도록 하였습니다. 이벤트 저장소에 대한 저장을 별도의 이벤트 구독자를 통해 진행한 이유는 도메인 서비스에서 이벤트 발행에 대한 의존만을 지니게 하고 싶었기 때문입니다.
그리고 `(1)`과 `(2)` 부분이 새롭게 추가되었는데, 이벤트 처리가 성공적으로 끝났다는 것을 이벤트 식별자를 통해 저장소에 갱신하기 위한 작업입니다.
원래의 Transactional Outbox Pattern 의도대로 구현한다면 `(2)` 동작은 이벤트 처리 성공 여부에 대한 갱신이 아니라 발행 여부에 대한 갱신이 이루어져야 하는 것이지만, 현재 프로젝트는 분산 환경이 아니기 때문에 이벤트 발행 보다는 처리 성공 여부에 초점을 맞추기로 하였습니다.
TSID?
`(1)` 에서 사용된 TSID는 Time Sorted Unique Identifiers 라이브러리로 시간순으로 정렬된 고유 식별자입니다. TSID는 시간 구성 요소 42bit와 랜덤 구성 요소 22bit로 이루어져 총 64bit (8byte)로 UUID에 비해 상대적으로 적은 용량을 사용하며 Java에서 long타입으로 사용이 가능합니다.
시간 구성 요소를 사용하기에 정렬이 가능하며, 랜덤 구성 요소를 통해 고유성을 보장합니다. 대규모 분산 시스템에서 고유성과 정렬 가능성을 보장하기 위해 설계되었기 때문에 이벤트 식별자로 사용하기에 적합하다고 생각하여 적용하였습니다.
[참고] TSID Github : https://github.com/f4b6a3/tsid-creator
이제 가장 중요하다고 할 수 있는 이벤트 엔티티 구조에 대해서 확인해보도록 하겠습니다.
이벤트 엔티티 구조
ReviewEvent
@Getter
@Entity
@Table(name = "review_events")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ReviewEvent extends BaseEntity implements Persistable<Long> {
@Id
private Long id;
@Embedded
private ReviewEventPayload payload;
private boolean status = false;
@Builder
private ReviewEvent(Long id,
Long entityId,
EventCommand command,
String attribute) {
this.id = id;
this.payload = ReviewEventPayload.builder()
.entityId(entityId)
.command(command)
.attribute(attribute)
.build();
}
public void successEvent() {
this.status = true;
}
}
이벤트 엔티티에는 식별자, 이벤트 페이로드, 성공 여부를 담는 필드를 갖도록 하였습니다. 이벤트 생성 시점에는 성공 여부를 `false`로 두고, 이벤트 처리가 정상적으로 수행되면 `successEvent()`를 통해 갱신이 이루어질 수 있도록 하였습니다. 이는 추후에 성공 여부가 `false`인 이벤트를 다시 조회하여 처리하기 위함입니다.
ReviewEventPayload
@Getter
@Embeddable
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ReviewEventPayload {
private Long entityId;
@Convert(converter = EventCommandConverter.class, attributeName = "command")
private EventCommand command;
private String attribute;
@Builder
private ReviewEventPayload(Long entityId,
EventCommand command,
String attribute) {
validateEntityId(entityId);
this.entityId = entityId;
this.command = command;
this.attribute = attribute;
}
private void validateEntityId(Long entityId) {
if (Objects.isNull(entityId)) {
throw new InvalidValueException(GlobalErrorCode.INVALID_REQUEST_EXCEPTION);
}
}
}
리뷰 이벤트 페이로드에는 이벤트 처리를 위해 필요한 정보 (리뷰 엔티티 식별자, 이벤트 발행 커맨드, 변경 속성) 를 담도록 하였습니다. 변경 속성의 경우 현재 이미지 생성에 대한 변경만 존재하기 때문에 여러 이미지 Url을 하나의 문자열로 관리하고 있지만, 더욱 많은 변경 속성을 다루게 된다면 `JSON` 형태로 보관하여 속성별 변경사항을 관리할 수 있을 것 같습니다.
EventCommand
@Getter
public enum EventCommand {
REVIEW_IMAGE_CREATE("/리뷰이미지생성")
;
private final String command;
EventCommand(String command) {
this.command = command;
}
public static EventCommand from(String command) {
return Arrays.stream(EventCommand.values())
.filter(v -> v.getReason().equals(command))
.findAny()
.orElseThrow(() -> new InvalidValueException());
}
}
`ReviewCreateEvent`를 구독하는 구독자가 늘어나는 경우 이벤트를 통해 기대되는 작업이 여러가지가 될 수 있습니다. 때문에 위와 같이 이벤트를 통해 수행되어야 하는 작업들을 관리할 수 있도록 Enum 객체를 만들었습니다.
위 구조를 통해서 이벤트를 수행하기 위해 필요한 정보들을 지닌 엔티티를 이벤트 저장소에 저장할 수 있게 되었습니다. 다음으로는 저장소에 실패한 이벤트를 조회하고 처리하는 부분을 확인해보도록 하겠습니다.
실패 이벤트 재시도 구조
ReviewEventPollingRetryer
@Slf4j
@Component
@RequiredArgsConstructor
public class ReviewEventPollingRetryer {
private final LoadReviewEventPort loadReviewEventPort;
private final ReviewEventRetryHandler reviewEventRetryHandler;
@Scheduled(cron = "0 0/1 * * * *")
public void retryFailedReviewEvent() {
List<ReviewEvent> events = loadReviewEventPort.findAllFailedEvent();
for (ReviewEvent event : events) {
try {
reviewEventRetryHandler.handle(event);
} catch (Exception e) {
log.error("재시도 실패 이벤트 발생 : id = " + event.getId(), e);
}
}
}
}
우선 스케줄링을 이용하여 주기적으로 실패한 이벤트를 조회하고 가져오도록 하였습니다. 해당 부분은 EDA에서 이벤트 퍼블리셔를 구현하는 Message Relay 방식 중 하나인 Polling Publisher를 보고 힌트를 얻어 구현하였습니다. Polling Publisher 방식은 저장된 이벤트를 주기적으로 조회하여 메세지 브로커에게 전달해줌으로써 이벤트 발행을 보장하는 방식입니다.
ReviewEventRetryHandler
@Component
@RequiredArgsConstructor
public class ReviewEventRetryHandler {
private final ImageService imageService;
private final ReviewEventService reviewEventService;
@Transactional
public void handle(ReviewEvent event) {
EventCommand command = event.getPayload().getCommand();
switch (command) {
case REVIEW_IMAGE_CREATE -> {
List<String> imageUrls = reviewEventService.convertImageUrlsToList(event.getPayload().getAttribute());
imageService.createImages(imageUrls, ImageType.REVIEW, event.getPayload().getEntityId());
reviewEventService.successEvent(event.getId());
}
}
}
}
처음에는 위와 같이 `RetryHandler`를 만들지 않고 `ReviewEventPollingRetryer` 에서 조회한 `RevieEvent`를 다시 `ReviewCreateEvent`로 변경하여 발행하도록 함으로써 기존에 작성되어 있는 이벤트 리스너를 재활용 할 수 있도록 하였습니다.
하지만, 이렇게 이벤트를 다시 발행해버리면 불필요하게 이벤트가 다시 저장되게 됩니다. (이벤트 리스너 동작에서 이벤트를 다시 저장하기 때문) 따라서 위와 같이 별도로 재시도를 진행할 수 있는 핸들러를 구성하였습니다.
앞서 하나의 이벤트에서 기대되는 작업이 여러가지일 수 있기 때문에 `EventCommand`를 생성하여 관리하다고 하였는데, 위 핸들러에서 `switch-case`를 통해 이벤트 커맨드에 따라 재시도가 처리될 수 있도록 하였습니다.
결과
이벤트 저장소를 구축하고 재시도 로직을 구현함으로써 실패한 이벤트를 성공적으로 핸들링할 수 있게 되었습니다.
마무리
MSA와 EDA를 학습하고 이해함으로써 이벤트를 다루는 방법에 대해서 좀 더 깊게 알아볼 수 있는 시간이었습니다. 이번 포스팅에서는 다루지 않았지만, MSA를 구현하기 위한 API Gateway, SAGA 패턴 등 다양한 주제들에 대해서도 공부할 수 있어서 정말 좋은 시간이었습니다.
그럼에도 아직은 위 개념들을 제대로 다루기 위한 선수지식이 부족한 것 같습니다. 결국 MSA와 EDA를 제대로 다루기 위해서는 도메인과 이벤트에 대한 충분한 이해가 필요한데, 아직은 도메인을 어떻게 정의하고 분리하면 좋을지, 어떤 상황에서 이벤트를 사용하면 좋을지에 대해서 많은 고민을 하게 되는 것 같습니다.
DDD를 공부하면 좀 더 잘 알 수 있지 않을까 하여 다음은 DDD와 관련된 포스팅을 다뤄봐도 좋을 것 같습니다! 긴 글 읽어주셔서 감사합니다!
Reference
https://techblog.woowahan.com/7835/
https://www.inflearn.com/pages/infcon-2023-tech-msa
https://sharplee7.tistory.com/150
https://issuebombom.tistory.com/112
https://devocean.sk.com/blog/techBoardDetail.do?ID=165445
'Spring' 카테고리의 다른 글
동시성 테스트를 통한 성능 개선하기 (2) - Spring Event (2) | 2024.04.04 |
---|---|
조회 성능 최적화(3) - QueryDsl : SQL (0) | 2024.03.16 |
조회 성능 최적화 (1) - 쿼리 발생 줄이기 (1) | 2024.02.13 |
테스트 객체 생성 라이브러리 Instancio, Fixture Monkey (feat. Test Fixture) (0) | 2024.02.05 |