blog post cover

매칭됐는데 채팅방이 없다 — Transactional Outbox Pattern으로 이벤트 유실 0건 달성하기

동기 호출로 연결된 매칭-채팅 서비스에서 장애 전파와 채팅방 누락이 발생했다. Outbox Pattern과 Redis Streams로 서비스를 격리하고 이벤트 유실 0건을 달성한 과정.


매칭됐는데 채팅방이 없다

“좋아요를 눌렀는데 채팅방이 안 생겨요.”

CS 인입이 하루에 서너 건씩 들어오기 시작했습니다. 로그를 열어보면 매칭은 정상 성사. 그런데 채팅방이 없습니다.

Contacto의 스와이프 매칭은 단순합니다. 양쪽 사용자가 서로 좋아요를 누르면 매칭이 성사되고, 1:1 채팅방이 자동 생성됩니다. 문제는 “매칭 성사”와 “채팅방 생성” 사이에 있었습니다.


동기 호출의 함정

초기 구조에서 매칭 서비스는 채팅 서비스를 HTTP로 직접 호출했습니다.

sequenceDiagram
    participant M as 매칭 서비스
    participant C as 채팅 서비스

    M->>M: 1. 매칭 결과 DB 저장
    M->>C: 2. POST /api/chat/rooms
    C->>C: 채팅방 생성
    C-->>M: 200 OK
    M->>M: 3. 트랜잭션 커밋

동기 호출 구조

잘 돌아갈 때는 아무 문제가 없었습니다. 그러나 채팅 서비스에 지연이 발생하는 순간, 두 가지 실패 모드가 드러났습니다.

실패 모드 1: 채팅 서비스 타임아웃 → 매칭 롤백

채팅 서비스 응답이 3초를 넘기면 매칭 서비스의 HTTP 클라이언트가 타임아웃을 던집니다. 매칭 트랜잭션이 롤백되면서, 양쪽 사용자 모두 매칭이 성사되지 않은 것처럼 보입니다. 실제로는 양쪽 다 좋아요를 눌렀는데.

실패 모드 2: 채팅방은 생겼는데 매칭이 롤백

더 교묘한 경우도 있었습니다. 채팅 서비스 호출은 성공했지만, 이후 매칭 서비스의 DB 커밋이 실패하면 매칭은 롤백되는데 채팅방은 이미 생성된 상태. 유령 채팅방이 남습니다.

공통 원인: 두 서비스의 상태 변경이 하나의 트랜잭션으로 묶이지 않는다.

분산 환경에서 서로 다른 DB를 쓰는 두 서비스의 상태를 원자적으로 변경하는 것은 본질적으로 불가능합니다. 동기 호출은 이 사실을 숨겨줄 뿐, 해결해주지 않습니다.


대안 비교

방식원리장점단점
2PC (Two-Phase Commit)분산 트랜잭션 코디네이터가 양쪽 DB를 동시에 커밋강한 일관성가용성 저하, 코디네이터 SPOF, NoSQL/Redis 미지원
Saga각 서비스가 로컬 트랜잭션 실행 → 실패 시 보상 트랜잭션서비스 독립성보상 로직 복잡, 채팅방 “생성 취소” 정의 모호
Transactional Outbox이벤트를 로컬 DB에 저장 → 별도 프로세스가 발행DB 트랜잭션으로 원자성 보장, 단순발행 지연 (폴링 간격)

매칭-채팅 시나리오에서 2PC는 Redis Streams를 브로커로 쓰는 환경에 맞지 않았고, Saga는 “채팅방 생성을 되돌린다”는 보상 트랜잭션의 정의가 모호했습니다. Outbox Pattern은 매칭 서비스의 DB 트랜잭션 하나로 원자성을 보장하면서, 발행은 별도 프로세스에 위임합니다. 폴링 간격만큼의 지연은 채팅방 생성이라는 맥락에서 충분히 허용 가능했습니다.


Outbox Pattern 적용

핵심 원리

매칭 결과와 이벤트를 같은 트랜잭션으로 저장합니다. 이벤트 발행은 별도 프로세스(Polling Publisher)가 담당합니다.

sequenceDiagram
    participant M as 매칭 서비스
    participant P as Polling Publisher
    participant R as Redis Streams
    participant C as 채팅 서비스

    M->>M: BEGIN TX
    M->>M: 1. 매칭 결과 INSERT
    M->>M: 2. outbox_event INSERT (PENDING)
    M->>M: COMMIT

    P->>M: SELECT (PENDING)
    P->>R: XADD
    P->>M: UPDATE (PUBLISHED)

    R->>C: XREADGROUP
    C->>C: 채팅방 생성

Outbox Pattern 흐름

DB 트랜잭션이 커밋되면 매칭 결과와 이벤트는 반드시 함께 존재합니다. 트랜잭션이 롤백되면 둘 다 사라집니다. 이것이 Outbox Pattern의 전부입니다.

Outbox 테이블

CREATE TABLE outbox_event (
    id             BIGINT AUTO_INCREMENT PRIMARY KEY,
    aggregate_type VARCHAR(50)  NOT NULL,
    aggregate_id   BIGINT       NOT NULL,
    event_type     VARCHAR(100) NOT NULL,
    payload        JSON         NOT NULL,
    status         VARCHAR(20)  NOT NULL DEFAULT 'PENDING',
    created_at     DATETIME(6)  NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
    published_at   DATETIME(6)  NULL
);

