어떻게 하면 중복 예약이 발생하지 않는 시스템을 구축할 수 있을까?

SeokHwan An·2025년 2월 14일
0

초록스터디

목록 보기
7/7

초록 스터디 예약 시스템 미션을 수행하면서 중복 예약을 막는 법에 대해서 스터디원들과 논의할 수 있는 시간을 가졌습니다.

미션의 목표는 Spring 입문자들이 Spring을 익숙하게 활용할 수 있도록 돕는 것이었으며 주요 내용은 예약 시스템에 예약 데이터를 추가하는 것이었습니다. 저는 스터디원들과 spring 사용 방법을 넘어서 예약 시스템 구축하는 데 핵심인 중복 예약에 대해 어떻게 해결할 수 있을지에 대해서 이야기를 나누었습니다.

문제 정의

해결책을 이야기 하기 앞서서 중복 예약 문제가 발생할 수 있는 시나리오에 대해서 먼저 정의했습니다. 크게 2가지 시나리오가 도출되었습니다.

  1. 이미 존재하는 시간, 날짜에 예약이 들어온 경우
  2. 동시 타이밍에 같은 날짜와 시간에 예약이 들어온 경우

위의 두 시나리오에 대해서 해결 방안에 대해서 이야기를 나누어보았습니다.

문제 해결법

이미 존재하는 시간, 날짜에 예약이 들어온 경우

  1. 관리자가 새로운 예약을 추가한다.

  2. 저장소(메모리 혹은 db)에서 예약이 이미 존재하는지 파악

    2 - 1. 이미 존재하는 경우 예외처리

    2 - 2. 그렇지 않는 경우는 예약 추가를 하는 방안에 대해서

Reservation.java (편하게 다루기 위해서 date와 time은 String으로 처리하겠습니다.)

public class Reservation {

    private final Long id;
    private final String name;
    private final String date;
    private final String time;

    public Reservation(Long id, String name, String date, String time) {
        this.id = id;
        this.name = name;
        this.date = date;
        this.time = time;
    }
}

Reservations.java

public class Reservations {
    
    private final List<Reservation> reservations;
    
    public Reservations(List<Reservation> reservations) {
        this.reservations = reservations;
    }   
}

위와 같이 Reservation과 여러 Reservation을 관리하는 일급 컬랙션인 Reservations가 있다면 중복 예약을 막는 방법은 간단하게 처리할 수 있습니다. 먼저 Reservation이 같은 날짜와 시간을 가지는 경우에 대해서 같은 객체로 인식할 수 있게 equals와 hashCode를 재정의 합니다.

Reservation.java에 추가할 부분

@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;
    Reservation that = (Reservation) o;
    return Objects.equals(date, that.date) && Objects.equals(time, that.time);
}

@Override
public int hashCode() {
    return Objects.hash(date, time);
}

equals를 재정의하면 Collection을 이용할 때 객체의 주소 값이 아닌 원하는 필드 값으로 비교가 용이해져 코드의 가독성이 보다 높아집니다.

Reservations.java에 새로운 예약 시간을 추가하는 기능 구현

public void addReservation(Reservation reservation) {
    if (reservations.contains(reservation)) {
        throw new IllegalArgumentException("이미 해당 날짜와 시간에 예약이 존재합니다.");)
    }
    reservations.add(reservation);
}

Reservation 리스트에 새롭게 추가할 reservation이 존재하는지 먼저 확인하고 존재하지 않는다면 새로운 예약을 추가합니다. 이와 같이 손쉽게 해당 중복 예약 시나리오를 보다 쉽게 해결할 수 있습니다. 여기서 조금 더 나아가 스터디 원들과 적절한 Collection 선택에 대해서도 고민해보았습니다. 현재 Reservations는 contains()를 통해 예약이 이미 존재하는지 파악하고 있습니다.

List의 Contains() 내부 동작 코드를 보면 for문을 통해 직접 탐색하는 것을 볼 수 있습니다. 그렇기에 O(N)의 시간이 발생합니다. 여기서 기존 예약 객체의 개수가 적다면 성능 상에 문제가 없겠지만 점점 예약이 많아지게 되면 성능 저하가 발생할 수 있을 것입니다. 대신 HashSet이나 Hash 기반의 자료 구조를 이용한다면 O(1)에 시간으로도 같은 날짜와 시간의 예약이 이미 존재하는지 파악할 수 있다는 것을 스터디원들에게 공유했습니다.

동시 타이밍에 같은 날짜, 시간을 원하는 예약이 들어온 경우

먼저 스터디 원들에게 테스트 코드를 통해 앞선 포함여부 처리만 가지고는 중복 예약을 막을 수 없다는 것을 보여주었습니다.

@DisplayName("동시 타이밍의 요청에서 중복 예약이 발생할 수 있다.")
@Test
void createDuplicationReservation_ShouldFail() throws InterruptedException {
    // given
    Reservations reservations = new Reservations(new ArrayList<>());

    // 동시 실행할 스레드 개수
    int reserveCount = 1000;
    final ExecutorService executorService = Executors.newFixedThreadPool(100);
    final CountDownLatch countDownLatch = new CountDownLatch(reserveCount);

    for (int i = 0; i < reserveCount; i++) {
        executorService.submit(() -> {
            try {
                reservations.addReservation(new Reservation(1L, "예약자", "2023-08-05", "15:40"));
            } catch (Exception e) {
            } finally {
                countDownLatch.countDown();
            }
        });
    }

    countDownLatch.await();

    // then
    assertThat(reservations.getReservations()).hasSize(1); // 동시성 문제로 인해 중복 데이터가 발생해야 한다
}

해당 테스트를 실행하면 다음과 같은 예외가 발생합니다.(중복 예약 상황이 자주 발생할 것이라 생각했지만 생각보다 자주 발생하지 않고 간헐적으로 발생합니다.)

즉, 두 개의 동일한 Reservation 객체가 Reservations에서 포함된 상황입니다.

해당 문제가 발생하게 된 원인은 무엇인지 살펴보겠습니다. 우리는 여러 addReservation()이 발생할 때 addReservation이 하나씩 하나씩 처리되는 것을 기대할 것입니다. 하지만 이는 실제 동작과는 다릅니다. 지금과 같이 두개의 중복 예약이 발생한 상황을 자세하게 보겠습니다.

위의 그림처럼 두 쓰레드가 동시에 if문을 통과하면서 중복 요청이 발생하는 것입니다. 자바에서는 요청을 동기화하는 기법으로 synchronized 키워드를 활용할 수 있습니다.

synchronized를 이용하면 두 쓰레드가 해당 메소드에 동시에 접근을 할 수 있는 것이 아닌 순차적으로 접근을 하게 됩니다. 그렇기에 우리가 의도했던 방향인 하나의 요청이 마무리 되고 다음 요청이 처리되어 중복 예약이 발생하지 않게 됩니다. 다만 synchronized의 경우 동시 요청 처리를 포기한 만큼 성능 저하가 발생할 수 있기에 synchronized를 붙이는 메소드는 많은 양의 일처리를 하지 않게 구성하는 것이 좋을 것입니다.

synchronized의 이용 방법에 대해 알아보고자 한다면 https://www.baeldung.com/java-synchronized 를 참고하시면 좋을 것 같습니다.

서비스가 발전해서 분산시스템을 확장된다면

먼저 분산시스템이 되면 기존에 객체를 통해 예약을 관리하는 것이 어려워집니다.

각 서버에서 메모리에 예약을 관리하게 되면 중복된 예약이 각각 서버 내부에서는 발생하지 않겠지만 전체적인 예약 시스템 관점에서는 중복 예약이 발생하게 됩니다. 그 이유는 synchronized는 하나의 서버 내부에서 동기화 작업은 처리해주지만 다른 서버에서 처리하는 요청에 대해서는 제어를 할 수 없기 때문입니다.

이 때에는 동시 요청 처리 방식이 변경되어야 할 것 같습니다. 여러가지 방안이 있겠지만 스터디 원들과 이야기를 나눠본 결과는 다음과 같습니다.

정리

스터디원들과 중복 예약 처리 방법에 대해서 이야기를 나눠보면서 이론으로만 알고 있던 synchronized의 동작원리를 파악할 수 있었고 더불어 서비스가 발전해 변화하는 상황에서도 중복 예약 방지를 어떻게 해야하지에 대해서 고민할 수 있었던 시간이 되어서 좋았던 것 같습니다.

앞으로도 스터디원들과 지속적으로 의견을 나누고 함께 리뷰하는 과정을 통해, 서로 성장해 나가는 데 힘쓰고자 합니다.

0개의 댓글