k6로 n8n 부하테스트 - worker를 8배 늘려도 처리량이 그대로인 이유

안녕하세요. 인포그랩에서 DevOps 엔지니어로 일하는 Chad입니다. 자동화 시스템을 운영하다 보면 "우리 시스템은 동시에 몇 건까지 버티나요?"라는 질문을 받습니다. 동시에 실행 중인 워크플로를 몇 건까지 감당하느냐는 뜻입니다. 감으로 답할 수 없고, 장애가 난 뒤에 확인하면 늦습니다.
그래서 직접 측정해 봤습니다. 'worker를 늘리면 처리량도 늘겠지'라고 생각하고 worker 동시 실행 수를 크게 늘려 부하를 한계까지 올렸습니다. 결과는 예상과 달랐습니다. 처리량은 늘지 않고, 응답이 느려지고, 요청 5건 중 1건이 에러로 실패했습니다. 원인을 찾으려고 대시보드를 봤지만, 떠 있는 건 worker 점유율 100%라는 숫자뿐이었습니다. 정작 그 worker의 CPU 사용률은 한 자릿수였습니다. worker를 더 늘려도 처리량은 꿈쩍하지 않았습니다. 무엇이 처리량을 가로막고 있는지는 어느 대시보드 패널에도 없었습니다. 이 글은 그 병목을 찾아가는 기록입니다.
n8n을 Queue Mode(main이 실행 ID를 Redis 큐에 넣으면 worker가 이를 가져와 데이터베이스에서 워크플로를 읽고 실행하는 구성)로 띄운 스택에 k6로 부하를 걸고, Prometheus가 수집한 지표를 Grafana로 관측했습니다. 부하 모델과 부하 축 선정, 정상 상태 측정, 한계 지점 관측, 병목 격리, 운영값 환산, 측정의 한계 정리 순으로 진행했습니다. 이 글은 구체적인 실습 과정과 결과를 다뤘습니다.
테스트 환경
- k6 (컨테이너 실행), Grafana v11.4.0
- n8n 2.23.2, Queue Mode, main과 worker와 task runner 분리
- PostgreSQL, Redis, Prometheus
- 단일 호스트 Docker Compose
수치는 실측과 환산을 표에 구분해 적었습니다.
이 글에서 실행한 부하테스트
본격적인 측정에 들어가기 앞서, 부하테스트의 정의부터 이 글에서 쓴 방법과 도구, 용어까지 먼저 정리하겠습니다.
부하테스트로 확인하는 것
부하테스트는 시스템이 부하 조건에 따라 어떻게 동작하는지 측정하는 성능 테스트입니다. 응답성, 처리량, 자원 사용률이 부하에 따라 어떻게 변하는지를 숫자로 남깁니다.
운영 관점에서 이 숫자는 세 가지 위험을 부하가 몰리기 전에 보여줍니다.
- 포화: 동시 요청이 처리 능력을 넘어 대기가 쌓이는 상태
- 메모리 고갈: 메모리 소비가 컨테이너 한계를 넘어 프로세스가 강제 종료되는 실패
- 응답 지연: 큐 대기가 길어져 응답시간 기준을 넘는 증상
세 항목은 성격이 각각 다릅니다. 포화는 상태, 메모리 고갈은 실패, 응답 지연은 증상입니다. 그래도 함께 보는 이유는 서로 영향을 미치기 때문입니다. 포화가 지연을 만들고, 지연이 길어지면 타임아웃으로 터집니다.
정리하면 부하테스트는 다음 세 질문에 답하는 일입니다.
- 시스템이 동시 요청을 몇 건까지 감당하나.
- 무엇이 먼저 한계에 닿나.
- 한계를 넘으면 에러로 터지나, 지연으로 늘어지나.
세 번째 질문이 특히 중요합니다. 이 글의 측정에서 시스템은 에러 없이 조용히 느려지기만 한 구간이 여러 번 있었습니다. 에러율만 보고 있었다면 아무 일도 없는 것처럼 보였을 겁니다. 에러율 중심 모니터링은 이 유형의 성능 저하를 놓칩니다. 따라서 에러율과 지연 지표를 함께 읽어야 합니다.
이 글에서 수행한 것: smoke와 breakpoint
부하테스트에는 여러 유형이 있고, 유형마다 확인하려는 것이 다릅니다. k6는 주요 유형을 여섯 가지로 나눕니다.
| 유형 | 목적 |
|---|---|
| smoke | 최소 부하에서 정상 동작 확인 및 기준선 수집 |
| average-load | 평상시 트래픽에서 성능 확인 |
| stress | 피크 트래픽에서 성능 확인 |
| spike | 갑작스러운 대규모 트래픽 증가에서 생존 여부 확인 |
| breakpoint | 부하를 계속 올려 시스템 한계 지점 탐색 |
| soak | 장시간 부하에서 성능 저하 확인 |
이 글은 기준선을 smoke로 잡고, 한계를 breakpoint로 찾았습니다. 부하를 15, 30, 50, 80, 120으로 계단식으로 올리며 시스템이 무너지는 지점을 관측했습니다. 다른 유형과 달리 breakpoint 테스트는 성능이 나빠지기 시작해도 부하를 계속 올립니다. 한계 지점을 찾는 것이 목적이라 저하가 시작돼도 멈추지 않습니다.
한 가지 덧붙이면, k6는 breakpoint에 ramping-arrival-rate를 권장합니다. 앞 요청이 끝나기를 기다리지 않고, 정해진 속도로 새 요청을 계속 시작하는 방식입니다. 이 글은 요청 하나가 끝나야 다음 요청이 나가는 방식을 썼고, 따라서 처리 중 요청 수가 가상 사용자 수만큼으로 고정됩니다.
도구: k6
k6는 Grafana Labs의 오픈 소스 부하테스트 도구입니다. 부하 시나리오를 자바스크립트 파일로 작성하고 CLI로 실행합니다.
이 글에서 k6를 고른 이유는 세 가지입니다.
- 시나리오가 자바스크립트 파일이라 git으로 관리하고 코드 리뷰를 거칠 수 있습니다.
- 공식 컨테이너 이미지를 제공해 별도 설치 없이 명령 한 줄로 실행됩니다.
- 통과 기준(threshold)을 스크립트에 적어 두면 위반 시 종료 코드 99로 끝나기에 CI에서 자동으로 판정할 수 있습니다.
핵심 용어
| 용어 | 뜻 |
|---|---|
| 가상 사용자(VU) | k6가 만드는 가상의 클라이언트 하나. 각자 독립적으로 요청을 보냅니다. 20명이면 20개 요청 흐름이 동시에 돌아갑니다 |
| 반복(iteration) | 가상 사용자 한 명이 시나리오를 한 번 완주하는 단위 |
| p95 | 응답시간을 빠른 순으로 정렬했을 때 95% 지점의 값. 가장 느린 5%를 제외한 상한으로, 평균이 가리는 지연을 드러냅니다 |
| 처리량 | 초당(req/s) 또는 분당 처리한 요청 수 |
| worker | 큐에서 실행을 꺼내 워크플로를 실행하는 n8n 프로세스 |
| task runner(러너) | Code 노드처럼 사용자가 작성한 코드를 실행하는 노드를 대신 처리하는 별도 프로세스 |
| worker 동시 실행 수 | 한 worker가 동시에 붙잡을 수 있는 실행 수. --concurrency 플래그로 지정하며 기본값은 10입니다. 환경변수 N8N_CONCURRENCY_PRODUCTION_LIMIT이 -1 이외의 값으로 설정돼 있으면 그 값이 우선합니다 |
| 러너 한도 | 러너 하나가 동시에 처리할 수 있는 태스크 수. N8N_RUNNERS_MAX_CONCURRENCY로 지정합니다 |
| 슬롯 점유율 | worker 동시 실행 수 중 실제로 채워진 비율 |
부하 모델부터 정한다
스크립트보다 부하 모델이 먼저입니다. n8n Webhook 노드는 응답 방식에 따라 측정 대상이 달라지기 때문에 모델을 정하지 않고 스크립트부터 쓰면 나중에 결과를 해석할 수 없습니다.
두 가지 응답 방식
n8n Webhook 노드의 Respond 옵션 중 부하 측정과 관련된 것은 Immediately, When Last Node Finishes, Using 'Respond to Webhook' Node 셋입니다. 부하 측정 관점에서는 응답이 워크플로 완료 전에 나가느냐 후에 나가느냐로 갈리기에 이 글에서는 두 방식으로 묶어 다룹니다. 이 선택이 k6가 무엇을 측정하는지를 결정합니다.
| 응답 방식 | 해당 옵션 | 응답 시점 | 요청하는 쪽(k6) 입장 | 측정 대상 |
|---|---|---|---|---|
| 즉시 응답 | Immediately | Webhook 노드 실행 즉시 응답 코드와 Workflow got started 메시지 반환. 워크플로는 백그라운드에서 계속 진행 | 바로 응답받고 다음 요청 | 접수 능력 |
| 완료 후 응답 | When Last Node Finishes, Using 'Respond to Webhook' Node | 워크플로가 응답 지점에 도달한 뒤 반환 | 끝까지 대기 | 처리 능력 |
완료 후 응답 중 When Last Node Finishes는 응답 시점이 워크플로 완료 시점으로 고정됩니다. Using 'Respond to Webhook' Node는 응답 노드를 어디에 두느냐에 따라 달라지기에 처리 능력을 측정하려면 응답 노드를 워크플로 마지막에 둬야 합니다. 이 글의 측정에서는 응답 시점이 완료 시점과 일치합니다.
둘 다 유효한 측정이지만 같은 것이 아닙니다. 즉시 응답은 요청을 얼마나 빨리 받아 낼 수 있는지를 측정하고, 완료 후 응답은 요청을 얼마나 빨리 처리해 낼 수 있는지를 측정합니다.
이 글이 쓴 방식: 완료 후 응답
이 글은 완료 후 응답을 썼습니다. 워크플로가 끝나야 응답이 나가기에 각 가상 사용자는 그동안 대기하고, k6는 "요청 → 응답 대기 → 다음 요청"을 반복합니다. 따라서 가상 사용자 1명이 곧 처리 중인 요청 1건이 됩니다. 가상 사용자를 20명으로 두면 처리 중인 요청이 항상 20건으로 고정됩니다.

