Post

캐시를 사용한 조회 성능 개선기

서비스의 사용자가 늘어나면 트래픽의 총량이 늘어나고 DB에 저장되는 데이터의 양도 늘어납니다. 개발자 입장에서는 서비스가 잘 운영된다는 지표이므로 기쁜 일이지만, 그동안 발견하지 못했던 성능 문제들이 하나씩 발견되기도 합니다.

조회 성능을 향상시키는 방법에는 여러가지가 있습니다. 비효율적인 쿼리 개선, JPA 사용시 N+1문제 해결 그리고 인덱스 적용을 통해서도 조회 성능을 비약적으로 향상시킬 수 있습니다. 하지만 성능 문제가 발생하는 API의 특성에 따라서는 캐시라는 수단을 사용할 수도 있습니다.

이번 글에서는 서비스의 성능 문제를 해결하기 위해 캐시를 도입하고 부하 테스트를 통해 성능 개선의 정량적 지표를 도출한 과정을 공유하고자 합니다.

성능 개선이 필요한 부분

현재 개선 중인 프로젝트는 사용자가 장소를 방문한 후 지인들과 메모리라는 기록을 공유하는 서비스입니다.

스크린샷 2023-12-11 오전 1 20 21

위의 화면은 사용자가 남긴 메모리의 상세 페이지입니다. 현재 DDD(도메인 주도 설계)의 개념을 적용해서 도메인 간의 관계를 객체 참조가 아닌 ID 참조로 분리했습니다. 따라서 연관 데이터를 결합하기 위해서는 도메인 별로 쿼리를 진행해서 최종 정보를 결합해야 합니다. 한번의 요청에 수반되는 쿼리가 많은 만큼 사용자가 증가했을때 얼마나 성능 저하가 발생하는지 테스트가 필요했습니다.

성능 테스트 기준 설정

성능 테스트를 진행할때는 테스트의 목적기준을 명확히 정해야 합니다. 무엇을 달성하기 위한 테스트인지 진행자가 제대로 이해하지 못한다면 부하 테스트는 의미 없이 끝나고 말 것입니다.

개발자는 사용자를 위한 서비스를 만드는 만큼 사용자들이 어떤 부분에 가장 민감한지를 파악해야 합니다. 저는 그중 하나가 응답시간이라고 생각합니다. 실제로 사용자들은 페이지 접속 후 3초가 넘어가면 접속을 포기하고 다른 페이지로 넘어간다는 말이 있습니다. 우리는 지금부터 한정된 서버 리소스 내에서 특정 응답시간 유지를 성능 개선의 목표로 설정해보겠습니다.

사용자에게 보장해야 하는 응답 시간을 지키기 위해 하드웨어 부분에서는 스케일 업이나 스케일 아웃을 진행할 수 있고, 어플리케이션 부분에서는 커넥션풀/쓰레드풀 튜닝, 인덱스 적용 그리고 캐시 적용 등을 고려할 수 있습니다.

목표 응답시간 설정

저는 서비스가 제공해야 하는 목표 응답 시간을 상위 95%에서 150ms로 설정했습니다. 여기서 상위 95%(p95)라는 지표를 사용했는데요. 서비스의 성능을 측정할때는 가장 이상적인 지표를 기준으로 삼는게 아니라 최악의 상황을 기준으로 삼아야 합니다. 그래야 모든 사용자가 일정한 수준의 서비스를 제공받는걸 보장할 수 있습니다.

서비스 내 대부분의 단건 요청들이 100ms 이내의 결과를 제공하기에 일정 수준의 지연을 감안하여 150ms로 여유를 두고 설정했습니다.

목표 RPS(Request Per Second)와 가상 유저

목표 응답 시간을 정했다면 목표 PRS가상의 동시접속자수를 설정해보겠습니다. 만약 지속적으로 운영한 서비스라면 APM 툴을 사용해 기존의 트래픽을 근거로 만들 수 있지만, 우리는 그렇지 못하기에 공식을 사용해서 수치들을 예상해보겠습니다.

1일 총 요청 수

  • DAU(Daily Access User) x 1명당 평균 요청 수
  • 50만명 x 5번 = 250만

1일 평균 RPS

  • 1일 총 요청 수 x 하루를 초로 환산
  • 250만 / 43200(초) = 평균 57RPS

최대 트래픽

  • 1일 평균 RPS x 피크시간 집중률
  • 저는 주말의 경우, 평일보다 최대 5배 정도 더 사용할 것이라 판단했습니다.
  • 57RPS x 5 = 최대 285RPS

vUser 산정

  • 최대 트래픽 x (시나리오1 응답시간 + 시나리오2 응답시간 + …) / 시나리오 개수
  • 285RPS x 0.15 = 45명

위의 공식을 기반으로 도출한 결과를 요약해보겠습니다.

285RPS에서 p95 150ms의 응답시간을 보장해야 한다.

DB 세팅

