알림톡과 예약 시스템의 트랜잭션 분리 - 아웃박스 패턴 도입기

2025. 1. 29. 17:07Spring

문제 상황

시작은 이랬습니다.

“앤디(영어이름), 서버에 알림톡 에러 났다는 버그가 있던데요?”
“앤디, 항공권이 예약이 제대로 안 됐다는데 확인좀 부탁드려요.”

이런 문의가 들어왔을 때 가장 먼저 확인하는 것이 예약 상태입니다.

일반적으로는 예약 실패시 즉시 사용자에게 피드백이 가야하는데,
해당 건은 예약도 실패했고 알림도 실패한 상황이었죠.

로그를 확인해보니 ApiPlex(카카오 알림톡 대행사)와의 통신 오류로 인해
알림톡 발송에 실패했고, 이 실패가 예약 트랜잭션을 롤백시킨 것이었습니다.

더 큰 문제는 사용자는 예약을 시도했다가 실패했다는 것조차 알 수 없었다는 점입니다.
이는 출장 예약 시스템에서는 심각한 문제였습니다.

항공권이나 호텔의 경우 실시간 재고(좌석, 객실)가 중요한데,
알림톡 장애로 인해 예약이 실패하면 그 사이 다른 사람이 해당 좌석을 예약할 수 있기 때문입니다.

@Transactional
public void book(BookingRequest request) {
// 1. 예약 정보 저장
    Booking booking = bookingRepository.save(
        Booking.from(request)
    );

// 2. 알림톡 발송 요청 - 이 부분에서 실패하면 예약도 롤백됨
    alimtalkService.sendAlimtalk(
        booking.getCompanyCode(),
        booking.getTransactionId(),
        booking.getBookingItemCode(),
        UmsStatusCode.BOOKING_CONFIRMED,
        booking.getUserPhoneNumber()
    );
}

위 다이어그램처럼, 알림톡 발송 실패 시 예약도 함께 롤백 되는 구조였습니다.
이는 핵심 비즈니스 로직(예약)이 부가 기능(알림)에 영향을 받는 잘못된 설계입니다.

특히 우리 시스템은 출장 관련 예약이 주 기능이라
호텔, 항공권 등의 실시간 좌석이 곧 메인 비즈니스 로직인데,
단순 알림 실패로 예약이 취소되면 그 사이 다른 사용자가 예약할 수도 있는 심각한 문제였습니다.

그래서 아웃박스 패턴을 들고오게 되었습니다.

아웃박스 패턴 소개와 설계

패턴 소개

아웃박스 패턴

아웃박스 패턴(Outbox Pattern)은 분산 시스템에서 데이터 일관성을 유지하면서
이벤트를 안정적으로 발행하기 위한 패턴입니다.

주요 특징:

  1. 이벤트 저장: 메인 비즈니스 로직과 동일한 Transaction 에서
    이벤트를 로컬 데이터베이스의 아웃박스 테이블에 저장
  2. 이벤트 발행 분리: 저장된 이벤트를 별도의 프로세스가 읽어서 외부 시스템으로 발행
  3. 최소 1회 전달 보장: 이벤트가 유실되지 않도록 보장

구현 방식:

  1. Message Broker 사용
    • Kafka, RabbitMQ 등의 메시지 큐 활용
    • Debezium 같은 CDC(Change Data Capture) 도구 활용
  2. Polling 방식
    • 주기적으로 DB를 조회하여 이벤트 처리
    • 구현이 단순하고 추가 인프라 불필요

테이블 설계

아웃박스 테이블 설계

테이블은 크게 네 가지 정보를 관리합니다:

  1. 기본 정보: 누구에게 보내는 알림인지
  2. 템플릿 정보: 어떤 내용의 알림인지
  3. 상태 정보: 발송 성공 여부와 재시도 관련 정보
  4. 시간 정보: 언제 생성되고 수정되었는지

상태 관리

알림톡 발송 이벤트의 상태는
다음과 같은 4가지 상태를 가지고 있습니다.

알림톡 이벤트 4가지 상태

  • PENDING: 발송 대기 중 (택배 접수 상태)
  • RETRY: 발송 실패, 재시도 예정 (배송 시도 실패, 재배송 예정)
  • SUCCESS: 발송 성공 (배송 완료)
  • FAILED: 최종 실패 (배송 실패 확정)

구현 방식 선택

DB Polling vs Kafka

처음에는 Kafka 도입을 고려했습니다.
실시간성이 좋고 확장성도 뛰어나니까요.
하지만 현실적인 제약이 있었습니다:

  1. 1주일이라는 짧은 개발 기간
    • Kafka 학습과 인프라 구축에 시간 소요
    • 빠른 문제 해결 필요
  2. 팀 상황 고려
    • Kafka 경험자 부재
    • 추후 유지보수 인력 고려
  3. 현재 시스템 규모
    • 하루 평균 3000건 정도의 알림톡
    • DB 조회로도 충분한 처리량

결국 폴링(Polling) 방식을 선택했습니다.

핵심 컴포넌트 구현

알림톡 발송 요청

알림톡 발송 요청을 받으면 즉시 발송하는 대신,
PENDING 상태로 아웃박스 테이블에 저장합니다.

@Service
@RequiredArgsConstructor
public class DefaultStrategy implements CompanyStrategy {
    private final AlimtalkOutboxEventMapper alimtalkOutboxEventMapper;

    @Override
    public void sendAlimtalk(String transactionSetId,
                           String key,
                           UmsStatusCode umsStatusCode,
                           String receiverMobileNo) {
        CreateAlimtalkOutboxEvent createEvent = new CreateAlimtalkOutboxEvent(
            transactionSetId,
            key,
            umsStatusCode.getTemplateCode(),
            receiverMobileNo,
            umsStatusCode.getCategoryCode().getCode(),
            "DEFAULT",
            EventStatus.PENDING
        );

        alimtalkOutboxEventMapper.createEvent(createEvent);
    }
}

Redis 분산 락과 이벤트 처리

한 건의 알림을 여러 서버가 동시에 처리하거나,
이전의 scheduling 이 아직 다 끝나지 않았을 경우가 존재합니다.
이를 위해 Redis를 이용한 분산 락을 구현했습니다.

@Slf4j
@Service
@RequiredArgsConstructor
public class AlimtalkOutboxEventService {
    private final RedisLockService redisLockService;
    private final AlimtalkOutboxEventMapper alimtalkOutboxEventMapper;
    private final CommonApiPlexService commonApiPlexService;

    private static final String LOCK_KEY = "AlimtalkOutboxEventLock";
    private static final int RETRY_LIMIT = 3;

    public void processEvents() {
        try {
						// 락 획득 시도
            if (!redisLockService.tryLock(LOCK_KEY)) {
                return;
            }

						// PENDING 상태의 이벤트 조회
            List<AlimtalkOutboxEventVo> events =
                alimtalkOutboxEventMapper.selectPendingOrRetryingEvents(5);

						// 각 이벤트 처리
            List<AlimtalkResultUpdateParam> results = new ArrayList<>();
            for (AlimtalkOutboxEventVo event : events) {
                results.add(processEvent(event));
            }

						// 결과 업데이트
            alimtalkOutboxEventMapper.updateEventResults(results);

        } finally {
            redisLockService.unlock(LOCK_KEY);
        }
    }

    private AlimtalkResultUpdateParam processEvent(AlimtalkOutboxEventVo event) {
        int nextRetryCount = event.retryCount() + 1;

        try {
            commonApiPlexService.sendApiPlexAlimtalk(
                event.transactionSetId(),
                event.dataKey(),
                event.templateType().equals("TRIPLUS") ?
                    TriplusAlimtalkTemplate.getTemplate(event.templateCode())
                    : DefaultAlimtalkTemplate.getTemplate(event.templateCode()),
                event.receiverMobileNo()
            );

            return new AlimtalkResultUpdateParam(
                event.id(),
                EventStatus.SUCCESS,
                null,
                nextRetryCount
            );

        } catch (Exception e) {
            EventStatus status = nextRetryCount > RETRY_LIMIT ?
                EventStatus.FAILED : EventStatus.RETRY;

            return new AlimtalkResultUpdateParam(
                event.id(),
                status,
                e.getMessage(),
                nextRetryCount
            );
        }
    }
}

스케줄러 구현

스케줄러는 10초마다 한 번씩 PENDING 상태의 알림을 처리합니다.

@Scheduled(fixedDelay = 10000)// 10초마다 실행
public void processAlimtalkOutboxEvent() {
    try {
        if (!batchRedisService.isBatchAvailable(defaultCompCode, batchCode)) {
            return;
        }

        alimtalkOutboxEventService.processEvents();
    } catch (Exception e) {
        log.error("알림톡 아웃박스 이벤트 실행 중 오류 발생", e);
    }
}

모니터링과 운영

운영 중에는 크게 두 가지 고민이 있었습니다:

  1. 실패한 알림톡을 어떻게 처리할 것인가?
  2. 관리자는 어떻게 모니터링할 것인가?

처음에는 실패한 알림톡을 재발송하려고 했습니다.

하지만 기획팀과의 논의 과정에서 중요한 인사이트를 얻었습니다:
"대부분의 예약이 한 명의 담당자에 의해 여러 건이 동시에 이뤄져요.
이런 상황에서 일부 알림만 늦게 도착하면 오히려 더 혼란스러울 수 있어요."

이 의견에 동의했고, 다음과 같이 처리하기로 결정했습니다:

  1. 실패한 알림톡은 재발송하지 않음
  2. 대신 관리자 대시보드에서 실패 건 모니터링
  3. 필요시 관리자가 수동으로 대응

개선 효과

  1. 안정성 향상
    • 알림톡 실패가 예약에 영향을 주지 않음
    • 핵심 비즈니스 로직 보호
  2. 운영 효율성
    • 실패 케이스 모니터링 가능
    • 장애 상황에 대한 대응력 향상
  3. 확장성 확보
    • 추후 Kafka 전환시 내부 구현만 변경하면 됨
    • 다른 알림 채널 추가 용이

현재는 Polling 방식으로 안정적으로 운영 중이지만,

시스템이 더 성장하고 실시간성이 더 중요해진다면 Kafka로의 전환을 검토할 예정입니다.

후기

단순히 기술을 도입하는 것이 아닌, 현실과의 균형점을 찾는 과정이 참 흥미로웠습니다.
팀의 상황, 비즈니스의 특성, 시간적 제약 등 다양한 요소들을 고려해야 한다는 것을 다시 한 번 깨달았죠.

특히 대학교 시절 이론으로만 접했던 아웃박스 패턴을
실제 비즈니스 문제 해결에 적용해보니 그 가치를 더 잘 이해할 수 있었습니다.

Kafka 대신 선택한 DB Polling 방식도 우리 상황에서는 꽤 괜찮은 결정이었던 것 같습니다.
때론 '완벽한' 솔루션보다는 '현실적인' 솔루션이 더 나은 선택일 수 있다는 걸 배웠습니다.

무엇보다 도입 이후 알림톡 관련 에러로 인한 예약 실패가 한 건도 발생하지 않았다는 게 가장 큰 성과였습니다.

728x90