이 그림에서 main과 worker 사이는 Redis를 거치는 비동기 전달입니다. 그런데 main이 결과를 기다렸다 응답하기에 요청하는 쪽이 보기에는 처음부터 끝까지 하나의 요청입니다.
이 구조가 만드는 결과는 세 가지입니다.
- 처리 중인 요청 수가 가상 사용자 수로 고정돼 부하가 스스로 제한됩니다.
- 응답 시간은 접수 시간이 아니라 큐 대기와 실행을 모두 포함한 값이 됩니다.
- 큐잉 공식이 실측에 그대로 맞아떨어집니다. 뒤에서 측정값을 운영 설정값으로 환산할 때 이 성질을 씁니다.
부하 축을 고른다: 무엇을 재려는가
부하 모델 다음에 정할 것은 부하 축입니다. 시간을 끄는 작업을 워크플로의 어느 노드에 두느냐로 병목이 잡히는 위치가 통째로 바뀝니다.
시간을 어느 노드에 두느냐
| 시간 끄는 작업을 두는 곳 | 실행 주체 | 한계를 정하는 설정 |
|---|---|---|
| Code 노드(파싱·변환) | task runner | 러너 한도 N8N_RUNNERS_MAX_CONCURRENCY |
| HTTP 요청 노드(외부 API 대기) | worker | worker 동시 실행 수 --concurrency |
표에서 보듯 n8n 2.x에서 Code 노드는 worker가 직접 실행하지 않고 별도 러너 프로세스(task runner)에 위임합니다. 반면 외부 API를 기다리는 HTTP 노드는 worker가 실행 슬롯을 점유한 채 응답을 기다립니다. 따라서 worker와 러너는 서로 다른 설정으로 서로 다른 한계를 갖는 별개의 자원입니다.
같은 부하량이라도 축이 다르면 전혀 다른 컨테이너가 병목이 되고, 봐야 할 지표도 올려야 할 설정도 달라집니다.
축을 잘못 고르면 측정이 헛수고가 됩니다
worker 동시 실행 수를 튜닝하려고 부하를 걸었는데 실제로는 러너 한도를 측정하고 있었다면, 그 숫자로 내린 결론이 전부 엉뚱한 곳을 가리킵니다. 설정 한 줄이면 될 일에 서버를 늘리게 되고, 늘려도 처리량은 그대로입니다.
이 글은 뒤에서 두 축을 다 사용합니다. 원리를 볼 때는 파싱만 남긴 단순 워크플로를, 실전을 볼 때는 외부 대기까지 넣은 워크플로를 씁니다.
부하 스크립트와 워크플로는 이렇게 구성했다
측정에 사용한 것은 k6 스크립트 파일 하나와 n8n 워크플로 하나입니다.
부하 스크립트
핵심은 scenarios와 thresholds 두 항목입니다.
export const options = {
scenarios: SCENARIOS[__ENV.SCENARIO || "s0"],
thresholds: {
http_req_failed: ["rate<0.05"],
http_req_duration: ["p(95)<60000"],
},
};
scenarios가 부하 프로파일이고 thresholds가 통과 기준입니다. 실행할 때 SCENARIO를 지정하면 스크립트 하나로 여러 시나리오를 돌릴 수 있습니다.
기본 설정에서 k6는 통과 기준을 위반해도 부하를 계속 겁니다. 끝까지 다 돌린 뒤 종료 코드 99로 기준 위반을 알립니다. 위 p(95)<60000도 통과 여부를 판정하는 기준이지, 요청을 끊는 기준이 아닙니다. 기준 위반 시점에 테스트를 중단하려면 threshold에 abortOnFail을 지정해야 합니다.
요청이 실제로 끊기는 시점은 따로 있습니다. k6의 기본 요청 대기 시간은 60초입니다. 그보다 오래 걸리는 응답은 그 자리에서 실패로 처리됩니다. 따라서 단건 처리시간이 60초를 넘길 수 있는 워크플로를 측정할 때는 timeout을 함께 조정해야 합니다.
실행 본문
가상 사용자가 반복 실행하는 함수입니다.
export default function () {
const res = http.post(
`${BASE}${PATH}`,
JSON.stringify({ sleep_ms: SLEEP_MS, alloc_mb: ALLOC_MB }),
{ headers: { "Content-Type": "application/json" } },
);
check(res, {
"status 200": (r) => r.status === 200,
"ok:true": (r) => r.json("ok") === true,
});
}
여기서 세 가지가 중요합니다.
http.post가 응답까지 기다립니다. 이것이 완료 후 응답 방식의 핵심입니다.check로 상태 코드와 응답 내용을 함께 확인합니다. 상태 코드만으로는 부족한데, 200이 아닌 응답이 오히려 빨라 보이는 경우가 있기 때문입니다. 이 함정은 뒤에서 실제 사례로 봅니다.check실패는 종료 코드에 반영되지 않습니다. 통과 기준과 달리 기록만 남겨 검증 통과율은 요약에서 직접 읽어야 합니다.
서버 쪽 워크플로
부하를 받아 처리하는 워크플로입니다. 노드 세 개로 구성했습니다.
[요청 받기] → [Code: 지정 시간 대기 + 지정 용량 할당] → [응답 반환]
Code 노드가 합성 부하입니다. 처리시간은 대기로, 메모리 압박은 버퍼 할당으로 재현합니다. 요청에 담긴 sleep_ms와 alloc_mb를 읽어 동작하기에 워크플로를 고치지 않고 값만 바꿔 성격이 다른 부하를 만들 수 있습니다.
먼저 정상 상태를 측정한다
부하 모델과 부하 축을 정하고 스크립트를 준비했으니, 이제 측정을 시작합니다. 한계까지 부하를 올리기 전에 낮은 부하부터 단계적으로 올리며 정상 상태의 기준값을 확보합니다. 나중에 나올 숫자가 정상인지 이상인지 판단하려면 비교 대상이 있어야 하기 때문입니다.
여기서는 파싱만 남긴 단순 워크플로를 사용합니다. 부하는 세 단계로 올립니다.
| 구간 | 가상 사용자 | 처리 시간 | 측정 시간 | 목적 |
|---|---|---|---|---|
| s0 | 1명 | 1초 | 2분 | 기준선 확보 |
| s1 | 10명 | 3초 | 3분 | 슬롯 여유 구간 관측 |
| s2 | 20명 | 3초 | 3분 | 슬롯 포화 구간 관측 |
worker 동시 실행 수는 세 구간 모두 15로 고정했습니다.
기준선 확보
가장 낮은 부하부터 시작합니다. 다음은 s0 시나리오를 실행하는 명령입니다.
docker run --rm --network n8n_default --entrypoint k6 \
-e SCENARIO=s0 -e N8N_BASE_URL=http://n8n-main-1:5678 \
-v /opt/n8n/bench/k6:/scripts k6-runner:0.1.0 \
run --no-color /scripts/queue-capacity-load.js
--summary-export 옵션을 붙이면 실행 결과가 JSON 파일로 남습니다. 이 파일은 k6가 직접 집계한 원본 수치라, 나중에 대시보드 수치와 어긋날 때 판단 기준이 됩니다. 실제로 이 글의 측정에서도 두 수치가 갈리는 구간이 나옵니다.

