티스토리 뷰

안녕하세요 강정호 입니다.

이번 포스팅에서는 콘서트 예약시스템을 구현하면서 고민했던 내용에 대해서 정리해보려고 합니다.

 

[동시성 이슈에 대한 Lock 처리]

콘서트 예약시스템에서 동시성 이슈가 발생하는 지점은 "좌석 임시예약" 기능에서 발생합니다.

1) 좌석 테이블에 Lock이 걸리지 않은 경우

Lock이 걸려있지 않으면 모든 사용자가 좌석 예약이 가능하게 됩니다. 그렇게 되면 가장 마지막에 처리한 사용자의 ID로 예약이 됩니다.

이는 예약시스템에서 절대 발생하면 안되는 오류이므로 동시성을 제어할 수 있는 Lock은 반드시 필요합니다

 

2) Optimistic Lock(낙관적락)을 이용한 동시성 제어

낙관적 락은 아래 조건에서 사용하기가 좋은데요

- 여러개의 요청 중 1건만 성공시켜야 하는 경우

- 충돌 빈도가 낮은 경우

 

낙관적 락으로도 좌석 예약시 동시성을 제어할 수 있습니다. 하지만 위 시스템에서 낙관적 락이 적합하지 않다고 생각하는 이유는 인기 좌석의 경우 충돌이 빈번하게 발생합니다. 낙관적 락에서 충돌이 발생하면 트랜잭션 롤백과 재처리 로직을 구현해줘야 합니다. 그리고 사용자 입장에서도 좌석을 예약할 때 실패하면 처음부터 다시 다른 좌석을 선택하여 예약을 시도해야 한다는 사용자 경험에서의 단점이 있습니다.

 

3) Pessimistic Lock(비관적락)을 이용한 동시성 제어

비관적 락은 아래 조건에서 사용하기가 좋은데요

- 순차적으로 처리를 해야하는 경우

- 충돌 빈도가 높은 경우

- 정합성이 매우 중요한 경우

 

비관적락으로 구현하는 경우 여러 사용자들의 트랜잭션 충돌을 방지할 수 있습니다. 그리고 Lock이 걸린 상태에서 안전하게 좌석의 예약상태를 변경할 수 있기 때문에 정합성 보장도 용이합니다. 따라서 비관적 락을 채택하여 좌석 임시예약 기능을 구현하였습니다.

 

[대기열을 구현하는 방식에 대한 고민]

초기 구현 방식 : 은행 창구방식 + DB 로 구현

* 은행 창구방식이란?

은행 창구에서 서비스를 응대하는 방식처럼 고객의 업무가 끝나면 빈 창구의 갯수만큼 사용자를 받아들이는 방식이다.

예를 들면 50명의 사용자만 예약이 가능하다면, 나머지 고객은 예약이 끝날때까지 대기열에서 기다리다가 다른 고객의 예약이 끝나면

차례대로 예약을 진행하는 방식.

 

장점 : 대용량 트래픽이 들어올 때 유량 제어 + 서버 부하 감소

콘서트 예약처럼 트래픽이 높은 경우 일시적으로 들어오는 서버의 부하를 줄여줄 수 있다.

 

단점

1. 고객의 대기시간과 사용자 경험

50명만 예약이 가능하면, 나머지 고객들은 대기열에서 언제까지 기다려야 하는지 알지도 못한채 대기해야 한다.

그렇다면 중도 이탈하는 사용자들이 발생하고 사용자 경험이 나쁠 것이라고 판단.

 

2. DB 부하 증가

DB는 비싼 자원이다. 그런데 대용량으로 트래픽이 몰릴 때 모든 사용자에게 토큰과 사용자 ID를 발행하고 테이블에 저장하는 작업은

DB에 큰 부하를 줄 수 있다.

 


 

개선된 구현 방식 : 놀이동산 방식 + Redis로 구현

