Performance · Scaling · Testing

부하 테스트를 통과하고도 터지는 이유

부하 테스트가 초록불을 줬는데 프로덕션이 터진다. 테스트가 틀린 게 아니라 볼 수 없는 것이 있다. 시간, 사용자의 쏠림, 데이터 크기, 목으로 세운 외부 의존이 그 자리다.

목차
  1. 시험을 통과한 다리도 무너진다
  2. 통과가 말하는 것과 말하지 않는 것
  3. 30분으로는 안 보이고 30일이면 보인다
  4. 사용자는 고르게 오지 않는다
  5. 데이터가 작았다
  6. 목으로 세운 것은 절대 안 느려진다
  7. 통과한 뒤에 배포한 코드
  8. 여기서 다리 비유가 다시 깨진다
  9. 실무에서: 미리 재는 것과 계속 재는 것을 같이 둔다
  10. 정리

하중 시험을 통과한 다리가 몇 년 뒤에 무너지는 일이 있다. 시험이 엉터리여서가 아니라 시험으로 볼 수 없는 것이 있어서다.

시험을 통과한 다리도 무너진다

트럭을 올려 봤고, 계산대로 휘었고, 합격 도장을 받았다. 그런데 개통하고 몇 년이 지나 무너진다.

원인은 대개 하중 시험이 못 본 것이다. 시험은 트럭을 하루 올렸는데 실제 다리는 십 년 동안 진동을 받는다. 시험은 무게를 봤는데 실제로는 소금과 습기가 철근을 삭힌다. 시험이 틀린 게 아니라 시험의 창이 좁았던 것이다.

부하 테스트도 정확히 같은 구조를 갖는다. 부하를 제대로 거는 법을 다 지켜도, 그래서 결과가 진짜여도, 여전히 못 보는 것들이 남는다. 이 글은 그 목록이다.

통과가 말하는 것과 말하지 않는 것

먼저 통과라는 결과가 정확히 무슨 뜻인지 좁혀 두면 나머지가 쉬워진다.

통과가 말하는 것통과가 말하지 않는 것
그 환경에서프로덕션에서도
그 시나리오로실제 사용자가 하는 모든 일에 대해
그 데이터 크기로반년 뒤 커진 데이터로
그 시점의 코드가오늘 배포한 코드가
그 시간 동안계속 돌아도

왼쪽 다섯 개가 전부 조건이다. 조건이 하나라도 어긋나면 결과는 그만큼 약해진다. 그런데 결과지에는 “통과”라고만 적히니, 시간이 지날수록 조건은 잊히고 판정만 남는다.

그래서 부하 테스트 결과는 **“안전하다”가 아니라 “이 조건에서 여기까지는 됐다”**로 읽어야 한다. 아래 다섯 절은 그 조건이 어긋나는 대표적인 자리들이다.

30분으로는 안 보이고 30일이면 보인다

가장 크게 벌어지는 자리는 시간이다. 부하 테스트가 보는 창은 길어야 몇십 분인데, 어떤 고장은 그보다 훨씬 느리게 자란다.

메모리 사용량 메모리 한계 부하 테스트가 본 구간 30분 - 축 전체의 0.07% 26일째 멈춘다 0일 10일 20일 30일

회수될 때마다 내려가지만 바닥이 조금씩 높아진다. 30분 안에서는 그 기울기가 보이지 않아 완벽하게 정상으로 읽히고, 몇 주를 이어 봐야 드러난다.

메모리가 조금씩 새는 상황이 딱 이 모양이다. 회수될 때마다 내려가긴 하는데 바닥이 매번 조금씩 높아진다. 30분 안에서는 그 기울기가 너무 완만해서 완벽하게 정상으로 읽힌다. 그리고 몇 주 뒤 새벽에 죽는다.

같은 방식으로 자라는 것이 몇 가지 더 있다.

  • 커넥션과 파일 핸들 - 어떤 예외 경로에서만 안 닫으면, 그 경로가 드물수록 늦게 터진다
  • 디스크 - 로그와 임시 파일이 쌓인다. 부하 테스트 30분이 만드는 양은 며칠 치의 몇백분의 일이다
  • 캐시 - 만료를 안 걸어 둔 항목이 계속 쌓여 메모리를 먹는다
참고

GC가 있는 언어에도 누수는 있다. 아무도 안 쓰는데 어딘가에서 참조를 붙잡고 있으면 회수 대상이 아니다. 이런 누수는 코드를 읽어서는 잘 안 보이고, 오래 걸어 보는 것 말고는 드러낼 방법이 마땅치 않다.