이 0.04초, 즉 40밀리초가 이후 판독의 기준이 됩니다. 부하를 올렸을 때 나타나는 지연이 수십 밀리초 수준이면 시스템이 원래 갖는 고정 비용입니다. 반면 수백 밀리초나 초 단위로 커지면 고정 비용이 아니라 대기가 발생한 것입니다. 자릿수가 판단 기준입니다.
무엇을 어떤 순서로 볼 것인가
부하를 올리면서 k6 실행 결과 요약에서 확인할 지표는 넷입니다. 보는 순서가 정해져 있습니다.
| 순서 | 지표 | 의미 |
|---|---|---|
| 1 | 검증 통과율 | check가 통과한 비율. 응답이 의도한 내용인지 확인 |
| 2 | 에러율 | 요청이 실패한 비율 |
| 3 | 처리량 | 초당 처리한 요청 수 |
| 4 | p95 | 가장 느린 5%를 제외한 응답시간 상한 |
지연 지표를 마지막에 보는 이유가 있습니다. 요청이 빨리 실패하면 지연 지표는 오히려 좋아집니다. 응답을 받지 못하고 즉시 끊긴 요청도 응답시간에는 짧게 기록되기 때문입니다. 따라서 검증 통과율과 에러율로 측정 자체가 유효했는지 먼저 확인하고, 그다음에 지연을 읽어야 의미가 있습니다.
슬롯이 남는 구간과 차는 구간
기준선을 확보했으니 부하를 올립니다. 가상 사용자를 10명, 20명으로 늘리며 두 구간을 비교했습니다.

세 구간의 결과를 정리하면 다음과 같습니다.
| 구간 | p95 | 처리량 | 실패율 |
|---|---|---|---|
| s0: 가상 사용자 1, 처리시간 1초 | 1.04s | 0.93 req/s | 0% |
| s1: 가상 사용자 10, 처리시간 3초 | 3.09s | 3.25 req/s | 0% |
| s2: 가상 사용자 20, 처리시간 3초 | 6.04s | 3.29 req/s | 0% |
s1에서 s2로 갈 때 부하는 2배가 됐는데 처리량은 3.25에서 3.29로 1% 남짓 올랐습니다. 반면 p95는 3.09초에서 6.04초로 약 2배가 됐습니다. 늘어난 부하가 처리량으로 가지 않고 전부 대기 시간으로 갔다는 뜻입니다.
부하를 올려도 처리량이 더 늘지 않는 지점을 앞으로 '천장'이라고 부르겠습니다. 이 글이 찾으려는 것이 바로 이 천장의 위치와 원인입니다.
다만 k6 실행 결과만으로는 처리량이 왜 멈췄는지 알 수 없습니다. 부하가 시스템의 처리 능력을 넘어선 것인지, 아니면 다른 이유로 막힌 것인지 구분되지 않습니다. k6는 요청을 보내고 응답을 받는 쪽이라 서버 내부에서 무슨 일이 있었는지 볼 수 없습니다. 서버 쪽 지표를 봐야 합니다.
서버 쪽에서 본 세 구간
Grafana 대시보드로 같은 세 구간을 확인했습니다. 볼 것은 세 가지입니다. worker가 처리 중인 job 수, 큐에서 대기 중인 job 수, 슬롯 점유율입니다.



s2의 대기 5건은 계산으로 확인됩니다. 처리 중인 요청이 가상 사용자 수인 20건으로 고정돼 있고 worker가 그중 15건을 슬롯에 넣으면, 남은 5건은 큐에서 기다릴 수밖에 없습니다.

