diff --git a/.claude/skills/requirements-analysis/SKILL.md b/.claude/skills/requirements-analysis/SKILL.md new file mode 100644 index 00000000..3485a8af --- /dev/null +++ b/.claude/skills/requirements-analysis/SKILL.md @@ -0,0 +1,77 @@ +--- +name: requirements-analysis +description: + 제공된 요구사항을 분석하고, 개발자와의 질문/대답을 통해 애매한 요구사항을 명확히 하여 정리합니다. + 모든 정리가 끝나면, 시퀀스 다이어그램, 클래스 다이어그램, ERD 등을 Mermaid 문법으로 작성한다. + 요구사항이 제공되었을 때, 코드를 작성하기 전 이를 명확히 하는 데에 사용합니다. +--- +요구사항을 분석할 때 반드시 다음 흐름을 따른다. +### 1️⃣ 요구사항을 그대로 믿지 말고, 문제 상황으로 다시 설명한다. +- 요구사항 문장을 정리하는 데서 끝내지 않는다. +- "무엇을 만들까?"가 아니라 "지금 어떤 문제가 있고, 그걸 왜 해결하려는가?" 로 재해석한다. +- 다음 관점을 분리해서 정리한다: + - 사용자 관점 + - 비즈니스 관점 + - 시스템 관점 +> 예시 +> "주문 실패 시 결제를 취소한다" → "결제 성공/실패와 주문 상태가 어긋나지 않도록 일관성을 유지하려는 문제" + +### 2️⃣ 애매한 요구사항을 숨기지 말고 드러낸다 +- 추측하거나 알아서 결정하지 않는다. +- 요구사항에서 결정되지 않은 부분을 명시적으로 나열한다. + **다음 유형의 질문을 반드시 포함한다:** +- 정책 질문: 기준 시점, 성공/실패 조건, 예외 처리 규칙 +- 경계 질문: 어디까지가 한 책임인가, 어디서 분리되는가 +- 확장 질문: 나중에 바뀔 가능성이 있는가 + +### 3️⃣ 요구사항 명확화를 위한 질문을 개발자 답변이 쉬운 형태로 제시한다 +- 질문은 우선순위를 가진다 (중요한 것부터). +- 선택지가 있는 경우, 옵션 + 영향도를 함께 제시한다. +> 형식 예시: +- 선택지 A: 하나의 트랜잭션으로 처리 → 구현 단순, 확장성 낮음 +- 선택지 B: 단계별 분리 → 구조 복잡, 확장/보상 처리 유리 + +### 4️⃣ 합의된 내용을 바탕으로 개념 모델부터 잡는다 +- 바로 코드나 기술 얘기로 들어가지 않는다. +- 먼저 다음을 정의한다: + - 액터 (사용자, 외부 시스템) + - 핵심 도메인 + - 보조/외부 시스템 +- 이 단계는 “구현”이 아니라 설계 사고 정렬이 목적이다. + +### 5️⃣ 다이어그램은 항상 이유 → 다이어그램 → 해석 순서로 제시한다 +**다이어그램을 그리기 전에 반드시 설명한다** +- 왜 이 다이어그램이 필요한지 +- 이 다이어그램으로 무엇을 검증하려는지 + +**다이어그램은 Mermaid 문법으로 작성한다** +사용 기준: +- **시퀀스 다이어그램** + - 책임 분리 + - 호출 순서 + - 트랜잭션 경계 확인 +- **클래스 다이어그램** + - 도메인 책임 + - 의존 방향 + - 응집도 확인 +- **ERD** + - 영속성 구조 + - 관계의 주인 + - 정규화 여부 + +### 6️⃣ 다이어그램을 던지고 끝내지 말고 읽는 법을 짚어준다 +- "이 구조에서 특히 봐야 할 포인트"를 2~3줄로 설명한다. +- 설계 의도가 드러나도록 해석을 붙인다. + +### 7️⃣ 설계의 잠재 리스크를 반드시 언급한다 +- 현재 설계가 가질 수 있는 위험을 숨기지 않는다. + - 트랜잭션 비대화 + - 도메인 간 결합도 증가 + - 정책 변경 시 영향 범위 확대 +- 해결책은 정답처럼 말하지 않고 선택지로 제시한다. + +### 톤 & 스타일 가이드 +- 강의처럼 설명하지 말고 설계 리뷰 톤을 유지한다 +- 정답이라고 제시하기보다, 다른 선택지가 있다면 이를 제공하도록 한다. +- 코드보다 의도, 책임, 경계를 더 중요하게 다룬다 +- 구현 전에 생각해야 할 것을 끌어내는 데 집중한다 \ No newline at end of file diff --git a/.codeguide/loopers-1-week.md b/.codeguide/loopers-1-week.md deleted file mode 100644 index a8ace53e..00000000 --- a/.codeguide/loopers-1-week.md +++ /dev/null @@ -1,45 +0,0 @@ -## 🧪 Implementation Quest - -> 지정된 **단위 테스트 / 통합 테스트 / E2E 테스트 케이스**를 필수로 구현하고, 모든 테스트를 통과시키는 것을 목표로 합니다. - -### 회원 가입 - -**🧱 단위 테스트** - -- [ ] ID 가 `영문 및 숫자 10자 이내` 형식에 맞지 않으면, User 객체 생성에 실패한다. -- [ ] 이메일이 `xx@yy.zz` 형식에 맞지 않으면, User 객체 생성에 실패한다. -- [ ] 생년월일이 `yyyy-MM-dd` 형식에 맞지 않으면, User 객체 생성에 실패한다. - -**🔗 통합 테스트** - -- [ ] 회원 가입시 User 저장이 수행된다. ( spy 검증 ) -- [ ] 이미 가입된 ID 로 회원가입 시도 시, 실패한다. - -**🌐 E2E 테스트** - -- [ ] 회원 가입이 성공할 경우, 생성된 유저 정보를 응답으로 반환한다. -- [ ] 회원 가입 시에 성별이 없을 경우, `400 Bad Request` 응답을 반환한다. - -### 내 정보 조회 - -**🔗 통합 테스트** - -- [ ] 해당 ID 의 회원이 존재할 경우, 회원 정보가 반환된다. -- [ ] 해당 ID 의 회원이 존재하지 않을 경우, null 이 반환된다. - -**🌐 E2E 테스트** - -- [ ] 내 정보 조회에 성공할 경우, 해당하는 유저 정보를 응답으로 반환한다. -- [ ] 존재하지 않는 ID 로 조회할 경우, `404 Not Found` 응답을 반환한다. - -### 포인트 조회 - -**🔗 통합 테스트** - -- [ ] 해당 ID 의 회원이 존재할 경우, 보유 포인트가 반환된다. -- [ ] 해당 ID 의 회원이 존재하지 않을 경우, null 이 반환된다. - -**🌐 E2E 테스트** - -- [ ] 포인트 조회에 성공할 경우, 보유 포인트를 응답으로 반환한다. -- [ ] `X-USER-ID` 헤더가 없을 경우, `400 Bad Request` 응답을 반환한다. diff --git a/build.gradle.kts b/build.gradle.kts index 9c8490b8..dc167f2e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -42,6 +42,7 @@ subprojects { dependencyManagement { imports { mavenBom("org.springframework.cloud:spring-cloud-dependencies:${project.properties["springCloudDependenciesVersion"]}") + mavenBom("org.testcontainers:testcontainers-bom:${project.properties["testcontainersVersion"]}") } } diff --git a/docs/design/01-requirements.md b/docs/design/01-requirements.md new file mode 100644 index 00000000..2962cace --- /dev/null +++ b/docs/design/01-requirements.md @@ -0,0 +1,218 @@ +# 브랜드 & 상품 요구사항 분석 + +## 1. 요구사항 정리 + +### 1-1. 일반 사용자 (Brands / Products) + +| # | 기능 | 상세 | +|---|------|---------------------------------------| +| 1 | 브랜드 기본 정보 조회 | 브랜드 이름, 소개문구, 판매중 상품 총 개수, 공식 웹사이트 링크 | +| 2 | 브랜드 상품 목록 조회 | 선택한 브랜드에 속한 상품 목록 (페이징) | +| 3 | 상품 상세 조회 | 상품명, 가격, 좋아요 수, 생성일시, 설명, 이미지, 상태 | +| 4 | 상품 목록 정렬 | 최신순, 낮은 가격순, 높은 가격순, 좋아요 많은순 | +| 5 | 브랜드 검색 | 브랜드명 기반 LIKE 검색 | +| 6 | 상품 검색 | 상품명 기반 LIKE 검색 | + +### 1-2. 관리자 (Brands & Products ADMIN) + +| # | 기능 | 상세 | +|---|------|------| +| 1 | 브랜드 목록 조회 | 등록된 브랜드 전체 목록 (페이징) | +| 2 | 브랜드 등록 | 브랜드명, 소개문구, 웹사이트 URL | +| 3 | 브랜드 수정 | 브랜드명, 소개문구, 웹사이트 URL 수정 가능 | +| 4 | 브랜드 삭제 | Soft Delete. 해당 브랜드의 상품 일괄 soft delete (벌크 UPDATE) | +| 5 | 브랜드 상품 목록 조회 | 브랜드에 등록된 상품 목록 (페이징) | +| 6 | 브랜드 상품 상세 조회 | 상품 전체 정보 조회 | +| 7 | 상품 등록 | 브랜드에 상품 등록 (브랜드 존재 확인 필수) | +| 8 | 상품 수정 | 상품 정보 수정 (소속 브랜드 변경 불가) | +| 9 | 상품 삭제 | Soft Delete | + +## 2. 설계 결정 사항 + +| 항목 | 결정 | 이유 | +|------|------|----------------------------------------------| +| 삭제 전략 | Soft Delete | BaseEntity 패턴과 일관성 유지 | +| API 분리 | 대고객 `/api/v1`, 어드민 `/api-admin/v1` | 도메인 서비스 공유, 컨트롤러/DTO만 분리. 헤더 기반 식별 | +| 인증/인가 | 미구현 (헤더 기반 식별만) | 대고객: X-Loopers-LoginId/LoginPw, 어드민: X-Loopers-Ldap | +| 상품 이미지 | 단일 이미지 URL 필드 | 요구사항 범위 내 최소 구현 | +| 상품 상태 | `SELLING`, `SOLD_OUT` | 최소 구성. 추후 확장 예정 | +| 검색 방식 | SQL LIKE | 추후 데이터 증가 시 Full-text, ElasticSearch 등 검토 예정 | +| 페이징 | Offset 기반 (Pageable) | Spring Data 표준 활용 | +| 좋아요 수 | `likeCount` 비정규화 필드 | 조회/정렬 성능. 좋아요 도메인 연동 시 동기화 | +| Brand 참조 | `brandId(Long)` | 도메인 간 결합도 최소화 | +| 대량 삭제 | 벌크 UPDATE 쿼리 | 성능 우선. JPQL 일괄 처리 | + +## 3. 잠재 리스크 + +| 리스크 | 현재 대응 | 추후 검토 | +|--------|----------|----------| +| LIKE 검색 성능 | 데이터 소규모 시 문제없음 | Full-text index 또는 Elasticsearch | +| likeCount 동시성 | 좋아요 도메인 미구현 | 낙관적/비관적 락 또는 Redis 활용 | +| 대량 상품 삭제 | 벌크 UPDATE | 비동기 이벤트 기반 삭제 | +| soft delete 필터 누락 | 조회 쿼리에 `deletedAt IS NULL` 필수 | `@Where` 또는 QueryDSL 기본 필터 | + +--- + +# 좋아요 & 주문 요구사항 분석 + +## 4. 요구사항 정리 + +### 4-1. 좋아요 (Likes) + +| # | 기능 | 상세 | +|---|------|------| +| 1 | 상품 좋아요 등록 | 조회 중인 상품에 좋아요를 등록. 이미 좋아요 상태면 그대로 유지 (멱등) | +| 2 | 상품 좋아요 취소 | 이미 좋아요한 상품의 좋아요를 취소. 좋아요가 없는 상태면 그대로 유지 (멱등) | +| 3 | 좋아요 목록 조회 | 내가 좋아요 한 상품 목록을 상품 상세 정보 포함하여 조회 (페이징) | + +- 모든 기능은 로그인한 사용자만 수행할 수 있다. +- 멱등성 보장: DB 복합 유니크 키 + 서비스 레이어 더블 체크. + +### 4-2. 주문 (Orders) + +| # | 기능 | 상세 | +|---|------|------| +| 1 | 주문 요청 | 복수 상품 주문. 상품 스냅샷 저장, 재고 확인 및 차감 | +| 2 | 주문 목록 조회 | 본인의 주문 목록을 기간 필터(`startAt`, `endAt`)와 페이징으로 조회 | +| 3 | 주문 상세 조회 | 단일 주문의 상세 정보(주문 항목 포함) 조회 | + +- 모든 기능은 로그인한 사용자만 수행할 수 있다. +- 결제는 이번 설계에서 생략한다. + +### 4-3. 주문 ADMIN + +| # | 기능 | 상세 | +|---|------|------| +| 1 | 주문 목록 조회 | 전체 사용자의 주문 목록을 페이징으로 조회 | +| 2 | 주문 상세 조회 | 단일 주문의 상세 정보(주문 항목 포함) 조회 | + +- 모든 기능은 관리자만 수행할 수 있다. + +## 5. 설계 결정 사항 + +| 항목 | 결정 | 이유 | +|------|------|------| +| 재고 관리 | 별도 Stock 엔티티 분리 | Product와 독립적인 재고 도메인. 비관적 락 범위 최소화, 추후 입/출고 이력 확장 가능 | +| 부분 재고 부족 | 전체 주문 실패 (롤백) | 트랜잭션 단순화, 결제 연동 시에도 안전. 부분 주문 허용 시 금액 불일치 리스크 | +| likeCount 동기화 | 서비스 레이어 직접 증감 | 같은 트랜잭션 내에서 비관적 락으로 정합성 보장. 이벤트 기반 비동기 대비 구현 단순 | +| 주문 상태 | 단일 ORDERED, 취소 미구현 | 결제 생략이므로 주문 즉시 확정. 취소/환불은 결제 도입 시 함께 구현 | +| 좋아요 목록 | 상품 상세 포함 | 클라이언트 N+1 호출 방지. Product 조인으로 한 번에 반환 | +| 주문 스냅샷 | 이름 + 가격 + 이미지 | 주문 이력 확인에 필요한 핵심 정보. 상품 삭제/수정 후에도 원래 거래 조건 보존 | +| 재고 0 처리 | 자동 OUT_OF_STOCK 전환 | SELLING → OUT_OF_STOCK 자동 전환, 재입고 시 SELLING 자동 복원. SOLD_OUT은 어드민 수동 | +| 주문 목록 필터 | startAt/endAt 기간 필터 | 사용자 주문 조회 시 기간 범위 지정. 어드민은 전체 목록 + 페이징만 | +| 삭제 전략 | Soft Delete | BaseEntity 패턴과 일관성 유지 | + +## 6. 설계 고민과 결정 + +### 6-1. 좋아요 기능의 멱등성을 보장하기 위해 어떻게 설계할 수 있을까? + +**고민 배경**: 좋아요 등록을 여러 번 호출해도 결과는 '좋아요' 상태를 유지해야 하고, 취소를 여러 번 호출해도 결과는 '취소' 상태를 유지해야 한다. 네트워크 재시도나 클라이언트 중복 요청에도 부작용이 없어야 한다. + +**검토한 선택지**: + +| 선택지 | 장점 | 단점 | +|--------|------|------| +| A. DB 유니크 키만 의존 | 구현 단순 | 유니크 키 위반 시 예외 처리 필요, likeCount 정합성 리스크 | +| B. 서비스 레이어 사전 검증만 | 코드 레벨에서 명확 | 동시 요청 시 레이스 컨디션 가능 | +| **C. DB 유니크 키 + 서비스 레이어 더블 체크** | 정상 흐름은 서비스에서 처리, 동시성 엣지 케이스는 DB가 방어 | 두 곳에서 중복 로직 | + +**결정: 선택지 C (더블 체크)** + +- **서비스 레이어**: 좋아요 등록 시 `findByUserIdAndProductId`로 기존 레코드 확인. 이미 존재하면 INSERT 없이 기존 레코드 반환(멱등). 취소 시에도 레코드 없으면 무시(멱등). +- **DB 레벨**: `LIKES` 테이블에 `(user_id, product_id)` 복합 유니크 키 설정. 서비스 레이어를 우회하는 동시 요청이 들어와도 중복 INSERT가 물리적으로 불가능. +- **likeCount 정합성**: 좋아요 등록/취소 시 같은 트랜잭션 내에서 ProductEntity.likeCount를 비관적 락으로 증감. 서비스 레이어 사전 검증이 불필요한 likeCount 변경을 방지하고, 유니크 키가 최종 방어선 역할. + +### 6-2. 주문 생성 시 재고/포인트 차감을 어떻게 연결했는가? + +**고민 배경**: 주문은 복수 상품을 동시에 처리하며, 각 상품의 재고 확인과 차감이 원자적으로 이루어져야 한다. 동시 주문 시에도 재고가 음수가 되어선 안 된다. (포인트/결제는 이번 스코프에서 생략하므로, 재고 차감에 집중한다.) + +**검토한 선택지**: + +| 선택지 | 장점 | 단점 | +|--------|------|------| +| A. 낙관적 락 (버전 필드) | 락 경합 낮음, 읽기 성능 좋음 | 충돌 시 재시도 로직 필요, 인기 상품은 재시도 빈번 | +| **B. 비관적 락 (SELECT FOR UPDATE)** | 충돌 방지 확실, 재시도 불필요 | 락 대기 발생, 데드락 가능성 | +| C. Redis 분산 락 | DB 부하 분산 | 인프라 복잡도 증가, 락 해제 실패 시 처리 필요 | + +**결정: 선택지 B (비관적 락) + 데드락 방지 전략** + +- **Stock 엔티티 분리**: Product와 재고를 분리하여 비관적 락 범위를 최소화. Product 전체를 잠그지 않고 Stock만 잠금. +- **productId 오름차순 정렬 후 락 획득**: 복수 상품 주문 시 항상 같은 순서로 락을 획득하여 데드락 방지. +- **전체 롤백 정책**: 하나라도 재고 부족이면 전체 트랜잭션 롤백. 부분 주문을 허용하면 추후 결제 연동 시 금액 불일치 리스크가 발생하므로 all-or-nothing. +- **OUT_OF_STOCK 자동 전환**: 재고 차감 후 0이 되면 ProductEntity.status를 자동으로 OUT_OF_STOCK으로 변경. 재입고 시(Stock 증가) 자동 SELLING 복원. 영구 판매중지(SOLD_OUT)는 어드민이 수동으로 관리. +- **확장 포인트**: 결제/포인트 차감이 추가될 때, Facade에 결제 서비스 호출을 순서에 맞게 추가하면 된다. 결제 실패 시 재고 복원도 같은 트랜잭션 내에서 처리. + +### 6-3. 도메인 객체를 어떻게 나누고 책임을 위임했는가? + +**고민 배경**: 기존 User, Brand, Product는 Domain ↔ Entity 분리 패턴을 따르고 있다. 새로운 도메인(Like, Order, Stock)도 같은 패턴을 적용해야 할지, 도메인의 특성에 따라 달리할 것인지가 문제. + +**도메인별 결정**: + +| 도메인 | Domain ↔ Entity 분리 | 이유 | +|--------|:---:|------| +| Like | X (Entity만) | userId + productId 조합 외에 별도 비즈니스 로직이 없음. 유효성 검증도 없으므로 Entity가 곧 도메인 모델 | +| Order | X (Entity만) | 현재 상태는 ORDERED 하나. 상태 전이 로직이 생기면(결제 도입 시) 그때 분리 검토 | +| OrderItem | X (Entity만) | 스냅샷 데이터 저장이 핵심 책임. 비즈니스 로직보다 데이터 보존 역할 | +| Stock | O (Domain + Entity) | 재고 차감/증가 시 유효성 검증 필요 (음수 방지, 차감 가능 여부 확인). 도메인 객체에서 `deduct()`, `increase()` 메서드로 비즈니스 룰 캡슐화 | + +**책임 위임 구조**: + +| 레이어 | 역할 | +|--------|------| +| **Facade** | 도메인 간 오케스트레이션. LikeFacade는 UserService + LikeService + ProductService를 조합. OrderFacade는 UserService + ProductService + StockService + OrderService를 조합 | +| **Service** | 단일 도메인 비즈니스 로직. LikeService는 좋아요 CRUD + 멱등성 검증. OrderService는 주문/주문항목 생성과 조회. StockService는 재고 차감/증가 + OUT_OF_STOCK 자동 전환 | +| **Repository** | 영속성 인터페이스. 도메인 레이어에 인터페이스, Infrastructure에 구현체 | + +**StockService → ProductService 직접 의존에 대한 고민**: + +StockService가 재고 0일 때 ProductService를 호출하여 상태를 변경하는 것은 Service 간 직접 의존이다. 이를 Facade에서 처리할 수도 있었지만, "재고가 0이면 품절 상태로 전환"은 재고 도메인의 핵심 정책이므로 StockService에 두었다. 추후 결합도가 문제되면 도메인 이벤트로 분리할 수 있다. + +### 6-4. ERD 설계 시 어떤 부분에서 고민이 있었는가? + +**고민 1: LIKES 테이블의 soft delete vs hard delete** + +좋아요 취소 시 레코드를 soft delete 할지 hard delete 할지가 문제였다. + +| 선택지 | 장점 | 단점 | +|--------|------|------| +| A. Soft delete (deletedAt) | 이력 추적 가능, BaseEntity 일관성 | 유니크 키에 deletedAt 포함 필요, 복잡도 증가 | +| **B. Hard delete** | 유니크 키 단순 (user_id, product_id), 멱등성 보장 직관적 | 좋아요 이력 소실 | + +**결정: Hard delete**. 좋아요는 이력 보존 필요성이 낮고, soft delete 시 유니크 키에 deletedAt을 포함해야 하는데 이는 멱등성 보장 로직을 복잡하게 만든다. 같은 사용자가 좋아요 → 취소 → 재좋아요 할 때 soft delete 레코드가 유니크 키 충돌을 일으키는 문제를 원천 차단한다. + +**고민 2: ORDER_ITEM.product_id를 FK로 설정할 것인가?** + +| 선택지 | 장점 | 단점 | +|--------|------|------| +| A. FK 설정 | 참조 무결성 보장 | 상품 삭제 시 주문 이력 조회 불가능 또는 삭제 차단 | +| **B. FK 미설정 (참조용 ID만)** | 상품 soft delete 후에도 주문 이력 보존 | DB 레벨 무결성 보장 없음 | + +**결정: FK 미설정**. 주문 스냅샷은 주문 시점의 상품 정보를 독립적으로 보존하는 것이 목적이다. product_id는 원본 상품 추적용 참조 ID일 뿐, 상품의 존재 여부와 무관하게 주문 이력이 유지되어야 한다. + +**고민 3: Stock을 Product 내 필드로 둘 것인가, 별도 테이블로 분리할 것인가?** + +| 선택지 | 장점 | 단점 | +|--------|------|------| +| A. Product 내 stock 필드 | 조인 불필요, 단순 | 비관적 락 시 Product 전체가 잠김, 좋아요 등 다른 연산과 락 경합 | +| **B. 별도 STOCK 테이블** | 락 범위 최소화, 재고 도메인 독립성 | 조인 비용, 테이블 관리 포인트 증가 | + +**결정: 별도 STOCK 테이블**. 주문 시 비관적 락으로 재고를 차감하는데, Product에 stock 필드를 두면 좋아요(likeCount 증감)와 재고 차감이 같은 행에서 락 경합을 일으킨다. Stock을 분리하면 재고 락과 좋아요 락이 독립적으로 동작한다. + +**고민 4: ProductStatus에 OUT_OF_STOCK 추가** + +기존 설계는 `SELLING`과 `SOLD_OUT` 두 가지 상태만 존재했다. 그러나 재고가 0이 된 상황(일시적 품절)과 어드민이 의도적으로 판매를 중지한 상황(영구 판매 중지)은 성격이 다르다. + +**결정: 3단계 상태** +- `SELLING` — 판매 중 (재고 > 0) +- `OUT_OF_STOCK` — 일시적 품절 (재고 = 0, 재입고 시 자동 SELLING 복원) +- `SOLD_OUT` — 영구 판매 중지 (어드민 수동 설정) + +## 7. 잠재 리스크 + +| 리스크 | 현재 대응 | 추후 검토 | +|--------|----------|----------| +| 주문 트랜잭션 비대화 | productId 오름차순 락 획득으로 데드락 방지 | 분산 락(Redis) 또는 Saga 패턴 | +| likeCount 락 경합 | 비관적 락 + Stock 테이블 분리로 경합 분리 | Redis 카운터 + 배치 동기화 | +| StockService → ProductService 결합 | Service 간 직접 의존 허용 (재고 정책) | 도메인 이벤트로 분리 | +| 스냅샷 데이터 누적 | 현재 규모에서 문제 없음 | 아카이빙 정책 검토 | +| OUT_OF_STOCK 복원 누락 | 재입고(Stock 증가) 시 자동 SELLING 복원 | 어드민 재고 관리 API 필요 | \ No newline at end of file diff --git a/docs/design/02-sequence-diagrams.md b/docs/design/02-sequence-diagrams.md new file mode 100644 index 00000000..1627efc3 --- /dev/null +++ b/docs/design/02-sequence-diagrams.md @@ -0,0 +1,667 @@ +# 시퀀스 다이어그램 — 브랜드 & 상품 + +## API 공통 규칙 + +- 대고객 API: `/api/v1` prefix. 유저 식별 시 `X-Loopers-LoginId`, `X-Loopers-LoginPw` 헤더 사용. +- 어드민 API: `/api-admin/v1` prefix. 어드민 식별 시 `X-Loopers-Ldap: loopers.admin` 헤더 사용. +- 인증/인가는 구현하지 않으며, 헤더 기반 식별만 수행한다. + +--- + +## 1. 일반 사용자 — 브랜드 상세 조회 + +Facade가 BrandService와 ProductService를 조합하여 브랜드 기본 정보 + 판매중 상품 수를 반환한다. + +```mermaid +sequenceDiagram + actor User as 일반 사용자 + participant Controller as BrandController + participant Facade as BrandFacade + participant BrandService as BrandService + participant ProductService as ProductService + participant BrandRepo as BrandRepository + participant ProductRepo as ProductRepository + + User->>Controller: GET /api/v1/brands/{brandId} + Controller->>Facade: getBrandDetail(brandId) + Facade->>BrandService: getBrand(brandId) + BrandService->>BrandRepo: findById(brandId) + BrandRepo-->>BrandService: BrandEntity + BrandService-->>Facade: BrandEntity + Facade->>ProductService: countSellingProducts(brandId) + ProductService->>ProductRepo: countByBrandIdAndStatus(brandId, SELLING) + ProductRepo-->>ProductService: count + ProductService-->>Facade: count + Facade-->>Controller: BrandInfo (이름, 소개, 웹사이트, 판매중 상품수) + Controller-->>User: ApiResponse +``` + +- 포인트: Facade가 BrandService와 ProductService를 각각 호출하여 조합한다. 브랜드 기본 정보와 판매중 상품 개수는 서로 다른 서비스의 책임이므로, Facade에서 조합하는 것이 적절하다. + +## 2. 일반 사용자 — 상품 목록 조회 (정렬/페이징) + +정렬 조건은 Controller에서 enum으로 변환, Repository에서 동적 정렬 처리. + +```mermaid +sequenceDiagram + actor User as 일반 사용자 + participant Controller as ProductController + participant Facade as ProductFacade + participant ProductService as ProductService + participant ProductRepo as ProductRepository + + User->>Controller: GET /api/v1/brands/{brandId}/products?sort=PRICE_ASC&page=0&size=20 + Controller->>Facade: getProducts(brandId, sort, pageable) + Facade->>ProductService: getProductsByBrand(brandId, sort, pageable) + ProductService->>ProductRepo: findByBrandId(brandId, sort, pageable) + ProductRepo-->>ProductService: Page + ProductService-->>Facade: Page + Facade-->>Controller: PageInfo + Controller-->>User: ApiResponse> +``` + +- 포인트: 정렬 조건은 Controller에서 enum으로 변환하여 전달하고, Repository에서 동적 정렬을 처리한다. Pageable은 Spring Data의 표준 페이징을 활용한다. + +## 3. 일반 사용자 — 브랜드 검색 + +```mermaid +sequenceDiagram + actor User as 일반 사용자 + participant Controller as BrandController + participant Facade as BrandFacade + participant BrandService as BrandService + participant BrandRepo as BrandRepository + + User->>Controller: GET /api/v1/brands/search?keyword=나이키&page=0&size=20 + Controller->>Facade: searchBrands(keyword, pageable) + Facade->>BrandService: searchBrands(keyword, pageable) + BrandService->>BrandRepo: searchByName(keyword, pageable) + BrandRepo-->>BrandService: Page + BrandService-->>Facade: Page + Facade-->>Controller: PageInfo + Controller-->>User: ApiResponse> +``` + +## 4. 일반 사용자 — 상품 검색 + +```mermaid +sequenceDiagram + actor User as 일반 사용자 + participant Controller as ProductController + participant Facade as ProductFacade + participant ProductService as ProductService + participant ProductRepo as ProductRepository + + User->>Controller: GET /api/v1/products/search?keyword=운동화&page=0&size=20 + Controller->>Facade: searchProducts(keyword, pageable) + Facade->>ProductService: searchProducts(keyword, pageable) + ProductService->>ProductRepo: searchByName(keyword, pageable) + ProductRepo-->>ProductService: Page + ProductService-->>Facade: Page + Facade-->>Controller: PageInfo + Controller-->>User: ApiResponse> +``` + +## 5. Admin — 브랜드 목록 조회 + +```mermaid +sequenceDiagram + actor Admin as 관리자 + participant Controller as AdminBrandController + participant Facade as BrandFacade + participant BrandService as BrandService + participant BrandRepo as BrandRepository + + Note over Admin, Controller: Header: X-Loopers-Ldap: loopers.admin + Admin->>Controller: GET /api-admin/v1/brands?page=0&size=20 + Controller->>Facade: getBrands(pageable) + Facade->>BrandService: getBrands(pageable) + BrandService->>BrandRepo: findAll(pageable) + BrandRepo-->>BrandService: Page + BrandService-->>Facade: Page + Facade-->>Controller: PageInfo + Controller-->>Admin: ApiResponse> +``` + +## 6. Admin — 브랜드 등록 + +```mermaid +sequenceDiagram + actor Admin as 관리자 + participant Controller as AdminBrandController + participant Facade as BrandFacade + participant BrandService as BrandService + participant BrandRepo as BrandRepository + + Note over Admin, Controller: Header: X-Loopers-Ldap: loopers.admin + Admin->>Controller: POST /api-admin/v1/brands + Controller->>Facade: registerBrand(command) + Facade->>BrandService: registerBrand(brand) + Note over BrandService: Brand 도메인 객체 생성 (유효성 검증) + BrandService->>BrandRepo: save(brandEntity) + BrandRepo-->>BrandService: BrandEntity + BrandService-->>Facade: BrandEntity + Facade-->>Controller: BrandInfo + Controller-->>Admin: ApiResponse +``` + +## 7. Admin — 브랜드 수정 + +```mermaid +sequenceDiagram + actor Admin as 관리자 + participant Controller as AdminBrandController + participant Facade as BrandFacade + participant BrandService as BrandService + participant BrandRepo as BrandRepository + + Note over Admin, Controller: Header: X-Loopers-Ldap: loopers.admin + Admin->>Controller: PUT /api-admin/v1/brands/{brandId} + Controller->>Facade: updateBrand(brandId, command) + Facade->>BrandService: updateBrand(brandId, brand) + BrandService->>BrandRepo: findById(brandId) + BrandRepo-->>BrandService: BrandEntity + Note over BrandService: Brand 도메인 객체로 유효성 검증 + Note over BrandService: brandEntity.update(name, introduction, websiteUrl) + BrandService->>BrandRepo: save(brandEntity) + BrandRepo-->>BrandService: BrandEntity + BrandService-->>Facade: BrandEntity + Facade-->>Controller: BrandInfo + Controller-->>Admin: ApiResponse +``` + +## 8. Admin — 브랜드 삭제 (상품 일괄 삭제) + +하나의 트랜잭션 안에서 상품 벌크 soft delete -> 브랜드 soft delete 순서로 처리. + +```mermaid +sequenceDiagram + actor Admin as 관리자 + participant Controller as AdminBrandController + participant Facade as BrandFacade + participant BrandService as BrandService + participant ProductService as ProductService + participant BrandRepo as BrandRepository + participant ProductRepo as ProductRepository + + Note over Admin, Controller: Header: X-Loopers-Ldap: loopers.admin + Admin->>Controller: DELETE /api-admin/v1/brands/{brandId} + Controller->>Facade: deleteBrand(brandId) + + rect rgb(255, 240, 240) + Note over Facade, ProductRepo: @Transactional 경계 + Facade->>ProductService: deleteAllByBrandId(brandId) + ProductService->>ProductRepo: bulkSoftDeleteByBrandId(brandId) + Note over ProductRepo: UPDATE product SET deleted_at = NOW() WHERE brand_id = ? + ProductRepo-->>ProductService: void + ProductService-->>Facade: void + Facade->>BrandService: deleteBrand(brandId) + BrandService->>BrandRepo: softDelete(brandId) + BrandRepo-->>BrandService: void + BrandService-->>Facade: void + end + + Facade-->>Controller: void + Controller-->>Admin: ApiResponse +``` + +- 포인트: 브랜드 삭제와 상품 일괄 삭제는 하나의 트랜잭션 안에서 처리된다. 상품을 먼저 삭제한 후 브랜드를 삭제하는 순서. Facade 레벨에서 @Transactional을 걸어 두 서비스 호출을 묶는다. + +## 9. Admin — 상품 등록 + +상품 등록 시 브랜드 존재 여부 확인 필수. Product 도메인 객체에서 유효성 검증 수행. + +```mermaid +sequenceDiagram + actor Admin as 관리자 + participant Controller as AdminProductController + participant Facade as ProductFacade + participant BrandService as BrandService + participant ProductService as ProductService + participant BrandRepo as BrandRepository + participant ProductRepo as ProductRepository + + Note over Admin, Controller: Header: X-Loopers-Ldap: loopers.admin + Admin->>Controller: POST /api-admin/v1/brands/{brandId}/products + Controller->>Facade: registerProduct(brandId, command) + Facade->>BrandService: getBrand(brandId) + BrandService->>BrandRepo: findById(brandId) + BrandRepo-->>BrandService: BrandEntity + BrandService-->>Facade: BrandEntity + Facade->>ProductService: registerProduct(brandId, command) + Note over ProductService: Product 도메인 객체 생성 (유효성 검증) + ProductService->>ProductRepo: save(productEntity) + ProductRepo-->>ProductService: ProductEntity + ProductService-->>Facade: ProductEntity + Facade-->>Controller: ProductInfo + Controller-->>Admin: ApiResponse +``` + +- 포인트: 상품 등록 시 브랜드 존재 여부를 먼저 확인한다. 존재하지 않으면 CoreException(NOT_FOUND)을 던진다. Product 도메인 객체에서 비즈니스 유효성 검증(가격 > 0 등)을 수행한다. + +## 10. Admin — 상품 수정 + +상품 수정 시 소속 브랜드는 변경 불가. + +```mermaid +sequenceDiagram + actor Admin as 관리자 + participant Controller as AdminProductController + participant Facade as ProductFacade + participant ProductService as ProductService + participant ProductRepo as ProductRepository + + Note over Admin, Controller: Header: X-Loopers-Ldap: loopers.admin + Admin->>Controller: PUT /api-admin/v1/brands/{brandId}/products/{productId} + Controller->>Facade: updateProduct(productId, command) + Facade->>ProductService: updateProduct(productId, command) + ProductService->>ProductRepo: findById(productId) + ProductRepo-->>ProductService: ProductEntity + Note over ProductService: Product 도메인 객체로 유효성 검증 + Note over ProductService: productEntity.update(name, price, description, imageUrl, status) + ProductService->>ProductRepo: save(productEntity) + ProductRepo-->>ProductService: ProductEntity + ProductService-->>Facade: ProductEntity + Facade-->>Controller: ProductInfo + Controller-->>Admin: ApiResponse +``` + +## 11. Admin — 상품 삭제 + +```mermaid +sequenceDiagram + actor Admin as 관리자 + participant Controller as AdminProductController + participant Facade as ProductFacade + participant ProductService as ProductService + participant ProductRepo as ProductRepository + + Note over Admin, Controller: Header: X-Loopers-Ldap: loopers.admin + Admin->>Controller: DELETE /api-admin/v1/brands/{brandId}/products/{productId} + Controller->>Facade: deleteProduct(productId) + Facade->>ProductService: deleteProduct(productId) + ProductService->>ProductRepo: findById(productId) + ProductRepo-->>ProductService: ProductEntity + Note over ProductService: productEntity.delete() (soft delete) + ProductService->>ProductRepo: save(productEntity) + ProductRepo-->>ProductService: void + ProductService-->>Facade: void + Facade-->>Controller: void + Controller-->>Admin: ApiResponse +``` + +## 12. Admin — 브랜드 상품 목록 조회 + +```mermaid +sequenceDiagram + actor Admin as 관리자 + participant Controller as AdminProductController + participant Facade as ProductFacade + participant ProductService as ProductService + participant ProductRepo as ProductRepository + + Note over Admin, Controller: Header: X-Loopers-Ldap: loopers.admin + Admin->>Controller: GET /api-admin/v1/brands/{brandId}/products?page=0&size=20 + Controller->>Facade: getProductsByBrand(brandId, pageable) + Facade->>ProductService: getProductsByBrand(brandId, pageable) + ProductService->>ProductRepo: findByBrandId(brandId, pageable) + ProductRepo-->>ProductService: Page + ProductService-->>Facade: Page + Facade-->>Controller: PageInfo + Controller-->>Admin: ApiResponse> +``` + +## 13. Admin — 브랜드 상품 상세 조회 + +```mermaid +sequenceDiagram + actor Admin as 관리자 + participant Controller as AdminProductController + participant Facade as ProductFacade + participant ProductService as ProductService + participant ProductRepo as ProductRepository + + Note over Admin, Controller: Header: X-Loopers-Ldap: loopers.admin + Admin->>Controller: GET /api-admin/v1/brands/{brandId}/products/{productId} + Controller->>Facade: getProduct(productId) + Facade->>ProductService: getProduct(productId) + ProductService->>ProductRepo: findById(productId) + ProductRepo-->>ProductService: ProductEntity + ProductService-->>Facade: ProductEntity + Facade-->>Controller: ProductInfo + Controller-->>Admin: ApiResponse +``` + +--- + +# 시퀀스 다이어그램 — 좋아요 & 주문 + +## 14. 좋아요 등록 + +멱등성 보장이 핵심. 서비스 레이어에서 기존 레코드 존재 여부를 먼저 확인하고, 이미 좋아요 상태면 에러 없이 기존 레코드를 반환한다. likeCount 증감은 같은 트랜잭션 내에서 비관적 락으로 처리한다. + +```mermaid +sequenceDiagram + actor User as 사용자 + participant Controller as LikeController + participant Facade as LikeFacade + participant UserSvc as UserService + participant ProductSvc as ProductService + participant LikeSvc as LikeService + participant LikeRepo as LikeRepository + participant ProductRepo as ProductRepository + + User->>Controller: POST /api/v1/products/{productId}/likes + Note over User, Controller: X-Loopers-LoginId / X-Loopers-LoginPw 헤더로 사용자 식별 + Controller->>Facade: like(loginId, productId) + + Facade->>UserSvc: getUserByLoginId(loginId) + UserSvc-->>Facade: UserEntity + + Facade->>ProductSvc: getProduct(productId) + ProductSvc-->>Facade: ProductEntity (존재 확인) + + rect rgb(255, 240, 220) + Note over LikeSvc, ProductRepo: 트랜잭션 경계 + Facade->>LikeSvc: like(userId, productId) + LikeSvc->>LikeRepo: findByUserIdAndProductId(userId, productId) + + alt 이미 좋아요 상태 + LikeRepo-->>LikeSvc: Optional(LikeEntity) + LikeSvc-->>Facade: 기존 레코드 반환 (멱등) + else 좋아요 없음 + LikeRepo-->>LikeSvc: Optional.empty() + LikeSvc->>LikeRepo: save(new LikeEntity) + LikeSvc->>ProductSvc: increaseLikeCount(productId) + Note right of ProductSvc: 비관적 락으로 likeCount + 1 + ProductSvc->>ProductRepo: SELECT FOR UPDATE → UPDATE likeCount + LikeSvc-->>Facade: 새 LikeEntity 반환 + end + end + + Facade-->>Controller: LikedProductInfo + Controller-->>User: ApiResponse (SUCCESS) +``` + +- 포인트: 서비스 레이어 사전 검증이 불필요한 likeCount 변경을 방지한다. DB 복합 유니크 키 `(user_id, product_id)`는 동시 요청 시 최종 방어선 역할. + +--- + +## 15. 좋아요 취소 + +등록과 대칭적으로 멱등성을 보장한다. 좋아요 레코드가 없으면 에러 없이 무시한다. + +```mermaid +sequenceDiagram + actor User as 사용자 + participant Controller as LikeController + participant Facade as LikeFacade + participant UserSvc as UserService + participant LikeSvc as LikeService + participant ProductSvc as ProductService + participant LikeRepo as LikeRepository + participant ProductRepo as ProductRepository + + User->>Controller: DELETE /api/v1/products/{productId}/likes + Note over User, Controller: X-Loopers-LoginId / X-Loopers-LoginPw 헤더로 사용자 식별 + Controller->>Facade: unlike(loginId, productId) + + Facade->>UserSvc: getUserByLoginId(loginId) + UserSvc-->>Facade: UserEntity + + rect rgb(255, 240, 220) + Note over LikeSvc, ProductRepo: 트랜잭션 경계 + Facade->>LikeSvc: unlike(userId, productId) + LikeSvc->>LikeRepo: findByUserIdAndProductId(userId, productId) + + alt 좋아요 레코드 없음 + LikeRepo-->>LikeSvc: Optional.empty() + LikeSvc-->>Facade: 무시 (멱등) + else 좋아요 존재 + LikeRepo-->>LikeSvc: Optional(LikeEntity) + LikeSvc->>LikeRepo: delete(likeEntity) + Note right of LikeRepo: Hard delete (soft delete 아님) + LikeSvc->>ProductSvc: decreaseLikeCount(productId) + Note right of ProductSvc: 비관적 락으로 likeCount - 1 + ProductSvc->>ProductRepo: SELECT FOR UPDATE → UPDATE likeCount + LikeSvc-->>Facade: 완료 + end + end + + Facade-->>Controller: void + Controller-->>User: ApiResponse (SUCCESS) +``` + +- 포인트: 좋아요는 hard delete를 사용한다. soft delete 시 유니크 키에 deletedAt을 포함해야 하며, 재좋아요 시 복잡도가 증가하기 때문. + +--- + +## 16. 좋아요 목록 조회 + +사용자가 좋아요 한 상품 목록을 상품 상세 포함하여 조회한다. + +```mermaid +sequenceDiagram + actor User as 사용자 + participant Controller as LikeController + participant Facade as LikeFacade + participant UserSvc as UserService + participant LikeSvc as LikeService + participant LikeRepo as LikeRepository + + User->>Controller: GET /api/v1/likes?page=0&size=20 + Note over User, Controller: X-Loopers-LoginId / X-Loopers-LoginPw 헤더로 사용자 식별 + Controller->>Facade: getLikedProducts(loginId, pageable) + + Facade->>UserSvc: getUserByLoginId(loginId) + UserSvc-->>Facade: UserEntity + + Facade->>LikeSvc: getLikedProducts(userId, pageable) + LikeSvc->>LikeRepo: findByUserIdWithProduct(userId, pageable) + Note right of LikeRepo: Like JOIN Product 조회 + LikeRepo-->>LikeSvc: Page + LikeSvc-->>Facade: Page + + Facade-->>Controller: Page + Controller-->>User: ApiResponse (Page) +``` + +- 포인트: 헤더의 loginId로 사용자를 식별하므로 별도의 pathVariable 없이 본인의 좋아요 목록만 조회 가능하다. + +--- + +## 17. 주문 생성 + +핵심 흐름: 상품 존재/판매 상태 확인 → 재고 비관적 락 차감 → 스냅샷과 함께 주문 생성. 하나라도 실패하면 전체 롤백. + +```mermaid +sequenceDiagram + actor User as 사용자 + participant Controller as OrderController + participant Facade as OrderFacade + participant UserSvc as UserService + participant ProductSvc as ProductService + participant StockSvc as StockService + participant OrderSvc as OrderService + participant StockRepo as StockRepository + participant OrderRepo as OrderRepository + + User->>Controller: POST /api/v1/orders { items: [{productId, quantity}, ...] } + Note over User, Controller: X-Loopers-LoginId / X-Loopers-LoginPw 헤더로 사용자 식별 + Controller->>Facade: createOrder(loginId, orderCommand) + + Facade->>UserSvc: getUserByLoginId(loginId) + UserSvc-->>Facade: UserEntity + + rect rgb(255, 240, 220) + Note over Facade, OrderRepo: 트랜잭션 경계 + Note over Facade: productId 오름차순 정렬 (데드락 방지) + + loop 각 주문 항목 (productId 오름차순) + Facade->>ProductSvc: getProduct(productId) + ProductSvc-->>Facade: ProductEntity (존재/판매중 확인) + + Facade->>StockSvc: deductStock(productId, quantity) + StockSvc->>StockRepo: findByProductIdWithLock(productId) + Note right of StockRepo: SELECT FOR UPDATE + StockRepo-->>StockSvc: StockEntity + + alt 재고 부족 (quantity > stock) + StockSvc-->>Facade: CoreException (재고 부족) + Note over Facade, OrderRepo: 전체 롤백 + else 재고 충분 + StockSvc->>StockRepo: save (quantity 차감) + alt 차감 후 재고 = 0 + StockSvc->>ProductSvc: changeStatus(productId, OUT_OF_STOCK) + end + StockSvc-->>Facade: StockEntity + end + end + + Note over Facade: 상품 정보 스냅샷 생성 (name, price, imageUrl) + Facade->>OrderSvc: createOrder(userId, orderItemSnapshots) + OrderSvc->>OrderRepo: save(OrderEntity + OrderItemEntities) + OrderSvc-->>Facade: OrderEntity + end + + Facade-->>Controller: OrderInfo + Controller-->>User: ApiResponse (SUCCESS) +``` + +- 포인트: productId 오름차순 정렬 후 순차적으로 락을 획득하여 데드락을 방지한다. 스냅샷 생성은 재고 차감이 모두 성공한 후, 주문 저장 직전에 수행. + +--- + +## 18. 주문 목록 조회 (사용자) + +기간 필터(startAt, endAt)와 페이징을 지원한다. + +```mermaid +sequenceDiagram + actor User as 사용자 + participant Controller as OrderController + participant Facade as OrderFacade + participant UserSvc as UserService + participant OrderSvc as OrderService + participant OrderRepo as OrderRepository + + User->>Controller: GET /api/v1/orders?startAt=2026-01-31&endAt=2026-02-10&page=0&size=20 + Note over User, Controller: X-Loopers-LoginId / X-Loopers-LoginPw 헤더로 사용자 식별 + Controller->>Facade: getOrders(loginId, startAt, endAt, pageable) + + Facade->>UserSvc: getUserByLoginId(loginId) + UserSvc-->>Facade: UserEntity + + Facade->>OrderSvc: getOrdersByUserId(userId, startAt, endAt, pageable) + OrderSvc->>OrderRepo: findByUserIdAndCreatedAtBetween(userId, startAt, endAt, pageable) + OrderRepo-->>OrderSvc: Page + OrderSvc-->>Facade: Page + + Facade-->>Controller: Page + Controller-->>User: ApiResponse (Page) +``` + +- 포인트: 기간 필터는 `createdAt` 기준. startAt, endAt은 선택적 파라미터로, 미제공 시 전체 조회. + +--- + +## 19. 주문 상세 조회 (사용자) + +주문 기본 정보 + 주문 항목(스냅샷 포함)을 조회한다. + +```mermaid +sequenceDiagram + actor User as 사용자 + participant Controller as OrderController + participant Facade as OrderFacade + participant UserSvc as UserService + participant OrderSvc as OrderService + participant OrderRepo as OrderRepository + participant OrderItemRepo as OrderItemRepository + + User->>Controller: GET /api/v1/orders/{orderId} + Note over User, Controller: X-Loopers-LoginId / X-Loopers-LoginPw 헤더로 사용자 식별 + Controller->>Facade: getOrderDetail(loginId, orderId) + + Facade->>UserSvc: getUserByLoginId(loginId) + UserSvc-->>Facade: UserEntity + + Facade->>OrderSvc: getOrder(orderId) + OrderSvc->>OrderRepo: findById(orderId) + OrderRepo-->>OrderSvc: OrderEntity + OrderSvc-->>Facade: OrderEntity + + Note over Facade: 주문의 userId와 요청자 userId 일치 확인 + + Facade->>OrderSvc: getOrderItems(orderId) + OrderSvc->>OrderItemRepo: findByOrderId(orderId) + OrderItemRepo-->>OrderSvc: List + OrderSvc-->>Facade: List + + Facade-->>Controller: OrderDetailInfo (주문 + 항목 스냅샷) + Controller-->>User: ApiResponse (OrderDetail) +``` + +- 포인트: Facade에서 주문 소유자 확인. 타 유저의 주문 상세 조회를 차단. + +--- + +## 20. 주문 목록 조회 (어드민) + +전체 주문을 페이징으로 조회한다. 필터 없음. + +```mermaid +sequenceDiagram + actor Admin as 관리자 + participant Controller as AdminOrderController + participant Facade as OrderFacade + participant OrderSvc as OrderService + participant OrderRepo as OrderRepository + + Admin->>Controller: GET /api-admin/v1/orders?page=0&size=20 + Note over Admin, Controller: X-Loopers-Ldap: loopers.admin 헤더 확인 + Controller->>Facade: getAllOrders(pageable) + + Facade->>OrderSvc: getAllOrders(pageable) + OrderSvc->>OrderRepo: findAll(pageable) + OrderRepo-->>OrderSvc: Page + OrderSvc-->>Facade: Page + + Facade-->>Controller: Page + Controller-->>Admin: ApiResponse (Page) +``` + +--- + +## 21. 주문 상세 조회 (어드민) + +어드민은 소유자 확인 없이 모든 주문의 상세를 조회할 수 있다. + +```mermaid +sequenceDiagram + actor Admin as 관리자 + participant Controller as AdminOrderController + participant Facade as OrderFacade + participant OrderSvc as OrderService + participant OrderRepo as OrderRepository + participant OrderItemRepo as OrderItemRepository + + Admin->>Controller: GET /api-admin/v1/orders/{orderId} + Note over Admin, Controller: X-Loopers-Ldap: loopers.admin 헤더 확인 + Controller->>Facade: getOrderDetailForAdmin(orderId) + + Facade->>OrderSvc: getOrder(orderId) + OrderSvc->>OrderRepo: findById(orderId) + OrderRepo-->>OrderSvc: OrderEntity + OrderSvc-->>Facade: OrderEntity + + Facade->>OrderSvc: getOrderItems(orderId) + OrderSvc->>OrderItemRepo: findByOrderId(orderId) + OrderItemRepo-->>OrderSvc: List + OrderSvc-->>Facade: List + + Facade-->>Controller: OrderDetailInfo (주문 + 항목 스냅샷) + Controller-->>Admin: ApiResponse (OrderDetail) +``` + +- 포인트: 사용자 상세 조회(19번)와 동일한 흐름이지만 소유자 확인을 생략한다. Facade에 별도 메서드(`getOrderDetailForAdmin`)로 분리. diff --git a/docs/design/03-class-diagram.md b/docs/design/03-class-diagram.md new file mode 100644 index 00000000..948de2b7 --- /dev/null +++ b/docs/design/03-class-diagram.md @@ -0,0 +1,300 @@ +# 클래스 다이어그램 + +도메인 객체의 책임, 의존 방향, 응집도를 검증하기 위해 작성한다. +레이어 구조(Controller, Facade, Repository 등)는 시퀀스 다이어그램에서 확인하며, 여기서는 도메인 모델에 집중한다. + +--- + +## 1. Brand & Product 도메인 + +Brand와 Product의 도메인 객체, 엔티티, 그리고 두 도메인 간 관계를 표현한다. + +```mermaid +classDiagram + direction TB + + class Brand { + -String name + -String introduction + -String websiteUrl + +Brand(name, introduction, websiteUrl) + -validateName(name) + -validateIntroduction(introduction) + } + + class BrandEntity { + -String name + -String introduction + -String websiteUrl + +from(Brand)$ BrandEntity + +update(name, introduction, websiteUrl) + } + + class Product { + -String name + -Long price + -String description + -String imageUrl + -ProductStatus status + +Product(name, price, description, imageUrl, status) + -validateName(name) + -validatePrice(price) + } + + class ProductStatus { + <> + SELLING + OUT_OF_STOCK + SOLD_OUT + } + + class ProductEntity { + -Long brandId + -String name + -Long price + -String description + -String imageUrl + -ProductStatus status + -Long likeCount + +from(Product, Long brandId)$ ProductEntity + +update(name, price, description, imageUrl, status) + +increaseLikeCount() + +decreaseLikeCount() + } + + class BaseEntity { + <> + #Long id + #ZonedDateTime createdAt + #ZonedDateTime updatedAt + #ZonedDateTime deletedAt + +delete() + +restore() + } + + BrandEntity --|> BaseEntity + ProductEntity --|> BaseEntity + ProductEntity --> ProductStatus + ProductEntity ..> BrandEntity : brandId로 참조 + Brand <.. BrandEntity : from()으로 변환 + Product <.. ProductEntity : from()으로 변환 +``` + +### 포인트 +- **Domain ↔ Entity 분리**: Brand, Product는 순수 도메인 객체(유효성 검증), BrandEntity, ProductEntity는 JPA 영속 객체. +- **ProductEntity.brandId**: `@ManyToOne` 대신 Long ID 참조로 도메인 간 결합도 최소화. +- **ProductEntity.likeCount**: 비정규화 필드. Like 도메인과 같은 트랜잭션에서 비관적 락으로 동기화. +- **ProductStatus 3단계**: `SELLING` → `OUT_OF_STOCK`(재고 0 자동) → `SOLD_OUT`(어드민 수동). + +--- + +## 2. Like 도메인 + +Like는 비즈니스 로직이 단순하여 Domain ↔ Entity 분리 없이 Entity가 도메인 모델을 겸한다. + +```mermaid +classDiagram + direction TB + + class LikeEntity { + -Long userId + -Long productId + +LikeEntity(userId, productId) + } + + class BaseEntity { + <> + #Long id + #ZonedDateTime createdAt + #ZonedDateTime updatedAt + #ZonedDateTime deletedAt + } + + LikeEntity --|> BaseEntity + LikeEntity ..> UserEntity : userId로 참조 + LikeEntity ..> ProductEntity : productId로 참조 +``` + +### 포인트 +- **Domain ↔ Entity 분리 안 함**: userId + productId 조합 외에 유효성 검증이나 비즈니스 로직이 없음. +- **Hard delete**: soft delete 시 유니크 키 충돌 문제를 피하기 위해 물리 삭제. BaseEntity를 상속하지만 `deletedAt`은 사용하지 않음. +- **복합 유니크 키**: `(userId, productId)` — 멱등성 보장의 DB 레벨 방어선. + +--- + +## 3. Order & OrderItem 도메인 + +Order는 현재 상태가 ORDERED 하나이므로 Domain ↔ Entity 분리를 하지 않는다. OrderItem은 주문 시점의 상품 스냅샷을 보존하는 역할. + +```mermaid +classDiagram + direction TB + + class OrderEntity { + -Long userId + -OrderStatus status + +OrderEntity(userId, status) + } + + class OrderStatus { + <> + ORDERED + } + + class OrderItemEntity { + -Long orderId + -Long productId + -Long quantity + -String productName + -Long productPrice + -String productImageUrl + +OrderItemEntity(orderId, productId, quantity, productName, productPrice, productImageUrl) + } + + class BaseEntity { + <> + #Long id + #ZonedDateTime createdAt + #ZonedDateTime updatedAt + #ZonedDateTime deletedAt + } + + OrderEntity --|> BaseEntity + OrderEntity --> OrderStatus + OrderItemEntity --|> BaseEntity + OrderEntity "1" --> "*" OrderItemEntity : contains + OrderEntity ..> UserEntity : userId로 참조 + OrderItemEntity ..> ProductEntity : productId로 참조 (FK 아님) +``` + +### 포인트 +- **Domain ↔ Entity 분리 안 함**: 상태 전이 로직이 없으므로 Entity가 도메인 모델 겸임. 결제 도입 시 상태가 복잡해지면 그때 분리 검토. +- **OrderItemEntity = 스냅샷**: 주문 시점의 상품 정보(name, price, imageUrl)를 직접 저장. 상품 수정/삭제 후에도 원래 거래 조건 보존. +- **productId는 FK 아님**: 참조 무결성보다 이력 보존을 우선. + +--- + +## 4. Stock 도메인 + +Stock은 재고 차감/증가 시 유효성 검증(음수 방지)이 필요하므로 Domain ↔ Entity 분리를 적용한다. + +```mermaid +classDiagram + direction TB + + class Stock { + -Long quantity + +Stock(quantity) + +deduct(Long amount) + +increase(Long amount) + -validateQuantity(quantity) + } + + class StockEntity { + -Long productId + -Long quantity + +from(Stock, Long productId)$ StockEntity + +toDomain() Stock + +update(Long quantity) + } + + class BaseEntity { + <> + #Long id + #ZonedDateTime createdAt + #ZonedDateTime updatedAt + #ZonedDateTime deletedAt + } + + StockEntity --|> BaseEntity + Stock <.. StockEntity : toDomain + StockEntity ..> ProductEntity : productId 1대1 참조 +``` + +### 포인트 +- **Domain ↔ Entity 분리 적용**: `Stock.deduct()`, `Stock.increase()`에서 음수 방지, 차감 가능 여부 검증. 비즈니스 룰을 도메인 객체에 캡슐화. +- **비관적 락 대상**: `StockEntity`는 `SELECT FOR UPDATE`로 동시성 제어. Product와 분리하여 좋아요(likeCount 락)와 경합하지 않음. +- **1:1 관계**: `productId` UNIQUE 제약으로 상품당 하나의 재고 레코드 보장. + +--- + +## 5. 도메인 간 의존 관계 + +전체 도메인의 의존 방향을 표현한다. 화살표는 "의존한다" 방향. + +```mermaid +classDiagram + direction LR + + class User { + loginId + password + name + email + birthDate + } + + class Brand { + name + introduction + websiteUrl + } + + class Product { + brandId + name + price + status + likeCount + } + + class Like { + userId + productId + } + + class Stock { + productId + quantity + } + + class Order { + userId + status + } + + class OrderItem { + orderId + productId + quantity + productName_스냅샷 + productPrice_스냅샷 + productImageUrl_스냅샷 + } + + Product ..> Brand : brandId 참조 + Like ..> User : userId 참조 + Like ..> Product : productId 참조 likeCount 증감 + Stock ..> Product : productId 1대1 참조 + Order ..> User : userId 참조 + Order "1" --> "*" OrderItem : contains + OrderItem ..> Product : productId 참조 스냅샷 +``` + +### 포인트 +- **Product가 중심 도메인**: Brand, Like, Stock, OrderItem 모두 Product에 의존. likeCount 증감, 상태 변경, 스냅샷 조회 등 다양한 역할. +- **도메인 간 참조는 ID**: `@ManyToOne` 같은 JPA 연관관계 없이 Long ID로 참조. 결합도 최소화. +- **Stock → Product 상태 변경**: 재고 0이면 OUT_OF_STOCK 자동 전환, 재입고 시 SELLING 복원. 유일한 Service 간 직접 의존. + +--- + +## Domain ↔ Entity 분리 기준 요약 + +| 도메인 | 분리 여부 | 이유 | +|--------|:---:|------| +| Brand | O | 이름, 소개문구 유효성 검증 | +| Product | O | 이름, 가격 유효성 검증 | +| Like | X | 비즈니스 로직 없음 (userId + productId 조합만) | +| Order | X | 현재 상태 하나 (ORDERED). 결제 도입 시 분리 검토 | +| OrderItem | X | 스냅샷 데이터 보존 역할. 비즈니스 로직 없음 | +| Stock | O | 차감/증가 시 음수 방지 등 유효성 검증 필요 | diff --git a/docs/design/04-erd.md b/docs/design/04-erd.md new file mode 100644 index 00000000..bd25bbf3 --- /dev/null +++ b/docs/design/04-erd.md @@ -0,0 +1,133 @@ +## ERD + +영속성 구조와 관계, 인덱스 전략을 검증하기 위해 작성합니다. 특히 상품 목록의 다중 정렬 요구사항과 검색 요구사항에 대한 인덱스 설계, 좋아요의 멱등성 보장을 위한 복합 유니크 키, 주문 스냅샷의 독립성, Stock 분리에 따른 락 범위 최소화가 핵심 포인트입니다. + +```mermaid +erDiagram + USER { + bigint id PK "AUTO_INCREMENT (idx)" + varchar(50) login_id UK "NOT NULL (로그인 ID)" + varchar(200) password "NOT NULL (비밀번호)" + varchar(50) name "NOT NULL (이름)" + varchar(10) birth_date "NOT NULL (생년월일)" + varchar(100) email "NOT NULL (이메일)" + datetime created_at "NOT NULL (생성일자)" + datetime updated_at "NOT NULL (수정일자)" + datetime deleted_at "NULL (soft delete) (삭제일자)" + } + + BRAND { + bigint id PK "AUTO_INCREMENT (idx)" + varchar(100) name "NOT NULL (브랜드명)" + varchar(500) introduction "NOT NULL (소개글)" + varchar(500) website_url "NULL (웹사이트 URL)" + datetime created_at "NOT NULL (생성일자)" + datetime updated_at "NOT NULL (수정일자)" + datetime deleted_at "NULL (soft delete) (삭제일자)" + } + + PRODUCT { + bigint id PK "AUTO_INCREMENT (idx)" + bigint brand_id FK "NOT NULL (브랜드 idx)" + varchar(200) name "NOT NULL (상품명)" + bigint price "NOT NULL (상품 가격)" + varchar(2000) description "NULL (설명)" + varchar(500) image_url "NULL (상품 이미지 URL)" + varchar(20) status "NOT NULL (SELLING/OUT_OF_STOCK/SOLD_OUT) (상품 상태)" + bigint like_count "NOT NULL DEFAULT 0 (좋아요 수)" + datetime created_at "NOT NULL (생성일자)" + datetime updated_at "NOT NULL (수정일자)" + datetime deleted_at "NULL (soft delete) (삭제일자)" + } + + LIKES { + bigint id PK "AUTO_INCREMENT (idx)" + bigint user_id "NOT NULL (사용자 idx)" + bigint product_id "NOT NULL (상품 idx)" + datetime created_at "NOT NULL (생성일자)" + datetime updated_at "NOT NULL (수정일자)" + } + + STOCK { + bigint id PK "AUTO_INCREMENT (idx)" + bigint product_id UK "NOT NULL (상품 idx, 1:1)" + bigint quantity "NOT NULL DEFAULT 0 (재고 수량)" + datetime created_at "NOT NULL (생성일자)" + datetime updated_at "NOT NULL (수정일자)" + datetime deleted_at "NULL (soft delete) (삭제일자)" + } + + ORDERS { + bigint id PK "AUTO_INCREMENT (idx)" + bigint user_id "NOT NULL (사용자 idx)" + varchar(20) status "NOT NULL (ORDERED) (주문 상태)" + datetime created_at "NOT NULL (생성일자)" + datetime updated_at "NOT NULL (수정일자)" + datetime deleted_at "NULL (soft delete) (삭제일자)" + } + + ORDER_ITEM { + bigint id PK "AUTO_INCREMENT (idx)" + bigint order_id FK "NOT NULL (주문 idx)" + bigint product_id "NOT NULL (상품 idx, 참조용)" + bigint quantity "NOT NULL (주문 수량)" + varchar(200) product_name "NOT NULL (스냅샷: 상품명)" + bigint product_price "NOT NULL (스냅샷: 상품 가격)" + varchar(500) product_image_url "NULL (스냅샷: 상품 이미지 URL)" + datetime created_at "NOT NULL (생성일자)" + datetime updated_at "NOT NULL (수정일자)" + datetime deleted_at "NULL (soft delete) (삭제일자)" + } + + BRAND ||--o{ PRODUCT : "has" + USER ||--o{ LIKES : "likes" + PRODUCT ||--o{ LIKES : "liked by" + PRODUCT ||--|| STOCK : "has stock" + USER ||--o{ ORDERS : "places" + ORDERS ||--o{ ORDER_ITEM : "contains" +``` + +## 인덱스 전략 (좋아요 & 주문) + +| 테이블 | 인덱스 | 유형 | 컬럼 | 용도 | +|--------|--------|------|------|------| +| LIKES | `uk_likes_user_product` | UNIQUE | `(user_id, product_id)` | 멱등성 보장. 동일 사용자가 같은 상품에 중복 좋아요 방지 | +| LIKES | `idx_likes_user_id` | INDEX | `(user_id)` | 좋아요 목록 조회 성능 (유니크 키의 선행 컬럼이므로 별도 인덱스 불필요할 수 있음) | +| STOCK | `uk_stock_product_id` | UNIQUE | `(product_id)` | 상품 1:1 관계 보장 | +| ORDERS | `idx_orders_user_id` | INDEX | `(user_id)` | 사용자별 주문 목록 조회 | +| ORDERS | `idx_orders_user_created` | INDEX | `(user_id, created_at)` | 기간 필터 + 사용자별 조회 | +| ORDER_ITEM | `idx_order_item_order_id` | INDEX | `(order_id)` | 주문별 항목 조회 | + +## 설계 포인트 + +### 1. LIKES 테이블 — hard delete 채택 + +LIKES는 BaseEntity의 `deletedAt` 패턴을 따르지 않는다. 좋아요 취소 시 레코드를 물리적으로 삭제(hard delete)한다. + +**이유**: soft delete 적용 시 복합 유니크 키에 `deletedAt`을 포함해야 하는데, 이는 좋아요 → 취소 → 재좋아요 흐름에서 유니크 키 충돌 문제를 야기한다. hard delete로 단순화하면 `(user_id, product_id)` 유니크 키만으로 멱등성을 보장할 수 있다. + +### 2. ORDER_ITEM.product_id — FK 미설정 + +`product_id`는 원본 상품 추적용 참조 ID일 뿐, FK 제약을 설정하지 않는다. + +**이유**: 주문 스냅샷은 주문 시점의 상품 정보를 독립적으로 보존하는 것이 목적. 상품이 soft delete되어도 주문 이력은 영향받지 않아야 한다. 스냅샷 필드(product_name, product_price, product_image_url)가 원본 상품과 무관하게 보존된다. + +### 3. STOCK 분리 — 락 범위 최소화 + +Stock을 Product 내 필드가 아닌 별도 테이블로 분리한다. + +**이유**: 주문 시 재고 차감은 비관적 락(`SELECT FOR UPDATE`)을 사용하는데, Product에 stock 필드를 두면 좋아요(likeCount 비관적 락)와 주문(stock 비관적 락)이 같은 행에서 경합한다. 분리하면 두 락이 독립적으로 동작한다. + +### 4. ORDERS 기간 인덱스 + +사용자 주문 목록은 `startAt`, `endAt` 기간 필터를 지원하므로 `(user_id, created_at)` 복합 인덱스를 설정한다. + +### 5. ProductStatus 확장 + +기존 `SELLING`, `SOLD_OUT`에 `OUT_OF_STOCK`이 추가된다. + +| 상태 | 의미 | 전환 주체 | +|------|------|----------| +| SELLING | 판매 중 | 기본 상태, 재입고 시 자동 복원 | +| OUT_OF_STOCK | 일시적 품절 | 재고 0 시 자동 전환 (StockService) | +| SOLD_OUT | 영구 판매 중지 | 어드민 수동 설정 | \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 142d7120..5ae37ac9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,6 +10,7 @@ springBootVersion=3.4.4 springDependencyManagementVersion=1.1.7 springCloudDependenciesVersion=2024.0.1 ### Library versions ### +testcontainersVersion=2.0.2 springDocOpenApiVersion=2.7.0 springMockkVersion=4.0.2 mockitoVersion=5.14.0