diff --git a/.docs/design/01-requirements.md b/.docs/design/01-requirements.md new file mode 100644 index 00000000..87db08e8 --- /dev/null +++ b/.docs/design/01-requirements.md @@ -0,0 +1,178 @@ +# 프로젝트: 감성 이커머스 (Emotional E-commerce) +## 기능 정의 및 요구사항 명세서 (Requirement Specification) + +- **버전**: v1.0 +- **작성일**: 2026-02-12 +- **문서 개요**: 본 문서는 유저 시나리오를 기반으로 서비스의 핵심 기능, API 인터페이스, 데이터 제약사항 및 예외 처리 기준을 정의한다. + +--- + +## 1. 개요 및 시나리오 (Overview) + +### 1.1 서비스 목표 +내가 좋아하는 브랜드의 상품을 탐색하고 좋아요를 누르며, 쿠폰을 통해 합리적으로 구매하는 감성 커머스 플랫폼. 유저의 행동 데이터는 추후 랭킹과 추천 시스템의 기반이 된다. + +### 1.2 유저 시나리오 (User Journey) +1. **진입**: 유저는 회원가입을 하고 로그인을 통해 본인을 식별한다. +2. **탐색**: 브랜드와 상품을 둘러보고, 마음에 드는 상품에 '좋아요'를 누른다. +3. **혜택**: 주문 전 사용 가능한 쿠폰을 발급받는다. +4. **구매**: 장바구니 혹은 바로 구매를 통해 상품을 주문하고 결제한다. +5. **관리**: 내 주문 내역을 확인하고, 배송 상태를 조회하거나 영수증을 발급받는다. + +--- + +## 2. 공통 아키텍처 및 규칙 (General Rules) + +### 2.1 API Endpoint 규칙 +* **User (대고객)**: `/api/v1` +* **Admin (관리자)**: `/api-admin/v1` + +### 2.2 인증 및 식별 (Authentication) +표준 인증 방식 대신, 아래 커스텀 헤더를 통해 요청자를 식별한다. + +| 구분 | Header Key | 설명 | 비고 | +| :--- | :--- | :--- | :--- | +| **User** | `X-Loopers-LoginId` | 로그인 ID | | +| **User** | `X-Loopers-LoginPw` | 비밀번호 | 평문 전송 (Test Scope) | +| **Admin** | `X-Loopers-Ldap` | LDAP 식별자 | 값: `loopers.admin` | + +### 2.3 보안 및 접근 제어 +* 유저는 **타 유저의 정보**에 절대 접근할 수 없다. +* 관리자 API는 일반 유저가 호출할 수 없다. + +--- + +## 3. 상세 기능 명세 (Detailed Specifications) + +### 3.1 사용자 (User) + +| Role | Method | Endpoint | 기능 | 상세 로직 및 제약사항 | +| :--- | :---: | :--- | :--- | :--- | +| Guest | `POST` | `/users/register` | **회원가입** | ID 중복 체크 필수 | +| User | `GET` | `/users/me` | **내 정보 조회** | 이름 마스킹 처리 | +| User | `PUT` | `/users/me/password` | **비밀번호 변경** | 기존 비밀번호 확인 로직 포함 | + +#### 상세 요구사항 +* **회원가입 입력값**: ID, PW, 이름, 생년월일, 이메일 +* **ID 규칙**: 영문 소문자, 숫자만 허용 (4~10자). +* **비밀번호 규칙**: + * 8~16자, 영문 대소문자/숫자/특수문자 허용. + * 생년월일은 비밀번호에 포함될 수 없음. +* **정보 조회 마스킹**: 이름의 마지막 글자를 `*`로 처리 (예: `홍길동` -> `홍길*`). +* **비밀번호 변경**: `{기존 PW, 새 PW}` 입력. 새 PW는 기존 PW와 달라야 함. + +--- + +### 3.2 상품 & 브랜드 (Product & Brand) + +| Role | Method | Endpoint | 기능 | 상세 로직 및 제약사항 | +| :--- | :---: | :--- | :--- | :--- | +| Any | `GET` | `/brands/{brandId}` | **브랜드 조회** | 브랜드 정보 반환 | +| Any | `GET` | `/products` | **상품 목록** | 필터, 정렬, 페이징 | +| Any | `GET` | `/products/{productId}` | **상품 상세** | | + +#### 상세 요구사항 +* **목록 조회 쿼리 파라미터**: + * `brandId`: 특정 브랜드 필터링 + * `sort`: `latest`(기본), `price_asc`(가격낮은순), `likes_desc`(좋아요순) + * `page`: 0부터 시작 (Default 0), `size`: 20 (Default 20) + +--- + +### 3.3 좋아요 (Like) + +| Role | Method | Endpoint | 기능 | 상세 로직 및 제약사항 | +| :--- | :---: | :--- | :--- | :--- | +| User | `POST` | `/products/{id}/likes` | **좋아요 등록** | Idempotency 보장 | +| User | `DELETE` | `/products/{id}/likes` | **좋아요 취소** | | +| User | `GET` | `/users/me/likes` | **좋아요 목록** | 필터링 지원 | + +#### 상세 요구사항 +* **제약**: 유저당 1개의 상품에 1번만 좋아요 가능. +* **목록 필터**: `sale_yn` (세일중), `status` (판매중/품절제외). +* **목록 정렬**: 날짜순, 가격순, 할인율순, 브랜드명순. +* **표시 정보**: 품절 여부(Dimmed), 세일 뱃지 등 UI 상태값 포함. + +--- + +### 3.4 쿠폰 (Coupon) + +| Role | Method | Endpoint | 기능 | 상세 로직 및 제약사항 | +| :--- | :---: | :--- | :--- | :--- | +| User | `POST` | `/coupons/{id}/issue` | **쿠폰 발급** | 선착순/중복 방지 | +| User | `GET` | `/users/me/coupons` | **내 쿠폰 조회** | 사용 가능 여부 포함 | + +#### 상세 요구사항 +* **발급 제약**: 유저당 1회 제한. 전체 발행량 소진 시 발급 불가(`Sold Out`). +* **조회 정보**: 쿠폰명, 할인금액(율), 최소주문금액, 만료일, 상태(사용가능/사용완료/만료). + +--- + +### 3.5 주문 (Order) + +| Role | Method | Endpoint | 기능 | 상세 로직 및 제약사항 | +| :--- | :---: | :--- | :--- | :--- | +| User | `POST` | `/orders` | **주문 요청** | 트랜잭션 처리 필수 | +| User | `GET` | `/orders/me` | **내 주문 목록** | 기간 조회 | +| User | `GET` | `/orders/{id}` | **주문 상세** | 영수증 데이터 포함 | + +#### 상세 요구사항 +1. **주문 요청**: + * **프로세스**: 재고 확인 -> 재고 차감 -> 쿠폰 적용 -> 결제 금액 검증 -> 주문 생성. + * **스냅샷(Snapshot)**: 주문 시점의 상품명, 가격을 별도 저장 (상품 정보 변경 영향 없음). + * **입력값**: 배송지(이름/주소/요청사항), 결제수단, 도착희망일. +2. **주문 상태 및 액션**: + * `결제완료/상품준비중`: 주문 취소 가능, 배송지 변경 가능. + * `배송중/배송완료`: 주문 취소 불가(반품 절차), 배송지 변경 불가. +3. **영수증 (Receipt)**: + * **카드영수증**: 상점정보, 결제일시, 금액. + * **거래명세서**: 공급자/공급받는자(유저 입력 가능) 정보, 품목, 세액, 비고 포함. + +--- + +### 3.6 관리자 기능 (Admin) + +| Role | Method | Endpoint | 기능 | 상세 로직 및 제약사항 | +| :--- | :---: | :--- | :--- | :--- | +| Admin | `GET` | `/api-admin/v1/brands` | 브랜드 목록 | | +| Admin | `POST` | `/api-admin/v1/brands` | 브랜드 등록 | | +| Admin | `PUT` | `/api-admin/v1/brands/{id}` | 브랜드 수정 | | +| Admin | `DELETE`| `/api-admin/v1/brands/{id}` | **브랜드 삭제** | **[Cascade]** 하위 상품 일괄 삭제 | +| Admin | `POST` | `/api-admin/v1/products` | **상품 등록** | 등록된 브랜드 ID만 허용 | +| Admin | `PUT` | `/api-admin/v1/products/{id}`| **상품 수정** | **[Immutable]** 브랜드 변경 불가 | +| Admin | `DELETE`| `/api-admin/v1/products/{id}`| 상품 삭제 | Soft Delete 권장 | +| Admin | `GET` | `/api-admin/v1/orders` | 주문 목록 | 전체 유저 주문 조회 | + +--- + +## 4. 예외 처리 및 에러 표준 + +### 4.1 에러 응답 포맷 (JSON) +모든 API는 실패 시 아래 포맷을 준수한다. +```json +{ + "code": "BAD_REQUEST", + "message": "사용자에게 노출될 상세 에러 메시지" +} +``` +### 4.2 HTTP 상태 코드 +- 200 OK: 요청 성공. + +- 400 Bad Request: 필수 파라미터 누락, 유효성 검사 실패 (PW 규칙 위반 등), 인증 실패 (ID/PW 불일치), 필수 헤더 누락. + +- 404 Not Found: 존재하지 않는 리소스 (상품, 브랜드, 주문 등). + +- 409 Conflict: 비즈니스 로직 충돌 (이미 좋아요 누름, 재고 부족, 이미 발급된 쿠폰). + +- 500 Internal Server Error: 서버 내부 오류. + +### 4.3 주요 에러 코드 정의 + +| 코드 | HTTP 상태 | 설명 | +| :--- | :---: | :--- | +| `BAD_REQUEST` | 400 | 유효성 검사 실패, 인증 실패, ID 중복 등 | +| `VALIDATION_ERROR` | 400 | DTO `@Valid` 어노테이션 검증 실패 | +| `MISSING_HEADER` | 400 | 필수 헤더 누락 (`X-Loopers-LoginId` 등) | +| `Not Found` | 404 | 존재하지 않는 리소스 | +| `Conflict` | 409 | 비즈니스 로직 충돌 (리소스 중복 등) | +| `INTERNAL_ERROR` | 500 | 서버 내부 오류 | \ 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..3f95fbfa --- /dev/null +++ b/.docs/design/02-sequence-diagrams.md @@ -0,0 +1,586 @@ +# 5. 시스템 시퀀스 다이어그램 (System Sequence Diagrams) + +모든 핵심 기능(회원가입, 인증, 조회, 어드민 등록)에 대해 **객체의 역할과 책임(Responsibility)**이 명확히 드러나도록 시퀀스 다이어그램을 작성했습니다. + +단순한 `Service` 하나가 모든 일을 다 하는 것이 아니라, **인증(Authentication), 값 객체 검증(Value Object), 암호화(Encoder), 조회(Query)** 등의 책임이 분리된 구조입니다. + +| Flow | 핵심 책임 | +|------|-----------| +| User Flow | 회원가입, 헤더 기반 인증, 정보 조회, 비밀번호 변경 | +| Read Flow | 데이터 조회와 DTO 변환 | +| Write Flow (Admin) | 권한 체크와 데이터 무결성(참조 관계) | +| Like Flow | 멱등성 보장과 좋아요 수 동기화 | +| Order Flow | 재고/결제/스냅샷의 트랜잭션 | + +--- + +## 5-1. 회원 기능 (User Flow) + +**핵심 책임 객체:** + +| 객체 | 책임 | +|------|------| +| `UserController` | HTTP 요청 수신 및 UseCase 위임 | +| `UserService` | 회원가입, 인증, 정보 조회, 비밀번호 변경 통합 서비스 | +| `PasswordEncoder` | 비밀번호 암호화 및 매칭 (SHA-256) | +| `UserRepository` | 중복 ID 체크 및 사용자 영속화 | + +### Scenario 1 — 회원가입 (Register) + +```mermaid +sequenceDiagram + autonumber + actor User as 👤 User + participant API as 🌐 UserController + participant Service as 📦 UserService + participant VO as 🔒 Value Objects + participant Encoder as 🛡️ PasswordEncoder + participant DB as 💾 UserRepository + + User->>API: POST /api/v1/users/register (loginId, password, name, birthday, email) + API->>Service: register(loginId, name, rawPassword, birthday, email) + + rect rgb(240, 248, 255) + Note right of Service: [책임 1] 값 객체 검증 + Service->>VO: UserId.of(loginId), UserName.of(name), Birthday.of(birthday), Email.of(email), Password.of(rawPassword, birthday) + alt 검증 실패 (형식 불일치, 생년월일 포함 등) + VO-->>Service: throw IllegalArgumentException + Service-->>API: 예외 전파 + API-->>User: 400 Bad Request + else 검증 통과 + VO-->>Service: Value Objects + end + end + + rect rgb(255, 240, 245) + Note right of Service: [책임 2] 중복 확인 + Service->>DB: existsById(userId) + alt ID 중복 + DB-->>Service: true + Service-->>API: throw IllegalArgumentException("이미 사용중인 ID 입니다.") + API-->>User: 400 Bad Request + else ID 사용 가능 + DB-->>Service: false + end + end + + rect rgb(255, 250, 205) + Note right of Service: [책임 3] 암호화 + Service->>Encoder: encrypt(rawPassword) + Encoder-->>Service: salt:hashedPassword + end + + rect rgb(240, 255, 240) + Note right of Service: [책임 4] 도메인 객체 생성 및 저장 + Service->>Service: User.register(userId, userName, encodedPassword, birth, email, wrongPasswordCount, now) + Service->>DB: save(User) + DB-->>Service: User + end + + Service-->>API: void + API-->>User: 200 OK +``` + +### Scenario 2 — 내 정보 조회 (Get My Info) + +```mermaid +sequenceDiagram + autonumber + actor User as 👤 User + participant API as 🌐 UserController + participant Auth as 🔐 UserService + participant Query as 🔍 UserService + participant Encoder as 🛡️ PasswordEncoder + participant DB as 💾 UserRepository + + User->>API: GET /api/v1/users/me (Header: X-Loopers-LoginId, X-Loopers-LoginPw) + + alt 필수 헤더 누락 + API-->>User: 400 Bad Request ("필수 헤더가 누락되었습니다") + end + + rect rgb(255, 230, 230) + Note right of API: [책임 1] 헤더 기반 인증 + API->>Auth: authenticate(userId, rawPassword) + Auth->>DB: findById(userId) + alt 유저 없음 + DB-->>Auth: Optional.empty() + Auth-->>API: throw IllegalArgumentException("사용자를 찾을 수 없습니다.") + API-->>User: 400 Bad Request + else 유저 존재 + DB-->>Auth: User + end + Auth->>Encoder: matches(rawPassword, encodedPassword) + alt 비밀번호 불일치 + Encoder-->>Auth: false + Auth-->>API: throw IllegalArgumentException("비밀번호가 일치하지 않습니다.") + API-->>User: 400 Bad Request + else 비밀번호 일치 + Encoder-->>Auth: true + end + end + + rect rgb(240, 248, 255) + Note right of API: [책임 2] 사용자 정보 조회 + API->>Query: getUserInfo(userId) + Query->>DB: findById(userId) + DB-->>Query: User + Note right of Query: 이름 마스킹: "홍길동" → "홍길*" + Query-->>API: UserInfoResponse(loginId, maskedName, birthday, email) + end + + API->>API: UserInfoResponse.from(userInfo) — birthday → "yyyyMMdd" 포맷 + API-->>User: 200 OK (JSON) +``` + +### Scenario 3 — 비밀번호 변경 (Update Password) + +```mermaid +sequenceDiagram + autonumber + actor User as 👤 User + participant API as 🌐 UserController + participant Auth as 🔐 UserService + participant Service as 🔑 UserService + participant VO as 🔒 Value Objects + participant Encoder as 🛡️ PasswordEncoder + participant DB as 💾 UserRepository + + User->>API: PUT /api/v1/users/me/password (Header: X-Loopers-LoginId, X-Loopers-LoginPw, Body: currentPassword, newPassword) + + rect rgb(255, 230, 230) + Note right of API: [책임 1] 헤더 기반 인증 + API->>Auth: authenticate(userId, rawPassword) + Auth->>DB: findById(userId) + Auth->>Encoder: matches(rawPassword, encodedPassword) + alt 인증 실패 + Auth-->>API: throw IllegalArgumentException + API-->>User: 400 Bad Request + end + end + + API->>Service: updatePassword(userId, currentPassword, newPassword) + + rect rgb(240, 248, 255) + Note right of Service: [책임 2] 사용자 조회 및 비밀번호 값 객체 검증 + Service->>DB: findById(userId) + DB-->>Service: User + Service->>VO: Password.of(currentRawPassword, birthday), Password.of(newRawPassword, birthday) + alt 비밀번호 형식 오류 또는 생년월일 포함 + VO-->>Service: throw IllegalArgumentException + Service-->>API: 예외 전파 + API-->>User: 400 Bad Request + end + end + + rect rgb(255, 240, 245) + Note right of Service: [책임 3] 비밀번호 검증 + Service->>Encoder: matches(currentPassword, encodedPassword) + alt 현재 비밀번호 불일치 + Encoder-->>Service: false + Service-->>API: throw IllegalArgumentException("현재 비밀번호가 일치하지 않습니다.") + API-->>User: 400 Bad Request + end + + Service->>Encoder: matches(newPassword, encodedPassword) + alt 새 비밀번호가 기존과 동일 + Encoder-->>Service: true + Service-->>API: throw IllegalArgumentException("현재 비밀번호는 사용할 수 없습니다.") + API-->>User: 400 Bad Request + end + end + + rect rgb(240, 255, 240) + Note right of Service: [책임 4] 암호화 및 저장 + Service->>Encoder: encrypt(newPassword) + Encoder-->>Service: salt:hashedPassword + Service->>Service: user.changePassword(encodedNewPassword) + Service->>DB: save(updatedUser) + DB-->>Service: User + end + + Service-->>API: void + API-->>User: 200 OK +``` + +--- + +## 5-2. 브랜드 및 상품 조회 (Public Read Flow) + +**핵심 책임 객체:** + +| 객체 | 책임 | +|------|------| +| `QueryHandler` | 복잡한 검색/필터링 쿼리 처리 (QueryDSL 등) | +| `DtoMapper` | 엔티티 → API 응답 객체 변환 (민감 정보 제외, 포맷팅) | + +```mermaid +sequenceDiagram + autonumber + actor User as 👤 User + participant API as 🌐 ProductController + participant Service as 🛍️ ProductService + participant Query as 🔍 QueryHandler + participant Mapper as 🎨 DtoMapper + participant DB as 💾 Repository + + Note over User, API: 인증 불필요 (Public API) + + User->>API: GET /api/v1/products?brandId=1&sort=latest&page=0 + API->>Service: getProductList(filterCondition) + + rect rgb(240, 248, 255) + Note right of Service: [책임 1] 데이터 조회 + Service->>Query: search(brandId, sort, page) + Query->>DB: Dynamic Select Query + DB-->>Query: List + Query-->>Service: List + end + + rect rgb(255, 240, 245) + Note right of Service: [책임 2] 응답 데이터 가공 + Service->>Mapper: toSummaryDtoList(entities) + Note right of Mapper: 품절 여부 계산, 이미지 URL 매핑 + Mapper-->>Service: List + end + + Service-->>API: PageResponse + API-->>User: 200 OK (JSON) +``` + +--- + +## 5-3. 브랜드 및 상품 등록 (Admin Write Flow) + +**핵심 책임 객체:** + +| 객체 | 책임 | +|------|------| +| `AdminGuard` | 관리자 권한 및 헤더 검증 (AOP/Interceptor) | +| `ImageUploader` | 이미지 파일 외부 저장소 업로드 (S3 등) | +| `CatalogService` | 브랜드 유효성 검증 및 상품 등록 오케스트레이션 | + +```mermaid +sequenceDiagram + autonumber + actor Admin as 👨‍💼 Admin + participant API as 🌐 AdminController + participant Guard as 👮 AdminGuard + participant Service as 📦 CatalogService + participant Uploader as ☁️ ImageUploader + participant DB as 💾 Repository + + Note over Admin, API: Header: X-Loopers-Ldap + + Admin->>API: POST /api-admin/v1/products (Info, Images, BrandId) + + rect rgb(255, 230, 230) + Note right of API: [책임 1] 관리자 권한 검증 + API->>Guard: checkAdminHeader(request) + alt 권한 없음 + Guard-->>API: throw UnauthorizedException + API-->>Admin: 403 Forbidden + else 권한 확인됨 + Guard-->>API: AdminInfo + end + end + + API->>Service: registerProduct(dto, files) + + rect rgb(240, 248, 255) + Note right of Service: [책임 2] 비즈니스 유효성 검사 + Service->>DB: existsBrand(brandId) + alt 브랜드 없음 + DB-->>Service: false + Service-->>API: throw InvalidBrandException + API-->>Admin: 400 Bad Request + else 브랜드 존재 + DB-->>Service: true + end + end + + rect rgb(255, 250, 205) + Note right of Service: [책임 3] 리소스(이미지) 처리 + Service->>Uploader: uploadImages(files) + Uploader-->>Service: List + end + + rect rgb(240, 255, 240) + Note right of Service: [책임 4] 데이터 영속화 + Service->>DB: save(ProductEntity + ImageEntities) + DB-->>Service: Product ID + end + + Service-->>API: ProductResponse + API-->>Admin: 201 Created +``` + +--- + +## 5-4. 좋아요 기능 (Like Flow) + +**핵심 책임 객체:** + +| 객체 | 책임 | +|------|------| +| `LikeController` | HTTP 요청 수신 및 UseCase 위임 | +| `UserService` | 헤더 기반 인증 (사용자 조회, 비밀번호 매칭) | +| `LikeService` | 좋아요 등록/취소 오케스트레이션 (멱등성 보장) | +| `ProductRepository` | 상품 존재 여부 확인 | +| `LikeRepository` | 좋아요 데이터 영속화 및 중복 확인 | + +### Scenario 1 — 좋아요 등록 (Add Like) + +```mermaid +sequenceDiagram + autonumber + actor User as 👤 User + participant API as 🌐 LikeController + participant Auth as 🔐 UserService + participant Service as ❤️ LikeService + participant ProductDB as 💾 ProductRepository + participant LikeDB as 💾 LikeRepository + + User->>API: POST /api/v1/products/{productId}/likes (Header: X-Loopers-LoginId, X-Loopers-LoginPw) + + rect rgb(255, 230, 230) + Note right of API: [책임 1] 헤더 기반 인증 + API->>Auth: authenticate(userId, rawPassword) + alt 인증 실패 + Auth-->>API: throw IllegalArgumentException + API-->>User: 400 Bad Request + end + end + + API->>Service: addLike(userId, productId) + + rect rgb(240, 248, 255) + Note right of Service: [책임 2] 상품 존재 확인 + Service->>ProductDB: findById(productId) + alt 상품 없음 + ProductDB-->>Service: Optional.empty() + Service-->>API: throw IllegalArgumentException("상품을 찾을 수 없습니다.") + API-->>User: 404 Not Found + else 상품 존재 + ProductDB-->>Service: Product + end + end + + rect rgb(255, 240, 245) + Note right of Service: [책임 3] 멱등성 보장 (중복 확인) + Service->>LikeDB: existsByUserIdAndProductId(userId, productId) + alt 이미 좋아요 누름 + LikeDB-->>Service: true + Service-->>API: 정상 응답 (멱등성 — 에러 아님) + API-->>User: 200 OK + else 좋아요 없음 + LikeDB-->>Service: false + end + end + + rect rgb(240, 255, 240) + Note right of Service: [책임 4] 좋아요 저장 + Service->>Service: Like.create(userId, productId) + Service->>LikeDB: save(Like) + LikeDB-->>Service: Like + end + + Service-->>API: void + API-->>User: 200 OK +``` + +### Scenario 2 — 좋아요 취소 (Cancel Like) + +```mermaid +sequenceDiagram + autonumber + actor User as 👤 User + participant API as 🌐 LikeController + participant Auth as 🔐 UserService + participant Service as ❤️ LikeService + participant LikeDB as 💾 LikeRepository + + User->>API: DELETE /api/v1/products/{productId}/likes (Header: X-Loopers-LoginId, X-Loopers-LoginPw) + + rect rgb(255, 230, 230) + Note right of API: [책임 1] 헤더 기반 인증 + API->>Auth: authenticate(userId, rawPassword) + alt 인증 실패 + Auth-->>API: throw IllegalArgumentException + API-->>User: 400 Bad Request + end + end + + API->>Service: cancelLike(userId, productId) + + rect rgb(240, 248, 255) + Note right of Service: [책임 2] 좋아요 존재 확인 + Service->>LikeDB: findByUserIdAndProductId(userId, productId) + alt 좋아요 없음 + LikeDB-->>Service: Optional.empty() + Service-->>API: 정상 응답 (멱등성 — 에러 아님) + API-->>User: 200 OK + else 좋아요 존재 + LikeDB-->>Service: Like + end + end + + rect rgb(255, 240, 245) + Note right of Service: [책임 3] 좋아요 삭제 + Service->>LikeDB: delete(Like) + end + + Service-->>API: void + API-->>User: 200 OK +``` + +--- + +## 5-5. 주문 기능 (Order Flow) + +**핵심 책임 객체:** + +| 객체 | 책임 | +|------|------| +| `OrderController` | HTTP 요청 수신 및 UseCase 위임 | +| `UserService` | 헤더 기반 인증 (사용자 조회, 비밀번호 매칭) | +| `OrderCreateService` | 주문 생성 오케스트레이션 (재고 확인, 스냅샷) | +| `OrderCancelService` | 주문 취소 처리 (상태 검증, 재고 복원) | +| `ProductRepository` | 재고 확인 및 차감 | +| `OrderRepository` | 주문 데이터 영속화 | + +### Scenario 1 — 주문 생성 (Create Order) + +```mermaid +sequenceDiagram + autonumber + actor User as 👤 User + participant API as 🌐 OrderController + participant Auth as 🔐 UserService + participant Service as 🛒 OrderCreateService + participant ProductDB as 💾 ProductRepository + participant OrderDB as 💾 OrderRepository + + User->>API: POST /api/v1/orders (Header: X-Loopers-LoginId, X-Loopers-LoginPw, Body: items, deliveryInfo, paymentMethod) + + rect rgb(255, 230, 230) + Note right of API: [책임 1] 헤더 기반 인증 + API->>Auth: authenticate(userId, rawPassword) + alt 인증 실패 + Auth-->>API: throw IllegalArgumentException + API-->>User: 400 Bad Request + end + end + + API->>Service: createOrder(userId, orderRequest) + + rect rgb(240, 248, 255) + Note right of Service: [책임 2] 재고 확인 및 차감 + loop 각 주문 항목 + Service->>ProductDB: findByIdForUpdate(productId) + Note right of ProductDB: SELECT ... FOR UPDATE (동시성 제어) + alt 상품 없음 + ProductDB-->>Service: Optional.empty() + Service-->>API: throw IllegalArgumentException("상품을 찾을 수 없습니다.") + API-->>User: 404 Not Found + else 상품 존재 + ProductDB-->>Service: Product + end + Service->>Service: product.decreaseStock(quantity) + alt 재고 부족 + Service-->>API: throw IllegalStateException("재고가 부족합니다.") + API-->>User: 409 Conflict + end + Service->>ProductDB: save(product) + end + end + + rect rgb(255, 240, 245) + Note right of Service: [책임 3] 결제 금액 검증 + Service->>Service: calculateTotalAmount(items) + Service->>Service: verifyPaymentAmount(calculated, requested) + alt 금액 불일치 + Service-->>API: throw IllegalArgumentException("결제 금액이 일치하지 않습니다.") + API-->>User: 400 Bad Request + end + end + + rect rgb(240, 255, 240) + Note right of Service: [책임 4] 주문 생성 및 스냅샷 저장 + Service->>Service: Order.create(userId, items, totalAmount, deliveryInfo) + Service->>Service: OrderSnapshot.capture(order, products) — 주문 시점 상품 정보 보존 + Service->>OrderDB: save(Order + OrderItems + OrderSnapshot) + OrderDB-->>Service: Order + end + + Service-->>API: OrderResponse + API-->>User: 200 OK (JSON) +``` + +### Scenario 2 — 주문 취소 (Cancel Order) + +```mermaid +sequenceDiagram + autonumber + actor User as 👤 User + participant API as 🌐 OrderController + participant Auth as 🔐 UserService + participant Service as 🛒 OrderCancelService + participant OrderDB as 💾 OrderRepository + participant ProductDB as 💾 ProductRepository + + User->>API: POST /api/v1/orders/{orderId}/cancel (Header: X-Loopers-LoginId, X-Loopers-LoginPw) + + rect rgb(255, 230, 230) + Note right of API: [책임 1] 헤더 기반 인증 + API->>Auth: authenticate(userId, rawPassword) + alt 인증 실패 + Auth-->>API: throw IllegalArgumentException + API-->>User: 400 Bad Request + end + end + + API->>Service: cancelOrder(userId, orderId) + + rect rgb(240, 248, 255) + Note right of Service: [책임 2] 주문 조회 및 권한/상태 확인 + Service->>OrderDB: findById(orderId) + alt 주문 없음 + OrderDB-->>Service: Optional.empty() + Service-->>API: throw IllegalArgumentException("주문을 찾을 수 없습니다.") + API-->>User: 404 Not Found + else 주문 존재 + OrderDB-->>Service: Order + end + Service->>Service: order.validateOwner(userId) — 본인 주문 확인 + alt 본인 주문 아님 + Service-->>API: throw IllegalArgumentException("본인의 주문만 취소할 수 있습니다.") + API-->>User: 400 Bad Request + end + Service->>Service: order.isCancellable() — 상태 확인 (결제완료/상품준비중) + alt 취소 불가 상태 (배송중/배송완료) + Service-->>API: throw IllegalStateException("배송중/배송완료 상태에서는 취소할 수 없습니다.") + API-->>User: 409 Conflict + end + end + + rect rgb(255, 240, 245) + Note right of Service: [책임 3] 재고 복원 + loop 각 주문 항목 + Service->>ProductDB: findById(productId) + ProductDB-->>Service: Product + Service->>Service: product.increaseStock(quantity) + Service->>ProductDB: save(product) + end + end + + rect rgb(240, 255, 240) + Note right of Service: [책임 4] 주문 상태 변경 + Service->>Service: order.cancel() — 상태를 '취소'로 변경 + Service->>OrderDB: save(order) + OrderDB-->>Service: Order + end + + Service-->>API: void + API-->>User: 200 OK +``` \ No newline at end of file diff --git a/.docs/design/03-class-diagram.md b/.docs/design/03-class-diagram.md new file mode 100644 index 00000000..34c77d89 --- /dev/null +++ b/.docs/design/03-class-diagram.md @@ -0,0 +1,698 @@ +# 헥사고날 아키텍처 - User 도메인 설계 + +## 아키텍처 개요 + +클린 아키텍처 기반으로 **도메인 계층이 어떤 외부 기술에도 의존하지 않도록** 설계했습니다. + +```mermaid +graph LR + I[Interfaces] --> A[Application] --> D[Domain] + I_F[Infrastructure] -.-> D + style D fill:#fffde7,stroke:#fdd835,color:black + style A fill:#e8f5e9,stroke:#43a047,color:black + style I fill:#e3f2fd,stroke:#1e88e5,color:black + style I_F fill:#ede7f6,stroke:#5e35b1,color:black +``` + +## UML 관계 범례 + +| 관계 | Mermaid 표기 | 설명 | +|---|---|---| +| 일반화(Generalization) | `--|>` 실선 + 빈 삼각형 | 상속 (extends) | +| 실체화(Realization) | `..|>` 점선 + 빈 삼각형 | 구현 (implements) | +| 의존(Dependency) | `..>` 점선 화살표 | 메서드 파라미터/로컬 변수로 참조 | +| 연관(Association) | `-->` 실선 화살표 | 필드로 참조 | +| 합성(Composition) | `*--` 채워진 다이아몬드 | 강한 소유 (생명주기 종속) | +| 집합(Aggregation) | `o--` 빈 다이아몬드 | 약한 소유 (독립 생명주기) | + +--- + +## 전체 아키텍처 클래스 다이어그램 + +> 다이어그램이 크므로 **상위 레이어**(Interfaces → Application)와 **하위 레이어**(Domain ← Infrastructure)로 나눠서 보여줍니다. + +--- + +## Part A. Interfaces → Application (요청 흐름) + +> Controller가 UseCase 인터페이스에 의존하고, UserService가 이를 **실체화(Realization)** 합니다. + +```mermaid +classDiagram + direction LR + + %% ═══════════════════════════════════════ + %% Interfaces Layer + %% ═══════════════════════════════════════ + class UserController { + <> + -RegisterUseCase registerUseCase + -AuthenticationUseCase authenticationUseCase + -UserQueryUseCase userQueryUseCase + -PasswordUpdateUseCase passwordUpdateUseCase + +register(UserRegisterRequest) ResponseEntity + +getMyInfo(String, String) ResponseEntity + +updatePassword(String, String, PasswordUpdateRequest) ResponseEntity + } + class UserRegisterRequest { + <> + -String loginId + -String password + -String name + -LocalDate birthday + -String email + } + class UserInfoResponse { + <> + -String loginId + -String name + -String birthday + -String email + +from(UserQueryUseCase) UserInfoResponse$ + } + class PasswordUpdateRequest { + <> + -String currentPassword + -String newPassword + } + + %% ═══════════════════════════════════════ + %% Application Layer + %% ═══════════════════════════════════════ + class RegisterUseCase { + <> + +register(String, String, String, LocalDate, String) void + } + class AuthenticationUseCase { + <> + +authenticate(UserId, String) void + } + class UserQueryUseCase { + <> + +getUserInfo(UserId) UserInfoResponse + } + class UserQueryUseCase_UserInfoResponse { + <> + -String loginId + -String maskedName + -LocalDate birthday + -String email + } + class PasswordUpdateUseCase { + <> + +updatePassword(UserId, String, String) void + } + class UserService { + <> + -UserRepository userRepository + -PasswordEncoder passwordEncoder + +register(String, String, String, LocalDate, String) void + +authenticate(UserId, String) void + +getUserInfo(UserId) UserInfoResponse + +updatePassword(UserId, String, String) void + -findUser(UserId) User + -maskName(String) String + } + + %% --- 의존 (Dependency): Controller → UseCase --- + UserController ..> RegisterUseCase : uses + UserController ..> AuthenticationUseCase : uses + UserController ..> UserQueryUseCase : uses + UserController ..> PasswordUpdateUseCase : uses + + %% --- 의존 (Dependency): Controller → DTO --- + UserController ..> UserRegisterRequest + UserController ..> PasswordUpdateRequest + UserController ..> UserInfoResponse + + %% --- 실체화 (Realization): Service → UseCase --- + UserService ..|> RegisterUseCase : implements + UserService ..|> AuthenticationUseCase : implements + UserService ..|> UserQueryUseCase : implements + UserService ..|> PasswordUpdateUseCase : implements + + %% --- inner record --- + UserQueryUseCase *-- UserQueryUseCase_UserInfoResponse : inner record + + %% --- DTO 변환 --- + UserInfoResponse ..> UserQueryUseCase_UserInfoResponse : from() + + %% ═══════════════════════════════════════ + %% Styling + %% ═══════════════════════════════════════ + style UserController fill:#e3f2fd,stroke:#1e88e5,stroke-width:2px,color:#000 + style UserRegisterRequest fill:#fffde7,stroke:#fbc02d,stroke-width:1px,color:#000 + style UserInfoResponse fill:#fffde7,stroke:#fbc02d,stroke-width:1px,color:#000 + style PasswordUpdateRequest fill:#fffde7,stroke:#fbc02d,stroke-width:1px,color:#000 + + style RegisterUseCase fill:#e8f5e9,stroke:#43a047,stroke-width:2px,color:#000 + style AuthenticationUseCase fill:#e8f5e9,stroke:#43a047,stroke-width:2px,color:#000 + style UserQueryUseCase fill:#e8f5e9,stroke:#43a047,stroke-width:2px,color:#000 + style UserQueryUseCase_UserInfoResponse fill:#e8f5e9,stroke:#43a047,stroke-width:1px,color:#000 + style PasswordUpdateUseCase fill:#e8f5e9,stroke:#43a047,stroke-width:2px,color:#000 + style UserService fill:#c8e6c9,stroke:#2e7d32,stroke-width:3px,color:#000 +``` + +### 이 다이어그램에서 봐야 할 포인트 + +- Controller는 `UserService`를 직접 알지 못한다. 4개의 UseCase 인터페이스만 의존하고, 구현체는 Spring이 주입한다. 이 경계가 무너지면 Controller 변경 시 Service 내부까지 영향이 번진다. +- UseCase 4개 분리는 ISP 적용이다. `register()`만 쓰는 곳에서 `updatePassword()`를 알 필요가 없다. 단, UserService 하나가 4개를 모두 구현하므로 **인터페이스는 분리되어 있지만 구현의 응집은 유지**된다. + +### 설계 의도 + +- **UseCase 인터페이스 분리 + Service 구현체 통합**: Controller는 자신이 사용하는 UseCase만 의존하고, 구현 코드의 중복(`findUser()`, `passwordEncoder` 등)은 UserService 한 곳에서 관리한다. +- `UserQueryUseCase` 안에 `UserInfoResponse` inner record를 두어, 반환 타입이 Application 레이어에서 정의된다. Interfaces 레이어의 DTO와 분리하여 레이어 간 결합을 끊는다. + +### 잠재 리스크 + +| 리스크 | 설명 | 선택지 | +|---|---|---| +| UserService 비대화 | 현재 4개 UseCase를 하나가 구현. 도메인이 커지면(주문, 좋아요 등) 메서드가 계속 늘어날 수 있음 | **A)** 도메인별 Service 분리 (OrderService, LikeService) **B)** 현재 User 도메인 내에서만 통합 유지하고, 다른 도메인은 별도 Service | +| Controller에서 직접 인증 호출 | `getMyInfo()`와 `updatePassword()`에서 `authenticationUseCase.authenticate()`를 직접 호출. 인증 로직이 Controller에 노출됨 | **A)** 현행 유지 — 단순하고 명시적 **B)** Spring Interceptor/Filter로 인증을 분리하여 Controller는 비즈니스만 담당 | + +--- + +## Part B. Domain ← Infrastructure (핵심 도메인 + 어댑터) + +> Domain의 포트(interface)를 Infrastructure가 **실체화(Realization)** 합니다. User 애그리거트는 Value Object를 **합성(Composition)** 합니다. + +```mermaid +classDiagram + direction TB + + %% ═══════════════════════════════════════ + %% Application (연결점) + %% ═══════════════════════════════════════ + class UserService { + <> + -UserRepository userRepository + -PasswordEncoder passwordEncoder + } + + %% ═══════════════════════════════════════ + %% Domain Layer + %% ═══════════════════════════════════════ + class User { + <> + -Long id + -UserId userId + -UserName userName + -String encodedPassword + -Birthday birth + -Email email + -WrongPasswordCount wrongPasswordCount + -LocalDateTime createdAt + +register() User$ + +reconstitute() User$ + +matchesPassword(Password, PasswordMatchChecker) boolean + +changePassword(String) User + } + class PasswordMatchChecker { + <> + <> + +matches(String, String) boolean + } + class UserId { + <> + -String value + } + class UserName { + <> + -String value + } + class Password { + <> + -String value + } + class Email { + <> + -String value + } + class Birthday { + <> + -LocalDate value + } + class WrongPasswordCount { + <> + -int value + } + class UserRepository { + <> + <> + +save(User) User + +findById(UserId) User? + +existsById(UserId) boolean + } + class PasswordEncoder { + <> + <> + +encrypt(String) String + +matches(String, String) boolean + } + + %% ═══════════════════════════════════════ + %% Infrastructure Layer + %% ═══════════════════════════════════════ + class UserRepositoryImpl { + <> + <> + -UserJpaRepository userJpaRepository + +save(User) User + +findById(UserId) User? + +existsById(UserId) boolean + -toEntity(User) UserJpaEntity + -toDomain(UserJpaEntity) User + } + class UserJpaRepository { + <> + <> + +findByUserId(String) UserJpaEntity? + +existsByUserId(String) boolean + } + class UserJpaEntity { + <> + -Long id + -String userId + -String encodedPassword + -String username + -LocalDate birthday + -String email + -LocalDateTime createdAt + } + class JpaRepositoryBase { + <> + <> + } + class Sha256PasswordEncoder { + <> + <> + +encrypt(String) String + +matches(String, String) boolean + -generateSalt() String + -sha256(String) String + } + + %% --- Application → Domain (연관) --- + UserService --> UserRepository : -userRepository + UserService --> PasswordEncoder : -passwordEncoder + UserService ..> User : uses + + %% --- 합성 (Composition): User → Value Objects --- + User *-- "1" UserId : -userId + User *-- "1" UserName : -userName + User *-- "1" Birthday : -birth + User *-- "1" Email : -email + User *-- "1" WrongPasswordCount : -wrongPasswordCount + + %% --- 의존 (Dependency): 메서드에서만 사용 --- + User ..> Password : 생성/변경 시 검증 + User ..> PasswordMatchChecker : matchesPassword() + + %% --- 실체화 (Realization): Infrastructure → Domain Port --- + UserRepositoryImpl ..|> UserRepository : implements + Sha256PasswordEncoder ..|> PasswordEncoder : implements + + %% --- 일반화 (Generalization): JPA 상속 --- + UserJpaRepository --|> JpaRepositoryBase : extends + + %% --- 연관/의존: Infrastructure 내부 --- + UserRepositoryImpl --> "1" UserJpaRepository : -userJpaRepository + UserRepositoryImpl ..> UserJpaEntity : toEntity() / toDomain() + + %% ═══════════════════════════════════════ + %% Styling + %% ═══════════════════════════════════════ + + %% Application + style UserService fill:#c8e6c9,stroke:#2e7d32,stroke-width:3px,color:#000 + + %% Domain - Aggregate Root + style User fill:#ffecb3,stroke:#ff6f00,stroke-width:3px,color:#000 + + %% Domain - Value Objects + style UserId fill:#fff9c4,stroke:#fbc02d,stroke-width:1px,color:#000 + style UserName fill:#fff9c4,stroke:#fbc02d,stroke-width:1px,color:#000 + style Password fill:#fff9c4,stroke:#fbc02d,stroke-width:1px,color:#000 + style Email fill:#fff9c4,stroke:#fbc02d,stroke-width:1px,color:#000 + style Birthday fill:#fff9c4,stroke:#fbc02d,stroke-width:1px,color:#000 + style WrongPasswordCount fill:#fff9c4,stroke:#fbc02d,stroke-width:1px,color:#000 + + %% Domain - Ports + style UserRepository fill:#fffde7,stroke:#fdd835,stroke-width:2px,color:#000 + style PasswordEncoder fill:#fffde7,stroke:#fdd835,stroke-width:2px,color:#000 + style PasswordMatchChecker fill:#fffde7,stroke:#fdd835,stroke-width:2px,color:#000 + + %% Infrastructure - Adapters + style UserRepositoryImpl fill:#ede7f6,stroke:#5e35b1,stroke-width:2px,color:#000 + style Sha256PasswordEncoder fill:#ede7f6,stroke:#5e35b1,stroke-width:2px,color:#000 + + %% Infrastructure - JPA + style UserJpaRepository fill:#eeeeee,stroke:#9e9e9e,stroke-width:1px,color:#000 + style JpaRepositoryBase fill:#eeeeee,stroke:#9e9e9e,stroke-width:1px,color:#000 + style UserJpaEntity fill:#eeeeee,stroke:#9e9e9e,stroke-width:1px,color:#000 +``` + +### 이 다이어그램에서 봐야 할 포인트 + +- 화살표 방향에 주목: `UserService → UserRepository(interface) ← UserRepositoryImpl`. Domain Port를 사이에 두고 Application과 Infrastructure가 **서로를 직접 모르는 구조**다. 이것이 의존성 역전(DIP)의 핵심이다. +- User가 6개의 Value Object를 합성(Composition)하고 있다. Value Object는 User 없이 독립 존재하지 않으므로 채워진 다이아몬드(`*--`)로 표현한다. +- `PasswordMatchChecker`는 `@FunctionalInterface`다. User 도메인이 암호화 구현을 모르면서도 비밀번호 매칭을 할 수 있게 하는 전략 패턴이다. + +### 잠재 리스크 + +| 리스크 | 설명 | 선택지 | +|---|---|---| +| 도메인 ↔ JPA 변환 비용 | `toEntity()` / `toDomain()`을 매번 호출. 엔티티가 복잡해지면 변환 로직 유지보수 부담 증가 | **A)** 현행 유지 — 도메인 순수성의 대가로 감수 **B)** MapStruct 등 매핑 라이브러리 도입 | +| WrongPasswordCount 영속 누락 | 도메인에는 존재하지만 DB에 저장하지 않아, `toDomain()` 시 항상 0으로 복원됨 | ERD 문서의 데이터 정합성 섹션 참고 | +| Value Object 검증이 앱 레벨에만 존재 | DB 레벨에는 `NOT NULL`과 `UNIQUE` 외에 검증 없음. 직접 SQL 실행 시 도메인 규칙 우회 가능 | **A)** 운영 DDL에 CHECK 제약 추가 **B)** DB는 저장소 역할에 한정하고, 앱 레벨 검증만으로 충분하다고 판단 | + +--- + +## Value Objects 상세 다이어그램 + +> User 애그리거트가 소유하는 값 객체들의 **합성(Composition)** 관계와 검증 규칙을 보여줍니다. + +```mermaid +classDiagram + direction LR + + class User { + <> + -Long id + -UserId userId + -UserName userName + -String encodedPassword + -Birthday birth + -Email email + -WrongPasswordCount wrongPasswordCount + -LocalDateTime createdAt + +register() User$ + +reconstitute() User$ + +matchesPassword(Password, PasswordMatchChecker) boolean + +changePassword(String encodedPassword) User + } + + class UserId { + <> + -String value + +of(String) UserId$ + } + + class UserName { + <> + -String value + +of(String) UserName$ + } + + class Password { + <> + -String value + +of(String, LocalDate) Password$ + } + + class Email { + <> + -String value + +of(String) Email$ + } + + class Birthday { + <> + -LocalDate value + +of(LocalDate) Birthday$ + } + + class WrongPasswordCount { + <> + -int value + +init() WrongPasswordCount$ + +increment() WrongPasswordCount + +reset() WrongPasswordCount + } + + class PasswordMatchChecker { + <> + <> + } + + %% 합성 관계 (Composition) + User *-- "1" UserId + User *-- "1" UserName + User *-- "1" Birthday + User *-- "1" Email + User *-- "1" WrongPasswordCount + + %% 의존 관계 (Dependency) + User ..> Password : 생성/변경 시 검증 + + %% 연관 관계 (Association) + User ..> PasswordMatchChecker : matchesPassword()에서 사용 + + %% Styling + style User fill:#ffecb3,stroke:#ff6f00,stroke-width:3px,color:#000 + style UserId fill:#fff9c4,stroke:#fbc02d,stroke-width:1px,color:#000 + style UserName fill:#fff9c4,stroke:#fbc02d,stroke-width:1px,color:#000 + style Password fill:#fff9c4,stroke:#fbc02d,stroke-width:1px,color:#000 + style Email fill:#fff9c4,stroke:#fbc02d,stroke-width:1px,color:#000 + style Birthday fill:#fff9c4,stroke:#fbc02d,stroke-width:1px,color:#000 + style WrongPasswordCount fill:#fff9c4,stroke:#fbc02d,stroke-width:1px,color:#000 + style PasswordMatchChecker fill:#fffde7,stroke:#fdd835,stroke-width:2px,color:#000 +``` + +### Value Object 검증 규칙 + +| Value Object | 검증 규칙 | 예외 메시지 | +|---|---|---| +| `UserId` | 4~10자, 영문 소문자+숫자만 | `로그인 ID는 4~10자의 영문 소문자, 숫자만 가능합니다.` | +| `UserName` | 2~20자, 한글/영문/숫자 | `이름은 2~20자의 한글 또는 영문만 가능합니다.` | +| `Password` | 8~16자, 영문+숫자+특수문자, 생년월일 포함 불가 | `비밀번호는 8~16자리 영문 대소문자, 숫자, 특수문자만 가능합니다.` | +| `Email` | 이메일 형식 정규식 | `올바른 이메일 형식이 아닙니다` | +| `Birthday` | not null, 미래 불가, 1900년 이후 | `생년월일은 미래 날짜일 수 없습니다.` | +| `WrongPasswordCount` | 음수 불가, 5회 이상 잠금 | `비밀번호 오류 횟수는 음수일 수 없습니다.` | + +--- + +## Infrastructure 계층 상세 + +> 도메인 인터페이스를 **실체화(Realization)** 하는 인프라 어댑터와 JPA 엔티티 매핑을 보여줍니다. + +```mermaid +classDiagram + direction TB + + class UserRepository { + <> + <> + +save(User) User + +findById(UserId) User? + +existsById(UserId) boolean + } + class PasswordEncoder { + <> + <> + +encrypt(String) String + +matches(String, String) boolean + } + + class UserRepositoryImpl { + <> + <> + -UserJpaRepository userJpaRepository + +save(User) User + +findById(UserId) User? + +existsById(UserId) boolean + -toEntity(User) UserJpaEntity + -toDomain(UserJpaEntity) User + } + class Sha256PasswordEncoder { + <> + <> + +encrypt(String) String + +matches(String, String) boolean + -generateSalt() String + -sha256(String) String + } + + class UserJpaRepository { + <> + <> + +findByUserId(String) UserJpaEntity? + +existsByUserId(String) boolean + } + class JpaRepositoryBase { + <> + <> + } + class UserJpaEntity { + <> + -Long id + -String userId + -String encodedPassword + -String username + -LocalDate birthday + -String email + -LocalDateTime createdAt + } + + class User { + <> + } + + %% === 관계 === + %% 실체화 (Realization) + UserRepositoryImpl ..|> UserRepository : implements + Sha256PasswordEncoder ..|> PasswordEncoder : implements + + %% 일반화 (Generalization) + UserJpaRepository --|> JpaRepositoryBase : extends + + %% 연관 (Association) + UserRepositoryImpl --> "1" UserJpaRepository : -userJpaRepository + + %% 의존 (Dependency) + UserRepositoryImpl ..> UserJpaEntity : toEntity() / toDomain() + UserRepositoryImpl ..> User : 도메인 모델 변환 + + %% Styling + style UserRepository fill:#fffde7,stroke:#fdd835,stroke-width:2px,color:#000 + style PasswordEncoder fill:#fffde7,stroke:#fdd835,stroke-width:2px,color:#000 + style UserRepositoryImpl fill:#ede7f6,stroke:#5e35b1,stroke-width:2px,color:#000 + style Sha256PasswordEncoder fill:#ede7f6,stroke:#5e35b1,stroke-width:2px,color:#000 + style UserJpaRepository fill:#eeeeee,stroke:#9e9e9e,stroke-width:1px,color:#000 + style JpaRepositoryBase fill:#eeeeee,stroke:#9e9e9e,stroke-width:1px,color:#000 + style UserJpaEntity fill:#eeeeee,stroke:#9e9e9e,stroke-width:1px,color:#000 + style User fill:#ffecb3,stroke:#ff6f00,stroke-width:3px,color:#000 +``` + +### Entity Mapping + +```java +// Domain → Persistence +UserRepositoryImpl.toEntity(User) → UserJpaEntity + +// Persistence → Domain +UserRepositoryImpl.toDomain(UserJpaEntity) → User +``` + +--- + +## 에러 처리 다이어그램 + +```mermaid +classDiagram + direction TB + + class GlobalExceptionHandler { + <> + +handleCoreException(CoreException) ResponseEntity + +handleIllegalArgumentException(IllegalArgumentException) ResponseEntity + +handleValidationException(MethodArgumentNotValidException) ResponseEntity + +handleMissingHeaderException(MissingRequestHeaderException) ResponseEntity + +handleException(Exception) ResponseEntity + } + class CoreException { + -ErrorType errorType + -String customMessage + +CoreException(ErrorType) + +CoreException(ErrorType, String) + +getErrorType() ErrorType + +getCustomMessage() String + } + class ErrorType { + <> + INTERNAL_ERROR + BAD_REQUEST + NOT_FOUND + CONFLICT + -HttpStatus status + -String code + -String message + } + class RuntimeException { + <> + } + + %% 일반화 (Generalization) + CoreException --|> RuntimeException : extends + + %% 합성 (Composition) + CoreException *-- "1" ErrorType : -errorType + + %% 의존 (Dependency) - 예외 핸들링 + GlobalExceptionHandler ..> CoreException : catches + GlobalExceptionHandler ..> IllegalArgumentException : catches + GlobalExceptionHandler ..> Exception : catches + + %% Styling + style GlobalExceptionHandler fill:#ffebee,stroke:#e53935,stroke-width:2px,color:#000 + style CoreException fill:#ffcdd2,stroke:#c62828,stroke-width:2px,color:#000 + style ErrorType fill:#ef9a9a,stroke:#b71c1c,stroke-width:1px,color:#000 + style RuntimeException fill:#eeeeee,stroke:#9e9e9e,stroke-width:1px,color:#000 +``` + +--- + +## 전체 아키텍처 요약 + +### 전체 흐름도 + +``` +┌─────────────────────────────────────┐ +│ Interface Layer │ +│ (UserController, DTOs) │ ← REST API 엔드포인트 +├─────────────────────────────────────┤ +│ Application Layer │ +│ (UseCases, UserService) │ ← 비즈니스 로직 +├─────────────────────────────────────┤ +│ Domain Layer │ +│ (User, Value Objects, Ports) │ ← 핵심 도메인 +├─────────────────────────────────────┤ +│ Infrastructure Layer │ +│ (Adapters) │ ← 기술 구현 +├─────────────────────────────────────┤ +│ Persistence Layer │ +│ (JPA, Entity) │ ← 데이터베이스 +└─────────────────────────────────────┘ +``` + +### 요청 처리 흐름 예시 + +1. **HTTP Request** → `UserController.register()` +2. **Controller** → `RegisterUseCase.register()` 호출 +3. **UseCase** → `UserService.register()` 실행 +4. **Service** → `User.register()` (도메인 로직) +5. **Service** → `UserRepository.save()` 호출 (Domain Port) +6. **Repository** → `UserRepositoryImpl.save()` 실행 (Adapter) +7. **Adapter** → `UserJpaRepository.save()` 실행 (JPA) +8. **JPA** → 데이터베이스에 저장 +9. 역순으로 응답 반환 + +### 의존성 방향 + +``` +Interface → Application → Domain ← Infrastructure ← Persistence + ↑ ↓ + └──────────────┘ + (의존성 역전) +``` + +### 핵심 원칙 + +1. ✅ **도메인 독립성**: Domain은 외부 기술에 의존하지 않음 +2. ✅ **의존성 역전**: Infrastructure가 Domain을 구현 +3. ✅ **Port & Adapter**: 인터페이스(Port)와 구현(Adapter) 분리 +4. ✅ **불변성**: Value Object는 모두 불변 +5. ✅ **응집도**: 관련된 로직은 한 곳에 모음 +6. ✅ **테스트 용이성**: 각 레이어를 독립적으로 테스트 가능 + +### 레이어별 색상 가이드 + +| 레이어 | 색상 | 설명 | +|--------|------|------| +| Interface | 🔵 파란색 | REST API, DTOs | +| Application | 🟢 초록색 | UseCases, Service | +| Domain (Aggregate) | 🟠 주황색 | User (Aggregate Root) | +| Domain (Value Object) | 🟡 노란색 | 불변 값 객체들 | +| Domain (Port) | 🟡 진한 노란색 | 인터페이스 | +| Infrastructure | 🟣 보라색 | Adapter 구현체 | +| Persistence | ⚪ 회색 | JPA, Entity | \ No newline at end of file diff --git a/.docs/design/04-erd.md b/.docs/design/04-erd.md new file mode 100644 index 00000000..e39cf7d0 --- /dev/null +++ b/.docs/design/04-erd.md @@ -0,0 +1,276 @@ +# 7. 전체 테이블 구조 및 관계 정리 (ERD) + +현재 구현된 테이블 구조와 향후 확장될 테이블 관계를 정리합니다. + +- **DB**: MySQL +- **DDL 전략**: local/test 환경은 `ddl-auto: create`, 운영 환경은 `ddl-auto: none` (수동 관리) +- **타임존**: UTC 기준 저장 (`hibernate.jdbc.time_zone: UTC`) + +--- + +## 7-1. 현재 구현된 ERD + +```mermaid +erDiagram + USERS { + BIGINT id PK "AUTO_INCREMENT" + VARCHAR user_id UK "NOT NULL, 로그인 ID (20)" + VARCHAR encoded_password "NOT NULL, salt:hash 형식" + VARCHAR username "NOT NULL, 사용자 이름 (20)" + DATE birthday "NOT NULL, 생년월일" + VARCHAR email "NOT NULL, 이메일" + DATETIME created_at "NOT NULL, 생성일시 (수정 불가)" + } +``` + +### 이 ERD에서 봐야 할 포인트 + +- `users` 테이블은 현재 유일하게 구현된 테이블이며, **도메인 검증은 전부 애플리케이션 레벨**에서 처리한다. DB 제약조건은 `NOT NULL`과 `UNIQUE` 정도만 걸려 있다. +- `WrongPasswordCount`는 DB에 저장하지 않는 설계 결정이 있다 — 이 트레이드오프가 어떤 의미인지 7-3에서 다룬다. + +--- + +## 7-2. 테이블 상세 명세 + +### `users` 테이블 + +> JPA Entity: `UserJpaEntity` (`com.loopers.infrastructure.entity`) + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|---|---|---|---| +| `id` | `BIGINT` | `PK`, `AUTO_INCREMENT` | 내부 식별자 | +| `user_id` | `VARCHAR(20)` | `NOT NULL`, `UNIQUE` | 로그인 ID (영문 소문자+숫자 4~10자) | +| `encoded_password` | `VARCHAR(255)` | `NOT NULL` | 암호화된 비밀번호 (`salt:hash` 형식) | +| `username` | `VARCHAR(20)` | `NOT NULL` | 사용자 이름 (한글/영문/숫자 2~20자) | +| `birthday` | `DATE` | `NOT NULL` | 생년월일 (1900-01-01 이후, 미래 불가) | +| `email` | `VARCHAR(255)` | `NOT NULL` | 이메일 주소 | +| `created_at` | `DATETIME` | `NOT NULL`, `updatable = false` | 가입일시 (수정 불가) | + +### DDL (예상) + +```sql +CREATE TABLE users ( + id BIGINT NOT NULL AUTO_INCREMENT, + user_id VARCHAR(20) NOT NULL, + encoded_password VARCHAR(255) NOT NULL, + username VARCHAR(20) NOT NULL, + birthday DATE NOT NULL, + email VARCHAR(255) NOT NULL, + created_at DATETIME NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_users_user_id (user_id) +); +``` + +### 인덱스 + +| 인덱스 | 타입 | 컬럼 | 용도 | +|---|---|---|---| +| `PRIMARY` | PK | `id` | 내부 식별자 | +| `uk_users_user_id` | UNIQUE | `user_id` | 로그인 ID 중복 방지 및 조회 | + +--- + +## 7-3. 도메인 모델 ↔ 테이블 매핑 + +`UserJpaEntity`는 `BaseEntity`를 상속하지 않고 독자적으로 컬럼을 정의합니다. + +``` +Domain (User) DB (users) +───────────── ────────── +Long id → id (BIGINT, PK) +UserId.value → user_id (VARCHAR) +String encodedPassword → encoded_password (VARCHAR) +UserName.value → username (VARCHAR) +Birthday.value → birthday (DATE) +Email.value → email (VARCHAR) +LocalDateTime createdAt → created_at (DATETIME) +───────────────────────────────────────────── +WrongPasswordCount → (DB에 미저장, 도메인 전용) +``` + +### 주의 사항 + +- **`WrongPasswordCount`**: 현재 DB에 컬럼이 없으며, 복원 시 항상 `WrongPasswordCount.init()` (0)으로 초기화됩니다. +- **`updated_at` / `deleted_at`**: `BaseEntity`에 정의되어 있지만 `UserJpaEntity`는 상속하지 않아 해당 컬럼이 없습니다. + +### 데이터 정합성 검토 + +**현재 구현 (users)** + +| 항목 | 현재 상태 | 리스크 | +|---|---|---| +| `email` UNIQUE 제약 없음 | 동일 이메일로 다중 가입 가능 | 이메일 기반 기능(비밀번호 찾기 등) 추가 시 정합성 깨짐 | +| `WrongPasswordCount` 미영속 | 서버 재시작/복원 시 항상 0으로 리셋 | 비밀번호 5회 오류 잠금 정책이 사실상 무력화됨 | +| `birthday` 검증이 앱 레벨에만 존재 | DB에는 어떤 날짜든 들어갈 수 있음 | 직접 SQL 삽입이나 마이그레이션 시 검증 우회 가능 | +| `updated_at` 컬럼 없음 | 비밀번호 변경 이력 추적 불가 | 감사(audit) 요구사항 발생 시 대응 어려움 | + +**선택지** + +- `email`에 UNIQUE 걸지 않은 건 의도적인 결정일 수 있다. 단, 향후 요구사항에 따라 `uk_users_email` 추가를 검토할 필요가 있다. +- `WrongPasswordCount`는 두 가지 방향이 있다: + - **A) DB 컬럼 추가** → 영속화하여 재시작에도 유지. 단순하지만 매번 UPDATE 발생. + - **B) Redis 캐시** → TTL 기반으로 일정 시간 후 자동 리셋. 별도 인프라 의존. +- `birthday` DB 레벨 제약은 `CHECK (birthday >= '1900-01-01' AND birthday <= CURDATE())`로 가능하지만, JPA `ddl-auto: create` 환경에서는 적용되지 않으므로 운영 DDL에서 관리해야 한다. + +--- + +## 7-4. BaseEntity 공통 컬럼 (향후 테이블 확장 시 적용) + +`modules/jpa`에 정의된 `BaseEntity`는 향후 새로운 엔티티가 상속받아 사용할 공통 컬럼입니다. + +```mermaid +erDiagram + BASE_ENTITY { + BIGINT id PK "AUTO_INCREMENT" + DATETIME created_at "NOT NULL, 자동 설정" + DATETIME updated_at "NOT NULL, 자동 갱신" + DATETIME deleted_at "NULL, Soft Delete" + } +``` + +| 컬럼 | 동작 | 설명 | +|---|---|---| +| `created_at` | `@PrePersist` 시 자동 설정 | 최초 생성일시 (수정 불가) | +| `updated_at` | `@PrePersist`, `@PreUpdate` 시 자동 갱신 | 마지막 수정일시 | +| `deleted_at` | `delete()` 호출 시 설정, `restore()` 시 null | Soft Delete 지원 | + +--- + +## 7-5. 향후 확장 ERD (미래 목표) + +시퀀스 다이어그램 5-2 ~ 5-6에서 설계한 브랜드/상품/좋아요/주문 기능 구현 시 예상되는 테이블 구조입니다. + +```mermaid +erDiagram + USERS ||--o{ ORDERS : "주문" + USERS ||--o{ LIKES : "좋아요" + + BRANDS ||--o{ PRODUCTS : "보유 상품" + + PRODUCTS ||--o{ PRODUCT_IMAGES : "상품 이미지" + PRODUCTS ||--o{ ORDER_ITEMS : "주문 항목" + PRODUCTS ||--o{ LIKES : "좋아요 대상" + + ORDERS ||--o{ ORDER_ITEMS : "주문 상세" + ORDERS ||--o| ORDER_SNAPSHOTS : "주문 스냅샷" + + USERS { + BIGINT id PK + VARCHAR user_id UK + VARCHAR encoded_password + VARCHAR username + DATE birthday + VARCHAR email + DATETIME created_at + } + + BRANDS { + BIGINT id PK + VARCHAR name UK "NOT NULL" + VARCHAR description + DATETIME created_at + DATETIME updated_at + DATETIME deleted_at + } + + PRODUCTS { + BIGINT id PK + BIGINT brand_id FK "NOT NULL" + VARCHAR name "NOT NULL" + INT price "NOT NULL, CHECK >= 0" + INT stock_quantity "NOT NULL, CHECK >= 0" + INT like_count "NOT NULL, DEFAULT 0" + VARCHAR description + DATETIME created_at + DATETIME updated_at + DATETIME deleted_at + } + + PRODUCT_IMAGES { + BIGINT id PK + BIGINT product_id FK + VARCHAR image_url + INT sort_order + DATETIME created_at + } + + LIKES { + BIGINT id PK + BIGINT user_id FK "UNIQUE(user_id, product_id)" + BIGINT product_id FK + DATETIME created_at + } + + ORDERS { + BIGINT id PK + BIGINT user_id FK + VARCHAR receiver_name "NOT NULL, 수령인" + VARCHAR address "NOT NULL, 배송지" + VARCHAR request "NULL, 배송 요청사항" + VARCHAR payment_method "NOT NULL, 결제수단" + INT total_amount "NOT NULL, 총 주문금액" + INT discount_amount "NOT NULL, 할인금액 (DEFAULT 0)" + INT payment_amount "NOT NULL, 최종 결제금액" + VARCHAR status "NOT NULL, 주문상태" + DATE desired_delivery_date "NULL, 도착희망일" + DATETIME created_at + DATETIME updated_at + } + + ORDER_ITEMS { + BIGINT id PK + BIGINT order_id FK "NOT NULL, CASCADE DELETE" + BIGINT product_id FK "NOT NULL" + INT quantity "NOT NULL, CHECK > 0" + INT unit_price "NOT NULL, 주문 시점 단가" + } + + ORDER_SNAPSHOTS { + BIGINT id PK + BIGINT order_id FK "NOT NULL, UNIQUE, CASCADE DELETE" + TEXT snapshot_data "NOT NULL, JSON" + DATETIME created_at "NOT NULL" + } +``` + +### 이 ERD에서 봐야 할 포인트 + +- `ORDERS`와 `ORDER_ITEMS`는 합성(Composition) 관계다. 주문이 삭제되면 주문 항목도 함께 사라져야 하므로 FK에 **CASCADE DELETE**를 명시했다. `ORDER_SNAPSHOTS`도 마찬가지. +- `LIKES`에 `UNIQUE(user_id, product_id)` 복합 유니크가 걸려 있다. 이것이 "유저당 1상품 1좋아요"를 DB 레벨에서 보장하는 핵심 제약이다. +- `ORDER_ITEMS.unit_price`는 주문 시점의 상품 단가를 스냅샷한다. `PRODUCTS.price`가 이후 변경되어도 주문 금액이 보존된다. `ORDER_SNAPSHOTS`와 함께 **주문 시점 불변성**을 이중으로 보장하는 구조다. +- `PRODUCTS.like_count`는 비정규화 컬럼이다. `COUNT(LIKES)`를 매번 조회하는 비용을 줄이기 위해 도입했지만, `LIKES` 테이블과의 **정합성 유지 책임**이 서비스 레이어에 생긴다. + +### 정합성을 위해 적용한 DB 제약조건 + +| 테이블 | 제약 | 보장하는 것 | +|---|---|---| +| `BRANDS.name` | `UNIQUE` | 브랜드 이름 중복 방지 | +| `PRODUCTS.price` | `CHECK >= 0` | 음수 가격 방지 | +| `PRODUCTS.stock_quantity` | `CHECK >= 0` | 재고 음수 방지 (동시성은 앱 레벨에서 추가 보장 필요) | +| `LIKES(user_id, product_id)` | `UNIQUE` | 유저당 1상품 1좋아요 | +| `ORDER_ITEMS.quantity` | `CHECK > 0` | 0개 주문 방지 | +| `ORDER_ITEMS.order_id` | `FK CASCADE DELETE` | 주문 삭제 시 항목 자동 삭제 | +| `ORDER_SNAPSHOTS.order_id` | `FK CASCADE DELETE`, `UNIQUE` | 주문당 1스냅샷, 주문 삭제 시 자동 삭제 | + +### 금액 정합성 규칙 + +``` +ORDERS.total_amount = SUM(ORDER_ITEMS.unit_price * ORDER_ITEMS.quantity) +ORDERS.payment_amount = ORDERS.total_amount - ORDERS.discount_amount +``` + +이 관계는 DB `CHECK` 제약으로 걸 수 없다 (cross-row 참조). 주문 생성 시 **서비스 레이어에서 계산하고 검증**해야 하며, 사후에 불일치가 발생하면 `ORDER_SNAPSHOTS`의 원본 데이터로 추적할 수 있다. + +### 데이터 정합성 리스크 (향후 구현 시 검토) + +| 항목 | 리스크 | 선택지 | +|---|---|---| +| `PRODUCTS.stock_quantity` 동시성 | `CHECK >= 0`만으로는 동시 주문 시 race condition 방지 불가 | **A)** `SELECT ... FOR UPDATE` 비관적 잠금 **B)** `UPDATE ... SET stock = stock - ? WHERE stock >= ?` 원자적 감소 | +| `PRODUCTS.like_count` ↔ `LIKES` 불일치 | 좋아요 생성/삭제 시 count 동기화가 깨질 수 있음 | **A)** 같은 트랜잭션에서 `LIKES` INSERT + `PRODUCTS.like_count` UPDATE **B)** 비동기 이벤트로 분리 (eventual consistency) | +| `PRODUCTS` Soft Delete + `ORDER_ITEMS` FK | 삭제된 상품의 주문 내역 조회 시 FK 참조 깨짐 | **A)** Soft Delete이므로 실제 삭제 안 됨 — 조회 시 `deleted_at IS NOT NULL` 필터링 **B)** `ORDER_SNAPSHOTS`에 상품 정보가 있으므로 FK 대신 스냅샷 활용 | +| `BRANDS` Soft Delete + `PRODUCTS.brand_id` FK | 브랜드 삭제 시 하위 상품 처리 정책 필요 | **A)** Cascade Soft Delete — 브랜드 삭제 시 상품도 함께 Soft Delete **B)** 브랜드에 상품이 남아있으면 삭제 차단 | +| `ORDERS.discount_amount` 근거 없음 | 쿠폰 제거 후 할인 금액의 산출 근거가 불명확 | 할인 정책이 없다면 컬럼 제거 검토. 있다면 `discount_type` 등으로 근거를 남겨야 함 | +| `ORDERS.status` VARCHAR | 문자열이라 오타/잘못된 값 입력 가능 | **A)** `ENUM` 타입 사용 — DB 레벨 보장, 마이그레이션 시 변경 어려움 **B)** VARCHAR 유지 + 앱 레벨 검증 — 유연하지만 DB 정합성 약함 | + +> 위 ERD는 미래 구현 목표이며, 실제 구현 시 도메인 설계에 따라 변경될 수 있습니다. diff --git a/apps/commerce-api/src/main/java/com/loopers/application/service/AuthenticationService.java b/apps/commerce-api/src/main/java/com/loopers/application/service/AuthenticationService.java deleted file mode 100644 index 9ea21ed0..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/service/AuthenticationService.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.loopers.application.service; - -import com.loopers.application.AuthenticationUseCase; -import com.loopers.domain.model.User; -import com.loopers.domain.model.UserId; -import com.loopers.domain.repository.UserRepository; -import com.loopers.domain.service.PasswordEncoder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@Transactional(readOnly = true) -public class AuthenticationService implements AuthenticationUseCase { - - private final UserRepository userRepository; - private final PasswordEncoder passwordEncoder; - - public AuthenticationService(UserRepository userRepository, PasswordEncoder passwordEncoder) { - this.userRepository = userRepository; - this.passwordEncoder = passwordEncoder; - } - - @Override - public void authenticate(UserId userId, String rawPassword) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); - - if (!passwordEncoder.matches(rawPassword, user.getEncodedPassword())) { - throw new IllegalArgumentException("비밀번호가 일치하지 않습니다."); - } - } -} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/application/service/PasswordUpdateService.java b/apps/commerce-api/src/main/java/com/loopers/application/service/PasswordUpdateService.java deleted file mode 100644 index 1de70c1e..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/service/PasswordUpdateService.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.loopers.application.service; - -import com.loopers.application.PasswordUpdateUseCase; -import com.loopers.domain.model.Password; -import com.loopers.domain.model.User; -import com.loopers.domain.model.UserId; -import com.loopers.domain.repository.UserRepository; -import com.loopers.domain.service.PasswordEncoder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDate; - -@Service -public class PasswordUpdateService implements PasswordUpdateUseCase { - - private final UserRepository userRepository; - private final PasswordEncoder passwordEncoder; - - public PasswordUpdateService(UserRepository userRepository, PasswordEncoder passwordEncoder) { - this.userRepository = userRepository; - this.passwordEncoder = passwordEncoder; - } - - @Override - @Transactional - public void updatePassword(UserId userId, String currentRawPassword, String newRawPassword) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); - - LocalDate birthday = user.getBirth().getValue(); - Password currentPassword = Password.of(currentRawPassword, birthday); - Password newPassword = Password.of(newRawPassword, birthday); - - // 기존 비밀번호 확인 - if (!passwordEncoder.matches(currentPassword.getValue(), user.getEncodedPassword())) { - throw new IllegalArgumentException("현재 비밀번호가 일치하지 않습니다."); - } - - // 새 비밀번호가 현재 비밀번호와 동일한지 확인 - if (passwordEncoder.matches(newPassword.getValue(), user.getEncodedPassword())) { - throw new IllegalArgumentException("현재 비밀번호는 사용할 수 없습니다."); - } - - // 비밀번호 암호화 후 저장 - String encodedNewPassword = passwordEncoder.encrypt(newPassword.getValue()); - User updatedUser = user.changePassword(encodedNewPassword); - userRepository.save(updatedUser); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/service/UserQueryService.java b/apps/commerce-api/src/main/java/com/loopers/application/service/UserQueryService.java deleted file mode 100644 index 0dc883ca..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/service/UserQueryService.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.loopers.application.service; - -import com.loopers.application.UserQueryUseCase; -import com.loopers.domain.model.User; -import com.loopers.domain.model.UserId; -import com.loopers.domain.repository.UserRepository; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@Transactional(readOnly = true) -public class UserQueryService implements UserQueryUseCase { - - private final UserRepository userRepository; - - public UserQueryService(UserRepository userRepository) { - this.userRepository = userRepository; - } - - @Override - public UserInfoResponse getUserInfo(UserId userId) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); - - return new UserInfoResponse( - user.getUserId().getValue(), - maskName(user.getUserName().getValue()), - user.getBirth().getValue(), - user.getEmail().getValue() - ); - } - - private String maskName(String name) { - if (name == null || name.isEmpty()) { - return name; - } - if (name.length() == 1) { - return "*"; - } - return name.substring(0, name.length() - 1) + "*"; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/service/UserRegisterService.java b/apps/commerce-api/src/main/java/com/loopers/application/service/UserRegisterService.java deleted file mode 100644 index 5766561c..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/service/UserRegisterService.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.loopers.application.service; - -import com.loopers.application.RegisterUseCase; -import com.loopers.domain.model.*; -import com.loopers.domain.repository.UserRepository; -import com.loopers.domain.service.PasswordEncoder; -import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDate; -import java.time.LocalDateTime; - -@Service -public class UserRegisterService implements RegisterUseCase { - - private final UserRepository userRepository; - private final PasswordEncoder passwordEncoder; - - public UserRegisterService(UserRepository userRepository, PasswordEncoder passwordEncoder) { - this.userRepository = userRepository; - this.passwordEncoder = passwordEncoder; - } - - @Override - @Transactional - public void register(String loginId, String name, String rawPassword, LocalDate birthday, String email) { - UserId userId = UserId.of(loginId); - UserName userName = UserName.of(name); - Birthday birth = Birthday.of(birthday); - Email userEmail = Email.of(email); - Password password = Password.of(rawPassword, birthday); - String encodedPassword = passwordEncoder.encrypt(password.getValue()); - - try { - User user = User.register( - userId, - userName, - encodedPassword, - birth, - userEmail, - WrongPasswordCount.init(), - LocalDateTime.now() - ); - userRepository.save(user); - } catch (DataIntegrityViolationException ex) { - throw new IllegalArgumentException("이미 사용중인 ID 입니다.", ex); - } - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/service/UserService.java b/apps/commerce-api/src/main/java/com/loopers/application/service/UserService.java new file mode 100644 index 00000000..46c72079 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/service/UserService.java @@ -0,0 +1,107 @@ +package com.loopers.application.service; + +import com.loopers.application.AuthenticationUseCase; +import com.loopers.application.PasswordUpdateUseCase; +import com.loopers.application.RegisterUseCase; +import com.loopers.application.UserQueryUseCase; +import com.loopers.domain.model.*; +import com.loopers.domain.repository.UserRepository; +import com.loopers.domain.service.PasswordEncoder; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Service +@Transactional(readOnly = true) +public class UserService implements RegisterUseCase, AuthenticationUseCase, PasswordUpdateUseCase, UserQueryUseCase { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) { + this.userRepository = userRepository; + this.passwordEncoder = passwordEncoder; + } + + @Override + @Transactional + public void register(String loginId, String name, String rawPassword, LocalDate birthday, String email) { + UserId userId = UserId.of(loginId); + UserName userName = UserName.of(name); + Birthday birth = Birthday.of(birthday); + Email userEmail = Email.of(email); + Password password = Password.of(rawPassword, birthday); + String encodedPassword = passwordEncoder.encrypt(password.getValue()); + + try { + User user = User.register( + userId, userName, encodedPassword, birth, + userEmail, WrongPasswordCount.init(), LocalDateTime.now() + ); + userRepository.save(user); + } catch (DataIntegrityViolationException ex) { + throw new IllegalArgumentException("이미 사용중인 ID 입니다.", ex); + } + } + + @Override + public void authenticate(UserId userId, String rawPassword) { + User user = findUser(userId); + + if (!passwordEncoder.matches(rawPassword, user.getEncodedPassword())) { + throw new IllegalArgumentException("비밀번호가 일치하지 않습니다."); + } + } + + @Override + @Transactional + public void updatePassword(UserId userId, String currentRawPassword, String newRawPassword) { + User user = findUser(userId); + + LocalDate birthday = user.getBirth().getValue(); + Password currentPassword = Password.of(currentRawPassword, birthday); + Password newPassword = Password.of(newRawPassword, birthday); + + if (!passwordEncoder.matches(currentPassword.getValue(), user.getEncodedPassword())) { + throw new IllegalArgumentException("현재 비밀번호가 일치하지 않습니다."); + } + + if (passwordEncoder.matches(newPassword.getValue(), user.getEncodedPassword())) { + throw new IllegalArgumentException("현재 비밀번호는 사용할 수 없습니다."); + } + + String encodedNewPassword = passwordEncoder.encrypt(newPassword.getValue()); + User updatedUser = user.changePassword(encodedNewPassword); + userRepository.save(updatedUser); + } + + @Override + public UserInfoResponse getUserInfo(UserId userId) { + User user = findUser(userId); + + return new UserInfoResponse( + user.getUserId().getValue(), + maskName(user.getUserName().getValue()), + user.getBirth().getValue(), + user.getEmail().getValue() + ); + } + + private User findUser(UserId userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); + } + + private String maskName(String name) { + if (name == null || name.isEmpty()) { + return name; + } + if (name.length() == 1) { + return "*"; + } + return name.substring(0, name.length() - 1) + "*"; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/GlobalExceptionHandler.java b/apps/commerce-api/src/main/java/com/loopers/support/error/GlobalExceptionHandler.java new file mode 100644 index 00000000..105a897c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/GlobalExceptionHandler.java @@ -0,0 +1,69 @@ +package com.loopers.support.error; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingRequestHeaderException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.Map; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(CoreException.class) + public ResponseEntity> handleCoreException(CoreException e) { + return ResponseEntity + .status(e.getErrorType().getStatus()) + .body(Map.of( + "code", e.getErrorType().getCode(), + "message", e.getMessage() + )); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleIllegalArgumentException(IllegalArgumentException e) { + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(Map.of( + "code", "BAD_REQUEST", + "message", e.getMessage() + )); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidationException(MethodArgumentNotValidException e) { + String message = e.getBindingResult().getFieldErrors().stream() + .findFirst() + .map(error -> error.getField() + ": " + error.getDefaultMessage()) + .orElse("유효성 검사 실패"); + + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(Map.of( + "code", "VALIDATION_ERROR", + "message", message + )); + } + + @ExceptionHandler(MissingRequestHeaderException.class) + public ResponseEntity> handleMissingHeaderException(MissingRequestHeaderException e) { + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(Map.of( + "code", "MISSING_HEADER", + "message", "필수 헤더가 누락되었습니다: " + e.getHeaderName() + )); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleException(Exception e) { + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of( + "code", "INTERNAL_ERROR", + "message", "일시적인 오류가 발생했습니다." + )); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/service/AuthenticationServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/service/AuthenticationServiceTest.java deleted file mode 100644 index d0b5e776..00000000 --- a/apps/commerce-api/src/test/java/com/loopers/application/service/AuthenticationServiceTest.java +++ /dev/null @@ -1,100 +0,0 @@ -package com.loopers.application.service; - -import com.loopers.domain.model.*; -import com.loopers.domain.repository.UserRepository; -import com.loopers.domain.service.PasswordEncoder; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.*; - -class AuthenticationServiceTest { - - private UserRepository userRepository; - private PasswordEncoder passwordEncoder; - private AuthenticationService service; - - @BeforeEach - void setUp() { - userRepository = mock(UserRepository.class); - passwordEncoder = mock(PasswordEncoder.class); - service = new AuthenticationService(userRepository, passwordEncoder); - } - - @Test - @DisplayName("인증 성공") - void authenticate_success() { - // given - UserId userId = UserId.of("test1234"); - String rawPassword = "Password1!"; - String encodedPassword = "encoded_password"; - - User user = User.reconstitute( - 1L, - userId, - UserName.of("홍길동"), - encodedPassword, - Birthday.of(LocalDate.of(1990, 5, 15)), - Email.of("test@example.com"), - WrongPasswordCount.init(), - LocalDateTime.now() - ); - - when(userRepository.findById(userId)).thenReturn(Optional.of(user)); - when(passwordEncoder.matches(rawPassword, encodedPassword)).thenReturn(true); - - // when & then - 예외가 발생하지 않으면 성공 - service.authenticate(userId, rawPassword); - - verify(userRepository).findById(userId); - verify(passwordEncoder).matches(rawPassword, encodedPassword); - } - - @Test - @DisplayName("존재하지 않는 사용자 인증 실패") - void authenticate_fail_userNotFound() { - // given - UserId userId = UserId.of("notexist"); - - when(userRepository.findById(userId)).thenReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> service.authenticate(userId, "password")) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("사용자를 찾을 수 없습니다"); - } - - @Test - @DisplayName("비밀번호 불일치 인증 실패") - void authenticate_fail_passwordMismatch() { - // given - UserId userId = UserId.of("test1234"); - String wrongPassword = "WrongPassword1!"; - String encodedPassword = "encoded_password"; - - User user = User.reconstitute( - 1L, - userId, - UserName.of("홍길동"), - encodedPassword, - Birthday.of(LocalDate.of(1990, 5, 15)), - Email.of("test@example.com"), - WrongPasswordCount.init(), - LocalDateTime.now() - ); - - when(userRepository.findById(userId)).thenReturn(Optional.of(user)); - when(passwordEncoder.matches(wrongPassword, encodedPassword)).thenReturn(false); - - // when & then - assertThatThrownBy(() -> service.authenticate(userId, wrongPassword)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("비밀번호가 일치하지 않습니다"); - } -} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/application/service/PasswordUpdateServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/service/PasswordUpdateServiceTest.java deleted file mode 100644 index 33a30c4a..00000000 --- a/apps/commerce-api/src/test/java/com/loopers/application/service/PasswordUpdateServiceTest.java +++ /dev/null @@ -1,123 +0,0 @@ -package com.loopers.application.service; - -import com.loopers.domain.model.*; -import com.loopers.domain.repository.UserRepository; -import com.loopers.domain.service.PasswordEncoder; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThatNoException; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -class PasswordUpdateServiceTest { - - private UserRepository userRepository; - private PasswordEncoder passwordEncoder; - private PasswordUpdateService service; - - private static final LocalDate BIRTHDAY = LocalDate.of(1990, 5, 15); - - @BeforeEach - void setUp() { - userRepository = mock(UserRepository.class); - passwordEncoder = mock(PasswordEncoder.class); - service = new PasswordUpdateService(userRepository, passwordEncoder); - } - - @Test - @DisplayName("비밀번호 변경 성공") - void updatePassword_success() { - // given - UserId userId = UserId.of("test1234"); - User user = createUser(userId, "encoded_current"); - String currentRawPassword = "Current1!"; - String newRawPassword = "NewPass1!"; - - when(userRepository.findById(userId)).thenReturn(Optional.of(user)); - when(passwordEncoder.matches(currentRawPassword, "encoded_current")).thenReturn(true); - when(passwordEncoder.matches(newRawPassword, "encoded_current")).thenReturn(false); - when(passwordEncoder.encrypt(newRawPassword)).thenReturn("encoded_new"); - - // when & then - assertThatNoException() - .isThrownBy(() -> service.updatePassword(userId, currentRawPassword, newRawPassword)); - - verify(userRepository).save(any(User.class)); - } - - @Test - @DisplayName("현재 비밀번호 불일치시 예외") - void updatePassword_fail_wrong_current() { - // given - UserId userId = UserId.of("test1234"); - User user = createUser(userId, "encoded_current"); - String wrongRawPassword = "WrongPw1!"; - String newRawPassword = "NewPass1!"; - - when(userRepository.findById(userId)).thenReturn(Optional.of(user)); - when(passwordEncoder.matches(wrongRawPassword, "encoded_current")).thenReturn(false); - - // when & then - assertThatThrownBy(() -> service.updatePassword(userId, wrongRawPassword, newRawPassword)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("현재 비밀번호가 일치하지 않습니다"); - - verify(userRepository, never()).save(any(User.class)); - } - - @Test - @DisplayName("새 비밀번호가 현재와 동일하면 예외") - void updatePassword_fail_same_password() { - // given - UserId userId = UserId.of("test1234"); - User user = createUser(userId, "encoded_current"); - String currentRawPassword = "Current1!"; - String sameRawPassword = "Current1!"; - - when(userRepository.findById(userId)).thenReturn(Optional.of(user)); - when(passwordEncoder.matches(currentRawPassword, "encoded_current")).thenReturn(true); - - // when & then - assertThatThrownBy(() -> service.updatePassword(userId, currentRawPassword, sameRawPassword)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("현재 비밀번호는 사용할 수 없습니다"); - - verify(userRepository, never()).save(any(User.class)); - } - - @Test - @DisplayName("존재하지 않는 사용자면 예외") - void updatePassword_fail_user_not_found() { - // given - UserId userId = UserId.of("notexist"); - String currentRawPassword = "Current1!"; - String newRawPassword = "NewPass1!"; - - when(userRepository.findById(userId)).thenReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> service.updatePassword(userId, currentRawPassword, newRawPassword)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("사용자를 찾을 수 없습니다"); - } - - private User createUser(UserId userId, String encodedPassword) { - return User.reconstitute( - 1L, - userId, - UserName.of("홍길동"), - encodedPassword, - Birthday.of(BIRTHDAY), - Email.of("test@example.com"), - WrongPasswordCount.init(), - LocalDateTime.now() - ); - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/service/UserQueryServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/service/UserQueryServiceTest.java deleted file mode 100644 index 230dba2a..00000000 --- a/apps/commerce-api/src/test/java/com/loopers/application/service/UserQueryServiceTest.java +++ /dev/null @@ -1,105 +0,0 @@ -package com.loopers.application.service; - -import com.loopers.domain.model.*; -import com.loopers.domain.repository.UserRepository; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -class UserQueryServiceTest { - - private UserRepository userRepository; - private UserQueryService service; - - @BeforeEach - void setUp() { - userRepository = mock(UserRepository.class); - service = new UserQueryService(userRepository); - } - - @Test - @DisplayName("내 정보 조회 성공") - void getUserInfo_success() { - // given - UserId userId = UserId.of("test1234"); - LocalDate birthday = LocalDate.of(1990, 5, 15); - User user = User.reconstitute( - 1L, - userId, - UserName.of("홍길동"), - "encoded_password", - Birthday.of(birthday), - Email.of("test@example.com"), - WrongPasswordCount.init(), - LocalDateTime.now() - ); - - when(userRepository.findById(userId)).thenReturn(Optional.of(user)); - - // when - var result = service.getUserInfo(userId); - - // then - assertThat(result.loginId()).isEqualTo("test1234"); - assertThat(result.maskedName()).isEqualTo("홍길*"); - assertThat(result.birthday()).isEqualTo(birthday); - assertThat(result.email()).isEqualTo("test@example.com"); - } - - @Test - @DisplayName("이름 마스킹 - 2자") - void getUserInfo_maskedName_2chars() { - // given - UserId userId = UserId.of("test1234"); - User user = User.reconstitute( - 1L, - userId, - UserName.of("홍길"), - "encoded_password", - Birthday.of(LocalDate.of(1990, 5, 15)), - Email.of("test@example.com"), - WrongPasswordCount.init(), - LocalDateTime.now() - ); - - when(userRepository.findById(userId)).thenReturn(Optional.of(user)); - - // when - var result = service.getUserInfo(userId); - - // then - assertThat(result.maskedName()).isEqualTo("홍*"); - } - - @Test - @DisplayName("존재하지 않는 사용자 조회시 예외") - void getUserInfo_fail_not_found() { - // given - UserId userId = UserId.of("notexist"); - - when(userRepository.findById(userId)).thenReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> service.getUserInfo(userId)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("사용자를 찾을 수 없습니다"); - } - - @Test - @DisplayName("이름은 최소 2자 이상이어야 한다") - void userName_fail_lessThan2chars() { - // UserName은 2~20자만 허용하므로 1자는 생성 불가 - assertThatThrownBy(() -> UserName.of("홍")) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("2~20자"); - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/service/UserRegisterServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/service/UserRegisterServiceTest.java deleted file mode 100644 index 86721148..00000000 --- a/apps/commerce-api/src/test/java/com/loopers/application/service/UserRegisterServiceTest.java +++ /dev/null @@ -1,71 +0,0 @@ -package com.loopers.application.service; - -import com.loopers.domain.model.User; -import com.loopers.domain.repository.UserRepository; -import com.loopers.domain.service.PasswordEncoder; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.dao.DataIntegrityViolationException; - -import java.time.LocalDate; - -import static org.assertj.core.api.Assertions.assertThatNoException; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.*; - -class UserRegisterServiceTest { - - private UserRepository userRepository; - private PasswordEncoder passwordEncoder; - private UserRegisterService service; - - @BeforeEach - void setUp() { - userRepository = mock(UserRepository.class); - passwordEncoder = mock(PasswordEncoder.class); - service = new UserRegisterService(userRepository, passwordEncoder); - } - - @Test - @DisplayName("회원가입 성공") - void register_success() { - // given - String loginId = "test1234"; - String name = "홍길동"; - String rawPassword = "Password1!"; - LocalDate birthday = LocalDate.of(1990, 5, 15); - String email = "test@example.com"; - - when(passwordEncoder.encrypt(anyString())).thenReturn("encoded_password"); - - // when & then - assertThatNoException() - .isThrownBy(() -> service.register(loginId, name, rawPassword, birthday, email)); - - verify(passwordEncoder).encrypt(rawPassword); - verify(userRepository).save(any(User.class)); - } - - @Test - @DisplayName("중복된 ID로 가입시 예외") - void register_fail_duplicated_id() { - // given - String duplicatedId = "test1234"; - String name = "홍길동"; - String rawPassword = "Password1!"; - LocalDate birthday = LocalDate.of(1990, 5, 15); - String email = "test@example.com"; - - when(passwordEncoder.encrypt(anyString())).thenReturn("encoded_password"); - doThrow(new DataIntegrityViolationException("Duplicate entry")) - .when(userRepository).save(any(User.class)); - - // when & then - assertThatThrownBy(() -> service.register(duplicatedId, name, rawPassword, birthday, email)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("이미 사용중인 ID"); - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/service/UserServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/service/UserServiceTest.java new file mode 100644 index 00000000..92ca8c06 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/service/UserServiceTest.java @@ -0,0 +1,307 @@ +package com.loopers.application.service; + +import com.loopers.domain.model.*; +import com.loopers.domain.repository.UserRepository; +import com.loopers.domain.service.PasswordEncoder; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DataIntegrityViolationException; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +class UserServiceTest { + + private UserRepository userRepository; + private PasswordEncoder passwordEncoder; + private UserService service; + + private static final LocalDate BIRTHDAY = LocalDate.of(1990, 5, 15); + + @BeforeEach + void setUp() { + userRepository = mock(UserRepository.class); + passwordEncoder = mock(PasswordEncoder.class); + service = new UserService(userRepository, passwordEncoder); + } + + @Nested + @DisplayName("회원가입") + class Register { + + @Test + @DisplayName("회원가입 성공") + void register_success() { + // given + String loginId = "test1234"; + String name = "홍길동"; + String rawPassword = "Password1!"; + String email = "test@example.com"; + + when(passwordEncoder.encrypt(anyString())).thenReturn("encoded_password"); + + // when & then + assertThatNoException() + .isThrownBy(() -> service.register(loginId, name, rawPassword, BIRTHDAY, email)); + + verify(passwordEncoder).encrypt(rawPassword); + verify(userRepository).save(any(User.class)); + } + + @Test + @DisplayName("중복된 ID로 가입시 예외") + void register_fail_duplicated_id() { + // given + String duplicatedId = "test1234"; + String name = "홍길동"; + String rawPassword = "Password1!"; + String email = "test@example.com"; + + when(passwordEncoder.encrypt(anyString())).thenReturn("encoded_password"); + doThrow(new DataIntegrityViolationException("Duplicate entry")) + .when(userRepository).save(any(User.class)); + + // when & then + assertThatThrownBy(() -> service.register(duplicatedId, name, rawPassword, BIRTHDAY, email)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("이미 사용중인 ID"); + } + } + + @Nested + @DisplayName("인증") + class Authentication { + + @Test + @DisplayName("인증 성공") + void authenticate_success() { + // given + UserId userId = UserId.of("test1234"); + String rawPassword = "Password1!"; + String encodedPassword = "encoded_password"; + User user = createUser(userId, encodedPassword); + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(passwordEncoder.matches(rawPassword, encodedPassword)).thenReturn(true); + + // when & then + service.authenticate(userId, rawPassword); + + verify(userRepository).findById(userId); + verify(passwordEncoder).matches(rawPassword, encodedPassword); + } + + @Test + @DisplayName("존재하지 않는 사용자 인증 실패") + void authenticate_fail_userNotFound() { + // given + UserId userId = UserId.of("notexist"); + + when(userRepository.findById(userId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> service.authenticate(userId, "password")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("사용자를 찾을 수 없습니다"); + } + + @Test + @DisplayName("비밀번호 불일치 인증 실패") + void authenticate_fail_passwordMismatch() { + // given + UserId userId = UserId.of("test1234"); + String wrongPassword = "WrongPassword1!"; + String encodedPassword = "encoded_password"; + User user = createUser(userId, encodedPassword); + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(passwordEncoder.matches(wrongPassword, encodedPassword)).thenReturn(false); + + // when & then + assertThatThrownBy(() -> service.authenticate(userId, wrongPassword)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("비밀번호가 일치하지 않습니다"); + } + } + + @Nested + @DisplayName("비밀번호 변경") + class PasswordUpdate { + + @Test + @DisplayName("비밀번호 변경 성공") + void updatePassword_success() { + // given + UserId userId = UserId.of("test1234"); + User user = createUser(userId, "encoded_current"); + String currentRawPassword = "Current1!"; + String newRawPassword = "NewPass1!"; + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(passwordEncoder.matches(currentRawPassword, "encoded_current")).thenReturn(true); + when(passwordEncoder.matches(newRawPassword, "encoded_current")).thenReturn(false); + when(passwordEncoder.encrypt(newRawPassword)).thenReturn("encoded_new"); + + // when & then + assertThatNoException() + .isThrownBy(() -> service.updatePassword(userId, currentRawPassword, newRawPassword)); + + verify(userRepository).save(any(User.class)); + } + + @Test + @DisplayName("현재 비밀번호 불일치시 예외") + void updatePassword_fail_wrong_current() { + // given + UserId userId = UserId.of("test1234"); + User user = createUser(userId, "encoded_current"); + String wrongRawPassword = "WrongPw1!"; + String newRawPassword = "NewPass1!"; + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(passwordEncoder.matches(wrongRawPassword, "encoded_current")).thenReturn(false); + + // when & then + assertThatThrownBy(() -> service.updatePassword(userId, wrongRawPassword, newRawPassword)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("현재 비밀번호가 일치하지 않습니다"); + + verify(userRepository, never()).save(any(User.class)); + } + + @Test + @DisplayName("새 비밀번호가 현재와 동일하면 예외") + void updatePassword_fail_same_password() { + // given + UserId userId = UserId.of("test1234"); + User user = createUser(userId, "encoded_current"); + String currentRawPassword = "Current1!"; + String sameRawPassword = "Current1!"; + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(passwordEncoder.matches(currentRawPassword, "encoded_current")).thenReturn(true); + + // when & then + assertThatThrownBy(() -> service.updatePassword(userId, currentRawPassword, sameRawPassword)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("현재 비밀번호는 사용할 수 없습니다"); + + verify(userRepository, never()).save(any(User.class)); + } + + @Test + @DisplayName("존재하지 않는 사용자면 예외") + void updatePassword_fail_user_not_found() { + // given + UserId userId = UserId.of("notexist"); + + when(userRepository.findById(userId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> service.updatePassword(userId, "Current1!", "NewPass1!")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("사용자를 찾을 수 없습니다"); + } + } + + @Nested + @DisplayName("내 정보 조회") + class UserQuery { + + @Test + @DisplayName("내 정보 조회 성공") + void getUserInfo_success() { + // given + UserId userId = UserId.of("test1234"); + User user = User.reconstitute( + 1L, + userId, + UserName.of("홍길동"), + "encoded_password", + Birthday.of(BIRTHDAY), + Email.of("test@example.com"), + WrongPasswordCount.init(), + LocalDateTime.now() + ); + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + + // when + var result = service.getUserInfo(userId); + + // then + assertThat(result.loginId()).isEqualTo("test1234"); + assertThat(result.maskedName()).isEqualTo("홍길*"); + assertThat(result.birthday()).isEqualTo(BIRTHDAY); + assertThat(result.email()).isEqualTo("test@example.com"); + } + + @Test + @DisplayName("이름 마스킹 - 2자") + void getUserInfo_maskedName_2chars() { + // given + UserId userId = UserId.of("test1234"); + User user = User.reconstitute( + 1L, + userId, + UserName.of("홍길"), + "encoded_password", + Birthday.of(BIRTHDAY), + Email.of("test@example.com"), + WrongPasswordCount.init(), + LocalDateTime.now() + ); + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + + // when + var result = service.getUserInfo(userId); + + // then + assertThat(result.maskedName()).isEqualTo("홍*"); + } + + @Test + @DisplayName("존재하지 않는 사용자 조회시 예외") + void getUserInfo_fail_not_found() { + // given + UserId userId = UserId.of("notexist"); + + when(userRepository.findById(userId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> service.getUserInfo(userId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("사용자를 찾을 수 없습니다"); + } + + @Test + @DisplayName("이름은 최소 2자 이상이어야 한다") + void userName_fail_lessThan2chars() { + assertThatThrownBy(() -> UserName.of("홍")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("2~20자"); + } + } + + private User createUser(UserId userId, String encodedPassword) { + return User.reconstitute( + 1L, + userId, + UserName.of("홍길동"), + encodedPassword, + Birthday.of(BIRTHDAY), + Email.of("test@example.com"), + WrongPasswordCount.init(), + LocalDateTime.now() + ); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserApiE2ETest.java new file mode 100644 index 00000000..4a08ee37 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserApiE2ETest.java @@ -0,0 +1,297 @@ +package com.loopers.interfaces.api; + +import com.loopers.interfaces.api.dto.PasswordUpdateRequest; +import com.loopers.interfaces.api.dto.UserInfoResponse; +import com.loopers.interfaces.api.dto.UserRegisterRequest; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.context.annotation.Import; +import org.springframework.http.*; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Import(MySqlTestContainersConfig.class) +class UserApiE2ETest { + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private static final String BASE_URL = "/api/v1/users"; + private static final LocalDate TEST_BIRTHDAY = LocalDate.of(1990, 5, 15); + + @BeforeEach + void setUp() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + @DisplayName("E2E: 회원가입 시나리오") + class RegisterE2E { + + @Test + @DisplayName("회원가입 → 내 정보 조회 성공") + void register_then_getMyInfo() { + // given + String loginId = "e2euser1"; + String password = "Password1!"; + var registerRequest = createRegisterRequest(loginId, password, "홍길동"); + + // when - 회원가입 + ResponseEntity registerResponse = restTemplate.postForEntity( + BASE_URL + "/register", + registerRequest, + Void.class + ); + + // then - 회원가입 성공 + assertThat(registerResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + + // when - 내 정보 조회 + HttpHeaders headers = createAuthHeaders(loginId, password); + ResponseEntity getInfoResponse = restTemplate.exchange( + BASE_URL + "/me", + HttpMethod.GET, + new HttpEntity<>(headers), + UserInfoResponse.class + ); + + // then - 조회 성공 + assertThat(getInfoResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(getInfoResponse.getBody()).isNotNull(); + assertThat(getInfoResponse.getBody().loginId()).isEqualTo(loginId); + assertThat(getInfoResponse.getBody().name()).isEqualTo("홍길*"); + assertThat(getInfoResponse.getBody().birthday()).isEqualTo("19900515"); + } + + @Test + @DisplayName("중복 ID 가입 시도 실패") + void register_duplicateId_fail() { + // given + String loginId = "e2euser1"; + var request = createRegisterRequest(loginId, "Password1!", "홍길동"); + + // 첫 번째 가입 + restTemplate.postForEntity(BASE_URL + "/register", request, Void.class); + + // when - 동일 ID로 재가입 + ResponseEntity response = restTemplate.postForEntity( + BASE_URL + "/register", + request, + Void.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @Nested + @DisplayName("E2E: 인증 시나리오") + class AuthenticationE2E { + + @Test + @DisplayName("잘못된 비밀번호로 인증 실패") + void authentication_wrongPassword_fail() { + // given + String loginId = "e2euser1"; + registerUser(loginId, "Password1!", "홍길동"); + + // when - 잘못된 비밀번호로 조회 + HttpHeaders headers = createAuthHeaders(loginId, "WrongPassword1!"); + ResponseEntity response = restTemplate.exchange( + BASE_URL + "/me", + HttpMethod.GET, + new HttpEntity<>(headers), + String.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + @DisplayName("존재하지 않는 사용자 인증 실패") + void authentication_userNotFound_fail() { + // when + HttpHeaders headers = createAuthHeaders("notexist", "Password1!"); + ResponseEntity response = restTemplate.exchange( + BASE_URL + "/me", + HttpMethod.GET, + new HttpEntity<>(headers), + String.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @Nested + @DisplayName("E2E: 비밀번호 변경 시나리오") + class PasswordChangeE2E { + + @Test + @DisplayName("비밀번호 변경 → 새 비밀번호로 로그인 성공") + void changePassword_then_loginWithNewPassword() { + // given + String loginId = "e2euser1"; + String oldPassword = "Password1!"; + String newPassword = "NewPassword1!"; + registerUser(loginId, oldPassword, "홍길동"); + + // when - 비밀번호 변경 + HttpHeaders headers = createAuthHeaders(loginId, oldPassword); + headers.setContentType(MediaType.APPLICATION_JSON); + var updateRequest = new PasswordUpdateRequest(oldPassword, newPassword); + + ResponseEntity updateResponse = restTemplate.exchange( + BASE_URL + "/me/password", + HttpMethod.PUT, + new HttpEntity<>(updateRequest, headers), + Void.class + ); + + // then - 변경 성공 + assertThat(updateResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + + // when - 새 비밀번호로 조회 + HttpHeaders newHeaders = createAuthHeaders(loginId, newPassword); + ResponseEntity getInfoResponse = restTemplate.exchange( + BASE_URL + "/me", + HttpMethod.GET, + new HttpEntity<>(newHeaders), + UserInfoResponse.class + ); + + // then - 새 비밀번호로 조회 성공 + assertThat(getInfoResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + + // when - 이전 비밀번호로 조회 시도 + HttpHeaders oldHeaders = createAuthHeaders(loginId, oldPassword); + ResponseEntity oldPasswordResponse = restTemplate.exchange( + BASE_URL + "/me", + HttpMethod.GET, + new HttpEntity<>(oldHeaders), + String.class + ); + + // then - 이전 비밀번호로는 실패 + assertThat(oldPasswordResponse.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + @DisplayName("동일한 비밀번호로 변경 시 실패") + void changePassword_samePassword_fail() { + // given + String loginId = "e2euser1"; + String password = "Password1!"; + registerUser(loginId, password, "홍길동"); + + // when + HttpHeaders headers = createAuthHeaders(loginId, password); + headers.setContentType(MediaType.APPLICATION_JSON); + var updateRequest = new PasswordUpdateRequest(password, password); + + ResponseEntity response = restTemplate.exchange( + BASE_URL + "/me/password", + HttpMethod.PUT, + new HttpEntity<>(updateRequest, headers), + String.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @Nested + @DisplayName("E2E: 전체 사용자 플로우") + class FullUserFlowE2E { + + @Test + @DisplayName("회원가입 → 조회 → 비밀번호 변경 → 새 비밀번호로 조회") + void fullUserFlow() { + // Step 1: 회원가입 + String loginId = "flowuser1"; + String password = "Password1!"; + var registerRequest = createRegisterRequest(loginId, password, "김철수"); + + ResponseEntity registerResponse = restTemplate.postForEntity( + BASE_URL + "/register", + registerRequest, + Void.class + ); + assertThat(registerResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + + // Step 2: 내 정보 조회 + HttpHeaders headers = createAuthHeaders(loginId, password); + ResponseEntity infoResponse = restTemplate.exchange( + BASE_URL + "/me", + HttpMethod.GET, + new HttpEntity<>(headers), + UserInfoResponse.class + ); + assertThat(infoResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(infoResponse.getBody().name()).isEqualTo("김철*"); + + // Step 3: 비밀번호 변경 + String newPassword = "NewPassword1!"; + headers.setContentType(MediaType.APPLICATION_JSON); + var updateRequest = new PasswordUpdateRequest(password, newPassword); + + ResponseEntity updateResponse = restTemplate.exchange( + BASE_URL + "/me/password", + HttpMethod.PUT, + new HttpEntity<>(updateRequest, headers), + Void.class + ); + assertThat(updateResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + + // Step 4: 새 비밀번호로 조회 + HttpHeaders newHeaders = createAuthHeaders(loginId, newPassword); + ResponseEntity finalResponse = restTemplate.exchange( + BASE_URL + "/me", + HttpMethod.GET, + new HttpEntity<>(newHeaders), + UserInfoResponse.class + ); + assertThat(finalResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(finalResponse.getBody().loginId()).isEqualTo(loginId); + } + } + + private UserRegisterRequest createRegisterRequest(String loginId, String password, String name) { + return new UserRegisterRequest( + loginId, + password, + name, + TEST_BIRTHDAY, + "test@example.com" + ); + } + + private HttpHeaders createAuthHeaders(String loginId, String password) { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", loginId); + headers.set("X-Loopers-LoginPw", password); + return headers; + } + + private void registerUser(String loginId, String password, String name) { + var request = createRegisterRequest(loginId, password, name); + restTemplate.postForEntity(BASE_URL + "/register", request, Void.class); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserApiIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserApiIntegrationTest.java new file mode 100644 index 00000000..c14faa6b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserApiIntegrationTest.java @@ -0,0 +1,275 @@ +package com.loopers.interfaces.api; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.interfaces.api.dto.PasswordUpdateRequest; +import com.loopers.interfaces.api.dto.UserRegisterRequest; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDate; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@Import(MySqlTestContainersConfig.class) +class UserApiIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private static final String BASE_URL = "/api/v1/users"; + private static final LocalDate TEST_BIRTHDAY = LocalDate.of(1990, 5, 15); + + @BeforeEach + void setUp() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + @DisplayName("회원가입 API") + class RegisterApi { + + @Test + @DisplayName("회원가입 성공") + void register_success() throws Exception { + var request = new UserRegisterRequest( + "testuser1", + "Password1!", + "홍길동", + TEST_BIRTHDAY, + "test@example.com" + ); + + mockMvc.perform(post(BASE_URL + "/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("중복 ID로 회원가입 시 실패") + void register_fail_duplicateId() throws Exception { + var request = new UserRegisterRequest( + "testuser1", + "Password1!", + "홍길동", + TEST_BIRTHDAY, + "test@example.com" + ); + + // 첫 번째 가입 + mockMvc.perform(post(BASE_URL + "/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + + // 동일 ID로 재가입 시도 + mockMvc.perform(post(BASE_URL + "/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("필수 필드 누락 시 실패") + void register_fail_missingFields() throws Exception { + var request = new UserRegisterRequest( + "", + "Password1!", + "홍길동", + TEST_BIRTHDAY, + "test@example.com" + ); + + mockMvc.perform(post(BASE_URL + "/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("잘못된 이메일 형식으로 가입 시 실패") + void register_fail_invalidEmail() throws Exception { + var request = new UserRegisterRequest( + "testuser1", + "Password1!", + "홍길동", + TEST_BIRTHDAY, + "invalid-email" + ); + + mockMvc.perform(post(BASE_URL + "/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + } + + @Nested + @DisplayName("내 정보 조회 API") + class GetMyInfoApi { + + @Test + @DisplayName("내 정보 조회 성공") + void getMyInfo_success() throws Exception { + String loginId = "testuser1"; + String password = "Password1!"; + registerUser(loginId, password, "홍길동"); + + mockMvc.perform(get(BASE_URL + "/me") + .header("X-Loopers-LoginId", loginId) + .header("X-Loopers-LoginPw", password)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.loginId").value(loginId)) + .andExpect(jsonPath("$.name").value("홍길*")) + .andExpect(jsonPath("$.birthday").value("19900515")) + .andExpect(jsonPath("$.email").value("test@example.com")); + } + + @Test + @DisplayName("잘못된 비밀번호로 조회 시 실패") + void getMyInfo_fail_wrongPassword() throws Exception { + String loginId = "testuser1"; + registerUser(loginId, "Password1!", "홍길동"); + + mockMvc.perform(get(BASE_URL + "/me") + .header("X-Loopers-LoginId", loginId) + .header("X-Loopers-LoginPw", "WrongPassword1!")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("존재하지 않는 사용자 조회 시 실패") + void getMyInfo_fail_userNotFound() throws Exception { + mockMvc.perform(get(BASE_URL + "/me") + .header("X-Loopers-LoginId", "notexist") + .header("X-Loopers-LoginPw", "Password1!")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("2자 이름 마스킹 확인") + void getMyInfo_maskedName_2chars() throws Exception { + String loginId = "testuser1"; + String password = "Password1!"; + registerUser(loginId, password, "홍길"); + + mockMvc.perform(get(BASE_URL + "/me") + .header("X-Loopers-LoginId", loginId) + .header("X-Loopers-LoginPw", password)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("홍*")); + } + } + + @Nested + @DisplayName("비밀번호 변경 API") + class UpdatePasswordApi { + + @Test + @DisplayName("비밀번호 변경 성공") + void updatePassword_success() throws Exception { + String loginId = "testuser1"; + String currentPassword = "Password1!"; + String newPassword = "NewPassword1!"; + registerUser(loginId, currentPassword, "홍길동"); + + var request = new PasswordUpdateRequest(currentPassword, newPassword); + + mockMvc.perform(put(BASE_URL + "/me/password") + .header("X-Loopers-LoginId", loginId) + .header("X-Loopers-LoginPw", currentPassword) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + + // 변경된 비밀번호로 조회 확인 + mockMvc.perform(get(BASE_URL + "/me") + .header("X-Loopers-LoginId", loginId) + .header("X-Loopers-LoginPw", newPassword)) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("현재 비밀번호 불일치 시 실패") + void updatePassword_fail_wrongCurrentPassword() throws Exception { + String loginId = "testuser1"; + registerUser(loginId, "Password1!", "홍길동"); + + var request = new PasswordUpdateRequest("WrongPassword1!", "NewPassword1!"); + + mockMvc.perform(put(BASE_URL + "/me/password") + .header("X-Loopers-LoginId", loginId) + .header("X-Loopers-LoginPw", "Password1!") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("동일한 비밀번호로 변경 시 실패") + void updatePassword_fail_samePassword() throws Exception { + String loginId = "testuser1"; + String password = "Password1!"; + registerUser(loginId, password, "홍길동"); + + var request = new PasswordUpdateRequest(password, password); + + mockMvc.perform(put(BASE_URL + "/me/password") + .header("X-Loopers-LoginId", loginId) + .header("X-Loopers-LoginPw", password) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("인증 실패 시 비밀번호 변경 불가") + void updatePassword_fail_authenticationFailed() throws Exception { + String loginId = "testuser1"; + registerUser(loginId, "Password1!", "홍길동"); + + var request = new PasswordUpdateRequest("Password1!", "NewPassword1!"); + + mockMvc.perform(put(BASE_URL + "/me/password") + .header("X-Loopers-LoginId", loginId) + .header("X-Loopers-LoginPw", "WrongPassword1!") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + } + + private void registerUser(String loginId, String password, String name) throws Exception { + var request = new UserRegisterRequest( + loginId, + password, + name, + TEST_BIRTHDAY, + "test@example.com" + ); + + mockMvc.perform(post(BASE_URL + "/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } +}