[Spring] 동시성 제어 개념부터 Redisson 분산 락 구현까지

thezz9·2025년 5월 24일
9
post-thumbnail

개요

백엔드 개발자에게 데이터 정합성은 선택이 아닌 필수다.
트래픽이 몰리는 상황에서 하나의 자원을 동시에 여러 사용자가 요청할 경우, 정합성이 깨지면 곧바로 운영 리스크로 이어진다.

이번 글에서는 동시성 문제란 무엇인지, 이를 해결하기 위해 어떤 방식들이 존재하는지 살펴보고, 실제 예매 시스템에 Redisson 기반의 분산 락과 AOP를 적용해 구현한 과정과 결과를 정리했다.

단순히 개념 소개에 그치지 않고, JMeter로 실전 부하 테스트를 진행하여 락 적용 전후의 차이를 명확히 검증해보았다.

글이 꽤 길고 내용이 많을 수 있다.
또한 일부 코드나 설명에는 실수가 있을 수도 있다.
그럼에도 분산 락을 사용해보고자 하는 사람들에게 도움이 되길 바라며,
실제로 어떻게 적용했는지를 가능한 자세히 기록해두려 한다.

이제 본격적으로 동시성 제어란 무엇인지부터 살펴보자.


1. 동시성 제어란 무엇인가?

둘 이상의 작업(요청, 트랜잭션, 스레드 등)이 동시에 같은 자원(데이터, 파일, 메모리 등)에 접근할 때, 데이터의 정합성(무결성)을 보장하기 위한 제어 기법이다.

예: 콘서트 티켓 예매 상황

내가 세계적으로 유명한 아이돌 그룹 콘서트의 예매 사이트를 운영하는 개발자라고 생각해보자.
지금부터 티켓 예매가 시작되고 시간이 지나, 티켓이 단 1장만 남아있는 상황이다.

동시성 제어가 적용되지 않은 서버의 처리 방식을 생각해보면,

  • 사용자 A가 예매 요청을 보냄
  • 거의 동시에 사용자 B도 예매 요청을 보냄
  • 서버는 두 요청을 동시에 받아들임
  • A와 B 둘 다 "티켓 있음!"이라는 응답을 받음
  • 둘 다 결제 성공 → 티켓은 1장인데 2장이 팔려버림

이러면 좌석 충돌, 예매 기록 중복, 환불 처리 등 운영 리스크가 발생하고 팬들의 불만이 폭주할 것이다.

또 다른 예로 코레일 예매 사이트가 이러한 동시성 문제를 해결하지 않은 채 개발이 되었다면 기차에 탑승은 이미 했는데 같은 좌석을 두고 승객 여러 명이 이러지도 저러지도 못하는 곤란한 상황이 발생할 것이다.

이런 문제를 '동시성 문제'라고 부르며, 이를 해결하기 위한 기술이 바로 '동시성 제어'다.

이런 동시성 문제는 단순히 "티켓이 동시에 두 명에게 팔렸다"는 현상에서 끝나지 않는다.
시스템 내부적으로는 데이터베이스나 애플리케이션에서 발생하는 데이터 읽기/쓰기 충돌로 나타나며, 구체적으로는 다음과 같은 유형으로 분류된다.

1. Dirty Read (더티 리드)
커밋되지 않은 데이터를 다른 트랜잭션이 읽는 현상이다.
예를 들어 A 사용자의 예매 트랜잭션이 아직 끝나지 않았는데,
B 사용자가 그 중간 상태의 데이터를 읽고 "아직 티켓이 남아 있다"고 잘못 판단하는 상황이다.

2. Lost Update (업데이트 손실)
두 사용자가 거의 동시에 같은 데이터를 수정하고, 한 쪽의 수정 결과가 덮어씌워지는 경우다.
A, B 둘 다 예매를 성공했다고 판단되지만, 마지막에 처리된 B의 요청이 A의 예매 정보를 덮어버리는 식이다.

3. Non-Repeatable Read (반복 불일치 읽기)
같은 쿼리를 두 번 수행했는데 결과가 달라지는 상황이다.
A 사용자가 티켓을 조회했을 땐 남아 있었지만, 결제를 진행하는 동안 B 사용자가 예매를 완료해서 A가 다시 조회했을 때는 티켓이 사라진 경우다.

4. Phantom Read (팬텀 리드)
같은 조건으로 데이터를 조회했는데, 중간에 데이터가 추가되거나 삭제되어 결과가 바뀌는 현상이다.
예를 들어 예매 가능한 좌석 리스트를 조회했는데, 누군가가 그 사이에 좌석을 예매하거나 추가해서 리스트가 달라지는 경우가 여기에 해당한다.

이런 동시성 문제는 어디에서 발생할까?

동시성 문제는 단순히 한 지점에서만 생기는 것이 아니다. 애플리케이션의 구조와 동작 방식에 따라 다양한 곳에서 발생할 수 있다.

첫째, 자바 애플리케이션 내부에서 발생할 수 있다. 예를 들어, 여러 스레드가 동시에 같은 예매 메서드를 호출하게 되면, 객체의 상태가 충돌하는 문제가 생길 수 있다.

둘째, 데이터베이스 수준에서도 문제는 발생한다. 여러 트랜잭션이 동시에 같은 데이터를 수정하려고 하면, 트랜잭션 충돌이나 데이터 손실이 생길 수 있다.

셋째, 시스템이 여러 대의 서버로 구성된 멀티 인스턴스 환경이라면, 서버 A와 서버 B가 동시에 같은 좌석을 처리하려고 하면 충돌이 발생할 수 있다. 이때는 애플리케이션 내부의 락으로는 부족하며, 모든 서버가 함께 공유할 수 있는 외부 자원 기반의 락, 즉 분산 락(distributed lock)과 같은 기술이 필요하다.

이처럼 동시성 문제는 다양한 지점에서 발생하며, 상황에 맞는 제어 기법을 선택하는 것이 매우 중요하다. 그렇다면 이러한 문제들을 해결하기 위해 어떤 방법들이 존재할까?


2. 동시성 문제 해결 방법

앞서 동시성 문제는 다양한 위치(애플리케이션, DB, 서버 간)에 걸쳐 발생한다고 했다.
그렇기에 해결 방법은 상황과 시스템 구조에 따라 다양하지만, 크게 다음 세 가지 범주로 나눌 수 있다.

1. 애플리케이션(자바) 단에서의 락 제어

멀티스레드 환경에서 하나의 자원을 동시에 접근하지 못하도록 JVM 내부에서 락을 거는 방식이다. 주로 단일 서버 환경 또는 로컬 테스트에서 사용된다.

1.1. synchronized

가장 기본적인 자바 락 메커니즘.
한 번에 하나의 스레드만 해당 메서드나 코드 블록에 접근할 수 있도록 한다.

public class TicketService {
    private int ticketCount = 1;

    public synchronized boolean bookTicket(String user) {
        if (ticketCount <= 0) return false;
        ticketCount--;
        System.out.println(user + " 예매 성공!");
        return true;
    }
}
  • 장점: 사용이 간단함
  • 단점: JVM 수준에서만 통용되며, 서버가 여러 대면 효과 없음

1.2. ReentrantLock

synchronized보다 더 유연하고 강력한 락 제어 도구.
락 획득, 해제를 명시적으로 제어할 수 있으며 타임아웃, 공정성 설정도 가능하다.

private final Lock lock = new ReentrantLock();

public boolean bookTicket(String user) {
    lock.lock();
    try {
        if (ticketCount <= 0) return false;
        ticketCount--;
        return true;
    } finally {
        lock.unlock(); // 반드시 해제!
    }
}
  • tryLock()으로 대기 없이 락 획득 시도 가능
  • lockInterruptibly()로 인터럽트 대응 가능

1.3. AtomicInteger / AtomicLong

락 없이도 동시성 안전한 연산을 제공하는 클래스.
내부적으로 CAS (Compare-And-Swap) 알고리즘을 사용한다.

private final AtomicInteger ticketCount = new AtomicInteger(1);

public boolean bookTicket(String user) {
    int remain = ticketCount.get();
    if (remain <= 0) return false;
    int updated = ticketCount.decrementAndGet();
    return updated >= 0;
}
  • 장점: 빠르고 간단
  • 단점: 조건이 복잡한 로직에는 적합하지 않음

1.4. Key별 락 (ConcurrentHashMap 활용)

좌석 ID 같은 특정 자원마다 개별 락을 걸어 제어하는 방식

private final ConcurrentHashMap<String, ReentrantLock> lockMap = new ConcurrentHashMap<>();

public void lock(String key) {
    lockMap.computeIfAbsent(key, k -> new ReentrantLock()).lock();
}

public void unlock(String key) {
    lockMap.get(key).unlock();
}
  • 장점: 리소스 단위로 미세한 락 제어 가능
  • 단점: JVM 내부에서만 작동 → 분산 환경에서는 무효

2. 데이터베이스에서의 락 제어

DB 트랜잭션 수준에서 락을 걸어 동시에 동일한 데이터를 조작하지 못하게 막는 방식이다.

2.1. 비관적 락 (Pessimistic Lock)

“어차피 충돌 날 거야, 미리 잠가두자”는 방식

SELECT * FROM seat WHERE id = 1 FOR UPDATE;

트랜잭션 범위 내에서 해당 데이터를 다른 트랜잭션이 수정하지 못하게 락을 건다
JPA에서는 @Lock(LockModeType.PESSIMISTIC_WRITE)로 사용 가능

  • 단점: 데드락 위험, 성능 저하 가능성

2.2. 낙관적 락 (Optimistic Lock)

“충돌 안 날 거야. 대신 나중에 충돌했는지 확인하자”

버전 번호를 두고, 충돌 발생 시 롤백한다.
JPA에서는 @Version을 사용한다.

@Version
private Long version;
  • 장점: 충돌 가능성 낮은 경우 성능 우수
  • 단점: 충돌 시 재시도 필요

3. 분산 환경에서의 락 제어

서버가 여러 대인 분산 환경에서는, JVM이나 DB 수준의 락만으로는 자원 충돌을 막기에 한계가 있다.
이럴 땐 모든 서버가 함께 접근 가능한 공통 저장소(예: Redis, ZooKeeper 등)를 기반으로 락을 제어해야 한다.
여기서는 실무에서 가장 널리 쓰이는 Redis를 중심으로 분산 락 구현 방법을 정리해보겠다.

3.1. Redis 명령어 기반 락

Redis는 빠르고 경량화된 인메모리 데이터베이스로, 분산 시스템에서 락을 구현하기 위한 중앙 저장소 역할을 한다.
가장 기본적인 락 구현 방식은 Redis의 SETNXEXPIRE 명령어를 조합하는 것이다.

SETNX lock:seat:1 <UUID>
EXPIRE lock:seat:1 5
  • SETNX (SET if Not Exists): 해당 키가 존재하지 않을 때만 값을 설정 → 락 획득
  • EXPIRE (Expire Time): TTL(Time To Live) 설정 → 락이 영구적으로 점유되지 않도록 제한

락 획득 예시 흐름

Redis를 이용한 분산 락의 기본 흐름은 다음과 같다.

  1. 클라이언트 A가 SETNX lock:seat:1 UUID_A 명령을 보낸다.
    해당 키가 없으면 락을 획득하고, 이미 존재하면 실패한다.

  2. 락을 획득한 경우, 이어서 EXPIRE lock:seat:1 5 명령을 실행해
    락의 유효 시간을 5초로 설정한다. (TTL)

  3. 클라이언트 A는 예매 처리와 같은 핵심 비즈니스 로직을 수행한다.

  4. 처리가 완료되면 DEL lock:seat:1 명령으로 락을 해제한다.


3.2. Redis 직접 구현의 위험성

위 방식은 단순해 보이지만, 실제 서비스 환경에서는 아래와 같은 문제가 발생할 수 있다.

