서버 개발자에게 있어서 모니터링은 마치 생명줄과도 같다. 그도 그럴 것이 서버는 언제든 문제가 발생할 수 있는 시한폭탄 같은 존재기 때문이다. 모니터링과 함께라면 장애가 발생하더라도 빠르게 원인을 좁힐 수 있고 장애가 발생하기 전에 미리 파악하는 것도 가능하다.

하지만 안타깝게도 모니터링 대시보드를 설치하고 만드는 것에 대한 가이드는 많지만 그 대시보드를 실제로 읽고 문제를 진단하는 방법에 대한 가이드는 많지 않다. 따라서 모니터링에 대해 막연히 두려움을 느끼는 개발자가 많다. 이 글은 그런 두려움을 극복하기 위한 시작점을 목표로 모니터링을 어떻게 읽고 행동하면 좋을지에 대해 정리한 글이다.

무엇이 중요한가?

잘 만든 대시보드일수록 그래프가 많다. 대강 CPU, 메모리, 초당 요청 수, 응답 시간, 스레드 풀 사용률 등 필요한 지표를 붙이다 보면 한 페이지에서 볼 수 없을 정도로 많아진다. 그런데 이 수많은 그래프는 사실 네 개의 카테고리로 묶을 수 있다. 구글의 SRE 조직은 이것을 네 가지 황금 신호라고 부른다.1

  • 트래픽(Traffic): 얼마나 들어오고 있는가?
  • 지연 시간(Latency): 얼마나 걸리는가?
  • 에러(Errors): 얼마나 실패하는가?
  • 포화도(Saturation): 얼마나 차 있는가?

트래픽은 서버가 얼마나 바쁜지를 나타내며 주로 초당 요청 수나 트랜잭션 수 등으로 표현된다. 지연 시간은 요청이 들어와서 응답이 나가기까지 걸리는 시간이다. 에러는 서버가 요청을 처리하지 못하고 실패한 비율을 나타낸다. 포화도는 서버의 자원이 얼마나 사용되고 있는지를 나타내며 CPU, 메모리, 스레드 풀, 커넥션 풀 등의 지표로 표현된다.

어떤 지표든 결국 이 넷 중 하나에 속하고 대시보드가 아무리 복잡해도 장애 상황에선 이 네 가지 관점에서 살펴보면 된다. 여기에 관점 하나를 더하면 그래프 읽는 방법의 기초를 세웠다고 볼 수 있다. 지표는 증상을 파악할 수 있는 지표와 원인을 파악할 수 있는 지표로 나뉜다. 여기서 지연 시간과 에러율은 증상이다. 이는 사용자가 실제로 겪은 문제이며 늘어나면 무조건 문제다. 반면 CPU, 메모리, 스레드 풀 같은 리소스 지표는 원인 쪽이다. CPU가 90%여도 응답이 빠르면 당장 심각한 문제는 아니고 CPU가 20%여도 응답이 느리면 문제가 된다. 물론 지속되는 90%는 여유가 없다는 신호이므로 주의할 필요는 있지만 새벽에 사람을 깨울 일은 아니라는 뜻이다.

그래서 지표는 언제나 증상을 먼저 파악하고 원인으로 범인을 좁혀야 한다. 알람도 원칙적으로 증상에 걸어야 한다. 순간적인 “CPU 80% 초과” 알람은 새벽에 사람을 깨워놓고 아무 일도 아닌 경우가 많지만 “에러율 초과” 알람은 울렸다면 누군가 실제로 문제를 겪고 있다는 뜻이다.

이 글의 구성도 이 순서를 그대로 따른다. 먼저 증상 쪽인 트래픽, 지연 시간, 에러 그래프의 평소 모양과 이상 패턴을 읽는 법을 익히고 다음으로 원인 쪽인 CPU, 메모리, 풀로 범인을 좁히는 법을 익힌다. 그다음은 병목, 배압, 캐시, 타임아웃처럼 그래프 하나가 아니라 여러 개를 겹쳐 읽어야 보이는 문제들이다. 마지막에는 이렇게 익힌 것들을 언제 어떻게 쓰는지를 다룰 것이다.

사용자의 문제를 읽는 법

지연과 에러는 사용자가 실제로 느끼는 문제이므로 모든 분석의 출발점이다. 트래픽은 엄밀히 말하면 증상이 아니라 맥락이지만 지연과 에러를 읽기 위한 기본적인 수단이므로 가장 먼저 다룬다.

트래픽, 얼마나 들어오고 있는가

트래픽 그래프에서 첫 번째로 익혀야 할 것은 이상 패턴이 아니라 평소 모양이다. 서비스의 트래픽은 공격이나 이벤트 등의 특별한 일이 없다면 거의 비슷한 모양을 그린다. 이 모양을 외우고 있어야 이상을 알아챌 수 있다. 지표가 평소의 절반이라면 정상 범위여도 뭔가 잘못된 것이다.

평소 모양을 알고 나면 이상 패턴은 몇 가지로 정리된다. 트래픽이 수직으로 떨어지는 것은 서버가 한가해진 것이 아니라 요청이 도달하지 못하고 있다는 뜻이다. 로드 밸런서, DNS, 앞단 게이트웨이 어딘가에 문제가 생겼을 때 뒷단 서버의 그래프는 오히려 평화로워진다. 장애 중에 서버 지표가 전부 깨끗하다면 그것이 가장 큰 이상 신호라고 볼 수 있다. 반대로 수직으로 치솟는 것은 갑작스러운 바이럴이나 크롤러 접근 혹은 공격이라고 판단할 수 있다. 그리고 새벽마다 규칙적으로 솟는 스파이크는 대부분 배치 작업이나 크론이다. 스파이크가 규칙적이라면 내부 작업을 한번 의심해보자.

백문이 불여일견이라고 실제 그래프 모양을 살펴보자. 아래 애니메이션은 평소의 지표와 세 가지 이상 패턴을 보여준다. 같은 트래픽 그래프라도 방향에 따라 봐야 할 곳이 완전히 달라진다는 점을 눈여겨보자.

그리고 마지막으로 트래픽 지표에서 중요한 것은 분모가 될 수 있다는 점이다. 같은 에러 500건이라도 분당 요청이 백만 건일 때와 천 건일 때의 심각도는 완전히 다르다. 에러든 슬로 쿼리든 개수를 보면 반드시 트래픽으로 나눠 비율로 읽어야 한다.