놀이동산 방식 : 일정 시간마다 N명을 예약 가능한 상태로 전환해주는 방식

 

**대기열 토큰(Token Queue)**

- 대기열 토큰은 유저가 서비스를 이용하기에 앞서 서비스를 이용할 수 있는 유효한 토큰인지를 검증하기 위해 사용됩니다. 대규모 트래픽이 한번에 도메인 서비스에 접근하는 것을 방지할 수 있습니다.
- 대기열 토큰은 Redis를 활용하여 관리합니다.
- 대기열 토큰은 두가지 상태를 갖고, 서비스를 이용을 위해 대기하거나, 서비스를 이용할 수 있습니다. 사용이 완료되었거나, 만료된 토큰은 삭제하면 되기에 별도의 상태를 관리하지 않습니다.
    - [Understand Redis data types](https://redis.io/docs/latest/develop/data-types/)
    1. Waiting Tokens
        - Sorted Set 자료구조로 저장되며, Key는 토큰을 Score는 요청시간을 Member에는 유저정보를 저장합니다.
        - ZADD 명령어로 신규 대기열을 추가하고, ZRANK 명령어로 토큰 조회에 내 앞 대기인원을 계산하며, ZRANGE 명령어로 Active 토큰으로 활성화합니다.
    2. Active Tokens
        - Sets 자료구조로 저장되며, Key는 토큰을 Member에는 유저정보와 만료일시 등 메타정보를 포함합니다.
        - SADD을 이용하여 Active Tokens에 토큰을 추가합니다.
        - SMEMBERS를 이용하여 Active Tokens과 메타정보를 조회합니다.
        - Active Tokens에 포함된 토큰은 예약 서비스를 이용할 수 있는 토큰으로 api 요청에 포함된 토큰이 Active Tokens에 있다면, 추가로 유저정보 및 만료일시 등을 체크하여 서비스 이용 validation을 처리합니다.
        - 예약완료시에 해당 토큰을 삭제하고, 일정주기로 Active Tokens에 만료일시가 지난 토큰들을 삭제하는 프로세스를 수행합니다.

**Active Tokens 전환 방식**

1. Active Tokens 에서 만료된 토큰의 수만큼 Waiting Tokens에서 전환
    - 서비스를 이용할 수 있는 유저를 항상 일정 수 이하로 유지할 수 있다.
    - 서비스를 이용하는 유저의 액션하는 속도에 따라 대기열의 전환시간이 불규칙하다.
2. N초마다 M개의 토큰을 Active Tokens 으로 전환
    - 대기열 고객에게 서비스 진입 가능 시간을 대체로 보장할 수 있다.
    - 서비스를 이용하는 유저의 수가 보장될 수 없다.

 

장점

1. 일정 시간마다 사용자들이 예약 가능

10초마다 750명을 대기열에서 예약가능한 상태로 사용자들의 상태를 변환하였습니다.

그렇게 하여 사용자들은 일정한 시간만 기다리면 예약 가능.

ex) 12,000번째 사용자 => 2.6분 후에 입장 가능.

 

2. DB의 부하 감소

Redis의 Sorted Set을 사용하여 Wait_Queue, Active_Queue 2개로 구분.

Wait_Queue은 사용자들이 대기하는 대기열이고, Active_Queue는 사용자들이 예약을 진행하는 상태입니다.

DB에서 관리하는 것이 아니라 Redis에서 처리하기 때문에 DB 부하를 줄이고 속도도 빠르다.

 

단점

1. 서버 부하

10초마다 750명의 사용자를 예약 가능하도록 전환시키면 예약 완료한 고객보다 진행하는 고객 수의 증가속도가 더 높아서 서버의 부하가 될 수 있다. 이 문제에 대한 해결책은 없을까??

위 문제 고민해보기

 

 

[아키텍쳐에 대한 고민]

초기 구현 : Controller <--> Service <--> Repository

