본문 바로가기

프로젝트/고민

우리의 문제 상황에 맞는 락 구현

요약

사용자가 여러개의 좌석을 선택한 상황에서 동시에 다른 사용자가 선택한 좌석과 겹치는 상황이 발생했을 때 어떻게 하면 사용자의 경험을 좋게하면서 최소한의 비용으로 이 상황을 극복할지에 관해 작성했다.

 

좌석을 선점(선택)할 때는 db를 사용하지 않았기 때문에 일반적인 락과는 다르게 접근해야 했으며, 레디스라는 캐시 서버에 특정 조건에 연산이 수행되지 않도록 하는 것이 문제 해결의 열쇠였다.

문제상황

문제 상황을 말하기에 앞서 예약을 하는 흐름은 다음과 같다.

  1. 매장을 선택한다.
  2. 매장에서 예약가능한 시간과 날짜를 선택한다
  3. 해당 날짜의 좌석을 선점한다.
  4. 예약 버튼을 누르면 선점한 좌석이 예약이 된다.

이를 코드로 보여주자면 아래와 같다.

 

문제의 코드

예약할 좌석의 상태를 캐싱해두는 코드

@Service
@RequiredArgsConstructor
public class ReservationService {

    private final ReservationCache reservationCache;

    private final ApplicationEventPublisher eventPublisher;

    public UUID preemtiveReservation(Long userId, ReservatㅇionPreemptiveRequest reservationPreemptiveRequest) {
        // 예약을 만듦
        var reservationId = UUID.randomUUID();
        var reservation = createReservation(reservationId, userId, reservationPreemptiveRequest);

        // 선점한 좌석 및 만든 좌석 캐시에 저장
        var shopReservationDateTimeSeatIds = reservationPreemptiveRequest.shopReservationDateTimeSeatIds();
        return reservationCache.preemp(shopReservationDateTimeSeatIds, reservation.getReservationId(), reservation);
    }

    private Reservation createReservation(UUID reservationId,
                                          Long userId,
                                          ReservationPreemptiveRequest reservationPreemptiveRequest) {
        return new Reservation(reservationId,
            userId,
            reservationPreemptiveRequest.requirement(),
            reservationPreemptiveRequest.personCount());
    }
}

예약할 좌석은 아래와 같이 list로 id로 받는다.

public record ReservationPreemptiveRequest(

        @NotNull(message = "선점하려는 좌석이 null이면 안됩니다.")
        @Size(min = 1, message = "선점하려는 좌석은 1개 이상이어야 합니다.")
        List<Long> shopReservationDateTimeSeatIds,

        String requirement,

        @Min(value = 1, message = "예약 인원은 1명 이상이어야 합니다.")
        @Max(value = 30, message = "예약 인원은 30명 이하여야 합니다.")
        int personCount
) {
}

예약할 상태를 저장할 캐시는 아래와 같이 구현했다.

@Slf4j
@Component
@RequiredArgsConstructor
public class ReservationCache {

    private final PreemptiveShopReservationDateTimeSeats preemptiveShopReservationDateTimeSeats;

    private final PreemptiveReservations preemptiveReservations;

    private final StringRedisTemplate redisTemplate;

    public UUID preemp(List<Long> shopReservationDateTimeSeatIds, UUID reservationId, Reservation reservation) {

        var reservationRedisDto = ReservationRedisDto.of(reservation);

        redisTemplate.execute(new SessionCallback<Object>() {
            @Override
            public <K, V> Object execute(RedisOperations<K, V> operations) throws DataAccessException {
                try {
                    operations.multi();
                    preemptiveShopReservationDateTimeSeats.addAll(shopReservationDateTimeSeatIds);
                    preemptiveReservations.add(reservationId, reservationRedisDto);
                } catch (Exception e) {
                    operations.discard();
                }
                return operations.exec();
            }
        });
        return reservationId;
    }
}

이 상황에서 동시에 여러 사용자가 같은 좌석에 대한 선점 요청을 할 수 있는 문제를 발견했다.

 

문제 발견

30명의 사용자가 동일한 좌석에 동시에 요청했을 때의 시나리오를 코드로 작성해봤다.

    @Test
    @DisplayName("30명의 클라이언트가 동일한 예약 좌석에 대해 동시에 선점할 수 있다.")
    void concurrencyTest() {
        var es = Executors.newFixedThreadPool(30);

        var latch = new CountDownLatch(30);
        var request = new ReservationPreemptiveRequest(List.of(1L, 2L), "require", 2);
        var success = new AtomicInteger();
        var fail = new AtomicInteger();
        for (int i = 0; i < 30; i++) {
            es.execute(() -> {
                try {
                    reservationService.preemtiveReservation(1L, request);
                    log.info("성공 횟수" + success.incrementAndGet());
                } catch (Exception e) {
                    log.info("실패 횟수" + fail.incrementAndGet());
                } finally {
                    latch.countDown();
                }

            });
        }
        try {
            // 모든 스레드가 완료될 때까지 대기
            latch.await();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            es.shutdown();
        }
    }

요청할 때 좌석의 아이디를 선택한 뒤 요청을 하도록 했다.

 

아래와 같이 30회의 요청이 모두 수행이 된다. 즉, 같은 좌석에 대해 동시에 선점을 할 경우 마지막 스레드의 요청만 요청이 되는 것이다.

 

해결 과정

어떻게 하면 최소한의 자원으로 이 문제를 해결할지 많은 고민을 했다.

선점 좌석에 대한 상태 저장을 db에 한 것이 아닌 Redis 캐시에 했으니 db row에 낙관적, 비관적, 네임드 락을 걸 수 는 없다.

여기서 다음과 같은 의문을 가질 수 있다.

Redis는 싱글 스레드로 동작하는데 왜 동시성에 대해 고민을 하시나요??

다음의 시나리오를 생각해보자.

사용자 A, B가 있다.

사용자 A는 11월 1일 19시에 1번, 2번 좌석을 선택하고 예약을 했다.

이와 동시에 사용자 B는 11월 1일 19시에 1번, 3번 좌석에 예약을 했다.

그렇다면 1번 좌석에 대한 권하는 사용자 A에게 있을까? B에게 있을까? Redis가 싱글 스레드로 동작한다고 해도 요청을 순차적으로 처리하지 다음 요청에 예외를 던지지는 않는다.

한마디로 1번 좌석에 대한 권한은 누가 가질지 모르게 되는 것이다.

 

이제 해결해보자.

아이디어

요청 자체에 락을 거는 것이 아니라 db로 생각했을 때 row 하나에 락을 거는 것이기 때문에 Spring Data에서 지원하는 낙관적 락을 직접 구현해야겠다는 아이디어를 떠올렸다.

 

일단 Redis가 싱글 스레드로 동작한다는 것을 계속 인지하고 있자.

캐싱을 하는 코드에 낙관적 락을 직접 구현했다.

 

코드는 아래와 같다.

@Slf4j
@Component
@RequiredArgsConstructor
public class ReservationCache {

    private final PreemptiveShopReservationDateTimeSeats preemptiveShopReservationDateTimeSeats;

    private final PreemptiveReservations preemptiveReservations;

    private final StringRedisTemplate redisTemplate;

    private static final int TIME_TO_LEAVE = 9;

    public UUID preemp(List<Long> shopReservationDateTimeSeatIds, UUID reservationId, Reservation reservation) {
				//아래의 코드가 추가됨
        shopReservationDateTimeSeatIds.forEach(i -> {
            var key = "lock" + i;
            var currentValue = redisTemplate.opsForValue().increment(key);
            if (currentValue != null && currentValue == 1) {
                redisTemplate.expire(key, TIME_TO_LEAVE, TimeUnit.MINUTES);
            }
            if (currentValue != null && currentValue >= 2) {
                throw new IllegalStateException("좌석 " + i + "의 선점 횟수가 2 이상입니다.");
            }
        });
				//여기까지

        var reservationRedisDto = ReservationRedisDto.of(reservation);

        redisTemplate.execute(new SessionCallback<Object>() {
            @Override
            public <K, V> Object execute(RedisOperations<K, V> operations) throws DataAccessException {
                try {
                    operations.multi();
                    preemptiveShopReservationDateTimeSeats.addAll(shopReservationDateTimeSeatIds);
                    preemptiveReservations.add(reservationId, reservationRedisDto);
                } catch (Exception e) {
                    operations.discard();
                }
                return operations.exec();
            }
        });
        return reservationId;
    }
}

