자동화 전체가 불리언 하나에 걸려 있었다 — n8n import가 워크플로를 끄는 이유

n8n자동화배포트러블슈팅데이터파이프라인

제 시스템의 자동화는 전부 n8n 워크플로 하나에 들어 있습니다.

워크플로            1개
노드                65개
스케줄 트리거        18개
등록된 트리거        19개  (스케줄 18 + 웹훅 1)
평일 실행 횟수       409회 / 일

수집·분류·알림·리밸런싱·포트폴리오 진입·청산·감사가 전부 여기 있습니다. 그리고 이 전부는 데이터베이스 컬럼 하나로 켜지고 꺼집니다.

SELECT id, name, active FROM workflow_entity;

WXNhoSCUeuYZeu0I | Market Risk Radar - Complete | 1

active = 1. 이 값이 0이 되면 409번이 0번이 됩니다. 에러 없이.

왜 CLI로 배포하는가

먼저 배경 하나. 저는 n8n 워크플로를 커맨드라인으로 배포합니다. UI에서 손으로 고치지 않습니다. 변경을 코드처럼 다루고 싶어서이기도 하지만, 사실은 선택지가 없어서입니다.

제가 쓰는 버전은 n8n 2.13.4입니다. 이 구성에서 프로그래밍 방식 접근 경로가 이렇습니다.

그래서 배포 절차가 이렇게 됩니다.

n8n export:workflow --id=<id> --output=wf.json   # 라이브 내려받기
# ... JSON 편집 ...
n8n import:workflow --input=wf.json              # 올리기
n8n publish:workflow --id=<id>                   # ← 이 줄
docker restart <n8n container>                   # 트리거 재등록

이 글은 네 번째 줄이 없을 때 무슨 일이 벌어지는가에 대한 것입니다.

import에는 활성화 옵션이 없다

import:workflow가 받는 인자 전부입니다. 명령어 소스에 선언된 스키마 그대로입니다.

const flagsSchema = z.object({
  input:     z.string().alias('i').describe('Input file name or directory ...').optional(),
  separate:  z.boolean().describe('Imports *.json files from directory ...').default(false),
  userId:    z.string().describe('The ID of the user to assign ...').optional(),
  projectId: z.string().describe('The ID of the project to assign ...').optional(),
});

input, separate, userId, projectId. 끝입니다. 활성화 여부를 지정할 방법이 없습니다.

없는 이유는 임포트 서비스를 열어보면 나옵니다. 선택지가 아니라 정해진 동작이기 때문입니다.

// dist/services/import.service.js
workflow.versionId = uuid.v4();
const oldActiveVersionId = workflow.id ? activeVersionIdByWorkflow.get(workflow.id) : null;

if (oldActiveVersionId || workflow.activeVersionId || workflow.active) {
  this.logger.info(`Deactivating workflow "${workflow.name}". Remember to activate later.`);
}

workflow.active = false;          // ← 조건 없음
workflow.activeVersionId = null;  // ← 조건 없음
await tx.upsert(WorkflowEntity, workflow, ['id']);

두 줄에 if가 붙어 있지 않습니다. 임포트하면 무조건 꺼집니다. 조건문이 감싸고 있는 건 로그 메시지뿐입니다.

두 개의 스위치가 동시에 내려간다

여기서 activeVersionId = null이 중요합니다. 2.x에는 층이 하나 더 있습니다.

versionId        = 워크플로에 저장된 현재 내용
activeVersionId  = 실제로 실행 중인 내용

임포트는 앞쪽에 새 UUID를 발급하고 뒤쪽을 null로 비웁니다. “새 정의가 저장되지만 옛 정의가 계속 돈다”가 아니라, 실행 대상을 가리키는 포인터가 아예 사라집니다. 스키마가 그 관계를 강제합니다.

CREATE TABLE "workflow_entity" (
  ...
  "active"          boolean NOT NULL,     -- DEFAULT 절 없음: 값은 애플리케이션이 정한다
  "versionId"       varchar(36) NOT NULL,
  "activeVersionId" varchar(36),
  CONSTRAINT "FK_..." FOREIGN KEY ("activeVersionId")
    REFERENCES "workflow_history" ("versionId") ON DELETE RESTRICT
);

되돌리는 건 별도 명령어입니다.

$ ls dist/commands/
... export/  import/  publish/  unpublish/ ...

publish:workflow의 설명이 정확히 그렇게 말합니다 — “Publish a specific version of a workflow. If no version is specified, publishes the current version.”

