알림 발송을 한 곳으로 — 채널·프로바이더·어댑터를 가른 이유
흩어진 발송 코드와 중계 서버 뒤에 숨은 실패를, 하루 만에 세운 통합 메시징 서비스로 걷어낸 기록 — 발송의 정본을 큐가 아니라 DB 상태 기계에 둔 이유.
서비스가 커질수록 알림 코드가 함께 늘었다. 주문이 확정되면 알림톡, 배차가 잡히면 문자, 앱 사용자에겐 푸시 — 보내는 물건만 다를 뿐 하는 일은 똑같은 코드가 도메인마다 복제됐다. 더 나쁜 건 발송이 사내 중계 서버 뒤에 숨어 있었다는 것이다. "그 문자 나갔어요?"라는 물음에 누구도 우리 로그로 답할 수 없었다. 하루를 잡아 발송을 독립 서비스로 떼어냈고, 이 글은 그날 했던 설계 결정들의 기록이다.
한 줄 요약: 발송을 통합 메시징 서비스(UMS)로 떼어내고, 수집–발송–보고 3단과 채널(무엇으로)·프로바이더(누구를 통해)·어댑터(어디서 가져와) 세 축으로 갈랐다. 발송의 정본은 큐가 아니라 DB 상태 기계다 — 부하가 넘치면 유실이 아니라 지연이 된다.
배경
우리 솔루션은 배출·운반·처리 사업자 사이의 주문, 배차, 계약, 정산을 다루는 B2B 물류 플랫폼이다. 흐름의 마디마다 알림이 붙는다 — 발주 접수 알림톡, 배차 확정 문자, 기사 앱 푸시. 알림은 어느 도메인의 관심사도 아니지만 모든 도메인이 필요로 하는, 떼어내기 딱 좋은 관심사였다.
문제를 느낀 계기
- N×M 복제: 서비스 N개 × 채널 M개만큼 발송 코드가 늘었다. 대행사 호출, 재시도, 실패 로깅이 조금씩 다른 모습으로 곳곳에 있었다.
- 중계 서버 뒤의 실패: 문자 발송이 사내 프록시를 거쳐 나가고 있었다. 실패가 프록시 뒤에 숨어 발송 이력도 실패 사유도 우리 쪽에는 없었고, 실패 원인을 물으면 돌아오는 답은 늘 "대행사에 확인해 보겠습니다"였다.
- 대행사 교체 = 전 서비스 수정: 문자 대행사를 바꾸는 일이 발송 코드를 품은 모든 서비스의 배포가 됐다.
- 예약·재시도의 각자 구현: "이 시각에 보내라"와 "실패하면 다시"를 서비스마다 다르게 구현하고 있었다.
제약
- 등록된 메시지는 유실되면 안 된다 — 프로세스가 죽든 큐가 넘치든.
- 문자 대행사처럼 발송 결과가 콜백으로 나중에 오는 비동기 프로바이더를 수용해야 한다.
- 채널 추가와 프로바이더 교체는 이 서비스 안에서 끝나야 한다.
- 도메인 서비스 입장에서는 등록 API 호출 한 줄이 전부여야 한다.
선택지
1. 공용 라이브러리로 추출 장점: 도입이 쉽다. 단점: 발송 이력이 여전히 서비스마다 흩어지고, 라이브러리 수정이 전 서비스 재배포가 된다.
2. 메시지 브로커 중심 파이프라인 장점: 전달 보장을 인프라가 진다. 단점: 운영물이 하나 늘고, 브로커가 없는 환경에서는 구조 자체가 성립하지 않는다.
3. DB를 정본으로 하는 독립 서비스 + 교체형 어댑터 장점: 이력이 한 곳에 모이고, 어디서든 같은 구조가 선다. 단점: 폴링 지연과 DB 부하를 감수해야 한다.
3을 택했다. 브로커는 포기가 아니라 유예다 — 수집 어댑터 자리를 비워 두고, 필요해지면 어댑터 하나로 붙인다.
결정
단은 셋으로. Collector가 받고, Sender가 보내고, Reporter가 답한다. 받는 쪽은 REST 수신과 저장까지, 보내는 쪽은 채널 발송과 재시도, 답하는 쪽은 이력·통계·조회다. 도메인 서비스는 메시지를 등록만 하고, 그 뒤는 전부 이 서비스의 일이다.
채널과 프로바이더를 가른다. 채널은 "무엇으로 보내나"(SMS·알림톡·앱푸시·웹푸시), 프로바이더는 "누구를 통해 보내나"(문자 대행사, FCM)다. 중계 서버는 걷어내고 대행사를 직접 호출하게 바꿨다 — 실패가 우리 로그에 남아야 다음 단계(재시도, 이력)가 성립한다.
// 채널(무엇으로)과 프로바이더(누구를 통해)를 가른다.
// 같은 SMS 채널의 대행사 교체는 프로바이더 구현 교체로 끝난다.
public interface MessageChannel {
ChannelType type();
SendResult send(Message message); // 동기면 확정 결과, 비동기면 pending
}
어댑터는 "어디서 가져오나"만 안다. 발송 대상을 공급하는 쪽을 fetch() 하나로 추상화했다. 기본 구현은 DB 폴링이다 — 5초마다 발송 대기 행을 집어 큐에 싣고, 큐 뒤의 워커들이 비동기로 발송한다. 폴링과 발송 사이에 큐(용량 1만, 워커 5)를 끼운 것은 폴링 주기와 발송 속도를 서로에게서 떼어 놓기 위해서다.
// 발송의 정본은 DB 상태 기계 — 큐는 운반 수단일 뿐이다.
// 큐가 만석이면 행은 PENDING 으로 남아 다음 폴링이 다시 집는다: 유실이 아니라 지연.
@Scheduled(fixedDelay = 5_000)
public void poll() {
List<Message> batch = messageService.fetchPending(100);
sendQueue.offerAll(batch); // BlockingQueue(10,000) → 워커 5개가 비동기 발송
}
상태 기계가 정본이다. 메시지는 PENDING → SENDING → SENT/FAILED로 흐른다. 큐·스레드·프로세스는 전부 죽을 수 있는 것들이고, 죽어도 되는 것들이어야 한다 — 어디서 죽든 행은 DB에 남고, 폴링이 다시 줍는다. 비동기 프로바이더는 접수 결과만 받고 SENDING으로 남긴 뒤, 대행사 콜백이 최종 상태를 확정한다. 이를 위해 발송 결과에 대행사 쪽 식별자를 함께 저장해 콜백을 매칭할 수 있게 했다.
결과와 한계
- 얻은 것: 하루 만에 3단 구조, 어댑터·큐, 대행사 직접 호출, 콜백 수신 API까지 섰다. 새 서비스의 알림은 등록 한 줄이고, "그 문자 나갔어요?"는 조회 API 하나로 답한다.
- 잃은 것: 폴링 주기만큼의 지연(최대 5초), DB가 큐를 겸하는 데서 오는 처리량 한계.
- 남은 숙제: 비동기 콜백의 실제 규격과 폴링–선점 사이의 경합은 실전 트래픽이 검증해 줄 것이다. 상태 기계의 경계 사례(중복 수집, 콜백 순서)는 도면이 아니라 운영이 가르쳐 준다.
- 되돌릴 조건: 처리량이 폴링의 한계를 넘으면 브로커 구독 어댑터를 붙인다 — 어댑터 인터페이스는 그날을 위해 비워 뒀다.
팀에 남긴 규칙은 한 줄이다: 발송의 정본은 큐가 아니라 DB 상태다.