여기까지는 예상대로입니다. 슬롯이 다 차면 처리량이 멈추고 큐에 대기가 쌓입니다.
계산이 맞지 않는다
그런데 검산을 해보면 숫자 하나가 어긋납니다.
슬롯 15개가 각각 3초짜리 작업을 처리한다면, 이론상 처리량은 다음과 같아야 합니다.
15개 ÷ 3초 = 5 req/s
그런데 s2의 실측 처리량은 3.29 req/s입니다. 이론값보다 34% 낮습니다.
점유율은 100%인데 처리량은 이론값에 못 미칩니다. 슬롯은 다 찼지만 그 안의 작업이 전부 실행되고 있지는 않다는 뜻입니다. 무엇이 15건을 붙잡아 놓고도 5 req/s를 내지 못하게 하는지는 다음 섹션에서 확인하겠습니다.
판독할 때 걸리기 쉬운 두 가지
정상 상태를 확인하는 과정에서 자주 걸리는 함정이 둘 있습니다. 둘 다 이 글의 뒷부분에서 실제로 나와서 미리 짚어 둡니다.
응답이 빠른 것이 실패의 결과일 수 있습니다.
다른 워크플로로 측정을 시도했을 때 이런 결과가 나왔습니다. 검증은 0건 통과에 3,883건 실패로 실패율 100%인데, p95는 190밀리초였습니다. 지연 지표만 보면 대단히 빠른 시스템입니다.
원인은 설정 파일의 포트 오타였습니다. 요청이 서버에 닿지 못하고 즉시 에러로 끊긴 것이라, 응답시간이 짧게 기록됐습니다. 앞에서 검증 통과율과 에러율을 먼저 보라고 한 이유가 이것입니다.
메모리 사용량은 힙 패널로 판단하면 안 됩니다.
뒤에서 메모리를 많이 쓰는 부하를 거는 구간이 나옵니다. 그때 Grafana의 힙 사용량 패널은 거의 움직이지 않는데, 메모리 문제가 없어서가 아닙니다.
힙은 자바스크립트 엔진이 관리하는 메모리 영역입니다. 그런데 이 측정에서 부하를 만드는 방식은 버퍼 할당인데, 버퍼는 힙 바깥에 잡힙니다. 힙 사용량을 측정하는 패널로는 원천적으로 보이지 않는 영역입니다.
프로세스가 실제로 점유한 메모리를 보려면 RSS를 읽어야 합니다. RSS는 운영체제가 프로세스에 실제로 할당한 물리 메모리 크기이고, 힙과 힙 바깥을 모두 포함합니다. 컨테이너 단위로는 docker stats의 메모리 사용량으로 대신 읽을 수 있습니다. 정확히 같은 값은 아니고 컨테이너 전체 사용량에서 비활성 파일 캐시를 뺀 값이지만, 힙 바깥까지 포함한다는 점에서 신호는 같습니다.
누가 실제로 일하는가: 메모리가 알려준다
앞 절에서 계산이 34% 어긋났습니다. 슬롯은 100% 찼는데 처리량은 이론값에 못 미쳤습니다. 원인을 찾으려면 어느 프로세스가 실제로 Code를 실행하는지 확인해야 합니다.
이번에는 메모리를 많이 쓰는 조건으로 다시 측정했습니다. 가상 사용자 12명, 처리시간 20초, 요청당 300MiB를 할당하도록 설정하고, docker stats로 컨테이너 넷을 동시에 관찰했습니다. 정상 상태 측정과는 다른 조건입니다. 메모리가 어디에 쌓이는지를 보는 것이 목적이기 때문입니다.

메모리는 러너에 쌓인다
부하 전후를 비교하면 차이가 분명합니다.
| 컨테이너 | 부하 전 | 부하 중 | 상한 |
|---|---|---|---|
| n8n-main-1 | 430.4MiB | 429.1MiB | 15.42GiB |
| runner-main-1 | 3.215MiB | 3.215MiB | 15.42GiB |
| n8n-worker-1 | 198MiB | 200.7MiB | 3GiB |
| runner-worker-1 | 85.13MiB | 3.111GiB | 10GiB |
Code 노드가 할당하는 메모리는 worker가 아니라 러너에 쌓입니다. worker는 부하 전후로 200MiB 안팎을 유지하는데, worker 쪽 러너는 40배 가까이 늘었습니다. main 쪽 러너는 소수점까지 그대로인데, Code 노드 실행이 worker 쪽에서만 일어났기 때문입니다.
CPU도 함께 볼 만합니다. 러너가 3.1GiB를 쓰는 동안 CPU 사용률은 0.05%에서 0.25% 사이였습니다. 메모리는 40배 가까이 늘었는데 CPU는 움직이지 않았습니다.
메모리 상한을 어디에 걸 것인가
이 차이가 운영 설정에서 문제가 됩니다.
컨테이너에 메모리 상한을 걸면, 사용량이 그 한도를 넘는 순간 해당 컨테이너만 강제 종료되고 호스트는 살아남습니다. 상한이 없으면 그 컨테이너가 호스트 메모리를 계속 소비하다가 시스템 전체를 위험하게 만듭니다.
그런데 상한을 어디에 걸겠냐고 물으면 대부분 worker라고 답합니다. 위 화면에서 worker 상한은 3GiB인데, 실제로 3.1GiB를 쓴 것은 러너입니다. worker 기준으로 산정한 상한을 러너에 걸었다면 부하 시작과 동시에 종료됐을 값입니다. 반대로 worker에만 상한을 걸고 러너를 무제한으로 두면, 정작 메모리를 쓰는 쪽에는 제한이 없습니다.
34% 간극의 원인
이제 앞 절의 계산이 왜 어긋났는지 설명할 수 있습니다.
큐 대시보드가 표시한 "처리 중 15"는 worker가 붙잡은 job의 수입니다. 그런데 Code를 실제로 실행하는 것은 러너이고, 러너가 동시에 처리할 수 있는 태스크 수에는 별도 한도가 있습니다. 이 측정 환경에서 그 한도는 10이었습니다.
그러면 이렇게 됩니다.
| 단계 | 건수 |
|---|---|
| worker가 큐에서 꺼내 붙잡은 job | 15 |
| 러너에서 실제로 실행 중인 태스크 | 10 |
| worker 안에서 러너 순서를 기다리는 job | 5 |
앞 절의 계산은 첫 번째 줄을 처리 능력으로 봤습니다. 그래서 15 ÷ 3초 = 5 req/s가 나왔습니다. 실제로 일하는 것은 두 번째 줄이므로 10 ÷ 3초 = 3.33 req/s가 맞고, 실측 3.29와 일치합니다.
메모리 관측도 같은 방향을 가리킵니다. 그 측정은 가상 사용자 12명 조건이었는데, 러너가 쓴 3.1GiB를 요청당 할당량 300MiB로 나누면 약 10입니다. 12건이 전부 실행됐다면 3.6GiB가 나왔어야 하므로, 러너 쪽에 그보다 작은 한도가 있었다는 뜻입니다.
worker가 job을 받았다는 것과 그 job이 실행되고 있다는 것은 다른 말입니다. 대시보드는 앞의 것을 보여주고, 처리량을 정하는 것은 뒤의 것입니다.
한계 지점을 관측한다: 동시 실행 수를 크게 올리고
앞 절에서 처리량을 정하는 것이 러너 한도라는 사실을 확인했습니다. 그렇다면 worker 동시 실행 수를 올리면 어떻게 될까요. 정상 상태 측정에서는 15로 고정했는데, 이 값을 크게 올려 한계 지점까지 부하를 높였습니다.
실험 조건
이번에는 실제 운영 워크플로에 가까운 구성을 사용했습니다.
| 항목 | 값 |
|---|---|
| 워크플로 구성 | 외부 API 응답 대기(약 24초) + 파싱(약 1.5초) |
| worker 동시 실행 수 | 120 |
| 부하 | 15에서 120까지 계단식 상승 |
두 단계의 실행 주체가 다릅니다. 외부 API를 기다리는 동안에는 worker가 실행 슬롯을 점유하고, 파싱은 러너가 실행합니다. 앞 절에서 정리한 두 부하 축이 한 워크플로에 함께 들어간 셈입니다.