그래서 내구 테스트는 다른 방식들과 성격이 다르다. 몇 시간에서 며칠을 그냥 돌려 두는 일이라 비싸고 지루한데, 그 지루한 시간이 유일하게 이 부류를 잡는다.

사용자는 고르게 오지 않는다

부하 테스트는 대개 고른 부하를 만든다. 초당 500건이면 매초 500건씩. 실제 사용자는 그렇게 오지 않는다.

몰림은 세 가지 모양으로 온다.

  • 시간에 몰린다. 점심시간, 퇴근길, 광고가 나간 직후. 하루 평균으로는 여유가 넘치는데 그 10분에 무너진다
  • 대상에 몰린다. 모든 상품이 고르게 팔리지 않는다. 한 상품에 주문이 쏠리면 그 행 하나에 잠금이 몰리고, 나머지가 아무리 한가해도 거기서 막힌다
  • 같은 순간에 겹친다. 캐시 항목이 한꺼번에 만료되면 그 순간 모두가 DB로 간다. 평소 캐시가 받아내던 부하가 통째로 뒤로 넘어간다

셋 다 평균으로는 안 보인다. 그리고 부하 테스트가 만드는 것은 대개 평균이다. 그래서 부하 테스트가 통과한 시스템이 이벤트 시작 5초 만에 죽는다.

팁

캐시가 한꺼번에 만료되는 문제는 만료 시간을 조금씩 흩어 주는 것으로 상당 부분 눌린다. 다만 부하 테스트로 이 문제를 발견하기는 어렵다 - 만료가 겹치는 상황을 일부러 만들지 않으면 재현이 안 된다.

데이터가 작았다

테스트 환경의 DB에는 대개 몇만 건이 들어 있다. 프로덕션에는 몇억 건이 들어 있다.

이 차이는 성능을 비례해서 나쁘게 만드는 게 아니라 성질을 바꾼다.

  • 데이터가 작으면 인덱스와 데이터가 통째로 메모리에 들어간다. 디스크를 거의 안 읽으니 인덱스가 왜 빠른지를 확인할 기회조차 없다. 커지면 그때부터 디스크 읽기가 시작된다
  • 데이터가 작으면 인덱스를 안 타도 빠르다. 1만 건 전체를 훑는 것과 1억 건 전체를 훑는 것은 완전히 다른 일인데, 테스트에서는 둘 다 통과한다
  • 정렬·집계처럼 양에 비례해 무거워지는 연산은 작은 데이터에서 거의 공짜로 보인다

그래서 부하 테스트에서 놓쳤다가 나중에 터지는 쿼리의 상당수가 처음부터 잘못 짠 쿼리다. 잘못된 게 그때는 안 아팠을 뿐이다.

대응은 명확하지만 손이 간다. 테스트 데이터를 실제 규모에 맞춰 부풀린다. 다 채울 수 없으면 최소한 가장 큰 테이블 한둘만이라도 실제 크기에 맞춘다. 여기서 다시 어디서 거느냐가 숫자를 정한다는 이야기가 값을 한다 - 다 못 맞추면 무엇이 다른지를 조건으로 적어 둔다.

목으로 세운 것은 절대 안 느려진다

외부 결제사, 배송 조회, 알림 발송. 부하 테스트에서 이것들을 실제로 호출할 수는 없다. 남의 시스템에 우리 부하를 걸 수는 없기 때문이다.

그래서 가짜로 세운다. 그리고 가짜는 항상 1밀리초 안에 성공한다.

실제로는 그 호출이 200밀리초 걸리고, 가끔 3초 걸리고, 어떤 날은 아예 응답을 안 한다. 그 시간 동안 우리 쪽 스레드는 잡혀 있다. 외부 하나가 느려지면 우리 서버의 처리량이 통째로 떨어지는데, 목을 세운 테스트에서는 이 연결이 아예 존재하지 않는다.

이건 완전히 없앨 수 없는 한계다. 대신 줄일 수는 있다.

  • 가짜에 실제 지연을 넣는다. 평소 걸리는 시간만큼 일부러 기다리게 한다. 이 한 줄만으로도 결과가 크게 달라진다
  • 느려지는 경우를 따로 건다. 외부 응답을 5초로 고정해 놓고 걸면, 우리 쪽이 그때 무엇을 하는지가 드러난다
  • 타임아웃이 실제로 걸리는지 본다. 설정만 해 두고 안 먹는 경우가 흔하다

통과한 뒤에 배포한 코드

마지막 자리는 가장 단순하다. 부하 테스트 결과는 잰 그 순간의 코드에 대한 것이다.

