선착순 한정 수량 상품을 구매할 수 있는 이커머스 플랫폼입니다. 회원가입/로그인부터 상품 탐색, 장바구니, 주문, 결제, 리뷰까지 이커머스 핵심 플로우를 구현했습니다.
- ✨ 프로젝트 소개
- 🛠️ 기술 스택
- 🏗️ 시스템 아키텍처
- 📊 ERD
- 💡 기술적 의사결정
- 🏛️ 도메인 설계 원칙
- 📈 V1 부하 테스트 & 성능 최적화
- 🚀 V2 / Scale-Out 검증
- 📊 V1 vs V2 최종 비교
Flash Deal은 한정 수량 상품을 선착순으로 구매하는 이커머스 플랫폼입니다.
단순한 기능 구현을 넘어, 서비스가 성장하면서 마주치는 실제 성능 병목을 직접 재현하고, 여러 기술을 비교·분석한 뒤 트레이드오프를 고려하여 해결책을 선택하는 과정을 담은 포트폴리오 프로젝트입니다.
| 🗓️ 개발 기간 | 2026.02 ~ 진행 중 |
| 👤 개발 인원 | 1인 (개인 프로젝트) |
| 🌐 배포 URL | https://flash-deal-eight.vercel.app |
| 📄 API 명세 | https://flashdeal.서버.한국/swagger-ui/index.html |
| 도메인 | 주요 기능 |
|---|---|
| 🔐 Auth | 회원가입, 로그인, 로그아웃 |
| 👤 Member | 프로필 조회/수정, 비밀번호 변경 |
| 📦 Product | 상품 등록/수정/삭제/검색 (어드민), 상품 조회/검색 (일반) |
| 🛒 Cart | 장바구니 담기/조회/수량 수정/삭제/비우기 |
| ⚡ Deal | 딜 목록/상세 조회, 선착순 딜 주문, 딜 주문 취소 |
| 📝 Order | 장바구니 주문, 바로 구매, 주문 조회/취소, 배송 관리 (어드민) |
| 💳 Payment | 결제 처리, TossPayments 승인, 결제 조회, 환불 |
| ⭐ Review | 구매 확인 후 리뷰 작성, 상품별 리뷰 조회 |
단순한 기능 구현에서 끝나지 않고, 부하 테스트로 병목을 재현한 뒤 아키텍처를 단계적으로 진화시켰습니다.
| 단계 | 핵심 변화 | 해결한 문제 |
|---|---|---|
| MVP | 단일 서버에 모든 컴포넌트 배치 | 핵심 기능 구현 및 배포 |
| V1 | 서버 분리 + 비관적 락 + 트랜잭션 분리 | Race Condition, Connection Pool 고갈 |
| V2 | 3대 Scale-Out + Redis 세션/캐시 + Lua Script | CPU 포화, 세션 불일치, 캐시 불일치, 락 직렬화 |
단일 서버에 App, DB, Monitoring을 모두 배치한 초기 구성. 핵심 도메인(주문, 결제, 상품) 기능 구현에 집중.
Nginx 리버스 프록시 + Spring Boot 단일 서버 구성. 비관적 락과 트랜잭션 분리로 정합성을 확보했지만, CPU 2코어 포화와 비관적 락 직렬화로 300명부터 전체 API가 붕괴.
NCP Load Balancer + App Server 3대로 확장. Spring Session + Redis로 세션 공유, Redis 공용 캐시로 서버 간 일관성 확보, Lua Script로 DB row lock 의존 제거. 300명 시나리오에서 전체 TPS 7.7배 증가, 에러율 0%.
src/main/java/com/prj/flashdeal/
├── domain/
│ ├── auth/ # 인증 (회원가입, 로그인)
│ ├── member/ # 회원 관리
│ ├── product/ # 상품 관리
│ ├── stock/ # 재고 관리
│ ├── cart/ # 장바구니
│ ├── order/ # 주문
│ ├── payment/ # 결제
│ ├── deal/ # 선착순 딜
│ ├── review/ # 리뷰
│ └── file/ # 파일 업로드
└── global/
├── config/ # Security, QueryDSL, S3 설정
├── entity/ # BaseEntity (createdAt, updatedAt)
├── exception/ # 공통 예외 처리
├── response/ # ApiResponse, PageResponse
└── security/ # CustomUserDetails, SecurityConfig
주요 Enum 값
| 테이블 | 컬럼 | 값 |
|---|---|---|
| MEMBER | role | USER, ADMIN |
| MEMBER | status | ACTIVE, DORMANT, WITHDRAWN, BANNED |
| PRODUCT | status | PREPARING, ON_SALE, SOLD_OUT |
| ORDERS | status | PENDING, PAID, SHIPPED, DELIVERED, CANCELED |
| PAYMENT | status | PENDING, COMPLETED, REFUNDED |
| PAYMENT | method | CARD, CASH, TRANSFER, TOSS |
| DEAL | status | SCHEDULED, ACTIVE, ENDED |
| DEAL_ORDER | status | PENDING, PAID, CANCELED |
🔐 "JWT가 더 좋다고요?" — 즉시 무효화가 필요한 서비스에서의 선택
배경
인증 방식을 설계할 때 JWT와 세션 중 하나를 선택해야 했습니다. 많은 프로젝트에서 JWT가 "무상태, 스케일아웃에 유리"하다는 이유로 기본처럼 선택되지만, 먼저 이 서비스의 특성을 고민했습니다.
고민
| 방식 | 장점 | 단점 |
|---|---|---|
| 세션 | 서버에서 즉시 무효화 가능, 구현 단순 | 스케일아웃 시 서버 간 세션 공유 필요 |
| JWT | 무상태, 스케일아웃 용이 | 만료 전까지 서버가 토큰을 강제 무효화 불가 |
JWT의 근본적인 문제는 "발급된 토큰은 만료 전까지 서버가 막을 수 없다" 는 점입니다. 블랙리스트로 해결할 수 있지만, 결국 Redis 등 외부 저장소에 상태를 저장하는 것으로 "무상태"라는 JWT의 핵심 장점이 퇴색됩니다.
Flash Deal은 선착순 한정 수량 구매 플랫폼으로, 어뷰징 감지 시 즉각적인 계정 차단이 중요합니다. 이 요구사항 앞에서 JWT는 구조적으로 취약합니다.
결론 — 세션 채택
- 현재 단일 서버 환경에서 세션은 구현 복잡도가 낮고, 서버 측 즉시 무효화가 가능합니다
- JWT + 블랙리스트 조합은 "무상태"를 포기하면서도 세션보다 복잡합니다
- 이 서비스 규모에서 JWT의 장점(스케일아웃)은 아직 필요 없고, 단점(무효화 불가)은 치명적입니다
개선 계획 (V2)
스케일아웃(3대 서버) 시 세션 불일치 문제 발생 → Spring Session + Redis로 세션 저장소를 외부화하여 해결 예정
🔍 "런타임이 아닌 컴파일 타임에 잡는다" — QueryDSL 타입 안전 동적 쿼리
배경
상품 검색은 이름, 상태, 가격 범위 등 조건이 동적으로 조합됩니다. 이를 구현하는 방법은 여러 가지가 있었습니다.
고민
| 방식 | 문제 |
|---|---|
| JPQL 문자열 조합 | 타입 안전하지 않음, 오타가 런타임에야 발견됨 |
| Specification | 코드 가독성 저하, 복잡한 조인 조건에서 한계 |
| QueryDSL | 타입 안전, IDE 자동완성, 컴파일 타임 오류 감지 |
결론 — QueryDSL 채택
// 조건이 null이면 자동으로 무시되는 타입 안전한 동적 쿼리
BooleanBuilder builder = new BooleanBuilder();
if (name != null) builder.and(product.name.containsIgnoreCase(name));
if (minPrice != null) builder.and(product.price.goe(minPrice));
if (maxPrice != null) builder.and(product.price.loe(maxPrice));*RepositoryCustom 인터페이스 + *RepositoryCustomImpl 구현체 패턴으로 Spring Data JPA와 QueryDSL을 함께 사용합니다.
컬럼명 오타나 타입 불일치는 컴파일 시점에 즉시 감지됩니다.
🧩 "Service가 아닌 Entity가 스스로 검증한다" — Rich Domain Model 채택
배경
비즈니스 규칙을 서비스 계층에만 두면 엔티티가 단순 데이터 컨테이너(Anemic Domain Model)로 전락하고, 같은 검증 로직이 여러 서비스에 흩어져 한 곳에서라도 빠뜨리면 바로 버그가 됩니다.
고민
| 방식 | 문제 |
|---|---|
| Anemic Domain Model | 검증 로직이 서비스마다 중복, 누락 시 버그 |
| Rich Domain Model | 엔티티가 자신의 상태를 스스로 지킴 |
결론 — 핵심 규칙을 엔티티 메서드로 캡슐화
member.validateActive() // ACTIVE가 아니면 즉시 예외
product.validateVisibleToUser() // ON_SALE + 미삭제가 아니면 즉시 예외
order.cancel() // 취소 가능 상태 검증 후 CANCELED 전환
order.ship() / order.deliver() // 배송 상태 전환 시 순서 검증 포함상태 변경과 검증이 엔티티 안에 있어 서비스에서 검증을 빠뜨릴 가능성 자체가 사라지고, 엔티티 단위 테스트만으로 핵심 비즈니스 규칙을 검증할 수 있습니다.
싱글 서버(NCP vCPU 2EA, 8GB RAM)에서 선착순 딜 주문의 성능 한계를 단계적으로 탐색하고, 병목 원인을 데이터로 증명한 뒤, 최적화 적용 후 Before/After를 비교한 기록입니다.
| 항목 | 스펙 |
|---|---|
| 서버 | NCP vCPU 2EA, RAM 8GB, Ubuntu |
| WAS | Spring Boot 3.5.6 (내장 Tomcat) |
| DB | NCP Cloud DB for MySQL (vCPU 2EA, RAM 8GB) |
| HikariCP | max-pool-size: 10 (기본값) |
| 세션 | In-Memory (서버 메모리) |
| 테스트 도구 | Apache JMeter 5.6.3 |
| 모니터링 | 별도 모니터링 서버 구성 (Grafana + Prometheus) |
"비관적 락 + 트랜잭션 분리" — 정합성은 해결, 하지만 성능은 여전히
// Before: lock 없는 조회
Stock stock = stockRepository.findByProductId(productId);
// After: SELECT FOR UPDATE — 한 트랜잭션씩 순차 처리
Stock stock = stockRepository.findByProductIdWithLock(productId);Spring 프록시의 내부 호출 한계를 해결하기 위해 DealOrderTransactionService를 별도 빈으로 분리했습니다.
// Before: 하나의 @Transactional에서 결제까지 실행 → 500~1000ms 커넥션 점유
@Transactional
public OrderResponse createDealOrder(...) {
stockService.decreaseStock(...);
fakePaymentClient.pay(...);
order.completePayment(...);
}
// After: 트랜잭션 3단계 분리
public OrderResponse createDealOrder(...) {
// TX1: 검증 + 재고 차감 → 커넥션 즉시 반환
DealOrderContext ctx = dealOrderTxService.validateAndDecreaseStock(...);
// 결제: 트랜잭션 밖 → DB 커넥션 미점유
fakePaymentClient.pay(...);
// TX2: 주문 생성 + 결제 완료
return dealOrderTxService.completeOrder(ctx, request);
}| 동시 사용자 | Before 초과 판매 | After 초과 판매 | Before Error | After Error | Before Pending | After Pending |
|---|---|---|---|---|---|---|
| 10명 | 9건 | 0건 | 30% | 0% | 0 | 0 |
| 100명 | ~72건 | 0건 | 17% | 0% | 3 | 16 |
| 300명 | ~205건 | 0건 | 21.33% | 0% | 30 | 0 |
| 500명 | ~346건 | 0건 | 20.80% | 0% | 164 | 4 |
| 1000명 | ~700건 | 0건 | 20.30% | 0.50% | 42 | 0 |
| 병목 | 해결 방법 | 효과 |
|---|---|---|
| Race Condition | 비관적 락 (SELECT FOR UPDATE) | 초과 판매 0건 — 데이터 정합성 100% 보장 |
| Connection Pool 고갈 | 트랜잭션 분리 (결제를 TX 밖으로) | 500명 기준 Pending 164 → 4 (97% 감소) |
| 한계 | 수치 (1000명 기준) | 원인 |
|---|---|---|
| 딜 주문 평균 응답시간 | 38초 | 비관적 락이 1000건을 순차 처리 |
| Connection Timeout | 5회 | 순차 처리로 인한 대기 시간 누적 |
| 모니터링 공백 | 약 2분 | 서버 자원 한계 |
비관적 락으로 데이터 정합성은 완전히 해결했지만, 처리 성능은 단일 서버 + 단일 DB의 물리적 한계입니다. 여기서 바로 서버를 늘리는 것은 "느린 서버를 3대 띄우는 것" 에 불과합니다. 단일 서버에서 할 수 있는 최적화를 모두 시도하고, 그래도 한계가 올 때 비로소 스케일아웃의 근거가 됩니다.
"쿼리를 91% 줄여도 TPS가 안 변한다?" — N+1 해결과 Caffeine 캐싱
스케일아웃 전에 단일 서버에서 뽑아낼 수 있는 최대 성능을 먼저 확인한다. 최적화 항목별로 단일 API 테스트(100 Threads, Duration 60초)로 효과를 격리 측정한다.
Hibernate SQL 로그를 분석한 결과, 딜 목록 조회 1회에 22개 쿼리가 발생했다.
요청 1회 → 22개 쿼리
├── SELECT ... FROM deals LIMIT ?, ? (1개: 딜 목록)
├── SELECT count(deal_id) FROM deals (1개: 페이징 count)
├── SELECT ... FROM stocks WHERE product_id=? (10개: 딜마다 재고 N+1)
└── SELECT ... FROM products WHERE product_id=? (10개: 딜마다 상품 N+1)
QueryDSL JOIN + DTO Projection으로 22개 → 2개 쿼리로 통합했다.
queryFactory
.select(Projections.constructor(DealResponse.class, ...))
.from(deal)
.join(deal.product, product)
.leftJoin(stock).on(stock.productId.eq(product.id))
.orderBy(deal.createdAt.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();| 지표 | Before (N+1) | After (JOIN) | 변화 |
|---|---|---|---|
| 쿼리 수 | 22개 | 2개 | 91% 감소 |
| TPS | 31.4/sec | 30.0/sec | 거의 동일 |
쿼리를 91% 줄였는데 TPS가 변하지 않았다. HikariCP 커넥션 풀(10개)이 진짜 병목이기 때문이다. 쿼리를 아무리 줄여도 커넥션 10개라는 물리적 한계는 그대로 → DB 접근 자체를 제거해야 한다.
| 결정 | 이유 |
|---|---|
| Caffeine (로컬 캐시) 채택 | 단일 서버에서는 JVM 내 캐시가 네트워크 비용 없이 가장 빠름 |
| Redis 배제 | 다중 서버 간 캐시 일관성이 필요한 V2에서 도입 |
| TTL 5초 | 재고 변동을 적절히 반영하면서 DB 부하를 줄이는 균형점 |
@Cacheable(value = "deals", key = "#pageable.pageNumber + '-' + #pageable.pageSize")
@Transactional(readOnly = true)
public PageResponse<DealResponse> getDeals(Pageable pageable) {
Page<DealResponse> page = dealRepository.findDealsWithStock(pageable);
return new PageResponse<>(page);
}100명 테스트:
| 지표 | Before (JOIN만) | After (JOIN + Cache) | 개선율 |
|---|---|---|---|
| Avg 응답시간 | 3,125ms | 23ms | 99.3% 감소 |
| TPS | 30.0/sec | 4,037.9/sec | 134배 증가 |
300명으로 부하 증가:
| 지표 | 100명 | 300명 | 변화 |
|---|---|---|---|
| TPS | 4,037.9/sec | 1,406.7/sec | 65% 감소 |
| CPU Max | 71.5% | 포화 | - |
캐시로 DB 병목은 해결했지만, 새로운 병목 — CPU 2코어. 300명에서는 컨텍스트 스위칭 오버헤드로 TPS가 오히려 떨어진다.
LAZY 로딩으로 인한 3개 쿼리를 1개로 통합하고, Caffeine 캐시를 적용했다.
| 지표 | Before (JOIN만) | After (JOIN + Cache) | 개선율 |
|---|---|---|---|
| Avg 응답시간 | 67ms | 20ms | 70% 감소 |
| TPS | 1,425.4/sec | 4,699.7/sec | 3.3배 증가 |
딜 주문은 SELECT ... FOR UPDATE로 재고 정합성을 보장하므로 반드시 DB를 거쳐야 하며, 캐싱 대상이 아니다.
| 지표 | 100명 | 300명 | 변화 |
|---|---|---|---|
| 딜 주문 TPS | 12.9/sec | 12.8/sec | 변화 없음 |
| 딜 주문 Avg | 6,824ms | 17,938ms | 대기 시간만 증가 |
100명이든 300명이든 TPS ~12.9로 동일. 단일 MySQL row lock은 초당 ~13건이 물리적 한계.
| API | Before Avg | Before TPS | After Avg | After TPS | 개선율 |
|---|---|---|---|---|---|
| 딜 목록 조회 | 2,989ms | 31.4 | 23ms | 4,037.9 | 130배 |
| 딜 상세 조회 | 67ms | 1,425.4 | 20ms | 4,699.7 | 3.3배 |
| 딜 주문 | 6,824ms | 12.9 | 6,824ms | 12.9 | 변화 없음 |
조회 API는 극적으로 개선했지만, 주문 API는 비관적 락이라는 구조적 한계 때문에 코드 레벨로는 더 이상 개선할 수 없다.
"100명은 버티지만 300명은 무너진다" — 시나리오 테스트와 단일 서버의 한계
단일 API 테스트에서는 각 API가 최대 성능을 보여줬지만, 실제 서비스는 로그인 + 조회 + 주문이 동시에 발생한다. 모든 최적화를 적용한 상태에서 시나리오 테스트로 최종 검증한다.
1. 로그인 POST /api/auth/login (Once Only Controller — 스레드당 1회)
2. 전원 로그인 완료 대기 (Synchronizing Timer)
3. 딜 목록 조회 GET /api/deals ← Think Time 1~3초 (Uniform Random Timer)
4. 딜 상세 조회 GET /api/deals/{id} ← Think Time 1~3초
5. 딜 주문 POST /api/deals/{id}/order
| API | Avg (ms) | Error % | TPS |
|---|---|---|---|
| 로그인 | 189 | 0% | 14.8/sec |
| 딜 목록 조회 | 21 | 0% | 11.6/sec |
| 딜 상세 조회 | 153 | 0% | 11.7/sec |
| 딜 주문 | 945 | 0% | 11.4/sec |
| 인프라 | 값 |
|---|---|
| CPU Max | 74.5% |
| Connection Timeout | 7 |
| 재고 | 100,000 → 99,400 (600건 정확 차감) |
딜 목록 21ms, 딜 상세 153ms, 딜 주문 945ms — 모두 SLO 이내. 100명까지는 단일 서버로 안정적이다.
| API | Avg (ms) | Error % | TPS |
|---|---|---|---|
| 로그인 | 2,151 | 0% | 29.3/sec |
| 딜 목록 조회 | 1,214 | 0% | 13.7/sec |
| 딜 상세 조회 | 5,447 | 0% | 12.9/sec |
| 딜 주문 | 7,196 | 0% | 12.3/sec |
| 인프라 | 값 |
|---|---|
| CPU Max | 0.7% (처리를 못하고 있음) |
| HikariCP Active | 0 (커넥션 획득 자체가 안 됨) |
| 재고 | 100,000 → ~99,270 (극소량 차감) |
| API | 100명 Avg | 300명 Avg | 악화 |
|---|---|---|---|
| 딜 목록 조회 | 21ms | 1,214ms | 58배 |
| 딜 상세 조회 | 153ms | 5,447ms | 36배 |
| 딜 주문 | 945ms | 7,196ms | 7.6배 |
핵심 원인: 커넥션 풀 경합 + 직렬 파이프라인 효과
- 주문 API가 커넥션을 독점한다 —
SELECT FOR UPDATE로 한 번에 1건만 처리. 10개 커넥션이 락 대기에 묶인다. - 조회 API도 커넥션을 못 받는다 — 캐시 MISS 시 DB 커넥션이 필요한데, 커넥션이 모두 주문에 점유당해 대기열에 갇힌다.
- 파이프라인 전체가 누적 지연된다 — 앞 단계가 느려지면 뒤 단계도 밀린다.
증거: CPU 0.7%(처리를 못하는 것), HikariCP Active 0(커넥션 획득 불가), 재고 거의 미차감(주문 미처리)
| 한계 | 증거 | 원인 |
|---|---|---|
| CPU 2코어 | 캐시 적용 후 100명 CPU 71.5%, 300명에서 TPS 하락 | 컨텍스트 스위칭 오버헤드 |
| 비관적 락 직렬화 | 주문 TPS ~12.9 고정 (스레드 수 무관) | 단일 MySQL row lock |
| 커넥션 풀 10개 공유 | 300명 시나리오에서 전체 API 응답시간 폭증 | 주문이 커넥션 독점 → 조회까지 연쇄 지연 |
이 세 가지는 코드가 아니라 인프라의 제약이다. 캐시, JOIN, 트랜잭션 분리는 이미 적용했고, 남은 병목은 서버 한 대의 물리적 자원과 단일 DB의 락 구조다.
| 한계 | V2 해결 방향 |
|---|---|
| CPU 2코어 | 앱 서버 3대 + Nginx 로드밸런서 |
| 비관적 락 TPS 한계 | Redis Lua Script로 atomic 재고 차감 |
| 커넥션 풀 경합 | 서버 3대 = 커넥션 30개 |
| 세션 불일치 | Spring Session + Redis |
V2는 단순히 서버 수를 늘려보는 단계가 아니었습니다.
V1에서 조회 최적화, 비관적 락, 트랜잭션 분리까지 먼저 적용한 뒤 다시 부하 테스트를 수행했고, 그 결과 단일 서버 구조에서는 더 이상 코드 레벨 최적화만으로 넘기기 어려운 한계가 명확해졌습니다.
- 주문 TPS는 약
12.8~12.9/sec수준에서 고정되었고 - 300명 시나리오에서는 로그인/조회/주문 전체가 함께 지연되었으며
- 서버를 여러 대로 확장하자 세션 불일치와 로컬 캐시 불일치가 새롭게 드러났습니다
즉 V2는 단일 서버 구조에서 드러난 주문 병목, 상태 공유 문제, 캐시 일관성 문제를 분산 환경에 맞게 다시 설계하는 단계였습니다.
| 항목 | 스펙 |
|---|---|
| 서버 | NCP vCPU 2EA, RAM 8GB, Ubuntu × 3대 |
| WAS | Spring Boot 3.5.6 (내장 Tomcat) |
| 로드밸런서 | NCP Load Balancer (Round Robin) |
| DB | NCP Cloud DB for MySQL (vCPU 2EA, RAM 8GB) |
| 캐시 / 세션 | NCP Cloud DB for Cache (Redis) — 세션 저장소 + 공용 캐시 + Lua Script 재고 차감 |
| HikariCP | max-pool-size: 10 (서버당) → 총 30개 |
| 세션 | Spring Session + Redis (서버 간 공유) |
| 테스트 도구 | Apache JMeter 5.6.3 |
| 모니터링 | 별도 모니터링 서버 구성 (Grafana + Prometheus) |
V2의 목표는 단순히 서버 수를 늘리는 것이 아니라, V1에서 확인한 병목이 다중 서버 환경에서 어디까지 완화되고, 무엇은 여전히 구조적으로 남는지를 검증하는 것이었습니다.
| V1 한계 | 현재 원인 | V2 방향 |
|---|---|---|
| 주문 TPS가 약 13/sec 수준에서 고정 | MySQL row-level 비관적 락 직렬화 | Redis Lua Script 기반 atomic 재고 차감 |
| 300명 시나리오에서 전체 API 지연 폭증 | 주문이 커넥션을 점유해 조회까지 연쇄 지연 | 앱 서버 다중화 + 커넥션 분산 |
| CPU 2코어 한계로 조회 TPS 하락 | 단일 서버 물리적 자원 한계 | 앱 서버 3대 + 로드밸런서 |
| 세션 공유 불가 | In-Memory 세션 | Spring Session + Redis |
| 로컬 캐시 불일치 | 서버별 JVM 로컬 캐시 | Redis 공용 캐시 |
"왜 자꾸 로그인이 풀리나?" — 세션 불일치 재현과 해결
V1에서는 세션을 각 애플리케이션 서버 메모리에 저장했습니다. 단일 서버에서는 문제가 없었지만, V2에서 앱 서버를 3대로 늘리고 NCP Load Balancer의 Round Robin 분산을 적용하자 로그인은 성공해도 인증이 필요한 API가 일부 서버에서 실패하는 문제가 발생했습니다.
- 환경: App Server 3대 + NCP Load Balancer + In-Memory Session
- 시나리오: 로그인 1회 후
/api/members/me20회 호출 - 결과: 로그인은 성공했지만 내 정보 조회는
6 성공 / 14 실패
원인은 로그인 세션이 특정 서버 메모리에만 저장되기 때문이었습니다. 로드밸런서가 다음 요청을 다른 서버로 보내면, 해당 서버는 같은 JSESSIONID를 받아도 세션을 찾지 못했습니다.
Target Group에서 Sticky Session을 활성화해 같은 사용자의 요청이 같은 서버로 고정되도록 설정했습니다.
같은 시나리오를 다시 실행하자 /api/members/me 20회가 모두 성공했습니다.
| 구분 | 결과 |
|---|---|
| 적용 전 | 6 성공 / 14 실패 |
| 적용 후 | 20 성공 / 0 실패 |
하지만 Sticky Session은 세션을 공유한 것이 아니라, 같은 사용자를 같은 서버로 계속 보내는 방식이었습니다. 서버 장애 시 세션이 함께 사라지고, 특정 서버 쏠림도 발생할 수 있어 최종안으로는 적절하지 않았습니다.
최종적으로는 세션 저장소를 애플리케이션 서버 메모리 밖으로 분리했습니다.
Spring Session + Redis적용- 세션 저장 위치를 서버 메모리에서 Redis로 이전
- 어느 서버가 요청을 받아도 같은
JSESSIONID로 Redis에서 세션 조회 가능
실제로 Redis에도 아래와 같은 세션 키가 저장되는 것을 확인했습니다.
flashdeal:session:sessions:<sessionId>flashdeal:session:sessions:expires:<sessionId>flashdeal:session:expirations:<timestamp>flashdeal:session:index:org.springframework.session.FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME:<email>
이를 통해 V2에서는 Sticky Session 없이도, 어느 서버가 요청을 받더라도 동일한 로그인 상태를 유지할 수 있는 구조를 적용했습니다.
"왜 어떤 서버는 새 딜이 보이고, 어떤 서버는 안 보일까?" — 로컬 캐시 불일치 해결
Scale-Out 환경에서 각 앱 서버가 로컬 Caffeine 캐시를 사용하자, 서버마다 서로 다른 캐시 데이터를 보유하게 되었습니다.
실제로 서버 3대에서 모두 딜 목록 캐시를 적재한 뒤, 한 서버에서 새로운 딜을 생성하고 다시 조회해보니:
- 어떤 서버는 최신 딜이 포함된 응답을 반환했고
- 어떤 서버는 이전에 저장된 오래된 캐시 데이터를 그대로 반환했습니다
즉, 같은 시점에도 서버마다 다른 응답을 반환하는 캐시 불일치 문제가 발생했습니다.
서버 3대에서 동일한 딜 목록 캐시를 먼저 적재
한 서버에서 새로운 딜 생성
같은 시점에도 서버마다 서로 다른 딜 목록을 반환
기존 캐시는 각 서버 JVM 메모리에 저장되는 로컬 캐시(Caffeine) 였습니다.
따라서:
app-server-1에서 캐시를 비워도app-server-2,app-server-3의 캐시는 그대로 남아 있었고- 로드밸런서를 통해 들어오는 요청은 서버마다 다른 결과를 반환할 수 있었습니다.
처음에는 딜 메타데이터가 자주 변경되지 않는다는 점에서,
로컬 캐시 + 짧은 TTL 만으로도 충분하지 않을까 고민했습니다.
실제로 딜은 관리자가 미리 등록해두고, 사용자는 정해진 시간에 접속해 조회하고 구매하는 흐름에 가깝습니다.
즉 재고처럼 초 단위로 계속 바뀌는 데이터가 아니기 때문에, 메타데이터만 놓고 보면 로컬 캐시도 충분히 현실적인 선택이었습니다.
하지만 V2는 단순히 조회 성능을 높이는 것이 아니라,
Scale-Out 환경에서 서버 간 동일한 데이터를 안정적으로 제공할 수 있는 구조를 만드는 것이 더 중요했습니다.
로컬 캐시는 불일치를 줄일 수는 있지만, 서버마다 캐시가 분리되어 있기 때문에
구조적으로 같은 시점의 일관성을 보장하지는 못한다고 판단했습니다.
Redis 공용 캐시는:
- 여러 앱 서버가 동일한 캐시 데이터를 바라볼 수 있고
- Scale-Out 환경에서도 캐시 일관성을 유지할 수 있으며
- 이후 세션 저장소, 조회 캐시 등 공통 인프라 계층으로 확장하기에도 적합했습니다.
그래서 최종적으로는:
- 딜 메타데이터는 Redis 공용 캐시
- 재고는 DB 실시간 조회
- 주문 시점은 항상 DB 기준 재검증
구조를 선택했습니다.
한정판매 이커머스에서는 모든 데이터를 캐시하면 안 된다고 판단했습니다.
- 캐시 가능한 데이터
- 딜 제목, 설명, 이미지, 할인 가격, 시작/종료 시간 같은 메타데이터
- 캐시하면 안 되는 데이터
- 재고 수량
- 품절 여부
- 실제 주문 가능 여부
특히 재고는 조금만 오래된 값이 보여도 오버셀링으로 이어질 수 있기 때문에,
정합성이 성능보다 우선이라고 판단했습니다.
그래서 딜 상세 조회는 아래 두 영역으로 분리했습니다.
- 캐시 가능 영역
- 딜/상품 메타데이터
- 실시간 조회 영역
- 현재 재고 정보
조회 흐름은 다음과 같습니다.
- Redis에서 딜 메타데이터 조회
- DB에서 현재 재고 조회
- 두 데이터를 조합해 최종 응답 생성
GET /api/deals/{dealId}
-> Redis: 딜 메타데이터 조회
-> DB: 현재 재고 조회
-> DealResponse 조합
즉, "보여주는 정보"와 "판단 기준 정보"를 분리했습니다.
책임을 분리하기 위해 캐시 조회 서비스와 오케스트레이션 로직을 나눴습니다.
DealCacheService@Cacheable로 딜 메타데이터 캐시 조회
StockService- 현재 재고 실시간 조회
DealService- 메타데이터 + 재고를 조합해 최종
DealResponse반환
- 메타데이터 + 재고를 조합해 최종
@Cacheable(value = "deal", key = "#dealId")
public DealDetailCacheValue getDealDetailMetadata(Long dealId) {
return dealRepository.findDealDetailCacheValueById(dealId)
.orElseThrow(() -> new CustomException(DealErrorCode.DEAL_NOT_FOUND));
}
DealCacheService는 캐시 가능한 딜 메타데이터만 Redis에서 조회합니다.
public DealResponse getDeal(Long dealId) {
DealDetailCacheValue metadata = dealCacheService.getDealDetailMetadata(dealId);
Stock stock = stockService.getStock(metadata.productId());
return metadata.toResponse(stock.getQuantity());
}
DealService는 Redis에서 가져온 메타데이터와 DB에서 조회한 재고를 조합해 최종 응답을 생성합니다.
딜 조회 캐시를 설계하면서, 읽기 전략은 Cache-Aside, 쓰기 전략은 Write-Invalidate를 선택했습니다.
먼저 읽기 전략에서는 Read-Through도 고려할 수 있었지만,
이 프로젝트는 모든 데이터를 캐시하지 않고 딜 메타데이터만 캐시하고 재고는 실시간 조회해야 했습니다.
즉 캐시 대상과 비캐시 대상을 애플리케이션에서 세밀하게 분리해야 했기 때문에,
캐시 조회와 DB 조회 흐름을 직접 제어할 수 있는 Cache-Aside가 더 적합하다고 판단했습니다.
쓰기 전략에서는 Write-Through도 검토했지만,
딜 생성/변경 시점마다 상세 캐시와 페이지 단위 목록 캐시를 모두 즉시 갱신하는 것은 구현 복잡도가 높았습니다.
특히 목록 캐시는 페이지, 크기, 향후 정렬/필터 조건에 따라 여러 캐시 키가 생길 수 있어,
쓰기 시점에 정확한 값을 모두 갱신하는 것보다 DB를 먼저 반영한 뒤 관련 캐시를 비우고, 다음 조회 요청에서 최신 데이터를 다시 적재하는 Write-Invalidate 방식이 더 단순하고 안전하다고 판단했습니다.
결과적으로 이 프로젝트의 캐시 전략은 다음과 같이 정리했습니다.
- 읽기:
Cache-Aside - 쓰기:
Write-Invalidate - 재고: 캐시하지 않고 DB 실시간 조회
- 주문 시점: 항상 DB 기준 재검증
즉 성능만 극대화하는 전략보다,
한정판매 이커머스에서 중요한 정합성과 Scale-Out 환경의 일관성을 함께 고려한 전략을 선택했습니다.
처음에는 한정판매 딜의 특성상, 사용자에게 동시에 노출되는 딜 수가 많지 않을 것이라고 판단했습니다.
그래서 처음에는 첫 페이지 캐시 또는 페이지네이션 제거까지도 검토했습니다.
하지만 이 방향을 그대로 선택하기에는 한 가지 문제가 있었습니다.
한정판매 딜은 평소에는 적은 수로 운영될 수 있지만,
이벤트나 운영 정책에 따라 어느 날은 10개 이상이 동시에 노출될 가능성도 충분히 있었습니다.
즉 "지금은 적다"는 이유만으로 구조 자체를 단순화하면, 이후 딜 수가 늘어났을 때 다시 API와 캐시 전략을 변경해야 할 수 있다고 판단했습니다.
그래서 다음과 같이 판단했습니다.
- 딜 수가 적다고 해서 페이지네이션을 제거하지는 않는다
- 향후 노출 딜 수 증가 가능성을 고려해 페이지네이션은 유지한다
- 다만 캐시는 페이지 단위로 적용해 목록 조회 성능을 개선한다
최종적으로 V2의 캐시 전략은 다음과 같이 정리했습니다.
- 딜 상세 조회
- 메타데이터는 Redis 캐시
- 재고는 실시간 조회
- 딜 목록 조회
- 페이지네이션은 유지
- 목록은 페이지 단위 Redis 캐시
- 재고는 캐시 판단 기준에서 제외
- 주문 시점
- 항상 DB 기준 재검증
이를 통해:
- Redis 공용 캐시로 서버 간 메타데이터 일관성 확보
- 로컬 캐시 불일치 문제 제거
- 재고는 실시간 조회로 정합성 유지
- Scale-Out 환경에서 성능과 정합성을 함께 고려한 캐시 구조 확보
Redis 공용 캐시 적용 후 동일한 목록 조회 요청을 다시 확인해보니, 서버 3대가 모두 같은 딜 목록을 반환했습니다.
즉 이전처럼 서버마다 서로 다른 딜 목록을 반환하지 않았고, 공용 캐시를 통해 동일한 메타데이터를 바라보는 구조로 정리된 것을 확인할 수 있었습니다.
Redis 공용 캐시 적용 후 서버 3대가 동일한 딜 목록을 반환하는 것을 확인
같은 시점의 동일한 요청에 대해 서버 간 응답 차이가 사라진 상태
로컬 캐시 불일치 문제를 재현했던 동일한 흐름에서, Redis 공용 캐시 적용 후에는 일관된 응답을 반환
결과적으로 딜 목록 조회는:
- 메타데이터는 Redis 공용 캐시로 공유하고
- 재고는 DB에서 실시간 조회하며
- 서버가 달라져도 동일한 목록 메타데이터를 반환하는 구조
로 안정화했습니다.
"왜 주문 TPS가 더 이상 늘지 않을까?" — 주문 병목 비교와 Redis Lua Script 채택
V1에서는 딜 주문 병목을 줄이기 위해 단순히 문제를 확인하는 데 그치지 않고, 먼저 적용 가능한 최적화를 수행했습니다.
- 재고 정합성을 위한 비관적 락 적용
- 외부 결제 구간을 분리하기 위한 트랜잭션 분리
즉, V1 주문 처리는 이미 한 차례 구조를 정리한 상태였습니다.
하지만 같은 상품에 주문이 집중되는 상황에서는 여전히 처리량이 거의 늘지 않았습니다.
- 100명 스레드: 딜 주문 TPS
12.9/sec - 300명 스레드: 딜 주문 TPS
12.8/sec
동시 요청 수를 3배로 늘려도 처리량은 거의 증가하지 않았고, 평균 응답시간만 크게 증가했습니다.
비관적 락 + 트랜잭션 분리 적용 후 100명 스레드 테스트 결과
같은 구조에서 300명 스레드로 올려도 주문 TPS는 약
12.8/sec수준에 머물렀다
처리량은 늘지 않았고, 커넥션 획득 대기와 타임아웃이 함께 증가했다
문제의 본질은 애플리케이션 코드 자체보다, 동일 상품 재고를 차감할 때 발생하는 단일 MySQL row lock 직렬화 구조에 있었습니다.
즉 트랜잭션 분리로 DB 커넥션 점유 시간을 줄이는 데는 도움이 되었지만,
재고 차감 자체가 SELECT ... FOR UPDATE 기반으로 직렬화되기 때문에
같은 상품 주문이 몰리면 결국 처리량은 락이 허용하는 수준에서 고정될 수밖에 없었습니다.
정리하면:
- 트랜잭션 분리는 의미 있었다
- 하지만 락 구조 자체의 물리적 한계는 남아 있었다
- 따라서 이 문제는 단순 코드 최적화만으로는 넘기 어렵다고 판단했다
이 문제는 세션 불일치나 로컬 캐시 불일치처럼 "서버를 여러 대로 늘리면 자동으로 해결되는 문제"가 아니었습니다.
그래서 V2에서는:
- 세션은 Redis로 외부화하고
- 캐시는 Redis 공용 캐시로 통합하되
- 주문 병목은 최종적으로 DB row lock 중심 구조를 Redis atomic 재고 차감 구조로 넘기는 방향으로 설계했습니다
즉 주문 병목은 V2의 출발점이 아니라,
V1 최적화 이후에도 남아 있던 구조적 한계를 V2에서 어떻게 풀어갈지 보여주는 핵심 과제로 정리했습니다.
먼저 가장 보수적인 방식으로 접근했습니다.
- 재고 차감 방식은 기존과 동일하게 MySQL 비관적 락
- 앱 서버만 3대로 확장
- 같은 조건(
threads 100 / 300,ramp-up 5초,duration 60초)으로 재테스트
테스트 결과:
100명: 주문 TPS37.6/sec, 평균 응답시간2439ms, 에러율0%300명: 주문 TPS38.2/sec, 평균 응답시간6866ms, 에러율0%
즉 V1의 12.8~12.9/sec 대비 약 3배 수준으로 개선됐고, 재고도 정확히 차감되었습니다.
이 결과는 V1의 주문 병목이 단순히 DB row lock 하나만의 문제가 아니라, 단일 서버 자원 한계와 커넥션 풀 한계가 함께 작용한 결과였음을 보여주었습니다.
다만 100명과 300명에서 처리량이 거의 비슷하게 유지된다는 점에서, 남은 최종 상한은 여전히 비관적 락 기반 재고 차감 구조 자체에 있음을 확인했습니다.
다음으로는 DB row lock 대신 Redis 분산 락(Redisson) 을 적용했습니다.
의도는 단순했습니다.
- DB가 하던 직렬화를 Redis가 대신하게 만들면
- DB 락 부담이 줄고 처리량이 더 올라갈 수 있는지 확인하고 싶었습니다.
하지만 결과는 기대와 달랐습니다.
waitTime = 10초
- 주문 TPS
29.9/sec - 평균 응답시간
3063ms - 에러율
9.82%
충분히 기다리게 하면 실패율은 줄지만, TPS는 비관적 락보다 좋아지지 않았다
waitTime = 2초
- 주문 TPS
49.2/sec - 평균 응답시간
1910ms - 에러율
52.48%
실무적인 대기시간으로 줄이자 TPS는 올라갔지만, 실패율이 절반을 넘었다
실패 응답은 대부분
재고 락 획득 실패였다
즉 Redisson은 락 위치를 DB에서 Redis로 옮겼을 뿐, 락 기반 직렬화 구조 자체는 그대로 유지했습니다.
그래서:
- 충분히 기다리게 하면 TPS 이점이 크지 않았고
- 대기시간을 줄이면 실패율이 급격히 증가했습니다
결론적으로 Redisson은 비교 실험으로는 의미 있었지만, 이 프로젝트의 최종 주문 처리 방식으로 채택하기에는 처리량과 실패율의 트레이드오프가 너무 컸습니다.
마지막으로는 락을 잡는 방식 대신, Redis 안에서 재고 확인과 차감을 한 번에 수행하는 Lua Script 기반 원자 차감을 적용했습니다.
테스트 결과:
100명: 주문 TPS37.5/sec, 평균 응답시간2479ms, 에러율0%300명: 주문 TPS37.6/sec, 평균 응답시간6949ms, 에러율0%
100명 부하에서는 에러 없이 주문 TPS
37.5/sec를 기록했다
300명 부하에서도 에러 없이 주문 TPS
37.6/sec를 유지했다
재고 정합성도 정확했습니다.
100명:100000 - 2346 = 97654300명:100000 - 2558 = 97442
100명 부하 후 실제 재고가 주문 성공 수만큼 정확히 감소했다
300명 부하에서도 재고 정합성은 정확히 유지됐다
다만 여기서도 기대한 만큼 TPS가 더 오르지는 않았습니다.
이 결과는 단순히 Redis를 도입한다고 병목이 자동으로 사라지는 것이 아니라, 현재 구조에서는 Redis 차감 이후에도 DB 재고 반영이 주문 핫패스에 남아 있기 때문에 처리량 상한이 크게 바뀌지 않을 수 있다는 점을 보여주었습니다.
즉 이번 실험을 통해 확인한 것은 다음과 같습니다.
- V2-1 비관적 락 + scale-out
- V1 대비 약 3배 개선
- 정합성 유지
- V2-2 Redisson
- 락 기반 직렬화 한계를 크게 넘지 못함
- 대기시간과 실패율의 트레이드오프가 큼
- V2-3 Lua Script
- 정합성은 가장 안정적
- 실패율 0%
- 하지만 현재 구조에서는 TPS 추가 개선이 제한적
| 방식 | TPS (100명) | TPS (300명) | Error | 정합성 | 채택 |
|---|---|---|---|---|---|
| V1 비관적 락 (1대) | 12.9 | 12.8 | 0% | O | - |
| V2 비관적 락 (3대) | 37.6 | 38.2 | 0% | O | - |
| V2 Redisson (10s) | 29.9 | - | 9.82% | O | X |
| V2 Redisson (2s) | 49.2 | - | 52.48% | O | X |
| V2 Lua Script (3대) | 37.5 | 37.6 | 0% | O | O |
Lua Script는 비관적 락 + 스케일아웃과 TPS는 비슷하지만, 락 없이 원자적 차감으로 DB row lock 의존을 제거했다는 점에서 구조적으로 더 나은 선택이었습니다. Redisson은 처리량과 실패율의 트레이드오프가 커서 채택하지 않았습니다.
1. Lua Script의 실질적 TPS 효과가 제한적이다
Lua Script를 채택했지만 비관적 락 + 스케일아웃 대비 TPS가 거의 동일합니다. 원인은 Redis에서 재고를 차감한 뒤에도 DB UPDATE가 주문 핫패스에 동기적으로 남아 있기 때문입니다. 즉 Lua Script가 DB row lock 의존을 제거했지만, DB 쓰기 자체가 병목으로 남아 처리량 상한이 크게 바뀌지 않았습니다.
개선 방향: DB 재고 반영을 비동기(이벤트 기반)로 분리하면, Redis 차감만으로 주문 응답을 반환할 수 있습니다. 이 구조에서 Lua Script의 원자적 차감이 진정한 성능 이점을 발휘할 것으로 예상하며, V3(MSA) 전환의 핵심 방향이기도 합니다.
2. Redis 단일 장애점(SPOF) 대비가 없다
현재 Redis에 세션, 캐시, 재고 데이터가 집중되어 있어, Redis 장애 시 세션 유실 · 캐시 미스 · 주문 실패가 동시에 발생할 수 있습니다. 또한 Redis 재고 차감 → DB 반영 → 실패 시 Redis 롤백이라는 보상 로직이 있지만, Redis 롤백 자체가 실패하면(네트워크 단절 등) Redis와 DB 간 재고 불일치가 발생할 수 있습니다.
개선 방향: Redis Sentinel/Cluster로 가용성을 확보하고, 정합성 검증 배치(Redis ↔ DB 재고 비교 · 보정)를 도입하여 불일치를 자동 보정하는 구조가 필요합니다.
V1에서 확인한 병목이 V2에서 실제로 해소되었는지, 동일 조건(300명 시나리오)에서 직접 비교한 결과입니다.
| V1 한계 | V1 증거 | V2 해결 방법 | V2 증거 | 해소 |
|---|---|---|---|---|
| CPU 2코어 포화 | 100명 71.5%, 300명 포화 | 앱 서버 3대 + 로드밸런서 | 서버별 최대 ~11% | 해소 |
| 비관적 락 직렬화 | TPS ~12.9 고정 | Redis Lua Script 원자 차감 | 주문 TPS 31.9/sec | 2.5배 개선 |
| 커넥션 풀 경합 | Active 0, Timeout 다수 | 서버 3대 = 커넥션 30개 | 3대 모두 Timeout 0 | 완전 해소 |
| 세션 불일치 | — (V1 단일 서버) | Spring Session + Redis | 서버 무관 인증 유지 | 해소 |
| 로컬 캐시 불일치 | — (V1 단일 서버) | Redis 공용 캐시 | 서버 간 동일 응답 | 해소 |
| 지표 | V1 (1대) | V2 (3대) | 변화 |
|---|---|---|---|
| 딜 목록 Avg | 1,214ms | 303ms | 75% 감소 |
| 딜 상세 Avg | 5,447ms | 272ms | 95% 감소 |
| 딜 주문 Avg | 7,196ms | 1,157ms | 84% 감소 |
| 전체 TPS | ~12.3 | 94.3/sec | 7.7배 증가 |
| Error | 0% | 0% | 유지 |
| CPU | 0.7% (처리 불가) | ~10% (여유) | 정상 처리 |
| HikariCP | Active 0 (획득 불가) | Timeout 0 (3대) | 완전 해소 |
| 재고 정합성 | ~730건 | 1,684건 정확 | 100% 유지 |
V2 300명 시나리오 — 전체 API 에러율 0%, 안정적 처리
3대 서버 모두 HikariCP Timeout 0 — 커넥션 풀 경합 완전 해소
V1에서 확인한 CPU 포화, 비관적 락 직렬화, 커넥션 풀 경합 세 가지 물리적 한계가 V2에서 모두 해소되었습니다.
V2는 단순히 서버 수만 늘린 것이 아니라, 세션/캐시/주문 경로를 분산 환경에 맞게 재설계한 결과입니다.
300명 시나리오에서 CPU ~10%, HikariCP 여유 충분 — 아직 V2의 천장에 도달하지 않았습니다.
500명, 1000명에서 어떤 지점이 먼저 병목이 되는지(DB 쓰기, Redis 처리량, 네트워크 대역폭)를 확인하고, 이를 V3(MSA) 전환의 근거로 활용할 계획입니다.






































