대외활동/KB IT's Your Life

[KB IT's Your Life] 부하 테스트 및 성능 최적화 (K6, APM [Scouter], 인덱싱, 쿼리 최적화)

대기업 가고 싶은 공돌이 2025. 8. 23. 01:23

Dolfin 프로젝트

KB It's your life 과정에서 프로젝트를 진행하며, 마지막 주간에 성능 최적화 과정을 진행중이다.

 

우선 성능 최적화의 목표를 정해보겠다.

 

성능 최적화 목표치

우리 프로젝트의 1년 이내 목표 시장은 7만명이다.

 

7만명을 반올림하여 10만명의 유저를 수용한다고 가정하자.

 

물론 DAU(일일 활성 사용자)를 전체 유저로 잡진 않지만 우리는 최대한 보수적으로 잡고 테스트를 진행하기로 했다.

 

DAU를 10만명으로 잡고, 보통 10% 정도의 유저의 동시 접속을 최대 부하로 잡는다.

 

하지만 우리는 10만명의 동시접속을 목표치로 잡았고, 서비스의 메인 시나리오에 대해 성능 최적화를 진행하기로 했다.

 

서비스 메인 시나리오

우리 서비스의 주된 유저 시나리오는 다음과 같다.

  1. 로그인
  2. 메인페이지 접속(총 3개의 api 호출)
  3. 최근 거래 내역 조회 api 호출
  4. 송금 api 호출

약 2분간 총 6개의 api가 호출된다.

 

2분간 6개의 api라 함은, 120초 동안 6개의 api를 호출한다는 의미이며,

 

유저 한 명당 0.05 TPS를 요구한다는 의미가 된다.

 

 

10만 명의 동시 접속을 목표로 잡는다면, 

5000 TPS가 우리의 목표치가 된다.

 

우리의 시스템 아키텍쳐는 메인서버에 ASG가 설정돼 있고, 최대 5개까지 서버 증설이 자동적으로 이뤄진다.

 

5개의 서버로 로드밸런싱이 이뤄지니, 1개의 서버당 1000TPS를 목표치로 설정하고 성능 최적화를 진행했다.

 

에러율 및 목표 응답 시간

부트캠프 강사님은 에러율은 무조건 99.9% 이내, 응답 시간은 보통 프론트 + 백엔드를 합쳐 3초 이내라고 하셨다.

 

https://odown.com/blog/api-response-time-standards/?utm_source=chatgpt.com

 

API Response Time Standards: What's Good, Bad, and Unacceptable

Discover industry benchmarks for API response times and learn how to monitor, optimize, and set SLAs for fast, reliable, and scalable API performance.

odown.com

Odown.com이라는 API 모니터링·장애 감지 서비스 업체는 다음과 같이 정의했다.

 

일반 API 응답 시간 범주

동급 최고의 성능:

  • 100~300ms: 매우 우수하고 즉각적으로 인식됨 
  • 300~500ms: 매우 좋음, 대기 인식이 최소 수준임
  • 500~800ms: 양호, 눈에 띄지만 복잡한 작업에는 허용 가능
  • 800~1000ms: 보통, 느린 것으로 인식됨
  • 1000~2000ms: 좋지 않음, 사용자 이탈 위험 있음 
  • 2000ms+: 대부분의 사용 사례에 적합하지 않음

우리는 1번 즉 1000 TPS에서 95% 이내의 응답이 300ms 안에 처리되는 것을 목표로 잡았다.

 

커넥션 풀 사이즈 검증 루프

커넥션 풀 사이즈 검증 공식은 대표적으로 다음 두 가지를 예시로 들 수 있다.

 

  1. 리틀의 법칙
    • TPS = 커넥션 풀 사이즈 / 평균 처리 시간(데이터베이스의)
  2. 오라클에서 실험 후 정의한 스레드 풀 개수
 

About Pool Sizing

光 HikariCP・A solid, high-performance, JDBC connection pool at last. - brettwooldridge/HikariCP

github.com

 

커넥션 풀 사이즈는 보통 CPU 코어 수를 따라가야한다.

 

운영체제 시간을 복기해보자 -> 커넥션 풀 사이즈를 많이 늘린다면, 불필요한 컨텍스트 스위칭이 늘어나 성능이 악화된다.

 

커넥션 풀 사이즈와 CPU 코어 수를 동일하게 가져가면? -> CPU가 작업 후 I/O 블로킹이 걸렸을 때 다른 작업을 수행할 스레드가 없어
손해가 발생한다.

 

리틀의 법칙이 저렇게 나온 이유를 살펴보자

리틀의 법칙은 데이터베이스의 하드웨어를 고려하지 않고 현재 서비스의 상황에 맞추어 목표로 하는 TPS를 구하기 위해,
잡아야하는 커넥션 수를 알려주는 공식이다.

  • 커넥션 풀 사이즈란 동시에 처리하는 최대 요청 수라 의미할 수 있고, 평균 처리 시간이란 대기 후, 처리되는 데까지 걸리는 시간을 의미한다. 동시에 처리되는 요청수와 평균 응답 시간(초 단위)을 나눈다는 의미는 즉, 1초에 처리할 수 있는 요청 수를 구한다는 의미가 된다.

오라클에서 정의한 공식도 살펴보자

cpu *2 인 이유는 I/O 블로킹을 고려하여 CPU의 두 배인 커넥션 풀을 잡은 것이다.

유효 스핀들 수는 원래 하드 디스크에서 스핀들을 돌려가며 데이터를 탐색하는 시간이 오래 걸리기에 그만큼 I/O 블로킹이 늘어난다 가정하고 오라클에서 저렇게 공식을 정의했던 것이다.

그러나, SSD를 사용하는 요즘 데이터를 탐색하는 소요되는 시간이 거의 없어진 추세라 커넥션 풀 사이즈를 CPU 코어 수와 거의 비슷하게 가져간다고 한다. 그래도 블로킹에 소요되는 시간을 무시할 수는 없으니 CPU 코어 수 * 2 정도의 커넥션을 부여하고, 유효 스핀들 수를 무시하는 전략으로 커넥션 풀 사이즈를 조정하도록 하자.

 

결론

오라클에서 정의한 공식에 기반하여 커넥션 풀사이즈를 정한 후 테스트를 진행하고,

나온 평균 시간을 기반으로 리틀의 법칙을 사용하여, 1000TPS를 구하기 위해 필요한 커넥션 수를 구한다.

 

만약 리틀의 법칙을 통해 나온 커넥션 풀 사이즈의 갯수가, 오라클에서 정의한 공식에 의해 나온 커넥션 풀 사이즈 보다 적다면?

훌륭하게 최적화를 완료한 것으로 간주할 수 있다.

 

우리 AWS의 RDS는 Vcpu2개 + 버스트 시 cpu 일시적으로 추가 기능을 제공한다.

 

버스트 기능은 의존하는 게 좋지 않으니 (2 + (0.5) * 2) 로 버스트 기능을 0.5 cpu로 간주하고, 커넥션 풀 사이즈를 5개로 조정하자. 

 

이제 부하테스트를 시작해보자.

 

K6를 이용한 부하테스트

// k6script-stress-1000.js
import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
scenarios: {
transfer_scn: {
executor: 'ramping-arrival-rate',
exec: 'transfer',
startRate: 0,
timeUnit: '1s',
preAllocatedVUs: 250,
maxVUs: 1500,
stages: [
{ target: 200, duration: '1m' },
{ target: 400, duration: '1m' },
{ target: 600, duration: '1m' },
{ target: 800, duration: '1m' },
{ target: 1000, duration: '2m' }, // 피크 유지
{ target: 0, duration: '30s' }, // 쿨다운
],
gracefulStop: '30s',
},
},
thresholds: {
http_req_failed: ['rate<0.01'], // 에러율 1% 미만이면 OK로 간주
http_req_duration: ['p(95)<300'], // p95 0.3s 미만이면 OK로 간주
},
};

