Java

[Java/Spring]선착순 티켓 예매의 동시성 문제: 잠금 없이 처리하기(Feat. 우테코 아티클)

Woonys 2023. 10. 10. 00:40
반응형

Introduction

예제는 깃허브에 업로드해뒀습니다.

 

얼마 전, 우테코 블로그에 올라온 한 아티클이 화제였다. 바로 <선착순 티켓 예매의 동시성 문제: 잠금으로 안전하게 처리하기>라는 제목의 글이었다. 사내 슬랙 채널에 리드님께서 해당 글을 공유해주셨더라. 그러면서 스레드에 아래와 같은 화두를 던지셨다.

아예 데이터(Ticket 엔티티)에 락을 안 걸고 만들 수 있는 방법은 어떤 게 있을까요?

여기서 전제는 "기존 환경 그대로에서"였다. 즉, 위에 다른 분께서 언급하신 것처럼 메시지큐를 사용한다거나 싱글 스레드 모델을 적용하는 등 아키텍쳐를 변경하지 않고도 바꿀 수 있는 지였다. 이에 많은 팀원들이 여러 가지 방법을 제안해주셨다. 흥미로운 이야기들이 많이 오고갔는데(사실상 거의 모든 동시성 해결 기술이 언급된듯), 결과적으로 리드님께서 원하는 방향은 테이블 구조를 변경하는 방식으로 구현하는 것이었다. 상위 개념으로 치면 "불변성"을 이용하는 방법이다. 아이디어는 다음과 같다.

  • 순서를 먼저 확정하면 특정 순서 이전의 티켓만 취할 수 있지 않을까?
    • Ticket 에서 행사의 메타데이터인 reserved_amount, total_amount 를 빼서 따로 관리하면?
    • 티켓 발급 요청이 들어왔을 때, 티켓을 순서대로 발급하고 (번호표), total_amount 만큼만 확정해준다면?
      • Ticket insert 시 - id auto_increment 로 순차적인 ID 발급
      • idtotal_amount 보다 작을때만 인정해줌

 

그렇다면, 실제로 잘 동작하는지 구현 후 테스트해보도록 하자.

구현 실습: 불변으로 잠금 없이 동시성 처리하기

 

실습 환경 및 의존성은 다음과 같다.

  • 실습 환경
    • SPRING BOOT 2.7.17
    • JAVA 11
    • MySQL
    • Spring Data JPA
  • 의존성 추가
    • Spring-starter-data-jpa
    • lombok
    • mysql-connector-j(MySQL 연결을 위한 JDBC 라이브러리)
MySQL Connector/J is the official JDBC driver for MySQL(링크).

여담이지만 부끄러운 마음에 고백하자면, 이번에 MySQL - Java 연결에 mysql-connector-j 라이브러리를 쓴다는 걸 처음 알았다.(실습 연습 더 많이 하자...)

 

build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '2.7.17-SNAPSHOT'
    id 'io.spring.dependency-management' version '1.0.15.RELEASE'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    sourceCompatibility = '11'
}

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
    maven { url 'https://repo.spring.io/milestone' }
    maven { url 'https://repo.spring.io/snapshot' }
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'com.mysql:mysql-connector-j'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.named('test') {
    useJUnitPlatform()
}

 

Scenario 1: 순차 티켓 발행 예제

일단은 가장 쉬운 순차 발행 시나리오부터 진행하자. 말 그대로 모든 사용자들이 순차적으로 티켓을 한 장씩 받아가는 시나리오다(물론 현실에서는 이와 같이 이뤄지지 않겠지만).

 

위에 주어진 기본 아이디어를 아래와 같이 구현했다. 여기서 핵심은 불변값인 티켓의 id를 이용한다는 점이다.

  • 티켓팅 요청이 들어오면 티켓(Ticket)을 생성한다.
  • 예약 내역(TicketReservedAmount)을 확인해 현재 티켓 여분이 남은 상황이면
    • 예약 내역에 예약 완료된 티켓 장수를 올리고(+1)
    • 티켓의 상태를 예약 완료(success)로 변경한다.
  • 변경된 상태의 티켓을 저장한 뒤(Ticket 테이블)
  • 해당 티켓의 아이디를 들고 있는 예약 객체를 새로 생성해 역시 Reservation 테이블에 저장한다.