원리를 설명하자면 좌석의 아이디를 key, count를 value로 넣고<id, count> 캐싱하는 메소드가 호출되는 순간 좌석의 아이디를 하나씩 늘리고 결과 값이 2 이상이면 예외를 던지게 했다. (redis가 싱글 스레드로 동작하기에 가능한 코드)

이제 테스트 결과를 확인해보자. 이전과 똑같은 테스트 코드다.

    @Test
    @DisplayName("30명의 클라이언트가 동일한 예약 좌석에 대해 동시에 선점할 때 1명의 사용자만 성공한다.")
    void concurrencyTest4() {
        var es = Executors.newFixedThreadPool(30);

        var latch = new CountDownLatch(30);
        var request = new ReservationPreemptiveRequest(List.of(1L, 2L), "require", 2);
        var success = new AtomicInteger();
        var fail = new AtomicInteger();
        for (int i = 0; i < 30; i++) {
            es.execute(() -> {
                try {
                    reservationService.preemtiveReservation(1L, request);
                    log.info("성공 횟수" + success.incrementAndGet());
                } catch (Exception e) {
                    log.info("실패 횟수" + fail.incrementAndGet());
                } finally {
                    latch.countDown();
                }

            });
        }
        try {
            // 모든 스레드가 완료될 때까지 대기
            latch.await();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            es.shutdown();
        }
    }

결과는 아래와 같이 1명의 사용자만 선점에 성공한다.

redis를 통해 구현했기 때문에 서버가 확장됐을 때 분산 서버에서도 작동하는 코드이다.

문제상황

문제 상황을 말하기에 앞서 예약을 하는 흐름은 다음과 같다.

  1. 매장을 선택한다.
  2. 매장에서 예약가능한 시간과 날짜를 선택한다
  3. 해당 날짜의 좌석을 선점한다.
  4. 예약 버튼을 누르면 선점한 좌석이 예약이 된다.

이를 코드로 보여주자면 아래와 같다.

예약할 좌석의 상태를 캐싱해두는 코드

@Service
@RequiredArgsConstructor
public class ReservationService {

    private final ReservationCache reservationCache;

    private final ApplicationEventPublisher eventPublisher;

    public UUID preemtiveReservation(Long userId, ReservatㅇionPreemptiveRequest reservationPreemptiveRequest) {
        // 예약을 만듦
        var reservationId = UUID.randomUUID();
        var reservation = createReservation(reservationId, userId, reservationPreemptiveRequest);

        // 선점한 좌석 및 만든 좌석 캐시에 저장
        var shopReservationDateTimeSeatIds = reservationPreemptiveRequest.shopReservationDateTimeSeatIds();
        return reservationCache.preemp(shopReservationDateTimeSeatIds, reservation.getReservationId(), reservation);
    }

		private Reservation createReservation(UUID reservationId,
                                          Long userId,
                                          ReservationPreemptiveRequest reservationPreemptiveRequest) {
        return new Reservation(reservationId,
            userId,
            reservationPreemptiveRequest.requirement(),
            reservationPreemptiveRequest.personCount());
    }
}

예약할 좌석은 아래와 같이 list로 id로 받는다.

public record ReservationPreemptiveRequest(

        @NotNull(message = "선점하려는 좌석이 null이면 안됩니다.")
        @Size(min = 1, message = "선점하려는 좌석은 1개 이상이어야 합니다.")
        List<Long> shopReservationDateTimeSeatIds,

        String requirement,

        @Min(value = 1, message = "예약 인원은 1명 이상이어야 합니다.")
        @Max(value = 30, message = "예약 인원은 30명 이하여야 합니다.")
        int personCount
) {
}

예약할 상태를 저장할 캐시는 아래와 같이 구현했다.

@Slf4j
@Component
@RequiredArgsConstructor
public class ReservationCache {

    private final PreemptiveShopReservationDateTimeSeats preemptiveShopReservationDateTimeSeats;

    private final PreemptiveReservations preemptiveReservations;

    private final StringRedisTemplate redisTemplate;

    public UUID preemp(List<Long> shopReservationDateTimeSeatIds, UUID reservationId, Reservation reservation) {

        var reservationRedisDto = ReservationRedisDto.of(reservation);

        redisTemplate.execute(new SessionCallback<Object>() {
            @Override
            public <K, V> Object execute(RedisOperations<K, V> operations) throws DataAccessException {
                try {
                    operations.multi();
                    preemptiveShopReservationDateTimeSeats.addAll(shopReservationDateTimeSeatIds);
                    preemptiveReservations.add(reservationId, reservationRedisDto);
                } catch (Exception e) {
                    operations.discard();
                }
                return operations.exec();
            }
        });
        return reservationId;
    }
}

이 상황에서 동시에 여러 사용자가 같은 좌석에 대한 선점 요청을 할 수 있는 문제를 발견했다.

30명의 사용자가 동일한 좌석에 동시에 요청했을 때의 시나리오를 코드로 작성해봤다.

  	@Test
    @DisplayName("30명의 클라이언트가 동일한 예약 좌석에 대해 동시에 선점할 수 있다.")
    void concurrencyTest() {
        var es = Executors.newFixedThreadPool(30);

        var latch = new CountDownLatch(30);
        var request = new ReservationPreemptiveRequest(List.of(1L, 2L), "require", 2);
        var success = new AtomicInteger();
        var fail = new AtomicInteger();
        for (int i = 0; i < 30; i++) {
            es.execute(() -> {
                try {
                    reservationService.preemtiveReservation(1L, request);
                    log.info("성공 횟수" + success.incrementAndGet());
                } catch (Exception e) {
                    log.info("실패 횟수" + fail.incrementAndGet());
                } finally {
                    latch.countDown();
                }

            });
        }
        try {
            // 모든 스레드가 완료될 때까지 대기
            latch.await();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            es.shutdown();
        }
    }

요청할 때 좌석의 아이디를 선택한 뒤 요청을 하도록 했다.

아래와 같이 30회의 요청이 모두 수행이 된다. 즉, 같은 좌석에 대해 동시에 선점을 할 경우 마지막 스레드의 요청만 요청이 되는 것이다.

해결 과정

어떻게 하면 최소한의 자원으로 이 문제를 해결할지 많은 고민을 했다.

선점 좌석에 대한 상태 저장을 db에 한 것이 아닌 Redis 캐시에 했으니 db row에 낙관적, 비관적, 네임드 락을 걸 수 는 없다.

여기서 다음과 같은 의문을 가질 수 있다.

Redis는 싱글 스레드로 동작하는데 왜 동시성에 대해 고민을 하시나요??

다음의 시나리오를 생각해보자.

사용자 A, B가 있다.

사용자 A는 11월 1일 19시에 1번, 2번 좌석을 선택하고 예약을 했다.

이와 동시에 사용자 B는 11월 1일 19시에 1번, 3번 좌석에 예약을 했다.

그렇다면 1번 좌석에 대한 권하는 사용자 A에게 있을까? B에게 있을까? Redis가 싱글 스레드로 동작한다고 해도 요청을 순차적으로 처리하지 다음 요청에 예외를 던지지는 않는다.

한마디로 1번 좌석에 대한 권한은 누가 가질지 모르게 되는 것이다.

이제 해결해보자.

요청 자체에 락을 거는 것이 아니라 db로 생각했을 때 row 하나에 락을 거는 것이기 때문에 Spring Data에서 지원하는 낙관적 락을 직접 구현해야겠다는 아이디어를 떠올렸다.

