2023년도에 겪었던 경험입니다.
처음에 메일 발송은 단순했다. 회원가입 시 환영 메일 한 통. 네이버웍스 메일 API로 충분했다.
그런데 온보딩 진행 상태에 따라 매일 대상자를 추려 메일을 보내는 배치 기능이 필요해졌다. "어차피 메일 보내는 기능은 이미 있으니 재사용하면 되겠지" — 처음엔 그렇게 생각했다.
하지만 곧 한계에 부딪혔다. 네이버웍스 측에 문의한 결과, 돌아온 답변은 명확했다. 메일 발송 API는 계정별 로그인 기반의 서비스 API라서, 서버가 일괄적으로 대량 발송하는 방식을 지원하지 않는다는 것이었다.
즉, 단순히 "발송량 제한"의 문제가 아니었다. 애초에 서버가 배치성으로 메일을 쏘는 용도로 설계된 API가 아니었다. 환영 메일 1건을 보내는 것과, 매일 대상자를 추려 정기 발송하는 것은 완전히 다른 문제였다.
새로운 도구가 필요했다. 이미 AWS 인프라 위에서 서비스가 운영되고 있었기 때문에, 다른 대안을 길게 고민할 필요는 없었다. AWS SES를 선택했다.
구조는 단순하게 잡았다. 배치 작업을 수행하는 batch 서버와, 메일 발송을 전담하는 noti 서버를 분리하고, 그 사이를 SQS로 연결했다.