부하 120에서 시스템이 무너졌다
k6 실행 결과는 다음과 같습니다. 계단 스윕 전체의 집계값입니다.
| 항목 | 값 |
|---|---|
| 완료 | 611건 |
| 실패율 | 20.5% |
| p50 | 78초 |
| p95 | 88초 |
| 최대 | 95초 |
단건 처리시간이 약 24초인데 p95가 88초입니다. 3.7배까지 늘어난 값입니다. p50이 78초라는 것도 함께 봐야 합니다. 요청의 절반이 78초를 넘겼다는 뜻입니다. 또 요청 5건 중 1건이 에러로 실패했습니다.
그런데 자원은 어디도 포화가 아니었다
무너진 원인을 찾으려고 서버 자원을 확인했습니다. 결과는 예상과 달랐습니다.
| 자원 | 부하 120에서 관측값 | 판정 |
|---|---|---|
| worker 이벤트 루프 지연 | 13밀리초 | 여유 |
| worker 힙 사용률 | 6.2% | 여유 |
| worker 실제 점유 메모리 | 약 450MB | 여유 |
| 호스트 CPU | 5.6% | 여유 |
| 호스트 메모리 | 58.7% | 여유 |
| 데이터베이스 캐시 적중률 | 70% | 판단 보류 |
슬롯 점유율만 100%이고, 나머지 자원은 한계에 가까운 항목이 없습니다.
이벤트 루프 지연 13밀리초는 worker가 요청을 처리하느라 막혀 있지 않다는 뜻입니다. Node.js는 단일 스레드로 동작하므로, worker가 계산으로 바빴다면 이 값이 크게 늘어납니다. 힙과 메모리도 상한에서 멀고, 호스트 CPU는 5.6%입니다.
슬롯은 전부 찼는데 처리할 여력은 남아 있는 상태입니다. 이 표만 보고 "자원을 늘려야 하나"를 판단하면 답은 언제나 "아니오"입니다. 그런데 실제로는 요청 5건 중 1건이 실패하고 있었습니다.
PostgreSQL 기준으로 70%는 낮은 값입니다. 일반적으로 90% 이상을 정상으로 보고, 80% 아래로 지속되면 구성 점검 대상입니다. 다만 캐시가 워밍업되기 전 값일 수 있습니다. 컨테이너를 재시작한 직후에는 적중률이 낮게 나옵니다. 그래서 이 항목은 판단을 보류했습니다. 실제 운영 환경에서 동시성을 크게 올릴 때는 이 지표를 따로 확인해야 합니다.
천장은 worker가 아니라 러너에 있었다
앞 절에서 확인한 구분이 여기서 다시 나타납니다. worker가 job을 받았다는 것과 그 job이 실행되고 있다는 것은 다른 말입니다. 정상 상태에서는 15건 중 10건만 실행되고 있었는데, 부하 120에서는 그 격차가 훨씬 커집니다.
worker는 120건을 붙잡고 있었지만, 그중 대부분은 외부 API 응답을 기다리는 중이었습니다. 기다리는 동안 worker는 슬롯만 점유하고 CPU는 쓰지 않습니다. 이벤트 루프 지연 13밀리초와 호스트 CPU 5.6%가 그 상태를 보여줍니다.
실제로 처리 능력을 소모하는 것은 파싱이고, 파싱을 실행하는 것은 러너입니다. worker가 120건을 받아도 러너가 동시에 처리할 수 있는 수는 그보다 훨씬 적습니다. 단건 처리시간이 약 25초인데 p95가 88초로 늘어난 것이 그 대기를 보여줍니다. 슬롯에 들어간 요청 대부분이 파싱 차례를 기다리며 시간을 보낸 것입니다.
부하를 올려도 처리량이 늘지 않는 지점, 즉 천장은 worker의 자원이 아니라 러너 쪽에 있는 것으로 보입니다.
병목을 격리한다: 러너 한도만 바꿔 본다
앞서 천장이 러너 쪽에 있는 것으로 보인다고 했습니다. 다만 그것은 정황 증거입니다. 자원이 남는데 처리량이 멈췄으니 러너가 원인일 것이라고 추론했을 뿐, 러너가 원인임을 직접 확인한 것은 아닙니다.
확실히 하려면 다른 조건은 모두 고정하고 러너 한도만 바꿔 처리량이 따라 움직이는지 확인해야 합니다.
실험 조건
| 항목 | 값 |
|---|---|
| 워크플로 | 파싱만 남긴 단순 워크플로(처리시간 3초) |
| 부하 | 가상 사용자 20명, 3분 |
| worker 동시 실행 수 | 15 (고정) |
러너 한도 N8N_RUNNERS_MAX_CONCURRENCY | 20, 10, 5, 2 (변경) |
각 조건을 실행하기 전에 러너 컨테이너의 환경변수를 확인해 설정이 실제로 적용됐는지 검증했습니다.
docker exec n8n-runner-worker-1 env | grep MAX_CONCURRENCY