그 뒤로 배포가 스무 번 나갔다면, 그 결과지는 이제 존재하지 않는 시스템을 설명하고 있다. 그런데 문서에는 여전히 “초당 800건 처리 확인”이라고 적혀 있고, 아무도 그 날짜를 안 본다.

성능이 나빠지는 변경은 대개 티가 안 난다. 조회 하나를 반복문 안으로 옮기거나, 인덱스를 안 타는 조건이 하나 붙거나, 응답에 필드가 몇 개 늘어난다. 기능은 멀쩡히 동작하니 리뷰에서도 안 걸린다. 부하를 걸어 보기 전까지는 아무도 모른다.

그래서 한 번 잘 재는 것보다 같은 조건으로 반복해서 재는 것이 오래 간다. 절대값이 아니라 지난번 대비 얼마나 나빠졌나를 보는 것이고, 이건 자동으로 돌 때만 실제로 지켜진다.

여기서 다리 비유가 다시 깨진다

이 비유를 여기까지 끌고 왔는데, 마지막에 안 맞는 자리가 둘 남는다.

다리는 무너지기 전에 균열이 보인다. 정기 점검에서 눈으로 찾을 수 있고, 못 찾아도 소리가 나고 처짐이 생긴다. 서버는 그런 게 없다. 지표를 안 남겨 두면 아무 신호 없이 죽는다. 메모리가 3주 동안 차오르는 동안 화면에는 아무 일도 안 일어난다.

그리고 다리에 오르는 하중은 예측 가능하다. 차량 무게는 법으로 정해져 있고 통행량은 통계로 잡힌다. 서비스 트래픽은 하루아침에 백 배가 될 수 있다. 그래서 다리는 “설계 하중 안에서 쓴다”가 성립하지만 서버는 설계 하중을 넘는 날이 온다는 전제로 만들어야 한다.

이 둘이 같은 결론을 가리킨다. 미리 재는 것만으로는 부족하고, 도는 동안 계속 봐야 한다.

실무에서: 미리 재는 것과 계속 재는 것을 같이 둔다

그래서 부하 테스트의 자리는 “이걸로 안전을 확인한다”가 아니다. 두 가지를 짝으로 두는 것이다.

부하 테스트관측
언제배포 전, 정해진 조건에서도는 내내
무엇을아직 안 온 부하실제로 온 부하
강점사고 전에 안다조건이 진짜다
약점조건이 가짜다이미 겪은 뒤다

약점이 정확히 서로 반대다. 그래서 하나로 다른 하나를 대신할 수 없다. 관측가능성이 붙어 있으면 부하 테스트가 못 본 것이 프로덕션에서 신호로 먼저 나타난다. 반대로 부하 테스트가 있으면 그 신호를 사용자가 아니라 우리가 먼저 만든다.

실제로 붙이는 순서는 이렇다.

  • 관측을 먼저 붙인다. 부하 테스트를 아무리 잘 짜도 안 보이는 게 남는데, 관측이 없으면 그게 나타나도 모른다
  • 부하 테스트에서 본 한계를 알림 기준으로 옮긴다. 갈라지는 지점이 초당 700건이었다면 500건에서 알림을 건다. 재 놓고 안 옮기면 그 숫자는 문서에만 남는다
  • 터진 뒤에는 시나리오를 늘린다. 사고 하나가 곧 “다음부터 걸어 볼 부하” 하나다. 이렇게 해야 테스트가 실제 사용자를 따라간다

정리

  • 부하 테스트 결과는 **“안전하다”가 아니라 “이 조건에서 여기까지는 됐다”**로 읽는다.
  • 시간이 가장 크게 벌어진다. 누수·디스크처럼 몇 주에 걸쳐 자라는 것은 30분 안에서 완벽하게 정상으로 보인다.
  • 사용자는 고르게 오지 않는다. 시간·대상·만료 시점에 몰리는데, 고른 부하로는 이게 안 만들어진다.
  • 데이터 크기는 성능을 비례해 나쁘게 하는 게 아니라 성질을 바꾼다. 작은 데이터에서는 잘못 짠 쿼리도 통과한다.
  • 목으로 세운 외부 의존은 절대 안 느려진다. 최소한 실제 지연만큼은 넣어 둔다.
  • 결과는 잰 그 시점 코드의 것이다. 한 번 잘 재는 것보다 같은 조건으로 반복해 재는 것이 오래 간다.
  • 미리 재는 것과 도는 동안 계속 보는 것은 약점이 정반대라 서로를 대신하지 못한다. 둘을 짝으로 둔다.