export function setup() {
const res = http.post(
`${__ENV.BASE_URL}/auth/signin`,
JSON.stringify({ loginId: __ENV.LOGIN_ID, password: __ENV.PASSWORD }),
{ headers: { 'Content-Type': 'application/json' } }
);
check(res, { 'login 200': (r) => r.status === 200 });
const token = res.json()?.data?.accessToken;
check(token, { 'got accessToken': (t) => !!t });
return { token };
}

function authHeaders(token) {
return {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
'Idempotency-Key': Math.random().toString(36).slice(2),
},
tags: {
// Grafana/Prometheus에서 구분 보기 좋게
api: 'unknown',
},
};
}

export function transfer(data) {
const opts = authHeaders(data.token);
opts.tags.api = 'transfer';
const r = http.post(
`${__ENV.BASE_URL}/transfer/phone-num`,
JSON.stringify({
phoneNumber: '',
amount: '',
password: 1234,
}),
opts
);
check(r, {
'transfer ok': (res) => res.status === 200 || res.status === 201,
});
sleep(0.02);
}

export function handleSummary(data) {
return { 'reports/transfer-test-1.html': htmlReport(data) };
}

 

위와 같이 K6 스크립트를 작성하고 점진적으로 부하를 올려 1000TPS 에서 2분간 유지하도록 설정했다.

 