러너 한도가 처리량을 결정한다
| 러너 한도 | 처리량 | p95 |
|---|---|---|
| 2 | 0.66 req/s | 30.2s |
| 5 | 1.66 req/s | 12.1s |
| 10 | 3.30 req/s | 6.1s |
| 20 | 4.80 req/s | 5.6s |
각 조건을 한 번씩만 측정했으므로 값의 오차 범위는 알 수 없습니다. 다만 변화의 방향과 크기는 분명합니다.
러너 한도만 바꿨는데 처리량이 따라 바뀌었습니다. 다른 조건은 모두 같았으므로, 러너 한도가 처리량을 결정한다고 볼 수 있습니다. 한도 2와 한도 20 사이에서 처리량은 7.2배 차이가 납니다.
계산과 대조하면 규칙이 더 분명해집니다. 처리시간이 3초이므로 이론상 처리량은 한도 ÷ 3초입니다.
| 러너 한도 | 계산값 | 실측 |
|---|---|---|
| 2 | 0.67 req/s | 0.66 |
| 5 | 1.67 req/s | 1.66 |
| 10 | 3.33 req/s | 3.30 |
| 20 | 6.67 req/s | 4.80 |
앞의 세 조건은 계산값과 실측이 소수점까지 맞습니다. 그런데 한도 20에서만 실측이 계산값보다 훨씬 낮습니다.
이유는 worker 동시 실행 수입니다. 한도를 20으로 올려도 worker가 동시에 붙잡을 수 있는 job은 15건이므로, 러너에 들어오는 태스크도 15건이 상한입니다. 15 ÷ 3초 = 5 req/s가 되고, 실측 4.80이 여기에 맞습니다.
처리량을 정하는 것은 러너 한도와 worker 동시 실행 수 중 작은 쪽입니다. 한도 2, 5, 10에서는 러너가 작은 쪽이었고, 한도 20에서는 worker가 작은 쪽이 됐습니다. 뒤에서 운영값을 계산할 때 쓸 규칙이 여기서 이미 관측됩니다.
설정할 때 주의할 점
러너 한도는 러너 프로세스 하나당 값입니다. 러너를 여러 개 띄우면 전체 처리 용량은 그만큼 늘어납니다.
대시보드는 이 차이를 보여주지 못한다
환경변수는 러너 컨테이너에 설정해야 합니다. 러너를 별도 컨테이너로 실행하는 구성에서는 러너 프로세스가 자기 컨테이너의 환경변수만 참조합니다. n8n main이나 worker 컨테이너에 N8N_RUNNERS_MAX_CONCURRENCY를 설정해도 러너는 그 값을 읽지 않습니다. 값을 바꿨는데 처리량이 달라지지 않는다면, 환경변수를 어느 컨테이너에 설정했는지부터 확인해야 합니다.
기본값은 버전에 따라 다를 수 있습니다. 현재 n8n 공식 문서에는 이 값의 기본값이 5로 적혀 있습니다. 다만 이 측정 환경에서는 10으로 동작했습니다. 위 확인 명령으로 실행 중인 러너의 값을 직접 확인하는 편이 안전합니다.
처리량이 7.2배 차이 나는 동안 서버 쪽 지표는 어떻게 나왔을까요.

