JAVA

[Java] ReentrantLock으로 티켓팅 시스템 동시성 문제 해결하기

Stark97 2024. 11. 9. 23:03
반응형

안녕하세요. 개발자 stark입니다! 오늘은 재미있는 주제를 가지고 왔습니다.

바로 인기 아티스트인 지드래곤의 power 콘서트 티켓팅 시스템을 구현하면서 겪을 수 있는 동시성 문제와 그 해결 방법에 대해 이야기해보려고 합니다. (실제로 존재하는 콘서트는 아니며 이런 콘서트가 있다고 상황만 가정해 봤습니다.)

 

아마 많은 분들이 티켓팅에 도전해 보신 경험이 있으실 텐데요. 인기 공연은 오픈과 동시에 수만 명이 접속합니다. 이런 상황에서 시스템이 제대로 동작하지 않으면 어떤 문제가 발생할까요? 예를 들어, 지드래곤의 power 콘서트는 굉장히 특별한 콘서트라 단 100석만 예약이 가능하다고 가정해 보겠습니다. 이 콘서트를 예매하기 위해 만 명의 사용자가 동시에 접속했습니다. 우리가 흔히 생각할 수 있는 방식으로 시스템을 구현하면 어떤 일이 벌어질까요?

 

분명 동시에 같은 데이터를 예약하려고 시도할 것이기 때문에 동시성 문제가 발생할 것입니다. 다행히도 Java에서는 이런 동시성 문제를 해결할 수 있는 도구들을 제공하고 있습니다. 그중에서도 오늘은 ReentrantLock이라는 것을 사용해서 이 문제를 해결해보려고 합니다.

 

 

발단: 티켓 예매 시스템에서 발생한 문제


티켓팅 시스템을 개발해 보신 분들이라면 공감하실 텐데요. 100석짜리 공연에 정확히 100장만 팔리도록 하는 건 정말 중요한 요구사항입니다. 처음 이 상황을 가정하고 시스템을 설계할 때는 단순하게 생각했습니다. '예약 가능한 좌석 수를 체크하고, 좌석이 있으면 예약을 진행하면 되겠지?'

 

실제로 개발 초기에는 잘 동작하는 것처럼 보였습니다. 100~300명 정도가 동시에 예매를 시도하는 환경에서는 아무리 테스트를 해봐도 특별한 문제가 발생하지 않았습니다. 그런데! 실제 서비스 환경을 가정해서 만 명의 동시 접속 테스트를 돌려보니 충격적인 일이 벌어졌습니다. 100석짜리 공연인데 무려 965장의 티켓이 예매된 것입니다.

 

이런 상황이 실제로 발생했다면? 수백 명의 고객들이 예매 성공 메시지를 받고 기뻐했다가, 나중에 예매 취소 통보를 받게 되어서 예약 시스템을 개발한 회사는 굉장히 많은 욕을 먹었을 것입니다. 도대체 어떻게 이런 일이 발생한 걸까요?

 

코드를 하나씩 살펴보면서 문제의 원인을 찾아봅시다. 먼저 엔티티입니다.

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Concert {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String name;
    private int maxSeats;        // 최대 좌석 수
    private int reservedSeats;   // 예약된 좌석 수
    
    public Concert(String name, int maxSeats) {
        this.name = name;
        this.maxSeats = maxSeats;
        this.reservedSeats = 0;
    }
    
    public boolean canReserve(int count) {
        return reservedSeats + count <= maxSeats;
    }
    
    public void reserve(int count) {
        if (!canReserve(count)) {
            throw new SoldOutException("좌석이 부족합니다.");
        }
        this.reservedSeats += count;
    }
    
}
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Reservation {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @JoinColumn(name = "concert_id")
    @ManyToOne(fetch = FetchType.LAZY)
    private Concert concert;
    
    private int seatCount;
    private LocalDateTime reservedAt;
    
    public Reservation(Concert concert, int seatCount) {
        this.concert = concert;
        this.seatCount = seatCount;
        this.reservedAt = LocalDateTime.now();
    }

}

응답 객체도 선언해 봅시다.

@AllArgsConstructor
@Getter
public class ReservationResult {

    private boolean success;
    private String message;

}

그리고 핵심이 되는 예약을 처리하는 서비스 코드입니다. (여기서 동시성 문제가 발생합니다.)

@RequiredArgsConstructor
@Service
public class ConcertServiceWithoutLock {

    private final ConcertRepository concertRepository;
    private final ReservationRepository reservationRepository;

