우리 서비스는 필요한 트래픽을 견딜 수 있을까?

jomminii·2025년 1월 8일
1

easy-coding

목록 보기
3/3
post-thumbnail

이번에 부하 테스트 관련된 학습을 하면서, 항상 갖고 있었던 우리 서비스는 필요한 트래픽을 견딜 수 있을까? 라는 궁금증을 풀어보기로 했다.

# 현재 처리하고 있는 최대 트래픽 확인

우선 내가 관여하는 서비스는 공유 오피스 어드민과 출입/예약 관련된 앱 서비스, 빌딩 관리 시스템인데, 트래픽 시점으로 가장 부하가 되는 시스템은 B2C 서비스인 공유 오피스 앱 서비스이다. 그리고 이 중에서도 트래픽이 가장 높다고 예상되는 API는 출입문에서 출입을 위해 카메라에 QR 을 태깅 인증하는 API 라고 가정했다.

아래 다이어그램에서 보이는 QR API에 해당된다.

그리고 해당 API 의 관련 테이블을 조회해서 가장 트래픽이 높았던 구간의 수치를 추출했다.

직전 년 기준 초당 요청 수가 가장 많았던 때를 조회했고, 그 결과 9건이 조회되었다. 부하 테스트로 부하를 알아보기엔 작고 소소한 수치지만, 어쩌다 부하를 견디는 것과 알고 견디는 건 다르니까 세세히 분석해보겠다.

## 서버 스펙

테스트 대상으로 삼은 개발/스테이징 서버의 스펙은 아래와 같이 이루어져 있다. 백엔드 서버는 Elastic Beanstalk 라는 관리형 서비스 하에 2개의 t3.small EC2 2 개로 이루어져 있고(로드 밸런서로 부하 분산) RDS 는 db.t3.medium 인스턴스 1개로 이루어져 있다.

서버스펙


# 개발 서버 기준 부하 테스트

먼저 개발 서버 기준으로 부하 테스트를 진행해 보겠다.

## 테스트 방식 : k6

  • 로컬 환경에서 k6 를 통해 지표를 측정하고, 대상 api 는 개발 서버의 QR 인증 api 로 한다
  • k6 인자
    - duration : 테스트 기간이며, 해당 시간만큼 테스트가 진행된다.
    - VU : 가상 유저 수이며, duration 이 지남에 따라 점차 증가하여 테스트 마지막 시점에는 지정한 VU 에 도달하게 된다.
  • TPS(transaction per second) : 초당 트랜잭션 처리량, 초당 요청 처리량 정도로 파악하면 되고, k6 상에서는 request rate 로 표현된다.

테스트를 위한 k6 코드는 아래와 같은 방식으로 작성한다.

import http from 'k6/http';
import { sleep } from 'k6';

export const options = {
  // 부하를 생성하는 단계(stages)를 설정
  stages: [
    // 10분에 걸쳐 vus(virtual users, 가상 유저수)가 200에 도달하도록 설정
    { duration: '10m', target: 200 }
  ],
}


const header = {
    'Authorization': 'Bearer xxx',
}

const qrcode = 'xxxx-yyyyy';
const device_code = 'xxxdevicecode';

export default function () {
    const params = {
        headers: header,
		...
    };

    http.get(
        `https://xxx.co/yyy?qrcode=${qrcode}&device_code=${device_code}`,
        params
    );
    sleep(1);
}


## 1차 테스트 - 3분간 500 VU까지

[테스트 조건]
duration : 3 minute
VU : 500

[환경]
백엔드 서버 : dev
RDS : dev
k6 : 로컬

  • 클라우드 워치 지표
    - RDS CPU 최대 61%, 메모리는 거의 변화 없었음
    d-1차-rds
    - EC2 CPU 사용율 최대 58%, 네트워크 in/out 최대 16MB
    - EC2 메모리 사용율 최대 46%
    d-1차-ec2

  • k6 지표
    - 최대 50 TPS
    d-1-k6


