본문 바로가기

카테고리 없음

Transactional Outbox Pattern 을 이용한 로직 처리 - 삽질중인 개발자

반응형

구독형 서비스를 운영하는 과정에서 운영 비용 상승으로 인해 구독 서비스 요금 인상을 해야 하는 상황이 발생했다.

 

자연스러운 요금제 변경을 유도하기 위하여 정해진 정책은 다음과 같다

 

1. 적용 대상

   - 현재 구독형 요금제를 사용 중인 고객


2. 정책 내용

   - 요금제 인상 사전 안내 및 동의 여부 확인

 

3. 적용 시나리오

   - 동의한 경우 : 다음 정기 결제부터 인상된 요금 적용 & 사내 타 서비스의 기간제 이용권 추가 지급

   - 미동의한 경우 : 다음 정기 결제일을 기준으로 구독 자동 해지

 

우선, 해당 기능을 구현하기 위해 필요한 사항을 정리해 보았다.

 

첫 번째로 요금제 인상에 대한 동의 여부 확인 폼을 구현을 해야 하는데 이 부분은 기존에 구현해서 사용 중인 서비스 중 설문 관련 서비스를 재사용하면 금방 구현이 가능할 것으로 보여서 재사용 및 필요한 부분을 추가 개발 하는 방향으로 진행하기로 했다.

 

두 번째로 해당 폼에서 사용자의 응답 결과에 따라 정기 결제 상품 변경, 특정 상품으로 변경 시 타 서비스 이용권 지급 등 여러 부가 로직이 필요한 상황이었다. 여러 부가 로직을 구현하기 위해서는 설문 응답에 대한 이벤트를 카프카를 사용해서 발행하고 각각의 필요한 서비스들이 컨슈머를 통해서 로직을 구현하는 게 좋다고 판단이 되었다.

 

구현하기에 앞서 해당 이벤트는 누락이 되거나 일관성이 깨지면 안되는 상황이어서 최소 1회 이상의 메시지 발행 보장 및 데이터 일관성 유지하기 위해 Transactional Outbox Pattern을 도입하기로 했다.

 

구현된 Outbox의 대략적인 구조는 아래와 같다.

CREATE TABLE t_survey_outbox (
    id              VARCHAR(36) PRIMARY KEY,
    event_type      VARCHAR(100) NOT NULL COMMENT '이벤트 타입',
    aggregate_id    VARCHAR(150) NOT NULL COMMENT '이벤트 고유 값',
    payload         LONGTEXT NOT NULL COMMENT '이벤트 본문',    -- 이벤트 데이터 (메시지 본문)
    status          VARCHAR(20) NOT NULL COMMENT '이벤트 발행 상태', -- 상태
    retry_count     INT NOT NULL DEFAULT 0 -- 재시도 횟수
);

 

유저가 요금제 인상 설문에 응답하면 해당 응답 데이터를 DB에 저장함과 동시에 Outbox 메시지도 함께 저장한다. 이때 이 두 작업은 하나의 트랜잭션으로 묶어서 처리해 데이터 일관성을 보장한다.

 

이렇게 되면 Outbox 테이블이 이벤트 발행 대기중인 row 하나가 생기며 이때 이 정보를 가지고 카프카에 이벤트를 발행해야 하는데 이때 사용할 수 있는 방법은 Polling Publisher 방식, Transaction Log Tailing 방식등이 있다.

 

Polling Publisher 은 Outbox DB 테이블을 주기적으로 Polling 해 DB를 확인하여 발행되지 않은 메시지를 읽어와서 발행하는 방식으로 구현 자체는 간단하지만 폴링 간격에 따라 처리 지연이 발생하거나 주기적으로 DB에 부하가 생기는 단점이 존재한다. 

 

Transaction Log Tailing 은 DB에서 생성되는 Transaction Log를 추적하여 데이터 변경을 감지하여 낮은 지연 시간과 DB 부하가 적다는 점이 장점이지만 한다로 별도의 CDC 툴을 이용해야 한다는 점 그리고 장애 대응 및 중복과 누락 처리가 복잡하다는 점이 단점이다.


아직 CDC 방식은 도입 경험이 없고 신규 적용에 대한 부담이 있어 제외하기로 했고 이미 서비스를 구독 중인 기존 유저를 대상으로 하며 요금제 인상에 쉽게 동의하지 않을 가능성이 높아 트래픽이 많지 않을 것으로 판단을 했다. 또한 이벤트 전달이 완전한 실시간일 필요는 없다고 보아 Polling Publisher 방식으로 주기가 짧게 이벤트를 발행하도록 배치를 구현하고 동일한 이벤트가 일정 횟수 이상 이벤트 발행이 실패하는 경우는 따로 로그를 통해 알람이 오도록 구현을 했다.

 

해당 방식은 배치가 도는 주기에 따라 약간 화면상에 이상하게 노출이 될 수도 있다. 이 부분이 유저에게 혼란을 줄 수도 있을 것 같아서 응답과 outbox를 저장 트랜잭션이 커밋되면 ApplicationEventPublisher를 통해 Outbox 메시지의 PK를 포함한 이벤트를 발행하고 이 이벤트를 기반으로 카프카에 메시지를 전송했다. ( 장애가 나지 않는 이상 거의 바로 작동을 하며 해당 로직이 문제가 발행하더라도 배치에서 이벤트를 재 발행하여서 최종적으로는 DB의 상태가 원하는 상태로 변경된다. )

@Transaction
public void save(){
	// saveAnswer()
	// saveOutbox()
	applicationEventPublisher.publishEvent(new OutboxEvent(outboxId, message))
}

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void sendAddSurveyAnswerMessage(OutboxEvent event){
	// 카프카에 전송 로직
}

 

카프카 이벤트가 성공적으로 발행이 된 상태라면 해당 토픽을 구독하고 있는 컨슈머를 통해서 해당 Outbox의 이벤트가 정상적으로 카프카에 발행된 상태라고 체크를 해준다. 

@KafkaListener(topics = "survey.response.completed", groupId = "outbox-published-marker")
@Transactional
public void consume(ConsumerRecord<String, String> record) {
    String key = record.key();
    
    try{
        Outbox outbox = outboxRepository.findById(key)
                .orElseThrow(() -> new IllegalArgumentException("Outbox event not found: " + key));

        outbox.markAsPublished();

        log.info("Marked Outbox event as PUBLISHED | key={}", key);
    } catch (Exception e ) {
        log.error("Failed to process Outbox event | key={}, message={}", key, e.getMessage(), e);
    }

}

 

이러면 카프카에 정상적으로 발행된 이벤트에 대해서는 Outbox Table에서도 상태값이 발행 상태로 변경이 된다. 

 

위와 같은 방식으로 Kafka 메시지는 최소 1회 발행이 보장되지만 상황에 따라 동일한 이벤트가 여러 번 발행되는 문제가 발생할 수 있다. 이에 따라 해당 토픽을 구독하는 각 컨슈머에서는 동일한 이벤트가 여러 번 처리되더라도 결과가 변하지 않도록 멱등성을 보장하는 방식으로 로직을 구현하였다.

 

반응형