본문 바로가기

Stress Test

[Benchmark] Datasource 분리

이번 장에서는 시간이 많이 소비되는 요청(이하 비싼 요청)과 그렇지 않은 요청(이하 일반 요청)을 다른 Datasource에서 처리하도록 수정해보고 기존과 대비하여 어느정도 성능 향상이 있는지 알아본다.


지금까지의 부하 테스트와 벤치마크 결과를 자세하게 살펴보았다면 상당히 불합리한 결과가 보였을 것이다.
아래의 이미지와 같은 경우다. 실제로 요청을 처리하기위한 쿼리는 ms 단위에서 처리되었다. 하지만 다른 비싼 요청들 때문에 29초 동안 쿼리를 얻기위해 대기하였고 결론적으로 처리하는데 29초가 넘는 시간이 소비되었다.

즉, 몇몇 비싼 요청때문에 빠르게 처리되어야 할 요청까지 처리되지 못하였고 결과적으로 서비스가 전체적으로 느려진것이다. 몇몇 사용자의 비싼 요청때문에 누군가는 로그인하기 위해 30초를 기다려야할 수도 있고 심지어 실패할 수도 있다는 의미가 된다.

지금부터

  1. 비싼 요청과 일반 요청을 분리하지 않은 환경 (이하 A 환경)
  2. 비싼 요청과 일반 요청을 분리한 환경 (이하 B 환경)
    두 환경에서 테스트를 진행해보고 결과를 확인해보도록 한다.

테스트 환경

  • 요청은 비싼 요청 (2개), 일반 요청 (2개), 총 네 개의 요청으로 진행한다.
  • 50명의 사용자가 10분간 1분간격으로 무작위 요청을 한다.
  1. A 경우
    • read라는 DataSource 하나만 사용하며 DataSource는 20개의 Connection Pool을 사용한다.
  2. B 경우
    • read, heavy-read 두 개의 DataSource를 사용하며 각각 10개의 Connection Pool을 사용한다.

두 환경 모두 총 20개의 Connection Pool을 사용하므로 동등한 조건이라고 볼 수 있다.

결과

다른 벤치마크와 다르게 TPS가 아닌 평균 응답 시간으로 비교하였다.
50명이 10분간 1분간격으로 요청하는 것은 실제로 많은 요청이 아니기 때문에 TPS는 큰 의미가 없다.
여기서 요청수를 늘리는 경우 비싼 요청들은 전부 Timeout이 발생하는 상황이 발생하여 총 요청수를 줄이고 평균 응답 시간으로 비교하였다.

비싼 요청 평균 응답 시간 일반 요청 평균 응답시간 전체 평균 응답시간 비싼 요청 에러율 일반 요청 에러율 전체 평균 에러율
A 환경 79,365 13,900 46,391 3.34% 6.84% 5.09%
B 환경 101,134 102 51,606 0 0 0

분석

지금부터 분리해야만 하는 이유를 알아보도록 한다.

  • 전체 평균 응답시간이 비슷하다.
    A환경이 오히려 5,000ms정도 빨라보일 수 있다. 하지만 5%의 응답은 오류이므로 빠르다고 볼 수는 없다.
  • 분리하지 않으면(A환경) 일반 요청에서 에러가 발생하였다.
    비싼 요청때문에 일부 사용자들은 로그인하면서 에러가 발생할 수도 있다.
  • 분리하지 않으면(A환경) 일반 요청의 응답속도가 100배 이상 증가하였다.
    아래의 그래프를 보면 B환경의 응답시간은 시간이 지나도 일정하였지만 A환경에서의 응답시간은 시간이 지나면서 증가하였다.
    테스트 시간이 10분 이내의 증가 수치일 뿐 시간이 늘어난다면 더욱 많이 늘어날 것으로 예상된다.

A환경 응답시간 변화

B환경 응답시간 변화

비싼 요청과 그렇지 않은 요청을 분리하는 방법

application-*.yml 파일을 살펴보면

  • 기존
  datasource:
    read:
      driverClassName: net.sf.log4jdbc.sql.jdbcapi.DriverSpy

      -- 중략

    write:
      driverClassName: net.sf.log4jdbc.sql.jdbcapi.DriverSpy

      -- 이하 생략
  • 분리 이후
  datasource:
    read:
      driverClassName: net.sf.log4jdbc.sql.jdbcapi.DriverSpy

      -- 중략

    heavy-read:
      driverClassName: net.sf.log4jdbc.sql.jdbcapi.DriverSpy

      -- 중략

    write:
      driverClassName: net.sf.log4jdbc.sql.jdbcapi.DriverSpy

      -- 이하 생략

heavy-read Datasource가 추가된 것을 확인할 수 있다.
그러면 ReplicationRoutingDataSource.class파일을 확인하면서 어떠한 방식으로 분리하였는지 확인해본다.

  • 기존
public class ReplicationRoutingDataSource extends AbstractRoutingDataSource {

    @Override
    protected Object determineCurrentLookupKey() {
        String dataSourceType = TransactionSynchronizationManager.isCurrentTransactionReadOnly() ? "read" : "write";
        return dataSourceType;
    }
}
  • 분리 이후
public class ReplicationRoutingDataSource extends AbstractRoutingDataSource {

    private final String[] heavyReadUrls = {"/excel-download"};

