EDI 214 연동을 n8n으로 만들었다 — 인증 통과까지 실제로 막힌 5가지

미국 화주(대형 제조사) 한 곳에 배송 상태를 EDI 214(X12 v4030)로 자동 전송하는 연동을 만들었다. 발신 명의는 물류사, 수신·인증은 화주 쪽. 목표는 화주 시스템의 인증(certification) 통과였다.

이걸 왜 하냐면, 그전까지 이 상태 공유가 사람 손이었기 때문이다. 담당자가 운영 시트를 보고 “이 컨테이너 도착했다”를 화주 포맷에 맞춰 옮긴다. 건수가 늘면 누락이 나고, 누락은 화주 쪽 리포트를 조용히 틀리게 만든다. 그리고 EDI는 화주가 요구하는 계약 조건이기도 하다.

전송 방식은 원래 VAN 경유를 검토하다가 화주 SFTP 직결로 바뀌었다. 중간 사업자가 빠지니 파이프라인을 우리가 다 쥐게 됐고, 그래서 n8n으로 짰다.

아키텍처 — 왜 두 단계로 갈랐나

한 워크플로에 다 몰지 않고 2단계로 분리하고, 사이에 상태 테이블(n8n Data Table)을 뒀다.

EDI 214 파이프라인 2단계 구조 — Sync & Track, 상태 테이블(sent_at) 경계, Build & Send

  1. Sync & Track — ERP 시트 읽기 → 필터 → 마일스톤 추출(도착/출발/배송도착/배송완료/ETA) → 주소 lookup → dedup key 생성 → 상태 테이블 upsert
  2. Build & Send — 미전송 행 조회 → EDI 214 문자열 빌드 → SFTP 업로드 → 전송분 sent_at 마킹

분리는 취향이 아니다. EDI 214는 델타 전송이다 — 한 이벤트는 딱 한 번만 나가야 한다. 그러면 “무엇을 이미 보냈나”가 파이프라인에서 제일 중요한 상태가 되고, 그건 워크플로 실행 메모리가 아니라 durable한 곳에 있어야 한다. 상태 테이블이 그 경계다. 전송이 깨지면 Build 단계만 다시 돌리면 되고, ERP를 다시 읽을 필요가 없다.

앞 단계 끝에서 Execute Workflow 노드로 뒷 단계를 부르게 체이닝했다. 두 스케줄이 각자 돌면 뒤 단계가 앞보다 먼저 끝나 신규 이벤트를 한 사이클 놓치는 경쟁 조건이 생긴다.

dedup key 설계가 델타의 전부다. container + status를 key로 upsert하되, sent_at은 upsert 입력에서 뺀다.

upsert key : container_no + status
upsert 값  : event_date, location, eta, ...   ← sent_at 없음

이러면 재sync로 같은 이벤트가 다시 들어와도 행은 갱신되지만 sent_at은 보존된다. 이미 보낸 건 다시 안 나간다.

막힌 지점들

1. disabled 노드가 sent_at을 오염시킨다

제일 뒤통수를 맞은 지점. n8n에서 비활성(disabled) 노드는 입력을 그대로 통과시킨다. 자격증명이 아직 없어서 SFTP 업로드 노드를 disable해뒀는데, 그 뒤의 “sent_at 마킹” 노드는 켜져 있었다. 결과는 실제 전송 0건인데 전 행이 “보냄”으로 마킹.

델타 특성상 한 번 찍히면 그 이벤트는 영원히 안 나간다. 실행 로그만 보면 아무 에러도 없다.

교훈은 두 개다. 노드를 보류할 땐 상태를 바꾸는 마킹 노드까지 같이 끊는다. 그리고 인증 반복 기간엔 마킹 노드를 아예 꺼두는 편이 낫다sent_at이 안 찍혀야 구조를 고치며 몇 번이고 재전송할 수 있다.

2. 안내받은 SFTP 경로가 실제와 다르다 (chroot)

