개요
24.11.12 ~ 24.12.19 동안 LG U+ 유레카에서 Filmeet이라는 영화 리뷰와 소셜 플랫폼 프로젝트를 진행했습니다.
15일까지 프로젝트를 진행했고 16 ~ 18일 동안은 팀원들과 프로젝트 API에 대한 부하 테스트를 진행하고 성능 개선 작업을 진행했습니다. 이번 글에서는 성능 테스트 진행 과정에 대한 내용을 이야기하려 합니다.
성능 테스트는 어떤 상황에서 왜 하는가?
성능 테스트가 필요한 상황
요청이 많아지면 서버 부하가 증가합니다. 서버의 자원은 무한하지 않아 많은 요청을 처리하기 위해서는 많은 서버가 필요합니다. 사용자들이 많아졌을 때 사용자들이 발생시키는 트래픽을 감당하기 위해서는 얼마나 많은 서버가 있어야 할지에 대한 고민이 필요합니다. 무턱대고 서버를 필요 이상으로 많이 투입하는 건 불필요하게 비용을 증가시키는 일이니 서버를 무한히 증설할 수 없습니다. 당연히 이런 상황에 대한 연습이 필요한데 바로 성능 테스트는 사용자가 많아졌을 때를 가정한 연습을 해볼 수 있는 수단입니다.
많은 사용자가 발생시키는 요청을 성능 테스트 툴이 생성을 해줍니다.
그리고 성능 테스트 툴은 성능 테스트에 대한 결과 역시 리포팅을 해줍니다.
그러면 이 성능 테스트 결과를 반복하면서 예상되는 사용자의 숫자와 그에 따른 요청 수를 계산해 보면서 얼마나 많은 수의 서버가 필요한지 테스트해 볼 수 있습니다. 이런 상황이 성능 테스트가 필요한 상황입니다.
성능 테스트를 하는 이유가 필요한 서버 숫자만 이야기하는 게 아니다.
비효율적으로 동작하는 애플리케이션 로직 개선
-> 메모리 정리가 제대로 되지 않아 지속적으로 GC가 발생하는 문제 등
데이터베이스 같은 저장소에 대한 성능 개선
-> 인덱스 타지 않거나, 데드락 발생 문제 등
시스템 설계 개선
-> 캐시 필요, 비동기적으로 요청을 처리하도록 구조 개선, 상황에 따라 Circuit Breaker 도입 등
성능 테스트를 반드시 얼마나 많은 수의 서버가 필요한지에 대해 파악하기 위해서만 진행하는 거는 아닙니다.
위와 같은 상황을 개선하기 위해서도 성능 테스트를 진행합니다. 그리고 성능 테스트를 해봐야 실제 사용자들이 사용하는 상황과 유사한 환경을 만들고 미리 동일한 트래픽을 받아 볼 수 있어 실제 장애나 병목 현상을 사전에 발견하고, 이를 해결해 안정적인 서비스를 제공할 수 있기 때문입니다.
성능 목표 잡기
그렇다면 성능 테스트를 진행할때 어떤 지표를 가지고 진행을 해야 할까요?
지연시간(Latency)
지연시간은 요청 한건 한건의 처리시간을 의미합니다.
어떤 요청이 처리되어서 사용자에게 응답이 되기까지 1초가 걸렸다면 이 요청의 latency는 1초가 됩니다.
보통 1000분의 1초인 밀리세컨드 단위가 우리가 보통 사용하는 지연시간의 단위가 됩니다.
사용자 입장에서 보면 API를 호출한 응답이 와야 그 데이터를 활용해서 다음 작업을 할 수 있습니다.
따라서 사용자가 체감하는 중요한 성능 지표가 될 수밖에 없습니다.
지연시간이 길어지만 사용자는 서비스 이용에 불편함을 겪습니다.
서비스나 기능에 따라 사용자가 기대하는 지연시간은 크게 달라지고 개발자가 목표로 해야 하는 지연시간 역시 사용자들이 기대하는 지연시간에 맞춰줘야 합니다.
그렇다면 실제 사용자들이 기대하는 지연시간 어떻게 될까요?
Perplexity에서의 설명
- 주요 통계
- 모바일 웹사이트 방문자의 53%는 웹페이지 로딩에 3초 이상 걸리면 해당 사이트를 떠납니다.
- 평균적으로 모바일 사이트의 로딩 시간은 3G 연결에서 19초, 4G 연결에서 14초입니다.
- 로딩 속도의 영향
- 5초 내에 로딩되는 모바일 사이트는 19초가 걸리는 사이트에 비해 다음과 같은 이점이 있습니다:
- 25% 더 높은 광고 가시성
- 70% 더 긴 평균 세션 시간
- 35% 더 낮은 이탈률
- 페이지 로딩 시간이 1초에서 3초로 증가하면 이탈 확률이 32% 증가합니다.
- 5초 내에 로딩되는 모바일 사이트는 19초가 걸리는 사이트에 비해 다음과 같은 이점이 있습니다:
- 사용자 기대치
- 인터넷 사용자의 약 50%는 웹페이지 로딩 시간이 2초 미만이기를 기대합니다.
- 성능이 좋지 않은 경험을 한 사용자의 약 80%는 해당 페이지를 영구적으로 피하고 다시 방문하지 않습니다.
참고 링크
https://www.portent.com/blog/analytics/research-site-speed-hurting-everyones-revenue.htm|
이 정보를 바탕으로 평균 Latency를 3초 이내를 목표로 삼았습니다.
하지만 실제 현업에 계신 멘토님의 조언을 들은 결과,
유플러스 측에선 목표를 평균 응답시간을 모두 0.1초 내로 받는 것으로 한다고 하셨습니다.
그래서 다소 어려운 목표라고 생각했지만, 저희 역시 평균응답시간 0.1초를 성능 목표로 잡았습니다.
성능 테스트 툴 및 테스트 환경
테스트 툴
APM : Pinpoint
부하테스트 툴 : Postman의 Run collection test 기능(제한적으로 유료)
APM 툴로는 일반적으로 Grafana나, 프로메테우스를 주로 사용합니다.
프로메테우스 + 그라파나 조합과 비교해서 아래와 같은 차이점이 있습니다.
항목 | Prometheus + Grafana | Pinpoint |
주요 목적 | 시스템 및 인프라 모니터링 | 애플리케이션 트랜잭션 추적(APM) |
트랜잭션 추적 | ❌ (Jaeger, Zipkin 필요) | ⭕ (기본 제공) |
시각화 | Grafana에서 고급 대시보드 제공 | 기본적인 시각화 (커스터마이징 제한) |
확장성 및 유연성 | ⭕ (다양한 Exporter, 커스터마이징 가능) | ❌ (Java/PHP 위주) |
설치 및 설정 | 복잡 (Exporter, Grafana 등 설치 필요) | 간단 (에이전트 추가로 바로 사용) |
언어 지원 | 다수의 언어 및 서비스 | Java, PHP 중심 |
알람 및 경고 시스템 | ⭕ (Alertmanager 활용) | ⭕ (기본 알람 기능) |
20년 이상의 경력을 가지신 네이버 출신 멘토님의 다른 툴들에 비해 Pinpoint를 추천한다는 점과 애플리케이션 레벨에서 트랜잭션 흐름을 추적하고 병목을 분석하여 성능을 개선하기 위해 최종적으로 Pinpoint를 사용하기로 했습니다.
테스트 서버 환경
테스트 환경에 대해 고민하던 중, 백엔드 팀원 한 분이 전적으로 테스트 서버 환경을 담당해 주셨습니다.
아래 테스트 서버 환경에 대한 내용은 해당 팀원분이 설명해 주신 내용을 기반으로 작성하였습니다.
테스트 환경을 구성하는 방법에 대해서는 세 가지 선택지가 있었습니다.
- 부하 테스트 실행 환경
- (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 리소스를 확대하여 더 높은 트래픽 테스트 가능.
더미 데이터 구성
데이터를 16개의 테이블에 100만 ~ 1000만 개의 데이터를 넣어, 부하테스트에 적합한 환경을 세팅했습니다.
성능 테스트 단계
성능 테스트는 아래와 같은 절차로 진행했습니다.
- API 요청 후 핀 포인트로 확인
- 병목지점 확인 / 응답시간 확인
- 실행된 SQL 문 확인
- DB 콘솔 창에 SQL문을 여러 번 반복해서 응답시간 확인
- EXPIAIN, EXPAIN ANALYZE로 실행 계획 확인
- 확인한 결과를 통해서 쿼리 최적화
- EXPIAIN, EXPAIN ANALYZE로 실행 계획 결과 재확인
- API 요청해서 핀포인트로 최적화된 결과 재확인
성능 테스트 시작
처음 결과
처음 가상의 유저를 100명으로 하고 5분 정도 간단하게 부하 테스트를 진행해 봤습니다.
아래는 포스트맨으로 확인한 결과입니다.
포스트맨
결과는 굉장히 안 좋게 나왔습니다.
총 요청 수 : 1815
평균 응답 시간 : 약 15초
에러율 : 36%
특히 응답시간 worst 5개의 평균 응답시간을 보면 약 30초로, 정말 말도 안 되는 성능을 보여줬습니다.
Top 5 slowest requests based on their average response times.
개선할 API 분석
API 요청 후 핀 포인트로 확인
개선할 API 분석을 위해 포스트맨으로 API를 하나씩 요청해서 핀 포인트로 API 하나하나 얼마나 걸리는지 확인했습니다.
확인해 본 결과 아래 6개의 API가 대략 10초 ~ 30초 정도의 시간이 걸리는 것을 확인했습니다.
- 영화 TOP 10 조회
- 추천 영화 조회
- 리뷰 목록 조회
- 개봉 영화 조회
- 박스오피스 영화 조회
- 영화 제목 검색
제일 시간이 오래 걸린 영화 TOP 10 조회 API에 요청을 한번 더 보냈고 SQL 응답시간만 36초가 소요된 것을 확인했습니다. 또한 영화 TOP 10 조회뿐만 아니라 10초 이상 걸린 다른 API들도 대부분의 응답시간이 SQL 응답시간에서 발생했습니다.
그리고 콘솔에 찍힌 예외 로그를 확인해 보니 커넥션 고갈로 인한 오류임을 확인했습니다.
org.springframework.transaction.CannotCreateTransactionException: Could not open JPA EntityManager for transaction
at org.springframework.orm.jpa.JpaTransactionManager.doBegin(JpaTransactionManager.java:466) ~[spring-orm-6.0.13.jar:6.0.13]
....
run(TaskThread.java:61) ~[tomcat-embed-core-10.1.15.jar:10.1.15]
at java.base/java.lang.Thread.run(Thread.java:840) ~[na:na]
Caused by: org.hibernate.exception.JDBCConnectionException: Unable to acquire JDBC Connection [HikariPool-1 - Connection is not available, request timed out after 30005ms.] [n/a]
at org.hibernate.exception.internal.SQLExceptionTypeDelegate.convert(SQLExceptionTypeDelegate.java:49) ~[hibernate-core-6.2.13.Final.jar:6.2.13.Final]
at org.hibernate.exception.internal.StandardSQLExceptionConverter.convert(StandardSQLExceptionConverter.java:56) ~[hibernate-core-6.2.13.Final.jar:6.2.13.Final]
... 151 common frames omitted
Caused by: java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30005ms.
at com.zaxxer.hikari.pool.HikariPool.createTimeoutException(HikariPool.java:696) ~[HikariCP-5.0.1.jar:na]
... 154 common frames omitted
org.hibernate.exception.JDBCConnectionException: Unable to acquire JDBC Connection [HikariPool-1 - Connection is not available, request timed out after 30005ms.] [n/a]
at org.hibernate.exception.internal.SQLExceptionTypeDelegate.convert(SQLExceptionTypeDelegate.java:49) ~[hibernate-core-6.2.13.Final.jar:6.2.13.Final]
...
tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659) ~[tomcat-embed-core-10.1.15.jar:10.1.15]
at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61) ~[tomcat-embed-core-10.1.15.jar:10.1.15]
at java.base/java.lang.Thread.run(Thread.java:840) ~[na:na]
Caused by: java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30005ms.
...
at org.hibernate.resource.jdbc.internal.LogicalConnectionManagedImpl.acquireConnectionIfNeeded(LogicalConnectionManagedImpl.java:113) ~[hibernate-core-6.2.13.Final.jar:6.2.13.Final]
이러한 예외가 발생한 이유는 아래와 같습니다.
Spring Boot 2.0 이전에는 tomcat-jdbc를 기본 커넥션 풀로 사용했지만, 2.0 이후부터는 HikariCP가 기본 옵션으로 채택되었습니다.
HikariCP의 기본 커넥션 풀 크기는 10개로 설정되어 있습니다.
영화 TOP 10을 조회하는 API에서 SQL 실행 시간이 36초가 소요되다 보니 API의 쿼리 처리 시간이 길어지면, 사용 가능한 커넥션이 부족해집니다. 결과적으로 다른 API 요청은 커넥션을 기다리다가, 일정 시간이 지나면 타임아웃 오류가 발생하며 요청을 처리하지 못하게 됩니다. 이 문제를 해결하기 위해, 커넥션 풀 크기를 조정하거나 쿼리 최적화가 필요합니다.
이 문제의 핵심은 SQL 쿼리 실행 시간이 지나치게 길다는 점입니다.
커넥션 풀의 크기를 늘려 일시적으로 문제를 완화할 수 있지만,
쿼리 실행 시간이 개선되지 않으면 결국 커넥션이 부족해지고 동일한 문제가 반복됩니다.
근본적인 원인을 해결하지 않으면 커넥션 개수를 늘려도 한계가 있으므로, 쿼리 성능 개선이 필수적입니다.
실행계획 세워 쿼리 튜닝
실제 SQL 쿼리 실행 시간이 얼마나 걸렸는지 확인하기 위해 실행 계획을 통해 확인했습니다.
Query Plan이란?
DBMS가 SQL 쿼리를 처리하기 위해 사용하는 실행 계획으로, 쿼리 실행에 대한 단계를 보여주며 각 단계에서 필요한 리소스와 처리 시간을 단계적으로 보여준다.
단일 인덱스 적용
실행된 SQL 문 확인
우선 추천 영화 조회에서 발생한 쿼리는 아래와 같습니다.
(SELECT *
FROM movie m
WHERE m.like_counts > 0
AND m.is_deleted = false
ORDER BY
m.like_counts DESC
LIMIT 1000)
UNION
(SELECT *
FROM movie m
WHERE m.average_rating > 0
AND m.is_deleted = false
ORDER BY m.average_rating DESC
LIMIT 1000);
실행계획을 통해 확인한 결과는 아래와 같습니다.
-> Table scan on <union temporary> (cost=438405..438433 rows=2000) (actual time=32713..32714 rows=1999 loops=1)
-> Union materialize with deduplication (cost=438405..438405 rows=2000) (actual time=32713..32713 rows=1999 loops=1)
-> Limit: 1000 row(s) (cost=219103 rows=1000) (actual time=16839..16839 rows=1000 loops=1)
-> Sort: m.like_counts DESC, limit input to 1000 row(s) per chunk (cost=219103 rows=1.98e+6) (actual time=16838..16838 rows=1000 loops=1)
-> Filter: ((m.is_deleted = false) and (m.like_counts > 0)) (cost=219103 rows=1.98e+6) (actual time=3.61..14490 rows=2e+6 loops=1)
-> Table scan on m (cost=219103 rows=1.98e+6) (actual time=3.6..14192 rows=2e+6 loops=1)
-> Limit: 1000 row(s) (cost=219103 rows=1000) (actual time=15844..15844 rows=1000 loops=1)
-> Sort: m.average_rating DESC, limit input to 1000 row(s) per chunk (cost=219103 rows=1.98e+6) (actual time=15844..15844 rows=1000 loops=1)
-> Filter: ((m.is_deleted = false) and (m.average_rating > 0.00)) (cost=219103 rows=1.98e+6) (actual time=17.3..13084 rows=2e+6 loops=1)
-> Table scan on m (cost=219103 rows=1.98e+6) (actual time=14.1..12707 rows=2e+6 loops=1)
type이 ALL로 표시되어 있어, 테이블 전체 스캔(Full Table Scan)이 발생하고 있음을 확인할 수 있습니다. possible_keys 및 key 항목이 모두 null로 표시되어, 인덱스를 전혀 사용하지 않고 쿼리가 실행되고 있음을 확인할 수 있습니다. 테이블 전체 스캔을 하다 보니 2백만 건(2e+6)의 데이터를 조회하는 데 14~17초가 소요되고 있는 상황입니다.
가장 큰 문제는 Full Table Scan이 발생하는 것이었습니다.
이를 해결하기 위해 인덱스를 생성하여 쿼리가 인덱스를 타도록 수정했습니다.
특히, 조회 시 like_counts와 average_rating을 기준으로 정렬하는 부분이 병목의 원인이었기 때문에,
이 두 컬럼을 중심으로 인덱스를 추가하여 쿼리 성능을 개선했습니다.
CREATE INDEX idx_movie_like_counts ON movie (like_counts DESC);
CREATE INDEX idx_movie_average_rating ON movie (average_rating DESC);
인덱스 추가 후
-> Table scan on <union temporary> (cost=451396..451424 rows=2000) (actual time=26.1..26.5 rows=1997 loops=1)
-> Union materialize with deduplication (cost=451396..451396 rows=2000) (actual time=26.1..26.1 rows=1997 loops=1)
-> Limit: 1000 row(s) (cost=225598 rows=1000) (actual time=0.0621..7.07 rows=1000 loops=1)
-> Filter: (m.is_deleted = false) (cost=225598 rows=494878) (actual time=0.0614..7 rows=1000 loops=1)
-> Index range scan on m using idx_movie_like_counts over (like_counts < 0), with index condition: (m.like_counts > 0) (cost=225598 rows=989755) (actual time=0.0595..6.84 rows=1000 loops=1)
-> Limit: 1000 row(s) (cost=225598 rows=1000) (actual time=0.0312..12.6 rows=1000 loops=1)
-> Filter: (m.is_deleted = false) (cost=225598 rows=494878) (actual time=0.0307..12.5 rows=1000 loops=1)
-> Index range scan on m using idx_movie_average_rating over (average_rating < 0.00), with index condition: (m.average_rating > 0.00) (cost=225598 rows=989755) (actual time=0.0298..12.4 rows=1000 loops=1)
세부 지표 비교
항목 |
기존 실행 계획 | 개선된 실행 계획 |
쿼리 실행 시간 | 32,713 ms (약 32.7초) | 26.5 ms (약 0.027초) |
스캔 방식 | Table Scan | Index Range Scan |
스캔 데이터 수 | 2,000,000 rows | 1,000 rows (per scan) |
쿼리 실행 시간이 32.7초에서 26.5ms초로 단축되어 99.92%의 성능 개선이 이루어졌습니다.
핀 포인트로 재확인
총 9번 요청을 했고 평균 응답 시간은 231.67 ms임을 확인했습니다.
세부적으로도 얼마나 걸렸는지 확인해 보면 쿼리 실행 시간이 인덱스 적용 전보다 많이 줄어든 것을 볼 수 있습니다.
Full-text index 적용
이번에는 영화 제목 검색 쿼리를 튜닝해 보겠습니다.
실행된 SQL 문 확인
SELECT
m.release_date,
m.title,
m.poster_url,
m.movie_id
FROM
movie m
WHERE
m.is_deleted = false
AND m.lower_title LIKE CONCAT('%', :title, '%')
LIMIT :offset, :limit;
실행계획을 통해 확인한 결과는 아래와 같습니다.
-> Limit: 10 row(s) (cost=208620 rows=10) (actual time=5758..5758 rows=0 loops=1)
-> Filter: ((m.is_deleted = false) and (lower(m.title) like <cache>(concat('%','소방 관','%')))) (cost=208620 rows=963788) (actual time=5758..5758 rows=0 loops=1)
-> Table scan on m (cost=208620 rows=1.93e+6) (actual time=2.54..4971 rows=2e+6 loops=1)
위 실행계획을 보면 Table scan on m에서 대략 2백만 건(1.93e+6)의 데이터를 조회하고 있습니다.
그렇다면 이 상태에서 title 부분에 인덱스를 적용하면 될까요?
title 부분에 인덱스를 적용해도 인덱스가 제대로 활용되지 않습니다.
그 이유는 LOWER(m.title) LIKE '%소방 관%'는 함수 기반의 조건으로 인해 인덱스를 타지 못합니다.
그리고 B-Tree 인덱스는 왼쪽부터 시작하는 문자열을 기반으로 동작합니다. 즉 인덱스는 첫 글자부터 순차적으로 검색합니다. 하지만 LIKE '%keyword%'처럼 앞에 %가 붙으면 첫 글자가 불확실하기 때문에, 인덱스는 처음부터 순차적으로 전체 테이블을 스캔해야 합니다.
결과적으로 Full Table Scan이 발생하며 쿼리 응답 시간이 5.758초나 소요됩니다.
그래서 이러한 문제를 해결하기 위해 아래 2가지 방식을 사용했습니다.
- Generated Column
- Full Text Index
Generated Column 추가
Generated colomn이란?
Generated Column은 MySQL 테이블에 있는 다른 컬럼의 값을 기반으로 계산된 값을 저장하거나 계산하는 컬럼
LOWER(title)의 결과를 저장할 lower_title 컬럼을 생성
ALTER TABLE movie
ADD lower_title VARCHAR(255)
GENERATED ALWAYS AS (LOWER(title)) STORED;
lower_title에 인덱스를 생성하여 WHERE 조건을 최적화
CREATE INDEX idx_lower_title ON movie (lower_title);
기존 쿼리에서 LOWER(m.lower_title) 대신 m.lower_title을 사용
LOWER(m.lower_title) -> m.lower_title
Full Text Index 사용
Full Text Index란?
텍스트 검색 전용:긴 텍스트 데이터(CHAR, VARCHAR, TEXT)에 적합한 인덱스. 일반 인덱스와 달리, 단어 단위로 데이터를 인덱싱하여 검색 속도를 크게 향상.
CREATE FULLTEXT INDEX ft_index_lower_title ON movie (lower_title) WITH PARSER ngram;
인덱스 추가 후
-> Limit: 10 row(s) (cost=1.05 rows=0.5) (actual time=4..4 rows=0 loops=1)
-> Filter: ((m.is_deleted = false) and (match m.lower_title against ('소방 관'))) (cost=1.05 rows=0.5) (actual time=3.4..3.4 rows=0 loops=1)
-> Full-text index search on m using ft_index_lower_title (lower_title='소방 관') (cost=1.05 rows=1) (actual time=3.4..3.4 rows=0 loops=1)
성능 비교 요약
항목 | LIKE 기반 (기존) | FULLTEXT 기반 (개선) |
Total Cost | 208620 | 1.05 |
실행 시간 (ms) | 5758 | 4 |
Rows Scanned | 2 million | 거의 없음 |
인덱스 사용 여부 | ❌ (Table Scan 발생) | ✅ (Full-text Index 사용) |
쿼리 방식 | 테이블 전체 스캔 | 인덱스 기반 검색 |
쿼리 실행 시간이 5.7초에서 4ms로 단축되어 99.93%의 성능 개선이 이루어졌습니다.
핀 포인트로 재확인
총 9번 요청을 했고 평균 응답 시간은 85.56ms 임을 확인했습니다.
쿼리 실행 시간이 인덱스 적용 전보다 많이 줄어든 것을 볼 수 있습니다.
영화 TOP 10 조회, 영화 제목 검색 API 뿐만 아니라 아래 4가지 API도 비슷하게 쿼리 튜닝을 진행했습니다.
- 추천 영화 조회
- 리뷰 목록 조회
- 개봉 영화 조회
- 박스오피스 영화 조회
쿼리 튜닝 후
쿼리 튜닝 진행 후 포스트맨으로 다시 한번 API 요청을 보냈고 핀 포인트에서 실행 시간을 확인했습니다.
아래 결과를 보다시피 쿼리 튜닝을 하기 전에 비해 굉장히 응답시간이 단축된 것을 확인할 수 있습니다.
그리고 다시 한번 부하 테스트를 진행해 보겠습니다.
이번에는 가상의 유저를 100명으로 하고 5분 동안 진행해 보겠습니다.
결과를 보면 처음 평균 15초에서 0.5초로 줄어들고 에러율 0% 전체 요청수는 56,000건 처리한 것을 볼 수 있습니다. 굉장히 유의미한 성능 개선을 이뤄낸 것을 확인할 수 있습니다. 하지만 아쉽게도 처음 목표한 평균 응답시간 0.1초는 달성하지 못했습니다.
그래서 최악의 TOP5 API 중에서 평균 응답 시간이 제일 긴 영화 상세 조회에 대해 좀 더 튜닝을 해보겠습니다.
캐시 적용 & 쿼리 줄이기
캐시 적용
영화 TOP 10 조회와 박스오피스 순위 조회 기능에는 캐시를 적용하였습니다.
로컬 캐시(Local Cache)를 사용하게 되면 서버가 확장(스케일 아웃)될 경우, 각 서버가 개별적으로 캐시 데이터를 관리하게 됩니다. 이로 인해 서버 간 캐시 데이터의 정합성(Consistency) 문제가 발생할 수 있습니다.
이러한 문제를 방지하기 위해 분산 환경에서도 일관된 데이터 관리가 가능한 Redis 캐시를 도입했습니다.
redis 캐시 데이터는 스케줄러(Scheduler)를 사용해 주기적으로 갱신하게 했습니다.
특정 시간 간격으로 최신 데이터를 조회하여 Redis에 덮어쓰기 방식으로 초기화합니다.
필밋 TOP 10 영화 업데이트 스케줄러
@Scheduled(cron = "0 0/30 * * * *")
public void updateMoviesRankings() {
log.info("Scheduled Task Started: Updating Movies Rankings in Redis...");
try {
log.info("Fetching movies rankings from database...");
List<MoviesRankingsResponse> moviesRankings = movieRankingsQueryService.getMoviesRankings();
List<Map<String, String>> rankingsCacheData = toMaps(moviesRankings);
redisTemplate.opsForValue().set(MOVIE_RANKINGS, rankingsCacheData);
log.info("Successfully updated movies rankings in Redis.");
} catch (Exception e) {
log.error("Failed to update movies rankings in Redis. Error: {}", e.getMessage(), e);
}
}
박스오피스 영화 업데이트 스케줄러
@Scheduled(cron = "0 0 * * * ?")
public void updateBoxOfficeMovies() {
try {
log.info("Update box office data...");
List<Map<String, String>> boxOfficeMovies = fetchBoxOfficeMovies();
redisTemplate.opsForValue().set(MOVIE_BOX_OFFICE, boxOfficeMovies);
log.info("Successfully updated box office data in cache.");
} catch (Exception e) {
log.error("Failed to update box office data in Redis. Error: {}", e.getMessage(), e);
}
}
캐시 읽기 전략
캐시 읽기 전략 같은 경우에는 Look Aside 패턴을 적용했습니다.
Look Aside 패턴이란?
Cache Aside 패턴이라고도 불림.
데이터를 찾을 때 우선 캐시에 저장된 데이터가 있는지 우선적으로 확인하는 전략.
만일 캐시에 데이터가 없으면 DB에서 조회함.
영화 TOP 10 조회와 박스오피스 순위 조회 같은 경우에는 반복적인 읽기가 많은 호출이라 Look Aside 패턴에 적합하다고 생각했습니다. 또한 캐시와 DB가 분리되어 가용되기 때문에 캐시 장애 대비 구성이 되어있어 만일 redis가 다운되더라도 DB에서 데이터를 가져올 수 있어 서비스 자체는 문제가 없는 이점이 있습니다. 다만 캐시에 붙어있던 connection이 많았다면, redis가 다운된 순간순간적으로 DB로 몰려서 부하 발생하는 문제점은 존재합니다.
쿼리 줄이기
영화 상세 조회
영화 상세 조회 시, 기본적으로 9개의 쿼리가 발생하여 쿼리 수가 상당히 많은 상황이었습니다.
이를 해결하기 위해 쿼리 수를 줄이는 방향으로 개선이 필요하다고 판단했습니다.
기존의 영화 상세 조회에서는 사용자가 작성한 리뷰, 평점, 좋아요 여부를 각각의 쿼리로 개별 조회하고 처리했습니다. 이로 인해 불필요한 다중 쿼리 호출이 발생해 성능 저하의 원인이 되었습니다. 이를 개선하기 위해, 리뷰, 평점, 좋아요 여부를 하나의 쿼리에서 동시에 조회하도록 쿼리를 수정했습니다.
public MovieDetailResponse getMovieDetail(Long movieId, Long userId) {
Movie movie = movieRepository.findMovieDetailInfo(movieId)
.orElseThrow(MovieNotFoundException::new);
FilmRatings filmRatings = movie.getFilmRatings();
if (filmRatings.equals(FilmRatings.ADULT) || filmRatings.equals(FilmRatings.RESTRICTED_RATING)) {
throw new AccessDeniedException("성인 유저만 조회 가능합니다.");
}
boolean isLiked = movieLikesRepository.findMovieLikesBy(movieId, userId).isPresent();
MyMovieReview myMovieReview = reviewRepository.findReviewBy(movieId, userId)
.map(MyMovieReview::of)
.orElse(new MyMovieReview(null, null, null));
MyMovieRating myMovieRating = movieRatingsRepository.findMovieRatingBy(movieId, userId)
.map(MyMovieRating::of)
.orElse(new MyMovieRating(null, null));
List<String> countries = movieCountriesRepository.findMovieCountriesByMovieId(movieId)
.stream()
.map(movieCountries -> movieCountries.getCountry().getNation())
.toList();
List<GenreType> genres = movieGenreRepository.findMovieGenresByMovieId(movieId)
.stream()
.map(movieGenre -> movieGenre.getGenre().getGenreType())
.toList();
List<PersonnelInfoResponse> personnels = getPersonnelInfoResponses(movie);
List<String> galleryImages = getGalleryImages(movie);
List<RatingDistributionResponse> ratingDistribution = movieRatingsRepository.findRatingDistributionByMovieId(
movieId);
return MovieDetailResponse.from(
movie,
isLiked,
myMovieReview,
myMovieRating,
countries,
genres,
personnels,
galleryImages,
ratingDistribution
);
}
쿼리 수정
public MovieDetailResponse getMovieDetail(Long movieId, Long userId) {
Movie movie = movieRepository.findMovieDetailInfo(movieId)
.orElseThrow(MovieNotFoundException::new);
UserMovieInteractionResponse userMovieInteraction = movieRepository.findUserMovieReviewAndRating(movieId,
userId)
.orElseThrow(() -> new RuntimeException("no review and rating"));
List<String> countries = movieCountriesRepository.findMovieCountriesByMovieId(movieId)
.stream()
.map(movieCountries -> movieCountries.getCountry().getNation())
.toList();
List<GenreType> genres = movieGenreRepository.findMovieGenresByMovieId(movieId)
.stream()
.map(movieGenre -> movieGenre.getGenre().getGenreType())
.toList();
List<PersonnelInfoResponse> personnels = getPersonnelInfoResponses(movie);
List<String> galleryImages = getGalleryImages(movie);
List<RatingDistributionResponse> ratingDistribution = movieRatingsRepository.findRatingDistributionByMovieId(
movieId);
return MovieDetailResponse.from(
movie,
userMovieInteraction,
countries,
genres,
personnels,
galleryImages,
ratingDistribution
);
}
@Query("""
SELECT new com.ureca.filmeet.domain.movie.dto.response.UserMovieInteractionResponse(
mr.id,
mr.ratingScore,
r.id,
r.content,
u.profileImage,
CASE WHEN ml.id IS NOT NULL THEN true ELSE false END
)
FROM Movie m
LEFT JOIN MovieRatings mr ON mr.movie.id = m.id AND mr.user.id = :userId
LEFT JOIN Review r ON r.movie.id = m.id AND r.user.id = :userId AND r.isDeleted = false
LEFT JOIN User u ON u.id = :userId
LEFT JOIN MovieLikes ml ON ml.movie.id = m.id AND ml.user.id = :userId
WHERE m.id = :movieId AND m.isDeleted = false
""")
Optional<UserMovieInteractionResponse> findUserMovieReviewAndRating(
@Param("movieId") Long movieId,
@Param("userId") Long userId
);
결과적으로 발생 쿼리 개수가 9개에서 7개로 줄어든 것을 확인했습니다.
커넥션 풀 조정
일반적으로 "트래픽이 많으니 당연히 더 많은 연결이 필요하겠지"라는 직관적인 판단을 합니다.
Oracle의 실제 성능 테스트 결과를 보면 사실이 아니라는 것을 확인할 수 있습니다.
https://www.youtube.com/watch?v=_C77sBcAtSQ
해당 영상의 결과는 커넥션 풀 크기를 2,048개에서 96개로 줄이자 응답 시간이 100ms에서 2ms로 줄어든 것입니다
왜 '더 많은 연결 = 더 나은 성능'이라는 공식이 틀렸을까요?
하나의 CPU 코어는 한 번에 하나의 작업(스레드)만 실행할 수 있습니다.
여러 작업이 동시에 실행되는 것처럼 보이지만, 사실은 순서대로 조금씩 나눠서 실행합니다.
이걸 시분할(Time-Slicing)이라고 합니다.
시분할은 마치 여러 작업을 동시에 하는 것처럼 보이게 만드는 '환상'입니다.
실제로는 A 작업을 하다 멈추고 → B 작업으로 전환 → 다시 A 작업하는 식으로 돌아갑니다.
다만 전환(컨텍스트 스위칭)할 때마다 시간이 들기 때문에 효율이 떨어질 수 있습니다.
그래서 CPU 코어 수보다 스레드가 너무 많으면 성능이 오히려 나빠질 수 있습니다.
스레드가 많아질수록 컨텍스트 스위칭이 잦아져서 시간이 낭비되기 때문입니다.
즉 스레드를 추가할수록 성능이 향상되는 것이 아니라 오히려 저하됩니다.
데이터베이스 성능의 병목은 주로 3가지에서 발생합니다.
- CPU – 데이터 처리 속도
- 디스크 – 데이터 읽기/쓰기 속도
- 네트워크 – 데이터 전송 속도
디스크와 네트워크 요소를 제외하고 CPU만 놓고 보면, 코어 수에 맞춰서 연결(커넥션) 수를 설정하는 게 좋습니다. 예를 들어, 8 코어 서버라면 8개의 연결이 가장 효율입니다. 그 이상 연결을 추가하면, 위에서 얘기한 것처럼 컨텍스트 전환(스레드 간 전환) 때문에 오히려 느려져서 성능이 저하됩니다.
하지만 메모리는 디스크나 네트워크만큼 느리지는 않습니다.
그래서 현실에서는 디스크와 네트워크를 무시할 수 없습니다.
데이터베이스는 일반적으로 디스크에 데이터를 저장하는데, 전통적인 하드 디스크는 스테퍼 모터로 구동되는 암에 장착된 읽기/쓰기 헤드가 있는 금속 플래터로 구성되어 있습니다. 읽기/쓰기 헤드는 한 번에 한 위치에서만 데이터를 읽거나 쓸 수 있으며(단일 쿼리용), 다른 쿼리의 데이터에 접근하려면 새로운 위치로 "탐색"해야 합니다. 따라서 탐색 시간이 소요되며, 플래터가 회전하면서 원하는 데이터가 헤드 위치까지 "돌아오기를" 기다려야 하는 회전 지연 시간도 발생합니다. 물론 캐싱이 이러한 문제를 완화해 주지만, 기본 원리는 여전히 유효합니다.
이러한 대기 시간("I/O 대기") 동안 해당 연결/쿼리/스레드는 디스크 작업이 완료되기를 기다리며 "블록" 상태가 됩니다. 이때 운영체제는 블록 된 스레드 대신 다른 스레드의 코드를 실행함으로써 CPU 리소스를 더 효율적으로 활용할 수 있습니다. 즉, 스레드가 I/O 작업으로 블록 되는 동안에는 물리적 CPU 코어 수보다 많은 연결/스레드를 가짐으로써 전체적인 처리량을 높일 수 있습니다.
그렇다면 얼마나 많은 연결이 필요할까요?
이는 디스크 시스템의 특성에 따라 달라집니다.
최신 SSD는 기계적인 "탐색 시간"이나 회전 지연이 없기 때문입니다.
하지만 "SSD가 더 빠르니까 더 많은 스레드를 사용해도 된다"는 생각은 완전히 잘못된 것입니다.
오히려 그 반대입니다. SSD는 더 빠르고 탐색이나 회전 지연이 없기 때문에 블로킹이 덜 발생하며, 따라서 더 적은 수의 스레드[코어 수에 가까운]가 더 많은 스레드보다 나은 성능을 보입니다. 스레드 수를 늘리는 것은 블로킹으로 인한 유휴 시간이 있을 때만 의미가 있습니다.
최적의 연결 수 계산 공식
활성 커넥션 수 = ((CPU 코어 수 * 2) + 유효 스핀들 수)
수년간의 벤치마크 결과를 통해 검증된 이 공식에 따르면, 최적의 처리량을 위한 활성 커넥션 수는 ((코어 수 * 2) + 유효 스핀들 수) 정도가 되어야 합니다.
스핀들이란, HDD의 물리적 플래터(데이터 저장 원반)를 움직이는 축을 의미하므로 유효 스핀들 수는 HDD에서 동시에 읽고 쓸 수 있는 독립된 경로를 의미합니다. 위 공식은 하이퍼스레딩(물리적 코어를 두 개의 논리적 스레드로 나누어 동작하게 하는 기능)이 적용되어 있더라도 HT스레드(논리적 스레드, 여기선 물리적 코어보다 많음)를 제외한 실제 물리적 코어 수만을 계산합니다.
그래서 위 공식을 다시 정리하자면,
- CPU 코어 수: 물리적 CPU 코어 수(하이퍼스레딩 무시).
- × 2: CPU 하나가 I/O 대기 시간을 줄이고 효율적으로 스레드를 처리할 수 있도록 두 개의 연결을 할당
- 유효 스핀들 수: 하드 디스크에서 동시에 데이터를 읽고 쓸 수 있는 경로 수
- SSD를 사용하면 유효 스핀들 수 = 0으로 계산
예제
4 코어 i7 CPU와 HDD
- 물리적 CPU 코어: 4개
- 유효 스핀들 수: 1 (하드 디스크가 1개)
예제
4 코어 i7 서버 + SSD:
활성 커넥션 수 = (4 * 2) = 8개의 커넥션이 최적
최종 결과
성능 비교
첫 번째 성능 테스트 (개선 전)
- 총 요청 수: 1,815
- 처리량: 5.92 requests/second
- 평균 응답 시간: 15,639 ms
- 에러율: 36.31%
최종 성능 테스트 (개선 후)
- 총 요청 수: 196,040
- 처리량: 640.62 requests/second
- 평균 응답 시간: 107 ms
- 에러율: 0.00%
결론적으로 목표했던 평균 응답시간 0.1초를 만족할 수 있었습니다.
마무리
이번 최적화 경험을 통해서
- 테스트 툴을 활용한 병목지점 분석의 체계적인 접근법
- 실행계획을 통한 쿼리 튜닝
- 커넥션 풀 최적화
을 배웠습니다.
API 개발은 단순히 만들어서 끝나는 것이 아니라, API의 성능을 측정하고, 병목 지점을 파악하며, 이를 개선하는 과정이 중요하다는 것을 깨달았습니다. 직접 만든 API의 한계를 분석하고, 성능을 최적화하여 개선 효과를 확인하는 과정은 저에게 큰 경험과 성취감을 안겨주었습니다.
특히, 초기 평균 응답 시간이 15초였을 때는 "이걸 어떻게 0.1초로 줄일 수 있을까?"라는 막막함이 있었습니다.
하지만 하나씩 문제를 분석하고 해결해 나가면서, 결국 평균 응답 시간 0.1초라는 목표를 달성할 수 있었습니다.
이 과정에서 멘토님과 팀원들의 조언은 큰 도움이 되었고, 덕분에 더 나은 개발자로 성장할 수 있었습니다.
최적화를 성공적으로 마쳤을 때의 기쁨은, 앞으로도 개발을 지속할 수 있는 강력한 원동력이 될 것이라고 생각합니다.
제 글을 읽어주셔서 감사합니다.
부족한 점이 많지만, 성능 개선에 있어 작은 도움이 되기를 바랍니다.
감사합니다. 😊
참고
https://maily.so/devpill/posts/e9o02g3ez8w
'프로젝트 > Filmeet' 카테고리의 다른 글
콘텐츠 기반 추천과 유사 사용자 기반 추천을 결합한 하이브리드 추천 시스템 만들기 (3) | 2024.12.19 |
---|---|
Redis 분산 락을 활용하여 동시성 문제 해결하기 (0) | 2024.12.04 |
@Query with "not in" not work with empty List parameter (0) | 2024.12.02 |
MultipleBagFetchException - 두 개 이상의 OneToMany 관계에서 N+1 문제 최적화하기 (0) | 2024.11.22 |
동적 조건 처리를 위한 Querydsl 사용하기 (0) | 2024.11.21 |