지연 시간, 얼마나 걸리는가

대시보드의 평균 응답 시간은 100ms로 며칠째 안정적인데 앱이 너무 느리다는 문의가 계속 들어온다고 가정해보자. 지표는 건강한데 사용자는 문제를 느낀다. 누가 거짓말을 하고 있을까?

거짓말쟁이는 평균이다. 평균은 전체를 더해 나눈 값이므로 분포의 모양을 지워버린다. 모든 요청이 100ms 근처인 서버와 대부분은 30ms인데 일부가 900ms씩 걸리는 서버의 평균은 똑같이 100ms일 수 있다. 그래서 응답 시간은 평균이 아니라 백분위수(percentile)로 읽어야 한다. 어떤 구간의 요청을 응답 시간 순으로 줄 세웠을 때 앞에서 50% 지점의 값이 P50, 95% 지점이 P95, 99% 지점이 P99다. 응답 시간 분포를 히스토그램으로 그리면 오른쪽으로 길게 늘어진 부분이 생기는데 이 부분이 마치 꼬리처럼 생겼다 하여 P99 부근의 느린 응답을 꼬리 지연이라고 한다.

아래 애니메이션은 방금의 두 서버를 나란히 보여준다. 요청이 쌓이며 히스토그램이 만들어지는데 평균선은 두 서버 모두 100ms로 같다. 하지만 백분위 마커가 등장하는 순간 두 서버가 완전히 다른 시스템이라는 것이 드러난다.

어쩌면 “1%면 무시해도 되지 않나?”라는 생각이 들 수 있다. 하지만 두 가지 이유로 무시해서는 안 된다. 첫 번째로 트래픽이 조금만 커져도 1%는 큰 수다. 초당 1,000건이면 매초 10명이 P99를 맞는다. 하루면 86만 건이다. 두 번째로 화면 하나를 그리는 데 API 호출을 수십 번 하는 현대 서비스에서는 호출이 많을수록 그중 하나가 꼬리에 걸릴 확률이 곱으로 커진다. 요청 하나가 P99를 피할 확률이 99%라도 마흔 번 호출하면 전부 피할 확률은 67%다. 사용자 셋 중 하나는 화면 어딘가에서 꼬리 지연을 경험한다는 뜻이다. 구글이 “꼬리 지연과의 싸움”을 시스템 설계의 중심에 두는 이유가 이것이다. 게다가 꼬리에 걸리는 사용자는 무작위가 아니다. 오래 사용한 헤비 유저일수록 데이터가 많으므로 무거운 쿼리를 만들며 꼬리에 자주 걸린다. P99는 우리 서비스의 최우수 고객이 겪는 응답 시간일 가능성이 높다.

정리하면 이렇다. 사용자 경험의 대푯값은 P50, 알람과 성능 목표의 기준은 P95나 P99로 잡아야 한다. 뒤에서 다루겠지만 장애의 전조는 거의 항상 P99에 먼저 나타난다. 꼬리는 시스템에서 가장 약한 부분이 어디인지 알려주는 조기 경보다.

에러, 얼마나 실패하는가

에러율 그래프가 뛰었을 때 첫 질문은 “몇 퍼센트인가”가 아니라 “어떤 에러인가”다. HTTP 상태 코드 기준으로 4xx와 5xx는 완전히 다른 사건이기 때문이다. 5xx는 서버가 처리에 실패했다는 뜻이므로 언제나 우리 문제다. 4xx는 명세상 클라이언트의 잘못된 요청이지만 무시해도 된다는 뜻은 아니다. 배포 직후에 400이나 401이 급증했다면 클라이언트 잘못이 아니라 우리가 API 계약을 깨뜨렸을 가능성이 높다. 새 버전 앱이 구버전 서버에 요청을 보내고 있거나 그 반대인 경우다. 결국 4xx 그래프는 “누구의 실수인가”를 묻는 그래프이고 5xx 그래프는 “우리의 무엇이 문제인가”를 묻는 그래프다.

상태 코드 다음으로 볼 것은 실패하는 속도다. 타임아웃으로 실패하는 요청은 느리게 실패한다. 몇 초씩 스레드와 커넥션을 붙잡고 있다가 실패하므로 에러와 포화 문제를 동시에 일으킨다. 반면 의존 서비스가 아예 죽어서 나는 커넥션 거부나 널 참조 같은 코드 버그는 즉시 실패한다. 밀리초 안에 5xx를 뱉으므로 자원은 오히려 여유로워진다. 그래서 에러율과 P99를 같이 읽으면 실패의 종류를 구분할 수 있다. 에러율과 P99가 함께 오르면 어딘가가 느려지며 죽어가는 것이고 에러율은 치솟는데 P99가 멀쩡하다면 무언가가 즉시 실패하고 있는 것이다.

그리고 에러율이 오르는 동안의 지연 지표가 좋아지는 경우도 있을 수 있다. 아래 애니메이션에서 장애가 시작되는 순간 에러율과 P99가 각자 어느 방향으로 움직이는지 지켜보자.

즉시 실패한 요청은 지연 분포에서 빠져나가거나 밀리초짜리 표본으로 섞여 들어오므로 장애가 심해질수록 P99가 오히려 좋아 보일 수 있다. 장애 그래프에서 에러율은 최악인데 응답 시간은 평소보다 좋다면 회복이 아니라 생존자 편향을 의심해야 한다.

범인을 좁히는 법

증상이 “누군가 문제를 겪는다”는 것까지 알려줬다면 다음은 원인을 좁힐 차례다. CPU, 메모리, 스레드 풀과 커넥션 풀과 같은 리소스 지표들을 읽을 때의 대원칙은 하나다. 리소스 지표는 혼자 읽으면 거짓말을 한다. CPU 90%가 문제인지 아닌지는 CPU 그래프가 아니라 응답 시간이 말해준다. 그래서 이번 장에서는 리소스 지표를 증상과 겹쳐놓고 둘의 조합이 가리키는 문제가 무엇인지 찾는 법을 살펴볼 것이다.

CPU 사용률이라는 말의 함정

CPU 사용률은 대체로 모든 대시보드의 첫 자리에 있는 지표지만 단독으로는 놀라울 만큼 정보가 없는 지표이기도 하다. CPU 그래프는 항상 단독으로 읽지 말고 응답 시간과 겹쳐 읽어야 한다.