    @Transactional
    public ReservationResult reserve(Long concertId, int count) {
        Concert concert = concertRepository.findById(concertId)
                .orElseThrow(() -> new RuntimeException("공연을 찾을 수 없습니다."));

        // 여기가 바로 동시성 문제가 발생하는 지점!
        if (concert.canReserve(count)) {
            concert.reserve(count);

            Reservation reservation = new Reservation(concert, count);
            reservationRepository.save(reservation);

            return new ReservationResult(true, "예약이 완료되었습니다.");
        } else {
            return new ReservationResult(false, "좌석이 부족합니다.");
        }
    }

}

이 코드의 문제점이 보이시나요? 바로 여러 스레드가 동시에 canReserve() 메서드를 호출할 수 있다는 점입니다. 예를 들어 이런 상황이 발생할 수 있습니다.

  1. 좌석이 1자리 남았을 때 동시에 두 사용자가 예약 시도
  2. 두 스레드 모두 canReserve()에서 true를 반환받음
  3. 두 스레드 모두 예약 진행
  4. 결과적으로 초과 예약 발생!

이 문제를 해결하기 위해 Lock을 사용해야겠다고 생각했고 저는 Lock 중에서도 ReentrantLock을 선택했습니다.

 

 

ReentrantLock이 뭘까요?


Java 5에서 등장한 ReentrantLock은 synchronized 블록보다 섬세한 락 제어 기능을 제공합니다. 이름에서 알 수 있듯, “재진입 가능”한 락으로, 이미 락을 획득한 스레드가 다시 동일한 락을 획득할 수 있도록 허용합니다. 이는 메서드 호출이 중첩될 때 유용하며, 복잡한 비즈니스 로직에서 발생하는 문제를 해결하는 데 도움을 줍니다.

 

기본적으로 ReentrantLock은 다음과 같은 상황에 적합합니다.

  1. 섬세한 락 제어가 필요한 경우: 락을 획득하거나 포기할 때 대기 시간을 설정하거나 조건에 따라 동작을 제어하고 싶을 때.
  2. 재진입이 필요한 경우: 동일한 스레드가 여러 번 락을 요청할 수 있도록 해야 할 때.

ReentrantLock의 주요 메서드를 살펴봅시다.

  • lock(): 락을 획득할 때까지 대기합니다.
  • unlock(): 락을 해제합니다. 반드시 try-finally 블록 안에서 사용해 락이 항상 해제되도록 하는 것이 좋습니다.
  • tryLock(): 락을 획득 시도하고, 실패 시 즉시 포기할 수 있습니다. 예매 시스템에서는 락이 이미 선점된 경우 “예매 실패” 응답을 줄 때 유용합니다.
  • tryLock(long time, TimeUnit unit): 지정된 시간 동안 락 획득을 시도하고, 실패 시 포기합니다. 예매 시스템에서 트래픽이 몰릴 때 일정 시간만 대기 후 응답을 줄 수 있습니다.

 

 

ReentrantLock으로 동시성 문제 해결하기


저는 동시성 문제를 해결하기 위해 Java에서 제공하는 ReentrantLock을 사용했습니다.

  • 참고로 ReentrantLock을 적용해서 서비스 코드를 작성할 때는 클래스를 2개로 분리하는 것이 좋습니다. 왜냐하면 같은 클래스 내의 메서드를 호출할 때는 프록시 기반의 트랜잭션이 동작하지 않기 때문입니다. 즉, 제가 한 클래스 내부에 lock을 거는 reserve() 메서드와 실제 예약을 진행하는 doReserve()를 함께 선언하고 내부적으로 호출하게 되면 비즈니스 오류는 발생하지 않지만 실제로 동작할 때는 동시성 제어가 완벽하게 되지 않는 문제가 발생합니다. 그 이유는 트랜잭션이 커밋되기 전에 락이 해제되면 '다른 스레드'가 끼어들 수 있기 때문입니다.

무슨 말이냐면 이렇게 구성해야 한다는 의미입니다.

  1. 외부 메서드(reserve)에서 락을 관리
  2. 내부 메서드(doReserve)에서 트랜잭션을 관리
  3. 락이 트랜잭션을 완전히 감싸도록 구성

이렇게 구성하면 "Lock → Transaction 시작 → 비즈니스 로직 → Transaction 종료 → Unlock" 순서로 진행되어 안전하게 동시성이 제어됩니다.

@RequiredArgsConstructor
@Service
public class ConcertService {

    private final ConcertReserveService concertReserveService;
    private final ReentrantLock lock = new ReentrantLock();

