← 목록으로

BullMQ로 알림 시스템 운영하기 3편 - 발송 이력을 어디에 쌓을 것인가

들어가며

1편2편은 발송을 어떻게 안정적으로 내보낼 것인가에 대한 이야기였다. 이번 글은 보낸 다음의 이야기다.

무엇을 언제 누구에게 보냈고 결과가 어땠는지는 어딘가에 남아야 한다. 문제는 어디에 남기느냐다. 관계형 DB, S3 직렬 파이프라인, SNS fan-out, Firehose 경유를 놓고 저울질해볼 것이다.

순서는 문제 → 데이터 → 설계 → 저장소로 잡았다. 저장소는 구현체라 마지막에 고르는 게 맞다고 봤다.

겪고 있던 문제

발송은 서드파티가 대신해주고 이력도 그쪽 콘솔에 다 있다. 서드파티를 잘 활용하면 데이터를 따로 쌓을 이유가 없다고 생각할 수도 있겠다. 하지만 알림 시스템을 직접 운영해보면 아래와 같은 문제를 만나게 될 것이다.

  • “이 사용자에게 무엇이 언제 갔나”를 우리 쪽에서 못 본다. 콘솔은 messageId나 groupId로는 찾아주지만 우리 운영 DB 기반으로는 찾을 수 없다. CS 문의가 들어올 때마다 콘솔과 운영 DB를 사람이 대조해야 한다.
  • 실패율을 기간으로 집계할 수 없다. 실패 목록은 응답 시점에만 손에 들어온다. 그때 남기지 않으면 어제의 실패율도 다시 만들지 못한다. 네트워크 문제나 4xx/5xx로 호출 자체가 throw한 건은 서드파티 쪽에 이력조차 없어서 분모에도 안 잡힌다.
  • 무슨 내용으로 나갔는지 확인할 수 없다. 콘솔의 CSV·엑셀 내보내기에는 템플릿 변수가 빠져 있다.
  • 보관 기간이 6개월이고 조회 API에도 rate limit이 있다. 작년 같은 시기와 비교하려는 순간 데이터가 없고 5초 20회 한도 탓에 어드민에서 조회 용도로 쓰다가 429가 빈발했다.

행위는 위임해도 데이터는 위임하지 말자.

무엇을 남겨야 하는가

위 문제를 해결하기 위해 어떤 데이터를 어떻게 쌓아야 할까?

먼저 기록의 단위는 수신자 1명이다. 접수 요청 1회에 수신자 여러 명이 들어가는데 요청을 단위로 잡으면 첫 번째 문제에 답할 수 없다. CS 문의는 언제나 사람 하나에 대한 모든 이력 확인이 필요하다. 다만 요청 단위로만 아는 정보가 있어서 그룹과 엔트리 두 층으로 뒀다.

남길 데이터는 앞의 문제에서 하나씩 나온다.

문제 남기는 데이터
사용자 축으로 못 찾는다 우리 쪽 수신자 식별자, 발송 시각
실패율을 기간으로 못 센다 메시지별 최종 결과와 실패 사유
throw 건이 분모에서 빠진다 provider 응답이 없어도 우리가 직접 남기는 실패 기록
무슨 내용으로 나갔는지 모른다 치환이 끝난 발송 내용
템플릿·채널별로 못 나눈다 템플릿과 채널 구분

데이터 항목으로는 잡히지 않는 조건도 붙는다. 보관 기간은 최소 2년이다. 작년 같은 시기와 비교해보려면 그만큼이 필요하다. 그리고 운영 DB에 영향을 주지 않으면서 운영 DB에 있는 유저 테이블과 조인할 수 있어야 한다. 채널별 성과나 리텐션을 따지기 위해서다. 마지막은 가공 전 원본을 아카이빙할 공간이다. 이력을 우리 스키마로 바꿔 적재하고 나면, 변환이 틀렸거나 새 질문이 생겼을 때 돌아갈 원천이 필요하다. 원본이 남아 있어야 백필도 재처리도 가능하다.

조회 패턴은 보통 아래와 같을 것이다.

  • 기간별 실패율과 사유 분포
  • 템플릿·채널별 성과
  • 유저 테이블과 조인한 반응·리텐션 분석
  • CS 문의 시 특정 수신자의 이력 조회

집계와 대량 조회가 주를 이룬다.

쓰기는 대량 append이고 읽기는 대부분 스캔이 될 것이다.

설계를 먼저 그린다

이 데이터를 쌓는 방식에서 정할 게 셋 있었다. 셋 다 특정 저장소를 전제하지 않고 답할 수 있는 질문이고 답할 때마다 저장소에 요구할 성질이 하나씩 쌓인다. 물론 반대 방향도 있다. 저장소가 주는 성질이 충분히 매력적이면 여기서 내린 답을 되물릴 수도 있다. 그 검토는 저장소를 고르는 절에서 한다.

1. 발송과 이력은 같은 관심사인가

알림을 발송하는 일과 이력으로 성과를 모니터링하는 일은 관심사가 다르다. 발송은 지금 이 메시지를 내보내는 일이고 실패하면 안 된다. 이력은 그 결과를 모아뒀다가 나중에 묻는 일이다. CS 추적, 실패율 집계, 성과 분석이 소비처라 분 단위로 늦어도 되고, 드물게 한 건을 잃어도 잃는 것은 기록 한 건이다. 요구사항이 정반대인 둘이다.