아래 애니메이션은 같은 CPU 그래프가 응답 시간과 함께 놓였을 때 어떻게 세 가지 다른 진단이 되는지 보여준다.

첫 번째 패턴은 건강한 상태다. CPU가 트래픽 곡선을 따라 완만하게 오르내리고 응답 시간은 낮은 자리에서 안정적이다.

두 번째 패턴이 가장 헷갈리게 만드는 상황이다. CPU는 20~30%로 한가한데 응답 시간이 폭증한다. CPU가 놀고 있으니 서버는 문제없다고 결론 내리고 싶어지지만 대부분은 정반대다. CPU가 놀면서 느리다는 것은 스레드들이 계산을 하는 게 아니라 무언가를 기다리고 있다는 뜻이다. 예를 들어, 느려진 DB 응답이나 잠긴 락, 고갈된 커넥션 풀, 외부 API의 타임아웃 등이 원인이 될 수 있다. 문제는 CPU 밖에 있고 CPU 그래프는 그 사실을 “한가함”으로 표현하고 있는 것이다. 이 패턴을 보면 CPU 그래프는 넘어가고 I/O와 풀 지표를 같이 살펴봐야 한다.

세 번째 패턴은 CPU가 100%에 붙어 톱니 하나 없는 직선이 되는 경우다. 이때 응답 시간도 함께 폭증한다면 트래픽이 정말로 처리 용량을 넘었거나 코드 어딘가에서 무한 루프를 돌고 있는 상황이다. 이 경우 트래픽 그래프를 같이 보면 문제의 원인을 구분할 수 있다. 트래픽이 함께 치솟았다면 용량 문제니 서버 대수를 늘리는 스케일 아웃이 답이고 트래픽은 평소 그대로인데 CPU만 치솟았다면 코드를 의심해야 한다.

컨테이너 환경이라면 하나 더 확인할 것이 있다. 쿠버네티스에서 CPU limit을 걸면 컨테이너는 주기(기본 100ms)마다 정해진 할당량만큼만 CPU를 쓸 수 있고 할당량을 다 쓰면 그 주기가 끝날 때까지 강제로 멈춘다. 이를 스로틀링이라 부른다.2 트래픽도 코드도 그대로인데 응답 시간 꼬리만 길어졌다면 이 컨테이너 설정이 원인일 수 있다.

이 스로틀링이 실제로 어떤 모양인지 100ms 주기 하나를 확대해보자. 아래 애니메이션은 CPU limit이 0.5코어로 걸린 컨테이너의 시간을 주기 단위로 쪼개서 보여준다. 참고로 할당량은 limit에 비례한다. 코어 하나는 주기 100ms 동안 CPU 시간 100ms를 제공하므로, 500m이면 주기당 50ms, 2코어면 주기당 200ms다.

주기당 할당량 50ms를 다 쓴 순간부터 그 주기가 끝날 때까지 애플리케이션은 통째로 멈춘다. 그런데 CPU 사용률은 쓴 시간에 대한 기록이라 쓰지 못하고 기다린 시간은 사용률 그래프에 남지 않는다. 그래서 컨테이너 환경에서는 CPU 사용률 옆에 스로틀 지표를 함께 두고 P99가 튀는데 스로틀 횟수가 함께 오르고 있다면 limit 설정을 의심해야 한다.

메모리 누수와 스파이크

JVM이나 Node.js와 같은 운영 서버의 메모리 그래프를 보면 메모리가 계속 차오르다가 갑자기 뚝 떨어지고, 다시 차오르다 뚝 떨어지는 모습을 볼 수 있다. 혹시 누수가 아닐까 의심할 수 있지만 이 톱니 모양은 가비지 컬렉터(GC)가 일하고 있다는 정상 신호다. GC 언어의 런타임은 객체를 만들며 메모리를 채우다가 GC가 돌면 죽은 객체를 한꺼번에 수거한다. 톱니 모양 그래프는 일종의 심장 박동 같은 것이다.

그렇다면 진짜 누수는 어떻게 알아볼까? 봐야 할 곳은 톱니의 봉우리가 아니라 바닥이다. 건강한 서버는 이 최저점이 수평선을 그린다. 누수가 있는 서버는 GC가 돌아도 회수하지 못하는 객체가 매번 조금씩 남으므로 최저점이 계단처럼 슬금슬금 올라간다. 아래 애니메이션에서 두 서버를 비교해보자.

최저점이 우상향한다면 문제가 발생하는 건 시간문제다. 힙 한계에 가까워질수록 GC는 점점 자주, 오래 돌면서 CPU를 잡아먹고(응답 시간이 먼저 나빠진다) 마지막에는 메모리 부족으로 프로세스가 죽는다. 메모리 누수를 발견했다면 그 자리에서 원인을 찾으려 하지 말고 힙 덤프(그 순간 힙에 살아 있는 객체 전체의 스냅샷)를 떠서 남겨둔 뒤 재시작으로 시간을 버는 것이 옳다. 바로 원인을 찾지 못한 것이 부끄러운 일이 아니다. 누수는 덤프를 통해 침착하게 분석하면 된다.

메모리가 장애의 범인이 되는 또 다른 모양은 계단이다. 평소의 톱니 위에서 어느 순간 메모리가 수직에 가깝게 뛰어오른다면 대량의 데이터를 한 번에 메모리로 올리는 요청이 방금 들어왔다는 뜻이다. 전체 조회, 대용량 파일 처리, 엑셀 다운로드 같은 것들이 단골이다. 아래 애니메이션은 평소의 톱니 위로 계단이 찍히는 순간과 같은 시각의 액세스 로그를 함께 보여준다.

계단이 찍힌 시각과 액세스 로그를 대조하면 범인 요청을 특정할 수 있다. 힙 메모리가 터지기 전에 발견했다면 페이징이나 스트리밍 처리로 고치면 되고 이미 문제가 발생했다면 그 API부터 차단하고 수습하는 것이 순서다.

비둘기집의 원리

비둘기집의 원리라는 것이 있다. 비둘기가 n+1마리인데 집이 n개라면 어느 집에는 반드시 두 마리가 들어간다는 원리다. 증명이랄 것도 없이 너무 뻔한 이야기지만 이 뻔함이 서버 용량 문제의 본질이다. 서버로 번역하면 이렇게 된다. 스레드가 n개인데 동시에 처리해야 할 요청이 n개를 넘는 순간 어떤 요청은 반드시 기다린다. 아키텍처를 어떻게 짜든 프레임워크가 무엇이든 피할 수 없다.

