MSA 환경의 데이터 정합성 문제 : Outbox Pattern

MSA 환경의 데이터 정합성 문제 : Outbox Pattern

최근 제가 맡고있는 MSA 기반 서비스에서 발생했던, 데이터 동기화 장애를 어떻게 해결했는지, 그리고 그 과정에서 배운 분산 시스템의 정합성 보장 전략을 공유하고자 합니다.

1. 문제

제가 맡게된 프로젝트에서 주기적으로 이런 문의가 들어오고 있었습니다.

“인사 발령이 났는데 여전히 이전 부서로 나와요.”

“퇴사자가 임직원 목록에 노출되는 이유가 뭔가요?”

서비스 구조에서 주기적으로 외부 인사 시스템과 데이터를 동기화하고, 변경된 정보를 user 정보를 관리하는 서비스로 전파하기 위해 이벤트를 발행하는 역할을 하는 approval 서비스가 있습니다.

이때, 간헐적으로 user 서비스의 데이터가 최신화되지 않는 문제가 발생하는 고질적인 장애가 있었습니다. 외부 시스템으로부터 데이터를 받아와 갱신하는 approval 서비스의 DB 데이터는 정상적으로 업데이트되었으나, 이후 발행되는 이벤트를 수신하여 데이터를 동기화하는 user 서비스의 로그와 kafka log 설정을 통해 확인한 결과, 어떠한 이유로 이벤트가 발행되지 않거나 이벤트 내 데이터가 유실되어 정합성을 유지하기 어려운 상황이었습니다.

왜 이런 일이 벌어질까요?
가장 큰 원인은 소위 Dual-write라 불리는 문제입니다. DB를 업데이트하는 행위와 메시지 큐에 이벤트를 보내는 행위는 서로 다른 시스템에서 일어납니다. 아래와 같은 시나리오를 생각해보면 이해가 쉽습니다.

  • case1. DB 업데이트 성공 → 이벤트 발행 실패인 경우
    네트워크 오류나 메시징 시스템 장애 시 데이터만 바뀌고 이벤트는 유실됩니다.
  • case2. 이벤트 발행 성공 → DB 업데이트 실패인 경우
    트랜잭션 롤백 시 데이터는 이전 상태인데 이벤트만 날아가 데이터 불일치가 심화됩니다.

시나리오를 보면 알 수 있듯 둘 다 실패하거나, 둘 다 성공해야 하지만, 하나의 원자적 트랜잭션으로 묶지 못하는 것이 분산 시스템에서 자주 일어나는 고질적인 문제입니다.

2. 해결방안: Transactional Outbox Pattern

이 문제를 해결하기 위해 Transactional Outbox Pattern을 도입했습니다. 핵심은 간단합니다. “이벤트를 메시지 큐로 바로 보내지 말고, 일단 DB 내의 보관함(Outbox) 테이블에 저장하자”는 것입니다.

동작 순서 설명
1. 비즈니스 로직 + 이벤트 저장 사용자의 데이터를 수정하는 로직과 이벤트를 EventOutbox 테이블에 기록하는 로직을 하나의 로컬 트랜잭션으로 묶습니다. 이로써 둘 다 성공하거나
둘 다 실패하게 됩니다.
2. 별도 프로세스 가동 별도의 스케줄러가 EventOutbox에서 아직 발행되지 않은 데이터를 읽어 실제 메시지 큐로 전송합니다.
3. 상태 업데이트 전송에 성공하면 상태를 변경하고(PUBLISHED), 실패하면 재시도 로직을 가동합니다. 상태값을 이용해 이벤트 발송 실패 상황을 방어합니다.

3. 구현 사례

실제 프로젝트에 적용한 코드를 통해 구체적인 구현 방식을 살펴보겠습니다.

3-1. 아웃박스 엔티티 설계

이벤트를 담을 그릇입니다. 어떤 이벤트인지(eventType), 데이터 본문(payload), 그리고 현재 상태와 재시도 횟수를 관리합니다.

// EventOutbox.java 내 핵심 필드
...
private String eventType;
private String payload; // JSON 형태의 데이터
private OutboxEventStatus status; // PENDING, PUBLISHED, FAILED
private int retryCount;
...

