이번 포스팅에선 사용자 경험 향상을 위해 응답 속도와 TPS를 높일 수 있는 방법을 알아보겠습니다.
응답 속도가 느린 이유
이전의 부하 테스트 결과 지표에서 필요한 부분을 가져왔습니다.
부하 테스트 결과 (트래픽 10배 늘었을 때를 가정)
라벨 | 표본 수 | 평균 | 최소값 | 최대값 | 표준편차 | 오류 % | 처리량 |
생성 가능 일자 조회 (데일리 리포트) |
5000 | 351 | 17 | 500 | 48.13 | 96.340% | 423.51347 |
생성 가능 일자 조회 (위클리 리포트) |
5000 | 348 | 327 | 483 | 19.76 | 100.000% | 421.86973 |
데일리 리포트 조회 | 5000 | 359 | 327 | 579 | 40.92 | 100.000% | 427.31390 |
총계 | 15000 | 353 | 17 | 579 | 38.50 | 98.780% | 283.02955 |
평균 응답 속도는 351ms로 목표치인 `300ms` 보다 51ms 정도 초과했습니다.
전체 지표를 보면 첫 번째 API에 대한 5000개의 요청 중 `4%`인 200건만 `300ms 이내`에 응답을 했고, 나머지 `96%`에 해당하는 4800건은 `300ms를 초과`한 불안정한 수치를 보여줍니다.
심지어 나머지 API에 대한 응답은 모두 `300ms`를 초과합니다.
스파이크 테스트에선 어땠을까요?
스파이크 테스트 결과 (1초 내에 10배의 트래픽이 몰렸을 때를 가정)
라벨 | 표본 수 | 평균 | 최소값 | 최대값 | 표준편차 | 오류 % | 처리량 |
생성 가능 일자 조회 (데일리 리포트) |
1000 | 1605 | 51 | 2719 | 849.00 | 57.600% | 283.28612 |
생성 가능 일자 조회 (위클리 리포트) |
1000 | 1121 | 0 | 2148 | 971.76 | 94.000% | 180.31013 |
데일리 리포트 조회 | 1000 | 1467 | 0 | 2150 | 897.93 | 98.900% | 132.81976 |
총계 | 3000 | 1397 | 0 | 2719 | 930.17 | 83.500% | 393.28789 |
급격하게 트래픽이 몰렸을 때의 목표치는 `2000ms`입니다.
스파이크 테스트에서도 평균 응답 속도가 목표치보다 적게 나왔습니다.
하지만 전체 지표를 보면 오류율이 상당히 높고, 처리량도 부하 테스트 때보다 떨어지는 것을 볼 수 있습니다.
심지어 최솟값이 0인 것을 보면 서버에 응답이 제대로 전달되지 않았을 가능성이 높습니다.
Grafana 대시보드 확인하기
부하 테스트와 스파이크 테스트 모두 대부분의 요청이 응답 시간이 느린 원인을 알아보기 위해 Grafana 대시보드를 확인해 보겠습니다.
MySQL의 기본 설정값에 따른 최대 커넥션 개수는 150개입니다.
이 중 Spring 애플리케이션은 HikariCP 기본값에 따라 커넥션 풀로 10개를 가져갑니다.
커넥션 풀의 사이즈가 10일 때 HikariCP에서 커넥션을 획득하기 위해 잠시 대기한 요청은 117건으로 확인됩니다.
커넥션 획득을 위해 대기는 얼마나 걸렸을까요?
핀포인트를 통해 확인한 수많은 트랜잭션 가운데 가장 느린 응답 속도를 봤습니다.
응답 시간은 무려 `32초`였는데요, 그 이유를 보면 커넥션 획득을 하는데 `29초`가 걸렸기 때문입니다.
HikariCP의 커넥션 타임아웃 기본값이 30초인데 1초만 더 넘었더라면 커넥션 타임아웃이 발생했을 겁니다.
커넥션을 획득한 후 사용한 시간은 얼마였을까요?
Grafana를 통해 확인한 결과로는 `50ms` 정도로 보입니다.
핀포인트로 확인한 가장 느린 트랜잭션에서는 `107ms` 였습니다.
Connection Acquire Time 지표는 HikariCP가 커넥션을 획득하는 데 걸리는 평균 시간을 나타냅니다.
(Prometheus 메트릭에서 `hikaricp_connections_acquire_seconds_sum / hikaricp_connections_acquire_seconds_count` 값을 사용합니다. 즉, `커넥션을 획득하는 데 걸린 시간의 합 / 커넥션 획득한 개수`)
확인해 보니 `평균 790ms` 정도로 측정됐는데, 평소에는 매우 짧은 시간은 `1~10ms` 이내가 나옵니다.
여기까지만 봐도 커넥션 풀 고갈 문제일 가능성이 높다고 생각됩니다.
이번엔 Spring 애플리케이션이 띄워지는 톰캣 WAS의 스레드 상태를 보겠습니다.
0시 6분 45초부터로 급격히 활성화된 스레드 수가 증가하다 0시 7분쯤부터 최대 개수인 200개에 도달한 모습입니다.
톰캣 스레드의 최대 개수는 기본값이 200개이며, 201번째 요청부터는 대기 큐에 쌓입니다.
대기 큐에 들어갈 수 있는 요청의 개수는 기본값으로 100개이며, 이후 요청부터는 요청에 대해 거부를 합니다. (503 Service Unavailable)
참고로 톰캣 스레드의 커넥션 타임아웃 시간은 기본값으로 20초입니다.
그래프 상으로 얼마나 대기가 되었는지 확인이 되지 않지만 JMeter 결과에는 503 에러 응답을 받진 않은걸 보니 요청은 전부 처리된 것으로 파악할 수 있습니다.
TPS가 예상보다 낮은 이유
자연스러운 상황에선 사용자 수가 늘어나면 TPS는 증가합니다.
그 이유를 알기 위해 리틀의 법칙(Little's Law)을 잠깐 보겠습니다.
리틀의 법칙(Little's Law)과 성능 테스트
성능 테스트에 대해 알아가다 보면 리틀의 법칙을 많이 볼 수 있는데요,
John Little 이 제안한 정리로, 정상 시스템에서 장기 평균 고객 수
L은 장기 평균 유효 도착률 λ 에 고객이 시스템에서 소비하는 평균 시간 W를 곱한 값과 같다
위키피디아의 정의는 이렇다고 합니다.
대수적으로 표현한 수식을 봅시다.
L = λ× W
L - Average number of items within the system
λ- Average arrival rate of items into and out of system
W - Average time an item spends in the system
출처: https://kanbanzone.com/resources/lean/littles-law/
리틀의 법칙에서 L은 시스템 내에 있는 항목들의 평균 수, λ(람다)는 시스템 내에 도착해서 나가기까지 평균 속도(처리량), W는 시스템 내에서 소모하는 평균 시간(대기 시간)을 뜻합니다.
쉽게 이해하기 위해 카페에 빗대어 설명하면,
- L은 커피를 마시러 카페에 오는 고객의 수
- λ(람다)는 커피를 마시고 나가는데 걸리는 평균 시간('시간 단위당 처리량'이라고도 표현합니다.)
- W는 커피를 마시기 위해 주문에 걸리는 대기 시간입니다.
이를 성능 테스트에서는 아래처럼 표현합니다.
- L은 동시 사용자 수(Concurrent User, Vitual User)
- 동시 사용자 수(또는 가상 사용자 수)에는 `활성 사용자(Active Users)`와 `비활성 사용자(Inactive Users)`가 있습니다.
`활성 사용자`는 요청을 보내고 응답을 기다리는 사용자를 말합니다.
`비활성 사용자`는 요청에 대한 응답을 받고 화면을 보고 있거나 다른 요청을 보내기 전까지 대기하는 사용자를 말합니다.
- 동시 사용자 수(또는 가상 사용자 수)에는 `활성 사용자(Active Users)`와 `비활성 사용자(Inactive Users)`가 있습니다.
- λ(람다)는 처리량(TPS; 초당 트랜잭션의 수)
- 초당 처리 건수를 의미합니다.
- w는 처리되기 위해 대기하는 시간(Average response time + think time)입니다. (위의 λ(람다)와 같은 시간 단위를 사용합니다.)
- 여기서 think time은 사용자가 응답을 받자마자 다른 요청을 보내지 않고 잠시 페이지에 머물다가 다른 요청을 보내듯이,
머무는 시간을 의미합니다.
- 여기서 think time은 사용자가 응답을 받자마자 다른 요청을 보내지 않고 잠시 페이지에 머물다가 다른 요청을 보내듯이,
정리하면,
Virtual User = TPS × (Average response time + Think Time)
이 수식을 변형하면 아래와 같은 수식이 됩니다.
TPS = Virtual User ÷ (Average response time + Think time)
따라서 TPS는 사용자 수에 비례하고 응답 속도에 반비례하다는 것을 알 수 있습니다.
즉, 사용자 수가 많을수록 TPS는 증가하는 것은 당연하다는 의미입니다.
응답 속도가 낮을수록 TPS가 증가하는 것도 직관적으로 알 수 있습니다.
하지만 TPS는 일정 사용자 수까지는 증가하다 특정 사용자 수부터는 한 값에 수렴하는 현상을 보입니다.
그 이유로는 시스템 처리에 한계가 있기 때문인데요.
예를 들면 톰캣 스레드의 수, 데이터베이스의 커넥션 풀 사이즈, 네트워크 지연 등 여러 요소가 있기 때문입니다.
위의 그림은 머무르는 시간이 없고(=Think Time), 활성 유저만 존재할 때 사용자 수가 증가함에 따른 TPS를 설명합니다.
- Virtual User가 증가하면서 경부하 구간(Light Load Zone)까지는 로그 함수 형태로 TPS가 증가합니다.
- 그리고 중부하 구간(Heavy Load Zone)으로 넘어가면 TPS가 서서히 일정 값에 수렴하는 것을 볼 수 있습니다.
- 이후 경합 구간(Buckle Zone)에선 시스템 내에서 자원의 경쟁이 심해져 오히려 TPS가 감소하는 것을 나타냅니다.
경부하 구간에서 중부하 구간에 진입하는 지점을 포화 지점(Saturation Point)라고 하고 이때의 사용자 수를 포화 사용자 수(Saturated Users)라고 합니다.
사용자 수의 증가가 더 이상 TPS에 비례하지 않고, 경합 구간에 이르면 오히려 TPS가 감소하기까지 합니다.
따라서 예측 사용자 수는 1,000명으로 변함이 없을 때 TPS를 증가시키려면 응답 속도를 더 낮추는 방법으로 해결해야 합니다.
이제까지 정리한 리틀의 법칙에 따르면 TPS는 응답 속도에 반비례하기 때문이죠.
TPS를 증가시키는 방법들
이제 응답 속도를 낮춰야 고정된 예측 사용자 수에 대한 TPS를 증가시킬 수 있다는 것을 알았습니다.
응답 속도를 낮추기 위해서 어떤 작업을 해야 할까요?
위에서 확인한 문제들을 나열해 보겠습니다.
- WAS: 톰캣 최대 스레드 수를 초과한 요청은 대기 큐에 들어가 대기한다.
- Database: HikariCP 커넥션 풀이 고갈되면 이후 요청들은 커넥션을 획득하기 위해 대기한다.
이 문제들을 해결하기 위한 방법은 무엇이 있을까요?
(1) 톰캣 스레드 풀 최대 사이즈 증가
톰캣 스레드 풀의 사이즈를 100개 정도 늘렸다고 가정해 보겠습니다.
HTTP 요청이 대기 큐에 들어가지 않고 즉시 Spring에 전달되면 문제가 해결될까요?
문제를 해결하기 위해선 병목 지점이 어디인지를 먼저 봐야 합니다.
예시를 들 때 톰캣 스레드 풀 문제와 데이터베이스 커넥션 풀 문제를 제외한 Spring 애플리케이션 내 다른 문제는 없다고 가정하겠습니다.
그림을 보겠습니다.
데이터베이스에 병목이 없을 때
데이터베이스 커넥션을 획득하는데 병목이 없다면 스레드 풀을 늘리는 것이 효과적일 수 있습니다.
대기 큐(Request Queue)에서 대기하는 HTTP 요청이 그만큼 적어지기 때문에 응답 시간이 낮아질 수 있습니다.
다만, 트래픽이 일관되게 많지 않다면 idle 스레드 개수가 그만큼 많을 것이고, 메모리가 그만큼 낭비될 수 있습니다.
데이터베이스에 병목이 있을 때
데이터베이스에 병목이 있다면 스레드 풀의 사이즈가 커져도 데이터베이스 커넥션 획득을 위해 대기하는 시간이 여전히 존재합니다.
즉, 데이터베이스 병목이 해결되지 않으면 응답 시간이 줄어들 수 없기 때문에 TPS도 증가하기 어렵습니다.
정리
일반 상태에선 전체 TPS는 TPS가 가장 낮은 지점의 영향을 받습니다. (Critical Path)
WAS와 DB가 가장 낮은 1000 TPS이기 때문에 전체 처리량도 이를 넘을 수 없습니다.
위의 상태에서 WAS의 TPS를 1000에서 1500으로 증가시켰습니다.
그럼에도 여전히 DB에서 1000 TPS만큼 처리합니다. (병목 지점)
따라서 전체 처리량도 변함이 없습니다.
이번엔 DB의 처리량도 1000에서 1500으로 증가시켰습니다.
시스템에서 가장 낮은 지점은 1500 TPS가 되었기 때문에 전체 처리량도 증가합니다.
만약 WAS와 DB 모두 2500 TPS로 증가시켰다면, 시스템의 최저 처리량은 Spring인 2000 TPS에 영향을 받기 때문에 전체 처리량은 2000 이 한계가 됩니다.
따라서 문제를 해결할 때는 병목 지점이 어디인지 파악하는 것이 중요함을 알 수 있었습니다.
(2) HikariCP 커넥션 풀의 크기를 조정
동시에 처리할 수 있는 요청 수가 데이터베이스 커넥션 풀 크기에 의해 제한되기 때문에 톰캣 스레드 풀의 개수보다 먼저 HikariCP 커넥션 풀의 크기를 조정해야 병목이 해소된다는 것을 알았습니다.
그렇다면 커넥션 풀의 크기를 조정할 때 무엇을 고려해야 할까요?
데이터베이스의 최대 커넥션 개수를 고려해야 한다.
커넥션 풀의 크기를 조절할 때는 시스템 전체에서 사용하는 데이터베이스의 커넥션 수를 먼저 고려해야 합니다.
MySQL의 최대 커넥션 수를 기본값인 `150개`를 변경하지 않는다고 가정하고 예시를 들어보겠습니다.
예를 들어, 각 애플리케이션의 커넥션 풀 크기가 `10`이라고 했을 때 `일반 서버 10개`, `관리자 전용 서버 2개`, `기타 관리 애플리케이션 3개`라면 시스템 전체에서 (10 + 2 + 3) * 10 = 150개의 커넥션을 사용합니다.
이 상황이 되면 추가적인 서버 증설은 MySQL의 최대 커넥션 수를 조절하지 않고서는 연결 실패나 성능 저하가 발생할 수 있습니다.
현재 시스템에서는 MySQL와 연동하는 애플리케이션은 `일반 서버 1개`와 `관리자 전용 서버 1개`가 있습니다.
따라서 데이터베이스의 최대 커넥션 개수를 고려했을 때 일반 서버의 커넥션 풀 사이즈를 5배 정도 늘려도 문제 되진 않을 것으로 보입니다.
적정한 커넥션 풀의 크기를 정하는 방법
하지만 커넥션 풀의 크기를 무작정 늘린다고 좋은 것은 아닙니다.
그 이유를 설명하기 위해 HikariCP 공식 GitHub에 있는 내용을 가져왔습니다.
https://github.com/brettwooldridge/HikariCP/wiki/About-Pool-Sizing
커넥션 풀 크기의 동적 변화는 성능에 불리하다는 내용입니다.
세 줄 요약하면,
- CPU 코어가 1개라도 수십, 수백 개의 연결을 `"동시에"` 하는 것은 가능합니다. 다만, 이는 CPU가 `시분할 시스템`에 의해 동작하는 눈속임일 뿐입니다. 코어 1개당 1개의 스레드만 실행할 수 있기 때문에 A, B가 `"동시에"` 주어졌을 때 순차대로 실행하는 것이 시분할로 실행하는 것보다 `"항상"` 더 빠릅니다. 스레드 수가 코어 수보다 많아지면 빨라지지 않고 더 느려집니다.
- 디스크는 플래터에서 데이터를 `"읽기/쓰기"` 작업을 하는 동안 I/O 대기를 합니다. 즉, 쿼리 수행을 위해 연결된 스레드는 디스크를 기다리며 `"blocking"`됩니다. `"blocking"`된 동안 OS는 다른 스레드의 코드를 실행하지만 여전히 디스크에 `"읽기/쓰기"` 작업을 해야 하는 스레드는 앞서 차단된 스레드가 끝날 때까지 기다려야 합니다.
그리고 SSD는 더 빠르고, 플래터 탐색이나 회전이 없다고 해서 더 많은 스레드를 사용할 수 있는 것은 아닙니다. 오히려 정반대로 `"blocking"`이 적기 때문에 CPU 코어 개수에 근접한 더 적은 스레드를 사용하는 것이 더 많은 스레드를 사용하는 것보다 성능이 더 좋습니다. - 네트워크 문제도 있습니다. 전송할 데이터가 많으면 송신/수신 버퍼가 가득 차 네트워크 지연이 발생할 수 있습니다.
커넥션 풀 크기 공식
가이드에 따르면 커넥션 풀의 크기를 설정하는 공식이 있습니다.
connections = ((core_count * 2) + effective_spindle_count)
effective_spindle_count: 데이터베이스가 물리적으로 분산된 디스크(스핀들) 개수. 현대 SSD에서는 일반적으로 1로 간주됩니다.
이 수식에 의하면 2 코어 CPU와 SSD를 사용하는 환경에선 (2 * 2) + 1 = 5개의 커넥션 풀 크기가 권장됩니다.
4 코어 CPU라면 (4 * 2) + 1 = 9개의 커넥션 풀 크기가 권장 되고, 가이드에선 반올림해서 10개 정도로 설정한 벤치마크 결과를 보여줬죠.
또한, stackoverflow 글에 의하면 `스파이크 트래픽 상황`에서 새로운 커넥션을 획득하려고 하면 서버에 더 많은 부담이 가기 때문에 역효과가 있다고 합니다. 실제로 HikariCP는 부하량에 맞춰서 커넥션 풀을 점진적으로 늘리기 때문에 고정된 풀 사이즈를 사용해야 예측 가능한 성능을 제공할 수 있습니다.
현재 서버는 2 코어 CPU, 8GB 메모리를 사용 중입니다.
따라서 HikariCP에서 권장하는 커넥션 풀의 크기는 2 * 2 + 1 = `5개`일 것입니다.
(3) 캐시 사용
캐시를 사용해서 데이터베이스 접근을 최소화하는 방법으로 해결하는 방법입니다.
사용자의 조회 한 번으로 응답이 캐시 되면, 이후 동일한 요청에 대해선 `캐시 히트`로 인해 데이터베이스 커넥션을 사용하지 않는 원리입니다.
캐시는 크게 3가지로 분류할 수 있는데 하나씩 살펴보겠습니다.
로컬 캐시
애플리케이션의 메모리에 데이터를 저장하는 방법입니다.
이 방식은 요청을 받은 서버의 메모리를 활용하기 때문에 속도가 빠르지만, 분산 환경에서 캐시 된 데이터의 정합성이 요구될 때는 사용하기 적합하지 않습니다.
서버 내부에서 동작하기 때문에 추가적인 네트워크 통신이 필요하지 않는다는 장점이 있는데요,
결국 서버 내부의 자원을 활용하기 때문에 과도한 메모리를 사용하지 않도록 관리가 필요한 부분도 있습니다.
글로벌 캐시
Redis, Memcached 같이 분산 캐시 시스템을 활용해 데이터를 저장하는 방법입니다.
분산 환경에서 여러 서버가 동일한 캐시 데이터를 전달받기 때문에 데이터 정합성이 요구될 때 사용하기 적합하다고 할 수 있습니다.
글로벌 캐시는 로컬 캐시와 반대로 서버 외부에서 동작하기 때문에 추가적인 네트워크 통신이 필요할 수 있다는 단점이 있는데,
반대로 서버 외부의 자원을 사용하기 때문에 더 많은 캐시가 가능하다는 장점이 있습니다.
로컬 캐시 + 글로벌 캐시
로컬 캐시와 글로벌 캐시의 장점들을 모은 방식입니다.
정합성이 필요한 캐시 데이터를 글로벌 캐시에 저장하고, 이렇게 저장된 캐시 데이터를 다시 로컬 캐시에 저장하는 방식입니다.
이렇게 하면 로컬 캐시를 이용해 빠른 응답 속도를 제공할 수 있으면서, 글로벌 캐시를 통해 정합성 문제도 해결할 수 있습니다.
다만, 로컬 캐시와 글로벌 캐시 모두 사용하기 때문에 서버 내부의 메모리 사용량 관리에서 그렇게 자유롭지는 않습니다.
또, 두 가지 캐시 계층을 모두 관리해야 해서 로직이 복잡해질 수 있는 단점도 있습니다.
캐시 무효화
캐시를 사용할 땐 캐시 무효화 정책을 잘 만들어야 합니다.
예를 들어, 매일 자정이 됐을 때 응답받아야 하는 데이터가 바뀌어야 한다면 자정이 되기 전에 캐시 무효화가 돼야 합니다.
다음 포스팅에선 각 해결 방법을 적용한 내용들을 다뤄보겠습니다.
긴 글 읽어주셔서 감사합니다.
참고
'Project' 카테고리의 다른 글
[포텐데이 409-1pick] 신규 분석 기능 성능 테스트 (3) (1) | 2024.12.08 |
---|---|
[포텐데이 409-1pick] 신규 분석 기능 성능 테스트 (1) (1) | 2024.12.05 |
[포텐데이 409-1pick] 분석 서비스 개발 중 만난 데드락과 동시성 문제 해결기 (5) (0) | 2024.11.28 |
올려 올려 라디오 NCloud 활용 후기 (2) | 2024.11.27 |
[포텐데이 409-1pick] 분석 서비스 개발 중 만난 데드락과 동시성 문제 해결기 (4) (2) | 2024.11.27 |