diff --git a/CLAUDE.md b/CLAUDE.md index 27561da..7f63bdf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,8 +28,8 @@ api/ global/ security/ → JwtProvider, JwtAuthenticationFilter, HostUserDetailsService exception/ → BusinessException hierarchy + GlobalExceptionHandler - config/ → SecurityConfig, SwaggerConfig, AwsConfig, QuerydslConfig, RedissonConfig - lock/ → DistributedLockService (Redisson 분산 락) + config/ → SecurityConfig, SwaggerConfig, AwsConfig, QuerydslConfig + lock/ → LockExecutor 인터페이스 + InMemoryLockExecutor (인메모리 락) mail/ → Async email sending (Google SMTP) image/ → S3 presigned URL generation common/domain/ → BaseTimeEntity (createdAt/updatedAt auditing) @@ -52,7 +52,7 @@ XxxRepositoryImpl implements XxxRepositoryCustom ← Query **Key Domain Relationships:** - `Host` → owns many `Event` (via eventId) - `Event` → has `EventSchedule[]`, `FormQuestion[]`, `EventImage[]` -- `EventSchedule` → has `Reservation[]` (capacity-controlled with Redisson distributed lock) +- `EventSchedule` → has `Reservation[]` (capacity-controlled with in-memory lock via LockExecutor) - `Reservation` → has `FormAnswer[]`, a `qrToken` (UUID) for check-in, and `ReservationStatus` (CONFIRMED/CANCELLED) ## Conventions @@ -68,9 +68,10 @@ XxxRepositoryImpl implements XxxRepositoryCustom ← Query ## Concurrency Control -- `EventSchedule.reservedCount` is protected by **Redisson distributed lock** (Redis) via Facade pattern (ADR-003) +- `EventSchedule.reservedCount` is protected by **인메모리 락** (`ReentrantLock`) via Facade pattern +- `LockExecutor` 인터페이스 기반 설계로 추후 분산 락(Redis 등) 전환 가능 +- `InMemoryLockExecutor`: `ConcurrentHashMap` — 키별 공정 락 (FIFO), `tryLock(5, SECONDS)` - Facade classes (`ReservationFacade`, `HostReservationFacade`) acquire `schedule:{scheduleId}` lock **outside** `@Transactional` to minimize DB connection hold time -- `DistributedLockService.executeWithLock()` uses `tryLock(5, SECONDS)` with Watchdog auto-renewal (no leaseTime) - Lock targets: `createReservation`, `cancelReservation` (both guest and host) - Duplicate reservation check: same phone number + same schedule combination is rejected @@ -85,7 +86,7 @@ XxxRepositoryImpl implements XxxRepositoryCustom ← Query ## Testing -- Test profile (`application-test.yml`): H2 in-memory DB with `MODE=MySQL`, embedded Redis (port 6370), mock mail config, dedicated JWT secret +- Test profile (`application-test.yml`): H2 in-memory DB with `MODE=MySQL`, mock mail config, dedicated JWT secret - Tests run with: `./gradlew clean test` - Currently only a context-load test exists (`ReservationSolutionApplicationTests`) @@ -105,7 +106,6 @@ XxxRepositoryImpl implements XxxRepositoryCustom ← Query - **JWT**: jjwt 0.11.5 | **AWS SDK**: v2.27.21 (S3) | **API Docs**: Springdoc OpenAPI 2.7.0 - **Monitoring**: Spring Actuator + Micrometer Prometheus | **Cache**: Caffeine -- **Redis**: Redisson 3.27.2 (분산 락) | **Test**: embedded-redis 1.4.3 - **QueryDSL**: 5.1.0 (jakarta classifier) | Q클래스: `build/generated/` 하위 자동 생성 ## 필수 참조 문서 diff --git a/build.gradle b/build.gradle index 1695600..37dbe57 100644 --- a/build.gradle +++ b/build.gradle @@ -58,9 +58,6 @@ dependencies { annotationProcessor "jakarta.annotation:jakarta.annotation-api" annotationProcessor "jakarta.persistence:jakarta.persistence-api" - // Redis + Redisson (분산 락) - implementation 'org.redisson:redisson-spring-boot-starter:3.27.2' - testImplementation 'com.github.codemonstur:embedded-redis:1.4.3' } bootJar { diff --git a/docs/reports/stress-test-report-distributed-lock-20260309.md b/docs/reports/stress-test-report-distributed-lock-20260309.md index 6fa5f64..a422ca6 100644 --- a/docs/reports/stress-test-report-distributed-lock-20260309.md +++ b/docs/reports/stress-test-report-distributed-lock-20260309.md @@ -1,4 +1,4 @@ -# 스트레스 테스트 리포트 — 분산 락 도입 Before/After 비교 +# 스트레스 테스트 리포트 — 비관적 락 vs 분산 락 동일 인프라 비교 > 테스트일: 2026-03-09 > 환경: EC2 t3.small (2vCPU, 2GB) + Redis (같은 인스턴스) @@ -8,8 +8,8 @@ ## 1. 테스트 목적 -비관적 락(`SELECT FOR UPDATE`)을 Redisson 분산 락으로 전환한 후, -500 VU 스트레스 테스트에서 **서버 에러율과 응답시간이 개선되었는지** 검증한다. +동일한 인프라(t3.small)에서 비관적 락(`SELECT FOR UPDATE`)과 Redisson 분산 락의 +**성능 지표와 시스템 리소스 사용량을 비교**하여, 분산 락 도입의 근거를 검증한다. --- @@ -17,12 +17,19 @@ | 항목 | 값 | |------|-----| -| 스크립트 | `k6/reservation-stress-test.js` | +| 부하 패턴 | 단계적 부하 증가 (Step-up Load) | | VU 단계 | 100 → 200 → 300 → 500 (각 30초 유지) | | 총 시간 | 2분 20초 | -| 대상 API | `POST /api/reservations` | | 대상 스케줄 | 단일 스케줄 (scheduleId: 1, maxCapacity: 10,000+) | | sleep | 0.5초 (요청 간 간격) | +| 인스턴스 | EC2 t3.small (2vCPU, 2GB) | +| DB 커넥션 풀 | HikariCP 30 | +| 톰캣 스레드 | 200 | + +| 테스트 | 스크립트 | 대상 API | +|--------|---------|----------| +| 비관적 락 | `k6/reservation-pessimistic-stress-test.js` | `POST /api/reservations/pessimistic` | +| 분산 락 | `k6/reservation-stress-test.js` | `POST /api/reservations` | ### 성공/실패 판정 기준 @@ -35,103 +42,134 @@ --- -## 3. Before/After 비교 +## 3. 핵심 지표 비교 + +### k6 성능 지표 -### 환경 차이 +| 지표 | 비관적 락 | 분산 락 | +|------|----------|--------| +| **서버 에러율 (5xx)** | **0.00%** | **0.00%** | +| 예약 성공 | 8,690건 | 6,509건 | +| 정상 거부 | 14건 (0.16%) | 209건 (3.11%) | +| 처리량 | 57.03 req/s | 44.14 req/s | +| 총 요청 | 8,705건 | 6,719건 | -| | Before | After | -|---|---|---| -| 인스턴스 | t2.micro (1vCPU, 1GB) | t3.small (2vCPU, 2GB) | -| 락 방식 | 비관적 락 (SELECT FOR UPDATE) | Redisson 분산 락 (Redis) | -| 커넥션 풀 | HikariCP 30 | HikariCP 30 | -| Redis | 없음 | localhost:6379 (maxmemory 50mb) | +### 응답시간 -### 핵심 지표 비교 +| 지표 | 비관적 락 | 분산 락 | +|------|----------|--------| +| 평균 | 4.09s | 5.42s | +| 중앙값 | 2.59s | 5.44s | +| p90 | 9.98s | 9.91s | +| p95 | 10.28s | 10.65s | +| p99 | 10.54s | 10.72s | +| 최대 | 14.10s | 11.44s | -| 지표 | Before (비관적 락) | After (분산 락) | 변화 | -|------|-------------------|----------------|------| -| **서버 에러율 (5xx)** | **19.09%** | **0.00%** | **완전 해소** | -| 예약 성공 | 4,970건 | 6,509건 | +30.9% | -| 정상 거부 (409/429) | - | 209건 (3.11%) | - | -| 처리량 | ~36 req/s | 44.14 req/s | +22.6% | -| 총 요청 | 6,143건 | 6,719건 | +9.3% | +### 시스템 모니터링 지표 -### 응답시간 비교 +| 지표 | 비관적 락 | 분산 락 | +|------|----------|--------| +| 시스템 CPU | 0.76 (76%) | - | +| 프로세스 CPU | 0.75 (75%) | - | +| DBCP 활성 커넥션 | 30/30 (전부 사용) | - | +| **DBCP pending** | **169개** | - | +| 톰캣 스레드 | 200/200 (전부 busy) | - | +| 최대 RPS | 75.7 | - | -| 지표 | Before | After | 변화 | -|------|--------|-------|------| -| 평균 | 3.14s | 5.42s | +2.28s | -| p90 | - | 9.91s | - | -| p95 | 8.21s | 10.65s | +2.44s | -| p99 | 11.72s | 10.72s | -1.00s | -| 최대 | 30.00s (타임아웃) | 11.44s | **-18.56s** | +> 분산 락 모니터링 지표는 추후 동일 조건 테스트 후 업데이트 예정 --- ## 4. 분석 -### 4-1. 서버 에러율: 19.09% → 0.00% +### 4-1. 에러율: 둘 다 0.00% + +500 VU 환경에서 비관적 락과 분산 락 모두 서버 에러 없이 처리되었다. +t3.small(2vCPU, 2GB) 환경에서는 비관적 락도 500 VU를 감당할 수 있다. + +### 4-2. 처리량: 비관적 락 57 req/s vs 분산 락 44 req/s -**가장 중요한 개선.** 비관적 락에서 발생하던 5xx 에러가 완전히 사라졌다. +비관적 락의 처리량이 29% 더 높다. +분산 락은 Redis를 경유하는 오버헤드(네트워크 I/O, Pub/Sub 대기)가 있기 때문이다. +단일 인스턴스 환경에서 순수 처리량만 보면 비관적 락이 유리하다. -원인: -- 비관적 락: 락 대기 중 DB 커넥션 점유 → 커넥션 풀 고갈 → 타임아웃 → 5xx -- 분산 락: 락 대기를 Redis가 담당 → DB 커넥션은 트랜잭션 처리 시간만 점유 +### 4-3. 비관적 락의 구조적 위험 — DBCP pending 169개 + +에러율은 0%였으나, 모니터링 지표는 **시스템이 한계에 도달한 상태**를 보여준다. ``` -[Before — 비관적 락] -500 VU 동시 요청 → 30개 커넥션 점유 + 470개 대기 → 커넥션 풀 고갈 → 5xx +[비관적 락 — 500 VU 시점의 시스템 상태] -[After — 분산 락] -500 VU 동시 요청 → Redis에서 순차 대기 → 락 획득한 요청만 DB 커넥션 사용 -→ 커넥션 풀 고갈 없음 → 5xx 0건 +톰캣 스레드: 200/200 busy (유휴 스레드 0개) +DB 커넥션: 30/30 active (전부 사용) +DB pending: 169개 (커넥션을 기다리는 스레드) +CPU: 76% (여유 거의 없음) ``` -### 4-2. 예약 성공: 4,970건 → 6,509건 (+30.9%) +**비관적 락은 락 대기 중에도 DB 커넥션을 점유하거나, 커넥션 풀에서 대기하는 구조**이다. +현재 500 VU에서는 버텼지만, 다음 상황에서 커넥션 대기 타임아웃 → 5xx 에러가 발생할 수 있다: -5xx로 실패하던 요청이 정상 처리되면서 성공 건수가 증가했다. +- VU가 더 높아지는 경우 (700+) +- 인스턴스 리소스가 줄어드는 경우 +- 트랜잭션 처리 시간이 길어지는 경우 (복잡한 비즈니스 로직 추가 등) -### 4-3. 정상 거부 209건 (3.11%) +### 4-4. 분산 락의 구조적 이점 -429(락 대기 초과)와 409(중복 예약/정원 초과)를 합산한 수치. -시스템이 과부하 상황에서 **에러 대신 명확한 거부 응답**을 반환하는 것이므로 정상 동작이다. +분산 락은 락 대기를 Redis가 담당하므로, DB 커넥션은 **실제 트랜잭션 처리 시간만** 점유한다. -### 4-4. p95 응답시간: 8.21s → 10.65s +``` +[비관적 락] +요청 → DB 커넥션 획득 → SELECT FOR UPDATE (락 대기 + 처리) → 커넥션 반환 + └────────────── 전체 시간 DB 커넥션 점유 ──────────────┘ -p95가 다소 증가했으나, 이는 다음 요인에 의한 것: +[분산 락] +요청 → Redis 락 대기 (DB 커넥션 없음) → 락 획득 → DB 커넥션 획득 → 처리 → 커넥션 반환 + └── 최소 시간만 점유 ──┘ +``` -1. **단일 스케줄 집중**: 500 VU가 같은 스케줄 1개에 몰리면 락이 순차 처리됨 -2. **Before에서는 5xx로 빠르게 실패**: 커넥션 풀 고갈 시 타임아웃으로 즉시 실패 → 응답시간 통계에서 제외 -3. **After에서는 모든 요청이 처리됨**: 락 대기 후 성공까지 가므로 전체 응답시간이 길어짐 +이로 인해: +- DB 커넥션 풀 고갈 위험이 구조적으로 낮음 +- VU가 높아져도 DB 커넥션 pending이 급증하지 않음 +- 시스템의 확장 여유(headroom)가 더 큼 -즉, **Before의 낮은 p95는 "빠르게 실패한 것"이지 "빠르게 처리한 것"이 아니다.** -After에서 p95가 높아진 것은 **이전에 실패하던 요청까지 성공적으로 처리**하기 때문이다. +### 4-5. 분산 락의 추가적 이점 -### 4-5. 실제 운영 환경에서의 예상 성능 +성능 외에도, 비관적 락은 다음 상황에서 **구조적으로 동작하지 않는다**: -이 테스트는 **최악의 시나리오**(500명이 동일한 스케줄 1개에 동시 예약)를 재현한 것이다. -실제 운영에서는: +| 시나리오 | 비관적 락 | 분산 락 | +|----------|----------|--------| +| 스케일아웃 (인스턴스 2대+) | 인스턴스 간 락 공유 불가 | Redis가 중앙 락 역할 | +| 무중단 배포 (Blue/Green) | 구 버전·신 버전 간 락 미공유 | 동일 Redis로 공유 | -- 여러 이벤트, 여러 스케줄에 트래픽이 분산됨 -- 스케줄별로 별도의 락 키(`schedule:{id}`)를 사용하므로 락 경합이 줄어듦 -- p95 응답시간은 이보다 크게 낮아질 것으로 예상 +현재는 단일 인스턴스이지만, 향후 스케일아웃이나 무중단 배포를 도입하면 +비관적 락은 즉시 무효화되고, 그 시점에 분산 락으로 전환하는 마이그레이션 비용이 발생한다. --- -## 5. Before 상세 데이터 (비관적 락) +## 5. 비관적 락 상세 데이터 -> 출처: `docs/reports/stress-test-report-20260307.md` +``` +CUSTOM +reservation_created............: 8690 56.927828/s +reservation_duration...........: avg=4094ms min=79ms med=2614ms max=14108ms + p(90)=9990ms p(95)=10287ms p(99)=10545ms +reservation_rejected...........: 14 0.091713/s +server_error_rate..............: 0.00% 0 out of 8704 -| 지표 | 커넥션 풀 10 | 커넥션 풀 30 | -|------|-------------|-------------| -| 에러율 | 13.82% | 19.09% | -| p95 응답시간 | 11.31s | 8.21s | -| 예약 성공 | 4,691건 | 4,970건 | -| 총 요청 | 5,445건 | 6,143건 | +HTTP +http_req_duration..............: avg=4.09s min=51ms med=2.59s max=14.1s + p(90)=9.98s p(95)=10.28s p(99)=10.54s +http_req_failed................: 0.16% 14 out of 8705 +http_reqs......................: 8705 57.03/s + +EXECUTION +iterations.....................: 8704 57.02/s +vus_max........................: 500 +``` --- -## 6. After 상세 데이터 (분산 락) +## 6. 분산 락 상세 데이터 ``` CUSTOM @@ -156,14 +194,22 @@ vus_max.......................: 500 ## 7. 결론 -| 목표 | 결과 | -|------|------| -| 서버 에러율 해소 | **달성** — 19.09% → 0.00% | -| 초과 예약 방지 | **달성** — 정합성 유지 | -| 처리량 유지/개선 | **달성** — 36 → 44 req/s (+22.6%) | +500 VU 환경에서 비관적 락과 분산 락 모두 서버 에러 없이 동작했다. +순수 처리량만 보면 비관적 락(57 req/s)이 분산 락(44 req/s)보다 높다. + +그러나 **비관적 락은 시스템이 한계에 도달한 상태에서 동작**하고 있었다: +- DB 커넥션 풀 전부 가동, pending 169개 +- 톰캣 스레드 200개 전부 busy +- CPU 76% + +분산 락을 도입한 이유는 단순한 성능 개선이 아니라, **구조적 안정성과 확장성** 확보이다: -분산 락 도입으로 **DB 커넥션 풀 고갈 문제가 근본적으로 해결**되었다. -500 VU 환경에서 서버 에러 없이 안정적으로 예약을 처리할 수 있게 되었다. +| 관점 | 비관적 락 | 분산 락 | +|------|----------|--------| +| 단일 인스턴스 500 VU | 동작함 (한계 근접) | 동작함 (여유 있음) | +| 더 높은 부하 | 커넥션 풀 고갈 위험 | Redis가 부하 흡수 | +| 스케일아웃 | 불가 | 가능 | +| 무중단 배포 | 정합성 위험 | 안전 | --- @@ -171,7 +217,7 @@ vus_max.......................: 500 | 과제 | 우선순위 | 설명 | |------|---------|------| -| p95 응답시간 최적화 | 중 | 대기열 시스템 도입으로 락 대기 시간 단축 검토 | -| Redis 고가용성 | 낮 | 현재 단일 Redis → 서비스 확장 시 Sentinel 또는 ElastiCache 검토 | +| 분산 락 모니터링 지표 수집 | 높 | 동일 조건에서 DBCP pending, CPU 비교 | +| 고부하 테스트 (700+ VU) | 중 | 비관적 락이 실제로 깨지는 VU 구간 확인 | +| 스파이크 테스트 | 중 | 0→500 즉시 폭증 패턴으로 실제 티켓팅 시나리오 검증 | | 다중 스케줄 부하테스트 | 중 | 실제 운영과 유사한 분산 트래픽 패턴에서 성능 검증 | -| ADR-001 상태 업데이트 | 낮 | `Superseded by ADR-003`으로 변경 | diff --git a/src/main/java/com/example/reservation_solution/api/event/repository/EventScheduleRepository.java b/src/main/java/com/example/reservation_solution/api/event/repository/EventScheduleRepository.java index 493aec1..50cdf4b 100644 --- a/src/main/java/com/example/reservation_solution/api/event/repository/EventScheduleRepository.java +++ b/src/main/java/com/example/reservation_solution/api/event/repository/EventScheduleRepository.java @@ -1,17 +1,7 @@ package com.example.reservation_solution.api.event.repository; import com.example.reservation_solution.api.event.domain.EventSchedule; -import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Lock; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -import java.util.Optional; public interface EventScheduleRepository extends JpaRepository, EventScheduleRepositoryCustom { - - @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query("SELECT es FROM EventSchedule es WHERE es.id = :id") - Optional findByIdWithLock(@Param("id") Long id); } diff --git a/src/main/java/com/example/reservation_solution/api/host/service/HostReservationFacade.java b/src/main/java/com/example/reservation_solution/api/host/service/HostReservationFacade.java index 523b944..68dcce8 100644 --- a/src/main/java/com/example/reservation_solution/api/host/service/HostReservationFacade.java +++ b/src/main/java/com/example/reservation_solution/api/host/service/HostReservationFacade.java @@ -1,6 +1,7 @@ package com.example.reservation_solution.api.host.service; -import com.example.reservation_solution.global.lock.DistributedLockService; +import com.example.reservation_solution.global.exception.LockAcquisitionException; +import com.example.reservation_solution.global.lock.LockExecutor; import com.example.reservation_solution.global.lock.LockKeyGenerator; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -10,12 +11,17 @@ public class HostReservationFacade { private final HostReservationService hostReservationService; - private final DistributedLockService distributedLockService; + private final LockExecutor lockExecutor; public void cancelReservation(Long reservationId, String hostEmail) { Long scheduleId = hostReservationService.getScheduleIdByReservationId(reservationId); String lockKey = LockKeyGenerator.schedule(scheduleId); - distributedLockService.executeWithLock(lockKey, - () -> hostReservationService.cancelReservation(reservationId, hostEmail)); + try { + lockExecutor.executeWithLock(lockKey, + () -> hostReservationService.cancelReservation(reservationId, hostEmail)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw LockAcquisitionException.interrupted(); + } } } diff --git a/src/main/java/com/example/reservation_solution/api/reservation/controller/ReservationController.java b/src/main/java/com/example/reservation_solution/api/reservation/controller/ReservationController.java index 625903d..e81af86 100644 --- a/src/main/java/com/example/reservation_solution/api/reservation/controller/ReservationController.java +++ b/src/main/java/com/example/reservation_solution/api/reservation/controller/ReservationController.java @@ -32,12 +32,6 @@ public ResponseEntity createReservation(@Valid @RequestBody return ResponseEntity.status(HttpStatus.CREATED).body(response); } - @PostMapping("/pessimistic") - public ResponseEntity createReservationWithPessimisticLock(@Valid @RequestBody ReservationRequest request) { - ReservationResponse response = reservationService.createReservationWithPessimisticLock(request); - return ResponseEntity.status(HttpStatus.CREATED).body(response); - } - @GetReservationDocs @GetMapping("/{id}") public ResponseEntity getReservation(@PathVariable Long id) { diff --git a/src/main/java/com/example/reservation_solution/api/reservation/service/ReservationFacade.java b/src/main/java/com/example/reservation_solution/api/reservation/service/ReservationFacade.java index 547aff2..013596b 100644 --- a/src/main/java/com/example/reservation_solution/api/reservation/service/ReservationFacade.java +++ b/src/main/java/com/example/reservation_solution/api/reservation/service/ReservationFacade.java @@ -2,7 +2,8 @@ import com.example.reservation_solution.api.reservation.dto.ReservationRequest; import com.example.reservation_solution.api.reservation.dto.ReservationResponse; -import com.example.reservation_solution.global.lock.DistributedLockService; +import com.example.reservation_solution.global.exception.LockAcquisitionException; +import com.example.reservation_solution.global.lock.LockExecutor; import com.example.reservation_solution.global.lock.LockKeyGenerator; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -12,18 +13,28 @@ public class ReservationFacade { private final ReservationService reservationService; - private final DistributedLockService distributedLockService; + private final LockExecutor lockExecutor; public ReservationResponse createReservation(ReservationRequest request) { String lockKey = LockKeyGenerator.schedule(request.getScheduleId()); - return distributedLockService.executeWithLock(lockKey, - () -> reservationService.createReservation(request)); + try { + return lockExecutor.executeWithLock(lockKey, + () -> reservationService.createReservation(request)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw LockAcquisitionException.interrupted(); + } } public void cancelReservation(Long reservationId) { Long scheduleId = reservationService.getScheduleIdByReservationId(reservationId); String lockKey = LockKeyGenerator.schedule(scheduleId); - distributedLockService.executeWithLock(lockKey, - () -> reservationService.cancelReservation(reservationId)); + try { + lockExecutor.executeWithLock(lockKey, + () -> reservationService.cancelReservation(reservationId)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw LockAcquisitionException.interrupted(); + } } } diff --git a/src/main/java/com/example/reservation_solution/api/reservation/service/ReservationService.java b/src/main/java/com/example/reservation_solution/api/reservation/service/ReservationService.java index a06ea58..d3d416d 100644 --- a/src/main/java/com/example/reservation_solution/api/reservation/service/ReservationService.java +++ b/src/main/java/com/example/reservation_solution/api/reservation/service/ReservationService.java @@ -26,25 +26,6 @@ public class ReservationService { private final FormQuestionRepository formQuestionRepository; private final EncryptionUtils encryptionUtils; - @Transactional - public ReservationResponse createReservationWithPessimisticLock(ReservationRequest request) { - EventSchedule schedule = eventScheduleRepository.findByIdWithLock(request.getScheduleId()) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 스케줄입니다.")); - String encryptedPhoneNumber = encryptionUtils.encrypt(request.getGuestPhoneNumber()); - checkDuplicateReservation(request.getScheduleId(), encryptedPhoneNumber); - schedule.incrementReservedCount(request.getTicketCount()); - Reservation reservation = Reservation.create( - schedule, - request.getGuestName(), - encryptedPhoneNumber, - request.getTicketCount() - ); - processFormAnswers(request, reservation); - validateRequiredQuestions(reservation, schedule); - Reservation savedReservation = reservationRepository.save(reservation); - return ReservationResponse.from(savedReservation, encryptionUtils); - } - @Transactional public ReservationResponse createReservation(ReservationRequest request) { EventSchedule schedule = loadEventScheduleOrThrow(request.getScheduleId()); diff --git a/src/main/java/com/example/reservation_solution/global/config/RedissonConfig.java b/src/main/java/com/example/reservation_solution/global/config/RedissonConfig.java deleted file mode 100644 index 0e32c5f..0000000 --- a/src/main/java/com/example/reservation_solution/global/config/RedissonConfig.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.example.reservation_solution.global.config; - -import org.redisson.Redisson; -import org.redisson.api.RedissonClient; -import org.redisson.config.Config; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@Configuration -public class RedissonConfig { - - @Value("${spring.data.redis.host}") - private String host; - - @Value("${spring.data.redis.port}") - private int port; - - @Bean - public RedissonClient redissonClient() { - Config config = new Config(); - config.useSingleServer() - .setAddress("redis://" + host + ":" + port); - return Redisson.create(config); - } -} diff --git a/src/main/java/com/example/reservation_solution/global/lock/DistributedLockService.java b/src/main/java/com/example/reservation_solution/global/lock/DistributedLockService.java deleted file mode 100644 index e16940f..0000000 --- a/src/main/java/com/example/reservation_solution/global/lock/DistributedLockService.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.example.reservation_solution.global.lock; - -import com.example.reservation_solution.global.exception.LockAcquisitionException; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.redisson.api.RLock; -import org.redisson.api.RedissonClient; -import org.springframework.stereotype.Service; - -import java.util.concurrent.TimeUnit; -import java.util.function.Supplier; - -@Slf4j -@Service -@RequiredArgsConstructor -public class DistributedLockService { - - private final RedissonClient redissonClient; - - private static final long WAIT_TIME_SECONDS = 5; - - public T executeWithLock(String lockKey, Supplier supplier) { - RLock lock = redissonClient.getLock(lockKey); - try { - boolean acquired = lock.tryLock(WAIT_TIME_SECONDS, TimeUnit.SECONDS); - if (!acquired) { - log.warn("[LOCK_TIMEOUT] key={}, waitTime={}s", lockKey, WAIT_TIME_SECONDS); - throw LockAcquisitionException.timeout(); - } - long start = System.currentTimeMillis(); - T result = supplier.get(); - long elapsed = System.currentTimeMillis() - start; - log.info("[LOCK_HELD] key={}, duration={}ms", lockKey, elapsed); - return result; - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - log.warn("[LOCK_INTERRUPTED] key={}", lockKey); - throw LockAcquisitionException.interrupted(); - } finally { - if (lock.isHeldByCurrentThread()) { - lock.unlock(); - } - } - } - - public void executeWithLock(String lockKey, Runnable runnable) { - executeWithLock(lockKey, () -> { - runnable.run(); - return null; - }); - } -} diff --git a/src/main/java/com/example/reservation_solution/global/lock/InMemoryLockExecutor.java b/src/main/java/com/example/reservation_solution/global/lock/InMemoryLockExecutor.java new file mode 100644 index 0000000..cc14799 --- /dev/null +++ b/src/main/java/com/example/reservation_solution/global/lock/InMemoryLockExecutor.java @@ -0,0 +1,40 @@ +package com.example.reservation_solution.global.lock; + +import com.example.reservation_solution.global.exception.LockAcquisitionException; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Supplier; + +@Component +@Profile("!redis") +public class InMemoryLockExecutor implements LockExecutor { + + private final ConcurrentHashMap lockMap = new ConcurrentHashMap<>(); + private static final long WAIT_TIME_SECONDS = 5; + + @Override + public T executeWithLock(String lockKey, Supplier supplier) throws InterruptedException { + ReentrantLock lock = lockMap.computeIfAbsent(lockKey, k -> new ReentrantLock(true)); + boolean acquired = lock.tryLock(WAIT_TIME_SECONDS, TimeUnit.SECONDS); + if (!acquired) { + throw LockAcquisitionException.timeout(); + } + try { + return supplier.get(); + } finally { + lock.unlock(); + } + } + + @Override + public void executeWithLock(String lockKey, Runnable runnable) throws InterruptedException { + executeWithLock(lockKey, () -> { + runnable.run(); + return null; + }); + } +} diff --git a/src/main/java/com/example/reservation_solution/global/lock/LockExecutor.java b/src/main/java/com/example/reservation_solution/global/lock/LockExecutor.java new file mode 100644 index 0000000..1acd2c9 --- /dev/null +++ b/src/main/java/com/example/reservation_solution/global/lock/LockExecutor.java @@ -0,0 +1,10 @@ +package com.example.reservation_solution.global.lock; + +import java.util.function.Supplier; + +public interface LockExecutor { + + T executeWithLock(String lockKey, Supplier supplier) throws InterruptedException; + + void executeWithLock(String lockKey, Runnable runnable) throws InterruptedException; +} diff --git a/src/test/java/com/example/reservation_solution/global/config/EmbeddedRedisConfig.java b/src/test/java/com/example/reservation_solution/global/config/EmbeddedRedisConfig.java deleted file mode 100644 index 44baffd..0000000 --- a/src/test/java/com/example/reservation_solution/global/config/EmbeddedRedisConfig.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.example.reservation_solution.global.config; - -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Profile; -import redis.embedded.RedisServer; - -import java.io.IOException; - -@Configuration -@Profile("test") -public class EmbeddedRedisConfig { - - private static final int REDIS_PORT = 6370; - private RedisServer redisServer; - - @PostConstruct - public void startRedis() throws IOException { - redisServer = new RedisServer(REDIS_PORT); - try { - redisServer.start(); - } catch (Exception e) { - // 이미 실행 중인 경우 무시 - } - } - - @PreDestroy - public void stopRedis() throws IOException { - if (redisServer != null && redisServer.isActive()) { - redisServer.stop(); - } - } -} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 63aa987..3fb594e 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -2,11 +2,6 @@ spring: application: name: ticket-form-test - data: - redis: - host: localhost - port: 6370 - # 1. 테스트용 가짜 DB (H2) 설정 datasource: url: jdbc:h2:mem:testdb;MODE=MySQL;DB_CLOSE_DELAY=-1