카테고리 없음

Redisson 분산락 waitTime · leaseTime 적절한 값 찾기

hwangsehee 2026. 6. 21. 20:20

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   },
        ],
    },
},

초당 요청 수(TPS)를 점진적으로 올린 지표

 

 

다중 방 시나리오 설계

방 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에 위임하는 방향도 고려할 수 있다