실제와 비슷한 성능 테스트 환경 구축을 위해 MAU 규모를 고려한 데이터 셋을 집어넣었습니다.

  • 유저 : 150만
  • 그룹 : 50만
  • 메모리 : 750만
  • 메모리 이미지 : 1500만

인프라 스펙

현재 Melly 서비스를 구성하는 인프라 스펙입니다.

  • 서버 EC2 2개 : t3.medium
  • RDS : t3.micro
  • 레디스 EC2 2개 : t2.micro
  • 핀포인트 Collector : t3.medium

성능 개선 전 테스트

성능 테스트 목표 설정과 데이터셋 적재를 완료했으니 이제 부하테스트를 진행해보겠습니다. 부하테스트 툴로는 여러가지가 있고 대표적으로 nGrinder, Jmeter, k6 등이 있습니다. 각가의 장단점이 있지만 저는 그중에서 k6를 선택했습니다.

k6의 장점은 크게 3가지가 있다고 생각합니다.

  • Javascript를 사용해서 이해하기 쉬운 스크립트 작성 가능
  • 친절한 Document 제공
  • Go 언어 기반으로 최적화된 성능 확보

성능 테스트는 총 10분동안 진행했고 처음에는 작은 부하로 시작해서 점차 부하를 늘려가는 방식으로 진행했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export const options = {
  stages: [
    { duration: '1m', target: 15 }, // 1분동안 vUser를 15명으로 올린다
    { duration: '2m',target: 15 }, // 2분동안 vUser를 15명으로 유지한다
    { duration: '1m', target: 25 },  // 1분동안 vUser를 25명으로 올린다
    { duration: '2m', target: 25 }, // 2분동안 vUser를 25명으로 유지한다
    { duration: '1m', target: 35 },  // 1분동안 vUser를 35명으로 올린다
    { duration: '2m', target: 35 }, // 2분동안 vUser를 35명으로 유지한다
    { duration: '1m', target: 0 } // 1분동안 천천히 vUser를 0명까지 줄이고 테스트를 종료한다
    
  ],
    thresholds: {
        http_req_failed: ['rate<0.01'], // 에러율이 1% 미만이여야 한다
        http_req_duration: ['p(95)<150'], // 95% 응답시간이 150ms 이하여야 한다
    },
};

테스트를 진행하는 API는 메모리 식별자를 랜덤으로 생성 후 파라미터로 전달해서 동일한 데이터가 반복 호출되지 않고 균등하게 호출이 되도록 만들었습니다.

스크린샷 2023-12-14 오전 4 52 31

가상 유저는 해당 그래프처럼 계단식으로 증가하게 됩니다. 테스트를 진행한 후 결과가 어떻게 나왔는지 확인해보겠습니다.

vUser RPS 응답시간(p95)
15 218 117.07ms
35 265 243.31ms
45 263 313.50ms

상단 표를 보면 vUser 35에서 이미 응답시간이 240ms가 넘었습니다.

스크린샷 2023-12-27 오후 1 26 27

핀포인트를 사용해서 어느 부분에서 얼마나 시간이 걸렸는지 확인해봤습니다. 여러 쿼리를 호출하다보니 이미 내부적으로 215ms가 넘는 시간을 사용한 것을 알 수 있습니다.

스크린샷 2023-12-27 오후 10 02 12

CPU 또한 80% 가까이 사용되고 있습니다. 만약 현재 로드밸런싱 환경에서 서버가 한대라도 죽어버린다면 나머지 서버도 CPU 부하를 견디지 못하고 죽어버릴 가능성이 있습니다.

우리는 285RPS에서 p95 150ms를 보장하도록 성능을 개선해야 합니다. 어떤 방법을 사용할 수 있을까요? 저는 이 부분에서 캐시 도입을 생각했습니다.

캐시 적용 근거

캐시는 DB에서 데이터를 조회하거나 타 서버로부터 네트워크 통신을 통해 데이터를 얻어오는 작업을 줄여줌으로써 극적인 성능 개선을 이끌어낼 수 있습니다. 이쯤되면 모든 조회 API에 캐시를 적용하는게 합리적으로 보이지만, 캐시는 적용하기 적절한 케이스들이 존재하고 비효율적으로 사용할 경우 매번 Cache Miss가 발생하여 캐시를 적용하기 전과 비교해서 별 다른 개선을 못 이끌어낼 수 있습니다.

따라서 캐시를 사용하기 적절한 상황인지 판단하는게 중요합니다. 아래는 제가 생각하는 캐시 적용이 적절한 케이스입니다.

  • 조회의 비중이 높은 기능인가
  • 수정될 가능성이 적은가
  • 내부적으로 복잡한 연산, 쿼리, 외부 API 호출을 포함하는가