초기에는 위와 같이 일반적인 구조로 설계하여 개발을 하였습니다. 하지만 이렇게 구현했을 때 아래와 같은 한계점이 존재했습니다.

 

한계점 1 : 단위테스트

JpaRepository 테스트를 위해서 ~impl 클래스에서 인터페이스로 상속받아서 단위테스트를 하는 경우

JpaRepository에서 제공하는 "메서드 쿼리"를 모두 구현해야 하기 때문에 단위 테스트에 어려움이 존재

 

한계점 2 : 강한 결합도

결제를 하려고 하면 좌석 조회모듈, 예약 모듈, 결제 모듈 등 여러가지 모듈들이 Service 레이어에서 수평적으로 서로 호출해야 합니다.

이렇게 각 모듈이 수평적인 관계에서 서로 호출하게 되면 강한 결합이 발생합니다. 그래서 코드를 수정하거나 다른 곳에 이식을 하려고 할 때

영향도가 커서 수정할 범위가 매우 커집니다.

 

개선된 구현 : 클린 아키텍쳐 + 레이어드 아키텍쳐 + UseCase Pattern

콘서트 시스템 폴더 구조

프로그래밍을 하면서 개인적으로 느꼈던 위 아키텍쳐의 장점은 이렇다

 

장점 1 : 프로그램 수정에 대한 영향도 최소화

UseCase 레이어가 있고, 여기에서 각 도메인 모듈을 호출한다. 마치 부페식으로 필요한 모듈 호출해서 데이터 조합해서 Controller로 반환해주는 것이다. 이 때 도메인 모듈을 수직적으로 호출하기 때문에 각 도메인끼리는 호출을 하지 않아서 영향도가 없다.

그렇다보니까 프로그램 수정할 때도 타 도메인에 대한 영향도가 적어서 수정 범위가 작아진다. 프로그램 수정에 매우 용이하다!!

 

장점 2 : 단위테스트 용이

@Repository
@RequiredArgsConstructor
@Slf4j
public class ReservationCoreRepositoryImpl implements ReservationCoreRepository {

  private final ReservationJpaRepository reservationJpaRepository;

  @Override
  public Reservation reserveSeat(Reservation reservation) {

    return reservationJpaRepository.save(reservation);
  }

  @Override
  public Reservation findReservationByUserIdAndSeatId(Long userId, Long seatId) {
    return reservationJpaRepository.findReservationByUserIdAndSeatId(userId, seatId);
  }

  @Override
  public Reservation findReservationByReservationId(Long reservationId) {
    return reservationJpaRepository.findReservationByReservationId(reservationId);
  }
}

 

위 코드에서 보면 "ReservationCoreRepository" 인터페이스를 구현하고 있다.

실제 구현은 ReservationJpaRepository 인터페이스에서 구현되어 있는 메서드 쿼리로 구현을 하고 있다.

위와 같이 ReservationJpaRepository를 멤버변수로 두어 사용하면 모든 메서드 쿼리를 구현할 필요 없이 ReservationCoreRepository 인터페이스에서 내가 구현하고 싶은 메서드만 직접 구현하면 된다.

이렇게 되면 단위 테스트 작성이 매우 쉬워진다.

 

아래는 단위테스트 예제.

@ExtendWith(MockitoExtension.class)
public class ReservationReaderTest {

  @Mock
  private ReservationCoreRepositoryImpl reservationCoreRepositoryImpl;

  @InjectMocks
  private ReservationReader reservationReader;

  private Concert concert;
  private User user;
  private Seat seat;

  @BeforeEach
  void setUp() {

    concert = new Concert(1L, "드림콘서트", "아이유");
    user = new User(1L, "AAAABBBBCCC", 0);
    seat = new Seat(1L, 500, SeatStatus.SOLDOUT);

  }