전형적인 자바 서버를 보자. Tomcat은 요청을 처리할 스레드를 풀로 관리하는데 이 스레드 풀의 기본 크기가 200개다. 요청 하나가 들어오면 풀에서 스레드 하나가 붙어서 처리가 끝날 때까지 그 요청을 전담한다. 그러면 동시에 처리 중인 요청은 몇 개나 될까? 이 답은 리틀의 법칙3으로 계산할 수 있다.

동시에 처리 중인 요청 수 = 초당 유입량 × 평균 처리 시간

초당 1,000건이 들어오고 평균 처리가 50ms라면 동시 요청은 1,000 × 0.05 = 50개라서 스레드 200개로는 여유롭게 처리가 가능하다. 그런데 이 공식의 무서운 점은 처리 시간이 곱해진다는 데 있다. DB에 슬로 쿼리가 생겨 평균 처리 시간이 2초가 됐다고 하자. 트래픽은 그대로인데 동시 요청은 1,000 × 2 = 2,000개가 된다. 스레드 200개는 순식간에 바닥나고 초과분 1,800개가 큐에 쌓인다. 트래픽은 한 건도 늘지 않았는데 서버가 죽는 것이다.

아래 애니메이션이 이 붕괴 과정을 보여준다. DB가 빠를 때는 문제없이 소화하지만 DB가 느려지는 순간 문제가 발생하기 시작한다.

이 데모에서 눈여겨볼 것은 큐가 쌓이기 시작한 뒤의 체감 응답 시간이다. 큐에 들어간 요청의 응답 시간은 “자기 처리 시간 + 앞사람들 전부의 대기 시간”이므로 풀이 마르는 순간 응답 시간이 치솟는다. 그래프에서 P99가 서서히 나빠지는 게 아니라 어느 순간 수직으로 뛰었다면 어딘가의 풀이 방금 말랐을 가능성이 높다.

여기서 연쇄적으로 문제가 시작된다. 스레드가 전부 DB 대기에 묶이면 새 요청은 하나도 처리되지 못하는데 그 “새 요청”에는 로드 밸런서의 헬스 체크도 포함된다. 헬스 체크가 타임아웃되면 로드 밸런서는 그 서버를 빼버리고 남은 서버들이 그만큼의 트래픽을 더 받아 더 빨리 마른다. 슬로 쿼리 하나가 전체 클러스터를 도미노처럼 쓰러뜨리는 연쇄 장애의 가장 흔한 시나리오다. 아래 애니메이션은 서버 세 대가 무너지는 과정을 보여준다.

서버 세 대가 전부 죽었지만 원인은 DB의 슬로 쿼리 하나다. 장애 대응에서 죽은 서버부터 살리려 들면 헛수고가 되는 이유이기도 하다. 재시작해봤자 같은 DB에 물리는 순간 다시 문제가 발생한다. 여기에 기름을 붓는 것이 재시도다. 응답을 받지 못한 클라이언트와 게이트웨이가 일제히 재시도를 시작하면 트래픽은 평소의 두세 배로 불어나고 회복할 틈을 얻지 못한다. 재시도 폭풍이라 부르는 이 문제 때문에 장애 시점 이후 트래픽 그래프가 오히려 치솟아 있다면 사용자가 늘어난 것이 아니라 같은 사용자가 여러 번 두드리고 있는 것으로 읽어야 한다.4

그래서 스레드 풀 지표에서 봐야 할 것은 사용률의 절댓값보다 기울기다. 활성 스레드 수가 트래픽을 따라 완만하게 오르내리는 게 아니라 급경사로 차오르고 있다면 원인은 대개 풀 자체가 아니라 어딘가의 지연이다.

커넥션 풀도 같은 수학이 지배한다. 커넥션 풀이란 요청마다 DB 연결을 새로 맺는 대신 미리 맺어둔 연결 몇 개를 빌려 쓰고 반납하는 구조인데 수가 스레드 풀보다 훨씬 적다. 자바 진영의 표준 커넥션 풀 라이브러리인 HikariCP의 기본 풀 크기는 10개다. 스레드 200개가 커넥션 10개를 나눠 쓰는 구조이므로 DB가 조금만 느려져도 커넥션 풀이 스레드 풀보다 먼저 마른다. 이때 애플리케이션 로그에는 “connection timeout”이 찍히기 때문에 개발자는 반사적으로 풀 크기를 늘리고 싶어진다. 하지만 잠깐 생각해보자. 커넥션이 마른 이유가 DB가 느려서라면 커넥션을 늘리는 것은 느린 DB에 줄을 더 길게 세우는 일일 뿐이다. 오히려 DB 입장에서는 동시에 처리할 세션이 늘어나 더 느려질 수도 있다. 풀 고갈 그래프를 봤을 때의 올바른 질문은 풀이 작은가가 아니라 왜 반납이 느려졌는가다. 커넥션 대기 시간 지표와 DB의 슬로 쿼리 로그를 나란히 놓으면 대부분 답이 나온다.

이벤트 루프 서버라면

여기까지의 이야기는 요청마다 스레드가 붙는 모델의 이야기다. Node.js나 Spring WebFlux(Netty)처럼 이벤트 루프로 도는 서버는 구조가 다르므로 봐야 할 그래프도 다르다. 설명은 Node 기준으로 하지만 원리는 같다. Node에는 요청을 전담하는 스레드 풀이 없다. 자바스크립트 코드를 실행하는 스레드는 단 하나이고 이 스레드가 이벤트 루프라는 회전문을 돌리며 모든 요청을 조금씩 번갈아 처리한다. I/O처럼 기다림이 필요한 일은 운영체제에 위임해두고 회전문은 계속 돈다. 그래서 스레드 하나로도 수천 개의 동시 연결을 감당할 수 있다.

이 구조의 대가는 명확하다. 회전문이 한 번 멈추면 모든 것이 멈춘다. 스레드 풀 모델에서는 무거운 작업이 스레드 하나를 잡아먹어도 나머지 199개가 일을 계속하지만, Node에서는 CPU를 오래 쓰는 작업 하나가 이벤트 루프를 잡는 순간 그 프로세스의 모든 요청이 정지한다. 아래 애니메이션에서 그 순간을 볼 수 있다.