우선 메모리 상세 보기 기능은 Melly 서비스의 가장 핵심 기능 중 하나라 할 수 있습니다. 따라서 가장 많은 유저가 몰리는 페이지입니다. 하지만 유저들이 한번 작성한 메모리의 내용을 자주 변경할 가능성은 낮다고 생각했습니다. 그리고 여러 도메인의 정보를 조합해야 하는 만큼 DB 쿼리가 많이 발생합니다. 메모리 상세 보기 페이지가 조건을 충족한다 판단했고 캐시를 적용하기로 결정했습니다.

TTL(Time To Live) 설정 기준

캐시를 적용하기로 결정했다면 다음으로 고려해봐야 하는건 TTL이라고 생각합니다. 현재 개선을 진행중인 페이지는 여러 도메인의 정보들이 합쳐져 있습니다. 따라서 하나의 도메인의 변경이 상세 페이지에도 영향을 미칠 가능성이 높습니다. 하지만 그 변경사항을 통해 메모리 상세페이지의 캐시를 바로 Invalidation 시킬 수는 없습니다.

예를 들어 그룹의 이름과 아이콘을 변경하는 작업을 수행했을때 그룹은 메모리의 식별자를 가지고 있지 않기에 특정 상세 페이지를 Invalidation할 수 있는 단서가 없습니다. 따라서 이 부분에서 오는 데이터 불일치는 직접적인 Invalidation을 수행할 수 없습니다.

그렇다면 메모리 상세의 TTL을 어느정도 짧게 설정함으로써 최대한 데이터 동기화를 보장하는게 중요하다고 생각합니다. 저는 메모리 상세페이지의 TTL을 5분으로 설정했습니다.

캐시의 TTL은 너무 길게 잡으면 데이터의 정합성이 떨어지고 너무 짧게 잡으면 캐시를 사용하는 장점이 희석됩니다.

현재 메모리 상세 페이지를 구성하는 도메인 중 가장 변화의 가능성이 큰 데이터는 그룹 데이터입니다. 사용자가 그룹 화면으로 진입해서 데이터를 수정한 뒤 바로 자신의 그룹과 함께 공유한 메모리를 조회할 가능성은 낮다고 생각했습니다. 또한 만약 전체 공개로 작성한 메모리에서 다른 사용자들이 이전 그룹 정보를 보게 된다고 해도 크게 문제가 되지는 않는다고 생각했습니다. 따라서 저는 리프레시 주기로 5분 정도가 적절하다고 판단했습니다.

만약 메모리 상세 페이지에 차후 사용자들이 변경에 민감한 데이터가 추가된다면 리프레시 주기를 줄이는 방향으로 조절하면 된다고 생각합니다.

그렇다면 이제 캐시를 도입해서 성능이 얼마나 개선되는지 확인해보겠습니다.

캐시를 통한 성능 최적화

캐시를 적용한 후 동일 조건에서 테스트한 결과 아래의 표와 같은 결과가 나왔습니다.

vUser RPS 응답시간(p95)
15 333 77.26ms
35 417 145.26ms
45 418 206.71ms

vUser가 15명인 구간에서 이미 333RPS 응답 시간 77ms의 수치를 보여줍니다. 기존에 목표했던 285RPS 150ms를 훨씬 상회하는 결과입니다. 조금 더 성능을 극대화 하기 위해 vUser 35명 까지 증가시키면 417RPS 145ms까지 증가하고, 그 뒤로는 RPS는 증가하지 않고 응답시간만 늘어나고 있습니다. 이렇게 더 이상 성능이 개선되지 않는 지점을 Saturation Point라고 합니다.

스크린샷 2023-12-27 오후 2 43 45

핀포인트로 다시 조회했을때 내부 처리 시간이 200ms에서 40ms로 크게 감소한 걸 알 수 있습니다.

스크린샷 2023-12-27 오후 10 10 48

CPU 또한 60%대로 안정적인 수치를 유지하고 있습니다.

결론적으로 저희 서비스의 개선 후 현재 상태를 아래와 같이 정리할 수 있습니다.

417RPS에서 p95 응답시간 150ms 이내를 유지

물론 여기서 더 성능을 최적화 하기 위해서 할 수 있는 조치들이 많을 것이라 생각합니다. 하드웨어 스케일 업을 진행할 수 도 있고, 현재는 2대의 서버만 로드밸런서에 연결되어 있지만 스케일 아웃을 통해 부하를 더 분산시킬수도 있습니다.

결론

성능 테스트를 통해 확인해야 하는 지표는 정말 많습니다. JVM 지표를 통해 자바 어플리케이션이 효율적으로 동작하고 있는지도 체크해야 하고 TCP 소켓 관련 모니터링을 통해 추가적인 커넥션이 맺어질 여유가 있는지도 체크해야 합니다. 앞으로도 성능 테스트와 서비스 모니터링에 대해 학습해야할 내용이 정말 많은 것 같습니다.

다음에는 좀 더 다양한 지표를 모니터링해서 문제를 분석하는 글로 돌아오겠습니다.

This post is licensed under CC BY 4.0 by the author.