관심사가 다른 둘을 한 흐름에 묶으면 부산물의 장애가 주 기능으로 전파된다. 같은 트랜잭션 안에서 이력을 쓰고 성공을 확인한 뒤 발송을 마치는 구조에서는 이력 저장소가 죽으면 발송도 멈춘다. 예를 들어, 분석 플랫폼이 느려졌다고 알림톡이 안 나가는 상황은 받아들이기 어렵다.

물론 둘이 정말 한 관심사인 경우도 있다. 이력 쓰기가 발송의 성립 조건일 때다. 규제상 “보낸 기록이 없으면 보내지 않은 것으로 본다”에 해당하는 통신이라면 묶는 게 맞고 그 값으로 완결성을 얻는다. 발송은 성공했는데 이력이 없는 상태가 원천적으로 생기지 않는다.

우리 요구사항에서는 이력이 발송의 성립 조건이 아니었다. 둘을 독립으로 두고 의존성은 이벤트로 끊는다. 워커는 발송 결과를 이벤트로 발행할 뿐 저장의 성공을 기다리지 않는다. 발행이 실패해도 발송은 계속 나가고 실패한 이벤트는 발송 흐름 밖에서 재시도한다. 재시도마저 실패하면 발송은 됐는데 이벤트만 없는 창이 남는데, 이 창은 드문 기록 유실로 수용한다. 응답을 받은 건은 provider 콘솔에서 백필할 수 있지만, throw 건은 상대에게 이력조차 없어 그마저도 안 된다. 이벤트로 끊으면 따라오는 것도 있다. 저장소에 맞는 스키마로 바꾸는 책임까지 발송 코드 밖으로 나간다.

여기서 저장소에 걸리는 조건이 나온다. 발송 경로 밖에서 이벤트를 받아 쓸 수 있고 쓰기가 실패해도 발송이 계속된다.

2. 언제 확정할 것인가

접수 시점부터 행을 만들어 상태를 전이시키는 길과, 결과가 확정된 뒤 한 번만 쓰는 길이 있다.

결과가 확정된 뒤에만 이벤트를 발행하는 메시지 이력 모델. 응답을 받으면 보낸 메시지 전부에 종결 상태를 귀속시키고, 응답 자체를 받지 못한 경우에만 재시도 소진 후 ERROR를 남긴다.

상태 전이 방식은 진행 중인 발송을 관측할 수 있다. 지금 몇 건이 접수 대기인지, 어느 그룹이 멈춰 있는지가 이력에서 바로 보인다. 무엇보다 호출 자체가 throw해도 “보내려 했다”는 행이 이미 남는다. 분모에서 빠지던 throw 건이 여기서 그냥 잡힌다. 대신 미종결 행이 생긴다. 응답을 영영 못 받은 행이 REQUESTED로 남고 이걸 정리하려면 sweep이 필요하다.

확정 후 기록 방식은 메시지 1건이 이력 1행으로 끝나고 상태 전이가 없다. 미종결 행이 없으니 sweep도 필요 없고 적재량도 상태 수만큼 늘지 않는다. 대신 진행 중 상태를 이력에서 볼 수 없고 throw 케이스는 재시도를 소진한 뒤에야 기록된다.

확정 후로 정했다. 접수 사실은 이미 BullMQ job이 갖고 있어서 이력 DB가 그걸 복사하면 적재량만 늘고 두 기록이 어긋날 여지가 생긴다. 진행 상황은 큐 대시보드에서 보면 된다. 남기는 상태는 셋이다.

상태 의미 발행 시점
ACCEPTED 접수 성공 응답의 성공 목록에 매칭
FAILED 접수 실패 응답의 실패 목록에 매칭
ERROR provider 호출 자체가 throw 재시도 소진 또는 재시도 불가

짚어둘 것이 있다. 이 셋은 전부 접수까지의 결과다. ACCEPTED도 provider가 요청을 받아줬다는 뜻이지 단말에 도달했다는 뜻이 아니다. 실제 도달 여부는 provider가 한참 뒤에 웹훅으로 알려주는 별개의 사건이고, 그 결과도 웹훅을 받아 이력에 남긴다. 방식은 같다. 이미 쓴 행을 고치는 게 아니라 확정된 결과를 별도 이벤트로 append한다. 상태 전이 없음이라는 원칙은 웹훅이 들어와도 유지된다. 그 웹훅을 어디서 받을지는 저장소를 고른 뒤에 저울질한다.

확정 후 기록에서 지켜야 할 불변식이 하나 있다. 응답 하나를 처리하고 나면 그 요청으로 보낸 메시지 전부가 종결 상태를 얻어야 한다. 응답의 어느 목록에도 담기지 않은 메시지가 미종결로 남는 순간, 없다고 했던 sweep이 도로 필요해진다. 그래서 매칭 규칙은 남는 것 없이 전부 귀속시키는 방향으로 잡는다. 구체적인 매칭 방식은 provider 응답 포맷에 달린 구현 문제라, 설계에서는 이 불변식까지만 정한다.