그래서 Node 서버의 핵심 지표는 스레드 풀 사용률이 아니라 이벤트 루프 지연이다. 이 지표는 루프가 한 바퀴 도는 데 예정보다 얼마나 늦어지고 있는가를 뜻하는 값이다.

이 지표를 읽을 때 조심할 것이 두 가지 있다. 첫째, 평균이 아니라 최대치나 높은 백분위로 봐야 한다. 루프 지연은 밀리초급 샘플 수천 개 사이에 큰 정지 한 번이 끼어드는 식으로 나타나므로 1초짜리 정지가 있어도 평균은 정상 샘플에 희석되어 거의 움직이지 않는다. 둘째, 절대 기준이 아니라 평소 값 대비로 읽어야 한다. 정상에 대한 기준은 도구와 환경마다 다를 수 있다. 그러므로 자기 서비스의 평소 값을 확인해두고 그보다 수십 ms 이상 높은 상태가 지속되면 문제 상황으로 판단하는 것이 좋다.

이벤트 루프 지연의 원인은 대개 커진 JSON 직렬화, 백트래킹이 폭발하는 정규식, 루프 위에서 도는 암호화 연산 같은 것들이고 루프 지연이 치솟는데 CPU도 100%라면 거의 확정이다. 참고로 이것은 브라우저의 메인 스레드가 막히는 것과 정확히 같은 원리다. 그 이야기는 비싼 메인 스레드에서 프론트엔드 관점으로 다뤘다.

모니터링 관점에서 또 하나 중요한 차이는 스케일의 단위다. Node는 스레드 대신 프로세스를 늘린다. 이 말은 지표도 프로세스 단위로 봐야 한다는 뜻이다. 프로세스 여덟 개의 평균 CPU가 40%라는 그래프는 일곱 개가 25%이고 하나가 100%에 붙어 죽어가는 상황을 숨겨버린다. 스레드 풀 모델에서는 인스턴스 하나가 문제라면 인스턴스 지표에 드러나지만, Node에서는 인스턴스 안의 프로세스 하나가 문제일 수 있으므로 집계 단위를 한 단계 더 쪼개야 한다. 따라서 평균이 아니라 프로세스별 최댓값을 두거나 가능하면 프로세스별 이벤트 루프 지연을 대시보드에 두는 것이 좋다.

지표 하나로는 보이지 않는 것들

여기까지 리소스 지표에 대해 살펴보았다. 하지만 실제 장애 상황에서 중요한 것은 CPU 사용률이 높은지가 아니라 지금 어디가 막혔는가다. 요청은 로드 밸런서, 게이트웨이, 애플리케이션, DB를 거쳐서 흐르는데 이 흐름 어딘가 한 곳이 좁아지면 전체가 느려진다. 그 병목을 찾는 것이 그래프 읽기의 중심 기술이고 이어지는 장들은 전부 이 기술의 응용이다.

대기가 쌓이는 곳

병목에는 다행히 뚜렷한 신호가 있다. 병목이 되는 곳 앞은 대기가 쌓이고 그 뒤는 한가하다. 도로의 사고 지점 뒤로는 정체가 늘어서지만 사고 지점을 지나면 도로가 텅 비는 것과 같다. 아래 애니메이션은 게이트웨이, 애플리케이션, DB로 이어지는 파이프라인에 트래픽이 늘었을 때 무슨 일이 벌어지는지 보여준다.

트래픽이 600 req/s로 늘면 애플리케이션 앞에 줄이 쌓이기 시작한다. 게이트웨이의 게이지는 여유롭고, 병목 뒤의 DB는 오히려 한가하다. 전체 처리량은 애플리케이션의 용량인 300에서 멈추고 초과분은 전부 애플리케이션 앞에서 대기한다. 여기서 병목의 두 번째 성질이 나온다. 전체 처리량은 가장 좁은 단이 결정한다. 병목이 아닌 곳을 아무리 증설해도 전체는 조금도 빨라지지 않는다. 게이트웨이를 증설하는 것은 사고 지점 앞의 차선을 늘리는 것과 같다.

그래서 병목 찾기는 각 계층의 그래프를 겹쳐놓고 누가 기다리고 누가 한가한가를 판단하는 일이다. 실전에서 자주 만나는 조합을 정리하면 이렇다.

  • P99 급증 + CPU도 급증
    → 트래픽을 본다. 트래픽 초과(스케일 아웃이 답)거나 코드 문제가 있을 수 있다.
  • P99 급증 + CPU는 한가
    → DB 응답 시간과 풀 대기를 본다. I/O 병목, 락 경합, 풀 고갈 같은 대기 문제일 수 있다.
  • 모든 API가 동시에 느려짐
    → DB, 캐시, 공용 풀을 본다.
  • 특정 API만 느려짐
    → 그 API의 쿼리와 외부 호출을 본다.
  • 에러율 급증 + P99는 정상(혹은 개선)
    → 배포 이력과 의존 서비스를 본다.
  • 한 계층은 에러, 다른 계층은 성공
    → 계층별 타임아웃 설정을 본다.

세 번째와 네 번째 항목의 구분은 특히 유용하다. 장애가 났을 때 전부 느린지 일부만 느린지에 대한 판단 하나로 용의선상이 절반으로 줄어든다. 전부 느리면 공유 자원(대부분 DB나 캐시)이고 일부만 느리면 그 API의 코드다.

마지막으로 병목은 갑자기 나타나는 것 같지만 전조 증상은 반드시 있다. 그런데 이 전조를 제대로 읽으려면 대기 시간은 사용률에 비례해서 늘지 않는다는 것을 알아야 한다. 사용률이 낮을 때 도착한 요청은 대부분 빈자리를 바로 얻는다. 하지만 사용률이 높아질수록 먼저 온 요청이 아직 처리 중일 확률이 커지고 우연히 몰린 요청을 받아줄 여유도 사라진다. 그래서 대기 시간은 사용률이 낮은 동안에는 거의 0이다가 어느 지점을 넘으면 폭발적으로 치솟는다.

사용률 50%일 때의 대기를 1이라 하면 80%에서 4배, 90%에서 9배, 95%에서는 19배가 된다. 곡선이 꺾이는 부분이 대략 80% 언저리에 있다. 오토스케일 임계값으로 흔히 쓰는 80%는 여기서 얻은 일종의 휴리스틱 값이라 볼 수 있다. 이 곡선을 알고 나면 두 가지가 달라진다. 첫째, “아직 90%니까 10% 남았다”는 계산이 얼마나 위험한지 알게 된다. 둘째, P99가 트래픽 증가보다 가파르게 나빠지기 시작했다면 어딘가의 사용률이 이 곡선이 꺾이고 있다는 뜻이라는 것을 알게 된다. 그때가 스케일 아웃이든 쿼리 튜닝이든 병목을 손볼 마지막 여유 구간이다.