큐 지표는 러너 한도가 달라져도 같은 값을 표시합니다. 조건이 무엇이든 처리 중 15건, 큐 대기 5건, 슬롯 점유율 100%입니다.
이유는 각 지표가 무엇을 세는지에 있습니다. 처리 중인 요청이 가상 사용자 수인 20건으로 고정돼 있고, worker는 러너 한도와 무관하게 자기 동시 실행 수인 15건까지 job을 가져옵니다. 러너 한도가 2든 20이든 worker가 붙잡는 수는 같습니다.
한도가 2일 때 그 15건 중 실제로 러너에서 실행되는 것은 2건이고, 나머지 13건은 worker 안에서 러너에 배정되기를 기다립니다. 그런데 큐 지표는 이 둘을 구분하지 않고 worker가 붙잡은 15건을 모두 "처리 중"으로 계산합니다.
| 러너 한도 | worker가 붙잡은 job | 러너에서 실행 중인 태스크 | worker 안에서 대기 중인 job |
|---|---|---|---|
| 2 | 15 | 2 | 13 |
| 20 | 15 | 15 | 0 |
한도 20에서 실행 중이 20이 아니라 15인 것도 앞에서 본 이유와 같습니다. worker가 15건까지만 붙잡으므로 러너에 들어오는 태스크도 15건이 최대입니다.
대시보드가 보여주는 것은 첫 번째 열뿐입니다. 두 번째 열, 즉 러너에서 실제로 실행 중인 태스크 수는 어느 패널에도 나타나지 않습니다.
n8n은 이 지표를 제공하지 않는다
대시보드 구성이 잘못됐을 가능성을 확인하려고 n8n 소스 코드를 확인했습니다. 대시보드 문제가 아니었습니다.
- task runner는 성능 지표를 제공하지 않습니다. 러너가 여는 HTTP 서버에는 프로세스가 정상 동작 중인지 확인하는 health check 엔드포인트만 있고, 지표 수집 라이브러리 자체가 포함돼 있지 않습니다.
- n8n main과 worker가 제공하는 지표 목록에도 러너 관련 항목이 없습니다. 큐 지표를 활성화하면 Redis 큐의 job 수(대기, 처리 중, 완료, 실패)를 얻을 수 있는데, 이는 worker가 붙잡은 수이지 러너에서 실행 중인 수가 아닙니다.
- 러너는 자기 한도와 실행 중인 태스크 수를 프로세스 내부에서만 관리하며, 외부로 노출하는 경로가 없습니다.
즉 worker가 붙잡은 job과 러너에서 실행 중인 태스크를 구분하는 지표는 대시보드에 누락된 것이 아니라 n8n이 제공하지 않는 것입니다.
n8n이 공식적으로 제공하는 러너 포화 신호는 하나뿐입니다. 정해진 시간(기본 60초) 안에 태스크를 러너에 배정하지 못하면 실행이 타임아웃 에러로 실패합니다. 하지만 이는 요청이 이미 실패한 뒤에 오는 신호입니다.
새 지표 없이 대시보드를 보완하는 방법
n8n이 지표를 제공하지 않더라도 기존 값과 설정값만으로 판단을 도울 수 있습니다. 두 가지입니다.
러너 한도를 화면에 표시합니다. 설정값을 고정 숫자 패널로 만들어 worker 동시 실행 수 옆에 배치하면, 두 값의 차이가 시각적으로 드러납니다. 한도 2와 동시 실행 수 15가 나란히 보이면 어느 쪽이 처리량을 제한하는지 바로 알 수 있습니다.
이론상 최대 처리량을 처리량 패널에 함께 표시합니다. 작은 쪽(러너 한도, worker 동시 실행 수) ÷ 단건 처리시간으로 계산한 값을 기준선으로 겹쳐 두면, 실측 처리량이 그 기준선에 근접했는지로 현재 어느 쪽이 상한인지 판단할 수 있습니다.
두 방법 모두 n8n이 새 지표를 제공하지 않아도 구현할 수 있습니다. 이미 있는 값과 사람이 아는 설정값만 쓰기 때문입니다.
측정값을 운영 설정값으로 환산한다
지금까지 측정으로 두 가지가 확인됐습니다. worker 동시 실행 수를 120까지 올리면 worker는 요청을 모두 받지만 파싱이 따라가지 못해 시스템이 무너지고, 처리량을 결정하는 것은 러너 한도라는 점입니다. 그렇다면 운영 환경에서는 이 값들을 얼마로 설정해야 할까요.
무너진 지점을 그대로 쓸 수 없는 이유
부하 120에서 시스템이 무너졌다고 해서 119를 상한으로 삼을 수는 없습니다. 두 가지 이유가 있습니다.
측정 환경의 한계가 섞여 있습니다. 외부 API 대기를 단일 목업 서버로 재현했는데, 120개 동시 연결에서는 그 목업 서버 자체가 한계에 닿았을 수 있습니다. 실제 외부 API는 동시 요청을 더 잘 처리하므로, 실제 환경에서는 더 높은 지점까지 견딜 여지가 있습니다.
계단 스윕은 구간을 합산한 값입니다. 15에서 120까지 여러 구간을 한 번에 측정했으므로, 정확히 어느 지점에서 처리량이 꺾이는지는 이 측정만으로 알 수 없습니다.
안전 계수를 적용한다
따라서 무너진 지점의 2/3를 운영 시작값으로 잡습니다. 한계에 붙여 쓰지 않고 여유를 두는 안전 계수입니다. 동시 실행 수 120에서 무너지는 것을 관측했으므로 운영값은 80이 됩니다.
worker 동시 실행 수만 올려서는 안 됩니다. 처리량을 결정하는 것은 러너 한도이므로, worker 동시 실행 수를 올릴 때 러너 한도도 함께 올려야 효과가 있습니다. 그러지 않으면 파싱 단계에서 다시 막힙니다.
계산에 쓸 공식
측정값을 설정값으로 옮기는 데 필요한 식은 셋입니다.
처리량 한계 = 실제 처리 수 ÷ 단건 처리시간
평균 응답시간 = (처리 중 요청 ÷ 실제 처리 수) × 단건 처리시간
큐 대기 = 처리 중 요청 − worker 동시 실행 수
여기서 실제 처리 수는 러너 한도와 worker 동시 실행 수 중 작은 쪽입니다. 앞의 두 식이 이 값을 씁니다.
세 번째 식만 worker 동시 실행 수를 그대로 씁니다. Redis 큐에서 job을 가져오는 주체가 worker이기 때문입니다. 러너 한도가 얼마든 worker는 자기 동시 실행 수만큼 job을 가져오고, 큐에 남는 것은 그 차이입니다.
정상 상태 측정에서 계산이 34% 어긋난 것도 이 구분 때문이었습니다. 앞의 두 식에 worker 동시 실행 수 15를 넣었는데, 실제로 일하는 것은 러너 한도 10이었습니다. 10을 넣으면 계산과 실측이 맞습니다.
처리시간이 일정할 때만 평균과 p95가 비슷합니다. 처리시간이 일정하지 않은 실제 워크플로에서는 이 식이 p95를 실제보다 낮게 산출합니다. p95 기준을 정할 때는 이 식의 결과에 여유를 더해 잡아야 합니다.
환산 예시
위 식으로 운영 설정값을 계산하면 다음과 같습니다. 실제 값은 워크플로와 환경에 따라 달라서 예시로 읽어 주세요.
| 항목 | 값 |
|---|---|
| 단건 처리시간 | 24초 |
| worker 동시 실행 수 | 80 (무너진 지점 120의 2/3) |
| 러너 한도 | worker 동시 실행 수에 맞춰 함께 상향 |
| 처리량 한계 | 작은 쪽 ÷ 24초 |
두 값을 함께 올려야 처리량이 따라옵니다. worker 동시 실행 수만 80으로 올리고 러너 한도를 그대로 두면, 처리량 한계는 러너 한도에 묶입니다. 예를 들어 러너 한도가 10이면 worker를 80으로 올려도 처리량은 10 ÷ 24초, 즉 분당 25건에 머뭅니다.
확장할 때 지켜야 할 것
러너 한도를 함께 올리는 방법은 두 가지입니다. 러너 하나의 한도를 올리거나, 러너를 여러 개 띄워 전체 용량을 늘리는 것입니다. 어느 쪽이든 실제 처리 수가 worker 동시 실행 수에 근접해야 worker를 올린 효과가 처리량으로 나타납니다.
이 값은 시작값입니다
여기서 계산한 값은 확정된 설정값이 아니라 운영을 시작할 때의 기준값입니다.
2/3라는 안전 계수는 여유가 크지 않고, 합성 부하로 측정한 값이라 실제 워크로드로 검증해야 확정할 수 있습니다. 특히 대용량 첨부 파일이 데이터베이스에 주는 부하는 이번 측정에 포함되지 않았습니다. 운영 중 이상 징후가 보이면 60, 40으로 단계적으로 낮춰 가며 확인해야 합니다.
지금까지 계산한 값으로 시작한 뒤, 처리량이 더 필요해지면 확장을 검토합니다. 먼저 동시성 설정을 올리고, 그것으로 부족하면 서버를 늘립니다. 각 단계에 지켜야 할 규칙이 있습니다.
동시성은 두 값을 함께 올린다
worker 동시 실행 수와 러너 한도 중 하나만 올리면 다른 쪽이 새로운 제한 요인이 될 뿐입니다. 앞의 러너 한도 실험이 이를 보여줍니다. worker 동시 실행 수를 15로 고정한 채 러너 한도만 20까지 올렸을 때, 처리량은 계산값 6.67 req/s가 아니라 4.80 req/s에서 멈췄습니다. worker가 job을 15건까지만 가져오므로, 러너 한도를 올려도 러너에 들어오는 태스크가 그만큼 늘지 않기 때문입니다.
서버를 늘릴 때는 worker와 러너를 짝으로 늘린다
external 모드에서 task broker는 각 n8n 프로세스 안에 있고, 러너는 그 broker에 연결됩니다. worker 하나에 러너가 최소 하나 연결돼 있어야 그 worker의 Code 노드 태스크가 실행됩니다.
worker 컨테이너의 복제 수만 늘리면, 러너가 연결되지 않은 worker가 생깁니다. 그 worker로 배정된 Code 노드 태스크는 실행할 러너가 없어 대기하다가 지정된 시간(N8N_RUNNERS_TASK_REQUEST_TIMEOUT, 기본 60초)이 지나면 타임아웃 에러로 실패합니다. 실패까지 60초가 걸리므로 즉시 드러나지 않고, 그만큼 발견이 늦어집니다.
n8n 공식 문서의 확장 예시도 worker 복제본 3개에 러너 복제본 6개를 배치합니다. worker와 러너는 항상 짝으로 늘려야 합니다.
메모리 상한은 러너에 건다
앞에서 확인했듯 Code 노드가 할당하는 메모리는 러너에 쌓입니다. worker에만 상한을 걸고 러너를 무제한으로 두면, 메모리를 실제로 소비하는 쪽에는 제한이 없습니다. 러너가 호스트 메모리를 계속 소비하다가 시스템 전체가 영향을 받을 수 있습니다.
러너에 상한을 걸면 한도를 넘는 순간 러너만 종료되고, worker와 호스트는 영향을 받지 않습니다. 러너는 런처가 다시 시작합니다.
합성 부하가 측정하지 못하는 것
이번 측정으로 확인한 것과 함께, 확인하지 못한 것도 정리해 둡니다. 부하테스트 결과를 전할 때는 무엇을 측정하지 않았는지가 측정한 값만큼 중요합니다.
- 대용량 첨부 파일이 데이터베이스에 주는 부하. 이 스택은 첨부 파일을 데이터베이스에 저장하는데 합성 부하는 작은 버퍼만 할당할 뿐 대용량 데이터의 저장과 조회를 발생시키지 않습니다. 동시성을 크게 올리면 데이터베이스가 새로운 병목이 될 수 있습니다.
- CPU를 많이 쓰는 파싱. 이번 부하는 대기로만 처리시간을 만들었기 때문에 러너가 CPU를 거의 사용하지 않습니다. 실제 Code 노드가 무거운 변환으로 CPU를 많이 쓴다면 코어 수가 새로운 상한이 되고, 러너 한도를 올려도 처리량이 그만큼 증가하지 않습니다.
- 처리량이 정확히 어느 동시성에서 꺾이는지. 계단 스윕으로 구간을 합산해 측정한 값이라, 정확한 지점은 구간별로 따로 측정해야 알 수 있습니다.
- 이번에 측정하지 않은 나머지 지표. 러너의 CPU와 응답성, worker와 러너 사이의 통신, 데이터베이스 커넥션, 디스크 입출력은 이번에 확인하지 않았습니다. 이 글의 결론이 "관측하지 않던 곳에 병목이 있었다"인 만큼 위 항목들에도 같은 상황이 있을 수 있습니다.
같은 실험을 해 보려면
스크립트 외에 다음 조건이 갖춰져야 합니다.
- task runner를 external 모드로 실행(
N8N_RUNNERS_MODE=external). 이 설정을 켜지 않으면 Code 노드가 worker 프로세스 안에서 실행되므로, 이 글의 내용이 성립하지 않습니다. - worker 동시 실행 수를 명시적으로 지정(
--concurrency). 다만 환경변수N8N_CONCURRENCY_PRODUCTION_LIMIT이 -1 이외의 값으로 설정돼 있으면 그 값이 우선하므로, 두 곳을 함께 확인해야 합니다. - 완료 후 응답 방식의 워크플로. 즉시 응답 방식을 쓰면 이 글의 공식이 모두 무효가 됩니다.
- n8n 지표와 큐 지표 활성화(
N8N_METRICS=true,N8N_METRICS_INCLUDE_QUEUE_METRICS=true). 활성화하지 않으면 서버 쪽 지표를 수집할 수 없습니다. - 러너 이미지와 n8n 이미지의 버전 일치. 버전이 다르면 러너가 정상 동작하지 않습니다.
맺음말
부하테스트는 스크립트를 실행하는 일이 아니라 판단의 순서를 지키는 일에 가깝습니다. 이 글에서 지킨 순서는 다음과 같습니다.
- 부하 모델을 먼저 정합니다. 완료 후 응답이냐 즉시 응답이냐에 따라 측정 대상이 처리 능력에서 접수 능력으로 바뀝니다
- 부하 축을 고릅니다. 시간을 끄는 작업을 어느 노드에 두느냐가 어느 컨테이너를 측정할지 정합니다
- 정상 상태를 먼저 측정합니다. 낮은 부하에서 얻은 기준값이 있어야 이후 숫자를 해석할 수 있습니다
- 검증 통과율부터 읽습니다. 실패한 요청은 지연 지표를 좋게 만듭니다
- 지표가 무엇을 세는지 확인합니다. 이 글의 34% 간극도, 부하 120에서의 붕괴도 모두 여기서 비롯됐습니다
이 순서를 지키면 측정값을 운영 설정값으로 옮길 수 있습니다. 처리량을 정하는 것이 러너 한도와 worker 동시 실행 수 중 작은 쪽이라는 사실도, 두 값을 함께 올려야 한다는 규칙도 이 과정에서 나왔습니다.
마지막 원칙이 이번에 가장 크게 배운 것입니다. 슬롯 점유율 100%라는 신호는 시스템이 정상일 때도, 무너지기 직전에도 똑같이 나타납니다. 그 신호만 보고 서버를 늘리면 설정 한 줄로 해결될 일에 비용을 쓰게 되고, worker와 러너의 짝을 맞추지 않으면 늘린 서버마저 조용히 실패합니다.
대시보드가 평온한데 시스템이 느리다면, 대시보드가 측정하지 않는 곳을 의심해야 합니다.
대시보드는 평온한데 시스템은 느립니다
슬롯 점유율 100%는 정상일 때도 무너질 때도 똑같이 나타납니다. 인포그랩은 자동화 스택의 실제 병목을 측정하고 운영 설정값까지 도출합니다.
참고 자료
- "Load testing", Grafana k6 Documentation, https://grafana.com/docs/k6/latest/
- "Enable queue mode", n8n Docs, https://docs.n8n.io/deploy/host-n8n/configure-n8n/scaling/enable-queue-mode
- "Set up task runners", n8n Docs, https://docs.n8n.io/deploy/host-n8n/configure-n8n/set-up-task-runners
- "Task runner environment variables", n8n Docs, https://docs.n8n.io/deploy/host-n8n/configure-n8n/basic-configuration/use-environment-variables/task-runners
- "Control concurrency", n8n Docs, https://docs.n8n.io/deploy/host-n8n/configure-n8n/scaling/control-concurrency
- "Enable Prometheus metrics", n8n Docs, https://docs.n8n.io/deploy/host-n8n/configure-n8n/basic-configuration/configuration-examples/enable-prometheus-metrics
- "Thresholds", Grafana k6 Documentation, https://grafana.com/docs/k6/latest/using-k6/thresholds/
- "Checks", Grafana k6 Documentation, https://grafana.com/docs/k6/latest/using-k6/checks/
Chad
DevOps Engineer
InfoGrab의 DevOps Engineer로서, 인프라 운영부터 AI 에이전트 자동화까지 폭넓게 다룹니다. Kubernetes·Teleport·GitLab CI/CD 기반 인프라 설계와 운영을 담당하며, Claude Code 스킬·MCP 서버 등 AI Native 방식의 업무 자동화를 적극 실험하고 있습니다. 반복 작업을 에이전트에게 위임하는 구조를 만드는 데 관심이 많고, 클러스터에서 직접 인프라를 구성하고 검증하는 것을 즐깁니다. 만든 것을 도구화하여 팀에 공유하는 스타일로 일합니다.
이 저자의 글 모두 보기 →이 글이 도움이 되셨나요?
인포그랩 전문가가 맞춤 상담을 도와드립니다.
관련 글

