자동화 워크플로우에서 중요한 것은 모든 실행이 실패하지 않는다는 약속이 아니라, 실패를 빠르게 식별하고 안전하게 재처리할 수 있는 구조입니다. 이 글은 n8n에서 오류를 유형별로 나누고, 재시도·대체 경로·알림·중복 방지를 설계하는 순서를 정리합니다.
이 글의 범위는 오류 분류·재시도 예산·대체 결과 계약·재처리 상태입니다. 동시성·배치·성능 측정은 n8n 동시성·배치 처리 성능 측정 설계와 기록표에서, LLM 비용·쿼터 회계는 비용 한도와 실제 사용량 정산에서 다룹니다.
특정 API의 오류율, 가동률, 비용 절감액은 서비스·워크로드·계약·구현에 따라 달라집니다. 이 글에는 운영 인스턴스의 실측 결과가 없으므로 그러한 결과는 측정 필요로 표시합니다.
먼저 결정할 것
- 이 오류는 같은 요청을 다시 보내도 안전한가?
- 실패한 작업을 대체 모델이나 대체 경로로 넘겨도 결과 품질이 충분한가?
- 사람이 확인해야 하는 오류인가?
- 성공·실패·재시도 상태를 나중에 구분할 수 있는가?
이 네 가지를 정하지 않고 모든 오류에 자동 재시도를 걸면 중복 생성, 비용 증가, rate limit 악화가 생길 수 있습니다.
1. 오류를 유형별로 분류하기
| 오류 유형 | 예시 | 기본 대응 |
|---|---|---|
| 입력 오류 | 필수 필드 누락, 형식 오류 | 입력 격리·수정 요청, 무한 재시도 금지 |
| 인증·권한 | 401, 403 | 자격증명·권한 확인, 자동 재시도 금지 |
| 제한 초과 | 429, quota exceeded | 제공자의 제한·대기 지시 확인, 제한된 재시도 |
| 일시적 서버 오류 | 500, 502, 503, 504 | 제한된 재시도와 지수형 대기 검토 |
| 데이터 품질 오류 | 응답 스키마 불일치 | 검증 실패로 격리, 기본값으로 덮지 않기 |
| 비즈니스 승인 오류 | 사람 승인 필요 | 승인 대기열로 보내고 자동 발송 중지 |
상태 코드만으로 원인을 확정하지 않습니다. API 문서의 오류 의미와 응답 본문의 민감정보 제거 규칙을 함께 확인해야 합니다.
2. n8n 오류 워크플로우와 노드 재시도 구분하기
n8n의 공식 오류 워크플로우는 Workflow Settings에서 지정하며, 별도 오류 workflow의 첫 노드는 Error Trigger여야 합니다(오류 처리 공식 문서). 반면 HTTP Request 노드의 재시도 설정은 특정 노드 요청을 다시 실행하는 동작입니다(HTTP Request 공통 문제). 둘은 대체 관계가 아닙니다.
- 노드 재시도: 일시적 오류에 제한적으로 사용. n8n의
Retry On Fail,Max Tries,Wait Between Tries (ms)설정을 확인합니다. - 오류 워크플로우: 실패 사실을 기록하고 알림·격리·재처리 티켓 생성
- 대체 경로: 원래 결과 계약을 만족할 때만 사용하는 애플리케이션 설계 패턴
- 사람 검토: 데이터 손실·중복·권한·비용 위험이 있는 경우 사용
오류 알림에 API 키, 전체 요청 본문, 개인 식별정보를 그대로 넣지 않습니다. Error Trigger가 제공할 수 있는 실행 ID·실패 노드·오류 정보는 저장 설정·실행 유형에 따라 달라질 수 있으므로, n8n payload에서 실제로 받은 값과 애플리케이션이 별도로 기록한 workflow 버전·재시도 횟수·대체 경로 여부를 구분합니다. 기록하지 못한 값은 추측하지 않고 확인 필요로 남깁니다.
3. 재시도와 대체 경로의 안전한 순서
1단계: 재시도 가능성 확인
다음 요청은 같은 입력을 다시 보내도 안전한지 확인합니다. 조회·생성·결제의 재시도 가능성은 n8n이 보장하는 분류가 아니라, 제공자 문서와 업무 계약을 확인한 뒤 정하는 운영 판단입니다.
- 조회 요청은 대체로 재시도 가능하지만 제공자 정책을 확인합니다.
- 생성·결제·메일 발송은 idempotency 키나 중복 검증 없이는 자동 재시도를 보류합니다.
- 이미 성공했지만 응답을 받지 못한 경우를 실패로 단정하지 않습니다.
- 외부 시스템의 상태 조회로 실제 처리 여부를 먼저 확인합니다.
2단계: 재시도 예산 설정
재시도 횟수와 총 대기 시간을 제한합니다. 지수형 대기와 jitter는 n8n의 모든 노드가 자동 제공하는 기능이 아니라 애플리케이션 또는 provider SDK에서 구현할 수 있는 권장 설계입니다. 적절한 값은 제공자 제한과 워크플로우의 허용 지연을 측정해 정해야 합니다. n8n의 rate limit 대응은 Retry On Fail 또는 Loop Over Items와 Wait 조합을 기준으로 문서와 환경을 확인합니다(rate limit 공식 안내).
3단계: 대체 경로의 결과 계약 고정
대체 모델이나 다른 노드로 전환할 때는 “응답이 왔다”만으로 성공 처리하지 않습니다.
- 필수 필드가 존재하는가?
- 자료형과 길이 제한을 만족하는가?
- 원래 업무에서 허용하는 품질 기준을 넘는가?
- 사람이 검토해야 하는 플래그가 붙었는가?
- 원래 경로와 다른 모델·버전·프롬프트가 기록됐는가?
검증을 통과하지 못하면 기본값으로 덮지 말고 실패 상태로 남깁니다.
4단계: 중복 방지와 재처리
각 입력에 안정적인 source_id와 업무별 idempotency_key를 부여하고 다음 상태를 기록합니다. 실제 중복 방지는 저장소의 unique 제약이나 성공 기록 조회가 필요하며, 아래 표는 계약 예시입니다.
source_id | idempotency_key | status | attempt | route | result_hash | updated_at
----------|-----------------|--------|---------|-------|-------------|-----------
A-001 | order-A-001 | failed | 2 | backup| | 2026-08-12T12:00:00Z
재처리 시 source_id와 업무 키를 먼저 확인합니다. 성공 기록이 있는데 다시 생성하면 안 되는 업무라면 중복 방지 조건을 통과할 때까지 전송하지 않습니다.
4. 실패 알림과 관찰성
최소 알림 필드는 다음과 같습니다.
- workflow 이름과 버전
- 실행 ID
- 실패 노드
- 오류 유형·상태 코드
- 첫 실패 시각과 마지막 재시도 시각
- 재시도 횟수
- 대체 경로 사용 여부
- 사람에게 필요한 다음 조치
알림은 많다고 좋은 것이 아닙니다. 동일 원인으로 수백 건이 실패하면 묶음 알림과 rate limit을 적용하고, 원인별 담당자와 재처리 기준을 연결합니다.
5. 대체 모델을 사용할 때의 검증표
| 검증 항목 | 통과 조건 |
|---|---|
| 출력 형식 | JSON schema 또는 필수 필드 검사 통과 |
| 품질 | 업무별 표본 검토 기준 통과 |
| 비용 | 예상 호출 비용과 예산 한도 내 |
| 개인정보 | 대체 경로에 보내도 되는 필드만 포함 |
| 재현성 | 모델·버전·프롬프트·입력 해시 기록 |
| 사람 검토 | 낮은 신뢰도·중요 업무는 승인 대기 |
모델 이름만 바꾸어도 결과가 같다고 가정하지 않습니다. 대체 경로는 별도 테스트셋으로 비교하고 결과가 없으면 측정 필요로 남깁니다.
6. 로컬에서 상태 전이 규칙 검증하기
아래 코드는 외부 API 없이 terminal 상태 재진입, 음수 시도 횟수, 최대 재시도, 중복 감지, 대체 결과의 schema·품질 조건을 확인하는 작은 예제입니다. 실제 저장소의 idempotency unique 제약이나 n8n 실행을 대신하지 않습니다.
from __future__ import annotations
from dataclasses import dataclass, replace
from enum import Enum
class Status(str, Enum):
PENDING = "pending"
RETRY = "retry"
SUCCEEDED = "succeeded"
FAILED = "failed"
REVIEW = "review"
@dataclass(frozen=True)
class Job:
source_id: str
idempotency_key: str
status: Status
attempt: int
route: str
def require_non_negative_int(name: str, value: object) -> int:
if type(value) is not int or value < 0:
raise ValueError(f"{name} must be a non-negative integer")
return value
def require_bool(name: str, value: object) -> bool:
if type(value) is not bool:
raise TypeError(f"{name} must be a boolean")
return value
def on_error(
job: Job,
*,
retryable: bool,
max_attempts: int,
deadline_exceeded: bool,
duplicate_detected: bool,
fallback_schema_valid: bool,
fallback_quality_ok: bool,
) -> Job:
if type(job.status) is not Status:
raise TypeError("status must be a Status value")
if type(job.source_id) is not str or not job.source_id:
raise ValueError("source_id and idempotency_key are required")
if type(job.idempotency_key) is not str or not job.idempotency_key:
raise ValueError("source_id and idempotency_key are required")
attempt = require_non_negative_int("attempt", job.attempt)
max_attempts = require_non_negative_int("max_attempts", max_attempts)
retryable = require_bool("retryable", retryable)
deadline_exceeded = require_bool("deadline_exceeded", deadline_exceeded)
duplicate_detected = require_bool("duplicate_detected", duplicate_detected)
fallback_schema_valid = require_bool("fallback_schema_valid", fallback_schema_valid)
fallback_quality_ok = require_bool("fallback_quality_ok", fallback_quality_ok)
if job.status in {Status.SUCCEEDED, Status.FAILED, Status.REVIEW}:
raise ValueError("terminal jobs cannot re-enter retry")
if duplicate_detected:
return replace(job, status=Status.FAILED, route="dedupe")
if deadline_exceeded:
return replace(job, status=Status.FAILED, route="deadline")
if retryable and attempt < max_attempts:
return replace(job, status=Status.RETRY, attempt=attempt + 1)
if fallback_schema_valid and fallback_quality_ok:
return replace(job, status=Status.REVIEW, route="fallback")
return replace(job, status=Status.FAILED)
if __name__ == "__main__":
base = Job("A-001", "order-A-001", Status.PENDING, 0, "primary")
retry = on_error(
base,
retryable=True,
max_attempts=2,
deadline_exceeded=False,
duplicate_detected=False,
fallback_schema_valid=False,
fallback_quality_ok=False,
)
assert retry.status is Status.RETRY and retry.attempt == 1
review = on_error(
retry,
retryable=True,
max_attempts=1,
deadline_exceeded=False,
duplicate_detected=False,
fallback_schema_valid=True,
fallback_quality_ok=True,
)
assert review.status is Status.REVIEW and review.route == "fallback"
failed = on_error(
base,
retryable=False,
max_attempts=0,
deadline_exceeded=False,
duplicate_detected=False,
fallback_schema_valid=False,
fallback_quality_ok=False,
)
assert failed.status is Status.FAILED
duplicate = on_error(
base,
retryable=True,
max_attempts=2,
deadline_exceeded=False,
duplicate_detected=True,
fallback_schema_valid=True,
fallback_quality_ok=True,
)
assert duplicate.status is Status.FAILED and duplicate.route == "dedupe"
try:
on_error(
Job("A-002", "order-A-002", Status.SUCCEEDED, 0, "primary"),
retryable=True,
max_attempts=2,
deadline_exceeded=False,
duplicate_detected=False,
fallback_schema_valid=False,
fallback_quality_ok=False,
)
except ValueError:
pass
else:
raise AssertionError("terminal state must not re-enter retry")
try:
on_error(
Job("A-003", "order-A-003", Status.PENDING, -1, "primary"),
retryable=True,
max_attempts=2,
deadline_exceeded=False,
duplicate_detected=False,
fallback_schema_valid=False,
fallback_quality_ok=False,
)
except ValueError:
pass
else:
raise AssertionError("negative attempt must be rejected")
invalid_fallback = on_error(
base,
retryable=False,
max_attempts=0,
deadline_exceeded=False,
duplicate_detected=False,
fallback_schema_valid=True,
fallback_quality_ok=False,
)
assert invalid_fallback.status is Status.FAILED
deadline = on_error(
base,
retryable=True,
max_attempts=2,
deadline_exceeded=True,
duplicate_detected=False,
fallback_schema_valid=True,
fallback_quality_ok=True,
)
assert deadline.status is Status.FAILED and deadline.route == "deadline"
for bad_attempt in (1.5, float("inf"), float("nan"), True):
try:
on_error(
Job("A-004", "order-A-004", Status.PENDING, bad_attempt, "primary"),
retryable=True,
max_attempts=2,
deadline_exceeded=False,
duplicate_detected=False,
fallback_schema_valid=False,
fallback_quality_ok=False,
)
except (TypeError, ValueError):
pass
else:
raise AssertionError("non-integer attempt must be rejected")
for bad_flag in (1, "true", None):
try:
on_error(
base,
retryable=bad_flag,
max_attempts=2,
deadline_exceeded=False,
duplicate_detected=False,
fallback_schema_valid=False,
fallback_quality_ok=False,
)
except TypeError:
pass
else:
raise AssertionError("non-boolean flag must be rejected")
print(retry, review, failed)
이 코드는 상태 전이와 입력 경계만 검증합니다. duplicate_detected는 성공 기록 조회 결과를 전달하는 자리일 뿐이며, 코드가 영속 저장소를 구현하지는 않습니다. 실제 n8n 오류 워크플로우가 안전하다는 뜻은 아니며, 운영 전에는 외부 API의 재시도·중복·권한 조건과 대체 결과의 schema·품질 검토를 별도로 테스트해야 합니다.
7. 중단·복구 체크리스트
- 오류를 입력·권한·제한·일시적 서버·스키마·승인 오류로 분류했는가?
- 재시도해도 안전한 작업인지 확인했는가?
- 재시도 횟수·총 대기 시간·중단 조건이 있는가?
- 429와 제공자의 대기 지시를 확인했는가?
- 대체 경로의 출력 형식·품질·비용·개인정보 조건을 검증했는가?
- source_id·idempotency key와 저장소의 중복 방지 조건을 확인했는가?
- 알림에서 API 키·개인정보·전체 payload를 제거했는가?
- 사람이 승인해야 하는 업무는 자동 발송하지 않는가?
- 실측 결과가 없으면
측정 필요로 표시했는가?
공식 자료
- n8n Error handling: https://docs.n8n.io/build/flow-logic/handle-errors-gracefully (확인일: 2026-08-12)
- n8n HTTP Request 공통 문제: https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.httprequest/common-issues (확인일: 2026-08-12)
- n8n Handle rate limits: https://docs.n8n.io/integrations/builtin/handle-rate-limits (확인일: 2026-08-12)
- Google 사용자 중심 콘텐츠: https://developers.google.com/search/docs/fundamentals/creating-helpful-content?hl=ko (확인일: 2026-08-12)
'🤖 1인 에이전트 구축기' 카테고리의 다른 글
| 고객사 맞춤 제안서 자동화: 포트폴리오 검색부터 검수까지 (0) | 2026.08.02 |
|---|---|
| 외주 개발/디자인 프로젝트 관리를 위한 캘린더-노션-슬랙 실시간 태스크 동기화로 누락 방지 (0) | 2026.07.01 |
| K-Startup 정부지원사업 공고를 자동 수집·필터링하는 방법 (0) | 2026.06.25 |
| 국세청 홈택스 자료 연동을 위한 영수증 OCR 및 부가세 신고용 지출 증빙 자동 분류 (0) | 2026.06.24 |
| 정기 구독형 서비스(SaaS) 구축을 위한 토스페이먼츠 API와 n8n 연동 기초 (0) | 2026.06.24 |