개발기록

TL;DR

먼저 Transactional Outbox 패턴은

1.비즈니스 데이터

2.수행해야할 작업에 대한 정보(ex 메일 발송, 알림처리 등)

하나의 DB트랜잭션에서 저장하고, 저장된 작업을 별도의 처리과정에서 비동기로 처리하는 패턴입니다.

 

이번 프로젝트에서는 계약 처리와 메일 발송을 분리하면서도

발송해야 할 작업이 유실되지 않도록 Transactional Outbox 패턴을 적용했습니다.

 

Outbox에 저장된 발송 요청은 시스템의 배포 환경과 처리량을 고려하여

Scheduler기반 Polling 방식으로 조회하고 재처리하도록 구성하였습니다.

 

배경

기존 전자계약 시스템은 사용자가 계약서를 생성하고 서명을 요청하고,

최종적으로 계약을 완료하는 기능을 중심으로 제공하고 있었습니다.

 

이후 문서관리 시스템과 전자계약 시스템을 통합하는 프로젝트를 진행하면서 새로운 요구사항이 추가되었습니다.

계약이 생성되거나 서명이 요청되고, 계약이 완료되는 등의 시점에 사용자에게

  • 메일
  • 카카오톡
  • 브라우저 알림

등 다양한 방식으로 진행 상황을 알려줘야 했습니다.

 

이 글에서는 이 중 메일을 기준으로 설명하려 합니다.

 

구현을 진행하며 "외부 메일 전송 서비스에 문제가 생겨 발송에 실패한다면 어떻게 처리해야할까?"를 고민하였습니다.

 

메일이나 카카오톡과 같은 기능은 외부 시스템에 의존하기 때문에 장애가 발생하거나 응답이 지연될 수 있지만

계약과 관련된 핵심 로직과는 연관이 없었습니다.

 

따라서 조건을 먼저 정리하였습니다.

  1. 계약 처리가 가장 우선되어야한다.
  2. 외부 메일 시스템의 장애가 계약 처리에 영향을 주지 않아야 한다.
  3. 메일 발송에 실패하면 이후 재처리할 수 있어야 한다.
  4. 서버 장애가 발생하더라도 발송해야 할 작업은 사라지지 않아야 한다.

 

 

계약과 메일을 어디까지 하나의 작업으로 볼 것인가

처음에는 계약 생성까지만 트랜잭션으로 처리하고,

계약이 정상적으로 완료된 이후 메일을 발송하는 구조를 생각했습니다.

 

여기서 만약 메일 발송을 시도하기 전에 

서버에 장애가 발생한다면?

계약은 정상적으로 생성됐지만

메일 발송의 성공 기록도, 실패 기록도 존재하지 않습니다.

 

즉, 발송에 실패한 것을 재처리하는 것이 아닌,

발송을 시도하지 못하더라도 발송해야하는 정보는 남아 있어야 했습니다.

 

 

 

계약과 메일 발송을 하나의 트랜잭션으로 묶으면?

가장 단순하게 생각하면 실제 메일 전송까지 계약 트랜잭션에 포함시킬 수도 있습니다.

 

하지만 이렇게 진행할 경우 다음과 같은 문제가 생겼습니다.

1. 메일과 같은 외부 서버의 응답을 기다리는 동안 DB 트랜잭션이 불필요하게 길어질 수 있습니다.

2. 외부 시스템의 장애가 계약 처리의 성공 여부에 영향을 주게 됩니다.

3. 외부로 이미 전송된 메일은 DB 트랜잭션처럼 Rollback할 수도 없습니다.

 

따라서 실제 메일 발송을 계약 트랜잭션에 포함시키는 것은 빠르게 기각했습니다.

 

 

실제 발송이 아니라 '발송 요청'만 저장한다면

여기서 실제 메일 발송과 메일 발송 요청을 기록하는 것을 분리하였습니다.

 

계약 트랜잭션에서는 실제 메일을 발송하지 않습니다.

 

대신 "이 계약에 대해 이 메일을 나중에 발송해야 한다"는 정보만 DB에 저장합니다.

 

 

이 경우 계약이 정상적으로 생성되었을 때, 메일 발송 요청도 함께 DB에 남습니다.

 

반대로 계약 생성 과정에서 문제가 발생하면

두 데이터가 함께 Rollback됩니다.

 

따라서 존재하지 않는 계약에 대한 발송 요청이 남는 것을 방지할 수 있습니다.

 

Outbox 저장 실패로 인해 계약까지 Rollback 되는 경우?

Transactional Outbox를 적용하면 계약 데이터와 Outbox 데이터를 같은 트랜잭션으로 저장하기 때문에 