담당자가 업로드 경로를 JT/TEST/Inbox로 알려줬는데 붙지 않았다. 접속해서 훑어보니 실제 루트에 그 접두어가 없고 /TEST/Inbox였다. 계정이 chroot돼 로그인 루트가 이미 그 영역이라, 상대가 서버 절대경로 기준으로 알려준 앞부분이 우리 관점에선 빠지는 것이다.

파트너가 준 경로는 그쪽 서버 기준이다. 첫 접속에서 직접 ls 한 번 하는 게 답이었다.

3. “Unsupported key format” — 비번 방식인데 키를 파싱하려 함

자격증명 등록 후 연결 실패, 에러는 Cannot parse privateKey: Unsupported key format. 원인은 허무했다. 비밀번호 인증인데 Private Key 칸이 완전히 비어있지 않았다(이전 잔여값). n8n이 그걸 SSH 키로 파싱하려다 실패한 것. 칸을 완전히 비우니 붙었다.

여기서 한 판단이 시간을 줄였다. 자격증명 자체가 무효인지, 설정이 잘못된 건지를 가르려고 접속을 워크플로 밖에서 스크립트로 독립 검증했다. 인증이 통과하는 걸 먼저 확인하니 남은 건 설정 문제라는 게 확정됐다.

4. 997 리젝 한 건 — 원인은 “지저분한 원천 문자열”

첫 테스트 파일(11개 shipment)을 보냈더니 화주 시스템이 997(functional acknowledgment)로 10개 수락, 1개 리젝을 돌려줬다. 사유는 필수 필드인 위치의 도시(MS101)가 비어있음.

MS1***US*****0        ← 리젝된 세그먼트. 도시·주가 통째로 빔

파고들어 보니 그 shipment 하나가 특별한 게 아니라 철도(rail) 운송 건이라는 부류의 문제였고, 그때 실제 이벤트 날짜가 찍힌 rail 건이 마침 그것 하나뿐이었다.

근본 원인은 우리가 위치를 원천에서 그대로 받지 않고 코드(포트·램프 이름)를 lookup 테이블로 변환하는 구조인데, 그 테이블 key가 원천 문자열과 안 맞았던 것이다.

  • 코드의 lookup key: COLUMBUS,US
  • 원천 실제값: COLUMBUS, OH — 콤마 뒤 공백 + 지역 토큰이 다름

기존 정규화는 앞뒤 trim + 대문자만 했고 중간 ,의 공백을 접지 않았다. 다른 지역(Dallas)은 원천값이 우연히 key와 똑같아서 통과했던 거라, 테스트에선 안 보였다.

5. “터미널을 다 매핑해야 하나?” — 상상 말고 원천을 세어봤다

원인을 알고 나니 겁이 났다. rail 램프는 세상에 수없이 많은데 그걸 다 테이블에 넣을 수 있나?

그래서 원천 데이터를 전수로 세어봤다. 이 카운트가 문제를 다시 정의했다.

구분 고유 문자열 실제 대상
포트 19종 19 — 전부 표준 코드(UN/LOCODE)
rail 램프 52종 ~17개 도시

램프 52종은 접어보니 실제로는 17개 도시였다. 나머지는 전부 같은 도시의 표기 변형과 오타였다.

DETROIT,MI
DETROIT, MI, UNITED STATES
CN RAIL - DETROIT
NASHIVILLE / NASHVILLIE   ← 오타까지

즉 진짜 문제는 “무한한 터미널”이 아니라 유한한 도시 + 지저분한 자유입력이었다. 그러면 개별 터미널을 등록해 나가는 게 아니라 문자열에서 도시/주를 파싱하는 쪽이 맞다. 이 판단은 카운트를 하기 전과 후가 완전히 달랐다.

이번에 한 수정

  • lookup key 정규화 강화 — ,,, 연속 공백 접기, 변형 key 등록
  • 램프를 못 찾으면 포트로 폴백 — 어떤 경우에도 유효한 위치를 보장
  • Build 단계에 가드 — 도시가 빈 이벤트는 그 세그먼트를 아예 내보내지 않음. 한 건 때문에 shipment 전체가 리젝되는 걸 막고, 그 건은 미전송으로 남겨 다음 실행에서 재시도

