1. 배경 — 왜 설정값이 중요한가
뇌이싱 프로젝트에서 방 입장 로직에 Redisson 분산락을 적용했다.
방 입장 시 두 개의 락이 순차적으로 걸린다.
lockExecutor.executeWithLock(USER_LOCK_PREFIX, userId, () -> exitIfInAnotherRoom(...));
lockExecutor.executeWithLock(ROOM_LOCK_PREFIX, roomId, () -> performEnterRoomLogic(...));
USER_LOCK 으로 다른 방에 이미 접속 중인 유저를 처리하고, ROOM_LOCK 으로 방 입장 로직의 원자성을 보장하는 구조다.
두 락이 필요한 이유는 보호해야 하는 자원이 다르기 때문이다.
USER_LOCK (userId 키) — 동일 유저가 여러 탭에서 동시에 방 입장을 시도할 경우, "다른 방에 있는지 확인 → 있으면 퇴장" 처리가 중복 실행될 수 있다. userId를 키로 락을 걸어 한 유저의 입장 요청이 동시에 처리되지 않도록 직렬화한다.
ROOM_LOCK (roomId 키) — 여러 유저가 동시에 같은 방에 입장을 시도할 경우, 락 없이는 두 요청이 동시에 "현재 7명, 입장 가능" 으로 판단해 9명이 입장하는 상황이 생긴다. roomId를 키로 락을 걸어 "인원 확인 → 정원 체크 → 인원 증가" 가 원자적으로 실행되도록 보장한다.
처음 설정값은 아래와 같이 보수적으로 잡았다.
private static final long DEFAULT_WAIT_TIME = 5L; // 락 획득 대기 시간
private static final long DEFAULT_LEASE_TIME = 3L; // 락 점유 시간
private static final TimeUnit DEFAULT_TIME_UNIT = TimeUnit.SECONDS;
이 값의 근거가 없었다. 실제로 락 대기나 점유가 얼마나 걸리는지 측정한 적이 없었다. 이를 실측 데이터 기반으로 검증하고 싶었다.
2. 부하 테스트 설계
executor 선택
부하테스트 툴은 k6를 선택했다.
k6에서 제공하는 executor는 여러 종류가 있다.
ramping-vus: VU 수를 점진적으로 올림. VU가 루프를 돌면서 재시도하기 때문에 재입장 문제가 생길 수 있음per-vu-iterations: VU당 1회만 실행. 동시성 정합성 검증에 적합하지만 부하 점진 증가가 안 됨ramping-arrival-rate: 초당 요청 수(TPS)를 점진적으로 올림. 각 요청이 독립적으로 실행되어 재시도 루프 없음
락 대기/점유 시간 측정이 목적이고, 부하를 점진적으로 올리면서 임계점을 탐색하고 싶었기 때문에 ramping-arrival-rate 를 선택했다.
scenarios: {
ramp_up_test: {
executor: 'ramping-arrival-rate',
startRate: 0,
timeUnit: '1s',
preAllocatedVUs: 100,
maxVUs: 200,
stages: [
{ duration: '20s', target: 10 },
{ duration: '30s', target: 10 },
{ duration: '20s', target: 30 },
{ duration: '30s', target: 30 },
{ duration: '20s', target: 60 },
{ duration: '30s', target: 60 },
{ duration: '20s', target: 100 },
{ duration: '30s', target: 100 },
{ duration: '20s', target: 0 },
],
},
},