이를 위해 필요한 도메인 객체는 총 3개이다.

  • Ticket
  • Reservation
  • TicketReservationAmount(total amount, reserved amount를 관리하는 객체) -> 테이블과 매핑되지 않음

구현 코드는 아래와 같다.


Ticket 엔티티

@Entity
@Getter
public class Ticket {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private ReservationStatus reservationStatus;

    public Ticket() {
        this.reservationStatus = ReservationStatus.WAITING;
    }

    public void succeed() {
        this.reservationStatus = ReservationStatus.RESERVED;
    }

    public void failed() {
        this.reservationStatus = ReservationStatus.FAILED;
    }
}

Reservation 엔티티

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Reservation {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;
    private Long ticketId;

    public Reservation(Long ticketId) {
        this.ticketId = ticketId;
    }
}

 

ReservationStatus(티켓의 예약 상태를 나타내는 enum)

public enum ReservationStatus {
    RESERVED,
    WAITING,
    FAILED
}

 

TicketReservationAmount

@Component
@Getter
@NoArgsConstructor
public class TicketReservationAmount {
    private int totalAmount;
    private int reservedAmount;

    public TicketReservationAmount(int totalAmount) {
        this.totalAmount = totalAmount;
        this.reservedAmount = 0;
    }

    public void increaseReservedAmount() {
        this.reservedAmount++;
    }

    public boolean isPossibleToReserve(Ticket ticket) {
        return ticket.getId() <= totalAmount;
    }

    public void setTotalAmount(int ticketAmount) {
        totalAmount = ticketAmount;
    }
}

 

TicketingService - ticketing() 메소드

    @Transactional
    public void ticketing() {
        Ticket ticket = createTicket();
        if (reservation.isPossibleToReserve(ticket)) {
            reservation.increaseReservedAmount();
            ticket.succeed();
            ticketRepository.save(ticket);
            reservationRepository.save(new Reservation(ticket.getId()));
            return;
        }
        ticket.failed();
        throw new IllegalStateException("예약이 불가능합니다.");
    }

    public Ticket createTicket() {
        Ticket ticket = new Ticket();
        return ticketRepository.save(ticket);
    }

 

돌려보면 어떻게 될까? 테스트 코드는 아래와 같다. 참고로 우테코 아티클의 테스트 코드와 동일하다.

@Test
    void test() throws InterruptedException {
        //given
        int memberCount = 30;
        int ticketAmount = 10;

        final TicketReservationAmount ticketReservationAmount = new TicketReservationAmount(ticketAmount);
        TicketingService ticketingService = new TicketingService(ticketReservationAmount, ticketRepository, reservationRepository);

        AtomicInteger successCount = new AtomicInteger();
        AtomicInteger failCount = new AtomicInteger();

        for (int i = 0; i < memberCount; i++) {
            try {
                ticketingService.ticketing();
                successCount.incrementAndGet();
            } catch (Exception e) {
                System.out.println(e);
                failCount.incrementAndGet();
            }
        }

        System.out.println("successCount = " + successCount);
        System.out.println("failCount = " + failCount);
    }

 

테스트 결과
(좌) Reservation / (우) Ticket(status = 0(Reserved) / 1(Failure))

오케이. 순차 실행은 잘 되는 것을 확인했다. 그러면 이제 대망의 동시 티켓팅 시나리오로 넘어가보자.

 

Scenario 2: 동시 티켓 발행 예제

긴말 할 것 없이 바로 테스트 코드를 돌려보도록 하자.

 @Test
    void test() throws InterruptedException {
        //given
        int memberCount = 30;
        int ticketAmount = 10;

        final TicketReservationAmount ticketReservationAmount = new TicketReservationAmount(ticketAmount);
        TicketingService ticketingService = new TicketingService(ticketReservationAmount, ticketRepository, reservationRepository);

        AtomicInteger successCount = new AtomicInteger();
        AtomicInteger failCount = new AtomicInteger();

        ExecutorService executorsService = Executors.newFixedThreadPool(memberCount);
        CountDownLatch latch = new CountDownLatch(memberCount);

        for (int i = 0; i < memberCount; i++) {
            executorsService.submit(() -> {
                try {
                    ticketingService.ticketing();
                    successCount.incrementAndGet();
                } catch (Exception e) {
                    System.out.println(e.getMessage());
                    failCount.incrementAndGet();
                } finally {
                    latch.countDown();
                }
            });
        }
        latch.await();

        System.out.println("successCount = " + successCount);
        System.out.println("failCount = " + failCount);
    }