일반적으로 CPU나 메모리가 피크를 찍어야 병목이 생기는데, 지표 상 피크를 찍은 것은 없어보인다. 다만 네트워크 사용량이 16MB 를 찍었고, 현재 EC2 네트워크 대역폭이 MB 로 환산하면 16MB ~ 625MB 인데, 여기서 문제가 생겼을 수 있겠다고 생각했다.

하지만 t3.small 의 경우 네트워크 대역폭 burst 라는 기능을 제공하고, 최대 625MB 까지 가능하므로 문제가 되지 않을텐데? 테스트 시간이 너무 짧아서 burst 가 될 시간을 얻지 못했나?

duration 을 늘려서 다시 테스트 해보자.

** 참고로 CPU burst 와 달리 네트워크 burst 는 명시적으로 burst 를 위한 토큰 확인이 불가하여, 테스트를 통해서 burst 가 적용되는지가 확인 됨


2차 테스트 - 10분간 500 VU까지

[테스트 조건]
duration : 10 minute
VU : 500

[환경]
백엔드 서버 : dev
RDS : dev
k6 : 로컬

가정 : 테스트 시간을 늘리면 burst를 충분히 진행할 수 있을 것이고, 네트워크 부하 때문에 병목이 생기진 않을 것이다.

  • 클라우드 워치 지표
    - RDS CPU 최대 61%
    d-2-RDS
    - EC2 CPU 사용률 최대 97%, 네트워크 in/out 최대 27MB
    d-2-EC2
  • k6 지표
    - 최대 50 TPS
    d-2차-k6

테스트 시간을 늘리니 네트워크 대역폭 burst 가 발동되어 16MB 보다 더 높은 대역폭 만큼 네트워크를 받아낸 것이 확인 되었다. 그럼 EC2의 CPU 가 문제인걸까? 병목이 시작된 시점은 17:40 분 경인데, CPU 사용율에서 97% 피크를 찍은건 17:35분으로 보인다. 도대체 뭐가 맞는거지?

Gunicorn 으로 실행되고 있는 FastAPI 의 특정 옵션에 따라 이런 제한이 생길 수 있는건가? 하고 찾아봤지만, 따로 이에 따른 제한이 걸리는건 없어보였다.

그러다 다시 지표를 확인해보니 내가 1분 단위로 조회를 하라고 해도, 5분 단위의 데이터만 조회가 되고 있었다. CPU 사용율의 경우 여러 측정 값을 기반으로 한 평균 값이라고 한다.

위처럼 지표가 측정되고 있다면, CPU 사용율을 그대로 믿을 순 없다. 테스트 시간이 10분인데, 이걸 5분씩 평균을 내버리면 내가 원하는 값을 정확히 볼 수가 없다.

이 지표를 더 상세히 보고 싶다면, EC2의 Detailed Monitoring 설정을 사용하면 된다.

이 설정에 대한 가격은 24시간 기준, 각 지표 * 인스턴스 수 * $ 0.3/month 로 계산한다. (cloudwatch pricing examples 참고)

이 설정을 활성화 하게 되면, 기존에 5분 단위로 수집 되던 지표가 1분 단위로 수집되어 더 상세한 지표를 확인할 수 있게 된다.

EC2 가 인스턴스 단위로 설정할 수도 있고,

cw-detail

Elastic Beanstalk 같은 관리형 서비스를 사용한다면, 전체 인스턴스에 한 번에 적용할 수도 있다.

beanstalk

참고로 EC2 개별적으로 옵션을 적용할 때는 가격에 대한 노티가 있는데, Beanstalk 설정에서는 따로 노티를 하지 않아서, 눈치껏 가격이 적용되는 것을 인지해야한다. Beanstalk 에 적용을 하면, 각 하위 인스턴스의 Detailed Monitoring 이 자동으로 적용된다.

크레딧 사용에 대한 지표는 5분 단위로 고정인것 같다.

이제 상세 모니터링이 적용된 상태에서 다시 테스트를 해보자.


##3차 테스트 - 10분간 80 VU까지

