Backend

간단한 티켓팅 시스템을 구현하여 동시성 문제를 경험해보자 !(feat. 비관락, 낙관락)

eunmiee 2025. 4. 20. 17:58

시작하며


안녕하세요 ! 오랜만에 찾아온 포스팅이네요 :-)

 

이번 포스팅은 간단한 티켓팅 시스템을 구축해 보고 동시성 문제를 경험해 보며 어떻게 문제를 해결해 볼 수 있을지 고민해 보는 시간을 가져보려고 합니다 !

 

갑자기 해당 공부를 시작하게 된 저의 TMI를 공유하자면.. 

최근 회사에서 쇼핑몰 솔루션 프로젝트를 시작했는데요 !
상품과 주문 도메인을 개발하면서 하나의 고민에 빠지게 되었습니다.

바로 여러 고객이 동시에 재고가 1개 남은 상품을 주문할 경우, 해당 동시성 문제는 어떻게 처리가 되고 어떻게 해결할 수 있을까? 에 대한 문제였습니다.

 

서블릿 컨테이너가 멀티 쓰레드를 지원하면서 동시성 문제에 부딪힐 수 있다는 것을 개념적으로는 알고 있었지만 한 번도 경험해보지 못했고, 그에 대한 해결방법이나 지식이 없어 어떻게 구현해야할지 고민하고 있는 상황이였습니다.

 

 

마침 운이 좋게도 학부 동아리에서 서버 스터디를 제안해주셔서 커리큘럼을 보게 되었는데, 현재 제가 직면하고 있는 문제를 해결할 수 있는 공부인 것 같아 참여하기로 했습니다 :)

이번 기회를 통해 학습한 내용을 바탕으로 회사 프로젝트에 도입할 수 있도록 열심히 공부를 해보도록 하겠습니다 !!

 

트랜잭션 개념 이해하기


시작하기에 앞서, 다음 실습 내용은 데이터베이스 트랜잭션의 대한 기본 지식을 알고 시작하는게 도움이 됩니다 !

트랜잭션의 개념에 대해 간단히 정리를 해보도록 하겠습니다.

 

트랜잭션이란?

트랜잭션은 이름 그대로 번역했을 때 '거래'라는 뜻이라고 합니다.

이것을 쉽게 풀어서 이야기 하면 트랜잭션은 하나의 거래를 안전하게 처리하도록 보장해주는 것을 뜻하는 것이죠 !

만약 계좌이체를 한다고 할 경우 A가 B에게 10000원을 이체한다고 생각해봅시다.

A의 계좌에서는 10000원이 감소하고, B에 계좌에서는 10000원이 증가해야할 것입니다.

만약 이중에서 하나의 작업이라도 제대로 이루어지지 않는다면 이는 잘못된 서비스가 되겠죠 ?

그래서 이러한 경우가 생길 경우 데이터 베이스에서는 롤백(rollback)을 진행하여 이전 상태로 되돌릴 수 있도록 지원합니다.

반대로, 모두 정상적으로 작업이 성공했다면 커밋(commit)을 진행하게 되는 것이죠 !

 

트랜잭션은 ACID라고 하는 원자성, 일관성, 격리성, 지속성을 보장해야합니다.

 

Atomicity (원자성) 트랜잭션은 모두 실행되거나, 아예 실행되지 않아야한다. 중간 실패 ❌

Consistency (일관성) 트랜잭션 전후에 DB의 상태가 무결성을 유지해야 한다. 규칙에 맞지 않는 상태가 되면 안 됨

Isolation (격리성) 동시에 여러 트랜잭션이 실행돼도 서로 영향을 주면 안 됨. 독립적으로 처리돼야 함

Durability (지속성) 트랜잭션이 성공적으로 커밋되면, 그 데이터는 영구적으로 보존돼야 한다 (서버 꺼져도 OK)

 

트랜잭션은 원자성, 일관성, 지속성을 보장합니다. 문제는 격리성인데, 트랜잭션 간에 격리성을 완벽히 보장하려면 트랜잭션을 거의 순서대로 실행해야만 합니다. 하지만 그렇게 진행할 경우 성능이 매우 느려지게 되겠죠..

 

이러한 문제로 인해 트랜잭션의 격리수준을 4단계로 나누어 정의했습니다.

트랜잭션 격리수준(Isolation level)

  • READ UNICOMMITED (커밋되지 않은 읽기)
  • READ COMMITED (커밋된 읽기) - 기본적으로 사용(DB)
  • REPREATABLE READ (반복 가능한 읽기)
  • SERIALIZABLE (직렬화 기능)

 

격리수준에 따라 발생할 수 있는 문제점

