사전 성능 회귀
개념

성능 회귀

gabury1고친 사람 github-actions[bot]

성능 회귀는 코드나 설정을 바꾼 뒤에 같은 일이 전보다 느려지는 일입니다. 기능은 여전히 맞게 돌아가서 테스트를 통과한 채로 배포되기 쉽습니다. 그래서 바꾸기 전에 재 둔 값과 비교해야 드러납니다.

쉽고 빠른 이해

성능 회귀는 바꾼 뒤에 전보다 느려지거나 자원을 더 먹게 되는 일입니다. 어제까지 0.1초에 끝나던 주문 조회가 새 코드를 올린 뒤 0.3초씩 걸리는 경우가 그렇습니다.

따로 챙겨야 하는 까닭은 기능 테스트가 이걸 못 잡기 때문입니다. 답이 맞으면 테스트는 통과합니다. 느려진 코드도 함께 사용자에게 갑니다.

어떻게 잡나:

  1. 바꾸기 전에 성능을 재서 기준선(바꾸기 전에 재 둔 값)으로 남깁니다
  2. 바꾼 뒤 같은 조건에서 다시 잽니다
  3. 둘의 차이가 미리 정한 폭을 넘으면 회귀로 봅니다

대가도 있습니다. 잴 때마다 값이 조금씩 흔들립니다. 폭을 좁게 잡으면 헛경보가 잦습니다. 넓게 잡으면 작은 회귀가 빠져나갑니다.

상세

날마다 같은 길로 출근하는 사람을 떠올려 봅시다. 어느 날 길 하나가 막혀 돌아가게 되어도 회사에는 도착합니다. 평소 걸린 시간을 적어 두지 않았다면 출근이 십 분 늦어진 것을 알아채기 어렵습니다.

소프트웨어에서 회귀는 바꾸기 전에 되던 것이 바꾼 뒤에 나빠지는 일을 말합니다. 저장 버튼을 눌러도 저장이 안 되는 것처럼 기능이 깨지면 기능 회귀입니다. 기능은 멀쩡한데 속도나 자원 사용이 나빠지면 성능 회귀입니다.

통계에서 쓰는 회귀 분석과는 이름만 같고 뜻이 다릅니다. 이 편의 회귀는 「뒤로 물러난다」는 본래 뜻에 가깝습니다.

나빠지는 지표

성능 회귀는 느려지는 것만 뜻하지 않습니다. 같은 일을 하는 데 드는 시간이나 자원 가운데 무엇이든 늘면 회귀입니다. 백엔드에서 자주 보는 지표를 표로 모았습니다.

지표 나빠진 모습
응답 시간 요청 하나에 답하기까지 걸리는 시간이 늘어난다
처리량 같은 시간에 끝내는 요청 수가 줄어든다
CPU(Central Processing Unit, 중앙처리장치) 사용률 같은 양의 요청을 처리하는 데 CPU 를 더 쓴다
메모리 사용량 같은 일을 하는 데 메모리를 더 차지한다
쿼리 수 요청 하나가 데이터베이스에 보내는 쿼리가 늘어난다
시작 시간 서버가 떠서 요청을 받기까지 더 오래 걸린다

응답 시간은 평균만 보면 회귀를 놓치기 쉽습니다. 요청 대부분은 전과 같고 일부만 크게 느려지면 평균은 조금밖에 안 움직입니다.

그래서 백분위수로도 봅니다. 백분위수는 값을 작은 것부터 줄 세웠을 때 어느 순번에 오는 값인지를 말합니다. 요청 백 개를 걸린 시간이 짧은 순으로 세웠을 때 99번째 요청이 걸린 시간이 99번째 백분위수입니다.

이 값은 오래 걸린 쪽 끝을 보여 줍니다. 그 끝에 선 요청들이 겪는 지연을 꼬리 지연이라고 부릅니다. 일부 요청만 느려지는 회귀는 평균보다 이 값에서 먼저 보입니다.

기능 테스트를 빠져나가는 까닭

단위 테스트 같은 기능 테스트는 결과가 맞는지를 봅니다. 목록 조회가 맞는 목록을 돌려주면 통과합니다. 그 목록을 만드는 데 0.1초가 걸렸는지 1초가 걸렸는지는 묻지 않습니다.

