[미래를 담아낸 뼈대 2/7] 모듈 경계를 넘는 이벤트

기술 블로그
페이스북링크드인트위터

같은 구조라서 가능한 일

지난 글에서 flex 백엔드의 모든 도메인이 Hexagonal Modular Monolith라는 동일한 구조를 따른다는 이야기를 했습니다. Gradle Convention Plugin이 이 구조를 컴파일 타임에 강제하고, 'flex-skeleton' 템플릿이 출발점을 통일한다고요.

이번에는 그 일관성이 만들어낸 구체적인 결과물을 이야기해 보겠습니다.

HR SaaS에서 도메인 간 데이터 전달은 일상입니다. 구성원 정보가 바뀌면 급여에 반영되어야 하고, 조직 변경은 권한과 근무 정책에 영향을 줍니다. 평가 시즌이 되면 구성원, 조직, 목표 데이터가 한꺼번에 얽힙니다. 문제는, 이 데이터 흐름이 단순한 API 호출 한 번으로 끝나지 않는다는 겁니다.

예를 들어봅시다. 구성원의 소속 부서가 변경되었습니다. 이 변경은 인사 도메인에서 시작하지만, 그 영향은 훨씬 넓습니다. 근무 정책이 바뀔 수 있고, 급여 정산 기준이 달라질 수 있고, 해당 구성원이 접근할 수 있는 데이터의 범위도 변합니다. 이 모든 도메인이 "부서가 바뀌었다"는 사실을 알아야 합니다. 그런데 만약 인사 도메인이 DB 트랜잭션은 성공적으로 커밋했는데, 다른 도메인에 알려주는 이벤트가 유실된다면? 인사 시스템에는 부서가 바뀌어 있는데, 급여 시스템에서는 이전 부서 기준으로 정산이 되는 상황이 벌어집니다. 분산 환경에서 이런 데이터 불일치는 HR 서비스에서 치명적입니다.


왜 Outbox인가 — 버린 선택지들

이 문제를 풀 수 있는 후보는 여러 가지가 있었습니다.

가장 단순한 접근은 Dual Write입니다. DB에 저장하고, 바로 이어서 Kafka에도 메시지를 보내는 것. 하지만 이건 근본적으로 두 개의 독립된 시스템에 동시에 쓰는 것이기 때문에, 둘 중 하나가 실패하면 불일치가 생깁니다. DB 커밋은 됐는데 Kafka 발행이 실패하면? 혹은 Kafka에는 갔는데 DB 커밋이 롤백되면? 재시도하면 중복이 생깁니다. 원자성을 구조적으로 보장할 수 없습니다.

Polling 방식도 있습니다. 발행할 이벤트를 테이블에 쌓아두고, 주기적으로 폴링해서 Kafka로 보내는 것. 구현은 간단하지만, 폴링 주기에 따라 지연이 생기고, 여러 인스턴스가 동시에 폴링하면 순서 보장이 깨집니다.

flex가 선택한 건 Transactional Outbox + CDC 조합입니다. 이 조합만이 "DB 트랜잭션과 이벤트 발행의 원자성"을 구조적으로 보장하면서, 실시간에 가까운 전파와 순서 보장을 동시에 달성할 수 있었습니다.


Transactional Outbox — 이벤트를 잃어버리지 않는 구조

flex는 이 문제를 Transactional Outbox 패턴과 Debezium CDC를 조합해서 풀었습니다.

원리는 이렇습니다. 도메인 로직이 DB에 데이터를 저장할 때, 같은 트랜잭션 안에서 Outbox 테이블에도 이벤트를 기록합니다. "부서를 변경했다"는 비즈니스 로직과 "부서 변경 이벤트를 발행한다"는 메시징 로직이 하나의 트랜잭션 안에서 원자적으로 처리됩니다. 트랜잭션이 커밋되면 둘 다 저장되고, 롤백되면 둘 다 취소됩니다. "처리는 됐는데 이벤트는 안 날아간" 상황이 구조적으로 불가능합니다.

그 다음은 Debezium이 합니다. Debezium은 DB의 변경 로그(Change Data Capture)를 감시하다가, Outbox 테이블에 새 레코드가 생기면 이를 Kafka 토픽으로 전달합니다. 애플리케이션 코드가 Kafka에 직접 메시지를 보내는 게 아니라, DB에 쓰기만 하면 CDC가 알아서 이벤트를 전파하는 구조입니다.

이 구조의 장점은 명확합니다. 첫째, 데이터 변경과 이벤트 발행의 일관성이 DB 트랜잭션 수준에서 보장됩니다. 둘째, 애플리케이션은 Kafka에 대해 직접 알 필요가 없습니다. DB에 저장하는 것만 신경 쓰면 됩니다. 셋째, Debezium이 중간에서 버퍼 역할을 하기 때문에, Kafka가 일시적으로 불안정해도 이벤트가 유실되지 않습니다.