    // 예약 처리 메서드
    public ReservationResult reserve(Long concertId, int count) {
        // ReentrantLock을 사용하여 한 번에 하나의 스레드만 접근 가능하도록 제어
        if (!lock.tryLock()) {
            return new ReservationResult(false, "다른 예약이 진행 중입니다. 잠시 후 다시 시도해주세요.");
        }

        try {
            // 다른 클래스의 트랜잭션 메서드 호출
            return concertReserveService.doReserve(concertId, count);
        } finally {
            // 락 해제 - 다른 스레드가 접근할 수 있도록 함
            lock.unlock();
        }
    }

}
@RequiredArgsConstructor
@Service
class ConcertReserveService {

    private final ConcertRepository concertRepository;
    private final ReservationRepository reservationRepository;

    // 실제 예약 처리를 담당하는 메서드
    @Transactional
    public ReservationResult doReserve(Long concertId, int count) {
        // 공연 정보 조회
        Concert concert = concertRepository.findById(concertId)
                .orElseThrow(() -> new RuntimeException("공연을 찾을 수 없습니다."));

        // 예약 가능 여부 확인
        if (concert.canReserve(count)) {
            // 예약 처리 - 좌석 수 감소
            concert.reserve(count);

            // 예약 정보 생성 및 저장
            Reservation reservation = new Reservation(concert, count);
            reservationRepository.save(reservation);

            // 예약 성공 결과 반환
            return new ReservationResult(true, "예약이 완료되었습니다.");
        } else {
            // 좌석 부족으로 예약 실패
            return new ReservationResult(false, "좌석이 부족합니다.");
        }
    }

}

자, 이제 ReentrantLock을 사용해서 예매 시스템의 동시성 문제를 해결하는 흐름을 설명드리겠습니다.

 

우선, tryLock()이라는 메서드를 사용해서 락을 얻으려는 시도를 합니다. 이 메서드는 락을 획득할 수 있을 때는 true를 반환하고, 그렇지 않을 때는 false를 반환합니다. 다른 스레드가 이미 락을 가지고 있으면 false가 반환되고, 락을 잡을 수 있다면 true가 반환되는 방식입니다.

 

tryLock()에서 true가 반환되면, 안전하게 예매를 처리할 수 있는 상태가 됩니다. 이때 if 문을 통해 true인 경우에만 예매 처리를 진행하게 되는데, 락을 잡고 나서 “이제 안전하게 예매를 진행할 수 있겠구나” 하고 안심하고 작업을 시작합니다.

 

반대로, 다른 스레드가 이미 락을 잡고 있는 상황이라면 어떻게 될까요? 이 경우 tryLock()이 false를 반환합니다. 그래서 if 문을 통과하지 못하고 "다른 예약이 진행 중입니다"라는 메시지를 반환하게 됩니다. 이런 식으로 락을 잡지 못한 스레드는 기다리지 않고 즉시 응답을 받게 됩니다.

 

이제 락을 잡고 안전하게 예매를 진행하는 단계로 넘어가면, Concert 객체를 조회해서 예약 가능한 좌석 수를 확인하게 됩니다. 좌석이 충분하다면 concertReserveService.doReserve(concertId, count) 메서드를 호출해 좌석 수를 줄이고, 예매 정보를 DB에 저장하게 됩니다. 예매가 성공적으로 완료되면 성공 메시지가 반환될 것입니다. 만약 좌석이 부족한 상황이라면 바로 예매 실패 메시지를 반환해서 사용자가 빠르게 피드백을 받을 수 있도록 합니다.

 

여기서 중요한 부분이 하나 더 있습니다. 바로 finally 블록에서 락을 해제하는 것입니다. 이 finally 블록 덕분에 예외가 발생하더라도 락이 항상 해제되도록 보장할 수 있습니다. 예매 처리 중에 문제가 생기더라도 락이 걸려 있지 않게 되므로, 다음 스레드가 이어서 예매를 진행할 수 있는 상태가 됩니다. 이것이 ReentrantLock의 중요한 장점입니다.

 

결론적으로, ReentrantLock을 사용하면 한 번에 하나의 스레드만 예매를 처리할 수 있으며, 락을 잡지 못한 경우에는 바로 응답을 줄 수 있습니다. 또한, finally 블록을 통해 락을 확실하게 해제하여 시스템의 안정성을 높일 수 있습니다.

 

 

테스트 코드로 동시성 제어가 되는지 검증해 봅시다.


먼저 동시성 문제가 발생하는 경우 테스트를 실행해 보겠습니다.

@DisplayName("동시성 문제 테스트")
@Slf4j
@SpringBootTest
class ConcertWithoutLockTest {

    @Autowired
    private ConcertServiceWithoutLock concertServiceWithoutLock;