[테스트 조건]
duration : 10 minute
VU : 80

[환경]
백엔드 서버 : dev
RDS : dev
k6 : 로컬

가정 : 1분 단위로 지표를 확인하면, 병목 지점에서 CPU 가 피크를 찍는지 확인할 수 있을 것이고, 그렇다면 CPU 를 병목의 원인으로 판단할 수 있다. 이전까지 병목현상이 50 VU 에서 발생 했으므로, 대상 VU 는 더 줄여서 테스트 한다.

  • 클라우드 워치 지표
    - RDS CPU 최대 56%
    d-3-rds
    - EC2 CPU 사용률 최대 79%, CPU credit 사용 최대 6, 네트워크 in/out 최대 23MB
    d-3-ec2-all
    - EC2 CPU 사용율 최대 79%
    d-3-ec2-usage

    - EC2 credit 사용 최대 6
    d-3-ec2-credit-usage
    - EC2 네트워크 부하 최대 23MB
    d-3-ec2-network

  • k6 지표
    - 최대 50 TPS, 병목없이 계속 증가, 하지만 request duration 은 꾸준히 증가, 최대 latency 1.5s 내외
    d-3-k6
    d-3-compare

상세 모니터링을 활성화하니 1분 단위로 더 세분화된 지표를 확인할 수 있게 되었다.

테스트 시간을 늘려서 진행하니 CPU 및 네트워크 burst 가 진행되어서 토큰이 소모되며 처리할 수 있는 능력이 더 늘어났다. 이전과 똑같은 50 TPS 를 처리함에도 CPU 는 최대 79%를 사용함에 그쳤다.

burst 로 능력치가 계속 올라가다보니 전처럼 병목이 발생하는 라인이 완만하게 계속 올라가는 모습을 보인다.

하지만 상위 10% 사용자들이 40 TPS 구간부터 latency 를 1초 이상 느끼게 되어 실질적인 TPS 는 그 이하로 봐야할 것 같다.


4차 테스트 - 5분간 200 VU까지

[테스트 조건]
duration : 5 minute
VU : 200

[환경]
백엔드 서버 : dev
RDS : dev
k6 : 로컬

가정 : burst 가 적용될 수 없게 적은 시간 내에 높은 VU 를 적용하여 부하를 확인하면, 좀 더 명확한 병목지점을 알 수 있을 것이다.

  • 클라우드 워치 지표
    - cpu 최대 97%, token 은 5분단위 지표만 제공
    d-4-cpu
    - EC2 네트워크 부하 최대 29MB
    d-4-network
    - EC2 credit 사용 최대 7
    d-4-credit
  • k6 지표
    - 최대 TPS 57, CPU 사용율이 80% 넘어서는 시점(40 TPS)부터 latency 1초 넘어감
    d-4-k6

d-4-compare

burst 가 적용되기 전에 피크를 찍게해서 테스트 3분 안에 CPU 피크를 찍을 수 있었고, latency 상관 없는 TPS 는 57 TPS 라는걸 확인했다. 다만 latency 1초가 넘어가면 이상이 있다는 걸 기준으로 삼는다면, latency 가 1초를 넘어가는 시점인 40 TPS 지점을 병목지점이라고 판단할 수 있겠다.

## DEV 서버 테스트 결론

각각의 api 요청이 1초 내로 이루어져야한다는 전제 하에, 병목지점은 40 TPS 지점임을 알 수 있고, 이 시점까지 RDS 는 평시와 별반 차이 없는 수준으로 부하가 유지되었다. EC2 의 경우에만 CPU 와 네트워크 수치가 높게 올라가 병목에 영향을 주고 있음을 알 수 있었다.

그런데 상용 환경을 기준으로도 1분에 최대로 요청이 왔던 수치가 9 밖에 되지 않는데, 개발 환경에서 40 TPS 수준을 감당할 환경을 유지하는게 맞냐는 의문이 들 수 있다.