1. 락 획득과 TTL 설정 사이 장애 발생

SETNX lock:seat:1 UUID_A  # 성공
# EXPIRE 실행 전에 서버 다운
EXPIRE lock:seat:1 5      # 실행되지 않음

→ TTL 설정이 빠지면 락이 영구히 유지되어 데드락 발생

해결책: SET key value NX EX 5 명령어를 사용하면 원자적으로 설정 가능 (Redis 2.6.12 이상)

SET lock:seat:1 UUID_A NX EX 5

2. 락 해제 시, 다른 사용자의 락을 잘못 삭제

락을 DEL로 해제할 때 단순히 키만 보고 지우면,
락이 만료되어 다른 사용자가 새로 잡은 락도 지워버릴 수 있다.

예를 들어 다음과 같은 상황을 생각해보자.

A가 UUID_A로 락을 획득했지만 TTL이 만료되었고, 그 직후 B가 UUID_B로 락을 재획득했다.
그런데 A가 뒤늦게 작업을 마치고 DEL lock:seat:1을 실행하면,
B가 획득한 락까지 삭제되어버리는 심각한 문제가 발생할 수 있다.

해결책: 락 해제 전에 value(UUID) 비교 → Lua 스크립트를 사용하여 안전하게 해제

if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end

3.3. 그래서 Redisson을 사용한다

이처럼 직접 Redis 명령어로 락을 구현하면 위험 요소가 많고 복잡하다.
그래서 실무에서는 Redisson, Lettuce 같은 Redis 클라이언트 라이브러리를 사용한다.

그 중에서도 Redisson은

  • 분산 락 구현에 필요한 기능을 완비하고 있으며
  • 락 획득, 자동 해제, 예외 처리, 공정 락, 멀티 락 등 고급 기능까지 지원
  • 내부적으로 Lua 스크립트를 사용하여 락 해제 안전성까지 보장

그렇다면 Redisson을 실제로 어떻게 적용할 수 있을까?


3. Redisson을 활용한 분산 락 구현기

Redisson은 락 획득/해제의 안정성과 다양한 락 유형 지원 덕분에
멀티 서버 환경에서도 신뢰할 수 있는 동시성 제어 수단으로 널리 사용된다.

이번 파트에서는 AOP를 이용해 락 처리 로직을 비즈니스 코드에서 분리하고 LockService 구조를 통해 재사용성과 유연성까지 확보한 구현 방식을 소개하며, 어떻게 테스트하고 검증했는지 단계별로 설명한다.

1. Redisson 설정

1.1. build.gradle

// aop
implementation "org.springframework.boot:spring-boot-starter-aop"

// redisson
implementation 'org.redisson:redisson-spring-boot-starter:3.46.0'

1.2. application.yml

data:
  redis:
    host: localhost
    port: 6379

1.3. RedisConfig

@Configuration
public class RedisConfig {

	@Value("${data.redis.host}")
	private String redisHost;

	@Value("${data.redis.port}")
	private int redisPort;

	@Bean
	public RedissonClient redissonClient() {
		Config config = new Config();
		config.useSingleServer()
			.setAddress("redis://" + redisHost + ":" + redisPort);
		return Redisson.create(config);
	}
}

2. LockService 추상화

Redisson을 사용할 때 가장 흔히 할 수 있는 실수 중 하나는,
서비스 로직 곳곳에 lock.tryLock()unlock()을 매번 직접 작성한다는 점이다.

이렇게 하면 코드 중복도 많아지고, 예외 처리나 락 해제 누락 같은 실수도 쉽게 발생한다.
그래서 나는 락 처리 흐름을 재사용 가능한 구조로 추상화하기 위해
LockRedisService 인터페이스와 이를 구현한 RedissonLockRedisService 클래스를 도입했다.

공통 구조로 감싸는 이유

락은 보통 다음과 같은 흐름으로 사용된다.

  1. tryLock()으로 락 획득 시도
  2. 락을 얻었다면 비즈니스 로직 실행
  3. 종료 후 unlock() 호출
  4. 예외 상황도 고려해야 함 (락 점유 실패, 로직 수행 중 예외 등)

