콘텐츠로 건너뛰기

마지막 남은 콘서트 좌석, 두 명이 동시에 예약한다면?

콘서트 티켓 예매를 해본 사람이라면 알 것이다. 인기 가수의 콘서트 예매는 그야말로 전쟁이다. 수만 명이 동시에 접속하고, 원하는 좌석을 선택하려는 순간 이미 다른 사람이 예약했다는 메시지가 뜨기도 한다.

그런데 여기서 한 가지 의문이 생겼다.

마지막 남은 좌석을 두 명이 정확히 동시에 예약한다면 어떻게 될까?

가령 A열 10번 좌석이 하나 남아 있는 상황이다.

사용자 A와 사용자 B가 거의 같은 시각에 예약 버튼을 눌렀고, 두 요청이 서버에 도착했다.

치열한 자리 예약하기

상식적으로는 둘 중 한 명만 예약에 성공해야 한다.

하지만 서버의 구현 방식에 따라 두 사람 모두 예약 성공 메시지를 받을 수 있다. 좌석은 하나인데 예약자는 둘이 되는 것이다. 실제 결제까지 이어졌다면 꽤나 골치 아픈 상황이 벌어질 것이다.

이 문제를 이해하려면 운영체제의 동시성(Concurrency)부터 Java의 동기화, 데이터베이스의 트랜잭션과 락(Lock)까지 살펴볼 필요가 있다.

하나의 좌석 예약 문제를 통해 동시성 제어의 원리와 해결 방법을 정리해 보고자 한다.


1. 멀쩡해 보이는 코드가 사고를 일으킨다

먼저 아주 간단한 예약 시스템을 만들어보자.

콘서트 좌석이 하나 남아 있고, 예약 요청이 들어오면 남은 좌석 수를 확인한 뒤 1 감소시키는 구조다.

public class ConcertReservation {

    private int remainingSeats = 1;

    public boolean reserve() {
        if (remainingSeats > 0) {
            remainingSeats--;
            return true;
        }

        return false;
    }
}

코드만 보면 특별히 문제가 없어 보인다.

남은 좌석이 있다면 예약을 진행하고, 없다면 실패를 반환한다. 사실 이 정도 로직이라면 개발을 처음 배우는 사람도 어렵지 않게 작성할 수 있다.

그런데 이 코드에는 결정적인 문제가 숨어 있다.

여러 스레드가 동시에 실행할 수 있다는 사실을 고려하지 않았다는 것이다.

두 사람이 동시에 접근한다면?

하나의 ConcertReservation 객체를 두 스레드가 공유하고 있다고 가정하자.

현재 remainingSeats의 값은 1이다.

시간Thread AThread B
T1남은 좌석 확인 → 1
T2남은 좌석 확인 → 1
T3remainingSeats--
T4remainingSeats--
T5예약 성공예약 성공

Thread A가 좌석이 남아 있다는 사실을 확인한 직후, Thread B도 같은 값을 확인했다.

두 스레드 모두 예약이 가능하다고 판단한 것이다.

이후 두 스레드가 각각 좌석을 감소시키고 예약 성공을 반환한다.

심지어 remainingSeats의 값이 -1이 될 수도 있다.

좌석은 한 개인데 두 사람이 예약에 성공했고, 남은 좌석 수는 음수가 되어버렸다.

왜 이런 문제가 발생할까?

Race Condition, 실행 순서에 따라 결과가 달라지는 문제

이를 경쟁 상태(Race Condition)라고 한다.

여러 스레드가 공유 자원에 접근하면서 실행 순서에 따라 결과가 달라지는 상황을 의미한다.

여기서 주목할 것은 다음 코드다.

remainingSeats--;

우리가 보기에는 단 한 줄이지만, 실제로는 단순한 하나의 작업이 아니다.

개념적으로 다음과 같은 과정이 필요하다.

1. remainingSeats 값을 읽는다.
2. 값을 1 감소시킨다.
3. 감소한 값을 다시 저장한다.

문제는 이 세 과정이 반드시 하나의 불가분한 작업으로 실행되는 것은 아니라는 점이다.

한 스레드가 값을 읽은 직후 다른 스레드가 동일한 값을 읽을 수 있고, 서로 계산한 결과를 다시 저장하는 과정에서 충돌이 발생할 수 있다.