그리고 재전송 전에 원천 전수 시뮬레이션으로 “빈 도시 0건”을 먼저 증명했다. 그 다음 재전송했고, 997에서 전부 수락됐다. 리젝됐던 rail 건도 정상으로 채워진 걸 SFTP에서 파일을 직접 받아 확인했다.

MS1*Columbus*OH*US*****43219

배운 것

  1. “무엇을 이미 보냈나”는 durable 상태 테이블로 관리하고, 마킹 컬럼은 upsert에서 뺀다. 파이프라인을 ingest / transmit 두 단계로 나누고 그 사이에 이 경계를 둔다.
  2. n8n disabled 노드는 passthrough다. 뒤에 상태를 바꾸는 노드가 있으면 “안 했는데 했다고 기록”되는 오염이 난다.
  3. 파트너가 준 경로·설정은 실측한다. chroot 경로도, 크레덴셜 유효성도. 워크플로 밖에서 독립 검증하면 원인 판별이 빨라진다.
  4. 코드 매핑은 하드코딩 테이블보다 파싱 + 정규화 + 폴백 + 가드가 튼튼하다. 그리고 “다 매핑 가능한가?”는 상상하지 말고 원천을 세어서 규모를 먼저 확정한다. 52개 문자열이 알고 보니 17개 도시였다.
  5. 필드 하나가 비면 배치 전체가 리젝될 수 있다. 불량 레코드는 내보내기 전에 걸러 보류하고 배치는 살린다.

한계 · 다음 계획

  • 우편번호(zip) 필수 여부를 화주에 확인 중이다. 선택이면 문자열 파싱만으로 도시/주를 채울 수 있어 매핑 의존도가 확 떨어진다.
  • 정정 재전송이 안 된다. 현재 dedup key(container + status)에 시각이 없어서, 이미 보낸 마일스톤의 값이 나중에 정정되면 재전송이 안 나간다. 인증 통과 이후 다룰 항목으로 남겨뒀다.
  • 인증은 테스트 파일 기준으로 통과했다. 실운영 볼륨에서의 재시도·에러 알림은 아직 최소 수준이다.

FAQ

Q. EDI 전송에서 중복 방지를 워크플로 안에서 처리하면 안 되나요?
A. 실행 단위 안에서만 처리하면 워크플로가 죽거나 재실행될 때 상태가 사라진다. “이미 보냈다”는 다음 실행에서도 살아있어야 하는 정보라 외부 상태 테이블에 둔다. 그리고 마킹 컬럼을 upsert 입력에서 빼는 게 핵심이다 — 안 빼면 재sync 한 번에 전부 미전송으로 되돌아간다.

Q. 997에서 리젝이 나면 파일 전체가 버려지나요?
A. 우리가 겪은 케이스는 11건 중 10건 수락 / 1건 리젝이었다. 즉 트랜잭션 단위로 갈린다. 다만 한 세그먼트의 필수 필드가 비면 그 shipment는 통째로 떨어지므로, 불량 레코드는 보내기 전에 걸러 배치를 살리는 게 낫다.

Q. 위치 코드는 표준 코드로 다 처리되지 않나요?
A. 해상 포트는 됐다. 19종 전부 UN/LOCODE로 정리됐다. 문제는 rail 램프 쪽이다. 운영 시스템에 자유입력으로 쌓인 문자열이라 같은 도시가 표기 변형·오타로 여러 개씩 존재했다. 표준 코드가 있는 축과 없는 축을 분리해서 다뤄야 한다.

Q. n8n으로 EDI를 다루는 게 적절한가요?
A. 매핑이 자주 바뀌고 원천이 시트·API처럼 잡다한 초기 구간에선 괜찮았다. 스케줄·SFTP·상태 테이블이 다 붙어 있어 붙이는 속도가 빠르다. 대신 disabled 노드 passthrough 같은 런타임 특성을 모르면 조용한 데이터 오염이 나므로, 상태를 바꾸는 노드는 별도로 관리해야 한다.

Leave a Comment