태어나서 지금까지 티켓팅이라는 시스템을 이용해본건 세 번 정도 인데 한 번을 성공 한 적이 없다..
우선 티켓팅을 해보면서 궁금한 점이 두가지 생겼다.
- 대기 순번이 줄어들수록 서버 접속자가 계속 늘어나는 것인가?
- 동시에 좌석을 선택하고 결제를 할 때 어떻게 최종적으로 한사람만 성공하는것인지?
기본적으로 웹 흐름에 대해 알고 있어서 간단한 프로젝트는 만들 수 있기 때문에, 백엔드를 더 깊이 체험해보고 싶었는데 예전부터 만들어 보고 싶었던 티켓팅 시스템을 만들기로 했다.
이 토이 프로젝트를 진행하면서 단순히 기능 구현에 그치지 않고, 실제 서비스에서 발생할 수 있는 문제를 고민하고 해결하는 경험을 목표로 삼았다.
아래와 같은 개념과 기술들을 단순히 알아 내는 것이 아니라, 직접 적용해보면서 내 것으로 만드는 것에 집중하고 싶다.
1. 동시성 제어
-> 여러 사용자가 동시에 하나의 좌석을 선택하는 상황
-> 낙관적 락 VS 비관적 락
-> 트랜잭션
-> (확장) Redis 기반 분산 락
2. REST API
-> 지금까지 나는 RESTful 한 api를 설계하지 않았던것 같다 그 이유는 url 안에 동사들이 많이 들어가 있었고 상태 코드와 에러 처리를 제대로 하지 않았다. 이 기회에 RESTful 하게 설계 하는 것을 목표로 한다.
3. 데이터베이스 설계 및 최적화
-> 테이블 관계 설계
-> 인덱스 활용(중요)
-> N + 1 문제 해결
- 처음엔 H2로 간단한 CRUD만을 구현하고 후에 더 큰 DB로 교체하는 것으로 하기로 했다.
- 첫 의존성은 JPA, H2, Spring web 세가지만을 추가하고 필요할때 찾아서 쓰는 방법으로 진행했다.
- 속도가 너무 늦어지지 않게 하기 위해 프론트엔드 UI을 따로 만들지 않을 것이고 로그인 기능 없이 비로그인으로 진행할 계획이다.
Guest(게스트), Event(행사), Reservation(예약), Seat(좌석), Ticket(티켓) 엔티티를 만들어 주고 연관관계를 설정해줬다.
Reservation(1) : Ticket(N) 관계
Reservation(N) : Guest(1) 관계
Seat(N) : Event(1) 관계
Seat(1) : Ticket(N) 관계
Seat과 Ticket의 관계가 헷갈렸었는데 보통 하나의 티켓은 하나의 좌석으로 1 : 1 관계로 생각했었다. 하지만 만약 티켓이 취소가 된다면? 이라는 상황을 생각해보니까 여러개의 티켓이 하나의 좌석을 가리킬 수 있겠다고 생각했다. 그래서 Reservation과 Guest의 관계도 취소의 상황을 생각해서 N : 1 관계로 생각을 했다.
H2 DB를 사용하는데 인메모리 방식을 사용해서 서버를 매번 실행할 때마다 비워진 DB를 사용하면서 간단한 C,R 만 확인 한 뒤에 파일 형식으로 교체하면서 생성된 이벤트, 좌석으로 예약까지 하게되었다.
-> 파일 형식으로 진행하면 서버가 재실행 되어도 DB의 데이터는 그대로 남게 된다.
아이유 콘서트의 좌석들 조회
[
{
"id": 10,
"seatNumber": "A1",
"status": "AVAILABLE",
"event": {
"id": 1,
"title": "아이유 콘서트",
"eventTime": "2026-04-10T19:00:00",
"venue": "올림픽공원 체조경기장"
}
},
{
"id": 11,
"seatNumber": "A2",
"status": "AVAILABLE",
"event": {
"id": 1,
"title": "아이유 콘서트",
"eventTime": "2026-04-10T19:00:00",
"venue": "올림픽공원 체조경기장"
}
},
{
"id": 12,
"seatNumber": "A3",
"status": "AVAILABLE",
"event": {
"id": 1,
"title": "아이유 콘서트",
"eventTime": "2026-04-10T19:00:00",
"venue": "올림픽공원 체조경기장"
}
}
]
조회했을 때 콘솔에 찍힌 쿼리문