그래서 EC2 인스턴스를 기준으로 교체할 수 있는 인스턴스가 있는지 조건들을 확인해 봤다.

현재 개발 환경의 EC2 는 t3.small 로 vCPU 2개와 2GiB 의 RAM 으로 이루어져있다. 현재의 판단은 이 스펙도 과하다는 판단인데, 우리 서버가 구동되는 최소 스펙을 한 번 생각해봐야한다.

우리 서버는 Docker 환경으로 이루어져있는데, Docker 의 공식 권장사양은 RAM 4G 이다. 다만 상용이 아닌 상태에서 어느정도 안정적으로 운영이 가능한 정도의 스펙은 2G 정도는 유지해야한다고 한다. 현재 우리가 사용하고 있는 t3.small 이 RAM 기준으로는 최소 사양인 것이다. 2G 이면서 더 아래 스펙은 t4g.smallt3a.small 인데, 이들은 다른 프로세서 아키텍쳐를 사용하고 있어, 아끼는 비용에 비해 고려해야하는 리소스가 더 투입될 수 있다.

그렇기에 현재 개발 서버에서 사용중인 t3.small 은 적합한 선택이라고 할 수 있다.

끝..이 아니고, 아직 테스트 안 끝났다.


# Staging 기준 부하 테스트

Staging 환경의 경우 서버 환경은 Dev와 모두 동일하나, DB에 쌓인 데이터만 상용의 것을 가져다 가공한 환경이다. 때문에 DB 의 부하를 dev 에 비해 더 명확하게 할 수 있다.

Staging 환경은 EC2 지표에 detailed monitoring 은 적용하지 않아 5분단위 지표까지만 볼 수 있다

## 1차 테스트 - 10분간 200 VU까지

[테스트 조건]
duration : 10 minute
VU : 200

[환경]
백엔드 서버 : staging
RDS : staging
k6 : 로컬

가정 : 개발 환경과 다르게 DB 의 데이터가 매우 많기에 DB 에서 병목이 발생할 것이다.

  • 클라우드 워치 지표
    - RDS CPU는 시작하자마자 피크를 찍었고, 메모리는 사용량이 50MB 늘었다
    s-1-rds-cpu
    s-1-rds-mem
    - EC2 CPU 사용율(5%)과 네트워크 사용량이 늘었지만 부담이 되는 수준은 아니다
    s-1-ec2-usage
    s-1-ec2-network
  • k6 지표
    - 최대 1.4 TPS 발생, latency 도 측정하는게 의미 없을 정도로 VU 에 비례하여 늘어남
    s-1-k6

데이터가 많긴 하지만 개발환경에서는 50 TPS 였던 병목이 1.4 TPS 에서 발생한게 믿어지지 않았다. RDS CPU 가 100%에 도달하는 것을 보니 쿼리 쪽에 문제가 있어보였다. 일단은 병목이 생길 정도의 TPS 만 일으켜서 테스트를 한 번 더 해보자


## 2차 테스트 - 3분간 매 요청 당 2 VU

[테스트 조건]
duration : 3 minute
VU : 2
이번엔 점진적으로 VU 가 증가하는게 아니라, 일정 VU를 유지하면서 테스트 한다

[환경]
백엔드 서버 : local (코드 수정 반영 위해)
RDS : staging
k6 : 로컬

가정 : 과한 TPS가 아닌 병목이 발생하는 정도의 TPS 를 일으키면 지표적으로 문제가 좀 더 명확히 보일 것이다

// k6 사용 옵션
export const options = {
  // 2명의 가상 유저가 3분 동안 일정하게 요청
  vus: 2,                // 2명의 가상 유저
  duration: '3m',        // 3분 동안 실행
};
  • 클라우드 워치 지표
    - RDS CPU 47%
    s-2-rds-cpu
  • k6 지표
    - 0.7 TPS 하에 latency 가 2초 수준으로 유지되고 있다.
    s-2-k6
    s-2-compare