일단 Redis가 싱글 스레드로 동작한다는 것을 계속 인지하고 있자.

캐싱을 하는 코드에 낙관적 락을 직접 구현했다. 코드는 아래와 같다.

@Slf4j
@Component
@RequiredArgsConstructor
public class ReservationCache {

    private final PreemptiveShopReservationDateTimeSeats preemptiveShopReservationDateTimeSeats;

    private final PreemptiveReservations preemptiveReservations;

    private final StringRedisTemplate redisTemplate;

    private static final int TIME_TO_LEAVE = 9;

    public UUID preemp(List<Long> shopReservationDateTimeSeatIds, UUID reservationId, Reservation reservation) {
				//아래의 코드가 추가됨
        shopReservationDateTimeSeatIds.forEach(i -> {
            var key = "lock" + i;
            var currentValue = redisTemplate.opsForValue().increment(key);
            if (currentValue != null && currentValue == 1) {
                redisTemplate.expire(key, TIME_TO_LEAVE, TimeUnit.MINUTES);
            }
            if (currentValue != null && currentValue >= 2) {
                throw new IllegalStateException("좌석 " + i + "의 선점 횟수가 2 이상입니다.");
            }
        });
				//여기까지

        var reservationRedisDto = ReservationRedisDto.of(reservation);

        redisTemplate.execute(new SessionCallback<Object>() {
            @Override
            public <K, V> Object execute(RedisOperations<K, V> operations) throws DataAccessException {
                try {
                    operations.multi();
                    preemptiveShopReservationDateTimeSeats.addAll(shopReservationDateTimeSeatIds);
                    preemptiveReservations.add(reservationId, reservationRedisDto);
                } catch (Exception e) {
                    operations.discard();
                }
                return operations.exec();
            }
        });
        return reservationId;
    }
}

원리를 설명하자면 좌석의 아이디를 key, count를 value로 넣고<id, count> 캐싱하는 메소드가 호출되는 순간 좌석의 아이디를 하나씩 늘리고 결과 값이 2 이상이면 예외를 던지게 했다. (redis가 싱글 스레드로 동작하기에 가능한 코드)

이제 테스트 결과를 확인해보자. 이전과 똑같은 테스트 코드다.

    @Test
    @DisplayName("30명의 클라이언트가 동일한 예약 좌석에 대해 동시에 선점할 때 1명의 사용자만 성공한다.")
    void concurrencyTest4() {
        var es = Executors.newFixedThreadPool(30);

        var latch = new CountDownLatch(30);
        var request = new ReservationPreemptiveRequest(List.of(1L, 2L), "require", 2);
        var success = new AtomicInteger();
        var fail = new AtomicInteger();
        for (int i = 0; i < 30; i++) {
            es.execute(() -> {
                try {
                    reservationService.preemtiveReservation(1L, request);
                    log.info("성공 횟수" + success.incrementAndGet());
                } catch (Exception e) {
                    log.info("실패 횟수" + fail.incrementAndGet());
                } finally {
                    latch.countDown();
                }

            });
        }
        try {
            // 모든 스레드가 완료될 때까지 대기
            latch.await();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            es.shutdown();
        }
    }

결과는 아래와 같이 1명의 사용자만 선점에 성공한다.

 

redis를 통해 구현했기 때문에 서버가 확장됐을 때 분산 서버에서도 작동하는 코드이다.

 

후기

문제를 직면했을 당시에는 redisson 의존성을 추가해서 분산락을 적용할지, 선점 과정에 DB I/O를 발생하게 해서 버전 필드를 추가해서 낙관적으로 락을 해결할지 고민했다.

 

하지만, 우리의 상황은 다른 케이스들과는 다른 특이한 상황이었기 때문에 우리의 어플리케이션에 맞는 문제 해결 방법이 필요했고 고민한 결과 어렵지 않게(?) 적은 비용으로 문제를 해결했다. 

 

이번 경험을 통해 동시성에 대한 시야가 넓어졌고 문제 해결 능력이 올라갔다고 느껴졌다.