[HOTSPOT] OutBox 패턴과 Debezium 도입

이재·2026년 3월 26일

HOTSPOT

목록 보기
3/5
post-thumbnail

직접 반영보다 커밋 이후를 기준으로 움직이는 구조를 택하다

들어가며

HotSpot은 가족 단위 데이터 사용량을 실시간으로 제어하는 서비스였다.

이 구조에서 Redis는 단순 캐시가 아니라 실제로 정책과 한도를 빠르게 판단하기 위한 실시간 상태 저장소 역할을 맡고 있었다. 사용자의 즉시 차단 여부, 요금제 한도, 가족 공유 상태, 선물 데이터, 시간대별 정책 등 서비스가 실시간으로 참고해야 하는 값들이 Redis에 반영되어 있어야 했다.

하지만 여기서 중요한 전제가 하나 있었다.

Redis는 빠르게 상태를 조회하고 판단하는 용도로 쓰이지만 실제 비즈니스 상태의 원천은 RDB(PostgreSQL)라는 점이다. 즉 서비스의 기준은 결국 DB이고 Redis는 그 상태를 실시간 처리를 위해 옮겨놓은 레이어에 가깝다.

문제는 이 구조에서 RDB와 Redis의 상태가 언제나 같아야 한다는 점이었다.

사용자 잠금 여부가 바뀌면 Redis에도 즉시 반영되어야 했고 가족 구성원이 추가되거나 제거되면 관련 상태들이 함께 갱신되어야 했으며 정책 적용이나 선물 데이터 변경도 누락 없이 동기화되어야 했다.

처음에는 “DB를 바꾼 뒤 Kafka로 이벤트를 보내고 Consumer가 Redis를 업데이트하면 되지 않을까?”라고 생각할 수 있다. 하지만 이 흐름을 자세히 들여다보면 생각보다 쉽게 정합성이 깨질 수 있는 구간이 많았다. 이 글은 바로 그 문제를 어떻게 바라봤고 왜 결국 Outbox 패턴과 Debezium CDC를 선택하게 되었는지에 대한 기록이다.


DB와 Redis는 둘 다 맞아야 한다

우리 시스템에서 DB는 실제 비즈니스 상태를 보관하는 Source of Truth 였고 Redis는 실시간 정책 판단을 위한 상태 저장소였다. 둘의 역할은 다르지만 결과적으로는 같은 상태를 표현하고 있어야 한다.

예를 들어 이런 변경들은 모두 Redis와 동기화되어야 했다.

  • 잠금 여부가 바뀌는 경우
  • 가족 구성원이 추가되거나 삭제되는 경우
  • 요금제 변경으로 한도가 바뀌는 경우
  • 선물 데이터가 들어오거나 소진되는 경우
  • 즉시 차단 반복 차단 시간 기반 차단 같은 정책이 적용되거나 해제되는 경우

겉보기에는 단순히 “DB 바꾸고 Redis도 바꾸면 되는 것 아닌가?”처럼 보일 수 있다. 하지만 실제로는 이 두 저장소를 항상 같은 상태로 유지하는 일이 그렇게 단순하지 않았다.

내가 고민한 핵심은 이것이었다.

Redis는 실시간 처리를 위해 빠르게 반응해야 하지만 그 기준은 항상 DB와 일치해야 한다.

즉 Redis만 빨리 갱신된다고 해결되는 문제가 아니었다. 어떤 상황에서도 DB와 Redis가 어긋나지 않게 만드는 구조가 필요했다.


단순 Kafka 직접 발행이나 Redis 직접 업데이트 방식이 불안했던 이유

처음 떠올릴 수 있는 방식은 두 가지였다.

하나는 DB 업데이트 후 애플리케이션이 직접 Kafka에 이벤트를 발행하고 Consumer가 Redis를 업데이트하는 방식이다. 다른 하나는 DB를 바꾼 뒤 애플리케이션이 Redis까지 직접 함께 반영하는 방식이다.

처음에는 둘 다 꽤 직관적으로 보였다. 하지만 조금만 더 들여다보면 둘 다 애플리케이션 레벨에서 너무 많은 책임을 떠안게 된다.

