SMS 수신 추적과 자체 점유인증으로 알림 사각지대 제거
목차
이커머스 운영하다 보면 알림 파이프라인은 "보냈으면 됐다" 구간으로 취급받기 쉽다. 발송 로그에 status=sent 찍히면 그걸로 끝. 그런데 통신사 리포트랑 우리 발송 카운트가 종종 어긋나기 시작하면서, 진짜 봐야 할 건 수신자가 실제로 받았느냐라는 걸 뒤늦게 실감했다. CS로 "고객이 못 받았다" 컴플레인이 오면 추적할 수단이 아예 없었으니까.
이번 작업 두 가지 - SMS 수신 추적 통계 구축, 자체 점유인증 구현 - 는 출발점이 달랐는데 결국 같은 곳에서 만났다. 측정 못 하는 건 개선도 못 한다.
SMS 수신 추적 - "발송"과 "수신"은 다른 이벤트다
발송 API를 호출하는 시점과 수신자 단말에 메시지가 도달하는 시점 사이에는 통신사 라우팅, 수신자 기기 상태, 번호 유효성 등 여러 변수가 끼어 있다. 그런데 우리 시스템은 그 사이를 블랙박스로 놔뒀다.
개선 방향은 비교적 명확했다. 통신사 콜백을 받아서 통계 테이블로 적재하는 것. 회원·주문 양쪽 진입점이 따로 있어서 콜백 수신 포인트를 각각 정리하고, 발송 ID 기준 join으로 결과를 분리 적재했다.
- 발송 ID 기준 join으로
sent / delivered / failed분리 적재 - 실패 사유 코드 정규화 - 통신사마다 raw 에러 메시지 포맷이 제각각이라 매핑 테이블을 따로 뒀다
- 일별 집계는 야간 배치, 실시간 조회는 최근 1시간 범위만 인덱스 뷰로 분리
-- 최근 1시간 실시간 조회용 인덱스
CREATE INDEX CONCURRENTLY idx_sms_log_recent
ON sms_delivery_log (sent_at DESC)
WHERE sent_at > now() - interval '1 hour';
-- 일별 집계 배치 (야간 실행)
INSERT INTO sms_daily_stats (date, total_sent, delivered, failed, top_fail_code)
SELECT
date_trunc('day', sent_at)::date,
COUNT(*),
COUNT(*) FILTER (WHERE status = 'delivered'),
COUNT(*) FILTER (WHERE status = 'failed'),
mode() WITHIN GROUP (ORDER BY fail_reason_code)
FROM sms_delivery_log
WHERE sent_at::date = CURRENT_DATE - 1
GROUP BY 1
ON CONFLICT (date) DO UPDATE
SET delivered = EXCLUDED.delivered,
failed = EXCLUDED.failed,
top_fail_code = EXCLUDED.top_fail_code;
대시보드에 깔아두니 "어제 12% 미수신" 같은 신호가 바로 보인다. 이전엔 보이지도 않던 영역이었는데, 숫자가 생기니까 다음 대응이 자동으로 정렬된다. 특정 통신사 쪽 실패율이 높으면 발신 경로 교체를 검토할 수 있고, 특정 시간대에 미수신이 몰린다면 배치 발송 타이밍 조정 근거가 생긴다.
실패 사유 코드 매핑은 생각보다 손이 많이 갔다. 통신사 A는 ERR_017, B는 UNDELIV_4, C는 그냥 timeout이라고 내려주는 식이다. 이걸 number_inactive / network_error / spam_block / unknown 같은 내부 코드로 정규화해두지 않으면 나중에 분석할 때 각 통신사 문서를 다시 뒤져야 한다. 매핑 테이블 관리가 귀찮더라도 이 시점에 해두는 게 맞았다.
여기서 또 하나 신경 쓴 건 중복 콜백 처리다. 통신사 콜백은 네트워크 이슈로 재전송되는 경우가 있고, 그걸 그냥 두면 수신 카운트가 부풀려진다. idempotency 키를 발송 ID에 걸어두고, 이미 처리된 콜백은 무시하도록 했다. 콜백 URL이 타임아웃으로 응답하면 통신사 측이 재전송하는데, 재시도 정책이 문서에 명확히 안 나와 있어서 실제로 중복이 들어오고 나서야 확인했다. 이제는 외부 콜백 받는 엔드포인트에는 무조건 idempotency 처리를 먼저 끼워넣는다.
자체 SMS 점유인증 - 스펙을 좁히면 구현이 가벼워진다
기존 로그인·가입 플로우는 외부 본인확인 모듈에 전부 태웠다. 법적 요건이 있는 실명확인 흐름에서는 맞는 선택이다. 근데 "이 번호를 이 사람이 지금 들고 있는가"만 확인하면 되는 가벼운 케이스 - 배송지 변경, 쿠폰 수령, 임시 비밀번호 발급 같은 - 까지 동일한 외부 모듈을 태우는 건 과한 스펙이다. 건당 비용에 응답 지연까지 따져보면 더 그렇다.
점유인증과 실명확인은 다른 개념이다. 점유인증은 "해당 번호의 단말기를 소지하고 있음을 증명"하는 것이고, 실명확인은 그 번호가 특정 법인격에 귀속됨을 확인하는 것이다. 전자만 필요한 케이스에 후자를 끌어오면 비용과 복잡도 모두 올라간다.
구조는 일부러 단순하게 잡았다.
| 단계 | 처리 | TTL / 제한 |
|---|---|---|
| 발급 | 6자리 난수 + 캐시 저장 | 3분 |
| 검증 | 입력값 vs 저장값 비교 | 1회성 사용 후 무효화 |
| 재발송 | 동일 번호 60초 쿨다운 | - |
| 실패 잠금 | 5회 연속 실패 시 30분 락 | 번호/IP 각각 |
[발급]
issue(phone)
-> generate 6-digit code
-> store cache(key=hash(phone), value=code, expire=180s)
-> sms_send(phone, code)
[검증]
verify(phone, input)
-> fetch cache(key=hash(phone))
-> if not found: return expired
-> constantTimeEq(input, stored) # 타이밍 공격 방지
-> if match: invalidate cache, return ok
-> else: increment fail_count(phone, ip)
if fail_count >= 5: lock(30min)
보안 포인트 세 가지는 빠뜨리면 안 됐다.
타이밍 공격 방지. 평문 == 비교는 문자열 앞쪽이 다르면 빠르게 리턴하기 때문에, 응답 시간 차이로 코드를 유추하는 공격 벡터가 생긴다. 상수시간 비교 함수를 쓰는 이유다. 6자리 숫자라서 현실적 위협이 크진 않지만, 패턴으로 박아두는 게 낫다.
brute-force 차단. 6자리 숫자의 경우의 수는 100만. rate limit 없이 두면 자동화 공격으로 금방 뚫린다. 동일 번호 기준 5회 실패 시 30분 락, IP 기준으로도 따로 카운팅한다. 두 축 모두 잡아야 하는 이유는, 번호 고정 + IP 바꾸기 또는 IP 고정 + 번호 변경 식으로 우회 시도가 들어올 수 있어서다.
로그 마스킹. 인증번호는 절대 평문으로 남기지 않는다. DB든 로그 파일이든. 번호도 가운데 4자리 마스킹(010-****-5678 형태). 내부 로그 열람 권한이 있는 인원도 개인정보를 불필요하게 보지 않게 하려는 설계다.
시행착오
초반에 인증번호를 RDB에 박았다가 캐시로 옮겼다. TTL 관리를 애플리케이션 레이어에서 직접 하면 만료된 레코드 정리 배치가 필요하고, 동시성 처리도 신경 써야 한다. 캐시를 쓰면 expire 설정 하나로 끝난다. "DB에 기록을 남기자"는 생각이 앞서서 돌아갔는데, 처음부터 캐시로 갔어야 했다.
발신번호 화이트리스트 누락도 뒤늦게 발견했다. 결제대행사 쪽 발신번호 사전등록이 일부 빠진 게 있어서, 발송 요청은 성공 응답을 받았는데 실제론 발송이 안 된 케이스가 있었다. 지금은 발송 직전에 화이트리스트 체크를 한 단계 끼워뒀다. 사소해 보이지만 이게 없으면 수신 통계에서 뒤늦게야 이상 신호로 잡히고, 그때는 이미 CS 컴플레인이 들어온 다음이다.
두 작업을 같이 돌아보면 공통으로 남는 게 있다. 외부 의존을 줄일수록 디버깅이 편해진다. 외부 모듈은 에러가 나도 내부 로그에 안 잡히고, 스펙 변경 공지를 놓치기도 쉽다. 스펙을 일부러 좁혀서 자체 구현 가능한 범위를 명확히 하면, 유지보수 부담이 오히려 작다. 그리고 측정 인프라를 먼저 깔면 - 수신 통계처럼 - 다음에 뭘 고쳐야 하는지 데이터가 알려준다. 우선순위가 숫자에서 나오니까 "이게 더 중요할 것 같다"는 식의 논쟁이 줄어든다.
댓글 0
첫 댓글 달아줘.