0.7 TPS 인데도, RDS CPU 를 이정도 사용하고 있다는건, 확실히 쿼리를 사용하는 곳에 문제가 있다는걸로 판단된다. 이제 코드를 뜯어보면서 문제를 찾아보자. 그리고 TPS가 줄어든 건 로컬 백엔드 서버로 테스트해서 일부 과부하가 있었을 걸로 판단된다.


코드에서 문제 찾기

이전에 Profiling 을 통한 쿼리 템플릿 렌더링 속도 90% 개선기 를 통해 확인했던 것처럼 pyinstrument 를 통해 어느 부분에서 병목이 생겼는지 확인해보자.

api 요청을 해보니, 아래처럼 qr로 접근이 가능한 영역을 조회하는 부분에 병목이 있어보인다. 거의 60% 이상의 시간을 이 부분에서 차지하고 있었다. 코드를 따라가보자.

문제로 예상되는 쿼리는 출입을 요청한 멤버가 출입 가능한 기기정보를 조회하는 쿼리였다. 쿼리를 따로 실행해보니 0.5s 내외로 쿼리가 실행되고 있었다. where 조건도 이것저것 많은데, 왜 느릴까? 실행계획으로 한번 더 살펴보자.

gate 테이블을 join 할 때 ALL type 으로 필터링해서 가져오는 것을 볼 수 있다. 이 부분에 조건을 걸 수 있으면 효율이 많이 좋아질 걸로 보인다.

쿼리 바깥으로 나와 비즈니스 로직을 보니, 이 쿼리의 목적은 현재 요청을 보낸 사람이 출입 가능한 디바이스인지 만을 확인하기 위해 사용하고 있었다.

data_list = await crud.select_qr_list(xxx)
if(len(data_list) > 0):
    
    # 넘어온 기기 코드가 내가 속한 권한에 포함 되었는지 체크
    for data in data_list:
        if data['device_code_number'] == device_code:
			xxxx
            break

확인해보니 쿼리 결과에서 device_code_number 데이터를 가져와 이 데이터가 요청으로 온 device_code 와 맞는지 확인만 하면 되는 요건이었다.

그런데 이미 device_code 정보를 알고 있으니, 쿼리에 이 정보를 넘겨서, 이 값이 있는지를 확인하면 되지 않을까?

쿼리를 exists 쿼리로 바꾸고, 비즈니스 로직도 존재 여부에 따라 분기를 나누어 처리하게 바꿔봤다.

(개선 쿼리가 본 글의 목적은 아니므로 자세한 코드는 따로 명시 안하겠다.)

  is_exists = await crud.select_exists_qr_data(xxx, device_code)

  if not is_exists:
  		xxxx

이제 다시 프로파일러로 확인해보자.

wow! 빨간색이던 녀석이 이젠 순위권에서도 밀려나 거의 존재감이 없어졌다. (0.895s → 0.029s, 96% 개선 ) 전체 실행속도도 꽤 빨라졌다. (1.4s → 0.6s, 57% 개선)

explain 으로도 살펴보자. gate 에 조건을 추가한 뒤 rows 는 모두 1이 되었고, 하나 중에 하나가 조회되기에 filtered 도 100%가 되었다. 여기선 효율적이라는 뜻이다. type 도 All → const 로 바뀌었다.


## 3차 테스트 - (개선 후) 3분간 매 요청 당 2 VU

[테스트 조건]
duration : 3 minute
VU : 2 (일정 VU 유지)

  • 이번엔 점진적으로 VU 가 증가하는게 아니라, 일정 VU를 유지하면서 테스트 한다

[환경]
백엔드 서버 : local
RDS : staging
k6 : 로컬

가정 : 직전 테스트와 동일한 조건에서 테스트하여 rds CPU 의 부하를 확인한다. 부하를 확실히 덜 받을 것이다.

  • 클라우드 워치 지표
    - RDS CPU는 최대 18%로 평시 12%와 크게 차이나지 않는다.

  • k6 지표
    - 초반에 값이 튀긴 했으나, latency 도 평균 0.6s 로 안정적이고, 별다른 이슈는 없어 보인다

