From 026d966a703dbc27c1c586bf5850bd32f48e89d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B8=B0=EB=AC=98=ED=95=9C=20=EC=A3=BC=EB=8B=88=EC=96=B4?= =?UTF-8?q?=20=EA=B0=9C=EB=B0=9C=EC=9E=90?= Date: Mon, 16 Feb 2026 00:40:45 +0900 Subject: [PATCH 01/50] =?UTF-8?q?docs=20:=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EC=83=9D=EC=84=B1.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/design/05-component-diagram.md | 405 ++++++++++++++++++++++++++++ 1 file changed, 405 insertions(+) create mode 100644 docs/design/05-component-diagram.md diff --git a/docs/design/05-component-diagram.md b/docs/design/05-component-diagram.md new file mode 100644 index 000000000..f5746fd92 --- /dev/null +++ b/docs/design/05-component-diagram.md @@ -0,0 +1,405 @@ +# 컴포넌트 다이어그램 + +## 개요 + +이 문서는 레이어드 아키텍처의 컴포넌트 구조와 의존성 관계를 PlantUML로 표현한다. 컴포넌트 다이어그램은 **패키지 구조**, **컴포넌트 책임**, **의존성 방향**을 중심으로 작성되며, 클래스 다이어그램(03-class-diagram.md)을 보완한다. + +**핵심 검증 포인트**: +1. 레이어 간 의존성 방향 (Interfaces → Application → Domain ← Infrastructure) +2. 컴포넌트 간 결합도 (느슨한 결합, 인터페이스 기반) +3. 각 컴포넌트의 단일 책임 준수 + +**표기법**: +- `[Component]`: 컴포넌트 (클래스 또는 인터페이스) +- `-->`: 의존성 (사용 관계) +- `..>`: 약한 의존성 (생성, 반환) +- `..|>`: 구현 관계 + +--- + +## 예시: Enrollment 도메인 컴포넌트 다이어그램 + +다음은 Enrollment/Course/Student 도메인의 컴포넌트 다이어그램 예시이다. 이 패턴을 Member 도메인에 적용한다. + +```plantuml +@startuml +skinparam componentStyle rectangle +top to bottom direction + +package "Presentation Layer\n(interfaces)" { + [EnrollmentController] as Controller + [EnrollmentRequest] as Request + [EnrollmentResponse] as Response + note right of Controller + 역할: + - HTTP 요청/응답 + - Request → Facade 파라미터 변환 + - Result VO → Response 변환 + end note +} + +package "Application Layer\n(application)" { + [EnrollmentFacade] as Facade + [EnrollmentResult] as ResultVO + note right of Facade + 역할 (Thin Facade): + - @Transactional 관리 + - Domain → Result VO 변환 + - Domain Service 조율만 + end note +} + +package "Domain Layer\n(domain)" { + [EnrollmentService] as DomainService + [Enrollment] as EnrollmentEntity + [Course] as CourseEntity + [Student] as StudentEntity + interface "EnrollmentRepository" as EnrollmentRepo + interface "CourseRepository" as CourseRepo + interface "StudentRepository" as StudentRepo + + note right of DomainService + 비즈니스 로직: + - validateCapacity() + - validateCreditLimit() + - validateScheduleConflict() + - enroll() → Enrollment 반환 + end note + + note right of CourseEntity + 도메인 메서드: + - isFull() + - incrementEnrolledCount() + end note +} + +package "Infrastructure Layer\n(infrastructure)" { + [EnrollmentRepositoryImpl] as EnrollmentRepoImpl + [EnrollmentJpaRepository] as EnrollmentJpa + [CourseRepositoryImpl] as CourseRepoImpl + [StudentRepositoryImpl] as StudentRepoImpl +} + +' --- Requests flow (올바른 의존성 방향) --- +Controller --> Facade : 호출 +Controller --> Request : 사용 +Controller ..> Response : 생성 (from Result) + +' --- Facade to Domain Service --- +Facade --> DomainService : 호출 +Facade ..> ResultVO : 생성 (from Entity) + +' --- Domain Service to Repository --- +DomainService --> EnrollmentRepo : 의존 +DomainService --> CourseRepo : 의존 +DomainService --> StudentRepo : 의존 +DomainService --> EnrollmentEntity : 생성 +DomainService --> CourseEntity : 사용 + +' --- Domain relationships --- +EnrollmentEntity --> StudentEntity : 연관 +EnrollmentEntity --> CourseEntity : 연관 + +' --- Infrastructure implements Domain --- +EnrollmentRepoImpl ..|> EnrollmentRepo : 구현 +CourseRepoImpl ..|> CourseRepo : 구현 +StudentRepoImpl ..|> StudentRepo : 구현 + +EnrollmentRepoImpl --> EnrollmentJpa : 위임 + +@enduml +``` + +--- + +## Member 도메인 컴포넌트 다이어그램 + +### 검증 목적 +Member 도메인의 레이어드 아키텍처 구조를 확인한다. Controller → Facade → Service → Repository 의존성 흐름이 명확히 드러나야 하며, Facade가 Service만 호출하고 Reader를 직접 호출하지 않는지 검증한다. + +### 다이어그램 + +```plantuml +@startuml +skinparam componentStyle rectangle +skinparam linetype ortho +skinparam backgroundColor #FEFEFE + +package "Presentation Layer\n(interfaces.api.member)" #E3F2FD { + [MemberV1Controller] as Controller + note right of Controller + **역할:** + - HTTP request/response 처리 + - 인증 헤더 추출 + - DTO ↔ Info 변환 + + **엔드포인트:** + - POST /api/v1/members/register + - GET /api/v1/members/me + - PATCH /api/v1/members/me/password + end note + + package "DTOs (record)" { + [RegisterRequest] as RegReq + [MemberResponse] as MemResp + [MeResponse] as MeResp + [ChangePasswordRequest] as ChgPwdReq + } +} + +package "Application Layer\n(application.member)" #FFF3E0 { + [MemberFacade] as Facade + note right of Facade + **역할 (Thin Facade):** + - Service 조합 + - Model → Info 변환 + - 유스케이스 경계 + + **패턴:** + - Service만 호출 (Reader 직접 호출 금지) + - Info 반환 (Model 노출 금지) + end note + + [MemberInfo] as Info + note right of Info + **타입:** record (불변) + + **필드:** + - id, memberId, email + - birthDate, name, gender + + **팩토리:** + - from(MemberModel) + end note +} + +package "Domain Layer\n(domain.member)" #E8F5E9 { + [MemberService] as Service + note right of Service + **책임:** + - 비즈니스 로직 + - 교차 엔티티 규칙 + - @Transactional 경계 + + **메서드:** + - register(...) + - authenticate(...) + - changePassword(...) + end note + + [MemberModel] as Model + note right of Model + **타입:** JPA Entity + + **도메인 행위:** + - verifyPassword() + - changePassword() + + **팩토리:** + - create(...) + end note + + [MemberReader] as Reader + note right of Reader + **책임:** + - 읽기 전용 조회 + - getOrThrow 패턴 + - 존재 확인 (exists) + end note + + interface "MemberRepository" as Repo + note right of Repo + **Port (인터페이스)** + 영속화 추상화 + end note +} + +package "Infrastructure Layer\n(infrastructure.member)" #F3E5F5 { + [MemberRepositoryImpl] as RepoImpl + note right of RepoImpl + **Adapter** + Domain Repository 구현 + end note + + [MemberJpaRepository] as JpaRepo + note right of JpaRepo + **Spring Data JPA** + extends JpaRepository + end note +} + +' === 의존성 흐름 (레이어 간) === +Controller --> Facade : 호출 +Controller ..> RegReq : 사용 +Controller ..> MemResp : 반환 +Controller ..> MeResp : 반환 +Controller ..> ChgPwdReq : 사용 + +Facade --> Service : 조율 +Facade ..> Info : 반환 + +Service --> Reader : 조회 +Service --> Repo : 영속화 +Service ..> Model : 조작 + +Reader --> Repo : 조회 +Reader ..> Model : 로드 + +Repo <|.. RepoImpl : 구현 +RepoImpl --> JpaRepo : 위임 +RepoImpl ..> Model : 로드/저장 + +@enduml +``` + +### 해석 + +**레이어 흐름**: +- **Controller**: HTTP 요청 수신 → Facade 호출 → Info 수신 → DTO 변환하여 응답 +- **Facade**: Service 호출 → Model 수신 → Info 변환하여 반환 +- **Service**: 비즈니스 로직 실행 → Reader/Repository 사용 → Model 반환 +- **Reader**: Repository 사용 → 조회 전용 → Model 반환 +- **Repository**: 영속화 추상화 (Port) +- **RepositoryImpl**: Repository 구현 (Adapter) → JpaRepository 위임 + +**핵심 패턴**: +1. **Facade는 Service만 호출**: Reader를 직접 호출하지 않음 (Service가 Reader 소유) +2. **Info 변환**: Facade에서 Model → Info 변환 (레이어 격리) +3. **Port-Adapter**: Domain의 Repository(interface)를 Infrastructure의 RepositoryImpl이 구현 +4. **DTO vs Info**: DTO는 HTTP 계층, Info는 Application 계층 (서로 다른 관심사) + +--- + +## 설계 원칙 + +### 1. Facade Pattern (Application Layer) + +**Thin Facade 원칙**: +- Facade는 Service만 호출, Reader 직접 호출 금지 +- 여러 도메인 서비스 조합은 Facade 책임 +- 비즈니스 로직은 Service에 위임 (Facade는 조율만) + +**Info 변환**: +- Facade가 Model → Info 변환 담당 (레이어 격리) +- Controller는 Info를 알지만 Model은 모름 +- Domain Model이 Presentation Layer에 노출되지 않음 + +**트랜잭션 경계**: +- Facade 메서드가 @Transactional 경계 +- 유스케이스 단위로 트랜잭션 관리 + +--- + +### 2. DTO vs Info vs Model + +| 타입 | 레이어 | 목적 | 특징 | +|-----|--------|------|------| +| **DTO** | Presentation | HTTP 요청/응답 | Jakarta Validation, record 타입 | +| **Info** | Application | 유스케이스 결과 | record 타입, 불변, from(Model) 팩토리 | +| **Model** | Domain | 도메인 엔티티 | JPA Entity, 도메인 행위 메서드 | + +**분리 이유**: +- **DTO**: HTTP 프로토콜 의존 (헤더, 쿼리 파라미터 등) +- **Info**: 애플리케이션 유스케이스 결과 (도메인 독립적) +- **Model**: 비즈니스 규칙과 영속화 (인프라 독립적) + +**변환 흐름**: +``` +HTTP Request → DTO → Facade(파라미터) → Service +Service → Model → Facade → Info → Controller → DTO → HTTP Response +``` + +--- + +### 3. Repository Pattern (Port-Adapter) + +**Port (인터페이스)**: +- Domain 레이어에 정의 (MemberRepository) +- 영속화 추상화 (기술 독립적) +- Domain이 Infrastructure를 의존하지 않음 + +**Adapter (구현체)**: +- Infrastructure 레이어에 구현 (MemberRepositoryImpl) +- JpaRepository에 위임 +- JPA 기술 세부사항 캡슐화 + +**의존성 역전**: +``` +Domain (Repository interface) <--- Infrastructure (RepositoryImpl) +``` +- Domain이 Infrastructure를 모름 +- Infrastructure가 Domain 인터페이스를 구현 + +--- + +### 4. Reader vs Service 분리 + +**Reader 책임**: +- 읽기 전용 조회 +- getOrThrow 패턴 (조회 + 예외 통합) +- exists 체크 + +**Service 책임**: +- CUD (Create, Update, Delete) +- 비즈니스 규칙 (중복 체크, 검증) +- @Transactional 관리 + +**관계**: +- Service가 Reader를 의존 (Service가 Reader 소유) +- Facade는 Service만 호출 (Reader 직접 호출 금지) + +--- + +## 레이어별 책임 정리 + +| 레이어 | 패키지 | 컴포넌트 | 책임 | +|--------|--------|----------|------| +| **Presentation** | interfaces.api.member | MemberV1Controller | HTTP 엔드포인트, 인증 헤더 추출, DTO ↔ Info 변환 | +| | | DTOs (record) | 요청/응답 데이터, Jakarta Validation | +| **Application** | application.member | MemberFacade | 유스케이스 조합, Service 호출, Model → Info 변환 | +| | | MemberInfo (record) | 애플리케이션 결과 VO, 불변 | +| **Domain** | domain.member | MemberService | 비즈니스 로직, 트랜잭션 경계, 교차 엔티티 규칙 | +| | | MemberReader | 읽기 전용 조회, getOrThrow 패턴 | +| | | MemberModel | JPA Entity, 도메인 행위 메서드 (verifyPassword, changePassword) | +| | | MemberRepository (interface) | 영속화 추상화 (Port) | +| **Infrastructure** | infrastructure.member | MemberRepositoryImpl | Repository 구현 (Adapter) | +| | | MemberJpaRepository | Spring Data JPA, extends JpaRepository | + +--- + +## 기존 다이어그램과의 관계 + +### 02-sequence-diagrams.md (시퀀스 다이어그램) +- **시퀀스**: 동적 흐름 (시간 순서, 메서드 호출 순서) +- **컴포넌트**: 정적 구조 (의존성 관계, 패키지 구조) +- **보완 관계**: 시퀀스는 "어떻게 동작하는가", 컴포넌트는 "어떻게 구성되는가" + +### 03-class-diagram.md (클래스 다이어그램) +- **클래스**: 클래스 상세 (필드, 메서드, 타입) +- **컴포넌트**: 컴포넌트 책임 (레이어, 의존성, 역할) +- **보완 관계**: 클래스는 "무엇을 가지는가", 컴포넌트는 "왜 분리되는가" + +### 04-erd.md (ERD) +- **ERD**: 데이터베이스 테이블 구조 (컬럼, FK, 인덱스) +- **컴포넌트**: 애플리케이션 레이어 구조 (컴포넌트, 의존성) +- **연결 고리**: Infrastructure 레이어의 JpaRepository가 ERD 테이블과 매핑 + +--- + +## 검증 체크리스트 + +- [x] 레이어 간 의존성 방향: Interfaces → Application → Domain ← Infrastructure +- [x] Facade는 Service만 호출 (Reader 직접 호출 금지) +- [x] Info는 Application 레이어 (Model은 Domain 레이어) +- [x] Repository는 Domain에 정의 (Port), Infrastructure에 구현 (Adapter) +- [x] Controller는 Model을 모름 (Info만 알음) +- [x] 모든 컴포넌트가 단일 책임 원칙 준수 + +--- + +## 다음 단계 + +이 컴포넌트 다이어그램을 기반으로: +- **구현**: MemberFacade, MemberInfo 구현 (현재 Facade는 비어있음) +- **테스트**: Facade 단위 테스트 (Service 모킹) +- **확장**: 다른 도메인에도 동일한 패턴 적용 (Product, Order 등) From b23196c19ff9977fc59f4db11749b42023d2fcf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B8=B0=EB=AC=98=ED=95=9C=20=EC=A3=BC=EB=8B=88=EC=96=B4?= =?UTF-8?q?=20=EA=B0=9C=EB=B0=9C=EC=9E=90?= Date: Mon, 16 Feb 2026 00:52:15 +0900 Subject: [PATCH 02/50] =?UTF-8?q?refactor(member)=20:=20=EC=9D=B8=ED=84=B0?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EC=98=81=EC=97=AD=EC=9D=B4=20?= =?UTF-8?q?=EC=96=B4=ED=94=8C=EB=A6=AC=EC=BC=80=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EC=98=81=EC=97=AD=EC=9D=84=20=EA=B1=B0=EC=B9=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EA=B3=A0=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=98=81?= =?UTF-8?q?=EC=97=AD=EC=9D=84=20=EC=B9=A8=EB=B2=94=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EA=B2=83=20=EC=88=98=EC=A0=95.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/member/MemberFacade.java | 32 +++++++++++++++++++ .../application/member/MemberInfo.java | 25 +++++++++++++++ .../api/member/MemberV1Controller.java | 16 +++++----- .../interfaces/api/member/MemberV1Dto.java | 21 ++++++++++++ 4 files changed, 86 insertions(+), 8 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java new file mode 100644 index 000000000..c957f5ce1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java @@ -0,0 +1,32 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.Gender; +import com.loopers.domain.member.MemberModel; +import com.loopers.domain.member.MemberService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + + +@RequiredArgsConstructor +@Component +public class MemberFacade { + private final MemberService memberService; + + public MemberInfo register(String memberId, String password, String email, + String birthDate, String name, Gender gender) { + MemberModel member = memberService.register( + memberId, password, email, birthDate, name, gender + ); + return MemberInfo.from(member); + } + + public MemberInfo authenticate(String loginId, String loginPw) { + MemberModel member = memberService.authenticate(loginId, loginPw); + return MemberInfo.from(member); + } + + public void changePassword(String loginId, String loginPw, + String currentPassword, String newPassword) { + memberService.changePassword(loginId, loginPw, currentPassword, newPassword); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java new file mode 100644 index 000000000..5781fddc3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java @@ -0,0 +1,25 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.MemberModel; + + +public record MemberInfo( + Long id, + String memberId, + String email, + String birthDate, + String name, + String gender +) { + + public static MemberInfo from(MemberModel member) { + return new MemberInfo( + member.getId(), + member.getMemberId().value(), + member.getEmail().address(), + member.getBirthDate().asString(), + member.getName().value(), + member.getGender().name() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java index e6bbd03b3..4ca38669d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java @@ -1,7 +1,7 @@ package com.loopers.interfaces.api.member; -import com.loopers.domain.member.MemberModel; -import com.loopers.domain.member.MemberService; +import com.loopers.application.member.MemberFacade; +import com.loopers.application.member.MemberInfo; import com.loopers.interfaces.api.ApiResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -18,12 +18,12 @@ @RequestMapping("/api/v1/members") public class MemberV1Controller implements MemberV1ApiSpec { - private final MemberService memberService; + private final MemberFacade memberFacade; @PostMapping("/register") @Override public ApiResponse register(@Valid @RequestBody MemberV1Dto.RegisterRequest request) { - MemberModel member = memberService.register( + MemberInfo info = memberFacade.register( request.memberId(), request.password(), request.email(), @@ -32,7 +32,7 @@ public ApiResponse register(@Valid @RequestBody Memb request.gender() ); - MemberV1Dto.MemberResponse response = MemberV1Dto.MemberResponse.from(member); + MemberV1Dto.MemberResponse response = MemberV1Dto.MemberResponse.fromInfo(info); return ApiResponse.success(response); } @@ -42,9 +42,9 @@ public ApiResponse getMe( @RequestHeader("X-Loopers-LoginId") String loginId, @RequestHeader("X-Loopers-LoginPw") String loginPw ) { - MemberModel member = memberService.authenticate(loginId, loginPw); + MemberInfo info = memberFacade.authenticate(loginId, loginPw); - MemberV1Dto.MeResponse response = MemberV1Dto.MeResponse.from(member); + MemberV1Dto.MeResponse response = MemberV1Dto.MeResponse.fromInfo(info); return ApiResponse.success(response); } @@ -55,7 +55,7 @@ public ApiResponse changePassword( @RequestHeader("X-Loopers-LoginPw") String loginPw, @Valid @RequestBody MemberV1Dto.ChangePasswordRequest request ) { - memberService.changePassword(loginId, loginPw, + memberFacade.changePassword(loginId, loginPw, request.currentPassword(), request.newPassword()); return ApiResponse.success(null); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java index 4a1bc0618..086b70d64 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java @@ -1,5 +1,6 @@ package com.loopers.interfaces.api.member; +import com.loopers.application.member.MemberInfo; import com.loopers.domain.member.Gender; import com.loopers.domain.member.MemberModel; import jakarta.validation.constraints.NotBlank; @@ -34,6 +35,17 @@ public static MemberResponse from(MemberModel member) { member.getGender() ); } + + public static MemberResponse fromInfo(MemberInfo info) { + return new MemberResponse( + info.id(), + info.memberId(), + info.email(), + info.birthDate(), + info.name(), + Gender.valueOf(info.gender()) + ); + } } public record ChangePasswordRequest( @@ -56,6 +68,15 @@ public static MeResponse from(MemberModel member) { ); } + public static MeResponse fromInfo(MemberInfo info) { + return new MeResponse( + info.memberId(), + maskName(info.name()), + info.birthDate(), + info.email() + ); + } + private static String maskName(String name) { if (name == null || name.isEmpty()) { return name; From 7838d7a525035bbf0829511a66b3e1a0686cee20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B8=B0=EB=AC=98=ED=95=9C=20=EC=A3=BC=EB=8B=88=EC=96=B4?= =?UTF-8?q?=20=EA=B0=9C=EB=B0=9C=EC=9E=90?= Date: Mon, 16 Feb 2026 01:50:36 +0900 Subject: [PATCH 03/50] =?UTF-8?q?refactor(security)=20:=20=EC=8B=9C?= =?UTF-8?q?=ED=81=90=EB=A6=AC=ED=8B=B0=20=EC=9D=98=EC=A1=B4=EC=84=B1=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/commerce-api/build.gradle.kts | 2 +- .../java/com/loopers/domain/member/MemberModel.java | 2 +- .../java/com/loopers/domain/member/MemberService.java | 1 + modules/security/build.gradle.kts | 8 ++++++++ .../main/java/com/loopers}/config/SecurityConfig.java | 7 ++++--- .../com/loopers}/security/BCryptPasswordHasher.java | 11 +++++------ .../java/com/loopers/security}/PasswordHasher.java | 2 +- settings.gradle.kts | 1 + 8 files changed, 22 insertions(+), 12 deletions(-) create mode 100644 modules/security/build.gradle.kts rename {apps/commerce-api/src/main/java/com/loopers/infrastructure => modules/security/src/main/java/com/loopers}/config/SecurityConfig.java (63%) rename {apps/commerce-api/src/main/java/com/loopers/infrastructure => modules/security/src/main/java/com/loopers}/security/BCryptPasswordHasher.java (55%) rename {apps/commerce-api/src/main/java/com/loopers/domain/member => modules/security/src/main/java/com/loopers/security}/PasswordHasher.java (79%) diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 1a98c156e..4bb64d4da 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -2,6 +2,7 @@ dependencies { // add-ons implementation(project(":modules:jpa")) implementation(project(":modules:redis")) + implementation(project(":modules:security")) implementation(project(":supports:jackson")) implementation(project(":supports:logging")) implementation(project(":supports:monitoring")) @@ -10,7 +11,6 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}") - implementation("org.springframework.security:spring-security-crypto") // querydsl annotationProcessor("com.querydsl:querydsl-apt::jakarta") diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java index 5541fa979..5b99571e4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java @@ -1,11 +1,11 @@ package com.loopers.domain.member; - import com.loopers.domain.BaseEntity; import com.loopers.infrastructure.jpa.converter.BirthDateConverter; import com.loopers.infrastructure.jpa.converter.EmailConverter; import com.loopers.infrastructure.jpa.converter.MemberIdConverter; import com.loopers.infrastructure.jpa.converter.NameConverter; +import com.loopers.security.PasswordHasher; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import jakarta.persistence.*; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java index 7ec98d1ff..b2b1c3ac4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java @@ -1,5 +1,6 @@ package com.loopers.domain.member; +import com.loopers.security.PasswordHasher; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; diff --git a/modules/security/build.gradle.kts b/modules/security/build.gradle.kts new file mode 100644 index 000000000..ac6d65309 --- /dev/null +++ b/modules/security/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + `java-library` +} + +dependencies { + // Spring Security for BCrypt + api("org.springframework.security:spring-security-crypto") +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/config/SecurityConfig.java b/modules/security/src/main/java/com/loopers/config/SecurityConfig.java similarity index 63% rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/config/SecurityConfig.java rename to modules/security/src/main/java/com/loopers/config/SecurityConfig.java index 40f0996c7..04df98327 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/config/SecurityConfig.java +++ b/modules/security/src/main/java/com/loopers/config/SecurityConfig.java @@ -1,12 +1,13 @@ -package com.loopers.infrastructure.config; +package com.loopers.config; -import com.loopers.domain.member.PasswordHasher; -import com.loopers.infrastructure.security.BCryptPasswordHasher; +import com.loopers.security.BCryptPasswordHasher; +import com.loopers.security.PasswordHasher; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class SecurityConfig { + @Bean public PasswordHasher passwordHasher() { return new BCryptPasswordHasher(); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/security/BCryptPasswordHasher.java b/modules/security/src/main/java/com/loopers/security/BCryptPasswordHasher.java similarity index 55% rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/security/BCryptPasswordHasher.java rename to modules/security/src/main/java/com/loopers/security/BCryptPasswordHasher.java index 267037546..c51fbd1a8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/security/BCryptPasswordHasher.java +++ b/modules/security/src/main/java/com/loopers/security/BCryptPasswordHasher.java @@ -1,6 +1,5 @@ -package com.loopers.infrastructure.security; +package com.loopers.security; -import com.loopers.domain.member.PasswordHasher; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; @@ -9,12 +8,12 @@ public class BCryptPasswordHasher implements PasswordHasher { private static final PasswordEncoder encoder = new BCryptPasswordEncoder(); @Override - public String hash(String raw) { - return encoder.encode(raw); + public String hash(String rawPassword) { + return encoder.encode(rawPassword); } @Override - public boolean matches(String raw, String hashed) { - return encoder.matches(raw, hashed); + public boolean matches(String rawPassword, String hashedPassword) { + return encoder.matches(rawPassword, hashedPassword); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/PasswordHasher.java b/modules/security/src/main/java/com/loopers/security/PasswordHasher.java similarity index 79% rename from apps/commerce-api/src/main/java/com/loopers/domain/member/PasswordHasher.java rename to modules/security/src/main/java/com/loopers/security/PasswordHasher.java index 860d0c079..265a680bd 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/PasswordHasher.java +++ b/modules/security/src/main/java/com/loopers/security/PasswordHasher.java @@ -1,4 +1,4 @@ -package com.loopers.domain.member; +package com.loopers.security; public interface PasswordHasher { String hash(String rawPassword); diff --git a/settings.gradle.kts b/settings.gradle.kts index a2c303835..ff027592f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -7,6 +7,7 @@ include( ":modules:jpa", ":modules:redis", ":modules:kafka", + ":modules:security", ":supports:jackson", ":supports:logging", ":supports:monitoring", From b8cae512afa2982b66bddc1177447c0f97304811 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B8=B0=EB=AC=98=ED=95=9C=20=EC=A3=BC=EB=8B=88=EC=96=B4?= =?UTF-8?q?=20=EA=B0=9C=EB=B0=9C=EC=9E=90?= Date: Mon, 16 Feb 2026 01:53:53 +0900 Subject: [PATCH 04/50] =?UTF-8?q?refactor(member)=20:=20member=20vo=20?= =?UTF-8?q?=ED=8C=A8=ED=82=A4=EC=A7=80=20=EC=9D=B4=EB=8F=99.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/loopers/domain/member/MemberModel.java | 4 ++++ .../src/main/java/com/loopers/domain/member/MemberReader.java | 1 + .../main/java/com/loopers/domain/member/MemberRepository.java | 2 ++ .../java/com/loopers/domain/member/{ => vo}/BirthDate.java | 2 +- .../main/java/com/loopers/domain/member/{ => vo}/Email.java | 2 +- .../java/com/loopers/domain/member/{ => vo}/MemberId.java | 2 +- .../main/java/com/loopers/domain/member/{ => vo}/Name.java | 2 +- .../infrastructure/jpa/converter/BirthDateConverter.java | 2 +- .../loopers/infrastructure/jpa/converter/EmailConverter.java | 2 +- .../infrastructure/jpa/converter/MemberIdConverter.java | 2 +- .../loopers/infrastructure/jpa/converter/NameConverter.java | 2 +- .../loopers/infrastructure/member/MemberJpaRepository.java | 2 +- .../loopers/infrastructure/member/MemberRepositoryImpl.java | 2 +- .../loopers/domain/member/MemberServiceIntegrationTest.java | 1 + 14 files changed, 18 insertions(+), 10 deletions(-) rename apps/commerce-api/src/main/java/com/loopers/domain/member/{ => vo}/BirthDate.java (97%) rename apps/commerce-api/src/main/java/com/loopers/domain/member/{ => vo}/Email.java (96%) rename apps/commerce-api/src/main/java/com/loopers/domain/member/{ => vo}/MemberId.java (94%) rename apps/commerce-api/src/main/java/com/loopers/domain/member/{ => vo}/Name.java (95%) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java index 5b99571e4..5d40dee7e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java @@ -1,6 +1,10 @@ package com.loopers.domain.member; import com.loopers.domain.BaseEntity; +import com.loopers.domain.member.vo.BirthDate; +import com.loopers.domain.member.vo.Email; +import com.loopers.domain.member.vo.MemberId; +import com.loopers.domain.member.vo.Name; import com.loopers.infrastructure.jpa.converter.BirthDateConverter; import com.loopers.infrastructure.jpa.converter.EmailConverter; import com.loopers.infrastructure.jpa.converter.MemberIdConverter; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberReader.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberReader.java index ea559ba3d..6755af5f6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberReader.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberReader.java @@ -1,5 +1,6 @@ package com.loopers.domain.member; +import com.loopers.domain.member.vo.MemberId; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java index fce19e93c..9ae2f2a48 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java @@ -1,5 +1,7 @@ package com.loopers.domain.member; +import com.loopers.domain.member.vo.MemberId; + import java.util.Optional; public interface MemberRepository { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/BirthDate.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/BirthDate.java similarity index 97% rename from apps/commerce-api/src/main/java/com/loopers/domain/member/BirthDate.java rename to apps/commerce-api/src/main/java/com/loopers/domain/member/vo/BirthDate.java index 7fefe5c60..747eed6af 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/BirthDate.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/BirthDate.java @@ -1,4 +1,4 @@ -package com.loopers.domain.member; +package com.loopers.domain.member.vo; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/Email.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Email.java similarity index 96% rename from apps/commerce-api/src/main/java/com/loopers/domain/member/Email.java rename to apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Email.java index 9c9711993..558836e5d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/Email.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Email.java @@ -1,4 +1,4 @@ -package com.loopers.domain.member; +package com.loopers.domain.member.vo; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberId.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/MemberId.java similarity index 94% rename from apps/commerce-api/src/main/java/com/loopers/domain/member/MemberId.java rename to apps/commerce-api/src/main/java/com/loopers/domain/member/vo/MemberId.java index d9c99ef97..516b9f799 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberId.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/MemberId.java @@ -1,4 +1,4 @@ -package com.loopers.domain.member; +package com.loopers.domain.member.vo; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/Name.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Name.java similarity index 95% rename from apps/commerce-api/src/main/java/com/loopers/domain/member/Name.java rename to apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Name.java index 8a956383c..aa8bc23a8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/Name.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Name.java @@ -1,4 +1,4 @@ -package com.loopers.domain.member; +package com.loopers.domain.member.vo; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/BirthDateConverter.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/BirthDateConverter.java index 0c51a2939..c4f59b05c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/BirthDateConverter.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/BirthDateConverter.java @@ -1,6 +1,6 @@ package com.loopers.infrastructure.jpa.converter; -import com.loopers.domain.member.BirthDate; +import com.loopers.domain.member.vo.BirthDate; import jakarta.persistence.AttributeConverter; import jakarta.persistence.Converter; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/EmailConverter.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/EmailConverter.java index 9162511fe..a1cb198ad 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/EmailConverter.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/EmailConverter.java @@ -1,6 +1,6 @@ package com.loopers.infrastructure.jpa.converter; -import com.loopers.domain.member.Email; +import com.loopers.domain.member.vo.Email; import jakarta.persistence.AttributeConverter; import jakarta.persistence.Converter; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/MemberIdConverter.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/MemberIdConverter.java index 264d13a0b..cee28ac78 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/MemberIdConverter.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/MemberIdConverter.java @@ -1,6 +1,6 @@ package com.loopers.infrastructure.jpa.converter; -import com.loopers.domain.member.MemberId; +import com.loopers.domain.member.vo.MemberId; import jakarta.persistence.AttributeConverter; import jakarta.persistence.Converter; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/NameConverter.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/NameConverter.java index d3769254a..d254055a3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/NameConverter.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/NameConverter.java @@ -1,6 +1,6 @@ package com.loopers.infrastructure.jpa.converter; -import com.loopers.domain.member.Name; +import com.loopers.domain.member.vo.Name; import jakarta.persistence.AttributeConverter; import jakarta.persistence.Converter; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java index 6fa82bd28..810324a84 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java @@ -1,6 +1,6 @@ package com.loopers.infrastructure.member; -import com.loopers.domain.member.MemberId; +import com.loopers.domain.member.vo.MemberId; import com.loopers.domain.member.MemberModel; import org.springframework.data.jpa.repository.JpaRepository; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java index c9a1594a9..4a87e4d78 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java @@ -1,6 +1,6 @@ package com.loopers.infrastructure.member; -import com.loopers.domain.member.MemberId; +import com.loopers.domain.member.vo.MemberId; import com.loopers.domain.member.MemberModel; import com.loopers.domain.member.MemberRepository; import lombok.RequiredArgsConstructor; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java index 5c9ca525d..98c23808e 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java @@ -1,5 +1,6 @@ package com.loopers.domain.member; +import com.loopers.domain.member.vo.MemberId; import com.loopers.infrastructure.member.MemberJpaRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; From 4e20ddb6e36bad4c5f2bed57ad6d1689d0d0cefa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B8=B0=EB=AC=98=ED=95=9C=20=EC=A3=BC=EB=8B=88=EC=96=B4?= =?UTF-8?q?=20=EA=B0=9C=EB=B0=9C=EC=9E=90?= Date: Mon, 16 Feb 2026 12:01:50 +0900 Subject: [PATCH 05/50] =?UTF-8?q?test(architecture)=20:=20archunit=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=95=84?= =?UTF-8?q?=ED=82=A4=ED=85=8D=EC=B3=90=20=EB=B0=A9=ED=96=A5=20=EA=B2=80?= =?UTF-8?q?=EC=82=AC=20=EC=B6=94=EA=B0=80.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../architecture/ApplicationLayerTest.java | 33 ++++++ .../loopers/architecture/DomainLayerTest.java | 58 ++++++++++ .../architecture/InfrastructureLayerTest.java | 57 ++++++++++ .../architecture/InterfacesLayerTest.java | 69 ++++++++++++ .../architecture/LayeredArchitectureTest.java | 105 ++++++++++++++++++ .../architecture/NamingConventionTest.java | 34 ++++++ .../member/MemberServiceIntegrationTest.java | 1 + build.gradle.kts | 1 + gradle.properties | 1 + 9 files changed, 359 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/architecture/ApplicationLayerTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/architecture/DomainLayerTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/architecture/InfrastructureLayerTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/architecture/InterfacesLayerTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/architecture/LayeredArchitectureTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/architecture/NamingConventionTest.java diff --git a/apps/commerce-api/src/test/java/com/loopers/architecture/ApplicationLayerTest.java b/apps/commerce-api/src/test/java/com/loopers/architecture/ApplicationLayerTest.java new file mode 100644 index 000000000..7507b67a7 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/architecture/ApplicationLayerTest.java @@ -0,0 +1,33 @@ +package com.loopers.architecture; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.lang.ArchRule; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; + +@DisplayName("Application Layer 규칙") +class ApplicationLayerTest { + + private static JavaClasses classes; + + @BeforeAll + static void setUp() { + classes = new ClassFileImporter() + .importPackages("com.loopers"); + } + + @Test + @DisplayName("Facade는 application 패키지에 위치해야 함") + void facades_must_reside_in_application() { + ArchRule rule = classes() + .that().haveSimpleNameEndingWith("Facade") + .should().resideInAPackage("..application..") + .because("Facade는 유스케이스를 조합하는 Application Layer에 위치해야 합니다"); + + rule.check(classes); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/architecture/DomainLayerTest.java b/apps/commerce-api/src/test/java/com/loopers/architecture/DomainLayerTest.java new file mode 100644 index 000000000..8aedda604 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/architecture/DomainLayerTest.java @@ -0,0 +1,58 @@ +package com.loopers.architecture; + +import com.loopers.domain.BaseEntity; +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.lang.ArchRule; +import jakarta.persistence.Entity; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; + +@DisplayName("Domain Layer 규칙") +class DomainLayerTest { + + private static JavaClasses classes; + + @BeforeAll + static void setUp() { + classes = new ClassFileImporter() + .importPackages("com.loopers"); + } + + @Test + @DisplayName("JPA Entity는 BaseEntity를 상속해야 함") + void entities_must_extend_base_entity() { + ArchRule rule = classes() + .that().areAnnotatedWith(Entity.class) + .should().beAssignableTo(BaseEntity.class) + .because("모든 Entity는 공통 필드(id, createdAt, updatedAt)를 위해 BaseEntity를 상속해야 합니다"); + + rule.check(classes); + } + + @Test + @DisplayName("Value Object는 record 타입이어야 함") + void value_objects_must_be_records() { + ArchRule rule = classes() + .that().resideInAPackage("..vo..") + .and().areNotNestedClasses() + .should().beRecords() + .because("Value Object는 불변성을 보장하기 위해 record 타입을 사용해야 합니다"); + + rule.check(classes); + } + + @Test + @DisplayName("Service는 domain 패키지에 위치해야 함") + void service_must_reside_in_application() { + ArchRule rule = classes() + .that().haveSimpleNameEndingWith("Service") + .should().resideInAPackage("..domain..") + .because("Service 클래스는 도메인 로직을 포함하므로 domain 패키지에 위치해야 합니다"); + + rule.check(classes); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/architecture/InfrastructureLayerTest.java b/apps/commerce-api/src/test/java/com/loopers/architecture/InfrastructureLayerTest.java new file mode 100644 index 000000000..b96b280c6 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/architecture/InfrastructureLayerTest.java @@ -0,0 +1,57 @@ +package com.loopers.architecture; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.lang.ArchRule; +import jakarta.persistence.AttributeConverter; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.data.jpa.repository.JpaRepository; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; + +@DisplayName("Infrastructure Layer 규칙") +class InfrastructureLayerTest { + + private static JavaClasses classes; + + @BeforeAll + static void setUp() { + classes = new ClassFileImporter() + .importPackages("com.loopers"); + } + + @Test + @DisplayName("Repository 구현체는 infrastructure 패키지에 위치해야 함") + void repository_implementations_must_reside_in_infrastructure() { + ArchRule rule = classes() + .that().haveSimpleNameEndingWith("RepositoryImpl") + .should().resideInAPackage("..infrastructure..") + .because("Repository 구현체는 기술적 세부사항으로 infrastructure 패키지에 위치해야 합니다"); + + rule.check(classes); + } + + @Test + @DisplayName("JpaRepository는 infrastructure.jpa 패키지에 위치해야 함") + void jpa_repositories_must_reside_in_infrastructure_jpa() { + ArchRule rule = classes() + .that().areAssignableTo(JpaRepository.class) + .should().resideInAPackage("..infrastructure..") + .because("Spring Data JPA Repository는 infrastructure.jpa 패키지에 위치해야 합니다"); + + rule.check(classes); + } + + @Test + @DisplayName("Converter는 'Converter'로 끝나야 함") + void converters_must_end_with_converter() { + ArchRule rule = classes() + .that().implement(AttributeConverter.class) + .should().haveSimpleNameEndingWith("Converter") + .because("JPA Converter는 'Converter' 접미사를 사용해야 합니다"); + + rule.check(classes); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/architecture/InterfacesLayerTest.java b/apps/commerce-api/src/test/java/com/loopers/architecture/InterfacesLayerTest.java new file mode 100644 index 000000000..af294575a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/architecture/InterfacesLayerTest.java @@ -0,0 +1,69 @@ +package com.loopers.architecture; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.lang.ArchRule; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.web.bind.annotation.RestController; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; + +@DisplayName("Interfaces Layer 규칙") +class InterfacesLayerTest { + + private static JavaClasses classes; + + @BeforeAll + static void setUp() { + classes = new ClassFileImporter() + .importPackages("com.loopers"); + } + + @Test + @DisplayName("Controller는 interfaces.api 패키지에 위치해야 함") + void controllers_must_reside_in_interfaces_api() { + ArchRule rule = classes() + .that().haveSimpleNameEndingWith("Controller") + .should().resideInAPackage("..interfaces.api..") + .because("Controller는 외부 통신을 담당하는 Interfaces Layer에 위치해야 합니다"); + + rule.check(classes); + } + + @Test + @DisplayName("Controller는 @RestController 어노테이션을 가져야 함") + void controllers_must_be_annotated_with_rest_controller() { + ArchRule rule = classes() + .that().haveSimpleNameEndingWith("Controller") + .and().resideInAPackage("..interfaces.api..") + .should().beAnnotatedWith(RestController.class) + .because("REST API Controller는 @RestController 어노테이션을 사용해야 합니다"); + + rule.check(classes); + } + + @Test + @DisplayName("Dto는 interfaces.api 패키지에 위치해야 함") + void dtos_must_reside_in_interfaces_api() { + ArchRule rule = classes() + .that().haveSimpleNameEndingWith("Dto") + .should().resideInAPackage("..interfaces.api..") + .because("Dto는 API 계층의 데이터 전달 객체로 interfaces.api 패키지에 위치해야 합니다"); + + rule.check(classes); + } + + @Test + @DisplayName("Interfaces Layer는 다른 레이어에서 접근하지 않아야 함") + void interfaces_should_not_be_accessed_by_any_layer() { + ArchRule rule = classes() + .that().resideInAPackage("..interfaces..") + .should().onlyBeAccessed().byClassesThat().resideInAnyPackage("..interfaces..", ""); + + // 참고: 빈 패키지("")는 테스트 클래스 등을 허용하기 위함 + + rule.check(classes); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/architecture/LayeredArchitectureTest.java b/apps/commerce-api/src/test/java/com/loopers/architecture/LayeredArchitectureTest.java new file mode 100644 index 000000000..4d4f8b92f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/architecture/LayeredArchitectureTest.java @@ -0,0 +1,105 @@ +package com.loopers.architecture; + +import com.tngtech.archunit.base.DescribedPredicate; +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.lang.ArchRule; +import com.tngtech.archunit.library.Architectures; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static com.tngtech.archunit.library.Architectures.layeredArchitecture; + +/** + * 레이어드 아키텍처 의존성 규칙 테스트 + * + *

아키텍처 구조: + *

+ * Interfaces Layer (Controller, Dto)
+ *     ↓
+ * Application Layer (Facade, Info)
+ *     ↓
+ * Domain Layer (Model, Reader, Service, Repository)
+ *     ↑
+ * Infrastructure Layer (RepositoryImpl, JpaRepository, Converter)
+ * 
+ */ +@DisplayName("레이어드 아키텍처 의존성 규칙") +class LayeredArchitectureTest { + + private static JavaClasses classes; + + @BeforeAll + static void setUp() { + classes = new ClassFileImporter() + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) + .importPackages("com.loopers"); + } + + @Test + @DisplayName("레이어 간 의존성 방향이 올바른지 검증") + void layer_dependencies_are_respected() { + Architectures.LayeredArchitecture architecture = layeredArchitecture() + .consideringAllDependencies() + + // 레이어 정의 + .layer("Interfaces").definedBy("com.loopers.interfaces..") + .layer("Application").definedBy("com.loopers.application..") + .layer("Domain").definedBy("com.loopers.domain..") + .layer("Infrastructure").definedBy("com.loopers.infrastructure..") + + // 예외 1: Domain → Infrastructure.Converter 의존 허용 (JPA @Convert 어노테이션 때문) + .ignoreDependency( + DescribedPredicate.describe( + "Domain classes using JPA Converters", + javaClass -> javaClass.getPackageName().startsWith("com.loopers.domain") + ), + DescribedPredicate.describe( + "JPA Converter classes", + javaClass -> javaClass.getPackageName().contains("infrastructure.jpa.converter") + ) + ) + + // 예외 2: Infrastructure → Domain.Repository 구현 허용 (DIP 패턴) + .ignoreDependency( + DescribedPredicate.describe( + "Infrastructure repository implementations", + javaClass -> javaClass.getPackageName().startsWith("com.loopers.infrastructure") + && javaClass.getSimpleName().endsWith("RepositoryImpl") + ), + DescribedPredicate.describe( + "Domain repository interfaces", + javaClass -> javaClass.getPackageName().startsWith("com.loopers.domain") + && javaClass.isInterface() + && javaClass.getSimpleName().endsWith("Repository") + ) + ) + + // 예외 3: 데이터 타입(VO, Enum, Entity)은 모든 레이어에서 사용 가능 + // 컴포넌트(Service, Repository, Facade 등) 간 의존성만 검증 + .ignoreDependency( + DescribedPredicate.alwaysTrue(), + DescribedPredicate.describe( + "Data types (VO, Enum, Entity)", + javaClass -> javaClass.getPackageName().contains(".vo") + || javaClass.isEnum() + || javaClass.isAnnotatedWith("jakarta.persistence.Entity") + ) + ) + + // 의존성 규칙 (다이어그램과 동일한 방향) + // Interfaces → Application → Domain ↔ Infrastructure + .whereLayer("Interfaces").mayNotBeAccessedByAnyLayer() + .whereLayer("Application").mayOnlyBeAccessedByLayers("Interfaces") + .whereLayer("Domain").mayOnlyBeAccessedByLayers("Application") + .whereLayer("Infrastructure").mayOnlyBeAccessedByLayers("Domain"); + + ArchRule rule = architecture + .because("컴포넌트(Service, Repository, Facade) 간 단방향 의존성을 검증합니다. " + + "(데이터 타입(VO/Enum/Entity)은 검증 대상에서 제외)"); + + rule.check(classes); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/architecture/NamingConventionTest.java b/apps/commerce-api/src/test/java/com/loopers/architecture/NamingConventionTest.java new file mode 100644 index 000000000..fb5d1dc59 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/architecture/NamingConventionTest.java @@ -0,0 +1,34 @@ +package com.loopers.architecture; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.lang.ArchRule; +import jakarta.persistence.Entity; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; + +@DisplayName("네이밍 규칙") +class NamingConventionTest { + + private static JavaClasses classes; + + @BeforeAll + static void setUp() { + classes = new ClassFileImporter() + .importPackages("com.loopers"); + } + + @Test + @DisplayName("JPA Entity는 'Model'로 끝나야 함") + void entities_must_end_with_model() { + ArchRule rule = classes() + .that().areAnnotatedWith(Entity.class) + .should().haveSimpleNameEndingWith("Model") + .because("JPA Entity는 'Model' 접미사를 사용하여 도메인 모델임을 명확히 해야 합니다"); + + rule.check(classes); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java index 98c23808e..126e3ef6d 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java @@ -2,6 +2,7 @@ import com.loopers.domain.member.vo.MemberId; import com.loopers.infrastructure.member.MemberJpaRepository; +import com.loopers.security.PasswordHasher; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import com.loopers.utils.DatabaseCleanUp; diff --git a/build.gradle.kts b/build.gradle.kts index 9c8490b8a..9168790fa 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -67,6 +67,7 @@ subprojects { testImplementation("org.springframework.boot:spring-boot-testcontainers") testImplementation("org.testcontainers:testcontainers") testImplementation("org.testcontainers:junit-jupiter") + testImplementation("com.tngtech.archunit:archunit-junit5:${project.properties["archunitVersion"]}") } tasks.withType(Jar::class) { enabled = true } diff --git a/gradle.properties b/gradle.properties index 142d7120f..8ec36075b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -14,5 +14,6 @@ springDocOpenApiVersion=2.7.0 springMockkVersion=4.0.2 mockitoVersion=5.14.0 instancioJUnitVersion=5.0.2 +archunitVersion=1.3.0 slackAppenderVersion=1.6.1 kotlin.daemon.jvmargs=-Xmx1g -XX:MaxMetaspaceSize=512m From 8f90f4900b04e8154760d47a4f8cc1b500123cac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B8=B0=EB=AC=98=ED=95=9C=20=EC=A3=BC=EB=8B=88=EC=96=B4?= =?UTF-8?q?=20=EA=B0=9C=EB=B0=9C=EC=9E=90?= Date: Mon, 16 Feb 2026 12:05:37 +0900 Subject: [PATCH 06/50] =?UTF-8?q?remove=20:=20example=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EB=B0=8F=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/example/ExampleFacade.java | 17 --- .../application/example/ExampleInfo.java | 13 -- .../loopers/domain/example/ExampleModel.java | 44 ------- .../domain/example/ExampleRepository.java | 7 -- .../domain/example/ExampleService.java | 20 --- .../example/ExampleJpaRepository.java | 6 - .../example/ExampleRepositoryImpl.java | 19 --- .../api/example/ExampleV1ApiSpec.java | 19 --- .../api/example/ExampleV1Controller.java | 28 ----- .../interfaces/api/example/ExampleV1Dto.java | 15 --- .../domain/example/ExampleModelTest.java | 65 ---------- .../ExampleServiceIntegrationTest.java | 72 ----------- .../interfaces/api/ExampleV1ApiE2ETest.java | 114 ------------------ 13 files changed, 439 deletions(-) delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java deleted file mode 100644 index 552a9ad62..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.loopers.application.example; - -import com.loopers.domain.example.ExampleModel; -import com.loopers.domain.example.ExampleService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -@RequiredArgsConstructor -@Component -public class ExampleFacade { - private final ExampleService exampleService; - - public ExampleInfo getExample(Long id) { - ExampleModel example = exampleService.getExample(id); - return ExampleInfo.from(example); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java deleted file mode 100644 index 877aba96c..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.loopers.application.example; - -import com.loopers.domain.example.ExampleModel; - -public record ExampleInfo(Long id, String name, String description) { - public static ExampleInfo from(ExampleModel model) { - return new ExampleInfo( - model.getId(), - model.getName(), - model.getDescription() - ); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java deleted file mode 100644 index c588c4a8a..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.domain.BaseEntity; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; - -@Entity -@Table(name = "example") -public class ExampleModel extends BaseEntity { - - private String name; - private String description; - - protected ExampleModel() {} - - public ExampleModel(String name, String description) { - if (name == null || name.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "이름은 비어있을 수 없습니다."); - } - if (description == null || description.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "설명은 비어있을 수 없습니다."); - } - - this.name = name; - this.description = description; - } - - public String getName() { - return name; - } - - public String getDescription() { - return description; - } - - public void update(String newDescription) { - if (newDescription == null || newDescription.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "설명은 비어있을 수 없습니다."); - } - this.description = newDescription; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java deleted file mode 100644 index 3625e5662..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.loopers.domain.example; - -import java.util.Optional; - -public interface ExampleRepository { - Optional find(Long id); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java deleted file mode 100644 index c0e8431e8..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -@RequiredArgsConstructor -@Component -public class ExampleService { - - private final ExampleRepository exampleRepository; - - @Transactional(readOnly = true) - public ExampleModel getExample(Long id) { - return exampleRepository.find(id) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + id + "] 예시를 찾을 수 없습니다.")); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java deleted file mode 100644 index ce6d3ead0..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.loopers.infrastructure.example; - -import com.loopers.domain.example.ExampleModel; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface ExampleJpaRepository extends JpaRepository {} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java deleted file mode 100644 index 37f2272f0..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.loopers.infrastructure.example; - -import com.loopers.domain.example.ExampleModel; -import com.loopers.domain.example.ExampleRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.util.Optional; - -@RequiredArgsConstructor -@Component -public class ExampleRepositoryImpl implements ExampleRepository { - private final ExampleJpaRepository exampleJpaRepository; - - @Override - public Optional find(Long id) { - return exampleJpaRepository.findById(id); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java deleted file mode 100644 index 219e3101e..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.loopers.interfaces.api.example; - -import com.loopers.interfaces.api.ApiResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.tags.Tag; - -@Tag(name = "Example V1 API", description = "Loopers 예시 API 입니다.") -public interface ExampleV1ApiSpec { - - @Operation( - summary = "예시 조회", - description = "ID로 예시를 조회합니다." - ) - ApiResponse getExample( - @Schema(name = "예시 ID", description = "조회할 예시의 ID") - Long exampleId - ); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java deleted file mode 100644 index 917376016..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.loopers.interfaces.api.example; - -import com.loopers.application.example.ExampleFacade; -import com.loopers.application.example.ExampleInfo; -import com.loopers.interfaces.api.ApiResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RequiredArgsConstructor -@RestController -@RequestMapping("/api/v1/examples") -public class ExampleV1Controller implements ExampleV1ApiSpec { - - private final ExampleFacade exampleFacade; - - @GetMapping("/{exampleId}") - @Override - public ApiResponse getExample( - @PathVariable(value = "exampleId") Long exampleId - ) { - ExampleInfo info = exampleFacade.getExample(exampleId); - ExampleV1Dto.ExampleResponse response = ExampleV1Dto.ExampleResponse.from(info); - return ApiResponse.success(response); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java deleted file mode 100644 index 4ecf0eea5..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.loopers.interfaces.api.example; - -import com.loopers.application.example.ExampleInfo; - -public class ExampleV1Dto { - public record ExampleResponse(Long id, String name, String description) { - public static ExampleResponse from(ExampleInfo info) { - return new ExampleResponse( - info.id(), - info.name(), - info.description() - ); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java deleted file mode 100644 index 44ca7576e..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertThrows; - -class ExampleModelTest { - @DisplayName("예시 모델을 생성할 때, ") - @Nested - class Create { - @DisplayName("제목과 설명이 모두 주어지면, 정상적으로 생성된다.") - @Test - void createsExampleModel_whenNameAndDescriptionAreProvided() { - // arrange - String name = "제목"; - String description = "설명"; - - // act - ExampleModel exampleModel = new ExampleModel(name, description); - - // assert - assertAll( - () -> assertThat(exampleModel.getId()).isNotNull(), - () -> assertThat(exampleModel.getName()).isEqualTo(name), - () -> assertThat(exampleModel.getDescription()).isEqualTo(description) - ); - } - - @DisplayName("제목이 빈칸으로만 이루어져 있으면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequestException_whenTitleIsBlank() { - // arrange - String name = " "; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new ExampleModel(name, "설명"); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @DisplayName("설명이 비어있으면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequestException_whenDescriptionIsEmpty() { - // arrange - String description = ""; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new ExampleModel("제목", description); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java deleted file mode 100644 index bbd5fdbe1..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.infrastructure.example.ExampleJpaRepository; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.AfterEach; -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 static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertThrows; - -@SpringBootTest -class ExampleServiceIntegrationTest { - @Autowired - private ExampleService exampleService; - - @Autowired - private ExampleJpaRepository exampleJpaRepository; - - @Autowired - private DatabaseCleanUp databaseCleanUp; - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @DisplayName("예시를 조회할 때,") - @Nested - class Get { - @DisplayName("존재하는 예시 ID를 주면, 해당 예시 정보를 반환한다.") - @Test - void returnsExampleInfo_whenValidIdIsProvided() { - // arrange - ExampleModel exampleModel = exampleJpaRepository.save( - new ExampleModel("예시 제목", "예시 설명") - ); - - // act - ExampleModel result = exampleService.getExample(exampleModel.getId()); - - // assert - assertAll( - () -> assertThat(result).isNotNull(), - () -> assertThat(result.getId()).isEqualTo(exampleModel.getId()), - () -> assertThat(result.getName()).isEqualTo(exampleModel.getName()), - () -> assertThat(result.getDescription()).isEqualTo(exampleModel.getDescription()) - ); - } - - @DisplayName("존재하지 않는 예시 ID를 주면, NOT_FOUND 예외가 발생한다.") - @Test - void throwsException_whenInvalidIdIsProvided() { - // arrange - Long invalidId = 999L; // Assuming this ID does not exist - - // act - CoreException exception = assertThrows(CoreException.class, () -> { - exampleService.getExample(invalidId); - }); - - // assert - assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java deleted file mode 100644 index 1bb3dba65..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java +++ /dev/null @@ -1,114 +0,0 @@ -package com.loopers.interfaces.api; - -import com.loopers.domain.example.ExampleModel; -import com.loopers.infrastructure.example.ExampleJpaRepository; -import com.loopers.interfaces.api.example.ExampleV1Dto; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.AfterEach; -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.core.ParameterizedTypeReference; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpMethod; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; - -import java.util.function.Function; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertTrue; - -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -class ExampleV1ApiE2ETest { - - private static final Function ENDPOINT_GET = id -> "/api/v1/examples/" + id; - - private final TestRestTemplate testRestTemplate; - private final ExampleJpaRepository exampleJpaRepository; - private final DatabaseCleanUp databaseCleanUp; - - @Autowired - public ExampleV1ApiE2ETest( - TestRestTemplate testRestTemplate, - ExampleJpaRepository exampleJpaRepository, - DatabaseCleanUp databaseCleanUp - ) { - this.testRestTemplate = testRestTemplate; - this.exampleJpaRepository = exampleJpaRepository; - this.databaseCleanUp = databaseCleanUp; - } - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @DisplayName("GET /api/v1/examples/{id}") - @Nested - class Get { - @DisplayName("존재하는 예시 ID를 주면, 해당 예시 정보를 반환한다.") - @Test - void returnsExampleInfo_whenValidIdIsProvided() { - // arrange - ExampleModel exampleModel = exampleJpaRepository.save( - new ExampleModel("예시 제목", "예시 설명") - ); - String requestUrl = ENDPOINT_GET.apply(exampleModel.getId()); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is2xxSuccessful()), - () -> assertThat(response.getBody().data().id()).isEqualTo(exampleModel.getId()), - () -> assertThat(response.getBody().data().name()).isEqualTo(exampleModel.getName()), - () -> assertThat(response.getBody().data().description()).isEqualTo(exampleModel.getDescription()) - ); - } - - @DisplayName("숫자가 아닌 ID 로 요청하면, 400 BAD_REQUEST 응답을 받는다.") - @Test - void throwsBadRequest_whenIdIsNotProvided() { - // arrange - String requestUrl = "/api/v1/examples/나나"; - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is4xxClientError()), - () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST) - ); - } - - @DisplayName("존재하지 않는 예시 ID를 주면, 404 NOT_FOUND 응답을 받는다.") - @Test - void throwsException_whenInvalidIdIsProvided() { - // arrange - Long invalidId = -1L; - String requestUrl = ENDPOINT_GET.apply(invalidId); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is4xxClientError()), - () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) - ); - } - } -} From 2e20b20ab39407d55ec141935e8e7a0979c79c82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B8=B0=EB=AC=98=ED=95=9C=20=EC=A3=BC=EB=8B=88=EC=96=B4?= =?UTF-8?q?=20=EA=B0=9C=EB=B0=9C=EC=9E=90?= Date: Mon, 16 Feb 2026 12:18:45 +0900 Subject: [PATCH 07/50] =?UTF-8?q?test(architecture)=20:=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=EB=93=9C=20=EC=95=84=ED=82=A4=ED=85=8D?= =?UTF-8?q?=EC=B3=90=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../architecture/LayeredArchitectureTest.java | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/apps/commerce-api/src/test/java/com/loopers/architecture/LayeredArchitectureTest.java b/apps/commerce-api/src/test/java/com/loopers/architecture/LayeredArchitectureTest.java index 4d4f8b92f..842206e3e 100644 --- a/apps/commerce-api/src/test/java/com/loopers/architecture/LayeredArchitectureTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/architecture/LayeredArchitectureTest.java @@ -10,6 +10,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; import static com.tngtech.archunit.library.Architectures.layeredArchitecture; /** @@ -102,4 +103,78 @@ void layer_dependencies_are_respected() { rule.check(classes); } + + @Test + @DisplayName("Interfaces 컴포넌트는 Domain, Infrastructure 컴포넌트를 직접 의존하면 안 됨") + void interfaces_components_should_not_depend_on_domain_or_infrastructure_components() { + // Controller가 Service나 Repository를 직접 호출하는 것 금지 + // (VO, Enum, Entity 같은 데이터 타입 의존은 허용) + ArchRule rule = noClasses() + .that().resideInAPackage("com.loopers.interfaces..") + .and().haveSimpleNameEndingWith("Controller") + .should().dependOnClassesThat() + .resideInAnyPackage("com.loopers.domain..", "com.loopers.infrastructure..") + .andShould().haveSimpleNameEndingWith("Service") + .orShould().haveSimpleNameEndingWith("Repository") + .orShould().haveSimpleNameEndingWith("RepositoryImpl") + .because("Controller는 Facade를 통해서만 하위 레이어에 접근해야 합니다"); + + rule.check(classes); + } + + @Test + @DisplayName("Application 컴포넌트는 Infrastructure 컴포넌트를 직접 의존하면 안 됨") + void application_components_should_not_depend_on_infrastructure_components() { + // Facade가 Repository 구현체를 직접 호출하는 것 금지 + ArchRule rule = noClasses() + .that().resideInAPackage("com.loopers.application..") + .and().haveSimpleNameEndingWith("Facade") + .should().dependOnClassesThat() + .resideInAnyPackage("com.loopers.infrastructure..") + .andShould().haveSimpleNameEndingWith("RepositoryImpl") + .because("Facade는 Domain Service를 통해서만 데이터에 접근해야 합니다"); + + rule.check(classes); + } + + @Test + @DisplayName("Domain Service는 Repository 인터페이스만 의존해야 함") + void domain_services_should_only_depend_on_repository_interfaces() { + // Service가 RepositoryImpl을 직접 의존하지 않고 Repository 인터페이스만 사용 + ArchRule rule = noClasses() + .that().resideInAPackage("com.loopers.domain..") + .and().haveSimpleNameEndingWith("Service") + .should().dependOnClassesThat() + .resideInAnyPackage("com.loopers.infrastructure..") + .andShould().haveSimpleNameNotEndingWith("Converter") // Converter는 예외 + .because("Service는 Repository 인터페이스를 통해 데이터에 접근해야 합니다"); + + rule.check(classes); + } + + @Test + @DisplayName("레이어는 역방향으로 접근할 수 없음 (상위 레이어 접근 금지)") + void layers_should_not_access_upper_layers() { + // Application이 Interfaces 접근 금지 + ArchRule applicationToInterfaces = noClasses() + .that().resideInAPackage("com.loopers.application..") + .should().dependOnClassesThat().resideInAnyPackage("com.loopers.interfaces..") + .because("하위 레이어가 상위 레이어를 의존하면 순환 의존성이 발생합니다"); + + // Domain이 Interfaces, Application 접근 금지 + ArchRule domainToUpper = noClasses() + .that().resideInAPackage("com.loopers.domain..") + .should().dependOnClassesThat().resideInAnyPackage("com.loopers.interfaces..", "com.loopers.application..") + .because("하위 레이어가 상위 레이어를 의존하면 순환 의존성이 발생합니다"); + + // Infrastructure가 Interfaces, Application 접근 금지 + ArchRule infraToUpper = noClasses() + .that().resideInAPackage("com.loopers.infrastructure..") + .should().dependOnClassesThat().resideInAnyPackage("com.loopers.interfaces..", "com.loopers.application..") + .because("하위 레이어가 상위 레이어를 의존하면 순환 의존성이 발생합니다"); + + applicationToInterfaces.check(classes); + domainToUpper.check(classes); + infraToUpper.check(classes); + } } From 2f28020042bab8eba65fb973fe7d1fa125b2e22d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B8=B0=EB=AC=98=ED=95=9C=20=EC=A3=BC=EB=8B=88=EC=96=B4?= =?UTF-8?q?=20=EA=B0=9C=EB=B0=9C=EC=9E=90?= Date: Mon, 16 Feb 2026 19:05:40 +0900 Subject: [PATCH 08/50] =?UTF-8?q?docs(claude)=20:=20CLAUDE.md=20=EB=82=B4?= =?UTF-8?q?=20=EB=8F=84=EB=A9=94=EC=9D=B8/=EC=95=84=ED=82=A4=ED=85=8D?= =?UTF-8?q?=EC=B2=98=20=EC=A0=84=EB=9E=B5=20=EC=B6=94=EA=B0=80.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 58 ++++++++++++++++++++++++++----------------------------- 1 file changed, 27 insertions(+), 31 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 36b69ba47..767b4664d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -142,37 +142,6 @@ Infrastructure Layer (RepositoryImpl, JpaRepository, Converter) - **local**: 로컬 개발 / **test**: 테스트 (TestContainers) - **dev**: 개발 서버 / **qa**: QA 서버 / **prd**: 운영 서버 -### 인프라 실행 -```bash -# MySQL, Redis, Kafka -docker-compose -f ./docker/infra-compose.yml up - -# Prometheus, Grafana -docker-compose -f ./docker/monitoring-compose.yml up -``` - -### 접속 정보 -- Swagger UI: http://localhost:8080/swagger-ui.html -- Grafana: http://localhost:3000 (admin/admin) - ---- - -## 빌드 및 실행 - -```bash -# 전체 빌드 -./gradlew clean build - -# 테스트 -./gradlew test - -# 커버리지 -./gradlew test jacocoTestReport - -# commerce-api 실행 -./gradlew :apps:commerce-api:bootRun -``` - --- ## 도메인 예시 (Member) @@ -203,4 +172,31 @@ docker-compose -f ./docker/monitoring-compose.yml up --- +## 도메인 & 객체 설계 전략 + +- 도메인 모델링은 데이터 설계가 아니라 **업무 규칙을 객체 책임으로 고정**하는 작업입니다. +- **Entity**: ID로 동일성 판단, 상태 변화와 연속성이 핵심(행위를 내부에 둠). +- **VO**: 값 자체가 핵심, 불변 + 생성 시 유효성 강제(원시타입 규칙 중복 제거). +- **Domain Service**: 특정 엔티티에 두기 부자연스러운 "도메인 규칙"만, 무상태로 둠. +- **Application Service(Usecase)**: 트랜잭션/권한/저장/외부연동 등 "흐름 조립" 담당, 규칙은 도메인에 위임. +- 규칙이 여러 서비스에 반복되면 → **도메인(엔티티/VO/도메인서비스)로 내려갈 신호**입니다. +- 관계 자체가 의미를 가지면(누가/언제/중복/취소/이력) → `Like`처럼 **독립 도메인으로 분리**합니다. +- 동시성 규칙은 if문만 믿지 말고 **DB 제약(유니크)로 최종 방어선**을 둡니다. +- 의존 방향은 **Interfaces → Application → Domain ← Infrastructure**(Repo 인터페이스는 Domain, 구현은 Infra). +- 리뷰 기준: 도메인이 기술(Spring/JPA/HTTP)을 모르고, 컬렉션/상태 변경은 루트가 통제하며, 테스트는 Fake로 가능해야 합니다. + +--- + +## 아키텍처, 패키지 구성 전략 + +- **레이어 의존성 방향**: `Controller → Facade → Service → Repository` (단방향), Infrastructure는 Domain 인터페이스 구현 (Port-Adapter). +- **Thin Facade 원칙**: Facade는 Service만 호출하고 Reader 직접 호출 금지, 비즈니스 로직은 Service에 위임(조율만 담당). +- **DTO vs Info vs Model 분리**: DTO(HTTP 계층) → Info(Application 결과 VO) → Model(Domain Entity), 각 레이어 독립성 유지. +- **Reader vs Service**: Reader는 읽기 전용 + getOrThrow 패턴, Service는 CUD + 비즈니스 규칙 + @Transactional 경계. +- **Repository Pattern**: Domain에 Repository 인터페이스(Port), Infrastructure에 구현체(Adapter), Domain이 Infrastructure를 모름. +- **Info 변환**: Facade에서 Model → Info 변환, Controller는 Model 노출 금지(Info만 사용), 레이어 격리 유지. +- **컴포넌트 책임**: Controller(HTTP), Facade(유스케이스 조합), Service(비즈니스 로직), Reader(조회), Repository(영속화). + +--- + 이 문서는 프로젝트의 핵심 원칙과 구조를 요약합니다. 상세 내용은 skills를 참조하세요. From dcea02b4bd05477f8a61565dcff47a7e1c1a683f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B8=B0=EB=AC=98=ED=95=9C=20=EC=A3=BC=EB=8B=88=EC=96=B4?= =?UTF-8?q?=20=EA=B0=9C=EB=B0=9C=EC=9E=90?= Date: Mon, 16 Feb 2026 19:44:54 +0900 Subject: [PATCH 09/50] =?UTF-8?q?feat(Brand)=20:=20Brand=20VO=20=EB=B0=8F?= =?UTF-8?q?=20Entity=20=EA=B5=AC=ED=98=84.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/domain/brand/BrandModel.java | 42 ++++++++++ .../com/loopers/domain/brand/vo/BrandId.java | 23 +++++ .../loopers/domain/brand/vo/BrandName.java | 18 ++++ .../jpa/converter/BrandIdConverter.java | 19 +++++ .../jpa/converter/BrandNameConverter.java | 19 +++++ .../loopers/domain/brand/BrandModelTest.java | 78 +++++++++++++++++ .../loopers/domain/brand/vo/BrandIdTest.java | 84 +++++++++++++++++++ .../domain/brand/vo/BrandNameTest.java | 71 ++++++++++++++++ 8 files changed, 354 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandModel.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/vo/BrandId.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/vo/BrandName.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/BrandIdConverter.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/BrandNameConverter.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandModelTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/brand/vo/BrandIdTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/brand/vo/BrandNameTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandModel.java new file mode 100644 index 000000000..37bb69b75 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandModel.java @@ -0,0 +1,42 @@ +package com.loopers.domain.brand; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.brand.vo.BrandId; +import com.loopers.domain.brand.vo.BrandName; +import com.loopers.infrastructure.jpa.converter.BrandIdConverter; +import com.loopers.infrastructure.jpa.converter.BrandNameConverter; +import jakarta.persistence.*; +import lombok.Getter; + +@Entity +@Table(name = "brands") +@Getter +public class BrandModel extends BaseEntity { + + @Convert(converter = BrandIdConverter.class) + @Column(name = "brand_id", nullable = false, unique = true, length = 10) + private BrandId brandId; + + @Convert(converter = BrandNameConverter.class) + @Column(name = "brand_name", nullable = false, length = 50) + private BrandName brandName; + + protected BrandModel() {} + + private BrandModel(String brandId, String brandName) { + this.brandId = new BrandId(brandId); + this.brandName = new BrandName(brandName); + } + + public static BrandModel create(String brandId, String brandName) { + return new BrandModel(brandId, brandName); + } + + public void markAsDeleted() { + delete(); + } + + public boolean isDeleted() { + return getDeletedAt() != null; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/vo/BrandId.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/vo/BrandId.java new file mode 100644 index 000000000..82d172b24 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/vo/BrandId.java @@ -0,0 +1,23 @@ +package com.loopers.domain.brand.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.util.regex.Pattern; + +public record BrandId(String value) { + + // 영문+숫자, 1~10자 + private static final Pattern PATTERN = Pattern.compile("^[A-Za-z0-9]{1,10}$"); + + public BrandId { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "brandId가 비어 있습니다"); + } + value = value.trim(); + + if (!PATTERN.matcher(value).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "brandId는 영문+숫자, 1~10자로 이루어져야 합니다: " + value); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/vo/BrandName.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/vo/BrandName.java new file mode 100644 index 000000000..fe3ed4352 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/vo/BrandName.java @@ -0,0 +1,18 @@ +package com.loopers.domain.brand.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +public record BrandName(String value) { + + public BrandName { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드명이 비어 있습니다"); + } + value = value.trim(); + + if (value.isEmpty() || value.length() > 50) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드명 길이는 1자 이상 50자 이하여야 합니다: " + value.length()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/BrandIdConverter.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/BrandIdConverter.java new file mode 100644 index 000000000..0c138c112 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/BrandIdConverter.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.jpa.converter; + +import com.loopers.domain.brand.vo.BrandId; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter(autoApply = false) +public class BrandIdConverter implements AttributeConverter { + + @Override + public String convertToDatabaseColumn(BrandId attribute) { + return attribute == null ? null : attribute.value(); + } + + @Override + public BrandId convertToEntityAttribute(String dbData) { + return dbData == null ? null : new BrandId(dbData); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/BrandNameConverter.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/BrandNameConverter.java new file mode 100644 index 000000000..6daef7c15 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/BrandNameConverter.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.jpa.converter; + +import com.loopers.domain.brand.vo.BrandName; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter(autoApply = false) +public class BrandNameConverter implements AttributeConverter { + + @Override + public String convertToDatabaseColumn(BrandName attribute) { + return attribute == null ? null : attribute.value(); + } + + @Override + public BrandName convertToEntityAttribute(String dbData) { + return dbData == null ? null : new BrandName(dbData); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandModelTest.java new file mode 100644 index 000000000..bbb5508a1 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandModelTest.java @@ -0,0 +1,78 @@ +package com.loopers.domain.brand; + +import com.loopers.domain.brand.vo.BrandId; +import com.loopers.domain.brand.vo.BrandName; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.ZonedDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("BrandModel Entity") +class BrandModelTest { + + @Test + @DisplayName("create() 정적 팩토리로 BrandModel 생성") + void create_brand_model() { + // given + String brandId = "nike"; + String brandName = "Nike"; + + // when + BrandModel brand = BrandModel.create(brandId, brandName); + + // then + assertThat(brand.getBrandId()).isEqualTo(new BrandId(brandId)); + assertThat(brand.getBrandName()).isEqualTo(new BrandName(brandName)); + assertThat(brand.getDeletedAt()).isNull(); + assertThat(brand.isDeleted()).isFalse(); + } + + @Test + @DisplayName("markAsDeleted() 호출 시 deletedAt 설정됨") + void mark_as_deleted_sets_deletedAt() { + // given + BrandModel brand = BrandModel.create("adidas", "Adidas"); + assertThat(brand.getDeletedAt()).isNull(); + + // when + ZonedDateTime beforeDelete = ZonedDateTime.now(); + brand.markAsDeleted(); + ZonedDateTime afterDelete = ZonedDateTime.now(); + + // then + assertThat(brand.getDeletedAt()).isNotNull(); + assertThat(brand.getDeletedAt()) + .isAfterOrEqualTo(beforeDelete) + .isBeforeOrEqualTo(afterDelete); + } + + @Test + @DisplayName("isDeleted()는 deletedAt이 null이 아니면 true 반환") + void isDeleted_returns_true_when_deletedAt_is_not_null() { + // given + BrandModel brand = BrandModel.create("puma", "Puma"); + + // when & then + assertThat(brand.isDeleted()).isFalse(); + + brand.markAsDeleted(); + assertThat(brand.isDeleted()).isTrue(); + } + + @Test + @DisplayName("markAsDeleted() 중복 호출 시 deletedAt 변경되지 않음 (멱등성)") + void markAsDeleted_idempotent() { + // given + BrandModel brand = BrandModel.create("reebok", "Reebok"); + brand.markAsDeleted(); + ZonedDateTime firstDeletedAt = brand.getDeletedAt(); + + // when + brand.markAsDeleted(); + + // then + assertThat(brand.getDeletedAt()).isEqualTo(firstDeletedAt); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/vo/BrandIdTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/vo/BrandIdTest.java new file mode 100644 index 000000000..175e3102b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/vo/BrandIdTest.java @@ -0,0 +1,84 @@ +package com.loopers.domain.brand.vo; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("BrandId VO") +class BrandIdTest { + + @Test + @DisplayName("유효한 BrandId 생성 성공 - 영문+숫자 1-10자") + void create_valid_brandId() { + // given & when + BrandId brandId1 = new BrandId("brand1"); + BrandId brandId2 = new BrandId("BRAND123"); + BrandId brandId3 = new BrandId("b"); + BrandId brandId4 = new BrandId("1234567890"); + + // then + assertThat(brandId1.value()).isEqualTo("brand1"); + assertThat(brandId2.value()).isEqualTo("BRAND123"); + assertThat(brandId3.value()).isEqualTo("b"); + assertThat(brandId4.value()).isEqualTo("1234567890"); + } + + @Test + @DisplayName("null이면 예외 발생") + void null_brandId_throws_exception() { + assertThatThrownBy(() -> new BrandId(null)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("brandId가 비어 있습니다"); + } + + @Test + @DisplayName("빈 문자열이면 예외 발생") + void empty_brandId_throws_exception() { + assertThatThrownBy(() -> new BrandId("")) + .isInstanceOf(CoreException.class) + .hasMessageContaining("brandId가 비어 있습니다"); + } + + @Test + @DisplayName("공백 문자열이면 예외 발생") + void blank_brandId_throws_exception() { + assertThatThrownBy(() -> new BrandId(" ")) + .isInstanceOf(CoreException.class) + .hasMessageContaining("brandId가 비어 있습니다"); + } + + @Test + @DisplayName("특수문자 포함 시 예외 발생") + void brandId_with_special_characters_throws_exception() { + assertThatThrownBy(() -> new BrandId("brand-1")) + .isInstanceOf(CoreException.class) + .hasMessageContaining("영문+숫자, 1~10자"); + + assertThatThrownBy(() -> new BrandId("brand_1")) + .isInstanceOf(CoreException.class) + .hasMessageContaining("영문+숫자, 1~10자"); + + assertThatThrownBy(() -> new BrandId("brand@1")) + .isInstanceOf(CoreException.class) + .hasMessageContaining("영문+숫자, 1~10자"); + } + + @Test + @DisplayName("11자 이상이면 예외 발생") + void brandId_longer_than_10_throws_exception() { + assertThatThrownBy(() -> new BrandId("12345678901")) + .isInstanceOf(CoreException.class) + .hasMessageContaining("영문+숫자, 1~10자"); + } + + @Test + @DisplayName("한글 포함 시 예외 발생") + void brandId_with_korean_throws_exception() { + assertThatThrownBy(() -> new BrandId("브랜드1")) + .isInstanceOf(CoreException.class) + .hasMessageContaining("영문+숫자, 1~10자"); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/vo/BrandNameTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/vo/BrandNameTest.java new file mode 100644 index 000000000..14d053b6b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/vo/BrandNameTest.java @@ -0,0 +1,71 @@ +package com.loopers.domain.brand.vo; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("BrandName VO") +class BrandNameTest { + + @Test + @DisplayName("유효한 BrandName 생성 성공 - 1-50자") + void create_valid_brandName() { + // given & when + BrandName brandName1 = new BrandName("Nike"); + BrandName brandName2 = new BrandName("삼성전자"); + BrandName brandName3 = new BrandName("A"); + BrandName brandName4 = new BrandName("A".repeat(50)); + + // then + assertThat(brandName1.value()).isEqualTo("Nike"); + assertThat(brandName2.value()).isEqualTo("삼성전자"); + assertThat(brandName3.value()).isEqualTo("A"); + assertThat(brandName4.value()).hasSize(50); + } + + @Test + @DisplayName("null이면 예외 발생") + void null_brandName_throws_exception() { + assertThatThrownBy(() -> new BrandName(null)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("브랜드명이 비어 있습니다"); + } + + @Test + @DisplayName("빈 문자열이면 예외 발생") + void empty_brandName_throws_exception() { + assertThatThrownBy(() -> new BrandName("")) + .isInstanceOf(CoreException.class) + .hasMessageContaining("브랜드명이 비어 있습니다"); + } + + @Test + @DisplayName("공백 문자열이면 예외 발생") + void blank_brandName_throws_exception() { + assertThatThrownBy(() -> new BrandName(" ")) + .isInstanceOf(CoreException.class) + .hasMessageContaining("브랜드명이 비어 있습니다"); + } + + @Test + @DisplayName("51자 이상이면 예외 발생") + void brandName_longer_than_50_throws_exception() { + String longName = "A".repeat(51); + assertThatThrownBy(() -> new BrandName(longName)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("브랜드명 길이는 1자 이상 50자 이하여야 합니다"); + } + + @Test + @DisplayName("앞뒤 공백은 trim 처리됨") + void brandName_with_leading_trailing_spaces_is_trimmed() { + // given & when + BrandName brandName = new BrandName(" Nike "); + + // then + assertThat(brandName.value()).isEqualTo("Nike"); + } +} From 4136b3a6e0fc669f78ee9c037186c54815b2e3fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B8=B0=EB=AC=98=ED=95=9C=20=EC=A3=BC=EB=8B=88=EC=96=B4?= =?UTF-8?q?=20=EA=B0=9C=EB=B0=9C=EC=9E=90?= Date: Mon, 16 Feb 2026 20:09:28 +0900 Subject: [PATCH 10/50] =?UTF-8?q?feat(brand):=20Service=20=EB=B0=8F=20Repo?= =?UTF-8?q?sitory=20=EA=B5=AC=ED=98=84.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BrandService 구현 (createBrand, deleteBrand) - BrandReader 구현 (getOrThrow, exists 패턴) - BrandRepository 인터페이스 정의 (Port) - BrandRepositoryImpl 구현 (Adapter, Port-Adapter 패턴) - BrandJpaRepository 구현 (Spring Data JPA) - 단위 테스트 작성 (Repository/Reader 모킹) - 통합 테스트 작성 (Spring Context, DatabaseCleanUp) --- .../com/loopers/domain/brand/BrandReader.java | 26 +++ .../loopers/domain/brand/BrandRepository.java | 11 ++ .../loopers/domain/brand/BrandService.java | 38 ++++ .../brand/BrandJpaRepository.java | 12 ++ .../brand/BrandRepositoryImpl.java | 30 +++ .../brand/BrandServiceIntegrationTest.java | 171 ++++++++++++++++++ .../domain/brand/BrandServiceTest.java | 110 +++++++++++ 7 files changed, 398 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandReader.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandReader.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandReader.java new file mode 100644 index 000000000..2ff34736c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandReader.java @@ -0,0 +1,26 @@ +package com.loopers.domain.brand; + +import com.loopers.domain.brand.vo.BrandId; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +public class BrandReader { + + private final BrandRepository brandRepository; + + @Transactional(readOnly = true) + public BrandModel getOrThrow(String brandId) { + return brandRepository.findByBrandId(new BrandId(brandId)) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 브랜드가 존재하지 않습니다.")); + } + + @Transactional(readOnly = true) + public boolean exists(String brandId) { + return brandRepository.existsByBrandId(new BrandId(brandId)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java new file mode 100644 index 000000000..45cd9d2b9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -0,0 +1,11 @@ +package com.loopers.domain.brand; + +import com.loopers.domain.brand.vo.BrandId; + +import java.util.Optional; + +public interface BrandRepository { + BrandModel save(BrandModel brand); + Optional findByBrandId(BrandId brandId); + boolean existsByBrandId(BrandId brandId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java new file mode 100644 index 000000000..e53a48a40 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -0,0 +1,38 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class BrandService { + + private final BrandRepository brandRepository; + private final BrandReader brandReader; + + @Transactional + public BrandModel createBrand(String brandId, String brandName) { + if (brandReader.exists(brandId)) { + throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 브랜드 ID입니다."); + } + + BrandModel brand = BrandModel.create(brandId, brandName); + return brandRepository.save(brand); + } + + @Transactional + public void deleteBrand(String brandId) { + BrandModel brand = brandReader.getOrThrow(brandId); + + // TODO: Product 도메인 구현 후 상품 참조 체크 로직 추가 + // if (productReader.existsByBrandId(brandId)) { + // throw new CoreException(ErrorType.CONFLICT, "해당 브랜드를 참조하는 상품이 존재하여 삭제할 수 없습니다."); + // } + + brand.markAsDeleted(); + brandRepository.save(brand); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java new file mode 100644 index 000000000..6fc4c24a4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -0,0 +1,12 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.vo.BrandId; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface BrandJpaRepository extends JpaRepository { + boolean existsByBrandId(BrandId brandId); + Optional findByBrandId(BrandId brandId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java new file mode 100644 index 000000000..a3a2eb860 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -0,0 +1,30 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.brand.vo.BrandId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class BrandRepositoryImpl implements BrandRepository { + private final BrandJpaRepository brandJpaRepository; + + @Override + public BrandModel save(BrandModel brand) { + return brandJpaRepository.save(brand); + } + + @Override + public Optional findByBrandId(BrandId brandId) { + return brandJpaRepository.findByBrandId(brandId); + } + + @Override + public boolean existsByBrandId(BrandId brandId) { + return brandJpaRepository.existsByBrandId(brandId); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java new file mode 100644 index 000000000..065e5c15c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java @@ -0,0 +1,171 @@ +package com.loopers.domain.brand; + +import com.loopers.domain.brand.vo.BrandId; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@SpringBootTest +@DisplayName("BrandService 통합 테스트") +class BrandServiceIntegrationTest { + + @Autowired + private BrandService brandService; + + @Autowired + private BrandReader brandReader; + + @Autowired + private BrandJpaRepository brandJpaRepository; + + @Autowired + private BrandRepository spyBrandRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + Mockito.reset(spyBrandRepository); + } + + @Test + @DisplayName("브랜드 생성 성공") + void createBrand_success() { + // given + String brandId = "nike"; + String brandName = "Nike"; + + // when + BrandModel savedBrand = brandService.createBrand(brandId, brandName); + + // then + verify(spyBrandRepository, times(1)).save(any(BrandModel.class)); + + assertAll( + () -> assertThat(savedBrand).isNotNull(), + () -> assertThat(savedBrand.getId()).isNotNull(), + () -> assertThat(savedBrand.getBrandId()).isEqualTo(new BrandId(brandId)), + () -> assertThat(savedBrand.getBrandName().value()).isEqualTo(brandName), + () -> assertThat(savedBrand.isDeleted()).isFalse() + ); + + // DB에서 직접 조회하여 검증 + BrandModel foundBrand = brandJpaRepository.findById(savedBrand.getId()).orElseThrow(); + assertAll( + () -> assertThat(foundBrand.getBrandId()).isEqualTo(new BrandId(brandId)), + () -> assertThat(foundBrand.getBrandName().value()).isEqualTo(brandName), + () -> assertThat(foundBrand.isDeleted()).isFalse() + ); + } + + @Test + @DisplayName("중복된 브랜드 ID로 생성 시 예외 발생") + void createBrand_duplicateId_throwsException() { + // given + String brandId = "adidas"; + brandService.createBrand(brandId, "Adidas"); + + // when & then + assertThatThrownBy(() -> brandService.createBrand(brandId, "Adidas2")) + .isInstanceOf(CoreException.class) + .hasMessageContaining("이미 존재하는 브랜드 ID입니다.") + .extracting("errorType") + .isEqualTo(ErrorType.CONFLICT); + } + + @Test + @DisplayName("브랜드 삭제 성공 (soft delete)") + void deleteBrand_success() { + // given + String brandId = "puma"; + BrandModel brand = brandService.createBrand(brandId, "Puma"); + assertThat(brand.isDeleted()).isFalse(); + + // when + brandService.deleteBrand(brandId); + + // then + BrandModel deletedBrand = brandReader.getOrThrow(brandId); + assertThat(deletedBrand.isDeleted()).isTrue(); + assertThat(deletedBrand.getDeletedAt()).isNotNull(); + + // save가 2번 호출됨 (생성 1회 + 삭제 1회) + verify(spyBrandRepository, times(2)).save(any(BrandModel.class)); + } + + @Test + @DisplayName("존재하지 않는 브랜드 삭제 시 예외 발생") + void deleteBrand_notFound_throwsException() { + // given + String nonExistentBrandId = "nonexist"; + + // when & then + assertThatThrownBy(() -> brandService.deleteBrand(nonExistentBrandId)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("해당 ID의 브랜드가 존재하지 않습니다.") + .extracting("errorType") + .isEqualTo(ErrorType.NOT_FOUND); + } + + // TODO: Product 도메인 구현 후 추가할 테스트 + // @Test + // @DisplayName("상품이 참조하고 있는 브랜드 삭제 시 예외 발생") + // void deleteBrand_hasProducts_throwsException() { + // // given + // String brandId = "samsung"; + // brandService.createBrand(brandId, "Samsung"); + // // productService.createProduct(..., brandId, ...); // Product 생성 + // + // // when & then + // assertThatThrownBy(() -> brandService.deleteBrand(brandId)) + // .isInstanceOf(CoreException.class) + // .hasMessageContaining("해당 브랜드를 참조하는 상품이 존재하여 삭제할 수 없습니다.") + // .extracting("errorType") + // .isEqualTo(ErrorType.CONFLICT); + // } + + @TestConfiguration + static class SpyConfig { + @Bean + @Primary + public BrandRepository spyBrandRepository(BrandJpaRepository brandJpaRepository) { + return Mockito.spy(new BrandRepository() { + @Override + public BrandModel save(BrandModel brand) { + return brandJpaRepository.save(brand); + } + + @Override + public Optional findByBrandId(BrandId brandId) { + return brandJpaRepository.findByBrandId(brandId); + } + + @Override + public boolean existsByBrandId(BrandId brandId) { + return brandJpaRepository.existsByBrandId(brandId); + } + }); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java new file mode 100644 index 000000000..2932b9e77 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java @@ -0,0 +1,110 @@ +package com.loopers.domain.brand; + +import com.loopers.domain.brand.vo.BrandId; +import com.loopers.domain.brand.vo.BrandName; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("BrandService 단위 테스트") +class BrandServiceTest { + + @Mock + private BrandRepository brandRepository; + + @Mock + private BrandReader brandReader; + + @InjectMocks + private BrandService brandService; + + @Test + @DisplayName("브랜드 생성 - 성공") + void createBrand_success() { + // given + String brandId = "nike"; + String brandName = "Nike"; + BrandModel mockBrand = BrandModel.create(brandId, brandName); + + when(brandReader.exists(brandId)).thenReturn(false); + when(brandRepository.save(any(BrandModel.class))).thenReturn(mockBrand); + + // when + BrandModel result = brandService.createBrand(brandId, brandName); + + // then + assertThat(result).isNotNull(); + assertThat(result.getBrandId()).isEqualTo(new BrandId(brandId)); + assertThat(result.getBrandName()).isEqualTo(new BrandName(brandName)); + + verify(brandReader, times(1)).exists(brandId); + verify(brandRepository, times(1)).save(any(BrandModel.class)); + } + + @Test + @DisplayName("브랜드 생성 - 중복 ID로 실패") + void createBrand_duplicateId_throwsException() { + // given + String brandId = "adidas"; + when(brandReader.exists(brandId)).thenReturn(true); + + // when & then + assertThatThrownBy(() -> brandService.createBrand(brandId, "Adidas")) + .isInstanceOf(CoreException.class) + .hasMessageContaining("이미 존재하는 브랜드 ID입니다.") + .extracting("errorType") + .isEqualTo(ErrorType.CONFLICT); + + verify(brandReader, times(1)).exists(brandId); + verify(brandRepository, never()).save(any(BrandModel.class)); + } + + @Test + @DisplayName("브랜드 삭제 - 성공") + void deleteBrand_success() { + // given + String brandId = "puma"; + BrandModel mockBrand = BrandModel.create(brandId, "Puma"); + + when(brandReader.getOrThrow(brandId)).thenReturn(mockBrand); + when(brandRepository.save(any(BrandModel.class))).thenReturn(mockBrand); + + // when + brandService.deleteBrand(brandId); + + // then + verify(brandReader, times(1)).getOrThrow(brandId); + verify(brandRepository, times(1)).save(mockBrand); + assertThat(mockBrand.isDeleted()).isTrue(); + } + + @Test + @DisplayName("브랜드 삭제 - 존재하지 않는 브랜드") + void deleteBrand_notFound_throwsException() { + // given + String brandId = "nonexistent"; + when(brandReader.getOrThrow(brandId)) + .thenThrow(new CoreException(ErrorType.NOT_FOUND, "해당 ID의 브랜드가 존재하지 않습니다.")); + + // when & then + assertThatThrownBy(() -> brandService.deleteBrand(brandId)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("해당 ID의 브랜드가 존재하지 않습니다.") + .extracting("errorType") + .isEqualTo(ErrorType.NOT_FOUND); + + verify(brandReader, times(1)).getOrThrow(brandId); + verify(brandRepository, never()).save(any(BrandModel.class)); + } +} From 40f47ed935a1204469307d810cce2a67d70dcd16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B8=B0=EB=AC=98=ED=95=9C=20=EC=A3=BC=EB=8B=88=EC=96=B4?= =?UTF-8?q?=20=EA=B0=9C=EB=B0=9C=EC=9E=90?= Date: Mon, 16 Feb 2026 20:16:06 +0900 Subject: [PATCH 11/50] =?UTF-8?q?feat(brand):=20REST=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BrandFacade 구현 (Service 조율, @Transactional 경계) - BrandInfo 구현 (record, from(BrandModel) 팩토리) - BrandV1Controller 구현 (POST, DELETE 엔드포인트) - BrandV1Dto 구현 (CreateBrandRequest, BrandResponse) - BrandV1ApiSpec 구현 (OpenAPI 명세) - E2E 테스트 작성 (브랜드 생성→삭제 플로우) - .http/brand.http 작성 (수동 테스트용) --- .http/brand.http | 32 +++++ .../application/brand/BrandFacade.java | 24 ++++ .../loopers/application/brand/BrandInfo.java | 18 +++ .../interfaces/api/brand/BrandV1ApiSpec.java | 31 +++++ .../api/brand/BrandV1Controller.java | 33 +++++ .../interfaces/api/brand/BrandV1Dto.java | 26 ++++ .../api/brand/BrandV1ControllerE2ETest.java | 127 ++++++++++++++++++ 7 files changed, 291 insertions(+) create mode 100644 .http/brand.http create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ControllerE2ETest.java diff --git a/.http/brand.http b/.http/brand.http new file mode 100644 index 000000000..0add6a61e --- /dev/null +++ b/.http/brand.http @@ -0,0 +1,32 @@ +### 브랜드 생성 +POST http://localhost:8080/api/v1/brands +Content-Type: application/json + +{ + "brandId": "nike", + "brandName": "Nike" +} + +### 브랜드 생성 - Adidas +POST http://localhost:8080/api/v1/brands +Content-Type: application/json + +{ + "brandId": "adidas", + "brandName": "Adidas" +} + +### 브랜드 생성 - 중복 ID (실패 케이스) +POST http://localhost:8080/api/v1/brands +Content-Type: application/json + +{ + "brandId": "nike", + "brandName": "Nike Duplicate" +} + +### 브랜드 삭제 +DELETE http://localhost:8080/api/v1/brands/nike + +### 브랜드 삭제 - 존재하지 않는 브랜드 (실패 케이스) +DELETE http://localhost:8080/api/v1/brands/nonexistent diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java new file mode 100644 index 000000000..b203b8ee6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -0,0 +1,24 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class BrandFacade { + private final BrandService brandService; + + @Transactional + public BrandInfo createBrand(String brandId, String brandName) { + BrandModel brand = brandService.createBrand(brandId, brandName); + return BrandInfo.from(brand); + } + + @Transactional + public void deleteBrand(String brandId) { + brandService.deleteBrand(brandId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java new file mode 100644 index 000000000..3d27a3215 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java @@ -0,0 +1,18 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.BrandModel; + +public record BrandInfo( + Long id, + String brandId, + String brandName +) { + + public static BrandInfo from(BrandModel brand) { + return new BrandInfo( + brand.getId(), + brand.getBrandId().value(), + brand.getBrandName().value() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1ApiSpec.java new file mode 100644 index 000000000..05cf37a4d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1ApiSpec.java @@ -0,0 +1,31 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "브랜드 관리 API", description = "브랜드 관련 API") +public interface BrandV1ApiSpec { + + @Operation( + summary = "브랜드 생성", + description = "새로운 브랜드를 생성합니다." + ) + ApiResponse createBrand( + @Schema(name = "브랜드 생성 요청 DTO", description = "브랜드 생성에 필요한 정보를 담고 있는 DTO") + @Valid @RequestBody BrandV1Dto.CreateBrandRequest request + ); + + @Operation( + summary = "브랜드 삭제", + description = "브랜드를 삭제합니다 (soft delete). 상품이 참조하고 있는 경우 삭제할 수 없습니다." + ) + ApiResponse deleteBrand( + @Parameter(description = "브랜드 ID") @PathVariable String brandId + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java new file mode 100644 index 000000000..fc87c170b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java @@ -0,0 +1,33 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandFacade; +import com.loopers.application.brand.BrandInfo; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/brands") +public class BrandV1Controller implements BrandV1ApiSpec { + + private final BrandFacade brandFacade; + + @PostMapping + @Override + public ApiResponse createBrand( + @Valid @RequestBody BrandV1Dto.CreateBrandRequest request + ) { + BrandInfo info = brandFacade.createBrand(request.brandId(), request.brandName()); + BrandV1Dto.BrandResponse response = BrandV1Dto.BrandResponse.fromInfo(info); + return ApiResponse.success(response); + } + + @DeleteMapping("/{brandId}") + @Override + public ApiResponse deleteBrand(@PathVariable String brandId) { + brandFacade.deleteBrand(brandId); + return ApiResponse.success(null); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java new file mode 100644 index 000000000..5c81aba9c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java @@ -0,0 +1,26 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandInfo; +import jakarta.validation.constraints.NotBlank; + +public class BrandV1Dto { + + public record CreateBrandRequest( + @NotBlank String brandId, + @NotBlank String brandName + ) {} + + public record BrandResponse( + Long id, + String brandId, + String brandName + ) { + public static BrandResponse fromInfo(BrandInfo info) { + return new BrandResponse( + info.id(), + info.brandId(), + info.brandName() + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ControllerE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ControllerE2ETest.java new file mode 100644 index 000000000..d9193ebcb --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ControllerE2ETest.java @@ -0,0 +1,127 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +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.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DisplayName("BrandV1Controller E2E 테스트") +class BrandV1ControllerE2ETest { + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Test + @DisplayName("브랜드 생성 → 삭제 전체 플로우") + void brandLifecycle() { + // given + BrandV1Dto.CreateBrandRequest createRequest = new BrandV1Dto.CreateBrandRequest("nike", "Nike"); + + // when - 브랜드 생성 + ResponseEntity createResponse = restTemplate.postForEntity( + "/api/v1/brands", + createRequest, + ApiResponse.class + ); + + // then - 생성 성공 + assertAll( + () -> assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(createResponse.getBody()).isNotNull(), + () -> assertThat(createResponse.getBody().success()).isEqualTo(true) + ); + + // when - 브랜드 삭제 + ResponseEntity deleteResponse = restTemplate.exchange( + "/api/v1/brands/nike", + HttpMethod.DELETE, + null, + ApiResponse.class + ); + + // then - 삭제 성공 + assertAll( + () -> assertThat(deleteResponse.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(deleteResponse.getBody()).isNotNull(), + () -> assertThat(deleteResponse.getBody().success()).isEqualTo(true) + ); + } + + @Test + @DisplayName("중복된 브랜드 ID로 생성 시 409 Conflict") + void createBrand_duplicate_returns409() { + // given + BrandV1Dto.CreateBrandRequest request = new BrandV1Dto.CreateBrandRequest("adidas", "Adidas"); + restTemplate.postForEntity("/api/v1/brands", request, ApiResponse.class); + + // when - 동일한 ID로 재생성 + ResponseEntity response = restTemplate.postForEntity( + "/api/v1/brands", + request, + ApiResponse.class + ); + + // then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().success()).isEqualTo(false) + ); + } + + @Test + @DisplayName("존재하지 않는 브랜드 삭제 시 404 Not Found") + void deleteBrand_notFound_returns404() { + // when + ResponseEntity response = restTemplate.exchange( + "/api/v1/brands/nonexistent", + HttpMethod.DELETE, + null, + ApiResponse.class + ); + + // then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().success()).isEqualTo(false) + ); + } + + @Test + @DisplayName("유효하지 않은 요청 데이터로 생성 시 400 Bad Request") + void createBrand_invalidRequest_returns400() { + // given - brandId가 빈 문자열 + BrandV1Dto.CreateBrandRequest invalidRequest = new BrandV1Dto.CreateBrandRequest("", "Nike"); + + // when + ResponseEntity response = restTemplate.postForEntity( + "/api/v1/brands", + invalidRequest, + ApiResponse.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } +} From 3bafbec8149ecb6f502c4905e68a6f8a2dde4274 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B8=B0=EB=AC=98=ED=95=9C=20=EC=A3=BC=EB=8B=88=EC=96=B4?= =?UTF-8?q?=20=EA=B0=9C=EB=B0=9C=EC=9E=90?= Date: Mon, 16 Feb 2026 20:21:33 +0900 Subject: [PATCH 12/50] =?UTF-8?q?feat(product):=20VO=20=EB=B0=8F=20Entity?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProductId VO 구현 (영문+숫자, 1-20자 검증) - ProductName VO 구현 (1-100자 검증) - Price VO 구현 (BigDecimal, 음수 불가, scale 2) - StockQuantity VO 구현 (int, 음수 불가) - ProductModel Entity 구현 (BaseEntity 상속, BrandId FK, soft delete 지원) - 재고 관리 메서드 구현 (decreaseStock, increaseStock) - JPA Converter 구현 (ProductId, ProductName, Price, StockQuantity) - 단위 테스트 작성 (VO 검증 규칙, Entity 도메인 메서드) --- .../loopers/domain/product/ProductModel.java | 78 +++++++++++++ .../com/loopers/domain/product/vo/Price.java | 23 ++++ .../loopers/domain/product/vo/ProductId.java | 23 ++++ .../domain/product/vo/ProductName.java | 18 +++ .../domain/product/vo/StockQuantity.java | 13 +++ .../jpa/converter/PriceConverter.java | 21 ++++ .../jpa/converter/ProductIdConverter.java | 19 ++++ .../jpa/converter/ProductNameConverter.java | 19 ++++ .../jpa/converter/StockQuantityConverter.java | 19 ++++ .../domain/product/ProductModelTest.java | 107 ++++++++++++++++++ .../loopers/domain/product/vo/PriceTest.java | 56 +++++++++ .../domain/product/vo/ProductIdTest.java | 60 ++++++++++ .../domain/product/vo/ProductNameTest.java | 63 +++++++++++ .../domain/product/vo/StockQuantityTest.java | 34 ++++++ 14 files changed, 553 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/vo/Price.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/vo/ProductId.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/vo/ProductName.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/vo/StockQuantity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/PriceConverter.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/ProductIdConverter.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/ProductNameConverter.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/StockQuantityConverter.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/vo/PriceTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/vo/ProductIdTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/vo/ProductNameTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/vo/StockQuantityTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java new file mode 100644 index 000000000..1e89c489a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java @@ -0,0 +1,78 @@ +package com.loopers.domain.product; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.brand.vo.BrandId; +import com.loopers.domain.product.vo.Price; +import com.loopers.domain.product.vo.ProductId; +import com.loopers.domain.product.vo.ProductName; +import com.loopers.domain.product.vo.StockQuantity; +import com.loopers.infrastructure.jpa.converter.BrandIdConverter; +import com.loopers.infrastructure.jpa.converter.PriceConverter; +import com.loopers.infrastructure.jpa.converter.ProductIdConverter; +import com.loopers.infrastructure.jpa.converter.ProductNameConverter; +import com.loopers.infrastructure.jpa.converter.StockQuantityConverter; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; +import lombok.Getter; + +import java.math.BigDecimal; + +@Entity +@Table(name = "products") +@Getter +public class ProductModel extends BaseEntity { + + @Convert(converter = ProductIdConverter.class) + @Column(name = "product_id", nullable = false, unique = true, length = 20) + private ProductId productId; + + @Convert(converter = BrandIdConverter.class) + @Column(name = "brand_id", nullable = false, length = 10) + private BrandId brandId; + + @Convert(converter = ProductNameConverter.class) + @Column(name = "product_name", nullable = false, length = 100) + private ProductName productName; + + @Convert(converter = PriceConverter.class) + @Column(name = "price", nullable = false, precision = 10, scale = 2) + private Price price; + + @Convert(converter = StockQuantityConverter.class) + @Column(name = "stock_quantity", nullable = false) + private StockQuantity stockQuantity; + + protected ProductModel() {} + + private ProductModel(String productId, String brandId, String productName, BigDecimal price, int stockQuantity) { + this.productId = new ProductId(productId); + this.brandId = new BrandId(brandId); + this.productName = new ProductName(productName); + this.price = new Price(price); + this.stockQuantity = new StockQuantity(stockQuantity); + } + + public static ProductModel create(String productId, String brandId, String productName, BigDecimal price, int stockQuantity) { + return new ProductModel(productId, brandId, productName, price, stockQuantity); + } + + public void decreaseStock(int quantity) { + if (this.stockQuantity.value() < quantity) { + throw new CoreException(ErrorType.CONFLICT, "재고가 부족합니다. 현재 재고: " + this.stockQuantity.value() + ", 요청 수량: " + quantity); + } + this.stockQuantity = new StockQuantity(this.stockQuantity.value() - quantity); + } + + public void increaseStock(int quantity) { + this.stockQuantity = new StockQuantity(this.stockQuantity.value() + quantity); + } + + public void markAsDeleted() { + delete(); + } + + public boolean isDeleted() { + return getDeletedAt() != null; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/vo/Price.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/vo/Price.java new file mode 100644 index 000000000..a52cee998 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/vo/Price.java @@ -0,0 +1,23 @@ +package com.loopers.domain.product.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +public record Price(BigDecimal value) { + + public Price { + if (value == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격이 비어 있습니다"); + } + + if (value.compareTo(BigDecimal.ZERO) < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 0 이상이어야 합니다: " + value); + } + + // scale을 2로 설정 (소수점 2자리, 반올림) + value = value.setScale(2, RoundingMode.HALF_UP); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/vo/ProductId.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/vo/ProductId.java new file mode 100644 index 000000000..9447d89b4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/vo/ProductId.java @@ -0,0 +1,23 @@ +package com.loopers.domain.product.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.util.regex.Pattern; + +public record ProductId(String value) { + + // 영문+숫자, 1~20자 + private static final Pattern PATTERN = Pattern.compile("^[A-Za-z0-9]{1,20}$"); + + public ProductId { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "productId가 비어 있습니다"); + } + value = value.trim(); + + if (!PATTERN.matcher(value).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "productId는 영문+숫자, 1~20자로 이루어져야 합니다: " + value); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/vo/ProductName.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/vo/ProductName.java new file mode 100644 index 000000000..e46f663e7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/vo/ProductName.java @@ -0,0 +1,18 @@ +package com.loopers.domain.product.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +public record ProductName(String value) { + + public ProductName { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품명이 비어 있습니다"); + } + value = value.trim(); + + if (value.isEmpty() || value.length() > 100) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품명 길이는 1자 이상 100자 이하여야 합니다: " + value.length()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/vo/StockQuantity.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/vo/StockQuantity.java new file mode 100644 index 000000000..666bb2f7e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/vo/StockQuantity.java @@ -0,0 +1,13 @@ +package com.loopers.domain.product.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +public record StockQuantity(int value) { + + public StockQuantity { + if (value < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고 수량은 0 이상이어야 합니다: " + value); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/PriceConverter.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/PriceConverter.java new file mode 100644 index 000000000..21169d7cb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/PriceConverter.java @@ -0,0 +1,21 @@ +package com.loopers.infrastructure.jpa.converter; + +import com.loopers.domain.product.vo.Price; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +import java.math.BigDecimal; + +@Converter(autoApply = false) +public class PriceConverter implements AttributeConverter { + + @Override + public BigDecimal convertToDatabaseColumn(Price attribute) { + return attribute == null ? null : attribute.value(); + } + + @Override + public Price convertToEntityAttribute(BigDecimal dbData) { + return dbData == null ? null : new Price(dbData); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/ProductIdConverter.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/ProductIdConverter.java new file mode 100644 index 000000000..4d61bf345 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/ProductIdConverter.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.jpa.converter; + +import com.loopers.domain.product.vo.ProductId; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter(autoApply = false) +public class ProductIdConverter implements AttributeConverter { + + @Override + public String convertToDatabaseColumn(ProductId attribute) { + return attribute == null ? null : attribute.value(); + } + + @Override + public ProductId convertToEntityAttribute(String dbData) { + return dbData == null ? null : new ProductId(dbData); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/ProductNameConverter.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/ProductNameConverter.java new file mode 100644 index 000000000..bc24c5f4a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/ProductNameConverter.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.jpa.converter; + +import com.loopers.domain.product.vo.ProductName; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter(autoApply = false) +public class ProductNameConverter implements AttributeConverter { + + @Override + public String convertToDatabaseColumn(ProductName attribute) { + return attribute == null ? null : attribute.value(); + } + + @Override + public ProductName convertToEntityAttribute(String dbData) { + return dbData == null ? null : new ProductName(dbData); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/StockQuantityConverter.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/StockQuantityConverter.java new file mode 100644 index 000000000..947e29446 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/StockQuantityConverter.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.jpa.converter; + +import com.loopers.domain.product.vo.StockQuantity; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter(autoApply = false) +public class StockQuantityConverter implements AttributeConverter { + + @Override + public Integer convertToDatabaseColumn(StockQuantity attribute) { + return attribute == null ? null : attribute.value(); + } + + @Override + public StockQuantity convertToEntityAttribute(Integer dbData) { + return dbData == null ? null : new StockQuantity(dbData); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java new file mode 100644 index 000000000..0e282e897 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java @@ -0,0 +1,107 @@ +package com.loopers.domain.product; + +import com.loopers.domain.brand.vo.BrandId; +import com.loopers.domain.product.vo.Price; +import com.loopers.domain.product.vo.ProductId; +import com.loopers.domain.product.vo.ProductName; +import com.loopers.domain.product.vo.StockQuantity; +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("ProductModel Entity") +class ProductModelTest { + + @Test + @DisplayName("create() 정적 팩토리로 ProductModel 생성") + void create_product_model() { + // given + String productId = "prod1"; + String brandId = "nike"; + String productName = "Nike Air Max"; + BigDecimal price = new BigDecimal("150000"); + int stockQuantity = 100; + + // when + ProductModel product = ProductModel.create(productId, brandId, productName, price, stockQuantity); + + // then + assertThat(product.getProductId()).isEqualTo(new ProductId(productId)); + assertThat(product.getBrandId()).isEqualTo(new BrandId(brandId)); + assertThat(product.getProductName()).isEqualTo(new ProductName(productName)); + assertThat(product.getPrice().value()).isEqualByComparingTo(price.setScale(2, java.math.RoundingMode.HALF_UP)); + assertThat(product.getStockQuantity().value()).isEqualTo(stockQuantity); + assertThat(product.isDeleted()).isFalse(); + } + + @Test + @DisplayName("decreaseStock() 성공 - 재고 차감") + void decreaseStock_success() { + // given + ProductModel product = ProductModel.create("prod1", "nike", "Nike Air", new BigDecimal("100000"), 50); + + // when + product.decreaseStock(10); + + // then + assertThat(product.getStockQuantity().value()).isEqualTo(40); + } + + @Test + @DisplayName("decreaseStock() 재고 부족 시 예외 발생") + void decreaseStock_insufficient_stock_throws_exception() { + // given + ProductModel product = ProductModel.create("prod1", "nike", "Nike Air", new BigDecimal("100000"), 5); + + // when & then + assertThatThrownBy(() -> product.decreaseStock(10)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("재고가 부족합니다"); + } + + @Test + @DisplayName("decreaseStock() 0개 차감 시 재고 변화 없음") + void decreaseStock_zero_does_not_change_stock() { + // given + ProductModel product = ProductModel.create("prod1", "nike", "Nike Air", new BigDecimal("100000"), 50); + + // when + product.decreaseStock(0); + + // then + assertThat(product.getStockQuantity().value()).isEqualTo(50); + } + + @Test + @DisplayName("increaseStock() 성공 - 재고 증가") + void increaseStock_success() { + // given + ProductModel product = ProductModel.create("prod1", "nike", "Nike Air", new BigDecimal("100000"), 50); + + // when + product.increaseStock(20); + + // then + assertThat(product.getStockQuantity().value()).isEqualTo(70); + } + + @Test + @DisplayName("markAsDeleted() 호출 시 deletedAt 설정됨") + void mark_as_deleted_sets_deletedAt() { + // given + ProductModel product = ProductModel.create("prod1", "nike", "Nike Air", new BigDecimal("100000"), 50); + assertThat(product.isDeleted()).isFalse(); + + // when + product.markAsDeleted(); + + // then + assertThat(product.isDeleted()).isTrue(); + assertThat(product.getDeletedAt()).isNotNull(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/vo/PriceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/vo/PriceTest.java new file mode 100644 index 000000000..19ab2655e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/vo/PriceTest.java @@ -0,0 +1,56 @@ +package com.loopers.domain.product.vo; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("Price VO") +class PriceTest { + + @Test + @DisplayName("유효한 Price 생성 성공 - 음수 불가, scale 2") + void create_valid_price() { + // given & when + Price price1 = new Price(new BigDecimal("1000")); + Price price2 = new Price(new BigDecimal("0")); + Price price3 = new Price(new BigDecimal("99999.99")); + + // then + assertThat(price1.value()).isEqualByComparingTo(new BigDecimal("1000.00")); + assertThat(price2.value()).isEqualByComparingTo(new BigDecimal("0.00")); + assertThat(price3.value()).isEqualByComparingTo(new BigDecimal("99999.99")); + assertThat(price1.value().scale()).isEqualTo(2); + } + + @Test + @DisplayName("null이면 예외 발생") + void null_price_throws_exception() { + assertThatThrownBy(() -> new Price(null)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("가격이 비어 있습니다"); + } + + @Test + @DisplayName("음수이면 예외 발생") + void negative_price_throws_exception() { + assertThatThrownBy(() -> new Price(new BigDecimal("-1"))) + .isInstanceOf(CoreException.class) + .hasMessageContaining("가격은 0 이상이어야 합니다"); + } + + @Test + @DisplayName("소수점 3자리 이상은 반올림되어 2자리로 저장됨") + void price_with_more_than_2_decimals_is_rounded() { + // given & when + Price price = new Price(new BigDecimal("1234.567")); + + // then + assertThat(price.value()).isEqualByComparingTo(new BigDecimal("1234.57")); + assertThat(price.value().scale()).isEqualTo(2); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/vo/ProductIdTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/vo/ProductIdTest.java new file mode 100644 index 000000000..51707a2f7 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/vo/ProductIdTest.java @@ -0,0 +1,60 @@ +package com.loopers.domain.product.vo; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("ProductId VO") +class ProductIdTest { + + @Test + @DisplayName("유효한 ProductId 생성 성공 - 영문+숫자 1-20자") + void create_valid_productId() { + // given & when + ProductId productId1 = new ProductId("prod1"); + ProductId productId2 = new ProductId("PRODUCT123"); + ProductId productId3 = new ProductId("p"); + ProductId productId4 = new ProductId("12345678901234567890"); + + // then + assertThat(productId1.value()).isEqualTo("prod1"); + assertThat(productId2.value()).isEqualTo("PRODUCT123"); + assertThat(productId3.value()).isEqualTo("p"); + assertThat(productId4.value()).isEqualTo("12345678901234567890"); + } + + @Test + @DisplayName("null이면 예외 발생") + void null_productId_throws_exception() { + assertThatThrownBy(() -> new ProductId(null)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("productId가 비어 있습니다"); + } + + @Test + @DisplayName("빈 문자열이면 예외 발생") + void empty_productId_throws_exception() { + assertThatThrownBy(() -> new ProductId("")) + .isInstanceOf(CoreException.class) + .hasMessageContaining("productId가 비어 있습니다"); + } + + @Test + @DisplayName("21자 이상이면 예외 발생") + void productId_longer_than_20_throws_exception() { + assertThatThrownBy(() -> new ProductId("123456789012345678901")) + .isInstanceOf(CoreException.class) + .hasMessageContaining("영문+숫자, 1~20자"); + } + + @Test + @DisplayName("특수문자 포함 시 예외 발생") + void productId_with_special_characters_throws_exception() { + assertThatThrownBy(() -> new ProductId("prod-1")) + .isInstanceOf(CoreException.class) + .hasMessageContaining("영문+숫자, 1~20자"); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/vo/ProductNameTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/vo/ProductNameTest.java new file mode 100644 index 000000000..b4abf6d49 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/vo/ProductNameTest.java @@ -0,0 +1,63 @@ +package com.loopers.domain.product.vo; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("ProductName VO") +class ProductNameTest { + + @Test + @DisplayName("유효한 ProductName 생성 성공 - 1-100자") + void create_valid_productName() { + // given & when + ProductName productName1 = new ProductName("Nike Air Max"); + ProductName productName2 = new ProductName("갤럭시 S24"); + ProductName productName3 = new ProductName("A"); + ProductName productName4 = new ProductName("A".repeat(100)); + + // then + assertThat(productName1.value()).isEqualTo("Nike Air Max"); + assertThat(productName2.value()).isEqualTo("갤럭시 S24"); + assertThat(productName3.value()).isEqualTo("A"); + assertThat(productName4.value()).hasSize(100); + } + + @Test + @DisplayName("null이면 예외 발생") + void null_productName_throws_exception() { + assertThatThrownBy(() -> new ProductName(null)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("상품명이 비어 있습니다"); + } + + @Test + @DisplayName("빈 문자열이면 예외 발생") + void empty_productName_throws_exception() { + assertThatThrownBy(() -> new ProductName("")) + .isInstanceOf(CoreException.class) + .hasMessageContaining("상품명이 비어 있습니다"); + } + + @Test + @DisplayName("101자 이상이면 예외 발생") + void productName_longer_than_100_throws_exception() { + String longName = "A".repeat(101); + assertThatThrownBy(() -> new ProductName(longName)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("상품명 길이는 1자 이상 100자 이하여야 합니다"); + } + + @Test + @DisplayName("앞뒤 공백은 trim 처리됨") + void productName_with_leading_trailing_spaces_is_trimmed() { + // given & when + ProductName productName = new ProductName(" Nike Air "); + + // then + assertThat(productName.value()).isEqualTo("Nike Air"); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/vo/StockQuantityTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/vo/StockQuantityTest.java new file mode 100644 index 000000000..964ca1607 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/vo/StockQuantityTest.java @@ -0,0 +1,34 @@ +package com.loopers.domain.product.vo; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("StockQuantity VO") +class StockQuantityTest { + + @Test + @DisplayName("유효한 StockQuantity 생성 성공 - 음수 불가") + void create_valid_stockQuantity() { + // given & when + StockQuantity quantity1 = new StockQuantity(0); + StockQuantity quantity2 = new StockQuantity(100); + StockQuantity quantity3 = new StockQuantity(999999); + + // then + assertThat(quantity1.value()).isEqualTo(0); + assertThat(quantity2.value()).isEqualTo(100); + assertThat(quantity3.value()).isEqualTo(999999); + } + + @Test + @DisplayName("음수이면 예외 발생") + void negative_stockQuantity_throws_exception() { + assertThatThrownBy(() -> new StockQuantity(-1)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("재고 수량은 0 이상이어야 합니다"); + } +} From a17d7e76bd5898ebb7af8d8282d4940da745a638 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B8=B0=EB=AC=98=ED=95=9C=20=EC=A3=BC=EB=8B=88=EC=96=B4?= =?UTF-8?q?=20=EA=B0=9C=EB=B0=9C=EC=9E=90?= Date: Mon, 16 Feb 2026 20:38:05 +0900 Subject: [PATCH 13/50] =?UTF-8?q?refactor:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=ED=98=95=EC=8B=9D=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B4=80=EB=A0=A8=20SKILL=20?= =?UTF-8?q?=EC=88=98=EC=A0=95.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/skills/testing/SKILL.md | 125 +++++++++++++++++ .../loopers/domain/brand/BrandModelTest.java | 129 ++++++++++-------- .../domain/product/ProductModelTest.java | 93 +++++++------ 3 files changed, 249 insertions(+), 98 deletions(-) diff --git a/.claude/skills/testing/SKILL.md b/.claude/skills/testing/SKILL.md index 0d3eddb97..37ae2c1ea 100644 --- a/.claude/skills/testing/SKILL.md +++ b/.claude/skills/testing/SKILL.md @@ -45,6 +45,131 @@ void testExample() { } ``` +**주석 규칙:** +- 단위/통합 테스트: `// given`, `// when`, `// then` +- E2E 테스트: `// arrange`, `// act`, `// assert` + +### @Nested 테스트 구조 패턴 + +**목적:** 관련된 테스트를 논리적으로 그룹화하여 가독성 향상 + +**구조:** +```java +@DisplayName("{Entity} 엔티티") +class EntityTest { + + @DisplayName("{행위}를 할 때,") + @Nested + class ContextGroup { + @Test + @DisplayName("{조건}이면 {결과}") + void test_method_name() { + // given: 테스트 데이터 준비 + + // when: 테스트 대상 실행 + + // then: 결과 검증 + } + } +} +``` + +**네이밍 컨벤션:** +- **테스트 클래스 DisplayName**: `"{Entity} 엔티티"` 또는 `"{Domain} {레이어}"` +- **@Nested 클래스명**: 영어 명사/동사 (Create, Delete, DecreaseStock 등) +- **@Nested DisplayName**: 한글 동사구 + 쉼표 (예: `"브랜드를 생성할 때,"`, `"재고를 차감할 때,"`) +- **테스트 메서드명**: 영어 snake_case (예: `create_brand_model`, `decreaseStock_success`) +- **테스트 메서드 DisplayName**: 한글 명사구 (예: `"create() 정적 팩토리로 BrandModel 생성 성공"`) + +**실제 예시 - Entity 테스트:** +```java +@DisplayName("ProductModel Entity") +class ProductModelTest { + + @DisplayName("상품을 생성할 때,") + @Nested + class Create { + @Test + @DisplayName("create() 정적 팩토리로 ProductModel 생성 성공") + void create_product_model() { + // given + String productId = "prod1"; + String brandId = "nike"; + String productName = "Nike Air Max"; + BigDecimal price = new BigDecimal("150000"); + int stockQuantity = 100; + + // when + ProductModel product = ProductModel.create(productId, brandId, productName, price, stockQuantity); + + // then + assertThat(product.getProductId()).isEqualTo(new ProductId(productId)); + assertThat(product.getBrandId()).isEqualTo(new BrandId(brandId)); + assertThat(product.getStockQuantity().value()).isEqualTo(stockQuantity); + } + } + + @DisplayName("재고를 차감할 때,") + @Nested + class DecreaseStock { + @Test + @DisplayName("재고가 충분하면 차감 성공") + void decreaseStock_success() { + // given + ProductModel product = ProductModel.create("prod1", "nike", "Nike Air", new BigDecimal("100000"), 50); + + // when + product.decreaseStock(10); + + // then + assertThat(product.getStockQuantity().value()).isEqualTo(40); + } + + @Test + @DisplayName("재고가 부족하면 예외 발생") + void decreaseStock_insufficient_stock_throws_exception() { + // given + ProductModel product = ProductModel.create("prod1", "nike", "Nike Air", new BigDecimal("100000"), 5); + + // when & then + assertThatThrownBy(() -> product.decreaseStock(10)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("재고가 부족합니다"); + } + } + + @DisplayName("상품을 삭제할 때,") + @Nested + class Delete { + @Test + @DisplayName("markAsDeleted() 호출 시 deletedAt 설정됨") + void mark_as_deleted_sets_deletedAt() { + // given + ProductModel product = ProductModel.create("prod1", "nike", "Nike Air", new BigDecimal("100000"), 50); + assertThat(product.isDeleted()).isFalse(); + + // when + product.markAsDeleted(); + + // then + assertThat(product.isDeleted()).isTrue(); + assertThat(product.getDeletedAt()).isNotNull(); + } + } +} +``` + +**@Nested 사용 시기:** +- ✅ Entity 테스트: 도메인 행위별 그룹화 (Create, Update, Delete 등) +- ✅ Service/Facade 통합 테스트: 유스케이스별 그룹화 (Register, Login 등) +- ✅ Controller E2E 테스트: 엔드포인트별 그룹화 (POST /api/v1/members 등) +- ❌ Value Object 단위 테스트: 단순한 경우 @Nested 불필요 + +**장점:** +- 테스트 리포트의 계층 구조로 가독성 향상 +- 관련 테스트끼리 논리적으로 묶어 관리 용이 +- 각 @Nested 클래스에서 공통 setup 가능 (@BeforeEach) + --- ## 단위 테스트 (Unit Test) diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandModelTest.java index bbb5508a1..bcbd55cc7 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandModelTest.java @@ -3,6 +3,7 @@ import com.loopers.domain.brand.vo.BrandId; import com.loopers.domain.brand.vo.BrandName; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import java.time.ZonedDateTime; @@ -12,67 +13,75 @@ @DisplayName("BrandModel Entity") class BrandModelTest { - @Test - @DisplayName("create() 정적 팩토리로 BrandModel 생성") - void create_brand_model() { - // given - String brandId = "nike"; - String brandName = "Nike"; - - // when - BrandModel brand = BrandModel.create(brandId, brandName); - - // then - assertThat(brand.getBrandId()).isEqualTo(new BrandId(brandId)); - assertThat(brand.getBrandName()).isEqualTo(new BrandName(brandName)); - assertThat(brand.getDeletedAt()).isNull(); - assertThat(brand.isDeleted()).isFalse(); + @DisplayName("브랜드를 생성할 때,") + @Nested + class Create { + @Test + @DisplayName("create() 정적 팩토리로 BrandModel 생성 성공") + void create_brand_model() { + // given + String brandId = "nike"; + String brandName = "Nike"; + + // when + BrandModel brand = BrandModel.create(brandId, brandName); + + // then + assertThat(brand.getBrandId()).isEqualTo(new BrandId(brandId)); + assertThat(brand.getBrandName()).isEqualTo(new BrandName(brandName)); + assertThat(brand.getDeletedAt()).isNull(); + assertThat(brand.isDeleted()).isFalse(); + } } - @Test - @DisplayName("markAsDeleted() 호출 시 deletedAt 설정됨") - void mark_as_deleted_sets_deletedAt() { - // given - BrandModel brand = BrandModel.create("adidas", "Adidas"); - assertThat(brand.getDeletedAt()).isNull(); - - // when - ZonedDateTime beforeDelete = ZonedDateTime.now(); - brand.markAsDeleted(); - ZonedDateTime afterDelete = ZonedDateTime.now(); - - // then - assertThat(brand.getDeletedAt()).isNotNull(); - assertThat(brand.getDeletedAt()) - .isAfterOrEqualTo(beforeDelete) - .isBeforeOrEqualTo(afterDelete); - } - - @Test - @DisplayName("isDeleted()는 deletedAt이 null이 아니면 true 반환") - void isDeleted_returns_true_when_deletedAt_is_not_null() { - // given - BrandModel brand = BrandModel.create("puma", "Puma"); - - // when & then - assertThat(brand.isDeleted()).isFalse(); - - brand.markAsDeleted(); - assertThat(brand.isDeleted()).isTrue(); - } - - @Test - @DisplayName("markAsDeleted() 중복 호출 시 deletedAt 변경되지 않음 (멱등성)") - void markAsDeleted_idempotent() { - // given - BrandModel brand = BrandModel.create("reebok", "Reebok"); - brand.markAsDeleted(); - ZonedDateTime firstDeletedAt = brand.getDeletedAt(); - - // when - brand.markAsDeleted(); - - // then - assertThat(brand.getDeletedAt()).isEqualTo(firstDeletedAt); + @DisplayName("브랜드를 삭제할 때,") + @Nested + class Delete { + @Test + @DisplayName("markAsDeleted() 호출 시 deletedAt 설정됨") + void mark_as_deleted_sets_deletedAt() { + // given + BrandModel brand = BrandModel.create("adidas", "Adidas"); + assertThat(brand.getDeletedAt()).isNull(); + + // when + ZonedDateTime beforeDelete = ZonedDateTime.now(); + brand.markAsDeleted(); + ZonedDateTime afterDelete = ZonedDateTime.now(); + + // then + assertThat(brand.getDeletedAt()).isNotNull(); + assertThat(brand.getDeletedAt()) + .isAfterOrEqualTo(beforeDelete) + .isBeforeOrEqualTo(afterDelete); + } + + @Test + @DisplayName("isDeleted()는 deletedAt이 null이 아니면 true 반환") + void isDeleted_returns_true_when_deletedAt_is_not_null() { + // given + BrandModel brand = BrandModel.create("puma", "Puma"); + + // when & then + assertThat(brand.isDeleted()).isFalse(); + + brand.markAsDeleted(); + assertThat(brand.isDeleted()).isTrue(); + } + + @Test + @DisplayName("markAsDeleted() 중복 호출 시 deletedAt 변경되지 않음 (멱등성)") + void markAsDeleted_idempotent() { + // given + BrandModel brand = BrandModel.create("reebok", "Reebok"); + brand.markAsDeleted(); + ZonedDateTime firstDeletedAt = brand.getDeletedAt(); + + // when + brand.markAsDeleted(); + + // then + assertThat(brand.getDeletedAt()).isEqualTo(firstDeletedAt); + } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java index 0e282e897..44893e12b 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java @@ -7,6 +7,7 @@ import com.loopers.domain.product.vo.StockQuantity; import com.loopers.support.error.CoreException; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import java.math.BigDecimal; @@ -17,9 +18,12 @@ @DisplayName("ProductModel Entity") class ProductModelTest { - @Test - @DisplayName("create() 정적 팩토리로 ProductModel 생성") - void create_product_model() { + @DisplayName("상품을 생성할 때,") + @Nested + class Create { + @Test + @DisplayName("create() 정적 팩토리로 ProductModel 생성 성공") + void create_product_model() { // given String productId = "prod1"; String brandId = "nike"; @@ -37,11 +41,15 @@ void create_product_model() { assertThat(product.getPrice().value()).isEqualByComparingTo(price.setScale(2, java.math.RoundingMode.HALF_UP)); assertThat(product.getStockQuantity().value()).isEqualTo(stockQuantity); assertThat(product.isDeleted()).isFalse(); + } } - @Test - @DisplayName("decreaseStock() 성공 - 재고 차감") - void decreaseStock_success() { + @DisplayName("재고를 차감할 때,") + @Nested + class DecreaseStock { + @Test + @DisplayName("재고가 충분하면 차감 성공") + void decreaseStock_success() { // given ProductModel product = ProductModel.create("prod1", "nike", "Nike Air", new BigDecimal("100000"), 50); @@ -50,11 +58,11 @@ void decreaseStock_success() { // then assertThat(product.getStockQuantity().value()).isEqualTo(40); - } + } - @Test - @DisplayName("decreaseStock() 재고 부족 시 예외 발생") - void decreaseStock_insufficient_stock_throws_exception() { + @Test + @DisplayName("재고가 부족하면 예외 발생") + void decreaseStock_insufficient_stock_throws_exception() { // given ProductModel product = ProductModel.create("prod1", "nike", "Nike Air", new BigDecimal("100000"), 5); @@ -62,11 +70,11 @@ void decreaseStock_insufficient_stock_throws_exception() { assertThatThrownBy(() -> product.decreaseStock(10)) .isInstanceOf(CoreException.class) .hasMessageContaining("재고가 부족합니다"); - } + } - @Test - @DisplayName("decreaseStock() 0개 차감 시 재고 변화 없음") - void decreaseStock_zero_does_not_change_stock() { + @Test + @DisplayName("0개 차감 시 재고 변화 없음") + void decreaseStock_zero_does_not_change_stock() { // given ProductModel product = ProductModel.create("prod1", "nike", "Nike Air", new BigDecimal("100000"), 50); @@ -75,33 +83,42 @@ void decreaseStock_zero_does_not_change_stock() { // then assertThat(product.getStockQuantity().value()).isEqualTo(50); + } } - @Test - @DisplayName("increaseStock() 성공 - 재고 증가") - void increaseStock_success() { - // given - ProductModel product = ProductModel.create("prod1", "nike", "Nike Air", new BigDecimal("100000"), 50); - - // when - product.increaseStock(20); - - // then - assertThat(product.getStockQuantity().value()).isEqualTo(70); + @DisplayName("재고를 증가할 때,") + @Nested + class IncreaseStock { + @Test + @DisplayName("재고 증가 성공") + void increaseStock_success() { + // given + ProductModel product = ProductModel.create("prod1", "nike", "Nike Air", new BigDecimal("100000"), 50); + + // when + product.increaseStock(20); + + // then + assertThat(product.getStockQuantity().value()).isEqualTo(70); + } } - @Test - @DisplayName("markAsDeleted() 호출 시 deletedAt 설정됨") - void mark_as_deleted_sets_deletedAt() { - // given - ProductModel product = ProductModel.create("prod1", "nike", "Nike Air", new BigDecimal("100000"), 50); - assertThat(product.isDeleted()).isFalse(); - - // when - product.markAsDeleted(); - - // then - assertThat(product.isDeleted()).isTrue(); - assertThat(product.getDeletedAt()).isNotNull(); + @DisplayName("상품을 삭제할 때,") + @Nested + class Delete { + @Test + @DisplayName("markAsDeleted() 호출 시 deletedAt 설정됨") + void mark_as_deleted_sets_deletedAt() { + // given + ProductModel product = ProductModel.create("prod1", "nike", "Nike Air", new BigDecimal("100000"), 50); + assertThat(product.isDeleted()).isFalse(); + + // when + product.markAsDeleted(); + + // then + assertThat(product.isDeleted()).isTrue(); + assertThat(product.getDeletedAt()).isNotNull(); + } } } From 62db086ddbfe5904bbb65a282bc87a2cd347d3cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B8=B0=EB=AC=98=ED=95=9C=20=EC=A3=BC=EB=8B=88=EC=96=B4?= =?UTF-8?q?=20=EA=B0=9C=EB=B0=9C=EC=9E=90?= Date: Mon, 16 Feb 2026 20:45:22 +0900 Subject: [PATCH 14/50] =?UTF-8?q?feat(product):=20Service=20=EB=B0=8F=20Re?= =?UTF-8?q?pository=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20=EB=8B=A8=EC=9C=84/?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/domain/product/ProductReader.java | 35 +++ .../domain/product/ProductRepository.java | 18 ++ .../domain/product/ProductService.java | 52 ++++ .../product/ProductJpaRepository.java | 13 + .../product/ProductRepositoryImpl.java | 97 +++++++ .../ProductServiceIntegrationTest.java | 263 ++++++++++++++++++ .../domain/product/ProductServiceTest.java | 158 +++++++++++ 7 files changed, 636 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductReader.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductReader.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductReader.java new file mode 100644 index 000000000..60fb141f0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductReader.java @@ -0,0 +1,35 @@ +package com.loopers.domain.product; + +import com.loopers.domain.brand.vo.BrandId; +import com.loopers.domain.product.vo.ProductId; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +public class ProductReader { + + private final ProductRepository productRepository; + + @Transactional(readOnly = true) + public ProductModel getOrThrow(String productId) { + return productRepository.findByProductId(new ProductId(productId)) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 상품이 존재하지 않습니다.")); + } + + @Transactional(readOnly = true) + public boolean exists(String productId) { + return productRepository.existsByProductId(new ProductId(productId)); + } + + @Transactional(readOnly = true) + public Page findProducts(String brandId, String sortBy, Pageable pageable) { + BrandId brandIdVO = brandId != null ? new BrandId(brandId) : null; + return productRepository.findProducts(brandIdVO, sortBy, pageable); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java new file mode 100644 index 000000000..42bb74769 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -0,0 +1,18 @@ +package com.loopers.domain.product; + +import com.loopers.domain.brand.vo.BrandId; +import com.loopers.domain.product.vo.ProductId; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.Optional; + +public interface ProductRepository { + ProductModel save(ProductModel product); + + Optional findByProductId(ProductId productId); + + boolean existsByProductId(ProductId productId); + + Page findProducts(BrandId brandId, String sortBy, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java new file mode 100644 index 000000000..29778783f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -0,0 +1,52 @@ +package com.loopers.domain.product; + +import com.loopers.domain.brand.BrandReader; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; + +@Service +@RequiredArgsConstructor +public class ProductService { + + private final ProductRepository productRepository; + private final ProductReader productReader; + private final BrandReader brandReader; + + @Transactional + public ProductModel createProduct(String productId, String brandId, String productName, BigDecimal price, int stockQuantity) { + // 중복 체크 + if (productReader.exists(productId)) { + throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 상품 ID입니다."); + } + + // 브랜드 존재 확인 + brandReader.getOrThrow(brandId); + + // 상품 생성 + ProductModel product = ProductModel.create(productId, brandId, productName, price, stockQuantity); + + // 저장 + return productRepository.save(product); + } + + @Transactional + public void deleteProduct(String productId) { + ProductModel product = productReader.getOrThrow(productId); + + // Soft delete + product.markAsDeleted(); + productRepository.save(product); + } + + @Transactional(readOnly = true) + public Page getProducts(String brandId, String sortBy, Pageable pageable) { + return productReader.findProducts(brandId, sortBy, pageable); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java new file mode 100644 index 000000000..01899f050 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -0,0 +1,13 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.vo.ProductId; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface ProductJpaRepository extends JpaRepository { + Optional findByProductId(ProductId productId); + + boolean existsByProductId(ProductId productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java new file mode 100644 index 000000000..c6cce99b7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -0,0 +1,97 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.brand.vo.BrandId; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.vo.ProductId; +import jakarta.persistence.EntityManager; +import jakarta.persistence.TypedQuery; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class ProductRepositoryImpl implements ProductRepository { + + private final ProductJpaRepository productJpaRepository; + private final EntityManager entityManager; + + @Override + public ProductModel save(ProductModel product) { + return productJpaRepository.save(product); + } + + @Override + public Optional findByProductId(ProductId productId) { + return productJpaRepository.findByProductId(productId); + } + + @Override + public boolean existsByProductId(ProductId productId) { + return productJpaRepository.existsByProductId(productId); + } + + @Override + public Page findProducts(BrandId brandId, String sortBy, Pageable pageable) { + // JPQL 쿼리 작성 + StringBuilder jpql = new StringBuilder("SELECT p FROM ProductModel p WHERE p.deletedAt IS NULL"); + + // 브랜드 필터 + if (brandId != null) { + jpql.append(" AND p.brandId = :brandId"); + } + + // 정렬 + jpql.append(getSortClause(sortBy)); + + // 쿼리 실행 + TypedQuery query = entityManager.createQuery(jpql.toString(), ProductModel.class); + if (brandId != null) { + query.setParameter("brandId", brandId); + } + + // 페이징 + query.setFirstResult((int) pageable.getOffset()); + query.setMaxResults(pageable.getPageSize()); + + List products = query.getResultList(); + + // 전체 개수 조회 + long total = countProducts(brandId); + + return new PageImpl<>(products, pageable, total); + } + + private String getSortClause(String sortBy) { + if (sortBy == null || "latest".equals(sortBy)) { + return " ORDER BY p.updatedAt DESC"; + } else if ("price_asc".equals(sortBy)) { + return " ORDER BY p.price ASC"; + } else if ("likes_desc".equals(sortBy)) { + // TODO: Like 도메인 구현 후 LEFT JOIN + COUNT 서브쿼리로 구현 + throw new UnsupportedOperationException("likes_desc 정렬은 아직 구현되지 않았습니다. Like 도메인 구현 후 추가됩니다."); + } + return " ORDER BY p.updatedAt DESC"; // 기본값 + } + + private long countProducts(BrandId brandId) { + StringBuilder jpql = new StringBuilder("SELECT COUNT(p) FROM ProductModel p WHERE p.deletedAt IS NULL"); + + if (brandId != null) { + jpql.append(" AND p.brandId = :brandId"); + } + + TypedQuery query = entityManager.createQuery(jpql.toString(), Long.class); + if (brandId != null) { + query.setParameter("brandId", brandId); + } + + return query.getSingleResult(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java new file mode 100644 index 000000000..024560409 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java @@ -0,0 +1,263 @@ +package com.loopers.domain.product; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandService; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.support.error.CoreException; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +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.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +@DisplayName("ProductService 통합 테스트") +class ProductServiceIntegrationTest { + + @Autowired + private ProductService productService; + + @Autowired + private BrandService brandService; + + @Autowired + private ProductJpaRepository productJpaRepository; + + @Autowired + private BrandJpaRepository brandJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("상품을 생성할 때,") + @Nested + class CreateProduct { + + @Test + @DisplayName("유효한 브랜드로 상품 생성 성공") + void createProduct_withValidBrand_success() { + // given + BrandModel brand = brandService.createBrand("nike", "Nike"); + String productId = "prod1"; + String productName = "Nike Air Max"; + BigDecimal price = new BigDecimal("150000"); + int stockQuantity = 100; + + // when + ProductModel savedProduct = productService.createProduct(productId, brand.getBrandId().value(), productName, price, stockQuantity); + + // then + assertAll( + () -> assertThat(savedProduct).isNotNull(), + () -> assertThat(savedProduct.getId()).isNotNull(), + () -> assertThat(savedProduct.getProductId().value()).isEqualTo(productId), + () -> assertThat(savedProduct.getBrandId().value()).isEqualTo("nike"), + () -> assertThat(savedProduct.getProductName().value()).isEqualTo(productName), + () -> assertThat(savedProduct.getPrice().value()).isEqualByComparingTo(price.setScale(2)), + () -> assertThat(savedProduct.getStockQuantity().value()).isEqualTo(stockQuantity), + () -> assertThat(savedProduct.isDeleted()).isFalse() + ); + + // DB에서 직접 조회하여 검증 + ProductModel foundProduct = productJpaRepository.findById(savedProduct.getId()).orElseThrow(); + assertThat(foundProduct.getProductId().value()).isEqualTo(productId); + } + + @Test + @DisplayName("중복된 상품 ID로 생성 시 예외 발생") + void createProduct_withDuplicateId_throwsException() { + // given + BrandModel brand = brandService.createBrand("nike", "Nike"); + String productId = "prod1"; + productService.createProduct(productId, brand.getBrandId().value(), "Nike Air Max", new BigDecimal("150000"), 100); + + // when & then + assertThatThrownBy(() -> productService.createProduct(productId, brand.getBrandId().value(), "Nike Air Max 2", new BigDecimal("200000"), 50)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("이미 존재하는 상품 ID입니다"); + } + + @Test + @DisplayName("존재하지 않는 브랜드로 생성 시 예외 발생") + void createProduct_withNonExistentBrand_throwsException() { + // given + String productId = "prod1"; + String invalidBrandId = "nobrand"; // 유효한 형식이지만 존재하지 않는 brandId (10자 이내) + + // when & then + assertThatThrownBy(() -> productService.createProduct(productId, invalidBrandId, "Product", new BigDecimal("10000"), 10)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("브랜드가 존재하지 않습니다"); + } + } + + @DisplayName("상품을 삭제할 때,") + @Nested + class DeleteProduct { + + @Test + @DisplayName("존재하는 상품 삭제 성공 (soft delete)") + void deleteProduct_existingProduct_success() { + // given + BrandModel brand = brandService.createBrand("nike", "Nike"); + ProductModel product = productService.createProduct("prod1", brand.getBrandId().value(), "Nike Air", new BigDecimal("100000"), 50); + assertThat(product.isDeleted()).isFalse(); + + // when + productService.deleteProduct(product.getProductId().value()); + + // then + ProductModel deletedProduct = productJpaRepository.findById(product.getId()).orElseThrow(); + assertThat(deletedProduct.isDeleted()).isTrue(); + assertThat(deletedProduct.getDeletedAt()).isNotNull(); + } + + @Test + @DisplayName("존재하지 않는 상품 삭제 시 예외 발생") + void deleteProduct_nonExistentProduct_throwsException() { + // given + String invalidProductId = "invalidProduct"; + + // when & then + assertThatThrownBy(() -> productService.deleteProduct(invalidProductId)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("상품이 존재하지 않습니다"); + } + } + + @DisplayName("상품 목록을 조회할 때,") + @Nested + class GetProducts { + + @Test + @DisplayName("삭제되지 않은 상품만 조회됨") + void getProducts_excludesDeletedProducts() { + // given + BrandModel brand = brandService.createBrand("nike", "Nike"); + ProductModel product1 = productService.createProduct("prod1", brand.getBrandId().value(), "Product 1", new BigDecimal("10000"), 10); + ProductModel product2 = productService.createProduct("prod2", brand.getBrandId().value(), "Product 2", new BigDecimal("20000"), 20); + ProductModel product3 = productService.createProduct("prod3", brand.getBrandId().value(), "Product 3", new BigDecimal("30000"), 30); + + // product2 삭제 + productService.deleteProduct(product2.getProductId().value()); + + Pageable pageable = PageRequest.of(0, 10); + + // when + Page products = productService.getProducts(null, "latest", pageable); + + // then + assertThat(products.getContent()).hasSize(2); + assertThat(products.getContent()) + .extracting(p -> p.getProductId().value()) + .containsExactlyInAnyOrder("prod1", "prod3") + .doesNotContain("prod2"); + } + + @Test + @DisplayName("브랜드 필터링 동작") + void getProducts_filtersByBrand() { + // given + BrandModel nike = brandService.createBrand("nike", "Nike"); + BrandModel adidas = brandService.createBrand("adidas", "Adidas"); + + productService.createProduct("prod1", nike.getBrandId().value(), "Nike Product", new BigDecimal("10000"), 10); + productService.createProduct("prod2", adidas.getBrandId().value(), "Adidas Product", new BigDecimal("20000"), 20); + productService.createProduct("prod3", nike.getBrandId().value(), "Nike Product 2", new BigDecimal("30000"), 30); + + Pageable pageable = PageRequest.of(0, 10); + + // when + Page nikeProducts = productService.getProducts(nike.getBrandId().value(), "latest", pageable); + + // then + assertThat(nikeProducts.getContent()).hasSize(2); + assertThat(nikeProducts.getContent()) + .allMatch(p -> p.getBrandId().value().equals("nike")); + } + + @Test + @DisplayName("latest 정렬 (updatedAt DESC)") + void getProducts_sortByLatest() { + // given + BrandModel brand = brandService.createBrand("nike", "Nike"); + ProductModel product1 = productService.createProduct("prod1", brand.getBrandId().value(), "Product 1", new BigDecimal("10000"), 10); + ProductModel product2 = productService.createProduct("prod2", brand.getBrandId().value(), "Product 2", new BigDecimal("20000"), 20); + + Pageable pageable = PageRequest.of(0, 10); + + // when + Page products = productService.getProducts(null, "latest", pageable); + + // then + assertThat(products.getContent()).hasSize(2); + // 최신 생성된 상품이 먼저 (updatedAt DESC) + assertThat(products.getContent().get(0).getUpdatedAt()) + .isAfterOrEqualTo(products.getContent().get(1).getUpdatedAt()); + } + + @Test + @DisplayName("price_asc 정렬 (가격 오름차순)") + void getProducts_sortByPriceAsc() { + // given + BrandModel brand = brandService.createBrand("nike", "Nike"); + productService.createProduct("prod1", brand.getBrandId().value(), "Expensive", new BigDecimal("100000"), 10); + productService.createProduct("prod2", brand.getBrandId().value(), "Cheap", new BigDecimal("10000"), 20); + productService.createProduct("prod3", brand.getBrandId().value(), "Medium", new BigDecimal("50000"), 30); + + Pageable pageable = PageRequest.of(0, 10); + + // when + Page products = productService.getProducts(null, "price_asc", pageable); + + // then + assertThat(products.getContent()).hasSize(3); + assertThat(products.getContent()) + .extracting(p -> p.getPrice().value()) + .containsExactly( + new BigDecimal("10000.00"), + new BigDecimal("50000.00"), + new BigDecimal("100000.00") + ); + } + + @Test + @DisplayName("페이징 동작") + void getProducts_pagination() { + // given + BrandModel brand = brandService.createBrand("nike", "Nike"); + for (int i = 1; i <= 15; i++) { + productService.createProduct("prod" + i, brand.getBrandId().value(), "Product " + i, new BigDecimal(i * 1000), i); + } + + Pageable pageable = PageRequest.of(0, 10); + + // when + Page firstPage = productService.getProducts(null, "latest", pageable); + Page secondPage = productService.getProducts(null, "latest", PageRequest.of(1, 10)); + + // then + assertThat(firstPage.getContent()).hasSize(10); + assertThat(secondPage.getContent()).hasSize(5); + assertThat(firstPage.getTotalElements()).isEqualTo(15); + assertThat(firstPage.getTotalPages()).isEqualTo(2); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java new file mode 100644 index 000000000..d9f33fb94 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java @@ -0,0 +1,158 @@ +package com.loopers.domain.product; + +import com.loopers.domain.brand.BrandReader; +import com.loopers.domain.brand.vo.BrandId; +import com.loopers.domain.product.vo.ProductId; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@DisplayName("ProductService 단위 테스트") +@ExtendWith(MockitoExtension.class) +class ProductServiceTest { + + @Mock + private ProductRepository productRepository; + + @Mock + private ProductReader productReader; + + @Mock + private BrandReader brandReader; + + @InjectMocks + private ProductService productService; + + @DisplayName("상품을 생성할 때,") + @Nested + class CreateProduct { + + @Test + @DisplayName("유효한 브랜드로 상품 생성 성공") + void createProduct_withValidBrand_success() { + // given + String productId = "prod1"; + String brandId = "nike"; + String productName = "Nike Air Max"; + BigDecimal price = new BigDecimal("150000"); + int stockQuantity = 100; + + when(productReader.exists(productId)).thenReturn(false); + // brandReader.getOrThrow()는 예외를 던지지 않으면 정상 동작으로 간주 + when(productRepository.save(any(ProductModel.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // when + ProductModel result = productService.createProduct(productId, brandId, productName, price, stockQuantity); + + // then + assertThat(result).isNotNull(); + assertThat(result.getProductId().value()).isEqualTo(productId); + assertThat(result.getBrandId().value()).isEqualTo(brandId); + verify(productReader, times(1)).exists(productId); + verify(brandReader, times(1)).getOrThrow(brandId); + verify(productRepository, times(1)).save(any(ProductModel.class)); + } + + @Test + @DisplayName("중복된 상품 ID로 생성 시 예외 발생") + void createProduct_withDuplicateId_throwsException() { + // given + String productId = "prod1"; + String brandId = "nike"; + String productName = "Nike Air Max"; + BigDecimal price = new BigDecimal("150000"); + int stockQuantity = 100; + + when(productReader.exists(productId)).thenReturn(true); + + // when & then + assertThatThrownBy(() -> productService.createProduct(productId, brandId, productName, price, stockQuantity)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("이미 존재하는 상품 ID입니다"); + + verify(productReader, times(1)).exists(productId); + verify(brandReader, never()).getOrThrow(anyString()); + verify(productRepository, never()).save(any(ProductModel.class)); + } + + @Test + @DisplayName("존재하지 않는 브랜드로 생성 시 예외 발생") + void createProduct_withNonExistentBrand_throwsException() { + // given + String productId = "prod1"; + String brandId = "invalidBrand"; + String productName = "Nike Air Max"; + BigDecimal price = new BigDecimal("150000"); + int stockQuantity = 100; + + when(productReader.exists(productId)).thenReturn(false); + doThrow(new CoreException(ErrorType.NOT_FOUND, "해당 ID의 브랜드가 존재하지 않습니다.")) + .when(brandReader).getOrThrow(brandId); + + // when & then + assertThatThrownBy(() -> productService.createProduct(productId, brandId, productName, price, stockQuantity)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("브랜드가 존재하지 않습니다"); + + verify(productReader, times(1)).exists(productId); + verify(brandReader, times(1)).getOrThrow(brandId); + verify(productRepository, never()).save(any(ProductModel.class)); + } + } + + @DisplayName("상품을 삭제할 때,") + @Nested + class DeleteProduct { + + @Test + @DisplayName("존재하는 상품 삭제 성공 (soft delete)") + void deleteProduct_existingProduct_success() { + // given + String productId = "prod1"; + ProductModel product = ProductModel.create(productId, "nike", "Nike Air", new BigDecimal("100000"), 50); + + when(productReader.getOrThrow(productId)).thenReturn(product); + when(productRepository.save(any(ProductModel.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // when + productService.deleteProduct(productId); + + // then + assertThat(product.isDeleted()).isTrue(); + verify(productReader, times(1)).getOrThrow(productId); + verify(productRepository, times(1)).save(product); + } + + @Test + @DisplayName("존재하지 않는 상품 삭제 시 예외 발생") + void deleteProduct_nonExistentProduct_throwsException() { + // given + String productId = "invalidProduct"; + + when(productReader.getOrThrow(productId)) + .thenThrow(new CoreException(ErrorType.NOT_FOUND, "해당 ID의 상품이 존재하지 않습니다.")); + + // when & then + assertThatThrownBy(() -> productService.deleteProduct(productId)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("상품이 존재하지 않습니다"); + + verify(productReader, times(1)).getOrThrow(productId); + verify(productRepository, never()).save(any(ProductModel.class)); + } + } +} From fcae9f8fea0bc0a9a5b358f26d5653cfe2b623d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B8=B0=EB=AC=98=ED=95=9C=20=EC=A3=BC=EB=8B=88=EC=96=B4?= =?UTF-8?q?=20=EA=B0=9C=EB=B0=9C=EC=9E=90?= Date: Mon, 16 Feb 2026 20:51:54 +0900 Subject: [PATCH 15/50] =?UTF-8?q?feat(product):=20REST=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProductFacade 추가 (Application Layer) - ProductInfo 추가 (Entity → DTO 변환) - ProductV1Controller 추가 (REST API 엔드포인트) - POST /api/v1/products (상품 생성) - GET /api/v1/products (목록 조회, 페이징/필터링/정렬) - DELETE /api/v1/products/{productId} (상품 삭제) - ProductV1Dto 추가 (Request/Response DTOs) - Jakarta Validation 적용 - ProductV1ApiSpec 추가 (OpenAPI 명세) - E2E 테스트 작성 (8개 테스트, @Nested 구조) - .http/product.http 추가 (수동 테스트용) - ArchUnit 테스트 통과 (레이어 아키텍처 검증) --- .http/product.http | 97 +++++ .../application/product/ProductFacade.java | 35 ++ .../application/product/ProductInfo.java | 25 ++ .../api/product/ProductV1ApiSpec.java | 61 +++ .../api/product/ProductV1Controller.java | 61 +++ .../interfaces/api/product/ProductV1Dto.java | 71 ++++ .../product/ProductV1ControllerE2ETest.java | 363 ++++++++++++++++++ 7 files changed, 713 insertions(+) create mode 100644 .http/product.http create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ControllerE2ETest.java diff --git a/.http/product.http b/.http/product.http new file mode 100644 index 000000000..33e219cfa --- /dev/null +++ b/.http/product.http @@ -0,0 +1,97 @@ +### Product API 테스트 + +### 1. 상품 생성 (먼저 브랜드를 생성해야 함) +POST http://localhost:8080/api/v1/products +Content-Type: application/json + +{ + "productId": "prod001", + "brandId": "nike", + "productName": "Nike Air Max 2024", + "price": 150000, + "stockQuantity": 100 +} + +### 2. 상품 목록 조회 (전체) +GET http://localhost:8080/api/v1/products?page=0&size=10&sort=latest + +### 3. 상품 목록 조회 (브랜드 필터링) +GET http://localhost:8080/api/v1/products?brandId=nike&page=0&size=10&sort=latest + +### 4. 상품 목록 조회 (가격 낮은순 정렬) +GET http://localhost:8080/api/v1/products?page=0&size=10&sort=price_asc + +### 5. 상품 삭제 +DELETE http://localhost:8080/api/v1/products/prod001 + +### 6. 여러 상품 생성 (테스트용) +POST http://localhost:8080/api/v1/products +Content-Type: application/json + +{ + "productId": "prod002", + "brandId": "nike", + "productName": "Nike Air Force 1", + "price": 120000, + "stockQuantity": 50 +} + +### +POST http://localhost:8080/api/v1/products +Content-Type: application/json + +{ + "productId": "prod003", + "brandId": "nike", + "productName": "Nike Dunk Low", + "price": 130000, + "stockQuantity": 30 +} + +### 7. 중복 상품 ID 테스트 (409 Conflict 예상) +POST http://localhost:8080/api/v1/products +Content-Type: application/json + +{ + "productId": "prod001", + "brandId": "nike", + "productName": "Duplicate Product", + "price": 100000, + "stockQuantity": 10 +} + +### 8. 존재하지 않는 브랜드 테스트 (404 Not Found 예상) +POST http://localhost:8080/api/v1/products +Content-Type: application/json + +{ + "productId": "prod999", + "brandId": "nobrand", + "productName": "No Brand Product", + "price": 100000, + "stockQuantity": 10 +} + +### 9. 유효성 검증 테스트 - 빈 상품명 (400 Bad Request 예상) +POST http://localhost:8080/api/v1/products +Content-Type: application/json + +{ + "productId": "prod004", + "brandId": "nike", + "productName": "", + "price": 100000, + "stockQuantity": 10 +} + +### 10. 유효성 검증 테스트 - 음수 가격 (400 Bad Request 예상) +POST http://localhost:8080/api/v1/products +Content-Type: application/json + +{ + "productId": "prod005", + "brandId": "nike", + "productName": "Invalid Price Product", + "price": -1000, + "stockQuantity": 10 +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java new file mode 100644 index 000000000..02a59dc69 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -0,0 +1,35 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; + +@RequiredArgsConstructor +@Component +public class ProductFacade { + + private final ProductService productService; + + @Transactional + public ProductInfo createProduct(String productId, String brandId, String productName, BigDecimal price, int stockQuantity) { + ProductModel product = productService.createProduct(productId, brandId, productName, price, stockQuantity); + return ProductInfo.from(product); + } + + @Transactional + public void deleteProduct(String productId) { + productService.deleteProduct(productId); + } + + @Transactional(readOnly = true) + public Page getProducts(String brandId, String sortBy, Pageable pageable) { + Page products = productService.getProducts(brandId, sortBy, pageable); + return products.map(ProductInfo::from); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java new file mode 100644 index 000000000..a18b59f21 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java @@ -0,0 +1,25 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.ProductModel; + +import java.math.BigDecimal; + +public record ProductInfo( + Long id, + String productId, + String brandId, + String productName, + BigDecimal price, + int stockQuantity +) { + public static ProductInfo from(ProductModel product) { + return new ProductInfo( + product.getId(), + product.getProductId().value(), + product.getBrandId().value(), + product.getProductName().value(), + product.getPrice().value(), + product.getStockQuantity().value() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java new file mode 100644 index 000000000..daa9fea44 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java @@ -0,0 +1,61 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "Product API", description = "상품 관리 API") +public interface ProductV1ApiSpec { + + @Operation(summary = "상품 생성", description = "새로운 상품을 생성합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "상품 생성 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "브랜드를 찾을 수 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "중복된 상품 ID") + }) + ResponseEntity> createProduct( + @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "상품 생성 요청", + required = true, + content = @Content(schema = @Schema(implementation = ProductV1Dto.CreateProductRequest.class)) + ) + @RequestBody ProductV1Dto.CreateProductRequest request + ); + + @Operation(summary = "상품 목록 조회", description = "상품 목록을 조회합니다. 브랜드 필터링, 정렬, 페이징을 지원합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공") + }) + ResponseEntity> getProducts( + @Parameter(description = "브랜드 ID (선택)", example = "nike") + @RequestParam(required = false) String brandId, + + @Parameter(description = "정렬 기준 (latest: 최신순, price_asc: 가격 낮은순)", example = "latest") + @RequestParam(required = false, defaultValue = "latest") String sort, + + @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") + @RequestParam(defaultValue = "0") int page, + + @Parameter(description = "페이지 크기", example = "10") + @RequestParam(defaultValue = "10") int size + ); + + @Operation(summary = "상품 삭제", description = "상품을 삭제합니다 (Soft Delete).") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "삭제 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "상품을 찾을 수 없음") + }) + ResponseEntity> deleteProduct( + @Parameter(description = "상품 ID", example = "prod1") + @PathVariable String productId + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java new file mode 100644 index 000000000..986b0e7c4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -0,0 +1,61 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductFacade; +import com.loopers.application.product.ProductInfo; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/products") +@RequiredArgsConstructor +public class ProductV1Controller implements ProductV1ApiSpec { + + private final ProductFacade productFacade; + + @PostMapping + @Override + public ResponseEntity> createProduct( + @Valid @RequestBody ProductV1Dto.CreateProductRequest request + ) { + ProductInfo info = productFacade.createProduct( + request.productId(), + request.brandId(), + request.productName(), + request.price(), + request.stockQuantity() + ); + + ProductV1Dto.ProductResponse response = ProductV1Dto.ProductResponse.from(info); + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.success(response)); + } + + @GetMapping + @Override + public ResponseEntity> getProducts( + @RequestParam(required = false) String brandId, + @RequestParam(required = false, defaultValue = "latest") String sort, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size + ) { + Pageable pageable = PageRequest.of(page, size); + Page productPage = productFacade.getProducts(brandId, sort, pageable); + + ProductV1Dto.ProductListResponse response = ProductV1Dto.ProductListResponse.from(productPage); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + @DeleteMapping("/{productId}") + @Override + public ResponseEntity> deleteProduct(@PathVariable String productId) { + productFacade.deleteProduct(productId); + return ResponseEntity.ok(ApiResponse.success(null)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java new file mode 100644 index 000000000..585920b4e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -0,0 +1,71 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductInfo; +import jakarta.validation.constraints.*; + +import java.math.BigDecimal; + +public class ProductV1Dto { + + public record CreateProductRequest( + @NotBlank(message = "상품 ID는 필수입니다") + @Pattern(regexp = "^[A-Za-z0-9]{1,20}$", message = "상품 ID는 영문+숫자, 1~20자여야 합니다") + String productId, + + @NotBlank(message = "브랜드 ID는 필수입니다") + @Pattern(regexp = "^[A-Za-z0-9]{1,10}$", message = "브랜드 ID는 영문+숫자, 1~10자여야 합니다") + String brandId, + + @NotBlank(message = "상품명은 필수입니다") + @Size(min = 1, max = 100, message = "상품명은 1~100자여야 합니다") + String productName, + + @NotNull(message = "가격은 필수입니다") + @DecimalMin(value = "0.0", inclusive = true, message = "가격은 0 이상이어야 합니다") + BigDecimal price, + + @Min(value = 0, message = "재고 수량은 0 이상이어야 합니다") + int stockQuantity + ) { + } + + public record ProductResponse( + Long id, + String productId, + String brandId, + String productName, + BigDecimal price, + int stockQuantity + ) { + public static ProductResponse from(ProductInfo info) { + return new ProductResponse( + info.id(), + info.productId(), + info.brandId(), + info.productName(), + info.price(), + info.stockQuantity() + ); + } + } + + public record ProductListResponse( + java.util.List products, + int currentPage, + int pageSize, + long totalElements, + int totalPages + ) { + public static ProductListResponse from(org.springframework.data.domain.Page page) { + return new ProductListResponse( + page.getContent().stream() + .map(ProductResponse::from) + .toList(), + page.getNumber(), + page.getSize(), + page.getTotalElements(), + page.getTotalPages() + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ControllerE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ControllerE2ETest.java new file mode 100644 index 000000000..6cf2b765f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ControllerE2ETest.java @@ -0,0 +1,363 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.domain.brand.BrandService; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +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.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DisplayName("Product API E2E 테스트") +class ProductV1ControllerE2ETest { + + private static final String ENDPOINT_PRODUCTS = "/api/v1/products"; + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private BrandService brandService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("POST /api/v1/products") + @Nested + class CreateProduct { + + @Test + @DisplayName("상품 생성 성공 시 201 Created와 생성된 상품 정보 반환") + void createProduct_success_returns201() { + // arrange + brandService.createBrand("nike", "Nike"); + + ProductV1Dto.CreateProductRequest request = new ProductV1Dto.CreateProductRequest( + "prod1", + "nike", + "Nike Air Max", + new BigDecimal("150000"), + 100 + ); + + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() { + }; + + // act + ResponseEntity> response = + testRestTemplate.exchange( + ENDPOINT_PRODUCTS, + HttpMethod.POST, + new HttpEntity<>(request), + responseType + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().success()).isEqualTo(true), + () -> assertThat(response.getBody().data().productId()).isEqualTo("prod1"), + () -> assertThat(response.getBody().data().brandId()).isEqualTo("nike"), + () -> assertThat(response.getBody().data().productName()).isEqualTo("Nike Air Max"), + () -> assertThat(response.getBody().data().price()).isEqualByComparingTo(new BigDecimal("150000.00")), + () -> assertThat(response.getBody().data().stockQuantity()).isEqualTo(100) + ); + } + + @Test + @DisplayName("중복된 상품 ID로 생성 시 409 Conflict 반환") + void createProduct_duplicateId_returns409() { + // arrange + brandService.createBrand("nike", "Nike"); + + ProductV1Dto.CreateProductRequest request = new ProductV1Dto.CreateProductRequest( + "prod1", + "nike", + "Nike Air Max", + new BigDecimal("150000"), + 100 + ); + + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() { + }; + + // 첫 번째 생성 + testRestTemplate.exchange(ENDPOINT_PRODUCTS, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // act - 중복 생성 시도 + ResponseEntity> response = + testRestTemplate.exchange( + ENDPOINT_PRODUCTS, + HttpMethod.POST, + new HttpEntity<>(request), + responseType + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } + + @Test + @DisplayName("존재하지 않는 브랜드로 생성 시 404 Not Found 반환") + void createProduct_nonExistentBrand_returns404() { + // arrange + ProductV1Dto.CreateProductRequest request = new ProductV1Dto.CreateProductRequest( + "prod1", + "nobrand", + "Product", + new BigDecimal("10000"), + 10 + ); + + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() { + }; + + // act + ResponseEntity> response = + testRestTemplate.exchange( + ENDPOINT_PRODUCTS, + HttpMethod.POST, + new HttpEntity<>(request), + responseType + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @DisplayName("GET /api/v1/products") + @Nested + class GetProducts { + + @Test + @DisplayName("상품 목록 조회 성공") + void getProducts_success_returns200() { + // arrange + brandService.createBrand("nike", "Nike"); + + ProductV1Dto.CreateProductRequest request1 = new ProductV1Dto.CreateProductRequest( + "prod1", "nike", "Product 1", new BigDecimal("10000"), 10 + ); + ProductV1Dto.CreateProductRequest request2 = new ProductV1Dto.CreateProductRequest( + "prod2", "nike", "Product 2", new BigDecimal("20000"), 20 + ); + + ParameterizedTypeReference> createResponseType = + new ParameterizedTypeReference<>() { + }; + + testRestTemplate.exchange(ENDPOINT_PRODUCTS, HttpMethod.POST, new HttpEntity<>(request1), createResponseType); + testRestTemplate.exchange(ENDPOINT_PRODUCTS, HttpMethod.POST, new HttpEntity<>(request2), createResponseType); + + ParameterizedTypeReference> listResponseType = + new ParameterizedTypeReference<>() { + }; + + // act + ResponseEntity> response = + testRestTemplate.exchange( + ENDPOINT_PRODUCTS + "?page=0&size=10&sort=latest", + HttpMethod.GET, + null, + listResponseType + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().success()).isEqualTo(true), + () -> assertThat(response.getBody().data().products()).hasSize(2), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(2) + ); + } + + @Test + @DisplayName("브랜드 필터링 동작") + void getProducts_filterByBrand_success() { + // arrange + brandService.createBrand("nike", "Nike"); + brandService.createBrand("adidas", "Adidas"); + + ProductV1Dto.CreateProductRequest nikeProduct = new ProductV1Dto.CreateProductRequest( + "prod1", "nike", "Nike Product", new BigDecimal("10000"), 10 + ); + ProductV1Dto.CreateProductRequest adidasProduct = new ProductV1Dto.CreateProductRequest( + "prod2", "adidas", "Adidas Product", new BigDecimal("20000"), 20 + ); + + ParameterizedTypeReference> createResponseType = + new ParameterizedTypeReference<>() { + }; + + testRestTemplate.exchange(ENDPOINT_PRODUCTS, HttpMethod.POST, new HttpEntity<>(nikeProduct), createResponseType); + testRestTemplate.exchange(ENDPOINT_PRODUCTS, HttpMethod.POST, new HttpEntity<>(adidasProduct), createResponseType); + + ParameterizedTypeReference> listResponseType = + new ParameterizedTypeReference<>() { + }; + + // act + ResponseEntity> response = + testRestTemplate.exchange( + ENDPOINT_PRODUCTS + "?brandId=nike", + HttpMethod.GET, + null, + listResponseType + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data().products()).hasSize(1), + () -> assertThat(response.getBody().data().products().get(0).brandId()).isEqualTo("nike") + ); + } + + @Test + @DisplayName("price_asc 정렬 동작") + void getProducts_sortByPriceAsc_success() { + // arrange + brandService.createBrand("nike", "Nike"); + + ProductV1Dto.CreateProductRequest expensive = new ProductV1Dto.CreateProductRequest( + "prod1", "nike", "Expensive", new BigDecimal("100000"), 10 + ); + ProductV1Dto.CreateProductRequest cheap = new ProductV1Dto.CreateProductRequest( + "prod2", "nike", "Cheap", new BigDecimal("10000"), 20 + ); + + ParameterizedTypeReference> createResponseType = + new ParameterizedTypeReference<>() { + }; + + testRestTemplate.exchange(ENDPOINT_PRODUCTS, HttpMethod.POST, new HttpEntity<>(expensive), createResponseType); + testRestTemplate.exchange(ENDPOINT_PRODUCTS, HttpMethod.POST, new HttpEntity<>(cheap), createResponseType); + + ParameterizedTypeReference> listResponseType = + new ParameterizedTypeReference<>() { + }; + + // act + ResponseEntity> response = + testRestTemplate.exchange( + ENDPOINT_PRODUCTS + "?sort=price_asc", + HttpMethod.GET, + null, + listResponseType + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data().products()).hasSize(2), + () -> assertThat(response.getBody().data().products().get(0).price()) + .isLessThan(response.getBody().data().products().get(1).price()) + ); + } + } + + @DisplayName("DELETE /api/v1/products/{productId}") + @Nested + class DeleteProduct { + + @Test + @DisplayName("상품 삭제 성공 시 200 OK 반환") + void deleteProduct_success_returns200() { + // arrange + brandService.createBrand("nike", "Nike"); + + ProductV1Dto.CreateProductRequest request = new ProductV1Dto.CreateProductRequest( + "prod1", "nike", "Nike Air", new BigDecimal("100000"), 50 + ); + + ParameterizedTypeReference> createResponseType = + new ParameterizedTypeReference<>() { + }; + + testRestTemplate.exchange(ENDPOINT_PRODUCTS, HttpMethod.POST, new HttpEntity<>(request), createResponseType); + + ParameterizedTypeReference> deleteResponseType = + new ParameterizedTypeReference<>() { + }; + + // act + ResponseEntity> response = + testRestTemplate.exchange( + ENDPOINT_PRODUCTS + "/prod1", + HttpMethod.DELETE, + null, + deleteResponseType + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().success()).isEqualTo(true) + ); + + // 삭제 후 목록 조회 시 제외됨 확인 + ParameterizedTypeReference> listResponseType = + new ParameterizedTypeReference<>() { + }; + + ResponseEntity> listResponse = + testRestTemplate.exchange( + ENDPOINT_PRODUCTS, + HttpMethod.GET, + null, + listResponseType + ); + + assertThat(listResponse.getBody().data().products()).isEmpty(); + } + + @Test + @DisplayName("존재하지 않는 상품 삭제 시 404 Not Found 반환") + void deleteProduct_nonExistent_returns404() { + // arrange + ParameterizedTypeReference> deleteResponseType = + new ParameterizedTypeReference<>() { + }; + + // act + ResponseEntity> response = + testRestTemplate.exchange( + ENDPOINT_PRODUCTS + "/nonexistent", + HttpMethod.DELETE, + null, + deleteResponseType + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } +} From 3e7f0be4ee0b5f75229a52e16a07c6174dd7c745 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B8=B0=EB=AC=98=ED=95=9C=20=EC=A3=BC=EB=8B=88=EC=96=B4?= =?UTF-8?q?=20=EA=B0=9C=EB=B0=9C=EC=9E=90?= Date: Mon, 16 Feb 2026 21:27:46 +0900 Subject: [PATCH 16/50] =?UTF-8?q?feat(like):=20VO=20=EB=B0=8F=20Entity=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/domain/like/LikeModel.java | 40 +++++++++++++++ .../loopers/domain/like/vo/RefMemberId.java | 16 ++++++ .../loopers/domain/like/vo/RefProductId.java | 16 ++++++ .../jpa/converter/RefMemberIdConverter.java | 19 +++++++ .../jpa/converter/RefProductIdConverter.java | 19 +++++++ .../loopers/domain/like/LikeModelTest.java | 49 +++++++++++++++++++ 6 files changed, 159 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/vo/RefMemberId.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/vo/RefProductId.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/RefMemberIdConverter.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/RefProductIdConverter.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java new file mode 100644 index 000000000..09ff4b83e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java @@ -0,0 +1,40 @@ +package com.loopers.domain.like; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.like.vo.RefMemberId; +import com.loopers.domain.like.vo.RefProductId; +import com.loopers.infrastructure.jpa.converter.RefMemberIdConverter; +import com.loopers.infrastructure.jpa.converter.RefProductIdConverter; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table( + name = "likes", + uniqueConstraints = { + @UniqueConstraint(name = "uk_likes_member_product", columnNames = {"ref_member_id", "ref_product_id"}) + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class LikeModel extends BaseEntity { + + @Convert(converter = RefMemberIdConverter.class) + @Column(name = "ref_member_id", nullable = false) + private RefMemberId refMemberId; + + @Convert(converter = RefProductIdConverter.class) + @Column(name = "ref_product_id", nullable = false) + private RefProductId refProductId; + + private LikeModel(Long refMemberId, Long refProductId) { + this.refMemberId = new RefMemberId(refMemberId); + this.refProductId = new RefProductId(refProductId); + } + + public static LikeModel create(Long refMemberId, Long refProductId) { + return new LikeModel(refMemberId, refProductId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/vo/RefMemberId.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/vo/RefMemberId.java new file mode 100644 index 000000000..ec68110ae --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/vo/RefMemberId.java @@ -0,0 +1,16 @@ +package com.loopers.domain.like.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +public record RefMemberId(Long value) { + + public RefMemberId { + if (value == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "refMemberId가 비어 있습니다"); + } + if (value <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "refMemberId는 양수여야 합니다: " + value); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/vo/RefProductId.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/vo/RefProductId.java new file mode 100644 index 000000000..a62e616f9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/vo/RefProductId.java @@ -0,0 +1,16 @@ +package com.loopers.domain.like.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +public record RefProductId(Long value) { + + public RefProductId { + if (value == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "refProductId가 비어 있습니다"); + } + if (value <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "refProductId는 양수여야 합니다: " + value); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/RefMemberIdConverter.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/RefMemberIdConverter.java new file mode 100644 index 000000000..b8550d0b5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/RefMemberIdConverter.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.jpa.converter; + +import com.loopers.domain.like.vo.RefMemberId; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter(autoApply = false) +public class RefMemberIdConverter implements AttributeConverter { + + @Override + public Long convertToDatabaseColumn(RefMemberId attribute) { + return attribute == null ? null : attribute.value(); + } + + @Override + public RefMemberId convertToEntityAttribute(Long dbData) { + return dbData == null ? null : new RefMemberId(dbData); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/RefProductIdConverter.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/RefProductIdConverter.java new file mode 100644 index 000000000..ed849b648 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/RefProductIdConverter.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.jpa.converter; + +import com.loopers.domain.like.vo.RefProductId; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter(autoApply = false) +public class RefProductIdConverter implements AttributeConverter { + + @Override + public Long convertToDatabaseColumn(RefProductId attribute) { + return attribute == null ? null : attribute.value(); + } + + @Override + public RefProductId convertToEntityAttribute(Long dbData) { + return dbData == null ? null : new RefProductId(dbData); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java new file mode 100644 index 000000000..313a32ea5 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java @@ -0,0 +1,49 @@ +package com.loopers.domain.like; + +import com.loopers.domain.like.vo.RefMemberId; +import com.loopers.domain.like.vo.RefProductId; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("LikeModel Entity") +class LikeModelTest { + + @DisplayName("좋아요를 생성할 때,") + @Nested + class Create { + + @Test + @DisplayName("create() 정적 팩토리로 LikeModel 생성 성공") + void create_like_model() { + // given + Long refMemberId = 1L; + Long refProductId = 100L; + + // when + LikeModel like = LikeModel.create(refMemberId, refProductId); + + // then + assertThat(like).isNotNull(); + assertThat(like.getRefMemberId()).isEqualTo(new RefMemberId(refMemberId)); + assertThat(like.getRefProductId()).isEqualTo(new RefProductId(refProductId)); + } + + @Test + @DisplayName("Member PK와 Product PK로 좋아요 생성") + void create_withMemberAndProductIds() { + // given + Long refMemberId = 5L; + Long refProductId = 200L; + + // when + LikeModel like = LikeModel.create(refMemberId, refProductId); + + // then + assertThat(like.getRefMemberId().value()).isEqualTo(5L); + assertThat(like.getRefProductId().value()).isEqualTo(200L); + } + } +} From 329ba8f35d1a520c7491a6afdb6dcc438af231a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B8=B0=EB=AC=98=ED=95=9C=20=EC=A3=BC=EB=8B=88=EC=96=B4?= =?UTF-8?q?=20=EA=B0=9C=EB=B0=9C=EC=9E=90?= Date: Mon, 16 Feb 2026 21:29:02 +0900 Subject: [PATCH 17/50] =?UTF-8?q?refactor(product):=20FK=20=EC=B0=B8?= =?UTF-8?q?=EC=A1=B0=EC=97=90=20RefBrandId=20VO=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/product/ProductInfo.java | 4 ++-- .../loopers/domain/product/ProductModel.java | 18 +++++++-------- .../loopers/domain/product/ProductReader.java | 6 ++--- .../domain/product/ProductRepository.java | 3 +-- .../domain/product/ProductService.java | 15 ++++++++---- .../loopers/domain/product/vo/RefBrandId.java | 16 +++++++++++++ .../jpa/converter/RefBrandIdConverter.java | 19 +++++++++++++++ .../product/ProductRepositoryImpl.java | 23 +++++++++---------- .../interfaces/api/product/ProductV1Dto.java | 4 ++-- .../domain/product/ProductModelTest.java | 15 ++++++------ .../ProductServiceIntegrationTest.java | 4 ++-- .../domain/product/ProductServiceTest.java | 10 +++++--- .../product/ProductV1ControllerE2ETest.java | 4 ++-- 13 files changed, 91 insertions(+), 50 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/vo/RefBrandId.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/RefBrandIdConverter.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java index a18b59f21..370060b0a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java @@ -7,7 +7,7 @@ public record ProductInfo( Long id, String productId, - String brandId, + Long refBrandId, String productName, BigDecimal price, int stockQuantity @@ -16,7 +16,7 @@ public static ProductInfo from(ProductModel product) { return new ProductInfo( product.getId(), product.getProductId().value(), - product.getBrandId().value(), + product.getRefBrandId().value(), product.getProductName().value(), product.getPrice().value(), product.getStockQuantity().value() diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java index 1e89c489a..93cfe2608 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java @@ -1,15 +1,15 @@ package com.loopers.domain.product; import com.loopers.domain.BaseEntity; -import com.loopers.domain.brand.vo.BrandId; import com.loopers.domain.product.vo.Price; import com.loopers.domain.product.vo.ProductId; import com.loopers.domain.product.vo.ProductName; +import com.loopers.domain.product.vo.RefBrandId; import com.loopers.domain.product.vo.StockQuantity; -import com.loopers.infrastructure.jpa.converter.BrandIdConverter; import com.loopers.infrastructure.jpa.converter.PriceConverter; import com.loopers.infrastructure.jpa.converter.ProductIdConverter; import com.loopers.infrastructure.jpa.converter.ProductNameConverter; +import com.loopers.infrastructure.jpa.converter.RefBrandIdConverter; import com.loopers.infrastructure.jpa.converter.StockQuantityConverter; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; @@ -27,9 +27,9 @@ public class ProductModel extends BaseEntity { @Column(name = "product_id", nullable = false, unique = true, length = 20) private ProductId productId; - @Convert(converter = BrandIdConverter.class) - @Column(name = "brand_id", nullable = false, length = 10) - private BrandId brandId; + @Convert(converter = RefBrandIdConverter.class) + @Column(name = "ref_brand_id", nullable = false) + private RefBrandId refBrandId; @Convert(converter = ProductNameConverter.class) @Column(name = "product_name", nullable = false, length = 100) @@ -45,16 +45,16 @@ public class ProductModel extends BaseEntity { protected ProductModel() {} - private ProductModel(String productId, String brandId, String productName, BigDecimal price, int stockQuantity) { + private ProductModel(String productId, Long refBrandId, String productName, BigDecimal price, int stockQuantity) { this.productId = new ProductId(productId); - this.brandId = new BrandId(brandId); + this.refBrandId = new RefBrandId(refBrandId); this.productName = new ProductName(productName); this.price = new Price(price); this.stockQuantity = new StockQuantity(stockQuantity); } - public static ProductModel create(String productId, String brandId, String productName, BigDecimal price, int stockQuantity) { - return new ProductModel(productId, brandId, productName, price, stockQuantity); + public static ProductModel create(String productId, Long refBrandId, String productName, BigDecimal price, int stockQuantity) { + return new ProductModel(productId, refBrandId, productName, price, stockQuantity); } public void decreaseStock(int quantity) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductReader.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductReader.java index 60fb141f0..7db484fcb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductReader.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductReader.java @@ -1,6 +1,5 @@ package com.loopers.domain.product; -import com.loopers.domain.brand.vo.BrandId; import com.loopers.domain.product.vo.ProductId; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; @@ -28,8 +27,7 @@ public boolean exists(String productId) { } @Transactional(readOnly = true) - public Page findProducts(String brandId, String sortBy, Pageable pageable) { - BrandId brandIdVO = brandId != null ? new BrandId(brandId) : null; - return productRepository.findProducts(brandIdVO, sortBy, pageable); + public Page findProducts(Long refBrandId, String sortBy, Pageable pageable) { + return productRepository.findProducts(refBrandId, sortBy, pageable); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java index 42bb74769..5033ef8ee 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -1,6 +1,5 @@ package com.loopers.domain.product; -import com.loopers.domain.brand.vo.BrandId; import com.loopers.domain.product.vo.ProductId; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -14,5 +13,5 @@ public interface ProductRepository { boolean existsByProductId(ProductId productId); - Page findProducts(BrandId brandId, String sortBy, Pageable pageable); + Page findProducts(Long refBrandId, String sortBy, Pageable pageable); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java index 29778783f..143cd2ed5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -26,11 +26,12 @@ public ProductModel createProduct(String productId, String brandId, String produ throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 상품 ID입니다."); } - // 브랜드 존재 확인 - brandReader.getOrThrow(brandId); + // 브랜드 존재 확인 및 PK 획득 + var brand = brandReader.getOrThrow(brandId); + Long refBrandId = brand.getId(); // 상품 생성 - ProductModel product = ProductModel.create(productId, brandId, productName, price, stockQuantity); + ProductModel product = ProductModel.create(productId, refBrandId, productName, price, stockQuantity); // 저장 return productRepository.save(product); @@ -47,6 +48,12 @@ public void deleteProduct(String productId) { @Transactional(readOnly = true) public Page getProducts(String brandId, String sortBy, Pageable pageable) { - return productReader.findProducts(brandId, sortBy, pageable); + // brandId가 제공되면 Brand PK로 변환 + Long refBrandId = null; + if (brandId != null && !brandId.isBlank()) { + var brand = brandReader.getOrThrow(brandId); + refBrandId = brand.getId(); + } + return productReader.findProducts(refBrandId, sortBy, pageable); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/vo/RefBrandId.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/vo/RefBrandId.java new file mode 100644 index 000000000..10df94388 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/vo/RefBrandId.java @@ -0,0 +1,16 @@ +package com.loopers.domain.product.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +public record RefBrandId(Long value) { + + public RefBrandId { + if (value == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "refBrandId가 비어 있습니다"); + } + if (value <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "refBrandId는 양수여야 합니다: " + value); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/RefBrandIdConverter.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/RefBrandIdConverter.java new file mode 100644 index 000000000..58852f995 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/RefBrandIdConverter.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.jpa.converter; + +import com.loopers.domain.product.vo.RefBrandId; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter(autoApply = false) +public class RefBrandIdConverter implements AttributeConverter { + + @Override + public Long convertToDatabaseColumn(RefBrandId attribute) { + return attribute == null ? null : attribute.value(); + } + + @Override + public RefBrandId convertToEntityAttribute(Long dbData) { + return dbData == null ? null : new RefBrandId(dbData); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java index c6cce99b7..abe487159 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -1,6 +1,5 @@ package com.loopers.infrastructure.product; -import com.loopers.domain.brand.vo.BrandId; import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductRepository; import com.loopers.domain.product.vo.ProductId; @@ -38,13 +37,13 @@ public boolean existsByProductId(ProductId productId) { } @Override - public Page findProducts(BrandId brandId, String sortBy, Pageable pageable) { + public Page findProducts(Long refBrandId, String sortBy, Pageable pageable) { // JPQL 쿼리 작성 StringBuilder jpql = new StringBuilder("SELECT p FROM ProductModel p WHERE p.deletedAt IS NULL"); // 브랜드 필터 - if (brandId != null) { - jpql.append(" AND p.brandId = :brandId"); + if (refBrandId != null) { + jpql.append(" AND p.refBrandId = :refBrandId"); } // 정렬 @@ -52,8 +51,8 @@ public Page findProducts(BrandId brandId, String sortBy, Pageable // 쿼리 실행 TypedQuery query = entityManager.createQuery(jpql.toString(), ProductModel.class); - if (brandId != null) { - query.setParameter("brandId", brandId); + if (refBrandId != null) { + query.setParameter("refBrandId", refBrandId); } // 페이징 @@ -63,7 +62,7 @@ public Page findProducts(BrandId brandId, String sortBy, Pageable List products = query.getResultList(); // 전체 개수 조회 - long total = countProducts(brandId); + long total = countProducts(refBrandId); return new PageImpl<>(products, pageable, total); } @@ -80,16 +79,16 @@ private String getSortClause(String sortBy) { return " ORDER BY p.updatedAt DESC"; // 기본값 } - private long countProducts(BrandId brandId) { + private long countProducts(Long refBrandId) { StringBuilder jpql = new StringBuilder("SELECT COUNT(p) FROM ProductModel p WHERE p.deletedAt IS NULL"); - if (brandId != null) { - jpql.append(" AND p.brandId = :brandId"); + if (refBrandId != null) { + jpql.append(" AND p.refBrandId = :refBrandId"); } TypedQuery query = entityManager.createQuery(jpql.toString(), Long.class); - if (brandId != null) { - query.setParameter("brandId", brandId); + if (refBrandId != null) { + query.setParameter("refBrandId", refBrandId); } return query.getSingleResult(); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java index 585920b4e..d2ab0ae2a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -32,7 +32,7 @@ public record CreateProductRequest( public record ProductResponse( Long id, String productId, - String brandId, + Long refBrandId, String productName, BigDecimal price, int stockQuantity @@ -41,7 +41,7 @@ public static ProductResponse from(ProductInfo info) { return new ProductResponse( info.id(), info.productId(), - info.brandId(), + info.refBrandId(), info.productName(), info.price(), info.stockQuantity() diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java index 44893e12b..2cc80092a 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java @@ -1,6 +1,6 @@ package com.loopers.domain.product; -import com.loopers.domain.brand.vo.BrandId; + import com.loopers.domain.product.vo.Price; import com.loopers.domain.product.vo.ProductId; import com.loopers.domain.product.vo.ProductName; @@ -32,11 +32,10 @@ void create_product_model() { int stockQuantity = 100; // when - ProductModel product = ProductModel.create(productId, brandId, productName, price, stockQuantity); + ProductModel product = ProductModel.create(productId, 1L, productName, price, stockQuantity); // then assertThat(product.getProductId()).isEqualTo(new ProductId(productId)); - assertThat(product.getBrandId()).isEqualTo(new BrandId(brandId)); assertThat(product.getProductName()).isEqualTo(new ProductName(productName)); assertThat(product.getPrice().value()).isEqualByComparingTo(price.setScale(2, java.math.RoundingMode.HALF_UP)); assertThat(product.getStockQuantity().value()).isEqualTo(stockQuantity); @@ -51,7 +50,7 @@ class DecreaseStock { @DisplayName("재고가 충분하면 차감 성공") void decreaseStock_success() { // given - ProductModel product = ProductModel.create("prod1", "nike", "Nike Air", new BigDecimal("100000"), 50); + ProductModel product = ProductModel.create("prod1", 1L, "Nike Air", new BigDecimal("100000"), 50); // when product.decreaseStock(10); @@ -64,7 +63,7 @@ void decreaseStock_success() { @DisplayName("재고가 부족하면 예외 발생") void decreaseStock_insufficient_stock_throws_exception() { // given - ProductModel product = ProductModel.create("prod1", "nike", "Nike Air", new BigDecimal("100000"), 5); + ProductModel product = ProductModel.create("prod1", 1L, "Nike Air", new BigDecimal("100000"), 5); // when & then assertThatThrownBy(() -> product.decreaseStock(10)) @@ -76,7 +75,7 @@ void decreaseStock_insufficient_stock_throws_exception() { @DisplayName("0개 차감 시 재고 변화 없음") void decreaseStock_zero_does_not_change_stock() { // given - ProductModel product = ProductModel.create("prod1", "nike", "Nike Air", new BigDecimal("100000"), 50); + ProductModel product = ProductModel.create("prod1", 1L, "Nike Air", new BigDecimal("100000"), 50); // when product.decreaseStock(0); @@ -93,7 +92,7 @@ class IncreaseStock { @DisplayName("재고 증가 성공") void increaseStock_success() { // given - ProductModel product = ProductModel.create("prod1", "nike", "Nike Air", new BigDecimal("100000"), 50); + ProductModel product = ProductModel.create("prod1", 1L, "Nike Air", new BigDecimal("100000"), 50); // when product.increaseStock(20); @@ -110,7 +109,7 @@ class Delete { @DisplayName("markAsDeleted() 호출 시 deletedAt 설정됨") void mark_as_deleted_sets_deletedAt() { // given - ProductModel product = ProductModel.create("prod1", "nike", "Nike Air", new BigDecimal("100000"), 50); + ProductModel product = ProductModel.create("prod1", 1L, "Nike Air", new BigDecimal("100000"), 50); assertThat(product.isDeleted()).isFalse(); // when diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java index 024560409..eb0b35fa6 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java @@ -68,7 +68,7 @@ void createProduct_withValidBrand_success() { () -> assertThat(savedProduct).isNotNull(), () -> assertThat(savedProduct.getId()).isNotNull(), () -> assertThat(savedProduct.getProductId().value()).isEqualTo(productId), - () -> assertThat(savedProduct.getBrandId().value()).isEqualTo("nike"), + () -> assertThat(savedProduct.getRefBrandId()).isEqualTo(brand.getId()), () -> assertThat(savedProduct.getProductName().value()).isEqualTo(productName), () -> assertThat(savedProduct.getPrice().value()).isEqualByComparingTo(price.setScale(2)), () -> assertThat(savedProduct.getStockQuantity().value()).isEqualTo(stockQuantity), @@ -190,7 +190,7 @@ void getProducts_filtersByBrand() { // then assertThat(nikeProducts.getContent()).hasSize(2); assertThat(nikeProducts.getContent()) - .allMatch(p -> p.getBrandId().value().equals("nike")); + .allMatch(p -> p.getRefBrandId().equals(nike.getId())); } @Test diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java index d9f33fb94..0f260e983 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java @@ -1,5 +1,6 @@ package com.loopers.domain.product; +import com.loopers.domain.brand.BrandModel; import com.loopers.domain.brand.BrandReader; import com.loopers.domain.brand.vo.BrandId; import com.loopers.domain.product.vo.ProductId; @@ -51,8 +52,11 @@ void createProduct_withValidBrand_success() { BigDecimal price = new BigDecimal("150000"); int stockQuantity = 100; + BrandModel mockBrand = mock(BrandModel.class); + when(mockBrand.getId()).thenReturn(1L); + when(productReader.exists(productId)).thenReturn(false); - // brandReader.getOrThrow()는 예외를 던지지 않으면 정상 동작으로 간주 + when(brandReader.getOrThrow(brandId)).thenReturn(mockBrand); when(productRepository.save(any(ProductModel.class))).thenAnswer(invocation -> invocation.getArgument(0)); // when @@ -61,7 +65,7 @@ void createProduct_withValidBrand_success() { // then assertThat(result).isNotNull(); assertThat(result.getProductId().value()).isEqualTo(productId); - assertThat(result.getBrandId().value()).isEqualTo(brandId); + assertThat(result.getRefBrandId().value()).isEqualTo(1L); verify(productReader, times(1)).exists(productId); verify(brandReader, times(1)).getOrThrow(brandId); verify(productRepository, times(1)).save(any(ProductModel.class)); @@ -123,7 +127,7 @@ class DeleteProduct { void deleteProduct_existingProduct_success() { // given String productId = "prod1"; - ProductModel product = ProductModel.create(productId, "nike", "Nike Air", new BigDecimal("100000"), 50); + ProductModel product = ProductModel.create(productId, 1L, "Nike Air", new BigDecimal("100000"), 50); when(productReader.getOrThrow(productId)).thenReturn(product); when(productRepository.save(any(ProductModel.class))).thenAnswer(invocation -> invocation.getArgument(0)); diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ControllerE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ControllerE2ETest.java index 6cf2b765f..4972011ed 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ControllerE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ControllerE2ETest.java @@ -78,7 +78,7 @@ void createProduct_success_returns201() { () -> assertThat(response.getBody()).isNotNull(), () -> assertThat(response.getBody().success()).isEqualTo(true), () -> assertThat(response.getBody().data().productId()).isEqualTo("prod1"), - () -> assertThat(response.getBody().data().brandId()).isEqualTo("nike"), + () -> assertThat(response.getBody().data().refBrandId()).isNotNull(), () -> assertThat(response.getBody().data().productName()).isEqualTo("Nike Air Max"), () -> assertThat(response.getBody().data().price()).isEqualByComparingTo(new BigDecimal("150000.00")), () -> assertThat(response.getBody().data().stockQuantity()).isEqualTo(100) @@ -235,7 +235,7 @@ void getProducts_filterByBrand_success() { () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), () -> assertThat(response.getBody()).isNotNull(), () -> assertThat(response.getBody().data().products()).hasSize(1), - () -> assertThat(response.getBody().data().products().get(0).brandId()).isEqualTo("nike") + () -> assertThat(response.getBody().data().products().get(0).refBrandId()).isNotNull() ); } From 59863d209ef772923358f44a4f6e9a81854de129 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B8=B0=EB=AC=98=ED=95=9C=20=EC=A3=BC=EB=8B=88=EC=96=B4?= =?UTF-8?q?=20=EA=B0=9C=EB=B0=9C=EC=9E=90?= Date: Mon, 16 Feb 2026 21:48:19 +0900 Subject: [PATCH 18/50] =?UTF-8?q?refactor=20:=20Reader=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0,=20Repository=20=ED=86=B5=ED=95=A9.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 13 +++--- .../com/loopers/domain/brand/BrandReader.java | 26 ----------- .../loopers/domain/brand/BrandService.java | 9 ++-- .../loopers/domain/member/MemberReader.java | 32 -------------- .../loopers/domain/member/MemberService.java | 10 +++-- .../loopers/domain/product/ProductReader.java | 33 -------------- .../domain/product/ProductService.java | 20 +++++---- .../brand/BrandServiceIntegrationTest.java | 5 +-- .../domain/brand/BrandServiceTest.java | 30 ++++++------- .../member/MemberServiceIntegrationTest.java | 9 ++-- .../domain/product/ProductServiceTest.java | 43 ++++++++----------- 11 files changed, 67 insertions(+), 163 deletions(-) delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandReader.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/MemberReader.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductReader.java diff --git a/CLAUDE.md b/CLAUDE.md index 767b4664d..6f5d60024 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,7 +46,7 @@ Interfaces Layer (Controller, ApiSpec, Dto) ↓ Application Layer (Facade, Info) ↓ -Domain Layer (Model, Reader, Service, Repository, VO) +Domain Layer (Model, Service, Repository, VO) ↓ Infrastructure Layer (RepositoryImpl, JpaRepository, Converter) ``` @@ -55,8 +55,8 @@ Infrastructure Layer (RepositoryImpl, JpaRepository, Converter) **Domain Layer** - 핵심 비즈니스 로직 - **Model**: JPA Entity, `BaseEntity` 상속, 정적 팩토리 `create()`, 도메인 행위 메서드 -- **Reader**: 읽기 전용 조회, VO 변환, 조회+예외 통합 (`getOrThrow`) -- **Service**: 교차 엔티티 규칙 (중복 체크 등), 트랜잭션 관리 +- **Service**: 비즈니스 로직, 트랜잭션 관리, Repository를 통한 조회 및 저장 +- **Repository**: 데이터 조회 및 저장, `findByXXX().orElseThrow()` 패턴 사용 - **Value Object**: `record` 타입, Compact Constructor 검증, 불변 **Application Layer** - 유스케이스 조합 @@ -77,7 +77,6 @@ Infrastructure Layer (RepositoryImpl, JpaRepository, Converter) ### 네이밍 규칙 - Entity: `{Domain}Model` (예: `MemberModel`) -- Reader: `{Domain}Reader` - Service: `{Domain}Service` - Repository: `{Domain}Repository` / `{Domain}RepositoryImpl` / `{Domain}JpaRepository` - Controller: `{Domain}V{version}Controller` @@ -190,12 +189,12 @@ Infrastructure Layer (RepositoryImpl, JpaRepository, Converter) ## 아키텍처, 패키지 구성 전략 - **레이어 의존성 방향**: `Controller → Facade → Service → Repository` (단방향), Infrastructure는 Domain 인터페이스 구현 (Port-Adapter). -- **Thin Facade 원칙**: Facade는 Service만 호출하고 Reader 직접 호출 금지, 비즈니스 로직은 Service에 위임(조율만 담당). +- **Thin Facade 원칙**: Facade는 Service만 호출, 비즈니스 로직은 Service에 위임(조율만 담당). - **DTO vs Info vs Model 분리**: DTO(HTTP 계층) → Info(Application 결과 VO) → Model(Domain Entity), 각 레이어 독립성 유지. -- **Reader vs Service**: Reader는 읽기 전용 + getOrThrow 패턴, Service는 CUD + 비즈니스 규칙 + @Transactional 경계. +- **Service 책임**: Service는 Repository를 통한 조회 및 저장, 비즈니스 규칙 검증, @Transactional 경계 관리. - **Repository Pattern**: Domain에 Repository 인터페이스(Port), Infrastructure에 구현체(Adapter), Domain이 Infrastructure를 모름. - **Info 변환**: Facade에서 Model → Info 변환, Controller는 Model 노출 금지(Info만 사용), 레이어 격리 유지. -- **컴포넌트 책임**: Controller(HTTP), Facade(유스케이스 조합), Service(비즈니스 로직), Reader(조회), Repository(영속화). +- **컴포넌트 책임**: Controller(HTTP), Facade(유스케이스 조합), Service(비즈니스 로직 + 조회), Repository(영속화). --- diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandReader.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandReader.java deleted file mode 100644 index 2ff34736c..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandReader.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.loopers.domain.brand; - -import com.loopers.domain.brand.vo.BrandId; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -@Component -@RequiredArgsConstructor -public class BrandReader { - - private final BrandRepository brandRepository; - - @Transactional(readOnly = true) - public BrandModel getOrThrow(String brandId) { - return brandRepository.findByBrandId(new BrandId(brandId)) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 브랜드가 존재하지 않습니다.")); - } - - @Transactional(readOnly = true) - public boolean exists(String brandId) { - return brandRepository.existsByBrandId(new BrandId(brandId)); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java index e53a48a40..afb2ec54d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -1,5 +1,6 @@ package com.loopers.domain.brand; +import com.loopers.domain.brand.vo.BrandId; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; @@ -11,11 +12,10 @@ public class BrandService { private final BrandRepository brandRepository; - private final BrandReader brandReader; @Transactional public BrandModel createBrand(String brandId, String brandName) { - if (brandReader.exists(brandId)) { + if (brandRepository.existsByBrandId(new BrandId(brandId))) { throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 브랜드 ID입니다."); } @@ -25,10 +25,11 @@ public BrandModel createBrand(String brandId, String brandName) { @Transactional public void deleteBrand(String brandId) { - BrandModel brand = brandReader.getOrThrow(brandId); + BrandModel brand = brandRepository.findByBrandId(new BrandId(brandId)) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 브랜드가 존재하지 않습니다.")); // TODO: Product 도메인 구현 후 상품 참조 체크 로직 추가 - // if (productReader.existsByBrandId(brandId)) { + // if (productRepository.existsByRefBrandId(brand.getId())) { // throw new CoreException(ErrorType.CONFLICT, "해당 브랜드를 참조하는 상품이 존재하여 삭제할 수 없습니다."); // } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberReader.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberReader.java deleted file mode 100644 index 6755af5f6..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberReader.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.loopers.domain.member; - -import com.loopers.domain.member.vo.MemberId; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -@Component -@RequiredArgsConstructor -public class MemberReader { - - private final MemberRepository memberRepository; - - @Transactional(readOnly = true) - public MemberModel getMemberByMemberId(String memberId) { - return memberRepository.findByMemberId(new MemberId(memberId)) - .orElse(null); - } - - @Transactional(readOnly = true) - public MemberModel getOrThrow(String memberId) { - return memberRepository.findByMemberId(new MemberId(memberId)) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 회원이 존재하지 않습니다.")); - } - - @Transactional(readOnly = true) - public boolean existsByMemberId(String memberId) { - return memberRepository.existsByMemberId(new MemberId(memberId)); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java index b2b1c3ac4..a8507b1d8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java @@ -1,5 +1,6 @@ package com.loopers.domain.member; +import com.loopers.domain.member.vo.MemberId; import com.loopers.security.PasswordHasher; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; @@ -12,12 +13,11 @@ public class MemberService { private final MemberRepository memberRepository; - private final MemberReader memberReader; private final PasswordHasher passwordHasher; @Transactional public MemberModel register(String memberId, String rawPassword, String email, String birthDate, String name, Gender gender) { - if (memberReader.existsByMemberId(memberId)) { + if (memberRepository.existsByMemberId(new MemberId(memberId))) { throw new CoreException(ErrorType.CONFLICT, "이미 가입된 ID 입니다."); } @@ -27,7 +27,8 @@ public MemberModel register(String memberId, String rawPassword, String email, S @Transactional(readOnly = true) public MemberModel authenticate(String loginId, String loginPw) { - MemberModel member = memberReader.getOrThrow(loginId); + MemberModel member = memberRepository.findByMemberId(new MemberId(loginId)) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 회원이 존재하지 않습니다.")); if (!member.verifyPassword(passwordHasher, loginPw)) { throw new CoreException(ErrorType.UNAUTHORIZED, "비밀번호가 일치하지 않습니다."); } @@ -37,7 +38,8 @@ public MemberModel authenticate(String loginId, String loginPw) { @Transactional public void changePassword(String loginId, String loginPw, String currentPassword, String newPassword) { - MemberModel member = memberReader.getOrThrow(loginId); + MemberModel member = memberRepository.findByMemberId(new MemberId(loginId)) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 회원이 존재하지 않습니다.")); if (!member.verifyPassword(passwordHasher, loginPw)) { throw new CoreException(ErrorType.UNAUTHORIZED, "비밀번호가 일치하지 않습니다."); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductReader.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductReader.java deleted file mode 100644 index 7db484fcb..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductReader.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.loopers.domain.product; - -import com.loopers.domain.product.vo.ProductId; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -@Component -@RequiredArgsConstructor -public class ProductReader { - - private final ProductRepository productRepository; - - @Transactional(readOnly = true) - public ProductModel getOrThrow(String productId) { - return productRepository.findByProductId(new ProductId(productId)) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 상품이 존재하지 않습니다.")); - } - - @Transactional(readOnly = true) - public boolean exists(String productId) { - return productRepository.existsByProductId(new ProductId(productId)); - } - - @Transactional(readOnly = true) - public Page findProducts(Long refBrandId, String sortBy, Pageable pageable) { - return productRepository.findProducts(refBrandId, sortBy, pageable); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java index 143cd2ed5..0ae09f386 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -1,6 +1,8 @@ package com.loopers.domain.product; -import com.loopers.domain.brand.BrandReader; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.brand.vo.BrandId; +import com.loopers.domain.product.vo.ProductId; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; @@ -16,18 +18,18 @@ public class ProductService { private final ProductRepository productRepository; - private final ProductReader productReader; - private final BrandReader brandReader; + private final BrandRepository brandRepository; @Transactional public ProductModel createProduct(String productId, String brandId, String productName, BigDecimal price, int stockQuantity) { // 중복 체크 - if (productReader.exists(productId)) { + if (productRepository.existsByProductId(new ProductId(productId))) { throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 상품 ID입니다."); } // 브랜드 존재 확인 및 PK 획득 - var brand = brandReader.getOrThrow(brandId); + var brand = brandRepository.findByBrandId(new BrandId(brandId)) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 브랜드가 존재하지 않습니다.")); Long refBrandId = brand.getId(); // 상품 생성 @@ -39,7 +41,8 @@ public ProductModel createProduct(String productId, String brandId, String produ @Transactional public void deleteProduct(String productId) { - ProductModel product = productReader.getOrThrow(productId); + ProductModel product = productRepository.findByProductId(new ProductId(productId)) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 상품이 존재하지 않습니다.")); // Soft delete product.markAsDeleted(); @@ -51,9 +54,10 @@ public Page getProducts(String brandId, String sortBy, Pageable pa // brandId가 제공되면 Brand PK로 변환 Long refBrandId = null; if (brandId != null && !brandId.isBlank()) { - var brand = brandReader.getOrThrow(brandId); + var brand = brandRepository.findByBrandId(new BrandId(brandId)) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 브랜드가 존재하지 않습니다.")); refBrandId = brand.getId(); } - return productReader.findProducts(refBrandId, sortBy, pageable); + return productRepository.findProducts(refBrandId, sortBy, pageable); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java index 065e5c15c..995717155 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java @@ -31,9 +31,6 @@ class BrandServiceIntegrationTest { @Autowired private BrandService brandService; - @Autowired - private BrandReader brandReader; - @Autowired private BrandJpaRepository brandJpaRepository; @@ -106,7 +103,7 @@ void deleteBrand_success() { brandService.deleteBrand(brandId); // then - BrandModel deletedBrand = brandReader.getOrThrow(brandId); + BrandModel deletedBrand = brandJpaRepository.findByBrandId(new BrandId(brandId)).orElseThrow(); assertThat(deletedBrand.isDeleted()).isTrue(); assertThat(deletedBrand.getDeletedAt()).isNotNull(); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java index 2932b9e77..c0ded67eb 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java @@ -11,6 +11,8 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import java.util.Optional; + import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; @@ -23,9 +25,6 @@ class BrandServiceTest { @Mock private BrandRepository brandRepository; - @Mock - private BrandReader brandReader; - @InjectMocks private BrandService brandService; @@ -37,7 +36,7 @@ void createBrand_success() { String brandName = "Nike"; BrandModel mockBrand = BrandModel.create(brandId, brandName); - when(brandReader.exists(brandId)).thenReturn(false); + when(brandRepository.existsByBrandId(any(BrandId.class))).thenReturn(false); when(brandRepository.save(any(BrandModel.class))).thenReturn(mockBrand); // when @@ -48,7 +47,7 @@ void createBrand_success() { assertThat(result.getBrandId()).isEqualTo(new BrandId(brandId)); assertThat(result.getBrandName()).isEqualTo(new BrandName(brandName)); - verify(brandReader, times(1)).exists(brandId); + verify(brandRepository, times(1)).existsByBrandId(any(BrandId.class)); verify(brandRepository, times(1)).save(any(BrandModel.class)); } @@ -57,7 +56,7 @@ void createBrand_success() { void createBrand_duplicateId_throwsException() { // given String brandId = "adidas"; - when(brandReader.exists(brandId)).thenReturn(true); + when(brandRepository.existsByBrandId(any(BrandId.class))).thenReturn(true); // when & then assertThatThrownBy(() -> brandService.createBrand(brandId, "Adidas")) @@ -66,7 +65,7 @@ void createBrand_duplicateId_throwsException() { .extracting("errorType") .isEqualTo(ErrorType.CONFLICT); - verify(brandReader, times(1)).exists(brandId); + verify(brandRepository, times(1)).existsByBrandId(any(BrandId.class)); verify(brandRepository, never()).save(any(BrandModel.class)); } @@ -77,14 +76,14 @@ void deleteBrand_success() { String brandId = "puma"; BrandModel mockBrand = BrandModel.create(brandId, "Puma"); - when(brandReader.getOrThrow(brandId)).thenReturn(mockBrand); + when(brandRepository.findByBrandId(any(BrandId.class))).thenReturn(Optional.of(mockBrand)); when(brandRepository.save(any(BrandModel.class))).thenReturn(mockBrand); // when brandService.deleteBrand(brandId); // then - verify(brandReader, times(1)).getOrThrow(brandId); + verify(brandRepository, times(1)).findByBrandId(any(BrandId.class)); verify(brandRepository, times(1)).save(mockBrand); assertThat(mockBrand.isDeleted()).isTrue(); } @@ -93,18 +92,19 @@ void deleteBrand_success() { @DisplayName("브랜드 삭제 - 존재하지 않는 브랜드") void deleteBrand_notFound_throwsException() { // given - String brandId = "nonexistent"; - when(brandReader.getOrThrow(brandId)) - .thenThrow(new CoreException(ErrorType.NOT_FOUND, "해당 ID의 브랜드가 존재하지 않습니다.")); + String brandId = "invalid123"; // 10자 이하로 변경 + when(brandRepository.findByBrandId(any(BrandId.class))).thenReturn(Optional.empty()); // when & then assertThatThrownBy(() -> brandService.deleteBrand(brandId)) .isInstanceOf(CoreException.class) .hasMessageContaining("해당 ID의 브랜드가 존재하지 않습니다.") - .extracting("errorType") - .isEqualTo(ErrorType.NOT_FOUND); + .satisfies(e -> { + CoreException ce = (CoreException) e; + assertThat(ce.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + }); - verify(brandReader, times(1)).getOrThrow(brandId); + verify(brandRepository, times(1)).findByBrandId(any(BrandId.class)); verify(brandRepository, never()).save(any(BrandModel.class)); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java index 126e3ef6d..488be28d3 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java @@ -30,9 +30,6 @@ class MemberServiceIntegrationTest { @Autowired private MemberService memberService; - @Autowired - private MemberReader memberReader; - @Autowired private MemberJpaRepository memberJpaRepository; @@ -243,7 +240,7 @@ void changesPassword_whenValidCurrentAndNewPassword() { memberService.changePassword(VALID_MEMBER_ID, VALID_PASSWORD, VALID_PASSWORD, newPassword); // assert - MemberModel member = memberReader.getOrThrow(VALID_MEMBER_ID); + MemberModel member = memberJpaRepository.findByMemberId(new MemberId(VALID_MEMBER_ID)).orElseThrow(); assertThat(member.verifyPassword(passwordHasher, newPassword)).isTrue(); } @@ -340,7 +337,7 @@ void returnsMemberInfo_whenMemberExists() { ); // act - MemberModel foundMember = memberReader.getMemberByMemberId(VALID_MEMBER_ID); + MemberModel foundMember = memberJpaRepository.findByMemberId(new MemberId(VALID_MEMBER_ID)).orElse(null); // assert assertAll( @@ -364,7 +361,7 @@ void returnsNull_whenMemberDoesNotExist() { String nonExistentMemberId = "nonexist1"; // act - MemberModel foundMember = memberReader.getMemberByMemberId(nonExistentMemberId); + MemberModel foundMember = memberJpaRepository.findByMemberId(new MemberId(nonExistentMemberId)).orElse(null); // assert assertThat(foundMember).isNull(); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java index 0f260e983..4c98b82d3 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java @@ -1,7 +1,7 @@ package com.loopers.domain.product; import com.loopers.domain.brand.BrandModel; -import com.loopers.domain.brand.BrandReader; +import com.loopers.domain.brand.BrandRepository; import com.loopers.domain.brand.vo.BrandId; import com.loopers.domain.product.vo.ProductId; import com.loopers.support.error.CoreException; @@ -30,10 +30,7 @@ class ProductServiceTest { private ProductRepository productRepository; @Mock - private ProductReader productReader; - - @Mock - private BrandReader brandReader; + private BrandRepository brandRepository; @InjectMocks private ProductService productService; @@ -55,8 +52,8 @@ void createProduct_withValidBrand_success() { BrandModel mockBrand = mock(BrandModel.class); when(mockBrand.getId()).thenReturn(1L); - when(productReader.exists(productId)).thenReturn(false); - when(brandReader.getOrThrow(brandId)).thenReturn(mockBrand); + when(productRepository.existsByProductId(any(ProductId.class))).thenReturn(false); + when(brandRepository.findByBrandId(any(BrandId.class))).thenReturn(Optional.of(mockBrand)); when(productRepository.save(any(ProductModel.class))).thenAnswer(invocation -> invocation.getArgument(0)); // when @@ -66,8 +63,8 @@ void createProduct_withValidBrand_success() { assertThat(result).isNotNull(); assertThat(result.getProductId().value()).isEqualTo(productId); assertThat(result.getRefBrandId().value()).isEqualTo(1L); - verify(productReader, times(1)).exists(productId); - verify(brandReader, times(1)).getOrThrow(brandId); + verify(productRepository, times(1)).existsByProductId(any(ProductId.class)); + verify(brandRepository, times(1)).findByBrandId(any(BrandId.class)); verify(productRepository, times(1)).save(any(ProductModel.class)); } @@ -81,15 +78,15 @@ void createProduct_withDuplicateId_throwsException() { BigDecimal price = new BigDecimal("150000"); int stockQuantity = 100; - when(productReader.exists(productId)).thenReturn(true); + when(productRepository.existsByProductId(any(ProductId.class))).thenReturn(true); // when & then assertThatThrownBy(() -> productService.createProduct(productId, brandId, productName, price, stockQuantity)) .isInstanceOf(CoreException.class) .hasMessageContaining("이미 존재하는 상품 ID입니다"); - verify(productReader, times(1)).exists(productId); - verify(brandReader, never()).getOrThrow(anyString()); + verify(productRepository, times(1)).existsByProductId(any(ProductId.class)); + verify(brandRepository, never()).findByBrandId(any(BrandId.class)); verify(productRepository, never()).save(any(ProductModel.class)); } @@ -98,22 +95,21 @@ void createProduct_withDuplicateId_throwsException() { void createProduct_withNonExistentBrand_throwsException() { // given String productId = "prod1"; - String brandId = "invalidBrand"; + String brandId = "invalid12"; // 10자 이하로 변경 String productName = "Nike Air Max"; BigDecimal price = new BigDecimal("150000"); int stockQuantity = 100; - when(productReader.exists(productId)).thenReturn(false); - doThrow(new CoreException(ErrorType.NOT_FOUND, "해당 ID의 브랜드가 존재하지 않습니다.")) - .when(brandReader).getOrThrow(brandId); + when(productRepository.existsByProductId(any(ProductId.class))).thenReturn(false); + when(brandRepository.findByBrandId(any(BrandId.class))).thenReturn(Optional.empty()); // when & then assertThatThrownBy(() -> productService.createProduct(productId, brandId, productName, price, stockQuantity)) .isInstanceOf(CoreException.class) - .hasMessageContaining("브랜드가 존재하지 않습니다"); + .hasMessageContaining("해당 ID의 브랜드가 존재하지 않습니다"); - verify(productReader, times(1)).exists(productId); - verify(brandReader, times(1)).getOrThrow(brandId); + verify(productRepository, times(1)).existsByProductId(any(ProductId.class)); + verify(brandRepository, times(1)).findByBrandId(any(BrandId.class)); verify(productRepository, never()).save(any(ProductModel.class)); } } @@ -129,7 +125,7 @@ void deleteProduct_existingProduct_success() { String productId = "prod1"; ProductModel product = ProductModel.create(productId, 1L, "Nike Air", new BigDecimal("100000"), 50); - when(productReader.getOrThrow(productId)).thenReturn(product); + when(productRepository.findByProductId(any(ProductId.class))).thenReturn(Optional.of(product)); when(productRepository.save(any(ProductModel.class))).thenAnswer(invocation -> invocation.getArgument(0)); // when @@ -137,7 +133,7 @@ void deleteProduct_existingProduct_success() { // then assertThat(product.isDeleted()).isTrue(); - verify(productReader, times(1)).getOrThrow(productId); + verify(productRepository, times(1)).findByProductId(any(ProductId.class)); verify(productRepository, times(1)).save(product); } @@ -147,15 +143,14 @@ void deleteProduct_nonExistentProduct_throwsException() { // given String productId = "invalidProduct"; - when(productReader.getOrThrow(productId)) - .thenThrow(new CoreException(ErrorType.NOT_FOUND, "해당 ID의 상품이 존재하지 않습니다.")); + when(productRepository.findByProductId(any(ProductId.class))).thenReturn(Optional.empty()); // when & then assertThatThrownBy(() -> productService.deleteProduct(productId)) .isInstanceOf(CoreException.class) .hasMessageContaining("상품이 존재하지 않습니다"); - verify(productReader, times(1)).getOrThrow(productId); + verify(productRepository, times(1)).findByProductId(any(ProductId.class)); verify(productRepository, never()).save(any(ProductModel.class)); } } From be00880e3a90063707a13b5dbfb17bf21854f52f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B8=B0=EB=AC=98=ED=95=9C=20=EC=A3=BC=EB=8B=88=EC=96=B4?= =?UTF-8?q?=20=EA=B0=9C=EB=B0=9C=EC=9E=90?= Date: Mon, 16 Feb 2026 21:56:41 +0900 Subject: [PATCH 19/50] =?UTF-8?q?feat(like)=20:=20Like=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20=EB=8B=A8=EC=9C=84?= =?UTF-8?q?/=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B5=AC?= =?UTF-8?q?=EC=B6=95.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/domain/like/LikeRepository.java | 12 ++ .../com/loopers/domain/like/LikeService.java | 57 ++++++ .../like/LikeJpaRepository.java | 12 ++ .../like/LikeRepositoryImpl.java | 32 ++++ .../like/LikeServiceIntegrationTest.java | 136 ++++++++++++++ .../loopers/domain/like/LikeServiceTest.java | 172 ++++++++++++++++++ 6 files changed, 421 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java new file mode 100644 index 000000000..07c446157 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java @@ -0,0 +1,12 @@ +package com.loopers.domain.like; + +import com.loopers.domain.like.vo.RefMemberId; +import com.loopers.domain.like.vo.RefProductId; + +import java.util.Optional; + +public interface LikeRepository { + LikeModel save(LikeModel like); + Optional findByRefMemberIdAndRefProductId(RefMemberId refMemberId, RefProductId refProductId); + void delete(LikeModel like); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java new file mode 100644 index 000000000..e8756671f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java @@ -0,0 +1,57 @@ +package com.loopers.domain.like; + +import com.loopers.domain.like.vo.RefMemberId; +import com.loopers.domain.like.vo.RefProductId; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.vo.ProductId; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class LikeService { + + private final LikeRepository likeRepository; + private final ProductRepository productRepository; + + @Transactional + public LikeModel addLike(Long memberId, String productId) { + // 상품 존재 확인 + var product = productRepository.findByProductId(new ProductId(productId)) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 상품이 존재하지 않습니다.")); + + RefMemberId refMemberId = new RefMemberId(memberId); + RefProductId refProductId = new RefProductId(product.getId()); + + // 이미 좋아요가 있는지 확인 (멱등성) + return likeRepository.findByRefMemberIdAndRefProductId(refMemberId, refProductId) + .orElseGet(() -> { + try { + LikeModel like = LikeModel.create(memberId, product.getId()); + return likeRepository.save(like); + } catch (DataIntegrityViolationException e) { + // 동시성 이슈로 UNIQUE 제약 위반 시 다시 조회 + return likeRepository.findByRefMemberIdAndRefProductId(refMemberId, refProductId) + .orElseThrow(() -> new CoreException(ErrorType.CONFLICT, "좋아요 추가 중 오류가 발생했습니다.")); + } + }); + } + + @Transactional + public void removeLike(Long memberId, String productId) { + // 상품 존재 확인 + var product = productRepository.findByProductId(new ProductId(productId)) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 상품이 존재하지 않습니다.")); + + RefMemberId refMemberId = new RefMemberId(memberId); + RefProductId refProductId = new RefProductId(product.getId()); + + // 좋아요가 있으면 삭제 (멱등성 - 없어도 예외 발생 안함) + likeRepository.findByRefMemberIdAndRefProductId(refMemberId, refProductId) + .ifPresent(likeRepository::delete); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java new file mode 100644 index 000000000..869346649 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java @@ -0,0 +1,12 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.LikeModel; +import com.loopers.domain.like.vo.RefMemberId; +import com.loopers.domain.like.vo.RefProductId; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface LikeJpaRepository extends JpaRepository { + Optional findByRefMemberIdAndRefProductId(RefMemberId refMemberId, RefProductId refProductId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java new file mode 100644 index 000000000..0fd298cb0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -0,0 +1,32 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.LikeModel; +import com.loopers.domain.like.LikeRepository; +import com.loopers.domain.like.vo.RefMemberId; +import com.loopers.domain.like.vo.RefProductId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class LikeRepositoryImpl implements LikeRepository { + + private final LikeJpaRepository likeJpaRepository; + + @Override + public LikeModel save(LikeModel like) { + return likeJpaRepository.save(like); + } + + @Override + public Optional findByRefMemberIdAndRefProductId(RefMemberId refMemberId, RefProductId refProductId) { + return likeJpaRepository.findByRefMemberIdAndRefProductId(refMemberId, refProductId); + } + + @Override + public void delete(LikeModel like) { + likeJpaRepository.delete(like); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java new file mode 100644 index 000000000..58b8e3e5b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java @@ -0,0 +1,136 @@ +package com.loopers.domain.like; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +import com.loopers.support.error.CoreException; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +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 java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest +@DisplayName("LikeService 통합 테스트") +class LikeServiceIntegrationTest { + + @Autowired + private LikeService likeService; + + @Autowired + private ProductService productService; + + @Autowired + private BrandService brandService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("좋아요를 추가할 때,") + @Nested + class AddLike { + + @Test + @DisplayName("좋아요 추가 성공") + void addLike_success() { + // given + BrandModel brand = brandService.createBrand("nike", "Nike"); + ProductModel product = productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 10); + Long memberId = 1L; + + // when + LikeModel like = likeService.addLike(memberId, "prod1"); + + // then + assertThat(like).isNotNull(); + assertThat(like.getRefMemberId().value()).isEqualTo(memberId); + assertThat(like.getRefProductId().value()).isEqualTo(product.getId()); + } + + @Test + @DisplayName("중복 좋아요 추가 시 기존 좋아요 반환 (멱등성)") + void addLike_duplicate_returnsExisting() { + // given + BrandModel brand = brandService.createBrand("nike", "Nike"); + ProductModel product = productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 10); + Long memberId = 1L; + + // when + LikeModel firstLike = likeService.addLike(memberId, "prod1"); + LikeModel secondLike = likeService.addLike(memberId, "prod1"); + + // then + assertThat(firstLike.getId()).isEqualTo(secondLike.getId()); + } + + @Test + @DisplayName("존재하지 않는 상품에 좋아요 추가 시 예외 발생") + void addLike_productNotFound_throwsException() { + // given + Long memberId = 1L; + + // when & then + assertThatThrownBy(() -> likeService.addLike(memberId, "invalid")) + .isInstanceOf(CoreException.class) + .hasMessageContaining("해당 ID의 상품이 존재하지 않습니다"); + } + } + + @DisplayName("좋아요를 취소할 때,") + @Nested + class RemoveLike { + + @Test + @DisplayName("좋아요 취소 성공") + void removeLike_success() { + // given + BrandModel brand = brandService.createBrand("nike", "Nike"); + ProductModel product = productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 10); + Long memberId = 1L; + likeService.addLike(memberId, "prod1"); + + // when + likeService.removeLike(memberId, "prod1"); + + // then - 중복 취소해도 예외 발생하지 않음 + likeService.removeLike(memberId, "prod1"); + } + + @Test + @DisplayName("좋아요가 없어도 예외 발생하지 않음 (멱등성)") + void removeLike_notExists_noException() { + // given + BrandModel brand = brandService.createBrand("nike", "Nike"); + ProductModel product = productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 10); + Long memberId = 1L; + + // when & then - 예외 발생하지 않음 + likeService.removeLike(memberId, "prod1"); + } + + @Test + @DisplayName("존재하지 않는 상품에 좋아요 취소 시 예외 발생") + void removeLike_productNotFound_throwsException() { + // given + Long memberId = 1L; + + // when & then + assertThatThrownBy(() -> likeService.removeLike(memberId, "invalid")) + .isInstanceOf(CoreException.class) + .hasMessageContaining("해당 ID의 상품이 존재하지 않습니다"); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java new file mode 100644 index 000000000..53c659b89 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java @@ -0,0 +1,172 @@ +package com.loopers.domain.like; + +import com.loopers.domain.like.vo.RefMemberId; +import com.loopers.domain.like.vo.RefProductId; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.vo.ProductId; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; + +import java.math.BigDecimal; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@DisplayName("LikeService 단위 테스트") +@ExtendWith(MockitoExtension.class) +class LikeServiceTest { + + @Mock + private LikeRepository likeRepository; + + @Mock + private ProductRepository productRepository; + + @InjectMocks + private LikeService likeService; + + @DisplayName("좋아요를 추가할 때,") + @Nested + class AddLike { + + @Test + @DisplayName("유효한 상품에 좋아요 추가 성공") + void addLike_success() { + // given + Long memberId = 1L; + String productId = "prod1"; + ProductModel mockProduct = mock(ProductModel.class); + when(mockProduct.getId()).thenReturn(100L); + + LikeModel mockLike = LikeModel.create(memberId, 100L); + + when(productRepository.findByProductId(any(ProductId.class))).thenReturn(Optional.of(mockProduct)); + when(likeRepository.findByRefMemberIdAndRefProductId(any(RefMemberId.class), any(RefProductId.class))) + .thenReturn(Optional.empty()); + when(likeRepository.save(any(LikeModel.class))).thenReturn(mockLike); + + // when + LikeModel result = likeService.addLike(memberId, productId); + + // then + assertThat(result).isNotNull(); + verify(productRepository, times(1)).findByProductId(any(ProductId.class)); + verify(likeRepository, times(1)).findByRefMemberIdAndRefProductId(any(RefMemberId.class), any(RefProductId.class)); + verify(likeRepository, times(1)).save(any(LikeModel.class)); + } + + @Test + @DisplayName("이미 좋아요가 있으면 기존 좋아요 반환 (멱등성)") + void addLike_alreadyExists_returnsExisting() { + // given + Long memberId = 1L; + String productId = "prod1"; + ProductModel mockProduct = mock(ProductModel.class); + when(mockProduct.getId()).thenReturn(100L); + LikeModel existingLike = LikeModel.create(memberId, 100L); + + when(productRepository.findByProductId(any(ProductId.class))).thenReturn(Optional.of(mockProduct)); + when(likeRepository.findByRefMemberIdAndRefProductId(any(RefMemberId.class), any(RefProductId.class))) + .thenReturn(Optional.of(existingLike)); + + // when + LikeModel result = likeService.addLike(memberId, productId); + + // then + assertThat(result).isEqualTo(existingLike); + verify(likeRepository, never()).save(any(LikeModel.class)); + } + + @Test + @DisplayName("존재하지 않는 상품에 좋아요 추가 시 예외 발생") + void addLike_productNotFound_throwsException() { + // given + Long memberId = 1L; + String productId = "invalid"; + + when(productRepository.findByProductId(any(ProductId.class))).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> likeService.addLike(memberId, productId)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("해당 ID의 상품이 존재하지 않습니다"); + + verify(likeRepository, never()).save(any(LikeModel.class)); + } + } + + @DisplayName("좋아요를 취소할 때,") + @Nested + class RemoveLike { + + @Test + @DisplayName("좋아요 취소 성공") + void removeLike_success() { + // given + Long memberId = 1L; + String productId = "prod1"; + ProductModel mockProduct = mock(ProductModel.class); + when(mockProduct.getId()).thenReturn(100L); + LikeModel existingLike = LikeModel.create(memberId, 100L); + + when(productRepository.findByProductId(any(ProductId.class))).thenReturn(Optional.of(mockProduct)); + when(likeRepository.findByRefMemberIdAndRefProductId(any(RefMemberId.class), any(RefProductId.class))) + .thenReturn(Optional.of(existingLike)); + + // when + likeService.removeLike(memberId, productId); + + // then + verify(likeRepository, times(1)).delete(existingLike); + } + + @Test + @DisplayName("좋아요가 없어도 예외 발생하지 않음 (멱등성)") + void removeLike_notExists_noException() { + // given + Long memberId = 1L; + String productId = "prod1"; + ProductModel mockProduct = mock(ProductModel.class); + when(mockProduct.getId()).thenReturn(100L); + + when(productRepository.findByProductId(any(ProductId.class))).thenReturn(Optional.of(mockProduct)); + when(likeRepository.findByRefMemberIdAndRefProductId(any(RefMemberId.class), any(RefProductId.class))) + .thenReturn(Optional.empty()); + + // when + likeService.removeLike(memberId, productId); + + // then + verify(likeRepository, never()).delete(any(LikeModel.class)); + } + + @Test + @DisplayName("존재하지 않는 상품에 좋아요 취소 시 예외 발생") + void removeLike_productNotFound_throwsException() { + // given + Long memberId = 1L; + String productId = "invalid"; + + when(productRepository.findByProductId(any(ProductId.class))).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> likeService.removeLike(memberId, productId)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("해당 ID의 상품이 존재하지 않습니다"); + + verify(likeRepository, never()).delete(any(LikeModel.class)); + } + } +} From f692bb639edcb12463704c2a5b07dd252c04373d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B8=B0=EB=AC=98=ED=95=9C=20=EC=A3=BC=EB=8B=88=EC=96=B4?= =?UTF-8?q?=20=EA=B0=9C=EB=B0=9C=EC=9E=90?= Date: Mon, 16 Feb 2026 21:59:42 +0900 Subject: [PATCH 20/50] =?UTF-8?q?feat(like):=20REST=20API=20=EB=B0=8F=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B5=AC=EC=B6=95.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .http/like.http | 26 +++ .../loopers/application/like/LikeFacade.java | 21 ++ .../loopers/application/like/LikeInfo.java | 17 ++ .../interfaces/api/like/LikeV1Controller.java | 32 ++++ .../interfaces/api/like/LikeV1Dto.java | 38 ++++ .../api/like/LikeV1ControllerE2ETest.java | 181 ++++++++++++++++++ 6 files changed, 315 insertions(+) create mode 100644 .http/like.http create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ControllerE2ETest.java diff --git a/.http/like.http b/.http/like.http new file mode 100644 index 000000000..3cb16ca8b --- /dev/null +++ b/.http/like.http @@ -0,0 +1,26 @@ +### 좋아요 추가 +POST http://localhost:8080/api/v1/likes +Content-Type: application/json + +{ + "memberId": 1, + "productId": "prod1" +} + +### 좋아요 취소 +DELETE http://localhost:8080/api/v1/likes +Content-Type: application/json + +{ + "memberId": 1, + "productId": "prod1" +} + +### 존재하지 않는 상품에 좋아요 추가 (404 에러) +POST http://localhost:8080/api/v1/likes +Content-Type: application/json + +{ + "memberId": 1, + "productId": "invalid" +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java new file mode 100644 index 000000000..633f53d79 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -0,0 +1,21 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.LikeService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class LikeFacade { + + private final LikeService likeService; + + public LikeInfo addLike(Long memberId, String productId) { + var like = likeService.addLike(memberId, productId); + return LikeInfo.from(like); + } + + public void removeLike(Long memberId, String productId) { + likeService.removeLike(memberId, productId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java new file mode 100644 index 000000000..464fca31f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java @@ -0,0 +1,17 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.LikeModel; + +public record LikeInfo( + Long id, + Long refMemberId, + Long refProductId +) { + public static LikeInfo from(LikeModel like) { + return new LikeInfo( + like.getId(), + like.getRefMemberId().value(), + like.getRefProductId().value() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java new file mode 100644 index 000000000..bb5970897 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java @@ -0,0 +1,32 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.like.LikeFacade; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import static com.loopers.interfaces.api.like.LikeV1Dto.*; + +@RestController +@RequestMapping("/api/v1/likes") +@RequiredArgsConstructor +public class LikeV1Controller { + + private final LikeFacade likeFacade; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse addLike(@Valid @RequestBody AddLikeRequest request) { + var info = likeFacade.addLike(request.memberId(), request.productId()); + return ApiResponse.success(LikeResponse.from(info)); + } + + @DeleteMapping + @ResponseStatus(HttpStatus.NO_CONTENT) + public ApiResponse removeLike(@Valid @RequestBody RemoveLikeRequest request) { + likeFacade.removeLike(request.memberId(), request.productId()); + return ApiResponse.success(null); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java new file mode 100644 index 000000000..12a66c6f2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java @@ -0,0 +1,38 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.like.LikeInfo; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public class LikeV1Dto { + + public record AddLikeRequest( + @NotNull(message = "회원 ID는 필수입니다.") + Long memberId, + + @NotBlank(message = "상품 ID는 필수입니다.") + String productId + ) {} + + public record RemoveLikeRequest( + @NotNull(message = "회원 ID는 필수입니다.") + Long memberId, + + @NotBlank(message = "상품 ID는 필수입니다.") + String productId + ) {} + + public record LikeResponse( + Long id, + Long refMemberId, + Long refProductId + ) { + public static LikeResponse from(LikeInfo info) { + return new LikeResponse( + info.id(), + info.refMemberId(), + info.refProductId() + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ControllerE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ControllerE2ETest.java new file mode 100644 index 000000000..1dbca8f51 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ControllerE2ETest.java @@ -0,0 +1,181 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.ProductService; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +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.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; + +import java.math.BigDecimal; + +import static com.loopers.interfaces.api.like.LikeV1Dto.*; +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DisplayName("Like API E2E 테스트") +class LikeV1ControllerE2ETest { + + @LocalServerPort + private int port; + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private BrandService brandService; + + @Autowired + private ProductService productService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private String baseUrl() { + return "http://localhost:" + port + "/api/v1/likes"; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("POST /api/v1/likes") + @Nested + class AddLike { + + @Test + @DisplayName("좋아요 추가 성공 시 201 Created와 생성된 좋아요 정보 반환") + void addLike_success_returns201() { + // given + brandService.createBrand("nike", "Nike"); + var product = productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 10); + + var request = new AddLikeRequest(1L, "prod1"); + + // when + var response = restTemplate.postForEntity( + baseUrl(), + request, + ApiResponse.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().success()).isTrue(); + } + + @Test + @DisplayName("중복 좋아요 추가 시 201 Created 반환 (멱등성)") + void addLike_duplicate_returns201() { + // given + brandService.createBrand("nike", "Nike"); + productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 10); + + var request = new AddLikeRequest(1L, "prod1"); + + // when + var firstResponse = restTemplate.postForEntity(baseUrl(), request, ApiResponse.class); + var secondResponse = restTemplate.postForEntity(baseUrl(), request, ApiResponse.class); + + // then + assertThat(firstResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(secondResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); + } + + @Test + @DisplayName("존재하지 않는 상품에 좋아요 추가 시 404 Not Found 반환") + void addLike_productNotFound_returns404() { + // given + var request = new AddLikeRequest(1L, "invalid"); + + // when + var response = restTemplate.postForEntity( + baseUrl(), + request, + ApiResponse.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @DisplayName("DELETE /api/v1/likes") + @Nested + class RemoveLike { + + @Test + @DisplayName("좋아요 취소 성공 시 204 No Content 반환") + void removeLike_success_returns204() { + // given + brandService.createBrand("nike", "Nike"); + productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 10); + + var addRequest = new AddLikeRequest(1L, "prod1"); + restTemplate.postForEntity(baseUrl(), addRequest, ApiResponse.class); + + var removeRequest = new RemoveLikeRequest(1L, "prod1"); + + // when + var response = restTemplate.exchange( + baseUrl(), + HttpMethod.DELETE, + new HttpEntity<>(removeRequest), + ApiResponse.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + } + + @Test + @DisplayName("좋아요가 없어도 204 No Content 반환 (멱등성)") + void removeLike_notExists_returns204() { + // given + brandService.createBrand("nike", "Nike"); + productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 10); + + var removeRequest = new RemoveLikeRequest(1L, "prod1"); + + // when + var response = restTemplate.exchange( + baseUrl(), + HttpMethod.DELETE, + new HttpEntity<>(removeRequest), + ApiResponse.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + } + + @Test + @DisplayName("존재하지 않는 상품에 좋아요 취소 시 404 Not Found 반환") + void removeLike_productNotFound_returns404() { + // given + var removeRequest = new RemoveLikeRequest(1L, "invalid"); + + // when + var response = restTemplate.exchange( + baseUrl(), + HttpMethod.DELETE, + new HttpEntity<>(removeRequest), + ApiResponse.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } +} From b9ef9924df11f1a04860dab522efe6a6e727b2e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B8=B0=EB=AC=98=ED=95=9C=20=EC=A3=BC=EB=8B=88=EC=96=B4?= =?UTF-8?q?=20=EA=B0=9C=EB=B0=9C=EC=9E=90?= Date: Mon, 16 Feb 2026 22:08:13 +0900 Subject: [PATCH 21/50] =?UTF-8?q?feat(order):=20VO,=20Entity=20=EB=B0=8F?= =?UTF-8?q?=20Enum=20=EA=B5=AC=ED=98=84.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/domain/order/OrderItemModel.java | 58 +++++++ .../com/loopers/domain/order/OrderModel.java | 77 ++++++++++ .../com/loopers/domain/order/OrderStatus.java | 25 ++++ .../com/loopers/domain/order/vo/OrderId.java | 25 ++++ .../loopers/domain/order/vo/OrderItemId.java | 25 ++++ .../jpa/converter/OrderIdConverter.java | 19 +++ .../jpa/converter/OrderItemIdConverter.java | 19 +++ .../domain/order/OrderItemModelTest.java | 51 +++++++ .../loopers/domain/order/OrderModelTest.java | 141 ++++++++++++++++++ .../loopers/domain/order/OrderStatusTest.java | 62 ++++++++ .../api/like/LikeV1ControllerE2ETest.java | 2 +- 11 files changed, 503 insertions(+), 1 deletion(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/vo/OrderId.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/vo/OrderItemId.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/OrderIdConverter.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/OrderItemIdConverter.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderStatusTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java new file mode 100644 index 000000000..4e3226147 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java @@ -0,0 +1,58 @@ +package com.loopers.domain.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.order.vo.OrderItemId; +import com.loopers.infrastructure.jpa.converter.OrderItemIdConverter; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +@Entity +@Table(name = "order_items") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class OrderItemModel extends BaseEntity { + + @Convert(converter = OrderItemIdConverter.class) + @Column(name = "order_item_id", nullable = false, unique = true, length = 36) + private OrderItemId orderItemId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "order_id", nullable = false) + private OrderModel order; + + @Column(name = "product_id", nullable = false, length = 20) + private String productId; // 스냅샷: 주문 시점의 상품 ID + + @Column(name = "product_name", nullable = false, length = 100) + private String productName; // 스냅샷: 주문 시점의 상품명 + + @Column(name = "price", nullable = false, precision = 10, scale = 2) + private BigDecimal price; // 스냅샷: 주문 시점의 가격 + + @Column(name = "quantity", nullable = false) + private int quantity; + + private OrderItemModel(String productId, String productName, BigDecimal price, int quantity) { + this.orderItemId = OrderItemId.generate(); + this.productId = productId; + this.productName = productName; + this.price = price; + this.quantity = quantity; + } + + public static OrderItemModel create(String productId, String productName, BigDecimal price, int quantity) { + return new OrderItemModel(productId, productName, price, quantity); + } + + public BigDecimal getTotalPrice() { + return price.multiply(BigDecimal.valueOf(quantity)); + } + + void setOrder(OrderModel order) { + this.order = order; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java new file mode 100644 index 000000000..8732ca13f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java @@ -0,0 +1,77 @@ +package com.loopers.domain.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.like.vo.RefMemberId; +import com.loopers.domain.order.vo.OrderId; +import com.loopers.infrastructure.jpa.converter.OrderIdConverter; +import com.loopers.infrastructure.jpa.converter.RefMemberIdConverter; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "orders") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class OrderModel extends BaseEntity { + + @Convert(converter = OrderIdConverter.class) + @Column(name = "order_id", nullable = false, unique = true, length = 36) + private OrderId orderId; + + @Convert(converter = RefMemberIdConverter.class) + @Column(name = "ref_member_id", nullable = false) + private RefMemberId refMemberId; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private OrderStatus status; + + @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true) + private List orderItems = new ArrayList<>(); + + private OrderModel(Long memberId, List items) { + this.orderId = OrderId.generate(); + this.refMemberId = new RefMemberId(memberId); + this.status = OrderStatus.PENDING; + items.forEach(this::addOrderItem); + } + + public static OrderModel create(Long memberId, List items) { + if (items == null || items.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 상품이 비어 있습니다."); + } + return new OrderModel(memberId, items); + } + + public void cancel() { + if (this.status == OrderStatus.CANCELED) { + // 멱등성: 이미 취소된 주문은 그대로 반환 + return; + } + this.status.validateTransition(OrderStatus.CANCELED); + this.status = OrderStatus.CANCELED; + } + + public boolean isOwner(Long memberId) { + return this.refMemberId.value().equals(memberId); + } + + public BigDecimal getTotalAmount() { + return orderItems.stream() + .map(OrderItemModel::getTotalPrice) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } + + private void addOrderItem(OrderItemModel item) { + this.orderItems.add(item); + item.setOrder(this); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java new file mode 100644 index 000000000..a058bcea6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java @@ -0,0 +1,25 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +public enum OrderStatus { + PENDING, // 주문 대기 + CANCELED; // 주문 취소 + + public boolean canTransitionTo(OrderStatus newStatus) { + return switch (this) { + case PENDING -> newStatus == CANCELED; + case CANCELED -> newStatus == CANCELED; // 멱등성: 이미 취소된 상태에서 취소 허용 + }; + } + + public void validateTransition(OrderStatus newStatus) { + if (!canTransitionTo(newStatus)) { + throw new CoreException( + ErrorType.BAD_REQUEST, + String.format("주문 상태를 %s에서 %s로 변경할 수 없습니다.", this, newStatus) + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/vo/OrderId.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/vo/OrderId.java new file mode 100644 index 000000000..4e4f6ce49 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/vo/OrderId.java @@ -0,0 +1,25 @@ +package com.loopers.domain.order.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.util.UUID; + +public record OrderId(String value) { + + public OrderId { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "orderId가 비어 있습니다"); + } + // UUID 형식 검증 + try { + UUID.fromString(value); + } catch (IllegalArgumentException e) { + throw new CoreException(ErrorType.BAD_REQUEST, "orderId는 UUID 형식이어야 합니다: " + value); + } + } + + public static OrderId generate() { + return new OrderId(UUID.randomUUID().toString()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/vo/OrderItemId.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/vo/OrderItemId.java new file mode 100644 index 000000000..ef6824046 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/vo/OrderItemId.java @@ -0,0 +1,25 @@ +package com.loopers.domain.order.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.util.UUID; + +public record OrderItemId(String value) { + + public OrderItemId { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "orderItemId가 비어 있습니다"); + } + // UUID 형식 검증 + try { + UUID.fromString(value); + } catch (IllegalArgumentException e) { + throw new CoreException(ErrorType.BAD_REQUEST, "orderItemId는 UUID 형식이어야 합니다: " + value); + } + } + + public static OrderItemId generate() { + return new OrderItemId(UUID.randomUUID().toString()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/OrderIdConverter.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/OrderIdConverter.java new file mode 100644 index 000000000..f99e79a71 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/OrderIdConverter.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.jpa.converter; + +import com.loopers.domain.order.vo.OrderId; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter(autoApply = false) +public class OrderIdConverter implements AttributeConverter { + + @Override + public String convertToDatabaseColumn(OrderId attribute) { + return attribute == null ? null : attribute.value(); + } + + @Override + public OrderId convertToEntityAttribute(String dbData) { + return dbData == null ? null : new OrderId(dbData); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/OrderItemIdConverter.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/OrderItemIdConverter.java new file mode 100644 index 000000000..7a06e68e2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/OrderItemIdConverter.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.jpa.converter; + +import com.loopers.domain.order.vo.OrderItemId; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter(autoApply = false) +public class OrderItemIdConverter implements AttributeConverter { + + @Override + public String convertToDatabaseColumn(OrderItemId attribute) { + return attribute == null ? null : attribute.value(); + } + + @Override + public OrderItemId convertToEntityAttribute(String dbData) { + return dbData == null ? null : new OrderItemId(dbData); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java new file mode 100644 index 000000000..457557f76 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java @@ -0,0 +1,51 @@ +package com.loopers.domain.order; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("OrderItemModel Entity") +class OrderItemModelTest { + + @Test + @DisplayName("create() 정적 팩토리로 OrderItemModel 생성 성공") + void create_orderItem_success() { + // given + String productId = "prod1"; + String productName = "Test Product"; + BigDecimal price = new BigDecimal("10000"); + int quantity = 3; + + // when + OrderItemModel item = OrderItemModel.create(productId, productName, price, quantity); + + // then + assertThat(item).isNotNull(); + assertThat(item.getOrderItemId()).isNotNull(); + assertThat(item.getProductId()).isEqualTo(productId); + assertThat(item.getProductName()).isEqualTo(productName); + assertThat(item.getPrice()).isEqualByComparingTo(price); + assertThat(item.getQuantity()).isEqualTo(quantity); + } + + @Test + @DisplayName("getTotalPrice() = price * quantity") + void getTotalPrice() { + // given + OrderItemModel item = OrderItemModel.create( + "prod1", + "Test Product", + new BigDecimal("10000"), + 3 + ); + + // when + BigDecimal totalPrice = item.getTotalPrice(); + + // then + assertThat(totalPrice).isEqualByComparingTo(new BigDecimal("30000")); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java new file mode 100644 index 000000000..f375dc81d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java @@ -0,0 +1,141 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("OrderModel Entity") +class OrderModelTest { + + @DisplayName("주문을 생성할 때,") + @Nested + class Create { + + @Test + @DisplayName("create() 정적 팩토리로 주문 생성 성공") + void create_order_success() { + // given + Long memberId = 1L; + var item1 = OrderItemModel.create("prod1", "Product 1", new BigDecimal("10000"), 2); + var item2 = OrderItemModel.create("prod2", "Product 2", new BigDecimal("20000"), 1); + List items = List.of(item1, item2); + + // when + OrderModel order = OrderModel.create(memberId, items); + + // then + assertThat(order).isNotNull(); + assertThat(order.getOrderId()).isNotNull(); + assertThat(order.getRefMemberId().value()).isEqualTo(memberId); + assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING); + assertThat(order.getOrderItems()).hasSize(2); + } + + @Test + @DisplayName("주문 상품이 비어있으면 예외 발생") + void create_emptyItems_throwsException() { + // given + Long memberId = 1L; + List items = List.of(); + + // when & then + assertThatThrownBy(() -> OrderModel.create(memberId, items)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("주문 상품이 비어 있습니다"); + } + + @Test + @DisplayName("총 주문 금액 계산") + void getTotalAmount() { + // given + Long memberId = 1L; + var item1 = OrderItemModel.create("prod1", "Product 1", new BigDecimal("10000"), 2); // 20000 + var item2 = OrderItemModel.create("prod2", "Product 2", new BigDecimal("20000"), 1); // 20000 + OrderModel order = OrderModel.create(memberId, List.of(item1, item2)); + + // when + BigDecimal totalAmount = order.getTotalAmount(); + + // then + assertThat(totalAmount).isEqualByComparingTo(new BigDecimal("40000")); + } + } + + @DisplayName("주문을 취소할 때,") + @Nested + class Cancel { + + @Test + @DisplayName("PENDING 상태에서 취소 성공") + void cancel_fromPending_success() { + // given + Long memberId = 1L; + var item = OrderItemModel.create("prod1", "Product 1", new BigDecimal("10000"), 1); + OrderModel order = OrderModel.create(memberId, List.of(item)); + + // when + order.cancel(); + + // then + assertThat(order.getStatus()).isEqualTo(OrderStatus.CANCELED); + } + + @Test + @DisplayName("이미 취소된 주문 재취소 시 멱등성 보장") + void cancel_alreadyCanceled_idempotent() { + // given + Long memberId = 1L; + var item = OrderItemModel.create("prod1", "Product 1", new BigDecimal("10000"), 1); + OrderModel order = OrderModel.create(memberId, List.of(item)); + order.cancel(); + + // when + order.cancel(); // 두 번째 취소 + + // then + assertThat(order.getStatus()).isEqualTo(OrderStatus.CANCELED); + } + } + + @DisplayName("주문 소유자 확인") + @Nested + class IsOwner { + + @Test + @DisplayName("소유자가 맞으면 true 반환") + void isOwner_correctMember_returnsTrue() { + // given + Long memberId = 1L; + var item = OrderItemModel.create("prod1", "Product 1", new BigDecimal("10000"), 1); + OrderModel order = OrderModel.create(memberId, List.of(item)); + + // when + boolean isOwner = order.isOwner(1L); + + // then + assertThat(isOwner).isTrue(); + } + + @Test + @DisplayName("소유자가 아니면 false 반환") + void isOwner_wrongMember_returnsFalse() { + // given + Long memberId = 1L; + var item = OrderItemModel.create("prod1", "Product 1", new BigDecimal("10000"), 1); + OrderModel order = OrderModel.create(memberId, List.of(item)); + + // when + boolean isOwner = order.isOwner(2L); + + // then + assertThat(isOwner).isFalse(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderStatusTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderStatusTest.java new file mode 100644 index 000000000..bba6a7c00 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderStatusTest.java @@ -0,0 +1,62 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("OrderStatus Enum 테스트") +class OrderStatusTest { + + @Test + @DisplayName("PENDING에서 CANCELED로 전이 가능") + void canTransition_pendingToCanceled() { + // given + OrderStatus status = OrderStatus.PENDING; + + // when + boolean canTransition = status.canTransitionTo(OrderStatus.CANCELED); + + // then + assertThat(canTransition).isTrue(); + } + + @Test + @DisplayName("CANCELED에서 CANCELED로 전이 가능 (멱등성)") + void canTransition_canceledToCanceled() { + // given + OrderStatus status = OrderStatus.CANCELED; + + // when + boolean canTransition = status.canTransitionTo(OrderStatus.CANCELED); + + // then + assertThat(canTransition).isTrue(); + } + + @Test + @DisplayName("CANCELED에서 PENDING으로 전이 불가") + void cannotTransition_canceledToPending() { + // given + OrderStatus status = OrderStatus.CANCELED; + + // when + boolean canTransition = status.canTransitionTo(OrderStatus.PENDING); + + // then + assertThat(canTransition).isFalse(); + } + + @Test + @DisplayName("불가능한 상태 전이 시 예외 발생") + void validateTransition_throwsException() { + // given + OrderStatus status = OrderStatus.CANCELED; + + // when & then + assertThatThrownBy(() -> status.validateTransition(OrderStatus.PENDING)) + .isInstanceOf(CoreException.class); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ControllerE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ControllerE2ETest.java index 1dbca8f51..fe106d60f 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ControllerE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ControllerE2ETest.java @@ -72,7 +72,7 @@ void addLike_success_returns201() { // then assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); assertThat(response.getBody()).isNotNull(); - assertThat(response.getBody().success()).isTrue(); + assertThat(response.getBody()).extracting("success").isEqualTo(true); } @Test From dccc5318e097e973be7cf3f63b3b8d2d2638b259 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B8=B0=EB=AC=98=ED=95=9C=20=EC=A3=BC=EB=8B=88=EC=96=B4?= =?UTF-8?q?=20=EA=B0=9C=EB=B0=9C=EC=9E=90?= Date: Mon, 16 Feb 2026 22:51:16 +0900 Subject: [PATCH 22/50] =?UTF-8?q?feat(order):=20=EC=A3=BC=EB=AC=B8=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/domain/order/OrderReader.java | 18 ++ .../loopers/domain/order/OrderRepository.java | 10 ++ .../loopers/domain/order/OrderService.java | 85 ++++++++++ .../domain/product/ProductRepository.java | 11 ++ .../order/OrderJpaRepository.java | 11 ++ .../order/OrderRepositoryImpl.java | 25 +++ .../product/ProductRepositoryImpl.java | 29 ++++ .../OrderServiceCreateIntegrationTest.java | 158 ++++++++++++++++++ .../domain/order/OrderServiceTest.java | 155 +++++++++++++++++ 9 files changed, 502 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderReader.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceCreateIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderReader.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderReader.java new file mode 100644 index 000000000..e532f58bf --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderReader.java @@ -0,0 +1,18 @@ +package com.loopers.domain.order; + +import com.loopers.domain.order.vo.OrderId; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class OrderReader { + private final OrderRepository orderRepository; + + public OrderModel getOrThrow(String orderId) { + return orderRepository.findByOrderId(new OrderId(orderId)) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 주문이 존재하지 않습니다.")); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java new file mode 100644 index 000000000..e35433c37 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.order; + +import com.loopers.domain.order.vo.OrderId; + +import java.util.Optional; + +public interface OrderRepository { + OrderModel save(OrderModel order); + Optional findByOrderId(OrderId orderId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java new file mode 100644 index 000000000..f6b5b3710 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -0,0 +1,85 @@ +package com.loopers.domain.order; + +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.vo.ProductId; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class OrderService { + + private final OrderRepository orderRepository; + private final ProductRepository productRepository; + + @Transactional + public OrderModel createOrder(Long memberId, List itemRequests) { + // 1. 중복 상품 수량 합산 + Map aggregatedItems = aggregateQuantities(itemRequests); + + // 2. 상품 ID 정렬 (데드락 방지) + List sortedProductIds = aggregatedItems.keySet().stream() + .sorted() + .collect(Collectors.toList()); + + // 3. 상품 조회 및 재고 차감 + List orderItems = new ArrayList<>(); + for (String productIdValue : sortedProductIds) { + int quantity = aggregatedItems.get(productIdValue); + + // 상품 조회 + ProductModel product = productRepository.findByProductId(new ProductId(productIdValue)) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + "해당 ID의 상품이 존재하지 않습니다: " + productIdValue)); + + // 재고 차감 (동시성 제어) + boolean decreased = productRepository.decreaseStockIfAvailable(product.getId(), quantity); + if (!decreased) { + throw new CoreException(ErrorType.CONFLICT, + "재고가 부족합니다. 상품 ID: " + productIdValue); + } + + // OrderItemModel 생성 (스냅샷 패턴) + OrderItemModel orderItem = OrderItemModel.create( + product.getProductId().value(), + product.getProductName().value(), + product.getPrice().value(), + quantity + ); + orderItems.add(orderItem); + } + + // 4. OrderModel 생성 및 저장 + OrderModel order = OrderModel.create(memberId, orderItems); + return orderRepository.save(order); + } + + private Map aggregateQuantities(List itemRequests) { + Map aggregated = new HashMap<>(); + for (OrderItemRequest request : itemRequests) { + aggregated.merge(request.productId(), request.quantity(), Integer::sum); + } + return aggregated; + } + + /** + * 주문 상품 요청 DTO + */ + public record OrderItemRequest(String productId, int quantity) { + public OrderItemRequest { + if (productId == null || productId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID는 필수입니다."); + } + if (quantity <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "수량은 1개 이상이어야 합니다."); + } + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java index 5033ef8ee..cda0bc62b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -14,4 +14,15 @@ public interface ProductRepository { boolean existsByProductId(ProductId productId); Page findProducts(Long refBrandId, String sortBy, Pageable pageable); + + /** + * 재고를 차감합니다. 재고가 부족하면 false를 반환합니다. + * 동시성 제어를 위해 조건부 UPDATE를 사용합니다. + */ + boolean decreaseStockIfAvailable(Long productId, int quantity); + + /** + * 재고를 증가시킵니다. + */ + void increaseStock(Long productId, int quantity); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java new file mode 100644 index 000000000..274d098cd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.order.vo.OrderId; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface OrderJpaRepository extends JpaRepository { + Optional findByOrderId(OrderId orderId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java new file mode 100644 index 000000000..1206a96df --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -0,0 +1,25 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.order.OrderRepository; +import com.loopers.domain.order.vo.OrderId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class OrderRepositoryImpl implements OrderRepository { + private final OrderJpaRepository orderJpaRepository; + + @Override + public OrderModel save(OrderModel order) { + return orderJpaRepository.save(order); + } + + @Override + public Optional findByOrderId(OrderId orderId) { + return orderJpaRepository.findByOrderId(orderId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java index abe487159..5a68a246d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -93,4 +93,33 @@ private long countProducts(Long refBrandId) { return query.getSingleResult(); } + + @Override + public boolean decreaseStockIfAvailable(Long productId, int quantity) { + // 동시성 제어를 위한 조건부 UPDATE (Native Query 사용 - VO 타입 문제 회피) + String sql = "UPDATE products " + + "SET stock_quantity = stock_quantity - :quantity " + + "WHERE id = :productId " + + "AND stock_quantity >= :quantity"; + + int updatedCount = entityManager.createNativeQuery(sql) + .setParameter("quantity", quantity) + .setParameter("productId", productId) + .executeUpdate(); + + return updatedCount > 0; + } + + @Override + public void increaseStock(Long productId, int quantity) { + // Native Query 사용 (VO 타입 문제 회피) + String sql = "UPDATE products " + + "SET stock_quantity = stock_quantity + :quantity " + + "WHERE id = :productId"; + + entityManager.createNativeQuery(sql) + .setParameter("quantity", quantity) + .setParameter("productId", productId) + .executeUpdate(); + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceCreateIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceCreateIntegrationTest.java new file mode 100644 index 000000000..cf1d9f190 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceCreateIntegrationTest.java @@ -0,0 +1,158 @@ +package com.loopers.domain.order; + +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.vo.ProductId; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.math.BigDecimal; +import java.util.List; + +import static com.loopers.domain.order.OrderService.OrderItemRequest; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest +@DisplayName("OrderService 주문 생성 통합 테스트") +class OrderServiceCreateIntegrationTest { + + @Autowired + private OrderService orderService; + + @Autowired + private BrandService brandService; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @BeforeEach + void setUp() { + // Brand와 Product 생성 + brandService.createBrand("nike", "Nike"); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Test + @DisplayName("주문 생성 성공 (재고 감소 확인)") + void createOrder_success_decreasesStock() { + // given + ProductModel product = createProduct("prod1", "Nike Air", new BigDecimal("100000"), 10); + Long memberId = 1L; + List requests = List.of(new OrderItemRequest("prod1", 3)); + + // when + OrderModel order = orderService.createOrder(memberId, requests); + + // then + assertThat(order).isNotNull(); + assertThat(order.getOrderId()).isNotNull(); + assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING); + assertThat(order.getOrderItems()).hasSize(1); + assertThat(order.getTotalAmount()).isEqualByComparingTo(new BigDecimal("300000.00")); // 100000 * 3 + + // 재고 감소 확인 + ProductModel updatedProduct = productRepository.findByProductId(new ProductId("prod1")).orElseThrow(); + assertThat(updatedProduct.getStockQuantity().value()).isEqualTo(7); // 10 - 3 = 7 + } + + @Test + @DisplayName("재고 부족 시 409 Conflict (롤백 확인)") + void createOrder_insufficientStock_rollback() { + // given + ProductModel product = createProduct("prod1", "Nike Air", new BigDecimal("100000"), 5); + Long memberId = 1L; + List requests = List.of(new OrderItemRequest("prod1", 10)); // 재고보다 많이 요청 + + // when & then + assertThatThrownBy(() -> orderService.createOrder(memberId, requests)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.CONFLICT); + + // 재고가 롤백되어 원래대로 유지됨 + ProductModel unchangedProduct = productRepository.findByProductId(new ProductId("prod1")).orElseThrow(); + assertThat(unchangedProduct.getStockQuantity().value()).isEqualTo(5); + } + + @Test + @DisplayName("중복 상품 수량 합산 동작") + void createOrder_aggregateDuplicates() { + // given + ProductModel product = createProduct("prod1", "Nike Air", new BigDecimal("100000"), 10); + Long memberId = 1L; + List requests = List.of( + new OrderItemRequest("prod1", 2), + new OrderItemRequest("prod1", 3) // 동일 상품 + ); + + // when + OrderModel order = orderService.createOrder(memberId, requests); + + // then + assertThat(order.getOrderItems()).hasSize(1); // 하나로 합쳐짐 + assertThat(order.getOrderItems().get(0).getQuantity()).isEqualTo(5); // 2+3=5 + + // 재고 감소 확인 + ProductModel updatedProduct = productRepository.findByProductId(new ProductId("prod1")).orElseThrow(); + assertThat(updatedProduct.getStockQuantity().value()).isEqualTo(5); // 10 - 5 = 5 + } + + @Test + @DisplayName("존재하지 않는 상품 404") + void createOrder_productNotFound() { + // given + Long memberId = 1L; + List requests = List.of(new OrderItemRequest("invalid", 1)); + + // when & then + assertThatThrownBy(() -> orderService.createOrder(memberId, requests)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); + } + + @Test + @DisplayName("여러 상품 주문 시 재고 감소 확인") + void createOrder_multipleProducts() { + // given + ProductModel product1 = createProduct("prod1", "Nike Air", new BigDecimal("100000"), 10); + ProductModel product2 = createProduct("prod2", "Nike Jordan", new BigDecimal("200000"), 5); + Long memberId = 1L; + List requests = List.of( + new OrderItemRequest("prod1", 2), + new OrderItemRequest("prod2", 3) + ); + + // when + OrderModel order = orderService.createOrder(memberId, requests); + + // then + assertThat(order.getOrderItems()).hasSize(2); + assertThat(order.getTotalAmount()).isEqualByComparingTo(new BigDecimal("800000.00")); // (100000*2) + (200000*3) + + // 재고 감소 확인 + ProductModel updatedProduct1 = productRepository.findByProductId(new ProductId("prod1")).orElseThrow(); + ProductModel updatedProduct2 = productRepository.findByProductId(new ProductId("prod2")).orElseThrow(); + assertThat(updatedProduct1.getStockQuantity().value()).isEqualTo(8); // 10 - 2 + assertThat(updatedProduct2.getStockQuantity().value()).isEqualTo(2); // 5 - 3 + } + + private ProductModel createProduct(String productId, String productName, BigDecimal price, int stockQuantity) { + ProductModel product = ProductModel.create(productId, 1L, productName, price, stockQuantity); + return productRepository.save(product); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java new file mode 100644 index 000000000..bbab240ce --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java @@ -0,0 +1,155 @@ +package com.loopers.domain.order; + +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.vo.ProductId; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Optional; + +import static com.loopers.domain.order.OrderService.OrderItemRequest; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("OrderService 단위 테스트") +class OrderServiceTest { + + @Mock + private OrderRepository orderRepository; + + @Mock + private ProductRepository productRepository; + + @InjectMocks + private OrderService orderService; + + @Test + @DisplayName("createOrder: 주문 생성 성공") + void createOrder_success() { + // given + Long memberId = 1L; + List requests = List.of( + new OrderItemRequest("prod1", 2), + new OrderItemRequest("prod2", 3) + ); + + ProductModel product1 = mockProduct("prod1", "Product 1", new BigDecimal("10000"), 100L); + ProductModel product2 = mockProduct("prod2", "Product 2", new BigDecimal("20000"), 101L); + + when(productRepository.findByProductId(new ProductId("prod1"))).thenReturn(Optional.of(product1)); + when(productRepository.findByProductId(new ProductId("prod2"))).thenReturn(Optional.of(product2)); + when(productRepository.decreaseStockIfAvailable(100L, 2)).thenReturn(true); + when(productRepository.decreaseStockIfAvailable(101L, 3)).thenReturn(true); + + OrderModel savedOrder = mock(OrderModel.class); + when(orderRepository.save(any(OrderModel.class))).thenReturn(savedOrder); + + // when + OrderModel result = orderService.createOrder(memberId, requests); + + // then + assertThat(result).isEqualTo(savedOrder); + verify(productRepository).decreaseStockIfAvailable(100L, 2); + verify(productRepository).decreaseStockIfAvailable(101L, 3); + verify(orderRepository).save(any(OrderModel.class)); + } + + @Test + @DisplayName("createOrder: 중복 상품 수량 합산") + void createOrder_aggregateQuantities() { + // given + Long memberId = 1L; + List requests = List.of( + new OrderItemRequest("prod1", 2), + new OrderItemRequest("prod1", 3) // 동일 상품 + ); + + ProductModel product = mockProduct("prod1", "Product 1", new BigDecimal("10000"), 100L); + when(productRepository.findByProductId(new ProductId("prod1"))).thenReturn(Optional.of(product)); + when(productRepository.decreaseStockIfAvailable(100L, 5)).thenReturn(true); // 2+3=5 + + OrderModel savedOrder = mock(OrderModel.class); + when(orderRepository.save(any(OrderModel.class))).thenReturn(savedOrder); + + // when + orderService.createOrder(memberId, requests); + + // then + verify(productRepository).decreaseStockIfAvailable(100L, 5); + } + + @Test + @DisplayName("createOrder: 재고 부족 시 예외 발생") + void createOrder_insufficientStock_throwsException() { + // given + Long memberId = 1L; + List requests = List.of(new OrderItemRequest("prod1", 100)); + + // 재고 부족 시에는 OrderItemModel 생성 전에 예외 발생하므로 getId()만 필요 + ProductModel product = mock(ProductModel.class); + when(product.getId()).thenReturn(100L); + when(productRepository.findByProductId(new ProductId("prod1"))).thenReturn(Optional.of(product)); + when(productRepository.decreaseStockIfAvailable(100L, 100)).thenReturn(false); + + // when & then + assertThatThrownBy(() -> orderService.createOrder(memberId, requests)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.CONFLICT); + } + + @Test + @DisplayName("createOrder: 존재하지 않는 상품 시 예외 발생") + void createOrder_productNotFound_throwsException() { + // given + Long memberId = 1L; + List requests = List.of(new OrderItemRequest("invalid", 1)); + + when(productRepository.findByProductId(new ProductId("invalid"))).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> orderService.createOrder(memberId, requests)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); + } + + @Test + @DisplayName("OrderItemRequest: 상품 ID null 시 예외 발생") + void orderItemRequest_nullProductId_throwsException() { + // when & then + assertThatThrownBy(() -> new OrderItemRequest(null, 1)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + + @Test + @DisplayName("OrderItemRequest: 수량 0 이하 시 예외 발생") + void orderItemRequest_invalidQuantity_throwsException() { + // when & then + assertThatThrownBy(() -> new OrderItemRequest("prod1", 0)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + + private ProductModel mockProduct(String productId, String productName, BigDecimal price, Long id) { + ProductModel product = mock(ProductModel.class); + when(product.getId()).thenReturn(id); + when(product.getProductId()).thenReturn(new ProductId(productId)); + when(product.getProductName()).thenReturn(new com.loopers.domain.product.vo.ProductName(productName)); + when(product.getPrice()).thenReturn(new com.loopers.domain.product.vo.Price(price)); + return product; + } +} From dfb22128a35fe42b857529151f73166daeadd650 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B8=B0=EB=AC=98=ED=95=9C=20=EC=A3=BC=EB=8B=88=EC=96=B4?= =?UTF-8?q?=20=EA=B0=9C=EB=B0=9C=EC=9E=90?= Date: Mon, 16 Feb 2026 23:05:40 +0900 Subject: [PATCH 23/50] =?UTF-8?q?feat(product):=20likes=5Fdesc=20=EC=A0=95?= =?UTF-8?q?=EB=A0=AC=20=EA=B5=AC=ED=98=84=20(=EC=A2=8B=EC=95=84=EC=9A=94?= =?UTF-8?q?=20=EC=88=98=20=EA=B8=B0=EC=A4=80=20=EB=82=B4=EB=A6=BC=EC=B0=A8?= =?UTF-8?q?=EC=88=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../product/ProductRepositoryImpl.java | 85 +++++++++++++------ .../ProductServiceIntegrationTest.java | 40 ++++++++- 2 files changed, 96 insertions(+), 29 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java index 5a68a246d..a605e6bf1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -4,7 +4,6 @@ import com.loopers.domain.product.ProductRepository; import com.loopers.domain.product.vo.ProductId; import jakarta.persistence.EntityManager; -import jakarta.persistence.TypedQuery; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; @@ -38,60 +37,92 @@ public boolean existsByProductId(ProductId productId) { @Override public Page findProducts(Long refBrandId, String sortBy, Pageable pageable) { - // JPQL 쿼리 작성 - StringBuilder jpql = new StringBuilder("SELECT p FROM ProductModel p WHERE p.deletedAt IS NULL"); + List products; + + // VO 타입 문제로 Native Query 사용 + if ("likes_desc".equals(sortBy)) { + products = findProductsWithLikesCount(refBrandId, pageable); + } else { + products = findProductsNative(refBrandId, sortBy, pageable); + } + + // 전체 개수 조회 + long total = countProducts(refBrandId); + + return new PageImpl<>(products, pageable, total); + } + + private List findProductsNative(Long refBrandId, String sortBy, Pageable pageable) { + // Native Query 사용 (VO 타입 문제 회피) + StringBuilder sql = new StringBuilder("SELECT * FROM products WHERE deleted_at IS NULL"); - // 브랜드 필터 if (refBrandId != null) { - jpql.append(" AND p.refBrandId = :refBrandId"); + sql.append(" AND ref_brand_id = :refBrandId"); } - // 정렬 - jpql.append(getSortClause(sortBy)); + sql.append(getNativeSortClause(sortBy)); + + var query = entityManager.createNativeQuery(sql.toString(), ProductModel.class); - // 쿼리 실행 - TypedQuery query = entityManager.createQuery(jpql.toString(), ProductModel.class); if (refBrandId != null) { query.setParameter("refBrandId", refBrandId); } - // 페이징 query.setFirstResult((int) pageable.getOffset()); query.setMaxResults(pageable.getPageSize()); - List products = query.getResultList(); - - // 전체 개수 조회 - long total = countProducts(refBrandId); - - return new PageImpl<>(products, pageable, total); + return query.getResultList(); } - private String getSortClause(String sortBy) { + private String getNativeSortClause(String sortBy) { if (sortBy == null || "latest".equals(sortBy)) { - return " ORDER BY p.updatedAt DESC"; + return " ORDER BY updated_at DESC"; } else if ("price_asc".equals(sortBy)) { - return " ORDER BY p.price ASC"; - } else if ("likes_desc".equals(sortBy)) { - // TODO: Like 도메인 구현 후 LEFT JOIN + COUNT 서브쿼리로 구현 - throw new UnsupportedOperationException("likes_desc 정렬은 아직 구현되지 않았습니다. Like 도메인 구현 후 추가됩니다."); + return " ORDER BY price ASC"; } - return " ORDER BY p.updatedAt DESC"; // 기본값 + return " ORDER BY updated_at DESC"; // 기본값 + } + + private List findProductsWithLikesCount(Long refBrandId, Pageable pageable) { + // Native Query: LEFT JOIN으로 좋아요 수 카운트 후 정렬 + StringBuilder sql = new StringBuilder( + "SELECT p.* FROM products p " + + "LEFT JOIN likes l ON p.id = l.ref_product_id " + + "WHERE p.deleted_at IS NULL"); + + if (refBrandId != null) { + sql.append(" AND p.ref_brand_id = :refBrandId"); + } + + sql.append(" GROUP BY p.id") + .append(" ORDER BY COUNT(l.id) DESC, p.updated_at DESC"); // 좋아요 수 동일 시 최신순 + + var query = entityManager.createNativeQuery(sql.toString(), ProductModel.class); + + if (refBrandId != null) { + query.setParameter("refBrandId", refBrandId); + } + + query.setFirstResult((int) pageable.getOffset()); + query.setMaxResults(pageable.getPageSize()); + + return query.getResultList(); } private long countProducts(Long refBrandId) { - StringBuilder jpql = new StringBuilder("SELECT COUNT(p) FROM ProductModel p WHERE p.deletedAt IS NULL"); + // Native Query 사용 (VO 타입 문제 회피) + StringBuilder sql = new StringBuilder("SELECT COUNT(*) FROM products WHERE deleted_at IS NULL"); if (refBrandId != null) { - jpql.append(" AND p.refBrandId = :refBrandId"); + sql.append(" AND ref_brand_id = :refBrandId"); } - TypedQuery query = entityManager.createQuery(jpql.toString(), Long.class); + var query = entityManager.createNativeQuery(sql.toString()); if (refBrandId != null) { query.setParameter("refBrandId", refBrandId); } - return query.getSingleResult(); + return ((Number) query.getSingleResult()).longValue(); } @Override diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java index eb0b35fa6..f33644ae1 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java @@ -2,6 +2,7 @@ import com.loopers.domain.brand.BrandModel; import com.loopers.domain.brand.BrandService; +import com.loopers.domain.like.LikeService; import com.loopers.infrastructure.brand.BrandJpaRepository; import com.loopers.infrastructure.product.ProductJpaRepository; import com.loopers.support.error.CoreException; @@ -38,6 +39,9 @@ class ProductServiceIntegrationTest { @Autowired private BrandJpaRepository brandJpaRepository; + @Autowired + private LikeService likeService; + @Autowired private DatabaseCleanUp databaseCleanUp; @@ -68,7 +72,7 @@ void createProduct_withValidBrand_success() { () -> assertThat(savedProduct).isNotNull(), () -> assertThat(savedProduct.getId()).isNotNull(), () -> assertThat(savedProduct.getProductId().value()).isEqualTo(productId), - () -> assertThat(savedProduct.getRefBrandId()).isEqualTo(brand.getId()), + () -> assertThat(savedProduct.getRefBrandId().value()).isEqualTo(brand.getId()), () -> assertThat(savedProduct.getProductName().value()).isEqualTo(productName), () -> assertThat(savedProduct.getPrice().value()).isEqualByComparingTo(price.setScale(2)), () -> assertThat(savedProduct.getStockQuantity().value()).isEqualTo(stockQuantity), @@ -190,7 +194,7 @@ void getProducts_filtersByBrand() { // then assertThat(nikeProducts.getContent()).hasSize(2); assertThat(nikeProducts.getContent()) - .allMatch(p -> p.getRefBrandId().equals(nike.getId())); + .allMatch(p -> p.getRefBrandId().value().equals(nike.getId())); } @Test @@ -259,5 +263,37 @@ void getProducts_pagination() { assertThat(firstPage.getTotalElements()).isEqualTo(15); assertThat(firstPage.getTotalPages()).isEqualTo(2); } + + @Test + @DisplayName("likes_desc 정렬 (좋아요 많은 순)") + void getProducts_sortByLikesDesc() { + // given + BrandModel brand = brandService.createBrand("nike", "Nike"); + ProductModel product1 = productService.createProduct("prod1", brand.getBrandId().value(), "Product 1", new BigDecimal("10000"), 10); + ProductModel product2 = productService.createProduct("prod2", brand.getBrandId().value(), "Product 2", new BigDecimal("20000"), 20); + ProductModel product3 = productService.createProduct("prod3", brand.getBrandId().value(), "Product 3", new BigDecimal("30000"), 30); + + // product2: 좋아요 3개 + likeService.addLike(1L, "prod2"); + likeService.addLike(2L, "prod2"); + likeService.addLike(3L, "prod2"); + + // product1: 좋아요 1개 + likeService.addLike(1L, "prod1"); + + // product3: 좋아요 0개 + + Pageable pageable = PageRequest.of(0, 10); + + // when + Page products = productService.getProducts(null, "likes_desc", pageable); + + // then + assertThat(products.getContent()).hasSize(3); + // 좋아요 많은 순: prod2(3) > prod1(1) > prod3(0) + assertThat(products.getContent()) + .extracting(p -> p.getProductId().value()) + .containsExactly("prod2", "prod1", "prod3"); + } } } From f028ab7a1393347991523799c59b305ed4cd00dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B8=B0=EB=AC=98=ED=95=9C=20=EC=A3=BC=EB=8B=88=EC=96=B4?= =?UTF-8?q?=20=EA=B0=9C=EB=B0=9C=EC=9E=90?= Date: Mon, 16 Feb 2026 23:19:50 +0900 Subject: [PATCH 24/50] =?UTF-8?q?feat(product):=20ProductInfo=EC=97=90=20?= =?UTF-8?q?=EB=B8=8C=EB=9E=9C=EB=93=9C=20=EC=A0=95=EB=B3=B4=EC=99=80=20?= =?UTF-8?q?=EC=A2=8B=EC=95=84=EC=9A=94=20=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/product/ProductFacade.java | 27 +++++++++++++++++-- .../application/product/ProductInfo.java | 12 ++++++--- .../loopers/domain/brand/BrandRepository.java | 1 + .../domain/product/ProductRepository.java | 5 ++++ .../brand/BrandRepositoryImpl.java | 5 ++++ .../product/ProductRepositoryImpl.java | 10 +++++++ .../interfaces/api/product/ProductV1Dto.java | 9 +++++-- .../brand/BrandServiceIntegrationTest.java | 5 ++++ .../product/ProductV1ControllerE2ETest.java | 13 ++++++--- 9 files changed, 76 insertions(+), 11 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index 02a59dc69..86d078084 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -1,7 +1,13 @@ package com.loopers.application.product; +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.brand.vo.BrandId; import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductRepository; import com.loopers.domain.product.ProductService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -15,11 +21,13 @@ public class ProductFacade { private final ProductService productService; + private final ProductRepository productRepository; + private final BrandRepository brandRepository; @Transactional public ProductInfo createProduct(String productId, String brandId, String productName, BigDecimal price, int stockQuantity) { ProductModel product = productService.createProduct(productId, brandId, productName, price, stockQuantity); - return ProductInfo.from(product); + return enrichProductInfo(product); } @Transactional @@ -30,6 +38,21 @@ public void deleteProduct(String productId) { @Transactional(readOnly = true) public Page getProducts(String brandId, String sortBy, Pageable pageable) { Page products = productService.getProducts(brandId, sortBy, pageable); - return products.map(ProductInfo::from); + return products.map(this::enrichProductInfo); + } + + /** + * ProductModel에 Brand 정보와 좋아요 수를 추가하여 ProductInfo 생성 + */ + private ProductInfo enrichProductInfo(ProductModel product) { + // Brand 정보 조회 + BrandModel brand = brandRepository.findById(product.getRefBrandId().value()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + "브랜드 정보를 찾을 수 없습니다. ID: " + product.getRefBrandId().value())); + + // 좋아요 수 조회 + long likesCount = productRepository.countLikes(product.getId()); + + return ProductInfo.from(product, brand, likesCount); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java index 370060b0a..47f6a9aaa 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java @@ -1,5 +1,7 @@ package com.loopers.application.product; +import com.loopers.application.brand.BrandInfo; +import com.loopers.domain.brand.BrandModel; import com.loopers.domain.product.ProductModel; import java.math.BigDecimal; @@ -10,16 +12,20 @@ public record ProductInfo( Long refBrandId, String productName, BigDecimal price, - int stockQuantity + int stockQuantity, + BrandInfo brand, + long likesCount ) { - public static ProductInfo from(ProductModel product) { + public static ProductInfo from(ProductModel product, BrandModel brand, long likesCount) { return new ProductInfo( product.getId(), product.getProductId().value(), product.getRefBrandId().value(), product.getProductName().value(), product.getPrice().value(), - product.getStockQuantity().value() + product.getStockQuantity().value(), + BrandInfo.from(brand), + likesCount ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java index 45cd9d2b9..9729d7c0f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -7,5 +7,6 @@ public interface BrandRepository { BrandModel save(BrandModel brand); Optional findByBrandId(BrandId brandId); + Optional findById(Long id); boolean existsByBrandId(BrandId brandId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java index cda0bc62b..4448fc3bc 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -25,4 +25,9 @@ public interface ProductRepository { * 재고를 증가시킵니다. */ void increaseStock(Long productId, int quantity); + + /** + * 상품의 좋아요 수를 조회합니다. + */ + long countLikes(Long productId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java index a3a2eb860..ff8eb2d24 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -23,6 +23,11 @@ public Optional findByBrandId(BrandId brandId) { return brandJpaRepository.findByBrandId(brandId); } + @Override + public Optional findById(Long id) { + return brandJpaRepository.findById(id); + } + @Override public boolean existsByBrandId(BrandId brandId) { return brandJpaRepository.existsByBrandId(brandId); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java index a605e6bf1..8e8a57dfa 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -153,4 +153,14 @@ public void increaseStock(Long productId, int quantity) { .setParameter("productId", productId) .executeUpdate(); } + + @Override + public long countLikes(Long productId) { + String sql = "SELECT COUNT(*) FROM likes WHERE ref_product_id = :productId"; + + var query = entityManager.createNativeQuery(sql); + query.setParameter("productId", productId); + + return ((Number) query.getSingleResult()).longValue(); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java index d2ab0ae2a..1a30df591 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -1,5 +1,6 @@ package com.loopers.interfaces.api.product; +import com.loopers.application.brand.BrandInfo; import com.loopers.application.product.ProductInfo; import jakarta.validation.constraints.*; @@ -35,7 +36,9 @@ public record ProductResponse( Long refBrandId, String productName, BigDecimal price, - int stockQuantity + int stockQuantity, + BrandInfo brand, + long likesCount ) { public static ProductResponse from(ProductInfo info) { return new ProductResponse( @@ -44,7 +47,9 @@ public static ProductResponse from(ProductInfo info) { info.refBrandId(), info.productName(), info.price(), - info.stockQuantity() + info.stockQuantity(), + info.brand(), + info.likesCount() ); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java index 995717155..9ec836548 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java @@ -158,6 +158,11 @@ public Optional findByBrandId(BrandId brandId) { return brandJpaRepository.findByBrandId(brandId); } + @Override + public Optional findById(Long id) { + return brandJpaRepository.findById(id); + } + @Override public boolean existsByBrandId(BrandId brandId) { return brandJpaRepository.existsByBrandId(brandId); diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ControllerE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ControllerE2ETest.java index 4972011ed..7dba05ba2 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ControllerE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ControllerE2ETest.java @@ -76,12 +76,17 @@ void createProduct_success_returns201() { assertAll( () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), () -> assertThat(response.getBody()).isNotNull(), - () -> assertThat(response.getBody().success()).isEqualTo(true), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS), + () -> assertThat(response.getBody().data()).isNotNull(), () -> assertThat(response.getBody().data().productId()).isEqualTo("prod1"), () -> assertThat(response.getBody().data().refBrandId()).isNotNull(), () -> assertThat(response.getBody().data().productName()).isEqualTo("Nike Air Max"), () -> assertThat(response.getBody().data().price()).isEqualByComparingTo(new BigDecimal("150000.00")), - () -> assertThat(response.getBody().data().stockQuantity()).isEqualTo(100) + () -> assertThat(response.getBody().data().stockQuantity()).isEqualTo(100), + () -> assertThat(response.getBody().data().brand()).isNotNull(), + () -> assertThat(response.getBody().data().brand().brandId()).isEqualTo("nike"), + () -> assertThat(response.getBody().data().brand().brandName()).isEqualTo("Nike"), + () -> assertThat(response.getBody().data().likesCount()).isEqualTo(0) ); } @@ -190,7 +195,7 @@ void getProducts_success_returns200() { assertAll( () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), () -> assertThat(response.getBody()).isNotNull(), - () -> assertThat(response.getBody().success()).isEqualTo(true), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS), () -> assertThat(response.getBody().data().products()).hasSize(2), () -> assertThat(response.getBody().data().totalElements()).isEqualTo(2) ); @@ -320,7 +325,7 @@ void deleteProduct_success_returns200() { assertAll( () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), () -> assertThat(response.getBody()).isNotNull(), - () -> assertThat(response.getBody().success()).isEqualTo(true) + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS) ); // 삭제 후 목록 조회 시 제외됨 확인 From 3b9e15f0105d64f6d610b67ea41e4654fa2821a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B8=B0=EB=AC=98=ED=95=9C=20=EC=A3=BC=EB=8B=88=EC=96=B4?= =?UTF-8?q?=20=EA=B0=9C=EB=B0=9C=EC=9E=90?= Date: Mon, 16 Feb 2026 23:25:05 +0900 Subject: [PATCH 25/50] =?UTF-8?q?test(archunit):=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=EB=8A=94=20=EC=A0=9C=EC=99=B8?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/test/java/com/loopers/architecture/DomainLayerTest.java | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/commerce-api/src/test/java/com/loopers/architecture/DomainLayerTest.java b/apps/commerce-api/src/test/java/com/loopers/architecture/DomainLayerTest.java index 8aedda604..e56276d88 100644 --- a/apps/commerce-api/src/test/java/com/loopers/architecture/DomainLayerTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/architecture/DomainLayerTest.java @@ -39,6 +39,7 @@ void value_objects_must_be_records() { ArchRule rule = classes() .that().resideInAPackage("..vo..") .and().areNotNestedClasses() + .and().haveSimpleNameNotEndingWith("Test") .should().beRecords() .because("Value Object는 불변성을 보장하기 위해 record 타입을 사용해야 합니다"); From 213dd2808dbe46c6ce2aac21b60760dc1e8d2721 Mon Sep 17 00:00:00 2001 From: YoHanKi Date: Fri, 20 Feb 2026 22:31:31 +0900 Subject: [PATCH 26/50] =?UTF-8?q?docs(requirements)=20:=20=EB=9D=BD=20?= =?UTF-8?q?=EC=A0=84=EB=9E=B5=20=EB=B3=80=EA=B2=BD.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/design/01-requirements.md | 225 ++++++++++++++++----------------- 1 file changed, 109 insertions(+), 116 deletions(-) diff --git a/docs/design/01-requirements.md b/docs/design/01-requirements.md index 48c0e88d8..5fad6a742 100644 --- a/docs/design/01-requirements.md +++ b/docs/design/01-requirements.md @@ -26,6 +26,7 @@ | 주문 항목 | Order Item | 주문 내 개별 상품 라인 | Order Line, Line Item | | 스냅샷 | Snapshot | 주문 시점의 상품 정보 복사본 | Copy, Archive | | 삭제 일시 | Deleted At | Soft Delete 타임스탬프 | Removed At | +| 비관적 락 | Pessimistic Lock | 재고 차감 전 행(row) 잠금 (`SELECT ... FOR UPDATE`) | Exclusive Lock | ### 상태(Enum) @@ -123,37 +124,32 @@ - items 배열이 비어있지 않음 - 각 quantity >= 1 - 동일 productId가 여러 개 있으면 quantity 합산 -4. **상품 존재 확인**: 각 productId에 대해 - - product 테이블에서 조회 - - `deleted_at IS NULL` 확인 - - 존재하지 않으면 → 404 Not Found -5. **재고 차감** (동시성 제어): +4. **재고 차감** (동시성 제어): - productId를 오름차순으로 정렬 (데드락 방지) - - 각 상품에 대해 조건부 UPDATE 실행: + - 각 상품에 대해 **비관적 락** 획득 후 재고 검증 및 차감: ```sql - UPDATE product - SET stock_qty = stock_qty - :quantity, updated_at = NOW() - WHERE id = :productId - AND deleted_at IS NULL - AND stock_qty >= :quantity; + -- 1단계: 비관적 락 획득 + SELECT * FROM products WHERE id = :productId FOR UPDATE; + -- 2단계: 재고 검증 (애플리케이션 레이어) + -- stock_quantity < :quantity → 409 Conflict + -- 3단계: 재고 차감 + UPDATE products SET stock_quantity = stock_quantity - :quantity WHERE id = :productId; ``` - - affected rows = 0이면 재고 부족 → 전체 롤백 후 409 Conflict + - 재고 부족 → 전체 롤백 후 409 Conflict +5. **상품 존재 확인**: 재고 차감 전 productId로 조회 + - 존재하지 않으면 → 404 Not Found 6. **주문 저장**: - - `orders` 테이블에 INSERT: `user_id`, `total_amount`, `status=PENDING`, `ordered_at=NOW()` - - `total_amount` = Σ(unit_price × quantity) + - `orders` 테이블에 INSERT: `ref_member_id`, `order_id(UUID)`, `status=PENDING` 7. **주문 항목 스냅샷 저장**: - - `order_item` 테이블에 각 항목 INSERT: - - `order_id`, `product_id`, `product_name`, `brand_id`, `brand_name` - - `unit_price`, `quantity`, `line_amount` (= unit_price × quantity) - - 선택: `image_url` + - `order_items` 테이블에 각 항목 INSERT: + - `order_id`, `product_id(스냅샷)`, `product_name(스냅샷)`, `price(스냅샷)`, `quantity` 8. **응답**: 201 Created - - `orderId`, `status`, `orderedAt`, `totalAmount` - - `items` 배열 (스냅샷 포함) + - `orderId`, `status`, `items` 배열 (스냅샷 포함) #### Alternate Flow - **A1**: 동일 productId 중복 - quantity를 합산하여 단일 항목으로 처리 - - 예: `[{productId:1, qty:2}, {productId:1, qty:3}]` → `{productId:1, qty:5}` + - 예: `[{productId:"P001", qty:2}, {productId:"P001", qty:3}]` → `{productId:"P001", qty:5}` #### Exception Flow - **E1**: 인증 실패 → 401 Unauthorized @@ -172,27 +168,26 @@ 3. **주문 조회**: - `orderId`로 주문 조회 - 존재하지 않으면 → 404 Not Found -4. **소유권 확인**: `order.user_id == user_id` 검증 +4. **소유권 확인**: `order.ref_member_id == user_id` 검증 - 다르면 → 403 Forbidden 5. **상태 확인**: `status == PENDING` 확인 - - CANCELED 또는 다른 상태면 → 409 Conflict (Alternate Flow A1 참조) + - CANCELED 상태면 → Alternate Flow A1 (멱등 성공) 6. **상태 전이**: 같은 트랜잭션 내에서 - - `orders.status = CANCELED`, `canceled_at = NOW()` UPDATE + - `orders.status = CANCELED` UPDATE 7. **재고 복구**: - - `order_item`의 각 항목에 대해: + - `order_items`의 각 항목에 대해: ```sql - UPDATE product - SET stock_qty = stock_qty + :quantity, updated_at = NOW() + UPDATE products + SET stock_quantity = stock_quantity + :quantity WHERE id = :productId; ``` - 상품이 삭제되었어도 재고 복구 시도 (soft delete이므로 가능) 8. **응답**: 200 OK - - `orderId`, `status=CANCELED`, `canceledAt` + - `orderId`, `status=CANCELED` #### Alternate Flow - **A1**: 이미 CANCELED 상태 - - 멱등 처리: 200 OK 응답 (재고 복구는 중복 실행하지 않음) - - 또는 409 Conflict (정책에 따라 선택, 권장은 200 OK) + - 멱등 처리: 200 OK 응답 (재고 복구 중복 실행하지 않음) #### Exception Flow - **E1**: 인증 실패 → 401 Unauthorized @@ -206,22 +201,22 @@ ### UC-C04: 좋아요 추가 (POST /api/v1/products/{productId}/likes) #### Main Flow -1. **요청**: 사용자가 `productId`로 좋아요 추가 요청 +1. **요청**: 사용자가 `memberId`, `productId`로 좋아요 추가 요청 2. **인증**: 헤더 검증 → `user_id` 추출 3. **상품 존재 확인**: - `productId`로 상품 조회 - `deleted_at IS NULL` 확인 - 존재하지 않으면 → 404 Not Found -4. **중복 확인**: `(user_id, product_id)` UNIQUE 제약 +4. **중복 확인**: `(ref_member_id, ref_product_id)` UNIQUE 제약 - 이미 존재하면 → Alternate Flow A1 5. **좋아요 저장**: - - `like` 테이블에 INSERT: `user_id`, `product_id`, `created_at=NOW()` -6. **응답**: 201 Created (또는 204 No Content) + - `likes` 테이블에 INSERT: `ref_member_id`, `ref_product_id`, `created_at=NOW()` +6. **응답**: 200 OK (기존 또는 신규 좋아요 정보 반환) #### Alternate Flow - **A1**: 이미 좋아요 존재 - - 멱등 처리: 200 OK 또는 204 No Content (INSERT 스킵) - - UNIQUE 제약 위반 catch 후 성공 처리 + - 멱등 처리: 200 OK (INSERT 스킵, 기존 좋아요 반환) + - UNIQUE 제약 위반 catch 후 재조회하여 성공 처리 #### Exception Flow - **E1**: 인증 실패 → 401 Unauthorized @@ -233,19 +228,23 @@ ### UC-C05: 좋아요 취소 (DELETE /api/v1/products/{productId}/likes) #### Main Flow -1. **요청**: 사용자가 `productId`로 좋아요 취소 요청 +1. **요청**: 사용자가 `memberId`, `productId`로 좋아요 취소 요청 2. **인증**: 헤더 검증 → `user_id` 추출 -3. **좋아요 삭제**: - - `DELETE FROM like WHERE user_id = :userId AND product_id = :productId` -4. **응답**: 204 No Content +3. **상품 존재 확인**: + - `productId`로 상품 조회 + - 존재하지 않으면 → 404 Not Found +4. **좋아요 삭제**: + - `(ref_member_id, ref_product_id)` 조건으로 조회 후 삭제 +5. **응답**: 204 No Content #### Alternate Flow - **A1**: 좋아요 존재하지 않음 - - 멱등 처리: 204 No Content (affected rows = 0이어도 성공) + - 멱등 처리: 204 No Content (없어도 성공) #### Exception Flow - **E1**: 인증 실패 → 401 Unauthorized -- **E2**: DB 에러 → 500 Internal Server Error +- **E2**: 상품 존재하지 않음 → 404 Not Found +- **E3**: DB 에러 → 500 Internal Server Error --- @@ -255,12 +254,12 @@ 1. **요청**: 사용자가 자신의 좋아요 목록 조회 2. **인증**: 헤더 검증 → `user_id` 추출 3. **조회**: - - `like` 테이블에서 `user_id`로 필터링 - - JOIN `product` ON `like.product_id = product.id` - - `product.deleted_at IS NULL` 필터링 (삭제된 상품 제외) + - `likes` 테이블에서 `ref_member_id`로 필터링 + - JOIN `products` ON `likes.ref_product_id = products.id` + - `products.deleted_at IS NULL` 필터링 (삭제된 상품 제외) 4. **페이징**: page, size 파라미터 (선택) 5. **응답**: 200 OK - - products 배열: `productId`, `productName`, `brandName`, `price`, `imageUrl`, `likedAt` + - products 배열: `productId`, `productName`, `brandName`, `price`, `likedAt` #### Alternate Flow - **A1**: 좋아요한 상품이 없음 @@ -275,27 +274,24 @@ #### Main Flow 1. **요청**: 사용자가 상품 목록 조회 (쿼리 파라미터: `brandId`, `sort`, `page`, `size`) -2. **인증**: 헤더 검증 (선택: 비로그인도 허용 가능, 정책에 따라) -3. **필터링**: +2. **필터링**: - `deleted_at IS NULL` (삭제된 상품 제외) - - `brandId` 제공 시: `product.brand_id = :brandId` -4. **정렬**: - - `latest` (필수): `updated_at DESC` - - `price_asc` (선택): `price ASC` - - `likes_desc` (선택): - - Phase 1: `SELECT ..., (SELECT COUNT(*) FROM like WHERE product_id = product.id) AS like_count ORDER BY like_count DESC` - - Phase 2 (병목 시): `product.like_count DESC` -5. **페이징**: page, size 적용 (기본: page=0, size=20) -6. **응답**: 200 OK - - products 배열: `productId`, `productName`, `brandName`, `price`, `stockQty`, `imageUrl`, `likeCount`(선택) + - `brandId` 제공 시: `products.ref_brand_id = :brandId` +3. **정렬**: + - `latest` (기본): `updated_at DESC` + - `price_asc`: `price ASC` + - `likes_desc`: + - LEFT JOIN likes, GROUP BY p.id, COUNT(l.id) DESC +4. **페이징**: page, size 적용 (기본: page=0, size=20) +5. **응답**: 200 OK + - products 배열: `productId`, `productName`, `brandId`, `brandName`, `price`, `stockQuantity`, `likesCount` #### Alternate Flow - **A1**: 조건에 맞는 상품 없음 - 빈 배열 반환 #### Exception Flow -- **E1**: 인증 실패 (인증 필수 정책인 경우) → 401 Unauthorized -- **E2**: 유효하지 않은 sort 값 → 400 Bad Request +- **E1**: 유효하지 않은 sort 값 → 400 Bad Request --- @@ -303,16 +299,15 @@ #### Main Flow 1. **요청**: 사용자가 `productId`로 상품 상세 조회 -2. **인증**: 헤더 검증 (선택: 비로그인도 허용 가능) -3. **조회**: +2. **조회**: - `productId`로 상품 조회 - `deleted_at IS NULL` 확인 - - JOIN `brand` ON `product.brand_id = brand.id` + - Brand 정보 조회 (ref_brand_id → brands 테이블) - 존재하지 않으면 → 404 Not Found -4. **좋아요 수 집계** (선택): - - `SELECT COUNT(*) FROM like WHERE product_id = :productId` -5. **응답**: 200 OK - - `productId`, `productName`, `brandId`, `brandName`, `price`, `stockQty`, `description`, `imageUrl`, `likeCount` +3. **좋아요 수 집계**: + - `SELECT COUNT(*) FROM likes WHERE ref_product_id = :productId` +4. **응답**: 200 OK + - `productId`, `productName`, `brandId`, `brandName`, `price`, `stockQuantity`, `likesCount` #### Exception Flow - **E1**: 상품 존재하지 않음 또는 deleted → 404 Not Found @@ -322,21 +317,18 @@ ### UC-A08: 상품 등록 (POST /api-admin/v1/products) #### Main Flow -1. **요청**: 어드민이 상품 등록 (필드: `productName`, `brandId`, `price`, `stockQty`, `description?`, `imageUrl?`, `status?`) +1. **요청**: 어드민이 상품 등록 (필드: `productId`, `brandId`, `productName`, `price`, `stockQuantity`) 2. **인증**: `X-Loopers-Ldap=loopers.admin` 검증 - 실패 시 → 403 Forbidden 3. **입력 검증**: - - `price >= 0`, `stockQty >= 0` + - `price >= 0`, `stockQuantity >= 0` - `productName` 비어있지 않음 4. **브랜드 존재 확인**: - `brandId`로 브랜드 조회 - `deleted_at IS NULL` 확인 - 존재하지 않으면 → 404 Not Found -5. **상품 저장**: - - `product` 테이블에 INSERT - - `status` 기본값: `ACTIVE` (정책에 따라) +5. **상품 저장**: `products` 테이블에 INSERT 6. **응답**: 201 Created - - `productId`, `productName`, `brandId`, `price`, `stockQty`, ... #### Exception Flow - **E1**: 인증 실패 → 403 Forbidden @@ -380,8 +372,8 @@ - 존재하지 않으면 → 404 Not Found 4. **연쇄 삭제** (Soft Delete): - 같은 트랜잭션 내에서: - - `UPDATE brand SET deleted_at = NOW() WHERE id = :brandId` - - `UPDATE product SET deleted_at = NOW() WHERE brand_id = :brandId AND deleted_at IS NULL` + - `UPDATE brands SET deleted_at = NOW() WHERE id = :brandId` + - `UPDATE products SET deleted_at = NOW() WHERE ref_brand_id = :brandId AND deleted_at IS NULL` 5. **응답**: 204 No Content #### Exception Flow @@ -406,7 +398,7 @@ - 주문 상태 불일치 (주문 취소 시 status != PENDING) - **멱등 성공**: - 좋아요 추가/취소: 이미 존재/없어도 성공 - - 주문 취소: 이미 CANCELED면 성공 (권장) + - 주문 취소: 이미 CANCELED면 성공 처리 (권장) --- @@ -417,10 +409,21 @@ - **주문 취소**: 상태 전이 + 재고 복구 → 단일 트랜잭션 - **브랜드 삭제**: 브랜드 soft delete + 상품 연쇄 soft delete → 단일 트랜잭션 -### 동시성 제어 -- **재고 차감**: 조건부 원자 UPDATE (`WHERE stock_qty >= :qty`) -- **데드락 방지**: productId 오름차순 정렬로 락 순서 고정 -- **좋아요 중복**: UNIQUE 제약 (`user_id`, `product_id`)으로 DB 레벨 보장 +### 동시성 제어 전략 + +| 도메인 | 경합 수준 | 중요도 | 전략 | +|--------|-----------|--------|------| +| 재고 (stock) | **높음** | **비즈니스 핵심** | **비관적 락** (`SELECT ... FOR UPDATE`) | +| 좋아요 (like) | 낮음 | 참고 데이터 | DB UNIQUE 제약 (`uk_likes_member_product`) | + +**재고 차감 (비관적 락)**: +- `SELECT ... FOR UPDATE`로 행 잠금 후 재고 검증 및 차감 +- 데드락 방지: productId 오름차순 정렬로 락 획득 순서 고정 +- 재고 부족 시: 예외 발생 → 트랜잭션 전체 롤백 → 409 Conflict + +**좋아요 중복 방지 (DB 제약)**: +- UNIQUE 제약 (`ref_member_id`, `ref_product_id`)으로 DB 레벨 최종 방어 +- 경합 발생 시: `DataIntegrityViolationException` catch → 기존 좋아요 재조회 → 멱등 성공 ### 일관성 수준 - **강한 일관성**: 재고 수량 (과판매 절대 불가) @@ -432,58 +435,47 @@ ### Risk-01: Soft Delete 필터 누락 - **리스크**: 모든 조회 쿼리에 `deleted_at IS NULL` 필터 필요, 누락 시 삭제된 항목 노출 -- **증상**: 고객에게 삭제된 상품/브랜드가 보임, 주문 생성 시 삭제된 상품 선택 가능 - **완화책**: - - Repository 기본 조건/전역 스코프 적용 (JPA `@Where`, QueryDSL BooleanExpression) - - 코드 리뷰 체크리스트에 추가 + - Repository Native Query에 `deleted_at IS NULL` 조건 포함 - E2E 테스트에 삭제 시나리오 포함 ### Risk-02: 좋아요 수 집계 성능 -- **리스크**: `likes_desc` 정렬 시 COUNT 집계가 느려질 수 있음 (상품 수 × 좋아요 수 증가 시) -- **증상**: 상품 목록 조회 API 응답 시간 증가 (1초 이상) +- **리스크**: `likes_desc` 정렬 시 COUNT 집계가 느려질 수 있음 - **완화책**: - - Phase 1: COUNT 집계 (정확성 우선) - - Phase 2: `product.like_count` 컬럼 도입 (약한 일관성 허용) + - Phase 1: LEFT JOIN + GROUP BY + COUNT (정확성 우선, 현재 구현) + - Phase 2: `products.like_count` 컬럼 도입 (약한 일관성 허용) - 전환 시점: APM 모니터링으로 병목 관측 후 결정 ### Risk-03: 재고 차감 데드락 -- **리스크**: 다품목 주문 시 productId 순서가 다르면 데드락 발생 가능 -- **증상**: 주문 생성 실패, DB 로그에 deadlock 감지 +- **리스크**: 다품목 주문 시 productId 순서가 다르면 비관적 락 데드락 가능 - **완화책**: - - productId 오름차순 정렬로 락 순서 고정 - - 재시도 로직 (exponential backoff) - - 모니터링: 데드락 발생 횟수 추적 + - productId 오름차순 정렬로 락 순서 고정 (모든 트랜잭션이 동일한 순서로 락 획득) + - DB 데드락 타임아웃 설정 (innodb_lock_wait_timeout) + - 데드락 발생 시 자동 롤백 → 클라이언트 재시도 ### Risk-04: 주문 스냅샷 불완전 - **리스크**: 스냅샷에 필수 정보 누락 시, 상품/브랜드 삭제 후 주문 조회 불가 -- **증상**: 주문 내역에 "알 수 없는 상품" 표시, 고객 불만 - **완화책**: - - 최소 스냅샷 필드 명시: `product_name`, `brand_name`, `unit_price`, `quantity` + - 최소 스냅샷 필드: `product_id`, `product_name`, `price`, `quantity` - E2E 테스트: 상품 삭제 후 주문 조회 시나리오 ### Risk-05: latest 정렬 조작 가능 - **리스크**: `updated_at` 기준 정렬 시, 운영자가 단순 수정으로 상단 노출 조작 가능 -- **증상**: 특정 상품이 부당하게 상단 노출, 공정성 문제 - **완화책**: - Phase 1: `updated_at` 사용 (단순함 우선) - - Phase 2: `published_at` 또는 `last_restocked_at` 컬럼 도입 (의미 있는 갱신만 반영) - - 정책: 어드민 수정 시 경고 메시지 또는 별도 "노출 순서" 필드 + - Phase 2: `published_at` 또는 `last_restocked_at` 컬럼 도입 ### Risk-06: 권한 검증 누락 - **리스크**: owner check 누락 시, 타 유저의 주문/좋아요 접근 가능 -- **증상**: 보안 취약점, 개인정보 노출 - **완화책**: - 모든 "내 리소스" API에 owner check 필수화 - - AOP 또는 Spring Security 필터로 공통화 - E2E 테스트: 타 유저 접근 시도 시나리오 (403 확인) ### Risk-07: 주문 취소 멱등성 불명확 - **리스크**: 이미 CANCELED 상태일 때 200 vs 409 정책 불명확 -- **증상**: 클라이언트 재시도 로직 혼란 - **완화책**: - - 명확한 정책 수립: 멱등 성공 (200 OK) 권장 - - API 문서에 멱등성 명시 - - 클라이언트 가이드 제공 + - 명확한 정책: 멱등 성공 (200 OK) 채택 + - OrderModel.cancel()에서 이미 CANCELED면 그대로 반환 (예외 없음) --- @@ -496,24 +488,25 @@ - **연쇄 삭제**: 브랜드 삭제 시 해당 브랜드의 모든 상품도 soft delete 처리 ### 2. 정렬 기준 -- **latest**: `updated_at DESC` (상품 갱신 반영, 추후 `published_at` 확장 가능) +- **latest**: `updated_at DESC` (상품 갱신 반영) - **price_asc**: `price ASC` -- **likes_desc**: 기본은 COUNT 집계, 병목 시 `like_count` 컬럼 도입 +- **likes_desc**: LEFT JOIN likes, COUNT(l.id) DESC (Phase 1, 병목 시 like_count 컬럼 도입) ### 3. 좋아요 집계 정합성 -- **기본안(Phase 1)**: 조회 시 COUNT 집계 (정확성 우선) -- **확장안(Phase 2)**: `product.like_count` 컬럼 유지 (성능 우선, 약한 일관성 허용) +- **기본안(Phase 1)**: 조회 시 COUNT 집계 (정확성 우선, 현재 구현) +- **확장안(Phase 2)**: `products.like_count` 컬럼 유지 (성능 우선, 약한 일관성 허용) - **전환 시점**: 병목 관측 후 결정 ### 4. 재고 차감 동시성 제어 -- **방식**: 조건부 원자 UPDATE (`UPDATE ... WHERE stock_qty >= :qty`) -- **데드락 완화**: productId 오름차순 정렬로 락 순서 고정 +- **방식**: 비관적 락 (`SELECT ... FOR UPDATE`) +- **근거**: 재고는 경합이 심하고 비즈니스적으로 중요한 자원. 과판매는 절대 허용 불가. 낙관적 접근(조건부 UPDATE)보다 명시적 락으로 직렬화 보장 +- **데드락 방지**: productId 오름차순 정렬로 락 순서 고정 - **실패 처리**: 재고 부족 시 전체 롤백, 409 Conflict 응답 +- **좋아요와의 차이**: 좋아요는 경합이 낮고 중복 1건은 치명적이지 않아 UNIQUE 제약만으로 충분 ### 5. 주문 스냅샷 범위 -- **최소 스냅샷**: `product_name`, `brand_name`, `unit_price`, `quantity`, `line_amount` -- **선택 필드**: `image_url`, `product_id`, `brand_id` (참조용) -- **제외**: 상품 상세 메타데이터 (description 등) +- **최소 스냅샷**: `product_id(비즈니스 ID)`, `product_name`, `price`, `quantity` +- **제외**: brand_name, line_amount (총 금액은 getTotalPrice()로 계산), image_url ### 6. 주문 상태 - **PENDING**: 주문 생성 직후 (결제 전 상태) @@ -521,13 +514,13 @@ - **허용 전이**: `PENDING → CANCELED` (이번 범위에서는 결제 상태 제외) ### 7. 멱등성 정책 -- **좋아요 추가(POST)**: 이미 존재해도 200/204 성공 +- **좋아요 추가(POST)**: 이미 존재해도 200 성공 (기존 좋아요 반환) - **좋아요 취소(DELETE)**: 없어도 204 성공 -- **주문 취소(PATCH)**: 이미 CANCELED면 성공 처리 (권장) +- **주문 취소(PATCH)**: 이미 CANCELED면 성공 처리 ### 8. 권한/접근 제어 -- **내 좋아요 목록**: 본인만 조회 가능 (URI: `/api/v1/users/me/likes`) -- **내 주문 목록/상세**: 본인만 조회 가능 (owner check 필수) +- **내 좋아요 목록**: 본인만 조회 가능 +- **내 주문 목록/상세**: 본인만 조회 가능 (isOwner() check 필수) - **어드민 API**: `X-Loopers-Ldap=loopers.admin` 검증 --- From 81828fd6a07ef2f9c18ae7936c64742163775e33 Mon Sep 17 00:00:00 2001 From: YoHanKi Date: Fri, 20 Feb 2026 22:35:11 +0900 Subject: [PATCH 27/50] =?UTF-8?q?docs=20:=20=EA=B5=AC=ED=98=84=EC=97=90=20?= =?UTF-8?q?=EB=A7=9E=EA=B2=8C=20=EB=AC=B8=EC=84=9C=20=EC=B5=9C=EC=8B=A0?= =?UTF-8?q?=ED=99=94.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/design/02-sequence-diagrams.md | 258 ++++++++------ docs/design/03-class-diagram.md | 489 +++++++++++++------------- docs/design/04-erd.md | 519 ++++++++++++++++------------ 3 files changed, 701 insertions(+), 565 deletions(-) diff --git a/docs/design/02-sequence-diagrams.md b/docs/design/02-sequence-diagrams.md index d5dcdc063..e029fb9f4 100644 --- a/docs/design/02-sequence-diagrams.md +++ b/docs/design/02-sequence-diagrams.md @@ -6,8 +6,15 @@ **레이어 구조**: - **Interfaces Layer**: Controller +- **Application Layer**: Facade - **Domain Layer**: Service, Reader, Model -- **Infrastructure Layer**: Repository, JpaRepository +- **Infrastructure Layer**: Repository(Impl), JpaRepository + +**동시성 전략 요약**: +| 도메인 | 전략 | 근거 | +|--------|------|------| +| 재고 (stock) | 비관적 락 (`SELECT ... FOR UPDATE`) | 높은 경합, 과판매 절대 불가 | +| 좋아요 (like) | DB UNIQUE 제약 + 예외 catch | 낮은 경합, 중복 1건은 치명적이지 않음 | --- @@ -16,7 +23,7 @@ ### 검증 목적 주문 생성의 핵심은 **재고 차감의 동시성 제어**와 **스냅샷 저장의 트랜잭션 일관성**이다. 이 다이어그램은: 1. productId 정렬로 데드락을 방지하는 흐름 -2. 조건부 원자 UPDATE로 재고 부족을 안전하게 감지하는 방법 +2. 비관적 락(`SELECT ... FOR UPDATE`)으로 재고를 안전하게 차감하는 방법 3. 주문/주문항목 저장과 재고 차감이 단일 트랜잭션으로 묶이는 경계 를 검증한다. @@ -27,46 +34,53 @@ sequenceDiagram actor Customer participant Controller as OrderV1Controller participant OrderService as OrderService - participant ProductReader as ProductReader + participant ProductRepo as ProductRepository Customer->>Controller: POST /api/v1/orders
{items: [{productId, quantity}]} - Controller->>OrderService: createOrder(userId, items) + Controller->>OrderService: createOrder(memberId, items) activate OrderService Note over OrderService: @Transactional 시작 -%% 입력 검증 - OrderService->>OrderService: 중복 상품 합산 + %% 1. 중복 상품 합산 + OrderService->>OrderService: aggregateQuantities(items)
동일 productId 수량 합산 -%% 상품 존재 확인 - loop 각 상품 - OrderService->>ProductReader: getOrThrow(productId) - ProductReader-->>OrderService: ProductInfo - end + %% 2. productId 오름차순 정렬 (데드락 방지) + OrderService->>OrderService: sort productIds ASC
(락 획득 순서 고정) + + %% 3. 상품 조회 + 비관적 락 + 재고 차감 + loop 각 상품 (정렬된 순서) + OrderService->>ProductRepo: findByProductId(productId) + ProductRepo-->>OrderService: ProductModel (or 404) -%% 재고 차감 (정렬된 순서) - loop 각 항목 (정렬된 순서) - alt affected rows = 0 - OrderService-->>Controller: 409 Conflict (Stock 부족) - Controller-->>Customer: 409 Conflict
{재고 부족} + Note over OrderService,ProductRepo: SELECT FOR UPDATE
(비관적 락 획득) + OrderService->>ProductRepo: decreaseStock(productId, quantity)
재고 검증 후 차감 + + alt 재고 부족 + OrderService-->>Controller: CoreException(409 CONFLICT) + Controller-->>Customer: 409 Conflict {재고 부족} + Note over OrderService: @Transactional 롤백
(락 해제) end + + OrderService->>OrderService: createOrderItem(snapshot)
product_id, product_name, price, quantity 저장 end -%% 주문 저장 - OrderService->>OrderService: createOrderEntityAndSave(userId, orderItems) - OrderService-->>Controller: OrderInfo + %% 4. 주문 저장 + OrderService->>ProductRepo: save(OrderModel + OrderItemModels) + ProductRepo-->>OrderService: OrderModel - Note over OrderService: @Transactional 커밋 + Note over OrderService: @Transactional 커밋
(모든 비관적 락 해제) deactivate OrderService Controller-->>Customer: 201 Created
{orderId, status, items} ``` ### 해석 -- **트랜잭션 경계**: Service의 `@Transactional`이 재고 차감부터 주문 저장까지 묶는다. 재고 부족 시 전체 롤백된다. -- **동시성 제어**: `UPDATE ... WHERE stock_qty >= ?`로 조건부 원자 업데이트를 수행하며, productId 정렬로 데드락을 완화한다. -- **책임 분리**: ProductReader는 조회만, ProductRepository는 재고 차감, OrderService는 주문 로직과 스냅샷 저장을 담당한다. -- **실패 지점**: 재고 부족 시 affected rows=0 감지 후 즉시 예외를 던지고 트랜잭션이 롤백된다. +- **트랜잭션 경계**: `OrderService@Transactional`이 재고 차감부터 주문 저장까지 묶는다. 재고 부족 시 전체 롤백. +- **비관적 락 전략**: `SELECT ... FOR UPDATE`로 행 잠금 → 재고 검증 → 차감. 조건부 UPDATE 방식보다 명시적이고 안전. +- **데드락 방지**: productId 오름차순 정렬로 모든 트랜잭션이 동일한 순서로 락을 획득하여 순환 대기 제거. +- **스냅샷 패턴**: OrderItemModel에 주문 시점의 product_id, product_name, price를 복사 저장. Product 삭제/수정 후에도 주문 이력 유지. +- **Facade 없음**: 주문 도메인은 OrderService가 직접 ProductRepository를 의존하여 재고 차감 + 주문 저장 오케스트레이션. --- @@ -87,60 +101,56 @@ sequenceDiagram participant Controller as OrderV1Controller participant OrderService as OrderService participant OrderReader as OrderReader + participant ProductRepo as ProductRepository Customer->>Controller: PATCH /api/v1/orders/{orderId}/cancel - Controller->>OrderService: cancelOrder(userId, orderId) + Controller->>OrderService: cancelOrder(memberId, orderId) activate OrderService Note over OrderService: @Transactional 시작 %% 주문 조회 OrderService->>OrderReader: getOrThrow(orderId) - OrderReader-->>OrderService: OrderInfo + OrderReader-->>OrderService: OrderModel (or 404) %% 소유권 확인 - OrderService->>OrderService: validateOwner(order.userId == userId) + OrderService->>OrderService: order.isOwner(memberId) alt owner mismatch - OrderService-->>Controller: CoreException (403 Forbidden) + OrderService-->>Controller: CoreException(403 Forbidden) Controller-->>Customer: 403 Forbidden else owner ok %% 상태 확인 (멱등 포함) - OrderService->>OrderService: validateStatus(order.status) alt status == CANCELED - Note over OrderService: idempotent success - OrderService-->>Controller: OrderInfo (existing) - Controller-->>Customer: 200 OK (already canceled) - else status != PENDING - OrderService-->>Controller: ConflictException (409 invalid status) - Controller-->>Customer: 409 Conflict + Note over OrderService: 멱등 성공 (재고 복구 생략) + OrderService-->>Controller: OrderModel (already canceled) + Controller-->>Customer: 200 OK else status == PENDING - %% 상태 전이 + 아이템 조회 - OrderService->>OrderService: updateStatusToCanceled(orderId) - OrderService->>OrderService: findItems(orderId) + %% 상태 전이 + OrderService->>OrderService: order.cancel()
status = CANCELED %% 재고 복구 - loop each item - OrderService->>OrderService: increaseStock(productId, quantity) + loop each orderItem + OrderService->>ProductRepo: increaseStock(productId, quantity)
UPDATE products SET stock_quantity += :qty end - OrderService-->>Controller: OrderInfo (CANCELED) - Controller-->>Customer: 200 OK (canceled) + OrderService-->>Controller: OrderModel (CANCELED) + Controller-->>Customer: 200 OK end end Note over OrderService: @Transactional 커밋 deactivate OrderService - ``` ### 해석 - **트랜잭션 경계**: 상태 전이와 재고 복구가 단일 트랜잭션으로 묶여, 부분 성공을 방지한다. -- **멱등성**: 이미 CANCELED 상태면 200 OK로 성공 처리 (재고 복구 중복 실행 방지). -- **책임 분리**: OrderReader는 조회+검증, OrderService는 상태 전이+재고 복구 오케스트레이션. -- **소유권 확인**: Service에서 userId 일치 여부를 확인하여 타 유저 접근을 차단한다. +- **멱등성**: 이미 CANCELED 상태면 재고 복구 없이 200 OK 성공 처리 (중복 복구 방지). +- **책임 분리**: OrderReader는 orderId 기반 조회 + 404 처리, OrderService는 상태 전이 + 재고 복구 오케스트레이션. +- **소유권 확인**: `order.isOwner(memberId)`로 타 유저 접근 차단 (도메인 행위 메서드). +- **재고 복구**: 단순 증가 UPDATE (비관적 락 불필요 - 복구는 충돌 없음). --- @@ -148,9 +158,9 @@ sequenceDiagram ### 검증 목적 좋아요는 **멱등성**과 **UNIQUE 제약 처리**가 핵심이다. 이 다이어그램은: -1. 추가 시 중복 처리 (UNIQUE 제약 catch) +1. 추가 시 중복 처리 (UNIQUE 제약 catch → 기존 좋아요 반환) 2. 취소 시 없어도 성공 처리 -3. 상품 존재 확인 흐름 +3. 상품 존재 확인 흐름 (LikeService → ProductRepository) 을 검증한다. ### 시퀀스 다이어그램(좋아요 추가) @@ -159,71 +169,94 @@ sequenceDiagram sequenceDiagram actor Customer participant Controller as LikeV1Controller + participant LikeFacade as LikeFacade participant LikeService as LikeService - participant ProductReader as ProductReader + participant ProductRepo as ProductRepository participant LikeRepo as LikeRepository - Customer->>Controller: POST /likes
{productId} - Controller->>LikeService: addLike(userId, productId) + Customer->>Controller: POST /api/v1/likes
{memberId, productId} + Controller->>LikeFacade: addLike(memberId, productId) + LikeFacade->>LikeService: addLike(memberId, productId) activate LikeService Note over LikeService: @Transactional 시작 -%% 상품 존재 확인 - LikeService->>ProductReader: getOrThrow(productId) - ProductReader-->>LikeService: ProductInfo - -%% 좋아요 저장 - LikeService->>LikeRepo: save(LikeModel) - - alt unique constraint violation - LikeRepo-->>LikeService: DataIntegrityViolationException - Note over LikeService: idempotent success (already liked)
기존 Like 조회 or 바로 성공 처리 - LikeService->>LikeRepo: findByUserIdAndProductId(userId, productId) - LikeRepo-->>LikeService: LikeInfo (existing) - LikeService-->>Controller: LikeInfo (existing) - Controller-->>Customer: 200 OK
{already liked} - else inserted - LikeRepo-->>LikeService: LikeInfo (new) - LikeService-->>Controller: LikeInfo (new) - Controller-->>Customer: 201 Created
{likeId, productId} + %% 상품 존재 확인 (ProductRepository 직접 사용) + LikeService->>ProductRepo: findByProductId(productId) + ProductRepo-->>LikeService: ProductModel (or 404) + + %% 중복 좋아요 확인 (멱등성 - 선조회) + LikeService->>LikeRepo: findByRefMemberIdAndRefProductId(refMemberId, refProductId) + + alt already liked (existing) + LikeRepo-->>LikeService: LikeModel (existing) + Note over LikeService: 멱등 성공 (INSERT 생략) + LikeService-->>LikeFacade: LikeModel (existing) + else not found → try insert + LikeRepo-->>LikeService: empty + LikeService->>LikeRepo: save(LikeModel.create(memberId, productId)) + + alt DataIntegrityViolationException (UNIQUE 위반 - 동시 요청) + Note over LikeService: 동시성 fallback:
UNIQUE 위반 catch → 재조회 + LikeService->>LikeRepo: findByRefMemberIdAndRefProductId(...) + LikeRepo-->>LikeService: LikeModel (existing) + LikeService-->>LikeFacade: LikeModel (existing) + else insert success + LikeRepo-->>LikeService: LikeModel (new) + LikeService-->>LikeFacade: LikeModel (new) + end end Note over LikeService: @Transactional 커밋 deactivate LikeService + LikeFacade-->>Controller: LikeInfo + Controller-->>Customer: 200 OK {likeId, memberId, productId} ``` ### 시퀀스 다이어그램(좋아요 취소) + ```mermaid sequenceDiagram actor Customer participant Controller as LikeV1Controller + participant LikeFacade as LikeFacade participant LikeService as LikeService + participant ProductRepo as ProductRepository + participant LikeRepo as LikeRepository - Customer->>Controller: DELETE /likes
{productId} - Controller->>LikeService: removeLike(userId, productId) + Customer->>Controller: DELETE /api/v1/likes
{memberId, productId} + Controller->>LikeFacade: removeLike(memberId, productId) + LikeFacade->>LikeService: removeLike(memberId, productId) activate LikeService Note over LikeService: @Transactional 시작 - - alt affectedRows == 0 - Note over LikeService: idempotent success (already unliked) + %% 상품 존재 확인 + LikeService->>ProductRepo: findByProductId(productId) + ProductRepo-->>LikeService: ProductModel (or 404) + + %% 좋아요 조회 후 삭제 (멱등성 - 없어도 성공) + LikeService->>LikeRepo: findByRefMemberIdAndRefProductId(refMemberId, refProductId) + alt found + LikeRepo-->>LikeService: LikeModel + LikeService->>LikeRepo: delete(likeModel) + else not found + Note over LikeService: 멱등 성공 (없어도 정상) end - LikeService-->>Controller: void - Controller-->>Customer: 204 No Content - Note over LikeService: @Transactional 커밋 deactivate LikeService + + LikeFacade-->>Controller: void + Controller-->>Customer: 204 No Content ``` ### 해석 -- **멱등성**: 추가 시 UNIQUE 제약 위반을 catch하여 성공 처리, 취소 시 affected rows=0이어도 성공. -- **책임 분리**: ProductReader는 상품 존재 확인만, LikeService는 좋아요 추가/삭제 로직 담당. -- **간결한 트랜잭션**: 좋아요는 단순 CUD이므로 트랜잭션이 짧고 명확하다. -- **예외 처리**: 상품이 삭제되었거나 존재하지 않으면 404 Not Found (ProductReader.getOrThrow). +- **락 불필요**: 좋아요는 경합이 낮고, 중복 1건은 비즈니스적으로 치명적이지 않다. DB UNIQUE 제약이 최종 방어선. +- **멱등성**: 추가 시 선조회로 중복 확인, UNIQUE 위반 시 catch 후 재조회로 성공 처리. 취소 시 없어도 성공. +- **Reader 미사용**: LikeService가 ProductRepository를 직접 사용하여 상품 존재 확인 (Reader 패턴 제거됨). +- **Facade 역할**: LikeFacade는 LikeService를 위임 호출하고 LikeInfo로 변환하는 thin facade. --- @@ -232,8 +265,8 @@ sequenceDiagram ### 검증 목적 상품 목록 조회는 **soft delete 필터링**, **정렬 옵션**, **좋아요 수 집계**의 성능 트레이드오프를 보여준다. 이 다이어그램은: 1. deleted_at 필터가 항상 적용되는지 -2. likes_desc 정렬 시 COUNT 집계 또는 like_count 컬럼 사용 -3. 페이징 적용 흐름 +2. likes_desc 정렬 시 LEFT JOIN + COUNT 집계 +3. Brand 정보와 좋아요 수 enrichment 흐름 을 검증한다. ### 시퀀스 다이어그램 @@ -242,45 +275,60 @@ sequenceDiagram sequenceDiagram actor Customer participant Controller as ProductV1Controller + participant ProductFacade as ProductFacade participant ProductService as ProductService - participant ProductReader as ProductReader + participant ProductRepo as ProductRepository + participant BrandRepo as BrandRepository Customer->>Controller: GET /api/v1/products
?brandId=&sort=likes_desc&page=0&size=20 - Controller->>ProductService: getProducts(criteria) + Controller->>ProductFacade: getProducts(brandId, sortBy, pageable) + + activate ProductFacade + Note over ProductFacade: @Transactional(readOnly=true) - activate ProductService - Note over ProductService: read-only usecase + ProductFacade->>ProductService: getProducts(brandId, sortBy, pageable) + ProductService->>ProductRepo: findProducts(refBrandId, sortBy, pageable) - ProductService->>ProductReader: findProducts(criteria) + Note over ProductRepo: Native SQL:
SELECT * FROM products
WHERE deleted_at IS NULL
[AND ref_brand_id = :brandId]
[LEFT JOIN likes GROUP BY id
ORDER BY COUNT(l.id) DESC] - ProductService-->>Controller: List - deactivate ProductService + ProductRepo-->>ProductService: Page + ProductService-->>ProductFacade: Page + + %% enrichment (Brand 정보 + 좋아요 수) + loop each ProductModel + ProductFacade->>BrandRepo: findById(refBrandId) + BrandRepo-->>ProductFacade: BrandModel + ProductFacade->>ProductRepo: countLikes(productId) + ProductRepo-->>ProductFacade: long likesCount + ProductFacade->>ProductFacade: ProductInfo.from(product, brand, likesCount) + end - Controller-->>Customer: 200 OK
{products: [...], page, size} + deactivate ProductFacade + ProductFacade-->>Controller: Page + Controller-->>Customer: 200 OK
{products: [...], page, size, totalElements} ``` ### 해석 -- **Soft Delete 필터**: 모든 쿼리에 `deleted_at IS NULL`이 포함되어 삭제된 상품을 제외한다. +- **Soft Delete 필터**: 모든 쿼리에 `deleted_at IS NULL`이 포함되어 삭제된 상품을 제외. - **정렬 옵션**: - - **Phase 1 (likes_desc)**: COUNT 서브쿼리로 실시간 집계 (정확, 느릴 수 있음) - - **Phase 2 (likes_desc)**: `like_count` 컬럼 사용 (빠름, 약한 일관성) - - **latest**: `updated_at DESC` - - **price_asc**: `price ASC` -- **페이징**: `LIMIT`, `OFFSET` 적용으로 성능 보장. -- **책임 분리**: ProductReader는 조회 조건 조합, ProductRepository는 쿼리 실행, Controller는 DTO 변환. -- **성능 리스크**: likes_desc + COUNT는 상품/좋아요 수 증가 시 병목 가능 → 모니터링 후 Phase 2로 전환. + - **latest** (기본): `ORDER BY updated_at DESC` + - **price_asc**: `ORDER BY price ASC` + - **likes_desc**: `LEFT JOIN likes ON p.id = l.ref_product_id GROUP BY p.id ORDER BY COUNT(l.id) DESC` +- **Native Query 사용 이유**: VO 타입 (`ProductId`, `RefBrandId` 등)이 JPQL에서 처리 어려워 Native Query 사용. +- **Enrichment**: Facade에서 각 상품별 Brand 정보 + 좋아요 수를 추가로 조회하여 ProductInfo 구성 (N+1 주의 - 병목 시 단일 쿼리로 통합 고려). +- **성능 리스크**: likes_desc + COUNT는 상품/좋아요 수 증가 시 병목 가능 → 모니터링 후 Phase 2 (like_count 컬럼) 전환. --- ## 다이어그램 요약 -| 유스케이스 | 핵심 검증 포인트 | 트랜잭션 범위 | -|-----------|-----------------|--------------------------| -| 주문 생성 | 재고 차감 동시성, 스냅샷 저장 | Service (@Transactional) | -| 주문 취소 | 상태 전이, 재고 복구, 멱등성 | Service (@Transactional) | -| 좋아요 추가/취소 | UNIQUE 제약, 멱등성 | Service (@Transactional) | -| 상품 목록 조회 | Soft Delete 필터, 정렬/집계 성능 | 없음 (읽기 전용) | +| 유스케이스 | 핵심 검증 포인트 | 트랜잭션 범위 | 동시성 전략 | +|-----------|-----------------|--------------|------------| +| 주문 생성 | 재고 차감 동시성, 스냅샷 저장 | Service (@Transactional) | 비관적 락 + productId 정렬 | +| 주문 취소 | 상태 전이, 재고 복구, 멱등성 | Service (@Transactional) | 없음 (복구는 충돌 없음) | +| 좋아요 추가/취소 | UNIQUE 제약, 멱등성 | Service (@Transactional) | DB UNIQUE 제약 | +| 상품 목록 조회 | Soft Delete 필터, 정렬/집계 성능 | 없음 (읽기 전용) | 없음 | --- diff --git a/docs/design/03-class-diagram.md b/docs/design/03-class-diagram.md index ea1a2d0b6..16070d808 100644 --- a/docs/design/03-class-diagram.md +++ b/docs/design/03-class-diagram.md @@ -2,7 +2,7 @@ ## 개요 -이 문서는 레이어드 아키텍처에 따른 도메인 모델과 각 레이어의 책임을 정의한다. 클래스 다이어그램은 **의존성 방향**, **책임 경계**, **불변 규칙**을 중심으로 작성되며, 과도한 필드 객체화를 지양하고 실제 비즈니스 규칙이 있는 Value Object만 도입한다. +이 문서는 레이어드 아키텍처에 따른 도메인 모델과 각 레이어의 책임을 정의한다. 클래스 다이어그램은 **의존성 방향**, **책임 경계**, **불변 규칙**을 중심으로 작성되며, **실제 코드 구현**을 기반으로 한다. **레이어 의존성 규칙**: ```mermaid @@ -19,17 +19,17 @@ flowchart LR | 레이어 | 구성 요소 | 책임 | |--------|----------|------| -| **Interfaces** | Controller, Dto | HTTP 요청/응답 처리, DTO 변환 | -| **Application** | Facade, Info | 유스케이스 조합, 도메인 서비스 오케스트레이션 | -| **Domain** | Model, Service, Reader, VO, Repository(interface) | 핵심 비즈니스 규칙, 상태 변화, 조회 로직 | -| **Infrastructure** | RepositoryImpl, JpaRepository, Converter | 기술 구현, 영속화, VO 변환 | +| **Interfaces** | Controller, Dto, ApiSpec | HTTP 요청/응답 처리, DTO 변환 | +| **Application** | Facade, Info | 유스케이스 조합, 도메인 서비스 오케스트레이션, Info 변환 | +| **Domain** | Model, Service, Reader(Order만), VO, Repository(interface) | 핵심 비즈니스 규칙, 상태 변화, 트랜잭션 관리 | +| **Infrastructure** | RepositoryImpl, JpaRepository, Converter | 기술 구현, 영속화, VO ↔ DB 변환 | --- ## 도메인 모델 전체 구조 ### 검증 목적 -전체 도메인 모델의 **관계**와 **의존성 방향**을 파악한다. Brand-Product, User-Like-Product, User-Order-OrderItem 관계가 명확히 드러나야 하며, 각 도메인이 다른 도메인의 **구현 세부사항에 의존하지 않는지** 확인한다. +전체 도메인 모델의 **관계**와 **의존성 방향**을 파악한다. Brand-Product, Member-Like-Product, Member-Order-OrderItem 관계가 명확히 드러나야 하며, 각 도메인이 다른 도메인의 **구현 세부사항에 의존하지 않는지** 확인한다. ### 다이어그램 @@ -40,82 +40,81 @@ classDiagram class BrandModel { <> +Long id - +String brandName + +BrandId brandId + +BrandName brandName + +create(brandId, brandName) BrandModel$ + +markAsDeleted() + +isDeleted() boolean } class ProductModel { <> +Long id - +Long brandId - +String productName - +BigDecimal price - +int stockQty - +ProductStatus status + +ProductId productId + +RefBrandId refBrandId + +ProductName productName + +Price price + +StockQuantity stockQuantity + +create(productId, refBrandId, productName, price, stockQuantity) ProductModel$ +decreaseStock(int qty) +increaseStock(int qty) + +markAsDeleted() + +isDeleted() boolean } class LikeModel { <> +Long id - +Long userId - +Long productId - +create(Long userId, Long productId) LikeModel + +RefMemberId refMemberId + +RefProductId refProductId + +create(refMemberId, refProductId) LikeModel$ } class OrderModel { <> +Long id - +Long userId - +BigDecimal totalAmount + +OrderId orderId + +RefMemberId refMemberId +OrderStatus status + +List~OrderItemModel~ orderItems + +create(memberId, items) OrderModel$ +cancel() - +isCancelable() boolean + +isOwner(memberId) boolean + +getTotalAmount() BigDecimal } class OrderItemModel { <> +Long id - +Long orderId - +Long productId + +OrderItemId orderItemId + +String productId +String productName - +String brandName - +BigDecimal unitPrice + +BigDecimal price +int quantity - +BigDecimal lineAmount - } - - class ProductStatus { - <> - ACTIVE - INACTIVE - OUT_OF_STOCK + +create(productId, productName, price, quantity) OrderItemModel$ + +getTotalPrice() BigDecimal } class OrderStatus { <> PENDING CANCELED + +validateTransition(target) } - BrandModel "1" --> "0..*" ProductModel : brandId - ProductModel "1" --> "0..*" LikeModel : productId - OrderModel "1" --> "1..*" OrderItemModel : orderId - -%% 주문항목은 productId로만 참조하고, 나머지는 스냅샷으로 고정 - OrderItemModel ..> ProductModel : reference(productId) - - ProductModel --> ProductStatus + BrandModel "1" --> "0..*" ProductModel : refBrandId + ProductModel "1" --> "0..*" LikeModel : refProductId + OrderModel "1" --> "1..*" OrderItemModel : oneToMany(cascade) + OrderItemModel ..> ProductModel : productId(스냅샷 참조) OrderModel --> OrderStatus - ``` ### 해석 -- **Brand-Product**: 1:N 관계이지만, ProductModel은 brandId(Long)만 보유하고 BrandModel 객체를 직접 참조하지 않는다 (느슨한 결합). -- **User-Like-Product**: LikeModel은 userId, productId만 보유 (User 도메인은 이번 범위 밖). -- **Order-OrderItem**: 1:N 강한 연관. OrderModel이 OrderItemModel을 Aggregate Root로 관리. -- **OrderItem-Product**: OrderItemModel은 productId를 참조하되, 스냅샷(productName, brandName, unitPrice 등)을 저장하여 Product 삭제에 독립적. -- **Soft Delete**: 모든 Model에 deletedAt 필드 존재 (BrandModel, ProductModel). +- **Brand-Product**: 1:N 관계. ProductModel은 `refBrandId(Long)`만 보유하고 BrandModel 객체를 직접 참조하지 않는다 (느슨한 결합). +- **Like**: Member-Product 간 독립 도메인. `refMemberId(Long)`, `refProductId(Long)`로 간접 참조. +- **Order-OrderItem**: 1:N 강한 연관 (cascade). OrderModel이 Aggregate Root로 OrderItemModel을 관리. +- **OrderItem 스냅샷**: productId, productName, price를 저장 시점의 값으로 복사. Product 삭제/수정 후에도 주문 이력 유지. +- **Soft Delete**: BrandModel, ProductModel에 deletedAt 필드 존재 (BaseEntity 상속). LikeModel, OrderModel은 hard delete 또는 삭제 없음. --- @@ -124,7 +123,7 @@ classDiagram ### 1. Brand 도메인 #### 검증 목적 -Brand 도메인은 **soft delete 연쇄**와 **단순 CRUD** 책임을 가진다. Facade에서 Brand 삭제 시 Product도 함께 soft delete 처리하는 오케스트레이션을 확인한다. +Brand 도메인은 **soft delete**와 **단순 CRUD** 책임을 가진다. BrandService가 브랜드 생성/삭제를 담당하며, BrandFacade가 Controller와 Service를 연결한다. #### 다이어그램 @@ -135,34 +134,45 @@ classDiagram %% Interfaces class BrandV1Controller { <> - } - class BrandAdminV1Controller { - <> + +createBrand(request) ApiResponse + +deleteBrand(brandId) ApiResponse } class BrandV1Dto { - <> + <> + +CreateBrandRequest + +BrandResponse } %% Application class BrandFacade { <> + +createBrand(brandId, brandName) BrandInfo + +deleteBrand(brandId) } class BrandInfo { - <> + <> + +Long id + +String brandId + +String brandName } %% Domain class BrandService { <> - } - class BrandReader { - <> + +createBrand(brandId, brandName) BrandModel + +deleteBrand(brandId) } class BrandRepository { <> + +save(brand) BrandModel + +findByBrandId(brandId) Optional + +findById(id) Optional + +existsByBrandId(brandId) boolean } class BrandModel { <> + +BrandId brandId + +BrandName brandName } %% Infrastructure @@ -172,39 +182,21 @@ classDiagram class BrandJpaRepository { <> } - class ProductService { - <> - } -%% Relationships (dependency direction) +%% Relationships BrandV1Controller --> BrandFacade - BrandAdminV1Controller --> BrandFacade BrandV1Controller ..> BrandV1Dto - BrandAdminV1Controller ..> BrandV1Dto - -%% Facade -> Service only (no direct Reader call) BrandFacade --> BrandService BrandFacade ..> BrandInfo - -%% Service owns query + command orchestration - BrandService --> BrandReader BrandService --> BrandRepository - BrandReader --> BrandRepository - -%% Port/Adapter BrandRepository <|.. BrandRepositoryImpl BrandRepositoryImpl --> BrandJpaRepository - -%% Domain persistence relation - BrandRepository ..> BrandModel : persists/loads - -%% Business rule - BrandService --> ProductService : cascade delete + BrandRepository ..> BrandModel ``` #### 해석 -- **Facade 책임**: 브랜드 삭제 시 ProductService를 호출하여 연쇄 soft delete 처리 (도메인 간 조합). -- **Reader vs Service**: BrandReader는 조회 전용, BrandService는 CUD 담당. +- **Reader 미사용**: BrandService가 BrandRepository를 직접 사용. Reader 패턴은 Order 도메인에서만 유지. +- **Thin Facade**: BrandFacade는 BrandService를 위임 호출하고 BrandInfo로 변환. - **의존성 역전**: Domain의 BrandRepository(interface)를 Infrastructure의 BrandRepositoryImpl이 구현. --- @@ -212,7 +204,7 @@ classDiagram ### 2. Product 도메인 #### 검증 목적 -Product 도메인은 **재고 차감/복구**, **soft delete**, **Brand 참조** 책임을 가진다. ProductModel의 `decreaseStock`, `increaseStock` 메서드가 도메인 행위를 표현하는지 확인한다. +Product 도메인은 **재고 차감/복구**, **soft delete**, **Brand 참조**, **좋아요 수 집계** 책임을 가진다. ProductFacade가 Brand 정보와 좋아요 수를 enrichment하는 흐름을 확인한다. #### 다이어그램 @@ -223,91 +215,97 @@ classDiagram %% Interfaces class ProductV1Controller { <> - } - class ProductAdminV1Controller { - <> + +createProduct(request) ApiResponse + +getProducts(brandId, sort, page, size) ApiResponse + +deleteProduct(productId) ApiResponse } class ProductV1Dto { - <> + <> + +CreateProductRequest + +ProductResponse + +ProductListResponse } %% Application class ProductFacade { <> + +createProduct(...) ProductInfo + +deleteProduct(productId) + +getProducts(brandId, sortBy, pageable) Page~ProductInfo~ + -enrichProductInfo(product) ProductInfo } class ProductInfo { - <> + <> + +Long id + +String productId + +Long refBrandId + +String productName + +BigDecimal price + +int stockQuantity + +BrandInfo brand + +long likesCount } %% Domain class ProductService { <> - } - class ProductReader { - <> - } - class BrandReader { - <> + +createProduct(productId, brandId, productName, price, stockQuantity) ProductModel + +deleteProduct(productId) + +getProducts(brandId, sortBy, pageable) Page~ProductModel~ } class ProductRepository { <> + +save(product) ProductModel + +findByProductId(productId) Optional + +existsByProductId(productId) boolean + +findProducts(refBrandId, sortBy, pageable) Page + +decreaseStockIfAvailable(productId, quantity) boolean + +increaseStock(productId, quantity) + +countLikes(productId) long } class ProductModel { <> } - class ProductStatus { - <> - ACTIVE - INACTIVE - OUT_OF_STOCK - } %% Infrastructure class ProductRepositoryImpl { <> + -EntityManager entityManager + +findProducts() Native SQL + +decreaseStockIfAvailable() Native SQL UPDATE + +increaseStock() Native SQL UPDATE + +countLikes() Native SQL COUNT } class ProductJpaRepository { <> } -%% Relationships (dependency direction) +%% Relationships ProductV1Controller --> ProductFacade - ProductAdminV1Controller --> ProductFacade ProductV1Controller ..> ProductV1Dto - ProductAdminV1Controller ..> ProductV1Dto - -%% Facade -> Service only (no direct Reader call) ProductFacade --> ProductService + ProductFacade --> ProductRepository : countLikes + ProductFacade --> BrandRepository : enrichment ProductFacade ..> ProductInfo - -%% Service owns query + command orchestration + validation - ProductService --> ProductReader ProductService --> ProductRepository - ProductReader --> ProductRepository - - ProductService --> BrandReader : validate brand - -%% Port/Adapter + ProductService --> BrandRepository : validate brand exists ProductRepository <|.. ProductRepositoryImpl ProductRepositoryImpl --> ProductJpaRepository - -%% Domain relations - ProductModel --> ProductStatus - ProductRepository ..> ProductModel : persists/loads + ProductRepository ..> ProductModel ``` #### 해석 -- **재고 도메인 행위**: `decreaseStock`, `increaseStock`은 ProductModel의 도메인 메서드이지만, 동시성 제어를 위해 Repository에서 조건부 UPDATE 실행. -- **Brand 참조**: ProductService가 BrandReader를 의존하여 브랜드 존재 확인 (brandId 검증). -- **연쇄 삭제**: ProductFacade의 `softDeleteByBrandId`는 BrandFacade에서 호출됨. -- **정렬/집계**: ProductRepository의 findAll에서 likes_desc 정렬 시 COUNT 서브쿼리 또는 like_count 컬럼 사용. +- **Facade Enrichment**: ProductFacade가 ProductModel → ProductInfo 변환 시, BrandRepository와 ProductRepository(countLikes)를 추가 조회하여 Brand 정보와 좋아요 수를 enrichment. +- **Reader 미사용**: ProductService가 ProductRepository를 직접 사용. +- **Native Query**: VO 타입(ProductId, RefBrandId 등)이 JPQL과 호환 어려워 EntityManager + Native SQL 사용. +- **재고 동시성**: `decreaseStockIfAvailable`는 비관적 락 기반으로 구현 예정 (`SELECT FOR UPDATE`). --- ### 3. Like 도메인 #### 검증 목적 -Like 도메인은 **멱등성**과 **UNIQUE 제약** 처리를 확인한다. LikeService에서 중복 처리 로직이 명확한지 검증한다. +Like 도메인은 **멱등성**과 **UNIQUE 제약** 처리를 확인한다. LikeService가 ProductRepository를 직접 사용하여 상품 존재 확인을 하는지 검증한다. #### 다이어그램 @@ -318,31 +316,45 @@ classDiagram %% Interfaces class LikeV1Controller { <> + +addLike(request) ApiResponse + +removeLike(request) ApiResponse } class LikeV1Dto { - <> + <> + +AddLikeRequest + +RemoveLikeRequest + +LikeResponse } %% Application class LikeFacade { <> + +addLike(memberId, productId) LikeInfo + +removeLike(memberId, productId) } class LikeInfo { - <> + <> + +Long id + +Long refMemberId + +Long refProductId } %% Domain class LikeService { <> - } - class ProductReader { - <> + +addLike(memberId, productId) LikeModel + +removeLike(memberId, productId) } class LikeRepository { <> + +save(like) LikeModel + +findByRefMemberIdAndRefProductId(refMemberId, refProductId) Optional + +delete(like) } class LikeModel { <> + +RefMemberId refMemberId + +RefProductId refProductId } %% Infrastructure @@ -351,41 +363,32 @@ classDiagram } class LikeJpaRepository { <> + +UNIQUE(ref_member_id, ref_product_id) } -%% Relationships (dependency direction) +%% Relationships LikeV1Controller --> LikeFacade LikeV1Controller ..> LikeV1Dto - -%% Facade -> Service only LikeFacade --> LikeService LikeFacade ..> LikeInfo - -%% Service owns validation + orchestration - LikeService --> ProductReader : validate product exists LikeService --> LikeRepository - -%% Port/Adapter + LikeService --> ProductRepository : validate product exists LikeRepository <|.. LikeRepositoryImpl LikeRepositoryImpl --> LikeJpaRepository - -%% Persistence relation + invariant - LikeRepository ..> LikeModel : persists/loads - LikeJpaRepository ..> LikeModel : UNIQUE(userId, productId) - + LikeRepository ..> LikeModel ``` #### 해석 -- **멱등 처리**: LikeService의 `addLike`에서 UNIQUE 제약 위반 시 catch하여 성공 처리, `removeLike`는 affected rows=0이어도 성공. -- **Facade 책임**: ProductReader를 호출하여 상품 존재 확인 (삭제된 상품에 좋아요 방지). -- **간결한 도메인**: LikeModel은 단순 CUD만 수행, 복잡한 비즈니스 규칙 없음. +- **ProductRepository 직접 사용**: LikeService가 Reader 없이 ProductRepository를 직접 의존하여 상품 존재 확인. +- **UNIQUE 제약**: `uk_likes_member_product(ref_member_id, ref_product_id)` - DB 레벨 중복 방지. +- **멱등 처리**: 선조회로 중복 확인 → INSERT 시도 → DataIntegrityViolationException catch → 재조회 반환. --- ### 4. Order 도메인 #### 검증 목적 -Order 도메인은 **재고 차감**, **스냅샷 저장**, **주문 취소** 책임을 가진다. OrderFacade가 ProductService, OrderService를 조합하여 트랜잭션을 관리하는지 확인한다. +Order 도메인은 **재고 차감(비관적 락)**, **스냅샷 저장**, **주문 취소** 책임을 가진다. OrderService가 ProductRepository를 직접 사용하여 재고 차감 + 주문 저장을 오케스트레이션하는지 확인한다. #### 다이어그램 @@ -393,52 +396,52 @@ Order 도메인은 **재고 차감**, **스냅샷 저장**, **주문 취소** classDiagram direction LR -%% Interfaces +%% Interfaces (TODO: 미구현) class OrderV1Controller { - <> - } - class OrderV1Dto { - <> - } - -%% Application - class OrderFacade { - <> - } - class OrderInfo { - <> - } - class OrderItemInfo { - <> + <> } %% Domain class OrderService { <> + +createOrder(memberId, items) OrderModel + -aggregateQuantities(items) Map } class OrderReader { <> + +getOrThrow(orderId) OrderModel } - class ProductReader { - <> - } - class ProductService { - <> - } - class OrderRepository { <> + +save(order) OrderModel + +findByOrderId(orderId) Optional } class OrderModel { <> + +OrderId orderId + +RefMemberId refMemberId + +OrderStatus status + +List~OrderItemModel~ orderItems + +create(memberId, items) OrderModel$ + +cancel() + +isOwner(memberId) boolean + +getTotalAmount() BigDecimal } class OrderItemModel { <> + +OrderItemId orderItemId + +String productId + +String productName + +BigDecimal price + +int quantity + +create(...) OrderItemModel$ + +getTotalPrice() BigDecimal } class OrderStatus { <> PENDING CANCELED + +validateTransition(target) } %% Infrastructure @@ -448,81 +451,106 @@ classDiagram class OrderJpaRepository { <> } - class OrderItemJpaRepository { - <> - } - -%% Relationships (dependency direction) - OrderV1Controller --> OrderFacade - OrderV1Controller ..> OrderV1Dto -%% Facade -> Service only (no direct Reader call) - OrderFacade --> OrderService - OrderFacade ..> OrderInfo - OrderFacade ..> OrderItemInfo - -%% Service owns query + command orchestration - OrderService --> OrderReader +%% Relationships OrderService --> OrderRepository + OrderService --> ProductRepository : 재고 차감/복구 + 상품 조회 OrderReader --> OrderRepository - -%% Order creation needs product lookup + stock change (or compensation on cancel) - OrderService --> ProductReader : load product for snapshot/price - OrderService --> ProductService : decrease/increase stock - -%% Port/Adapter OrderRepository <|.. OrderRepositoryImpl OrderRepositoryImpl --> OrderJpaRepository - OrderRepositoryImpl --> OrderItemJpaRepository - -%% Aggregate - OrderModel "1" --> "1..*" OrderItemModel : contains + OrderModel "1" --> "1..*" OrderItemModel : cascade OrderModel --> OrderStatus - OrderRepository ..> OrderModel : persists/loads - OrderRepository ..> OrderItemModel : persists/loads - + OrderRepository ..> OrderModel ``` #### 해석 -- **Facade 트랜잭션 조합**: - - 주문 생성: ProductReader(상품 조회) → ProductService(재고 차감) → OrderService(주문+스냅샷 저장) - - 주문 취소: OrderReader(주문 조회) → OrderService(상태 전이) → ProductService(재고 복구) -- **스냅샷**: OrderItemModel이 productName, brandName 등을 저장하여 Product 삭제에 독립적. -- **상태 전이**: OrderModel의 `cancel()` 메서드가 상태 검증 후 CANCELED로 전이. -- **Aggregate Root**: OrderModel이 OrderItemModel을 소유 (1:N 강한 연관). +- **Facade 미구현**: Order 도메인은 아직 Facade 없이 OrderService가 직접 ProductRepository와 OrderRepository를 사용. +- **OrderReader 유지**: orderId 기반 조회 + 404 처리를 담당하는 Reader는 Order 도메인에만 존재. +- **재고 차감 책임**: OrderService가 ProductRepository.decreaseStockIfAvailable()를 호출하여 재고 차감. +- **OrderItem은 별도 JpaRepository 없음**: OrderModel에 cascade ALL 설정으로 OrderJpaRepository가 order_items도 함께 관리. --- ## Value Object 설계 ### 검증 목적 -이 프로젝트에서는 **간단한 원시 타입은 VO로 만들지 않는다**. 대신 **검증 규칙이나 연산이 필요한 경우에만** VO를 도입한다. 현재 설계에서는 ProductStatus, OrderStatus만 enum으로 정의하고, 나머지(price, stockQty 등)는 원시 타입 사용. +이 프로젝트에서 VO는 **검증 규칙이 있는 원시값을 캡슐화**한다. `record` 타입의 Compact Constructor에서 검증을 수행하여, 잘못된 상태가 생성 시점에 차단된다. #### 다이어그램 ```mermaid classDiagram - class ProductStatus { - <> - ACTIVE - INACTIVE - OUT_OF_STOCK - +isActive() boolean + class ProductId { + <> + +String value + %% ^[A-Za-z0-9]{1,20}$ + } + class ProductName { + <> + +String value + %% 1-100자 + } + class Price { + <> + +BigDecimal value + %% >= 0, scale=2 + } + class StockQuantity { + <> + +int value + %% >= 0 + } + class RefBrandId { + <> + +Long value + %% > 0 + } + class BrandId { + <> + +String value + %% ^[A-Za-z0-9]{1,10}$ + } + class BrandName { + <> + +String value + %% 1-50자 + } + class OrderId { + <> + +String value + +generate() OrderId$ + %% UUID 형식 + } + class OrderItemId { + <> + +String value + +generate() OrderItemId$ + %% UUID 형식 + } + class RefMemberId { + <> + +Long value + %% > 0 + } + class RefProductId { + <> + +Long value + %% > 0 } - class OrderStatus { <> PENDING CANCELED - +isCancelable() boolean - +canTransitionTo(target) boolean + +validateTransition(target) } ``` #### 해석 -- **ProductStatus**: 상품 상태 관리 (활성/비활성/품절). 확장 가능 (추후 SOLD_OUT 등 추가). -- **OrderStatus**: 주문 상태 전이 검증 메서드 제공 (`isCancelable`, `canTransitionTo`). -- **VO 미도입 대상**: price (BigDecimal), stockQty (Integer), userId (Long) 등은 별도 검증 규칙 없이 원시 타입 사용. +- **record 타입**: 불변 + Compact Constructor 검증으로 잘못된 값 생성 차단. +- **Converter 패턴**: 각 VO에 대응하는 JPA Converter가 DB 저장/조회 시 원시타입 ↔ VO 변환. +- **FK 참조 VO**: `RefBrandId(Long)`, `RefMemberId(Long)`, `RefProductId(Long)` - 외래키를 VO로 래핑. +- **UUID VO**: `OrderId`, `OrderItemId` - UUID 기반으로 정적 `generate()` 메서드 제공. +- **OrderStatus**: 상태 전이 검증 메서드(`validateTransition`) 포함. --- @@ -551,9 +579,9 @@ flowchart LR subgraph D["Domain"] direction TB S["Service"] - R["Reader"] + R["Reader (Order only)"] M["Model (Entity)"] - VO["Value Object"] + VO["Value Object (record)"] REPO["Repository (interface)"] end @@ -561,7 +589,7 @@ flowchart LR direction TB REPOIMPL["RepositoryImpl"] JPA["JpaRepository"] - CONV["Converter"] + CONV["Converter (VO ↔ DB)"] end %% ===== Main dependency flow ===== @@ -571,8 +599,7 @@ flowchart LR F --> S F -. "returns" .-> INFO -%% Service owns orchestration (including reads) - S --> R +%% Service uses Repository directly (Reader only in Order domain) S --> REPO R --> REPO @@ -583,12 +610,12 @@ flowchart LR REPOIMPL --> JPA REPOIMPL --> CONV -%% Domain composition (expressed in flowchart terms) +%% Domain composition S -. "uses" .-> M R -. "uses" .-> M M -->|composes| VO -%% ===== Styling (light) ===== +%% ===== Styling ===== classDef layer fill:#f7f7f7,stroke:#999,stroke-width:1px,color:#111; classDef domain fill:#eef6ff,stroke:#3b82f6,stroke-width:1px,color:#111; classDef infra fill:#fff3e6,stroke:#f59e0b,stroke-width:1px,color:#111; @@ -596,13 +623,13 @@ flowchart LR class C,DTO,F,INFO layer; class S,R,M,VO,REPO domain; class REPOIMPL,JPA,CONV infra; - ``` ### 해석 -- **의존성 역전**: Domain의 Repository(interface)를 Infrastructure의 RepositoryImpl이 구현 (점선). -- **DTO 변환**: Controller에서 Dto → Info 변환, Facade에서 Info → Dto 변환 (레이어 간 격리). -- **도메인 독립성**: Domain Layer는 Infrastructure를 직접 의존하지 않음 (Spring, JPA 무관). +- **Reader 패턴**: Brand/Product 도메인에서는 제거됨. Order 도메인에만 OrderReader 유지 (orderId 기반 조회 특화). +- **의존성 역전**: Domain의 Repository(interface) ← Infrastructure의 RepositoryImpl이 구현 (점선). +- **Converter**: VO ↔ DB 원시타입 변환 담당. EntityManager Native Query 사용 시 VO 타입 제약 우회. +- **도메인 독립성**: Domain Layer는 Spring, JPA 기술을 직접 알지 않음 (단, JPA Entity 어노테이션은 예외). --- @@ -610,28 +637,20 @@ flowchart LR ### 1. 레이어 책임 분리 - **Controller**: HTTP 프로토콜, DTO 변환, 인증 헤더 추출 -- **Facade**: 유스케이스 조합, 트랜잭션 경계 (@Transactional) -- **Service**: 도메인 규칙 실행, 교차 엔티티 로직 -- **Reader**: 조회 전용, VO 변환, getOrThrow 패턴 -- **Repository**: 영속화, 쿼리 실행 +- **Facade**: 유스케이스 조합, Model → Info 변환, Thin Facade (로직 없음) +- **Service**: 도메인 규칙 실행, @Transactional 경계, Repository 호출 +- **Reader**: orderId 기반 조회 + orElseThrow (Order 도메인 한정) +- **Repository**: 영속화, 쿼리 실행 (Domain interface → Infrastructure 구현) ### 2. 도메인 모델 설계 -- **정적 팩토리**: `create()` 메서드로 생성 (생성자 private) -- **도메인 행위**: `cancel()`, `decreaseStock()` 등 도메인 메서드 제공 -- **검증 로직**: private 메서드로 캡슐화 (`validateStockSufficient`) +- **정적 팩토리**: `create()` 메서드로 생성 (생성자 private/protected) +- **도메인 행위**: `cancel()`, `decreaseStock()`, `isOwner()` 등 도메인 메서드 제공 +- **불변 VO**: record 타입, Compact Constructor 검증, Converter로 DB 연동 -### 3. Value Object 최소화 -- **원칙**: 검증/연산 규칙이 없으면 원시 타입 사용 -- **VO 도입 기준**: 불변 + 검증 규칙 + 의미 있는 연산 -- **현재 VO**: ProductStatus, OrderStatus (enum) +### 3. 동시성 제어 +- **재고**: 비관적 락 (`SELECT ... FOR UPDATE`) - 경합 높음, 과판매 불가 +- **좋아요**: DB UNIQUE 제약 + catch - 경합 낮음, 중복 1건 허용 범위 ### 4. 의존성 역전 - Domain이 Infrastructure를 의존하지 않음 - Repository interface를 Domain에 두고, Infrastructure에서 구현 - ---- - -## 다음 단계 - -이 클래스 다이어그램을 기반으로: -- **04-erd.md**: 각 Model의 테이블 구조, UNIQUE 제약, FK, 인덱스 설계 diff --git a/docs/design/04-erd.md b/docs/design/04-erd.md index 332fb967d..ba0f5e2c6 100644 --- a/docs/design/04-erd.md +++ b/docs/design/04-erd.md @@ -2,206 +2,207 @@ ## 개요 -이 문서는 데이터베이스 설계를 정의한다. ERD는 **테이블 구조**, **제약 조건**, **인덱스 후보**, **상태/삭제 정책**을 포함하며, 요구사항에서 도출된 **핵심 조회 패턴**을 기반으로 검증한다. +이 문서는 데이터베이스 설계를 정의한다. ERD는 **실제 구현된 테이블 구조**, **제약 조건**, **인덱스 후보**, **상태/삭제 정책**을 포함하며, 요구사항에서 도출된 **핵심 조회 패턴**을 기반으로 검증한다. **핵심 조회 패턴** (인덱스 설계 근거): -1. 상품 목록 조회 (brandId 필터, 정렬, soft delete 필터) -2. 좋아요한 상품 목록 조회 (userId 필터) -3. 주문 목록 조회 (userId 필터, 기간 필터) +1. 상품 목록 조회 (ref_brand_id 필터, 정렬, soft delete 필터) +2. 좋아요 수 집계 (ref_product_id 기반 COUNT) +3. 주문 목록 조회 (ref_member_id 필터) --- ## 전체 ERD ### 검증 목적 -테이블 간 **FK 관계**, **UNIQUE 제약**, **soft delete** 컬럼이 요구사항과 일치하는지 확인한다. 특히 order_item의 **스냅샷 컬럼**이 product/brand 삭제에 독립적인지 검증한다. +테이블 간 **FK 관계**, **UNIQUE 제약**, **soft delete** 컬럼이 요구사항과 일치하는지 확인한다. 특히 order_items의 **스냅샷 컬럼**이 products/brands 삭제에 독립적인지 검증한다. ### 다이어그램 ```mermaid erDiagram - member ||--o{ like : "1:N" - member ||--o{ orders : "1:N" - brand ||--o{ product : "1:N" - product ||--o{ like : "1:N" - product ||--o{ order_item : "1:N (참조)" - orders ||--|{ order_item : "1:N" - - member { + members ||--o{ likes : "1:N" + members ||--o{ orders : "1:N" + brands ||--o{ products : "1:N" + products ||--o{ likes : "1:N" + orders ||--|{ order_items : "1:N" + + members { bigint id PK "AUTO_INCREMENT" varchar(10) member_id UK "NOT NULL, 영문+숫자, 1~10자" varchar(320) email UK "NOT NULL, RFC 5322" date birth_date "NOT NULL" varchar(255) password "NOT NULL" - datetime created_at "NOT NULL, DEFAULT CURRENT_TIMESTAMP" - datetime updated_at "NOT NULL, DEFAULT CURRENT_TIMESTAMP ON UPDATE" + datetime created_at "NOT NULL" + datetime updated_at "NOT NULL" } - brand { + brands { bigint id PK "AUTO_INCREMENT" - varchar(100) brand_name "NOT NULL" - text description "NULL" - varchar(500) logo_url "NULL" + varchar(10) brand_id UK "NOT NULL, 영문+숫자, 1~10자" + varchar(50) brand_name "NOT NULL" datetime created_at "NOT NULL" datetime updated_at "NOT NULL" datetime deleted_at "NULL, soft delete" } - product { + products { bigint id PK "AUTO_INCREMENT" - bigint brand_id FK "NOT NULL, REFERENCES brand(id)" - varchar(200) product_name "NOT NULL" - decimal(152) price "NOT NULL, >= 0" - int stock_qty "NOT NULL, >= 0" - text description "NULL" - varchar(500) image_url "NULL" - varchar(20) status "NOT NULL, DEFAULT 'ACTIVE'" + varchar(20) product_id UK "NOT NULL, 영문+숫자, 1~20자" + bigint ref_brand_id "NOT NULL, REFERENCES brands(id)" + varchar(100) product_name "NOT NULL" + decimal(10_2) price "NOT NULL, >= 0" + int stock_quantity "NOT NULL, >= 0" datetime created_at "NOT NULL" datetime updated_at "NOT NULL" datetime deleted_at "NULL, soft delete" } - like { + likes { bigint id PK "AUTO_INCREMENT" - bigint user_id FK "NOT NULL, REFERENCES member(id)" - bigint product_id FK "NOT NULL, REFERENCES product(id)" + bigint ref_member_id "NOT NULL, REFERENCES members(id)" + bigint ref_product_id "NOT NULL, REFERENCES products(id)" datetime created_at "NOT NULL" + datetime updated_at "NOT NULL" + datetime deleted_at "NULL (미사용)" } orders { bigint id PK "AUTO_INCREMENT" - bigint user_id FK "NOT NULL, REFERENCES member(id)" - decimal(152) total_amount "NOT NULL, >= 0" - varchar(20) status "NOT NULL, DEFAULT 'PENDING'" - datetime ordered_at "NOT NULL" - datetime canceled_at "NULL" + varchar(36) order_id UK "NOT NULL, UUID" + bigint ref_member_id "NOT NULL, REFERENCES members(id)" + varchar(20) status "NOT NULL, PENDING or CANCELED" datetime created_at "NOT NULL" + datetime updated_at "NOT NULL" + datetime deleted_at "NULL (미사용)" } - order_item { + order_items { bigint id PK "AUTO_INCREMENT" + varchar(36) order_item_id UK "NOT NULL, UUID" bigint order_id FK "NOT NULL, REFERENCES orders(id)" - bigint product_id FK "NOT NULL, REFERENCES product(id), 참조용" - varchar(200) product_name "NOT NULL, 스냅샷" - bigint brand_id "NULL, 참조용" - varchar(100) brand_name "NOT NULL, 스냅샷" - decimal(152) unit_price "NOT NULL, 스냅샷" + varchar(20) product_id "NOT NULL, 스냅샷(비즈니스 ID)" + varchar(100) product_name "NOT NULL, 스냅샷" + decimal(10_2) price "NOT NULL, 스냅샷" int quantity "NOT NULL, >= 1" - decimal(152) line_amount "NOT NULL, = unit_price * quantity" - varchar(500) image_url "NULL, 스냅샷" + datetime created_at "NOT NULL" + datetime updated_at "NOT NULL" + datetime deleted_at "NULL (미사용)" } ``` ### 해석 -- **Soft Delete**: brand, product 테이블에 `deleted_at` 컬럼 존재. 삭제 시 `deleted_at = NOW()` UPDATE. -- **스냅샷**: order_item이 product_name, brand_name, unit_price 등을 저장하여 product/brand 삭제에 독립적. -- **FK 관계**: product → brand, like → member/product, orders → member, order_item → orders/product (참조용). -- **UNIQUE 제약**: like 테이블에 (user_id, product_id) UNIQUE (다이어그램에서 명시 필요). +- **Soft Delete**: brands, products 테이블에 `deleted_at` 컬럼 존재. 삭제 시 `deleted_at = NOW()` UPDATE. +- **스냅샷**: order_items가 product_id(비즈니스 ID), product_name, price를 저장하여 products 삭제에 독립적. +- **BaseEntity 상속**: created_at, updated_at, deleted_at 컬럼은 모든 테이블에 포함 (JPA BaseEntity 상속). +- **likes 테이블**: ref_member_id, ref_product_id로 members/products를 간접 참조. Hard delete (물리 삭제). +- **orders/order_items**: 삭제 없음 (영구 보존). cascade로 order_items는 orders와 함께 관리. --- ## 테이블 상세 설계 -### 1. member (이미 구현됨) +### 1. members (이미 구현됨) 이 테이블은 이번 설계 범위 밖이지만, 참조 무결성을 위해 명시한다. | 컬럼 | 타입 | 제약 | 설명 | |------|------|------|------| -| id | BIGINT | PK, AUTO_INCREMENT | 회원 ID | +| id | BIGINT | PK, AUTO_INCREMENT | 시스템 ID | | member_id | VARCHAR(10) | NOT NULL, UNIQUE | 로그인 ID (영문+숫자, 1~10자) | | email | VARCHAR(320) | NOT NULL, UNIQUE | 이메일 (RFC 5322) | | birth_date | DATE | NOT NULL | 생년월일 | | password | VARCHAR(255) | NOT NULL | 비밀번호 해시 | -| created_at | DATETIME | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 생성 일시 | -| updated_at | DATETIME | NOT NULL, DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | 수정 일시 | - -**제약**: -- PK: `id` +| created_at | DATETIME | NOT NULL | 생성 일시 | +| updated_at | DATETIME | NOT NULL | 수정 일시 | --- -### 2. brand +### 2. brands | 컬럼 | 타입 | 제약 | 설명 | |------|------|------|------| -| id | BIGINT | PK, AUTO_INCREMENT | 브랜드 ID | -| brand_name | VARCHAR(100) | NOT NULL | 브랜드명 | -| description | TEXT | NULL | 브랜드 설명 | -| logo_url | VARCHAR(500) | NULL | 로고 이미지 URL | +| id | BIGINT | PK, AUTO_INCREMENT | 시스템 ID | +| brand_id | VARCHAR(10) | NOT NULL, UNIQUE | 브랜드 비즈니스 ID (영문+숫자, 1~10자) | +| brand_name | VARCHAR(50) | NOT NULL | 브랜드명 | | created_at | DATETIME | NOT NULL | 생성 일시 | | updated_at | DATETIME | NOT NULL | 수정 일시 | | deleted_at | DATETIME | NULL | 삭제 일시 (soft delete) | **제약**: - PK: `id` +- UK: `brand_id` **삭제 정책**: - Soft Delete: `deleted_at = NOW()` -- 연쇄 삭제: brand 삭제 시 product도 soft delete 처리 (애플리케이션 레벨) +- 연쇄 삭제: brand 삭제 시 products도 soft delete 처리 (애플리케이션 레벨 트랜잭션) --- -### 3. product +### 3. products | 컬럼 | 타입 | 제약 | 설명 | |------|------|------|------| -| id | BIGINT | PK, AUTO_INCREMENT | 상품 ID | -| brand_id | BIGINT | NOT NULL | 브랜드 ID | -| product_name | VARCHAR(200) | NOT NULL | 상품명 | -| price | DECIMAL(15,2) | NOT NULL, CHECK (price >= 0) | 가격 | -| stock_qty | INT | NOT NULL, CHECK (stock_qty >= 0) | 재고 수량 | -| description | TEXT | NULL | 상품 설명 | -| image_url | VARCHAR(500) | NULL | 상품 이미지 URL | -| status | VARCHAR(20) | NOT NULL, DEFAULT 'ACTIVE' | 상품 상태 (ACTIVE/INACTIVE/OUT_OF_STOCK) | +| id | BIGINT | PK, AUTO_INCREMENT | 시스템 ID | +| product_id | VARCHAR(20) | NOT NULL, UNIQUE | 상품 비즈니스 ID (영문+숫자, 1~20자) | +| ref_brand_id | BIGINT | NOT NULL | 브랜드 시스템 ID 참조 | +| product_name | VARCHAR(100) | NOT NULL | 상품명 | +| price | DECIMAL(10,2) | NOT NULL, >= 0 | 가격 | +| stock_quantity | INT | NOT NULL, >= 0 | 재고 수량 | | created_at | DATETIME | NOT NULL | 생성 일시 | | updated_at | DATETIME | NOT NULL | 수정 일시 (latest 정렬 기준) | | deleted_at | DATETIME | NULL | 삭제 일시 (soft delete) | **제약**: - PK: `id` -- FK: `brand_id` REFERENCES `brand(id)` -- CHECK: `price >= 0`, `stock_qty >= 0` - -**재고 차감 동시성 제어**: -- 조건부 원자 UPDATE: - ```sql - UPDATE product - SET stock_qty = stock_qty - :quantity, updated_at = NOW() - WHERE id = :productId - AND deleted_at IS NULL - AND stock_qty >= :quantity; - ``` -- affected rows = 0이면 재고 부족 또는 삭제된 상품 +- UK: `product_id` +- FK: `ref_brand_id` → `brands(id)` + +**재고 차감 동시성 제어 (비관적 락)**: +```sql +-- 1단계: 비관적 락 획득 (트랜잭션 내) +SELECT * FROM products WHERE id = :productId FOR UPDATE; + +-- 2단계: 애플리케이션 레이어에서 재고 검증 +-- product.stockQuantity < quantity → throw CONFLICT + +-- 3단계: 재고 차감 +UPDATE products +SET stock_quantity = stock_quantity - :quantity +WHERE id = :productId; +``` +- `SELECT ... FOR UPDATE`로 행 잠금 → 검증 → 차감이 원자적으로 실행 +- productId 오름차순 정렬로 락 획득 순서 고정 (데드락 방지) **삭제 정책**: - Soft Delete: `deleted_at = NOW()` -- 조회 필터: 모든 조회 쿼리에 `deleted_at IS NULL` 조건 필수 +- 조회 필터: 모든 SELECT 쿼리에 `deleted_at IS NULL` 조건 필수 --- -### 4. like +### 4. likes | 컬럼 | 타입 | 제약 | 설명 | |------|------|------|------| -| id | BIGINT | PK, AUTO_INCREMENT | 좋아요 ID | -| user_id | BIGINT | NOT NULL | 사용자 ID | -| product_id | BIGINT | NOT NULL | 상품 ID | +| id | BIGINT | PK, AUTO_INCREMENT | 시스템 ID | +| ref_member_id | BIGINT | NOT NULL | 회원 시스템 ID 참조 | +| ref_product_id | BIGINT | NOT NULL | 상품 시스템 ID 참조 | | created_at | DATETIME | NOT NULL | 좋아요 일시 | +| updated_at | DATETIME | NOT NULL | 수정 일시 | +| deleted_at | DATETIME | NULL | 미사용 (hard delete) | **제약**: - PK: `id` -- UK: `(user_id, product_id)` (중복 좋아요 방지) -- FK: `user_id` REFERENCES `member(id)` -- FK: `product_id` REFERENCES `product(id)` +- UK: `uk_likes_member_product(ref_member_id, ref_product_id)` — 중복 좋아요 방지 -**멱등성**: -- INSERT 시 UNIQUE 제약 위반 catch → 성공 처리 +**멱등성 (동시성 전략)**: +- 락 없음: 좋아요는 경합이 낮고 1건 중복은 비즈니스적으로 치명적이지 않음 +- 선조회로 중복 확인 후 INSERT 시도 +- 동시 요청으로 UNIQUE 위반 시: `DataIntegrityViolationException` catch → 재조회 → 멱등 성공 - DELETE 시 affected rows = 0 → 성공 처리 **좋아요 수 집계**: -- Phase 1: `SELECT COUNT(*) FROM like WHERE product_id = ?` -- Phase 2 (병목 시): product.like_count 컬럼 도입 (별도 DDL 필요) +- Phase 1 (현재): `SELECT COUNT(*) FROM likes WHERE ref_product_id = :productId` +- Phase 2 (병목 시): products.like_count 컬럼 도입 (별도 DDL 필요) --- @@ -209,88 +210,88 @@ erDiagram | 컬럼 | 타입 | 제약 | 설명 | |------|------|------|------| -| id | BIGINT | PK, AUTO_INCREMENT | 주문 ID | -| user_id | BIGINT | NOT NULL | 사용자 ID | -| total_amount | DECIMAL(15,2) | NOT NULL, CHECK (total_amount >= 0) | 총 주문 금액 | -| status | VARCHAR(20) | NOT NULL, DEFAULT 'PENDING' | 주문 상태 (PENDING/CANCELED) | -| ordered_at | DATETIME | NOT NULL | 주문 일시 | -| canceled_at | DATETIME | NULL | 취소 일시 | -| created_at | DATETIME | NOT NULL | 생성 일시 | +| id | BIGINT | PK, AUTO_INCREMENT | 시스템 ID | +| order_id | VARCHAR(36) | NOT NULL, UNIQUE | 주문 UUID (비즈니스 ID) | +| ref_member_id | BIGINT | NOT NULL | 회원 시스템 ID 참조 | +| status | VARCHAR(20) | NOT NULL | 주문 상태 (PENDING/CANCELED) | +| created_at | DATETIME | NOT NULL | 생성 일시 (= 주문 일시) | +| updated_at | DATETIME | NOT NULL | 수정 일시 (= 취소 일시) | +| deleted_at | DATETIME | NULL | 미사용 (영구 보존) | **제약**: - PK: `id` -- FK: `user_id` REFERENCES `member(id)` -- CHECK: `total_amount >= 0` +- UK: `order_id` +- FK: `ref_member_id` → `members(id)` **상태 전이**: -- PENDING → CANCELED (주문 취소) -- 추후 확장: PENDING → PAID → SHIPPED → COMPLETED +- `PENDING → CANCELED` (주문 취소) +- CANCELED 상태에서 cancel() 호출 시 멱등 성공 (예외 없음) **취소 정책**: -- status = PENDING인 경우에만 취소 가능 -- 취소 시 canceled_at = NOW(), status = CANCELED -- 재고 복구: order_item 기반으로 product.stock_qty += quantity +- PENDING 상태인 경우만 취소 가능 (도메인 OrderStatus.validateTransition) +- 취소 시 order_items 기반으로 products.stock_quantity 복구 --- -### 6. order_item +### 6. order_items | 컬럼 | 타입 | 제약 | 설명 | |------|------|------|------| -| id | BIGINT | PK, AUTO_INCREMENT | 주문 항목 ID | -| order_id | BIGINT | NOT NULL | 주문 ID | -| product_id | BIGINT | NOT NULL | 상품 ID (참조용) | -| product_name | VARCHAR(200) | NOT NULL | 상품명 (스냅샷) | -| brand_id | BIGINT | NULL | 브랜드 ID (참조용) | -| brand_name | VARCHAR(100) | NOT NULL | 브랜드명 (스냅샷) | -| unit_price | DECIMAL(15,2) | NOT NULL | 단가 (스냅샷) | -| quantity | INT | NOT NULL, CHECK (quantity >= 1) | 수량 | -| line_amount | DECIMAL(15,2) | NOT NULL | 행 금액 (= unit_price × quantity) | -| image_url | VARCHAR(500) | NULL | 상품 이미지 URL (스냅샷) | +| id | BIGINT | PK, AUTO_INCREMENT | 시스템 ID | +| order_item_id | VARCHAR(36) | NOT NULL, UNIQUE | 주문항목 UUID (비즈니스 ID) | +| order_id | BIGINT | NOT NULL | 주문 시스템 ID (FK) | +| product_id | VARCHAR(20) | NOT NULL | 상품 비즈니스 ID (스냅샷) | +| product_name | VARCHAR(100) | NOT NULL | 상품명 (스냅샷) | +| price | DECIMAL(10,2) | NOT NULL | 단가 (스냅샷) | +| quantity | INT | NOT NULL, >= 1 | 수량 | +| created_at | DATETIME | NOT NULL | 생성 일시 | +| updated_at | DATETIME | NOT NULL | 수정 일시 | +| deleted_at | DATETIME | NULL | 미사용 | **제약**: - PK: `id` -- FK: `order_id` REFERENCES `orders(id)` ON DELETE CASCADE -- FK: `product_id` REFERENCES `product(id)` (참조용, ON DELETE RESTRICT 또는 SET NULL 고려) -- CHECK: `quantity >= 1` +- UK: `order_item_id` +- FK: `order_id` → `orders(id)` ON DELETE CASCADE **스냅샷 정책**: -- **저장 필드**: product_name, brand_name, unit_price, quantity, line_amount, image_url -- **참조 필드**: product_id, brand_id (조인 최소화, 삭제 후에도 조회 가능) -- **제외 필드**: description (최소 스냅샷 원칙) - -**삭제 정책**: -- product 삭제 시: order_item.product_id는 유지 (FK ON DELETE RESTRICT 또는 SET NULL) -- 스냅샷 덕분에 product 삭제 후에도 주문 조회 가능 +- **저장 필드**: product_id(비즈니스 ID), product_name, price, quantity +- **총액 계산**: `price × quantity` (getTotalPrice() 메서드 - DB 컬럼 없음) +- **제외 필드**: brand_name, image_url (최소 스냅샷 원칙) +- 스냅샷이므로 product 삭제/수정 후에도 주문 이력 조회 가능 --- ## 제약 조건 요약 ### UNIQUE 제약 -| 테이블 | 컬럼 | 목적 | -|--------|------|------| -| member | member_id | 중복 로그인 ID 방지 | -| member | email | 중복 이메일 방지 | -| like | (user_id, product_id) | 중복 좋아요 방지 | + +| 테이블 | 제약명 | 컬럼 | 목적 | +|--------|--------|------|------| +| members | - | member_id | 중복 로그인 ID 방지 | +| members | - | email | 중복 이메일 방지 | +| brands | - | brand_id | 중복 브랜드 ID 방지 | +| products | - | product_id | 중복 상품 ID 방지 | +| likes | uk_likes_member_product | (ref_member_id, ref_product_id) | 중복 좋아요 방지 | +| orders | - | order_id | 중복 주문 ID 방지 | +| order_items | - | order_item_id | 중복 주문항목 ID 방지 | ### FK 제약 -| 자식 테이블 | 부모 테이블 | 삭제 정책 | -|-----------|-----------|----------| -| product | brand | RESTRICT (애플리케이션 레벨 soft delete) | -| like | member | CASCADE 또는 RESTRICT | -| like | product | CASCADE 또는 RESTRICT | -| orders | member | RESTRICT | -| order_item | orders | CASCADE | -| order_item | product | RESTRICT 또는 SET NULL | + +| 자식 테이블 | 컬럼 | 부모 테이블 | 삭제 정책 | +|-----------|------|-----------|----------| +| products | ref_brand_id | brands(id) | RESTRICT (애플리케이션 레벨 soft delete) | +| likes | ref_member_id | members(id) | RESTRICT | +| likes | ref_product_id | products(id) | RESTRICT | +| orders | ref_member_id | members(id) | RESTRICT | +| order_items | order_id | orders(id) | CASCADE | ### CHECK 제약 + | 테이블 | 제약 | 목적 | |--------|------|------| -| product | price >= 0 | 음수 가격 방지 | -| product | stock_qty >= 0 | 음수 재고 방지 | -| orders | total_amount >= 0 | 음수 금액 방지 | -| order_item | quantity >= 1 | 0 이하 수량 방지 | +| products | price >= 0 | 음수 가격 방지 | +| products | stock_quantity >= 0 | 음수 재고 방지 | +| order_items | quantity >= 1 | 0 이하 수량 방지 | --- @@ -299,136 +300,204 @@ erDiagram ### 인덱스 후보 우선순위 #### 1. 필수 인덱스 (P0) -- **product**: `(deleted_at, updated_at)` - latest 정렬 + soft delete 필터 (가장 빈번한 조회) -- **product**: `(brand_id, deleted_at)` - 브랜드별 상품 조회 -- **like**: `(user_id, product_id)` - UNIQUE 제약 + 좋아요 추가/취소 -- **orders**: `(user_id, ordered_at)` - 사용자별 주문 목록 + 기간 필터 -- **order_item**: `order_id` - 주문별 항목 조회 +- **products**: `(deleted_at, updated_at)` — latest 정렬 + soft delete 필터 +- **products**: `(ref_brand_id, deleted_at)` — 브랜드별 상품 조회 +- **likes**: `(ref_member_id, ref_product_id)` — UNIQUE 제약 (자동 인덱스) +- **likes**: `ref_product_id` — 상품별 좋아요 수 집계 +- **order_items**: `order_id` — 주문별 항목 조회 #### 2. 성능 개선 인덱스 (P1) -- **product**: `(deleted_at, price)` - price_asc 정렬 (사용 빈도 중간) -- **like**: `product_id` - 상품별 좋아요 수 집계 (likes_desc 정렬 시) -- **brand**: `deleted_at` - soft delete 필터링 +- **products**: `(deleted_at, price)` — price_asc 정렬 +- **brands**: `deleted_at` — soft delete 필터링 +- **orders**: `ref_member_id` — 사용자별 주문 목록 #### 3. 확장 인덱스 (P2, 병목 시 고려) -- **product**: `like_count` - likes_desc 정렬 성능 개선 (컬럼 추가 필요) -- **order_item**: `product_id` - 상품별 주문 이력 조회 (어드민 분석용) +- **products**: `like_count` — likes_desc 정렬 성능 개선 (컬럼 추가 필요) ### 복합 인덱스 설계 근거 -- **(deleted_at, updated_at)**: WHERE deleted_at IS NULL AND ORDER BY updated_at DESC -- **(brand_id, deleted_at)**: WHERE brand_id = ? AND deleted_at IS NULL -- **(user_id, ordered_at)**: WHERE user_id = ? AND ordered_at BETWEEN ? AND ? ORDER BY ordered_at DESC +- `(deleted_at, updated_at)`: `WHERE deleted_at IS NULL ORDER BY updated_at DESC` +- `(ref_brand_id, deleted_at)`: `WHERE ref_brand_id = ? AND deleted_at IS NULL` +- `(ref_member_id, ref_product_id)`: UNIQUE 제약이므로 자동 생성 --- ## 상태 및 삭제 정책 ### Soft Delete 정책 -- **대상 테이블**: brand, product -- **구현**: `deleted_at DATETIME NULL` -- **삭제 동작**: `UPDATE {table} SET deleted_at = NOW() WHERE id = ?` +- **대상 테이블**: brands, products +- **구현**: `deleted_at DATETIME NULL` (BaseEntity 상속) +- **삭제 동작**: `markAsDeleted()` → `delete()` → `deletedAt = LocalDateTime.now()` - **조회 필터**: 모든 SELECT 쿼리에 `deleted_at IS NULL` 조건 필수 -- **연쇄 삭제**: brand 삭제 시 해당 brand_id의 모든 product도 soft delete (애플리케이션 레벨 트랜잭션) +- **연쇄 삭제**: brand 삭제 시 해당 ref_brand_id의 모든 products도 soft delete (애플리케이션 트랜잭션) ### Hard Delete 대상 -- **like**: 삭제 시 물리 삭제 (이력 불필요) -- **order/order_item**: 삭제하지 않음 (영구 보존) +- **likes**: 물리 삭제 (이력 불필요, `likeRepository.delete(like)`) + +### 삭제 없음 (영구 보존) +- **orders, order_items**: 주문 이력은 삭제하지 않음 ### 상태 전이 -- **product.status**: ACTIVE ↔ INACTIVE, ACTIVE → OUT_OF_STOCK -- **orders.status**: PENDING → CANCELED (이번 범위, 추후 확장 가능) +- **orders.status**: `PENDING → CANCELED` (취소) --- ## 데이터 정합성 규칙 -### 재고 일관성 -- **강한 일관성**: 조건부 원자 UPDATE로 재고 음수 방지 -- **동시성 제어**: WHERE stock_qty >= :quantity -- **데드락 방지**: productId 오름차순 정렬로 락 순서 고정 +### 재고 일관성 (비관적 락) -### 주문 스냅샷 -- **불변성**: order_item은 생성 후 수정 불가 (INSERT만) -- **독립성**: product/brand 삭제 후에도 order_item 조회 가능 +``` +전략: SELECT ... FOR UPDATE + 애플리케이션 검증 + UPDATE + +트랜잭션 내 처리 순서: +1. 상품 목록을 productId 오름차순 정렬 (데드락 방지) +2. 각 상품에 대해: + a. SELECT * FROM products WHERE id = :productId FOR UPDATE; (비관적 락) + b. 재고 검증: stockQuantity >= requestedQuantity + c. 재고 부족 → 예외 발생 → 트랜잭션 전체 롤백 (모든 락 해제) + d. UPDATE products SET stock_quantity = stock_quantity - :qty WHERE id = :id; +3. OrderModel + OrderItemModel 저장 +4. 트랜잭션 커밋 (모든 비관적 락 해제) + +장점: 명시적 직렬화, 명확한 재고 검증 로직 +단점: 락 보유 시간 증가, 높은 경합 시 처리량 감소 +``` -### 좋아요 중복 방지 -- **DB 레벨**: UNIQUE (user_id, product_id) -- **애플리케이션 레벨**: DuplicateKeyException catch → 멱등 성공 +### 좋아요 중복 방지 (DB 제약) + +``` +전략: UNIQUE 제약 + 예외 catch (락 없음) + +처리 순서: +1. findByRefMemberIdAndRefProductId 선조회 +2. 없으면 INSERT 시도 +3. DataIntegrityViolationException 발생 시 재조회하여 멱등 성공 +4. 있으면 기존 반환 + +장점: 락 없음, 높은 처리량 +단점: 동시 요청 시 예외 발생 후 재조회 오버헤드 (극히 드문 케이스) +``` + +### 주문 스냅샷 불변성 +- order_items는 생성 후 수정 불가 (INSERT only) +- product/brand 삭제 후에도 주문 이력 조회 가능 --- ## 성능 최적화 고려사항 ### 1. 좋아요 수 집계 (likes_desc 정렬) -- **Phase 1**: COUNT 서브쿼리 (정확성 우선) - ```sql - SELECT p.*, (SELECT COUNT(*) FROM like WHERE product_id = p.id) AS like_count - FROM product p - WHERE deleted_at IS NULL - ORDER BY like_count DESC; - ``` -- **Phase 2**: like_count 컬럼 도입 (성능 우선) - - DDL: `ALTER TABLE product ADD COLUMN like_count INT NOT NULL DEFAULT 0;` - - 집계: INSERT/DELETE like 시 +1/-1 (약한 일관성 허용) - - 인덱스: `(deleted_at, like_count)` + +**Phase 1 (현재 구현)**: LEFT JOIN + COUNT +```sql +SELECT p.* +FROM products p +LEFT JOIN likes l ON p.id = l.ref_product_id +WHERE p.deleted_at IS NULL +[AND p.ref_brand_id = :refBrandId] +GROUP BY p.id +ORDER BY COUNT(l.id) DESC, p.updated_at DESC; +``` + +**Phase 2 (병목 시)**: like_count 컬럼 도입 +```sql +ALTER TABLE products ADD COLUMN like_count INT NOT NULL DEFAULT 0; +-- like INSERT/DELETE 시 +1/-1 (약한 일관성 허용) +-- 인덱스: (deleted_at, like_count) +``` ### 2. Soft Delete 필터 성능 -- **문제**: deleted_at IS NULL 조건이 모든 쿼리에 필요 -- **완화**: deleted_at에 인덱스 생성, 또는 복합 인덱스 활용 -- **JPA 전역 필터**: `@Where(clause = "deleted_at IS NULL")` 또는 QueryDSL BooleanExpression +- `deleted_at IS NULL` 조건이 모든 쿼리에 필요 +- 복합 인덱스 `(deleted_at, 정렬컬럼)` 활용 -### 3. 주문 목록 조회 성능 -- **인덱스**: (user_id, ordered_at)로 기간 필터 + 정렬 최적화 -- **페이징**: LIMIT/OFFSET 또는 Cursor 기반 페이징 +### 3. Facade Enrichment N+1 주의 +- 현재: 상품 목록에서 각 상품별 Brand 조회 + 좋아요 수 조회 (N+1 위험) +- 개선 방향: 단일 쿼리로 JOIN하여 통합 조회 --- ## DDL 예시 -### product 테이블 +### brands 테이블 + +```sql +CREATE TABLE brands ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + brand_id VARCHAR(10) NOT NULL, + brand_name VARCHAR(50) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at DATETIME, + UNIQUE KEY uk_brand_id (brand_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +``` + +### products 테이블 ```sql -CREATE TABLE product ( +CREATE TABLE products ( id BIGINT AUTO_INCREMENT PRIMARY KEY, - brand_id BIGINT NOT NULL, - product_name VARCHAR(200) NOT NULL, - price DECIMAL(15,2) NOT NULL CHECK (price >= 0), - stock_qty INT NOT NULL CHECK (stock_qty >= 0), - description TEXT, - image_url VARCHAR(500), - status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + product_id VARCHAR(20) NOT NULL, + ref_brand_id BIGINT NOT NULL, + product_name VARCHAR(100) NOT NULL, + price DECIMAL(10,2) NOT NULL CHECK (price >= 0), + stock_quantity INT NOT NULL CHECK (stock_quantity >= 0), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - deleted_at DATETIME + deleted_at DATETIME, + UNIQUE KEY uk_product_id (product_id), + KEY idx_ref_brand_deleted (ref_brand_id, deleted_at), + KEY idx_deleted_updated (deleted_at, updated_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ``` -### like 테이블 +### likes 테이블 ```sql -CREATE TABLE `like` ( +CREATE TABLE likes ( id BIGINT AUTO_INCREMENT PRIMARY KEY, - user_id BIGINT NOT NULL, - product_id BIGINT NOT NULL, + ref_member_id BIGINT NOT NULL, + ref_product_id BIGINT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - UNIQUE KEY uk_user_product (user_id, product_id) + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at DATETIME, + UNIQUE KEY uk_likes_member_product (ref_member_id, ref_product_id), + KEY idx_ref_product_id (ref_product_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ``` -### order_item 테이블 +### orders 테이블 ```sql -CREATE TABLE order_item ( +CREATE TABLE orders ( id BIGINT AUTO_INCREMENT PRIMARY KEY, + order_id VARCHAR(36) NOT NULL, + ref_member_id BIGINT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at DATETIME, + UNIQUE KEY uk_order_id (order_id), + KEY idx_ref_member_id (ref_member_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +``` + +### order_items 테이블 + +```sql +CREATE TABLE order_items ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + order_item_id VARCHAR(36) NOT NULL, order_id BIGINT NOT NULL, - product_id BIGINT NOT NULL, - product_name VARCHAR(200) NOT NULL, - brand_id BIGINT, - brand_name VARCHAR(100) NOT NULL, - unit_price DECIMAL(15,2) NOT NULL, + product_id VARCHAR(20) NOT NULL, + product_name VARCHAR(100) NOT NULL, + price DECIMAL(10,2) NOT NULL, quantity INT NOT NULL CHECK (quantity >= 1), - line_amount DECIMAL(15,2) NOT NULL, - image_url VARCHAR(500) + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at DATETIME, + UNIQUE KEY uk_order_item_id (order_item_id), + KEY idx_order_id (order_id), + CONSTRAINT fk_order_items_order FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ``` @@ -437,16 +506,16 @@ CREATE TABLE order_item ( ## 확장 고려사항 ### 1. 좋아요 수 캐싱 (Phase 2) -- **컬럼 추가**: `ALTER TABLE product ADD COLUMN like_count INT NOT NULL DEFAULT 0;` -- **동기화**: like INSERT/DELETE 시 product.like_count +1/-1 +- **컬럼 추가**: `ALTER TABLE products ADD COLUMN like_count INT NOT NULL DEFAULT 0;` +- **동기화**: like INSERT/DELETE 시 products.like_count +1/-1 - **정합성**: 약한 일관성 허용 (Eventual Consistency) ### 2. 주문 상태 확장 - **현재**: PENDING, CANCELED - **확장**: PAID, SHIPPED, COMPLETED, REFUNDED -- **전이 규칙**: 상태 다이어그램 정의 필요 +- **전이 규칙**: OrderStatus.validateTransition에서 상태 다이어그램 정의 -### 3. 상품 이력 (History Table) -- **목적**: 가격 변경 이력 추적 -- **테이블**: product_history (product_id, price, changed_at) -- **트리거**: product UPDATE 시 이력 INSERT \ No newline at end of file +### 3. 재고 락 방식 전환 가능성 +- **현재**: 비관적 락 (`SELECT ... FOR UPDATE`) +- **고트래픽 시 대안**: 낙관적 락 (`@Version`) 또는 Redis 분산 락 +- **전환 시점**: 재고 차감 병목이 확인된 후 결정 From c2d38d8e6ee14f94f388bd52310ce65ce0bac891 Mon Sep 17 00:00:00 2001 From: YoHanKi Date: Sat, 21 Feb 2026 00:09:58 +0900 Subject: [PATCH 28/50] =?UTF-8?q?feat(like):=20API=20=EA=B2=BD=EB=A1=9C?= =?UTF-8?q?=EB=A5=BC=20/products/{productId}/likes=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interfaces/api/like/LikeV1Controller.java | 16 +++++--- .../interfaces/api/like/LikeV1Dto.java | 10 +---- .../api/like/LikeV1ControllerE2ETest.java | 40 +++++++++---------- 3 files changed, 33 insertions(+), 33 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java index bb5970897..6419f0bbb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java @@ -10,7 +10,7 @@ import static com.loopers.interfaces.api.like.LikeV1Dto.*; @RestController -@RequestMapping("/api/v1/likes") +@RequestMapping("/api/v1/products/{productId}/likes") @RequiredArgsConstructor public class LikeV1Controller { @@ -18,15 +18,21 @@ public class LikeV1Controller { @PostMapping @ResponseStatus(HttpStatus.CREATED) - public ApiResponse addLike(@Valid @RequestBody AddLikeRequest request) { - var info = likeFacade.addLike(request.memberId(), request.productId()); + public ApiResponse addLike( + @PathVariable String productId, + @Valid @RequestBody AddLikeRequest request + ) { + var info = likeFacade.addLike(request.memberId(), productId); return ApiResponse.success(LikeResponse.from(info)); } @DeleteMapping @ResponseStatus(HttpStatus.NO_CONTENT) - public ApiResponse removeLike(@Valid @RequestBody RemoveLikeRequest request) { - likeFacade.removeLike(request.memberId(), request.productId()); + public ApiResponse removeLike( + @PathVariable String productId, + @Valid @RequestBody RemoveLikeRequest request + ) { + likeFacade.removeLike(request.memberId(), productId); return ApiResponse.success(null); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java index 12a66c6f2..5c5493ed5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java @@ -8,18 +8,12 @@ public class LikeV1Dto { public record AddLikeRequest( @NotNull(message = "회원 ID는 필수입니다.") - Long memberId, - - @NotBlank(message = "상품 ID는 필수입니다.") - String productId + Long memberId ) {} public record RemoveLikeRequest( @NotNull(message = "회원 ID는 필수입니다.") - Long memberId, - - @NotBlank(message = "상품 ID는 필수입니다.") - String productId + Long memberId ) {} public record LikeResponse( diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ControllerE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ControllerE2ETest.java index fe106d60f..54337f26d 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ControllerE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ControllerE2ETest.java @@ -40,8 +40,8 @@ class LikeV1ControllerE2ETest { @Autowired private DatabaseCleanUp databaseCleanUp; - private String baseUrl() { - return "http://localhost:" + port + "/api/v1/likes"; + private String baseUrl(String productId) { + return "http://localhost:" + port + "/api/v1/products/" + productId + "/likes"; } @AfterEach @@ -49,7 +49,7 @@ void tearDown() { databaseCleanUp.truncateAllTables(); } - @DisplayName("POST /api/v1/likes") + @DisplayName("POST /api/v1/products/{productId}/likes") @Nested class AddLike { @@ -58,13 +58,13 @@ class AddLike { void addLike_success_returns201() { // given brandService.createBrand("nike", "Nike"); - var product = productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 10); + productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 10); - var request = new AddLikeRequest(1L, "prod1"); + var request = new AddLikeRequest(1L); // when var response = restTemplate.postForEntity( - baseUrl(), + baseUrl("prod1"), request, ApiResponse.class ); @@ -82,11 +82,11 @@ void addLike_duplicate_returns201() { brandService.createBrand("nike", "Nike"); productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 10); - var request = new AddLikeRequest(1L, "prod1"); + var request = new AddLikeRequest(1L); // when - var firstResponse = restTemplate.postForEntity(baseUrl(), request, ApiResponse.class); - var secondResponse = restTemplate.postForEntity(baseUrl(), request, ApiResponse.class); + var firstResponse = restTemplate.postForEntity(baseUrl("prod1"), request, ApiResponse.class); + var secondResponse = restTemplate.postForEntity(baseUrl("prod1"), request, ApiResponse.class); // then assertThat(firstResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); @@ -97,11 +97,11 @@ void addLike_duplicate_returns201() { @DisplayName("존재하지 않는 상품에 좋아요 추가 시 404 Not Found 반환") void addLike_productNotFound_returns404() { // given - var request = new AddLikeRequest(1L, "invalid"); + var request = new AddLikeRequest(1L); // when var response = restTemplate.postForEntity( - baseUrl(), + baseUrl("invalid"), request, ApiResponse.class ); @@ -111,7 +111,7 @@ void addLike_productNotFound_returns404() { } } - @DisplayName("DELETE /api/v1/likes") + @DisplayName("DELETE /api/v1/products/{productId}/likes") @Nested class RemoveLike { @@ -122,14 +122,14 @@ void removeLike_success_returns204() { brandService.createBrand("nike", "Nike"); productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 10); - var addRequest = new AddLikeRequest(1L, "prod1"); - restTemplate.postForEntity(baseUrl(), addRequest, ApiResponse.class); + var addRequest = new AddLikeRequest(1L); + restTemplate.postForEntity(baseUrl("prod1"), addRequest, ApiResponse.class); - var removeRequest = new RemoveLikeRequest(1L, "prod1"); + var removeRequest = new RemoveLikeRequest(1L); // when var response = restTemplate.exchange( - baseUrl(), + baseUrl("prod1"), HttpMethod.DELETE, new HttpEntity<>(removeRequest), ApiResponse.class @@ -146,11 +146,11 @@ void removeLike_notExists_returns204() { brandService.createBrand("nike", "Nike"); productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 10); - var removeRequest = new RemoveLikeRequest(1L, "prod1"); + var removeRequest = new RemoveLikeRequest(1L); // when var response = restTemplate.exchange( - baseUrl(), + baseUrl("prod1"), HttpMethod.DELETE, new HttpEntity<>(removeRequest), ApiResponse.class @@ -164,11 +164,11 @@ void removeLike_notExists_returns204() { @DisplayName("존재하지 않는 상품에 좋아요 취소 시 404 Not Found 반환") void removeLike_productNotFound_returns404() { // given - var removeRequest = new RemoveLikeRequest(1L, "invalid"); + var removeRequest = new RemoveLikeRequest(1L); // when var response = restTemplate.exchange( - baseUrl(), + baseUrl("invalid"), HttpMethod.DELETE, new HttpEntity<>(removeRequest), ApiResponse.class From 138314dc748709d7a4eceffd2579060b315faaac Mon Sep 17 00:00:00 2001 From: YoHanKi Date: Sat, 21 Feb 2026 00:10:20 +0900 Subject: [PATCH 29/50] =?UTF-8?q?feat(brand):=20=EB=B8=8C=EB=9E=9C?= =?UTF-8?q?=EB=93=9C=20=EB=8B=A8=EA=B1=B4=20=EC=A1=B0=ED=9A=8C=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/application/brand/BrandFacade.java | 6 ++++++ .../main/java/com/loopers/domain/brand/BrandService.java | 6 ++++++ .../com/loopers/interfaces/api/brand/BrandV1ApiSpec.java | 8 ++++++++ .../loopers/interfaces/api/brand/BrandV1Controller.java | 7 +++++++ 4 files changed, 27 insertions(+) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java index b203b8ee6..023766a4a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -17,6 +17,12 @@ public BrandInfo createBrand(String brandId, String brandName) { return BrandInfo.from(brand); } + @Transactional(readOnly = true) + public BrandInfo getBrand(String brandId) { + BrandModel brand = brandService.getBrand(brandId); + return BrandInfo.from(brand); + } + @Transactional public void deleteBrand(String brandId) { brandService.deleteBrand(brandId); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java index afb2ec54d..73466e0ef 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -23,6 +23,12 @@ public BrandModel createBrand(String brandId, String brandName) { return brandRepository.save(brand); } + @Transactional(readOnly = true) + public BrandModel getBrand(String brandId) { + return brandRepository.findByBrandId(new BrandId(brandId)) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 브랜드가 존재하지 않습니다.")); + } + @Transactional public void deleteBrand(String brandId) { BrandModel brand = brandRepository.findByBrandId(new BrandId(brandId)) diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1ApiSpec.java index 05cf37a4d..d7a394b37 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1ApiSpec.java @@ -12,6 +12,14 @@ @Tag(name = "브랜드 관리 API", description = "브랜드 관련 API") public interface BrandV1ApiSpec { + @Operation( + summary = "브랜드 단건 조회", + description = "brandId로 브랜드 정보를 조회합니다." + ) + ApiResponse getBrand( + @Parameter(description = "브랜드 ID") @PathVariable String brandId + ); + @Operation( summary = "브랜드 생성", description = "새로운 브랜드를 생성합니다." diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java index fc87c170b..4f4c57ca3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java @@ -14,6 +14,13 @@ public class BrandV1Controller implements BrandV1ApiSpec { private final BrandFacade brandFacade; + @Override + @GetMapping("/{brandId}") + public ApiResponse getBrand(@PathVariable String brandId) { + BrandInfo info = brandFacade.getBrand(brandId); + return ApiResponse.success(BrandV1Dto.BrandResponse.fromInfo(info)); + } + @PostMapping @Override public ApiResponse createBrand( From 5628a43a6d1f0267abaea66c4bd60348e89599ee Mon Sep 17 00:00:00 2001 From: YoHanKi Date: Sat, 21 Feb 2026 00:10:46 +0900 Subject: [PATCH 30/50] =?UTF-8?q?feat(product):=20=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EB=8B=A8=EA=B1=B4=20=EC=A1=B0=ED=9A=8C=20=EB=B0=8F=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/product/ProductFacade.java | 12 ++++++++++ .../loopers/domain/product/ProductModel.java | 6 +++++ .../domain/product/ProductService.java | 15 +++++++++++++ .../api/product/ProductV1ApiSpec.java | 22 +++++++++++++++++++ .../api/product/ProductV1Controller.java | 22 +++++++++++++++++++ .../interfaces/api/product/ProductV1Dto.java | 13 +++++++++++ 6 files changed, 90 insertions(+) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index 86d078084..2b4a3b2e2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -30,6 +30,18 @@ public ProductInfo createProduct(String productId, String brandId, String produc return enrichProductInfo(product); } + @Transactional(readOnly = true) + public ProductInfo getProduct(String productId) { + ProductModel product = productService.getProduct(productId); + return enrichProductInfo(product); + } + + @Transactional + public ProductInfo updateProduct(String productId, String productName, java.math.BigDecimal price, int stockQuantity) { + ProductModel product = productService.updateProduct(productId, productName, price, stockQuantity); + return enrichProductInfo(product); + } + @Transactional public void deleteProduct(String productId) { productService.deleteProduct(productId); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java index 93cfe2608..1b69d5243 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java @@ -68,6 +68,12 @@ public void increaseStock(int quantity) { this.stockQuantity = new StockQuantity(this.stockQuantity.value() + quantity); } + public void update(String productName, BigDecimal price, int stockQuantity) { + this.productName = new ProductName(productName); + this.price = new Price(price); + this.stockQuantity = new StockQuantity(stockQuantity); + } + public void markAsDeleted() { delete(); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java index 0ae09f386..4e0db37cb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -39,6 +39,21 @@ public ProductModel createProduct(String productId, String brandId, String produ return productRepository.save(product); } + @Transactional(readOnly = true) + public ProductModel getProduct(String productId) { + return productRepository.findByProductId(new ProductId(productId)) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 상품이 존재하지 않습니다.")); + } + + @Transactional + public ProductModel updateProduct(String productId, String productName, BigDecimal price, int stockQuantity) { + ProductModel product = productRepository.findByProductId(new ProductId(productId)) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 상품이 존재하지 않습니다.")); + + product.update(productName, price, stockQuantity); + return productRepository.save(product); + } + @Transactional public void deleteProduct(String productId) { ProductModel product = productRepository.findByProductId(new ProductId(productId)) diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java index daa9fea44..2f83b4d6a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java @@ -15,6 +15,28 @@ @Tag(name = "Product API", description = "상품 관리 API") public interface ProductV1ApiSpec { + @Operation(summary = "상품 단건 조회", description = "productId로 상품 상세 정보를 조회합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "상품을 찾을 수 없음") + }) + ResponseEntity> getProduct( + @Parameter(description = "상품 ID", example = "prod1") + @PathVariable String productId + ); + + @Operation(summary = "상품 수정", description = "상품 정보(상품명, 가격, 재고)를 수정합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "수정 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "상품을 찾을 수 없음") + }) + ResponseEntity> updateProduct( + @Parameter(description = "상품 ID", example = "prod1") + @PathVariable String productId, + @RequestBody ProductV1Dto.UpdateProductRequest request + ); + @Operation(summary = "상품 생성", description = "새로운 상품을 생성합니다.") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "상품 생성 성공"), diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java index 986b0e7c4..b7652124c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -19,6 +19,28 @@ public class ProductV1Controller implements ProductV1ApiSpec { private final ProductFacade productFacade; + @GetMapping("/{productId}") + @Override + public ResponseEntity> getProduct(@PathVariable String productId) { + ProductInfo info = productFacade.getProduct(productId); + return ResponseEntity.ok(ApiResponse.success(ProductV1Dto.ProductResponse.from(info))); + } + + @PutMapping("/{productId}") + @Override + public ResponseEntity> updateProduct( + @PathVariable String productId, + @Valid @RequestBody ProductV1Dto.UpdateProductRequest request + ) { + ProductInfo info = productFacade.updateProduct( + productId, + request.productName(), + request.price(), + request.stockQuantity() + ); + return ResponseEntity.ok(ApiResponse.success(ProductV1Dto.ProductResponse.from(info))); + } + @PostMapping @Override public ResponseEntity> createProduct( diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java index 1a30df591..edf3042be 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -54,6 +54,19 @@ public static ProductResponse from(ProductInfo info) { } } + public record UpdateProductRequest( + @NotBlank(message = "상품명은 필수입니다") + @Size(min = 1, max = 100, message = "상품명은 1~100자여야 합니다") + String productName, + + @NotNull(message = "가격은 필수입니다") + @DecimalMin(value = "0.0", inclusive = true, message = "가격은 0 이상이어야 합니다") + BigDecimal price, + + @Min(value = 0, message = "재고 수량은 0 이상이어야 합니다") + int stockQuantity + ) {} + public record ProductListResponse( java.util.List products, int currentPage, From e99274b653b9c3b76cf8059bc193f03c475f4111 Mon Sep 17 00:00:00 2001 From: YoHanKi Date: Sat, 21 Feb 2026 00:11:19 +0900 Subject: [PATCH 31/50] =?UTF-8?q?feat(order):=20=EC=A3=BC=EB=AC=B8=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1/=EC=B7=A8=EC=86=8C=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=B0=8F=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/order/OrderFacade.java | 32 +++++++ .../loopers/application/order/OrderInfo.java | 29 +++++++ .../application/order/OrderItemCommand.java | 10 +++ .../application/order/OrderItemInfo.java | 27 ++++++ .../domain/order/OrderItemRequest.java | 15 ++++ .../com/loopers/domain/order/OrderReader.java | 18 ---- .../loopers/domain/order/OrderService.java | 50 ++++++----- .../interfaces/api/order/OrderV1ApiSpec.java | 36 ++++++++ .../api/order/OrderV1Controller.java | 45 ++++++++++ .../interfaces/api/order/OrderV1Dto.java | 87 +++++++++++++++++++ .../com/loopers/support/error/ErrorType.java | 1 + .../OrderServiceCreateIntegrationTest.java | 2 +- .../domain/order/OrderServiceTest.java | 2 +- 13 files changed, 314 insertions(+), 40 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemCommand.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRequest.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderReader.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java new file mode 100644 index 000000000..846da6f7a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -0,0 +1,32 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.OrderItemRequest; +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.order.OrderService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class OrderFacade { + + private final OrderService orderService; + + @Transactional + public OrderInfo createOrder(Long memberId, List items) { + List orderItems = items.stream() + .map(OrderItemCommand::toOrderItemRequest) + .toList(); + OrderModel order = orderService.createOrder(memberId, orderItems); + return OrderInfo.from(order); + } + + @Transactional + public OrderInfo cancelOrder(Long memberId, String orderId) { + OrderModel order = orderService.cancelOrder(memberId, orderId); + return OrderInfo.from(order); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java new file mode 100644 index 000000000..20da15b3e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java @@ -0,0 +1,29 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderModel; + +import java.math.BigDecimal; +import java.util.List; + +public record OrderInfo( + Long id, + String orderId, + Long refMemberId, + String status, + BigDecimal totalAmount, + List items +) { + public static OrderInfo from(OrderModel order) { + return new OrderInfo( + order.getId(), + order.getOrderId().value(), + order.getRefMemberId().value(), + order.getStatus().name(), + order.getTotalAmount(), + order.getOrderItems().stream() + .map(OrderItemInfo::from) + .toList() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemCommand.java new file mode 100644 index 000000000..bb38e0937 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemCommand.java @@ -0,0 +1,10 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.OrderItemRequest; + +public record OrderItemCommand(String productId, int quantity) { + + public OrderItemRequest toOrderItemRequest() { + return new OrderItemRequest(productId, quantity); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java new file mode 100644 index 000000000..3d0d3f4bd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java @@ -0,0 +1,27 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.OrderItemModel; + +import java.math.BigDecimal; + +public record OrderItemInfo( + Long id, + String orderItemId, + String productId, + String productName, + BigDecimal price, + int quantity, + BigDecimal totalPrice + ) { + public static OrderItemInfo from(OrderItemModel item) { + return new OrderItemInfo( + item.getId(), + item.getOrderItemId().value(), + item.getProductId(), + item.getProductName(), + item.getPrice(), + item.getQuantity(), + item.getTotalPrice() + ); + } + } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRequest.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRequest.java new file mode 100644 index 000000000..ec1b7fa08 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRequest.java @@ -0,0 +1,15 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +public record OrderItemRequest(String productId, int quantity) { + public OrderItemRequest { + if (productId == null || productId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID는 필수입니다."); + } + if (quantity <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "수량은 1개 이상이어야 합니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderReader.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderReader.java deleted file mode 100644 index e532f58bf..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderReader.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.loopers.domain.order; - -import com.loopers.domain.order.vo.OrderId; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -public class OrderReader { - private final OrderRepository orderRepository; - - public OrderModel getOrThrow(String orderId) { - return orderRepository.findByOrderId(new OrderId(orderId)) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 주문이 존재하지 않습니다.")); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java index f6b5b3710..d78f86c11 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -1,5 +1,6 @@ package com.loopers.domain.order; +import com.loopers.domain.order.vo.OrderId; import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductRepository; import com.loopers.domain.product.vo.ProductId; @@ -9,8 +10,10 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.*; -import java.util.stream.Collectors; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; @Service @RequiredArgsConstructor @@ -27,26 +30,23 @@ public OrderModel createOrder(Long memberId, List itemRequests // 2. 상품 ID 정렬 (데드락 방지) List sortedProductIds = aggregatedItems.keySet().stream() .sorted() - .collect(Collectors.toList()); + .toList(); // 3. 상품 조회 및 재고 차감 List orderItems = new ArrayList<>(); for (String productIdValue : sortedProductIds) { int quantity = aggregatedItems.get(productIdValue); - // 상품 조회 ProductModel product = productRepository.findByProductId(new ProductId(productIdValue)) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 상품이 존재하지 않습니다: " + productIdValue)); - // 재고 차감 (동시성 제어) boolean decreased = productRepository.decreaseStockIfAvailable(product.getId(), quantity); if (!decreased) { throw new CoreException(ErrorType.CONFLICT, "재고가 부족합니다. 상품 ID: " + productIdValue); } - // OrderItemModel 생성 (스냅샷 패턴) OrderItemModel orderItem = OrderItemModel.create( product.getProductId().value(), product.getProductName().value(), @@ -61,6 +61,30 @@ public OrderModel createOrder(Long memberId, List itemRequests return orderRepository.save(order); } + @Transactional + public OrderModel cancelOrder(Long memberId, String orderId) { + OrderModel order = orderRepository.findByOrderId(new OrderId(orderId)) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 주문이 존재하지 않습니다.")); + + if (!order.isOwner(memberId)) { + throw new CoreException(ErrorType.FORBIDDEN, "본인의 주문만 취소할 수 있습니다."); + } + + OrderStatus previousStatus = order.getStatus(); + order.cancel(); + + if (previousStatus == OrderStatus.PENDING) { + for (OrderItemModel item : order.getOrderItems()) { + productRepository.findByProductId(new ProductId(item.getProductId())) + .ifPresent(product -> + productRepository.increaseStock(product.getId(), item.getQuantity()) + ); + } + } + + return orderRepository.save(order); + } + private Map aggregateQuantities(List itemRequests) { Map aggregated = new HashMap<>(); for (OrderItemRequest request : itemRequests) { @@ -68,18 +92,4 @@ private Map aggregateQuantities(List itemRequ } return aggregated; } - - /** - * 주문 상품 요청 DTO - */ - public record OrderItemRequest(String productId, int quantity) { - public OrderItemRequest { - if (productId == null || productId.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID는 필수입니다."); - } - if (quantity <= 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "수량은 1개 이상이어야 합니다."); - } - } - } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java new file mode 100644 index 000000000..1bcb112d0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java @@ -0,0 +1,36 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "주문 API", description = "주문 생성 및 취소 API") +public interface OrderV1ApiSpec { + + @Operation(summary = "주문 생성", description = "여러 상품을 포함한 주문을 생성합니다. 재고 차감과 스냅샷 저장이 단일 트랜잭션으로 처리됩니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "주문 생성 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청 (빈 주문, 수량 < 1)"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "상품을 찾을 수 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "재고 부족") + }) + ResponseEntity> createOrder( + @RequestBody OrderV1Dto.CreateOrderRequest request + ); + + @Operation(summary = "주문 취소", description = "PENDING 상태의 주문을 취소합니다. 이미 취소된 주문은 멱등 성공(200)으로 처리됩니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "주문 취소 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "본인의 주문이 아님"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "주문을 찾을 수 없음") + }) + ResponseEntity> cancelOrder( + @Parameter(description = "주문 ID (UUID)") @PathVariable String orderId, + @RequestBody OrderV1Dto.CancelOrderRequest request + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java new file mode 100644 index 000000000..02d532b90 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -0,0 +1,45 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderInfo; +import com.loopers.application.order.OrderItemCommand; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1/orders") +@RequiredArgsConstructor +public class OrderV1Controller implements OrderV1ApiSpec { + + private final OrderFacade orderFacade; + + @PostMapping + @Override + public ResponseEntity> createOrder( + @Valid @RequestBody OrderV1Dto.CreateOrderRequest request + ) { + List items = request.items().stream() + .map(OrderV1Dto.OrderItemRequest::toCommand) + .toList(); + + OrderInfo info = orderFacade.createOrder(request.memberId(), items); + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.success(OrderV1Dto.OrderResponse.from(info))); + } + + @PatchMapping("/{orderId}/cancel") + @Override + public ResponseEntity> cancelOrder( + @PathVariable String orderId, + @Valid @RequestBody OrderV1Dto.CancelOrderRequest request + ) { + OrderInfo info = orderFacade.cancelOrder(request.memberId(), orderId); + return ResponseEntity.ok(ApiResponse.success(OrderV1Dto.OrderResponse.from(info))); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java new file mode 100644 index 000000000..45df49ba5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java @@ -0,0 +1,87 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderInfo; +import com.loopers.application.order.OrderItemCommand; +import com.loopers.application.order.OrderItemInfo; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; + +import java.math.BigDecimal; +import java.util.List; + +public class OrderV1Dto { + + public record CreateOrderRequest( + @NotNull(message = "회원 ID는 필수입니다.") + Long memberId, + + @NotNull(message = "주문 항목은 필수입니다.") + @NotEmpty(message = "주문 항목은 1개 이상이어야 합니다.") + @Valid + List items + ) {} + + public record OrderItemRequest( + @NotBlank(message = "상품 ID는 필수입니다.") + String productId, + + @Min(value = 1, message = "수량은 1개 이상이어야 합니다.") + int quantity + ) { + public OrderItemCommand toCommand() { + return new OrderItemCommand(productId, quantity); + } + } + + public record CancelOrderRequest( + @NotNull(message = "회원 ID는 필수입니다.") + Long memberId + ) {} + + public record OrderResponse( + Long id, + String orderId, + Long refMemberId, + String status, + BigDecimal totalAmount, + List items + ) { + public static OrderResponse from(OrderInfo info) { + return new OrderResponse( + info.id(), + info.orderId(), + info.refMemberId(), + info.status(), + info.totalAmount(), + info.items().stream() + .map(OrderItemResponse::from) + .toList() + ); + } + } + + public record OrderItemResponse( + Long id, + String orderItemId, + String productId, + String productName, + BigDecimal price, + int quantity, + BigDecimal totalPrice + ) { + public static OrderItemResponse from(OrderItemInfo item) { + return new OrderItemResponse( + item.id(), + item.orderItemId(), + item.productId(), + item.productName(), + item.price(), + item.quantity(), + item.totalPrice() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java index 8d493491a..b770c36e1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java @@ -12,6 +12,7 @@ public enum ErrorType { BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "잘못된 요청입니다."), UNAUTHORIZED(HttpStatus.UNAUTHORIZED, HttpStatus.UNAUTHORIZED.getReasonPhrase(), "인증에 실패했습니다."), NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 요청입니다."), + FORBIDDEN(HttpStatus.FORBIDDEN, HttpStatus.FORBIDDEN.getReasonPhrase(), "접근 권한이 없습니다."), CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다."); private final HttpStatus status; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceCreateIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceCreateIntegrationTest.java index cf1d9f190..5a0f7f314 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceCreateIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceCreateIntegrationTest.java @@ -17,7 +17,7 @@ import java.math.BigDecimal; import java.util.List; -import static com.loopers.domain.order.OrderService.OrderItemRequest; +import com.loopers.domain.order.OrderItemRequest; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java index bbab240ce..702f21583 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java @@ -17,7 +17,7 @@ import java.util.List; import java.util.Optional; -import static com.loopers.domain.order.OrderService.OrderItemRequest; +import com.loopers.domain.order.OrderItemRequest; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; From 8952cc01779fa56a84d07bd4e0174907af658ad4 Mon Sep 17 00:00:00 2001 From: YoHanKi Date: Sat, 21 Feb 2026 00:11:28 +0900 Subject: [PATCH 32/50] =?UTF-8?q?docs:=20=EC=9A=94=EA=B5=AC=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EB=AA=85=EC=84=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/design/01-requirements.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/docs/design/01-requirements.md b/docs/design/01-requirements.md index 5fad6a742..7eb0aa5a6 100644 --- a/docs/design/01-requirements.md +++ b/docs/design/01-requirements.md @@ -339,20 +339,19 @@ ### UC-A09: 상품 수정 (PUT /api-admin/v1/products/{productId}) +> **미구현**: 현재 ProductModel은 productName, price, stockQuantity만 수정 가능. brandId 변경은 불가. + #### Main Flow -1. **요청**: 어드민이 상품 수정 (수정 가능: `productName`, `price`, `stockQty`, `description`, `imageUrl`, `status`) +1. **요청**: 어드민이 상품 수정 (수정 가능: `productName`, `price`, `stockQuantity`) 2. **인증**: `X-Loopers-Ldap=loopers.admin` 검증 3. **상품 조회**: - `productId`로 조회 - 존재하지 않으면 → 404 Not Found 4. **입력 검증**: - - `price >= 0`, `stockQty >= 0` + - `price >= 0`, `stockQuantity >= 0` - **brandId 변경 시도 확인**: 요청에 brandId가 포함되어 있으면 → 400 Bad Request -5. **상품 수정**: - - UPDATE `product` SET ... `updated_at = NOW()` - - `stockQty`는 절대값 SET 방식 (운영 목적) +5. **상품 수정**: Dirty Checking으로 UPDATE 6. **응답**: 200 OK - - 수정된 상품 정보 #### Exception Flow - **E1**: 인증 실패 → 403 Forbidden From 8b80f7d95f3187f7f0400a3e256599d9a3a31c5e Mon Sep 17 00:00:00 2001 From: YoHanKi Date: Sat, 21 Feb 2026 01:15:24 +0900 Subject: [PATCH 33/50] =?UTF-8?q?feat(brand):=20=EB=B8=8C=EB=9E=9C?= =?UTF-8?q?=EB=93=9C=20=EC=82=AD=EC=A0=9C=20=EC=8B=9C=20=EC=83=81=ED=92=88?= =?UTF-8?q?=20=EC=97=B0=EC=87=84=20soft=20delete=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/domain/brand/BrandService.java | 16 ++-- .../domain/product/ProductRepository.java | 11 +++ .../product/ProductRepositoryImpl.java | 13 ++++ .../brand/BrandServiceIntegrationTest.java | 74 +++++++++++++++---- .../domain/brand/BrandServiceTest.java | 7 ++ 5 files changed, 100 insertions(+), 21 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java index 73466e0ef..f35971bb1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -1,17 +1,22 @@ package com.loopers.domain.brand; import com.loopers.domain.brand.vo.BrandId; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + @Service @RequiredArgsConstructor public class BrandService { private final BrandRepository brandRepository; + private final ProductRepository productRepository; @Transactional public BrandModel createBrand(String brandId, String brandName) { @@ -34,12 +39,13 @@ public void deleteBrand(String brandId) { BrandModel brand = brandRepository.findByBrandId(new BrandId(brandId)) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 브랜드가 존재하지 않습니다.")); - // TODO: Product 도메인 구현 후 상품 참조 체크 로직 추가 - // if (productRepository.existsByRefBrandId(brand.getId())) { - // throw new CoreException(ErrorType.CONFLICT, "해당 브랜드를 참조하는 상품이 존재하여 삭제할 수 없습니다."); - // } - brand.markAsDeleted(); brandRepository.save(brand); + + List products = productRepository.findByRefBrandId(brand.getId()); + for (ProductModel product : products) { + product.markAsDeleted(); + productRepository.save(product); + } } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java index 4448fc3bc..d070baa2b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -4,6 +4,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import java.util.List; import java.util.Optional; public interface ProductRepository { @@ -30,4 +31,14 @@ public interface ProductRepository { * 상품의 좋아요 수를 조회합니다. */ long countLikes(Long productId); + + /** + * 브랜드 DB PK로 삭제되지 않은 상품 목록을 조회합니다. + */ + List findByRefBrandId(Long brandId); + + /** + * DB PK로 상품을 조회합니다. + */ + Optional findById(Long id); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java index 8e8a57dfa..5c8cc1322 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -163,4 +163,17 @@ public long countLikes(Long productId) { return ((Number) query.getSingleResult()).longValue(); } + + @Override + public List findByRefBrandId(Long brandId) { + String sql = "SELECT * FROM products WHERE ref_brand_id = :brandId AND deleted_at IS NULL"; + return entityManager.createNativeQuery(sql, ProductModel.class) + .setParameter("brandId", brandId) + .getResultList(); + } + + @Override + public Optional findById(Long id) { + return productJpaRepository.findById(id); + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java index 9ec836548..5de291403 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java @@ -1,7 +1,10 @@ package com.loopers.domain.brand; import com.loopers.domain.brand.vo.BrandId; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import com.loopers.utils.DatabaseCleanUp; @@ -15,6 +18,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Primary; +import java.math.BigDecimal; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -31,9 +35,15 @@ class BrandServiceIntegrationTest { @Autowired private BrandService brandService; + @Autowired + private ProductService productService; + @Autowired private BrandJpaRepository brandJpaRepository; + @Autowired + private ProductJpaRepository productJpaRepository; + @Autowired private BrandRepository spyBrandRepository; @@ -125,22 +135,54 @@ void deleteBrand_notFound_throwsException() { .isEqualTo(ErrorType.NOT_FOUND); } - // TODO: Product 도메인 구현 후 추가할 테스트 - // @Test - // @DisplayName("상품이 참조하고 있는 브랜드 삭제 시 예외 발생") - // void deleteBrand_hasProducts_throwsException() { - // // given - // String brandId = "samsung"; - // brandService.createBrand(brandId, "Samsung"); - // // productService.createProduct(..., brandId, ...); // Product 생성 - // - // // when & then - // assertThatThrownBy(() -> brandService.deleteBrand(brandId)) - // .isInstanceOf(CoreException.class) - // .hasMessageContaining("해당 브랜드를 참조하는 상품이 존재하여 삭제할 수 없습니다.") - // .extracting("errorType") - // .isEqualTo(ErrorType.CONFLICT); - // } + @Test + @DisplayName("브랜드 삭제 시 해당 브랜드의 상품도 연쇄 soft delete") + void deleteBrand_cascadeDeletesProducts() { + // given + String brandId = "samsung"; + BrandModel brand = brandService.createBrand(brandId, "Samsung"); + + ProductModel product1 = productService.createProduct("prod1", brandId, "Product 1", new BigDecimal("10000"), 10); + ProductModel product2 = productService.createProduct("prod2", brandId, "Product 2", new BigDecimal("20000"), 20); + + assertThat(product1.isDeleted()).isFalse(); + assertThat(product2.isDeleted()).isFalse(); + + // when + brandService.deleteBrand(brandId); + + // then - 브랜드 삭제됨 + BrandModel deletedBrand = brandJpaRepository.findByBrandId(new BrandId(brandId)).orElseThrow(); + assertThat(deletedBrand.isDeleted()).isTrue(); + + // then - 상품도 연쇄 삭제됨 + ProductModel deletedProduct1 = productJpaRepository.findById(product1.getId()).orElseThrow(); + ProductModel deletedProduct2 = productJpaRepository.findById(product2.getId()).orElseThrow(); + assertThat(deletedProduct1.isDeleted()).isTrue(); + assertThat(deletedProduct2.isDeleted()).isTrue(); + } + + @Test + @DisplayName("이미 삭제된 상품은 브랜드 삭제 시 영향받지 않음") + void deleteBrand_alreadyDeletedProduct_notAffected() { + // given + String brandId = "lg"; + BrandModel brand = brandService.createBrand(brandId, "LG"); + + ProductModel product = productService.createProduct("prodlg", brandId, "LG Product", new BigDecimal("50000"), 5); + productService.deleteProduct("prodlg"); // 미리 삭제 + + // when + brandService.deleteBrand(brandId); + + // then - 브랜드는 삭제됨 + BrandModel deletedBrand = brandJpaRepository.findByBrandId(new BrandId(brandId)).orElseThrow(); + assertThat(deletedBrand.isDeleted()).isTrue(); + + // then - 상품 삭제 상태는 그대로 + ProductModel deletedProduct = productJpaRepository.findById(product.getId()).orElseThrow(); + assertThat(deletedProduct.isDeleted()).isTrue(); + } @TestConfiguration static class SpyConfig { diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java index c0ded67eb..4a62dc11d 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java @@ -2,6 +2,7 @@ import com.loopers.domain.brand.vo.BrandId; import com.loopers.domain.brand.vo.BrandName; +import com.loopers.domain.product.ProductRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import org.junit.jupiter.api.DisplayName; @@ -11,11 +12,13 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import java.util.List; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) @@ -25,6 +28,9 @@ class BrandServiceTest { @Mock private BrandRepository brandRepository; + @Mock + private ProductRepository productRepository; + @InjectMocks private BrandService brandService; @@ -78,6 +84,7 @@ void deleteBrand_success() { when(brandRepository.findByBrandId(any(BrandId.class))).thenReturn(Optional.of(mockBrand)); when(brandRepository.save(any(BrandModel.class))).thenReturn(mockBrand); + when(productRepository.findByRefBrandId(anyLong())).thenReturn(List.of()); // when brandService.deleteBrand(brandId); From 870bda784f6bda5c43103b936d0f8bcbc6c96688 Mon Sep 17 00:00:00 2001 From: YoHanKi Date: Sat, 21 Feb 2026 01:16:29 +0900 Subject: [PATCH 34/50] =?UTF-8?q?feat(order):=20=EC=A3=BC=EB=AC=B8=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/order/OrderFacade.java | 14 ++ .../loopers/domain/order/OrderService.java | 19 ++ .../interfaces/api/order/OrderV1ApiSpec.java | 27 +++ .../api/order/OrderV1Controller.java | 32 +++ .../OrderServiceQueryIntegrationTest.java | 149 +++++++++++++ .../domain/order/OrderServiceTest.java | 71 +++++- .../api/order/OrderV1ControllerE2ETest.java | 208 ++++++++++++++++++ 7 files changed, 519 insertions(+), 1 deletion(-) create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceQueryIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderV1ControllerE2ETest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java index 846da6f7a..0839e4ac2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -4,9 +4,12 @@ import com.loopers.domain.order.OrderModel; import com.loopers.domain.order.OrderService; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; import java.util.List; @Component @@ -29,4 +32,15 @@ public OrderInfo cancelOrder(Long memberId, String orderId) { OrderModel order = orderService.cancelOrder(memberId, orderId); return OrderInfo.from(order); } + + @Transactional(readOnly = true) + public OrderInfo getMyOrder(Long memberId, String orderId) { + return OrderInfo.from(orderService.getMyOrder(memberId, orderId)); + } + + @Transactional(readOnly = true) + public Page getMyOrders(Long memberId, LocalDateTime startDateTime, LocalDateTime endDateTime, Pageable pageable) { + return orderService.getMyOrders(memberId, startDateTime, endDateTime, pageable) + .map(OrderInfo::from); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java index d78f86c11..0ed1b7d30 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -1,5 +1,6 @@ package com.loopers.domain.order; +import com.loopers.domain.like.vo.RefMemberId; import com.loopers.domain.order.vo.OrderId; import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductRepository; @@ -7,9 +8,12 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -85,6 +89,21 @@ public OrderModel cancelOrder(Long memberId, String orderId) { return orderRepository.save(order); } + @Transactional(readOnly = true) + public Page getMyOrders(Long memberId, LocalDateTime startDateTime, LocalDateTime endDateTime, Pageable pageable) { + return orderRepository.findByRefMemberId(new RefMemberId(memberId), startDateTime, endDateTime, pageable); + } + + @Transactional(readOnly = true) + public OrderModel getMyOrder(Long memberId, String orderId) { + OrderModel order = orderRepository.findByOrderId(new OrderId(orderId)) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.")); + if (!order.isOwner(memberId)) { + throw new CoreException(ErrorType.FORBIDDEN, "본인의 주문만 조회할 수 있습니다."); + } + return order; + } + private Map aggregateQuantities(List itemRequests) { Map aggregated = new HashMap<>(); for (OrderItemRequest request : itemRequests) { diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java index 1bcb112d0..1bf940853 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java @@ -5,13 +5,40 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.data.domain.Pageable; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +import java.time.LocalDate; @Tag(name = "주문 API", description = "주문 생성 및 취소 API") public interface OrderV1ApiSpec { + @Operation(summary = "내 주문 목록 조회", description = "회원의 주문 목록을 기간 필터와 페이징으로 조회합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "주문 목록 조회 성공") + }) + ResponseEntity> getOrders( + @Parameter(description = "회원 DB PK") @RequestParam Long memberId, + @Parameter(description = "조회 시작일 (yyyy-MM-dd)") @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @Parameter(description = "조회 종료일 (yyyy-MM-dd)") @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, + Pageable pageable + ); + + @Operation(summary = "주문 상세 조회", description = "주문 ID로 특정 주문을 조회합니다. 본인의 주문만 조회할 수 있습니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "주문 조회 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "본인의 주문이 아님"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "주문을 찾을 수 없음") + }) + ResponseEntity> getOrder( + @Parameter(description = "주문 ID (UUID)") @PathVariable String orderId, + @Parameter(description = "회원 DB PK") @RequestParam Long memberId + ); + @Operation(summary = "주문 생성", description = "여러 상품을 포함한 주문을 생성합니다. 재고 차감과 스냅샷 저장이 단일 트랜잭션으로 처리됩니다.") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "주문 생성 성공"), diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java index 02d532b90..dc3d36414 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -6,10 +6,15 @@ import com.loopers.interfaces.api.ApiResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.time.LocalDate; + import java.util.List; @RestController @@ -33,6 +38,33 @@ public ResponseEntity> createOrder( .body(ApiResponse.success(OrderV1Dto.OrderResponse.from(info))); } + @GetMapping + @Override + public ResponseEntity> getOrders( + @RequestParam Long memberId, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, + Pageable pageable + ) { + Page orders = orderFacade.getMyOrders( + memberId, + startDate != null ? startDate.atStartOfDay() : null, + endDate != null ? endDate.plusDays(1).atStartOfDay() : null, + pageable + ); + return ResponseEntity.ok(ApiResponse.success(OrderV1Dto.OrderListResponse.from(orders))); + } + + @GetMapping("/{orderId}") + @Override + public ResponseEntity> getOrder( + @PathVariable String orderId, + @RequestParam Long memberId + ) { + OrderInfo info = orderFacade.getMyOrder(memberId, orderId); + return ResponseEntity.ok(ApiResponse.success(OrderV1Dto.OrderResponse.from(info))); + } + @PatchMapping("/{orderId}/cancel") @Override public ResponseEntity> cancelOrder( diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceQueryIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceQueryIntegrationTest.java new file mode 100644 index 000000000..fece45024 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceQueryIntegrationTest.java @@ -0,0 +1,149 @@ +package com.loopers.domain.order; + +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.order.OrderItemRequest; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +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.data.domain.Page; +import org.springframework.data.domain.PageRequest; + +import java.math.BigDecimal; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +@DisplayName("OrderService 주문 조회 통합 테스트") +class OrderServiceQueryIntegrationTest { + + @Autowired + private OrderService orderService; + + @Autowired + private BrandService brandService; + + @Autowired + private com.loopers.domain.product.ProductRepository productRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @BeforeEach + void setUp() { + brandService.createBrand("nike", "Nike"); + com.loopers.domain.product.ProductModel product = com.loopers.domain.product.ProductModel.create( + "prod1", 1L, "Nike Air", new BigDecimal("100000"), 100); + productRepository.save(product); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("주문 상세 조회 - getMyOrder") + @Nested + class GetMyOrder { + + @Test + @DisplayName("본인 주문 조회 성공") + void getMyOrder_success() { + // given + Long memberId = 1L; + OrderModel order = orderService.createOrder(memberId, List.of(new OrderItemRequest("prod1", 1))); + + // when + OrderModel found = orderService.getMyOrder(memberId, order.getOrderId().value()); + + // then + assertAll( + () -> assertThat(found).isNotNull(), + () -> assertThat(found.getOrderId()).isEqualTo(order.getOrderId()), + () -> assertThat(found.isOwner(memberId)).isTrue() + ); + } + + @Test + @DisplayName("존재하지 않는 주문 조회 시 404 예외") + void getMyOrder_notFound_throwsException() { + // when & then + assertThatThrownBy(() -> orderService.getMyOrder(1L, "00000000-0000-0000-0000-000000000001")) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); + } + + @Test + @DisplayName("타인의 주문 조회 시 403 예외") + void getMyOrder_notOwner_throwsForbidden() { + // given + Long ownerId = 1L; + Long otherMemberId = 2L; + OrderModel order = orderService.createOrder(ownerId, List.of(new OrderItemRequest("prod1", 1))); + + // when & then + assertThatThrownBy(() -> orderService.getMyOrder(otherMemberId, order.getOrderId().value())) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.FORBIDDEN); + } + } + + @DisplayName("주문 목록 조회 - getMyOrders") + @Nested + class GetMyOrders { + + @Test + @DisplayName("회원 주문 목록 페이징 조회") + void getMyOrders_success() { + // given + Long memberId = 1L; + orderService.createOrder(memberId, List.of(new OrderItemRequest("prod1", 1))); + orderService.createOrder(memberId, List.of(new OrderItemRequest("prod1", 1))); + + // when + Page orders = orderService.getMyOrders(memberId, null, null, PageRequest.of(0, 10)); + + // then + assertAll( + () -> assertThat(orders.getTotalElements()).isEqualTo(2), + () -> assertThat(orders.getContent()).hasSize(2) + ); + } + + @Test + @DisplayName("다른 회원의 주문은 조회되지 않음") + void getMyOrders_onlyReturnsOwnOrders() { + // given + Long memberId1 = 1L; + Long memberId2 = 2L; + orderService.createOrder(memberId1, List.of(new OrderItemRequest("prod1", 1))); + orderService.createOrder(memberId2, List.of(new OrderItemRequest("prod1", 1))); + + // when + Page orders = orderService.getMyOrders(memberId1, null, null, PageRequest.of(0, 10)); + + // then + assertThat(orders.getTotalElements()).isEqualTo(1); + } + + @Test + @DisplayName("주문이 없는 회원은 빈 목록 반환") + void getMyOrders_noOrders_returnsEmpty() { + // when + Page orders = orderService.getMyOrders(99L, null, null, PageRequest.of(0, 10)); + + // then + assertThat(orders.getContent()).isEmpty(); + assertThat(orders.getTotalElements()).isEqualTo(0); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java index 702f21583..489530815 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java @@ -1,19 +1,24 @@ package com.loopers.domain.order; +import com.loopers.domain.like.vo.RefMemberId; +import com.loopers.domain.order.vo.OrderId; import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductRepository; import com.loopers.domain.product.vo.ProductId; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; import java.math.BigDecimal; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -144,6 +149,70 @@ void orderItemRequest_invalidQuantity_throwsException() { .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); } + @Test + @DisplayName("getMyOrder: 본인 주문 조회 성공") + void getMyOrder_success() { + // given + Long memberId = 1L; + String orderId = "550e8400-e29b-41d4-a716-446655440000"; + OrderModel order = mock(OrderModel.class); + when(order.isOwner(memberId)).thenReturn(true); + when(orderRepository.findByOrderId(new OrderId(orderId))).thenReturn(Optional.of(order)); + + // when + OrderModel result = orderService.getMyOrder(memberId, orderId); + + // then + assertThat(result).isEqualTo(order); + } + + @Test + @DisplayName("getMyOrder: 존재하지 않는 주문 → 404") + void getMyOrder_notFound_throwsException() { + // given + String validButNonExistentUuid = "00000000-0000-0000-0000-000000000001"; + when(orderRepository.findByOrderId(any(OrderId.class))).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> orderService.getMyOrder(1L, validButNonExistentUuid)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); + } + + @Test + @DisplayName("getMyOrder: 타인의 주문 조회 시 → 403") + void getMyOrder_notOwner_throwsForbidden() { + // given + Long memberId = 1L; + String orderId = "550e8400-e29b-41d4-a716-446655440000"; + OrderModel order = mock(OrderModel.class); + when(order.isOwner(memberId)).thenReturn(false); + when(orderRepository.findByOrderId(new OrderId(orderId))).thenReturn(Optional.of(order)); + + // when & then + assertThatThrownBy(() -> orderService.getMyOrder(memberId, orderId)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.FORBIDDEN); + } + + @Test + @DisplayName("getMyOrders: 회원의 주문 목록 조회 성공") + void getMyOrders_success() { + // given + Long memberId = 1L; + OrderModel order = mock(OrderModel.class); + Page page = new PageImpl<>(List.of(order)); + when(orderRepository.findByRefMemberId( + any(RefMemberId.class), any(), any(), any() + )).thenReturn(page); + + // when + Page result = orderService.getMyOrders(memberId, null, null, PageRequest.of(0, 10)); + + // then + assertThat(result.getContent()).hasSize(1); + } + private ProductModel mockProduct(String productId, String productName, BigDecimal price, Long id) { ProductModel product = mock(ProductModel.class); when(product.getId()).thenReturn(id); diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderV1ControllerE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderV1ControllerE2ETest.java new file mode 100644 index 000000000..1635aa48b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderV1ControllerE2ETest.java @@ -0,0 +1,208 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.order.OrderService; +import com.loopers.domain.order.OrderItemRequest; +import com.loopers.domain.product.ProductRepository; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +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.core.ParameterizedTypeReference; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.math.BigDecimal; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DisplayName("OrderV1Controller E2E 테스트") +class OrderV1ControllerE2ETest { + + private static final String ORDERS_URL = "/api/v1/orders"; + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private BrandService brandService; + + @Autowired + private OrderService orderService; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @BeforeEach + void setUp() { + brandService.createBrand("nike", "Nike"); + com.loopers.domain.product.ProductModel product = com.loopers.domain.product.ProductModel.create( + "prod1", 1L, "Nike Air", new BigDecimal("100000"), 100); + productRepository.save(product); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("GET /api/v1/orders/{orderId} - 주문 상세 조회") + @Nested + class GetOrder { + + @Test + @DisplayName("본인 주문 조회 성공 - 200 OK") + void getOrder_success_returns200() { + // given + Long memberId = 1L; + OrderModel order = orderService.createOrder(memberId, List.of(new OrderItemRequest("prod1", 2))); + + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + + // when + ResponseEntity> response = restTemplate.exchange( + ORDERS_URL + "/" + order.getOrderId().value() + "?memberId=" + memberId, + HttpMethod.GET, + null, + responseType + ); + + // then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data()).isNotNull(), + () -> assertThat(response.getBody().data().orderId()).isEqualTo(order.getOrderId().value()), + () -> assertThat(response.getBody().data().refMemberId()).isEqualTo(memberId), + () -> assertThat(response.getBody().data().items()).hasSize(1) + ); + } + + @Test + @DisplayName("존재하지 않는 주문 조회 - 404 Not Found") + void getOrder_notFound_returns404() { + // when + ResponseEntity response = restTemplate.exchange( + ORDERS_URL + "/00000000-0000-0000-0000-000000000001?memberId=1", + HttpMethod.GET, + null, + ApiResponse.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + @DisplayName("타인의 주문 조회 - 403 Forbidden") + void getOrder_notOwner_returns403() { + // given + Long ownerId = 1L; + Long otherMemberId = 2L; + OrderModel order = orderService.createOrder(ownerId, List.of(new OrderItemRequest("prod1", 1))); + + // when + ResponseEntity response = restTemplate.exchange( + ORDERS_URL + "/" + order.getOrderId().value() + "?memberId=" + otherMemberId, + HttpMethod.GET, + null, + ApiResponse.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + } + + @DisplayName("GET /api/v1/orders - 주문 목록 조회") + @Nested + class GetOrders { + + @Test + @DisplayName("주문 목록 페이징 조회 성공 - 200 OK") + void getOrders_success_returns200() { + // given + Long memberId = 1L; + orderService.createOrder(memberId, List.of(new OrderItemRequest("prod1", 1))); + orderService.createOrder(memberId, List.of(new OrderItemRequest("prod1", 1))); + + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + + // when + ResponseEntity> response = restTemplate.exchange( + ORDERS_URL + "?memberId=" + memberId + "&page=0&size=10", + HttpMethod.GET, + null, + responseType + ); + + // then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(2), + () -> assertThat(response.getBody().data().content()).hasSize(2) + ); + } + + @Test + @DisplayName("주문이 없는 회원은 빈 목록 반환 - 200 OK") + void getOrders_noOrders_returnsEmpty() { + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + + ResponseEntity> response = restTemplate.exchange( + ORDERS_URL + "?memberId=99", + HttpMethod.GET, + null, + responseType + ); + + // then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().content()).isEmpty(), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(0) + ); + } + + @Test + @DisplayName("다른 회원의 주문은 포함되지 않음") + void getOrders_onlyReturnsOwnOrders() { + // given + Long memberId1 = 1L; + Long memberId2 = 2L; + orderService.createOrder(memberId1, List.of(new OrderItemRequest("prod1", 1))); + orderService.createOrder(memberId2, List.of(new OrderItemRequest("prod1", 1))); + + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + + // when + ResponseEntity> response = restTemplate.exchange( + ORDERS_URL + "?memberId=" + memberId1, + HttpMethod.GET, + null, + responseType + ); + + // then + assertThat(response.getBody().data().totalElements()).isEqualTo(1); + } + } +} From ffb25b3693ee54dc07f21a7d01403cf04745762d Mon Sep 17 00:00:00 2001 From: YoHanKi Date: Sat, 21 Feb 2026 01:17:00 +0900 Subject: [PATCH 35/50] =?UTF-8?q?feat(order):=20=EB=82=B4=20=EC=A3=BC?= =?UTF-8?q?=EB=AC=B8=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/domain/order/OrderRepository.java | 5 ++ .../order/OrderRepositoryImpl.java | 64 +++++++++++++++++++ .../interfaces/api/order/OrderV1Dto.java | 19 ++++++ 3 files changed, 88 insertions(+) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java index e35433c37..4b56a7cab 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -1,10 +1,15 @@ package com.loopers.domain.order; +import com.loopers.domain.like.vo.RefMemberId; import com.loopers.domain.order.vo.OrderId; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import java.time.LocalDateTime; import java.util.Optional; public interface OrderRepository { OrderModel save(OrderModel order); Optional findByOrderId(OrderId orderId); + Page findByRefMemberId(RefMemberId refMemberId, LocalDateTime startDateTime, LocalDateTime endDateTime, Pageable pageable); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java index 1206a96df..c831e58ef 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -1,17 +1,25 @@ package com.loopers.infrastructure.order; +import com.loopers.domain.like.vo.RefMemberId; import com.loopers.domain.order.OrderModel; import com.loopers.domain.order.OrderRepository; import com.loopers.domain.order.vo.OrderId; +import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; +import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; @RequiredArgsConstructor @Component public class OrderRepositoryImpl implements OrderRepository { private final OrderJpaRepository orderJpaRepository; + private final EntityManager entityManager; @Override public OrderModel save(OrderModel order) { @@ -22,4 +30,60 @@ public OrderModel save(OrderModel order) { public Optional findByOrderId(OrderId orderId) { return orderJpaRepository.findByOrderId(orderId); } + + @Override + public Page findByRefMemberId(RefMemberId refMemberId, LocalDateTime startDateTime, LocalDateTime endDateTime, Pageable pageable) { + StringBuilder sql = new StringBuilder( + "SELECT * FROM orders WHERE ref_member_id = :memberId"); + + if (startDateTime != null) { + sql.append(" AND created_at >= :startDateTime"); + } + if (endDateTime != null) { + sql.append(" AND created_at <= :endDateTime"); + } + sql.append(" ORDER BY created_at DESC"); + + var query = entityManager.createNativeQuery(sql.toString(), OrderModel.class) + .setParameter("memberId", refMemberId.value()); + + if (startDateTime != null) { + query.setParameter("startDateTime", startDateTime); + } + if (endDateTime != null) { + query.setParameter("endDateTime", endDateTime); + } + + query.setFirstResult((int) pageable.getOffset()); + query.setMaxResults(pageable.getPageSize()); + + List orders = query.getResultList(); + long total = countByRefMemberId(refMemberId, startDateTime, endDateTime); + + return new PageImpl<>(orders, pageable, total); + } + + private long countByRefMemberId(RefMemberId refMemberId, LocalDateTime startDateTime, LocalDateTime endDateTime) { + StringBuilder sql = new StringBuilder( + "SELECT COUNT(*) FROM orders WHERE ref_member_id = :memberId"); + + if (startDateTime != null) { + sql.append(" AND created_at >= :startDateTime"); + } + if (endDateTime != null) { + sql.append(" AND created_at <= :endDateTime"); + } + + var query = entityManager.createNativeQuery(sql.toString()) + .setParameter("memberId", refMemberId.value()); + + if (startDateTime != null) { + query.setParameter("startDateTime", startDateTime); + } + if (endDateTime != null) { + query.setParameter("endDateTime", endDateTime); + } + + return ((Number) query.getSingleResult()).longValue(); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java index 45df49ba5..01c7e020d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java @@ -8,6 +8,7 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; +import org.springframework.data.domain.Page; import java.math.BigDecimal; import java.util.List; @@ -84,4 +85,22 @@ public static OrderItemResponse from(OrderItemInfo item) { ); } } + + public record OrderListResponse( + List content, + long totalElements, + int page, + int size + ) { + public static OrderListResponse from(Page page) { + return new OrderListResponse( + page.getContent().stream() + .map(OrderResponse::from) + .toList(), + page.getTotalElements(), + page.getNumber(), + page.getSize() + ); + } + } } From db856872b269f739b72bff4cb9d8a228ebc205f5 Mon Sep 17 00:00:00 2001 From: YoHanKi Date: Sat, 21 Feb 2026 01:18:15 +0900 Subject: [PATCH 36/50] =?UTF-8?q?feat(like):=20=EB=82=B4=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/application/like/LikeFacade.java | 24 ++- .../application/like/LikedProductInfo.java | 12 ++ .../application/product/ProductFacade.java | 6 + .../loopers/domain/like/LikeRepository.java | 3 + .../com/loopers/domain/like/LikeService.java | 7 + .../domain/product/ProductService.java | 6 + .../like/LikeRepositoryImpl.java | 36 ++++ .../interfaces/api/like/LikeV1Dto.java | 22 +++ .../api/like/MyLikeV1Controller.java | 35 ++++ .../like/LikeServiceIntegrationTest.java | 82 +++++++++ .../api/like/MyLikeV1ControllerE2ETest.java | 172 ++++++++++++++++++ 11 files changed, 404 insertions(+), 1 deletion(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/LikedProductInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/MyLikeV1Controller.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/MyLikeV1ControllerE2ETest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java index 633f53d79..f5c9d34ff 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -1,21 +1,43 @@ package com.loopers.application.like; +import com.loopers.application.product.ProductFacade; +import com.loopers.application.product.ProductInfo; +import com.loopers.domain.like.LikeModel; import com.loopers.domain.like.LikeService; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor public class LikeFacade { private final LikeService likeService; + private final ProductFacade productFacade; public LikeInfo addLike(Long memberId, String productId) { - var like = likeService.addLike(memberId, productId); + LikeModel like = likeService.addLike(memberId, productId); return LikeInfo.from(like); } public void removeLike(Long memberId, String productId) { likeService.removeLike(memberId, productId); } + + @Transactional(readOnly = true) + public Page getMyLikedProducts(Long memberId, Pageable pageable) { + return likeService.getMyLikes(memberId, pageable) + .map(like -> { + ProductInfo product = productFacade.getProductByDbId(like.getRefProductId().value()); + return new LikedProductInfo( + product.productId(), + product.productName(), + product.brand().brandName(), + product.price(), + like.getCreatedAt() + ); + }); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikedProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikedProductInfo.java new file mode 100644 index 000000000..bdad547de --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikedProductInfo.java @@ -0,0 +1,12 @@ +package com.loopers.application.like; + +import java.math.BigDecimal; +import java.time.ZonedDateTime; + +public record LikedProductInfo( + String productId, + String productName, + String brandName, + BigDecimal price, + ZonedDateTime likedAt +) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index 2b4a3b2e2..ae8fc1d8c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -53,6 +53,12 @@ public Page getProducts(String brandId, String sortBy, Pageable pag return products.map(this::enrichProductInfo); } + @Transactional(readOnly = true) + public ProductInfo getProductByDbId(Long id) { + ProductModel product = productService.getProductByDbId(id); + return enrichProductInfo(product); + } + /** * ProductModel에 Brand 정보와 좋아요 수를 추가하여 ProductInfo 생성 */ diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java index 07c446157..e3c7a478b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java @@ -2,6 +2,8 @@ import com.loopers.domain.like.vo.RefMemberId; import com.loopers.domain.like.vo.RefProductId; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import java.util.Optional; @@ -9,4 +11,5 @@ public interface LikeRepository { LikeModel save(LikeModel like); Optional findByRefMemberIdAndRefProductId(RefMemberId refMemberId, RefProductId refProductId); void delete(LikeModel like); + Page findByRefMemberId(RefMemberId refMemberId, Pageable pageable); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java index e8756671f..ad7b1888d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java @@ -8,6 +8,8 @@ import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -41,6 +43,11 @@ public LikeModel addLike(Long memberId, String productId) { }); } + @Transactional(readOnly = true) + public Page getMyLikes(Long memberId, Pageable pageable) { + return likeRepository.findByRefMemberId(new RefMemberId(memberId), pageable); + } + @Transactional public void removeLike(Long memberId, String productId) { // 상품 존재 확인 diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java index 4e0db37cb..3c30623c5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -64,6 +64,12 @@ public void deleteProduct(String productId) { productRepository.save(product); } + @Transactional(readOnly = true) + public ProductModel getProductByDbId(Long id) { + return productRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 상품이 존재하지 않습니다.")); + } + @Transactional(readOnly = true) public Page getProducts(String brandId, String sortBy, Pageable pageable) { // brandId가 제공되면 Brand PK로 변환 diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java index 0fd298cb0..f93612b19 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -4,9 +4,14 @@ import com.loopers.domain.like.LikeRepository; import com.loopers.domain.like.vo.RefMemberId; import com.loopers.domain.like.vo.RefProductId; +import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; @Repository @@ -14,6 +19,7 @@ public class LikeRepositoryImpl implements LikeRepository { private final LikeJpaRepository likeJpaRepository; + private final EntityManager entityManager; @Override public LikeModel save(LikeModel like) { @@ -29,4 +35,34 @@ public Optional findByRefMemberIdAndRefProductId(RefMemberId refMembe public void delete(LikeModel like) { likeJpaRepository.delete(like); } + + @Override + public Page findByRefMemberId(RefMemberId refMemberId, Pageable pageable) { + String sql = "SELECT l.* FROM likes l " + + "JOIN products p ON l.ref_product_id = p.id " + + "WHERE l.ref_member_id = :memberId " + + "AND p.deleted_at IS NULL " + + "ORDER BY l.created_at DESC"; + + List likes = entityManager.createNativeQuery(sql, LikeModel.class) + .setParameter("memberId", refMemberId.value()) + .setFirstResult((int) pageable.getOffset()) + .setMaxResults(pageable.getPageSize()) + .getResultList(); + + long total = countByRefMemberId(refMemberId); + + return new PageImpl<>(likes, pageable, total); + } + + private long countByRefMemberId(RefMemberId refMemberId) { + String sql = "SELECT COUNT(*) FROM likes l " + + "JOIN products p ON l.ref_product_id = p.id " + + "WHERE l.ref_member_id = :memberId " + + "AND p.deleted_at IS NULL"; + + return ((Number) entityManager.createNativeQuery(sql) + .setParameter("memberId", refMemberId.value()) + .getSingleResult()).longValue(); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java index 5c5493ed5..ebe5c7310 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java @@ -1,9 +1,13 @@ package com.loopers.interfaces.api.like; import com.loopers.application.like.LikeInfo; +import com.loopers.application.like.LikedProductInfo; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import java.math.BigDecimal; +import java.time.ZonedDateTime; + public class LikeV1Dto { public record AddLikeRequest( @@ -29,4 +33,22 @@ public static LikeResponse from(LikeInfo info) { ); } } + + public record LikedProductResponse( + String productId, + String productName, + String brandName, + BigDecimal price, + ZonedDateTime likedAt + ) { + public static LikedProductResponse from(LikedProductInfo info) { + return new LikedProductResponse( + info.productId(), + info.productName(), + info.brandName(), + info.price(), + info.likedAt() + ); + } + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/MyLikeV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/MyLikeV1Controller.java new file mode 100644 index 000000000..173180bfc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/MyLikeV1Controller.java @@ -0,0 +1,35 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.like.LikeFacade; +import com.loopers.application.like.LikedProductInfo; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1/users/me/likes") +@RequiredArgsConstructor +public class MyLikeV1Controller { + + private final LikeFacade likeFacade; + + @GetMapping + public ResponseEntity>> getMyLikedProducts( + @RequestParam Long memberId, + Pageable pageable + ) { + Page likes = likeFacade.getMyLikedProducts(memberId, pageable); + List response = likes.getContent().stream() + .map(LikeV1Dto.LikedProductResponse::from) + .toList(); + return ResponseEntity.ok(ApiResponse.success(response)); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java index 58b8e3e5b..70812b581 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java @@ -12,11 +12,14 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import java.math.BigDecimal; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; @SpringBootTest @DisplayName("LikeService 통합 테스트") @@ -133,4 +136,83 @@ void removeLike_productNotFound_throwsException() { .hasMessageContaining("해당 ID의 상품이 존재하지 않습니다"); } } + + @DisplayName("내 좋아요 목록을 조회할 때,") + @Nested + class GetMyLikes { + + @Test + @DisplayName("좋아요한 상품 목록 페이징 조회 성공") + void getMyLikes_success() { + // given + BrandModel brand = brandService.createBrand("nike", "Nike"); + ProductModel product1 = productService.createProduct("prod1", "nike", "Nike Air 1", new BigDecimal("100000"), 10); + ProductModel product2 = productService.createProduct("prod2", "nike", "Nike Air 2", new BigDecimal("200000"), 20); + Long memberId = 1L; + + likeService.addLike(memberId, "prod1"); + likeService.addLike(memberId, "prod2"); + + // when + Page likes = likeService.getMyLikes(memberId, PageRequest.of(0, 10)); + + // then + assertAll( + () -> assertThat(likes.getTotalElements()).isEqualTo(2), + () -> assertThat(likes.getContent()).hasSize(2) + ); + } + + @Test + @DisplayName("삭제된 상품은 좋아요 목록에 포함되지 않음") + void getMyLikes_excludesDeletedProducts() { + // given + BrandModel brand = brandService.createBrand("nike", "Nike"); + productService.createProduct("prod1", "nike", "Nike Air 1", new BigDecimal("100000"), 10); + productService.createProduct("prod2", "nike", "Nike Air 2", new BigDecimal("200000"), 20); + Long memberId = 1L; + + likeService.addLike(memberId, "prod1"); + likeService.addLike(memberId, "prod2"); + + // 상품 삭제 + productService.deleteProduct("prod2"); + + // when + Page likes = likeService.getMyLikes(memberId, PageRequest.of(0, 10)); + + // then + assertThat(likes.getTotalElements()).isEqualTo(1); + assertThat(likes.getContent().get(0).getRefProductId().value()) + .isEqualTo(productService.getProduct("prod1").getId()); + } + + @Test + @DisplayName("좋아요가 없으면 빈 목록 반환") + void getMyLikes_noLikes_returnsEmpty() { + // when + Page likes = likeService.getMyLikes(99L, PageRequest.of(0, 10)); + + // then + assertThat(likes.getContent()).isEmpty(); + assertThat(likes.getTotalElements()).isEqualTo(0); + } + + @Test + @DisplayName("다른 회원의 좋아요는 포함되지 않음") + void getMyLikes_onlyReturnsOwnLikes() { + // given + BrandModel brand = brandService.createBrand("nike", "Nike"); + productService.createProduct("prod1", "nike", "Nike Air 1", new BigDecimal("100000"), 10); + + likeService.addLike(1L, "prod1"); + likeService.addLike(2L, "prod1"); + + // when + Page likes = likeService.getMyLikes(1L, PageRequest.of(0, 10)); + + // then + assertThat(likes.getTotalElements()).isEqualTo(1); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/MyLikeV1ControllerE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/MyLikeV1ControllerE2ETest.java new file mode 100644 index 000000000..192819962 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/MyLikeV1ControllerE2ETest.java @@ -0,0 +1,172 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.like.LikeService; +import com.loopers.domain.product.ProductService; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +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.core.ParameterizedTypeReference; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.math.BigDecimal; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DisplayName("MyLikeV1Controller E2E 테스트") +class MyLikeV1ControllerE2ETest { + + private static final String MY_LIKES_URL = "/api/v1/users/me/likes"; + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private BrandService brandService; + + @Autowired + private ProductService productService; + + @Autowired + private LikeService likeService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("GET /api/v1/users/me/likes - 내 좋아요 목록 조회") + @Nested + class GetMyLikes { + + @Test + @DisplayName("좋아요 목록 조회 성공 - 200 OK") + void getMyLikedProducts_success_returns200() { + // given + brandService.createBrand("nike", "Nike"); + productService.createProduct("prod1", "nike", "Nike Air 1", new BigDecimal("100000"), 10); + productService.createProduct("prod2", "nike", "Nike Air 2", new BigDecimal("200000"), 20); + Long memberId = 1L; + + likeService.addLike(memberId, "prod1"); + likeService.addLike(memberId, "prod2"); + + ParameterizedTypeReference>> responseType = + new ParameterizedTypeReference<>() {}; + + // when + ResponseEntity>> response = restTemplate.exchange( + MY_LIKES_URL + "?memberId=" + memberId, + HttpMethod.GET, + null, + responseType + ); + + // then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data()).hasSize(2) + ); + } + + @Test + @DisplayName("삭제된 상품은 좋아요 목록에 포함되지 않음") + void getMyLikedProducts_excludesDeletedProducts() { + // given + brandService.createBrand("nike", "Nike"); + productService.createProduct("prod1", "nike", "Nike Air 1", new BigDecimal("100000"), 10); + productService.createProduct("prod2", "nike", "Nike Air 2", new BigDecimal("200000"), 20); + Long memberId = 1L; + + likeService.addLike(memberId, "prod1"); + likeService.addLike(memberId, "prod2"); + + // 상품 삭제 + productService.deleteProduct("prod2"); + + ParameterizedTypeReference>> responseType = + new ParameterizedTypeReference<>() {}; + + // when + ResponseEntity>> response = restTemplate.exchange( + MY_LIKES_URL + "?memberId=" + memberId, + HttpMethod.GET, + null, + responseType + ); + + // then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data()).hasSize(1), + () -> assertThat(response.getBody().data().get(0).productId()).isEqualTo("prod1") + ); + } + + @Test + @DisplayName("좋아요 목록에 상품명, 브랜드명, 가격 정보 포함") + void getMyLikedProducts_containsProductInfo() { + // given + brandService.createBrand("nike", "Nike"); + productService.createProduct("prod1", "nike", "Nike Air Max", new BigDecimal("150000"), 10); + Long memberId = 1L; + likeService.addLike(memberId, "prod1"); + + ParameterizedTypeReference>> responseType = + new ParameterizedTypeReference<>() {}; + + // when + ResponseEntity>> response = restTemplate.exchange( + MY_LIKES_URL + "?memberId=" + memberId, + HttpMethod.GET, + null, + responseType + ); + + // then + LikeV1Dto.LikedProductResponse item = response.getBody().data().get(0); + assertAll( + () -> assertThat(item.productId()).isEqualTo("prod1"), + () -> assertThat(item.productName()).isEqualTo("Nike Air Max"), + () -> assertThat(item.brandName()).isEqualTo("Nike"), + () -> assertThat(item.price()).isEqualByComparingTo(new BigDecimal("150000")), + () -> assertThat(item.likedAt()).isNotNull() + ); + } + + @Test + @DisplayName("좋아요가 없으면 빈 목록 반환 - 200 OK") + void getMyLikedProducts_noLikes_returnsEmpty() { + // when + ParameterizedTypeReference>> responseType = + new ParameterizedTypeReference<>() {}; + + ResponseEntity>> response = restTemplate.exchange( + MY_LIKES_URL + "?memberId=99", + HttpMethod.GET, + null, + responseType + ); + + // then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data()).isEmpty() + ); + } + } +} From bc9a61ff184847b7d4c995f7775ac6504e9c0dd5 Mon Sep 17 00:00:00 2001 From: YoHanKi Date: Sat, 21 Feb 2026 01:18:40 +0900 Subject: [PATCH 37/50] =?UTF-8?q?feat(product):=20=EC=96=B4=EB=93=9C?= =?UTF-8?q?=EB=AF=BC=20=EC=83=81=ED=92=88=20=EC=88=98=EC=A0=95=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/product/ProductAdminV1ApiSpec.java | 28 +++ .../api/product/ProductAdminV1Controller.java | 50 ++++ .../api/product/ProductAdminV1Dto.java | 27 +++ .../ProductAdminV1ControllerE2ETest.java | 223 ++++++++++++++++++ docs/design/01-requirements.md | 2 - 5 files changed, 328 insertions(+), 2 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminV1ControllerE2ETest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1ApiSpec.java new file mode 100644 index 000000000..07a17b9ed --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1ApiSpec.java @@ -0,0 +1,28 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +@Tag(name = "Product Admin API", description = "어드민 상품 관리 API") +public interface ProductAdminV1ApiSpec { + + @Operation(summary = "상품 수정", description = "상품 정보(상품명, 가격, 재고)를 수정합니다. brandId 변경은 허용되지 않습니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "수정 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청 또는 brandId 변경 시도"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "어드민 인증 실패"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "상품을 찾을 수 없음") + }) + ResponseEntity> updateProduct( + @Parameter(description = "LDAP 어드민 토큰") @RequestHeader("X-Loopers-Ldap") String ldapHeader, + @Parameter(description = "상품 ID") @PathVariable String productId, + @RequestBody ProductAdminV1Dto.UpdateProductAdminRequest request + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java new file mode 100644 index 000000000..19b37e035 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java @@ -0,0 +1,50 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductFacade; +import com.loopers.application.product.ProductInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api-admin/v1/products") +@RequiredArgsConstructor +public class ProductAdminV1Controller implements ProductAdminV1ApiSpec { + + private static final String ADMIN_LDAP_VALUE = "loopers.admin"; + + private final ProductFacade productFacade; + + @PutMapping("/{productId}") + @Override + public ResponseEntity> updateProduct( + @RequestHeader(value = "X-Loopers-Ldap", required = false) String ldapHeader, + @PathVariable String productId, + @Valid @RequestBody ProductAdminV1Dto.UpdateProductAdminRequest request + ) { + if (!ADMIN_LDAP_VALUE.equals(ldapHeader)) { + throw new CoreException(ErrorType.FORBIDDEN, "어드민 권한이 필요합니다."); + } + + if (request.brandId() != null && !request.brandId().isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "brandId는 변경할 수 없습니다."); + } + + ProductInfo info = productFacade.updateProduct( + productId, + request.productName(), + request.price(), + request.stockQuantity() + ); + return ResponseEntity.ok(ApiResponse.success(ProductV1Dto.ProductResponse.from(info))); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java new file mode 100644 index 000000000..159bbc8b7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java @@ -0,0 +1,27 @@ +package com.loopers.interfaces.api.product; + +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.math.BigDecimal; + +public class ProductAdminV1Dto { + + public record UpdateProductAdminRequest( + @NotBlank(message = "상품명은 필수입니다") + @Size(min = 1, max = 100, message = "상품명은 1~100자여야 합니다") + String productName, + + @NotNull(message = "가격은 필수입니다") + @DecimalMin(value = "0.0", inclusive = true, message = "가격은 0 이상이어야 합니다") + BigDecimal price, + + @Min(value = 0, message = "재고 수량은 0 이상이어야 합니다") + int stockQuantity, + + String brandId + ) {} +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminV1ControllerE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminV1ControllerE2ETest.java new file mode 100644 index 000000000..217ca5cce --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductAdminV1ControllerE2ETest.java @@ -0,0 +1,223 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.ProductService; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +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.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DisplayName("ProductAdminV1Controller E2E 테스트") +class ProductAdminV1ControllerE2ETest { + + private static final String ADMIN_PRODUCTS_URL = "/api-admin/v1/products"; + private static final String ADMIN_LDAP_HEADER = "X-Loopers-Ldap"; + private static final String ADMIN_LDAP_VALUE = "loopers.admin"; + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private BrandService brandService; + + @Autowired + private ProductService productService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @BeforeEach + void setUp() { + brandService.createBrand("nike", "Nike"); + productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 50); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set(ADMIN_LDAP_HEADER, ADMIN_LDAP_VALUE); + return headers; + } + + @DisplayName("PUT /api-admin/v1/products/{productId} - 상품 수정") + @Nested + class UpdateProduct { + + @Test + @DisplayName("상품 수정 성공 - 200 OK") + void updateProduct_success_returns200() { + // given + ProductAdminV1Dto.UpdateProductAdminRequest request = + new ProductAdminV1Dto.UpdateProductAdminRequest( + "Nike Air Max Updated", + new BigDecimal("120000"), + 30, + null + ); + + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + + // when + ResponseEntity> response = restTemplate.exchange( + ADMIN_PRODUCTS_URL + "/prod1", + HttpMethod.PUT, + new HttpEntity<>(request, adminHeaders()), + responseType + ); + + // then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().productName()).isEqualTo("Nike Air Max Updated"), + () -> assertThat(response.getBody().data().price()).isEqualByComparingTo(new BigDecimal("120000")), + () -> assertThat(response.getBody().data().stockQuantity()).isEqualTo(30) + ); + } + + @Test + @DisplayName("어드민 인증 헤더 없으면 403 Forbidden") + void updateProduct_noAdminHeader_returns403() { + // given + ProductAdminV1Dto.UpdateProductAdminRequest request = + new ProductAdminV1Dto.UpdateProductAdminRequest( + "Updated Name", + new BigDecimal("100000"), + 50, + null + ); + + // when + ResponseEntity response = restTemplate.exchange( + ADMIN_PRODUCTS_URL + "/prod1", + HttpMethod.PUT, + new HttpEntity<>(request), + ApiResponse.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + @DisplayName("잘못된 어드민 LDAP 값이면 403 Forbidden") + void updateProduct_invalidLdap_returns403() { + // given + ProductAdminV1Dto.UpdateProductAdminRequest request = + new ProductAdminV1Dto.UpdateProductAdminRequest( + "Updated Name", + new BigDecimal("100000"), + 50, + null + ); + + HttpHeaders invalidHeaders = new HttpHeaders(); + invalidHeaders.set(ADMIN_LDAP_HEADER, "invalid.user"); + + // when + ResponseEntity response = restTemplate.exchange( + ADMIN_PRODUCTS_URL + "/prod1", + HttpMethod.PUT, + new HttpEntity<>(request, invalidHeaders), + ApiResponse.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + @DisplayName("brandId 변경 시도 시 400 Bad Request") + void updateProduct_brandIdChangeAttempt_returns400() { + // given + ProductAdminV1Dto.UpdateProductAdminRequest request = + new ProductAdminV1Dto.UpdateProductAdminRequest( + "Updated Name", + new BigDecimal("100000"), + 50, + "adidas" + ); + + // when + ResponseEntity response = restTemplate.exchange( + ADMIN_PRODUCTS_URL + "/prod1", + HttpMethod.PUT, + new HttpEntity<>(request, adminHeaders()), + ApiResponse.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + @DisplayName("존재하지 않는 상품 수정 시 404 Not Found") + void updateProduct_notFound_returns404() { + // given + ProductAdminV1Dto.UpdateProductAdminRequest request = + new ProductAdminV1Dto.UpdateProductAdminRequest( + "Updated Name", + new BigDecimal("100000"), + 50, + null + ); + + // when + ResponseEntity response = restTemplate.exchange( + ADMIN_PRODUCTS_URL + "/nonexistent", + HttpMethod.PUT, + new HttpEntity<>(request, adminHeaders()), + ApiResponse.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + @DisplayName("가격이 음수이면 400 Bad Request") + void updateProduct_negativePrice_returns400() { + // given + ProductAdminV1Dto.UpdateProductAdminRequest request = + new ProductAdminV1Dto.UpdateProductAdminRequest( + "Updated Name", + new BigDecimal("-1000"), + 50, + null + ); + + // when + ResponseEntity response = restTemplate.exchange( + ADMIN_PRODUCTS_URL + "/prod1", + HttpMethod.PUT, + new HttpEntity<>(request, adminHeaders()), + ApiResponse.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } +} diff --git a/docs/design/01-requirements.md b/docs/design/01-requirements.md index 7eb0aa5a6..6b2254727 100644 --- a/docs/design/01-requirements.md +++ b/docs/design/01-requirements.md @@ -339,8 +339,6 @@ ### UC-A09: 상품 수정 (PUT /api-admin/v1/products/{productId}) -> **미구현**: 현재 ProductModel은 productName, price, stockQuantity만 수정 가능. brandId 변경은 불가. - #### Main Flow 1. **요청**: 어드민이 상품 수정 (수정 가능: `productName`, `price`, `stockQuantity`) 2. **인증**: `X-Loopers-Ldap=loopers.admin` 검증 From 5b29fa6a9fc5ec2c2f774db55a859bfb289c523e Mon Sep 17 00:00:00 2001 From: YoHanKi Date: Sun, 22 Feb 2026 16:00:43 +0900 Subject: [PATCH 38/50] =?UTF-8?q?refactor(arch):=20Facade=20=E2=86=92=20Re?= =?UTF-8?q?pository=20=EC=A7=81=EC=A0=91=20=EC=9D=98=EC=A1=B4=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20=EB=B0=8F=20cascade=20delete=EB=A5=BC=20Application?= =?UTF-8?q?=20=EA=B3=84=EC=B8=B5=EC=9C=BC=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/brand/BrandFacade.java | 6 +++- .../loopers/application/like/LikeFacade.java | 26 ++++++++++------- .../application/product/ProductFacade.java | 28 +++++-------------- .../loopers/domain/brand/BrandService.java | 21 ++++++-------- .../domain/product/ProductService.java | 22 +++++++++++++-- 5 files changed, 55 insertions(+), 48 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java index 023766a4a..3a6a9abdb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -2,6 +2,7 @@ import com.loopers.domain.brand.BrandModel; import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.ProductService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -9,7 +10,9 @@ @RequiredArgsConstructor @Component public class BrandFacade { + private final BrandService brandService; + private final ProductService productService; @Transactional public BrandInfo createBrand(String brandId, String brandName) { @@ -25,6 +28,7 @@ public BrandInfo getBrand(String brandId) { @Transactional public void deleteBrand(String brandId) { - brandService.deleteBrand(brandId); + BrandModel brand = brandService.deleteBrand(brandId); + productService.deleteProductsByBrandRefId(brand.getId()); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java index f5c9d34ff..402aec934 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -1,27 +1,32 @@ package com.loopers.application.like; -import com.loopers.application.product.ProductFacade; -import com.loopers.application.product.ProductInfo; +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandService; import com.loopers.domain.like.LikeModel; import com.loopers.domain.like.LikeService; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Service; +import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; -@Service +@Component @RequiredArgsConstructor public class LikeFacade { private final LikeService likeService; - private final ProductFacade productFacade; + private final ProductService productService; + private final BrandService brandService; + @Transactional public LikeInfo addLike(Long memberId, String productId) { LikeModel like = likeService.addLike(memberId, productId); return LikeInfo.from(like); } + @Transactional public void removeLike(Long memberId, String productId) { likeService.removeLike(memberId, productId); } @@ -30,12 +35,13 @@ public void removeLike(Long memberId, String productId) { public Page getMyLikedProducts(Long memberId, Pageable pageable) { return likeService.getMyLikes(memberId, pageable) .map(like -> { - ProductInfo product = productFacade.getProductByDbId(like.getRefProductId().value()); + ProductModel product = productService.getProductByRefId(like.getRefProductId().value()); + BrandModel brand = brandService.getBrandByRefId(product.getRefBrandId().value()); return new LikedProductInfo( - product.productId(), - product.productName(), - product.brand().brandName(), - product.price(), + product.getProductId().value(), + product.getProductName().value(), + brand.getBrandName().value(), + product.getPrice().value(), like.getCreatedAt() ); }); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index ae8fc1d8c..3720a5c82 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -1,13 +1,9 @@ package com.loopers.application.product; import com.loopers.domain.brand.BrandModel; -import com.loopers.domain.brand.BrandRepository; -import com.loopers.domain.brand.vo.BrandId; +import com.loopers.domain.brand.BrandService; import com.loopers.domain.product.ProductModel; -import com.loopers.domain.product.ProductRepository; import com.loopers.domain.product.ProductService; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -21,8 +17,7 @@ public class ProductFacade { private final ProductService productService; - private final ProductRepository productRepository; - private final BrandRepository brandRepository; + private final BrandService brandService; @Transactional public ProductInfo createProduct(String productId, String brandId, String productName, BigDecimal price, int stockQuantity) { @@ -37,7 +32,7 @@ public ProductInfo getProduct(String productId) { } @Transactional - public ProductInfo updateProduct(String productId, String productName, java.math.BigDecimal price, int stockQuantity) { + public ProductInfo updateProduct(String productId, String productName, BigDecimal price, int stockQuantity) { ProductModel product = productService.updateProduct(productId, productName, price, stockQuantity); return enrichProductInfo(product); } @@ -54,23 +49,14 @@ public Page getProducts(String brandId, String sortBy, Pageable pag } @Transactional(readOnly = true) - public ProductInfo getProductByDbId(Long id) { - ProductModel product = productService.getProductByDbId(id); + public ProductInfo getProductByRefId(Long id) { + ProductModel product = productService.getProductByRefId(id); return enrichProductInfo(product); } - /** - * ProductModel에 Brand 정보와 좋아요 수를 추가하여 ProductInfo 생성 - */ private ProductInfo enrichProductInfo(ProductModel product) { - // Brand 정보 조회 - BrandModel brand = brandRepository.findById(product.getRefBrandId().value()) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, - "브랜드 정보를 찾을 수 없습니다. ID: " + product.getRefBrandId().value())); - - // 좋아요 수 조회 - long likesCount = productRepository.countLikes(product.getId()); - + BrandModel brand = brandService.getBrandByRefId(product.getRefBrandId().value()); + long likesCount = productService.countLikes(product.getId()); return ProductInfo.from(product, brand, likesCount); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java index f35971bb1..67b646928 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -1,22 +1,17 @@ package com.loopers.domain.brand; import com.loopers.domain.brand.vo.BrandId; -import com.loopers.domain.product.ProductModel; -import com.loopers.domain.product.ProductRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.List; - @Service @RequiredArgsConstructor public class BrandService { private final BrandRepository brandRepository; - private final ProductRepository productRepository; @Transactional public BrandModel createBrand(String brandId, String brandName) { @@ -34,18 +29,18 @@ public BrandModel getBrand(String brandId) { .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 브랜드가 존재하지 않습니다.")); } + @Transactional(readOnly = true) + public BrandModel getBrandByRefId(Long id) { + return brandRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 브랜드가 존재하지 않습니다.")); + } + @Transactional - public void deleteBrand(String brandId) { + public BrandModel deleteBrand(String brandId) { BrandModel brand = brandRepository.findByBrandId(new BrandId(brandId)) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 브랜드가 존재하지 않습니다.")); brand.markAsDeleted(); - brandRepository.save(brand); - - List products = productRepository.findByRefBrandId(brand.getId()); - for (ProductModel product : products) { - product.markAsDeleted(); - productRepository.save(product); - } + return brandRepository.save(brand); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java index 3c30623c5..bb05f7f95 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -1,5 +1,6 @@ package com.loopers.domain.product; +import com.loopers.domain.brand.BrandModel; import com.loopers.domain.brand.BrandRepository; import com.loopers.domain.brand.vo.BrandId; import com.loopers.domain.product.vo.ProductId; @@ -12,6 +13,7 @@ import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; +import java.util.List; @Service @RequiredArgsConstructor @@ -28,7 +30,7 @@ public ProductModel createProduct(String productId, String brandId, String produ } // 브랜드 존재 확인 및 PK 획득 - var brand = brandRepository.findByBrandId(new BrandId(brandId)) + BrandModel brand = brandRepository.findByBrandId(new BrandId(brandId)) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 브랜드가 존재하지 않습니다.")); Long refBrandId = brand.getId(); @@ -65,7 +67,7 @@ public void deleteProduct(String productId) { } @Transactional(readOnly = true) - public ProductModel getProductByDbId(Long id) { + public ProductModel getProductByRefId(Long id) { return productRepository.findById(id) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 상품이 존재하지 않습니다.")); } @@ -75,10 +77,24 @@ public Page getProducts(String brandId, String sortBy, Pageable pa // brandId가 제공되면 Brand PK로 변환 Long refBrandId = null; if (brandId != null && !brandId.isBlank()) { - var brand = brandRepository.findByBrandId(new BrandId(brandId)) + BrandModel brand = brandRepository.findByBrandId(new BrandId(brandId)) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 브랜드가 존재하지 않습니다.")); refBrandId = brand.getId(); } return productRepository.findProducts(refBrandId, sortBy, pageable); } + + @Transactional(readOnly = true) + public long countLikes(Long productId) { + return productRepository.countLikes(productId); + } + + @Transactional + public void deleteProductsByBrandRefId(Long brandDbId) { + List products = productRepository.findByRefBrandId(brandDbId); + for (ProductModel product : products) { + product.markAsDeleted(); + productRepository.save(product); + } + } } From a33c020777444f57eb398fa4563ac18519add3f0 Mon Sep 17 00:00:00 2001 From: YoHanKi Date: Sun, 22 Feb 2026 16:01:38 +0900 Subject: [PATCH 39/50] =?UTF-8?q?refactor(infra):=20EntityManager=20?= =?UTF-8?q?=EC=A7=81=EC=A0=91=20=EC=82=AC=EC=9A=A9=20=EC=A0=9C=EA=B1=B0,?= =?UTF-8?q?=20JpaRepository=20@Query=EB=A1=9C=20=EB=8C=80=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../like/LikeJpaRepository.java | 15 +++ .../like/LikeRepositoryImpl.java | 31 +---- .../order/OrderJpaRepository.java | 18 +++ .../order/OrderRepositoryImpl.java | 58 +------- .../product/ProductJpaRepository.java | 54 ++++++++ .../product/ProductRepositoryImpl.java | 124 +----------------- 6 files changed, 97 insertions(+), 203 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java index 869346649..e01aee66c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java @@ -3,10 +3,25 @@ import com.loopers.domain.like.LikeModel; import com.loopers.domain.like.vo.RefMemberId; import com.loopers.domain.like.vo.RefProductId; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.Optional; public interface LikeJpaRepository extends JpaRepository { + Optional findByRefMemberIdAndRefProductId(RefMemberId refMemberId, RefProductId refProductId); + + @Query( + value = "SELECT l.* FROM likes l JOIN products p ON l.ref_product_id = p.id " + + "WHERE l.ref_member_id = :memberId AND p.deleted_at IS NULL " + + "ORDER BY l.created_at DESC", + countQuery = "SELECT COUNT(*) FROM likes l JOIN products p ON l.ref_product_id = p.id " + + "WHERE l.ref_member_id = :memberId AND p.deleted_at IS NULL", + nativeQuery = true + ) + Page findActiveByRefMemberId(@Param("memberId") Long memberId, Pageable pageable); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java index f93612b19..8fc339906 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -4,14 +4,11 @@ import com.loopers.domain.like.LikeRepository; import com.loopers.domain.like.vo.RefMemberId; import com.loopers.domain.like.vo.RefProductId; -import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; -import java.util.List; import java.util.Optional; @Repository @@ -19,7 +16,6 @@ public class LikeRepositoryImpl implements LikeRepository { private final LikeJpaRepository likeJpaRepository; - private final EntityManager entityManager; @Override public LikeModel save(LikeModel like) { @@ -38,31 +34,6 @@ public void delete(LikeModel like) { @Override public Page findByRefMemberId(RefMemberId refMemberId, Pageable pageable) { - String sql = "SELECT l.* FROM likes l " + - "JOIN products p ON l.ref_product_id = p.id " + - "WHERE l.ref_member_id = :memberId " + - "AND p.deleted_at IS NULL " + - "ORDER BY l.created_at DESC"; - - List likes = entityManager.createNativeQuery(sql, LikeModel.class) - .setParameter("memberId", refMemberId.value()) - .setFirstResult((int) pageable.getOffset()) - .setMaxResults(pageable.getPageSize()) - .getResultList(); - - long total = countByRefMemberId(refMemberId); - - return new PageImpl<>(likes, pageable, total); - } - - private long countByRefMemberId(RefMemberId refMemberId) { - String sql = "SELECT COUNT(*) FROM likes l " + - "JOIN products p ON l.ref_product_id = p.id " + - "WHERE l.ref_member_id = :memberId " + - "AND p.deleted_at IS NULL"; - - return ((Number) entityManager.createNativeQuery(sql) - .setParameter("memberId", refMemberId.value()) - .getSingleResult()).longValue(); + return likeJpaRepository.findActiveByRefMemberId(refMemberId.value(), pageable); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java index 274d098cd..c9fc1468a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -1,11 +1,29 @@ package com.loopers.infrastructure.order; +import com.loopers.domain.like.vo.RefMemberId; import com.loopers.domain.order.OrderModel; import com.loopers.domain.order.vo.OrderId; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import java.time.LocalDateTime; import java.util.Optional; public interface OrderJpaRepository extends JpaRepository { + Optional findByOrderId(OrderId orderId); + + @Query("SELECT o FROM OrderModel o WHERE o.refMemberId = :refMemberId " + + "AND (:startDateTime IS NULL OR o.createdAt >= :startDateTime) " + + "AND (:endDateTime IS NULL OR o.createdAt <= :endDateTime) " + + "ORDER BY o.createdAt DESC") + Page findByRefMemberIdWithDateFilter( + @Param("refMemberId") RefMemberId refMemberId, + @Param("startDateTime") LocalDateTime startDateTime, + @Param("endDateTime") LocalDateTime endDateTime, + Pageable pageable + ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java index c831e58ef..511b95717 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -4,22 +4,19 @@ import com.loopers.domain.order.OrderModel; import com.loopers.domain.order.OrderRepository; import com.loopers.domain.order.vo.OrderId; -import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; import java.time.LocalDateTime; -import java.util.List; import java.util.Optional; @RequiredArgsConstructor @Component public class OrderRepositoryImpl implements OrderRepository { + private final OrderJpaRepository orderJpaRepository; - private final EntityManager entityManager; @Override public OrderModel save(OrderModel order) { @@ -33,57 +30,6 @@ public Optional findByOrderId(OrderId orderId) { @Override public Page findByRefMemberId(RefMemberId refMemberId, LocalDateTime startDateTime, LocalDateTime endDateTime, Pageable pageable) { - StringBuilder sql = new StringBuilder( - "SELECT * FROM orders WHERE ref_member_id = :memberId"); - - if (startDateTime != null) { - sql.append(" AND created_at >= :startDateTime"); - } - if (endDateTime != null) { - sql.append(" AND created_at <= :endDateTime"); - } - sql.append(" ORDER BY created_at DESC"); - - var query = entityManager.createNativeQuery(sql.toString(), OrderModel.class) - .setParameter("memberId", refMemberId.value()); - - if (startDateTime != null) { - query.setParameter("startDateTime", startDateTime); - } - if (endDateTime != null) { - query.setParameter("endDateTime", endDateTime); - } - - query.setFirstResult((int) pageable.getOffset()); - query.setMaxResults(pageable.getPageSize()); - - List orders = query.getResultList(); - long total = countByRefMemberId(refMemberId, startDateTime, endDateTime); - - return new PageImpl<>(orders, pageable, total); - } - - private long countByRefMemberId(RefMemberId refMemberId, LocalDateTime startDateTime, LocalDateTime endDateTime) { - StringBuilder sql = new StringBuilder( - "SELECT COUNT(*) FROM orders WHERE ref_member_id = :memberId"); - - if (startDateTime != null) { - sql.append(" AND created_at >= :startDateTime"); - } - if (endDateTime != null) { - sql.append(" AND created_at <= :endDateTime"); - } - - var query = entityManager.createNativeQuery(sql.toString()) - .setParameter("memberId", refMemberId.value()); - - if (startDateTime != null) { - query.setParameter("startDateTime", startDateTime); - } - if (endDateTime != null) { - query.setParameter("endDateTime", endDateTime); - } - - return ((Number) query.getSingleResult()).longValue(); + return orderJpaRepository.findByRefMemberIdWithDateFilter(refMemberId, startDateTime, endDateTime, pageable); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java index 01899f050..145422799 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -2,12 +2,66 @@ import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.vo.ProductId; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import java.util.List; import java.util.Optional; public interface ProductJpaRepository extends JpaRepository { + Optional findByProductId(ProductId productId); boolean existsByProductId(ProductId productId); + + @Query( + value = "SELECT * FROM products WHERE deleted_at IS NULL AND (:refBrandId IS NULL OR ref_brand_id = :refBrandId) ORDER BY updated_at DESC", + countQuery = "SELECT COUNT(*) FROM products WHERE deleted_at IS NULL AND (:refBrandId IS NULL OR ref_brand_id = :refBrandId)", + nativeQuery = true + ) + Page findActiveSortByLatest(@Param("refBrandId") Long refBrandId, Pageable pageable); + + @Query( + value = "SELECT * FROM products WHERE deleted_at IS NULL AND (:refBrandId IS NULL OR ref_brand_id = :refBrandId) ORDER BY price ASC", + countQuery = "SELECT COUNT(*) FROM products WHERE deleted_at IS NULL AND (:refBrandId IS NULL OR ref_brand_id = :refBrandId)", + nativeQuery = true + ) + Page findActiveSortByPriceAsc(@Param("refBrandId") Long refBrandId, Pageable pageable); + + @Query( + value = "SELECT p.* FROM products p LEFT JOIN likes l ON p.id = l.ref_product_id WHERE p.deleted_at IS NULL AND (:refBrandId IS NULL OR p.ref_brand_id = :refBrandId) GROUP BY p.id ORDER BY COUNT(l.id) DESC, p.updated_at DESC", + countQuery = "SELECT COUNT(*) FROM products WHERE deleted_at IS NULL AND (:refBrandId IS NULL OR ref_brand_id = :refBrandId)", + nativeQuery = true + ) + Page findActiveSortByLikesDesc(@Param("refBrandId") Long refBrandId, Pageable pageable); + + @Modifying + @Query( + value = "UPDATE products SET stock_quantity = stock_quantity - :quantity WHERE id = :productId AND stock_quantity >= :quantity", + nativeQuery = true + ) + int decreaseStockIfAvailable(@Param("productId") Long productId, @Param("quantity") int quantity); + + @Modifying + @Query( + value = "UPDATE products SET stock_quantity = stock_quantity + :quantity WHERE id = :productId", + nativeQuery = true + ) + void increaseStock(@Param("productId") Long productId, @Param("quantity") int quantity); + + @Query( + value = "SELECT COUNT(*) FROM likes WHERE ref_product_id = :productId", + nativeQuery = true + ) + long countLikesByProductId(@Param("productId") Long productId); + + @Query( + value = "SELECT * FROM products WHERE ref_brand_id = :brandId AND deleted_at IS NULL", + nativeQuery = true + ) + List findActiveByRefBrandId(@Param("brandId") Long brandId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java index 5c8cc1322..47be6645d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -3,10 +3,8 @@ import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductRepository; import com.loopers.domain.product.vo.ProductId; -import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; @@ -18,7 +16,6 @@ public class ProductRepositoryImpl implements ProductRepository { private final ProductJpaRepository productJpaRepository; - private final EntityManager entityManager; @Override public ProductModel save(ProductModel product) { @@ -37,139 +34,32 @@ public boolean existsByProductId(ProductId productId) { @Override public Page findProducts(Long refBrandId, String sortBy, Pageable pageable) { - List products; - - // VO 타입 문제로 Native Query 사용 if ("likes_desc".equals(sortBy)) { - products = findProductsWithLikesCount(refBrandId, pageable); - } else { - products = findProductsNative(refBrandId, sortBy, pageable); - } - - // 전체 개수 조회 - long total = countProducts(refBrandId); - - return new PageImpl<>(products, pageable, total); - } - - private List findProductsNative(Long refBrandId, String sortBy, Pageable pageable) { - // Native Query 사용 (VO 타입 문제 회피) - StringBuilder sql = new StringBuilder("SELECT * FROM products WHERE deleted_at IS NULL"); - - if (refBrandId != null) { - sql.append(" AND ref_brand_id = :refBrandId"); - } - - sql.append(getNativeSortClause(sortBy)); - - var query = entityManager.createNativeQuery(sql.toString(), ProductModel.class); - - if (refBrandId != null) { - query.setParameter("refBrandId", refBrandId); - } - - query.setFirstResult((int) pageable.getOffset()); - query.setMaxResults(pageable.getPageSize()); - - return query.getResultList(); - } - - private String getNativeSortClause(String sortBy) { - if (sortBy == null || "latest".equals(sortBy)) { - return " ORDER BY updated_at DESC"; + return productJpaRepository.findActiveSortByLikesDesc(refBrandId, pageable); } else if ("price_asc".equals(sortBy)) { - return " ORDER BY price ASC"; - } - return " ORDER BY updated_at DESC"; // 기본값 - } - - private List findProductsWithLikesCount(Long refBrandId, Pageable pageable) { - // Native Query: LEFT JOIN으로 좋아요 수 카운트 후 정렬 - StringBuilder sql = new StringBuilder( - "SELECT p.* FROM products p " + - "LEFT JOIN likes l ON p.id = l.ref_product_id " + - "WHERE p.deleted_at IS NULL"); - - if (refBrandId != null) { - sql.append(" AND p.ref_brand_id = :refBrandId"); - } - - sql.append(" GROUP BY p.id") - .append(" ORDER BY COUNT(l.id) DESC, p.updated_at DESC"); // 좋아요 수 동일 시 최신순 - - var query = entityManager.createNativeQuery(sql.toString(), ProductModel.class); - - if (refBrandId != null) { - query.setParameter("refBrandId", refBrandId); + return productJpaRepository.findActiveSortByPriceAsc(refBrandId, pageable); } - - query.setFirstResult((int) pageable.getOffset()); - query.setMaxResults(pageable.getPageSize()); - - return query.getResultList(); - } - - private long countProducts(Long refBrandId) { - // Native Query 사용 (VO 타입 문제 회피) - StringBuilder sql = new StringBuilder("SELECT COUNT(*) FROM products WHERE deleted_at IS NULL"); - - if (refBrandId != null) { - sql.append(" AND ref_brand_id = :refBrandId"); - } - - var query = entityManager.createNativeQuery(sql.toString()); - if (refBrandId != null) { - query.setParameter("refBrandId", refBrandId); - } - - return ((Number) query.getSingleResult()).longValue(); + return productJpaRepository.findActiveSortByLatest(refBrandId, pageable); } @Override public boolean decreaseStockIfAvailable(Long productId, int quantity) { - // 동시성 제어를 위한 조건부 UPDATE (Native Query 사용 - VO 타입 문제 회피) - String sql = "UPDATE products " + - "SET stock_quantity = stock_quantity - :quantity " + - "WHERE id = :productId " + - "AND stock_quantity >= :quantity"; - - int updatedCount = entityManager.createNativeQuery(sql) - .setParameter("quantity", quantity) - .setParameter("productId", productId) - .executeUpdate(); - - return updatedCount > 0; + return productJpaRepository.decreaseStockIfAvailable(productId, quantity) > 0; } @Override public void increaseStock(Long productId, int quantity) { - // Native Query 사용 (VO 타입 문제 회피) - String sql = "UPDATE products " + - "SET stock_quantity = stock_quantity + :quantity " + - "WHERE id = :productId"; - - entityManager.createNativeQuery(sql) - .setParameter("quantity", quantity) - .setParameter("productId", productId) - .executeUpdate(); + productJpaRepository.increaseStock(productId, quantity); } @Override public long countLikes(Long productId) { - String sql = "SELECT COUNT(*) FROM likes WHERE ref_product_id = :productId"; - - var query = entityManager.createNativeQuery(sql); - query.setParameter("productId", productId); - - return ((Number) query.getSingleResult()).longValue(); + return productJpaRepository.countLikesByProductId(productId); } @Override public List findByRefBrandId(Long brandId) { - String sql = "SELECT * FROM products WHERE ref_brand_id = :brandId AND deleted_at IS NULL"; - return entityManager.createNativeQuery(sql, ProductModel.class) - .setParameter("brandId", brandId) - .getResultList(); + return productJpaRepository.findActiveByRefBrandId(brandId); } @Override From 3a27b488b698130229afd1fa5a494226647e5fc2 Mon Sep 17 00:00:00 2001 From: YoHanKi Date: Sun, 22 Feb 2026 16:02:08 +0900 Subject: [PATCH 40/50] =?UTF-8?q?refactor(style):=20var=20=ED=82=A4?= =?UTF-8?q?=EC=9B=8C=EB=93=9C=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20DbId=20?= =?UTF-8?q?=E2=86=92=20RefId=20=EB=A9=94=EC=84=9C=EB=93=9C=EB=AA=85=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/domain/like/LikeService.java | 5 ++-- .../interfaces/api/like/LikeV1Controller.java | 3 +- .../loopers/domain/order/OrderModelTest.java | 16 +++++----- .../api/like/LikeV1ControllerE2ETest.java | 29 ++++++++++--------- .../loopers/batch/listener/JobListener.java | 21 +++++++------- .../batch/listener/StepMonitorListener.java | 4 +-- .../com/loopers/job/demo/DemoJobE2ETest.java | 8 +++-- 7 files changed, 46 insertions(+), 40 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java index ad7b1888d..5cb2fb0da 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java @@ -2,6 +2,7 @@ import com.loopers.domain.like.vo.RefMemberId; import com.loopers.domain.like.vo.RefProductId; +import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductRepository; import com.loopers.domain.product.vo.ProductId; import com.loopers.support.error.CoreException; @@ -23,7 +24,7 @@ public class LikeService { @Transactional public LikeModel addLike(Long memberId, String productId) { // 상품 존재 확인 - var product = productRepository.findByProductId(new ProductId(productId)) + ProductModel product = productRepository.findByProductId(new ProductId(productId)) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 상품이 존재하지 않습니다.")); RefMemberId refMemberId = new RefMemberId(memberId); @@ -51,7 +52,7 @@ public Page getMyLikes(Long memberId, Pageable pageable) { @Transactional public void removeLike(Long memberId, String productId) { // 상품 존재 확인 - var product = productRepository.findByProductId(new ProductId(productId)) + ProductModel product = productRepository.findByProductId(new ProductId(productId)) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 상품이 존재하지 않습니다.")); RefMemberId refMemberId = new RefMemberId(memberId); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java index 6419f0bbb..9393283ca 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java @@ -1,6 +1,7 @@ package com.loopers.interfaces.api.like; import com.loopers.application.like.LikeFacade; +import com.loopers.application.like.LikeInfo; import com.loopers.interfaces.api.ApiResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -22,7 +23,7 @@ public ApiResponse addLike( @PathVariable String productId, @Valid @RequestBody AddLikeRequest request ) { - var info = likeFacade.addLike(request.memberId(), productId); + LikeInfo info = likeFacade.addLike(request.memberId(), productId); return ApiResponse.success(LikeResponse.from(info)); } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java index f375dc81d..e6d6e52d3 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java @@ -23,8 +23,8 @@ class Create { void create_order_success() { // given Long memberId = 1L; - var item1 = OrderItemModel.create("prod1", "Product 1", new BigDecimal("10000"), 2); - var item2 = OrderItemModel.create("prod2", "Product 2", new BigDecimal("20000"), 1); + OrderItemModel item1 = OrderItemModel.create("prod1", "Product 1", new BigDecimal("10000"), 2); + OrderItemModel item2 = OrderItemModel.create("prod2", "Product 2", new BigDecimal("20000"), 1); List items = List.of(item1, item2); // when @@ -56,8 +56,8 @@ void create_emptyItems_throwsException() { void getTotalAmount() { // given Long memberId = 1L; - var item1 = OrderItemModel.create("prod1", "Product 1", new BigDecimal("10000"), 2); // 20000 - var item2 = OrderItemModel.create("prod2", "Product 2", new BigDecimal("20000"), 1); // 20000 + OrderItemModel item1 = OrderItemModel.create("prod1", "Product 1", new BigDecimal("10000"), 2); // 20000 + OrderItemModel item2 = OrderItemModel.create("prod2", "Product 2", new BigDecimal("20000"), 1); // 20000 OrderModel order = OrderModel.create(memberId, List.of(item1, item2)); // when @@ -77,7 +77,7 @@ class Cancel { void cancel_fromPending_success() { // given Long memberId = 1L; - var item = OrderItemModel.create("prod1", "Product 1", new BigDecimal("10000"), 1); + OrderItemModel item = OrderItemModel.create("prod1", "Product 1", new BigDecimal("10000"), 1); OrderModel order = OrderModel.create(memberId, List.of(item)); // when @@ -92,7 +92,7 @@ void cancel_fromPending_success() { void cancel_alreadyCanceled_idempotent() { // given Long memberId = 1L; - var item = OrderItemModel.create("prod1", "Product 1", new BigDecimal("10000"), 1); + OrderItemModel item = OrderItemModel.create("prod1", "Product 1", new BigDecimal("10000"), 1); OrderModel order = OrderModel.create(memberId, List.of(item)); order.cancel(); @@ -113,7 +113,7 @@ class IsOwner { void isOwner_correctMember_returnsTrue() { // given Long memberId = 1L; - var item = OrderItemModel.create("prod1", "Product 1", new BigDecimal("10000"), 1); + OrderItemModel item = OrderItemModel.create("prod1", "Product 1", new BigDecimal("10000"), 1); OrderModel order = OrderModel.create(memberId, List.of(item)); // when @@ -128,7 +128,7 @@ void isOwner_correctMember_returnsTrue() { void isOwner_wrongMember_returnsFalse() { // given Long memberId = 1L; - var item = OrderItemModel.create("prod1", "Product 1", new BigDecimal("10000"), 1); + OrderItemModel item = OrderItemModel.create("prod1", "Product 1", new BigDecimal("10000"), 1); OrderModel order = OrderModel.create(memberId, List.of(item)); // when diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ControllerE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ControllerE2ETest.java index 54337f26d..e0d7ddd51 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ControllerE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ControllerE2ETest.java @@ -15,6 +15,7 @@ import org.springframework.http.HttpEntity; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import java.math.BigDecimal; @@ -60,10 +61,10 @@ void addLike_success_returns201() { brandService.createBrand("nike", "Nike"); productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 10); - var request = new AddLikeRequest(1L); + AddLikeRequest request = new AddLikeRequest(1L); // when - var response = restTemplate.postForEntity( + ResponseEntity response = restTemplate.postForEntity( baseUrl("prod1"), request, ApiResponse.class @@ -82,11 +83,11 @@ void addLike_duplicate_returns201() { brandService.createBrand("nike", "Nike"); productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 10); - var request = new AddLikeRequest(1L); + AddLikeRequest request = new AddLikeRequest(1L); // when - var firstResponse = restTemplate.postForEntity(baseUrl("prod1"), request, ApiResponse.class); - var secondResponse = restTemplate.postForEntity(baseUrl("prod1"), request, ApiResponse.class); + ResponseEntity firstResponse = restTemplate.postForEntity(baseUrl("prod1"), request, ApiResponse.class); + ResponseEntity secondResponse = restTemplate.postForEntity(baseUrl("prod1"), request, ApiResponse.class); // then assertThat(firstResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); @@ -97,10 +98,10 @@ void addLike_duplicate_returns201() { @DisplayName("존재하지 않는 상품에 좋아요 추가 시 404 Not Found 반환") void addLike_productNotFound_returns404() { // given - var request = new AddLikeRequest(1L); + AddLikeRequest request = new AddLikeRequest(1L); // when - var response = restTemplate.postForEntity( + ResponseEntity response = restTemplate.postForEntity( baseUrl("invalid"), request, ApiResponse.class @@ -122,13 +123,13 @@ void removeLike_success_returns204() { brandService.createBrand("nike", "Nike"); productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 10); - var addRequest = new AddLikeRequest(1L); + AddLikeRequest addRequest = new AddLikeRequest(1L); restTemplate.postForEntity(baseUrl("prod1"), addRequest, ApiResponse.class); - var removeRequest = new RemoveLikeRequest(1L); + RemoveLikeRequest removeRequest = new RemoveLikeRequest(1L); // when - var response = restTemplate.exchange( + ResponseEntity response = restTemplate.exchange( baseUrl("prod1"), HttpMethod.DELETE, new HttpEntity<>(removeRequest), @@ -146,10 +147,10 @@ void removeLike_notExists_returns204() { brandService.createBrand("nike", "Nike"); productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 10); - var removeRequest = new RemoveLikeRequest(1L); + RemoveLikeRequest removeRequest = new RemoveLikeRequest(1L); // when - var response = restTemplate.exchange( + ResponseEntity response = restTemplate.exchange( baseUrl("prod1"), HttpMethod.DELETE, new HttpEntity<>(removeRequest), @@ -164,10 +165,10 @@ void removeLike_notExists_returns204() { @DisplayName("존재하지 않는 상품에 좋아요 취소 시 404 Not Found 반환") void removeLike_productNotFound_returns404() { // given - var removeRequest = new RemoveLikeRequest(1L); + RemoveLikeRequest removeRequest = new RemoveLikeRequest(1L); // when - var response = restTemplate.exchange( + ResponseEntity response = restTemplate.exchange( baseUrl("invalid"), HttpMethod.DELETE, new HttpEntity<>(removeRequest), diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java index cb5c8bebd..668672619 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java @@ -9,6 +9,7 @@ import java.time.Duration; import java.time.Instant; +import java.time.LocalDateTime; import java.time.ZoneId; @Slf4j @@ -24,23 +25,23 @@ void beforeJob(JobExecution jobExecution) { @AfterJob void afterJob(JobExecution jobExecution) { - var startTime = jobExecution.getExecutionContext().getLong("startTime"); - var endTime = System.currentTimeMillis(); + long startTime = jobExecution.getExecutionContext().getLong("startTime"); + long endTime = System.currentTimeMillis(); - var startDateTime = Instant.ofEpochMilli(startTime) + LocalDateTime startDateTime = Instant.ofEpochMilli(startTime) .atZone(ZoneId.systemDefault()) .toLocalDateTime(); - var endDateTime = Instant.ofEpochMilli(endTime) + LocalDateTime endDateTime = Instant.ofEpochMilli(endTime) .atZone(ZoneId.systemDefault()) .toLocalDateTime(); - var totalTime = endTime - startTime; - var duration = Duration.ofMillis(totalTime); - var hours = duration.toHours(); - var minutes = duration.toMinutes() % 60; - var seconds = duration.getSeconds() % 60; + long totalTime = endTime - startTime; + Duration duration = Duration.ofMillis(totalTime); + long hours = duration.toHours(); + long minutes = duration.toMinutes() % 60; + long seconds = duration.getSeconds() % 60; - var message = String.format( + String message = String.format( """ *Start Time:* %s *End Time:* %s diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java index 4f22f40b0..603c531ce 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java @@ -23,8 +23,8 @@ public void beforeStep(@Nonnull StepExecution stepExecution) { @Override public ExitStatus afterStep(@Nonnull StepExecution stepExecution) { if (!stepExecution.getFailureExceptions().isEmpty()) { - var jobName = stepExecution.getJobExecution().getJobInstance().getJobName(); - var exceptions = stepExecution.getFailureExceptions().stream() + String jobName = stepExecution.getJobExecution().getJobInstance().getJobName(); + String exceptions = stepExecution.getFailureExceptions().stream() .map(Throwable::getMessage) .filter(Objects::nonNull) .collect(Collectors.joining("\n")); diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java index dafe59a18..a4bbaaa6c 100644 --- a/apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java +++ b/apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java @@ -7,6 +7,8 @@ import org.junit.jupiter.api.Test; import org.springframework.batch.core.ExitStatus; import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParameters; import org.springframework.batch.core.JobParametersBuilder; import org.springframework.batch.test.JobLauncherTestUtils; import org.springframework.batch.test.context.SpringBatchTest; @@ -46,7 +48,7 @@ void shouldNotSaveCategories_whenApiError() throws Exception { jobLauncherTestUtils.setJob(job); // act - var jobExecution = jobLauncherTestUtils.launchJob(); + JobExecution jobExecution = jobLauncherTestUtils.launchJob(); // assert assertAll( @@ -62,10 +64,10 @@ void success() throws Exception { jobLauncherTestUtils.setJob(job); // act - var jobParameters = new JobParametersBuilder() + JobParameters jobParameters = new JobParametersBuilder() .addLocalDate("requestDate", LocalDate.now()) .toJobParameters(); - var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + JobExecution jobExecution = jobLauncherTestUtils.launchJob(jobParameters); // assert assertAll( From 98e1211ed3f94704c823ca0337941b41049f910d Mon Sep 17 00:00:00 2001 From: YoHanKi Date: Sun, 22 Feb 2026 16:22:26 +0900 Subject: [PATCH 41/50] =?UTF-8?q?docs(arch):=20=EC=95=84=ED=82=A4=ED=85=8D?= =?UTF-8?q?=EC=B2=98=20=ED=99=95=EC=A0=95=20=EA=B7=9C=EC=B9=99=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=ED=99=94=20(Facade=20=EA=B7=9C=EC=B9=99,=20VO=20?= =?UTF-8?q?=EC=86=8C=EC=9C=A0=EA=B6=8C,=20@Query=20=ED=8C=A8=ED=84=B4,=20?= =?UTF-8?q?=EC=BD=94=EB=94=A9=20=EC=BB=A8=EB=B2=A4=EC=85=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/skills/architecture/SKILL.md | 67 ++++++++++++++++++++++++ .claude/skills/chat/SKILL.md | 13 +++++ .claude/skills/coding-standards/SKILL.md | 24 +++++++++ .claude/skills/jpa-database/SKILL.md | 61 +++++++++++++++++++++ CLAUDE.md | 11 ++++ 5 files changed, 176 insertions(+) create mode 100644 .claude/skills/chat/SKILL.md diff --git a/.claude/skills/architecture/SKILL.md b/.claude/skills/architecture/SKILL.md index f08144908..26cb4fe46 100644 --- a/.claude/skills/architecture/SKILL.md +++ b/.claude/skills/architecture/SKILL.md @@ -423,3 +423,70 @@ com.loopers - ❌ 순환 참조 - ❌ 도메인 로직 누수 (Controller에 비즈니스 로직) - ❌ God Service (하나의 Service에 모든 로직) + +--- + +## Facade 계층 규칙 (확정 결정) + +### 어노테이션 +- Facade: **`@Component`** 사용 (절대 `@Service` 사용 금지) +- Service: **`@Service`** 사용 + +### Facade 의존성 규칙 +``` +✅ Facade → Service (허용) +❌ Facade → Repository 직접 의존 (금지) +❌ Facade → Facade 의존 (금지) +``` + +Facade가 여러 도메인을 조율할 때는 반드시 각 도메인의 Service를 통해야 함. + +### 크로스 도메인 오케스트레이션은 Facade 책임 +```java +// ✅ 올바른 예: BrandFacade에서 cascade delete 처리 +@Transactional +public void deleteBrand(String brandId) { + BrandModel brand = brandService.deleteBrand(brandId); // 브랜드 soft delete + productService.deleteProductsByBrandRefId(brand.getId()); // 연쇄 상품 soft delete +} +// ❌ 잘못된 예: BrandService 내부에서 ProductRepository 직접 접근하여 cascade 처리 +``` + +도메인 간 연쇄 처리(cascade)가 필요하면 Service에 두지 말고 Facade에서 조율할 것. + +--- + +## 도메인 간 의존성 규칙 (확정 결정) + +### Model과 Repository 인터페이스: 자기 도메인 VO만 사용 +```java +// ✅ OrderModel은 order.vo만 import +import com.loopers.domain.order.vo.OrderId; +import com.loopers.domain.order.vo.RefMemberId; // order 도메인 소유 + +// ❌ OrderModel이 like.vo를 import하는 것은 금지 +import com.loopers.domain.like.vo.RefMemberId; +``` + +### Service: 타 도메인 Repository 호출 시 해당 도메인 VO import 허용 +```java +// ✅ LikeService가 ProductRepository를 호출하기 위해 ProductId VO import +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.vo.ProductId; // 관계가 있으므로 허용 + +// ✅ ProductService가 BrandRepository를 호출하기 위해 BrandId VO import +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.brand.vo.BrandId; // 관계가 있으므로 허용 +``` + +Service 계층에서 타 도메인 Repository를 직접 사용하는 것은 **트랜잭션 원자성 보장** 목적으로 허용됨. +단, 이는 의도적 설계 결정이며, 단순 조회 위임이라면 타 도메인 Service를 통하는 것을 검토할 것. + +### 참조 VO (RefOOOId) 소유권 +- `RefMemberId`, `RefProductId` 등 타 도메인의 PK를 참조하는 VO는 **사용하는 도메인이 자기 vo 패키지에 별도 정의** +- 예: `order.vo.RefMemberId`, `like.vo.RefMemberId` — 같은 이름이어도 별개의 독립 VO +- 한 도메인의 VO를 다른 도메인 Model/Repository가 import하는 것은 도메인 경계 위반 + +### Converter 소유권 +- 각 도메인의 VO에 대응하는 Converter는 해당 도메인의 VO를 사용하는 Entity 맥락에 맞게 별도 정의 +- 예: `RefMemberIdConverter` (like.vo용), `OrderRefMemberIdConverter` (order.vo용) diff --git a/.claude/skills/chat/SKILL.md b/.claude/skills/chat/SKILL.md new file mode 100644 index 000000000..a79508ead --- /dev/null +++ b/.claude/skills/chat/SKILL.md @@ -0,0 +1,13 @@ +--- +name: chat +description: 파일을 수정하지 않고 질문에만 답변하는 채팅 모드. 코드 분석, 개념 설명, 아키텍처 논의 등 순수 대화가 필요할 때 사용. +disable-model-invocation: true +allowed-tools: Read, Grep, Glob +--- + +채팅 모드가 활성화되었습니다. + +이 모드에서는 파일 수정, 생성, 삭제, 명령어 실행을 하지 않습니다. +코드 읽기와 검색만 허용되며, 질문에 대한 답변과 분석에 집중합니다. + +$ARGUMENTS \ No newline at end of file diff --git a/.claude/skills/coding-standards/SKILL.md b/.claude/skills/coding-standards/SKILL.md index 872b946a6..24c4d7cbd 100644 --- a/.claude/skills/coding-standards/SKILL.md +++ b/.claude/skills/coding-standards/SKILL.md @@ -39,6 +39,15 @@ allowed-tools: Read, Grep #### Service - 도메인 용어 사용: `register`, `getMemberByMemberId`, `updateProfile`, `withdraw` +- 타 도메인 PK(DB id)를 파라미터로 받는 메서드: **`RefId` 접미사** 사용 (`DbId` 사용 금지) + ```java + // ✅ 올바름 + ProductModel getProductByRefId(Long id) + void deleteProductsByBrandRefId(Long brandDbId) + BrandModel getBrandByRefId(Long id) + // ❌ 금지 + ProductModel getProductByDbId(Long id) + ``` #### Controller - RESTful 원칙: GET (조회), POST (생성), PUT (전체 수정), PATCH (부분 수정), DELETE (삭제) @@ -401,6 +410,21 @@ public record Email(String address) { 5. **Unused Import**: 사용하지 않는 import 제거 6. **Raw Type**: 제네릭 타입 명시 7. **Exception Swallowing**: 예외를 무시하지 말 것 +8. **`var` 키워드 사용 금지**: 반드시 명시적 타입 사용 + ```java + // ❌ 금지 + var product = productRepository.findById(id); + // ✅ 허용 + Optional product = productRepository.findById(id); + ``` +9. **중첩 클래스/레코드 정의 금지**: 클래스나 record 내부에 다른 record/class 정의 금지 → 별도 파일로 분리 + ```java + // ❌ 금지 + public class OrderFacade { + public record OrderCommand(String productId, int qty) {} + } + // ✅ 허용: OrderCommand.java 별도 파일로 생성 + ``` ### ✅ Best Practices 1. **불변 객체 선호**: `record`, `final` 활용 diff --git a/.claude/skills/jpa-database/SKILL.md b/.claude/skills/jpa-database/SKILL.md index 78474d828..583e90ef2 100644 --- a/.claude/skills/jpa-database/SKILL.md +++ b/.claude/skills/jpa-database/SKILL.md @@ -452,6 +452,66 @@ class MemberServiceIntegrationTest { --- +## @Query 패턴 (확정 결정) + +### EntityManager 직접 사용 금지 +프로덕션 코드에서 `EntityManager`를 직접 사용하는 것은 금지. 반드시 JpaRepository의 `@Query`로 대체. + +```java +// ❌ 금지: EntityManager 직접 사용 +@Autowired +private EntityManager entityManager; + +public Page findProducts(...) { + Query query = entityManager.createNativeQuery("SELECT ...", ProductModel.class); + // ... +} + +// ✅ 올바름: JpaRepository에 @Query 정의 +public interface ProductJpaRepository extends JpaRepository { + @Query(value = "SELECT * FROM products WHERE ...", nativeQuery = true) + Page findActiveProducts(...); +} +``` + +### 페이징 네이티브 쿼리: countQuery 필수 +```java +// ✅ 페이징 native query는 반드시 countQuery 명시 +@Query( + value = "SELECT p.* FROM products p LEFT JOIN likes l ON p.id = l.ref_product_id " + + "WHERE p.deleted_at IS NULL GROUP BY p.id ORDER BY COUNT(l.id) DESC", + countQuery = "SELECT COUNT(*) FROM products p WHERE p.deleted_at IS NULL", + nativeQuery = true +) +Page findActiveSortByLikesDesc(Pageable pageable); +``` + +### 조건부 UPDATE: @Modifying + @Query +```java +// ✅ 재고 차감처럼 조건부 UPDATE는 @Modifying 사용 +@Modifying +@Query(value = "UPDATE products SET stock_quantity = stock_quantity - :quantity " + + "WHERE id = :productId AND stock_quantity >= :quantity", nativeQuery = true) +int decreaseStockIfAvailable(@Param("productId") Long productId, @Param("quantity") int quantity); +``` + +### nullable 파라미터 조건 필터링: JPQL 활용 +```java +// ✅ null 가능한 파라미터는 JPQL의 조건부 표현식으로 처리 +@Query("SELECT o FROM OrderModel o WHERE o.refMemberId = :refMemberId " + + "AND (:startDateTime IS NULL OR o.createdAt >= :startDateTime) " + + "AND (:endDateTime IS NULL OR o.createdAt <= :endDateTime) " + + "ORDER BY o.createdAt DESC") +Page findByRefMemberIdWithDateFilter( + @Param("refMemberId") RefMemberId refMemberId, + @Param("startDateTime") LocalDateTime startDateTime, + @Param("endDateTime") LocalDateTime endDateTime, + Pageable pageable +); +``` + +--- + ## 주의사항 ### Entity 설계 @@ -462,6 +522,7 @@ class MemberServiceIntegrationTest { ### Repository 설계 - ❌ **Service에서 JpaRepository 직접 사용 금지**: RepositoryImpl 경유 +- ❌ **EntityManager 직접 사용 금지**: `@Query` 어노테이션으로 대체 - ✅ **Domain Repository 인터페이스**: 도메인 용어 사용 - ✅ **쿼리 메서드 활용**: 간단한 조회는 메서드명으로 diff --git a/CLAUDE.md b/CLAUDE.md index 6f5d60024..81cd96ec8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -120,6 +120,11 @@ Infrastructure Layer (RepositoryImpl, JpaRepository, Converter) - ❌ null-safety 위반 금지 (Optional 활용) - ❌ println 코드 남기지 말 것 (`@Slf4j` 사용) - ❌ 테스트 임의 삭제/수정 금지 (`@Disabled`, assertion 약화 금지) +- ❌ `var` 키워드 사용 금지 — 반드시 명시적 타입으로 선언 +- ❌ `EntityManager` 직접 사용 금지 — JpaRepository `@Query`로 대체 +- ❌ Facade에서 Repository 직접 의존 금지 — 반드시 Service 경유 +- ❌ Facade → Facade 의존 금지 +- ❌ 클래스/record 내부에 nested 클래스/record 정의 금지 — 별도 파일로 분리 ### Recommendation (권장사항) - ✅ 실제 API를 호출해 확인하는 E2E 테스트 작성 @@ -190,11 +195,17 @@ Infrastructure Layer (RepositoryImpl, JpaRepository, Converter) - **레이어 의존성 방향**: `Controller → Facade → Service → Repository` (단방향), Infrastructure는 Domain 인터페이스 구현 (Port-Adapter). - **Thin Facade 원칙**: Facade는 Service만 호출, 비즈니스 로직은 Service에 위임(조율만 담당). +- **Facade 어노테이션**: Facade는 `@Component`, Service는 `@Service` — 절대 혼용 금지. +- **Facade 의존성**: Facade → Service만 허용. Facade → Repository 직접 의존, Facade → Facade 의존은 금지. +- **크로스 도메인 오케스트레이션**: 여러 도메인을 걸치는 연쇄 처리(cascade 등)는 Service가 아닌 Facade에서 조율. - **DTO vs Info vs Model 분리**: DTO(HTTP 계층) → Info(Application 결과 VO) → Model(Domain Entity), 각 레이어 독립성 유지. - **Service 책임**: Service는 Repository를 통한 조회 및 저장, 비즈니스 규칙 검증, @Transactional 경계 관리. +- **Service 크로스 도메인**: Service는 트랜잭션 원자성을 위해 타 도메인 Repository를 직접 사용 가능. 이때 해당 도메인 VO import도 허용. - **Repository Pattern**: Domain에 Repository 인터페이스(Port), Infrastructure에 구현체(Adapter), Domain이 Infrastructure를 모름. +- **도메인 VO 소유권**: Model과 Repository 인터페이스는 자기 도메인 vo만 사용. `RefMemberId` 같은 참조 VO는 사용하는 도메인이 자기 vo 패키지에 별도 정의. - **Info 변환**: Facade에서 Model → Info 변환, Controller는 Model 노출 금지(Info만 사용), 레이어 격리 유지. - **컴포넌트 책임**: Controller(HTTP), Facade(유스케이스 조합), Service(비즈니스 로직 + 조회), Repository(영속화). +- **메서드 네이밍**: 타 도메인 PK를 파라미터로 받는 메서드는 `RefId` 접미사 사용 (`DbId` 금지). 예: `getProductByRefId(Long id)`. --- From ec86ccf4da3455bf4af91c7c407f426797d61c6a Mon Sep 17 00:00:00 2001 From: YoHanKi Date: Sun, 22 Feb 2026 17:25:01 +0900 Subject: [PATCH 42/50] =?UTF-8?q?refactor(domain):=20Ref*Id=20VO=EB=A5=BC?= =?UTF-8?q?=20domain.common.vo=EB=A1=9C=20=ED=86=B5=ED=95=A9=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20=EB=8F=84=EB=A9=94=EC=9D=B8=EB=B3=84=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/domain/{product => common}/vo/RefBrandId.java | 2 +- .../com/loopers/domain/{like => common}/vo/RefMemberId.java | 2 +- .../com/loopers/domain/{like => common}/vo/RefProductId.java | 2 +- .../src/main/java/com/loopers/domain/like/LikeModel.java | 4 ++-- .../src/main/java/com/loopers/domain/like/LikeRepository.java | 4 ++-- .../src/main/java/com/loopers/domain/like/LikeService.java | 4 ++-- .../src/main/java/com/loopers/domain/order/OrderModel.java | 2 +- .../main/java/com/loopers/domain/order/OrderRepository.java | 2 +- .../src/main/java/com/loopers/domain/order/OrderService.java | 2 +- .../main/java/com/loopers/domain/product/ProductModel.java | 2 +- .../infrastructure/jpa/converter/RefBrandIdConverter.java | 2 +- .../infrastructure/jpa/converter/RefMemberIdConverter.java | 2 +- .../infrastructure/jpa/converter/RefProductIdConverter.java | 2 +- .../com/loopers/infrastructure/like/LikeJpaRepository.java | 4 ++-- .../com/loopers/infrastructure/like/LikeRepositoryImpl.java | 4 ++-- .../com/loopers/infrastructure/order/OrderJpaRepository.java | 2 +- .../com/loopers/infrastructure/order/OrderRepositoryImpl.java | 2 +- 17 files changed, 22 insertions(+), 22 deletions(-) rename apps/commerce-api/src/main/java/com/loopers/domain/{product => common}/vo/RefBrandId.java (91%) rename apps/commerce-api/src/main/java/com/loopers/domain/{like => common}/vo/RefMemberId.java (92%) rename apps/commerce-api/src/main/java/com/loopers/domain/{like => common}/vo/RefProductId.java (92%) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/vo/RefBrandId.java b/apps/commerce-api/src/main/java/com/loopers/domain/common/vo/RefBrandId.java similarity index 91% rename from apps/commerce-api/src/main/java/com/loopers/domain/product/vo/RefBrandId.java rename to apps/commerce-api/src/main/java/com/loopers/domain/common/vo/RefBrandId.java index 10df94388..657814701 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/vo/RefBrandId.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/common/vo/RefBrandId.java @@ -1,4 +1,4 @@ -package com.loopers.domain.product.vo; +package com.loopers.domain.common.vo; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/vo/RefMemberId.java b/apps/commerce-api/src/main/java/com/loopers/domain/common/vo/RefMemberId.java similarity index 92% rename from apps/commerce-api/src/main/java/com/loopers/domain/like/vo/RefMemberId.java rename to apps/commerce-api/src/main/java/com/loopers/domain/common/vo/RefMemberId.java index ec68110ae..fde0c3562 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/vo/RefMemberId.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/common/vo/RefMemberId.java @@ -1,4 +1,4 @@ -package com.loopers.domain.like.vo; +package com.loopers.domain.common.vo; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/vo/RefProductId.java b/apps/commerce-api/src/main/java/com/loopers/domain/common/vo/RefProductId.java similarity index 92% rename from apps/commerce-api/src/main/java/com/loopers/domain/like/vo/RefProductId.java rename to apps/commerce-api/src/main/java/com/loopers/domain/common/vo/RefProductId.java index a62e616f9..fa2a7e9a7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/vo/RefProductId.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/common/vo/RefProductId.java @@ -1,4 +1,4 @@ -package com.loopers.domain.like.vo; +package com.loopers.domain.common.vo; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java index 09ff4b83e..234c22d2a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java @@ -1,8 +1,8 @@ package com.loopers.domain.like; import com.loopers.domain.BaseEntity; -import com.loopers.domain.like.vo.RefMemberId; -import com.loopers.domain.like.vo.RefProductId; +import com.loopers.domain.common.vo.RefMemberId; +import com.loopers.domain.common.vo.RefProductId; import com.loopers.infrastructure.jpa.converter.RefMemberIdConverter; import com.loopers.infrastructure.jpa.converter.RefProductIdConverter; import jakarta.persistence.*; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java index e3c7a478b..7c72c7377 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java @@ -1,7 +1,7 @@ package com.loopers.domain.like; -import com.loopers.domain.like.vo.RefMemberId; -import com.loopers.domain.like.vo.RefProductId; +import com.loopers.domain.common.vo.RefMemberId; +import com.loopers.domain.common.vo.RefProductId; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java index 5cb2fb0da..4ce59c7b4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java @@ -1,7 +1,7 @@ package com.loopers.domain.like; -import com.loopers.domain.like.vo.RefMemberId; -import com.loopers.domain.like.vo.RefProductId; +import com.loopers.domain.common.vo.RefMemberId; +import com.loopers.domain.common.vo.RefProductId; import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductRepository; import com.loopers.domain.product.vo.ProductId; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java index 8732ca13f..2297cd85b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java @@ -1,7 +1,7 @@ package com.loopers.domain.order; import com.loopers.domain.BaseEntity; -import com.loopers.domain.like.vo.RefMemberId; +import com.loopers.domain.common.vo.RefMemberId; import com.loopers.domain.order.vo.OrderId; import com.loopers.infrastructure.jpa.converter.OrderIdConverter; import com.loopers.infrastructure.jpa.converter.RefMemberIdConverter; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java index 4b56a7cab..372622e52 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -1,6 +1,6 @@ package com.loopers.domain.order; -import com.loopers.domain.like.vo.RefMemberId; +import com.loopers.domain.common.vo.RefMemberId; import com.loopers.domain.order.vo.OrderId; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java index 0ed1b7d30..595e60d1a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -1,6 +1,6 @@ package com.loopers.domain.order; -import com.loopers.domain.like.vo.RefMemberId; +import com.loopers.domain.common.vo.RefMemberId; import com.loopers.domain.order.vo.OrderId; import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductRepository; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java index 1b69d5243..c42d6d401 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java @@ -4,7 +4,7 @@ import com.loopers.domain.product.vo.Price; import com.loopers.domain.product.vo.ProductId; import com.loopers.domain.product.vo.ProductName; -import com.loopers.domain.product.vo.RefBrandId; +import com.loopers.domain.common.vo.RefBrandId; import com.loopers.domain.product.vo.StockQuantity; import com.loopers.infrastructure.jpa.converter.PriceConverter; import com.loopers.infrastructure.jpa.converter.ProductIdConverter; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/RefBrandIdConverter.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/RefBrandIdConverter.java index 58852f995..3c63bfeda 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/RefBrandIdConverter.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/RefBrandIdConverter.java @@ -1,6 +1,6 @@ package com.loopers.infrastructure.jpa.converter; -import com.loopers.domain.product.vo.RefBrandId; +import com.loopers.domain.common.vo.RefBrandId; import jakarta.persistence.AttributeConverter; import jakarta.persistence.Converter; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/RefMemberIdConverter.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/RefMemberIdConverter.java index b8550d0b5..592346346 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/RefMemberIdConverter.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/RefMemberIdConverter.java @@ -1,6 +1,6 @@ package com.loopers.infrastructure.jpa.converter; -import com.loopers.domain.like.vo.RefMemberId; +import com.loopers.domain.common.vo.RefMemberId; import jakarta.persistence.AttributeConverter; import jakarta.persistence.Converter; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/RefProductIdConverter.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/RefProductIdConverter.java index ed849b648..f7fbf79a5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/RefProductIdConverter.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/jpa/converter/RefProductIdConverter.java @@ -1,6 +1,6 @@ package com.loopers.infrastructure.jpa.converter; -import com.loopers.domain.like.vo.RefProductId; +import com.loopers.domain.common.vo.RefProductId; import jakarta.persistence.AttributeConverter; import jakarta.persistence.Converter; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java index e01aee66c..5a1f45cdb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java @@ -1,8 +1,8 @@ package com.loopers.infrastructure.like; import com.loopers.domain.like.LikeModel; -import com.loopers.domain.like.vo.RefMemberId; -import com.loopers.domain.like.vo.RefProductId; +import com.loopers.domain.common.vo.RefMemberId; +import com.loopers.domain.common.vo.RefProductId; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java index 8fc339906..caf6c8702 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -2,8 +2,8 @@ import com.loopers.domain.like.LikeModel; import com.loopers.domain.like.LikeRepository; -import com.loopers.domain.like.vo.RefMemberId; -import com.loopers.domain.like.vo.RefProductId; +import com.loopers.domain.common.vo.RefMemberId; +import com.loopers.domain.common.vo.RefProductId; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java index c9fc1468a..dea29e452 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -1,6 +1,6 @@ package com.loopers.infrastructure.order; -import com.loopers.domain.like.vo.RefMemberId; +import com.loopers.domain.common.vo.RefMemberId; import com.loopers.domain.order.OrderModel; import com.loopers.domain.order.vo.OrderId; import org.springframework.data.domain.Page; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java index 511b95717..97ee790da 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -1,6 +1,6 @@ package com.loopers.infrastructure.order; -import com.loopers.domain.like.vo.RefMemberId; +import com.loopers.domain.common.vo.RefMemberId; import com.loopers.domain.order.OrderModel; import com.loopers.domain.order.OrderRepository; import com.loopers.domain.order.vo.OrderId; From 3c23b8b97d75d9e443c0d75728a17654ba8dedad Mon Sep 17 00:00:00 2001 From: YoHanKi Date: Sun, 22 Feb 2026 17:25:20 +0900 Subject: [PATCH 43/50] =?UTF-8?q?test:=20common.vo=20ArchUnit=20=EB=A3=B0?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80,=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../architecture/LayeredArchitectureTest.java | 28 +++++++++++++++++++ .../brand/BrandServiceIntegrationTest.java | 12 +++++--- .../domain/brand/BrandServiceTest.java | 7 ----- .../loopers/domain/like/LikeModelTest.java | 4 +-- .../loopers/domain/like/LikeServiceTest.java | 4 +-- .../member/MemberServiceIntegrationTest.java | 4 +-- .../domain/order/OrderServiceTest.java | 2 +- .../api/brand/BrandV1ControllerE2ETest.java | 11 ++++---- .../api/like/LikeV1ControllerE2ETest.java | 3 +- 9 files changed, 51 insertions(+), 24 deletions(-) diff --git a/apps/commerce-api/src/test/java/com/loopers/architecture/LayeredArchitectureTest.java b/apps/commerce-api/src/test/java/com/loopers/architecture/LayeredArchitectureTest.java index 842206e3e..ad5d4d71e 100644 --- a/apps/commerce-api/src/test/java/com/loopers/architecture/LayeredArchitectureTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/architecture/LayeredArchitectureTest.java @@ -1,15 +1,20 @@ package com.loopers.architecture; import com.tngtech.archunit.base.DescribedPredicate; +import com.tngtech.archunit.core.domain.JavaClass; import com.tngtech.archunit.core.domain.JavaClasses; import com.tngtech.archunit.core.importer.ClassFileImporter; import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.lang.ArchCondition; import com.tngtech.archunit.lang.ArchRule; +import com.tngtech.archunit.lang.ConditionEvents; +import com.tngtech.archunit.lang.SimpleConditionEvent; import com.tngtech.archunit.library.Architectures; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; import static com.tngtech.archunit.library.Architectures.layeredArchitecture; @@ -152,6 +157,29 @@ void domain_services_should_only_depend_on_repository_interfaces() { rule.check(classes); } + @Test + @DisplayName("domain.common.vo 패키지에는 Ref*Id 형식의 참조 VO만 허용") + void common_vo_should_only_contain_reference_value_objects() { + ArchCondition haveRefIdName = new ArchCondition<>("have simple name matching Ref*Id pattern") { + @Override + public void check(JavaClass item, ConditionEvents events) { + boolean matches = item.getSimpleName().matches("Ref[A-Z][a-zA-Z]+Id"); + if (!matches) { + events.add(SimpleConditionEvent.violated(item, + item.getSimpleName() + " does not match Ref*Id pattern in domain.common.vo")); + } + } + }; + + ArchRule rule = classes() + .that().resideInAPackage("com.loopers.domain.common.vo") + .should(haveRefIdName) + .because("domain.common.vo 패키지는 FK 참조용 Ref*Id VO만 허용합니다. " + + "비즈니스 로직이 있는 VO는 각 도메인 vo 패키지에 정의하세요."); + + rule.check(classes); + } + @Test @DisplayName("레이어는 역방향으로 접근할 수 없음 (상위 레이어 접근 금지)") void layers_should_not_access_upper_layers() { diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java index 5de291403..fa503e20b 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java @@ -1,5 +1,6 @@ package com.loopers.domain.brand; +import com.loopers.application.brand.BrandFacade; import com.loopers.domain.brand.vo.BrandId; import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductService; @@ -35,6 +36,9 @@ class BrandServiceIntegrationTest { @Autowired private BrandService brandService; + @Autowired + private BrandFacade brandFacade; + @Autowired private ProductService productService; @@ -136,11 +140,11 @@ void deleteBrand_notFound_throwsException() { } @Test - @DisplayName("브랜드 삭제 시 해당 브랜드의 상품도 연쇄 soft delete") + @DisplayName("브랜드 삭제 시 해당 브랜드의 상품도 연쇄 soft delete (BrandFacade 경유)") void deleteBrand_cascadeDeletesProducts() { // given String brandId = "samsung"; - BrandModel brand = brandService.createBrand(brandId, "Samsung"); + brandService.createBrand(brandId, "Samsung"); ProductModel product1 = productService.createProduct("prod1", brandId, "Product 1", new BigDecimal("10000"), 10); ProductModel product2 = productService.createProduct("prod2", brandId, "Product 2", new BigDecimal("20000"), 20); @@ -148,8 +152,8 @@ void deleteBrand_cascadeDeletesProducts() { assertThat(product1.isDeleted()).isFalse(); assertThat(product2.isDeleted()).isFalse(); - // when - brandService.deleteBrand(brandId); + // when - cascade는 Facade 책임 + brandFacade.deleteBrand(brandId); // then - 브랜드 삭제됨 BrandModel deletedBrand = brandJpaRepository.findByBrandId(new BrandId(brandId)).orElseThrow(); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java index 4a62dc11d..c0ded67eb 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java @@ -2,7 +2,6 @@ import com.loopers.domain.brand.vo.BrandId; import com.loopers.domain.brand.vo.BrandName; -import com.loopers.domain.product.ProductRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import org.junit.jupiter.api.DisplayName; @@ -12,13 +11,11 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import java.util.List; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) @@ -28,9 +25,6 @@ class BrandServiceTest { @Mock private BrandRepository brandRepository; - @Mock - private ProductRepository productRepository; - @InjectMocks private BrandService brandService; @@ -84,7 +78,6 @@ void deleteBrand_success() { when(brandRepository.findByBrandId(any(BrandId.class))).thenReturn(Optional.of(mockBrand)); when(brandRepository.save(any(BrandModel.class))).thenReturn(mockBrand); - when(productRepository.findByRefBrandId(anyLong())).thenReturn(List.of()); // when brandService.deleteBrand(brandId); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java index 313a32ea5..53e471c35 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java @@ -1,7 +1,7 @@ package com.loopers.domain.like; -import com.loopers.domain.like.vo.RefMemberId; -import com.loopers.domain.like.vo.RefProductId; +import com.loopers.domain.common.vo.RefMemberId; +import com.loopers.domain.common.vo.RefProductId; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java index 53c659b89..ae07b8429 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java @@ -1,7 +1,7 @@ package com.loopers.domain.like; -import com.loopers.domain.like.vo.RefMemberId; -import com.loopers.domain.like.vo.RefProductId; +import com.loopers.domain.common.vo.RefMemberId; +import com.loopers.domain.common.vo.RefProductId; import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductRepository; import com.loopers.domain.product.vo.ProductId; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java index 488be28d3..a1876bbe7 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java @@ -337,7 +337,7 @@ void returnsMemberInfo_whenMemberExists() { ); // act - MemberModel foundMember = memberJpaRepository.findByMemberId(new MemberId(VALID_MEMBER_ID)).orElse(null); + MemberModel foundMember = spyMemberRepository.findByMemberId(new MemberId(VALID_MEMBER_ID)).orElse(null); // assert assertAll( @@ -361,7 +361,7 @@ void returnsNull_whenMemberDoesNotExist() { String nonExistentMemberId = "nonexist1"; // act - MemberModel foundMember = memberJpaRepository.findByMemberId(new MemberId(nonExistentMemberId)).orElse(null); + MemberModel foundMember = spyMemberRepository.findByMemberId(new MemberId(nonExistentMemberId)).orElse(null); // assert assertThat(foundMember).isNull(); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java index 489530815..0b10832b5 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java @@ -1,6 +1,6 @@ package com.loopers.domain.order; -import com.loopers.domain.like.vo.RefMemberId; +import com.loopers.domain.common.vo.RefMemberId; import com.loopers.domain.order.vo.OrderId; import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductRepository; diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ControllerE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ControllerE2ETest.java index d9193ebcb..4478da6a8 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ControllerE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ControllerE2ETest.java @@ -1,6 +1,7 @@ package com.loopers.interfaces.api.brand; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.ApiResponse.Metadata.Result; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; @@ -48,7 +49,7 @@ void brandLifecycle() { assertAll( () -> assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.OK), () -> assertThat(createResponse.getBody()).isNotNull(), - () -> assertThat(createResponse.getBody().success()).isEqualTo(true) + () -> assertThat(createResponse.getBody().meta().result()).isEqualTo(Result.SUCCESS) ); // when - 브랜드 삭제 @@ -63,7 +64,7 @@ void brandLifecycle() { assertAll( () -> assertThat(deleteResponse.getStatusCode()).isEqualTo(HttpStatus.OK), () -> assertThat(deleteResponse.getBody()).isNotNull(), - () -> assertThat(deleteResponse.getBody().success()).isEqualTo(true) + () -> assertThat(deleteResponse.getBody().meta().result()).isEqualTo(Result.SUCCESS) ); } @@ -85,7 +86,7 @@ void createBrand_duplicate_returns409() { assertAll( () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT), () -> assertThat(response.getBody()).isNotNull(), - () -> assertThat(response.getBody().success()).isEqualTo(false) + () -> assertThat(response.getBody().meta().result()).isEqualTo(Result.FAIL) ); } @@ -94,7 +95,7 @@ void createBrand_duplicate_returns409() { void deleteBrand_notFound_returns404() { // when ResponseEntity response = restTemplate.exchange( - "/api/v1/brands/nonexistent", + "/api/v1/brands/notexist", HttpMethod.DELETE, null, ApiResponse.class @@ -104,7 +105,7 @@ void deleteBrand_notFound_returns404() { assertAll( () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND), () -> assertThat(response.getBody()).isNotNull(), - () -> assertThat(response.getBody().success()).isEqualTo(false) + () -> assertThat(response.getBody().meta().result()).isEqualTo(Result.FAIL) ); } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ControllerE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ControllerE2ETest.java index e0d7ddd51..11eb29aaf 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ControllerE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ControllerE2ETest.java @@ -3,6 +3,7 @@ import com.loopers.domain.brand.BrandService; import com.loopers.domain.product.ProductService; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.ApiResponse.Metadata.Result; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; @@ -73,7 +74,7 @@ void addLike_success_returns201() { // then assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); assertThat(response.getBody()).isNotNull(); - assertThat(response.getBody()).extracting("success").isEqualTo(true); + assertThat(response.getBody().meta().result()).isEqualTo(Result.SUCCESS); } @Test From a1e302f7e9d79069f20da02072625978d1c74586 Mon Sep 17 00:00:00 2001 From: YoHanKi Date: Mon, 23 Feb 2026 21:02:21 +0900 Subject: [PATCH 44/50] =?UTF-8?q?docs=20:=20application=20=EB=82=B4=20app?= =?UTF-8?q?=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EB=AC=B8=EC=84=9C=ED=99=94=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/skills/architecture/SKILL.md | 126 ++++++++++++++++------- .claude/skills/coding-standards/SKILL.md | 23 ++++- CLAUDE.md | 31 ++++-- docs/design/05-component-diagram.md | 78 ++++++++------ 4 files changed, 182 insertions(+), 76 deletions(-) diff --git a/.claude/skills/architecture/SKILL.md b/.claude/skills/architecture/SKILL.md index 26cb4fe46..73a96f30d 100644 --- a/.claude/skills/architecture/SKILL.md +++ b/.claude/skills/architecture/SKILL.md @@ -18,7 +18,7 @@ allowed-tools: Read, Grep ↓ ┌─────────────────────────────────────────┐ │ Application Layer │ ← 유스케이스 조합 -│ (Facade, Info) │ +│ (App, Facade, Info) │ └─────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────┐ @@ -233,38 +233,75 @@ public interface MemberRepository { ## Application Layer (응용 계층) ### 책임 -- 여러 도메인 서비스 조합 -- 유스케이스 구현 -- 트랜잭션 경계 설정 (선택적) +- **App**: 단일 도메인의 유스케이스 처리, Service 호출 및 Model → Info 변환 +- **Facade**: **2개 이상의 App을 조합**할 때만 사용, 크로스 도메인 오케스트레이션 -### Facade 예시 +### App 예시 (단일 도메인 — 기본 패턴) ```java @Component @RequiredArgsConstructor -public class MemberFacade { +public class MemberApp { private final MemberService memberService; - private final PointService pointService; - private final NotificationService notificationService; - @Transactional - public MemberInfo registerMemberWithWelcomePoint(MemberInfo.RegisterRequest request) { - // 1. 회원 가입 - MemberModel member = memberService.register( - request.memberId(), request.password(), request.email(), - request.birthDate(), request.name(), request.gender() - ); + public MemberInfo register(String memberId, String password, String email, + String birthDate, String name, Gender gender) { + MemberModel member = memberService.register(memberId, password, email, birthDate, name, gender); + return MemberInfo.from(member); + } + + @Transactional(readOnly = true) + public MemberInfo getMe(String loginId, String loginPw) { + MemberModel member = memberService.authenticate(loginId, loginPw); + return MemberInfo.from(member); + } - // 2. 웰컴 포인트 지급 - pointService.grantWelcomePoint(member.getMemberId()); + public void changePassword(String loginId, String loginPw, + String currentPassword, String newPassword) { + memberService.changePassword(loginId, loginPw, currentPassword, newPassword); + } +} +``` - // 3. 가입 환영 알림 발송 - notificationService.sendWelcomeNotification(member.getEmail()); +**App 의존성 규칙**: +``` +✅ App → Service (허용) +❌ App → Repository 직접 의존 (금지) +❌ App → App 의존 (금지 — 크로스 도메인은 Facade 책임) +❌ App → Facade 의존 (금지) +``` - return MemberInfo.from(member); +### Facade 예시 (크로스 도메인 — 2개 이상 App 조합 시에만) +```java +@Component +@RequiredArgsConstructor +public class OrderFacade { + private final MemberApp memberApp; + private final ProductApp productApp; + private final OrderApp orderApp; + + @Transactional + public OrderInfo placeOrder(String memberId, String productId, int quantity) { + // 1. 회원 확인 + MemberInfo member = memberApp.getMe(memberId, /* ... */); + + // 2. 상품 재고 확인 및 차감 + productApp.decreaseStock(productId, quantity); + + // 3. 주문 생성 + return orderApp.createOrder(member.id(), productId, quantity); } } ``` +**Facade 의존성 규칙**: +``` +✅ Facade → App (허용, 반드시 2개 이상) +❌ Facade → Service 직접 호출 (금지 — 반드시 App 경유) +❌ Facade → Repository 직접 의존 (금지) +❌ Facade → Facade 의존 (금지) +❌ 단일 도메인만 처리하는 Facade 생성 (금지 — App을 사용할 것) +``` + --- ## Interfaces Layer (인터페이스 계층) @@ -282,18 +319,18 @@ public class MemberFacade { @RequestMapping("/api/v1/members") public class MemberV1Controller implements MemberV1ApiSpec { - private final MemberService memberService; + private final MemberApp memberApp; @PostMapping("/register") @Override public ApiResponse register( @Valid @RequestBody MemberV1Dto.RegisterRequest request) { - MemberModel member = memberService.register( + MemberInfo info = memberApp.register( request.memberId(), request.password(), request.email(), request.birthDate(), request.name(), request.gender() ); - MemberV1Dto.MemberResponse response = MemberV1Dto.MemberResponse.from(member); + MemberV1Dto.MemberResponse response = MemberV1Dto.MemberResponse.from(info); return ApiResponse.success(response); } } @@ -374,8 +411,12 @@ com.loopers │ │ └── PasswordHasher.java # Interface ├── application # 응용 계층 │ ├── member -│ │ ├── MemberFacade.java # Facade +│ │ ├── MemberApp.java # App (단일 도메인 유스케이스) │ │ └── MemberInfo.java # Info +│ ├── order # 크로스 도메인 예시 +│ │ ├── OrderApp.java # App (order 도메인) +│ │ ├── OrderFacade.java # Facade (MemberApp + ProductApp + OrderApp 조합) +│ │ └── OrderInfo.java # Info ├── infrastructure # 인프라 계층 │ ├── member │ │ ├── MemberRepositoryImpl.java @@ -426,33 +467,48 @@ com.loopers --- -## Facade 계층 규칙 (확정 결정) +## Application Layer 규칙 (확정 결정) ### 어노테이션 +- App: **`@Component`** 사용 (절대 `@Service` 사용 금지) - Facade: **`@Component`** 사용 (절대 `@Service` 사용 금지) - Service: **`@Service`** 사용 -### Facade 의존성 규칙 +### App 의존성 규칙 +``` +✅ App → Service (허용) +❌ App → Repository 직접 의존 (금지) +❌ App → App 의존 (금지) +❌ App → Facade 의존 (금지) ``` -✅ Facade → Service (허용) + +### Facade 사용 조건 및 의존성 규칙 +``` +✅ Facade → App (허용, 반드시 2개 이상의 App 사용 시에만 Facade 생성) +❌ Facade → Service 직접 호출 (금지 — 반드시 App 경유) ❌ Facade → Repository 직접 의존 (금지) ❌ Facade → Facade 의존 (금지) +❌ 단일 App만 사용하는 Facade 생성 (금지 — App을 직접 사용할 것) ``` -Facade가 여러 도메인을 조율할 때는 반드시 각 도메인의 Service를 통해야 함. - -### 크로스 도메인 오케스트레이션은 Facade 책임 +### 크로스 도메인 오케스트레이션은 Facade 책임 (App 경유) ```java -// ✅ 올바른 예: BrandFacade에서 cascade delete 처리 +// ✅ 올바른 예: BrandFacade에서 cascade delete 처리 (App 경유) +@Transactional +public void deleteBrand(String brandId) { + brandApp.deleteBrand(brandId); // BrandService 호출 + productApp.deleteProductsByBrandRefId(brandId); // ProductService 호출 +} + +// ❌ 잘못된 예: Facade에서 Service 직접 호출 @Transactional public void deleteBrand(String brandId) { - BrandModel brand = brandService.deleteBrand(brandId); // 브랜드 soft delete - productService.deleteProductsByBrandRefId(brand.getId()); // 연쇄 상품 soft delete + brandService.deleteBrand(brandId); // 금지: Facade → Service 직접 호출 + productService.deleteProductsByBrandRefId(brand.getId()); // 금지 } -// ❌ 잘못된 예: BrandService 내부에서 ProductRepository 직접 접근하여 cascade 처리 ``` -도메인 간 연쇄 처리(cascade)가 필요하면 Service에 두지 말고 Facade에서 조율할 것. +도메인 간 연쇄 처리(cascade)가 필요하면 Service에 두지 말고 Facade에서 App을 통해 조율할 것. --- diff --git a/.claude/skills/coding-standards/SKILL.md b/.claude/skills/coding-standards/SKILL.md index 24c4d7cbd..80bae82ae 100644 --- a/.claude/skills/coding-standards/SKILL.md +++ b/.claude/skills/coding-standards/SKILL.md @@ -23,11 +23,17 @@ allowed-tools: Read, Grep | Controller | `{Domain}V{version}Controller` | `MemberV1Controller` | `interfaces.api.{domain}` | | API Spec | `{Domain}V{version}ApiSpec` | `MemberV1ApiSpec` | `interfaces.api.{domain}` | | DTO | `{Domain}V{version}Dto` | `MemberV1Dto` | `interfaces.api.{domain}` | -| Facade | `{Domain}Facade` | `MemberFacade` | `application.{domain}` | +| **App** | `{Domain}App` | `MemberApp`, `OrderApp` | `application.{domain}` | +| **Facade** | `{Domain}Facade` | `OrderFacade` | `application.{domain}` | | Info | `{Domain}Info` | `MemberInfo` | `application.{domain}` | | Exception | `{Concept}Exception` | `CoreException` | `support.error` | | Converter | `{ValueObject}Converter` | `MemberIdConverter` | `infrastructure.jpa.converter` | +> **App vs Facade 선택 기준**: +> - 단일 도메인 유스케이스 → **`{Domain}App`** 사용 +> - 2개 이상의 App을 조합하는 크로스 도메인 → **`{Domain}Facade`** 사용 +> - Facade는 반드시 2개 이상의 App을 호출할 때만 생성 (단일 App만 쓰는 Facade 금지) + ### 메서드 네이밍 #### Repository @@ -420,11 +426,24 @@ public record Email(String address) { 9. **중첩 클래스/레코드 정의 금지**: 클래스나 record 내부에 다른 record/class 정의 금지 → 별도 파일로 분리 ```java // ❌ 금지 - public class OrderFacade { + public class OrderApp { public record OrderCommand(String productId, int qty) {} } // ✅ 허용: OrderCommand.java 별도 파일로 생성 ``` +10. **단일 도메인에 Facade 생성 금지**: 단일 도메인은 App으로 처리 + ```java + // ❌ 금지: MemberFacade가 MemberApp 하나만 사용하는 경우 + public class MemberFacade { + private final MemberApp memberApp; // 단일 App만 사용 → Facade 불필요 + } + // ✅ 허용: App 직접 사용 + public class MemberV1Controller { + private final MemberApp memberApp; + } + ``` +11. **App/Facade에서 Repository 직접 의존 금지**: 반드시 Service 경유 +12. **Facade에서 Service 직접 호출 금지**: 반드시 App 경유 ### ✅ Best Practices 1. **불변 객체 선호**: `record`, `final` 활용 diff --git a/CLAUDE.md b/CLAUDE.md index 81cd96ec8..6919f444e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,7 +44,7 @@ Root ``` Interfaces Layer (Controller, ApiSpec, Dto) ↓ -Application Layer (Facade, Info) +Application Layer (App, Facade, Info) ↓ Domain Layer (Model, Service, Repository, VO) ↓ @@ -60,7 +60,8 @@ Infrastructure Layer (RepositoryImpl, JpaRepository, Converter) - **Value Object**: `record` 타입, Compact Constructor 검증, 불변 **Application Layer** - 유스케이스 조합 -- **Facade**: 여러 도메인 서비스 조합 +- **App**: 단일 도메인 유스케이스 처리. Service 호출 및 Model → Info 변환 담당 +- **Facade**: **2개 이상의 App을 조합**할 때만 사용. 크로스 도메인 오케스트레이션 **Interfaces Layer** - 외부 통신 - **Controller**: REST API, `ApiResponse` 반환 @@ -83,6 +84,9 @@ Infrastructure Layer (RepositoryImpl, JpaRepository, Converter) - DTO: `{Domain}V{version}Dto` - Value Object: `{Name}` (예: `MemberId`, `Email`) - Converter: `{ValueObject}Converter` +- App: `{Domain}App` (예: `MemberApp`) — 단일 도메인 유스케이스 +- Facade: `{Domain}Facade` (예: `OrderFacade`) — 2개 이상 App 조합 시에만 사용 +- Info: `{Domain}Info` (예: `MemberInfo`) — Application 레이어 결과 VO ### 타입 사용 - **Entity**: `class` (가변 상태) @@ -122,8 +126,11 @@ Infrastructure Layer (RepositoryImpl, JpaRepository, Converter) - ❌ 테스트 임의 삭제/수정 금지 (`@Disabled`, assertion 약화 금지) - ❌ `var` 키워드 사용 금지 — 반드시 명시적 타입으로 선언 - ❌ `EntityManager` 직접 사용 금지 — JpaRepository `@Query`로 대체 -- ❌ Facade에서 Repository 직접 의존 금지 — 반드시 Service 경유 +- ❌ App/Facade에서 Repository 직접 의존 금지 — 반드시 Service 경유 +- ❌ App → App 의존 금지 (크로스 도메인은 Facade 책임) - ❌ Facade → Facade 의존 금지 +- ❌ Facade → Service 직접 호출 금지 — 반드시 App 경유 +- ❌ Facade를 단일 도메인에서만 사용 금지 — App을 사용할 것 - ❌ 클래스/record 내부에 nested 클래스/record 정의 금지 — 별도 파일로 분리 ### Recommendation (권장사항) @@ -193,18 +200,22 @@ Infrastructure Layer (RepositoryImpl, JpaRepository, Converter) ## 아키텍처, 패키지 구성 전략 -- **레이어 의존성 방향**: `Controller → Facade → Service → Repository` (단방향), Infrastructure는 Domain 인터페이스 구현 (Port-Adapter). -- **Thin Facade 원칙**: Facade는 Service만 호출, 비즈니스 로직은 Service에 위임(조율만 담당). -- **Facade 어노테이션**: Facade는 `@Component`, Service는 `@Service` — 절대 혼용 금지. -- **Facade 의존성**: Facade → Service만 허용. Facade → Repository 직접 의존, Facade → Facade 의존은 금지. -- **크로스 도메인 오케스트레이션**: 여러 도메인을 걸치는 연쇄 처리(cascade 등)는 Service가 아닌 Facade에서 조율. +- **레이어 의존성 방향 (단일 도메인)**: `Controller → App → Service → Repository` +- **레이어 의존성 방향 (크로스 도메인)**: `Controller → Facade → App(복수) → Service → Repository` +- Infrastructure는 Domain 인터페이스 구현 (Port-Adapter). +- **App 원칙**: App은 단일 도메인의 유스케이스를 처리. Service 호출 및 Model → Info 변환 담당. 비즈니스 로직은 Service에 위임. +- **Facade 사용 조건**: **2개 이상의 App을 조합할 때만** Facade를 사용. 단일 도메인은 App으로 처리. +- **App 어노테이션**: App은 `@Component`, Service는 `@Service`, Facade는 `@Component` — 절대 혼용 금지. +- **App 의존성**: App → Service만 허용. App → Repository 직접 의존, App → App 의존은 금지. +- **Facade 의존성**: Facade → App만 허용 (2개 이상). Facade → Service 직접 호출, Facade → Repository 직접 의존, Facade → Facade 의존은 모두 금지. +- **크로스 도메인 오케스트레이션**: 여러 도메인을 걸치는 연쇄 처리(cascade 등)는 Service가 아닌 Facade에서 App을 통해 조율. - **DTO vs Info vs Model 분리**: DTO(HTTP 계층) → Info(Application 결과 VO) → Model(Domain Entity), 각 레이어 독립성 유지. - **Service 책임**: Service는 Repository를 통한 조회 및 저장, 비즈니스 규칙 검증, @Transactional 경계 관리. - **Service 크로스 도메인**: Service는 트랜잭션 원자성을 위해 타 도메인 Repository를 직접 사용 가능. 이때 해당 도메인 VO import도 허용. - **Repository Pattern**: Domain에 Repository 인터페이스(Port), Infrastructure에 구현체(Adapter), Domain이 Infrastructure를 모름. - **도메인 VO 소유권**: Model과 Repository 인터페이스는 자기 도메인 vo만 사용. `RefMemberId` 같은 참조 VO는 사용하는 도메인이 자기 vo 패키지에 별도 정의. -- **Info 변환**: Facade에서 Model → Info 변환, Controller는 Model 노출 금지(Info만 사용), 레이어 격리 유지. -- **컴포넌트 책임**: Controller(HTTP), Facade(유스케이스 조합), Service(비즈니스 로직 + 조회), Repository(영속화). +- **Info 변환**: App에서 Model → Info 변환, Controller는 Model 노출 금지(Info만 사용), 레이어 격리 유지. +- **컴포넌트 책임**: Controller(HTTP), App(단일 도메인 유스케이스 + Info 변환), Facade(복수 App 조합), Service(비즈니스 로직 + 조회), Repository(영속화). - **메서드 네이밍**: 타 도메인 PK를 파라미터로 받는 메서드는 `RefId` 접미사 사용 (`DbId` 금지). 예: `getProductByRefId(Long id)`. --- diff --git a/docs/design/05-component-diagram.md b/docs/design/05-component-diagram.md index f5746fd92..2f58a980d 100644 --- a/docs/design/05-component-diagram.md +++ b/docs/design/05-component-diagram.md @@ -39,13 +39,14 @@ package "Presentation Layer\n(interfaces)" { } package "Application Layer\n(application)" { - [EnrollmentFacade] as Facade + [EnrollmentApp] as App [EnrollmentResult] as ResultVO - note right of Facade - 역할 (Thin Facade): + note right of App + 역할 (App): - @Transactional 관리 - Domain → Result VO 변환 - Domain Service 조율만 + (단일 도메인 유스케이스) end note } @@ -81,13 +82,13 @@ package "Infrastructure Layer\n(infrastructure)" { } ' --- Requests flow (올바른 의존성 방향) --- -Controller --> Facade : 호출 +Controller --> App : 호출 Controller --> Request : 사용 Controller ..> Response : 생성 (from Result) -' --- Facade to Domain Service --- -Facade --> DomainService : 호출 -Facade ..> ResultVO : 생성 (from Entity) +' --- App to Domain Service --- +App --> DomainService : 호출 +App ..> ResultVO : 생성 (from Entity) ' --- Domain Service to Repository --- DomainService --> EnrollmentRepo : 의존 @@ -115,7 +116,7 @@ EnrollmentRepoImpl --> EnrollmentJpa : 위임 ## Member 도메인 컴포넌트 다이어그램 ### 검증 목적 -Member 도메인의 레이어드 아키텍처 구조를 확인한다. Controller → Facade → Service → Repository 의존성 흐름이 명확히 드러나야 하며, Facade가 Service만 호출하고 Reader를 직접 호출하지 않는지 검증한다. +Member 도메인의 레이어드 아키텍처 구조를 확인한다. Controller → App → Service → Repository 의존성 흐름이 명확히 드러나야 하며, App이 Service만 호출하고 Reader를 직접 호출하지 않는지 검증한다. ### 다이어그램 @@ -148,9 +149,9 @@ package "Presentation Layer\n(interfaces.api.member)" #E3F2FD { } package "Application Layer\n(application.member)" #FFF3E0 { - [MemberFacade] as Facade - note right of Facade - **역할 (Thin Facade):** + [MemberApp] as App + note right of App + **역할 (App — 단일 도메인):** - Service 조합 - Model → Info 변환 - 유스케이스 경계 @@ -158,6 +159,7 @@ package "Application Layer\n(application.member)" #FFF3E0 { **패턴:** - Service만 호출 (Reader 직접 호출 금지) - Info 반환 (Model 노출 금지) + - 크로스 도메인은 Facade에 위임 end note [MemberInfo] as Info @@ -229,14 +231,14 @@ package "Infrastructure Layer\n(infrastructure.member)" #F3E5F5 { } ' === 의존성 흐름 (레이어 간) === -Controller --> Facade : 호출 +Controller --> App : 호출 Controller ..> RegReq : 사용 Controller ..> MemResp : 반환 Controller ..> MeResp : 반환 Controller ..> ChgPwdReq : 사용 -Facade --> Service : 조율 -Facade ..> Info : 반환 +App --> Service : 조율 +App ..> Info : 반환 Service --> Reader : 조회 Service --> Repo : 영속화 @@ -255,39 +257,52 @@ RepoImpl ..> Model : 로드/저장 ### 해석 **레이어 흐름**: -- **Controller**: HTTP 요청 수신 → Facade 호출 → Info 수신 → DTO 변환하여 응답 -- **Facade**: Service 호출 → Model 수신 → Info 변환하여 반환 +- **Controller**: HTTP 요청 수신 → App 호출 → Info 수신 → DTO 변환하여 응답 +- **App**: Service 호출 → Model 수신 → Info 변환하여 반환 (단일 도메인 유스케이스) - **Service**: 비즈니스 로직 실행 → Reader/Repository 사용 → Model 반환 - **Reader**: Repository 사용 → 조회 전용 → Model 반환 - **Repository**: 영속화 추상화 (Port) - **RepositoryImpl**: Repository 구현 (Adapter) → JpaRepository 위임 **핵심 패턴**: -1. **Facade는 Service만 호출**: Reader를 직접 호출하지 않음 (Service가 Reader 소유) -2. **Info 변환**: Facade에서 Model → Info 변환 (레이어 격리) +1. **App은 Service만 호출**: Reader를 직접 호출하지 않음 (Service가 Reader 소유) +2. **Info 변환**: App에서 Model → Info 변환 (레이어 격리) 3. **Port-Adapter**: Domain의 Repository(interface)를 Infrastructure의 RepositoryImpl이 구현 4. **DTO vs Info**: DTO는 HTTP 계층, Info는 Application 계층 (서로 다른 관심사) +5. **App vs Facade**: 단일 도메인은 App, 2개 이상 App 조합 시에만 Facade --- ## 설계 원칙 -### 1. Facade Pattern (Application Layer) +### 1. App / Facade Pattern (Application Layer) -**Thin Facade 원칙**: -- Facade는 Service만 호출, Reader 직접 호출 금지 -- 여러 도메인 서비스 조합은 Facade 책임 -- 비즈니스 로직은 Service에 위임 (Facade는 조율만) +**App 원칙 (기본 패턴)**: +- App은 단일 도메인 유스케이스를 담당 +- Service만 호출, Reader 직접 호출 금지 +- Model → Info 변환 담당 +- Controller는 단일 도메인 처리 시 App을 직접 호출 + +**Facade 원칙 (크로스 도메인)**: +- Facade는 **2개 이상의 App**을 조합할 때만 사용 +- App → App 의존은 금지이므로 크로스 도메인은 Facade 책임 +- Facade는 Service를 직접 호출하지 않고 App 경유 **Info 변환**: -- Facade가 Model → Info 변환 담당 (레이어 격리) +- App이 Model → Info 변환 담당 (레이어 격리) - Controller는 Info를 알지만 Model은 모름 - Domain Model이 Presentation Layer에 노출되지 않음 **트랜잭션 경계**: -- Facade 메서드가 @Transactional 경계 +- App 메서드 또는 Facade 메서드가 @Transactional 경계 - 유스케이스 단위로 트랜잭션 관리 +**변환 흐름**: +``` +단일 도메인: Controller → App → Service → Repository +크로스 도메인: Controller → Facade → App(복수) → Service → Repository +``` + --- ### 2. DTO vs Info vs Model @@ -356,7 +371,8 @@ Domain (Repository interface) <--- Infrastructure (RepositoryImpl) |--------|--------|----------|------| | **Presentation** | interfaces.api.member | MemberV1Controller | HTTP 엔드포인트, 인증 헤더 추출, DTO ↔ Info 변환 | | | | DTOs (record) | 요청/응답 데이터, Jakarta Validation | -| **Application** | application.member | MemberFacade | 유스케이스 조합, Service 호출, Model → Info 변환 | +| **Application** | application.member | MemberApp | 단일 도메인 유스케이스, Service 호출, Model → Info 변환 | +| | | (크로스 도메인 시) XxxFacade | 2개 이상 App 조합, 크로스 도메인 오케스트레이션 | | | | MemberInfo (record) | 애플리케이션 결과 VO, 불변 | | **Domain** | domain.member | MemberService | 비즈니스 로직, 트랜잭션 경계, 교차 엔티티 규칙 | | | | MemberReader | 읽기 전용 조회, getOrThrow 패턴 | @@ -389,7 +405,10 @@ Domain (Repository interface) <--- Infrastructure (RepositoryImpl) ## 검증 체크리스트 - [x] 레이어 간 의존성 방향: Interfaces → Application → Domain ← Infrastructure -- [x] Facade는 Service만 호출 (Reader 직접 호출 금지) +- [x] 단일 도메인 유스케이스는 App 사용 (Facade 금지) +- [x] Facade는 2개 이상의 App을 조합할 때만 사용 +- [x] App은 Service만 호출 (Reader 직접 호출 금지) +- [x] Facade는 App만 호출 (Service 직접 호출 금지) - [x] Info는 Application 레이어 (Model은 Domain 레이어) - [x] Repository는 Domain에 정의 (Port), Infrastructure에 구현 (Adapter) - [x] Controller는 Model을 모름 (Info만 알음) @@ -400,6 +419,7 @@ Domain (Repository interface) <--- Infrastructure (RepositoryImpl) ## 다음 단계 이 컴포넌트 다이어그램을 기반으로: -- **구현**: MemberFacade, MemberInfo 구현 (현재 Facade는 비어있음) -- **테스트**: Facade 단위 테스트 (Service 모킹) +- **구현**: MemberApp, MemberInfo 구현 +- **테스트**: App 단위 테스트 (Service 모킹) - **확장**: 다른 도메인에도 동일한 패턴 적용 (Product, Order 등) +- **크로스 도메인**: 2개 이상 App 조합이 필요한 경우에만 Facade 추가 From 3db244d7a9a4c81173e9321fac9e5d4a750eaebf Mon Sep 17 00:00:00 2001 From: YoHanKi Date: Mon, 23 Feb 2026 21:19:55 +0900 Subject: [PATCH 45/50] =?UTF-8?q?refactor(application):=20MemberFacade?= =?UTF-8?q?=EB=A5=BC=20MemberApp=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{MemberFacade.java => MemberApp.java} | 2 +- .../api/member/MemberV1Controller.java | 10 +++---- .../architecture/LayeredArchitectureTest.java | 27 ++++++++++++++----- 3 files changed, 27 insertions(+), 12 deletions(-) rename apps/commerce-api/src/main/java/com/loopers/application/member/{MemberFacade.java => MemberApp.java} (97%) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberApp.java similarity index 97% rename from apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java rename to apps/commerce-api/src/main/java/com/loopers/application/member/MemberApp.java index c957f5ce1..9c992dcca 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberApp.java @@ -9,7 +9,7 @@ @RequiredArgsConstructor @Component -public class MemberFacade { +public class MemberApp { private final MemberService memberService; public MemberInfo register(String memberId, String password, String email, diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java index 4ca38669d..ea1286c4b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java @@ -1,6 +1,6 @@ package com.loopers.interfaces.api.member; -import com.loopers.application.member.MemberFacade; +import com.loopers.application.member.MemberApp; import com.loopers.application.member.MemberInfo; import com.loopers.interfaces.api.ApiResponse; import jakarta.validation.Valid; @@ -18,12 +18,12 @@ @RequestMapping("/api/v1/members") public class MemberV1Controller implements MemberV1ApiSpec { - private final MemberFacade memberFacade; + private final MemberApp memberApp; @PostMapping("/register") @Override public ApiResponse register(@Valid @RequestBody MemberV1Dto.RegisterRequest request) { - MemberInfo info = memberFacade.register( + MemberInfo info = memberApp.register( request.memberId(), request.password(), request.email(), @@ -42,7 +42,7 @@ public ApiResponse getMe( @RequestHeader("X-Loopers-LoginId") String loginId, @RequestHeader("X-Loopers-LoginPw") String loginPw ) { - MemberInfo info = memberFacade.authenticate(loginId, loginPw); + MemberInfo info = memberApp.authenticate(loginId, loginPw); MemberV1Dto.MeResponse response = MemberV1Dto.MeResponse.fromInfo(info); return ApiResponse.success(response); @@ -55,7 +55,7 @@ public ApiResponse changePassword( @RequestHeader("X-Loopers-LoginPw") String loginPw, @Valid @RequestBody MemberV1Dto.ChangePasswordRequest request ) { - memberFacade.changePassword(loginId, loginPw, + memberApp.changePassword(loginId, loginPw, request.currentPassword(), request.newPassword()); return ApiResponse.success(null); } diff --git a/apps/commerce-api/src/test/java/com/loopers/architecture/LayeredArchitectureTest.java b/apps/commerce-api/src/test/java/com/loopers/architecture/LayeredArchitectureTest.java index ad5d4d71e..ada298757 100644 --- a/apps/commerce-api/src/test/java/com/loopers/architecture/LayeredArchitectureTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/architecture/LayeredArchitectureTest.java @@ -25,12 +25,13 @@ *
  * Interfaces Layer (Controller, Dto)
  *     ↓
- * Application Layer (Facade, Info)
+ * Application Layer (App, Facade, Info)
  *     ↓
  * Domain Layer (Model, Reader, Service, Repository)
  *     ↑
  * Infrastructure Layer (RepositoryImpl, JpaRepository, Converter)
  * 
+ * App: 단일 도메인 유스케이스. Facade: 2개 이상 App 조합 시에만 사용. */ @DisplayName("레이어드 아키텍처 의존성 규칙") class LayeredArchitectureTest { @@ -103,7 +104,7 @@ void layer_dependencies_are_respected() { .whereLayer("Infrastructure").mayOnlyBeAccessedByLayers("Domain"); ArchRule rule = architecture - .because("컴포넌트(Service, Repository, Facade) 간 단방향 의존성을 검증합니다. " + + .because("컴포넌트(Service, Repository, App, Facade) 간 단방향 의존성을 검증합니다. " + "(데이터 타입(VO/Enum/Entity)은 검증 대상에서 제외)"); rule.check(classes); @@ -122,14 +123,28 @@ void interfaces_components_should_not_depend_on_domain_or_infrastructure_compone .andShould().haveSimpleNameEndingWith("Service") .orShould().haveSimpleNameEndingWith("Repository") .orShould().haveSimpleNameEndingWith("RepositoryImpl") - .because("Controller는 Facade를 통해서만 하위 레이어에 접근해야 합니다"); + .because("Controller는 App 또는 Facade를 통해서만 하위 레이어에 접근해야 합니다"); rule.check(classes); } @Test - @DisplayName("Application 컴포넌트는 Infrastructure 컴포넌트를 직접 의존하면 안 됨") - void application_components_should_not_depend_on_infrastructure_components() { + @DisplayName("Application 컴포넌트(App)는 Infrastructure 컴포넌트를 직접 의존하면 안 됨") + void app_components_should_not_depend_on_infrastructure_components() { + ArchRule rule = noClasses() + .that().resideInAPackage("com.loopers.application..") + .and().haveSimpleNameEndingWith("App") + .should().dependOnClassesThat() + .resideInAnyPackage("com.loopers.infrastructure..") + .andShould().haveSimpleNameEndingWith("RepositoryImpl") + .because("App은 Domain Service를 통해서만 데이터에 접근해야 합니다"); + + rule.check(classes); + } + + @Test + @DisplayName("Application 컴포넌트(Facade)는 Infrastructure 컴포넌트를 직접 의존하면 안 됨") + void facade_components_should_not_depend_on_infrastructure_components() { // Facade가 Repository 구현체를 직접 호출하는 것 금지 ArchRule rule = noClasses() .that().resideInAPackage("com.loopers.application..") @@ -137,7 +152,7 @@ void application_components_should_not_depend_on_infrastructure_components() { .should().dependOnClassesThat() .resideInAnyPackage("com.loopers.infrastructure..") .andShould().haveSimpleNameEndingWith("RepositoryImpl") - .because("Facade는 Domain Service를 통해서만 데이터에 접근해야 합니다"); + .because("Facade는 App을 통해서만 데이터에 접근해야 합니다"); rule.check(classes); } From 429b35486fa5b8ff86f42adf90e893ef3e020f80 Mon Sep 17 00:00:00 2001 From: YoHanKi Date: Mon, 23 Feb 2026 21:29:14 +0900 Subject: [PATCH 46/50] =?UTF-8?q?refactor(application):=20OrderFacade?= =?UTF-8?q?=EB=A5=BC=20OrderApp=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../order/{OrderFacade.java => OrderApp.java} | 2 +- .../interfaces/api/order/OrderV1Controller.java | 12 ++++++------ .../loopers/architecture/ApplicationLayerTest.java | 13 ++++++++++++- 3 files changed, 19 insertions(+), 8 deletions(-) rename apps/commerce-api/src/main/java/com/loopers/application/order/{OrderFacade.java => OrderApp.java} (98%) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderApp.java similarity index 98% rename from apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java rename to apps/commerce-api/src/main/java/com/loopers/application/order/OrderApp.java index 0839e4ac2..298e9766d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderApp.java @@ -14,7 +14,7 @@ @Component @RequiredArgsConstructor -public class OrderFacade { +public class OrderApp { private final OrderService orderService; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java index dc3d36414..ecd5371ff 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -1,6 +1,6 @@ package com.loopers.interfaces.api.order; -import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderApp; import com.loopers.application.order.OrderInfo; import com.loopers.application.order.OrderItemCommand; import com.loopers.interfaces.api.ApiResponse; @@ -22,7 +22,7 @@ @RequiredArgsConstructor public class OrderV1Controller implements OrderV1ApiSpec { - private final OrderFacade orderFacade; + private final OrderApp orderApp; @PostMapping @Override @@ -33,7 +33,7 @@ public ResponseEntity> createOrder( .map(OrderV1Dto.OrderItemRequest::toCommand) .toList(); - OrderInfo info = orderFacade.createOrder(request.memberId(), items); + OrderInfo info = orderApp.createOrder(request.memberId(), items); return ResponseEntity.status(HttpStatus.CREATED) .body(ApiResponse.success(OrderV1Dto.OrderResponse.from(info))); } @@ -46,7 +46,7 @@ public ResponseEntity> getOrders( @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, Pageable pageable ) { - Page orders = orderFacade.getMyOrders( + Page orders = orderApp.getMyOrders( memberId, startDate != null ? startDate.atStartOfDay() : null, endDate != null ? endDate.plusDays(1).atStartOfDay() : null, @@ -61,7 +61,7 @@ public ResponseEntity> getOrder( @PathVariable String orderId, @RequestParam Long memberId ) { - OrderInfo info = orderFacade.getMyOrder(memberId, orderId); + OrderInfo info = orderApp.getMyOrder(memberId, orderId); return ResponseEntity.ok(ApiResponse.success(OrderV1Dto.OrderResponse.from(info))); } @@ -71,7 +71,7 @@ public ResponseEntity> cancelOrder( @PathVariable String orderId, @Valid @RequestBody OrderV1Dto.CancelOrderRequest request ) { - OrderInfo info = orderFacade.cancelOrder(request.memberId(), orderId); + OrderInfo info = orderApp.cancelOrder(request.memberId(), orderId); return ResponseEntity.ok(ApiResponse.success(OrderV1Dto.OrderResponse.from(info))); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/architecture/ApplicationLayerTest.java b/apps/commerce-api/src/test/java/com/loopers/architecture/ApplicationLayerTest.java index 7507b67a7..ea7184b3a 100644 --- a/apps/commerce-api/src/test/java/com/loopers/architecture/ApplicationLayerTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/architecture/ApplicationLayerTest.java @@ -20,13 +20,24 @@ static void setUp() { .importPackages("com.loopers"); } + @Test + @DisplayName("App은 application 패키지에 위치해야 함") + void apps_must_reside_in_application() { + ArchRule rule = classes() + .that().haveSimpleNameEndingWith("App") + .should().resideInAPackage("..application..") + .because("App은 단일 도메인 유스케이스를 처리하는 Application Layer에 위치해야 합니다"); + + rule.check(classes); + } + @Test @DisplayName("Facade는 application 패키지에 위치해야 함") void facades_must_reside_in_application() { ArchRule rule = classes() .that().haveSimpleNameEndingWith("Facade") .should().resideInAPackage("..application..") - .because("Facade는 유스케이스를 조합하는 Application Layer에 위치해야 합니다"); + .because("Facade는 2개 이상의 App을 조합하는 Application Layer에 위치해야 합니다"); rule.check(classes); } From a28cd64cc8bf8f33101e63f2633fa7d42e36b7da Mon Sep 17 00:00:00 2001 From: YoHanKi Date: Mon, 23 Feb 2026 21:44:29 +0900 Subject: [PATCH 47/50] =?UTF-8?q?refactor(application):=20Brand/Product=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20App/Facade=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/application/brand/BrandApp.java | 38 ++++++++++++ .../application/brand/BrandFacade.java | 24 ++----- .../application/product/ProductApp.java | 62 +++++++++++++++++++ .../application/product/ProductFacade.java | 37 +++++------ .../application/product/ProductInfo.java | 17 +++++ .../api/brand/BrandV1Controller.java | 9 +-- 6 files changed, 141 insertions(+), 46 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/brand/BrandApp.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductApp.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandApp.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandApp.java new file mode 100644 index 000000000..8191e0885 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandApp.java @@ -0,0 +1,38 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class BrandApp { + + private final BrandService brandService; + + @Transactional + public BrandInfo createBrand(String brandId, String brandName) { + BrandModel brand = brandService.createBrand(brandId, brandName); + return BrandInfo.from(brand); + } + + @Transactional(readOnly = true) + public BrandInfo getBrand(String brandId) { + BrandModel brand = brandService.getBrand(brandId); + return BrandInfo.from(brand); + } + + @Transactional + public BrandInfo deleteBrand(String brandId) { + BrandModel brand = brandService.deleteBrand(brandId); + return BrandInfo.from(brand); + } + + @Transactional(readOnly = true) + public BrandInfo getBrandByRefId(Long id) { + BrandModel brand = brandService.getBrandByRefId(id); + return BrandInfo.from(brand); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java index 3a6a9abdb..914c0f4a8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -1,8 +1,6 @@ package com.loopers.application.brand; -import com.loopers.domain.brand.BrandModel; -import com.loopers.domain.brand.BrandService; -import com.loopers.domain.product.ProductService; +import com.loopers.application.product.ProductApp; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -11,24 +9,12 @@ @Component public class BrandFacade { - private final BrandService brandService; - private final ProductService productService; - - @Transactional - public BrandInfo createBrand(String brandId, String brandName) { - BrandModel brand = brandService.createBrand(brandId, brandName); - return BrandInfo.from(brand); - } - - @Transactional(readOnly = true) - public BrandInfo getBrand(String brandId) { - BrandModel brand = brandService.getBrand(brandId); - return BrandInfo.from(brand); - } + private final BrandApp brandApp; + private final ProductApp productApp; @Transactional public void deleteBrand(String brandId) { - BrandModel brand = brandService.deleteBrand(brandId); - productService.deleteProductsByBrandRefId(brand.getId()); + BrandInfo brand = brandApp.deleteBrand(brandId); + productApp.deleteProductsByBrandRefId(brand.id()); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductApp.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductApp.java new file mode 100644 index 000000000..5934379d5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductApp.java @@ -0,0 +1,62 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; + +@RequiredArgsConstructor +@Component +public class ProductApp { + + private final ProductService productService; + + @Transactional + public ProductInfo createProduct(String productId, String brandId, String productName, BigDecimal price, int stockQuantity) { + ProductModel product = productService.createProduct(productId, brandId, productName, price, stockQuantity); + return ProductInfo.from(product); + } + + @Transactional(readOnly = true) + public ProductInfo getProduct(String productId) { + ProductModel product = productService.getProduct(productId); + return ProductInfo.from(product); + } + + @Transactional + public ProductInfo updateProduct(String productId, String productName, BigDecimal price, int stockQuantity) { + ProductModel product = productService.updateProduct(productId, productName, price, stockQuantity); + return ProductInfo.from(product); + } + + @Transactional + public void deleteProduct(String productId) { + productService.deleteProduct(productId); + } + + @Transactional(readOnly = true) + public Page getProducts(String brandId, String sortBy, Pageable pageable) { + return productService.getProducts(brandId, sortBy, pageable).map(ProductInfo::from); + } + + @Transactional(readOnly = true) + public ProductInfo getProductByRefId(Long id) { + ProductModel product = productService.getProductByRefId(id); + return ProductInfo.from(product); + } + + @Transactional(readOnly = true) + public long countLikes(Long productId) { + return productService.countLikes(productId); + } + + @Transactional + public void deleteProductsByBrandRefId(Long brandId) { + productService.deleteProductsByBrandRefId(brandId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index 3720a5c82..d75b8eded 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -1,14 +1,11 @@ package com.loopers.application.product; -import com.loopers.domain.brand.BrandModel; -import com.loopers.domain.brand.BrandService; -import com.loopers.domain.product.ProductModel; -import com.loopers.domain.product.ProductService; +import com.loopers.application.brand.BrandApp; +import com.loopers.application.brand.BrandInfo; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; @@ -16,47 +13,41 @@ @Component public class ProductFacade { - private final ProductService productService; - private final BrandService brandService; + private final ProductApp productApp; + private final BrandApp brandApp; - @Transactional public ProductInfo createProduct(String productId, String brandId, String productName, BigDecimal price, int stockQuantity) { - ProductModel product = productService.createProduct(productId, brandId, productName, price, stockQuantity); + ProductInfo product = productApp.createProduct(productId, brandId, productName, price, stockQuantity); return enrichProductInfo(product); } - @Transactional(readOnly = true) public ProductInfo getProduct(String productId) { - ProductModel product = productService.getProduct(productId); + ProductInfo product = productApp.getProduct(productId); return enrichProductInfo(product); } - @Transactional public ProductInfo updateProduct(String productId, String productName, BigDecimal price, int stockQuantity) { - ProductModel product = productService.updateProduct(productId, productName, price, stockQuantity); + ProductInfo product = productApp.updateProduct(productId, productName, price, stockQuantity); return enrichProductInfo(product); } - @Transactional public void deleteProduct(String productId) { - productService.deleteProduct(productId); + productApp.deleteProduct(productId); } - @Transactional(readOnly = true) public Page getProducts(String brandId, String sortBy, Pageable pageable) { - Page products = productService.getProducts(brandId, sortBy, pageable); + Page products = productApp.getProducts(brandId, sortBy, pageable); return products.map(this::enrichProductInfo); } - @Transactional(readOnly = true) public ProductInfo getProductByRefId(Long id) { - ProductModel product = productService.getProductByRefId(id); + ProductInfo product = productApp.getProductByRefId(id); return enrichProductInfo(product); } - private ProductInfo enrichProductInfo(ProductModel product) { - BrandModel brand = brandService.getBrandByRefId(product.getRefBrandId().value()); - long likesCount = productService.countLikes(product.getId()); - return ProductInfo.from(product, brand, likesCount); + private ProductInfo enrichProductInfo(ProductInfo product) { + BrandInfo brand = brandApp.getBrandByRefId(product.refBrandId()); + long likesCount = productApp.countLikes(product.id()); + return product.enrich(brand, likesCount); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java index 47f6a9aaa..61c1d8986 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java @@ -28,4 +28,21 @@ public static ProductInfo from(ProductModel product, BrandModel brand, long like likesCount ); } + + public static ProductInfo from(ProductModel product) { + return new ProductInfo( + product.getId(), + product.getProductId().value(), + product.getRefBrandId().value(), + product.getProductName().value(), + product.getPrice().value(), + product.getStockQuantity().value(), + null, + 0L + ); + } + + public ProductInfo enrich(BrandInfo brand, long likesCount) { + return new ProductInfo(id, productId, refBrandId, productName, price, stockQuantity, brand, likesCount); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java index 4f4c57ca3..000a3706d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java @@ -1,5 +1,6 @@ package com.loopers.interfaces.api.brand; +import com.loopers.application.brand.BrandApp; import com.loopers.application.brand.BrandFacade; import com.loopers.application.brand.BrandInfo; import com.loopers.interfaces.api.ApiResponse; @@ -12,12 +13,13 @@ @RequestMapping("/api/v1/brands") public class BrandV1Controller implements BrandV1ApiSpec { + private final BrandApp brandApp; private final BrandFacade brandFacade; @Override @GetMapping("/{brandId}") public ApiResponse getBrand(@PathVariable String brandId) { - BrandInfo info = brandFacade.getBrand(brandId); + BrandInfo info = brandApp.getBrand(brandId); return ApiResponse.success(BrandV1Dto.BrandResponse.fromInfo(info)); } @@ -26,9 +28,8 @@ public ApiResponse getBrand(@PathVariable String brand public ApiResponse createBrand( @Valid @RequestBody BrandV1Dto.CreateBrandRequest request ) { - BrandInfo info = brandFacade.createBrand(request.brandId(), request.brandName()); - BrandV1Dto.BrandResponse response = BrandV1Dto.BrandResponse.fromInfo(info); - return ApiResponse.success(response); + BrandInfo info = brandApp.createBrand(request.brandId(), request.brandName()); + return ApiResponse.success(BrandV1Dto.BrandResponse.fromInfo(info)); } @DeleteMapping("/{brandId}") From 60ed95bdc9d0f80f33b82c3843a8782ad2202ffe Mon Sep 17 00:00:00 2001 From: YoHanKi Date: Mon, 23 Feb 2026 21:52:22 +0900 Subject: [PATCH 48/50] =?UTF-8?q?refactor(like):=20LikeApp=20=EB=8F=84?= =?UTF-8?q?=EC=9E=85=20=EB=B0=8F=20LikeFacade=EB=A5=BC=20App=20=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=20=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EC=A0=84?= =?UTF-8?q?=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/application/like/LikeApp.java | 32 +++++++++++++ .../loopers/application/like/LikeFacade.java | 45 +++++++------------ .../loopers/application/like/LikeInfo.java | 8 +++- .../interfaces/api/like/LikeV1Controller.java | 8 ++-- 4 files changed, 57 insertions(+), 36 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/LikeApp.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApp.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApp.java new file mode 100644 index 000000000..23757af93 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApp.java @@ -0,0 +1,32 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.LikeModel; +import com.loopers.domain.like.LikeService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class LikeApp { + + private final LikeService likeService; + + @Transactional + public LikeInfo addLike(Long memberId, String productId) { + LikeModel like = likeService.addLike(memberId, productId); + return LikeInfo.from(like); + } + + @Transactional + public void removeLike(Long memberId, String productId) { + likeService.removeLike(memberId, productId); + } + + @Transactional(readOnly = true) + public Page getMyLikes(Long memberId, Pageable pageable) { + return likeService.getMyLikes(memberId, pageable).map(LikeInfo::from); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java index 402aec934..74a0e9fe2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -1,48 +1,33 @@ package com.loopers.application.like; -import com.loopers.domain.brand.BrandModel; -import com.loopers.domain.brand.BrandService; -import com.loopers.domain.like.LikeModel; -import com.loopers.domain.like.LikeService; -import com.loopers.domain.product.ProductModel; -import com.loopers.domain.product.ProductService; +import com.loopers.application.brand.BrandApp; +import com.loopers.application.brand.BrandInfo; +import com.loopers.application.product.ProductApp; +import com.loopers.application.product.ProductInfo; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; @Component @RequiredArgsConstructor public class LikeFacade { - private final LikeService likeService; - private final ProductService productService; - private final BrandService brandService; + private final LikeApp likeApp; + private final ProductApp productApp; + private final BrandApp brandApp; - @Transactional - public LikeInfo addLike(Long memberId, String productId) { - LikeModel like = likeService.addLike(memberId, productId); - return LikeInfo.from(like); - } - - @Transactional - public void removeLike(Long memberId, String productId) { - likeService.removeLike(memberId, productId); - } - - @Transactional(readOnly = true) public Page getMyLikedProducts(Long memberId, Pageable pageable) { - return likeService.getMyLikes(memberId, pageable) + return likeApp.getMyLikes(memberId, pageable) .map(like -> { - ProductModel product = productService.getProductByRefId(like.getRefProductId().value()); - BrandModel brand = brandService.getBrandByRefId(product.getRefBrandId().value()); + ProductInfo product = productApp.getProductByRefId(like.refProductId()); + BrandInfo brand = brandApp.getBrandByRefId(product.refBrandId()); return new LikedProductInfo( - product.getProductId().value(), - product.getProductName().value(), - brand.getBrandName().value(), - product.getPrice().value(), - like.getCreatedAt() + product.productId(), + product.productName(), + brand.brandName(), + product.price(), + like.likedAt() ); }); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java index 464fca31f..69de15646 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java @@ -2,16 +2,20 @@ import com.loopers.domain.like.LikeModel; +import java.time.ZonedDateTime; + public record LikeInfo( Long id, Long refMemberId, - Long refProductId + Long refProductId, + ZonedDateTime likedAt ) { public static LikeInfo from(LikeModel like) { return new LikeInfo( like.getId(), like.getRefMemberId().value(), - like.getRefProductId().value() + like.getRefProductId().value(), + like.getCreatedAt() ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java index 9393283ca..363e9bcbd 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java @@ -1,6 +1,6 @@ package com.loopers.interfaces.api.like; -import com.loopers.application.like.LikeFacade; +import com.loopers.application.like.LikeApp; import com.loopers.application.like.LikeInfo; import com.loopers.interfaces.api.ApiResponse; import jakarta.validation.Valid; @@ -15,7 +15,7 @@ @RequiredArgsConstructor public class LikeV1Controller { - private final LikeFacade likeFacade; + private final LikeApp likeApp; @PostMapping @ResponseStatus(HttpStatus.CREATED) @@ -23,7 +23,7 @@ public ApiResponse addLike( @PathVariable String productId, @Valid @RequestBody AddLikeRequest request ) { - LikeInfo info = likeFacade.addLike(request.memberId(), productId); + LikeInfo info = likeApp.addLike(request.memberId(), productId); return ApiResponse.success(LikeResponse.from(info)); } @@ -33,7 +33,7 @@ public ApiResponse removeLike( @PathVariable String productId, @Valid @RequestBody RemoveLikeRequest request ) { - likeFacade.removeLike(request.memberId(), productId); + likeApp.removeLike(request.memberId(), productId); return ApiResponse.success(null); } } From ed22994d85c6d581e64c42d515e74b0b5dc0cd14 Mon Sep 17 00:00:00 2001 From: YoHanKi Date: Mon, 23 Feb 2026 23:29:32 +0900 Subject: [PATCH 49/50] =?UTF-8?q?refactor(service):=20=ED=8A=B8=EB=9E=9C?= =?UTF-8?q?=EC=9E=AD=EC=85=98=20=EA=B2=BD=EA=B3=84=EB=A5=BC=20Service?= =?UTF-8?q?=EC=97=90=EC=84=9C=20App=20=EB=A0=88=EC=9D=B4=EC=96=B4=EB=A1=9C?= =?UTF-8?q?=20=EC=9D=B4=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/application/member/MemberApp.java | 4 ++++ .../main/java/com/loopers/domain/brand/BrandService.java | 5 ----- .../main/java/com/loopers/domain/like/LikeService.java | 4 ---- .../java/com/loopers/domain/member/MemberService.java | 4 ---- .../main/java/com/loopers/domain/order/OrderService.java | 5 ----- .../java/com/loopers/domain/product/ProductService.java | 9 --------- 6 files changed, 4 insertions(+), 27 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberApp.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberApp.java index 9c992dcca..c4d7b11e1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberApp.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberApp.java @@ -5,6 +5,7 @@ import com.loopers.domain.member.MemberService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor @@ -12,6 +13,7 @@ public class MemberApp { private final MemberService memberService; + @Transactional public MemberInfo register(String memberId, String password, String email, String birthDate, String name, Gender gender) { MemberModel member = memberService.register( @@ -20,11 +22,13 @@ public MemberInfo register(String memberId, String password, String email, return MemberInfo.from(member); } + @Transactional(readOnly = true) public MemberInfo authenticate(String loginId, String loginPw) { MemberModel member = memberService.authenticate(loginId, loginPw); return MemberInfo.from(member); } + @Transactional public void changePassword(String loginId, String loginPw, String currentPassword, String newPassword) { memberService.changePassword(loginId, loginPw, currentPassword, newPassword); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java index 67b646928..ebc5587bc 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -5,7 +5,6 @@ import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor @@ -13,7 +12,6 @@ public class BrandService { private final BrandRepository brandRepository; - @Transactional public BrandModel createBrand(String brandId, String brandName) { if (brandRepository.existsByBrandId(new BrandId(brandId))) { throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 브랜드 ID입니다."); @@ -23,19 +21,16 @@ public BrandModel createBrand(String brandId, String brandName) { return brandRepository.save(brand); } - @Transactional(readOnly = true) public BrandModel getBrand(String brandId) { return brandRepository.findByBrandId(new BrandId(brandId)) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 브랜드가 존재하지 않습니다.")); } - @Transactional(readOnly = true) public BrandModel getBrandByRefId(Long id) { return brandRepository.findById(id) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 브랜드가 존재하지 않습니다.")); } - @Transactional public BrandModel deleteBrand(String brandId) { BrandModel brand = brandRepository.findByBrandId(new BrandId(brandId)) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 브랜드가 존재하지 않습니다.")); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java index 4ce59c7b4..25788a704 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java @@ -12,7 +12,6 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor @@ -21,7 +20,6 @@ public class LikeService { private final LikeRepository likeRepository; private final ProductRepository productRepository; - @Transactional public LikeModel addLike(Long memberId, String productId) { // 상품 존재 확인 ProductModel product = productRepository.findByProductId(new ProductId(productId)) @@ -44,12 +42,10 @@ public LikeModel addLike(Long memberId, String productId) { }); } - @Transactional(readOnly = true) public Page getMyLikes(Long memberId, Pageable pageable) { return likeRepository.findByRefMemberId(new RefMemberId(memberId), pageable); } - @Transactional public void removeLike(Long memberId, String productId) { // 상품 존재 확인 ProductModel product = productRepository.findByProductId(new ProductId(productId)) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java index a8507b1d8..c62246246 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java @@ -6,7 +6,6 @@ import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor @@ -15,7 +14,6 @@ public class MemberService { private final MemberRepository memberRepository; private final PasswordHasher passwordHasher; - @Transactional public MemberModel register(String memberId, String rawPassword, String email, String birthDate, String name, Gender gender) { if (memberRepository.existsByMemberId(new MemberId(memberId))) { throw new CoreException(ErrorType.CONFLICT, "이미 가입된 ID 입니다."); @@ -25,7 +23,6 @@ public MemberModel register(String memberId, String rawPassword, String email, S return memberRepository.save(member); } - @Transactional(readOnly = true) public MemberModel authenticate(String loginId, String loginPw) { MemberModel member = memberRepository.findByMemberId(new MemberId(loginId)) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 회원이 존재하지 않습니다.")); @@ -35,7 +32,6 @@ public MemberModel authenticate(String loginId, String loginPw) { return member; } - @Transactional public void changePassword(String loginId, String loginPw, String currentPassword, String newPassword) { MemberModel member = memberRepository.findByMemberId(new MemberId(loginId)) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java index 595e60d1a..423a9ee08 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -11,7 +11,6 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.ArrayList; @@ -26,7 +25,6 @@ public class OrderService { private final OrderRepository orderRepository; private final ProductRepository productRepository; - @Transactional public OrderModel createOrder(Long memberId, List itemRequests) { // 1. 중복 상품 수량 합산 Map aggregatedItems = aggregateQuantities(itemRequests); @@ -65,7 +63,6 @@ public OrderModel createOrder(Long memberId, List itemRequests return orderRepository.save(order); } - @Transactional public OrderModel cancelOrder(Long memberId, String orderId) { OrderModel order = orderRepository.findByOrderId(new OrderId(orderId)) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 주문이 존재하지 않습니다.")); @@ -89,12 +86,10 @@ public OrderModel cancelOrder(Long memberId, String orderId) { return orderRepository.save(order); } - @Transactional(readOnly = true) public Page getMyOrders(Long memberId, LocalDateTime startDateTime, LocalDateTime endDateTime, Pageable pageable) { return orderRepository.findByRefMemberId(new RefMemberId(memberId), startDateTime, endDateTime, pageable); } - @Transactional(readOnly = true) public OrderModel getMyOrder(Long memberId, String orderId) { OrderModel order = orderRepository.findByOrderId(new OrderId(orderId)) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.")); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java index bb05f7f95..4f68512bc 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -10,7 +10,6 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.util.List; @@ -22,7 +21,6 @@ public class ProductService { private final ProductRepository productRepository; private final BrandRepository brandRepository; - @Transactional public ProductModel createProduct(String productId, String brandId, String productName, BigDecimal price, int stockQuantity) { // 중복 체크 if (productRepository.existsByProductId(new ProductId(productId))) { @@ -41,13 +39,11 @@ public ProductModel createProduct(String productId, String brandId, String produ return productRepository.save(product); } - @Transactional(readOnly = true) public ProductModel getProduct(String productId) { return productRepository.findByProductId(new ProductId(productId)) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 상품이 존재하지 않습니다.")); } - @Transactional public ProductModel updateProduct(String productId, String productName, BigDecimal price, int stockQuantity) { ProductModel product = productRepository.findByProductId(new ProductId(productId)) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 상품이 존재하지 않습니다.")); @@ -56,7 +52,6 @@ public ProductModel updateProduct(String productId, String productName, BigDecim return productRepository.save(product); } - @Transactional public void deleteProduct(String productId) { ProductModel product = productRepository.findByProductId(new ProductId(productId)) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 상품이 존재하지 않습니다.")); @@ -66,13 +61,11 @@ public void deleteProduct(String productId) { productRepository.save(product); } - @Transactional(readOnly = true) public ProductModel getProductByRefId(Long id) { return productRepository.findById(id) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 상품이 존재하지 않습니다.")); } - @Transactional(readOnly = true) public Page getProducts(String brandId, String sortBy, Pageable pageable) { // brandId가 제공되면 Brand PK로 변환 Long refBrandId = null; @@ -84,12 +77,10 @@ public Page getProducts(String brandId, String sortBy, Pageable pa return productRepository.findProducts(refBrandId, sortBy, pageable); } - @Transactional(readOnly = true) public long countLikes(Long productId) { return productRepository.countLikes(productId); } - @Transactional public void deleteProductsByBrandRefId(Long brandDbId) { List products = productRepository.findByRefBrandId(brandDbId); for (ProductModel product : products) { From 6d672bdccd70bb05a805a5192e85147474de3599 Mon Sep 17 00:00:00 2001 From: YoHanKi Date: Tue, 24 Feb 2026 01:04:19 +0900 Subject: [PATCH 50/50] =?UTF-8?q?refactor:=20Service=20=EB=8B=A8=EC=88=9C?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20App=EC=97=90=EC=84=9C=20Reposi?= =?UTF-8?q?tory=20=EC=A7=81=EC=A0=91=20=ED=98=B8=EC=B6=9C=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 18 +++++++------ .../loopers/application/brand/BrandApp.java | 11 ++++++-- .../com/loopers/application/like/LikeApp.java | 5 +++- .../loopers/application/order/OrderApp.java | 5 +++- .../application/product/ProductApp.java | 13 +++++++--- .../loopers/domain/brand/BrandService.java | 10 ------- .../com/loopers/domain/like/LikeService.java | 6 ----- .../loopers/domain/order/OrderService.java | 8 ------ .../domain/product/ProductService.java | 14 ---------- .../product/ProductJpaRepository.java | 4 +-- .../like/LikeServiceIntegrationTest.java | 19 ++++++++++---- .../OrderServiceCreateIntegrationTest.java | 2 ++ .../OrderServiceQueryIntegrationTest.java | 12 ++++++--- .../domain/order/OrderServiceTest.java | 23 ---------------- .../api/order/OrderV1ControllerE2ETest.java | 26 +++++++++---------- 15 files changed, 77 insertions(+), 99 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6919f444e..68f396a58 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,12 +55,12 @@ Infrastructure Layer (RepositoryImpl, JpaRepository, Converter) **Domain Layer** - 핵심 비즈니스 로직 - **Model**: JPA Entity, `BaseEntity` 상속, 정적 팩토리 `create()`, 도메인 행위 메서드 -- **Service**: 비즈니스 로직, 트랜잭션 관리, Repository를 통한 조회 및 저장 +- **Service**: 비즈니스 규칙 검증, 상태 변경, 크로스 도메인 조율 등 비즈니스 로직 담당. 단순 조회만 하는 경우 App에서 Repository 직접 호출 허용 - **Repository**: 데이터 조회 및 저장, `findByXXX().orElseThrow()` 패턴 사용 - **Value Object**: `record` 타입, Compact Constructor 검증, 불변 **Application Layer** - 유스케이스 조합 -- **App**: 단일 도메인 유스케이스 처리. Service 호출 및 Model → Info 변환 담당 +- **App**: 단일 도메인 유스케이스 처리. Service 또는 Repository 호출 및 Model → Info 변환 담당 - **Facade**: **2개 이상의 App을 조합**할 때만 사용. 크로스 도메인 오케스트레이션 **Interfaces Layer** - 외부 통신 @@ -126,7 +126,8 @@ Infrastructure Layer (RepositoryImpl, JpaRepository, Converter) - ❌ 테스트 임의 삭제/수정 금지 (`@Disabled`, assertion 약화 금지) - ❌ `var` 키워드 사용 금지 — 반드시 명시적 타입으로 선언 - ❌ `EntityManager` 직접 사용 금지 — JpaRepository `@Query`로 대체 -- ❌ App/Facade에서 Repository 직접 의존 금지 — 반드시 Service 경유 +- ❌ 비즈니스 로직(검증·상태변경·크로스도메인)이 있는 경우 App에서 Repository 직접 의존 금지 — 반드시 Service 경유 +- ✅ 단순 조회(비즈니스 규칙·상태 변경 없음)는 App에서 Repository 직접 의존 허용 - ❌ App → App 의존 금지 (크로스 도메인은 Facade 책임) - ❌ Facade → Facade 의존 금지 - ❌ Facade → Service 직접 호출 금지 — 반드시 App 경유 @@ -200,17 +201,18 @@ Infrastructure Layer (RepositoryImpl, JpaRepository, Converter) ## 아키텍처, 패키지 구성 전략 -- **레이어 의존성 방향 (단일 도메인)**: `Controller → App → Service → Repository` -- **레이어 의존성 방향 (크로스 도메인)**: `Controller → Facade → App(복수) → Service → Repository` +- **레이어 의존성 방향 (단일 도메인, 비즈니스 로직 있음)**: `Controller → App → Service → Repository` +- **레이어 의존성 방향 (단일 도메인, 단순 조회)**: `Controller → App → Repository` +- **레이어 의존성 방향 (크로스 도메인)**: `Controller → Facade → App(복수) → Service/Repository` - Infrastructure는 Domain 인터페이스 구현 (Port-Adapter). -- **App 원칙**: App은 단일 도메인의 유스케이스를 처리. Service 호출 및 Model → Info 변환 담당. 비즈니스 로직은 Service에 위임. +- **App 원칙**: App은 단일 도메인의 유스케이스를 처리. Model → Info 변환 담당. 비즈니스 로직은 Service에 위임. 단순 조회는 Repository 직접 호출 허용. - **Facade 사용 조건**: **2개 이상의 App을 조합할 때만** Facade를 사용. 단일 도메인은 App으로 처리. - **App 어노테이션**: App은 `@Component`, Service는 `@Service`, Facade는 `@Component` — 절대 혼용 금지. -- **App 의존성**: App → Service만 허용. App → Repository 직접 의존, App → App 의존은 금지. +- **App 의존성**: App → Service (비즈니스 로직), App → Repository (단순 조회) 허용. App → App 의존은 금지. - **Facade 의존성**: Facade → App만 허용 (2개 이상). Facade → Service 직접 호출, Facade → Repository 직접 의존, Facade → Facade 의존은 모두 금지. - **크로스 도메인 오케스트레이션**: 여러 도메인을 걸치는 연쇄 처리(cascade 등)는 Service가 아닌 Facade에서 App을 통해 조율. - **DTO vs Info vs Model 분리**: DTO(HTTP 계층) → Info(Application 결과 VO) → Model(Domain Entity), 각 레이어 독립성 유지. -- **Service 책임**: Service는 Repository를 통한 조회 및 저장, 비즈니스 규칙 검증, @Transactional 경계 관리. +- **Service 책임**: 비즈니스 규칙 검증, 상태 변경, 크로스 도메인 Repository 조율. 단순 조회만 하는 경우 Service 생략 가능. - **Service 크로스 도메인**: Service는 트랜잭션 원자성을 위해 타 도메인 Repository를 직접 사용 가능. 이때 해당 도메인 VO import도 허용. - **Repository Pattern**: Domain에 Repository 인터페이스(Port), Infrastructure에 구현체(Adapter), Domain이 Infrastructure를 모름. - **도메인 VO 소유권**: Model과 Repository 인터페이스는 자기 도메인 vo만 사용. `RefMemberId` 같은 참조 VO는 사용하는 도메인이 자기 vo 패키지에 별도 정의. diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandApp.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandApp.java index 8191e0885..fde80e6c4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandApp.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandApp.java @@ -1,7 +1,11 @@ package com.loopers.application.brand; import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandRepository; import com.loopers.domain.brand.BrandService; +import com.loopers.domain.brand.vo.BrandId; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -11,6 +15,7 @@ public class BrandApp { private final BrandService brandService; + private final BrandRepository brandRepository; @Transactional public BrandInfo createBrand(String brandId, String brandName) { @@ -20,7 +25,8 @@ public BrandInfo createBrand(String brandId, String brandName) { @Transactional(readOnly = true) public BrandInfo getBrand(String brandId) { - BrandModel brand = brandService.getBrand(brandId); + BrandModel brand = brandRepository.findByBrandId(new BrandId(brandId)) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 브랜드가 존재하지 않습니다.")); return BrandInfo.from(brand); } @@ -32,7 +38,8 @@ public BrandInfo deleteBrand(String brandId) { @Transactional(readOnly = true) public BrandInfo getBrandByRefId(Long id) { - BrandModel brand = brandService.getBrandByRefId(id); + BrandModel brand = brandRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 브랜드가 존재하지 않습니다.")); return BrandInfo.from(brand); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApp.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApp.java index 23757af93..97859e86b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApp.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApp.java @@ -1,6 +1,8 @@ package com.loopers.application.like; +import com.loopers.domain.common.vo.RefMemberId; import com.loopers.domain.like.LikeModel; +import com.loopers.domain.like.LikeRepository; import com.loopers.domain.like.LikeService; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -13,6 +15,7 @@ public class LikeApp { private final LikeService likeService; + private final LikeRepository likeRepository; @Transactional public LikeInfo addLike(Long memberId, String productId) { @@ -27,6 +30,6 @@ public void removeLike(Long memberId, String productId) { @Transactional(readOnly = true) public Page getMyLikes(Long memberId, Pageable pageable) { - return likeService.getMyLikes(memberId, pageable).map(LikeInfo::from); + return likeRepository.findByRefMemberId(new RefMemberId(memberId), pageable).map(LikeInfo::from); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderApp.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderApp.java index 298e9766d..7902dfd02 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderApp.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderApp.java @@ -1,7 +1,9 @@ package com.loopers.application.order; +import com.loopers.domain.common.vo.RefMemberId; import com.loopers.domain.order.OrderItemRequest; import com.loopers.domain.order.OrderModel; +import com.loopers.domain.order.OrderRepository; import com.loopers.domain.order.OrderService; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -17,6 +19,7 @@ public class OrderApp { private final OrderService orderService; + private final OrderRepository orderRepository; @Transactional public OrderInfo createOrder(Long memberId, List items) { @@ -40,7 +43,7 @@ public OrderInfo getMyOrder(Long memberId, String orderId) { @Transactional(readOnly = true) public Page getMyOrders(Long memberId, LocalDateTime startDateTime, LocalDateTime endDateTime, Pageable pageable) { - return orderService.getMyOrders(memberId, startDateTime, endDateTime, pageable) + return orderRepository.findByRefMemberId(new RefMemberId(memberId), startDateTime, endDateTime, pageable) .map(OrderInfo::from); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductApp.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductApp.java index 5934379d5..a89b1159e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductApp.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductApp.java @@ -1,7 +1,11 @@ package com.loopers.application.product; import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductRepository; import com.loopers.domain.product.ProductService; +import com.loopers.domain.product.vo.ProductId; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -15,6 +19,7 @@ public class ProductApp { private final ProductService productService; + private final ProductRepository productRepository; @Transactional public ProductInfo createProduct(String productId, String brandId, String productName, BigDecimal price, int stockQuantity) { @@ -24,7 +29,8 @@ public ProductInfo createProduct(String productId, String brandId, String produc @Transactional(readOnly = true) public ProductInfo getProduct(String productId) { - ProductModel product = productService.getProduct(productId); + ProductModel product = productRepository.findByProductId(new ProductId(productId)) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 상품이 존재하지 않습니다.")); return ProductInfo.from(product); } @@ -46,13 +52,14 @@ public Page getProducts(String brandId, String sortBy, Pageable pag @Transactional(readOnly = true) public ProductInfo getProductByRefId(Long id) { - ProductModel product = productService.getProductByRefId(id); + ProductModel product = productRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 상품이 존재하지 않습니다.")); return ProductInfo.from(product); } @Transactional(readOnly = true) public long countLikes(Long productId) { - return productService.countLikes(productId); + return productRepository.countLikes(productId); } @Transactional diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java index ebc5587bc..988fcee2f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -21,16 +21,6 @@ public BrandModel createBrand(String brandId, String brandName) { return brandRepository.save(brand); } - public BrandModel getBrand(String brandId) { - return brandRepository.findByBrandId(new BrandId(brandId)) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 브랜드가 존재하지 않습니다.")); - } - - public BrandModel getBrandByRefId(Long id) { - return brandRepository.findById(id) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 브랜드가 존재하지 않습니다.")); - } - public BrandModel deleteBrand(String brandId) { BrandModel brand = brandRepository.findByBrandId(new BrandId(brandId)) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 브랜드가 존재하지 않습니다.")); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java index 25788a704..465035e28 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java @@ -9,8 +9,6 @@ import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @Service @@ -42,10 +40,6 @@ public LikeModel addLike(Long memberId, String productId) { }); } - public Page getMyLikes(Long memberId, Pageable pageable) { - return likeRepository.findByRefMemberId(new RefMemberId(memberId), pageable); - } - public void removeLike(Long memberId, String productId) { // 상품 존재 확인 ProductModel product = productRepository.findByProductId(new ProductId(productId)) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java index 423a9ee08..ed540c663 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -1,6 +1,5 @@ package com.loopers.domain.order; -import com.loopers.domain.common.vo.RefMemberId; import com.loopers.domain.order.vo.OrderId; import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductRepository; @@ -8,11 +7,8 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; -import java.time.LocalDateTime; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -86,10 +82,6 @@ public OrderModel cancelOrder(Long memberId, String orderId) { return orderRepository.save(order); } - public Page getMyOrders(Long memberId, LocalDateTime startDateTime, LocalDateTime endDateTime, Pageable pageable) { - return orderRepository.findByRefMemberId(new RefMemberId(memberId), startDateTime, endDateTime, pageable); - } - public OrderModel getMyOrder(Long memberId, String orderId) { OrderModel order = orderRepository.findByOrderId(new OrderId(orderId)) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.")); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java index 4f68512bc..22aa5aee2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -39,11 +39,6 @@ public ProductModel createProduct(String productId, String brandId, String produ return productRepository.save(product); } - public ProductModel getProduct(String productId) { - return productRepository.findByProductId(new ProductId(productId)) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 상품이 존재하지 않습니다.")); - } - public ProductModel updateProduct(String productId, String productName, BigDecimal price, int stockQuantity) { ProductModel product = productRepository.findByProductId(new ProductId(productId)) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 상품이 존재하지 않습니다.")); @@ -61,11 +56,6 @@ public void deleteProduct(String productId) { productRepository.save(product); } - public ProductModel getProductByRefId(Long id) { - return productRepository.findById(id) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 상품이 존재하지 않습니다.")); - } - public Page getProducts(String brandId, String sortBy, Pageable pageable) { // brandId가 제공되면 Brand PK로 변환 Long refBrandId = null; @@ -77,10 +67,6 @@ public Page getProducts(String brandId, String sortBy, Pageable pa return productRepository.findProducts(refBrandId, sortBy, pageable); } - public long countLikes(Long productId) { - return productRepository.countLikes(productId); - } - public void deleteProductsByBrandRefId(Long brandDbId) { List products = productRepository.findByRefBrandId(brandDbId); for (ProductModel product : products) { diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java index 145422799..78825d8ec 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -39,14 +39,14 @@ public interface ProductJpaRepository extends JpaRepository ) Page findActiveSortByLikesDesc(@Param("refBrandId") Long refBrandId, Pageable pageable); - @Modifying + @Modifying(clearAutomatically = true) @Query( value = "UPDATE products SET stock_quantity = stock_quantity - :quantity WHERE id = :productId AND stock_quantity >= :quantity", nativeQuery = true ) int decreaseStockIfAvailable(@Param("productId") Long productId, @Param("quantity") int quantity); - @Modifying + @Modifying(clearAutomatically = true) @Query( value = "UPDATE products SET stock_quantity = stock_quantity + :quantity WHERE id = :productId", nativeQuery = true diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java index 70812b581..42af83c79 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java @@ -2,8 +2,11 @@ import com.loopers.domain.brand.BrandModel; import com.loopers.domain.brand.BrandService; +import com.loopers.domain.common.vo.RefMemberId; import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductRepository; import com.loopers.domain.product.ProductService; +import com.loopers.domain.product.vo.ProductId; import com.loopers.support.error.CoreException; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; @@ -28,9 +31,15 @@ class LikeServiceIntegrationTest { @Autowired private LikeService likeService; + @Autowired + private LikeRepository likeRepository; + @Autowired private ProductService productService; + @Autowired + private ProductRepository productRepository; + @Autowired private BrandService brandService; @@ -154,7 +163,7 @@ void getMyLikes_success() { likeService.addLike(memberId, "prod2"); // when - Page likes = likeService.getMyLikes(memberId, PageRequest.of(0, 10)); + Page likes = likeRepository.findByRefMemberId(new RefMemberId(memberId), PageRequest.of(0, 10)); // then assertAll( @@ -179,19 +188,19 @@ void getMyLikes_excludesDeletedProducts() { productService.deleteProduct("prod2"); // when - Page likes = likeService.getMyLikes(memberId, PageRequest.of(0, 10)); + Page likes = likeRepository.findByRefMemberId(new RefMemberId(memberId), PageRequest.of(0, 10)); // then assertThat(likes.getTotalElements()).isEqualTo(1); assertThat(likes.getContent().get(0).getRefProductId().value()) - .isEqualTo(productService.getProduct("prod1").getId()); + .isEqualTo(productRepository.findByProductId(new ProductId("prod1")).orElseThrow().getId()); } @Test @DisplayName("좋아요가 없으면 빈 목록 반환") void getMyLikes_noLikes_returnsEmpty() { // when - Page likes = likeService.getMyLikes(99L, PageRequest.of(0, 10)); + Page likes = likeRepository.findByRefMemberId(new RefMemberId(99L), PageRequest.of(0, 10)); // then assertThat(likes.getContent()).isEmpty(); @@ -209,7 +218,7 @@ void getMyLikes_onlyReturnsOwnLikes() { likeService.addLike(2L, "prod1"); // when - Page likes = likeService.getMyLikes(1L, PageRequest.of(0, 10)); + Page likes = likeRepository.findByRefMemberId(new RefMemberId(1L), PageRequest.of(0, 10)); // then assertThat(likes.getTotalElements()).isEqualTo(1); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceCreateIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceCreateIntegrationTest.java index 5a0f7f314..29dfbc272 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceCreateIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceCreateIntegrationTest.java @@ -13,6 +13,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.util.List; @@ -22,6 +23,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; @SpringBootTest +@Transactional @DisplayName("OrderService 주문 생성 통합 테스트") class OrderServiceCreateIntegrationTest { diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceQueryIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceQueryIntegrationTest.java index fece45024..be52c337b 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceQueryIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceQueryIntegrationTest.java @@ -1,6 +1,7 @@ package com.loopers.domain.order; import com.loopers.domain.brand.BrandService; +import com.loopers.domain.common.vo.RefMemberId; import com.loopers.domain.order.OrderItemRequest; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; @@ -14,6 +15,7 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; +import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.util.List; @@ -23,12 +25,16 @@ import static org.junit.jupiter.api.Assertions.assertAll; @SpringBootTest +@Transactional @DisplayName("OrderService 주문 조회 통합 테스트") class OrderServiceQueryIntegrationTest { @Autowired private OrderService orderService; + @Autowired + private OrderRepository orderRepository; + @Autowired private BrandService brandService; @@ -110,7 +116,7 @@ void getMyOrders_success() { orderService.createOrder(memberId, List.of(new OrderItemRequest("prod1", 1))); // when - Page orders = orderService.getMyOrders(memberId, null, null, PageRequest.of(0, 10)); + Page orders = orderRepository.findByRefMemberId(new RefMemberId(memberId), null, null, PageRequest.of(0, 10)); // then assertAll( @@ -129,7 +135,7 @@ void getMyOrders_onlyReturnsOwnOrders() { orderService.createOrder(memberId2, List.of(new OrderItemRequest("prod1", 1))); // when - Page orders = orderService.getMyOrders(memberId1, null, null, PageRequest.of(0, 10)); + Page orders = orderRepository.findByRefMemberId(new RefMemberId(memberId1), null, null, PageRequest.of(0, 10)); // then assertThat(orders.getTotalElements()).isEqualTo(1); @@ -139,7 +145,7 @@ void getMyOrders_onlyReturnsOwnOrders() { @DisplayName("주문이 없는 회원은 빈 목록 반환") void getMyOrders_noOrders_returnsEmpty() { // when - Page orders = orderService.getMyOrders(99L, null, null, PageRequest.of(0, 10)); + Page orders = orderRepository.findByRefMemberId(new RefMemberId(99L), null, null, PageRequest.of(0, 10)); // then assertThat(orders.getContent()).isEmpty(); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java index 0b10832b5..b49cf899b 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java @@ -1,6 +1,5 @@ package com.loopers.domain.order; -import com.loopers.domain.common.vo.RefMemberId; import com.loopers.domain.order.vo.OrderId; import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductRepository; @@ -13,12 +12,8 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; -import org.springframework.data.domain.PageRequest; import java.math.BigDecimal; -import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -195,24 +190,6 @@ void getMyOrder_notOwner_throwsForbidden() { .hasFieldOrPropertyWithValue("errorType", ErrorType.FORBIDDEN); } - @Test - @DisplayName("getMyOrders: 회원의 주문 목록 조회 성공") - void getMyOrders_success() { - // given - Long memberId = 1L; - OrderModel order = mock(OrderModel.class); - Page page = new PageImpl<>(List.of(order)); - when(orderRepository.findByRefMemberId( - any(RefMemberId.class), any(), any(), any() - )).thenReturn(page); - - // when - Page result = orderService.getMyOrders(memberId, null, null, PageRequest.of(0, 10)); - - // then - assertThat(result.getContent()).hasSize(1); - } - private ProductModel mockProduct(String productId, String productName, BigDecimal price, Long id) { ProductModel product = mock(ProductModel.class); when(product.getId()).thenReturn(id); diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderV1ControllerE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderV1ControllerE2ETest.java index 1635aa48b..504951ac6 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderV1ControllerE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderV1ControllerE2ETest.java @@ -1,9 +1,9 @@ package com.loopers.interfaces.api.order; +import com.loopers.application.order.OrderApp; +import com.loopers.application.order.OrderInfo; +import com.loopers.application.order.OrderItemCommand; import com.loopers.domain.brand.BrandService; -import com.loopers.domain.order.OrderModel; -import com.loopers.domain.order.OrderService; -import com.loopers.domain.order.OrderItemRequest; import com.loopers.domain.product.ProductRepository; import com.loopers.interfaces.api.ApiResponse; import com.loopers.utils.DatabaseCleanUp; @@ -39,7 +39,7 @@ class OrderV1ControllerE2ETest { private BrandService brandService; @Autowired - private OrderService orderService; + private OrderApp orderApp; @Autowired private ProductRepository productRepository; @@ -69,14 +69,14 @@ class GetOrder { void getOrder_success_returns200() { // given Long memberId = 1L; - OrderModel order = orderService.createOrder(memberId, List.of(new OrderItemRequest("prod1", 2))); + OrderInfo order = orderApp.createOrder(memberId, List.of(new OrderItemCommand("prod1", 2))); ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; // when ResponseEntity> response = restTemplate.exchange( - ORDERS_URL + "/" + order.getOrderId().value() + "?memberId=" + memberId, + ORDERS_URL + "/" + order.orderId() + "?memberId=" + memberId, HttpMethod.GET, null, responseType @@ -86,7 +86,7 @@ void getOrder_success_returns200() { assertAll( () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), () -> assertThat(response.getBody().data()).isNotNull(), - () -> assertThat(response.getBody().data().orderId()).isEqualTo(order.getOrderId().value()), + () -> assertThat(response.getBody().data().orderId()).isEqualTo(order.orderId()), () -> assertThat(response.getBody().data().refMemberId()).isEqualTo(memberId), () -> assertThat(response.getBody().data().items()).hasSize(1) ); @@ -113,11 +113,11 @@ void getOrder_notOwner_returns403() { // given Long ownerId = 1L; Long otherMemberId = 2L; - OrderModel order = orderService.createOrder(ownerId, List.of(new OrderItemRequest("prod1", 1))); + OrderInfo order = orderApp.createOrder(ownerId, List.of(new OrderItemCommand("prod1", 1))); // when ResponseEntity response = restTemplate.exchange( - ORDERS_URL + "/" + order.getOrderId().value() + "?memberId=" + otherMemberId, + ORDERS_URL + "/" + order.orderId() + "?memberId=" + otherMemberId, HttpMethod.GET, null, ApiResponse.class @@ -137,8 +137,8 @@ class GetOrders { void getOrders_success_returns200() { // given Long memberId = 1L; - orderService.createOrder(memberId, List.of(new OrderItemRequest("prod1", 1))); - orderService.createOrder(memberId, List.of(new OrderItemRequest("prod1", 1))); + orderApp.createOrder(memberId, List.of(new OrderItemCommand("prod1", 1))); + orderApp.createOrder(memberId, List.of(new OrderItemCommand("prod1", 1))); ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; @@ -187,8 +187,8 @@ void getOrders_onlyReturnsOwnOrders() { // given Long memberId1 = 1L; Long memberId2 = 2L; - orderService.createOrder(memberId1, List.of(new OrderItemRequest("prod1", 1))); - orderService.createOrder(memberId2, List.of(new OrderItemRequest("prod1", 1))); + orderApp.createOrder(memberId1, List.of(new OrderItemCommand("prod1", 1))); + orderApp.createOrder(memberId2, List.of(new OrderItemCommand("prod1", 1))); ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {};