김코딩

같은 자원에 동시에 요청이 발생하였을 때, 어떻게 대처할까? (lock을 이용한 동시성 제어) 본문

개발팁

같은 자원에 동시에 요청이 발생하였을 때, 어떻게 대처할까? (lock을 이용한 동시성 제어)

김코딩딩 2025. 7. 13. 12:16

콘서트 예매 프로젝트를 진행하면서, 좌석 예매 시 동일한 좌석에 대해 여러 사용자가 동시에 요청을 보내는 상황이 발생했고, 이로 인해 중복 예매 문제가 실제로 발생하는 것을 확인했습니다.
이러한 문제를 해결하기 위해 저는 Lock을 활용한 동시성 제어 방식을 도입하여, 하나의 좌석에 대해 한 명만 예매할 수 있도록 제어하는 로직을 구현하였습니다.


동시성 제어란?

동시성 제어(Concurrency Control)는 여러 사용자가 동시에 같은 자원에 접근할 때 발생할 수 있는 문제를 방지하는 기술입니다.

예를 들어, 콘서트 좌석 예매 시스템에서 두 명의 사용자가 동시에 같은 좌석을 예매하려고 한다면, 시스템은 다음과 같은 상황에 빠질 수 있습니다:

  • 오버부킹(overbooking): 한 좌석에 여러 명이 예매됨

이러한 문제를 방지하려면 자원의 일관성과 무결성을 보장할 수 있는 동시성 제어가 필수적입니다.


동시성 제어를 위한 lock

동시성 문제를 해결하기 위한 가장 대표적인 방법이 바로 Lock입니다.
Lock은 하나의 자원에 동시에 여러 사용자가 접근하지 못하도록 막는 장치입니다.

예를 들어, 좌석을 예매하려고 할 때 누군가가 먼저 그 좌석을 점유했다면, 다른 사용자는 그 좌석을 예약할 수 없어야 합니다. 이를 위해 좌석 자원에 Lock을 걸어두고, 예매가 끝난 뒤에 Lock을 해제하는 방식으로 자원을 보호할 수 있습니다.


다양한 Lock 방식 비교 – 그리고 우리가 Redis를 선택한 이유

우리 팀은 아래의 3가지 락 방식에 대해 학습했고, 그중에서 Redis 기반 분산 락을 선택하게 되었습니다.

그 이유를 설명하기에 앞서, 먼저 각 락 방식의 특징을 비교해보겠습니다.


1. Java synchronized

항목 내용
동작 범위 단일 서버, 하나의 JVM 프로세스 내에서만 유효
특징 스레드 단위 락. 가장 단순한 락. 코드 블록/메서드에 직접 사용
장점 구현 매우 간단, 빠름 (메모리 기반)
단점 분산 환경에서는 무력함 (다른 서버에서 인식 불가)
적용 예시 한 서버 내의 멀티스레드 처리 상황 (ex. 캐시 동기화 등)

결론: 우리 시스템은 다중 서버 또는 마이크로서비스 구조를 염두에 두고 있기 때문에 JVM 내 락으로는 부족하다.


2. DB 기반 락 (비관적 락 / 낙관적 락)

항목  비관적 락 (SELECT FOR UPDATE) 낙관적 락 (버전 필드 기반)
동작 범위 여러 서버에서 가능 (공통 DB 사용 시) 여러 서버에서 가능
특징 트랜잭션 중 해당 행을 잠금 버전 값으로 충돌 감지 후 재시도
장점 구현 쉬움, 트랜잭션과 자연스럽게 연동 충돌이 적을 때 성능 우수
단점 성능 저하, DB 락 경합, 데드락 가능성 충돌 시 재시도 필요, 로직 복잡
적용 예시 중요한 자원의 강한 정합성 보장 재고 감소, 상태 변경 등 동시성 적은 작업