테스트 결과 확연히 나아진 성능을 보여주고 있다. 이제 1차 테스트와 동일한 조건으로 좀 더 과부하를 줘보자.


## 4차 테스트 - 10분간 200 VU까지

[테스트 조건]
duration : 10 minute
VU : 200 (점진적 증가)

[환경]
백엔드 서버 : local
RDS : staging
k6 : 로컬

가정 : 좀 더 나아진 성능에서 어느정도의 TPS 를 감당할 수 있는지 알 수 있다.

  • 클라우드 워치 지표
    - RDS CPU 사용율 20%로 무난하다.

  • k6 지표
    - 4.4 TPS 까지는 통과했다.

어? 기껏 4.4 TPS 받자고 이걸 한건가? 싶었는데, 테스트 환경이 문제였다.

코드를 테스트하기 위해 로컬에서 k6 와 백엔드 서버를 돌리고 있었는데, 이제는 RDS 메모리가 터지기 전에 로컬 환경이 이걸 감당하지 못한 것 같다. 80 VU 부근 부터 정상적으로 작동하지 않는 듯 보였다. 이제 스테이징 서버 환경에서 테스트를 해보자.


## 5차 테스트 - 10분간 200 VU까지(staging 백엔드 서버)

[테스트 조건]
duration : 10 minute
VU : 200

[환경]
백엔드 서버 : staging
RDS : staging
k6 : 로컬

가정 : k6, 백엔드 서버가 분산되므로 명확한 테스트가 될 것이고, 이전보다 TPS 가 확연히 상승될 것이다.

  • 클라우드 워치 지표
    - EC2 CPU 사용율 최대 81%

    - RDS CPU 사용율 최대 91%

  • k6 지표
    - 약 40 TPS에서 병목 발생

로컬에서와 다르게 staging 환경에서는 정상적으로 테스트가 되었다. TPS 는 1차 테스트에 비해 28배 개선되었다.


# 마무리

이번 테스트의 목적은 '비즈니스 로직을 개선해서 성능이 개선되었다'의 결론을 내기 위한 것은 아니었고, 부하 테스트를 통해 병목 지점을 파악하며 우리 서비스가 견딜 수 있는 트래픽 양을 추정할 수 있고, 이를 프로파일링 등의 방법으로 문제점을 파악해 해결할 수 있는 시작점을 찾아낼 수 있다 라는데 있다.

비록 이번 테스트에서는 상용 인스턴스로 이루어진 테스트 환경이 없어서 정확히 우리 서버가 견딜 수 있는 트래픽 양을 확실히 알진 못했다. 하지만 상용과 강도는 다르지만 비율적으로 더 낮은 성능의 환경에서도 동일 지점에서 병목 지점이 발생할 것이라고 추정할 수 있다. 이렇게 확인한 병목 지점을 개선함으로써 상용에서 문제가 될 수 있는 부분을 선제적으로 처리할 수 있다.

이번 부하 테스트를 통해 배운 주요 포인트들을 정리해보면:
1. k6를 활용한 체계적인 부하 테스트 방법
2. AWS CloudWatch의 상세 모니터링을 통한 정확한 메트릭 수집 방법
3. pyinstrument를 활용한 병목 지점 프로파일링
4. 실제 서비스에서 발생할 수 있는 성능 이슈를 선제적으로 발견하고 개선하는 프로세스

앞으로는 이러한 부하 테스트를 정기적으로 진행하여 서비스 성능을 모니터링하고, 특히 주요 기능 배포 전에는 반드시 부하 테스트를 수행하여 안정적인 서비스를 제공할 계획이다. 또한, 상용 환경과 동일한 테스트 환경을 구축하여 더욱 정확한 성능 측정이 가능하도록 개선해 나갈 예정이다.

이제 터득한 프로세스로 현황을 좀 더 세밀히 파악하고 더 좋은 서비스를 만들도록 하자.

profile
고민은 격렬하게, 행동은 단순하게

0개의 댓글

관련 채용 정보