큐는 공짜가 아니다

병목 장에서 “초과분은 전부 줄이 된다”고 했다. 그 줄에 대해 조금 더 이야기해야 한다. 큐는 트래픽의 출렁임을 흡수해주는 고마운 완충 장치지만 트래픽이 처리 용량을 지속적으로 넘는 순간 성격이 바뀐다. 만약 큐에 100개가 쌓여 있고 초당 100개를 처리한다면 지금 101번째 요청은 1초를 기다려야 한다. 큐가 1만 개면 100초다. 즉 자라나는 큐는 요청을 구해주는 것이 아니라 전원의 대기 시간을 늘리는 부채다. 이렇게 처리량보다 트래픽이 많은 상황을 배압이라고 부른다.

배압 현상이 발생하면 대기 시간이 클라이언트의 타임아웃을 넘어서는 순간부터는 처리에 성공해도 아무도 받지 않는 응답을 만들 뿐이고 그 사이 큐 자체가 메모리를 먹어치우다 OOM으로 끝나버린다.

그래서 필요한 것이 흐름 제어다. 배압을 큐 안에 숨기지 말고 앞 단에 알려서 소화할 수 없는 트래픽은 지금은 못 받는다고 명시적으로 거부하는 것이다. 가장 단순한 구현은 큐에 상한을 두고 넘치면 즉시 429(Too Many Requests)로 거부하는 것이다. 이런 의도적인 거부를 로드 셰딩이라 부른다. 멀쩡한 요청을 일부러 실패시키는 게 이상하게 들리겠지만 두 상황을 나란히 놓고 보면 생각이 바뀔 것이다.

흐름 제어가 없는 왼쪽에서는 모두가 공평하게 조금씩 서서히 죽어간다. 반면 오른쪽은 일부를 빠르게 실패시키는 대신 수용한 나머지를 멀쩡하게 살린다. 모니터링 관점의 시그니처도 뚜렷하다. 큐 길이나 대기 시간 그래프가 우상향을 멈추지 않는다면 흐름 제어가 없거나 부족하다는 뜻이다. 건강한 시스템의 큐 그래프는 출렁이더라도 반드시 평형으로 돌아온다.

캐시 히트율이 무너지는 순간

캐시가 있는 시스템의 대시보드에는 캐시 히트율(hit rate) 그래프가 있어야 한다. 평소에는 거의 직선만 보이는 지루한 그래프지만 이 지루한 직선이야말로 시스템의 안전판이다. 예를 들어 히트율 97%인 경우 DB가 전체 트래픽의 3%만 감당하고 있다는 뜻이고 거꾸로 말하자면 DB의 용량은 전체 트래픽이 아니라 그 3%에 맞춰져 있다는 뜻이기 때문이다. 캐시 뒤의 DB는 캐시가 건재하다는 전제에서만 안전하다.

그 전제가 무너지는 대표적인 순간이 캐시 스탬피드(cache stampede)다. 인기 키 하나의 TTL(캐시 유효 기간)이 만료되는 순간을 따라가 보자.

캐시가 낀 시스템에서 DB 부하가 갑자기 튀었다면, DB부터 파지 말고 히트율 그래프를 먼저 살펴보자. 히트율 급락과 DB 스파이크가 같은 시각이라면 범인은 DB가 아니라 캐시 쪽이다. DB만 보고 있으면 “DB가 갑자기 느려졌다”로 보이지만 사실 DB는 평소에 받지 않던 일을 받았을 뿐이다.

두 대시보드가 서로 다른 말을 할 때

요청이 지나는 각 계층에는 저마다의 타임아웃이 있다. 문제는 이 값들이 서로 조율된 적이 없을 때 발생한다. 게이트웨이는 타임아웃을 3초로 잡았는데 백엔드의 쿼리가 5초 걸린다면 무슨 일이 벌어질까?

사용자는 3초 만에 504를 받지만 백엔드는 그 사실을 모른 채 5초짜리 일을 끝까지 마치고 뿌듯하게 성공을 기록할 수 있다. 이때 응답은 이미 아무도 기다리지 않으므로 버려진다. 그래서 백엔드 지표에는 실패가 없고 게이트웨이 지표에는 성공이 없는 기묘한 현상이 발생한다. 이런 현상은 지표를 어지럽히는 데서 끝나지 않는다. 아무도 받지 않을 응답을 만드느라 스레드와 커넥션과 CPU를 계속 쓰고 있으므로 과부하 상황에서는 이 낭비가 회복을 그만큼 늦춘다.

이 문제를 해결하기 위해서는 안쪽 계층의 타임아웃을 바깥 계층보다 짧게 잡아야 한다. 게이트웨이가 3초라면 백엔드의 DB 타임아웃은 2.5초쯤이어야 백엔드가 먼저 깔끔하게 포기하고 게이트웨이는 정돈된 에러를 받는다. 이렇게 바깥에서 안으로 갈수록 줄어드는 시간을 타임아웃 예산(timeout budget)이라 부른다. 한 계층의 에러율과 다른 계층의 성공률이 모순된다면 코드보다 타임아웃 설정부터 살펴보자.

세 가지 시간

같은 대시보드라도 분석의 목적은 시점에 따라 완전히 달라진다. 문제가 없을 때 하는 평시 분석은 문제가 발생할 곳을 미리 찾는 일이고 장애 중 분석은 문제를 해결하는 최단 경로를 찾는 일이며, 장애 후 분석은 같은 장애를 두 번 겪지 않게 만드는 일이다. 셋은 같은 그래프에 서로 다른 질문을 던진다. 그리고 이 셋을 섞는 것이 모니터링에서 가장 흔한 실수다.

미리 문제를 찾는 시간

평시 분석의 핵심은 무엇이 문제가 될 것인지를 미리 찾는 것이다. 앞에서 익힌 방법의 대부분은 장애가 발생했을 때 빛을 발하지만 문제를 빠르게 해결하기 위한 숙련도는 평시에 결정된다. 장애 한복판에서 처음 보는 패턴을 해석하기는 어렵기 때문이다.