이 전체 과정을 매번 작성하는 대신, 다음처럼 공통 메서드에 람다 형태로 위임하면 로직은 깔끔해지고, 실수 가능성도 줄일 수 있다.
다만 람다 내부에 복잡한 로직이 들어가면 오히려 가독성이 떨어질 수 있으므로, 적절한 분리와 단순화를 함께 고려해야 한다.


2.1. LockRedisService

public interface LockRedisService {
    <T> T executeWithLock(String key, long waitTime, long leaseTime, TimeUnit timeUnit, ThrowingSupplier<T> action);

    <T> T executeWithMultiLock(List<String> key, long waitTime, long leaseTime, TimeUnit timeUnit, ThrowingSupplier<T> action);
}
  • executeWithLock(): 단일 리소스 락 처리
  • executeWithMultiLock(): 여러 리소스에 대해 동시에 락을 걸어야 할 경우

모든 메서드는 ThrowingSupplier<T>를 받아 예외가 있는 경우도 처리 가능하게 했다.


2.2. RedissonLockRedisService

@Service
@RequiredArgsConstructor
public class RedissonLockRedisService implements LockRedisService {

    private final LockRedisRepository lockRedisRepository;

    @Override
    public <T> T executeWithLock(String key, long waitTime, long leaseTime, TimeUnit timeUnit, ThrowingSupplier<T> action) {
        boolean locked = lockRedisRepository.lock(key, waitTime, leaseTime, timeUnit);
        if (!locked) {
            throw new LockRedisException(LockRedisExceptionCode.LOCK_TIMEOUT);
        }
        try {
            return action.get();
        } catch (Throwable t) {
            throw wrapThrowable(t);
        } finally {
            lockRedisRepository.unlock(key);
        }
    }

    @Override
    public <T> T executeWithMultiLock(List<String> keys, long waitTime, long leaseTime, TimeUnit timeUnit, ThrowingSupplier<T> action) {
        List<String> lockedKeys = new ArrayList<>();
        try {
            for (String key : keys) {
                if (!lockRedisRepository.lock(key, waitTime, leaseTime, timeUnit)) {
                    throw new LockRedisException(LockRedisExceptionCode.LOCK_TIMEOUT);
                }
                lockedKeys.add(key);
            }
            return action.get();
        } catch (Throwable t) {
            throw wrapThrowable(t);
        } finally {
            for (String key : lockedKeys) {
                lockRedisRepository.unlock(key);
            }
        }
    }

    private RuntimeException wrapThrowable(Throwable t) {
        if (t instanceof LockRedisException e) return e;
        if (t instanceof RuntimeException e) return e;
        if (t instanceof Error e) throw e;
        return new LockRedisException(LockRedisExceptionCode.LOCK_PROCEED_FAIL);
    }
}
  • 락 획득 실패 시 예외 처리: lock()이 false를 반환하면 즉시 LOCK_TIMEOUT 예외를 던져, 실패 여부를 명확히 판단할 수 있다.
  • 예외 일관성 확보: 람다 내부에서 발생한 ThrowablewrapThrowable()을 통해 RuntimeException 또는 커스텀 예외로 안전하게 감싸 예외 흐름을 통일한다.
  • unlock 보장: finally 블록에서 무조건 unlock()을 호출하며, 락 소유 여부 isHeldByCurrentThread() 확인은 내부적으로 Repository 레벨에서 처리하도록 위임했다.

2.3. 실제 사용 예시

lockRedisService.executeWithLock("lock:seat:" + seatId, 3, 5, TimeUnit.SECONDS, () -> {
    // 좌석 예매 처리 로직
    return bookingService.createBooking(user, requestDto);
});

이렇게 작성하면 락 관련 로직은 공통으로 처리되고, 비즈니스 로직만 깔끔하게 남는다.


3. AOP를 활용한 자동 락 적용