3-2. 트랜잭션 통합

기존에는 payload를 생성한 뒤 이벤트 발송을 하나의 스케줄러에서 수행했으나, 이제는 이벤트 발송을 미루고 3-1에서 정의한 엔티티를 사용하여 DB에 기록합니다.

// as-is
publisher.produceEvent(
    new ImportUserEvent(
        registeredUsers,
        modifiedUsers,
        reRegisteredUsers,
        transferredUsers,
        transferredAdminUsers,
        removedUsers
    )
);

// to-be
ImportUserEvent event = new ImportUserEvent(
    registeredUsers,
    modifiedUsers,
    reRegisteredUsers,
    transferredUsers,
    transferredAdminUsers,
    removedUsers
);

try {
    ...
    String payload = objectMapper.writeValueAsString(event);
    EventOutbox eventOutbox = new EventOutbox("ImportMemberEvent", payload);
    outboxRepository.save(eventOutbox);
} catch (JsonProcessingException e) {
    log.error("Error serializing ImportUserEvent to JSON", e);
    throw new RuntimeException(e);
}

특히 approval 서비스의 동기화 이력 등 관리해야 할 데이터 생성과 이벤트(EventOutbox 엔티티) 생성을 하나의 @Transactional 메서드 안에서 호출하도록 하여 원자성을 확보했습니다.

3-3. 별도의 이벤트 발송 스케줄러 구현

PENDING 상태의 이벤트를 찾아 실제 이벤트 발행 역할을 하는 메서드입니다. @Scheduled를 활용해 주기적으로 실행되며, 실패 시 재시도 전략을 수행합니다. 동기화 이벤트와는 충분한 간격을 두어 실행되도록 하고, 재시도 횟수는 retryCount 또는 crontab 설정을 통해 간단히 제어할 수 있습니다.

@Scheduled(cron = "...")
public void relayEvents() {
    ...
    List<EventOutbox> pendings = outboxRepository.findByStatus(OutboxEventStatus.PENDING);

    if (pendings.isEmpty()) {
        return;
    }

    for (EventOutbox outbox : pendings) {
        try {
            ImportUserEvent event = objectMapper.readValue(outbox.getPayload(), ImportUserEvent.class);
            publisher.publishEvent(event); // 실제 전송
            outbox.published(); // 성공 시 상태 변경
        } catch (Exception e) {
            outbox.increaseRetryCount(); // 실패 시 재시도 횟수를 올림
            if (outbox.isRetryLimitExceeded()) {
                outbox.failed();
            }
        }
    }
}

4. 도입 결과 및 추가 개선점

결과

  • 이벤트 유실 0%: 메시징 시스템에 일시적인 장애가 발생해도 DB에 기록이 남아 있으므로 시스템이 정상화되면 자동으로 재전송됩니다.
  • 데이터 정합성 보장: DB 데이터와 이벤트 발행이 하나의 트랜잭션으로 묶여 “데이터는 바뀌었는데 알림이 안 가는” 상황이 원천 차단되었습니다.

추가 개선점

이번 작업은 사실 운영적으로 더 개선할 포인트가 있습니다. 만약 retryCount가 3으로 제한되어 있고 최대치까지 실패한 이벤트는 어떻게 될까요? 상태가 FAILED가 되어 시스템은 자동으로 복구할 수 없고, 영원히 반영되지 않는 또 다른 문제가 발생합니다.

이를 모니터링하고 운영자에게 알림을 발송하는 기능을 추가 구축한다면, EventOutbox 테이블에 적재된 데이터를 기반으로 정합성을 더욱 안정적으로 유지할 수 있을 것입니다.

5. 마치며...

위 문제를 해결하며 앞으로도 염두에 두어야겠다고 느낀 점은, 네트워크는 믿을 수 없고 우리가 통제 불가한 외부 시스템(메시지 큐, 타 서비스 등)과의 통신은 언제든 실패할 수 있다는 가정하에 설계를 해야 한다는 것입니다.

MSA 환경에서 알고도 늘 마주치는 위와 같은 문제를 줄여 나가고, 믿을 수 있고 안정적인 서비스의 설계와 개발을 지향해야겠습니다.

sauce0127