뉴스레터 서비스
발송 동시성·운영 중 hot 테이블의 온라인 스키마 변경·외부에 떠 있는 식별자의 호환성을 동시에 만족시키도록 설계한 사내 뉴스레터 서비스
기술 스택
개요
마케팅·고객 커뮤니케이션 채널을 외부 솔루션에 의존하지 않고 자체 운영하기 위해 만든 사내 뉴스레터 발송 서비스입니다. NCP 위에 Django + Celery + PostgreSQL + Redis + NGINX가 Docker Compose로 떠 있고, 한 번 발송할 때마다 EmailLog가 수만 건 단위로 쌓이는 고-카디널리티 테이블이 시스템의 성능과 정합성을 사실상 결정합니다.
운영 중에도 인덱스를 추가하거나 트래킹 스키마를 갈아엎어야 하는 환경이라 — 발송 파이프라인의 동시성과 정합성, hot 테이블 위에서의 온라인 스키마 변경, 외부에 이미 발송된 메일 안에 떠 있는 식별자의 호환성 세 가지를 동시에 만족시키는 것을 핵심 과제로 잡고 그에 맞춘 설계 결정을 쌓아 올렸습니다.
기술 스택
- Backend: Python, Django, Celery, Redis, PostgreSQL, NGINX
- Infra: Docker Compose, Naver Cloud Platform (NCP)
역할
PM 1·FE 2·BE 1 팀에서 백엔드 1인 담당. Django WAS 설계, 데이터 모델링, 발송 파이프라인, 추적·통계, 마이그레이션 설계까지 서버 측 전 영역을 책임지며, 2024년 9월부터 신규 기능 개발과 운영 안정화를 함께 진행하고 있습니다.
주요 기여
-
EmailLog를 발송 대기 테이블로 두는 transaction outbox 구조.
- 이메일 발송이 시작되면 받는 사람마다 EmailLog 한 행을 미리 만들어 두는 것이 대기 적재이고, 발송 워커가 한 번에 100건씩 잠금을 잡고 SMTP로 보낸 뒤 성공·실패로 마무리하는 것이 relay입니다. 짧은 트랜잭션을 연쇄로 잇는 구조라 여러 워커가 같은 이메일을 충돌 없이 나눠 처리하고, 워커가 도중에 죽어도 잠금이 풀려 다른 워커가 그 자리에서 이어 처리합니다. (이메일, 구독자) 조합 유니크 제약으로 같은 행이 두 번 발송되는 일이 데이터 계층에서 차단되며, 30분 이상 처리 중인 행을 다시 대기 상태로 되돌리는 회수 작업을 별도로 두어 워커 크래시 복구까지 같은 모델 안에서 흡수했습니다.
-
운영 중에도 인덱스를 추가할 수 있도록 마이그레이션 패턴 자체를 갈아치움.
- 발송 1회마다 수만 행이 쌓이는 EmailLog에서 일반 인덱스 추가는 테이블을 분 단위로 잠가 발송 자체를 멈춥니다. PostgreSQL의 CONCURRENTLY 옵션으로 발송 트래픽이 도는 동안에도 합성 인덱스를 만들 수 있게 하고, 정합성 정리 마이그레이션도 ORM 루프 대신 단일 쿼리로 후보를 추리고 한 번에 삭제하는 패턴으로 다시 짜 — 다섯 개 마이그레이션 합산 422초 → 195초(-54%) 로 줄였습니다. 이후 큰 테이블 변경은 처음부터 같은 패턴을 default로 쓰도록 운영 룰로 정착시켰습니다.
-
외부에 이미 발송된 메일의 트래킹 링크를 깨지 않으면서 스키마를 정규화한 마이그레이션.
- 트래킹 링크 모델을 받는 사람별 트래킹에서 이메일 단위 통합 트래킹으로 정규화하려 했지만, 옛 형식의 트래킹 코드가 이미 외부로 나간 수만 통의 메일 안에 박혀 있어 단순 정규화하면 며칠 후 사람들이 클릭하는 링크가 모두 깨졌습니다. 옛 코드와 새 행을 이어 주는 매핑 테이블을 별도로 두어 옛 코드도 새 행을 가리키도록 보존하고, 클릭 시 옛 코드 → 새 행 해석을 한 단계 추가했습니다. 이미 외부로 나간 식별자는 깰 수 없다는 제약을 명시적 매핑 레이어로 풀어 낸 사례입니다.
트러블슈팅
-
초기 DB 설계가 EmailLink를 받는 사람 단위로 잡고 있어 데이터가 폭증하던 문제.
- 문제: 처음 EmailLink 스키마는 (이메일, 구독자, URL) 조합으로 한 행씩 만들도록 설계돼 있었습니다. 한 이메일의 구독자가 N명이고 추적할 URL이 M개면 EmailLink만 이메일 1회당 N × M 행이 생기는 구조라, 발송이 누적될수록 EmailLink가 시스템에서 가장 빠르게 부풀어 오르는 테이블이 됐습니다. (이메일, 구독자, URL) 조합에 유니크 제약도 빠져 있어 재시도·재추적 시 같은 조합에 중복 행까지 더해졌고, 클릭 추적·통계·리다이렉트의 source of truth가 EmailLink라 행 수가 부풀수록 조회 비용도 함께 늘었습니다.
- 해결: 두 단계로 정리했습니다. (1) 우선 (이메일, 구독자, URL) 조합에 유니크 제약을 추가하고 누적된 중복은 클릭 신호량이 가장 많은 행만 남기고 일괄 삭제 — 같은 종류 중복이 다시 쌓이지 않게 막았습니다. (2) 그다음 본격적인 스키마 재설계: 클릭 이력을 새 EmailLinkHistory 테이블로 분리하고, EmailLink 자체는 (이메일, URL) 단위로 통합해 구독자 차원을 EmailLink에서 제거했습니다. 결과적으로 EmailLink 행 수가 (이메일 × 구독자 × URL)에서 (이메일 × URL)로 줄어, 구독자 수에 곱해지던 multiplicative 차원 자체가 사라졌습니다 — 이미 발송된 메일 안의 옛 트래킹 코드는 별도 매핑 테이블로 호환성을 유지했습니다.
-
워커가 발송 대기 행을 만들다 죽으면 이메일이 침묵 실패하던 문제.
- 문제: 대기 행을 만드는 태스크가 브로커에 넘겨지는 즉시 ack 처리되도록 설정돼 있어, 워커가 대량 적재 작업을 시작하기 전이나 도중에 죽으면 브로커는 이미 처리된 줄로 알고 재배달하지 않았습니다. 결과적으로 EmailLog 행이 한 건도 안 만들어진 채 이메일이 “발송됨” 상태로 끝나는 침묵 실패가 발생 — 운영자 입장에서 어디까지 발송됐는지를 EmailLog로만 알 수 있는데 그 source of truth가 비어 있던 셈이었습니다.
- 해결: 태스크 본문이 정상 종료된 뒤에만 브로커가 ack 하도록 옵션을 바꿔 워커가 도중에 죽으면 브로커가 자동으로 재배달하게 했습니다. 재실행은 유니크 제약 + 충돌 무시 옵션 덕에 안전 — 두 번째 시도가 같은 행을 다시 만들지 않습니다. transaction outbox 구조의 핵심 가정인 “대기 적재의 durability”를 코드 차원에서 지키도록 굳혔습니다.
의의
수십만 건 단위의 메일을 흘리면서도 같은 워커가 같은 row를 두 번 잡지 않고, 인덱스를 추가해야 할 때 발송이 멈추지 않으며, 외부에 이미 떠 있는 트래킹 URL을 깨지 않으면서 내부 스키마를 갈아엎을 수 있게 한 사례입니다. “발송 동시성”과 “운영 변경 동시성”이라는 두 종류의 동시성을 같은 시스템 안에서 만족시키기 위해 outbox·온라인 DDL·legacy 매핑 레이어라는 같은 도구상자를 일관되게 가져다 쓴 점이 가장 만족스러웠습니다.