Outbox와 트랜잭션 로그를 이용한 탄력적인 이벤트 기반 마이크로서비스 구축
Min-jun Kim
Dev Intern · Leapcell

소개
마이크로서비스의 세계에서 분산 시스템 전반에 걸친 원활한 통신과 데이터 일관성을 달성하는 것은 끊임없는 과제입니다. 애플리케이션이 더 작고 독립적인 서비스로 디커플링되면서 안정적인 이벤트 전파의 필요성이 중요해집니다. 서비스가 로컬 상태 변경을 수행하고 해당 변경을 다른 서비스에 알리는 이벤트를 게시하려고 할 때 일반적인 함정이 발생합니다. 로컬 트랜잭션을 커밋하는 것과 이벤트를 성공적으로 게시하는 것 사이에 서비스가 충돌하면 어떻게 될까요? 데이터 불일치 및 누락된 이벤트는 전체 시스템을 빠르게 무너뜨릴 수 있습니다. 이 글에서는 'Outbox 패턴'과 데이터베이스 트랜잭션 로그 또는 폴링을 함께 사용하여 이러한 안정성 격차를 우아하게 해결하고, 탄력적이고 견고한 이벤트 기반 마이크로서비스를 육성하는 방법을 살펴봅니다. 우리는 기본 원칙, 실용적인 구현 및 이러한 기술이 고가용성 및 일관된 분산 아키텍처를 구축하는 데 수행하는 귀중한 역할을 탐구할 것입니다.
안정적인 이벤트 발행의 기반
세부 사항으로 들어가기 전에 이 논의의 기반을 형성하는 주요 용어에 대한 공통된 이해를 확립해 봅시다.
마이크로서비스: 애플리케이션을 느슨하게 결합된 독립적으로 배포 가능한 서비스 모음으로 구성하는 아키텍처 스타일입니다.
이벤트 기반 아키텍처(EDA): 느슨하게 결합된 소프트웨어 구성 요소가 이벤트를 비동기적으로 게시하고 구독하여 상호 작용하는 소프트웨어 설계 패러다임입니다.
트랜잭션 Outbox 패턴: 로컬 데이터베이스 트랜잭션의 원자적 실행과 해당 이벤트 게시를 보장하는 설계 패턴입니다. 이벤트를 직접 게시하는 대신, 해당 이벤트는 로컬 상태 변경과 동일한 데이터베이스 트랜잭션 내에서 전용 'Outbox' 테이블에 먼저 저장됩니다.
데이터베이스 트랜잭션 로그(변경 데이터 캡처 - CDC): 데이터베이스에 대한 모든 변경 사항의 순차적인 기록입니다. 많은 최신 데이터베이스(PostgreSQL, MySQL, SQL Server 등)는 복구 및 복제를 위해 이러한 로그를 유지 관리합니다. CDC 도구는 이러한 로그를 활용하여 기본 애플리케이션에 영향을 주지 않고 실시간 데이터 변경을 캡처합니다.
폴링: Outbox 패턴의 맥락에서, 이는 Outbox 테이블에서 아직 게시되지 않은 새 이벤트를 주기적으로 쿼리한 다음 메시지 브로커에 게시하는 별도의 프로세스를 참조합니다.
직접 이벤트 게시의 문제점
새 사용자를 생성한 다음 UserCreatedEvent를 발행해야 하는 UserService를 생각해 보세요.
// 간소화된 예시, 프로덕션 용이 아님 @Transactional public User createUser(User kullanıcı) { userRepository.save(kullanıcı); // 로컬 데이터베이스 트랜잭션 eventPublisher.publish(new UserCreatedEvent(user.getId())); // 이벤트 게시 시도 return kullanıcı; }
userRepository.save(user)가 커밋된 후 eventPublisher.publish()가 메시지를 성공적으로 보내기 전에 시스템이 충돌하면 사용자는 로컬에서 생성되지만 UserCreatedEvent는 전혀 게시되지 않습니다. 이 이벤트를 사용하는 다운스트림 서비스(예: 환영 이메일을 보내는 EmailService)는 알림을 받지 못하여 불일치 상태가 발생할 수 있습니다.
Outbox 패턴으로 해결
Outbox 패턴은 이 문제를 우아하게 해결하여 원자성을 보장합니다. 이벤트를 직접 게시하는 대신, 이벤트 세부 정보는 기본 비즈니스 로직과 동일한 데이터베이스 트랜잭션 내의 Outbox 테이블에 저장됩니다.
Outbox 테이블을 사용한 구현
Outbox 패턴으로 UserService 예시를 다듬어 보겠습니다.
먼저 Outbox 엔티티를 정의합니다.
// Outbox.java @Entity @Table(name = "outbox") public class Outbox { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "aggregatetype", nullable = false) private String aggregateType; @Column(name = "aggregateid", nullable = false) private String aggregateId; @Column(name = "eventtype", nullable = false) private String eventType; @Column(columnDefinition = "jsonb", nullable = false) // 또는 다른 DB의 경우 VARBINARY private String payload; // 이벤트의 JSON 표현 @Column(name = "createdat", nullable = false) private Instant createdAt; @Column(name = "processedat") private Instant processedAt; // 이벤트가 처리되었음을 표시하기 위해 // Getter 및 Setter }
이제 UserService는 Outbox를 통합합니다.
// UserService.java @Service public class UserService { private final UserRepository userRepository; private final OutboxRepository outboxRepository; private final ObjectMapper objectMapper; // JSON 직렬화용 public UserService(UserRepository userRepository, OutboxRepository outboxRepository, ObjectMapper objectMapper) { this.userRepository = userRepository; this.outboxRepository = outboxRepository; this.objectMapper = objectMapper; } @Transactional public User createUser(User user) throws JsonProcessingException { // 1. 로컬 비즈니스 로직 수행 userRepository.save(user); // 2. 이벤트 생성 UserCreatedEvent userCreatedEvent = new UserCreatedEvent(user.getId(), user.getEmail()); // 3. 동일한 트랜잭션 내에서 Outbox 테이블에 이벤트 저장 Outbox outboxEntry = new Outbox(); outboxEntry.setAggregateType("User"); outboxEntry.setAggregateId(user.getId().toString()); outboxEntry.setEventType("UserCreatedEvent"); outboxEntry.setPayload(objectMapper.writeValueAsString(userCreatedEvent)); outboxEntry.setCreatedAt(Instant.now()); outboxEntry.setProcessedAt(null); // 아직 처리되지 않음 outboxRepository.save(outboxEntry); return user; } }
이 접근 방식을 사용하면 트랜잭션이 성공적으로 커밋되면 사용자 및 Outbox 항목이 모두 영속화됩니다. 트랜잭션이 실패하면 둘 다 영속화되지 않습니다. 이는 원자성을 보장합니다.
이벤트 게시: 폴링 vs. 트랜잭션 로그 (CDC)
이벤트가 Outbox 테이블에 있으면 메시지 브로커(예: Kafka, RabbitMQ)에 안정적으로 게시해야 합니다. 이를 위한 두 가지 주요 전략이 있습니다.
-
폴링: 별도의 독립적인 프로세스(예약된 작업 또는 전용 마이크로서비스)가 주기적으로
outbox테이블에서 새로운, 미게시된 이벤트를 쿼리한 다음 이를 메시지 브로커에 게시합니다.// 예시 Poller Service (의사 코드) @Service public class OutboxPollerService { private final OutboxRepository outboxRepository; private final MessageBrokerPublisher messageBrokerPublisher; private final ObjectMapper objectMapper; public OutboxPollerService(OutboxRepository outboxRepository, MessageBrokerPublisher messageBrokerPublisher, ObjectMapper objectMapper) { this.outboxRepository = outboxRepository; this.messageBrokerPublisher = messageBrokerPublisher; this.objectMapper = objectMapper; } @Scheduled(fixedRate = 5000) // 5초마다 폴링 @Transactional public void processOutbox() { List<Outbox> unprocessedEvents = outboxRepository.findTop100ByProcessedAtIsNullOrderByCreatedAtAsc(); // 배치 가져오기 for (Outbox event : unprocessedEvents) { try { // 메시지 브로커에 게시 Object eventPayload = objectMapper.readValue(event.getPayload(), Class.forName(event.getEventType())); messageBrokerPublisher.publish(event.getEventType(), eventPayload); // 게시가 성공하면 처리됨으로 표시 event.setProcessedAt(Instant.now()); outboxRepository.save(event); // 안전을 위해 동일한 트랜잭션에서 업데이트 } catch (Exception e) { // 오류 로깅, 여기에서 재시도 메커니즘을 구현할 수 있음 // 중요: 게시가 실패하면 처리됨으로 표시하지 마십시오. // 폴러는 다음 실행 시 다시 가져올 것입니다. } } } }장점: 구현이 비교적 간단하고 특별한 데이터베이스 기능이 필요하지 않습니다. 단점: 폴링 간격에 따라 지연 시간이 더 길 수 있으며, 폴링 간격이 너무 짧거나 처리량이 매우 높으면 데이터베이스에 부하를 줄 수 있으며, 동시성 및 순서 보장을 신중하게 처리해야 합니다. 소비자 측의 중복 제거가 자주 필요합니다.
-
데이터베이스 트랜잭션 로그 (변경 데이터 캡처 - CDC): 이는 종종 선호되고 더 강력한 방법입니다. 폴링하는 대신 CDC 도구(예: Kafka용 Debezium 또는 데이터베이스별 CDC 솔루션)가
outbox테이블의 변경 사항을 모니터링하기 위해 데이터베이스의 트랜잭션 로그를 모니터링합니다.outbox에 항목이 삽입되면 CDC 도구가 이 변경 사항을 즉시 캡처하여 메시지 브로커로 스트리밍합니다.작동 방식:
- 애플리케이션은 이전과 같이
Outbox항목을 생성하고 저장합니다. - CDC 커넥터(예: Debezium)는 데이터베이스의 트랜잭션 로그(예: PostgreSQL의 WAL, MySQL의 binlog)에 연결됩니다.
- 커넥터는 로그를 지속적으로 읽고
outbox테이블에 대한 삽입을 감지합니다. - 새로운
outbox항목에 대해 커넥터는 메시지를 구성하고(종종 전체 행 데이터를 포함) Kafka 주제(또는 다른 메시지 브로커)에 게시합니다. - 별도의 '이벤트 디스패처' 서비스(또는 소비자 자체)는 이 Kafka 주제를 구독하고,
outbox항목을 읽고, 이를 비즈니스 이벤트로 변환한 다음, 관련 애플리케이션별 주제에 게시합니다. 그런 다음outbox항목은 일반적으로 테이블을 깔끔하게 유지하기 위해 다른 경량 프로세스에서 삭제되거나 게시됨으로 표시됩니다.
장점: 거의 실시간 이벤트 배달, 애플리케이션 데이터베이스에 미치는 영향 최소화(CDC 도구는 라이브 테이블이 아닌 로그에서 읽음), 안정적인 순서(이벤트는 로그에 커밋된 순서대로 스트리밍됨), 높은 확장성. 단점: 외부 CDC 도구가 필요하며, 설정 및 인프라 관리가 더 복잡하고, CDC를 지원하는 데이터베이스가 필요합니다.
- 애플리케이션은 이전과 같이
애플리케이션 시나리오
폴링 또는 CDC와 결합된 Outbox 패턴은 다음과 같은 상황에 이상적입니다.
- 원자적 이벤트 게시: 로컬 데이터베이스 트랜잭션과 관련 이벤트 게시가 모두 성공하거나 모두 실패하는 것을 보장해야 합니다.
- 서비스 간 통신: 서비스는 직접적인 결합 없이 다른 서비스에 상태 변경에 대해 안정적으로 통신해야 합니다.
- 이벤트 소싱 (부분): 완전한 이벤트 소싱은 아니지만, 모든 상태 변경이 이벤트로도 표현되도록 보장하는 디딤돌입니다.
- 명령-쿼리 책임 분리 (CQRS) 업데이트: CQRS 아키텍처에서 읽기 모델을 안정적으로 업데이트합니다.
결론
주기적 폴링 또는 더 정교한 접근 방식인 데이터베이스 트랜잭션 로그(CDC) 활용을 통해 구현되든, Outbox 패턴은 안정적인 이벤트 기반 마이크로서비스 구축의 초석입니다. 이는 로컬 트랜잭션 일관성과 분산 최종 일관성 사이의 격차를 효과적으로 연결하여 중요한 비즈니스 이벤트가 절대 손실되지 않고 분산 시스템이 일관성을 유지하도록 보장합니다. 이러한 패턴을 채택함으로써 개발자는 모든 커밋된 쓰기 작업이 해당 외부 알림을 안정적으로 트리거하는 고도로 탄력적이고 확장 가능한 아키텍처를 자신 있게 구축할 수 있습니다. 이 패턴은 마이크로서비스가 효과적으로 통신할 수 있도록 하여 전체 분산 환경에서 데이터 무결성을 유지합니다.