    @Autowired
    private ConcertService concertService;

    @Autowired
    private ConcertRepository concertRepository;

    @Autowired
    private ReservationRepository reservationRepository;

    @BeforeEach
    void setUp() {
        reservationRepository.deleteAll();  // 먼저 예약 정보 삭제
        concertRepository.deleteAll();      // 그 다음 공연 정보 삭제
    }

    @DisplayName("락이 없을 때는 좌석이 초과 예약됨")
    @Test
    void withoutLockTest() throws InterruptedException {
        // given
        final Concert concert = concertRepository.save(new Concert("지드레곤 power 콘서트", 100));

        int numberOfThreads = 10000; // 만 명이 동시 요청
        ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads);
        CountDownLatch latch = new CountDownLatch(numberOfThreads);
        AtomicInteger successCount = new AtomicInteger(0);

        // when
        for (int i = 0; i < numberOfThreads; i++) {
            executorService.submit(() -> {
                try {
                    ReservationResult result = concertServiceWithoutLock.reserve(concert.getId(), 1);
                    if (result.isSuccess()) {
                        successCount.incrementAndGet();
                    }
                } catch (Exception e) {
                    // 예외가 발생해도 성공 카운트 증가 (이미 DB에 반영되었을 수 있음)
                    successCount.incrementAndGet();
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await(30, TimeUnit.SECONDS);
        executorService.shutdown();

        // then
        Concert updatedConcert = concertRepository.findById(concert.getId()).orElseThrow();
        log.info("락 미사용) 전체 요청 수: {}", numberOfThreads);
        log.info("락 미사용) 예약된 좌석: {}", updatedConcert.getReservedSeats());
        log.info("락 미사용) 성공한 예약 수: {}", successCount.get());

        assertTrue(updatedConcert.getReservedSeats() > 100 || successCount.get() > 100,
                "동시성 문제가 발생하지 않았습니다.");
    }

}

실행 결과는 다음과 같습니다.

c.e.blog.service.ConcertWithoutLockTest  : 락 미사용) 전체 요청 수: 10000
c.e.blog.service.ConcertWithoutLockTest  : 락 미사용) 예약된 좌석: 100
c.e.blog.service.ConcertWithoutLockTest  : 락 미사용) 성공한 예약 수: 965

테스트 결과 꽤나 충격적인 결과를 얻었습니다. 데이터베이스를 확인해 보니 100석만 예약된 것으로 기록됐는데, 무려 965명의 사용자가 '예약 성공' 메시지를 받았습니다. 즉, 865명의 사용자는 실제로는 티켓이 없는데 예약이 됐다고 잘못 알고 있는 상황이 된 것입니다.

 

이게 실제 서비스였다면 어떤 일이 벌어졌을까요? 아마도 이런 시나리오가 펼쳐졌을 것 같습니다. '아, 예약 성공했다!' 하고 기뻐하는 965명의 사용자들. 하지만 실제 공연장에 입장할 수 있는 건 100명뿐입니다. 나머지 865명은 나중에 '죄송합니다. 시스템 오류로 인해...'라는 메시지를 받게 될 것입니다.

 

대체 왜 이런 일이 발생했을까요? 아주 짧은 순간에 여러 사용자가 동시에 '좌석이 남아있나요?' 하고 확인했고(조회쿼리), 그 순간에는 모두 '네, 좌석 있습니다'라는 답을 받은 거예요. 마치 편의점에서 마지막 한 개 남은 도시락을 여러 명이 동시에 집으려고 하는 것처럼요. 결국 데이터베이스는 100개의 좌석만 예약을 받아줬지만, 우리 서비스는 965명에게 '예약 성공' 메시지를 보내버렸습니다. 이런 불일치가 바로 동시성 문제의 대표적인 사례입니다.

 

동시성 문제를 해결하기 위해 ReentrantLock을 적용시킨 코드를 실행해 봅시다.