예를 들어 DB 업데이트는 성공했는데 Kafka 발행이 실패할 수 있다. 그러면 DB는 바뀌었지만 Redis는 여전히 예전 상태를 보고 있게 된다. 반대로 Kafka 발행까지는 성공했는데 이후 트랜잭션이 롤백된다면 더 위험하다. 실제로는 반영되지 않은 변경이 이벤트로 퍼져나가 Redis가 잘못된 상태를 갖게 될 수 있다.

Redis 직접 업데이트도 비슷하다. DB 반영은 성공했는데 Redis 업데이트만 실패하면 결국 동일한 정합성 문제가 생긴다. 이때부터는 재시도 로직, 실패 보상 로직, 중복 처리, 멱등성 보장까지 모두 애플리케이션이 책임져야 한다.

DB 변경과 이벤트 발행, 그리고 Redis 반영을 애플리케이션 코드에서 완전하게 보장하려는 것은 생각보다 훨씬 복잡하고 위험하다.

단순히 로직 몇 줄 추가해서 해결할 문제가 아니었다. 실패 구간이 많고 각 구간마다 재시도와 멱등성을 따로 설계해야 했으며 장애가 났을 때 어디까지 반영되었는지 설명하기도 어려웠다.

원한 건 DB 커밋이 기준이 되는 구조였다.


커밋된 변경만 확정적으로 흘려보내는 구조

정합성 문제를 계속 따라가다 보니 결국 필요한 조건은 단순해졌다.

  • DB가 커밋되지 않은 변경은 절대 이벤트로 나가면 안 된다
  • DB가 커밋된 변경은 유실 없이 반드시 이벤트로 이어져야 한다
  • 재시작 이후에도 끊긴 지점부터 이어서 처리할 수 있어야 한다
  • 이벤트 발행 로직이 애플리케이션 비즈니스 코드에 강하게 얽히지 않아야 한다

핵심은 “DB 변경이 확정된 이후에만 그 사실을 기반으로 이벤트가 흘러가게 만드는 것” 이었다.

이 관점에서 보니 애플리케이션이 직접 이벤트를 발행하는 방식은 구조적으로 아쉬움이 있었다. 비즈니스 로직 안에서 DB와 메시지 브로커를 동시에 신경 써야 하고 둘 사이의 실패 조합을 직접 다뤄야 하기 때문이다.

이때 눈에 들어온 것이 CDC(Change Data Capture) 였다. 그리고 그중에서도 PostgreSQL의 WAL을 읽어 커밋된 변경만 따라가는 Debezium 이 우리 문제와 잘 맞는다고 판단했다.


DB 커밋을 기준으로 움직이기 위해

Debezium이 매력적이었던 이유는 명확했다.

애플리케이션이 “이제 이벤트를 보내야지”라고 직접 판단하는 것이 아니라 DB 로그 레벨에서 커밋된 변경만 읽어간다는 점이었다.

이 말은 곧 다음을 의미한다.

  • 롤백된 변경은 이벤트로 나가지 않는다
  • 커밋된 변경만 순서대로 읽힌다
  • 재시작 시에도 이어서 복구 가능하다
  • 이벤트 발행이 애플리케이션 코드와 분리된다

이 구조를 보면서 나는 이게 단순히 편한 방식이 아니라 정합성 문제를 다루는 관점 자체를 바꾸는 선택이라고 느꼈다.

기존에는 애플리케이션이 “DB도 바꾸고, 이벤트도 보내고, Redis 반영까지 이어지게 해야 하는” 구조였다면 Debezium을 도입하면 애플리케이션은 DB를 올바르게 커밋하는 데 집중하고 이후의 이벤트 전파는 DB 로그를 기준으로 자동으로 이어지게 만들 수 있다.

책임이 더 명확해진다.

  • 애플리케이션: 상태를 정확히 바꾼다
  • Debezium: 커밋된 변경을 이벤트로 흘려보낸다
  • Consumer: 그 이벤트를 바탕으로 Redis를 맞춘다

모든 변경을 그대로 흘리는 건 오히려 과했다

Debezium을 이해한 뒤 처음 떠오른 생각은 간단했다.

