문제 상황
소프티어 부트캠프 5기에 참여해서 개발한 팀 프로젝트에서,
대학생을 위한 버스 쉐어링 예매 서비스를 개발했다.
서비스의 큰 특징은 유저가 원하는 탑승지를 설정할수 있다는 것이다.
후에 어드민이 버스 노선을 배차해준다. (버스의 정원은 15명 이기 때문에, 최대 15명의 유저를 경유하도록 노선을 배차해준다)
노선을 배차해줄때는, TMAP의 경유지 최적화 api를 이용하였다.
https://openapi.sk.com/products/detail?linkMenuSeq=38
SK open API
장소 장소 검색 장소 통합 검색 장소 상세 정보 검색 주변 카테고리 검색 읍면동/도로명 검색 지역 분류 코드 검색 경로 반경 검색 지오펜싱 공간 검색 영역 검색 지오코딩 Reverse Geocoding Geocoding F
openapi.sk.com
Tmap api에서 생성된 모든 point 정보를 DB에 저장한다.
DB에 저장하는 이유는 아래와같은 페이지에서, 버스 경로를 볼수 있어야 하기때문이다.
DB에 저장되는 points의 수는 평균적으로 15000~ 20000개이다,
유저의 수가 많아지면, 100명만 동시에 조회를해도 200만개의 레코드를 조회하는 것이다.
트래픽이 더 많아지면 장애가 날수 있다는 생각이 들었다.
JMeter를 통한 부하 테스트 결과
통계청 기준 2024년 서울/경기 재학생 수는 942,227명이다.
그중 10%가 현재 우리 서비스를 이용하고 있다고 가정한다면 , 94222명이 된다.
그중 1%인 1000명이 동시에 요청하는 상황을 가정하였다.
JMeter 파라미터
- users : 1000명
- ramp-up-time : 1초
- loop-count : 1회
(1000명이 한꺼번에 버스 경로를 조회하는 상황을 가정)
결과
응답이 대부분 Time out error가 발생해 장애가 발생한다. (Hikaricp의 기본 timeout은 30초이다)
원인 분석
Grafana로 분석을 한결과, Memory에 이상이 있음을 확인하게 되었다.
1.6만개의 데이터를 메모리에 로드 하고, 클라이언트에게 반환하는 과정에서 엄청난 양의 메모리가 순식간에 적재된다.
그결과, Eden 영역이 순식간에 꽉 차게 되면서, Minor GC가 발생하고, Stop the world가 발생하는 것을 확인 할 수 있다.
이렇게 발생한 STW때문에 콘보이 효과가 발생하여 점점 요청이 딜레이 되는것이라고 추측하였다.
따라서 메모리 성능 개선을 통해 STW를 줄이면 성능 향상이 되지 않을까 가정해보았다.
개선1 - JPA의 Interface Projection를 지양하자
필요한 컬럼만을 Projection하기 위해,JPA에서 제공하는 Interface Projection을 사용하고있었다.
이게 내부적으로 어떤 원리로 동작하는지 알지 못하고 그냥 사용하고 있었다.
Interface Projection은 내부에 프록시 객체를 생성해서 맵핑을 시켜주고 있다고 한다.
따라서 반환 DTO + 프록시 객체까지 추가적인 메모리 오버헤드가 발생한다.
쿼리 결과가 커질수록, 프록시 객체가 커져서 오버헤드가 커진다.
검색을 해보니 나와같은 문제를 겪는 사람을 쉽게 발견할 수 있었다
(https://github.com/spring-projects/spring-data-commons/issues/2831)
따라서 기존 Interface projection에서 Dto Projection으로 코드를 수정하였다.
결과
메모리 할당이 미세하게 줄어들었고, 특히 promoted(Eden -> Old로 이동하는 객체의 양)가 현저히 줄어들었다.
문제였던 STW의 시간이 1/4로 줄어든 것을 볼 수 있었다.
부하 테스트 결과 기존에는 82%의 에러가 발생하던것을, 이제는 에러 하나없이 모든 응답이 성공한다.
결과적으로는 26000ms -> 13000ms 로 100%의 성능 향상을 기록했고, 타임아웃 에러가 없어졌다는것을 포함하면 훨씬 더 많은 성능향상을 이룬것이다.
Interface Projection에서 DTO Projection으로만 변경했을 뿐인데 엄청난 성능 개선이 이루어져서 신기하기도 했지만 한편으로는 기술을 제대로 이해하지 않고 쓰고 있었다는 것이 부끄러웠다.
개선2 - MySQL + SSE를 이용한 Streaming
한번에 대량의 데이터가 조회되는 경우, 페이지네이션 처리를 하는 것이 일반적이다.
하지만 지도 경로의 특성상 어차피 모든 길이 불러와져야하고, 페이지네이션을통해 여러번 API를 호출하게 되면 오히려 네트워크 부하만 심해지게 된다.
따라서 MySQL에서 지원하는 Streaming을 사용, 필요할때마다 조금씩 불러와서 메모리를 효율적으로 쓰는 방식을 선택하고자 한다.
가져온 청크는 SSE emitter를 통해 즉시 클라이언트에게 반환되고 청크는 GC에 의해 즉시 소멸된다.
이러한 아이디어는 소프티어의 다른 팀의 발표에서 나온것인데, 문제 상황이 같아 아주 인상적으로 들었고, 이 아이디어를 사용해보기로 했다.
하지만 JPA에서는 Streaming을 지원하지 않는다.
관련하여 검색을 하다보면, JPA repository의 반환형에 Stream 타입을 쓰면 자동으로 Streaming을 지원을 해준다는 식으로 설명과 예제가 많았다. 그것은 사실이 아니다. DB에서 데이터를 조회해오면, 메모리 버퍼에 적재 후, 그것을 Stream API로 받아오는것이다. 즉 메모리 절감 효과는 없다. 나 또한 실제로 그렇게 구현을 했다가 오히려 메모리 소모만 많아지고, 성능만 하락하였다.
(참고 블로그 : https://jonghoonpark.com/2024/10/30/mysql-streaming)
MySQL JDBC API 공식문서에는 다음과 같이 설명한다:
By default, ResultSets are completely retrieved and stored in memory. In most cases this is the most efficient way to operate and, due to the design of the MySQL network protocol, is easier to implement. If you are working with ResultSets that have a large number of rows or large values and cannot allocate heap space in your JVM for the memory required, you can tell the driver to stream the results back one row at a time.
To enable this functionality, create a Statement instance in the following manner:
stmt = conn.createStatement(java.sql.ResultSet.TYPE_FORWARD_ONLY, java.sql.ResultSet.CONCUR_READ_ONLY); stmt.setFetchSize(Integer.MIN_VALUE);
따라서 JPA를 이용하는것이 아니라, JDBC Connection을 이용해서 직접 구현하였고, 그 결과는 다음과 같다:
13000ms -> 11000ms의 개선을 이루었다.
개선3 - SseEmitter 대신 ResponseBodyEmitter를 사용하자
스트리밍 방식에서 굳이 sse를 쓸 필요는 없다고 판단했다,
SseEmitter는 ResponseBodyEmitter를 상속받는다. 장기적이고 안정적인 연결을 위해 추가적인 헤더가 붙는다.
현재 상황은 클라이언트에게 빠르게 전송하는게 목적이기 때문에, SseEmitter를 사용하는것이 오버헤드가 발생한다고 생각했다.
따라서 SseEmitter를 ResponseBodyEmitter로 변경해주었다.
그결과 11000ms -> 8482ms 의 개선을 이루었다.
결론
2만개의 레코드를 1000명이 동시 조회 (2000만건) 상황에서 성능 저하와 장애 상황이 발생하였다.
Grafana로 분석 결과, 메모리에 한꺼번에 많은 객체가 적재되고, 이때문에 GC의 Eden영역이 순식간에 가득 차 Stop The World가 빈번하게 발생하는 문제 상황을 발견하였다.
따라서 메모리 절감을 위해, 기존 Interface projection을 사용하던 것을 DTO projection으로 변경하였고, STW가 4배 줄어들어 장애가 해결되었다.
그럼에도 불구하고 성능은 그렇게 좋지 못했기 때문에 추가적인 개선이 필요했다.
MySQL의 Streaming방식과 ResponseBodyEmitter를 이용해 13000ms -> 8000ms으로 성능을 개선하였다.
8000ms도 그렇게 좋은 성능은 아니지만, 최악의 상황에서 장애가 나지 않고 성능을 많이 개선하는것에 의의를 두고싶다.
또한 현재는 단일서버이기때문에, Jmeter로 쓰레드를 1000개 동시에 할당하는것은 애초에 물리적으로 지연이 발생 할 수 밖에 없는 것 같다.
Tomcat이 200개의 쓰레드풀을 가지고있고, DB 연결은 Hikari CP의 connection pool이 20개 이기때문에, DB에서 어쩔수 없는 병목이 발생한 것 같다.
이것을 개선하기 위해선, Cloud 서비스를 이용한 분산 아키텍쳐로의 개선이 필요하고, 혹은 DB사용이아니라 Redis를 통해서 캐싱하는 것이 필요할 것 같다. 경우에따라서는 로컬캐시도 사용하는것이 좋을 것 같다.
전 : 27000ms , 88% error
후 : 8000ms , 0 % error
깃허브 리포지토리 주소
https://github.com/keemjoonsung/dingdong-api
GitHub - keemjoonsung/dingdong-api: (소프티어 5기) 대학생 맞춤형 통학 버스 예매 "딩동" 백엔드 리포지토
(소프티어 5기) 대학생 맞춤형 통학 버스 예매 "딩동" 백엔드 리포지토리 입니다. Contribute to keemjoonsung/dingdong-api development by creating an account on GitHub.
github.com
'Trouble Shootings > 성능 개선' 카테고리의 다른 글
[Spring boot] Redis로 JWT 관리하기 (1) | 2024.10.09 |
---|---|
[Spring Boot] JPA N + 1 문제 직면과 해결, 그리고 Fetch Join (4) | 2024.10.01 |
[Springboot + Redis] 레디스를 이용한 캐싱을 통해 API 성능 개선하기 ( +JMeter) (0) | 2024.09.27 |