n8n 워크플로 관리: 워크플로는 남는데 소유권은 왜 사라질까
n8n 워크플로가 100개를 넘으면 누가 만들었고 무엇에 물려 있는지부터 흐려집니다. n8n 기본 기능으로 소유권·구조를 어디까지 볼 수 있는지, 그 빈자리를 인포그랩 Nelper는 어떻게 채우는지 정리했습니다.

LLM·하네스로 더 좋은 n8n 워크플로 생성하기
LLM에 정확한 정보와 도구를 제대로 쥐여 주면 더 높은 품질의 n8n 워크플로를 생성할 수 있습니다. 이 글은 LLM의 작업 환경 전체를 설계하는 접근 방식인 '하네스(Harness)'를 만들어 실험한 내용을 다뤘습니다. 같은 모델과 같은 요청을 두고 하네스 수준만 바꿨을 때 모델이 생성한 n8n 워크플로 품질이 어떻게 달라지는지 소개합니다.

n8n 기반 DevOps·AI 콘텐츠 자동 수집·요약 실전 가이드
백엔드 엔지니어 Andy는 n8n으로 기술 콘텐츠 자동 수집·요약 시스템을 구축했습니다. 이 시스템은 주 3회 40여 개의 채널을 자동으로 모니터링하고, DevOps·AI·자동화 콘텐츠를 선별해 한국어로 요약합니다. 이 글은 콘텐츠 자동 수집·요약 시스템의 아키텍처와 핵심 구현 방법, 개발 과정에서 마주한 문제점과 해결 방안을 다뤘습니다.