← все видео

분산 시스템을 위한 아웃박스(Outbox)패턴에 대하여

개발자 팀 · 2026-04-14 · 12м 20с · 211 просмотров · YouTube ↗

Топики: durable-execution

🎧 Аудио

📝 Summary

model=deepseek-v4-flash · prompt=summary-v7 · 6 178→2 557 tokens · 2026-07-20 15:11:50

🎯 Главная суть

Паттерн Outbox решает проблему dual write в микросервисах, когда нужно атомарно сохранить бизнес-данные в БД и отправить событие в очередь (например, Kafka). Вместо распределённой транзакции (2PC, которая медленна и не поддерживается Kafka) бизнес-данные и событие записываются в одну локальную транзакцию, а отдельный процесс (polling или CDC) читает outbox-таблицу и доставляет события в очередь. Гарантируется at-least-once delivery, поэтому потребитель должен быть идемпотентным.

Dual Write — корень проблемы

Если приложение сначала сохраняет заказ в БД, а затем отправляет событие в Kafka, возникает риск: БД закоммитилась, но отправка события не удалась (сетевая ошибка, сбой). Заказ есть, а событие потеряно — уведомления и расчёты не сработают. Если поменять порядок (сначала Kafka, потом БД), возможна обратная ситуация: событие отправлено, но БД откатилась, и «заказа нет, а уведомление пришло». Причина — БД и очередь — независимые системы, которые нельзя объединить в одну транзакцию. Встроенный @TransactionalEventListener(phase=AFTER_COMMIT) не спасает: между коммитом и отправкой остаётся микроскопический разрыв, достаточный для потери события при падении приложения.

Традиционный подход — двухфазный коммит (2PC/EPC) — медленный, сложный и не поддерживается Kafka. Outbox-паттерн даёт практичное решение без 2PC.

Структура Outbox-таблицы

Таблица outbox_event содержит минимум такие поля:

Обязательный композитный индекс по (status, created_at) — иначе polling-запросы будут делать Full Table Scan.

Реализация на Spring: сохранение в одной транзакции

Код сервиса заказа:

@Transactional
public void createOrder(...) {
  Order order = orderRepository.save(orderData);
  OutboxEvent event = OutboxEvent.builder()
    .aggregateType("ORDER")
    .aggregateId(order.getId())
    .eventType("ORDER_CREATED")
    .payload(toJson(order))
    .status(Status.PENDING)
    .build();
  outboxRepository.save(event);
  // Нет вызова KafkaTemplate.send()!
}

Ключевой момент — вся отправка в Kafka исключена из этого метода. Если транзакция коммитится, то и заказ, и запись outbox гарантированно сохраняются. Отправкой занимается отдельный компонент — relay process. Это разделение ответственности.

Polling Publisher

Самый простой relay — планировщик с @Scheduled(fixedDelay = 1000):

Плюсы: простота, не требует дополнительной инфраструктуры (только Spring Scheduling). Минусы: задержка равна периоду опроса (1–5 с).

CDC (Debezium)

Вместо polling Change Data Capture читает бинарный лог БД (MySQL binlog / PostgreSQL WAL) и отправляет изменения в Kafka практически в реальном времени. Debezium (на базе Kafka Connect) — популярный open-source инструмент. Задержка — десятки–сотни миллисекунд, не требуется изменять код приложения. Для Outbox-паттерна Debezium предоставляет встроенный SMT (Single Message Transform), который автоматически публикует событие в нужный топик.

Минусы: сложность инфраструктуры (Kafka Connect + Debezium Connector).

Выбор на практике: начинать с polling (быстрый запуск), при росте трафика или требованиях к low-latency переходить на CDC.

Идемпотентность потребителя

Оба способа доставки (polling и CDC) гарантируют at least once — одно и то же сообщение может прийти повторно. Потребитель обязан проверять, не обработано ли уже событие, используя eventId из заголовков сообщения.

Пример кода:

@Transactional
public void consume(Message msg) {
  String eventId = msg.getHeaders().getId();
  if (processedEventRepository.existsById(eventId)) return;
  // бизнес-логика
  processedEventRepository.save(new ProcessedEvent(eventId));
}