    @Override
    protected Object determineCurrentLookupKey() {
        boolean isReadOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();
        if (Objects.isNull(isReadOnly) || Boolean.TRUE == isReadOnly) {
            HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
            if (Arrays.stream(heavyReadUrls).anyMatch(i -> request.getRequestURI().contains(i))) {
                return "heavy-read";
            } else {
                return "read";
            }
        } else {
            return "write";
        }
    }
}

기존의 경우 Transaction 어노테이션의 속성 중에 readOnly 속성이 True인 경우 read datasource를 할당하였으며 그렇지 않은 경우 write의 속성을 할당 한 것으로 확인된다.
분리 이후에는 우리가 지정해놓은 비싼 요청의 URL로 요청이 오는 경우 heavy-read datasource를 할당하고 그렇지 않은 경우 일반 read datasource를 할당하는 것을 알 수 있다.

지금까지 Heavy한 작업과 그렇지 않은 작업을 처리하는 DB Connection을 분리하는 방법과 분리 전 후의 벤치마크 결과를 분석해보았다.
아래는 추가로 RDS Connection과 비용에 대해서 작성해두었다.
필요하다면 한 번쯤 읽어보기를 추천한다.


RDS Connection과 비용

현재 필자가 운영하는 서비스의 Tomcat Max Thread의 수는 200개, Hikari Connection Pool (이하 HikariCP)의 수는 20개이다.
HikariCP의 수에서 10개는 Write 전용, 10개는 Read 전용이다.
즉, Tomcat Max Thread의 수와 상관없이 필자의 서비스는 한 번에 10개의 Read 요청밖에 처리할 수 없다.
그렇다면 HikariCP의 수를 늘리면 되는 것 아닌가라는 의문이 들 수 있다.
결론적으로 HikariCP의 수를 늘리면 성능이 개선된다.

하지만 HikariCP의 수를 늘리는 것은 Scale UP에 해당하며 당연히 서비스 유지비용 상승으로 직결된다.
특히 필자의 회사와 같이 많은 서비스들이 하나의 DB (이하 Main DB)에 의존적인 서비스는 Main DB의 Scale UP에 주의해야한다. 간략하게 Main DB의 Connection 을 상승시키면 얼마의 유지비용이 추가로 발생하는지 알아보겠다. (할인 정책은 전부 무시하고 공식 홈페이지에서 명시된 금액으로만 계산한다.)

현재 필자의 회사에서 사용하는 DB의 경우 Write Instance 1개, Read Instace 2개를 사용하고 있으며 사양은 db.r5.xlarge이다.

db.r5.xlarge의 경우 몇 개의 connection 까지 처리 할 수 있는지 공식 문서 (링크)에 나와있는대로 계산해보자.
현재 2,730의 Connection 까지 처리 할 수 있다. 사용가능한 100%를 사용하는 경우는 없으므로 안전한 수치인 80%를 사용가능 량이라고 계산해보면 2,184개의 커넥션까지는 안전하게 처리할 수 있다는 계산이 나온다.

// 공식
Max Connection = DBInstanceClassMemory / 12582880

// 현재 RDS의 Max Connection
2,730 = 34359738368 / 12582880

그러면 2,184개의 커넥션으로는 서버 운영이 힘들어져서 바로 위 사양의 Instance로 변경하여 4,368개의 Connection 까지 처리가능하게 사양을 올리면 얼마만큼의 비용이 상승될까. 현재 사용중인 db.r5.xlarge보다 한 단계 위의 사양은 db.r5.2xlarge이며 메모리가 64GB로 증가하여 대략 db.r5.xlarge Max Connection의 두 배를 처리가능할 것으로 예상가능하다. 자세한 DB Instance 사양은 여기 (링크)를 확인한다.

그렇다면 db.r5.2xlarge의 사양으로 변경하였을 때 한달에 얼마의 지출이 늘어나게 될지 알아본다.
RDS Instrance 유형별 가격표는 여기 (링크)를 확인한다.

  • db.r5.xlarge의 가격 ($284, ₩338,418)

  • db.r5.2xlarge ($568, ₩676,908)

인스턴스의 수는 3개이므로 총 비용과 총 비용의 차이를 알아본다.

  • db.r5.xlarge의 총 비용 = 338,418 * 3 = 1,015,254
  • db.r5.2xlarge의 총 비용 = 676,908 * 3 = 2,030,724

사양을 한 단계 올렸을 경우 매달 추가로 지출되는 금액은 1,015,470원 이다.
서비스가 커지면 커질수록 이 차이는 더 많아질 것이다.
Connection수가 필요하다고 바로 Scale Up을 하는 것이 아니라 우리가 작성하는 코드를 우선 최적화를 진행해보고 정말 최후에 Scale Up하는 습관을 들이는 것이 실력 상승으로도 금전적으로도 좋을 것으로 예상된다.

지금까지 DB 인스턴스의 사양을 한 단계 올렸을 경우 증가하는 비용에 대해서 알아보았다.

'Stress Test' 카테고리의 다른 글

[Benchmark] HikariCP 설정  (0) 2022.01.22
[부하 테스트] 분석 (2차)  (0) 2022.01.22
[부하 테스트] 결과 (2차)  (0) 2022.01.22
[부하 테스트] 분석 (1차)  (0) 2022.01.22
[부하 테스트] 결과 (1차)  (0) 2022.01.22