전체가 4xx로 거부된 경우도 응답을 받은 실패이므로 FAILED로 흡수하고 throw하지 않는다. job은 completed로 끝난다. 재시도해봐야 결과가 같은 실패에 attempts를 쓰지 않기 위해서다. 응답 자체를 받지 못한 경우에만 throw하고 BullMQ가 3회를 소진했을 때 ERROR를 남긴다.

저장소는 append만 할 수 있으면 된다. UPDATE도, 미종결 행을 훑는 배치도 필요 없다.

3. 이력의 멱등을 어디서 보장할 것인가

재시도는 어디에나 있다. BullMQ가 job을 다시 돌릴 수도 있고 파이프라인 중간에서 같은 이벤트가 두 번 흐를 수도 있다. 그때마다 같은 발송이 이력에 두 번 적힐 수 있다. 여기서 정하는 건 이력의 멱등이다. 발송 한 건은 이력에서도 한 건이어야 한다. 같은 메시지를 두 번 보내지 않는다는 발송의 멱등은 별개 문제고 저장소를 고르는 절에서 다시 나온다. 걸러낼 자리는 크게 둘이다. 흐르는 중간, 아니면 최종 이력.

파이프라인 중간에서 exactly-once를 만들려면 처리한 키를 기억하는 상태 저장소가 하나 더 필요하다. 그 저장소가 새 장애점이 되고, 결국 중복 제거기를 누가 중복 제거하느냐는 문제로 돌아온다.

전달은 at-least-once로 두고, 이력의 멱등은 최종적으로 이력을 사용하는 곳에서 보장하기로 했다. 중간에 같은 이벤트가 몇 번 흘러도, 이력을 소비하는 시점에 메시지 1건이 1행으로 보이면 충분하다. 수단은 저장소가 정할 일이다. 유일성 제약을 걸 수 있는 저장소라면 PK나 UNIQUE로 적재 시점에 끝내면 되고 제약을 강제할 수 없는 저장소라면 조회에서 걸러낸다.

전제는 하나다. 같은 발송이 몇 번 재시도돼도 식별자가 변하지 않아야 한다. 식별자가 키 역할을 하지 못하면 어느 층에서도 중복을 알아볼 수 없다.

저장소에 거는 조건은 유일성을 보장할 수단이다. 메시지 단위든 발송 그룹 단위든 상관없다.

결정을 데이터에 투영하면

셋을 정하고 나면 데이터 형태는 따라 나온다. 요구사항 절의 추상 항목들이 여기서 처음 구체적인 필드가 된다. 수신자 식별자는 user_id, 최종 결과는 status, 실패 사유는 status_code, 발송 내용은 variables다.

export type NotificationHistoryGroup = {
  group_id: string;   // BullMQ job.id 기반 — 재시도에 걸쳐 고정
  sent_at: Date;
  template_type: string;
  notification_type: string;
  entries: NotificationHistory[];
};

// 그룹 내 엔트리 = 수신자 1명의 결과
type NotificationHistory = {
  message_id: string;
  user_id: number;
  status: 'ACCEPTED' | 'FAILED' | 'ERROR';
  status_code?: string;
  variables?: Record<string, string>;
};

모양 곳곳에 방금의 결정이 박혀 있다. statusREQUESTED가 없는 건 확정 후에만 쓰기로 한 결정 2의 결과다. message_id가 멱등의 키인 건 결정 3의 결과다. group_id를 job.id로 고정한 것도 같은 맥락이다. 같은 job이 여러 번 돌아도 그룹 식별자가 변하지 않아야 어느 층에서든 중복을 알아볼 수 있다. 결정을 반대로 내렸다면 이 타입에는 REQUESTED와 상태 전이 시각이 있었을 것이다.

이 모양 자체가 저장소에 거는 조건도 하나 만든다. 그룹과 엔트리로 중첩된 구조를 두 단위로 펼칠 수 있어야 한다. 이 조건 하나가 뒤에서 선택지 하나를 그대로 탈락시킨다.

설계가 저장소에 거는 조건

셋을 정하고 나니 저장소에 요구할 것이 이렇게 남았다.

  • 발송 경로 밖에서 쓸 수 있을 것
  • 그룹·엔트리 두 층 구조를 두 단위로 펼칠 수 있을 것
  • append만으로 충분할 것 (UPDATE·sweep 불필요)
  • 메시지 단위 유일성을 보장할 수단이 있을 것 (제약 또는 조회 창구 통제)
  • 가공 전 원본을 아카이빙할 자리가 있을 것
  • 여기에 처음 조건에서 온 집계 스캔, 2년 보존, 유저 테이블 조인

이제 저장소를 고른다

네 안을 같은 잣대로 봤다. 위 조건에 더해 저장소마다 값이 갈리는 것 셋을 얹었다. 저장소가 둘로 나뉠 때 어긋난 것이 스스로 맞춰지는가(정합성), 쓰고 나서 조회에 잡히기까지 얼마나 걸리는가(최신성), 관리할 것이 몇 개이고 비용은 얼마인가(운영 부담)다.

1. 관계형 DB