결과는 역시 성공이다.

(좌) 티켓 / (우) Reservation

멀티스레드 환경에서 서로 다른 요청이 동시에 발생했을 때도 1-10번 티켓까지만 예약이 된 것을 확인할 수 있다.

 

How to: 답은 auto_increment에 있다

다시 티켓 엔티티를 살펴보자. 티켓 엔티티는 id를 auto_increment로 생성한다.

위의 방식이 어떻게 불변성이고 동시성 이슈를 해결할 수 있는 걸까? 위 방식와 같이 구현하게 될 경우,

  • total_amount(예약의 총량)는 처음에 생성되면 이후에 티켓이 발급된다고 상태가 변하지 않는다(불변).
  • ticket.id 역시 티켓이 생성된 이후에 변하지 않는다(불변). 
    • 티켓 id는 auto_increment 방식으로 증가하게 되어있다. 티켓 생성 시점은 티켓팅 요청 시점이기 때문에 이른 번호일수록 더 일찍 요청했다고 볼 수 있다.
  • 각 티켓이 예약되었는지 아닌지에 대한 상태는 따로 관리하지 않고, 함수 단에서 인정 여부를 판단한다.(TicketReservedAmount.isPossibleToReserve())에서 결정하는 방식
    • 예컨대 총 10장만 티켓을 예약 가능하게 하고 싶다면 총량을 10으로 정한 뒤, 10보다 작은 티켓만 예약을 허가해주고 그 이상은 다 fail 처리하면 된다.
    • 단, 여기서는 상태를 추가해주긴 했다. 물론 저거 빼도 상관없이 동작한다. 비즈니스 상황을 고려해 추가했다.

이렇게 하면 티켓이 생성된 시점부터 예약 여부가 결정되기 때문에 티켓 엔티티에 명시적으로 락을 추가하지 않고도 동시성 이슈를 해결할 수 있게 된다. 

 

하지만 위와 같이 할 경우 다른 이슈가 발생할 여지도 있는데,

  • 매 티켓팅마다 auto_increment를 초기화해줘야 한다.
    • 데이터를 삭제(DELETE)하더라도 DB에서는 지금까지 생성된 티켓의 id를 기억하기 때문에 반드시 auto_increment도 초기화해줘야 한다.(ALTER TABLE `table-name` AUTO_INCREMENT=1`) 이것이 좋은 방식인지는 잘 모르겠음.
  • id를 비즈니스 로직에 활용해도 괜찮을까?
    • 사실 코드를 구현할 때만 해도 별 고민 안했는데, 글을 쓰면서 이 방식의 문제점에 대해 곰곰이 생각해보니 id를 비즈니스 로직에 사용하는 게 맞는지 의문이 들었다. id가 본래 지닌 역할인 식별자 이상으로 도메인 로직이 들어가버리니 코드 읽는 입장에서 왜 이렇게 했는지 한 방에 이해하기 어렵지 않을까 생각이 들었다.
    • 그래서 아티클을 찾아보니 비슷한 생각을 가진 글이 있더라. <글: Don't use Ids in your domain entities!> -> 정확히 이 글에서의 문제점과 일치한 건 아니지만, 엇비슷한 맥락이지 않나 싶었다. 

위 도전은 이렇게도 해볼 수 있다! 정도로 생각하면 되지 않을까 싶다..어쨌거나 연습하는데는 많이 도움이 되었다 :)

 

 

(주의!) 코드 상에서 락을 추가하지 않는다뿐이지, 모든 락을 아예 쓰지 않는다는 말은 아니었다. 예컨대 DB에서 자체적으로 락을 사용하는 경우는 여기서 배제했다.

반응형