postman에서의 응답데이터를 볼 때, event가 각각 있지만 콘솔에서는 event를 조회하는 쿼리가 하나인 이유 -> JPA 영속성 컨텍스트에 관리되는 객체이기 때문이다. 만약에 좌석마다 event id가 다르다면 event를 조회하는 쿼리가 3개가 찍힐것이다.
동시성 문제는 기본적인 기능들을 구현한 다음에 추가할 계획이라서 우선 동시성 제어가 없는 예약기능을 구현했다.
실제 코드
@Override
@Transactional
public ResponseReservation createReservation(ReservationRequest request) {
// 예약을 할 때 좌석의 상태가 이미 예약된 상태인지 확인을 해서 AVAILAVLE 상태면 예약 처리후 RESERVED로 상태 변경
// 좌석을 조회할 때 for문으로 조회하면 좌석 개수만큼 DB조회를 하기 때문에 List 조회 방식으로 DB에 한번만 조회하기
List<Seat> seats = seatRepository.findAllById(request.getSeatIds()); // -> IN 사용
// 상태 검증
// anyMatch => 하나라도 맞으면 즉시 true 반환
// allMatch => 모든 조건이 맞아야 true
// 예외 처리
if(seats.stream().anyMatch(seat -> seat.getStatus() == SeatStatus.RESERVED)){
throw new AlreadyReservedException();
}
// 예약 처리
// Transactional 어노테이션을 쓰면 메소드 종료 시점에 더티 체킹으로 DB에 반영
seats.forEach(seat -> seat.setStatus(SeatStatus.RESERVED));
// 예약 객체 생성
Guest guest = guestRepository.findById(request.getUserId()).orElseThrow(() -> new RuntimeException("사용자 없음"));
Reservation reservation = Reservation.builder()
.status(ReservationStatus.CONFIRMED)
.createdAt(LocalDateTime.now())
.guest(guest)
.tickets(new ArrayList<>())
.build();
// 티켓 생성
for(Seat seat : seats){
Ticket ticket = Ticket.builder()
.issuedAt(LocalDateTime.now())
.reservation(reservation)
.seat(seat)
.build();
reservation.getTickets().add(ticket);
}
Reservation savedReservation = reservationRepository.save(reservation);
return ResponseReservation.builder()
.id(savedReservation.getId())
.status(savedReservation.getStatus())
.createdAt(savedReservation.getCreatedAt())
.userId(savedReservation.getGuest().getId())
.tickets(savedReservation.getTickets().stream().map(ticket -> TicketDto.builder()
.id(ticket.getId())
.issuedAt(ticket.getIssuedAt())
.seatId(ticket.getSeat().getId()).build()).collect(Collectors.toList()))
.build();
}
N + 1 문제 해결 -> 좌석 조회시 각각 조회 X, IN 연산자 한번의 조회 O
1. 응답데이터의 무한 참조

DB에 데이터가 생성은 되었지만 응답 데이터를 확인 했을 때, 예약의 티켓 정보, 티켓 정보의 예약 정보... 이런식으로 계속 무한으로 쌓였다.
두 엔티티의 관계가 양방향 관계라서 서로를 무한하게 참조하고 있으므로 위와 같이 나타났다.
-> 보통 요청받을 때랑, 응답 보낼 때 DTO 객체를 사용해서 보낸다고 한다.
해결책 - DTO 객체
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
@Builder
public class ResponseReservation {
private Long id;
private ReservationStatus status;
private LocalDateTime createdAt;
private List<TicketDto> tickets;
private Long userId;
}
2. Ticket ID의 NULL 값

무한 참조의 문제를 해결 하고 나서 티켓의 ID 값이 들어오지 않았고, NULL값만을 응답 했다. 왜 ID 값만 들어오지 않았고, issuedAt과 seatID의 값만 들어왔을까?
그 이유는 ID값은 DB가 넣어주는 것이고 나머지 값들은 내가 직접 넣었기 때문이다. 그런데 왜 ID값이 불러와 지지 않은 것일까?
실제 코드를 봤을 때 Ticket 객체를 만들었지만 생성하는 부분이 존재하지 않는다.
매번 save를 해도 되지만 Reservation에서의 관계 설정으로 부모만 저장해도 자식들이 줄줄이 저장되는 방식이 있다.
해결책 - CascadeType.ALL
Reservation 엔티티 코드 일부분
@OneToMany(mappedBy = "reservation", cascade = CascadeType.ALL)
private List<Ticket> tickets;
생성 : 예약을 저장하면 티켓도 저장
수정 : 예약 정보가 바뀌어 업데이트 되면 티켓 정보도 같이 업데이트
삭제 : 예약을 삭제하면 그 안에 담긴 티켓들도 DB에서 삭제