CREATE INDEX idx_outbox_pending ON outbox_event(status) WHERE status = 'PENDING';
  • status: PENDINGPUBLISHED → (실패 시 FAILED)
  • aggregate_type + aggregate_id: 어떤 도메인 객체에서 발생한 이벤트인지 추적
  • payload: 채팅방 생성에 필요한 모든 정보를 JSON으로 담음

매칭 서비스 코드

@Transactional
public MatchResult completeMatch(Long userId, Long targetUserId) {
    // 1. 매칭 결과 저장
    Match match = matchRepository.save(
        Match.of(userId, targetUserId, MatchStatus.MATCHED)
    );

    // 2. 같은 트랜잭션에서 Outbox 이벤트 저장
    outboxEventRepository.save(OutboxEvent.builder()
        .aggregateType("MATCH")
        .aggregateId(match.getId())
        .eventType("MATCH_COMPLETED")
        .payload(Map.of(
            "matchId", match.getId(),
            "userIds", List.of(userId, targetUserId)
        ))
        .build());

    return match;
}

@Transactional 하나로, 매칭 결과와 이벤트의 원자성이 보장됩니다. 채팅 서비스에 대한 의존성은 완전히 사라졌습니다.


Polling Publisher

Outbox 테이블에 쌓인 PENDING 이벤트를 주기적으로 조회하여 Redis Streams에 발행합니다.

@Scheduled(fixedDelay = 1000)  // 1초 간격 폴링
public void publishPendingEvents() {
    List<OutboxEvent> events = outboxEventRepository
        .findTop100ByStatusOrderByCreatedAtAsc("PENDING");

    for (OutboxEvent event : events) {
        try {
            redisStreamPublisher.publish(
                "chat:stream:match-events",
                event.toStreamMessage()
            );
            event.markPublished();
        } catch (Exception e) {
            event.markFailed();
            log.warn("Outbox 발행 실패: eventId={}", event.getId(), e);
        }
    }

    outboxEventRepository.saveAll(events);
}

Polling Publisher 동작

설계 결정:

  • 폴링 간격 1초: 매칭 후 채팅방 생성까지 최대 1초 지연. 사용자가 매칭 성공 애니메이션을 보는 동안 채팅방이 준비됩니다.
  • 배치 100건: 한 번의 폴링에 최대 100건 처리. 일 매칭 3,000건 기준, 피크 시에도 충분한 처리량.
  • 실패 시 FAILED 마킹: 다음 폴링에서 재시도하지 않고, 별도 복구 배치가 FAILED 이벤트를 재처리합니다.

발행 실패 복구

@Scheduled(fixedDelay = 60000)  // 1분 간격
public void retryFailedEvents() {
    List<OutboxEvent> failed = outboxEventRepository
        .findByStatusAndCreatedAtAfter("FAILED", Instant.now().minus(1, ChronoUnit.HOURS));

    for (OutboxEvent event : failed) {
        event.resetToPending();
    }

    outboxEventRepository.saveAll(failed);
}

1시간 이내의 FAILED 이벤트를 PENDING으로 되돌려 재시도합니다. 1시간이 지나면 수동 조치 대상으로 분류됩니다.


Consumer 멱등성

Polling Publisher가 같은 이벤트를 두 번 발행할 수 있습니다 — 발행은 성공했지만 PUBLISHED 업데이트 전에 프로세스가 죽는 경우. 채팅 서비스는 이를 대비해야 합니다.

@StreamListener("chat:stream:match-events")
public void handleMatchCompleted(MatchCompletedEvent event) {
    if (chatRoomRepository.existsByMatchId(event.getMatchId())) {
        log.info("이미 생성된 채팅방: matchId={}", event.getMatchId());
        return;  // 멱등성 보장
    }

    chatRoomRepository.save(ChatRoom.createForMatch(
        event.getMatchId(),
        event.getUserIds()
    ));
}

matchId로 중복 체크. 같은 매칭에 대해 채팅방은 단 한 번만 생성됩니다.


결과

최종 아키텍처

메트릭Before (동기 호출)After (Outbox)
채팅방 누락일 평균 37건0건
매칭→채팅방 생성 지연120ms (정상) / 타임아웃 (장애)평균 1.2s (폴링 간격)
매칭 서비스 가용성채팅 서비스 장애에 의존완전 격리
장애 전파채팅 서비스 → 매칭 서비스 연쇄없음

Before/After 메트릭

매칭→채팅방 생성 지연이 120ms에서 1.2s로 늘었지만, 매칭 성공 애니메이션(약 2초)이 재생되는 동안 채팅방이 준비되므로 사용자 경험에 영향은 없었습니다.


되돌아보며

“같은 트랜잭션”이 답이었습니다. 분산 환경에서 두 서비스의 상태를 동시에 바꾸려는 시도 자체가 문제였습니다. 한쪽 서비스의 DB 트랜잭션 안에서 이벤트까지 저장하고, 실제 발행은 나중에 — 이 단순한 분리가 모든 문제를 해결했습니다.

동기 호출은 편리함의 빚입니다. 서비스 간 동기 호출은 초기에는 빠르게 구현할 수 있지만, 트래픽이 늘면서 장애 전파라는 이자가 붙습니다. Outbox Pattern은 그 빚을 갚는 가장 보수적이고 안전한 방법이었습니다.