@DisplayName("락 사용 시 정확히 100석만 예약됨")
@Test
void withLockTest() throws InterruptedException {
    // given
    final Concert concert = concertRepository.save(new Concert("지드레곤 power 콘서트", 100));

    int numberOfThreads = 10000; // 만 명이 동시 요청
    ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads);
    CountDownLatch latch = new CountDownLatch(numberOfThreads);
    AtomicInteger successCount = new AtomicInteger(0);
    AtomicInteger failCount = new AtomicInteger(0);

    // when
    for (int i = 0; i < numberOfThreads; i++) {
        executorService.submit(() -> {
            try {
                ReservationResult result = concertService.reserve(concert.getId(), 1);
                if (result.isSuccess()) {
                    successCount.incrementAndGet();
                } else {
                    failCount.incrementAndGet();
                }
            } finally {
                latch.countDown();
            }
        });
    }

    latch.await(30, TimeUnit.SECONDS);
    executorService.shutdown();

    // then
    Concert updatedConcert = concertRepository.findById(concert.getId()).orElseThrow();
    log.info("락 사용) 전체 요청 수: {}", numberOfThreads);
    log.info("락 사용) 예약된 좌석: {}", updatedConcert.getReservedSeats());
    log.info("락 사용) 성공한 예약 수: {}", successCount.get());
    log.info("락 사용) 실패한 예약 수: {}", failCount.get());

    // 검증
    assertEquals(100, updatedConcert.getReservedSeats(), "예약된 좌석 수가 100이어야 합니다.");
    assertEquals(100, successCount.get(), "성공한 예약 수가 100이어야 합니다.");
    assertEquals(9900, failCount.get(), "실패한 예약 수가 9900이어야 합니다.");
    assertEquals(numberOfThreads, successCount.get() + failCount.get(), "성공과 실패 합이 전체 요청 수와 같아야 합니다.");
}

실행 결과는 다음과 같습니다.

c.e.blog.service.ConcertWithoutLockTest  : 락 사용) 전체 요청 수: 10000
c.e.blog.service.ConcertWithoutLockTest  : 락 사용) 예약된 좌석: 100
c.e.blog.service.ConcertWithoutLockTest  : 락 사용) 성공한 예약 수: 100
c.e.blog.service.ConcertWithoutLockTest  : 락 사용) 실패한 예약 수: 9900

테스트를 실행해 보니 결과가 명확하게 나왔습니다. ReentrantLock 적용하지 않은 코드와 동일하게 지드래곤 power 콘서트를 만 명이 동시에 예매 시도하는 상황인데 이번에는 정확히 100명만 예매에 성공했습니다. 나머지 9,900명은 실패 응답을 받았습니다.

 

이건 정말 중요한 변화입니다. 이전에는 965명이나 되는 사용자들이 '예약 성공' 메시지를 받았는데, 이제는 정확히 100명만 성공 메시지를 받았습니다. 실제 좌석 수와 예약 성공 건수가 정확히 일치하게 된 것입니다. 더 중요한 건, 이제 예약에 실패한 9,900명의 사용자들이 즉시 실패 응답을 받았다는 점입니다. 확실하게 "다른 예약이 진행 중입니다. 잠시 후 다시 시도해 주세요." 또는 "좌석이 부족합니다."라는 명확한 메시지를 받았을 것입니다.

 

즉, 이전처럼 865명의 사용자가 "예약됐다고 생각했는데 알고 보니 안 됐어요."라는 황당한 상황은 이제 발생하지 않게 된 것입니다. 사용자 입장에서도 자신의 예매 시도가 성공했는지 실패했는지 명확하게 알 수 있게 됐습니다. 이런 게 바로 동시성 제어가 왜 중요한지 보여주는 좋은 예시입니다. ReentrantLock 덕분에 우리 시스템은 이제 믿을 수 있는 응답을 주게 됐고, 사용자들도 더 이상 혼란스러워하지 않고 믿음을 가지는 시스템이 되었습니다.

 

 

마무리하며


동시성 문제는 실제 서비스에서 자주 마주치게 되는 까다로운 문제입니다. 특히 동시에 같은 데이터에 접근하여 변경처리를 해야 하는 티켓 예매, 재고 관리와 같은 경우 정확한 수량 관리가 필요하기 때문에 반드시 적절한 동시성 제어가 필요합니다.

 

이런 상황에 ReentrantLock을 사용하면 우아하게  동시성 문제를 해결할 수 있습니다. 다만 다음과 같은 점을 주의하셔야 합니다.

  1. 락의 범위를 최소화하여 성능 저하를 방지해야 합니다.
  2. finally 블록에서 반드시 락을 해제해야 합니다.
  3. 데드락이 발생하지 않도록 주의해야 합니다.

더 나아가 실제 서비스에서는 분산 환경을 고려해야 할 수도 있습니다. 특히 저는 MSA 프로젝트를 진행 중이기에 이런 경우에는 Redis나 Zookeeper를 이용한 분산 락을 고려해 볼 수도 있습니다. 이번 기회에 여러분이 개발 중인 서비스에서도 동시성 문제가 발생하고 있진 않은지 한번 점검해 보시는 건 어떨까요? 😊

 

저는 이만 물러가보도록 하겠습니다. 긴 글 읽어주셔서 감사합니다 :)

 

 

반응형