심야 23:47, 본 번호(production) Webhook 실패 알림이 37건 발생했습니다. API는 계속해서 400 {"error":"invalid_json"}을 반환하고 있었습니다. 테스트 실행은 녹색 상태를 유지한 채였습니다. 원인은 JSON Body에 포함된 고객 이름 필드에 반각 인용 부호가 들어간 레코드가 유입되었기 때문이었습니다. Body 구성을 실행 시점의 문자열 평가에 맡긴 나머지, 문법적 파괴(syntax corruption)를 전송 전에 아무도 검증할 수 없었던 것입니다. 본 글에서는 Body 구성을 Code node로 통합하고, JSON 구문과 Expression을 전송 전에 정적으로 검증하는 완벽한 구현 방법을 작성합니다.
| 일시 | 발생한 일 |
|---|
| 23:47 | 본 번호 Webhook을 통한 등록 처리가 400으로 실패하기 시작함 (1건) |
| ... | |
| 응답 본문 (n8n 실행 로그에서): | |
400 - BAD_REQUEST
{"error":"invalid_json","detail":"Unexpected token , ... position 214"}
위치 214를 포함하는 Body의 해당 부분은 다음과 같았습니다:
"customer_name": "주식회사"샘플"홀딩스",
반각 인용 부호를 포함한 고객 이름이 문자열 리터럴의 인용 부호를 깨뜨린 것입니다. 더미 데이터(인용 부호가 없는 이름)로 실행한 테스트는 당연히 모두 성공했습니다. 데이터가 실제 형태가 되어야만 고장 나는 유형의 사고였습니다.
n8n은 Body의 JSON 구문을 실행 시점까지 검증하지 않는다. HTTP Request 노드의 Body는 '실행 시점에 평가되는 문자열'이며, 저장할 때는 아무 일도 일어나지 않습니다. -
구성이 HTTP Request 노드 안에 파묻혀 있었다. Body 문자열을 외부에서 다시 구성하여 검증할 장소가 애초에 존재하지 않았습니다. -
400 에러를 '이상'으로 처리하는 담당자가 없었다. HTTP Request는 400을 하나의 응답으로 기록할 뿐입니다. throw 하여 중단시키는 검증 노드가 없다면, 실패는 재시도(retry)에 맡겨져 양만 늘어납니다.
실패 1: 고객 이름의 인용 부호를 replaceAll로 제거했다. "customer_name": "${$json.name.replaceAll('"','')}"
이렇게 하면 확실히 깨지지는 않습니다. 하지만 데이터가 손실됩니다. JSON으로서 잘못된 문자에 대한 올바른 처리는 이스케이프(escape)이지, 제거가 아닙니다.
실패 2: Body 전체를 {{ JSON.stringify($json) }} 하나로 대체했다. 중첩 구조 설계가 무너지고 모든 항목을 전송하는 형태로 바뀌어, 전송 대상 API의 사양(일부 항목은 허용 목록제)과 충돌했습니다. 구성 방침은 바꾸지 않고,
구성된 결과를 검증하는 것이 정답입니다.
단 3단계만 거치면 됩니다.
1. 구성 (Assembly): Body 문자열을 Code node의 단일 템플릿으로 구성합니다 (HTTP Request에 Body를 직접 작성하는 것을 중단). -
2. 검증 (Validation): JSON.parse로 구문을 검증하고, 미평가된 {{ }}의 잔여물을 정규 표현식(regular expression)으로 탐지합니다. -
3. 중단 (Stop): 실패하면 throw 합니다. 하류의 HTTP Request는 실행되지 않으며, Error Workflow로 흐릅니다.
핵심은 '검증 노드를 뒤에 붙이는 것'이 아니라 '구성 자체를 이동시키는 것'입니다. 옮기지 않으면, 검증 대상과 실제 전송 Body가 다른 것이 되어 검증의 의미가 사라집니다.
Code node (Mode: Run Once for All Items)에 그대로 붙여넣습니다:
// JSON Body 정적 검증을 포함한 구성 (Code node / Run Once for All Items)
const out = [];
for (const item of $input.all()) {
...
HTTP Request 노드 측의 설정:
검증이 사고를 멈출 때의 출력:
Error: JSON 구문 오류: Unexpected token , in JSON at position 214 / 해당 부분: "주식회사\"샘플\"홀딩스",\n..."
필드별로 JSON.stringify를 했기 때문에 인용 부호가 포함된 데이터는 이스케이프되고, 검증은 매 실행 및 본 번호 데이터로 작동합니다. throw로 멈추기 때문에 HTTP Request는 한 번도 실행되지 않으며, retryOnFail의 5회 연속 실패(5連敗) 역시 구조적으로 발생하지 않습니다.
| 방식 | 검증 타이밍 | 감지 가능한 것 | 비용 | 판정 |
|---|
| 실행 후 에러 대기 (사고 버전) | 전송 후 | API가 반환하는 400만 | 영향을 주는 요청이 발생한 시점부터 | ✗ 중단 |
| ... | | | | |
-
Body 조립은 Code node 한 곳에 통합했는가 (HTTP Request에 직접 작성하지 않았는지)
-
조립 후에 JSON.parse를 한 번 통과시켰는지
-
미평가된 {{ }} 잔여물을 정규 표현식으로 감지하는지 - 실패 위치(position) 전후 20자도 함께 보고하는지
-
실패 시 throw로 하류를 멈추고 Error Workflow로 흘려보내는지
-
테스트 실행은 본방과 동등한 데이터(따옴표, 전각 문자, 다중 item)로 했는지
-
본방 Webhook을 가진 HTTP Request 노드를 열고, Body를 직접 작성하는 대신 위의 Code node로 대체한다 (조립 문자열은 자체 워크플로우용으로 3줄만 수정하면 된다).
-
throw 발생 시 알림 대상으로 Error Workflow를 연결한다 (retryOnFail과의 구분은 관련 기사 참조).
-
다음 Body 편집부터 "편집 — 정적 검증 — 수동 실행 1회 — 본방" 절차를 고정한다. 이때 주의: Code node 내부의 템플릿은 백틱(backtick)으로 조립하므로, n8n 표현식({{ }})을 안에 쓰지 않는다. 참조는 모두 JS(d.xxx)로 통일한다.
본 기사와 같은 구현 패턴을 가로지르며 정리한 치트시트와, Obsidian/Notion의 템플릿 모음을 무료 배포합니다. 8권 분량의 내용에서 "무엇을 어떤 도구로 할 것인가" 판단 차트도 포함됩니다.
- n8n의 HTTP Request에서 전송 대상 API가 400을 계속 반환하는 경우 — JSON Body 내 Expression이 평가되지 않는 3가지 함정
- n8n의 재시도 설정이 실패를 5배로 늘린 경우 — retryOnFail과 에러 워크플로우의 올바른 구분
📗 n8n × AI 자동화 실전 입문 — 노코드로 만드는 AI 워크플로우 (Kindle/Kindle Unlimited 무제한 읽기 대상)