테스트 환경 구성

AWS에 직접 트래픽을 쏴보고 성능을 측정하는 것이 가장 좋겠으나, 가난한 취준생은 그렇게 하지 못한다.

 

따라서 우리는 로컬에서 배포환경과 최대한 유사한 환경을 만들어야 한다.

 

brew로 cpulimit 툴을 설치하고 20%를 할당해주었다.

 

내 로컬 컴퓨터는 10개의 CPU를 가지고 있고 배포 서버는 2개의 CPU를 가지고 있으니 20%로 설정해주었다.

 

메모리는 배포 서버와 완전히 똑같이 설정해주었다.

 

-Xms512m

-Xmx512m

-XX:MaxRAM=1024m

 

프리티어 기준으로 1GB중 512를 힙 메모리에 할당해주었다.

 

cpu와 메모리 모두 제한을 걸었고, 마찬가지로 데이터베이스와 배포 서버와 최대한 비슷하게 환경을 구성해주었다.

 

이제 K6를 돌려서 부하를 넣어보고 scouter로 성능을 측정해보자

 

두 툴을 사용하는 방법은 포스트가 너무 길어질 거 같으니 패스하겠다.

 

첫 번째 부하 테스트 결과

 

테스트 결과가 너무 좋게 나왔다. 1000TPS를 쏟았는데도 응답 시간의 평균치는 13ms가 나왔으며, 95%에 해당하는 응답 시간도 46ms로 아주 준수하다. 에러도 커넥션을 얻지 못해 실패한 11개만 발생했고, 이는 0.01% 안에 들기 때문에 통과 가능한 수치가 나왔다.

 

원인은 데이터베이스가 텅텅 비어있기 때문에 성능이 굉장히 좋게 나온 것이었다.

 

우리의 목표치에 맞게 더미 데이터를 만들고 다시 트래픽을 쏘기로 했다.

 

더미 데이터 생성

우리의 목표 회원 수는 10만명이다.

즉 유저 테이블에 10만 개의 더미데이터, 전자지갑 테이블에 10만개의 더미데이터를 넣어야한다.

거래 내역 테이블엔 100만 개의 데이터를 넣었으며, 회계 테이블엔 200만개의 데이터가 들어갔다.

 

다시 부하테스트를 진행해보자

 

두 번째 부하 테스트 결과

 

더미데이터를 넣고나니 확실히 성능이 엄청나게 떨어졌다.

평균 응답 시간은 498ms로 늘었으며, 95%에 해당하는 응답 시간은 1.96s가 나왔다.

 

이제 95% 이내의 응답이 300ms 안에 처리되도록 성능 최적화를 진행해보자.

 

원인 분석

 

 

 

소요 시간을 보면 쿼리의 직접적인 수행 시간은 굉장히 짧게 측정된다. 문제는 커넥션을 얻기까지의 대기 시간이다.

 

나가는 쿼리 수 자체를 줄여서 커넥션이 빨리 빨리 돌도록 쿼리 최적화를 하는 방식으로 1차 최적화를 진행해보자.

 

현재는 api 호출 한 번에 총 12번의 쿼리가 나가고 있다.

 

쿼리 수 줄이기

Long firstLockMemberId = (member.getMemberId().compareTo(receiver.getMemberId()) < 0) ? member.getMemberId() :
    receiver.getMemberId();
Long secondLockMemberId =
    (member.getMemberId().compareTo(receiver.getMemberId()) < 0) ? receiver.getMemberId() :
       member.getMemberId();

Wallet firstLockedWallet = walletMapper.findByMemberIdWithLock(firstLockMemberId)
    .orElseThrow(() -> new CustomException(WALLET_NOT_FOUND, LogLevel.WARNING, null, common,
       "조회 실패 ID: " + firstLockMemberId));