이처럼 더 이상 쪼개거나 중간에 끼어들 수 없는 하나의 작업처럼 실행되는 성질을 원자성(Atomicity)이라고 한다.

즉, 우리가 작성한 코드는 좌석을 확인하고 감소시키는 과정 전체의 원자성을 보장하지 못한다.

그렇다면 이 문제를 어떻게 해결할 수 있을까?


2. synchronized 하나면 해결되는 것 아닌가?

운영체제에는 임계구역(Critical Section)이라는 개념이 있다.

여러 스레드가 공유 자원에 접근할 때, 동시에 실행되면 문제가 발생할 수 있는 코드 영역을 말한다.

앞서 작성한 코드에서는 다음 부분이 임계구역에 해당한다.

if (remainingSeats > 0) {
    remainingSeats--;
}

이 부분을 한 번에 하나의 스레드만 실행할 수 있도록 만들면 문제가 해결될 것이다.

이때 등장하는 개념이 상호 배제(Mutual Exclusion)다.

흔히 Mutex라는 용어로 접하는 개념이다. 여러 실행 흐름이 동시에 임계구역에 접근하지 못하도록 제어하는 것이다.

쉽게 생각하면 화장실 문에 달린 잠금장치와 비슷하다.

누군가 화장실에 들어가 문을 잠갔다면 다른 사람은 기다려야 한다. 먼저 들어간 사람이 나와 문을 열어야 다음 사람이 들어갈 수 있다.

Java에서는 synchronized 키워드를 통해 이러한 상호 배제를 구현할 수 있다.

public class ConcertReservation {

    private int remainingSeats = 1;

    public synchronized boolean reserve() {
        if (remainingSeats > 0) {
            remainingSeats--;
            return true;
        }

        return false;
    }
}

기존 코드에 synchronized 하나만 추가했다.

이제 동일한 객체에 접근하는 두 스레드가 동시에 reserve() 메서드를 실행할 수 없게 된다.

Thread A가 먼저 메서드에 진입했다면 Thread B는 A의 작업이 끝날 때까지 기다려야 한다.

결과적으로 A만 예약에 성공하고 B는 실패한다.

Java의 synchronized는 객체의 모니터 락(Monitor Lock)을 이용해 이러한 동기화를 구현한다.

여기까지 보면 문제가 해결된 것 같다.

그런데 실제 서비스를 운영한다고 생각하면 이야기가 조금 달라진다.

만약 서버가 한 대가 아니라 두 대라면?


3. 서버가 두 대라면 이야기가 달라진다

실제 서비스는 많은 사용자의 요청을 처리하기 위해 여러 대의 서버를 운영한다.

예를 들어 콘서트 예매 서비스가 두 대의 애플리케이션 서버로 구성되어 있다고 가정하자.

로드 밸런서가 사용자 A의 요청은 Server A로, 사용자 B의 요청은 Server B로 전달했다.

두 서버의 코드에는 모두 synchronized가 적용되어 있다.

그렇다면 중복 예약을 방지할 수 있을까?

그렇지 않다.

Java의 synchronized가 사용하는 모니터 락은 JVM을 넘어 공유되지 않기 때문이다.

Server A에서 락을 획득했다고 해서 Server B까지 잠기는 것은 아니다. 각 서버는 별도의 JVM에서 서로 다른 객체와 락을 관리한다.

물론 같은 JVM에서도 서로 다른 객체의 모니터 락을 사용한다면 상호 배제가 이루어지지 않는다.

이런 환경에서 서버별로 데이터베이스의 좌석 상태를 조회한 뒤 수정한다면, 두 서버가 모두 예약 가능한 좌석이라고 판단하는 문제가 다시 발생할 수 있다.

자물쇠를 열심히 채웠는데, 알고 보니 출입문이 두 개였던 셈이다.

그렇다면 어디에서 동시성을 제어해야 할까?

여기서 시스템 구조를 다시 살펴볼 필요가 있다.

서버 A와 서버 B는 서로 다른 JVM에서 실행된다. 하지만 두 서버 모두 동일한 데이터베이스에 접근한다.

