실시간 티켓 예매 시스템은 좌석 중복 예매 문제를 중심으로 동시성 제어 전략을 구현하고 검증한 티켓 예매 백엔드 프로젝트입니다.
💡 핵심 가치
┌─────────────────────────────────────────────────────────────┐
│ ✅ 동시 요청 환경에서 좌석 중복 예매 방지 │
│ ✅ Redis 분산 락을 통한 다중 인스턴스 환경 대비 │
│ ✅ JWT 자동 갱신으로 끊김없는 사용자 경험 │
│ ✅ 2단계 예약 프로세스 (HOLD → CONFIRM) │
│ ✅ Docker Compose 기반 실행 환경 │
└─────────────────────────────────────────────────────────────┘
- ⚡ 동시 요청 처리: Redisson 분산 락을 통한 무결성 보장
- 🔐 보안: JWT 기반 무상태 인증 + Refresh Token 자동 갱신
- 🚀 확장성: Redis를 활용한 분산 락 전략 검증
- 📦 실행 환경: Docker Compose 기반 MySQL/Redis/App 구성
현대의 티켓 예매 시스템은 다음과 같은 도전 과제를 가지고 있습니다:
문제 상황:
👤 사용자 A: 좌석 A-1 선택 (동시에)
👤 사용자 B: 좌석 A-1 선택 (동시에)
❌ 결과: 같은 좌석이 두 사람에게 배정될 수 있음
해결 방법:
✅ Redisson 분산 락으로 원자성 보장
✅ DB 조건부 update와 Redis 분산 락 두 가지 전략 구현 및 비교
문제 상황:
👤 사용자가 티켓 선택 중 Access Token 만료 (1시간)
❌ 결과: 401 Unauthorized → 다시 로그인 필요
해결 방법:
✅ Refresh Token을 활용한 자동 토큰 갱신
✅ JwtFilter에서 투명하게 처리 (사용자 인지 불필요)
✅ 응답 헤더에 X-New-Access-Token 반환
문제 상황:
📈 트래픽 증가 시 단일 서버로는 한계
❌ 세션 기반 인증은 수평 확장 어려움
해결 방법:
✅ JWT 무상태 인증
✅ Redis를 통한 분산 상태 관리
✅ Docker Compose 기반 컨테이너 오케스트레이션
// 로그인 시 두 종류의 토큰 발급
Access Token (1시간) → API 호출 시 사용
Refresh Token (7일) → Access Token 갱신용 (Redis 저장)# 클라이언트 요청
GET /api/shows
Authorization: Bearer {expiredAccessToken}
X-Refresh-Token: {refreshToken}
# 서버 응답 (자동 갱신)
HTTP/1.1 200 OK
X-New-Access-Token: {newAccessToken}
{
"data": [...공연 목록...]
}구현 하이라이트: JwtFilter.java:45-66
// Access token이 만료되었지만 서명은 유효한 경우
else if (jwtUtil.isTokenExpired(token) && jwtUtil.isTokenSignatureValid(token)) {
String refreshToken = resolveRefreshToken(request);
if (StringUtils.hasText(refreshToken)) {
try {
// 새 access token 발급
String newAccessToken = tokenRefreshService.refreshAccessToken(token, refreshToken);
// 응답 헤더에 새 토큰 추가
response.setHeader(NEW_ACCESS_TOKEN_HEADER, newAccessToken);
// SecurityContext에 인증 정보 설정
Authentication auth = tokenRefreshService.getAuthenticationFromToken(newAccessToken);
SecurityContextHolder.getContext().setAuthentication(auth);
log.info("Token refreshed for user: {} on request: {}",
jwtUtil.getUsername(token), request.getRequestURI());
} catch (Exception e) {
log.warn("Token refresh failed: {}", e.getMessage());
}
}
}- ROLE_USER: 공연 조회, 예매, 내 예매 조회
- ROLE_ADMIN: 공연 등록/삭제, 일정 등록, 사용자 관리
// ShowSchedule.java - 일정 생성 시 50개 좌석 자동 생성
public List<ShowSeat> generateSeats() {
List<ShowSeat> seats = new ArrayList<>();
for (char row = 'A'; row <= 'E'; row++) {
for (int number = 1; number <= 10; number++) {
seats.add(ShowSeat.builder()
.showSchedule(this)
.seatNumber(row + String.valueOf(number)) // A1 ~ E10
.price(50000L)
.status(SeatStatus.AVAILABLE)
.build());
}
}
return seats;
}좌석 배치: 5행 × 10열 = 50석
A1 A2 A3 A4 A5 A6 A7 A8 A9 A10
B1 B2 B3 B4 B5 B6 B7 B8 B9 B10
C1 C2 C3 C4 C5 C6 C7 C8 C9 C10
D1 D2 D3 D4 D5 D6 D7 D8 D9 D10
E1 E2 E3 E4 E5 E6 E7 E8 E9 E10
엔드포인트:
POST /api/tickets/db/reserve- 좌석 HOLDPOST /api/tickets/db/confirm- 예매 확정
구현: ShowSeatRepositoryCustomImpl.java
return queryFactory
.update(seat)
.set(seat.status, SeatStatus.HOLD)
.set(seat.holdUserId, userId)
.set(seat.holdStartTime, now)
.where(seat.id.eq(seatId).and(seat.status.eq(SeatStatus.AVAILABLE)))
.execute();특징:
- ✅ 구현이 단순함
- ✅ 트랜잭션 관리 용이
- ✅
AVAILABLE상태인 좌석만 갱신해 중복 HOLD 방지 - ❌ 다중 인스턴스 환경의 임시 점유 TTL 관리는 Redis 전략보다 제한적
엔드포인트:
POST /api/tickets/redis/reserve- 좌석 HOLD (분산 락)POST /api/tickets/redis/confirm- 예매 확정 (Redis 검증)
구현: ReservationService.java
public ReservationResponse.Reserve reserveSeat(ReservationRequest.Reserve request, Long userId) {
Long seatId = request.getSeatId();
String lockKey = "lock:seat:" + seatId;
// Redisson 분산 락 획득 (최대 3초 대기)
RLock lock = redissonClient.getLock(lockKey);
try {
boolean isLocked = lock.tryLock(3, TimeUnit.SECONDS);
if (!isLocked) {
throw new SeatAlreadyTakenException();
}
// 좌석 상태 확인
ShowSeat seat = showSeatRepository.findById(seatId)
.orElseThrow(() -> new IllegalArgumentException("좌석을 찾을 수 없습니다."));
if (seat.getStatus() != SeatStatus.AVAILABLE) {
throw new SeatAlreadyTakenException();
}
// Redis에 좌석 점유 정보 저장 (5분 TTL)
redisService.holdSeat(request, userId);
// 데이터베이스 상태 업데이트
seat.hold(userId);
showSeatRepository.save(seat);
return ReservationResponse.Reserve.from(seat);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}Redis Key 구조:
seat:{seatId} → {userId} (TTL: 5분)
lock:seat:{seatId} (Redisson 분산 락)
특징:
- ✅ 다중 인스턴스 환경에서 안전
- ✅ 자동 TTL로 데드락 방지
- ✅ 데이터베이스 부하 감소
- ✅ 수평 확장 가능
┌──────────┐ ┌──────────┐ ┌──────────┐
│ AVAILABLE│ ────▶ │ HOLD │ ────▶ │ RESERVED │
│ (대기중) │ │ (임시) │ │ (확정) │
└──────────┘ └──────────┘ └──────────┘
(5분 제한) (Booking 생성)
1단계: HOLD (임시 점유)
- 사용자가 좌석 선택 시 HOLD 상태로 변경
- Redis에
userId와 함께 저장 (5분 TTL) - 다른 사용자는 선택 불가
2단계: CONFIRM (확정)
- 사용자가 결제 완료 후 CONFIRM
- Redis에 저장된
userId확인 (본인 검증) - 좌석 상태 RESERVED 변경
Booking엔티티 생성
자동 만료:
- Redis TTL (5분) 만료 시 자동 해제
- 다른 사용자가 다시 선택 가능
- Java 17 - 최신 LTS 버전
- Spring Boot 3.5.7 - 엔터프라이즈 프레임워크
- Spring Security - 인증/인가
- Spring Data JPA - ORM
- QueryDSL 5.0.0 - 타입 안전 쿼리
- MySQL 8.0.37 - 메인 데이터베이스
- Redis 7.4.2 - 캐시 & 분산 락 & Refresh Token 저장
- Redisson 3.33.0 - Redis 분산 락 클라이언트
- JWT (jjwt 0.11.5) - 토큰 기반 인증
- BCrypt - 비밀번호 암호화
- HS512 - JWT 서명 알고리즘
- Docker & Docker Compose - 컨테이너 오케스트레이션
- Swagger/OpenAPI 3 - API 문서 자동 생성
- Lombok - 보일러플레이트 코드 제거
- Gradle 8.x - 빌드 도구
- JUnit 5 - 단위 테스트
- MockMvc - 통합 테스트
- ExecutorService - 동시성 테스트
┌─────────────────────────────────────────────────────────────────┐
│ Client Layer │
│ (Web/Mobile) - JWT Access Token + Refresh Token in Headers │
└────────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Spring Boot Application │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Controller │ │ Service │ │ Repository │ │
│ │ Layer │─▶│ Layer │─▶│ Layer │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │ │ │ │
│ │ ▼ ▼ │
│ ┌──────▼────────┐ ┌─────────────────────────────┐ │
│ │ JwtFilter │ │ TokenRefreshService │ │
│ │ (자동 갱신) │ │ (토큰 검증 & 발급) │ │
│ └───────────────┘ └─────────────────────────────┘ │
└────────────────────────────┬────────────────┬───────────────────┘
│ │
┌────────────▼────┐ ┌─────▼──────┐
│ MySQL 8.0 │ │ Redis 7.4 │
│ │ │ │
│ • User │ │ • Locks │
│ • Show │ │ • Tokens │
│ • Reservation │ │ • Seat Hold│
└─────────────────┘ └────────────┘
ticketReservationProject/
├── 📦 config/ # 설정
│ ├── RedissonConfig.java # Redisson 분산 락 설정
│ ├── QuerydslConfig.java # QueryDSL 설정
│ └── SwaggerConfig.java # Swagger 설정
│
├── 📦 security/ # 보안
│ ├── JwtUtil.java # JWT 생성/검증
│ ├── JwtFilter.java # JWT 필터 (자동 갱신 포함)
│ ├── TokenRefreshService.java # 토큰 갱신 서비스
│ ├── RefreshToken.java # Refresh Token 엔티티 (Redis)
│ ├── CustomUserDetails.java # UserDetails 구현체
│ └── SecurityConfig.java # Spring Security 설정
│
├── 📦 domain/ # 도메인 엔티티
│ ├── user/ # 사용자 도메인
│ │ ├── User.java
│ │ ├── Role.java
│ │ └── UserRole.java
│ ├── show/ # 공연 도메인
│ │ ├── ShowInfo.java
│ │ ├── ShowSchedule.java
│ │ ├── ShowSeat.java
│ │ └── SeatStatus.java
│ └── reservation/ # 예약 도메인
│ ├── Booking.java
│ ├── ReservationSeat.java
│ └── BookingStatus.java
│
├── 📦 repository/ # 데이터 접근
│ ├── user/
│ ├── show/
│ │ ├── ShowSeatRepository.java
│ │ └── ShowSeatRepositoryCustomImpl.java # QueryDSL 구현
│ ├── reservation/
│ └── security/
│ └── RefreshTokenRepository.java # Redis Repository
│
├── 📦 service/ # 비즈니스 로직
│ ├── UserService.java
│ ├── CustomUserService.java # UserDetailsService 구현
│ ├── ShowService.java
│ ├── ReservationService.java # 예약 로직 (Redis 분산 락)
│ ├── RedisService.java # Redis 캐시 서비스
│ └── RefreshTokenService.java # Refresh Token 관리
│
├── 📦 controller/ # API 엔드포인트
│ ├── UserController.java # 인증/회원
│ ├── ShowController.java # 공연 관리
│ └── ReservationController.java # 예약 관리
│
├── 📦 dto/ # 데이터 전송 객체
│ ├── user/
│ ├── show/
│ └── reservation/
│
└── 📦 exception/ # 예외 처리
├── ErrorCode.java # 에러 코드 정의
├── AbstractException.java # 커스텀 예외 추상 클래스
├── CustomExceptionHandler.java # 전역 예외 핸들러
└── custom/
├── user/
├── show/
├── reservation/
└── security/ # 토큰 관련 예외
문제: 여러 사용자가 동시에 같은 좌석 예약 시도
해결: Redisson RLock을 활용한 분산 락
// ReservationService.java
RLock lock = redissonClient.getLock("lock:seat:" + seatId);
try {
// 최대 3초 대기, 락 획득 실패 시 예외
boolean isLocked = lock.tryLock(3, TimeUnit.SECONDS);
if (!isLocked) {
throw new SeatAlreadyTakenException();
}
// 임계 영역: 좌석 상태 확인 및 변경
// ... 비즈니스 로직 ...
} finally {
// 현재 스레드가 락을 보유한 경우에만 해제
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}동시성 테스트 결과 (ReservationConcurrencyTest.java):
// 3명의 사용자가 동시에 1개 좌석 예약 시도
ExecutorService executor = Executors.newFixedThreadPool(3);
List<Future<ResultActions>> futures = new ArrayList<>();
for (int i = 0; i < 3; i++) {
futures.add(executor.submit(() -> {
return mockMvc.perform(post("/api/tickets/redis/reserve")
.header("Authorization", "Bearer " + accessToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)));
}));
}
// 결과: 1명 성공 (200), 2명 실패 (400 - 이미 선택된 좌석)문제: Access Token 만료 시 사용자가 재로그인 필요
해결: JwtFilter에서 투명하게 토큰 갱신
플로우:
┌────────────────────────────────────────────────────────────────┐
│ 1. 클라이언트 요청 │
│ Authorization: Bearer {expiredAccessToken} │
│ X-Refresh-Token: {refreshToken} │
└────────────────────┬───────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────┐
│ 2. JwtFilter 검증 │
│ • validateToken(token) → false (만료됨) │
│ • isTokenExpired(token) → true │
│ • isTokenSignatureValid(token) → true (서명은 유효) │
└────────────────────┬───────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────┐
│ 3. TokenRefreshService 호출 │
│ • Refresh token 서명 검증 │
│ • Refresh token 만료 확인 │
│ • Redis에 저장된 token과 비교 │
│ • 새 Access Token 발급 (같은 roles 유지) │
└────────────────────┬───────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────┐
│ 4. 응답 │
│ HTTP/1.1 200 OK │
│ X-New-Access-Token: {newAccessToken} │
│ {원래 요청한 데이터} │
└────────────────────────────────────────────────────────────────┘
코드 하이라이트:
JwtUtil.java - 토큰 상태 검증
/**
* 만료 여부와 관계없이 토큰의 서명이 유효한지 확인
*/
public boolean isTokenSignatureValid(String token) {
try {
Jwts.parser()
.setSigningKey(this.secretKey)
.parseClaimsJws(token);
return true;
} catch (ExpiredJwtException e) {
// 만료되었지만 서명은 유효함
return true;
} catch (Exception e) {
// 서명이 유효하지 않음
return false;
}
}TokenRefreshService.java - 토큰 갱신 로직
public String refreshAccessToken(String expiredAccessToken, String refreshToken) {
// 1. 만료된 access token에서 username 추출
String username = jwtUtil.getUsername(expiredAccessToken);
// 2. Refresh token 서명 검증
if (!jwtUtil.isTokenSignatureValid(refreshToken)) {
throw new InvalidRefreshTokenException();
}
// 3. Refresh token 만료 확인
if (jwtUtil.isTokenExpired(refreshToken)) {
throw new ExpiredRefreshTokenException();
}
// 4. Redis에 저장된 refresh token과 비교
boolean isValid = refreshTokenService.validateRefreshToken(username, refreshToken);
if (!isValid) {
throw new RefreshTokenNotFoundException();
}
// 5. 새로운 access token 발급
Set<String> roles = userService.getRolesByUsername(username);
String newAccessToken = jwtUtil.generateToken(username, roles);
log.info("Access token refreshed successfully for user: {}", username);
return newAccessToken;
}Redis 사용 목적:
// RefreshToken.java - Redis Hash 엔티티
@RedisHash("refreshToken")
@Data
@Builder
public class RefreshToken {
@Id
private String username; // Key: refreshToken:{username}
private String token;
@TimeToLive
private Long expiration; // 7일 (604,800초) - 자동 삭제
}// RedisService.java
public void holdSeat(ReservationRequest.Reserve request, Long userId) {
String key = "seat:" + request.getSeatId();
String value = String.valueOf(userId);
// 5분 TTL로 저장
redisTemplate.opsForValue().set(key, value, 5, TimeUnit.MINUTES);
}
public boolean verifySeatHolder(Long seatId, Long userId) {
String key = "seat:" + seatId;
String storedUserId = redisTemplate.opsForValue().get(key);
// Redis에 저장된 userId와 현재 userId 비교
return storedUserId != null && storedUserId.equals(String.valueOf(userId));
}// RedissonConfig.java
@Configuration
@Profile("prod")
public class RedissonConfig {
@Value("${spring.data.redis.host}")
private String redisHost;
@Value("${spring.data.redis.port}")
private int redisPort;
@Value("${spring.data.redis.password}")
private String redisPassword;
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://" + redisHost + ":" + redisPort)
.setPassword(redisPassword)
.setConnectionPoolSize(10)
.setConnectionMinimumIdleSize(2);
return Redisson.create(config);
}
}ShowSeat 엔티티 상태 전이:
// ShowSeat.java
public void hold(Long userId) {
if (this.status != SeatStatus.AVAILABLE) {
throw new SeatAlreadyTakenException();
}
this.status = SeatStatus.HOLD;
this.holdUserId = userId;
this.holdStartTime = LocalDateTime.now();
}
public void reserve() {
if (this.status != SeatStatus.HOLD) {
throw new ConfirmFailedException();
}
this.status = SeatStatus.RESERVED;
}
public void release() {
this.status = SeatStatus.AVAILABLE;
this.holdUserId = null;
this.holdStartTime = null;
}Confirm 검증 로직:
// ReservationService.java - confirmReservation()
public ReservationResponse.Confirm confirmReservation(
ReservationRequest.Confirm request, Long userId) {
Long seatId = request.getSeatId();
// 1. Redis에 저장된 점유자 확인
boolean isHolder = redisService.verifySeatHolder(seatId, userId);
if (!isHolder) {
throw new ConfirmFailedException();
}
// 2. 좌석 조회 및 상태 확인
ShowSeat seat = showSeatRepository.findById(seatId)
.orElseThrow(() -> new IllegalArgumentException("좌석을 찾을 수 없습니다."));
if (seat.getStatus() != SeatStatus.HOLD || !seat.getHoldUserId().equals(userId)) {
throw new ConfirmFailedException();
}
// 3. 예약 확정
seat.reserve();
// 4. Booking 엔티티 생성
Booking booking = Booking.builder()
.userId(userId)
.scheduleId(seat.getShowSchedule().getId())
.status(BookingStatus.CONFIRMED)
.build();
bookingRepository.save(booking);
// 5. ReservationSeat (연결 테이블)
ReservationSeat reservationSeat = ReservationSeat.builder()
.booking(booking)
.showSeat(seat)
.build();
reservationSeatRepository.save(reservationSeat);
// 6. Redis 데이터 삭제
redisService.releaseSeat(seatId);
return ReservationResponse.Confirm.from(booking, seat);
}예외 계층 구조:
AbstractException (추상 클래스)
├── UserAlreadyExistsException
├── UserNotFoundException
├── SeatAlreadyTakenException
├── ConfirmFailedException
├── InvalidAccessTokenException
├── ExpiredAccessTokenException
└── RefreshTokenNotFoundException전역 예외 핸들러:
// CustomExceptionHandler.java
@RestControllerAdvice
@Slf4j
public class CustomExceptionHandler {
@ExceptionHandler(AbstractException.class)
public ResponseEntity<ErrorResponse> handleAbstractException(AbstractException e) {
ErrorCode errorCode = e.getErrorCode();
// 401 에러는 별도 로깅
if (errorCode.getStatus() == 401) {
log.warn("[SecurityException] {}: {}",
e.getClass().getSimpleName(), e.getMessage());
}
ErrorResponse response = ErrorResponse.builder()
.status(errorCode.getStatus())
.message(errorCode.getMessage())
.timestamp(LocalDateTime.now())
.build();
return ResponseEntity
.status(errorCode.getStatus())
.body(response);
}
}ErrorCode 정의:
// ErrorCode.java
@Getter
@RequiredArgsConstructor
public enum ErrorCode {
// Token
INVALID_ACCESS_TOKEN(401, "유효하지 않은 Access Token입니다."),
EXPIRED_ACCESS_TOKEN(401, "만료된 Access Token입니다."),
INVALID_REFRESH_TOKEN(401, "유효하지 않은 Refresh Token입니다."),
EXPIRED_REFRESH_TOKEN(401, "만료된 Refresh Token입니다."),
REFRESH_TOKEN_NOT_FOUND(401, "Refresh Token을 찾을 수 없습니다."),
// Reservation
SEAT_ALREADY_HOLD(400, "이미 다른 사용자가 선택한 좌석입니다."),
INVALID_CONFIRM(400, "HOLD 상태가 아니거나 다른 사용자가 HOLD한 좌석입니다."),
// ...
}- Java 17 이상
- Docker & Docker Compose
- MySQL 8.0 (로컬 개발 시)
- Redis 7.x (로컬 개발 시)
git clone https://github.com/yourusername/ticketReservationProject.git
cd ticketReservationProjectsrc/main/resources/account_info.yml 생성:
# 로컬 개발용
spring:
datasource:
url: jdbc:mysql://localhost:3306/ticket_reservation?serverTimezone=Asia/Seoul
username: your_db_username
password: your_db_password
data:
redis:
host: localhost
port: 6379
password: your_redis_password # 비밀번호 없으면 생략
jwt:
secretKey: your_jwt_secret_key_at_least_256_bits_long
access-token-expiration: 3600000 # 1시간 (밀리초)
refresh-token-expiration: 604800000 # 7일 (밀리초)# 전체 스택 실행 (MySQL + Redis + Spring Boot)
docker-compose up -d
# 로그 확인
docker-compose logs -f tr-app
# 중지
docker-compose down
# 볼륨까지 삭제 (데이터 초기화)
docker-compose down -v서비스 접속 정보:
- API Server: http://localhost:80
- Swagger UI: http://localhost:80/swagger-ui.html
- MySQL: localhost:3306
- Redis: localhost:6379
# Gradle 빌드
./gradlew clean build
# Spring Boot 실행 (로컬 DB/Redis 설정 사용)
./gradlew bootRundev 프로필은 Docker Compose 내부 서비스명(tr-db, tr-redis)을 기본값으로 사용합니다. 로컬 JVM에서 dev 프로필을 직접 사용할 경우 REDIS_HOST=127.0.0.1 등 필요한 접속 정보를 명시하세요.
애플리케이션 실행 시 ddl-auto: update 설정으로 자동 테이블 생성됩니다.
기본 Role 데이터 수동 입력:
INSERT INTO role (name) VALUES ('ROLE_USER');
INSERT INTO role (name) VALUES ('ROLE_ADMIN');애플리케이션 실행 후 접속:
http://localhost:8080/swagger-ui.html
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| POST | /api/auth/signup |
회원가입 | Public |
| POST | /api/auth/signin |
로그인 (JWT 발급) | Public |
| POST | /api/auth/refresh |
Access Token 수동 갱신 | Public |
| POST | /api/auth/logout |
로그아웃 (Refresh Token 삭제) | USER |
회원가입 요청 예시:
POST /api/auth/signup
Content-Type: application/json
{
"username": "user123",
"password": "password123!",
"name": "홍길동",
"email": "user@example.com"
}로그인 응답 예시:
{
"accessToken": "eyJhbGciOiJIUzUxMiJ9...",
"refreshToken": "eyJhbGciOiJIUzUxMiJ9...",
"tokenType": "Bearer",
"expiresIn": 3600
}| Method | Endpoint | Description | Auth |
|---|---|---|---|
| POST | /api/shows |
공연 등록 | ADMIN |
| GET | /api/shows |
공연 목록 조회 | USER |
| GET | /api/shows/{showId} |
공연 상세 (일정+좌석) | USER |
| POST | /api/shows/{showId}/schedule |
일정 추가 | ADMIN |
| DELETE | /api/shows/{showId} |
공연 삭제 | ADMIN |
공연 등록 요청:
POST /api/shows
Authorization: Bearer {accessToken}
Content-Type: application/json
{
"title": "뮤지컬 레미제라블",
"description": "감동의 무대",
"runtime": 180
}일정 추가 요청:
POST /api/shows/1/schedule
Authorization: Bearer {accessToken}
Content-Type: application/json
{
"startTimes": [
"2024-02-01T19:00:00",
"2024-02-02T19:00:00",
"2024-02-03T14:00:00"
]
}| Method | Endpoint | Description | Auth |
|---|---|---|---|
| POST | /api/tickets/redis/reserve |
좌석 HOLD (Redis 분산 락) | USER |
| POST | /api/tickets/redis/confirm |
예약 확정 | USER |
| POST | /api/tickets/db/reserve |
좌석 HOLD (DB 조건부 update) | USER |
| POST | /api/tickets/db/confirm |
예약 확정 (DB) | USER |
| GET | /api/tickets/{reservationId} |
예약 상세 조회 | USER |
좌석 예약 (HOLD) 요청:
POST /api/tickets/redis/reserve
Authorization: Bearer {accessToken}
X-Refresh-Token: {refreshToken}
Content-Type: application/json
{
"seatId": 123
}예약 확정 (CONFIRM) 요청:
POST /api/tickets/redis/confirm
Authorization: Bearer {accessToken}
Content-Type: application/json
{
"seatId": 123
}모든 API 요청 시 두 헤더를 함께 전송:
GET /api/shows
Authorization: Bearer {accessToken}
X-Refresh-Token: {refreshToken}Access Token이 만료된 경우 응답:
HTTP/1.1 200 OK
X-New-Access-Token: eyJhbGciOiJIUzUxMiJ9...
Content-Type: application/json
{
"data": [...]
}클라이언트는 X-New-Access-Token 헤더를 확인하여 새 토큰 저장:
const response = await fetch('/api/shows', {
headers: {
'Authorization': `Bearer ${accessToken}`,
'X-Refresh-Token': refreshToken
}
});
// 응답 헤더에서 새 토큰 확인
const newAccessToken = response.headers.get('X-New-Access-Token');
if (newAccessToken) {
localStorage.setItem('accessToken', newAccessToken);
console.log('✅ Token auto-refreshed');
}-- 사용자
CREATE TABLE user (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
name VARCHAR(100) NOT NULL,
email VARCHAR(100),
status CHAR(1) DEFAULT 'Y', -- 'Y': 활성, 'N': 탈퇴
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- 권한
CREATE TABLE role (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(50) UNIQUE NOT NULL -- 'ROLE_USER', 'ROLE_ADMIN'
);
-- 사용자-권한 매핑
CREATE TABLE user_role (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
role_id BIGINT NOT NULL,
FOREIGN KEY (user_id) REFERENCES user(id),
FOREIGN KEY (role_id) REFERENCES role(id)
);
-- 공연
CREATE TABLE show_info (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
title VARCHAR(200) NOT NULL,
description TEXT,
runtime INT NOT NULL, -- 러닝타임 (분)
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 공연 일정
CREATE TABLE show_schedule (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
show_info_id BIGINT NOT NULL,
start_time TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (show_info_id) REFERENCES show_info(id) ON DELETE CASCADE
);
-- 좌석
CREATE TABLE show_seat (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
show_schedule_id BIGINT NOT NULL,
seat_number VARCHAR(10) NOT NULL, -- 'A1', 'B5' 등
price BIGINT NOT NULL,
status VARCHAR(20) NOT NULL, -- 'AVAILABLE', 'HOLD', 'RESERVED'
hold_user_id BIGINT,
hold_start_time TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (show_schedule_id) REFERENCES show_schedule(id) ON DELETE CASCADE
);
-- 예약
CREATE TABLE booking (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
schedule_id BIGINT NOT NULL,
status VARCHAR(20) NOT NULL, -- 'CONFIRMED', 'CANCELLED'
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 예약-좌석 매핑
CREATE TABLE reservation_seat (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
booking_id BIGINT NOT NULL,
seat_id BIGINT NOT NULL,
FOREIGN KEY (booking_id) REFERENCES booking(id),
FOREIGN KEY (seat_id) REFERENCES show_seat(id)
);User ──┐
│
├─ UserRole ─ Role
│
└─ Booking ──┐
│
ShowInfo │
│ │
ShowSchedule │
│ │
ShowSeat ──────────┴─ ReservationSeat
주요 관계:
- User : UserRole : Role = N : M (다대다)
- ShowInfo : ShowSchedule = 1 : N (일대다, CASCADE)
- ShowSchedule : ShowSeat = 1 : N (일대다, CASCADE)
- Booking : ReservationSeat : ShowSeat = N : M (다대다)
ReservationConcurrencyTest.java:
@Test
@DisplayName("3명이 동시에 1개 좌석 예약 - Redis 분산 락")
void testConcurrentReservation_Redis() throws Exception {
// Given: 1개 좌석, 3명 사용자
Long seatId = 1L;
int numberOfThreads = 3;
ExecutorService executor = Executors.newFixedThreadPool(numberOfThreads);
CountDownLatch latch = new CountDownLatch(numberOfThreads);
List<Future<ResultActions>> futures = new ArrayList<>();
// When: 3명이 동시에 예약 시도
for (int i = 0; i < numberOfThreads; i++) {
futures.add(executor.submit(() -> {
latch.countDown();
latch.await();
return mockMvc.perform(post("/api/tickets/redis/reserve")
.header("Authorization", "Bearer " + accessToken)
.contentType(MediaType.APPLICATION_JSON)
.content(createReserveRequest(seatId)));
}));
}
// Then: 1명만 성공, 2명은 실패
int successCount = 0;
int failCount = 0;
for (Future<ResultActions> future : futures) {
ResultActions result = future.get();
MvcResult mvcResult = result.andReturn();
if (mvcResult.getResponse().getStatus() == 200) {
successCount++;
} else {
failCount++;
}
}
assertThat(successCount).isEqualTo(1);
assertThat(failCount).isEqualTo(2);
executor.shutdown();
}# 전체 테스트
./gradlew test
# 특정 테스트 클래스
./gradlew test --tests ReservationConcurrencyTest
# 테스트 리포트 확인
open build/reports/tests/test/index.html증상:
Unable to connect to Redis; nested exception is io.lettuce.core.RedisConnectionException
해결:
# Redis 실행 확인
docker ps | grep redis
# Redis 재시작
docker-compose restart tr-redis
# Redis 접속 테스트
redis-cli -h localhost -p 6379 ping
# 응답: PONG증상:
{
"status": 401,
"message": "유효하지 않은 Access Token입니다."
}원인: account_info.yml의 secretKey가 충분히 길지 않음 (최소 256비트 필요)
해결:
spring:
jwt:
secretKey: your_secret_key_must_be_at_least_256_bits_long_for_HS512_algorithm증상:
{
"status": 400,
"message": "이미 다른 사용자가 선택한 좌석입니다."
}원인:
- Redis 응답 지연
- 락 대기 시간 초과 (3초)
해결:
// ReservationService.java - 대기 시간 조정
boolean isLocked = lock.tryLock(5, TimeUnit.SECONDS); // 3초 → 5초증상:
MySQL initialization failed: Permission denied
해결:
# 볼륨 삭제 후 재생성
docker-compose down -v
docker volume prune -f
docker-compose up -d증상:
Bind for 0.0.0.0:3306 failed: port is already allocated
해결:
# 기존 프로세스 확인
lsof -i :3306
# 프로세스 종료
kill -9 <PID>
# 또는 docker-compose.yml에서 포트 변경
ports:
- "3307:3306" # 호스트 포트 변경- Token Rotation: Refresh Token도 갱신 시 재발급
- Rate Limiting: Redis를 활용한 API 요청 제한
- Monitoring: Prometheus + Grafana 대시보드
- Circuit Breaker: Resilience4j 적용
- Event Sourcing: Kafka를 활용한 이벤트 기반 아키텍처
- Multi-tenancy: 여러 공연장 지원
- WebSocket: 실시간 좌석 현황 업데이트
- Payment Integration: 실제 PG 연동
이슈와 PR은 언제든 환영합니다!
- Fork the Project
- Create your Feature Branch (
git checkout -b feature/AmazingFeature) - Commit your Changes (
git commit -m 'Add some AmazingFeature') - Push to the Branch (
git push origin feature/AmazingFeature) - Open a Pull Request
This project is licensed under the MIT License.
프로젝트 관련 문의사항이 있으시면 이슈를 등록해주세요.
Built with ❤️ using Spring Boot