테스트 환경의 데이터가 작은 것도 한몫합니다. 행이 열 개뿐인 표에서는 오래 걸릴 코드도 금방 끝납니다. 운영 데이터가 수백만 행으로 불어난 뒤에야 차이가 드러납니다.

작은 회귀는 쌓입니다. 변경 하나가 조금만 느리게 만들면 한 번은 눈에 안 띕니다. 1.05배씩 느려지는 변경이 열 번 쌓이면 1.05를 열 번 곱한 1.6배를 넘깁니다. 쌓인 뒤에는 어느 변경이 얼마를 보탰는지 가리기 어렵습니다.

회귀를 부르는 흔한 변경

성능 회귀의 원인은 하나로 정해져 있지 않습니다. 대개는 기능을 고치려던 변경이 곁가지로 속도를 깎습니다. 백엔드에서 자주 보이는 변경을 표로 모았습니다.

변경 느려지는 까닭
반복문 안에 조회를 넣는다 목록 N건마다 쿼리가 한 번씩 더 나간다. N+1 문제다
쿼리 조건을 바꾸거나 인덱스를 지운다 인덱스로 바로 찾던 행을 표 전체를 훑어 찾는다
자료구조를 바꾼다 한 번에 찾던 값을 처음부터 하나씩 비교해 찾는다
라이브러리 버전을 올린다 안쪽 구현이 바뀌어 같은 호출이 더 많은 일을 한다
로그나 검사를 더한다 요청마다 하는 일이 늘어난다
캐시 설정을 바꾼다 캐시에서 찾지 못해 원본까지 가는 요청이 는다

자료구조를 바꾼 경우를 코드로 보겠습니다. 파이썬에서 어떤 값이 컬렉션 안에 있는지 물을 때, set 은 해시테이블로 만들어져 있어 바로 찾습니다. list 는 앞에서부터 하나씩 비교합니다.

Python
ids = list(range(1_000_000))
s = set(ids)

999_999 in s    # True · 해시로 바로
999_999 in ids  # True · 백만 번 비교

두 줄은 같은 답을 냅니다. 그래서 기능 테스트는 둘을 가리지 못합니다. 누군가 set 을 list 로 바꿔도 테스트는 통과합니다. 컬렉션이 커진 운영 환경에서야 느려진 것이 보입니다.

기준선과 비교

회귀인지 아닌지는 비교할 값이 있어야 가릴 수 있습니다. 바꾸기 전에 재 둔 값을 성능 기준선이라고 부릅니다. 줄여서 기준선이라고도 합니다.

기준선과 새 값은 같은 조건에서 재야 비교할 수 있습니다. 기계, 데이터의 양, 들어오는 요청의 양과 종류가 같아야 합니다. 조건이 다르면 차이가 코드 탓인지 환경 탓인지 모릅니다.

같은 코드를 같은 조건에서 재도 값은 매번 조금씩 다릅니다. 같은 기계의 다른 프로그램이 CPU 를 나눠 쓰기도 합니다. 캐시가 이미 차 있는지에 따라서도 달라집니다. 그래서 한 번 잰 값끼리 비교하지 않습니다. 여러 번 재서 중앙값끼리 비교합니다.

흔들림을 회귀로 잘못 잡지 않으려고 허용 폭을 미리 정해 둡니다. 차이가 그 폭 안이면 흔들림으로 보고 넘깁니다. 폭을 넘으면 한 번 더 재 봅니다. 그래도 넘으면 회귀로 표시합니다.

flowchart TD
    A["코드를 바꾼다"] --> B["같은 조건에서 여러 번 잰다"]
    B --> C{"기준선과의 차이가<br/>허용 폭을 넘나"}
    C -->|"넘지 않는다"| D["흔들림으로 보고 통과"]
    C -->|"넘는다"| E{"한 번 더 재도 넘나"}
    E -->|"넘지 않는다"| D
    E -->|"넘는다"| F["성능 회귀로 표시"]

허용 폭은 좁게 잡아도 넓게 잡아도 잃는 것이 있습니다. 좁으면 흔들림까지 회귀로 잡아 헛경보가 잦아집니다. 넓으면 작은 회귀가 빠져나가 앞에서 본 것처럼 쌓입니다. 그래서 폭은 그 지표가 평소 얼마나 흔들리는지를 보고 정합니다.

회귀를 잡는 단계