flowchart LR
    accTitle: 이력 전용 관계형 DB
    accDescr: 워커가 provider 호출 직전에 메시지 행과 REQUESTED 상태를 쓰고, 응답을 받은 뒤 마감 상태와 provider 식별자를 각각 추가한다. 분석이 필요하면 별도 파이프라인으로 분석 플랫폼에 다시 옮겨야 한다.
    W["Worker"] -->|"호출 직전"| M[("message
status: REQUESTED")] W -->|"응답 후"| S[("status: ACCEPTED · FAILED · ERROR
provider: groupId · messageId")] M --> SQL["SQL 조회
CS·운영"] S --> SQL SQL -. "분석하려면 재적재" .-> AP["분석 플랫폼"]

설계 절의 결정대로라면 이 안은 출발부터 불리하다. 그런데도 첫 후보로 자세히 보는 이유가 있다. 설계 결정 1번과 2번을 반대로 답할 수 있게 해주는 유일한 후보이기 때문이다. 나머지 셋은 그 선택지를 주지 않는다. 그러니 여기서 볼 것은 결정을 되물릴 만큼의 값을 주는가다.

provider 호출 직전에 메시지 행과 REQUESTED를 쓰고 응답을 받은 뒤 마감하면, 호출이 통째로 throw해도 흔적이 남는다. 재시도를 소진할 때까지 기다릴 필요가 없다. 멱등도 제약으로 잡힌다.

UNIQUE KEY uk_message_id_status (message_id, status)

INSERT IGNOREREQUESTED를 넣었는데 0행이 들어가면 이미 시도한 발송이라는 뜻이다. 그 자리에서 UnrecoverableError를 던져 BullMQ 재시도를 끊으면 중복 발송이 원천 차단된다. 이력의 중복을 거르는 건 어느 안이든 할 수 있다. 하지만 발송 자체의 중복을 막는 건 발송 전에 제약 있는 저장소에 기록할 때만 가능하다.

거기에 저장소가 하나라 정합성 문제 자체가 생기지 않고 쓰자마자 읽히므로 최신성도 가장 좋다. 익숙하고, 마이그레이션 도구도 있고, 조회도 SQL이다.

대신 치를 것이 셋 있었다.

마감 UPDATE가 병목이 된다

메시지마다 provider가 돌려주는 식별자가 다르므로, 응답을 받고 행마다 UPDATE를 날리게 된다. 2만 건 청크를 가정하고 로컬 MySQL 8.4에서 재봤다.

마감 방식 flush_log=1 flush_log=2
행별 UPDATE 19,000건 8.8초 6.6초
multi-row INSERT 19청크 0.56초 0.56초

innodb_flush_log_at_trx_commit을 완화해도 25%밖에 줄지 않았다. 병목이 fsync가 아니라 커밋 횟수였기 때문이다. 19,000번 커밋과 19번 커밋의 차이다.

해법은 있다. provider 식별자를 UNIQUE(message_id)를 건 별도 테이블에 insert-only로 쌓으면 마감이 multi-row INSERT 한 번으로 끝나고 같은 응답이 두 번 와도 키 중복으로 걸러진다. 다만 이건 그렇게 설계했을 때의 이야기다. 처음 그린 대로 message 행을 UPDATE했다면 대량 발송에서 그대로 병목이 됐을 것이다.

미종결 행을 직접 치워야 한다

발송 전에 기록하는 대가다. 호출은 나갔는데 응답을 쓰기 전에 워커가 죽으면 REQUESTED인 채로 남는다. 접수가 됐는지 아닌지를 우리 쪽에서는 알 수 없으니 자동 재발송도 위험하다.

그래서 sweep이 필요하다. 10분마다 최근 24시간에서 REQUESTED 이후 30분이 지났는데 마감 상태도 provider 행도 없는 건을 찾아 메트릭으로 올린다. 사람이 provider 콘솔에서 실제 발송 여부를 확인하고 안 나간 것만 재등록한다. 운영 부담이 코드가 아니라 이 사이클에서 생긴다.

인스턴스를 하나 더 소유하게 된다

운영 DB에 같이 두는 선택지가 먼저 있었다. 조인도 되고 외래 키도 걸 수 있다. 물량 때문에 접었다. 정기신고 기간에 일 평균 62만 건, 기간 합계 2,600만 건이 나갔고 그것도 시간대별로 고르지 않게 몰린다. 1년이면 메시지 행이 4,000~5,000만, 상태 행은 1억을 넘긴다. 이 쓰기가 운영 트랜잭션과 buffer pool과 IOPS를 두고 경합한다.

분리해야 하는 이유가 하나 더 있다. 위 표의 flush_log=2인스턴스 전역 설정이라 테이블별로 나눌 수 없다. 이력은 1초쯤 유실돼도 provider에서 백필하면 되지만 결제는 아니다. 두 워크로드가 한 인스턴스에 있으면 이력만 완화하는 선택 자체가 성립하지 않는다.

그래서 인스턴스를 따로 세우게 되고 스키마 마이그레이션·purge 배치·개인정보 삭제 파이프라인이 전부 그 인스턴스 몫으로 붙는다. 보존은 12개월만 온라인에 두기로 했다. 그런데 월별 파티셔닝이 멱등 가드로 쓰는 UNIQUE 키 구조와 충돌해서 PK 범위 청크 삭제로 갔다. 하나 정하면 하나가 따라오는 식으로 계속 늘어난다.

사소하지만 성격을 잘 보여주는 것도 있었다. error_message VARCHAR(500)에 provider throw의 에러 메시지를 그대로 넣으면 URL과 응답 body 조각이 붙어 500자를 쉽게 넘긴다. strict 모드에서는 잘리는 게 아니라 INSERT가 실패한다. 에러를 기록하는 INSERT가 에러 메시지 길이 때문에 실패한다. 스키마가 고정된 저장소를 쓰면 이런 자리를 하나씩 막아야 한다.

그래도 이 안이 맞는 때

조회 패턴 문제는 끝까지 남는다. 넷 중 셋이 집계라 인덱스로 얼마간 버티다가 결국 분석 플랫폼에 한 번 더 옮기게 되고, 그러면 RDS는 중간 기착지가 된다. 그럼에도 아래에 해당하면 이 안이 가장 단순하다.

  • 발송 직후 즉시 일관된 조회가 필요하다. 보냈는데 목록에 아직 안 보이면 곤란한 화면이 있다면 분 단위 지연을 감수할 수 없다.
  • 이력이 발송의 성립 조건이다. INSERT IGNORE 가드처럼 이력 쓰기가 곧 중복 발송 방지라면 설계 결정 1번의 답이 달라진다.
  • 물량이 작다. 집계도 그냥 SQL로 충분한 규모라면 저장소를 둘로 나눌 이유가 없다.
  • 분석 창구가 조직에 없다. 별도 분석 플랫폼이 없다면 이중 적재라는 단점 자체가 성립하지 않는다.

이 넷에 해당하는지가 이 안을 고를지 가르는 기준이 된다.

참고 — 트랜잭셔널 아웃박스 패턴

DB 상태 변경과 이벤트 발행을 원자적으로 묶어야 할 때 쓰는 패턴이다. 이벤트를 그 자리에서 내보내는 대신 같은 트랜잭션 안에서 outbox 테이블에 행으로 남기고, 별도 relay가 그 행을 읽어 밖으로 내보낸다. 커밋됐으면 이벤트도 반드시 나가므로, 상태는 바뀌었는데 이벤트만 유실되는 이중 쓰기 문제가 사라진다.

애플리케이션이 하나의 로컬 트랜잭션으로 비즈니스 테이블과 outbox 테이블에 함께 쓰고, 별도 relay가 outbox 행을 폴링 또는 CDC로 읽어 분석 플랫폼에 at-least-once로 전달하는 트랜잭셔널 아웃박스 패턴 구조.

대가도 있다. relay라는 운영 요소가 하나 늘고 최신성이 relay 주기만큼 밀린다. 전달이 at-least-once라 소비 쪽에는 멱등도 필요하다.

후보로 올리지 않은 이유는 하나다. 이 패턴은 상태 변경이 로컬 트랜잭션일 때 성립하는데, 우리 발송의 상태 변경은 provider HTTP 호출이라 트랜잭션에 넣을 수 없다. 아웃박스를 쓰려면 발송 경로에 트랜잭션 저장소부터 들여와야 하고, 그러면 결정 1이 끊어낸 의존이 도로 생긴다.

다만 관계형 DB 안을 골랐다면 이 패턴이 자연스럽게 따라온다. message 행이 곧 outbox 행이고 분석 플랫폼으로 옮기는 재적재가 relay가 된다. 위에서 중간 기착지라고 부른 것이 이 구조다.

2. S3 직렬 파이프라인

워커가 S3에 직접 PutObject로 이벤트를 적재하고, s3:ObjectCreated 알림이 SQS와 DLQ를 거쳐 Lambda로 전달되어 분석 플랫폼에 적재되는 직렬 파이프라인.

워커는 이벤트를 S3에 쓰는 것으로 끝이고, 객체 생성 알림이 SQS를 거쳐 Lambda로, 다시 분석 플랫폼으로 흐른다. 적재가 발송 경로 밖에 있고, Lambda가 중첩 구조를 두 테이블로 펼쳐 넣고, 집계는 분석 플랫폼이 맡는다. 설계가 건 조건에 그대로 맞는다.

S3가 단일 원천이라는 게 이 구조의 핵심이다. 두 저장소가 어긋날 일이 없고 분석 플랫폼 쪽이 유실되면 객체를 다시 흘리면 백필이 된다. 사람이 대조할 일이 없다.

중간에 SQS를 끼운 데는 이유가 있다. S3는 SNS·SQS·Lambda·EventBridge로만 이벤트를 보낼 수 있는데, SNS와 Lambda는 기본 설정에서는 재시도가 소진되면 이벤트를 보관하지 않고 버린다. Lambda 동시 실행 한도에 걸리곤 하는 환경에서는 이게 조용한 유실이 된다. 실패분을 남기려면 어차피 DLQ로 SQS를 붙이게 되니, 처음부터 SQS를 경로에 두는 편이 낫다. 최대 14일 보존에 DLQ까지 붙여 재시도 버퍼가 된다.

값은 직렬이라 앞이 막히면 뒤도 함께 뒤처진다는 것이다. 다만 멈추는 게 아니라 늦어지는 것이고 복구되면 쌓인 것을 스스로 따라잡는다.

소비처가 사후 분석 위주여서 수 초에서 수 분의 지연을 받아들일 수 있고 아카이브 원본과 분석 테이블이 항상 같아야 한다면 이 구조가 맞다. 실시간성이 요구사항이라면 다음 안을 봐야 한다.

3. SNS fan-out

워커가 SNS에 발행하고, SNS가 Firehose를 거치는 S3 아카이브 경로와 SQS를 거치는 분석 플랫폼 적재 경로로 각각 독립 fan-out하는 구조.

워커가 SNS에 발행하고 SNS가 아카이브 경로와 분석 경로로 브로드캐스트한다. 발송과 끊겨 있다는 점은 앞의 안과 같고 여기에 두 경로가 독립이라는 성질이 붙는다. S3 장애가 분석 플랫폼 적재를 막지 않고 최신성이 초 단위가 된다.

내주는 것은 정합성이다. 한쪽만 실패하면 S3와 분석 플랫폼이 어긋난 채로 남고 누군가 백필 스크립트를 돌려야 한다. 직렬에서 “늦어졌다가 따라잡는다”였던 것이 여기서는 “어긋난 채로 남는다”가 된다. 관리할 리소스도 하나 는다. SNS 메시지 크기 한계가 256KB라 대량 발송 이벤트를 쪼개는 로직도 필요하다. 비용은 직렬 대비 SNS 발행과 소비만큼 늘어 평시 월 $3~4, 피크에는 $13 안팎이 붙는다.

이 안이 맞는 때는 실패율이 실시간 대시보드에 떠야 하거나 이상 징후를 초 단위로 잡아야 할 때다. 결국 최신성을 살 것이냐 완결성을 살 것이냐의 문제다. 우리 쪽은 소비처가 전부 사후 분석이라 초 단위 최신성을 얻자고 정합성과 수동 백필을 치를 이유가 약했다.

4. Firehose 경유

단건 발송은 Firehose 버퍼를 거치고 다건 발송은 Firehose를 우회해 S3에 직접 적재하는, 경로가 둘로 갈라진 구조.

앞의 두 안과 나란히 놓기는 했지만 성격이 조금 다르다. S3에 넣는 방식만 바꾸는 것이라 직렬이든 fan-out이든 그 앞단에 끼워 넣을 수 있다.

끼우는 이유는 비용이다. S3에는 여러 객체를 한 번에 올리는 API가 없어서 단건 발송이 많으면 job 하나가 객체 하나가 된다. 피크에 월 1,100만 개가 넘는 PUT이 발생하고 요금이 그대로 따라온다.

피크 기준 월 비용 S3 직접 PUT Firehose 경유
PUT·ingest ~$56.7 ~$1.84
객체 단위 SQS·Lambda ~$16.4 ~$0.05
합계 ~$73 ~$1.9

버퍼링해서 묶으면 이 차이가 사라지는데 Firehose를 쓰면 그 버퍼링을 직접 구현하지 않아도 된다. 다만 레코드마다 5KB 배수로 올림해 과금한다. 단건 job은 실제 555B인데 5KB로 청구되니 9배다. 실사용 12.7GB가 청구 기준으로는 63.3GB가 되는 게 이 구조 때문이다. 그래도 절대 금액은 여전히 Firehose가 싸다.

싼 쪽을 안 고르게 만드는 요인은 셋이다.

  • 관리 포인트가 하나 늘어난다. 스트림 설정, 버퍼 크기, 플러시 주기가 운영 대상이 된다.
  • 버퍼가 찰 때까지 기다리므로 최신성이 버퍼 주기만큼 밀린다. 60초로 잡으면 1분, 900초로 잡으면 15분이다.
  • 발송 그룹별로 파일을 나눌 수 없다. 중첩 NDJSON을 쓰면 동적 파티셔닝이 걸리지 않는다. 다건 발송은 이미 배치라 Firehose를 우회하는 편이 낫고, 그러면 경로가 둘로 갈라진다. 위 그림이 그 모양이다.

이 안이 맞는 때는 S3를 순수 아카이빙 용도로만 쓰고 직접 조회하거나 백필 원천으로 삼을 일이 없을 때다. 반대로 객체 하나가 발송 그룹 하나에 대응해야 한다면 단순함을 사는 대신 PUT 비용을 지불하게 된다.

나란히 놓고 보면

기준 관계형 DB S3 직렬 SNS fan-out Firehose 경유
집계 조회 인덱스로 버티다 한계 분석 플랫폼이 맡음 분석 플랫폼이 맡음 분석 플랫폼이 맡음
발송과의 결합 동기면 전파 끊김 끊김 끊김
이력 멱등 적재 시점, 제약으로 조회 시점 dedup 조회 시점 dedup 조회 시점 dedup
미종결 행 sweep 필요 없음 없음 없음
정합성 저장소가 하나 자동 복구 수동 백필 자동 복구
최신성 즉시 수 초~수 분 초 단위 버퍼 주기(1~15분)
관리 대상 인스턴스·sweep·purge·PII S3·SQS·Lambda 위 + SNS 위 + 스트림
평시 월 비용 ~$70 ~$11 ~$15 ~$5
피크 월 비용 ~$360 ~$79 ~$92 ~$7

비용은 전부 S3 직접 PUT 기준이다. Firehose 열은 직렬에 끼웠을 때이고, fan-out에 끼우면 평시 ~$8, 피크 ~$21이 된다.

피크에서 순서가 뒤집히는 게 눈에 띈다. 평시에는 직렬이 관계형 DB보다 여섯 배 싸다. 피크로 가면 PUT 요청이 비용의 대부분을 차지해 $79까지 올라 관계형 DB와 비슷해진다. 그 구간에서 Firehose를 끼우면 다시 열 배 가까이 내려간다. 관계형 DB는 반대로 물량이 늘수록 인스턴스를 키워야 해서 격차가 벌어진다.

우리 선택은 이렇게 정리됐다. 관계형 DB는 결정 1과 2를 되물릴 이유가 없었다. 이력이 발송의 성립 조건인 것도, 발송 전 기록이 필요한 것도 아니었다. 즉시 조회 요구도 없어서 탈락했다. 남은 셋 중에서는 평시 구간의 운영 부담을 기준으로 S3 직렬을 골랐다. 접으면서 내려놓은 것도 있다. 발송 전에 기록해 중복 발송을 원천 차단하는 가드는 이 구조에 없으므로, 워커가 호출 후 결과를 남기기 전에 죽는 드문 경우의 중복 발송은 수용한다. 피크가 상시가 되면 Firehose를 끼우는 게 다음 수순이고 구조를 바꾸지 않고 앞단만 갈아 끼우면 된다.

표에 올리지 않은 두 안

분석 플랫폼의 관리형 적재 서비스는 S3까지만 두고 주기 로드를 맡기는 방식이다. 코드가 0줄이고 비용도 거의 없다. 다만 파일을 그대로 로드하기 때문에 앞에서 정한 중첩 구조를 두 테이블로 펼칠 수 없다. 조건 하나에 걸려 그대로 탈락했다. 적재 지연이 최소 15분이라는 점, 분석 플랫폼 쪽 설정이 AWS를 알아야 한다는 점도 감안해야 한다. 이벤트가 중첩 없는 구조라면 이게 가장 싸고 단순한 답이다.

서드파티 조회 API에 캐시를 씌우는 방식은 앞에서 적은 문제를 그대로 물려받는다. 보관 6개월과 429 한도에 종속되고 throw 케이스는 애초에 상대에게 이력이 없다. 다만 특정 유저의 최근 발송을 보여주는 정도의 단순 점 조회는 오히려 이쪽이 낫다. 분석 플랫폼은 조회량에 비례해 비용이 붙으므로 자주 열리는 화면을 스캔으로 채우는 건 낭비다. 역할이 다르다. 서로 대체하지 않는다.

웹훅 수신 경로

결정 2에서 도달 결과는 웹훅으로 받아 별도 이벤트로 남기기로 했다. 남은 건 받는 쪽이다. 지금까지는 우리가 밖으로 내보내는 흐름이었다면, 웹훅은 서드파티가 우리를 호출하는 인바운드다. HTTP 엔드포인트를 하나 열어야 한다.

워크로드 성격부터 보자. 평소에는 거의 없다가 대량 발송 직후에 몰려오는 저용량 스파이크형이다. 그리고 서드파티는 응답을 못 받으면 같은 결과를 재전송한다. 중복이 올 수 있다는 뜻인데, 이건 결정 3의 원칙대로 이력 층에서 걸러지므로 수신부가 신경 쓸 필요가 없다. 수신부가 할 일은 셋으로 좁혀진다. 요청을 검증하고, 종결 상태만 남기고 중간 상태는 버리고, 즉시 200을 돌려준 뒤 적재는 비동기로 넘긴다.

종착점은 이미 있다. 검증과 필터를 거친 이벤트를 이력 S3에 넣으면 그 뒤는 앞에서 만든 파이프라인이 그대로 받아준다. S3를 단일 원천으로 둔 설계의 보너스다. 유입 경로가 하나 늘어도 뒤는 바뀌지 않는다. 그러니 저울질할 것은 HTTPS부터 S3까지의 앞부분뿐이고 버퍼링이 필요하면 Firehose를 끼울 수 있다는 것도 앞에서 본 그대로다.

1. 공유 ALB에서 애플리케이션으로

서드파티 웹훅이 공유 ALB의 IP 타깃 그룹을 거쳐 클러스터 안의 워커 pod로 직접 들어오고, pod가 검증·필터 후 버퍼를 거쳐 이력 S3에 적재하는 구조.

웹훅도 그냥 서비스 코드로 받는다. ALB가 클러스터 안 pod로 바로 라우팅하고 pod가 검증·필터·적재까지 한다. 배포·로깅·관측이 기존 서비스와 동일하고 검증이나 필터에 도메인 로직이 필요해지면 코드를 그대로 재사용한다.

대신 스파이크가 서비스와 자원을 다툰다. 웹훅이 몰려오는 시점은 정확히 대량 발송 직후라 워커도 바쁠 때고 HPA가 늘려주기까지의 시차 동안 노드 CPU를 나눠 쓴다. 웹훅 처리에 서비스의 코드와 데이터가 필요하거나 팀이 관리형 조각을 늘리고 싶지 않다면 이 안이 맞다.

2. 공유 ALB에서 Lambda로

서드파티 웹훅이 기존 공유 ALB로 들어와 Lambda 타깃 그룹으로 전달되고, Lambda가 검증·필터 후 버퍼를 거쳐 이력 S3에 적재하는 구조. 트래픽이 EKS 클러스터에 들어가지 않는다.

ALB는 그대로 두고 타깃만 pod에서 Lambda로 바꾼다. 트래픽이 클러스터에 들어가지 않는다. Lambda가 검증하고 즉시 200을 돌려준 뒤 S3로 넘긴다. VPC 밖에서 돌리면 NAT Gateway 비용도 없다.

값은 관리 주체가 걸쳐진다는 것이다. 라우팅 규칙은 k8s Ingress 애너테이션으로, Lambda는 IaC로 관리돼 경계가 두 곳에 나뉜다. 이 안이 맞는 때는 공유 ALB가 이미 있고 수신 부하를 서비스 자원과 격리하고 싶을 때다.

3. API Gateway로 받는다

서드파티 웹훅이 API Gateway HTTP API로 들어와 SQS 통합으로 큐에 바로 쌓이고, Lambda가 배치로 꺼내 검증·필터 후 이력 S3에 적재해 기존 파이프라인에 합류하는 구조.

아예 우리 인프라 밖으로 나가는 안이다. API Gateway의 SQS 네이티브 통합으로 요청을 큐에 바로 넣는다. 접수 시점에 컴퓨트가 없다. Lambda가 배치로 꺼내 검증하고 종결 상태만 걸러 S3에 넣는다. 로드밸런서도 클러스터도 없이 전부 관리형이고 요청당 과금이라, 스파이크는 SQS가 흡수하고 평시 비용은 0에 수렴한다.

대신 검증이 큐 뒤로 밀린다. 서명이 틀린 요청도 일단 큐에 들어가고, 걸러지는 건 소비 시점이다. 재사용할 인프라가 없거나 수신부를 발송 인프라와 완전히 분리하고 싶다면 이 안이 맞다.

켜기 전에 선행 조건이 있다

비용은 세 안 모두 연 단위 수십~수백 달러 수준이라 결정적이지 않다. 갈리는 축은 스파이크를 누가 흡수하느냐(pod냐, Lambda 동시 실행이냐, 큐냐)와 관리 포인트가 어디에 생기느냐다.

그리고 어느 경로를 고르든 그 전에 정리할 일이 있다. 웹훅은 계정 단위로 온다. 우리 시스템 밖에서 나간 발송, 그러니까 콘솔 수동 발송이나 외부 도구 연동의 결과도 같은 엔드포인트로 들어온다. 매칭할 이력이 없는 결과는 내용 없는 고아 행으로만 쌓인다. 발송 경로를 시스템으로 일원화하는 것이 먼저고, 우리가 수신 경로 선택을 서두르지 않은 이유이기도 하다.

정리

S3를 단일 원천으로 두고 SQS와 Lambda를 거쳐 분석 플랫폼으로 흘리는 구조가 됐다. sent_at 일자 파티션에 2년 만료를 걸고 user_id로 클러스터링했다. 이력의 멱등은 결정대로 최종 이력 층에서 보장한다. 분석 플랫폼의 PK는 유일성을 강제하지 않으므로 수단은 message_id 기준 dedup 뷰가 된다. 개인정보 삭제 요청은 분석 플랫폼에서 user_id 기준 DML로 처리하고, 사실상 불변인 S3 객체는 3년 수명주기 만료로 대응한다. 관계형 DB 안이 삭제 파이프라인으로 치렀을 비용을, 이 구조는 원본이 만료될 때까지의 잔존 기간으로 치르는 셈이다. Lambda가 분석 플랫폼으로 쓰는 구간은 Workload Identity Federation으로 붙였다. 서비스 계정 키 JSON을 배포하면 로테이션 부담이 생기고 유출되면 무기한 유효하기 때문이다. 도달 결과 웹훅도 검증만 거쳐 같은 S3로 합류시킨다. 수신 경로는 발송 일원화가 끝나는 시점에 확정한다.

문제와 설계를 먼저 그려두고 나니 저장소 고르기는 오히려 기계적이었다. 조건을 하나씩 대보면 후보가 저절로 줄어든다. 반대로 저장소부터 정했다면 그 저장소가 잘하는 쪽으로 이력 모델이 끌려갔을 것이다.

물론 설계 결정 자체도 조건에 매여 있다. 발송과 이력이 같은 관심사인가가 그중 첫 단추다. 같은 관심사라고 — 이력이 발송의 성립 조건이라고 — 답하면 선택지는 좁아지는 대신 보장이 강해진다. 관계형 DB가 INSERT IGNORE 하나로 중복 발송을 막아주는 게 그 보장이다. 다른 관심사라고 답하는 순간 지연과 중복을 허용할 수 있게 되고 선택지가 넓어지는 대신 그 보장을 직접 만들어야 한다. 이 질문에 먼저 답하고 나머지를 따지는 게 순서다.

참고