오랜만입니다.
계속 취준, 학업, 부트캠프까지 병행하다 보니 블로그에 글을 많이 못 올렸는데
매일 300명 이상의 분들이 읽어주셔서 조만간 조회수 30000을 달성하겠네요. 감사합니다 :>
벌써 6월 11일에 시작한 유레카 1기의 수료를 일주일 앞두고 있습니다.
최종 프로젝트에서 훌륭한 백엔드 팀원들에게 많이 배우면서, 짧은 기간 동안 많은 성장이 있었습니다.
데브옵스 역할을 맡아, CI/CD 파이프라인을 구축하고 다양한 툴들을 세팅한 경험도 무척 좋았습니다.
하지만 오늘 글의 주제는, 성능 테스트를 통한 병목지점 최적화 얘기입니다.
연속으로 세 프로젝트에서 성능 테스트를 진행했지만 감히 예상하건대 이번 최적화가 가장 많은 성능개선을 이룬 것 같습니다.
잘 읽어주시면 감사하겠습니다.
성능목표 잡기
앞선 성능 테스트 글에서도, 어떤 성능 테스트를 하더라도 성능 목표를 우선적으로 잡아야 한다고 언급한 적이 있습니다.
이번 최적화 과정에서도 팀원과 논의를 하고,
추가로 유플러스 현업 개발자 분과의 주기적인 멘토링 시간에도 이에 대한 조언을 받아서 성능 목표를 결정했습니다.
처음엔 이전 글에 적혀있는 것처럼,
TTFB (Time To First Byte) 기준으로 평균 응답시간이 3초 이내로 들어오는 것을 목표로 했습니다.
그 근거로는 이전 글에 적혀있는 것처럼 아래의 통계 내용이 있기 때문입니다.
- 주요 통계
- 모바일 웹사이트 방문자의 53%는 웹페이지 로딩에 3초 이상 걸리면 해당 사이트를 떠납니다.
- 평균적으로 모바일 사이트의 로딩 시간은 3G 연결에서 19초, 4G 연결에서 14초입니다.
- 로딩 속도의 영향
- 5초 내에 로딩되는 모바일 사이트는 19초가 걸리는 사이트에 비해 다음과 같은 이점이 있습니다:
- 25% 더 높은 광고 가시성
- 70% 더 긴 평균 세션 시간
- 35% 더 낮은 이탈률
- 페이지 로딩 시간이 1초에서 3초로 증가하면 이탈 확률이 32% 증가합니다.
- 5초 내에 로딩되는 모바일 사이트는 19초가 걸리는 사이트에 비해 다음과 같은 이점이 있습니다:
- 사용자 기대치
- 인터넷 사용자의 약 50%는 웹페이지 로딩 시간이 2초 미만이기를 기대합니다.
- 성능이 좋지 않은 경험을 한 사용자의 약 80%는 해당 페이지를 영구적으로 피하고 다시 방문하지 않습니다.
하지만 실제 현업에 계신 멘토님의 조언을 들은 결과,
유플러스 측에선 목표를 평균 응답시간을 모두 0.1s 내로 받는 것으로 한다고 하셨습니다.
그래서 다소 어려운 목표라고 생각했지만, 저희 역시 평균응답시간 0.1s를 성능 목표로 잡았습니다.
성능테스트 환경 및 방법
환경
APM : Pinpoint
부하테스트 툴 : Postman의 Run collection test 기능(제한적으로 유료)
APM 툴로는 일반적으로 Grafana나, 프로메테우스를 주로 사용합니다.
20년 이상의 경력을 가지신 네이버 출신 멘토님의 다른 툴들에 비해 추천한다는 조언에 Pinpoint를 사용하기로 했습니다.
프로메테우스 + 그라파나 조합과 비교해서 아래와 같은 차이점이 있습니다.
기능 | Pinpoinit | Grafana + Prometheus |
목적 | 애플리케이션 성능 모니터링 및 추적 | 시계열 데이터 시각화 및 메트릭 모니터링 |
분산 추적 | 지원 (트랜잭션 추적) | Prometheus 자체로는 불가 (Jaeger 같은 추가 도구 필요) |
설치 및 설정 | 에이전트 설치만으로 가능 | Prometheus + Grafana 설정 필요 |
실시간 모니터링 | 상세한 트랜잭션 및 리소스 모니터링 | 메트릭 기반 시계열 데이터 모니터링 |
데이터 수집 및 저장 | 자동으로 애플리케이션 메트릭 및 로그 수집 | Prometheus는 메트릭 수집, Grafana는 시각화만 담당 |
개발 언어 | Java, PHP | 언어 제한 없음 |
시스템 분석 | 트랜잭션 병목 파악에 강점 | 시스템 리소스 수준 메트릭 파악에 강점 |
이중 아래와 같은 경우 Pinpoint를 사용하는 것을 더 추천하기에 최종적으로 Pinpoint를 사용하기로 했습니다.
Pinpoint를 사용하면 좋은 경우
- 분산 시스템에서 트랜잭션 단위로 병목을 분석해야 할 때
- Java 또는 PHP 기반 애플리케이션을 사용 중일 때
- 간편한 설치 및 빠른 도입이 필요할 때
다음으로는, 테스트 환경을 고민했습니다.
테스트 환경을 구성하는 방법에 대해서는 세 가지 선택지가 있었습니다.
- 부하 테스트 실행 환경
- (1) AWS에 모든 부하 테스트 구성(부하테스트 툴, Spring 서버, RDS)을 실행.
- 장점: 실제 AWS 환경과 동일한 조건에서 테스트.
- 단점: 높은 비용, 운영 데이터와 테스트 데이터 간 혼란 가능성.
- (2) 로컬에서 Spring 서버 실행 + AWS에 부하테스트 툴, RDS 구성.
- 장점: 비용 절감, 효율적인 협업 가능.
- 단점: 테스트의 일부는 네트워크 환경을 반영하지 못함.
- (3) 로컬에 모든 테스트 환경 구성.
- 장점: 비용 없음, 간단한 설정.
- 단점: 네트워크 및 실제 운영 환경과의 괴리, 테스트 데이터 공유 어려움.
- (1) AWS에 모든 부하 테스트 구성(부하테스트 툴, Spring 서버, RDS)을 실행.
또한 Pinpoint 설치 역시 2가지 방법이 있었습니다.
Pinpoint 배포 위치
- 로컬에 설치.
- 장점: 비용 없음, 간단한 설정.
- 단점: 팀원 간 협업 어려움, 중앙 집중 데이터 수집 불가.
- AWS에 설치.
- 장점: 팀원이 공유할 수 있는 중앙 집중형 모니터링 환경 제공.
- 단점: EC2 및 HBase 리소스 사용으로 비용 발생.
결론적으로, 돈을 유레카에서 지원받고 있는 상황이지만 ECS(Fargate)가 사용량에 비례해서 지불하는 금액이 올라가는 방식이기에 괜히 수십 번 성능테스트 하는 상황에서 예상치 못한 금액이 나올 것 같아 결론적으로 아래와 같이 설정하게 되었습니다.
최종 설계:
- AWS에 부하테스트, Pinpoint, 테스트용 RDS를 구성하고, 각자의 로컬에서 Spring 서버를 실행하여 부하 테스트를 수행
설계의 이유:
- 비용 효율성:
- 로컬에서 Spring 서버를 실행함으로써 ECS(Fargate) 사용 비용을 절감.
- AWS의 공용 리소스(부하테스트, Pinpoint, RDS)는 최소한의 비용으로 유지.
- 협업 및 중앙 관리:
- AWS RDS와 Pinpoint를 통해 중앙에서 데이터를 관리하며, 모든 개발자가 동일한 환경에서 테스트 가능.
- Pinpoint Web을 활용해 실시간 트랜잭션 흐름과 병목 지점 분석 가능.
- 테스트 데이터 분리:
- 테스트용 RDS를 별도로 구성하여 운영 데이터와 테스트 데이터를 철저히 분리.
- 테스트 완료 후 데이터를 초기화하거나 삭제하여 관리 용이.
- 확장 가능성:
- 추후 팀 전체의 Spring 서버를 AWS ECS로 옮기거나, AWS 리소스를 확대하여 더 높은 트래픽 테스트 가능.
AWS에 테스트용 RDS나 다른 세팅을 하는 것은 쉬웠으나, Pinpoint를 세팅하는 것은 매우 매우 매우 오래 걸렸습니다.
하지만 이 과정까지 적으면 너무 글이 길어질 것 같아 넘기겠습니다.
성능테스트는 아래와 같은 절차로 진행했습니다.
성능 테스트 방법
- API 요청 후 핀포인트로 확인
- 병목지점 확인 / 응답시간 확인
- SQL 문 확인
- DB 콘솔 창에 SQL 문 실행
- 여러 번 반복해서 응답시간 확인
- EXPIAIN, EXPAIN ANALYZE로 실행 계획 확인
- 확인한 결과를 통해서 최적화
- EXPIAIN, EXPAIN ANALYZE로 실행 계획 결과 재확인
- API 요청해서 핀포인트로 최적화된 결과 재확인
처음 결과
사실 맨 처음에 데이터를 적당히 넣은 상태에서 부하테스트를 했을 때는 상당히 괜찮은 성능을 보였습니다.
하지만 이전에 데이터를 많이 넣은 상태에서 부하테스트를 진행하면 다른 결과를 보인 적이 있다는 팀원 분의 의견을 반영하여,
각 엔티티에 100만 개의 데이터를 넣은 상태에서 다시 부하테스트를 진행했습니다.
그리고 그 결과는 다음과 같습니다.
기존에 데이터가 없었을 때 > 100만 데이터 삽입 후
총 요청 수 : 약 5만 > 1019
평균 응답 시간 : 약 3초 > 24초
에러율 : 0% > 56%
이와 같이 상당히 결과가 안 좋게 나왔습니다.
특히 응답시간 worst 5개의 평균응답시간을 보면 약 30초로, 정말 말도 안 되는 성능을 보여줬습니다.
DB 커넥션 풀 조정
대부분의 에러를 보면 DB 커넥션 풀이 부족해서 타임아웃이 발생해 생기는 에러가 대부분이었습니다.
그래서 과거에 기본 DB 커넥션 풀인 10에서 20으로 늘리고 확인을 했습니다.
HikariCP Connection Pool 설정
- maximum-pool-size: 20
- 기본값: 10
- 효과: 최대 동시 커넥션을 20개까지 지원. 트래픽이 많아도 커넥션 부족으로 인한 요청 대기 시간을 줄임.
결과
- 에러 발생률은 늘었지만, 응답시간이 24초에서 13초로 약 절반정도 줄어들음
- 최대 응답시간 api들도 30초에서 → 20초로 줄어들음
하지만 계속 에러가 발생을 하는 상황이어서, DB 커넥션 풀을 약 30~50씩 증가시키면서 계속 테스트를 진행했습니다.
그래서 여러 번의 테스트를 거친 결과, 결론적으로 200으로 늘렸을 때 에러가 발생하지 않고 끝까지 진행할 수 있었습니다.
결론적으로 초기 테스트 데이터 대비 아래와 같은 결과가 있었습니다.
총 요청 수 : 1019 -> 1913 약 87.7% 증가
평균 응답 시간 : 24초 -> 5초 약 79.2% 단축
에러율 : 56% -> 0% 100% 개선
초기 테스트 결과 치고는 많은 성능 향상이 있었지만, 아직 처음 성능 목표로 잡은 0.1초까지는 길이 한참 남았습니다.
하지만 아직 Pinpoint를 통한 각 API의 메서드 단위까지 응답시간을 나누어 분석하고 최적화하는 과정은 시작도 안 했습니다.
이어서 팀원들과 나누어서 Response time worst API 목록들을 하나씩 최적화하기로 했습니다.
최적화한 API 목록으로는
지금 뜨는 리뷰 조회 API (복합 인덱스)
영화 개봉 예정일 쿼리 (단일 인덱스)
영화 제목 검색 쿼리 (FULLTEXT INDEX)
개인 맞춤형 추천 영화 리스트 조회 (단일 인덱스)
컬렉션 제목 검색 API(Generated Column, FULLTEXT INDEX, 필요한 인덱스만 Query)
등등..
다양한 API를 개선했지만 대부분의 병목의 원인과 최적화 절차는 비슷했고,
그중 가장 많은 과정을 거친 컬렉션 제목 검색 API 최적화 과정을 중점적으로 설명하겠습니다.
Pinpoint와 Query Plan 분석을 통한 개선
API 요청 후 핀포인트로 확인
총 응답시간: 1,464 ms
executeQuery 쿼리 시간: 1,234 ms
즉 결과에 분석하면 쿼리 실행이 가장 큰 병목 구간임을 확인할 수 있습니다.
해당 부분의 SQL문을 확인하고, 콘솔에서 5번 정도 실행하여 평균 시간을 확인했습니다.
[2024-12-14 16:33:25] 0 rows retrieved in 1 s 648 ms (execution: 1 s 274 ms, fetching: 374 ms)
[2024-12-14 16:33:38] 0 rows retrieved in 1 s 375 ms (execution: 1 s 262 ms, fetching: 113 ms)
[2024-12-14 16:33:41] 0 rows retrieved in 1 s 627 ms (execution: 1 s 270 ms, fetching: 357 ms)
[2024-12-14 16:33:44] 0 rows retrieved in 1 s 472 ms (execution: 1 s 281 ms, fetching: 191 ms)
[2024-12-14 16:33:46] 0 rows retrieved in 1 s 315 ms (execution: 1 s 248 ms, fetching: 67 ms)
각 실행 시간 합계: 1.648, 1.375, 1.627, 1.472, 1.315초
- 평균 총 실행 시간: 약 1.487초
위에서 API를 통해 확인했던 것과 비슷하게, 단순히 쿼리만 입력해도 걸리는 시간이 1회당 평균적으로 1.487초 이상 걸렸습니다.
Query Plan을 통한 분석
MySQL의 EXPIAIN, EXPAIN ANALYZE를 통해 쿼리 실행 계획을 더 자세히 분석할 수 있습니다.
Query Plan이란?
- DBMS가 SQL 쿼리를 처리하기 위해 사용하는 실행 계획으로, 쿼리 실행에 대한 단계를 보여주며 각 단계에서 필요한 리소스와 처리 시간을 단계적으로 보여준다.
-> Limit: 11 row(s) (cost=520425 rows=11) (actual time=1297..1297 rows=0 loops=1)
-> Nested loop inner join (cost=520425 rows=1.59e+6) (actual time=1297..1297 rows=0 loops=1)
-> Sort: c1_0.created_at DESC (cost=161895 rows=1.59e+6) (actual time=1297..1297 rows=0 loops=1)
-> Filter: ((c1_0.is_deleted = 0) and (lower(c1_0.title) like <cache>(lower(concat('%','제목','%'))) escape '') and (c1_0.member_id is not null)) (cost=161895 rows=1.59e+6) (actual time=1297..1297 rows=0 loops=1)
-> Table scan on c1_0 (cost=161895 rows=1.59e+6) (actual time=0.0766..736 rows=1.6e+6 loops=1)
-> Single-row index lookup on u1_0 using PRIMARY (member_id=c1_0.member_id) (cost=0.251 rows=1) (never executed)
해당 Query Plan을 분석한 내용은 다음과 같습니다.
- Table Scan on collection
- 작업 내용: collection 테이블의 모든 행을 읽습니다.
- 실제 소요 시간: actual time=0.0766ms.. 736ms 이 단계에서 약 736ms 동안 전체 테이블을 읽었습니다.
- 결론: Full Table Scan으로 인해 테이블 전체를 스캔하는 데 736ms가 소요되었습니다.
- WHERE 조건과 정렬 작업 (Filter + Sort)
- 작업 내용:
- WHERE 조건:
- (c1_0.is_deleted = 0)
- (lower(c1_0.title) like '% 제목%')
- (c1_0.member_id IS NOT NULL)
- 실제 소요 시간:Table Scan이 끝난 후부터 정렬이 완료될 때까지 걸린 시간 1297ms - 736ms = 약 561ms
- 병목 원인: 조건이 전체 데이터를 대상으로 작동하므로 시간 소모가 큼.
- 작업 내용:
시간 계산 요약
- 전체 테이블 읽기 (Full Table Scan): 736ms
- WHERE 조건 평가 + 정렬: 561ms
- 총 소요 시간 = 736ms + 561ms = 1297ms
Query Plan 분석을 통해 알 수 있듯이,
현재 테이블을 Full Table Scan 하고 정렬하는 부분에서 지연시간이 발생하는 것을 알 수 있습니다.
이를 하나씩 최적화하면서 확인하였습니다.
LOWER 문제 해결 (Generated Column과 인덱스 추가)
- Generated Column 추가:
- LOWER(title)의 결과를 저장할 lower_title 컬럼을 생성하고, 인덱스를 추가합니다.
ALTER TABLE collection ADD lower_title VARCHAR(255) GENERATED ALWAYS AS (LOWER(title)) STORED;
- Generated Column에 인덱스 추가:
- lower_title에 인덱스를 생성하여 WHERE 조건을 최적화합니다.
CREATE INDEX idx_lower_title ON collection (lower_title);
- 쿼리 수정:
- 기존 쿼리에서 LOWER(c1_0.title) 대신 lower_title을 사용합니다.
SELECT ... FROM collection c1_0 WHERE lower_title LIKE '%제목%';
Generated colomn이란?
Generated Column은 MySQL 테이블에 있는 다른 컬럼의 값을 기반으로 계산된 값을 저장하거나 계산하는 컬럼입니다.
lower_title 같은 컬럼을 Generated colomn으로 만들어, 값이 추가될 때마다 자동으로 정렬되어 있는 컬럼을 생성해 index를 생성하면 쿼리를 실행할 때마다 정렬을 하지 않아도 됩니다.
결과
-> Limit: 11 row(s) (cost=342903 rows=11) (actual time=1185..1185 rows=0 loops=1)
-> Nested loop inner join (cost=342903 rows=1.59e+6) (actual time=1185..1185 rows=0 loops=1)
-> Sort: c1_0.created_at DESC (cost=162152 rows=1.59e+6) (actual time=1185..1185 rows=0 loops=1)
-> Filter: ((c1_0.is_deleted = 0) and (c1_0.lower_title like '%제목%' escape '') and (c1_0.member_id is not null)) (cost=162152 rows=1.59e+6) (actual time=1185..1185 rows=0 loops=1)
-> Table scan on c1_0 (cost=162152 rows=1.59e+6) (actual time=0.0736..806 rows=1.6e+6 loops=1)
-> Single-row index lookup on u1_0 using PRIMARY (member_id=c1_0.member_id) (cost=0.251 rows=1) (never executed)
- 쿼리 비용 감소
- 첫 번째 쿼리 비용: 520425
- 두 번째 쿼리 비용: 342903
→ 약 34.1% 비용 감소
- 실행 시간 개선
- 첫 번째 쿼리 실행 시간: 1297 ms
- 두 번째 쿼리 실행 시간: 1185 ms
→ 약 8.6% 시간 단축
필요한 인덱스만 읽기
이번엔 엔티티의 모든 Column을 읽지 않고, 필요한 인덱스만 읽는 식으로 수정해 결과를 확인했습니다.
-> Limit: 11 row(s) (cost=342903 rows=11) (actual time=1037..1037 rows=0 loops=1)
-> Nested loop inner join (cost=342903 rows=1.59e+6) (actual time=1037..1037 rows=0 loops=1)
-> Sort: c1_0.created_at DESC (cost=162152 rows=1.59e+6) (actual time=1037..1037 rows=0 loops=1)
-> Filter: ((c1_0.is_deleted = 0) and (c1_0.lower_title like '%제목%' escape '') and (c1_0.member_id is not null)) (cost=162152 rows=1.59e+6) (actual time=1037..1037 rows=0 loops=1)
-> Table scan on c1_0 (cost=162152 rows=1.59e+6) (actual time=0.0702..659 rows=1.6e+6 loops=1)
-> Single-row index lookup on u1_0 using PRIMARY (member_id=c1_0.member_id) (cost=0.251 rows=1) (never executed)
- 쿼리 실행 시간 개선
- 처음 실행 시간: 1297 ms
- 새로운 실행 시간: 1037 ms
→ 약 20% 단축
- 쿼리 비용 감소
- 처음 비용: 520425
- 새로운 비용: 342903
→ 약 34.1% 비용 감소
개선이 상당히 됐지만, 아직도 쿼리를 실행하는데 1초가 걸리는 문제와 row는 전체를 다 순회하는 문제가 있었다.
Full Text Index 활용
- Full Text Index 추가
- LIKE 대신 MATCH를 사용하여 검색
SELECT ... FROM collection WHERE MATCH(title) AGAINST ('제목' IN NATURAL LANGUAGE MODE);
Full Text Index란?
텍스트 검색 전용:긴 텍스트 데이터(CHAR, VARCHAR, TEXT)에 적합한 인덱스.
일반 인덱스와 달리, 단어 단위로 데이터를 인덱싱하여 검색 속도를 크게 향상.
결과
-> Limit: 11 row(s) (cost=1.28 rows=1) (actual time=0.0132..0.0132 rows=0 loops=1)
-> Nested loop inner join (cost=1.28 rows=1) (actual time=0.0127..0.0127 rows=0 loops=1)
-> Sort row IDs: c1_0.created_at DESC (cost=1.05 rows=1) (actual time=0.0121..0.0121 rows=0 loops=1)
-> Filter: ((c1_0.is_deleted = 0) and (match c1_0.lower_title against ('제목')) and (c1_0.member_id is not null)) (cost=1.05 rows=1) (actual time=0.00655..0.00655 rows=0 loops=1)
-> Full-text index search on c1_0 using lower_title (lower_title='제목') (cost=1.05 rows=1) (actual time=0.006..0.006 rows=0 loops=1)
-> Single-row index lookup on u1_0 using PRIMARY (member_id=c1_0.member_id) (cost=0.451 rows=1) (never executed)
쿼리 실행 계획 최적화 전후 비교
전체 테이블 읽기 | 736ms | 0.006ms | Full Table Scan → Full Text Index로 최적화. |
WHERE 조건 평가 + 정렬 | 561ms | 0.0121ms | 조건 및 정렬 최적화로 처리 속도 대폭 개선. |
총 소요 시간 | 1297ms | 0.0132ms | 총 소요 시간이 약 99% 이상 감소. |
여기까지 진행하고 부하테스트를 진행한 결과
총 요청 수 : 1019 -> 102855
평균 응답 시간 : 24초 -> 0.16초
에러율 : 56% -> 0%
로 개선되었습니다.
캐시
사실 이미 최적화의 목표는 거의 다 이루었지만, 목표였던 최종 평균 응답시간 0.1초를 위해서
worst 1순위인 개인 맞춤형 추천 영화 리스트 조회 API를 개선했습니다.
개인 맞춤형 추천 영화 리스트 조회할 때는 인기 TOP 10 영화를 제외한 영화들 중에서 선택을 하는데,
매번 TOP10 영화를 계산하는 과정이 굉장히 비효율적이었습니다.
그래서 TOP10 영화를 조회하는 API에서 Redis에 캐시를 하고, 개인 맞춤형 추천 영화 리스트 조회 API에서는 캐시 한 TOP10 영화를 조회해서 해당 영화들을 제외하고 나머지 로직을 진행했습니다.
커넥션 풀 최적화 - 심화
처음에 에러가 발생하지 않도록 200까지 DB 커넥션 풀을 증가해서 테스트를 진행했지만,
사실 직관적으로 '트래픽이 많으니 더 많은 연결이 필요하겠지'라는 판단은 사실 틀렸습니다.
실제로 Oracle에서 실제 성능 테스트를 진행한 결과 커넥션 풀을 2048개에서 96개로 줄여 응답 시간이 100ms에서 2ms로 오히려 줄어든 결과가 있습니다.
아래 글에서 적혀있듯이, 데이터베이스 커넥션 풀의 작동 원리를 CPU, 디스크, 네트워크 관점에서 심층적으로 살펴보고 실제 환경에서 최적의 풀 크기를 선정하였습니다.
최적의 풀 크기를 구하다 보면 직관적으로 생각하는 '많다' 보다 오히려 훨씬 적은 수의 커넥션 풀이 좋다는 것을 알 수 있습니다.
운영체제 관점에서 살펴보면,
CPU 코어가 하나인 컴퓨터도 여러 개의 스레드를 동시에 실행하는 것처럼 보입니다.
하지만 실제로는 하나의 코어에서 한 번에 하나의 스레드만 실행할 수 있고, OS가 context switching 하면서 다른 스레드의 코드를 실행하는 방식으로 동작합니다.
이는 운영체제의 Concurrency과 관련 있는 내용이죠.
그래서 스레드 수가 CPU 코어수를 초과하면, 스레드를 추가할수록 성능이 향상되는 것이 아니라 오히려 저하됩니다.
하지만 데이터베이스 관점에서 생각하면,
데이터는 일반적으로 디스크에 저장하는데, HDD 같은 경우 읽기와 쓰기를 하는 헤드가 데이터에 접근하기 위해 이곳저곳을 이동하면서 조회하는 탐색 시간이 필요합니다.
그래서 이런 I/O 대기 시간 동안 디스크 작업이 완료되기를 기다리며 스레드를 block 시키고, OS가 블록 된 스레드를 제외하고 다른 스레드의 코드를 실행함으로써 CPU 리소스를 좀 더 효율적으로 사용합니다.
그래서 스레드가 I/O 작업으로 블록 되는 동안 물리적 CPU 코어 수보다 많은 스레드를 실행할 때 최종적인 처리량을 높이는 것입니다.
그럼 최적의 스레드 개수는 몇 개일까요?
SSD의 경우 탐색 시간이 없기 때문에 블로킹이 발생하지 않고, 그래서 블록 > 다른 스레드 실행하는 걸 오히려 줄이는 게 효율적입니다.
그래서 코어 수에 가까운 더 적은 수의 스레드가 더 많은 스레드보다 성능이 좋을 가능성이 높습니다.
최적의 연결 수 계산 공식
여러 곳의 벤치마크 결과를 통해 검증된 공식은 아래와 같습니다.
활성 커넥션 수 = ((CPU 코어 수 * 2) + 유효 스핀들 수)
스핀들이란, HDD의 물리적 플래터(데이터 저장 원반)를 움직이는 축을 의미하므로 유효 스핀들 수는 HDD에서 동시에 읽고 쓸 수 있는 독립된 경로를 의미합니다.
위 공식은 하이퍼스레딩(물리적 코어를 두 개의 논리적 스레드로 나누어 동작하게 하는 기능)이 적용되어 있더라도
HT스레드(논리적 스레드, 여기선 물리적 코어보다 많음)를 제외한 실제 물리적 코어 수만을 계산합니다.
그래서 위 공식을 다시 정리하자면,
- CPU 코어 수: 물리적 CPU 코어 수(하이퍼스레딩 무시).
- × 2: CPU 하나가 I/O 대기 시간을 줄이고 효율적으로 스레드를 처리할 수 있도록 두 개의 연결을 할당
- 유효 스핀들 수: 하드 디스크에서 동시에 데이터를 읽고 쓸 수 있는 경로 수
- SSD를 사용하면 유효 스핀들 수 = 0으로 계산
예제
4 코어 i7 CPU와 HDD
- 물리적 CPU 코어: 4개
- 유효 스핀들 수: 1 (하드 디스크가 1개)
예제
4코어 i7 서버 + SSD:
최종 결과
총 요청 수 : 1019 -> 131734
평균 응답 시간 : 24초 -> 0.1초
에러율 : 56% -> 0% 100% 개선
1. 요청 수 증가율
최초 값: 1019
최적화 후 값: 131734
2. 평균 응답시간 감소율
최초 값: 24초
최적화 후 값: 0.1초
결론적으로 목표했던 평균 응답시간 0.1초를 만족할 수 있었습니다.
마무리하며
이번 최적화 경험을 통해서
- 새로운 테스트 환경 구축 방법
- 병목지점 분석의 체계적인 접근법
을 배웠습니다.
단순히 코드 개선뿐만 아니라 데이터베이스 최적화와 쿼리 구조를 이해하고 실질적인 개선 효과를 확인하는 과정이 매우 값진 경험이었습니다. 또한 최적화를 진행하면서 많은 조언을 해주신 멘토님과 팀원들의 의견을 통해서 더 나은 개발자로 성장할 수 있었습니다.
초기에 평균 응답 시간이 24초까지 치솟았을 때는 도대체 어떻게 0.1초로 줄이지,라는 좌절감도 있었지만,
문제를 단계적으로 분석하고 해결하면서 결국 목표를 달성한 순간 개발자로서 성장을 확실히 느낄 수 있었습니다.
다음 글에는, 이번 최적화만큼 프로젝트에서 열심히 했던 CI/CD 구축 경험과 데브옵스 설정 과정에 대해서도 글을 작성해 공유할 계획입니다. 기대해 주세요!
긴 글 읽어주셔서 감사합니다.
최적화는 끝이 없지만, 제 글이 작은 힌트가 되었길 바라며 앞으로도 꾸준히 나아가겠습니다😊
참고 블로그 글