“그럼 그냥 필요한 테이블 변화를 그대로 CDC로 읽으면 되지 않을까?”

하지만 여기서 또 다른 문제가 보였다. 비즈니스 테이블의 모든 변경이 Redis 동기화와 직결되는 것은 아니었다는 점이다. 예를 들어 하나의 테이블에는 잠금 여부, 요금제 정보, 삭제 여부, 수정 시각 등 다양한 컬럼이 함께 존재할 수 있다.

이 중 어떤 변경은 Redis 반영이 꼭 필요하지만 어떤 변경은 Redis와 무관할 수도 있다. 그런데 테이블 자체를 그대로 CDC로 따라가면 Redis 동기화와 상관없는 변경까지 전부 이벤트로 퍼질 수 있다.

이건 두 가지 면에서 좋지 않았다. 첫째, 불필요한 이벤트가 너무 많아진다. 둘째, Consumer가 “이 변경이 Redis 반영 대상인지 아닌지”를 다시 해석해야 하므로 책임이 뒤로 밀린다.

여기서 중요한 기준을 세웠다.

우리가 Kafka로 흘리고 싶은 것은 테이블의 모든 변경이 아니라,
Redis와 후속 시스템이 알아야 하는 ‘의미 있는 도메인 이벤트’여야 한다.

그래서 단순 CDC만 쓰는 대신 Outbox 패턴을 함께 도입하기로 했다.


의미 있는 변화만 이벤트로 보내기 위해

Outbox 패턴의 핵심은 단순하다.

실제 비즈니스 테이블을 변경하는 트랜잭션 안에서 그 변경의 의미를 담은 별도 이벤트 레코드를 함께 남기는 것이다.

흐름은 이렇게 바뀐다.

  • 실제 비즈니스 상태를 DB에서 변경한다
  • 같은 트랜잭션 안에 “이 변경이 어떤 의미를 가지는가”를 Outbox에 기록한다
  • 커밋이 완료되면 Debezium이 Outbox 테이블만 감지해 Kafka로 전달한다

이 구조가 좋았던 이유는 아주 분명했다.

첫째, DB 변경과 이벤트 기록이 하나의 트랜잭션 안에서 묶인다. DB만 성공하고 이벤트가 빠지거나 이벤트만 나가고 DB가 롤백되는 불일치 가능성을 크게 줄일 수 있다.

둘째, Kafka에 나가는 이벤트를 우리가 직접 설계할 수 있다. 단순히 컬럼 before/after를 퍼뜨리는 것이 아니라 잠금 적용, 가족 구성원 추가, 요금제 변경, 정책 적용처럼 의미 있는 이벤트만 보낼 수 있다.

셋째, Consumer가 훨씬 단순해진다. 이벤트를 다시 해석할 필요 없이 이미 의미가 정리된 payload를 보고 Redis 상태를 맞추면 된다.

이 지점에서 Outbox는 단순한 구현 패턴이 아니라 정합성을 다루는 동시에 이벤트의 의미를 통제하는 방식이라고 느꼈다.


Kafka와 Consumer의 역할도 더 명확해졌다

Outbox와 Debezium을 함께 놓고 보니 전체 흐름의 역할 분리가 훨씬 선명해졌다.

  • 애플리케이션은 DB 상태를 바꾸고 그 의미를 Outbox에 기록한다
  • Debezium은 Outbox를 감지해 Kafka로 전달한다
  • Kafka는 변경을 비동기적으로 전파하는 통로가 된다
  • Consumer는 해당 이벤트를 받아 Redis 상태를 갱신한다

각 단계가 더 이상 애매한 책임을 지지 않는다.

특히 좋았던 점은 토픽과 키 설계도 자연스럽게 정리되었다는 점이다. 회선 단위의 변화와 가족 단위의 변화를 분리해 흘릴 수 있었고 같은 대상에 대한 이벤트는 같은 파티셔닝 기준으로 순서를 유지하며 처리할 수 있었다.

이렇게 되면서 Redis 동기화도 단순히 “어떻게든 맞춘다”가 아니라 도메인 이벤트를 기준으로 안전하게 따라가는 구조가 되었다.