트랜잭션 격리수준을 바로 알아보기전, 각 격리수준에서 발생할 수 있는 문제점이 존재한다. 이 문제점을 간단히 사전 지식으로 학습하고, 격리수준을 상세히 알아보도록 한다.

  • 더티 리드 (Dirty Read) : 더티 리드란 특정 트랜잭션에 의해 데이터가 변경되었지만, 아직 커밋되지 않은 상황에서 다른 트랜잭션이 해당 변경 사항을 읽어와버리는 문제를 뜻한다.
  • 반복 불가능한 조회 (Non-Repeatable Read) : 같은 트랜잭션 내에서 같은 데이터를 여러번 조회했을 떄 읽어온 데이터가 다른 경우를 뜻한다.
  • 팬텀 리드 (Phantom Read) : Non-Repeatable Read 의 한 종류로, 읽어온 결과가 새로운 행이 생겼거나 또는 없어진 현상을 뜻한다.

 

간단한 티켓팅 시스템을 구축해보자!


스터디 첫째주 주제는 다음과 같았습니다.

간단한 티켓팅 시스템 구축 & 동시성 문제 이해 - 정합성 문제를 체험
여러 사용자가 동시에 티켓을 예약하는 상황에서 DB의 트랜잭션만으로는 정합성이 깨질 수 있다는 걸 실습으로 체험합니다.
- 동시에 자원에 접근하는 상황을 시뮬레이션
- 이중 예약 같은 정합성 문제를 직접 경험
- 키워드: 경합조건, 동시성, 트랜잭션의 한계

 

이를 바탕으로 간단한 티켓팅 시스템을 구축했고, 기술 스택과 ERD, 시나리오는 다음과 같습니다.

 

자세한 구현 코드는 아래 링크를 참고해주세요 !

https://github.com/appcenter-advanced-study/ticketing-system-eunmi

 

GitHub - appcenter-advanced-study/ticketing-system-eunmi

Contribute to appcenter-advanced-study/ticketing-system-eunmi development by creating an account on GitHub.

github.com

 

기술스택

  • JDK 17
  • Springboot3
  • SpringJPA
  • h2

설계 및 시나리오

ERD

  • 한명의 회원은 티켓을 한번에 1장씩만 구매할 수 있다.
  • 하나의 티켓은 하나의 재고상태를 갖는다 - 티켓에 따른 옵션은 존재하지 않음(etc. S석, R석..)

 

시나리오

동시성 문제 테스트는 총 3가지 시나리오로 테스트를 진행해보았습니다.

다음 시나리오를 통해 동시성 문제를 경험해보며, DB의 트랜잭션만으로는 정합성이 깨질 수 있다는 걸 실습으로 경험해보도록 하겠습니다 !

  • 한 티켓의 재고가 100장일 때, 100명이 동시에 해당 티켓을 요청하는 경우
  • 한 티켓의 재고가 1장일 때, 100명이 동시에 해당 티켓을 요청하는 경우
  • 관리자가 티켓명을 변경하려고 할 때, 동시에 같은 티켓의 티켓명을 변경하는 경우

 

01 티켓의 재고가 100장일 때, 100명이 동시에 해당 티켓을 요청하는 경우


해당 코드는 데이터베이스 락이나 데이터베이스 격리수준 설정 없이 진행된 코드입니다 !

