Redis 동시성 제어 구현

박정호·2025년 5월 7일

좌석 선점권 여부에 대한 확인을 위해 레디스를 사용하고 이를 위한 동시성 제어를 테스트 해봤다.

분산락은 Redisson 라이브러리를 사용해서 구현했다.

RedissonConfig.java

@Configuration
public class RedissonConfig {

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

    @Value("${spring.data.redis.port}")
    private String REDIS_PORT;

    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        String redisUrl = String.format("redis://%s:%s", REDIS_HOST, REDIS_PORT);
        config.useSingleServer()
                .setAddress(redisUrl)
                .setConnectionMinimumIdleSize(20)   // 유휴 커넥션
                .setConnectionPoolSize(64);         // 최대 커넥션
        return Redisson.create(config);
    }

}

SeatService.java

@RequiredArgsConstructor
@Service
public class SeatService {

    private final SeatRepository seatRepository;
    private final RedissonClient redissonClient;

    private static final String SEAT_LOCK_PREFIX = "lock:seat:";

    @Transactional
    public boolean tryLockSeat(Long seatId) {
        RLock lock = redissonClient.getLock(SEAT_LOCK_PREFIX + seatId);

        try {
            if (lock.tryLock(1, 10, TimeUnit.SECONDS)) {
                Optional<Seat> seatOpt = seatRepository.findById(seatId);
                if (seatOpt.isPresent() && !seatOpt.get().isSeatReserved()) {
                    Seat seat = seatOpt.get();
                    seat.reserve(); //좌석 상태 변경
                    return true;
                }
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
        return false;
    }

    @Transactional
    public boolean tryReserveSeatWithoutLock(Long seatId) {
        Optional<Seat> seatOpt = seatRepository.findById(seatId);
        if (seatOpt.isPresent() && !seatOpt.get().isSeatReserved()) {
            Seat seat = seatOpt.get();
            seat.reserve();
            return true;
        }
        return false;
    }
}

Test

SeatServiceWithoutLockTest.java

@Slf4j
@SpringBootTest
class SeatServiceWithoutLockTest {

    @Autowired
    SeatService seatService;

    @Autowired
    SeatRepository seatRepository;

    @Autowired
    PerformanceRepository performanceRepository;

    private Long seatId;
    private final int CONCURRENT_COUNT = 10;

    @BeforeEach
    void setup() {
        Performance performance = performanceRepository.save(
                Performance.builder()
                        .title("락 없음 테스트")
                        .description("동시성 충돌")
                        .category(PerformanceCategory.CONCERT)
                        .performCode("NOLOCK-01")
                        .performStartAt(LocalDateTime.now())
                        .performEndAt(LocalDateTime.now().plusHours(2))
                        .location("서울")
                        .price(10000)
                        .views(0L)
                        .totalSeats(1)
                        .remainSeats(1)
                        .performanceStatus(PerformanceStatus.UPCOMING)
                        .build()
        );

        Seat seat = seatRepository.saveAndFlush(
                Seat.builder()
                        .seatNum("A-NOLOCK")
                        .seatSection("A")
                        .seatReserved(false)
                        .performance(performance)
                        .build()
        );

        seatId = seat.getSeatId();
    }

    @AfterEach
    void tearDown() {
        seatRepository.deleteAll();
        performanceRepository.deleteAll();
    }

    @Test
    @DisplayName("Redisson 락 미적용: 여러 명이 선점 가능")
    void multipleSuccessWithoutLock() throws InterruptedException {
        ExecutorService executor = Executors.newFixedThreadPool(32);
        CountDownLatch latch = new CountDownLatch(CONCURRENT_COUNT);
        AtomicInteger successCount = new AtomicInteger(0);

        for (int i = 0; i < CONCURRENT_COUNT; i++) {
            final int threadNum = i; // 로그에 몇 번째 스레드인지 표시용

            executor.submit(() -> {
                try {
                    log.info("[Thread-{}] 좌석 선점 시도 시작", threadNum);

                    if (seatService.tryReserveSeatWithoutLock(seatId)) {
                        int current = successCount.incrementAndGet();
                        log.info("✅ [Thread-{}] 선점 성공! 현재 성공 횟수 = {}", threadNum, current);
                    } else {
                        log.info("❌ [Thread-{}] 선점 실패", threadNum);
                    }

                } catch (Exception e) {
                    log.error("[Thread-{}] 예외 발생: {}", threadNum, e.getMessage());
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await();
        executor.shutdown();

        log.info("🎯 최종 성공한 사용자 수: {}", successCount.get());

        assertTrue(successCount.get() > 1);
    }

}

실행결과

락 없이 좌석을 선점하는 로직은 문제 없이 발생한다.

SeatServiceWithLockTest.java

@Slf4j
@SpringBootTest
class SeatServiceWithLockTest {

    @Autowired
    SeatService seatService;

    @Autowired
    SeatRepository seatRepository;

    @Autowired
    PerformanceRepository performanceRepository;

    private Long seatId;
    private final int CONCURRENT_COUNT = 5;

    @BeforeEach
    void setup() {
        Performance performance = performanceRepository.save(
                Performance.builder()
                        .title("락 테스트")
                        .description("분산락")
                        .category(PerformanceCategory.CONCERT)
                        .performCode("LOCK-01")
                        .performStartAt(LocalDateTime.now())
                        .performEndAt(LocalDateTime.now().plusHours(2))
                        .location("서울")
                        .price(10000)
                        .views(100L)
                        .totalSeats(100)
                        .remainSeats(100)
                        .performanceStatus(PerformanceStatus.UPCOMING)
                        .build()
        );

        Seat seat = seatRepository.saveAndFlush(
                Seat.builder()
                        .seatNum("A-LOCK")
                        .seatSection("A")
                        .seatReserved(false)
                        .performance(performance)
                        .build()
        );

        seatId = seat.getSeatId();
    }

    @AfterEach
    void tearDown() {
        seatRepository.deleteAll();
        performanceRepository.deleteAll();
    }

    @Test
    @DisplayName("Redisson 락 적용: 1명만 선점 성공")
    void onlyOneSuccessWithLock() throws InterruptedException {
        ExecutorService executor = Executors.newFixedThreadPool(5);
        CountDownLatch latch = new CountDownLatch(CONCURRENT_COUNT);
        AtomicInteger successCount = new AtomicInteger(0);

        for (int i = 0; i < CONCURRENT_COUNT; i++) {
            final int threadNum = i;
            executor.submit(() -> {
                try {
                    log.info("[Thread-{}] 락 기반 좌석 선점 시도", threadNum);
                    if (seatService.tryLockSeat(seatId)) {
                        int current = successCount.incrementAndGet();
                        log.info("✅ [Thread-{}] 선점 성공! 현재 성공 수: {}", threadNum, current);
                    } else {
                        log.info("❌ [Thread-{}] 선점 실패 (락 획득 못함 또는 이미 선점됨)", threadNum);
                    }
                } catch (Exception e) {
                    log.error("💥 [Thread-{}] 예외 발생: {}", threadNum, e.getMessage());
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await();
        executor.shutdown();

        log.info("🎯 최종 성공한 사용자 수: {}", successCount.get());

        assertEquals(1, successCount.get());
    }
}

실행결과

테스트가 실패해서 결과를 보니 좌석에 대하여 하나의 락만 잡혀야하는데 세 개의 락이 잡히고 있었다.

로그를 찍어서 분석했더니 실제로 선점하는 스레드가 세 개가 발견되었다.

세 개의 스레드가 선점한 이후의 스레드는 선점에 실패하는 것으로 보아하니 락 자체는 잡히고 있는걸 확인할 수 있다.

수정

Service 코드의 락 로직에 문제가 있다고 느껴 분석했다.

SeatService.java

@Transactional
    public boolean tryLockSeat(Long seatId) {
        RLock lock = redissonClient.getLock(SEAT_LOCK_PREFIX + seatId);

        try {
            if (lock.tryLock(1, 10, TimeUnit.SECONDS)) {
                Optional<Seat> seatOpt = seatRepository.findById(seatId);
                if (seatOpt.isPresent() && !seatOpt.get().isSeatReserved()) {
                    Seat seat = seatOpt.get();
                    seat.reserve(); //좌석 상태 변경
                    return true;
                }
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
        return false;
    }

아래는 문제로 추정되는 부분의 코드이다.

try {
	if (lock.tryLock(1, 10, TimeUnit.SECONDS)) {
	  Optional<Seat> seatOpt = seatRepository.findById(seatId);
    if (seatOpt.isPresent() && !seatOpt.get().isSeatReserved()) {
	    Seat seat = seatOpt.get();
      seat.reserve(); //좌석 상태 변경
      return true;
    }
  }         
}

코드를 보면 좌석 상태를 변경하는 코드가 포함되어있다. redis의 락 반영과 좌석의 상태가 DB에 트랜잭션으로 반영되는 로직이 동기화?가 잘 안되어 문제가 생긴거 같다는 생각을 했다.

사실 생각해보면 위 로직은 우리 서비스의 좌석 선점 확인 로직과 잘 부합하지 않는다.

단순히 락에 대한 테스트를 진행할 목적이었다보니 이런 코드가 나온 것 같다.

우리 로직은 아래와 같다.

좌석 페이지 진입 → 좌석 선택 후, 다음 클릭 → Redis에서 좌석 이선좌 여부 체크 → 좌석이 선택 가능하면 결제창으로 이동.

결제까지 완료해야지 DB에 예매 정보를 저장하기 때문에 DB에 트랜잭션을 생성할 필요가 없다.

단순 Redis에 이선좌 여부만 확인하고 넘어가면 된다.

코드 수정

SeatService.java

@RequiredArgsConstructor
@Service
public class SeatService {

    private final SeatRepository seatRepository;
    private final RedissonClient redissonClient;
    private final StringRedisTemplate redisTemplate;

    private static final String SEAT_LOCK_PREFIX = "lock:seat:";
    private static final String SEAT_STATE_PREFIX = "seat:";

    //Redis Lock으로 좌석 이선좌 관리
    public boolean tryLockSeat(Long seatId) {
        RLock lock = redissonClient.getLock(SEAT_LOCK_PREFIX + seatId);
        String seatKey = SEAT_STATE_PREFIX + seatId;

        try {
            if (lock.tryLock(1, 10, TimeUnit.SECONDS)) {
                String seatStatus = redisTemplate.opsForValue().get(seatKey);

                if (!"RESERVED".equals(seatStatus)) {
                    // Redis에 임시 선점 상태 저장 (TTL: 5분)
                    redisTemplate.opsForValue().set(seatKey, "RESERVED", 5, TimeUnit.MINUTES);
                    return true;
                }
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }

        return false;
    }

    //Redis Lock 없이 좌석 선택
    public boolean tryReserveSeatWithoutLock(Long seatId) {
        String seatKey = SEAT_STATE_PREFIX + seatId;
        String seatStatus = redisTemplate.opsForValue().get(seatKey);

        if (!"RESERVED".equals(seatStatus)) {
            redisTemplate.opsForValue().set(seatKey, "RESERVED", 5, TimeUnit.MINUTES);
            return true;
        }

        return false;
    }

    //결제 완료 시 DB 반영
    @Transactional
    public void finalizeReservation(Long seatId) {
        Seat seat = seatRepository.findById(seatId)
                .orElseThrow(() -> new RuntimeException("좌석 없음"));
        seat.reserve(); // 실제 DB 반영
    }

}

핵심은 아래 코드이다.

Redis Lock에 대한 값과 좌석에 대한 값을 분리해서 저장했고 DB에 트랜잭션이 발생하지 않게 했다.

//Redis Lock으로 좌석 이선좌 관리
    public boolean tryLockSeat(Long seatId) {
        RLock lock = redissonClient.getLock(SEAT_LOCK_PREFIX + seatId);
        String seatKey = SEAT_STATE_PREFIX + seatId;

        try {
            if (lock.tryLock(1, 10, TimeUnit.SECONDS)) {
                String seatStatus = redisTemplate.opsForValue().get(seatKey);

                if (!"RESERVED".equals(seatStatus)) {
                    // Redis에 임시 선점 상태 저장 (TTL: 5분)
                    redisTemplate.opsForValue().set(seatKey, "RESERVED", 5, TimeUnit.MINUTES);
                    return true;
                }
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }

        return false;
    }

수정한 결과 아래와 같이 락이 잘 잡히는 것을 확인할 수 있었다.

더 많은 스레드를 통해 동시성 제어가 잘 되는지 확인했다.

하나의 좌석에 대하여 100명의 사용자가 요청을 보내는 것을 가정하고 테스트를 진행했다.

private final int CONCURRENT_COUNT = 100;

결과는 성공적이었다.

추가 Test

한 좌석에 대한 100개의 요청에 대하여 성공은 확인했다.

더 실전적인 상황을 가정해서 10개의 좌석에 대하여 각각 100개의 요청을 테스트 해봤다.

MultiLockTest.java

@Slf4j
@SpringBootTest
public class MultiLockTest {
    @Autowired
    SeatService seatService;

    @Autowired
    SeatRepository seatRepository;

    @Autowired
    PerformanceRepository performanceRepository;

    @Autowired
    StringRedisTemplate redisTemplate;

    private final int SEAT_COUNT = 10;
    private final int CONCURRENT_PER_SEAT = 100;
    private List<Long> seatIds;

    @BeforeEach
    void setup() {
        Performance performance = performanceRepository.save(
                Performance.builder()
                        .title("멀티 좌석 락 테스트")
                        .description("동시성 분산락 테스트")
                        .category(PerformanceCategory.PLAY)
                        .performCode("LOCK-MULTI")
                        .performStartAt(LocalDateTime.now())
                        .performEndAt(LocalDateTime.now().plusHours(2))
                        .location("부산")
                        .price(15000)
                        .views(0L)
                        .totalSeats(100)
                        .remainSeats(100)
                        .performanceStatus(PerformanceStatus.UPCOMING)
                        .build()
        );

        seatIds = new ArrayList<>();

        for (int i = 0; i < SEAT_COUNT; i++) {
            Seat seat = seatRepository.saveAndFlush(
                    Seat.builder()
                            .seatNum("S-" + i)
                            .seatSection("A")
                            .seatReserved(false)
                            .performance(performance)
                            .build()
            );
            seatIds.add(seat.getSeatId());
        }
    }

    @AfterEach
    void cleanup() {
        seatRepository.deleteAll();
        performanceRepository.deleteAll();
        for (Long seatId : seatIds) {
            redisTemplate.delete("seat:" + seatId);
        }
    }

    @Test
    @DisplayName("10개의 좌석에 대해 각각 100명이 동시에 선점 시도 → 좌석당 1명만 성공")
    void multiSeatConcurrencyTest() throws InterruptedException {
        Map<Long, AtomicInteger> successMap = new ConcurrentHashMap<>();

        for (Long seatId : seatIds) {
            successMap.put(seatId, new AtomicInteger(0));
            ExecutorService executor = Executors.newFixedThreadPool(10);
            CountDownLatch latch = new CountDownLatch(CONCURRENT_PER_SEAT);

            for (int i = 0; i < CONCURRENT_PER_SEAT; i++) {
                executor.submit(() -> {
                    try {
                        if (seatService.tryLockSeat(seatId)) {
                            successMap.get(seatId).incrementAndGet();
                        }
                    } finally {
                        latch.countDown();
                    }
                });
            }

            latch.await();
            executor.shutdown();
        }

        // 결과 로그 및 검증
        for (Long seatId : seatIds) {
            int success = successMap.get(seatId).get();
            log.info("🎯 Seat ID {} → 성공 수: {}", seatId, success);
            assertEquals(1, success, "좌석 " + seatId + "에는 1명만 성공해야 합니다.");
        }
    }

}

실행결과

  • 10개의 좌석에 각각 100개의 요청
  • 100개의 좌석에 각각 100개의 요청

100좌석 모두 한 명만 선점한 것을 확인할 수 있다.

추후 고려사항

Redis Lock은 이제 잘 되는 것을 확인했다.

현재 락의 유지시간에 대한 TTL이 5분으로 설정되어있다.

하지만, 사용자가 좌석 선점에 성공해서 락을 잡았는데 결제가 완료되기 전에 시스템 오류로 인해 나가진다면? 유령락이 발생하여 문제가 생긴다.

사용자는 좌석 선택 창으로 다시 이동해서 좌석을 고를 것이다. 이미 락이 걸린 좌석은 5분이 지날 때까지 아무도 선점할 수 없는 상태가 발생한다. 이렇게 되면 손실이 발생하기 때문에 장애 대응 로직을 마련해야한다.

0개의 댓글