1. 개요
GitHub - hyunsb/BK-marriott-server: MSA 호텔 예약 / 선착순 쿠폰 발급 서비스
MSA 호텔 예약 / 선착순 쿠폰 발급 서비스. Contribute to hyunsb/BK-marriott-server development by creating an account on GitHub.
github.com
MSA에서 선착순 쿠폰 발급 서비스 설계하기
개요20,000 TPS 환경에서 평균 500ms의 응답속도를 보장하는쿠폰 서비스를 설계하며 고민했던 내용을 정리하려 한다. 기능은 이벤트성 선착순 쿠폰 발급과 쿠폰 관리 기능으로 나뉘며,특히 선착순
hyunsb.tistory.com
선착순 쿠폰 발급 기능을 구현하며 쿠폰 발급 가능 유무를 판단하는 기능과 쿠폰 발급 및 관리에 대한 기능을 각각의 서비스로 분리했다. 또한, 쿠폰을 실제로 발급하는 데 발생하는 latency를 선착순 쿠폰 발급기능에 전파하지 않기 위해 비동기 이벤트 기반 통신을 적용했다.
여기서 kafka로의 이벤트 발급과 도메인 관련 트랜잭션을 원자적으로 처리해야 데이터 일관성이 보장되기 때문에 Transactional Outbox Pattern 을 사용하여 데이터 일관성을 보장하고자 한다.
만약, 도메인 관련 트랜잭션이 커밋된 후, Kafka로 이벤트를 발급하는데 Kafka가 응답하지 않는다면 어떻게 처리해야 할까. 이미 커밋된 트랜잭션 작업을 롤백할 수 있는 보상 트랜잭션을 실행할 수도 있을 것이다.
다만 선착순 쿠폰 발급 기능에서 롤백을 해버리는 경우 사용자 입장에서 불쾌함을 느낄 수 있다고 생각했다.
따라서, 선착순 쿠폰을 발급받을 수 있는 조건을 달성했지만 Kafka 와 같은 메시지 브로커가 동작하지 않아 이벤트를 발행할 수 없다면, 롤백하는 방법이 아닌 이벤트를 재발행 처리하는 방식으로 데이터 일관성을 지키려 한다.
Transactional outbox patern을 도입하기 전의 프로세스부터 살펴본 뒤 단점들을 보완해보겠다.
1.2. 선착순 쿠폰 발행 프로세스 (Outbox 도입 전)
@Transactional
public void tryToIssuanceCoupon(final TimeAttackCouponIssuance issuacne) {
// 1. 선착순 쿠폰 발급을 시도한다. (발급 가능 여부 검증 로직)
CouponIssuanceResult result = timeAttackCouponIssuer.tryIssuance(issuacne);
// 2. kafka로 이벤트를 발행한다.
if (result.isSuccess()) {
DomainEventEnvelop envelop = result.toEvent();
couponEventPublisher.publish(envelop);
}
}
기존의 로직은 데이터의 일관성을 보장하기 위해, 선착순 쿠폰 발급 시도와 kafka로의 이벤트 발행을 하나의 메서드에서 트랜잭션으로 관리했다. (롤백 처리를 위한 추가적인 코드 필요)
이러한 방식은 여러 불편한 점이 존재한다.
📌 카프카 장애로 인한 트랜잭션 롤백
Kafka 장애로 인해 트랜잭션이 롤백되어야 하는 경우, 메인 로직의 작업도 롤백되어야 한다. 실제로 쿠폰 발급이 성공했음에도 불구하고, Kafka 장애로 인해 쿠폰 발급이 취소되는 비즈니스 오류가 발생할 수 있고, 이를 해결하기 위한 추가적인 롤백 처리가 필요하다.
📌 성능 병목
Kafka가 성능 병목 지점이 될 가능성이 존재한다. 메인 로직의 트랜잭션이 Kafka로의 이벤트 발행 응답을 기다려야 하기에 Kafka의 Latency가 비즈니스 로직에 전파된다. 트랜잭션과 Kafka가 물리적으로 의존하기에 발생하는 문제이다.
📌 재처리의 어려움
Kafka로의 이벤트 발행이 실패한 경우, 메인 트랜잭션까지 롤백되어야 하기 때문에 이벤트를 재발행하기 위해서는 메인 로직을 다시 실행해야 한다. 롤백 처리를 완벽하게 하지 못했다면 비즈니스 로직이 중복 실행될 위험이 존재하게 된다. (쿠폰 중복 발급 등)
2. Transactional Outbox Pattern
메시징을 사용한 비동기 통신을 구현할 때는 비즈니스 로직과 메시지 발행을 원자적으로 처리하는 것이 중요하다. 이렇게 비지니스 로직과 메시지 발행을 원자적으로 실행하는 것을 트랜잭셔널 메시징(Transactional Messaging)이라 한다.
Transactional Messaging을 구현하는 방법은 대표적으로 Trasactional Outbox Pattern 과 로그테일링(CDC; Change Data Capture) 이 존재한다.
CDC 방식은 트랜잭션의 로그를 기반으로 데이터의 변경을 감지하려 이벤트를 추출하는 방식인데, 로그를 읽기 위해 Debezium과 같은 도구의 사용 숙지 및 추가적인 학습이 강제되기에 이번 프로젝트에서는 사용하지 않았다.
Transactional Outbox Pattern은 Transactional Messaging을 구현하는 방법 중 하나이다. 도메인 데이터와 이벤트 데이터의 CRUD는 동일한 트랜잭션 내에서 처리되지만, 이벤트의 직접적인 발행은 비동기로 처리하여 트랜잭션 롤백, 성능 병목, 재처리의 어려움등의 문제를 해결한다.
Microservices Pattern: Pattern: Transactional outbox
First, write the message/event to a database OUTBOX table as part of the transaction that updates business objects, and then publish it to a message broker.
microservices.io
비즈니스 로직에서 도메인 엔티티와 이벤트를 각각의 테이블에 트랜잭션으로 저장한다. Message Relay를 구현하여 주기적으로 outbox를 확인하고 이벤트브로커로 발행하는 방식으로 동작한다.
3. 도입기
도입 초기에는 Message Relay를 스케줄링 방식으로’만’ 구현했다. 해당 방식은 문제점이 존재하여, Spring Event를 활용해 개선했다.
3.1. Scheduling을 활용한 Message Relay
CREATE TABLE m_coupon_issuance_outbox (
outbox_id BIGINT PRIMARY KEY,
event_id CHAR(36) UNIQUE,
event_type VARCHAR(100) NOT NULL,
payload TEXT NOT NULL,
source VARCHAR(100) NOT NULL,
created_at TIMESTAMP NOT NULL,
is_published BOOLEAN NOT NULL DEFAULT FALSE
);
위와 같이 이벤트를 저장할 아웃박스 테이블을 생성했다. 테이블은 DomainEventEnvelop
의 구조와 동일하게 생성하고 DomainEvent
의 데이터는 확장성을 위해 JSON
포맷으로 payload
컬럼에 저장한다. JSON
타입으로 저장할 수도 있지만 조회등의 메리트가 필요없기에 TEXT
타입으로 저장한다.
is_published
컬럼은 이벤트가 카프카에 발행되었는지를 저장한다.
기존의 코드를 수정하여 이벤트가 outbox에 저장되게 한 뒤, 스케줄링으로 이벤트를 발행하면 된다.
@Transactional
public void tryToIssuanceCoupon(final TimeAttackCouponIssuance issuacne) {
// 1. 선착순 쿠폰 발급을 시도한다. (발급 가능 여부 검증 로직)
CouponIssuanceResult result = timeAttackCouponIssuer.tryIssuance(issuacne);
// 2. 이벤트를 아웃박스에 저장한다.
if (result.isSuccess()) {
DomainEventEnvelop envelop = result.toEvent();
// couponEventPublisher.publish(envelop);
couponExternalEventRecorder.record(envelop);
}
}
이제 스케줄링을 활용한 Message Relay를 구현한다. 주기적으로 outbox를 읽고 카프카로 이벤트를 발행한다.
@Transactional
@Scheduled(fixedRate = 30000) // 30초 마다
public void scheduleCouponIssuanceEventPublish() {
List<DomainEventEnvelop<CouponIssuanceEvent>> envelops =
eventReader.readAllBeforePublished();
envelops.forEach(envelop -> {
eventSendService.send(envelop); // 이벤트를 발행
eventRecorder.recordToPublished(envelop.getEventId()); // 아웃박스 상태를 변경
});
}
3.2. 문제점
리소스 낭비
위 방식은 Scheduling을 통해서만 이벤트를 발행하기 때문에 발행할 이벤트가 존재하지 않는 상태에서도 스케줄러가 동작하여 의미없는 리소스를 낭비한다.
이벤트 발행의 텀
도메인 로직이 처리되고 이벤트가 발행되기까지 최대 30초(스케줄링이 동작하는 텀)가 소요되기에 사용자 편의성을 저하할 우려가 존재한다.
3.3. 해결방법
새로운 이벤트가 생성되었을 때 트리거를 발동하여 kafka에 이벤트를 발행하는 로직을 추가하여 해당 문제를 해결할 수 있다. 트리거는 outbox에 새로운 이벤트가 commit 되는 것이다.
3.4. Spring Event를 활용한 Message Relay
스케줄링 방식을 개선하여 새로운 이벤트가 생성되었을 때(아웃박스에 이벤트가 저장되었을 때), 비동기 방식으로 카프카에 이벤트를 발행하도록 코드를 변경한다.
이벤트 발행 프로세스의 순서는 위 그림과 같다.
- 선착순 쿠폰 발행을 시도한다.
Transaction 시작
- 쿠폰 발행이 가능하다면 Spring Event를 발행한다.
- 아웃박스에 이벤트를 저장한다.
Transaction 종료
- 비동기 방식으로 카프카에 이벤트를 발행한다.
ApplicationEventPublisher를 사용하여 Spring 이벤트를 발행한다.
@Transactional
public void tryToIssuanceCoupon(final TimeAttackCouponIssuance issuance) {
// 1. 선착순 쿠폰 발급 시도
CouponIssuanceResult result = timeAttackCouponIssuer.tryIssuance(issuance);
// 2. Spring Event 발행
if (result.isSuccess()) {
DomainEventEnvelop envelop = result.toEvent();
// couponEventPublisher.publish(envelop);
// couponExternalEventRecorder.record(envelop);
applicationEventPublisher.publish(envelop);
}
}
이벤트가 Generic을 포함한다면 Type Erasure로 인한 비정상적인 동작이 발생할 수 있으니 유의할 것
@TransactionalEventListener
를 사용하여 이벤트를 구독하고 아웃박스에 이벤트를 저장한다.
@Component
public class CouponExternalEventRecordListener {
private final CouponExternalEventRecorder eventRecorder;
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void recordMessageHandle(CouponIssuance couponIssuance) {
log.debug("[CouponExternalEventRecordListener] [recordMessageHandle] event ::: {}", couponIssuance);
// 3. 아웃박스에 이벤트 저장
eventRecorder.record(couponIssuance.envelop());
}
}
비동기 방식으로 카프카에 이벤트를 발행한다.
@Component
public class CouponExternalEventMessageListener {
private static final String EVENT_ASYNC_TASK_EXECUTOR = "EVENT_ASYNC_TASK_EXECUTOR";
private final CouponExternalEventSendService eventSendService;
private final CouponExternalEventRecorder eventRecorder;
@Async(EVENT_ASYNC_TASK_EXECUTOR)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void sendMessageHandle(CouponIssuance couponIssuance) {
log.debug("[CouponExternalEventMessageListener] [publishEventHandle] async event publish running, event ::: {}", couponIssuance);
// 4. 카프카에 이벤트 발행
DomainEventEnvelop<CouponIssuanceEvent> envelop = couponIssuance.envelop();
envelop = eventSendService.send(envelop);
eventRecorder.recordToPublished(envelop.getEventId());
}
}
3.5. 문제점
scheduling 방식을 제외하고 트리거를 통해서만 이벤트를 발행하게 되면 Kafka의 shutdown 등의 문제로 발행되지 못하는 이벤트가 발생할 수 있다.
3.6. 해결방법
위의 같은 문제로 발행되지 못한 이벤트를 스케줄링하여 이벤트를 발행하는 로직을 추가한다.
@Component
public class CouponIssuancePublishScheduler {
private static final long TIME_LIMIT_MINUTES_FOR_OLD = 20;
/* ... */
/**
* TIME_LIMIT_MINUTES_FOR_OLD 분 동안 발급되지 못한 엔티티를
* 주기적으로 카프카에 발행하는 스케줄러
* */
@Transactional
@Scheduled(fixedRate = 20 * 60000)
public void scheduleCouponIssuanceEventPublish() {
LocalDateTime scheduledAt = LocalDateTime.now();
List<DomainEventEnvelop<CouponIssuanceEvent>> envelops = eventReader.readAllBeforePublished();
for (DomainEventEnvelop<CouponIssuanceEvent> envelop : envelops) {
// 20분동안 발행되지 못한 이벤트라면
if (isOldEvent(envelop, scheduledAt)) {
// 카프카에 발행하고 상태를 변경
eventSendService.send(envelop);
eventRecorder.recordToPublished(envelop.getEventId());
}
}
}
private boolean isOldEvent(DomainEventEnvelop<?> envelop, LocalDateTime baseTime) {
Duration duration = Duration.between(envelop.getCreatedAt(), baseTime);
return duration.toMinutes() >= TIME_LIMIT_MINUTES_FOR_OLD;
}
}
4. 결론
비동기 메시징 기반 시스템에서 데이터 일관성을 보장하기 위한 Outbox Pattern을 적용해보았다.
해당 패턴을 통해 주요 비즈니스 요구 사항인 선착순 쿠폰 발급과 데이터 일관성 유지를 해결할 수 있었다.
EDD를 적용하기 이전에는 Kafka의 설정과 Event만 잘 정의하면 만사 오케이라고 생각했는데, 실제로는 엄청난 전처리 작업이 필요하다는 것을 깨달았다. 새로운 기술의 도입은 일단 비판적 사고로 바라보자는 나의 신념이 잘 들어맞는 작업이었다.
5. Reference
트랜잭셔널 아웃박스 패턴의 실제 구현 사례 (29CM)
이 글에서는 실무 관점에서의 Apache Kafka 활용 에서 잠깐 소개했던 트랜잭셔널 아웃박스 패턴 (Transactional Outbox Pattern) 을 실제로 구현하여 활용하고 있는 29CM 의 사례를 소개하고자 한다.
medium.com
Microservices Pattern: Pattern: Transactional outbox
First, write the message/event to a database OUTBOX table as part of the transaction that updates business objects, and then publish it to a message broker.
microservices.io
Microservices Pattern: Pattern: Transaction log tailing
Microservices.io is brought to you by Chris Richardson. Experienced software architect, author of POJOs in Action, the creator of the original CloudFoundry.com, and the author of Microservices patterns.
microservices.io
트랜잭션 아웃박스 패턴 - AWS 권장 가이드
이 페이지에 작업이 필요하다는 점을 알려 주셔서 감사합니다. 실망시켜 드려 죄송합니다. 잠깐 시간을 내어 설명서를 향상시킬 수 있는 방법에 대해 말씀해 주십시오.
docs.aws.amazon.com
'트러블슈팅' 카테고리의 다른 글
페이지네이션, 검색 조건 값 관리 및 검증 자동화 구현 (1) | 2025.06.12 |
---|---|
MSA에서 선착순 쿠폰 발급 서비스 설계하기 (0) | 2025.03.11 |
템플릿 메소드 패턴으로 관리 포인트 줄이기 (0) | 2025.02.17 |
커서 기반 페이지네이션으로 조회 성능 개선 (0) | 2025.02.12 |
DB의 외부식별자와 내부식별자 분리 (Auto_Increment vs UUID) (0) | 2025.02.10 |