Outbox 저장에 실패하면 계약 역시 Rollback됩니다.

 

처음 조건을 정리할 때 계약이 메일보다 중요하다고 기준을 두었는데,

메일 요청을 저장하지 못하여 계약까지 되돌린다면 앞의 판단과 모순되는 것 같았습니다.

 

하지만 여기서 실제 메일 발송과 메일 발송 요청 저장성격은 다르다고 판단하였습니다.

 

실제 메일 발송에는 여러 실패 지점이 존재합니다.

1. 네트워크 통신

2. 외부 서버 상태

3. Timeout

4. 인증 오류

등의 다양한 이유가 있습니다.

 

반면 Outbox 저장은 기존 계약 데이터와 동일한 DB에 단순한 데이터를 추가하는 작업으로,

외부 시스템을 호출하는 것에 비해 구조가 단순하고 실패 지점도 상대적으로 적습니다.

 

따라서 실패 가능성이 상대적으로 높은 실제 외부 발송은 계약 트랜잭션에서 분리하되,

단순한 DB작업인 발송 요청 정보 저장까지는 계약 처리의 일부로 보고

동일한 트랜잭션에서 보장하도록 했습니다.

 

 

 

Outbox에 저장된 데이터 처리하기

Outbox는 보낼 작업을 저장해 놓은 발신함에 가깝습니다.

따라서 어디선가 Outbox에 쌓여 있는 데이터를 가져가 실제 작업을 수행해야 합니다.

 

이 부분은 Scheduler를 통해 미발송된 메일과 발송 실패한 메일을 조회하여 보내도록 진행하였습니다.

 

현재 시스템은 단일 패키지 형태로 배포되어야 한다는 제약이 있었고,

메일 처리량도 크지 않았으며 일정 수준의 처리 지연도 허용할 수 있었습니다.

 

따라서 별도의 메시지 브로커를 추가하기보다 Scheduler 기반 Polling 방식을 선택했습니다.

 

다만 향후 메일 처리량이 크게 증가하여 하나의 Scheduler에서 처리하는 것이 병목이 되거나,

작업을 병렬로 처리해야 할 정도의 확장성이 필요해진다면 메시지 브로커 도입을 고려할 수 있을 것 같습니다.

 

현재 요구사항에서는 이러한 수준의 확장성이 필요하지 않았기 때문에 별도의 인프라와 운영 복잡도를 추가하기보다 단순한 Scheduler 구조로도 충분하다고 판단했습니다.

 

 

At-Least-Once vs Exactly-Once

마지막으로 고민한 부분은 중복 발송이었습니다.

 

만약 메일 발송 로직에서 메일 발송까지 성공했지만

DB업데이트가 되기 전에 서버에 장애가 발생한다면

 

데이터는 여전히 PENDING상태로 남게 됩니다.

 

따라서 서버가 다시 실행되면 보냈던 메일이 다시 발송될 가능성이 있습니다.

 

이를 방지하기 위해 Event ID나 Idempotency Key 등을 이용하여 멱등성을 보장하는 방법도 고려할 수 있습니다.

다만 외부 메일 시스템까지 중복 처리를 방지하려면 해당 시스템에서 Idempotency Key를 지원하거나,

동일 요청에 대한 처리 여부를 확인할 수 있는 수단이 필요합니다.

 

 

1. 발송되어야 할 메일이 -> 발송되지 않음

2. 드문 상황에서 동일한 메일이 중복 발송됨

 

따라서 중복 가능성을 완전히 제거하기보다,

재처리를 허용하여 발송 유실 가능성을 낮추는 것에 우선순위를 두었습니다.

 

 

마무리

처음에는 단순히 메일이실패하면 어떻게 처리할까? 로 시작하였습니다.

하지만 고민해야할 지점이 꽤 많았고,

 

1. 계약이 메일 전송보다 중요하다

2. 메일 전송을 계약 처리 이후 실행

3. 메일 전송 전에 서버가 죽으면? -> 작업 자체가 유실

4. 계약과 메일 전송을 묶어버리면? -> 외부시스템이 계약에 영향을 줌

5. 실제 발송 말고 '발송 요청'만 저장하자 -> Transactional Outbox

6. Outbox 실행은 어떻게? -> Polling 방식, 단일 패키징 고려 -> Scheduler Polling

7. 중복 발송 처리는? -> Exactly-Once보다 At-Least-Once가 우선

 

이번 구조를 고민하면서 모든 실패 가능성을 제거하는 것보다

어떤 실패를 허용하고 어떤 데이터까지 반드시 보장할 것인지 경계를 정하는 것이 중요하다는 점을 느꼈습니다.

 

 

 

 

 

 

 

 

profile

개발기록

@HO0214

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!