LockRedisService로 공통 락 처리 흐름을 만들었다면,
이제는 이를 더 간결하고 일관성 있게 적용하기 위해 AOP(관점 지향 프로그래밍)를 활용할 수 있다.

핵심 아이디어는 비즈니스 메서드에 락 로직을 직접 작성하지 않고,
어노테이션만 붙이면 락을 자동으로 적용하도록 만드는 것이다.

3.1. 커스텀 어노테이션 정의

우선 메서드에 락을 적용하기 위한 전용 어노테이션을 만든다.

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RedissonMultiLock {
	String key();

	String group() default "";

	long waitTime() default 3L;

	long leaseTime() default 5L;

	TimeUnit timeUnit() default TimeUnit.SECONDS;
}
  • key: 락을 걸 리소스의 식별자 리스트를 추출하기 위한 SpEL 표현식
  • group: 락 키를 구분짓기 위한 접두사/이름 공간(namespace) 역할
  • waitTime: 락을 획득하기 위해 기다리는 최대 시간
  • leaseTime: 락을 획득한 후 유지되는 시간 (이후 자동 해제됨)
  • timeUnit: 시간 단위 (기본: 초)

3.2. AOP 구현: RedissonMultiLockAspect

@Aspect
@Component
@RequiredArgsConstructor
@Order(Ordered.HIGHEST_PRECEDENCE)
public class RedissonMultiLockAspect {

	private final LockRedisService lockRedisService;
	private final ExpressionParser parser = new SpelExpressionParser();

	@Around("@annotation(nbc.ticketing.ticket911.common.annotation.RedissonMultiLock)")
	public Object lock(ProceedingJoinPoint joinPoint) {
		Method method = ((MethodSignature)joinPoint.getSignature()).getMethod();
		RedissonMultiLock annotation = method.getAnnotation(RedissonMultiLock.class);

		List<String> lockKeys;
		List<String> dynamicKeys = parseKeyList(annotation.key(), joinPoint);
		lockKeys = dynamicKeys.stream()
			.map(key -> buildLockKey(annotation.group(), key))
			.toList();
		return lockRedisService.executeWithMultiLock(
			lockKeys,
			annotation.waitTime(),
			annotation.leaseTime(),
			annotation.timeUnit(),
			joinPoint::proceed
		);
	}

	private List<String> parseKeyList(String keyExpression, ProceedingJoinPoint joinPoint) {
		MethodSignature signature = (MethodSignature)joinPoint.getSignature();
		String[] paramNames = signature.getParameterNames();
		Object[] args = joinPoint.getArgs();

		EvaluationContext context = new StandardEvaluationContext();
		for (int i = 0; i < Objects.requireNonNull(paramNames).length; i++) {
			context.setVariable(paramNames[i], args[i]);
		}

		Object result = parser.parseExpression(keyExpression).getValue(context);
		if (result instanceof List<?> list) {
			return list.stream().map(String::valueOf).toList();
		}
		throw new LockRedisException(LockRedisExceptionCode.LOCK_KEY_EVALUATION_FAIL);
	}

