결제는 이미 자동이었습니다. 그런데 그 다음이 전부 수기였습니다. 세금계산서를 발행하고, 거래처에 알리고, 입금을 확인하는 일을 매번 사람이 했습니다.
세 가지 모두 API가 있습니다. 아무도 이어붙이지 않았을 뿐입니다. 그런데 이걸 웹훅 하나에 일렬로 연결하면 거의 확실히 사고가 납니다. 이 케이스의 절반은 그 이유와, 그걸 피하는 구조에 대한 이야기입니다.
왜 한 줄로 이으면 안 되는가
토스페이먼츠 웹훅 문서에는 두 가지 제약이 명시돼 있습니다. 이 두 문장이 이 케이스의 전체 설계를 결정합니다.
- 응답 시간 — 문서 원문: *"10 초 이내로 200 응답을 보내주세요."* 그런데 세금계산서 발행 API와 알림톡 발송 API를 순서대로 호출하면 10초를 넘길 수 있습니다. 상대 서버가 느린 날에는 확실히 넘깁니다.
- 재시도 — 응답이 없거나 실패하면 최대 7회 재시도합니다. 간격은 1분 → 4분 → 16분 → 64분 → 256분 → 1024분 → 4096분으로 늘어나고, 문서 표현으로 마지막 재시도는 *"최초 전송으로부터 3일 19시간 후"* 입니다.
- 둘을 합치면 최악의 시나리오가 보입니다. 발행은 성공했는데 응답이 10초를 넘겨 실패로 간주되면, 토스는 같은 결제를 다시 보냅니다. 그때마다 세금계산서가 또 발행됩니다. 세금계산서 중복 발행은 취소·재발행 절차가 따르는 실제 사고입니다.
어떻게 동작하나
Webhook노드가 토스의PAYMENT_STATUS_CHANGED이벤트를 받습니다. 토스는 상태가 바뀔 때마다 이 이벤트를 보내므로 결제 완료(`DONE`)만 골라냅니다.Respond to Webhook노드가 곧바로 200을 응답합니다. 이후 처리가 얼마나 걸리든 토스 입장에서는 이미 성공입니다. 10초 제한을 원천적으로 벗어납니다.Code노드가 페이로드를 정리하고paymentKey를 멱등키로 삼습니다.DONE이 아니거나paymentKey가 없으면 빈 배열을 반환해 조용히 끝냅니다.Postgres노드가invoice_queue테이블에ON CONFLICT DO NOTHING으로 적재합니다. 같은 결제가 재전송돼도 행이 늘지 않습니다. 중복 발행을 막는 지점이 정확히 여기입니다.- 별도의
Schedule Trigger가 5분마다 큐를 훑어pending행을 한 번에 20건까지 집어가면서 동시에 `processing`으로 바꿉니다. 한 문장으로 처리하므로 실행이 겹쳐도 같은 행을 두 번 집지 않습니다. - 세금계산서 발행 API를 호출하고, 이어서 알림톡(또는 문자)을 발송한 뒤 해당 행을
done으로 표시합니다. 실패하면attempts가 올라가고 5회를 넘으면 더 이상 집지 않습니다.
필요한 것
- n8n — 자체 호스팅 또는 Cloud. 웹훅을 외부에서 받아야 하므로 인터넷에서 접근 가능한 주소가 필요합니다. 로컬 개발 중이라면 터널링 도구를 쓰거나, n8n Cloud를 쓰세요.
- Postgres 데이터베이스 하나 — 큐 테이블 하나를 만듭니다.
- 토스페이먼츠 계정 — 웹훅 URL은 개발자센터의 웹훅 메뉴(
developers.tosspayments.com/my/webhooks)에서 등록합니다. 테스트 키는test로 시작하며, 문서에 따르면 전자결제 신청 전이라도 "개발 연동 체험 상점의 일부 테스트 키"를 확인할 수 있습니다 — 즉 개발·테스트 단계에서는 사업자등록 없이 진행할 수 있습니다. 인증은 시크릿 키를 이용한 Basic 인증이며, 키 뒤의:까지 포함해 인코딩해야 합니다. - 전자세금계산서 발행 대행 서비스 계정 — 팝빌·바로빌 등. 홈택스에 직접 연동하는 방식은 권하지 않습니다(아래 예외 상황 참고). 팝빌의 경우 테스트 환경(
test.popbill.com)이 별도로 있고, 문서상 *"파트너는 연동신청 시 입력한 계정정보(ID/PW)로 팝빌 테스트 환경과 운영 환경에 자동으로 가입"* 됩니다. 테스트용 공동인증서 신청도 안내되어 있습니다. 다만 연동신청에 사업자 정보가 필요한지는 공식 문서에서 확인되지 않았으므로, 착수 전 해당 벤더에 직접 문의하세요. - 알림톡을 쓸 경우 — 공식 딜러사 계약이 먼저입니다 — 카카오 공식 문서에 명시돼 있습니다: *"알림톡은 카카오와 파트너 계약이 되어있는 공식 딜러사를 통해 진행 가능합니다."* 즉 카카오에 직접 연동할 수 없고, 공식 딜러사 중 한 곳과 계약해야 합니다. 이 케이스에서 메시징 노드를 특정 벤더로 못 박지 않은 이유이기도 합니다 — 어느 딜러사를 쓰느냐에 따라 API가 달라집니다. 그리고 채널 조건도 있습니다: 카카오톡 채널을 개설해 비즈니스 채널로 전환하고, 홈공개를 ON으로 두고, 고객센터 정보를 입력해야 합니다. 메시지 자체도 *"정보통신망법에서 정한 영리목적 광고성 정보의 예외에 해당하는 정보성 메시지"*만 가능하며 광고성 메시지는 보낼 수 없습니다 — 결제·발행 통보는 여기에 해당합니다. 준비가 안 됐다면 이 단계를 문자(SMS)로 대체해도 워크플로우 구조는 그대로입니다.
1단계 — 큐 테이블 만들기
CREATE TABLE IF NOT EXISTS invoice_queue (
payment_key text PRIMARY KEY, -- 멱등키. 중복 웹훅 방어의 핵심
order_id text,
amount numeric NOT NULL DEFAULT 0,
approved_at timestamptz,
customer_email text,
status text NOT NULL DEFAULT 'pending', -- pending|processing|done
attempts integer NOT NULL DEFAULT 0, -- 무한 재시도 방지
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS invoice_queue_status_idx
ON invoice_queue (status, created_at);payment_key를 PRIMARY KEY로 둔 것이 이 설계에서 가장 중요한 한 줄입니다. 애플리케이션 코드가 아니라 데이터베이스 제약조건이 중복을 막습니다. 워크플로우에 버그가 있어도, 실행이 겹쳐도, 토스가 같은 웹훅을 일곱 번 보내도 행은 하나뿐입니다.2단계 — 큐에 넣는 쿼리
INSERT INTO invoice_queue
(payment_key, order_id, amount, approved_at, customer_email, status)
VALUES
('{{ $json.payment_key }}', '{{ $json.order_id }}', {{ $json.amount }},
'{{ $json.approved_at }}', '{{ $json.customer_email }}', 'pending')
ON CONFLICT (payment_key) DO NOTHING;재전송된 웹훅이 도착하면 INSERT 0 0이 반환되고 아무 일도 일어나지 않습니다. 에러가 아니라 정상적인 무시입니다. 워크플로우는 조용히 성공으로 끝납니다.
3단계 — 큐를 집어가는 쿼리
5분마다 도는 처리기가 쓰는 쿼리입니다. UPDATE ... RETURNING 한 문장으로 집어가기와 상태 변경을 동시에 합니다. 두 동작을 나누면 그 사이에 다른 실행이 같은 행을 집어갈 수 있습니다.
UPDATE invoice_queue
SET status = 'processing', attempts = attempts + 1, updated_at = now()
WHERE payment_key IN (
SELECT payment_key FROM invoice_queue
WHERE status = 'pending' AND attempts < 5
ORDER BY created_at
LIMIT 20
FOR UPDATE SKIP LOCKED -- 다른 실행이 잠근 행은 건너뜀
)
RETURNING payment_key, order_id, amount, approved_at, customer_email, attempts;FOR UPDATE SKIP LOCKED가 동시 실행 안전장치입니다. 처리가 5분보다 오래 걸려 다음 실행이 겹쳐도, 두 번째 실행은 잠긴 행을 기다리지 않고 건너뜁니다. attempts < 5는 계속 실패하는 행이 큐를 영원히 막는 것을 방지합니다.4단계 — 벤더 API 두 개 채우기
다운로드한 워크플로우에서 Issue Tax Invoice와 Send AlimTalk / SMS 두 노드는 일부러 비워 두었습니다. 요청 본문 형식이 벤더마다 다르고, 이 문서에서 특정 벤더의 스키마를 옮겨 적으면 벤더가 API를 바꾸는 순간 틀린 문서가 되기 때문입니다. 각자 쓰는 서비스의 API 레퍼런스를 보고 URL·인증 헤더·본문을 채우세요. 워크플로우의 나머지 구조는 그대로 쓰시면 됩니다.
- 세금계산서 발행 노드 — 타임아웃을 20초로 잡아 두었습니다. 큐 방식이라 오래 걸려도 웹훅에는 영향이 없습니다.
- 알림톡 노드 — 승인된 템플릿 ID가 필요합니다. 발신프로필이 아직 없다면 이 노드를 문자 발송으로 바꾸세요. 앞뒤 구조는 동일합니다.
- 두 노드 사이의 순서가 중요합니다 — 반드시 발행이 성공한 뒤에 통보해야 합니다. 발행 실패인데 "발행되었습니다" 알림이 나가면 그게 더 큰 사고입니다.
알림톡 템플릿을 설계할 때 반드시 알아야 할 것
이 부분에서 가장 많이 시간을 잃습니다. 공식 가이드에 명시된 제약들입니다.
- 등록이 완료된 템플릿은 수정이 불가능합니다. 문구 하나를 고치려면 새 템플릿을 만들어 다시 심사를 받아야 합니다. 그러므로 처음 설계할 때 변수를 넉넉히 잡아두세요. 금액만 넣었다가 나중에 주문번호를 추가하고 싶어지는 일이 반드시 생깁니다.
- 심사는 영업일 기준 2일 이내에 순차 처리됩니다. 원문: *"심사 요청하신 템플릿은 영업일 기준 2일 이내 순차 처리되며"*. 반려되면 수정 후 재심사를 받아야 하므로, 일정에는 왕복을 감안해 여유를 두세요.
- 반려 사유로 명시된 것들 — 변수 오류, 과도한 변수 사용(40개 초과), 변수로만 이루어진 템플릿 내용, 변수가 포함된 버튼명, 변수가 포함된 미리보기 메시지 설정. 특히 '변수로만 이루어진 내용'이 흔한 실수입니다. 고정 문구 없이 변수만 나열하면 반려됩니다.
- 대체 발송 설정은 템플릿과 별도로 관리되므로 언제든 변경할 수 있습니다. 템플릿은 못 고쳐도 문자 대체 설정은 나중에 조정할 수 있다는 뜻입니다.
test로 시작한다는 점, 그리고 알림톡의 '등록 완료 템플릿 수정 불가'·'승인된 템플릿만 발송'·'영업일 기준 2일 이내 심사'·반려 사유 목록은 2026-08-15에 각 공식 문서를 직접 열어 확인한 내용입니다. 큐 동작(중복 payment_key 재적재 차단, attempts 소진 행 제외, 겹친 실행에서 같은 행 재처리 안 됨)은 PostgreSQL 17에서 픽스처로 실행해 확인했습니다. 확인하지 못한 것: 팝빌 연동신청에 사업자 정보가 필요한지, 알림톡 발신프로필 개설에 사업자등록이 필요한지 — 두 가지 모두 공식 문서에서 근거를 찾지 못해 본문에 단정하지 않았습니다. 실제 결제·발행·발송을 관통하는 종단 테스트도 수행하지 않았습니다. 마지막 검증: 2026-08-15.설정 순서 (40분)
- 큐 테이블 생성 — 1단계 SQL을 실행합니다.
- Postgres 자격증명 등록 — n8n → Credentials →
Postgres→ Test로 초록불 확인. n8n이 Docker 안이고 DB가 호스트면 호스트에host.docker.internal을 넣으세요. - 워크플로우 import — 다운로드한 JSON을 n8n → Workflows →
...→ Import from File. - 웹훅 URL 확인 —
Toss Webhook노드를 열면 Test URL과 Production URL이 보입니다. Production URL을 복사하세요. 워크플로우를 Activate 해야 이 주소가 살아납니다. - 토스에 웹훅 등록 — 개발자센터 → 웹훅 메뉴 → 웹훅 등록하기 → 방금 복사한 Production URL 입력 → 이벤트로
PAYMENT_STATUS_CHANGED선택. - 세금계산서 벤더 준비 — 사용할 서비스(팝빌·바로빌 등)의 테스트 환경에 가입하고, 테스트용 인증 정보와 필요 시 테스트 공동인증서를 발급받습니다. 이 단계가 이 케이스에서 리드타임이 가장 깁니다. 먼저 시작하세요.
- 발행 노드 채우기 —
Issue Tax Invoice노드에 벤더 레퍼런스대로 URL·인증 헤더·요청 본문을 넣습니다. - 알림톡 준비 또는 문자 대체 — 발신프로필과 템플릿 심사에는 며칠이 걸립니다. 그 사이에는
Send AlimTalk / SMS노드를 문자 발송으로 두고 개발을 진행하세요. - 두 트리거 모두 Activate — 웹훅 플로우와 스케줄 플로우 둘 다 켜져 있어야 합니다. 하나만 켜면 큐에 쌓이기만 하거나, 아무것도 안 들어옵니다.
- 테스트 결제로 확인 — 테스트 키로 결제를 한 건 발생시킨 뒤
SELECT * FROM invoice_queue ORDER BY created_at DESC LIMIT 5;로 행이 들어왔는지 봅니다. 그다음 5분을 기다리거나 스케줄 워크플로우에서 Execute Workflow를 눌러 처리되는지 확인합니다. - 중복 방어 확인 — 같은 결제의 웹훅을 한 번 더 보내보고
invoice_queue의 행 수가 그대로인지 확인합니다. 이 확인을 꼭 하세요. 여기가 뚫리면 세금계산서가 중복 발행됩니다.
예외 상황이 있으면 어떻게 되나
- 같은 웹훅이 여러 번 도착 —
ON CONFLICT DO NOTHING으로 두 번째부터는 무시됩니다. 토스가 최대 7회까지 재시도하므로 이 방어가 반드시 있어야 합니다. - 발행은 성공했는데 알림톡이 실패 — 행이
done으로 넘어가지 않고attempts만 올라, 다음 회차에 다시 시도됩니다. 그러면 세금계산서가 또 발행될 수 있습니다. 실전에서는invoice_queue에invoice_issued_at같은 컬럼을 추가해 발행 완료를 따로 기록하고, 재시도 시 발행을 건너뛰게 하세요. 이 케이스의 기본 워크플로우에는 그 분리가 들어 있지 않습니다. - 계속 실패하는 행 —
attempts가 5에 도달하면 더 이상 집어가지 않습니다. 큐가 막히지 않지만, 그 행은 사람이 봐야 합니다.SELECT * FROM invoice_queue WHERE attempts >= 5;를 주기적으로 확인하거나, 별도 알림을 거세요. - 결제 취소·부분 환불 — 이 워크플로우는
DONE만 처리합니다. 취소·환불은 세금계산서 수정발행이라는 완전히 다른 절차이므로 자동화하지 마시고, 별도 알림만 보내 사람이 처리하게 하세요. - 웹훅이 10초를 넘겨도 — 즉시 응답 구조라 이 상황 자체가 발생하지 않습니다. 다만 n8n 인스턴스가 죽어 있으면 응답이 아예 없고, 토스는 최대 7회·약 3일 19시간에 걸쳐 재시도합니다. 그 사이에 인스턴스를 복구하면 결제가 유실되지 않습니다.
- 홈택스에 직접 연동하고 싶다면 — 권하지 않습니다. XML 스키마가 엄격하고, 서버용 공동인증서를 직접 관리해야 하며, 법령이 바뀔 때마다 유지보수가 발생합니다. 대행 서비스를 쓰는 편이 총비용이 낮습니다.
10 초 이내로 200 응답을 보내주세요.— 토스페이먼츠 웹훅 공식 문서