거창한 절차는 필요 없다. 하루 5분씩만 대시보드를 한 바퀴 돌아보자. 트래픽의 평소 모양, CPU의 리듬, P99의 평상시 응답 시간, 배포 때마다 출렁이는 폭 등을 눈에 익혀두자. 이상하다는 것은 정의상 평소와 다른 것이므로 평소를 모르면 무언가 이상하다는 것도 알아볼 수 없다.

알람 설계도 평시 분석의 몫이다. 꾸준히 관찰하며 “이 정도면 정상”이라는 기준선을 정하고, 그 기준선을 넘어서는 순간 알람이 울리도록 해야 한다. 이 기준을 장애가 발생했을 때 토론하는 것은 이미 늦었다는 뜻이다.

천천히 나빠지는 것들

평시 분석이 진짜로 상대해야 하는 것은 서서히 나빠지는 것이다. 하루에 1%씩 나빠지는 시스템은 매일 지켜보지 않는다면 눈치채기 어렵다. 다음 애니메이션을 살펴보자.

4주 동안 알람은 0건이었지만, 4주 전 같은 요일과 겹쳐보면 같은 트래픽에 응답만 크게 느려져 있다. 이런 경우 알람은 크게 의미 없다. 알람은 순간적인 문제를 잡는 방법이지 서서히 나빠지는 것을 찾는 방법은 아니다. 이럴 때 유용한 방법은 비교다. 오늘의 P99 위에 지난주 같은 요일의 P99를 겹쳐 그리는 주간 비교 패널을 대시보드에 두면 추세만 도드라지게 볼 수 있다. 이 추세를 통해 여러 가설을 세우는 것도 가능하다. 예를 들어, 서서히 트래픽이 늘어나는 것인지, 메모리 누수가 있는 것인지, DB가 점점 느려지는 것인지, 아니면 캐시 히트율이 점점 떨어지는 것인지 등의 질문을 던져볼 수 있다. 그리고 이는 곧 앞으로의 계획과 대비로 이어진다.

가장 위험한 30분

운영 장애의 다수는 새 트래픽도 하드웨어 고장도 아닌 방금 사람이 가한 변경에서 온다.5 그리고 서버 개발자가 시스템에 가하는 가장 크고 빈번한 변경이 배포다. 배포 직후 30분은 그래프를 가장 집중해서 읽어야 하는 시간이다.

첫 번째 규칙은 시시할 만큼 단순하다. 배포 시각을 그래프에 마커로 남겨라. 장애 분석의 절반은 “이 변화가 배포 전인가 후인가”라는 질문인데 마커가 없으면 이 간단한 질문에 답하기 위해 피곤한 시간을 보내야 한다. 간단한 기능이지만 비용 대비 효과가 가장 높은 모니터링 개선이다.

두 번째 규칙은 배포 직후에는 그래프가 출렁인다는 것을 이해하는 것이다. 새 프로세스는 JIT 컴파일이 덜 됐고 로컬 캐시는 비어 있으며 커넥션 풀은 새로 맺어야 한다.6 그래서 배포 직후 P99가 두세 배 뛰는 것 자체는 이상이 아니다. 그렇다면 정상적인 출렁임과 사고를 어떻게 구분할까? 답은 값이 아니라 방향에 있다. 아래 두 시나리오를 비교해보자.

시나리오 A는 배포 직후 치솟았다가 몇 분에 걸쳐 우하향한다. 워밍업이 진행되며 시스템이 새 평형을 찾아가는 그림이므로 지켜보면 된다. 시나리오 B는 같은 높이로 치솟은 뒤 내려오지 않는다. 고착되거나 오히려 악화된다. 워밍업은 시간이 지나면 반드시 좋아지는 과정이므로 몇 분을 지켜봐도 회복 추세가 없다면 롤백을 고려해야 한다.

롤백은 몇 분 만에 확실하게 문제를 해결할 수 있는 방법이다. 원인은 시스템이 안정된 뒤에 찾아도 늦지 않다. 문제는 장애가 진행 중일 때는 패닉 상태가 오기 때문에 이 판단을 잘 떠올리지 못한다는 것이다. 그래서 판단 기준은 미리 정해두는 것이 좋다. 예를 들어, “배포 후 에러율이 X%를 넘거나 P99가 10분 내 회복 추세를 보이지 않으면 토론 없이 롤백한다” 같은 규칙을 팀이 미리 합의해두면 좋다.

이 판단을 아예 구조로 만든 것이 카나리 배포다. 카나리 배포를 하면 새 버전을 전체가 아니라 한두 대에만 내보내고 카나리의 지표와 기존 버전의 지표를 같은 시간, 같은 트래픽 조건에서 나란히 비교한다. 다만, 점진 배포 중에는 지표가 뒤섞일 수 있으므로 반드시 버전 라벨로 나눠서 봐야 한다.

응급 처치의 시간

장애는 언제든 발생할 수 있다. 문제는 장애가 발생하면 머리가 하얗게 된다는 것이다. 장애 상황에 익숙하지 않다면 멍하게 그래프만 바라보다가 시간을 허비하는 경우도 많다. 이럴 때는 프로세스로 접근하는 것이 좋다.

처음으로 해야 할 일은 영향 범위 확인이다. 응급실이 치료에 앞서 환자의 중증도부터 분류하듯이 원인이 아니라 영향 범위부터 확인해야 한다. 장애 대응에서도 이 단계를 트리아지(triage)라 부른다. 전체 장애인가, 특정 API인가, 특정 지역이나 특정 고객사인가를 확인해야 한다. 이 확인은 대응의 급을 결정하는 동시에 첫 번째 진단 정보가 된다. 병목 장에서 봤듯 전부 느리면 공유 자원이고 일부만 느리면 그 코드이기 때문이다.

다음으로 판단할 것은 방금 무엇이 바뀌었는가다. 최근 30분에서 몇 시간 사이의 변경을 찾는다. 배포, 설정 변경, 피처 플래그(코드 배포 없이 기능을 켜고 끄는 스위치) 조작, 인프라 작업, 마케팅 푸시로 인한 트래픽 급증 등이 원인이 될 수 있다. 변경이 있었다면 그것이 범인일 사전 확률이 압도적으로 높으므로 그래프를 깊게 파기 전에 되돌리는 것부터 검토한다.