결론: DB 락은 우리가 처리하려는 “예매 시스템”처럼 트래픽이 많고 락이 많이 걸리는 상황에서는 DB 병목이 될 수 있다. 또한 장시간 유지가 어려워, 사용자가 10분간 결제를 망설이는 경우를 처리하기 힘들다.


3. Redis 기반 분산 락 (Lua Script 사용)

항목  내용
동작 범위 모든 서버에서 공유 가능 (분산 락 가능)
특징 Redis의 SET NX + TTL + Lua Script로 원자성 보장
장점 초고속, TTL로 데드락 방지, 구현 간단, 확장성 있음
단점 Redis 장애 시 대응 필요 (Sentinel/Redlock으로 보완 가능)
적용 예시 좌석 예매, 재고 감소, 스케줄러 동기화 등 분산 락이 필요한 상황

결론: 속도도 빠르고, TTL로 데드락 방지가 가능하며, 우리처럼 여러 사용자가 동시에 같은 좌석을 선택할 수 있는 구조에서는 가장 적합한 방법이다.


우리가 Redis Lock을 선택한 이유

  1. 분산 환경에서 사용할 수 있어야 한다.
  2. → Java synchronized는 JVM 내부에서만 유효.
  3. 성능 병목 없이 수많은 락 요청을 처리해야 한다.
  4. → DB 락은 트랜잭션 유지 비용이 크고, 동시 요청 많으면 부담이 크다.
  5. 사용자가 결제를 오래 고민해도 시스템이 멈추지 않아야 한다.
  6. → Redis는 TTL을 설정해 락이 자동으로 해제되므로 데드락 위험이 없다.
  7. 락 획득/해제를 정확하게 처리하고 싶다.
  8. → Redis는 Lua Script로 락 획득과 TTL 설정을 원자적으로 실행한다.
  9. 재시도, 실패 처리도 유연하게 만들 수 있다.
  10. → 락 획득 실패 시 즉시 실패 또는 재시도 로직을 자유롭게 설계할 수 있다.

Redis 락은 어떻게 구현했을까?

Redis 락을 구현할 때는 대표적으로 다음 두 가지 방식이 있습니다:

방식 특징 장점 단점
Redisson 고수준 Redis 라이브러리 tryLock(), 자동 연장, RedLock 등 다양한 기능 무겁고 설정 복잡
Lettuce + 직접 구현 Spring 기본 Redis 클라이언트 가볍고 유연, Spring에 기본 포함 락 획득/해제 직접 구현 필요

우리 팀은 학습 목적과 서비스 특성을 고려하여, 초기에는 Lettuce 기반으로 직접 Redis 락을 구현한 뒤,
후속 단계에서 Redisson 방식으로 교체하는 전략을 선택했습니다.

즉, 락의 작동 원리를 직접 체득하고, 시스템 안정성을 확보한 이후
더 강력하고 기능이 풍부한 Redisson으로 전환하는 방식으로 진행하였습니다.


Lettuce로 Redis 락을 어떻게 구현했을까?

RedisLockRepository

@Repository
@RequiredArgsConstructor
public class RedisLockRepository {

    private final RedisTemplate<String, String> redisTemplate;

    // 락 획득 script
    private static final String LOCK_SCRIPT =
        // key가 존재하지 않으면(uuid를 value로) 설정하고
        "if redis.call('setnx', KEYS[1], ARGV[1]) == 1 then " +
            // 설정된 key에 대해 만료시간(TTL) 지정 후
            "redis.call('pexpire', KEYS[1], ARGV[2]) " +
            // 성공적으로 락을 획득했으므로 true 반환
            "return true " +
            // key가 이미 존재하면 락 획득 실패 → false 반환
            "else return false end";

