From 49e1d7232d6a4c62c38deb79e59d4e2176100850 Mon Sep 17 00:00:00 2001 From: katiekim17 Date: Thu, 5 Feb 2026 01:01:34 +0900 Subject: [PATCH 01/39] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/member/MemberFacade.java | 31 ++++++ .../application/member/MemberInfo.java | 15 +++ .../com/loopers/config/SecurityConfig.java | 15 +++ .../loopers/domain/member/MemberModel.java | 99 +++++++++++++++++++ .../domain/member/MemberRepository.java | 9 ++ .../loopers/domain/member/MemberService.java | 39 ++++++++ .../member/MemberJpaRepository.java | 11 +++ .../member/MemberRepositoryImpl.java | 29 ++++++ .../api/member/MemberV1ApiSpec.java | 23 +++++ .../api/member/MemberV1Controller.java | 36 +++++++ .../interfaces/api/member/MemberV1Dto.java | 25 +++++ .../member/MemberServiceIntegrationTest.java | 91 +++++++++++++++++ build.gradle.kts | 49 +++++---- docker/infra-compose.yml | 2 +- modules/jpa/src/main/resources/jpa.yml | 2 +- 15 files changed, 455 insertions(+), 21 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 create mode 100644 apps/commerce-api/src/main/java/com/loopers/config/SecurityConfig.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.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..335399d2c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java @@ -0,0 +1,31 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.MemberModel; +import com.loopers.domain.member.MemberService; +import com.loopers.interfaces.api.member.MemberV1Dto; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class MemberFacade { + + private final MemberService memberService; + + public MemberInfo signupMember(MemberV1Dto.SignUpRequest request) { + // 1. Request → MemberModel로 변환 + MemberModel memberModel = new MemberModel( + request.loginId(), + request.password(), + request.name(), + request.birthDate(), + request.email() + ); + + // 2. Service 호출 (저장 + 중복 체크) + MemberModel saved = memberService.saveMember(memberModel); + + // 3. MemberModel → MemberInfo로 변환해서 반환 + return MemberInfo.from(saved); + } +} 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..2ae396246 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java @@ -0,0 +1,15 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.MemberModel; + +// MemberInfo는 Facade → Controller로 전달되는 데이터 +public record MemberInfo(String loginId, String name, String birthDate, String email) { + public static MemberInfo from(MemberModel model) { + return new MemberInfo( + model.getLoginId(), + model.getName(), + model.getBirthDate(), + model.getEmail() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/config/SecurityConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/SecurityConfig.java new file mode 100644 index 000000000..7e23f737a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/SecurityConfig.java @@ -0,0 +1,15 @@ +package com.loopers.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class SecurityConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} 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 new file mode 100644 index 000000000..4ba20abdf --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java @@ -0,0 +1,99 @@ +package com.loopers.domain.member; + +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 = "member") +public class MemberModel extends BaseEntity { + + private String loginId; + private String password ; + private String name; + private String birthDate; + private String email; + + protected MemberModel() {} + + public MemberModel(String loginId, String password, String name, String birthDate, String email) { + + // 모든 항목은 비어 있을 수 없다 + if (loginId == null || loginId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "아이디는 비어있을 수 없습니다."); + } + if (password == null || password.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 비어있을 수 없습니다."); + } + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이름은 비어있을 수 없습니다."); + } + if (birthDate == null || birthDate.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 비어있을 수 없습니다."); + } + if (email == null || email.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이메일은 비어있을 수 없습니다."); + } + + // 가입된 아이디로는 가입이 불가능하다 -> 디비에서 검증. 서비스에서 하기 + // 비밀번호 8~16자의 영문 대소문자, 숫자, 특수문자만 가능합니다. + // 비밀번호 생년월일은 비밀번호 내에 포함될 수 없습니다. + // 비밀번호 규칙 검증 + validatePassword(password, birthDate); + + this.loginId = loginId; + this.password = password; + this.name = name; + this.birthDate = birthDate; + this.email = email; + } + + public String getLoginId() { + return loginId; + } + + public String getPassword() { + return password; + } + + public String getName() { + return name; + } + + public String getBirthDate() { + return birthDate; + } + + public String getEmail() { + return email; + } + + private void validatePassword(String password, String birthDate) { + // 1. 8~16자 길이 체크 + if (password.length() < 8 || password.length() > 16) { + throw new CoreException(ErrorType.BAD_REQUEST, + "비밀번호는 8~16자여야 합니다."); + } + + // 2. 영문 대소문자, 숫자, 특수문자만 허용 (한글, 공백 등 불가) + if (!password.matches("^[a-zA-Z0-9!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?]+$")) { + throw new CoreException(ErrorType.BAD_REQUEST, + "비밀번호는 영문 대소문자, 숫자, 특수문자만 가능합니다."); + } + + // 3. 생년월일이 비밀번호에 포함되면 안됨 + if (password.contains(birthDate)) { + throw new CoreException(ErrorType.BAD_REQUEST, + "비밀번호에 생년월일을 포함할 수 없습니다."); + } + } + + // 암호화된 비밀번호를 엔티티에 넣어주기 + public void encryptPassword(String encryptedPassword) { + this.password = encryptedPassword; + } + + +} 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 new file mode 100644 index 000000000..1d703f710 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java @@ -0,0 +1,9 @@ +package com.loopers.domain.member; + +import java.util.Optional; + +public interface MemberRepository { + MemberModel save(MemberModel memberModel); + Optional update(MemberModel memberModel); + Optional findByLoginId(String id); +} 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 new file mode 100644 index 000000000..ec62150ab --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java @@ -0,0 +1,39 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class MemberService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional(readOnly = false) + public MemberModel saveMember(MemberModel memberModel) { + //저장하기 전에 이미 같은 loginId가 있는지 확인 + Optional existing = memberRepository.findByLoginId(memberModel.getLoginId()); + if (existing.isPresent()) { + throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 아이디입니다."); + } + + // 비밀번호 암호화 후 저장 + String encrypted = passwordEncoder.encode(memberModel.getPassword()); + memberModel.encryptPassword(encrypted); + memberRepository.save(memberModel); + + return getMember(memberModel.getLoginId()); + } + + @Transactional(readOnly = true) + public MemberModel getMember(String id) { + return memberRepository.findByLoginId(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + id + "] 회원을 찾을 수 없습니다.")); + } +} 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 new file mode 100644 index 000000000..0bcc2c78b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.MemberModel; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MemberJpaRepository extends JpaRepository { + + Optional findByLoginId(String loginId); + MemberModel save(MemberModel memberModel); +} 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 new file mode 100644 index 000000000..17e86c5de --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java @@ -0,0 +1,29 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.MemberModel; +import com.loopers.domain.member.MemberRepository; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class MemberRepositoryImpl implements MemberRepository { + + private final MemberJpaRepository memberJpaRepository; + + @Override + public MemberModel save(MemberModel memberModel) { + return memberJpaRepository.save(memberModel); + } + + @Override + public Optional update(MemberModel memberModel) { + return Optional.empty(); + } + + @Override + public Optional findByLoginId(String id) { + return memberJpaRepository.findByLoginId(id); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java new file mode 100644 index 000000000..c312ab9fe --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java @@ -0,0 +1,23 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.member.MemberV1Dto.SignUpResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.PostMapping; + +@Tag(name = "Member V1 API", description = "회원 API") +public interface MemberV1ApiSpec { + + @Operation( + summary = "회원 가입 요청", + description = "주어진 정보를 가지고 회원 가입을 실행한다" + ) + // @Schema는 Swagger API 문서에서 파라미터 설명을 보여주는 용도 + // 예제에서는 Long exampleId 같은 단일 파라미터에 붙였는데, 지금은 SignUpRequest로 통째로 받으니까 여기엔 필요 없음 + ApiResponse signUp( + MemberV1Dto.SignUpRequest request + ); + +} 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 new file mode 100644 index 000000000..744937a78 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java @@ -0,0 +1,36 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.application.member.MemberFacade; +import com.loopers.application.member.MemberInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.member.MemberV1Dto.SignUpRequest; +import com.loopers.interfaces.api.member.MemberV1Dto.SignUpResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/members") +public class MemberV1Controller implements MemberV1ApiSpec { + + // 클라이언트 → [SignUpRequest (record)] → Controller → Facade → Service → [MemberModel (entity)] → DB + // 요청 데이터 전달용 DB에 저장되는 객체 + // DB → [MemberModel (entity)] → Facade → [SignUpResponse (record)] → Controller → 클라이언트 + // DB에서 꺼낸 객체 응답 데이터 전달용 + private final MemberFacade memberFacade; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @Override + public ApiResponse signUp(@RequestBody SignUpRequest request) { + MemberInfo info = memberFacade.signupMember(request); + MemberV1Dto.SignUpResponse response = MemberV1Dto.SignUpResponse.from(info); + return ApiResponse.success(response); + } + +} 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 new file mode 100644 index 000000000..e15452995 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java @@ -0,0 +1,25 @@ +package com.loopers.interfaces.api.member; + + +import com.loopers.application.member.MemberInfo; + +public class MemberV1Dto { + + // Request: POST방식으로 보낼때 데이터를 담는 그릇 (from 필요 없음) + public record SignUpRequest( + String loginId, + String password, + String name, + String birthDate, + String email + ) {} + + // Response: 변환 메서드(from)가 여기에! + public record SignUpResponse(String loginId) { + public static SignUpResponse from(MemberInfo info) { + return new SignUpResponse(info.loginId()); + } + } + + +} 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 new file mode 100644 index 000000000..7cb23df8a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java @@ -0,0 +1,91 @@ +package com.loopers.domain.member; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +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; + +@SpringBootTest +class MemberServiceIntegrationTest { + + @Autowired + private MemberService memberService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("회원가입을 성공한다") + @Nested + class SaveMember { + + @DisplayName("회원가입에 필요한 정보가 들어오면 디비에 저장하고 저장한 아이디를 조회한다") + @Test + void returnsMemberInfo_whenValidMemberInfoIsProvided() { + // arrange + MemberModel memberModel = new MemberModel("testuser", "Test1234!", "홍길동", "19900101", "test@example.com"); + + // act + memberService.saveMember(memberModel); + MemberModel result = memberService.getMember(memberModel.getLoginId()); + + // assert + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result.getLoginId()).isEqualTo(memberModel.getLoginId()), + () -> assertThat(result.getName()).isEqualTo(memberModel.getName()), + () -> assertThat(result.getBirthDate()).isEqualTo(memberModel.getBirthDate()), + () -> assertThat(result.getEmail()).isEqualTo(memberModel.getEmail()) + ); + } + + @DisplayName("중복 ID로 가입 시도하면 예외가 발생한다") + @Test + void throwsException_whenExistIdIsTryToSaveMember() { + // arrange - 먼저 한 명 가입시키기 + memberService.saveMember(new MemberModel("testuser", "Test1234!", "홍길동", "19900101", "test@example.com")); + + // act - 같은 ID로 또 가입 시도 + CoreException exception = assertThrows(CoreException.class, () -> { + memberService.saveMember(new MemberModel("testuser", "Test1234!", "홍길동", "19900101", "test@example.com")); + }); + + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + + } + + @DisplayName("회원을 조회할 때, ") + @Nested + class GetMember { + + @DisplayName("존재하지 않는 ID로 조회하면 예외가 발생한다") + @Test + void throwsException_whenMemberNotFound() { + // arrange + String loginId = "testuser"; // Assuming this ID does not exist + + // act + CoreException exception = assertThrows(CoreException.class, () -> { + memberService.getMember(loginId); + }); + + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 9c8490b8a..6f9ce28b5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -34,10 +34,16 @@ allprojects { } subprojects { + val containerProjects = setOf("apps", "modules", "supports") + apply(plugin = "java") apply(plugin = "org.springframework.boot") apply(plugin = "io.spring.dependency-management") - apply(plugin = "jacoco") + + // TODO: JDK 24 환경에서 JacocoReport 태스크 생성 오류 발생 - JDK 21로 전환 후 활성화 필요 + // if (name !in containerProjects) { + // apply(plugin = "jacoco") + // } dependencyManagement { imports { @@ -55,6 +61,8 @@ subprojects { // Lombok implementation("org.projectlombok:lombok") annotationProcessor("org.projectlombok:lombok") + // 암호화 + implementation ("org.springframework.security:spring-security-crypto") // Test testRuntimeOnly("org.junit.platform:junit-platform-launcher") // testcontainers:mysql 이 jdbc 사용함 @@ -85,24 +93,27 @@ subprojects { jvmArgs("-Xshare:off") } - tasks.withType { - mustRunAfter("test") - executionData(fileTree(layout.buildDirectory.asFile).include("jacoco/*.exec")) - reports { - xml.required = true - csv.required = false - html.required = false - } - afterEvaluate { - classDirectories.setFrom( - files( - classDirectories.files.map { - fileTree(it) - }, - ), - ) - } - } + // TODO: JDK 24 환경에서 JacocoReport 태스크 생성 오류 발생 - JDK 21로 전환 후 활성화 필요 + // if (name !in containerProjects) { + // tasks.withType { + // mustRunAfter("test") + // executionData(fileTree(layout.buildDirectory.asFile).include("jacoco/*.exec")) + // reports { + // xml.required = true + // csv.required = false + // html.required = false + // } + // afterEvaluate { + // classDirectories.setFrom( + // files( + // classDirectories.files.map { + // fileTree(it) + // }, + // ), + // ) + // } + // } + // } } // module-container 는 task 를 실행하지 않도록 한다. diff --git a/docker/infra-compose.yml b/docker/infra-compose.yml index 18e5fcf5f..6921cb397 100644 --- a/docker/infra-compose.yml +++ b/docker/infra-compose.yml @@ -3,7 +3,7 @@ services: mysql: image: mysql:8.0 ports: - - "3306:3306" + - "3307:3306" environment: - MYSQL_ROOT_PASSWORD=root - MYSQL_USER=application diff --git a/modules/jpa/src/main/resources/jpa.yml b/modules/jpa/src/main/resources/jpa.yml index 37f4fb1b0..8b90b1c7b 100644 --- a/modules/jpa/src/main/resources/jpa.yml +++ b/modules/jpa/src/main/resources/jpa.yml @@ -42,7 +42,7 @@ spring: datasource: mysql-jpa: main: - jdbc-url: jdbc:mysql://localhost:3306/loopers + jdbc-url: jdbc:mysql://localhost:3307/loopers username: application password: application From d4a67a77a40a4e51ffef11e0bed1ee9d90c4ade8 Mon Sep 17 00:00:00 2001 From: katiekim17 Date: Thu, 5 Feb 2026 01:02:14 +0900 Subject: [PATCH 02/39] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 166 ++++++++++++++++++ .../domain/member/MemberModelTest.java | 118 +++++++++++++ .../interfaces/api/MemberV1ApiE2ETest.java | 100 +++++++++++ 3 files changed, 384 insertions(+) create mode 100644 CLAUDE.md create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..e57d39ca2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,166 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Tech Stack & Versions + +| Category | Technology | Version | +|----------|------------|---------| +| Language | Java | 21 | +| Framework | Spring Boot | 3.4.4 | +| Dependency Management | Spring Dependency Management | 1.1.7 | +| Cloud | Spring Cloud | 2024.0.1 | +| Build Tool | Gradle (Kotlin DSL) | 8.13+ | +| API Documentation | SpringDoc OpenAPI | 2.7.0 | +| ORM | Spring Data JPA + QueryDSL | (managed by Spring Boot) | +| Database | MySQL | 8.0 | +| Cache | Redis (Master-Replica) | - | +| Messaging | Kafka | 3.5.1 | +| Monitoring | Micrometer + Prometheus | (managed by Spring Boot) | +| Logging | Logback + Slack Appender | 1.6.1 | +| Testing | JUnit 5, Mockito 5.14.0, SpringMockk 4.0.2, Instancio 5.0.2 | - | +| Containers | TestContainers | (managed by Spring Boot) | + +## Build & Run Commands + +```bash +# Build all modules +./gradlew build + +# Run tests (profile: test, timezone: Asia/Seoul) +./gradlew test + +# Run specific app +./gradlew :apps:commerce-api:bootRun +./gradlew :apps:commerce-batch:bootRun --args='--job.name=jobName' +./gradlew :apps:commerce-streamer:bootRun + +# Build specific module +./gradlew :apps:commerce-api:build + +# Run single test class +./gradlew test --tests "com.loopers.ExampleServiceIntegrationTest" + +# Run single test method +./gradlew test --tests "com.loopers.ExampleServiceIntegrationTest.testMethodName" + +# Test with coverage report +./gradlew test jacocoTestReport +``` + +**Java version**: 21 (configured via Gradle toolchain) + +## Local Infrastructure + +```bash +# Start MySQL, Redis (master+replica), Kafka +docker-compose -f docker/infra-compose.yml up + +# Start Prometheus + Grafana monitoring +docker-compose -f docker/monitoring-compose.yml up +``` + +- MySQL: localhost:3307 (root/root, application/application) +- Redis Master: localhost:6379, Replica: localhost:6380 +- Kafka: localhost:19092, Kafka UI: localhost:9099 +- Grafana: localhost:3000 (admin/admin) + +## Architecture + +### Multi-Module Structure + +``` +loopers-java-spring-template/ +├── apps/ # Executable Spring Boot applications +│ ├── commerce-api # REST API (web, actuator, springdoc-openapi) +│ ├── commerce-batch # Batch jobs (spring-batch) +│ └── commerce-streamer # Event streaming (web, kafka) +├── modules/ # Reusable infrastructure configurations +│ ├── jpa # JPA, QueryDSL, MySQL connector +│ ├── redis # Spring Data Redis (master-replica) +│ └── kafka # Spring Kafka +└── supports/ # Cross-cutting add-on modules + ├── jackson # Jackson serialization (Kotlin module, JSR310) + ├── logging # Logback, Slack appender + └── monitoring # Micrometer, Prometheus registry +``` + +### Module Dependencies + +| App | modules | supports | +|-----|---------|----------| +| commerce-api | jpa, redis | jackson, logging, monitoring | +| commerce-batch | jpa, redis | jackson, logging, monitoring | +| commerce-streamer | jpa, redis, kafka | jackson, logging, monitoring | + +### Layer Architecture (commerce-api) +``` +interfaces/api/ → Controllers, DTOs, OpenAPI specs +application/ → Facades (use case orchestration) +domain/ → Entities, Services, Repository interfaces +infrastructure/ → Repository implementations, adapters +``` + +### Key Patterns +- **Controllers**: Implement `*ApiSpec` interfaces for OpenAPI documentation +- **Facades**: Orchestrate domain services, convert domain models to DTOs +- **Services**: `@Component` with `@Transactional`, contain business logic +- **Repositories**: Interface in `domain/`, implementation in `infrastructure/` +- **Entities**: Extend `BaseEntity` (provides id, createdAt, updatedAt, deletedAt) +- **Response wrapper**: All APIs return `ApiResponse` +- **Error handling**: `CoreException` with `ErrorType` enum, caught by `ApiControllerAdvice` + +### Soft Delete +Entities use `deletedAt` field via `BaseEntity`: +```java +entity.delete(); // marks as deleted +entity.restore(); // restores +``` + +## Configuration + +- Profile-based: local, test, dev, qa, prd +- Config imports in application.yml: jpa.yml, redis.yml, logging.yml, monitoring.yml +- Management endpoints on port 8081 (/health, /prometheus) + +## Testing + +- Framework: JUnit 5 + AssertJ + Mockito + SpringMockk + Instancio +- `DatabaseCleanUp` utility truncates tables between tests (from jpa test fixtures) +- `RedisCleanUp` available from redis test fixtures +- TestContainers support for MySQL, Redis, Kafka + +## 개발 규칙 + +### 진행 Workflow - 증강 코딩 +- **대원칙**: 방향성 및 주요 의사 결정은 개발자에게 제안만 할 수 있으며, 최종 승인된 사항을 기반으로 작업 수행 +- **중간 결과 보고**: AI가 반복적인 동작을 하거나, 요청하지 않은 기능을 구현, 테스트 삭제를 임의로 진행할 경우 개발자가 개입 +- **설계 주도권 유지**: AI가 임의판단을 하지 않고, 방향성에 대한 제안 등을 진행할 수 있으나 개발자의 승인을 받은 후 수행 + +### 개발 Workflow - TDD (Red → Green → Refactor) +- 모든 테스트는 3A 원칙으로 작성 (Arrange - Act - Assert) + +| Phase | 설명 | +|-------|------| +| **Red** | 요구사항을 만족하는 실패하는 테스트 케이스 먼저 작성 | +| **Green** | Red Phase의 테스트가 모두 통과할 수 있는 최소한의 코드 작성 (오버엔지니어링 금지) | +| **Refactor** | 불필요한 private 함수 지양, 객체지향적 코드 작성, unused import 제거, 성능 최적화. 모든 테스트 통과 필수 | + +### 주의사항 + +**Never Do:** +- 실제 동작하지 않는 코드, 불필요한 Mock 데이터를 이용한 구현 금지 +- null-safety 하지 않은 코드 작성 금지 (Java의 경우 Optional 활용) +- println 코드 남기지 말 것 + +**Recommendation:** +- 실제 API를 호출해 확인하는 E2E 테스트 코드 작성 +- 재사용 가능한 객체 설계 +- 성능 최적화에 대한 대안 및 제안 +- 개발 완료된 API는 `http/*.http` 파일에 분류해 작성 + +**Priority:** +1. 실제 동작하는 해결책만 고려 +2. null-safety, thread-safety 고려 +3. 테스트 가능한 구조로 설계 +4. 기존 코드 패턴 분석 후 일관성 유지 diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java new file mode 100644 index 000000000..0d0cfd1fb --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java @@ -0,0 +1,118 @@ +package com.loopers.domain.member; + +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.assertThrows; + +class MemberModelTest { + + @DisplayName("회원 모델을 생성할 때, ") + @Nested + class Create { + + @DisplayName("(성공케이스) 필수 정보가 모두 주어지면, 정상적으로 생성된다.") + @Test + void createsMemberModel_whenAllFieldsAreProvided() { + // arrange + String loginId = "testuser"; + String rawPassword = "Test1234!"; + String name = "홍길동"; + String birthDate = "19900101"; + String email = "test@example.com"; + + // act + MemberModel member = new MemberModel(loginId, rawPassword, name, birthDate, email); + + // assert + assertThat(member.getLoginId()).isEqualTo(loginId); + assertThat(member.getPassword()).isNotEqualTo(rawPassword); + assertThat(member.getName()).isEqualTo(name); + assertThat(member.getBirthDate()).isEqualTo(birthDate); + assertThat(member.getEmail()).isEqualTo(email); + // 비밀번호는 암호화되어 저장되므로 원본과 다를 수 있음 - 나중에 검증 방식 결정 + } + + + @DisplayName("(실패케이스) 비밀번호가 7자일때, 예외발생.") + @Test + void throwsBadRequestException_whenPwIsOutOfRange() { + // arrange + String loginId = "testuser"; + String password = "Test12!"; + String name = "홍길동"; + String birthDate = "19900101"; + String email = "test@example.com"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new MemberModel(loginId, password, name, birthDate, email); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + + @DisplayName("비밀번호가 17자일 때 → 예외 발생") + @Test + void throwsBadRequestException_whenPwIsOutOfRange2() { + // arrange + String loginId = "testuser"; + String password = "Test123456789012!"; + String name = "홍길동"; + String birthDate = "19900101"; + String email = "test@example.com"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new MemberModel(loginId, password, name, birthDate, email); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("비밀번호에 한글이 있을 때 → 예외 발생") + @Test + void throwsBadRequestException_whenPwIsKorean() { + // arrange + String loginId = "testuser"; + String password = "Test홍길동890123!"; + String name = "홍길동"; + String birthDate = "19900101"; + String email = "test@example.com"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new MemberModel(loginId, password, name, birthDate, email); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("비밀번호에 생년월일이 포함될 때 → 예외 발생") + @Test + void throwsBadRequestException_whenPwContainsBirthDate() { + // arrange + String loginId = "testuser"; + String password = "Test19900101!"; + String name = "홍길동"; + String birthDate = "19900101"; + String email = "test@example.com"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new MemberModel(loginId, password, name, birthDate, email); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java new file mode 100644 index 000000000..8e2f27f19 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java @@ -0,0 +1,100 @@ +package com.loopers.interfaces.api; + +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.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class MemberV1ApiE2ETest { + + private static final String ENDPOINT = "/api/v1/members"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public MemberV1ApiE2ETest( + TestRestTemplate testRestTemplate, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("POST /api/v1/members (회원가입)") + @Nested + class SignUp { + + @DisplayName("유효한 회원 정보를 보내면, 201 Created와 생성된 ID를 반환한다.") + @Test + void returnsCreated_whenValidMemberInfoIsProvided() { + // arrange + Map request = Map.of( + "loginId", "testuser", + "password", "Test1234!", + "name", "홍길동", + "birthDate", "19900101", + "email", "test@example.com" + ); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT, + HttpMethod.POST, + new HttpEntity<>(request), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(response.getBody().data().get("loginId")).isNotNull() + ); + } + + @DisplayName("중복된 loginId로 가입하면, 409 Conflict를 반환한다.") + @Test + void returnsConflict_whenDuplicateLoginIdIsProvided() { + // arrange - 먼저 한 명 가입 + Map request = Map.of( + "loginId", "testuser", + "password", "Test1234!", + "name", "홍길동", + "birthDate", "19900101", + "email", "test@example.com" + ); + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), new ParameterizedTypeReference>>() {}); + + // act - 같은 ID로 다시 가입 + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT, + HttpMethod.POST, + new HttpEntity<>(request), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } + } +} From 5860d3398d0f1859732dd116d490ef12e0d1e996 Mon Sep 17 00:00:00 2001 From: ksonepick-dev Date: Thu, 5 Feb 2026 18:57:17 +0900 Subject: [PATCH 03/39] =?UTF-8?q?feat:=20=EB=82=B4=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/member/MemberFacade.java | 11 +++-- .../application/member/MemberInfo.java | 2 +- .../loopers/domain/member/MemberModel.java | 46 +++++++++++++++++-- .../domain/member/MemberRepository.java | 2 +- .../loopers/domain/member/MemberService.java | 7 +-- .../api/member/MemberV1ApiSpec.java | 4 ++ .../api/member/MemberV1Controller.java | 26 +++++++---- .../domain/member/MemberModelTest.java | 2 +- .../member/MemberServiceIntegrationTest.java | 30 ++++++++++-- 9 files changed, 103 insertions(+), 27 deletions(-) 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 index 335399d2c..12272ab3f 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/MemberFacade.java @@ -25,7 +25,12 @@ public MemberInfo signupMember(MemberV1Dto.SignUpRequest request) { // 2. Service 호출 (저장 + 중복 체크) MemberModel saved = memberService.saveMember(memberModel); - // 3. MemberModel → MemberInfo로 변환해서 반환 - return MemberInfo.from(saved); - } + // 3. MemberModel → MemberInfo로 변환해서 반환 + return MemberInfo.from(saved); + } + + public MemberInfo getMyInfo(String loginId) { + MemberModel member = memberService.getMember(loginId); + return MemberInfo.from(member); + } } 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 index 2ae396246..38b998900 100644 --- 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 @@ -7,7 +7,7 @@ public record MemberInfo(String loginId, String name, String birthDate, String e public static MemberInfo from(MemberModel model) { return new MemberInfo( model.getLoginId(), - model.getName(), + model.getMaskedName(), model.getBirthDate(), model.getEmail() ); 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 4ba20abdf..bb3b422fd 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 @@ -37,11 +37,14 @@ public MemberModel(String loginId, String password, String name, String birthDat throw new CoreException(ErrorType.BAD_REQUEST, "이메일은 비어있을 수 없습니다."); } - // 가입된 아이디로는 가입이 불가능하다 -> 디비에서 검증. 서비스에서 하기 - // 비밀번호 8~16자의 영문 대소문자, 숫자, 특수문자만 가능합니다. - // 비밀번호 생년월일은 비밀번호 내에 포함될 수 없습니다. - // 비밀번호 규칙 검증 - validatePassword(password, birthDate); + // 가입된 아이디로는 가입이 불가능하다 -> 디비에서 검증. 서비스에서 하기 + // 가입하는 로그인 ID는 영문과 숫자만 허용한다 + validateLoginId(loginId); + + // 비밀번호 8~16자의 영문 대소문자, 숫자, 특수문자만 가능합니다. + // 비밀번호 생년월일은 비밀번호 내에 포함될 수 없습니다. + // 비밀번호 규칙 검증 + validatePassword(password, birthDate); this.loginId = loginId; this.password = password; @@ -50,6 +53,11 @@ public MemberModel(String loginId, String password, String name, String birthDat this.email = email; } + public MemberModel(String loginId) { + // 가입하는 로그인 ID는 영문과 숫자만 허용한다 + validateLoginId(loginId); + } + public String getLoginId() { return loginId; } @@ -70,6 +78,16 @@ public String getEmail() { return email; } + private void validateLoginId(String loginId) { + // 로그인 ID 는 영문과 숫자만 허용 + if (!loginId.matches("^[a-zA-Z0-9]+$")) { + throw new CoreException( + ErrorType.BAD_REQUEST, + "로그인 아이디는 영문자와 숫자만 사용할 수 있습니다." + ); + } + } + private void validatePassword(String password, String birthDate) { // 1. 8~16자 길이 체크 if (password.length() < 8 || password.length() > 16) { @@ -95,5 +113,23 @@ public void encryptPassword(String encryptedPassword) { this.password = encryptedPassword; } + // 이름 마지막 글자에 마스킹 추가 + public String maskLastChar(String name) { + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("이름은 비어 있을 수 없습니다."); + } + + if (name.length() == 1) { + return "*"; + } + + return name.substring(0, name.length() - 1) + "*"; + } + + // 마스킹된 이름 가져오기 + public String getMaskedName(){ + return maskLastChar(this.name); + } + } 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 1d703f710..b86afc71d 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 @@ -4,6 +4,6 @@ public interface MemberRepository { MemberModel save(MemberModel memberModel); - Optional update(MemberModel memberModel); Optional findByLoginId(String id); + Optional update(MemberModel memberModel); } 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 ec62150ab..bb6cca4a3 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 @@ -32,8 +32,9 @@ public MemberModel saveMember(MemberModel memberModel) { } @Transactional(readOnly = true) - public MemberModel getMember(String id) { - return memberRepository.findByLoginId(id) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + id + "] 회원을 찾을 수 없습니다.")); + public MemberModel getMember(String loginId) { + MemberModel model = new MemberModel(loginId); // 객체 먼저 생성해야 함 + return memberRepository.findByLoginId(model.getLoginId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + loginId + "] 회원을 찾을 수 없습니다.")); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java index c312ab9fe..c58dffe2d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java @@ -20,4 +20,8 @@ ApiResponse signUp( MemberV1Dto.SignUpRequest request ); + ApiResponse getMyInfo( + String loginId + ); + } 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 744937a78..6ca5f25c5 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 @@ -24,13 +24,23 @@ public class MemberV1Controller implements MemberV1ApiSpec { // DB에서 꺼낸 객체 응답 데이터 전달용 private final MemberFacade memberFacade; - @PostMapping - @ResponseStatus(HttpStatus.CREATED) - @Override - public ApiResponse signUp(@RequestBody SignUpRequest request) { - MemberInfo info = memberFacade.signupMember(request); - MemberV1Dto.SignUpResponse response = MemberV1Dto.SignUpResponse.from(info); - return ApiResponse.success(response); - } + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @Override + public ApiResponse signUp(@RequestBody SignUpRequest request) { + MemberInfo info = memberFacade.signupMember(request); + MemberV1Dto.SignUpResponse response = MemberV1Dto.SignUpResponse.from(info); + return ApiResponse.success(response); + } + + @GetMapping("/{loginId}") + @Override + public ApiResponse getMyInfo( + @PathVariable(value = "loginId") String loginId + ) { + MemberInfo info = memberFacade.getMyInfo(loginId); + MemberV1Dto.SignUpResponse response = MemberV1Dto.SignUpResponse.from(info); + return ApiResponse.success(response); + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java index 0d0cfd1fb..b827bbdd9 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java @@ -30,7 +30,7 @@ void createsMemberModel_whenAllFieldsAreProvided() { // assert assertThat(member.getLoginId()).isEqualTo(loginId); - assertThat(member.getPassword()).isNotEqualTo(rawPassword); + assertThat(member.getPassword()).isEqualTo(rawPassword); assertThat(member.getName()).isEqualTo(name); assertThat(member.getBirthDate()).isEqualTo(birthDate); assertThat(member.getEmail()).isEqualTo(email); 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 7cb23df8a..d7b296cb8 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 @@ -73,11 +73,31 @@ void throwsException_whenExistIdIsTryToSaveMember() { @Nested class GetMember { - @DisplayName("존재하지 않는 ID로 조회하면 예외가 발생한다") - @Test - void throwsException_whenMemberNotFound() { - // arrange - String loginId = "testuser"; // Assuming this ID does not exist + @DisplayName("존재하는 ID를 주면, 해당 유저 정보를 반환한다.") + @Test + void returnsExampleInfo_whenValidIdIsProvided() { + // arrange // 정보저장 + MemberModel memberModel = new MemberModel("testuser", "Test1234!", "홍길동", "19900101", "test@example.com"); + memberService.saveMember(memberModel); + + // act + MemberModel result = memberService.getMember(memberModel.getLoginId()); + + // assert + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result.getLoginId()).isEqualTo(memberModel.getLoginId()), + () -> assertThat(result.getName()).isEqualTo(memberModel.getName()), + () -> assertThat(result.getBirthDate()).isEqualTo(memberModel.getBirthDate()), + () -> assertThat(result.getEmail()).isEqualTo(memberModel.getEmail()) + ); + } + + @DisplayName("존재하지 않는 ID로 조회하면 예외가 발생한다") + @Test + void throwsException_whenMemberNotFound() { + // arrange + String loginId = "testuser"; // Assuming this ID does not exist // act CoreException exception = assertThrows(CoreException.class, () -> { From fd56b5e9874f993b77c60ac419dedc54c416a106 Mon Sep 17 00:00:00 2001 From: katiekim17 Date: Thu, 5 Feb 2026 21:30:57 +0900 Subject: [PATCH 04/39] =?UTF-8?q?feat:=20=EB=82=B4=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/domain/member/MemberModel.java | 1 + .../loopers/domain/member/MemberService.java | 8 ++- .../api/member/MemberV1ApiSpec.java | 9 +-- .../api/member/MemberV1Controller.java | 7 ++- .../interfaces/api/member/MemberV1Dto.java | 16 ++++++ .../domain/member/MemberModelTest.java | 54 ++++++++++++++++++ .../interfaces/api/MemberV1ApiE2ETest.java | 55 +++++++++++++++++++ build.gradle.kts | 7 +-- 8 files changed, 145 insertions(+), 12 deletions(-) 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 bb3b422fd..70b6bcf2c 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 @@ -56,6 +56,7 @@ public MemberModel(String loginId, String password, String name, String birthDat public MemberModel(String loginId) { // 가입하는 로그인 ID는 영문과 숫자만 허용한다 validateLoginId(loginId); + this.loginId = loginId; } public String getLoginId() { 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 bb6cca4a3..b8593099f 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 @@ -4,6 +4,7 @@ import com.loopers.support.error.ErrorType; import java.util.Optional; import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -26,9 +27,12 @@ public MemberModel saveMember(MemberModel memberModel) { // 비밀번호 암호화 후 저장 String encrypted = passwordEncoder.encode(memberModel.getPassword()); memberModel.encryptPassword(encrypted); - memberRepository.save(memberModel); - return getMember(memberModel.getLoginId()); + try { + return memberRepository.save(memberModel); + } catch (DataIntegrityViolationException e) { + throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 아이디입니다."); + } } @Transactional(readOnly = true) diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java index c58dffe2d..b1a6b2a1d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java @@ -1,11 +1,8 @@ package com.loopers.interfaces.api.member; import com.loopers.interfaces.api.ApiResponse; -import com.loopers.interfaces.api.member.MemberV1Dto.SignUpResponse; import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.tags.Tag; -import org.springframework.web.bind.annotation.PostMapping; @Tag(name = "Member V1 API", description = "회원 API") public interface MemberV1ApiSpec { @@ -20,7 +17,11 @@ ApiResponse signUp( MemberV1Dto.SignUpRequest request ); - ApiResponse getMyInfo( + @Operation( + summary = "내 정보 조회", + description = "로그인 ID로 내 회원 정보를 조회한다" + ) + ApiResponse getMyInfo( String loginId ); 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 6ca5f25c5..f375fdf29 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 @@ -3,10 +3,13 @@ import com.loopers.application.member.MemberFacade; import com.loopers.application.member.MemberInfo; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.member.MemberV1Dto.MemberInfoResponse; import com.loopers.interfaces.api.member.MemberV1Dto.SignUpRequest; import com.loopers.interfaces.api.member.MemberV1Dto.SignUpResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -35,11 +38,11 @@ public ApiResponse signUp(@RequestBody SignUpRequest request) { @GetMapping("/{loginId}") @Override - public ApiResponse getMyInfo( + public ApiResponse getMyInfo( @PathVariable(value = "loginId") String loginId ) { MemberInfo info = memberFacade.getMyInfo(loginId); - MemberV1Dto.SignUpResponse response = MemberV1Dto.SignUpResponse.from(info); + MemberInfoResponse response = MemberInfoResponse.from(info); return ApiResponse.success(response); } 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 e15452995..bd29ed0fd 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 @@ -21,5 +21,21 @@ public static SignUpResponse from(MemberInfo info) { } } + public record MemberInfoResponse( + String loginId, + String name, + String birthDate, + String email + ) { + public static MemberInfoResponse from(MemberInfo info) { + return new MemberInfoResponse( + info.loginId(), + info.name(), + info.birthDate(), + info.email() + ); + } + } + } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java index b827bbdd9..654ad0347 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java @@ -37,6 +37,22 @@ void createsMemberModel_whenAllFieldsAreProvided() { // 비밀번호는 암호화되어 저장되므로 원본과 다를 수 있음 - 나중에 검증 방식 결정 } + @DisplayName("아이디로 회원 모델을 생성할 때, 영문과 숫자가 아닌 문자가 포함되면 예외가 발생한다.") + @Test + void throwsBadRequestException_whenLoginIdContainsInvalidChars() { + // arrange + String loginId = "testuser!@#"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new MemberModel(loginId); + }); + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST + ); + + } + @DisplayName("(실패케이스) 비밀번호가 7자일때, 예외발생.") @Test @@ -115,4 +131,42 @@ void throwsBadRequestException_whenPwContainsBirthDate() { assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } } + + @DisplayName("회원정보조회할 때,") + @Nested + class GetMemberInfo { + + @DisplayName("이름 마지막 글자를 마스킹한다") + @Test + void mask_last_character() { + //arrange + String loginId = "testuser"; + String rawPassword = "Test1234!"; + String name = "홍길동"; + String birthDate = "19900101"; + String email = "test@example.com"; + + // act + MemberModel member = new MemberModel(loginId, rawPassword, name, birthDate, email); + + assertThat(member.getMaskedName()).isEqualTo("홍길*"); + } + + @DisplayName("이름 마지막 글자를 마스킹한다") + @Test + void single_character_name_is_fully_masked() { + //arrange + String loginId = "testuser"; + String rawPassword = "Test1234!"; + String name = "홍"; + String birthDate = "19900101"; + String email = "test@example.com"; + + // act + MemberModel member = new MemberModel(loginId, rawPassword, name, birthDate, email); + + assertThat(member.getMaskedName()).isEqualTo("*"); + } + + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java index 8e2f27f19..837a4dd1f 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java @@ -68,6 +68,8 @@ void returnsCreated_whenValidMemberInfoIsProvided() { // assert assertAll( () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data()).isNotNull(), () -> assertThat(response.getBody().data().get("loginId")).isNotNull() ); } @@ -97,4 +99,57 @@ void returnsConflict_whenDuplicateLoginIdIsProvided() { assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); } } + + @DisplayName("GET /api/v1/members/{loginId} (회원정보조회)") + @Nested + class GetMemberInfo { + + @DisplayName("존재하는 회원의 ID로 조회하면, 200 OK와 마스킹된 이름을 반환한다.") + @Test + void returnsMemberInfo_whenExistingLoginIdIsProvided() { + // arrange - 먼저 회원 가입 + Map signUpRequest = Map.of( + "loginId", "testuser", + "password", "Test1234!", + "name", "홍길동", + "birthDate", "19900101", + "email", "test@test.co.kr" + ); + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(signUpRequest), new ParameterizedTypeReference>>() {}); + + // act - GET 요청으로 회원 정보 조회 + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "/testuser", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data()).isNotNull(), + () -> assertThat(response.getBody().data().get("loginId")).isEqualTo("testuser"), + () -> assertThat(response.getBody().data().get("name")).isEqualTo("홍길*") + ); + } + + @DisplayName("존재하지 않는 회원의 ID로 조회하면, 404 Not Found를 반환한다.") + @Test + void returnsNotFound_whenNonExistingLoginIdIsProvided() { + // arrange - 아무 데이터 없음 + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "/nonexistent", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } } diff --git a/build.gradle.kts b/build.gradle.kts index 6f9ce28b5..b7f022798 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -40,10 +40,9 @@ subprojects { apply(plugin = "org.springframework.boot") apply(plugin = "io.spring.dependency-management") - // TODO: JDK 24 환경에서 JacocoReport 태스크 생성 오류 발생 - JDK 21로 전환 후 활성화 필요 - // if (name !in containerProjects) { - // apply(plugin = "jacoco") - // } + if (name !in containerProjects) { + apply(plugin = "jacoco") + } dependencyManagement { imports { From e6e5d94848f0dcda92960d1503332a171e6eb757 Mon Sep 17 00:00:00 2001 From: katiekim17 Date: Fri, 6 Feb 2026 00:36:51 +0900 Subject: [PATCH 05/39] =?UTF-8?q?feat:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 8 + .../application/member/MemberFacade.java | 25 +- .../loopers/domain/member/MemberModel.java | 212 +++++----- .../domain/member/MemberRepository.java | 1 + .../loopers/domain/member/MemberService.java | 25 ++ .../api/member/MemberV1ApiSpec.java | 9 + .../api/member/MemberV1Controller.java | 12 + .../interfaces/api/member/MemberV1Dto.java | 7 + .../com/loopers/support/error/ErrorType.java | 3 +- .../domain/member/MemberModelTest.java | 362 ++++++++++-------- .../member/MemberServiceIntegrationTest.java | 141 +++++-- .../interfaces/api/MemberV1ApiE2ETest.java | 96 +++++ 12 files changed, 603 insertions(+), 298 deletions(-) create mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..4cd838770 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(./gradlew test:*)", + "Bash(./gradlew :apps:commerce-api:test:*)" + ] + } +} 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 index 12272ab3f..1e9cd02b3 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/MemberFacade.java @@ -25,12 +25,21 @@ public MemberInfo signupMember(MemberV1Dto.SignUpRequest request) { // 2. Service 호출 (저장 + 중복 체크) MemberModel saved = memberService.saveMember(memberModel); - // 3. MemberModel → MemberInfo로 변환해서 반환 - return MemberInfo.from(saved); - } - - public MemberInfo getMyInfo(String loginId) { - MemberModel member = memberService.getMember(loginId); - return MemberInfo.from(member); - } + // 3. MemberModel → MemberInfo로 변환해서 반환 + return MemberInfo.from(saved); + } + + public MemberInfo getMyInfo(String loginId) { + MemberModel member = memberService.getMember(loginId); + return MemberInfo.from(member); + } + + public void changePassword(String loginId, String prevPassword, String newPassword) { + // Request → MemberModel로 변환 + MemberModel memberModel = new MemberModel(loginId, prevPassword); + + // Service 호출 + memberService.changePassword(memberModel, newPassword); + + } } 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 70b6bcf2c..58e72de9b 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 @@ -10,127 +10,139 @@ @Table(name = "member") public class MemberModel extends BaseEntity { - private String loginId; - private String password ; - private String name; - private String birthDate; - private String email; - - protected MemberModel() {} - - public MemberModel(String loginId, String password, String name, String birthDate, String email) { - - // 모든 항목은 비어 있을 수 없다 - if (loginId == null || loginId.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "아이디는 비어있을 수 없습니다."); - } - if (password == null || password.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 비어있을 수 없습니다."); - } - if (name == null || name.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "이름은 비어있을 수 없습니다."); - } - if (birthDate == null || birthDate.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 비어있을 수 없습니다."); - } - if (email == null || email.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "이메일은 비어있을 수 없습니다."); - } - - // 가입된 아이디로는 가입이 불가능하다 -> 디비에서 검증. 서비스에서 하기 - // 가입하는 로그인 ID는 영문과 숫자만 허용한다 - validateLoginId(loginId); - - // 비밀번호 8~16자의 영문 대소문자, 숫자, 특수문자만 가능합니다. - // 비밀번호 생년월일은 비밀번호 내에 포함될 수 없습니다. - // 비밀번호 규칙 검증 - validatePassword(password, birthDate); - - this.loginId = loginId; - this.password = password; - this.name = name; - this.birthDate = birthDate; - this.email = email; - } + private String loginId; + private String password; + private String name; + private String birthDate; + private String email; - public MemberModel(String loginId) { - // 가입하는 로그인 ID는 영문과 숫자만 허용한다 - validateLoginId(loginId); - this.loginId = loginId; - } + protected MemberModel() { + } + + public MemberModel(String loginId, String password, String name, String birthDate, String email) { - public String getLoginId() { - return loginId; + // 모든 항목은 비어 있을 수 없다 + if (loginId == null || loginId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "아이디는 비어있을 수 없습니다."); + } + if (password == null || password.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 비어있을 수 없습니다."); + } + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이름은 비어있을 수 없습니다."); + } + if (birthDate == null || birthDate.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 비어있을 수 없습니다."); + } + if (email == null || email.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이메일은 비어있을 수 없습니다."); } - public String getPassword() { - return password; + // 가입된 아이디로는 가입이 불가능하다 -> 디비에서 검증. 서비스에서 하기 + // 가입하는 로그인 ID는 영문과 숫자만 허용한다 + validateLoginId(loginId); + + // 비밀번호 8~16자의 영문 대소문자, 숫자, 특수문자만 가능합니다. + // 비밀번호 생년월일은 비밀번호 내에 포함될 수 없습니다. + // 비밀번호 규칙 검증 + validatePassword(password, birthDate); + + this.loginId = loginId; + this.password = password; + this.name = name; + this.birthDate = birthDate; + this.email = email; + } + + public MemberModel(String loginId) { + // 모든 항목은 비어 있을 수 없다 + if (loginId == null || loginId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "아이디는 비어있을 수 없습니다."); } - public String getName() { - return name; + // 가입하는 로그인 ID는 영문과 숫자만 허용한다 + validateLoginId(loginId); + this.loginId = loginId; + } + + public MemberModel(String loginId, String prevPassword) { + this.loginId = loginId; + this.password = prevPassword; + } + + public String getLoginId() { + return loginId; + } + + public String getPassword() { + return password; + } + + public String getName() { + return name; + } + + public String getBirthDate() { + return birthDate; + } + + public String getEmail() { + return email; + } + + private void validateLoginId(String loginId) { + // 로그인 ID 는 영문과 숫자만 허용 + if (!loginId.matches("^[a-zA-Z0-9]+$")) { + throw new CoreException(ErrorType.BAD_REQUEST, "로그인 아이디는 영문자와 숫자만 사용할 수 있습니다."); } + } - public String getBirthDate() { - return birthDate; + private void validatePassword(String password, String birthDate) { + // 1. 8~16자 길이 체크 + if (password.length() < 8 || password.length() > 16) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 8~16자여야 합니다."); } - public String getEmail() { - return email; + // 2. 영문 대소문자, 숫자, 특수문자만 허용 (한글, 공백 등 불가) + if (!password.matches("^[a-zA-Z0-9!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?]+$")) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 영문 대소문자, 숫자, 특수문자만 가능합니다."); } - private void validateLoginId(String loginId) { - // 로그인 ID 는 영문과 숫자만 허용 - if (!loginId.matches("^[a-zA-Z0-9]+$")) { - throw new CoreException( - ErrorType.BAD_REQUEST, - "로그인 아이디는 영문자와 숫자만 사용할 수 있습니다." - ); - } + // 3. 생년월일이 비밀번호에 포함되면 안됨 + if (password.contains(birthDate)) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호에 생년월일을 포함할 수 없습니다."); } + } + + // 암호화된 비밀번호를 엔티티에 넣어주기 + public void encryptPassword(String encryptedPassword) { + this.password = encryptedPassword; + } - private void validatePassword(String password, String birthDate) { - // 1. 8~16자 길이 체크 - if (password.length() < 8 || password.length() > 16) { - throw new CoreException(ErrorType.BAD_REQUEST, - "비밀번호는 8~16자여야 합니다."); - } - - // 2. 영문 대소문자, 숫자, 특수문자만 허용 (한글, 공백 등 불가) - if (!password.matches("^[a-zA-Z0-9!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?]+$")) { - throw new CoreException(ErrorType.BAD_REQUEST, - "비밀번호는 영문 대소문자, 숫자, 특수문자만 가능합니다."); - } - - // 3. 생년월일이 비밀번호에 포함되면 안됨 - if (password.contains(birthDate)) { - throw new CoreException(ErrorType.BAD_REQUEST, - "비밀번호에 생년월일을 포함할 수 없습니다."); - } + // 이름 마지막 글자에 마스킹 추가 + public String maskLastChar(String name) { + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("이름은 비어 있을 수 없습니다."); } - // 암호화된 비밀번호를 엔티티에 넣어주기 - public void encryptPassword(String encryptedPassword) { - this.password = encryptedPassword; + if (name.length() == 1) { + return "*"; } - // 이름 마지막 글자에 마스킹 추가 - public String maskLastChar(String name) { - if (name == null || name.isBlank()) { - throw new IllegalArgumentException("이름은 비어 있을 수 없습니다."); - } + return name.substring(0, name.length() - 1) + "*"; + } - if (name.length() == 1) { - return "*"; - } + // 마스킹된 이름 가져오기 + public String getMaskedName() { + return maskLastChar(this.name); + } - return name.substring(0, name.length() - 1) + "*"; - } - // 마스킹된 이름 가져오기 - public String getMaskedName(){ - return maskLastChar(this.name); - } + // 비밀번호 변경하기 + public void changePassword(String newPassword, String birthDate) { + validatePassword(newPassword, birthDate); + this.password = newPassword; + } } 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 b86afc71d..ca1e3cd86 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 @@ -6,4 +6,5 @@ public interface MemberRepository { MemberModel save(MemberModel memberModel); Optional findByLoginId(String id); Optional update(MemberModel memberModel); + } 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 b8593099f..f6ee186b3 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 @@ -41,4 +41,29 @@ public MemberModel getMember(String loginId) { return memberRepository.findByLoginId(model.getLoginId()) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + loginId + "] 회원을 찾을 수 없습니다.")); } + + @Transactional(readOnly = false) + public void changePassword(MemberModel memberModel, String newPassword) { + + // 기존 회원 정보 조회 + MemberModel member = getMember(memberModel.getLoginId()); + + // 암호화된 DB 비밀번호와 입력한 기존 비밀번호 비교 + if (!passwordEncoder.matches(memberModel.getPassword(), member.getPassword())) { + throw new CoreException(ErrorType.UNAUTHORIZED, "기존 비밀번호가 일치하지 않습니다."); + } + + // 암호화된 DB 기존 비밀번호와 입력한 새로운 비밀번호 비교 + if (passwordEncoder.matches(newPassword, member.getPassword())) { + throw new CoreException(ErrorType.BAD_REQUEST, "새 비밀번호는 기존 비밀번호와 달라야 합니다."); + } + + // 새 비밀번호 규칙 검증 + 암호화 + 저장 (Dirty Checking) + member.changePassword(newPassword, member.getBirthDate()); + + // 암호화 후 저장 (Dirty Checking) + String encryptedPassword = passwordEncoder.encode(newPassword); + member.encryptPassword(encryptedPassword); + + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java index b1a6b2a1d..41128ac7a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java @@ -25,4 +25,13 @@ ApiResponse getMyInfo( String loginId ); + @Operation( + summary = "비밀번호 변경", + description = "기존 비밀번호와 새 비밀번호를 받아 비밀번호를 변경한다" + ) + ApiResponse changePassword( + String loginId, + MemberV1Dto.ChangePasswordRequest request + ); + } 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 f375fdf29..a7a3928e2 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 @@ -3,12 +3,14 @@ import com.loopers.application.member.MemberFacade; import com.loopers.application.member.MemberInfo; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.member.MemberV1Dto.ChangePasswordRequest; import com.loopers.interfaces.api.member.MemberV1Dto.MemberInfoResponse; import com.loopers.interfaces.api.member.MemberV1Dto.SignUpRequest; import com.loopers.interfaces.api.member.MemberV1Dto.SignUpResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -46,4 +48,14 @@ public ApiResponse getMyInfo( return ApiResponse.success(response); } + @PatchMapping("/{loginId}/password") + public ApiResponse changePassword( + @PathVariable String loginId, + @RequestBody ChangePasswordRequest request + ) { + memberFacade.changePassword(loginId, request.oldPassword(), request.newPassword()); + return ApiResponse.success("비밀번호가 변경되었습니다."); + } + + } 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 bd29ed0fd..e4e6f0465 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 @@ -38,4 +38,11 @@ public static MemberInfoResponse from(MemberInfo info) { } + // Request: 비밀번호 변경 요청 + public record ChangePasswordRequest( + String oldPassword, + String newPassword + ) {} + + } 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 5d142efbf..96c81c9ff 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 @@ -11,7 +11,8 @@ public enum ErrorType { INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), "일시적인 오류가 발생했습니다."), BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "잘못된 요청입니다."), NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 요청입니다."), - CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다."); + CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다."), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, HttpStatus.UNAUTHORIZED.getReasonPhrase(), "유효하지 않은 인증 정보입니다."); private final HttpStatus status; private final String code; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java index 654ad0347..e28f0cdab 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java @@ -1,172 +1,220 @@ package com.loopers.domain.member; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + 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.assertThrows; - class MemberModelTest { - @DisplayName("회원 모델을 생성할 때, ") - @Nested - class Create { - - @DisplayName("(성공케이스) 필수 정보가 모두 주어지면, 정상적으로 생성된다.") - @Test - void createsMemberModel_whenAllFieldsAreProvided() { - // arrange - String loginId = "testuser"; - String rawPassword = "Test1234!"; - String name = "홍길동"; - String birthDate = "19900101"; - String email = "test@example.com"; - - // act - MemberModel member = new MemberModel(loginId, rawPassword, name, birthDate, email); - - // assert - assertThat(member.getLoginId()).isEqualTo(loginId); - assertThat(member.getPassword()).isEqualTo(rawPassword); - assertThat(member.getName()).isEqualTo(name); - assertThat(member.getBirthDate()).isEqualTo(birthDate); - assertThat(member.getEmail()).isEqualTo(email); - // 비밀번호는 암호화되어 저장되므로 원본과 다를 수 있음 - 나중에 검증 방식 결정 - } - - @DisplayName("아이디로 회원 모델을 생성할 때, 영문과 숫자가 아닌 문자가 포함되면 예외가 발생한다.") - @Test - void throwsBadRequestException_whenLoginIdContainsInvalidChars() { - // arrange - String loginId = "testuser!@#"; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new MemberModel(loginId); - }); - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST - ); - - } - - - @DisplayName("(실패케이스) 비밀번호가 7자일때, 예외발생.") - @Test - void throwsBadRequestException_whenPwIsOutOfRange() { - // arrange - String loginId = "testuser"; - String password = "Test12!"; - String name = "홍길동"; - String birthDate = "19900101"; - String email = "test@example.com"; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new MemberModel(loginId, password, name, birthDate, email); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - - @DisplayName("비밀번호가 17자일 때 → 예외 발생") - @Test - void throwsBadRequestException_whenPwIsOutOfRange2() { - // arrange - String loginId = "testuser"; - String password = "Test123456789012!"; - String name = "홍길동"; - String birthDate = "19900101"; - String email = "test@example.com"; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new MemberModel(loginId, password, name, birthDate, email); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @DisplayName("비밀번호에 한글이 있을 때 → 예외 발생") - @Test - void throwsBadRequestException_whenPwIsKorean() { - // arrange - String loginId = "testuser"; - String password = "Test홍길동890123!"; - String name = "홍길동"; - String birthDate = "19900101"; - String email = "test@example.com"; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new MemberModel(loginId, password, name, birthDate, email); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @DisplayName("비밀번호에 생년월일이 포함될 때 → 예외 발생") - @Test - void throwsBadRequestException_whenPwContainsBirthDate() { - // arrange - String loginId = "testuser"; - String password = "Test19900101!"; - String name = "홍길동"; - String birthDate = "19900101"; - String email = "test@example.com"; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new MemberModel(loginId, password, name, birthDate, email); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } + @DisplayName("회원 모델을 생성할 때, ") + @Nested + class Create { + + @DisplayName("(성공케이스) 필수 정보가 모두 주어지면, 정상적으로 생성된다.") + @Test + void createsMemberModel_whenAllFieldsAreProvided() { + // arrange + String loginId = "testuser"; + String rawPassword = "Test1234!"; + String name = "홍길동"; + String birthDate = "19900101"; + String email = "test@example.com"; + + // act + MemberModel member = new MemberModel(loginId, rawPassword, name, birthDate, email); + + // assert + assertThat(member.getLoginId()).isEqualTo(loginId); + assertThat(member.getPassword()).isEqualTo(rawPassword); + assertThat(member.getName()).isEqualTo(name); + assertThat(member.getBirthDate()).isEqualTo(birthDate); + assertThat(member.getEmail()).isEqualTo(email); + // 비밀번호는 암호화되어 저장되므로 원본과 다를 수 있음 - 나중에 검증 방식 결정 + } + + @DisplayName("아이디로 회원 모델을 생성할 때, 영문과 숫자가 아닌 문자가 포함되면 예외가 발생한다.") + @Test + void throwsBadRequestException_whenLoginIdContainsInvalidChars() { + // arrange + String loginId = "testuser!@#"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new MemberModel(loginId); + }); + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + + @DisplayName("(실패케이스) 비밀번호가 7자일때, 예외발생.") + @Test + void throwsBadRequestException_whenPwIsOutOfRange() { + // arrange + String loginId = "testuser"; + String password = "Test12!"; + String name = "홍길동"; + String birthDate = "19900101"; + String email = "test@example.com"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new MemberModel(loginId, password, name, birthDate, email); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + + @DisplayName("비밀번호가 17자일 때 → 예외 발생") + @Test + void throwsBadRequestException_whenPwIsOutOfRange2() { + // arrange + String loginId = "testuser"; + String password = "Test123456789012!"; + String name = "홍길동"; + String birthDate = "19900101"; + String email = "test@example.com"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new MemberModel(loginId, password, name, birthDate, email); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("비밀번호에 한글이 있을 때 → 예외 발생") + @Test + void throwsBadRequestException_whenPwIsKorean() { + // arrange + String loginId = "testuser"; + String password = "Test홍길동890123!"; + String name = "홍길동"; + String birthDate = "19900101"; + String email = "test@example.com"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new MemberModel(loginId, password, name, birthDate, email); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("비밀번호에 생년월일이 포함될 때 → 예외 발생") + @Test + void throwsBadRequestException_whenPwContainsBirthDate() { + // arrange + String loginId = "testuser"; + String password = "Test19900101!"; + String name = "홍길동"; + String birthDate = "19900101"; + String email = "test@example.com"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new MemberModel(loginId, password, name, birthDate, email); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } + } + + @DisplayName("회원정보조회할 때,") + @Nested + class GetMemberInfo { + + @DisplayName("이름 마지막 글자를 마스킹한다") + @Test + void mask_last_character() { + //arrange + String loginId = "testuser"; + String rawPassword = "Test1234!"; + String name = "홍길동"; + String birthDate = "19900101"; + String email = "test@example.com"; + + // act + MemberModel member = new MemberModel(loginId, rawPassword, name, birthDate, email); + + assertThat(member.getMaskedName()).isEqualTo("홍길*"); + } + + @DisplayName("이름 마지막 글자를 마스킹한다") + @Test + void single_character_name_is_fully_masked() { + //arrange + String loginId = "testuser"; + String rawPassword = "Test1234!"; + String name = "홍"; + String birthDate = "19900101"; + String email = "test@example.com"; + + // act + MemberModel member = new MemberModel(loginId, rawPassword, name, birthDate, email); + + assertThat(member.getMaskedName()).isEqualTo("*"); + } + + } + + @DisplayName("비밀번호 수정 할 때,") + @Nested + class ChangePassword { + + @DisplayName("새 비밀번호가 규칙을 만족하면, 비밀번호가 변경된다.") + @Test + void changesPassword_whenOldPasswordMatchesAndNewPasswordIsValid() { + // arrange + String loginId = "testuser"; + String prevPassword = "Test1234!"; + String name = "홍길동"; + String birthDate = "19900101"; + String email = "test@test.co.kr"; + + // act + MemberModel member = new MemberModel(loginId, prevPassword, name, birthDate, email); + String newPassword = "Newpass123!"; + member.changePassword(newPassword, birthDate); + + // assert + assertThat(member.getPassword()).isEqualTo(newPassword); + } + + + @DisplayName("새 비밀번호에 생년월일이 포함되면, 예외가 발생한다.") + @Test + void throwsException_whenNewPasswordContainsBirthDate() { + // arrange + String loginId = "testuser"; + String prevPassword = "Test1234!"; + String name = "홍길동"; + String birthDate = "19900101"; + String email = "test@test.co.kr"; + + String newPassword = "Test19900101!"; + + // act + MemberModel member = new MemberModel(loginId, prevPassword, name, birthDate, email); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + member.changePassword(newPassword, birthDate); + }); - @DisplayName("회원정보조회할 때,") - @Nested - class GetMemberInfo { - - @DisplayName("이름 마지막 글자를 마스킹한다") - @Test - void mask_last_character() { - //arrange - String loginId = "testuser"; - String rawPassword = "Test1234!"; - String name = "홍길동"; - String birthDate = "19900101"; - String email = "test@example.com"; - - // act - MemberModel member = new MemberModel(loginId, rawPassword, name, birthDate, email); - - assertThat(member.getMaskedName()).isEqualTo("홍길*"); - } - - @DisplayName("이름 마지막 글자를 마스킹한다") - @Test - void single_character_name_is_fully_masked() { - //arrange - String loginId = "testuser"; - String rawPassword = "Test1234!"; - String name = "홍"; - String birthDate = "19900101"; - String email = "test@example.com"; - - // act - MemberModel member = new MemberModel(loginId, rawPassword, name, birthDate, email); - - assertThat(member.getMaskedName()).isEqualTo("*"); - } + // assert - ErrorType.BAD_REQUEST + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } + } } 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 d7b296cb8..908201704 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 @@ -43,13 +43,7 @@ void returnsMemberInfo_whenValidMemberInfoIsProvided() { MemberModel result = memberService.getMember(memberModel.getLoginId()); // assert - assertAll( - () -> assertThat(result).isNotNull(), - () -> assertThat(result.getLoginId()).isEqualTo(memberModel.getLoginId()), - () -> assertThat(result.getName()).isEqualTo(memberModel.getName()), - () -> assertThat(result.getBirthDate()).isEqualTo(memberModel.getBirthDate()), - () -> assertThat(result.getEmail()).isEqualTo(memberModel.getEmail()) - ); + assertAll(() -> assertThat(result).isNotNull(), () -> assertThat(result.getLoginId()).isEqualTo(memberModel.getLoginId()), () -> assertThat(result.getName()).isEqualTo(memberModel.getName()), () -> assertThat(result.getBirthDate()).isEqualTo(memberModel.getBirthDate()), () -> assertThat(result.getEmail()).isEqualTo(memberModel.getEmail())); } @DisplayName("중복 ID로 가입 시도하면 예외가 발생한다") @@ -73,31 +67,25 @@ void throwsException_whenExistIdIsTryToSaveMember() { @Nested class GetMember { - @DisplayName("존재하는 ID를 주면, 해당 유저 정보를 반환한다.") - @Test - void returnsExampleInfo_whenValidIdIsProvided() { - // arrange // 정보저장 - MemberModel memberModel = new MemberModel("testuser", "Test1234!", "홍길동", "19900101", "test@example.com"); - memberService.saveMember(memberModel); - - // act - MemberModel result = memberService.getMember(memberModel.getLoginId()); - - // assert - assertAll( - () -> assertThat(result).isNotNull(), - () -> assertThat(result.getLoginId()).isEqualTo(memberModel.getLoginId()), - () -> assertThat(result.getName()).isEqualTo(memberModel.getName()), - () -> assertThat(result.getBirthDate()).isEqualTo(memberModel.getBirthDate()), - () -> assertThat(result.getEmail()).isEqualTo(memberModel.getEmail()) - ); - } - - @DisplayName("존재하지 않는 ID로 조회하면 예외가 발생한다") - @Test - void throwsException_whenMemberNotFound() { - // arrange - String loginId = "testuser"; // Assuming this ID does not exist + @DisplayName("존재하는 ID를 주면, 해당 유저 정보를 반환한다.") + @Test + void returnsExampleInfo_whenValidIdIsProvided() { + // arrange // 정보저장 + MemberModel memberModel = new MemberModel("testuser", "Test1234!", "홍길동", "19900101", "test@example.com"); + memberService.saveMember(memberModel); + + // act + MemberModel result = memberService.getMember(memberModel.getLoginId()); + + // assert + assertAll(() -> assertThat(result).isNotNull(), () -> assertThat(result.getLoginId()).isEqualTo(memberModel.getLoginId()), () -> assertThat(result.getName()).isEqualTo(memberModel.getName()), () -> assertThat(result.getBirthDate()).isEqualTo(memberModel.getBirthDate()), () -> assertThat(result.getEmail()).isEqualTo(memberModel.getEmail())); + } + + @DisplayName("존재하지 않는 ID로 조회하면 예외가 발생한다") + @Test + void throwsException_whenMemberNotFound() { + // arrange + String loginId = "testuser"; // Assuming this ID does not exist // act CoreException exception = assertThrows(CoreException.class, () -> { @@ -108,4 +96,93 @@ void throwsException_whenMemberNotFound() { assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); } } + + + @DisplayName("비밀번호 변경을 할 때, ") + @Nested + class ChangePassword { + + @DisplayName("기존 비밀번호와 새 비밀번호가 유효하면, 비밀번호가 변경된다.") + @Test + void changesPassword_whenOldAndNewPasswordsAreValid() { + // arrange + String loginId = "testuser"; + String prevPassword = "Test1234!"; + String name = "홍길동"; + String birthDate = "19900101"; + String email = "test@test.co.kr"; + + // act + // 기존 등록된 디비 설정 + MemberModel member = new MemberModel(loginId, prevPassword, name, birthDate, email); + memberService.saveMember(member); + + // 클라에서 입력한 아이디와 기존 비밀번호, 새로운 비밀번호 + MemberModel insertedMember = new MemberModel(loginId, prevPassword); + String newPassword = "NewPass123!"; + + // act + memberService.changePassword(insertedMember, newPassword); + + // assert + MemberModel updatedMember = memberService.getMember("testuser"); + // 비밀번호가 변경되었는지 확인 (암호화된 비밀번호 비교) + assertThat(updatedMember.getPassword()).isNotEqualTo(insertedMember.getPassword()); + } + + @DisplayName("기존 비밀번호가 일치하지 않으면, 예외가 발생한다.") + @Test + void throwsException_whenOldPasswordDoesNotMatch() { + // arrange + String loginId = "testuser"; + String prevPassword = "Test1234!"; + String name = "홍길동"; + String birthDate = "19900101"; + String email = "test@test.co.kr"; + + // act + // 기존 등록된 디비 설정 + MemberModel member = new MemberModel(loginId, prevPassword, name, birthDate, email); + memberService.saveMember(member); + + // 클라에서 입력한 아이디와 기존 비밀번호, 새로운 비밀번호 + String wrongPrevPassword = "WrongPass!"; + MemberModel insertedMember = new MemberModel(loginId, wrongPrevPassword); + String newPassword = "NewPass123!"; + + // act + CoreException exception = assertThrows(CoreException.class, () -> { + memberService.changePassword(insertedMember, newPassword); + }); + + // assert - ErrorType.UNAUTHORIZED + assertThat(exception.getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED); + } + + @DisplayName("새 비밀번호가 기존 비밀번호와 같으면, 예외가 발생한다.") + @Test + void throwsException_whenNewPasswordIsSameAsOld() { + // arrange + String loginId = "testuser"; + String prevPassword = "Test1234!"; + String name = "홍길동"; + String birthDate = "19900101"; + String email = "test@test.co.kr"; + + MemberModel member = new MemberModel(loginId, prevPassword, name, birthDate, email); + memberService.saveMember(member); + + // 클라에서 입력한 아이디와 기존 비밀번호, 새로운 비밀번호 + MemberModel insertedMember = new MemberModel(loginId, prevPassword); + String newPassword = "Test1234!"; + + // act + CoreException exception = assertThrows(CoreException.class, () -> { + memberService.changePassword(insertedMember, newPassword); + }); + + // assert - ErrorType.UNAUTHORIZED 또는 BAD_REQUEST + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java index 837a4dd1f..a31933ee0 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java @@ -152,4 +152,100 @@ void returnsNotFound_whenNonExistingLoginIdIsProvided() { assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); } } + + @DisplayName("PATCH /api/v1/members/{loginId}/password (비밀번호 변경)") + @Nested + class ChangePassword { + + @DisplayName("기존 비밀번호가 일치하고 새 비밀번호가 유효하면, 200 OK를 반환한다.") + @Test + void returnsOk_whenOldPasswordMatchesAndNewPasswordIsValid() { + // arrange - 먼저 회원 가입 + Map signUpRequest = Map.of( + "loginId", "testuser", + "password", "Test1234!", + "name", "홍길동", + "birthDate", "19900101", + "email", "test@test.co.kr" + ); + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(signUpRequest), new ParameterizedTypeReference>>() {}); + + // act - 비밀번호 변경 요청 + Map changePasswordRequest = Map.of( + "oldPassword", "Test1234!", + "newPassword", "NewPass123!" + ); + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/testuser/password", + HttpMethod.PATCH, + new HttpEntity<>(changePasswordRequest), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data()).isEqualTo("비밀번호가 변경되었습니다.") + ); + } + + @DisplayName("기존 비밀번호가 일치하지 않으면, 401 Unauthorized를 반환한다.") + @Test + void returnsUnauthorized_whenOldPasswordDoesNotMatch() { + // arrange - 먼저 회원 가입 + Map signUpRequest = Map.of( + "loginId", "testuser", + "password", "Test1234!", + "name", "홍길동", + "birthDate", "19900101", + "email", "test@test.co.kr" + ); + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(signUpRequest), new ParameterizedTypeReference>>() {}); + + // act - 틀린 기존 비밀번호로 변경 요청 + Map changePasswordRequest = Map.of( + "oldPassword", "WrongPass1!", + "newPassword", "NewPass123!" + ); + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/testuser/password", + HttpMethod.PATCH, + new HttpEntity<>(changePasswordRequest), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @DisplayName("새 비밀번호가 기존 비밀번호와 같으면, 400 Bad Request를 반환한다.") + @Test + void returnsBadRequest_whenNewPasswordIsSameAsOld() { + // arrange - 먼저 회원 가입 + Map signUpRequest = Map.of( + "loginId", "testuser", + "password", "Test1234!", + "name", "홍길동", + "birthDate", "19900101", + "email", "test@test.co.kr" + ); + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(signUpRequest), new ParameterizedTypeReference>>() {}); + + // act - 기존과 동일한 비밀번호로 변경 요청 + Map changePasswordRequest = Map.of( + "oldPassword", "Test1234!", + "newPassword", "Test1234!" + ); + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/testuser/password", + HttpMethod.PATCH, + new HttpEntity<>(changePasswordRequest), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } } From df0e33003002447ebdffc71a6f7a644af38eddad Mon Sep 17 00:00:00 2001 From: katiekim17 Date: Fri, 6 Feb 2026 01:18:27 +0900 Subject: [PATCH 06/39] =?UTF-8?q?feat:=20=ED=97=A4=EB=8D=94=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EC=B6=94=EA=B0=80(=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=97=90=EB=8F=84=20=EC=B6=94=EA=B0=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/member/MemberFacade.java | 14 +- .../loopers/domain/member/MemberService.java | 101 ++++++----- .../api/member/MemberV1ApiSpec.java | 7 +- .../api/member/MemberV1Controller.java | 16 +- .../interfaces/api/MemberV1ApiE2ETest.java | 170 ++++++++++++++---- 5 files changed, 217 insertions(+), 91 deletions(-) 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 index 1e9cd02b3..781bacce6 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/MemberFacade.java @@ -29,14 +29,18 @@ public MemberInfo signupMember(MemberV1Dto.SignUpRequest request) { return MemberInfo.from(saved); } - public MemberInfo getMyInfo(String loginId) { - MemberModel member = memberService.getMember(loginId); + + public MemberInfo getMyInfo(String loginId, String password) { + MemberModel member = memberService.authenticate(loginId, password); return MemberInfo.from(member); } - public void changePassword(String loginId, String prevPassword, String newPassword) { - // Request → MemberModel로 변환 - MemberModel memberModel = new MemberModel(loginId, prevPassword); + + public void changePassword(String loginId, String password, String prevPassword, String newPassword) { + // 헤더 인증 + memberService.authenticate(loginId, password); + + MemberModel memberModel = new MemberModel(loginId, prevPassword); // raw prevPassword // Service 호출 memberService.changePassword(memberModel, newPassword); 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 f6ee186b3..0ab66665b 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 @@ -13,57 +13,70 @@ @Component public class MemberService { - private final MemberRepository memberRepository; - private final PasswordEncoder passwordEncoder; - - @Transactional(readOnly = false) - public MemberModel saveMember(MemberModel memberModel) { - //저장하기 전에 이미 같은 loginId가 있는지 확인 - Optional existing = memberRepository.findByLoginId(memberModel.getLoginId()); - if (existing.isPresent()) { - throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 아이디입니다."); - } - - // 비밀번호 암호화 후 저장 - String encrypted = passwordEncoder.encode(memberModel.getPassword()); - memberModel.encryptPassword(encrypted); - - try { - return memberRepository.save(memberModel); - } catch (DataIntegrityViolationException e) { - throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 아이디입니다."); - } - } - - @Transactional(readOnly = true) - public MemberModel getMember(String loginId) { - MemberModel model = new MemberModel(loginId); // 객체 먼저 생성해야 함 - return memberRepository.findByLoginId(model.getLoginId()) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + loginId + "] 회원을 찾을 수 없습니다.")); + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional(readOnly = false) + public MemberModel saveMember(MemberModel memberModel) { + //저장하기 전에 이미 같은 loginId가 있는지 확인 + Optional existing = memberRepository.findByLoginId(memberModel.getLoginId()); + if (existing.isPresent()) { + throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 아이디입니다."); } - @Transactional(readOnly = false) - public void changePassword(MemberModel memberModel, String newPassword) { + // 비밀번호 암호화 후 저장 + String encrypted = passwordEncoder.encode(memberModel.getPassword()); + memberModel.encryptPassword(encrypted); + + try { + return memberRepository.save(memberModel); + } catch (DataIntegrityViolationException e) { + throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 아이디입니다."); + } + } + + public MemberModel authenticate(String loginId, String password) { + // 1. 회원 조회 + MemberModel member = getMember(loginId); // 없으면 NOT_FOUND + + // 2. 비밀번호 일치 여부 확인 + if (!passwordEncoder.matches(password, member.getPassword())) { + // 3. 불일치 시 UNAUTHORIZED 예외 + throw new CoreException(ErrorType.UNAUTHORIZED, "인증 실패"); + } - // 기존 회원 정보 조회 - MemberModel member = getMember(memberModel.getLoginId()); + return member; + } - // 암호화된 DB 비밀번호와 입력한 기존 비밀번호 비교 - if (!passwordEncoder.matches(memberModel.getPassword(), member.getPassword())) { - throw new CoreException(ErrorType.UNAUTHORIZED, "기존 비밀번호가 일치하지 않습니다."); - } + @Transactional(readOnly = true) + public MemberModel getMember(String loginId) { + MemberModel model = new MemberModel(loginId); // 객체 먼저 생성해야 함 + return memberRepository.findByLoginId(model.getLoginId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + loginId + "] 회원을 찾을 수 없습니다.")); + } - // 암호화된 DB 기존 비밀번호와 입력한 새로운 비밀번호 비교 - if (passwordEncoder.matches(newPassword, member.getPassword())) { - throw new CoreException(ErrorType.BAD_REQUEST, "새 비밀번호는 기존 비밀번호와 달라야 합니다."); - } + @Transactional(readOnly = false) + public void changePassword(MemberModel memberModel, String newPassword) { - // 새 비밀번호 규칙 검증 + 암호화 + 저장 (Dirty Checking) - member.changePassword(newPassword, member.getBirthDate()); + // 기존 회원 정보 조회 + MemberModel member = getMember(memberModel.getLoginId()); - // 암호화 후 저장 (Dirty Checking) - String encryptedPassword = passwordEncoder.encode(newPassword); - member.encryptPassword(encryptedPassword); + // 암호화된 DB 비밀번호와 입력한 기존 비밀번호 비교 + if (!passwordEncoder.matches(memberModel.getPassword(), member.getPassword())) { + throw new CoreException(ErrorType.UNAUTHORIZED, "기존 비밀번호가 일치하지 않습니다."); + } + // 암호화된 DB 기존 비밀번호와 입력한 새로운 비밀번호 비교 + if (passwordEncoder.matches(newPassword, member.getPassword())) { + throw new CoreException(ErrorType.BAD_REQUEST, "새 비밀번호는 기존 비밀번호와 달라야 합니다."); } + + // 새 비밀번호 규칙 검증 + 암호화 + 저장 (Dirty Checking) + member.changePassword(newPassword, member.getBirthDate()); + + // 암호화 후 저장 (Dirty Checking) + String encryptedPassword = passwordEncoder.encode(newPassword); + member.encryptPassword(encryptedPassword); + + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java index 41128ac7a..52cb64800 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java @@ -3,6 +3,7 @@ import com.loopers.interfaces.api.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.RequestHeader; @Tag(name = "Member V1 API", description = "회원 API") public interface MemberV1ApiSpec { @@ -22,7 +23,8 @@ ApiResponse signUp( description = "로그인 ID로 내 회원 정보를 조회한다" ) ApiResponse getMyInfo( - String loginId + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password ); @Operation( @@ -30,7 +32,8 @@ ApiResponse getMyInfo( description = "기존 비밀번호와 새 비밀번호를 받아 비밀번호를 변경한다" ) ApiResponse changePassword( - String loginId, + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, MemberV1Dto.ChangePasswordRequest request ); 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 a7a3928e2..07bf218ef 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 @@ -11,9 +11,9 @@ import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; -import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; 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.ResponseStatus; import org.springframework.web.bind.annotation.RestController; @@ -38,22 +38,24 @@ public ApiResponse signUp(@RequestBody SignUpRequest request) { return ApiResponse.success(response); } - @GetMapping("/{loginId}") + @GetMapping("/me") @Override public ApiResponse getMyInfo( - @PathVariable(value = "loginId") String loginId + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password ) { - MemberInfo info = memberFacade.getMyInfo(loginId); + MemberInfo info = memberFacade.getMyInfo(loginId, password); MemberInfoResponse response = MemberInfoResponse.from(info); return ApiResponse.success(response); } - @PatchMapping("/{loginId}/password") + @PatchMapping("/me/password") public ApiResponse changePassword( - @PathVariable String loginId, + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, @RequestBody ChangePasswordRequest request ) { - memberFacade.changePassword(loginId, request.oldPassword(), request.newPassword()); + memberFacade.changePassword(loginId, password, request.oldPassword(), request.newPassword()); return ApiResponse.success("비밀번호가 변경되었습니다."); } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java index a31933ee0..b9fb6e264 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java @@ -1,7 +1,12 @@ package com.loopers.interfaces.api; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + import com.loopers.utils.DatabaseCleanUp; +import java.util.Map; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -10,15 +15,11 @@ 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.util.Map; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; - @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class MemberV1ApiE2ETest { @@ -69,8 +70,14 @@ void returnsCreated_whenValidMemberInfoIsProvided() { assertAll( () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), () -> assertThat(response.getBody()).isNotNull(), - () -> assertThat(response.getBody().data()).isNotNull(), - () -> assertThat(response.getBody().data().get("loginId")).isNotNull() + () -> { + Assertions.assertNotNull(response.getBody()); + assertThat(response.getBody().data()).isNotNull(); + }, + () -> { + Assertions.assertNotNull(response.getBody()); + assertThat(response.getBody().data().get("loginId")).isNotNull(); + } ); } @@ -100,13 +107,13 @@ void returnsConflict_whenDuplicateLoginIdIsProvided() { } } - @DisplayName("GET /api/v1/members/{loginId} (회원정보조회)") + @DisplayName("GET /api/v1/members/me (회원정보조회)") @Nested class GetMemberInfo { - @DisplayName("존재하는 회원의 ID로 조회하면, 200 OK와 마스킹된 이름을 반환한다.") + @DisplayName("헤더 인증 성공 시, 200 OK와 마스킹된 이름을 반환한다.") @Test - void returnsMemberInfo_whenExistingLoginIdIsProvided() { + void returnsMemberInfo_whenHeaderAuthIsValid() { // arrange - 먼저 회원 가입 Map signUpRequest = Map.of( "loginId", "testuser", @@ -117,11 +124,15 @@ void returnsMemberInfo_whenExistingLoginIdIsProvided() { ); testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(signUpRequest), new ParameterizedTypeReference>>() {}); - // act - GET 요청으로 회원 정보 조회 + // act - 헤더에 인증 정보 포함하여 조회 + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "Test1234!"); + ResponseEntity>> response = testRestTemplate.exchange( - ENDPOINT + "/testuser", + ENDPOINT + "/me", HttpMethod.GET, - null, + new HttpEntity<>(headers), new ParameterizedTypeReference<>() {} ); @@ -129,22 +140,63 @@ void returnsMemberInfo_whenExistingLoginIdIsProvided() { assertAll( () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), () -> assertThat(response.getBody()).isNotNull(), - () -> assertThat(response.getBody().data()).isNotNull(), - () -> assertThat(response.getBody().data().get("loginId")).isEqualTo("testuser"), - () -> assertThat(response.getBody().data().get("name")).isEqualTo("홍길*") + () -> { + Assertions.assertNotNull(response.getBody()); + assertThat(response.getBody().data()).isNotNull(); + }, + () -> { + Assertions.assertNotNull(response.getBody()); + assertThat(response.getBody().data().get("loginId")).isEqualTo("testuser"); + }, + () -> { + Assertions.assertNotNull(response.getBody()); + assertThat(response.getBody().data().get("name")).isEqualTo("홍길*"); + } ); } + @DisplayName("헤더 인증 실패 시 (비밀번호 불일치), 401 Unauthorized를 반환한다.") + @Test + void returnsUnauthorized_whenHeaderPasswordIsWrong() { + // arrange - 먼저 회원 가입 + Map signUpRequest = Map.of( + "loginId", "testuser", + "password", "Test1234!", + "name", "홍길동", + "birthDate", "19900101", + "email", "test@test.co.kr" + ); + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(signUpRequest), new ParameterizedTypeReference>>() {}); + + // act - 틀린 비밀번호로 조회 + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "WrongPass1!"); + + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "/me", + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + @DisplayName("존재하지 않는 회원의 ID로 조회하면, 404 Not Found를 반환한다.") @Test - void returnsNotFound_whenNonExistingLoginIdIsProvided() { + void returnsNotFound_whenMemberDoesNotExist() { // arrange - 아무 데이터 없음 + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "nonexistent"); + headers.set("X-Loopers-LoginPw", "Test1234!"); // act ResponseEntity>> response = testRestTemplate.exchange( - ENDPOINT + "/nonexistent", + ENDPOINT + "/me", HttpMethod.GET, - null, + new HttpEntity<>(headers), new ParameterizedTypeReference<>() {} ); @@ -153,13 +205,13 @@ void returnsNotFound_whenNonExistingLoginIdIsProvided() { } } - @DisplayName("PATCH /api/v1/members/{loginId}/password (비밀번호 변경)") + @DisplayName("PATCH /api/v1/members/me/password (비밀번호 변경)") @Nested class ChangePassword { - @DisplayName("기존 비밀번호가 일치하고 새 비밀번호가 유효하면, 200 OK를 반환한다.") + @DisplayName("헤더 인증 성공 + 유효한 비밀번호 변경 요청이면, 200 OK를 반환한다.") @Test - void returnsOk_whenOldPasswordMatchesAndNewPasswordIsValid() { + void returnsOk_whenHeaderAuthAndPasswordChangeAreValid() { // arrange - 먼저 회원 가입 Map signUpRequest = Map.of( "loginId", "testuser", @@ -170,15 +222,20 @@ void returnsOk_whenOldPasswordMatchesAndNewPasswordIsValid() { ); testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(signUpRequest), new ParameterizedTypeReference>>() {}); - // act - 비밀번호 변경 요청 + // act - 헤더 인증 + 비밀번호 변경 요청 + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "Test1234!"); + headers.set("Content-Type", "application/json"); + Map changePasswordRequest = Map.of( "oldPassword", "Test1234!", "newPassword", "NewPass123!" ); ResponseEntity> response = testRestTemplate.exchange( - ENDPOINT + "/testuser/password", + ENDPOINT + "/me/password", HttpMethod.PATCH, - new HttpEntity<>(changePasswordRequest), + new HttpEntity<>(changePasswordRequest, headers), new ParameterizedTypeReference<>() {} ); @@ -186,13 +243,16 @@ void returnsOk_whenOldPasswordMatchesAndNewPasswordIsValid() { assertAll( () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), () -> assertThat(response.getBody()).isNotNull(), - () -> assertThat(response.getBody().data()).isEqualTo("비밀번호가 변경되었습니다.") + () -> { + Assertions.assertNotNull(response.getBody()); + assertThat(response.getBody().data()).isEqualTo("비밀번호가 변경되었습니다."); + } ); } - @DisplayName("기존 비밀번호가 일치하지 않으면, 401 Unauthorized를 반환한다.") + @DisplayName("헤더 인증 실패 시 (비밀번호 불일치), 401 Unauthorized를 반환한다.") @Test - void returnsUnauthorized_whenOldPasswordDoesNotMatch() { + void returnsUnauthorized_whenHeaderPasswordIsWrong() { // arrange - 먼저 회원 가입 Map signUpRequest = Map.of( "loginId", "testuser", @@ -203,15 +263,54 @@ void returnsUnauthorized_whenOldPasswordDoesNotMatch() { ); testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(signUpRequest), new ParameterizedTypeReference>>() {}); - // act - 틀린 기존 비밀번호로 변경 요청 + // act - 틀린 헤더 비밀번호로 요청 + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "WrongPass1!"); + headers.set("Content-Type", "application/json"); + + Map changePasswordRequest = Map.of( + "oldPassword", "Test1234!", + "newPassword", "NewPass123!" + ); + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/me/password", + HttpMethod.PATCH, + new HttpEntity<>(changePasswordRequest, headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @DisplayName("Body의 기존 비밀번호가 일치하지 않으면, 401 Unauthorized를 반환한다.") + @Test + void returnsUnauthorized_whenOldPasswordInBodyDoesNotMatch() { + // arrange - 먼저 회원 가입 + Map signUpRequest = Map.of( + "loginId", "testuser", + "password", "Test1234!", + "name", "홍길동", + "birthDate", "19900101", + "email", "test@test.co.kr" + ); + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(signUpRequest), new ParameterizedTypeReference>>() {}); + + // act - 헤더는 맞지만 Body의 oldPassword가 틀림 + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "Test1234!"); + headers.set("Content-Type", "application/json"); + Map changePasswordRequest = Map.of( "oldPassword", "WrongPass1!", "newPassword", "NewPass123!" ); ResponseEntity> response = testRestTemplate.exchange( - ENDPOINT + "/testuser/password", + ENDPOINT + "/me/password", HttpMethod.PATCH, - new HttpEntity<>(changePasswordRequest), + new HttpEntity<>(changePasswordRequest, headers), new ParameterizedTypeReference<>() {} ); @@ -233,14 +332,19 @@ void returnsBadRequest_whenNewPasswordIsSameAsOld() { testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(signUpRequest), new ParameterizedTypeReference>>() {}); // act - 기존과 동일한 비밀번호로 변경 요청 + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "Test1234!"); + headers.set("Content-Type", "application/json"); + Map changePasswordRequest = Map.of( "oldPassword", "Test1234!", "newPassword", "Test1234!" ); ResponseEntity> response = testRestTemplate.exchange( - ENDPOINT + "/testuser/password", + ENDPOINT + "/me/password", HttpMethod.PATCH, - new HttpEntity<>(changePasswordRequest), + new HttpEntity<>(changePasswordRequest, headers), new ParameterizedTypeReference<>() {} ); From 2f40dc76da2be94955326c43520eac84c782df6c Mon Sep 17 00:00:00 2001 From: katiekim17 Date: Fri, 6 Feb 2026 01:37:18 +0900 Subject: [PATCH 07/39] =?UTF-8?q?docs:=20=ED=9A=8C=EC=9B=90=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EB=8B=A4?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 회원가입 시퀀스 다이어그램 (핵심 + 예외 플로우) - 내 정보 조회 시퀀스 다이어그램 (헤더 인증 포함) - 비밀번호 변경 시퀀스 다이어그램 (핵심 + 예외 플로우) Co-Authored-By: Claude Opus 4.5 --- MERMAID.md | 167 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 MERMAID.md diff --git a/MERMAID.md b/MERMAID.md new file mode 100644 index 000000000..09f77cf36 --- /dev/null +++ b/MERMAID.md @@ -0,0 +1,167 @@ +# Flow Diagrams + +## 1. 회원가입 (POST /api/v1/members) + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Controller + participant Facade + participant Service + participant DB + + Client->>Controller: POST /api/v1/members (SignUpRequest) + Controller->>Facade: signupMember(request) + Facade->>Facade: Request to MemberModel 변환 + Facade->>Service: saveMember(memberModel) + Service->>DB: findByLoginId (중복 체크) + DB-->>Service: Optional.empty() + Service->>Service: passwordEncoder.encode() + Service->>DB: save(memberModel) + DB-->>Service: savedMember + Service-->>Facade: MemberModel + Facade->>Facade: MemberModel to MemberInfo 변환 + Facade-->>Controller: MemberInfo + Controller-->>Client: 201 Created (SignUpResponse) +``` + +### 예외 흐름 + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Controller + participant Facade + participant Service + participant DB + + Client->>Controller: POST /api/v1/members (중복 ID) + Controller->>Facade: signupMember(request) + Facade->>Service: saveMember(memberModel) + Service->>DB: findByLoginId + DB-->>Service: Optional.of(existingMember) + Service-->>Controller: CoreException (CONFLICT) + Controller-->>Client: 409 Conflict +``` + +--- + +## 2. 내 정보 조회 (GET /api/v1/members/me) + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Controller + participant Facade + participant Service + participant DB + + Client->>Controller: GET /api/v1/members/me + Note over Client,Controller: Headers: X-Loopers-LoginId, X-Loopers-LoginPw + Controller->>Facade: getMyInfo(loginId, password) + Facade->>Service: authenticate(loginId, password) + Service->>DB: findByLoginId + DB-->>Service: MemberModel + Service->>Service: passwordEncoder.matches() + Service-->>Facade: MemberModel (인증 성공) + Facade->>Facade: MemberModel to MemberInfo 변환 (이름 마스킹) + Facade-->>Controller: MemberInfo + Controller-->>Client: 200 OK (MemberInfoResponse) +``` + +### 예외 흐름 - 인증 실패 + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Controller + participant Facade + participant Service + participant DB + + Client->>Controller: GET /api/v1/members/me (틀린 비밀번호) + Controller->>Facade: getMyInfo(loginId, wrongPassword) + Facade->>Service: authenticate(loginId, wrongPassword) + Service->>DB: findByLoginId + DB-->>Service: MemberModel + Service->>Service: passwordEncoder.matches() = false + Service-->>Controller: CoreException (UNAUTHORIZED) + Controller-->>Client: 401 Unauthorized +``` + +--- + +## 3. 비밀번호 변경 (PATCH /api/v1/members/me/password) + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Controller + participant Facade + participant Service + participant DB + + Client->>Controller: PATCH /api/v1/members/me/password + Note over Client,Controller: Headers: X-Loopers-LoginId, X-Loopers-LoginPw + Note over Client,Controller: Body: oldPassword, newPassword + Controller->>Facade: changePassword(loginId, headerPw, oldPw, newPw) + Facade->>Service: authenticate(loginId, headerPw) + Service->>DB: findByLoginId + DB-->>Service: MemberModel + Service->>Service: passwordEncoder.matches(headerPw) + Service-->>Facade: 인증 성공 + Facade->>Facade: new MemberModel(loginId, oldPw) + Facade->>Service: changePassword(memberModel, newPw) + Service->>Service: passwordEncoder.matches(oldPw) 검증 + Service->>Service: newPw != oldPw 검증 + Service->>Service: validatePassword(newPw) 규칙 검증 + Service->>Service: passwordEncoder.encode(newPw) + Service->>DB: Dirty Checking (자동 저장) + Service-->>Facade: void + Facade-->>Controller: void + Controller-->>Client: 200 OK +``` + +### 예외 흐름 - Body의 기존 비밀번호 불일치 + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Controller + participant Facade + participant Service + + Client->>Controller: PATCH (헤더 인증 OK, Body oldPw 틀림) + Controller->>Facade: changePassword(...) + Facade->>Service: authenticate() 성공 + Facade->>Service: changePassword(wrongOldPw, newPw) + Service->>Service: passwordEncoder.matches(wrongOldPw) = false + Service-->>Controller: CoreException (UNAUTHORIZED) + Controller-->>Client: 401 Unauthorized +``` + +### 예외 흐름 - 새 비밀번호가 기존과 동일 + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Controller + participant Facade + participant Service + + Client->>Controller: PATCH (newPw == oldPw) + Controller->>Facade: changePassword(...) + Facade->>Service: authenticate() 성공 + Facade->>Service: changePassword(oldPw, samePassword) + Service->>Service: oldPw 검증 성공 + Service->>Service: newPw == oldPw 체크 + Service-->>Controller: CoreException (BAD_REQUEST) + Controller-->>Client: 400 Bad Request +``` \ No newline at end of file From 3310a3d56c1b923f66c5b96b98232816adeaf8eb Mon Sep 17 00:00:00 2001 From: madirony Date: Wed, 4 Feb 2026 01:27:01 +0900 Subject: [PATCH 08/39] =?UTF-8?q?fix=20:=20=EC=98=88=EC=A0=9C=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0=EC=9D=84=20=EC=9C=84=ED=95=9C=20testcontaine?= =?UTF-8?q?rs=20=EB=B2=84=EC=A0=84=20=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 1 + gradle.properties | 1 + 2 files changed, 2 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index 9c8490b8a..dc167f2e7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -42,6 +42,7 @@ subprojects { dependencyManagement { imports { mavenBom("org.springframework.cloud:spring-cloud-dependencies:${project.properties["springCloudDependenciesVersion"]}") + mavenBom("org.testcontainers:testcontainers-bom:${project.properties["testcontainersVersion"]}") } } diff --git a/gradle.properties b/gradle.properties index 142d7120f..5ae37ac99 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,6 +10,7 @@ springBootVersion=3.4.4 springDependencyManagementVersion=1.1.7 springCloudDependenciesVersion=2024.0.1 ### Library versions ### +testcontainersVersion=2.0.2 springDocOpenApiVersion=2.7.0 springMockkVersion=4.0.2 mockitoVersion=5.14.0 From 7710a024d5ac99972544ed14fe8430ea49353db2 Mon Sep 17 00:00:00 2001 From: katiekim17 Date: Thu, 12 Feb 2026 09:56:46 +0900 Subject: [PATCH 09/39] =?UTF-8?q?feat:=20=EB=AC=B8=EC=84=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/skills/requirements-analysis/SKILL.md | 77 ++ docs/design/01-requirements.md | 69 ++ docs/design/02-sequence-diagrams.md | 86 ++ docs/design/03-class-diagram.md | 150 ++++ docs/design/04-erd.md | 179 ++++ .../01-requirements.md" | 392 +++++++++ .../02-sequence-diagrams.md" | 354 ++++++++ .../03-class-diagram.md" | 599 +++++++++++++ .../04-erd.md" | 432 ++++++++++ .../01-requirements.md" | 213 +++++ .../02-sequence-diagrams.md" | 470 +++++++++++ .../03-class-diagram.md" | 723 ++++++++++++++++ .../04-erd.md" | 797 ++++++++++++++++++ .../01-requirements.md" | 69 ++ .../02-sequence-diagrams.md" | 86 ++ .../03-class-diagram.md" | 150 ++++ .../04-erd.md" | 179 ++++ 17 files changed, 5025 insertions(+) create mode 100644 .claude/skills/requirements-analysis/SKILL.md create mode 100644 docs/design/01-requirements.md create mode 100644 docs/design/02-sequence-diagrams.md create mode 100644 docs/design/03-class-diagram.md create mode 100644 docs/design/04-erd.md create mode 100644 "docs/design/\354\226\264\353\223\234\353\257\274/01-requirements.md" create mode 100644 "docs/design/\354\226\264\353\223\234\353\257\274/02-sequence-diagrams.md" create mode 100644 "docs/design/\354\226\264\353\223\234\353\257\274/03-class-diagram.md" create mode 100644 "docs/design/\354\226\264\353\223\234\353\257\274/04-erd.md" create mode 100644 "docs/design/\354\242\213\354\225\204\354\232\224/01-requirements.md" create mode 100644 "docs/design/\354\242\213\354\225\204\354\232\224/02-sequence-diagrams.md" create mode 100644 "docs/design/\354\242\213\354\225\204\354\232\224/03-class-diagram.md" create mode 100644 "docs/design/\354\242\213\354\225\204\354\232\224/04-erd.md" create mode 100644 "docs/design/\354\243\274\353\254\270\354\203\235\354\204\261/01-requirements.md" create mode 100644 "docs/design/\354\243\274\353\254\270\354\203\235\354\204\261/02-sequence-diagrams.md" create mode 100644 "docs/design/\354\243\274\353\254\270\354\203\235\354\204\261/03-class-diagram.md" create mode 100644 "docs/design/\354\243\274\353\254\270\354\203\235\354\204\261/04-erd.md" diff --git a/.claude/skills/requirements-analysis/SKILL.md b/.claude/skills/requirements-analysis/SKILL.md new file mode 100644 index 000000000..3485a8af8 --- /dev/null +++ b/.claude/skills/requirements-analysis/SKILL.md @@ -0,0 +1,77 @@ +--- +name: requirements-analysis +description: + 제공된 요구사항을 분석하고, 개발자와의 질문/대답을 통해 애매한 요구사항을 명확히 하여 정리합니다. + 모든 정리가 끝나면, 시퀀스 다이어그램, 클래스 다이어그램, ERD 등을 Mermaid 문법으로 작성한다. + 요구사항이 제공되었을 때, 코드를 작성하기 전 이를 명확히 하는 데에 사용합니다. +--- +요구사항을 분석할 때 반드시 다음 흐름을 따른다. +### 1️⃣ 요구사항을 그대로 믿지 말고, 문제 상황으로 다시 설명한다. +- 요구사항 문장을 정리하는 데서 끝내지 않는다. +- "무엇을 만들까?"가 아니라 "지금 어떤 문제가 있고, 그걸 왜 해결하려는가?" 로 재해석한다. +- 다음 관점을 분리해서 정리한다: + - 사용자 관점 + - 비즈니스 관점 + - 시스템 관점 +> 예시 +> "주문 실패 시 결제를 취소한다" → "결제 성공/실패와 주문 상태가 어긋나지 않도록 일관성을 유지하려는 문제" + +### 2️⃣ 애매한 요구사항을 숨기지 말고 드러낸다 +- 추측하거나 알아서 결정하지 않는다. +- 요구사항에서 결정되지 않은 부분을 명시적으로 나열한다. + **다음 유형의 질문을 반드시 포함한다:** +- 정책 질문: 기준 시점, 성공/실패 조건, 예외 처리 규칙 +- 경계 질문: 어디까지가 한 책임인가, 어디서 분리되는가 +- 확장 질문: 나중에 바뀔 가능성이 있는가 + +### 3️⃣ 요구사항 명확화를 위한 질문을 개발자 답변이 쉬운 형태로 제시한다 +- 질문은 우선순위를 가진다 (중요한 것부터). +- 선택지가 있는 경우, 옵션 + 영향도를 함께 제시한다. +> 형식 예시: +- 선택지 A: 하나의 트랜잭션으로 처리 → 구현 단순, 확장성 낮음 +- 선택지 B: 단계별 분리 → 구조 복잡, 확장/보상 처리 유리 + +### 4️⃣ 합의된 내용을 바탕으로 개념 모델부터 잡는다 +- 바로 코드나 기술 얘기로 들어가지 않는다. +- 먼저 다음을 정의한다: + - 액터 (사용자, 외부 시스템) + - 핵심 도메인 + - 보조/외부 시스템 +- 이 단계는 “구현”이 아니라 설계 사고 정렬이 목적이다. + +### 5️⃣ 다이어그램은 항상 이유 → 다이어그램 → 해석 순서로 제시한다 +**다이어그램을 그리기 전에 반드시 설명한다** +- 왜 이 다이어그램이 필요한지 +- 이 다이어그램으로 무엇을 검증하려는지 + +**다이어그램은 Mermaid 문법으로 작성한다** +사용 기준: +- **시퀀스 다이어그램** + - 책임 분리 + - 호출 순서 + - 트랜잭션 경계 확인 +- **클래스 다이어그램** + - 도메인 책임 + - 의존 방향 + - 응집도 확인 +- **ERD** + - 영속성 구조 + - 관계의 주인 + - 정규화 여부 + +### 6️⃣ 다이어그램을 던지고 끝내지 말고 읽는 법을 짚어준다 +- "이 구조에서 특히 봐야 할 포인트"를 2~3줄로 설명한다. +- 설계 의도가 드러나도록 해석을 붙인다. + +### 7️⃣ 설계의 잠재 리스크를 반드시 언급한다 +- 현재 설계가 가질 수 있는 위험을 숨기지 않는다. + - 트랜잭션 비대화 + - 도메인 간 결합도 증가 + - 정책 변경 시 영향 범위 확대 +- 해결책은 정답처럼 말하지 않고 선택지로 제시한다. + +### 톤 & 스타일 가이드 +- 강의처럼 설명하지 말고 설계 리뷰 톤을 유지한다 +- 정답이라고 제시하기보다, 다른 선택지가 있다면 이를 제공하도록 한다. +- 코드보다 의도, 책임, 경계를 더 중요하게 다룬다 +- 구현 전에 생각해야 할 것을 끌어내는 데 집중한다 \ No newline at end of file diff --git a/docs/design/01-requirements.md b/docs/design/01-requirements.md new file mode 100644 index 000000000..d52c9d146 --- /dev/null +++ b/docs/design/01-requirements.md @@ -0,0 +1,69 @@ +주문 생성 시스템 - 요구사항 분석 및 설계 │ +│ │ +│ Context │ +│ │ +│ 현재 시스템에는 Example, Member 도메인만 존재하며, 커머스의 핵심인 상품/재고/주문 도메인이 없다. │ +│ "여러 상품을 한 번에 주문할 때, 재고와 주문 상태 사이의 불일치가 발생하지 않도록" All-or-Nothing 정책으로 주문 생성 기능을 구현해야 │ +│ 한다. │ +│ │ +│ --- │ +│ 1️⃣문제 상황 재해석 │ +│ │ +│ 사용자 관점: 여러 상품을 한 번에 주문했는데 일부만 처리되면 혼란. 되거나/안 되거나 명확한 결과를 기대한다. │ +│ │ +│ 비즈니스 관점: 재고-주문 불일치는 클레임 직결. 재고 확인과 주문 생성이 원자적으로 묶여야 하며, 향후 부분취소 확장도 고려해야 한다. │ +│ │ +│ 시스템 관점: Order + OrderItem 생성 + 재고 차감이 단일 트랜잭션으로 처리되어야 한다. FK 없이 ID 참조로 느슨한 결합 유지, 스냅샷으로 시점 │ +│ 정합성 보장. │ +│ │ +│ --- │ +│ 2️⃣합의된 설계 결정 │ +│ ┌─────────────────────┬─────────────────────────────────────────────────────────────────────────────┐ │ +│ │ 항목 │ 결정 │ │ +│ ├─────────────────────┼─────────────────────────────────────────────────────────────────────────────┤ │ +│ │ Product/Stock/Brand │ 주문과 함께 새로 생성 │ │ +│ ├─────────────────────┼─────────────────────────────────────────────────────────────────────────────┤ │ +│ │ Product 구조 │ Product + Brand 별도 엔티티 분리 │ │ +│ ├─────────────────────┼─────────────────────────────────────────────────────────────────────────────┤ │ +│ │ Stock 구조 │ 별도 Stock 엔티티 (productId, quantity) │ │ +│ ├─────────────────────┼─────────────────────────────────────────────────────────────────────────────┤ │ +│ │ OrderStatus │ Enum 미리 정의 (CREATED, CONFIRMED, CANCELLED), 전이 로직은 CREATED까지만 │ │ +│ ├─────────────────────┼─────────────────────────────────────────────────────────────────────────────┤ │ +│ │ OrderItemStatus │ Enum 미리 정의 (ORDERED, CANCELLED) — 부분취소 확장성 │ │ +│ ├─────────────────────┼─────────────────────────────────────────────────────────────────────────────┤ │ +│ │ 동시성 제어 │ 비관적 락 (SELECT FOR UPDATE on Stock) │ │ +│ ├─────────────────────┼─────────────────────────────────────────────────────────────────────────────┤ │ +│ │ 인증 │ 기존 X-Loopers-LoginId 헤더 인증 │ │ +│ ├─────────────────────┼─────────────────────────────────────────────────────────────────────────────┤ │ +│ │ FK 제약조건 │ 없음, ID 참조만 │ │ +│ ├─────────────────────┼─────────────────────────────────────────────────────────────────────────────┤ │ +│ │ Value Objects │ Money (가격/금액), Quantity (수량), ProductSnapshot (주문 시점 상품 스냅샷) │ │ +│ ├─────────────────────┼─────────────────────────────────────────────────────────────────────────────┤ │ +│ │ 도메인 서비스 │ StockDeductionService — 재고 차감 검증 로직 분리 │ │ +│ └─────────────────────┴─────────────────────────────────────────────────────────────────────────────┘ │ +│ --- │ +│ 3️⃣개념 모델 │ +│ │ +│ 액터 │ +│ - 사용자 (회원): 주문을 생성하는 주체 │ +│ - 관리자 (미구현): 상품/브랜드/재고를 관리하는 주체 (테스트 데이터로 대체) │ +│ │ +│ 핵심 도메인 │ +│ - Order (주문): 주문 생성, 상태 관리 │ +│ - OrderItem (주문 항목): 개별 상품 주문 정보 + ProductSnapshot VO │ +│ - Stock (재고): 재고 수량 관리, 동시성 제어 대상 │ +│ │ +│ 보조 도메인 │ +│ - Product (상품): 상품 정보 제공 │ +│ - Brand (브랜드): 브랜드 정보 제공 │ +│ - Member (회원): 인증, 주문자 식별 (기존 구현) │ +│ │ +│ Value Objects │ +│ - Money: 가격/금액을 감싸는 VO. value >= 0 불변식 보장. Product.price, Order.totalAmount, ProductSnapshot.price에 사용 │ +│ - Quantity: 수량을 감싸는 VO. value > 0 불변식 보장. OrderItem.quantity에 사용 │ +│ - ProductSnapshot: 주문 시점 상품 정보(@Embeddable). productName, price(Money), brandName을 묶어 OrderItem에 내장 │ +│ │ +│ 도메인 서비스 │ +│ - StockDeductionService: 여러 Stock 엔티티에 걸친 All-or-Nothing 재고 차감을 담당. 단일 엔티티에 속하지 않는 크로스 엔티티 비즈니스 │ +│ 로직. │ +│ \ No newline at end of file diff --git a/docs/design/02-sequence-diagrams.md b/docs/design/02-sequence-diagrams.md new file mode 100644 index 000000000..8a02999b3 --- /dev/null +++ b/docs/design/02-sequence-diagrams.md @@ -0,0 +1,86 @@ +️⃣시퀀스 다이어그램 │ +│ │ +│ 왜 필요한가 │ +│ │ +│ 주문 생성은 Member, Product, Brand, Stock, Order, OrderItem 6개 도메인을 횡단한다. 호출 순서, 트랜잭션 경계, All-or-Nothing 실패 시점을 │ +│ 검증해야 한다. │ +│ │ +│ 검증 포인트 │ +│ │ +│ - 트랜잭션 경계가 어디서 시작하고 끝나는지 │ +│ - StockDeductionService의 책임 범위 │ +│ - 비관적 락 획득 순서 (데드락 방지) │ +│ - 재고 부족 시 롤백 시점 │ +│ │ +│ sequenceDiagram │ +│ actor User │ +│ participant Controller as OrderV1Controller │ +│ participant Facade as OrderFacade │ +│ participant MemberSvc as MemberService │ +│ participant ProductSvc as ProductService │ +│ participant OrderSvc as OrderService │ +│ participant StockDeduct as StockDeductionService │ +│ participant StockRepo as StockRepository │ +│ participant DB as Database │ +│ │ +│ User->>Controller: POST /api/v1/orders
[Header: X-Loopers-LoginId/LoginPw]
[Body: items[{productId, quantity}]] │ +│ Controller->>Facade: createOrder(loginId, password, request) │ +│ │ +│ Note over Facade: 트랜잭션 밖: 인증 + 조회 │ +│ Facade->>MemberSvc: authenticate(loginId, password) │ +│ MemberSvc-->>Facade: MemberModel (memberId) │ +│ │ +│ Facade->>ProductSvc: getProducts(productIds) │ +│ ProductSvc-->>Facade: List │ +│ Facade->>ProductSvc: getBrands(brandIds) │ +│ ProductSvc-->>Facade: List │ +│ │ +│ alt 존재하지 않는 상품/브랜드 │ +│ Facade-->>Controller: CoreException(NOT_FOUND) │ +│ Controller-->>User: 404 Not Found │ +│ end │ +│ │ +│ Note over Facade,DB: ── 트랜잭션 시작 (OrderService.createOrder) ── │ +│ │ +│ Facade->>OrderSvc: createOrder(memberId, products, brandMap, items) │ +│ │ +│ OrderSvc->>StockDeduct: deductAll(items) │ +│ Note over StockDeduct: productId 오름차순 정렬 → 데드락 방지 │ +│ loop 각 상품 (productId 오름차순) │ +│ StockDeduct->>StockRepo: findByProductIdWithLock(productId) │ +│ StockRepo->>DB: SELECT ... FOR UPDATE │ +│ DB-->>StockRepo: StockModel (locked) │ +│ StockRepo-->>StockDeduct: StockModel │ +│ │ +│ alt 재고 부족 │ +│ Note over StockDeduct,DB: 예외 → 트랜잭션 롤백 (All-or-Nothing) │ +│ StockDeduct-->>OrderSvc: CoreException(BAD_REQUEST) │ +│ OrderSvc-->>Facade: 예외 전파 │ +│ Facade-->>Controller: 예외 전파 │ +│ Controller-->>User: 400 Bad Request │ +│ end │ +│ │ +│ StockDeduct->>StockDeduct: stock.deduct(Quantity) │ +│ end │ +│ StockDeduct-->>OrderSvc: 차감 완료 │ +│ │ +│ Note over OrderSvc: 주문 생성 │ +│ OrderSvc->>OrderSvc: new OrderModel(memberId, Money(totalAmount), CREATED) │ +│ OrderSvc->>DB: INSERT orders │ +│ │ +│ loop 각 주문 항목 │ +│ OrderSvc->>OrderSvc: new OrderItemModel(orderId, productId,
ProductSnapshot, Quantity, ORDERED) │ +│ end │ +│ OrderSvc->>DB: INSERT order_item × N │ +│ │ +│ Note over Facade,DB: ── 트랜잭션 커밋 ── │ +│ │ +│ OrderSvc-->>Facade: OrderModel + OrderItems │ +│ Facade-->>Controller: OrderInfo │ +│ Controller-->>User: 201 Created + OrderResponse │ +│ │ +│ 읽는 법 │ +│ │ +│ - Facade는 조율자: 인증/조회는 트랜잭션 밖에서 처리하여 핵심 쓰기 트랜잭션 범위를 최소화. │ +│ - StockDeductionService가 재고 책임: 락 획득 순서, 재고 검증, 차감을 모두 담당. OrderService는 이 결과를 신뢰하고 주문만 생성. │ +│ - 실패 시점이 명확: 재고 부족이면 StockDeductionService에서 즉시 예외 → 전체 롤백. \ No newline at end of file diff --git a/docs/design/03-class-diagram.md b/docs/design/03-class-diagram.md new file mode 100644 index 000000000..a4c00279e --- /dev/null +++ b/docs/design/03-class-diagram.md @@ -0,0 +1,150 @@ +5️⃣클래스 다이어그램 │ +│ │ +│ 왜 필요한가 │ +│ │ +│ 5개 엔티티 + 3개 VO + 도메인 서비스의 책임 분배, 의존 방향을 검증해야 한다. │ +│ │ +│ 검증 포인트 │ +│ │ +│ - VO가 어떤 엔티티에서 사용되는지 │ +│ - StockDeductionService의 위치와 의존 방향 │ +│ - OrderService와 StockDeductionService의 책임 경계 │ +│ │ +│ classDiagram │ +│ direction TB │ +│ │ +│ class BaseEntity { │ +│ <> │ +│ #Long id │ +│ #ZonedDateTime createdAt │ +│ #ZonedDateTime updatedAt │ +│ #ZonedDateTime deletedAt │ +│ +delete() │ +│ +restore() │ +│ } │ +│ │ +│ class Money { │ +│ <> │ +│ -Long value │ +│ +Money(Long value) │ +│ +getValue() Long │ +│ } │ +│ note for Money "불변식: value >= 0\nProduct.price, Order.totalAmount,\nProductSnapshot.price에 사용" │ +│ │ +│ class Quantity { │ +│ <> │ +│ -Long value │ +│ +Quantity(Long value) │ +│ +getValue() Long │ +│ } │ +│ note for Quantity "불변식: value > 0\nOrderItem.quantity에 사용" │ +│ │ +│ class ProductSnapshot { │ +│ <> │ +│ -String productName │ +│ -Money price │ +│ -String brandName │ +│ +ProductSnapshot(productName, price, brandName) │ +│ } │ +│ note for ProductSnapshot "주문 시점 상품 정보 스냅샷\nOrderItem에 @Embedded로 내장" │ +│ │ +│ class BrandModel { │ +│ -String name │ +│ +BrandModel(name) │ +│ } │ +│ │ +│ class ProductModel { │ +│ -Long brandId │ +│ -String name │ +│ -Money price │ +│ -String description │ +│ +ProductModel(brandId, name, price, description) │ +│ } │ +│ │ +│ class StockModel { │ +│ -Long productId │ +│ -Long quantity │ +│ +StockModel(productId, quantity) │ +│ +deduct(Quantity amount) void │ +│ +hasEnoughStock(Quantity amount) boolean │ +│ } │ +│ │ +│ class OrderModel { │ +│ -Long memberId │ +│ -OrderStatus status │ +│ -Money totalAmount │ +│ +OrderModel(memberId, totalAmount, status) │ +│ } │ +│ │ +│ class OrderItemModel { │ +│ -Long orderId │ +│ -Long productId │ +│ -OrderItemStatus status │ +│ -ProductSnapshot snapshot │ +│ -Quantity quantity │ +│ +OrderItemModel(orderId, productId, status, snapshot, quantity) │ +│ } │ +│ │ +│ class OrderStatus { │ +│ <> │ +│ CREATED │ +│ CONFIRMED │ +│ CANCELLED │ +│ } │ +│ │ +│ class OrderItemStatus { │ +│ <> │ +│ ORDERED │ +│ CANCELLED │ +│ } │ +│ │ +│ BaseEntity <|-- BrandModel │ +│ BaseEntity <|-- ProductModel │ +│ BaseEntity <|-- StockModel │ +│ BaseEntity <|-- OrderModel │ +│ BaseEntity <|-- OrderItemModel │ +│ │ +│ ProductModel --> Money : price │ +│ OrderModel --> Money : totalAmount │ +│ OrderModel --> OrderStatus │ +│ OrderItemModel --> ProductSnapshot : snapshot │ +│ OrderItemModel --> Quantity : quantity │ +│ OrderItemModel --> OrderItemStatus │ +│ ProductSnapshot --> Money : price │ +│ │ +│ class StockDeductionService { │ +│ <<도메인 서비스>> │ +│ +deductAll(List~OrderItemRequest~ items) void │ +│ } │ +│ note for StockDeductionService "크로스 엔티티 비즈니스 로직\nproductId 오름차순 락 획득\nAll-or-Nothing 재고 차감" │ +│ │ +│ class OrderFacade { │ +│ +createOrder(loginId, password, request) OrderInfo │ +│ } │ +│ class OrderService { │ +│ +createOrder(memberId, products, brandMap, items) OrderModel │ +│ } │ +│ class ProductService { │ +│ +getProducts(productIds) List~ProductModel~ │ +│ +getBrands(brandIds) List~BrandModel~ │ +│ } │ +│ │ +│ OrderFacade --> MemberService │ +│ OrderFacade --> ProductService │ +│ OrderFacade --> OrderService │ +│ OrderService --> StockDeductionService │ +│ OrderService ..> OrderRepository │ +│ OrderService ..> OrderItemRepository │ +│ StockDeductionService ..> StockRepository │ +│ ProductService ..> ProductRepository │ +│ ProductService ..> BrandRepository │ +│ │ +│ 읽는 법 │ +│ │ +│ - VO 적용 범위가 명확: Money는 가격/금액이 나오는 3곳, Quantity는 주문 수량, ProductSnapshot은 OrderItem 내 스냅샷. 각각 명확한 불변식을 │ +│ 가진다. │ +│ - StockDeductionService: OrderService에서 분리된 도메인 서비스. "여러 Stock에 걸친 원자적 차감"이라는 단일 엔티티에 속하지 않는 책임을 │ +│ 담당. │ +│ - StockModel.deduct(): 실제 차감 로직은 엔티티 내부에 유지. 도메인 서비스는 "어떤 순서로, 어떤 조건에서" 차감할지를 조율. │ +│ - StockModel.quantity는 Long: 재고는 0이 될 수 있으므로 Quantity VO(>0)를 쓰지 않고 Long으로 유지. deduct()의 파라미터만 Quantity VO. │ +│ \ No newline at end of file diff --git a/docs/design/04-erd.md b/docs/design/04-erd.md new file mode 100644 index 000000000..3dd152e52 --- /dev/null +++ b/docs/design/04-erd.md @@ -0,0 +1,179 @@ +6️⃣ERD │ +│ │ +│ 왜 필요한가 │ +│ │ +│ FK 없이 ID 참조만 사용하므로, 논리적 관계를 문서로 명확히 해야 한다. VO는 DB 컬럼으로 풀어져 저장된다. │ +│ │ +│ 검증 포인트 │ +│ │ +│ - stock.product_id에 UNIQUE 제약 (1:1) │ +│ - order_item의 스냅샷 컬럼이 실제로 어떻게 매핑되는지 │ +│ - orders 테이블명 (MySQL 예약어 회피) │ +│ - VO(Money, Quantity)는 BIGINT 컬럼으로, ProductSnapshot은 개별 컬럼으로 매핑 │ +│ │ +│ erDiagram │ +│ brand { │ +│ BIGINT id PK "AUTO_INCREMENT" │ +│ VARCHAR name "NOT NULL" │ +│ DATETIME created_at "NOT NULL" │ +│ DATETIME updated_at "NOT NULL" │ +│ DATETIME deleted_at "NULL" │ +│ } │ +│ │ +│ product { │ +│ BIGINT id PK "AUTO_INCREMENT" │ +│ BIGINT brand_id "NOT NULL" │ +│ VARCHAR name "NOT NULL" │ +│ BIGINT price "NOT NULL (Money VO)" │ +│ VARCHAR description "NULL" │ +│ DATETIME created_at "NOT NULL" │ +│ DATETIME updated_at "NOT NULL" │ +│ DATETIME deleted_at "NULL" │ +│ } │ +│ │ +│ stock { │ +│ BIGINT id PK "AUTO_INCREMENT" │ +│ BIGINT product_id "NOT NULL, UNIQUE" │ +│ BIGINT quantity "NOT NULL, DEFAULT 0" │ +│ DATETIME created_at "NOT NULL" │ +│ DATETIME updated_at "NOT NULL" │ +│ DATETIME deleted_at "NULL" │ +│ } │ +│ │ +│ orders { │ +│ BIGINT id PK "AUTO_INCREMENT" │ +│ BIGINT member_id "NOT NULL" │ +│ VARCHAR status "NOT NULL" │ +│ BIGINT total_amount "NOT NULL (Money VO)" │ +│ DATETIME created_at "NOT NULL" │ +│ DATETIME updated_at "NOT NULL" │ +│ DATETIME deleted_at "NULL" │ +│ } │ +│ │ +│ order_item { │ +│ BIGINT id PK "AUTO_INCREMENT" │ +│ BIGINT order_id "NOT NULL" │ +│ BIGINT product_id "NOT NULL" │ +│ VARCHAR status "NOT NULL" │ +│ VARCHAR product_name "NOT NULL (ProductSnapshot)" │ +│ BIGINT product_price "NOT NULL (ProductSnapshot.Money)" │ +│ VARCHAR brand_name "NOT NULL (ProductSnapshot)" │ +│ BIGINT quantity "NOT NULL (Quantity VO)" │ +│ DATETIME created_at "NOT NULL" │ +│ DATETIME updated_at "NOT NULL" │ +│ DATETIME deleted_at "NULL" │ +│ } │ +│ │ +│ brand ||--o{ product : "has" │ +│ product ||--|| stock : "has" │ +│ member ||--o{ orders : "places" │ +│ orders ||--o{ order_item : "contains" │ +│ product ||--o{ order_item : "referenced by" │ +│ │ +│ 읽는 법 │ +│ │ +│ - VO → 컬럼 매핑: Money VO는 BIGINT 단일 컬럼, Quantity VO도 BIGINT 단일 컬럼, ProductSnapshot은 3개 컬럼(product_name, product_price, │ +│ brand_name)으로 풀어진다. │ +│ - stock ↔ product 1:1: 하나의 상품에 하나의 재고 레코드. UNIQUE 제약으로 보장. │ +│ - 논리적 참조만 존재: FK 없이 애플리케이션 레벨에서 ID 참조. +️⃣잠재 리스크 │ +│ │ +│ 트랜잭션 범위 │ +│ │ +│ Stock 락 + Order/OrderItem 저장이 단일 트랜잭션이므로, 주문 항목 수가 많으면 락 보유 시간이 길어진다. │ +│ - (A) 현재 유지 → 일반적 커머스 수준에서 충분. 단순하고 All-or-Nothing 보장 명확. │ +│ - (B) 2-phase (예약→확정) → 트랜잭션 분리 가능하나 복잡도 증가. 현 시점에서는 오버엔지니어링. │ +│ │ +│ 비관적 락 병목 │ +│ │ +│ 인기 상품에 동시 주문이 몰리면 Stock row 락 대기 발생. │ +│ - (A) 비관적 락 유지 → 정합성 확실, 트래픽이 극단적이지 않으면 충분. │ +│ - (B) Redis 분산 락 → 수평 확장 시 고려. 단일 DB에서는 불필요. │ +│ │ +│ 데드락 방지 │ +│ │ +│ 설계에 이미 반영: productId 오름차순으로 정렬 후 개별 락 획득 (StockDeductionService 책임). 애플리케이션 레벨에서 순서를 보장하는 것이 │ +│ DB 엔진 의존성보다 안전. │ +│ │ +│ VO JPA 매핑 복잡도 │ +│ │ +│ @Embeddable VO 사용 시 JPA 매핑이 추가된다. 특히 ProductSnapshot 내부의 Money VO는 중첩 @Embeddable이 된다. │ +│ - @AttributeOverrides로 컬럼명을 명시적으로 지정하여 해결. │ +│ - 복잡하다면 ProductSnapshot.price만 Long으로 직접 저장하는 것도 선택지. │ +│ │ +│ 스냅샷 시점 차이 │ +│ │ +│ Facade에서 Product/Brand를 조회한 시점과 OrderService에서 저장하는 시점의 미세한 차이가 있으나, 동일 HTTP 요청 내이므로 비즈니스적으로 │ +│ 수용 가능. │ +│ │ +│ --- │ +│ 생성할 파일 목록 │ +│ │ +│ domain 레이어 (com.loopers.domain) │ +│ │ +│ 공통 VO │ +│ - domain/vo/Money.java — 가격/금액 VO (@Embeddable, value >= 0) │ +│ - domain/vo/Quantity.java — 수량 VO (@Embeddable, value > 0) │ +│ │ +│ Brand │ +│ - domain/brand/BrandModel.java │ +│ - domain/brand/BrandRepository.java │ +│ │ +│ Product │ +│ - domain/product/ProductModel.java — Money VO 사용 │ +│ - domain/product/ProductRepository.java │ +│ - domain/product/ProductService.java │ +│ │ +│ Stock │ +│ - domain/stock/StockModel.java — deduct(Quantity), hasEnoughStock(Quantity) 메서드 포함 │ +│ - domain/stock/StockRepository.java │ +│ - domain/stock/StockDeductionService.java — 도메인 서비스: All-or-Nothing 재고 차감 │ +│ │ +│ Order │ +│ - domain/order/OrderModel.java — Money VO 사용 │ +│ - domain/order/OrderStatus.java │ +│ - domain/order/OrderItemModel.java — ProductSnapshot, Quantity VO 사용 │ +│ - domain/order/OrderItemStatus.java │ +│ - domain/order/ProductSnapshot.java — @Embeddable VO (productName, Money price, brandName) │ +│ - domain/order/OrderRepository.java │ +│ - domain/order/OrderItemRepository.java │ +│ - domain/order/OrderService.java │ +│ │ +│ infrastructure 레이어 (com.loopers.infrastructure) │ +│ │ +│ - infrastructure/brand/BrandJpaRepository.java │ +│ - infrastructure/brand/BrandRepositoryImpl.java │ +│ - infrastructure/product/ProductJpaRepository.java │ +│ - infrastructure/product/ProductRepositoryImpl.java │ +│ - infrastructure/stock/StockJpaRepository.java — @Lock(PESSIMISTIC_WRITE) 포함 │ +│ - infrastructure/stock/StockRepositoryImpl.java │ +│ - infrastructure/order/OrderJpaRepository.java │ +│ - infrastructure/order/OrderRepositoryImpl.java │ +│ - infrastructure/order/OrderItemJpaRepository.java │ +│ - infrastructure/order/OrderItemRepositoryImpl.java │ +│ │ +│ application 레이어 (com.loopers.application) │ +│ │ +│ - application/order/OrderFacade.java │ +│ - application/order/OrderInfo.java │ +│ │ +│ interfaces 레이어 (com.loopers.interfaces.api) │ +│ │ +│ - interfaces/api/order/OrderV1Controller.java │ +│ - interfaces/api/order/OrderV1ApiSpec.java │ +│ - interfaces/api/order/OrderV1Dto.java │ +│ │ +│ 참조할 기존 패턴 파일 │ +│ │ +│ - domain/example/ExampleModel.java — 엔티티 패턴 (BaseEntity 상속, guard()) │ +│ - domain/example/ExampleService.java — 서비스 패턴 (@Component + @Transactional) │ +│ - application/example/ExampleFacade.java — Facade 패턴 │ +│ - interfaces/api/member/MemberV1Controller.java — 헤더 인증 + ApiResponse 패턴 │ +│ - modules/jpa/src/main/java/com/loopers/domain/BaseEntity.java — BaseEntity │ +│ │ +│ 검증 방법 │ +│ │ +│ 1. 단위 테스트: Money/Quantity VO 불변식, StockModel.deduct(), ProductSnapshot 생성, OrderModel 생성 │ +│ 2. 통합 테스트: OrderService.createOrder() — 성공/재고부족 롤백, StockDeductionService 동시성 시나리오 │ +│ 3. E2E 테스트: POST /api/v1/orders 호출 → 주문 생성 확인, 재고 차감 확인 │ +│ 4. HTTP 파일: http/commerce-api/order-v1.http로 수동 확인 \ No newline at end of file diff --git "a/docs/design/\354\226\264\353\223\234\353\257\274/01-requirements.md" "b/docs/design/\354\226\264\353\223\234\353\257\274/01-requirements.md" new file mode 100644 index 000000000..c5baccf56 --- /dev/null +++ "b/docs/design/\354\226\264\353\223\234\353\257\274/01-requirements.md" @@ -0,0 +1,392 @@ +# 요구사항 명세서 + +## 📋 문서 정보 +- **작성일**: 2026-02-12 +- **버전**: 1.0 +- **목적**: 브랜드 & 상품 관리 시스템 요구사항 정의 + +--- + +## 1. 문제 상황 정의 + +### 1.1 사용자 관점 +- **어드민 사용자**: 브랜드와 상품을 등록/수정/비활성화하며 카탈로그를 관리해야 함 +- **일반 고객**: 활성화된 브랜드와 상품 정보를 조회하고, 상호작용(좋아요)을 통해 관심을 표현해야 함 +- **문제**: 어드민의 관리 정보와 고객에게 노출되는 정보가 다르며, 역할별 접근 제어가 필요함 + +### 1.2 비즈니스 관점 +- **카탈로그 일관성**: 브랜드가 비활성화되면 해당 상품들도 함께 비활성화되어야 함 +- **변경 이력 추적**: 상품의 모든 변경 사항(브랜드 변경 포함)을 스냅샷으로 보관하여 감사 추적이 가능해야 함 +- **재고 관리**: 상품의 재고 상태를 추적하여 품절 시 고객에게 적절히 표시해야 함 +- **문제**: 브랜드-상품 간 상태 동기화, 이력 관리, 재고 정합성 유지가 필요함 + +### 1.3 시스템 관점 +- **인증/인가**: LDAP 헤더(`X-Loopers-Ldap: loopers.admin`)로 어드민을 식별하여 접근 제어 +- **데이터 정합성**: 브랜드-상품 간 참조 무결성, 상태 연쇄 변경, 변경 이력 스냅샷 저장 +- **확장성**: 검색/필터링, 정렬, 페이징 기능 확장 가능하도록 설계 +- **문제**: 비활성화 트랜잭션 처리, 스냅샷 저장 전략, 모듈 간 책임 분리가 필요함 + +--- + +## 2. 핵심 도메인 개념 + +### 2.1 액터 (Actors) +- **Admin User**: 사내 어드민 시스템 사용자 (LDAP 인증) +- **Customer**: 일반 고객 (브랜드/상품 조회 및 상호작용) + +### 2.2 핵심 도메인 (Core Domain) +- **Brand**: 브랜드 (제조사/판매자) + - 상태: ACTIVE, INACTIVE, PENDING, SCHEDULED + - 브랜드 비활성화 시 모든 상품 연쇄 비활성화 + +- **Product**: 상품 + - 상태: ACTIVE, INACTIVE, PENDING, SCHEDULED, OUT_OF_STOCK + - 브랜드 변경 가능 (이력 추적) + - 재고 소진 시 OUT_OF_STOCK 상태 + +- **ProductHistory**: 상품 변경 이력 (스냅샷) + - 상품의 모든 변경 사항을 버전별로 저장 + - 특정 시점의 상품 상태 복원 가능 + +- **ProductLike**: 고객의 상품 좋아요 + - 고객별 좋아요 관리 + - 좋아요 수 집계 + +### 2.3 보조/외부 시스템 +- **LDAP 인증 시스템**: 어드민 식별 +- **이벤트 시스템**: 브랜드 비활성화 이벤트 → 상품 일괄 비활성화 처리 + +--- + +## 3. 유저 시나리오 기반 기능 정의 + +### 3.1 어드민 사용자 시나리오 + +#### 시나리오 1: 브랜드 관리 +``` +AS AN 어드민 사용자 +I WANT TO 브랜드를 등록/수정/조회/비활성화할 수 있다 +SO THAT 판매 가능한 브랜드 카탈로그를 관리할 수 있다 + +Given: LDAP 인증된 어드민 사용자가 로그인한 상태 +When: 브랜드 등록/수정 요청 시 +Then: 브랜드 정보가 저장되고, 어드민에게 결과가 반환된다 + +Given: 활성화된 브랜드에 활성 상품이 10개 있는 상태 +When: 브랜드 비활성화 요청 시 +Then: + - 브랜드 상태가 INACTIVE로 변경된다 + - BrandDeactivatedEvent가 발행된다 + - 이벤트 리스너가 해당 브랜드의 모든 상품을 INACTIVE로 일괄 업데이트한다 + - 각 상품의 변경 이력이 ProductHistory에 스냅샷으로 저장된다 +``` + +**기능 요구사항**: +- `POST /api-admin/v1/brands` - 브랜드 등록 +- `PUT /api-admin/v1/brands/{brandId}` - 브랜드 정보 수정 +- `GET /api-admin/v1/brands?page=0&size=20&status=ACTIVE&sort=createdAt,desc` - 브랜드 목록 조회 (페이징, 필터링, 정렬) +- `GET /api-admin/v1/brands/{brandId}` - 브랜드 상세 조회 +- `DELETE /api-admin/v1/brands/{brandId}` - 브랜드 비활성화 (상태 변경) + +**비기능 요구사항**: +- LDAP 헤더 필수 검증 (`X-Loopers-Ldap: loopers.admin`) +- 브랜드 비활성화 시 트랜잭션 분리 (브랜드 상태 변경 → 이벤트 → 비동기 상품 처리) +- 상품 수가 많아도 응답 시간 2초 이내 (브랜드 상태만 먼저 변경 후 응답) + +--- + +#### 시나리오 2: 상품 관리 +``` +AS AN 어드민 사용자 +I WANT TO 상품을 등록/수정/조회/비활성화할 수 있다 +SO THAT 판매 가능한 상품 카탈로그를 관리할 수 있다 + +Given: 활성화된 브랜드 A가 존재하는 상태 +When: 브랜드 A에 속한 상품을 등록 요청 시 +Then: + - 브랜드 A의 존재 여부를 확인한다 + - 상품이 저장되고, 초기 스냅샷이 ProductHistory에 저장된다 + +Given: 상품 P가 브랜드 A에 속한 상태 +When: 상품 P의 브랜드를 B로 변경 요청 시 +Then: + - 브랜드 B의 존재 여부를 확인한다 + - 상품 P의 브랜드가 B로 변경된다 + - 변경 시점의 전체 상품 정보가 ProductHistory에 스냅샷으로 저장된다 + +Given: 재고가 0인 상품이 있는 상태 +When: 재고 소진 처리 요청 시 +Then: + - 상품 상태가 OUT_OF_STOCK으로 변경된다 + - 고객 API에서 해당 상품은 "품절" 표시와 함께 조회된다 +``` + +**기능 요구사항**: +- `POST /api-admin/v1/products` - 상품 등록 + - 브랜드 존재 여부 검증 + - 초기 스냅샷 자동 저장 +- `PUT /api-admin/v1/products/{productId}` - 상품 정보 수정 + - 브랜드 변경 가능 + - 변경 시점 스냅샷 자동 저장 +- `GET /api-admin/v1/products?page=0&size=20&brandId={brandId}&status=ACTIVE&sort=name,asc` - 상품 목록 조회 +- `GET /api-admin/v1/products/{productId}` - 상품 상세 조회 +- `DELETE /api-admin/v1/products/{productId}` - 상품 비활성화 +- `GET /api-admin/v1/products/{productId}/history` - 상품 변경 이력 조회 + +**비기능 요구사항**: +- 상품 등록/수정 시 브랜드 검증은 Application Layer에서 수행 +- 스냅샷 저장은 동일 트랜잭션 내에서 처리 +- 이력 조회는 페이징 지원 (변경 시점 역순 정렬) + +--- + +### 3.2 고객 사용자 시나리오 + +#### 시나리오 3: 브랜드/상품 조회 +``` +AS A 고객 +I WANT TO 활성화된 브랜드와 상품을 조회할 수 있다 +SO THAT 구매할 상품을 찾을 수 있다 + +Given: 브랜드 목록에 ACTIVE 브랜드 5개, INACTIVE 브랜드 3개가 있는 상태 +When: 고객이 브랜드 목록 조회 시 +Then: ACTIVE 상태인 5개 브랜드만 반환된다 + +Given: 상품 목록에 ACTIVE 상품 10개, OUT_OF_STOCK 상품 2개, INACTIVE 상품 3개가 있는 상태 +When: 고객이 상품 목록 조회 시 +Then: + - ACTIVE 상품 10개는 "구매 가능"으로 표시 + - OUT_OF_STOCK 상품 2개는 "품절"로 표시 + - INACTIVE 상품 3개는 반환되지 않음 +``` + +**기능 요구사항**: +- `GET /api/v1/brands?page=0&size=20` - 활성 브랜드 목록 조회 +- `GET /api/v1/brands/{brandId}` - 브랜드 상세 조회 (ACTIVE만) +- `GET /api/v1/products?page=0&size=20&brandId={brandId}` - 활성/품절 상품 목록 조회 +- `GET /api/v1/products/{productId}` - 상품 상세 조회 (ACTIVE, OUT_OF_STOCK만) + +**비기능 요구사항**: +- LDAP 인증 불필요 +- 조회 성능 최적화 (인덱스: status, brand_id) +- 품절 상품도 노출하되, 구매 불가 표시 + +--- + +#### 시나리오 4: 상품 좋아요 +``` +AS A 고객 +I WANT TO 관심 있는 상품에 좋아요를 누를 수 있다 +SO THAT 나중에 다시 찾아볼 수 있다 + +Given: 고객 C가 로그인한 상태 +When: 상품 P에 좋아요 클릭 시 +Then: + - ProductLike 레코드가 생성된다 + - 상품 P의 좋아요 수가 1 증가한다 + +Given: 고객 C가 상품 P에 이미 좋아요를 누른 상태 +When: 좋아요 다시 클릭 시 +Then: + - ProductLike 레코드가 삭제된다 + - 상품 P의 좋아요 수가 1 감소한다 +``` + +**기능 요구사항**: +- `POST /api/v1/products/{productId}/likes` - 좋아요 추가 +- `DELETE /api/v1/products/{productId}/likes` - 좋아요 취소 +- `GET /api/v1/products/{productId}` 응답에 좋아요 수 포함 + +**비기능 요구사항**: +- 동일 사용자의 중복 좋아요 방지 (Unique 제약) +- 좋아요 수는 집계 테이블 또는 캐시 활용 (성능) + +--- + +## 4. 상태 전이도 + +### 4.1 브랜드 상태 +``` +[등록 시] → PENDING + ↓ (승인) + ACTIVE ←→ SCHEDULED (예약 판매) + ↓ (비활성화) + INACTIVE +``` + +### 4.2 상품 상태 +``` +[등록 시] → PENDING + ↓ (승인) + ACTIVE ←→ SCHEDULED (예약 판매) + ↓ (재고 소진) ↓ (비활성화) + OUT_OF_STOCK INACTIVE + ↓ (재입고) + ACTIVE +``` + +**상태별 의미**: +- `PENDING`: 등록 완료, 승인 대기 (향후 확장) +- `ACTIVE`: 활성 상태, 고객에게 노출 +- `INACTIVE`: 비활성 상태, 고객에게 미노출 +- `SCHEDULED`: 예약 판매 (특정 시점부터 활성화, 향후 확장) +- `OUT_OF_STOCK`: 품절 (고객에게 노출되지만 구매 불가) + +--- + +## 5. 데이터 일관성 정책 + +### 5.1 브랜드-상품 연쇄 비활성화 +- **트리거**: 브랜드 상태가 ACTIVE → INACTIVE 변경 +- **처리 방식**: + 1. 브랜드 상태 변경 (트랜잭션 A) + 2. `BrandDeactivatedEvent` 발행 + 3. 이벤트 리스너가 비동기로 상품 일괄 업데이트 (트랜잭션 B) + ```sql + UPDATE products + SET status = 'INACTIVE', updated_at = NOW() + WHERE brand_id = ? AND status = 'ACTIVE' + ``` + 4. 각 상품의 스냅샷을 ProductHistory에 저장 + +- **일시적 불일치 허용**: + - 브랜드는 즉시 비활성화되지만, 상품은 수 초 뒤 비활성화 + - 고객 조회 시 브랜드가 INACTIVE면 해당 상품도 필터링 + +### 5.2 상품 변경 이력 스냅샷 +- **저장 시점**: 상품 등록/수정 시 +- **저장 내용**: 상품의 모든 필드 + 버전 정보 +- **구조**: + ``` + ProductHistory { + id: Long + productId: Long (FK) + version: Integer + brandId: Long + name: String + price: BigDecimal + status: String + stockQuantity: Integer + ... (모든 필드) + changedAt: LocalDateTime + changedBy: String + } + ``` + +### 5.3 재고 관리 +- **재고 소진 조건**: `stockQuantity <= 0` +- **자동 상태 변경**: 재고가 0이 되면 상태를 OUT_OF_STOCK으로 변경 +- **재입고 처리**: 재고가 다시 증가하면 ACTIVE로 복원 + +--- + +## 6. API 모듈 분리 전략 + +### 6.1 모듈 구조 +``` +project-root/ +├── admin-api/ # 어드민 API 모듈 +│ ├── controller/ # Admin*Controller +│ ├── dto/ # Admin*Request, Admin*Response +│ └── service/ # AdminBrandService, AdminProductService +├── customer-api/ # 고객 API 모듈 +│ ├── controller/ # Customer*Controller +│ ├── dto/ # *Response (조회용) +│ └── service/ # CustomerBrandService, CustomerProductService +├── domain/ # 공통 도메인 모듈 +│ ├── model/ # Brand, Product, ProductHistory, ProductLike +│ ├── repository/ # *Repository 인터페이스 +│ └── event/ # BrandDeactivatedEvent +└── infrastructure/ # 공통 인프라 모듈 + ├── persistence/ # JPA 구현 + └── config/ # DB, Event 설정 +``` + +### 6.2 DTO 분리 전략 +- **어드민 API**: + - `CreateBrandRequest`, `UpdateBrandRequest` + - `BrandAdminResponse` (등록일, 수정일, 상태, 등록자 포함) + - `ProductAdminResponse` (원가, 재고, 상태, 이력 링크 포함) + +- **고객 API**: + - `BrandResponse` (브랜드명, 로고, 설명만) + - `ProductResponse` (상품명, 가격, 이미지, 좋아요 수, 재고 상태) + +### 6.3 책임 분리 +- **AdminBrandService**: 브랜드 등록/수정/비활성화, 이벤트 발행 +- **AdminProductService**: 상품 CRUD, 스냅샷 저장, 브랜드 검증 +- **CustomerBrandService**: ACTIVE 브랜드만 조회 +- **CustomerProductService**: ACTIVE/OUT_OF_STOCK 상품만 조회, 좋아요 처리 + +--- + +## 7. 비기능 요구사항 + +### 7.1 성능 +- 브랜드 목록 조회: 500ms 이내 +- 상품 목록 조회: 1초 이내 +- 브랜드 비활성화: 2초 이내 (응답 기준, 상품 처리는 비동기) +- 좋아요 처리: 300ms 이내 + +### 7.2 확장성 +- 페이징: Pageable 인터페이스 사용 +- 필터링/정렬: Specification 패턴 또는 QueryDSL 고려 +- 검색: 나중에 Elasticsearch 연동 가능하도록 Repository 인터페이스 추상화 + +### 7.3 보안 +- 어드민 API: LDAP 헤더 필수 검증 +- 고객 API: 좋아요 기능은 인증 필요, 조회는 비인증 허용 + +### 7.4 데이터 정합성 +- 브랜드-상품 참조 무결성: FK 제약 + Application Layer 검증 +- 상품 변경 이력: 트랜잭션 내 스냅샷 저장 보장 +- 재고-상태 동기화: 재고 변경 시 상태 자동 업데이트 + +--- + +## 8. 잠재 리스크 및 고려사항 + +### 8.1 브랜드 비활성화 시 대량 상품 처리 +- **문제**: 브랜드 하나에 상품 10,000개가 있을 때 일괄 업데이트 시 DB 락 증가 +- **완화 방안**: + - 배치 사이즈 제한 (1000개씩 분할 처리) + - 실패 시 재시도 메커니즘 + - 어드민에게 진행 상태 노출 (옵션) + +### 8.2 스냅샷 테이블 증가 +- **문제**: 상품이 자주 수정되면 ProductHistory 테이블 크기 급증 +- **완화 방안**: + - 파티셔닝 (월별, 연도별) + - 오래된 이력 아카이빙 정책 (예: 2년 이상 된 이력은 별도 저장소) + +### 8.3 일시적 데이터 불일치 +- **문제**: 브랜드 비활성화 후 상품 비활성화까지 수 초 간 불일치 +- **완화 방안**: + - 고객 조회 시 브랜드 상태로 필터링 (브랜드가 INACTIVE면 상품도 제외) + - 모니터링: 이벤트 처리 실패 시 알림 + +### 8.4 좋아요 동시성 +- **문제**: 동일 상품에 동시 좋아요 시 카운트 불일치 +- **완화 방안**: + - Unique 제약으로 중복 방지 + - 좋아요 수는 집계 쿼리로 실시간 계산 또는 캐시 활용 + +--- + +## 9. 다음 단계 + +1. **시퀀스 다이어그램**: 브랜드 비활성화 시 상품 연쇄 비활성화 흐름 +2. **클래스 다이어그램**: Brand, Product, ProductHistory, ProductLike 도메인 책임 +3. **ERD**: 테이블 구조, 관계, 인덱스 설계 + +--- + +## 10. 용어 정리 + +| 용어 | 설명 | +|------|------| +| LDAP | Lightweight Directory Access Protocol, 사내 사용자 인증 | +| 스냅샷 | 특정 시점의 데이터 전체 상태 복사본 | +| 연쇄 비활성화 | 브랜드 비활성화 시 모든 상품도 함께 비활성화 | +| Specification 패턴 | 동적 쿼리 조건 조합을 위한 디자인 패턴 | +| Pageable | Spring Data의 페이징/정렬 추상화 인터페이스 | \ No newline at end of file diff --git "a/docs/design/\354\226\264\353\223\234\353\257\274/02-sequence-diagrams.md" "b/docs/design/\354\226\264\353\223\234\353\257\274/02-sequence-diagrams.md" new file mode 100644 index 000000000..5c010ffd0 --- /dev/null +++ "b/docs/design/\354\226\264\353\223\234\353\257\274/02-sequence-diagrams.md" @@ -0,0 +1,354 @@ +# 시퀀스 다이어그램 + +## 1. 브랜드 비활성화 시 상품 연쇄 비활성화 + +### 설계 의도 +- **트랜잭션 분리**: 브랜드 상태 변경은 즉시 완료하고 응답, 상품 처리는 비동기로 수행 +- **일시적 불일치 허용**: 브랜드 비활성화 후 수 초간 상품은 아직 활성 상태일 수 있음 +- **보상 로직**: 이벤트 처리 실패 시 재시도 가능 (EventListener의 책임) + +### 특히 봐야 할 포인트 +1. `@Transactional` 경계가 AdminBrandService.deactivateBrand()에만 있음 +2. 이벤트 발행은 트랜잭션 커밋 후 발생 (`@TransactionalEventListener`) +3. ProductEventListener는 별도 트랜잭션으로 상품을 처리 +4. 각 상품 변경 시 스냅샷이 ProductHistoryRepository에 저장됨 + +```mermaid +sequenceDiagram + actor Admin as 어드민 사용자 + participant AC as AdminBrandController + participant ABS as AdminBrandService + participant BR as BrandRepository + participant EP as EventPublisher + participant PEL as ProductEventListener + participant PR as ProductRepository + participant PHR as ProductHistoryRepository + + Note over Admin,PHR: 트랜잭션 A: 브랜드 비활성화 + Admin->>AC: DELETE /api-admin/v1/brands/{brandId} + AC->>AC: LDAP 헤더 검증 + AC->>ABS: deactivateBrand(brandId) + + activate ABS + Note over ABS: @Transactional 시작 + ABS->>BR: findById(brandId) + BR-->>ABS: Brand(status=ACTIVE) + + ABS->>ABS: brand.deactivate() + Note over ABS: Brand 도메인 내부에서
상태를 INACTIVE로 변경 + + ABS->>BR: save(brand) + BR-->>ABS: Brand(status=INACTIVE) + + ABS->>EP: publish(BrandDeactivatedEvent) + Note over EP: 이벤트는 트랜잭션 커밋 후 발행됨
(TransactionalEventPublisher) + + ABS-->>AC: BrandAdminResponse + Note over ABS: @Transactional 커밋 + deactivate ABS + + AC-->>Admin: 200 OK (즉시 응답) + + Note over Admin,PHR: 트랜잭션 B: 비동기 상품 처리 + EP->>PEL: onBrandDeactivated(event) + + activate PEL + Note over PEL: @TransactionalEventListener
새로운 트랜잭션 시작 + + PEL->>PR: updateStatusByBrandId(brandId, INACTIVE) + Note over PR: UPDATE products
SET status = 'INACTIVE'
WHERE brand_id = ?
AND status = 'ACTIVE' + PR-->>PEL: updatedCount + + PEL->>PR: findAllByBrandId(brandId) + PR-->>PEL: List + + loop 각 상품마다 + PEL->>PHR: save(ProductHistory.from(product)) + Note over PHR: 스냅샷 저장
(변경 전 상태도 보관) + PHR-->>PEL: ProductHistory + end + + Note over PEL: @Transactional 커밋 + deactivate PEL + + Note over Admin,PHR: 고객 조회 시 필터링 보정 + actor Customer as 고객 + Customer->>PR: findAllByStatus(ACTIVE) + Note over PR: WHERE status = 'ACTIVE'
AND brand.status = 'ACTIVE'
(JOIN 조건으로 필터링) + PR-->>Customer: List (비활성 브랜드 상품 제외) +``` + +### 이 설계의 장단점 + +**장점**: +- 어드민 응답 속도 빠름 (2초 이내) +- 대량 상품 처리 시 브랜드 락이 오래 유지되지 않음 +- 이벤트 실패 시 재시도 가능 (이벤트 소싱/메시지 큐 도입 가능) + +**단점**: +- 일시적 불일치 (수 초간 브랜드는 비활성인데 상품은 활성) +- 고객 조회 쿼리에 브랜드 상태 조인 필요 (약간의 성능 오버헤드) + +**위험 시나리오**: +- 이벤트 리스너 실패 시 상품이 영구히 활성 상태로 남을 수 있음 +- 완화: 데드레터 큐 + 모니터링 + 재처리 배치 + +--- + +## 2. 상품 등록 (브랜드 검증 포함) + +### 설계 의도 +- **브랜드 존재 검증**: Application Layer(Service)에서 수행 +- **초기 스냅샷 저장**: 상품 등록과 동일 트랜잭션에서 처리 +- **도메인 순수성**: Product는 Brand 존재 여부를 몰라도 됨 + +### 특히 봐야 할 포인트 +1. 브랜드 검증이 ProductService에서 일어남 (Product 도메인은 관여 안 함) +2. 스냅샷 저장이 동일 트랜잭션 내에서 원자적으로 처리됨 +3. 브랜드가 없으면 예외 발생 → 트랜잭션 롤백 + +```mermaid +sequenceDiagram + actor Admin as 어드민 사용자 + participant APC as AdminProductController + participant APS as AdminProductService + participant BR as BrandRepository + participant PR as ProductRepository + participant PHR as ProductHistoryRepository + + Admin->>APC: POST /api-admin/v1/products + Note over Admin,APC: Body: CreateProductRequest
{brandId, name, price, ...} + + APC->>APC: LDAP 헤더 검증 + APC->>APS: createProduct(request) + + activate APS + Note over APS: @Transactional 시작 + + APS->>BR: existsById(request.brandId) + BR-->>APS: true/false + + alt 브랜드가 없으면 + APS-->>APC: throw BrandNotFoundException + APC-->>Admin: 404 Not Found + end + + APS->>APS: Product.create(request) + Note over APS: 정적 팩토리 메서드로 생성
초기 상태는 PENDING + + APS->>PR: save(product) + PR-->>APS: Product (id 생성됨) + + APS->>PHR: save(ProductHistory.from(product, version=1)) + Note over PHR: 초기 스냅샷 저장
version 1, changedBy='admin' + PHR-->>APS: ProductHistory + + Note over APS: @Transactional 커밋 + deactivate APS + + APS-->>APC: ProductAdminResponse + APC-->>Admin: 201 Created +``` + +--- + +## 3. 상품 브랜드 변경 (이력 추적) + +### 설계 의도 +- **브랜드 변경 허용**: 요구사항에 따라 가능하도록 설계 +- **전체 스냅샷 저장**: 브랜드만이 아니라 상품의 모든 상태를 저장 +- **버전 관리**: ProductHistory의 version을 자동 증가 + +### 특히 봐야 할 포인트 +1. 변경 전 상태를 스냅샷으로 저장하는가, 변경 후 상태를 저장하는가? + - → **변경 후 상태**를 저장 (최신 상태가 ProductHistory에 누적) +2. 트랜잭션 실패 시 스냅샷도 저장 안 됨 (원자성 보장) + +```mermaid +sequenceDiagram + actor Admin as 어드민 사용자 + participant APC as AdminProductController + participant APS as AdminProductService + participant BR as BrandRepository + participant PR as ProductRepository + participant PHR as ProductHistoryRepository + + Admin->>APC: PUT /api-admin/v1/products/{productId} + Note over Admin,APC: Body: UpdateProductRequest
{brandId, name, price, ...} + + APC->>APS: updateProduct(productId, request) + + activate APS + Note over APS: @Transactional 시작 + + APS->>PR: findById(productId) + PR-->>APS: Product (현재 상태) + + alt 브랜드를 변경하려는 경우 + APS->>BR: existsById(request.brandId) + BR-->>APS: true/false + + alt 새 브랜드가 없으면 + APS-->>APC: throw BrandNotFoundException + APC-->>Admin: 404 Not Found + end + end + + APS->>APS: product.update(request) + Note over APS: Product 도메인 메서드로 변경
brandId, name, price 등 업데이트 + + APS->>PR: save(product) + PR-->>APS: Product (변경 후 상태) + + APS->>PHR: getLatestVersion(productId) + PHR-->>APS: latestVersion (예: 5) + + APS->>PHR: save(ProductHistory.from(product, version=6)) + Note over PHR: 변경 후 스냅샷 저장
version 증가, changedAt=NOW() + PHR-->>APS: ProductHistory + + Note over APS: @Transactional 커밋 + deactivate APS + + APS-->>APC: ProductAdminResponse + APC-->>Admin: 200 OK +``` + +--- + +## 4. 고객의 상품 좋아요 + +### 설계 의도 +- **토글 방식**: 좋아요 누르면 추가, 다시 누르면 취소 +- **동시성 제어**: Unique 제약으로 중복 방지 +- **좋아요 수 집계**: 실시간 COUNT 쿼리 또는 캐시 활용 + +### 특히 봐야 할 포인트 +1. 좋아요 수는 Product 엔티티에 비정규화할 것인가, 매번 COUNT 할 것인가? + - → 현재는 **매번 COUNT** (나중에 캐시로 최적화 가능) +2. 중복 좋아요 시도 시 예외 처리 + +```mermaid +sequenceDiagram + actor Customer as 고객 + participant CPC as CustomerProductController + participant CPS as CustomerProductService + participant PR as ProductRepository + participant PLR as ProductLikeRepository + + Note over Customer,PLR: 좋아요 추가 + Customer->>CPC: POST /api/v1/products/{productId}/likes + CPC->>CPC: 사용자 인증 확인 + CPC->>CPS: likeProduct(customerId, productId) + + activate CPS + Note over CPS: @Transactional 시작 + + CPS->>PR: existsById(productId) + PR-->>CPS: true/false + + alt 상품이 없거나 INACTIVE면 + CPS-->>CPC: throw ProductNotFoundException + CPC-->>Customer: 404 Not Found + end + + CPS->>PLR: existsByCustomerIdAndProductId(customerId, productId) + PLR-->>CPS: false + + CPS->>CPS: ProductLike.create(customerId, productId) + CPS->>PLR: save(productLike) + + alt Unique 제약 위반 (동시 요청) + PLR-->>CPS: throw DataIntegrityViolationException + CPS-->>CPC: 이미 좋아요 누름 + CPC-->>Customer: 409 Conflict + end + + PLR-->>CPS: ProductLike + + Note over CPS: @Transactional 커밋 + deactivate CPS + + CPS-->>CPC: success + CPC-->>Customer: 200 OK + + Note over Customer,PLR: 좋아요 취소 + Customer->>CPC: DELETE /api/v1/products/{productId}/likes + CPC->>CPS: unlikeProduct(customerId, productId) + + activate CPS + Note over CPS: @Transactional 시작 + + CPS->>PLR: deleteByCustomerIdAndProductId(customerId, productId) + PLR-->>CPS: deletedCount (0 or 1) + + alt 좋아요가 없었으면 + CPS-->>CPC: 좋아요 안 한 상태 + CPC-->>Customer: 404 Not Found + end + + Note over CPS: @Transactional 커밋 + deactivate CPS + + CPS-->>CPC: success + CPC-->>Customer: 204 No Content +``` + +--- + +## 5. 고객의 상품 목록 조회 (필터링 포함) + +### 설계 의도 +- **상태 기반 필터링**: ACTIVE, OUT_OF_STOCK만 조회 (INACTIVE 제외) +- **브랜드 상태 조인**: 브랜드가 비활성이면 상품도 제외 +- **좋아요 수 포함**: LEFT JOIN으로 집계 + +### 특히 봐야 할 포인트 +1. 브랜드 비활성화 후 상품 비활성화 전 일시적 불일치 보정 +2. N+1 문제 방지 (fetch join 또는 batch size 설정) + +```mermaid +sequenceDiagram + actor Customer as 고객 + participant CPC as CustomerProductController + participant CPS as CustomerProductService + participant PR as ProductRepository + + Customer->>CPC: GET /api/v1/products?page=0&size=20&brandId=1 + CPC->>CPS: getProducts(brandId, pageable) + + activate CPS + + CPS->>PR: findAllWithBrandAndLikes(brandId, pageable) + Note over PR: SELECT p.*, b.status, COUNT(pl.id)
FROM products p
JOIN brands b ON p.brand_id = b.id
LEFT JOIN product_likes pl ON p.id = pl.product_id
WHERE b.id = ?
AND p.status IN ('ACTIVE', 'OUT_OF_STOCK')
AND b.status = 'ACTIVE'
GROUP BY p.id
LIMIT 20 OFFSET 0 + + PR-->>CPS: Page (좋아요 수 포함) + + CPS->>CPS: 각 Product를 ProductResponse로 변환 + Note over CPS: DTO 변환 시 좋아요 수,
재고 상태 (품절 여부) 포함 + + deactivate CPS + + CPS-->>CPC: Page + CPC-->>Customer: 200 OK + JSON +``` + +--- + +## 시퀀스 다이어그램 해석 가이드 + +### 핵심 설계 원칙 +1. **트랜잭션 분리**: 브랜드 비활성화(빠른 응답) vs 상품 비활성화(비동기) +2. **Application Layer 검증**: 브랜드 존재 여부는 Service에서 확인, Domain은 순수 유지 +3. **스냅샷 원자성**: 상품 변경과 이력 저장을 동일 트랜잭션으로 묶어 일관성 보장 +4. **조회 최적화**: JOIN + 집계 쿼리로 N+1 방지 + +### 이 구조에서 특히 봐야 할 포인트 +- **이벤트 발행 시점**: 트랜잭션 커밋 후 (`@TransactionalEventListener`) +- **비동기 처리의 일관성**: 고객 조회 시 브랜드 상태로 보정 +- **스냅샷 저장 전략**: 변경 후 상태를 저장하여 이력 추적 + +### 잠재 리스크 +- 이벤트 리스너 실패 시 상품이 활성 상태로 남음 → **모니터링 + 재처리 필요** +- 대량 상품 스냅샷 저장 시 DB 부하 → **배치 insert 최적화** +- 좋아요 수 COUNT 쿼리 비용 → **Redis 캐시 도입 검토** \ No newline at end of file diff --git "a/docs/design/\354\226\264\353\223\234\353\257\274/03-class-diagram.md" "b/docs/design/\354\226\264\353\223\234\353\257\274/03-class-diagram.md" new file mode 100644 index 000000000..df5c8ab08 --- /dev/null +++ "b/docs/design/\354\226\264\353\223\234\353\257\274/03-class-diagram.md" @@ -0,0 +1,599 @@ +# 클래스 다이어그램 + +## 1. 도메인 모델 전체 구조 + +### 설계 의도 +- **도메인 중심 설계**: Entity는 비즈니스 로직을 포함하고, Repository는 영속성만 담당 +- **VO(Value Object) 활용**: Money, ProductStatus, BrandStatus 등 개념을 타입으로 표현 +- **정적 팩토리 메서드**: 생성 로직을 명확히 하고 불변성 유지 + +### 특히 봐야 할 포인트 +1. Brand와 Product는 양방향 연관관계를 맺지 않음 (Product → Brand 단방향) +2. ProductHistory는 Product의 스냅샷이지만, Product와 직접 연관관계 없음 (느슨한 결합) +3. ProductLike는 Customer-Product 다대다 관계를 풀어낸 중간 엔티티 + +```mermaid +classDiagram + class Brand { + -Long id + -String name + -String description + -String logoUrl + -BrandStatus status + -LocalDateTime createdAt + -LocalDateTime updatedAt + -String createdBy + + +create(name, description, logoUrl, createdBy) Brand$ + +deactivate() void + +activate() void + +isActive() boolean + } + + class BrandStatus { + <> + ACTIVE + INACTIVE + PENDING + SCHEDULED + } + + class Product { + -Long id + -Long brandId + -String name + -String description + -Money price + -Integer stockQuantity + -ProductStatus status + -String imageUrl + -LocalDateTime createdAt + -LocalDateTime updatedAt + -String createdBy + + +create(brandId, name, price, stockQuantity, createdBy) Product$ + +update(name, description, price, stockQuantity) void + +changeBrand(newBrandId) void + +deactivate() void + +activate() void + +decreaseStock(quantity) void + +increaseStock(quantity) void + +isOutOfStock() boolean + +checkAndUpdateStockStatus() void + } + + class ProductStatus { + <> + ACTIVE + INACTIVE + PENDING + SCHEDULED + OUT_OF_STOCK + } + + class Money { + <> + -BigDecimal amount + -Currency currency + + +of(amount, currency) Money$ + +add(Money) Money + +multiply(int) Money + +isGreaterThan(Money) boolean + } + + class ProductHistory { + -Long id + -Long productId + -Integer version + -Long brandId + -String name + -String description + -Money price + -Integer stockQuantity + -ProductStatus status + -String imageUrl + -LocalDateTime changedAt + -String changedBy + + +from(product, version, changedBy) ProductHistory$ + } + + class ProductLike { + -Long id + -Long customerId + -Long productId + -LocalDateTime createdAt + + +create(customerId, productId) ProductLike$ + } + + Brand "1" -- "*" Product : brandId + Product "1" -- "*" ProductHistory : productId + Product "1" -- "*" ProductLike : productId + Product *-- Money : price + Product *-- ProductStatus : status + Brand *-- BrandStatus : status + ProductHistory *-- Money : price + ProductHistory *-- ProductStatus : status +``` + +--- + +## 2. 레이어별 클래스 구조 + +### 설계 의도 +- **Layered Architecture**: Presentation → Application → Domain → Infrastructure +- **의존성 역전**: Repository는 인터페이스(Domain)에 의존, 구현은 Infrastructure +- **DTO 분리**: Admin용, Customer용 DTO를 명확히 구분 + +### 특히 봐야 할 포인트 +1. Service는 Repository 인터페이스에만 의존 (구현체 모름) +2. Domain 레이어는 다른 레이어에 의존하지 않음 (순수 자바) +3. Controller는 DTO만 다루고, Domain은 Service 레이어에서만 다룸 + +```mermaid +classDiagram + %% Presentation Layer - Admin API + class AdminBrandController { + -AdminBrandService adminBrandService + +createBrand(CreateBrandRequest) ResponseEntity~BrandAdminResponse~ + +updateBrand(Long, UpdateBrandRequest) ResponseEntity~BrandAdminResponse~ + +getBrand(Long) ResponseEntity~BrandAdminResponse~ + +getBrands(Pageable) ResponseEntity~Page~BrandAdminResponse~~ + +deactivateBrand(Long) ResponseEntity~Void~ + } + + class AdminProductController { + -AdminProductService adminProductService + +createProduct(CreateProductRequest) ResponseEntity~ProductAdminResponse~ + +updateProduct(Long, UpdateProductRequest) ResponseEntity~ProductAdminResponse~ + +getProduct(Long) ResponseEntity~ProductAdminResponse~ + +getProducts(Long, Pageable) ResponseEntity~Page~ProductAdminResponse~~ + +deactivateProduct(Long) ResponseEntity~Void~ + +getProductHistory(Long, Pageable) ResponseEntity~Page~ProductHistoryResponse~~ + } + + %% Presentation Layer - Customer API + class CustomerBrandController { + -CustomerBrandService customerBrandService + +getBrand(Long) ResponseEntity~BrandResponse~ + +getBrands(Pageable) ResponseEntity~Page~BrandResponse~~ + } + + class CustomerProductController { + -CustomerProductService customerProductService + +getProduct(Long) ResponseEntity~ProductResponse~ + +getProducts(Long, Pageable) ResponseEntity~Page~ProductResponse~~ + +likeProduct(Long) ResponseEntity~Void~ + +unlikeProduct(Long) ResponseEntity~Void~ + } + + %% Application Layer - Admin + class AdminBrandService { + -BrandRepository brandRepository + -EventPublisher eventPublisher + +createBrand(CreateBrandRequest) BrandAdminResponse + +updateBrand(Long, UpdateBrandRequest) BrandAdminResponse + +getBrand(Long) BrandAdminResponse + +getBrands(Pageable) Page~BrandAdminResponse~ + +deactivateBrand(Long) void + } + + class AdminProductService { + -ProductRepository productRepository + -BrandRepository brandRepository + -ProductHistoryRepository productHistoryRepository + +createProduct(CreateProductRequest) ProductAdminResponse + +updateProduct(Long, UpdateProductRequest) ProductAdminResponse + +getProduct(Long) ProductAdminResponse + +getProducts(Long, Pageable) Page~ProductAdminResponse~ + +deactivateProduct(Long) void + +getProductHistory(Long, Pageable) Page~ProductHistoryResponse~ + } + + %% Application Layer - Customer + class CustomerBrandService { + -BrandRepository brandRepository + +getBrand(Long) BrandResponse + +getBrands(Pageable) Page~BrandResponse~ + } + + class CustomerProductService { + -ProductRepository productRepository + -ProductLikeRepository productLikeRepository + +getProduct(Long) ProductResponse + +getProducts(Long, Pageable) Page~ProductResponse~ + +likeProduct(Long, Long) void + +unlikeProduct(Long, Long) void + } + + %% Event Handling + class ProductEventListener { + -ProductRepository productRepository + -ProductHistoryRepository productHistoryRepository + +onBrandDeactivated(BrandDeactivatedEvent) void + } + + class BrandDeactivatedEvent { + -Long brandId + -LocalDateTime occurredAt + } + + %% Domain Layer - Repository Interfaces + class BrandRepository { + <> + +findById(Long) Optional~Brand~ + +save(Brand) Brand + +existsById(Long) boolean + +findAllByStatus(BrandStatus, Pageable) Page~Brand~ + +updateStatus(Long, BrandStatus) void + } + + class ProductRepository { + <> + +findById(Long) Optional~Product~ + +save(Product) Product + +existsById(Long) boolean + +findAllByBrandIdAndStatusIn(Long, List~ProductStatus~, Pageable) Page~Product~ + +updateStatusByBrandId(Long, ProductStatus) int + +findAllByBrandId(Long) List~Product~ + } + + class ProductHistoryRepository { + <> + +save(ProductHistory) ProductHistory + +findAllByProductId(Long, Pageable) Page~ProductHistory~ + +getLatestVersion(Long) Integer + } + + class ProductLikeRepository { + <> + +save(ProductLike) ProductLike + +existsByCustomerIdAndProductId(Long, Long) boolean + +deleteByCustomerIdAndProductId(Long, Long) int + +countByProductId(Long) long + } + + %% Domain Layer - Entities (이미 위에서 정의됨) + + %% Relationships + AdminBrandController --> AdminBrandService + AdminProductController --> AdminProductService + CustomerBrandController --> CustomerBrandService + CustomerProductController --> CustomerProductService + + AdminBrandService --> BrandRepository + AdminBrandService --> EventPublisher + AdminProductService --> ProductRepository + AdminProductService --> BrandRepository + AdminProductService --> ProductHistoryRepository + + CustomerBrandService --> BrandRepository + CustomerProductService --> ProductRepository + CustomerProductService --> ProductLikeRepository + + ProductEventListener --> ProductRepository + ProductEventListener --> ProductHistoryRepository + ProductEventListener ..> BrandDeactivatedEvent : listens + + AdminBrandService ..> Brand : uses + AdminProductService ..> Product : uses + AdminProductService ..> ProductHistory : uses + CustomerProductService ..> ProductLike : uses +``` + +--- + +## 3. DTO 클래스 구조 + +### 설계 의도 +- **역할별 분리**: Admin과 Customer가 보는 정보가 다름 +- **불변성**: 모든 DTO는 record로 정의하여 불변 유지 +- **변환 책임**: DTO ↔ Entity 변환은 DTO 자신이 담당 (정적 팩토리 메서드) + +### 특히 봐야 할 포인트 +1. Admin DTO는 관리 정보 포함 (생성일, 상태, 이력 링크) +2. Customer DTO는 고객 필요 정보만 (가격, 좋아요 수, 품절 여부) +3. Request DTO는 검증 로직 포함 (Bean Validation) + +```mermaid +classDiagram + %% Admin DTOs + class CreateBrandRequest { + <> + +String name + +String description + +String logoUrl + +validate() void + } + + class UpdateBrandRequest { + <> + +String name + +String description + +String logoUrl + +validate() void + } + + class BrandAdminResponse { + <> + +Long id + +String name + +String description + +String logoUrl + +BrandStatus status + +LocalDateTime createdAt + +LocalDateTime updatedAt + +String createdBy + + +from(Brand) BrandAdminResponse$ + } + + class CreateProductRequest { + <> + +Long brandId + +String name + +String description + +BigDecimal price + +String currency + +Integer stockQuantity + +String imageUrl + +validate() void + } + + class UpdateProductRequest { + <> + +Long brandId + +String name + +String description + +BigDecimal price + +Integer stockQuantity + +String imageUrl + +validate() void + } + + class ProductAdminResponse { + <> + +Long id + +Long brandId + +String brandName + +String name + +String description + +BigDecimal price + +String currency + +Integer stockQuantity + +ProductStatus status + +String imageUrl + +LocalDateTime createdAt + +LocalDateTime updatedAt + +String createdBy + +String historyUrl + + +from(Product, Brand) ProductAdminResponse$ + } + + class ProductHistoryResponse { + <> + +Long id + +Integer version + +Long brandId + +String name + +BigDecimal price + +ProductStatus status + +LocalDateTime changedAt + +String changedBy + + +from(ProductHistory) ProductHistoryResponse$ + } + + %% Customer DTOs + class BrandResponse { + <> + +Long id + +String name + +String description + +String logoUrl + + +from(Brand) BrandResponse$ + } + + class ProductResponse { + <> + +Long id + +Long brandId + +String brandName + +String name + +String description + +BigDecimal price + +String currency + +boolean outOfStock + +String imageUrl + +long likeCount + +boolean likedByMe + + +from(Product, Brand, long, boolean) ProductResponse$ + } + + AdminBrandController ..> CreateBrandRequest : uses + AdminBrandController ..> UpdateBrandRequest : uses + AdminBrandController ..> BrandAdminResponse : uses + + AdminProductController ..> CreateProductRequest : uses + AdminProductController ..> UpdateProductRequest : uses + AdminProductController ..> ProductAdminResponse : uses + AdminProductController ..> ProductHistoryResponse : uses + + CustomerBrandController ..> BrandResponse : uses + CustomerProductController ..> ProductResponse : uses +``` + +--- + +## 4. Value Object 상세 설계 + +### 설계 의도 +- **도메인 개념 표현**: 금액, 상태 같은 개념을 타입으로 명확히 +- **불변성**: VO는 생성 후 변경 불가 +- **비즈니스 로직 응집**: Money는 금액 계산 로직을 포함 + +### 특히 봐야 할 포인트 +1. Money는 `BigDecimal`을 감싸서 통화 단위 강제 +2. Status는 enum으로 허용된 상태만 표현 +3. VO는 Entity가 아니므로 식별자(id) 없음 + +```mermaid +classDiagram + class Money { + <> + -BigDecimal amount + -Currency currency + + +of(BigDecimal, Currency) Money$ + +krw(BigDecimal) Money$ + +usd(BigDecimal) Money$ + +add(Money) Money + +subtract(Money) Money + +multiply(int) Money + +divide(int) Money + +isGreaterThan(Money) boolean + +isLessThan(Money) boolean + +equals(Object) boolean + +hashCode() int + +toString() String + } + + class Currency { + <> + KRW + USD + EUR + JPY + } + + class BrandStatus { + <> + ACTIVE + INACTIVE + PENDING + SCHEDULED + + +isActive() boolean + +canTransitionTo(BrandStatus) boolean + } + + class ProductStatus { + <> + ACTIVE + INACTIVE + PENDING + SCHEDULED + OUT_OF_STOCK + + +isActive() boolean + +isAvailableForPurchase() boolean + +canTransitionTo(ProductStatus) boolean + } + + Money *-- Currency +``` + +--- + +## 5. 파사드 레이어 적용 여부 검토 + +### 파사드 패턴이 필요한 경우 +- 여러 Service를 조합하는 복잡한 비즈니스 로직이 있을 때 +- Controller가 여러 Service를 직접 호출하면 복잡도가 높아질 때 + +### 현재 시스템에서의 판단 +**불필요함**. 이유: +1. 각 Controller는 단일 Service만 사용 (AdminBrandController → AdminBrandService) +2. 복잡한 오케스트레이션 없음 (브랜드 비활성화 → 이벤트 발행만) +3. 파사드 도입 시 불필요한 레이어 추가 + +**예외 케이스**: 나중에 "주문" 기능 추가 시 +- OrderFacade: ProductService + InventoryService + PaymentService 조합 +- 이때는 파사드 도입 고려 + +--- + +## 6. 정적 팩토리 메서드 사용 전략 + +### 왜 사용하는가? +1. **생성 의도 명확화**: `Brand.create()` vs `new Brand()` +2. **검증 로직 캡슐화**: 생성자는 단순히 값만 할당, 팩토리는 검증 후 생성 +3. **불변성 강제**: VO는 정적 팩토리로만 생성 가능 + +### 적용 예시 +```java +// Brand.java +public class Brand { + private Brand(String name, String description, BrandStatus status, String createdBy) { + this.name = name; + this.description = description; + this.status = status; + this.createdBy = createdBy; + this.createdAt = LocalDateTime.now(); + } + + public static Brand create(String name, String description, String logoUrl, String createdBy) { + validateName(name); + validateCreatedBy(createdBy); + return new Brand(name, description, BrandStatus.PENDING, createdBy); + } +} + +// Money.java +public record Money(BigDecimal amount, Currency currency) { + public static Money of(BigDecimal amount, Currency currency) { + if (amount.compareTo(BigDecimal.ZERO) < 0) { + throw new IllegalArgumentException("Amount cannot be negative"); + } + return new Money(amount, currency); + } + + public static Money krw(long amount) { + return of(BigDecimal.valueOf(amount), Currency.KRW); + } +} +``` + +--- + +## 7. VO 사용 권장 사항 + +### 어디에 사용하는가? +| 개념 | VO 사용 여부 | 이유 | +|------|-------------|------| +| 금액 (price) | ✅ Money | 통화 단위 강제, 계산 로직 응집 | +| 상태 (status) | ✅ Enum | 허용된 값만 표현, 전이 규칙 포함 | +| 이메일 | ✅ Email | 형식 검증 로직 캡슐화 | +| 이름 (name) | ❌ String | 단순 문자열, VO 과잉 설계 | +| ID | ❌ Long | JPA 식별자, 원시 타입 유지 | + +### 현재 설계에 적용 +- ✅ **Money**: 가격은 금액+통화 조합 +- ✅ **BrandStatus, ProductStatus**: 상태는 enum +- ❌ **ProductName**: 과잉 설계 (검증만 필요하면 Bean Validation) +- ❌ **BrandId, ProductId**: JPA 식별자는 Long 유지 + +--- + +## 클래스 다이어그램 해석 가이드 + +### 핵심 설계 원칙 +1. **단일 책임**: 각 클래스는 하나의 책임만 (Brand는 브랜드 정보, Product는 상품 정보) +2. **의존성 역전**: Service → Repository Interface ← JPA Implementation +3. **도메인 순수성**: Entity는 JPA 어노테이션만, 비즈니스 로직은 메서드로 + +### 이 구조에서 특히 봐야 할 포인트 +- **Product는 Brand를 참조하지만, Brand는 Product를 모름** (단방향) +- **ProductHistory는 Product와 별도 테이블** (느슨한 결합, 스냅샷) +- **VO는 Entity가 아님** (식별자 없음, 값으로만 비교) + +### 잠재 리스크 +- Product가 Brand 정보를 필요로 할 때마다 조인 발생 → **N+1 문제 가능성** + - 완화: `@EntityGraph`, `fetch join` 사용 +- ProductHistory 증가 시 테이블 크기 급증 → **파티셔닝 필요** +- Money 계산 시 통화 불일치 → **예외 처리 필수** \ No newline at end of file diff --git "a/docs/design/\354\226\264\353\223\234\353\257\274/04-erd.md" "b/docs/design/\354\226\264\353\223\234\353\257\274/04-erd.md" new file mode 100644 index 000000000..86fbec9be --- /dev/null +++ "b/docs/design/\354\226\264\353\223\234\353\257\274/04-erd.md" @@ -0,0 +1,432 @@ +# ERD (Entity Relationship Diagram) + +## 1. 전체 데이터베이스 스키마 + +### 설계 의도 +- **참조 무결성**: FK 제약으로 데이터 일관성 보장 +- **성능 최적화**: 조회 패턴에 맞는 인덱스 설계 +- **확장성**: 파티셔닝, 샤딩 가능한 구조 + +### 특히 봐야 할 포인트 +1. **products.brand_id**: FK with ON DELETE RESTRICT (어플리케이션에서 상태 변경으로 처리) +2. **product_histories**: FK 없음 (느슨한 결합, 스냅샷 독립성) +3. **product_likes**: Unique 제약 (customer_id, product_id) + +```mermaid +erDiagram + brands ||--o{ products : "has many" + products ||--o{ product_histories : "tracks changes of" + products ||--o{ product_likes : "liked by customers" + + brands { + bigint id PK "자동 증가" + varchar(100) name UK "브랜드명 (유니크)" + text description "브랜드 설명" + varchar(500) logo_url "로고 이미지 URL" + varchar(20) status "ACTIVE, INACTIVE, PENDING, SCHEDULED" + timestamp created_at "등록 시각" + timestamp updated_at "수정 시각" + varchar(100) created_by "등록자 (LDAP ID)" + } + + products { + bigint id PK "자동 증가" + bigint brand_id FK "브랜드 ID (brands.id)" + varchar(200) name "상품명" + text description "상품 설명" + decimal(15,2) price "가격" + varchar(10) currency "통화 (KRW, USD 등)" + int stock_quantity "재고 수량" + varchar(20) status "ACTIVE, INACTIVE, OUT_OF_STOCK 등" + varchar(500) image_url "상품 이미지 URL" + timestamp created_at "등록 시각" + timestamp updated_at "수정 시각" + varchar(100) created_by "등록자 (LDAP ID)" + } + + product_histories { + bigint id PK "자동 증가" + bigint product_id "상품 ID (products.id)" + int version "버전 번호 (1부터 시작)" + bigint brand_id "스냅샷 당시 브랜드 ID" + varchar(200) name "스냅샷 당시 상품명" + text description "스냅샷 당시 설명" + decimal(15,2) price "스냅샷 당시 가격" + varchar(10) currency "스냅샷 당시 통화" + int stock_quantity "스냅샷 당시 재고" + varchar(20) status "스냅샷 당시 상태" + varchar(500) image_url "스냅샷 당시 이미지" + timestamp changed_at "변경 시각" + varchar(100) changed_by "변경자 (LDAP ID)" + } + + product_likes { + bigint id PK "자동 증가" + bigint customer_id "고객 ID" + bigint product_id FK "상품 ID (products.id)" + timestamp created_at "좋아요 누른 시각" + } +``` + +--- + +## 2. 테이블별 상세 스키마 + +### 2.1 brands 테이블 + +```sql +CREATE TABLE brands ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL UNIQUE, + description TEXT, + logo_url VARCHAR(500), + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + created_by VARCHAR(100) NOT NULL, + + INDEX idx_status (status), + INDEX idx_created_at (created_at DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +``` + +**컬럼 설명**: +- `name`: 브랜드명은 중복 불가 (Unique 제약) +- `status`: 기본값 PENDING (승인 후 ACTIVE) +- `created_by`: LDAP ID 저장 + +**인덱스 전략**: +- `idx_status`: 상태별 조회 빈번 (고객 API는 ACTIVE만 필터링) +- `idx_created_at`: 최신 등록 브랜드 조회 시 사용 + +--- + +### 2.2 products 테이블 + +```sql +CREATE TABLE products ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + brand_id BIGINT NOT NULL, + name VARCHAR(200) NOT NULL, + description TEXT, + price DECIMAL(15, 2) NOT NULL, + currency VARCHAR(10) NOT NULL DEFAULT 'KRW', + stock_quantity INT NOT NULL DEFAULT 0, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + image_url VARCHAR(500), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + created_by VARCHAR(100) NOT NULL, + + CONSTRAINT fk_products_brand FOREIGN KEY (brand_id) + REFERENCES brands(id) ON DELETE RESTRICT, + + INDEX idx_brand_id (brand_id), + INDEX idx_status (status), + INDEX idx_brand_status (brand_id, status), + INDEX idx_created_at (created_at DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +``` + +**컬럼 설명**: +- `brand_id`: 브랜드 FK (ON DELETE RESTRICT - 어플리케이션에서 처리) +- `price`, `currency`: 금액은 통화와 함께 저장 +- `stock_quantity`: 재고 수량 (0 이하면 OUT_OF_STOCK 상태로 변경) + +**인덱스 전략**: +- `idx_brand_id`: 브랜드별 상품 조회 +- `idx_status`: 상태별 필터링 (ACTIVE, OUT_OF_STOCK) +- `idx_brand_status`: 브랜드+상태 복합 조회 최적화 +- `idx_created_at`: 신상품 정렬 + +**ON DELETE RESTRICT 이유**: +- 브랜드 삭제 시 DB 레벨에서 막지 않고, 어플리케이션에서 상태 변경으로 처리 +- 실수로 브랜드 물리 삭제 시도 시 에러 발생 (데이터 보호) + +--- + +### 2.3 product_histories 테이블 + +```sql +CREATE TABLE product_histories ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + product_id BIGINT NOT NULL, + version INT NOT NULL, + brand_id BIGINT NOT NULL, + name VARCHAR(200) NOT NULL, + description TEXT, + price DECIMAL(15, 2) NOT NULL, + currency VARCHAR(10) NOT NULL, + stock_quantity INT NOT NULL, + status VARCHAR(20) NOT NULL, + image_url VARCHAR(500), + changed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + changed_by VARCHAR(100) NOT NULL, + + UNIQUE KEY uk_product_version (product_id, version), + INDEX idx_product_id_changed_at (product_id, changed_at DESC), + INDEX idx_changed_at (changed_at DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +PARTITION BY RANGE (YEAR(changed_at)) ( + PARTITION p2024 VALUES LESS THAN (2025), + PARTITION p2025 VALUES LESS THAN (2026), + PARTITION p2026 VALUES LESS THAN (2027), + PARTITION p_future VALUES LESS THAN MAXVALUE +); +``` + +**컬럼 설명**: +- `product_id`: 원본 상품 ID (FK 없음 - 스냅샷 독립성) +- `version`: 변경 버전 (1부터 시작, 자동 증가) +- `brand_id`, `name`, `price` 등: 변경 시점의 스냅샷 + +**Unique 제약**: +- `uk_product_version`: 동일 상품의 동일 버전 중복 방지 + +**인덱스 전략**: +- `idx_product_id_changed_at`: 상품 이력 조회 (최신순 정렬) +- `idx_changed_at`: 전체 변경 이력 조회 + +**파티셔닝 전략**: +- 연도별 파티션으로 이력 테이블 증가 대비 +- 오래된 이력은 별도 아카이빙 가능 + +**FK 없는 이유**: +- Product 삭제 시에도 이력은 보존 (감사 추적) +- 스냅샷은 독립적으로 존재 + +--- + +### 2.4 product_likes 테이블 + +```sql +CREATE TABLE product_likes ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + customer_id BIGINT NOT NULL, + product_id BIGINT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT fk_product_likes_product FOREIGN KEY (product_id) + REFERENCES products(id) ON DELETE CASCADE, + + UNIQUE KEY uk_customer_product (customer_id, product_id), + INDEX idx_product_id (product_id), + INDEX idx_customer_id (customer_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +``` + +**컬럼 설명**: +- `customer_id`: 고객 ID (나중에 customers 테이블과 FK 연결 가능) +- `product_id`: 상품 FK (ON DELETE CASCADE - 상품 삭제 시 좋아요도 삭제) + +**Unique 제약**: +- `uk_customer_product`: 동일 고객이 동일 상품에 중복 좋아요 방지 + +**인덱스 전략**: +- `idx_product_id`: 상품별 좋아요 수 집계 +- `idx_customer_id`: 고객별 좋아요 목록 조회 + +**ON DELETE CASCADE 이유**: +- 상품이 삭제(비활성화)되면 좋아요도 의미 없음 +- 좋아요 이력은 별도 추적 불필요 (현재 상태만 중요) + +--- + +## 3. 인덱스 전략 상세 + +### 3.1 조회 패턴별 인덱스 + +| 조회 패턴 | 사용 인덱스 | 설명 | +|----------|-----------|------| +| 활성 브랜드 목록 | `brands.idx_status` | WHERE status = 'ACTIVE' | +| 브랜드별 상품 목록 | `products.idx_brand_status` | WHERE brand_id = ? AND status IN (?, ?) | +| 상품 이력 조회 | `product_histories.idx_product_id_changed_at` | WHERE product_id = ? ORDER BY changed_at DESC | +| 상품별 좋아요 수 | `product_likes.idx_product_id` | COUNT(*) WHERE product_id = ? | +| 고객별 좋아요 목록 | `product_likes.idx_customer_id` | WHERE customer_id = ? | + +### 3.2 복합 인덱스 최적화 + +```sql +-- 브랜드별 활성 상품 조회에 최적화 +CREATE INDEX idx_brand_status ON products (brand_id, status); + +-- 상품 이력 최신순 조회에 최적화 +CREATE INDEX idx_product_id_changed_at ON product_histories (product_id, changed_at DESC); +``` + +**복합 인덱스 사용 이유**: +- `idx_brand_status`: 브랜드별 + 상태별 필터링 동시 최적화 +- 인덱스 순서: `brand_id` (등호 조건) → `status` (IN 조건) + +--- + +## 4. 데이터 정합성 제약 + +### 4.1 FK 제약 정리 + +| 테이블 | FK | 참조 | ON DELETE | 이유 | +|-------|----|----|-----------|------| +| products | brand_id | brands(id) | RESTRICT | 브랜드 물리 삭제 방지 (상태로 관리) | +| product_likes | product_id | products(id) | CASCADE | 상품 삭제 시 좋아요도 삭제 | + +### 4.2 Unique 제약 정리 + +| 테이블 | Unique 제약 | 의미 | +|-------|-----------|------| +| brands | name | 브랜드명 중복 방지 | +| product_histories | (product_id, version) | 동일 상품의 버전 중복 방지 | +| product_likes | (customer_id, product_id) | 중복 좋아요 방지 | + +### 4.3 Check 제약 (향후 추가 가능) + +```sql +-- 가격은 0 이상 +ALTER TABLE products ADD CONSTRAINT chk_price_positive + CHECK (price >= 0); + +-- 재고는 음수 불가 +ALTER TABLE products ADD CONSTRAINT chk_stock_non_negative + CHECK (stock_quantity >= 0); + +-- 상태는 허용된 값만 +ALTER TABLE brands ADD CONSTRAINT chk_brand_status + CHECK (status IN ('ACTIVE', 'INACTIVE', 'PENDING', 'SCHEDULED')); + +ALTER TABLE products ADD CONSTRAINT chk_product_status + CHECK (status IN ('ACTIVE', 'INACTIVE', 'PENDING', 'SCHEDULED', 'OUT_OF_STOCK')); +``` + +**주의**: MySQL 8.0.16 이상에서만 Check 제약 지원 + +--- + +## 5. 브랜드 비활성화 시 연쇄 처리 SQL + +### 5.1 어플리케이션에서 실행할 쿼리 + +```sql +-- 트랜잭션 A: 브랜드 상태 변경 +UPDATE brands +SET status = 'INACTIVE', updated_at = NOW() +WHERE id = ? AND status = 'ACTIVE'; + +-- 트랜잭션 B: 상품 일괄 비활성화 (이벤트 리스너에서 실행) +UPDATE products +SET status = 'INACTIVE', updated_at = NOW() +WHERE brand_id = ? AND status = 'ACTIVE'; + +-- 트랜잭션 B: 각 상품의 스냅샷 저장 +INSERT INTO product_histories + (product_id, version, brand_id, name, description, price, currency, + stock_quantity, status, image_url, changed_at, changed_by) +SELECT + id, + (SELECT IFNULL(MAX(version), 0) + 1 FROM product_histories ph WHERE ph.product_id = p.id), + brand_id, name, description, price, currency, + stock_quantity, status, image_url, NOW(), 'SYSTEM' +FROM products p +WHERE brand_id = ?; +``` + +### 5.2 고객 조회 시 필터링 쿼리 + +```sql +-- 브랜드가 비활성이면 상품도 제외 +SELECT p.*, b.name as brand_name, COUNT(pl.id) as like_count +FROM products p +INNER JOIN brands b ON p.brand_id = b.id +LEFT JOIN product_likes pl ON p.id = pl.product_id +WHERE b.status = 'ACTIVE' + AND p.status IN ('ACTIVE', 'OUT_OF_STOCK') + AND (? IS NULL OR p.brand_id = ?) +GROUP BY p.id +ORDER BY p.created_at DESC +LIMIT ? OFFSET ?; +``` + +--- + +## 6. 확장성 고려사항 + +### 6.1 파티셔닝 전략 +- **product_histories**: 연도별 파티셔닝으로 이력 테이블 크기 관리 +- **product_likes**: 고객 ID 기반 해시 파티셔닝 (나중에 샤딩 가능) + +### 6.2 인덱스 유지보수 +- **주기적 통계 갱신**: `ANALYZE TABLE products;` +- **사용하지 않는 인덱스 제거**: 쿼리 로그 분석 후 + +### 6.3 읽기 성능 최적화 +- **좋아요 수 캐싱**: Redis에 `product:{id}:like_count` 저장 +- **읽기 전용 레플리카**: 고객 조회는 레플리카로 분산 + +--- + +## 7. ERD 해석 가이드 + +### 핵심 설계 원칙 +1. **정규화**: 중복 데이터 최소화 (브랜드 정보는 brands에만) +2. **참조 무결성**: FK 제약으로 데이터 일관성 보장 +3. **성능 최적화**: 조회 패턴에 맞는 인덱스 설계 + +### 이 구조에서 특히 봐야 할 포인트 +- **products.brand_id**: ON DELETE RESTRICT로 물리 삭제 방지 +- **product_histories**: FK 없음 (스냅샷 독립성) +- **product_likes**: Unique 제약으로 중복 방지, ON DELETE CASCADE + +### 잠재 리스크 +- **product_histories 증가**: 파티셔닝으로 완화 +- **좋아요 수 집계 비용**: COUNT(*) 대신 캐시 활용 +- **브랜드-상품 조인**: 인덱스 최적화 필수 + +--- + +## 8. 데이터 마이그레이션 스크립트 + +### 8.1 초기 테이블 생성 순서 +```sql +-- 1. brands 테이블 생성 +CREATE TABLE brands (...); + +-- 2. products 테이블 생성 (brands FK 필요) +CREATE TABLE products (...); + +-- 3. product_histories 테이블 생성 (FK 없음) +CREATE TABLE product_histories (...); + +-- 4. product_likes 테이블 생성 (products FK 필요) +CREATE TABLE product_likes (...); +``` + +### 8.2 샘플 데이터 INSERT +```sql +-- 브랜드 샘플 데이터 +INSERT INTO brands (name, description, logo_url, status, created_by) VALUES +('Nike', 'Just Do It', 'https://example.com/nike-logo.png', 'ACTIVE', 'admin'), +('Adidas', 'Impossible is Nothing', 'https://example.com/adidas-logo.png', 'ACTIVE', 'admin'); + +-- 상품 샘플 데이터 +INSERT INTO products (brand_id, name, description, price, currency, stock_quantity, status, created_by) VALUES +(1, 'Air Max 90', 'Classic sneakers', 150000, 'KRW', 100, 'ACTIVE', 'admin'), +(1, 'Air Force 1', 'Iconic shoes', 120000, 'KRW', 0, 'OUT_OF_STOCK', 'admin'), +(2, 'Ultraboost', 'Running shoes', 180000, 'KRW', 50, 'ACTIVE', 'admin'); + +-- 초기 스냅샷 저장 +INSERT INTO product_histories + (product_id, version, brand_id, name, description, price, currency, stock_quantity, status, changed_at, changed_by) +SELECT id, 1, brand_id, name, description, price, currency, stock_quantity, status, created_at, created_by +FROM products; +``` + +--- + +## 정리 + +이 ERD는 다음을 보장합니다: + +1. **데이터 정합성**: FK 제약, Unique 제약으로 일관성 유지 +2. **확장성**: 파티셔닝, 인덱스 최적화로 성능 확보 +3. **감사 추적**: product_histories로 모든 변경 이력 보관 +4. **유연성**: 상태 관리로 물리 삭제 없이 비활성화 처리 + +**다음 단계**: JPA Entity 설계 시 이 ERD를 기반으로 매핑 \ No newline at end of file diff --git "a/docs/design/\354\242\213\354\225\204\354\232\224/01-requirements.md" "b/docs/design/\354\242\213\354\225\204\354\232\224/01-requirements.md" new file mode 100644 index 000000000..48f2248a6 --- /dev/null +++ "b/docs/design/\354\242\213\354\225\204\354\232\224/01-requirements.md" @@ -0,0 +1,213 @@ +# 좋아요 기능 요구사항 분석 + +## 1️⃣ 문제 상황 재정의 + +### 📱 사용자 관점 +**문제:** 사용자가 마음에 드는 상품을 저장하고 나중에 다시 찾아보고 싶다. +- 상품 목록이나 상세 페이지를 볼 때, 관심 가는 상품을 빠르게 표시하고 싶음 +- 내가 관심 표시한 상품들을 한 곳에서 모아보고 싶음 +- 다른 사람들이 얼마나 좋아하는지 참고하고 싶음 (좋아요 수) + +### 💼 비즈니스 관점 +**문제:** 사용자의 관심사를 파악하고, 인기 상품을 식별하고 싶다. +- 어떤 상품이 인기 있는지 측정 가능해야 함 +- 사용자의 관심 데이터를 수집하여 추후 추천 시스템 등에 활용 가능해야 함 +- 좋아요 수를 통해 상품의 사회적 증거(Social Proof)를 제공하고 싶음 + +### 🖥️ 시스템 관점 +**문제:** 좋아요 데이터의 일관성과 성능을 모두 고려해야 한다. +- 중복 좋아요를 방지해야 함 +- 좋아요 수 집계는 실시간일 필요는 없지만, 크게 어긋나서는 안 됨 +- 향후 트래픽 증가에 대비한 확장 가능한 구조여야 함 (Redis 등) + +--- + +## 2️⃣ 개념 모델 + +### 액터 (Actors) +- **인증된 사용자 (Authenticated User)** + - 좋아요를 등록/취소하는 주체 + - 자신의 좋아요 목록을 조회하는 주체 + +### 핵심 도메인 (Core Domain) +- **Product (상품)** + - 좋아요의 대상이 되는 엔티티 + - 좋아요 개수 정보를 가짐 + +- **Like (좋아요)** + - 사용자와 상품 간의 관계를 나타냄 + - 사용자 1명당 상품 1개에 대해 1개만 존재 가능 + +### 보조/외부 시스템 +- **이벤트 시스템** + - 좋아요 등록/취소 시 이벤트를 발행 + - 카운트 업데이트를 비동기로 처리 + +- **배치 시스템 (향후)** + - 정합성 불일치 시 복구 담당 + - 이벤트 실패로 인한 데이터 불일치 보정 + +--- + +## 3️⃣ 명확화된 기능 요구사항 + +### FR-1. 좋아요 등록 +**사용자 스토리:** +사용자는 상품 목록 또는 상세 페이지에서 좋아요 버튼을 눌러 관심 상품을 표시할 수 있다. + +**API 명세:** +``` +POST /api/v1/products/{productId}/likes +Authorization: Required +``` + +**상세 동작:** +1. 인증된 사용자만 좋아요 등록 가능 +2. 동일 사용자가 동일 상품에 대해 중복 좋아요 시도 시: + - DB Unique 제약에 의해 실패 + - 클라이언트에 적절한 에러 응답 (409 Conflict 또는 400 Bad Request) +3. 좋아요 등록 성공 시: + - `likes` 테이블에 데이터 저장 + - 이벤트 발행 (`LikeCreated`) + - 비동기로 상품의 `like_count` 증가 + +**정책:** +- 좋아요 등록은 동기 처리 (즉시 응답) +- 카운트 업데이트는 비동기 처리 (Eventual Consistency) +- 이벤트 실패 시 재시도 없음, 배치로 정합성 복구 + +--- + +### FR-2. 좋아요 취소 +**사용자 스토리:** +사용자는 이미 누른 좋아요를 취소할 수 있다. + +**API 명세:** +``` +DELETE /api/v1/products/{productId}/likes +Authorization: Required +``` + +**상세 동작:** +1. 인증된 사용자만 좋아요 취소 가능 +2. 존재하지 않는 좋아요에 대한 취소 요청 시: + - 비정상적인 접근으로 간주 + - 에러 로그 기록 + - 클라이언트에는 성공 응답 (멱등성 보장) +3. 좋아요 취소 성공 시: + - `likes` 테이블에서 데이터 삭제 + - 이벤트 발행 (`LikeDeleted`) + - 비동기로 상품의 `like_count` 감소 + +**정책:** +- 좋아요 취소는 멱등성을 가짐 (여러 번 호출해도 결과 동일) +- 카운트 업데이트는 비동기 처리 +- 이벤트 실패 시 재시도 없음, 배치로 정합성 복구 + +--- + +### FR-3. 내 좋아요 목록 조회 +**사용자 스토리:** +사용자는 자신이 좋아요한 상품 목록을 확인할 수 있다. + +**API 명세:** +``` +GET /api/v1/users/{userId}/likes +Authorization: Required +``` + +**상세 동작:** +1. 인증된 사용자만 조회 가능 +2. URL의 `{userId}` 파라미터는 무시 +3. 항상 로그인한 사용자의 좋아요 목록만 조회 +4. 페이지네이션 지원 (추후 구체화 필요) + +**정책:** +- 다른 사용자의 좋아요 목록은 조회 불가 +- 권한 에러(403) 없음, URL userId는 참고용으로만 사용 +- 향후 확장 가능성을 위해 URL 구조는 유지 + +**참고:** +- 현재는 본인 것만 조회하지만, URL 구조는 향후 "친구 좋아요 목록 보기" 등의 기능 확장을 염두 + +--- + +### FR-4. 좋아요 수 노출 +**사용자 스토리:** +사용자는 상품의 인기도를 파악하기 위해 좋아요 수를 확인할 수 있다. + +**노출 위치:** +- 상품 목록 페이지 +- 상품 상세 페이지 + +**상세 동작:** +1. 상품 조회 API 응답에 `like_count` 포함 +2. 좋아요 등록/취소 직후에는 카운트가 바로 반영되지 않을 수 있음 (Eventual Consistency) +3. 향후 트래픽 증가 시 Redis로 전환 고려 + +**정책:** +- 좋아요 수는 정확하지 않아도 됨 (몇 초~몇 분 지연 허용) +- 대략적인 인기도 파악이 목적 + +--- + +## 4️⃣ 비기능 요구사항 + +### NFR-1. 성능 +- 좋아요 등록/취소 API는 200ms 이내 응답 목표 +- 동시 좋아요 시 DB 락 경합 최소화 (비동기 카운트 업데이트) + +### NFR-2. 확장성 +- 향후 Redis를 통한 카운트 캐싱 구조로 전환 가능해야 함 +- 이벤트 기반 아키텍처로 다른 시스템과의 결합도 최소화 + +### NFR-3. 데이터 일관성 +- 좋아요 데이터는 강한 일관성 (DB 제약) +- 좋아요 카운트는 최종 일관성 (Eventual Consistency) +- 배치를 통한 정합성 복구 메커니즘 필요 + +### NFR-4. 보안 +- 모든 API는 인증 필수 +- SQL Injection, XSS 등 기본 보안 위협 방어 + +--- + +## 5️⃣ 결정된 제약사항 및 전제조건 + +### 데이터 제약 +- 사용자 1명당 상품 1개에 대해 좋아요 1개만 가능 + - DB Unique Index: `(user_id, product_id)` + +### 아키텍처 결정 +- **동기 처리:** 좋아요 등록/취소 (즉시 응답) +- **비동기 처리:** 카운트 업데이트 (이벤트 기반) +- **재시도 없음:** 이벤트 실패 시 로그만 남기고 배치로 복구 + +### 향후 확장 고려사항 +- Redis 도입 (카운트 캐싱) +- 친구/타인의 좋아요 목록 조회 기능 +- 좋아요 기반 추천 시스템 + +--- + +## 6️⃣ 다음 단계에서 다룰 내용 + +1. **시퀀스 다이어그램** + - 좋아요 등록 시 트랜잭션 경계 확인 + - 이벤트 발행 및 비동기 처리 흐름 + - 실패 시나리오 + +2. **클래스 다이어그램** + - Product, Like, User 간의 관계 + - 도메인 책임 분리 + - 의존 방향 + +3. **ERD** + - 테이블 구조 및 관계 + - Unique 제약 + - 인덱스 전략 + +4. **설계 리스크 분석** + - 이벤트 실패 시 정합성 불일치 리스크 + - 배치 복구 전략의 한계 + - 향후 확장 시 고려사항 \ No newline at end of file diff --git "a/docs/design/\354\242\213\354\225\204\354\232\224/02-sequence-diagrams.md" "b/docs/design/\354\242\213\354\225\204\354\232\224/02-sequence-diagrams.md" new file mode 100644 index 000000000..a25ba6d0f --- /dev/null +++ "b/docs/design/\354\242\213\354\225\204\354\232\224/02-sequence-diagrams.md" @@ -0,0 +1,470 @@ +# 시퀀스 다이어그램 + +## 다이어그램을 그리기 전에 + +이 문서에서는 **4개의 시퀀스 다이어그램**을 작성합니다: + +1. **좋아요 등록** - 단순 INSERT, 카운트는 실시간 계산 +2. **좋아요 취소** - 멱등성 보장 +3. **좋아요 목록 조회** - 본인 것만 조회 +4. **상품 조회 (좋아요 수 포함)** - COUNT 쿼리로 실시간 계산 + +각 다이어그램에서 주목할 점: +- **트랜잭션 경계**: 어디까지가 하나의 원자적 작업인가? +- **책임 분리**: 각 객체는 무엇을 책임지는가? +- **확장 포인트**: 나중에 Redis로 전환할 때 어디를 바꾸면 되는가? + +--- + +## 1. 좋아요 등록 + +### 왜 이 다이어그램이 필요한가? +좋아요 등록은 **단순 INSERT**만 수행합니다. +이 다이어그램을 통해 다음을 검증합니다: +- 중복 좋아요는 어떻게 방지하는가? +- 트랜잭션 경계가 어디까지인가? +- 카운트는 어디서 계산되는가? + +```mermaid +sequenceDiagram + actor User as 사용자 + participant API as LikeController + participant Service as LikeService + participant Repo as LikeRepository + participant DB as Database + + User->>API: POST /products/{productId}/likes + activate API + + API->>Service: createLike(userId, productId) + activate Service + + Service->>Repo: existsByUserIdAndProductId(userId, productId) + activate Repo + Repo->>DB: SELECT EXISTS(...) + DB-->>Repo: false + Repo-->>Service: false + deactivate Repo + + Note over Service: 중복 아님 확인 + + Service->>Repo: save(Like) + activate Repo + Repo->>DB: INSERT INTO likes
(user_id, product_id, created_at) + Note over DB: Unique(user_id, product_id) + DB-->>Repo: 성공 + Repo-->>Service: Like 객체 + deactivate Repo + + Note over Service: 트랜잭션 커밋
카운트 업데이트 없음 + + Service-->>API: Like 객체 + deactivate Service + + API-->>User: 201 Created + deactivate API +``` + +### 이 다이어그램에서 봐야 할 포인트 + +1. **트랜잭션 경계** + - `LikeService`의 `save(like)` 만 수행 + - 카운트 업데이트 없음 (실시간 계산) + +2. **중복 방지** + - 애플리케이션 레벨: `existsByUserIdAndProductId()` 체크 + - DB 레벨: Unique 제약으로 이중 안전장치 + +3. **응답 시점** + - `likes` 테이블 INSERT 성공 즉시 응답 + - 매우 빠른 응답 속도 (추가 작업 없음) + +4. **확장 포인트** + - 나중에 Redis 캐시를 추가할 때도 이 플로우는 변경 없음 + - 상품 조회 시 카운트 계산 로직만 수정하면 됨 + +--- + +## 2. 좋아요 취소 (멱등성 보장) + +### 왜 이 다이어그램이 필요한가? +우리는 **멱등성을 보장**하기로 했습니다. +존재하지 않는 좋아요를 취소해도 성공 응답을 주기 때문에, 이 플로우를 명확히 해야 합니다. + +```mermaid +sequenceDiagram + actor User as 사용자 + participant API as LikeController + participant Service as LikeService + participant Repo as LikeRepository + participant DB as Database + participant Logger as ErrorLogger + + User->>API: DELETE /products/{productId}/likes + activate API + + API->>Service: deleteLike(userId, productId) + activate Service + + Service->>Repo: findByUserIdAndProductId(userId, productId) + activate Repo + Repo->>DB: SELECT + DB-->>Repo: null (존재하지 않음) + Repo-->>Service: null + deactivate Repo + + Note over Service: 이미 없는 좋아요 + + Service->>Logger: warn("이미 삭제된 좋아요", userId, productId) + activate Logger + Logger-->>Service: 로그 기록 완료 + deactivate Logger + + Note over Service: 멱등성 보장:
성공으로 간주 + + Service-->>API: 성공 + deactivate Service + + API-->>User: 204 No Content + deactivate API + + Note over User,API: 클라이언트는 정상 응답 받음
내부적으로는 로그만 남김 +``` + +### 정상 삭제 플로우 (참고) + +존재하는 좋아요를 취소하는 경우: + +```mermaid +sequenceDiagram + actor User as 사용자 + participant API as LikeController + participant Service as LikeService + participant Repo as LikeRepository + participant DB as Database + + User->>API: DELETE /products/{productId}/likes + activate API + + API->>Service: deleteLike(userId, productId) + activate Service + + Service->>Repo: findByUserIdAndProductId(userId, productId) + activate Repo + Repo->>DB: SELECT + DB-->>Repo: Like 객체 + Repo-->>Service: Like 객체 + deactivate Repo + + Note over Service: 좋아요 존재 확인 + + Service->>Repo: delete(like) + activate Repo + Repo->>DB: DELETE FROM likes
WHERE id = ? + DB-->>Repo: 성공 + Repo-->>Service: 완료 + deactivate Repo + + Note over Service: 트랜잭션 커밋
카운트는 조회 시 계산됨 + + Service-->>API: 성공 + deactivate Service + + API-->>User: 204 No Content + deactivate API +``` + +### 이 다이어그램에서 봐야 할 포인트 + +1. **멱등성 보장** + - 없는 좋아요를 취소해도 `204 No Content` 응답 + - 클라이언트 입장에서는 여러 번 호출해도 결과 동일 + +2. **로깅 전략** + - 비정상적인 접근을 추적하기 위해 로그는 남김 + - 하지만 에러로 처리하지는 않음 + +3. **카운트 처리** + - 좋아요 취소 시 별도의 카운트 감소 작업 없음 + - 상품 조회 시 실시간으로 COUNT 쿼리 실행 + +4. **트랜잭션 단순화** + - DELETE 한 번만 수행 + - 추가 업데이트 없어서 트랜잭션 짧고 명확함 + +--- + +## 3. 좋아요 목록 조회 + +### 왜 이 다이어그램이 필요한가? +읽기 플로우는 단순하지만, **URL의 userId를 무시하고 항상 로그인 사용자 것만 조회**하는 정책을 명확히 해야 합니다. + +```mermaid +sequenceDiagram + actor User as 사용자 + participant API as LikeController + participant Auth as AuthContext + participant Service as LikeService + participant Repo as LikeRepository + participant DB as Database + + User->>API: GET /users/{userId}/likes + activate API + + Note over API: URL의 userId는 무시 + + API->>Auth: getCurrentUserId() + activate Auth + Auth-->>API: authenticatedUserId + deactivate Auth + + API->>Service: findLikesByUserId(authenticatedUserId) + activate Service + + Service->>Repo: findByUserId(authenticatedUserId) + activate Repo + Repo->>DB: SELECT l.*, p.*
FROM likes l
JOIN products p ON l.product_id = p.id
WHERE l.user_id = ? + DB-->>Repo: List + Repo-->>Service: List + deactivate Repo + + Service-->>API: List + deactivate Service + + API-->>User: 200 OK + 좋아요 목록 + deactivate API +``` + +### 이 다이어그램에서 봐야 할 포인트 + +1. **URL 파라미터 무시** + - `GET /users/{userId}/likes` 의 `{userId}` 는 받지만 사용하지 않음 + - 항상 `AuthContext.getCurrentUserId()`로 로그인 사용자 확인 + +2. **책임 분리** + - `AuthContext`: 현재 로그인 사용자 식별 책임 + - `LikeService`: 비즈니스 로직 없이 Repository 호출만 + - `LikeRepository`: DB 조회 책임 + +3. **확장 가능성** + - 나중에 "친구 좋아요 조회" 기능이 추가되면? + - `API` 레이어에서 권한 체크만 추가하면 됨 + - Service, Repository는 수정 불필요 + +4. **조인 처리** + - `likes` 테이블만 조회하면 상품 정보가 없음 + - `products` 와 조인하여 상품 정보도 함께 반환 + - (페이지네이션은 추후 구체화) + +--- + +## 4. 상품 조회 (좋아요 수 포함) + +### 왜 이 다이어그램이 필요한가? +상품 목록/상세 조회 시 **좋아요 수를 어떻게 계산하는지**가 핵심입니다. +이 다이어그램을 통해 다음을 검증합니다: +- COUNT 쿼리는 어디서 실행되는가? +- 향후 Redis 캐시로 전환할 때 어디를 수정하면 되는가? + +```mermaid +sequenceDiagram + actor User as 사용자 + participant API as ProductController + participant Service as ProductService + participant ProdRepo as ProductRepository + participant LikeRepo as LikeRepository + participant DB as Database + + User->>API: GET /products/{productId} + activate API + + API->>Service: getProductDetail(productId) + activate Service + + Service->>ProdRepo: findById(productId) + activate ProdRepo + ProdRepo->>DB: SELECT * FROM products
WHERE id = ? + DB-->>ProdRepo: Product 객체 + ProdRepo-->>Service: Product 객체 + deactivate ProdRepo + + Service->>LikeRepo: countByProductId(productId) + activate LikeRepo + LikeRepo->>DB: SELECT COUNT(*)
FROM likes
WHERE product_id = ? + DB-->>LikeRepo: count (예: 42) + LikeRepo-->>Service: 42 + deactivate LikeRepo + + Note over Service: Product + like_count 조합 + + Service-->>API: ProductDetailResponse
(product + likeCount: 42) + deactivate Service + + API-->>User: 200 OK + 상품 정보 + deactivate API +``` + +### 상품 목록 조회의 경우 + +목록 조회는 여러 상품의 좋아요 수를 한 번에 계산해야 합니다: + +```mermaid +sequenceDiagram + actor User as 사용자 + participant API as ProductController + participant Service as ProductService + participant ProdRepo as ProductRepository + participant LikeRepo as LikeRepository + participant DB as Database + + User->>API: GET /products?page=1 + activate API + + API->>Service: getProductList(page) + activate Service + + Service->>ProdRepo: findAll(pageable) + activate ProdRepo + ProdRepo->>DB: SELECT * FROM products
LIMIT 20 OFFSET 0 + DB-->>ProdRepo: List + ProdRepo-->>Service: List + deactivate ProdRepo + + Note over Service: 상품 ID 목록 추출
[1, 2, 3, ..., 20] + + Service->>LikeRepo: countByProductIds([1,2,3,...,20]) + activate LikeRepo + LikeRepo->>DB: SELECT product_id, COUNT(*)
FROM likes
WHERE product_id IN (...)
GROUP BY product_id + DB-->>LikeRepo: Map + LikeRepo-->>Service: Map<1→42, 2→15, ...> + deactivate LikeRepo + + Note over Service: Product + count 매칭 + + Service-->>API: List + deactivate Service + + API-->>User: 200 OK + 상품 목록 + deactivate API +``` + +### 이 다이어그램에서 봐야 할 포인트 + +1. **실시간 계산** + - 매 조회마다 `COUNT(*)` 쿼리 실행 + - 항상 정확한 값 반환 + - 비동기 처리나 이벤트 없음 + +2. **성능 고려** + - 단일 상품: COUNT 쿼리 1번 + - 목록 조회: GROUP BY로 한 번에 계산 (N+1 문제 방지) + - 인덱스 필요: `likes(product_id)` + +3. **확장 포인트 (Redis 도입 시)** + ``` + 현재: Service → LikeRepository.countByProductId() → DB + 향후: Service → LikeCacheService.getLikeCount() → Redis (Cache Miss 시 DB) + ``` + - `LikeRepository.countByProductId()` 호출 부분만 수정 + - 나머지 플로우는 변경 없음 + +4. **트레이드오프** + - 장점: 정합성 100%, 구조 단순 + - 단점: 조회 시 COUNT 쿼리 부하 + - 해결: 트래픽 증가 시 Redis 캐시로 전환 + +--- + +## 4. 설계 의도 정리 + +### 트랜잭션 경계 설정 이유 +``` +[동기 트랜잭션] +- likes 테이블 INSERT/DELETE만 포함 +- 빠른 응답 가능 +- DB 락 시간 최소화 + +[비동기 처리] +- 카운트 업데이트 +- 이벤트 실패해도 좋아요 등록/취소는 성공 +- 정합성은 배치로 복구 +``` + +### 객체별 책임 + +| 객체 | 책임 | +|------|------| +| `LikeController` | HTTP 요청/응답, 인증 확인 | +| `LikeService` | 비즈니스 로직, 이벤트 발행 | +| `LikeRepository` | 데이터 접근, 쿼리 실행 | +| `EventPublisher` | 메시지 큐 전송 | +| `LikeCountConsumer` | 카운트 업데이트 (독립 프로세스) | +| `ProductRepository` | 상품 데이터 접근 | + +### 호출 순서의 의미 + +**좋아요 등록:** +1. 중복 체크 (비즈니스 규칙) +2. 저장 (트랜잭션 커밋) +3. 이벤트 발행 (비동기 시작점) +4. 응답 반환 (사용자에게) +5. 카운트 업데이트 (백그라운드) + +**좋아요 취소:** +1. 존재 여부 확인 +2. 없으면 로그만 남기고 성공 응답 (멱등성) +3. 있으면 삭제 → 이벤트 발행 → 카운트 감소 + +--- + +## 5. 설계 리스크 및 트레이드오프 + +### ⚠️ 리스크 1: 이벤트 발행 실패 +**상황:** +`EventPublisher.publish()` 에서 예외 발생 + +**결과:** +- 좋아요는 DB에 저장됨 +- 카운트는 업데이트 안 됨 +- 사용자는 성공 응답 받음 + +**대응:** +- 배치로 정합성 복구 +- `likes` 테이블 COUNT와 `products.like_count` 비교하여 보정 + +### ⚠️ 리스크 2: Consumer 처리 실패 +**상황:** +`LikeCountConsumer`에서 DB 업데이트 실패 + +**결과:** +- 메시지는 큐에서 사라짐 (ack 전에 실패하면 재처리) +- 카운트가 실제와 어긋남 + +**대응:** +- 현재 설계에서는 재시도 없음 +- 배치로 정합성 복구 + +### ✅ 트레이드오프 정리 + +| 선택 | 얻은 것 | 잃은 것 | +|------|---------|---------| +| 비동기 카운트 업데이트 | 빠른 응답, 락 경합 없음 | 실시간 정합성 | +| 재시도 없음 | 구현 단순 | 실패 시 데이터 불일치 | +| 멱등성 보장 (취소) | 안정적인 API | 비정상 접근 추적 어려움 | +| URL userId 무시 | 확장 가능한 구조 | URL 파라미터 의미 모호 | + +--- + +## 다음 단계 + +다음 문서에서는 **클래스 다이어그램**을 작성하여: +- 각 객체의 속성과 메서드 정의 +- 도메인 간 의존 방향 명확화 +- 응집도와 결합도 검증 + +이후 **ERD**를 통해: +- 테이블 구조 및 제약사항 +- 인덱스 전략 +- 데이터 정합성 보장 방법 + +을 다룰 예정입니다. \ No newline at end of file diff --git "a/docs/design/\354\242\213\354\225\204\354\232\224/03-class-diagram.md" "b/docs/design/\354\242\213\354\225\204\354\232\224/03-class-diagram.md" new file mode 100644 index 000000000..a1baa7d9c --- /dev/null +++ "b/docs/design/\354\242\213\354\225\204\354\232\224/03-class-diagram.md" @@ -0,0 +1,723 @@ +# 클래스 다이어그램 + +## 다이어그램을 그리기 전에 + +이 문서에서는 **좋아요 기능의 도메인 객체 설계**를 다룹니다. + +### 설계 원칙 +1. **단순하게 시작** - Facade, VO, 도메인 서비스 같은 패턴은 필요할 때 추가 +2. **책임 분리** - 각 레이어가 자기 역할만 수행 +3. **확장 가능** - 나중에 Redis, MessageQueue 추가해도 구조 변경 최소화 + +### 레이어 구조 +``` +Controller (API 진입점) + ↓ +Service (비즈니스 로직 조정) + ↓ +Repository (데이터 접근) + ↓ +Entity (도메인 모델) +``` + +--- + +## 1. 전체 클래스 다이어그램 + +```mermaid +classDiagram + class LikeController { + -LikeService likeService + +createLike(productId, userId) ResponseEntity + +deleteLike(productId, userId) ResponseEntity + +getLikesByUser(userId) ResponseEntity + } + + class ProductController { + -ProductService productService + -LikeService likeService + +getProductDetail(productId) ResponseEntity + +getProductList(pageable) ResponseEntity + } + + class LikeService { + -LikeRepository likeRepository + +createLike(userId, productId) Like + +deleteLike(userId, productId) void + +findLikesByUserId(userId) List~Like~ + +countByProductId(productId) int + +countByProductIds(productIds) Map~Long,Integer~ + } + + class ProductService { + -ProductRepository productRepository + +findById(productId) Product + +findAll(pageable) List~Product~ + } + + class LikeRepository { + <> + +existsByUserIdAndProductId(userId, productId) boolean + +findByUserIdAndProductId(userId, productId) Optional~Like~ + +findByUserId(userId) List~Like~ + +countByProductId(productId) int + +countByProductIds(productIds) Map~Long,Integer~ + +save(like) Like + +delete(like) void + } + + class ProductRepository { + <> + +findById(id) Optional~Product~ + +findAll(pageable) Page~Product~ + } + + class Like { + -Long id + -Long userId + -Long productId + -LocalDateTime createdAt + +create(userId, productId)$ Like + } + + class Product { + -Long id + -String name + -String description + -int price + -LocalDateTime createdAt + } + + LikeController --> LikeService + ProductController --> ProductService + ProductController --> LikeService + LikeService --> LikeRepository + ProductService --> ProductRepository + LikeRepository --> Like + ProductRepository --> Product +``` + +--- + +## 2. 레이어별 책임 정의 + +### Controller 레이어 + +#### LikeController +**책임:** +- HTTP 요청/응답 처리 +- 인증 정보 추출 (현재 로그인 사용자) +- DTO 변환 + +**주요 메서드:** +```java +@PostMapping("/api/v1/products/{productId}/likes") +public ResponseEntity createLike( + @PathVariable Long productId, + @AuthenticationPrincipal Long userId +) { + Like like = likeService.createLike(userId, productId); + return ResponseEntity.status(201).body(LikeResponse.from(like)); +} + +@DeleteMapping("/api/v1/products/{productId}/likes") +public ResponseEntity deleteLike( + @PathVariable Long productId, + @AuthenticationPrincipal Long userId +) { + likeService.deleteLike(userId, productId); + return ResponseEntity.noContent().build(); +} + +@GetMapping("/api/v1/users/{userId}/likes") +public ResponseEntity> getLikesByUser( + @PathVariable Long userId, // 무시됨 + @AuthenticationPrincipal Long authenticatedUserId +) { + List likes = likeService.findLikesByUserId(authenticatedUserId); + return ResponseEntity.ok(LikeResponse.fromList(likes)); +} +``` + +**특이사항:** +- URL의 `{userId}` 파라미터는 받지만 사용하지 않음 +- 항상 `@AuthenticationPrincipal`에서 추출한 사용자 ID 사용 + +--- + +#### ProductController +**책임:** +- 상품 조회 API 제공 +- 좋아요 수 포함 응답 생성 + +**주요 메서드:** +```java +@GetMapping("/api/v1/products/{productId}") +public ResponseEntity getProductDetail( + @PathVariable Long productId +) { + Product product = productService.findById(productId); + int likeCount = likeService.countByProductId(productId); + + return ResponseEntity.ok( + new ProductDetailResponse(product, likeCount) + ); +} + +@GetMapping("/api/v1/products") +public ResponseEntity> getProductList( + Pageable pageable +) { + List products = productService.findAll(pageable); + + // 상품 ID 목록 추출 + List productIds = products.stream() + .map(Product::getId) + .collect(Collectors.toList()); + + // 한 번에 좋아요 수 조회 (N+1 방지) + Map likeCounts = likeService.countByProductIds(productIds); + + List responses = products.stream() + .map(product -> new ProductListResponse( + product, + likeCounts.getOrDefault(product.getId(), 0) + )) + .collect(Collectors.toList()); + + return ResponseEntity.ok(responses); +} +``` + +**왜 Facade가 없는가?** +- 현재는 2개 Service만 조합 (단순) +- 복잡도가 낮아서 Controller에서 직접 처리 +- 나중에 Service 조합이 복잡해지면 Facade 추가 고려 + +--- + +### Service 레이어 + +#### LikeService +**책임:** +- 좋아요 비즈니스 로직 조정 +- 중복 체크 (이중 안전장치) +- 트랜잭션 경계 관리 + +**주요 메서드:** +```java +@Service +@Transactional +public class LikeService { + private final LikeRepository likeRepository; + + public Like createLike(Long userId, Long productId) { + // 1차 중복 체크 (애플리케이션 레벨) + if (likeRepository.existsByUserIdAndProductId(userId, productId)) { + throw new DuplicateLikeException("이미 좋아요를 누르셨습니다"); + } + + // Like 생성 (생성자가 검증 책임) + Like like = new Like(userId, productId); + + // 저장 (2차 방어: DB Unique 제약) + return likeRepository.save(like); + } + + public void deleteLike(Long userId, Long productId) { + Optional likeOpt = likeRepository.findByUserIdAndProductId(userId, productId); + + if (likeOpt.isEmpty()) { + // 멱등성 보장: 이미 없으면 로그만 남기고 성공 처리 + log.warn("이미 삭제된 좋아요 - userId: {}, productId: {}", userId, productId); + return; + } + + likeRepository.delete(likeOpt.get()); + } + + @Transactional(readOnly = true) + public List findLikesByUserId(Long userId) { + return likeRepository.findByUserId(userId); + } + + @Transactional(readOnly = true) + public int countByProductId(Long productId) { + return likeRepository.countByProductId(productId); + } + + @Transactional(readOnly = true) + public Map countByProductIds(List productIds) { + return likeRepository.countByProductIds(productIds); + } +} +``` + +**특이사항:** +- `deleteLike`는 존재하지 않아도 예외를 던지지 않음 (멱등성) +- 조회 메서드는 `@Transactional(readOnly = true)` +- **도메인 서비스가 없는 이유**: 복잡한 비즈니스 규칙이 없음 + +--- + +#### ProductService +**책임:** +- 상품 조회 로직 + +**주요 메서드:** +```java +@Service +@Transactional(readOnly = true) +public class ProductService { + private final ProductRepository productRepository; + + public Product findById(Long productId) { + return productRepository.findById(productId) + .orElseThrow(() -> new ProductNotFoundException(productId)); + } + + public List findAll(Pageable pageable) { + return productRepository.findAll(pageable).getContent(); + } +} +``` + +**왜 LikeService를 의존하지 않는가?** +- Product는 Like를 알 필요가 없음 (도메인 경계) +- 좋아요 수는 Controller에서 조합 +- 의존 방향: `Like → Product` (O), `Product → Like` (X) + +--- + +### Repository 레이어 + +#### LikeRepository +**책임:** +- Like 엔티티의 데이터 접근 +- 쿼리 실행 (조회, 저장, 삭제, 집계) + +**주요 메서드:** +```java +public interface LikeRepository extends JpaRepository { + + // 존재 여부 확인 (중복 체크) + boolean existsByUserIdAndProductId(Long userId, Long productId); + + // 특정 좋아요 조회 + Optional findByUserIdAndProductId(Long userId, Long productId); + + // 사용자의 좋아요 목록 (상품 정보 포함) + @Query("SELECT l FROM Like l JOIN FETCH l.product WHERE l.userId = :userId") + List findByUserId(@Param("userId") Long userId); + + // 상품별 좋아요 수 (단일) + @Query("SELECT COUNT(l) FROM Like l WHERE l.productId = :productId") + int countByProductId(@Param("productId") Long productId); + + // 상품별 좋아요 수 (다건 - N+1 방지) + @Query("SELECT l.productId as productId, COUNT(l) as count " + + "FROM Like l " + + "WHERE l.productId IN :productIds " + + "GROUP BY l.productId") + List countByProductIdsGrouped(@Param("productIds") List productIds); + + // Map으로 변환하는 default 메서드 + default Map countByProductIds(List productIds) { + return countByProductIdsGrouped(productIds).stream() + .collect(Collectors.toMap( + LikeCountProjection::getProductId, + LikeCountProjection::getCount + )); + } + + interface LikeCountProjection { + Long getProductId(); + Integer getCount(); + } +} +``` + +**확장 포인트 (Redis 도입 시):** +```java +// 현재: DB에서 직접 COUNT +int countByProductId(Long productId); + +// 향후: Cache 레이어 추가 +@Cacheable(value = "likeCount", key = "#productId") +int countByProductId(Long productId); +``` + +--- + +#### ProductRepository +**책임:** +- Product 엔티티의 데이터 접근 + +```java +public interface ProductRepository extends JpaRepository { + // JpaRepository 기본 메서드 사용 + // findById, findAll, save, delete 등 +} +``` + +--- + +### Entity 레이어 + +#### Like (좋아요 엔티티) + +```java +@Entity +@Table( + name = "likes", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_likes_user_product", + columnNames = {"user_id", "product_id"} + ) + } +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Like { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private final Long userId; + + @Column(name = "product_id", nullable = false) + private final Long productId; + + @Column(name = "created_at", nullable = false, updatable = false) + private final LocalDateTime createdAt; + + // 생성자가 검증 책임 + public Like(Long userId, Long productId) { + validateUserId(userId); + validateProductId(productId); + + this.userId = userId; + this.productId = productId; + this.createdAt = LocalDateTime.now(); + } + + private void validateUserId(Long userId) { + if (userId == null || userId <= 0) { + throw new IllegalArgumentException("유효하지 않은 사용자 ID입니다"); + } + } + + private void validateProductId(Long productId) { + if (productId == null || productId <= 0) { + throw new IllegalArgumentException("유효하지 않은 상품 ID입니다"); + } + } +} +``` + +**설계 포인트:** +1. **생성자에서 검증** + - `new Like(userId, productId)` 사용 + - 생성자가 검증 로직을 가짐 + - 단순하고 명확함 + +2. **Unique 제약** + - DB 레벨에서 중복 방지 + - 애플리케이션 체크 실패 시 최종 방어선 + +3. **VO를 사용하지 않는 이유** + - 현재는 단순 primitive 타입으로 충분 + - 나중에 필요하면 `LikeCount` VO 추가 고려 + +4. **연관관계 매핑 안 함** + - `@ManyToOne Product product` 같은 거 없음 + - 이유: Like는 productId만 알면 됨 (객체 그래프 탐색 불필요) + - 조회 시 필요하면 JOIN FETCH 사용 + +--- + +#### Product (상품 엔티티) + +```java +@Entity +@Table(name = "products") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Product { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private final String name; + + @Column(columnDefinition = "TEXT") + private final String description; + + @Column(nullable = false) + private final int price; + + @Column(name = "created_at", nullable = false, updatable = false) + private final LocalDateTime createdAt; + + @Builder + public Product(String name, String description, int price) { + this.name = name; + this.description = description; + this.price = price; + this.createdAt = LocalDateTime.now(); + } +} +``` + +**설계 포인트:** +1. **like_count 컬럼 없음** + - 실시간 COUNT 쿼리로 계산 + - 정합성 문제 없음 + +2. **Like 엔티티와 연관관계 없음** + - Product는 Like를 모름 + - 단방향 의존: `Like → Product` + +--- + +## 3. 의존 방향 다이어그램 + +```mermaid +graph TD + A[LikeController] --> B[LikeService] + C[ProductController] --> D[ProductService] + C --> B + B --> E[LikeRepository] + D --> F[ProductRepository] + E --> G[Like Entity] + F --> H[Product Entity] + + style A fill:#e1f5ff + style C fill:#e1f5ff + style B fill:#fff4e1 + style D fill:#fff4e1 + style E fill:#f0f0f0 + style F fill:#f0f0f0 + style G fill:#e8f5e9 + style H fill:#e8f5e9 +``` + +**의존 방향 원칙:** +- Controller → Service (단방향) +- Service → Repository (단방향) +- Repository → Entity (단방향) +- **Product ↛ Like** (상품은 좋아요를 모름) + +--- + +## 4. 설계 의도 정리 + +### 왜 이렇게 설계했는가? + +#### 1. Facade를 넣지 않은 이유 +``` +현재: ProductController → ProductService + LikeService +``` + +**판단 기준:** +- Service 조합이 2개로 단순 +- 복잡한 트랜잭션 조율 없음 +- 나중에 필요하면 추가 가능 (YAGNI 원칙) + +**Facade가 필요한 시점:** +- ReviewService, CommentService 등 3개 이상 조합 +- 여러 Controller에서 동일한 조합 반복 +- 복잡한 보상 트랜잭션 필요 + +--- + +#### 2. VO를 넣지 않은 이유 +``` +현재: int likeCount +가능: LikeCount likeCount (VO) +``` + +**판단 기준:** +- 현재는 단순 정수로 충분 +- 복잡한 비즈니스 규칙 없음 +- 조기 최적화 방지 + +**VO가 필요한 시점:** +- 음수 방지, 범위 체크 등 검증 로직 필요 +- likeCount와 관련된 메서드가 여러 곳에 중복 +- 도메인 의미가 강해질 때 (예: "인기 상품 기준") + +--- + +#### 3. 도메인 서비스를 넣지 않은 이유 +``` +현재: LikeService (Application Service) +가능: LikeDomainService (Domain Service) +``` + +**판단 기준:** +- 복잡한 도메인 로직 없음 +- Like 생성/삭제/조회만 있음 +- 엔티티 간 협력 로직 없음 + +**도메인 서비스가 필요한 시점:** +- 여러 엔티티가 협력하는 로직 +- 특정 엔티티에 속하지 않는 비즈니스 규칙 +- 예: "스팸 좋아요 감지", "좋아요 제한 정책" + +--- + +#### 4. 중복 체크를 이중으로 하는 이유 +``` +1차: existsByUserIdAndProductId() - SELECT EXISTS +2차: DB Unique 제약 - INSERT 시 체크 +``` + +**판단 기준:** +- 트래픽 적음 → 쿼리 1번 더 괜찮음 +- 명확한 에러 메시지 (사용자 경험) +- DB Unique는 최종 방어선 (Race Condition 방지) + +**나중에 변경 가능:** +- 트래픽 증가 시 1차 체크 제거 +- `try-catch`로 DB 제약 위반 처리 + +--- + +## 5. 확장 시나리오 + +### 시나리오 1: Redis 캐시 도입 + +**변경 지점:** +```java +// LikeRepository 또는 LikeService +@Cacheable(value = "likeCount", key = "#productId") +public int countByProductId(Long productId) { + // 캐시 미스 시에만 DB 조회 + return likeRepository.countByProductId(productId); +} +``` + +**영향 범위:** +- Repository 또는 Service 한 곳만 수정 +- Controller, Entity는 변경 없음 + +--- + +### 시나리오 2: 좋아요 알림 기능 추가 + +**필요한 변경:** +```java +// LikeService +public Like createLike(Long userId, Long productId) { + // ... 기존 로직 + Like like = likeRepository.save(like); + + // 이벤트 발행 추가 + eventPublisher.publish(new LikeCreatedEvent(like)); + + return like; +} + +// 새로 추가 +@Component +class LikeEventListener { + @EventListener + public void onLikeCreated(LikeCreatedEvent event) { + // 상품 소유자에게 알림 전송 + } +} +``` + +**영향 범위:** +- LikeService에 이벤트 발행 추가 +- EventListener 새로 생성 +- Controller, Repository, Entity는 변경 없음 + +--- + +### 시나리오 3: 친구 좋아요 목록 조회 추가 + +**필요한 변경:** +```java +// LikeController +@GetMapping("/api/v1/users/{userId}/likes") +public ResponseEntity> getLikesByUser( + @PathVariable Long userId, + @AuthenticationPrincipal Long authenticatedUserId +) { + // 권한 체크 추가 + if (!userId.equals(authenticatedUserId) && !isFriend(userId, authenticatedUserId)) { + throw new ForbiddenException(); + } + + List likes = likeService.findLikesByUserId(userId); + return ResponseEntity.ok(LikeResponse.fromList(likes)); +} +``` + +**영향 범위:** +- Controller에 권한 체크만 추가 +- Service, Repository는 변경 없음 +- URL 구조는 이미 확장 가능하게 설계됨 + +--- + +## 6. 설계 리스크 및 트레이드오프 + +### ⚠️ 리스크 1: COUNT 쿼리 성능 +**상황:** +상품 목록 조회 시 매번 `GROUP BY` 실행 + +**영향:** +- 좋아요 테이블이 커지면 느려질 수 있음 +- 페이지당 20개 상품이면, 20개 상품의 COUNT 한 번에 계산 + +**대응:** +- 인덱스: `likes(product_id)` +- 나중에 Redis 캐시 도입 +- 현재는 문제 없음 (트래픽 적음) + +--- + +### ⚠️ 리스크 2: 중복 체크의 오버헤드 +**상황:** +좋아요 등록 시 `SELECT EXISTS` + `INSERT` 2번 쿼리 + +**영향:** +- 트래픽 많아지면 불필요한 SELECT +- 대부분 중복 아닐 텐데 매번 체크 + +**대응:** +- 초기에는 사용자 경험 우선 +- 트래픽 증가 시 `try-catch` 방식으로 전환 +- 현재는 괜찮음 + +--- + +### ✅ 트레이드오프 정리 + +| 선택 | 얻은 것 | 잃은 것 | 판단 | +|------|---------|---------|------| +| Facade 없음 | 코드 단순, 레이어 적음 | 조합 로직 분산 | 현재 규모에 적합 | +| VO 없음 | 구조 단순 | 검증 로직 분산 가능 | 필요 시 추가 | +| 도메인 서비스 없음 | 레이어 적음 | - | 복잡한 로직 없어서 불필요 | +| COUNT 실시간 계산 | 정합성 100% | 조회 성능 | 트래픽 적을 때 유리 | +| 이중 중복 체크 | 명확한 에러 | 쿼리 1번 더 | 사용자 경험 우선 | + +--- + +## 다음 단계 + +다음 문서에서는 **ERD(Entity Relationship Diagram)**를 작성하여: +- 테이블 구조 및 컬럼 정의 +- 인덱스 전략 +- Unique 제약 및 외래키 +- 데이터 정합성 보장 방법 + +을 다룰 예정입니다. \ No newline at end of file diff --git "a/docs/design/\354\242\213\354\225\204\354\232\224/04-erd.md" "b/docs/design/\354\242\213\354\225\204\354\232\224/04-erd.md" new file mode 100644 index 000000000..92996c112 --- /dev/null +++ "b/docs/design/\354\242\213\354\225\204\354\232\224/04-erd.md" @@ -0,0 +1,797 @@ +# ERD (Entity Relationship Diagram) + +## 다이어그램을 그리기 전에 + +이 문서에서는 **좋아요 기능의 데이터베이스 설계**를 다룹니다. + +### 설계 원칙 +1. **정합성 우선** - DB 제약으로 데이터 무결성 보장 +2. **성능 고려** - 필수 인덱스만 추가 (과한 인덱스는 쓰기 성능 저하) +3. **단순하게 시작** - 외래키, 트리거 등은 필요할 때 추가 + +### 주요 결정사항 +- **products 테이블에 like_count 컬럼 없음** - 실시간 COUNT 쿼리 사용 +- **외래키 제약** - 사용 여부 논의 필요 +- **인덱스 전략** - 조회 성능 최적화 + +--- + +## 1. 전체 ERD + +```mermaid +erDiagram + users ||--o{ likes : "좋아요 등록" + products ||--o{ likes : "좋아요 대상" + + users { + BIGINT id PK "사용자 ID" + VARCHAR(50) username UK "사용자명" + VARCHAR(100) email UK "이메일" + TIMESTAMP created_at "생성일시" + } + + products { + BIGINT id PK "상품 ID" + VARCHAR(200) name "상품명" + TEXT description "상품 설명" + INT price "가격" + TIMESTAMP created_at "생성일시" + } + + likes { + BIGINT id PK "좋아요 ID" + BIGINT user_id FK "사용자 ID" + BIGINT product_id FK "상품 ID" + TIMESTAMP created_at "생성일시" + } +``` + +--- + +## 2. 테이블 상세 정의 + +### users 테이블 + +> **참고:** 이 테이블은 좋아요 기능을 위해 필요하지만, 실제 구현은 인증/인가 시스템에서 제공될 것으로 가정합니다. + +```sql +CREATE TABLE users ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '사용자 ID', + username VARCHAR(50) NOT NULL UNIQUE COMMENT '사용자명', + email VARCHAR(100) NOT NULL UNIQUE COMMENT '이메일', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일시', + + INDEX idx_username (username), + INDEX idx_email (email) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='사용자'; +``` + +**컬럼 설명:** + +| 컬럼 | 타입 | 제약 | 설명 | +|------|------|------|------| +| id | BIGINT | PK, AUTO_INCREMENT | 사용자 고유 ID | +| username | VARCHAR(50) | NOT NULL, UNIQUE | 사용자명 | +| email | VARCHAR(100) | NOT NULL, UNIQUE | 이메일 | +| created_at | TIMESTAMP | NOT NULL, DEFAULT | 가입일시 | + +--- + +### products 테이블 + +```sql +CREATE TABLE products ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '상품 ID', + name VARCHAR(200) NOT NULL COMMENT '상품명', + description TEXT COMMENT '상품 설명', + price INT NOT NULL COMMENT '가격', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일시', + + INDEX idx_created_at (created_at DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='상품'; +``` + +**컬럼 설명:** + +| 컬럼 | 타입 | 제약 | 설명 | +|------|------|------|------| +| id | BIGINT | PK, AUTO_INCREMENT | 상품 고유 ID | +| name | VARCHAR(200) | NOT NULL | 상품명 | +| description | TEXT | NULL | 상품 설명 (길이 제한 없음) | +| price | INT | NOT NULL | 가격 (원 단위) | +| created_at | TIMESTAMP | NOT NULL, DEFAULT | 등록일시 | + +**중요: like_count 컬럼이 없는 이유** +- 실시간 COUNT 쿼리로 계산 +- 정합성 문제 없음 +- 나중에 Redis 캐시로 전환 가능 + +--- + +### likes 테이블 ⭐ + +```sql +CREATE TABLE likes ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '좋아요 ID', + user_id BIGINT NOT NULL COMMENT '사용자 ID', + product_id BIGINT NOT NULL COMMENT '상품 ID', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일시', + + -- 중복 방지 제약 (핵심!) + CONSTRAINT uk_likes_user_product UNIQUE (user_id, product_id), + + -- 인덱스 + INDEX idx_user_id (user_id), + INDEX idx_product_id (product_id), + INDEX idx_created_at (created_at DESC) + + -- 외래키 (선택적 - 아래에서 논의) + -- CONSTRAINT fk_likes_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + -- CONSTRAINT fk_likes_product FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='좋아요'; +``` + +**컬럼 설명:** + +| 컬럼 | 타입 | 제약 | 설명 | +|------|------|------|------| +| id | BIGINT | PK, AUTO_INCREMENT | 좋아요 고유 ID | +| user_id | BIGINT | NOT NULL | 사용자 ID | +| product_id | BIGINT | NOT NULL | 상품 ID | +| created_at | TIMESTAMP | NOT NULL, DEFAULT | 좋아요 등록일시 | + +--- + +## 3. 제약 조건 (Constraints) + +### Unique 제약: uk_likes_user_product ⭐⭐⭐ + +**가장 중요한 제약입니다.** + +```sql +CONSTRAINT uk_likes_user_product UNIQUE (user_id, product_id) +``` + +**역할:** +- 한 사용자가 한 상품에 대해 좋아요는 1개만 가능 +- DB 레벨에서 중복 방지 (Race Condition 방지) +- 애플리케이션 체크 실패 시 최종 방어선 + +**동작:** +```sql +-- 성공 +INSERT INTO likes (user_id, product_id) VALUES (1, 100); + +-- 실패 (DataIntegrityViolationException) +INSERT INTO likes (user_id, product_id) VALUES (1, 100); +``` + +**복합 인덱스 효과:** +- Unique 제약은 자동으로 인덱스를 생성 +- `(user_id, product_id)` 조합으로 빠른 조회 가능 + +--- + +### 외래키 제약 (Foreign Key) - 논의 필요 🤔 + +**외래키를 사용할까요?** + +```sql +-- 외래키 추가 시 +CONSTRAINT fk_likes_user + FOREIGN KEY (user_id) + REFERENCES users(id) + ON DELETE CASCADE, + +CONSTRAINT fk_likes_product + FOREIGN KEY (product_id) + REFERENCES products(id) + ON DELETE CASCADE +``` + +#### 외래키를 사용하는 경우 + +**장점:** +- 참조 무결성 보장 (존재하지 않는 user_id, product_id 막음) +- DB 레벨에서 데이터 정합성 보장 +- `ON DELETE CASCADE`: 상품 삭제 시 좋아요도 자동 삭제 + +**단점:** +- 쓰기 성능 저하 (INSERT/DELETE 시 참조 테이블 확인) +- 락 경합 가능성 +- 유연성 감소 (스키마 변경 어려움) + +#### 외래키를 사용하지 않는 경우 + +**장점:** +- 쓰기 성능 우수 +- 락 경합 없음 +- 스키마 변경 유연 + +**단점:** +- 애플리케이션에서 참조 무결성 보장해야 함 +- 고아 데이터(orphan data) 발생 가능 + +**어떻게 할까요?** + +--- + +## 4. 인덱스 전략 + +### likes 테이블 인덱스 + +```sql +-- 1. Unique 제약 (자동 생성) +UNIQUE INDEX uk_likes_user_product (user_id, product_id) + +-- 2. 사용자별 좋아요 조회 +INDEX idx_user_id (user_id) + +-- 3. 상품별 좋아요 수 계산 (핵심!) +INDEX idx_product_id (product_id) + +-- 4. 최근 좋아요 조회 (선택적) +INDEX idx_created_at (created_at DESC) +``` + +### 각 인덱스의 역할 + +#### 1. uk_likes_user_product (user_id, product_id) + +**사용 쿼리:** +```sql +-- 중복 체크 +SELECT COUNT(*) FROM likes WHERE user_id = ? AND product_id = ?; + +-- 좋아요 삭제 +DELETE FROM likes WHERE user_id = ? AND product_id = ?; +``` + +**특징:** +- 복합 인덱스 (user_id가 선두 컬럼) +- `user_id`로 시작하는 쿼리에 사용 가능 + +--- + +#### 2. idx_user_id (user_id) + +**사용 쿼리:** +```sql +-- 사용자의 좋아요 목록 조회 +SELECT * FROM likes WHERE user_id = ?; +``` + +**질문: uk_likes_user_product가 있는데 왜 필요한가?** + +복합 인덱스 `(user_id, product_id)`가 있으면 `user_id` 단독 조회도 가능합니다. +따라서 **이 인덱스는 사실 불필요할 수 있습니다.** + +**선택지:** +- A. `idx_user_id` 제거 (복합 인덱스로 충분) +- B. `idx_user_id` 유지 (명확성) + +--- + +#### 3. idx_product_id (product_id) ⭐ + +**사용 쿼리:** +```sql +-- 상품별 좋아요 수 (단일) +SELECT COUNT(*) FROM likes WHERE product_id = ?; + +-- 상품별 좋아요 수 (다건) +SELECT product_id, COUNT(*) +FROM likes +WHERE product_id IN (1, 2, 3, ...) +GROUP BY product_id; +``` + +**가장 중요한 인덱스입니다.** +- 상품 조회 시 좋아요 수 계산에 사용 +- 이 인덱스가 없으면 Full Table Scan 발생 +- 필수! + +--- + +#### 4. idx_created_at (created_at DESC) + +**사용 쿼리:** +```sql +-- 최근 좋아요 조회 (필요하다면) +SELECT * FROM likes ORDER BY created_at DESC LIMIT 10; +``` + +**필요 여부:** +- 현재 요구사항에는 없음 +- "최근 인기 상품" 같은 기능 추가 시 필요 +- 일단 **제거 고려** + +--- + +### 인덱스 정리 + +**최소한으로 필요한 인덱스:** +```sql +-- 1. 중복 방지 (필수) +UNIQUE INDEX uk_likes_user_product (user_id, product_id) + +-- 2. 상품별 좋아요 수 계산 (필수) +INDEX idx_product_id (product_id) +``` + +**선택적 인덱스:** +```sql +-- 3. 사용자별 조회 (복합 인덱스로 대체 가능) +INDEX idx_user_id (user_id) + +-- 4. 최근 좋아요 조회 (현재 불필요) +INDEX idx_created_at (created_at DESC) +``` + +--- + +## 5. 쿼리 성능 분석 + +### 주요 쿼리별 인덱스 활용 + +#### 쿼리 1: 좋아요 등록 (중복 체크) +```sql +SELECT COUNT(*) FROM likes +WHERE user_id = 1 AND product_id = 100; +``` +**사용 인덱스:** `uk_likes_user_product (user_id, product_id)` +- ✅ Index Scan +- ⚡ 매우 빠름 + +--- + +#### 쿼리 2: 상품별 좋아요 수 (단일) +```sql +SELECT COUNT(*) FROM likes WHERE product_id = 100; +``` +**사용 인덱스:** `idx_product_id (product_id)` +- ✅ Index Scan +- ⚡ 빠름 + +--- + +#### 쿼리 3: 상품별 좋아요 수 (다건) +```sql +SELECT product_id, COUNT(*) as count +FROM likes +WHERE product_id IN (1, 2, 3, ..., 20) +GROUP BY product_id; +``` +**사용 인덱스:** `idx_product_id (product_id)` +- ✅ Index Range Scan +- ⚡ 빠름 +- N+1 문제 방지 + +--- + +#### 쿼리 4: 사용자별 좋아요 목록 +```sql +SELECT l.*, p.* +FROM likes l +JOIN products p ON l.product_id = p.id +WHERE l.user_id = 1; +``` +**사용 인덱스:** `uk_likes_user_product (user_id, product_id)` +- ✅ Index Scan (user_id로 시작) +- ✅ JOIN은 products.id (PK) 사용 +- ⚡ 빠름 + +--- + +#### 쿼리 5: 좋아요 삭제 +```sql +DELETE FROM likes +WHERE user_id = 1 AND product_id = 100; +``` +**사용 인덱스:** `uk_likes_user_product (user_id, product_id)` +- ✅ Index Scan +- ⚡ 매우 빠름 + +--- + +## 6. 데이터 타입 선택 근거 + +### BIGINT vs INT + +**선택: BIGINT 사용** + +```sql +id BIGINT AUTO_INCREMENT +user_id BIGINT NOT NULL +product_id BIGINT NOT NULL +``` + +**이유:** +- INT 범위: -2,147,483,648 ~ 2,147,483,647 (약 21억) +- BIGINT 범위: -9,223,372,036,854,775,808 ~ 9,223,372,036,854,775,807 (매우 큼) +- 사용자/상품/좋아요가 21억 넘을 가능성 고려 +- 나중에 INT → BIGINT 변경은 매우 고통스러움 +- 저장 공간 차이: 4byte vs 8byte (큰 문제 아님) + +--- + +### TIMESTAMP vs DATETIME + +**선택: TIMESTAMP 사용** + +```sql +created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +``` + +**비교:** + +| 특성 | TIMESTAMP | DATETIME | +|------|-----------|----------| +| 범위 | 1970 ~ 2038 | 1000 ~ 9999 | +| 타임존 | 자동 변환 | 변환 안 함 | +| 저장 공간 | 4 byte | 5~8 byte | + +**TIMESTAMP 선택 이유:** +- 타임존 자동 변환 (서버 타임존 기준) +- 범위는 충분 (2038년 문제는 나중에 고민) +- 작은 저장 공간 + +**주의:** +- 2038년 이후가 중요하다면 DATETIME 사용 + +--- + +### VARCHAR vs TEXT + +**선택:** +- 상품명: `VARCHAR(200)` - 길이 제한 필요 +- 상품 설명: `TEXT` - 길이 제한 없음 + +**VARCHAR vs TEXT 차이:** + +| 특성 | VARCHAR | TEXT | +|------|---------|------| +| 최대 길이 | 65,535 byte | 65,535 byte | +| 인덱스 | 전체 가능 | Prefix만 가능 | +| 기본값 | 가능 | 불가능 (MySQL) | + +**상품명은 왜 VARCHAR(200)?** +- 인덱스 가능 (검색 필요 시) +- 너무 긴 상품명 방지 +- 200자면 충분 + +**상품 설명은 왜 TEXT?** +- 길이 제한 없음 (긴 설명 가능) +- 인덱스 불필요 (검색 안 함) + +--- + +## 7. 데이터 정합성 보장 전략 + +### 계층별 보장 방법 + +``` +┌─────────────────────────────────────┐ +│ Application (LikeService) │ +│ - exists() 체크 (1차 방어) │ +└──────────────┬──────────────────────┘ + │ +┌──────────────▼──────────────────────┐ +│ Database │ +│ - Unique 제약 (2차 방어, 최종) │ +│ - NOT NULL 제약 │ +│ - (선택) 외래키 제약 │ +└─────────────────────────────────────┘ +``` + +### 1. 중복 좋아요 방지 + +**계층:** +- 1차: Application - `existsByUserIdAndProductId()` +- 2차: Database - `UNIQUE (user_id, product_id)` + +**Race Condition 시나리오:** +``` +시간 0초: User1 exists() 체크 → false +시간 0초: User2 exists() 체크 → false +시간 1초: User1 INSERT → 성공 +시간 1초: User2 INSERT → Unique 제약 위반 (실패) +``` + +**결과:** DB 제약이 최종적으로 막음 ✅ + +--- + +### 2. NULL 방지 + +```sql +user_id BIGINT NOT NULL +product_id BIGINT NOT NULL +``` + +**애플리케이션:** +```java +public Like(Long userId, Long productId) { + if (userId == null) throw new IllegalArgumentException(); + if (productId == null) throw new IllegalArgumentException(); + // ... +} +``` + +**2중 보장:** +- 1차: 생성자 검증 +- 2차: DB NOT NULL 제약 + +--- + +### 3. 참조 무결성 (선택적) + +**외래키를 사용한다면:** +```sql +CONSTRAINT fk_likes_user FOREIGN KEY (user_id) REFERENCES users(id) +CONSTRAINT fk_likes_product FOREIGN KEY (product_id) REFERENCES products(id) +``` + +**외래키를 사용하지 않는다면:** +```java +// Service에서 확인 +public Like createLike(Long userId, Long productId) { + if (!userRepository.existsById(userId)) { + throw new UserNotFoundException(); + } + if (!productRepository.existsById(productId)) { + throw new ProductNotFoundException(); + } + // ... +} +``` + +**어느 쪽을 선택할까요?** + +--- + +## 8. 설계 의도 정리 + +### 왜 이렇게 설계했는가? + +#### 1. products.like_count 컬럼을 만들지 않은 이유 + +**선택: 실시간 COUNT 쿼리** + +```sql +-- 매번 계산 +SELECT COUNT(*) FROM likes WHERE product_id = ?; +``` + +**이유:** +- 정합성 100% 보장 +- 비동기 처리 복잡도 제거 +- 나중에 Redis로 전환 쉬움 + +**트레이드오프:** +- 장점: 정합성, 단순함 +- 단점: 조회 성능 (인덱스로 해결) + +--- + +#### 2. 복합 Unique 제약 사용 + +**선택: `UNIQUE (user_id, product_id)`** + +**이유:** +- 중복 방지를 DB가 보장 +- Race Condition 완벽 차단 +- 자동으로 복합 인덱스 생성 + +--- + +#### 3. 최소한의 인덱스 + +**선택:** +- `UNIQUE (user_id, product_id)` - 필수 +- `INDEX (product_id)` - 필수 +- 나머지는 제거 고려 + +**이유:** +- 인덱스는 쓰기 성능 저하 +- 필요할 때 추가하는 게 나음 +- 초기에는 단순하게 + +--- + +## 9. 확장 시나리오 + +### 시나리오 1: Redis 캐시 도입 + +**변경 전:** +```sql +SELECT COUNT(*) FROM likes WHERE product_id = ?; +``` + +**변경 후:** +``` +1. Redis에서 GET like_count:{product_id} +2. Cache Miss 시 DB 조회 +3. Redis에 SET like_count:{product_id} +``` + +**테이블 변경 없음!** + +--- + +### 시나리오 2: 좋아요 취소 시 "취소 사유" 추가 + +**변경:** +```sql +ALTER TABLE likes ADD COLUMN deleted_at TIMESTAMP NULL; +ALTER TABLE likes ADD COLUMN delete_reason VARCHAR(100) NULL; + +-- Unique 제약 수정 (soft delete 고려) +DROP INDEX uk_likes_user_product; +CREATE UNIQUE INDEX uk_likes_user_product +ON likes (user_id, product_id) +WHERE deleted_at IS NULL; -- PostgreSQL +``` + +--- + +### 시나리오 3: 샤딩 (Sharding) + +**user_id 기준 샤딩:** +``` +Shard 1: user_id % 4 = 0 +Shard 2: user_id % 4 = 1 +Shard 3: user_id % 4 = 2 +Shard 4: user_id % 4 = 3 +``` + +**product_id 기준 샤딩:** +``` +Shard 1: product_id % 4 = 0 +... +``` + +**고려사항:** +- 어떤 기준으로 샤딩할지 (user_id vs product_id) +- 현재 설계는 양쪽 다 가능 + +--- + +## 10. 설계 리스크 및 트레이드오프 + +### ⚠️ 리스크 1: COUNT 쿼리 성능 + +**상황:** +좋아요 테이블이 1억 건 이상으로 커지면? + +**영향:** +```sql +SELECT COUNT(*) FROM likes WHERE product_id = ?; +``` +- 인덱스 스캔이지만 느려질 수 있음 + +**대응:** +1. Redis 캐시 도입 (우선) +2. products.like_count 컬럼 추가 (차선) +3. 파티셔닝 (최후) + +--- + +### ⚠️ 리스크 2: 외래키 미사용 시 고아 데이터 + +**상황:** +외래키를 사용하지 않으면, 사용자/상품 삭제 시 좋아요가 남음 + +**영향:** +```sql +-- 상품 삭제 +DELETE FROM products WHERE id = 100; + +-- 좋아요는 남아있음 +SELECT * FROM likes WHERE product_id = 100; -- 여전히 있음 +``` + +**대응:** +1. 애플리케이션에서 명시적 삭제 +```java +productService.delete(productId) { + likeRepository.deleteByProductId(productId); // 먼저 삭제 + productRepository.deleteById(productId); +} +``` +2. 배치로 주기적 정리 +3. 외래키 사용 + +--- + +### ✅ 트레이드오프 정리 + +| 선택 | 얻은 것 | 잃은 것 | 판단 | +|------|---------|---------|------| +| like_count 컬럼 없음 | 정합성 100% | 조회 성능 | 인덱스+Redis로 해결 | +| 외래키 미사용 | 쓰기 성능, 유연성 | 고아 데이터 가능 | 애플리케이션에서 관리 | +| 최소 인덱스 | 쓰기 성능 | 조회 성능 저하 가능 | 필요 시 추가 | +| BIGINT 사용 | 확장성 | 저장 공간 (미미) | 나중에 바꾸기 어려움 | + +--- + +## 11. 최종 DDL + +### 추천 DDL (외래키 없음) + +```sql +-- users 테이블 +CREATE TABLE users ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(50) NOT NULL UNIQUE, + email VARCHAR(100) NOT NULL UNIQUE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_username (username), + INDEX idx_email (email) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- products 테이블 +CREATE TABLE products ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(200) NOT NULL, + description TEXT, + price INT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_created_at (created_at DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- likes 테이블 +CREATE TABLE likes ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + product_id BIGINT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + -- 중복 방지 (가장 중요!) + CONSTRAINT uk_likes_user_product UNIQUE (user_id, product_id), + + -- 필수 인덱스 + INDEX idx_product_id (product_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +``` + +### 선택사항 1: 외래키 추가 + +```sql +ALTER TABLE likes +ADD CONSTRAINT fk_likes_user + FOREIGN KEY (user_id) + REFERENCES users(id) + ON DELETE CASCADE; + +ALTER TABLE likes +ADD CONSTRAINT fk_likes_product + FOREIGN KEY (product_id) + REFERENCES products(id) + ON DELETE CASCADE; +``` + +### 선택사항 2: 추가 인덱스 + +```sql +-- 사용자별 조회 최적화 (복합 인덱스로 대체 가능하므로 선택적) +ALTER TABLE likes ADD INDEX idx_user_id (user_id); + +-- 최근 좋아요 조회 (현재 불필요) +-- ALTER TABLE likes ADD INDEX idx_created_at (created_at DESC); +``` + +--- + +## 다음 단계 + +이제 모든 설계 문서가 완료되었습니다: +- ✅ 01-requirements.md (요구사항 분석) +- ✅ 02-sequence-diagrams.md (시퀀스 다이어그램) +- ✅ 03-class-diagram.md (클래스 다이어그램) +- ✅ 04-erd.md (ERD) + +다음 할 일: +1. 문서 검토 및 피드백 +2. 구현 시작 +3. 테스트 코드 작성 \ No newline at end of file diff --git "a/docs/design/\354\243\274\353\254\270\354\203\235\354\204\261/01-requirements.md" "b/docs/design/\354\243\274\353\254\270\354\203\235\354\204\261/01-requirements.md" new file mode 100644 index 000000000..d52c9d146 --- /dev/null +++ "b/docs/design/\354\243\274\353\254\270\354\203\235\354\204\261/01-requirements.md" @@ -0,0 +1,69 @@ +주문 생성 시스템 - 요구사항 분석 및 설계 │ +│ │ +│ Context │ +│ │ +│ 현재 시스템에는 Example, Member 도메인만 존재하며, 커머스의 핵심인 상품/재고/주문 도메인이 없다. │ +│ "여러 상품을 한 번에 주문할 때, 재고와 주문 상태 사이의 불일치가 발생하지 않도록" All-or-Nothing 정책으로 주문 생성 기능을 구현해야 │ +│ 한다. │ +│ │ +│ --- │ +│ 1️⃣문제 상황 재해석 │ +│ │ +│ 사용자 관점: 여러 상품을 한 번에 주문했는데 일부만 처리되면 혼란. 되거나/안 되거나 명확한 결과를 기대한다. │ +│ │ +│ 비즈니스 관점: 재고-주문 불일치는 클레임 직결. 재고 확인과 주문 생성이 원자적으로 묶여야 하며, 향후 부분취소 확장도 고려해야 한다. │ +│ │ +│ 시스템 관점: Order + OrderItem 생성 + 재고 차감이 단일 트랜잭션으로 처리되어야 한다. FK 없이 ID 참조로 느슨한 결합 유지, 스냅샷으로 시점 │ +│ 정합성 보장. │ +│ │ +│ --- │ +│ 2️⃣합의된 설계 결정 │ +│ ┌─────────────────────┬─────────────────────────────────────────────────────────────────────────────┐ │ +│ │ 항목 │ 결정 │ │ +│ ├─────────────────────┼─────────────────────────────────────────────────────────────────────────────┤ │ +│ │ Product/Stock/Brand │ 주문과 함께 새로 생성 │ │ +│ ├─────────────────────┼─────────────────────────────────────────────────────────────────────────────┤ │ +│ │ Product 구조 │ Product + Brand 별도 엔티티 분리 │ │ +│ ├─────────────────────┼─────────────────────────────────────────────────────────────────────────────┤ │ +│ │ Stock 구조 │ 별도 Stock 엔티티 (productId, quantity) │ │ +│ ├─────────────────────┼─────────────────────────────────────────────────────────────────────────────┤ │ +│ │ OrderStatus │ Enum 미리 정의 (CREATED, CONFIRMED, CANCELLED), 전이 로직은 CREATED까지만 │ │ +│ ├─────────────────────┼─────────────────────────────────────────────────────────────────────────────┤ │ +│ │ OrderItemStatus │ Enum 미리 정의 (ORDERED, CANCELLED) — 부분취소 확장성 │ │ +│ ├─────────────────────┼─────────────────────────────────────────────────────────────────────────────┤ │ +│ │ 동시성 제어 │ 비관적 락 (SELECT FOR UPDATE on Stock) │ │ +│ ├─────────────────────┼─────────────────────────────────────────────────────────────────────────────┤ │ +│ │ 인증 │ 기존 X-Loopers-LoginId 헤더 인증 │ │ +│ ├─────────────────────┼─────────────────────────────────────────────────────────────────────────────┤ │ +│ │ FK 제약조건 │ 없음, ID 참조만 │ │ +│ ├─────────────────────┼─────────────────────────────────────────────────────────────────────────────┤ │ +│ │ Value Objects │ Money (가격/금액), Quantity (수량), ProductSnapshot (주문 시점 상품 스냅샷) │ │ +│ ├─────────────────────┼─────────────────────────────────────────────────────────────────────────────┤ │ +│ │ 도메인 서비스 │ StockDeductionService — 재고 차감 검증 로직 분리 │ │ +│ └─────────────────────┴─────────────────────────────────────────────────────────────────────────────┘ │ +│ --- │ +│ 3️⃣개념 모델 │ +│ │ +│ 액터 │ +│ - 사용자 (회원): 주문을 생성하는 주체 │ +│ - 관리자 (미구현): 상품/브랜드/재고를 관리하는 주체 (테스트 데이터로 대체) │ +│ │ +│ 핵심 도메인 │ +│ - Order (주문): 주문 생성, 상태 관리 │ +│ - OrderItem (주문 항목): 개별 상품 주문 정보 + ProductSnapshot VO │ +│ - Stock (재고): 재고 수량 관리, 동시성 제어 대상 │ +│ │ +│ 보조 도메인 │ +│ - Product (상품): 상품 정보 제공 │ +│ - Brand (브랜드): 브랜드 정보 제공 │ +│ - Member (회원): 인증, 주문자 식별 (기존 구현) │ +│ │ +│ Value Objects │ +│ - Money: 가격/금액을 감싸는 VO. value >= 0 불변식 보장. Product.price, Order.totalAmount, ProductSnapshot.price에 사용 │ +│ - Quantity: 수량을 감싸는 VO. value > 0 불변식 보장. OrderItem.quantity에 사용 │ +│ - ProductSnapshot: 주문 시점 상품 정보(@Embeddable). productName, price(Money), brandName을 묶어 OrderItem에 내장 │ +│ │ +│ 도메인 서비스 │ +│ - StockDeductionService: 여러 Stock 엔티티에 걸친 All-or-Nothing 재고 차감을 담당. 단일 엔티티에 속하지 않는 크로스 엔티티 비즈니스 │ +│ 로직. │ +│ \ No newline at end of file diff --git "a/docs/design/\354\243\274\353\254\270\354\203\235\354\204\261/02-sequence-diagrams.md" "b/docs/design/\354\243\274\353\254\270\354\203\235\354\204\261/02-sequence-diagrams.md" new file mode 100644 index 000000000..8a02999b3 --- /dev/null +++ "b/docs/design/\354\243\274\353\254\270\354\203\235\354\204\261/02-sequence-diagrams.md" @@ -0,0 +1,86 @@ +️⃣시퀀스 다이어그램 │ +│ │ +│ 왜 필요한가 │ +│ │ +│ 주문 생성은 Member, Product, Brand, Stock, Order, OrderItem 6개 도메인을 횡단한다. 호출 순서, 트랜잭션 경계, All-or-Nothing 실패 시점을 │ +│ 검증해야 한다. │ +│ │ +│ 검증 포인트 │ +│ │ +│ - 트랜잭션 경계가 어디서 시작하고 끝나는지 │ +│ - StockDeductionService의 책임 범위 │ +│ - 비관적 락 획득 순서 (데드락 방지) │ +│ - 재고 부족 시 롤백 시점 │ +│ │ +│ sequenceDiagram │ +│ actor User │ +│ participant Controller as OrderV1Controller │ +│ participant Facade as OrderFacade │ +│ participant MemberSvc as MemberService │ +│ participant ProductSvc as ProductService │ +│ participant OrderSvc as OrderService │ +│ participant StockDeduct as StockDeductionService │ +│ participant StockRepo as StockRepository │ +│ participant DB as Database │ +│ │ +│ User->>Controller: POST /api/v1/orders
[Header: X-Loopers-LoginId/LoginPw]
[Body: items[{productId, quantity}]] │ +│ Controller->>Facade: createOrder(loginId, password, request) │ +│ │ +│ Note over Facade: 트랜잭션 밖: 인증 + 조회 │ +│ Facade->>MemberSvc: authenticate(loginId, password) │ +│ MemberSvc-->>Facade: MemberModel (memberId) │ +│ │ +│ Facade->>ProductSvc: getProducts(productIds) │ +│ ProductSvc-->>Facade: List │ +│ Facade->>ProductSvc: getBrands(brandIds) │ +│ ProductSvc-->>Facade: List │ +│ │ +│ alt 존재하지 않는 상품/브랜드 │ +│ Facade-->>Controller: CoreException(NOT_FOUND) │ +│ Controller-->>User: 404 Not Found │ +│ end │ +│ │ +│ Note over Facade,DB: ── 트랜잭션 시작 (OrderService.createOrder) ── │ +│ │ +│ Facade->>OrderSvc: createOrder(memberId, products, brandMap, items) │ +│ │ +│ OrderSvc->>StockDeduct: deductAll(items) │ +│ Note over StockDeduct: productId 오름차순 정렬 → 데드락 방지 │ +│ loop 각 상품 (productId 오름차순) │ +│ StockDeduct->>StockRepo: findByProductIdWithLock(productId) │ +│ StockRepo->>DB: SELECT ... FOR UPDATE │ +│ DB-->>StockRepo: StockModel (locked) │ +│ StockRepo-->>StockDeduct: StockModel │ +│ │ +│ alt 재고 부족 │ +│ Note over StockDeduct,DB: 예외 → 트랜잭션 롤백 (All-or-Nothing) │ +│ StockDeduct-->>OrderSvc: CoreException(BAD_REQUEST) │ +│ OrderSvc-->>Facade: 예외 전파 │ +│ Facade-->>Controller: 예외 전파 │ +│ Controller-->>User: 400 Bad Request │ +│ end │ +│ │ +│ StockDeduct->>StockDeduct: stock.deduct(Quantity) │ +│ end │ +│ StockDeduct-->>OrderSvc: 차감 완료 │ +│ │ +│ Note over OrderSvc: 주문 생성 │ +│ OrderSvc->>OrderSvc: new OrderModel(memberId, Money(totalAmount), CREATED) │ +│ OrderSvc->>DB: INSERT orders │ +│ │ +│ loop 각 주문 항목 │ +│ OrderSvc->>OrderSvc: new OrderItemModel(orderId, productId,
ProductSnapshot, Quantity, ORDERED) │ +│ end │ +│ OrderSvc->>DB: INSERT order_item × N │ +│ │ +│ Note over Facade,DB: ── 트랜잭션 커밋 ── │ +│ │ +│ OrderSvc-->>Facade: OrderModel + OrderItems │ +│ Facade-->>Controller: OrderInfo │ +│ Controller-->>User: 201 Created + OrderResponse │ +│ │ +│ 읽는 법 │ +│ │ +│ - Facade는 조율자: 인증/조회는 트랜잭션 밖에서 처리하여 핵심 쓰기 트랜잭션 범위를 최소화. │ +│ - StockDeductionService가 재고 책임: 락 획득 순서, 재고 검증, 차감을 모두 담당. OrderService는 이 결과를 신뢰하고 주문만 생성. │ +│ - 실패 시점이 명확: 재고 부족이면 StockDeductionService에서 즉시 예외 → 전체 롤백. \ No newline at end of file diff --git "a/docs/design/\354\243\274\353\254\270\354\203\235\354\204\261/03-class-diagram.md" "b/docs/design/\354\243\274\353\254\270\354\203\235\354\204\261/03-class-diagram.md" new file mode 100644 index 000000000..a4c00279e --- /dev/null +++ "b/docs/design/\354\243\274\353\254\270\354\203\235\354\204\261/03-class-diagram.md" @@ -0,0 +1,150 @@ +5️⃣클래스 다이어그램 │ +│ │ +│ 왜 필요한가 │ +│ │ +│ 5개 엔티티 + 3개 VO + 도메인 서비스의 책임 분배, 의존 방향을 검증해야 한다. │ +│ │ +│ 검증 포인트 │ +│ │ +│ - VO가 어떤 엔티티에서 사용되는지 │ +│ - StockDeductionService의 위치와 의존 방향 │ +│ - OrderService와 StockDeductionService의 책임 경계 │ +│ │ +│ classDiagram │ +│ direction TB │ +│ │ +│ class BaseEntity { │ +│ <> │ +│ #Long id │ +│ #ZonedDateTime createdAt │ +│ #ZonedDateTime updatedAt │ +│ #ZonedDateTime deletedAt │ +│ +delete() │ +│ +restore() │ +│ } │ +│ │ +│ class Money { │ +│ <> │ +│ -Long value │ +│ +Money(Long value) │ +│ +getValue() Long │ +│ } │ +│ note for Money "불변식: value >= 0\nProduct.price, Order.totalAmount,\nProductSnapshot.price에 사용" │ +│ │ +│ class Quantity { │ +│ <> │ +│ -Long value │ +│ +Quantity(Long value) │ +│ +getValue() Long │ +│ } │ +│ note for Quantity "불변식: value > 0\nOrderItem.quantity에 사용" │ +│ │ +│ class ProductSnapshot { │ +│ <> │ +│ -String productName │ +│ -Money price │ +│ -String brandName │ +│ +ProductSnapshot(productName, price, brandName) │ +│ } │ +│ note for ProductSnapshot "주문 시점 상품 정보 스냅샷\nOrderItem에 @Embedded로 내장" │ +│ │ +│ class BrandModel { │ +│ -String name │ +│ +BrandModel(name) │ +│ } │ +│ │ +│ class ProductModel { │ +│ -Long brandId │ +│ -String name │ +│ -Money price │ +│ -String description │ +│ +ProductModel(brandId, name, price, description) │ +│ } │ +│ │ +│ class StockModel { │ +│ -Long productId │ +│ -Long quantity │ +│ +StockModel(productId, quantity) │ +│ +deduct(Quantity amount) void │ +│ +hasEnoughStock(Quantity amount) boolean │ +│ } │ +│ │ +│ class OrderModel { │ +│ -Long memberId │ +│ -OrderStatus status │ +│ -Money totalAmount │ +│ +OrderModel(memberId, totalAmount, status) │ +│ } │ +│ │ +│ class OrderItemModel { │ +│ -Long orderId │ +│ -Long productId │ +│ -OrderItemStatus status │ +│ -ProductSnapshot snapshot │ +│ -Quantity quantity │ +│ +OrderItemModel(orderId, productId, status, snapshot, quantity) │ +│ } │ +│ │ +│ class OrderStatus { │ +│ <> │ +│ CREATED │ +│ CONFIRMED │ +│ CANCELLED │ +│ } │ +│ │ +│ class OrderItemStatus { │ +│ <> │ +│ ORDERED │ +│ CANCELLED │ +│ } │ +│ │ +│ BaseEntity <|-- BrandModel │ +│ BaseEntity <|-- ProductModel │ +│ BaseEntity <|-- StockModel │ +│ BaseEntity <|-- OrderModel │ +│ BaseEntity <|-- OrderItemModel │ +│ │ +│ ProductModel --> Money : price │ +│ OrderModel --> Money : totalAmount │ +│ OrderModel --> OrderStatus │ +│ OrderItemModel --> ProductSnapshot : snapshot │ +│ OrderItemModel --> Quantity : quantity │ +│ OrderItemModel --> OrderItemStatus │ +│ ProductSnapshot --> Money : price │ +│ │ +│ class StockDeductionService { │ +│ <<도메인 서비스>> │ +│ +deductAll(List~OrderItemRequest~ items) void │ +│ } │ +│ note for StockDeductionService "크로스 엔티티 비즈니스 로직\nproductId 오름차순 락 획득\nAll-or-Nothing 재고 차감" │ +│ │ +│ class OrderFacade { │ +│ +createOrder(loginId, password, request) OrderInfo │ +│ } │ +│ class OrderService { │ +│ +createOrder(memberId, products, brandMap, items) OrderModel │ +│ } │ +│ class ProductService { │ +│ +getProducts(productIds) List~ProductModel~ │ +│ +getBrands(brandIds) List~BrandModel~ │ +│ } │ +│ │ +│ OrderFacade --> MemberService │ +│ OrderFacade --> ProductService │ +│ OrderFacade --> OrderService │ +│ OrderService --> StockDeductionService │ +│ OrderService ..> OrderRepository │ +│ OrderService ..> OrderItemRepository │ +│ StockDeductionService ..> StockRepository │ +│ ProductService ..> ProductRepository │ +│ ProductService ..> BrandRepository │ +│ │ +│ 읽는 법 │ +│ │ +│ - VO 적용 범위가 명확: Money는 가격/금액이 나오는 3곳, Quantity는 주문 수량, ProductSnapshot은 OrderItem 내 스냅샷. 각각 명확한 불변식을 │ +│ 가진다. │ +│ - StockDeductionService: OrderService에서 분리된 도메인 서비스. "여러 Stock에 걸친 원자적 차감"이라는 단일 엔티티에 속하지 않는 책임을 │ +│ 담당. │ +│ - StockModel.deduct(): 실제 차감 로직은 엔티티 내부에 유지. 도메인 서비스는 "어떤 순서로, 어떤 조건에서" 차감할지를 조율. │ +│ - StockModel.quantity는 Long: 재고는 0이 될 수 있으므로 Quantity VO(>0)를 쓰지 않고 Long으로 유지. deduct()의 파라미터만 Quantity VO. │ +│ \ No newline at end of file diff --git "a/docs/design/\354\243\274\353\254\270\354\203\235\354\204\261/04-erd.md" "b/docs/design/\354\243\274\353\254\270\354\203\235\354\204\261/04-erd.md" new file mode 100644 index 000000000..3dd152e52 --- /dev/null +++ "b/docs/design/\354\243\274\353\254\270\354\203\235\354\204\261/04-erd.md" @@ -0,0 +1,179 @@ +6️⃣ERD │ +│ │ +│ 왜 필요한가 │ +│ │ +│ FK 없이 ID 참조만 사용하므로, 논리적 관계를 문서로 명확히 해야 한다. VO는 DB 컬럼으로 풀어져 저장된다. │ +│ │ +│ 검증 포인트 │ +│ │ +│ - stock.product_id에 UNIQUE 제약 (1:1) │ +│ - order_item의 스냅샷 컬럼이 실제로 어떻게 매핑되는지 │ +│ - orders 테이블명 (MySQL 예약어 회피) │ +│ - VO(Money, Quantity)는 BIGINT 컬럼으로, ProductSnapshot은 개별 컬럼으로 매핑 │ +│ │ +│ erDiagram │ +│ brand { │ +│ BIGINT id PK "AUTO_INCREMENT" │ +│ VARCHAR name "NOT NULL" │ +│ DATETIME created_at "NOT NULL" │ +│ DATETIME updated_at "NOT NULL" │ +│ DATETIME deleted_at "NULL" │ +│ } │ +│ │ +│ product { │ +│ BIGINT id PK "AUTO_INCREMENT" │ +│ BIGINT brand_id "NOT NULL" │ +│ VARCHAR name "NOT NULL" │ +│ BIGINT price "NOT NULL (Money VO)" │ +│ VARCHAR description "NULL" │ +│ DATETIME created_at "NOT NULL" │ +│ DATETIME updated_at "NOT NULL" │ +│ DATETIME deleted_at "NULL" │ +│ } │ +│ │ +│ stock { │ +│ BIGINT id PK "AUTO_INCREMENT" │ +│ BIGINT product_id "NOT NULL, UNIQUE" │ +│ BIGINT quantity "NOT NULL, DEFAULT 0" │ +│ DATETIME created_at "NOT NULL" │ +│ DATETIME updated_at "NOT NULL" │ +│ DATETIME deleted_at "NULL" │ +│ } │ +│ │ +│ orders { │ +│ BIGINT id PK "AUTO_INCREMENT" │ +│ BIGINT member_id "NOT NULL" │ +│ VARCHAR status "NOT NULL" │ +│ BIGINT total_amount "NOT NULL (Money VO)" │ +│ DATETIME created_at "NOT NULL" │ +│ DATETIME updated_at "NOT NULL" │ +│ DATETIME deleted_at "NULL" │ +│ } │ +│ │ +│ order_item { │ +│ BIGINT id PK "AUTO_INCREMENT" │ +│ BIGINT order_id "NOT NULL" │ +│ BIGINT product_id "NOT NULL" │ +│ VARCHAR status "NOT NULL" │ +│ VARCHAR product_name "NOT NULL (ProductSnapshot)" │ +│ BIGINT product_price "NOT NULL (ProductSnapshot.Money)" │ +│ VARCHAR brand_name "NOT NULL (ProductSnapshot)" │ +│ BIGINT quantity "NOT NULL (Quantity VO)" │ +│ DATETIME created_at "NOT NULL" │ +│ DATETIME updated_at "NOT NULL" │ +│ DATETIME deleted_at "NULL" │ +│ } │ +│ │ +│ brand ||--o{ product : "has" │ +│ product ||--|| stock : "has" │ +│ member ||--o{ orders : "places" │ +│ orders ||--o{ order_item : "contains" │ +│ product ||--o{ order_item : "referenced by" │ +│ │ +│ 읽는 법 │ +│ │ +│ - VO → 컬럼 매핑: Money VO는 BIGINT 단일 컬럼, Quantity VO도 BIGINT 단일 컬럼, ProductSnapshot은 3개 컬럼(product_name, product_price, │ +│ brand_name)으로 풀어진다. │ +│ - stock ↔ product 1:1: 하나의 상품에 하나의 재고 레코드. UNIQUE 제약으로 보장. │ +│ - 논리적 참조만 존재: FK 없이 애플리케이션 레벨에서 ID 참조. +️⃣잠재 리스크 │ +│ │ +│ 트랜잭션 범위 │ +│ │ +│ Stock 락 + Order/OrderItem 저장이 단일 트랜잭션이므로, 주문 항목 수가 많으면 락 보유 시간이 길어진다. │ +│ - (A) 현재 유지 → 일반적 커머스 수준에서 충분. 단순하고 All-or-Nothing 보장 명확. │ +│ - (B) 2-phase (예약→확정) → 트랜잭션 분리 가능하나 복잡도 증가. 현 시점에서는 오버엔지니어링. │ +│ │ +│ 비관적 락 병목 │ +│ │ +│ 인기 상품에 동시 주문이 몰리면 Stock row 락 대기 발생. │ +│ - (A) 비관적 락 유지 → 정합성 확실, 트래픽이 극단적이지 않으면 충분. │ +│ - (B) Redis 분산 락 → 수평 확장 시 고려. 단일 DB에서는 불필요. │ +│ │ +│ 데드락 방지 │ +│ │ +│ 설계에 이미 반영: productId 오름차순으로 정렬 후 개별 락 획득 (StockDeductionService 책임). 애플리케이션 레벨에서 순서를 보장하는 것이 │ +│ DB 엔진 의존성보다 안전. │ +│ │ +│ VO JPA 매핑 복잡도 │ +│ │ +│ @Embeddable VO 사용 시 JPA 매핑이 추가된다. 특히 ProductSnapshot 내부의 Money VO는 중첩 @Embeddable이 된다. │ +│ - @AttributeOverrides로 컬럼명을 명시적으로 지정하여 해결. │ +│ - 복잡하다면 ProductSnapshot.price만 Long으로 직접 저장하는 것도 선택지. │ +│ │ +│ 스냅샷 시점 차이 │ +│ │ +│ Facade에서 Product/Brand를 조회한 시점과 OrderService에서 저장하는 시점의 미세한 차이가 있으나, 동일 HTTP 요청 내이므로 비즈니스적으로 │ +│ 수용 가능. │ +│ │ +│ --- │ +│ 생성할 파일 목록 │ +│ │ +│ domain 레이어 (com.loopers.domain) │ +│ │ +│ 공통 VO │ +│ - domain/vo/Money.java — 가격/금액 VO (@Embeddable, value >= 0) │ +│ - domain/vo/Quantity.java — 수량 VO (@Embeddable, value > 0) │ +│ │ +│ Brand │ +│ - domain/brand/BrandModel.java │ +│ - domain/brand/BrandRepository.java │ +│ │ +│ Product │ +│ - domain/product/ProductModel.java — Money VO 사용 │ +│ - domain/product/ProductRepository.java │ +│ - domain/product/ProductService.java │ +│ │ +│ Stock │ +│ - domain/stock/StockModel.java — deduct(Quantity), hasEnoughStock(Quantity) 메서드 포함 │ +│ - domain/stock/StockRepository.java │ +│ - domain/stock/StockDeductionService.java — 도메인 서비스: All-or-Nothing 재고 차감 │ +│ │ +│ Order │ +│ - domain/order/OrderModel.java — Money VO 사용 │ +│ - domain/order/OrderStatus.java │ +│ - domain/order/OrderItemModel.java — ProductSnapshot, Quantity VO 사용 │ +│ - domain/order/OrderItemStatus.java │ +│ - domain/order/ProductSnapshot.java — @Embeddable VO (productName, Money price, brandName) │ +│ - domain/order/OrderRepository.java │ +│ - domain/order/OrderItemRepository.java │ +│ - domain/order/OrderService.java │ +│ │ +│ infrastructure 레이어 (com.loopers.infrastructure) │ +│ │ +│ - infrastructure/brand/BrandJpaRepository.java │ +│ - infrastructure/brand/BrandRepositoryImpl.java │ +│ - infrastructure/product/ProductJpaRepository.java │ +│ - infrastructure/product/ProductRepositoryImpl.java │ +│ - infrastructure/stock/StockJpaRepository.java — @Lock(PESSIMISTIC_WRITE) 포함 │ +│ - infrastructure/stock/StockRepositoryImpl.java │ +│ - infrastructure/order/OrderJpaRepository.java │ +│ - infrastructure/order/OrderRepositoryImpl.java │ +│ - infrastructure/order/OrderItemJpaRepository.java │ +│ - infrastructure/order/OrderItemRepositoryImpl.java │ +│ │ +│ application 레이어 (com.loopers.application) │ +│ │ +│ - application/order/OrderFacade.java │ +│ - application/order/OrderInfo.java │ +│ │ +│ interfaces 레이어 (com.loopers.interfaces.api) │ +│ │ +│ - interfaces/api/order/OrderV1Controller.java │ +│ - interfaces/api/order/OrderV1ApiSpec.java │ +│ - interfaces/api/order/OrderV1Dto.java │ +│ │ +│ 참조할 기존 패턴 파일 │ +│ │ +│ - domain/example/ExampleModel.java — 엔티티 패턴 (BaseEntity 상속, guard()) │ +│ - domain/example/ExampleService.java — 서비스 패턴 (@Component + @Transactional) │ +│ - application/example/ExampleFacade.java — Facade 패턴 │ +│ - interfaces/api/member/MemberV1Controller.java — 헤더 인증 + ApiResponse 패턴 │ +│ - modules/jpa/src/main/java/com/loopers/domain/BaseEntity.java — BaseEntity │ +│ │ +│ 검증 방법 │ +│ │ +│ 1. 단위 테스트: Money/Quantity VO 불변식, StockModel.deduct(), ProductSnapshot 생성, OrderModel 생성 │ +│ 2. 통합 테스트: OrderService.createOrder() — 성공/재고부족 롤백, StockDeductionService 동시성 시나리오 │ +│ 3. E2E 테스트: POST /api/v1/orders 호출 → 주문 생성 확인, 재고 차감 확인 │ +│ 4. HTTP 파일: http/commerce-api/order-v1.http로 수동 확인 \ No newline at end of file From 0438e54d27b6c2e56ce403ee50900f158968c6bc Mon Sep 17 00:00:00 2001 From: katiekim17 Date: Fri, 13 Feb 2026 09:16:50 +0900 Subject: [PATCH 10/39] =?UTF-8?q?feat:=20=EB=AC=B8=EC=84=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../01-requirements.md" | 536 ++++++++ .../02-sequence-diagrams.md" | 461 +++++++ .../03-class-diagram.md" | 1179 +++++++++++++++++ .../04-erd.md" | 792 +++++++++++ .../01-requirements.md" | 0 .../02-sequence-diagrams.md" | 0 .../03-class-diagram.md" | 0 .../04-erd.md" | 0 .../01-requirements.md" | 0 .../02-sequence-diagrams.md" | 0 .../03-class-diagram.md" | 0 .../\354\243\274\353\254\270/04-erd.md" | 0 .../04-erd.md" | 15 - .../01-requirements.md" | 0 .../02-sequence-diagrams.md" | 0 .../03-class-diagram.md" | 0 .../\354\243\274\353\254\270/04-erd.md" | 0 17 files changed, 2968 insertions(+), 15 deletions(-) create mode 100644 "docs/design/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/01-requirements.md" create mode 100644 "docs/design/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/02-sequence-diagrams.md" create mode 100644 "docs/design/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/03-class-diagram.md" create mode 100644 "docs/design/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/04-erd.md" rename "docs/design/\354\226\264\353\223\234\353\257\274/01-requirements.md" => "docs/design/\354\226\264\353\223\234\353\257\274/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/01-requirements.md" (100%) rename "docs/design/\354\226\264\353\223\234\353\257\274/02-sequence-diagrams.md" => "docs/design/\354\226\264\353\223\234\353\257\274/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/02-sequence-diagrams.md" (100%) rename "docs/design/\354\226\264\353\223\234\353\257\274/03-class-diagram.md" => "docs/design/\354\226\264\353\223\234\353\257\274/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/03-class-diagram.md" (100%) rename "docs/design/\354\226\264\353\223\234\353\257\274/04-erd.md" => "docs/design/\354\226\264\353\223\234\353\257\274/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/04-erd.md" (100%) create mode 100644 "docs/design/\354\226\264\353\223\234\353\257\274/\354\243\274\353\254\270/01-requirements.md" create mode 100644 "docs/design/\354\226\264\353\223\234\353\257\274/\354\243\274\353\254\270/02-sequence-diagrams.md" create mode 100644 "docs/design/\354\226\264\353\223\234\353\257\274/\354\243\274\353\254\270/03-class-diagram.md" create mode 100644 "docs/design/\354\226\264\353\223\234\353\257\274/\354\243\274\353\254\270/04-erd.md" create mode 100644 "docs/design/\354\243\274\353\254\270/01-requirements.md" create mode 100644 "docs/design/\354\243\274\353\254\270/02-sequence-diagrams.md" create mode 100644 "docs/design/\354\243\274\353\254\270/03-class-diagram.md" create mode 100644 "docs/design/\354\243\274\353\254\270/04-erd.md" diff --git "a/docs/design/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/01-requirements.md" "b/docs/design/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/01-requirements.md" new file mode 100644 index 000000000..752faa978 --- /dev/null +++ "b/docs/design/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/01-requirements.md" @@ -0,0 +1,536 @@ +# 감성 이커머스 시스템 요구사항 명세서 (v1) + +## 1️⃣ 도메인 구조 + +### v1 핵심 도메인 +| 도메인 | 책임 | 주요 엔티티 | +|--------|------|------------| +| **브랜드** | 브랜드 정보 관리 | Brand | +| **상품** | 상품 카탈로그 관리 | Product, ProductOption, ProductImage | +| **좋아요** | 사용자 관심 상품 관리 | Like | + +### 도메인 간 관계 +``` +Brand (브랜드) + └─→ Product (상품) : 1:N [brand_id로 참조] + +Product (상품) + ├─→ ProductOption (상품 옵션) : 1:N [product_id로 참조] + ├─→ ProductImage (상품 이미지) : 1:N [product_id로 참조] + └─→ Like (좋아요) : 1:N [product_id로 참조] + +User (사용자) - v2에서 추가 예정 + └─→ Like (좋아요) : 1:N [user_id로 참조] +``` + +**핵심 개념:** +- **Brand**: 상품을 제공하는 브랜드 +- **Product**: 브랜드가 판매하는 상품 (기본 정보, brand_id 보유) +- **ProductOption**: 상품의 판매 단위 (사이즈, 색상 등 + 가격 + 재고, product_id 보유) +- **ProductImage**: 상품의 이미지들 (여러 장 가능, product_id 보유) +- **Like**: 사용자의 상품 좋아요 (v1에서는 인증 없이 임시 식별자 사용, product_id 보유) + +**설계 의도:** +- 상품과 옵션을 분리하여 **옵션별로 가격과 재고를 독립적으로 관리** +- 같은 상품이라도 옵션(사이즈, 색상 등)에 따라 가격과 재고가 다를 수 있음 +- 좋아요는 **상품 단위**로 관리 (옵션 단위 아님) +- 향후 장바구니, 주문 기능 추가 시 **옵션이 판매 단위**가 됨 +- **FK 제약 없음**: 애플리케이션 레벨에서 참조 무결성 관리 + +--- + +## 2️⃣ 유저 시나리오 기반 기능 정의 + +### US-1. 브랜드 탐색 +**시나리오:** +사용자는 여러 브랜드를 둘러보며 관심 있는 브랜드를 찾는다. + +**주요 흐름:** +1. 브랜드 목록 페이지 접속 +2. 브랜드 카드(썸네일, 이름) 확인 +3. 특정 브랜드 클릭 → 브랜드 상세 정보 조회 + +**제공 기능:** +- 브랜드 정보 조회 + +--- + +### US-2. 상품 탐색 +**시나리오:** +사용자는 브랜드의 상품 목록을 둘러보고, 정렬 기능을 활용하여 원하는 상품을 찾는다. 관심 있는 상품의 상세 정보를 확인한다. + +**주요 흐름:** +1. 전체 상품 목록 또는 특정 브랜드의 상품 목록 조회 +2. 정렬 옵션 선택 (최신순, 가격순, 인기순) +3. 상품 카드(이미지, 이름, 최저가, 좋아요 수) 확인 +4. 특정 상품 클릭 → 상품 상세 정보 조회 +5. 상품 옵션별 가격, 재고 확인 + +**제공 기능:** +- 상품 목록 조회 (브랜드 필터링, 정렬, 페이지네이션) +- 상품 상세 정보 조회 + +--- + +### US-3. 상품 좋아요 +**시나리오:** +사용자는 마음에 드는 상품에 좋아요를 누르고, 내가 좋아요한 상품 목록을 확인한다. + +**주요 흐름:** +1. 상품 목록 또는 상세 페이지에서 좋아요 버튼 클릭 +2. 좋아요 등록/취소 토글 +3. 좋아요 수 실시간 업데이트 (비동기) +4. 내 좋아요 목록 페이지에서 좋아요한 상품들 확인 + +**제공 기능:** +- 상품 좋아요 등록 +- 상품 좋아요 취소 +- 내 좋아요 목록 조회 + +**참고:** +- 좋아요 기능의 상세 요구사항(동기/비동기 처리, 카운트 업데이트 정책 등)은 별도 문서 참조 + +--- + +## 3️⃣ 기능별 상세 요구사항 + +### 🔹 브랜드 (Brand) + +#### FR-B-01. 브랜드 정보 조회 +**API:** `GET /api/v1/brands/{brandId}` +**인증:** 불필요 + +**Path Parameter:** +- `brandId` (Long): 브랜드 ID + +**반환 정보:** +```json +{ + "brandId": 1, + "name": "브랜드명", + "description": "브랜드 설명", + "logoImageUrl": "https://example.com/brand-logo.png", + "createdAt": "2025-01-01T00:00:00" +} +``` + +**에러 처리:** +- 존재하지 않는 브랜드 ID → 404 Not Found + +--- + +### 🔹 상품 (Product) + +#### FR-P-01. 상품 목록 조회 +**API:** `GET /api/v1/products` +**인증:** 불필요 (로그인 시 좋아요 여부 추가 제공) + +**Query Parameters:** +| 파라미터 | 타입 | 필수 | 기본값 | 설명 | +|---------|------|------|--------|------| +| `brandId` | Long | X | - | 특정 브랜드의 상품만 필터링 | +| `sort` | String | X | `latest` | 정렬 기준 (`latest`, `price_asc`, `likes_desc`) | +| `page` | Integer | X | 0 | 페이지 번호 | +| `size` | Integer | X | 20 | 페이지당 상품 수 | + +**정렬 옵션:** +- `latest` (기본값): 최신 등록순 (createdAt DESC) +- `price_asc`: 최저가 낮은 순 (각 상품의 옵션 중 최저가 기준) +- `likes_desc`: 좋아요 많은 순 (likeCount DESC) + +**반환 정보 (비로그인 사용자):** +```json +{ + "content": [ + { + "productId": 1, + "name": "상품명", + "brand": { + "brandId": 1, + "name": "브랜드명" + }, + "thumbnailImageUrl": "https://example.com/product-thumbnail.png", + "minPrice": 10000, + "likeCount": 150, + "createdAt": "2025-01-01T00:00:00" + } + ], + "page": 0, + "size": 20, + "totalElements": 100, + "totalPages": 5 +} +``` + +**반환 정보 (로그인 사용자):** +```json +{ + "content": [ + { + "productId": 1, + "name": "상품명", + "brand": { + "brandId": 1, + "name": "브랜드명" + }, + "thumbnailImageUrl": "https://example.com/product-thumbnail.png", + "minPrice": 10000, + "likeCount": 150, + "isLikedByMe": true, + "createdAt": "2025-01-01T00:00:00" + } + ], + "page": 0, + "size": 20, + "totalElements": 100, + "totalPages": 5 +} +``` + +**비즈니스 규칙:** +- `minPrice`: 해당 상품의 모든 옵션 중 **최저가**를 표시 +- `likeCount`: 해당 상품의 좋아요 수 (비동기 업데이트, Eventual Consistency) +- `isLikedByMe`: 로그인한 사용자가 해당 상품을 좋아요 했는지 여부 (로그인 시에만 포함) + +**성능 고려사항:** +- 비로그인: 브랜드 정보 Fetch Join, 최저가는 서브쿼리 사용 +- 로그인: 추가로 좋아요 여부 확인 (EXISTS 서브쿼리 또는 LEFT JOIN) + +--- + +#### FR-P-02. 상품 상세 정보 조회 +**API:** `GET /api/v1/products/{productId}` +**인증:** 불필요 (로그인 시 좋아요 여부 추가 제공) + +**Path Parameter:** +- `productId` (Long): 상품 ID + +**반환 정보 (비로그인 사용자):** +```json +{ + "productId": 1, + "name": "상품명", + "description": "상품 상세 설명", + "brand": { + "brandId": 1, + "name": "브랜드명" + }, + "imageUrls": [ + "https://example.com/product-image1.png", + "https://example.com/product-image2.png" + ], + "options": [ + { + "productOptionId": 1, + "name": "S 사이즈", + "price": 10000, + "stockQuantity": 50, + "isAvailable": true + }, + { + "productOptionId": 2, + "name": "M 사이즈", + "price": 10000, + "stockQuantity": 0, + "isAvailable": false + }, + { + "productOptionId": 3, + "name": "L 사이즈", + "price": 12000, + "stockQuantity": 30, + "isAvailable": true + } + ], + "likeCount": 150, + "createdAt": "2025-01-01T00:00:00" +} +``` + +**반환 정보 (로그인 사용자):** +```json +{ + "productId": 1, + "name": "상품명", + "description": "상품 상세 설명", + "brand": { + "brandId": 1, + "name": "브랜드명" + }, + "imageUrls": [ + "https://example.com/product-image1.png", + "https://example.com/product-image2.png" + ], + "options": [ + { + "productOptionId": 1, + "name": "S 사이즈", + "price": 10000, + "stockQuantity": 50, + "isAvailable": true + } + ], + "likeCount": 150, + "isLikedByMe": true, + "createdAt": "2025-01-01T00:00:00" +} +``` + +**비즈니스 규칙:** +- **옵션별 가격**: 각 옵션은 독립적인 가격을 가짐 +- **재고 표시**: `stockQuantity`로 재고 수량 표시 +- **판매 가능 여부**: `isAvailable = stockQuantity > 0` +- 옵션이 없는 상품은 존재하지 않음 (최소 1개 옵션 필수) + +**에러 처리:** +- 존재하지 않는 상품 ID → 404 Not Found + +--- + +### 🔹 좋아요 (Like) + +> **참고:** 좋아요 기능의 상세 요구사항(동기/비동기 처리, 이벤트 발행, 배치 복구 등)은 별도 문서 참조 + +#### FR-L-01. 좋아요 등록 +**API:** `POST /api/v1/products/{productId}/likes` +**인증:** 필수 (v1에서는 임시 식별자 사용, v2에서 정식 인증으로 전환) + +**처리:** +- 좋아요 등록 (동기) +- 중복 좋아요 방지 (DB Unique 제약) +- 카운트 업데이트 (비동기) + +**에러 처리:** +- 이미 좋아요한 상품 → 409 Conflict + +--- + +#### FR-L-02. 좋아요 취소 +**API:** `DELETE /api/v1/products/{productId}/likes` +**인증:** 필수 + +**처리:** +- 좋아요 삭제 (동기) +- 카운트 업데이트 (비동기) +- 멱등성 보장 (이미 취소된 좋아요 재요청 시 성공 응답) + +--- + +#### FR-L-03. 내 좋아요 목록 조회 +**API:** `GET /api/v1/users/me/likes` +**인증:** 필수 + +**Query Parameters:** +- `page` (선택, Integer, 기본값: 0): 페이지 번호 +- `size` (선택, Integer, 기본값: 20): 페이지 크기 + +**반환 정보:** +```json +{ + "content": [ + { + "productId": 1, + "name": "상품명", + "brand": { + "brandId": 1, + "name": "브랜드명" + }, + "thumbnailImageUrl": "https://example.com/product-thumbnail.png", + "minPrice": 10000, + "likeCount": 150, + "likedAt": "2025-01-15T10:30:00" + } + ], + "page": 0, + "size": 20, + "totalElements": 10, + "totalPages": 1 +} +``` + +--- + +## 4️⃣ 설계 고려사항 + +### 확장 포인트 + +#### 1. 옵션 구조의 유연성 +**현재 (v1):** +- 단순 옵션명 + 가격 + 재고 +- 예: "S 사이즈", "M 사이즈", "L 사이즈" + +**향후 확장 가능성 (v2):** +- 다차원 옵션 지원 (예: 색상 × 사이즈) +- 예: "빨강/S", "빨강/M", "파랑/S", "파랑/M" +- 옵션 그룹 개념 도입 (색상 그룹, 사이즈 그룹) + +**설계 시 주의사항:** +- 현재 단순 구조로 시작하되, 다차원 옵션으로 확장 가능하도록 옵션명을 구조화 +- 옵션 ID는 불변으로 유지, 옵션 속성 변경 시 새 옵션 생성 + +--- + +#### 2. 재고 관리 +**현재 (v1):** +- 단순 수량 관리 (`stock_quantity`) +- 재고 조회만 가능 + +**향후 확장 가능성 (v2):** +- 예약 재고 개념 (주문 생성 시 차감) +- 안전 재고 (품절 임박 알림) +- 재고 히스토리 (입고/출고 이력) + +**설계 시 주의사항:** +- 재고 차감은 트랜잭션 내에서 원자적으로 처리 +- 동시성 제어 (낙관적 락 또는 비관적 락) + +--- + +#### 3. 가격 정책 +**현재 (v1):** +- 옵션별 단일 가격 +- 최저가 기준 정렬 + +**향후 확장 가능성 (v2):** +- 프로모션 가격 (기간 한정 할인) +- 회원 등급별 가격 +- 쿠폰 적용 후 가격 + +**설계 시 주의사항:** +- 가격 이력 관리 (가격 변경 추적) +- 주문 시점 가격 고정 (계약 개념) + +--- + +#### 4. 좋아요 카운트 정합성 +**현재 (v1):** +- 좋아요 등록/취소: 동기 +- 카운트 업데이트: 비동기 (Eventual Consistency) + +**잠재 리스크:** +- 이벤트 발행 실패 시 카운트 불일치 +- 대량 좋아요 발생 시 카운트 업데이트 지연 + +**해결 방안:** +- 배치 작업을 통한 정합성 복구 +- 향후 Redis 캐시 도입 (실시간 카운트) + +**설계 시 주의사항:** +- 좋아요 수는 "대략적인 인기도" 지표로 사용 +- 정확한 수치가 필요한 경우 실시간 COUNT 쿼리 사용 + +--- + +### 성능 최적화 포인트 + +#### 1. 상품 목록 조회 +**N+1 문제 방지:** +``` +❌ 각 상품마다 브랜드 조회, 최저가 조회, 좋아요 수 조회 +✅ Fetch Join + 서브쿼리로 단일 쿼리 구성 +``` + +**쿼리 최적화 전략:** +- 브랜드 정보: Fetch Join +- 최저가: 서브쿼리 (SELECT MIN(price) FROM product_options WHERE ...) +- 좋아요 수: 서브쿼리 (SELECT COUNT(*) FROM likes WHERE ...) +- 좋아요 여부 (로그인 시): EXISTS 서브쿼리 또는 LEFT JOIN + +**인덱스 전략:** +- `products(brand_id)`: 브랜드별 상품 필터링 +- `products(created_at DESC)`: 최신순 정렬 +- `product_options(product_id, price)`: 최저가 계산 +- `likes(product_id)`: 좋아요 수 집계 +- `likes(user_id, product_id)`: 중복 방지 + 좋아요 여부 확인 + +--- + +#### 2. 상품 상세 조회 +**Fetch Join 활용:** +``` +✅ 상품 + 옵션 + 이미지 + 브랜드를 단일 쿼리로 조회 +``` + +**쿼리 최적화 전략:** +- 옵션 목록: Fetch Join (1:N) +- 이미지 목록: Fetch Join (1:N) +- 브랜드 정보: Fetch Join (N:1) + +**주의사항:** +- 1:N 관계가 여러 개면 카테시안 곱 발생 가능 +- 필요 시 배치 쿼리로 분리 + +--- + +#### 3. 정렬 성능 +**최신순 (`latest`):** +- 인덱스: `products(created_at DESC)` +- 추가 계산 없이 인덱스 스캔만으로 정렬 가능 + +**가격순 (`price_asc`):** +- 각 상품의 최저가 계산 필요 +- 서브쿼리 또는 집계 쿼리 사용 +- 대용량 데이터 시 성능 이슈 가능 → 캐싱 고려 + +**인기순 (`likes_desc`):** +- 좋아요 수 집계 필요 +- 비동기 업데이트로 인한 지연 허용 +- 대용량 데이터 시 Redis 캐시 활용 고려 + +--- + +### 잠재 리스크 및 해결 방안 + +#### 1. 대용량 상품 데이터 처리 +**리스크:** +- 상품 수 10만 개 이상 시 목록 조회 성능 저하 +- 특히 가격순, 인기순 정렬 시 계산 비용 증가 + +**해결 방안:** +- 적절한 인덱스 설계 +- 페이지네이션 필수 (Offset 방식 → Cursor 방식 고려) +- 자주 조회되는 정렬 결과 캐싱 (Redis) + +--- + +#### 2. 좋아요 동시성 이슈 +**리스크:** +- 같은 사용자가 동시에 좋아요 등록 요청 +- 여러 사용자가 동시에 같은 상품 좋아요 + +**해결 방안:** +- DB Unique 제약으로 중복 방지 +- 애플리케이션 레벨에서 멱등성 보장 +- 낙관적 락 사용 (버전 관리) + +--- + +#### 3. 이미지 로딩 성능 +**리스크:** +- 상품 목록에서 이미지 다수 로딩 시 초기 로딩 지연 + +**해결 방안:** +- CDN 활용 +- Lazy Loading (스크롤 시 순차 로딩) +- 썸네일 이미지 최적화 (리사이징, WebP 포맷) +- 이미지 URL은 DB에 저장, 실제 파일은 Object Storage (S3 등) + +--- + +## 5️⃣ 용어 사전 + +| 용어 | 설명 | +|------|------| +| **브랜드 (Brand)** | 상품을 제공하는 제조사 또는 판매자 | +| **상품 (Product)** | 판매 대상이 되는 아이템의 기본 정보 | +| **상품 옵션 (ProductOption)** | 상품의 실제 판매 단위 (사이즈, 색상 등 구분) | +| **재고 (Stock)** | 판매 가능한 수량 (옵션 단위로 관리) | +| **최저가 (Min Price)** | 상품의 모든 옵션 중 가장 낮은 가격 | +| **좋아요 (Like)** | 사용자가 상품에 대한 관심을 표현하는 행위 (상품 단위) | +| **좋아요 수 (Like Count)** | 해당 상품의 총 좋아요 개수 (인기도 지표) | +| **페이지네이션 (Pagination)** | 대량 데이터를 페이지 단위로 나누어 조회 | +| **Eventual Consistency** | 최종 일관성 - 일시적 불일치를 허용하되 최종적으로 일관성 보장 | + +--- + +**문서 끝** \ No newline at end of file diff --git "a/docs/design/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/02-sequence-diagrams.md" "b/docs/design/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/02-sequence-diagrams.md" new file mode 100644 index 000000000..b1165e3a7 --- /dev/null +++ "b/docs/design/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/02-sequence-diagrams.md" @@ -0,0 +1,461 @@ +# 시퀀스 다이어그램 (Sequence Diagrams) + +## 1️⃣ 브랜드 조회 + +### 개요 +사용자가 특정 브랜드의 상세 정보를 조회하는 시나리오. + +### 참여 객체 +- **Client**: 사용자 클라이언트 (웹/앱) +- **BrandController**: 브랜드 API 컨트롤러 +- **BrandService**: 브랜드 도메인 서비스 +- **BrandRepository**: 브랜드 데이터 접근 +- **Database**: 데이터베이스 + +### 주요 흐름 +1. 클라이언트가 브랜드 ID로 조회 요청 +2. 컨트롤러가 요청을 받아 서비스로 전달 +3. 서비스가 리포지토리를 통해 브랜드 조회 +4. 브랜드가 존재하면 정보 반환, 없으면 404 에러 + +### Mermaid 다이어그램 + +```mermaid +sequenceDiagram + participant Client + participant BrandController + participant BrandService + participant BrandRepository + participant Database + + Client->>BrandController: GET /api/v1/brands/{brandId} + BrandController->>BrandService: getBrand(brandId) + BrandService->>BrandRepository: findById(brandId) + BrandRepository->>Database: SELECT * FROM brands WHERE brand_id = ? + + alt 브랜드 존재 + Database-->>BrandRepository: Brand 데이터 + BrandRepository-->>BrandService: Brand + BrandService-->>BrandController: BrandResponse + BrandController-->>Client: 200 OK (BrandResponse) + else 브랜드 없음 + Database-->>BrandRepository: null + BrandRepository-->>BrandService: null + BrandService-->>BrandController: throw BrandNotFoundException + BrandController-->>Client: 404 Not Found + end +``` + +### 설계 포인트 +- **단순한 조회 흐름**: 브랜드 조회는 다른 도메인과의 협력이 필요 없는 단순 조회 +- **Facade 불필요**: 단일 도메인만 다루므로 Facade 레이어 없이 Controller → Service 직접 호출 +- **예외 처리**: 브랜드가 존재하지 않을 경우 404 응답 + +--- + +## 2️⃣ 상품 목록 조회 + +### 개요 +사용자가 상품 목록을 조회하는 시나리오. 브랜드 필터링, 정렬, 페이지네이션을 지원하며, 로그인 사용자의 경우 좋아요 여부도 포함. + +### 참여 객체 +- **Client**: 사용자 클라이언트 +- **ProductController**: 상품 API 컨트롤러 +- **ProductFacade**: 여러 도메인 서비스 조율 +- **ProductService**: 상품 도메인 서비스 +- **ProductOptionService**: 상품 옵션 도메인 서비스 +- **LikeService**: 좋아요 도메인 서비스 +- **각 Repository**: 데이터 접근 계층 +- **Database**: 데이터베이스 + +### 주요 흐름 (로그인 사용자) +1. 클라이언트가 상품 목록 조회 요청 (헤더에 로그인 정보 포함) +2. 컨트롤러가 헤더에서 userId 추출 +3. Facade가 여러 서비스를 조율: + - 상품 목록 조회 + - 각 상품의 최저가 계산 + - 각 상품의 좋아요 수 조회 + - 사용자의 좋아요 여부 확인 +4. Facade가 데이터를 조합하여 응답 구성 + +### Mermaid 다이어그램 (로그인 사용자) + +```mermaid +sequenceDiagram + participant Client + participant ProductController + participant ProductFacade + participant ProductService + participant ProductOptionService + participant LikeService + participant ProductRepository + participant ProductOptionRepository + participant LikeRepository + participant Database + + Client->>ProductController: GET /api/v1/products?brandId=1&sort=latest
(Headers: X-Loopers-LoginId, X-Loopers-LoginPw) + + ProductController->>ProductController: extractUserId(headers) + Note over ProductController: userId 추출 성공 + + ProductController->>ProductFacade: getProducts(brandId, sort, page, size, userId) + + par 상품 목록 조회 + ProductFacade->>ProductService: findProducts(brandId, sort, page, size) + ProductService->>ProductRepository: findAll(brandId, sort, pageable) + ProductRepository->>Database: SELECT * FROM products
WHERE brand_id = ? ORDER BY created_at DESC + Database-->>ProductRepository: List + ProductRepository-->>ProductService: List + ProductService-->>ProductFacade: List + end + + Note over ProductFacade: productIds 추출: [1, 2, 3, ...] + + par 최저가 계산 + ProductFacade->>ProductOptionService: calculateMinPrices(productIds) + ProductOptionService->>ProductOptionRepository: findMinPricesByProductIds(productIds) + ProductOptionRepository->>Database: SELECT product_id, MIN(price)
FROM product_options
WHERE product_id IN (?) GROUP BY product_id + Database-->>ProductOptionRepository: Map + ProductOptionRepository-->>ProductOptionService: Map + ProductOptionService-->>ProductFacade: Map + and 좋아요 수 조회 + ProductFacade->>LikeService: countLikes(productIds) + LikeService->>LikeRepository: countByProductIds(productIds) + LikeRepository->>Database: SELECT product_id, COUNT(*)
FROM likes
WHERE product_id IN (?) GROUP BY product_id + Database-->>LikeRepository: Map + LikeRepository-->>LikeService: Map + LikeService-->>ProductFacade: Map + and 좋아요 여부 확인 + ProductFacade->>LikeService: checkLikedByUser(userId, productIds) + LikeService->>LikeRepository: existsByUserIdAndProductIds(userId, productIds) + LikeRepository->>Database: SELECT product_id FROM likes
WHERE user_id = ? AND product_id IN (?) + Database-->>LikeRepository: Set + LikeRepository-->>LikeService: Set + LikeService-->>ProductFacade: Set (좋아요한 상품들) + end + + Note over ProductFacade: 데이터 조합:
Product + minPrice + likeCount + isLikedByMe + + ProductFacade-->>ProductController: Page + ProductController-->>Client: 200 OK (상품 목록 + 최저가 + 좋아요 수 + 좋아요 여부) +``` + +### Mermaid 다이어그램 (비로그인 사용자) + +```mermaid +sequenceDiagram + participant Client + participant ProductController + participant ProductFacade + participant ProductService + participant ProductOptionService + participant LikeService + participant ProductRepository + participant ProductOptionRepository + participant LikeRepository + participant Database + + Client->>ProductController: GET /api/v1/products?brandId=1&sort=latest + + ProductController->>ProductController: extractUserId(headers) + Note over ProductController: userId 없음 (비로그인) + + ProductController->>ProductFacade: getProducts(brandId, sort, page, size, null) + + par 상품 목록 조회 + ProductFacade->>ProductService: findProducts(brandId, sort, page, size) + ProductService->>ProductRepository: findAll(brandId, sort, pageable) + ProductRepository->>Database: SELECT * FROM products + Database-->>ProductRepository: List + ProductRepository-->>ProductService: List + ProductService-->>ProductFacade: List + end + + Note over ProductFacade: productIds 추출 + + par 최저가 계산 + ProductFacade->>ProductOptionService: calculateMinPrices(productIds) + ProductOptionService->>ProductOptionRepository: findMinPricesByProductIds(productIds) + ProductOptionRepository->>Database: SELECT product_id, MIN(price)
FROM product_options + Database-->>ProductOptionRepository: Map + ProductOptionRepository-->>ProductOptionService: Map + ProductOptionService-->>ProductFacade: Map + and 좋아요 수 조회 + ProductFacade->>LikeService: countLikes(productIds) + LikeService->>LikeRepository: countByProductIds(productIds) + LikeRepository->>Database: SELECT product_id, COUNT(*)
FROM likes + Database-->>LikeRepository: Map + LikeRepository-->>LikeService: Map + LikeService-->>ProductFacade: Map + end + + Note over ProductFacade: userId가 null이므로
좋아요 여부 조회 생략 + + Note over ProductFacade: 데이터 조합:
Product + minPrice + likeCount
(isLikedByMe 제외) + + ProductFacade-->>ProductController: Page + ProductController-->>Client: 200 OK (상품 목록 + 최저가 + 좋아요 수) +``` + +### 설계 포인트 + +#### 1. Facade의 역할 +- **여러 도메인 서비스 조율**: Product, ProductOption, Like 서비스를 조율 +- **병렬 처리 가능**: 최저가, 좋아요 수, 좋아요 여부 조회는 독립적이므로 병렬 실행 가능 +- **데이터 조합**: 각 서비스에서 받은 데이터를 하나의 응답 DTO로 조합 + +#### 2. 로그인 여부에 따른 분기 +- **Controller에서 userId 추출**: 헤더 존재 여부로 로그인 판단 +- **Facade에서 조건부 처리**: userId가 null이면 좋아요 여부 조회 생략 +- **응답 DTO 차이**: 로그인 시 `isLikedByMe` 필드 포함, 비로그인 시 제외 + +#### 3. 성능 최적화 +- **배치 조회**: productIds를 일괄 전달하여 N+1 문제 방지 +- **병렬 처리**: 최저가, 좋아요 수, 좋아요 여부를 동시에 조회 가능 (par 블록) +- **인덱스 활용**: 각 쿼리는 적절한 인덱스 사용 전제 + +#### 4. 트랜잭션 경계 +- **읽기 전용**: 모든 조회는 읽기 전용 트랜잭션 +- **일관성**: 각 조회는 독립적이므로 최종 일관성(Eventual Consistency) 허용 + +--- + +## 3️⃣ 상품 상세 조회 + +### 개요 +사용자가 특정 상품의 상세 정보를 조회하는 시나리오. 상품 기본 정보, 옵션 목록, 이미지 목록, 좋아요 수를 포함하며, 로그인 사용자의 경우 좋아요 여부도 포함. + +### 참여 객체 +- **Client**: 사용자 클라이언트 +- **ProductController**: 상품 API 컨트롤러 +- **ProductFacade**: 여러 도메인 서비스 조율 +- **ProductService**: 상품 도메인 서비스 +- **ProductOptionService**: 상품 옵션 도메인 서비스 +- **ProductImageService**: 상품 이미지 도메인 서비스 +- **LikeService**: 좋아요 도메인 서비스 +- **각 Repository**: 데이터 접근 계층 +- **Database**: 데이터베이스 + +### 주요 흐름 (로그인 사용자) +1. 클라이언트가 상품 상세 조회 요청 (헤더에 로그인 정보 포함) +2. 컨트롤러가 헤더에서 userId 추출 +3. Facade가 여러 서비스를 조율: + - 상품 기본 정보 조회 + - 상품 옵션 목록 조회 + - 상품 이미지 목록 조회 + - 좋아요 수 조회 + - 사용자의 좋아요 여부 확인 +4. Facade가 데이터를 조합하여 응답 구성 + +### Mermaid 다이어그램 (로그인 사용자) + +```mermaid +sequenceDiagram + participant Client + participant ProductController + participant ProductFacade + participant ProductService + participant ProductOptionService + participant ProductImageService + participant LikeService + participant ProductRepository + participant ProductOptionRepository + participant ProductImageRepository + participant LikeRepository + participant Database + + Client->>ProductController: GET /api/v1/products/{productId}
(Headers: X-Loopers-LoginId, X-Loopers-LoginPw) + + ProductController->>ProductController: extractUserId(headers) + Note over ProductController: userId 추출 성공 + + ProductController->>ProductFacade: getProductDetail(productId, userId) + + ProductFacade->>ProductService: findProduct(productId) + ProductService->>ProductRepository: findById(productId) + ProductRepository->>Database: SELECT * FROM products WHERE product_id = ? + + alt 상품 존재 + Database-->>ProductRepository: Product + ProductRepository-->>ProductService: Product + ProductService-->>ProductFacade: Product + + par 옵션 목록 조회 + ProductFacade->>ProductOptionService: findOptions(productId) + ProductOptionService->>ProductOptionRepository: findByProductId(productId) + ProductOptionRepository->>Database: SELECT * FROM product_options
WHERE product_id = ?
ORDER BY created_at + Database-->>ProductOptionRepository: List + ProductOptionRepository-->>ProductOptionService: List + ProductOptionService-->>ProductFacade: List + and 이미지 목록 조회 + ProductFacade->>ProductImageService: findImages(productId) + ProductImageService->>ProductImageRepository: findByProductId(productId) + ProductImageRepository->>Database: SELECT * FROM product_images
WHERE product_id = ?
ORDER BY display_order + Database-->>ProductImageRepository: List + ProductImageRepository-->>ProductImageService: List + ProductImageService-->>ProductFacade: List + and 좋아요 수 조회 + ProductFacade->>LikeService: countLikes(productId) + LikeService->>LikeRepository: countByProductId(productId) + LikeRepository->>Database: SELECT COUNT(*) FROM likes
WHERE product_id = ? + Database-->>LikeRepository: likeCount + LikeRepository-->>LikeService: likeCount + LikeService-->>ProductFacade: likeCount + and 좋아요 여부 확인 + ProductFacade->>LikeService: checkLikedByUser(userId, productId) + LikeService->>LikeRepository: existsByUserIdAndProductId(userId, productId) + LikeRepository->>Database: SELECT EXISTS(SELECT 1 FROM likes
WHERE user_id = ? AND product_id = ?) + Database-->>LikeRepository: boolean + LikeRepository-->>LikeService: boolean + LikeService-->>ProductFacade: isLiked + end + + Note over ProductFacade: 데이터 조합:
Product + Options + Images + likeCount + isLikedByMe + + ProductFacade-->>ProductController: ProductDetailResponse + ProductController-->>Client: 200 OK (상품 상세 정보) + + else 상품 없음 + Database-->>ProductRepository: null + ProductRepository-->>ProductService: null + ProductService-->>ProductFacade: throw ProductNotFoundException + ProductFacade-->>ProductController: throw ProductNotFoundException + ProductController-->>Client: 404 Not Found + end +``` + +### Mermaid 다이어그램 (비로그인 사용자) + +```mermaid +sequenceDiagram + participant Client + participant ProductController + participant ProductFacade + participant ProductService + participant ProductOptionService + participant ProductImageService + participant LikeService + participant ProductRepository + participant ProductOptionRepository + participant ProductImageRepository + participant LikeRepository + participant Database + + Client->>ProductController: GET /api/v1/products/{productId} + + ProductController->>ProductController: extractUserId(headers) + Note over ProductController: userId 없음 (비로그인) + + ProductController->>ProductFacade: getProductDetail(productId, null) + + ProductFacade->>ProductService: findProduct(productId) + ProductService->>ProductRepository: findById(productId) + ProductRepository->>Database: SELECT * FROM products WHERE product_id = ? + + alt 상품 존재 + Database-->>ProductRepository: Product + ProductRepository-->>ProductService: Product + ProductService-->>ProductFacade: Product + + par 옵션 목록 조회 + ProductFacade->>ProductOptionService: findOptions(productId) + ProductOptionService->>ProductOptionRepository: findByProductId(productId) + ProductOptionRepository->>Database: SELECT * FROM product_options + Database-->>ProductOptionRepository: List + ProductOptionRepository-->>ProductOptionService: List + ProductOptionService-->>ProductFacade: List + and 이미지 목록 조회 + ProductFacade->>ProductImageService: findImages(productId) + ProductImageService->>ProductImageRepository: findByProductId(productId) + ProductImageRepository->>Database: SELECT * FROM product_images + Database-->>ProductImageRepository: List + ProductImageRepository-->>ProductImageService: List + ProductImageService-->>ProductFacade: List + and 좋아요 수 조회 + ProductFacade->>LikeService: countLikes(productId) + LikeService->>LikeRepository: countByProductId(productId) + LikeRepository->>Database: SELECT COUNT(*) FROM likes + Database-->>LikeRepository: likeCount + LikeRepository-->>LikeService: likeCount + LikeService-->>ProductFacade: likeCount + end + + Note over ProductFacade: userId가 null이므로
좋아요 여부 조회 생략 + + Note over ProductFacade: 데이터 조합:
Product + Options + Images + likeCount
(isLikedByMe 제외) + + ProductFacade-->>ProductController: ProductDetailResponse + ProductController-->>Client: 200 OK (상품 상세 정보) + + else 상품 없음 + Database-->>ProductRepository: null + ProductRepository-->>ProductService: null + ProductService-->>ProductFacade: throw ProductNotFoundException + ProductFacade-->>ProductController: throw ProductNotFoundException + ProductController-->>Client: 404 Not Found + end +``` + +### 설계 포인트 + +#### 1. Facade의 역할 +- **다중 도메인 조율**: Product, ProductOption, ProductImage, Like 서비스 조율 +- **병렬 처리**: 옵션, 이미지, 좋아요 수, 좋아요 여부 조회는 독립적이므로 병렬 실행 가능 +- **예외 처리 위임**: 상품이 없으면 ProductService에서 예외 발생, Facade는 그대로 전파 + +#### 2. 로그인 여부에 따른 분기 +- **Controller에서 userId 추출**: 상품 목록 조회와 동일한 패턴 +- **Facade에서 조건부 처리**: userId가 null이면 좋아요 여부 조회 생략 +- **응답 일관성**: 로그인 여부에 따라 응답 구조 달라짐 + +#### 3. 데이터 조회 전략 +- **상품 기본 정보 먼저**: 상품이 존재하지 않으면 즉시 404 반환 +- **나머지 데이터 병렬**: 상품 존재 확인 후 옵션/이미지/좋아요 정보를 병렬로 조회 +- **Fetch Join 고려**: ProductOption, ProductImage는 Fetch Join으로 단일 쿼리 가능 (N+1 방지) + +#### 4. 성능 최적화 +- **병렬 처리**: par 블록으로 여러 조회를 동시에 수행 +- **Fetch Join**: 옵션과 이미지를 상품과 함께 조회하는 것도 가능 (트레이드오프 고려) +- **인덱스**: product_options(product_id), product_images(product_id, display_order) + +--- + +## 📊 전체 설계 요약 + +### 레이어별 책임 + +| 레이어 | 책임 | +|--------|------| +| **Controller** | - HTTP 요청/응답 처리
- 인증 정보 추출 (헤더에서 userId)
- 입력 검증
- 예외를 HTTP 상태 코드로 변환 | +| **Facade** | - 여러 도메인 서비스 조율 (orchestration)
- 데이터 조합 및 응답 DTO 구성
- 병렬 처리 최적화
- 로그인 여부에 따른 분기 처리 | +| **Service** | - 도메인별 비즈니스 로직
- 단일 도메인 책임
- 리포지토리 호출 | +| **Repository** | - 데이터 접근
- 쿼리 최적화 (Fetch Join, 배치 조회)
- 영속성 관리 | + +### Facade 사용 기준 + +| 시나리오 | Facade 사용 여부 | 이유 | +|---------|----------------|------| +| **브랜드 조회** | ❌ 불필요 | 단일 도메인만 다룸 | +| **상품 목록 조회** | ✅ 필요 | Product + ProductOption + Like 조율 | +| **상품 상세 조회** | ✅ 필요 | Product + ProductOption + ProductImage + Like 조율 | + +### 병렬 처리 가능 구간 + +**상품 목록 조회:** +``` +최저가 계산 ∥ 좋아요 수 조회 ∥ 좋아요 여부 확인 +``` + +**상품 상세 조회:** +``` +옵션 조회 ∥ 이미지 조회 ∥ 좋아요 수 조회 ∥ 좋아요 여부 확인 +``` + +### 트랜잭션 전략 +- 모든 조회는 **읽기 전용 트랜잭션** +- Facade 레벨에서 `@Transactional(readOnly = true)` 적용 +- 각 서비스 메서드도 독립적으로 읽기 전용 트랜잭션 가능 + +--- + +**문서 끝** \ No newline at end of file diff --git "a/docs/design/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/03-class-diagram.md" "b/docs/design/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/03-class-diagram.md" new file mode 100644 index 000000000..6271158e4 --- /dev/null +++ "b/docs/design/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/03-class-diagram.md" @@ -0,0 +1,1179 @@ +# 클래스 다이어그램 (Class Diagram) + +## 1️⃣ 전체 아키텍처 개요 + +### 레이어 구조 +``` +┌─────────────────────────────────────────┐ +│ Presentation Layer │ +│ (Controller) │ +└─────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────┐ +│ Application Layer │ +│ (Facade - 도메인 서비스 조율) │ +└─────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────┐ +│ Domain Layer │ +│ (Service, Entity) │ +└─────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────┐ +│ Infrastructure Layer │ +│ (Repository) │ +└─────────────────────────────────────────┘ +``` + +--- + +## 2️⃣ 전체 클래스 다이어그램 + +```mermaid +classDiagram + %% ============================================ + %% Presentation Layer (Controllers) + %% ============================================ + class BrandController { + -BrandService brandService + +getBrand(brandId: Long) ResponseEntity~BrandResponse~ + } + + class ProductController { + -ProductFacade productFacade + +getProducts(brandId: Long, sort: String, page: int, size: int, headers: HttpHeaders) ResponseEntity~Page~ProductListResponse~~ + +getProductDetail(productId: Long, headers: HttpHeaders) ResponseEntity~ProductDetailResponse~ + -extractUserId(headers: HttpHeaders) Long + } + + %% ============================================ + %% Application Layer (Facade) + %% ============================================ + class ProductFacade { + -ProductService productService + -ProductOptionService productOptionService + -ProductImageService productImageService + -LikeService likeService + +getProducts(brandId: Long, sort: String, page: Pageable, userId: Long) Page~ProductListResponse~ + +getProductDetail(productId: Long, userId: Long) ProductDetailResponse + -combineProductListData(products: List~Product~, minPrices: Map, likeCounts: Map, likedProducts: Set) Page~ProductListResponse~ + -combineProductDetailData(product: Product, options: List~ProductOption~, images: List~ProductImage~, likeCount: int, isLiked: boolean) ProductDetailResponse + } + + %% ============================================ + %% Domain Layer (Services) + %% ============================================ + class BrandService { + -BrandRepository brandRepository + +getBrand(brandId: Long) Brand + } + + class ProductService { + -ProductRepository productRepository + +findProducts(brandId: Long, sort: String, pageable: Pageable) Page~Product~ + +findProduct(productId: Long) Product + } + + class ProductOptionService { + -ProductOptionRepository productOptionRepository + +findOptions(productId: Long) List~ProductOption~ + +calculateMinPrices(productIds: List~Long~) Map~Long, Integer~ + +validateOptions(options: List~ProductOption~) void + } + + class ProductImageService { + -ProductImageRepository productImageRepository + +findImages(productId: Long) List~ProductImage~ + +validateImageExists(imageUrl: String) void + } + + class LikeService { + -LikeRepository likeRepository + +countLikes(productId: Long) int + +countLikes(productIds: List~Long~) Map~Long, Integer~ + +checkLikedByUser(userId: Long, productId: Long) boolean + +checkLikedByUser(userId: Long, productIds: List~Long~) Set~Long~ + +addLike(userId: Long, productId: Long) void + +removeLike(userId: Long, productId: Long) void + } + + %% ============================================ + %% Domain Layer (Entities) + %% ============================================ + class Brand { + -Long brandId + -String name + -String description + -String logoImageUrl + -LocalDateTime createdAt + +Brand(name: String, description: String, logoImageUrl: String) + } + + class Product { + -Long productId + -Long brandId + -String name + -String description + -LocalDateTime createdAt + +Product(brandId: Long, name: String, description: String) + +validateBusinessRules() void + } + + class ProductOption { + -Long optionId + -Long productId + -String name + -Integer price + -Integer stockQuantity + -LocalDateTime createdAt + +ProductOption(productId: Long, name: String, price: Integer, stockQuantity: Integer) + +isAvailable() boolean + +validatePrice() void + +validateStock() void + } + + class ProductImage { + -Long imageId + -Long productId + -String imageUrl + -Integer displayOrder + -LocalDateTime createdAt + +ProductImage(productId: Long, imageUrl: String, displayOrder: Integer) + } + + class Like { + -Long likeId + -Long userId + -Long productId + -LocalDateTime createdAt + +Like(userId: Long, productId: Long) + } + + %% ============================================ + %% Infrastructure Layer (Repositories) + %% ============================================ + class BrandRepository { + <> + +findById(brandId: Long) Optional~Brand~ + } + + class ProductRepository { + <> + +findById(productId: Long) Optional~Product~ + +findAll(brandId: Long, sort: String, pageable: Pageable) Page~Product~ + } + + class ProductOptionRepository { + <> + +findByProductId(productId: Long) List~ProductOption~ + +findMinPricesByProductIds(productIds: List~Long~) Map~Long, Integer~ + } + + class ProductImageRepository { + <> + +findByProductId(productId: Long) List~ProductImage~ + } + + class LikeRepository { + <> + +countByProductId(productId: Long) int + +countByProductIds(productIds: List~Long~) Map~Long, Integer~ + +existsByUserIdAndProductId(userId: Long, productId: Long) boolean + +existsByUserIdAndProductIds(userId: Long, productIds: List~Long~) Set~Long~ + +save(like: Like) Like + +deleteByUserIdAndProductId(userId: Long, productId: Long) void + } + + %% ============================================ + %% Exception Hierarchy + %% ============================================ + class BusinessException { + <> + -String errorCode + -String message + +BusinessException(errorCode: String, message: String) + } + + class BrandNotFoundException { + +BrandNotFoundException(brandId: Long) + } + + class ProductNotFoundException { + +ProductNotFoundException(productId: Long) + } + + class ProductOptionNotFoundException { + +ProductOptionNotFoundException(productId: Long) + } + + class ImageNotFoundException { + +ImageNotFoundException(imageUrl: String) + } + + class BusinessRuleViolationException { + +BusinessRuleViolationException(message: String) + } + + class DuplicateLikeException { + +DuplicateLikeException(userId: Long, productId: Long) + } + + %% ============================================ + %% Relationships - Controllers + %% ============================================ + BrandController ..> BrandService : uses + ProductController ..> ProductFacade : uses + + %% ============================================ + %% Relationships - Facade + %% ============================================ + ProductFacade ..> ProductService : uses + ProductFacade ..> ProductOptionService : uses + ProductFacade ..> ProductImageService : uses + ProductFacade ..> LikeService : uses + + %% ============================================ + %% Relationships - Services to Repositories + %% ============================================ + BrandService ..> BrandRepository : uses + ProductService ..> ProductRepository : uses + ProductOptionService ..> ProductOptionRepository : uses + ProductImageService ..> ProductImageRepository : uses + LikeService ..> LikeRepository : uses + + %% ============================================ + %% Relationships - Domain Models + %% ============================================ + Brand "1" --> "N" Product : has + Product "1" --> "N" ProductOption : has + Product "1" --> "N" ProductImage : has + Product "1" --> "N" Like : receives + + %% Note: User entity는 v2에서 추가 예정 + %% User "1" --> "N" Like : creates + + %% ============================================ + %% Relationships - Exceptions + %% ============================================ + BusinessException <|-- BrandNotFoundException + BusinessException <|-- ProductNotFoundException + BusinessException <|-- ProductOptionNotFoundException + BusinessException <|-- ImageNotFoundException + BusinessException <|-- BusinessRuleViolationException + BusinessException <|-- DuplicateLikeException + + BrandService ..> BrandNotFoundException : throws + ProductService ..> ProductNotFoundException : throws + ProductOptionService ..> ProductOptionNotFoundException : throws + ProductOptionService ..> BusinessRuleViolationException : throws + ProductImageService ..> ImageNotFoundException : throws + LikeService ..> DuplicateLikeException : throws +``` + +--- + +## 3️⃣ 레이어별 상세 설계 + +### Presentation Layer (Controller) + +#### BrandController +```java +@RestController +@RequestMapping("/api/v1/brands") +public class BrandController { + private final BrandService brandService; + + @GetMapping("/{brandId}") + public ResponseEntity getBrand(@PathVariable Long brandId) { + Brand brand = brandService.getBrand(brandId); + return ResponseEntity.ok(BrandResponse.from(brand)); + } +} +``` + +**책임:** +- HTTP 요청 처리 +- Path Variable 추출 +- 응답 DTO 변환 +- HTTP 상태 코드 반환 + +**예외 처리:** +- BrandNotFoundException → 404 Not Found + +--- + +#### ProductController +```java +@RestController +@RequestMapping("/api/v1/products") +public class ProductController { + private final ProductFacade productFacade; + + @GetMapping + public ResponseEntity> getProducts( + @RequestParam(required = false) Long brandId, + @RequestParam(defaultValue = "latest") String sort, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestHeader HttpHeaders headers + ) { + Long userId = extractUserId(headers); + Pageable pageable = PageRequest.of(page, size); + + Page products = productFacade.getProducts( + brandId, sort, pageable, userId + ); + + return ResponseEntity.ok(products); + } + + @GetMapping("/{productId}") + public ResponseEntity getProductDetail( + @PathVariable Long productId, + @RequestHeader HttpHeaders headers + ) { + Long userId = extractUserId(headers); + ProductDetailResponse response = productFacade.getProductDetail(productId, userId); + return ResponseEntity.ok(response); + } + + private Long extractUserId(HttpHeaders headers) { + // X-Loopers-LoginId 헤더에서 userId 추출 + // v1에서는 임시 구현, v2에서 정식 인증으로 전환 + String loginId = headers.getFirst("X-Loopers-LoginId"); + return loginId != null ? Long.valueOf(loginId) : null; + } +} +``` + +**책임:** +- HTTP 요청 처리 +- Query Parameter, Path Variable, Header 추출 +- 인증 정보 추출 (userId) +- Facade 호출 +- 응답 DTO 반환 + +--- + +### Application Layer (Facade) + +#### ProductFacade +```java +@Service +@Transactional(readOnly = true) +public class ProductFacade { + private final ProductService productService; + private final ProductOptionService productOptionService; + private final ProductImageService productImageService; + private final LikeService likeService; + + public Page getProducts( + Long brandId, String sort, Pageable pageable, Long userId + ) { + // 1. 상품 목록 조회 + Page products = productService.findProducts(brandId, sort, pageable); + List productIds = products.stream() + .map(Product::getProductId) + .collect(Collectors.toList()); + + // 2. 병렬로 부가 정보 조회 + Map minPrices = productOptionService.calculateMinPrices(productIds); + Map likeCounts = likeService.countLikes(productIds); + Set likedProducts = userId != null + ? likeService.checkLikedByUser(userId, productIds) + : Collections.emptySet(); + + // 3. 데이터 조합 + return combineProductListData(products, minPrices, likeCounts, likedProducts); + } + + public ProductDetailResponse getProductDetail(Long productId, Long userId) { + // 1. 상품 기본 정보 조회 + Product product = productService.findProduct(productId); + + // 2. 병렬로 상세 정보 조회 + List options = productOptionService.findOptions(productId); + List images = productImageService.findImages(productId); + int likeCount = likeService.countLikes(productId); + boolean isLiked = userId != null + ? likeService.checkLikedByUser(userId, productId) + : false; + + // 3. 옵션 검증 (최소 1개 이상) + if (options.isEmpty()) { + throw new ProductOptionNotFoundException(productId); + } + + // 4. 데이터 조합 + return combineProductDetailData(product, options, images, likeCount, isLiked); + } + + private Page combineProductListData(...) { + // 각 Product에 부가 정보를 조합하여 ProductListResponse 생성 + } + + private ProductDetailResponse combineProductDetailData(...) { + // Product + Options + Images + Like 정보를 조합하여 Response 생성 + } +} +``` + +**책임:** +- 여러 도메인 서비스 조율 (orchestration) +- 병렬 처리 가능한 작업 조율 +- 데이터 조합 및 응답 DTO 구성 +- 로그인 여부에 따른 분기 처리 +- 비즈니스 규칙 검증 (옵션 존재 여부) + +**예외 처리:** +- ProductNotFoundException (ProductService에서 전파) +- ProductOptionNotFoundException (옵션이 없을 때) + +--- + +### Domain Layer (Services) + +#### BrandService +```java +@Service +@Transactional(readOnly = true) +public class BrandService { + private final BrandRepository brandRepository; + + public Brand getBrand(Long brandId) { + return brandRepository.findById(brandId) + .orElseThrow(() -> new BrandNotFoundException(brandId)); + } +} +``` + +**책임:** +- 브랜드 도메인 비즈니스 로직 +- 브랜드 조회 + +**예외:** +- BrandNotFoundException + +--- + +#### ProductService +```java +@Service +@Transactional(readOnly = true) +public class ProductService { + private final ProductRepository productRepository; + + public Page findProducts(Long brandId, String sort, Pageable pageable) { + return productRepository.findAll(brandId, sort, pageable); + } + + public Product findProduct(Long productId) { + return productRepository.findById(productId) + .orElseThrow(() -> new ProductNotFoundException(productId)); + } +} +``` + +**책임:** +- 상품 도메인 비즈니스 로직 +- 상품 조회 (목록, 상세) + +**예외:** +- ProductNotFoundException + +--- + +#### ProductOptionService +```java +@Service +@Transactional(readOnly = true) +public class ProductOptionService { + private final ProductOptionRepository productOptionRepository; + + public List findOptions(Long productId) { + return productOptionRepository.findByProductId(productId); + } + + public Map calculateMinPrices(List productIds) { + return productOptionRepository.findMinPricesByProductIds(productIds); + } + + public void validateOptions(List options) { + for (ProductOption option : options) { + option.validatePrice(); + option.validateStock(); + } + } +} +``` + +**책임:** +- 상품 옵션 도메인 비즈니스 로직 +- 옵션 조회 +- 최저가 계산 (집계 쿼리) +- 가격/재고 검증 + +**예외:** +- BusinessRuleViolationException (가격 음수, 재고 음수 등) + +--- + +#### ProductImageService +```java +@Service +@Transactional(readOnly = true) +public class ProductImageService { + private final ProductImageRepository productImageRepository; + + public List findImages(Long productId) { + return productImageRepository.findByProductId(productId); + } + + public void validateImageExists(String imageUrl) { + // 실제 이미지 리소스 존재 여부 확인 (S3, CDN 등) + // 이미지가 없으면 ImageNotFoundException 발생 + } +} +``` + +**책임:** +- 상품 이미지 도메인 비즈니스 로직 +- 이미지 조회 +- 이미지 리소스 존재 검증 + +**예외:** +- ImageNotFoundException + +--- + +#### LikeService +```java +@Service +@Transactional(readOnly = true) +public class LikeService { + private final LikeRepository likeRepository; + + public int countLikes(Long productId) { + return likeRepository.countByProductId(productId); + } + + public Map countLikes(List productIds) { + return likeRepository.countByProductIds(productIds); + } + + public boolean checkLikedByUser(Long userId, Long productId) { + return likeRepository.existsByUserIdAndProductId(userId, productId); + } + + public Set checkLikedByUser(Long userId, List productIds) { + return likeRepository.existsByUserIdAndProductIds(userId, productIds); + } + + @Transactional + public void addLike(Long userId, Long productId) { + if (likeRepository.existsByUserIdAndProductId(userId, productId)) { + throw new DuplicateLikeException(userId, productId); + } + Like like = new Like(userId, productId); + likeRepository.save(like); + // 이벤트 발행 (카운트 업데이트) - 별도 문서 참조 + } + + @Transactional + public void removeLike(Long userId, Long productId) { + likeRepository.deleteByUserIdAndProductId(userId, productId); + // 이벤트 발행 (카운트 업데이트) - 별도 문서 참조 + } +} +``` + +**책임:** +- 좋아요 도메인 비즈니스 로직 +- 좋아요 수 조회 (단일, 배치) +- 좋아요 여부 확인 (단일, 배치) +- 좋아요 등록/취소 + +**예외:** +- DuplicateLikeException + +--- + +### Domain Layer (Entities) + +#### Brand +```java +@Entity +@Table(name = "brands") +public class Brand { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long brandId; + + @Column(nullable = false, unique = true, length = 100) + private String name; + + @Column(columnDefinition = "TEXT") + private String description; + + @Column(length = 500) + private String logoImageUrl; + + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + protected Brand() {} // JPA + + public Brand(String name, String description, String logoImageUrl) { + this.name = name; + this.description = description; + this.logoImageUrl = logoImageUrl; + this.createdAt = LocalDateTime.now(); + } + + // Getters +} +``` + +**설계 포인트:** +- 불변 객체 지향 (Setter 없음) +- 생성자를 통한 필수 값 주입 +- JPA 기본 생성자는 protected + +--- + +#### Product +```java +@Entity +@Table(name = "products") +public class Product { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long productId; + + @Column(nullable = false) + private Long brandId; // FK 제약 없음 + + @Column(nullable = false, length = 200) + private String name; + + @Column(columnDefinition = "TEXT") + private String description; + + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + protected Product() {} // JPA + + public Product(Long brandId, String name, String description) { + this.brandId = brandId; + this.name = name; + this.description = description; + this.createdAt = LocalDateTime.now(); + validateBusinessRules(); + } + + public void validateBusinessRules() { + if (brandId == null || brandId <= 0) { + throw new BusinessRuleViolationException("brandId must be positive"); + } + if (name == null || name.isBlank()) { + throw new BusinessRuleViolationException("Product name is required"); + } + } + + // Getters +} +``` + +**설계 포인트:** +- brandId는 Long 타입 (FK 제약 없음) +- 생성자에서 비즈니스 규칙 검증 +- 도메인 무결성은 애플리케이션 레벨에서 관리 + +--- + +#### ProductOption +```java +@Entity +@Table( + name = "product_options", + uniqueConstraints = @UniqueConstraint(columnNames = {"product_id", "name"}) +) +public class ProductOption { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long optionId; + + @Column(nullable = false) + private Long productId; // FK 제약 없음 + + @Column(nullable = false, length = 100) + private String name; + + @Column(nullable = false) + private Integer price; + + @Column(nullable = false) + private Integer stockQuantity; + + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + protected ProductOption() {} // JPA + + public ProductOption(Long productId, String name, Integer price, Integer stockQuantity) { + this.productId = productId; + this.name = name; + this.price = price; + this.stockQuantity = stockQuantity; + this.createdAt = LocalDateTime.now(); + validatePrice(); + validateStock(); + } + + public boolean isAvailable() { + return stockQuantity > 0; + } + + public void validatePrice() { + if (price == null || price < 0) { + throw new BusinessRuleViolationException("Price must be non-negative"); + } + } + + public void validateStock() { + if (stockQuantity == null || stockQuantity < 0) { + throw new BusinessRuleViolationException("Stock quantity must be non-negative"); + } + } + + // Getters +} +``` + +**설계 포인트:** +- 가격, 재고 검증 로직 포함 +- `isAvailable()` 비즈니스 메서드 +- Unique 제약: 같은 상품 내 옵션명 중복 불가 + +--- + +#### ProductImage +```java +@Entity +@Table(name = "product_images") +public class ProductImage { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long imageId; + + @Column(nullable = false) + private Long productId; // FK 제약 없음 + + @Column(nullable = false, length = 500) + private String imageUrl; + + @Column(nullable = false) + private Integer displayOrder; + + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + protected ProductImage() {} // JPA + + public ProductImage(Long productId, String imageUrl, Integer displayOrder) { + this.productId = productId; + this.imageUrl = imageUrl; + this.displayOrder = displayOrder; + this.createdAt = LocalDateTime.now(); + } + + // Getters +} +``` + +**설계 포인트:** +- displayOrder로 이미지 순서 관리 +- 실제 이미지 파일은 S3/CDN에 저장, URL만 DB에 보관 + +--- + +#### Like +```java +@Entity +@Table( + name = "likes", + uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "product_id"}) +) +public class Like { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long likeId; + + @Column(nullable = false) + private Long userId; // FK 제약 없음, v2에서 User 엔티티 추가 예정 + + @Column(nullable = false) + private Long productId; // FK 제약 없음 + + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + protected Like() {} // JPA + + public Like(Long userId, Long productId) { + this.userId = userId; + this.productId = productId; + this.createdAt = LocalDateTime.now(); + } + + // Getters +} +``` + +**설계 포인트:** +- Unique 제약: 사용자당 상품 1개만 좋아요 가능 +- 중복 좋아요는 DB 레벨에서 방지 +- v1에서는 userId를 임시 식별자로 사용 + +--- + +## 4️⃣ 예외 계층 구조 + +```mermaid +classDiagram + class RuntimeException { + <> + } + + class BusinessException { + <> + -String errorCode + -String message + -HttpStatus httpStatus + +BusinessException(errorCode, message, httpStatus) + +getErrorCode() String + +getMessage() String + +getHttpStatus() HttpStatus + } + + class BrandNotFoundException { + +BrandNotFoundException(brandId: Long) + } + + class ProductNotFoundException { + +ProductNotFoundException(productId: Long) + } + + class ProductOptionNotFoundException { + +ProductOptionNotFoundException(productId: Long) + } + + class ImageNotFoundException { + +ImageNotFoundException(imageUrl: String) + } + + class BusinessRuleViolationException { + +BusinessRuleViolationException(message: String) + } + + class DuplicateLikeException { + +DuplicateLikeException(userId: Long, productId: Long) + } + + RuntimeException <|-- BusinessException + BusinessException <|-- BrandNotFoundException + BusinessException <|-- ProductNotFoundException + BusinessException <|-- ProductOptionNotFoundException + BusinessException <|-- ImageNotFoundException + BusinessException <|-- BusinessRuleViolationException + BusinessException <|-- DuplicateLikeException +``` + +### 예외 클래스 상세 + +#### BusinessException (추상 베이스 클래스) +```java +public abstract class BusinessException extends RuntimeException { + private final String errorCode; + private final HttpStatus httpStatus; + + protected BusinessException(String errorCode, String message, HttpStatus httpStatus) { + super(message); + this.errorCode = errorCode; + this.httpStatus = httpStatus; + } + + public String getErrorCode() { + return errorCode; + } + + public HttpStatus getHttpStatus() { + return httpStatus; + } +} +``` + +--- + +#### BrandNotFoundException +```java +public class BrandNotFoundException extends BusinessException { + public BrandNotFoundException(Long brandId) { + super( + "BRAND_NOT_FOUND", + String.format("Brand not found: brandId=%d", brandId), + HttpStatus.NOT_FOUND + ); + } +} +``` + +**발생 시점:** 브랜드 조회 시 존재하지 않을 때 +**HTTP 상태:** 404 Not Found +**복구 전략:** 사용자에게 브랜드가 존재하지 않음을 알림 + +--- + +#### ProductNotFoundException +```java +public class ProductNotFoundException extends BusinessException { + public ProductNotFoundException(Long productId) { + super( + "PRODUCT_NOT_FOUND", + String.format("Product not found: productId=%d", productId), + HttpStatus.NOT_FOUND + ); + } +} +``` + +**발생 시점:** +- 상품 조회 시 존재하지 않을 때 +- 타이밍 이슈로 조회 중 삭제되었을 때 (동시성) + +**HTTP 상태:** 404 Not Found +**복구 전략:** 사용자에게 상품이 존재하지 않음을 알림 + +--- + +#### ProductOptionNotFoundException +```java +public class ProductOptionNotFoundException extends BusinessException { + public ProductOptionNotFoundException(Long productId) { + super( + "PRODUCT_OPTION_NOT_FOUND", + String.format("Product options not found for productId=%d. Data integrity violation.", productId), + HttpStatus.INTERNAL_SERVER_ERROR + ); + } +} +``` + +**발생 시점:** 상품은 존재하는데 옵션이 하나도 없을 때 (데이터 무결성 위반) +**HTTP 상태:** 500 Internal Server Error +**복구 전략:** +- 시스템 관리자에게 알림 +- 데이터 정합성 복구 필요 +- 사용자에게는 일시적 오류 안내 + +--- + +#### ImageNotFoundException +```java +public class ImageNotFoundException extends BusinessException { + public ImageNotFoundException(String imageUrl) { + super( + "IMAGE_NOT_FOUND", + String.format("Image resource not found: url=%s", imageUrl), + HttpStatus.NOT_FOUND + ); + } +} +``` + +**발생 시점:** 이미지 URL은 DB에 있지만 실제 리소스(S3, CDN)가 없을 때 +**HTTP 상태:** 404 Not Found (또는 500으로 설정 가능) +**복구 전략:** +- 기본 이미지로 대체 +- 시스템 관리자에게 알림 (이미지 리소스 복구 필요) + +--- + +#### BusinessRuleViolationException +```java +public class BusinessRuleViolationException extends BusinessException { + public BusinessRuleViolationException(String message) { + super( + "BUSINESS_RULE_VIOLATION", + message, + HttpStatus.INTERNAL_SERVER_ERROR + ); + } +} +``` + +**발생 시점:** +- 가격이 음수일 때 +- 재고가 음수일 때 +- 기타 비즈니스 규칙 위반 + +**HTTP 상태:** 500 Internal Server Error +**복구 전략:** +- 시스템 관리자에게 알림 +- 데이터 검증 강화 +- 사용자에게는 일시적 오류 안내 + +--- + +#### DuplicateLikeException +```java +public class DuplicateLikeException extends BusinessException { + public DuplicateLikeException(Long userId, Long productId) { + super( + "DUPLICATE_LIKE", + String.format("User already liked this product: userId=%d, productId=%d", userId, productId), + HttpStatus.CONFLICT + ); + } +} +``` + +**발생 시점:** 이미 좋아요를 누른 상품에 다시 좋아요 시도 +**HTTP 상태:** 409 Conflict +**복구 전략:** 사용자에게 이미 좋아요했음을 안내 + +--- + +## 5️⃣ 설계 원칙 및 고려사항 + +### 1. 레이어 분리 원칙 + +#### Controller 책임 +- HTTP 프로토콜 처리에만 집중 +- 비즈니스 로직 없음 +- 인증 정보 추출 (userId) +- 예외를 HTTP 상태 코드로 변환 + +#### Facade 책임 +- 여러 도메인 서비스 조율 +- 복잡한 흐름 관리 +- 데이터 조합 +- **비즈니스 규칙은 Service에 위임** + +#### Service 책임 +- 도메인별 비즈니스 로직 +- 단일 도메인에 집중 +- 트랜잭션 경계 +- Entity 검증 및 생성 + +#### Repository 책임 +- 데이터 접근만 +- 쿼리 최적화 +- 영속성 관리 + +--- + +### 2. Facade 사용 기준 + +**Facade가 필요한 경우:** +- 여러 도메인 서비스 협력이 필요한 경우 +- 복잡한 데이터 조합이 필요한 경우 +- 조건부 처리(로그인 여부 등)가 필요한 경우 + +**Facade가 불필요한 경우:** +- 단일 도메인만 다루는 경우 (예: 브랜드 조회) +- Controller → Service 직접 호출로 충분한 경우 + +--- + +### 3. 예외 처리 전략 + +#### 예외 계층 구조 +``` +RuntimeException + └─ BusinessException (추상) + ├─ BrandNotFoundException (404) + ├─ ProductNotFoundException (404) + ├─ ProductOptionNotFoundException (500) ← 치명적 + ├─ ImageNotFoundException (404/500) + ├─ BusinessRuleViolationException (500) ← 치명적 + └─ DuplicateLikeException (409) +``` + +#### 치명적 vs 일반 예외 + +| 예외 | 치명도 | HTTP | 복구 전략 | +|------|--------|------|----------| +| BrandNotFoundException | 일반 | 404 | 사용자 안내 | +| ProductNotFoundException | 일반 | 404 | 사용자 안내 | +| **ProductOptionNotFoundException** | **치명적** | **500** | **시스템 알림, 데이터 복구** | +| **ImageNotFoundException** | 치명적 | 404 | 기본 이미지 대체, 알림 | +| **BusinessRuleViolationException** | **치명적** | **500** | **시스템 알림, 데이터 검증** | +| DuplicateLikeException | 일반 | 409 | 사용자 안내 | + +#### GlobalExceptionHandler (예시) +```java +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(BusinessException.class) + public ResponseEntity handleBusinessException(BusinessException e) { + // 치명적 예외는 로깅 + 알림 + if (e.getHttpStatus().is5xxServerError()) { + log.error("Critical error occurred", e); + // 시스템 관리자에게 알림 (슬랙, 이메일 등) + } + + ErrorResponse response = new ErrorResponse( + e.getErrorCode(), + e.getMessage() + ); + + return ResponseEntity + .status(e.getHttpStatus()) + .body(response); + } +} +``` + +--- + +### 4. FK 제약 없는 설계 + +**이유:** +- 애플리케이션 레벨에서 참조 무결성 관리 +- DB 레벨 제약으로 인한 성능 오버헤드 제거 +- 향후 샤딩, 마이크로서비스 전환 시 유연성 확보 + +**트레이드오프:** +- 데이터 정합성은 애플리케이션 책임 +- 고아 레코드(orphan records) 발생 가능성 +- 정기적인 데이터 정합성 체크 필요 + +**보완 전략:** +- Service 레벨에서 참조 검증 +- 배치 작업을 통한 정합성 체크 +- 모니터링 및 알림 + +--- + +### 5. 성능 고려사항 + +#### N+1 문제 방지 +- Fetch Join 활용 +- 배치 조회 메서드 제공 (예: `calculateMinPrices(List)`) +- Repository에서 IN 절 쿼리 사용 + +#### 병렬 처리 +- Facade에서 독립적인 조회는 병렬 실행 가능 +- CompletableFuture 또는 @Async 활용 고려 + +#### 캐싱 +- 자주 조회되는 브랜드 정보 캐싱 +- 상품 최저가 계산 결과 캐싱 (Redis) +- 좋아요 수 캐싱 (Eventual Consistency 허용) + +--- + +**문서 끝** \ No newline at end of file diff --git "a/docs/design/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/04-erd.md" "b/docs/design/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/04-erd.md" new file mode 100644 index 000000000..9b98098bd --- /dev/null +++ "b/docs/design/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/04-erd.md" @@ -0,0 +1,792 @@ +brands - 브랜드 테이블 +sqlCREATE TABLE brands ( +brand_id BIGINT AUTO_INCREMENT PRIMARY KEY, +name VARCHAR(100) NOT NULL UNIQUE, +description TEXT, +logo_image_url VARCHAR(500), +created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, +INDEX idx_name (name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +product_options - 상품 옵션 (가격/재고 관리 단위) +sqlCREATE TABLE product_options ( +option_id BIGINT AUTO_INCREMENT PRIMARY KEY, +product_id BIGINT NOT NULL, +name VARCHAR(100) NOT NULL, +price INT NOT NULL, +stock_quantity INT NOT NULL DEFAULT 0, +created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, +CONSTRAINT uk_product_option_name UNIQUE (product_id, name), +INDEX idx_product_id (product_id), +INDEX idx_product_price (product_id, price) -- 최저가 계산용 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +product_images - 상품 이미지 (여러 장 가능) +sqlCREATE TABLE product_images ( +image_id BIGINT AUTO_INCREMENT PRIMARY KEY, +product_id BIGINT NOT NULL, +image_url VARCHAR(500) NOT NULL, +display_order INT NOT NULL, +created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, +INDEX idx_product_display_order (product_id, display_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +2. 수정해야 할 테이블 + products - brand_id 추가, price 제거 + sqlALTER TABLE products + ADD COLUMN brand_id BIGINT NOT NULL AFTER id, + DROP COLUMN price, + ADD INDEX idx_brand_id (brand_id), + ADD INDEX idx_brand_created (brand_id, created_at DESC); -- 브랜드별 최신순 조회 + 이유: + +price는 이제 product_options에서 관리 +brand_id 추가 (어느 브랜드 상품인지) +브랜드별 상품 조회를 위한 복합 인덱스 + +likes - user_id 인덱스 추가 +sqlALTER TABLE likes +ADD INDEX idx_user_id (user_id); -- 내 좋아요 목록 조회용 +이유: + +"내 좋아요 목록 조회" 기능을 위해 user_id 인덱스 필요 + + +3. 좋아요 카운트 관리 + 현재는 products 테이블에 like_count 컬럼이 없는데, 두 가지 선택지가 있어요: + A. 추가하지 않음 (실시간 COUNT) + +매번 SELECT COUNT(*) FROM likes WHERE product_id = ? +정확하지만 느림 + +B. 추가함 (비정규화) +sqlALTER TABLE products +ADD COLUMN like_count INT NOT NULL DEFAULT 0; + +좋아요 등록/취소 시 비동기로 업데이트 +Eventual Consistency (좋아요 명세 문서에서 언급) + + +# ERD (Entity Relationship Diagram) + +## 1️⃣ 전체 ERD 개요 + +### 테이블 구조 +``` +brands (브랜드) + ├── brand_id (PK) + └── [1:N] products + +products (상품) + ├── product_id (PK) + ├── brand_id (참조, FK 제약 없음) + ├── [1:N] product_options + ├── [1:N] product_images + └── [1:N] likes + +product_options (상품 옵션) + ├── option_id (PK) + └── product_id (참조, FK 제약 없음) + +product_images (상품 이미지) + ├── image_id (PK) + └── product_id (참조, FK 제약 없음) + +likes (좋아요) + ├── like_id (PK) + ├── user_id (참조, FK 제약 없음) + └── product_id (참조, FK 제약 없음) + +users (사용자) - v2에서 추가 예정 + └── user_id (PK) +``` + +--- + +## 2️⃣ ERD 다이어그램 + +```mermaid +erDiagram + brands ||--o{ products : "has" + products ||--o{ product_options : "has" + products ||--o{ product_images : "has" + products ||--o{ likes : "receives" + users ||--o{ likes : "creates" + + brands { + BIGINT brand_id PK "AUTO_INCREMENT" + VARCHAR(100) name UK "NOT NULL, UNIQUE" + TEXT description "브랜드 설명" + VARCHAR(500) logo_image_url "로고 이미지 URL" + TIMESTAMP created_at "NOT NULL, DEFAULT CURRENT_TIMESTAMP" + } + + products { + BIGINT product_id PK "AUTO_INCREMENT" + BIGINT brand_id "NOT NULL, 브랜드 참조 (FK 제약 없음)" + VARCHAR(200) name "NOT NULL, 상품명" + TEXT description "상품 상세 설명" + INT like_count "NOT NULL, DEFAULT 0, 비정규화 컬럼" + TIMESTAMP created_at "NOT NULL, DEFAULT CURRENT_TIMESTAMP" + } + + product_options { + BIGINT option_id PK "AUTO_INCREMENT" + BIGINT product_id "NOT NULL, 상품 참조 (FK 제약 없음)" + VARCHAR(100) name UK "NOT NULL, 옵션명 (S, M, L 등)" + INT price "NOT NULL, 가격" + INT stock_quantity "NOT NULL, DEFAULT 0, 재고" + TIMESTAMP created_at "NOT NULL, DEFAULT CURRENT_TIMESTAMP" + } + + product_images { + BIGINT image_id PK "AUTO_INCREMENT" + BIGINT product_id "NOT NULL, 상품 참조 (FK 제약 없음)" + VARCHAR(500) image_url "NOT NULL, 이미지 URL" + INT display_order "NOT NULL, 표시 순서" + TIMESTAMP created_at "NOT NULL, DEFAULT CURRENT_TIMESTAMP" + } + + likes { + BIGINT like_id PK "AUTO_INCREMENT" + BIGINT user_id "NOT NULL, 사용자 참조 (FK 제약 없음)" + BIGINT product_id "NOT NULL, 상품 참조 (FK 제약 없음)" + TIMESTAMP created_at "NOT NULL, DEFAULT CURRENT_TIMESTAMP" + } + + users { + BIGINT user_id PK "AUTO_INCREMENT, v2에서 추가 예정" + VARCHAR(50) username "NOT NULL, UNIQUE" + VARCHAR(100) email "NOT NULL, UNIQUE" + TIMESTAMP created_at "NOT NULL, DEFAULT CURRENT_TIMESTAMP" + } +``` + +--- + +## 3️⃣ 테이블 상세 정의 + +### brands (브랜드) + +```sql +CREATE TABLE brands ( + brand_id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '브랜드 ID', + name VARCHAR(100) NOT NULL UNIQUE COMMENT '브랜드명', + description TEXT COMMENT '브랜드 설명', + logo_image_url VARCHAR(500) COMMENT '로고 이미지 URL', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일시', + + -- 인덱스 + INDEX idx_name (name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='브랜드'; +``` + +**컬럼 설명:** +| 컬럼명 | 타입 | 제약 | 설명 | +|--------|------|------|------| +| brand_id | BIGINT | PK, AUTO_INCREMENT | 브랜드 고유 ID | +| name | VARCHAR(100) | NOT NULL, UNIQUE | 브랜드명 (중복 불가) | +| description | TEXT | NULL | 브랜드 설명 | +| logo_image_url | VARCHAR(500) | NULL | 로고 이미지 URL (S3/CDN) | +| created_at | TIMESTAMP | NOT NULL | 생성일시 | + +**인덱스 전략:** +| 인덱스명 | 컬럼 | 용도 | +|----------|------|------| +| PRIMARY | brand_id | PK | +| idx_name | name | 브랜드명 검색 (UNIQUE 제약) | + +**샘플 데이터:** +```sql +INSERT INTO brands (brand_id, name, description, logo_image_url, created_at) VALUES +(1, 'Nike', '글로벌 스포츠 브랜드', 'https://cdn.example.com/brands/nike-logo.png', '2025-01-01 00:00:00'), +(2, 'Adidas', '독일 스포츠 브랜드', 'https://cdn.example.com/brands/adidas-logo.png', '2025-01-01 00:00:00'), +(3, 'Apple', '프리미엄 전자기기 브랜드', 'https://cdn.example.com/brands/apple-logo.png', '2025-01-02 00:00:00'); +``` + +--- + +### products (상품) + +```sql +CREATE TABLE products ( + product_id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '상품 ID', + brand_id BIGINT NOT NULL COMMENT '브랜드 ID (FK 제약 없음)', + name VARCHAR(200) NOT NULL COMMENT '상품명', + description TEXT COMMENT '상품 상세 설명', + like_count INT NOT NULL DEFAULT 0 COMMENT '좋아요 수 (비정규화)', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일시', + + -- 인덱스 + INDEX idx_brand_id (brand_id), + INDEX idx_created_at (created_at DESC), + INDEX idx_brand_created (brand_id, created_at DESC), + INDEX idx_like_count (like_count DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='상품'; +``` + +**컬럼 설명:** +| 컬럼명 | 타입 | 제약 | 설명 | +|--------|------|------|------| +| product_id | BIGINT | PK, AUTO_INCREMENT | 상품 고유 ID | +| brand_id | BIGINT | NOT NULL | 브랜드 ID (애플리케이션 레벨에서 참조 관리) | +| name | VARCHAR(200) | NOT NULL | 상품명 | +| description | TEXT | NULL | 상품 상세 설명 | +| like_count | INT | NOT NULL, DEFAULT 0 | 좋아요 수 (비동기 업데이트, Eventual Consistency) | +| created_at | TIMESTAMP | NOT NULL | 생성일시 | + +**인덱스 전략:** +| 인덱스명 | 컬럼 | 용도 | +|----------|------|------| +| PRIMARY | product_id | PK | +| idx_brand_id | brand_id | 브랜드별 상품 조회 (`WHERE brand_id = ?`) | +| idx_created_at | created_at DESC | 최신순 정렬 (`ORDER BY created_at DESC`) | +| idx_brand_created | brand_id, created_at DESC | 브랜드별 최신순 조회 (복합 인덱스) | +| idx_like_count | like_count DESC | 인기순 정렬 (`ORDER BY like_count DESC`) | + +**설계 노트:** +- **like_count 비정규화**: 좋아요 수를 매번 COUNT 하지 않고 컬럼에 저장 +- **FK 제약 없음**: brand_id는 애플리케이션 레벨에서 검증 +- **복합 인덱스**: 브랜드별 + 최신순 조회 최적화 + +**샘플 데이터:** +```sql +INSERT INTO products (product_id, brand_id, name, description, like_count, created_at) VALUES +(1, 1, 'Nike Air Max 90', '나이키 에어맥스 90 운동화', 150, '2025-01-10 10:00:00'), +(2, 1, 'Nike Dri-FIT T-Shirt', '나이키 드라이핏 티셔츠', 80, '2025-01-11 10:00:00'), +(3, 2, 'Adidas Ultraboost', '아디다스 울트라부스트 러닝화', 200, '2025-01-12 10:00:00'), +(4, 3, 'iPhone 15 Pro', '애플 아이폰 15 프로', 500, '2025-01-13 10:00:00'); +``` + +--- + +### product_options (상품 옵션) + +```sql +CREATE TABLE product_options ( + option_id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '옵션 ID', + product_id BIGINT NOT NULL COMMENT '상품 ID (FK 제약 없음)', + name VARCHAR(100) NOT NULL COMMENT '옵션명 (예: S, M, L)', + price INT NOT NULL COMMENT '가격', + stock_quantity INT NOT NULL DEFAULT 0 COMMENT '재고 수량', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일시', + + -- 제약 + CONSTRAINT uk_product_option_name UNIQUE (product_id, name), + CONSTRAINT chk_price CHECK (price >= 0), + CONSTRAINT chk_stock CHECK (stock_quantity >= 0), + + -- 인덱스 + INDEX idx_product_id (product_id), + INDEX idx_product_price (product_id, price) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='상품 옵션'; +``` + +**컬럼 설명:** +| 컬럼명 | 타입 | 제약 | 설명 | +|--------|------|------|------| +| option_id | BIGINT | PK, AUTO_INCREMENT | 옵션 고유 ID | +| product_id | BIGINT | NOT NULL | 상품 ID (애플리케이션 레벨에서 참조 관리) | +| name | VARCHAR(100) | NOT NULL | 옵션명 (S, M, L, 빨강, 파랑 등) | +| price | INT | NOT NULL, >= 0 | 옵션별 가격 | +| stock_quantity | INT | NOT NULL, >= 0 | 옵션별 재고 수량 | +| created_at | TIMESTAMP | NOT NULL | 생성일시 | + +**인덱스 전략:** +| 인덱스명 | 컬럼 | 용도 | +|----------|------|------| +| PRIMARY | option_id | PK | +| uk_product_option_name | product_id, name | 같은 상품 내 옵션명 중복 방지 (UNIQUE) | +| idx_product_id | product_id | 상품별 옵션 조회 (`WHERE product_id = ?`) | +| idx_product_price | product_id, price | 최저가 계산 (`MIN(price) WHERE product_id IN (...)`) | + +**설계 노트:** +- **UNIQUE 제약**: 같은 상품 내에서 옵션명 중복 불가 (예: Nike Air Max 90에 "M" 사이즈는 1개만) +- **CHECK 제약**: 가격과 재고는 음수 불가 +- **복합 인덱스**: 최저가 계산 최적화 + +**샘플 데이터:** +```sql +INSERT INTO product_options (option_id, product_id, name, price, stock_quantity, created_at) VALUES +-- Nike Air Max 90 (product_id=1) +(1, 1, '250mm', 120000, 10, '2025-01-10 10:00:00'), +(2, 1, '260mm', 120000, 5, '2025-01-10 10:00:00'), +(3, 1, '270mm', 125000, 0, '2025-01-10 10:00:00'), + +-- Nike Dri-FIT T-Shirt (product_id=2) +(4, 2, 'S', 35000, 20, '2025-01-11 10:00:00'), +(5, 2, 'M', 35000, 15, '2025-01-11 10:00:00'), +(6, 2, 'L', 38000, 10, '2025-01-11 10:00:00'), + +-- Adidas Ultraboost (product_id=3) +(7, 3, '250mm', 180000, 8, '2025-01-12 10:00:00'), +(8, 3, '260mm', 180000, 12, '2025-01-12 10:00:00'), + +-- iPhone 15 Pro (product_id=4) +(9, 4, '128GB', 1350000, 50, '2025-01-13 10:00:00'), +(10, 4, '256GB', 1550000, 30, '2025-01-13 10:00:00'), +(11, 4, '512GB', 1850000, 20, '2025-01-13 10:00:00'); +``` + +**최저가 계산 예시:** +```sql +-- product_id=1 (Nike Air Max 90)의 최저가는 120000원 (옵션 1, 2) +-- product_id=4 (iPhone 15 Pro)의 최저가는 1350000원 (옵션 9) +``` + +--- + +### product_images (상품 이미지) + +```sql +CREATE TABLE product_images ( + image_id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '이미지 ID', + product_id BIGINT NOT NULL COMMENT '상품 ID (FK 제약 없음)', + image_url VARCHAR(500) NOT NULL COMMENT '이미지 URL', + display_order INT NOT NULL COMMENT '표시 순서', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일시', + + -- 인덱스 + INDEX idx_product_display_order (product_id, display_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='상품 이미지'; +``` + +**컬럼 설명:** +| 컬럼명 | 타입 | 제약 | 설명 | +|--------|------|------|------| +| image_id | BIGINT | PK, AUTO_INCREMENT | 이미지 고유 ID | +| product_id | BIGINT | NOT NULL | 상품 ID (애플리케이션 레벨에서 참조 관리) | +| image_url | VARCHAR(500) | NOT NULL | 이미지 URL (S3/CDN) | +| display_order | INT | NOT NULL | 표시 순서 (1, 2, 3...) | +| created_at | TIMESTAMP | NOT NULL | 생성일시 | + +**인덱스 전략:** +| 인덱스명 | 컬럼 | 용도 | +|----------|------|------| +| PRIMARY | image_id | PK | +| idx_product_display_order | product_id, display_order | 상품별 이미지 순서대로 조회 | + +**설계 노트:** +- **display_order**: 이미지 표시 순서 (첫 번째 이미지가 썸네일) +- **복합 인덱스**: 상품별 + 순서대로 정렬하여 조회 최적화 + +**샘플 데이터:** +```sql +INSERT INTO product_images (image_id, product_id, image_url, display_order, created_at) VALUES +-- Nike Air Max 90 (product_id=1) +(1, 1, 'https://cdn.example.com/products/nike-air-max-90-1.jpg', 1, '2025-01-10 10:00:00'), +(2, 1, 'https://cdn.example.com/products/nike-air-max-90-2.jpg', 2, '2025-01-10 10:00:00'), +(3, 1, 'https://cdn.example.com/products/nike-air-max-90-3.jpg', 3, '2025-01-10 10:00:00'), + +-- Nike Dri-FIT T-Shirt (product_id=2) +(4, 2, 'https://cdn.example.com/products/nike-tshirt-1.jpg', 1, '2025-01-11 10:00:00'), + +-- iPhone 15 Pro (product_id=4) +(5, 4, 'https://cdn.example.com/products/iphone-15-pro-1.jpg', 1, '2025-01-13 10:00:00'), +(6, 4, 'https://cdn.example.com/products/iphone-15-pro-2.jpg', 2, '2025-01-13 10:00:00'); +``` + +--- + +### likes (좋아요) + +```sql +CREATE TABLE likes ( + like_id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '좋아요 ID', + user_id BIGINT NOT NULL COMMENT '사용자 ID (FK 제약 없음)', + product_id BIGINT NOT NULL COMMENT '상품 ID (FK 제약 없음)', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일시', + + -- 제약 + CONSTRAINT uk_likes_user_product UNIQUE (user_id, product_id), + + -- 인덱스 + INDEX idx_product_id (product_id), + INDEX idx_user_id (user_id), + INDEX idx_created_at (created_at DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='좋아요'; +``` + +**컬럼 설명:** +| 컬럼명 | 타입 | 제약 | 설명 | +|--------|------|------|------| +| like_id | BIGINT | PK, AUTO_INCREMENT | 좋아요 고유 ID | +| user_id | BIGINT | NOT NULL | 사용자 ID (v1: 임시 식별자, v2: users 테이블 참조) | +| product_id | BIGINT | NOT NULL | 상품 ID (애플리케이션 레벨에서 참조 관리) | +| created_at | TIMESTAMP | NOT NULL | 좋아요 생성일시 | + +**인덱스 전략:** +| 인덱스명 | 컬럼 | 용도 | +|----------|------|------| +| PRIMARY | like_id | PK | +| uk_likes_user_product | user_id, product_id | 중복 좋아요 방지 (UNIQUE) | +| idx_product_id | product_id | 상품별 좋아요 수 집계 (`COUNT(*) WHERE product_id = ?`) | +| idx_user_id | user_id | 사용자의 좋아요 목록 조회 (`WHERE user_id = ?`) | +| idx_created_at | created_at DESC | 최근 좋아요 조회 (분석용) | + +**설계 노트:** +- **UNIQUE 제약**: 사용자는 상품 1개당 좋아요 1개만 가능 +- **중복 방지**: DB 레벨에서 중복 좋아요 차단 +- **인덱스 중복**: uk_likes_user_product (UNIQUE)가 user_id로 시작하므로 idx_user_id는 선택적 + +**샘플 데이터:** +```sql +INSERT INTO likes (like_id, user_id, product_id, created_at) VALUES +-- user_id=1 +(1, 1, 1, '2025-01-15 10:00:00'), -- Nike Air Max 90 +(2, 1, 3, '2025-01-15 10:05:00'), -- Adidas Ultraboost +(3, 1, 4, '2025-01-15 10:10:00'), -- iPhone 15 Pro + +-- user_id=2 +(4, 2, 1, '2025-01-15 11:00:00'), -- Nike Air Max 90 +(5, 2, 2, '2025-01-15 11:05:00'), -- Nike T-Shirt + +-- user_id=3 +(6, 3, 4, '2025-01-15 12:00:00'); -- iPhone 15 Pro +``` + +**좋아요 수 계산 예시:** +```sql +-- product_id=1 (Nike Air Max 90): 2개 (user_id 1, 2) +-- product_id=3 (Adidas Ultraboost): 1개 (user_id 1) +-- product_id=4 (iPhone 15 Pro): 2개 (user_id 1, 3) +``` + +--- + +### users (사용자) - v2에서 추가 예정 + +```sql +-- v2에서 추가 예정 +CREATE TABLE users ( + user_id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '사용자 ID', + username VARCHAR(50) NOT NULL UNIQUE COMMENT '사용자명', + email VARCHAR(100) NOT NULL UNIQUE COMMENT '이메일', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일시', + + -- 인덱스 + INDEX idx_username (username), + INDEX idx_email (email) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='사용자'; +``` + +**설계 노트:** +- v1에서는 user_id를 임시 식별자로 사용 (헤더의 LoginId) +- v2에서 정식 회원 테이블로 전환 예정 + +--- + +## 4️⃣ 인덱스 전략 상세 + +### 인덱스 설계 원칙 + +#### 1. 조회 패턴 기반 인덱스 +| 조회 패턴 | 인덱스 | 이유 | +|----------|--------|------| +| 브랜드별 상품 목록 | products(brand_id, created_at DESC) | 복합 인덱스로 정렬까지 최적화 | +| 최신순 상품 목록 | products(created_at DESC) | 전체 상품 최신순 조회 | +| 인기순 상품 목록 | products(like_count DESC) | 좋아요 많은 순 정렬 | +| 상품별 최저가 계산 | product_options(product_id, price) | MIN(price) 집계 최적화 | +| 상품별 옵션 조회 | product_options(product_id) | WHERE product_id = ? | +| 상품별 이미지 조회 | product_images(product_id, display_order) | 순서대로 정렬 | +| 좋아요 수 집계 | likes(product_id) | COUNT(*) WHERE product_id = ? | +| 내 좋아요 목록 | likes(user_id) | WHERE user_id = ? | +| 좋아요 여부 확인 | likes(user_id, product_id) | UNIQUE 제약이 인덱스 역할 | + +--- + +#### 2. 복합 인덱스 우선순위 + +**products(brand_id, created_at DESC)** +- 단일 쿼리: `WHERE brand_id = ? ORDER BY created_at DESC` +- 커버: brand_id만 조회하는 경우도 활용 가능 +- 선택도: brand_id 먼저 → created_at 순 + +**product_options(product_id, price)** +- 단일 쿼리: `SELECT MIN(price) WHERE product_id IN (...) GROUP BY product_id` +- 집계 최적화: 인덱스만으로 MIN 계산 가능 + +**product_images(product_id, display_order)** +- 단일 쿼리: `WHERE product_id = ? ORDER BY display_order` +- 순서 보장: display_order로 정렬 + +--- + +#### 3. UNIQUE 인덱스 활용 + +| 테이블 | UNIQUE 인덱스 | 목적 | +|--------|--------------|------| +| brands | name | 브랜드명 중복 방지 + 빠른 검색 | +| product_options | (product_id, name) | 같은 상품 내 옵션명 중복 방지 | +| likes | (user_id, product_id) | 중복 좋아요 방지 + 조회 최적화 | + +**UNIQUE 인덱스의 이중 역할:** +- 데이터 무결성 보장 +- 조회 성능 최적화 (일반 인덱스로도 활용) + +--- + +#### 4. 커버링 인덱스 고려 + +**좋아요 수 집계:** +```sql +-- 인덱스: likes(product_id) +-- 커버링: product_id만 있어도 COUNT 가능 +SELECT COUNT(*) FROM likes WHERE product_id = ?; +``` + +**최저가 계산:** +```sql +-- 인덱스: product_options(product_id, price) +-- 커버링: 테이블 접근 없이 인덱스만으로 MIN 계산 +SELECT product_id, MIN(price) +FROM product_options +WHERE product_id IN (1, 2, 3) +GROUP BY product_id; +``` + +--- + +## 5️⃣ 데이터 정합성 전략 + +### 1. FK 제약 없는 설계 + +**이유:** +- 애플리케이션 레벨에서 참조 무결성 관리 +- DB 레벨 제약으로 인한 성능 오버헤드 제거 +- 향후 샤딩, 마이크로서비스 전환 시 유연성 + +**트레이드오프:** +- 고아 레코드(orphan records) 발생 가능 +- 정기적인 데이터 정합성 체크 필요 + +**보완 전략:** +```sql +-- 고아 레코드 체크 (배치 작업) +-- 1. 존재하지 않는 brand_id를 가진 products 찾기 +SELECT p.product_id, p.brand_id +FROM products p +LEFT JOIN brands b ON p.brand_id = b.brand_id +WHERE b.brand_id IS NULL; + +-- 2. 존재하지 않는 product_id를 가진 product_options 찾기 +SELECT po.option_id, po.product_id +FROM product_options po +LEFT JOIN products p ON po.product_id = p.product_id +WHERE p.product_id IS NULL; + +-- 3. 존재하지 않는 product_id를 가진 likes 찾기 +SELECT l.like_id, l.product_id +FROM likes l +LEFT JOIN products p ON l.product_id = p.product_id +WHERE p.product_id IS NULL; +``` + +--- + +### 2. 비정규화 - like_count + +**설계:** +- products 테이블에 like_count 컬럼 추가 +- 좋아요 등록/취소 시 비동기로 업데이트 +- Eventual Consistency 허용 + +**동기화 전략:** +```sql +-- 정합성 체크 (배치 작업) +SELECT + p.product_id, + p.like_count AS stored_count, + COALESCE(l.actual_count, 0) AS actual_count, + (p.like_count - COALESCE(l.actual_count, 0)) AS diff +FROM products p +LEFT JOIN ( + SELECT product_id, COUNT(*) AS actual_count + FROM likes + GROUP BY product_id +) l ON p.product_id = l.product_id +WHERE p.like_count != COALESCE(l.actual_count, 0); + +-- 불일치 수정 +UPDATE products p +INNER JOIN ( + SELECT product_id, COUNT(*) AS actual_count + FROM likes + GROUP BY product_id +) l ON p.product_id = l.product_id +SET p.like_count = l.actual_count +WHERE p.like_count != l.actual_count; + +-- 좋아요가 0개인 상품도 0으로 업데이트 +UPDATE products p +LEFT JOIN ( + SELECT product_id, COUNT(*) AS actual_count + FROM likes + GROUP BY product_id +) l ON p.product_id = l.product_id +SET p.like_count = COALESCE(l.actual_count, 0) +WHERE l.product_id IS NULL AND p.like_count != 0; +``` + +--- + +### 3. 데이터 무결성 체크 + +**필수 비즈니스 규칙:** +| 규칙 | 체크 방법 | +|------|----------| +| 상품은 최소 1개 이상의 옵션 필요 | `LEFT JOIN` + `IS NULL` 체크 | +| 가격/재고는 0 이상 | `CHECK` 제약 (MySQL 8.0.16+) | +| 같은 상품 내 옵션명 중복 불가 | `UNIQUE` 제약 | +| 사용자당 상품 좋아요 1개 | `UNIQUE` 제약 | + +**정합성 체크 쿼리:** +```sql +-- 옵션이 없는 상품 찾기 (치명적 오류) +SELECT p.product_id, p.name +FROM products p +LEFT JOIN product_options po ON p.product_id = po.product_id +WHERE po.option_id IS NULL; + +-- 가격이 음수인 옵션 찾기 +SELECT option_id, product_id, name, price +FROM product_options +WHERE price < 0; + +-- 재고가 음수인 옵션 찾기 +SELECT option_id, product_id, name, stock_quantity +FROM product_options +WHERE stock_quantity < 0; +``` + +--- + +## 6️⃣ 성능 최적화 전략 + +### 1. 쿼리 패턴별 최적화 + +**상품 목록 조회 (브랜드별 최신순)** +```sql +-- 인덱스 활용: idx_brand_created (brand_id, created_at DESC) +SELECT * FROM products +WHERE brand_id = 1 +ORDER BY created_at DESC +LIMIT 20 OFFSET 0; + +-- 실행 계획: Using index condition +``` + +**최저가 계산 (배치)** +```sql +-- 인덱스 활용: idx_product_price (product_id, price) +SELECT product_id, MIN(price) AS min_price +FROM product_options +WHERE product_id IN (1, 2, 3, 4, 5) +GROUP BY product_id; + +-- 실행 계획: Using index for group-by +``` + +**좋아요 수 집계 (배치)** +```sql +-- 인덱스 활용: idx_product_id +SELECT product_id, COUNT(*) AS like_count +FROM likes +WHERE product_id IN (1, 2, 3, 4, 5) +GROUP BY product_id; + +-- 실행 계획: Using index +``` + +--- + +### 2. 캐싱 전략 + +**Redis 캐싱 대상:** +| 데이터 | 캐시 키 | TTL | 이유 | +|--------|---------|-----|------| +| 브랜드 정보 | `brand:{brandId}` | 1시간 | 변경 빈도 낮음 | +| 상품 최저가 | `product:minPrice:{productId}` | 10분 | 집계 비용 높음 | +| 좋아요 수 | `product:likeCount:{productId}` | 5분 | 비정규화 컬럼과 이중화 | + +**캐시 무효화:** +- 상품 옵션 변경 시 → 최저가 캐시 삭제 +- 좋아요 등록/취소 시 → 좋아요 수 캐시 삭제 + +--- + +### 3. 페이지네이션 최적화 + +**Offset 방식 (현재)** +```sql +-- 문제: 깊은 페이지일수록 느림 (OFFSET 10000) +SELECT * FROM products +ORDER BY created_at DESC +LIMIT 20 OFFSET 10000; +``` + +**Cursor 방식 (향후 개선)** +```sql +-- 개선: 마지막 조회 시점 기준으로 다음 페이지 +SELECT * FROM products +WHERE created_at < '2025-01-10 10:00:00' -- 이전 페이지 마지막 시각 +ORDER BY created_at DESC +LIMIT 20; +``` + +--- + +## 7️⃣ 확장 고려사항 + +### 1. 샤딩 전략 (향후) + +**샤딩 키 후보:** +- `brand_id`: 브랜드별 샤딩 (브랜드 독립성 높음) +- `product_id % N`: 상품 ID 기반 해시 샤딩 + +**샤딩 시 고려사항:** +- FK 제약 없음 → 샤드 간 참조 가능 +- 좋아요 집계는 각 샤드에서 수행 후 병합 + +--- + +### 2. 읽기/쓰기 분리 + +**Read Replica 활용:** +- 모든 조회 쿼리 → Read Replica +- 좋아요 등록/취소, 카운트 업데이트 → Master +- Eventual Consistency 허용 + +--- + +### 3. 파티셔닝 (대용량 데이터) + +**likes 테이블 파티셔닝:** +```sql +-- created_at 기준 월별 파티셔닝 +ALTER TABLE likes PARTITION BY RANGE (YEAR(created_at) * 100 + MONTH(created_at)) ( + PARTITION p202501 VALUES LESS THAN (202502), + PARTITION p202502 VALUES LESS THAN (202503), + PARTITION p202503 VALUES LESS THAN (202504), + ... +); +``` + +**이유:** +- 오래된 좋아요 데이터는 분석용으로만 사용 +- 최근 데이터만 활발히 조회 + +--- + +## 📊 전체 테이블 요약 + +| 테이블 | 행 수 (예상) | 주요 인덱스 | 특이사항 | +|--------|-------------|------------|----------| +| brands | 수백 ~ 수천 | name(UNIQUE), brand_id | 변경 빈도 낮음, 캐싱 적합 | +| products | 수만 ~ 수십만 | brand_id, created_at, like_count | 복합 인덱스 중요 | +| product_options | products × 5~10 | product_id, (product_id, price) | 최저가 계산 최적화 필요 | +| product_images | products × 3~5 | (product_id, display_order) | CDN 활용 필수 | +| likes | 수백만 ~ 수천만 | (user_id, product_id)(UNIQUE), product_id, user_id | 파티셔닝 고려, 비정규화 | +| users | 수만 ~ 수백만 | username(UNIQUE), email(UNIQUE) | v2에서 추가 | + +--- + +**문서 끝** \ No newline at end of file diff --git "a/docs/design/\354\226\264\353\223\234\353\257\274/01-requirements.md" "b/docs/design/\354\226\264\353\223\234\353\257\274/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/01-requirements.md" similarity index 100% rename from "docs/design/\354\226\264\353\223\234\353\257\274/01-requirements.md" rename to "docs/design/\354\226\264\353\223\234\353\257\274/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/01-requirements.md" diff --git "a/docs/design/\354\226\264\353\223\234\353\257\274/02-sequence-diagrams.md" "b/docs/design/\354\226\264\353\223\234\353\257\274/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/02-sequence-diagrams.md" similarity index 100% rename from "docs/design/\354\226\264\353\223\234\353\257\274/02-sequence-diagrams.md" rename to "docs/design/\354\226\264\353\223\234\353\257\274/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/02-sequence-diagrams.md" diff --git "a/docs/design/\354\226\264\353\223\234\353\257\274/03-class-diagram.md" "b/docs/design/\354\226\264\353\223\234\353\257\274/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/03-class-diagram.md" similarity index 100% rename from "docs/design/\354\226\264\353\223\234\353\257\274/03-class-diagram.md" rename to "docs/design/\354\226\264\353\223\234\353\257\274/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/03-class-diagram.md" diff --git "a/docs/design/\354\226\264\353\223\234\353\257\274/04-erd.md" "b/docs/design/\354\226\264\353\223\234\353\257\274/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/04-erd.md" similarity index 100% rename from "docs/design/\354\226\264\353\223\234\353\257\274/04-erd.md" rename to "docs/design/\354\226\264\353\223\234\353\257\274/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/04-erd.md" diff --git "a/docs/design/\354\226\264\353\223\234\353\257\274/\354\243\274\353\254\270/01-requirements.md" "b/docs/design/\354\226\264\353\223\234\353\257\274/\354\243\274\353\254\270/01-requirements.md" new file mode 100644 index 000000000..e69de29bb diff --git "a/docs/design/\354\226\264\353\223\234\353\257\274/\354\243\274\353\254\270/02-sequence-diagrams.md" "b/docs/design/\354\226\264\353\223\234\353\257\274/\354\243\274\353\254\270/02-sequence-diagrams.md" new file mode 100644 index 000000000..e69de29bb diff --git "a/docs/design/\354\226\264\353\223\234\353\257\274/\354\243\274\353\254\270/03-class-diagram.md" "b/docs/design/\354\226\264\353\223\234\353\257\274/\354\243\274\353\254\270/03-class-diagram.md" new file mode 100644 index 000000000..e69de29bb diff --git "a/docs/design/\354\226\264\353\223\234\353\257\274/\354\243\274\353\254\270/04-erd.md" "b/docs/design/\354\226\264\353\223\234\353\257\274/\354\243\274\353\254\270/04-erd.md" new file mode 100644 index 000000000..e69de29bb diff --git "a/docs/design/\354\242\213\354\225\204\354\232\224/04-erd.md" "b/docs/design/\354\242\213\354\225\204\354\232\224/04-erd.md" index 92996c112..47f17adea 100644 --- "a/docs/design/\354\242\213\354\225\204\354\232\224/04-erd.md" +++ "b/docs/design/\354\242\213\354\225\204\354\232\224/04-erd.md" @@ -780,18 +780,3 @@ ALTER TABLE likes ADD INDEX idx_user_id (user_id); -- 최근 좋아요 조회 (현재 불필요) -- ALTER TABLE likes ADD INDEX idx_created_at (created_at DESC); ``` - ---- - -## 다음 단계 - -이제 모든 설계 문서가 완료되었습니다: -- ✅ 01-requirements.md (요구사항 분석) -- ✅ 02-sequence-diagrams.md (시퀀스 다이어그램) -- ✅ 03-class-diagram.md (클래스 다이어그램) -- ✅ 04-erd.md (ERD) - -다음 할 일: -1. 문서 검토 및 피드백 -2. 구현 시작 -3. 테스트 코드 작성 \ No newline at end of file diff --git "a/docs/design/\354\243\274\353\254\270/01-requirements.md" "b/docs/design/\354\243\274\353\254\270/01-requirements.md" new file mode 100644 index 000000000..e69de29bb diff --git "a/docs/design/\354\243\274\353\254\270/02-sequence-diagrams.md" "b/docs/design/\354\243\274\353\254\270/02-sequence-diagrams.md" new file mode 100644 index 000000000..e69de29bb diff --git "a/docs/design/\354\243\274\353\254\270/03-class-diagram.md" "b/docs/design/\354\243\274\353\254\270/03-class-diagram.md" new file mode 100644 index 000000000..e69de29bb diff --git "a/docs/design/\354\243\274\353\254\270/04-erd.md" "b/docs/design/\354\243\274\353\254\270/04-erd.md" new file mode 100644 index 000000000..e69de29bb From fe51124d51af75a743e16e4f106a7fd0e71a2aa8 Mon Sep 17 00:00:00 2001 From: katie kim <31602148+katiekim17@users.noreply.github.com> Date: Fri, 13 Feb 2026 12:58:56 +0900 Subject: [PATCH 11/39] Update User entity requirements in documentation Removed the version reference for User entity in requirements. --- .../01-requirements.md" | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git "a/docs/design/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/01-requirements.md" "b/docs/design/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/01-requirements.md" index 752faa978..e6811ba94 100644 --- "a/docs/design/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/01-requirements.md" +++ "b/docs/design/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/01-requirements.md" @@ -19,7 +19,7 @@ Product (상품) ├─→ ProductImage (상품 이미지) : 1:N [product_id로 참조] └─→ Like (좋아요) : 1:N [product_id로 참조] -User (사용자) - v2에서 추가 예정 +User (사용자) └─→ Like (좋아요) : 1:N [user_id로 참조] ``` @@ -533,4 +533,4 @@ User (사용자) - v2에서 추가 예정 --- -**문서 끝** \ No newline at end of file +**문서 끝** From f97bc157b15331e128bf709cafac6ea83aa8e968 Mon Sep 17 00:00:00 2001 From: katie kim <31602148+katiekim17@users.noreply.github.com> Date: Fri, 13 Feb 2026 13:46:20 +0900 Subject: [PATCH 12/39] Update 01-requirements.md --- .../01-requirements.md" | 24 ------------------- 1 file changed, 24 deletions(-) diff --git "a/docs/design/\354\242\213\354\225\204\354\232\224/01-requirements.md" "b/docs/design/\354\242\213\354\225\204\354\232\224/01-requirements.md" index 48f2248a6..a860d8463 100644 --- "a/docs/design/\354\242\213\354\225\204\354\232\224/01-requirements.md" +++ "b/docs/design/\354\242\213\354\225\204\354\232\224/01-requirements.md" @@ -187,27 +187,3 @@ Authorization: Required - Redis 도입 (카운트 캐싱) - 친구/타인의 좋아요 목록 조회 기능 - 좋아요 기반 추천 시스템 - ---- - -## 6️⃣ 다음 단계에서 다룰 내용 - -1. **시퀀스 다이어그램** - - 좋아요 등록 시 트랜잭션 경계 확인 - - 이벤트 발행 및 비동기 처리 흐름 - - 실패 시나리오 - -2. **클래스 다이어그램** - - Product, Like, User 간의 관계 - - 도메인 책임 분리 - - 의존 방향 - -3. **ERD** - - 테이블 구조 및 관계 - - Unique 제약 - - 인덱스 전략 - -4. **설계 리스크 분석** - - 이벤트 실패 시 정합성 불일치 리스크 - - 배치 복구 전략의 한계 - - 향후 확장 시 고려사항 \ No newline at end of file From dd412da5c467272b10be539cfc9f9e4cffa45687 Mon Sep 17 00:00:00 2001 From: katiekim17 Date: Fri, 13 Feb 2026 23:20:24 +0900 Subject: [PATCH 13/39] =?UTF-8?q?feat:=20=EB=AC=B8=EC=84=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/design/01-requirements.md | 163 ++- docs/design/02-sequence-diagrams.md | 86 -- docs/design/03-class-diagram.md | 884 +++++++++--- docs/design/04-erd.md | 261 ++-- .../01-requirements.md" | 168 +-- .../02-sequence-diagrams.md" | 132 +- .../03-class-diagram.md" | 1179 ----------------- .../04-erd.md" | 792 ----------- .../01-requirements.md" | 57 - .../04-erd.md" | 432 ------ .../03-class-diagram.md" | 0 .../\354\243\274\353\254\270/04-erd.md" | 0 .../01-requirements.md" | 123 +- .../02-sequence-diagrams.md" | 184 ++- .../03-class-diagram.md" | 723 ---------- .../04-erd.md" | 782 ----------- .../03-class-diagram.md" | 0 .../\354\243\274\353\254\270/04-erd.md" | 0 18 files changed, 1161 insertions(+), 4805 deletions(-) delete mode 100644 docs/design/02-sequence-diagrams.md delete mode 100644 "docs/design/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/03-class-diagram.md" delete mode 100644 "docs/design/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/04-erd.md" delete mode 100644 "docs/design/\354\226\264\353\223\234\353\257\274/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/04-erd.md" delete mode 100644 "docs/design/\354\226\264\353\223\234\353\257\274/\354\243\274\353\254\270/03-class-diagram.md" delete mode 100644 "docs/design/\354\226\264\353\223\234\353\257\274/\354\243\274\353\254\270/04-erd.md" delete mode 100644 "docs/design/\354\242\213\354\225\204\354\232\224/03-class-diagram.md" delete mode 100644 "docs/design/\354\242\213\354\225\204\354\232\224/04-erd.md" delete mode 100644 "docs/design/\354\243\274\353\254\270/03-class-diagram.md" delete mode 100644 "docs/design/\354\243\274\353\254\270/04-erd.md" diff --git a/docs/design/01-requirements.md b/docs/design/01-requirements.md index d52c9d146..1ea070cdb 100644 --- a/docs/design/01-requirements.md +++ b/docs/design/01-requirements.md @@ -1,69 +1,94 @@ -주문 생성 시스템 - 요구사항 분석 및 설계 │ -│ │ -│ Context │ -│ │ -│ 현재 시스템에는 Example, Member 도메인만 존재하며, 커머스의 핵심인 상품/재고/주문 도메인이 없다. │ -│ "여러 상품을 한 번에 주문할 때, 재고와 주문 상태 사이의 불일치가 발생하지 않도록" All-or-Nothing 정책으로 주문 생성 기능을 구현해야 │ -│ 한다. │ -│ │ -│ --- │ -│ 1️⃣문제 상황 재해석 │ -│ │ -│ 사용자 관점: 여러 상품을 한 번에 주문했는데 일부만 처리되면 혼란. 되거나/안 되거나 명확한 결과를 기대한다. │ -│ │ -│ 비즈니스 관점: 재고-주문 불일치는 클레임 직결. 재고 확인과 주문 생성이 원자적으로 묶여야 하며, 향후 부분취소 확장도 고려해야 한다. │ -│ │ -│ 시스템 관점: Order + OrderItem 생성 + 재고 차감이 단일 트랜잭션으로 처리되어야 한다. FK 없이 ID 참조로 느슨한 결합 유지, 스냅샷으로 시점 │ -│ 정합성 보장. │ -│ │ -│ --- │ -│ 2️⃣합의된 설계 결정 │ -│ ┌─────────────────────┬─────────────────────────────────────────────────────────────────────────────┐ │ -│ │ 항목 │ 결정 │ │ -│ ├─────────────────────┼─────────────────────────────────────────────────────────────────────────────┤ │ -│ │ Product/Stock/Brand │ 주문과 함께 새로 생성 │ │ -│ ├─────────────────────┼─────────────────────────────────────────────────────────────────────────────┤ │ -│ │ Product 구조 │ Product + Brand 별도 엔티티 분리 │ │ -│ ├─────────────────────┼─────────────────────────────────────────────────────────────────────────────┤ │ -│ │ Stock 구조 │ 별도 Stock 엔티티 (productId, quantity) │ │ -│ ├─────────────────────┼─────────────────────────────────────────────────────────────────────────────┤ │ -│ │ OrderStatus │ Enum 미리 정의 (CREATED, CONFIRMED, CANCELLED), 전이 로직은 CREATED까지만 │ │ -│ ├─────────────────────┼─────────────────────────────────────────────────────────────────────────────┤ │ -│ │ OrderItemStatus │ Enum 미리 정의 (ORDERED, CANCELLED) — 부분취소 확장성 │ │ -│ ├─────────────────────┼─────────────────────────────────────────────────────────────────────────────┤ │ -│ │ 동시성 제어 │ 비관적 락 (SELECT FOR UPDATE on Stock) │ │ -│ ├─────────────────────┼─────────────────────────────────────────────────────────────────────────────┤ │ -│ │ 인증 │ 기존 X-Loopers-LoginId 헤더 인증 │ │ -│ ├─────────────────────┼─────────────────────────────────────────────────────────────────────────────┤ │ -│ │ FK 제약조건 │ 없음, ID 참조만 │ │ -│ ├─────────────────────┼─────────────────────────────────────────────────────────────────────────────┤ │ -│ │ Value Objects │ Money (가격/금액), Quantity (수량), ProductSnapshot (주문 시점 상품 스냅샷) │ │ -│ ├─────────────────────┼─────────────────────────────────────────────────────────────────────────────┤ │ -│ │ 도메인 서비스 │ StockDeductionService — 재고 차감 검증 로직 분리 │ │ -│ └─────────────────────┴─────────────────────────────────────────────────────────────────────────────┘ │ -│ --- │ -│ 3️⃣개념 모델 │ -│ │ -│ 액터 │ -│ - 사용자 (회원): 주문을 생성하는 주체 │ -│ - 관리자 (미구현): 상품/브랜드/재고를 관리하는 주체 (테스트 데이터로 대체) │ -│ │ -│ 핵심 도메인 │ -│ - Order (주문): 주문 생성, 상태 관리 │ -│ - OrderItem (주문 항목): 개별 상품 주문 정보 + ProductSnapshot VO │ -│ - Stock (재고): 재고 수량 관리, 동시성 제어 대상 │ -│ │ -│ 보조 도메인 │ -│ - Product (상품): 상품 정보 제공 │ -│ - Brand (브랜드): 브랜드 정보 제공 │ -│ - Member (회원): 인증, 주문자 식별 (기존 구현) │ -│ │ -│ Value Objects │ -│ - Money: 가격/금액을 감싸는 VO. value >= 0 불변식 보장. Product.price, Order.totalAmount, ProductSnapshot.price에 사용 │ -│ - Quantity: 수량을 감싸는 VO. value > 0 불변식 보장. OrderItem.quantity에 사용 │ -│ - ProductSnapshot: 주문 시점 상품 정보(@Embeddable). productName, price(Money), brandName을 묶어 OrderItem에 내장 │ -│ │ -│ 도메인 서비스 │ -│ - StockDeductionService: 여러 Stock 엔티티에 걸친 All-or-Nothing 재고 차감을 담당. 단일 엔티티에 속하지 않는 크로스 엔티티 비즈니스 │ -│ 로직. │ -│ \ No newline at end of file +# 감성 이커머스 시스템 요구사항 명세서 (v1) + +## 1️⃣ 도메인 구조 + +### v1 핵심 도메인 +| 도메인 | 책임 | 주요 엔티티 | +|---------|------|------------| +| **브랜드** | 브랜드 정보 관리 | Brand | +| **상품** | 상품 카탈로그 관리 | Product, ProductOption, ProductImage | +| **좋아요** | 사용자 관심 상품 관리 | Like | +| **주문** | 주문 생성 및 재고 예약 관리 | Order, OrderItem, Stock | + +### 도메인 간 관계 +``` +Brand (브랜드) + └─→ Product (상품) : 1:N [brand_id로 참조] + +Product (상품) + ├─→ ProductOption (상품 옵션) : 1:N [product_id로 참조] + ├─→ ProductImage (상품 이미지) : 1:N [product_id로 참조] + └─→ Like (좋아요) : 1:N [product_id로 참조] + +ProductOption (상품 옵션) + └─→ Stock (재고) : 1:1 [product_option_id로 참조] + +User (사용자) + ├─→ Like (좋아요) : 1:N [user_id로 참조] + └─→ Order (주문) : 1:N [user_id로 참조] + +Order (주문) + └─→ OrderItem (주문 항목) : 1:N [order_id로 참조] + +``` + +### 핵심 개념 +- **Brand**: 상품을 제공하는 브랜드 +- **Product**: 브랜드가 판매하는 상품의 기본 정보 및 판매 상태 관리 + - 상태 기반 관리 (DRAFT / ACTIVE / INACTIVE) + - 삭제 불가 + - ACTIVE는 운영자 수동 전이 +- **ProductOption**: 상품의 판매 단위 + - 상태 보유 (ON_SALE / SOLD_OUT / STOPPED) + - 재고는 옵션 단위 + - 삭제 불가 + - 구매 조건: + - Product ACTIVE + - Option ON_SALE + - stock > 0 +- **Stock**: 옵션 단위 재고 관리 + - stock_quantity : 실제 보유 재고 + - reserved_quantity : 잠가 둔 재고 + - available : 현재 주문 가능한 수량 +- **ProductImage**: 상품의 이미지들 (여러 장 가능, product_id 보유) +- **Like**: 사용자의 상품 좋아요 + - Unique(user_id, product_id) + - Product ACTIVE일 때만 등록 가능 + - 취소는 멱등 + - Product INACTIVE 시 기존 Like는 유지 +- **Order**: 사용자의 주문 + - 상태 기반 관리 (CREATED / CONFIRMED / CANCELLED) + - 예약 재고 기반 처리 + - All-or-Nothing 정책 적용 + **OrderItem**: 주문 시점의 상품 정보 스냅샷 보존 + - productName + - brandName + - optionName + - optionAttributes + - thumbnailImageUrl + - orderPrice (Money) + - quantity (Quantity) + + +### 상품 및 주문 시스템 설계 원칙 +**1. 데이터 연결 및 무결성 (No Foreign Key)** +- **ID 기반 참조**: 데이터베이스 강제 연결(FK) 대신, 서비스 로직에서 상품과 옵션을 연결합니다. +- **유연한 관리**: 시스템이 멈추지 않고 유연하게 데이터를 처리하며, 데이터 간의 정합성은 애플리케이션 서비스 단계에서 꼼꼼히 체크합니다. + +**2. 상태 중심 운영 (State Control)** +- **상태로 판매 통제**: 상품의 판매 가능 여부는 '판매중', '품절', '중지' 등 상태값으로 관리합니다. +- **수동 제어 우선**: 시스템 자동 처리보다는 운영자가 직접 상황에 맞춰 판매를 제어할 수 있도록 설계하여 운영의 묘를 살립니다. + +**3. 데이터 보존 (Soft Delete)** +- **삭제 금지**: 브랜드, 상품, 옵션 데이터는 절대 삭제하지 않습니다. +- **히스토리 유지**: 상품이 품절되거나 사라져도 과거의 주문 내역이나 고객의 '좋아요' 기록은 그대로 보존하여 데이터 추적성을 확보합니다. + +**4. 데이터 일관성 전략 (Consistency)** +- **재고와 주문 (절대 일관성)**: 결제와 재고 차감은 **'모두 성공하거나 모두 실패'**해야 합니다. 수량 오차를 0으로 유지합니다. +- **좋아요 수 (점진적 반영)**: 수치는 실시간으로 아주 약간의 오차가 있을 수 있으나, 시스템 부하를 줄이기 위해 비동기 방식으로 빠르게 업데이트합니다. + +### 핵심 설계 의도 +- **옵션 중심 관리**: 같은 상품이라도 옵션(사이즈, 색상 등)에 따라 가격과 재고가 다를 수 있음 +- **판매의 실질 단위**: 고객이 구경하는 것은 '상품'이지만, 실제로 장바구니에 담고 **결제하는 단위는 '옵션'** +- 좋아요는 **상품 단위**로 관리 (옵션 단위 아님) +- **FK 제약 없음**: 애플리케이션 레벨에서 참조 무결성 관리 diff --git a/docs/design/02-sequence-diagrams.md b/docs/design/02-sequence-diagrams.md deleted file mode 100644 index 8a02999b3..000000000 --- a/docs/design/02-sequence-diagrams.md +++ /dev/null @@ -1,86 +0,0 @@ -️⃣시퀀스 다이어그램 │ -│ │ -│ 왜 필요한가 │ -│ │ -│ 주문 생성은 Member, Product, Brand, Stock, Order, OrderItem 6개 도메인을 횡단한다. 호출 순서, 트랜잭션 경계, All-or-Nothing 실패 시점을 │ -│ 검증해야 한다. │ -│ │ -│ 검증 포인트 │ -│ │ -│ - 트랜잭션 경계가 어디서 시작하고 끝나는지 │ -│ - StockDeductionService의 책임 범위 │ -│ - 비관적 락 획득 순서 (데드락 방지) │ -│ - 재고 부족 시 롤백 시점 │ -│ │ -│ sequenceDiagram │ -│ actor User │ -│ participant Controller as OrderV1Controller │ -│ participant Facade as OrderFacade │ -│ participant MemberSvc as MemberService │ -│ participant ProductSvc as ProductService │ -│ participant OrderSvc as OrderService │ -│ participant StockDeduct as StockDeductionService │ -│ participant StockRepo as StockRepository │ -│ participant DB as Database │ -│ │ -│ User->>Controller: POST /api/v1/orders
[Header: X-Loopers-LoginId/LoginPw]
[Body: items[{productId, quantity}]] │ -│ Controller->>Facade: createOrder(loginId, password, request) │ -│ │ -│ Note over Facade: 트랜잭션 밖: 인증 + 조회 │ -│ Facade->>MemberSvc: authenticate(loginId, password) │ -│ MemberSvc-->>Facade: MemberModel (memberId) │ -│ │ -│ Facade->>ProductSvc: getProducts(productIds) │ -│ ProductSvc-->>Facade: List │ -│ Facade->>ProductSvc: getBrands(brandIds) │ -│ ProductSvc-->>Facade: List │ -│ │ -│ alt 존재하지 않는 상품/브랜드 │ -│ Facade-->>Controller: CoreException(NOT_FOUND) │ -│ Controller-->>User: 404 Not Found │ -│ end │ -│ │ -│ Note over Facade,DB: ── 트랜잭션 시작 (OrderService.createOrder) ── │ -│ │ -│ Facade->>OrderSvc: createOrder(memberId, products, brandMap, items) │ -│ │ -│ OrderSvc->>StockDeduct: deductAll(items) │ -│ Note over StockDeduct: productId 오름차순 정렬 → 데드락 방지 │ -│ loop 각 상품 (productId 오름차순) │ -│ StockDeduct->>StockRepo: findByProductIdWithLock(productId) │ -│ StockRepo->>DB: SELECT ... FOR UPDATE │ -│ DB-->>StockRepo: StockModel (locked) │ -│ StockRepo-->>StockDeduct: StockModel │ -│ │ -│ alt 재고 부족 │ -│ Note over StockDeduct,DB: 예외 → 트랜잭션 롤백 (All-or-Nothing) │ -│ StockDeduct-->>OrderSvc: CoreException(BAD_REQUEST) │ -│ OrderSvc-->>Facade: 예외 전파 │ -│ Facade-->>Controller: 예외 전파 │ -│ Controller-->>User: 400 Bad Request │ -│ end │ -│ │ -│ StockDeduct->>StockDeduct: stock.deduct(Quantity) │ -│ end │ -│ StockDeduct-->>OrderSvc: 차감 완료 │ -│ │ -│ Note over OrderSvc: 주문 생성 │ -│ OrderSvc->>OrderSvc: new OrderModel(memberId, Money(totalAmount), CREATED) │ -│ OrderSvc->>DB: INSERT orders │ -│ │ -│ loop 각 주문 항목 │ -│ OrderSvc->>OrderSvc: new OrderItemModel(orderId, productId,
ProductSnapshot, Quantity, ORDERED) │ -│ end │ -│ OrderSvc->>DB: INSERT order_item × N │ -│ │ -│ Note over Facade,DB: ── 트랜잭션 커밋 ── │ -│ │ -│ OrderSvc-->>Facade: OrderModel + OrderItems │ -│ Facade-->>Controller: OrderInfo │ -│ Controller-->>User: 201 Created + OrderResponse │ -│ │ -│ 읽는 법 │ -│ │ -│ - Facade는 조율자: 인증/조회는 트랜잭션 밖에서 처리하여 핵심 쓰기 트랜잭션 범위를 최소화. │ -│ - StockDeductionService가 재고 책임: 락 획득 순서, 재고 검증, 차감을 모두 담당. OrderService는 이 결과를 신뢰하고 주문만 생성. │ -│ - 실패 시점이 명확: 재고 부족이면 StockDeductionService에서 즉시 예외 → 전체 롤백. \ No newline at end of file diff --git a/docs/design/03-class-diagram.md b/docs/design/03-class-diagram.md index a4c00279e..80d6ef41e 100644 --- a/docs/design/03-class-diagram.md +++ b/docs/design/03-class-diagram.md @@ -1,150 +1,734 @@ -5️⃣클래스 다이어그램 │ -│ │ -│ 왜 필요한가 │ -│ │ -│ 5개 엔티티 + 3개 VO + 도메인 서비스의 책임 분배, 의존 방향을 검증해야 한다. │ -│ │ -│ 검증 포인트 │ -│ │ -│ - VO가 어떤 엔티티에서 사용되는지 │ -│ - StockDeductionService의 위치와 의존 방향 │ -│ - OrderService와 StockDeductionService의 책임 경계 │ -│ │ -│ classDiagram │ -│ direction TB │ -│ │ -│ class BaseEntity { │ -│ <> │ -│ #Long id │ -│ #ZonedDateTime createdAt │ -│ #ZonedDateTime updatedAt │ -│ #ZonedDateTime deletedAt │ -│ +delete() │ -│ +restore() │ -│ } │ -│ │ -│ class Money { │ -│ <> │ -│ -Long value │ -│ +Money(Long value) │ -│ +getValue() Long │ -│ } │ -│ note for Money "불변식: value >= 0\nProduct.price, Order.totalAmount,\nProductSnapshot.price에 사용" │ -│ │ -│ class Quantity { │ -│ <> │ -│ -Long value │ -│ +Quantity(Long value) │ -│ +getValue() Long │ -│ } │ -│ note for Quantity "불변식: value > 0\nOrderItem.quantity에 사용" │ -│ │ -│ class ProductSnapshot { │ -│ <> │ -│ -String productName │ -│ -Money price │ -│ -String brandName │ -│ +ProductSnapshot(productName, price, brandName) │ -│ } │ -│ note for ProductSnapshot "주문 시점 상품 정보 스냅샷\nOrderItem에 @Embedded로 내장" │ -│ │ -│ class BrandModel { │ -│ -String name │ -│ +BrandModel(name) │ -│ } │ -│ │ -│ class ProductModel { │ -│ -Long brandId │ -│ -String name │ -│ -Money price │ -│ -String description │ -│ +ProductModel(brandId, name, price, description) │ -│ } │ -│ │ -│ class StockModel { │ -│ -Long productId │ -│ -Long quantity │ -│ +StockModel(productId, quantity) │ -│ +deduct(Quantity amount) void │ -│ +hasEnoughStock(Quantity amount) boolean │ -│ } │ -│ │ -│ class OrderModel { │ -│ -Long memberId │ -│ -OrderStatus status │ -│ -Money totalAmount │ -│ +OrderModel(memberId, totalAmount, status) │ -│ } │ -│ │ -│ class OrderItemModel { │ -│ -Long orderId │ -│ -Long productId │ -│ -OrderItemStatus status │ -│ -ProductSnapshot snapshot │ -│ -Quantity quantity │ -│ +OrderItemModel(orderId, productId, status, snapshot, quantity) │ -│ } │ -│ │ -│ class OrderStatus { │ -│ <> │ -│ CREATED │ -│ CONFIRMED │ -│ CANCELLED │ -│ } │ -│ │ -│ class OrderItemStatus { │ -│ <> │ -│ ORDERED │ -│ CANCELLED │ -│ } │ -│ │ -│ BaseEntity <|-- BrandModel │ -│ BaseEntity <|-- ProductModel │ -│ BaseEntity <|-- StockModel │ -│ BaseEntity <|-- OrderModel │ -│ BaseEntity <|-- OrderItemModel │ -│ │ -│ ProductModel --> Money : price │ -│ OrderModel --> Money : totalAmount │ -│ OrderModel --> OrderStatus │ -│ OrderItemModel --> ProductSnapshot : snapshot │ -│ OrderItemModel --> Quantity : quantity │ -│ OrderItemModel --> OrderItemStatus │ -│ ProductSnapshot --> Money : price │ -│ │ -│ class StockDeductionService { │ -│ <<도메인 서비스>> │ -│ +deductAll(List~OrderItemRequest~ items) void │ -│ } │ -│ note for StockDeductionService "크로스 엔티티 비즈니스 로직\nproductId 오름차순 락 획득\nAll-or-Nothing 재고 차감" │ -│ │ -│ class OrderFacade { │ -│ +createOrder(loginId, password, request) OrderInfo │ -│ } │ -│ class OrderService { │ -│ +createOrder(memberId, products, brandMap, items) OrderModel │ -│ } │ -│ class ProductService { │ -│ +getProducts(productIds) List~ProductModel~ │ -│ +getBrands(brandIds) List~BrandModel~ │ -│ } │ -│ │ -│ OrderFacade --> MemberService │ -│ OrderFacade --> ProductService │ -│ OrderFacade --> OrderService │ -│ OrderService --> StockDeductionService │ -│ OrderService ..> OrderRepository │ -│ OrderService ..> OrderItemRepository │ -│ StockDeductionService ..> StockRepository │ -│ ProductService ..> ProductRepository │ -│ ProductService ..> BrandRepository │ -│ │ -│ 읽는 법 │ -│ │ -│ - VO 적용 범위가 명확: Money는 가격/금액이 나오는 3곳, Quantity는 주문 수량, ProductSnapshot은 OrderItem 내 스냅샷. 각각 명확한 불변식을 │ -│ 가진다. │ -│ - StockDeductionService: OrderService에서 분리된 도메인 서비스. "여러 Stock에 걸친 원자적 차감"이라는 단일 엔티티에 속하지 않는 책임을 │ -│ 담당. │ -│ - StockModel.deduct(): 실제 차감 로직은 엔티티 내부에 유지. 도메인 서비스는 "어떤 순서로, 어떤 조건에서" 차감할지를 조율. │ -│ - StockModel.quantity는 Long: 재고는 0이 될 수 있으므로 Quantity VO(>0)를 쓰지 않고 Long으로 유지. deduct()의 파라미터만 Quantity VO. │ -│ \ No newline at end of file +# 클래스 다이어그램 (Class Diagram) + +## 1️⃣ 전체 아키텍처 개요 + +### 레이어 구조 +``` +┌─────────────────────────────────────────┐ +│ Presentation Layer │ +│ (Controller) │ +└─────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────┐ +│ Application Layer │ +│ (Facade - 도메인 서비스 조율) │ +└─────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────┐ +│ Domain Layer │ +│ (Service, Entity) │ +└─────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────┐ +│ Infrastructure Layer │ +│ (Repository) │ +└─────────────────────────────────────────┘ + + +상세 +Presentation Layer +├── ProductController +├── LikeController +└── OrderController + +Application Layer +├── ProductFacade +└── OrderFacade ← 주문 유스케이스 조율 + +Domain Layer +├── Brand, Product, ProductOption, Like +├── Order, OrderItem, Stock +├── OrderService +├── StockDeductionService +└── VO (Money, Quantity, Snapshot) + +Infrastructure Layer +├── ProductRepository +├── LikeRepository +├── OrderRepository +└── StockRepository +``` +--- + +## 2️⃣ 전체 클래스 다이어그램 + +```mermaid +classDiagram + %% ============================================ + %% Presentation Layer (Controllers) + %% ============================================ + class BrandController { + -BrandService brandService + +getBrand(brandId: Long) BrandResponse + } + + class ProductController { + -ProductFacade productFacade + +getProducts(brandId: Long, sort: String, page: int, size: int, headers) Page~ProductListResponse~ + +getProductDetail(productId: Long, headers) ProductDetailResponse + -extractUserId(headers) Long + } + + class LikeController { + -LikeService likeService + +createLike(productId: Long, headers) void + +deleteLike(productId: Long, headers) void + +getMyLikes(headers, page:int, size:int) Page~MyLikeResponse~ + -extractUserId(headers) Long + } + + %% ============================================ + %% Application Layer (Facades) + %% ============================================ + class ProductFacade { + -ProductService productService + -ProductOptionService productOptionService + -ProductImageService productImageService + -LikeService likeService + +getProducts(brandId: Long, sort: String, page:int, size:int, userId: Long) Page~ProductListResponse~ + +getProductDetail(productId: Long, userId: Long) ProductDetailResponse + } + + %% ============================================ + %% Domain Layer (Services) + %% ============================================ + class BrandService { + -BrandRepository brandRepository + +getBrand(brandId: Long) Brand + } + + class ProductService { + -ProductRepository productRepository + +findActiveProducts(brandId: Long, sort: String, page:int, size:int) Page~Product~ + +findActiveProduct(productId: Long) Product + +findLikeCounts(productIds: List~Long~) Map~Long, Integer~ + +getLikeCount(productId: Long) Integer + +validateProductActive(productId: Long) void + } + + class ProductOptionService { + -ProductOptionRepository productOptionRepository + +findOptions(productId: Long) List~ProductOption~ + +calculateMinPrices(productIds: List~Long~) Map~Long, Integer~ + } + + class ProductImageService { + -ProductImageRepository productImageRepository + +findImages(productId: Long) List~ProductImage~ + } + + class LikeService { + -LikeRepository likeRepository + -ProductService productService + -EventPublisher eventPublisher + +createLike(userId: Long, productId: Long) void + +deleteLike(userId: Long, productId: Long) void + +findMyLikes(userId: Long, page:int, size:int) Page~MyLikeResponse~ + +checkLikedByUser(userId: Long, productId: Long) boolean + +findLikedProductIds(userId: Long, productIds: List~Long~) Set~Long~ + } + + %% ============================================ + %% Domain Layer (Entities / Enums) + %% ============================================ + class Brand { + -Long id + -String name + -String description + -String logoImageUrl + -LocalDateTime createdAt + } + + class Product { + -Long id + -Long brandId + -String name + -String description + -String thumbnailImageUrl + -ProductStatus status + -int likeCount %% products.like_count (집계값) + -LocalDateTime createdAt + } + + class ProductStatus { + <> + DRAFT + ACTIVE + INACTIVE + } + + class ProductOption { + -Long id + -Long productId + -String name + -int price + -int stockQuantity + -ProductOptionStatus status + -LocalDateTime createdAt + } + + class ProductOptionStatus { + <> + ON_SALE + SOLD_OUT + STOPPED + } + + class ProductImage { + -Long id + -Long productId + -String imageUrl + -int displayOrder + } + + class Like { + -Long id + -Long userId + -Long productId + -LocalDateTime createdAt + %% Unique(userId, productId) + } + + %% ============================================ + %% Infrastructure Layer (Repositories) + %% ============================================ + class BrandRepository { + <> + +findById(brandId: Long) Optional~Brand~ + } + + class ProductRepository { + <> + +findActiveById(productId: Long) Optional~Product~ + +findAllActive(brandId: Long, sort: String, page:int, size:int) Page~Product~ + +findLikeCountsByIds(productIds: List~Long~) Map~Long, Integer~ %% products.like_count batch + +findLikeCountById(productId: Long) Integer + +incrementLikeCount(productId: Long) void + +decrementLikeCount(productId: Long) void + } + + class ProductOptionRepository { + <> + +findByProductId(productId: Long) List~ProductOption~ + +findMinPricesByProductIds(productIds: List~Long~) Map~Long, Integer~ + } + + class ProductImageRepository { + <> + +findByProductId(productId: Long) List~ProductImage~ + } + + class LikeRepository { + <> + +save(like: Like) Like + +findByUserIdAndProductId(userId: Long, productId: Long) Optional~Like~ + +deleteById(likeId: Long) void + +findLikedProductIds(userId: Long, productIds: List~Long~) Set~Long~ + +findByUserId(userId: Long, page:int, size:int) Page~Like~ + %% countByProductId(s)는 사용하지 않음 (like_count는 Product에 저장) + } + + %% ============================================ + %% Async / Batch (Eventual Consistency) + %% ============================================ + class EventPublisher { + <> + +publish(event) void + } + + class LikeCreatedEvent { + -Long userId + -Long productId + -LocalDateTime occurredAt + } + + class LikeDeletedEvent { + -Long userId + -Long productId + -LocalDateTime occurredAt + } + + class LikeCountConsumer { + -ProductRepository productRepository + +handle(event) void + } + + class LikeCountReconcileBatch { + -LikeRepository likeRepository + -ProductRepository productRepository + +reconcile() void + } + + %% ============================================ + %% Exceptions (minimal) + %% ============================================ + class BusinessException { + <> + -String errorCode + -String message + } + + class ProductNotFoundException + class BrandNotFoundException + class DuplicateLikeException + + BusinessException <|-- ProductNotFoundException + BusinessException <|-- BrandNotFoundException + BusinessException <|-- DuplicateLikeException + + %% ============================================ + %% Relationships + %% ============================================ + BrandController ..> BrandService : uses + ProductController ..> ProductFacade : uses + LikeController ..> LikeService : uses + + ProductFacade ..> ProductService : uses + ProductFacade ..> ProductOptionService : uses + ProductFacade ..> ProductImageService : uses + ProductFacade ..> LikeService : uses + + BrandService ..> BrandRepository : uses + ProductService ..> ProductRepository : uses + ProductOptionService ..> ProductOptionRepository : uses + ProductImageService ..> ProductImageRepository : uses + LikeService ..> LikeRepository : uses + LikeService ..> ProductService : validates ACTIVE + LikeService ..> EventPublisher : publishes + + LikeCountConsumer ..> ProductRepository : updates like_count + LikeCountReconcileBatch ..> LikeRepository : reads likes + LikeCountReconcileBatch ..> ProductRepository : fixes like_count + + Brand "1" --> "N" Product : has + Product "1" --> "N" ProductOption : has + Product "1" --> "N" ProductImage : has + Product "1" --> "N" Like : receives +``` + +--- + +## 3️⃣ 레이어별 상세 설계 + +### Presentation Layer (Controller) + +#### BrandController + +**책임:** +- HTTP 요청 처리 +- Path Variable 추출 +- 응답 DTO 변환 +- HTTP 상태 코드 반환 + +**예외 처리:** +- BrandNotFoundException → 404 Not Found + +--- + +#### ProductController + +**책임:** +- HTTP 요청 처리 +- Query Parameter, Path Variable, Header 추출 +- 인증 정보 추출 (userId) +- Facade 호출 +- 응답 DTO 반환 + +--- + +### Application Layer (Facade) + +#### ProductFacade + +**책임:** +- 여러 도메인 서비스 조율 (orchestration) +- 병렬 처리 가능한 작업 조율 +- 데이터 조합 및 응답 DTO 구성 +- 로그인 여부에 따른 분기 처리 +- 비즈니스 규칙 검증 (옵션 존재 여부) + +**예외 처리:** +- ProductNotFoundException (ProductService에서 전파) +- ProductOptionNotFoundException (옵션이 없을 때) + + +#### OrderFacade + +**책임:** “주문 생성” 유스케이스를 끝까지 완주시키기 + +**하는 일:** +- 로그인 인증으로 memberId 확보 +- 요청에서 optionIds 추출 +- ProductService로 옵션/상품/브랜드 조회 +- ProductSnapshot 만들어서 OrderItem 재료 준비 +- OrderService 호출해서 “예약 주문 생성” 실행 +- 결과를 응답 DTO로 매핑 + +--- + +### Domain Layer (Services) + +#### BrandService + +**책임:** +- 브랜드 도메인 비즈니스 로직 +- 브랜드 조회 + +**예외:** +- BrandNotFoundException + +--- + +#### ProductService + +**책임:** +- 상품 도메인 비즈니스 로직 +- 상품 조회 (목록, 상세) + +**예외:** +- ProductNotFoundException + +--- + +#### ProductOptionService + +**책임:** +- 상품 옵션 도메인 비즈니스 로직 +- 옵션 조회 +- 최저가 계산 (집계 쿼리) +- 가격/재고 검증 + +**예외:** +- BusinessRuleViolationException (가격 음수, 재고 음수 등) + +--- + +#### ProductImageService + +**책임:** +- 상품 이미지 도메인 비즈니스 로직 +- 이미지 조회 +- 이미지 리소스 존재 검증 + +**예외:** +- ImageNotFoundException + +--- + +#### LikeService + +**책임:** +- 좋아요 도메인 비즈니스 로직 +- 좋아요 수 조회 (단일, 배치) +- 좋아요 여부 확인 (단일, 배치) +- 좋아요 등록/취소 + +**예외:** +- DuplicateLikeException + +#### OrderService + +**책임:** Order Aggregate 생성/상태 전이 같은 도메인 규칙 + +**하는 일:** +- OrderModel 생성 (status=CREATED) + +- OrderItemModel 생성 + +- StockDeductionService.reserveAll() 호출 + +- 저장 + +#### StockDeductionService + +**책임:** “여러 Stock에 걸친 원자적 예약/확정/해제” (크로스 엔티티 규칙) + + +--- + +### Domain Layer (Entities) + +#### Brand + +**설계 포인트:** +- 불변 객체 지향 (Setter 없음) +- 생성자를 통한 필수 값 주입 +- JPA 기본 생성자는 protected + +--- + +#### Product + +**설계 포인트:** +- brandId는 Long 타입 (FK 제약 없음) +- 생성자에서 비즈니스 규칙 검증 +- 도메인 무결성은 애플리케이션 레벨에서 관리 + +--- + +#### ProductOption + +**설계 포인트:** +- 가격, 재고 검증 로직 포함 +- `isAvailable()` 비즈니스 메서드 +- Unique 제약: 같은 상품 내 옵션명 중복 불가 + +--- + +#### ProductImage + +**설계 포인트:** +- displayOrder로 이미지 순서 관리 +- 실제 이미지 파일은 S3/CDN에 저장, URL만 DB에 보관 + +--- + +#### Like + +**설계 포인트:** +- Unique 제약: 사용자당 상품 1개만 좋아요 가능 +- 중복 좋아요는 DB 레벨에서 방지 +- v1에서는 userId를 임시 식별자로 사용 + +--- + +## 4️⃣ 예외 계층 구조 + +```mermaid +classDiagram + class RuntimeException { + <> + } + + class BusinessException { + <> + -String errorCode + -String message + +BusinessException(errorCode: String, message: String) + +getErrorCode() String + +getMessage() String + } + + %% ---- Catalog (Brand/Product/Like) ---- + class BrandNotFoundException { + +BrandNotFoundException(brandId: Long) + } + + class ProductNotFoundException { + +ProductNotFoundException(productId: Long) + } + + class DuplicateLikeException { + +DuplicateLikeException(userId: Long, productId: Long) + } + + class BusinessRuleViolationException { + +BusinessRuleViolationException(message: String) + } + + %% ---- Order/Stock (예약/확정/취소 정책 반영) ---- + class OrderNotFoundException { + +OrderNotFoundException(orderId: Long) + } + + class ProductOptionNotFoundException { + +ProductOptionNotFoundException(productOptionId: Long) + } + + class InsufficientStockException { + +InsufficientStockException(productOptionId: Long) + } + + class InvalidOrderStateException { + +InvalidOrderStateException(orderId: Long, fromStatus: String, action: String) + } + + class UnauthorizedOrderActionException { + +UnauthorizedOrderActionException(orderId: Long, action: String) + } + + RuntimeException <|-- BusinessException + BusinessException <|-- BrandNotFoundException + BusinessException <|-- ProductNotFoundException + BusinessException <|-- DuplicateLikeException + BusinessException <|-- BusinessRuleViolationException + BusinessException <|-- OrderNotFoundException + BusinessException <|-- ProductOptionNotFoundException + BusinessException <|-- InsufficientStockException + BusinessException <|-- InvalidOrderStateException + BusinessException <|-- UnauthorizedOrderActionException +``` + +### 예외 클래스 상세 + + +#### BrandNotFoundException + +**발생 시점:** 브랜드 조회 시 존재하지 않을 때 +**HTTP 상태:** 404 Not Found +**복구 전략:** 사용자에게 브랜드가 존재하지 않음을 알림 + +--- + +#### ProductNotFoundException + +**발생 시점:** +- 상품 조회 시 존재하지 않을 때 +- 타이밍 이슈로 조회 중 삭제되었을 때 (동시성) + +**HTTP 상태:** 404 Not Found +**복구 전략:** 사용자에게 상품이 존재하지 않음을 알림 + +--- + +#### ProductOptionNotFoundException + +**발생 시점:** 상품은 존재하는데 옵션이 하나도 없을 때 (데이터 무결성 위반) +**HTTP 상태:** 500 Internal Server Error +**복구 전략:** +- 시스템 관리자에게 알림 +- 데이터 정합성 복구 필요 +- 사용자에게는 일시적 오류 안내 + +--- + +#### ImageNotFoundException + +**발생 시점:** 이미지 URL은 DB에 있지만 실제 리소스(S3, CDN)가 없을 때 +**HTTP 상태:** 404 Not Found (또는 500으로 설정 가능) +**복구 전략:** +- 기본 이미지로 대체 +- 시스템 관리자에게 알림 (이미지 리소스 복구 필요) + +--- + +#### BusinessRuleViolationException + +**발생 시점:** +- 가격이 음수일 때 +- 재고가 음수일 때 +- 기타 비즈니스 규칙 위반 + +**HTTP 상태:** 500 Internal Server Error +**복구 전략:** +- 시스템 관리자에게 알림 +- 데이터 검증 강화 +- 사용자에게는 일시적 오류 안내 + +--- + +#### DuplicateLikeException + +**발생 시점:** 이미 좋아요를 누른 상품에 다시 좋아요 시도 +**HTTP 상태:** 409 Conflict +**복구 전략:** 사용자에게 이미 좋아요했음을 안내 + +--- + +## 5️⃣ 설계 원칙 및 고려사항 + +### 1. 레이어 분리 원칙 + +#### Controller 책임 +- HTTP 프로토콜 처리에만 집중 +- 비즈니스 로직 없음 +- 인증 정보 추출 (userId) +- 예외를 HTTP 상태 코드로 변환 + +#### Facade 책임 +- 여러 도메인 서비스 조율 +- 복잡한 흐름 관리 +- 데이터 조합 +- **비즈니스 규칙은 Service에 위임** + +#### Service 책임 +- 도메인별 비즈니스 로직 +- 단일 도메인에 집중 +- 트랜잭션 경계 +- Entity 검증 및 생성 + +#### Repository 책임 +- 데이터 접근만 +- 쿼리 최적화 +- 영속성 관리 + +--- + +### 2. Facade 사용 기준 + +**Facade가 필요한 경우:** +- 여러 도메인 서비스 협력이 필요한 경우 +- 복잡한 데이터 조합이 필요한 경우 +- 조건부 처리(로그인 여부 등)가 필요한 경우 + +**Facade가 불필요한 경우:** +- 단일 도메인만 다루는 경우 (예: 브랜드 조회) +- Controller → Service 직접 호출로 충분한 경우 + +--- + +### 3. 예외 처리 전략 + +#### 예외 계층 구조 +``` +RuntimeException + └─ BusinessException (추상) + ├─ BrandNotFoundException (404) + ├─ ProductNotFoundException (404) + ├─ ProductOptionNotFoundException (500) ← 치명적 + ├─ ImageNotFoundException (404/500) + ├─ BusinessRuleViolationException (500) ← 치명적 + └─ DuplicateLikeException (409) +``` + +#### 치명적 vs 일반 예외 + +| 예외 | 치명도 | HTTP | 복구 전략 | +|------|--------|------|----------| +| BrandNotFoundException | 일반 | 404 | 사용자 안내 | +| ProductNotFoundException | 일반 | 404 | 사용자 안내 | +| **ProductOptionNotFoundException** | **치명적** | **500** | **시스템 알림, 데이터 복구** | +| **ImageNotFoundException** | 치명적 | 404 | 기본 이미지 대체, 알림 | +| **BusinessRuleViolationException** | **치명적** | **500** | **시스템 알림, 데이터 검증** | +| DuplicateLikeException | 일반 | 409 | 사용자 안내 | + +--- + +### 4. FK 제약 없는 설계 + +**이유:** +- 애플리케이션 레벨에서 참조 무결성 관리 +- DB 레벨 제약으로 인한 성능 오버헤드 제거 +- 향후 샤딩, 마이크로서비스 전환 시 유연성 확보 + +**트레이드오프:** +- 데이터 정합성은 애플리케이션 책임 +- 고아 레코드(orphan records) 발생 가능성 +- 정기적인 데이터 정합성 체크 필요 + +**보완 전략:** +- Service 레벨에서 참조 검증 +- 배치 작업을 통한 정합성 체크 +- 모니터링 및 알림 + +--- + +### 5. 성능 고려사항 + +#### N+1 문제 방지 +- Fetch Join 활용 +- 배치 조회 메서드 제공 (예: `calculateMinPrices(List)`) +- Repository에서 IN 절 쿼리 사용 + +#### 병렬 처리 +- Facade에서 독립적인 조회는 병렬 실행 가능 +- CompletableFuture 또는 @Async 활용 고려 + +#### 캐싱 +- 자주 조회되는 브랜드 정보 캐싱 +- 상품 최저가 계산 결과 캐싱 (Redis) +- 좋아요 수 캐싱 (Eventual Consistency 허용) + +--- + +**문서 끝** \ No newline at end of file diff --git a/docs/design/04-erd.md b/docs/design/04-erd.md index 3dd152e52..ef42ed8be 100644 --- a/docs/design/04-erd.md +++ b/docs/design/04-erd.md @@ -1,179 +1,82 @@ -6️⃣ERD │ -│ │ -│ 왜 필요한가 │ -│ │ -│ FK 없이 ID 참조만 사용하므로, 논리적 관계를 문서로 명확히 해야 한다. VO는 DB 컬럼으로 풀어져 저장된다. │ -│ │ -│ 검증 포인트 │ -│ │ -│ - stock.product_id에 UNIQUE 제약 (1:1) │ -│ - order_item의 스냅샷 컬럼이 실제로 어떻게 매핑되는지 │ -│ - orders 테이블명 (MySQL 예약어 회피) │ -│ - VO(Money, Quantity)는 BIGINT 컬럼으로, ProductSnapshot은 개별 컬럼으로 매핑 │ -│ │ -│ erDiagram │ -│ brand { │ -│ BIGINT id PK "AUTO_INCREMENT" │ -│ VARCHAR name "NOT NULL" │ -│ DATETIME created_at "NOT NULL" │ -│ DATETIME updated_at "NOT NULL" │ -│ DATETIME deleted_at "NULL" │ -│ } │ -│ │ -│ product { │ -│ BIGINT id PK "AUTO_INCREMENT" │ -│ BIGINT brand_id "NOT NULL" │ -│ VARCHAR name "NOT NULL" │ -│ BIGINT price "NOT NULL (Money VO)" │ -│ VARCHAR description "NULL" │ -│ DATETIME created_at "NOT NULL" │ -│ DATETIME updated_at "NOT NULL" │ -│ DATETIME deleted_at "NULL" │ -│ } │ -│ │ -│ stock { │ -│ BIGINT id PK "AUTO_INCREMENT" │ -│ BIGINT product_id "NOT NULL, UNIQUE" │ -│ BIGINT quantity "NOT NULL, DEFAULT 0" │ -│ DATETIME created_at "NOT NULL" │ -│ DATETIME updated_at "NOT NULL" │ -│ DATETIME deleted_at "NULL" │ -│ } │ -│ │ -│ orders { │ -│ BIGINT id PK "AUTO_INCREMENT" │ -│ BIGINT member_id "NOT NULL" │ -│ VARCHAR status "NOT NULL" │ -│ BIGINT total_amount "NOT NULL (Money VO)" │ -│ DATETIME created_at "NOT NULL" │ -│ DATETIME updated_at "NOT NULL" │ -│ DATETIME deleted_at "NULL" │ -│ } │ -│ │ -│ order_item { │ -│ BIGINT id PK "AUTO_INCREMENT" │ -│ BIGINT order_id "NOT NULL" │ -│ BIGINT product_id "NOT NULL" │ -│ VARCHAR status "NOT NULL" │ -│ VARCHAR product_name "NOT NULL (ProductSnapshot)" │ -│ BIGINT product_price "NOT NULL (ProductSnapshot.Money)" │ -│ VARCHAR brand_name "NOT NULL (ProductSnapshot)" │ -│ BIGINT quantity "NOT NULL (Quantity VO)" │ -│ DATETIME created_at "NOT NULL" │ -│ DATETIME updated_at "NOT NULL" │ -│ DATETIME deleted_at "NULL" │ -│ } │ -│ │ -│ brand ||--o{ product : "has" │ -│ product ||--|| stock : "has" │ -│ member ||--o{ orders : "places" │ -│ orders ||--o{ order_item : "contains" │ -│ product ||--o{ order_item : "referenced by" │ -│ │ -│ 읽는 법 │ -│ │ -│ - VO → 컬럼 매핑: Money VO는 BIGINT 단일 컬럼, Quantity VO도 BIGINT 단일 컬럼, ProductSnapshot은 3개 컬럼(product_name, product_price, │ -│ brand_name)으로 풀어진다. │ -│ - stock ↔ product 1:1: 하나의 상품에 하나의 재고 레코드. UNIQUE 제약으로 보장. │ -│ - 논리적 참조만 존재: FK 없이 애플리케이션 레벨에서 ID 참조. -️⃣잠재 리스크 │ -│ │ -│ 트랜잭션 범위 │ -│ │ -│ Stock 락 + Order/OrderItem 저장이 단일 트랜잭션이므로, 주문 항목 수가 많으면 락 보유 시간이 길어진다. │ -│ - (A) 현재 유지 → 일반적 커머스 수준에서 충분. 단순하고 All-or-Nothing 보장 명확. │ -│ - (B) 2-phase (예약→확정) → 트랜잭션 분리 가능하나 복잡도 증가. 현 시점에서는 오버엔지니어링. │ -│ │ -│ 비관적 락 병목 │ -│ │ -│ 인기 상품에 동시 주문이 몰리면 Stock row 락 대기 발생. │ -│ - (A) 비관적 락 유지 → 정합성 확실, 트래픽이 극단적이지 않으면 충분. │ -│ - (B) Redis 분산 락 → 수평 확장 시 고려. 단일 DB에서는 불필요. │ -│ │ -│ 데드락 방지 │ -│ │ -│ 설계에 이미 반영: productId 오름차순으로 정렬 후 개별 락 획득 (StockDeductionService 책임). 애플리케이션 레벨에서 순서를 보장하는 것이 │ -│ DB 엔진 의존성보다 안전. │ -│ │ -│ VO JPA 매핑 복잡도 │ -│ │ -│ @Embeddable VO 사용 시 JPA 매핑이 추가된다. 특히 ProductSnapshot 내부의 Money VO는 중첩 @Embeddable이 된다. │ -│ - @AttributeOverrides로 컬럼명을 명시적으로 지정하여 해결. │ -│ - 복잡하다면 ProductSnapshot.price만 Long으로 직접 저장하는 것도 선택지. │ -│ │ -│ 스냅샷 시점 차이 │ -│ │ -│ Facade에서 Product/Brand를 조회한 시점과 OrderService에서 저장하는 시점의 미세한 차이가 있으나, 동일 HTTP 요청 내이므로 비즈니스적으로 │ -│ 수용 가능. │ -│ │ -│ --- │ -│ 생성할 파일 목록 │ -│ │ -│ domain 레이어 (com.loopers.domain) │ -│ │ -│ 공통 VO │ -│ - domain/vo/Money.java — 가격/금액 VO (@Embeddable, value >= 0) │ -│ - domain/vo/Quantity.java — 수량 VO (@Embeddable, value > 0) │ -│ │ -│ Brand │ -│ - domain/brand/BrandModel.java │ -│ - domain/brand/BrandRepository.java │ -│ │ -│ Product │ -│ - domain/product/ProductModel.java — Money VO 사용 │ -│ - domain/product/ProductRepository.java │ -│ - domain/product/ProductService.java │ -│ │ -│ Stock │ -│ - domain/stock/StockModel.java — deduct(Quantity), hasEnoughStock(Quantity) 메서드 포함 │ -│ - domain/stock/StockRepository.java │ -│ - domain/stock/StockDeductionService.java — 도메인 서비스: All-or-Nothing 재고 차감 │ -│ │ -│ Order │ -│ - domain/order/OrderModel.java — Money VO 사용 │ -│ - domain/order/OrderStatus.java │ -│ - domain/order/OrderItemModel.java — ProductSnapshot, Quantity VO 사용 │ -│ - domain/order/OrderItemStatus.java │ -│ - domain/order/ProductSnapshot.java — @Embeddable VO (productName, Money price, brandName) │ -│ - domain/order/OrderRepository.java │ -│ - domain/order/OrderItemRepository.java │ -│ - domain/order/OrderService.java │ -│ │ -│ infrastructure 레이어 (com.loopers.infrastructure) │ -│ │ -│ - infrastructure/brand/BrandJpaRepository.java │ -│ - infrastructure/brand/BrandRepositoryImpl.java │ -│ - infrastructure/product/ProductJpaRepository.java │ -│ - infrastructure/product/ProductRepositoryImpl.java │ -│ - infrastructure/stock/StockJpaRepository.java — @Lock(PESSIMISTIC_WRITE) 포함 │ -│ - infrastructure/stock/StockRepositoryImpl.java │ -│ - infrastructure/order/OrderJpaRepository.java │ -│ - infrastructure/order/OrderRepositoryImpl.java │ -│ - infrastructure/order/OrderItemJpaRepository.java │ -│ - infrastructure/order/OrderItemRepositoryImpl.java │ -│ │ -│ application 레이어 (com.loopers.application) │ -│ │ -│ - application/order/OrderFacade.java │ -│ - application/order/OrderInfo.java │ -│ │ -│ interfaces 레이어 (com.loopers.interfaces.api) │ -│ │ -│ - interfaces/api/order/OrderV1Controller.java │ -│ - interfaces/api/order/OrderV1ApiSpec.java │ -│ - interfaces/api/order/OrderV1Dto.java │ -│ │ -│ 참조할 기존 패턴 파일 │ -│ │ -│ - domain/example/ExampleModel.java — 엔티티 패턴 (BaseEntity 상속, guard()) │ -│ - domain/example/ExampleService.java — 서비스 패턴 (@Component + @Transactional) │ -│ - application/example/ExampleFacade.java — Facade 패턴 │ -│ - interfaces/api/member/MemberV1Controller.java — 헤더 인증 + ApiResponse 패턴 │ -│ - modules/jpa/src/main/java/com/loopers/domain/BaseEntity.java — BaseEntity │ -│ │ -│ 검증 방법 │ -│ │ -│ 1. 단위 테스트: Money/Quantity VO 불변식, StockModel.deduct(), ProductSnapshot 생성, OrderModel 생성 │ -│ 2. 통합 테스트: OrderService.createOrder() — 성공/재고부족 롤백, StockDeductionService 동시성 시나리오 │ -│ 3. E2E 테스트: POST /api/v1/orders 호출 → 주문 생성 확인, 재고 차감 확인 │ -│ 4. HTTP 파일: http/commerce-api/order-v1.http로 수동 확인 \ No newline at end of file +### ERD │ +```mermaid +erDiagram +brands ||--o{ products : "has" +products ||--o{ product_options : "has" +products ||--o{ product_images : "has" +products ||--o{ likes : "receives" +products ||--o{ order_items : "referenced by" +product_options ||--o{ order_items : "ordered as" +orders ||--o{ order_items : "contains" +users ||--o{ likes : "creates" +users ||--o{ orders : "places" + + brands { + BIGINT brand_id PK "AUTO_INCREMENT" + VARCHAR(100) name UK "NOT NULL, UNIQUE" + TEXT description "NULL" + VARCHAR(500) logo_image_url "NULL" + TIMESTAMP created_at "NOT NULL, DEFAULT CURRENT_TIMESTAMP" + } + + products { + BIGINT product_id PK "AUTO_INCREMENT" + BIGINT brand_id "NOT NULL (FK 없음)" + VARCHAR(200) name "NOT NULL" + TEXT description "NULL" + INT like_count "NOT NULL DEFAULT 0 (비정규화, 선택)" + TIMESTAMP created_at "NOT NULL, DEFAULT CURRENT_TIMESTAMP" + } + + product_options { + BIGINT option_id PK "AUTO_INCREMENT" + BIGINT product_id "NOT NULL (FK 없음)" + VARCHAR(100) name UK "NOT NULL (product 내 UNIQUE)" + INT price "NOT NULL" + INT stock_quantity "NOT NULL DEFAULT 0" + TIMESTAMP created_at "NOT NULL, DEFAULT CURRENT_TIMESTAMP" + } + + product_images { + BIGINT image_id PK "AUTO_INCREMENT" + BIGINT product_id "NOT NULL (FK 없음)" + VARCHAR(500) image_url "NOT NULL" + INT display_order "NOT NULL" + TIMESTAMP created_at "NOT NULL, DEFAULT CURRENT_TIMESTAMP" + } + + likes { + BIGINT like_id PK "AUTO_INCREMENT" + BIGINT user_id "NOT NULL (FK 없음)" + BIGINT product_id "NOT NULL (FK 없음)" + TIMESTAMP created_at "NOT NULL, DEFAULT CURRENT_TIMESTAMP" + } + + orders { + BIGINT order_id PK "AUTO_INCREMENT" + BIGINT user_id "NOT NULL (FK 없음)" + VARCHAR(20) status "NOT NULL (CREATED/CONFIRMED/CANCELLED)" + BIGINT total_amount "NOT NULL (Money VO -> BIGINT)" + TIMESTAMP created_at "NOT NULL, DEFAULT CURRENT_TIMESTAMP" + } + + order_items { + BIGINT order_item_id PK "AUTO_INCREMENT" + BIGINT order_id "NOT NULL (FK 없음)" + BIGINT product_id "NOT NULL (FK 없음)" + BIGINT option_id "NOT NULL (FK 없음)" + VARCHAR(20) status "NOT NULL (ORDERED/CANCELLED)" + + VARCHAR(200) product_name "NOT NULL (Snapshot)" + VARCHAR(100) brand_name "NOT NULL (Snapshot)" + VARCHAR(100) option_name "NOT NULL (Snapshot)" + BIGINT option_price "NOT NULL (Money Snapshot -> BIGINT)" + BIGINT quantity "NOT NULL (Quantity VO -> BIGINT)" + + TIMESTAMP created_at "NOT NULL, DEFAULT CURRENT_TIMESTAMP" + } + + users { + BIGINT user_id PK "v2에서 추가 예정" + } +``` \ No newline at end of file diff --git "a/docs/design/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/01-requirements.md" "b/docs/design/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/01-requirements.md" index 752faa978..55e8b42ec 100644 --- "a/docs/design/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/01-requirements.md" +++ "b/docs/design/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/01-requirements.md" @@ -1,51 +1,10 @@ -# 감성 이커머스 시스템 요구사항 명세서 (v1) - -## 1️⃣ 도메인 구조 - -### v1 핵심 도메인 -| 도메인 | 책임 | 주요 엔티티 | -|--------|------|------------| -| **브랜드** | 브랜드 정보 관리 | Brand | -| **상품** | 상품 카탈로그 관리 | Product, ProductOption, ProductImage | -| **좋아요** | 사용자 관심 상품 관리 | Like | - -### 도메인 간 관계 -``` -Brand (브랜드) - └─→ Product (상품) : 1:N [brand_id로 참조] - -Product (상품) - ├─→ ProductOption (상품 옵션) : 1:N [product_id로 참조] - ├─→ ProductImage (상품 이미지) : 1:N [product_id로 참조] - └─→ Like (좋아요) : 1:N [product_id로 참조] - -User (사용자) - v2에서 추가 예정 - └─→ Like (좋아요) : 1:N [user_id로 참조] -``` - -**핵심 개념:** -- **Brand**: 상품을 제공하는 브랜드 -- **Product**: 브랜드가 판매하는 상품 (기본 정보, brand_id 보유) -- **ProductOption**: 상품의 판매 단위 (사이즈, 색상 등 + 가격 + 재고, product_id 보유) -- **ProductImage**: 상품의 이미지들 (여러 장 가능, product_id 보유) -- **Like**: 사용자의 상품 좋아요 (v1에서는 인증 없이 임시 식별자 사용, product_id 보유) - -**설계 의도:** -- 상품과 옵션을 분리하여 **옵션별로 가격과 재고를 독립적으로 관리** -- 같은 상품이라도 옵션(사이즈, 색상 등)에 따라 가격과 재고가 다를 수 있음 -- 좋아요는 **상품 단위**로 관리 (옵션 단위 아님) -- 향후 장바구니, 주문 기능 추가 시 **옵션이 판매 단위**가 됨 -- **FK 제약 없음**: 애플리케이션 레벨에서 참조 무결성 관리 - ---- - -## 2️⃣ 유저 시나리오 기반 기능 정의 +# 브랜드 & 상품 관리 유저 시나리오 기반 기능 정의정의서 ### US-1. 브랜드 탐색 **시나리오:** 사용자는 여러 브랜드를 둘러보며 관심 있는 브랜드를 찾는다. -**주요 흐름:** +**상세 흐름:** 1. 브랜드 목록 페이지 접속 2. 브랜드 카드(썸네일, 이름) 확인 3. 특정 브랜드 클릭 → 브랜드 상세 정보 조회 @@ -92,13 +51,16 @@ User (사용자) - v2에서 추가 예정 --- -## 3️⃣ 기능별 상세 요구사항 +## 기능별 상세 요구사항 ### 🔹 브랜드 (Brand) #### FR-B-01. 브랜드 정보 조회 -**API:** `GET /api/v1/brands/{brandId}` -**인증:** 불필요 +**API 명세:** +``` +POST GET /api/v1/brands/{brandId} +Authorization: Not Required +``` **Path Parameter:** - `brandId` (Long): 브랜드 ID @@ -122,13 +84,18 @@ User (사용자) - v2에서 추가 예정 ### 🔹 상품 (Product) #### FR-P-01. 상품 목록 조회 -**API:** `GET /api/v1/products` -**인증:** 불필요 (로그인 시 좋아요 여부 추가 제공) + +**API 명세:** +``` +POST GET GET /api/v1/products +Authorization: Not Required (로그인 시 좋아요 여부 추가 제공) +``` **Query Parameters:** + | 파라미터 | 타입 | 필수 | 기본값 | 설명 | |---------|------|------|--------|------| -| `brandId` | Long | X | - | 특정 브랜드의 상품만 필터링 | +| **brandId** | Long | X | - | 특정 브랜드의 상품만 필터링 | | `sort` | String | X | `latest` | 정렬 기준 (`latest`, `price_asc`, `likes_desc`) | | `page` | Integer | X | 0 | 페이지 번호 | | `size` | Integer | X | 20 | 페이지당 상품 수 | @@ -199,8 +166,11 @@ User (사용자) - v2에서 추가 예정 --- #### FR-P-02. 상품 상세 정보 조회 -**API:** `GET /api/v1/products/{productId}` -**인증:** 불필요 (로그인 시 좋아요 여부 추가 제공) +**API 명세:** +``` +GET /api/v1/products/{productId} +Authorization: Not Required (로그인 시 좋아요 여부 추가 제공) +``` **Path Parameter:** - `productId` (Long): 상품 ID @@ -291,66 +261,8 @@ User (사용자) - v2에서 추가 예정 > **참고:** 좋아요 기능의 상세 요구사항(동기/비동기 처리, 이벤트 발행, 배치 복구 등)은 별도 문서 참조 -#### FR-L-01. 좋아요 등록 -**API:** `POST /api/v1/products/{productId}/likes` -**인증:** 필수 (v1에서는 임시 식별자 사용, v2에서 정식 인증으로 전환) - -**처리:** -- 좋아요 등록 (동기) -- 중복 좋아요 방지 (DB Unique 제약) -- 카운트 업데이트 (비동기) - -**에러 처리:** -- 이미 좋아요한 상품 → 409 Conflict - ---- - -#### FR-L-02. 좋아요 취소 -**API:** `DELETE /api/v1/products/{productId}/likes` -**인증:** 필수 - -**처리:** -- 좋아요 삭제 (동기) -- 카운트 업데이트 (비동기) -- 멱등성 보장 (이미 취소된 좋아요 재요청 시 성공 응답) - ---- - -#### FR-L-03. 내 좋아요 목록 조회 -**API:** `GET /api/v1/users/me/likes` -**인증:** 필수 - -**Query Parameters:** -- `page` (선택, Integer, 기본값: 0): 페이지 번호 -- `size` (선택, Integer, 기본값: 20): 페이지 크기 - -**반환 정보:** -```json -{ - "content": [ - { - "productId": 1, - "name": "상품명", - "brand": { - "brandId": 1, - "name": "브랜드명" - }, - "thumbnailImageUrl": "https://example.com/product-thumbnail.png", - "minPrice": 10000, - "likeCount": 150, - "likedAt": "2025-01-15T10:30:00" - } - ], - "page": 0, - "size": 20, - "totalElements": 10, - "totalPages": 1 -} -``` - ---- -## 4️⃣ 설계 고려사항 +## 설계 고려사항 ### 확장 포인트 @@ -402,24 +314,6 @@ User (사용자) - v2에서 추가 예정 --- -#### 4. 좋아요 카운트 정합성 -**현재 (v1):** -- 좋아요 등록/취소: 동기 -- 카운트 업데이트: 비동기 (Eventual Consistency) - -**잠재 리스크:** -- 이벤트 발행 실패 시 카운트 불일치 -- 대량 좋아요 발생 시 카운트 업데이트 지연 - -**해결 방안:** -- 배치 작업을 통한 정합성 복구 -- 향후 Redis 캐시 도입 (실시간 카운트) - -**설계 시 주의사항:** -- 좋아요 수는 "대략적인 인기도" 지표로 사용 -- 정확한 수치가 필요한 경우 실시간 COUNT 쿼리 사용 - ---- ### 성능 최적화 포인트 @@ -515,22 +409,4 @@ User (사용자) - v2에서 추가 예정 - 썸네일 이미지 최적화 (리사이징, WebP 포맷) - 이미지 URL은 DB에 저장, 실제 파일은 Object Storage (S3 등) ---- - -## 5️⃣ 용어 사전 - -| 용어 | 설명 | -|------|------| -| **브랜드 (Brand)** | 상품을 제공하는 제조사 또는 판매자 | -| **상품 (Product)** | 판매 대상이 되는 아이템의 기본 정보 | -| **상품 옵션 (ProductOption)** | 상품의 실제 판매 단위 (사이즈, 색상 등 구분) | -| **재고 (Stock)** | 판매 가능한 수량 (옵션 단위로 관리) | -| **최저가 (Min Price)** | 상품의 모든 옵션 중 가장 낮은 가격 | -| **좋아요 (Like)** | 사용자가 상품에 대한 관심을 표현하는 행위 (상품 단위) | -| **좋아요 수 (Like Count)** | 해당 상품의 총 좋아요 개수 (인기도 지표) | -| **페이지네이션 (Pagination)** | 대량 데이터를 페이지 단위로 나누어 조회 | -| **Eventual Consistency** | 최종 일관성 - 일시적 불일치를 허용하되 최종적으로 일관성 보장 | - ---- - **문서 끝** \ No newline at end of file diff --git "a/docs/design/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/02-sequence-diagrams.md" "b/docs/design/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/02-sequence-diagrams.md" index b1165e3a7..846b2f21c 100644 --- "a/docs/design/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/02-sequence-diagrams.md" +++ "b/docs/design/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/02-sequence-diagrams.md" @@ -81,7 +81,7 @@ sequenceDiagram ### Mermaid 다이어그램 (로그인 사용자) ```mermaid -sequenceDiagram +ssequenceDiagram participant Client participant ProductController participant ProductFacade @@ -100,10 +100,10 @@ sequenceDiagram ProductController->>ProductFacade: getProducts(brandId, sort, page, size, userId) - par 상품 목록 조회 + par 상품 목록 조회 (ACTIVE만) ProductFacade->>ProductService: findProducts(brandId, sort, page, size) - ProductService->>ProductRepository: findAll(brandId, sort, pageable) - ProductRepository->>Database: SELECT * FROM products
WHERE brand_id = ? ORDER BY created_at DESC + ProductService->>ProductRepository: findAllActive(brandId, sort, pageable) + ProductRepository->>Database: SELECT * FROM products
WHERE brand_id = ? AND status = 'ACTIVE'
ORDER BY created_at DESC Database-->>ProductRepository: List ProductRepository-->>ProductService: List ProductService-->>ProductFacade: List @@ -118,16 +118,16 @@ sequenceDiagram Database-->>ProductOptionRepository: Map ProductOptionRepository-->>ProductOptionService: Map ProductOptionService-->>ProductFacade: Map - and 좋아요 수 조회 - ProductFacade->>LikeService: countLikes(productIds) - LikeService->>LikeRepository: countByProductIds(productIds) - LikeRepository->>Database: SELECT product_id, COUNT(*)
FROM likes
WHERE product_id IN (?) GROUP BY product_id - Database-->>LikeRepository: Map - LikeRepository-->>LikeService: Map - LikeService-->>ProductFacade: Map + and 좋아요 수 조회 (Product.like_count 사용) + ProductFacade->>ProductService: findLikeCounts(productIds) + ProductService->>ProductRepository: findLikeCountsByIds(productIds) + ProductRepository->>Database: SELECT id AS product_id, like_count
FROM products WHERE id IN (?) + Database-->>ProductRepository: Map + ProductRepository-->>ProductService: Map + ProductService-->>ProductFacade: Map and 좋아요 여부 확인 ProductFacade->>LikeService: checkLikedByUser(userId, productIds) - LikeService->>LikeRepository: existsByUserIdAndProductIds(userId, productIds) + LikeService->>LikeRepository: findLikedProductIds(userId, productIds) LikeRepository->>Database: SELECT product_id FROM likes
WHERE user_id = ? AND product_id IN (?) Database-->>LikeRepository: Set LikeRepository-->>LikeService: Set @@ -193,6 +193,56 @@ sequenceDiagram Note over ProductFacade: 데이터 조합:
Product + minPrice + likeCount
(isLikedByMe 제외) + ProductFacade-->>ProductController: Page + ProductController-->>Client: 200 OK (상품 목록 + 최저가 + 좋아요 수) +sequenceDiagram + participant Client + participant ProductController + participant ProductFacade + participant ProductService + participant ProductOptionService + participant ProductRepository + participant ProductOptionRepository + participant Database + + Client->>ProductController: GET /api/v1/products?brandId=1&sort=latest + + ProductController->>ProductController: extractUserId(headers) + Note over ProductController: userId 없음 (비로그인) + + ProductController->>ProductFacade: getProducts(brandId, sort, page, size, null) + + par 상품 목록 조회 (ACTIVE만) + ProductFacade->>ProductService: findProducts(brandId, sort, page, size) + ProductService->>ProductRepository: findAllActive(brandId, sort, pageable) + ProductRepository->>Database: SELECT * FROM products
WHERE brand_id = ? AND status = 'ACTIVE'
ORDER BY created_at DESC + Database-->>ProductRepository: List + ProductRepository-->>ProductService: List + ProductService-->>ProductFacade: List + end + + Note over ProductFacade: productIds 추출 + + par 최저가 계산 + ProductFacade->>ProductOptionService: calculateMinPrices(productIds) + ProductOptionService->>ProductOptionRepository: findMinPricesByProductIds(productIds) + ProductOptionRepository->>Database: SELECT product_id, MIN(price)
FROM product_options
WHERE product_id IN (?) GROUP BY product_id + Database-->>ProductOptionRepository: Map + ProductOptionRepository-->>ProductOptionService: Map + ProductOptionService-->>ProductFacade: Map + and 좋아요 수 조회 (Product.like_count 사용) + ProductFacade->>ProductService: findLikeCounts(productIds) + ProductService->>ProductRepository: findLikeCountsByIds(productIds) + ProductRepository->>Database: SELECT id AS product_id, like_count
FROM products WHERE id IN (?) + Database-->>ProductRepository: Map + ProductRepository-->>ProductService: Map + ProductService-->>ProductFacade: Map + end + + Note over ProductFacade: userId가 null이므로
좋아요 여부 조회 생략 + + Note over ProductFacade: 데이터 조합:
Product + minPrice + likeCount
(isLikedByMe 제외) + ProductFacade-->>ProductController: Page ProductController-->>Client: 200 OK (상품 목록 + 최저가 + 좋아요 수) ``` @@ -271,11 +321,11 @@ sequenceDiagram ProductController->>ProductFacade: getProductDetail(productId, userId) - ProductFacade->>ProductService: findProduct(productId) - ProductService->>ProductRepository: findById(productId) - ProductRepository->>Database: SELECT * FROM products WHERE product_id = ? + ProductFacade->>ProductService: findActiveProduct(productId) + ProductService->>ProductRepository: findActiveById(productId) + ProductRepository->>Database: SELECT * FROM products WHERE product_id = ? AND status = 'ACTIVE' - alt 상품 존재 + alt 상품 존재 (ACTIVE) Database-->>ProductRepository: Product ProductRepository-->>ProductService: Product ProductService-->>ProductFacade: Product @@ -283,24 +333,24 @@ sequenceDiagram par 옵션 목록 조회 ProductFacade->>ProductOptionService: findOptions(productId) ProductOptionService->>ProductOptionRepository: findByProductId(productId) - ProductOptionRepository->>Database: SELECT * FROM product_options
WHERE product_id = ?
ORDER BY created_at + ProductOptionRepository->>Database: SELECT * FROM product_options
WHERE product_id = ? ORDER BY created_at Database-->>ProductOptionRepository: List ProductOptionRepository-->>ProductOptionService: List ProductOptionService-->>ProductFacade: List and 이미지 목록 조회 ProductFacade->>ProductImageService: findImages(productId) ProductImageService->>ProductImageRepository: findByProductId(productId) - ProductImageRepository->>Database: SELECT * FROM product_images
WHERE product_id = ?
ORDER BY display_order + ProductImageRepository->>Database: SELECT * FROM product_images
WHERE product_id = ? ORDER BY display_order Database-->>ProductImageRepository: List ProductImageRepository-->>ProductImageService: List ProductImageService-->>ProductFacade: List - and 좋아요 수 조회 - ProductFacade->>LikeService: countLikes(productId) - LikeService->>LikeRepository: countByProductId(productId) - LikeRepository->>Database: SELECT COUNT(*) FROM likes
WHERE product_id = ? - Database-->>LikeRepository: likeCount - LikeRepository-->>LikeService: likeCount - LikeService-->>ProductFacade: likeCount + and 좋아요 수 조회 (Product.like_count) + ProductFacade->>ProductService: getLikeCount(productId) + ProductService->>ProductRepository: findLikeCountById(productId) + ProductRepository->>Database: SELECT like_count FROM products WHERE product_id = ? + Database-->>ProductRepository: likeCount + ProductRepository-->>ProductService: likeCount + ProductService-->>ProductFacade: likeCount and 좋아요 여부 확인 ProductFacade->>LikeService: checkLikedByUser(userId, productId) LikeService->>LikeRepository: existsByUserIdAndProductId(userId, productId) @@ -315,7 +365,7 @@ sequenceDiagram ProductFacade-->>ProductController: ProductDetailResponse ProductController-->>Client: 200 OK (상품 상세 정보) - else 상품 없음 + else 상품 없음 or 비활성 (DRAFT/INACTIVE) Database-->>ProductRepository: null ProductRepository-->>ProductService: null ProductService-->>ProductFacade: throw ProductNotFoundException @@ -334,11 +384,9 @@ sequenceDiagram participant ProductService participant ProductOptionService participant ProductImageService - participant LikeService participant ProductRepository participant ProductOptionRepository participant ProductImageRepository - participant LikeRepository participant Database Client->>ProductController: GET /api/v1/products/{productId} @@ -348,11 +396,11 @@ sequenceDiagram ProductController->>ProductFacade: getProductDetail(productId, null) - ProductFacade->>ProductService: findProduct(productId) - ProductService->>ProductRepository: findById(productId) - ProductRepository->>Database: SELECT * FROM products WHERE product_id = ? + ProductFacade->>ProductService: findActiveProduct(productId) + ProductService->>ProductRepository: findActiveById(productId) + ProductRepository->>Database: SELECT * FROM products WHERE product_id = ? AND status = 'ACTIVE' - alt 상품 존재 + alt 상품 존재 (ACTIVE) Database-->>ProductRepository: Product ProductRepository-->>ProductService: Product ProductService-->>ProductFacade: Product @@ -360,24 +408,24 @@ sequenceDiagram par 옵션 목록 조회 ProductFacade->>ProductOptionService: findOptions(productId) ProductOptionService->>ProductOptionRepository: findByProductId(productId) - ProductOptionRepository->>Database: SELECT * FROM product_options + ProductOptionRepository->>Database: SELECT * FROM product_options
WHERE product_id = ? ORDER BY created_at Database-->>ProductOptionRepository: List ProductOptionRepository-->>ProductOptionService: List ProductOptionService-->>ProductFacade: List and 이미지 목록 조회 ProductFacade->>ProductImageService: findImages(productId) ProductImageService->>ProductImageRepository: findByProductId(productId) - ProductImageRepository->>Database: SELECT * FROM product_images + ProductImageRepository->>Database: SELECT * FROM product_images
WHERE product_id = ? ORDER BY display_order Database-->>ProductImageRepository: List ProductImageRepository-->>ProductImageService: List ProductImageService-->>ProductFacade: List - and 좋아요 수 조회 - ProductFacade->>LikeService: countLikes(productId) - LikeService->>LikeRepository: countByProductId(productId) - LikeRepository->>Database: SELECT COUNT(*) FROM likes - Database-->>LikeRepository: likeCount - LikeRepository-->>LikeService: likeCount - LikeService-->>ProductFacade: likeCount + and 좋아요 수 조회 (Product.like_count) + ProductFacade->>ProductService: getLikeCount(productId) + ProductService->>ProductRepository: findLikeCountById(productId) + ProductRepository->>Database: SELECT like_count FROM products WHERE product_id = ? + Database-->>ProductRepository: likeCount + ProductRepository-->>ProductService: likeCount + ProductService-->>ProductFacade: likeCount end Note over ProductFacade: userId가 null이므로
좋아요 여부 조회 생략 @@ -387,7 +435,7 @@ sequenceDiagram ProductFacade-->>ProductController: ProductDetailResponse ProductController-->>Client: 200 OK (상품 상세 정보) - else 상품 없음 + else 상품 없음 or 비활성 (DRAFT/INACTIVE) Database-->>ProductRepository: null ProductRepository-->>ProductService: null ProductService-->>ProductFacade: throw ProductNotFoundException diff --git "a/docs/design/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/03-class-diagram.md" "b/docs/design/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/03-class-diagram.md" deleted file mode 100644 index 6271158e4..000000000 --- "a/docs/design/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/03-class-diagram.md" +++ /dev/null @@ -1,1179 +0,0 @@ -# 클래스 다이어그램 (Class Diagram) - -## 1️⃣ 전체 아키텍처 개요 - -### 레이어 구조 -``` -┌─────────────────────────────────────────┐ -│ Presentation Layer │ -│ (Controller) │ -└─────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────┐ -│ Application Layer │ -│ (Facade - 도메인 서비스 조율) │ -└─────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────┐ -│ Domain Layer │ -│ (Service, Entity) │ -└─────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────┐ -│ Infrastructure Layer │ -│ (Repository) │ -└─────────────────────────────────────────┘ -``` - ---- - -## 2️⃣ 전체 클래스 다이어그램 - -```mermaid -classDiagram - %% ============================================ - %% Presentation Layer (Controllers) - %% ============================================ - class BrandController { - -BrandService brandService - +getBrand(brandId: Long) ResponseEntity~BrandResponse~ - } - - class ProductController { - -ProductFacade productFacade - +getProducts(brandId: Long, sort: String, page: int, size: int, headers: HttpHeaders) ResponseEntity~Page~ProductListResponse~~ - +getProductDetail(productId: Long, headers: HttpHeaders) ResponseEntity~ProductDetailResponse~ - -extractUserId(headers: HttpHeaders) Long - } - - %% ============================================ - %% Application Layer (Facade) - %% ============================================ - class ProductFacade { - -ProductService productService - -ProductOptionService productOptionService - -ProductImageService productImageService - -LikeService likeService - +getProducts(brandId: Long, sort: String, page: Pageable, userId: Long) Page~ProductListResponse~ - +getProductDetail(productId: Long, userId: Long) ProductDetailResponse - -combineProductListData(products: List~Product~, minPrices: Map, likeCounts: Map, likedProducts: Set) Page~ProductListResponse~ - -combineProductDetailData(product: Product, options: List~ProductOption~, images: List~ProductImage~, likeCount: int, isLiked: boolean) ProductDetailResponse - } - - %% ============================================ - %% Domain Layer (Services) - %% ============================================ - class BrandService { - -BrandRepository brandRepository - +getBrand(brandId: Long) Brand - } - - class ProductService { - -ProductRepository productRepository - +findProducts(brandId: Long, sort: String, pageable: Pageable) Page~Product~ - +findProduct(productId: Long) Product - } - - class ProductOptionService { - -ProductOptionRepository productOptionRepository - +findOptions(productId: Long) List~ProductOption~ - +calculateMinPrices(productIds: List~Long~) Map~Long, Integer~ - +validateOptions(options: List~ProductOption~) void - } - - class ProductImageService { - -ProductImageRepository productImageRepository - +findImages(productId: Long) List~ProductImage~ - +validateImageExists(imageUrl: String) void - } - - class LikeService { - -LikeRepository likeRepository - +countLikes(productId: Long) int - +countLikes(productIds: List~Long~) Map~Long, Integer~ - +checkLikedByUser(userId: Long, productId: Long) boolean - +checkLikedByUser(userId: Long, productIds: List~Long~) Set~Long~ - +addLike(userId: Long, productId: Long) void - +removeLike(userId: Long, productId: Long) void - } - - %% ============================================ - %% Domain Layer (Entities) - %% ============================================ - class Brand { - -Long brandId - -String name - -String description - -String logoImageUrl - -LocalDateTime createdAt - +Brand(name: String, description: String, logoImageUrl: String) - } - - class Product { - -Long productId - -Long brandId - -String name - -String description - -LocalDateTime createdAt - +Product(brandId: Long, name: String, description: String) - +validateBusinessRules() void - } - - class ProductOption { - -Long optionId - -Long productId - -String name - -Integer price - -Integer stockQuantity - -LocalDateTime createdAt - +ProductOption(productId: Long, name: String, price: Integer, stockQuantity: Integer) - +isAvailable() boolean - +validatePrice() void - +validateStock() void - } - - class ProductImage { - -Long imageId - -Long productId - -String imageUrl - -Integer displayOrder - -LocalDateTime createdAt - +ProductImage(productId: Long, imageUrl: String, displayOrder: Integer) - } - - class Like { - -Long likeId - -Long userId - -Long productId - -LocalDateTime createdAt - +Like(userId: Long, productId: Long) - } - - %% ============================================ - %% Infrastructure Layer (Repositories) - %% ============================================ - class BrandRepository { - <> - +findById(brandId: Long) Optional~Brand~ - } - - class ProductRepository { - <> - +findById(productId: Long) Optional~Product~ - +findAll(brandId: Long, sort: String, pageable: Pageable) Page~Product~ - } - - class ProductOptionRepository { - <> - +findByProductId(productId: Long) List~ProductOption~ - +findMinPricesByProductIds(productIds: List~Long~) Map~Long, Integer~ - } - - class ProductImageRepository { - <> - +findByProductId(productId: Long) List~ProductImage~ - } - - class LikeRepository { - <> - +countByProductId(productId: Long) int - +countByProductIds(productIds: List~Long~) Map~Long, Integer~ - +existsByUserIdAndProductId(userId: Long, productId: Long) boolean - +existsByUserIdAndProductIds(userId: Long, productIds: List~Long~) Set~Long~ - +save(like: Like) Like - +deleteByUserIdAndProductId(userId: Long, productId: Long) void - } - - %% ============================================ - %% Exception Hierarchy - %% ============================================ - class BusinessException { - <> - -String errorCode - -String message - +BusinessException(errorCode: String, message: String) - } - - class BrandNotFoundException { - +BrandNotFoundException(brandId: Long) - } - - class ProductNotFoundException { - +ProductNotFoundException(productId: Long) - } - - class ProductOptionNotFoundException { - +ProductOptionNotFoundException(productId: Long) - } - - class ImageNotFoundException { - +ImageNotFoundException(imageUrl: String) - } - - class BusinessRuleViolationException { - +BusinessRuleViolationException(message: String) - } - - class DuplicateLikeException { - +DuplicateLikeException(userId: Long, productId: Long) - } - - %% ============================================ - %% Relationships - Controllers - %% ============================================ - BrandController ..> BrandService : uses - ProductController ..> ProductFacade : uses - - %% ============================================ - %% Relationships - Facade - %% ============================================ - ProductFacade ..> ProductService : uses - ProductFacade ..> ProductOptionService : uses - ProductFacade ..> ProductImageService : uses - ProductFacade ..> LikeService : uses - - %% ============================================ - %% Relationships - Services to Repositories - %% ============================================ - BrandService ..> BrandRepository : uses - ProductService ..> ProductRepository : uses - ProductOptionService ..> ProductOptionRepository : uses - ProductImageService ..> ProductImageRepository : uses - LikeService ..> LikeRepository : uses - - %% ============================================ - %% Relationships - Domain Models - %% ============================================ - Brand "1" --> "N" Product : has - Product "1" --> "N" ProductOption : has - Product "1" --> "N" ProductImage : has - Product "1" --> "N" Like : receives - - %% Note: User entity는 v2에서 추가 예정 - %% User "1" --> "N" Like : creates - - %% ============================================ - %% Relationships - Exceptions - %% ============================================ - BusinessException <|-- BrandNotFoundException - BusinessException <|-- ProductNotFoundException - BusinessException <|-- ProductOptionNotFoundException - BusinessException <|-- ImageNotFoundException - BusinessException <|-- BusinessRuleViolationException - BusinessException <|-- DuplicateLikeException - - BrandService ..> BrandNotFoundException : throws - ProductService ..> ProductNotFoundException : throws - ProductOptionService ..> ProductOptionNotFoundException : throws - ProductOptionService ..> BusinessRuleViolationException : throws - ProductImageService ..> ImageNotFoundException : throws - LikeService ..> DuplicateLikeException : throws -``` - ---- - -## 3️⃣ 레이어별 상세 설계 - -### Presentation Layer (Controller) - -#### BrandController -```java -@RestController -@RequestMapping("/api/v1/brands") -public class BrandController { - private final BrandService brandService; - - @GetMapping("/{brandId}") - public ResponseEntity getBrand(@PathVariable Long brandId) { - Brand brand = brandService.getBrand(brandId); - return ResponseEntity.ok(BrandResponse.from(brand)); - } -} -``` - -**책임:** -- HTTP 요청 처리 -- Path Variable 추출 -- 응답 DTO 변환 -- HTTP 상태 코드 반환 - -**예외 처리:** -- BrandNotFoundException → 404 Not Found - ---- - -#### ProductController -```java -@RestController -@RequestMapping("/api/v1/products") -public class ProductController { - private final ProductFacade productFacade; - - @GetMapping - public ResponseEntity> getProducts( - @RequestParam(required = false) Long brandId, - @RequestParam(defaultValue = "latest") String sort, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size, - @RequestHeader HttpHeaders headers - ) { - Long userId = extractUserId(headers); - Pageable pageable = PageRequest.of(page, size); - - Page products = productFacade.getProducts( - brandId, sort, pageable, userId - ); - - return ResponseEntity.ok(products); - } - - @GetMapping("/{productId}") - public ResponseEntity getProductDetail( - @PathVariable Long productId, - @RequestHeader HttpHeaders headers - ) { - Long userId = extractUserId(headers); - ProductDetailResponse response = productFacade.getProductDetail(productId, userId); - return ResponseEntity.ok(response); - } - - private Long extractUserId(HttpHeaders headers) { - // X-Loopers-LoginId 헤더에서 userId 추출 - // v1에서는 임시 구현, v2에서 정식 인증으로 전환 - String loginId = headers.getFirst("X-Loopers-LoginId"); - return loginId != null ? Long.valueOf(loginId) : null; - } -} -``` - -**책임:** -- HTTP 요청 처리 -- Query Parameter, Path Variable, Header 추출 -- 인증 정보 추출 (userId) -- Facade 호출 -- 응답 DTO 반환 - ---- - -### Application Layer (Facade) - -#### ProductFacade -```java -@Service -@Transactional(readOnly = true) -public class ProductFacade { - private final ProductService productService; - private final ProductOptionService productOptionService; - private final ProductImageService productImageService; - private final LikeService likeService; - - public Page getProducts( - Long brandId, String sort, Pageable pageable, Long userId - ) { - // 1. 상품 목록 조회 - Page products = productService.findProducts(brandId, sort, pageable); - List productIds = products.stream() - .map(Product::getProductId) - .collect(Collectors.toList()); - - // 2. 병렬로 부가 정보 조회 - Map minPrices = productOptionService.calculateMinPrices(productIds); - Map likeCounts = likeService.countLikes(productIds); - Set likedProducts = userId != null - ? likeService.checkLikedByUser(userId, productIds) - : Collections.emptySet(); - - // 3. 데이터 조합 - return combineProductListData(products, minPrices, likeCounts, likedProducts); - } - - public ProductDetailResponse getProductDetail(Long productId, Long userId) { - // 1. 상품 기본 정보 조회 - Product product = productService.findProduct(productId); - - // 2. 병렬로 상세 정보 조회 - List options = productOptionService.findOptions(productId); - List images = productImageService.findImages(productId); - int likeCount = likeService.countLikes(productId); - boolean isLiked = userId != null - ? likeService.checkLikedByUser(userId, productId) - : false; - - // 3. 옵션 검증 (최소 1개 이상) - if (options.isEmpty()) { - throw new ProductOptionNotFoundException(productId); - } - - // 4. 데이터 조합 - return combineProductDetailData(product, options, images, likeCount, isLiked); - } - - private Page combineProductListData(...) { - // 각 Product에 부가 정보를 조합하여 ProductListResponse 생성 - } - - private ProductDetailResponse combineProductDetailData(...) { - // Product + Options + Images + Like 정보를 조합하여 Response 생성 - } -} -``` - -**책임:** -- 여러 도메인 서비스 조율 (orchestration) -- 병렬 처리 가능한 작업 조율 -- 데이터 조합 및 응답 DTO 구성 -- 로그인 여부에 따른 분기 처리 -- 비즈니스 규칙 검증 (옵션 존재 여부) - -**예외 처리:** -- ProductNotFoundException (ProductService에서 전파) -- ProductOptionNotFoundException (옵션이 없을 때) - ---- - -### Domain Layer (Services) - -#### BrandService -```java -@Service -@Transactional(readOnly = true) -public class BrandService { - private final BrandRepository brandRepository; - - public Brand getBrand(Long brandId) { - return brandRepository.findById(brandId) - .orElseThrow(() -> new BrandNotFoundException(brandId)); - } -} -``` - -**책임:** -- 브랜드 도메인 비즈니스 로직 -- 브랜드 조회 - -**예외:** -- BrandNotFoundException - ---- - -#### ProductService -```java -@Service -@Transactional(readOnly = true) -public class ProductService { - private final ProductRepository productRepository; - - public Page findProducts(Long brandId, String sort, Pageable pageable) { - return productRepository.findAll(brandId, sort, pageable); - } - - public Product findProduct(Long productId) { - return productRepository.findById(productId) - .orElseThrow(() -> new ProductNotFoundException(productId)); - } -} -``` - -**책임:** -- 상품 도메인 비즈니스 로직 -- 상품 조회 (목록, 상세) - -**예외:** -- ProductNotFoundException - ---- - -#### ProductOptionService -```java -@Service -@Transactional(readOnly = true) -public class ProductOptionService { - private final ProductOptionRepository productOptionRepository; - - public List findOptions(Long productId) { - return productOptionRepository.findByProductId(productId); - } - - public Map calculateMinPrices(List productIds) { - return productOptionRepository.findMinPricesByProductIds(productIds); - } - - public void validateOptions(List options) { - for (ProductOption option : options) { - option.validatePrice(); - option.validateStock(); - } - } -} -``` - -**책임:** -- 상품 옵션 도메인 비즈니스 로직 -- 옵션 조회 -- 최저가 계산 (집계 쿼리) -- 가격/재고 검증 - -**예외:** -- BusinessRuleViolationException (가격 음수, 재고 음수 등) - ---- - -#### ProductImageService -```java -@Service -@Transactional(readOnly = true) -public class ProductImageService { - private final ProductImageRepository productImageRepository; - - public List findImages(Long productId) { - return productImageRepository.findByProductId(productId); - } - - public void validateImageExists(String imageUrl) { - // 실제 이미지 리소스 존재 여부 확인 (S3, CDN 등) - // 이미지가 없으면 ImageNotFoundException 발생 - } -} -``` - -**책임:** -- 상품 이미지 도메인 비즈니스 로직 -- 이미지 조회 -- 이미지 리소스 존재 검증 - -**예외:** -- ImageNotFoundException - ---- - -#### LikeService -```java -@Service -@Transactional(readOnly = true) -public class LikeService { - private final LikeRepository likeRepository; - - public int countLikes(Long productId) { - return likeRepository.countByProductId(productId); - } - - public Map countLikes(List productIds) { - return likeRepository.countByProductIds(productIds); - } - - public boolean checkLikedByUser(Long userId, Long productId) { - return likeRepository.existsByUserIdAndProductId(userId, productId); - } - - public Set checkLikedByUser(Long userId, List productIds) { - return likeRepository.existsByUserIdAndProductIds(userId, productIds); - } - - @Transactional - public void addLike(Long userId, Long productId) { - if (likeRepository.existsByUserIdAndProductId(userId, productId)) { - throw new DuplicateLikeException(userId, productId); - } - Like like = new Like(userId, productId); - likeRepository.save(like); - // 이벤트 발행 (카운트 업데이트) - 별도 문서 참조 - } - - @Transactional - public void removeLike(Long userId, Long productId) { - likeRepository.deleteByUserIdAndProductId(userId, productId); - // 이벤트 발행 (카운트 업데이트) - 별도 문서 참조 - } -} -``` - -**책임:** -- 좋아요 도메인 비즈니스 로직 -- 좋아요 수 조회 (단일, 배치) -- 좋아요 여부 확인 (단일, 배치) -- 좋아요 등록/취소 - -**예외:** -- DuplicateLikeException - ---- - -### Domain Layer (Entities) - -#### Brand -```java -@Entity -@Table(name = "brands") -public class Brand { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long brandId; - - @Column(nullable = false, unique = true, length = 100) - private String name; - - @Column(columnDefinition = "TEXT") - private String description; - - @Column(length = 500) - private String logoImageUrl; - - @Column(nullable = false, updatable = false) - private LocalDateTime createdAt; - - protected Brand() {} // JPA - - public Brand(String name, String description, String logoImageUrl) { - this.name = name; - this.description = description; - this.logoImageUrl = logoImageUrl; - this.createdAt = LocalDateTime.now(); - } - - // Getters -} -``` - -**설계 포인트:** -- 불변 객체 지향 (Setter 없음) -- 생성자를 통한 필수 값 주입 -- JPA 기본 생성자는 protected - ---- - -#### Product -```java -@Entity -@Table(name = "products") -public class Product { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long productId; - - @Column(nullable = false) - private Long brandId; // FK 제약 없음 - - @Column(nullable = false, length = 200) - private String name; - - @Column(columnDefinition = "TEXT") - private String description; - - @Column(nullable = false, updatable = false) - private LocalDateTime createdAt; - - protected Product() {} // JPA - - public Product(Long brandId, String name, String description) { - this.brandId = brandId; - this.name = name; - this.description = description; - this.createdAt = LocalDateTime.now(); - validateBusinessRules(); - } - - public void validateBusinessRules() { - if (brandId == null || brandId <= 0) { - throw new BusinessRuleViolationException("brandId must be positive"); - } - if (name == null || name.isBlank()) { - throw new BusinessRuleViolationException("Product name is required"); - } - } - - // Getters -} -``` - -**설계 포인트:** -- brandId는 Long 타입 (FK 제약 없음) -- 생성자에서 비즈니스 규칙 검증 -- 도메인 무결성은 애플리케이션 레벨에서 관리 - ---- - -#### ProductOption -```java -@Entity -@Table( - name = "product_options", - uniqueConstraints = @UniqueConstraint(columnNames = {"product_id", "name"}) -) -public class ProductOption { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long optionId; - - @Column(nullable = false) - private Long productId; // FK 제약 없음 - - @Column(nullable = false, length = 100) - private String name; - - @Column(nullable = false) - private Integer price; - - @Column(nullable = false) - private Integer stockQuantity; - - @Column(nullable = false, updatable = false) - private LocalDateTime createdAt; - - protected ProductOption() {} // JPA - - public ProductOption(Long productId, String name, Integer price, Integer stockQuantity) { - this.productId = productId; - this.name = name; - this.price = price; - this.stockQuantity = stockQuantity; - this.createdAt = LocalDateTime.now(); - validatePrice(); - validateStock(); - } - - public boolean isAvailable() { - return stockQuantity > 0; - } - - public void validatePrice() { - if (price == null || price < 0) { - throw new BusinessRuleViolationException("Price must be non-negative"); - } - } - - public void validateStock() { - if (stockQuantity == null || stockQuantity < 0) { - throw new BusinessRuleViolationException("Stock quantity must be non-negative"); - } - } - - // Getters -} -``` - -**설계 포인트:** -- 가격, 재고 검증 로직 포함 -- `isAvailable()` 비즈니스 메서드 -- Unique 제약: 같은 상품 내 옵션명 중복 불가 - ---- - -#### ProductImage -```java -@Entity -@Table(name = "product_images") -public class ProductImage { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long imageId; - - @Column(nullable = false) - private Long productId; // FK 제약 없음 - - @Column(nullable = false, length = 500) - private String imageUrl; - - @Column(nullable = false) - private Integer displayOrder; - - @Column(nullable = false, updatable = false) - private LocalDateTime createdAt; - - protected ProductImage() {} // JPA - - public ProductImage(Long productId, String imageUrl, Integer displayOrder) { - this.productId = productId; - this.imageUrl = imageUrl; - this.displayOrder = displayOrder; - this.createdAt = LocalDateTime.now(); - } - - // Getters -} -``` - -**설계 포인트:** -- displayOrder로 이미지 순서 관리 -- 실제 이미지 파일은 S3/CDN에 저장, URL만 DB에 보관 - ---- - -#### Like -```java -@Entity -@Table( - name = "likes", - uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "product_id"}) -) -public class Like { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long likeId; - - @Column(nullable = false) - private Long userId; // FK 제약 없음, v2에서 User 엔티티 추가 예정 - - @Column(nullable = false) - private Long productId; // FK 제약 없음 - - @Column(nullable = false, updatable = false) - private LocalDateTime createdAt; - - protected Like() {} // JPA - - public Like(Long userId, Long productId) { - this.userId = userId; - this.productId = productId; - this.createdAt = LocalDateTime.now(); - } - - // Getters -} -``` - -**설계 포인트:** -- Unique 제약: 사용자당 상품 1개만 좋아요 가능 -- 중복 좋아요는 DB 레벨에서 방지 -- v1에서는 userId를 임시 식별자로 사용 - ---- - -## 4️⃣ 예외 계층 구조 - -```mermaid -classDiagram - class RuntimeException { - <> - } - - class BusinessException { - <> - -String errorCode - -String message - -HttpStatus httpStatus - +BusinessException(errorCode, message, httpStatus) - +getErrorCode() String - +getMessage() String - +getHttpStatus() HttpStatus - } - - class BrandNotFoundException { - +BrandNotFoundException(brandId: Long) - } - - class ProductNotFoundException { - +ProductNotFoundException(productId: Long) - } - - class ProductOptionNotFoundException { - +ProductOptionNotFoundException(productId: Long) - } - - class ImageNotFoundException { - +ImageNotFoundException(imageUrl: String) - } - - class BusinessRuleViolationException { - +BusinessRuleViolationException(message: String) - } - - class DuplicateLikeException { - +DuplicateLikeException(userId: Long, productId: Long) - } - - RuntimeException <|-- BusinessException - BusinessException <|-- BrandNotFoundException - BusinessException <|-- ProductNotFoundException - BusinessException <|-- ProductOptionNotFoundException - BusinessException <|-- ImageNotFoundException - BusinessException <|-- BusinessRuleViolationException - BusinessException <|-- DuplicateLikeException -``` - -### 예외 클래스 상세 - -#### BusinessException (추상 베이스 클래스) -```java -public abstract class BusinessException extends RuntimeException { - private final String errorCode; - private final HttpStatus httpStatus; - - protected BusinessException(String errorCode, String message, HttpStatus httpStatus) { - super(message); - this.errorCode = errorCode; - this.httpStatus = httpStatus; - } - - public String getErrorCode() { - return errorCode; - } - - public HttpStatus getHttpStatus() { - return httpStatus; - } -} -``` - ---- - -#### BrandNotFoundException -```java -public class BrandNotFoundException extends BusinessException { - public BrandNotFoundException(Long brandId) { - super( - "BRAND_NOT_FOUND", - String.format("Brand not found: brandId=%d", brandId), - HttpStatus.NOT_FOUND - ); - } -} -``` - -**발생 시점:** 브랜드 조회 시 존재하지 않을 때 -**HTTP 상태:** 404 Not Found -**복구 전략:** 사용자에게 브랜드가 존재하지 않음을 알림 - ---- - -#### ProductNotFoundException -```java -public class ProductNotFoundException extends BusinessException { - public ProductNotFoundException(Long productId) { - super( - "PRODUCT_NOT_FOUND", - String.format("Product not found: productId=%d", productId), - HttpStatus.NOT_FOUND - ); - } -} -``` - -**발생 시점:** -- 상품 조회 시 존재하지 않을 때 -- 타이밍 이슈로 조회 중 삭제되었을 때 (동시성) - -**HTTP 상태:** 404 Not Found -**복구 전략:** 사용자에게 상품이 존재하지 않음을 알림 - ---- - -#### ProductOptionNotFoundException -```java -public class ProductOptionNotFoundException extends BusinessException { - public ProductOptionNotFoundException(Long productId) { - super( - "PRODUCT_OPTION_NOT_FOUND", - String.format("Product options not found for productId=%d. Data integrity violation.", productId), - HttpStatus.INTERNAL_SERVER_ERROR - ); - } -} -``` - -**발생 시점:** 상품은 존재하는데 옵션이 하나도 없을 때 (데이터 무결성 위반) -**HTTP 상태:** 500 Internal Server Error -**복구 전략:** -- 시스템 관리자에게 알림 -- 데이터 정합성 복구 필요 -- 사용자에게는 일시적 오류 안내 - ---- - -#### ImageNotFoundException -```java -public class ImageNotFoundException extends BusinessException { - public ImageNotFoundException(String imageUrl) { - super( - "IMAGE_NOT_FOUND", - String.format("Image resource not found: url=%s", imageUrl), - HttpStatus.NOT_FOUND - ); - } -} -``` - -**발생 시점:** 이미지 URL은 DB에 있지만 실제 리소스(S3, CDN)가 없을 때 -**HTTP 상태:** 404 Not Found (또는 500으로 설정 가능) -**복구 전략:** -- 기본 이미지로 대체 -- 시스템 관리자에게 알림 (이미지 리소스 복구 필요) - ---- - -#### BusinessRuleViolationException -```java -public class BusinessRuleViolationException extends BusinessException { - public BusinessRuleViolationException(String message) { - super( - "BUSINESS_RULE_VIOLATION", - message, - HttpStatus.INTERNAL_SERVER_ERROR - ); - } -} -``` - -**발생 시점:** -- 가격이 음수일 때 -- 재고가 음수일 때 -- 기타 비즈니스 규칙 위반 - -**HTTP 상태:** 500 Internal Server Error -**복구 전략:** -- 시스템 관리자에게 알림 -- 데이터 검증 강화 -- 사용자에게는 일시적 오류 안내 - ---- - -#### DuplicateLikeException -```java -public class DuplicateLikeException extends BusinessException { - public DuplicateLikeException(Long userId, Long productId) { - super( - "DUPLICATE_LIKE", - String.format("User already liked this product: userId=%d, productId=%d", userId, productId), - HttpStatus.CONFLICT - ); - } -} -``` - -**발생 시점:** 이미 좋아요를 누른 상품에 다시 좋아요 시도 -**HTTP 상태:** 409 Conflict -**복구 전략:** 사용자에게 이미 좋아요했음을 안내 - ---- - -## 5️⃣ 설계 원칙 및 고려사항 - -### 1. 레이어 분리 원칙 - -#### Controller 책임 -- HTTP 프로토콜 처리에만 집중 -- 비즈니스 로직 없음 -- 인증 정보 추출 (userId) -- 예외를 HTTP 상태 코드로 변환 - -#### Facade 책임 -- 여러 도메인 서비스 조율 -- 복잡한 흐름 관리 -- 데이터 조합 -- **비즈니스 규칙은 Service에 위임** - -#### Service 책임 -- 도메인별 비즈니스 로직 -- 단일 도메인에 집중 -- 트랜잭션 경계 -- Entity 검증 및 생성 - -#### Repository 책임 -- 데이터 접근만 -- 쿼리 최적화 -- 영속성 관리 - ---- - -### 2. Facade 사용 기준 - -**Facade가 필요한 경우:** -- 여러 도메인 서비스 협력이 필요한 경우 -- 복잡한 데이터 조합이 필요한 경우 -- 조건부 처리(로그인 여부 등)가 필요한 경우 - -**Facade가 불필요한 경우:** -- 단일 도메인만 다루는 경우 (예: 브랜드 조회) -- Controller → Service 직접 호출로 충분한 경우 - ---- - -### 3. 예외 처리 전략 - -#### 예외 계층 구조 -``` -RuntimeException - └─ BusinessException (추상) - ├─ BrandNotFoundException (404) - ├─ ProductNotFoundException (404) - ├─ ProductOptionNotFoundException (500) ← 치명적 - ├─ ImageNotFoundException (404/500) - ├─ BusinessRuleViolationException (500) ← 치명적 - └─ DuplicateLikeException (409) -``` - -#### 치명적 vs 일반 예외 - -| 예외 | 치명도 | HTTP | 복구 전략 | -|------|--------|------|----------| -| BrandNotFoundException | 일반 | 404 | 사용자 안내 | -| ProductNotFoundException | 일반 | 404 | 사용자 안내 | -| **ProductOptionNotFoundException** | **치명적** | **500** | **시스템 알림, 데이터 복구** | -| **ImageNotFoundException** | 치명적 | 404 | 기본 이미지 대체, 알림 | -| **BusinessRuleViolationException** | **치명적** | **500** | **시스템 알림, 데이터 검증** | -| DuplicateLikeException | 일반 | 409 | 사용자 안내 | - -#### GlobalExceptionHandler (예시) -```java -@RestControllerAdvice -public class GlobalExceptionHandler { - - @ExceptionHandler(BusinessException.class) - public ResponseEntity handleBusinessException(BusinessException e) { - // 치명적 예외는 로깅 + 알림 - if (e.getHttpStatus().is5xxServerError()) { - log.error("Critical error occurred", e); - // 시스템 관리자에게 알림 (슬랙, 이메일 등) - } - - ErrorResponse response = new ErrorResponse( - e.getErrorCode(), - e.getMessage() - ); - - return ResponseEntity - .status(e.getHttpStatus()) - .body(response); - } -} -``` - ---- - -### 4. FK 제약 없는 설계 - -**이유:** -- 애플리케이션 레벨에서 참조 무결성 관리 -- DB 레벨 제약으로 인한 성능 오버헤드 제거 -- 향후 샤딩, 마이크로서비스 전환 시 유연성 확보 - -**트레이드오프:** -- 데이터 정합성은 애플리케이션 책임 -- 고아 레코드(orphan records) 발생 가능성 -- 정기적인 데이터 정합성 체크 필요 - -**보완 전략:** -- Service 레벨에서 참조 검증 -- 배치 작업을 통한 정합성 체크 -- 모니터링 및 알림 - ---- - -### 5. 성능 고려사항 - -#### N+1 문제 방지 -- Fetch Join 활용 -- 배치 조회 메서드 제공 (예: `calculateMinPrices(List)`) -- Repository에서 IN 절 쿼리 사용 - -#### 병렬 처리 -- Facade에서 독립적인 조회는 병렬 실행 가능 -- CompletableFuture 또는 @Async 활용 고려 - -#### 캐싱 -- 자주 조회되는 브랜드 정보 캐싱 -- 상품 최저가 계산 결과 캐싱 (Redis) -- 좋아요 수 캐싱 (Eventual Consistency 허용) - ---- - -**문서 끝** \ No newline at end of file diff --git "a/docs/design/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/04-erd.md" "b/docs/design/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/04-erd.md" deleted file mode 100644 index 9b98098bd..000000000 --- "a/docs/design/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/04-erd.md" +++ /dev/null @@ -1,792 +0,0 @@ -brands - 브랜드 테이블 -sqlCREATE TABLE brands ( -brand_id BIGINT AUTO_INCREMENT PRIMARY KEY, -name VARCHAR(100) NOT NULL UNIQUE, -description TEXT, -logo_image_url VARCHAR(500), -created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, -INDEX idx_name (name) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -product_options - 상품 옵션 (가격/재고 관리 단위) -sqlCREATE TABLE product_options ( -option_id BIGINT AUTO_INCREMENT PRIMARY KEY, -product_id BIGINT NOT NULL, -name VARCHAR(100) NOT NULL, -price INT NOT NULL, -stock_quantity INT NOT NULL DEFAULT 0, -created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, -CONSTRAINT uk_product_option_name UNIQUE (product_id, name), -INDEX idx_product_id (product_id), -INDEX idx_product_price (product_id, price) -- 최저가 계산용 -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -product_images - 상품 이미지 (여러 장 가능) -sqlCREATE TABLE product_images ( -image_id BIGINT AUTO_INCREMENT PRIMARY KEY, -product_id BIGINT NOT NULL, -image_url VARCHAR(500) NOT NULL, -display_order INT NOT NULL, -created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, -INDEX idx_product_display_order (product_id, display_order) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - -2. 수정해야 할 테이블 - products - brand_id 추가, price 제거 - sqlALTER TABLE products - ADD COLUMN brand_id BIGINT NOT NULL AFTER id, - DROP COLUMN price, - ADD INDEX idx_brand_id (brand_id), - ADD INDEX idx_brand_created (brand_id, created_at DESC); -- 브랜드별 최신순 조회 - 이유: - -price는 이제 product_options에서 관리 -brand_id 추가 (어느 브랜드 상품인지) -브랜드별 상품 조회를 위한 복합 인덱스 - -likes - user_id 인덱스 추가 -sqlALTER TABLE likes -ADD INDEX idx_user_id (user_id); -- 내 좋아요 목록 조회용 -이유: - -"내 좋아요 목록 조회" 기능을 위해 user_id 인덱스 필요 - - -3. 좋아요 카운트 관리 - 현재는 products 테이블에 like_count 컬럼이 없는데, 두 가지 선택지가 있어요: - A. 추가하지 않음 (실시간 COUNT) - -매번 SELECT COUNT(*) FROM likes WHERE product_id = ? -정확하지만 느림 - -B. 추가함 (비정규화) -sqlALTER TABLE products -ADD COLUMN like_count INT NOT NULL DEFAULT 0; - -좋아요 등록/취소 시 비동기로 업데이트 -Eventual Consistency (좋아요 명세 문서에서 언급) - - -# ERD (Entity Relationship Diagram) - -## 1️⃣ 전체 ERD 개요 - -### 테이블 구조 -``` -brands (브랜드) - ├── brand_id (PK) - └── [1:N] products - -products (상품) - ├── product_id (PK) - ├── brand_id (참조, FK 제약 없음) - ├── [1:N] product_options - ├── [1:N] product_images - └── [1:N] likes - -product_options (상품 옵션) - ├── option_id (PK) - └── product_id (참조, FK 제약 없음) - -product_images (상품 이미지) - ├── image_id (PK) - └── product_id (참조, FK 제약 없음) - -likes (좋아요) - ├── like_id (PK) - ├── user_id (참조, FK 제약 없음) - └── product_id (참조, FK 제약 없음) - -users (사용자) - v2에서 추가 예정 - └── user_id (PK) -``` - ---- - -## 2️⃣ ERD 다이어그램 - -```mermaid -erDiagram - brands ||--o{ products : "has" - products ||--o{ product_options : "has" - products ||--o{ product_images : "has" - products ||--o{ likes : "receives" - users ||--o{ likes : "creates" - - brands { - BIGINT brand_id PK "AUTO_INCREMENT" - VARCHAR(100) name UK "NOT NULL, UNIQUE" - TEXT description "브랜드 설명" - VARCHAR(500) logo_image_url "로고 이미지 URL" - TIMESTAMP created_at "NOT NULL, DEFAULT CURRENT_TIMESTAMP" - } - - products { - BIGINT product_id PK "AUTO_INCREMENT" - BIGINT brand_id "NOT NULL, 브랜드 참조 (FK 제약 없음)" - VARCHAR(200) name "NOT NULL, 상품명" - TEXT description "상품 상세 설명" - INT like_count "NOT NULL, DEFAULT 0, 비정규화 컬럼" - TIMESTAMP created_at "NOT NULL, DEFAULT CURRENT_TIMESTAMP" - } - - product_options { - BIGINT option_id PK "AUTO_INCREMENT" - BIGINT product_id "NOT NULL, 상품 참조 (FK 제약 없음)" - VARCHAR(100) name UK "NOT NULL, 옵션명 (S, M, L 등)" - INT price "NOT NULL, 가격" - INT stock_quantity "NOT NULL, DEFAULT 0, 재고" - TIMESTAMP created_at "NOT NULL, DEFAULT CURRENT_TIMESTAMP" - } - - product_images { - BIGINT image_id PK "AUTO_INCREMENT" - BIGINT product_id "NOT NULL, 상품 참조 (FK 제약 없음)" - VARCHAR(500) image_url "NOT NULL, 이미지 URL" - INT display_order "NOT NULL, 표시 순서" - TIMESTAMP created_at "NOT NULL, DEFAULT CURRENT_TIMESTAMP" - } - - likes { - BIGINT like_id PK "AUTO_INCREMENT" - BIGINT user_id "NOT NULL, 사용자 참조 (FK 제약 없음)" - BIGINT product_id "NOT NULL, 상품 참조 (FK 제약 없음)" - TIMESTAMP created_at "NOT NULL, DEFAULT CURRENT_TIMESTAMP" - } - - users { - BIGINT user_id PK "AUTO_INCREMENT, v2에서 추가 예정" - VARCHAR(50) username "NOT NULL, UNIQUE" - VARCHAR(100) email "NOT NULL, UNIQUE" - TIMESTAMP created_at "NOT NULL, DEFAULT CURRENT_TIMESTAMP" - } -``` - ---- - -## 3️⃣ 테이블 상세 정의 - -### brands (브랜드) - -```sql -CREATE TABLE brands ( - brand_id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '브랜드 ID', - name VARCHAR(100) NOT NULL UNIQUE COMMENT '브랜드명', - description TEXT COMMENT '브랜드 설명', - logo_image_url VARCHAR(500) COMMENT '로고 이미지 URL', - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일시', - - -- 인덱스 - INDEX idx_name (name) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='브랜드'; -``` - -**컬럼 설명:** -| 컬럼명 | 타입 | 제약 | 설명 | -|--------|------|------|------| -| brand_id | BIGINT | PK, AUTO_INCREMENT | 브랜드 고유 ID | -| name | VARCHAR(100) | NOT NULL, UNIQUE | 브랜드명 (중복 불가) | -| description | TEXT | NULL | 브랜드 설명 | -| logo_image_url | VARCHAR(500) | NULL | 로고 이미지 URL (S3/CDN) | -| created_at | TIMESTAMP | NOT NULL | 생성일시 | - -**인덱스 전략:** -| 인덱스명 | 컬럼 | 용도 | -|----------|------|------| -| PRIMARY | brand_id | PK | -| idx_name | name | 브랜드명 검색 (UNIQUE 제약) | - -**샘플 데이터:** -```sql -INSERT INTO brands (brand_id, name, description, logo_image_url, created_at) VALUES -(1, 'Nike', '글로벌 스포츠 브랜드', 'https://cdn.example.com/brands/nike-logo.png', '2025-01-01 00:00:00'), -(2, 'Adidas', '독일 스포츠 브랜드', 'https://cdn.example.com/brands/adidas-logo.png', '2025-01-01 00:00:00'), -(3, 'Apple', '프리미엄 전자기기 브랜드', 'https://cdn.example.com/brands/apple-logo.png', '2025-01-02 00:00:00'); -``` - ---- - -### products (상품) - -```sql -CREATE TABLE products ( - product_id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '상품 ID', - brand_id BIGINT NOT NULL COMMENT '브랜드 ID (FK 제약 없음)', - name VARCHAR(200) NOT NULL COMMENT '상품명', - description TEXT COMMENT '상품 상세 설명', - like_count INT NOT NULL DEFAULT 0 COMMENT '좋아요 수 (비정규화)', - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일시', - - -- 인덱스 - INDEX idx_brand_id (brand_id), - INDEX idx_created_at (created_at DESC), - INDEX idx_brand_created (brand_id, created_at DESC), - INDEX idx_like_count (like_count DESC) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='상품'; -``` - -**컬럼 설명:** -| 컬럼명 | 타입 | 제약 | 설명 | -|--------|------|------|------| -| product_id | BIGINT | PK, AUTO_INCREMENT | 상품 고유 ID | -| brand_id | BIGINT | NOT NULL | 브랜드 ID (애플리케이션 레벨에서 참조 관리) | -| name | VARCHAR(200) | NOT NULL | 상품명 | -| description | TEXT | NULL | 상품 상세 설명 | -| like_count | INT | NOT NULL, DEFAULT 0 | 좋아요 수 (비동기 업데이트, Eventual Consistency) | -| created_at | TIMESTAMP | NOT NULL | 생성일시 | - -**인덱스 전략:** -| 인덱스명 | 컬럼 | 용도 | -|----------|------|------| -| PRIMARY | product_id | PK | -| idx_brand_id | brand_id | 브랜드별 상품 조회 (`WHERE brand_id = ?`) | -| idx_created_at | created_at DESC | 최신순 정렬 (`ORDER BY created_at DESC`) | -| idx_brand_created | brand_id, created_at DESC | 브랜드별 최신순 조회 (복합 인덱스) | -| idx_like_count | like_count DESC | 인기순 정렬 (`ORDER BY like_count DESC`) | - -**설계 노트:** -- **like_count 비정규화**: 좋아요 수를 매번 COUNT 하지 않고 컬럼에 저장 -- **FK 제약 없음**: brand_id는 애플리케이션 레벨에서 검증 -- **복합 인덱스**: 브랜드별 + 최신순 조회 최적화 - -**샘플 데이터:** -```sql -INSERT INTO products (product_id, brand_id, name, description, like_count, created_at) VALUES -(1, 1, 'Nike Air Max 90', '나이키 에어맥스 90 운동화', 150, '2025-01-10 10:00:00'), -(2, 1, 'Nike Dri-FIT T-Shirt', '나이키 드라이핏 티셔츠', 80, '2025-01-11 10:00:00'), -(3, 2, 'Adidas Ultraboost', '아디다스 울트라부스트 러닝화', 200, '2025-01-12 10:00:00'), -(4, 3, 'iPhone 15 Pro', '애플 아이폰 15 프로', 500, '2025-01-13 10:00:00'); -``` - ---- - -### product_options (상품 옵션) - -```sql -CREATE TABLE product_options ( - option_id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '옵션 ID', - product_id BIGINT NOT NULL COMMENT '상품 ID (FK 제약 없음)', - name VARCHAR(100) NOT NULL COMMENT '옵션명 (예: S, M, L)', - price INT NOT NULL COMMENT '가격', - stock_quantity INT NOT NULL DEFAULT 0 COMMENT '재고 수량', - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일시', - - -- 제약 - CONSTRAINT uk_product_option_name UNIQUE (product_id, name), - CONSTRAINT chk_price CHECK (price >= 0), - CONSTRAINT chk_stock CHECK (stock_quantity >= 0), - - -- 인덱스 - INDEX idx_product_id (product_id), - INDEX idx_product_price (product_id, price) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='상품 옵션'; -``` - -**컬럼 설명:** -| 컬럼명 | 타입 | 제약 | 설명 | -|--------|------|------|------| -| option_id | BIGINT | PK, AUTO_INCREMENT | 옵션 고유 ID | -| product_id | BIGINT | NOT NULL | 상품 ID (애플리케이션 레벨에서 참조 관리) | -| name | VARCHAR(100) | NOT NULL | 옵션명 (S, M, L, 빨강, 파랑 등) | -| price | INT | NOT NULL, >= 0 | 옵션별 가격 | -| stock_quantity | INT | NOT NULL, >= 0 | 옵션별 재고 수량 | -| created_at | TIMESTAMP | NOT NULL | 생성일시 | - -**인덱스 전략:** -| 인덱스명 | 컬럼 | 용도 | -|----------|------|------| -| PRIMARY | option_id | PK | -| uk_product_option_name | product_id, name | 같은 상품 내 옵션명 중복 방지 (UNIQUE) | -| idx_product_id | product_id | 상품별 옵션 조회 (`WHERE product_id = ?`) | -| idx_product_price | product_id, price | 최저가 계산 (`MIN(price) WHERE product_id IN (...)`) | - -**설계 노트:** -- **UNIQUE 제약**: 같은 상품 내에서 옵션명 중복 불가 (예: Nike Air Max 90에 "M" 사이즈는 1개만) -- **CHECK 제약**: 가격과 재고는 음수 불가 -- **복합 인덱스**: 최저가 계산 최적화 - -**샘플 데이터:** -```sql -INSERT INTO product_options (option_id, product_id, name, price, stock_quantity, created_at) VALUES --- Nike Air Max 90 (product_id=1) -(1, 1, '250mm', 120000, 10, '2025-01-10 10:00:00'), -(2, 1, '260mm', 120000, 5, '2025-01-10 10:00:00'), -(3, 1, '270mm', 125000, 0, '2025-01-10 10:00:00'), - --- Nike Dri-FIT T-Shirt (product_id=2) -(4, 2, 'S', 35000, 20, '2025-01-11 10:00:00'), -(5, 2, 'M', 35000, 15, '2025-01-11 10:00:00'), -(6, 2, 'L', 38000, 10, '2025-01-11 10:00:00'), - --- Adidas Ultraboost (product_id=3) -(7, 3, '250mm', 180000, 8, '2025-01-12 10:00:00'), -(8, 3, '260mm', 180000, 12, '2025-01-12 10:00:00'), - --- iPhone 15 Pro (product_id=4) -(9, 4, '128GB', 1350000, 50, '2025-01-13 10:00:00'), -(10, 4, '256GB', 1550000, 30, '2025-01-13 10:00:00'), -(11, 4, '512GB', 1850000, 20, '2025-01-13 10:00:00'); -``` - -**최저가 계산 예시:** -```sql --- product_id=1 (Nike Air Max 90)의 최저가는 120000원 (옵션 1, 2) --- product_id=4 (iPhone 15 Pro)의 최저가는 1350000원 (옵션 9) -``` - ---- - -### product_images (상품 이미지) - -```sql -CREATE TABLE product_images ( - image_id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '이미지 ID', - product_id BIGINT NOT NULL COMMENT '상품 ID (FK 제약 없음)', - image_url VARCHAR(500) NOT NULL COMMENT '이미지 URL', - display_order INT NOT NULL COMMENT '표시 순서', - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일시', - - -- 인덱스 - INDEX idx_product_display_order (product_id, display_order) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='상품 이미지'; -``` - -**컬럼 설명:** -| 컬럼명 | 타입 | 제약 | 설명 | -|--------|------|------|------| -| image_id | BIGINT | PK, AUTO_INCREMENT | 이미지 고유 ID | -| product_id | BIGINT | NOT NULL | 상품 ID (애플리케이션 레벨에서 참조 관리) | -| image_url | VARCHAR(500) | NOT NULL | 이미지 URL (S3/CDN) | -| display_order | INT | NOT NULL | 표시 순서 (1, 2, 3...) | -| created_at | TIMESTAMP | NOT NULL | 생성일시 | - -**인덱스 전략:** -| 인덱스명 | 컬럼 | 용도 | -|----------|------|------| -| PRIMARY | image_id | PK | -| idx_product_display_order | product_id, display_order | 상품별 이미지 순서대로 조회 | - -**설계 노트:** -- **display_order**: 이미지 표시 순서 (첫 번째 이미지가 썸네일) -- **복합 인덱스**: 상품별 + 순서대로 정렬하여 조회 최적화 - -**샘플 데이터:** -```sql -INSERT INTO product_images (image_id, product_id, image_url, display_order, created_at) VALUES --- Nike Air Max 90 (product_id=1) -(1, 1, 'https://cdn.example.com/products/nike-air-max-90-1.jpg', 1, '2025-01-10 10:00:00'), -(2, 1, 'https://cdn.example.com/products/nike-air-max-90-2.jpg', 2, '2025-01-10 10:00:00'), -(3, 1, 'https://cdn.example.com/products/nike-air-max-90-3.jpg', 3, '2025-01-10 10:00:00'), - --- Nike Dri-FIT T-Shirt (product_id=2) -(4, 2, 'https://cdn.example.com/products/nike-tshirt-1.jpg', 1, '2025-01-11 10:00:00'), - --- iPhone 15 Pro (product_id=4) -(5, 4, 'https://cdn.example.com/products/iphone-15-pro-1.jpg', 1, '2025-01-13 10:00:00'), -(6, 4, 'https://cdn.example.com/products/iphone-15-pro-2.jpg', 2, '2025-01-13 10:00:00'); -``` - ---- - -### likes (좋아요) - -```sql -CREATE TABLE likes ( - like_id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '좋아요 ID', - user_id BIGINT NOT NULL COMMENT '사용자 ID (FK 제약 없음)', - product_id BIGINT NOT NULL COMMENT '상품 ID (FK 제약 없음)', - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일시', - - -- 제약 - CONSTRAINT uk_likes_user_product UNIQUE (user_id, product_id), - - -- 인덱스 - INDEX idx_product_id (product_id), - INDEX idx_user_id (user_id), - INDEX idx_created_at (created_at DESC) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='좋아요'; -``` - -**컬럼 설명:** -| 컬럼명 | 타입 | 제약 | 설명 | -|--------|------|------|------| -| like_id | BIGINT | PK, AUTO_INCREMENT | 좋아요 고유 ID | -| user_id | BIGINT | NOT NULL | 사용자 ID (v1: 임시 식별자, v2: users 테이블 참조) | -| product_id | BIGINT | NOT NULL | 상품 ID (애플리케이션 레벨에서 참조 관리) | -| created_at | TIMESTAMP | NOT NULL | 좋아요 생성일시 | - -**인덱스 전략:** -| 인덱스명 | 컬럼 | 용도 | -|----------|------|------| -| PRIMARY | like_id | PK | -| uk_likes_user_product | user_id, product_id | 중복 좋아요 방지 (UNIQUE) | -| idx_product_id | product_id | 상품별 좋아요 수 집계 (`COUNT(*) WHERE product_id = ?`) | -| idx_user_id | user_id | 사용자의 좋아요 목록 조회 (`WHERE user_id = ?`) | -| idx_created_at | created_at DESC | 최근 좋아요 조회 (분석용) | - -**설계 노트:** -- **UNIQUE 제약**: 사용자는 상품 1개당 좋아요 1개만 가능 -- **중복 방지**: DB 레벨에서 중복 좋아요 차단 -- **인덱스 중복**: uk_likes_user_product (UNIQUE)가 user_id로 시작하므로 idx_user_id는 선택적 - -**샘플 데이터:** -```sql -INSERT INTO likes (like_id, user_id, product_id, created_at) VALUES --- user_id=1 -(1, 1, 1, '2025-01-15 10:00:00'), -- Nike Air Max 90 -(2, 1, 3, '2025-01-15 10:05:00'), -- Adidas Ultraboost -(3, 1, 4, '2025-01-15 10:10:00'), -- iPhone 15 Pro - --- user_id=2 -(4, 2, 1, '2025-01-15 11:00:00'), -- Nike Air Max 90 -(5, 2, 2, '2025-01-15 11:05:00'), -- Nike T-Shirt - --- user_id=3 -(6, 3, 4, '2025-01-15 12:00:00'); -- iPhone 15 Pro -``` - -**좋아요 수 계산 예시:** -```sql --- product_id=1 (Nike Air Max 90): 2개 (user_id 1, 2) --- product_id=3 (Adidas Ultraboost): 1개 (user_id 1) --- product_id=4 (iPhone 15 Pro): 2개 (user_id 1, 3) -``` - ---- - -### users (사용자) - v2에서 추가 예정 - -```sql --- v2에서 추가 예정 -CREATE TABLE users ( - user_id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '사용자 ID', - username VARCHAR(50) NOT NULL UNIQUE COMMENT '사용자명', - email VARCHAR(100) NOT NULL UNIQUE COMMENT '이메일', - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일시', - - -- 인덱스 - INDEX idx_username (username), - INDEX idx_email (email) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='사용자'; -``` - -**설계 노트:** -- v1에서는 user_id를 임시 식별자로 사용 (헤더의 LoginId) -- v2에서 정식 회원 테이블로 전환 예정 - ---- - -## 4️⃣ 인덱스 전략 상세 - -### 인덱스 설계 원칙 - -#### 1. 조회 패턴 기반 인덱스 -| 조회 패턴 | 인덱스 | 이유 | -|----------|--------|------| -| 브랜드별 상품 목록 | products(brand_id, created_at DESC) | 복합 인덱스로 정렬까지 최적화 | -| 최신순 상품 목록 | products(created_at DESC) | 전체 상품 최신순 조회 | -| 인기순 상품 목록 | products(like_count DESC) | 좋아요 많은 순 정렬 | -| 상품별 최저가 계산 | product_options(product_id, price) | MIN(price) 집계 최적화 | -| 상품별 옵션 조회 | product_options(product_id) | WHERE product_id = ? | -| 상품별 이미지 조회 | product_images(product_id, display_order) | 순서대로 정렬 | -| 좋아요 수 집계 | likes(product_id) | COUNT(*) WHERE product_id = ? | -| 내 좋아요 목록 | likes(user_id) | WHERE user_id = ? | -| 좋아요 여부 확인 | likes(user_id, product_id) | UNIQUE 제약이 인덱스 역할 | - ---- - -#### 2. 복합 인덱스 우선순위 - -**products(brand_id, created_at DESC)** -- 단일 쿼리: `WHERE brand_id = ? ORDER BY created_at DESC` -- 커버: brand_id만 조회하는 경우도 활용 가능 -- 선택도: brand_id 먼저 → created_at 순 - -**product_options(product_id, price)** -- 단일 쿼리: `SELECT MIN(price) WHERE product_id IN (...) GROUP BY product_id` -- 집계 최적화: 인덱스만으로 MIN 계산 가능 - -**product_images(product_id, display_order)** -- 단일 쿼리: `WHERE product_id = ? ORDER BY display_order` -- 순서 보장: display_order로 정렬 - ---- - -#### 3. UNIQUE 인덱스 활용 - -| 테이블 | UNIQUE 인덱스 | 목적 | -|--------|--------------|------| -| brands | name | 브랜드명 중복 방지 + 빠른 검색 | -| product_options | (product_id, name) | 같은 상품 내 옵션명 중복 방지 | -| likes | (user_id, product_id) | 중복 좋아요 방지 + 조회 최적화 | - -**UNIQUE 인덱스의 이중 역할:** -- 데이터 무결성 보장 -- 조회 성능 최적화 (일반 인덱스로도 활용) - ---- - -#### 4. 커버링 인덱스 고려 - -**좋아요 수 집계:** -```sql --- 인덱스: likes(product_id) --- 커버링: product_id만 있어도 COUNT 가능 -SELECT COUNT(*) FROM likes WHERE product_id = ?; -``` - -**최저가 계산:** -```sql --- 인덱스: product_options(product_id, price) --- 커버링: 테이블 접근 없이 인덱스만으로 MIN 계산 -SELECT product_id, MIN(price) -FROM product_options -WHERE product_id IN (1, 2, 3) -GROUP BY product_id; -``` - ---- - -## 5️⃣ 데이터 정합성 전략 - -### 1. FK 제약 없는 설계 - -**이유:** -- 애플리케이션 레벨에서 참조 무결성 관리 -- DB 레벨 제약으로 인한 성능 오버헤드 제거 -- 향후 샤딩, 마이크로서비스 전환 시 유연성 - -**트레이드오프:** -- 고아 레코드(orphan records) 발생 가능 -- 정기적인 데이터 정합성 체크 필요 - -**보완 전략:** -```sql --- 고아 레코드 체크 (배치 작업) --- 1. 존재하지 않는 brand_id를 가진 products 찾기 -SELECT p.product_id, p.brand_id -FROM products p -LEFT JOIN brands b ON p.brand_id = b.brand_id -WHERE b.brand_id IS NULL; - --- 2. 존재하지 않는 product_id를 가진 product_options 찾기 -SELECT po.option_id, po.product_id -FROM product_options po -LEFT JOIN products p ON po.product_id = p.product_id -WHERE p.product_id IS NULL; - --- 3. 존재하지 않는 product_id를 가진 likes 찾기 -SELECT l.like_id, l.product_id -FROM likes l -LEFT JOIN products p ON l.product_id = p.product_id -WHERE p.product_id IS NULL; -``` - ---- - -### 2. 비정규화 - like_count - -**설계:** -- products 테이블에 like_count 컬럼 추가 -- 좋아요 등록/취소 시 비동기로 업데이트 -- Eventual Consistency 허용 - -**동기화 전략:** -```sql --- 정합성 체크 (배치 작업) -SELECT - p.product_id, - p.like_count AS stored_count, - COALESCE(l.actual_count, 0) AS actual_count, - (p.like_count - COALESCE(l.actual_count, 0)) AS diff -FROM products p -LEFT JOIN ( - SELECT product_id, COUNT(*) AS actual_count - FROM likes - GROUP BY product_id -) l ON p.product_id = l.product_id -WHERE p.like_count != COALESCE(l.actual_count, 0); - --- 불일치 수정 -UPDATE products p -INNER JOIN ( - SELECT product_id, COUNT(*) AS actual_count - FROM likes - GROUP BY product_id -) l ON p.product_id = l.product_id -SET p.like_count = l.actual_count -WHERE p.like_count != l.actual_count; - --- 좋아요가 0개인 상품도 0으로 업데이트 -UPDATE products p -LEFT JOIN ( - SELECT product_id, COUNT(*) AS actual_count - FROM likes - GROUP BY product_id -) l ON p.product_id = l.product_id -SET p.like_count = COALESCE(l.actual_count, 0) -WHERE l.product_id IS NULL AND p.like_count != 0; -``` - ---- - -### 3. 데이터 무결성 체크 - -**필수 비즈니스 규칙:** -| 규칙 | 체크 방법 | -|------|----------| -| 상품은 최소 1개 이상의 옵션 필요 | `LEFT JOIN` + `IS NULL` 체크 | -| 가격/재고는 0 이상 | `CHECK` 제약 (MySQL 8.0.16+) | -| 같은 상품 내 옵션명 중복 불가 | `UNIQUE` 제약 | -| 사용자당 상품 좋아요 1개 | `UNIQUE` 제약 | - -**정합성 체크 쿼리:** -```sql --- 옵션이 없는 상품 찾기 (치명적 오류) -SELECT p.product_id, p.name -FROM products p -LEFT JOIN product_options po ON p.product_id = po.product_id -WHERE po.option_id IS NULL; - --- 가격이 음수인 옵션 찾기 -SELECT option_id, product_id, name, price -FROM product_options -WHERE price < 0; - --- 재고가 음수인 옵션 찾기 -SELECT option_id, product_id, name, stock_quantity -FROM product_options -WHERE stock_quantity < 0; -``` - ---- - -## 6️⃣ 성능 최적화 전략 - -### 1. 쿼리 패턴별 최적화 - -**상품 목록 조회 (브랜드별 최신순)** -```sql --- 인덱스 활용: idx_brand_created (brand_id, created_at DESC) -SELECT * FROM products -WHERE brand_id = 1 -ORDER BY created_at DESC -LIMIT 20 OFFSET 0; - --- 실행 계획: Using index condition -``` - -**최저가 계산 (배치)** -```sql --- 인덱스 활용: idx_product_price (product_id, price) -SELECT product_id, MIN(price) AS min_price -FROM product_options -WHERE product_id IN (1, 2, 3, 4, 5) -GROUP BY product_id; - --- 실행 계획: Using index for group-by -``` - -**좋아요 수 집계 (배치)** -```sql --- 인덱스 활용: idx_product_id -SELECT product_id, COUNT(*) AS like_count -FROM likes -WHERE product_id IN (1, 2, 3, 4, 5) -GROUP BY product_id; - --- 실행 계획: Using index -``` - ---- - -### 2. 캐싱 전략 - -**Redis 캐싱 대상:** -| 데이터 | 캐시 키 | TTL | 이유 | -|--------|---------|-----|------| -| 브랜드 정보 | `brand:{brandId}` | 1시간 | 변경 빈도 낮음 | -| 상품 최저가 | `product:minPrice:{productId}` | 10분 | 집계 비용 높음 | -| 좋아요 수 | `product:likeCount:{productId}` | 5분 | 비정규화 컬럼과 이중화 | - -**캐시 무효화:** -- 상품 옵션 변경 시 → 최저가 캐시 삭제 -- 좋아요 등록/취소 시 → 좋아요 수 캐시 삭제 - ---- - -### 3. 페이지네이션 최적화 - -**Offset 방식 (현재)** -```sql --- 문제: 깊은 페이지일수록 느림 (OFFSET 10000) -SELECT * FROM products -ORDER BY created_at DESC -LIMIT 20 OFFSET 10000; -``` - -**Cursor 방식 (향후 개선)** -```sql --- 개선: 마지막 조회 시점 기준으로 다음 페이지 -SELECT * FROM products -WHERE created_at < '2025-01-10 10:00:00' -- 이전 페이지 마지막 시각 -ORDER BY created_at DESC -LIMIT 20; -``` - ---- - -## 7️⃣ 확장 고려사항 - -### 1. 샤딩 전략 (향후) - -**샤딩 키 후보:** -- `brand_id`: 브랜드별 샤딩 (브랜드 독립성 높음) -- `product_id % N`: 상품 ID 기반 해시 샤딩 - -**샤딩 시 고려사항:** -- FK 제약 없음 → 샤드 간 참조 가능 -- 좋아요 집계는 각 샤드에서 수행 후 병합 - ---- - -### 2. 읽기/쓰기 분리 - -**Read Replica 활용:** -- 모든 조회 쿼리 → Read Replica -- 좋아요 등록/취소, 카운트 업데이트 → Master -- Eventual Consistency 허용 - ---- - -### 3. 파티셔닝 (대용량 데이터) - -**likes 테이블 파티셔닝:** -```sql --- created_at 기준 월별 파티셔닝 -ALTER TABLE likes PARTITION BY RANGE (YEAR(created_at) * 100 + MONTH(created_at)) ( - PARTITION p202501 VALUES LESS THAN (202502), - PARTITION p202502 VALUES LESS THAN (202503), - PARTITION p202503 VALUES LESS THAN (202504), - ... -); -``` - -**이유:** -- 오래된 좋아요 데이터는 분석용으로만 사용 -- 최근 데이터만 활발히 조회 - ---- - -## 📊 전체 테이블 요약 - -| 테이블 | 행 수 (예상) | 주요 인덱스 | 특이사항 | -|--------|-------------|------------|----------| -| brands | 수백 ~ 수천 | name(UNIQUE), brand_id | 변경 빈도 낮음, 캐싱 적합 | -| products | 수만 ~ 수십만 | brand_id, created_at, like_count | 복합 인덱스 중요 | -| product_options | products × 5~10 | product_id, (product_id, price) | 최저가 계산 최적화 필요 | -| product_images | products × 3~5 | (product_id, display_order) | CDN 활용 필수 | -| likes | 수백만 ~ 수천만 | (user_id, product_id)(UNIQUE), product_id, user_id | 파티셔닝 고려, 비정규화 | -| users | 수만 ~ 수백만 | username(UNIQUE), email(UNIQUE) | v2에서 추가 | - ---- - -**문서 끝** \ No newline at end of file diff --git "a/docs/design/\354\226\264\353\223\234\353\257\274/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/01-requirements.md" "b/docs/design/\354\226\264\353\223\234\353\257\274/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/01-requirements.md" index c5baccf56..bc21d286f 100644 --- "a/docs/design/\354\226\264\353\223\234\353\257\274/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/01-requirements.md" +++ "b/docs/design/\354\226\264\353\223\234\353\257\274/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/01-requirements.md" @@ -1,60 +1,3 @@ -# 요구사항 명세서 - -## 📋 문서 정보 -- **작성일**: 2026-02-12 -- **버전**: 1.0 -- **목적**: 브랜드 & 상품 관리 시스템 요구사항 정의 - ---- - -## 1. 문제 상황 정의 - -### 1.1 사용자 관점 -- **어드민 사용자**: 브랜드와 상품을 등록/수정/비활성화하며 카탈로그를 관리해야 함 -- **일반 고객**: 활성화된 브랜드와 상품 정보를 조회하고, 상호작용(좋아요)을 통해 관심을 표현해야 함 -- **문제**: 어드민의 관리 정보와 고객에게 노출되는 정보가 다르며, 역할별 접근 제어가 필요함 - -### 1.2 비즈니스 관점 -- **카탈로그 일관성**: 브랜드가 비활성화되면 해당 상품들도 함께 비활성화되어야 함 -- **변경 이력 추적**: 상품의 모든 변경 사항(브랜드 변경 포함)을 스냅샷으로 보관하여 감사 추적이 가능해야 함 -- **재고 관리**: 상품의 재고 상태를 추적하여 품절 시 고객에게 적절히 표시해야 함 -- **문제**: 브랜드-상품 간 상태 동기화, 이력 관리, 재고 정합성 유지가 필요함 - -### 1.3 시스템 관점 -- **인증/인가**: LDAP 헤더(`X-Loopers-Ldap: loopers.admin`)로 어드민을 식별하여 접근 제어 -- **데이터 정합성**: 브랜드-상품 간 참조 무결성, 상태 연쇄 변경, 변경 이력 스냅샷 저장 -- **확장성**: 검색/필터링, 정렬, 페이징 기능 확장 가능하도록 설계 -- **문제**: 비활성화 트랜잭션 처리, 스냅샷 저장 전략, 모듈 간 책임 분리가 필요함 - ---- - -## 2. 핵심 도메인 개념 - -### 2.1 액터 (Actors) -- **Admin User**: 사내 어드민 시스템 사용자 (LDAP 인증) -- **Customer**: 일반 고객 (브랜드/상품 조회 및 상호작용) - -### 2.2 핵심 도메인 (Core Domain) -- **Brand**: 브랜드 (제조사/판매자) - - 상태: ACTIVE, INACTIVE, PENDING, SCHEDULED - - 브랜드 비활성화 시 모든 상품 연쇄 비활성화 - -- **Product**: 상품 - - 상태: ACTIVE, INACTIVE, PENDING, SCHEDULED, OUT_OF_STOCK - - 브랜드 변경 가능 (이력 추적) - - 재고 소진 시 OUT_OF_STOCK 상태 - -- **ProductHistory**: 상품 변경 이력 (스냅샷) - - 상품의 모든 변경 사항을 버전별로 저장 - - 특정 시점의 상품 상태 복원 가능 - -- **ProductLike**: 고객의 상품 좋아요 - - 고객별 좋아요 관리 - - 좋아요 수 집계 - -### 2.3 보조/외부 시스템 -- **LDAP 인증 시스템**: 어드민 식별 -- **이벤트 시스템**: 브랜드 비활성화 이벤트 → 상품 일괄 비활성화 처리 --- diff --git "a/docs/design/\354\226\264\353\223\234\353\257\274/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/04-erd.md" "b/docs/design/\354\226\264\353\223\234\353\257\274/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/04-erd.md" deleted file mode 100644 index 86fbec9be..000000000 --- "a/docs/design/\354\226\264\353\223\234\353\257\274/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/04-erd.md" +++ /dev/null @@ -1,432 +0,0 @@ -# ERD (Entity Relationship Diagram) - -## 1. 전체 데이터베이스 스키마 - -### 설계 의도 -- **참조 무결성**: FK 제약으로 데이터 일관성 보장 -- **성능 최적화**: 조회 패턴에 맞는 인덱스 설계 -- **확장성**: 파티셔닝, 샤딩 가능한 구조 - -### 특히 봐야 할 포인트 -1. **products.brand_id**: FK with ON DELETE RESTRICT (어플리케이션에서 상태 변경으로 처리) -2. **product_histories**: FK 없음 (느슨한 결합, 스냅샷 독립성) -3. **product_likes**: Unique 제약 (customer_id, product_id) - -```mermaid -erDiagram - brands ||--o{ products : "has many" - products ||--o{ product_histories : "tracks changes of" - products ||--o{ product_likes : "liked by customers" - - brands { - bigint id PK "자동 증가" - varchar(100) name UK "브랜드명 (유니크)" - text description "브랜드 설명" - varchar(500) logo_url "로고 이미지 URL" - varchar(20) status "ACTIVE, INACTIVE, PENDING, SCHEDULED" - timestamp created_at "등록 시각" - timestamp updated_at "수정 시각" - varchar(100) created_by "등록자 (LDAP ID)" - } - - products { - bigint id PK "자동 증가" - bigint brand_id FK "브랜드 ID (brands.id)" - varchar(200) name "상품명" - text description "상품 설명" - decimal(15,2) price "가격" - varchar(10) currency "통화 (KRW, USD 등)" - int stock_quantity "재고 수량" - varchar(20) status "ACTIVE, INACTIVE, OUT_OF_STOCK 등" - varchar(500) image_url "상품 이미지 URL" - timestamp created_at "등록 시각" - timestamp updated_at "수정 시각" - varchar(100) created_by "등록자 (LDAP ID)" - } - - product_histories { - bigint id PK "자동 증가" - bigint product_id "상품 ID (products.id)" - int version "버전 번호 (1부터 시작)" - bigint brand_id "스냅샷 당시 브랜드 ID" - varchar(200) name "스냅샷 당시 상품명" - text description "스냅샷 당시 설명" - decimal(15,2) price "스냅샷 당시 가격" - varchar(10) currency "스냅샷 당시 통화" - int stock_quantity "스냅샷 당시 재고" - varchar(20) status "스냅샷 당시 상태" - varchar(500) image_url "스냅샷 당시 이미지" - timestamp changed_at "변경 시각" - varchar(100) changed_by "변경자 (LDAP ID)" - } - - product_likes { - bigint id PK "자동 증가" - bigint customer_id "고객 ID" - bigint product_id FK "상품 ID (products.id)" - timestamp created_at "좋아요 누른 시각" - } -``` - ---- - -## 2. 테이블별 상세 스키마 - -### 2.1 brands 테이블 - -```sql -CREATE TABLE brands ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - name VARCHAR(100) NOT NULL UNIQUE, - description TEXT, - logo_url VARCHAR(500), - status VARCHAR(20) NOT NULL DEFAULT 'PENDING', - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - created_by VARCHAR(100) NOT NULL, - - INDEX idx_status (status), - INDEX idx_created_at (created_at DESC) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -``` - -**컬럼 설명**: -- `name`: 브랜드명은 중복 불가 (Unique 제약) -- `status`: 기본값 PENDING (승인 후 ACTIVE) -- `created_by`: LDAP ID 저장 - -**인덱스 전략**: -- `idx_status`: 상태별 조회 빈번 (고객 API는 ACTIVE만 필터링) -- `idx_created_at`: 최신 등록 브랜드 조회 시 사용 - ---- - -### 2.2 products 테이블 - -```sql -CREATE TABLE products ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - brand_id BIGINT NOT NULL, - name VARCHAR(200) NOT NULL, - description TEXT, - price DECIMAL(15, 2) NOT NULL, - currency VARCHAR(10) NOT NULL DEFAULT 'KRW', - stock_quantity INT NOT NULL DEFAULT 0, - status VARCHAR(20) NOT NULL DEFAULT 'PENDING', - image_url VARCHAR(500), - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - created_by VARCHAR(100) NOT NULL, - - CONSTRAINT fk_products_brand FOREIGN KEY (brand_id) - REFERENCES brands(id) ON DELETE RESTRICT, - - INDEX idx_brand_id (brand_id), - INDEX idx_status (status), - INDEX idx_brand_status (brand_id, status), - INDEX idx_created_at (created_at DESC) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -``` - -**컬럼 설명**: -- `brand_id`: 브랜드 FK (ON DELETE RESTRICT - 어플리케이션에서 처리) -- `price`, `currency`: 금액은 통화와 함께 저장 -- `stock_quantity`: 재고 수량 (0 이하면 OUT_OF_STOCK 상태로 변경) - -**인덱스 전략**: -- `idx_brand_id`: 브랜드별 상품 조회 -- `idx_status`: 상태별 필터링 (ACTIVE, OUT_OF_STOCK) -- `idx_brand_status`: 브랜드+상태 복합 조회 최적화 -- `idx_created_at`: 신상품 정렬 - -**ON DELETE RESTRICT 이유**: -- 브랜드 삭제 시 DB 레벨에서 막지 않고, 어플리케이션에서 상태 변경으로 처리 -- 실수로 브랜드 물리 삭제 시도 시 에러 발생 (데이터 보호) - ---- - -### 2.3 product_histories 테이블 - -```sql -CREATE TABLE product_histories ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - product_id BIGINT NOT NULL, - version INT NOT NULL, - brand_id BIGINT NOT NULL, - name VARCHAR(200) NOT NULL, - description TEXT, - price DECIMAL(15, 2) NOT NULL, - currency VARCHAR(10) NOT NULL, - stock_quantity INT NOT NULL, - status VARCHAR(20) NOT NULL, - image_url VARCHAR(500), - changed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - changed_by VARCHAR(100) NOT NULL, - - UNIQUE KEY uk_product_version (product_id, version), - INDEX idx_product_id_changed_at (product_id, changed_at DESC), - INDEX idx_changed_at (changed_at DESC) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci -PARTITION BY RANGE (YEAR(changed_at)) ( - PARTITION p2024 VALUES LESS THAN (2025), - PARTITION p2025 VALUES LESS THAN (2026), - PARTITION p2026 VALUES LESS THAN (2027), - PARTITION p_future VALUES LESS THAN MAXVALUE -); -``` - -**컬럼 설명**: -- `product_id`: 원본 상품 ID (FK 없음 - 스냅샷 독립성) -- `version`: 변경 버전 (1부터 시작, 자동 증가) -- `brand_id`, `name`, `price` 등: 변경 시점의 스냅샷 - -**Unique 제약**: -- `uk_product_version`: 동일 상품의 동일 버전 중복 방지 - -**인덱스 전략**: -- `idx_product_id_changed_at`: 상품 이력 조회 (최신순 정렬) -- `idx_changed_at`: 전체 변경 이력 조회 - -**파티셔닝 전략**: -- 연도별 파티션으로 이력 테이블 증가 대비 -- 오래된 이력은 별도 아카이빙 가능 - -**FK 없는 이유**: -- Product 삭제 시에도 이력은 보존 (감사 추적) -- 스냅샷은 독립적으로 존재 - ---- - -### 2.4 product_likes 테이블 - -```sql -CREATE TABLE product_likes ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - customer_id BIGINT NOT NULL, - product_id BIGINT NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT fk_product_likes_product FOREIGN KEY (product_id) - REFERENCES products(id) ON DELETE CASCADE, - - UNIQUE KEY uk_customer_product (customer_id, product_id), - INDEX idx_product_id (product_id), - INDEX idx_customer_id (customer_id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -``` - -**컬럼 설명**: -- `customer_id`: 고객 ID (나중에 customers 테이블과 FK 연결 가능) -- `product_id`: 상품 FK (ON DELETE CASCADE - 상품 삭제 시 좋아요도 삭제) - -**Unique 제약**: -- `uk_customer_product`: 동일 고객이 동일 상품에 중복 좋아요 방지 - -**인덱스 전략**: -- `idx_product_id`: 상품별 좋아요 수 집계 -- `idx_customer_id`: 고객별 좋아요 목록 조회 - -**ON DELETE CASCADE 이유**: -- 상품이 삭제(비활성화)되면 좋아요도 의미 없음 -- 좋아요 이력은 별도 추적 불필요 (현재 상태만 중요) - ---- - -## 3. 인덱스 전략 상세 - -### 3.1 조회 패턴별 인덱스 - -| 조회 패턴 | 사용 인덱스 | 설명 | -|----------|-----------|------| -| 활성 브랜드 목록 | `brands.idx_status` | WHERE status = 'ACTIVE' | -| 브랜드별 상품 목록 | `products.idx_brand_status` | WHERE brand_id = ? AND status IN (?, ?) | -| 상품 이력 조회 | `product_histories.idx_product_id_changed_at` | WHERE product_id = ? ORDER BY changed_at DESC | -| 상품별 좋아요 수 | `product_likes.idx_product_id` | COUNT(*) WHERE product_id = ? | -| 고객별 좋아요 목록 | `product_likes.idx_customer_id` | WHERE customer_id = ? | - -### 3.2 복합 인덱스 최적화 - -```sql --- 브랜드별 활성 상품 조회에 최적화 -CREATE INDEX idx_brand_status ON products (brand_id, status); - --- 상품 이력 최신순 조회에 최적화 -CREATE INDEX idx_product_id_changed_at ON product_histories (product_id, changed_at DESC); -``` - -**복합 인덱스 사용 이유**: -- `idx_brand_status`: 브랜드별 + 상태별 필터링 동시 최적화 -- 인덱스 순서: `brand_id` (등호 조건) → `status` (IN 조건) - ---- - -## 4. 데이터 정합성 제약 - -### 4.1 FK 제약 정리 - -| 테이블 | FK | 참조 | ON DELETE | 이유 | -|-------|----|----|-----------|------| -| products | brand_id | brands(id) | RESTRICT | 브랜드 물리 삭제 방지 (상태로 관리) | -| product_likes | product_id | products(id) | CASCADE | 상품 삭제 시 좋아요도 삭제 | - -### 4.2 Unique 제약 정리 - -| 테이블 | Unique 제약 | 의미 | -|-------|-----------|------| -| brands | name | 브랜드명 중복 방지 | -| product_histories | (product_id, version) | 동일 상품의 버전 중복 방지 | -| product_likes | (customer_id, product_id) | 중복 좋아요 방지 | - -### 4.3 Check 제약 (향후 추가 가능) - -```sql --- 가격은 0 이상 -ALTER TABLE products ADD CONSTRAINT chk_price_positive - CHECK (price >= 0); - --- 재고는 음수 불가 -ALTER TABLE products ADD CONSTRAINT chk_stock_non_negative - CHECK (stock_quantity >= 0); - --- 상태는 허용된 값만 -ALTER TABLE brands ADD CONSTRAINT chk_brand_status - CHECK (status IN ('ACTIVE', 'INACTIVE', 'PENDING', 'SCHEDULED')); - -ALTER TABLE products ADD CONSTRAINT chk_product_status - CHECK (status IN ('ACTIVE', 'INACTIVE', 'PENDING', 'SCHEDULED', 'OUT_OF_STOCK')); -``` - -**주의**: MySQL 8.0.16 이상에서만 Check 제약 지원 - ---- - -## 5. 브랜드 비활성화 시 연쇄 처리 SQL - -### 5.1 어플리케이션에서 실행할 쿼리 - -```sql --- 트랜잭션 A: 브랜드 상태 변경 -UPDATE brands -SET status = 'INACTIVE', updated_at = NOW() -WHERE id = ? AND status = 'ACTIVE'; - --- 트랜잭션 B: 상품 일괄 비활성화 (이벤트 리스너에서 실행) -UPDATE products -SET status = 'INACTIVE', updated_at = NOW() -WHERE brand_id = ? AND status = 'ACTIVE'; - --- 트랜잭션 B: 각 상품의 스냅샷 저장 -INSERT INTO product_histories - (product_id, version, brand_id, name, description, price, currency, - stock_quantity, status, image_url, changed_at, changed_by) -SELECT - id, - (SELECT IFNULL(MAX(version), 0) + 1 FROM product_histories ph WHERE ph.product_id = p.id), - brand_id, name, description, price, currency, - stock_quantity, status, image_url, NOW(), 'SYSTEM' -FROM products p -WHERE brand_id = ?; -``` - -### 5.2 고객 조회 시 필터링 쿼리 - -```sql --- 브랜드가 비활성이면 상품도 제외 -SELECT p.*, b.name as brand_name, COUNT(pl.id) as like_count -FROM products p -INNER JOIN brands b ON p.brand_id = b.id -LEFT JOIN product_likes pl ON p.id = pl.product_id -WHERE b.status = 'ACTIVE' - AND p.status IN ('ACTIVE', 'OUT_OF_STOCK') - AND (? IS NULL OR p.brand_id = ?) -GROUP BY p.id -ORDER BY p.created_at DESC -LIMIT ? OFFSET ?; -``` - ---- - -## 6. 확장성 고려사항 - -### 6.1 파티셔닝 전략 -- **product_histories**: 연도별 파티셔닝으로 이력 테이블 크기 관리 -- **product_likes**: 고객 ID 기반 해시 파티셔닝 (나중에 샤딩 가능) - -### 6.2 인덱스 유지보수 -- **주기적 통계 갱신**: `ANALYZE TABLE products;` -- **사용하지 않는 인덱스 제거**: 쿼리 로그 분석 후 - -### 6.3 읽기 성능 최적화 -- **좋아요 수 캐싱**: Redis에 `product:{id}:like_count` 저장 -- **읽기 전용 레플리카**: 고객 조회는 레플리카로 분산 - ---- - -## 7. ERD 해석 가이드 - -### 핵심 설계 원칙 -1. **정규화**: 중복 데이터 최소화 (브랜드 정보는 brands에만) -2. **참조 무결성**: FK 제약으로 데이터 일관성 보장 -3. **성능 최적화**: 조회 패턴에 맞는 인덱스 설계 - -### 이 구조에서 특히 봐야 할 포인트 -- **products.brand_id**: ON DELETE RESTRICT로 물리 삭제 방지 -- **product_histories**: FK 없음 (스냅샷 독립성) -- **product_likes**: Unique 제약으로 중복 방지, ON DELETE CASCADE - -### 잠재 리스크 -- **product_histories 증가**: 파티셔닝으로 완화 -- **좋아요 수 집계 비용**: COUNT(*) 대신 캐시 활용 -- **브랜드-상품 조인**: 인덱스 최적화 필수 - ---- - -## 8. 데이터 마이그레이션 스크립트 - -### 8.1 초기 테이블 생성 순서 -```sql --- 1. brands 테이블 생성 -CREATE TABLE brands (...); - --- 2. products 테이블 생성 (brands FK 필요) -CREATE TABLE products (...); - --- 3. product_histories 테이블 생성 (FK 없음) -CREATE TABLE product_histories (...); - --- 4. product_likes 테이블 생성 (products FK 필요) -CREATE TABLE product_likes (...); -``` - -### 8.2 샘플 데이터 INSERT -```sql --- 브랜드 샘플 데이터 -INSERT INTO brands (name, description, logo_url, status, created_by) VALUES -('Nike', 'Just Do It', 'https://example.com/nike-logo.png', 'ACTIVE', 'admin'), -('Adidas', 'Impossible is Nothing', 'https://example.com/adidas-logo.png', 'ACTIVE', 'admin'); - --- 상품 샘플 데이터 -INSERT INTO products (brand_id, name, description, price, currency, stock_quantity, status, created_by) VALUES -(1, 'Air Max 90', 'Classic sneakers', 150000, 'KRW', 100, 'ACTIVE', 'admin'), -(1, 'Air Force 1', 'Iconic shoes', 120000, 'KRW', 0, 'OUT_OF_STOCK', 'admin'), -(2, 'Ultraboost', 'Running shoes', 180000, 'KRW', 50, 'ACTIVE', 'admin'); - --- 초기 스냅샷 저장 -INSERT INTO product_histories - (product_id, version, brand_id, name, description, price, currency, stock_quantity, status, changed_at, changed_by) -SELECT id, 1, brand_id, name, description, price, currency, stock_quantity, status, created_at, created_by -FROM products; -``` - ---- - -## 정리 - -이 ERD는 다음을 보장합니다: - -1. **데이터 정합성**: FK 제약, Unique 제약으로 일관성 유지 -2. **확장성**: 파티셔닝, 인덱스 최적화로 성능 확보 -3. **감사 추적**: product_histories로 모든 변경 이력 보관 -4. **유연성**: 상태 관리로 물리 삭제 없이 비활성화 처리 - -**다음 단계**: JPA Entity 설계 시 이 ERD를 기반으로 매핑 \ No newline at end of file diff --git "a/docs/design/\354\226\264\353\223\234\353\257\274/\354\243\274\353\254\270/03-class-diagram.md" "b/docs/design/\354\226\264\353\223\234\353\257\274/\354\243\274\353\254\270/03-class-diagram.md" deleted file mode 100644 index e69de29bb..000000000 diff --git "a/docs/design/\354\226\264\353\223\234\353\257\274/\354\243\274\353\254\270/04-erd.md" "b/docs/design/\354\226\264\353\223\234\353\257\274/\354\243\274\353\254\270/04-erd.md" deleted file mode 100644 index e69de29bb..000000000 diff --git "a/docs/design/\354\242\213\354\225\204\354\232\224/01-requirements.md" "b/docs/design/\354\242\213\354\225\204\354\232\224/01-requirements.md" index 48f2248a6..a59190163 100644 --- "a/docs/design/\354\242\213\354\225\204\354\232\224/01-requirements.md" +++ "b/docs/design/\354\242\213\354\225\204\354\232\224/01-requirements.md" @@ -1,55 +1,4 @@ -# 좋아요 기능 요구사항 분석 - -## 1️⃣ 문제 상황 재정의 - -### 📱 사용자 관점 -**문제:** 사용자가 마음에 드는 상품을 저장하고 나중에 다시 찾아보고 싶다. -- 상품 목록이나 상세 페이지를 볼 때, 관심 가는 상품을 빠르게 표시하고 싶음 -- 내가 관심 표시한 상품들을 한 곳에서 모아보고 싶음 -- 다른 사람들이 얼마나 좋아하는지 참고하고 싶음 (좋아요 수) - -### 💼 비즈니스 관점 -**문제:** 사용자의 관심사를 파악하고, 인기 상품을 식별하고 싶다. -- 어떤 상품이 인기 있는지 측정 가능해야 함 -- 사용자의 관심 데이터를 수집하여 추후 추천 시스템 등에 활용 가능해야 함 -- 좋아요 수를 통해 상품의 사회적 증거(Social Proof)를 제공하고 싶음 - -### 🖥️ 시스템 관점 -**문제:** 좋아요 데이터의 일관성과 성능을 모두 고려해야 한다. -- 중복 좋아요를 방지해야 함 -- 좋아요 수 집계는 실시간일 필요는 없지만, 크게 어긋나서는 안 됨 -- 향후 트래픽 증가에 대비한 확장 가능한 구조여야 함 (Redis 등) - ---- - -## 2️⃣ 개념 모델 - -### 액터 (Actors) -- **인증된 사용자 (Authenticated User)** - - 좋아요를 등록/취소하는 주체 - - 자신의 좋아요 목록을 조회하는 주체 - -### 핵심 도메인 (Core Domain) -- **Product (상품)** - - 좋아요의 대상이 되는 엔티티 - - 좋아요 개수 정보를 가짐 - -- **Like (좋아요)** - - 사용자와 상품 간의 관계를 나타냄 - - 사용자 1명당 상품 1개에 대해 1개만 존재 가능 - -### 보조/외부 시스템 -- **이벤트 시스템** - - 좋아요 등록/취소 시 이벤트를 발행 - - 카운트 업데이트를 비동기로 처리 - -- **배치 시스템 (향후)** - - 정합성 불일치 시 복구 담당 - - 이벤트 실패로 인한 데이터 불일치 보정 - ---- - -## 3️⃣ 명확화된 기능 요구사항 +## 좋아요 기능 상세 정의 ### FR-1. 좋아요 등록 **사용자 스토리:** @@ -70,12 +19,15 @@ Authorization: Required - `likes` 테이블에 데이터 저장 - 이벤트 발행 (`LikeCreated`) - 비동기로 상품의 `like_count` 증가 - + **정책:** - 좋아요 등록은 동기 처리 (즉시 응답) - 카운트 업데이트는 비동기 처리 (Eventual Consistency) - 이벤트 실패 시 재시도 없음, 배치로 정합성 복구 +**에러 처리:** +- 이미 좋아요한 상품 → 409 Conflict + --- ### FR-2. 좋아요 취소 @@ -98,6 +50,7 @@ Authorization: Required - `likes` 테이블에서 데이터 삭제 - 이벤트 발행 (`LikeDeleted`) - 비동기로 상품의 `like_count` 감소 +4. 멱등성 보장 (이미 취소된 좋아요 재요청 시 성공 응답) **정책:** - 좋아요 취소는 멱등성을 가짐 (여러 번 호출해도 결과 동일) @@ -122,6 +75,34 @@ Authorization: Required 3. 항상 로그인한 사용자의 좋아요 목록만 조회 4. 페이지네이션 지원 (추후 구체화 필요) +**Query Parameters:** +- `page` (선택, Integer, 기본값: 0): 페이지 번호 +- `size` (선택, Integer, 기본값: 20): 페이지 크기 + +**반환 정보:** +```json +{ + "content": [ + { + "productId": 1, + "name": "상품명", + "brand": { + "brandId": 1, + "name": "브랜드명" + }, + "thumbnailImageUrl": "https://example.com/product-thumbnail.png", + "minPrice": 10000, + "likeCount": 150, + "likedAt": "2025-01-15T10:30:00" + } + ], + "page": 0, + "size": 20, + "totalElements": 10, + "totalPages": 1 +} +``` + **정책:** - 다른 사용자의 좋아요 목록은 조회 불가 - 권한 에러(403) 없음, URL userId는 참고용으로만 사용 @@ -151,7 +132,7 @@ Authorization: Required --- -## 4️⃣ 비기능 요구사항 +## 비기능 요구사항 ### NFR-1. 성능 - 좋아요 등록/취소 API는 200ms 이내 응답 목표 @@ -172,7 +153,7 @@ Authorization: Required --- -## 5️⃣ 결정된 제약사항 및 전제조건 +## 결정된 제약사항 및 전제조건 ### 데이터 제약 - 사용자 1명당 상품 1개에 대해 좋아요 1개만 가능 @@ -188,26 +169,20 @@ Authorization: Required - 친구/타인의 좋아요 목록 조회 기능 - 좋아요 기반 추천 시스템 ---- - -## 6️⃣ 다음 단계에서 다룰 내용 -1. **시퀀스 다이어그램** - - 좋아요 등록 시 트랜잭션 경계 확인 - - 이벤트 발행 및 비동기 처리 흐름 - - 실패 시나리오 +#### 좋아요 카운트 정합성 +**현재 (v1):** +- 좋아요 등록/취소: 동기 +- 카운트 업데이트: 비동기 (Eventual Consistency) -2. **클래스 다이어그램** - - Product, Like, User 간의 관계 - - 도메인 책임 분리 - - 의존 방향 +**잠재 리스크:** +- 이벤트 발행 실패 시 카운트 불일치 +- 대량 좋아요 발생 시 카운트 업데이트 지연 -3. **ERD** - - 테이블 구조 및 관계 - - Unique 제약 - - 인덱스 전략 +**해결 방안:** +- 배치 작업을 통한 정합성 복구 +- 향후 Redis 캐시 도입 (실시간 카운트) -4. **설계 리스크 분석** - - 이벤트 실패 시 정합성 불일치 리스크 - - 배치 복구 전략의 한계 - - 향후 확장 시 고려사항 \ No newline at end of file +**설계 시 주의사항:** +- 좋아요 수는 "대략적인 인기도" 지표로 사용 +- 정확한 수치가 필요한 경우 실시간 COUNT 쿼리 사용 \ No newline at end of file diff --git "a/docs/design/\354\242\213\354\225\204\354\232\224/02-sequence-diagrams.md" "b/docs/design/\354\242\213\354\225\204\354\232\224/02-sequence-diagrams.md" index a25ba6d0f..cd7d075f0 100644 --- "a/docs/design/\354\242\213\354\225\204\354\232\224/02-sequence-diagrams.md" +++ "b/docs/design/\354\242\213\354\225\204\354\232\224/02-sequence-diagrams.md" @@ -4,10 +4,10 @@ 이 문서에서는 **4개의 시퀀스 다이어그램**을 작성합니다: -1. **좋아요 등록** - 단순 INSERT, 카운트는 실시간 계산 +1. **좋아요 등록** - 단순 INSERT, 카운트는 비동기 반영(products.like_count) 2. **좋아요 취소** - 멱등성 보장 3. **좋아요 목록 조회** - 본인 것만 조회 -4. **상품 조회 (좋아요 수 포함)** - COUNT 쿼리로 실시간 계산 +4. **상품 조회 (좋아요 수 포함)** - products.like_count 조회 각 다이어그램에서 주목할 점: - **트랜잭션 경계**: 어디까지가 하나의 원자적 작업인가? @@ -30,7 +30,9 @@ sequenceDiagram actor User as 사용자 participant API as LikeController participant Service as LikeService + participant ProdSvc as ProductService participant Repo as LikeRepository + participant Pub as EventPublisher participant DB as Database User->>API: POST /products/{productId}/likes @@ -39,29 +41,41 @@ sequenceDiagram API->>Service: createLike(userId, productId) activate Service - Service->>Repo: existsByUserIdAndProductId(userId, productId) - activate Repo - Repo->>DB: SELECT EXISTS(...) - DB-->>Repo: false - Repo-->>Service: false - deactivate Repo + Service->>ProdSvc: validateProductActive(productId) + activate ProdSvc + ProdSvc->>DB: SELECT status FROM products WHERE id = ? + DB-->>ProdSvc: ACTIVE + ProdSvc-->>Service: OK + deactivate ProdSvc - Note over Service: 중복 아님 확인 + Note over Service: 중복 방지는 DB Unique가 최종 보루 Service->>Repo: save(Like) activate Repo Repo->>DB: INSERT INTO likes
(user_id, product_id, created_at) Note over DB: Unique(user_id, product_id) - DB-->>Repo: 성공 - Repo-->>Service: Like 객체 + alt 중복이면 + DB-->>Repo: UniqueViolation + Repo-->>Service: DuplicateLikeException + else 성공 + DB-->>Repo: 성공 + Repo-->>Service: Like 객체 + end deactivate Repo - Note over Service: 트랜잭션 커밋
카운트 업데이트 없음 - - Service-->>API: Like 객체 + alt 성공 + Service->>Pub: publish(LikeCreated) + activate Pub + Pub-->>Service: publish 요청 완료(비동기) + deactivate Pub + + Service-->>API: 성공 + API-->>User: 201 Created + else 중복 + Service-->>API: 409 Conflict + API-->>User: 409 Conflict + end deactivate Service - - API-->>User: 201 Created deactivate API ``` @@ -92,11 +106,12 @@ sequenceDiagram 존재하지 않는 좋아요를 취소해도 성공 응답을 주기 때문에, 이 플로우를 명확히 해야 합니다. ```mermaid -sequenceDiagram +ssequenceDiagram actor User as 사용자 participant API as LikeController participant Service as LikeService participant Repo as LikeRepository + participant Pub as EventPublisher participant DB as Database participant Logger as ErrorLogger @@ -108,27 +123,38 @@ sequenceDiagram Service->>Repo: findByUserIdAndProductId(userId, productId) activate Repo - Repo->>DB: SELECT - DB-->>Repo: null (존재하지 않음) - Repo-->>Service: null + Repo->>DB: SELECT id FROM likes WHERE user_id=? AND product_id=? + DB-->>Repo: null or likeId + Repo-->>Service: null or likeId deactivate Repo - Note over Service: 이미 없는 좋아요 - - Service->>Logger: warn("이미 삭제된 좋아요", userId, productId) - activate Logger - Logger-->>Service: 로그 기록 완료 - deactivate Logger + alt 좋아요 없음 + Service->>Logger: warn("이미 삭제된 좋아요", userId, productId) + activate Logger + Logger-->>Service: logged + deactivate Logger + + Service-->>API: 성공(멱등) + API-->>User: 204 No Content + else 좋아요 존재 + Service->>Repo: deleteById(likeId) + activate Repo + Repo->>DB: DELETE FROM likes WHERE id=? + DB-->>Repo: 성공 + Repo-->>Service: 완료 + deactivate Repo + + Service->>Pub: publish(LikeDeleted) + activate Pub + Pub-->>Service: publish 요청 완료(비동기) + deactivate Pub + + Service-->>API: 성공 + API-->>User: 204 No Content + end - Note over Service: 멱등성 보장:
성공으로 간주 - - Service-->>API: 성공 deactivate Service - - API-->>User: 204 No Content deactivate API - - Note over User,API: 클라이언트는 정상 응답 받음
내부적으로는 로그만 남김 ``` ### 정상 삭제 플로우 (참고) @@ -224,8 +250,8 @@ sequenceDiagram Service->>Repo: findByUserId(authenticatedUserId) activate Repo Repo->>DB: SELECT l.*, p.*
FROM likes l
JOIN products p ON l.product_id = p.id
WHERE l.user_id = ? - DB-->>Repo: List - Repo-->>Service: List + DB-->>Repo: List + Repo-->>Service: List deactivate Repo Service-->>API: List @@ -272,7 +298,6 @@ sequenceDiagram participant API as ProductController participant Service as ProductService participant ProdRepo as ProductRepository - participant LikeRepo as LikeRepository participant DB as Database User->>API: GET /products/{productId} @@ -281,23 +306,16 @@ sequenceDiagram API->>Service: getProductDetail(productId) activate Service - Service->>ProdRepo: findById(productId) + Service->>ProdRepo: findActiveById(productId) activate ProdRepo - ProdRepo->>DB: SELECT * FROM products
WHERE id = ? - DB-->>ProdRepo: Product 객체 - ProdRepo-->>Service: Product 객체 + ProdRepo->>DB: SELECT * FROM products
WHERE id = ? AND status='ACTIVE' + DB-->>ProdRepo: Product(+like_count) + ProdRepo-->>Service: Product(+like_count) deactivate ProdRepo - Service->>LikeRepo: countByProductId(productId) - activate LikeRepo - LikeRepo->>DB: SELECT COUNT(*)
FROM likes
WHERE product_id = ? - DB-->>LikeRepo: count (예: 42) - LikeRepo-->>Service: 42 - deactivate LikeRepo + Note over Service: like_count는 products 테이블 값 사용(최종 일관성) - Note over Service: Product + like_count 조합 - - Service-->>API: ProductDetailResponse
(product + likeCount: 42) + Service-->>API: ProductDetailResponse
(product + likeCount) deactivate Service API-->>User: 200 OK + 상품 정보 @@ -314,7 +332,6 @@ sequenceDiagram participant API as ProductController participant Service as ProductService participant ProdRepo as ProductRepository - participant LikeRepo as LikeRepository participant DB as Database User->>API: GET /products?page=1 @@ -323,23 +340,14 @@ sequenceDiagram API->>Service: getProductList(page) activate Service - Service->>ProdRepo: findAll(pageable) + Service->>ProdRepo: findAllActive(pageable) activate ProdRepo - ProdRepo->>DB: SELECT * FROM products
LIMIT 20 OFFSET 0 - DB-->>ProdRepo: List - ProdRepo-->>Service: List + ProdRepo->>DB: SELECT * FROM products
WHERE status='ACTIVE'
LIMIT 20 OFFSET 0 + DB-->>ProdRepo: List + ProdRepo-->>Service: List deactivate ProdRepo - Note over Service: 상품 ID 목록 추출
[1, 2, 3, ..., 20] - - Service->>LikeRepo: countByProductIds([1,2,3,...,20]) - activate LikeRepo - LikeRepo->>DB: SELECT product_id, COUNT(*)
FROM likes
WHERE product_id IN (...)
GROUP BY product_id - DB-->>LikeRepo: Map - LikeRepo-->>Service: Map<1→42, 2→15, ...> - deactivate LikeRepo - - Note over Service: Product + count 매칭 + Note over Service: like_count는 각 Product에 포함되어 반환 Service-->>API: List deactivate Service @@ -351,9 +359,12 @@ sequenceDiagram ### 이 다이어그램에서 봐야 할 포인트 1. **실시간 계산** - - 매 조회마다 `COUNT(*)` 쿼리 실행 - - 항상 정확한 값 반환 - - 비동기 처리나 이벤트 없음 + + ~~- 매 조회마다 `COUNT(*)` 쿼리 실행~~ + + ~~- 항상 정확한 값 반환~~ + + ~~- 비동기 처리나 이벤트 없음~~ 2. **성능 고려** - 단일 상품: COUNT 쿼리 1번 @@ -385,9 +396,9 @@ sequenceDiagram - DB 락 시간 최소화 [비동기 처리] -- 카운트 업데이트 -- 이벤트 실패해도 좋아요 등록/취소는 성공 -- 정합성은 배치로 복구 +- products.like_count 증감 +- 이벤트 실패/지연 시 정합성 불일치 가능 +- 배치로 정합성 복구 ``` ### 객체별 책임 @@ -395,11 +406,12 @@ sequenceDiagram | 객체 | 책임 | |------|------| | `LikeController` | HTTP 요청/응답, 인증 확인 | -| `LikeService` | 비즈니스 로직, 이벤트 발행 | +| `LikeService` | 좋아요 등록/취소, 상품 ACTIVE 검증 호출, 이벤트 발행 | | `LikeRepository` | 데이터 접근, 쿼리 실행 | -| `EventPublisher` | 메시지 큐 전송 | -| `LikeCountConsumer` | 카운트 업데이트 (독립 프로세스) | -| `ProductRepository` | 상품 데이터 접근 | +| `EventPublisher` | 이벤트 발행(비동기 시작점) | +| `LikeCountConsumer` | like_count 증감 처리 (독립 프로세스) | +| `ProductRepository` | 상품 조회 및 like_count 제공 | +| `BatchJob` | likes와 like_count 불일치 보정 | ### 호출 순서의 의미 @@ -425,12 +437,12 @@ sequenceDiagram **결과:** - 좋아요는 DB에 저장됨 -- 카운트는 업데이트 안 됨 +- like_count는 갱신되지 않을 수 있음 - 사용자는 성공 응답 받음 **대응:** - 배치로 정합성 복구 -- `likes` 테이블 COUNT와 `products.like_count` 비교하여 보정 +- `배치로 정합성 복구 (likes COUNT vs products.like_count 비교 후 보정) ### ⚠️ 리스크 2: Consumer 처리 실패 **상황:** @@ -438,7 +450,7 @@ sequenceDiagram **결과:** - 메시지는 큐에서 사라짐 (ack 전에 실패하면 재처리) -- 카운트가 실제와 어긋남 +- like_count가 실제와 어긋남 **대응:** - 현재 설계에서는 재시도 없음 @@ -451,20 +463,4 @@ sequenceDiagram | 비동기 카운트 업데이트 | 빠른 응답, 락 경합 없음 | 실시간 정합성 | | 재시도 없음 | 구현 단순 | 실패 시 데이터 불일치 | | 멱등성 보장 (취소) | 안정적인 API | 비정상 접근 추적 어려움 | -| URL userId 무시 | 확장 가능한 구조 | URL 파라미터 의미 모호 | - ---- - -## 다음 단계 - -다음 문서에서는 **클래스 다이어그램**을 작성하여: -- 각 객체의 속성과 메서드 정의 -- 도메인 간 의존 방향 명확화 -- 응집도와 결합도 검증 - -이후 **ERD**를 통해: -- 테이블 구조 및 제약사항 -- 인덱스 전략 -- 데이터 정합성 보장 방법 - -을 다룰 예정입니다. \ No newline at end of file +| URL userId 무시 | 확장 가능한 구조 | URL 파라미터 의미 모호 | \ No newline at end of file diff --git "a/docs/design/\354\242\213\354\225\204\354\232\224/03-class-diagram.md" "b/docs/design/\354\242\213\354\225\204\354\232\224/03-class-diagram.md" deleted file mode 100644 index a1baa7d9c..000000000 --- "a/docs/design/\354\242\213\354\225\204\354\232\224/03-class-diagram.md" +++ /dev/null @@ -1,723 +0,0 @@ -# 클래스 다이어그램 - -## 다이어그램을 그리기 전에 - -이 문서에서는 **좋아요 기능의 도메인 객체 설계**를 다룹니다. - -### 설계 원칙 -1. **단순하게 시작** - Facade, VO, 도메인 서비스 같은 패턴은 필요할 때 추가 -2. **책임 분리** - 각 레이어가 자기 역할만 수행 -3. **확장 가능** - 나중에 Redis, MessageQueue 추가해도 구조 변경 최소화 - -### 레이어 구조 -``` -Controller (API 진입점) - ↓ -Service (비즈니스 로직 조정) - ↓ -Repository (데이터 접근) - ↓ -Entity (도메인 모델) -``` - ---- - -## 1. 전체 클래스 다이어그램 - -```mermaid -classDiagram - class LikeController { - -LikeService likeService - +createLike(productId, userId) ResponseEntity - +deleteLike(productId, userId) ResponseEntity - +getLikesByUser(userId) ResponseEntity - } - - class ProductController { - -ProductService productService - -LikeService likeService - +getProductDetail(productId) ResponseEntity - +getProductList(pageable) ResponseEntity - } - - class LikeService { - -LikeRepository likeRepository - +createLike(userId, productId) Like - +deleteLike(userId, productId) void - +findLikesByUserId(userId) List~Like~ - +countByProductId(productId) int - +countByProductIds(productIds) Map~Long,Integer~ - } - - class ProductService { - -ProductRepository productRepository - +findById(productId) Product - +findAll(pageable) List~Product~ - } - - class LikeRepository { - <> - +existsByUserIdAndProductId(userId, productId) boolean - +findByUserIdAndProductId(userId, productId) Optional~Like~ - +findByUserId(userId) List~Like~ - +countByProductId(productId) int - +countByProductIds(productIds) Map~Long,Integer~ - +save(like) Like - +delete(like) void - } - - class ProductRepository { - <> - +findById(id) Optional~Product~ - +findAll(pageable) Page~Product~ - } - - class Like { - -Long id - -Long userId - -Long productId - -LocalDateTime createdAt - +create(userId, productId)$ Like - } - - class Product { - -Long id - -String name - -String description - -int price - -LocalDateTime createdAt - } - - LikeController --> LikeService - ProductController --> ProductService - ProductController --> LikeService - LikeService --> LikeRepository - ProductService --> ProductRepository - LikeRepository --> Like - ProductRepository --> Product -``` - ---- - -## 2. 레이어별 책임 정의 - -### Controller 레이어 - -#### LikeController -**책임:** -- HTTP 요청/응답 처리 -- 인증 정보 추출 (현재 로그인 사용자) -- DTO 변환 - -**주요 메서드:** -```java -@PostMapping("/api/v1/products/{productId}/likes") -public ResponseEntity createLike( - @PathVariable Long productId, - @AuthenticationPrincipal Long userId -) { - Like like = likeService.createLike(userId, productId); - return ResponseEntity.status(201).body(LikeResponse.from(like)); -} - -@DeleteMapping("/api/v1/products/{productId}/likes") -public ResponseEntity deleteLike( - @PathVariable Long productId, - @AuthenticationPrincipal Long userId -) { - likeService.deleteLike(userId, productId); - return ResponseEntity.noContent().build(); -} - -@GetMapping("/api/v1/users/{userId}/likes") -public ResponseEntity> getLikesByUser( - @PathVariable Long userId, // 무시됨 - @AuthenticationPrincipal Long authenticatedUserId -) { - List likes = likeService.findLikesByUserId(authenticatedUserId); - return ResponseEntity.ok(LikeResponse.fromList(likes)); -} -``` - -**특이사항:** -- URL의 `{userId}` 파라미터는 받지만 사용하지 않음 -- 항상 `@AuthenticationPrincipal`에서 추출한 사용자 ID 사용 - ---- - -#### ProductController -**책임:** -- 상품 조회 API 제공 -- 좋아요 수 포함 응답 생성 - -**주요 메서드:** -```java -@GetMapping("/api/v1/products/{productId}") -public ResponseEntity getProductDetail( - @PathVariable Long productId -) { - Product product = productService.findById(productId); - int likeCount = likeService.countByProductId(productId); - - return ResponseEntity.ok( - new ProductDetailResponse(product, likeCount) - ); -} - -@GetMapping("/api/v1/products") -public ResponseEntity> getProductList( - Pageable pageable -) { - List products = productService.findAll(pageable); - - // 상품 ID 목록 추출 - List productIds = products.stream() - .map(Product::getId) - .collect(Collectors.toList()); - - // 한 번에 좋아요 수 조회 (N+1 방지) - Map likeCounts = likeService.countByProductIds(productIds); - - List responses = products.stream() - .map(product -> new ProductListResponse( - product, - likeCounts.getOrDefault(product.getId(), 0) - )) - .collect(Collectors.toList()); - - return ResponseEntity.ok(responses); -} -``` - -**왜 Facade가 없는가?** -- 현재는 2개 Service만 조합 (단순) -- 복잡도가 낮아서 Controller에서 직접 처리 -- 나중에 Service 조합이 복잡해지면 Facade 추가 고려 - ---- - -### Service 레이어 - -#### LikeService -**책임:** -- 좋아요 비즈니스 로직 조정 -- 중복 체크 (이중 안전장치) -- 트랜잭션 경계 관리 - -**주요 메서드:** -```java -@Service -@Transactional -public class LikeService { - private final LikeRepository likeRepository; - - public Like createLike(Long userId, Long productId) { - // 1차 중복 체크 (애플리케이션 레벨) - if (likeRepository.existsByUserIdAndProductId(userId, productId)) { - throw new DuplicateLikeException("이미 좋아요를 누르셨습니다"); - } - - // Like 생성 (생성자가 검증 책임) - Like like = new Like(userId, productId); - - // 저장 (2차 방어: DB Unique 제약) - return likeRepository.save(like); - } - - public void deleteLike(Long userId, Long productId) { - Optional likeOpt = likeRepository.findByUserIdAndProductId(userId, productId); - - if (likeOpt.isEmpty()) { - // 멱등성 보장: 이미 없으면 로그만 남기고 성공 처리 - log.warn("이미 삭제된 좋아요 - userId: {}, productId: {}", userId, productId); - return; - } - - likeRepository.delete(likeOpt.get()); - } - - @Transactional(readOnly = true) - public List findLikesByUserId(Long userId) { - return likeRepository.findByUserId(userId); - } - - @Transactional(readOnly = true) - public int countByProductId(Long productId) { - return likeRepository.countByProductId(productId); - } - - @Transactional(readOnly = true) - public Map countByProductIds(List productIds) { - return likeRepository.countByProductIds(productIds); - } -} -``` - -**특이사항:** -- `deleteLike`는 존재하지 않아도 예외를 던지지 않음 (멱등성) -- 조회 메서드는 `@Transactional(readOnly = true)` -- **도메인 서비스가 없는 이유**: 복잡한 비즈니스 규칙이 없음 - ---- - -#### ProductService -**책임:** -- 상품 조회 로직 - -**주요 메서드:** -```java -@Service -@Transactional(readOnly = true) -public class ProductService { - private final ProductRepository productRepository; - - public Product findById(Long productId) { - return productRepository.findById(productId) - .orElseThrow(() -> new ProductNotFoundException(productId)); - } - - public List findAll(Pageable pageable) { - return productRepository.findAll(pageable).getContent(); - } -} -``` - -**왜 LikeService를 의존하지 않는가?** -- Product는 Like를 알 필요가 없음 (도메인 경계) -- 좋아요 수는 Controller에서 조합 -- 의존 방향: `Like → Product` (O), `Product → Like` (X) - ---- - -### Repository 레이어 - -#### LikeRepository -**책임:** -- Like 엔티티의 데이터 접근 -- 쿼리 실행 (조회, 저장, 삭제, 집계) - -**주요 메서드:** -```java -public interface LikeRepository extends JpaRepository { - - // 존재 여부 확인 (중복 체크) - boolean existsByUserIdAndProductId(Long userId, Long productId); - - // 특정 좋아요 조회 - Optional findByUserIdAndProductId(Long userId, Long productId); - - // 사용자의 좋아요 목록 (상품 정보 포함) - @Query("SELECT l FROM Like l JOIN FETCH l.product WHERE l.userId = :userId") - List findByUserId(@Param("userId") Long userId); - - // 상품별 좋아요 수 (단일) - @Query("SELECT COUNT(l) FROM Like l WHERE l.productId = :productId") - int countByProductId(@Param("productId") Long productId); - - // 상품별 좋아요 수 (다건 - N+1 방지) - @Query("SELECT l.productId as productId, COUNT(l) as count " + - "FROM Like l " + - "WHERE l.productId IN :productIds " + - "GROUP BY l.productId") - List countByProductIdsGrouped(@Param("productIds") List productIds); - - // Map으로 변환하는 default 메서드 - default Map countByProductIds(List productIds) { - return countByProductIdsGrouped(productIds).stream() - .collect(Collectors.toMap( - LikeCountProjection::getProductId, - LikeCountProjection::getCount - )); - } - - interface LikeCountProjection { - Long getProductId(); - Integer getCount(); - } -} -``` - -**확장 포인트 (Redis 도입 시):** -```java -// 현재: DB에서 직접 COUNT -int countByProductId(Long productId); - -// 향후: Cache 레이어 추가 -@Cacheable(value = "likeCount", key = "#productId") -int countByProductId(Long productId); -``` - ---- - -#### ProductRepository -**책임:** -- Product 엔티티의 데이터 접근 - -```java -public interface ProductRepository extends JpaRepository { - // JpaRepository 기본 메서드 사용 - // findById, findAll, save, delete 등 -} -``` - ---- - -### Entity 레이어 - -#### Like (좋아요 엔티티) - -```java -@Entity -@Table( - name = "likes", - uniqueConstraints = { - @UniqueConstraint( - name = "uk_likes_user_product", - columnNames = {"user_id", "product_id"} - ) - } -) -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@Getter -public class Like { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(name = "user_id", nullable = false) - private final Long userId; - - @Column(name = "product_id", nullable = false) - private final Long productId; - - @Column(name = "created_at", nullable = false, updatable = false) - private final LocalDateTime createdAt; - - // 생성자가 검증 책임 - public Like(Long userId, Long productId) { - validateUserId(userId); - validateProductId(productId); - - this.userId = userId; - this.productId = productId; - this.createdAt = LocalDateTime.now(); - } - - private void validateUserId(Long userId) { - if (userId == null || userId <= 0) { - throw new IllegalArgumentException("유효하지 않은 사용자 ID입니다"); - } - } - - private void validateProductId(Long productId) { - if (productId == null || productId <= 0) { - throw new IllegalArgumentException("유효하지 않은 상품 ID입니다"); - } - } -} -``` - -**설계 포인트:** -1. **생성자에서 검증** - - `new Like(userId, productId)` 사용 - - 생성자가 검증 로직을 가짐 - - 단순하고 명확함 - -2. **Unique 제약** - - DB 레벨에서 중복 방지 - - 애플리케이션 체크 실패 시 최종 방어선 - -3. **VO를 사용하지 않는 이유** - - 현재는 단순 primitive 타입으로 충분 - - 나중에 필요하면 `LikeCount` VO 추가 고려 - -4. **연관관계 매핑 안 함** - - `@ManyToOne Product product` 같은 거 없음 - - 이유: Like는 productId만 알면 됨 (객체 그래프 탐색 불필요) - - 조회 시 필요하면 JOIN FETCH 사용 - ---- - -#### Product (상품 엔티티) - -```java -@Entity -@Table(name = "products") -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@Getter -public class Product { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(nullable = false) - private final String name; - - @Column(columnDefinition = "TEXT") - private final String description; - - @Column(nullable = false) - private final int price; - - @Column(name = "created_at", nullable = false, updatable = false) - private final LocalDateTime createdAt; - - @Builder - public Product(String name, String description, int price) { - this.name = name; - this.description = description; - this.price = price; - this.createdAt = LocalDateTime.now(); - } -} -``` - -**설계 포인트:** -1. **like_count 컬럼 없음** - - 실시간 COUNT 쿼리로 계산 - - 정합성 문제 없음 - -2. **Like 엔티티와 연관관계 없음** - - Product는 Like를 모름 - - 단방향 의존: `Like → Product` - ---- - -## 3. 의존 방향 다이어그램 - -```mermaid -graph TD - A[LikeController] --> B[LikeService] - C[ProductController] --> D[ProductService] - C --> B - B --> E[LikeRepository] - D --> F[ProductRepository] - E --> G[Like Entity] - F --> H[Product Entity] - - style A fill:#e1f5ff - style C fill:#e1f5ff - style B fill:#fff4e1 - style D fill:#fff4e1 - style E fill:#f0f0f0 - style F fill:#f0f0f0 - style G fill:#e8f5e9 - style H fill:#e8f5e9 -``` - -**의존 방향 원칙:** -- Controller → Service (단방향) -- Service → Repository (단방향) -- Repository → Entity (단방향) -- **Product ↛ Like** (상품은 좋아요를 모름) - ---- - -## 4. 설계 의도 정리 - -### 왜 이렇게 설계했는가? - -#### 1. Facade를 넣지 않은 이유 -``` -현재: ProductController → ProductService + LikeService -``` - -**판단 기준:** -- Service 조합이 2개로 단순 -- 복잡한 트랜잭션 조율 없음 -- 나중에 필요하면 추가 가능 (YAGNI 원칙) - -**Facade가 필요한 시점:** -- ReviewService, CommentService 등 3개 이상 조합 -- 여러 Controller에서 동일한 조합 반복 -- 복잡한 보상 트랜잭션 필요 - ---- - -#### 2. VO를 넣지 않은 이유 -``` -현재: int likeCount -가능: LikeCount likeCount (VO) -``` - -**판단 기준:** -- 현재는 단순 정수로 충분 -- 복잡한 비즈니스 규칙 없음 -- 조기 최적화 방지 - -**VO가 필요한 시점:** -- 음수 방지, 범위 체크 등 검증 로직 필요 -- likeCount와 관련된 메서드가 여러 곳에 중복 -- 도메인 의미가 강해질 때 (예: "인기 상품 기준") - ---- - -#### 3. 도메인 서비스를 넣지 않은 이유 -``` -현재: LikeService (Application Service) -가능: LikeDomainService (Domain Service) -``` - -**판단 기준:** -- 복잡한 도메인 로직 없음 -- Like 생성/삭제/조회만 있음 -- 엔티티 간 협력 로직 없음 - -**도메인 서비스가 필요한 시점:** -- 여러 엔티티가 협력하는 로직 -- 특정 엔티티에 속하지 않는 비즈니스 규칙 -- 예: "스팸 좋아요 감지", "좋아요 제한 정책" - ---- - -#### 4. 중복 체크를 이중으로 하는 이유 -``` -1차: existsByUserIdAndProductId() - SELECT EXISTS -2차: DB Unique 제약 - INSERT 시 체크 -``` - -**판단 기준:** -- 트래픽 적음 → 쿼리 1번 더 괜찮음 -- 명확한 에러 메시지 (사용자 경험) -- DB Unique는 최종 방어선 (Race Condition 방지) - -**나중에 변경 가능:** -- 트래픽 증가 시 1차 체크 제거 -- `try-catch`로 DB 제약 위반 처리 - ---- - -## 5. 확장 시나리오 - -### 시나리오 1: Redis 캐시 도입 - -**변경 지점:** -```java -// LikeRepository 또는 LikeService -@Cacheable(value = "likeCount", key = "#productId") -public int countByProductId(Long productId) { - // 캐시 미스 시에만 DB 조회 - return likeRepository.countByProductId(productId); -} -``` - -**영향 범위:** -- Repository 또는 Service 한 곳만 수정 -- Controller, Entity는 변경 없음 - ---- - -### 시나리오 2: 좋아요 알림 기능 추가 - -**필요한 변경:** -```java -// LikeService -public Like createLike(Long userId, Long productId) { - // ... 기존 로직 - Like like = likeRepository.save(like); - - // 이벤트 발행 추가 - eventPublisher.publish(new LikeCreatedEvent(like)); - - return like; -} - -// 새로 추가 -@Component -class LikeEventListener { - @EventListener - public void onLikeCreated(LikeCreatedEvent event) { - // 상품 소유자에게 알림 전송 - } -} -``` - -**영향 범위:** -- LikeService에 이벤트 발행 추가 -- EventListener 새로 생성 -- Controller, Repository, Entity는 변경 없음 - ---- - -### 시나리오 3: 친구 좋아요 목록 조회 추가 - -**필요한 변경:** -```java -// LikeController -@GetMapping("/api/v1/users/{userId}/likes") -public ResponseEntity> getLikesByUser( - @PathVariable Long userId, - @AuthenticationPrincipal Long authenticatedUserId -) { - // 권한 체크 추가 - if (!userId.equals(authenticatedUserId) && !isFriend(userId, authenticatedUserId)) { - throw new ForbiddenException(); - } - - List likes = likeService.findLikesByUserId(userId); - return ResponseEntity.ok(LikeResponse.fromList(likes)); -} -``` - -**영향 범위:** -- Controller에 권한 체크만 추가 -- Service, Repository는 변경 없음 -- URL 구조는 이미 확장 가능하게 설계됨 - ---- - -## 6. 설계 리스크 및 트레이드오프 - -### ⚠️ 리스크 1: COUNT 쿼리 성능 -**상황:** -상품 목록 조회 시 매번 `GROUP BY` 실행 - -**영향:** -- 좋아요 테이블이 커지면 느려질 수 있음 -- 페이지당 20개 상품이면, 20개 상품의 COUNT 한 번에 계산 - -**대응:** -- 인덱스: `likes(product_id)` -- 나중에 Redis 캐시 도입 -- 현재는 문제 없음 (트래픽 적음) - ---- - -### ⚠️ 리스크 2: 중복 체크의 오버헤드 -**상황:** -좋아요 등록 시 `SELECT EXISTS` + `INSERT` 2번 쿼리 - -**영향:** -- 트래픽 많아지면 불필요한 SELECT -- 대부분 중복 아닐 텐데 매번 체크 - -**대응:** -- 초기에는 사용자 경험 우선 -- 트래픽 증가 시 `try-catch` 방식으로 전환 -- 현재는 괜찮음 - ---- - -### ✅ 트레이드오프 정리 - -| 선택 | 얻은 것 | 잃은 것 | 판단 | -|------|---------|---------|------| -| Facade 없음 | 코드 단순, 레이어 적음 | 조합 로직 분산 | 현재 규모에 적합 | -| VO 없음 | 구조 단순 | 검증 로직 분산 가능 | 필요 시 추가 | -| 도메인 서비스 없음 | 레이어 적음 | - | 복잡한 로직 없어서 불필요 | -| COUNT 실시간 계산 | 정합성 100% | 조회 성능 | 트래픽 적을 때 유리 | -| 이중 중복 체크 | 명확한 에러 | 쿼리 1번 더 | 사용자 경험 우선 | - ---- - -## 다음 단계 - -다음 문서에서는 **ERD(Entity Relationship Diagram)**를 작성하여: -- 테이블 구조 및 컬럼 정의 -- 인덱스 전략 -- Unique 제약 및 외래키 -- 데이터 정합성 보장 방법 - -을 다룰 예정입니다. \ No newline at end of file diff --git "a/docs/design/\354\242\213\354\225\204\354\232\224/04-erd.md" "b/docs/design/\354\242\213\354\225\204\354\232\224/04-erd.md" deleted file mode 100644 index 47f17adea..000000000 --- "a/docs/design/\354\242\213\354\225\204\354\232\224/04-erd.md" +++ /dev/null @@ -1,782 +0,0 @@ -# ERD (Entity Relationship Diagram) - -## 다이어그램을 그리기 전에 - -이 문서에서는 **좋아요 기능의 데이터베이스 설계**를 다룹니다. - -### 설계 원칙 -1. **정합성 우선** - DB 제약으로 데이터 무결성 보장 -2. **성능 고려** - 필수 인덱스만 추가 (과한 인덱스는 쓰기 성능 저하) -3. **단순하게 시작** - 외래키, 트리거 등은 필요할 때 추가 - -### 주요 결정사항 -- **products 테이블에 like_count 컬럼 없음** - 실시간 COUNT 쿼리 사용 -- **외래키 제약** - 사용 여부 논의 필요 -- **인덱스 전략** - 조회 성능 최적화 - ---- - -## 1. 전체 ERD - -```mermaid -erDiagram - users ||--o{ likes : "좋아요 등록" - products ||--o{ likes : "좋아요 대상" - - users { - BIGINT id PK "사용자 ID" - VARCHAR(50) username UK "사용자명" - VARCHAR(100) email UK "이메일" - TIMESTAMP created_at "생성일시" - } - - products { - BIGINT id PK "상품 ID" - VARCHAR(200) name "상품명" - TEXT description "상품 설명" - INT price "가격" - TIMESTAMP created_at "생성일시" - } - - likes { - BIGINT id PK "좋아요 ID" - BIGINT user_id FK "사용자 ID" - BIGINT product_id FK "상품 ID" - TIMESTAMP created_at "생성일시" - } -``` - ---- - -## 2. 테이블 상세 정의 - -### users 테이블 - -> **참고:** 이 테이블은 좋아요 기능을 위해 필요하지만, 실제 구현은 인증/인가 시스템에서 제공될 것으로 가정합니다. - -```sql -CREATE TABLE users ( - id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '사용자 ID', - username VARCHAR(50) NOT NULL UNIQUE COMMENT '사용자명', - email VARCHAR(100) NOT NULL UNIQUE COMMENT '이메일', - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일시', - - INDEX idx_username (username), - INDEX idx_email (email) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='사용자'; -``` - -**컬럼 설명:** - -| 컬럼 | 타입 | 제약 | 설명 | -|------|------|------|------| -| id | BIGINT | PK, AUTO_INCREMENT | 사용자 고유 ID | -| username | VARCHAR(50) | NOT NULL, UNIQUE | 사용자명 | -| email | VARCHAR(100) | NOT NULL, UNIQUE | 이메일 | -| created_at | TIMESTAMP | NOT NULL, DEFAULT | 가입일시 | - ---- - -### products 테이블 - -```sql -CREATE TABLE products ( - id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '상품 ID', - name VARCHAR(200) NOT NULL COMMENT '상품명', - description TEXT COMMENT '상품 설명', - price INT NOT NULL COMMENT '가격', - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일시', - - INDEX idx_created_at (created_at DESC) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='상품'; -``` - -**컬럼 설명:** - -| 컬럼 | 타입 | 제약 | 설명 | -|------|------|------|------| -| id | BIGINT | PK, AUTO_INCREMENT | 상품 고유 ID | -| name | VARCHAR(200) | NOT NULL | 상품명 | -| description | TEXT | NULL | 상품 설명 (길이 제한 없음) | -| price | INT | NOT NULL | 가격 (원 단위) | -| created_at | TIMESTAMP | NOT NULL, DEFAULT | 등록일시 | - -**중요: like_count 컬럼이 없는 이유** -- 실시간 COUNT 쿼리로 계산 -- 정합성 문제 없음 -- 나중에 Redis 캐시로 전환 가능 - ---- - -### likes 테이블 ⭐ - -```sql -CREATE TABLE likes ( - id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '좋아요 ID', - user_id BIGINT NOT NULL COMMENT '사용자 ID', - product_id BIGINT NOT NULL COMMENT '상품 ID', - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일시', - - -- 중복 방지 제약 (핵심!) - CONSTRAINT uk_likes_user_product UNIQUE (user_id, product_id), - - -- 인덱스 - INDEX idx_user_id (user_id), - INDEX idx_product_id (product_id), - INDEX idx_created_at (created_at DESC) - - -- 외래키 (선택적 - 아래에서 논의) - -- CONSTRAINT fk_likes_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, - -- CONSTRAINT fk_likes_product FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='좋아요'; -``` - -**컬럼 설명:** - -| 컬럼 | 타입 | 제약 | 설명 | -|------|------|------|------| -| id | BIGINT | PK, AUTO_INCREMENT | 좋아요 고유 ID | -| user_id | BIGINT | NOT NULL | 사용자 ID | -| product_id | BIGINT | NOT NULL | 상품 ID | -| created_at | TIMESTAMP | NOT NULL, DEFAULT | 좋아요 등록일시 | - ---- - -## 3. 제약 조건 (Constraints) - -### Unique 제약: uk_likes_user_product ⭐⭐⭐ - -**가장 중요한 제약입니다.** - -```sql -CONSTRAINT uk_likes_user_product UNIQUE (user_id, product_id) -``` - -**역할:** -- 한 사용자가 한 상품에 대해 좋아요는 1개만 가능 -- DB 레벨에서 중복 방지 (Race Condition 방지) -- 애플리케이션 체크 실패 시 최종 방어선 - -**동작:** -```sql --- 성공 -INSERT INTO likes (user_id, product_id) VALUES (1, 100); - --- 실패 (DataIntegrityViolationException) -INSERT INTO likes (user_id, product_id) VALUES (1, 100); -``` - -**복합 인덱스 효과:** -- Unique 제약은 자동으로 인덱스를 생성 -- `(user_id, product_id)` 조합으로 빠른 조회 가능 - ---- - -### 외래키 제약 (Foreign Key) - 논의 필요 🤔 - -**외래키를 사용할까요?** - -```sql --- 외래키 추가 시 -CONSTRAINT fk_likes_user - FOREIGN KEY (user_id) - REFERENCES users(id) - ON DELETE CASCADE, - -CONSTRAINT fk_likes_product - FOREIGN KEY (product_id) - REFERENCES products(id) - ON DELETE CASCADE -``` - -#### 외래키를 사용하는 경우 - -**장점:** -- 참조 무결성 보장 (존재하지 않는 user_id, product_id 막음) -- DB 레벨에서 데이터 정합성 보장 -- `ON DELETE CASCADE`: 상품 삭제 시 좋아요도 자동 삭제 - -**단점:** -- 쓰기 성능 저하 (INSERT/DELETE 시 참조 테이블 확인) -- 락 경합 가능성 -- 유연성 감소 (스키마 변경 어려움) - -#### 외래키를 사용하지 않는 경우 - -**장점:** -- 쓰기 성능 우수 -- 락 경합 없음 -- 스키마 변경 유연 - -**단점:** -- 애플리케이션에서 참조 무결성 보장해야 함 -- 고아 데이터(orphan data) 발생 가능 - -**어떻게 할까요?** - ---- - -## 4. 인덱스 전략 - -### likes 테이블 인덱스 - -```sql --- 1. Unique 제약 (자동 생성) -UNIQUE INDEX uk_likes_user_product (user_id, product_id) - --- 2. 사용자별 좋아요 조회 -INDEX idx_user_id (user_id) - --- 3. 상품별 좋아요 수 계산 (핵심!) -INDEX idx_product_id (product_id) - --- 4. 최근 좋아요 조회 (선택적) -INDEX idx_created_at (created_at DESC) -``` - -### 각 인덱스의 역할 - -#### 1. uk_likes_user_product (user_id, product_id) - -**사용 쿼리:** -```sql --- 중복 체크 -SELECT COUNT(*) FROM likes WHERE user_id = ? AND product_id = ?; - --- 좋아요 삭제 -DELETE FROM likes WHERE user_id = ? AND product_id = ?; -``` - -**특징:** -- 복합 인덱스 (user_id가 선두 컬럼) -- `user_id`로 시작하는 쿼리에 사용 가능 - ---- - -#### 2. idx_user_id (user_id) - -**사용 쿼리:** -```sql --- 사용자의 좋아요 목록 조회 -SELECT * FROM likes WHERE user_id = ?; -``` - -**질문: uk_likes_user_product가 있는데 왜 필요한가?** - -복합 인덱스 `(user_id, product_id)`가 있으면 `user_id` 단독 조회도 가능합니다. -따라서 **이 인덱스는 사실 불필요할 수 있습니다.** - -**선택지:** -- A. `idx_user_id` 제거 (복합 인덱스로 충분) -- B. `idx_user_id` 유지 (명확성) - ---- - -#### 3. idx_product_id (product_id) ⭐ - -**사용 쿼리:** -```sql --- 상품별 좋아요 수 (단일) -SELECT COUNT(*) FROM likes WHERE product_id = ?; - --- 상품별 좋아요 수 (다건) -SELECT product_id, COUNT(*) -FROM likes -WHERE product_id IN (1, 2, 3, ...) -GROUP BY product_id; -``` - -**가장 중요한 인덱스입니다.** -- 상품 조회 시 좋아요 수 계산에 사용 -- 이 인덱스가 없으면 Full Table Scan 발생 -- 필수! - ---- - -#### 4. idx_created_at (created_at DESC) - -**사용 쿼리:** -```sql --- 최근 좋아요 조회 (필요하다면) -SELECT * FROM likes ORDER BY created_at DESC LIMIT 10; -``` - -**필요 여부:** -- 현재 요구사항에는 없음 -- "최근 인기 상품" 같은 기능 추가 시 필요 -- 일단 **제거 고려** - ---- - -### 인덱스 정리 - -**최소한으로 필요한 인덱스:** -```sql --- 1. 중복 방지 (필수) -UNIQUE INDEX uk_likes_user_product (user_id, product_id) - --- 2. 상품별 좋아요 수 계산 (필수) -INDEX idx_product_id (product_id) -``` - -**선택적 인덱스:** -```sql --- 3. 사용자별 조회 (복합 인덱스로 대체 가능) -INDEX idx_user_id (user_id) - --- 4. 최근 좋아요 조회 (현재 불필요) -INDEX idx_created_at (created_at DESC) -``` - ---- - -## 5. 쿼리 성능 분석 - -### 주요 쿼리별 인덱스 활용 - -#### 쿼리 1: 좋아요 등록 (중복 체크) -```sql -SELECT COUNT(*) FROM likes -WHERE user_id = 1 AND product_id = 100; -``` -**사용 인덱스:** `uk_likes_user_product (user_id, product_id)` -- ✅ Index Scan -- ⚡ 매우 빠름 - ---- - -#### 쿼리 2: 상품별 좋아요 수 (단일) -```sql -SELECT COUNT(*) FROM likes WHERE product_id = 100; -``` -**사용 인덱스:** `idx_product_id (product_id)` -- ✅ Index Scan -- ⚡ 빠름 - ---- - -#### 쿼리 3: 상품별 좋아요 수 (다건) -```sql -SELECT product_id, COUNT(*) as count -FROM likes -WHERE product_id IN (1, 2, 3, ..., 20) -GROUP BY product_id; -``` -**사용 인덱스:** `idx_product_id (product_id)` -- ✅ Index Range Scan -- ⚡ 빠름 -- N+1 문제 방지 - ---- - -#### 쿼리 4: 사용자별 좋아요 목록 -```sql -SELECT l.*, p.* -FROM likes l -JOIN products p ON l.product_id = p.id -WHERE l.user_id = 1; -``` -**사용 인덱스:** `uk_likes_user_product (user_id, product_id)` -- ✅ Index Scan (user_id로 시작) -- ✅ JOIN은 products.id (PK) 사용 -- ⚡ 빠름 - ---- - -#### 쿼리 5: 좋아요 삭제 -```sql -DELETE FROM likes -WHERE user_id = 1 AND product_id = 100; -``` -**사용 인덱스:** `uk_likes_user_product (user_id, product_id)` -- ✅ Index Scan -- ⚡ 매우 빠름 - ---- - -## 6. 데이터 타입 선택 근거 - -### BIGINT vs INT - -**선택: BIGINT 사용** - -```sql -id BIGINT AUTO_INCREMENT -user_id BIGINT NOT NULL -product_id BIGINT NOT NULL -``` - -**이유:** -- INT 범위: -2,147,483,648 ~ 2,147,483,647 (약 21억) -- BIGINT 범위: -9,223,372,036,854,775,808 ~ 9,223,372,036,854,775,807 (매우 큼) -- 사용자/상품/좋아요가 21억 넘을 가능성 고려 -- 나중에 INT → BIGINT 변경은 매우 고통스러움 -- 저장 공간 차이: 4byte vs 8byte (큰 문제 아님) - ---- - -### TIMESTAMP vs DATETIME - -**선택: TIMESTAMP 사용** - -```sql -created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -``` - -**비교:** - -| 특성 | TIMESTAMP | DATETIME | -|------|-----------|----------| -| 범위 | 1970 ~ 2038 | 1000 ~ 9999 | -| 타임존 | 자동 변환 | 변환 안 함 | -| 저장 공간 | 4 byte | 5~8 byte | - -**TIMESTAMP 선택 이유:** -- 타임존 자동 변환 (서버 타임존 기준) -- 범위는 충분 (2038년 문제는 나중에 고민) -- 작은 저장 공간 - -**주의:** -- 2038년 이후가 중요하다면 DATETIME 사용 - ---- - -### VARCHAR vs TEXT - -**선택:** -- 상품명: `VARCHAR(200)` - 길이 제한 필요 -- 상품 설명: `TEXT` - 길이 제한 없음 - -**VARCHAR vs TEXT 차이:** - -| 특성 | VARCHAR | TEXT | -|------|---------|------| -| 최대 길이 | 65,535 byte | 65,535 byte | -| 인덱스 | 전체 가능 | Prefix만 가능 | -| 기본값 | 가능 | 불가능 (MySQL) | - -**상품명은 왜 VARCHAR(200)?** -- 인덱스 가능 (검색 필요 시) -- 너무 긴 상품명 방지 -- 200자면 충분 - -**상품 설명은 왜 TEXT?** -- 길이 제한 없음 (긴 설명 가능) -- 인덱스 불필요 (검색 안 함) - ---- - -## 7. 데이터 정합성 보장 전략 - -### 계층별 보장 방법 - -``` -┌─────────────────────────────────────┐ -│ Application (LikeService) │ -│ - exists() 체크 (1차 방어) │ -└──────────────┬──────────────────────┘ - │ -┌──────────────▼──────────────────────┐ -│ Database │ -│ - Unique 제약 (2차 방어, 최종) │ -│ - NOT NULL 제약 │ -│ - (선택) 외래키 제약 │ -└─────────────────────────────────────┘ -``` - -### 1. 중복 좋아요 방지 - -**계층:** -- 1차: Application - `existsByUserIdAndProductId()` -- 2차: Database - `UNIQUE (user_id, product_id)` - -**Race Condition 시나리오:** -``` -시간 0초: User1 exists() 체크 → false -시간 0초: User2 exists() 체크 → false -시간 1초: User1 INSERT → 성공 -시간 1초: User2 INSERT → Unique 제약 위반 (실패) -``` - -**결과:** DB 제약이 최종적으로 막음 ✅ - ---- - -### 2. NULL 방지 - -```sql -user_id BIGINT NOT NULL -product_id BIGINT NOT NULL -``` - -**애플리케이션:** -```java -public Like(Long userId, Long productId) { - if (userId == null) throw new IllegalArgumentException(); - if (productId == null) throw new IllegalArgumentException(); - // ... -} -``` - -**2중 보장:** -- 1차: 생성자 검증 -- 2차: DB NOT NULL 제약 - ---- - -### 3. 참조 무결성 (선택적) - -**외래키를 사용한다면:** -```sql -CONSTRAINT fk_likes_user FOREIGN KEY (user_id) REFERENCES users(id) -CONSTRAINT fk_likes_product FOREIGN KEY (product_id) REFERENCES products(id) -``` - -**외래키를 사용하지 않는다면:** -```java -// Service에서 확인 -public Like createLike(Long userId, Long productId) { - if (!userRepository.existsById(userId)) { - throw new UserNotFoundException(); - } - if (!productRepository.existsById(productId)) { - throw new ProductNotFoundException(); - } - // ... -} -``` - -**어느 쪽을 선택할까요?** - ---- - -## 8. 설계 의도 정리 - -### 왜 이렇게 설계했는가? - -#### 1. products.like_count 컬럼을 만들지 않은 이유 - -**선택: 실시간 COUNT 쿼리** - -```sql --- 매번 계산 -SELECT COUNT(*) FROM likes WHERE product_id = ?; -``` - -**이유:** -- 정합성 100% 보장 -- 비동기 처리 복잡도 제거 -- 나중에 Redis로 전환 쉬움 - -**트레이드오프:** -- 장점: 정합성, 단순함 -- 단점: 조회 성능 (인덱스로 해결) - ---- - -#### 2. 복합 Unique 제약 사용 - -**선택: `UNIQUE (user_id, product_id)`** - -**이유:** -- 중복 방지를 DB가 보장 -- Race Condition 완벽 차단 -- 자동으로 복합 인덱스 생성 - ---- - -#### 3. 최소한의 인덱스 - -**선택:** -- `UNIQUE (user_id, product_id)` - 필수 -- `INDEX (product_id)` - 필수 -- 나머지는 제거 고려 - -**이유:** -- 인덱스는 쓰기 성능 저하 -- 필요할 때 추가하는 게 나음 -- 초기에는 단순하게 - ---- - -## 9. 확장 시나리오 - -### 시나리오 1: Redis 캐시 도입 - -**변경 전:** -```sql -SELECT COUNT(*) FROM likes WHERE product_id = ?; -``` - -**변경 후:** -``` -1. Redis에서 GET like_count:{product_id} -2. Cache Miss 시 DB 조회 -3. Redis에 SET like_count:{product_id} -``` - -**테이블 변경 없음!** - ---- - -### 시나리오 2: 좋아요 취소 시 "취소 사유" 추가 - -**변경:** -```sql -ALTER TABLE likes ADD COLUMN deleted_at TIMESTAMP NULL; -ALTER TABLE likes ADD COLUMN delete_reason VARCHAR(100) NULL; - --- Unique 제약 수정 (soft delete 고려) -DROP INDEX uk_likes_user_product; -CREATE UNIQUE INDEX uk_likes_user_product -ON likes (user_id, product_id) -WHERE deleted_at IS NULL; -- PostgreSQL -``` - ---- - -### 시나리오 3: 샤딩 (Sharding) - -**user_id 기준 샤딩:** -``` -Shard 1: user_id % 4 = 0 -Shard 2: user_id % 4 = 1 -Shard 3: user_id % 4 = 2 -Shard 4: user_id % 4 = 3 -``` - -**product_id 기준 샤딩:** -``` -Shard 1: product_id % 4 = 0 -... -``` - -**고려사항:** -- 어떤 기준으로 샤딩할지 (user_id vs product_id) -- 현재 설계는 양쪽 다 가능 - ---- - -## 10. 설계 리스크 및 트레이드오프 - -### ⚠️ 리스크 1: COUNT 쿼리 성능 - -**상황:** -좋아요 테이블이 1억 건 이상으로 커지면? - -**영향:** -```sql -SELECT COUNT(*) FROM likes WHERE product_id = ?; -``` -- 인덱스 스캔이지만 느려질 수 있음 - -**대응:** -1. Redis 캐시 도입 (우선) -2. products.like_count 컬럼 추가 (차선) -3. 파티셔닝 (최후) - ---- - -### ⚠️ 리스크 2: 외래키 미사용 시 고아 데이터 - -**상황:** -외래키를 사용하지 않으면, 사용자/상품 삭제 시 좋아요가 남음 - -**영향:** -```sql --- 상품 삭제 -DELETE FROM products WHERE id = 100; - --- 좋아요는 남아있음 -SELECT * FROM likes WHERE product_id = 100; -- 여전히 있음 -``` - -**대응:** -1. 애플리케이션에서 명시적 삭제 -```java -productService.delete(productId) { - likeRepository.deleteByProductId(productId); // 먼저 삭제 - productRepository.deleteById(productId); -} -``` -2. 배치로 주기적 정리 -3. 외래키 사용 - ---- - -### ✅ 트레이드오프 정리 - -| 선택 | 얻은 것 | 잃은 것 | 판단 | -|------|---------|---------|------| -| like_count 컬럼 없음 | 정합성 100% | 조회 성능 | 인덱스+Redis로 해결 | -| 외래키 미사용 | 쓰기 성능, 유연성 | 고아 데이터 가능 | 애플리케이션에서 관리 | -| 최소 인덱스 | 쓰기 성능 | 조회 성능 저하 가능 | 필요 시 추가 | -| BIGINT 사용 | 확장성 | 저장 공간 (미미) | 나중에 바꾸기 어려움 | - ---- - -## 11. 최종 DDL - -### 추천 DDL (외래키 없음) - -```sql --- users 테이블 -CREATE TABLE users ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - username VARCHAR(50) NOT NULL UNIQUE, - email VARCHAR(100) NOT NULL UNIQUE, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - INDEX idx_username (username), - INDEX idx_email (email) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- products 테이블 -CREATE TABLE products ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - name VARCHAR(200) NOT NULL, - description TEXT, - price INT NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - INDEX idx_created_at (created_at DESC) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- likes 테이블 -CREATE TABLE likes ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - user_id BIGINT NOT NULL, - product_id BIGINT NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - - -- 중복 방지 (가장 중요!) - CONSTRAINT uk_likes_user_product UNIQUE (user_id, product_id), - - -- 필수 인덱스 - INDEX idx_product_id (product_id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -``` - -### 선택사항 1: 외래키 추가 - -```sql -ALTER TABLE likes -ADD CONSTRAINT fk_likes_user - FOREIGN KEY (user_id) - REFERENCES users(id) - ON DELETE CASCADE; - -ALTER TABLE likes -ADD CONSTRAINT fk_likes_product - FOREIGN KEY (product_id) - REFERENCES products(id) - ON DELETE CASCADE; -``` - -### 선택사항 2: 추가 인덱스 - -```sql --- 사용자별 조회 최적화 (복합 인덱스로 대체 가능하므로 선택적) -ALTER TABLE likes ADD INDEX idx_user_id (user_id); - --- 최근 좋아요 조회 (현재 불필요) --- ALTER TABLE likes ADD INDEX idx_created_at (created_at DESC); -``` diff --git "a/docs/design/\354\243\274\353\254\270/03-class-diagram.md" "b/docs/design/\354\243\274\353\254\270/03-class-diagram.md" deleted file mode 100644 index e69de29bb..000000000 diff --git "a/docs/design/\354\243\274\353\254\270/04-erd.md" "b/docs/design/\354\243\274\353\254\270/04-erd.md" deleted file mode 100644 index e69de29bb..000000000 From a3cebcc4043eefd873601b47e4dd9294d745a299 Mon Sep 17 00:00:00 2001 From: katiekim17 Date: Fri, 13 Feb 2026 23:27:28 +0900 Subject: [PATCH 14/39] =?UTF-8?q?feat:=20=EB=AC=B8=EC=84=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../\354\243\274\353\254\270/02-sequence-diagrams.md" | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 "docs/design/\354\226\264\353\223\234\353\257\274/\354\243\274\353\254\270/02-sequence-diagrams.md" diff --git "a/docs/design/\354\226\264\353\223\234\353\257\274/\354\243\274\353\254\270/02-sequence-diagrams.md" "b/docs/design/\354\226\264\353\223\234\353\257\274/\354\243\274\353\254\270/02-sequence-diagrams.md" deleted file mode 100644 index e69de29bb..000000000 From b9cf952fb47709fa6f60688165b81940ad945be4 Mon Sep 17 00:00:00 2001 From: katiekim17 Date: Fri, 20 Feb 2026 01:33:39 +0900 Subject: [PATCH 15/39] =?UTF-8?q?fix:=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EC=84=A4=EA=B3=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../\354\242\213\354\225\204\354\232\224/01-requirements.md" | 1 + 1 file changed, 1 insertion(+) diff --git "a/docs/design/\354\242\213\354\225\204\354\232\224/01-requirements.md" "b/docs/design/\354\242\213\354\225\204\354\232\224/01-requirements.md" index a59190163..979eb89f5 100644 --- "a/docs/design/\354\242\213\354\225\204\354\232\224/01-requirements.md" +++ "b/docs/design/\354\242\213\354\225\204\354\232\224/01-requirements.md" @@ -24,6 +24,7 @@ Authorization: Required - 좋아요 등록은 동기 처리 (즉시 응답) - 카운트 업데이트는 비동기 처리 (Eventual Consistency) - 이벤트 실패 시 재시도 없음, 배치로 정합성 복구 +- 좋아요 카운트전략은 "비정규화"로 만들어보기 **에러 처리:** - 이미 좋아요한 상품 → 409 Conflict From b327e80ae6b4c2f4401eec8612b96ad3ab7fe013 Mon Sep 17 00:00:00 2001 From: katiekim17 Date: Fri, 20 Feb 2026 15:31:45 +0900 Subject: [PATCH 16/39] =?UTF-8?q?feature:=20=ED=8C=A8=ED=82=A4=EC=A7=80=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=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 --- .../java/com/loopers/application/brand/BrandService.java | 6 ++++++ .../java/com/loopers/application/product/OrderFacade.java | 5 +++++ .../com/loopers/application/product/ProductFacade.java | 7 +++++++ .../src/main/java/com/loopers/domain/brand/Brand.java | 5 +++++ .../java/com/loopers/domain/brand/BrandRepository.java | 5 +++++ .../src/main/java/com/loopers/domain/common/Money.java | 5 +++++ .../src/main/java/com/loopers/domain/common/Quantity.java | 5 +++++ .../src/main/java/com/loopers/domain/like/Like.java | 5 +++++ .../main/java/com/loopers/domain/like/LikeRepository.java | 5 +++++ .../src/main/java/com/loopers/domain/like/LikeService.java | 5 +++++ .../src/main/java/com/loopers/domain/order/Order.java | 5 +++++ .../src/main/java/com/loopers/domain/order/OrderItem.java | 5 +++++ .../java/com/loopers/domain/order/OrderItemStatus.java | 4 ++++ .../java/com/loopers/domain/order/OrderRepository.java | 5 +++++ .../main/java/com/loopers/domain/order/OrderService.java | 5 +++++ .../main/java/com/loopers/domain/order/OrderStatus.java | 4 ++++ .../com/loopers/domain/order/StockDeductionService.java | 5 +++++ .../src/main/java/com/loopers/domain/product/Product.java | 5 +++++ .../main/java/com/loopers/domain/product/ProductImage.java | 5 +++++ .../com/loopers/domain/product/ProductImageRepository.java | 5 +++++ .../com/loopers/domain/product/ProductImageService.java | 5 +++++ .../java/com/loopers/domain/product/ProductOption.java | 5 +++++ .../loopers/domain/product/ProductOptionRepository.java | 5 +++++ .../com/loopers/domain/product/ProductOptionService.java | 5 +++++ .../com/loopers/domain/product/ProductOptionStatus.java | 4 ++++ .../java/com/loopers/domain/product/ProductRepository.java | 5 +++++ .../java/com/loopers/domain/product/ProductService.java | 5 +++++ .../java/com/loopers/domain/product/ProductSortType.java | 4 ++++ .../java/com/loopers/domain/product/ProductStatus.java | 5 +++++ .../java/com/loopers/infrastructure/brand/BrandEntity.java | 5 +++++ .../loopers/infrastructure/brand/BrandRepositoryImpl.java | 5 +++++ .../java/com/loopers/infrastructure/like/LikeEntity.java | 5 +++++ .../loopers/infrastructure/like/LikeRepositoryImpl.java | 5 +++++ .../com/loopers/infrastructure/member/MemberEntity.java | 5 +++++ .../java/com/loopers/infrastructure/order/OrderEntity.java | 5 +++++ .../com/loopers/infrastructure/order/OrderItemEntity.java | 5 +++++ .../loopers/infrastructure/order/OrderRepositoryImpl.java | 5 +++++ .../com/loopers/infrastructure/product/ProductEntity.java | 5 +++++ .../loopers/infrastructure/product/ProductImageEntity.java | 5 +++++ .../infrastructure/product/ProductImageRepositoryImpl.java | 5 +++++ .../infrastructure/product/ProductOptionEntity.java | 5 +++++ .../product/ProductOptionRepositoryImpl.java | 5 +++++ .../infrastructure/product/ProductRepositoryImpl.java | 5 +++++ .../com/loopers/interfaces/api/brand/BrandController.java | 5 +++++ .../com/loopers/interfaces/api/like/LikeController.java | 5 +++++ .../com/loopers/interfaces/api/order/OrderController.java | 5 +++++ .../loopers/interfaces/api/product/ProductController.java | 5 +++++ 47 files changed, 234 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/OrderFacade.java 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/domain/brand/Brand.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/common/Money.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/common/Quantity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java 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/domain/order/Order.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemStatus.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/domain/order/OrderStatus.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/StockDeductionService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductImage.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductImageRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductImageService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOption.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOptionRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOptionService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOptionStatus.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/domain/product/ProductSortType.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductStatus.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductImageEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductImageRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductOptionEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductOptionRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeController.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java new file mode 100644 index 000000000..39aecfc59 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java @@ -0,0 +1,6 @@ +package com.loopers.application.brand; + +public class BrandService { + + // Facade 불필요 (단일 도메인) +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/OrderFacade.java new file mode 100644 index 000000000..9b78fdd29 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/OrderFacade.java @@ -0,0 +1,5 @@ +package com.loopers.application.product; + +public class OrderFacade { + +} 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..9e0dd634a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -0,0 +1,7 @@ +package com.loopers.application.product; + +public class ProductFacade { + + + // Product + Option + Image + Like 조율 +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java new file mode 100644 index 000000000..915d3f17d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java @@ -0,0 +1,5 @@ +package com.loopers.domain.brand; + +public class Brand { + +} 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..fa8fa450b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -0,0 +1,5 @@ +package com.loopers.domain.brand; + +public interface BrandRepository { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java b/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java new file mode 100644 index 000000000..a2b4e4471 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java @@ -0,0 +1,5 @@ +package com.loopers.domain.common; + +public class Money { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/common/Quantity.java b/apps/commerce-api/src/main/java/com/loopers/domain/common/Quantity.java new file mode 100644 index 000000000..8217fbfa1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/common/Quantity.java @@ -0,0 +1,5 @@ +package com.loopers.domain.common; + +public class Quantity { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java new file mode 100644 index 000000000..8a2f7bb79 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java @@ -0,0 +1,5 @@ +package com.loopers.domain.like; + +public class Like { + +} 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..0f90d773c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java @@ -0,0 +1,5 @@ +package com.loopers.domain.like; + +public class LikeRepository { + +} 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..cf751059d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java @@ -0,0 +1,5 @@ +package com.loopers.domain.like; + +public class LikeService { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java new file mode 100644 index 000000000..6bf284871 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -0,0 +1,5 @@ +package com.loopers.domain.order; + +public class Order { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java new file mode 100644 index 000000000..f5aa60ffe --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java @@ -0,0 +1,5 @@ +package com.loopers.domain.order; + +public class OrderItem { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemStatus.java new file mode 100644 index 000000000..0568d6824 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemStatus.java @@ -0,0 +1,4 @@ +package com.loopers.domain.order; + +public enum OrderItemStatus { +} 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..c03f5a908 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -0,0 +1,5 @@ +package com.loopers.domain.order; + +public class OrderRepository { + +} 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..d7659b340 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -0,0 +1,5 @@ +package com.loopers.domain.order; + +public class OrderService { + +} 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..43192dc7d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java @@ -0,0 +1,4 @@ +package com.loopers.domain.order; + +public enum OrderStatus { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/StockDeductionService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/StockDeductionService.java new file mode 100644 index 000000000..65b343354 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/StockDeductionService.java @@ -0,0 +1,5 @@ +package com.loopers.domain.order; + +public class StockDeductionService { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java new file mode 100644 index 000000000..89c19bb58 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -0,0 +1,5 @@ +package com.loopers.domain.product; + +public class Product { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductImage.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductImage.java new file mode 100644 index 000000000..d3cf3552d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductImage.java @@ -0,0 +1,5 @@ +package com.loopers.domain.product; + +public class ProductImage { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductImageRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductImageRepository.java new file mode 100644 index 000000000..21d8c2ea8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductImageRepository.java @@ -0,0 +1,5 @@ +package com.loopers.domain.product; + +public class ProductImageRepository { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductImageService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductImageService.java new file mode 100644 index 000000000..03327f421 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductImageService.java @@ -0,0 +1,5 @@ +package com.loopers.domain.product; + +public class ProductImageService { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOption.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOption.java new file mode 100644 index 000000000..0bed739cf --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOption.java @@ -0,0 +1,5 @@ +package com.loopers.domain.product; + +public class ProductOption { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOptionRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOptionRepository.java new file mode 100644 index 000000000..195c068b7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOptionRepository.java @@ -0,0 +1,5 @@ +package com.loopers.domain.product; + +public class ProductOptionRepository { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOptionService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOptionService.java new file mode 100644 index 000000000..ed26fe1ae --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOptionService.java @@ -0,0 +1,5 @@ +package com.loopers.domain.product; + +public class ProductOptionService { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOptionStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOptionStatus.java new file mode 100644 index 000000000..e031b7f25 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOptionStatus.java @@ -0,0 +1,4 @@ +package com.loopers.domain.product; + +public enum ProductOptionStatus { +} 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..79ec6c73c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -0,0 +1,5 @@ +package com.loopers.domain.product; + +public class ProductRepository { + +} 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..11284f650 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -0,0 +1,5 @@ +package com.loopers.domain.product; + +public class ProductService { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java new file mode 100644 index 000000000..d23c5b54e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java @@ -0,0 +1,4 @@ +package com.loopers.domain.product; + +public enum ProductSortType { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductStatus.java new file mode 100644 index 000000000..408873b90 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductStatus.java @@ -0,0 +1,5 @@ +package com.loopers.domain.product; + +public enum ProductStatus { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandEntity.java new file mode 100644 index 000000000..b79633333 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandEntity.java @@ -0,0 +1,5 @@ +package com.loopers.infrastructure.brand; + +public class BrandEntity { + +} 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..e2bddb17d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -0,0 +1,5 @@ +package com.loopers.infrastructure.brand; + +public class BrandRepositoryImpl { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEntity.java new file mode 100644 index 000000000..a64b8d292 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEntity.java @@ -0,0 +1,5 @@ +package com.loopers.infrastructure.like; + +public class LikeEntity { + +} 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..fa042b4d6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -0,0 +1,5 @@ +package com.loopers.infrastructure.like; + +public class LikeRepositoryImpl { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberEntity.java new file mode 100644 index 000000000..2f67f4d88 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberEntity.java @@ -0,0 +1,5 @@ +package com.loopers.infrastructure.member; + +public class MemberEntity { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEntity.java new file mode 100644 index 000000000..e37103368 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEntity.java @@ -0,0 +1,5 @@ +package com.loopers.infrastructure.order; + +public class OrderEntity { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemEntity.java new file mode 100644 index 000000000..b4dc1348a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemEntity.java @@ -0,0 +1,5 @@ +package com.loopers.infrastructure.order; + +public class OrderItemEntity { + +} 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..3d2730329 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -0,0 +1,5 @@ +package com.loopers.infrastructure.order; + +public class OrderRepositoryImpl { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java new file mode 100644 index 000000000..1d3fe4dbe --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java @@ -0,0 +1,5 @@ +package com.loopers.infrastructure.product; + +public class ProductEntity { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductImageEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductImageEntity.java new file mode 100644 index 000000000..49c91b6ec --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductImageEntity.java @@ -0,0 +1,5 @@ +package com.loopers.infrastructure.product; + +public class ProductImageEntity { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductImageRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductImageRepositoryImpl.java new file mode 100644 index 000000000..e91190410 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductImageRepositoryImpl.java @@ -0,0 +1,5 @@ +package com.loopers.infrastructure.product; + +public class ProductImageRepositoryImpl { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductOptionEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductOptionEntity.java new file mode 100644 index 000000000..cfed0518b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductOptionEntity.java @@ -0,0 +1,5 @@ +package com.loopers.infrastructure.product; + +public class ProductOptionEntity { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductOptionRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductOptionRepositoryImpl.java new file mode 100644 index 000000000..d5db2ab2b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductOptionRepositoryImpl.java @@ -0,0 +1,5 @@ +package com.loopers.infrastructure.product; + +public class ProductOptionRepositoryImpl { + +} 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..3a619a660 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -0,0 +1,5 @@ +package com.loopers.infrastructure.product; + +public class ProductRepositoryImpl { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java new file mode 100644 index 000000000..b34ee6fae --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java @@ -0,0 +1,5 @@ +package com.loopers.interfaces.api.brand; + +public class BrandController { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeController.java new file mode 100644 index 000000000..cc671299d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeController.java @@ -0,0 +1,5 @@ +package com.loopers.interfaces.api.like; + +public class LikeController { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java new file mode 100644 index 000000000..8d56b0b9b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java @@ -0,0 +1,5 @@ +package com.loopers.interfaces.api.order; + +public class OrderController { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java new file mode 100644 index 000000000..6b707ee3f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java @@ -0,0 +1,5 @@ +package com.loopers.interfaces.api.product; + +public class ProductController { + +} From ac283f6ddeb70f17d75a7308ec0160d52fb26d05 Mon Sep 17 00:00:00 2001 From: katiekim17 Date: Fri, 20 Feb 2026 15:38:10 +0900 Subject: [PATCH 17/39] =?UTF-8?q?fix:=20=EB=AC=B8=EC=84=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 --- docs/design/03-class-diagram.md | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/docs/design/03-class-diagram.md b/docs/design/03-class-diagram.md index 80d6ef41e..35c174d5a 100644 --- a/docs/design/03-class-diagram.md +++ b/docs/design/03-class-diagram.md @@ -18,7 +18,7 @@ │ Domain Layer │ │ (Service, Entity) │ └─────────────────────────────────────────┘ - ↓ +  ↑ ┌─────────────────────────────────────────┐ │ Infrastructure Layer │ │ (Repository) │ @@ -188,6 +188,18 @@ classDiagram -LocalDateTime createdAt %% Unique(userId, productId) } + + class Money { + -long value + +Money(value: long) + %% value >= 0 (음수 방지) + } + + class Quantity { + -int value + +Quantity(value: int) + %% 주문 수량: value >= 1 / 재고 수량: value >= 0 + } %% ============================================ %% Infrastructure Layer (Repositories) @@ -292,6 +304,11 @@ classDiagram ProductService ..> ProductRepository : uses ProductOptionService ..> ProductOptionRepository : uses ProductImageService ..> ProductImageRepository : uses + ProductOption ..> Money : price + ProductOption ..> Quantity : stockQuantity + OrderItem ..> Money : optionPrice + OrderItem ..> Quantity : quantity + Order ..> Money : totalAmount LikeService ..> LikeRepository : uses LikeService ..> ProductService : validates ACTIVE LikeService ..> EventPublisher : publishes @@ -492,6 +509,18 @@ classDiagram --- +#### Money (VO) +- value: long +- 생성 규칙: value >= 0 (음수 방지) +- 사용처: ProductOption.price, Order.totalAmount, OrderItem.optionPrice + +#### Quantity (VO) +- value: int +- 생성 규칙: 주문 수량 value >= 1 / 재고 수량 value >= 0 +- 사용처: ProductOption.stockQuantity, OrderItem.quantity + +--- + ## 4️⃣ 예외 계층 구조 ```mermaid From 4e160208684955e8352330e2078ce3a0d110cea9 Mon Sep 17 00:00:00 2001 From: katiekim17 Date: Sun, 22 Feb 2026 18:07:13 +0900 Subject: [PATCH 18/39] =?UTF-8?q?fix:=20Member=20->=20User=EB=A1=9C=20=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=ED=9B=84=20VO=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/member/MemberFacade.java | 49 ---- .../application/member/MemberInfo.java | 15 -- .../loopers/application/users/UserFacade.java | 33 +++ .../loopers/application/users/UserInfo.java | 15 ++ .../java/com/loopers/domain/common/Money.java | 18 +- .../loopers/domain/member/MemberModel.java | 148 ------------ .../domain/member/MemberRepository.java | 10 - .../loopers/domain/member/MemberService.java | 82 ------- .../java/com/loopers/domain/order/Order.java | 8 + .../com/loopers/domain/product/Product.java | 8 + .../loopers/domain/users/UserRepository.java | 9 + .../com/loopers/domain/users/UserService.java | 84 +++++++ .../java/com/loopers/domain/users/Users.java | 102 ++++++++ .../com/loopers/domain/users/vo/Email.java | 47 ++++ .../domain/users/vo/EncryptedPassword.java | 47 ++++ .../com/loopers/domain/users/vo/LoginId.java | 55 +++++ .../loopers/domain/users/vo/RawPassword.java | 33 +++ .../infrastructure/member/MemberEntity.java | 5 - .../member/MemberJpaRepository.java | 11 - .../member/MemberRepositoryImpl.java | 29 --- .../infrastructure/users/UserEntity.java | 5 + .../users/UserJpaRepository.java | 12 + .../users/UserRepositoryImpl.java | 29 +++ .../interfaces/api/admin/AdminController.java | 24 ++ .../interfaces/api/admin/AdminV1ApiSpec.java | 5 + .../interfaces/api/brand/BrandController.java | 12 +- .../interfaces/api/brand/BrandV1ApiSpec.java | 5 + .../interfaces/api/like/LikeController.java | 5 - .../api/member/MemberV1Controller.java | 63 ----- .../interfaces/api/order/OrderController.java | 39 +++- .../interfaces/api/order/OrderV1ApiSpec.java | 22 ++ .../api/product/ProductsController.java | 17 ++ ...Controller.java => ProductsV1ApiSpec.java} | 2 +- .../UserV1ApiSpec.java} | 21 +- .../api/users/UserV1Controller.java | 74 ++++++ .../MemberV1Dto.java => users/UserV1Dto.java} | 10 +- .../domain/member/MemberModelTest.java | 220 ------------------ .../member/MemberServiceIntegrationTest.java | 188 --------------- .../users/UsersServiceIntegrationTest.java | 175 ++++++++++++++ .../com/loopers/domain/users/UsersTest.java | 116 +++++++++ .../loopers/domain/users/vo/EmailTest.java | 64 +++++ .../loopers/domain/users/vo/LoginIdTest.java | 64 +++++ .../domain/users/vo/RawPasswordTest.java | 88 +++++++ ...ApiE2ETest.java => UsersV1ApiE2ETest.java} | 6 +- docs/design/04-erd.md | 2 +- 45 files changed, 1232 insertions(+), 844 deletions(-) delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/users/UserFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/users/UserInfo.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/users/UserRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/users/UserService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/users/Users.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/users/vo/Email.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/users/vo/EncryptedPassword.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/users/vo/LoginId.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/users/vo/RawPassword.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberEntity.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/users/UserEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/users/UserJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/users/UserRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminController.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1ApiSpec.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeController.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.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/product/ProductsController.java rename apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/{ProductController.java => ProductsV1ApiSpec.java} (56%) rename apps/commerce-api/src/main/java/com/loopers/interfaces/api/{member/MemberV1ApiSpec.java => users/UserV1ApiSpec.java} (68%) create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/users/UserV1Controller.java rename apps/commerce-api/src/main/java/com/loopers/interfaces/api/{member/MemberV1Dto.java => users/UserV1Dto.java} (78%) delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/users/UsersServiceIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/users/UsersTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/users/vo/EmailTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/users/vo/LoginIdTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/users/vo/RawPasswordTest.java rename apps/commerce-api/src/test/java/com/loopers/interfaces/api/{MemberV1ApiE2ETest.java => UsersV1ApiE2ETest.java} (99%) 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 deleted file mode 100644 index 781bacce6..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.loopers.application.member; - -import com.loopers.domain.member.MemberModel; -import com.loopers.domain.member.MemberService; -import com.loopers.interfaces.api.member.MemberV1Dto; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -@RequiredArgsConstructor -@Component -public class MemberFacade { - - private final MemberService memberService; - - public MemberInfo signupMember(MemberV1Dto.SignUpRequest request) { - // 1. Request → MemberModel로 변환 - MemberModel memberModel = new MemberModel( - request.loginId(), - request.password(), - request.name(), - request.birthDate(), - request.email() - ); - - // 2. Service 호출 (저장 + 중복 체크) - MemberModel saved = memberService.saveMember(memberModel); - - // 3. MemberModel → MemberInfo로 변환해서 반환 - return MemberInfo.from(saved); - } - - - public MemberInfo getMyInfo(String loginId, String password) { - MemberModel member = memberService.authenticate(loginId, password); - return MemberInfo.from(member); - } - - - public void changePassword(String loginId, String password, String prevPassword, String newPassword) { - // 헤더 인증 - memberService.authenticate(loginId, password); - - MemberModel memberModel = new MemberModel(loginId, prevPassword); // raw prevPassword - - // Service 호출 - memberService.changePassword(memberModel, 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 deleted file mode 100644 index 38b998900..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.loopers.application.member; - -import com.loopers.domain.member.MemberModel; - -// MemberInfo는 Facade → Controller로 전달되는 데이터 -public record MemberInfo(String loginId, String name, String birthDate, String email) { - public static MemberInfo from(MemberModel model) { - return new MemberInfo( - model.getLoginId(), - model.getMaskedName(), - model.getBirthDate(), - model.getEmail() - ); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/users/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/users/UserFacade.java new file mode 100644 index 000000000..ef4c5c6e9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/users/UserFacade.java @@ -0,0 +1,33 @@ +package com.loopers.application.users; + +import com.loopers.domain.users.UserService; +import com.loopers.domain.users.Users; +import com.loopers.interfaces.api.users.UserV1Dto; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +// Facade는 “조합/변환”만 한다는 원칙에 맞게! +@RequiredArgsConstructor +@Component +public class UserFacade { + + private final UserService userService; + + public UserInfo signupUser(UserV1Dto.SignUpRequest request) { + Users saved = userService.register( + request.loginId(), request.password(), + request.name(), request.birthDate(), request.email() + ); + return UserInfo.from(saved); + } + + public UserInfo getMyInfo(String loginId, String password) { + Users users = userService.authenticate(loginId, password); + return UserInfo.from(users); + } + + public void changePassword(String loginId, String password, String prevPassword, String newPassword) { + userService.authenticate(loginId, password); + userService.changePassword(loginId, prevPassword, newPassword); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/application/users/UserInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/users/UserInfo.java new file mode 100644 index 000000000..0d3f29b9b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/users/UserInfo.java @@ -0,0 +1,15 @@ +package com.loopers.application.users; + +import com.loopers.domain.users.Users; + +// MemberInfo는 Facade → Controller로 전달되는 데이터 +public record UserInfo(String loginId, String name, String birthDate, String email) { + public static UserInfo from(Users model) { + return new UserInfo( + model.getLoginId(), + model.getMaskedName(), + model.getBirthDate(), + model.getEmail() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java b/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java index a2b4e4471..bf6a7d7f7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java @@ -1,5 +1,21 @@ package com.loopers.domain.common; -public class Money { +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +@Embeddable +public final class Money { + @Column(name = "value", nullable = false) // 엔티티별 컬럼명은 @AttributeOverrides로 재정의 + private Long value; + + private Money() {} + + public Money(Long value) { + if (value == null || value < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "금액은 0 이상이어야 합니다."); + } + this.value = value; + } } 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 deleted file mode 100644 index 58e72de9b..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java +++ /dev/null @@ -1,148 +0,0 @@ -package com.loopers.domain.member; - -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 = "member") -public class MemberModel extends BaseEntity { - - private String loginId; - private String password; - private String name; - private String birthDate; - private String email; - - protected MemberModel() { - } - - public MemberModel(String loginId, String password, String name, String birthDate, String email) { - - // 모든 항목은 비어 있을 수 없다 - if (loginId == null || loginId.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "아이디는 비어있을 수 없습니다."); - } - if (password == null || password.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 비어있을 수 없습니다."); - } - if (name == null || name.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "이름은 비어있을 수 없습니다."); - } - if (birthDate == null || birthDate.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 비어있을 수 없습니다."); - } - if (email == null || email.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "이메일은 비어있을 수 없습니다."); - } - - // 가입된 아이디로는 가입이 불가능하다 -> 디비에서 검증. 서비스에서 하기 - // 가입하는 로그인 ID는 영문과 숫자만 허용한다 - validateLoginId(loginId); - - // 비밀번호 8~16자의 영문 대소문자, 숫자, 특수문자만 가능합니다. - // 비밀번호 생년월일은 비밀번호 내에 포함될 수 없습니다. - // 비밀번호 규칙 검증 - validatePassword(password, birthDate); - - this.loginId = loginId; - this.password = password; - this.name = name; - this.birthDate = birthDate; - this.email = email; - } - - public MemberModel(String loginId) { - // 모든 항목은 비어 있을 수 없다 - if (loginId == null || loginId.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "아이디는 비어있을 수 없습니다."); - } - - // 가입하는 로그인 ID는 영문과 숫자만 허용한다 - validateLoginId(loginId); - this.loginId = loginId; - } - - public MemberModel(String loginId, String prevPassword) { - this.loginId = loginId; - this.password = prevPassword; - } - - public String getLoginId() { - return loginId; - } - - public String getPassword() { - return password; - } - - public String getName() { - return name; - } - - public String getBirthDate() { - return birthDate; - } - - public String getEmail() { - return email; - } - - private void validateLoginId(String loginId) { - // 로그인 ID 는 영문과 숫자만 허용 - if (!loginId.matches("^[a-zA-Z0-9]+$")) { - throw new CoreException(ErrorType.BAD_REQUEST, "로그인 아이디는 영문자와 숫자만 사용할 수 있습니다."); - } - } - - private void validatePassword(String password, String birthDate) { - // 1. 8~16자 길이 체크 - if (password.length() < 8 || password.length() > 16) { - throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 8~16자여야 합니다."); - } - - // 2. 영문 대소문자, 숫자, 특수문자만 허용 (한글, 공백 등 불가) - if (!password.matches("^[a-zA-Z0-9!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?]+$")) { - throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 영문 대소문자, 숫자, 특수문자만 가능합니다."); - } - - // 3. 생년월일이 비밀번호에 포함되면 안됨 - if (password.contains(birthDate)) { - throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호에 생년월일을 포함할 수 없습니다."); - } - } - - // 암호화된 비밀번호를 엔티티에 넣어주기 - public void encryptPassword(String encryptedPassword) { - this.password = encryptedPassword; - } - - // 이름 마지막 글자에 마스킹 추가 - public String maskLastChar(String name) { - if (name == null || name.isBlank()) { - throw new IllegalArgumentException("이름은 비어 있을 수 없습니다."); - } - - if (name.length() == 1) { - return "*"; - } - - return name.substring(0, name.length() - 1) + "*"; - } - - // 마스킹된 이름 가져오기 - public String getMaskedName() { - return maskLastChar(this.name); - } - - - // 비밀번호 변경하기 - public void changePassword(String newPassword, String birthDate) { - validatePassword(newPassword, birthDate); - this.password = newPassword; - } - - -} 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 deleted file mode 100644 index ca1e3cd86..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.loopers.domain.member; - -import java.util.Optional; - -public interface MemberRepository { - MemberModel save(MemberModel memberModel); - Optional findByLoginId(String id); - Optional update(MemberModel memberModel); - -} 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 deleted file mode 100644 index 0ab66665b..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java +++ /dev/null @@ -1,82 +0,0 @@ -package com.loopers.domain.member; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -@RequiredArgsConstructor -@Component -public class MemberService { - - private final MemberRepository memberRepository; - private final PasswordEncoder passwordEncoder; - - @Transactional(readOnly = false) - public MemberModel saveMember(MemberModel memberModel) { - //저장하기 전에 이미 같은 loginId가 있는지 확인 - Optional existing = memberRepository.findByLoginId(memberModel.getLoginId()); - if (existing.isPresent()) { - throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 아이디입니다."); - } - - // 비밀번호 암호화 후 저장 - String encrypted = passwordEncoder.encode(memberModel.getPassword()); - memberModel.encryptPassword(encrypted); - - try { - return memberRepository.save(memberModel); - } catch (DataIntegrityViolationException e) { - throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 아이디입니다."); - } - } - - public MemberModel authenticate(String loginId, String password) { - // 1. 회원 조회 - MemberModel member = getMember(loginId); // 없으면 NOT_FOUND - - // 2. 비밀번호 일치 여부 확인 - if (!passwordEncoder.matches(password, member.getPassword())) { - // 3. 불일치 시 UNAUTHORIZED 예외 - throw new CoreException(ErrorType.UNAUTHORIZED, "인증 실패"); - } - - return member; - } - - @Transactional(readOnly = true) - public MemberModel getMember(String loginId) { - MemberModel model = new MemberModel(loginId); // 객체 먼저 생성해야 함 - return memberRepository.findByLoginId(model.getLoginId()) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + loginId + "] 회원을 찾을 수 없습니다.")); - } - - @Transactional(readOnly = false) - public void changePassword(MemberModel memberModel, String newPassword) { - - // 기존 회원 정보 조회 - MemberModel member = getMember(memberModel.getLoginId()); - - // 암호화된 DB 비밀번호와 입력한 기존 비밀번호 비교 - if (!passwordEncoder.matches(memberModel.getPassword(), member.getPassword())) { - throw new CoreException(ErrorType.UNAUTHORIZED, "기존 비밀번호가 일치하지 않습니다."); - } - - // 암호화된 DB 기존 비밀번호와 입력한 새로운 비밀번호 비교 - if (passwordEncoder.matches(newPassword, member.getPassword())) { - throw new CoreException(ErrorType.BAD_REQUEST, "새 비밀번호는 기존 비밀번호와 달라야 합니다."); - } - - // 새 비밀번호 규칙 검증 + 암호화 + 저장 (Dirty Checking) - member.changePassword(newPassword, member.getBirthDate()); - - // 암호화 후 저장 (Dirty Checking) - String encryptedPassword = passwordEncoder.encode(newPassword); - member.encryptPassword(encryptedPassword); - - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java index 6bf284871..62b2aa794 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -1,5 +1,13 @@ package com.loopers.domain.order; +import com.loopers.domain.common.Money; +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; + public class Order { + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "total_amount")) + private Money totalAmount; } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java index 89c19bb58..b58d135b6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -1,5 +1,13 @@ package com.loopers.domain.product; +import com.loopers.domain.common.Money; +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; + public class Product { + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "price")) + private Money price; } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/users/UserRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/users/UserRepository.java new file mode 100644 index 000000000..67fd35833 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/users/UserRepository.java @@ -0,0 +1,9 @@ +package com.loopers.domain.users; + +import java.util.Optional; + +public interface UserRepository { + Users save(Users users); + Optional findByLoginId(String loginId); + boolean existsByLoginId(String loginId); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/users/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/users/UserService.java new file mode 100644 index 000000000..5ac241cab --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/users/UserService.java @@ -0,0 +1,84 @@ +package com.loopers.domain.users; + +import com.loopers.domain.users.vo.Email; +import com.loopers.domain.users.vo.EncryptedPassword; +import com.loopers.domain.users.vo.LoginId; +import com.loopers.domain.users.vo.RawPassword; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + + +// 유즈케이스 흐름을 책임지도록!, Facade에서 엔티티를 만들지 않게 하려면, 서비스가 입력값을 받아서 엔티티를 생성 +@RequiredArgsConstructor +@Component +public class UserService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional + public Users register(String loginId, String rawPassword, String name, String birthDate, String email) { + + // 1) 중복 체크 (최종 방어는 DB unique constraint) + if (userRepository.existsByLoginId(loginId)) { + throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 아이디입니다."); + } + + // 2) VO 생성 + 검증 (Service가 담당) + LoginId lid = LoginId.of(loginId); + Email em = Email.of(email); + RawPassword rp = RawPassword.of(rawPassword, birthDate); + EncryptedPassword ep = EncryptedPassword.of(passwordEncoder.encode(rp.value())); + + // 3) 엔티티 생성 — 이미 준비된 VO만 전달 + Users users = Users.create(lid, ep, name, birthDate, em); + + // 4) 저장 + 최종 중복 방어 + try { + return userRepository.save(users); + } catch (DataIntegrityViolationException e) { + throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 아이디입니다."); + } + } + + @Transactional(readOnly = true) + public Users authenticate(String loginId, String password) { + Users users = getMember(loginId); + + if (!passwordEncoder.matches(password, users.getPassword())) { + throw new CoreException(ErrorType.UNAUTHORIZED, "인증 실패"); + } + + return users; + } + + @Transactional(readOnly = true) + public Users getMember(String loginId) { + return userRepository.findByLoginId(loginId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + "[id = " + loginId + "] 회원을 찾을 수 없습니다.")); + } + + @Transactional + public void changePassword(String loginId, String prevPassword, String newPassword) { + Users users = getMember(loginId); + + // 입력한 기존 비밀번호 vs DB 암호화 값 비교 + if (!passwordEncoder.matches(prevPassword, users.getPassword())) { + throw new CoreException(ErrorType.UNAUTHORIZED, "기존 비밀번호가 일치하지 않습니다."); + } + + // 새 비밀번호 = 기존 비밀번호 중복 방지 + if (passwordEncoder.matches(newPassword, users.getPassword())) { + throw new CoreException(ErrorType.BAD_REQUEST, "새 비밀번호는 기존 비밀번호와 달라야 합니다."); + } + + // VO 검증 + 암호화 + Dirty Checking으로 자동 저장 + users.changePassword(newPassword, raw -> passwordEncoder.encode(raw)); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/users/Users.java b/apps/commerce-api/src/main/java/com/loopers/domain/users/Users.java new file mode 100644 index 000000000..6a43d8587 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/users/Users.java @@ -0,0 +1,102 @@ +package com.loopers.domain.users; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.users.vo.Email; +import com.loopers.domain.users.vo.EncryptedPassword; +import com.loopers.domain.users.vo.LoginId; +import com.loopers.domain.users.vo.RawPassword; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.util.function.Function; + +@Entity +@Table( + name = "users", + uniqueConstraints = { + @UniqueConstraint(name = "uk_users_login_id", columnNames = "login_id"), + @UniqueConstraint(name = "uk_users_email", columnNames = "email") + } +) +public class Users extends BaseEntity { + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "login_id", nullable = false, length = 50)) + private LoginId loginId; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "password", nullable = false, length = 255)) + private EncryptedPassword password; + + @Column(name = "name", nullable = false, length = 50) + private String name; + + @Column(name = "birth_date", nullable = false, length = 8) + private String birthDate; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "email", nullable = false, length = 255)) + private Email email; + + protected Users() {} + + private Users(LoginId loginId, EncryptedPassword password, String name, String birthDate, Email email) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이름은 비어있을 수 없습니다."); + } + if (birthDate == null || birthDate.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 비어있을 수 없습니다."); + } + this.loginId = loginId; + this.password = password; + this.name = name; + this.birthDate = birthDate; + this.email = email; + } + + /** + * 회원가입 팩토리 메서드 + * - 이미 검증·암호화된 VO를 받아서 엔티티를 생성 + * - 검증(RawPassword)과 암호화는 Service에서 담당 + */ + public static Users create(LoginId loginId, EncryptedPassword password, String name, String birthDate, Email email) { + return new Users(loginId, password, name, birthDate, email); + } + + public void changePassword(String newRawPassword, Function encoder) { + RawPassword rp = RawPassword.of(newRawPassword, this.birthDate); + this.password = EncryptedPassword.of(encoder.apply(rp.value())); + } + + public String getLoginId() { + return loginId.value(); + } + + public String getPassword() { + return password.value(); + } + + public String getName() { + return name; + } + + public String getBirthDate() { + return birthDate; + } + + public String getEmail() { + return email.value(); + } + + public String getMaskedName() { + if (name.length() == 1) { + return "*"; + } + return name.substring(0, name.length() - 1) + "*"; + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/users/vo/Email.java b/apps/commerce-api/src/main/java/com/loopers/domain/users/vo/Email.java new file mode 100644 index 000000000..6603fab48 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/users/vo/Email.java @@ -0,0 +1,47 @@ +package com.loopers.domain.users.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Embeddable; +import java.util.Objects; + +@Embeddable +public class Email { + + private String value; + + protected Email() {} + + public Email(String value) { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이메일은 비어있을 수 없습니다."); + } + // 필요시 더 강한 정규식/라이브러리로 교체 + if (!value.contains("@")) { + throw new CoreException(ErrorType.BAD_REQUEST, "이메일 형식이 올바르지 않습니다."); + } + this.value = value; + } + + public static Email of(String value) { + return new Email(value); + } + + public String value() { + return value; + } + + @Override public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Email)) return false; + return Objects.equals(value, ((Email) o).value); + } + + @Override public int hashCode() { + return Objects.hash(value); + } + + @Override public String toString() { + return value; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/users/vo/EncryptedPassword.java b/apps/commerce-api/src/main/java/com/loopers/domain/users/vo/EncryptedPassword.java new file mode 100644 index 000000000..c9791bd08 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/users/vo/EncryptedPassword.java @@ -0,0 +1,47 @@ +package com.loopers.domain.users.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Embeddable; +import java.util.Objects; + +// Password를 Raw/Encrypted로 분리하면 “평문이 엔티티/DB에 남는 실수”를 구조적으로 막을 수 있어요. +// @Embeddable을 쓰면 VO 내부 필드가 부모 테이블의 컬럼으로 펼쳐집니다. JPA가 객체를 재구성할 때 기본 생성자가 필요합니다. +@Embeddable +public class EncryptedPassword { + + private String value; + + protected EncryptedPassword() {} + + public EncryptedPassword(String value) { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 비어있을 수 없습니다."); + } + this.value = value; + } + + public static EncryptedPassword of(String value) { + return new EncryptedPassword(value); + } + + public String value() { + return value; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof EncryptedPassword)) { + return false; + } + return Objects.equals(value, ((EncryptedPassword) o).value); + } + + @Override + public int hashCode() { + return Objects.hash(value); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/users/vo/LoginId.java b/apps/commerce-api/src/main/java/com/loopers/domain/users/vo/LoginId.java new file mode 100644 index 000000000..ce223e1ff --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/users/vo/LoginId.java @@ -0,0 +1,55 @@ +package com.loopers.domain.users.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Embeddable; +import java.util.Objects; + +// VO는 도메인 규칙(형식/제약)을 “자기 책임”으로 갖게 되고, +@Embeddable +public class LoginId { + + private String value; + + protected LoginId() {} + + public LoginId(String value) { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "아이디는 비어있을 수 없습니다."); + } + if (!value.matches("^[a-zA-Z0-9]+$")) { + throw new CoreException(ErrorType.BAD_REQUEST, "로그인 아이디는 영문자와 숫자만 사용할 수 있습니다."); + } + this.value = value; + } + + public static LoginId of(String value) { + return new LoginId(value); + } + + public String value() { + return value; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof LoginId)) { + return false; + } + return Objects.equals(value, ((LoginId) o).value); + } + + @Override + public int hashCode() { + return Objects.hash(value); + } + + @Override + public String toString() { + return value; + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/users/vo/RawPassword.java b/apps/commerce-api/src/main/java/com/loopers/domain/users/vo/RawPassword.java new file mode 100644 index 000000000..f0db5c713 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/users/vo/RawPassword.java @@ -0,0 +1,33 @@ +package com.loopers.domain.users.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +public final class RawPassword { + + private final String value; + + private RawPassword(String value, String birthDate) { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 비어있을 수 없습니다."); + } + if (value.length() < 8 || value.length() > 16) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 8~16자여야 합니다."); + } + if (!value.matches("^[a-zA-Z0-9!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?]+$")) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 영문 대소문자, 숫자, 특수문자만 가능합니다."); + } + if (birthDate != null && !birthDate.isBlank() && value.contains(birthDate)) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호에 생년월일을 포함할 수 없습니다."); + } + this.value = value; + } + + public static RawPassword of(String value, String birthDate) { + return new RawPassword(value, birthDate); + } + + public String value() { + return value; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberEntity.java deleted file mode 100644 index 2f67f4d88..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberEntity.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.loopers.infrastructure.member; - -public class MemberEntity { - -} 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 deleted file mode 100644 index 0bcc2c78b..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.loopers.infrastructure.member; - -import com.loopers.domain.member.MemberModel; -import java.util.Optional; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface MemberJpaRepository extends JpaRepository { - - Optional findByLoginId(String loginId); - MemberModel save(MemberModel memberModel); -} 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 deleted file mode 100644 index 17e86c5de..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.loopers.infrastructure.member; - -import com.loopers.domain.member.MemberModel; -import com.loopers.domain.member.MemberRepository; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -@RequiredArgsConstructor -@Component -public class MemberRepositoryImpl implements MemberRepository { - - private final MemberJpaRepository memberJpaRepository; - - @Override - public MemberModel save(MemberModel memberModel) { - return memberJpaRepository.save(memberModel); - } - - @Override - public Optional update(MemberModel memberModel) { - return Optional.empty(); - } - - @Override - public Optional findByLoginId(String id) { - return memberJpaRepository.findByLoginId(id); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/users/UserEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/users/UserEntity.java new file mode 100644 index 000000000..37aec8699 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/users/UserEntity.java @@ -0,0 +1,5 @@ +package com.loopers.infrastructure.users; + +public class UserEntity { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/users/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/users/UserJpaRepository.java new file mode 100644 index 000000000..6db9f59b8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/users/UserJpaRepository.java @@ -0,0 +1,12 @@ +package com.loopers.infrastructure.users; + +import com.loopers.domain.users.Users; +import com.loopers.domain.users.vo.LoginId; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserJpaRepository extends JpaRepository { + + // loginId 필드는 @Embedded LoginId 타입 → "loginId.value" 경로로 조회 + Optional findByLoginIdValue(String value); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/users/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/users/UserRepositoryImpl.java new file mode 100644 index 000000000..1d18151b4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/users/UserRepositoryImpl.java @@ -0,0 +1,29 @@ +package com.loopers.infrastructure.users; + +import com.loopers.domain.users.UserRepository; +import com.loopers.domain.users.Users; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class UserRepositoryImpl implements UserRepository { + + private final UserJpaRepository userJpaRepository; + + @Override + public Users save(Users users) { + return userJpaRepository.save(users); + } + + @Override + public Optional findByLoginId(String loginId) { + return userJpaRepository.findByLoginIdValue(loginId); + } + + @Override + public boolean existsByLoginId(String loginId) { + return userJpaRepository.findByLoginIdValue(loginId).isPresent(); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminController.java new file mode 100644 index 000000000..2b977dde0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminController.java @@ -0,0 +1,24 @@ +package com.loopers.interfaces.api.admin; + +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1") +public class AdminController implements AdminV1ApiSpec { + // (GET) /api-admin/v1/brands?page=0&size=20 // 등록된 브랜드 목록 조회 + // (GET) /api-admin/v1/brands/{brandId} // 브랜드 상세 조회 + // (POST) /api-admin/v1/brands // 브랜드 등록 + // (PUT) /api-admin/v1/brands/{brandId} // 브랜드 정보 수정 + // (DELETE) /api-admin/v1/brands/{brandId} // 브랜드 삭제 + // (GET) /api-admin/v1/products?page=0&size=20&brandId={ brandId} // 등록된 상품 목록 조회 + // (GET) /api-admin/v1/products/{productId} // 상품 상세 조회 + // (POST) /api-admin/v1/products // 상품 등록 + // (PUT) /api-admin/v1/products/{productId} // 상품 정보 수정 + // (DELETE) /api-admin/v1/products/{productId} // 상품 삭제 + // (POST) /api-admin/v1/orders // 주문 요청 + // (GET) /api-admin/v1/orders?startAt=2026-01-31&endAt=2026-02-10 // 유저의 주문 목록 조회 + // (GET) /api-admin/v1/orders/{orderId} // 단일 주문 상세 조회 +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminV1ApiSpec.java new file mode 100644 index 000000000..f143e13a8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminV1ApiSpec.java @@ -0,0 +1,5 @@ +package com.loopers.interfaces.api.admin; + +public interface AdminV1ApiSpec { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java index b34ee6fae..3115a0339 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java @@ -1,5 +1,15 @@ package com.loopers.interfaces.api.brand; -public class BrandController { +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/brands") +public class BrandController implements BrandV1ApiSpec { + // /api/v1/brands/{brandId} // 브랜드 정보 조회 + // /api/v1/products // 상품 목록 조회 + // /api/v1/products/{productId} // 상품 정보 조회 } 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..bc6acfd88 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1ApiSpec.java @@ -0,0 +1,5 @@ +package com.loopers.interfaces.api.brand; + +public interface BrandV1ApiSpec { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeController.java deleted file mode 100644 index cc671299d..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeController.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.loopers.interfaces.api.like; - -public class LikeController { - -} 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 deleted file mode 100644 index 07bf218ef..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.loopers.interfaces.api.member; - -import com.loopers.application.member.MemberFacade; -import com.loopers.application.member.MemberInfo; -import com.loopers.interfaces.api.ApiResponse; -import com.loopers.interfaces.api.member.MemberV1Dto.ChangePasswordRequest; -import com.loopers.interfaces.api.member.MemberV1Dto.MemberInfoResponse; -import com.loopers.interfaces.api.member.MemberV1Dto.SignUpRequest; -import com.loopers.interfaces.api.member.MemberV1Dto.SignUpResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PatchMapping; -import org.springframework.web.bind.annotation.PostMapping; -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.ResponseStatus; -import org.springframework.web.bind.annotation.RestController; - -@RequiredArgsConstructor -@RestController -@RequestMapping("/api/v1/members") -public class MemberV1Controller implements MemberV1ApiSpec { - - // 클라이언트 → [SignUpRequest (record)] → Controller → Facade → Service → [MemberModel (entity)] → DB - // 요청 데이터 전달용 DB에 저장되는 객체 - // DB → [MemberModel (entity)] → Facade → [SignUpResponse (record)] → Controller → 클라이언트 - // DB에서 꺼낸 객체 응답 데이터 전달용 - private final MemberFacade memberFacade; - - @PostMapping - @ResponseStatus(HttpStatus.CREATED) - @Override - public ApiResponse signUp(@RequestBody SignUpRequest request) { - MemberInfo info = memberFacade.signupMember(request); - MemberV1Dto.SignUpResponse response = MemberV1Dto.SignUpResponse.from(info); - return ApiResponse.success(response); - } - - @GetMapping("/me") - @Override - public ApiResponse getMyInfo( - @RequestHeader("X-Loopers-LoginId") String loginId, - @RequestHeader("X-Loopers-LoginPw") String password - ) { - MemberInfo info = memberFacade.getMyInfo(loginId, password); - MemberInfoResponse response = MemberInfoResponse.from(info); - return ApiResponse.success(response); - } - - @PatchMapping("/me/password") - public ApiResponse changePassword( - @RequestHeader("X-Loopers-LoginId") String loginId, - @RequestHeader("X-Loopers-LoginPw") String password, - @RequestBody ChangePasswordRequest request - ) { - memberFacade.changePassword(loginId, password, request.oldPassword(), request.newPassword()); - return ApiResponse.success("비밀번호가 변경되었습니다."); - } - - -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java index 8d56b0b9b..55def36c8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java @@ -1,5 +1,42 @@ package com.loopers.interfaces.api.order; -public class OrderController { +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/orders") +public class OrderController implements OrderV1ApiSpec { + +// @PostMapping +// @ResponseStatus(HttpStatus.CREATED) +// @Override +// public ApiResponse makeOrder(@RequestBody SignUpRequest request) { +// UserInfo info = userFacade.signupUser(request); +// UserV1Dto.SignUpResponse response = UserV1Dto.SignUpResponse.from(info); +// return ApiResponse.success(response); +// } +// +// @GetMapping("/startAt=2026-01-31&endAt=2026-02-10") +// @Override +// public ApiResponse getMyOrders( +// @RequestHeader("X-Loopers-LoginId") String loginId, +// @RequestHeader("X-Loopers-LoginPw") String password +// ) { +// UserInfo info = userFacade.getMyInfo(loginId, password); +// MemberInfoResponse response = MemberInfoResponse.from(info); +// return ApiResponse.success(response); +// } +// +// @GetMapping("/{orderId}") +// @Override +// public ApiResponse getOrder( +// @RequestHeader("X-Loopers-LoginId") String loginId, +// @RequestHeader("X-Loopers-LoginPw") String password +// ) { +// UserInfo info = userFacade.getMyInfo(loginId, password); +// MemberInfoResponse response = MemberInfoResponse.from(info); +// return ApiResponse.success(response); +// } } 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..bec82c801 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java @@ -0,0 +1,22 @@ +package com.loopers.interfaces.api.order; + +public interface OrderV1ApiSpec { + +// @Operation( +// summary = "예시 조회", +// description = "ID로 예시를 조회합니다." +// ) +// ApiResponse makeOrder( +// @Schema(name = "예시 ID", description = "조회할 예시의 ID") +// Long exampleId +// ); +// +// @Operation( +// summary = "예시 조회", +// description = "ID로 예시를 조회합니다." +// ) +// ApiResponse getMyOrders( +// @Schema(name = "예시 ID", description = "조회할 예시의 ID") +// Long exampleId +// ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductsController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductsController.java new file mode 100644 index 000000000..b0febe09b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductsController.java @@ -0,0 +1,17 @@ +package com.loopers.interfaces.api.product; + +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/Products") +public class ProductsController implements ProductsV1ApiSpec { + // /api/v1/products // 상품 목록 조회 + // /api/v1/products/{productId} // 상품 정보 조회 + // (POST) /api/v1/products/{productId}/likes // 상품 좋아요 등록 + // (DELETE) /api/v1/products/{productId}/likes // 상품 좋아요 취소 + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductsV1ApiSpec.java similarity index 56% rename from apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java rename to apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductsV1ApiSpec.java index 6b707ee3f..0753303a1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductsV1ApiSpec.java @@ -1,5 +1,5 @@ package com.loopers.interfaces.api.product; -public class ProductController { +public interface ProductsV1ApiSpec { } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/users/UserV1ApiSpec.java similarity index 68% rename from apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java rename to apps/commerce-api/src/main/java/com/loopers/interfaces/api/users/UserV1ApiSpec.java index 52cb64800..8061ad958 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/users/UserV1ApiSpec.java @@ -1,4 +1,4 @@ -package com.loopers.interfaces.api.member; +package com.loopers.interfaces.api.users; import com.loopers.interfaces.api.ApiResponse; import io.swagger.v3.oas.annotations.Operation; @@ -6,7 +6,7 @@ import org.springframework.web.bind.annotation.RequestHeader; @Tag(name = "Member V1 API", description = "회원 API") -public interface MemberV1ApiSpec { +public interface UserV1ApiSpec { @Operation( summary = "회원 가입 요청", @@ -14,15 +14,15 @@ public interface MemberV1ApiSpec { ) // @Schema는 Swagger API 문서에서 파라미터 설명을 보여주는 용도 // 예제에서는 Long exampleId 같은 단일 파라미터에 붙였는데, 지금은 SignUpRequest로 통째로 받으니까 여기엔 필요 없음 - ApiResponse signUp( - MemberV1Dto.SignUpRequest request + ApiResponse signUp( + UserV1Dto.SignUpRequest request ); @Operation( summary = "내 정보 조회", description = "로그인 ID로 내 회원 정보를 조회한다" ) - ApiResponse getMyInfo( + ApiResponse getMyInfo( @RequestHeader("X-Loopers-LoginId") String loginId, @RequestHeader("X-Loopers-LoginPw") String password ); @@ -34,7 +34,16 @@ ApiResponse getMyInfo( ApiResponse changePassword( @RequestHeader("X-Loopers-LoginId") String loginId, @RequestHeader("X-Loopers-LoginPw") String password, - MemberV1Dto.ChangePasswordRequest request + UserV1Dto.ChangePasswordRequest request + ); + + @Operation( + summary = "내 정보 조회", + description = "로그인 ID로 내 회원 정보를 조회한다" + ) + ApiResponse getMyLikes( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/users/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/users/UserV1Controller.java new file mode 100644 index 000000000..ffeb93ce6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/users/UserV1Controller.java @@ -0,0 +1,74 @@ +package com.loopers.interfaces.api.users; + +import com.loopers.application.users.UserFacade; +import com.loopers.application.users.UserInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.users.UserV1Dto.ChangePasswordRequest; +import com.loopers.interfaces.api.users.UserV1Dto.MemberInfoResponse; +import com.loopers.interfaces.api.users.UserV1Dto.SignUpRequest; +import com.loopers.interfaces.api.users.UserV1Dto.SignUpResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PostMapping; +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.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/users") +public class UserV1Controller implements UserV1ApiSpec { + + // 클라이언트 → [SignUpRequest (record)] → Controller → Facade → Service → [MemberModel (entity)] → DB + // 요청 데이터 전달용 DB에 저장되는 객체 + // DB → [MemberModel (entity)] → Facade → [SignUpResponse (record)] → Controller → 클라이언트 + // DB에서 꺼낸 객체 응답 데이터 전달용 + private final UserFacade userFacade; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @Override + public ApiResponse signUp(@RequestBody SignUpRequest request) { + UserInfo info = userFacade.signupUser(request); + UserV1Dto.SignUpResponse response = UserV1Dto.SignUpResponse.from(info); + return ApiResponse.success(response); + } + + @GetMapping("/me") + @Override + public ApiResponse getMyInfo( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password + ) { + UserInfo info = userFacade.getMyInfo(loginId, password); + MemberInfoResponse response = MemberInfoResponse.from(info); + return ApiResponse.success(response); + } + + @PatchMapping("/password") + @Override + public ApiResponse changePassword( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, + @RequestBody ChangePasswordRequest request + ) { + userFacade.changePassword(loginId, password, request.oldPassword(), request.newPassword()); + return ApiResponse.success("비밀번호가 변경되었습니다."); + } + + @GetMapping("me/likes") + @Override + public ApiResponse getMyLikes( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password + ) { + UserInfo info = userFacade.getMyInfo(loginId, password); + MemberInfoResponse response = MemberInfoResponse.from(info); + return ApiResponse.success(response); + } + +} 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/users/UserV1Dto.java similarity index 78% rename from apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java rename to apps/commerce-api/src/main/java/com/loopers/interfaces/api/users/UserV1Dto.java index e4e6f0465..a2af05d71 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/users/UserV1Dto.java @@ -1,9 +1,9 @@ -package com.loopers.interfaces.api.member; +package com.loopers.interfaces.api.users; -import com.loopers.application.member.MemberInfo; +import com.loopers.application.users.UserInfo; -public class MemberV1Dto { +public class UserV1Dto { // Request: POST방식으로 보낼때 데이터를 담는 그릇 (from 필요 없음) public record SignUpRequest( @@ -16,7 +16,7 @@ public record SignUpRequest( // Response: 변환 메서드(from)가 여기에! public record SignUpResponse(String loginId) { - public static SignUpResponse from(MemberInfo info) { + public static SignUpResponse from(UserInfo info) { return new SignUpResponse(info.loginId()); } } @@ -27,7 +27,7 @@ public record MemberInfoResponse( String birthDate, String email ) { - public static MemberInfoResponse from(MemberInfo info) { + public static MemberInfoResponse from(UserInfo info) { return new MemberInfoResponse( info.loginId(), info.name(), diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java deleted file mode 100644 index e28f0cdab..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java +++ /dev/null @@ -1,220 +0,0 @@ -package com.loopers.domain.member; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertThrows; - -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; - -class MemberModelTest { - - @DisplayName("회원 모델을 생성할 때, ") - @Nested - class Create { - - @DisplayName("(성공케이스) 필수 정보가 모두 주어지면, 정상적으로 생성된다.") - @Test - void createsMemberModel_whenAllFieldsAreProvided() { - // arrange - String loginId = "testuser"; - String rawPassword = "Test1234!"; - String name = "홍길동"; - String birthDate = "19900101"; - String email = "test@example.com"; - - // act - MemberModel member = new MemberModel(loginId, rawPassword, name, birthDate, email); - - // assert - assertThat(member.getLoginId()).isEqualTo(loginId); - assertThat(member.getPassword()).isEqualTo(rawPassword); - assertThat(member.getName()).isEqualTo(name); - assertThat(member.getBirthDate()).isEqualTo(birthDate); - assertThat(member.getEmail()).isEqualTo(email); - // 비밀번호는 암호화되어 저장되므로 원본과 다를 수 있음 - 나중에 검증 방식 결정 - } - - @DisplayName("아이디로 회원 모델을 생성할 때, 영문과 숫자가 아닌 문자가 포함되면 예외가 발생한다.") - @Test - void throwsBadRequestException_whenLoginIdContainsInvalidChars() { - // arrange - String loginId = "testuser!@#"; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new MemberModel(loginId); - }); - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - - @DisplayName("(실패케이스) 비밀번호가 7자일때, 예외발생.") - @Test - void throwsBadRequestException_whenPwIsOutOfRange() { - // arrange - String loginId = "testuser"; - String password = "Test12!"; - String name = "홍길동"; - String birthDate = "19900101"; - String email = "test@example.com"; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new MemberModel(loginId, password, name, birthDate, email); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - - @DisplayName("비밀번호가 17자일 때 → 예외 발생") - @Test - void throwsBadRequestException_whenPwIsOutOfRange2() { - // arrange - String loginId = "testuser"; - String password = "Test123456789012!"; - String name = "홍길동"; - String birthDate = "19900101"; - String email = "test@example.com"; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new MemberModel(loginId, password, name, birthDate, email); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @DisplayName("비밀번호에 한글이 있을 때 → 예외 발생") - @Test - void throwsBadRequestException_whenPwIsKorean() { - // arrange - String loginId = "testuser"; - String password = "Test홍길동890123!"; - String name = "홍길동"; - String birthDate = "19900101"; - String email = "test@example.com"; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new MemberModel(loginId, password, name, birthDate, email); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @DisplayName("비밀번호에 생년월일이 포함될 때 → 예외 발생") - @Test - void throwsBadRequestException_whenPwContainsBirthDate() { - // arrange - String loginId = "testuser"; - String password = "Test19900101!"; - String name = "홍길동"; - String birthDate = "19900101"; - String email = "test@example.com"; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new MemberModel(loginId, password, name, birthDate, email); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - } - - @DisplayName("회원정보조회할 때,") - @Nested - class GetMemberInfo { - - @DisplayName("이름 마지막 글자를 마스킹한다") - @Test - void mask_last_character() { - //arrange - String loginId = "testuser"; - String rawPassword = "Test1234!"; - String name = "홍길동"; - String birthDate = "19900101"; - String email = "test@example.com"; - - // act - MemberModel member = new MemberModel(loginId, rawPassword, name, birthDate, email); - - assertThat(member.getMaskedName()).isEqualTo("홍길*"); - } - - @DisplayName("이름 마지막 글자를 마스킹한다") - @Test - void single_character_name_is_fully_masked() { - //arrange - String loginId = "testuser"; - String rawPassword = "Test1234!"; - String name = "홍"; - String birthDate = "19900101"; - String email = "test@example.com"; - - // act - MemberModel member = new MemberModel(loginId, rawPassword, name, birthDate, email); - - assertThat(member.getMaskedName()).isEqualTo("*"); - } - - } - - @DisplayName("비밀번호 수정 할 때,") - @Nested - class ChangePassword { - - @DisplayName("새 비밀번호가 규칙을 만족하면, 비밀번호가 변경된다.") - @Test - void changesPassword_whenOldPasswordMatchesAndNewPasswordIsValid() { - // arrange - String loginId = "testuser"; - String prevPassword = "Test1234!"; - String name = "홍길동"; - String birthDate = "19900101"; - String email = "test@test.co.kr"; - - // act - MemberModel member = new MemberModel(loginId, prevPassword, name, birthDate, email); - String newPassword = "Newpass123!"; - member.changePassword(newPassword, birthDate); - - // assert - assertThat(member.getPassword()).isEqualTo(newPassword); - } - - - @DisplayName("새 비밀번호에 생년월일이 포함되면, 예외가 발생한다.") - @Test - void throwsException_whenNewPasswordContainsBirthDate() { - // arrange - String loginId = "testuser"; - String prevPassword = "Test1234!"; - String name = "홍길동"; - String birthDate = "19900101"; - String email = "test@test.co.kr"; - - String newPassword = "Test19900101!"; - - // act - MemberModel member = new MemberModel(loginId, prevPassword, name, birthDate, email); - - // act - CoreException result = assertThrows(CoreException.class, () -> { - member.changePassword(newPassword, birthDate); - }); - - // assert - ErrorType.BAD_REQUEST - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - - } - } -} 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 deleted file mode 100644 index 908201704..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java +++ /dev/null @@ -1,188 +0,0 @@ -package com.loopers.domain.member; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertThrows; - -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; - -@SpringBootTest -class MemberServiceIntegrationTest { - - @Autowired - private MemberService memberService; - - @Autowired - private DatabaseCleanUp databaseCleanUp; - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @DisplayName("회원가입을 성공한다") - @Nested - class SaveMember { - - @DisplayName("회원가입에 필요한 정보가 들어오면 디비에 저장하고 저장한 아이디를 조회한다") - @Test - void returnsMemberInfo_whenValidMemberInfoIsProvided() { - // arrange - MemberModel memberModel = new MemberModel("testuser", "Test1234!", "홍길동", "19900101", "test@example.com"); - - // act - memberService.saveMember(memberModel); - MemberModel result = memberService.getMember(memberModel.getLoginId()); - - // assert - assertAll(() -> assertThat(result).isNotNull(), () -> assertThat(result.getLoginId()).isEqualTo(memberModel.getLoginId()), () -> assertThat(result.getName()).isEqualTo(memberModel.getName()), () -> assertThat(result.getBirthDate()).isEqualTo(memberModel.getBirthDate()), () -> assertThat(result.getEmail()).isEqualTo(memberModel.getEmail())); - } - - @DisplayName("중복 ID로 가입 시도하면 예외가 발생한다") - @Test - void throwsException_whenExistIdIsTryToSaveMember() { - // arrange - 먼저 한 명 가입시키기 - memberService.saveMember(new MemberModel("testuser", "Test1234!", "홍길동", "19900101", "test@example.com")); - - // act - 같은 ID로 또 가입 시도 - CoreException exception = assertThrows(CoreException.class, () -> { - memberService.saveMember(new MemberModel("testuser", "Test1234!", "홍길동", "19900101", "test@example.com")); - }); - - // assert - assertThat(exception.getErrorType()).isEqualTo(ErrorType.CONFLICT); - } - - } - - @DisplayName("회원을 조회할 때, ") - @Nested - class GetMember { - - @DisplayName("존재하는 ID를 주면, 해당 유저 정보를 반환한다.") - @Test - void returnsExampleInfo_whenValidIdIsProvided() { - // arrange // 정보저장 - MemberModel memberModel = new MemberModel("testuser", "Test1234!", "홍길동", "19900101", "test@example.com"); - memberService.saveMember(memberModel); - - // act - MemberModel result = memberService.getMember(memberModel.getLoginId()); - - // assert - assertAll(() -> assertThat(result).isNotNull(), () -> assertThat(result.getLoginId()).isEqualTo(memberModel.getLoginId()), () -> assertThat(result.getName()).isEqualTo(memberModel.getName()), () -> assertThat(result.getBirthDate()).isEqualTo(memberModel.getBirthDate()), () -> assertThat(result.getEmail()).isEqualTo(memberModel.getEmail())); - } - - @DisplayName("존재하지 않는 ID로 조회하면 예외가 발생한다") - @Test - void throwsException_whenMemberNotFound() { - // arrange - String loginId = "testuser"; // Assuming this ID does not exist - - // act - CoreException exception = assertThrows(CoreException.class, () -> { - memberService.getMember(loginId); - }); - - // assert - assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); - } - } - - - @DisplayName("비밀번호 변경을 할 때, ") - @Nested - class ChangePassword { - - @DisplayName("기존 비밀번호와 새 비밀번호가 유효하면, 비밀번호가 변경된다.") - @Test - void changesPassword_whenOldAndNewPasswordsAreValid() { - // arrange - String loginId = "testuser"; - String prevPassword = "Test1234!"; - String name = "홍길동"; - String birthDate = "19900101"; - String email = "test@test.co.kr"; - - // act - // 기존 등록된 디비 설정 - MemberModel member = new MemberModel(loginId, prevPassword, name, birthDate, email); - memberService.saveMember(member); - - // 클라에서 입력한 아이디와 기존 비밀번호, 새로운 비밀번호 - MemberModel insertedMember = new MemberModel(loginId, prevPassword); - String newPassword = "NewPass123!"; - - // act - memberService.changePassword(insertedMember, newPassword); - - // assert - MemberModel updatedMember = memberService.getMember("testuser"); - // 비밀번호가 변경되었는지 확인 (암호화된 비밀번호 비교) - assertThat(updatedMember.getPassword()).isNotEqualTo(insertedMember.getPassword()); - } - - @DisplayName("기존 비밀번호가 일치하지 않으면, 예외가 발생한다.") - @Test - void throwsException_whenOldPasswordDoesNotMatch() { - // arrange - String loginId = "testuser"; - String prevPassword = "Test1234!"; - String name = "홍길동"; - String birthDate = "19900101"; - String email = "test@test.co.kr"; - - // act - // 기존 등록된 디비 설정 - MemberModel member = new MemberModel(loginId, prevPassword, name, birthDate, email); - memberService.saveMember(member); - - // 클라에서 입력한 아이디와 기존 비밀번호, 새로운 비밀번호 - String wrongPrevPassword = "WrongPass!"; - MemberModel insertedMember = new MemberModel(loginId, wrongPrevPassword); - String newPassword = "NewPass123!"; - - // act - CoreException exception = assertThrows(CoreException.class, () -> { - memberService.changePassword(insertedMember, newPassword); - }); - - // assert - ErrorType.UNAUTHORIZED - assertThat(exception.getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED); - } - - @DisplayName("새 비밀번호가 기존 비밀번호와 같으면, 예외가 발생한다.") - @Test - void throwsException_whenNewPasswordIsSameAsOld() { - // arrange - String loginId = "testuser"; - String prevPassword = "Test1234!"; - String name = "홍길동"; - String birthDate = "19900101"; - String email = "test@test.co.kr"; - - MemberModel member = new MemberModel(loginId, prevPassword, name, birthDate, email); - memberService.saveMember(member); - - // 클라에서 입력한 아이디와 기존 비밀번호, 새로운 비밀번호 - MemberModel insertedMember = new MemberModel(loginId, prevPassword); - String newPassword = "Test1234!"; - - // act - CoreException exception = assertThrows(CoreException.class, () -> { - memberService.changePassword(insertedMember, newPassword); - }); - - // assert - ErrorType.UNAUTHORIZED 또는 BAD_REQUEST - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/users/UsersServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/users/UsersServiceIntegrationTest.java new file mode 100644 index 000000000..475ef63c8 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/users/UsersServiceIntegrationTest.java @@ -0,0 +1,175 @@ +package com.loopers.domain.users; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +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; + +@SpringBootTest +class UsersServiceIntegrationTest { + + @Autowired + private UserService userService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("회원가입을 할 때, ") + @Nested + class SaveUsers { + + @DisplayName("필수 정보가 모두 주어지면, DB에 저장되고 조회할 수 있다.") + @Test + void returnsUserInfo_whenValidUserInfoIsProvided() { + // arrange + String loginId = "testuser"; + String password = "Test1234!"; + String name = "홍길동"; + String birthDate = "19900101"; + String email = "test@example.com"; + + // act + userService.register(loginId, password, name, birthDate, email); + Users result = userService.getMember(loginId); + + // assert + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result.getLoginId()).isEqualTo(loginId), + () -> assertThat(result.getName()).isEqualTo(name), + () -> assertThat(result.getBirthDate()).isEqualTo(birthDate), + () -> assertThat(result.getEmail()).isEqualTo(email) + ); + } + + @DisplayName("중복 ID로 가입 시도하면, CONFLICT 예외가 발생한다.") + @Test + void throwsException_whenExistIdIsTryToSaveUser() { + // arrange — 먼저 한 명 가입시켜 DB에 저장 + String loginId = "testuser"; + userService.register(loginId, "Test1234!", "홍길동", "19900101", "test@example.com"); + + // act — 같은 ID로 또 가입 시도 + CoreException exception = assertThrows(CoreException.class, () -> + userService.register(loginId, "Test1234!", "홍길동", "19900101", "test@example.com") + ); + + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + } + + @DisplayName("회원을 조회할 때, ") + @Nested + class GetUsers { + + @DisplayName("존재하는 ID를 주면, 해당 유저 정보를 반환한다.") + @Test + void returnsUserInfo_whenValidIdIsProvided() { + // arrange + String loginId = "testuser"; + String password = "Test1234!"; + String name = "홍길동"; + String birthDate = "19900101"; + String email = "test@example.com"; + userService.register(loginId, password, name, birthDate, email); + + // act + Users result = userService.getMember(loginId); + + // assert + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result.getLoginId()).isEqualTo(loginId), + () -> assertThat(result.getName()).isEqualTo(name), + () -> assertThat(result.getBirthDate()).isEqualTo(birthDate), + () -> assertThat(result.getEmail()).isEqualTo(email) + ); + } + + @DisplayName("존재하지 않는 ID로 조회하면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsException_whenMemberNotFound() { + // arrange + String loginId = "nonexistent"; + + // act + CoreException exception = assertThrows(CoreException.class, () -> + userService.getMember(loginId) + ); + + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("비밀번호 변경을 할 때, ") + @Nested + class ChangePassword { + + @DisplayName("기존 비밀번호와 새 비밀번호가 유효하면, 비밀번호가 변경된다.") + @Test + void changesPassword_whenOldAndNewPasswordsAreValid() { + // arrange + String loginId = "testuser"; + String prevPassword = "Test1234!"; + String newPassword = "NewPass123!"; + userService.register(loginId, prevPassword, "홍길동", "19900101", "test@test.co.kr"); + String beforeEncrypted = userService.getMember(loginId).getPassword(); + + // act + userService.changePassword(loginId, prevPassword, newPassword); + + // assert + Users after = userService.getMember(loginId); + assertThat(after.getPassword()).isNotEqualTo(beforeEncrypted); + } + + @DisplayName("기존 비밀번호가 일치하지 않으면, UNAUTHORIZED 예외가 발생한다.") + @Test + void throwsException_whenOldPasswordDoesNotMatch() { + // arrange + String loginId = "testuser"; + userService.register(loginId, "Test1234!", "홍길동", "19900101", "test@test.co.kr"); + + // act — 틀린 비밀번호로 시도 + CoreException exception = assertThrows(CoreException.class, () -> + userService.changePassword(loginId, "WrongPass123!", "NewPass456!") + ); + + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED); + } + + @DisplayName("새 비밀번호가 기존 비밀번호와 같으면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsException_whenNewPasswordIsSameAsOld() { + // arrange + String loginId = "testuser"; + String prevPassword = "Test1234!"; + userService.register(loginId, prevPassword, "홍길동", "19900101", "test@test.co.kr"); + + // act + CoreException exception = assertThrows(CoreException.class, () -> + userService.changePassword(loginId, prevPassword, prevPassword) + ); + + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/users/UsersTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/users/UsersTest.java new file mode 100644 index 000000000..81a7537c6 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/users/UsersTest.java @@ -0,0 +1,116 @@ +package com.loopers.domain.users; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.loopers.domain.users.vo.Email; +import com.loopers.domain.users.vo.EncryptedPassword; +import com.loopers.domain.users.vo.LoginId; +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; + +class UsersTest { + + // Users.create()에 전달할 이미 검증된 VO 헬퍼 + private Users createUser(String loginId, String password, String name, String birthDate, String email) { + return Users.create( + new LoginId(loginId), + new EncryptedPassword(password), + name, + birthDate, + new Email(email) + ); + } + + @DisplayName("회원을 생성할 때, ") + @Nested + class Create { + + @DisplayName("필수 정보가 모두 주어지면, 정상적으로 생성된다.") + @Test + void createsUser_whenAllFieldsAreProvided() { + // arrange + String loginId = "testuser"; + String password = "hashed_pw"; + String name = "홍길동"; + String birthDate = "19900101"; + String email = "test@example.com"; + + // act + Users users = createUser(loginId, password, name, birthDate, email); + + // assert + assertAll( + () -> assertThat(users.getLoginId()).isEqualTo(loginId), + () -> assertThat(users.getName()).isEqualTo(name), + () -> assertThat(users.getBirthDate()).isEqualTo(birthDate), + () -> assertThat(users.getEmail()).isEqualTo(email) + ); + } + + @DisplayName("이름이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameIsNull() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + createUser("testuser", "hashed_pw", null, "19900101", "test@example.com") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("이름이 빈 문자열이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameIsBlank() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + createUser("testuser", "hashed_pw", " ", "19900101", "test@example.com") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("생년월일이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenBirthDateIsNull() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + createUser("testuser", "hashed_pw", "홍길동", null, "test@example.com") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("이름을 마스킹할 때, ") + @Nested + class MaskName { + + @DisplayName("2자 이상이면 마지막 글자만 마스킹된다.") + @Test + void masksLastCharacter_whenNameIsMultipleChars() { + // arrange + Users users = createUser("testuser", "hashed_pw", "홍길동", "19900101", "test@example.com"); + + // act & assert + assertThat(users.getMaskedName()).isEqualTo("홍길*"); + } + + @DisplayName("1자이면 전체가 마스킹된다.") + @Test + void masksAllCharacters_whenNameIsSingleChar() { + // arrange + Users users = createUser("testuser", "hashed_pw", "홍", "19900101", "test@example.com"); + + // act & assert + assertThat(users.getMaskedName()).isEqualTo("*"); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/users/vo/EmailTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/users/vo/EmailTest.java new file mode 100644 index 000000000..b284deed3 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/users/vo/EmailTest.java @@ -0,0 +1,64 @@ +package com.loopers.domain.users.vo; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +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; + +class EmailTest { + + @DisplayName("이메일을 생성할 때, ") + @Nested + class Create { + + @DisplayName("올바른 이메일 형식이면, 정상적으로 생성된다.") + @Test + void createsEmail_whenValueIsValid() { + // arrange & act + Email email = new Email("test@example.com"); + + // assert + assertThat(email.value()).isEqualTo("test@example.com"); + } + + @DisplayName("null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueIsNull() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new Email(null) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("빈 문자열이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueIsBlank() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new Email(" ") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("@ 문자가 없으면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueHasNoAtSign() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new Email("testexample.com") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/users/vo/LoginIdTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/users/vo/LoginIdTest.java new file mode 100644 index 000000000..3fe3f981a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/users/vo/LoginIdTest.java @@ -0,0 +1,64 @@ +package com.loopers.domain.users.vo; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +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; + +class LoginIdTest { + + @DisplayName("로그인 아이디를 생성할 때, ") + @Nested + class Create { + + @DisplayName("영문자와 숫자로만 이루어진 경우, 정상적으로 생성된다.") + @Test + void createsLoginId_whenValueIsAlphanumeric() { + // arrange & act + LoginId loginId = new LoginId("testuser123"); + + // assert + assertThat(loginId.value()).isEqualTo("testuser123"); + } + + @DisplayName("null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueIsNull() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new LoginId(null) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("빈 문자열이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueIsBlank() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new LoginId(" ") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("영문자·숫자 외의 문자가 포함되면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueContainsSpecialChars() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new LoginId("user!@#") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/users/vo/RawPasswordTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/users/vo/RawPasswordTest.java new file mode 100644 index 000000000..283ab6ebf --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/users/vo/RawPasswordTest.java @@ -0,0 +1,88 @@ +package com.loopers.domain.users.vo; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +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; + +class RawPasswordTest { + + @DisplayName("비밀번호를 생성할 때, ") + @Nested + class Create { + + @DisplayName("규칙을 모두 만족하면, 정상적으로 생성된다.") + @Test + void createsRawPassword_whenValueIsValid() { + // arrange & act + RawPassword password = RawPassword.of("Test1234!", "19900101"); + + // assert + assertThat(password.value()).isEqualTo("Test1234!"); + } + + @DisplayName("null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueIsNull() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + RawPassword.of(null, "19900101") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("7자 미만이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueIsTooShort() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + RawPassword.of("Test12!", "19900101") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("17자 초과이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueIsTooLong() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + RawPassword.of("Test123456789012!", "19900101") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("한글이 포함되면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueContainsKorean() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + RawPassword.of("Test홍길동!", "19900101") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("생년월일이 포함되면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueContainsBirthDate() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + RawPassword.of("Test19900101!", "19900101") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UsersV1ApiE2ETest.java similarity index 99% rename from apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java rename to apps/commerce-api/src/test/java/com/loopers/interfaces/api/UsersV1ApiE2ETest.java index b9fb6e264..e220da823 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UsersV1ApiE2ETest.java @@ -21,7 +21,7 @@ import org.springframework.http.ResponseEntity; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -class MemberV1ApiE2ETest { +class UsersV1ApiE2ETest { private static final String ENDPOINT = "/api/v1/members"; @@ -29,7 +29,7 @@ class MemberV1ApiE2ETest { private final DatabaseCleanUp databaseCleanUp; @Autowired - public MemberV1ApiE2ETest( + public UsersV1ApiE2ETest( TestRestTemplate testRestTemplate, DatabaseCleanUp databaseCleanUp ) { @@ -109,7 +109,7 @@ void returnsConflict_whenDuplicateLoginIdIsProvided() { @DisplayName("GET /api/v1/members/me (회원정보조회)") @Nested - class GetMemberInfo { + class GetUsersInfo { @DisplayName("헤더 인증 성공 시, 200 OK와 마스킹된 이름을 반환한다.") @Test diff --git a/docs/design/04-erd.md b/docs/design/04-erd.md index ef42ed8be..5be882d72 100644 --- a/docs/design/04-erd.md +++ b/docs/design/04-erd.md @@ -77,6 +77,6 @@ users ||--o{ orders : "places" } users { - BIGINT user_id PK "v2에서 추가 예정" + BIGINT user_id PK } ``` \ No newline at end of file From a8415cd024943134c23f377ff7485c97f6c7f029 Mon Sep 17 00:00:00 2001 From: katiekim17 Date: Tue, 24 Feb 2026 01:17:31 +0900 Subject: [PATCH 19/39] =?UTF-8?q?feature:=20=EB=B8=8C=EB=9E=9C=EB=93=9C=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B0=9C=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/application/brand/BrandInfo.java | 16 +++ .../application/brand/BrandService.java | 19 +++- .../java/com/loopers/domain/brand/Brand.java | 46 +++++++- .../loopers/domain/brand/BrandRepository.java | 10 +- .../brand/BrandRepositoryImpl.java | 27 ++++- .../interfaces/api/brand/BrandController.java | 25 ++++- .../interfaces/api/brand/BrandV1ApiSpec.java | 9 ++ .../interfaces/api/brand/BrandV1Dto.java | 18 ++++ .../interfaces/api/BrandV1ApiE2ETest.java | 101 ++++++++++++++++++ 9 files changed, 264 insertions(+), 7 deletions(-) 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/BrandV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/BrandV1ApiE2ETest.java 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..a9bef26db --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java @@ -0,0 +1,16 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.Brand; +import java.time.ZonedDateTime; + +public record BrandInfo(Long brandId, String name, String description, String logoImageUrl, ZonedDateTime createdAt) { + public static BrandInfo from(Brand model) { + return new BrandInfo( + model.getId(), + model.getName(), + model.getDescription(), + model.getLogoImageUrl(), + model.getCreatedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java index 39aecfc59..f618a0539 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java @@ -1,6 +1,23 @@ package com.loopers.application.brand; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +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 BrandService { - // Facade 불필요 (단일 도메인) + private final BrandRepository brandRepository; + + @Transactional(readOnly = true) + public BrandInfo getBrandInfo(Long brandId) { + Brand brand = brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 브랜드입니다.")); + return BrandInfo.from(brand); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java index 915d3f17d..0efb3b449 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java @@ -1,5 +1,49 @@ package com.loopers.domain.brand; -public class Brand { +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +@Entity +@Table(name = "brand") +public class Brand extends BaseEntity { + + @Column(name = "name", nullable = false, length = 100) + private String name; + + @Column(name = "description", length = 500) + private String description; + + @Column(name = "logo_image_url", length = 500) + private String logoImageUrl; + + protected Brand() {} + + public Brand(String name) { + this(name, null, null); + } + + public Brand(String name, String description, String logoImageUrl) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드명은 비어있을 수 없습니다."); + } + this.name = name; + this.description = description; + this.logoImageUrl = logoImageUrl; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public String getLogoImageUrl() { + return logoImageUrl; + } } 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 fa8fa450b..9ef5d6da9 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 @@ -1,5 +1,13 @@ package com.loopers.domain.brand; +import java.util.List; +import java.util.Optional; + public interface BrandRepository { -} + Brand save(Brand brand); + + Optional findById(Long id); + + List findAllByIds(List ids); +} \ No newline at end of file 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 e2bddb17d..6b22cbdb2 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 @@ -1,5 +1,30 @@ package com.loopers.infrastructure.brand; -public class BrandRepositoryImpl { +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +@RequiredArgsConstructor +@Component +public class BrandRepositoryImpl implements BrandRepository { + + private final BrandJpaRepository brandJpaRepository; + + @Override + public Brand save(Brand brand) { + return brandJpaRepository.save(brand); + } + + @Override + public Optional findById(Long id) { + return brandJpaRepository.findById(id); + } + + @Override + public List findAllByIds(List ids) { + return brandJpaRepository.findAllByIdIn(ids); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java index 3115a0339..44fd9f4d7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java @@ -1,6 +1,12 @@ package com.loopers.interfaces.api.brand; +import com.loopers.application.brand.BrandInfo; +import com.loopers.application.brand.BrandService; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.brand.BrandV1Dto.BrandResponse; 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; @@ -8,8 +14,21 @@ @RestController @RequestMapping("/api/v1/brands") public class BrandController implements BrandV1ApiSpec { - // /api/v1/brands/{brandId} // 브랜드 정보 조회 - // /api/v1/products // 상품 목록 조회 - // /api/v1/products/{productId} // 상품 정보 조회 + + private final BrandService brandService; + + @GetMapping("/{brandId}") + @Override + public ApiResponse getBrands( + @PathVariable(value = "brandId") Long brandId + ) { + BrandInfo info = brandService.getBrandInfo(brandId); + BrandV1Dto.BrandResponse response = BrandV1Dto.BrandResponse.from(info); + return ApiResponse.success(response); + } + + + // TODO /api/v1/products // 상품 목록 조회 + // TODO /api/v1/products/{productId} // 상품 정보 조회 } 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 bc6acfd88..f338fd546 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 @@ -1,5 +1,14 @@ package com.loopers.interfaces.api.brand; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.brand.BrandV1Dto.BrandResponse; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; + public interface BrandV1ApiSpec { + @GetMapping("/{brandId}") + ApiResponse getBrands( + @PathVariable(value = "brandId") Long brandId + ); } 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..e6a883d0d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java @@ -0,0 +1,18 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandInfo; +import java.time.ZonedDateTime; + +public class BrandV1Dto { + public record BrandResponse(Long brandId, String name, String description, String logoImageUrl, ZonedDateTime createdAt) { + public static BrandResponse from(BrandInfo info) { + return new BrandResponse( + info.brandId(), + info.name(), + info.description(), + info.logoImageUrl(), + info.createdAt() + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/BrandV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/BrandV1ApiE2ETest.java new file mode 100644 index 000000000..31b7a1f72 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/BrandV1ApiE2ETest.java @@ -0,0 +1,101 @@ +package com.loopers.interfaces.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.loopers.domain.brand.Brand; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.utils.DatabaseCleanUp; +import java.util.Map; +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; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class BrandV1ApiE2ETest { + + private static final String ENDPOINT = "/api/v1/brands"; + + private final TestRestTemplate testRestTemplate; + private final BrandJpaRepository brandJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public BrandV1ApiE2ETest( + TestRestTemplate testRestTemplate, + BrandJpaRepository brandJpaRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.brandJpaRepository = brandJpaRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("GET /api/v1/brands/{brandId}") + @Nested + class GetBrand { + + @DisplayName("유효한 brandId를 주면, brandId, name, description, logoImageUrl, createdAt을 반환한다.") + @Test + void returnsBrandInfo_whenValidBrandIdIsProvided() { + // arrange + Brand brand = brandJpaRepository.save( + new Brand("나이키", "스포츠 브랜드", "https://example.com/nike-logo.png") + ); + Long brandId = brand.getId(); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "/" + brandId, + HttpMethod.GET, + new HttpEntity<>(null), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> { + Map data = response.getBody().data(); + assertThat(((Number) data.get("brandId")).longValue()).isEqualTo(brandId); + assertThat(data.get("name")).isEqualTo("나이키"); + assertThat(data).containsKey("description"); + assertThat(data).containsKey("logoImageUrl"); + assertThat(data).containsKey("createdAt"); + } + ); + } + + @DisplayName("존재하지 않는 brandId를 주면, 404 Not Found를 반환한다.") + @Test + void returnsNotFound_whenBrandDoesNotExist() { + // arrange — DB에 아무 브랜드도 없음 + Long nonExistentId = 999999L; + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "/" + nonExistentId, + HttpMethod.GET, + new HttpEntity<>(null), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } +} From 95cd05f9eeb2c6619d2f76982cd4236dcfeec909 Mon Sep 17 00:00:00 2001 From: katiekim17 Date: Tue, 24 Feb 2026 03:48:21 +0900 Subject: [PATCH 20/39] =?UTF-8?q?fix:=20=EB=B8=8C=EB=9E=9C=EB=93=9C=5F?= =?UTF-8?q?=EC=83=81=ED=92=88=20=EC=9A=94=EA=B5=AC=EC=82=AC=ED=95=AD=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../01-requirements.md" | 56 +++++++++---------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git "a/docs/design/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/01-requirements.md" "b/docs/design/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/01-requirements.md" index 55e8b42ec..795a152a9 100644 --- "a/docs/design/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/01-requirements.md" +++ "b/docs/design/\353\270\214\353\236\234\353\223\234_\354\203\201\355\222\210/01-requirements.md" @@ -217,34 +217,34 @@ Authorization: Not Required (로그인 시 좋아요 여부 추가 제공) } ``` -**반환 정보 (로그인 사용자):** -```json -{ - "productId": 1, - "name": "상품명", - "description": "상품 상세 설명", - "brand": { - "brandId": 1, - "name": "브랜드명" - }, - "imageUrls": [ - "https://example.com/product-image1.png", - "https://example.com/product-image2.png" - ], - "options": [ - { - "productOptionId": 1, - "name": "S 사이즈", - "price": 10000, - "stockQuantity": 50, - "isAvailable": true - } - ], - "likeCount": 150, - "isLikedByMe": true, - "createdAt": "2025-01-01T00:00:00" -} -``` +[//]: # (**반환 정보 (로그인 사용자):**) +[//]: # (```json) +[//]: # ({) +[//]: # ( "productId": 1,) +[//]: # ( "name": "상품명",) +[//]: # ( "description": "상품 상세 설명",) +[//]: # ( "brand": {) +[//]: # ( "brandId": 1,) +[//]: # ( "name": "브랜드명") +[//]: # ( },) +[//]: # ( "imageUrls": [) +[//]: # ( "https://example.com/product-image1.png",) +[//]: # ( "https://example.com/product-image2.png") +[//]: # ( ],) +[//]: # ( "options": [) +[//]: # ( {) +[//]: # ( "productOptionId": 1,) +[//]: # ( "name": "S 사이즈",) +[//]: # ( "price": 10000,) +[//]: # ( "stockQuantity": 50,) +[//]: # ( "isAvailable": true) +[//]: # ( }) +[//]: # ( ],) +[//]: # ( "likeCount": 150,) +[//]: # ( "isLikedByMe": true,) +[//]: # ( "createdAt": "2025-01-01T00:00:00") +[//]: # (}) +[//]: # (```) **비즈니스 규칙:** - **옵션별 가격**: 각 옵션은 독립적인 가격을 가짐 From 92fa0933f21f52adbb2e08c296789dad9f1b6d35 Mon Sep 17 00:00:00 2001 From: katiekim17 Date: Tue, 24 Feb 2026 03:48:52 +0900 Subject: [PATCH 21/39] =?UTF-8?q?feature:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EB=B6=80=EB=B6=84=20=EC=9D=BC=EB=B6=80=20=EA=B0=9C=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interfaces/api/admin/AdminController.java | 61 ++++++++++++++++++- .../interfaces/api/admin/AdminV1ApiSpec.java | 27 ++++++++ 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminController.java index 2b977dde0..84f85ae01 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminController.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminController.java @@ -1,7 +1,19 @@ package com.loopers.interfaces.api.admin; +import com.loopers.application.brand.BrandInfo; +import com.loopers.application.brand.BrandService; +import com.loopers.application.product.ProductFacade; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.brand.BrandV1Dto; +import com.loopers.interfaces.api.brand.BrandV1Dto.BrandResponse; +import com.loopers.interfaces.api.product.ProductV1Dto; +import com.loopers.interfaces.api.product.ProductV1Dto.ProductListItemResponse; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +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.RequestParam; import org.springframework.web.bind.annotation.RestController; @RequiredArgsConstructor @@ -9,16 +21,59 @@ @RequestMapping("/api-admin/v1") public class AdminController implements AdminV1ApiSpec { // (GET) /api-admin/v1/brands?page=0&size=20 // 등록된 브랜드 목록 조회 - // (GET) /api-admin/v1/brands/{brandId} // 브랜드 상세 조회 + // (POST) /api-admin/v1/brands // 브랜드 등록 // (PUT) /api-admin/v1/brands/{brandId} // 브랜드 정보 수정 // (DELETE) /api-admin/v1/brands/{brandId} // 브랜드 삭제 - // (GET) /api-admin/v1/products?page=0&size=20&brandId={ brandId} // 등록된 상품 목록 조회 - // (GET) /api-admin/v1/products/{productId} // 상품 상세 조회 + + // (POST) /api-admin/v1/products // 상품 등록 // (PUT) /api-admin/v1/products/{productId} // 상품 정보 수정 // (DELETE) /api-admin/v1/products/{productId} // 상품 삭제 // (POST) /api-admin/v1/orders // 주문 요청 // (GET) /api-admin/v1/orders?startAt=2026-01-31&endAt=2026-02-10 // 유저의 주문 목록 조회 // (GET) /api-admin/v1/orders/{orderId} // 단일 주문 상세 조회 + + private final BrandService brandService; + private final ProductFacade productFacade; + + + + // (GET) /api-admin/v1/brands/{brandId} // 브랜드 상세 조회 + @GetMapping("/brands/{brandId}") + @Override + public ApiResponse getBrands( + @PathVariable(value = "brandId") Long brandId + ) { + BrandInfo info = brandService.getBrandInfo(brandId); + BrandV1Dto.BrandResponse response = BrandV1Dto.BrandResponse.from(info); + return ApiResponse.success(response); + } + + + // (GET) /api-admin/v1/products?page=0&size=20&brandId={ brandId} // 등록된 상품 목록 조회 + @GetMapping("/products") + @Override + public ApiResponse> getProductList( + @RequestParam(required = false) Long brandId, + @RequestParam(required = false, defaultValue = "latest") String sort, + @RequestParam(required = false, defaultValue = "0") int page, + @RequestParam(required = false, defaultValue = "20") int size + ) { + Page responsePage = productFacade.getProductList(brandId, sort, page, size) + .map(ProductV1Dto.ProductListItemResponse::from); + return ApiResponse.success(ProductV1Dto.PageResponse.from(responsePage)); + } + + // (GET) /api-admin/v1/products/{productId} // 상품 상세 조회 + @GetMapping("/products/{productId}") + @Override + public ApiResponse getProduct( + @PathVariable(value = "productId") Long productId + ) { + return ApiResponse.success( + ProductV1Dto.ProductDetailResponse.from(productFacade.getProductDetail(productId)) + ); + } + } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminV1ApiSpec.java index f143e13a8..f9fb1ad6f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminV1ApiSpec.java @@ -1,5 +1,32 @@ package com.loopers.interfaces.api.admin; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.brand.BrandV1Dto.BrandResponse; +import com.loopers.interfaces.api.product.ProductV1Dto; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "Brand&Products", description = "어드민 브랜드/상품 API") public interface AdminV1ApiSpec { + @GetMapping("/{brandId}") + ApiResponse getBrands( + @PathVariable(value = "brandId") Long brandId + ); + + // (GET) /api-admin/v1/products?page=0&size=20&brandId={ brandId} // 등록된 상품 목록 조회 + @GetMapping("/products") + ApiResponse> getProductList( + @RequestParam(required = false) Long brandId, + @RequestParam(required = false, defaultValue = "latest") String sort, + @RequestParam(required = false, defaultValue = "0") int page, + @RequestParam(required = false, defaultValue = "20") int size + ); + + @GetMapping("/{productId}") + ApiResponse getProduct( + @PathVariable(value = "productId") Long productId + ); } From 20df40ceca2083c16de87e3dd5439d34a060a4cd Mon Sep 17 00:00:00 2001 From: katiekim17 Date: Tue, 24 Feb 2026 03:49:54 +0900 Subject: [PATCH 22/39] =?UTF-8?q?fix:=20=EC=9C=A0=EC=A0=80=20VO=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/application/users/UserInfo.java | 2 +- .../infrastructure/users/UserJpaRepository.java | 6 ++++-- .../loopers/interfaces/api/users/UserV1ApiSpec.java | 4 ++-- .../interfaces/api/users/UserV1Controller.java | 12 ++++++------ .../com/loopers/interfaces/api/users/UserV1Dto.java | 6 +++--- .../loopers/interfaces/api/UsersV1ApiE2ETest.java | 2 +- 6 files changed, 17 insertions(+), 15 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/users/UserInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/users/UserInfo.java index 0d3f29b9b..0864255f2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/users/UserInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/users/UserInfo.java @@ -2,7 +2,7 @@ import com.loopers.domain.users.Users; -// MemberInfo는 Facade → Controller로 전달되는 데이터 +// UserInfo는 Facade → Controller로 전달되는 데이터 public record UserInfo(String loginId, String name, String birthDate, String email) { public static UserInfo from(Users model) { return new UserInfo( diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/users/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/users/UserJpaRepository.java index 6db9f59b8..1defe830c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/users/UserJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/users/UserJpaRepository.java @@ -1,7 +1,6 @@ package com.loopers.infrastructure.users; import com.loopers.domain.users.Users; -import com.loopers.domain.users.vo.LoginId; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; @@ -9,4 +8,7 @@ public interface UserJpaRepository extends JpaRepository { // loginId 필드는 @Embedded LoginId 타입 → "loginId.value" 경로로 조회 Optional findByLoginIdValue(String value); -} \ No newline at end of file + + boolean existsByLoginId(String loginId); + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/users/UserV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/users/UserV1ApiSpec.java index 8061ad958..5df7f3e08 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/users/UserV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/users/UserV1ApiSpec.java @@ -22,7 +22,7 @@ ApiResponse signUp( summary = "내 정보 조회", description = "로그인 ID로 내 회원 정보를 조회한다" ) - ApiResponse getMyInfo( + ApiResponse getMyInfo( @RequestHeader("X-Loopers-LoginId") String loginId, @RequestHeader("X-Loopers-LoginPw") String password ); @@ -41,7 +41,7 @@ ApiResponse changePassword( summary = "내 정보 조회", description = "로그인 ID로 내 회원 정보를 조회한다" ) - ApiResponse getMyLikes( + ApiResponse getMyLikes( @RequestHeader("X-Loopers-LoginId") String loginId, @RequestHeader("X-Loopers-LoginPw") String password ); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/users/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/users/UserV1Controller.java index ffeb93ce6..83255a64f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/users/UserV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/users/UserV1Controller.java @@ -4,7 +4,7 @@ import com.loopers.application.users.UserInfo; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.users.UserV1Dto.ChangePasswordRequest; -import com.loopers.interfaces.api.users.UserV1Dto.MemberInfoResponse; +import com.loopers.interfaces.api.users.UserV1Dto.UserInfoResponse; import com.loopers.interfaces.api.users.UserV1Dto.SignUpRequest; import com.loopers.interfaces.api.users.UserV1Dto.SignUpResponse; import lombok.RequiredArgsConstructor; @@ -40,16 +40,16 @@ public ApiResponse signUp(@RequestBody SignUpRequest request) { @GetMapping("/me") @Override - public ApiResponse getMyInfo( + public ApiResponse getMyInfo( @RequestHeader("X-Loopers-LoginId") String loginId, @RequestHeader("X-Loopers-LoginPw") String password ) { UserInfo info = userFacade.getMyInfo(loginId, password); - MemberInfoResponse response = MemberInfoResponse.from(info); + UserInfoResponse response = UserInfoResponse.from(info); return ApiResponse.success(response); } - @PatchMapping("/password") + @PatchMapping("/me/password") @Override public ApiResponse changePassword( @RequestHeader("X-Loopers-LoginId") String loginId, @@ -62,12 +62,12 @@ public ApiResponse changePassword( @GetMapping("me/likes") @Override - public ApiResponse getMyLikes( + public ApiResponse getMyLikes( @RequestHeader("X-Loopers-LoginId") String loginId, @RequestHeader("X-Loopers-LoginPw") String password ) { UserInfo info = userFacade.getMyInfo(loginId, password); - MemberInfoResponse response = MemberInfoResponse.from(info); + UserInfoResponse response = UserInfoResponse.from(info); return ApiResponse.success(response); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/users/UserV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/users/UserV1Dto.java index a2af05d71..36a18ae6d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/users/UserV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/users/UserV1Dto.java @@ -21,14 +21,14 @@ public static SignUpResponse from(UserInfo info) { } } - public record MemberInfoResponse( + public record UserInfoResponse( String loginId, String name, String birthDate, String email ) { - public static MemberInfoResponse from(UserInfo info) { - return new MemberInfoResponse( + public static UserInfoResponse from(UserInfo info) { + return new UserInfoResponse( info.loginId(), info.name(), info.birthDate(), diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UsersV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UsersV1ApiE2ETest.java index e220da823..2b0de1982 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UsersV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UsersV1ApiE2ETest.java @@ -23,7 +23,7 @@ @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class UsersV1ApiE2ETest { - private static final String ENDPOINT = "/api/v1/members"; + private static final String ENDPOINT = "/api/v1/users"; private final TestRestTemplate testRestTemplate; private final DatabaseCleanUp databaseCleanUp; From ff44ff5f875478688d1dabd0827520455745e596 Mon Sep 17 00:00:00 2001 From: katiekim17 Date: Tue, 24 Feb 2026 03:50:37 +0900 Subject: [PATCH 23/39] =?UTF-8?q?feature:=20=EC=A3=BC=EB=AC=B8=20=EA=B0=9C?= =?UTF-8?q?=EB=B0=9C=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/domain/order/Order.java | 50 +++++++++++- .../com/loopers/domain/order/OrderItem.java | 78 ++++++++++++++++++- .../loopers/domain/order/OrderItemStatus.java | 2 + .../loopers/domain/order/OrderRepository.java | 3 +- .../loopers/domain/order/OrderService.java | 48 ++++++++++++ .../com/loopers/domain/order/OrderStatus.java | 3 + .../order/OrderRepositoryImpl.java | 15 +++- .../interfaces/api/order/OrderController.java | 58 +++++++------- .../interfaces/api/order/OrderV1ApiSpec.java | 32 ++++---- 9 files changed, 235 insertions(+), 54 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java index 62b2aa794..e791858db 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -1,13 +1,55 @@ package com.loopers.domain.order; +import com.loopers.domain.BaseEntity; import com.loopers.domain.common.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import jakarta.persistence.AttributeOverride; import jakarta.persistence.Column; import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; -public class Order { +@Entity +@Table(name = "orders") +public class Order extends BaseEntity { - @Embedded - @AttributeOverride(name = "value", column = @Column(name = "total_amount")) - private Money totalAmount; + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private OrderStatus status; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "total_amount", nullable = false)) + private Money totalAmount; + + protected Order() {} + + public Order(Long memberId, Money totalAmount, OrderStatus status) { + if (memberId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "회원 ID는 비어있을 수 없습니다."); + } + if (totalAmount == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "총 금액은 비어있을 수 없습니다."); + } + this.memberId = memberId; + this.totalAmount = totalAmount; + this.status = status; + } + + public Long getMemberId() { + return memberId; + } + + public OrderStatus getStatus() { + return status; + } + + public Long getTotalAmount() { + return totalAmount.getValue(); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java index f5aa60ffe..3ff7415f9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java @@ -1,5 +1,81 @@ package com.loopers.domain.order; -public class OrderItem { +import com.loopers.domain.BaseEntity; +import com.loopers.domain.common.Quantity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.AttributeOverrides; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; +@Entity +@Table(name = "order_item") +public class OrderItem extends BaseEntity { + + @Column(name = "order_id", nullable = false) + private Long orderId; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private OrderItemStatus status; + + @Embedded + @AttributeOverrides({ + @AttributeOverride(name = "productName", column = @Column(name = "product_name", nullable = false, length = 200)), + @AttributeOverride(name = "price.value", column = @Column(name = "product_price", nullable = false)), + @AttributeOverride(name = "brandName", column = @Column(name = "brand_name", nullable = false, length = 100)) + }) + private ProductSnapshot snapshot; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "quantity", nullable = false)) + private Quantity quantity; + + protected OrderItem() {} + + public OrderItem(Long orderId, Long productId, OrderItemStatus status, + ProductSnapshot snapshot, Quantity quantity) { + if (orderId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 ID는 비어있을 수 없습니다."); + } + if (productId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID는 비어있을 수 없습니다."); + } + if (snapshot == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 스냅샷은 비어있을 수 없습니다."); + } + this.orderId = orderId; + this.productId = productId; + this.status = status; + this.snapshot = snapshot; + this.quantity = quantity; + } + + public Long getOrderId() { + return orderId; + } + + public Long getProductId() { + return productId; + } + + public OrderItemStatus getStatus() { + return status; + } + + public Long getQuantity() { + return quantity.getValue(); + } + + public ProductSnapshot getSnapshot() { + return snapshot; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemStatus.java index 0568d6824..01e96eb76 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemStatus.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemStatus.java @@ -1,4 +1,6 @@ package com.loopers.domain.order; public enum OrderItemStatus { + ORDERED, + CANCELLED } 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 c03f5a908..5a83a7f65 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,5 +1,6 @@ package com.loopers.domain.order; -public class OrderRepository { +public interface OrderRepository { + Order save(Order order); } 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 d7659b340..06ebbe95f 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,53 @@ package com.loopers.domain.order; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.common.Money; +import com.loopers.domain.common.Quantity; +import com.loopers.domain.product.Product; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component public class OrderService { + private final StockDeductionService stockDeductionService; + private final OrderRepository orderRepository; + private final OrderItemRepository orderItemRepository; + + @Transactional + public Order createOrder( + Long memberId, + List products, + Map brandMap, + Map deductionMap + ) { + stockDeductionService.deductAll(deductionMap); + + long totalAmount = products.stream() + .mapToLong(p -> p.getPrice() * deductionMap.get(p.getId()).getValue()) + .sum(); + + Order order = orderRepository.save(new Order(memberId, new Money(totalAmount), OrderStatus.CREATED)); + + List orderItems = products.stream() + .map(product -> { + Brand brand = brandMap.get(product.getBrandId()); + Quantity quantity = deductionMap.get(product.getId()); + ProductSnapshot snapshot = new ProductSnapshot( + product.getName(), + new Money(product.getPrice()), + brand.getName() + ); + return new OrderItem(order.getId(), product.getId(), OrderItemStatus.ORDERED, snapshot, quantity); + }) + .toList(); + + orderItemRepository.saveAll(orderItems); + + return order; + } } 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 index 43192dc7d..19793ee74 100644 --- 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 @@ -1,4 +1,7 @@ package com.loopers.domain.order; public enum OrderStatus { + CREATED, + CONFIRMED, + CANCELLED } 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 3d2730329..5ea7ca142 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,5 +1,18 @@ package com.loopers.infrastructure.order; -public class OrderRepositoryImpl { +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +@RequiredArgsConstructor +@Component +public class OrderRepositoryImpl implements OrderRepository { + + private final OrderJpaRepository orderJpaRepository; + + @Override + public Order save(Order order) { + return orderJpaRepository.save(order); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java index 55def36c8..9aa7e60c4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java @@ -1,7 +1,17 @@ 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 java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.PostMapping; +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.ResponseStatus; import org.springframework.web.bind.annotation.RestController; @RequiredArgsConstructor @@ -9,34 +19,22 @@ @RequestMapping("/api/v1/orders") public class OrderController implements OrderV1ApiSpec { -// @PostMapping -// @ResponseStatus(HttpStatus.CREATED) -// @Override -// public ApiResponse makeOrder(@RequestBody SignUpRequest request) { -// UserInfo info = userFacade.signupUser(request); -// UserV1Dto.SignUpResponse response = UserV1Dto.SignUpResponse.from(info); -// return ApiResponse.success(response); -// } -// -// @GetMapping("/startAt=2026-01-31&endAt=2026-02-10") -// @Override -// public ApiResponse getMyOrders( -// @RequestHeader("X-Loopers-LoginId") String loginId, -// @RequestHeader("X-Loopers-LoginPw") String password -// ) { -// UserInfo info = userFacade.getMyInfo(loginId, password); -// MemberInfoResponse response = MemberInfoResponse.from(info); -// return ApiResponse.success(response); -// } -// -// @GetMapping("/{orderId}") -// @Override -// public ApiResponse getOrder( -// @RequestHeader("X-Loopers-LoginId") String loginId, -// @RequestHeader("X-Loopers-LoginPw") String password -// ) { -// UserInfo info = userFacade.getMyInfo(loginId, password); -// MemberInfoResponse response = MemberInfoResponse.from(info); -// return ApiResponse.success(response); -// } + private final OrderFacade orderFacade; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @Override + public ApiResponse createOrder( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, + @RequestBody OrderV1Dto.CreateOrderRequest request + ) { + List commands = request.items().stream() + .map(item -> new OrderItemCommand(item.productId(), item.quantity())) + .toList(); + + OrderInfo info = orderFacade.createOrder(loginId, password, commands); + + return ApiResponse.success(OrderV1Dto.CreateOrderResponse.from(info)); + } } 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 bec82c801..f724ec314 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 @@ -1,22 +1,20 @@ 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.tags.Tag; +import org.springframework.web.bind.annotation.RequestHeader; + +@Tag(name = "Order V1 API", description = "주문 API") public interface OrderV1ApiSpec { -// @Operation( -// summary = "예시 조회", -// description = "ID로 예시를 조회합니다." -// ) -// ApiResponse makeOrder( -// @Schema(name = "예시 ID", description = "조회할 예시의 ID") -// Long exampleId -// ); -// -// @Operation( -// summary = "예시 조회", -// description = "ID로 예시를 조회합니다." -// ) -// ApiResponse getMyOrders( -// @Schema(name = "예시 ID", description = "조회할 예시의 ID") -// Long exampleId -// ); + @Operation( + summary = "주문 생성", + description = "상품 목록을 받아 주문을 생성한다." + ) + ApiResponse createOrder( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, + OrderV1Dto.CreateOrderRequest request + ); } From cb2031b944c5fe32365e25f583498552226f2ea9 Mon Sep 17 00:00:00 2001 From: katiekim17 Date: Tue, 24 Feb 2026 03:51:16 +0900 Subject: [PATCH 24/39] =?UTF-8?q?feature:=20Common/VO=20Money,=20Quantity?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/domain/common/Money.java | 43 ++++++++++++++----- .../com/loopers/domain/common/Quantity.java | 41 +++++++++++++++++- 2 files changed, 73 insertions(+), 11 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java b/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java index bf6a7d7f7..3a9118b30 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java @@ -4,18 +4,41 @@ import com.loopers.support.error.ErrorType; import jakarta.persistence.Column; import jakarta.persistence.Embeddable; +import java.util.Objects; @Embeddable -public final class Money { - @Column(name = "value", nullable = false) // 엔티티별 컬럼명은 @AttributeOverrides로 재정의 - private Long value; +public class Money { - private Money() {} + @Column(name = "value", nullable = false) + private Long value; - public Money(Long value) { - if (value == null || value < 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "금액은 0 이상이어야 합니다."); + protected Money() {} + + public Money(Long value) { + if (value == null || value < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "금액은 0 이상이어야 합니다."); + } + this.value = value; + } + + public Long getValue() { + return value; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Money)) return false; + return Objects.equals(value, ((Money) o).value); + } + + @Override + public int hashCode() { + return Objects.hash(value); + } + + @Override + public String toString() { + return String.valueOf(value); } - this.value = value; - } -} +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/common/Quantity.java b/apps/commerce-api/src/main/java/com/loopers/domain/common/Quantity.java index 8217fbfa1..759b616dc 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/common/Quantity.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/common/Quantity.java @@ -1,5 +1,44 @@ package com.loopers.domain.common; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import java.util.Objects; + +@Embeddable public class Quantity { -} + @Column(name = "quantity", nullable = false) + private Long value; + + protected Quantity() {} + + public Quantity(Long value) { + if (value == null || value <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "수량은 1 이상이어야 합니다."); + } + this.value = value; + } + + public Long getValue() { + return value; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Quantity)) return false; + return Objects.equals(value, ((Quantity) o).value); + } + + @Override + public int hashCode() { + return Objects.hash(value); + } + + @Override + public String toString() { + return String.valueOf(value); + } +} \ No newline at end of file From d4a005952730809fb8b9b47c607185c2f5f16d11 Mon Sep 17 00:00:00 2001 From: katiekim17 Date: Tue, 24 Feb 2026 03:51:39 +0900 Subject: [PATCH 25/39] =?UTF-8?q?feature:=20=EB=B8=8C=EB=9E=9C=EB=93=9C,?= =?UTF-8?q?=20=EC=83=81=ED=92=88=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../product/ProductDetailInfo.java | 43 ++++++++++ .../application/product/ProductFacade.java | 24 +++++- .../application/product/ProductListInfo.java | 29 +++++++ .../domain/order/StockDeductionService.java | 29 +++++++ .../com/loopers/domain/product/Product.java | 70 +++++++++++++++- .../loopers/domain/product/ProductImage.java | 39 ++++++++- .../product/ProductImageRepository.java | 9 +- .../loopers/domain/product/ProductOption.java | 69 ++++++++++++++- .../product/ProductOptionRepository.java | 11 ++- .../domain/product/ProductRepository.java | 12 ++- .../domain/product/ProductService.java | 84 ++++++++++++++++++- .../domain/product/ProductSortType.java | 16 +++- .../product/ProductImageRepositoryImpl.java | 23 ++++- .../product/ProductOptionRepositoryImpl.java | 28 ++++++- .../product/ProductRepositoryImpl.java | 39 ++++++++- .../interfaces/api/brand/BrandController.java | 4 - .../interfaces/api/product/ProductV1Dto.java | 80 ++++++++++++++++++ .../api/product/ProductsController.java | 17 ---- .../api/product/ProductsV1ApiSpec.java | 24 +++++- .../api/product/ProductsV1Controller.java | 42 ++++++++++ 20 files changed, 647 insertions(+), 45 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductListInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductsController.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductsV1Controller.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java new file mode 100644 index 000000000..058a37271 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java @@ -0,0 +1,43 @@ +package com.loopers.application.product; + +import com.loopers.application.brand.BrandInfo; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductImage; +import com.loopers.domain.product.ProductOption; +import java.time.ZonedDateTime; +import java.util.List; + +public record ProductDetailInfo( + Long productId, + String name, + String description, + BrandInfo brand, + List imageUrls, + List options, + long likeCount, + ZonedDateTime createdAt +) { + + public record OptionInfo(Long optionId, String name, Long price, Long stockQuantity, boolean isAvailable) {} + + public static ProductDetailInfo from( + Product product, + Brand brand, + List options, + List images + ) { + return new ProductDetailInfo( + product.getId(), + product.getName(), + product.getDescription(), + BrandInfo.from(brand), + images.stream().map(ProductImage::getImageUrl).toList(), + options.stream() + .map(o -> new OptionInfo(o.getId(), o.getName(), o.getPrice(), o.getStockQuantity(), o.isAvailable())) + .toList(), + 0L, + product.getCreatedAt() + ); + } +} \ No newline at end of file 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 9e0dd634a..2938f64d4 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,27 @@ package com.loopers.application.product; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.product.ProductService.ProductDetail; +import com.loopers.domain.product.ProductSortType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component public class ProductFacade { + private final ProductService productService; + + public ProductDetailInfo getProductDetail(Long productId) { + ProductDetail detail = productService.getProductDetail(productId); + return ProductDetailInfo.from(detail.product(), detail.brand(), detail.options(), detail.images()); + } - // Product + Option + Image + Like 조율 -} + public Page getProductList(Long brandId, String sort, int page, int size) { + ProductSortType sortType = ProductSortType.from(sort); + return productService.getProductList(brandId, sortType, PageRequest.of(page, size)) + .map(ProductListInfo::from); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductListInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductListInfo.java new file mode 100644 index 000000000..1ee3792a9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductListInfo.java @@ -0,0 +1,29 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.ProductService.ProductListItem; +import java.time.ZonedDateTime; + +public record ProductListInfo( + Long productId, + String name, + Long brandId, + String brandName, + String thumbnailImageUrl, + Long minPrice, + long likeCount, + ZonedDateTime createdAt +) { + + public static ProductListInfo from(ProductListItem item) { + return new ProductListInfo( + item.product().getId(), + item.product().getName(), + item.brand().getId(), + item.brand().getName(), + item.product().getThumbnailImageUrl(), + item.minPrice(), + 0L, + item.product().getCreatedAt() + ); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/StockDeductionService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/StockDeductionService.java index 65b343354..48bd32ac3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/StockDeductionService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/StockDeductionService.java @@ -1,5 +1,34 @@ package com.loopers.domain.order; +import com.loopers.domain.common.Quantity; +import com.loopers.domain.stock.Stock; +import com.loopers.domain.stock.StockRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component public class StockDeductionService { + private final StockRepository stockRepository; + + @Transactional + public void deductAll(Map deductionMap) { + deductionMap.entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .forEach(entry -> { + Long productId = entry.getKey(); + Quantity quantity = entry.getValue(); + + Stock stock = stockRepository.findByProductIdWithLock(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + "재고를 찾을 수 없습니다. [productId=" + productId + "]")); + + stock.deduct(quantity); + }); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java index b58d135b6..900122d4e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -1,13 +1,75 @@ package com.loopers.domain.product; +import com.loopers.domain.BaseEntity; import com.loopers.domain.common.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import jakarta.persistence.AttributeOverride; import jakarta.persistence.Column; import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; -public class Product { +@Entity +@Table(name = "product") +public class Product extends BaseEntity { - @Embedded - @AttributeOverride(name = "value", column = @Column(name = "price")) - private Money price; + @Column(name = "brand_id", nullable = false) + private Long brandId; + + @Column(name = "name", nullable = false, length = 200) + private String name; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "price", nullable = false)) + private Money price; + + @Column(name = "description") + private String description; + + @Column(name = "thumbnail_image_url", length = 500) + private String thumbnailImageUrl; + + protected Product() {} + + public Product(Long brandId, String name, Money price, String description) { + this(brandId, name, price, description, null); + } + + public Product(Long brandId, String name, Money price, String description, String thumbnailImageUrl) { + if (brandId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 ID는 비어있을 수 없습니다."); + } + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품명은 비어있을 수 없습니다."); + } + if (price == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 비어있을 수 없습니다."); + } + this.brandId = brandId; + this.name = name; + this.price = price; + this.description = description; + this.thumbnailImageUrl = thumbnailImageUrl; + } + + public Long getBrandId() { + return brandId; + } + + public String getName() { + return name; + } + + public Long getPrice() { + return price.getValue(); + } + + public String getDescription() { + return description; + } + + public String getThumbnailImageUrl() { + return thumbnailImageUrl; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductImage.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductImage.java index d3cf3552d..bbb38542a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductImage.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductImage.java @@ -1,5 +1,40 @@ package com.loopers.domain.product; -public class ProductImage { +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; -} +@Entity +@Table(name = "product_image") +public class ProductImage extends BaseEntity { + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "image_url", nullable = false, length = 500) + private String imageUrl; + + protected ProductImage() {} + + public ProductImage(Long productId, String imageUrl) { + if (productId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID는 비어있을 수 없습니다."); + } + if (imageUrl == null || imageUrl.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이미지 URL은 비어있을 수 없습니다."); + } + this.productId = productId; + this.imageUrl = imageUrl; + } + + public Long getProductId() { + return productId; + } + + public String getImageUrl() { + return imageUrl; + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductImageRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductImageRepository.java index 21d8c2ea8..8d78b1d5d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductImageRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductImageRepository.java @@ -1,5 +1,10 @@ package com.loopers.domain.product; -public class ProductImageRepository { +import java.util.List; -} +public interface ProductImageRepository { + + ProductImage save(ProductImage productImage); + + List findAllByProductId(Long productId); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOption.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOption.java index 0bed739cf..3b29c9ce6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOption.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOption.java @@ -1,5 +1,70 @@ package com.loopers.domain.product; -public class ProductOption { +import com.loopers.domain.BaseEntity; +import com.loopers.domain.common.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; -} +@Entity +@Table(name = "product_option") +public class ProductOption extends BaseEntity { + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "name", nullable = false, length = 200) + private String name; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "price", nullable = false)) + private Money price; + + @Column(name = "stock_quantity", nullable = false) + private Long stockQuantity; + + protected ProductOption() {} + + public ProductOption(Long productId, String name, Money price, Long stockQuantity) { + if (productId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID는 비어있을 수 없습니다."); + } + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "옵션명은 비어있을 수 없습니다."); + } + if (price == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 비어있을 수 없습니다."); + } + if (stockQuantity == null || stockQuantity < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고 수량은 0 이상이어야 합니다."); + } + this.productId = productId; + this.name = name; + this.price = price; + this.stockQuantity = stockQuantity; + } + + public boolean isAvailable() { + return stockQuantity > 0; + } + + public Long getProductId() { + return productId; + } + + public String getName() { + return name; + } + + public Long getPrice() { + return price.getValue(); + } + + public Long getStockQuantity() { + return stockQuantity; + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOptionRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOptionRepository.java index 195c068b7..17761c6c3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOptionRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOptionRepository.java @@ -1,5 +1,12 @@ package com.loopers.domain.product; -public class ProductOptionRepository { +import java.util.List; -} +public interface ProductOptionRepository { + + ProductOption save(ProductOption productOption); + + List findAllByProductId(Long productId); + + List findAllByProductIds(List productIds); +} \ No newline at end of file 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 79ec6c73c..dc96a4eee 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,5 +1,15 @@ package com.loopers.domain.product; -public class ProductRepository { +import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +public interface ProductRepository { + + Optional findById(Long id); + + List findAllByIds(List ids); + + Page findAll(Long brandId, ProductSortType sortType, 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 11284f650..da2dcdc17 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,87 @@ package com.loopers.domain.product; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +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 ProductService { -} + private final ProductRepository productRepository; + private final BrandRepository brandRepository; + private final ProductOptionRepository productOptionRepository; + private final ProductImageRepository productImageRepository; + + @Transactional(readOnly = true) + public List getProducts(List productIds) { + List products = productRepository.findAllByIds(productIds); + if (products.size() != productIds.size()) { + throw new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품이 포함되어 있습니다."); + } + return products; + } + + @Transactional(readOnly = true) + public Product getProduct(Long productId) { + return productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다.")); + } + + @Transactional(readOnly = true) + public List getBrands(List brandIds) { + List brands = brandRepository.findAllByIds(brandIds); + if (brands.size() != brandIds.size()) { + throw new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 브랜드가 포함되어 있습니다."); + } + return brands; + } + + @Transactional(readOnly = true) + public ProductDetail getProductDetail(Long productId) { + Product product = productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다.")); + Brand brand = brandRepository.findById(product.getBrandId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 브랜드입니다.")); + List options = productOptionRepository.findAllByProductId(productId); + List images = productImageRepository.findAllByProductId(productId); + return new ProductDetail(product, brand, options, images); + } + + @Transactional(readOnly = true) + public Page getProductList(Long brandId, ProductSortType sortType, Pageable pageable) { + Page products = productRepository.findAll(brandId, sortType, pageable); + List productIds = products.getContent().stream().map(Product::getId).toList(); + List brandIds = products.getContent().stream().map(Product::getBrandId).distinct().toList(); + + Map brandMap = brandRepository.findAllByIds(brandIds).stream() + .collect(Collectors.toMap(Brand::getId, b -> b)); + Map minPriceMap = productOptionRepository.findAllByProductIds(productIds).stream() + .collect(Collectors.groupingBy( + ProductOption::getProductId, + Collectors.collectingAndThen( + Collectors.minBy(java.util.Comparator.comparingLong(ProductOption::getPrice)), + opt -> opt.map(ProductOption::getPrice).orElse(0L) + ) + )); + + return products.map(product -> new ProductListItem( + product, + brandMap.get(product.getBrandId()), + minPriceMap.getOrDefault(product.getId(), 0L) + )); + } + + public record ProductDetail(Product product, Brand brand, List options, List images) {} + + public record ProductListItem(Product product, Brand brand, Long minPrice) {} +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java index d23c5b54e..0fc76fa80 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java @@ -1,4 +1,18 @@ package com.loopers.domain.product; public enum ProductSortType { -} + LATEST, + PRICE_ASC, + LIKES_DESC; + + public static ProductSortType from(String value) { + if (value == null) { + return LATEST; + } + return switch (value.toLowerCase()) { + case "price_asc" -> PRICE_ASC; + case "likes_desc" -> LIKES_DESC; + default -> LATEST; + }; + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductImageRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductImageRepositoryImpl.java index e91190410..537010cce 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductImageRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductImageRepositoryImpl.java @@ -1,5 +1,24 @@ package com.loopers.infrastructure.product; -public class ProductImageRepositoryImpl { +import com.loopers.domain.product.ProductImage; +import com.loopers.domain.product.ProductImageRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; -} +@RequiredArgsConstructor +@Component +public class ProductImageRepositoryImpl implements ProductImageRepository { + + private final ProductImageJpaRepository productImageJpaRepository; + + @Override + public ProductImage save(ProductImage productImage) { + return productImageJpaRepository.save(productImage); + } + + @Override + public List findAllByProductId(Long productId) { + return productImageJpaRepository.findAllByProductId(productId); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductOptionRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductOptionRepositoryImpl.java index d5db2ab2b..baa17c72f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductOptionRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductOptionRepositoryImpl.java @@ -1,5 +1,29 @@ package com.loopers.infrastructure.product; -public class ProductOptionRepositoryImpl { +import com.loopers.domain.product.ProductOption; +import com.loopers.domain.product.ProductOptionRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; -} +@RequiredArgsConstructor +@Component +public class ProductOptionRepositoryImpl implements ProductOptionRepository { + + private final ProductOptionJpaRepository productOptionJpaRepository; + + @Override + public ProductOption save(ProductOption productOption) { + return productOptionJpaRepository.save(productOption); + } + + @Override + public List findAllByProductId(Long productId) { + return productOptionJpaRepository.findAllByProductId(productId); + } + + @Override + public List findAllByProductIds(List productIds) { + return productOptionJpaRepository.findAllByProductIdIn(productIds); + } +} \ No newline at end of file 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 3a619a660..d79f756a2 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,5 +1,40 @@ package com.loopers.infrastructure.product; -public class ProductRepositoryImpl { +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.ProductSortType; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Component; -} +@RequiredArgsConstructor +@Component +public class ProductRepositoryImpl implements ProductRepository { + + private final ProductJpaRepository productJpaRepository; + + @Override + public Optional findById(Long id) { + return productJpaRepository.findById(id); + } + + @Override + public List findAllByIds(List ids) { + return productJpaRepository.findAllByIdIn(ids); + } + + @Override + public Page findAll(Long brandId, ProductSortType sortType, Pageable pageable) { + Sort sort = switch (sortType) { + case PRICE_ASC -> Sort.by(Sort.Direction.ASC, "price.value"); + default -> Sort.by(Sort.Direction.DESC, "createdAt"); + }; + Pageable sortedPageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), sort); + return productJpaRepository.findAllByBrandIdFilter(brandId, sortedPageable); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java index 44fd9f4d7..cf1f0fe7a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java @@ -27,8 +27,4 @@ public ApiResponse getBrands( return ApiResponse.success(response); } - - // TODO /api/v1/products // 상품 목록 조회 - // TODO /api/v1/products/{productId} // 상품 정보 조회 - } 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..2fbaa7c36 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -0,0 +1,80 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductDetailInfo; +import com.loopers.application.product.ProductListInfo; +import java.time.ZonedDateTime; +import java.util.List; +import org.springframework.data.domain.Page; + +public class ProductV1Dto { + + public record ProductDetailResponse( + Long productId, + String name, + String description, + BrandSummary brand, + List imageUrls, + List options, + long likeCount, + ZonedDateTime createdAt + ) { + public static ProductDetailResponse from(ProductDetailInfo info) { + return new ProductDetailResponse( + info.productId(), + info.name(), + info.description(), + new BrandSummary(info.brand().brandId(), info.brand().name()), + info.imageUrls(), + info.options().stream() + .map(o -> new OptionResponse(o.optionId(), o.name(), o.price(), o.stockQuantity(), o.isAvailable())) + .toList(), + info.likeCount(), + info.createdAt() + ); + } + } + + public record BrandSummary(Long brandId, String name) {} + + public record OptionResponse(Long optionId, String name, Long price, Long stockQuantity, boolean isAvailable) {} + + public record ProductListItemResponse( + Long productId, + String name, + BrandSummary brand, + String thumbnailImageUrl, + Long minPrice, + long likeCount, + ZonedDateTime createdAt + ) { + public static ProductListItemResponse from(ProductListInfo info) { + return new ProductListItemResponse( + info.productId(), + info.name(), + new BrandSummary(info.brandId(), info.brandName()), + info.thumbnailImageUrl(), + info.minPrice(), + info.likeCount(), + info.createdAt() + ); + } + } + + public record PageResponse( + List content, + int page, + int size, + long totalElements, + int totalPages + ) { + public static PageResponse from(Page page) { + return new PageResponse<>( + page.getContent(), + page.getNumber(), + page.getSize(), + page.getTotalElements(), + page.getTotalPages() + ); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductsController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductsController.java deleted file mode 100644 index b0febe09b..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductsController.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.loopers.interfaces.api.product; - -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - - -@RequiredArgsConstructor -@RestController -@RequestMapping("/api/v1/Products") -public class ProductsController implements ProductsV1ApiSpec { - // /api/v1/products // 상품 목록 조회 - // /api/v1/products/{productId} // 상품 정보 조회 - // (POST) /api/v1/products/{productId}/likes // 상품 좋아요 등록 - // (DELETE) /api/v1/products/{productId}/likes // 상품 좋아요 취소 - -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductsV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductsV1ApiSpec.java index 0753303a1..0fe839996 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductsV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductsV1ApiSpec.java @@ -1,5 +1,27 @@ 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.tags.Tag; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "Products", description = "상품 API") public interface ProductsV1ApiSpec { -} + @Operation(summary = "상품 목록 조회", description = "브랜드 필터, 정렬 조건으로 상품 목록을 페이지네이션으로 조회합니다.") + @GetMapping("") + ApiResponse> getProductList( + @RequestParam(required = false) Long brandId, + @RequestParam(required = false, defaultValue = "latest") String sort, + @RequestParam(required = false, defaultValue = "0") int page, + @RequestParam(required = false, defaultValue = "20") int size + ); + + @Operation(summary = "상품 상세 조회", description = "상품 ID로 상품 상세 정보를 조회합니다.") + @GetMapping("/{productId}") + ApiResponse getProduct( + @PathVariable(value = "productId") Long productId + ); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductsV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductsV1Controller.java new file mode 100644 index 000000000..a3188a859 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductsV1Controller.java @@ -0,0 +1,42 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductFacade; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/products") +public class ProductsV1Controller implements ProductsV1ApiSpec { + + private final ProductFacade productFacade; + + @GetMapping("") + @Override + public ApiResponse> getProductList( + @RequestParam(required = false) Long brandId, + @RequestParam(required = false, defaultValue = "latest") String sort, + @RequestParam(required = false, defaultValue = "0") int page, + @RequestParam(required = false, defaultValue = "20") int size + ) { + Page responsePage = productFacade.getProductList(brandId, sort, page, size) + .map(ProductV1Dto.ProductListItemResponse::from); + return ApiResponse.success(ProductV1Dto.PageResponse.from(responsePage)); + } + + @GetMapping("/{productId}") + @Override + public ApiResponse getProduct( + @PathVariable(value = "productId") Long productId + ) { + return ApiResponse.success( + ProductV1Dto.ProductDetailResponse.from(productFacade.getProductDetail(productId)) + ); + } +} \ No newline at end of file From 9b8e853eb2a019760d4301a9976d00a4438edb48 Mon Sep 17 00:00:00 2001 From: katiekim17 Date: Tue, 24 Feb 2026 03:52:06 +0900 Subject: [PATCH 26/39] fix: settings.local.json --- .claude/settings.local.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 4cd838770..7a824d72d 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -2,7 +2,8 @@ "permissions": { "allow": [ "Bash(./gradlew test:*)", - "Bash(./gradlew :apps:commerce-api:test:*)" + "Bash(./gradlew :apps:commerce-api:test:*)", + "Bash(./gradlew :apps:commerce-api:compileJava:*)" ] } } From 009e3192cadbc162ce35c214025ce30c67566622 Mon Sep 17 00:00:00 2001 From: katiekim17 Date: Tue, 24 Feb 2026 03:53:25 +0900 Subject: [PATCH 27/39] =?UTF-8?q?feature:=20=EB=B8=8C=EB=9E=9C=EB=93=9C=20?= =?UTF-8?q?VO=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../brand/BrandJpaRepository.java | 10 +++ .../com/loopers/domain/brand/BrandTest.java | 64 +++++++++++++++++++ http/commerce-api/brand-v1.http | 5 ++ 3 files changed, 79 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java create mode 100644 http/commerce-api/brand-v1.http 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..aeb729805 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -0,0 +1,10 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.Brand; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BrandJpaRepository extends JpaRepository { + + List findAllByIdIn(List ids); +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java new file mode 100644 index 000000000..df9ad452c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java @@ -0,0 +1,64 @@ +package com.loopers.domain.brand; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +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; + +class BrandTest { + + @DisplayName("브랜드를 생성할 때, ") + @Nested + class Create { + + @DisplayName("유효한 이름이면, 정상적으로 생성된다.") + @Test + void createsBrand_whenNameIsValid() { + // arrange & act + Brand brand = new Brand("나이키"); + + // assert + assertThat(brand.getName()).isEqualTo("나이키"); + } + + @DisplayName("이름이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameIsNull() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new Brand(null) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("이름이 빈 문자열이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameIsEmpty() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new Brand("") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("이름이 공백으로만 이루어지면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameIsBlank() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new Brand(" ") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} \ No newline at end of file diff --git a/http/commerce-api/brand-v1.http b/http/commerce-api/brand-v1.http new file mode 100644 index 000000000..33cd8ef14 --- /dev/null +++ b/http/commerce-api/brand-v1.http @@ -0,0 +1,5 @@ +### 브랜드 정보 조회 (성공) +GET {{commerce-api}}/api/v1/brands/1 + +### 브랜드 정보 조회 (존재하지 않는 ID → 404) +GET {{commerce-api}}/api/v1/brands/999999 From 9fdcc723cab62dda876c83ae0948cbc95f0f8020 Mon Sep 17 00:00:00 2001 From: katiekim17 Date: Tue, 24 Feb 2026 03:54:04 +0900 Subject: [PATCH 28/39] =?UTF-8?q?feature:=20=EC=A3=BC=EB=AC=B8=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/order/OrderFacade.java | 43 +++++++++++++++++ .../loopers/application/order/OrderInfo.java | 17 +++++++ .../application/order/OrderItemCommand.java | 4 ++ .../domain/order/OrderItemRepository.java | 8 ++++ .../order/OrderItemJpaRepository.java | 7 +++ .../order/OrderItemRepositoryImpl.java | 19 ++++++++ .../order/OrderJpaRepository.java | 7 +++ .../interfaces/api/order/OrderV1Dto.java | 33 +++++++++++++ http/commerce-api/order-v1.http | 48 +++++++++++++++++++ 9 files changed, 186 insertions(+) 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/domain/order/OrderItemRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.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/interfaces/api/order/OrderV1Dto.java create mode 100644 http/commerce-api/order-v1.http 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..f2554e3c0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -0,0 +1,43 @@ +package com.loopers.application.order; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.common.Quantity; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderService; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.users.UserService; +import com.loopers.domain.users.Users; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class OrderFacade { + + private final UserService userService; + private final ProductService productService; + private final OrderService orderService; + + public OrderInfo createOrder(String loginId, String password, List items) { + Users user = userService.authenticate(loginId, password); + + List productIds = items.stream().map(OrderItemCommand::productId).toList(); + List products = productService.getProducts(productIds); + + List brandIds = products.stream().map(Product::getBrandId).distinct().toList(); + List brands = productService.getBrands(brandIds); + + Map brandMap = brands.stream() + .collect(Collectors.toMap(Brand::getId, b -> b)); + Map deductionMap = items.stream() + .collect(Collectors.toMap(OrderItemCommand::productId, cmd -> new Quantity(cmd.quantity()))); + + Order order = orderService.createOrder(user.getId(), products, brandMap, deductionMap); + + 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..659832bf8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java @@ -0,0 +1,17 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderStatus; +import java.time.ZonedDateTime; + +public record OrderInfo(Long orderId, OrderStatus status, Long totalAmount, ZonedDateTime createdAt) { + + public static OrderInfo from(Order order) { + return new OrderInfo( + order.getId(), + order.getStatus(), + order.getTotalAmount(), + order.getCreatedAt() + ); + } +} 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..32ba098b1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemCommand.java @@ -0,0 +1,4 @@ +package com.loopers.application.order; + +public record OrderItemCommand(Long productId, Long quantity) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java new file mode 100644 index 000000000..6de57cb2a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java @@ -0,0 +1,8 @@ +package com.loopers.domain.order; + +import java.util.List; + +public interface OrderItemRepository { + + List saveAll(List orderItems); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java new file mode 100644 index 000000000..9d82b0259 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java @@ -0,0 +1,7 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderItem; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OrderItemJpaRepository extends JpaRepository { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java new file mode 100644 index 000000000..a1f9e17b2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderItem; +import com.loopers.domain.order.OrderItemRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class OrderItemRepositoryImpl implements OrderItemRepository { + + private final OrderItemJpaRepository orderItemJpaRepository; + + @Override + public List saveAll(List orderItems) { + return orderItemJpaRepository.saveAll(orderItems); + } +} 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..f2ee62050 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -0,0 +1,7 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.Order; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OrderJpaRepository extends JpaRepository { +} 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..0326961ff --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java @@ -0,0 +1,33 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderInfo; +import java.time.ZonedDateTime; +import java.util.List; + +public class OrderV1Dto { + + public record CreateOrderRequest( + List items + ) {} + + public record OrderItemRequest( + Long productId, + Long quantity + ) {} + + public record CreateOrderResponse( + Long orderId, + String status, + Long totalAmount, + ZonedDateTime createdAt + ) { + public static CreateOrderResponse from(OrderInfo info) { + return new CreateOrderResponse( + info.orderId(), + info.status().name(), + info.totalAmount(), + info.createdAt() + ); + } + } +} diff --git a/http/commerce-api/order-v1.http b/http/commerce-api/order-v1.http new file mode 100644 index 000000000..149676a13 --- /dev/null +++ b/http/commerce-api/order-v1.http @@ -0,0 +1,48 @@ +### 주문 생성 (성공) +POST {{commerce-api}}/api/v1/orders +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! +Content-Type: application/json + +{ + "items": [ + { "productId": 1, "quantity": 2 }, + { "productId": 2, "quantity": 3 } + ] +} + +### 주문 생성 (재고 부족 → 400) +POST {{commerce-api}}/api/v1/orders +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! +Content-Type: application/json + +{ + "items": [ + { "productId": 1, "quantity": 99999 } + ] +} + +### 주문 생성 (인증 실패 → 401) +POST {{commerce-api}}/api/v1/orders +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: WrongPassword! +Content-Type: application/json + +{ + "items": [ + { "productId": 1, "quantity": 1 } + ] +} + +### 주문 생성 (존재하지 않는 상품 → 404) +POST {{commerce-api}}/api/v1/orders +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! +Content-Type: application/json + +{ + "items": [ + { "productId": 999999, "quantity": 1 } + ] +} From 0f68769086b692e3c341b79603ddcc0aee66e145 Mon Sep 17 00:00:00 2001 From: katiekim17 Date: Tue, 24 Feb 2026 03:54:26 +0900 Subject: [PATCH 29/39] =?UTF-8?q?feature:=20=EC=A3=BC=EB=AC=B8=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/domain/order/OrderItemTest.java | 75 +++++++ .../order/OrderServiceIntegrationTest.java | 156 +++++++++++++ .../com/loopers/domain/order/OrderTest.java | 58 +++++ .../interfaces/api/OrderV1ApiE2ETest.java | 212 ++++++++++++++++++ 4 files changed, 501 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java new file mode 100644 index 000000000..6e4e78b57 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java @@ -0,0 +1,75 @@ +package com.loopers.domain.order; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.loopers.domain.common.Money; +import com.loopers.domain.common.Quantity; +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; + +class OrderItemTest { + + private static final ProductSnapshot VALID_SNAPSHOT = + new ProductSnapshot("나이키 신발", new Money(50000L), "나이키"); + + @DisplayName("주문 항목을 생성할 때, ") + @Nested + class Create { + + @DisplayName("유효한 정보가 주어지면, 정상적으로 생성된다.") + @Test + void createsOrderItem_whenValidInfoIsProvided() { + // arrange & act + OrderItem orderItem = new OrderItem(1L, 1L, OrderItemStatus.ORDERED, VALID_SNAPSHOT, new Quantity(2L)); + + // assert + assertAll( + () -> assertThat(orderItem.getOrderId()).isEqualTo(1L), + () -> assertThat(orderItem.getProductId()).isEqualTo(1L), + () -> assertThat(orderItem.getStatus()).isEqualTo(OrderItemStatus.ORDERED), + () -> assertThat(orderItem.getQuantity()).isEqualTo(2L) + ); + } + + @DisplayName("orderId가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenOrderIdIsNull() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new OrderItem(null, 1L, OrderItemStatus.ORDERED, VALID_SNAPSHOT, new Quantity(2L)) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("productId가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenProductIdIsNull() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new OrderItem(1L, null, OrderItemStatus.ORDERED, VALID_SNAPSHOT, new Quantity(2L)) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("snapshot이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenSnapshotIsNull() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new OrderItem(1L, 1L, OrderItemStatus.ORDERED, null, new Quantity(2L)) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java new file mode 100644 index 000000000..4518bb2c2 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java @@ -0,0 +1,156 @@ +package com.loopers.domain.order; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.common.Money; +import com.loopers.domain.common.Quantity; +import com.loopers.domain.product.Product; +import com.loopers.domain.stock.Stock; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.order.OrderItemJpaRepository; +import com.loopers.infrastructure.order.OrderJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.infrastructure.stock.StockJpaRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import java.util.List; +import java.util.Map; +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; + +@SpringBootTest +class OrderServiceIntegrationTest { + + @Autowired + private OrderService orderService; + + @Autowired + private BrandJpaRepository brandJpaRepository; + + @Autowired + private ProductJpaRepository productJpaRepository; + + @Autowired + private StockJpaRepository stockJpaRepository; + + @Autowired + private OrderJpaRepository orderJpaRepository; + + @Autowired + private OrderItemJpaRepository orderItemJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("주문을 생성할 때, ") + @Nested + class CreateOrder { + + @DisplayName("모든 재고가 충분하면, Order와 OrderItem이 생성되고 재고가 차감된다.") + @Test + void createsOrderAndItems_whenAllStocksAreSufficient() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + + Product productA = productJpaRepository.save(new Product(brand.getId(), "신발A", new Money(50000L), "설명A")); + Product productB = productJpaRepository.save(new Product(brand.getId(), "신발B", new Money(30000L), "설명B")); + + stockJpaRepository.save(new Stock(productA.getId(), 100L)); + stockJpaRepository.save(new Stock(productB.getId(), 50L)); + + Long memberId = 1L; + Map deductionMap = Map.of( + productA.getId(), new Quantity(2L), + productB.getId(), new Quantity(3L) + ); + Map brandMap = Map.of(brand.getId(), brand); + + // act + Order order = orderService.createOrder(memberId, List.of(productA, productB), brandMap, deductionMap); + + // assert — Order 검증 + assertAll( + () -> assertThat(order.getId()).isNotNull(), + () -> assertThat(order.getMemberId()).isEqualTo(memberId), + () -> assertThat(order.getStatus()).isEqualTo(OrderStatus.CREATED), + () -> assertThat(order.getTotalAmount()).isEqualTo(50000L * 2 + 30000L * 3) // 190000 + ); + + // assert — OrderItem 검증 + List orderItems = orderItemJpaRepository.findAll(); + assertThat(orderItems).hasSize(2); + assertThat(orderItems).allMatch(item -> item.getStatus() == OrderItemStatus.ORDERED); + + // assert — 재고 차감 검증 + assertThat(stockJpaRepository.findAll()) + .extracting(Stock::getQuantity) + .containsExactlyInAnyOrder(98L, 47L); // 100-2, 50-3 + } + + @DisplayName("하나라도 재고가 부족하면, BAD_REQUEST 예외가 발생하고 Order가 생성되지 않는다.") + @Test + void throwsBadRequest_whenAnyStockIsInsufficient() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + + Product productA = productJpaRepository.save(new Product(brand.getId(), "신발A", new Money(50000L), "설명A")); + Product productB = productJpaRepository.save(new Product(brand.getId(), "신발B", new Money(30000L), "설명B")); + + stockJpaRepository.save(new Stock(productA.getId(), 100L)); + stockJpaRepository.save(new Stock(productB.getId(), 5L)); // productB 재고 부족 + + Long memberId = 1L; + Map deductionMap = Map.of( + productA.getId(), new Quantity(2L), + productB.getId(), new Quantity(10L) // 5 < 10 → 부족 + ); + Map brandMap = Map.of(brand.getId(), brand); + + // act + CoreException result = assertThrows(CoreException.class, () -> + orderService.createOrder(memberId, List.of(productA, productB), brandMap, deductionMap) + ); + + // assert — 예외 타입 + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + + // assert — Order 미생성, 재고 원상복구 + assertThat(orderJpaRepository.count()).isZero(); + assertThat(stockJpaRepository.findAll()) + .extracting(Stock::getQuantity) + .containsExactlyInAnyOrder(100L, 5L); + } + + @DisplayName("totalAmount는 각 상품의 가격 × 수량의 합산이다.") + @Test + void calculatesTotalAmount_asSum_ofPriceTimesQuantity() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + + Product product = productJpaRepository.save(new Product(brand.getId(), "신발", new Money(25000L), "설명")); + stockJpaRepository.save(new Stock(product.getId(), 100L)); + + Map deductionMap = Map.of(product.getId(), new Quantity(4L)); + Map brandMap = Map.of(brand.getId(), brand); + + // act + Order order = orderService.createOrder(1L, List.of(product), brandMap, deductionMap); + + // assert — 25000 × 4 = 100000 + assertThat(order.getTotalAmount()).isEqualTo(100000L); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java new file mode 100644 index 000000000..83f71f982 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java @@ -0,0 +1,58 @@ +package com.loopers.domain.order; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.loopers.domain.common.Money; +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; + +class OrderTest { + + @DisplayName("주문을 생성할 때, ") + @Nested + class Create { + + @DisplayName("유효한 정보가 주어지면, 정상적으로 생성된다.") + @Test + void createsOrder_whenValidInfoIsProvided() { + // arrange & act + Order order = new Order(1L, new Money(100000L), OrderStatus.CREATED); + + // assert + assertAll( + () -> assertThat(order.getMemberId()).isEqualTo(1L), + () -> assertThat(order.getTotalAmount()).isEqualTo(100000L), + () -> assertThat(order.getStatus()).isEqualTo(OrderStatus.CREATED) + ); + } + + @DisplayName("memberId가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenMemberIdIsNull() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new Order(null, new Money(100000L), OrderStatus.CREATED) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("totalAmount가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenTotalAmountIsNull() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new Order(1L, null, OrderStatus.CREATED) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java new file mode 100644 index 000000000..ad0d6841f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java @@ -0,0 +1,212 @@ +package com.loopers.interfaces.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.common.Money; +import com.loopers.domain.product.Product; +import com.loopers.domain.stock.Stock; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.infrastructure.stock.StockJpaRepository; +import com.loopers.utils.DatabaseCleanUp; +import java.util.List; +import java.util.Map; +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; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class OrderV1ApiE2ETest { + + private static final String ENDPOINT = "/api/v1/orders"; + private static final String USERS_ENDPOINT = "/api/v1/users"; + private static final String LOGIN_ID = "testuser"; + private static final String PASSWORD = "Test1234!"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + private final BrandJpaRepository brandJpaRepository; + private final ProductJpaRepository productJpaRepository; + private final StockJpaRepository stockJpaRepository; + + @Autowired + public OrderV1ApiE2ETest( + TestRestTemplate testRestTemplate, + DatabaseCleanUp databaseCleanUp, + BrandJpaRepository brandJpaRepository, + ProductJpaRepository productJpaRepository, + StockJpaRepository stockJpaRepository + ) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + this.brandJpaRepository = brandJpaRepository; + this.productJpaRepository = productJpaRepository; + this.stockJpaRepository = stockJpaRepository; + } + + @BeforeEach + void setUp() { + Map signUpRequest = Map.of( + "loginId", LOGIN_ID, + "password", PASSWORD, + "name", "홍길동", + "birthDate", "19900101", + "email", "test@example.com" + ); + testRestTemplate.exchange( + USERS_ENDPOINT, + HttpMethod.POST, + new HttpEntity<>(signUpRequest), + new ParameterizedTypeReference>>() {} + ); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("POST /api/v1/orders (주문 생성)") + @Nested + class CreateOrder { + + @DisplayName("모든 재고가 충분하면, 201 Created와 주문 정보를 반환한다.") + @Test + void returnsCreated_whenAllStocksAreSufficient() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product productA = productJpaRepository.save(new Product(brand.getId(), "신발A", new Money(50000L), "설명A")); + Product productB = productJpaRepository.save(new Product(brand.getId(), "신발B", new Money(30000L), "설명B")); + stockJpaRepository.save(new Stock(productA.getId(), 100L)); + stockJpaRepository.save(new Stock(productB.getId(), 50L)); + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", LOGIN_ID); + headers.set("X-Loopers-LoginPw", PASSWORD); + headers.set("Content-Type", "application/json"); + + Map request = Map.of( + "items", List.of( + Map.of("productId", productA.getId(), "quantity", 2), + Map.of("productId", productB.getId(), "quantity", 3) + ) + ); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT, + HttpMethod.POST, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(response.getBody()).isNotNull(), + () -> { + Map data = response.getBody().data(); + assertThat(data).isNotNull(); + assertThat(data.get("orderId")).isNotNull(); + assertThat(data.get("status")).isEqualTo("CREATED"); + assertThat(((Number) data.get("totalAmount")).longValue()) + .isEqualTo(50000L * 2 + 30000L * 3); // 190000 + } + ); + } + + @DisplayName("재고가 부족한 상품이 있으면, 400 Bad Request를 반환한다.") + @Test + void returnsBadRequest_whenAnyStockIsInsufficient() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product = productJpaRepository.save(new Product(brand.getId(), "신발", new Money(50000L), "설명")); + stockJpaRepository.save(new Stock(product.getId(), 5L)); // 재고 5개 + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", LOGIN_ID); + headers.set("X-Loopers-LoginPw", PASSWORD); + headers.set("Content-Type", "application/json"); + + Map request = Map.of( + "items", List.of( + Map.of("productId", product.getId(), "quantity", 10) // 재고 초과 + ) + ); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT, + HttpMethod.POST, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("잘못된 비밀번호로 요청하면, 401 Unauthorized를 반환한다.") + @Test + void returnsUnauthorized_whenPasswordIsWrong() { + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", LOGIN_ID); + headers.set("X-Loopers-LoginPw", "WrongPass1!"); + headers.set("Content-Type", "application/json"); + + Map request = Map.of( + "items", List.of(Map.of("productId", 1L, "quantity", 1)) + ); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT, + HttpMethod.POST, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @DisplayName("존재하지 않는 상품 ID로 주문하면, 404 Not Found를 반환한다.") + @Test + void returnsNotFound_whenProductDoesNotExist() { + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", LOGIN_ID); + headers.set("X-Loopers-LoginPw", PASSWORD); + headers.set("Content-Type", "application/json"); + + Map request = Map.of( + "items", List.of(Map.of("productId", 999999L, "quantity", 1)) + ); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT, + HttpMethod.POST, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } +} From e7bcabcdf143b5c11bb71d75369658bcf48d4b81 Mon Sep 17 00:00:00 2001 From: katiekim17 Date: Tue, 24 Feb 2026 03:54:54 +0900 Subject: [PATCH 30/39] =?UTF-8?q?feature:=20=EC=83=81=ED=92=88=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/domain/order/ProductSnapshot.java | 50 +++++++++++++++++++ .../product/ProductImageJpaRepository.java | 10 ++++ .../product/ProductJpaRepository.java | 17 +++++++ .../product/ProductOptionJpaRepository.java | 12 +++++ 4 files changed, 89 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/ProductSnapshot.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductImageJpaRepository.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/ProductOptionJpaRepository.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/ProductSnapshot.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/ProductSnapshot.java new file mode 100644 index 000000000..713a1c137 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/ProductSnapshot.java @@ -0,0 +1,50 @@ +package com.loopers.domain.order; + +import com.loopers.domain.common.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.Embedded; + +@Embeddable +public class ProductSnapshot { + + @Column(name = "product_name", nullable = false, length = 200) + private String productName; + + @Embedded + private Money price; + + @Column(name = "brand_name", nullable = false, length = 100) + private String brandName; + + protected ProductSnapshot() {} + + public ProductSnapshot(String productName, Money price, String brandName) { + if (productName == null || productName.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품명은 비어있을 수 없습니다."); + } + if (price == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 비어있을 수 없습니다."); + } + if (brandName == null || brandName.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드명은 비어있을 수 없습니다."); + } + this.productName = productName; + this.price = price; + this.brandName = brandName; + } + + public String getProductName() { + return productName; + } + + public Long getProductPrice() { + return price.getValue(); + } + + public String getBrandName() { + return brandName; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductImageJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductImageJpaRepository.java new file mode 100644 index 000000000..02a3dfcc5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductImageJpaRepository.java @@ -0,0 +1,10 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductImage; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductImageJpaRepository extends JpaRepository { + + List findAllByProductId(Long productId); +} \ No newline at end of file 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..0fbd21343 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -0,0 +1,17 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.Product; +import java.util.List; +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; + +public interface ProductJpaRepository extends JpaRepository { + + List findAllByIdIn(List ids); + + @Query("SELECT p FROM Product p WHERE (:brandId IS NULL OR p.brandId = :brandId)") + Page findAllByBrandIdFilter(@Param("brandId") Long brandId, Pageable pageable); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductOptionJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductOptionJpaRepository.java new file mode 100644 index 000000000..7cec620b0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductOptionJpaRepository.java @@ -0,0 +1,12 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductOption; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductOptionJpaRepository extends JpaRepository { + + List findAllByProductId(Long productId); + + List findAllByProductIdIn(List productIds); +} \ No newline at end of file From ebc50ea30b0042393b503ef1a2178fb43d76ca94 Mon Sep 17 00:00:00 2001 From: katiekim17 Date: Tue, 24 Feb 2026 03:55:23 +0900 Subject: [PATCH 31/39] =?UTF-8?q?feature:=20=EC=83=81=ED=92=88=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EA=B8=B0=EB=8A=A5=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/domain/common/MoneyTest.java | 62 +++++ .../loopers/domain/common/QuantityTest.java | 64 +++++ .../domain/order/ProductSnapshotTest.java | 70 +++++ .../ProductServiceIntegrationTest.java | 109 ++++++++ .../loopers/domain/product/ProductTest.java | 83 ++++++ .../interfaces/api/ProductsV1ApiE2ETest.java | 261 ++++++++++++++++++ http/commerce-api/products-v1.http | 17 ++ 7 files changed, 666 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/common/MoneyTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/common/QuantityTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/ProductSnapshotTest.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/ProductTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductsV1ApiE2ETest.java create mode 100644 http/commerce-api/products-v1.http diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/common/MoneyTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/common/MoneyTest.java new file mode 100644 index 000000000..8be75a303 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/common/MoneyTest.java @@ -0,0 +1,62 @@ +package com.loopers.domain.common; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +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; + +class MoneyTest { + + @DisplayName("금액을 생성할 때, ") + @Nested + class Create { + + @DisplayName("양수 금액이면, 정상적으로 생성된다.") + @Test + void createsMoney_whenValueIsPositive() { + // arrange & act + Money money = new Money(1000L); + + // assert + assertThat(money.getValue()).isEqualTo(1000L); + } + + @DisplayName("0원이면, 정상적으로 생성된다.") + @Test + void createsMoney_whenValueIsZero() { + // arrange & act + Money money = new Money(0L); + + // assert + assertThat(money.getValue()).isEqualTo(0L); + } + + @DisplayName("음수이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueIsNegative() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new Money(-1L) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueIsNull() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new Money(null) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/common/QuantityTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/common/QuantityTest.java new file mode 100644 index 000000000..abfccead1 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/common/QuantityTest.java @@ -0,0 +1,64 @@ +package com.loopers.domain.common; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +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; + +class QuantityTest { + + @DisplayName("수량을 생성할 때, ") + @Nested + class Create { + + @DisplayName("양수이면, 정상적으로 생성된다.") + @Test + void createsQuantity_whenValueIsPositive() { + // arrange & act + Quantity quantity = new Quantity(5L); + + // assert + assertThat(quantity.getValue()).isEqualTo(5L); + } + + @DisplayName("0이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueIsZero() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new Quantity(0L) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("음수이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueIsNegative() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new Quantity(-1L) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueIsNull() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new Quantity(null) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/ProductSnapshotTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/ProductSnapshotTest.java new file mode 100644 index 000000000..e4a4ed380 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/ProductSnapshotTest.java @@ -0,0 +1,70 @@ +package com.loopers.domain.order; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.loopers.domain.common.Money; +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; + +class ProductSnapshotTest { + + @DisplayName("상품 스냅샷을 생성할 때, ") + @Nested + class Create { + + @DisplayName("유효한 정보가 주어지면, 정상적으로 생성된다.") + @Test + void createsProductSnapshot_whenValidInfoIsProvided() { + // arrange & act + ProductSnapshot snapshot = new ProductSnapshot("나이키 신발", new Money(50000L), "나이키"); + + // assert + assertAll( + () -> assertThat(snapshot.getProductName()).isEqualTo("나이키 신발"), + () -> assertThat(snapshot.getProductPrice()).isEqualTo(50000L), + () -> assertThat(snapshot.getBrandName()).isEqualTo("나이키") + ); + } + + @DisplayName("상품명이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenProductNameIsNull() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new ProductSnapshot(null, new Money(50000L), "나이키") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("가격이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPriceIsNull() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new ProductSnapshot("나이키 신발", null, "나이키") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("브랜드명이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenBrandNameIsNull() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new ProductSnapshot("나이키 신발", new Money(50000L), null) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} 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..92213a1f0 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java @@ -0,0 +1,109 @@ +package com.loopers.domain.product; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.common.Money; +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; +import java.util.List; +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; + +@SpringBootTest +class ProductServiceIntegrationTest { + + @Autowired + private ProductService productService; + + @Autowired + private ProductJpaRepository productJpaRepository; + + @Autowired + private BrandJpaRepository brandJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("상품을 조회할 때, ") + @Nested + class GetProducts { + + @DisplayName("존재하는 상품 ID 목록을 주면, 해당 상품 목록을 반환한다.") + @Test + void returnsProducts_whenValidIdsAreProvided() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product1 = productJpaRepository.save(new Product(brand.getId(), "신발A", new Money(50000L), "설명A")); + Product product2 = productJpaRepository.save(new Product(brand.getId(), "신발B", new Money(60000L), "설명B")); + + // act + List result = productService.getProducts(List.of(product1.getId(), product2.getId())); + + // assert + assertThat(result).hasSize(2); + } + + @DisplayName("존재하지 않는 상품 ID가 포함되면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenAnyIdDoesNotExist() { + // arrange + Long nonExistentId = 999L; + + // act + CoreException result = assertThrows(CoreException.class, () -> + productService.getProducts(List.of(nonExistentId)) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("브랜드를 조회할 때, ") + @Nested + class GetBrands { + + @DisplayName("존재하는 브랜드 ID 목록을 주면, 해당 브랜드 목록을 반환한다.") + @Test + void returnsBrands_whenValidIdsAreProvided() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + + // act + List result = productService.getBrands(List.of(brand.getId())); + + // assert + assertThat(result).hasSize(1); + assertThat(result.get(0).getName()).isEqualTo("나이키"); + } + + @DisplayName("존재하지 않는 브랜드 ID가 포함되면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenAnyBrandIdDoesNotExist() { + // arrange + Long nonExistentId = 999L; + + // act + CoreException result = assertThrows(CoreException.class, () -> + productService.getBrands(List.of(nonExistentId)) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java new file mode 100644 index 000000000..fac21fac1 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java @@ -0,0 +1,83 @@ +package com.loopers.domain.product; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.loopers.domain.common.Money; +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; + +class ProductTest { + + @DisplayName("상품을 생성할 때, ") + @Nested + class Create { + + @DisplayName("유효한 정보가 주어지면, 정상적으로 생성된다.") + @Test + void createsProduct_whenValidInfoIsProvided() { + // arrange & act + Product product = new Product(1L, "나이키 신발", new Money(50000L), "편한 신발"); + + // assert + assertAll( + () -> assertThat(product.getBrandId()).isEqualTo(1L), + () -> assertThat(product.getName()).isEqualTo("나이키 신발"), + () -> assertThat(product.getPrice()).isEqualTo(50000L), + () -> assertThat(product.getDescription()).isEqualTo("편한 신발") + ); + } + + @DisplayName("brandId가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenBrandIdIsNull() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new Product(null, "나이키 신발", new Money(50000L), "편한 신발") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("상품명이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameIsNull() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new Product(1L, null, new Money(50000L), "편한 신발") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("상품명이 공백이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameIsBlank() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new Product(1L, " ", new Money(50000L), "편한 신발") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("가격이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPriceIsNull() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new Product(1L, "나이키 신발", null, "편한 신발") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductsV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductsV1ApiE2ETest.java new file mode 100644 index 000000000..a6ffd6311 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductsV1ApiE2ETest.java @@ -0,0 +1,261 @@ +package com.loopers.interfaces.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.common.Money; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductImage; +import com.loopers.domain.product.ProductOption; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductImageJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.infrastructure.product.ProductOptionJpaRepository; +import com.loopers.utils.DatabaseCleanUp; +import java.util.List; +import java.util.Map; +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; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class ProductsV1ApiE2ETest { + + private static final String ENDPOINT = "/api/v1/products"; + + private final TestRestTemplate testRestTemplate; + private final BrandJpaRepository brandJpaRepository; + private final ProductJpaRepository productJpaRepository; + private final ProductOptionJpaRepository productOptionJpaRepository; + private final ProductImageJpaRepository productImageJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public ProductsV1ApiE2ETest( + TestRestTemplate testRestTemplate, + BrandJpaRepository brandJpaRepository, + ProductJpaRepository productJpaRepository, + ProductOptionJpaRepository productOptionJpaRepository, + ProductImageJpaRepository productImageJpaRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.brandJpaRepository = brandJpaRepository; + this.productJpaRepository = productJpaRepository; + this.productOptionJpaRepository = productOptionJpaRepository; + this.productImageJpaRepository = productImageJpaRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("GET /api/v1/products/{productId}") + @Nested + class GetProduct { + + @DisplayName("유효한 productId를 주면, productId, name, description, brand, imageUrls, options, likeCount, createdAt을 반환한다.") + @Test + void returnsProductDetail_whenValidProductIdIsProvided() { + // arrange + Brand brand = brandJpaRepository.save( + new Brand("나이키", "스포츠 브랜드", "https://example.com/nike-logo.png") + ); + Product product = productJpaRepository.save( + new Product(brand.getId(), "에어맥스 90", new Money(150000L), "클래식 러닝화") + ); + productOptionJpaRepository.save( + new ProductOption(product.getId(), "265mm", new Money(150000L), 10L) + ); + productImageJpaRepository.save( + new ProductImage(product.getId(), "https://example.com/airmax-main.png") + ); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "/" + product.getId(), + HttpMethod.GET, + new HttpEntity<>(null), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> { + Map data = response.getBody().data(); + assertThat(((Number) data.get("productId")).longValue()).isEqualTo(product.getId()); + assertThat(data.get("name")).isEqualTo("에어맥스 90"); + assertThat(data).containsKey("description"); + assertThat(data).containsKey("brand"); + assertThat(data).containsKey("imageUrls"); + assertThat(data).containsKey("options"); + assertThat(data).containsKey("likeCount"); + assertThat(data).containsKey("createdAt"); + Map brandData = (Map) data.get("brand"); + assertThat(((Number) brandData.get("brandId")).longValue()).isEqualTo(brand.getId()); + assertThat(brandData.get("name")).isEqualTo("나이키"); + List> imageUrls = (List>) data.get("imageUrls"); + assertThat(imageUrls).hasSize(1); + List> options = (List>) data.get("options"); + assertThat(options).hasSize(1); + } + ); + } + + @DisplayName("존재하지 않는 productId를 주면, 404 Not Found를 반환한다.") + @Test + void returnsNotFound_whenProductDoesNotExist() { + // arrange — DB에 아무 상품도 없음 + Long nonExistentId = 999999L; + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "/" + nonExistentId, + HttpMethod.GET, + new HttpEntity<>(null), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @DisplayName("GET /api/v1/products") + @Nested + class GetProductList { + + @DisplayName("조건 없이 조회하면, 전체 상품 목록을 페이지네이션 구조로 반환한다.") + @Test + void returnsPagedProductList_whenNoFilterProvided() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키", "스포츠 브랜드", "https://example.com/logo.png")); + Product product1 = productJpaRepository.save(new Product(brand.getId(), "에어맥스 90", new Money(150000L), "러닝화")); + Product product2 = productJpaRepository.save(new Product(brand.getId(), "조던 1", new Money(200000L), "농구화")); + productOptionJpaRepository.save(new ProductOption(product1.getId(), "265mm", new Money(150000L), 5L)); + productOptionJpaRepository.save(new ProductOption(product2.getId(), "270mm", new Money(200000L), 3L)); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT, + HttpMethod.GET, + new HttpEntity<>(null), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> { + Map data = response.getBody().data(); + assertThat(data).containsKey("content"); + assertThat(data).containsKey("page"); + assertThat(data).containsKey("size"); + assertThat(data).containsKey("totalElements"); + assertThat(data).containsKey("totalPages"); + List> content = (List>) data.get("content"); + assertThat(content).hasSize(2); + } + ); + } + + @DisplayName("brandId 필터를 주면, 해당 브랜드의 상품만 반환한다.") + @Test + void returnsFilteredProducts_whenBrandIdProvided() { + // arrange + Brand nike = brandJpaRepository.save(new Brand("나이키", "스포츠 브랜드", "https://example.com/nike.png")); + Brand adidas = brandJpaRepository.save(new Brand("아디다스", "스포츠 브랜드", "https://example.com/adidas.png")); + Product nikeProduct = productJpaRepository.save(new Product(nike.getId(), "에어맥스 90", new Money(150000L), "러닝화")); + productJpaRepository.save(new Product(adidas.getId(), "울트라부스트", new Money(180000L), "러닝화")); + productOptionJpaRepository.save(new ProductOption(nikeProduct.getId(), "265mm", new Money(150000L), 5L)); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "?brandId=" + nike.getId(), + HttpMethod.GET, + new HttpEntity<>(null), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> { + Map data = response.getBody().data(); + List> content = (List>) data.get("content"); + assertThat(content).hasSize(1); + assertThat(content.get(0).get("name")).isEqualTo("에어맥스 90"); + } + ); + } + + @DisplayName("sort=price_asc 파라미터를 주면, minPrice 오름차순으로 반환한다.") + @Test + void returnsProductsSortedByMinPriceAsc_whenSortParamProvided() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키", null, null)); + Product cheapProduct = productJpaRepository.save(new Product(brand.getId(), "저가 상품", new Money(50000L), null)); + Product expensiveProduct = productJpaRepository.save(new Product(brand.getId(), "고가 상품", new Money(200000L), null)); + productOptionJpaRepository.save(new ProductOption(cheapProduct.getId(), "S", new Money(50000L), 10L)); + productOptionJpaRepository.save(new ProductOption(expensiveProduct.getId(), "M", new Money(200000L), 10L)); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "?sort=price_asc", + HttpMethod.GET, + new HttpEntity<>(null), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> { + Map data = response.getBody().data(); + List> content = (List>) data.get("content"); + assertThat(content).hasSize(2); + long firstMinPrice = ((Number) content.get(0).get("minPrice")).longValue(); + long secondMinPrice = ((Number) content.get(1).get("minPrice")).longValue(); + assertThat(firstMinPrice).isLessThanOrEqualTo(secondMinPrice); + } + ); + } + + @DisplayName("상품이 없으면, 빈 content를 반환한다.") + @Test + void returnsEmptyContent_whenNoProductsExist() { + // arrange — DB에 아무 상품도 없음 + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT, + HttpMethod.GET, + new HttpEntity<>(null), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> { + Map data = response.getBody().data(); + List> content = (List>) data.get("content"); + assertThat(content).isEmpty(); + } + ); + } + } +} \ No newline at end of file diff --git a/http/commerce-api/products-v1.http b/http/commerce-api/products-v1.http new file mode 100644 index 000000000..283a117a3 --- /dev/null +++ b/http/commerce-api/products-v1.http @@ -0,0 +1,17 @@ +### 상품 목록 조회 (전체) +GET {{commerce-api}}/api/v1/products + +### 상품 목록 조회 (브랜드 필터) +GET {{commerce-api}}/api/v1/products?brandId=1 + +### 상품 목록 조회 (가격 오름차순) +GET {{commerce-api}}/api/v1/products?sort=price_asc + +### 상품 목록 조회 (브랜드 필터 + 정렬 + 페이지네이션) +GET {{commerce-api}}/api/v1/products?brandId=1&sort=price_asc&page=0&size=10 + +### 상품 상세 조회 (성공) +GET {{commerce-api}}/api/v1/products/1 + +### 상품 상세 조회 (존재하지 않는 ID → 404) +GET {{commerce-api}}/api/v1/products/999999 \ No newline at end of file From 607b383351162efec5cd4529888cf233036e8614 Mon Sep 17 00:00:00 2001 From: katiekim17 Date: Tue, 24 Feb 2026 03:55:43 +0900 Subject: [PATCH 32/39] =?UTF-8?q?feature:=20=EC=9E=AC=EA=B3=A0=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/domain/stock/Stock.java | 56 ++++++++ .../loopers/domain/stock/StockRepository.java | 10 ++ .../stock/StockJpaRepository.java | 16 +++ .../stock/StockRepositoryImpl.java | 24 ++++ .../StockDeductionServiceIntegrationTest.java | 98 +++++++++++++ .../com/loopers/domain/stock/StockTest.java | 134 ++++++++++++++++++ 6 files changed, 338 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/stock/Stock.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/stock/StockDeductionServiceIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/stock/StockTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/stock/Stock.java b/apps/commerce-api/src/main/java/com/loopers/domain/stock/Stock.java new file mode 100644 index 000000000..66738a084 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/stock/Stock.java @@ -0,0 +1,56 @@ +package com.loopers.domain.stock; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.common.Quantity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +@Entity +@Table( + name = "stock", + uniqueConstraints = @UniqueConstraint(name = "uk_stock_product_id", columnNames = "product_id") +) +public class Stock extends BaseEntity { + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "quantity", nullable = false) + private Long quantity; + + protected Stock() {} + + public Stock(Long productId, Long quantity) { + if (productId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID는 비어있을 수 없습니다."); + } + if (quantity == null || quantity < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고 수량은 0 이상이어야 합니다."); + } + this.productId = productId; + this.quantity = quantity; + } + + public Long getProductId() { + return productId; + } + + public Long getQuantity() { + return quantity; + } + + public boolean hasEnoughStock(Quantity amount) { + return this.quantity >= amount.getValue(); + } + + public void deduct(Quantity amount) { + if (!hasEnoughStock(amount)) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다. [productId=" + productId + "]"); + } + this.quantity -= amount.getValue(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java new file mode 100644 index 000000000..922bf84cb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.stock; + +import java.util.Optional; + +public interface StockRepository { + + Stock save(Stock stock); + + Optional findByProductIdWithLock(Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockJpaRepository.java new file mode 100644 index 000000000..b2b0d7788 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockJpaRepository.java @@ -0,0 +1,16 @@ +package com.loopers.infrastructure.stock; + +import com.loopers.domain.stock.Stock; +import jakarta.persistence.LockModeType; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface StockJpaRepository extends JpaRepository { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT s FROM Stock s WHERE s.productId = :productId") + Optional findByProductIdWithLock(@Param("productId") Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java new file mode 100644 index 000000000..9f9924730 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java @@ -0,0 +1,24 @@ +package com.loopers.infrastructure.stock; + +import com.loopers.domain.stock.Stock; +import com.loopers.domain.stock.StockRepository; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class StockRepositoryImpl implements StockRepository { + + private final StockJpaRepository stockJpaRepository; + + @Override + public Stock save(Stock stock) { + return stockJpaRepository.save(stock); + } + + @Override + public Optional findByProductIdWithLock(Long productId) { + return stockJpaRepository.findByProductIdWithLock(productId); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockDeductionServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockDeductionServiceIntegrationTest.java new file mode 100644 index 000000000..dfcf39be6 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockDeductionServiceIntegrationTest.java @@ -0,0 +1,98 @@ +package com.loopers.domain.stock; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.loopers.domain.common.Quantity; +import com.loopers.domain.order.StockDeductionService; +import com.loopers.infrastructure.stock.StockJpaRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import java.util.Map; +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; + +@SpringBootTest +class StockDeductionServiceIntegrationTest { + + @Autowired + private StockDeductionService stockDeductionService; + + @Autowired + private StockJpaRepository stockJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("재고를 차감할 때, ") + @Nested + class DeductAll { + + @DisplayName("모든 상품의 재고가 충분하면, 정상적으로 모두 차감된다.") + @Test + void deductsAll_whenAllStocksAreSufficient() { + // arrange + Stock stockA = stockJpaRepository.save(new Stock(1L, 100L)); + Stock stockB = stockJpaRepository.save(new Stock(2L, 50L)); + + // act + stockDeductionService.deductAll(Map.of( + stockA.getProductId(), new Quantity(30L), + stockB.getProductId(), new Quantity(20L) + )); + + // assert + assertThat(stockJpaRepository.findById(stockA.getId()).get().getQuantity()).isEqualTo(70L); + assertThat(stockJpaRepository.findById(stockB.getId()).get().getQuantity()).isEqualTo(30L); + } + + @DisplayName("하나라도 재고가 부족하면, BAD_REQUEST 예외가 발생하고 모든 차감이 롤백된다.") + @Test + void rollsBackAll_whenAnyStockIsInsufficient() { + // arrange — stockA 충분, stockB 부족 + Stock stockA = stockJpaRepository.save(new Stock(1L, 100L)); + Stock stockB = stockJpaRepository.save(new Stock(2L, 50L)); + + // act + CoreException result = assertThrows(CoreException.class, () -> + stockDeductionService.deductAll(Map.of( + stockA.getProductId(), new Quantity(80L), // stockA: 충분 + stockB.getProductId(), new Quantity(60L) // stockB: 부족 + )) + ); + + // assert — 예외 타입 확인 + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + // assert — stockA도 롤백되어 원래대로 (All-or-Nothing) + assertThat(stockJpaRepository.findById(stockA.getId()).get().getQuantity()).isEqualTo(100L); + } + + @DisplayName("productId가 역순으로 요청되어도, 오름차순으로 정렬되어 처리된다.") + @Test + void deductsInAscendingOrder_whenProductIdsAreInReverseOrder() { + // arrange — productId가 큰 것(2)을 먼저 Map에 넣어도 + Stock stockA = stockJpaRepository.save(new Stock(1L, 100L)); + Stock stockB = stockJpaRepository.save(new Stock(2L, 50L)); + + // act — 순서와 무관하게 정상 차감되어야 함 + stockDeductionService.deductAll(Map.of( + stockB.getProductId(), new Quantity(10L), // productId=2 먼저 + stockA.getProductId(), new Quantity(30L) // productId=1 나중 + )); + + // assert — 순서와 무관하게 모두 차감됨 + assertThat(stockJpaRepository.findById(stockA.getId()).get().getQuantity()).isEqualTo(70L); + assertThat(stockJpaRepository.findById(stockB.getId()).get().getQuantity()).isEqualTo(40L); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockTest.java new file mode 100644 index 000000000..fbf6d4c5d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockTest.java @@ -0,0 +1,134 @@ +package com.loopers.domain.stock; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.loopers.domain.common.Quantity; +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; + +class StockTest { + + @DisplayName("재고를 생성할 때, ") + @Nested + class Create { + + @DisplayName("유효한 정보가 주어지면, 정상적으로 생성된다.") + @Test + void createsStock_whenValidInfoIsProvided() { + // arrange & act + Stock stock = new Stock(1L, 100L); + + // assert + assertThat(stock.getQuantity()).isEqualTo(100L); + } + + @DisplayName("productId가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenProductIdIsNull() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new Stock(null, 100L) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("초기 수량이 음수이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenQuantityIsNegative() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new Stock(1L, -1L) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("재고를 확인할 때, ") + @Nested + class HasEnoughStock { + + @DisplayName("요청 수량보다 재고가 충분하면, true를 반환한다.") + @Test + void returnsTrue_whenStockIsSufficient() { + // arrange + Stock stock = new Stock(1L, 100L); + + // act & assert + assertThat(stock.hasEnoughStock(new Quantity(50L))).isTrue(); + } + + @DisplayName("요청 수량과 재고가 같으면, true를 반환한다.") + @Test + void returnsTrue_whenStockEqualsRequestedQuantity() { + // arrange + Stock stock = new Stock(1L, 100L); + + // act & assert + assertThat(stock.hasEnoughStock(new Quantity(100L))).isTrue(); + } + + @DisplayName("요청 수량보다 재고가 부족하면, false를 반환한다.") + @Test + void returnsFalse_whenStockIsInsufficient() { + // arrange + Stock stock = new Stock(1L, 100L); + + // act & assert + assertThat(stock.hasEnoughStock(new Quantity(101L))).isFalse(); + } + } + + @DisplayName("재고를 차감할 때, ") + @Nested + class Deduct { + + @DisplayName("재고가 충분하면, 수량이 차감된다.") + @Test + void deductsQuantity_whenStockIsSufficient() { + // arrange + Stock stock = new Stock(1L, 100L); + + // act + stock.deduct(new Quantity(30L)); + + // assert + assertThat(stock.getQuantity()).isEqualTo(70L); + } + + @DisplayName("재고가 정확히 맞으면, 차감 후 0이 된다.") + @Test + void deductsToZero_whenStockMatchesRequestedQuantity() { + // arrange + Stock stock = new Stock(1L, 100L); + + // act + stock.deduct(new Quantity(100L)); + + // assert + assertThat(stock.getQuantity()).isEqualTo(0L); + } + + @DisplayName("재고가 부족하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenStockIsInsufficient() { + // arrange + Stock stock = new Stock(1L, 100L); + + // act + CoreException result = assertThrows(CoreException.class, () -> + stock.deduct(new Quantity(101L)) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} From 5da7e476923f2dc4b2b3d00c4bb6f0b765cea7ca Mon Sep 17 00:00:00 2001 From: ksonepick-dev Date: Tue, 24 Feb 2026 19:16:02 +0900 Subject: [PATCH 33/39] =?UTF-8?q?FIX=20:=20ORDER=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=EC=97=90=EC=84=9C,=20COMMAND=EA=B0=80=20?= =?UTF-8?q?=EC=95=84=EC=A7=81=20=EB=B3=B4=EC=9D=BC=EB=9F=AC=20=ED=94=8C?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=ED=8A=B8=20=EC=9D=B4=EB=AF=80=EB=A1=9C=20?= =?UTF-8?q?=EB=8B=A4=EB=A5=B8=20=EA=B8=B0=EB=8A=A5=EB=93=A4=EA=B3=BC=20?= =?UTF-8?q?=ED=98=95=ED=83=9C=EA=B0=80=20=EB=8F=99=EC=9D=BC=ED=95=98?= =?UTF-8?q?=EA=B2=8C=20=EB=8B=A4=EC=8B=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/application/brand/BrandService.java | 4 ++++ .../java/com/loopers/application/order/OrderFacade.java | 7 ++++--- .../com/loopers/application/order/OrderItemCommand.java | 4 ---- .../com/loopers/interfaces/api/order/OrderController.java | 8 +------- 4 files changed, 9 insertions(+), 14 deletions(-) delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemCommand.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java index f618a0539..718f5232a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java @@ -14,6 +14,10 @@ public class BrandService { private final BrandRepository brandRepository; + public BrandService(BrandRepository brandRepository) { + this.brandRepository = brandRepository; + } + @Transactional(readOnly = true) public BrandInfo getBrandInfo(Long brandId) { Brand brand = brandRepository.findById(brandId) 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 f2554e3c0..0ad24bd23 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 @@ -8,6 +8,7 @@ import com.loopers.domain.product.ProductService; import com.loopers.domain.users.UserService; import com.loopers.domain.users.Users; +import com.loopers.interfaces.api.order.OrderV1Dto; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -22,10 +23,10 @@ public class OrderFacade { private final ProductService productService; private final OrderService orderService; - public OrderInfo createOrder(String loginId, String password, List items) { + public OrderInfo createOrder(String loginId, String password, List items) { Users user = userService.authenticate(loginId, password); - List productIds = items.stream().map(OrderItemCommand::productId).toList(); + List productIds = items.stream().map(OrderV1Dto.OrderItemRequest::productId).toList(); List products = productService.getProducts(productIds); List brandIds = products.stream().map(Product::getBrandId).distinct().toList(); @@ -34,7 +35,7 @@ public OrderInfo createOrder(String loginId, String password, List brandMap = brands.stream() .collect(Collectors.toMap(Brand::getId, b -> b)); Map deductionMap = items.stream() - .collect(Collectors.toMap(OrderItemCommand::productId, cmd -> new Quantity(cmd.quantity()))); + .collect(Collectors.toMap(OrderV1Dto.OrderItemRequest::productId, item -> new Quantity(item.quantity()))); Order order = orderService.createOrder(user.getId(), products, brandMap, deductionMap); 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 deleted file mode 100644 index 32ba098b1..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemCommand.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.loopers.application.order; - -public record OrderItemCommand(Long productId, Long quantity) { -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java index 9aa7e60c4..81c5e0a09 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java @@ -2,9 +2,7 @@ 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 java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.PostMapping; @@ -29,11 +27,7 @@ public ApiResponse createOrder( @RequestHeader("X-Loopers-LoginPw") String password, @RequestBody OrderV1Dto.CreateOrderRequest request ) { - List commands = request.items().stream() - .map(item -> new OrderItemCommand(item.productId(), item.quantity())) - .toList(); - - OrderInfo info = orderFacade.createOrder(loginId, password, commands); + OrderInfo info = orderFacade.createOrder(loginId, password, request.items()); return ApiResponse.success(OrderV1Dto.CreateOrderResponse.from(info)); } From fc935cd78c7cd007cab7318b319abfc0c4cc33cc Mon Sep 17 00:00:00 2001 From: katiekim17 Date: Wed, 25 Feb 2026 08:48:49 +0900 Subject: [PATCH 34/39] =?UTF-8?q?fix:=20=EB=8B=A8=EC=9D=BC=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8(brand)=EC=9D=BC=20=EA=B2=BD=EC=9A=B0,=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=EB=A5=BC=20=EC=97=86=EC=95=A0?= =?UTF-8?q?=EA=B3=A0=20=EC=96=B4=ED=94=8C=EB=A6=AC=EC=BC=80=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EB=B6=80=EB=B6=84=EC=97=90=20Facade=EB=A7=8C=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=EC=8B=9C=ED=82=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../brand/{BrandService.java => BrandFacade.java} | 4 ++-- .../com/loopers/interfaces/api/admin/AdminController.java | 7 +++---- .../com/loopers/interfaces/api/brand/BrandController.java | 6 +++--- 3 files changed, 8 insertions(+), 9 deletions(-) rename apps/commerce-api/src/main/java/com/loopers/application/brand/{BrandService.java => BrandFacade.java} (90%) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java similarity index 90% rename from apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java rename to apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java index 718f5232a..50f8bbefa 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -10,11 +10,11 @@ @RequiredArgsConstructor @Component -public class BrandService { +public class BrandFacade { private final BrandRepository brandRepository; - public BrandService(BrandRepository brandRepository) { + public BrandFacade(BrandRepository brandRepository) { this.brandRepository = brandRepository; } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminController.java index 84f85ae01..e4e40f96e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminController.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminController.java @@ -1,7 +1,7 @@ package com.loopers.interfaces.api.admin; +import com.loopers.application.brand.BrandFacade; import com.loopers.application.brand.BrandInfo; -import com.loopers.application.brand.BrandService; import com.loopers.application.product.ProductFacade; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.brand.BrandV1Dto; @@ -34,18 +34,17 @@ public class AdminController implements AdminV1ApiSpec { // (GET) /api-admin/v1/orders?startAt=2026-01-31&endAt=2026-02-10 // 유저의 주문 목록 조회 // (GET) /api-admin/v1/orders/{orderId} // 단일 주문 상세 조회 - private final BrandService brandService; + private final BrandFacade brandFacade; private final ProductFacade productFacade; - // (GET) /api-admin/v1/brands/{brandId} // 브랜드 상세 조회 @GetMapping("/brands/{brandId}") @Override public ApiResponse getBrands( @PathVariable(value = "brandId") Long brandId ) { - BrandInfo info = brandService.getBrandInfo(brandId); + BrandInfo info = brandFacade.getBrandInfo(brandId); BrandV1Dto.BrandResponse response = BrandV1Dto.BrandResponse.from(info); return ApiResponse.success(response); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java index cf1f0fe7a..0506ac89a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java @@ -1,7 +1,7 @@ package com.loopers.interfaces.api.brand; +import com.loopers.application.brand.BrandFacade; import com.loopers.application.brand.BrandInfo; -import com.loopers.application.brand.BrandService; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.brand.BrandV1Dto.BrandResponse; import lombok.RequiredArgsConstructor; @@ -15,14 +15,14 @@ @RequestMapping("/api/v1/brands") public class BrandController implements BrandV1ApiSpec { - private final BrandService brandService; + private final BrandFacade brandFacade; @GetMapping("/{brandId}") @Override public ApiResponse getBrands( @PathVariable(value = "brandId") Long brandId ) { - BrandInfo info = brandService.getBrandInfo(brandId); + BrandInfo info = brandFacade.getBrandInfo(brandId); BrandV1Dto.BrandResponse response = BrandV1Dto.BrandResponse.from(info); return ApiResponse.success(response); } From 9354b477d4fdb51546167849cfef14f789d78bcf Mon Sep 17 00:00:00 2001 From: katiekim17 Date: Thu, 26 Feb 2026 00:38:36 +0900 Subject: [PATCH 35/39] =?UTF-8?q?fix:=20=EC=96=B4=EB=93=9C=EB=AF=BC?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=B8=8C=EB=9E=9C=EB=93=9C,=20=EC=83=81?= =?UTF-8?q?=ED=92=88=20=EA=B4=80=EB=A0=A8=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/brand/BrandFacade.java | 7 +- .../application/product/ProductFacade.java | 7 +- .../java/com/loopers/domain/brand/Brand.java | 31 +++ .../loopers/domain/brand/BrandRepository.java | 5 + .../com/loopers/domain/product/Product.java | 39 ++++ .../domain/product/ProductRepository.java | 6 + .../domain/product/ProductService.java | 4 +- .../loopers/domain/product/ProductStatus.java | 2 +- .../brand/BrandJpaRepository.java | 9 + .../brand/BrandRepositoryImpl.java | 15 ++ .../product/ProductJpaRepository.java | 10 + .../product/ProductRepositoryImpl.java | 26 ++- .../interfaces/api/admin/AdminController.java | 216 +++++++++++++----- .../interfaces/api/admin/AdminV1ApiSpec.java | 93 ++++++-- .../com/loopers/support/error/ErrorType.java | 3 +- .../interfaces/api/BrandV1ApiE2ETest.java | 4 +- .../interfaces/api/ProductsV1ApiE2ETest.java | 14 +- 17 files changed, 394 insertions(+), 97 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 50f8bbefa..f4af314ab 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 @@ -14,14 +14,13 @@ public class BrandFacade { private final BrandRepository brandRepository; - public BrandFacade(BrandRepository brandRepository) { - this.brandRepository = brandRepository; - } - @Transactional(readOnly = true) public BrandInfo getBrandInfo(Long brandId) { Brand brand = brandRepository.findById(brandId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 브랜드입니다.")); + if (!brand.isActive()) { + throw new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 브랜드입니다."); + } return BrandInfo.from(brand); } } 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 2938f64d4..42edb1f82 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 @@ -3,6 +3,8 @@ import com.loopers.domain.product.ProductService; import com.loopers.domain.product.ProductService.ProductDetail; import com.loopers.domain.product.ProductSortType; +import com.loopers.domain.product.ProductStatus; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -12,6 +14,9 @@ @Component public class ProductFacade { + private static final List CUSTOMER_VISIBLE_STATUSES = + List.of(ProductStatus.ACTIVE, ProductStatus.OUT_OF_STOCK); + private final ProductService productService; public ProductDetailInfo getProductDetail(Long productId) { @@ -21,7 +26,7 @@ public ProductDetailInfo getProductDetail(Long productId) { public Page getProductList(Long brandId, String sort, int page, int size) { ProductSortType sortType = ProductSortType.from(sort); - return productService.getProductList(brandId, sortType, PageRequest.of(page, size)) + return productService.getProductList(brandId, sortType, CUSTOMER_VISIBLE_STATUSES, PageRequest.of(page, size)) .map(ProductListInfo::from); } } \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java index 0efb3b449..a82a5c920 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java @@ -5,6 +5,8 @@ import com.loopers.support.error.ErrorType; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.Table; @Entity @@ -20,6 +22,10 @@ public class Brand extends BaseEntity { @Column(name = "logo_image_url", length = 500) private String logoImageUrl; + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private BrandStatus status = BrandStatus.PENDING; + protected Brand() {} public Brand(String name) { @@ -35,6 +41,27 @@ public Brand(String name, String description, String logoImageUrl) { this.logoImageUrl = logoImageUrl; } + public void activate() { + this.status = BrandStatus.ACTIVE; + } + + public void deactivate() { + this.status = BrandStatus.INACTIVE; + } + + public boolean isActive() { + return this.status == BrandStatus.ACTIVE; + } + + public void updateInfo(String name, String description, String logoImageUrl) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드명은 비어있을 수 없습니다."); + } + this.name = name; + this.description = description; + this.logoImageUrl = logoImageUrl; + } + public String getName() { return name; } @@ -46,4 +73,8 @@ public String getDescription() { public String getLogoImageUrl() { return logoImageUrl; } + + public BrandStatus getStatus() { + return status; + } } 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 9ef5d6da9..4e55d6e5b 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 @@ -2,6 +2,8 @@ import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; public interface BrandRepository { @@ -10,4 +12,7 @@ public interface BrandRepository { Optional findById(Long id); List findAllByIds(List ids); + + // 어드민 브랜드 목록 조회: status가 null이면 전체, 아니면 해당 상태만 반환 + Page findAll(BrandStatus status, Pageable pageable); } \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java index 900122d4e..1d9e91c9c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -8,6 +8,8 @@ import jakarta.persistence.Column; import jakarta.persistence.Embedded; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.Table; @Entity @@ -30,6 +32,10 @@ public class Product extends BaseEntity { @Column(name = "thumbnail_image_url", length = 500) private String thumbnailImageUrl; + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private ProductStatus status = ProductStatus.PENDING; + protected Product() {} public Product(Long brandId, String name, Money price, String description) { @@ -53,6 +59,35 @@ public Product(Long brandId, String name, Money price, String description, Strin this.thumbnailImageUrl = thumbnailImageUrl; } + public void activate() { + this.status = ProductStatus.ACTIVE; + } + + public void deactivate() { + this.status = ProductStatus.INACTIVE; + } + + public void markOutOfStock() { + this.status = ProductStatus.OUT_OF_STOCK; + } + + public void updateInfo(Long brandId, String name, Money price, String description, String thumbnailImageUrl) { + if (brandId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 ID는 비어있을 수 없습니다."); + } + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품명은 비어있을 수 없습니다."); + } + if (price == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 비어있을 수 없습니다."); + } + this.brandId = brandId; + this.name = name; + this.price = price; + this.description = description; + this.thumbnailImageUrl = thumbnailImageUrl; + } + public Long getBrandId() { return brandId; } @@ -72,4 +107,8 @@ public String getDescription() { public String getThumbnailImageUrl() { return thumbnailImageUrl; } + + public ProductStatus getStatus() { + return status; + } } 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 dc96a4eee..9c1dd86e4 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 @@ -7,9 +7,15 @@ public interface ProductRepository { + Product save(Product product); + Optional findById(Long id); List findAllByIds(List ids); + List findAllByBrandId(Long brandId); + Page findAll(Long brandId, ProductSortType sortType, Pageable pageable); + + Page findAll(Long brandId, ProductSortType sortType, List statuses, 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 da2dcdc17..64714996c 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 @@ -58,8 +58,8 @@ public ProductDetail getProductDetail(Long productId) { } @Transactional(readOnly = true) - public Page getProductList(Long brandId, ProductSortType sortType, Pageable pageable) { - Page products = productRepository.findAll(brandId, sortType, pageable); + public Page getProductList(Long brandId, ProductSortType sortType, List statuses, Pageable pageable) { + Page products = productRepository.findAll(brandId, sortType, statuses, pageable); List productIds = products.getContent().stream().map(Product::getId).toList(); List brandIds = products.getContent().stream().map(Product::getBrandId).distinct().toList(); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductStatus.java index 408873b90..4f8ef1693 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductStatus.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductStatus.java @@ -1,5 +1,5 @@ package com.loopers.domain.product; public enum ProductStatus { - + PENDING, ACTIVE, INACTIVE, SCHEDULED, OUT_OF_STOCK } 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 index aeb729805..3cdf69f39 100644 --- 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 @@ -1,10 +1,19 @@ package com.loopers.infrastructure.brand; import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandStatus; import java.util.List; +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; public interface BrandJpaRepository extends JpaRepository { List findAllByIdIn(List ids); + + // status가 null이면 전체, 아니면 해당 상태만 반환 + @Query("SELECT b FROM Brand b WHERE (:status IS NULL OR b.status = :status)") + Page findAllByStatusFilter(@Param("status") BrandStatus status, Pageable pageable); } 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 6b22cbdb2..d39c5f546 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 @@ -2,9 +2,14 @@ import com.loopers.domain.brand.Brand; import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.brand.BrandStatus; import java.util.List; import java.util.Optional; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Component; @RequiredArgsConstructor @@ -27,4 +32,14 @@ public Optional findById(Long id) { public List findAllByIds(List ids) { return brandJpaRepository.findAllByIdIn(ids); } + + @Override + public Page findAll(BrandStatus status, Pageable pageable) { + // 기본 정렬: createdAt 내림차순 + Pageable sortedPageable = PageRequest.of( + pageable.getPageNumber(), pageable.getPageSize(), + Sort.by(Sort.Direction.DESC, "createdAt") + ); + return brandJpaRepository.findAllByStatusFilter(status, sortedPageable); + } } 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 0fbd21343..61e3b5352 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 @@ -1,6 +1,7 @@ package com.loopers.infrastructure.product; import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductStatus; import java.util.List; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -12,6 +13,15 @@ public interface ProductJpaRepository extends JpaRepository { List findAllByIdIn(List ids); + List findAllByBrandId(Long brandId); + @Query("SELECT p FROM Product p WHERE (:brandId IS NULL OR p.brandId = :brandId)") Page findAllByBrandIdFilter(@Param("brandId") Long brandId, Pageable pageable); + + @Query("SELECT p FROM Product p WHERE (:brandId IS NULL OR p.brandId = :brandId) AND p.status IN :statuses") + Page findAllByStatusFilter( + @Param("brandId") Long brandId, + @Param("statuses") List statuses, + Pageable pageable + ); } \ No newline at end of file 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 d79f756a2..7e5f6142d 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,6 +3,7 @@ import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; import com.loopers.domain.product.ProductSortType; +import com.loopers.domain.product.ProductStatus; import java.util.List; import java.util.Optional; import lombok.RequiredArgsConstructor; @@ -18,6 +19,11 @@ public class ProductRepositoryImpl implements ProductRepository { private final ProductJpaRepository productJpaRepository; + @Override + public Product save(Product product) { + return productJpaRepository.save(product); + } + @Override public Optional findById(Long id) { return productJpaRepository.findById(id); @@ -28,13 +34,27 @@ public List findAllByIds(List ids) { return productJpaRepository.findAllByIdIn(ids); } + @Override + public List findAllByBrandId(Long brandId) { + return productJpaRepository.findAllByBrandId(brandId); + } + @Override public Page findAll(Long brandId, ProductSortType sortType, Pageable pageable) { - Sort sort = switch (sortType) { + Pageable sortedPageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), resolveSort(sortType)); + return productJpaRepository.findAllByBrandIdFilter(brandId, sortedPageable); + } + + @Override + public Page findAll(Long brandId, ProductSortType sortType, List statuses, Pageable pageable) { + Pageable sortedPageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), resolveSort(sortType)); + return productJpaRepository.findAllByStatusFilter(brandId, statuses, sortedPageable); + } + + private Sort resolveSort(ProductSortType sortType) { + return switch (sortType) { case PRICE_ASC -> Sort.by(Sort.Direction.ASC, "price.value"); default -> Sort.by(Sort.Direction.DESC, "createdAt"); }; - Pageable sortedPageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), sort); - return productJpaRepository.findAllByBrandIdFilter(brandId, sortedPageable); } } \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminController.java index e4e40f96e..6adb4ebf3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminController.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminController.java @@ -1,78 +1,170 @@ package com.loopers.interfaces.api.admin; -import com.loopers.application.brand.BrandFacade; -import com.loopers.application.brand.BrandInfo; -import com.loopers.application.product.ProductFacade; +import com.loopers.application.brand.AdminBrandFacade; +import com.loopers.application.product.AdminProductFacade; +import com.loopers.domain.brand.BrandStatus; +import com.loopers.domain.product.ProductStatus; import com.loopers.interfaces.api.ApiResponse; -import com.loopers.interfaces.api.brand.BrandV1Dto; -import com.loopers.interfaces.api.brand.BrandV1Dto.BrandResponse; -import com.loopers.interfaces.api.product.ProductV1Dto; -import com.loopers.interfaces.api.product.ProductV1Dto.ProductListItemResponse; +import com.loopers.interfaces.api.admin.AdminBrandV1Dto.AdminBrandResponse; +import com.loopers.interfaces.api.admin.AdminBrandV1Dto.CreateBrandRequest; +import com.loopers.interfaces.api.admin.AdminBrandV1Dto.UpdateBrandRequest; +import com.loopers.interfaces.api.admin.AdminProductV1Dto.AdminProductResponse; +import com.loopers.interfaces.api.admin.AdminProductV1Dto.CreateProductRequest; +import com.loopers.interfaces.api.admin.AdminProductV1Dto.PageResponse; +import com.loopers.interfaces.api.admin.AdminProductV1Dto.ProductHistoryResponse; +import com.loopers.interfaces.api.admin.AdminProductV1Dto.UpdateProductRequest; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; +/** + * 어드민 브랜드/상품 관리 API. + * LDAP 인증은 AdminAuthInterceptor에서 body 파싱 이전에 처리하므로 + * 이 컨트롤러는 인증이 완료된 요청만 처리한다. + */ @RequiredArgsConstructor @RestController @RequestMapping("/api-admin/v1") public class AdminController implements AdminV1ApiSpec { - // (GET) /api-admin/v1/brands?page=0&size=20 // 등록된 브랜드 목록 조회 - - // (POST) /api-admin/v1/brands // 브랜드 등록 - // (PUT) /api-admin/v1/brands/{brandId} // 브랜드 정보 수정 - // (DELETE) /api-admin/v1/brands/{brandId} // 브랜드 삭제 - - - // (POST) /api-admin/v1/products // 상품 등록 - // (PUT) /api-admin/v1/products/{productId} // 상품 정보 수정 - // (DELETE) /api-admin/v1/products/{productId} // 상품 삭제 - // (POST) /api-admin/v1/orders // 주문 요청 - // (GET) /api-admin/v1/orders?startAt=2026-01-31&endAt=2026-02-10 // 유저의 주문 목록 조회 - // (GET) /api-admin/v1/orders/{orderId} // 단일 주문 상세 조회 - - private final BrandFacade brandFacade; - private final ProductFacade productFacade; - - - // (GET) /api-admin/v1/brands/{brandId} // 브랜드 상세 조회 - @GetMapping("/brands/{brandId}") - @Override - public ApiResponse getBrands( - @PathVariable(value = "brandId") Long brandId - ) { - BrandInfo info = brandFacade.getBrandInfo(brandId); - BrandV1Dto.BrandResponse response = BrandV1Dto.BrandResponse.from(info); - return ApiResponse.success(response); - } - - - // (GET) /api-admin/v1/products?page=0&size=20&brandId={ brandId} // 등록된 상품 목록 조회 - @GetMapping("/products") - @Override - public ApiResponse> getProductList( - @RequestParam(required = false) Long brandId, - @RequestParam(required = false, defaultValue = "latest") String sort, - @RequestParam(required = false, defaultValue = "0") int page, - @RequestParam(required = false, defaultValue = "20") int size - ) { - Page responsePage = productFacade.getProductList(brandId, sort, page, size) - .map(ProductV1Dto.ProductListItemResponse::from); - return ApiResponse.success(ProductV1Dto.PageResponse.from(responsePage)); - } - - // (GET) /api-admin/v1/products/{productId} // 상품 상세 조회 - @GetMapping("/products/{productId}") - @Override - public ApiResponse getProduct( - @PathVariable(value = "productId") Long productId - ) { - return ApiResponse.success( - ProductV1Dto.ProductDetailResponse.from(productFacade.getProductDetail(productId)) - ); - } + private final AdminBrandFacade adminBrandFacade; + private final AdminProductFacade adminProductFacade; + + // ───────────────────────────────────────────── + // 브랜드 관리 + // ───────────────────────────────────────────── + + @PostMapping("/brands") + @ResponseStatus(HttpStatus.CREATED) + @Override + public ApiResponse createBrand(@RequestBody CreateBrandRequest request) { + return ApiResponse.success(AdminBrandResponse.from( + adminBrandFacade.createBrand(request.name(), request.description(), request.logoImageUrl()) + )); + } + + @PutMapping("/brands/{brandId}") + @Override + public ApiResponse updateBrand(@PathVariable Long brandId, @RequestBody UpdateBrandRequest request) { + return ApiResponse.success(AdminBrandResponse.from( + adminBrandFacade.updateBrand(brandId, request.name(), request.description(), request.logoImageUrl()) + )); + } + + /** + * 브랜드를 비활성화(INACTIVE)한다. + * 연관 상품의 비활성화는 BrandDeactivatedEvent를 통해 비동기로 처리된다. + */ + @DeleteMapping("/brands/{brandId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Override + public void deactivateBrand(@PathVariable Long brandId) { + adminBrandFacade.deactivateBrand(brandId); + } + + @GetMapping("/brands/{brandId}") + @Override + public ApiResponse getBrand(@PathVariable Long brandId) { + // 어드민은 INACTIVE 포함 모든 상태의 브랜드 조회 가능 + return ApiResponse.success(AdminBrandResponse.from(adminBrandFacade.getBrandInfo(brandId))); + } + + @GetMapping("/brands") + @Override + public ApiResponse> getBrandList( + @RequestParam(required = false) String status, + @RequestParam(required = false, defaultValue = "0") int page, + @RequestParam(required = false, defaultValue = "20") int size + ) { + // status 문자열을 BrandStatus enum으로 변환 (null이면 전체 조회) + BrandStatus brandStatus = status != null ? BrandStatus.valueOf(status) : null; + return ApiResponse.success(PageResponse.from( + adminBrandFacade.getBrandList(brandStatus, page, size).map(AdminBrandResponse::from) + )); + } + + // ───────────────────────────────────────────── + // 상품 관리 + // ───────────────────────────────────────────── + + /** + * 상품을 등록한다. + * 등록 시 ProductHistory 스냅샷이 1건 자동 생성된다. + */ + @PostMapping("/products") + @ResponseStatus(HttpStatus.CREATED) + @Override + public ApiResponse createProduct(@RequestBody CreateProductRequest request) { + return ApiResponse.success(AdminProductResponse.from( + adminProductFacade.createProduct( + request.brandId(), request.name(), request.price(), + request.description(), request.thumbnailImageUrl() + ) + )); + } + + /** + * 상품 정보를 수정한다. + * 수정 시 ProductHistory 스냅샷이 1건 추가된다. + */ + @PutMapping("/products/{productId}") + @Override + public ApiResponse updateProduct(@PathVariable Long productId, @RequestBody UpdateProductRequest request) { + return ApiResponse.success(AdminProductResponse.from( + adminProductFacade.updateProduct( + productId, request.brandId(), request.name(), request.price(), + request.description(), request.thumbnailImageUrl() + ) + )); + } + + @DeleteMapping("/products/{productId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Override + public void deactivateProduct(@PathVariable Long productId) { + adminProductFacade.deactivateProduct(productId); + } + + @GetMapping("/products/{productId}") + @Override + public ApiResponse getProduct(@PathVariable Long productId) { + // 어드민은 PENDING/INACTIVE 포함 모든 상태의 상품 조회 가능 + return ApiResponse.success(AdminProductResponse.from(adminProductFacade.getProductInfo(productId))); + } + + @GetMapping("/products") + @Override + public ApiResponse> getProductList( + @RequestParam(required = false) Long brandId, + @RequestParam(required = false) String status, + @RequestParam(required = false, defaultValue = "0") int page, + @RequestParam(required = false, defaultValue = "20") int size + ) { + // status 문자열을 ProductStatus enum으로 변환 (null이면 전체 조회) + ProductStatus productStatus = status != null ? ProductStatus.valueOf(status) : null; + return ApiResponse.success(PageResponse.from( + adminProductFacade.getProductList(brandId, productStatus, page, size).map(AdminProductResponse::from) + )); + } + + @GetMapping("/products/{productId}/history") + @Override + public ApiResponse> getProductHistory( + @PathVariable Long productId, + @RequestParam(required = false, defaultValue = "0") int page, + @RequestParam(required = false, defaultValue = "20") int size + ) { + return ApiResponse.success(PageResponse.from( + adminProductFacade.getProductHistory(productId, page, size).map(ProductHistoryResponse::from) + )); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminV1ApiSpec.java index f9fb1ad6f..82df036be 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminV1ApiSpec.java @@ -1,32 +1,83 @@ package com.loopers.interfaces.api.admin; import com.loopers.interfaces.api.ApiResponse; -import com.loopers.interfaces.api.brand.BrandV1Dto.BrandResponse; -import com.loopers.interfaces.api.product.ProductV1Dto; +import com.loopers.interfaces.api.admin.AdminBrandV1Dto.AdminBrandResponse; +import com.loopers.interfaces.api.admin.AdminBrandV1Dto.CreateBrandRequest; +import com.loopers.interfaces.api.admin.AdminBrandV1Dto.UpdateBrandRequest; +import com.loopers.interfaces.api.admin.AdminProductV1Dto.AdminProductResponse; +import com.loopers.interfaces.api.admin.AdminProductV1Dto.CreateProductRequest; +import com.loopers.interfaces.api.admin.AdminProductV1Dto.PageResponse; +import com.loopers.interfaces.api.admin.AdminProductV1Dto.ProductHistoryResponse; +import com.loopers.interfaces.api.admin.AdminProductV1Dto.UpdateProductRequest; import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; -@Tag(name = "Brand&Products", description = "어드민 브랜드/상품 API") +@Tag(name = "Admin", description = "어드민 브랜드/상품 API") public interface AdminV1ApiSpec { - @GetMapping("/{brandId}") - ApiResponse getBrands( - @PathVariable(value = "brandId") Long brandId - ); - - // (GET) /api-admin/v1/products?page=0&size=20&brandId={ brandId} // 등록된 상품 목록 조회 - @GetMapping("/products") - ApiResponse> getProductList( - @RequestParam(required = false) Long brandId, - @RequestParam(required = false, defaultValue = "latest") String sort, - @RequestParam(required = false, defaultValue = "0") int page, - @RequestParam(required = false, defaultValue = "20") int size - ); - - @GetMapping("/{productId}") - ApiResponse getProduct( - @PathVariable(value = "productId") Long productId - ); + // ───────────────────────────────────────────── + // 브랜드 관리 + // ───────────────────────────────────────────── + + @PostMapping("/brands") + @ResponseStatus(HttpStatus.CREATED) + ApiResponse createBrand(@RequestBody CreateBrandRequest request); + + @PutMapping("/brands/{brandId}") + ApiResponse updateBrand(@PathVariable Long brandId, @RequestBody UpdateBrandRequest request); + + @DeleteMapping("/brands/{brandId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + void deactivateBrand(@PathVariable Long brandId); + + @GetMapping("/brands/{brandId}") + ApiResponse getBrand(@PathVariable Long brandId); + + @GetMapping("/brands") + ApiResponse> getBrandList( + @RequestParam(required = false) String status, + @RequestParam(required = false, defaultValue = "0") int page, + @RequestParam(required = false, defaultValue = "20") int size + ); + + // ───────────────────────────────────────────── + // 상품 관리 + // ───────────────────────────────────────────── + + @PostMapping("/products") + @ResponseStatus(HttpStatus.CREATED) + ApiResponse createProduct(@RequestBody CreateProductRequest request); + + @PutMapping("/products/{productId}") + ApiResponse updateProduct(@PathVariable Long productId, @RequestBody UpdateProductRequest request); + + @DeleteMapping("/products/{productId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + void deactivateProduct(@PathVariable Long productId); + + @GetMapping("/products/{productId}") + ApiResponse getProduct(@PathVariable Long productId); + + @GetMapping("/products") + ApiResponse> getProductList( + @RequestParam(required = false) Long brandId, + @RequestParam(required = false) String status, + @RequestParam(required = false, defaultValue = "0") int page, + @RequestParam(required = false, defaultValue = "20") int size + ); + + @GetMapping("/products/{productId}/history") + ApiResponse> getProductHistory( + @PathVariable Long productId, + @RequestParam(required = false, defaultValue = "0") int page, + @RequestParam(required = false, defaultValue = "20") int size + ); } 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 96c81c9ff..71b8e8e64 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,7 +12,8 @@ public enum ErrorType { BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "잘못된 요청입니다."), NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 요청입니다."), CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다."), - UNAUTHORIZED(HttpStatus.UNAUTHORIZED, HttpStatus.UNAUTHORIZED.getReasonPhrase(), "유효하지 않은 인증 정보입니다."); + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, HttpStatus.UNAUTHORIZED.getReasonPhrase(), "유효하지 않은 인증 정보입니다."), + FORBIDDEN(HttpStatus.FORBIDDEN, HttpStatus.FORBIDDEN.getReasonPhrase(), "접근 권한이 없습니다."); private final HttpStatus status; private final String code; diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/BrandV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/BrandV1ApiE2ETest.java index 31b7a1f72..106698cc4 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/BrandV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/BrandV1ApiE2ETest.java @@ -52,10 +52,12 @@ class GetBrand { @DisplayName("유효한 brandId를 주면, brandId, name, description, logoImageUrl, createdAt을 반환한다.") @Test void returnsBrandInfo_whenValidBrandIdIsProvided() { - // arrange + // arrange - 고객 API는 ACTIVE 브랜드만 반환하므로 activate 필요 Brand brand = brandJpaRepository.save( new Brand("나이키", "스포츠 브랜드", "https://example.com/nike-logo.png") ); + brand.activate(); + brandJpaRepository.save(brand); Long brandId = brand.getId(); // act diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductsV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductsV1ApiE2ETest.java index a6ffd6311..d42ab1d1d 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductsV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductsV1ApiE2ETest.java @@ -144,7 +144,11 @@ void returnsPagedProductList_whenNoFilterProvided() { // arrange Brand brand = brandJpaRepository.save(new Brand("나이키", "스포츠 브랜드", "https://example.com/logo.png")); Product product1 = productJpaRepository.save(new Product(brand.getId(), "에어맥스 90", new Money(150000L), "러닝화")); + product1.activate(); + productJpaRepository.save(product1); Product product2 = productJpaRepository.save(new Product(brand.getId(), "조던 1", new Money(200000L), "농구화")); + product2.activate(); + productJpaRepository.save(product2); productOptionJpaRepository.save(new ProductOption(product1.getId(), "265mm", new Money(150000L), 5L)); productOptionJpaRepository.save(new ProductOption(product2.getId(), "270mm", new Money(200000L), 3L)); @@ -179,7 +183,11 @@ void returnsFilteredProducts_whenBrandIdProvided() { Brand nike = brandJpaRepository.save(new Brand("나이키", "스포츠 브랜드", "https://example.com/nike.png")); Brand adidas = brandJpaRepository.save(new Brand("아디다스", "스포츠 브랜드", "https://example.com/adidas.png")); Product nikeProduct = productJpaRepository.save(new Product(nike.getId(), "에어맥스 90", new Money(150000L), "러닝화")); - productJpaRepository.save(new Product(adidas.getId(), "울트라부스트", new Money(180000L), "러닝화")); + nikeProduct.activate(); + productJpaRepository.save(nikeProduct); + Product adidasProduct = productJpaRepository.save(new Product(adidas.getId(), "울트라부스트", new Money(180000L), "러닝화")); + adidasProduct.activate(); + productJpaRepository.save(adidasProduct); productOptionJpaRepository.save(new ProductOption(nikeProduct.getId(), "265mm", new Money(150000L), 5L)); // act @@ -208,7 +216,11 @@ void returnsProductsSortedByMinPriceAsc_whenSortParamProvided() { // arrange Brand brand = brandJpaRepository.save(new Brand("나이키", null, null)); Product cheapProduct = productJpaRepository.save(new Product(brand.getId(), "저가 상품", new Money(50000L), null)); + cheapProduct.activate(); + productJpaRepository.save(cheapProduct); Product expensiveProduct = productJpaRepository.save(new Product(brand.getId(), "고가 상품", new Money(200000L), null)); + expensiveProduct.activate(); + productJpaRepository.save(expensiveProduct); productOptionJpaRepository.save(new ProductOption(cheapProduct.getId(), "S", new Money(50000L), 10L)); productOptionJpaRepository.save(new ProductOption(expensiveProduct.getId(), "M", new Money(200000L), 10L)); From 94789f425da0c08daf7b1973f0b761fcbdac6fc3 Mon Sep 17 00:00:00 2001 From: katiekim17 Date: Thu, 26 Feb 2026 00:38:57 +0900 Subject: [PATCH 36/39] =?UTF-8?q?fix:=20=EC=96=B4=EB=93=9C=EB=AF=BC?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=B8=8C=EB=9E=9C=EB=93=9C,=20=EC=83=81?= =?UTF-8?q?=ED=92=88=20=EA=B4=80=EB=A0=A8=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/brand/AdminBrandFacade.java | 61 ++ .../application/brand/AdminBrandInfo.java | 26 + .../product/AdminProductFacade.java | 87 +++ .../application/product/AdminProductInfo.java | 30 + .../product/ProductHistoryInfo.java | 26 + .../java/com/loopers/config/AsyncConfig.java | 9 + .../java/com/loopers/config/WebMvcConfig.java | 20 + .../domain/brand/BrandDeactivatedEvent.java | 3 + .../domain/brand/BrandEventListener.java | 35 + .../com/loopers/domain/brand/BrandStatus.java | 5 + .../domain/product/ProductHistory.java | 94 +++ .../product/ProductHistoryRepository.java | 13 + .../product/ProductHistoryJpaRepository.java | 13 + .../product/ProductHistoryRepositoryImpl.java | 30 + .../api/admin/AdminAuthInterceptor.java | 37 + .../interfaces/api/admin/AdminBrandV1Dto.java | 33 + .../api/admin/AdminProductV1Dto.java | 74 ++ .../interfaces/api/AdminV1ApiE2ETest.java | 681 ++++++++++++++++++ http/commerce-api/admin-brand-v1.http | 47 ++ http/commerce-api/admin-product-v1.http | 60 ++ 20 files changed, 1384 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/brand/AdminBrandFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/brand/AdminBrandInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/AdminProductFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/AdminProductInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductHistoryInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/config/AsyncConfig.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandDeactivatedEvent.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandEventListener.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandStatus.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductHistory.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductHistoryRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductHistoryJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductHistoryRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminAuthInterceptor.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminBrandV1Dto.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminProductV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/AdminV1ApiE2ETest.java create mode 100644 http/commerce-api/admin-brand-v1.http create mode 100644 http/commerce-api/admin-product-v1.http diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/AdminBrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/AdminBrandFacade.java new file mode 100644 index 000000000..a652bb145 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/AdminBrandFacade.java @@ -0,0 +1,61 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandDeactivatedEvent; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.brand.BrandStatus; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class AdminBrandFacade { + + private final BrandRepository brandRepository; + private final ApplicationEventPublisher eventPublisher; + + @Transactional + public AdminBrandInfo createBrand(String name, String description, String logoImageUrl) { + Brand brand = brandRepository.save(new Brand(name, description, logoImageUrl)); + return AdminBrandInfo.from(brand); + } + + @Transactional + public AdminBrandInfo updateBrand(Long brandId, String name, String description, String logoImageUrl) { + Brand brand = brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 브랜드입니다.")); + brand.updateInfo(name, description, logoImageUrl); + return AdminBrandInfo.from(brandRepository.save(brand)); + } + + @Transactional + public void deactivateBrand(Long brandId) { + Brand brand = brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 브랜드입니다.")); + brand.deactivate(); + brandRepository.save(brand); + // 브랜드 비활성화 이벤트 발행 → BrandEventListener가 비동기로 상품 연쇄 처리 + eventPublisher.publishEvent(new BrandDeactivatedEvent(brandId)); + } + + @Transactional(readOnly = true) + public AdminBrandInfo getBrandInfo(Long brandId) { + // 어드민은 INACTIVE 포함 모든 상태의 브랜드를 조회 가능 + Brand brand = brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 브랜드입니다.")); + return AdminBrandInfo.from(brand); + } + + @Transactional(readOnly = true) + public Page getBrandList(BrandStatus status, int page, int size) { + // status가 null이면 전체, 아니면 해당 상태만 반환 + return brandRepository.findAll(status, PageRequest.of(page, size)) + .map(AdminBrandInfo::from); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/AdminBrandInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/AdminBrandInfo.java new file mode 100644 index 000000000..075c7205a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/AdminBrandInfo.java @@ -0,0 +1,26 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.Brand; +import java.time.ZonedDateTime; + +public record AdminBrandInfo( + Long brandId, + String name, + String description, + String logoImageUrl, + String status, + ZonedDateTime createdAt, + ZonedDateTime updatedAt +) { + public static AdminBrandInfo from(Brand brand) { + return new AdminBrandInfo( + brand.getId(), + brand.getName(), + brand.getDescription(), + brand.getLogoImageUrl(), + brand.getStatus().name(), + brand.getCreatedAt(), + brand.getUpdatedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/AdminProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/AdminProductFacade.java new file mode 100644 index 000000000..ac6cc68c5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/AdminProductFacade.java @@ -0,0 +1,87 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.common.Money; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductHistory; +import com.loopers.domain.product.ProductHistoryRepository; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.ProductSortType; +import com.loopers.domain.product.ProductStatus; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class AdminProductFacade { + + private final ProductRepository productRepository; + private final BrandRepository brandRepository; + private final ProductHistoryRepository productHistoryRepository; + + @Transactional + public AdminProductInfo createProduct(Long brandId, String name, Long price, String description, String thumbnailImageUrl) { + brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 브랜드입니다.")); + Product product = productRepository.save( + new Product(brandId, name, new Money(price), description, thumbnailImageUrl) + ); + // 등록 시 버전 1의 스냅샷 자동 저장 + int version = productHistoryRepository.countByProductId(product.getId()) + 1; + productHistoryRepository.save(ProductHistory.snapshot(product, version, "admin")); + return AdminProductInfo.from(product); + } + + @Transactional + public AdminProductInfo updateProduct(Long productId, Long brandId, String name, Long price, String description, String thumbnailImageUrl) { + brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 브랜드입니다.")); + Product product = productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다.")); + product.updateInfo(brandId, name, new Money(price), description, thumbnailImageUrl); + productRepository.save(product); + // 수정 시 버전이 1 증가한 스냅샷 저장 + int version = productHistoryRepository.countByProductId(productId) + 1; + productHistoryRepository.save(ProductHistory.snapshot(product, version, "admin")); + return AdminProductInfo.from(product); + } + + @Transactional + public void deactivateProduct(Long productId) { + Product product = productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다.")); + product.deactivate(); + productRepository.save(product); + } + + @Transactional(readOnly = true) + public AdminProductInfo getProductInfo(Long productId) { + // 어드민은 PENDING/INACTIVE 포함 모든 상태의 상품 조회 가능 + Product product = productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다.")); + return AdminProductInfo.from(product); + } + + @Transactional(readOnly = true) + public Page getProductList(Long brandId, ProductStatus status, int page, int size) { + // status가 null이면 전체 조회, 아니면 해당 상태만 반환 + if (status != null) { + return productRepository.findAll(brandId, ProductSortType.LATEST, List.of(status), PageRequest.of(page, size)) + .map(AdminProductInfo::from); + } + return productRepository.findAll(brandId, ProductSortType.LATEST, PageRequest.of(page, size)) + .map(AdminProductInfo::from); + } + + @Transactional(readOnly = true) + public Page getProductHistory(Long productId, int page, int size) { + return productHistoryRepository.findAllByProductId(productId, PageRequest.of(page, size)) + .map(ProductHistoryInfo::from); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/AdminProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/AdminProductInfo.java new file mode 100644 index 000000000..5a9b374c1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/AdminProductInfo.java @@ -0,0 +1,30 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.Product; +import java.time.ZonedDateTime; + +public record AdminProductInfo( + Long productId, + String name, + String description, + Long brandId, + String thumbnailImageUrl, + Long price, + String status, + ZonedDateTime createdAt, + ZonedDateTime updatedAt +) { + public static AdminProductInfo from(Product product) { + return new AdminProductInfo( + product.getId(), + product.getName(), + product.getDescription(), + product.getBrandId(), + product.getThumbnailImageUrl(), + product.getPrice(), + product.getStatus().name(), + product.getCreatedAt(), + product.getUpdatedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductHistoryInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductHistoryInfo.java new file mode 100644 index 000000000..f1313bde3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductHistoryInfo.java @@ -0,0 +1,26 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.ProductHistory; +import java.time.ZonedDateTime; + +public record ProductHistoryInfo( + Long historyId, + Integer version, + String name, + Long price, + String status, + String changedBy, + ZonedDateTime changedAt +) { + public static ProductHistoryInfo from(ProductHistory history) { + return new ProductHistoryInfo( + history.getId(), + history.getVersion(), + history.getName(), + history.getPrice(), + history.getStatus(), + history.getChangedBy(), + history.getCreatedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/config/AsyncConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/AsyncConfig.java new file mode 100644 index 000000000..e9461feef --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/AsyncConfig.java @@ -0,0 +1,9 @@ +package com.loopers.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; + +@EnableAsync +@Configuration +public class AsyncConfig { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java new file mode 100644 index 000000000..ccdfc67c3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java @@ -0,0 +1,20 @@ +package com.loopers.config; + +import com.loopers.interfaces.api.admin.AdminAuthInterceptor; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@RequiredArgsConstructor +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + private final AdminAuthInterceptor adminAuthInterceptor; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(adminAuthInterceptor) + .addPathPatterns("/api-admin/v1/**"); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandDeactivatedEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandDeactivatedEvent.java new file mode 100644 index 000000000..f524b36b6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandDeactivatedEvent.java @@ -0,0 +1,3 @@ +package com.loopers.domain.brand; + +public record BrandDeactivatedEvent(Long brandId) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandEventListener.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandEventListener.java new file mode 100644 index 000000000..0c4eb3c25 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandEventListener.java @@ -0,0 +1,35 @@ +package com.loopers.domain.brand; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductHistory; +import com.loopers.domain.product.ProductHistoryRepository; +import com.loopers.domain.product.ProductRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@RequiredArgsConstructor +@Component +public class BrandEventListener { + + private final ProductRepository productRepository; + private final ProductHistoryRepository productHistoryRepository; + + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleBrandDeactivated(BrandDeactivatedEvent event) { + List products = productRepository.findAllByBrandId(event.brandId()); + for (Product product : products) { + product.deactivate(); + productRepository.save(product); + int version = productHistoryRepository.countByProductId(product.getId()) + 1; + productHistoryRepository.save(ProductHistory.snapshot(product, version, "system")); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandStatus.java new file mode 100644 index 000000000..fa00677b3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandStatus.java @@ -0,0 +1,5 @@ +package com.loopers.domain.brand; + +public enum BrandStatus { + PENDING, ACTIVE, INACTIVE, SCHEDULED +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductHistory.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductHistory.java new file mode 100644 index 000000000..3b1cae7cf --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductHistory.java @@ -0,0 +1,94 @@ +package com.loopers.domain.product; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "product_history") +public class ProductHistory extends BaseEntity { + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "version", nullable = false) + private Integer version; + + @Column(name = "brand_id", nullable = false) + private Long brandId; + + @Column(name = "name", nullable = false, length = 200) + private String name; + + @Column(name = "price", nullable = false) + private Long price; + + @Column(name = "status", nullable = false, length = 20) + private String status; + + @Column(name = "description") + private String description; + + @Column(name = "changed_by", length = 100) + private String changedBy; + + protected ProductHistory() {} + + private ProductHistory(Long productId, int version, Long brandId, String name, Long price, + String status, String description, String changedBy) { + this.productId = productId; + this.version = version; + this.brandId = brandId; + this.name = name; + this.price = price; + this.status = status; + this.description = description; + this.changedBy = changedBy; + } + + public static ProductHistory snapshot(Product product, int version, String changedBy) { + return new ProductHistory( + product.getId(), + version, + product.getBrandId(), + product.getName(), + product.getPrice(), + product.getStatus().name(), + product.getDescription(), + changedBy + ); + } + + public Long getProductId() { + return productId; + } + + public Integer getVersion() { + return version; + } + + public Long getBrandId() { + return brandId; + } + + public String getName() { + return name; + } + + public Long getPrice() { + return price; + } + + public String getStatus() { + return status; + } + + public String getDescription() { + return description; + } + + public String getChangedBy() { + return changedBy; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductHistoryRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductHistoryRepository.java new file mode 100644 index 000000000..4efa4cf01 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductHistoryRepository.java @@ -0,0 +1,13 @@ +package com.loopers.domain.product; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface ProductHistoryRepository { + + ProductHistory save(ProductHistory history); + + Page findAllByProductId(Long productId, Pageable pageable); + + int countByProductId(Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductHistoryJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductHistoryJpaRepository.java new file mode 100644 index 000000000..c90ba432e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductHistoryJpaRepository.java @@ -0,0 +1,13 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductHistory; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductHistoryJpaRepository extends JpaRepository { + + Page findAllByProductId(Long productId, Pageable pageable); + + int countByProductId(Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductHistoryRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductHistoryRepositoryImpl.java new file mode 100644 index 000000000..0d6e2ac53 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductHistoryRepositoryImpl.java @@ -0,0 +1,30 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductHistory; +import com.loopers.domain.product.ProductHistoryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class ProductHistoryRepositoryImpl implements ProductHistoryRepository { + + private final ProductHistoryJpaRepository productHistoryJpaRepository; + + @Override + public ProductHistory save(ProductHistory history) { + return productHistoryJpaRepository.save(history); + } + + @Override + public Page findAllByProductId(Long productId, Pageable pageable) { + return productHistoryJpaRepository.findAllByProductId(productId, pageable); + } + + @Override + public int countByProductId(Long productId) { + return productHistoryJpaRepository.countByProductId(productId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminAuthInterceptor.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminAuthInterceptor.java new file mode 100644 index 000000000..c91846292 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminAuthInterceptor.java @@ -0,0 +1,37 @@ +package com.loopers.interfaces.api.admin; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +/** + * 어드민 API LDAP 인증 인터셉터. + * Body 파싱 이전 단계에서 X-Loopers-Ldap 헤더를 검사하여, + * 유효하지 않은 요청에 대해 즉시 403을 반환하고 처리를 중단한다. + */ +@Component +public class AdminAuthInterceptor implements HandlerInterceptor { + + private static final String ADMIN_LDAP = "loopers.admin"; + private static final String LDAP_HEADER = "X-Loopers-Ldap"; + + // Map.of()는 null 값을 허용하지 않으므로 JSON 문자열을 직접 작성한다. + private static final String FORBIDDEN_BODY = + "{\"meta\":{\"code\":\"Forbidden\",\"message\":\"접근 권한이 없습니다.\"},\"data\":null}"; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + String ldap = request.getHeader(LDAP_HEADER); + if (!ADMIN_LDAP.equals(ldap)) { + response.setStatus(HttpStatus.FORBIDDEN.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write(FORBIDDEN_BODY); + return false; + } + return true; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminBrandV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminBrandV1Dto.java new file mode 100644 index 000000000..bcbe17b15 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminBrandV1Dto.java @@ -0,0 +1,33 @@ +package com.loopers.interfaces.api.admin; + +import com.loopers.application.brand.AdminBrandInfo; +import java.time.ZonedDateTime; + +public class AdminBrandV1Dto { + + public record CreateBrandRequest(String name, String description, String logoImageUrl) {} + + public record UpdateBrandRequest(String name, String description, String logoImageUrl) {} + + public record AdminBrandResponse( + Long brandId, + String name, + String description, + String logoImageUrl, + String status, + ZonedDateTime createdAt, + ZonedDateTime updatedAt + ) { + public static AdminBrandResponse from(AdminBrandInfo info) { + return new AdminBrandResponse( + info.brandId(), + info.name(), + info.description(), + info.logoImageUrl(), + info.status(), + info.createdAt(), + info.updatedAt() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminProductV1Dto.java new file mode 100644 index 000000000..89d446af2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminProductV1Dto.java @@ -0,0 +1,74 @@ +package com.loopers.interfaces.api.admin; + +import com.loopers.application.product.AdminProductInfo; +import com.loopers.application.product.ProductHistoryInfo; +import java.time.ZonedDateTime; +import java.util.List; +import org.springframework.data.domain.Page; + +public class AdminProductV1Dto { + + public record CreateProductRequest(Long brandId, String name, Long price, String description, String thumbnailImageUrl) {} + + public record UpdateProductRequest(Long brandId, String name, Long price, String description, String thumbnailImageUrl) {} + + public record AdminProductResponse( + Long productId, + String name, + String description, + Long brandId, + String thumbnailImageUrl, + Long price, + String status, + ZonedDateTime createdAt, + ZonedDateTime updatedAt + ) { + public static AdminProductResponse from(AdminProductInfo info) { + return new AdminProductResponse( + info.productId(), + info.name(), + info.description(), + info.brandId(), + info.thumbnailImageUrl(), + info.price(), + info.status(), + info.createdAt(), + info.updatedAt() + ); + } + } + + public record ProductHistoryResponse( + Long historyId, + Integer version, + String name, + Long price, + String status, + String changedBy, + ZonedDateTime changedAt + ) { + public static ProductHistoryResponse from(ProductHistoryInfo info) { + return new ProductHistoryResponse( + info.historyId(), + info.version(), + info.name(), + info.price(), + info.status(), + info.changedBy(), + info.changedAt() + ); + } + } + + public record PageResponse(List content, long totalElements, int totalPages, int page, int size) { + public static PageResponse from(Page page) { + return new PageResponse<>( + page.getContent(), + page.getTotalElements(), + page.getTotalPages(), + page.getNumber(), + page.getSize() + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AdminV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AdminV1ApiE2ETest.java new file mode 100644 index 000000000..d174d5e52 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AdminV1ApiE2ETest.java @@ -0,0 +1,681 @@ +package com.loopers.interfaces.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.common.Money; +import com.loopers.domain.product.Product; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductHistoryJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.utils.DatabaseCleanUp; +import java.util.Map; +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.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class AdminV1ApiE2ETest { + + private static final String BRAND_ENDPOINT = "/api-admin/v1/brands"; + private static final String PRODUCT_ENDPOINT = "/api-admin/v1/products"; + private static final String LDAP_HEADER = "X-Loopers-Ldap"; + private static final String ADMIN_LDAP = "loopers.admin"; + + private final TestRestTemplate testRestTemplate; + private final BrandJpaRepository brandJpaRepository; + private final ProductJpaRepository productJpaRepository; + private final ProductHistoryJpaRepository productHistoryJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public AdminV1ApiE2ETest( + TestRestTemplate testRestTemplate, + BrandJpaRepository brandJpaRepository, + ProductJpaRepository productJpaRepository, + ProductHistoryJpaRepository productHistoryJpaRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.brandJpaRepository = brandJpaRepository; + this.productJpaRepository = productJpaRepository; + this.productHistoryJpaRepository = productHistoryJpaRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set(LDAP_HEADER, ADMIN_LDAP); + headers.setContentType(MediaType.APPLICATION_JSON); + return headers; + } + + // ───────────────────────────────────────────── + // 브랜드 관리 + // ───────────────────────────────────────────── + + @DisplayName("POST /api-admin/v1/brands") + @Nested + class CreateBrand { + + @DisplayName("LDAP 헤더 없이 요청하면 403을 반환한다.") + @Test + void returnsForbidden_whenLdapHeaderIsMissing() { + // arrange + String body = """ + {"name": "나이키", "description": "스포츠 브랜드", "logoImageUrl": "https://example.com/logo.png"} + """; + + // act + ResponseEntity>> response = testRestTemplate.exchange( + BRAND_ENDPOINT, + HttpMethod.POST, + new HttpEntity<>(body, new HttpHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @DisplayName("유효한 요청으로 브랜드를 등록하면 201과 브랜드 정보를 반환한다.") + @Test + void returnsBrandInfo_whenValidRequestProvided() { + // arrange + String body = """ + {"name": "나이키", "description": "스포츠 브랜드", "logoImageUrl": "https://example.com/logo.png"} + """; + + // act + ResponseEntity>> response = testRestTemplate.exchange( + BRAND_ENDPOINT, + HttpMethod.POST, + new HttpEntity<>(body, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> { + Map data = response.getBody().data(); + assertThat(data.get("name")).isEqualTo("나이키"); + assertThat(data.get("status")).isEqualTo("PENDING"); + assertThat(data).containsKey("brandId"); + assertThat(data).containsKey("createdAt"); + } + ); + } + } + + @DisplayName("PUT /api-admin/v1/brands/{brandId}") + @Nested + class UpdateBrand { + + @DisplayName("브랜드 정보를 수정하면 200과 수정된 정보를 반환한다.") + @Test + void returnsUpdatedBrand_whenValidRequestProvided() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키", "스포츠", "https://example.com/logo.png")); + String body = """ + {"name": "나이키 코리아", "description": "한국 나이키", "logoImageUrl": "https://example.com/new-logo.png"} + """; + + // act + ResponseEntity>> response = testRestTemplate.exchange( + BRAND_ENDPOINT + "/" + brand.getId(), + HttpMethod.PUT, + new HttpEntity<>(body, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().get("name")).isEqualTo("나이키 코리아") + ); + } + } + + @DisplayName("DELETE /api-admin/v1/brands/{brandId}") + @Nested + class DeactivateBrand { + + @DisplayName("브랜드를 비활성화하면 204를 반환하고, 해당 브랜드의 상품도 INACTIVE가 된다.") + @Test + void returnsNoContent_andDeactivatesProducts_whenBrandDeactivated() throws InterruptedException { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키", null, null)); + brand.activate(); + brandJpaRepository.save(brand); + + Product product = productJpaRepository.save( + new Product(brand.getId(), "에어맥스", new Money(150000L), null) + ); + product.activate(); + productJpaRepository.save(product); + + // act + ResponseEntity response = testRestTemplate.exchange( + BRAND_ENDPOINT + "/" + brand.getId(), + HttpMethod.DELETE, + new HttpEntity<>(adminHeaders()), + Void.class + ); + + // assert - 브랜드 비활성화 응답 + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + + // assert - 상품 비동기 비활성화 (최대 3초 대기) + long deadline = System.currentTimeMillis() + 3000; + while (System.currentTimeMillis() < deadline) { + Product updated = productJpaRepository.findById(product.getId()).orElseThrow(); + if ("INACTIVE".equals(updated.getStatus().name())) { + break; + } + Thread.sleep(100); + } + Product updated = productJpaRepository.findById(product.getId()).orElseThrow(); + assertThat(updated.getStatus().name()).isEqualTo("INACTIVE"); + } + } + + // ───────────────────────────────────────────── + // 상품 관리 + // ───────────────────────────────────────────── + + @DisplayName("POST /api-admin/v1/products") + @Nested + class CreateProduct { + + @DisplayName("존재하지 않는 브랜드로 상품 등록 시 404를 반환한다.") + @Test + void returnsNotFound_whenBrandDoesNotExist() { + // arrange + String body = """ + {"brandId": 999999, "name": "에어맥스", "price": 150000, "description": "러닝화"} + """; + + // act + ResponseEntity>> response = testRestTemplate.exchange( + PRODUCT_ENDPOINT, + HttpMethod.POST, + new HttpEntity<>(body, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("유효한 요청으로 상품을 등록하면 201과 상품 정보를 반환하고 이력이 1건 저장된다.") + @Test + void returnsProductInfo_andSavesHistory_whenValidRequestProvided() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키", null, null)); + String body = String.format(""" + {"brandId": %d, "name": "에어맥스", "price": 150000, "description": "러닝화"} + """, brand.getId()); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + PRODUCT_ENDPOINT, + HttpMethod.POST, + new HttpEntity<>(body, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> { + Map data = response.getBody().data(); + assertThat(data.get("name")).isEqualTo("에어맥스"); + assertThat(data.get("status")).isEqualTo("PENDING"); + assertThat(data).containsKey("productId"); + }, + () -> { + Long productId = ((Number) response.getBody().data().get("productId")).longValue(); + assertThat(productHistoryJpaRepository.countByProductId(productId)).isEqualTo(1); + } + ); + } + } + + @DisplayName("PUT /api-admin/v1/products/{productId}") + @Nested + class UpdateProduct { + + @DisplayName("상품 수정 시 200을 반환하고 이력이 2건이 된다.") + @Test + void returnsUpdatedProduct_andHistoryCount2_whenProductUpdated() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키", null, null)); + // 상품 등록 (이력 1건 생성) + String createBody = String.format(""" + {"brandId": %d, "name": "에어맥스", "price": 150000, "description": "러닝화"} + """, brand.getId()); + ResponseEntity>> createResponse = testRestTemplate.exchange( + PRODUCT_ENDPOINT, + HttpMethod.POST, + new HttpEntity<>(createBody, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + Long productId = ((Number) createResponse.getBody().data().get("productId")).longValue(); + + // 상품 수정 + String updateBody = String.format(""" + {"brandId": %d, "name": "에어맥스 90", "price": 160000, "description": "클래식 러닝화"} + """, brand.getId()); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + PRODUCT_ENDPOINT + "/" + productId, + HttpMethod.PUT, + new HttpEntity<>(updateBody, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().get("name")).isEqualTo("에어맥스 90"), + () -> assertThat(productHistoryJpaRepository.countByProductId(productId)).isEqualTo(2) + ); + } + } + + @DisplayName("GET /api-admin/v1/products/{productId}/history") + @Nested + class GetProductHistory { + + @DisplayName("상품 이력을 조회하면 200과 이력 목록을 반환한다.") + @Test + void returnsHistoryList_whenProductHistoryRequested() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키", null, null)); + String createBody = String.format(""" + {"brandId": %d, "name": "에어맥스", "price": 150000} + """, brand.getId()); + ResponseEntity>> createResponse = testRestTemplate.exchange( + PRODUCT_ENDPOINT, + HttpMethod.POST, + new HttpEntity<>(createBody, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + Long productId = ((Number) createResponse.getBody().data().get("productId")).longValue(); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + PRODUCT_ENDPOINT + "/" + productId + "/history", + HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> { + Map data = response.getBody().data(); + assertThat(data).containsKey("content"); + assertThat(data).containsKey("totalElements"); + } + ); + } + } + + // ───────────────────────────────────────────── + // 브랜드 조회 + // ───────────────────────────────────────────── + + @DisplayName("GET /api-admin/v1/brands/{brandId}") + @Nested + class GetAdminBrand { + + @DisplayName("존재하는 brandId를 조회하면 200과 브랜드 상세 정보를 반환한다.") + @Test + void returnsBrandDetail_whenBrandExists() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키", "스포츠 브랜드", "https://example.com/logo.png")); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + BRAND_ENDPOINT + "/" + brand.getId(), + HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> { + Map data = response.getBody().data(); + assertThat(((Number) data.get("brandId")).longValue()).isEqualTo(brand.getId()); + assertThat(data.get("name")).isEqualTo("나이키"); + assertThat(data.get("status")).isEqualTo("PENDING"); + assertThat(data).containsKey("createdAt"); + assertThat(data).containsKey("updatedAt"); + } + ); + } + + @DisplayName("존재하지 않는 brandId를 조회하면 404를 반환한다.") + @Test + void returnsNotFound_whenBrandDoesNotExist() { + // act + ResponseEntity>> response = testRestTemplate.exchange( + BRAND_ENDPOINT + "/999999", + HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @DisplayName("GET /api-admin/v1/brands") + @Nested + class GetAdminBrandList { + + @DisplayName("status=ACTIVE 필터를 주면 ACTIVE 브랜드만 반환한다.") + @Test + void returnsActiveBrandsOnly_whenStatusFilterProvided() { + // arrange + brandJpaRepository.save(new Brand("나이키", null, null)); // PENDING + + Brand active = brandJpaRepository.save(new Brand("아디다스", null, null)); + active.activate(); + brandJpaRepository.save(active); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + BRAND_ENDPOINT + "?status=ACTIVE", + HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> { + Map data = response.getBody().data(); + java.util.List> content = + (java.util.List>) data.get("content"); + assertThat(content).hasSize(1); + assertThat(content.get(0).get("name")).isEqualTo("아디다스"); + } + ); + } + + @DisplayName("필터 없이 조회하면 모든 상태의 브랜드 목록을 반환한다.") + @Test + void returnsBrandList_whenNoFilterProvided() { + // arrange + brandJpaRepository.save(new Brand("나이키", null, null)); // PENDING + Brand active = brandJpaRepository.save(new Brand("아디다스", null, null)); + active.activate(); + brandJpaRepository.save(active); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + BRAND_ENDPOINT, + HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> { + Map data = response.getBody().data(); + java.util.List> content = + (java.util.List>) data.get("content"); + assertThat(content).hasSize(2); + } + ); + } + } + + // ───────────────────────────────────────────── + // 상품 조회 / 비활성화 + // ───────────────────────────────────────────── + + @DisplayName("GET /api-admin/v1/products/{productId}") + @Nested + class GetAdminProduct { + + @DisplayName("존재하는 productId를 조회하면 200과 상품 상세 정보를 반환한다.") + @Test + void returnsProductDetail_whenProductExists() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키", null, null)); + Product product = productJpaRepository.save( + new Product(brand.getId(), "에어맥스", new Money(150000L), "러닝화") + ); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + PRODUCT_ENDPOINT + "/" + product.getId(), + HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> { + Map data = response.getBody().data(); + assertThat(((Number) data.get("productId")).longValue()).isEqualTo(product.getId()); + assertThat(data.get("name")).isEqualTo("에어맥스"); + assertThat(data.get("status")).isEqualTo("PENDING"); + assertThat(data.get("price")).isEqualTo(150000); + assertThat(data).containsKey("brandId"); + assertThat(data).containsKey("createdAt"); + } + ); + } + + @DisplayName("존재하지 않는 productId를 조회하면 404를 반환한다.") + @Test + void returnsNotFound_whenProductDoesNotExist() { + // act + ResponseEntity>> response = testRestTemplate.exchange( + PRODUCT_ENDPOINT + "/999999", + HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @DisplayName("GET /api-admin/v1/products") + @Nested + class GetAdminProductList { + + @DisplayName("brandId 필터를 주면 해당 브랜드의 상품만 반환한다.") + @Test + void returnsFilteredProducts_whenBrandIdProvided() { + // arrange + Brand nike = brandJpaRepository.save(new Brand("나이키", null, null)); + Brand adidas = brandJpaRepository.save(new Brand("아디다스", null, null)); + productJpaRepository.save(new Product(nike.getId(), "에어맥스", new Money(150000L), null)); + productJpaRepository.save(new Product(adidas.getId(), "울트라부스트", new Money(180000L), null)); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + PRODUCT_ENDPOINT + "?brandId=" + nike.getId(), + HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> { + Map data = response.getBody().data(); + java.util.List> content = + (java.util.List>) data.get("content"); + assertThat(content).hasSize(1); + assertThat(content.get(0).get("name")).isEqualTo("에어맥스"); + } + ); + } + + @DisplayName("status=ACTIVE 필터를 주면 ACTIVE 상품만 반환한다.") + @Test + void returnsActiveProductsOnly_whenStatusFilterProvided() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키", null, null)); + productJpaRepository.save(new Product(brand.getId(), "PENDING상품", new Money(100000L), null)); // PENDING + + Product activeProduct = productJpaRepository.save( + new Product(brand.getId(), "ACTIVE상품", new Money(150000L), null) + ); + activeProduct.activate(); + productJpaRepository.save(activeProduct); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + PRODUCT_ENDPOINT + "?status=ACTIVE", + HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> { + Map data = response.getBody().data(); + java.util.List> content = + (java.util.List>) data.get("content"); + assertThat(content).hasSize(1); + assertThat(content.get(0).get("name")).isEqualTo("ACTIVE상품"); + } + ); + } + } + + @DisplayName("DELETE /api-admin/v1/products/{productId}") + @Nested + class DeactivateProduct { + + @DisplayName("상품을 비활성화하면 204를 반환하고 상태가 INACTIVE가 된다.") + @Test + void returnsNoContent_andDeactivatesProduct_whenProductDeactivated() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키", null, null)); + Product product = productJpaRepository.save( + new Product(brand.getId(), "에어맥스", new Money(150000L), null) + ); + product.activate(); + productJpaRepository.save(product); + + // act + ResponseEntity response = testRestTemplate.exchange( + PRODUCT_ENDPOINT + "/" + product.getId(), + HttpMethod.DELETE, + new HttpEntity<>(adminHeaders()), + Void.class + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + Product updated = productJpaRepository.findById(product.getId()).orElseThrow(); + assertThat(updated.getStatus().name()).isEqualTo("INACTIVE"); + } + } + + // ───────────────────────────────────────────── + // 고객 API 상태 필터링 + // ───────────────────────────────────────────── + + @DisplayName("고객 API - INACTIVE 브랜드 조회 시 404를 반환한다.") + @Test + void returnsNotFound_whenBrandIsInactive() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키", null, null)); + brand.deactivate(); + brandJpaRepository.save(brand); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + "/api/v1/brands/" + brand.getId(), + HttpMethod.GET, + new HttpEntity<>(null), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("고객 API - INACTIVE 상품은 상품 목록에 포함되지 않는다.") + @Test + void excludesInactiveProducts_fromCustomerProductList() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키", null, null)); + brand.activate(); + brandJpaRepository.save(brand); + + Product activeProduct = productJpaRepository.save( + new Product(brand.getId(), "에어맥스", new Money(150000L), null) + ); + activeProduct.activate(); + productJpaRepository.save(activeProduct); + + Product inactiveProduct = productJpaRepository.save( + new Product(brand.getId(), "단종 상품", new Money(50000L), null) + ); + inactiveProduct.deactivate(); + productJpaRepository.save(inactiveProduct); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + "/api/v1/products", + HttpMethod.GET, + new HttpEntity<>(null), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> { + Map data = response.getBody().data(); + java.util.List> content = + (java.util.List>) data.get("content"); + assertThat(content).hasSize(1); + assertThat(content.get(0).get("name")).isEqualTo("에어맥스"); + } + ); + } +} diff --git a/http/commerce-api/admin-brand-v1.http b/http/commerce-api/admin-brand-v1.http new file mode 100644 index 000000000..860ba784d --- /dev/null +++ b/http/commerce-api/admin-brand-v1.http @@ -0,0 +1,47 @@ +### 브랜드 등록 (성공 → 201) +POST {{commerce-api}}/api-admin/v1/brands +X-Loopers-Ldap: loopers.admin +Content-Type: application/json + +{ + "name": "나이키", + "description": "스포츠 브랜드", + "logoImageUrl": "https://example.com/nike-logo.png" +} + +### 브랜드 등록 (LDAP 헤더 없음 → 403) +POST {{commerce-api}}/api-admin/v1/brands +Content-Type: application/json + +{ + "name": "나이키", + "description": "스포츠 브랜드", + "logoImageUrl": "https://example.com/nike-logo.png" +} + +### 브랜드 정보 수정 (성공 → 200) +PUT {{commerce-api}}/api-admin/v1/brands/1 +X-Loopers-Ldap: loopers.admin +Content-Type: application/json + +{ + "name": "나이키 코리아", + "description": "한국 나이키", + "logoImageUrl": "https://example.com/nike-korea-logo.png" +} + +### 브랜드 비활성화 (성공 → 204, 연관 상품 비동기 INACTIVE 처리) +DELETE {{commerce-api}}/api-admin/v1/brands/1 +X-Loopers-Ldap: loopers.admin + +### 브랜드 상세 조회 (성공 → 200, INACTIVE 포함 모든 상태 조회 가능) +GET {{commerce-api}}/api-admin/v1/brands/1 +X-Loopers-Ldap: loopers.admin + +### 브랜드 목록 조회 - 전체 (성공 → 200) +GET {{commerce-api}}/api-admin/v1/brands?page=0&size=20 +X-Loopers-Ldap: loopers.admin + +### 브랜드 목록 조회 - ACTIVE 필터 (성공 → 200, ACTIVE 브랜드만) +GET {{commerce-api}}/api-admin/v1/brands?status=ACTIVE&page=0&size=20 +X-Loopers-Ldap: loopers.admin diff --git a/http/commerce-api/admin-product-v1.http b/http/commerce-api/admin-product-v1.http new file mode 100644 index 000000000..2ef8ea5a7 --- /dev/null +++ b/http/commerce-api/admin-product-v1.http @@ -0,0 +1,60 @@ +### 상품 등록 (성공 → 201, ProductHistory 1건 자동 생성) +POST {{commerce-api}}/api-admin/v1/products +X-Loopers-Ldap: loopers.admin +Content-Type: application/json + +{ + "brandId": 1, + "name": "에어맥스 90", + "price": 150000, + "description": "클래식 러닝화", + "thumbnailImageUrl": "https://example.com/airmax90.png" +} + +### 상품 등록 (존재하지 않는 브랜드 → 404) +POST {{commerce-api}}/api-admin/v1/products +X-Loopers-Ldap: loopers.admin +Content-Type: application/json + +{ + "brandId": 999999, + "name": "에어맥스 90", + "price": 150000, + "description": "클래식 러닝화" +} + +### 상품 정보 수정 (성공 → 200, ProductHistory 누적) +PUT {{commerce-api}}/api-admin/v1/products/1 +X-Loopers-Ldap: loopers.admin +Content-Type: application/json + +{ + "brandId": 1, + "name": "에어맥스 90 리미티드", + "price": 180000, + "description": "한정판 러닝화" +} + +### 상품 변경 이력 조회 (성공 → 200, content + totalElements) +GET {{commerce-api}}/api-admin/v1/products/1/history?page=0&size=20 +X-Loopers-Ldap: loopers.admin + +### 상품 상세 조회 (성공 → 200, PENDING/INACTIVE 포함 모든 상태 조회 가능) +GET {{commerce-api}}/api-admin/v1/products/1 +X-Loopers-Ldap: loopers.admin + +### 상품 목록 조회 - 전체 (성공 → 200) +GET {{commerce-api}}/api-admin/v1/products?page=0&size=20 +X-Loopers-Ldap: loopers.admin + +### 상품 목록 조회 - brandId 필터 +GET {{commerce-api}}/api-admin/v1/products?brandId=1&page=0&size=20 +X-Loopers-Ldap: loopers.admin + +### 상품 목록 조회 - status 필터 +GET {{commerce-api}}/api-admin/v1/products?status=ACTIVE&page=0&size=20 +X-Loopers-Ldap: loopers.admin + +### 상품 비활성화 (성공 → 204) +DELETE {{commerce-api}}/api-admin/v1/products/1 +X-Loopers-Ldap: loopers.admin From dd3e41866ef1f84135f65edc155aa5212b1bcd31 Mon Sep 17 00:00:00 2001 From: katiekim17 Date: Fri, 27 Feb 2026 08:20:31 +0900 Subject: [PATCH 37/39] =?UTF-8?q?feature:=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/application/like/LikeFacade.java | 68 ++++ .../application/like/LikeListItem.java | 31 ++ .../java/com/loopers/domain/like/Like.java | 36 +- .../loopers/domain/like/LikeCreatedEvent.java | 3 + .../loopers/domain/like/LikeDeletedEvent.java | 3 + .../domain/like/LikeEventListener.java | 45 +++ .../loopers/domain/like/LikeRepository.java | 16 +- .../com/loopers/domain/like/LikeService.java | 64 ++++ .../like/LikeJpaRepository.java | 20 + .../like/LikeRepositoryImpl.java | 43 ++- .../interfaces/api/like/LikeController.java | 49 +++ .../interfaces/api/like/LikeV1ApiSpec.java | 33 ++ .../interfaces/api/like/LikeV1Dto.java | 53 +++ .../interfaces/api/LikeV1ApiE2ETest.java | 356 ++++++++++++++++++ http/commerce-api/like-v1.http | 49 +++ 15 files changed, 866 insertions(+), 3 deletions(-) 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/LikeListItem.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/LikeCreatedEvent.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/LikeDeletedEvent.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/LikeEventListener.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/interfaces/api/like/LikeController.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1ApiSpec.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/LikeV1ApiE2ETest.java create mode 100644 http/commerce-api/like-v1.http 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..f35151fdd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -0,0 +1,68 @@ +package com.loopers.application.like; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeService; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.users.UserService; +import com.loopers.domain.users.Users; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class LikeFacade { + + private final UserService userService; + private final LikeService likeService; + private final ProductService productService; + + // 좋아요 등록: 인증 → 상품 존재 검증 → 저장 → 비동기 카운트 증가 이벤트 + public void addLike(String loginId, String password, Long productId) { + Users user = userService.authenticate(loginId, password); + likeService.addLike(user.getId(), productId); + } + + // 좋아요 취소: 인증 → 멱등성 보장 삭제 → 비동기 카운트 감소 이벤트 + public void removeLike(String loginId, String password, Long productId) { + Users user = userService.authenticate(loginId, password); + likeService.removeLike(user.getId(), productId); + } + + /** + * 내 좋아요 목록 조회. + * - 인증된 사용자의 Like 목록 페이징 조회 + * - 관련 Product / Brand를 배치 로딩하여 N+1 방지 + */ + public Page getMyLikes(String loginId, String password, int page, int size) { + Users user = userService.authenticate(loginId, password); + Page likes = likeService.getMyLikes(user.getId(), PageRequest.of(page, size)); + + // 배치 로딩 + List productIds = likes.getContent().stream().map(Like::getProductId).toList(); + if (productIds.isEmpty()) { + return likes.map(like -> null); // 빈 페이지 + } + + List products = productService.getProducts(productIds); + Map productMap = products.stream() + .collect(Collectors.toMap(Product::getId, p -> p)); + + List brandIds = products.stream().map(Product::getBrandId).distinct().toList(); + List brands = productService.getBrands(brandIds); + Map brandMap = brands.stream() + .collect(Collectors.toMap(Brand::getId, b -> b)); + + return likes.map(like -> { + Product product = productMap.get(like.getProductId()); + Brand brand = brandMap.get(product.getBrandId()); + return LikeListItem.from(like, product, brand); + }); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeListItem.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeListItem.java new file mode 100644 index 000000000..94d1013b1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeListItem.java @@ -0,0 +1,31 @@ +package com.loopers.application.like; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.like.Like; +import com.loopers.domain.product.Product; +import java.time.ZonedDateTime; + +public record LikeListItem( + Long productId, + String name, + Long brandId, + String brandName, + String thumbnailImageUrl, + Long minPrice, + long likeCount, + ZonedDateTime likedAt +) { + + public static LikeListItem from(Like like, Product product, Brand brand) { + return new LikeListItem( + product.getId(), + product.getName(), + brand.getId(), + brand.getName(), + product.getThumbnailImageUrl(), + product.getPrice(), + product.getLikeCount(), + like.getCreatedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java index 8a2f7bb79..ad8ec867d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java @@ -1,5 +1,39 @@ package com.loopers.domain.like; -public class Like { +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +@Entity +@Table( + name = "likes", + uniqueConstraints = @UniqueConstraint( + name = "uk_likes_user_product", + columnNames = {"user_id", "product_id"} + ) +) +public class Like extends BaseEntity { + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "product_id", nullable = false) + private Long productId; + + protected Like() {} + + public Like(Long userId, Long productId) { + this.userId = userId; + this.productId = productId; + } + + public Long getUserId() { + return userId; + } + + public Long getProductId() { + return productId; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeCreatedEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeCreatedEvent.java new file mode 100644 index 000000000..614e39c8f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeCreatedEvent.java @@ -0,0 +1,3 @@ +package com.loopers.domain.like; + +public record LikeCreatedEvent(Long userId, Long productId) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeDeletedEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeDeletedEvent.java new file mode 100644 index 000000000..dc7f0ba0d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeDeletedEvent.java @@ -0,0 +1,3 @@ +package com.loopers.domain.like; + +public record LikeDeletedEvent(Long userId, Long productId) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeEventListener.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeEventListener.java new file mode 100644 index 000000000..21ae94435 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeEventListener.java @@ -0,0 +1,45 @@ +package com.loopers.domain.like; + +import com.loopers.domain.product.ProductRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@RequiredArgsConstructor +@Component +public class LikeEventListener { + + private final ProductRepository productRepository; + + /** + * 좋아요 등록 커밋 후 비동기로 like_count 증가. + * REQUIRES_NEW: 새 트랜잭션을 사용해 이벤트 리스너가 독립적으로 커밋한다. + */ + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleLikeCreated(LikeCreatedEvent event) { + productRepository.findById(event.productId()).ifPresent(product -> { + product.incrementLikeCount(); + productRepository.save(product); + }); + } + + /** + * 좋아요 취소 커밋 후 비동기로 like_count 감소. + * 최솟값 0 보장은 Product.decrementLikeCount()에서 처리한다. + */ + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleLikeDeleted(LikeDeletedEvent event) { + productRepository.findById(event.productId()).ifPresent(product -> { + product.decrementLikeCount(); + productRepository.save(product); + }); + } +} 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 0f90d773c..9280c13aa 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,5 +1,19 @@ package com.loopers.domain.like; -public class LikeRepository { +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +public interface LikeRepository { + + Like save(Like like); + + boolean existsByUserIdAndProductId(Long userId, Long productId); + + Optional findByUserIdAndProductId(Long userId, Long productId); + + void deleteByUserIdAndProductId(Long userId, Long productId); + + // 특정 유저의 좋아요 목록 (최신순 페이징) + Page findAllByUserId(Long userId, 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 cf751059d..429dc715a 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,5 +1,69 @@ package com.loopers.domain.like; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@RequiredArgsConstructor +@Component public class LikeService { + private final LikeRepository likeRepository; + private final ProductRepository productRepository; + private final ApplicationEventPublisher eventPublisher; + + /** + * 좋아요 등록. + * - 상품 존재 여부 검증 + * - 중복 좋아요는 409 반환 (race condition 대비 DataIntegrityViolationException 처리) + * - 성공 시 LikeCreatedEvent 발행 (비동기 카운트 업데이트) + */ + @Transactional + public Like addLike(Long userId, Long productId) { + productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다.")); + + if (likeRepository.existsByUserIdAndProductId(userId, productId)) { + throw new CoreException(ErrorType.CONFLICT, "이미 좋아요한 상품입니다."); + } + + try { + Like like = likeRepository.save(new Like(userId, productId)); + eventPublisher.publishEvent(new LikeCreatedEvent(userId, productId)); + return like; + } catch (DataIntegrityViolationException e) { + // 동시 요청으로 unique constraint 위반 시 409 반환 + throw new CoreException(ErrorType.CONFLICT, "이미 좋아요한 상품입니다."); + } + } + + /** + * 좋아요 취소 (멱등성 보장). + * - 좋아요가 없으면 에러 로그만 남기고 성공 응답 + * - 성공 시 LikeDeletedEvent 발행 (비동기 카운트 업데이트) + */ + @Transactional + public void removeLike(Long userId, Long productId) { + if (!likeRepository.existsByUserIdAndProductId(userId, productId)) { + log.warn("좋아요 취소 요청: 좋아요가 존재하지 않습니다. [userId={}, productId={}]", userId, productId); + return; + } + likeRepository.deleteByUserIdAndProductId(userId, productId); + eventPublisher.publishEvent(new LikeDeletedEvent(userId, productId)); + } + + @Transactional(readOnly = true) + public Page getMyLikes(Long userId, Pageable pageable) { + return likeRepository.findAllByUserId(userId, pageable); + } } 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..b5ad6a20f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java @@ -0,0 +1,20 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.Like; +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.transaction.annotation.Transactional; + +public interface LikeJpaRepository extends JpaRepository { + + boolean existsByUserIdAndProductId(Long userId, Long productId); + + Optional findByUserIdAndProductId(Long userId, Long productId); + + @Transactional + void deleteByUserIdAndProductId(Long userId, Long productId); + + Page findAllByUserId(Long userId, 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 fa042b4d6..b7f4f1c15 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 @@ -1,5 +1,46 @@ package com.loopers.infrastructure.like; -public class LikeRepositoryImpl { +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeRepository; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Component; +@RequiredArgsConstructor +@Component +public class LikeRepositoryImpl implements LikeRepository { + + private final LikeJpaRepository likeJpaRepository; + + @Override + public Like save(Like like) { + return likeJpaRepository.save(like); + } + + @Override + public boolean existsByUserIdAndProductId(Long userId, Long productId) { + return likeJpaRepository.existsByUserIdAndProductId(userId, productId); + } + + @Override + public Optional findByUserIdAndProductId(Long userId, Long productId) { + return likeJpaRepository.findByUserIdAndProductId(userId, productId); + } + + @Override + public void deleteByUserIdAndProductId(Long userId, Long productId) { + likeJpaRepository.deleteByUserIdAndProductId(userId, productId); + } + + // 최신순(createdAt DESC) 정렬로 페이징 + @Override + public Page findAllByUserId(Long userId, Pageable pageable) { + Pageable sorted = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), + Sort.by(Sort.Direction.DESC, "createdAt")); + return likeJpaRepository.findAllByUserId(userId, sorted); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeController.java new file mode 100644 index 000000000..011b2b922 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeController.java @@ -0,0 +1,49 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.like.LikeFacade; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +/** + * 좋아요 등록/취소 API. + * ProductsV1Controller와 같은 /api/v1/products 기반 경로를 사용하지만 + * HTTP 메서드(POST/DELETE)가 다르므로 충돌하지 않는다. + */ +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/products") +public class LikeController implements LikeV1ApiSpec { + + private final LikeFacade likeFacade; + + @PostMapping("/{productId}/likes") + @ResponseStatus(HttpStatus.CREATED) + @Override + public ApiResponse addLike( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, + @PathVariable Long productId + ) { + likeFacade.addLike(loginId, password, productId); + return ApiResponse.success(null); + } + + @DeleteMapping("/{productId}/likes") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Override + public void removeLike( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, + @PathVariable Long productId + ) { + likeFacade.removeLike(loginId, password, productId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1ApiSpec.java new file mode 100644 index 000000000..bb62ca650 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1ApiSpec.java @@ -0,0 +1,33 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.ResponseStatus; + +@Tag(name = "Like V1 API", description = "좋아요 API") +public interface LikeV1ApiSpec { + + @Operation(summary = "좋아요 등록", description = "상품에 좋아요를 등록한다. 중복 시 409.") + @PostMapping("/{productId}/likes") + @ResponseStatus(HttpStatus.CREATED) + ApiResponse addLike( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, + @PathVariable Long productId + ); + + @Operation(summary = "좋아요 취소", description = "좋아요를 취소한다. 이미 취소된 경우에도 성공 응답 (멱등성).") + @DeleteMapping("/{productId}/likes") + @ResponseStatus(HttpStatus.NO_CONTENT) + void removeLike( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, + @PathVariable Long productId + ); +} 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..7968dc7dd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java @@ -0,0 +1,53 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.like.LikeListItem; +import java.time.ZonedDateTime; +import java.util.List; +import org.springframework.data.domain.Page; + +public class LikeV1Dto { + + // 좋아요 목록 아이템 응답 + public record LikeListItemResponse( + Long productId, + String name, + BrandInfo brand, + String thumbnailImageUrl, + Long minPrice, + long likeCount, + ZonedDateTime likedAt + ) { + public record BrandInfo(Long brandId, String name) {} + + public static LikeListItemResponse from(LikeListItem item) { + return new LikeListItemResponse( + item.productId(), + item.name(), + new BrandInfo(item.brandId(), item.brandName()), + item.thumbnailImageUrl(), + item.minPrice(), + item.likeCount(), + item.likedAt() + ); + } + } + + // 페이징 응답 래퍼 + public record PageResponse( + List content, + int page, + int size, + long totalElements, + int totalPages + ) { + public static PageResponse from(Page page) { + return new PageResponse<>( + page.getContent(), + page.getNumber(), + page.getSize(), + page.getTotalElements(), + page.getTotalPages() + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeV1ApiE2ETest.java new file mode 100644 index 000000000..aca41db4f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeV1ApiE2ETest.java @@ -0,0 +1,356 @@ +package com.loopers.interfaces.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.common.Money; +import com.loopers.domain.product.Product; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.utils.DatabaseCleanUp; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +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; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class LikeV1ApiE2ETest { + + private static final String PRODUCTS_ENDPOINT = "/api/v1/products"; + private static final String USERS_ENDPOINT = "/api/v1/users"; + private static final String LOGIN_ID = "testuser"; + private static final String PASSWORD = "Test1234!"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + private final BrandJpaRepository brandJpaRepository; + private final ProductJpaRepository productJpaRepository; + + @Autowired + public LikeV1ApiE2ETest( + TestRestTemplate testRestTemplate, + DatabaseCleanUp databaseCleanUp, + BrandJpaRepository brandJpaRepository, + ProductJpaRepository productJpaRepository + ) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + this.brandJpaRepository = brandJpaRepository; + this.productJpaRepository = productJpaRepository; + } + + @BeforeEach + void setUp() { + // 테스트마다 유저 회원가입 + Map signUpRequest = Map.of( + "loginId", LOGIN_ID, + "password", PASSWORD, + "name", "홍길동", + "birthDate", "19900101", + "email", "test@example.com" + ); + testRestTemplate.exchange( + USERS_ENDPOINT, + HttpMethod.POST, + new HttpEntity<>(signUpRequest), + new ParameterizedTypeReference>>() {} + ); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private HttpHeaders authHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", LOGIN_ID); + headers.set("X-Loopers-LoginPw", PASSWORD); + headers.set("Content-Type", "application/json"); + return headers; + } + + @DisplayName("POST /api/v1/products/{productId}/likes (좋아요 등록)") + @Nested + class PostLike { + + @DisplayName("존재하는 상품에 좋아요를 누르면, 201 Created를 반환한다.") + @Test + void returnsCreated_whenProductExists() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product = productJpaRepository.save(new Product(brand.getId(), "신발", new Money(50000L), "설명")); + + // act + ResponseEntity> response = testRestTemplate.exchange( + PRODUCTS_ENDPOINT + "/" + product.getId() + "/likes", + HttpMethod.POST, + new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + } + + @DisplayName("같은 상품에 중복 좋아요를 누르면, 409 Conflict를 반환한다.") + @Test + void returnsConflict_whenLikeAlreadyExists() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product = productJpaRepository.save(new Product(brand.getId(), "신발", new Money(50000L), "설명")); + testRestTemplate.exchange( + PRODUCTS_ENDPOINT + "/" + product.getId() + "/likes", + HttpMethod.POST, + new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference>() {} + ); + + // act - 두 번째 좋아요 시도 + ResponseEntity> response = testRestTemplate.exchange( + PRODUCTS_ENDPOINT + "/" + product.getId() + "/likes", + HttpMethod.POST, + new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } + + @DisplayName("잘못된 비밀번호로 요청하면, 401 Unauthorized를 반환한다.") + @Test + void returnsUnauthorized_whenPasswordIsWrong() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product = productJpaRepository.save(new Product(brand.getId(), "신발", new Money(50000L), "설명")); + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", LOGIN_ID); + headers.set("X-Loopers-LoginPw", "WrongPass1!"); + + // act + ResponseEntity> response = testRestTemplate.exchange( + PRODUCTS_ENDPOINT + "/" + product.getId() + "/likes", + HttpMethod.POST, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @DisplayName("존재하지 않는 상품에 좋아요를 누르면, 404 Not Found를 반환한다.") + @Test + void returnsNotFound_whenProductDoesNotExist() { + // act + ResponseEntity> response = testRestTemplate.exchange( + PRODUCTS_ENDPOINT + "/999999/likes", + HttpMethod.POST, + new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @DisplayName("DELETE /api/v1/products/{productId}/likes (좋아요 취소)") + @Nested + class DeleteLike { + + @DisplayName("좋아요한 상품을 취소하면, 204 No Content를 반환한다.") + @Test + void returnsNoContent_whenLikeExists() { + // arrange - 먼저 좋아요 등록 + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product = productJpaRepository.save(new Product(brand.getId(), "신발", new Money(50000L), "설명")); + testRestTemplate.exchange( + PRODUCTS_ENDPOINT + "/" + product.getId() + "/likes", + HttpMethod.POST, + new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference>() {} + ); + + // act + ResponseEntity response = testRestTemplate.exchange( + PRODUCTS_ENDPOINT + "/" + product.getId() + "/likes", + HttpMethod.DELETE, + new HttpEntity<>(authHeaders()), + Void.class + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + } + + @DisplayName("이미 취소된 좋아요를 재요청해도, 204 No Content를 반환한다 (멱등성).") + @Test + void returnsNoContent_whenLikeAlreadyCancelled() { + // arrange - 좋아요 없이 바로 취소 요청 + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product = productJpaRepository.save(new Product(brand.getId(), "신발", new Money(50000L), "설명")); + + // act + ResponseEntity response = testRestTemplate.exchange( + PRODUCTS_ENDPOINT + "/" + product.getId() + "/likes", + HttpMethod.DELETE, + new HttpEntity<>(authHeaders()), + Void.class + ); + + // assert - 멱등성: 좋아요가 없어도 성공 + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + } + + @DisplayName("잘못된 비밀번호로 요청하면, 401 Unauthorized를 반환한다.") + @Test + void returnsUnauthorized_whenPasswordIsWrong() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product = productJpaRepository.save(new Product(brand.getId(), "신발", new Money(50000L), "설명")); + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", LOGIN_ID); + headers.set("X-Loopers-LoginPw", "WrongPass1!"); + + // act + ResponseEntity response = testRestTemplate.exchange( + PRODUCTS_ENDPOINT + "/" + product.getId() + "/likes", + HttpMethod.DELETE, + new HttpEntity<>(headers), + Void.class + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + @DisplayName("GET /api/v1/users/{userId}/likes (내 좋아요 목록 조회)") + @Nested + class GetMyLikes { + + @DisplayName("좋아요한 상품이 있으면, 200 OK와 목록을 반환한다.") + @Test + void returnsLikeList_whenLikesExist() { + // arrange - 상품 2개 좋아요 + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product productA = productJpaRepository.save(new Product(brand.getId(), "신발A", new Money(50000L), "설명A")); + Product productB = productJpaRepository.save(new Product(brand.getId(), "신발B", new Money(80000L), "설명B")); + testRestTemplate.exchange(PRODUCTS_ENDPOINT + "/" + productA.getId() + "/likes", HttpMethod.POST, new HttpEntity<>(authHeaders()), new ParameterizedTypeReference>() {}); + testRestTemplate.exchange(PRODUCTS_ENDPOINT + "/" + productB.getId() + "/likes", HttpMethod.POST, new HttpEntity<>(authHeaders()), new ParameterizedTypeReference>() {}); + + // act - userId는 무시되고 헤더 인증 기준으로 조회 + ResponseEntity>> response = testRestTemplate.exchange( + USERS_ENDPOINT + "/1/likes", + HttpMethod.GET, + new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> { + Map data = response.getBody().data(); + List content = (List) data.get("content"); + // 좋아요 2건이 있어야 함 + assertThat(content).hasSize(2); + } + ); + } + + @DisplayName("좋아요가 없으면, 200 OK와 빈 목록을 반환한다.") + @Test + void returnsEmptyList_whenNoLikes() { + // act + ResponseEntity>> response = testRestTemplate.exchange( + USERS_ENDPOINT + "/1/likes", + HttpMethod.GET, + new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> { + List content = (List) response.getBody().data().get("content"); + assertThat(content).isEmpty(); + } + ); + } + + @DisplayName("잘못된 비밀번호로 요청하면, 401 Unauthorized를 반환한다.") + @Test + void returnsUnauthorized_whenPasswordIsWrong() { + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", LOGIN_ID); + headers.set("X-Loopers-LoginPw", "WrongPass1!"); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + USERS_ENDPOINT + "/1/likes", + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + @DisplayName("좋아요 수 비동기 반영") + @Nested + class LikeCount { + + @DisplayName("좋아요 등록 후, 상품 상세의 likeCount가 비동기로 1 증가한다.") + @Test + void incrementsLikeCount_afterLikeIsAdded() { + // arrange - 상품 ACTIVE 상태로 생성 + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product = productJpaRepository.save(new Product(brand.getId(), "신발", new Money(50000L), "설명")); + product.activate(); + productJpaRepository.save(product); + + // act - 좋아요 등록 + testRestTemplate.exchange( + PRODUCTS_ENDPOINT + "/" + product.getId() + "/likes", + HttpMethod.POST, + new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference>() {} + ); + + // assert - 비동기 이벤트 처리 완료 대기 후 likeCount 확인 + await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> { + ResponseEntity>> detailResponse = testRestTemplate.exchange( + PRODUCTS_ENDPOINT + "/" + product.getId(), + HttpMethod.GET, + new HttpEntity<>(new HttpHeaders()), + new ParameterizedTypeReference<>() {} + ); + assertThat(((Number) detailResponse.getBody().data().get("likeCount")).longValue()).isEqualTo(1L); + }); + } + } +} diff --git a/http/commerce-api/like-v1.http b/http/commerce-api/like-v1.http new file mode 100644 index 000000000..8c88ca5e0 --- /dev/null +++ b/http/commerce-api/like-v1.http @@ -0,0 +1,49 @@ +### 좋아요 추가 (성공 → 201) +POST {{commerce-api}}/api/v1/products/1/likes +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! + +### 좋아요 추가 (중복 → 409) +POST {{commerce-api}}/api/v1/products/1/likes +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! + +### 좋아요 추가 (인증 실패 → 401) +POST {{commerce-api}}/api/v1/products/1/likes +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: WrongPassword! + +### 좋아요 추가 (존재하지 않는 상품 → 404) +POST {{commerce-api}}/api/v1/products/999999/likes +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! + +### 좋아요 취소 (성공 → 204) +DELETE {{commerce-api}}/api/v1/products/1/likes +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! + +### 좋아요 취소 (멱등성: 없어도 204) +DELETE {{commerce-api}}/api/v1/products/1/likes +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! + +### 좋아요 취소 (인증 실패 → 401) +DELETE {{commerce-api}}/api/v1/products/1/likes +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: WrongPassword! + +### 내 좋아요 목록 조회 (성공 → 200, PageResponse) +GET {{commerce-api}}/api/v1/users/1/likes?page=0&size=20 +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! + +### 내 좋아요 목록 조회 (빈 목록 → 200) +GET {{commerce-api}}/api/v1/users/1/likes +X-Loopers-LoginId: newuser +X-Loopers-LoginPw: Test1234! + +### 내 좋아요 목록 조회 (인증 실패 → 401) +GET {{commerce-api}}/api/v1/users/1/likes +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: WrongPassword! From f2980457e59ab33a85c4afbe9605ed10c06818c2 Mon Sep 17 00:00:00 2001 From: katiekim17 Date: Fri, 27 Feb 2026 08:21:49 +0900 Subject: [PATCH 38/39] =?UTF-8?q?feature:=20=EC=A3=BC=EB=AC=B8=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/order/OrderDetailInfo.java | 25 +++ .../application/order/OrderFacade.java | 23 +++ .../application/order/OrderItemInfo.java | 26 +++ .../product/ProductDetailInfo.java | 2 +- .../application/product/ProductListInfo.java | 2 +- .../domain/order/OrderItemRepository.java | 3 + .../loopers/domain/order/OrderRepository.java | 9 + .../loopers/domain/order/OrderService.java | 24 +++ .../com/loopers/domain/product/Product.java | 18 ++ .../order/OrderItemJpaRepository.java | 3 + .../order/OrderItemRepositoryImpl.java | 5 + .../order/OrderJpaRepository.java | 4 + .../order/OrderRepositoryImpl.java | 18 ++ .../interfaces/api/order/OrderController.java | 31 ++++ .../interfaces/api/order/OrderV1ApiSpec.java | 22 ++- .../interfaces/api/order/OrderV1Dto.java | 81 ++++++++ .../api/product/ProductsV1ApiSpec.java | 2 +- .../api/product/ProductsV1Controller.java | 3 +- .../interfaces/api/OrderV1ApiE2ETest.java | 174 ++++++++++++++++++ http/commerce-api/order-v1.http | 20 ++ 20 files changed, 487 insertions(+), 8 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderDetailInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderDetailInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderDetailInfo.java new file mode 100644 index 000000000..d7ee2097b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderDetailInfo.java @@ -0,0 +1,25 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; +import java.time.ZonedDateTime; +import java.util.List; + +public record OrderDetailInfo( + Long orderId, + String status, + Long totalAmount, + ZonedDateTime createdAt, + List items +) { + + public static OrderDetailInfo from(Order order, List items) { + return new OrderDetailInfo( + order.getId(), + order.getStatus().name(), + order.getTotalAmount(), + order.getCreatedAt(), + items.stream().map(OrderItemInfo::from).toList() + ); + } +} 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 0ad24bd23..1a9d5dd13 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 @@ -3,6 +3,8 @@ import com.loopers.domain.brand.Brand; import com.loopers.domain.common.Quantity; import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; +import com.loopers.domain.order.OrderItemRepository; import com.loopers.domain.order.OrderService; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductService; @@ -13,6 +15,8 @@ import java.util.Map; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Component; @RequiredArgsConstructor @@ -22,6 +26,7 @@ public class OrderFacade { private final UserService userService; private final ProductService productService; private final OrderService orderService; + private final OrderItemRepository orderItemRepository; public OrderInfo createOrder(String loginId, String password, List items) { Users user = userService.authenticate(loginId, password); @@ -41,4 +46,22 @@ public OrderInfo createOrder(String loginId, String password, List getOrderList(String loginId, String password, int page, int size) { + Users user = userService.authenticate(loginId, password); + return orderService.getOrderList(user.getId(), PageRequest.of(page, size)) + .map(OrderInfo::from); + } + + /** + * 주문 단건 상세 조회. + * OrderService에서 소유권 검증 후, OrderItem 목록을 함께 반환한다. + */ + public OrderDetailInfo getOrderDetail(String loginId, String password, Long orderId) { + Users user = userService.authenticate(loginId, password); + Order order = orderService.getOrder(user.getId(), orderId); + List items = orderItemRepository.findAllByOrderId(orderId); + return OrderDetailInfo.from(order, items); + } } 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..8dd90d5a3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java @@ -0,0 +1,26 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.OrderItem; + +public record OrderItemInfo( + Long orderItemId, + Long productId, + String productName, + String brandName, + Long price, + Long quantity, + String status +) { + + public static OrderItemInfo from(OrderItem item) { + return new OrderItemInfo( + item.getId(), + item.getProductId(), + item.getSnapshot().getProductName(), + item.getSnapshot().getBrandName(), + item.getSnapshot().getProductPrice(), + item.getQuantity(), + item.getStatus().name() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java index 058a37271..596388382 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java @@ -36,7 +36,7 @@ public static ProductDetailInfo from( options.stream() .map(o -> new OptionInfo(o.getId(), o.getName(), o.getPrice(), o.getStockQuantity(), o.isAvailable())) .toList(), - 0L, + product.getLikeCount(), product.getCreatedAt() ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductListInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductListInfo.java index 1ee3792a9..da49cc691 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductListInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductListInfo.java @@ -22,7 +22,7 @@ public static ProductListInfo from(ProductListItem item) { item.brand().getName(), item.product().getThumbnailImageUrl(), item.minPrice(), - 0L, + item.product().getLikeCount(), item.product().getCreatedAt() ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java index 6de57cb2a..66b89bc6f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java @@ -5,4 +5,7 @@ public interface OrderItemRepository { List saveAll(List orderItems); + + // 특정 주문의 아이템 목록 조회 + List findAllByOrderId(Long orderId); } 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 5a83a7f65..db963769d 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,15 @@ package com.loopers.domain.order; +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + public interface OrderRepository { Order save(Order order); + + Optional findById(Long id); + + // 특정 회원의 주문 목록 페이징 조회 (최신순) + Page findAllByMemberId(Long memberId, Pageable 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 06ebbe95f..fd993a9c2 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 @@ -4,9 +4,13 @@ import com.loopers.domain.common.Money; import com.loopers.domain.common.Quantity; import com.loopers.domain.product.Product; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import java.util.List; import java.util.Map; 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; @@ -50,4 +54,24 @@ public Order createOrder( return order; } + + // 특정 회원의 주문 목록을 최신순으로 페이징 조회한다. + @Transactional(readOnly = true) + public Page getOrderList(Long memberId, Pageable pageable) { + return orderRepository.findAllByMemberId(memberId, pageable); + } + + /** + * 주문 단건 조회 + 본인 소유 검증. + * 타인의 주문이거나 존재하지 않으면 보안상 동일하게 NOT_FOUND를 반환한다. + */ + @Transactional(readOnly = true) + public Order getOrder(Long memberId, Long orderId) { + Order order = orderRepository.findById(orderId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 주문입니다.")); + if (!order.getMemberId().equals(memberId)) { + throw new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 주문입니다."); + } + return order; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java index 1d9e91c9c..b583a532d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -36,6 +36,10 @@ public class Product extends BaseEntity { @Column(name = "status", nullable = false, length = 20) private ProductStatus status = ProductStatus.PENDING; + // 비정규화 카운트: 좋아요 등록/취소 시 비동기 이벤트로 갱신 (Eventual Consistency) + @Column(name = "like_count", nullable = false) + private long likeCount = 0; + protected Product() {} public Product(Long brandId, String name, Money price, String description) { @@ -111,4 +115,18 @@ public String getThumbnailImageUrl() { public ProductStatus getStatus() { return status; } + + public void incrementLikeCount() { + this.likeCount++; + } + + public void decrementLikeCount() { + if (this.likeCount > 0) { + this.likeCount--; + } + } + + public long getLikeCount() { + return likeCount; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java index 9d82b0259..97f7a0e85 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java @@ -1,7 +1,10 @@ package com.loopers.infrastructure.order; import com.loopers.domain.order.OrderItem; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; public interface OrderItemJpaRepository extends JpaRepository { + + List findAllByOrderId(Long orderId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java index a1f9e17b2..f375335ee 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java @@ -16,4 +16,9 @@ public class OrderItemRepositoryImpl implements OrderItemRepository { public List saveAll(List orderItems) { return orderItemJpaRepository.saveAll(orderItems); } + + @Override + public List findAllByOrderId(Long orderId) { + return orderItemJpaRepository.findAllByOrderId(orderId); + } } 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 f2ee62050..2059ab61f 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,7 +1,11 @@ package com.loopers.infrastructure.order; import com.loopers.domain.order.Order; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; public interface OrderJpaRepository extends JpaRepository { + + Page findAllByMemberId(Long memberId, 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 5ea7ca142..48351899e 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 @@ -2,7 +2,12 @@ import com.loopers.domain.order.Order; import com.loopers.domain.order.OrderRepository; +import java.util.Optional; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Component; @RequiredArgsConstructor @@ -15,4 +20,17 @@ public class OrderRepositoryImpl implements OrderRepository { public Order save(Order order) { return orderJpaRepository.save(order); } + + @Override + public Optional findById(Long id) { + return orderJpaRepository.findById(id); + } + + // 최신순(createdAt DESC)으로 페이징 조회 + @Override + public Page findAllByMemberId(Long memberId, Pageable pageable) { + Pageable sorted = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), + Sort.by(Sort.Direction.DESC, "createdAt")); + return orderJpaRepository.findAllByMemberId(memberId, sorted); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java index 81c5e0a09..fa4670fee 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java @@ -5,10 +5,13 @@ import com.loopers.interfaces.api.ApiResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; 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.RequestParam; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; @@ -31,4 +34,32 @@ public ApiResponse createOrder( return ApiResponse.success(OrderV1Dto.CreateOrderResponse.from(info)); } + + // 인증된 사용자의 주문 목록을 최신순 페이징으로 반환한다. + @GetMapping + @Override + public ApiResponse> getOrderList( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, + @RequestParam(required = false, defaultValue = "0") int page, + @RequestParam(required = false, defaultValue = "20") int size + ) { + return ApiResponse.success(OrderV1Dto.PageResponse.from( + orderFacade.getOrderList(loginId, password, page, size) + .map(OrderV1Dto.OrderSummaryResponse::from) + )); + } + + // 주문 ID로 단건 상세(아이템 포함)를 반환한다. 타인의 주문은 404 처리. + @GetMapping("/{orderId}") + @Override + public ApiResponse getOrderDetail( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, + @PathVariable Long orderId + ) { + return ApiResponse.success(OrderV1Dto.OrderDetailResponse.from( + orderFacade.getOrderDetail(loginId, password, orderId) + )); + } } 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 f724ec314..a772cdfd7 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 @@ -3,18 +3,32 @@ import com.loopers.interfaces.api.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; @Tag(name = "Order V1 API", description = "주문 API") public interface OrderV1ApiSpec { - @Operation( - summary = "주문 생성", - description = "상품 목록을 받아 주문을 생성한다." - ) + @Operation(summary = "주문 생성", description = "상품 목록을 받아 주문을 생성한다.") ApiResponse createOrder( @RequestHeader("X-Loopers-LoginId") String loginId, @RequestHeader("X-Loopers-LoginPw") String password, OrderV1Dto.CreateOrderRequest request ); + + @Operation(summary = "유저 주문 목록 조회", description = "인증된 사용자의 주문 목록을 최신순으로 반환한다.") + ApiResponse> getOrderList( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, + @RequestParam(required = false, defaultValue = "0") int page, + @RequestParam(required = false, defaultValue = "20") int size + ); + + @Operation(summary = "주문 단건 상세 조회", description = "주문 ID로 주문 상세 및 아이템 목록을 반환한다.") + ApiResponse getOrderDetail( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, + @PathVariable Long orderId + ); } 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 0326961ff..377905b99 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 @@ -1,8 +1,11 @@ package com.loopers.interfaces.api.order; +import com.loopers.application.order.OrderDetailInfo; import com.loopers.application.order.OrderInfo; +import com.loopers.application.order.OrderItemInfo; import java.time.ZonedDateTime; import java.util.List; +import org.springframework.data.domain.Page; public class OrderV1Dto { @@ -30,4 +33,82 @@ public static CreateOrderResponse from(OrderInfo info) { ); } } + + // 주문 목록 아이템 응답 + public record OrderSummaryResponse( + Long orderId, + String status, + Long totalAmount, + ZonedDateTime createdAt + ) { + public static OrderSummaryResponse from(OrderInfo info) { + return new OrderSummaryResponse( + info.orderId(), + info.status().name(), + info.totalAmount(), + info.createdAt() + ); + } + } + + // 주문 아이템 응답 (상세 조회 내 포함) + public record OrderItemResponse( + Long orderItemId, + Long productId, + String productName, + String brandName, + Long price, + Long quantity, + String status + ) { + public static OrderItemResponse from(OrderItemInfo info) { + return new OrderItemResponse( + info.orderItemId(), + info.productId(), + info.productName(), + info.brandName(), + info.price(), + info.quantity(), + info.status() + ); + } + } + + // 주문 상세 응답 + public record OrderDetailResponse( + Long orderId, + String status, + Long totalAmount, + ZonedDateTime createdAt, + List items + ) { + public static OrderDetailResponse from(OrderDetailInfo info) { + return new OrderDetailResponse( + info.orderId(), + info.status(), + info.totalAmount(), + info.createdAt(), + info.items().stream().map(OrderItemResponse::from).toList() + ); + } + } + + // 페이징 응답 래퍼 + public record PageResponse( + List content, + int page, + int size, + long totalElements, + int totalPages + ) { + public static PageResponse from(Page page) { + return new PageResponse<>( + page.getContent(), + page.getNumber(), + page.getSize(), + page.getTotalElements(), + page.getTotalPages() + ); + } + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductsV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductsV1ApiSpec.java index 0fe839996..88a3d1f18 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductsV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductsV1ApiSpec.java @@ -24,4 +24,4 @@ ApiResponse> get ApiResponse getProduct( @PathVariable(value = "productId") Long productId ); -} \ No newline at end of file +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductsV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductsV1Controller.java index a3188a859..067b88016 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductsV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductsV1Controller.java @@ -39,4 +39,5 @@ public ApiResponse getProduct( ProductV1Dto.ProductDetailResponse.from(productFacade.getProductDetail(productId)) ); } -} \ No newline at end of file + +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java index ad0d6841f..fdadb93f0 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java @@ -209,4 +209,178 @@ void returnsNotFound_whenProductDoesNotExist() { assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); } } + + @DisplayName("GET /api/v1/orders (유저 주문 목록 조회)") + @Nested + class GetOrderList { + + @DisplayName("인증 성공 시, 200 OK와 본인의 주문 목록을 반환한다.") + @Test + void returnsOrderList_whenAuthIsValid() { + // arrange - 주문 2건 생성 + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product = productJpaRepository.save(new Product(brand.getId(), "신발", new Money(50000L), "설명")); + stockJpaRepository.save(new Stock(product.getId(), 100L)); + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", LOGIN_ID); + headers.set("X-Loopers-LoginPw", PASSWORD); + headers.set("Content-Type", "application/json"); + + Map orderRequest = Map.of("items", List.of(Map.of("productId", product.getId(), "quantity", 1))); + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(orderRequest, headers), new ParameterizedTypeReference>>() {}); + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(orderRequest, headers), new ParameterizedTypeReference>>() {}); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT, + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> { + Map data = response.getBody().data(); + assertThat(data).isNotNull(); + List content = (List) data.get("content"); + assertThat(content).hasSize(2); + } + ); + } + + @DisplayName("주문이 없는 유저 조회 시, 200 OK와 빈 목록을 반환한다.") + @Test + void returnsEmptyList_whenNoOrders() { + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", LOGIN_ID); + headers.set("X-Loopers-LoginPw", PASSWORD); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT, + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> { + List content = (List) response.getBody().data().get("content"); + assertThat(content).isEmpty(); + } + ); + } + + @DisplayName("잘못된 비밀번호로 요청하면, 401 Unauthorized를 반환한다.") + @Test + void returnsUnauthorized_whenPasswordIsWrong() { + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", LOGIN_ID); + headers.set("X-Loopers-LoginPw", "WrongPass1!"); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT, + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + @DisplayName("GET /api/v1/orders/{orderId} (주문 단건 상세 조회)") + @Nested + class GetOrderDetail { + + @DisplayName("본인 주문 조회 시, 200 OK와 주문 아이템을 포함한 상세를 반환한다.") + @Test + void returnsOrderDetail_whenOrderBelongsToUser() { + // arrange - 주문 생성 + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product = productJpaRepository.save(new Product(brand.getId(), "신발", new Money(50000L), "설명")); + stockJpaRepository.save(new Stock(product.getId(), 100L)); + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", LOGIN_ID); + headers.set("X-Loopers-LoginPw", PASSWORD); + headers.set("Content-Type", "application/json"); + + Map orderRequest = Map.of("items", List.of(Map.of("productId", product.getId(), "quantity", 2))); + ResponseEntity>> createResponse = testRestTemplate.exchange( + ENDPOINT, HttpMethod.POST, new HttpEntity<>(orderRequest, headers), new ParameterizedTypeReference<>() {} + ); + Long orderId = ((Number) createResponse.getBody().data().get("orderId")).longValue(); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "/" + orderId, + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> { + Map data = response.getBody().data(); + assertThat(((Number) data.get("orderId")).longValue()).isEqualTo(orderId); + assertThat(data.get("status")).isEqualTo("CREATED"); + assertThat(((Number) data.get("totalAmount")).longValue()).isEqualTo(100000L); + List items = (List) data.get("items"); + assertThat(items).hasSize(1); + } + ); + } + + @DisplayName("존재하지 않는 주문 ID로 조회 시, 404 Not Found를 반환한다.") + @Test + void returnsNotFound_whenOrderDoesNotExist() { + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", LOGIN_ID); + headers.set("X-Loopers-LoginPw", PASSWORD); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "/999999", + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("잘못된 비밀번호로 요청하면, 401 Unauthorized를 반환한다.") + @Test + void returnsUnauthorized_whenPasswordIsWrong() { + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", LOGIN_ID); + headers.set("X-Loopers-LoginPw", "WrongPass1!"); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "/1", + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } } diff --git a/http/commerce-api/order-v1.http b/http/commerce-api/order-v1.http index 149676a13..683b645ea 100644 --- a/http/commerce-api/order-v1.http +++ b/http/commerce-api/order-v1.http @@ -46,3 +46,23 @@ Content-Type: application/json { "productId": 999999, "quantity": 1 } ] } + +### 유저 주문 목록 조회 (성공 → 200, PageResponse) +GET {{commerce-api}}/api/v1/orders?page=0&size=20 +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! + +### 유저 주문 목록 조회 (인증 실패 → 401) +GET {{commerce-api}}/api/v1/orders +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: WrongPassword! + +### 주문 단건 상세 조회 (성공 → 200, items 포함) +GET {{commerce-api}}/api/v1/orders/1 +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! + +### 주문 단건 상세 조회 (존재하지 않는 주문 → 404) +GET {{commerce-api}}/api/v1/orders/999999 +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: Test1234! From 22b0127b095fc59af7fd619770525176365ce27d Mon Sep 17 00:00:00 2001 From: katiekim17 Date: Fri, 27 Feb 2026 08:21:56 +0900 Subject: [PATCH 39/39] =?UTF-8?q?feature:=20=EB=82=B4=20=EC=A2=8B=EC=95=84?= =?UTF-8?q?=EC=9A=94=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interfaces/api/users/UserV1ApiSpec.java | 59 +++++------- .../api/users/UserV1Controller.java | 94 ++++++++++--------- 2 files changed, 74 insertions(+), 79 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/users/UserV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/users/UserV1ApiSpec.java index 5df7f3e08..321f58de3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/users/UserV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/users/UserV1ApiSpec.java @@ -1,49 +1,38 @@ package com.loopers.interfaces.api.users; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.like.LikeV1Dto; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; @Tag(name = "Member V1 API", description = "회원 API") public interface UserV1ApiSpec { - @Operation( - summary = "회원 가입 요청", - description = "주어진 정보를 가지고 회원 가입을 실행한다" - ) - // @Schema는 Swagger API 문서에서 파라미터 설명을 보여주는 용도 - // 예제에서는 Long exampleId 같은 단일 파라미터에 붙였는데, 지금은 SignUpRequest로 통째로 받으니까 여기엔 필요 없음 - ApiResponse signUp( - UserV1Dto.SignUpRequest request - ); + @Operation(summary = "회원 가입 요청", description = "주어진 정보를 가지고 회원 가입을 실행한다") + ApiResponse signUp(UserV1Dto.SignUpRequest request); - @Operation( - summary = "내 정보 조회", - description = "로그인 ID로 내 회원 정보를 조회한다" - ) - ApiResponse getMyInfo( - @RequestHeader("X-Loopers-LoginId") String loginId, - @RequestHeader("X-Loopers-LoginPw") String password - ); + @Operation(summary = "내 정보 조회", description = "로그인 ID로 내 회원 정보를 조회한다") + ApiResponse getMyInfo( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password + ); - @Operation( - summary = "비밀번호 변경", - description = "기존 비밀번호와 새 비밀번호를 받아 비밀번호를 변경한다" - ) - ApiResponse changePassword( - @RequestHeader("X-Loopers-LoginId") String loginId, - @RequestHeader("X-Loopers-LoginPw") String password, - UserV1Dto.ChangePasswordRequest request - ); - - @Operation( - summary = "내 정보 조회", - description = "로그인 ID로 내 회원 정보를 조회한다" - ) - ApiResponse getMyLikes( - @RequestHeader("X-Loopers-LoginId") String loginId, - @RequestHeader("X-Loopers-LoginPw") String password - ); + @Operation(summary = "비밀번호 변경", description = "기존 비밀번호와 새 비밀번호를 받아 비밀번호를 변경한다") + ApiResponse changePassword( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, + UserV1Dto.ChangePasswordRequest request + ); + @Operation(summary = "내 좋아요 목록 조회", description = "인증된 사용자의 좋아요 목록을 반환한다. URL의 userId는 무시된다.") + ApiResponse> getMyLikes( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, + @PathVariable Long userId, + @RequestParam(required = false, defaultValue = "0") int page, + @RequestParam(required = false, defaultValue = "20") int size + ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/users/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/users/UserV1Controller.java index 83255a64f..f47dac52d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/users/UserV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/users/UserV1Controller.java @@ -1,8 +1,10 @@ package com.loopers.interfaces.api.users; +import com.loopers.application.like.LikeFacade; import com.loopers.application.users.UserFacade; import com.loopers.application.users.UserInfo; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.like.LikeV1Dto; import com.loopers.interfaces.api.users.UserV1Dto.ChangePasswordRequest; import com.loopers.interfaces.api.users.UserV1Dto.UserInfoResponse; import com.loopers.interfaces.api.users.UserV1Dto.SignUpRequest; @@ -11,10 +13,12 @@ import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; 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.RequestParam; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; @@ -23,52 +27,54 @@ @RequestMapping("/api/v1/users") public class UserV1Controller implements UserV1ApiSpec { - // 클라이언트 → [SignUpRequest (record)] → Controller → Facade → Service → [MemberModel (entity)] → DB - // 요청 데이터 전달용 DB에 저장되는 객체 - // DB → [MemberModel (entity)] → Facade → [SignUpResponse (record)] → Controller → 클라이언트 - // DB에서 꺼낸 객체 응답 데이터 전달용 - private final UserFacade userFacade; + private final UserFacade userFacade; + private final LikeFacade likeFacade; - @PostMapping - @ResponseStatus(HttpStatus.CREATED) - @Override - public ApiResponse signUp(@RequestBody SignUpRequest request) { - UserInfo info = userFacade.signupUser(request); - UserV1Dto.SignUpResponse response = UserV1Dto.SignUpResponse.from(info); - return ApiResponse.success(response); - } + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @Override + public ApiResponse signUp(@RequestBody SignUpRequest request) { + UserInfo info = userFacade.signupUser(request); + return ApiResponse.success(SignUpResponse.from(info)); + } - @GetMapping("/me") - @Override - public ApiResponse getMyInfo( - @RequestHeader("X-Loopers-LoginId") String loginId, - @RequestHeader("X-Loopers-LoginPw") String password - ) { - UserInfo info = userFacade.getMyInfo(loginId, password); - UserInfoResponse response = UserInfoResponse.from(info); - return ApiResponse.success(response); - } + @GetMapping("/me") + @Override + public ApiResponse getMyInfo( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password + ) { + UserInfo info = userFacade.getMyInfo(loginId, password); + return ApiResponse.success(UserInfoResponse.from(info)); + } - @PatchMapping("/me/password") - @Override - public ApiResponse changePassword( - @RequestHeader("X-Loopers-LoginId") String loginId, - @RequestHeader("X-Loopers-LoginPw") String password, - @RequestBody ChangePasswordRequest request - ) { - userFacade.changePassword(loginId, password, request.oldPassword(), request.newPassword()); - return ApiResponse.success("비밀번호가 변경되었습니다."); - } - - @GetMapping("me/likes") - @Override - public ApiResponse getMyLikes( - @RequestHeader("X-Loopers-LoginId") String loginId, - @RequestHeader("X-Loopers-LoginPw") String password - ) { - UserInfo info = userFacade.getMyInfo(loginId, password); - UserInfoResponse response = UserInfoResponse.from(info); - return ApiResponse.success(response); - } + @PatchMapping("/me/password") + @Override + public ApiResponse changePassword( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, + @RequestBody ChangePasswordRequest request + ) { + userFacade.changePassword(loginId, password, request.oldPassword(), request.newPassword()); + return ApiResponse.success("비밀번호가 변경되었습니다."); + } + /** + * 내 좋아요 목록 조회. + * URL의 {userId}는 향후 확장을 위한 구조이며, 현재는 헤더 인증 기준으로 본인 목록만 반환한다. + */ + @GetMapping("/{userId}/likes") + @Override + public ApiResponse> getMyLikes( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, + @PathVariable Long userId, + @RequestParam(required = false, defaultValue = "0") int page, + @RequestParam(required = false, defaultValue = "20") int size + ) { + return ApiResponse.success(LikeV1Dto.PageResponse.from( + likeFacade.getMyLikes(loginId, password, page, size) + .map(LikeV1Dto.LikeListItemResponse::from) + )); + } }