그렇다면 공유되는 데이터베이스 수준에서 좌석 상태를 제어하면 어떨까?

이를 위해서는 데이터베이스의 트랜잭션과 락을 이해해야 한다.


4. @Transactional을 붙이면 모든 것이 해결될까?

Spring을 사용해 본 개발자라면 @Transactional 어노테이션이 익숙할 것이다.

트랜잭션(Transaction)은 데이터베이스에서 수행하는 작업들을 하나의 논리적인 단위로 묶는 개념이다.

가령 좌석 예약 과정에 다음 두 작업이 있다고 가정하자.

  1. 좌석을 RESERVED 상태로 변경한다.
  2. 예약 내역을 저장한다.

좌석 상태만 변경되고 예약 내역은 저장되지 않는다면 데이터가 불일치하게 된다.

따라서 두 작업이 모두 성공하거나, 중간에 문제가 발생하면 전체 작업을 취소해야 한다.

이것이 트랜잭션의 원자성(Atomicity)이다.

데이터베이스 트랜잭션에는 흔히 ACID라고 부르는 네 가지 속성이 존재한다.

속성의미
Atomicity모두 성공하거나 모두 실패해야 한다
Consistency데이터의 무결성과 제약조건을 유지해야 한다
Isolation동시에 실행되는 트랜잭션 사이의 간섭을 제어해야 한다
Durability커밋된 결과는 장애 이후에도 보존되어야 한다

여기서 동시성과 직접적으로 관련된 속성은 격리성(Isolation)이다.

그런데 트랜잭션을 적용했다고 해서 모든 동시성 문제가 자동으로 해결되는 것은 아니다.

다음 코드를 살펴보자.

@Transactional
public void reserve(Long seatId) {

    Seat seat = seatRepository.findById(seatId)
        .orElseThrow();

    if (!seat.isAvailable()) {
        throw new IllegalStateException("이미 예약된 좌석입니다.");
    }

    seat.reserve();
}

JPA를 이용한 전형적인 예약 로직이다.

좌석을 조회한 뒤 예약이 가능하다면 상태를 변경한다. @Transactional도 적용되어 있다.

하지만 일반적인 격리 수준에서 잠금 없는 조회를 사용한다면 여전히 문제가 발생할 수 있다.

두 트랜잭션이 거의 동시에 좌석 정보를 조회했다고 가정하자.

두 트랜잭션 모두 좌석이 예약 가능하다고 판단할 수 있다.

이후 실제 UPDATE 과정에서 DB가 행 잠금을 사용하더라도, 이미 두 트랜잭션이 예약 가능하다는 판단을 내렸다는 점이 문제다.

구현 방식에 따라 두 요청 모두 성공으로 처리될 수 있다.

결국 @Transactional은 여러 작업을 하나의 트랜잭션으로 묶어주지만, 그 자체로 모든 경쟁 상태를 방지해 주지는 않는다.

중요한 것은 예약 가능 여부를 확인하는 과정과 예약 상태를 변경하는 과정을 어떻게 안전하게 제어할 것인가다.

비관적 락: 일단 잠그고 시작하자

한 가지 해결 방법은 비관적 락(Pessimistic Lock)이다.

다른 트랜잭션이 데이터를 수정할 가능성이 있다고 가정하고, 먼저 잠금을 획득한 뒤 작업하는 방식이다.

MySQL의 InnoDB에서는 SELECT ... FOR UPDATE를 사용할 수 있다.

START TRANSACTION;

SELECT status
FROM seats
WHERE id = 101
FOR UPDATE;

-- AVAILABLE 상태인 경우에만 실행
UPDATE seats
SET status = 'RESERVED'
WHERE id = 101;

COMMIT;

트랜잭션 A가 FOR UPDATE를 통해 좌석 행에 잠금을 획득하면, 트랜잭션 B가 동일한 행에 충돌하는 잠금을 요청할 때 기다리게 된다.

트랜잭션 A가 예약을 완료하고 커밋하면, 이후 트랜잭션 B는 변경된 상태를 확인할 수 있다.

이미 RESERVED 상태라면 예약을 거절하면 된다.

Spring Data JPA에서는 다음과 같이 구현할 수 있다.

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT s FROM Seat s WHERE s.id = :id")
Optional<Seat> findByIdForUpdate(@Param("id") Long id);