Важно, чтобы запись о завершении обработки была в той же транзакции, что и бизнес-логика, иначе при сбое после выполнения логики повторное сообщение приведёт к дублированию.

Инцидент с производительностью: как завал таблицы ломает relay

Реальный кейс: polling-процесс исправно работал с 1-секундным интервалом, но внезапно задержки выросли до 5 минут. Причина — отсутствовал индекс на status+created_at, а записи со статусом SENT не удалялись. Таблица разрослась до миллионов строк, каждый запрос PENDING-записей делал полный скан, занимая секунды. Решение:

  1. Добавить композитный индекс.
  2. Завести планировщик для удаления/архивации отправленных записей.
  3. Мониторить количество PENDING-записей через метрики (при всплеске — алерт).

Стратегии очистки Outbox-таблицы

Стратегия Описание Когда подходит
Немедленное удаление Удалять запись сразу после успешной отправки История не нужна, минимальный объём
Пакетное удаление Снимать SENT-записи старше N дней (например, 7) Нужно хранить историю ограниченное время
Смена партиций Партицировать таблицу по дате, дропать старые партиции Очень большие объёмы данных, минимальные затраты на DELETE
Архивация Переносить запись в отдельную архивную таблицу, затем удалять Финансовые/аудиторские требования (долгое хранение)

В любом случае обязательно регулярно чистить SENT-записи. FAILED-записи требуют ручного разбора и оповещения.

Практический чеклист

  1. Одна транзакция — бизнес-данные и outbox-запись сохранять в одном @Transactional.
  2. Индекс(status, created_at) обязателен.
  3. Идемпотентность потребителя — проверка по eventId в той же транзакции, что и бизнес-логика.
  4. Очистка — регулярное удаление SENT-записей (планировщик).
  5. Мониторинг — метрика количества PENDING-записей и алерт при росте.
  6. Постепенный переход — начать с Polling Publisher, при увеличении нагрузки переходить на CDC (Debezium).

📜 Transcript

ko · 1 350 слов · 27 сегментов · clean