batch 서버가 책임지는 건 "누구에게 무엇을 보내야 하는가"를 결정하고 큐에 메시지를 넣는 것까지다. 실제 발송 성공 여부는 noti 서버의 책임으로 넘긴다. 이렇게 분리하면 메일 발송이 실패하더라도 배치 Job 자체는 정상 종료된다.
온보딩 메일의 대상은 계정 오너이면서, 삭제되지 않은 클라이언트 중, 기준일 이전에 가입한 사용자로 좁힌다. 당일 가입자는 제외하는데, 가입 직후에는 아직 활동 데이터가 쌓이지 않아 온보딩 단계를 판단할 수 없기 때문이다.
이렇게 수집된 후보들에 대해, 체크리스트 수·점검 대상 수·QR 요청 및 다운로드 수 등의 활동 지표를 계산한다. 이 지표를 바탕으로 각 클라이언트가 현재 어떤 온보딩 단계(1차~4차 등)에 있는지를 판별한다.
배치 Job은 매일 UTC 00:00에 실행된다. 한국 시간으로는 09:00이다. 처음엔 "왜 0시지?"라는 생각이 들 수 있지만, 운영 서버가 해외 리전에 있다는 걸 고려하면 자연스러운 설정이다. 업무 시작 시간에 맞춰 메일이 발송되도록 크론을 맞춘 것이다.
@Scheduled(cron = "0 0 0 * * *", zone = "UTC")
fun runOnboardingMailJob() { ... }
Chunk size는 100으로 설정했다. 대상자 수가 크지 않은 초기 단계였기 때문에, 트랜잭션 단위를 작게 잡아 안정성을 우선했다.
큐에 담는 메시지는 다음과 같은 구조다.
{
"userId": "user-uuid",
"userEmail": "owner@example.com",
"mailTemplate": "ONBOARDING_O1_ALIVE",
"notiTransactionKey": "uuid-v4",
"titleArgs": { "clientName": "회사명" },
"contentArgs": {
"clientName": "회사명",
"token": "HS256-JWT"
},
..
}
titleArgs와 contentArgs를 분리한 게 의도적인 부분이다. titleArgs는 메일 제목의 i18n 메시지 치환용이고, contentArgs는 Thymeleaf 본문 템플릿에 들어가는 변수다. 제목과 본문의 다국어 처리 방식이 달랐기 때문에, 처음부터 이 둘을 섞지 않도록 분리해뒀다.
여기서 한 가지 설계 포인트가 있다. STEP2의 Writer는 두 가지 작업을 한다. 하나는 onboarding_activation 테이블에 오늘의 활동 지표를 기록하는 것이고, 다른 하나는 SQS에 메일 발송 메시지를 publish하는 것이다.
이 둘은 같은 대상자에 대해 항상 함께 일어나지 않는다. 활동 지표 insert는 STEP1에서 수집된 모든 대상자에 대해 실행되지만, SQS publish는 shouldSendMail()이라는 별도 조건을 통과한 경우에만 일어난다.
fun shouldSendMail(): Boolean =
(lang?.equals("ko", true) ?: true) // 언어 설정이 ko 또는 미설정
&& isAgreeOnboarding() // 메일 수신 동의
&& availableStat // 서비스 활성 상태
즉, "오늘 이 클라이언트가 어떤 온보딩 단계에 있는지 기록하는 것"과 "그 단계에 맞는 메일을 실제로 보내는 것"을 별개의 결정으로 나눠뒀다. 언어 설정이 없거나, 메일 수신에 동의하지 않았거나, 서비스가 비활성 상태인 클라이언트도 활동 지표는 계속 쌓이지만 메일은 받지 않는다. 온보딩 추적 데이터와 발송 여부를 분리해둔 덕분에, 나중에 "이 클라이언트는 왜 메일을 못 받았는가"를 활동 지표 데이터로 그대로 추적할 수 있다.
이 구조는 이후 인보이스 발송 기능이 추가될 때도 그대로 재사용됐다. 새로운 발송 종류가 생겼을 때 batch 쪽에 Job 하나, noti 쪽에 템플릿 하나만 추가하면 되는 구조였다는 점에서, 처음 설계가 어느 정도 의도한 방향으로 확장된 것 같다.
noti 서버의 Consumer는 다음과 같이 동작한다.
@SqsListener(
value = ["\${sqs.noti-svc.send-email.queue-url}"],
deletionPolicy = SqsMessageDeletionPolicy.NEVER
)
fun listen(@Payload body: SqsMessage<SendMailDto>, ack: Acknowledgment) {
mailService.sendMail(body.message)
ack.acknowledge()
}
deletionPolicy = NEVER로 설정해서, 메일 발송이 성공했을 때만 수동으로 ack.acknowledge()를 호출해 메시지를 큐에서 제거한다. 실패하면 메시지는 그대로 남아 있다가, visibility timeout이 지난 후 다시 Consumer에게 전달된다.
이 동작 방식이 멱등성 설계와 재시도 정책에 어떤 의미를 갖는지는 4번에서 좀 더 자세히 다룬다.
당시엔 "온보딩 메일 하나 보내는 시스템"치고는 꽤 견고하게 만들었다고 생각했다. 다시 코드를 들여다보면서, 잘 했다고 느낀 부분과 아쉬운 부분이 좀 더 선명하게 보였다.
SQS는 at-least-once 전달을 보장한다. 같은 메시지가 두 번 처리될 수 있다는 뜻이다. 게다가 배치 Job 자체도 재실행될 수 있다 — 장애로 중단됐거나, 운영자가 같은 날짜로 다시 돌릴 수도 있다.
이 두 가지 가능성을 처음부터 염두에 두고, 멱등성을 두 단계로 설계했다.

onboarding_activation 테이블의 UNIQUE INDEX (client_id, base_dt) — 같은 날짜에 배치가 다시 실행되더라도, 같은 클라이언트에 대한 activation 레코드는 한 번만 insert된다.mail_receiver.mail_transaction_key의 UNIQUE 제약 — SQS 메시지가 중복 처리되더라도, DB 레벨에서 같은 메일이 두 번 발송되지 않는다.키 포인트는, 이 두 제약이 "나중에 문제가 생겨서 추가한 패치"가 아니라 처음 설계 시점부터 함께 들어간 것이라는 점이다. "이 시스템은 언젠가 같은 입력으로 다시 실행될 수 있다"는 전제를 깔고 시작했기 때문에, 재실행이 실제로 발생해도 별도 대응 없이 안전하게 넘어갈 수 있는 구조가 됐다.
멱등성 설계와 맞물려 있는 또 하나의 선택은 순서다. STEP2의 Writer는 onboarding_activation에 insert를 먼저 하고, 그게 성공해야 SQS로 publish한다. DB의 UNIQUE 제약이 멱등성 체크 역할을 하기 때문에, 그 관문을 통과한 것만 큐로 내보내는 흐름이다.

