신뢰 가능한 알림: 트리거와 백그라운드 워커 비교
알림 전송에서 트리거와 백그라운드 워커 중 언제 어떤 방식이 더 안전한지, 재시도·트랜잭션 경계·중복 방지에 대한 실용적 가이드를 제공합니다.

실제 애플리케이션에서 알림 전달이 깨지는 이유
알림은 단순해 보입니다: 사용자가 무언가를 하면 이메일이나 SMS가 나갑니다. 현실에서 실패의 대부분은 타이밍과 중복에서 옵니다. 데이터가 진짜로 저장되기 전에 메시지가 전송되거나, 일부 실패 후에 두 번 전송되는 경우가 생깁니다.
“알림”은 영수증 이메일, 일회용 코드 SMS, 푸시 알림, 앱 내 메시지, Slack·Telegram 푸시, 또는 타 시스템으로의 웹훅 등 다양합니다. 공통된 문제는 데이터베이스 변경과 애플리케이션 외부의 어떤 것을 조율하려 한다는 점입니다.
외부는 엉망일 수 있습니다. 제공자가 느릴 수 있고, 타임아웃을 반환하거나 요청을 수락했지만 앱이 성공 응답을 받지 못할 수 있습니다. 앱 자체가 요청 중에 크래시하거나 재시작할 수 있습니다. 인프라 재시도로 같은 전송이 다시 실행되거나 워커 재시작, 사용자가 버튼을 다시 누르는 일로도 “성공한” 전송이 재실행될 수 있습니다.
일반적인 실패 원인으로는 네트워크 타임아웃, 제공자 장애나 요율 제한, 부적절한 시점의 앱 재시작, 고유한 보호장치 없이 같은 전송 로직을 다시 실행하는 재시도, 그리고 데이터베이스 쓰기와 외부 전송을 한 번에 처리하려는 설계 등이 있습니다.
사람들이 “신뢰 가능한 알림”이라고 할 때 보통 두 가지 중 하나를 의미합니다:
- 정확히 한 번 전달되도록 하거나,
- 최소한 중복은 절대 발생하지 않도록(중복은 지연보다 더 해로울 때가 많습니다).
빠르면서 완벽히 안전한 것을 얻기는 어렵고, 속도·안전성·복잡성 사이에서 절충을 선택하게 됩니다.
이것이 트리거와 백그라운드 작업 워커의 선택이 단순한 아키텍처 논쟁이 아닌 이유입니다. 언제 전송을 허용할지, 실패는 어떻게 재시도할지, 문제가 생겼을 때 중복 이메일이나 SMS를 어떻게 막을지에 관한 문제입니다.
트리거와 백그라운드 워커: 의미
사람들이 트리거와 백그라운드 작업을 비교할 때 실제로 비교하는 것은 알림 로직이 어디에서 실행되는지와 그 로직이 이를 발생시킨 액션에 얼마나 밀접하게 결합되어 있는가입니다.
트리거는 “X가 발생하면 바로 실행”입니다. 많은 애플리케이션에서는 사용자 액션 직후 같은 웹 요청 안에서 이메일이나 SMS를 보내는 것을 의미합니다. 트리거는 데이터베이스 레벨에 있을 수도 있습니다: 행이 삽입되거나 업데이트될 때 자동으로 실행되는 DB 트리거입니다. 두 유형 모두 즉각적으로 느껴지지만, 그것들을 트리거한 맥락의 타이밍과 제약을 그대로 물려받습니다.
백그라운드 워커는 “곧 실행하되 포그라운드에서 실행하지는 않는다”는 접근입니다. 워커는 큐에서 작업을 꺼내 별도의 프로세스로 처리합니다. 메인 앱은 무엇이 일어나야 하는지 기록하고 빠르게 응답한 뒤, 워커가 이메일·SMS 제공자 호출처럼 느리고 실패하기 쉬운 부분을 처리합니다.
“작업(job)”은 워커가 처리하는 단위입니다. 일반적으로 누굴 알릴지, 어떤 템플릿인지, 어떤 데이터를 채울지, 현재 상태(queued, processing, sent, failed), 시도 횟수, 예약 시간이 포함됩니다.
전형적인 알림 흐름은 메시지 세부 정보를 준비하고, 작업을 큐에 넣고, 제공자에게 전송하고, 결과를 기록한 뒤 재시도할지 중단할지 알림을 보내는 것입니다.
트랜잭션 경계: 실제로 안전한 시점
트랜잭션 경계는 “우리가 저장을 시도했다”와 “진짜로 저장되었다” 사이의 선입니다. 데이터베이스가 커밋되기 전까지는 변경이 롤백될 수 있습니다. 알림은 되돌리기 어렵기 때문에 이 차이가 중요합니다.
커밋 전에 이메일이나 SMS를 보내면 결코 일어나지 않은 일에 대해 알릴 수 있습니다. 제약 오류나 타임아웃으로 쓰기가 실패하면 고객은 혼란스러워지고 지원이 뒤처리를 해야 합니다.
데이터베이스 트리거 안에서 전송하는 것은 자동으로 실행된다는 점에서 매력적입니다. 문제는 트리거가 같은 트랜잭션 내부에서 실행된다는 점입니다. 트랜잭션이 롤백되면 이미 이메일이나 SMS 제공자에 호출을 했을 수 있습니다.
데이터베이스 트리거는 관찰, 테스트, 안전한 재시도가 어렵고, 느린 외부 호출을 하면 락을 더 오래 유지해 DB 문제를 진단하기 어렵게 만듭니다.
더 안전한 접근은 outbox 아이디어입니다: 전송 의도를 데이터로 기록하고 커밋한 뒤 전송합니다.
비즈니스 변경을 수행하면서 같은 트랜잭션에서 메시지(수신자, 채널, 템플릿, 고유 키 포함)를 설명하는 outbox 행을 삽입합니다. 커밋 후에 백그라운드 워커가 보류 중인 outbox 행을 읽어 전송하고 전송 완료로 표시합니다.
즉각 전송은 "처음엔" 괜찮을 수 있습니다 — 예를 들어 "요청을 처리 중입니다" 같은 정보성 메시지처럼 틀려도 큰 문제가 없는 경우입니다. 하지만 최종 상태와 정확히 일치해야 하는 경우는 커밋 이후까지 기다리세요.
재시도와 실패 처리: 각 접근의 장단점
대부분의 경우 재시도가 결정적 요소입니다.
트리거: 빠르지만 실패에 취약
대부분 트리거 기반 설계에는 좋은 재시도 이야기가 없습니다.
트리거가 이메일/SMS 제공자에 호출하고 호출이 실패하면 보통 두 가지 나쁜 선택 중 하나를 하게 됩니다:
- 트랜잭션을 실패로 만들고 원래 업데이트를 막거나, 또는
- 오류를 삼키고 알림을 조용히 잃어버리거나.
신뢰성이 중요할 때 둘 다 받아들일 수 없습니다.
트리거 안에서 루프를 돌리거나 지연시키려고 하면 트랜잭션을 더 오래 열어두고 락 시간을 늘려 DB를 느리게 하고 상황을 악화시킬 수 있습니다. 데이터베이스나 앱이 전송 중에 죽으면 제공자가 요청을 받았는지 여부를 알기 어렵습니다.
백그라운드 워커: 재시도에 특화됨
워커는 전송을 자체 상태를 가진 별도 작업으로 취급합니다. 그래서 필요한 경우에만 재시도하는 것이 자연스럽습니다.
실무 규칙으로는 보통 일시적 실패(타임아웃, 일시적 네트워크 문제, 서버 오류, 긴 대기 후 해결 가능한 요율 제한)는 재시도하고, 잘못된 전화번호나 잘못된 이메일 같은 영구 오류나 구독 해지 같은 강력한 거부는 재시도하지 않습니다. "알 수 없음" 오류는 시도 횟수를 제한하고 상태를 가시화합니다.
백오프는 재시도가 상황을 악화시키지 않게 하는 방법입니다. 처음에는 짧게 대기하고(예: 10초), 시도할수록 늘려갑니다(예: 10s, 30s, 2m, 10m) 그리고 고정된 횟수 이후에는 중단합니다.
배포나 재시작을 견디게 하려면 각 작업에 재시도 상태를 저장하세요: 시도 횟수, 다음 시도 시간, 마지막 오류(짧고 읽기 쉬운 형태), 마지막 시도 시간, 그리고 pending, sending, sent, failed 같은 명확한 상태.
앱이 전송 중에 재시작 되면 워커는 오래된 타임스탬프의 sending 작업을 재확인하고 안전하게 재시도할 수 있습니다. 이때 멱등성이 필수적입니다. 재시도가 중복 전송으로 이어지지 않도록 해야 합니다.
이메일·SMS 중복 방지: 멱등성
멱등성이란 같은 "전송 알림" 작업을 여러 번 실행해도 사용자에게는 한 번만 도달하도록 하는 것을 말합니다.
중복의 고전적 사례는 타임아웃입니다: 앱이 제공자에 요청을 보냈는데 타임아웃이 나서 재시도하면 첫 요청이 실제로 성공했을 가능성이 있어 재시도는 중복을 만듭니다.
실무적 해결책은 모든 메시지에 안정적 키를 부여하고 그 키를 단일 진실 소스로 취급하는 것입니다. 좋은 키는 메시지가 무엇을 의미하는지 설명해야 하며, 시도 시점이 아니라 메시지의 본질을 설명해야 합니다.
일반적 접근법:
- 메시지를 생성할 때 만든
notification_id같은 생성된 키, 또는 order_id + template + recipient같은 비즈니스 유도 키(정말로 고유성을 보장할 때만).
그런 다음 전송 원장(종종 outbox 테이블)을 저장하고 모든 재시도가 전송 전에 이를 확인하게 하세요. 상태는 단순하고 가시적으로 유지하세요: created(결정됨), queued(대기), sent(확인됨), failed(확인된 실패), canceled(더 이상 필요 없음). 핵심 규칙은 멱등성 키당 하나의 활성 레코드만 허용하는 것입니다.
제공자 측 멱등성이 지원되면 도움이 되지만 자체 원장을 대체하지는 못합니다. 배포, 재시도, 워커 재시작은 여전히 귀하의 시스템에서 처리해야 합니다.
또한 "알 수 없음" 결과를 1급 시민으로 취급하세요. 요청이 타임아웃되면 즉시 재전송하지 말고 확인 대기(pending confirmation)로 표시하고 가능하면 제공자의 전달 상태를 확인해 안전하게 재시도하세요. 확인할 수 없다면 중복 전송 대신 지연하고 알림을 보내는 것이 낫습니다.
안전한 기본 패턴: outbox + 백그라운드 워커 (단계별)
안전한 기본값이 필요하면 outbox 패턴과 워커 조합이 강력합니다. 비즈니스 트랜잭션 외부에서 전송을 유지하면서 전송 의도는 보장합니다.
흐름
"알림을 전송하라"는 것을 행동으로 바로 실행하는 대신 데이터로 저장하세요.
비즈니스 변경(예: 주문 상태 업데이트)을 정상 테이블에 저장하고 같은 데이터베이스 트랜잭션에서 수신자, 채널(email/SMS), 템플릿, 페이로드, 멱등성 키를 가진 outbox 레코드를 삽입한 뒤 커밋합니다. 그 시점 이후에만 실제 전송이 일어날 수 있습니다.
백그라운드 워커는 보류 중인 outbox 행을 주기적으로 가져와 전송하고 결과를 기록합니다.
두 워커가 같은 행을 처리하지 않도록 간단한 claim 단계(예: 상태를 processing으로 변경하거나 타임스탬프를 잠금)도 추가하세요.
중복 차단과 실패 처리
중복은 전송은 성공했지만 앱이 "sent"로 기록하기 전에 크래시할 때 자주 발생합니다. 이를 해결하려면 "sent로 표시"하는 쓰기를 반복해도 안전하게 만드세요.
멱등성 키 및 채널에 대한 고유 제약(예: idempotency key와 채널에 대한 유니크 제약)을 두고 재시도 규칙을 명확히 하세요: 제한된 시도 횟수, 점점 길어지는 지연, 재시도 가능한 오류만 재시도. 마지막 재시도 이후에는 작업을 failed_permanent 같은 데드레터 상태로 옮겨 사람이 검토하고 수동 재처리할 수 있게 하세요.
모니터링은 간단하게 유지할 수 있습니다: pending, processing, sent, retrying, failed_permanent의 카운트와 가장 오래된 pending 타임스탬프를 추적하세요.
구체적 예: 주문이 "Packed"에서 "Shipped"로 바뀔 때 주문 행을 업데이트하고 order-4815-shipped 같은 멱등성 키로 outbox 행을 하나 생성합니다. 워커가 중간에 죽더라도 재실행해도 중복 전송이 발생하지 않습니다. "sent" 기록은 고유 키로 보호됩니다.
백그라운드 워커를 선택해야 할 경우
데이터베이스 트리거는 데이터 변경 순간에 반응하기는 좋습니다. 하지만 "복잡한 현실 조건에서 신뢰성 있게 알림을 전달"하려면 보통 백그라운드 워커가 더 많은 제어권을 줍니다.
다음과 같은 경우 워커가 더 적절합니다: 일정 기반 전송(리마인더, 요약), 요율 제한과 역압력(백프레셔)이 있는 고부하, 제공자 변동성(429 제한, 느린 응답, 짧은 다운), 다단계 워크플로(전송 후 전달 확인을 기다렸다가 후속 조치), 또는 교차 시스템 이벤트의 조정이 필요한 경우.
간단한 예: 고객에게 결제를 청구하고 SMS 영수증을 보낸 뒤 이메일 인보이스를 전송한다고 합시다. SMS가 게이트웨이 이슈로 실패해도 주문은 결제 상태로 유지되길 원하고 안전하게 나중에 재시도하길 원합니다. 이 로직을 트리거에 넣으면 "데이터가 정확하다"는 것과 "제3자가 지금 사용 가능하다"는 것을 뒤섞게 됩니다.
워커는 운영 통제도 쉽게 만듭니다. 사고 시 큐를 일시 정지하고 실패를 검사하며 지연을 두고 재시도할 수 있습니다.
놓치거나 중복된 메시지를 초래하는 일반적 실수
가장 빠른 길은 아무 데서나 "그냥 보내기"를 하고 재시도가 알아서 해결해주길 바라는 것입니다. 트리거든 워커든 실패와 상태 관리의 세부사항이 사용자가 한 통을 받는지, 두 통을 받는지, 아예 못 받는지를 결정합니다.
자주 발생하는 함정은 데이터베이스 트리거에서 보내고 실패할 수 없다고 가정하는 것입니다. 트리거는 DB 트랜잭션 안에서 실행되므로 느린 제공자 호출로 인해 쓰기가 지연되거나 타임아웃이 나고, 더 나쁘게는 송신이 성공했음에도 트랜잭션을 롤백했다가 나중에 다시 시도하면서 두 번 전송될 수 있습니다.
반복적으로 보이는 실수들:
- 모든 것을 똑같이 재시도함(영구 오류 포함).
- "queued"와 "sent"를 분리하지 않아 크래시 후 무엇을 재시도해도 안전한지 알 수 없음.
- 타임스탬프를 중복 방지 키로 사용해 재시도가 고유성을 무력화함.
- 사용자 요청 경로(결제, 폼 제출)에서 제공자 호출을 함.
- 제공자 타임아웃을 "배달되지 않음"으로 취급함(많은 경우 실제로는 "알 수 없음").
간단한 사례: SMS를 보내는데 제공자가 타임아웃을 반환하면 재시도하면 중복 코드가 전송될 수 있습니다. 해결책은 안정적 멱등성 키(예: notification_id)를 기록하고 전송 전에 메시지를 queued로 표시한 다음 명확한 성공 응답을 받은 뒤에만 sent로 표시하는 것입니다.
릴리스 전에 빠르게 점검할 항목
대부분의 알림 버그는 도구 문제가 아니라 타이밍, 재시도, 누락된 기록 문제입니다.
다음 사항을 확인하세요:
- 데이터베이스 쓰기가 안전하게 커밋된 후에만 전송이 일어나는가. 쓰기 도중 전송하고 나중에 롤백되면 사용자는 결코 일어나지 않을 일에 대해 알림을 받을 수 있습니다.
- 각 알림에 고유한 멱등성 키가 있는가(예:
order_id + event_type + channel)와 저장소에서 중복이 거부되는가. - 재시도는 안전한가: 동일한 작업을 다시 실행해도 최대 한 번만 전송되는가.
- 모든 시도가 기록되는가(상태, last_error, 타임스탬프).
- 시도 횟수는 제한되어 있고, 멈춘 항목은 검토하고 재처리할 수 있는 명확한 장소가 있는가.
배포 전에 재시작 동작을 의도적으로 테스트하세요. 워커를 전송 중에 강제 종료하고 재시작하여 중복 전송이 발생하지 않는지 확인하세요. 데이터베이스에 부하가 걸린 상황에서도 동일하게 테스트하세요.
검증 시나리오 예: 사용자가 전화번호를 변경하고 SMS 인증을 보냅니다. SMS 제공자가 타임아웃을 내면 앱은 재시도합니다. 좋은 멱등성 키와 시도 로그가 있으면 한 번만 보내거나 안전하게 나중에 다시 시도하되 스팸을 보내지 않습니다.
예시 시나리오: 주문 업데이트에서 중복 전송 방지
상점은 (1) 결제 직후 주문 확인 이메일, (2) 배송 중·배송 완료 시 SMS 업데이트 두 종류의 메시지를 보냅니다.
너무 일찍 보내면(예: DB 트리거 내부) 문제가 생깁니다: 결제 단계가 orders 행을 쓰고 트리거가 고객에게 이메일을 보내고, 그 직후 결제 캡처가 실패하면 실제로 존재하지 않는 주문에 대해 "주문 감사합니다" 이메일을 보낸 셈이 됩니다.
반대로 배송 상태가 "Out for delivery"로 바뀔 때 SMS 제공자가 타임아웃하면 제공자가 메시지를 보냈는지 모릅니다. 즉시 재시도하면 두 번 보낼 위험이 있고, 재시도하지 않으면 아무 메시지도 가지 않을 위험이 있습니다.
안전한 흐름은 outbox 레코드와 백그라운드 워커를 사용합니다. 앱은 주문 또는 상태 변경을 커밋하고 같은 트랜잭션에서 "템플릿 X를 사용자 Y에게 전송, 채널 SMS, 멱등성 키 Z" 같은 outbox 행을 기록합니다. 커밋 이후에 워커가 메시지를 전달합니다.
간단한 타임라인:
- 결제 성공, 트랜잭션 커밋, 확인 이메일용 outbox 행 저장.
- 워커가 이메일을 보내고 제공자 메시지 ID로 outbox를 sent로 표시.
- 배송 상태 변경, 트랜잭션 커밋, SMS 업데이트용 outbox 행 저장.
- 제공자가 타임아웃, 워커는 outbox를 재시도 가능으로 표시하고 같은 멱등성 키로 나중에 재시도.
재시도 시 outbox 행이 단일 진실 소스입니다. 두 번째 "새로운" 전송 요청을 만들지 않고 첫 요청을 완료하는 방식입니다.
지원 관점에서도 명확합니다. 지원팀은 failed에 걸린 메시지와 마지막 오류(타임아웃, 잘못된 전화번호, 차단된 이메일), 시도 횟수 등을 보고 중복 없이 안전하게 재시도할 수 있습니다.
다음 단계: 패턴을 골라 깔끔하게 구현하세요
기본값을 정하고 문서화하세요. 일관되지 않은 동작은 보통 트리거와 워커를 무작위로 섞어 쓰는 데서 옵니다.
작게 시작해 outbox 테이블과 하나의 워커 루프를 만드세요. 첫 목표는 속도가 아니라 정확성입니다: 보내려는 것을 저장하고 커밋 후 전송하며 제공자가 확인해야만 sent로 표시하세요.
간단한 롤아웃 계획:
- 이벤트(order_paid, ticket_assigned)를 정의하고 어떤 채널을 사용할지 결정하세요.
- event_id, recipient, payload, status, attempts, next_retry_at, sent_at 같은 컬럼을 가진 outbox 테이블을 추가하세요.
- 보류 중인 행을 폴링하고 전송 후 상태를 업데이트하는 워커 하나를 만드세요.
- 메시지당 유니크 키로 멱등성을 구현하고 이미 전송된 경우 아무 것도 하지 않게 하세요.
- 오류를 재시도 가능한 것(타임아웃, 5xx, 요율 제한)과 재시도하지 않을 것(잘못된 번호, 차단된 이메일)으로 분류하세요.
볼륨을 키우기 전에 기본 가시성을 추가하세요. 보류 카운트, 실패율, 가장 오래된 보류 메시지의 연령을 추적하세요. 가장 오래된 보류가 계속 증가하면 워커가 멈췄거나 제공자 장애 또는 로직 버그가 있을 가능성이 높습니다.
AppMaster(appmaster.io)에서 구축한다면 이 패턴은 자연스럽게 매핑됩니다: Data Designer에 outbox를 모델링하고 비즈니스 변경과 outbox 행을 한 트랜잭션으로 쓰고, 전송·재시도 로직은 별도의 백그라운드 프로세스에서 실행하세요. 이 분리가 제공자나 배포가 잘못되어도 알림 전달을 신뢰할 수 있게 합니다.
자주 묻는 질문
백그라운드 워커가 보통 더 안전한 기본값입니다. 전송은 느리고 실패가 잦아서 재시도와 가시성이 필요한데, 워커는 그런 목적에 맞게 설계되어 있습니다. 트리거는 빠르지만 트랜잭션이나 요청에 강하게 묶여 있어 실패와 중복을 깔끔하게 처리하기 어렵습니다.
DB 커밋 이전에 전송하는 것은 위험합니다. 데이터베이스 쓰기가 나중에 롤백될 수 있어서 실제로 일어나지 않은 주문·비밀번호 변경·결제 등에 대해 사용자에게 알림을 보낼 수 있고, 이메일이나 SMS를 "되돌릴" 수는 없습니다.
데이터베이스 트리거는 행 변경과 같은 트랜잭션 내부에서 실행됩니다. 트리거가 이메일/SMS 제공자에 호출을 하고 나중에 트랜잭션이 실패하면, 실제로는 존재하지 않는 변경에 대한 메시지를 보냈거나, 외부 호출 때문에 트랜잭션이 지연되어 문제가 생길 수 있습니다.
Outbox 패턴은 전송 의도를 데이터베이스 행으로 저장하는 방식입니다. 비즈니스 변경과 동일한 트랜잭션에서 outbox 행을 삽입하고 커밋한 뒤, 커밋 이후에 워커가 대기 중인 outbox를 읽어 전송하고 전송 완료로 표시합니다. 이로써 타이밍과 재시도가 훨씬 안전해집니다.
타임아웃이 발생하면 실제 결과는 종종 "알 수 없음"입니다. 좋은 시스템은 시도를 기록하고 동일한 메시지 ID로 안전하게 지연·재시도하며, 즉시 재전송해서 중복을 만드는 대신 확인 가능한 방법(제공자 상태 확인 등)을 통해 처리하거나 지연 후 알립니다.
멱등성을 사용하세요: 각 알림에 메시지의 의미를 설명하는 안정적 키를 부여하고(시도 시점이 아니라), 그 키를 원장(종종 outbox 테이블)에 저장해 한 키당 하나의 활성 레코드만 허용하면 재시도 시 같은 메시지를 반복 생성하지 않습니다.
타임아웃, 5xx 응답, 요율 제한처럼 일시적 오류는 재시도하세요(백오프 사용). 잘못된 주소, 차단된 번호, 하드 바운스 같은 영구적 오류는 재시도하지 말고 실패로 표시해 데이터를 수정하도록 노출시키는 것이 좋습니다.
워커는 sending 상태로 오래 남아있는 작업을 찾아 재시도 가능 상태로 되돌리거나 다시 시도할 수 있습니다. 이 방식이 안전하려면 모든 작업이 시도 횟수, 타임스탬프, 마지막 오류처럼 상태를 기록하고 멱등성이 적용되어 중복 전송을 방지해야 합니다.
재시도해도 안전한지 답할 수 있어야 합니다. pending, processing, sent, failed 같은 명확한 상태와 시도 횟수, 마지막 오류를 저장하면 지원과 디버깅이 현실적으로 가능하고 시스템이 추측 없이 회복할 수 있습니다.
Data Designer에서 outbox 테이블을 모델링하고, 비즈니스 업데이트와 outbox 행을 하나의 트랜잭션으로 기록한 뒤, 별도의 백그라운드 프로세스에서 전송·재시도 로직을 실행하세요. 각 메시지에 하나의 멱등성 키를 두고 시도를 기록하면 배포·재시도·워커 재시작 시 중복을 피할 수 있습니다.