이 메서드를 서비스의 트랜잭션 내부에서 호출하면 된다.

다만 비관적 락에도 단점은 존재한다.

다른 트랜잭션이 잠금을 해제할 때까지 기다려야 하므로, 요청이 몰리는 환경에서는 대기 시간이 길어질 수 있다. 여러 자원을 서로 다른 순서로 잠그다가 데드락(Deadlock)이 발생할 가능성도 있다.

즉, 락을 사용한다고 해서 모든 문제가 공짜로 해결되는 것은 아니다.

낙관적 락: 충돌하면 그때 실패시키자

반대되는 접근 방식으로 낙관적 락(Optimistic Lock)이 있다.

비관적 락이 미리 잠그는 방식이라면, 낙관적 락은 데이터를 수정하는 시점에 충돌 여부를 검사한다.

대표적으로 버전 번호를 사용한다.

좌석 ID : 101
상태    : AVAILABLE
버전    : 3

두 사용자가 모두 버전 3을 조회했다고 가정하자.

사용자 A가 먼저 예약하면 버전은 4가 된다.

이후 사용자 B가 자신이 읽었던 버전 3을 기준으로 수정하려고 하면, 버전이 일치하지 않아 실패한다.

UPDATE seats
SET status = 'RESERVED',
    version = version + 1
WHERE id = 101
  AND version = 3;

수정된 행이 0개라면 다른 트랜잭션에 의해 데이터가 변경되었을 가능성이 있다.

JPA에서는 @Version을 이용해 이러한 방식을 구현할 수 있다.

그렇다면 비관적 락과 낙관적 락 중 어떤 것을 사용하는 것이 좋을까?

결론부터 말하면 상황에 따라 다르다.

구분비관적 락낙관적 락
방식먼저 잠금 획득수정 시 충돌 검사
충돌 시대기 또는 오류변경 실패 감지
장점충돌이 잦은 상황에서 처리 흐름이 명확잠금 대기를 줄일 수 있음
단점대기 시간, 데드락 가능성충돌 후 실패 및 재시도 처리 필요
적합한 환경충돌이 상대적으로 빈번한 경우충돌이 상대적으로 드문 경우

하지만 우리가 해결하려는 문제는 생각보다 단순하다.

좌석 하나의 상태를 AVAILABLE에서 RESERVED로 변경하는 것이다.

그런데 정말 이렇게까지 복잡하게 구현할 필요가 있을까?


5. 결국 가장 간단한 해결책은 데이터베이스에 있었다

처음 작성했던 예약 로직을 다시 생각해 보자.

1. 좌석을 조회한다.
2. 예약 가능한지 확인한다.
3. 예약 상태로 변경한다.

여기서 문제가 되는 부분은 2번과 3번이 분리되어 있다는 것이다.

좌석이 예약 가능한지 확인한 뒤 실제 변경하기까지의 짧은 순간에 다른 요청이 끼어들 수 있다.

그렇다면 두 작업을 하나로 합치면 어떨까?

UPDATE seats
SET status = 'RESERVED'
WHERE id = 101
  AND status = 'AVAILABLE';

단 한 줄의 SQL이다.

별도로 조회해서 판단할 필요 없이, 좌석 상태가 AVAILABLE일 때만 RESERVED로 변경하도록 조건을 지정했다.

이것이 조건부 UPDATE(Conditional UPDATE) 방식이다.

MySQL InnoDB는 UPDATE 과정에서 필요한 잠금을 관리한다. 동일한 좌석에 대해 두 트랜잭션이 동시에 UPDATE를 실행한다면, 한 트랜잭션이 변경하는 동안 다른 트랜잭션은 충돌하는 잠금이 해제되기를 기다린다.

첫 번째 트랜잭션이 커밋한 뒤에는 좌석이 이미 RESERVED 상태이므로 두 번째 트랜잭션의 조건을 만족하지 못한다.

결과적으로 하나의 요청만 성공한다.

성공 여부는 어떻게 판단할까?

UPDATE로 변경된 행의 수를 확인하면 된다.

MySQL에서는 다음과 같이 확인할 수 있다.

