멀티채널 알림 서버 구축기 - php 레거시에서 Java/Spring으로

PHP 파일에 흩어져 있던 알림 로직을 Spring Boot 기반 독립 알림 서버로 구축한 과정. 카카오 알림톡, SMS, 이메일, 푸시, Slack까지 6개 채널을 하나의 서버에서 처리하는 구조를 만들기까지.

배경

이 프로젝트의 시작은 앱푸시 기능이 없는 우리 서비스에 앱 푸시를 추가하면서 시작되었다. 현재 우리 서비스는 다양한 채널로 알림을 보낸다. 수업 예약 확인은 카카오 알림톡으로, 결제 실패는 SMS로, 내부 운영 알림은 Slack으로 보내고 있다. 문제는 이 알림들이 시스템 곳곳에 흩어져 있었다는 것이다. 이 상태에서 앱 푸시를 php로 구현하기엔 원하지 않았고 무엇보다 내부적으로 php 서비스를 점점 없애고 있는 추세이다 보니 갑자기 큰 프로젝트르 시작하게 되었다.

~~이 쓰레드로 인해 멀티 테넌트까지 고려하게 되었다.~~

PHP 시절: 파일마다 다른 알림 코드

PHP 레거시에서 알림은 채널별로 완전히 다른 파일에서 처리됐다.

채널별 산발적 구현

각 파일이 외부 API를 직접 호출하고 있었다. Slack: Webhook URL에 CURL로 직접 POST. 채널별로 다른 Webhook URL이 전역 변수에 하드코딩 카카오 알림톡: NHN Cloud(구 TOAST) API를 직접 호출. 발신 프로필 키가 서비스별로 여러 개 SMS: NHN Cloud SMS API. 발신번호 하드코딩 푸시: Expo Push API 직접 호출. 디바이스 토큰 검증 로직이 발송 코드에 섞여 있음 이메일: PHPMailer로 네이버/구글 SMTP 직접 연결. SMTP 비밀번호가 코드에 포함

크론 기반 예약 발송

예약 알림은 크론 작업으로 처리했다. 수업 7일 전 알림, 12시간 전 알림, 수업 후 1일/3일 리마인드 등 - 각각이 독립된 크론 파일이었다.

각 크론이 비즈니스 로직(대상 조회 쿼리)과 발송 로직을 모두 갖고 있었다. 새로운 알림을 추가하려면 크론 파일을 하나 더 만들어야 했다.

DB 기반 자체 큐 - 그리고 Lock 장애

DB 테이블을 큐처럼 써서 발송을 관리했다. SEND_YN = 'N'인 레코드를 크론이 주기적으로 폴링하고, 발송 후 'Y'로 업데이트하는 방식이다. 예약 발송은 RESERVED_SEND_DATETIME 컬럼으로 처리했다.

동작은 했지만, 어느 날 이 구조가 터졌다. 크론이 미발송 레코드를 SELECT ... FOR UPDATE로 잠그고 외부 API를 호출하는데, NHN Cloud 쪽 응답이 평소보다 느려지면서 트랜잭션이 길어졌다. 그 사이 같은 테이블에 INSERT하려는 다른 요청들 - 수업 예약 알림 등록, 결제 완료 알림 등록 - 이 줄줄이 Lock 대기에 걸렸다. 알림 발송 하나가 느려졌을 뿐인데 서비스 전체가 먹통이 된 것이다.

저 때 유저한테 알림 지속적으로 나간거 생각하면 생각만해도 아찔하다.

독립 알림 서버 구축

채널별로 흩어진 코드, 크론 지옥, DB Lock 장애까지 - PHP 알림 시스템의 문제는 명확했다. 이걸 메인 백엔드(Java/Spring)에 그대로 옮겨도 근본적인 문제는 달라지지 않는다. 알림은 외부 API 호출이 많아서 트래픽이 몰리는 시간대에 메인 서버까지 느려질 수 있고, 외부 API 장애가 결제나 수업 예약 같은 핵심 기능을 끌어내릴 위험이 있다.

알림은 비즈니스 로직과 분리해도 되는 영역이다. 메인 서버는 "무엇을 보낼지"만 결정하고, "어떻게 보낼지"는 별도 서버가 담당하는 구조로 가기로 했다. 설계 초기에 Duolingo가 5초 안에 600만 건의 알림을 발송하는 아키텍처를 참고했는데, SQS 기반 비동기 처리와 채널별 분리라는 핵심 아이디어를 많이 차용했다.

기술 스택 Java 25 + Spring Boot 3.5: Virtual Thread로 I/O 바운드 작업 최적화 AWS DynamoDB: 알림 이력, 예약, 앱 설정 저장 AWS SQS: 비동기 메시지 수신 Redis: API 키 캐시, 예약 발송 분산 락

전체 아키텍처

두 가지 호출 방식 HTTP 직접 호출 - 즉시 발송이 필요할 때 SQS 비동기 - 기존 백엔드에서 NotificationService.makeAndSend()로 호출하는 방식을 그대로 유지

기존 코드를 수정하지 않아도 알림 서버가 SQS 메시지를 소비해서 처리한다.

6개 채널 지원

| 채널 | 제공자 | 용도 | ||--|| | 카카오 알림톡 | NHN Cloud | 수업 알림, 결제 안내 | | 카카오 친구톡 | NHN Cloud | 마케팅 메시지 | | SMS | NHN Cloud | 결제 실패 안내 | | 이메일 | NHN Cloud | 리포트, 안내문 | | 푸시 | Expo | 앱 푸시 알림 | | Slack | Slack API | 내부 운영 알림 |

Strategy + Factory 패턴

채널별 발송 로직은 NotificationService 인터페이스의 구현체로 분리했다.

새 채널을 추가할 때 Service 구현체 하나만 만들면 된다.

멀티 테넌트: API Key 기반

서비스마다 독립된 Application을 등록하고, API Key를 발급받아 사용한다.

같은 알림 서버를 여러 서비스가 공유하되, 발신 프로필이나 채널 설정은 서비스별로 독립적이다.

예약 발송