Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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<String, ReentrantLock>` — 키별 공정 락 (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

Expand All @@ -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`)

Expand All @@ -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/` 하위 자동 생성

## 필수 참조 문서
Expand Down
3 changes: 0 additions & 3 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
198 changes: 122 additions & 76 deletions docs/reports/stress-test-report-distributed-lock-20260309.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# 스트레스 테스트 리포트 — 분산 락 도입 Before/After 비교
# 스트레스 테스트 리포트 — 비관적 락 vs 분산 락 동일 인프라 비교

> 테스트일: 2026-03-09
> 환경: EC2 t3.small (2vCPU, 2GB) + Redis (같은 인스턴스)
Expand All @@ -8,21 +8,28 @@

## 1. 테스트 목적

비관적 락(`SELECT FOR UPDATE`)을 Redisson 분산 락으로 전환한 후,
500 VU 스트레스 테스트에서 **서버 에러율과 응답시간이 개선되었는지** 검증한다.
동일한 인프라(t3.small)에서 비관적 락(`SELECT FOR UPDATE`)과 Redisson 분산 락의
**성능 지표와 시스템 리소스 사용량을 비교**하여, 분산 락 도입의 근거를 검증한다.

---

## 2. 테스트 조건

| 항목 | 값 |
|------|-----|
| 스크립트 | `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` |

### 성공/실패 판정 기준

Expand All @@ -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
Expand All @@ -156,22 +194,30 @@ 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가 부하 흡수 |
| 스케일아웃 | 불가 | 가능 |
| 무중단 배포 | 정합성 위험 | 안전 |

---

## 8. 후속 과제

| 과제 | 우선순위 | 설명 |
|------|---------|------|
| p95 응답시간 최적화 | 중 | 대기열 시스템 도입으로 락 대기 시간 단축 검토 |
| Redis 고가용성 | 낮 | 현재 단일 Redis → 서비스 확장 시 Sentinel 또는 ElastiCache 검토 |
| 분산 락 모니터링 지표 수집 | 높 | 동일 조건에서 DBCP pending, CPU 비교 |
| 고부하 테스트 (700+ VU) | 중 | 비관적 락이 실제로 깨지는 VU 구간 확인 |
| 스파이크 테스트 | 중 | 0→500 즉시 폭증 패턴으로 실제 티켓팅 시나리오 검증 |
| 다중 스케줄 부하테스트 | 중 | 실제 운영과 유사한 분산 트래픽 패턴에서 성능 검증 |
| ADR-001 상태 업데이트 | 낮 | `Superseded by ADR-003`으로 변경 |
Original file line number Diff line number Diff line change
@@ -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<EventSchedule, Long>, EventScheduleRepositoryCustom {

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT es FROM EventSchedule es WHERE es.id = :id")
Optional<EventSchedule> findByIdWithLock(@Param("id") Long id);
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,6 @@ public ResponseEntity<ReservationResponse> createReservation(@Valid @RequestBody
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}

@PostMapping("/pessimistic")
public ResponseEntity<ReservationResponse> createReservationWithPessimisticLock(@Valid @RequestBody ReservationRequest request) {
ReservationResponse response = reservationService.createReservationWithPessimisticLock(request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}

@GetReservationDocs
@GetMapping("/{id}")
public ResponseEntity<ReservationResponse> getReservation(@PathVariable Long id) {
Expand Down
Loading