들어가며
DungeonTalk 팀 위키
DungeonTalk 설명
DungeonTalk은 AI가 실시간으로 스토리를 생성하고 플레이어와 상호작용하는 TRPG 게임 플랫폼이다. 플레이어는 판타지 세계, 좀비 아포칼립스 등의 세계관을 선택하여 AI 게임 마스터가 진행하는 게임에 참여한다. AI 게임 마스터는 RAG 기반 대화 문맥을 유지하는 구조를 통해 플레이어들의 행동과 선택을 실시간으로 분석하여 그에 맞는 스토리를 생성하고, 이전 대화 맥락을 기억하며 일관된 게임 경험을 제공한다. 플레이어들은 AI 게임 마스터와의 대화뿐만 아니라 파티원들과 실시간으로 소통하며 전략을 세우고 협력하여 게임을 진행할 수 있다.
부하 테스트
TRPG 게임인 DungeonTalk 프로젝트의 서비스 품질 테스트를 위해 k6 기반 로그인 및 API 부하 테스트를 진행하였다.
테스트 결과 요약
핵심 지표 (VU 300 기준)
항목 | 목표 | 실제 결과 | 달성 여부 |
API 성공률 | 98% | 99.78% | 통과 |
로그인 성공률 | 98% | 83.48% | 실패 (16.5% 차이) |
전체 실패율 | < 1% | 3.65% | 실패 (3.6배 초과) |
API p(95) 응답 시간 | < 500ms | 3.11s | 실패 (6.2배 초과) |
로그인 p(95) 응답 시간 | < 1.5s | 26.91s | 실패 (18배 초과) |
로그인 p(99) 응답 시간 | < 2.5s | 47.78s | 실패 (19배 초과) |
처리량 | - | 79.84 req/s | 측정값 |
주요 문제점
•
로그인 성능 심각한 저하
◦
평균 응답 시간: 5.87s
◦
실패: 462건 / 2797건 (16.5%)
◦
Dropped iterations: 1539건
•
전체 실패율 높음
◦
485건 / 13,261건 실패
◦
대부분 로그인 실패
원인 분석
BruteForceManager의 보안 방어 메커니즘
로그인 보안을 위해 개발한 BruteForce 로직으로 인해 부하 테스트 진행 과정이 매끄럽지 못함을 확인했다.
작동 방식
public void loginFailed(String username) throws InterruptedException {
failCount++;
if (failCount < 5) {
// 지수 백오프: 1초, 2초, 4초, 8초
long delayMillis = (long) Math.pow(2, failCount - 1) * 1000L;
Thread.sleep(delayMillis); // ← 문제!
} else {
// 5회 이상 실패 → 10분간 계정 잠금
redisTemplate.opsForValue().set(lockKey, "LOCKED", Duration.ofMinutes(10));
}
}
Java
복사
문제점
•
Thread.sleep()으로 스레드 블로킹
◦
로그인 실패 시 1초, 2초, 4초, 8초 동안 스레드 대기
◦
고부하 환경에서 스레드 풀 고갈
◦
다른 정상 요청도 처리 지연
•
동시성 경쟁 조건
◦
3명의 사용자에 대해 150 VUs 동시 로그인
◦
각 사용자당 약 50 VUs 동시 시도
◦
Redis 동시 접근으로 일부 요청 실패 → Delay 발생 → 악순환
•
Dropped Iterations
◦
1539건의 요청이 제 시간에 처리되지 못함
◦
constant-arrival-rate executor가 목표 RPS를 달성하지 못함
성능 저하 계산
•
시나리오
◦
로그인 시도: 2797건
◦
실패: 462건 (16.5%)
•
지연 시간 추정
◦
1회 실패: 1초 지연
◦
2회 실패: 2초 지연
◦
3회 실패: 4초 지연
◦
4회 실패: 8초 지연
평균 지연 시간: (1 + 2 + 4 + 8) / 4 = 3.75초
총 지연 시간: 462건 × 3.75초 ≈ 1732초 (28.8분)
개선 방안
환경별 설정 분리
•
application-dev.properties 추가
# Brute Force Protection (개발 환경 완화)
brute-force.enabled=true
brute-force.max-attempts=10 # 5 → 10
brute-force.delay-enabled=false # Delay 비활성화
brute-force.cooldown-minutes=1 # 10분 → 1분
YAML
복사
•
BruteForceManager 수정
@Value("${brute-force.enabled:true}")
private boolean enabled;
@Value("${brute-force.max-attempts:5}")
private int maxAttempts;
@Value("${brute-force.delay-enabled:true}")
private boolean delayEnabled;
@Value("${brute-force.cooldown-minutes:10}")
private int cooldownMinutes;
public void loginFailed(String username) throws InterruptedException {
if (!enabled) return; // 비활성화 시 바로 리턴
failCount++;
redisTemplate.opsForValue().set(failKey, String.valueOf(failCount), Duration.ofMinutes(15));
if (failCount < maxAttempts) {
if (delayEnabled) { // Delay 활성화 시에만
long delayMillis = (long) Math.pow(2, failCount - 1) * 1000L;
Thread.sleep(delayMillis);
}
} else {
String lockKey = "login_lock:" + username;
redisTemplate.opsForValue().set(lockKey, "LOCKED", Duration.ofMinutes(cooldownMinutes));
}
}
Java
복사
테스트 사용자 수 증가
현재: 3명 (test_user_1, test_user_2, test_user_3)
개선: 50-100명
#!/bin/bash
for i in {1..100}; do
curl -s -X POST http://localhost:8080/v1/members \
-H "Content-Type: application/json" \
-d "{\"name\":\"load_test_user_$i\",\"password\":\"password123\",\"nickName\":\"부하테스트$i\"}"
done
Bash
복사
•
load-test.js 수정
import http from 'k6/http';
import { check, sleep } from 'k6';
import exec from 'k6/execution';
import { Rate, Trend, Counter } from 'k6/metrics';
// === Metrics
const loginSuccess = new Rate('login_success');
const apiSuccess = new Rate('api_success');
const loginDur = new Trend('login_duration');
const apiDur = new Trend('api_duration');
const totalReq = new Counter('total_requests');
export const options = {
// setup()에서 응답 body가 필요하므로 전역 discard 비활성화
// 대신 개별 함수에서 필요시 body를 버림
discardResponseBodies: false,
thresholds: {
// 전체 실패율
http_req_failed: ['rate<0.01'],
// 태그별 응답시간 목표(포트폴리오에 "로그인/일반 API 분리 측정" 어필 포인트)
'http_req_duration{endpoint:login}': ['p(95)<1500', 'p(99)<2500'],
'http_req_duration{endpoint:chatrooms_list}': ['p(95)<500', 'p(99)<1000'], // 개선된 목표
login_success: ['rate>0.98'],
api_success: ['rate>0.98'],
},
scenarios: {
// 로그인 부하 → 토큰 확보 성능
login_ramp: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '45s', target: 100 },
{ duration: '1m30s', target: 150 },
{ duration: '30s', target: 0 },
],
exec: 'loginOnly',
gracefulStop: '10s',
},
// API 부하 → 토큰 재사용하여 리스트 조회 (최적화)
api_constant: {
executor: 'constant-arrival-rate',
rate: 100, // 초당 100 req (증가)
timeUnit: '1s',
duration: '2m',
preAllocatedVUs: 100,
maxVUs: 200,
exec: 'apiOnly',
startTime: '45s', // 로그인 부하가 올라간 뒤 시작
gracefulStop: '10s',
},
},
};
const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080';
const USERS = [
{ name: 'test_user_1', password: 'password123' },
{ name: 'test_user_2', password: 'password123' },
{ name: 'test_user_3', password: 'password123' },
];
// 간단한 랜덤 IP 생성(레이트리밋 회피용)
function randomIp() {
return `${Math.floor(Math.random()*256)}.${Math.floor(Math.random()*256)}.${Math.floor(Math.random()*256)}.${Math.floor(Math.random()*256)}`;
}
// setup 단계에서 토큰 미리 발급
export function setup() {
console.log(`\n Setup: Generating tokens for ${USERS.length} users...`);
console.log(`Target URL: ${BASE_URL}`);
const tokens = [];
for (let user of USERS) {
const res = http.post(
`${BASE_URL}/v1/auth/login`,
JSON.stringify({ name: user.name, password: user.password }),
{ headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': randomIp() } }
);
// 응답 상태 로깅
if (res.status !== 200) {
console.error(`Login failed for ${user.name}: HTTP ${res.status}`);
console.error(`Response: ${res.body ? res.body.substring(0, 200) : 'empty'}`);
continue;
}
try {
const data = JSON.parse(res.body);
const token = data.data?.accessToken;
if (token) {
tokens.push({ username: user.name, token: token });
console.log(`Token generated for ${user.name}`);
} else {
console.error(`No token in response for ${user.name}`);
console.error(`Response structure: ${JSON.stringify(data).substring(0, 200)}`);
}
} catch (e) {
console.error(`Failed to parse response for ${user.name}: ${e.message}`);
console.error(`Response body: ${res.body ? res.body.substring(0, 200) : 'empty'}`);
}
}
if (tokens.length === 0) {
console.error('\n FATAL: No tokens generated! Cannot proceed with test.');
console.error(' Please check:');
console.error(' 1. Application is running at ' + BASE_URL);
console.error(' 2. Test users exist in database');
console.error(' 3. Login endpoint is accessible\n');
throw new Error('Setup failed: No tokens generated');
}
console.log(`\n Setup complete: ${tokens.length}/${USERS.length} tokens generated\n`);
return { tokens };
}
export function loginOnly() {
const u = USERS[Math.floor(Math.random() * USERS.length)];
const headers = {
'Content-Type': 'application/json',
'X-Forwarded-For': randomIp(),
};
const start = Date.now();
const res = http.post(
`${BASE_URL}/v1/auth/login`,
JSON.stringify({ name: u.name, password: u.password }),
{ headers, tags: { endpoint: 'login' } }
);
loginDur.add(Date.now() - start);
totalReq.add(1);
const ok = check(res, {
'login 200': (r) => r.status === 200,
'has accessToken': (r) => {
try { return JSON.parse(r.body).data?.accessToken; } catch { return false; }
},
});
loginSuccess.add(ok);
sleep(0.2);
}
export function apiOnly(data) {
// setup에서 받은 토큰 재사용 (로그인 불필요)
if (!data || !data.tokens || data.tokens.length === 0) {
console.error('No tokens available from setup');
return;
}
const tokenData = data.tokens[Math.floor(Math.random() * data.tokens.length)];
const ip = randomIp();
// 인증 API 호출 (로그인 없이 바로 API 호출)
const start = Date.now();
const apiRes = http.get(
`${BASE_URL}/v1/chat/room`,
{ headers: { Authorization: `Bearer ${tokenData.token}`, 'X-Forwarded-For': ip }, tags: { endpoint: 'chatrooms_list' } }
);
apiDur.add(Date.now() - start);
totalReq.add(1);
const ok = check(apiRes, { 'api 200': (r) => r.status === 200 });
apiSuccess.add(ok);
// 현실적인 think time
sleep(0.1 + Math.random() * 0.2); // 짧게 조정
}
JavaScript
복사
부하 테스트 성능 개선 결과
AS-IS
테스트 환경
•
VUs: 300 (login_ramp 150, api_constant 200)
•
Duration: 2분 45초
•
테스트 사용자: 3명
주요 지표
항목 | 값 | 상태 |
로그인 성공률 | 83.48% | 목표 98% 미달 |
로그인 p(95) | 26.91초 | 목표 1.5초 대비 18배 초과 |
로그인 p(99) | 47.78초 | 목표 2.5초 대비 19배 초과 |
API p(95) | 3.11초 | 목표 500ms 대비 6배 초과 |
전체 실패율 | 3.65% | 목표 1% 초과 |
처리량 | 79.84 req/s | 측정값 |
로그인 실패 | 462건 / 2,797건 | - |
병목 원인 분석
•
BruteForceManager Thread.sleep() 블로킹
◦
로그인 실패 시 1초, 2초, 4초, 8초 지수 백오프
◦
평균 지연 3.75초 × 462건 ≈ 28.8분 누적 지연
•
동시성 경쟁 조건
◦
3명 사용자 × 150 VUs = 사용자당 50 VUs 동시 시도
◦
Redis 동시 접근으로 일부 실패 → 악순환
•
인프라 병목
◦
HikariCP 기본 연결풀 10개
◦
Redis 연결풀 미설정
TO-BE
적용 개선 사항
•
BruteForceManager 환경별 설정 분리
# application-dev.properties
brute-force.enabled=true
brute-force.max-attempts=10 # 5 → 10
brute-force.delay-enabled=false # Thread.sleep() 비활성화
brute-force.cooldown-minutes=1 # 10분 → 1분
YAML
복사
•
인프라 최적화
◦
HikariCP 연결풀: 10 → 50
◦
Lettuce Redis 연결풀 설정
spring.datasource.hikari.maximum-pool-size=50
spring.datasource.hikari.minimum-idle=10
spring.datasource.hikari.connection-timeout=10000
spring.datasource.hikari.idle-timeout=300000
spring.datasource.hikari.max-lifetime=600000
spring.datasource.hikari.pool-name=DungeonTalkHikariCP
# Lettuce Connection Pool Settings (Performance Optimization)
spring.data.redis.lettuce.pool.max-active=50
spring.data.redis.lettuce.pool.max-idle=20
spring.data.redis.lettuce.pool.min-idle=10
spring.data.redis.lettuce.pool.max-wait=3000ms
spring.data.redis.timeout=5000ms
YAML
복사
개선 결과
항목 | AS-IS | TO-BE | 개선율 |
로그인 성공률 | 83.48% | 89.16% | +6.8%p ↑ |
로그인 p(95) | 26.91초 | 17.45초 | 35% ↓ |
로그인 p(99) | 47.78초 | 51.75초 | 8% ↑ (악화) |
API p(95) | 3.11초 | 2.51초 | 19% ↓ |
전체 실패율 | 3.65% | 3.30% | 10% ↓ |
처리량 | 79.84 req/s | 89.23 req/s | 12% ↑ |
로그인 실패 | 462건 | 456건 | 1% ↓ |
분석 및 추가 개선 필요사항
여전히 남아 있는 문제 문제
•
로그인 p(99) 악화 이유:
◦
BruteForceManager delay는 비활성화 완료
◦
하지만 Redis의 동시성 경쟁 조건은 해결되지 않음
◦
3명 사용자에 대한 과도한 동시 요청 지속
•
근본적 해결 방안:
◦
테스트 사용자 증가 (가장 효과적)
▪
3명 → 100명
▪
사용자당 동시성: 50 VUs → 1.5 VUs
▪
예상 개선: 로그인 성공률 95%+, p(95) < 1초
◦
Redis 동시성 처리 개선
▪
낙관적 락 또는 분산 락 적용
▪
failCount 증가 시 race condition 방지
◦
테스트 시나리오 조정
▪
login_ramp: 150 VUs → 30-50 VUs
▪
점진적 증가로 현실적인 부하 시뮬레이션
요약
기술적 성과
•
문제 발견
◦
k6 + Grafana 부하 테스트로 BruteForceManager 병목 발견
◦
Thread.sleep()으로 28.8분 누적 지연 분석
•
환경별 설정 분리
◦
개발 환경: 성능 우선 (delay 비활성화)
◦
프로덕션: 보안 우선 (delay 유지)
◦
Spring @Value로 동적 설정 주입
•
개선
◦
로그인 p(95): 35% 개선 (26.91초 → 17.45초)
◦
API p(95): 19% 개선 (3.11초 → 2.51초)
◦
처리량: 12% 증가 (79.84 → 89.23 req/s)
•
추가 개선 방안 도출
◦
동시성 경쟁 조건 분석
◦
테스트 설계 개선 방향 제시
배운 점
이번 과정을 통해 보안과 성능은 상충 관계에 있으며, 상황에 따라 적절한 균형점을 찾아야 한다는 점을 배웠다.
또한 환경별 설정 분리의 중요성을 인식하며, 개발·테스트·운영 환경에서의 설정 관리가
시스템 안정성과 직결됨을 확인했다.
마지막으로, 부하 테스트와 동시성 문제는 단순한 설정 변경으로 해결되지 않으며,
구조적 설계와 적절한 전략과 시나리오 수립이 중요하다는 것을 알았다.