Показать текст транскрипта
안녕하세요 개발자 팀입니다. 오늘은 멀티모질 마이크로 서비스에서 자주 사용되는 아웃박스 패턴을 심층적으로 파헤쳐 보겠습니다. 마이크로 서비스 환경에서 데이터베이스에 데이터를 저장한 뒤 카프카 같은 메시지 큐로 이벤트를 발행하는 건 정말 흔한 패턴인데요. 그런데 DB 커밋은 성공했는데 카프카 전송이 실패하면 어떡하지? 라는 질문에 확실하게 답할 수 있으신가요? 이 문제를 듀얼라이트 문제라고 부르는데 아웃박스 패턴은 이 문제를 DB 트랜잭션만으로 깔끔하게 해결하는 핵심 패턴입니다. 오늘 다룰 내용들을 살펴보겠습니다. 먼저 왜 아웃박스 패턴이 필요한지 듀얼라이트 문제로 시작해서 패턴의 핵심 구조와 테이블 설계를 살펴봅니다. 중반부에서는 스프링 구현, 폴링 퍼블리셔와 CDC 방식을 비교하고, 멱등 소비자 구현까지 코드로 다루고요. 후반부에서 프로덕션 장애 사례, 테이블 정리 전략까지 실무에서 바로 적용할 수 있는 내용으로 채웠습니다. 이런 상황을 가정해 보겠습니다. 주문 서비스에서 주문 데이터를 DB에 저장한 후 카프카로 주문 완료 이벤트를 발행하는 코드가 있어요. DB 커밋은 성공했는데 카프카 전송 직전에 네트워크 장애가 발생하면 어떻게 될까요? 주문은 DB에 존재하지만 이벤트가 유실되어서 알림도 안 가고 정산도 안 되는 거예요. 반대 순서로 카프카를 먼저 보내고 DB를 저장하면? 카프카 전송은 성공했는데 DB가 롤백 되면 알림은 갔는데 주문이 없는 상황이 벌어집니다. 근본 원인은 DB와 메시지 Q가 별도의 시스템이라서 두 곳을 하나의 트랜잭션으로 묶을 수 없다는 거예요. 애트랜잭셔널 이벤트 리스너의 애프터 커밋 방식도 커밋과 카프카 전송 사이에 틈이 있어서 같은 문제가 발생합니다. 다이어그램을 보시면 듀얼라이트 문제가 시각적으로 나와 있어요. 애플리케이션이 DB 저장에 성공한 후 MQ로의 전송이 실패하는 시나리오입니다. DB에는 주문 데이터가 있는데 이벤트는 유실된 거죠. 이 문제를 해결하는 전통적인 방법이 2페이즈 커밋, 즉 EPC인데요. DB와 MQ를 분산 트랜잭션으로 묶는 방식이에요. 하지만 EPC는 매우 느리고 복잡하며, 카프카 같은 현대적 메시지 Q는 EPC를 지원하지도 않습니다. 아웃박스 패턴은 EPC 없이 DB 트랜잭션만으로 원자성을 보장하는 실용적인 대안이에요. 다이어그램을 보시면 아웃박스 패턴의 전체 구조가 나와있어요. 핵심은 두 가지입니다. 첫째, 비즈니스 데이터와 이벤트 메시지를 같은 DB 트랜잭션으로 저장합니다. 주문 테이블과 아웃박스 테이블에 동시에 인서트하는 거죠. 같은 트랜잭션이니까 DB가 커밋되면 아웃박스의 메시지가 반드시 존재하고 롤백되면 둘 다 사라집니다. 둘째, 별도의 릴레이 프로세스가 아웃박스 테이블을 읽어서 MQ로 전송합니다. 콜링 방식이면 스케줄러가 주기적으로 조회하고 CGC 방식이면 DB 지엄이 바이너리 로그를 읽어서 카프카로 보내요. 결과적 일관성이기 때문에 즉시 전달은 아니지만 결국에는 MQ의 메시지가 도착한다는 것을 보장합니다. 코드를 보시면 아웃박스 이벤트 테이블의 SQL 스키마입니다. Aggregate 타입은 도메인 유형, 예를 들어 오더나 페이먼트를 나타내고 이걸 카프카의 토픽으로 사용합니다. Aggregate ID는 도메인 ID로 카프카의 파티션 키로 활용하면 같은 주문의 이벤트가 같은 파티션의 순서대로 들어가요. 이벤트 타입은 오더 크리에이티드, 오더 캔슬드 같은 이벤트 종류이고 페이로드의 실제 이벤트 데이터를 제이슨으로 직렬화해서 저장합니다. 스테이터스는 펜딩, 센트, 페일드 세 가지 상태를 가지고 위트라이 카운트로 제시도 횟수를 관리해요. 인덱스는 스테이터스와 크리에이티드에 대해 복합 인덱스가 필수인데 폴링 커리에서 펜딩 상태의 레코드를 빠르게 찾기 위해서입니다. 코드를 보시면 오더 서비스에서 주문 생성과 아웃박스 이벤트 저장을 같은 애 트랜잭션을 안해서 처리하고 있어요. orderRepository.save로 주문을 저장하고, OutboxRepository.save로 이벤트를 저장합니다. 여기서 핵심은 MQ에 직접 전송하지 않는다는 점이에요. KafkaTemplate.send 같은 코드가 없습니다. DB에만 저장하고 끝이에요. DB 커밋이 성공하면 Outbox 레코드가 반드시 존재하니까 이벤트 유실이 원천적으로 불가능합니다. MQ로의 실제 전송은 이 메서드의 책임이 아니라 릴레이 프로세스가 담당해요. 관심사 분리도 깔끔하게 이루어지는 거죠. 바이어그램을 보시면 폴링 퍼블리셔의 동작 흐름이 나와 있어요. 스케줄러가 주기적으로 실행되면서 아웃박스 테이블에서 펜딩 상태인 레코드를 조회합니다. 레코드가 있으면 MQ로 메시지를 전송하고 전송 성공하면 상태를 센트로 변경해요. 실패하면 위 트라이 카운트를 증가시키고 다음 주기에 재시도합니다. 최대 재시도 횟수를 초과하면 페일드로 변경하고 운영팀에 알림을 보내는 구조예요. 이 방식은 구현이 간단하고 별도 인프라가 필요없어요. 스프링에 에스케줄드만으로 구현할 수 있으니까요. 다만 폴링 주기만큼 지연이 발생한다는 트레이드 오프가 있습니다. 1초 주기로 폴링하면 최대 1초의 전달 지연이 생기는 거죠. 다음은 아웃박스 릴레이 스케줄러 클래스의 코드입니다. 에스케줄드에 픽스트 딜레이 1000으로 1초마다 실행되도록 설정했어요. 아웃박스 리포지토리에서 펜딩 상태의 레쿠드를 100건씩 조회하고 각 이벤트를 카프카로 전송합니다. 카프카 템플릿에 샌드 메서드의 에그리게이트 타입을 토픽으로 에그리게이트 아이디를 키로 페이로드를 값으로 넘기고 있어요. 전송에 성공하면 마크센트를 호출하고 실패하면 인크리먼트 리트라이를 호출합니다. 애트랜잭셔널이 붙어있으니까 상태 변경도 안전하게 커밋되고요. 100건씩 배치로 처리하는 이유는 한 번에 너무 많은 레코드를 가져오면 DB에 부하가 걸리기 때문이에요. CGC는 Change Data Capture의 약자로 DB의 변경 로그를 실시간으로 캡처하여 전달하는 방식이에요. 표를 보시면 MySQL의 바이너리 로그나 PostgreSQL의 월을 직접 읽어서 카프카로 보내는 원리입니다. 대표 도구가 DB지엄인데요. 카프카 커넥트 기반의 오픈소스 CDC 플랫폼이에요. 지연 시간이 수십에서 수백 밀리처로 폴링보다 훨씬 빠르고 애플리케이션 코드를 수정할 필요도 없습니다. DB로그만 읽으니까요. 다만 카프카 커넥터와 디비지엄 커넥터를 별도로 설치하고 운영해야 하는 인프라 복잡도가 트레이드 오프입니다. 디비지엄은 아웃박스 패턴 전용 S&T, 즉 싱글 메시지 트랜스폼도 제공해서 아웃박스 테이블의 인서트를 감지하면 자동으로 적절한 카프카 토픽에 메시지를 발행해줍니다. 표를 보시면 두 방식을 비교하고 있어요. 폴링은 지연시간이 폴링 주기에 의존하는데 보통 1초에서 5초 정도에요. CDC는 수십에서 수백 밀리초로 거의 실시간에 가깝습니다. DB 부하면에서 폴링은 주기적으로 셀렉트 쿼리를 실행하니까 아웃박스 테이블이 커지면 부하가 올라가지만 CDC는 바이너리 로그를 읽는 방식이라 상대적으로 경량이에요. 인프라 측면에서 폴링은 스프링 스케줄러만 있으면 되니까 추가 설치가 필요 없고 CDC는 카프가 커넥트와 디비지엄을 운영해야 합니다. 실무접으로는 처음에 폴링으로 빠르게 도입하고 트래픽이 커지거나 실시간 요구가 생기면 CDC로 전환하는 것을 권장해요. 두 방식 모두 엘리스트 원스 전달이라서 같은 메시지가 두 번 올 수 있으니까 소비자 쪽에서 반드시 역등성을 보장해야 합니다. 코드를 보시면 카프카 컨슈머에서 역등성을 보장하는 방법입니다. 메시지 헤더에서 이벤트 아이디를 추출하고 프로세스드 이벤트 리포지토리에서 이미 처리한 이벤트인지 확인합니다. 이미 처리했으면 바로 리턴하고 처리하지 않았으면 비즈니스 로직을 실행한 후 처리 완료를 기록해요. 같은 애트랜잭셔널로 묶는 게 중요한데요. 비즈니스 로직이 성공했는데 처리 기록이 안 되면 다음에 같은 메시지가 왔을 때 또 실행되니까요. At least once이기 때문에 이 몇 등 소비자 패턴은 아웃박스 패턴을 사용할 때 반드시 함께 적용해야 합니다. 코드를 보시면 애트랜잭셔널 안에서 DB 저장 후 카프카에 직접 전송하는 기존 방식이에요. 애트랜잭셔널 이벤트 리스너의 애프터 커밋 방식도 결과적으로 같은 문제를 가지고 있어요. DB 커밋은 성공했는데 카프카 템플릿.send가 실행되기 직전에 애플리케이션이 다운되면 이벤트가 유실됩니다. 커밋과 카프카 전송 사이의 시간 간격은 매우 짧지만 프로덕션에서 수백만 건을 처리하다 보면 이 짧은 틈에도 장애가 발생할 확률이 있습니다. 결제나 정산처럼 이벤트 유실이 허용되지 않는 도메인에서는 이 방식으로는 부족합니다. 코드를 보시면 MQ에 직접 전송하는 코드가 완전히 사라졌어요. 아웃박스 리포지토리 다 세이브만 있고 둘 다 같은 트랜잭션 안에서 실행됩니다. DB가 커밋되면 아웃박스 레코드가 반드시 존재하고 릴레이 프로세스가 나중에 카푸카로 전달합니다. 커밋에서 전송까지 시간 간격이 있지만 이건 지연이지 유실이 아니에요. 폴링 주기가 1초면 최대 1초 뒤에 카푸카에 도착하는 것이지 절대 사라지지 않습니다. 트레이드 오픈은 즉시 전달이 아닌 결과적 일관성이라는 점인데 대부분의 비즈니스에서 1초 지연은 충분히 허용 가능합니다. 이런 상황을 가정해보겠습니다. 아웃박스 폴링 프로세스가 1초 주기로 잘 동작하다가 어느 날부터 카프카 소비자 쪽에서 이벤트 지연이 5분 이상으로 증가했어요. 결제정산 서비스가 이벤트를 제때 못 받아서 정산 불일치가 발생했습니다. 원인을 파헤쳐보니 아웃박스 테이블에 인덱스가 빠져있었어요. 팬딩 상태 조회 시 풀테이블 스캔이 발생한 거죠. 거기에 발행 완료된 센트 레코드를 삭제하지 않고 계속 쌓아두니까 테이블이 수백만건으로 비대해졌고 폴링 쿼리 자체가 수초 걸리면서 전체 릴레이가 지연된 겁니다. 해결은 세 가지를 적용했어요. status plus created at 복합인덱스를 추가하고 발행 완료된 레코드를 주기적으로 삭제하는 정리 스케줄러를 추가하고 펜딩 건수를 매트릭으로 수집해서 급증시 알림이 오도록 모니터링을 설정했습니다. 표를 보시면 아웃박스 테이블을 정리하는 4가지 전략이 있어요. 가장 간단한 건 발행 성공 시 바로 딜리트하는 즉시 삭제 방식이에요. 이력 보관이 필요 없으면 이 방식이 가장 효율적입니다. 배치 삭제는 스케줄러로 센트 상태이면서 7일이 경과한 4코드를 주기적으로 삭제하는 방식인데 이력을 일정 기간 보관해야 할 때 적합해요. 파티션 교체는 날짜별로 테이블 파티션을 나누고 과거 파티션을 드롭하는 방식인데 대량 데이터를 다룰 때 삭제 비용을 최소화할 수 있습니다. 아카이브는 별도 테이블로 옮긴 후 삭제하는 건데 감사 추적이 필요한 금융 서비스에 적합해요. 핵심은 아웃박스 테이블이 비대해지면 폴링 쿼리 성능이 급격히 떨어진다는 거예요. 어떤 전략이든 최소한 센트 상태의 레코드를 주기적으로 정리하는 스케줄러는 필수입니다. 페일드 상태 레코드는 별도 알림과 함께 수동 조치 대상으로 관리해야 하고요. 마지막으로 오늘 내용을 정리하겠습니다. 듀얼라이트 문제는 DB와 MQ를 동시에 쓸 때 원자성이 보장되지 않는 문제이고, 아웃박스 패턴은 비즈니스 데이터와 이벤트를 같은 DB 트랜잭션으로 저장하여 이 문제를 해결합니다. 릴레이 프로세스가 아웃박스를 읽어서 MQ로 전달하는 별도 컴포넌트 역할을 하구요. 홀링 퍼블리션은 스케줄러로 주기적 조회하는 간단한 방식이고, CDC는 DB 지엄으로 바이너리 로그를 실시간 캡처하는 빠르지만 인프라가 복잡한 방식입니다. 엘리스트 원스 전달이므로 소비자 쪽 격등성 처리가 필수이고 테이블 정리를 안 하면 성능이 급격히 떨어집니다. 실전에서 활용 가능한 체크리스트를 살펴보겠습니다. 첫째, 비즈니스 테이블과 아웃박스 테이블을 반드시 같은 애트랜젝셔널로 묶으세요. 이게 아웃박스 패턴의 핵심이에요. 둘째, 아웃박스 테이블에 스테이터스 플러스 크리에이티드 앳 복합 인덱스를 반드시 추가하세요. 없으면 폴링 쿼리가 풀 테이블 스캔을 하면서 성능이 급락합니다. 셋째, 소비자 쪽에서 이벤트 아이디 기반 중복 처리 방어를 반드시 구현하세요. 엘리스트 원스니까요. 넷째, 센트 레코드를 주기적으로 삭제하는 스케줄러를 반드시 설정하세요. 다섯째, 펜딩 권수를 매트릭으로 수집하고 급증하면 알림이 오도록 모니터링을 설정하세요. 릴레이 지연을 조기에 감지할 수 있습니다. 마지막으로 폴링으로 시작하고 트래픽이 커지면 CDC로 전환하는 점진적 전략을 권장합니다. 오늘의 아웃박스 패턴에 대한 이야기는 여기까지입니다. 여러분의 구독과 좋아요는 영상 제작에 정말 큰 힘이 됩니다. 시청해주셔서 감사드립니다.