public interface TicketStockRepository extends JpaRepository<TicketStock, Long> {
    Optional<TicketStock> findByTicketId(Ticket ticketId);
}
    @Test
    @DisplayName("동시성 테스트 - 100명이 동시에 같은 재고가 100장인 티켓을 예매할 경우, 예매가 성공한다.")
    public void reservationException1() throws Exception {
        // given
        int threadCount = 100;
        ExecutorService executor = Executors.newFixedThreadPool(threadCount);  // thread 생성
        CountDownLatch latch = new CountDownLatch(threadCount);  // 모든 스레드 작업이 끝날 때까지 await() 대기

        log.info("beforeEach : 티켓 생성 및 재고 등록");
        Ticket newTicket = new Ticket("ticket1");
        Long ticketId = ticketService.createTicket(newTicket);
        TicketStock newTicketStock = new TicketStock(newTicket, 100);
        ticketStockService.save(newTicketStock);
        this.ticketId = ticketId;
        log.info("티켓 초기화 완료 : 티켓 번호는 {}", ticketId);

        log.info("티켓 예매를 시작합니다. : {}", ticketId);
        // when
        for (int i = 0; i < threadCount; i++) {
            final int idx = i + 1;
            executor.submit(() -> {
                try {
                    try {
                        Thread.sleep(100);
                        reservationService.reserve("member" + idx, ticketId);
                        log.info("예매 성공 : {}", "member" + idx);
                    } catch (Exception e) {
                        log.info("예매 실패 : {}번 회원 {}", idx, e.getMessage());
                    }
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await(); // thread 끝날 때까지 대기

        // then
        assertThat(reservationService.findAll().size()).isEqualTo(100);
        Ticket ticket = ticketService.findTicketById(ticketId);
        TicketStock stock = ticketStockService.findByTicketId(ticket);
        assertThat(stock.getQuantity()).isZero(); // 재고가 정확히 0이어야 성공
    }

 

기댓값

  • 티켓이 100장 있기 때문에 모든 회원이 티켓 예매에 성공하고, 남은 티켓 수량은 0이여야함.

결과는 ?

예약은 총 100건 처리가 되었지만 남은 재고 수량이 0이 아니라는 결과값을 리턴하면서 테스트가 실패하게 되었습니다..

 

왜 ? 이런 문제가 발생했을까?
  • 쓰레드 100개가 동시에 티켓 예매를 진행
  • DB에서 티켓 재고를 조회 → 동시성 문제 발생 !! (여러 트랜잭션이 동시에 같은 레코드에 접근하면 동시성 문제가 발생)
    but, 기본적으로 데이터베이스는 READ COMMIT 격리수준으로 설정되어 있기때문에 읽기(조회)에 대한 락이 발생하지 않는다.
    그렇기 때문에, 다른 트랜잭션과 동시에 조회를 하게 될 경우, 같은 재고 값을 조회하게 됨
  • 각 쓰레드는 같은 재고를 읽었음에도 불구하고, (현재 조회된 재고 - 1)로 예매 성공 처리
  • 현재 업데이트된 재고로 디비에 저장
  • 동시에 조회된 쓰레드의 경우, 같은 시점에 조회한 재고에서 1개를 차감하게 되면서 재고가 0이 아닌 잘못된 값으로 나타나게 됨

다음 시나리오도 테스트해보도록 하겠습니다.

02 티켓의 재고가 1장일 때, 100명이 동시에 해당 티켓을 요청하는 경우

    @Test
    @DisplayName("예외 테스트 - 동시에 1장의 티켓을 예매할 경우, 1명의 회원만 예매가 성공한다.")
    public void reservationException2() throws Exception {
        // given
        log.info("beforeEach : 티켓 생성 및 재고 등록");
        Ticket newTicket = new Ticket("ticket1");
        Long ticketId = ticketService.createTicket(newTicket);
        TicketStock newTicketStock = new TicketStock(newTicket, 1);
        ticketStockService.save(newTicketStock);
        this.ticketId = ticketId;
        log.info("티켓 초기화 완료 : 티켓 번호는 {}", ticketId);

        int threadCount = 100;
        ExecutorService executor = Executors.newFixedThreadPool(threadCount);  // thread 생성
        CountDownLatch latch = new CountDownLatch(threadCount);  // 모든 스레드 작업이 끝날 때까지 await() 대기

        log.info("티켓 예매를 시작합니다. : {}", ticketId);
        // when
        for (int i = 0; i < threadCount; i++) {
            final int idx = i + 1;
            executor.submit(() -> {
                try {
                    try {
                        Thread.sleep(100);
                        reservationService.reserve("member" + idx, ticketId);
                        log.info("예매 성공 : {}", "member" + idx);
                    } catch (Exception e) {
                        log.info("예매 실패 : {}번 회원 {}", idx, e.getMessage());
                    }
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await(); // thread 끝날 때까지 대기

        // then
        assertThat(reservationService.findAll().size()).isEqualTo(1);
        Ticket ticket = ticketService.findTicketById(ticketId);
        TicketStock stock = ticketStockService.findByTicketId(ticket);
        assertThat(stock.getQuantity()).isZero(); // 재고가 정확히 0이어야 성공
    }

마찬가지로 기댓값 1이 아닌 총 10명의 회원이 티켓 예매에 성공했다는 결과를 얻을 수 있었습니다.

 

위 문제도 마찬가지로 동시에 서로다른 트랜잭션이 커밋되기 이전 시점의 데이터를 조회하게 되면서 티켓이 1장이 남았다고 인식하여 10명은 티켓 예매를 성공하게 됩니다.

 

그렇다면 이러한 문제를 해결하기 위해서는 어떻게 해야하는가 ?

 

이 문제를 해결하기 위해서는 데이터베이스 트랜잭션 격리수준을 활용하거나 락을 이용하여 어플리케이션 단에서 처리할 수 있도록 제어를 할 수 있습니다.

 

해당 내용은 다음 포스팅에 이어서 첨부하도록 하겠습니다 :-)

 

맛보기로 락을 사용하여 해당 테스트 케이스를 재시도한 결과를 보도록 하겠습니다 !

 

Lock을 사용하여 동시성 문제를 해결해보자 !


public interface TicketStockRepository extends JpaRepository<TicketStock, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    Optional<TicketStock> findByTicketId(Ticket ticketId);
}

 

저는 JPA를 사용하고 있기 때문에 @Lock(LockModeType.PESSIMISTIC_WRITE)을 사용하여 비관적 락을 걸어 테스트를 진행했습니다.

 


 

결과는 ??

모든 테스트 케이스가 성공한 모습을 볼 수 있었습니다 !

 

왜 이런 결과가 나왔을까요 ?

그건 다음 포스팅에서 이어서 확인해보도록 하겠습니다 😉

 

 

그렇다면 트랜잭션은 한계가 없는가?


아직 한가지 테스트가 더 남았는데요 !

바로 세번째 시나리오인

  • 관리자가 티켓명을 변경하려고 할 때, 동시에 같은 티켓의 티켓명을 변경하는 경우

인데요 !

 

이 문제의 경우 동시에 같은 티켓을 조회하여 먼저 수정을 한 순서대로 티켓명이 업데이트 되는 상황입니다.

* 원래는 같은 좌석을 동시에 예매할 경우로 테스트를 진행하는 것이 조금 더 이해에 도움이 될 것 같았지만,, 데이터베이스 설계 시 고려하지 못해 다음 시나리오로 진행해보겠습니다😭

    @Test
    @DisplayName("동시에 티켓명을 변경할 경우, 마지막에 등록한 수정정보로 등록된다.")
    public void updateTicketTest1() throws Exception {
        // given
        int threadCount = 3;
        ExecutorService executor = Executors.newFixedThreadPool(threadCount);  // thread 생성
        CountDownLatch latch = new CountDownLatch(threadCount);  // 모든 스레드 작업이 끝날 때까지 await() 대기

        log.info("티켓명 변경을 시작합니다. : {}", ticketId);
        // when
        for (int i = 0; i < threadCount; i++) {
            final int idx = i + 1;
            executor.submit(() -> {
                try {
                    try {
                        Thread.sleep(100);
                        TicketRequest.UpdateTicketRequest dto =
                                new TicketRequest.UpdateTicketRequest(ticketId, "update ticket" + idx);
                        Ticket ticket = ticketService.updateTicket(dto);
                        log.info("{}번 회원 수정 완료 - 변경 후 : {}", idx, ticket.getName());
                    } catch (Exception e) {
                        log.info("{}번 회원 수정 실패 : {}", idx, e.getMessage());
                    }
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await(); // thread 끝날 때까지 대기

        // then
        /**
         * 두번의 갱신 분실의 문제 발생 - 트랜잭션 범위 수준을 넘어서는 문제(트랜잭션으로 해결 불가)
         */
//        Assertions.assertThat(ticketService.findTicketById(ticketId).getName()).isEqualTo("update ticket2");
        log.info("최종 저장된 티켓명 : {}", ticketService.findTicketById(ticketId).getName());
    }

  • 동시에 티켓명을 수정할 경우, 마지막 수정자의 수정 내용이 반영된다.
    • 데이터 베이스 트랜잭션의 범위를 넘어선 문제로, 트랜잭션만으로는 문제 해결 불가

위와 같은 문제를 두 번의 갱신 분실 문제라고 하는데요.

 

이 문제를 해결하기 위해서는 3가지의 방법이 존재합니다.

 

1. 마지막 커밋만 인정하기

사용자 A의 내용은 무시하고 마지막에 커밋한 사용자 B의 내용만 인정한다.

 

2. 최초 커밋만 인정하기

사용자 A가 이미 수정을 완료 했으므로 사용자 B가 수정을 완료할 때 오류가 발생한다.

 

3. 충돌하는 갱신 내용 병합하기

사용자 A와 사용자 B의 수정사항을 병합한다.

 

기본적으로는 마지막 커밋만 인정하기를 사용합니다. 하지만 상황에 따라 최초 커밋만 인정하기가 더 합리적일 수 있죠 !

JPA에서 제공하는 버전 관리 기능을 통해서 최초 커밋만 인정하기를 구현할 수 있습니다.

마지막으로 충돌하는 갱신 내용 병합하기의 경우 최초 커밋만 인정하기를 조금 더 우아하게 처리하는 방법으로 애플리케이션 개발자가 직접 사용자를 위해 병합 방법을 제공해야합니다 .

 

마무리


이렇게 간단한 티켓팅 시스템을 구축하여 몇가지 시나리오로 동시성 테스트를 진행해보며, 정합성 문제가 발생하는 것을 테스트 해보았습니다 :)

이후 해결방법으로 락을 사용하여 동시성 문제를 해결해보았는데요 !

해당 내용은 다음 포스팅에서 락과 트랜잭션을 활용하여 정합성 확보를 시도해보며 그에 대한 과정을 정리해보도록 하겠습니다.

 

오늘도 긴 글 읽어주셔서 감사합니다 !

 

 

📚 참고자료

- 자바 ORM 표준 JPA 프로그래밍