성능 회귀는 코드를 쓸 때부터 운영에 올린 뒤까지 여러 단계에서 잡을 수 있습니다. 앞 단계에서 잡을수록 의심할 변경이 적어 원인을 찾기 쉽습니다. 대신 운영과 조건이 달라 놓치는 것이 많습니다.

변경을 올릴 때마다 빌드와 테스트를 자동으로 돌리는 방식을 지속적 통합(CI, Continuous Integration)이라고 합니다. 성능을 재는 시험도 이 흐름에 넣어 변경마다 돌릴 수 있습니다.

단계 쓰는 방법 잘 잡는 것 놓치기 쉬운 것
코드를 쓸 때 함수 하나를 되풀이해 재는 마이크로벤치마크 한 함수의 계산량이 는 것 데이터베이스·네트워크가 끼어 느려진 것
변경을 올릴 때 지속적 통합에서 도는 성능 테스트 어느 변경에서 나빠졌는지 운영만큼 큰 데이터에서만 나는 느려짐
배포할 때 새 버전을 일부 서버에만 올려 옛 버전과 비교하는 카나리 배포 운영 요청에서 생기는 차이 요청이 드문 기능의 느려짐
운영 중 모니터링과 경보 쌓여서 커진 회귀 어느 변경 탓인지

단계마다 놓치는 것이 달라서 한 단계에만 기대지 않습니다. 운영에서 잡힌 회귀는 그 경우를 재는 시험으로 만들어 앞 단계에 더해 둡니다. 같은 회귀가 다시 새지 않게 하려는 것입니다.

찾은 뒤에 하는 일

회귀를 찾으면 먼저 어느 변경이 원인인지 좁힙니다. 괜찮던 버전과 나빠진 버전 사이의 변경을 반으로 나누고 가운데 버전을 잽니다. 가운데 버전이 이미 느리면 원인은 앞쪽 절반에, 아직 빠르면 뒤쪽 절반에 있습니다.

남은 쪽을 다시 반으로 나누기를 되풀이하면 변경이 천 개여도 열 번쯤 재서 하나로 좁혀집니다. Git 에는 이 과정을 돕는 git bisect 명령이 있습니다.

원인 변경을 찾으면 바꾸기 전과 뒤를 각각 프로파일링합니다. 프로파일링은 프로그램이 도는 동안 어느 함수가 시간을 얼마나 썼는지 재는 일입니다. 두 결과를 비교해 시간이 크게 는 함수가 고칠 곳입니다.

당장 고치기 어려우면 그 변경을 먼저 되돌려 운영을 원래 속도로 돌려놓습니다. 배포를 앞 버전으로 되돌리는 이 일을 롤백이라고 합니다. 원인을 고친 뒤 다시 올립니다.

모든 회귀가 고칠 대상은 아닙니다. 비밀번호를 더 안전하게 저장하려고 계산이 더 오래 걸리는 해시 함수로 바꾸면 로그인이 느려집니다. 다른 것을 얻으려고 일부러 치른 느려짐은 팀이 알고 받아들입니다. 그 뒤로는 새 값을 기준선으로 삼아 다음 변경부터 비교합니다.

관련 항목

성능 회귀를 재는 지표

응답 시간 · 처리량 · 지연 · 꼬리 지연 · 백분위수 · 사용률 · 오류율 · 메모리 사용량

성능 회귀를 잡는 시험

성능 테스트 · 부하 테스트 · 스트레스 테스트 · 소크 테스트 · 벤치마크 · 마이크로벤치마크 · 회귀 테스트

회귀 여부를 가르는 기준값

성능 기준선 · 기준선 · 성능 예산 · SLO · 통계적 유의성 · 측정 오차

성능 회귀를 잡는 배포·운영 단계

지속적 통합 · 카나리 배포 · 모니터링 · 경보 · 대시보드 · APM

성능 회귀의 원인을 좁히는 도구

git bisect · 프로파일링 · 프로파일러 · 플레임 그래프 · 트레이스 · 롤백

성능 회귀를 부르는 흔한 원인

N+1 문제 · 인덱스 · 풀 테이블 스캔 · 캐시 적중률 · 메모리 누수 · 락 경합 · 병목

성능 회귀가 속하는 상위 분류

성능 · 성능 효율성 · 성능 튜닝 · QA와 테스트

회귀라는 이름을 나눠 쓰는 용어

회귀 분석 · 기능 회귀 · 회귀 버그

다른 이름: performance regression · 퍼포먼스 리그레션