다중 방 시나리오 설계
방 1개에 모든 트래픽을 집중시키면 실제 서비스와 동떨어진 시나리오가 된다. 방 6개를 생성하고 요청을 랜덤 배정해 특정 방에 쏠리는 실제 서비스 상황을 재현했다.
const ROOM_COUNT = 6;
const roomId = roomIds[Math.floor(Math.random() * roomIds.length)];
방장 userId 분리 — 방 삭제 방지
테스트 중 유저가 퇴장하면 마지막 남은 유저가 나갈 때 방이 삭제된다. 방이 삭제되면 이후 요청이 "존재하지 않는 방" 에러를 내기 때문에 테스트가 오염된다.
이를 방지하기 위해 방 생성 시 userId 1001~1006 을 사용했다. VU는 1~200 범위라 겹치지 않으므로 방장이 테스트 중 퇴장되지 않아 방이 유지된다.
export function setup() {
const roomIds = [];
for (let i = 1; i <= ROOM_COUNT; i++) {
const res = http.post(`${BASE_URL}/rooms`, JSON.stringify({
roomName: `test-room-${i}`,
maxUserCount: 8,
password: '',
locked: false,
}), { headers: { 'X-Test-User-Id': String(1000 + i) }});
const roomId = res.json('roomId');
roomIds.push(roomId);
}
return { roomIds };
}
커스텀 메트릭 설계
기본 k6 메트릭만으로는 락 경합 실패와 만원(정원 초과)을 구분할 수 없다. 두 케이스를 분리해서 측정했다.
const lockTimeoutCount = new Counter('lock_timeout_count'); // 409: 락 경합 실패
const roomFullCount = new Counter('room_full_count'); // 403: 만원
const enterFailRate = new Rate('enter_fail_rate'); // 그 외 예상치 못한 에러
만원은 정상적인 비즈니스 케이스이므로 실패율에서 제외했다. lockTimeoutCount 가 올라가기 시작하는 지점이 실질적인 임계점이다.
3. 서버 측 실측 로그 추가
k6에서 http_req_duration 으로 응답 시간을 측정할 수 있지만, 이 값은 락 대기 + 비즈니스 로직 + 네트워크 시간이 전부 합산된 값이다. 락 대기 시간만 따로 보려면 서버 쪽에서 직접 측정해야 했다.
LockExecutor 에 측정 로그를 추가했다.
public <T> T executeWithLock(String prefix, Object key, Supplier<T> supplier) {
String lockKey = formatLockKey(prefix, key);
RLock rlock = redissonClient.getLock(lockKey);
boolean acquired = false;
try {
long start = System.currentTimeMillis();
acquired = rlock.tryLock(DEFAULT_WAIT_TIME, DEFAULT_LEASE_TIME, DEFAULT_TIME_UNIT);
long waitMs = System.currentTimeMillis() - start;
if (!acquired) {
log.warn("[LockExecutor] Lock acquisition failed (waited {}ms): {}", waitMs, key);
throw new CustomException(CommonErrorCode.LOCK_ACQUISITION_FAILED);
}
log.info("[LockExecutor] Lock acquired (waited {}ms): {}", waitMs, key);
//return supplier.get();
long leaseStart = System.currentTimeMillis();
T result = supplier.get();
long heldMs = System.currentTimeMillis() - leaseStart; // 실제 락 점유 시간
log.info("[LockExecutor] Lock held for {}ms: {}", heldMs, key);
return result;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new CustomException(CommonErrorCode.LOCK_INTERRUPTED);
} finally {
if (acquired && rlock.isHeldByCurrentThread()) {
rlock.unlock();
log.info("[LockExecutor] Lock released: {}", key);
}
}
}
k6 부하 테스트를 돌리면서 서버 로그를 확인한 결과:
전체 로그 분석 결과:
- 락 획득 대기 시간 최대: 12ms
[LockExecutor] Lock acquired (waited 12ms): room_id 7
- 락 점유 시간 최대: 22ms
[LockExecutor] Lock held for 22ms: room_id 17
- 락 획득 대기 시간 최대: 12ms
- 락 점유 시간 최대: 22ms
초기값인 waitTime 5s, leaseTime 3s 는 실측값 대비 수백 배 여유가 있었다.
4. 설정값 도출 과정
leaseTime, waitTime 도출
leaseTime이 실제 로직 실행 시간보다 짧으면 로직이 끝나기 전에 락이 강제 해제된다. 이 시점에 다른 스레드가 락을 획득하면 동시 접근이 발생해 데이터 정합성이 깨진다.
따라서 leaseTime은 실제 로직 실행 시간(최대 22ms)보다 충분히 커야 한다.
22ms × 50 (운영 환경 변수 마진) = 1,100ms → leaseTime = 1s (내림)
waitTime > leaseTime 조건 충족, leaseTime 기준 1.5배 여유 → waitTime = 1,500ms (1.5s)
운영 환경에서는 네트워크 레이턴시, GC pause, Redis 부하 등 예측 불가 요소가 존재하기 때문에 50배 마진을 적용했다.
최종 설정값
// 시간단위를 ms로 변경
private static final TimeUnit DEFAULT_TIME_UNIT = TimeUnit.MILLISECONDS;
// 락 점유를 위한 대기 시간
private static final long DEFAULT_WAIT_TIME = 1500L;
// 락 점유 시간
private static final long DEFAULT_LEASE_TIME = 1000L;
5. 변경값으로 재검증
설정값을 변경한 후 동일한 k6 시나리오로 재실행했다.
lock_timeout_count : 0건 ← 락 경합 실패 없음
enter_fail_rate : 0% ← 예상치 못한 입장 실패 없음
ws_connect_fail_rate: 0% ← WebSocket 연결 안정적
초기값(5s/3s)과 변경값(1.5s/1s) 모두 동일한 결과를 보였다. waitTime과 leaseTime을 줄여도 안정성에 영향이 없음을 확인했다.
6. 결론 및 회고
정리
| 항목 | 초기값 | 실측 최대값 | 변경값 |
|---|---|---|---|
| waitTime | 5s | 12ms | 1.5s |
| leaseTime | 3s | 22ms | 1s |
waitTime과 leaseTime을 감으로 잡는 것이 아니라, 실제 부하 환경에서 측정한 값을 기반으로 도출했다. 이 과정을 통해 설정값에 대한 명확한 근거를 만들 수 있었다.
TPS 목표 달성 여부에 대해
ramping-arrival-rate 로 최대 100 TPS를 목표로 설정했지만, 방 인원 제한(6방 × 8명 = 48명)과 WebSocket 유지 시간(5s) 때문에 실제 처리량은 약 44 req/s에 그쳤다.

그러나 이 테스트의 핵심 목적은 TPS 자체가 아니라 동시 입장 시 락 경합 상황에서의 대기/점유 시간 실측이었다. 랜덤 방 배정으로 동일 roomId에 동시 접근이 발생했고, 그 상황에서의 실측값을 얻었으므로 검증 목적은 달성했다.
개선할 수 있는 점
- 로컬 환경 기준이므로 실제 운영 환경(Redis 네트워크 레이턴시 포함)에서 재측정하면 더 정확한 값을 얻을 수 있다
- Watchdog을 활용하면 leaseTime 초과 걱정 없이 자동 연장이 가능하다. 다만
tryLock에 leaseTime을 명시하면 Watchdog이 비활성화되므로, 향후 로직 실행 시간이 길어질 경우 leaseTime 명시를 제거하고 Watchdog에 위임하는 방향도 고려할 수 있다