다만 insert는 성공했는데 publish에서 예외가 나면, "기록은 있지만 메일은 못 받은" 상태로 남는다. DB와 SQS를 하나의 트랜잭션으로 묶을 수 없기 때문이다. 완전히 풀려면 Outbox 패턴이 필요하지만, 이 시스템은 매일 도는 배치라 그날 실패해도 "오늘 메일을 못 받은 것"으로 끝난다. 일 배치 특성을 고려하면 합리적인 타협이었다고 생각한다.
@Retryable에 상한이 없다SES 발송 재시도는 다음과 같이 설정되어 있다.
@Retryable(backoff = Backoff(2000))
fun send(...) { ... }
maxAttempts나 @Recover가 없다. Spring Retry의 기본값(3회) 이후에 예외가 그대로 위로 전파되는데, 이 경우 @SqsListener는 ack.acknowledge()를 호출하지 못한 채 끝난다. 메시지는 삭제되지 않고, visibility timeout이 지나면 Consumer에게 다시 전달된다.
일시적인 SES 장애(네트워크 오류, 일시적인 throttling)라면 이 동작은 오히려 적절하다 — 시간이 지나면 자연스럽게 재시도되고, 결국 성공할 가능성이 있다.
문제는 영구적으로 실패하는 케이스다. 예를 들어 수신자 이메일 주소 형식이 잘못됐다면, SES는 매번 같은 이유로 실패한다. 이런 메시지는 3회 재시도 후에도 똑같이 실패하고, visibility timeout마다 계속 큐로 돌아와 같은 실패를 반복한다. 결국 DLQ 설정 없이는 이 메시지가 영원히 큐를 맴돌게 된다.
@Retryable만으로는 "재시도하면 나아질 가능성이 있는 실패"와 "재시도해도 똑같이 실패할 게 뻔한 실패"를 구분하지 못한다. @Recover로 분기해서, 후자는 즉시 실패로 기록하고 메시지를 ack 처리하는 로직이 필요했다.
위에서 짚은 부분들을 다시 설계한다면 다음과 같이 바꾸고 싶다.
@Retryable + @Recover로 실패를 분기: maxAttempts를 명시하고, 재시도가 모두 소진된 시점에 @Recover에서 실패 사유를 기록한 뒤 메시지를 ack 처리한다. 일시적인 실패는 재시도로 자연스럽게 회복되도록 두고, 영구적인 실패는 더 이상 큐에 남지 않도록 분리한다.
DB insert와 SQS publish 사이의 간극을 메우는 보정 절차: "DB 먼저, 큐는 그 다음"이라는 순서를 유지하더라도, publish에 실패한 행을 별도로 찾아 재발행하는 보정 배치를 추가한다. 예를 들어 onboarding_activation에 published_at 같은 컬럼을 두고, 이 값이 비어있는 행을 주기적으로 다시 publish하는 방식이다. Outbox 패턴을 완전히 도입하지 않아도, 이 정도의 보정 절차만으로 "기록은 있지만 발행은 안 된" 상태를 줄일 수 있다.
당시에는 "온보딩 메일 하나를 안정적으로 보내는 것"이 목표였고, 멱등성 설계와 처리 순서만큼은 그 목표에 맞게 합리적으로 만들어졌다고 생각한다. 다만 좋은 패턴을 한 번 설계하는 것과, 그 패턴이 이후에 추가되는 기능에서도 자연스럽게 이어지도록 만드는 것, 그리고 그 패턴의 빈틈을 메우는 보정 장치까지 갖추는 것은 모두 다른 단계의 문제였다. 이번에 코드를 다시 보면서, 구조를 만드는 일과 그 구조를 끝까지 책임지는 일 사이의 거리를 다시 생각하게 됐다.