그래서 publish는 선택 사항이 아닙니다. 임포트한 내용은 발행하기 전까지 초안입니다. 임포트 서비스가 마지막에 호출하는 함수 이름도 그렇게 부릅니다 — updateIndexForDraft(workflow).

제 운영 문서에는 이 항목이 이렇게 적혀 있습니다.

n8n import:workflow는 워크플로를 비활성화하므로 활성화 + 재시작 필수

한 줄짜리 체크리스트 항목이 있다는 건, 한 번 당했다는 뜻입니다.

도구는 경고했다. 그래도 놓쳤다.

여기서 이 글을 쓰면서 제가 틀렸던 부분을 하나 고쳐야 합니다.

저는 이 사건을 오랫동안 “아무 경고 없이 꺼진 것”으로 기억하고 있었습니다. 그런데 소스를 열어보니 n8n은 평문으로 경고합니다.

Deactivating workflow "Market Risk Radar - Complete". Remember to activate later.

이보다 더 명확하게 쓸 수도 없습니다. 그러니 “도구가 조용했다”는 제 기억은 사실이 아니었습니다.

그럼에도 놓치는 이유는 이 메시지의 위치와 등급에 있습니다.

$ n8n import:workflow --input=wf.json
Deactivating workflow "Market Risk Radar - Complete". Remember to activate later.
Successfully imported 1 workflow.
$ echo $?
0

그래서 이건 도구의 실패가 아니라 읽기의 실패입니다. 그리고 읽기의 실패는 체크리스트로는 잘 안 고쳐집니다 — 다음에도 똑같이 마지막 줄만 볼 테니까요. 고치려면 사람이 로그를 읽는 대신 기계가 상태를 확인하게 만들어야 합니다.

이 함정의 나머지 성질은 4편·6편에서 다룬 것과 같습니다. 그 순간부터 하루 409번 돌던 것이 0번 돕니다.

그리고 여기 두 번째 문제가 겹칩니다. 정지를 알아차릴 방법이 없습니다.

이 파이프라인의 출력은 슬랙 알림입니다. 알림이 안 오면 둘 중 하나입니다.

  1. 시스템이 멈췄다
  2. 알릴 만한 일이 없었다

2번이 정상 레퍼토리에 있습니다. 실제로 이 시스템은 주말에 아무것도 하지 않도록 설계돼 있습니다. 모든 스케줄이 * * 1-5, 즉 월~금이기 때문입니다. 8월 수집량을 요일로 갈라보면 이렇습니다.

평일   22,912건   (하루 평균 1,527건)
주말        0건   ← 버그가 아니라 설계

토요일 아침에 조용한 것은 정상입니다. 그래서 월요일 아침의 침묵도 정상처럼 보입니다. 조용함이 정상 신호인 시스템에서는 조용함으로 고장을 감지할 수 없습니다.

그래서 확인해야 한다

배포가 반영됐는지는 믿는 게 아니라 물어봐야 합니다. 명령어가 성공했다는 사실은 증거가 아닙니다.

제가 배포 뒤에 확인하는 것 세 가지입니다.

하나, 워크플로가 켜져 있는가.

docker exec <n8n> node -e '
  const {DatabaseSync} = require("node:sqlite");
  const db = new DatabaseSync("file:/home/node/.n8n/database.sqlite?mode=ro");
  console.log(db.prepare(
    "SELECT id, active, triggerCount, versionId, activeVersionId FROM workflow_entity"
  ).all());
'

읽기 전용(mode=ro)으로 엽니다. 확인하러 들어간 셸에서 실수로 쓰는 일을 원천 차단합니다.

지금 상태입니다.

active           : 1
triggerCount     : 19
versionId        : 21e57fd0-7ea6-48f0-9a50-978120bc380a
activeVersionId  : 21e57fd0-7ea6-48f0-9a50-978120bc380a   ← 같아야 한다

둘, 트리거 수가 맞는가. triggerCount는 n8n이 실제로 등록한 트리거 개수입니다. JSON에 스케줄 노드가 18개 있고 웹훅이 1개니 19가 맞습니다. 이 값이 0이면 파일은 올라갔지만 아무것도 등록되지 않은 것이고, 기대보다 작으면 일부 트리거가 유실된 것입니다. 노드 개수가 아니라 등록된 트리거 개수를 봐야 합니다.