이벤트 파이프라인 한눈에 보기

핵심: 비즈니스 데이터와 이벤트가 같은 트랜잭션에서 원자적으로 기록되고, CDC가 이를 Kafka로 전파합니다. 애플리케이션은 DB에 쓰기만 하면 됩니다.


받는 쪽도 한 번만 — 멱등 컨슈머

Outbox + CDC로 "보내는 쪽"의 신뢰성은 해결됐습니다. 하지만 이건 이야기의 절반입니다. 이벤트가 At Least Once(최소 한 번) 전달되기 때문에, 네트워크 재시도나 컨슈머 재시작 시 같은 이벤트가 두 번 도착할 수 있습니다. 받는 쪽에서 중복을 걸러내지 못하면, 급여가 두 번 정산되거나, 알림이 두 번 발송되는 상황이 벌어집니다.

flex에서는 이 문제도 공통 라이브러리가 해결합니다. 이벤트 포맷으로 CloudEvents 표준을 채택했는데, 모든 이벤트가 고유한 'id' 필드를 가집니다. 컨슈머 라이브러리는 이 'id'를 기준으로 이미 처리한 이벤트를 식별하고, 중복 수신 시 자동으로 무시합니다. 재처리 정책과 실패 시 DLQ(Dead Letter Queue) 처리까지 라이브러리가 제공합니다.

개별 도메인 팀이 멱등성을 직접 구현할 필요가 없습니다. 모든 컨슈머가 같은 라이브러리 위에서 같은 방식으로 이벤트를 처리하기 때문에, "At Least Once Produce, Exactly Once Consume"이 인프라가 보장하는 기본값이 됩니다.


라이브러리로 작동하는 이유 — 1화와의 연결

여기서 지난 글의 이야기가 연결됩니다. 이 Transactional Outbox 패턴을 모든 도메인 팀이 각자 구현했을까요? 아닙니다. flex에서는 이걸 공통 라이브러리 수준에서 제공하고 있습니다.

이게 가능한 이유는 구조의 일관성입니다. 모든 도메인이 같은 헥사고날 구조를 따르기 때문에, Adapter 계층의 위치가 동일하고, 트랜잭션 경계가 예측 가능하며, 모듈 간 의존성 방향이 통일되어 있습니다. 이 전제가 있으니까, 공통 모듈 하나로 모든 도메인 팀이 동일한 방식으로 이벤트를 발행할 수 있는 겁니다.

만약 도메인마다 구조가 다르다면 어떨까요? 어떤 팀은 트랜잭션 경계가 서비스 레이어에 있고, 어떤 팀은 컨트롤러에 있고, 어떤 팀은 레포지토리에 트랜잭션을 걸어놨다면, 공통 Outbox 라이브러리가 "기대한 대로" 동작하리라는 보장이 없습니다. 라이브러리를 만들 수는 있겠지만, 각 팀마다 다른 설정과 예외 처리가 필요할 테고, 결국 "공통"이라는 말이 무색해집니다.

flex에서는 이 문제가 없습니다. Gradle 플러그인과 flex-skeleton이 구조를 강제하고 있기 때문에, Outbox 라이브러리가 어느 도메인에 붙든 동일하게 작동합니다. 개별 도메인 팀은 이벤트 인프라의 내부를 신경 쓸 필요가 없습니다. "이 이벤트를 발행하겠다"는 의도만 코드로 표현하면, 나머지는 인프라가 처리합니다. 신뢰성이 개인의 역량이 아니라 시스템의 속성이 되는 순간입니다.


이제 이벤트가 신뢰성 있게 흐르는 레일이 깔렸습니다. 발행은 Outbox + CDC가, 소비는 멱등 컨슈머 라이브러리가, 그리고 이 모든 것을 가능하게 한 것은 1화의 구조적 일관성입니다. 도메인 A에서 일어난 변경이, 구조적으로 보장된 방식으로 도메인 B, C, D에 전달됩니다. 이 레일 위에 무엇을 올릴 수 있을까요?

다음 화에서는,
HR에서 가장 어려운 문제인 "권한"을 이 레일 위에 올립니다. 역할이 아닌 관계로 접근 권한을 판단하는, ReBAC 기반 통합 인가 이야기를 해보겠습니다.

플렉스팀 채용페이지 바로가기
글이 마음에 드셨나요?
공유하기
페이스북링크드인트위터
flex가 궁금하다면? 지금 무료로 체험해 보세요
flex가 궁금하다면? 지금 무료체험하기