동시 주문의 재고 정합성, 멱등한 주문 처리, 할인·환불 이력을 중심으로 구현한 Spring Boot 커머스 백엔드 API입니다.
이 프로젝트는 SKALA 교육 과정의 쇼핑몰 REST API 시나리오를 기반으로 구현한 포트폴리오 프로젝트이며, SK 또는 SKALA의 공식 제품이나 서비스가 아닙니다.
고객·판매자·관리자 역할을 분리하고 상품 탐색부터 주문, 취소, 배송 상태, 쿠폰, 리뷰, 찜, 추천까지 하나의 관계형 도메인으로 구성했습니다. 기능 수보다 주문 과정에서 포인트·재고·쿠폰·가격 이력을 일관되게 유지하는 데 초점을 맞췄습니다.
| 주제 | 구현 전략 | 검증 포인트 |
|---|---|---|
| 동시성 제어 | 고객·상품 행에 PESSIMISTIC_WRITE 잠금 |
재고 5개에 10개 동시 주문 시 초과 판매 방지 |
| 멱등성 | 고객별 Idempotency-Key와 요청·결과 저장 |
같은 요청은 이전 결과 반환, 다른 payload 재사용은 거부 |
| 거래 정합성 | 주문·취소를 @Transactional 경계로 처리 |
포인트 부족 등 실패 시 주문·포인트·재고를 변경하지 않음 |
| 과거 가격 환불 | 주문 시 가격·등급·쿠폰·실결제액 Snapshot 저장 | 상품 가격 변경 후에도 주문 당시 실결제액으로 환불 |
| 상태 전이 | 명시적인 주문 상태 머신과 변경 이력 | 배송 후 취소와 역방향 상태 전이 차단 |
flowchart LR
Client[API Client] --> Controller[REST Controllers]
Controller --> Service[Domain Services]
Service --> Auth[AuthorizationService]
Auth --> Session[SessionHandler / JWT Cookie]
Service --> Lifecycle[OrderLifecycleService]
Service --> Repository[Spring Data JPA Repositories]
Lifecycle --> Repository
Repository --> H2[(H2 Database)]
주문 처리의 실제 순서는 다음과 같습니다.
sequenceDiagram
participant C as Client
participant API as Order API
participant S as CustomerService
participant DB as Database
C->>API: POST /api/customers/order + Idempotency-Key
API->>S: placeOrder(request, key)
S->>DB: lock customer
S->>DB: find previous idempotency result
S->>DB: lock product
S->>S: calculate grade and coupon discounts
S->>S: validate stock and points
S->>DB: update points, spending and stock
S->>DB: save order item, snapshot and status history
S->>DB: save coupon usage and idempotency result
S-->>C: order result
- 상품 CRUD와 페이지 조회
- 상품명·카테고리·가격·재고 조합 검색과 선택 정렬·ID 보조 정렬
- 일간·주간·월간·전체 판매량·매출·리뷰 종합 랭킹
- 고객 회원가입, 조회, 수정, 삭제
- 로그인 성공 시
bff-accessJWT를 HttpOnly Cookie로 발급 - 고객·판매자 계정 분리와
CUSTOMER·ADMIN역할별 서비스 계층 권한 검사 - 판매자 전용 로그인과 자기 상품 등록·수정·삭제·목록 조회
- 상품별 판매자 소유권 검사와 다른 판매자 상품 변경 차단
- 관리자 전용 고객 관리·전체 상품 관리·배송 상태 변경
- 로그인 고객의 상품 주문과 주문 취소
- 등급·쿠폰 할인 후 최종 주문금액을 보유 포인트로 전액 결제
- 포인트 부족 시 주문·재고 변경 없이 원자적으로 실패
- 부분·전량 취소 시 주문 당시 실결제액만큼 포인트 환불
- 주문 시 재고 차감, 취소 시 재고 복원
- 상품 행 비관적 잠금으로 동시 주문의 초과 판매 방지
Idempotency-Key로 네트워크 재시도에 의한 중복 주문 방지- 주문 당시 가격 스냅샷과 실제 결제 단가 기준 환불
- 주문별 고유 주문번호, 상태 머신, 상태 변경 이력
- 배송 시작 후 취소 및 잘못된 역방향 상태 전이 차단
- 누적 실결제액 기반 고객등급과 등급별 자동 할인
- 정률·정액 쿠폰, 최소 주문금액·최대 할인·1회 사용 정책
- 관리자 전용 쿠폰 생성·전체 조회·수정·활성화 상태 변경
- 전체·카테고리·상품별 쿠폰 적용 범위와 대상 불일치 주문 차단
- 부분 취소 시 할인금액 비례 환불, 전량 취소 시 사용 쿠폰 복원
- 같은 상품 재주문 시 수량 누적, 전량 취소 시 주문 항목 삭제
- 배송 완료 구매자만 작성하는 실구매 인증 리뷰와 평점 통계
- 고객별 상품당 리뷰 1개 및 작성자 수정·삭제 정책
- 찜 목록과 찜·구매 카테고리, 평점, 판매량 기반 개인화 추천
- 판매자·상품·고객·쿠폰·주문·상태 이력·리뷰·찜 관계형 데모 데이터
- JPA/H2 기반
Seller-Product-OrderItem-Customer매핑 - 입력 검증과 공통 예외 응답
- 주문/취소 트랜잭션 및 고객 행 잠금
- Controller API 실행 시간 AOP 로그
회원가입 시 기본 포인트는 5,000, 신규 상품 기본 재고는 100이며 application.yml에서 변경할 수 있습니다.
- Java 17
- Spring Boot 3.3.0 / Gradle 8.5
- Spring Web, Validation, Data JPA, AOP, Actuator
- H2 인메모리 데이터베이스
- JJWT 0.11.5
- JUnit 5 / MockMvc
비밀번호는 DB에 평문으로 보관하지 않고 PBKDF2 해시로 저장합니다. 금액과 포인트는 부동소수점 오차를 피하기 위해 BigDecimal을 사용합니다.
spring-boot-commerce-api/
├── .github/workflows/ci.yml
├── gradle/wrapper/
├── src/
│ ├── main/
│ │ ├── java/com/sk/skala/shopapi/
│ │ │ ├── config/
│ │ │ ├── controller/
│ │ │ ├── data/
│ │ │ ├── exception/
│ │ │ ├── repository/
│ │ │ └── service/
│ │ └── resources/
│ └── test/java/com/sk/skala/shopapi/
├── .dockerignore
├── .gitignore
├── build.gradle
├── Dockerfile
├── gradlew
├── requests.http
└── settings.gradle
주문 시작 시 고객 레코드를 먼저 잠가 동일 고객의 포인트가 동시에 중복 사용되는 것을 막습니다. 이어 상품을 PESSIMISTIC_WRITE로 조회해 같은 상품의 재고 변경을 직렬화합니다. 포인트, 누적 실결제액, 재고, 주문 항목, 주문 Snapshot, 쿠폰 사용 이력은 하나의 Transaction 안에서 반영됩니다.
이 방식은 단일 데이터베이스를 사용하는 현재 구조에서 일관성을 우선한 선택입니다. 잠금 대기와 처리량의 trade-off가 있으므로 운영 규모에서는 충돌률과 응답 시간을 측정해 낙관적 잠금이나 재시도 정책을 함께 검토해야 합니다.
선택적인 Idempotency-Key를 고객 ID와 함께 저장합니다. 동일 Key와 동일 상품·수량·쿠폰 조합이 재전송되면 저장된 이전 결과를 반환하므로 포인트나 재고가 두 번 반영되지 않습니다. 같은 Key를 다른 payload에 사용하면 IDEMPOTENCY_KEY_REUSED로 거부합니다.
주문마다 상품, 수량, 적용 등급, 등급 할인, 쿠폰 할인, 실제 결제액과 잔여 수량을 Snapshot으로 보존합니다. 취소는 현재 상품 가격이 아니라 취소 가능한 과거 Snapshot을 오래된 순서로 소비하며, 전량 취소된 주문의 쿠폰만 다시 사용할 수 있게 복원합니다.
- 로그인 성공 시
bff-accessJWT를 HttpOnly Cookie로 발급합니다. - 고객·판매자·관리자는 서비스 계층에서 현재 계정 상태와 역할을 다시 확인합니다.
- 비밀번호는 PBKDF2 해시로 저장하며 평문으로 보관하지 않습니다.
- 현재 구현은 학습·포트폴리오 목적의 custom authentication/authorization입니다. 운영 서비스에서는 Spring Security Filter Chain, CSRF 정책, 표준
PasswordEncoder적용을 권장합니다.
테스트 코드는 MockMvc 기반 API·도메인 통합 테스트 31개와 실제 병렬 요청을 사용하는 동시성·멱등성 테스트 2개, 총 33개로 구성됩니다.
- 재고 초과 판매 방지와 동일 Key 동시 요청
- 멱등 재시도와 Key/payload 충돌
- 과거 가격 기준 부분·전량 환불
- 쿠폰 사용·복원과 등급 할인
- 역할·상품 소유권 기반 접근 제어
- 주문 상태 전이와 변경 이력
- 실구매 리뷰 제약, 랭킹, 찜 기반 추천
./gradlew clean test --no-daemonGitHub Actions도 push와 pull request마다 Java 17 환경에서 같은 테스트를 실행합니다.
최근 로컬 검증(2026-09-04): 33 passed, 0 failed, 0 skipped.
요구사항: JDK 17
./gradlew bootRun서버는 http://localhost:8080에서 실행됩니다.
- H2 Console:
http://localhost:8080/h2-console - Swagger UI:
http://localhost:8080/swagger-ui.html - OpenAPI JSON:
http://localhost:8080/v3/api-docs - JDBC URL:
jdbc:h2:mem:shopdb - User:
sa - Password: 없음
- Health Check:
http://localhost:8080/actuator/health
Repository에 포함된 JWT 기본값은 로컬 실행만을 위한 명백한 development-only dummy 값입니다. 운영 환경에서는 반드시 별도의 강한 비밀키를 환경 변수로 지정해야 합니다.
JWT_SECRET='32자-이상의-충분히-긴-비밀키' ./gradlew bootRun주요 환경변수는 다음과 같습니다.
| 환경변수 | 기본값 | 용도 |
|---|---|---|
JWT_SECRET |
development-only dummy | JWT 서명 키. 운영에서는 반드시 교체 |
JWT_SECURE_COOKIE |
false |
HTTPS 운영 환경에서는 true 권장 |
JWT_EXPIRATION_SECONDS |
3600 |
JWT 만료 시간 |
APP_DEMO_DATA_ENABLED |
true |
운영에서는 false 권장 |
H2_CONSOLE_ENABLED |
true |
로컬 개발용. 운영에서는 false 권장 |
ADMIN_ID, ADMIN_PASSWORD |
공개 데모 계정 | 관리자 초기 계정 override |
SELLER_ID, SELLER_PASSWORD |
공개 데모 계정 | 판매자 초기 계정 override |
ADMIN_ID='shop-admin' ADMIN_PASSWORD='강한-관리자-비밀번호' \
SELLER_ID='shop-seller' SELLER_PASSWORD='강한-판매자-비밀번호' \
JWT_SECRET='32자-이상의-충분히-긴-비밀키' \
JWT_SECURE_COOKIE=true \
APP_DEMO_DATA_ENABLED=false \
H2_CONSOLE_ENABLED=false \
./gradlew bootRun아래 계정은 로컬 데모 실행만을 위한 공개 테스트 계정이며 실제 서비스 Credential이 아닙니다.
| 역할 | ID | Password |
|---|---|---|
| 관리자 | admin |
admin1234 |
| 판매자 | seller01 |
seller1234 |
| 고객 | demo01 ~ demo10 |
demo1234 |
기본 실행에서는 Swagger를 바로 테스트할 수 있도록 다음 데이터가 자동 생성됩니다.
| 종류 | 개수 | 예시 |
|---|---|---|
| 상품 | 10 | 무선마우스, NVMe SSD 1TB, FHD 웹캠, 외장하드 2TB |
| 판매자 | 2 | 상품 보유 seller01, 삭제 테스트용 seller02 |
| 데모 고객 | 10 | demo01 ~ demo10 |
| 관리자 | 1 | admin |
| 쿠폰 | 10 | WELCOME10, SAVE5000, SUMMER15, VIP20 |
| 주문 | 10 | 고객별 포인트 결제 배송 완료 주문 1건 |
| 리뷰 | 10 | 주문 상품별 실구매 리뷰 1건 |
| 찜 | 10 | 고객별 찜 상품 1건 |
| 주문 상태 이력 | 40 | 주문별 ORDERED → PAID → SHIPPING → DELIVERED |
모든 데모 고객의 비밀번호는 demo1234이고 데모 판매자의 비밀번호는 seller1234입니다. 기본 상품 10개는 seller01 소유이고 seller02는 판매자 삭제 API의 성공 사례를 위한 상품 미보유 계정입니다. 데모 주문을 만들 때는 주문금액만큼 포인트를 추가 지급한 뒤 전액 포인트로 결제하므로, 주문 후 각 고객의 잔액은 회원가입 기본값과 같은 5,000포인트입니다. 주문 시점은 약 2시간 전부터 75일 전까지 분산되어 일간·주간·월간·전체 랭킹을 확인할 수 있습니다. 재고, 누적 실결제액, 등급도 주문 데이터와 일치하도록 반영됩니다.
데모 데이터가 필요 없는 실행 환경에서는 다음처럼 끌 수 있습니다.
APP_DEMO_DATA_ENABLED=false ./gradlew bootRun통합 테스트에서는 데모 데이터를 자동으로 비활성화하므로 고정된 33개 검증 조건에 영향을 주지 않습니다.
회원가입에는 고객 ID, 비밀번호, 이름, 이메일, 생년월일, 휴대폰 번호가 필요합니다.
- 이메일은 소문자로 정규화하고 중복 가입을 차단합니다.
- 휴대폰 번호는 하이픈을 제거한 숫자 11자리로 저장하고 중복 가입을 차단합니다.
- 생년월일은 과거 날짜만 허용합니다.
- 이메일·휴대폰·생년월일 형식 오류는 필드별
INVALID_PARAMETER응답으로 반환합니다. - 현재 단계에서는 이메일 링크 발송이나 SMS 인증번호 확인 같은 실제 소유권 인증은 수행하지 않습니다.
실제 인증을 추가하려면 인증번호 해시, 만료시각, 시도 횟수, 인증 완료시각을 별도 인증 테이블에 저장하고 이메일·SMS 제공자와 연동하는 방식이 적절합니다.
- Swagger UI:
http://localhost:8080/swagger-ui.html - OpenAPI JSON:
http://localhost:8080/v3/api-docs - 실행 가능한 전체 예시:
requests.http
offset은 0부터 시작하는 페이지 번호이고 count는 페이지 크기입니다.
| Method | URI | 설명 | 인증 |
|---|---|---|---|
| GET | /api/products?offset=0&count=10 |
상품 검색·필터·정렬 | 불필요 |
| GET | /api/products/categories |
상품 카테고리 목록 | 불필요 |
| GET | /api/products/{id} |
상품 상세 | 불필요 |
| GET | /api/products/rankings |
기간·카테고리별 상품 랭킹 | 불필요 |
| POST | /api/products |
상품 등록 | 판매자·관리자 |
| PUT | /api/products |
자기 상품 수정 | 판매자·관리자 |
| DELETE | /api/products/{id} |
자기 상품 삭제 | 판매자·관리자 |
| POST | /api/sellers/login |
판매자 로그인/JWT Cookie 발급 | 불필요 |
| GET | /api/sellers/me |
로그인 판매자 정보 | 판매자 |
| GET | /api/sellers/me/products |
로그인 판매자의 상품 목록 | 판매자 |
| DELETE | /api/sellers/{sellerId} |
상품 미보유 판매자 삭제 | 관리자 |
| GET | /api/coupons |
사용 가능한 쿠폰 목록 | 불필요 |
| GET | /api/coupons/admin |
만료·비활성 포함 전체 쿠폰 목록 | 관리자 |
| POST | /api/coupons |
전체·카테고리·상품 쿠폰 생성 | 관리자 |
| PUT | /api/coupons/{code} |
쿠폰 할인 정책·적용 범위 수정 | 관리자 |
| PATCH | /api/coupons/{code}/status |
쿠폰 활성화·비활성화 | 관리자 |
| GET | /api/customers?offset=0&count=10 |
고객 목록 | 관리자 |
| GET | /api/customers/{customerId} |
고객과 주문 상품 조회 | 관리자 |
| POST | /api/customers |
회원가입 | 불필요 |
| POST | /api/customers/login |
로그인/JWT Cookie 발급 | 불필요 |
| PUT | /api/customers |
고객 포인트 수정 | 관리자 |
| DELETE | /api/customers/{customerId} |
고객 삭제 | 관리자 |
| GET | /api/customers/me/products |
로그인 고객 주문 조회 | 필요 |
| GET | /api/customers/me/order-history |
주문 당시 가격·수량 이력 조회 | 필요 |
| POST | /api/customers/order |
상품 주문 | 필요 |
| POST | /api/customers/cancel |
주문 취소 | 필요 |
| GET | /api/orders |
내 주문 상태 목록 조회 | 필요 |
| GET | /api/orders/{orderId} |
내 주문 상세·상태 이력 조회 | 필요 |
| PATCH | /api/orders/{orderId}/status |
배송 상태 변경 | 관리자 |
| GET | /api/products/{productId}/reviews |
리뷰·평점 통계 조회 | 불필요 |
| POST | /api/products/{productId}/reviews |
실구매 인증 리뷰 등록 | 고객 |
| PUT | /api/products/{productId}/reviews/{reviewId} |
내 리뷰 수정 | 작성자 |
| DELETE | /api/products/{productId}/reviews/{reviewId} |
내 리뷰 삭제 | 작성자 |
| GET | /api/wishlists |
내 찜 목록 조회 | 고객 |
| POST | /api/wishlists/{productId} |
상품 찜하기 | 고객 |
| DELETE | /api/wishlists/{productId} |
찜 취소 | 고객 |
| GET | /api/recommendations?limit=10 |
개인화 상품 추천 | 고객 |
자료의 /list 경로와 Body 방식 삭제 요청도 호환을 위해 함께 지원합니다.
GET /api/products에서 다음 조건을 자유롭게 조합할 수 있습니다.
| 파라미터 | 설명 | 예시 |
|---|---|---|
keyword |
상품명 부분 검색 | 마우스 |
category |
상품 카테고리 | INPUT_DEVICE |
minPrice |
최소 가격 | 10000 |
maxPrice |
최대 가격 | 50000 |
inStock |
true: 재고 있음, false: 품절 |
true |
sort |
정렬 방식 | PRICE_DESC |
offset |
0부터 시작하는 페이지 번호 | 0 |
count |
페이지 크기, 최대 100 | 10 |
정렬은 ID_ASC, PRICE_ASC, PRICE_DESC, NAME_ASC, STOCK_DESC를 지원합니다.
curl -s \
'http://localhost:8080/api/products?keyword=키보드&category=INPUT_DEVICE&minPrice=20000&maxPrice=35000&inStock=true&sort=PRICE_DESC'카테고리 코드는 /api/products/categories에서 조회할 수 있습니다.
GET /api/products/rankings?period=DAILY&metric=POPULARITY&category=INPUT_DEVICE&limit=10period:DAILY,WEEKLY,MONTHLY,ALLmetric:POPULARITY,QUANTITY,REVENUEcategory: 선택한 카테고리 내부 순위이며 생략하면 전체 상품 순위- 날짜 경계:
Asia/Seoul, 주간 시작은 월요일 - 순판매량과 순매출: 부분·전량 취소 수량과 환불액을 제외
기본 POPULARITY 점수는 선택된 기간·카테고리 안에서 다음과 같이 계산합니다.
판매량 점수 = 상품 순판매량 / 최고 순판매량 × 100
매출 점수 = 상품 순매출 / 최고 순매출 × 100
보정 평점 = (평균 평점 × 리뷰 수 + 3.5 × 5) / (리뷰 수 + 5)
리뷰 점수 = 보정 평점 / 5 × 100
종합 점수 = 판매량 점수 × 0.55
+ 매출 점수 × 0.15
+ 리뷰 점수 × 0.30
구매 행동을 핵심 신호로 보기 때문에 판매량 비중이 가장 높습니다. 매출은 저가 상품의 수량 독점을 보정하되 고가 상품이 무조건 우세하지 않도록 15%만 반영하고, 리뷰는 만족도를 반영하면서 적은 표본이나 조작 가능성이 판매 실적을 압도하지 않도록 30%로 제한합니다. 리뷰 5개와 평점 3.5를 사전값으로 사용하는 베이지안 보정으로 리뷰 1개짜리 5점 상품의 과대평가도 줄입니다.
가중치와 리뷰 사전값은 application.yml의 app.ranking에서 변경할 수 있습니다. 응답에는 이전 동일 길이 기간의 previousRank, rankChange, trend와 실제 averageRating, reviewCount, popularityScore가 포함됩니다. ALL에는 비교할 이전 기간이 없으므로 trend는 NEW입니다.
고객등급은 취소 금액을 반영한 누적 실결제액으로 자동 산정됩니다.
| 등급 | 누적 실결제액 | 등급 할인율 |
|---|---|---|
| BASIC | 10만원 미만 | 0% |
| SILVER | 10만원 이상 | 2% |
| GOLD | 30만원 이상 | 5% |
| VIP | 70만원 이상 | 10% |
할인은 상품금액 → 등급 할인 → 쿠폰 할인 순서로 계산합니다.
다음은 기본 데모 데이터에 포함된 대표 쿠폰입니다.
| 쿠폰 코드 | 혜택 | 적용 범위 | 조건 |
|---|---|---|---|
WELCOME10 |
10% 할인 | 전체 상품 | 1만원 이상, 최대 2만원 |
SAVE5000 |
5천원 할인 | 전체 상품 | 3만원 이상 |
DEVICE5000 |
5천원 할인 | INPUT_DEVICE 카테고리 |
5만원 이상 |
STORAGE12 |
12% 할인 | STORAGE 카테고리 |
8만원 이상, 최대 2만5천원 |
NEW3000 |
3천원 할인 | FHD 웹캠 상품 | 2만원 이상 |
쿠폰의 discountType은 FIXED(정액) 또는 PERCENT(정률)입니다. 적용 범위 scope는 ALL, CATEGORY, PRODUCT 중 하나이며 CATEGORY는 category, PRODUCT는 productId를 함께 지정합니다. 쿠폰은 고객별 1회 사용할 수 있습니다. 부분 취소 중에는 사용 상태가 유지되며 해당 주문을 전량 취소하면 다시 사용할 수 있습니다. 상품 전용 쿠폰의 대상 상품이 삭제되면 사용 이력을 보존한 채 쿠폰이 자동 비활성화됩니다.
관리자 쿠폰 생성 예시:
{
"code": "MOUSE20",
"name": "입력 장치 20% 할인",
"discountType": "PERCENT",
"discountValue": 20,
"minimumOrderAmount": 30000,
"maximumDiscountAmount": 10000,
"validFrom": "2025-01-01T00:00:00Z",
"validUntil": "2035-12-31T23:59:59Z",
"scope": "CATEGORY",
"category": "INPUT_DEVICE",
"productId": null,
"active": true
}포인트는 1포인트 = 1원으로 계산합니다. 상품을 주문하면 할인 적용 후 최종 주문금액 전액을 고객의 보유 포인트에서 차감합니다. 포인트를 구매하거나 충전하는 API는 두지 않았고, 회원가입 때 5,000포인트를 지급합니다. 실습 중 더 큰 주문을 테스트할 때는 관리자가 고객 포인트 수정 API로 잔액을 지급할 수 있습니다.
상품금액 - 등급 할인 - 쿠폰 할인 = 포인트 결제금액
주문 후 포인트 = 주문 전 포인트 - 포인트 결제금액
{
"productId": 1,
"quantity": 2,
"couponCode": "WELCOME10"
}- 최종 주문금액보다 보유 포인트가 적으면
INSUFFICIENT_FUNDS로 실패합니다. - 실패한 주문은 포인트와 재고가 모두 변경되지 않습니다.
- 고객등급 산정용 누적 실결제액에는 포인트로 결제한 최종 주문금액이 반영됩니다.
- 부분 취소는 주문 당시 할인된 실결제액을 수량에 비례해 포인트로 환불합니다.
- 전량 취소는 남은 실결제액 전부를 포인트로 환불하고 사용한 쿠폰을 복원합니다.
주문은 결제가 끝나면 PAID 상태로 응답되며, 내부 이력에는 ORDERED → PAID가 모두 기록됩니다.
ORDERED → PAID → SHIPPING → DELIVERED
↘ PARTIALLY_CANCELLED → SHIPPING
↘ CANCELLED
- 결제 완료 주문을 일부 취소하면
PARTIALLY_CANCELLED, 전량 취소하면CANCELLED가 됩니다. PAID와PARTIALLY_CANCELLED상태에서만 취소할 수 있습니다.SHIPPING이후에는 포인트와 재고가 바뀌지 않도록 취소 요청을 차단합니다.- 관리자만 배송 상태를 변경할 수 있으며
SHIPPING,DELIVERED순방향 전이만 허용합니다. - 각 변경은 이전 상태, 다음 상태, 사유, 변경 시각과 함께 조회됩니다.
- 일반 고객은 상품 조회·주문·취소·리뷰·찜 기능을 사용합니다.
- 판매자는 별도 판매자 계정으로 로그인하고 자기 소유 상품만 등록·수정·삭제합니다.
- 관리자는 고객 관리, 전체 상품 관리, 배송 상태 변경을 담당합니다.
- 판매자는 고객 목록·포인트 수정·고객 삭제 같은 관리자 기능을 사용할 수 없습니다.
- JWT에는 로그인 주체를 담고 매 요청에서 고객 또는 판매자 DB의 현재 권한과 활성 상태를 검사합니다.
- 리뷰는 해당 상품의
DELIVERED주문과 남은 구매 수량이 있는 고객만 작성할 수 있습니다. - 공개 리뷰 응답은 평균 평점, 리뷰 수, 1~5점 분포, 실구매 인증 여부를 제공합니다.
- 추천 점수는 찜 카테고리 가중치, 구매 카테고리 가중치, 평균 평점, 판매량을 합산합니다.
- 이미 찜하거나 구매한 상품과 품절 상품은 추천 결과에서 제외하며 추천 이유를 함께 반환합니다.
판매자는 고객이나 관리자가 아닌 별도 계정으로 로그인합니다.
curl -s -c /tmp/skala-seller-cookie.txt \
-X POST http://localhost:8080/api/sellers/login \
-H 'Content-Type: application/json' \
-d '{"sellerId":"seller01","sellerPassword":"seller1234"}'로그인 판매자 정보와 자기 상품 목록을 조회합니다.
curl -s -b /tmp/skala-seller-cookie.txt \
http://localhost:8080/api/sellers/me
curl -s -b /tmp/skala-seller-cookie.txt \
http://localhost:8080/api/sellers/me/products판매자 쿠키로 상품을 등록하면 Request Body에 판매자 ID를 넣지 않아도 로그인 계정이 소유자로 자동 기록됩니다.
curl -s -b /tmp/skala-seller-cookie.txt \
-X POST http://localhost:8080/api/products \
-H 'Content-Type: application/json' \
-d '{
"productName":"인체공학 버티컬마우스",
"productPrice":54900,
"stockQuantity":30,
"category":"INPUT_DEVICE"
}'응답의 sellerId는 seller01, businessName은 SKALA 디지털입니다. 판매자가 다른 판매자의 상품이나 플랫폼 상품을 수정·삭제하면 ACCESS_DENIED를 반환하며 관리자는 모든 상품을 관리할 수 있습니다.
관리자는 상품이 없는 판매자만 삭제할 수 있습니다. seller02는 삭제에 성공하고 기본 상품 10개를 보유한 seller01은 DATA_IN_USE로 차단됩니다.
curl -s -c /tmp/skala-admin-cookie.txt \
-X POST http://localhost:8080/api/customers/login \
-H 'Content-Type: application/json' \
-d '{"customerId":"admin","customerPassword":"admin1234"}'
curl -s -b /tmp/skala-admin-cookie.txt \
-X DELETE http://localhost:8080/api/sellers/seller02curl -s -X POST http://localhost:8080/api/customers \
-H 'Content-Type: application/json' \
-d '{
"customerId":"skala01",
"customerPassword":"pw1234",
"customerName":"김스칼라",
"email":"skala01@example.com",
"birthDate":"1998-05-17",
"phoneNumber":"010-1234-5678"
}'가입 응답의 초기 포인트는 5000입니다.
curl -s -c /tmp/skala-shop-cookie.txt \
-X POST http://localhost:8080/api/customers/login \
-H 'Content-Type: application/json' \
-d '{"customerId":"skala01","customerPassword":"pw1234"}'상품 2개 주문을 확인할 수 있도록 관리자로 로그인한 뒤 회원 포인트를 100만으로 수정합니다. 회원가입 때 지급되는 기본 포인트는 그대로 5,000입니다.
curl -s -c /tmp/skala-admin-cookie.txt \
-X POST http://localhost:8080/api/customers/login \
-H 'Content-Type: application/json' \
-d '{"customerId":"admin","customerPassword":"admin1234"}'
curl -s -b /tmp/skala-admin-cookie.txt \
-X PUT http://localhost:8080/api/customers \
-H 'Content-Type: application/json' \
-d '{"customerId":"skala01","customerPoint":1000000}'curl -s 'http://localhost:8080/api/products?offset=0&count=10'초기 상품은 무선마우스, 블루투스키보드, USB허브를 포함해 총 10개입니다.
curl -s -b /tmp/skala-shop-cookie.txt \
-X POST http://localhost:8080/api/customers/order \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: order-skala01-001' \
-d '{
"productId":1,
"quantity":2
}'최종 주문금액 30,000포인트가 전액 차감됩니다. 새로 기동한 기본 데모 데이터 기준으로 customerPoint는 970000, 주문 수량은 2, 남은 재고는 93이 됩니다. 응답의 orderId는 이후 상태 조회·변경에 사용합니다. 같은 Idempotency-Key와 같은 Body로 다시 요청하면 최초 결과가 반환되며 포인트와 재고는 다시 반영되지 않습니다.
curl -s -b /tmp/skala-shop-cookie.txt \
http://localhost:8080/api/customers/me/products주문 당시 가격 이력은 다음 API로 확인합니다.
curl -s -b /tmp/skala-shop-cookie.txt \
http://localhost:8080/api/customers/me/order-history주문 상태와 전체 변경 이력은 주문 API로 확인합니다.
curl -s -b /tmp/skala-shop-cookie.txt \
http://localhost:8080/api/orders
curl -s -b /tmp/skala-shop-cookie.txt \
http://localhost:8080/api/orders/{orderId}curl -s -b /tmp/skala-shop-cookie.txt \
-X POST http://localhost:8080/api/customers/cancel \
-H 'Content-Type: application/json' \
-d '{"productId":1,"quantity":1}'주문 당시 실결제 단가 15,000포인트가 환불됩니다. 응답의 customerPoint는 985000, 남은 주문 수량은 1이며 주문 상태는 PARTIALLY_CANCELLED가 됩니다.
관리자로 로그인해 별도 Cookie를 저장합니다.
curl -s -c /tmp/skala-admin-cookie.txt \
-X POST http://localhost:8080/api/customers/login \
-H 'Content-Type: application/json' \
-d '{"customerId":"admin","customerPassword":"admin1234"}'curl -s -b /tmp/skala-admin-cookie.txt \
-X PATCH http://localhost:8080/api/orders/{orderId}/status \
-H 'Content-Type: application/json' \
-d '{"status":"SHIPPING","reason":"택배사 인계 완료"}'
curl -s -b /tmp/skala-admin-cookie.txt \
-X PATCH http://localhost:8080/api/orders/{orderId}/status \
-H 'Content-Type: application/json' \
-d '{"status":"DELIVERED"}'curl -s -b /tmp/skala-shop-cookie.txt \
-X POST http://localhost:8080/api/products/1/reviews \
-H 'Content-Type: application/json' \
-d '{"rating":5,"content":"실구매 인증 리뷰입니다."}'
curl -s http://localhost:8080/api/products/1/reviewscurl -s -b /tmp/skala-shop-cookie.txt \
-X POST http://localhost:8080/api/wishlists/3
curl -s -b /tmp/skala-shop-cookie.txt \
http://localhost:8080/api/wishlists
curl -s -b /tmp/skala-shop-cookie.txt \
'http://localhost:8080/api/recommendations?limit=10'IDE에서 순서대로 호출하려면 프로젝트 루트의 requests.http도 사용할 수 있습니다.
성공:
{
"success": true,
"message": "상품 주문이 완료되었습니다.",
"body": {
"customerId": "skala01",
"productId": 1,
"quantity": 2,
"customerPoint": 970000.00,
"stockQuantity": 93,
"baseAmount": 30000.00,
"appliedGrade": "BASIC",
"gradeDiscountAmount": 0.00,
"couponCode": null,
"couponDiscountAmount": 0.00,
"paidAmount": 30000.00,
"customerGrade": "BASIC",
"totalSpent": 30000.00,
"orderId": 11,
"orderNumber": "ORD-A7F2504D4E1B4E53AA6510D44CC58FD4",
"orderStatus": "PAID"
}
}보유 포인트를 초과하는 주문을 요청한 경우:
{
"timestamp": "2026-07-31T00:00:00Z",
"status": 409,
"code": "INSUFFICIENT_FUNDS",
"message": "보유 포인트가 부족합니다.",
"path": "/api/customers/order"
}주요 오류 코드는 INVALID_PARAMETER, DATA_NOT_FOUND, DATA_DUPLICATED, DATA_IN_USE, NOT_AUTHENTICATED, ACCESS_DENIED, INSUFFICIENT_FUNDS, OUT_OF_STOCK, IDEMPOTENCY_KEY_REUSED, COUPON_NOT_FOUND, COUPON_NOT_AVAILABLE, COUPON_MINIMUM_NOT_MET, COUPON_ALREADY_USED, INSUFFICIENT_QUANTITY, ORDER_HISTORY_INCONSISTENT, ORDER_NOT_CANCELLABLE, ORDER_STATUS_CHANGE_NOT_ALLOWED, REVIEW_NOT_ALLOWED, REVIEW_ALREADY_EXISTS, WISHLIST_ALREADY_EXISTS입니다.
- 재고 5개 상품에 서로 다른 고객 10명이 동시에 주문
- 성공 5건
OUT_OF_STOCK5건- 최종 재고 0, 총 주문 수량 5
- 같은 고객이 같은
Idempotency-Key로 주문을 동시에 두 번 요청- 두 요청 모두 같은 결과 반환
- 주문·포인트·재고는 한 번만 반영
- 포인트 전액 결제와 원자적 실패
- 주문금액 전액을 보유 포인트에서 차감
- 포인트 부족 시 주문 내역을 만들지 않고 포인트·재고 유지
- 부분·전량 취소 시 주문 당시 실결제액을 포인트로 환불
- 주문 후 상품 가격을 1,000원에서 2,000원으로 변경하고 1개 취소
- 현재 가격이 아닌 주문 당시 가격 1,000원 환급
- 원래 주문 단가와 잔여 수량 이력 유지
- SILVER 고객이 3만원 상품에
WELCOME10적용- 등급 할인 600원 적용 후 쿠폰 할인 2,940원 적용
- 실결제액 26,460원
- 쿠폰 주문 부분 취소 후 전량 취소
- 부분 취소는 할인된 실결제액을 비례 환불
- 전량 취소 후 동일 쿠폰 재사용 가능
- 상품 검색 조건 조합
- 상품명·카테고리·가격 범위·재고 여부 동시 적용
- 가격·상품명·재고 수량 정렬과 안정적인 보조 정렬
- 잘못된 가격 범위와 enum 값은
INVALID_PARAMETER
- 기간·카테고리별 상품 랭킹
- 일간·주간·월간·전체 순판매량 및 순매출 집계
- 취소 수량·환불액 즉시 제외
- 베이지안 리뷰 보정을 포함한 종합 인기점수와 이전 기간 순위 변화
- 주문 상태 수명주기
- 주문 시
ORDERED → PAID이력 자동 생성 - 부분 취소, 배송 시작, 배송 완료의 전체 변경 이력 보존
- 배송 시작 후 취소와
DELIVERED → SHIPPING역방향 전이 차단
- 주문 시
- 역할 기반 권한
- 일반 고객의 상품 등록·고객 목록·배송 상태 변경 요청은
ACCESS_DENIED - 판매자는 자기 상품만 등록·수정·삭제하고 고객 관리 API는
ACCESS_DENIED - 관리자는 고객 관리와 모든 상품 관리 가능
- 상품 보유 판매자 삭제는
DATA_IN_USE, 상품 미보유 판매자 삭제는 성공
- 일반 고객의 상품 등록·고객 목록·배송 상태 변경 요청은
- 실구매 인증 리뷰
- 배송 전 작성 차단, 배송 완료 후 작성 허용
- 상품당 1개 제한과 평균 평점·점수 분포 검증
- 개인화 추천
- 찜한 입력 장치와 같은 카테고리 상품의 추천 점수 상승
- 이미 찜·구매한 상품 제외 및 추천 이유 제공
./gradlew clean test --no-daemon
./gradlew clean build --no-daemon통합 테스트는 기능 시나리오 31건과 재고·멱등성 동시성 시나리오 2건으로 총 33건입니다. 회원가입 프로필·5천 포인트·연락처 중복과 형식 검증, 고객·판매자 로그인, JWT 인증, 판매자 상품 소유권·안전한 판매자 삭제·관리자 권한, 상품 조합 검색·필터·정렬, 기간·카테고리별 순판매량·순매출·리뷰 종합 랭킹, 포인트 전액 결제·부족 시 원자적 롤백·취소 환불, 주문 상태·변경 이력, 배송 후 취소·역방향 전이 차단, 구매 인증 리뷰·평점 통계, 찜·개인화 추천, 주문 가격 스냅샷, 쿠폰 생성·수정·상태 관리·적용 범위·사용·복원, 등급 승급·강등과 중복 할인, 재주문 수량 누적, 입력 검증을 확인합니다.
최근 로컬 clean build 검증(2026-09-04)도 성공했습니다. Gradle 9와 호환되지 않는 deprecated feature 경고는 향후 Gradle/Spring Boot 업그레이드 시 점검할 기술 부채로 남아 있습니다.
Dockerfile은 Gradle 빌드와 JRE 실행 이미지를 분리한 multi-stage 방식이므로 로컬 JAR를 미리 생성할 필요가 없습니다.
docker build -t spring-boot-commerce-api:1.0 .
docker run --rm -p 8080:8080 \
-e JWT_SECRET='32자-이상의-충분히-긴-비밀키' \
-e ADMIN_PASSWORD='강한-관리자-비밀번호' \
-e SELLER_PASSWORD='강한-판매자-비밀번호' \
-e JWT_SECURE_COOKIE=false \
spring-boot-commerce-api:1.0로컬 HTTP 데모에서는 JWT_SECURE_COOKIE=false를 사용합니다. 실제 HTTPS 환경에서는 true로 설정해야 합니다.
- SKALA 교육 과정의 쇼핑몰 REST API 시나리오를 기반으로 구현했습니다.
- 현재 저장소에는 상품·주문·권한·쿠폰·리뷰·추천 도메인과 동시성·멱등성 통합 테스트가 포함되어 있습니다.
- 교육 기본 코드와 이후 구현 범위의 세부 경계는 현재 자료만으로 확정하지 않았습니다. 공개 전 교육 자료와 기본 코드의 재배포 허용 여부를 사용자가 확인해야 합니다.
- 강의 PDF, 문제지, 원본 ZIP 등 교육 자료는 이 프로젝트에 포함하지 않았습니다.
- 이 프로젝트는 SK 또는 SKALA의 공식 제품이나 서비스가 아닙니다.
교육 자료와 기본 코드의 공개·재배포 조건을 확인하기 전까지 임의의 오픈소스 LICENSE를 추가하지 않았습니다. 따라서 별도 허가나 라이선스가 명시되기 전에는 코드 사용·복제 권한이 부여된 것으로 간주하면 안 됩니다.
- 현재 데이터베이스는 로컬 데모와 테스트에 적합한 H2 인메모리 DB입니다. PostgreSQL, Flyway, Testcontainers 적용을 검토할 수 있습니다.
- 인증·인가는 custom service-layer 구현입니다. Spring Security와 명시적인 CSRF 정책으로 전환할 수 있습니다.
- 큰 통합 테스트 클래스를 상품·고객·주문·쿠폰·권한·리뷰·랭킹 단위로 분리할 수 있습니다.
- 구조화 로그와 Micrometer 기반 주문 실패·재고 충돌 지표를 추가할 수 있습니다.
- 실제 운영에서는 demo data, demo credentials, H2 Console을 비활성화하고 Secret Manager 등으로 인증정보를 주입해야 합니다.