카카오 알림톡 연동은 API 키 넣고 호출하면 되겠다고 생각했다. 실제로는 5단계 트러블슈팅이 기다리고 있었다.
1단계: type 필드가 없었다
첫 발송 시도에서 에러가 났다. Solapi API 문서를 다시 읽어보니 메시지 타입 필드가 필수였다.
// ❌ 타입 없음 → SMS로 처리됨
{ to: phone, text: message }
// ✅ 알림톡으로 처리
{ type: 'ATA', to: phone, templateId: '...', variables: { ... } }
type: 'ATA'를 명시하지 않으면 SMS로 처리한다. 알림톡 전용 API인데 기본값이 SMS인 이유는 모르겠다. 아무튼 수정 후 해결.
2단계: 미승인 변수
템플릿 본문에 #{status} 변수를 넣었더니 400 에러가 떴다.
카카오 알림톡 템플릿에 사용하는 변수는 카카오로부터 사전 승인을 받아야 한다. 알림톡 서비스 신청 시 템플릿을 제출하고 승인이 나야 그 변수를 쓸 수 있다. 승인 안 된 변수가 하나라도 있으면 발송 자체가 거절된다.
#{status}를 제거하고 고정 문구로 대체했다. "발주가 정상 접수되었습니다." 처럼.
3단계: BOM 문자
이게 가장 오래 걸렸다. 로컬에서는 정상 발송되는데 Vercel 배포 후에만 400 에러가 났다.
환경변수 값을 16진수로 dump해봤다. SOLAPI_PF_ID 첫 바이트가 EF BB BF였다. UTF-8 BOM(Byte Order Mark) 문자였다.
특정 편집기에서 값을 복사해서 Vercel 환경변수에 붙여넣으면 문자열 앞에 BOM이 붙는다. 눈에 보이지 않으니 한참 헤맸다.
// 환경변수 읽을 때 BOM 제거
const pfId = process.env.SOLAPI_PF_ID?.replace(/^/, '').trim();
이후 외부 서비스 환경변수를 읽을 때는 이 패턴을 기본으로 붙였다.
4단계: 채널 검증 대기 기간
카카오 알림톡 채널은 신청 후 승인까지 수 일이 걸린다. 이 기간 동안 알림톡 발송 시도가 전부 실패한다.
서비스를 멈출 수 없으니 임시 해결책이 필요했다.
const result = await solapiClient.sendAlimtalk(...);
if (!result.ok && result.statusCode === 400) {
// 알림톡 실패 시 SMS로 폴백
return solapiClient.sendSMS({ to: phone, text: message });
}
채널 승인 이후에도 이 폴백 로직은 그대로 뒀다. 알림톡이 어떤 이유로든 실패하면 SMS로 전달된다.
5단계: 수신번호를 어디서 가져오나
트러블슈팅이 끝나고 구조적인 문제가 보였다. 알림 발송 시 수신번호를 어디서 가져와야 하는가.
거래처에 따로 연락처가 없는 경우, 포털 계정만 있는 경우, 견적에 직접 연락처가 적힌 경우 — 경우가 다 달랐다.
// dispatch.ts — 우선순위 폴백
const phone =
clientPhone || // 고객사(clients.phone) — 가장 우선
accountPhone || // 포털 계정(users.phone)
entPhone || // 견적 연락처(client_quotes.phone)
sitePhone || // 현장 연락처(sites.customer_phone)
null; // 없으면 'failed' 기록, 발송 안 함
어떤 상태에서 알림을 보낼까
전체 상태가 다 알림을 보내면 고객 입장에서 스팸이다. 고객이 의미 있다고 느끼는 상태는 네 개뿐이었다.
// 화이트리스트로 관리
const QUOTE_NOTIFY = { '견적완료': true };
const PRODUCTION_NOTIFY = { '접수': true, '설치시작': true, '설치완료': true };
나머지 상태(확정요청, 가견적, 생산중 등)는 내부 관리용이라 고객에게 알릴 필요가 없다. 상태가 바뀔 때마다 알림 확인 모달이 뜨고, 발송 여부를 선택할 수 있다.
견적완료 → "요청하신 견적이 완료되었습니다. 포털에서 확인하실 수 있습니다."
접수 → "발주가 정상 접수되었습니다."
설치시작 → "주방 상판 설치 작업이 시작되었습니다."
설치완료 → "설치가 완료되었습니다. 이용해 주셔서 감사합니다."
Provider 추상화
알림 채널은 바뀔 수 있다. 솔라피가 아닌 다른 대행사를 써야 할 수도, 이메일 채널이 추가될 수도 있다. 처음부터 인터페이스를 분리했다.
// lib/notify/types.ts
export interface AlimtalkProvider {
send(input: SendInput): Promise<SendResult>;
}
지금은 SolapiProvider가 이 인터페이스를 구현한다. 대행사가 바뀌어도 dispatch.ts 로직은 그대로이고 구현체만 교체하면 된다.
발송 내역을 전부 기록한다
notifications 테이블에 발송 이력을 남긴다.
notifications (
channel, -- 'alimtalk' | 'sms'
entity_type, -- 'quote' | 'production'
recipient_phone,
old_status, -- 상태 변경 이전
new_status, -- 상태 변경 이후
template_code, -- 'QUOTE_DONE' | 'PO_RECEIVED' | ...
message, -- 실제 발송된 본문
status, -- 'pending' | 'sent' | 'failed'
error, -- 실패 사유
provider_msg_id, -- Solapi 메시지 ID (추적용)
sent_at,
created_by -- 발송 트리거한 관리자 ID
)
old_status + new_status를 함께 저장하기 때문에 별도 이력 테이블 없이 notifications 테이블이 상태 변경 전체 이력의 역할을 겸한다. 관리자 화면에서 어느 고객에게 어떤 내용을 언제 보냈는지, 실패 사유는 무엇인지 전체 이력을 조회할 수 있다.
알림톡 트러블슈팅에 이렇게 많은 시간을 쓴다는 걸 처음엔 몰랐다. 그래도 한 번 겪고 나니 이후에는 동일한 문제가 재발하지 않았다.
다음 편에서는 전혀 다른 종류의 도전이다. 웹 브라우저에서 주방 도면을 직접 그리는 에디터를 만든 이야기.