셋, 파이프라인이 실제로 데이터를 낳고 있는가. 위 두 개는 설정이 맞다고 말해줄 뿐, 결과가 나오는지는 말해주지 않습니다. 마지막 확인은 항상 산출물 쪽에서 합니다 — 오늘 수집된 원문이 평소 범위 안에 있는가. 평일 1,527건 근처면 정상이고, 두 자리 수면 뭔가 잘못된 겁니다.

이 글에서 하지 않은 것

정직하게 밝힐 게 하나 있습니다. 저는 이 메커니즘을 재현 실험으로 증명하지 않았습니다.

증명하려면 라이브 인스턴스에 import를 다시 걸어 자동화를 실제로 정지시켜야 합니다. 돌아가는 시스템에서 그걸 할 이유가 없습니다. 그래서 이 글이 근거로 삼은 것은 세 가지입니다.

  1. 임포트 서비스 소스workflow.active = falseworkflow.activeVersionId = null이 조건 없이 실행됨
  2. 데이터베이스 스키마activeNOT NULL이면서 DEFAULT 없음, activeVersionIdversionId와 별개 컬럼
  3. 사고 이후 운영 문서에 남은 절차 기록

앞의 두 개는 누구나 자기 인스턴스에서 확인할 수 있습니다. 세 번째는 제 기록입니다. “소스가 이렇게 생겼다”와 “이렇게 동작하는 것을 관측했다”는 다른 주장이라, 어느 쪽인지 구분해 두는 편이 낫다고 생각합니다.

찾아보다 알게 된 것 하나. n8n 2.x에는 workflow_publish_history라는 테이블이 있고, 임포트가 활성 워크플로를 끌 때 event = 'deactivated' 행을 남깁니다. 사건의 DB 기록이 남는다는 뜻입니다. 그런데 제 인스턴스에서는 이 테이블이 비어 있습니다 — 마지막 임포트가 이 기능이 생기기 전이었기 때문으로 보입니다. 그래서 “그때 실제로 이런 행이 쓰였다”는 증거로는 쓸 수 없었습니다. 다음번 배포부터는 남을 겁니다.

남는 것

배포 도구의 기본값은 배포자의 의도를 모릅니다. import가 워크플로를 켜지 않는 건 합리적인 선택입니다 — 남의 워크플로 파일을 받아 무심코 실행시키는 것보다는 꺼진 채로 들어오는 게 안전합니다. 문제는 그 안전한 기본값이 내 상황에서는 정확히 사고의 형태라는 것입니다. 앞 글에서 로컬 LLM의 캐시 기본값이 제 워크로드에 맞지 않았던 것과 같은 종류입니다.

“성공”은 명령어의 판단이지 내 목표의 판단이 아닙니다. Successfully imported 1 workflow.는 참말입니다. 파일은 정말로 임포트됐습니다. 다만 제가 원한 건 임포트가 아니라 “자동화가 새 정의로 계속 도는 것” 이었고, 명령어는 그것에 대해 아무 말도 하지 않았습니다. 종료 코드는 “내가 시킨 일을 했다”는 뜻이지 “네가 원한 상태가 됐다”는 뜻이 아닙니다.

그리고 경고는 읽히는 위치에 있어야 경고입니다. n8n은 분명히 말해줬습니다. 다만 그 말이 info 등급으로, 성공 메시지 바로 위에, 종료 코드 0과 함께 나왔습니다. 사람이 마지막 줄만 읽는다는 건 잘못이라기보다 예측 가능한 사실입니다. 그러니 “다음엔 잘 읽자”는 대책이 아니고, 대책은 로그를 읽는 자리에 상태를 조회하는 명령 세 줄을 놓는 것입니다.

침묵을 모니터링하려면 침묵의 정상 범위를 먼저 정의해야 합니다. 주말에 0건인 시스템에서 “0건”은 알람이 될 수 없습니다. 쓸 수 있는 건 “평일 09:00~18:00에 0건” 같은 조건부 기대값이고, 그건 시스템의 스케줄을 알아야만 쓸 수 있습니다. 모니터링이 배포보다 어려운 이유가 대개 여기 있습니다.


이 글의 n8n 수치는 다음으로 직접 확인한 값입니다.

n8n list:workflow                                    # 워크플로 목록
n8n export:workflow --id=<id> --output=/tmp/wf.json  # 노드·트리거 구성
docker exec <n8n> node -e '...'                      # workflow_entity 읽기 전용 조회

평일 409회는 워크플로 JSON에서 18개 스케줄 노드의 cron 식을 추출해 하루치 발동을 세어 계산했습니다(금요일은 주간 리마인더가 더해져 410회).