  @DisplayName("[예약조회] userId, seatId로 예약내역 조회")
  @Test
  void case1() {
    // given
    Reservation expectedReservation = Reservation.builder()
                                         .reservationId(1L)
                                         .reservationDate(LocalDate.now())
                                         .reservationStatus(ReservationStatus.RESERVATION_SUCCESS)
                                         .concert(concert)
                                         .user(user)
                                         .seat(seat)
                                         .build();
    when(reservationCoreRepositoryImpl.findReservationByUserIdAndSeatId(anyLong(), anyLong())).thenReturn(expectedReservation);

    // when
    Reservation actualReservation = reservationReader.findReservationByUserIdAndSeatId(anyLong(), anyLong());

    // then
    assertEquals(expectedReservation, actualReservation);
  }


}

 

 

장점 3 : 각 도메인이 모듈화

위와 같은 아키텍쳐에서는 비즈니스 도메인 로직이 핵심인 설계구조이다. 그래서 User, Seat, Reservation 등의 도메인이 독립적으로 구현이 되어있다. 이렇게 되면 Reservation 도메인 폴더만 똑! 복사해서 다른 곳에 붙여 넣어도 바로 사용할 수 있다. 즉 각각의 도메인을 영향도를 최소화 하여 부품처럼 사용할 수 있다는 장점이 있다.

 

 

[테이블 설계]

 

콘서트 예약시스템에서 콘서트 테이블과 좌석 테이블을 어떻게 설계할까 고민을 했었다.

내가 생각한 초기 설계와 개선된 설계는 아래와 같다.

 

1. 초기 설계

좌석테이블과 콘서트 테이블이 1개에 같이 있다.

위와 같이 설계한 이유는 "조회 속도" 때문이다.

테이블이 2개로 나누어져 있다면 JOIN을 걸어서 조회해야 한다.

JOIN이 많으면 조회시 성능이 낮아지기 때문에 테이블을 반정규화 하는 케이스도 있어서 

위와 같이 좌석과 콘서트 테이블을 1개로 했다.

그런데 고민을 해보니 아래와 같은 문제가 있었다.

 

문제점 1 : 데이터 중복

특정 날짜의 한 콘서트에 좌석이 10만개일 경우?

이렇게 되면 콘서트아이디, 콘서트일자 데이터에 많은 중복이 생긴다. 

 

문제점 2 : 인덱스 설정

위 테이블에서 인덱스를 PK로 걸면 3개의 PK에 대해서 인덱스를 걸어야 한다.

그러나 콘서트아이디, 콘서트일자, 좌석아이디 컬럼 중에서 1개를 제외하고 조회하는 경우 인덱스를 타지 않을 수 있다.

- 콘서트아이디 & 콘서트일자 : 이 경우 인덱스를 타고 조회할 가능성 크다.

- 콘서트아이디 & 좌석아이디 : 인덱스를 타지 않는다. 콘서트아이디와 콘서트일자 조합으로 인덱스 정렬이 되어있는데, 이렇게 콘서트 일자가 없으면 인덱스 순서에 맞지 않기 때문.

- 콘서트일자 & 좌석아이디 : 인덱스를 타지 않는다. 콘서트아이디를 생략하면 인덱스를 효율적으로 사용할 수 없다.

 

2. 개선된 설계

 

위와 같이 테이블 설계를 개선하였다.

 

장점 1 : 좌석테이블의 인덱스 설정

좌석 테이블에서 좌석을 조회할 때 좌석ID가 PK이기 때문에 이것으로 인덱스를 설정하여 조회 가능

 

장점 2 : 데이터 중복 제거

콘서트 정보를 따로 만들어서 Meta성의 테이블로 사용한다.

그렇게 되면 좌석 테이블에 불필요한 콘서트ID 필드가 중복되지 않는다.

 

장점 3 : 콘서트 정보 변경시 영향도 감소

콘서트 정보가 변경된다면 콘서트 테이블의 정보만 변경하면 된다.

초기 구현에서 콘서트 정보를 변경하려면 모든 데이터를 바꿔줘야 하는 비효율성이 있다.

댓글