Wallet secondLockedWallet = walletMapper.findByMemberIdWithLock(secondLockMemberId)
    .orElseThrow(() -> new CustomException(WALLET_NOT_FOUND, LogLevel.WARNING, null, common,
       "조회 실패 ID: " + secondLockMemberId));

 

데드락을 피하기 위해 순차적으로 id가 낮은 전자지갑 먼저 락을 걸어주는 방식을 선택했다.

 

순차적으로 락을 걸기 위해 쿼리를 두 번 날렸었는데, 쿼리 한 번에 순차적으로 락을 걸도록 쿼리를 수정하였다.

 

BigDecimal senderNewBalance = senderWallet.getBalance().subtract(request.amount());
BigDecimal receiverNewBalance = receiverWallet.getBalance().add(request.amount());

walletMapper.updateBalance(senderWallet.getWalletId(), senderNewBalance);
walletMapper.updateBalance(receiverWallet.getWalletId(), receiverNewBalance);

 

그 다음 쿼리 최적화 부분이다. 이 부분도 마찬가지로 락을 건 두 개의 wallet에 대해 총 두 번의 업데이트 쿼리가 나간다.

이 쿼리도 한 개로 합쳐서 쿼리 수를 줄였다.

 

@Override
public void saveWalletTransferTransaction(Wallet senderWallet, BigDecimal senderNewBalance, Wallet receiverWallet,
    BigDecimal receiverNewBalance, Member member, Member receiver, String transactionGroupId, BigDecimal amount) {
    Transaction senderTransaction = Transaction.builder()
       .walletId(senderWallet.getWalletId())
       .memberId(member.getMemberId())
       .transactionGroupId(transactionGroupId)
       .amount(amount)
       .beforeBalance(senderWallet.getBalance())
       .afterBalance(senderNewBalance)
       .transactionType(WALLET_TRANSFER)
       .counterPartyMemberId(receiver.getMemberId())
       .counterPartyWalletId(receiverWallet.getWalletId())
       .counterPartyName(receiver.getName())
       .status(SUCCESS)
       .build();

    Transaction receiverTransaction = Transaction.builder()
       .walletId(receiverWallet.getWalletId())
       .memberId(receiver.getMemberId())
       .transactionGroupId(transactionGroupId)
       .amount(amount)
       .beforeBalance(receiverWallet.getBalance())
       .afterBalance(receiverNewBalance)
       .transactionType(DEPOSIT)
       .counterPartyMemberId(senderWallet.getMemberId())
       .counterPartyWalletId(senderWallet.getWalletId())
       .counterPartyName(member.getName())
       .status(SUCCESS)
       .build();

 

세 번째 쿼리 최적화 부분이다.

거래 내역을 각각 만든 후 쿼리를 두 번 날렸었으나, 이 부분도 마찬가지로 한 번의 쿼리로 insert하게 수정했다.

 

이제 송금 플로우에서 마지막 차대변 회계 테이블 저장 로직 부분이다.

 

이 부분은 차 대변 각각의 회계 장부에 대해 벌크 인서트를 잘 구현해뒀었다.

그러나, 

 

LedgerCode bankPayable = ledgerCodeMapper.findByName("BANK_PAYABLE")
    .orElseThrow(() -> new CustomException(LEDGER_CODE_NOT_FOUND, LogLevel.ERROR, null, Common.builder()
       .ledgerCode("BANK_PAYABLE")
       .srcIp(servletRequest.getRemoteAddr())
       .callApiPath(servletRequest.getRequestURI())
       .apiMethod(servletRequest.getMethod())
       .deviceInfo(servletRequest.getHeader("user-agent"))
       .build()));

LedgerCode bankAsset = ledgerCodeMapper.findByName("BANK_ASSET")
    .orElseThrow(() -> new CustomException(LEDGER_CODE_NOT_FOUND, LogLevel.ERROR, null, Common.builder()
       .ledgerCode("BANK_ASSET")
       .srcIp(servletRequest.getRemoteAddr())
       .callApiPath(servletRequest.getRequestURI())
       .apiMethod(servletRequest.getMethod())
       .deviceInfo(servletRequest.getHeader("user-agent"))
       .build()));

 

회계코드를 조회해 올 때 쿼리가 두 번 나간다. 이 또한 한 번의 쿼리로 List로 받아오기로 하자.

 

결과

총 12회의 쿼리 -> 8회의 쿼리로 줄일 수 있었다.

 

이제 인덱스를 걸어보자.

 

인덱싱

List<Wallet> wallets = walletMapper.findByMemberIdsWithLock(ids);

 

멤버 아이디를 기준으로 지갑을 조회해서 락을 거는 쿼리가 있다.

 

여기서 멤버 아이디에 인덱스를 걸어야할까?? 인덱스를 건다면 어떤 인덱스를 걸어야할지도 생각을 해보자.

 

우선, 삽입 케이스가 더 많은지 조회 케이스가 더 많은지 생각해보자.

 

전자지갑 생성은 모든 멤버 별로 딱 한 번밖에 이뤄지지 않는다. 즉 삽입은 현저히 낮다.

 

조회 같은 경우는 송금이 이뤄질 때마다, 또한 메인 페이지에 진입 할 때마다 조회가 이뤄진다.

 

즉, 인덱스는 무조건 걸어야한다는 결론이 나온다.

 

그럼 이제 인덱스의 종류에 대해 생각해보자. 현재 내가 고려할 수 있는 인덱스는 두 개다.

 

일반 인덱스 VS 유니크 인덱스

 

두 인덱스의 장단점은 어떻게 될까

 

유니크 인덱스

장점:

  1. where member_id = ? 같은 검색에서 최대 한 건이 확정이기 때문에 즉시 종료가 가능해, 조회 성능에서 약간 더 유리하다.
  2. update의 경우 (잔액 업데이트) 락을 걸어야 하는데, 유니크 키이므로 GAP Lock이 아닌 record Lock이 걸린다.
    따라서 대기, 교착 가능성이 낮아진다.

단점:

  1. 쓰기 성능이 일반 인덱스에 비해 떨어진다. -> 중복 체크를 위해 리프 페이지를 모두 읽어야하기 때문이다.

일반 인덱스

장점:

  1. 삽입에 대해 중복조회를 하지 않아도 되기에 유니크 인덱스보다 성능이 뛰어나다.

단점:

  1. 락이 걸릴 때 Gap Lock이 걸릴 수 있어 혼잡 상황에서 P95가 더 튈 수 있다는 단점이 있다.

현재 내 상황은 insert 쿼리가 현저히 낮고, 조회 및 Lock을 거는 상황이 엄청나게 많다.

 

즉, Unique 인덱스를 걸었을 때 훨씬 더 많은 이득을 얻을 수 있다.

 

Wallet 테이블의 member_id에 대해 유니크 인덱스를 걸어주도록 하자.

 

인덱스 교체 후 성능 비교

나는 외래키에 자동으로 인덱스가 걸리는지 모르고 있었다. 지금 인덱스 성능 측정을하려고 살펴보니
mysql에서 외래키에 자동으로 인덱스를 걸어준다고 한다. 따라서 기존 외래키 인덱스와 유니크 인덱스의 성능 차이를 비교하기로 했다.

 

우선 외래키 인덱스의 성능을 측정해보자.

EXPLAIN FORMAT=JSON
SELECT wallet_id, member_id, balance, password
FROM wallet
WHERE member_id IN (123, 456)
FOR UPDATE;

 

위의 쿼리를 통해 JSON 형태로 쿼리 실행 계획을 살펴보자.

{
  "query_block": {
    "select_id": 1,
    "cost_info": {
      "query_cost": "3.84"
    },
    "table": {
      "table_name": "wallet",
      "access_type": "range",
      "possible_keys": [
        "member_id"
      ],
      "key": "member_id",
      "used_key_parts": [
        "member_id"
      ],
      "key_length": "8",
      "rows_examined_per_scan": 2,
      "rows_produced_per_join": 2,
      "filtered": "100.00",
      "index_condition": "(`test1`.`wallet`.`member_id` in (123,456))",
      "cost_info": {
        "read_cost": "3.64",
        "eval_cost": "0.20",
        "prefix_cost": "3.84",
        "data_read_per_join": "96"
      },
      "used_columns": [
        "wallet_id",
        "member_id",
        "balance",
        "password"
      ]
    }
  }
}

 

인덱스를 이용해 범위 스캔을 진행하며, 인덱스를 잘 사용하는 모습이다.

 

실제 실행 결과를 explain 해보자, 

 

-> Index range scan on wallet using member_id over (member_id = 123) OR 
(member_id = 456), with index condition: (wallet.member_id in (123,456))  
(cost=3.84 rows=2) (actual time=0.0331..0.0364 rows=1 loops=1)

 

인덱스 범위 스캔이 일어났고, 실제 실행 시간은 31ms ~ 36ms가 나온다.

 

50회 반복 수행에서 평균치는 43ms가 나온다.

 

ALTER TABLE wallet
    ADD UNIQUE KEY ux_wallet_member_id (member_id);

 

이제 유니크 키를 걸고 성능을 다시 측정해보자.

 

{
  "query_block": {
    "select_id": 1,
    "cost_info": {
      "query_cost": "1.50"
    },
    "table": {
      "table_name": "wallet",
      "access_type": "range",
      "possible_keys": [
        "ux_wallet_member_id"
      ],
      "key": "ux_wallet_member_id",
      "used_key_parts": [
        "member_id"
      ],
      "key_length": "8",
      "rows_examined_per_scan": 2,
      "rows_produced_per_join": 2,
      "filtered": "100.00",
      "index_condition": "(`test1`.`wallet`.`member_id` in (123,456))",
      "cost_info": {
        "read_cost": "1.30",
        "eval_cost": "0.20",
        "prefix_cost": "1.50",
        "data_read_per_join": "96"
      },
      "used_columns": [
        "wallet_id",
        "member_id",
        "balance",
        "password"
      ]
    }
  }
}

 

비용 추정값이 일반 인덱스일 때는 3.84가 나왔지만, 유니크 인덱스를 사용할 때는 1.5가 나왔다 무려 추정치의 절반 이상이 감소했다.

실제 실행 시간도 비교해보자.

 

-> Index range scan on wallet using ux_wallet_member_id over (member_id = 123) 
OR (member_id = 456), with index condition: (wallet.member_id in (123,456))  
(cost=4.41 rows=2) (actual time=0.0257..0.0267 rows=1 loops=1)

 

유니크 인덱스를 사용했을 때 50회 반복 수행의 평균치는 32ms가 나온다.

 

평균지연이  43ms -> 32ms로 약 25.6%가 개선됐다.

 

인덱스를 걸 만한 다른 것이 더 있나 살펴보자, 휴대전화번호 기반 조회에는 이미 휴대전화 번호에 유니크 인덱스가 포함되어 있고,

이외의 인덱스를 걸만한 곳은 보이지 않는다. 이제 쿼리를 줄이고 인덱스를 걸었으니, 다시 한 번 부하 테스트를 진행해보자.

 

 

세 번째 부하테스트 결과

쿼리 최적화와 인덱싱 이후의 부하테스트 결과다.

 

음,, 1000TPS까지 올렸을 때 Elapse가 확 튀는 부분들이 많이 보인다. 최대 1초까지도 올라가는 거 같다.

문제를 살펴보자.

 

 

이번에도 쿼리 문제는 아니고 커넥션 풀 대기 문제였다.

 

아무래도 캐시를 적용해 데이터베이스 접근을 더 줄여야 할 거 같다.

 

전체 리포트를 살펴보자

 

 

에러율은 0.01%로  통과 95%의 요청이 103ms이내에 통과했으므로 초기의 목표는 이룬 셈이다. 하지만 maximum RPS가 1419ms 까지 오르니 캐시도 적용해서 좀 더 최적화를 시켜보도록 하자.

중간 결과

평균 응답 시간

  • 498ms  -> 22.21ms : 95.54% 개선

95P

  • 1966ms -> 103ms : 94.76% 개선

최대 응답 시간

  • 3634ms -> 1419ms : 60.95% 개선

캐시 적용

우선 캐시를 적용 시킬만한 부분이 있는지 살펴보자, 

Member receiver = memberService.getMemberByPhoneNumber(request.phoneNumber().replaceAll("-", ""),
    servletRequest);

 

멤버 조회 부분에 캐시를 적용시킬 부분이 보인다.

 

전화번호 → (memberId, name) 은 변경이 드물며(번호/이름 변경 이벤트), 읽기 수요가 굉장히 많다 → 캐시 적합

 

특징을 살펴보면 -> 휴대폰 번호로 멤버를 조회하고 송금을 진행해야하니 데이터 정합성이 중요하다.

 

따라서 write through 전략을 사용하도록 하자.

 

또한 캐시를 여러 곳에서 재활용하기 위해 네이밍도 중요하다.

 

PreFix는 다음과 같이 정했다.

MemberMapper:selectMemberByPhoneNumber:

매퍼의 이름:매퍼내 메서드의 이름: {조회에 필요한 인자}

 

이렇게 PREFIX를 정한다면, 같은 메서드를 사용하는 모든 곳에서 재사용을 할 수 있을 것이다.

 

TTL 설정: 솔직히 ttl 설정은 정답이 없다. 우리 애플리케이션의 특징과 어느정도의 재사용성이 있을지를 생각해보고 적용한 뒤 캐시 히트율을 최대로 끌어올릴 시간을 지속적으로 추적해야한다.

 

우리 애플리케이션에서 생각을 해보자, 

 

우선 TTL을 12시간으로 잡고 LFU 전략으로 사용하지 않는 키들을 삭제하며, 모니터링하는 방식으로 가자.

캐시 히트율은 85% 정도로 잡고 TTL을 추후 조정하도록 하겠다.

 

또 캐시를 걸만 한 곳이 있나 찾아보자.

 

var codes = ledgerCodeMapper.findByNames(List.of("BANK_PAYABLE", "BANK_ASSET"));

LedgerCode bankPayable = codes.get("BANK_PAYABLE");
LedgerCode bankAsset = codes.get("BANK_ASSET");

 

송금, 충전, 해외 송금 등 모든 내부 결제 관련 로직에서 다음과 같이 회계 코드를 검색하는 쿼리가 나간다.

 

회계 코드는 거의 모든 시간에 사용되고, 바뀔일이 전혀 없다. TTL을 24시간으로 잡고 하거나, 메모리 로드 시 바로 적재하고 시작해도 좋을 것 같다.

 

우선 TTL을 24시간으로 설정하고 적용시키도록 하자.

 

캐시를 적용하기 적합한 두 곳에 캐시 적용을 마쳤다.

 

이제 성능 측정을 다시 시도해보자.

 

네 번째 부하 테스트 결과

 

1000 TPS까지 잘 올라갔고, elapse도 튀는 거 없이 안정적이다.

 

에러율: 에러율은 59/219000로 -> 0.027%이다.

59개의 에러가 발생한 이유도 초당 1000 Request를 네트워크가 감당하지 못 해서 네트워크 단에서 타임아웃이 난 경우다.

 

모든 에러가 다 네트워크 에러로 발생한 것을 확인할 수 있다.

 

네트워크 에러를 제외한 나머지 에러는 발생하지 않았으므로 에러율도 0%라고 할 수 있다.

 

95% Rps: 95P 도 13.98ms로 기존 목표였던 300ms보다 훨씬 뛰어난 성능으로 성능 최적화를 마칠 수 있었다.

 

Maximum Rps 348ms로 나와 만족할만한 성과를 얻을 수 있었다.

 

이제 내가 할 수 있는 선에서 성능 최적화를 모두 끝마쳤으니, 리틀의 법칙에 근거하여 커넥션 풀 사이즈를 검증해보자.

 

 

커넥션 풀 사이즈 검증

 

평균 응답시간 : 6.4ms => 0.0064s, 그러나 평균 응답시간은 tomcat server + mysql server의 값이다. 이를 고려하자.

응답 시간이 6ms가 나온 응답을 하나 찾아서 분석해봤다.

 

sql Time은 3ms가 나온 것을 볼 수 있다.

 

평균 응답시간을 3ms로 잡고 진행하자.

 

목표 TPS  => 1000

 

커넥션 풀 사이즈 = 1000 * 0.003 = 3 즉, 최소 3개의 동시 워커가 필요하다는 결론이 나온다.

 

아까 위쪽에서 하드웨어를 고려한 최적의 커넥션 풀사이즈는 5개였다.

 

리틀의 법칙을 기반으로 계산한 최소 커넥션 풀 사이즈는 3이 나왔으므로 통과했다고 볼 수 있다.

 

최종 결과

평균 응답 시간

  • 498 ms  -> 22.21 ms -> 6.4 ms : 98.71% 개선

95P

  • 1966 ms -> 103 ms -> 13.98 ms : 99.29% 개선

최대 응답 시간

  • 3634ms -> 1419ms -> 348ms : 90.42% 개선

 

평균 응답 시간을 498 ms 에서 6.4 ms로 총 98.71%의 성능 개선을 이뤄냈다 !