    // 락 해제 script
    private static final String UNLOCK_SCRIPT =
        // 현재 key의 value가 내 UUID와 같으면 (내가 락을 잡은 것이라면)
        "if redis.call('get', KEYS[1]) == ARGV[1] then " +
            // 해당 key 삭제(락 해제) 후 1 반환
            "return redis.call('del', KEYS[1]) " +
            // value가 다르면 해제하지 않음 -> 0 반환
            "else return 0 end";

    /**
     * 락 획득 시도
     * @param key Redis에 저장할 락 키 (ex: lock:concert:1:seat:50)
     * @param uuid 락 소유자 식별용 UUID
     * @param ttlMillis 락의 TTL (밀리초 단위)
     * @return true = 락 획득 성공, false = 실패
     */
    public boolean tryLock(String key, String uuid, long ttlMillis) {
        DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
        redisScript.setScriptText(LOCK_SCRIPT);
        redisScript.setResultType(Long.class);

        Long result = redisTemplate.execute(
            redisScript,
            Collections.singletonList(key),
            uuid,
            String.valueOf(ttlMillis)
        );

        return result != null && result == 1L;
    }

    /**
     * 락 해제
     * @param key 락 키
     * @param uuid 락 소유자 UUID (소유자만 락 해제 가능)
     * @return true = 락 해제 성공, false = 실패 또는 이미 해제됨
     */
    public boolean unlock(String key, String uuid) {
        DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
        redisScript.setScriptText(UNLOCK_SCRIPT);
        redisScript.setResultType(Long.class);

        Long result = redisTemplate.execute(
            redisScript,
            Collections.singletonList(key),
            uuid
        );

        return result != null && result == 1L;
    }
}

 

  • SETNX + PEXPIRE로 락 획득
SETNX (Set if Not Exists)
- SETNX key value는 key가 존재하지 않을 경우에만 값을 설정하는 명령어입니다.
- 즉, 누군가가 이미 락을 잡았다면 SETNX는 실패합니다.
- 이를 통해 락의 선점(선착순) 기능을 구현할 수 있습니다.

PEXPIRE (Precise Expire)
- PEXPIRE key milliseconds는 해당 key에 TTL(Time-To-Live) 을 설정합니다.
- 밀리초 단위로 설정되며, TTL이 지나면 key가 자동 삭제됩니다.
  • Lua Script로 락 소유자만 해제 가능

 

 

RedisLockService

@Service
@RequiredArgsConstructor
public class RedisLockService {

    private final RedisLockRepository redisLockRepository;
    private static final long LOCK_TTL = 3000; // TTL = 3초

    // 여러 좌석 락 획득
    public String acquireLock(Long concertId, List<Long> seatIds) {
        String uuid = UUID.randomUUID().toString(); // 락 소유자 식별용
        List<Long> acquiredSeatIds = new ArrayList<>(); // 지금까지 락을 성공한 seatId 모아둠

        for (Long seatId : seatIds) {
            String key = generateKey(concertId, seatId);
            boolean isLocked = redisLockRepository.tryLock(key, uuid, LOCK_TTL);

            if (!isLocked) {
                for (Long acquiredId : acquiredSeatIds) {
                    redisLockRepository.unlock(generateKey(concertId, acquiredId), uuid);
                }
                throw new SeatAlreadyLockedException();
            }

            acquiredSeatIds.add(seatId);
        }

        return uuid;
    }

    // 여러 좌석 락 해제
    public void releaseLock(Long concertId, List<Long> seatIds, String uuid) {
        for (Long seatId : seatIds) {
            String key = generateKey(concertId, seatId);
            redisLockRepository.unlock(key, uuid);
        }
    }

    private String generateKey(Long concertId, Long seatId) {
        return "lock:concert:" + concertId + ":seat:" + seatId;
    }
}
  • TTL은 3초로 설정 → 데드락 방지
TTL(Time-To-Live)은 락의 유효 시간을 의미합니다.
우리 팀은 아래와 같은 이유로 3초로 설정했습니다:

1. 예매 요청 처리 시간은 대부분 1초 이내로 끝남
  -Redis 락은 빠르게 처리되는 작업에 적합함

2. 락 해제를 누락해도 3초 후 자동 해제되도록
  -예외 상황, 장애 발생 시에도 데드락 방지

3. 너무 짧으면 실제 처리 중 락이 풀릴 수 있음

그래서 “안정성과 데드락 방지의 균형”을 고려해 3초로 설정했습니다.

락 획득 : acquireLock ⭐ ⭐ ⭐ ⭐ ⭐

// 여러 좌석 락 획득
public String acquireLock(Long concertId, List<Long> seatIds) {
    String uuid = UUID.randomUUID().toString(); // 락 소유자 식별용
    List<Long> acquiredSeatIds = new ArrayList<>(); // 지금까지 락을 성공한 seatId 모아둠

    for (Long seatId : seatIds) {
        String key = generateKey(concertId, seatId);
        boolean isLocked = redisLockRepository.tryLock(key, uuid, LOCK_TTL);

        if (!isLocked) {
            for (Long acquiredId : acquiredSeatIds) {
                redisLockRepository.unlock(generateKey(concertId, acquiredId), uuid);
            }
            throw new SeatAlreadyLockedException();
        }

        acquiredSeatIds.add(seatId);
    }

    return uuid;
}

핵심 포인트

  • 좌석 리스트를 돌면서 하나씩 락을 획득
  • 하나라도 실패하면 지금까지 락을 잡았던 좌석들은 모두 해제하고 예외 발생
  • 성공 시 락 소유자 식별용 UUID 반환 → 이후 해제에 사용

락 해제 : releaseLock ⭐ ⭐ ⭐ ⭐ ⭐ ⭐

// 여러 좌석 락 해제
public void releaseLock(Long concertId, List<Long> seatIds, String uuid) {
    for (Long seatId : seatIds) {
        String key = generateKey(concertId, seatId);
        redisLockRepository.unlock(key, uuid);
    }
}

핵심 포인트

  • 락을 잡을 때 사용한 UUID를 그대로 사용해 해제
  • Lua Script 덕분에 내가 잡은 락만 해제됨 → 안전성 보장

예매 생성 메서드에 락 적용 ⭐ ⭐ ⭐ ⭐ ⭐

@Override
@Transactional
public ReservationResponse createReservation(ReservationRequest request, Long userId, Long concertId) {
    List<Long> seatIds = request.getSeatIds();

    // 락 획득
    String lockId = redisLockService.acquireLock(concertId, seatIds);
    try {
        //같은 공연에 이미 PENDING 중인 예약이 있을경우 예외
        if (reservationRepository.existsByUserIdAndConcertIdAndStatus(userId, concertId,
            ReservationStatus.PENDING)) {
            throw new ReservationException(ReservationErrorCode.ALREADY_PENDING);
        }

        List<Seat> seats = seatService.getSeatsForReservation(request.getSeatIds(), concertId);
        User user = userService.getActiveUserById(userId);
        Concert concert = concertService.getActiveConcert(concertId);

        Reservation reservation = new Reservation(user, concert, seats.size(),
            concert.getPrice() * seats.size());

        Reservation savedReservation = reservationRepository.save(
            reservationAddReservationSeats(seats, reservation, concert));

        return ReservationResponse.fromEntity(savedReservation);
    } finally {
        redisLockService.releaseLock(concertId, seatIds, lockId);
    }
}

정리

이번 글에서는 Redis 락을 선택한 이유부터, 직접 구현 방식과 예매 로직에 적용된 흐름까지 자세히 살펴보았습니다.
하지만 정말 중요한 건 락이 실제로 효과가 있었냐는 것이겠죠?

-> 다음 글에서는 락을 적용하기 전과 후의 동시성 테스트 결과를 비교하며,
실제로 오버부킹 문제가 어떻게 해결되었는지를 자세히 분석해보겠습니다!