동시성 제어 처리를 하지 않은 코드로 100명이 동시에 예약을 하도록 테스트 코드 작성을 해보겠다.
동시성 처리 테스트를 위한 3가지 핵심 요소
1. ExecutorService
2. CountDownLatch
3. AtomicInteger
ExecutorService
여러 개의 쓰레드를 만들고 관리하며, 작업을 할당한다.
ExecutorService executorService = Executors.newFixedThreadPool(10);ㄴ> 10개의 고정 쓰레드를 만들어서 작업을 할당해준다.
ExecutorService.submit()ㄴ> 비동기 방식으로 해당 코드가 실행 되기도 전에 다음 차례를 실행
CountDownLatch
모든 쓰레드가 끝날 때까지 다음 단계로 넘어가지 못하게 붙잡아 둔다.
CountDownLatch latch = new CountDownLatch(100);ㄴ> 100개의 작업 완료가 될 때까지 메인 스레드를 멈춰 세운다.
CountDownLatch.countDown()ㄴ> 카운트 변수를 감소시킨다.
CountDownLatch.await()ㄴ> 카운트가 0이 될 때까지 기다린다.
AtomicInteger
멀티 스레드 환경에서 안전하게 사용하는 Integer 변수
AtomicInteger.incrementAndGet()ㄴ> 값을 안전하게 변경하고 새로운 값을 반환
AtomicInteger.get()ㄴ> 최신 값을 즉시 읽어온다.
@Test
@DisplayName("100명이 동시에 예약을 했을 때 한명만 성공해야 한다.")
void concurrentReservationTest() throws InterruptedException {
// 100번의 예약
int reservationCount = 100;
// 10개의 고정된 스레드를 이용해서 100개의 일을 처리하겠다.
ExecutorService executorService = Executors.newFixedThreadPool(10); // 10개의 고정 스레드
CountDownLatch latch = new CountDownLatch(reservationCount); // 100개가 들어와야 종료 -> 이걸 쓰지 않으면 1개가 실패될 때 바로 테스트 실패로 끝남
// 일반적인 Integer 와의 차이점 -> 일반 Integer는 멀티스레드 환경에서 숫자가 새나간다.
// AtomicInteger를 사용하면 값을 계속 확인하기 때문에 새나갈수 없게한다.
AtomicInteger successCount = new AtomicInteger();
AtomicInteger failCount = new AtomicInteger();
// 예약할때 필요한 객체 DTO 생성
ReservationRequest request = ReservationRequest.builder()
.userId(guestId)
.eventId(eventId)
.seatIds(seatIds)
.build();
for(int i = 0; i < reservationCount; i++){
executorService.submit(() -> {
try{
reservationService.createReservation(request);
successCount.incrementAndGet();
}catch(Exception e){
failCount.incrementAndGet();
// 첫 실패에 대한 이유를 보기
if(failCount.get() == 1){
System.out.println("첫 번째 실패 원인 : " + e.getMessage());
}
}finally{
// 100 개를 카운트 다운
latch.countDown();
}
});
}
// 100개가 다 들어올 때까지 기다리기
latch.await();
// 테스트가 여러개면, 테스트가 하나 끝날때마다 10개씩 쌓이는데, 이걸 해제해주는 메서드
executorService.shutdown();
System.out.println("성공한 횟수 : " + successCount.get());
System.out.println("실패한 횟수 : " + failCount.get());
Assertions.assertThat(successCount.get()).isEqualTo(1);
}
테스트 코드를 실행 하기 전에 테스트 메서드가 실행되기 직전에 실행되는 @BeforeEach 어노테이션을 사용해서 객체의 생성과 추가를 해주었고, 테스트 메서드가 실행되고 바로 실행되는 @AfterEach 어노테이션을 사용해서 DB를 비워주는 메서드를 생성했다.
@BeforeEach
void setUp(){
// 1. 게스트 생성
Guest guest = Guest.builder()
.name("테스터")
.email("test@test.com")
.phone("01012121212")
.build();
// 2. 이벤트 생성
Event event = Event.builder()
.title("테스트_이벤트")
.eventTime(LocalDateTime.now())
.venue("테스트장소")
.build();
// 3. 좌석 생성
Seat seat1 = Seat.builder()
.seatNumber("A1")
.status(SeatStatus.AVAILABLE)
.event(event)
.build();
Seat seat2 = Seat.builder()
.seatNumber("A2")
.status(SeatStatus.AVAILABLE)
.event(event)
.build();
guestRepository.save(guest);
eventRepository.save(event);
seatRepository.save(seat1);
seatRepository.save(seat2);
this.guestId = guest.getId();
this.eventId = event.getId();
this.seatIds = List.of(seat1.getId(), seat2.getId());
}
@AfterEach
void cleanDB() {
reservationRepository.deleteAll();
seatRepository.deleteAll();
eventRepository.deleteAll();
guestRepository.deleteAll();
}

