k6로 부하 테스트 해보기

hee.moon·2025년 11월 30일

트러블 슈팅

목록 보기
27/27
post-thumbnail

FE 팀에서 사용할 서버(NestJS + PostgreSQL + Prisma)를 구축하고 있다. 오늘은 시간이 남아서 부하 테스트(Load testing)를 위한 코드를 프로젝트에 추가해보고 있다. 부하 테스트도 FE에서 작성하는 단위 테스트 코드처럼 시나리오 단위의 코드로 관리하고 싶었다.

나는 Artilleryk6 중에서 고민하다가 JS 개발자에 친화적인 k6를 사용하여 테스트해보기로 했다. (Artillery는 YAML 기반임)


k6 설치


MacOS

brew install k6

Window

choco install k6

Linux, Docker에서 설치하는 방법은 여기에서 확인할 수 있다.


그리고 프로젝트에서는 k6 type 정의를 설치해야 한다.

$ pnpm add --save-dev @types/k6

공식 문서에 VSCode 확장자에 대한 내용이 있는데 Cursor에서는 검색이 되지 않는 것 같다.


간단한 테스트 코드 작성


app/server 폴더에 load-test 폴더를 생성한 후 my-first-test.ts 파일을 추가한다. 지피티가 자꾸 k6는 typescript 지원을 안한다고 .js 파일로 변경하라고 헛소리한다.

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

export const options = {
  // 얼마나 반복할 건지
  iterations: 10,
};

// export default 처리한 함수는 k6에 의해 테스트 스크립트의 진입점이 됨
export default function () {
  // target URL로 GET 요청
  http.get('http://localhost:55029/health');

  // 실제 사용처럼 몇 초 동안 대기할 수 있음
  sleep(1);
}

나는 Docker로 띄운 http://localhost:55029를 target URL로 작성했다.


테스트 코드 실행


k6 CLI로 스크립트를 로컬에서 실행하면 다음과 같은 결과가 나온다.

k6 run load-test/my-first-test.ts

결과에서 주요 내용만 확인하면 다음과 같다.

  • 1 max VUs → 가상 유저 1명이

  • 10 iterations → 총 10번 요청을 보냈다. (옵션의 설정 때문)

  • http_req_duration: avg=7.89ms min=3.08ms max=6.23ms p(90)=10.29ms p(95)=18.58ms → /health를 한 번 호출하는 데 평균 7.89ms 걸렸고, 90%의 요청은 10.29ms 이내에 끝났다.

  • http_reqs: 10 → 10번 요청 전부 잘 나갔고

  • http_req_failed: 0.00% (0 out of 10) → 실패 없이 전부 2xx 또는 3xx로 응답했다.

  • iteration_duration: avg=1s ... → 코드에서 sleep(1)을 넣었기 때문에 각 루프가 1초 정도 걸린 것을 보여준다.

  • vus: 1, vus_max: 1 → 테스트 내내 유저는 1명


사용자를 여러 명으로 테스트할 수 있다는 것이 눈에 띄는 것 같다.

이번에는 options를 좀 더 빡세게 바꾼 후 테스트했다.

export const options = {
  vus: 10, // 동시 유저 10명
  duration: '30s', // 30초 동안 계속 요청
};
...

http_req_duration 평균이 2ms 정도 증가했다. 별로 차이가 나지 않았다. 나중에 많은 일을 하는 API로 교체해서 테스트해보면 좋을 것 같다.


connection_limit 테스트


적절한 connection_limit을 구해야 한다. 이유는 PM2 클러스터 모드에서 실행되는 각 프로세스가 자신만의 Prisma 커넥션 풀을 생성하기 때문에, 설정값 하나가 전체 데이터베이스 커넥션 수에 직접적인 영향을 미치기 때문이다.

적절한 connection_limit을 테스트하기 위해 고정해야하는 조건이 크게 두 가지 있다.

  1. 환경은 다음과 같이 고정되었다고 가정한다.
  • Docker, CPU 4코어, 메모리 4GB
  • PM2 instances: 'max' → 프로세스 4개

→ 테스트 동안 CPU / 메모리 / PM2 프로세스 / DB 설정이 변하지 않도록 유지한다. 실제 환경 정보는 직접 확인해볼 수 있다.

👀 Docker CPU 및 메모리 정보 확인 방법

$ docker stats <container-id>

  • CPU % → 컨테이너가 호스트 전체 CPU에서 차지하는 비율
    예를 들어, 호스트에 CPU 8코어가 있다면 컨테이너는 '전체 800% 중 5.21% 사용'이라는 의미다. (Docker CPU%는 전체 CPU 100%가 아니라 <<코어 수 x 100%>> 기준임)
  • MEM USAGE / LIMIT → LIMIT이 컨테이너에 부여된 최대 메모리다. (7.653GiB)
  • MEM % → MEM USAGE 나누기 LIMIT 한 값

👀 PM2 프로세스 수 확인 방법

  • 도커 진입
$ docker exec -it <container-id> sh

sh 대신 bash를 사용하면 Alpine 기반 이미지와 bash를 포함하지 않는 slim 이미지일 때 에러가 발생한다.

  • PM2가 관리하는 모든 프로세스를 표 형태로 확인
$ pm2 list 

PM2의 프로세스 수는 CPU 코어 수와 같다.
근데 너무 많은 것 같아서 내 맥북의 CPU 정보를 확인했는데 14개가 맞았다.

$ sysctl -a | grep machdep.cpu
machdep.cpu.cores_per_package: 14
machdep.cpu.core_count: 14
machdep.cpu.logical_per_package: 14
machdep.cpu.thread_count: 14
machdep.cpu.brand_string: Apple M4 Pro