⚙️ Pipeline jobs

StageStatusAtt.UpdatedError
download done 1/3 2026-07-20 15:11:12
transcribe done 1/3 2026-07-20 15:11:22
summarize done 1/3 2026-07-20 15:11:50
embed done 1/3 2026-07-20 15:11:51

📄 Описание YouTube

Показать
주문을 DB에 저장한 뒤 카프카로 이벤트를 발행하는 흔한 패턴, 그런데 DB 커밋은 성공했는데 카프카 전송 직전에 네트워크 장애가 나면? 주문은 존재하는데 이벤트는 유실되어 알림·정산이 전부 누락됩니다. 이게 바로 듀얼 라이트 문제입니다. 아웃박스 패턴은 2PC 없이 DB 트랜잭션만으로 이 문제를 해결하는 핵심 패턴입니다.
이번 영상에서는 듀얼 라이트 문제와 @TransactionalEventListener의 한계부터 시작해서, 비즈니스 데이터와 이벤트 메시지를 같은 DB 트랜잭션으로 저장하는 아웃박스 패턴의 핵심 구조, 아웃박스 테이블 설계(aggregate_type·aggregate_id·payload·status·인덱스 전략), 스프링에서 @Transactional로 주문과 이벤트를 원자적으로 저장하는 구현, Polling Publisher 방식(스케줄러가 주기적으로 조회)과 CDC 방식(Debezium이 binlog를 읽어 카프카로 전송)의 트레이드오프 비교, 멱등 소비자 구현, 아웃박스 테이블 정리 전략까지 프로덕션에서 바로 적용할 수 있는 내용으로 정리했습니다.

📌 이런 분들에게 추천합니다
- 마이크로서비스에서 이벤트 유실 문제로 고민 중인 백엔드 개발자
- 면접에서 "듀얼 라이트 문제", "이벤트 발행의 원자성 보장"을 답하고 싶은 분
- @TransactionalEventListener의 AFTER_COMMIT으로는 부족한 이유가 궁금한 분
- Polling과 CDC(Debezium) 중 어떤 방식을 선택해야 할지 기준이 필요한 개발자

#아웃박스패턴 #OutboxPattern #CDC #Debezium #백엔드개발