SELECT ROW_COUNT();

결과가 1이라면 예약에 성공한 것이다.

반대로 0이라면 조건에 부합하는 좌석이 없어 예약에 실패한 것이다.

예시에서는 유효한 좌석 ID가 존재하고, AVAILABLE 상태를 RESERVED로 변경하는 경우를 전제로 한다.

실제 Spring 코드로 구현한다면?

JdbcTemplate을 이용하면 다음과 같이 작성할 수 있다.

@Service
@RequiredArgsConstructor
public class ReservationService {

    private final JdbcTemplate jdbcTemplate;

    @Transactional
    public void reserve(long seatId, long userId) {

        int updated = jdbcTemplate.update("""
            UPDATE seats
            SET status = 'RESERVED'
            WHERE id = ?
              AND status = 'AVAILABLE'
            """, seatId);

        if (updated != 1) {
            throw new IllegalStateException(
                "이미 예약된 좌석입니다."
            );
        }

        jdbcTemplate.update("""
            INSERT INTO reservations (seat_id, user_id)
            VALUES (?, ?)
            """, seatId, userId);
    }
}

이 코드에서는 두 가지를 보장하려고 한다.

첫째, 조건부 UPDATE를 통해 좌석 상태를 변경하는 요청은 하나만 성공하도록 만든다.

둘째, @Transactional을 통해 좌석 상태 변경과 예약 내역 저장을 하나의 작업으로 묶는다.

예약 내역을 저장하는 과정에서 예외가 발생한다면 좌석 상태 변경도 함께 롤백된다. 위 코드에서 사용하는 IllegalStateException과 일반적인 DB 접근 예외는 Spring의 기본 트랜잭션 롤백 대상에 해당한다.

여기에 예약 테이블의 seat_id에 UNIQUE 제약조건까지 설정한다면 DB 차원에서 하나의 좌석에 예약 내역이 중복 저장되는 것을 추가로 방지할 수 있다.

단, 좌석당 예약 기록이 하나인 단순한 데이터 모델을 전제로 한다. 취소 및 재예약 이력을 보존해야 하는 시스템이라면 활성 예약과 예약 이력을 구분하여 모델링할 필요가 있다.

결국 중요한 것은 단순히 락을 거는 행위가 아니다.

비즈니스 규칙이 데이터베이스에서 깨지지 않도록 만드는 것이다.

직접 확인해 보자

개념만으로 이해하기 어렵다면 MySQL에서 직접 확인할 수 있다.

SQL 클라이언트 창을 두 개 열어 각각 독립된 연결을 만든다.

먼저 다음 테이블을 생성한다.

CREATE TABLE seats (
    id BIGINT PRIMARY KEY,
    status VARCHAR(20) NOT NULL
) ENGINE = InnoDB;

INSERT INTO seats VALUES (101, 'AVAILABLE');

첫 번째 창에서 트랜잭션을 시작하고 UPDATE를 실행한다.

세션 A

START TRANSACTION;

UPDATE seats
SET status = 'RESERVED'
WHERE id = 101
  AND status = 'AVAILABLE';

SELECT ROW_COUNT();
-- 결과: 1

이때 일부러 COMMIT을 실행하지 않는다.

두 번째 창에서도 동일한 SQL을 실행한다.

세션 B

START TRANSACTION;

UPDATE seats
SET status = 'RESERVED'
WHERE id = 101
  AND status = 'AVAILABLE';

재미있는 현상을 확인할 수 있다.

세션 B의 쿼리가 바로 끝나지 않고 기다리는 것이다.

세션 A가 해당 행을 수정한 뒤 아직 트랜잭션을 종료하지 않았기 때문이다.

이제 세션 A에서 커밋해 보자.

세션 A

COMMIT;

세션 B의 쿼리가 완료되면 다음 명령을 실행한다.

세션 B

SELECT ROW_COUNT();
-- 결과: 0

ROLLBACK;

세션 B에서는 아무런 행도 수정하지 못했다.

이미 세션 A가 좌석을 예약했기 때문이다.

단, 잠금을 기다리는 시간이 제한시간을 초과한다면 0이 반환되기 전에 잠금 대기 오류가 발생할 수 있다.

이 실험으로 확인할 수 있는 사실은 명확하다.