👀 PostgreSQL max_connections 확인 방법

  • PostgreSQL 접속
$ psql -h localhost -p 5432 -U moonhee postgres
  • max_connections 확인
postgres=# SHOW max_connections;
 max_connections
-----------------
 100
(1 row)

👀 Prisma connection_limit 확인
환경변수에 정의된 DATABASE_URL에서 설정된 connection_limit을 확인하면 된다.

DATABASE_URL=postgresql://USER:PW6@localhost:5432/postgres?connection_limit=20&timezone=Z

Prisma v6에서 connection_limit의 기본값 num_cpus::get_physical() * 2 + 1 이다. (Prisma 공식문서)
우선 20으로 설정해놓고 +-하면서 테스트할 예정이다.


  1. 쿼리는 DB 커넥션을 일정 시간 점유해야 한다. 이유는 커넥션을 오래 점유해야 connection pool을 실제로 가득 채울 수 있기 때문이다.

connection_limit 테스트는 동시에 n개의 요청이 들어와 커넥션이 부족할 때 시스템이 어떻게 동작하는지 확인하는 것이다.
그런데 쿼리가 너무 빨리 끝나면 커넥션이 즉시 반환되고, connection pool이 꽉 차지 않아서 테스트 효과가 없게 된다.

쿼리가 DB 커넥션을 일정 시간 점유하도록 하기 위해 테스트용 API를 추가하면 된다.

PostgreSQL에는 pg_sleep(seconds)라는 내장 함수가 있다. 이 함수는 쿼리 실행을 n초 동안 멈추게 만든다. 이걸 그대로 SELECT pg_sleep(0.05) 같은 식으로 호출하면, DB 커넥션이 50ms 동안 점유된다.

다만 Prisma에서 바로 SELECT pg_sleep(0.05)를 호출하면 void 타입 칼럼 역직렬화 문제로 에러가 날 수 있어서, 반환값이 없는 DO 블록 안에서 PERFORM pg_sleep(...)를 호출하는 방식으로 우회했다.

  • app/server/src/apps/test/test.controller.ts
import { Controller, Get } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';

@Controller('test')
export class TestController {
  constructor(private readonly prisma: PrismaService) {}

  @Get('db-delay')
  async simulateDbDelay() {
    // DO 블록 안에서 pg_sleep 호출 > 결과셋이 없어서 Prisma가 void 타입을 역직렬화하지 않아도 됨
    await this.prisma.$executeRawUnsafe(`
      DO $$
      BEGIN
        PERFORM pg_sleep(0.05); -- 50ms 동안 DB 커넥션 점유
      END
      $$;
    `);

    return { ok: true };
  }
}

이제 /test/db-delay 를 호출하면, 항상 약 50ms 동안 DB 커넥션을 점유한 뒤 { ok: true } 를 돌려주는 부하 테스트용 엔드포인트가 준비됐다.

$ curl http://localhost:55035/test/db-delay
# { "ok": true }

k6 시나리오 작성

이제 이 엔드포인트를 대상으로 k6 시나리오를 하나 정의해야 한다.
"총 1000개의 요청을 200명의 가상 유저가 나눠서 보내는" 테스트를 만들었다.

  • app/server/load-test/connection-limit.ts
import { check } from 'k6';
import http from 'k6/http';

const BASE_URL = 'http://localhost:55029';

export const options = {
	scenarios: {
    	connection_limit_test: {
        	executor: 'shared-iterations',
          	vus: 200, // 동시 유저 200명
          	iterations: 1000, // 총 1000 요청
          	maxDuration: '2m', // 안전 장치
        },
    },
};

export default function () {
	const res = http.get(`${BASE_URL}/test/db-delay`);
  
  	check(res, {
    	'status is 200': (r) => r.status === 200,
    });
}

도커를 실행한다. 이때 connection_limit 값만 바꿔가면서 반복 실행하면 된다.

# 예: connection_limit=20
$ docker run \
  -e DATABASE_URL="postgresql://USER:PW@host.docker.internal:5432/postgres?connection_limit=20&timezone=Z" \
  ...

# 그 상태에서 k6 실행
$ k6 run load-test/connection-limit.ts


1000개 요청 중에서 142개만 성공하고 나머지는 실패했다.

그 다음 connection_limit을 15로 설정한 후 테스트하면 1000개 요청 중에서 448개가 성공했다.

connection_limit을 10으로 낮춘 결과는 다음과 같다.

  • 결과 정리
지표connection_limit=20connection_limit=15connection_limit=10
성공률14.2%44.8%47.9%
테스트 총 소요시간1.5초1.2초0.9초

connection_limit=10은 200개의 동시 요청을 처리하기엔 부족하다.

10개의 커넥션만 DB에서 동시에 사용가능하기 때문에 나머지 190개의 요청은 DB 커넥션을 기다리거나 바로 실패한다.

응답 시간은 일정하게 유지된다. 왜냐하면 실패 요청은 즉시 에러 처리되고, 성공 요청은 빠르게 처리되기 때문이다.

0.9초 만에 테스트가 끝났다는 것의 의미를 찾아보니 요청이 빠르게 실패하면서 빠르게 소진되었다는 것을 의미했다.

어쨌든 connection_limit을 낮추는 전략만으로는 동시성을 해결할 수없다는 걸 알게 되었다.


다음에는 캐싱을 적용해본 후 다시 테스트해봐야겠다.

profile
Frontend Engineer

0개의 댓글