세 번째 갈림길은 복구와 원인 규명의 순서인데, 거의 항상 복구가 먼저다. 롤백, 재시작, 스케일 아웃, 문제 기능의 플래그 오프 등을 고려할 수 있다. 미리 관측 가능성을 확보해두었다면 근본 원인은 도망가지 않는다. 메트릭과 로그와 힙 덤프에 증거가 남아 있으므로 문제를 해결한 뒤에 분석해도 늦지 않다.

준비된 복구 수단이 통하지 않아 원인을 더 좁혀야 할 때가 비로소 장애 중의 진단이다. 여기서 하지 말아야 할 것이 그래프 스무 개를 띄워놓고 뭔가 튀는 것이 없나 훑는 일이다. 그것보다 중요한 것은 가설을 세우는 것이다. “DB가 느려진 거라면 커넥션 대기가 올랐을 것이다” 같은 가설을 먼저 세우고 그것을 확인할 그래프 하나를 열어 폐기하거나 채택한다. 즉, 체크리스트를 구성하는 것이라 볼 수 있다. 앞서 다룬 패턴들이 가설의 재료가 될 수 있다.

포스트모템

장애 상황이 끝났다고 일이 끝난 것은 아니다. 가장 중요한 원인 파악과 재발 방지 작업이 남아 있다. 장애 중의 분석이 “배포를 되돌렸더니 장애가 끝났다”와 같이 상관관계에서 멈췄다면, 장애 후 분석은 “왜 그 배포가 문제였고, 왜 리뷰에서 걸리지 않았나”와 같이 인과까지 가야 한다.

시작은 그래프로 타임라인을 재구성하는 것이다. 문제의 첫 흔적이 그래프에 나타난 시각, 알람이 울린 시각, 사람이 대응을 시작한 시각, 복구된 시각, 이 네 점을 찍고 나면 두 개의 간격이 보인다.

첫 흔적과 알람 사이의 간격은 감지의 공백이다. 그래프를 되감아 보면 대부분 알람보다 훨씬 전에 전조가 있다. P99 꼬리가 슬금슬금 오르고 있었거나, 풀 대기가 몇 번 튀었거나. 그 전조를 다음번에는 알람으로 만들 수 있는가가 포스트모템의 첫 질문이다. 알람과 복구 사이의 간격은 대응의 속도다. 배포 마커가 없어서 30분을 헤맸다면 그것이 액션 아이템이다. 그래서 장애 후 분석의 산출물은 반성문이 아니라 다음 장애를 짧게 만드는 변경 목록이다. 대시보드에 추가할 패널, 새로 걸 알람, 미리 합의할 롤백 기준, 그리고 코드 수정이나 설정 변경 같은 근본 원인 제거까지 포함된다.

원인을 적을 때의 규칙이 두 가지 있다. 첫 번째로 표면 원인에서 한 겹 더 들어가야 한다. 원인인 “슬로 쿼리 때문”에서 멈추지 말고 “대량 테이블에 대한 쿼리의 실행 계획을 확인하는 절차가 없어서”까지 가야 다음 슬로 쿼리를 막는다. 두 번째로 사람을 원인으로 적지 말아야 한다. 원인 칸에 사람 이름이 들어가는 순간 다음 장애 때 그 자리에 있던 사람은 장애가 발생했다는 것을 숨기거나 자책하게 된다. 사람은 원인에서 제외하고 “이런 상황에서 이런 판단을 내릴 수밖에 없었다”라는 맥락만을 적어야 한다. 사람을 탓하는 포스트모템은 다음 장애를 더 길게 만들 뿐이다.

마치며

돌이켜보면 모니터링 분석은 외국어 독해와 비슷하다. 처음에는 단어(지표) 하나하나를 사전에서 찾지만 익숙해지면 문장(패턴)이 통째로 읽히고, 나중에는 행간(상관관계)이 보인다. 그리고 외국어가 그렇듯 지름길은 없지만 왕도는 있다. 매일 조금씩 읽는 것이다. 장애가 났을 때만 대시보드를 여는 사람에게 그래프는 영원히 낯선 외국어지만 평소의 모양을 아는 사람에게 이상 신호는 문장 한가운데의 오타처럼 저절로 눈에 띈다.

다음에 알람이 울리면, 그래프를 전부 열기 전에 잠깐 멈추고 “얼마나 들어오고, 얼마나 걸리고, 얼마나 실패하고, 무엇이 차 있는가”라는 네 가지 질문부터 던져보자. 침착하게 접근하면 생각보다 간단하다.

  1. 구글 종사자들이 집필한 사이트 신뢰성 엔지니어링 6장 “분산 모니터링 시스템”에서 제안된 개념이다.

  2. 쿠버네티스의 CPU limit은 리눅스 CFS 스케줄러의 할당량 기능으로 구현되어 있다. 이때 할당량은 진짜 시간이 아니라 CPU 시간이라서 여러 스레드가 나눠 소모한다. 예를 들어 limit이 2코어면 주기당 할당량이 200ms인데 스레드 8개가 동시에 돌면 25ms 만에 8 × 25ms = 200ms를 소진하고 그 주기의 남은 75ms 동안 통째로 멈춘다. 스레드가 많을수록 정지는 더 갑작스럽다.

  3. 1961년 존 리틀이 증명했다. 도착 분포나 처리 시간 분포에 대한 아무 가정 없이 안정 상태의 모든 큐잉 시스템에서 성립하는 놀라울 만큼 일반적인 법칙이다.

  4. 그래서 재시도에는 지수 백오프(실패할수록 대기 시간을 두 배씩 늘리는 것)와 지터(무작위 지연)를 넣고 재시도 총량에 예산을 두는 것이 표준이다. 잘 만든 재시도는 일시 장애를 흡수하지만 못 만든 재시도는 장애를 증폭한다.

  5. 사이트 신뢰성 엔지니어링에 따르면 장애의 약 70%가 실행 중인 시스템에 대한 변경에서 비롯된다.

  6. 이 출렁임을 줄이기 위해 배포 직후 트래픽을 서서히 늘리는 슬로 스타트나 실제 트래픽을 받기 전에 예열 요청을 미리 보내 JIT 컴파일과 캐시, 커넥션 풀을 데워두는 웜업 단계를 두는 조직도 많다. 특히 JVM은 JIT 컴파일러가 자주 실행되는 코드를 최적화하기 전까지 수 배 느리게 돌기 때문에 웜업의 효과가 크다.