같은 좌석에 대해 여러 요청이 동시에 들어오더라도, 데이터베이스의 조건부 UPDATE와 잠금 제어를 이용해 하나의 요청만 성공하도록 만들 수 있다.


6. 동시성 문제의 본질은 어디에서 제어할 것인가에 있다

처음으로 돌아가 보자.

마지막 남은 콘서트 좌석 하나를 두 명이 동시에 예약하면 어떻게 될까?

단순히 Java 코드만 작성하면 두 요청 모두 성공할 수 있다.

이를 막기 위해 synchronized를 적용할 수 있지만, 여러 JVM에 걸친 시스템에서는 그 방법만으로 충분하지 않다.

그래서 데이터베이스의 트랜잭션과 락을 살펴봤고, 최종적으로는 조건부 UPDATE라는 간단한 해결책에 도달했다.

여기서 주목할 것은 어떤 기술이 가장 뛰어난가가 아니다.

해결 방법보장 범위와 특징
synchronized동일 JVM의 동일 모니터를 사용하는 스레드 간 상호 배제
비관적 락DB 데이터를 먼저 잠그고 수정
낙관적 락버전 등을 통해 동시 수정 충돌 감지
조건부 UPDATE단일 SQL로 조건 검사와 상태 변경
UNIQUE 제약조건중복 데이터를 DB 수준에서 차단

Synchronized가 나쁜 기술이라는 의미가 아니다. 비관적 락이 무조건 느리다는 의미도 아니다.

각 기술은 서로 다른 문제를 해결하기 위해 존재하고, 각자 적합한 환경이 있다.

사용자 수가 적고 단일 JVM에서 공유하는 메모리 자원을 보호해야 한다면 synchronized만으로도 충분할 수 있다.

동시 수정 충돌이 빈번한 데이터라면 비관적 락을 고려할 수 있고, 충돌이 드물다면 낙관적 락이 더 적합할 수도 있다.

우리의 예시처럼 단순한 좌석 상태 변경이라면 조건부 UPDATE가 효과적인 해결책이 될 수 있다.

그러나 실제 콘서트 예매 시스템은 여기서 끝나지 않는다.

결제 승인과 예약 확정 사이에 시간이 걸릴 수도 있고, 사용자가 결제를 취소하거나 네트워크 장애로 동일한 요청을 여러 번 보낼 수도 있다. 예약에 성공한 사용자가 결제하지 않고 좌석을 무한정 점유하는 문제도 해결해야 한다.

이 경우에는 임시 좌석 점유, 만료 시간, 결제 처리의 멱등성(Idempotency)까지 고려해야 한다.

결국 하나의 문제를 해결하면 새로운 문제가 등장한다. 그리고 그 문제를 해결하기 위해 또 다른 기술과 설계가 필요해진다.

기술의 세계에는 모든 상황에 적용할 수 있는 만능 해결책(Silver Bullet)이 없다.

중요한 것은 동시성을 제어하기 위해 어떤 기술을 사용했는가가 아니라, 무엇을 보호해야 하며 어디에서 그 규칙을 보장할 것인지 판단하는 능력이다.

콘서트 예매 시스템에서 절대로 깨져서는 안 되는 규칙은 단순하다.

“하나의 좌석은 동시에 두 사람에게 판매될 수 없다.”

개발자의 역할은 수만 개의 요청이 동시에 들어오는 상황에서도 이 규칙이 깨지지 않도록 시스템을 설계하는 것이다.

그것이 동시성 제어의 본질이라고 생각한다.

생각해 볼 문제

Q1. Java의 synchronized로 예약 시스템을 구현했다. 서버가 3대로 늘어나면 어떤 문제가 발생할까?

Q2. @Transactional을 적용한 예약 메서드에서 두 사용자가 동시에 같은 좌석을 조회했다. 트랜잭션을 사용했는데도 중복 예약이 발생할 수 있는 이유는 무엇일까?

Q3. 좌석 예약에 성공한 사용자가 결제하지 않고 10분 동안 기다린 뒤 이탈했다. 이 좌석을 다른 사용자에게 다시 판매하려면 어떤 설계가 필요할까?


참고 자료

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

Exit mobile version