본문으로 건너뛰기
20줄짜리 회원 조회 코드에서 찾은 다섯 가지 성능 병목
목차

20줄짜리 회원 조회 코드에서 찾은 다섯 가지 성능 병목 (2023-07)

원문 정보

트래픽 150% 증가에 응답 시간 1000% 증가

어카운트팀은 성수기를 앞두고 인증 담당 서비스를 분리하고, 읽기와 쓰기 트래픽을 나누고, 캐시를 넣는 개편을 마쳤습니다. 그런데 대규모 프로모션 첫날 “느리다"는 신고가 들어왔습니다. 처음에는 이벤트가 성공했다는 신호로 받아들였지만 수치는 달랐습니다. 트래픽은 평소보다 약 150% 늘었는데 응답 시간은 1000% 이상 늘어 있었습니다.

원인은 개선 대상에서 빠져 있던 회원 서비스였습니다. 쏘카의 각 서비스가 회원 정보를 요청하면 회원 서비스가 인증 서비스에서 인증 정보를 받아 합쳐 돌려주는 구조입니다. 팀은 문제가 없다고 보고 손대지 않았던 곳입니다. 코드를 열어 보니 선언부까지 합쳐 20줄이 안 되는 깔끔한 조회 메서드였습니다. 팀은 이 짧은 코드에서 문제를 하나씩 찾아냈습니다.

읽기 메서드의 쓰기 트랜잭션과 길어진 커넥션 점유

조회 메서드에는 쓰기용 @Transactional이 붙어 있었습니다. JPA 영속 컨텍스트의 변경 감지, 쓰기 지연, 원본 스냅샷은 읽기만 하는 메서드에는 필요 없는 기능입니다. 팀은 readOnly = true로 바꿔 이 기능들을 끄고, 지연 로딩은 그대로 쓸 수 있게 했습니다.

더 큰 문제는 커넥션 점유 시간이었습니다. auto-commit 기본값에서는 트랜잭션이 시작될 때 DB 커넥션을 얻습니다. 그런데 메서드 안에는 외부 API 호출과 DTO 변환처럼 DB와 무관한 작업이 섞여 있었고, 그동안 커넥션을 쥔 채라 다른 스레드가 연결을 기다렸습니다. 팀은 커넥션 수를 늘리는 대신 빨리 쓰고 반납하는 쪽을 근본 해법으로 골랐습니다. 늘어난 스레드끼리 자원을 다투면 오히려 성능이 떨어질 수 있다고 봤기 때문입니다. 메서드의 트랜잭션 선언을 지우고 readOnly 트랜잭션을 Repository 인터페이스에만 붙여 범위를 좁혔습니다.

객체를 JSON으로 바꿨다 되돌리던 변환 코드

엔티티를 DTO로 바꾸는 코드는 ObjectMapper로 객체를 JSON 문자열로 쓴 뒤 다시 읽어 DTO를 만들었습니다. 두 번 모두 리플렉션을 쓰기 때문에 JIT 컴파일러가 메서드 인라이닝 같은 정적 최적화를 하기 어렵습니다. 변환마다 클래스 정보와 JSON 문자열이 힙에 쌓여 GC도 잦아집니다. 팀은 필드를 하나씩 옮겨 담는 생성자 호출로 바꿨습니다.

Tomcat 스레드 200개를 30개로

Pod는 1 core, 1GB 메모리로 돌고 있었고 Embedded Tomcat은 설정이 없으면 최대 200개 스레드를 만듭니다. 팀은 스레드 수를 바꿔 가며 성능 테스트를 했습니다. 스레드가 200개일 때 처리량이 가장 낮았고, 트래픽이 몰릴수록 컨텍스트 스위칭이 늘어난다는 점을 vmstat 지표에서 확인했습니다. API에 따라 결과가 달랐지만 전반적으로 30개 안팎이 가장 안정적이어서 최대 30개로 정했습니다.

첫 요청을 위한 웜업과 8배 개선

JVM은 클래스를 처음 참조할 때 로딩하므로 구동 직후 응답이 느렸습니다. 팀은 애플리케이션이 준비되면 외부 트래픽을 받기 전에 주요 API를 한 번씩 호출하는 리스너를 추가했습니다. 같은 호출이 JIT 최적화를 유도하는 효과도 있다고 설명합니다. 웜업이 실패해도 로그만 남기고 기동은 계속됩니다.

이렇게 고친 뒤 그래프에서 개선 전후를 비교하면 약 8배 개선됐고, 응답 편차는 2~10ms로 줄었습니다. 원문은 이 과정이 특별한 비법이 아니라 알고 있던 개념을 조합한 결과였고, 사소한 실수가 쌓이면 성능을 해친다는 점을 확인했다고 정리합니다.

Tomcat 최대 스레드별 성능 테스트 결과
최대 Tomcat 스레드Pod당 RPS응답 편차
200500불안정
100800불안정
501100다소 불안정
301300안정적
201300불안정
마지막 수정 일자