상황
결제로직, 외부서버(Elasticsearch)와 데이터 전송을 할 때 만약 데이터 정합성이 크게 문제가 되는 부분은 어떻게 처리를 해야할까요? 만약 로직 과정에서 데이터가 유실되고 이벤트가 유실된다면 환불, 결제 로직은 누락되어 돈문제가 발생할 것이며, Es는 트랜잭션이 따로 존재하지 않기 때문에 데이터 변경 정보가 누락된다면 데이터 정합성이 깨지는 문제가 발생할 것입니다.
이러한 문제를 해결하기 위해서 도입한 패턴이 아웃박스 패턴입니다.
아웃박스 패턴
아웃박스 패턴이란 이벤트를 발행할 때 데이터베이스에 저장하는 것과 이벤트 발행이 둘다 확실하게 성공하게 하는 패턴입니다. 시나리오를 통해 생각해보겠습니다.
모임 삭제를 호스트가 실행했다면 곧바로 모임 삭제를 하고 끝나는 것이 아닌, 내부 참가자에게 환불 로직이 필요합니다. 만약 동일 트랜잭션에서 모임삭제와 RabbitMQ를 통해 타 도메인으로 요청을 한 경우에 RabbitMQ 서버에 문제가 생겨 메세지가 가지 않았습니다. 그렇다면 DLQ나 따로 저장 자체를 못하기 때문에 메세지 자체가 유실될 가능성이 있을겁니다.
그렇다면 RabbitMQ로 보내는 행위 자체를 SpringEvent Listener에서 AfterCommit으로 처리하여 트랜잭션을 분리하고 이벤트 발행 전 Outbox Table에 publish status : False 상태로 저장을 미리 해두고, 요청 후 성공한다면 True. 재시도 로직을 넣어놨는데도 끝까지 실패한다면 스케줄러를 통해 실행한다면 실패하더라도 결국 다시 실행하고 어떤 데이터가 유실될 뻔 했는지 확인 또한 가능할 것입니다.
이러한 패턴을 아웃박스 패턴이라 합니다.
아웃박스 패턴 코드 확인
위와 같이 설명드렸던 패턴의 흐름을 예시로 한번 확인해보겠습니다.
1. 모임 삭제
2. 아웃박스 테이블에 저장
3. SpringEvent 모임 삭제에 따른 참가자 전원 환불 처리 로직
4. Event 내부에서 RabbitMQ 메세지 발행 후 아웃박스 테이블 마킹 (성공)
5. 스케줄러를 통해 끝까지 실패한 유실된 메세지는 1분마다 실행.
모임 삭제 시 환불을 하는 흐름은 위와 같습니다. 저는 아웃박스 패턴을 제가 맡은 도메인 Meeting에서 두가지를 사용하는데 하나는 위와 같은 모임 삭제, 다음은 Elasticsearch를 사용하는 부분입니다. 동일한 이유로 Elasticsearch 또한 데이터의 정합성을 위해서 아웃박스 패턴을 적용합니다.
두가지 코드를 확인해보겠습니다.
모임 삭제 아웃박스 패턴 적용 코드
# MeetingPayment Outbox Table
CREATE TABLE meeting_payment_outbox
(
id BIGINT AUTO_INCREMENT PRIMARY KEY,
meeting_id BIGINT NOT NULL,
event_uuid VARCHAR(255) NOT NULL,
event_type VARCHAR(255) NOT NULL,
payload TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
published_at TIMESTAMP,
published BOOLEAN NOT NULL DEFAULT FALSE,
processed BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT uk_event_uuid UNIQUE (event_uuid)
);
CREATE INDEX idx_published_processed ON meeting_payment_outbox (published, processed);
Table은 위와 같습니다. published를 처음 저장할 때는 기본적으로 FALSE로 설정 후 외부서버(RabbitMQ, ES 서버) 등으로 메세지를 보내는 것에 성공하면 published를 TRUE로 바꿔줍니다. 이제 로직을 쭉 확인해보겠습니다.
# MeetingServiceImpl - deleteMeeting [모임 삭제 메서드]
@Override
@Transactional
public void deleteMeeting(Long meetingId, Long userId) {
Meeting meeting = meetingRepository.findById(meetingId).orElseThrow(() ->
new MeetingException(MeetingExceptionCode.MEETING_NOT_FOUND));
if (!meeting.getHostUserId().equals(userId)) {
throw new MeetingException(MeetingExceptionCode.MEETING_FORBIDDEN);
}
List<Long> participants = meeting.getParticipants().stream().map(MeetingParticipant::getId).toList();
meeting.delete();
// 모임 삭제 메세지 mq 발행
EventWrapper<?> mqWrapper = EventWrapper.of(MEETING_DELETE, new MeetingAlarmMessages.Delete(
meeting.getHostUserId(),
meeting.getId(),
meeting.getTitle(),
participants
));
meetingProducer.deleteMeetingMQ(mqWrapper);
// 모임 삭제시 참가자들 환불 mq 발행
// 상태가 아직 진행중, 참가자가 존재 시 환불 이벤트 발행
if (!participants.isEmpty() &&
meeting.getStatus().equals(MeetingStatus.IN_PROGRESS)) {
MeetingEvents.Delete deleteEvent = new MeetingEvents.Delete(
meeting.getId(),
meeting.getTitle(),
participants
);
try {
EventWrapper<?> wrapper = EventWrapper.of(MEETING_DELETE, deleteEvent);
MeetingPaymentOutbox paymentOutbox =
MeetingPaymentOutbox.create(
MEETING_DELETE,
meetingId,
wrapper.uuId(),
objectMapper.writeValueAsString(wrapper)
);
meetingPaymentOutboxService.savePaymentOutbox(paymentOutbox);
eventPublisher.publishEvent(wrapper);
} catch (Exception e) {
throw new MeetingException(MeetingExceptionCode.JSON_SERIALIZATION_ERROR);
}
}
# MeetingPaymentListener - deleteMeetingEventListener [환불 SpringEvent Listener]
@Retryable(backoff = @Backoff(delay = 1000, multiplier = 2))
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void deleteMeetingEventListener(EventWrapper<?> wrapper) {
if (!wrapper.type().equals(MEETING_DELETE))
return;
try {
log.info("[Meeting] - MeetingPaymentListener.deleteMeetingEventListener : Meeting Delete 이후 참가자 환불 메세지 발행");
meetingProducer.deleteMeetingWithRefundsMQ(wrapper);
service.markEventAsPublished(wrapper.uuId());
} catch (Exception e) {
log.error(
"[Meeting] : MeetingPaymentListener.deleteMeetingEventListener - Meeting Delete 이후 payment mq 에러가 발생");
throw e;
}
}
# MeetingProducer - deleteMeetingWithRefundsMQ [모임 삭제 시 전체 환불 RabbitMQ 발행 로직]
/**
* 모임 삭제 시 참가자들 환불 메세지 발행 메서드
*/
public void deleteMeetingWithRefundsMQ(EventWrapper<?> wrapper) {
log.info("[Meeting] - MeetingProducer.deleteMeetingWithRefundsMQ : Meeting Delete 시 참가자 환불 메세지 발행");
rabbitTemplate.convertAndSend(
RabbitExchangeNames.MEETING_EVENTS,
MEETING_DELETE_KEY,
wrapper
);
}
위에서 미리 작성한것과 마찬가지로 코드의 흐름을 보면 하나의 트랜잭션에서 삭제 후 AFTER_COMMIT으로 스프링 이벤트 발행. FALSE 처리 이후 이벤트에서 try-catch를 통해 RabbitMQ 서버가 정상적으로 작동중이라면 발행 이후 published = TRUE로 마킹해줍니다.
위와 같은 아웃박스 패턴을 적용하여 메세지를 발행한 경우 발생할 수 있는 문제점인 메세지 유실을 막을 수 있었습니다. 특히나 위 로직에서 실패한다면 Scheduler를 통해 false 상태인 데이터를 재발행 해주기 때문에 데이터가 완전히 유실될 수 있는 가능성을 막았습니다.