알림 발행도 같은 원칙으로

이 구조를 설계하면서 내가 중요하게 본 또 하나의 포인트는 알림도 같은 원칙 위에서 다뤄야 한다는 점이었다.

처음에는 Redis 정합성 문제와 알림 발행을 별개의 주제로 볼 수도 있었다. 하지만 실제로는 둘 다 본질적으로 같은 문제를 갖고 있었다.

예를 들어 정책이 적용되거나, 임계치가 발화되거나, 특정 상태가 변경되었을 때 알림을 보내야 한다고 하면 이때 애플리케이션이 비즈니스 로직 안에서 직접 메시지를 발행하면 앞서 봤던 것과 같은 문제가 그대로 반복된다.

  • DB는 반영됐는데 알림 발행이 실패할 수 있다
  • 메시지는 나갔는데 DB는 롤백될 수 있다
  • 재시도와 중복 방지를 애플리케이션이 또 직접 관리해야 한다

알림도 결국 “무엇이 커밋되었는가”를 기준으로 움직여야 하는 영역이었다. 그래서 알림 역시 Outbox를 통해 발행하는 방향을 택했다. 이렇게 하면 Redis 정합성 동기화와 마찬가지로 알림도 커밋된 사실에 기반해 안정적으로 전파할 수 있다.

상태 동기화도 알림 발행도

모두 “애플리케이션이 직접 책임지는 비동기 작업”이 아니라

커밋된 변경을 기준으로 이어지는 후속 처리로 바라보게 되었다.


정합성과 확장성 그리고 설명 가능성이 함께 좋아졌다

이 구조 전환 이후 가장 크게 얻은 것은 정합성을 다루는 기준이 명확해졌다는 점이다.

이전에는 애플리케이션이 DB와 Kafka, Redis 사이를 모두 조율해야 했다면 이제는 DB 커밋이 기준이 되고 그 이후 흐름은 Outbox와 Debezium을 통해 자연스럽게 이어진다. 덕분에 “어디서 실패하면 어떤 불일치가 생기는가”를 애플리케이션 코드 안에서 일일이 감당하지 않아도 된다.

또한 이벤트의 의미도 더 선명해졌다. 단순 테이블 변경이 아니라 도메인 이벤트를 흘리게 되면서 Consumer와 후속 시스템은 “무슨 일이 있었는가”를 더 직접적으로 이해할 수 있게 됐다. 이는 Redis 동기화뿐 아니라 알림, 추후 확장 기능, 운영 관점에서도 큰 장점이었다.

무엇보다 이 구조가 설명 가능한 시스템에 더 가깝다고 느꼈다. 어떤 상태 변경이 있었고 그것이 왜 이벤트로 기록되었으며 왜 Redis와 알림으로 이어졌는지를 흐름상 자연스럽게 설명할 수 있기 때문이다.


마무리

처음에는 DB를 바꾼 뒤 Kafka를 발행하거나 Redis를 직접 업데이트하는 방식도 충분해 보였다. 하지만 정합성을 정말 끝까지 보장하려고 하면 그 방식은 애플리케이션에 너무 많은 책임을 남긴다.

그래서 방향을 바꿨다.

  • DB 변경은 DB 트랜잭션 안에서 확정하고
  • 그 의미는 Outbox에 함께 기록하며
  • Debezium이 커밋된 변경만 Kafka로 흘려보내고
  • Consumer가 Redis와 후속 처리를 맞추도록 구조를 나눴다

그리고 알림 발행까지도 같은 원칙 위에 올려놓았다.

이 과정에서 배운 가장 큰 점은 정합성은 단순히 “같아 보여야 한다”의 문제가 아니라는 것이다.

정합성은 커밋을 기준으로 후속 시스템들이 같은 사실을 공유하게 만드는 구조의 문제다.

HotSpot에서 Outbox와 Debezium을 선택한 이유는 바로 여기에 있었다.

Redis를 빠르게 맞추기 위해서가 아니라 DB를 기준으로 시스템 전체가 같은 방향으로 움직이게 만들기 위해서였다.

profile
고민을 좋아하는 개발자

0개의 댓글