	private String buildLockKey(String group, String key) {
		return String.format("lock:%s:%s", group, key);
	}
}
  • @Around: AOP를 통해 락이 필요한 메서드를 감싼다
  • SpEL: 파라미터 기반으로 동적인 락 키 리스트를 추출한다 (#bookingRequestDto.seatIds)
  • buildLockKey(): group 값을 포함한 실제 Redis 키로 변환한다 (lock:concertSeat:{seatId})
  • executeWithMultiLock(): 모든 락을 획득한 경우에만 메서드를 실행하며, 실패 시 예외 발생


3.3. 트러블슈팅: @Order(Ordered.HIGHEST_PRECEDENCE)가 무슨 역할인가?

Redisson 락은 단순히 트랜잭션과 병렬로 실행된다고 해서 안전한 것이 아니다.
락은 트랜잭션보다 먼저 시작되고, 트랜잭션 커밋이 끝날 때까지 유지되어야만 동시성 문제가 발생하지 않는다.

그런데 @Transactional도 사실은 Spring AOP 기반으로 동작하기 때문에 락 AOP와 트랜잭션 AOP는 동일한 AOP 체인 상에서 실행 순서가 결정되며, @Order를 명시하지 않으면 트랜잭션보다 늦게 실행된 락이 트랜잭션 커밋 전에 먼저 해제되는 시나리오가 발생할 수 있다.
즉, 트랜잭션이 아직 커밋되지 않았는데 락이 먼저 풀리면서, 다른 사용자가 락을 획득하고 트랜잭션을 시작해 버리는 Race Condition이 발생할 수 있는 것이다.

위 시퀀스 다이어그램에서도 볼 수 있듯,
User A는 트랜잭션 커밋 전에 락을 해제했고, 그 사이 User B가 락을 획득해 트랜잭션을 시작해 버렸다.
그 결과, User B는 아직 커밋되지 않은 상태의 데이터를 기반으로 예매를 진행했고, 중복 예매가 발생했다.

이 문제는 @Order(Ordered.HIGHEST_PRECEDENCE)를 설정해
Redisson 락 AOP가 트랜잭션보다 먼저 실행되도록 보장함으로써 해결할 수 있었다.
즉, 락은 트랜잭션 전체를 감싸고, 트랜잭션 커밋이 완료된 후에야 락이 해제되므로
다른 사용자 요청이 그 사이 끼어들 여지가 완전히 사라진다.

실제로 JMeter를 이용해 100건 이상의 동시 요청을 테스트했을 때,
@Order를 설정하지 않은 경우에는 간헐적으로 2~3건의 중복 예매가 발생했다.
반면 @Order(Ordered.HIGHEST_PRECEDENCE)를 설정한 이후에는
1000건 이상의 요청 중에도 항상 단 1건만 성공하며 완벽한 동시성 제어가 가능함을 확인할 수 있었다.


3.4. 실제 사용 예시

@RedissonMultiLock(key = "#bookingRequestDto.seatIds", group = "concertSeat")
@Transactional
public BookingResponseDto createBookingByRedisson(
	AuthUser authUser, 
	BookingRequestDto bookingRequestDto) {
    // 좌석 예매 처리 로직
    return bookingService.createBooking(user, requestDto);
}

락을 어떤 리소스(여기선 좌석 ID 리스트)를 기준으로 걸지 key에 명시하면 된다.
락 획득 여부나 충돌 방지는 모두 AOPLockRedisService가 책임지며,
비즈니스 로직은 락 처리 로직과 완전히 분리되어 깔끔하게 유지된다.


4. JMeter를 활용해 테스트

Redisson 분산 락이 실제 서비스 환경에서 동시성 문제를 제대로 방어할 수 있는지 확인하기 위해, 단위 테스트가 아닌 JMeter를 활용한 부하 테스트(Load Test)를 진행했다.

특히 동일한 좌석에 대해 동시에 여러 사용자가 예매 요청을 보내는 시나리오를 구성하고,
Redisson 락이 적용된 API와 적용되지 않은 API를 비교 테스트했다.

1. 테스트 시나리오

  • API: POST /bookings (Redisson 적용 vs 미적용 비교)
  • 요청 본문: 같은 좌석 ID를 포함한 예매 요청 → 예: { "seatIds": [1] }
  • 동시 요청 수: 2000건
  • 목적: 중복 예매 발생 여부 확인

2. JMeter 테스트 설정

2.1. Thread Group 설정

  • Threads: 2000
  • Ramp-Up: 0초
  • Loop: 1회

2.2. HTTP Request 설정

  • Method: POST
  • URL: http://localhost:8080/bookings
  • Body Data: { "seatIds": [1] } & { "seatIds": [2] }
  • Header: Authorization: Bearer {token}

3. 테스트 결과

3.1. 락 미적용 API

  • 응답 상태: 200 OK가 2건 이상 존재 → 중복 예매 발생


3.2. Redisson 락 적용 API

  • 응답 상태: 200 OK가 1건만 존재 → 동시성 문제 방지 성공

Redisson 락을 통해 실제 트래픽 상황에서도 중복 예매와 데이터 충돌 없이
단 1건의 성공 요청만 처리되었음을 확인할 수 있었다.
즉, Redisson 기반의 분산 락이 실서비스 환경에서도 충분히 효과적인 동시성 제어 수단이 될 수 있음을 증명한 셈이다.


5. 마무리 및 실무 적용 시 고려사항

1. 실무 적용 시 주의사항

  • 락은 반드시 꼭 필요한 핵심 로직에만 최소한으로 적용해야 한다.

  • leaseTimewaitTime은 상황에 맞게 조절할 것
    → 너무 짧으면 조기 해제, 너무 길면 자원 낭비

  • 락 키는 자원을 명확히 식별할 수 있는 고유값을 기준으로 설계 (예: seat:123)

  • 멀티 락을 사용하는 경우 락 순서를 고정해 데드락을 예방해야 함 (예: 키 정렬)

2. Redisson의 한계 및 대안 고려

  • Redis 장애 발생 시 락 자체도 불안정해질 수 있음 → Redis Sentinel / Cluster 등 HA 구성 필수

  • 글로벌 락(분산 시스템 전체를 아우르는 락)이 필요한 경우엔 DB 락, Kafka, SQS, Zookeeper와 같은 대안도 검토

  • 모든 상황에 락이 필요한 건 아님
    → 캐시 + 조건 쿼리, 낙관적 락(@Version) 등 가벼운 방법으로 대체할 수도 있음

(+추가) Redisson은 기본적으로 정합성을 중요시하기 때문에 느리다!
실제로 JMeter를 이용해 10,000건의 동시 요청을 보낸 결과, 다음과 같은 수치가 확인되었다.

  • Basic(락 없음): 평균 응답 시간 785ms, TPS 1659.8/sec
  • Lettuce 기반 Redis 락: 평균 응답 시간 370ms, TPS 1290.3/sec
  • MySQL 기반 DB 락: 평균 응답 시간 1806ms, TPS 829.1/sec
  • Redisson 분산 락: 평균 응답 시간 436ms, TPS 912.7/sec

락 해제 안전성, 멀티 락 처리, 분산 환경에서의 신뢰성 면에서 Redisson은 높은 수준의 안정성을 제공한다.
하지만 이러한 정합성 보호 기능들은 필연적으로 네트워크 오버헤드와 내부 연산 비용을 발생시킨다.

즉, Redisson의 도입은 정합성과 성능 사이의 trade-off를 고려한 선택이어야 한다.

3. 지속적인 모니터링과 실험 필요

  • 운영 환경은 계속 변하고, 트래픽이나 예외 발생 패턴도 시간에 따라 달라질 수 있기 때문에 지속적인 모니터링과 조정이 필요하다.

  • 예를 들어 락 획득 실패가 자주 발생한다면, waitTime을 늘리거나 락의 범위를 좁히는 등의 개선이 필요할 수 있다.


지금까지 Redisson을 활용해 동시성 문제를 어떻게 해결할 수 있는지,
그리고 그것이 어떻게 효과를 발휘하는지를 단계별로 정리해보았다.

락은 단순한 기술 도입이 아니라 시스템의 안정성을 위한 선택이다.
특히 사용자 요청이 동시에 몰리는 상황에서는, 데이터 정합성을 지키는 최소한의 안전장치가 된다.

하지만 모든 로직에 락을 적용할 필요는 없다.
락은 신뢰를 위한 비용인 만큼, 충분히 충돌 가능성이 있는 핵심 영역에만 신중하게 적용해야 한다.

그래서 도입할 땐 단순히 "걸면 안전하겠지"가 아니라, 언제, 어떻게, 어디에 최소한으로 적용할지를 고민하는 태도가 더욱 중요하다고 생각한다.

profile
개발 취준생

1개의 댓글

comment-user-thumbnail
2025년 5월 26일

너무 상세하고 자세한 설명 최고입니다 동시성 제어 너무 어려워요

답글 달기