LabHub
배우기 러닝패스 코스

요청 하나가 아니라 전부가 느려졌다 · 한 줄에 선 일감 · 이론

전부가 같은 시각에 느려졌다면 줄이 하나다

LabHub 에서 이어서 보기

한 줄 요약

Node 에서 자바스크립트는 줄 하나에서 돈다. 그래서 "이 API 가 느리다" 와 "전부
느리다" 는 대개 다른 사건이 아니라 같은 사건의 두 얼굴이고, 그 줄이 얼마나 오래
멈춰 있었는지는 프로파일러 없이도 잴 수 있다.

왜 이게 필요했나

신고는 늘 한 곳을 가리킨다. "결제 확인 API 가 가끔 3초씩 걸립니다." 그 API 의
코드를 열어 쿼리를 보고 인덱스를 보고 외부 호출을 본다. 아무것도 이상하지 않다.
그럴 때 해야 할 일은 그 API 를 더 파는 것이 아니라 **다른 API 의 지연도 같이
올라갔는지** 보는 것이다.

같이 올라갔다면 범인은 그 API 가 아니다. Node 프로세스 안에서 자바스크립트를
실행하는 줄은 하나뿐이고, 그 줄은 지금 실행 중인 콜백이 스스로 돌려줄 때까지
아무에게도 넘어가지 않는다. 운영체제가 시간을 쪼개 뺏어 가는 선점형 스케줄링이
아니라, 각자가 알아서 양보하는 협력형이다. 그래서 어느 콜백 하나가 300ms
동안 CPU 를 붙잡으면 그 사이에 도착한 요청은 전부 300ms 를 기다린다. 그 요청들의
코드에는 문제가 없다. 줄에 서 있었을 뿐이다.

이 구조는 결함이 아니라 선택이다. 스레드를 요청마다 띄우지 않기 때문에 연결
수만 개를 적은 메모리로 감당할 수 있다. 대신 **한 번에 오래 걸리는 일을 하면 안
된다**는 규칙이 따라붙는다. 규칙을 지키고 있는지 확인하려면 재야 하고, 그래서
이 코스의 첫 도구는 프로파일러가 아니라 자다.

어떻게 동작하나

이벤트 루프는 한 바퀴를 여러 단계로 나눠 돈다. 타이머(setTimeout·setInterval)
가 만료되었는지 보는 단계, 완료된 입출력 콜백을 부르는 단계, 새 이벤트를 기다리는
폴 단계, setImmediate 를 부르는 체크 단계 순이고, 자세한 순서는 공식 안내서
[이벤트 루프, 타이머, process.nextTick()](https://nodejs.org/en/learn/asynchronous-work/event-loop-timers-and-nexttick)
에 정리되어 있다. 중요한 것은 단계의 이름이 아니라 **단계 사이로 넘어가려면 지금
콜백이 끝나야 한다**는 사실이다.

재는 방법은 여기서 나온다. 20ms 마다 울리게 해 둔 타이머가 실제로는 320ms 만에
울렸다면, 그 사이 300ms 동안 루프는 다른 일에 붙잡혀 있었던 것이다. 공식 문서도
같은 근거를 적는다 — "타이머의 실행은 libuv 이벤트 루프의 수명에 묶여 있으므로,
루프의 지연은 곧 타이머의 지연으로 나타난다"
([perf_hooks](https://nodejs.org/docs/latest-v22.x/api/perf_hooks.html)).

let last = Date.now();setInterval(() => {  const now = Date.now();  const lagMs = Math.max(0, now - last - 20);   // 간격을 뺀 나머지가 지연이다  last = now;}, 20);

Node 는 같은 일을 하는 내장 도구도 준다. perf_hooks.monitorEventLoopDelay()
히스토그램을 만들어 주는데, 두 가지를 알고 써야 한다. 값의 단위가 나노초이고,
표본 간격(resolution)의 기본값이 10ms 라는 것이다. 그래서 아무 일도 없는
프로세스에서도 이 히스토그램의 중앙값은 0 이 아니라 10ms 근처로 나온다 — 실습
이미지의 Node 22.11.0 에서 재 보면 조용한 상태의 p50 이 10.5ms 였다. 이 값을
"우리 서비스는 늘 10ms 밀려 있다" 로 읽으면 없는 문제를 쫓게 된다. 자를 직접
만들어 보면 그 바닥이 어디서 오는지가 손에 잡힌다.

현장에서 만나는 모습

이 사고의 가장 고약한 성질은 평균이 거의 움직이지 않는다는 것이다. 1.2초
동안 20ms 간격으로 잰 표본 44개 가운데 100ms 를 넘긴 것은 단 하나였고, 평균은
7.7ms 였다(Node 22.11.0, 2코어 기준 실측). 평균 지연을 그리는 대시보드는 이
사고가 나는 동안에도 평평하다. 움직이는 것은 최댓값과 p99 뿐이다.

두 번째 성질은 피해자가 여럿이라는 것이다. 같은 조건에서 요청 10건을
한꺼번에 보내고 그중 세 번째만 300ms 동안 CPU 를 붙잡게 했더니, 자기 자신을 뺀
7건이 함께 300ms 넘게 걸렸다. 로그에는 느린 요청이 8건 찍히고, 그중 진짜 원인은
1건이다. 나머지 7건의 코드를 아무리 들여다봐도 답이 없다.

그래서 Node 서비스의 장애 조사는 "느린 엔드포인트를 찾는다" 가 아니라 "**같은
시각에 함께 느려진 것이 무엇인지** 본다" 로 시작해야 한다. 함께 느려졌다면 줄이
하나라는 뜻이고, 그다음 질문은 "그 줄을 누가 붙잡았나" 하나뿐이다.

다음 실습에서 할 것

지연 분포를 재는 자를 직접 만듭니다. 백분위를 세는 규칙부터 못 박고, 타이머가
늦은 만큼을 표본으로 모으고, 동기 작업을 한 번 끼워 넣어 꼬리만 움직이는 것을
확인합니다. 그다음 요청 열 건을 한꺼번에 보내 한 건의 멈춤이 몇 건을 늦추는지
세어 보고, 마지막으로 그 숫자를 "예산을 몇 번 넘겼는가" 라는 판단으로 바꿉니다.

채점기는 여러분이 적은 p50·p99·max 를 표본에서 다시 계산해 대조하고,
여러분이 만든 함수를 한 번 더 돌려 같은 성질이 나오는지 봅니다. 숫자만 그럴듯하게
적으면 통과하지 못합니다.