[동시성제어] 서버가 2대라면? 분산 락으로 한 좌석만 예약되게 막기 — Redisson 테스트

y001·2025년 4월 18일
0
post-thumbnail

왜 Redisson을 사용했는가?

동시성 테스트에서 가장 먼저 맞닥뜨리는 문제는 JVM 락이 인스턴스 경계를 넘지 못한다는 점이다. 예매 시스템처럼 여러 서버 인스턴스가 동시에 하나의 좌석을 처리할 수 있는 상황이라면, JVM 내부 락만으로는 동시성 제어가 불가능하다. 이 때문에 분산 환경에서도 일관성 있게 락을 제어할 수 있는 도구가 필요했고, Redis 기반의 락 구현체 중 가장 안정적으로 평가받는 Redisson을 선택하게 되었다.


Redisson 락 동작 방식

Redisson은 내부적으로 Redis의 해시 구조를 활용해 락을 구현한다. 예를 들어 lock.tryLock("seat:200001")을 수행하면, Redis에는 다음과 같은 구조의 키가 저장된다:

Key: seat:200001
Type: Hash
Fields: 
  - UUID(노드ID + 스레드ID) : 현재 락을 점유한 주체를 의미
  - expire : leaseTime에 기반한 만료 설정

락은 tryLock(waitTime, leaseTime) 메서드를 통해 시도한다.
여기서 의미하는 시간은 다음과 같다:

  • waitTime: 다른 요청이 락을 점유 중일 때, 락 획득을 위해 최대 대기할 수 있는 시간
  • leaseTime: 락을 획득한 후 자동으로 해제되는 시간. 트랜잭션이 길어도 leaseTime 안에서만 보장된다

만약 waitTime 이내에 락을 획득하지 못하면 실패하며, leaseTime이 지나면 락은 자동으로 만료된다.


락과 트랜잭션의 경계에 대한 고찰

처음 테스트를 설계하면서 고민한 것 중 하나는 락 범위와 트랜잭션 범위를 어디에 둘 것인가였다.

🔸 잘못된 구조

처음에는 서비스 메서드에 트랜잭션을 걸고, AOP로 락을 감싸는 구조를 사용했다. 즉, 다음과 같은 흐름이 된다:

[트랜잭션 시작] → [락 획득] → [로직 실행] → [락 해제] → [트랜잭션 커밋]

이 구조는 언뜻 보기엔 문제가 없어 보인다. 하지만 트랜잭션의 종료 시점이 락 해제 이후라는 점에서, 그 사이 새로운 요청이 락을 획득하고 SELECTINSERT를 시도할 수 있게 된다.

즉, 트랜잭션이 커밋되기도 전에 락이 풀리면서 다른 요청이 들어와 중복 예약이 발생할 수 있다. 실제 테스트 중, 이런 구조에서는 간헐적으로 중복 예약이 발생하는 것을 확인했다.

✅ 개선된 구조

이에 따라 트랜잭션을 컨트롤러 레벨에서 시작하고, 락은 트랜잭션 커밋이 끝난 이후에 해제되도록 조정했다.
이전과 달리 다음과 같은 순서로 흐름을 제어할 수 있다:

[락 획득] → [트랜잭션 시작] → [비즈니스 로직 실행] → [트랜잭션 커밋] → [락 해제]

이 구조를 적용한 후부터는 Redisson이 락을 제대로 해제한 이후에 다음 요청이 실행되도록 제어할 수 있었고, 중복 예약 문제도 해결되었다.

락은 트랜잭션을 감싸야 한다. 이것이 내가 얻은 중요한 교훈이었다.


K6를 활용한 실전 테스트

Redisson의 락이 제대로 작동하는지를 테스트하기 위해, 멀티 인스턴스 환경을 구성하고 K6로 동시 요청을 보내는 부하 테스트를 진행했다. 테스트 조건은 다음과 같다:

  • 8081, 8082 두 개의 포트로 동일한 애플리케이션을 실행
  • Redis는 두 인스턴스가 동일하게 접근
  • 동일한 유저 ID와 좌석 ID로 100명의 VU가 거의 동시에 예약 요청

테스트 스크립트는 다음과 같다:

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

export const options = {
    vus: 100,
    iterations: 100, // 각 VU가 1회만 요청
};

const scheduleId = 304897;
const seatId = 200001;
const endpoints = [
    `http://localhost:8082/api/v3/reservations`,
    `http://localhost:8081/api/v3/reservations`,
];

const payload = JSON.stringify({ scheduleId, seatId });
const userId = 51;

export default function () {
    const endpoint = endpoints[Math.floor(Math.random() * endpoints.length)];
    const headers = {
        'Content-Type': 'application/json',
        'X-USER-ID': `${userId}`,
    };
    const res = http.post(endpoint, payload, { headers });

    check(res, {
        '응답 코드가 200 또는 409인지': (r) => r.status === 200 || r.status === 409,
    });
}

🔍 테스트 결과

성공 응답 (200): 1건  
충돌 응답 (409): 99건

성공한 요청은 단 하나였고, 나머지는 모두 "이미 예약된 좌석입니다" 응답을 받았다.
레디스에는 단일 키 200001에 대해 Redisson 락 해시가 유지되었고, 락이 해제된 이후에야 다음 요청이 들어올 수 있었다.

0개의 댓글