Spring Boot 내부 API로 알림 서비스 경계 그리기

백엔드

마냑은 사용자가 설정을 넣으면 AI가 스토리를 만들고 그 스토리 속 인물과 채팅하는 서비스다. 앞 글은 서버 안에 내부 API를 만들어 흩어진 발송 자격 판정을 한 곳으로 모으겠다는 예고로 끝났다.

분리 전에는 같은 질문을 세 곳에서 서로 다르게 답하고 있었다. 이번에는 회원 데이터와 판정 기준을 서버에 남긴 채 알림 서비스에 공개할 경계를 API 하나로 정했다.

흩어진 판정을 내부 API 하나로 모으기

분리 전에는 “이 알림을 보내도 되는가”라는 판정이 세 군데에 흩어져 있었다. 발송기는 회원이 활성인지만 확인했다. 스토리 완성 리스너는 서비스 알림 동의를 따로 봤고 출석과 프로모션 서비스는 광고 동의와 야간 시간 판정을 각자 처리했다.

서비스를 나누지 않았다면 그대로 둘 수 있는 중복이었다. 하지만 알림 서비스가 회원 테이블을 직접 읽지 않게 하려면 서버가 하나의 답을 내놓아야 했다. 이 판정을 서버의 내부 API 하나로 모았다.

GET /internal/users/{publicId}/push-eligibility?kind&at

kindSERVICE 또는 MARKETING이다. 서비스 알림과 광고가 확인하는 동의가 다르기 때문이다. 응답에는 발송 허용 여부와 이유 그리고 기기 토큰 목록이 들어간다. 거절된 회원의 토큰은 응답에 넣지 않고 허용일 때만 돌려준다.

이 API가 돌려주는 값이 알림 서비스가 회원에 대해 알게 될 전부다. 응답에 없는 값은 알림 서비스가 알지 못한다. 어디까지 돌려줄지가 곧 서비스 경계가 됐다.

판정 시각을 호출자가 넘기기

at은 필수다. 광고를 보낼 수 있는지는 야간 시간인지에 따라 달라진다. 서버가 현재 시각을 추측하지 않고 발송을 요청한 쪽이 판정 기준 시각을 넘긴다. at을 빼면 서버는 400을 돌려준다.

동의와 토큰을 발송 요청에 싣지 않기

알림 요청에 동의와 기기 토큰을 바로 싣는 방법도 있었다. 하지만 요청을 만든 뒤 사용자가 수신을 끄면 이전 동의 상태로 광고가 나갈 수 있다. 요청 생성과 실제 발송 사이에 시간이 벌어질수록 오래된 스냅샷을 믿기 어렵다.

알림 서비스는 발송 직전에 자격 API를 다시 호출한다. 회원 상태와 수신 동의 판정 그리고 기기 토큰 보관은 계속 서버가 맡는다. 알림 서비스는 회원 테이블을 모르며 상태도 거의 없다.

무효 토큰도 서버에서 지우기

FCM이 UNREGISTERED를 돌려주면 알림 서비스는 다음 API로 삭제를 요청한다.

DELETE /internal/push-tokens

기기 토큰 테이블의 소유자가 서버이기 때문이다. 알림 서비스가 테이블을 직접 수정하면 발송 실행만 분리한다는 경계가 흐려진다.

공유 시크릿으로 내부 경계 지키기

내부 API는 공유 시크릿 헤더로 인증한다. 시크릿을 설정하지 않으면 경로의 존재를 숨기려고 404를 돌려준다. 헤더가 없거나 값이 틀리면 401을 반환한다. 값은 MessageDigest.isEqual로 비교해 상수 시간 비교를 적용했다.

회원에 관한 판단은 서버 API 하나로 모였고 알림 서비스에는 그 응답만 공개됐다. 다음은 이 경계를 유지한 채 프로세스를 실제로 나눠 보는 이야기다.

목록으로