우선 테스트 실패
같은 좌석의 예약을 10개나 성공을 했다.
한 명의 사용자만 성공을 해야하고 나머지는 다 실패를 해야하기 때문에
성공한 횟수 : 1, 실패한 횟수 : 99가 뜨는게 정상이다.
말 그대로 비관적 락은 '누군가 반드시 내 데이터를 건드릴 거라고' 비관적으로 생각하는 것이고, 낙관적 락은 '설마 동시에 건드리겠어?' 라고 낙관적으로 생각하는 방식이다.
비관적 락
데이터를 읽는 순간부터 락을 걸어서 나의 수정이 끝날때까지 다른 사람들은 아무도 그 데이터를 읽거나, 쓸 수 없게 막는 방식이다.
장점은 데이터 정합성이 완벽하게 보장되지만, 다른 사람들이 기다려야 하므로 성능이 떨어질 수 있다.
낙관적 락
데이터를 읽을 때도 락을 걸지 않고, 버전이라는 번호표를 활용한다. 수정할 때 내가 처음에 봤던 번호표가 맞는지 확인을 하고 맞으면 수정, 틀리면 실패 처리를 한다.
장점은 실제로 락을 걸지 않아서 성능이 빠르지만, 충돌이 일어났을 때 개발자가 직접 재시도 로직을 짜줘야 한다.
지금 진행중인 티켓팅 시스템에서는 낙관적 락 보다 비관적 락이 적합하다.
그 이유는 수천 명이 달려드는 상황이고, 낙관적 락을 쓰면 예를들어 100명 중 99명이 결제 단계까지 가서 이미 예약됐다는 사실을 알게될 것이지만, 비관적 락을 쓰면, 한명만 들어가서 결제 단계를 끝내기 때문에 충돌이 일어나지 않는다.
SeatRepository에서 추가 메서드를 작성한다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Seat s where s.id in :ids")
List<Seat> findAllByIdInWithLock(@Param("ids") List<Long> ids);
PESSIMISTIC_WRITE
사용자들은 좌석 목록을 봐야하기 때문에 해당 좌석을 예약하려고 할 때만 락을 걸어둔다.
그리고 실제 예약하는 로직에서 사용하는 메서드 교체
List<Seat> seats = seatRepository.findAllByIdInWithLock(request.getSeatIds());

현재 이 프로젝트에서는 결제 단계에서 사용자가 잠수를 타도 쫓겨나는 부분이 존재하지 않아서 무한정으로 잠수를 타버리면 뒷사람은 결제 단계까지 가지 못한다.
타임 아웃이라는 결제를 할 수 있는 사람의 자격을 뺏는? 내보내는? 기능까지 있어야 완벽한 티켓팅이라고 할 수 있겠지만, 나의 궁금증은 해결이 되었기 때문에, 여기서 마무리를 할 것이다.
아직 모르는 객체, 어노테이션들도 내가 아는것들과 비교도 할 수 없겠지만, 조금이라도 더 알게된 것 같아서 좋은 경험을 한 것 같다.
티켓팅 시스템의 원리를 알게되면 나중에 티켓팅 할 때 좀 더 유리하지 않을까 생각했는데 공부 끝에 마주한 결론은 결국 빠른 사람이 임자라고 다시 한번 느껴졌다.
예전에는 좌석 선택을 눌렀을 때 로딩중이라면 기대가 됐었는데, 이제는 그 로딩창의 의미를 알게되어서 포기할 것 같다. 그 의미는 이미 내 앞사람이 결제 자격을 갖고 결제를 진행중이라는 신호이니까...
그래도 직접 해보고 테스트까지 해보니 재밌었고 다음에 또 궁금한 시스템이 있으면 직접 해보면서 깊이 이해하고 싶다.