From 22046d46063a5cdddfe5311bd28a7b07153c8925 Mon Sep 17 00:00:00 2001 From: APapeIsName Date: Mon, 2 Feb 2026 22:33:21 +0900 Subject: [PATCH 01/22] =?UTF-8?q?test:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20Red=20Test=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setting: gitignore 수 test: AI 를 활용한 TDD Red 코드 작성 feature: member 회원가입 도메인 기능 구현 test: 아이디 포맷 관련 테스트 작성 필요없는 mock 종속성 삭제 test: 아이디 test 작성 및 exception message 값 추가 fix: lombok @Getter 제거하고, message() 라는 메소드로 구현해 message 필드 캡슐화(추후 필드 형태가 바뀌더라도 그대로 message()로 불러올 수 있도록) fix: 가독성 향상을 위해 내부 이넘으로 변경 fix: 비밀번호 암호화 관련 메시지 추가 test: 비밀번호 RED 테스트 작성 feature: PasswordEncryptor 유틸성 클래스 구현 delete: password -> membertest 안에 통합 test: 이름, 이메일, 생년월일 테스트 코드 작성 및 메시지 추가 --- .DS_Store | Bin 0 -> 6148 bytes .gitignore | 3 + docs/design/member-class-diagram.md | 238 ++++++++++++ modules/jpa/build.gradle.kts | 7 + .../com/loopers/domain/member/Member.java | 51 +++ .../domain/member/MemberExceptionMessage.java | 102 +++++ .../com/loopers/utils/PasswordEncryptor.java | 38 ++ .../domain/member/MemberTest.java | 353 ++++++++++++++++++ 8 files changed, 792 insertions(+) create mode 100644 .DS_Store create mode 100644 docs/design/member-class-diagram.md create mode 100644 modules/jpa/src/main/java/com/loopers/domain/member/Member.java create mode 100644 modules/jpa/src/main/java/com/loopers/domain/member/MemberExceptionMessage.java create mode 100644 modules/jpa/src/main/java/com/loopers/utils/PasswordEncryptor.java create mode 100644 modules/jpa/src/testFixtures/java/com/loopers/testcontainers/domain/member/MemberTest.java diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..83b7e14dcdfc001bff9d4e3d62c14125a204ab72 GIT binary patch literal 6148 zcmeHKT}}cq5dMlAMTrLEi;o)*K;jKnga;D!K`x-MM2X-Me&UCE(-uRtjP(_ZDLKodZnO)%MGx4~py+M3n;JNrZ~IG7XzjwPX* zDxeDdZ3X1Jo8T5Rz8015Z<}+icWbtYCI(nxj0tkIaECeLa*jqEJz%*O|5}OPrgwoM zeMgwbYnhWZ-ynbN3*q$=@H(t;nUPt+dbkQ%jfvIDgjtwjC(7^?uV=Z1-i_Fs)eoq| zGL90K<{)61;GS%jm{t79CGy+B1$sC~7gy+`8+!|viuaVY=G+(AhiaX|;W^;2i)-dR zLkE|{7N*GK{17%_#1R(EE4;^eWZjGJ?7U?eW~no7Wj|wu-IGl`-vXZ89Q? zz2Q)MRX`O`1wIv!--nbh($ literal 0 HcmV?d00001 diff --git a/.gitignore b/.gitignore index 5a979af6..df3d44c1 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ out/ ### Kotlin ### .kotlin + +/.claude +CLAUDE.md \ No newline at end of file diff --git a/docs/design/member-class-diagram.md b/docs/design/member-class-diagram.md new file mode 100644 index 00000000..a2696cf7 --- /dev/null +++ b/docs/design/member-class-diagram.md @@ -0,0 +1,238 @@ +# 회원(Member) 도메인 클래스 다이어그램 + +> 작성일: 2026-02-01 +> 상태: **Planner Mode - 승인 대기** + +## 1. 요구사항 정리 + +### 1.1 회원가입 요구사항 +| 항목 | 설명 | 검증 규칙 | +|------|------|-----------| +| loginId | 로그인 ID | 중복 불가, 포맷 검증 (추후 정의) | +| password | 비밀번호 | 암호화 저장, 아래 규칙 적용 | +| name | 이름 | 포맷 검증 (추후 정의) | +| email | 이메일 | 포맷 검증 (추후 정의) | +| birthDate | 생년월일 | 비밀번호 검증에 사용 | + +### 1.2 비밀번호 규칙 +1. **길이**: 8~16자 +2. **허용 문자**: 영문 대소문자, 숫자, 특수문자만 가능 +3. **금지 조건**: 생년월일(YYYYMMDD, YYMMDD, MMDD 등)이 비밀번호에 포함될 수 없음 + +--- + +## 2. 클래스 다이어그램 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ <> │ +│ BaseEntity │ +├─────────────────────────────────────────────────────────────┤ +│ - id: Long │ +│ - createdAt: ZonedDateTime │ +│ - updatedAt: ZonedDateTime │ +│ - deletedAt: ZonedDateTime │ +├─────────────────────────────────────────────────────────────┤ +│ + delete(): void │ +│ + restore(): void │ +│ # guard(): void │ +└─────────────────────────────────────────────────────────────┘ + △ + │ extends + │ +┌─────────────────────────────────────────────────────────────┐ +│ <> │ +│ Member │ +├─────────────────────────────────────────────────────────────┤ +│ - loginId: String // 로그인 ID (Unique) │ +│ - password: String // 암호화된 비밀번호 │ +│ - name: String // 이름 │ +│ - email: String // 이메일 │ +│ - birthDate: LocalDate // 생년월일 │ +├─────────────────────────────────────────────────────────────┤ +│ + Member(loginId, rawPassword, name, email, birthDate, │ +│ passwordEncoder): Member │ +│ + updatePassword(rawPassword, passwordEncoder): void │ +│ + matchesPassword(rawPassword, passwordEncoder): boolean │ +│ # guard(): void │ +└─────────────────────────────────────────────────────────────┘ + │ + │ uses + ▽ +┌─────────────────────────────────────────────────────────────┐ +│ <> │ +│ PasswordValidator │ +├─────────────────────────────────────────────────────────────┤ +│ + validate(rawPassword, birthDate): void │ +│ - validateLength(password): void │ +│ - validateCharacters(password): void │ +│ - validateNotContainsBirthDate(password, birthDate): void │ +└─────────────────────────────────────────────────────────────┘ + + +┌─────────────────────────────────────────────────────────────┐ +│ <> │ +│ PasswordEncoder │ +├─────────────────────────────────────────────────────────────┤ +│ + encode(rawPassword): String │ +│ + matches(rawPassword, encodedPassword): boolean │ +└─────────────────────────────────────────────────────────────┘ + △ + │ implements + │ +┌─────────────────────────────────────────────────────────────┐ +│ <> │ +│ BCryptPasswordEncoder (Spring) │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 3. 계층별 클래스 구조 + +``` +com.loopers +├── interfaces/ +│ └── api/ +│ └── member/ +│ ├── MemberV1Controller.java // REST API +│ ├── MemberV1ApiSpec.java // OpenAPI 스펙 +│ └── MemberV1Dto.java // 요청/응답 DTO +│ +├── application/ +│ └── member/ +│ ├── MemberFacade.java // 비즈니스 조율 +│ └── MemberInfo.java // 응답 정보 (Record) +│ +├── domain/ +│ └── member/ +│ ├── Member.java // 엔티티 +│ ├── MemberService.java // 도메인 서비스 +│ ├── MemberRepository.java // 도메인 인터페이스 +│ └── PasswordValidator.java // 비밀번호 검증 +│ +└── infrastructure/ + └── member/ + ├── MemberJpaRepository.java // Spring Data JPA + └── MemberRepositoryImpl.java // 도메인 구현체 +``` + +--- + +## 4. 주요 클래스 상세 + +### 4.1 Member (엔티티) + +```java +@Entity +@Table(name = "member") +public class Member extends BaseEntity { + + @Column(name = "login_id", nullable = false, unique = true) + private String loginId; + + @Column(name = "password", nullable = false) + private String password; // 암호화된 값 + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "email", nullable = false) + private String email; + + @Column(name = "birth_date", nullable = false) + private LocalDate birthDate; + + // JPA용 기본 생성자 + protected Member() {} + + // 생성자에서 비밀번호 검증 + 암호화 + public Member(String loginId, String rawPassword, String name, + String email, LocalDate birthDate, + PasswordEncoder passwordEncoder) { + PasswordValidator.validate(rawPassword, birthDate); + this.loginId = loginId; + this.password = passwordEncoder.encode(rawPassword); + this.name = name; + this.email = email; + this.birthDate = birthDate; + guard(); + } +} +``` + +### 4.2 PasswordValidator (검증기) + +```java +public final class PasswordValidator { + + private static final int MIN_LENGTH = 8; + private static final int MAX_LENGTH = 16; + private static final Pattern VALID_PATTERN = + Pattern.compile("^[a-zA-Z0-9!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?]+$"); + + public static void validate(String rawPassword, LocalDate birthDate) { + validateLength(rawPassword); + validateCharacters(rawPassword); + validateNotContainsBirthDate(rawPassword, birthDate); + } + + // 8~16자 검증 + private static void validateLength(String password) { ... } + + // 영문 대소문자, 숫자, 특수문자만 허용 + private static void validateCharacters(String password) { ... } + + // 생년월일 포함 여부 검증 (YYYYMMDD, YYMMDD, MMDD 등) + private static void validateNotContainsBirthDate(String password, LocalDate birthDate) { ... } +} +``` + +--- + +## 5. 데이터베이스 스키마 + +```sql +CREATE TABLE member ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + login_id VARCHAR(50) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, -- BCrypt 해시 + name VARCHAR(100) NOT NULL, + email VARCHAR(255) NOT NULL, + birth_date DATE NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + deleted_at DATETIME(6) NULL, + + INDEX idx_member_login_id (login_id), + INDEX idx_member_email (email) +); +``` + +--- + +## 6. 검토 필요 사항 + +### 확인 요청 +1. **loginId 포맷**: 어떤 형식을 허용할지 (영문+숫자, 길이 제한 등) +2. **email 포맷**: 표준 이메일 검증만 할지, 특정 도메인 제한이 있는지 +3. **name 포맷**: 한글/영문 허용 범위, 길이 제한 +4. **생년월일 검증 범위**: `YYYYMMDD`, `YYMMDD`, `MMDD` 외 추가 패턴이 있는지 + +### 추후 확장 고려 +- [ ] 로그인 기능 (JWT/Session) +- [ ] 비밀번호 변경 +- [ ] 이메일 인증 +- [ ] 소셜 로그인 연동 + +--- + +## 7. 승인 요청 + +위 설계에 대해 검토 부탁드립니다. + +- [ ] 클래스 구조 승인 +- [ ] DB 스키마 승인 +- [ ] 검증 규칙 추가 정보 제공 + +**승인 후 TDD Red Phase로 진입합니다.** diff --git a/modules/jpa/build.gradle.kts b/modules/jpa/build.gradle.kts index e62a6a7e..941d8c27 100644 --- a/modules/jpa/build.gradle.kts +++ b/modules/jpa/build.gradle.kts @@ -18,4 +18,11 @@ dependencies { testFixturesImplementation("org.springframework.boot:spring-boot-starter-data-jpa") testFixturesImplementation("org.testcontainers:mysql") + + testFixturesImplementation("org.junit.jupiter:junit-jupiter-api") + testFixturesImplementation("org.assertj:assertj-core") + + testFixturesImplementation("org.junit.jupiter:junit-jupiter-params") + + testFixturesRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") } diff --git a/modules/jpa/src/main/java/com/loopers/domain/member/Member.java b/modules/jpa/src/main/java/com/loopers/domain/member/Member.java new file mode 100644 index 00000000..bb52932a --- /dev/null +++ b/modules/jpa/src/main/java/com/loopers/domain/member/Member.java @@ -0,0 +1,51 @@ +package com.loopers.domain.member; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Entity +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Member { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String loginId; + + private String password; + + private String name; + + private LocalDate birthDate; + + private String email; + + public static Member register( + String loginId, + String password, + String name, + LocalDate birthDate, + String email + ) { + return Member.builder() + .loginId(loginId) + .password(password) + .name(name) + .birthDate(birthDate) + .email(email) + .build(); + } + +} diff --git a/modules/jpa/src/main/java/com/loopers/domain/member/MemberExceptionMessage.java b/modules/jpa/src/main/java/com/loopers/domain/member/MemberExceptionMessage.java new file mode 100644 index 00000000..c8c177da --- /dev/null +++ b/modules/jpa/src/main/java/com/loopers/domain/member/MemberExceptionMessage.java @@ -0,0 +1,102 @@ +package com.loopers.domain.member; + +import lombok.AllArgsConstructor; + +public class MemberExceptionMessage { + + public interface ExceptionMessage { + String message(); + } + + /** + * 1_000 ~ 1_099: 로그인 ID 관련 오류 + */ + @AllArgsConstructor + public enum LoginId implements ExceptionMessage { + INVALID_ID_FORMAT("아이디는 영문이 포함되어야 하며, 한글이나 특수문자는 사용할 수 없습니다.", 1_001), + INVALID_ID_NUMERIC_ONLY("아이디를 숫자만으로 구성할 수 없습니다. 영문을 포함해주세요.", 1_002), + DUPLICATE_ID_EXISTS("이미 사용 중인 아이디입니다.", 1_003), + INVALID_ID_LENGTH("아이디는 6글자 이상, 20글자 이하여야 합니다.", 1_004) + ; + + private final String message; + private final Integer code; + + public String message() { + return message; + } + } + + /** + * 1_100 ~ 1_199: 비밀번호 관련 오류 + */ + + @AllArgsConstructor + public enum Password implements ExceptionMessage { + // 1. 기본 형식 검증 (Basic Format) + // 1-1. 길이 제한 (8~16자) + INVALID_PASSWORD_LENGTH("비밀번호는 8자 이상 16자 이하여야 합니다.", 1_101), + + // 1-2. 조합 규칙 (대소문자, 숫자, 특수문자) + INVALID_PASSWORD_COMPOSITION("비밀번호는 영문 대소문자, 숫자, 특수문자를 모두 포함해야 합니다.", 1_102), + + // 2. 생년월일 포함 금지 규칙 (Zero-Birthdate Policy) + // 2-1, 2-2, 2-3 통합 검증 + PASSWORD_CONTAINS_BIRTHDATE("비밀번호에 생년월일 정보(YYYYMMDD 또는 YYMMDD)를 포함할 수 없습니다.", 1_103), + + // 3. 수정 시 정책 (Update Policy) + // 3-1. 재사용 금지 + PASSWORD_CANNOT_BE_SAME_AS_CURRENT("현재 사용 중인 비밀번호와 동일한 비밀번호로 변경할 수 없습니다.", 1_104), + + PASSWORD_NOT_ENCODED("비밀번호가 암호화되지 않았습니다.", 1_105) + + ; + + private final String message; + private final Integer code; + + public String message() { + return message; + } + } + + /** + * 1_200 ~ 1_219: 이름(Name) 관련 오류 + */ + @AllArgsConstructor + public enum Name implements ExceptionMessage { + TOO_SHORT("이름은 최소 2자 이상이어야 합니다.", 1_201), + TOO_LONG("이름은 최대 40자까지 가능합니다.", 1_202), + CONTAINS_INVALID_CHAR("이름에 숫자나 특수문자를 포함할 수 없습니다. 한글과 영문만 가능합니다.", 1_203); + + private final String message; + private final Integer code; + public String message() { return message; } + } + + /** + * 1_220 ~ 1_239: 이메일(Email) 관련 오류 + */ + @AllArgsConstructor + public enum Email implements ExceptionMessage { + INVALID_FORMAT("유효하지 않은 이메일 형식입니다.", 1_221), + TOO_LONG("이메일은 255자를 초과할 수 없습니다.", 1_222); + + private final String message; + private final Integer code; + public String message() { return message; } + } + + /** + * 1_240 ~ 1_259: 생년월일(BirthDate) 관련 오류 + */ + @AllArgsConstructor + public enum BirthDate implements ExceptionMessage { + CANNOT_BE_FUTURE("생년월일은 미래 날짜일 수 없습니다.", 1_241); + + private final String message; + private final Integer code; + public String message() { return message; } + } + +} diff --git a/modules/jpa/src/main/java/com/loopers/utils/PasswordEncryptor.java b/modules/jpa/src/main/java/com/loopers/utils/PasswordEncryptor.java new file mode 100644 index 00000000..a3503503 --- /dev/null +++ b/modules/jpa/src/main/java/com/loopers/utils/PasswordEncryptor.java @@ -0,0 +1,38 @@ +package com.loopers.utils; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; + +public class PasswordEncryptor { + + private static final String ALGORITHM = "SHA-256"; + + /** + * 비밀번호를 SHA-256으로 해싱함. + * (주의: 이 예제는 순수 해싱만 수행합니다. 실무에선 Salt를 추가해야 안전합니다.) + */ + public static String encode(String rawPassword) { + try { + MessageDigest digest = MessageDigest.getInstance(ALGORITHM); + byte[] encodedHash = digest.digest(rawPassword.getBytes(StandardCharsets.UTF_8)); + + // 바이트 배열을 읽기 쉬운 문자열(Base64)로 변환 + return Base64.getEncoder().encodeToString(encodedHash); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("암호화 알고리즘을 찾을 수 없습니다.", e); + } + } + + /** + * 일치 여부 확인 + */ + public static boolean matches(String rawPassword, String encodedPassword) { + if (rawPassword == null || encodedPassword == null) return false; + + // 입력받은 원문을 똑같이 해싱해서 결과가 같은지 비교 + String newHash = encode(rawPassword); + return newHash.equals(encodedPassword); + } +} diff --git a/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/domain/member/MemberTest.java b/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/domain/member/MemberTest.java new file mode 100644 index 00000000..ad01f2cc --- /dev/null +++ b/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/domain/member/MemberTest.java @@ -0,0 +1,353 @@ +package com.loopers.testcontainers.domain.member; + +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberExceptionMessage; +import com.loopers.utils.PasswordEncryptor; +import org.assertj.core.api.AbstractThrowableAssert; +import org.assertj.core.api.LocalDateAssert; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +/** + * Member 도메인 엔티티 유효성 검증 테스트 + * + * 검증 대상: + * - 로그인 ID: 영문/숫자만 허용, 6~20글자 + * - 비밀번호: 8~16자, 영문 대소문자 + 숫자 + 특수문자 조합 + * - 이름: 한글/영어 허용, 2~40글자 + * - 이메일: RFC 5321 표준 준수 + */ +@DisplayName("Member 도메인 유효성 검증 테스트") +class MemberTest { + + // 테스트용 유효한 기본값 + private static final String VALID_LOGIN_ID = "hello1234"; + private static final String VALID_PASSWORD = "Password1!"; + private static final String VALID_NAME = "홍길동"; + private static final LocalDate VALID_BIRTH_DATE = LocalDate.of(2001, 2, 9); + private static final String VALID_EMAIL = "test@example.com"; + + @Test + public void 회원가입_성공() throws Exception { + //given + + //when + + //then + assertDoesNotThrow(() -> + Member.register(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTH_DATE, VALID_EMAIL) + ); + + } + + @DisplayName("회원가입 시에 ID 검증") + @Nested + class LoginIdValidation { + + // 1. 아이디는 영문과 숫자가 합쳐진 경우 허용하며, 중복 가입할 수 없으며, 6~20글자여야 함. + // 1-1. 아이디는 영문이 들어가야 함. + // 1-1-1. 아이디는 영문이 아닌 한글이 들어갈 수 없음. + // 1-1-2. 아이디는 영문이 아닌 특수 문자가 들어갈 수 없음. + // 1-2. 아이디는 숫자만 존재할 수 없음. + // 1-3. 아이디는 중복 가입할 수 없음. -> + // 1-3-1. 아이디가 중복인 경우 예외 메시지를 띄워야 함. + // 1-4. 아이디의 길이는 6~20글자여야 함. + // 1-4-1. 아이디의 길이는 6글자 미만일 수 없음. + // 1-4-2. 아이디의 길이는 20글자 초과일 수 없음. + + // 1-1-1 + @Test + public void 아이디는_영문이_아닌_한글이_들어갈_수_없음() throws Exception { + //given + String wrongId = "한글입slek"; + + //when + + //then + throwIfWrongIdInput(wrongId) + .hasMessage(MemberExceptionMessage.LoginId.INVALID_ID_FORMAT.message()); + + } + + // 1-1-2 + @Test + public void 아이디는_영문이_아닌_특수문자가_들어갈_수_없음() throws Exception + { + //given + String wrongId = "@apgl!#"; + + //when + + //then + throwIfWrongIdInput(wrongId) + .hasMessage(MemberExceptionMessage.LoginId.INVALID_ID_FORMAT.message()); + } + + // 1-2 + @Test + public void 아이디는_숫자만_존재할_수_없음() throws Exception { + //given + String wrongId = "12345678"; + + //when + + //then + throwIfWrongIdInput(wrongId) + .hasMessage(MemberExceptionMessage.LoginId.INVALID_ID_NUMERIC_ONLY.message()); + } + + // 1-3 -> 레포지토리 가져오니까 서비스의 통합 테스트 + @Test + public void 아이디는_중복_가입할_수_없음() throws Exception { + //given + + //when + + //then + + } + + // 1-4-1 + @Test + public void 아이디의_길이_6자_미만_불가() throws Exception { + //given + String wrongId = "ap245"; + + //when + + //then + throwIfWrongIdInput(wrongId) + .hasMessage(MemberExceptionMessage.LoginId.INVALID_ID_LENGTH.message()); + } + + // 1-4-2 + @Test + public void 아이디의_길이_20자_초과_불가() throws Exception { + //given + String wrongId = "apapeisname1234ppap56"; // 21글자 + + //when + + //then + throwIfWrongIdInput(wrongId) + .hasMessage(MemberExceptionMessage.LoginId.INVALID_ID_LENGTH.message()); + } + + private AbstractThrowableAssert throwIfWrongIdInput(String wrongId) { + return assertThatThrownBy(() -> Member.register(wrongId, VALID_PASSWORD, VALID_NAME, VALID_BIRTH_DATE, VALID_EMAIL)) + .isInstanceOf(IllegalArgumentException.class); + } + } + + /** + * [비밀번호 보안 정책 - Zero-Birthdate Policy 기반] + * * 1. 기본 형식 검증 (Basic Format) + * - 1-1. 길이는 8자 이상 16자 이하여야 함. + * - 1-1-1. 길이는 8자 미만일 수 없음 + * - 1-1-2. 길이는 16자 초과일 수 없음 + * - 1-2. 영문 대문자, 영문 소문자, 숫자, 특수문자가 최소 1개 이상 포함된 조합이어야 함. + * * 2. 생년월일 포함 금지 규칙 (Zero-Birthdate Policy) + * - 2-1. 사용자의 생년월일(YYYYMMDD)이 비밀번호 문자열 내에 포함될 수 없음. (ex: "pass19950520!") + * - 2-2. 사용자의 생년월일(YYMMDD)이 비밀번호 문자열 내에 포함될 수 없음. (ex: "950520pass#") + * * 3. 수정 시 정책 (Update Policy) + * - 3-1. 현재 사용 중인 비밀번호(암호화 전 원문 기준)와 동일한 비밀번호로 변경 불가. -> Service, 통합 + * * 4. 보안 전제 조건 + * - 4-1. DB 저장 전 반드시 단방향 해시 암호화 과정을 거쳐야 함. + */ + + @DisplayName("비밀번호 형식 검증") + @Nested + class PasswordFormatValidation { + + // 1-1-1 + @Test + public void 비밀번호_길이는_8자_미만일_수_없음() throws Exception { + //given + String wrongPassword = "pap1234"; // 7글자 + + //when + + //then + throwIfWrongPasswordInput(wrongPassword) + .hasMessage(MemberExceptionMessage.Password.INVALID_PASSWORD_LENGTH.message()); + } + + // 1-1-2 + @Test + public void 비밀번호_길이는_16자_초과일_수_없음() throws Exception { + //given + String wrongPassword = "qwer1234tyui5678a"; // 17글자 + + //when + + //then + throwIfWrongPasswordInput(wrongPassword) + .hasMessage(MemberExceptionMessage.Password.INVALID_PASSWORD_LENGTH.message()); + + } + + // 1-2 + @Test + public void 비밀번호는_영문_숫자_특수문자만_사용할_수_있음() throws Exception { + //given + String wrongPassword = "한글password123"; + + //when + + //then + throwIfWrongPasswordInput(wrongPassword) + .hasMessage(MemberExceptionMessage.Password.INVALID_PASSWORD_COMPOSITION.message()); + + } + + // 2-1 + @Test + public void 사용자_생년월일_YYYYMMDD가_비밀번호_포함_불가() throws Exception { + //given + LocalDate userBirthDate = LocalDate.of(2001, 2, 9); + String wrongPassword = "pwd20010209!"; + + //when + + //then + throwIfPasswordContainsBirthDate(wrongPassword, userBirthDate) + .hasMessage(MemberExceptionMessage.Password.PASSWORD_CONTAINS_BIRTHDATE.message()); + } + + // 2-2 + @Test + public void 사용자_생년월일_YYMMDD가_비밀번호_포함_불가() throws Exception { + //given + LocalDate userBirthDate = LocalDate.of(2001, 2, 9); + String wrongPassword = "pass010209!"; + + //when + + //then + throwIfPasswordContainsBirthDate(wrongPassword, userBirthDate) + .hasMessage(MemberExceptionMessage.Password.PASSWORD_CONTAINS_BIRTHDATE.message()); + } + + // 4 + @Test + public void 비밀번호는_암호화해_저장() throws Exception { + //given + + //when + String encodedPassword = PasswordEncryptor.encode(VALID_PASSWORD); + + //then + assertThat(PasswordEncryptor.matches(VALID_PASSWORD, encodedPassword)).isTrue(); + } + + private AbstractThrowableAssert throwIfWrongPasswordInput(String wrongPassword) { + return assertThatThrownBy(() -> Member.register(VALID_LOGIN_ID, wrongPassword, VALID_NAME, VALID_BIRTH_DATE, VALID_EMAIL)) + .isInstanceOf(IllegalArgumentException.class); + } + + private AbstractThrowableAssert throwIfPasswordContainsBirthDate(String wrongPassword, LocalDate birthDate) { + return assertThatThrownBy(() -> Member.register(VALID_LOGIN_ID, wrongPassword, VALID_NAME, birthDate, VALID_EMAIL)) + .isInstanceOf(IllegalArgumentException.class); + } + + } + + @DisplayName("이름(Name) 유효성 검증") + @Nested + class NameValidation { + + @Test + void 이름은_2자_미만일_수_없음() { + String shortName = "홍"; + throwIfWrongNameInput(shortName) + .hasMessage(MemberExceptionMessage.Name.TOO_SHORT.message()); + } + + @Test + void 이름은_40자를_초과할_수_없음() { + String longName = "가".repeat(41); + throwIfWrongNameInput(longName) + .hasMessage(MemberExceptionMessage.Name.TOO_LONG.message()); + } + + @Test + void 이름에_숫자가_포함될_수_없음() { + String nameWithDigit = "홍길동1"; + throwIfWrongNameInput(nameWithDigit) + .hasMessage(MemberExceptionMessage.Name.CONTAINS_INVALID_CHAR.message()); + } + + @Test + void 이름에_특수문자가_포함될_수_없음() { + String nameWithSpecial = "John@"; + throwIfWrongNameInput(nameWithSpecial) + .hasMessage(MemberExceptionMessage.Name.CONTAINS_INVALID_CHAR.message()); + } + + private AbstractThrowableAssert throwIfWrongNameInput(String name) { + return assertThatThrownBy(() -> Member.register(VALID_LOGIN_ID, VALID_PASSWORD, name, VALID_BIRTH_DATE, VALID_EMAIL)) + .isInstanceOf(IllegalArgumentException.class); + } + } + + @DisplayName("이메일(Email) 유효성 검증") + @Nested + class EmailValidation { + + @Test + void 이메일_기본_형식을_준수해야_함() { + String wrongEmail = "test#example.com"; // @ 없음 + throwIfWrongEmailInput(wrongEmail) + .hasMessage(MemberExceptionMessage.Email.INVALID_FORMAT.message()); + } + + @Test + @DisplayName("이메일은 255자를 초과할 수 없음") + void emailTooLong() { + String longEmail = "a".repeat(250) + "@test.com"; + throwIfWrongEmailInput(longEmail) + .hasMessage(MemberExceptionMessage.Email.TOO_LONG.message()); + } + + private AbstractThrowableAssert throwIfWrongEmailInput(String email) { + return assertThatThrownBy(() -> Member.register(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTH_DATE, email)) + .isInstanceOf(IllegalArgumentException.class); + } + } + + @DisplayName("생년월일(BirthDate) 유효성 검증") + @Nested + class BirthDateValidation { + + @Test + @DisplayName("미래_날짜는_생년월일_등록_불가") + void birthDateCannotBeFuture() { + LocalDate futureDate = LocalDate.now().plusDays(1); + throwIfWrongBirthDateInput(futureDate) + .hasMessage(MemberExceptionMessage.BirthDate.CANNOT_BE_FUTURE.message()); + } + + private AbstractThrowableAssert throwIfWrongBirthDateInput(LocalDate birthDate) { + return assertThatThrownBy(() -> Member.register(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, birthDate, VALID_EMAIL)) + .isInstanceOf(IllegalArgumentException.class); + } + } + + @DisplayName("회원가입 통합 성공 검증") + @Nested + class RegistrationSuccess { + + @Test + @DisplayName("모든 조건이 유효하면 회원가입에 성공한다") + void successWhenAllFieldsValid() { + assertThatCode(() -> Member.register(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTH_DATE, VALID_EMAIL)) + .doesNotThrowAnyException(); + } + } +} From fde25e8862f7312cf1ed58fc61e27b80e90842be Mon Sep 17 00:00:00 2001 From: APapeIsName Date: Thu, 5 Feb 2026 23:18:04 +0900 Subject: [PATCH 02/22] =?UTF-8?q?test:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20Green=20Test=EB=A5=BC=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=20=EC=A7=84=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feature: 정책 인터페이스화 -> 구체화 객체들 작성 refactor: 정책 인터페이스 제거 후, 하나의 Policy 안에 내부 정적 클래스로 변경 refactor: MemberPolicy 생성 feature: MemberPolicy 구현 fix: MemberPolicy 및 MemberExceptionMessage 잘못된 부분 수정 --- .../com/loopers/domain/member/Member.java | 7 ++ .../domain/member/MemberExceptionMessage.java | 4 +- .../domain/member/policy/MemberPolicy.java | 93 +++++++++++++++++++ 3 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 modules/jpa/src/main/java/com/loopers/domain/member/policy/MemberPolicy.java diff --git a/modules/jpa/src/main/java/com/loopers/domain/member/Member.java b/modules/jpa/src/main/java/com/loopers/domain/member/Member.java index bb52932a..d558f310 100644 --- a/modules/jpa/src/main/java/com/loopers/domain/member/Member.java +++ b/modules/jpa/src/main/java/com/loopers/domain/member/Member.java @@ -1,5 +1,6 @@ package com.loopers.domain.member; +import com.loopers.domain.member.policy.*; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; @@ -39,6 +40,12 @@ public static Member register( LocalDate birthDate, String email ) { + MemberPolicy.LoginId.validate(loginId); + MemberPolicy.BirthDate.validate(birthDate); + MemberPolicy.Password.validate(password, birthDate); + MemberPolicy.Name.validate(name); + MemberPolicy.Email.validate(email); + return Member.builder() .loginId(loginId) .password(password) diff --git a/modules/jpa/src/main/java/com/loopers/domain/member/MemberExceptionMessage.java b/modules/jpa/src/main/java/com/loopers/domain/member/MemberExceptionMessage.java index c8c177da..3df17a3d 100644 --- a/modules/jpa/src/main/java/com/loopers/domain/member/MemberExceptionMessage.java +++ b/modules/jpa/src/main/java/com/loopers/domain/member/MemberExceptionMessage.java @@ -37,8 +37,8 @@ public enum Password implements ExceptionMessage { // 1-1. 길이 제한 (8~16자) INVALID_PASSWORD_LENGTH("비밀번호는 8자 이상 16자 이하여야 합니다.", 1_101), - // 1-2. 조합 규칙 (대소문자, 숫자, 특수문자) - INVALID_PASSWORD_COMPOSITION("비밀번호는 영문 대소문자, 숫자, 특수문자를 모두 포함해야 합니다.", 1_102), + // 메시지 수정: '모두 포함' -> '영문, 숫자, 특수문자만 사용 가능' + INVALID_PASSWORD_COMPOSITION("비밀번호는 영문, 숫자, 특수문자만 사용할 수 있으며 한글 등은 포함할 수 없습니다.", 1_102), // 2. 생년월일 포함 금지 규칙 (Zero-Birthdate Policy) // 2-1, 2-2, 2-3 통합 검증 diff --git a/modules/jpa/src/main/java/com/loopers/domain/member/policy/MemberPolicy.java b/modules/jpa/src/main/java/com/loopers/domain/member/policy/MemberPolicy.java new file mode 100644 index 00000000..3bfcca8f --- /dev/null +++ b/modules/jpa/src/main/java/com/loopers/domain/member/policy/MemberPolicy.java @@ -0,0 +1,93 @@ +package com.loopers.domain.member.policy; + +import com.loopers.domain.member.MemberExceptionMessage; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +public class MemberPolicy { + + public static class LoginId { + public static void validate(String loginId) { + // 1-4. 길이 제한 (6~20자) + if (loginId == null || loginId.length() < 6 || loginId.length() > 20) { + throw new IllegalArgumentException(MemberExceptionMessage.LoginId.INVALID_ID_LENGTH.message()); + } + // 1-2. 숫자만 존재할 수 없음 + if (loginId.matches("^[0-9]*$")) { + throw new IllegalArgumentException(MemberExceptionMessage.LoginId.INVALID_ID_NUMERIC_ONLY.message()); + } + // 1-1. 영문/숫자만 허용 및 영문 필수 포함 (한글, 특수문자 불가) + if (!loginId.matches("^[a-zA-Z0-9]*$") || !loginId.matches(".*[a-zA-Z].*")) { + throw new IllegalArgumentException(MemberExceptionMessage.LoginId.INVALID_ID_FORMAT.message()); + } + } + } + + public static class Password { + public static void validate(String password, LocalDate birthDate) { + // 1-1. 길이 제한 (8~16자) + if (password == null || password.length() < 8 || password.length() > 16) { + throw new IllegalArgumentException(MemberExceptionMessage.Password.INVALID_PASSWORD_LENGTH.message()); + } + + // 1-2. 조합 규칙 수정: "한글 등 허용되지 않은 문자"가 포함되었는지만 체크 + // 영문, 숫자, 특수문자(@$!%*?&)만 허용하는 정규식으로 변경 (필수 포함 조건 삭제) + String allowedCharsRegex = "^[A-Za-z\\d@$!%*?&]*$"; + if (!password.matches(allowedCharsRegex)) { + throw new IllegalArgumentException(MemberExceptionMessage.Password.INVALID_PASSWORD_COMPOSITION.message()); + } + + // 2. 생년월일 포함 금지 규칙 (Zero-Birthdate Policy) + // 이 로직에 도달하기 전에 위 정규식에서 튕기지 않도록 테스트 데이터가 수정되거나 정규식이 유연해야 합니다. + String yyyyMMdd = birthDate.format(DateTimeFormatter.ofPattern("yyyyMMdd")); + String yyMMdd = yyyyMMdd.substring(2); + if (password.contains(yyyyMMdd) || password.contains(yyMMdd)) { + throw new IllegalArgumentException(MemberExceptionMessage.Password.PASSWORD_CONTAINS_BIRTHDATE.message()); + } + } + } + + public static class Name { + public static void validate(String name) { + // 이름 길이 체크 + if (name == null || name.length() < 2) { + throw new IllegalArgumentException(MemberExceptionMessage.Name.TOO_SHORT.message()); + } + if (name.length() > 40) { + throw new IllegalArgumentException(MemberExceptionMessage.Name.TOO_LONG.message()); + } + // 한글/영문만 허용 (숫자, 특수문자 불가) + if (!name.matches("^[a-zA-Z가-힣\\s]*$")) { + throw new IllegalArgumentException(MemberExceptionMessage.Name.CONTAINS_INVALID_CHAR.message()); + } + } + } + + public static class Email { + public static void validate(String email) { + if (email == null) throw new IllegalArgumentException("이메일은 필수입니다."); + + // 길이 체크 + if (email.length() > 255) { + throw new IllegalArgumentException(MemberExceptionMessage.Email.TOO_LONG.message()); + } + // RFC 5321 기반 기본 형식 체크 + if (!email.matches("^[A-Za-z0-9+_.-]+@(.+)$")) { + throw new IllegalArgumentException(MemberExceptionMessage.Email.INVALID_FORMAT.message()); + } + } + } + + public static class BirthDate { + public static void validate(LocalDate birthDate) { + if (birthDate == null) throw new IllegalArgumentException("생년월일은 필수입니다."); + + // 미래 날짜 불가 + if (birthDate.isAfter(LocalDate.now())) { + throw new IllegalArgumentException(MemberExceptionMessage.BirthDate.CANNOT_BE_FUTURE.message()); + } + } + } + +} From 3f5d00db55929ec20b03236a44150a450a7d3eea Mon Sep 17 00:00:00 2001 From: APapeIsName Date: Fri, 6 Feb 2026 00:04:39 +0900 Subject: [PATCH 03/22] =?UTF-8?q?test:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?Red=20Test=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test: 회원가입 서비스 로직 Red Test 작성 feature: MemberRepository 구현 fix: @ExtendWith(MockitoExtension.class) 없어서 오류 발생 -> 추가 --- .../member/MemberRepository.java | 10 ++++ .../application/MemberServiceTest.java | 46 +++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepository.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/MemberServiceTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepository.java new file mode 100644 index 00000000..c3810e0e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepository.java @@ -0,0 +1,10 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.Member; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MemberRepository extends JpaRepository { + + boolean existsByLoginId(String inputId); + +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/MemberServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/MemberServiceTest.java new file mode 100644 index 00000000..7d363198 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/MemberServiceTest.java @@ -0,0 +1,46 @@ +package com.loopers.application; + +import com.loopers.application.service.MemberService; +import com.loopers.application.service.dto.MemberRegisterRequest; +import com.loopers.domain.member.MemberExceptionMessage; +import com.loopers.infrastructure.member.MemberRepository; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +public class MemberServiceTest { + + @InjectMocks + private MemberService memberService; + + @Mock + private MemberRepository memberRepository; + + @Test + public void 회원가입_시_아이디_중복_불가() throws Exception { + //given + String inputId = "apape123"; + MemberRegisterRequest request = new MemberRegisterRequest( + inputId, "password123!", "공명선", LocalDate.of(2001,2,9), "gms72901217@gmail.com" + ); + + //when + when(memberRepository.existsByLoginId(inputId)).thenReturn(true); + + //then + assertThatThrownBy(() -> memberService.register(request)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(MemberExceptionMessage.LoginId.DUPLICATE_ID_EXISTS.message()); + } + +} From c2726b60992a100aa47ea4fe134c172b354bde18 Mon Sep 17 00:00:00 2001 From: APapeIsName Date: Fri, 6 Feb 2026 00:15:20 +0900 Subject: [PATCH 04/22] =?UTF-8?q?feature:=20=EB=8B=A8=EC=9C=84=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EC=97=90=20=EB=A7=9E=EB=8A=94=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/service/MemberService.java | 24 +++++++++++++++++++ .../service/dto/MemberRegisterRequest.java | 12 ++++++++++ 2 files changed, 36 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/service/MemberService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/service/dto/MemberRegisterRequest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/service/MemberService.java b/apps/commerce-api/src/main/java/com/loopers/application/service/MemberService.java new file mode 100644 index 00000000..70821d71 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/service/MemberService.java @@ -0,0 +1,24 @@ +package com.loopers.application.service; + +import com.loopers.application.service.dto.MemberRegisterRequest; +import com.loopers.domain.member.MemberExceptionMessage; +import com.loopers.infrastructure.member.MemberRepository; +import com.loopers.support.error.CoreException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class MemberService { + + private final MemberRepository memberRepository; + + public void register(MemberRegisterRequest request) { + boolean isLoginIdAlreadyExists = memberRepository.existsByLoginId(request.loginId()); + + if (isLoginIdAlreadyExists) { + throw new IllegalArgumentException(MemberExceptionMessage.LoginId.DUPLICATE_ID_EXISTS.message()); + } + + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/service/dto/MemberRegisterRequest.java b/apps/commerce-api/src/main/java/com/loopers/application/service/dto/MemberRegisterRequest.java new file mode 100644 index 00000000..f32ca9f1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/service/dto/MemberRegisterRequest.java @@ -0,0 +1,12 @@ +package com.loopers.application.service.dto; + +import java.time.LocalDate; + +public record MemberRegisterRequest ( + String loginId, + String password, + String name, + LocalDate birthdate, + String email +) { +} From 5ae82ccb4634cbfd30eb52d71c1c7d7f16f2ef28 Mon Sep 17 00:00:00 2001 From: APapeIsName Date: Fri, 6 Feb 2026 00:30:12 +0900 Subject: [PATCH 05/22] =?UTF-8?q?feature:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?Green=20=EB=B0=8F=20Refactor=20=EC=9E=91=EC=97=85=20=EC=A7=84?= =?UTF-8?q?=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test: 회원가입 성공 시 test 추가 - red feature: MemberService save 로직 구현 - green refactor: 회원가입 시 아이디 중복 불가 코드 수정 - builder 사용해 필요한 값만 받도록 수정 - refactor --- .../application/service/MemberService.java | 2 ++ .../service/dto/MemberRegisterRequest.java | 3 ++ .../application/MemberServiceTest.java | 30 ++++++++++++++++--- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/service/MemberService.java b/apps/commerce-api/src/main/java/com/loopers/application/service/MemberService.java index 70821d71..13a5f32b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/service/MemberService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/service/MemberService.java @@ -1,6 +1,7 @@ package com.loopers.application.service; import com.loopers.application.service.dto.MemberRegisterRequest; +import com.loopers.domain.member.Member; import com.loopers.domain.member.MemberExceptionMessage; import com.loopers.infrastructure.member.MemberRepository; import com.loopers.support.error.CoreException; @@ -20,5 +21,6 @@ public void register(MemberRegisterRequest request) { throw new IllegalArgumentException(MemberExceptionMessage.LoginId.DUPLICATE_ID_EXISTS.message()); } + memberRepository.save(Member.register(request.loginId(), request.password(), request.name(), request.birthdate(), request.email())); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/service/dto/MemberRegisterRequest.java b/apps/commerce-api/src/main/java/com/loopers/application/service/dto/MemberRegisterRequest.java index f32ca9f1..3b2f9cd1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/service/dto/MemberRegisterRequest.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/service/dto/MemberRegisterRequest.java @@ -1,7 +1,10 @@ package com.loopers.application.service.dto; +import lombok.Builder; + import java.time.LocalDate; +@Builder public record MemberRegisterRequest ( String loginId, String password, diff --git a/apps/commerce-api/src/test/java/com/loopers/application/MemberServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/MemberServiceTest.java index 7d363198..6e223d75 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/MemberServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/MemberServiceTest.java @@ -2,11 +2,13 @@ import com.loopers.application.service.MemberService; import com.loopers.application.service.dto.MemberRegisterRequest; +import com.loopers.domain.member.Member; import com.loopers.domain.member.MemberExceptionMessage; import com.loopers.infrastructure.member.MemberRepository; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; @@ -30,12 +32,15 @@ public class MemberServiceTest { public void 회원가입_시_아이디_중복_불가() throws Exception { //given String inputId = "apape123"; - MemberRegisterRequest request = new MemberRegisterRequest( - inputId, "password123!", "공명선", LocalDate.of(2001,2,9), "gms72901217@gmail.com" - ); + MemberRegisterRequest request = MemberRegisterRequest.builder() + .loginId(inputId) + .password("password123!") + .name("공명선") + .birthdate(LocalDate.of(2001, 2, 9)) + .email("gms72901217@gmail.com").build(); + when(memberRepository.existsByLoginId(inputId)).thenReturn(true); //when - when(memberRepository.existsByLoginId(inputId)).thenReturn(true); //then assertThatThrownBy(() -> memberService.register(request)) @@ -43,4 +48,21 @@ public class MemberServiceTest { .hasMessage(MemberExceptionMessage.LoginId.DUPLICATE_ID_EXISTS.message()); } + @Test + public void 회원가입_성공() throws Exception { + //given + ArgumentCaptor memberCaptor = ArgumentCaptor.forClass(Member.class); + String inputId = "newId123"; + MemberRegisterRequest request = new MemberRegisterRequest( + inputId,"password123!","공명선",LocalDate.of(2001, 2, 9),"gms72901217@gmail.com"); + when(memberRepository.existsByLoginId(inputId)).thenReturn(false); + + //when + memberService.register(request); + + //then + verify(memberRepository).save(memberCaptor.capture()); + assertThat(memberCaptor.getValue().getLoginId()).isEqualTo(request.loginId()); + } + } From d4a79a87d05e6bbe07f7a4a54df872ee7b30b455 Mon Sep 17 00:00:00 2001 From: APapeIsName Date: Fri, 6 Feb 2026 01:02:13 +0900 Subject: [PATCH 06/22] =?UTF-8?q?test:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=ED=86=B5=ED=95=A9=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1=20-=20Red?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test: 회원가입 통합테스트 - red fix: 회원가입 테스트 오류 해결 - Member 에 @Table 추가 및 Service 에 @Transactional 추가 --- .../application/service/MemberService.java | 2 + .../MemberServiceIntegrationTest.java | 71 +++++++++++++++++++ .../com/loopers/domain/member/Member.java | 6 +- 3 files changed, 75 insertions(+), 4 deletions(-) create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/MemberServiceIntegrationTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/service/MemberService.java b/apps/commerce-api/src/main/java/com/loopers/application/service/MemberService.java index 13a5f32b..d38b6a1d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/service/MemberService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/service/MemberService.java @@ -7,6 +7,7 @@ import com.loopers.support.error.CoreException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor @@ -14,6 +15,7 @@ public class MemberService { private final MemberRepository memberRepository; + @Transactional public void register(MemberRegisterRequest request) { boolean isLoginIdAlreadyExists = memberRepository.existsByLoginId(request.loginId()); diff --git a/apps/commerce-api/src/test/java/com/loopers/application/MemberServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/MemberServiceIntegrationTest.java new file mode 100644 index 00000000..71b5f4e4 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/MemberServiceIntegrationTest.java @@ -0,0 +1,71 @@ +package com.loopers.application; + +import com.loopers.application.service.MemberService; +import com.loopers.application.service.dto.MemberRegisterRequest; +import com.loopers.domain.member.Member; +import com.loopers.infrastructure.member.MemberRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest +@Transactional +public class MemberServiceIntegrationTest { + + @Autowired + private MemberService memberService; + + @Autowired + private MemberRepository memberRepository; + + @Test + public void 회원가입_성공() throws Exception { + // given + String inputId = "integrationId123"; + MemberRegisterRequest request = MemberRegisterRequest.builder() + .loginId(inputId) + .password("Pass!1234") + .name("공명선") + .birthdate(LocalDate.of(2001, 2, 9)) + .email("test@loopers.com") + .build(); + + // when + memberService.register(request); + + // then + assertThat(memberRepository.existsByLoginId(inputId)).isTrue(); + } + + @Test + void 회원가입_시_중복_아이디_사용_불가() { + // given + String duplicateId = "existingId"; + memberRepository.save(Member.builder() + .loginId(duplicateId) + .password("encodedPassword") + .name("기존유저") + .birthDate(LocalDate.of(1990, 1, 1)) + .email("old@test.com") + .build()); + + MemberRegisterRequest request = MemberRegisterRequest.builder() + .loginId(duplicateId) + .password("NewPass!123") + .name("신규유저") + .birthdate(LocalDate.of(2000, 1, 1)) + .email("new@test.com") + .build(); + + // when & then + assertThatThrownBy(() -> memberService.register(request)) + .isInstanceOf(IllegalArgumentException.class); + } + +} diff --git a/modules/jpa/src/main/java/com/loopers/domain/member/Member.java b/modules/jpa/src/main/java/com/loopers/domain/member/Member.java index d558f310..3b147dea 100644 --- a/modules/jpa/src/main/java/com/loopers/domain/member/Member.java +++ b/modules/jpa/src/main/java/com/loopers/domain/member/Member.java @@ -1,10 +1,7 @@ package com.loopers.domain.member; import com.loopers.domain.member.policy.*; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; +import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -17,6 +14,7 @@ @NoArgsConstructor @AllArgsConstructor @Builder +@Table(name = "member") public class Member { @Id From f1e2ef3a72f142c1a779867598d38cf81f352ddf Mon Sep 17 00:00:00 2001 From: "hanyoung.park" Date: Mon, 2 Feb 2026 01:26:59 +0900 Subject: [PATCH 07/22] remove: deprecated codeguide --- .codeguide/loopers-1-week.md | 45 ------------------------------------ 1 file changed, 45 deletions(-) delete mode 100644 .codeguide/loopers-1-week.md diff --git a/.codeguide/loopers-1-week.md b/.codeguide/loopers-1-week.md deleted file mode 100644 index a8ace53e..00000000 --- a/.codeguide/loopers-1-week.md +++ /dev/null @@ -1,45 +0,0 @@ -## 🧪 Implementation Quest - -> 지정된 **단위 테스트 / 통합 테스트 / E2E 테스트 케이스**를 필수로 구현하고, 모든 테스트를 통과시키는 것을 목표로 합니다. - -### 회원 가입 - -**🧱 단위 테스트** - -- [ ] ID 가 `영문 및 숫자 10자 이내` 형식에 맞지 않으면, User 객체 생성에 실패한다. -- [ ] 이메일이 `xx@yy.zz` 형식에 맞지 않으면, User 객체 생성에 실패한다. -- [ ] 생년월일이 `yyyy-MM-dd` 형식에 맞지 않으면, User 객체 생성에 실패한다. - -**🔗 통합 테스트** - -- [ ] 회원 가입시 User 저장이 수행된다. ( spy 검증 ) -- [ ] 이미 가입된 ID 로 회원가입 시도 시, 실패한다. - -**🌐 E2E 테스트** - -- [ ] 회원 가입이 성공할 경우, 생성된 유저 정보를 응답으로 반환한다. -- [ ] 회원 가입 시에 성별이 없을 경우, `400 Bad Request` 응답을 반환한다. - -### 내 정보 조회 - -**🔗 통합 테스트** - -- [ ] 해당 ID 의 회원이 존재할 경우, 회원 정보가 반환된다. -- [ ] 해당 ID 의 회원이 존재하지 않을 경우, null 이 반환된다. - -**🌐 E2E 테스트** - -- [ ] 내 정보 조회에 성공할 경우, 해당하는 유저 정보를 응답으로 반환한다. -- [ ] 존재하지 않는 ID 로 조회할 경우, `404 Not Found` 응답을 반환한다. - -### 포인트 조회 - -**🔗 통합 테스트** - -- [ ] 해당 ID 의 회원이 존재할 경우, 보유 포인트가 반환된다. -- [ ] 해당 ID 의 회원이 존재하지 않을 경우, null 이 반환된다. - -**🌐 E2E 테스트** - -- [ ] 포인트 조회에 성공할 경우, 보유 포인트를 응답으로 반환한다. -- [ ] `X-USER-ID` 헤더가 없을 경우, `400 Bad Request` 응답을 반환한다. From 0b4411d3cecbed9b33123091c4e37d4700b68a7d Mon Sep 17 00:00:00 2001 From: APapeIsName Date: Fri, 6 Feb 2026 01:37:09 +0900 Subject: [PATCH 08/22] =?UTF-8?q?test:=20=EB=82=B4=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EB=8B=A8=EC=9C=84=20Test=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test: 내 정보 조회 단위 Test 작성 test: 내 정보 조회 단위 Test 시 미존재 회원 조회 시 코드 수정 test: 내 정보 조회 단위 Test Member, MemberService 추가 작성 test: 내 정보 조회 단위 Test MemberService 누락된 부분 수정 --- .../application/MemberServiceTest.java | 53 +++++++++++++++++++ .../domain/member/MemberExceptionMessage.java | 15 ++++++ .../domain/member/MemberTest.java | 37 +++++++++++++ 3 files changed, 105 insertions(+) diff --git a/apps/commerce-api/src/test/java/com/loopers/application/MemberServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/MemberServiceTest.java index 6e223d75..5a11af29 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/MemberServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/MemberServiceTest.java @@ -15,8 +15,10 @@ import org.mockito.junit.jupiter.MockitoExtension; import java.time.LocalDate; +import java.util.Optional; import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) @@ -28,6 +30,10 @@ public class MemberServiceTest { @Mock private MemberRepository memberRepository; + /** + * 회원가입 + */ + @Test public void 회원가입_시_아이디_중복_불가() throws Exception { //given @@ -65,4 +71,51 @@ public class MemberServiceTest { assertThat(memberCaptor.getValue().getLoginId()).isEqualTo(request.loginId()); } + /** + * 요청 공통 + */ + + @Test + public void 존재하지_않는_회원_조회_시_예외_발생() throws Exception { + //given + String dummyId = "unknownId"; + String dummyPwd = "password123!"; + given(memberRepository.findByLoginId(dummyId)).willReturn(Optional.empty()); + + //when + + //then + assertThatThrownBy(() -> memberService.getMyInfo(dummyId, dummyPwd)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(MemberExceptionMessage.ExistsMember.CANNOT_LOGIN.message()); + } + + /** + * 내 정보 조회 + */ + + @Test + public void 내_정보_조회_시_이름_마스킹() throws Exception { + // given + String loginId = "apape123"; + String password = "password123!"; + + Member member = Member.builder() + .loginId(loginId) + .password(password) + .name("공명선") + .birthDate(LocalDate.of(2001, 2, 9)) + .email("gms72901217@gmail.com") + .build(); + + given(memberRepository.findByLoginId(loginId)).willReturn(Optional.of(member)); + + // when + MyMemberInfoResponse response = memberService.getMyInfo(loginId, password); + + // then + assertThat(response.loginId()).isEqualTo(loginId); + + } + } diff --git a/modules/jpa/src/main/java/com/loopers/domain/member/MemberExceptionMessage.java b/modules/jpa/src/main/java/com/loopers/domain/member/MemberExceptionMessage.java index 3df17a3d..bbee5973 100644 --- a/modules/jpa/src/main/java/com/loopers/domain/member/MemberExceptionMessage.java +++ b/modules/jpa/src/main/java/com/loopers/domain/member/MemberExceptionMessage.java @@ -99,4 +99,19 @@ public enum BirthDate implements ExceptionMessage { public String message() { return message; } } + /** + * 1_300 ~ 1_399: 회원(Member) 존재 여부 관련 오류 + */ + @AllArgsConstructor + public enum ExistsMember implements ExceptionMessage { + NOT_FOUND("존재하지 않는 회원입니다.", 1_301); + + private final String message; + private final Integer code; + + public String message() { + return message; + } + } + } diff --git a/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/domain/member/MemberTest.java b/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/domain/member/MemberTest.java index ad01f2cc..34473a81 100644 --- a/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/domain/member/MemberTest.java +++ b/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/domain/member/MemberTest.java @@ -350,4 +350,41 @@ void successWhenAllFieldsValid() { .doesNotThrowAnyException(); } } + + @DisplayName("요청 시 비밀번호 동일한지 검증") + @Nested + class SamePasswordValidation { + + @Test + @DisplayName("입력받은 비밀번호가 저장된 비밀번호와 정확히 일치하면 true를 반환한다") + void isSamePassword_Success() { + // given + String savedPassword = "password123!"; + Member member = Member.builder() + .password(PasswordEncryptor.encode(savedPassword)) + .build(); + + // when + boolean result = member.isSamePassword("password123!"); + + // then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("입력받은 비밀번호가 저장된 비밀번호와 다르면 false를 반환한다") + void isSamePassword_Fail() { + // given + String savedPassword = "password123!"; + Member member = Member.builder() + .password(savedPassword) + .build(); + + // when + boolean result = member.isSamePassword("wrongPassword"); + + // then + assertThat(result).isFalse(); + } + } } From 83ca5c92bbee914631abbda543d9c8fce2ee1cfe Mon Sep 17 00:00:00 2001 From: APapeIsName Date: Fri, 6 Feb 2026 01:58:23 +0900 Subject: [PATCH 09/22] =?UTF-8?q?feature:=20=EB=82=B4=20=EC=A0=95=EB=B3=B4?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=20=EB=B0=8F=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feature: 내 정보 조회 기능 구현 test: 테스트 코드 내 비밀번호 암호화 추가 fix: 예외 메시지 수정 --- .../application/service/MemberService.java | 35 +++++++++++++++++++ .../service/dto/MyMemberInfoResponse.java | 11 ++++++ .../member/MemberRepository.java | 3 ++ .../application/MemberServiceTest.java | 4 ++- .../com/loopers/domain/member/Member.java | 5 +++ .../domain/member/MemberExceptionMessage.java | 2 +- 6 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/service/dto/MyMemberInfoResponse.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/service/MemberService.java b/apps/commerce-api/src/main/java/com/loopers/application/service/MemberService.java index d38b6a1d..813de23c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/service/MemberService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/service/MemberService.java @@ -1,6 +1,7 @@ package com.loopers.application.service; import com.loopers.application.service.dto.MemberRegisterRequest; +import com.loopers.application.service.dto.MyMemberInfoResponse; import com.loopers.domain.member.Member; import com.loopers.domain.member.MemberExceptionMessage; import com.loopers.infrastructure.member.MemberRepository; @@ -25,4 +26,38 @@ public void register(MemberRegisterRequest request) { memberRepository.save(Member.register(request.loginId(), request.password(), request.name(), request.birthdate(), request.email())); } + + @Transactional(readOnly = true) + public MyMemberInfoResponse getMyInfo(String userId, String password) { + // 1. 회원 조회 (없으면 예외 발생 - MemberExceptionMessage.Common.NOT_FOUND 사용) + Member member = memberRepository.findByLoginId(userId) + .orElseThrow(() -> new IllegalArgumentException(MemberExceptionMessage.ExistsMember.CANNOT_LOGIN.message())); + + // 2. 비밀번호 일치 여부 확인 (도메인 모델의 isSamePassword 활용) + if (!member.isSamePassword(password)) { + // 비밀번호 불일치 시 예외 발생 (인증 관련 메시지 사용) + throw new IllegalArgumentException(MemberExceptionMessage.ExistsMember.CANNOT_LOGIN.message()); + } + + // 3. DTO 변환 및 이름 마스킹 처리 + return new MyMemberInfoResponse( + member.getLoginId(), + maskName(member.getName()), // 마스킹 로직 적용 + member.getBirthDate(), + member.getEmail() + ); + } + + /** + * 이름의 마지막 글자를 *로 마스킹 처리 + */ + private String maskName(String name) { + if (name == null || name.isEmpty()) { + return ""; + } + if (name.length() == 1) { + return "*"; + } + return name.substring(0, name.length() - 1) + "*"; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/service/dto/MyMemberInfoResponse.java b/apps/commerce-api/src/main/java/com/loopers/application/service/dto/MyMemberInfoResponse.java new file mode 100644 index 00000000..77b8c487 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/service/dto/MyMemberInfoResponse.java @@ -0,0 +1,11 @@ +package com.loopers.application.service.dto; + +import java.time.LocalDate; + +public record MyMemberInfoResponse( + String loginId, + String name, + LocalDate birthdate, + String email +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepository.java index c3810e0e..236e016c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepository.java @@ -3,8 +3,11 @@ import com.loopers.domain.member.Member; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface MemberRepository extends JpaRepository { boolean existsByLoginId(String inputId); + Optional findByLoginId(String loginId); } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/MemberServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/MemberServiceTest.java index 5a11af29..234732ed 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/MemberServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/MemberServiceTest.java @@ -2,9 +2,11 @@ import com.loopers.application.service.MemberService; import com.loopers.application.service.dto.MemberRegisterRequest; +import com.loopers.application.service.dto.MyMemberInfoResponse; import com.loopers.domain.member.Member; import com.loopers.domain.member.MemberExceptionMessage; import com.loopers.infrastructure.member.MemberRepository; +import com.loopers.utils.PasswordEncryptor; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -102,7 +104,7 @@ public class MemberServiceTest { Member member = Member.builder() .loginId(loginId) - .password(password) + .password(PasswordEncryptor.encode(password)) .name("공명선") .birthDate(LocalDate.of(2001, 2, 9)) .email("gms72901217@gmail.com") diff --git a/modules/jpa/src/main/java/com/loopers/domain/member/Member.java b/modules/jpa/src/main/java/com/loopers/domain/member/Member.java index 3b147dea..c6da3d63 100644 --- a/modules/jpa/src/main/java/com/loopers/domain/member/Member.java +++ b/modules/jpa/src/main/java/com/loopers/domain/member/Member.java @@ -1,6 +1,7 @@ package com.loopers.domain.member; import com.loopers.domain.member.policy.*; +import com.loopers.utils.PasswordEncryptor; import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Builder; @@ -53,4 +54,8 @@ public static Member register( .build(); } + public boolean isSamePassword(String inputPassword) { + return PasswordEncryptor.matches(inputPassword, this.password); + } + } diff --git a/modules/jpa/src/main/java/com/loopers/domain/member/MemberExceptionMessage.java b/modules/jpa/src/main/java/com/loopers/domain/member/MemberExceptionMessage.java index bbee5973..5bfe4830 100644 --- a/modules/jpa/src/main/java/com/loopers/domain/member/MemberExceptionMessage.java +++ b/modules/jpa/src/main/java/com/loopers/domain/member/MemberExceptionMessage.java @@ -104,7 +104,7 @@ public enum BirthDate implements ExceptionMessage { */ @AllArgsConstructor public enum ExistsMember implements ExceptionMessage { - NOT_FOUND("존재하지 않는 회원입니다.", 1_301); + CANNOT_LOGIN("아이디나 비밀번호가 잘못됐습니다.", 1_301); private final String message; private final Integer code; From a5cac9553e2e5253ee066de866a6424cc7148fe5 Mon Sep 17 00:00:00 2001 From: APapeIsName Date: Fri, 6 Feb 2026 02:06:37 +0900 Subject: [PATCH 10/22] =?UTF-8?q?test:=20=EB=82=B4=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MemberServiceIntegrationTest.java | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/apps/commerce-api/src/test/java/com/loopers/application/MemberServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/MemberServiceIntegrationTest.java index 71b5f4e4..205c8700 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/MemberServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/MemberServiceIntegrationTest.java @@ -2,8 +2,12 @@ import com.loopers.application.service.MemberService; import com.loopers.application.service.dto.MemberRegisterRequest; +import com.loopers.application.service.dto.MyMemberInfoResponse; import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberExceptionMessage; import com.loopers.infrastructure.member.MemberRepository; +import com.loopers.utils.PasswordEncryptor; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @@ -68,4 +72,59 @@ public class MemberServiceIntegrationTest { .isInstanceOf(IllegalArgumentException.class); } + @Test + @DisplayName("조회 성공: 올바른 ID와 비밀번호를 입력하면 마스킹된 정보를 반환한다") + void getMyInfo_Success() { + // given + String loginId = "tester123"; + String password = "password123!"; + memberRepository.save(Member.builder() + .loginId(loginId) + .password(PasswordEncryptor.encode(password)) // 실제로는 암호화된 값이 들어갈 지점 + .name("공명선") + .birthDate(LocalDate.of(2001, 2, 9)) + .email("test@loopers.com") + .build()); + + // when + MyMemberInfoResponse response = memberService.getMyInfo(loginId, password); + + // then + assertThat(response.loginId()).isEqualTo(loginId); + assertThat(response.name()).isEqualTo("공명*"); // 마스킹 검증 + assertThat(response.email()).isEqualTo("test@loopers.com"); + } + + @Test + @DisplayName("조회 실패: 아이디는 맞지만 비밀번호가 틀리면 예외를 던진다") + void getMyInfo_Fail_InvalidPassword() { + // given + String loginId = "tester123"; + memberRepository.save(Member.builder() + .loginId(loginId) + .password("correctPassword") + .name("공명선") + .birthDate(LocalDate.of(2001, 2, 9)) + .email("test@loopers.com") + .build()); + + // when & then + assertThatThrownBy(() -> memberService.getMyInfo(loginId, "wrongPassword")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(MemberExceptionMessage.ExistsMember.CANNOT_LOGIN.message()); + } + + @Test + @DisplayName("조회 실패: 존재하지 않는 아이디로 조회하면 예외를 던진다") + void getMyInfo_Fail_NotFoundId() { + // given + String unknownId = "nobody"; + String anyPassword = "anyPassword"; + + // when & then + assertThatThrownBy(() -> memberService.getMyInfo(unknownId, anyPassword)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(MemberExceptionMessage.ExistsMember.CANNOT_LOGIN.message()); + } + } From d9a06efe4b374cce5411926aa151251d80606bf4 Mon Sep 17 00:00:00 2001 From: APapeIsName Date: Fri, 6 Feb 2026 02:16:49 +0900 Subject: [PATCH 11/22] =?UTF-8?q?test:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EB=B3=80=EA=B2=BD=20=EB=8B=A8=EC=9C=84=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/MemberServiceTest.java | 21 +++ .../domain/member/MemberTest.java | 126 ++++++++++++++++++ 2 files changed, 147 insertions(+) diff --git a/apps/commerce-api/src/test/java/com/loopers/application/MemberServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/MemberServiceTest.java index 234732ed..35939504 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/MemberServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/MemberServiceTest.java @@ -8,6 +8,7 @@ import com.loopers.infrastructure.member.MemberRepository; import com.loopers.utils.PasswordEncryptor; import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; @@ -120,4 +121,24 @@ public class MemberServiceTest { } + @Test + @DisplayName("현재 비밀번호가 틀리면 수정을 진행하지 않고 예외를 던진다") + void updatePassword_Fail_InvalidCurrentPassword() { + // given + String loginId = "tester"; + Member member = Member.builder() + .loginId(loginId) + .password("correctPassword") + .build(); + + given(memberRepository.findByLoginId(loginId)).willReturn(Optional.of(member)); + + // when & then + assertThatThrownBy(() -> memberService.updatePassword(loginId, "wrongPassword", "newPassword123!")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(MemberExceptionMessage.Password.PASSWORD_CANNOT_BE_SAME_AS_CURRENT.message()); + + // 비밀번호 수정 메서드가 호출되지 않았는지 간접적으로 확인 가능 (혹은 상태 검증) + } + } diff --git a/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/domain/member/MemberTest.java b/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/domain/member/MemberTest.java index 34473a81..6230dc4b 100644 --- a/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/domain/member/MemberTest.java +++ b/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/domain/member/MemberTest.java @@ -387,4 +387,130 @@ void isSamePassword_Fail() { assertThat(result).isFalse(); } } + + @DisplayName("비밀번호 수정 정책 검증") + @Nested + class UpdatePasswordPolicy { + + @Test + @DisplayName("새 비밀번호가 현재 비밀번호와 같으면 예외가 발생한다") + void updatePassword_Fail_SameAsCurrent() { + // given + Member member = Member.builder() + .password("oldPassword123!") + .build(); + + // when & then + assertThatThrownBy(() -> member.updatePassword("oldPassword123!")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(MemberExceptionMessage.Password.PASSWORD_CANNOT_BE_SAME_AS_CURRENT.message()); + } + + @Test + @DisplayName("새 비밀번호에 생년월일이 포함되면 예외가 발생한다") + void updatePassword_Fail_ContainsBirthDate() { + // given + Member member = Member.builder() + .password("oldPass123!") + .birthDate(LocalDate.of(2001, 2, 9)) + .build(); + + // when & then + assertThatThrownBy(() -> member.updatePassword("pass20010209!")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(MemberExceptionMessage.Password.PASSWORD_CONTAINS_BIRTHDATE.message()); + } + + @DisplayName("비밀번호 형식 검증") + @Nested + class PasswordFormatValidation { + + // 1-1-1 + @Test + public void 비밀번호_길이는_8자_미만일_수_없음() throws Exception { + //given + String wrongPassword = "pap1234"; // 7글자 + + //when + + //then + throwIfWrongPasswordInput(wrongPassword) + .hasMessage(MemberExceptionMessage.Password.INVALID_PASSWORD_LENGTH.message()); + } + + // 1-1-2 + @Test + public void 비밀번호_길이는_16자_초과일_수_없음() throws Exception { + //given + String wrongPassword = "qwer1234tyui5678a"; // 17글자 + + //when + + //then + throwIfWrongPasswordInput(wrongPassword) + .hasMessage(MemberExceptionMessage.Password.INVALID_PASSWORD_LENGTH.message()); + + } + + // 1-2 + @Test + public void 비밀번호는_영문_숫자_특수문자만_사용할_수_있음() throws Exception { + //given + String wrongPassword = "한글password123"; + + //when + + //then + throwIfWrongPasswordInput(wrongPassword) + .hasMessage(MemberExceptionMessage.Password.INVALID_PASSWORD_COMPOSITION.message()); + + } + + // 2-1 + @Test + public void 사용자_생년월일_YYYYMMDD가_비밀번호_포함_불가() throws Exception { + //given + LocalDate userBirthDate = LocalDate.of(2001, 2, 9); + String wrongPassword = "pwd20010209!"; + + //when + + //then + throwIfWrongPasswordInput(wrongPassword) + .hasMessage(MemberExceptionMessage.Password.PASSWORD_CONTAINS_BIRTHDATE.message()); + } + + // 2-2 + @Test + public void 사용자_생년월일_YYMMDD가_비밀번호_포함_불가() throws Exception { + //given + LocalDate userBirthDate = LocalDate.of(2001, 2, 9); + String wrongPassword = "pass010209!"; + + //when + + //then + throwIfWrongPasswordInput(wrongPassword) + .hasMessage(MemberExceptionMessage.Password.PASSWORD_CONTAINS_BIRTHDATE.message()); + } + + // 4 + @Test + public void 비밀번호는_암호화해_저장() throws Exception { + //given + + //when + String encodedPassword = PasswordEncryptor.encode(VALID_PASSWORD); + + //then + assertThat(PasswordEncryptor.matches(VALID_PASSWORD, encodedPassword)).isTrue(); + } + + private AbstractThrowableAssert throwIfWrongPasswordInput(String wrongPassword) { + return assertThatThrownBy(() -> Member.updatePassword(wrongPassword)) + .isInstanceOf(IllegalArgumentException.class); + } + + } + } } From e94314b07690c7279115f3b242a186e92169ae88 Mon Sep 17 00:00:00 2001 From: APapeIsName Date: Fri, 6 Feb 2026 02:32:02 +0900 Subject: [PATCH 12/22] =?UTF-8?q?feature:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EB=B3=80=EA=B2=BD=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/service/MemberService.java | 15 +++++++++++++++ .../java/com/loopers/domain/member/Member.java | 15 ++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/service/MemberService.java b/apps/commerce-api/src/main/java/com/loopers/application/service/MemberService.java index 813de23c..78423e49 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/service/MemberService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/service/MemberService.java @@ -48,6 +48,21 @@ public MyMemberInfoResponse getMyInfo(String userId, String password) { ); } + @Transactional + public void updatePassword(String userId, String currentPassword, String newPassword) { + // 1. 회원 조회 + Member member = memberRepository.findByLoginId(userId) + .orElseThrow(() -> new IllegalArgumentException(MemberExceptionMessage.ExistsMember.CANNOT_LOGIN.message())); + + // 2. 본인 확인 (기존 비밀번호 일치 여부) + if (!member.isSamePassword(currentPassword)) { + throw new IllegalArgumentException(MemberExceptionMessage.Password.PASSWORD_INCORRECT.message()); // 적절한 메시지로 변경 가능 + } + + // 4. 도메인 정책 검증 및 수정 (생년월일 포함 여부 등은 도메인 내 로직에서 처리) + member.updatePassword(newPassword); + } + /** * 이름의 마지막 글자를 *로 마스킹 처리 */ diff --git a/modules/jpa/src/main/java/com/loopers/domain/member/Member.java b/modules/jpa/src/main/java/com/loopers/domain/member/Member.java index c6da3d63..4b4155e8 100644 --- a/modules/jpa/src/main/java/com/loopers/domain/member/Member.java +++ b/modules/jpa/src/main/java/com/loopers/domain/member/Member.java @@ -47,7 +47,7 @@ public static Member register( return Member.builder() .loginId(loginId) - .password(password) + .password(encodedPassword(password)) .name(name) .birthDate(birthDate) .email(email) @@ -58,4 +58,17 @@ public boolean isSamePassword(String inputPassword) { return PasswordEncryptor.matches(inputPassword, this.password); } + public void updatePassword(String newPassword) { + if (isSamePassword(newPassword)) { + throw new IllegalArgumentException(MemberExceptionMessage.Password.PASSWORD_CANNOT_BE_SAME_AS_CURRENT.message()); + } + MemberPolicy.Password.validate(newPassword, birthDate); + + this.password = encodedPassword(newPassword); + } + + private static String encodedPassword(String password) { + return PasswordEncryptor.encode(password); + } + } From fe9ae0581ad43302959de4e44a344798b638b8cc Mon Sep 17 00:00:00 2001 From: APapeIsName Date: Fri, 6 Feb 2026 02:32:17 +0900 Subject: [PATCH 13/22] =?UTF-8?q?fix:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EB=B3=80=EA=B2=BD=20=EB=8B=A8=EC=9C=84=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/application/MemberServiceTest.java | 6 +++--- .../loopers/domain/member/MemberExceptionMessage.java | 4 +++- .../testcontainers/domain/member/MemberTest.java | 10 +++++++--- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/apps/commerce-api/src/test/java/com/loopers/application/MemberServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/MemberServiceTest.java index 35939504..3963636b 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/MemberServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/MemberServiceTest.java @@ -128,15 +128,15 @@ void updatePassword_Fail_InvalidCurrentPassword() { String loginId = "tester"; Member member = Member.builder() .loginId(loginId) - .password("correctPassword") + .password("correctPasswo") .build(); given(memberRepository.findByLoginId(loginId)).willReturn(Optional.of(member)); // when & then - assertThatThrownBy(() -> memberService.updatePassword(loginId, "wrongPassword", "newPassword123!")) + assertThatThrownBy(() -> memberService.updatePassword(loginId, "wrongPassword", "newPass123!")) .isInstanceOf(IllegalArgumentException.class) - .hasMessage(MemberExceptionMessage.Password.PASSWORD_CANNOT_BE_SAME_AS_CURRENT.message()); + .hasMessage(MemberExceptionMessage.Password.PASSWORD_INCORRECT.message()); // 비밀번호 수정 메서드가 호출되지 않았는지 간접적으로 확인 가능 (혹은 상태 검증) } diff --git a/modules/jpa/src/main/java/com/loopers/domain/member/MemberExceptionMessage.java b/modules/jpa/src/main/java/com/loopers/domain/member/MemberExceptionMessage.java index 5bfe4830..8adce180 100644 --- a/modules/jpa/src/main/java/com/loopers/domain/member/MemberExceptionMessage.java +++ b/modules/jpa/src/main/java/com/loopers/domain/member/MemberExceptionMessage.java @@ -48,7 +48,9 @@ public enum Password implements ExceptionMessage { // 3-1. 재사용 금지 PASSWORD_CANNOT_BE_SAME_AS_CURRENT("현재 사용 중인 비밀번호와 동일한 비밀번호로 변경할 수 없습니다.", 1_104), - PASSWORD_NOT_ENCODED("비밀번호가 암호화되지 않았습니다.", 1_105) + PASSWORD_NOT_ENCODED("비밀번호가 암호화되지 않았습니다.", 1_105), + + PASSWORD_INCORRECT("비밀번호가 일치하지 않습니다.", 1_106) ; diff --git a/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/domain/member/MemberTest.java b/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/domain/member/MemberTest.java index 6230dc4b..06c44d3d 100644 --- a/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/domain/member/MemberTest.java +++ b/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/domain/member/MemberTest.java @@ -397,7 +397,7 @@ class UpdatePasswordPolicy { void updatePassword_Fail_SameAsCurrent() { // given Member member = Member.builder() - .password("oldPassword123!") + .password(PasswordEncryptor.encode("oldPassword123!")) .build(); // when & then @@ -421,7 +421,7 @@ void updatePassword_Fail_ContainsBirthDate() { .hasMessage(MemberExceptionMessage.Password.PASSWORD_CONTAINS_BIRTHDATE.message()); } - @DisplayName("비밀번호 형식 검증") + @DisplayName("비밀번호 수정 시 형식 검증") @Nested class PasswordFormatValidation { @@ -507,7 +507,11 @@ class PasswordFormatValidation { } private AbstractThrowableAssert throwIfWrongPasswordInput(String wrongPassword) { - return assertThatThrownBy(() -> Member.updatePassword(wrongPassword)) + Member member = Member.builder() + .password("oldPass123!") + .birthDate(LocalDate.of(2001, 2, 9)) + .build(); + return assertThatThrownBy(() -> member.updatePassword(wrongPassword)) .isInstanceOf(IllegalArgumentException.class); } From 531d2a6a48ef49cb808bee2a4760976f772e72ab Mon Sep 17 00:00:00 2001 From: APapeIsName Date: Fri, 6 Feb 2026 02:36:02 +0900 Subject: [PATCH 14/22] =?UTF-8?q?fix:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EB=B3=80=EA=B2=BD=20=ED=86=B5=ED=95=A9=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MemberServiceIntegrationTest.java | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/apps/commerce-api/src/test/java/com/loopers/application/MemberServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/MemberServiceIntegrationTest.java index 205c8700..d6210ee2 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/MemberServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/MemberServiceIntegrationTest.java @@ -127,4 +127,85 @@ void getMyInfo_Fail_NotFoundId() { .hasMessage(MemberExceptionMessage.ExistsMember.CANNOT_LOGIN.message()); } + /** + * 비밀번호 수정 통합 테스트 + */ + @Test + @DisplayName("비밀번호 수정 성공: 기존 비밀번호가 일치하고 새 비밀번호가 정책에 맞으면 수정된다") + void updatePassword_Success() { + // given + String loginId = "tester123"; + String currentPw = "oldPass123!"; + String newPw = "newPass5678@"; + + memberRepository.save(Member.builder() + .loginId(loginId) + .password(PasswordEncryptor.encode(currentPw)) + .name("공명선") + .birthDate(LocalDate.of(2001, 2, 9)) + .email("test@loopers.com") + .build()); + + // when + memberService.updatePassword(loginId, currentPw, newPw); + + // then + Member updatedMember = memberRepository.findByLoginId(loginId).orElseThrow(); + assertThat(updatedMember.isSamePassword(newPw)).isTrue(); + } + + @Test + @DisplayName("비밀번호 수정 실패: 현재 비밀번호와 새 비밀번호가 동일하면 예외가 발생한다") + void updatePassword_Fail_SamePassword() { + // given + String loginId = "tester123"; + String currentPw = "oldPass123!"; + + memberRepository.save(Member.builder() + .loginId(loginId) + .password(PasswordEncryptor.encode(currentPw)) + .birthDate(LocalDate.of(2001, 2, 9)) + .build()); + + // when & then + assertThatThrownBy(() -> memberService.updatePassword(loginId, currentPw, currentPw)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(MemberExceptionMessage.Password.PASSWORD_CANNOT_BE_SAME_AS_CURRENT.message()); + } + + @Test + @DisplayName("비밀번호 수정 실패: 새 비밀번호에 생년월일이 포함되면 예외가 발생한다") + void updatePassword_Fail_ContainsBirthDate() { + // given + String loginId = "tester123"; + String currentPw = "oldPass123!"; + String newPw = "pass20010209!"; // 생년월일 포함 + + memberRepository.save(Member.builder() + .loginId(loginId) + .password(PasswordEncryptor.encode(currentPw)) + .birthDate(LocalDate.of(2001, 2, 9)) + .build()); + + // when & then + assertThatThrownBy(() -> memberService.updatePassword(loginId, currentPw, newPw)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(MemberExceptionMessage.Password.PASSWORD_CONTAINS_BIRTHDATE.message()); + } + + @Test + @DisplayName("비밀번호 수정 실패: 현재 비밀번호가 틀리면 예외가 발생한다") + void updatePassword_Fail_IncorrectCurrentPassword() { + // given + String loginId = "tester123"; + memberRepository.save(Member.builder() + .loginId(loginId) + .password(PasswordEncryptor.encode("correct123!")) + .build()); + + // when & then + assertThatThrownBy(() -> memberService.updatePassword(loginId, "wrong123!", "newPass123!")) + .isInstanceOf(IllegalArgumentException.class); + } + } From ea90623d7efde6d2cfbaf2b4307a5193987e5f2e Mon Sep 17 00:00:00 2001 From: APapeIsName Date: Fri, 6 Feb 2026 02:50:16 +0900 Subject: [PATCH 15/22] =?UTF-8?q?test:=20E2E=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/controller/MemberController.java | 4 + .../com/loopers/controller/MemberE2ETest.java | 81 +++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/controller/MemberController.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/controller/MemberE2ETest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/controller/MemberController.java b/apps/commerce-api/src/main/java/com/loopers/controller/MemberController.java new file mode 100644 index 00000000..d17935ac --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/controller/MemberController.java @@ -0,0 +1,4 @@ +package com.loopers.controller; + +public class MemberController { +} diff --git a/apps/commerce-api/src/test/java/com/loopers/controller/MemberE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/controller/MemberE2ETest.java new file mode 100644 index 00000000..ad501412 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/controller/MemberE2ETest.java @@ -0,0 +1,81 @@ +package com.loopers.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.service.dto.MemberRegisterRequest; +import com.loopers.application.service.dto.PasswordUpdateRequest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +public class MemberE2ETest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + @DisplayName("전체 시나리오: 회원가입 - 내 정보 조회 - 비밀번호 변경") + void member_full_lifecycle_scenario() throws Exception { + // [1] 회원가입 (Register) + String loginId = "loopers123"; + String initialPw = "Initial!1234"; + MemberRegisterRequest registerRequest = new MemberRegisterRequest( + loginId, initialPw, "공명선", LocalDate.of(2001, 2, 9), "test@loopers.com" + ); + + mockMvc.perform(post("/api/members/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(registerRequest))) + .andExpect(status().isCreated()); + + // [2] 내 정보 조회 (Get Info) - 마스킹 확인 + mockMvc.perform(get("/api/members/me") + .header("X-Loopers-LoginId", loginId) + .header("X-Loopers-LoginPw", initialPw)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.loginId").value(loginId)) + .andExpect(jsonPath("$.name").value("공명*")) // 마지막 글자 마스킹 + .andExpect(jsonPath("$.email").value("test@loopers.com")); + + // [3] 비밀번호 수정 (Update Password) + String newPw = "Updated!5678"; + PasswordUpdateRequest updateRequest = new PasswordUpdateRequest(newPw); + + mockMvc.perform(patch("/api/members/password") + .header("X-Loopers-LoginId", loginId) + .header("X-Loopers-LoginPw", initialPw) // 수정 요청도 기존 헤더 인증 필요 + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest))) + .andExpect(status().isNoContent()); + + // [4] 변경된 비밀번호로 내 정보 재조회 (Re-verify) + // 새 비밀번호로 헤더를 보내야만 성공해야 함 + mockMvc.perform(get("/api/members/me") + .header("X-Loopers-LoginId", loginId) + .header("X-Loopers-LoginPw", newPw)) // New Password + .andExpect(status().isOk()) + .andExpect(jsonPath("$.loginId").value(loginId)); + + // [5] 기존 비밀번호로 조회 시도 (Should Fail) + mockMvc.perform(get("/api/members/me") + .header("X-Loopers-LoginId", loginId) + .header("X-Loopers-LoginPw", initialPw)) // Old Password + .andExpect(status().isUnauthorized()); // 또는 400 에러 (설정한 예외 처리에 따름) + } + +} From ab01a98258cb4ff85edbdaec8199ba44b03c3a61 Mon Sep 17 00:00:00 2001 From: APapeIsName Date: Fri, 6 Feb 2026 02:54:01 +0900 Subject: [PATCH 16/22] =?UTF-8?q?feature:=20=EC=BB=A8=ED=8A=B8=EB=A1=A4?= =?UTF-8?q?=EB=9F=AC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/dto/PasswordUpdateRequest.java | 6 +++ .../loopers/controller/MemberController.java | 48 +++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/service/dto/PasswordUpdateRequest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/service/dto/PasswordUpdateRequest.java b/apps/commerce-api/src/main/java/com/loopers/application/service/dto/PasswordUpdateRequest.java new file mode 100644 index 00000000..7ca9a26c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/service/dto/PasswordUpdateRequest.java @@ -0,0 +1,6 @@ +package com.loopers.application.service.dto; + +public record PasswordUpdateRequest( + String newPassword +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/controller/MemberController.java b/apps/commerce-api/src/main/java/com/loopers/controller/MemberController.java index d17935ac..8cd40646 100644 --- a/apps/commerce-api/src/main/java/com/loopers/controller/MemberController.java +++ b/apps/commerce-api/src/main/java/com/loopers/controller/MemberController.java @@ -1,4 +1,52 @@ package com.loopers.controller; +import com.loopers.application.service.MemberService; +import com.loopers.application.service.dto.MemberRegisterRequest; +import com.loopers.application.service.dto.MyMemberInfoResponse; +import com.loopers.application.service.dto.PasswordUpdateRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/members") +@RequiredArgsConstructor public class MemberController { + + private final MemberService memberService; + + /** + * 회원가입 + */ + @PostMapping("/register") + @ResponseStatus(HttpStatus.CREATED) + public void register(@RequestBody MemberRegisterRequest request) { + memberService.register(request); + } + + /** + * 내 정보 조회 + * 헤더 인증 (ID, PW) 기반 + */ + @GetMapping("/me") + public MyMemberInfoResponse getMyInfo( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password + ) { + return memberService.getMyInfo(loginId, password); + } + + /** + * 비밀번호 수정 + * 헤더 인증 (ID, 기존 PW) + 바디 (새 PW) + */ + @PatchMapping("/password") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void updatePassword( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String currentPassword, + @RequestBody PasswordUpdateRequest request + ) { + memberService.updatePassword(loginId, currentPassword, request.newPassword()); + } } From de31138c307ff23594c3354513b702e40eef0c9b Mon Sep 17 00:00:00 2001 From: APapeIsName Date: Fri, 6 Feb 2026 02:58:23 +0900 Subject: [PATCH 17/22] =?UTF-8?q?fix:=20IllegalArgumentException=20?= =?UTF-8?q?=EC=9D=BC=20=EB=95=8C=20401=EB=A1=9C=20=EC=88=98=EC=A0=95=20+?= =?UTF-8?q?=20=EC=98=88=EC=99=B8=EC=B2=98=EB=A6=AC=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?TODO=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/interfaces/api/ApiControllerAdvice.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java index 20b2809c..5852b88a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java @@ -113,6 +113,14 @@ public ResponseEntity> handle(Throwable e) { return failureResponse(ErrorType.INTERNAL_ERROR, null); } + // TODO: 예외처리 변경하는 게 좋을 것 같음 + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleIllegalArgumentException(IllegalArgumentException e) { + log.warn("IllegalArgumentException : {}", e.getMessage()); + return ResponseEntity.status(401) + .body(ApiResponse.fail("UNAUTHORIZED", e.getMessage())); + } + private String extractMissingParameter(String message) { Pattern pattern = Pattern.compile("'(.+?)'"); Matcher matcher = pattern.matcher(message); From 8fd69eee9ff1bce1a36197246cc3e3e4bccfa88a Mon Sep 17 00:00:00 2001 From: APapeIsName Date: Thu, 12 Feb 2026 02:29:38 +0900 Subject: [PATCH 18/22] =?UTF-8?q?docs:=2001.=20=EC=9A=94=EA=B5=AC=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EB=AC=B8=EC=84=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../prompt-design-process-analysis.md | 45 ++ .../requirements-gathering-analysis.md | 233 +++++++++ docs/design/01-requirements.md | 478 ++++++++++++++++++ 3 files changed, 756 insertions(+) create mode 100644 docs/analysis/prompt-design-process-analysis.md create mode 100644 docs/analysis/requirements-gathering-analysis.md create mode 100644 docs/design/01-requirements.md diff --git a/docs/analysis/prompt-design-process-analysis.md b/docs/analysis/prompt-design-process-analysis.md new file mode 100644 index 00000000..25a0a405 --- /dev/null +++ b/docs/analysis/prompt-design-process-analysis.md @@ -0,0 +1,45 @@ +# 설계 프로세스 분석 프롬프트 + +> 각 설계 산출물(요구사항 정의서, 시퀀스 다이어그램, 클래스 다이어그램 등)을 함께 작성한 뒤, +> 그 과정을 분석하여 재사용 가능한 규칙을 도출하기 위한 프롬프트이다. +> 아래 프롬프트를 산출물명에 맞게 수정하여 사용한다. + +--- + +## 프롬프트 원형 + +``` +{산출물명}을 같이 그리면서 내가 말했던 내용들을 바탕으로 분석하는 md를 작성해줘. + +구체적으로: +1. 내가 {산출물명} 을 정리하면서 말했던 내용들을 바탕으로 + 내가 어떤 식으로 {산출물명} 을 정리하는지에 대해 세세히 분석해줘. +2. 그 예시 채팅을 아래 적어줘. +3. 이를 단계나 순서로 나누고, 내가 어떤 점에 주목하는지에 대한 패턴도 분석해줘. +4. 나한테 부족했던 점들 중 너가 재질문 하면서 채워줬던 부분들도 포함해줘. +5. 나중에 {산출물명} 정리 시 사용할 수 있는 rule을 만들어줘. +``` + +## 최초 사용 예시 (요구사항 정의서) + +> "시퀀스 다이어그램을 조금 고쳐야 할 거 같아. 그전에 일단 내가 요구사항 정리하면서 말했던 내용들을 바탕으로 내가 어떤 식으로 요구사항을 정리하는지에 대해 세세히 분석하고, 그 예시 채팅을 아래 적어줘. 그리고 이를 단계나 순서로 나누고, 내가 어떤 점에 주목하는지에 대한 패턴도 분석해줘. 또한, 나한테 부족했던 점들 중 너가 재질문 하면서 채워줬던 부분들 등등 나중에 요구사항 정리 시 사용할 수 있는 rule을 만들기 위한 md 문서를 만들어줘." + +## 분석 문서 구조 (공통) + +``` +1. {산출물명} 정리 단계 (실제 진행 순서) + - 각 단계마다: 설명 + 예시 채팅 + 패턴 요약 +2. 의사결정 시 주목하는 포인트 +3. AI가 재질문으로 채워준 영역 (개발자가 놓친 부분) +4. 발견된 반복 실수 패턴 +5. {산출물명} 정리 시 확인해야 할 체크리스트 +``` + +## 적용 이력 + +| # | 산출물 | 분석 문서 | +|---|--------|----------| +| 1 | 요구사항 정의서 (01-requirements.md) | requirements-gathering-analysis.md | +| 2 | 시퀀스 다이어그램 (02-sequence-diagrams.md) | sequence-diagram-analysis.md | +| 3 | 클래스 다이어그램 (03-class-diagram.md) | class-diagram-analysis.md | +| 4 | ERD (04-erd.md) | erd-analysis.md | diff --git a/docs/analysis/requirements-gathering-analysis.md b/docs/analysis/requirements-gathering-analysis.md new file mode 100644 index 00000000..36ca1b8e --- /dev/null +++ b/docs/analysis/requirements-gathering-analysis.md @@ -0,0 +1,233 @@ +# 요구사항 정리 프로세스 분석 + +> 01-requirements.md 작성 과정에서 드러난 요구사항 정리 패턴, 의사결정 방식, 보완이 필요한 영역을 분석한다. +> 향후 요구사항 정리 시 재사용할 수 있는 규칙(Rule)을 도출하기 위한 문서이다. + +--- + +## 1. 요구사항 정리 단계 (실제 진행 순서) + +### 1단계: 초안 생성 → 즉시 톤 보정 +- AI가 기존 설계 문서(도메인 정의서, 유저 스토리 등)를 기반으로 초안을 작성 +- **개발자가 가장 먼저 한 일**: 대상 독자(audience) 확인 + +``` +예시 채팅: +개발자: "너무 개발적이라는 거야. 이 정리된 문서는 기획자와 디자이너도 알아들을 수 있어야 해." +``` + +> **패턴**: 내용보다 톤/독자를 먼저 잡는다. 내용이 아무리 정확해도 읽는 사람이 이해 못하면 의미 없다. + +--- + +### 2단계: 빈 곳 식별 → 빠른 의사결정 +- AI에게 "설계 문서에서 채워지지 않은 부분이 뭐냐"고 질문 +- AI가 9개 미결 항목을 제시하면, **한 번에 전부 답변** + +``` +예시 채팅: +개발자: "여기서 너가 생각했을 때 애매했던 내용이 뭐였어? design 안에 있던 모든 문서들을 확인해보면서 + 그 부분이 안 채워진 게 뭐였어?" + +AI: [9가지 미결사항 제시] + +개발자: "1번은 폐점 브랜드인 걸 보여줘야 해. 2번은 주문 내역에 남겨야 해. 3번은 취소 가능하게 해야 해. + 4번은 별도 라인으로 처리해줘. 5번은 반드시 넣어야 돼. 6번은 중복 불가. 7번은 전체로 하자. + 8번은 [정렬 테이블]. 9번은 당연히 에러야." +``` + +> **패턴**: 결정을 미루지 않는다. "나중에 결정"이 아닌, 지금 당장 가능한 답을 준다. +> 단, 확실하지 않은 건 "미정"이나 "지금 중요하지 않음"으로 분류한다 (예: 주문 수량 상한). + +--- + +### 3단계: 자기 검증 요청 +- 문서가 어느 정도 완성되면, AI에게 셀프 리뷰를 요청 + +``` +예시 채팅: +개발자: "그러면 지금 요구사항 문서가 내가 제시해줬던 요구사항들을 잘 풀어내고 있다고 생각해? + 추상화가 너무 돼 있거나, 구현 시 필요한 용어들이 포함돼 있거나, + 특정 상황에 대한 예외 상황들이 명시되지 않았거나 하진 않아?" +``` + +> **패턴**: 직접 검토하기 전에 AI를 먼저 검증 도구로 활용한다. +> 리뷰 관점을 구체적으로 제시한다: (1) 추상화 수준, (2) 기술 용어 혼입, (3) 예외 상황 누락. + +--- + +### 4단계: 설계 방향 전환 (대대적 수정) +- 리뷰 과정에서 근본적인 설계 변경을 결단 + +``` +예시 채팅: +개발자: "오케이 일단 하나 수정할게. 좀 대대적인 수정인데, 폐점이라는 기능은 없애고, + soft-delete의 삭제로 바꿀게. 내가 너무 요구사항까지 바꾸려고 했던 것 같아서." +``` + +> **패턴**: 복잡도가 올라가면 기능을 빼는 쪽으로 결정한다. "더 넣기"보다 "덜어내기"를 선호한다. +> 자기 판단의 오류도 인정한다 ("내가 너무 요구사항까지 바꾸려고 했던 것 같아서"). + +--- + +### 5단계: 안티패턴 체크 +- 문서가 다 만들어진 후, 알려진 안티패턴과 비교 + +``` +예시 채팅: +개발자: "전체적으로 + - 기능 중심만 있고 예외/조건이 없음 + - 유스케이스 흐름을 너무 추상적으로 작성 + - 실제 흐름과 명세가 점차 이격이 발생 + 이 부분은 요구사항의 안 좋은 형태야. 이 부분과 비교해서 우리 요구사항이 잘 적혀 있는지 확인해줘." +``` + +> **패턴**: 일반론이 아닌, 구체적인 안티패턴 목록을 제시하고 대조 검증을 요청한다. + +--- + +### 6단계: 원본 명세 대조 +- 최종 단계에서 원본 API 명세와 1:1 대조 + +``` +예시 채팅: +개발자: [원본 API 명세 전체를 제공하며] + "이 부분들이 전부 요구사항에 녹여들어있는지 확인해줘." + +AI: [3가지 불일치 발견 - 좋아요 토글/분리, 브랜드 연쇄 삭제, 추가된 기능] + +개발자: "1번은 원본 명세에 맞게 수정하자. 2번도 연쇄로 바꾸자. 3번은 전부 없애자." +``` + +> **패턴**: 요구사항이 원본에서 벗어나지 않았는지 최종 확인한다. 벗어난 부분은 원본에 맞춘다. +> 원본에 없는 기능은 과감히 제거한다. + +--- + +## 2. 의사결정 시 주목하는 포인트 + +### 2-1. 독자 중심 +- 문서의 첫 번째 기준: "기획자와 디자이너가 알아들을 수 있는가?" +- HTTP 상태코드, DB 용어, 트랜잭션 등 기술 용어 배제 +- 사용자 관점의 에러 메시지로 대체 ("404" → "상품을 찾을 수 없습니다") + +### 2-2. 단순화 우선 +- 복잡한 기능보다 단순한 구조를 선호 +- 폐점 + 삭제 → 삭제(soft-delete)만으로 통합 +- 주문 취소 기능 → 원본에 없으니 제거 +- REQUESTED 상태 → 저장하지 않음 + +### 2-3. 일관성 강박 +- 문서 간 불일치를 허용하지 않음 +- 요구사항 ↔ 원본 API 명세 1:1 대조 +- 같은 개념에 다른 용어를 쓰는 것을 허용하지 않음 + +### 2-4. 예외 상황 우선 +- "정상 흐름"보다 "이런 경우에는"에 더 관심 +- "이미 삭제된 걸 또 삭제하면?", "브랜드가 삭제된 상품에 좋아요 하면?" 등 +- 예외 상황이 명시되지 않은 문서는 불완전하다고 판단 + +### 2-5. 결정의 이유 기록 +- 단순히 "A로 하자"가 아닌, 왜 A인지를 함께 결정 +- 예: 스냅샷 저장 → "거절된 내용도 사용자 입장에서는 필요하기 때문" +- 예: soft-delete → "내가 너무 요구사항까지 바꾸려고 했던 것 같아서" + +--- + +## 3. AI가 재질문으로 채워준 영역 (개발자가 놓친 부분) + +### 3-1. 흐름 간 모순 +**문제**: 좋아요 목록에서 폐점 브랜드 상품이 보이는데, 그걸 누르면 상세 조회 시 "not found" +``` +AI: "좋아요 목록에서는 폐점 상품이 보이는데, 그걸 탭하면 상세에서 404가 뜨는 UX 모순이 있습니다." +``` +> **교훈**: 기능 단위로 정의하면 기능 사이의 연결점에서 모순이 생긴다. 기능 간 흐름을 따라가며 검증해야 한다. + +### 3-2. 등록에는 있고 수정에는 없는 검증 규칙 +**문제**: 상품 등록(5-3)에는 가격/재고 검증이 있는데, 상품 수정(5-4)에는 없었음 +``` +AI: "5-3에는 가격/재고 검증이 있는데, 5-4에는 빠져 있습니다." +``` +> **교훈**: 등록과 수정의 검증 규칙은 대칭이어야 한다. 하나를 정의하면 나머지도 확인해야 한다. + +### 3-3. 삭제의 연쇄 효과 범위 +**문제**: 브랜드를 삭제하면 상품은? 상품을 삭제하면 좋아요는? 주문 기록은? +``` +AI: "삭제된 브랜드의 상품에 좋아요/주문이 가능한가요?" +개발자: "불가능해." +``` +> **교훈**: 삭제 기능을 정의할 때, 관련된 모든 도메인에 대한 영향도를 체크리스트로 확인해야 한다. + +### 3-4. soft-delete 시 재등록 메커니즘 +**문제**: 좋아요를 취소(soft-delete)한 후 다시 등록하면? 새 레코드? 복원? +``` +AI: "좋아요 재등록 시 새로운 레코드를 만드나요, 기존 soft-delete 기록을 복원하나요?" +개발자: "기존 기록을 복원하는 쪽이야." +``` +> **교훈**: soft-delete를 선택하면, "다시 만들기" 시나리오가 반드시 따라온다. 삭제 방식을 정하면 재생성 방식도 함께 정해야 한다. + +### 3-5. 조회 조건의 필수/선택 구분 +**문제**: 주문 목록의 날짜 필터가 필수인지 선택인지 명시되지 않았음 +``` +AI: "날짜 필터링은 필수인가요, 선택인가요?" +개발자: "반드시 넣어야 돼." +``` +> **교훈**: 모든 필터/파라미터에 대해 필수/선택을 명시해야 한다. 암묵적으로 남겨두면 구현 시 혼란이 생긴다. + +### 3-6. 거절/실패 케이스의 상세 수준 +**문제**: 거절된 주문의 상세 조회 시 어떤 정보가 보이는지 정의되지 않았음 +``` +AI: "거절된 주문 상세에는 거절 사유? 스냅샷? 상태만?" +개발자: "거절됨 상태만 보이게 일단은." +→ (이후 추가 결정) "스냅샷을 저장하도록 하자. 거절된 내용도 사용자 입장에서는 필요하기 때문." +``` +> **교훈**: 실패 케이스도 정상 케이스만큼 상세하게 정의해야 한다. "에러"로 퉁치면 안 된다. + +--- + +## 4. 발견된 반복 실수 패턴 + +### 4-1. AI의 과잉 추가 +- AI가 원본 명세에 없는 기능을 "자연스럽다"는 이유로 추가 + - 사용자 브랜드 목록 (원본에 없음) + - 주문 취소 (원본에 없음) + - 브랜드 복원 (원본에 없음) +- **규칙**: 원본 명세에 없는 기능은 절대 추가하지 않는다. 필요하면 별도 제안으로 분리한다. + +### 4-2. AI의 임의 판단 +- AI가 시퀀스 다이어그램에서 "단순 CRUD"라고 판단하여 임의로 제외 +``` +개발자: "혹시 단순 CRUD를 빼라고 했었나 내가?" +``` +- **규칙**: 제외/추가 판단은 개발자가 한다. AI는 전부 포함한 후 개발자가 빼도록 한다. + +### 4-3. 기술 용어 반복 혼입 +- "비관적 락", "SELECT FOR UPDATE", "트랜잭션", "FK" 등이 요구사항 문서에 반복 등장 +- 한 번 지적해도 다시 나타남 +- **규칙**: 요구사항 문서에 기술 용어가 포함되면 작성 실패로 간주한다. + +--- + +## 5. 요구사항 정리 시 확인해야 할 체크리스트 + +### A. 문서 작성 전 +- [ ] 대상 독자가 누구인지 먼저 정한다 (기획자? 개발자? 디자이너?) +- [ ] 원본 명세(API 스펙, 기획서 등)를 확보한다 +- [ ] 기존 설계 문서를 전부 읽고, 빈 곳을 식별한다 + +### B. 빈 곳 채우기 +- [ ] 모든 "삭제" 기능에 대해: 연쇄 범위, 되돌림 가능 여부, 재생성 시 동작 +- [ ] 모든 "조회" 기능에 대해: 필터 필수/선택, 정렬 기본값, 페이지 크기 +- [ ] 모든 "등록" 기능에 대해: 필수/선택 필드, 검증 규칙, 중복 허용 여부 +- [ ] 모든 "수정" 기능에 대해: 전체/부분, 변경 불가 필드, 검증 규칙 (등록과 대칭) +- [ ] 실패 케이스: 어떤 정보를 보여주는지, 기록으로 남기는지 + +### C. 검증 +- [ ] 기능 간 흐름 추적: A 화면 → B 화면 이동 시 모순 없는지 +- [ ] 원본 명세와 1:1 대조: 빠진 것, 추가된 것, 변경된 것 +- [ ] 안티패턴 체크: 예외 없는 기능, 추상적 흐름, 명세 이격 +- [ ] 기술 용어 오염 체크: 독자가 이해 못할 단어가 없는지 + +### D. 마무리 +- [ ] 열린 결정사항을 명시적으로 기록한다 (결정 보류 ≠ 누락) +- [ ] 결정된 사항에는 이유를 함께 기록한다 diff --git a/docs/design/01-requirements.md b/docs/design/01-requirements.md new file mode 100644 index 00000000..f7bd7dc0 --- /dev/null +++ b/docs/design/01-requirements.md @@ -0,0 +1,478 @@ +# 기능 정의서 — 감성 이커머스 + +> 작성일: 2026-02-11 +> 이 문서는 기획, 디자인, 개발이 같은 그림을 보기 위한 기능 정의서입니다. +> "사용자가 뭘 하면, 어떤 일이 일어나는가"를 중심으로 기술합니다. + +--- + +## 등장인물 + +| 누구 | 설명 | 할 수 있는 일 | +|------|------|--------------| +| **사용자** | 앱에 들어온 모든 사람 (비회원 포함) | 상품 둘러보기, 브랜드 조회 | +| **회원** | 회원가입을 마친 사용자 | 좋아요 등록/취소, 주문 | +| **관리자** | 서비스를 운영하는 내부 직원 | 브랜드 관리, 상품 관리, 주문 확인 | + +--- + +## 1. 상품 둘러보기 + +> 누구나 할 수 있다. 로그인 필요 없음. + +### 1-1. 상품 목록 보기 + +**[유저 스토리]** +- 사용자는 등록된 상품 목록을 볼 수 있다. +- 특정 브랜드의 상품만 골라 볼 수 있다. +- 최신순, 가격순, 좋아요순으로 정렬할 수 있다. + +**[기능 흐름]** +1. 사용자가 상품 목록 화면에 진입한다. +2. 기본으로 최신 등록순으로 상품이 노출된다. +3. 원하면 브랜드를 선택하여 해당 브랜드 상품만 필터링한다. +4. 원하면 정렬 기준을 변경한다. + - **최신순** (기본값) + - 가격 낮은순 + - 좋아요 많은순 +5. 한 페이지에 20개씩 노출되며, 페이지를 넘길 수 있다. + +**[이런 경우에는]** +- 상품이 하나도 없으면 → 빈 목록 화면 ("등록된 상품이 없습니다" 등) +- 삭제된 상품은 → 목록에 나타나지 않는다 + +--- + +### 1-2. 상품 상세 보기 + +**[유저 스토리]** +- 사용자는 상품을 눌러 상세 정보를 확인할 수 있다. +- 상품 이름, 설명, 가격, 남은 재고, 소속 브랜드 정보가 보인다. + +**[기능 흐름]** +1. 사용자가 목록에서 상품을 선택한다. +2. 상품 상세 화면에 진입한다. +3. 상품 정보(이름, 설명, 가격, 재고)와 브랜드 정보(이름, 설명)가 함께 표시된다. + +**[이런 경우에는]** +- 없는 상품이거나 삭제된 상품을 조회하면 → "상품을 찾을 수 없습니다" + +--- + +### 1-3. 브랜드 상세 보기 + +**[유저 스토리]** +- 사용자는 특정 브랜드의 이름과 설명을 확인할 수 있다. + +**[기능 흐름]** +1. 사용자가 브랜드를 선택한다. +2. 브랜드 상세 정보(이름, 설명)가 표시된다. + +**[이런 경우에는]** +- 없는 브랜드이거나 삭제된 브랜드면 → "브랜드를 찾을 수 없습니다" + +--- + +## 2. 좋아요 + +> 회원만 할 수 있다. 로그인 필요. + +### 2-1. 좋아요 등록 + +**[유저 스토리]** +- 회원은 마음에 드는 상품에 좋아요를 등록할 수 있다. + +**[기능 흐름]** +1. 회원이 상품에서 좋아요 등록을 요청한다. +2. 해당 상품이 존재하고 삭제되지 않았는지 확인한다. +3. 해당 회원이 이 상품에 이미 좋아요를 눌렀는지 확인한다. +4. 좋아요가 등록된다. + +**[이런 경우에는]** +- 로그인하지 않은 사용자 → 로그인 필요 안내 +- 없거나 삭제된 상품 → "상품을 찾을 수 없습니다" +- 이미 좋아요를 누른 상태 → "이미 좋아요한 상품입니다" +- 소속 브랜드가 삭제된 상품 → "현재 이용할 수 없는 상품입니다" + +--- + +### 2-2. 좋아요 취소 + +**[유저 스토리]** +- 회원은 좋아요를 누른 상품의 좋아요를 취소할 수 있다. + +**[기능 흐름]** +1. 회원이 상품에서 좋아요 취소를 요청한다. +2. 해당 상품에 좋아요를 누른 상태인지 확인한다. +3. 좋아요가 취소된다. + +**[이런 경우에는]** +- 로그인하지 않은 사용자 → 로그인 필요 안내 +- 없거나 삭제된 상품 → "상품을 찾을 수 없습니다" +- 좋아요를 누르지 않은 상태에서 취소 시도 → "좋아요하지 않은 상품입니다" +- 소속 브랜드가 삭제된 상품이라도 → 취소는 정상적으로 가능 + +--- + +### 2-3. 내 좋아요 목록 보기 + +**[유저 스토리]** +- 회원은 자신이 좋아요한 상품을 모아 볼 수 있다. + +**[기능 흐름]** +1. 회원이 내 좋아요 목록 화면에 진입한다. +2. 좋아요한 상품이 목록으로 노출된다. +3. 페이지 단위로 표시된다. + +**[이런 경우에는]** +- 로그인하지 않은 사용자 → 로그인 필요 안내 +- 좋아요한 상품이 없으면 → 빈 목록 화면 ("좋아요한 상품이 없습니다" 등) +- 좋아요를 눌렀는데 이후 상품이 삭제된 경우 → 해당 항목은 목록에서 제외 + +--- + +## 3. 주문 + +> 회원만 할 수 있다. 로그인 필요. + +### 3-1. 주문하기 + +**[유저 스토리]** +- 회원은 여러 상품을 수량과 함께 지정하여 한 번에 주문할 수 있다. +- 모든 상품의 재고가 충분하면 주문이 **수락**되고, 해당 수량만큼 재고가 줄어든다. +- 하나라도 재고가 부족하면 주문 전체가 **거절**되고, 이미 줄어든 재고도 원래대로 돌아간다. +- 주문 시점의 상품 정보(이름, 설명, 가격, 브랜드명, 수량)가 주문 기록으로 남는다. + +**[기능 흐름]** +1. 회원이 주문할 상품과 수량을 선택한다. + - 예: A상품 2개, B상품 1개 +2. 주문 요청을 보낸다. +3. 각 상품의 재고가 충분한지 확인한다. +4. 주문 당시의 상품 정보를 기록(스냅샷)으로 저장한다. +5. **재고가 모두 충분하면:** + - 재고를 수량만큼 차감한다. + - 주문 상태: **수락(ACCEPTED)** + - 회원에게 주문 완료 화면을 보여준다. +6. **하나라도 재고가 부족하면:** + - 아무것도 차감하지 않는다. (전부 아니면 전무) + - 주문 상태: **거절(REJECTED)** + - 어떤 상품이 부족한지 알려준다 (상품명, 요청 수량, 남은 재고). + +**[이런 경우에는]** +- 로그인하지 않은 사용자 → 로그인 필요 안내 +- 주문할 상품을 하나도 선택하지 않음 → "주문할 상품을 선택해주세요" +- 수량을 0개 이하로 입력 → "수량은 1개 이상이어야 합니다" +- 없거나 삭제된 상품이 포함됨 → "상품을 찾을 수 없습니다" +- 소속 브랜드가 삭제된 상품이 포함됨 → "현재 주문할 수 없는 상품이 포함되어 있습니다" +- 같은 상품이 중복으로 들어오면 → "동일한 상품이 중복으로 포함되어 있습니다" +- 거절된 주문도 주문 내역에 기록으로 남는다 (회원이 "왜 거절됐는지" 확인할 수 있도록) + +**[주문 기록(스냅샷)이란?]** +> 주문을 넣는 순간의 상품 정보를 사진처럼 찍어두는 것이다. +> 나중에 상품 이름이 바뀌거나 가격이 올라도, 주문 기록에는 "그때 그 가격, 그때 그 이름"이 그대로 남는다. +> 상품이나 브랜드가 삭제되어도 주문 기록은 절대 사라지지 않는다. + +--- + +### 3-2. 내 주문 내역 보기 + +**[유저 스토리]** +- 회원은 자신의 주문 내역을 기간을 지정하여 조회할 수 있다. +- 수락된 주문, 거절된 주문 모두 내역에 남아 있다. +- 다른 사람의 주문은 볼 수 없다. + +**[기능 흐름]** +1. 회원이 주문 내역 화면에 진입한다. +2. **시작일과 종료일을 반드시 선택**한다. +3. 해당 기간 내의 본인 주문이 목록으로 표시된다. +4. 각 주문에는 상태(수락/거절)가 함께 보인다. +5. 페이지 단위로 표시된다. + +**[이런 경우에는]** +- 로그인하지 않은 사용자 → 로그인 필요 안내 +- 날짜를 선택하지 않으면 → "조회 기간을 선택해주세요" +- 시작일이 종료일보다 뒤면 → "시작일은 종료일보다 앞이어야 합니다" +- 해당 기간에 주문이 없으면 → 빈 목록 화면 + +--- + +### 3-3. 주문 상세 보기 + +**[유저 스토리]** +- 회원은 특정 주문을 눌러 상세 내역을 확인할 수 있다. +- 주문 상태, 주문한 상품 목록, 각 상품의 당시 가격과 수량이 보인다. + +**[기능 흐름]** +1. 회원이 주문 내역에서 특정 주문을 선택한다. +2. 주문 정보(상태, 주문일시)와 상품 기록(이름, 가격, 수량, 브랜드명)이 표시된다. + +**[이런 경우에는]** +- 없는 주문을 조회하면 → "주문을 찾을 수 없습니다" +- 다른 사람의 주문을 조회하면 → "접근 권한이 없습니다" + +--- + +## 4. 관리자 — 브랜드 관리 + +> 관리자 인증을 통과해야 사용할 수 있다. + +### 4-1. 브랜드 목록 보기 + +**[유저 스토리]** +- 관리자는 전체 브랜드를 한눈에 볼 수 있다. +- 삭제된 브랜드도 모두 보인다. + +**[기능 흐름]** +1. 관리자가 브랜드 관리 화면에 진입한다. +2. 모든 브랜드가 목록으로 표시된다 (삭제된 브랜드 포함). +3. 각 브랜드의 상태(활성 / 삭제됨)가 표시된다. +4. 페이지 단위로 표시된다. + +--- + +### 4-2. 브랜드 상세 보기 + +**[유저 스토리]** +- 관리자는 브랜드의 상세 정보와 삭제 여부를 확인할 수 있다. + +**[기능 흐름]** +1. 관리자가 브랜드를 선택한다. +2. 이름, 설명, 상태(활성 / 삭제됨)가 표시된다. + +**[이런 경우에는]** +- 없는 브랜드 → "브랜드를 찾을 수 없습니다" + +--- + +### 4-3. 브랜드 등록 + +**[유저 스토리]** +- 관리자는 새로운 브랜드를 등록할 수 있다. +- 같은 이름의 브랜드는 등록할 수 없다. + +**[기능 흐름]** +1. 관리자가 이름, 설명을 입력한다. +2. 브랜드가 등록된다. + +**[이런 경우에는]** +- 이름을 입력하지 않으면 → "브랜드명을 입력해주세요" +- 설명은 입력하지 않아도 된다 (선택) +- 이미 같은 이름의 브랜드가 있으면 → "이미 존재하는 브랜드명입니다" + +--- + +### 4-4. 브랜드 수정 + +**[유저 스토리]** +- 관리자는 브랜드의 이름과 설명을 수정할 수 있다. +- 수정 시 이름과 설명을 모두 보내야 한다 (전체 덮어쓰기 방식). + +**[기능 흐름]** +1. 관리자가 수정할 브랜드를 선택한다. +2. 이름과 설명을 변경하고 저장한다. + +**[이런 경우에는]** +- 없는 브랜드 → "브랜드를 찾을 수 없습니다" +- 변경한 이름이 다른 브랜드와 중복 → "이미 존재하는 브랜드명입니다" + +--- + +### 4-5. 브랜드 삭제 + +**[유저 스토리]** +- 관리자는 브랜드를 삭제할 수 있다. +- 삭제하면 해당 브랜드의 소속 상품도 함께 삭제된다. +- 기존 주문 기록(스냅샷)은 영향 없다. + +**[기능 흐름]** +1. 관리자가 삭제할 브랜드를 선택한다. +2. 삭제를 확인한다. +3. 브랜드가 삭제 상태로 전환된다. +4. 해당 브랜드의 소속 상품도 함께 삭제 상태로 전환된다. + +**[이런 경우에는]** +- 없는 브랜드 → "브랜드를 찾을 수 없습니다" +- 이미 삭제된 브랜드 → "이미 삭제된 브랜드입니다" + +--- + +## 5. 관리자 — 상품 관리 + +> 관리자 인증을 통과해야 사용할 수 있다. + +### 5-1. 상품 목록 보기 + +**[유저 스토리]** +- 관리자는 전체 상품을 한눈에 볼 수 있다. +- 삭제된 상품도 모두 보인다. +- 브랜드별로 필터링할 수 있다. + +**[기능 흐름]** +1. 관리자가 상품 관리 화면에 진입한다. +2. 모든 상품이 목록으로 표시된다 (삭제된 상품 포함). +3. 원하면 브랜드를 선택하여 필터링한다. +4. 페이지 단위로 표시된다. + +--- + +### 5-2. 상품 상세 보기 + +**[유저 스토리]** +- 관리자는 상품의 상세 정보를 확인할 수 있다. + +**[기능 흐름]** +1. 관리자가 상품을 선택한다. +2. 상품 정보(이름, 설명, 가격, 재고)와 소속 브랜드 정보가 표시된다. + +**[이런 경우에는]** +- 없는 상품 → "상품을 찾을 수 없습니다" + +--- + +### 5-3. 상품 등록 + +**[유저 스토리]** +- 관리자는 새로운 상품을 등록할 수 있다. +- 상품은 반드시 하나의 브랜드에 소속되어야 한다. +- 한번 정해진 브랜드는 나중에 바꿀 수 없다. +- 삭제된 브랜드에는 상품을 등록할 수 없다. + +**[기능 흐름]** +1. 관리자가 상품 정보를 입력한다: 이름, 설명, 가격, 재고, 소속 브랜드 +2. 해당 브랜드가 존재하고 활성 상태인지 확인한다. +3. 상품이 등록된다. + +**[이런 경우에는]** +- 브랜드를 선택하지 않거나 없는 브랜드이면 → "브랜드를 찾을 수 없습니다" +- 삭제된 브랜드를 선택하면 → "삭제된 브랜드에는 상품을 등록할 수 없습니다" +- 이름을 입력하지 않으면 → "상품명을 입력해주세요" +- 설명은 입력하지 않아도 된다 (선택) +- 가격이 0원 이하이면 → "가격은 1원 이상이어야 합니다" +- 재고가 0 미만이면 → "재고는 0 이상이어야 합니다" + +--- + +### 5-4. 상품 수정 + +**[유저 스토리]** +- 관리자는 상품의 이름, 설명, 가격, 재고를 수정할 수 있다. +- 소속 브랜드는 변경할 수 없다. +- 수정 시 모든 항목을 보내야 한다 (전체 덮어쓰기 방식). + +**[기능 흐름]** +1. 관리자가 수정할 상품을 선택한다. +2. 이름, 설명, 가격, 재고를 변경하고 저장한다. + +**[이런 경우에는]** +- 없는 상품 → "상품을 찾을 수 없습니다" +- 브랜드를 바꾸려고 하면 → "소속 브랜드는 변경할 수 없습니다" +- 가격이 0원 이하이면 → "가격은 1원 이상이어야 합니다" +- 재고가 0 미만이면 → "재고는 0 이상이어야 합니다" + +--- + +### 5-5. 상품 삭제 + +**[유저 스토리]** +- 관리자는 상품을 삭제할 수 있다. +- 사용자 화면에서 삭제된 상품은 더 이상 보이지 않는다. +- 기존 주문 기록(스냅샷)은 영향 없다. + +**[기능 흐름]** +1. 관리자가 삭제할 상품을 선택한다. +2. 삭제를 확인한다. +3. 상품이 삭제 상태로 전환된다. + +**[이런 경우에는]** +- 없는 상품 → "상품을 찾을 수 없습니다" +- 이미 삭제된 상품 → "이미 삭제된 상품입니다" + +--- + +## 6. 관리자 — 주문 확인 + +> 관리자 인증을 통과해야 사용할 수 있다. +> 관리자는 주문을 **확인만** 할 수 있다. 주문을 만들거나 취소할 수 없다. + +### 6-1. 주문 목록 보기 + +**[유저 스토리]** +- 관리자는 모든 회원의 주문 목록을 확인할 수 있다. + +**[기능 흐름]** +1. 관리자가 주문 관리 화면에 진입한다. +2. 전체 주문이 목록으로 표시된다. +3. 페이지 단위로 표시된다. + +--- + +### 6-2. 주문 상세 보기 + +**[유저 스토리]** +- 관리자는 특정 주문의 상세 내역을 확인할 수 있다. + +**[기능 흐름]** +1. 관리자가 주문을 선택한다. +2. 주문 정보(상태, 주문일시, 주문 회원)와 상품 기록(이름, 가격, 수량, 브랜드명)이 표시된다. + +**[이런 경우에는]** +- 없는 주문 → "주문을 찾을 수 없습니다" + +--- + +## 7. 인증 + +### 7-1. 회원 인증 + +회원 전용 기능(좋아요, 주문)을 사용하려면 로그인이 필요하다. +로그인하지 않고 회원 전용 기능에 접근하면 → "로그인이 필요합니다" + +### 7-2. 관리자 인증 + +관리자 전용 기능(브랜드 관리, 상품 관리, 주문 확인)을 사용하려면 관리자 인증이 필요하다. +인증에 실패하면 → "관리자 인증이 필요합니다" + +--- + +## 부록 A: 주문의 생명주기 + +주문은 두 가지 상태를 가진다. + +``` +주문 요청 ──(재고 충분)──→ 수락 + │ + └──(재고 부족)──→ 거절 +``` + +| 상태 | 의미 | +|------|------| +| **수락 (ACCEPTED)** | 재고 확인 완료, 재고 차감됨, 주문 성공 | +| **거절 (REJECTED)** | 재고 부족으로 주문 실패, 차감 없음 | + +- 수락과 거절 모두 최종 상태이다. 한번 결정되면 변경할 수 없다. +- 거절된 주문도 내역에 남아서, 회원이 "왜 주문이 안 됐는지" 확인할 수 있다. + +--- + +## 부록 B: 삭제 정책 + +브랜드와 상품의 삭제는 **되돌릴 수 있는 삭제**(soft-delete)이다. +삭제된 데이터는 사용자 화면에서 보이지 않지만, 관리자 화면에서는 확인할 수 있다. +좋아요는 **물리 삭제**(hard-delete)이다. 취소 시 레코드가 완전히 제거된다. + +| 대상 | 삭제 방식 | 삭제하면 | 연쇄 효과 | +|------|----------|---------|----------| +| **브랜드** | soft-delete | 사용자 화면에서 브랜드가 사라짐 | 소속 상품도 함께 삭제됨 | +| **상품** | soft-delete | 사용자 화면에서 상품이 사라짐. 좋아요 목록에서도 제외됨 | - | +| **좋아요** | hard-delete | 좋아요가 완전히 제거됨 | - | + +- 주문 기록(스냅샷)은 어떤 삭제에도 영향받지 않는다. + +--- + +## 부록 C: 결정 이력 + +| # | 항목 | 결정 | 이유 | +|---|------|------|------| +| 1 | 거절된 주문에 상품 기록(스냅샷)을 저장할지 | 저장한다 | 거절된 내용도 사용자 입장에서 확인이 필요하기 때문 | +| 2 | 좋아요 삭제 방식 | hard-delete | 좋아요 처리가 soft-delete 시 많은 자원을 소모하므로, 물리 삭제로 변경 | From e2ba79d7e13567f72dece4bbb370fa870d4793f0 Mon Sep 17 00:00:00 2001 From: APapeIsName Date: Thu, 12 Feb 2026 02:30:04 +0900 Subject: [PATCH 19/22] =?UTF-8?q?docs:=2002.=20=EC=8B=9C=ED=80=80=EC=8A=A4?= =?UTF-8?q?=20=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/analysis/sequence-diagram-analysis.md | 252 +++++++++ docs/design/02-sequence-diagrams.md | 588 +++++++++++++++++++++ 2 files changed, 840 insertions(+) create mode 100644 docs/analysis/sequence-diagram-analysis.md create mode 100644 docs/design/02-sequence-diagrams.md diff --git a/docs/analysis/sequence-diagram-analysis.md b/docs/analysis/sequence-diagram-analysis.md new file mode 100644 index 00000000..5939a2ec --- /dev/null +++ b/docs/analysis/sequence-diagram-analysis.md @@ -0,0 +1,252 @@ +# 시퀀스 다이어그램 프로세스 분석 + +> 02-sequence-diagrams.md 작성 과정에서 드러난 설계 철학, 의사결정 방식, 보완이 필요한 영역을 분석한다. +> 향후 시퀀스 다이어그램 작성 시 재사용할 수 있는 규칙(Rule)을 도출하기 위한 문서이다. + +--- + +## 1. 시퀀스 다이어그램 정리 단계 (실제 진행 순서) + +### 1단계: 초안 생성 → 범위 보정 + +- AI가 기능 정의서(01-requirements.md)와 기존 base 문서들을 참고하여 초안 작성 +- **AI가 "단순 CRUD는 가치가 없다"고 판단하여 6개만 작성** → 개발자가 즉시 지적 + +``` +예시 채팅: +개발자: "혹시 단순 CRUD를 빼라고 했었나 내가?" +AI: "... 제 판단으로 뺐습니다." +개발자: "응 모든 API들은 일단 넣어줘." +``` + +> **패턴**: 포함/제외 판단은 개발자가 한다. AI는 전부 포함한 후 개발자가 빼도록 한다. 이 패턴은 요구사항 정리 때와 동일하다. + +--- + +### 2단계: 시퀀스 다이어그램의 목적 정의 + +- 21개 전체 다이어그램이 완성된 후, 개발자가 시퀀스 다이어그램의 **근본 목적**을 설명 + +``` +예시 채팅: +개발자: "시퀀스 다이어그램은 객체지향 설계를 위해 객체들이 어떤 일을 맡아야 하는가? 를 정하기 위한 + 작업이라고 생각해. 시퀀스 다이어그램에서 객체와 메시지가 있는데, 이 메시지들은 결국 객체의 + 책임이라고 생각하거든. 객체가 수행해야 하는 책임은 무엇인가 에 대해 생각하고, 각 객체가 + 이를 잘 수행하고 있는가 를 생각할 수 있어야 해." +``` + +> **패턴**: 산출물의 형태보다 목적을 먼저 정의한다. "어떻게 그릴까"가 아닌 "왜 그리는가"를 먼저 합의한다. + +--- + +### 3단계: 표현 수준 결정 (개발적 vs 구현적) + +- 목적 정의 후, 어디까지 보여주고 어디부터 빼야 하는지를 결정 + +``` +예시 채팅: +개발자: "구현적인 내용이 들어가면 안 될 거 같아. DB 명령어가 뭐고 이런 건 필요없어. + 다만, 조금은 개발적이어도 괜찮아. 예를 들어, 객체명이나 메소드명이 들어간다던지 하는 것들. + 물론, API 엔드포인트명 도 포함이야." +``` + +> **패턴**: "금지 선"과 "허용 선"을 명시적으로 긋는다. +> - **금지**: SQL, DB 명령어, 트랜잭션 키워드, 물리적 저장 방식 +> - **허용**: 객체명, 메서드명, API 엔드포인트, 한글 설명 + +--- + +### 4단계: 애매한 설계 요소 질의 → 즉시 결정 + +- AI가 4가지 설계 의문을 제시하고, 개발자가 즉시 결정 + +``` +예시 채팅: +AI: "1. Repository도 참여자로 포함할지? + 2. Facade가 필요한지? + 3. 예외/alt 블록은 유지할지? + 4. 메시지 포맷은?" + +개발자: "1. repository 도 필요해. 서비스도 repository 가 없다면 어디서 정보를 가져올지를 못 정할 거 같아. + 2. Facade 는 서비스 여러개가 서로 순환 관계를 이룰 때만 사용하면 될 것 같아. + 우선 facade 가 정말 필요한지 확인해줘. + 3. 응 그건 유지해줘. + 4. 한글 설명 + 메서드명 이렇게." +``` + +> **패턴**: 결정을 미루지 않고, 이유와 함께 즉시 답변한다. 확실하지 않은 건 "확인해줘"로 검증을 요청한다. + +--- + +### 5단계: Facade 필요성 검증 + +- AI가 서비스 간 의존 관계를 분석하고, 순환이 있는 곳만 Facade를 적용 +- 개발자가 순환 해소 방안(Controller로 올리기 vs Facade)을 평가 + +``` +예시 채팅: +AI: "BrandService ↔ ProductService 순환을 Controller에서 조율하면 Facade 없이 해결 가능합니다." + +개발자: "음 컨트롤러를 헤친다면 별로 인 것 같아. 일단 Facade 넣어봐. + Brand - Product 사이 브랜드 삭제 시랑 상품 등록 시에는." +``` + +> **패턴**: 이론적으로 맞더라도 실용적 판단을 우선한다. "Controller가 지저분해지면 차라리 Facade"라는 실용 기준. 패턴의 교과서적 정당성보다 코드의 깔끔함을 중시한다. + +--- + +### 6단계: 안티패턴 제시 → 자기 진단 요청 + +- 개발자가 시퀀스 다이어그램의 안티패턴 3가지를 제시하고 현재 상태를 점검 + +``` +예시 채팅: +개발자: "- 너무 많은 세부 흐름을 다 넣어서 시퀀스가 복잡함 + - 도메인 객체 간 메시지 없이 Service만 호출 + - 시퀀스와 실제 구현이 따로 놀아 유지보수 불가능 + 이 부분은 안 좋은 예시야. 그리고 시퀀스 다이어그램에서 책임 객체가 드러나는가?" +``` + +> **패턴**: 요구사항 정리 때와 동일하게, 구체적인 안티패턴을 제시하고 대조 검증을 요청한다. "잘 됐어?"가 아닌 "이 기준에 맞아?"라고 묻는다. + +--- + +### 7단계: 핵심 문제 진단 → 전면 재작성 지시 + +- AI가 자기 진단에서 문제 #2, #3에 해당한다고 인정 +- 개발자가 최종 판단을 내리고 재작성 지시 + +``` +예시 채팅: +개발자: "그렇네 너무 alt 가 많고 엔티티가 하는 일은 없어. 이건 시퀀스 다이어그램으로써 + 큰 가치가 없어. 객체지향적이지 못하니까. 수정해서 다시 작성해줘." +``` + +> **패턴**: 부분 수정이 아닌 전면 재작성을 결정한다. 근본 구조가 잘못되면 덧대기보다 다시 짓는 것을 선택한다. + +--- + +## 2. 의사결정 시 주목하는 포인트 + +### 2-1. 목적 중심 사고 +- 다이어그램의 목적: 객체의 책임을 정하는 것 +- 형식보다 "이 다이어그램을 보고 누가 무슨 일을 하는지 알 수 있는가?"를 기준으로 평가 +- 예쁜 다이어그램보다 책임이 드러나는 다이어그램을 선호 + +### 2-2. 구현과 설계의 경계 +- SQL, 트랜잭션, FK → 구현. 시퀀스에 불필요 +- 객체명, 메서드명, Repository → 설계. 시퀀스에 필요 +- **경계선**: "이 정보가 없으면 객체의 책임을 정할 수 없는가?" → 없으면 빼고, 있어야 하면 넣는다 + +### 2-3. 실용적 패턴 선택 +- Facade: 이론적으로 필요하지 않아도, 코드가 지저분해지면 도입 +- Controller에 로직 올리기: "컨트롤러를 헤치면 별로" → 거부 +- **판단 기준**: 코드를 읽는 사람이 자연스럽게 이해할 수 있는가 + +### 2-4. 엔티티에 행위 부여 +- Service가 모든 걸 하는 건 "객체지향적이지 못함" +- Brand가 `delete()`를 직접 수행, Product가 `decreaseStock()`을 직접 수행 +- **판단 기준**: "이 로직이 누구의 책임인가?" → 데이터를 가진 객체가 직접 해야 한다 + +### 2-5. 단순함이 가치 +- alt 블록이 많으면 → 복잡해져서 가치가 떨어진다 +- 예외 처리는 Note나 guard 메서드로 간결하게 +- 진짜 비즈니스 분기(수락/거절)만 alt로 표현 + +--- + +## 3. AI가 질문으로 채워준 영역 (개발자가 놓친 부분) + +### 3-1. Facade 적용 범위 구체화 +**문제**: "순환일 때 Facade를 쓴다"까지는 결정했지만, 어떤 케이스가 순환인지 구체적으로 식별하지 않았음 + +``` +AI: [서비스 의존 관계 분석 결과] + "OrderService → ProductService (단방향) + LikeService → ProductService (단방향) + BrandService ↔ ProductService (순환: 브랜드 삭제 연쇄 + 상품 등록 시 브랜드 검증)" +``` + +> **교훈**: 패턴(Facade)을 적용하겠다는 결정 후, 적용 대상을 구체적으로 열거해야 한다. "필요할 때"가 아닌 "이 두 곳에서"로 확정해야 한다. + +### 3-2. Controller vs Facade 트레이드오프 제시 +**문제**: 개발자가 Facade를 바로 넣으라고 했지만, AI가 Controller에서 해결하는 대안을 먼저 제시 + +``` +AI: "BrandService ↔ ProductService 순환을 Controller에서 Brand 조회 후 ProductService에 전달하면 + Facade 없이도 해결됩니다." + +개발자: "음 컨트롤러를 헤친다면 별로 인 것 같아. 일단 Facade 넣어봐." +``` + +> **교훈**: 대안을 제시하고 비교한 뒤 결정하는 것이 더 나은 의사결정으로 이어진다. 다만 개발자가 빠르게 판단할 수 있도록 간결하게 제시해야 한다. + +### 3-3. 엔티티 행위 구체화 +**문제**: "엔티티가 행위를 가져야 한다"는 방향은 정했지만, 어떤 행위를 누가 가질지 구체화되지 않았음 + +``` +AI: [엔티티별 행위 도출] + "Brand: update(), guardNotDeleted(), delete() + Product: update(), guardNotDeleted(), delete(), decreaseStock() + Order: isOwnedBy() + ProductLike: restore(), delete() → (hard-delete 전환 후 제거)" +``` + +> **교훈**: 객체지향 원칙에 합의한 뒤, 각 엔티티의 구체적 행위 목록을 확정하는 과정이 필요하다. + +--- + +## 4. 발견된 반복 실수 패턴 + +### 4-1. AI의 범위 임의 축소 +- "단순 CRUD"라고 판단하여 6개 API만 작성 (21개 중 15개 누락) +- 요구사항 정리 때의 "과잉 추가"와 반대 방향이지만, 근본 원인은 동일: **AI가 임의 판단** +- **규칙**: 전부 포함한 후 개발자가 뺀다. 빼는 것도, 넣는 것도 AI가 판단하지 않는다. + +### 4-2. 구현 디테일 반복 혼입 +- 1차: SQL 쿼리, Database 참여자, FOR UPDATE 구문 포함 +- 2차: Repository로 교체했으나, Note에 SQL 힌트("JOIN brand b ON...") 잔존 +- **규칙**: 시퀀스 다이어그램에 SQL, 트랜잭션 키워드, DB 명령어가 포함되면 작성 실패로 간주한다. + +### 4-3. Service만 있는 절차적 흐름 +- 여러 차례 수정에도 엔티티가 참여자로 등장하지 않음 +- 엔티티 행위를 Note에 숨김 ("Product 엔티티가 stock 차감 수행"을 메모로만 표시) +- **규칙**: Note에 엔티티 행위가 서술되어 있으면, 그 엔티티를 참여자로 승격시켜야 한다. + +### 4-4. alt 블록 남용 +- 모든 예외 상황(404, 403, 400 등)을 alt로 표현 +- 다이어그램이 예외 처리 명세서가 되어 핵심 흐름이 묻힘 +- **규칙**: alt는 진짜 비즈니스 분기(수락/거절 등)에만 사용한다. 예외는 guard 메서드나 Note로 처리한다. + +--- + +## 5. 시퀀스 다이어그램 작성 시 확인해야 할 체크리스트 + +### A. 작성 전 확인 +- [ ] 이 다이어그램의 목적을 정의했는가? (객체의 책임 도출) +- [ ] 대상 API 전체 목록을 확인했는가? (임의 누락 방지) +- [ ] 표현 수준의 경계를 정했는가? (금지: SQL, DB / 허용: 객체명, 메서드명) + +### B. 참여자 점검 +- [ ] 도메인 엔티티가 참여자로 포함되어 있는가? +- [ ] 엔티티가 직접 메시지를 받는 행위가 있는가? (update, delete, guard 등) +- [ ] Repository가 포함되어 있는가? +- [ ] Facade는 순환 의존이 증명된 곳에만 사용했는가? +- [ ] Note에 엔티티 행위가 서술되어 있지 않은가? (있으면 → 참여자로 승격) + +### C. 메시지 점검 +- [ ] 모든 메시지가 "한글 설명 + 메서드명(파라미터)" 형식인가? +- [ ] 메시지가 해당 객체의 책임을 나타내는가? +- [ ] 구현 디테일(SQL, 트랜잭션)이 포함되지 않았는가? + +### D. 구조 점검 +- [ ] alt 블록이 진짜 비즈니스 분기에만 사용되었는가? +- [ ] 예외 처리가 guard 메서드나 Note로 간결하게 표현되었는가? +- [ ] 시퀀스와 실제 구현 구조가 대응하는가? (Service → Entity 메서드 호출 등) +- [ ] "이 다이어그램을 보고 각 객체가 무슨 일을 하는지 알 수 있는가?" + +### E. 완성 후 검증 +- [ ] 안티패턴 체크: Service만 호출하는 절차적 흐름이 아닌가? +- [ ] 안티패턴 체크: 너무 많은 세부 흐름으로 복잡하지 않은가? +- [ ] 안티패턴 체크: 시퀀스와 실제 구현이 따로 놀지 않는가? +- [ ] 기능 정의서(01-requirements.md)의 모든 API가 빠짐없이 포함되었는가? diff --git a/docs/design/02-sequence-diagrams.md b/docs/design/02-sequence-diagrams.md new file mode 100644 index 00000000..66ba80f4 --- /dev/null +++ b/docs/design/02-sequence-diagrams.md @@ -0,0 +1,588 @@ +# 시퀀스 다이어그램 + +> 작성일: 2026-02-11 +> 기능 정의서(01-requirements.md) 기반 +> 각 객체가 어떤 책임을 맡는가, 객체 간 메시지(책임 위임)는 어떻게 흐르는가를 중심으로 기술한다. +> 도메인 엔티티(Brand, Product, Order 등)도 책임 객체로서 다이어그램에 참여한다. + +### 객체 의존 관계 + +``` +OrderService → ProductService (주문 시 재고 확인/차감) +LikeService → ProductService (좋아요 시 상품/브랜드 유효성 확인) +BrandService ↔ ProductService (순환: 브랜드 삭제 연쇄 / 상품 등록 시 브랜드 검증) + → Facade로 해소: AdminBrandFacade, AdminProductFacade +``` + +--- + +## 1. 상품 둘러보기 + +### 1-1. 상품 목록 보기 + +```mermaid +sequenceDiagram + actor U as 사용자 + participant C as ProductController + participant PS as ProductService + participant PR as ProductRepository + + U->>C: GET /api/v1/products?page&size&sort&brandId + C->>PS: 상품 목록 조회 getProducts(page, size, sort, brandId) + PS->>PR: 활성 상품 페이징 조회 findAllActive(page, size, sort, brandId) + Note over PR: 삭제된 상품·브랜드 제외
brandId 필터 (선택)
정렬: latest / price_asc / likes_desc + PR-->>PS: Page + PS-->>C: Page + C-->>U: 200 OK +``` + +#### 읽는 포인트 +- **ProductRepository**: 삭제 필터링(상품 + 브랜드), 정렬, 페이징을 한 번의 조회로 처리하는 책임. + +--- + +### 1-2. 상품 상세 보기 + +```mermaid +sequenceDiagram + actor U as 사용자 + participant C as ProductController + participant PS as ProductService + participant PR as ProductRepository + + U->>C: GET /api/v1/products/{productId} + C->>PS: 상품 상세 조회 getProductDetail(productId) + PS->>PR: 활성 상품 단건 조회 findActiveById(productId) + Note over PR: 삭제된 상품·브랜드 제외
브랜드 정보 함께 조회 + PR-->>PS: Product + Brand + PS-->>C: ProductDetailInfo + C-->>U: 200 OK +``` + +#### 읽는 포인트 +- **ProductRepository**: 상품과 브랜드를 한 번에 조회하며, 어느 쪽이든 삭제되었으면 조회 불가. + +--- + +### 1-3. 브랜드 상세 보기 + +```mermaid +sequenceDiagram + actor U as 사용자 + participant C as BrandController + participant BS as BrandService + participant BR as BrandRepository + + U->>C: GET /api/v1/brands/{brandId} + C->>BS: 브랜드 상세 조회 getBrand(brandId) + BS->>BR: 활성 브랜드 단건 조회 findActiveById(brandId) + BR-->>BS: Brand + BS-->>C: BrandInfo + C-->>U: 200 OK +``` + +--- + +## 2. 좋아요 + +### 2-1. 좋아요 등록 + +```mermaid +sequenceDiagram + actor M as 회원 + participant C as LikeController + participant LS as LikeService + participant PS as ProductService + participant LR as LikeRepository + + M->>C: POST /api/v1/products/{productId}/likes + C->>LS: 좋아요 등록 registerLike(memberId, productId) + + LS->>PS: 상품 유효성 확인 getActiveProduct(productId) + Note over PS: 상품 존재·삭제 여부, 브랜드 삭제 여부 확인 + PS-->>LS: Product + + LS->>LR: 중복 확인 existsByMemberIdAndProductId(memberId, productId) + LR-->>LS: boolean + + LS->>LR: 좋아요 저장 save(newLike) + + LS-->>C: 등록 완료 + C-->>M: 201 Created +``` + +#### 읽는 포인트 +- **LikeService**: 등록 흐름 조율. 상품 유효성은 ProductService에 위임하여, 상품/브랜드 상태를 직접 알 필요가 없다. +- **LikeRepository**: 중복 확인과 저장의 책임. hard-delete 방식이므로 취소 이력 없이 단순하게 존재 여부만 확인한다. +- 이미 좋아요가 있으면 LikeService가 예외를 발생시킨다. + +--- + +### 2-2. 좋아요 취소 + +```mermaid +sequenceDiagram + actor M as 회원 + participant C as LikeController + participant LS as LikeService + participant LR as LikeRepository + + M->>C: DELETE /api/v1/products/{productId}/likes + C->>LS: 좋아요 취소 cancelLike(memberId, productId) + + LS->>LR: 좋아요 조회 findByMemberIdAndProductId(memberId, productId) + LR-->>LS: ProductLike + + LS->>LR: 좋아요 삭제 delete(productLike) + Note over LR: 물리 삭제 (hard-delete) + + LS-->>C: 취소 완료 + C-->>M: 200 OK +``` + +#### 읽는 포인트 +- **LikeRepository**: 물리 삭제(hard-delete)의 책임. 레코드가 완전히 제거되므로 재등록 시 새 레코드가 생성된다. +- **LikeService**: 상품/브랜드 존재 여부를 확인하지 않는다. 요구사항에 따라 브랜드가 삭제되어도 취소는 가능하다. + +--- + +### 2-3. 내 좋아요 목록 보기 + +```mermaid +sequenceDiagram + actor M as 회원 + participant C as LikeController + participant LS as LikeService + participant LR as LikeRepository + + M->>C: GET /api/v1/likes?page&size + C->>LS: 내 좋아요 목록 조회 getMyLikes(memberId, page, size) + LS->>LR: 좋아요 목록 조회 findLikesByMemberId(memberId, page, size) + Note over LR: 상품 활성 + 브랜드 활성 조건 필터
삭제된 상품·브랜드의 좋아요는 제외 + LR-->>LS: Page + LS-->>C: Page + C-->>M: 200 OK +``` + +#### 읽는 포인트 +- **LikeRepository**: 상품·브랜드 활성 필터링의 책임. 삭제된 상품/브랜드에 대한 좋아요는 목록에서 제외된다. + +--- + +## 3. 주문 + +### 3-1. 주문하기 + +```mermaid +sequenceDiagram + actor M as 회원 + participant C as OrderController + participant OS as OrderService + participant PS as ProductService + participant P as Product + participant OR as OrderRepository + + M->>C: POST /api/v1/orders [{productId, quantity}, ...] + C->>OS: 주문 생성 createOrder(memberId, items) + + OS->>OS: 중복 상품 검증, productId 오름차순 정렬 + + loop 각 상품 (오름차순) + OS->>PS: 주문용 상품 조회 (락) getProductForOrder(productId) + Note over PS: 삭제된 상품·브랜드 확인, 비관적 락 획득 + PS-->>OS: Product + end + + OS->>OS: 스냅샷 생성 (모든 상품의 현재 정보 캡처) + + loop 각 상품 재고 확인 + OS->>P: 재고 충분 확인 hasEnoughStock(quantity) + end + + alt 모든 상품 재고 충분 + loop 각 상품 + OS->>P: 재고 차감 decreaseStock(quantity) + end + OS->>OR: 수락 주문 저장 save(order: ACCEPTED, snapshots) + else 하나라도 재고 부족 + OS->>OR: 거절 주문 저장 save(order: REJECTED, snapshots) + end + + OS-->>C: 주문 결과 + C-->>M: 응답 +``` + +#### 읽는 포인트 +- **OrderService**: 주문 흐름 전체를 조율하는 책임. 중복 검증, 정렬(데드락 방지), 스냅샷 생성, 수락/거절 판단까지 관장한다. ProductService와 단방향 의존. +- **Product 엔티티**: `hasEnoughStock(quantity)` — 재고 충분 여부를 Product이 스스로 판단한다. `decreaseStock(quantity)` — 재고 차감도 Product이 스스로 수행한다. 외부에서 stock 값을 직접 조작하지 않는다. +- **ProductService**: 비관적 락으로 상품을 조회하고 유효성(삭제 여부, 브랜드 상태)을 확인하는 책임. +- **OrderRepository**: 수락/거절 모두 스냅샷과 함께 저장하는 책임. +- **"확인 먼저, 차감 나중"**: 모든 상품을 먼저 확보한 후, 재고 충분 여부를 판단하고, 충분할 때만 차감한다. + +--- + +### 3-2. 내 주문 내역 보기 + +```mermaid +sequenceDiagram + actor M as 회원 + participant C as OrderController + participant OS as OrderService + participant OR as OrderRepository + + M->>C: GET /api/v1/orders?startDate&endDate&page&size + C->>OS: 내 주문 목록 조회 getMyOrders(memberId, startDate, endDate, page, size) + Note over OS: 날짜 유효성 검증 (필수, 시작일 ≤ 종료일) + OS->>OR: 회원의 기간별 주문 조회 findByMemberIdAndPeriod(memberId, startDate, endDate, page, size) + OR-->>OS: Page + OS-->>C: Page + C-->>M: 200 OK +``` + +#### 읽는 포인트 +- **OrderService**: 날짜 유효성 검증의 책임. 날짜는 필수이며, 시작일이 종료일보다 뒤면 예외. +- **OrderRepository**: 회원 ID + 기간 필터의 책임. 본인 주문만 조회된다. + +--- + +### 3-3. 주문 상세 보기 + +```mermaid +sequenceDiagram + actor M as 회원 + participant C as OrderController + participant OS as OrderService + participant OR as OrderRepository + participant O as Order + + M->>C: GET /api/v1/orders/{orderId} + C->>OS: 주문 상세 조회 getOrderDetail(memberId, orderId) + OS->>OR: 주문 + 스냅샷 조회 findWithSnapshotsById(orderId) + OR-->>OS: Order + List + + OS->>O: 본인 확인 isOwnedBy(memberId) + Note over O: 본인 주문이 아니면 예외 + + OS-->>C: OrderDetailInfo + C-->>M: 200 OK +``` + +#### 읽는 포인트 +- **Order 엔티티**: `isOwnedBy(memberId)` — 본인 확인은 Order 객체 스스로가 판단한다. Service가 memberId를 비교하는 것이 아니다. +- **OrderRepository**: 주문과 스냅샷을 함께 로딩하는 책임. + +--- + +## 4. 관리자 — 브랜드 관리 + +### 4-1. 브랜드 목록 보기 + +```mermaid +sequenceDiagram + actor A as 관리자 + participant C as AdminBrandController + participant BS as BrandService + participant BR as BrandRepository + + A->>C: GET /api/v1/admin/brands?page&size + C->>BS: 전체 브랜드 목록 조회 getAllBrands(page, size) + BS->>BR: 전체 브랜드 페이징 조회 findAll(page, size) + Note over BR: 삭제된 브랜드 포함 + BR-->>BS: Page + BS-->>C: Page + C-->>A: 200 OK +``` + +--- + +### 4-2. 브랜드 상세 보기 + +```mermaid +sequenceDiagram + actor A as 관리자 + participant C as AdminBrandController + participant BS as BrandService + participant BR as BrandRepository + + A->>C: GET /api/v1/admin/brands/{brandId} + C->>BS: 브랜드 상세 조회 getBrand(brandId) + BS->>BR: 브랜드 단건 조회 findById(brandId) + Note over BR: 삭제된 브랜드도 조회 가능 + BR-->>BS: Brand + BS-->>C: BrandDetailInfo + C-->>A: 200 OK +``` + +--- + +### 4-3. 브랜드 등록 + +```mermaid +sequenceDiagram + actor A as 관리자 + participant C as AdminBrandController + participant BS as BrandService + participant BR as BrandRepository + + A->>C: POST /api/v1/admin/brands {name, description} + C->>BS: 브랜드 등록 createBrand(name, description) + BS->>BR: 이름 중복 확인 existsByName(name) + BR-->>BS: boolean + BS->>BR: 브랜드 저장 save(brand) + BR-->>BS: Brand + BS-->>C: BrandInfo + C-->>A: 201 Created +``` + +#### 읽는 포인트 +- **BrandService**: 입력값 검증(이름 필수)과 이름 중복 검증의 책임. 설명은 선택. +- **BrandRepository**: 이름 중복 여부 확인과 저장의 책임. + +--- + +### 4-4. 브랜드 수정 + +```mermaid +sequenceDiagram + actor A as 관리자 + participant C as AdminBrandController + participant BS as BrandService + participant BR as BrandRepository + participant B as Brand + + A->>C: PUT /api/v1/admin/brands/{brandId} {name, description} + C->>BS: 브랜드 수정 updateBrand(brandId, name, description) + BS->>BR: 브랜드 조회 findById(brandId) + BR-->>BS: Brand + BS->>BR: 이름 중복 확인 (자기 제외) existsByNameAndIdNot(name, brandId) + BR-->>BS: boolean + BS->>B: 정보 변경 update(name, description) + BS-->>C: BrandInfo + C-->>A: 200 OK +``` + +#### 읽는 포인트 +- **Brand 엔티티**: `update(name, description)` — 정보 변경은 Brand 객체 스스로가 수행한다. 전체 덮어쓰기 방식. +- **BrandRepository**: 이름 중복 검증 시 자기 자신을 제외하는 책임. + +--- + +### 4-5. 브랜드 삭제 (연쇄 soft-delete) + +> BrandService ↔ ProductService 순환 의존을 Facade로 해소한다. + +```mermaid +sequenceDiagram + actor A as 관리자 + participant C as AdminBrandController + participant F as AdminBrandFacade + participant BS as BrandService + participant PS as ProductService + participant B as Brand + + A->>C: DELETE /api/v1/admin/brands/{brandId} + C->>F: 브랜드 삭제 deleteBrand(brandId) + + F->>BS: 브랜드 조회 getBrand(brandId) + BS-->>F: Brand + + F->>B: 삭제 여부 확인 guardNotDeleted() + Note over B: 이미 삭제된 상태면 예외 + + F->>PS: 소속 상품 연쇄 삭제 softDeleteByBrandId(brandId) + F->>B: 삭제 delete() + Note over B: name 변경 + deletedAt 세팅
(UNIQUE 제약 해소) + + F-->>C: 삭제 완료 + C-->>A: 204 No Content +``` + +#### 읽는 포인트 +- **AdminBrandFacade**: BrandService ↔ ProductService 순환을 해소하는 조율자. 삭제 순서(상품 먼저 → 브랜드 나중)를 결정하는 책임. +- **Brand 엔티티**: `guardNotDeleted()` — 삭제 가능 상태인지 스스로 검증한다. `delete()` — deletedAt 세팅도 스스로 수행한다. +- **ProductService**: 브랜드 ID로 소속 상품을 일괄 soft-delete하는 책임. 좋아요는 건드리지 않는다. + +--- + +## 5. 관리자 — 상품 관리 + +### 5-1. 상품 목록 보기 + +```mermaid +sequenceDiagram + actor A as 관리자 + participant C as AdminProductController + participant PS as ProductService + participant PR as ProductRepository + + A->>C: GET /api/v1/admin/products?page&size&brandId + C->>PS: 전체 상품 목록 조회 getAllProducts(page, size, brandId) + PS->>PR: 전체 상품 페이징 조회 findAll(page, size, brandId) + Note over PR: 삭제된 상품 포함, brandId 필터 (선택) + PR-->>PS: Page + PS-->>C: Page + C-->>A: 200 OK +``` + +--- + +### 5-2. 상품 상세 보기 + +```mermaid +sequenceDiagram + actor A as 관리자 + participant C as AdminProductController + participant PS as ProductService + participant PR as ProductRepository + + A->>C: GET /api/v1/admin/products/{productId} + C->>PS: 상품 상세 조회 getProduct(productId) + PS->>PR: 상품 단건 조회 findById(productId) + Note over PR: 삭제된 상품도 조회 가능, 브랜드 정보 함께 조회 + PR-->>PS: Product + Brand + PS-->>C: ProductDetailInfo + C-->>A: 200 OK +``` + +--- + +### 5-3. 상품 등록 + +> BrandService ↔ ProductService 순환 의존을 Facade로 해소한다. + +```mermaid +sequenceDiagram + actor A as 관리자 + participant C as AdminProductController + participant F as AdminProductFacade + participant BS as BrandService + participant B as Brand + participant PS as ProductService + participant P as Product + participant PR as ProductRepository + + A->>C: POST /api/v1/admin/products {name, description, price, stock, brandId} + C->>F: 상품 등록 createProduct(name, description, price, stock, brandId) + + F->>BS: 브랜드 조회 getBrand(brandId) + BS-->>F: Brand + + F->>B: 삭제 여부 확인 guardNotDeleted() + Note over B: 삭제된 브랜드면 예외 + + F->>PS: 상품 생성 createProduct(name, description, price, stock, brandId) + PS->>P: 생성 new Product(name, description, price, stock, brandId) + Note over P: 가격 > 0, 재고 >= 0 검증 + PS->>PR: 상품 저장 save(product) + PR-->>PS: Product + PS-->>F: Product + + F-->>C: ProductInfo + C-->>A: 201 Created +``` + +#### 읽는 포인트 +- **AdminProductFacade**: BrandService ↔ ProductService 순환을 해소하는 조율자. 브랜드 검증 → 상품 생성 순서를 결정하는 책임. +- **Brand 엔티티**: `guardNotDeleted()` — 삭제된 브랜드에 상품을 등록할 수 없다는 불변식을 Brand 스스로가 지킨다. +- **Product 엔티티**: 생성 시 입력값 검증(가격 > 0, 재고 >= 0)을 스스로 수행한다. +- **ProductService**: 상품 생성 조율과 저장의 책임. 입력값 검증은 Product에 위임. BrandService를 모른다. + +--- + +### 5-4. 상품 수정 + +```mermaid +sequenceDiagram + actor A as 관리자 + participant C as AdminProductController + participant PS as ProductService + participant PR as ProductRepository + participant P as Product + + A->>C: PUT /api/v1/admin/products/{productId} {name, description, price, stock} + C->>PS: 상품 수정 updateProduct(productId, name, description, price, stock) + PS->>PR: 상품 조회 findById(productId) + PR-->>PS: Product + + PS->>P: 정보 변경 update(name, description, price, stock) + Note over P: 가격 > 0, 재고 >= 0 검증
브랜드는 변경 불가 + + PS-->>C: ProductInfo + C-->>A: 200 OK +``` + +#### 읽는 포인트 +- **Product 엔티티**: `update(name, description, price, stock)` — 정보 변경과 입력값 검증을 Product 스스로가 수행한다. 브랜드 변경은 아예 파라미터로 받지 않는다. + +--- + +### 5-5. 상품 삭제 + +```mermaid +sequenceDiagram + actor A as 관리자 + participant C as AdminProductController + participant PS as ProductService + participant PR as ProductRepository + participant P as Product + + A->>C: DELETE /api/v1/admin/products/{productId} + C->>PS: 상품 삭제 deleteProduct(productId) + PS->>PR: 상품 조회 findById(productId) + PR-->>PS: Product + + PS->>P: 삭제 여부 확인 guardNotDeleted() + Note over P: 이미 삭제된 상태면 예외 + PS->>P: 삭제 delete() + Note over P: deletedAt 세팅 + + PS-->>C: 삭제 완료 + C-->>A: 204 No Content +``` + +#### 읽는 포인트 +- **Product 엔티티**: `guardNotDeleted()` — 삭제 가능 상태인지 스스로 검증한다. `delete()` — deletedAt 세팅도 스스로 수행한다. +- 단독 soft-delete. 좋아요는 건드리지 않으며, 목록 조회 시 자연스럽게 제외된다. + +--- + +## 6. 관리자 — 주문 확인 + +### 6-1. 주문 목록 보기 + +```mermaid +sequenceDiagram + actor A as 관리자 + participant C as AdminOrderController + participant OS as OrderService + participant OR as OrderRepository + + A->>C: GET /api/v1/admin/orders?page&size + C->>OS: 전체 주문 목록 조회 getAllOrders(page, size) + OS->>OR: 전체 주문 페이징 조회 findAll(page, size) + OR-->>OS: Page + OS-->>C: Page + C-->>A: 200 OK +``` + +--- + +### 6-2. 주문 상세 보기 + +```mermaid +sequenceDiagram + actor A as 관리자 + participant C as AdminOrderController + participant OS as OrderService + participant OR as OrderRepository + + A->>C: GET /api/v1/admin/orders/{orderId} + C->>OS: 주문 상세 조회 getOrderDetail(orderId) + OS->>OR: 주문 + 스냅샷 조회 findWithSnapshotsById(orderId) + OR-->>OS: Order + List + OS-->>C: OrderDetailInfo + C-->>A: 200 OK +``` + +#### 읽는 포인트 +- 회원 주문 상세(3-3)와 달리 `Order.isOwnedBy()` 호출이 없다. 관리자는 모든 주문을 조회할 수 있다. From ff727623c3de8605e1b2585dc18e34a0917af138 Mon Sep 17 00:00:00 2001 From: APapeIsName Date: Thu, 12 Feb 2026 02:30:30 +0900 Subject: [PATCH 20/22] =?UTF-8?q?docs:=2003.=20=ED=81=B4=EB=9E=98=EC=8A=A4?= =?UTF-8?q?=20=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/analysis/class-diagram-analysis.md | 312 ++++++++++++++++++++++ docs/design/03-class-diagram.md | 340 ++++++++++++++++++++++++ 2 files changed, 652 insertions(+) create mode 100644 docs/analysis/class-diagram-analysis.md create mode 100644 docs/design/03-class-diagram.md diff --git a/docs/analysis/class-diagram-analysis.md b/docs/analysis/class-diagram-analysis.md new file mode 100644 index 00000000..e4b0af55 --- /dev/null +++ b/docs/analysis/class-diagram-analysis.md @@ -0,0 +1,312 @@ +# 클래스 다이어그램 프로세스 분석 + +> 03-class-diagram.md 작성 과정에서 드러난 설계 철학, 의사결정 방식, 보완이 필요한 영역을 분석한다. +> 향후 클래스 다이어그램 작성 시 재사용할 수 있는 규칙(Rule)을 도출하기 위한 문서이다. + +--- + +## 1. 클래스 다이어그램 정리 단계 (실제 진행 순서) + +### 1단계: 원칙 선언 → 계획 보정 + +- AI가 기능 정의서 + 시퀀스 다이어그램 + base 문서를 기반으로 계획 수립 +- **첫 계획에 ERD를 포함** → 개발자가 즉시 거부하고 4가지 원칙을 선언 + +``` +예시 채팅: +개발자: "ERD 는 따로 할 거야. 클래스 다이어그램만 진행해줘. + 1. 엔티티/VO 분리 기준: ID 존재 여부, 생명 주기 + 2. 연관 관계: 단방향 기본, 양방향 최소화 + 3. 비즈니스 책임은 도메인 객체에 포함시키기 + 4. 설계 후, '한 객체에 책임이 몰리지 않았는가?' 점검 + 이 4가지가 중요해." +``` + +> **패턴**: 산출물 작성 전에 **원칙을 먼저 선언**한다. AI에게 자유도를 주지 않고, 판단 기준을 명시적으로 제공한다. 시퀀스 다이어그램 때의 "목적 정의"와 동일한 패턴이지만, 클래스 다이어그램에서는 더 구체적인 원칙(4가지)으로 발전했다. + +--- + +### 2단계: 확장 방향 제시 → 현재와 미래의 경계 설정 + +- 두 번째 계획도 거부 — 확장성 고려가 빠져 있었음 +- 개발자가 **메인 목표**와 **확장 방향**을 명시 + +``` +예시 채팅: +개발자: "감성 이커머스 — 좋아요 누르고, 쿠폰 쓰고, 주문 및 결제하는 커머스 플랫폼. + 내가 좋아하는 브랜드의 상품들을 한 번에 담아 주문하고, + 유저 행동은 랭킹과 추천으로 연결된다. 요게 메인 목표야." + +개발자: "그리고 나중에 갔을 때 좋아요라는 개념이 바운디드 컨텍스트로 만들어질 거 같아. + 브랜드와 상품에 좋아요가 추가될 거고, 선호 라는 개념으로 확장되면서 + 이는 추천과 랭킹으로 이어질 것 같은 느낌이 있거든." +``` + +> **패턴**: "현재 구현 범위"와 "장기 목표"를 모두 제시한 뒤, "현재 구조가 장기 목표를 막지 않는가?"를 검증 기준으로 삼는다. YAGNI(현재 불필요한 건 안 만든다)와 확장성(막히지는 않게 한다)의 균형을 잡는다. + +--- + +### 3단계: 안티패턴 대조 → 구조 검증 + +- 문서 초안이 완성된 후, 개발자가 3가지 안티패턴을 제시하고 현재 설계와 대조 + +``` +예시 채팅: +개발자: "- 모든 필드를 객체로 표현하려다 지나친 복잡도 + - 도메인 책임 없이 Service에 모든 로직 집중 + - VO를 테이블처럼 다루려는 시도 (ex. Price를 별도 DB로 설계) + 이건 안 좋은 방법이야. 그리고 클래스 구조가 도메인 설계를 잘 표현하고 있는가? + 이를 만족해야 해. 어때보여? 지금 구조" +``` + +> **패턴**: 요구사항·시퀀스 때와 동일하게, **구체적 안티패턴 → 대조 검증** 방식을 사용한다. "잘 됐어?"가 아닌 "이 기준에 맞아?"로 묻는다. 3번 연속 동일 패턴. + +--- + +### 4단계: 다이어그램의 본질 질문 → 범위 재정의 + +- AI의 안티패턴 분석에서 오류 발견 후, 개발자가 다이어그램의 범위를 재정의 + +``` +예시 채팅: +AI: "다이어그램이 도메인 모델과 애플리케이션 계층을 섞고 있습니다..." + +개발자: "1번이 맞는 말 같기는 한데, 다이어그램 내부에 Service 같은 게 들어가 있나?" + +(AI가 파일 재확인 후, Service는 없고 Payment/Coupon 확장 지점만 있음을 인정) + +개발자: "2번은 당연히 빼야지 이번에는. 그냥 확장 가능성만 따로 적어둬." + +개발자: "서비스나 이런 도메인 특히 클래스들과 관련없다면 다이어그램에는 존재하지 않도록. + 다만 책임 분산에는 넣어둬." +``` + +> **패턴**: "이 다이어그램에 뭐가 있어야 하고, 뭐가 없어야 하는가?"를 명확히 한다. 다이어그램 = 도메인 클래스만. Service/Facade/확장 미구현 = 본문 텍스트에서 다룬다. + +--- + +### 5단계: 행위 부재 진단 → VO 도출 + +- 시퀀스 ↔ 클래스 교차 점검에서 "도메인 객체 간 행위 관계가 없다"는 사실을 확인 +- 개발자가 핵심 문제를 지적하고, VO를 통한 책임 위임을 제안 + +``` +예시 채팅: +개발자: "확실히 이러니까 좀 보이네. 대부분의 로직들이 서비스에 있어. + 클래스들은 아무런 행위를 하고 있지 않아. + 객체 지향적으로 설계해보자. 일단 VO 를 정하자. + VO들은 그 자체로 룰이 생길 수 있는 경우, 정책이 있다거나 하는 경우야. + 현재 있는 엔티티들 중의 속성들 중 어떤 것이 VO가 될 수 있을까?" +``` + +> **패턴**: 문제 진단("서비스에 로직이 몰려 있다") → 해결 방향("VO로 책임 위임") → 구체적 기준("자체 규칙이 있는가?") 순서로 사고한다. 추상적 원칙을 먼저 세우고, 그 원칙으로 후보를 선별한다. + +--- + +### 6단계: VO 선별 → 위임 패턴 적용 + +- AI가 후보를 분석하고, 개발자가 Stock, Price, Quantity를 선택 +- 위임 패턴 적용 + 기술 인프라 클래스 제거 + +``` +예시 채팅: +개발자: "Stock, Price, Quantity를 VO 로 두자. + 그리고 decreaseStock 같은 메소드의 책임을 위임해. + 이와 같은 방식으로 다시 그려주고, + BaseTimeEntity 와 BaseEntity 를 여기서는 일단 빼줘. + 굳이 당장 필요없는 것 같아 비즈니스 설계를 보는 데에 있어서. + 클래스 다이어그램이 모든 엔티티들 사이 관계만 보는 다이어그램은 아니지 않나?" +``` + +> **패턴**: 클래스 다이어그램의 역할을 재정의한다. "엔티티 간 관계도"가 아닌 "비즈니스 규칙이 어떤 객체에 어떻게 분배되어 있는가를 보여주는 다이어그램"이다. 기술 인프라(BaseEntity)는 비즈니스 설계를 보는 데 방해가 되므로 제거한다. + +--- + +### 7단계: 시퀀스 다이어그램 동기화 + +- 클래스 다이어그램 변경에 따라, 시퀀스 다이어그램에서 Service가 하던 검증이 엔티티로 이동한 부분을 반영 + +``` +예시 채팅: +개발자: "그리고 여기서 바뀐 내용에서 서비스에서 객체로 책임이 위임된 경우가 있을 건데, + 해당 부분도 시퀀스 다이어그램에 적용시켜줘. + 대신 시퀀스 다이어그램에서는 VO 까지는 안 적어도 돼." +``` + +> **패턴**: 산출물 간 일관성을 유지한다. 클래스 다이어그램에서 책임이 이동하면, 시퀀스 다이어그램도 반드시 동기화한다. 단, 시퀀스는 VO 수준까지 내려가지 않는다(표현 수준의 경계). + +--- + +### 8단계: 교차 검증 → 빈틈 발견 → 즉시 해결 + +- 최종적으로 시퀀스 ↔ 클래스 양방향 전수 검증 +- 2건의 불일치 발견 후 즉시 해결 방안 결정 + +``` +예시 채팅: +개발자: "둘 다 검증할 거야. + 둘 다 객체지향적 설계를 잘 따르나? + 엔티티와 VO 로 나누어 책임을 위임받고 있나? + 객체는 자신의 행위를 수행하고 있나? + 서비스에서 이 역할을 빼앗아가고 있진 않나? + 그리고 이전에 제시했던 조건들도 붙여서 최종 검증해줘." + +(AI가 2건 발견: Stock 사전 확인 메서드 부재, Brand.delete() Note 불일치) + +개발자: "발견 1은 a 선택지를 고르고, 이를 Quantity 가 가져갈 수 있는지도 체크해줘. + 발견 2는 시퀀스 다이어그램에 내용 추가해줘." +``` + +> **패턴**: 검증은 단방향이 아닌 **양방향 교차 검증**이다. 클래스 → 시퀀스, 시퀀스 → 클래스 양쪽을 모두 확인한다. 발견된 문제는 미루지 않고 즉시 해결하며, VO 책임 배분(Stock vs Quantity)까지 검증한다. + +--- + +## 2. 의사결정 시 주목하는 포인트 + +### 2-1. 원칙 선행, 산출물 후행 +- 시퀀스: "목적을 먼저 정의" +- 클래스: "4가지 원칙을 먼저 선언" +- **패턴 진화**: 산출물이 복잡해질수록, 원칙이 더 구체적이고 명시적으로 발전한다 + +### 2-2. "이 다이어그램은 무엇을 보여주는가?" +- 클래스 다이어그램 ≠ 엔티티 관계도 +- 클래스 다이어그램 = 비즈니스 규칙의 분배도 +- 이 정의에 맞지 않는 것(BaseEntity, Payment 미구현, Service)은 다이어그램에서 제외 +- **판단 기준**: "이 요소가 없으면 비즈니스 규칙 분배를 이해할 수 없는가?" + +### 2-3. VO 선별 기준: "자체 규칙이 있는가?" +- 규칙이 있으면 VO (Stock: >= 0 + decrease, Price: > 0, Quantity: > 0) +- 규칙이 없으면 원시 타입 (name, description, brandId) +- 과도한 래핑도, 규칙 없는 원시 타입도 거부하는 **중간 지점**을 선택 + +### 2-4. 위임의 방향 검증 +- "이 책임을 누가 가져가는가?"를 VO 수준까지 추적 +- Stock.isEnough vs Quantity.canBeSatisfiedBy → 같은 불변식은 같은 객체에, 의존 방향 유지 +- **판단 기준**: (1) 같은 규칙은 한 곳에 (2) 의존 방향 역전 금지 + +### 2-5. 현재와 미래의 균형 +- YAGNI: 현재 불필요한 건 구현하지 않는다 (Payment, Coupon, 선호 BC) +- 확장성: 현재 구조가 미래를 막지 않아야 한다 (ID 참조, enum 확장, VO 추가 가능) +- **경계선**: "구현하지 않되, 막히지도 않게" + +--- + +## 3. AI가 질문으로 채워준 영역 (개발자가 놓친 부분) + +### 3-1. Brand UNIQUE + soft-delete 충돌 +**문제**: Brand name에 UNIQUE 제약이 있는데, soft-delete하면 이름이 점유된 채로 남음 + +``` +AI: "삭제된 브랜드 이름을 새 브랜드가 사용할 수 있어야 하나요?" + → 3가지 선택지 제시: + (a) 삭제 시 이름 변경 (name_deleted_timestamp) + (b) UNIQUE 제거 + (c) 앱 레벨 검증만 + +개발자: "삭제 시 이름 변경" +``` + +> **교훈**: 기능(UNIQUE) + 정책(soft-delete)의 조합이 충돌을 만든다. 기능과 정책을 동시에 정의할 때, 조합의 부작용을 반드시 점검해야 한다. + +### 3-2. VO 후보 분석 +**문제**: "VO를 정하자"까지는 결정했지만, 어떤 속성이 VO가 될 수 있는지 구체적으로 식별하지 않았음 + +``` +AI: [전체 속성 분석 테이블 제시] + "stock — 강력 후보: >= 0 불변식 + decrease 행위 + price — 후보: > 0 불변식, 행위는 현재 없음 + quantity — 후보: > 0 불변식 + name, description, ID — 아님: 규칙 없음" + +개발자: "Stock, Price, Quantity를 VO로 두자." +``` + +> **교훈**: "VO를 정한다"는 방향 결정 후, 전체 속성을 "규칙 있는가?"라는 기준으로 선별하는 과정이 필요하다. AI가 후보를 제시하고 개발자가 선택하는 패턴. + +### 3-3. 재고 사전 확인 메서드 부재 +**문제**: 시퀀스 다이어그램에 `alt 모든 상품 재고 충분`이 있는데, 이 판단을 수행하는 메서드가 클래스 다이어그램에 없었음 + +``` +AI: "시퀀스 3-1에서 '모든 상품 재고 충분' 조건의 판단 주체가 불명확합니다. + Stock에 decrease(Quantity)만 있고, isEnough(Quantity) 같은 확인 메서드가 없습니다." + +개발자: "발견 1은 a 선택지를 고르고, Quantity가 가져갈 수 있는지도 체크해줘." +``` + +> **교훈**: 시퀀스 다이어그램의 `alt` 조건은 반드시 클래스 다이어그램의 메서드와 대응해야 한다. "누가 이 판단을 하는가?"가 양쪽에서 일치해야 한다. + +### 3-4. Brand.delete() 설명 불일치 +**문제**: 클래스 다이어그램에서는 delete()가 "name 변경 + deletedAt 세팅"인데, 시퀀스 다이어그램에서는 "deletedAt 세팅"만 기술 + +``` +AI: "시퀀스 4-5에서 Brand.delete()의 Note에 이름 변경이 누락되어 있습니다." +``` + +> **교훈**: 산출물 간 동일한 메서드의 설명이 일치해야 한다. 한 곳을 수정하면 다른 곳도 동기화한다. + +--- + +## 4. 발견된 반복 실수 패턴 + +### 4-1. AI가 다이어그램 내용을 오독 +- AI가 "Service가 다이어그램에 있다"고 주장 → 개발자가 "진짜 있나?" → 확인 결과 없었음 +- **규칙**: AI의 분석을 맹신하지 않는다. "정말 그런가?" 한 번 더 확인한다. AI가 파일을 재확인한 후 답변해야 한다. + +### 4-2. 기술 인프라가 비즈니스 설계를 가림 +- BaseEntity, BaseTimeEntity가 다이어그램에 포함되어 비즈니스 구조가 보이지 않음 +- Payment, Coupon `<<미구현>>`이 다이어그램에 포함되어 현재 범위와 혼동 +- **규칙**: 클래스 다이어그램에는 현재 구현 범위의 도메인 클래스만 포함한다. 기술 인프라와 미구현 확장 지점은 텍스트 섹션으로 분리한다. + +### 4-3. 첫 계획에 범위 초과 항목 포함 +- 첫 계획에 ERD를 포함 → "따로 할 거야"로 즉시 거부 +- 요구사항 때 "과잉 추가", 시퀀스 때 "임의 축소"와 근본 원인 동일: **AI의 임의 판단** +- **규칙**: 산출물의 범위는 개발자가 지정한다. AI는 지정된 범위만 작업한다. + +### 4-4. VO 없는 빈약한 도메인 모델 +- 초기 클래스 다이어그램에서 엔티티의 속성이 전부 원시 타입이고, 행위가 Service에 집중 +- 시퀀스 ↔ 클래스 교차 검증으로 비로소 발견 +- **규칙**: 클래스 다이어그램 작성 후, "이 엔티티에서 자체 규칙이 있는 속성은 없는가?"를 반드시 점검한다. + +--- + +## 5. 클래스 다이어그램 작성 시 확인해야 할 체크리스트 + +### A. 작성 전 확인 +- [ ] 원칙을 명시적으로 선언했는가? (엔티티/VO 분리, 연관 방향, 책임 배치, 집중 점검) +- [ ] 현재 구현 범위와 장기 목표를 모두 파악했는가? +- [ ] 시퀀스 다이어그램에서 도출된 엔티티 행위 목록을 확보했는가? + +### B. 다이어그램 범위 +- [ ] 현재 구현 범위의 도메인 클래스만 포함했는가? +- [ ] 기술 인프라 클래스(BaseEntity 등)를 다이어그램에서 분리했는가? +- [ ] 미구현 확장 지점을 다이어그램이 아닌 텍스트 섹션으로 관리하는가? +- [ ] Service, Facade, Repository는 다이어그램에 포함하지 않았는가? (책임 분산 섹션에서 다룸) + +### C. 엔티티/VO 분리 +- [ ] 모든 엔티티가 고유 ID + 독립 생명주기를 가지는가? +- [ ] 모든 VO가 자체 규칙(불변식)을 캡슐화하는가? +- [ ] "자체 규칙이 있는 속성"이 원시 타입으로 남아 있지 않은가? (VO 후보 점검) +- [ ] 규칙이 없는 속성이 VO로 과도하게 래핑되지 않았는가? + +### D. 연관 관계 +- [ ] 모든 연관이 단방향인가? +- [ ] BC 간 참조가 ID(Long)만 사용하는가? +- [ ] VO 간 의존 방향에 순환이 없는가? + +### E. 책임 분배 +- [ ] 비즈니스 규칙이 도메인 객체(엔티티/VO)에 있는가? +- [ ] 엔티티가 VO에 적절히 위임하는가? (위임 표 작성) +- [ ] Service가 도메인 로직을 수행하지 않는가? (조율만 수행) +- [ ] 한 객체에 책임이 몰려 있지 않은가? + +### F. 교차 검증 (시퀀스 ↔ 클래스) +- [ ] 클래스 다이어그램의 모든 메서드가 시퀀스에서 사용되는가? +- [ ] 시퀀스에서 엔티티가 하는 일이 클래스에 정의되어 있는가? +- [ ] 시퀀스의 alt 조건이 클래스의 메서드와 대응하는가? +- [ ] 두 문서 간 동일 메서드의 설명이 일치하는가? + +### G. 안티패턴 점검 +- [ ] 모든 필드를 객체로 표현하려다 지나친 복잡도를 만들지 않았는가? +- [ ] 도메인 책임 없이 Service에 모든 로직이 집중되지 않았는가? +- [ ] VO를 테이블처럼 다루려 하지 않았는가? +- [ ] 클래스 구조가 도메인 설계를 잘 표현하고 있는가? diff --git a/docs/design/03-class-diagram.md b/docs/design/03-class-diagram.md new file mode 100644 index 00000000..1a1a5c40 --- /dev/null +++ b/docs/design/03-class-diagram.md @@ -0,0 +1,340 @@ +# 클래스 다이어그램 + +> 작성일: 2026-02-12 +> 기능 정의서(01-requirements.md) + 시퀀스 다이어그램(02-sequence-diagrams.md) 기반 +> 4가지 원칙: (1) 엔티티/VO 분리 — ID·생명주기·자체 규칙 (2) 단방향 연관 기본 (3) 비즈니스 책임은 도메인 객체에 (위임 패턴) (4) 책임 집중 점검 + +--- + +## 1. 클래스 다이어그램 + +```mermaid +classDiagram + direction TB + + %% ── Value Objects ── + + class Stock { + <> + -int value + +decrease(Quantity) Stock + +isEnough(Quantity) boolean + } + + class Price { + <> + -int value + } + + class Quantity { + <> + -int value + } + + note for Stock "불변 객체\nisEnough → 충분 여부 boolean\ndecrease → 새 Stock 반환 (부족 시 예외)" + note for Price "불변 객체\n생성 시 value > 0 검증" + note for Quantity "불변 객체\n생성 시 value > 0 검증" + + %% ── 브랜드 ── + + class Brand { + -String name + -String description + +update(name, description) void + +guardNotDeleted() void + +delete() void + } + + note for Brand "delete():\nname 변경 + soft-delete\n(UNIQUE 제약 해소)" + + %% ── 상품 ── + + class Product { + -String name + -String description + -Price price + -Stock stock + -Long brandId + +update(name, description, Price, Stock) void + +hasEnoughStock(Quantity) boolean + +decreaseStock(Quantity) void + +guardNotDeleted() void + } + + class ProductLike { + -Long memberId + -Long productId + } + + %% ── 주문 ── + + class Order { + -Long memberId + -OrderStatus status + -ZonedDateTime orderedAt + -List~OrderLineSnapshot~ lines + +isOwnedBy(memberId) boolean + } + + class OrderLineSnapshot { + <> + -Long productId + -String productName + -String productDescription + -Price price + -Quantity quantity + -String brandName + } + + class OrderStatus { + <> + ACCEPTED + REJECTED + } + + %% ── VO 포함 (Composition) ── + Product *-- Stock : stock + Product *-- Price : price + OrderLineSnapshot *-- Price : 주문 시점 가격 + OrderLineSnapshot *-- Quantity : quantity + + %% ── VO 간 행위 의존 ── + Stock ..> Quantity : isEnough / decrease + + %% ── 연관 (단방향, ID 참조) ── + Product ..> Brand : brandId (Long) + ProductLike ..> Product : productId (Long) + ProductLike ..> Member : memberId (Long) + Order ..> Member : memberId (Long) + Order *-- OrderLineSnapshot : 1..N + Order --> OrderStatus : status +``` + +--- + +## 2. 읽는 포인트 + +### 원칙 1: 엔티티/VO 분리 — ID 존재 여부, 생명주기, 자체 규칙 + +**엔티티 (Entity)**: 고유한 식별자(ID)를 가지며, 독립적인 생명주기를 가진다. + +- **Brand**: 고유 ID. 생성 → 수정 → 삭제의 독립 생명주기. +- **Product**: 고유 ID. 생성 → 수정 → 삭제의 독립 생명주기. 브랜드 삭제 시 연쇄 삭제되지만, 이는 비즈니스 규칙이지 생명주기 종속이 아니다. +- **ProductLike**: `memberId + productId`로 고유 식별. 등록 → 삭제의 독립 생명주기. +- **Order**: 고유 ID. 생성 시 즉시 최종 상태(ACCEPTED/REJECTED)로 결정. + +**Value Object (VO)**: 고유 식별자가 불필요하며, 자체 규칙(불변식)을 캡슐화하는 불변 객체다. + +- **Stock**: 재고의 본질적 규칙("음수가 될 수 없다")을 스스로 지킨다. `decrease(Quantity)` 시 부족하면 예외, 충분하면 새 Stock을 반환한다. +- **Price**: 가격의 규칙("0보다 커야 한다")을 생성 시 검증한다. 불변. +- **Quantity**: 수량의 규칙("0보다 커야 한다")을 생성 시 검증한다. Stock.decrease의 인자로 사용된다. +- **OrderLineSnapshot**: Order 없이 존재할 수 없다. 주문 시점의 Price와 Quantity를 포함하며, 한번 생성되면 불변이다. + +### 원칙 2: 단방향 연관, 양방향 최소화 + +모든 연관이 **단방향**이다. 양방향 참조는 하나도 없다. + +- `Product → Brand`: Product가 `brandId(Long)`로 브랜드를 참조한다. Brand는 자기에게 소속된 Product를 모른다. +- `ProductLike → Product`: ProductLike가 `productId(Long)`로 상품을 참조한다. Product는 자기에게 달린 좋아요를 모른다. +- `Order → Member`: Order가 `memberId(Long)`로 회원을 참조한다. Member는 자기의 주문을 모른다. + +**BC 간 참조는 ID(Long)만 사용한다.** 객체 참조가 아닌 ID 참조이므로 BC 간 직접 의존이 없다. + +### 원칙 3: 비즈니스 책임은 도메인 객체에 — 위임 패턴 + +엔티티는 VO에게 규칙 검증을 **위임**한다. Service가 아닌 도메인 객체가 비즈니스 규칙을 수행한다. + +**VO가 지키는 규칙 (자기 자신의 불변식)** + +| VO | 규칙 | 검증 시점 | +|----|------|----------| +| Stock | value >= 0 | 생성 시 | +| Stock | value >= quantity.value | isEnough(), decrease() 시 | +| Price | value > 0 | 생성 시 | +| Quantity | value > 0 | 생성 시 | + +**엔티티가 VO에 위임하는 행위** + +| 엔티티 | 메서드 | 위임 대상 | 위임 내용 | +|--------|--------|----------|----------| +| Product | `hasEnoughStock(Quantity)` | Stock.isEnough(Quantity) | 재고 충분 여부 판단을 Stock에 위임 | +| Product | `decreaseStock(Quantity)` | Stock.decrease(Quantity) | 재고 차감 규칙은 Stock이 수행. Product는 결과를 받아 자기 상태를 교체 | +| Product | `update(name, desc, Price, Stock)` | Price, Stock 생성자 | 가격·재고 검증은 VO 생성 시 이미 완료. Product는 교체만 수행 | + +**엔티티가 직접 수행하는 행위 (위임 불필요)** + +| 엔티티 | 메서드 | 왜 이 객체의 책임인가 | +|--------|--------|---------------------| +| Brand | `update(name, desc)` | 자기 데이터를 자기가 변경한다 | +| Brand | `guardNotDeleted()` | 자기 상태(삭제 여부)를 자기가 검증한다 | +| Brand | `delete()` | 삭제 + 이름 변경을 한 번에 수행한다 (UNIQUE 해소) | +| Product | `guardNotDeleted()` | 삭제 여부를 자기가 검증한다 | +| Order | `isOwnedBy(memberId)` | 본인 주문 확인을 자기가 판단한다 | + +**ProductLike에 메서드가 없는 이유**: 좋아요는 "회원과 상품 사이의 관계 기록"이라는 본질에 충실한 단순 엔티티다. 등록은 `new ProductLike(memberId, productId)`, 삭제는 물리 삭제(hard-delete). + +### 원칙 4: 한 객체에 책임이 몰리지 않았는가? + +> 섹션 4(책임 분산 점검)에서 상세 분석. + +--- + +## 3. 엔티티 vs VO 분류표 + +| 클래스 | 분류 | 기준: ID | 기준: 생명주기 | 기준: 자체 규칙 | 삭제 방식 | +|--------|------|---------|--------------|---------------|----------| +| Brand | Entity | 고유 ID | 독립 (생성→수정→삭제) | - | soft-delete | +| Product | Entity | 고유 ID | 독립, 브랜드 연쇄 삭제 가능 | - | soft-delete | +| ProductLike | Entity | member+product 식별 | 독립 (등록→삭제) | - | hard-delete | +| Order | Entity | 고유 ID | 독립 (생성→최종 상태) | - | 삭제 없음 | +| Stock | **VO** | 불필요 | Product에 종속 | value >= 0, decrease 시 비음수 검증 | Product와 동일 | +| Price | **VO** | 불필요 | Product 또는 Snapshot에 종속 | value > 0 | 소유자와 동일 | +| Quantity | **VO** | 불필요 | Snapshot에 종속 | value > 0 | 소유자와 동일 | +| OrderLineSnapshot | **VO** | 불필요 | Order에 종속, 불변 | Price·Quantity에 위임 | Order와 동일 | +| OrderStatus | enum | - | - | - | - | + +### VO 선별 기준: "자체 규칙이 있는가?" + +``` +자체 규칙 있음 → VO +├── Stock: 음수 불가 + 차감 행위 +├── Price: 양수만 가능 +├── Quantity: 양수만 가능 +└── OrderLineSnapshot: 주문에 종속 + 불변 + Price·Quantity 포함 + +자체 규칙 없음 → 원시 타입 유지 +├── name (String): 단순 필수값 +├── description (String): 선택값 +└── brandId, memberId, productId (Long): 식별 참조 +``` + +--- + +## 4. 책임 분산 점검 + +### 도메인 객체별 (엔티티 + VO) + +| 객체 | 책임 수 | 책임 목록 | 판단 | +|------|--------|----------|------| +| Brand | 3 | update, guardNotDeleted, delete | **적절**. 자기 데이터에 대한 변경/검증/삭제 | +| Product | 4 | update, hasEnoughStock(위임), decreaseStock(위임), guardNotDeleted | **적절**. 재고 규칙을 Stock에 위임하여 책임 감소 | +| Stock | 2 | isEnough(Quantity), decrease(Quantity) | **적절**. 재고의 핵심 규칙만 보유. 같은 불변식(value >= quantity)의 조회/변경 | +| Price | 0+1 | 생성자 검증 | **적절**. 가격 규칙만 보유 | +| Quantity | 0+1 | 생성자 검증 | **적절**. 수량 규칙만 보유 | +| Order | 1 | isOwnedBy | **적절**. 현재 최소 | +| ProductLike | 0 | - | **적절**. 단순 관계 레코드 | +| OrderLineSnapshot | 0 | - | **적절**. 불변 VO. Price, Quantity를 포함하여 스냅샷 | + +### Service별 + +| Service | 주요 책임 | 판단 | +|---------|----------|------| +| BrandService | 브랜드 CRUD, 이름 중복 검증 | **적절** | +| ProductService | 상품 CRUD, 브랜드별 연쇄 삭제, 주문용 락 조회 | **적절** | +| LikeService | 좋아요 등록/취소, 목록 조회 + 상품 유효성 확인 위임 | **적절** | +| OrderService | 주문 생성 조율 (중복 검증, 정렬, 상품 확보, 스냅샷 생성, 수락/거절 판단) | **모니터링 필요**. 확장 시 분리 고려 | + +### Facade별 + +| Facade | 존재 이유 | 판단 | +|--------|----------|------| +| AdminBrandFacade | BrandService ↔ ProductService 순환 해소 (브랜드 삭제 연쇄) | **적절** | +| AdminProductFacade | BrandService ↔ ProductService 순환 해소 (상품 등록 브랜드 검증) | **적절** | + +--- + +## 5. 확장 지점 + +> 메인 목표: **좋아요 누르고, 쿠폰 쓰고, 주문 및 결제하는 커머스 플랫폼. 유저 행동은 랭킹과 추천으로 연결.** +> 현재는 구현하지 않지만, 현재 클래스 구조가 확장을 막지 않아야 한다. + +### 5-1. 결제 BC (Payment Context) + +주문에 결제를 연동한다. + +``` +현재: 주문 요청 → 재고 확인 → ACCEPTED / REJECTED +확장: 주문 요청 → 재고 확인 → ACCEPTED → PAYMENT_PENDING → PAID +``` + +- **현재 구조의 대응**: OrderStatus는 enum이므로 값 추가만으로 확장 가능. Payment BC는 `orderId(Long)`로 주문을 참조한다 (ID 참조 패턴 유지). +- **막히지 않는 이유**: Order가 Payment를 모른다. Payment가 Order를 ID로 참조하는 단방향. 새 BC를 추가해도 기존 코드를 수정할 필요 없다. + +### 5-2. 쿠폰 BC (Coupon Context) + +주문 시 쿠폰을 적용한다. + +- **현재 구조의 대응**: Order에 `couponId(Long)` 필드를 추가하고, Coupon BC는 별도로 분리한다. Order는 쿠폰의 존재만 알고, 할인 계산은 Coupon BC에 위임한다. +- **막히지 않는 이유**: ID 참조 패턴. BC 간 직접 의존 없음. + +### 5-3. 좋아요 → 선호 BC (Preference Context) + +좋아요 대상이 상품에서 브랜드로 확장되고, "선호"라는 상위 개념으로 통합되어 랭킹/추천으로 연결된다. + +``` +현재: ProductLike (상품 좋아요만) +확장: Preference BC + ├── ProductLike (상품 좋아요) + ├── BrandLike (브랜드 좋아요) + └── → 랭킹/추천 시스템 연동 +``` + +- **현재 구조의 대응**: ProductLike가 독립 엔티티이며 상품 BC에 소속. 나중에 BrandLike를 추가하고, 이들을 "선호 BC"로 묶으면 된다. +- **막히지 않는 이유**: ProductLike는 `memberId + productId` ID 참조만 사용하므로, 동일 패턴으로 `BrandLike(memberId + brandId)`를 만들 수 있다. 랭킹/추천은 이 데이터를 이벤트 기반으로 소비하면 된다. + +### 5-4. 주문 취소 + +수락된 주문을 회원이 취소한다. + +- **현재 구조의 대응**: OrderStatus에 `CANCELLED` 추가, Order에 `cancel()` 메서드 추가, Stock에 `increase(Quantity)` 추가. +- **막히지 않는 이유**: enum 값 추가 + 도메인 객체 메서드 추가만으로 구현 가능. 기존 구조를 변경할 필요 없다. + +### 5-5. 서비스 분리 (MSA) + +모놀리스에서 마이크로서비스로 전환한다. + +- **현재 구조의 대응**: 모든 BC 간 참조가 `Long` ID. Aggregate Root 경계 명확. 브랜드 삭제 연쇄를 도메인 이벤트로 전환하면 된다 (Facade → Event Publisher). +- **막히지 않는 이유**: 객체 참조가 아닌 ID 참조이므로, BC를 별도 서비스로 분리해도 참조 방식 변경 불필요. + +--- + +## 6. 설계 결정 기록 + +| # | 결정 | 이유 | 대안 | +|---|------|------|------| +| 1 | BaseTimeEntity 신규 도입 | ProductLike(hard-delete)와 Order(never deleted)는 deletedAt 불필요. 상속으로 삭제 정책을 코드에 명시 | BaseEntity 그대로 상속 (불필요한 컬럼, 의도 불명확) | +| 2 | Brand/Product는 BaseEntity 상속 | soft-delete 필요. deletedAt 활용 | 별도 closedAt 관리 (폐점 개념 제거됨, 불필요) | +| 3 | Brand.delete(): 이름 변경 + soft-delete | DB UNIQUE 제약 유지하면서 삭제된 브랜드 이름 재사용 가능 | 앱 레벨 검증만 (UNIQUE 없음), UNIQUE 제거 (데이터 정합성 약화) | +| 4 | OrderStatus: ACCEPTED, REJECTED만 | 현재 요구사항에 중간 상태/취소 없음. enum이므로 확장 용이 | CANCELLED 포함 (현재 불필요, YAGNI) | +| 5 | 모든 BC 간 참조를 ID(Long)만 사용 | BC 간 직접 의존 제거. MSA 전환 시 변경 최소화 | 객체 참조 (편리하나 BC 경계 위반) | +| 6 | OrderLineSnapshot은 VO | Order 없이 존재 불가, 불변, 독립 식별 불필요 | Entity로 분류 (불필요한 생명주기 관리) | +| 7 | ProductLike에 메서드 없음 | 단순 관계 레코드. hard-delete이므로 엔티티 행위 불필요 | toggle() 등 추가 (과도한 추상화) | +| 8 | 양방향 연관 0개 | 단방향만으로 모든 요구사항 충족. 양방향은 순환 의존과 복잡성 유발 | Product ↔ Brand 양방향 (편의성 vs 복잡성 트레이드오프) | +| 9 | 좋아요를 상품 BC에 배치 (현재) | 현재는 상품 좋아요만 존재. 확장 시 선호 BC로 분리 | 처음부터 선호 BC 분리 (YAGNI, 과도한 설계) | +| 10 | Stock, Price, Quantity를 VO로 분리 | 자체 규칙(불변식)이 있는 속성만 VO로 캡슐화. "규칙 없으면 원시 타입" 기준 | 원시 타입 유지 (규칙이 엔티티나 Service에 흩어짐) | +| 11 | Product.decreaseStock → Stock.decrease 위임 | 재고 규칙은 재고의 책임. Product는 조율만 수행 | Product가 직접 검증 (책임 혼재) | +| 12 | BaseEntity/BaseTimeEntity를 다이어그램에서 제외 | 비즈니스 설계에 기술 인프라 클래스가 불필요. 코드 구현 시 적용 | 포함 (기술적 완전성은 높지만 비즈니스 가독성 저하) | +| 13 | Stock.isEnough(Quantity) + Product.hasEnoughStock(Quantity) 추가 | 주문 시 "확인 먼저, 차감 나중" 흐름에서 재고 확인 판단 주체를 명확화. Quantity가 아닌 Stock이 보유 (같은 불변식, 의존 방향 유지) | Quantity.canBeSatisfiedBy(Stock) (VO 간 순환 의존 발생) | + +--- + +## 7. 안티패턴 점검 + +### 점검 항목 + +| # | 안티패턴 | 현재 설계 | 판단 | +|---|---------|----------|------| +| 1 | 모든 필드를 객체로 표현하려다 지나친 복잡도 | Stock, Price, Quantity만 VO — 자체 규칙이 있는 것만 선별. name, description, ID 등 규칙 없는 필드는 원시 타입 유지 | **통과**. "규칙이 있는 것만 VO"라는 기준으로 과도한 래핑 방지 | +| 2 | 도메인 책임 없이 Service에 모든 로직 집중 | 재고 규칙(Stock.decrease), 가격 검증(Price 생성), 수량 검증(Quantity 생성)이 VO에 캡슐화. 엔티티는 VO에 위임 | **통과**. 비즈니스 규칙이 도메인 객체에 분배됨 | +| 3 | VO를 테이블처럼 다루려는 시도 | Stock, Price, Quantity는 별도 테이블 없이 엔티티 컬럼으로 매핑. OrderLineSnapshot도 Order 내부에 Composition | **통과**. VO는 코드 구조이지 DB 구조가 아님 | + +### 클래스 구조가 도메인 설계를 잘 표현하고 있는가? + +| 설계 요소 | 도메인 의미 표현 방식 | +|----------|---------------------| +| VO 포함 (Product ◆── Stock, Price) | "재고와 가격은 상품의 속성이면서, 자기만의 규칙을 가진다"를 구조로 표현 | +| VO 간 의존 (Stock ──▷ Quantity) | "재고를 차감하려면 수량이 필요하다"는 도메인 관계를 표현 | +| 위임 패턴 (decreaseStock → Stock.decrease) | "규칙은 규칙을 아는 객체가 수행한다"는 객체지향 원칙을 표현 | +| 연관 방향 (전부 단방향 ID 참조) | BC 경계가 다이어그램에서 바로 보임 | +| Composition (Order ◆── OrderLineSnapshot) | "스냅샷은 주문의 일부"라는 생명주기 종속을 시각적으로 표현 | +| 메서드 없는 엔티티 (ProductLike) | "관계 기록"이라는 본질에 충실 — 억지 행위 없음 | From 14f8b3b5c5a38f6d51b1e01623caf7396244251d Mon Sep 17 00:00:00 2001 From: APapeIsName Date: Thu, 12 Feb 2026 02:31:34 +0900 Subject: [PATCH 21/22] =?UTF-8?q?docs:=2004.=20ERD=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/analysis/erd-analysis.md | 267 ++++++++++++++++++++++++++++++++++ docs/design/04-erd.md | 229 +++++++++++++++++++++++++++++ 2 files changed, 496 insertions(+) create mode 100644 docs/analysis/erd-analysis.md create mode 100644 docs/design/04-erd.md diff --git a/docs/analysis/erd-analysis.md b/docs/analysis/erd-analysis.md new file mode 100644 index 00000000..97590d93 --- /dev/null +++ b/docs/analysis/erd-analysis.md @@ -0,0 +1,267 @@ +# ERD 프로세스 분석 + +> 04-erd.md 작성 과정에서 드러난 ERD 설계 방식, 의사결정 패턴, 보완이 필요한 영역을 분석한다. +> 향후 ERD 작성 시 재사용할 수 있는 규칙(Rule)을 도출하기 위한 문서이다. + +--- + +## 1. ERD 정리 단계 (실제 진행 순서) + +### 1단계: 외부 조언 수용 → 핵심 제약 선언 + +- ERD 계획을 수립하는 과정에서, 개발자가 **외부 조언**을 즉시 설계 제약으로 반영 +- 계획 수립 중에 추가 제약이 들어옴 + +``` +예시 채팅: +개발자: "그리고 FK 는 걸지마. 오늘 들었던 조언이었는데 FK는 빼는 게 낫대." +``` + +> **패턴**: 설계 제약은 프로젝트 내부에서만 나오지 않는다. **외부 조언이나 학습 결과를 즉시 설계에 반영**한다. "오늘 들었던"이라는 표현에서, 조언을 받은 즉시 적용하는 의사결정 속도가 드러난다. 이전 산출물에서 "결정을 미루지 않는다"는 패턴의 연장선이다. + +--- + +### 2단계: 참조 범위 한정 → base 배제 + +- 클래스 다이어그램까지는 base 문서를 "확장 방향 참고"로 사용했으나, ERD부터는 명시적으로 배제 + +``` +예시 채팅: +개발자: "기존 파일들 기반으로(design/01~03) ERD 다이어그램 만들자. + base는 이제 더 이상 필요없으니 참고하지 말고." +``` + +> **패턴**: 산출물이 구체화될수록 **입력 범위를 좁힌다**. 요구사항(넓은 범위) → 시퀀스(기능 단위) → 클래스(도메인 단위) → ERD(구현 단위). ERD는 "현재 확정된 설계의 DB 매핑"이므로, 장기 방향(base)은 입력에서 제외한다. + +--- + +### 3단계: 계획 수립 → 테이블 수준 상세화 + +- AI가 클래스 다이어그램 + 기존 코드(BaseEntity, Member)를 읽고, 테이블 수준의 상세 계획 작성 +- 개발자가 계획을 검토 후 승인 + +``` +예시 채팅: +AI: [6개 테이블 상세 설계: member, brand, product, product_like, orders, order_line_snapshot] + [VO → 컬럼 매핑 규칙, 상속 → 컬럼 매핑, 논리 참조 목록, 설계 결정 7개] + +개발자: (승인) +``` + +> **패턴**: ERD는 이전 산출물(클래스 다이어그램)에서 **기계적으로 도출 가능한 부분이 많다**. 따라서 계획 단계에서 이미 테이블 컬럼까지 상세화할 수 있다. 이전 산출물(요구사항, 시퀀스, 클래스)에서 여러 번의 보정이 필요했던 것과 대조적이다. 이는 앞선 설계가 충분히 확정되었다는 증거이기도 하다. + +--- + +### 4단계: 작성 → 즉시 자체 교차 검증 + +- 문서 작성 완료 후, AI가 자발적으로 클래스 다이어그램 ↔ ERD 교차 검증을 수행 + +``` +예시 채팅: +AI: [13개 항목 교차 검증 테이블] + "Brand (name, description) → brand(name VARCHAR, description TEXT) ✅" + "Stock VO → product.stock INT ✅" + "soft-delete (Brand, Product) → deleted_at 컬럼 존재 ✅" + ... + "모든 항목이 일치합니다." +``` + +> **패턴**: 이전 산출물에서 개발자가 반복적으로 "교차 검증해줘"를 요청했기 때문에, AI가 검증을 선행 수행한 케이스. **반복된 패턴이 습관화**된 사례다. + +--- + +### 5단계: 안티패턴 대조 검증 + +- 개발자가 ERD 특화 안티패턴 3가지를 제시하고, 현재 설계와 대조 요청 + +``` +예시 채팅: +개발자: "ERD 설계 시 데이터 정합성을 고려하여 구성하였는가? 이를 지켰는지, + - 비즈니스 흐름이 반영되지 않은 정규화만 추구 + - 중복 데이터 제거만 집중해 조회 JOIN이 과도해짐 + - 상태 컬럼 없음 → 코드에서 하드코딩으로 해결함 + 이를 무시하지 않았는지 판단해줘." +``` + +> **패턴**: 요구사항(안티패턴 3개) → 시퀀스(안티패턴 제시) → 클래스(안티패턴 3개) → ERD(안티패턴 3개). **4번째 연속 동일 패턴**이다. 산출물마다 해당 산출물 특화 안티패턴을 제시하고, 구체적 대조 검증을 요청한다. "잘 됐어?"가 아닌 "이 기준에 맞아?"로 묻는다. + +--- + +### 6단계: 전 문서 전수 대조 → 최종 검증 + +- 안티패턴 점검 후, 개발자가 01~03 + base 전체를 대상으로 최종 검증 요청 + +``` +예시 채팅: +개발자: "최종적으로 design/base 에 있는 내용과 01-requirements, 02-sequence-diagrams, + 03-class-diagram 를 확인해서 내가 생각하는 방향과 ERD가 제대로 나왔는지 확인해줘. + 대신 01-requirements, 02-sequence-diagrams, 03-class-diagram 3개가 현재 요구사항이니 + 이 부분을 기반으로 확인해야 해. 마지막이야. 검증해줘." +``` + +> **패턴**: "base는 참조하지 마"(3단계)와 "base와 비교해서 방향 확인해"(6단계)는 모순이 아니다. **작성 시에는 현재 범위만, 검증 시에는 장기 방향도 함께 확인**한다. 작성과 검증의 입력 범위가 다르다. +> +> 또한 "마지막이야. 검증해줘"라는 표현에서, **최종 검증은 별도의 단계로 의식적으로 수행**하는 패턴이 보인다. + +--- + +## 2. 의사결정 시 주목하는 포인트 + +### 2-1. 외부 입력의 즉시 반영 + +- "오늘 들었던 조언"을 설계 제약으로 즉시 적용 +- 결정 이유를 명시: "FK는 빼는 게 낫대" — 출처와 근거가 함께 전달 +- **패턴**: 외부 학습(강의, 리뷰, 조언)의 결과를 설계에 반영할 때, "무엇을"과 "왜"를 함께 기록한다 + +### 2-2. 산출물의 입력 범위 축소 + +- 요구사항: base + 원본 명세 + 도메인 정의서 (가장 넓음) +- 시퀀스: 기능 정의서 기반 +- 클래스: 기능 정의서 + 시퀀스 + base(확장 방향 참고) +- ERD: 01~03만 (base 배제) +- **패턴**: 산출물이 구현에 가까워질수록 입력 범위가 좁아진다. 추상(넓음) → 구체(좁음) + +### 2-3. 작성 범위 ≠ 검증 범위 + +- ERD **작성**: 01~03만 (현재 확정 범위) +- ERD **검증**: 01~03 + base (장기 방향 포함) +- **패턴**: 작성은 현재에 집중하고, 검증은 미래까지 포함한다. "만들 때는 YAGNI, 확인할 때는 방향성" + +### 2-4. 도메인 개념 → DB 개념 매핑의 원칙 + +- VO는 컬럼이다 (테이블이 아니다) +- 도메인 VO지만 JPA 매핑을 위해 id가 필요할 수 있다 (OrderLineSnapshot) +- 도메인 개념과 DB 구현의 차이를 명시적으로 기록한다 +- **패턴**: "도메인에서는 X이지만 DB에서는 Y" — 이 차이를 설계 결정으로 문서화한다 + +### 2-5. 안티패턴 기반 검증의 일관성 + +| 산출물 | 안티패턴 수 | 검증 방식 | +|--------|-----------|----------| +| 요구사항 | 3개 | 구체적 안티패턴 → 대조 | +| 시퀀스 | 안티패턴 제시 | 구체적 안티패턴 → 대조 | +| 클래스 | 3개 | 구체적 안티패턴 → 대조 | +| ERD | 3개 | 구체적 안티패턴 → 대조 | + +> 4개 산출물 모두 동일한 검증 패턴. "이게 좋은가?"가 아닌 **"이 나쁜 패턴에 해당하지 않는가?"**로 검증한다. + +--- + +## 3. AI가 채워준 영역 (개발자가 놓친 부분) + +### 3-1. SQL 예약어 충돌 + +**문제**: 클래스 다이어그램의 `Order` 엔티티를 그대로 테이블명으로 사용하면 SQL 예약어와 충돌 + +``` +AI: 테이블명을 `orders`로 설정 (ORDER는 SQL 예약어) + 설계 결정 #3으로 기록: "백틱 의존보다 명확한 이름 사용" +``` + +> **교훈**: 도메인 이름 → DB 테이블명 변환 시, SQL 예약어 목록을 반드시 확인해야 한다. + +### 3-2. VO의 JPA 매핑 갭 + +**문제**: OrderLineSnapshot은 도메인에서 VO(독립 식별 불필요)이지만, JPA 1:N 매핑에 PK가 필요 + +``` +AI: "도메인 VO이지만 JPA @OneToMany 매핑에 PK 필요" + → id 컬럼 포함 + 설계 결정 #4로 기록 + 대안: "@ElementCollection (컬렉션 전체 삭제/재삽입 성능 이슈)" +``` + +> **교훈**: 도메인 설계와 ORM 구현 사이에는 갭이 존재한다. "도메인에서는 X이지만 구현에서는 Y"를 명시적으로 기록해야 한다. + +### 3-3. 불변 VO의 timestamp 불필요 + +**문제**: OrderLineSnapshot에 timestamp(created_at, updated_at)를 포함할지 결정이 필요 + +``` +AI: "불변 VO. Order의 created_at이 생성 시점을 대변" + → timestamp 제외 + 설계 결정 #5로 기록 +``` + +> **교훈**: 모든 테이블에 기계적으로 timestamp를 넣는 것이 아니라, **테이블의 성격(불변/가변)에 따라 판단**해야 한다. + +### 3-4. UNIQUE 제약의 동시성 보장 + +**문제**: product_like의 중복 방지를 앱 레벨만으로 할지, DB 레벨도 필요한지 + +``` +AI: "중복 좋아요 방지를 DB 레벨에서 보장. + 앱 레벨 검증만으로는 동시성 이슈 가능" + → UNIQUE(member_id, product_id) + 설계 결정 #6으로 기록 +``` + +> **교훈**: 비즈니스 규칙 중 "중복 불가" 규칙은 앱 레벨 검증만으로는 경쟁 조건에 취약하다. DB UNIQUE 제약으로 최종 방어선을 만들어야 한다. + +--- + +## 4. 발견된 반복 실수 패턴 + +### 4-1. ERD에서 특별히 새로운 실수 없음 + +이번 ERD 작성에서는 이전 산출물들에서 발견된 실수 패턴(AI 과잉 추가, 임의 판단, 기술 용어 혼입 등)이 **재발하지 않았다**. 이유: + +1. **입력이 이미 정제됨**: 01~03 문서가 충분히 확정된 상태에서 ERD를 작성 +2. **기계적 매핑 비중이 높음**: 클래스 다이어그램 → ERD는 도메인 판단보다 매핑 규칙이 중심 +3. **누적된 규칙이 작동**: 이전 3개 산출물에서 도출된 규칙들(AI 과잉 추가 금지, 범위 한정 등)이 적용됨 + +### 4-2. 주의해야 할 잠재적 패턴 + +이번에는 발생하지 않았으나, ERD 작성 시 자주 발생할 수 있는 패턴: + +- **인덱스 전략 누락**: 현재 ERD에 인덱스가 명시되지 않았다. base 문서에는 8개의 인덱스가 정의되어 있었으나, 현재 범위에서는 제외. 구현 시 별도로 다뤄야 한다. +- **컬럼 길이/정밀도 미정의**: VARCHAR의 길이, DATETIME의 정밀도 등이 명시되지 않았다. 구현 시 결정해야 한다. +- **VO 매핑 시 AttributeConverter 누락**: Stock, Price, Quantity를 INT 컬럼으로 매핑할 때, JPA AttributeConverter 또는 @Embeddable 전략이 필요하나 ERD 수준에서는 다루지 않았다. + +> **규칙**: 이들은 "ERD 수준의 결정"이 아닌 "구현 수준의 결정"이다. ERD에서는 논리적 구조만 다루고, 물리적 세부사항(인덱스, 길이, 컨버터)은 구현 단계에서 결정한다. + +--- + +## 5. ERD 작성 시 확인해야 할 체크리스트 + +### A. 작성 전 확인 + +- [ ] 클래스 다이어그램이 확정되었는가? (ERD의 입력) +- [ ] 상속 전략(MappedSuperclass / Single Table 등)이 결정되었는가? +- [ ] 삭제 정책(soft-delete / hard-delete / 삭제 없음)이 엔티티별로 확정되었는가? +- [ ] FK 사용 여부가 결정되었는가? + +### B. 도메인 → DB 매핑 + +- [ ] 모든 엔티티가 테이블로 매핑되었는가? +- [ ] VO는 별도 테이블이 아닌 소유 엔티티의 **컬럼**으로 매핑되었는가? +- [ ] Composition 관계(1:N VO)가 별도 테이블 + 참조 컬럼으로 매핑되었는가? +- [ ] Composition 테이블에 JPA PK(id)가 포함되었는가? +- [ ] enum이 컬럼(VARCHAR 등)으로 매핑되었는가? +- [ ] SQL 예약어와 충돌하는 테이블/컬럼명이 없는가? + +### C. 삭제 정책 반영 + +- [ ] soft-delete 엔티티에 deleted_at 컬럼이 있는가? +- [ ] hard-delete 엔티티에 deleted_at이 **없는가**? +- [ ] 삭제 없는 엔티티에 deleted_at이 **없는가**? +- [ ] 불변 VO 테이블에 불필요한 timestamp가 없는가? + +### D. 참조 무결성 + +- [ ] FK 유무 결정이 명시되어 있는가? +- [ ] FK가 없다면, 앱 레벨 검증 방식이 문서화되었는가? +- [ ] UNIQUE 제약이 필요한 곳(중복 불가 비즈니스 규칙)에 설정되었는가? +- [ ] 동시성 이슈가 있는 규칙이 DB 레벨 제약으로 보호되는가? + +### E. 안티패턴 점검 + +- [ ] 정규화만 추구하여 비즈니스 흐름이 무시되지 않았는가? (스냅샷 비정규화 등) +- [ ] 중복 제거만 집중하여 조회 JOIN이 과도하지 않은가? +- [ ] 모든 비즈니스 상태가 DB 컬럼으로 표현되는가? (하드코딩 없음) +- [ ] VO를 테이블로 만들지 않았는가? + +### F. 교차 검증 + +- [ ] 클래스 다이어그램의 모든 엔티티/VO가 ERD에 매핑되었는가? +- [ ] 클래스 다이어그램의 모든 연관 관계가 ERD에 반영되었는가? +- [ ] 시퀀스 다이어그램의 Repository 호출 패턴이 ERD 구조로 지원되는가? +- [ ] 요구사항의 모든 기능이 ERD 구조로 구현 가능한가? +- [ ] base 방향성을 ERD가 차단하지 않는가? (작성 범위 밖이지만 검증 시 확인) diff --git a/docs/design/04-erd.md b/docs/design/04-erd.md new file mode 100644 index 00000000..408c8609 --- /dev/null +++ b/docs/design/04-erd.md @@ -0,0 +1,229 @@ +# ERD (Entity-Relationship Diagram) + +> 작성일: 2026-02-12 +> 기능 정의서(01), 시퀀스 다이어그램(02), 클래스 다이어그램(03) 기반 +> FK 제약조건 없음 — 모든 참조 무결성은 애플리케이션 레벨에서 관리 + +--- + +## 1. ERD 다이어그램 + +```mermaid +erDiagram + member { + BIGINT id PK "AUTO_INCREMENT" + VARCHAR login_id "NOT NULL" + VARCHAR password "NOT NULL" + VARCHAR name "NOT NULL" + DATE birth_date "NOT NULL" + VARCHAR email "NOT NULL" + } + + brand { + BIGINT id PK "AUTO_INCREMENT" + VARCHAR name "NOT NULL, UNIQUE" + TEXT description "nullable" + DATETIME created_at "NOT NULL" + DATETIME updated_at "NOT NULL" + DATETIME deleted_at "nullable" + } + + product { + BIGINT id PK "AUTO_INCREMENT" + VARCHAR name "NOT NULL" + TEXT description "nullable" + INT price "NOT NULL" + INT stock "NOT NULL" + BIGINT brand_id "NOT NULL" + DATETIME created_at "NOT NULL" + DATETIME updated_at "NOT NULL" + DATETIME deleted_at "nullable" + } + + product_like { + BIGINT id PK "AUTO_INCREMENT" + BIGINT member_id "NOT NULL" + BIGINT product_id "NOT NULL" + DATETIME created_at "NOT NULL" + DATETIME updated_at "NOT NULL" + } + + orders { + BIGINT id PK "AUTO_INCREMENT" + BIGINT member_id "NOT NULL" + VARCHAR status "NOT NULL" + DATETIME ordered_at "NOT NULL" + DATETIME created_at "NOT NULL" + DATETIME updated_at "NOT NULL" + } + + order_line_snapshot { + BIGINT id PK "AUTO_INCREMENT" + BIGINT order_id "NOT NULL" + BIGINT product_id "NOT NULL" + VARCHAR product_name "NOT NULL" + TEXT product_description "nullable" + INT price "NOT NULL" + INT quantity "NOT NULL" + VARCHAR brand_name "NOT NULL" + } + + brand ||--o{ product : "brand_id" + member ||--o{ product_like : "member_id" + product ||--o{ product_like : "product_id" + member ||--o{ orders : "member_id" + orders ||--o{ order_line_snapshot : "order_id" +``` + +> **관계선 = 논리 참조**. DB에 FK 제약조건은 존재하지 않는다. 참조 무결성은 애플리케이션 레벨에서 보장한다. + +--- + +## 2. 읽는 포인트 + +### FK 없음 — 앱 레벨 참조 무결성 + +모든 테이블 간 참조는 `BIGINT` 컬럼(brand_id, member_id 등)으로만 연결된다. DB에 FOREIGN KEY 제약조건을 걸지 않는다. + +- **이유**: BC(Bounded Context) 간 결합도를 최소화한다. MSA 전환 시 테이블이 별도 DB로 분리되어도 구조 변경이 불필요하다. +- **대신**: 참조 대상이 존재하는지, 삭제되지 않았는지는 Service/Facade 레벨에서 검증한다 (시퀀스 다이어그램 참고). + +### VO → 컬럼 매핑 + +클래스 다이어그램의 VO(Value Object)는 별도 테이블이 아닌 **엔티티 테이블의 컬럼**으로 매핑된다. + +| VO | 매핑 컬럼 | DB 타입 | 규칙 (앱 레벨) | +|----|----------|---------|--------------| +| Stock | product.stock | INT | >= 0 (음수 불가) | +| Price | product.price, order_line_snapshot.price | INT | > 0 (양수만) | +| Quantity | order_line_snapshot.quantity | INT | > 0 (양수만) | + +> VO는 코드 구조이지 DB 구조가 아니다 (클래스 다이어그램 안티패턴 #3). +> DB에는 INT 컬럼으로 저장되고, 앱에서 VO 객체로 감싸서 규칙을 검증한다. + +### 상속 전략 (MappedSuperclass) + +JPA 상속은 `@MappedSuperclass`를 사용한다. 상속 클래스별로 별도 테이블이 생기지 않고, 자식 테이블에 컬럼이 포함된다. + +| 상속 클래스 | 포함 컬럼 | 상속하는 테이블 | +|------------|----------|---------------| +| BaseEntity | id, created_at, updated_at, **deleted_at** | brand, product | +| BaseTimeEntity (신규) | id, created_at, updated_at | product_like, orders | + +### 삭제 정책별 테이블 구분 + +| 삭제 정책 | 테이블 | deleted_at 유무 | 상속 | +|----------|--------|----------------|------| +| soft-delete | brand, product | 있음 | BaseEntity | +| hard-delete | product_like | 없음 | BaseTimeEntity | +| 삭제 없음 | orders, order_line_snapshot | 없음 | BaseTimeEntity / 없음 | + +--- + +## 3. 테이블 상세 정의 + +### member (기존) + +독립 엔티티. BaseEntity를 상속하지 않는다. + +| 컬럼 | 타입 | 제약 | 비고 | +|-------|------|------|------| +| id | BIGINT | PK, AUTO_INCREMENT | | +| login_id | VARCHAR | NOT NULL | | +| password | VARCHAR | NOT NULL | 암호화 저장 | +| name | VARCHAR | NOT NULL | 2-40자, 한글/영문 | +| birth_date | DATE | NOT NULL | 미래 날짜 불가 | +| email | VARCHAR | NOT NULL | RFC 5321, 최대 255자 | + +### brand + +BaseEntity 상속 (soft-delete). + +| 컬럼 | 타입 | 제약 | 비고 | +|-------|------|------|------| +| id | BIGINT | PK, AUTO_INCREMENT | | +| name | VARCHAR | NOT NULL, UNIQUE | delete 시 이름 변경으로 UNIQUE 해소 | +| description | TEXT | nullable | 선택 입력 | +| created_at | DATETIME | NOT NULL | BaseEntity | +| updated_at | DATETIME | NOT NULL | BaseEntity | +| deleted_at | DATETIME | nullable | soft-delete 마커 | + +**UNIQUE 해소 전략**: Brand.delete() 시 name을 변경하여(예: `_DELETED_{timestamp}` 접미) UNIQUE 제약을 해소한다. 삭제된 브랜드 이름을 새 브랜드가 재사용할 수 있다. + +### product + +BaseEntity 상속 (soft-delete). + +| 컬럼 | 타입 | 제약 | 비고 | +|-------|------|------|------| +| id | BIGINT | PK, AUTO_INCREMENT | | +| name | VARCHAR | NOT NULL | | +| description | TEXT | nullable | 선택 입력 | +| price | INT | NOT NULL | VO: Price → 앱 레벨 > 0 검증 | +| stock | INT | NOT NULL | VO: Stock → 앱 레벨 >= 0 검증 | +| brand_id | BIGINT | NOT NULL | → brand.id (FK 없음) | +| created_at | DATETIME | NOT NULL | BaseEntity | +| updated_at | DATETIME | NOT NULL | BaseEntity | +| deleted_at | DATETIME | nullable | soft-delete 마커 | + +### product_like + +BaseTimeEntity 상속 (hard-delete). + +| 컬럼 | 타입 | 제약 | 비고 | +|-------|------|------|------| +| id | BIGINT | PK, AUTO_INCREMENT | | +| member_id | BIGINT | NOT NULL | → member.id (FK 없음) | +| product_id | BIGINT | NOT NULL | → product.id (FK 없음) | +| created_at | DATETIME | NOT NULL | BaseTimeEntity | +| updated_at | DATETIME | NOT NULL | BaseTimeEntity | + +- **UNIQUE(member_id, product_id)**: 같은 회원이 같은 상품에 중복 좋아요를 할 수 없다. + +### orders + +BaseTimeEntity 상속 (삭제 없음). 테이블명은 `orders` (ORDER는 SQL 예약어). + +| 컬럼 | 타입 | 제약 | 비고 | +|-------|------|------|------| +| id | BIGINT | PK, AUTO_INCREMENT | | +| member_id | BIGINT | NOT NULL | → member.id (FK 없음) | +| status | VARCHAR | NOT NULL | ACCEPTED / REJECTED | +| ordered_at | DATETIME | NOT NULL | 주문 시점 | +| created_at | DATETIME | NOT NULL | BaseTimeEntity | +| updated_at | DATETIME | NOT NULL | BaseTimeEntity | + +- **status**: 주문 생성 시 즉시 최종 상태(ACCEPTED/REJECTED)로 결정된다. 중간 상태 없음. + +### order_line_snapshot + +Order에 종속되는 VO. Composition 1:N. + +| 컬럼 | 타입 | 제약 | 비고 | +|-------|------|------|------| +| id | BIGINT | PK, AUTO_INCREMENT | JPA 매핑용 | +| order_id | BIGINT | NOT NULL | → orders.id (FK 없음) | +| product_id | BIGINT | NOT NULL | 스냅샷 시점 상품 ID | +| product_name | VARCHAR | NOT NULL | 스냅샷 | +| product_description | TEXT | nullable | 스냅샷 | +| price | INT | NOT NULL | VO: Price (주문 시점 가격) | +| quantity | INT | NOT NULL | VO: Quantity (주문 수량) | +| brand_name | VARCHAR | NOT NULL | 스냅샷 시점 브랜드명 | + +- **timestamp 없음**: 불변 VO. 생성 시점은 소속 Order의 created_at/ordered_at이 대변한다. +- **id 컬럼 존재 이유**: 도메인에서는 VO(독립 식별 불필요)이지만, JPA 1:N 매핑에 PK가 필요하다. +- **상품/브랜드 삭제 무관**: 스냅샷이므로 원본이 삭제되어도 기록은 유지된다. + +--- + +## 4. 설계 결정 기록 + +| # | 결정 | 이유 | 대안 | +|---|------|------|------| +| 1 | FK 제약조건 없음 | 앱 레벨에서 참조 무결성 관리. BC 간 결합도 최소화. MSA 전환 대비 | FK 설정 (DB 정합성 보장이 강하나, BC 간 결합 증가) | +| 2 | VO는 컬럼으로 매핑 | VO는 코드 구조이지 DB 구조가 아니다. 별도 테이블은 안티패턴 | VO별 테이블 (과도한 JOIN, 도메인 의미 왜곡) | +| 3 | order → orders 테이블명 | ORDER는 SQL 예약어. 백틱 의존보다 명확한 이름 사용 | 백틱으로 감싸기 (DB 종류 변경 시 호환성 문제) | +| 4 | OrderLineSnapshot에 id 컬럼 포함 | 도메인 VO이지만 JPA @OneToMany 매핑에 PK 필요 | @ElementCollection (컬렉션 전체 삭제/재삽입 성능 이슈) | +| 5 | OrderLineSnapshot에 timestamp 없음 | 불변 VO. Order의 created_at이 생성 시점을 대변 | timestamp 포함 (불필요한 중복 정보) | +| 6 | product_like에 UNIQUE(member_id, product_id) | 중복 좋아요 방지를 DB 레벨에서 보장. 앱 레벨 검증만으로는 동시성 이슈 가능 | 앱 레벨만 (경쟁 조건에 취약) | +| 7 | brand.name에 UNIQUE 제약 | 이름 중복 불가 요구사항. delete 시 이름 변경으로 UNIQUE 해소 (클래스 다이어그램 결정 #3) | UNIQUE 없이 앱 검증만 (동시성에 취약) | From b98e73e272922f8150de427b8accdb49e4f1d653 Mon Sep 17 00:00:00 2001 From: APapeIsName Date: Thu, 12 Feb 2026 02:32:18 +0900 Subject: [PATCH 22/22] =?UTF-8?q?docs:=20=EA=B8=B0=ED=83=80=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=20=EB=AC=B8=EC=84=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/design/base/class-diagram-erd.md | 418 ++++++++++++++++++ docs/design/base/domain-definition-v2.md | 228 ++++++++++ .../design/{ => base}/member-class-diagram.md | 0 docs/design/base/requirements-input.md | 88 ++++ docs/design/base/sequence-diagrams.md | 270 +++++++++++ docs/design/base/ubiquitous-language.md | 112 +++++ docs/design/base/user-story.md | 400 +++++++++++++++++ 7 files changed, 1516 insertions(+) create mode 100644 docs/design/base/class-diagram-erd.md create mode 100644 docs/design/base/domain-definition-v2.md rename docs/design/{ => base}/member-class-diagram.md (100%) create mode 100644 docs/design/base/requirements-input.md create mode 100644 docs/design/base/sequence-diagrams.md create mode 100644 docs/design/base/ubiquitous-language.md create mode 100644 docs/design/base/user-story.md diff --git a/docs/design/base/class-diagram-erd.md b/docs/design/base/class-diagram-erd.md new file mode 100644 index 00000000..f18c7e7e --- /dev/null +++ b/docs/design/base/class-diagram-erd.md @@ -0,0 +1,418 @@ +# 클래스 다이어그램 & ERD + +> 작성일: 2026-02-10 +> 도메인 정의서(v2) + 요구사항 분석 기반 + +--- + +## 1. 클래스 다이어그램 + +### 왜 필요한가 +각 도메인의 책임 배분, Aggregate 경계, 엔티티/VO 구분, BC 간 참조 방식을 한눈에 확인한다. +도메인 로직이 Service에 집중되지 않고 엔티티 자체에 비즈니스 규칙이 있는지 검증한다. + +### 다이어그램 + +```mermaid +classDiagram + direction TB + + class BaseEntity { + <> + #Long id + #ZonedDateTime createdAt + #ZonedDateTime updatedAt + #ZonedDateTime deletedAt + +delete() void + +restore() void + +guard() void + } + + class BaseTimeEntity { + <> + #Long id + #ZonedDateTime createdAt + #ZonedDateTime updatedAt + +guard() void + } + + note for BaseTimeEntity "물리 삭제 도메인용\n(Brand, Product, ProductLike, Order)\ndeletedAt 없음" + note for BaseEntity "소프트 삭제 도메인용\n(Member 등 기존 엔티티)" + + class Brand { + -String name + -String description + -ZonedDateTime closedAt + +close() void + +reopen() void + +isClosed() boolean + +update(name, description) void + #guard() void + } + + class Product { + -String name + -String description + -int price + -int stock + -Long brandId + +decreaseStock(quantity) void + +restoreStock(quantity) void + +update(name, description, price, stock) void + #guard() void + } + + class ProductLike { + -Long memberId + -Long productId + } + + class Order { + -Long memberId + -OrderStatus status + -ZonedDateTime orderedAt + -List~OrderLineSnapshot~ lines + +cancel() void + +isOwnedBy(memberId) boolean + +isCancellable() boolean + } + + class OrderLineSnapshot { + <> + -Long productId + -String productName + -String productDescription + -int price + -int quantity + -String brandName + } + + class OrderStatus { + <> + REQUESTED + ACCEPTED + REJECTED + CANCELLED + } + + BaseTimeEntity <|-- Brand + BaseTimeEntity <|-- Product + BaseTimeEntity <|-- ProductLike + BaseTimeEntity <|-- Order + + Product ..> Brand : brandId 참조 (ID only) + ProductLike ..> Product : productId 참조 + ProductLike ..> Member : memberId 참조 + Order ..> Member : memberId 참조 + Order *-- OrderLineSnapshot : 1..N 포함 + Order --> OrderStatus : status +``` + +### 읽는 포인트 + +**1. 도메인 로직이 엔티티에 있다** +- `Brand.close()`, `Brand.reopen()` — 폐점/재입점은 브랜드 스스로가 결정하는 행위 +- `Product.decreaseStock()`, `Product.restoreStock()` — 재고 변경은 상품의 책임. 재고 비음수 불변식(`stock >= 0`)은 여기서 검증 +- `Order.cancel()` — 상태 전이 규칙(`ACCEPTED → CANCELLED`만 허용)은 주문이 판단 +- `Order.isOwnedBy()` — 본인 주문 여부 확인도 주문 엔티티의 책임 + +**2. BC 간 참조는 ID만 사용한다** +- `Product.brandId`는 `Long` 타입. `Brand` 객체 참조가 아님 +- `ProductLike.memberId`, `Order.memberId`도 동일 +- 이렇게 하면 BC 간 직접 의존이 없어, 나중에 서비스 분리 시 변경이 최소화됨 + +**3. OrderLineSnapshot은 Value Object이다** +- 식별자(`id`)가 없다 (DB에서는 기술적으로 PK를 부여하지만, 도메인 관점에서 독립 식별 불필요) +- 한번 생성되면 변경 불가 (불변) +- Order와 동일한 생명주기를 가짐 (Order 없이 존재 불가) + +**4. ProductLike는 독립 엔티티이다** +- 값 객체가 아닌 이유: `memberId + productId`라는 고유 식별이 필요하고, 등록/삭제(토글)라는 상태 변경이 있음 +- 다만 도메인 로직이 거의 없는 단순 엔티티. 이는 좋아요의 본질이 "관계의 기록"이기 때문 + +--- + +## 2. 계층별 패키지 구조 (예상) + +기존 코드베이스 패턴(ExampleV1Controller, ExampleFacade, ExampleService, ExampleRepository)을 따른다. + +``` +com.loopers +├── interfaces/api/ +│ ├── brand/ +│ │ ├── BrandV1ApiSpec.java +│ │ ├── BrandV1Controller.java +│ │ └── BrandV1Dto.java +│ ├── product/ +│ │ ├── ProductV1ApiSpec.java +│ │ ├── ProductV1Controller.java +│ │ └── ProductV1Dto.java +│ ├── like/ +│ │ ├── LikeV1ApiSpec.java +│ │ ├── LikeV1Controller.java +│ │ └── LikeV1Dto.java +│ ├── order/ +│ │ ├── OrderV1ApiSpec.java +│ │ ├── OrderV1Controller.java +│ │ └── OrderV1Dto.java +│ └── admin/ +│ ├── AdminBrandV1Controller.java +│ ├── AdminProductV1Controller.java +│ └── AdminOrderV1Controller.java +│ +├── application/ +│ ├── brand/ +│ │ ├── BrandFacade.java +│ │ └── BrandInfo.java +│ ├── product/ +│ │ ├── ProductFacade.java +│ │ └── ProductInfo.java +│ ├── like/ +│ │ └── LikeFacade.java +│ ├── order/ +│ │ ├── OrderFacade.java +│ │ └── OrderInfo.java +│ └── admin/ +│ ├── AdminBrandFacade.java +│ ├── AdminProductFacade.java +│ └── AdminOrderFacade.java +│ +├── domain/ +│ ├── brand/ +│ │ ├── Brand.java +│ │ ├── BrandService.java +│ │ └── BrandRepository.java +│ ├── product/ +│ │ ├── Product.java +│ │ ├── ProductService.java +│ │ └── ProductRepository.java +│ ├── like/ +│ │ ├── ProductLike.java +│ │ ├── LikeService.java +│ │ └── LikeRepository.java +│ └── order/ +│ ├── Order.java +│ ├── OrderLineSnapshot.java +│ ├── OrderStatus.java +│ ├── OrderService.java +│ └── OrderRepository.java +│ +└── infrastructure/ + ├── brand/ + │ ├── BrandJpaRepository.java + │ └── BrandRepositoryImpl.java + ├── product/ + │ ├── ProductJpaRepository.java + │ └── ProductRepositoryImpl.java + ├── like/ + │ ├── LikeJpaRepository.java + │ └── LikeRepositoryImpl.java + └── order/ + ├── OrderJpaRepository.java + └── OrderRepositoryImpl.java +``` + +--- + +## 3. ERD + +### 왜 필요한가 +영속성 구조, FK 방향, 인덱스 전략, 정규화 수준을 확인한다. +특히 스냅샷의 product_id가 FK가 아닌 이유, 테이블명 예약어 회피 등을 명시한다. + +### 다이어그램 + +```mermaid +erDiagram + MEMBER { + bigint id PK + varchar login_id UK "로그인 ID" + varchar password "암호화된 비밀번호" + varchar name "이름" + date birth_date "생년월일" + varchar email "이메일" + } + + BRAND { + bigint id PK + varchar name "브랜드명" + varchar description "브랜드 설명" + datetime closed_at "NULL=영업중, 값=폐점일시" + datetime created_at "생성일시" + datetime updated_at "수정일시" + } + + PRODUCT { + bigint id PK + bigint brand_id FK "소속 브랜드 (생성 후 불변)" + varchar name "상품명" + varchar description "상품 설명" + int price "가격 (원)" + int stock "재고 수량" + datetime created_at "생성일시" + datetime updated_at "수정일시" + } + + PRODUCT_LIKE { + bigint id PK + bigint member_id FK "회원 ID" + bigint product_id FK "상품 ID" + datetime created_at "좋아요 등록일시" + } + + ORDERS { + bigint id PK + bigint member_id FK "주문한 회원" + varchar status "REQUESTED/ACCEPTED/REJECTED/CANCELLED" + datetime ordered_at "주문일시" + datetime created_at "생성일시" + datetime updated_at "수정일시" + } + + ORDER_LINE_SNAPSHOT { + bigint id PK + bigint order_id FK "소속 주문" + bigint product_id "원본 상품 ID (FK 아님)" + varchar product_name "스냅샷: 상품명" + varchar product_description "스냅샷: 상품 설명" + int price "스냅샷: 주문 당시 가격" + int quantity "주문 수량" + varchar brand_name "스냅샷: 브랜드명" + datetime created_at "생성일시" + } + + BRAND ||--o{ PRODUCT : "1:N 소속" + PRODUCT ||--o{ PRODUCT_LIKE : "1:N 좋아요" + MEMBER ||--o{ PRODUCT_LIKE : "1:N 좋아요" + MEMBER ||--o{ ORDERS : "1:N 주문" + ORDERS ||--|{ ORDER_LINE_SNAPSHOT : "1:N 주문라인" +``` + +### 읽는 포인트 + +**1. ORDER_LINE_SNAPSHOT.product_id는 FK가 아니다** +- 상품이 물리 삭제되어도 스냅샷은 보존되어야 한다 +- FK를 걸면 상품 삭제 시 CASCADE DELETE로 스냅샷이 사라지거나, RESTRICT로 삭제가 막힌다 +- 따라서 비즈니스 참조(조회용)로만 사용하고, DB 레벨 참조 무결성은 적용하지 않는다 + +**2. PRODUCT_LIKE에 복합 유니크 제약이 필요하다** +```sql +UNIQUE INDEX uk_like_member_product (member_id, product_id) +``` +- 한 회원이 같은 상품에 좋아요를 중복 생성하면 안 된다 +- 토글 시 이 제약으로 데이터 정합성을 DB 레벨에서 보장한다 + +**3. ORDERS 테이블명** +- `ORDER`는 SQL 예약어(`ORDER BY`)이므로 `ORDERS`로 명명한다 + +**4. BRAND.closed_at의 이중 역할** +- NULL: 영업 중 +- NOT NULL: 폐점 상태 + 폐점 시각 기록 +- 상품 조회 시 `WHERE brand.closed_at IS NULL`이 핵심 필터 조건 + +**5. BaseTimeEntity로 물리 삭제 도메인을 분리한다** +- 기존 `BaseEntity`(deletedAt 포함)는 소프트 삭제가 필요한 엔티티(Member 등)에서 사용한다 +- 신규 `BaseTimeEntity`(deletedAt 없음)는 물리 삭제 도메인(Brand, Product, ProductLike, Order)에서 사용한다 +- 주문(ORDERS)은 삭제하지 않고 상태(CANCELLED)로 관리한다 +- 이 분리로 도메인의 삭제 정책이 코드에 명확히 드러난다 + +--- + +## 4. 인덱스 전략 + +| 테이블 | 인덱스 | 용도 | +|--------|--------|------| +| `product` | `idx_product_brand_id (brand_id)` | 브랜드별 상품 필터링 | +| `product_like` | `uk_like_member_product (member_id, product_id)` UNIQUE | 중복 좋아요 방지 + 토글 조회 | +| `product_like` | `idx_like_member_id (member_id)` | 내 좋아요 목록 조회 | +| `product_like` | `idx_like_product_id (product_id)` | 상품 삭제 시 연쇄 삭제 | +| `orders` | `idx_orders_member_id (member_id)` | 내 주문 목록 조회 | +| `orders` | `idx_orders_ordered_at (ordered_at)` | 날짜 기간 필터링 | +| `orders` | `idx_orders_member_ordered (member_id, ordered_at)` | 회원별 기간 필터 복합 | +| `order_line_snapshot` | `idx_snapshot_order_id (order_id)` | 주문 상세 조회 시 스냅샷 로딩 | + +--- + +## 5. DDL (예상) + +```sql +CREATE TABLE brand ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description VARCHAR(500) NOT NULL, + closed_at DATETIME(6) NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL +); + +CREATE TABLE product ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + brand_id BIGINT NOT NULL, + name VARCHAR(200) NOT NULL, + description VARCHAR(1000) NOT NULL, + price INT NOT NULL, + stock INT NOT NULL DEFAULT 0, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + + INDEX idx_product_brand_id (brand_id), + CONSTRAINT fk_product_brand FOREIGN KEY (brand_id) REFERENCES brand(id) +); + +CREATE TABLE product_like ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + member_id BIGINT NOT NULL, + product_id BIGINT NOT NULL, + created_at DATETIME(6) NOT NULL, + + UNIQUE INDEX uk_like_member_product (member_id, product_id), + INDEX idx_like_member_id (member_id), + INDEX idx_like_product_id (product_id), + CONSTRAINT fk_like_member FOREIGN KEY (member_id) REFERENCES member(id), + CONSTRAINT fk_like_product FOREIGN KEY (product_id) REFERENCES product(id) +); + +CREATE TABLE orders ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + member_id BIGINT NOT NULL, + status VARCHAR(20) NOT NULL, + ordered_at DATETIME(6) NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + + INDEX idx_orders_member_id (member_id), + INDEX idx_orders_member_ordered (member_id, ordered_at), + CONSTRAINT fk_orders_member FOREIGN KEY (member_id) REFERENCES member(id) +); + +CREATE TABLE order_line_snapshot ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + order_id BIGINT NOT NULL, + product_id BIGINT NOT NULL, + product_name VARCHAR(200) NOT NULL, + product_description VARCHAR(1000) NOT NULL, + price INT NOT NULL, + quantity INT NOT NULL, + brand_name VARCHAR(100) NOT NULL, + created_at DATETIME(6) NOT NULL, + + INDEX idx_snapshot_order_id (order_id), + CONSTRAINT fk_snapshot_order FOREIGN KEY (order_id) REFERENCES orders(id) +); +``` + +--- + +## 6. 설계 결정 기록 + +| 결정 | 이유 | 대안 | +|------|------|------| +| 스냅샷 product_id에 FK 미적용 | 상품 삭제 후에도 스냅샷 보존 필요 | FK + ON DELETE SET NULL (NULL 허용 시) | +| ORDERS 테이블명 | ORDER는 SQL 예약어 | order_info, purchase 등 | +| **BaseTimeEntity 신규 도입** | 물리 삭제 도메인(Brand, Product, ProductLike, Order)은 deletedAt이 불필요. 기존 BaseEntity(deletedAt 포함)는 소프트 삭제 도메인(Member 등)에서 계속 사용 | BaseEntity를 그대로 상속하고 deletedAt 미사용 (불필요한 컬럼 낭비) | +| 비관적 락 (FOR UPDATE) | 재고 정합성 확보. 감성 이커머스 규모에 적합 | 낙관적 락, 원자적 UPDATE | +| 개별 UPDATE 루프 | 실패 상품 식별 가능. 비관적 락과 자연스러운 조합 | 벌크 UPDATE | +| **productId 오름차순 정렬 후 락 획득** | 데드락 방지. 모든 TX가 동일 순서로 락을 잡아 교착 상태 원천 차단 | 정렬 없이 요청 순서대로 (데드락 위험) | +| **폐점 브랜드 검증 규칙 위치: Facade** | 현재 모놀리스에서 Facade가 BC 간 조율자 역할. 상품은 brandId(Long)만 보유하여 브랜드 상태를 스스로 알 수 없음 | 도메인 서비스(`OrderDomainService.validateOrderable(product, brand)`)로 이동 — 도메인 모델링 순수성을 높이지만 복잡도 증가 | +| **주문 취소 시 삭제된 상품의 재고 복원 건너뛰기** | 상품이 물리 삭제되면 복원할 대상이 없음. 주문 상태 변경(CANCELLED)은 정상 수행 | 취소 자체를 거부 (사용자 경험 저하) | +| **모놀리스에서 BC 간 단일 TX** | BC 경계는 논리적 자치권(언어, 책임). TX 경계는 데이터 정합성(물리적). 같은 DB를 쓰는 모놀리스에서 두 BC가 하나의 TX에 참여하는 것은 정합성과 단순성을 동시에 확보 | 도메인 이벤트 + 보상 트랜잭션 (서비스 분리 시 전환) | diff --git a/docs/design/base/domain-definition-v2.md b/docs/design/base/domain-definition-v2.md new file mode 100644 index 00000000..de0ecdaf --- /dev/null +++ b/docs/design/base/domain-definition-v2.md @@ -0,0 +1,228 @@ +# 감성 이커머스 도메인 정의서 (v2) + +> 작성일: 2026-02-10 +> 상태: **확정** + +## 1. 프로젝트 개요 + +**감성 이커머스** — 좋아요 누르고, 쿠폰 쓰고, 주문 및 결제하는 커머스 플랫폼. +내가 좋아하는 브랜드의 상품들을 한 번에 담아 주문하고, 유저 행동은 랭킹과 추천으로 연결된다. + +**목표**: 회원이 구매하고 싶은 상품과 그 브랜드를 제시할 수 있어야 한다. + +**전제 조건**: +- 회원은 회원가입한 사용자를 일컫는다. +- 사용자는 회원과 비회원을 포함한 애플리케이션 사용자이다. +- 사용자는 모든 행동의 중심이 되는 인물이다. + +--- + +## 2. 액터 정의 + +| 액터 | 정의 | 인증 방식 | +|------|------|----------| +| **사용자** | 회원과 비회원을 포함한 서비스 이용자. 상품 탐색이 주 행위 | 인증 없음 | +| **회원** | 서비스와 신뢰의 계약을 맺은 사용자. 좋아요·주문 등 편의 기능 사용 가능 | 헤더 기반 인증 (`X-Loopers-LoginId/Pw`) | +| **관리자** | 서비스의 운영 권한을 가진 내부 사용자. 브랜드·상품·주문을 관리 | 헤더 기반 간이 LDAP 인증 | + +--- + +## 3. 도메인 본질 정의 + +### 3.1 회원 (Member) — 구현 완료 +서비스와 사용자 사이 맺은 신뢰의 계약. +회원은 서비스에게 정보를, 서비스는 회원에게 편의 기능(좋아요, 주문)을 제공하며 상호 이익이 되는 관계를 형성한다. + +### 3.2 브랜드 (Brand) +상품을 만들고 신뢰를 부여하는 주체. +사용자에게는 탐색의 분류 기준이 되고, 관리자에게는 입점/폐점/삭제의 관리 대상이 된다. +브랜드는 **입점(활성)**과 **폐점(비활성)**이라는 영업 상태를 가지며, 이는 소속 상품의 노출 여부를 결정한다. + +### 3.3 상품 (Product) +판매를 위해 진열된 재화. +이름·가격·재고라는 고유 속성을 가지며, 재고는 주문 가능 여부를 결정하는 핵심 비즈니스 규칙과 직결된다. +상품은 반드시 하나의 브랜드에 소속되며, 이 소속은 생성 시 확정되어 이후 변경할 수 없다. + +### 3.4 좋아요 (Like) +회원이 특정 상품에 대해 표현한 관심의 기록. +회원과 상품 사이의 관계를 나타내는 연관 엔티티이며, 등록과 취소라는 행위를 가진다. + +### 3.5 주문 (Order) +회원이 상품 구매를 위해 서비스에 제출하는 요청서. +요청 시점의 상품 정보를 스냅샷으로 보존하며, 요청·수락·거절·취소라는 생명주기를 가진다. + +### 3.6 주문 상품 스냅샷 (Order Product Snapshot) +주문 시점에 캡처된 상품의 불변 사본. +상품명, 가격, 브랜드명, 수량 등이 기록되며, 원본 상품이나 브랜드의 변경·삭제와 무관하게 영구 보존된다. + +--- + +## 4. 서브도메인 분류 + +| 서브도메인 | 분류 | 근거 | 우선순위 | +|-----------|------|------|---------| +| **상품** | Core | 탐색과 구매의 핵심 대상, 재고가 주문 가능 여부를 결정 | 1순위 | +| **주문** | Core | 구매 행위 자체, 결제 확장 지점 | 1순위 | +| **브랜드** | Supporting → Core 전환 가능 | 어드민 CRUD + 입점/폐점 생명주기 보유 | 2순위 | +| **좋아요** | Supporting | 회원 편의, 향후 랭킹/추천 연동 가능 | 3순위 | +| **회원** | Generic (구현 완료) | 인증/인가 기반 제공 | - | + +--- + +## 5. 바운디드 컨텍스트 (BC) 구조 + +``` +BC: 브랜드 (Brand Context) +└── 브랜드 (Aggregate Root) — 이름, 설명, closedAt + └── 관리자: CRUD + 폐점/재입점 / 사용자: 조회 전용 + +BC: 상품 (Product Context) +├── 상품 (Aggregate Root) — 이름, 가격, 재고, 브랜드ID(참조) +└── 좋아요 (Entity) — 회원ID, 상품ID, 등록일시 + └── 회원-상품 간 연관 엔티티 + +BC: 주문 (Order Context) +├── 주문 (Aggregate Root) — 회원ID, 상태, 주문일시 +└── 주문 상품 스냅샷 (Value Object) — 상품명, 가격, 브랜드명, 수량 + └── 주문 시점 불변 사본 + +[확장 지점] BC: 결제 (Payment Context) — 미구현 +└── 주문ID 참조, 결제 상태 관리 +``` + +### BC 간 관계 + +``` +[관리자] ──(LDAP 간이 인증)──▶ [브랜드 BC] ──(브랜드ID)──▶ [상품 BC] ◀──(상품ID)── [주문 BC] + (CRUD, 폐점/재입점) (CRUD, 좋아요) (생성, 조회) + │ + 삭제 시 연쇄: 상품 물리삭제 → 좋아요 물리삭제 + 폐점 시: 상품 자동 비노출 (데이터 보존) + │ + [주문 스냅샷은 항상 보존] +``` + +--- + +## 6. 브랜드 생명주기 — 폐점과 삭제 + +### 두 가지 행위의 경계 + +| | 폐점 (Close) | 삭제 (Delete) | +|---|---|---| +| **의도** | 브랜드가 더 이상 판매하지 않는다 | 브랜드를 완전히 제거한다 | +| **비유** | 가게 셔터를 내림 | 등기부에서 말소 | +| **브랜드** | `closedAt` 세팅, 데이터 보존 | 물리 삭제 | +| **상품** | 데이터 보존, 조회에서 자동 제외 | 물리 삭제 (연쇄) | +| **좋아요** | 데이터 보존 | 물리 삭제 (연쇄) | +| **주문 스냅샷** | 영향 없음 | 영향 없음 | +| **복원** | 재입점 가능 (`closedAt = null`) | 불가 | + +### 상품 비노출 규칙 + +폐점된 브랜드의 상품은 별도 플래그 없이 **브랜드의 `closedAt`**으로 판단한다. +- 사용자 상품 목록/상세 → 폐점 브랜드 상품 제외 +- 브랜드별 필터 → 폐점 브랜드 자체가 필터 대상에서 제외 +- 관리자 조회 → 폐점 브랜드·상품 모두 조회 가능 (운영 목적) + +--- + +## 7. 주문 상태 모델 + +``` +REQUESTED ──(재고 확인 성공)──▶ ACCEPTED ──(회원 요청)──▶ CANCELLED + │ (재고 복원) + └──(재고 부족)──▶ REJECTED +``` + +| 상태 | 의미 | 전이 조건 | +|------|------|----------| +| `REQUESTED` | 주문 요청됨 | 회원의 주문 요청 | +| `ACCEPTED` | 주문 수락, 재고 차감 완료 | 재고 >= 주문 수량, 수량만큼 차감 | +| `REJECTED` | 주문 거절 | 재고 부족 | +| `CANCELLED` | 주문 취소, 재고 복원 | 수락 이후 회원이 취소 요청 | + +--- + +## 8. 도메인 불변식 (Invariants) + +### 브랜드 BC +| 규칙 | 설명 | +|------|------| +| 폐점 브랜드 상품 등록 금지 | `closedAt != null`인 브랜드에는 새 상품을 등록할 수 없다 | +| 폐점 브랜드 상품 주문 불가 | 폐점 브랜드의 상품에는 주문을 요청할 수 없다 | +| 삭제 시 연쇄 물리 삭제 | 브랜드 삭제 시 소속 상품 + 해당 좋아요가 물리 삭제된다 | +| 재입점 시 자동 복원 | `closedAt` 해제 시 보존된 상품·좋아요가 자동 복원된다 | + +### 상품 BC +| 규칙 | 설명 | +|------|------| +| 브랜드 불변 | 상품의 브랜드는 생성 시 확정, 이후 변경 불가 | +| 브랜드 존재 검증 | 상품 등록 시 참조하는 브랜드는 반드시 존재해야 한다 | +| 재고 비음수 | 재고는 0 미만이 될 수 없다 | + +### 주문 BC +| 규칙 | 설명 | +|------|------| +| 스냅샷 불변 | 주문 스냅샷은 생성 이후 변경할 수 없다 | +| 스냅샷 영구 보존 | 원본 상품/브랜드의 삭제와 무관하게 보존된다 | +| 취소 시 재고 복원 | ACCEPTED → CANCELLED 전이 시 차감한 수량만큼 재고를 복원한다 | +| 취소 시 삭제된 상품 건너뛰기 | 원본 상품이 이미 물리 삭제된 경우, 해당 상품의 재고 복원은 건너뛰고 주문 상태만 CANCELLED로 변경한다 | + +--- + +## 9. 요구사항-도메인 매핑 + +### 사용자/회원 요구사항 +| # | 요구사항 | 담당 BC | 핵심 개념 | +|---|---------|---------|----------| +| 1 | 상품 목록 조회 | 상품 | Product (브랜드 closedAt 필터) | +| 2 | 상품 상세 조회 | 상품 | Product | +| 3 | 상품의 브랜드 정보 조회 | 상품 + 브랜드 | Product → Brand 참조 | +| 4 | 특정 브랜드 상품만 조회 | 상품 | Brand ID 필터링 | +| 5 | 좋아요 등록/취소 | 상품 | Like (Entity) | +| 6 | 좋아요 상품 목록 조회 | 상품 | Like → Product | +| 7 | 주문 요청 | 주문 + 상품 | Order 생성 + 재고 확인 | +| 8 | 재고 0 → 거절 | 주문 + 상품 | Order(REJECTED) | +| 9 | 재고 > 0 → 수락 + 차감 | 주문 + 상품 | Order(ACCEPTED) + 수량만큼 차감 | +| 10 | 스냅샷 저장 | 주문 | OrderProductSnapshot(VO) | +| 11 | 날짜 필터 주문 목록 조회 | 주문 | Order 날짜 범위 조회 | +| 12 | 주문 상세 조회 | 주문 | Order + Snapshot | +| 13 | 결제 확장성 | 주문 상태 | 상태 열거형 확장 + Payment BC 예약 | + +### 관리자 요구사항 +| # | 요구사항 | 담당 BC | 핵심 개념 | +|---|---------|---------|----------| +| A1 | 브랜드 목록 조회 (페이징) | 브랜드 | Brand | +| A2 | 브랜드 상세 조회 | 브랜드 | Brand | +| A3 | 브랜드 등록 | 브랜드 | Brand 생성 | +| A4 | 브랜드 수정 | 브랜드 | Brand 업데이트 | +| A5 | 브랜드 삭제 (상품 연쇄 삭제) | 브랜드 + 상품 | 물리 삭제 + 연쇄 | +| A6 | 상품 목록 조회 (페이징, 브랜드 필터) | 상품 | Product | +| A7 | 상품 상세 조회 | 상품 | Product | +| A8 | 상품 등록 (브랜드 존재 검증) | 상품 + 브랜드 | Product 생성 | +| A9 | 상품 수정 (브랜드 변경 불가) | 상품 | Product 업데이트 | +| A10 | 상품 삭제 | 상품 | Product 물리 삭제 | +| A11 | 주문 목록 조회 (페이징) | 주문 | Order (읽기 전용) | +| A12 | 주문 상세 조회 | 주문 | Order + Snapshot (읽기 전용) | +| Auth | 모든 관리자 API LDAP 인증 | 공통 | 헤더 기반 간이 인증 | + +--- + +## 10. 리스크 및 열린 결정사항 + +### 리스크 — 확정 +| 리스크 | 결정 | 상태 | +|--------|------|------| +| 주문 시 재고 차감 동시성 | 비관적 락(SELECT FOR UPDATE) + productId 오름차순 정렬로 데드락 방지 | ✅ 확정 | +| 재고 차감 방식 | 개별 UPDATE 루프. 실패 상품을 구체적으로 식별하여 에러 응답에 포함 | ✅ 확정 | +| 대량 연쇄 삭제 시 트랜잭션 부하 | 단일 TX 유지. 브랜드 삭제는 빈번하지 않은 관리자 작업 | ✅ 확정 | +| BC 간 트랜잭션 경계 | 모놀리스에서 상품 BC + 주문 BC 단일 TX 참여. 서비스 분리 시 이벤트 기반으로 전환 | ✅ 확정 | +| 주문 취소 시 상품 삭제됨 | 재고 복원 건너뛰기. 주문 상태만 CANCELLED로 변경 | ✅ 확정 | +| 폐점 브랜드 검증 규칙 위치 | 현재 Facade에서 검증. 향후 도메인 서비스로 이동 고려 | ✅ 확정 (현재) | +| 좋아요 데이터 증가 | 인덱스 전략 수립 (복합 유니크, member_id, product_id 인덱스) | ✅ 확정 | +| 브랜드 삭제 후 동일 이름 재등록 | Hard Delete이므로 UNIQUE 충돌 없음 | ✅ 해결됨 | + +### 열린 결정사항 +- [ ] 폐점 브랜드의 좋아요 목록 노출 정책 (숨김 vs "폐점된 상품" 표시) +- [ ] REJECTED 주문에 스냅샷을 저장할 것인지 여부 (저장: 거절 이력 확인 가능 / 미저장: 스냅샷 = 계약 증거라는 본질 유지) diff --git a/docs/design/member-class-diagram.md b/docs/design/base/member-class-diagram.md similarity index 100% rename from docs/design/member-class-diagram.md rename to docs/design/base/member-class-diagram.md diff --git a/docs/design/base/requirements-input.md b/docs/design/base/requirements-input.md new file mode 100644 index 00000000..65693156 --- /dev/null +++ b/docs/design/base/requirements-input.md @@ -0,0 +1,88 @@ +# 요구사항 분석 입력 문서 + +> 도메인 정의서(v2) 기반 — `/requirements-analysis` 스킬 입력용 + +## 프로젝트 컨텍스트 + +- 감성 이커머스 플랫폼 +- 기존 구현: 회원 도메인 (회원가입, 인증, 비밀번호 변경) +- 이번 범위: 브랜드, 상품, 좋아요, 주문 + +## 액터 + +| 액터 | 인증 | 역할 | +|------|------|------| +| 사용자 | 없음 | 상품/브랜드 조회 | +| 회원 | 헤더 인증 (`X-Loopers-LoginId/Pw`) | 좋아요, 주문 | +| 관리자 | 헤더 기반 간이 LDAP | 브랜드·상품 CRUD, 주문 조회 | + +## 확정된 도메인 설계 + +### BC 구조 +- **브랜드 BC**: 브랜드(Aggregate Root) — 이름, 설명, closedAt +- **상품 BC**: 상품(Aggregate Root) — 이름, 가격, 재고, 브랜드ID / 좋아요(Entity) — 회원ID, 상품ID +- **주문 BC**: 주문(Aggregate Root) — 회원ID, 상태 / 스냅샷(Value Object) — 상품명, 가격, 브랜드명, 수량 + +### 브랜드 생명주기 +- **폐점(Close)**: closedAt 세팅 → 상품 자동 비노출 (데이터 보존) → 재입점 가능 +- **삭제(Delete)**: 물리 삭제 → 상품·좋아요 연쇄 물리 삭제 → 복원 불가 + +### 주문 상태: REQUESTED → ACCEPTED / REJECTED / CANCELLED +- 주문 수량 N개 가능 +- 취소 시 재고 복원 + +### 핵심 불변식 +- 상품의 브랜드는 생성 후 변경 불가 +- 상품 등록 시 브랜드 존재 필수 +- 폐점 브랜드에 상품 등록/주문 불가 +- 주문 스냅샷은 원본 삭제와 무관하게 보존 + +## 사용자/회원 요구사항 + +1. 사용자는 상품 목록을 조회할 수 있어야 한다. +2. 사용자는 상품의 상세 정보를 조회할 수 있어야 한다. +3. 사용자는 상품의 브랜드 정보를 조회할 수 있어야 한다. +4. 사용자는 특정 브랜드의 상품 정보만 조회할 수 있어야 한다. +5. 회원은 상품에 좋아요를 등록하고, 취소할 수 있다. +6. 회원은 개인이 좋아요를 누른 상품 목록만을 따로 조회할 수 있다. +7. 회원은 상품에 대하여 주문을 요청할 수 있다. (수량 N개) +8. 주문 요청 시 재고가 부족하면 주문이 거절된다 (REJECTED). +9. 주문 요청 시 재고가 충분하면 주문이 수락되며 수량만큼 재고가 차감된다 (ACCEPTED). +10. 주문 시 당시 상품 정보가 스냅샷으로 저장된다. +11. 회원은 날짜 기간을 필터링하여 자신의 주문 목록을 조회할 수 있다. +12. 회원은 주문한 내용의 상세 정보를 확인할 수 있다. +13. 수락된 주문은 회원이 취소할 수 있으며, 취소 시 재고가 복원된다 (CANCELLED). + +## 관리자 요구사항 + +### 브랜드 관리 +A1. 관리자는 등록된 브랜드 목록을 조회할 수 있어야 한다. (페이징) +A2. 관리자는 특정 브랜드의 상세 정보를 조회할 수 있어야 한다. +A3. 관리자는 새로운 브랜드를 등록할 수 있어야 한다. +A4. 관리자는 기존 브랜드의 정보를 수정할 수 있어야 한다. +A5. 관리자는 브랜드를 삭제할 수 있어야 한다. (상품·좋아요 연쇄 물리 삭제) +A6. 관리자는 브랜드를 폐점할 수 있어야 한다. (closedAt 세팅, 재입점 가능) + +### 상품 관리 +A7. 관리자는 등록된 상품 목록을 조회할 수 있어야 한다. (페이징, 브랜드 필터) +A8. 관리자는 특정 상품의 상세 정보를 조회할 수 있어야 한다. +A9. 관리자는 새로운 상품을 등록할 수 있어야 한다. (브랜드 존재 검증 필수) +A10. 관리자는 상품 정보를 수정할 수 있어야 한다. (브랜드 변경 불가) +A11. 관리자는 등록된 상품을 삭제할 수 있어야 한다. (물리 삭제) + +### 주문 관리 +A12. 관리자는 전체 주문 목록을 조회할 수 있어야 한다. (페이징) +A13. 관리자는 특정 주문의 상세 내역을 조회할 수 있어야 한다. + +### 인증 +Auth. 모든 관리자 API는 헤더 기반 간이 LDAP 인증을 거쳐야 한다. + +## 결제 확장 지점 (미구현) + +- 결제는 별도 BC(Payment Context)로 분리 예정 +- 주문 상태 모델에 결제 관련 상태 추가 가능 (PAYMENT_PENDING, PAID 등) +- 현재는 주문 상태를 REQUESTED/ACCEPTED/REJECTED/CANCELLED로만 운영 + +## 열린 결정사항 + +- 폐점 브랜드의 좋아요 목록 노출 정책 (숨김 vs "폐점된 상품" 표시) diff --git a/docs/design/base/sequence-diagrams.md b/docs/design/base/sequence-diagrams.md new file mode 100644 index 00000000..16a33f5c --- /dev/null +++ b/docs/design/base/sequence-diagrams.md @@ -0,0 +1,270 @@ +# 시퀀스 다이어그램 + +> 작성일: 2026-02-10 +> 도메인 정의서(v2) + 요구사항 분석 기반 +> 각 다이어그램은 "왜 필요한가 → 다이어그램 → 읽는 포인트" 순서로 기술한다. + +--- + +## 1. 주문 생성 흐름 + +### 왜 필요한가 +주문은 상품 BC(재고 확인/차감)와 주문 BC(주문 생성/스냅샷)가 만나는 지점이다. +트랜잭션 경계, 비관적 락의 적용 지점, 실패 시 롤백 범위를 검증한다. + +### 다이어그램 + +```mermaid +sequenceDiagram + actor M as 회원 + participant C as OrderController + participant F as OrderFacade + participant PS as ProductService + participant OS as OrderService + participant DB as Database + + M->>C: POST /api/v1/orders
[{productId, quantity}, ...] + C->>F: createOrder(memberId, orderItems) + + F->>F: @Transactional 시작 + F->>F: 주문 상품을 productId 오름차순 정렬 (데드락 방지) + + loop 정렬된 각 주문 상품에 대해 + F->>PS: getProductForOrder(productId) + PS->>DB: SELECT * FROM product WHERE id = ? FOR UPDATE + DB-->>PS: Product (행 락 획득) + PS-->>F: Product + Brand 정보 + + alt 브랜드 폐점 (brand.closedAt != null) + F-->>C: 400 Bad Request (폐점 브랜드) + Note over F: 트랜잭션 롤백 + end + + F->>PS: decreaseStock(product, quantity) + + alt 재고 부족 (stock < quantity) + PS-->>F: IllegalArgumentException + Note over F: 트랜잭션 롤백 + F-->>C: 400 Bad Request (REJECTED) + end + + PS->>DB: UPDATE product SET stock = stock - ? WHERE id = ? + PS-->>F: 차감 완료 + end + + F->>OS: createOrder(memberId, snapshots, ACCEPTED) + OS->>DB: INSERT INTO orders (ACCEPTED) + OS->>DB: INSERT INTO order_line_snapshot (N건) + OS-->>F: Order + + F->>F: 트랜잭션 커밋 + F-->>C: Order 정보 + C-->>M: 201 Created +``` + +### 읽는 포인트 +- **Facade가 트랜잭션을 소유한다.** ProductService와 OrderService는 각자의 책임만 수행하고, 조율은 Facade에서 한다. 모놀리스에서 두 BC(상품, 주문)가 하나의 트랜잭션에 참여하는 것은 정합성과 단순성을 동시에 확보하는 올바른 선택이다. BC 경계는 논리적 자치권이며, TX 경계와 반드시 일치할 필요는 없다. +- **비관적 락(FOR UPDATE)**은 ProductService가 DB에서 상품을 조회하는 시점에 획득된다. 다른 트랜잭션은 이 행이 해제될 때까지 대기한다. +- **productId 오름차순 정렬 후 락 획득**: 데드락을 방지한다. TX1이 A→B, TX2가 B→A 순서로 락을 잡으면 교착 상태가 발생하므로, 모든 트랜잭션이 동일한 순서(ID 오름차순)로 락을 획득해야 한다. +- **실패 시 전체 롤백**: 3개 상품 중 3번째에서 재고 부족이면, 1~2번째의 재고 차감도 롤백된다. 에러 응답에는 **어떤 상품이 부족한지** 구체적 정보(productId, productName, requestedQuantity, availableStock)를 포함한다. +- **REQUESTED 상태는 DB에 저장되지 않는다.** 요청 수신~재고 확인 사이의 논리적 상태이며, 저장 시점에는 ACCEPTED 또는 REJECTED가 확정된다. + +--- + +## 2. 주문 취소 흐름 + +### 왜 필요한가 +취소 시 재고 복원이 수반된다. 주문 BC에서 상품 BC로의 역방향 호출이 발생하며, +상태 전이의 유효성 검증과 재고 복원의 정확성을 확인한다. + +### 다이어그램 + +```mermaid +sequenceDiagram + actor M as 회원 + participant C as OrderController + participant F as OrderFacade + participant OS as OrderService + participant PS as ProductService + participant DB as Database + + M->>C: PATCH /api/v1/orders/{orderId}/cancel + C->>F: cancelOrder(memberId, orderId) + + F->>F: @Transactional 시작 + + F->>OS: getOrder(orderId) + OS->>DB: SELECT * FROM orders WHERE id = ? + OS-->>F: Order (with snapshots) + + alt 본인 주문이 아님 + F-->>C: 403 Forbidden + end + + alt 상태가 ACCEPTED가 아님 + F-->>C: 400 Bad Request (취소 불가 상태) + end + + F->>OS: cancel(order) + OS->>DB: UPDATE orders SET status = 'CANCELLED' WHERE id = ? + + loop 각 스냅샷 라인에 대해 (productId 오름차순) + F->>PS: restoreStock(productId, quantity) + PS->>DB: SELECT * FROM product WHERE id = ? FOR UPDATE + + alt 상품이 이미 삭제됨 (조회 결과 없음) + Note over PS: 재고 복원 건너뛰기 (skip) + else 상품 존재 + PS->>DB: UPDATE product SET stock = stock + ? WHERE id = ? + end + end + + F->>F: 트랜잭션 커밋 + F-->>C: 취소 완료 + C-->>M: 200 OK +``` + +### 읽는 포인트 +- **상태 검증이 먼저, 재고 복원이 나중이다.** 상태가 유효하지 않으면 DB 수정 없이 즉시 반환한다. +- **스냅샷에 기록된 수량으로 복원한다.** 원본 상품의 현재 상태가 아닌, 주문 당시 차감한 수량을 기준으로 한다. +- **상품이 이미 삭제된 경우 재고 복원을 건너뛴다.** 상품이 물리 삭제되었다면 복원할 대상이 없으므로, 해당 라인은 skip하고 주문 상태만 CANCELLED로 변경한다. +- **재고 복원 시에도 productId 오름차순 정렬**을 적용하여 데드락을 방지한다. + +--- + +## 3. 브랜드 삭제 흐름 (연쇄) + +### 왜 필요한가 +3개 테이블에 걸친 연쇄 물리 삭제의 순서와 트랜잭션 범위를 명확히 한다. +FK 제약 위반 없이 삭제하려면 의존 역순으로 처리해야 한다. + +### 다이어그램 + +```mermaid +sequenceDiagram + actor A as 관리자 + participant C as AdminBrandController + participant F as AdminBrandFacade + participant BS as BrandService + participant PS as ProductService + participant LS as LikeService + participant DB as Database + + A->>C: DELETE /api/v1/admin/brands/{id}
[X-Loopers-Ldap] + C->>F: deleteBrand(brandId) + + F->>F: @Transactional 시작 + + F->>BS: getBrand(brandId) + BS->>DB: SELECT * FROM brand WHERE id = ? + BS-->>F: Brand + + alt 브랜드 없음 + F-->>C: 404 Not Found + end + + F->>PS: findProductIdsByBrandId(brandId) + PS->>DB: SELECT id FROM product WHERE brand_id = ? + PS-->>F: List productIds + + F->>LS: deleteLikesByProductIds(productIds) + LS->>DB: DELETE FROM product_like WHERE product_id IN (...) + + F->>PS: deleteProductsByBrandId(brandId) + PS->>DB: DELETE FROM product WHERE brand_id = ? + + F->>BS: deleteBrand(brandId) + BS->>DB: DELETE FROM brand WHERE id = ? + + F->>F: 트랜잭션 커밋 + F-->>C: 삭제 완료 + C-->>A: 204 No Content +``` + +### 읽는 포인트 +- **삭제 순서: 좋아요 → 상품 → 브랜드.** FK 의존의 역순이다. 순서를 바꾸면 참조 무결성 위반이 발생한다. +- **productIds를 먼저 조회한 후** 좋아요 삭제에 사용한다. 상품 삭제 이후에는 brand_id로 상품을 찾을 수 없다. +- **단일 트랜잭션**: 중간에 실패하면 전체가 롤백되어 부분 삭제 상태가 발생하지 않는다. + +--- + +## 4. 좋아요 토글 흐름 + +### 왜 필요한가 +토글 방식의 내부 분기 로직과 응답 구조를 확인한다. +폐점 브랜드 상품에 대한 좋아요 차단 조건도 포함된다. + +### 다이어그램 + +```mermaid +sequenceDiagram + actor M as 회원 + participant C as LikeController + participant LS as LikeService + participant PS as ProductService + participant DB as Database + + M->>C: POST /api/v1/products/{productId}/likes
[X-Loopers-LoginId, X-Loopers-LoginPw] + C->>LS: toggleLike(memberId, productId) + + LS->>PS: getProduct(productId) + PS->>DB: SELECT p.*, b.closed_at FROM product p JOIN brand b ... + PS-->>LS: Product + Brand + + alt 상품 없음 + LS-->>C: 404 Not Found + end + + alt 브랜드 폐점 + LS-->>C: 400 Bad Request + end + + LS->>DB: SELECT * FROM product_like
WHERE member_id = ? AND product_id = ? + + alt 좋아요 존재 + LS->>DB: DELETE FROM product_like WHERE id = ? + LS-->>C: { liked: false } + else 좋아요 미존재 + LS->>DB: INSERT INTO product_like (member_id, product_id, created_at) + LS-->>C: { liked: true } + end + + C-->>M: 200 OK { liked: true/false } +``` + +### 읽는 포인트 +- **상품 존재 + 브랜드 폐점 여부를 먼저 확인한다.** 좋아요 처리 전에 비즈니스 전제 조건을 검증한다. +- **토글 로직**: 존재하면 삭제(물리), 없으면 생성. 별도의 등록/취소 API가 없다. +- **응답에 현재 상태를 포함**하여 클라이언트가 UI를 동기화할 수 있다. + +--- + +## 5. 상품 조회 흐름 (사용자) + +### 왜 필요한가 +사용자 상품 조회에서 폐점 브랜드 필터링이 어떻게 적용되는지 확인한다. +두 BC(상품 + 브랜드)의 데이터를 조합하는 조회 전략을 검증한다. + +### 다이어그램 + +```mermaid +sequenceDiagram + actor U as 사용자 + participant C as ProductController + participant PS as ProductService + participant DB as Database + + U->>C: GET /api/v1/products?page=0&size=20&brandId=1 + C->>PS: getProducts(page, size, brandId) + + PS->>DB: SELECT p.* FROM product p
JOIN brand b ON p.brand_id = b.id
WHERE b.closed_at IS NULL
[AND p.brand_id = :brandId]
LIMIT :size OFFSET :page*size + DB-->>PS: Page + + PS-->>C: Page + C-->>U: 200 OK { data: [...], meta: { page, size, totalElements } } +``` + +### 읽는 포인트 +- **JOIN을 통해 브랜드 폐점 여부를 필터링한다.** 상품 테이블에 별도 플래그가 없으므로, 반드시 brand 테이블과 조인해야 한다. +- **brandId 파라미터는 선택적이다.** 없으면 전체 상품(폐점 제외), 있으면 해당 브랜드 상품만 반환한다. +- **관리자 조회와의 차이**: 관리자는 `WHERE b.closed_at IS NULL` 조건 없이 전체를 조회한다. diff --git a/docs/design/base/ubiquitous-language.md b/docs/design/base/ubiquitous-language.md new file mode 100644 index 00000000..833db368 --- /dev/null +++ b/docs/design/base/ubiquitous-language.md @@ -0,0 +1,112 @@ +# 유비쿼터스 언어 사전 (Ubiquitous Language Dictionary) + +> 작성일: 2026-02-10 +> 이 문서는 기획, 설계, 코드에서 동일한 용어를 사용하기 위한 약속입니다. +> 코드의 클래스명, 변수명, enum 값은 이 사전의 영문 표현을 따릅니다. + +--- + +## 1. 액터 + +| 한글 | 영문 (코드) | 정의 | 비고 | +|------|------------|------|------| +| 사용자 | User | 회원과 비회원을 포함한 서비스 이용자 | 인증 불필요한 행위의 주체 | +| 회원 | Member | 회원가입을 완료한 사용자 | `member` 테이블, `Member` 엔티티 | +| 관리자 | Admin | 서비스 운영 권한을 가진 내부 사용자 | LDAP 간이 인증 | + +--- + +## 2. 브랜드 컨텍스트 + +| 한글 | 영문 (코드) | 정의 | DB 컬럼/테이블 | +|------|------------|------|---------------| +| 브랜드 | Brand | 상품을 만들고 신뢰를 부여하는 주체 | `brand` 테이블 | +| 브랜드명 | name | 브랜드의 고유 이름 | `brand.name` | +| 브랜드 설명 | description | 브랜드에 대한 소개 | `brand.description` | +| 폐점 | close | 브랜드가 더 이상 상품을 판매하지 않는 상태로 전환 | `brand.closed_at` 세팅 | +| 재입점 | reopen | 폐점된 브랜드가 다시 영업을 시작하는 상태로 전환 | `brand.closed_at` = NULL | +| 폐점일시 | closedAt | 브랜드가 폐점된 시각. NULL이면 영업 중 | `brand.closed_at` | +| 브랜드 삭제 | delete (Brand) | 브랜드를 완전히 제거 (물리 삭제). 상품·좋아요 연쇄 삭제 | `DELETE FROM brand` | + +--- + +## 3. 상품 컨텍스트 + +| 한글 | 영문 (코드) | 정의 | DB 컬럼/테이블 | +|------|------------|------|---------------| +| 상품 | Product | 판매를 위해 진열된 재화 | `product` 테이블 | +| 상품명 | name | 상품의 이름 | `product.name` | +| 상품 설명 | description | 상품에 대한 소개 | `product.description` | +| 가격 | price | 상품의 판매 가격 (정수, 원 단위) | `product.price` | +| 재고 | stock | 현재 판매 가능한 수량 | `product.stock` | +| 재고 차감 | decreaseStock | 주문 수락 시 수량만큼 재고를 줄이는 행위 | `Product.decreaseStock(quantity)` | +| 재고 복원 | restoreStock | 주문 취소 시 수량만큼 재고를 되돌리는 행위 | `Product.restoreStock(quantity)` | +| 브랜드 소속 | brandId | 상품이 소속된 브랜드의 식별자. 생성 후 변경 불가 | `product.brand_id` FK | +| 상품 삭제 | delete (Product) | 상품을 완전히 제거 (물리 삭제). 좋아요 연쇄 삭제 | `DELETE FROM product` | + +--- + +## 4. 좋아요 컨텍스트 + +| 한글 | 영문 (코드) | 정의 | DB 컬럼/테이블 | +|------|------------|------|---------------| +| 좋아요 | ProductLike | 회원이 특정 상품에 표현한 관심의 기록 | `product_like` 테이블 | +| 좋아요 토글 | toggleLike | 좋아요가 없으면 등록, 있으면 취소하는 행위 | `POST /api/v1/products/{id}/likes` | +| 좋아요 상태 | liked | 현재 좋아요 여부 (true/false) | API 응답 필드 | + +**주의**: "좋아요"는 기획서에서는 한글, 코드에서는 `ProductLike`로 통일. +`Like`는 SQL 예약어이므로 단독 사용을 피한다. + +--- + +## 5. 주문 컨텍스트 + +| 한글 | 영문 (코드) | 정의 | DB 컬럼/테이블 | +|------|------------|------|---------------| +| 주문 | Order | 회원이 상품 구매를 위해 서비스에 제출하는 요청서 | `orders` 테이블 | +| 주문 상태 | OrderStatus | 주문의 현재 생명주기 단계 | `orders.status` | +| 주문 요청 | REQUESTED | 주문이 접수되어 재고 확인 중인 상태 | enum 값 | +| 주문 수락 | ACCEPTED | 재고 확인 완료, 재고 차감됨 | enum 값 | +| 주문 거절 | REJECTED | 재고 부족으로 주문이 거절됨 | enum 값 | +| 주문 취소 | CANCELLED | 회원이 수락된 주문을 취소함, 재고 복원됨 | enum 값 | +| 주문일시 | orderedAt | 주문이 생성된 시각 | `orders.ordered_at` | +| 주문 라인 스냅샷 | OrderLineSnapshot | 주문 시점에 캡처된 상품의 불변 사본 | `order_line_snapshot` 테이블 | +| 주문 수량 | quantity | 특정 상품을 몇 개 주문했는지 | `order_line_snapshot.quantity` | + +**주의**: 테이블명은 `orders` (ORDER는 SQL 예약어). + +--- + +## 6. 주문 상태 전이 규칙 + +``` +상태 전이가 허용되는 경로만 아래에 기재한다. +이 외의 전이는 모두 비즈니스 규칙 위반이다. + +REQUESTED → ACCEPTED (재고 충분) +REQUESTED → REJECTED (재고 부족) +ACCEPTED → CANCELLED (회원 취소 요청) +``` + +--- + +## 7. 인증 헤더 + +| 용도 | 헤더명 | 값 | 사용 주체 | +|------|--------|---|----------| +| 회원 인증 (ID) | `X-Loopers-LoginId` | 회원 로그인 ID | 회원 | +| 회원 인증 (PW) | `X-Loopers-LoginPw` | 회원 비밀번호 | 회원 | +| 관리자 인증 | `X-Loopers-Ldap` | 관리자 식별 값 | 관리자 | + +--- + +## 8. 용어 혼동 방지 + +| 혼동하기 쉬운 표현 | 올바른 용어 | 이유 | +|-------------------|-----------|------| +| 상품 비활성화 | 브랜드 폐점 | 상품 자체에 비활성 플래그가 없음. 브랜드의 closedAt으로 판단 | +| 소프트 삭제 | 해당 없음 | 이 프로젝트에서 브랜드/상품/좋아요는 물리 삭제만 사용 | +| 장바구니 | 해당 없음 | 현재 범위에 장바구니 없음. 주문 시 직접 상품 목록 전달 | +| 결제 | 해당 없음 (미구현) | Payment BC로 확장 예정. 현재 주문 상태에 결제 관련 값 없음 | +| 삭제 (브랜드) | 물리 삭제 + 연쇄 | "삭제"는 영구 제거를 의미. 복원 불가. 폐점과 구분 필수 | +| 삭제 (상품) | 물리 삭제 + 좋아요 연쇄 | 상품 단독 삭제 시에도 좋아요 함께 제거 | diff --git a/docs/design/base/user-story.md b/docs/design/base/user-story.md new file mode 100644 index 00000000..7e98c772 --- /dev/null +++ b/docs/design/base/user-story.md @@ -0,0 +1,400 @@ +# 유저 스토리 & 유스케이스 + +> 작성일: 2026-02-10 +> 도메인 정의서(v2) 기반 + +--- + +## 1. 액터 정의 + +| 액터 | 정의 | 인증 방식 | 주요 행위 | +|------|------|----------|----------| +| **사용자 (User)** | 회원/비회원 포함 서비스 이용자 | 없음 | 상품 탐색, 브랜드 조회 | +| **회원 (Member)** | 회원가입을 완료한 사용자 | `X-Loopers-LoginId` + `X-Loopers-LoginPw` | 좋아요, 주문, 주문 취소 | +| **관리자 (Admin)** | 서비스 운영 권한을 가진 내부 사용자 | `X-Loopers-Ldap` | 브랜드/상품 CRUD, 주문 모니터링 | + +--- + +## 2. 유저 스토리 + +### 2.1 상품 탐색 (사용자) + +#### US-01: 상품 목록 조회 +- **As a** 사용자 +- **I want to** 등록된 상품 목록을 페이지 단위로 조회한다 +- **So that** 구매하고 싶은 상품을 찾을 수 있다 + +**인수 조건:** +- 폐점된 브랜드(`closed_at IS NOT NULL`)의 상품은 목록에 노출되지 않는다 +- 페이징이 적용된다 (page, size 파라미터) +- 브랜드 ID로 필터링할 수 있다 (선택) + +**예외:** +- 등록된 상품이 없으면 빈 목록을 반환한다 + +--- + +#### US-02: 상품 상세 조회 +- **As a** 사용자 +- **I want to** 특정 상품의 상세 정보를 조회한다 +- **So that** 상품의 이름, 설명, 가격, 재고, 브랜드 정보를 확인할 수 있다 + +**인수 조건:** +- 상품 정보에 소속 브랜드의 이름과 설명이 포함된다 +- 폐점 브랜드의 상품은 조회할 수 없다 + +**예외:** +- 존재하지 않는 상품 ID → 404 Not Found +- 폐점 브랜드의 상품 → 404 Not Found (사용자에게는 "없는 상품"과 동일하게 처리) + +--- + +#### US-03: 브랜드 목록 조회 +- **As a** 사용자 +- **I want to** 영업 중인 브랜드 목록을 조회한다 +- **So that** 관심 있는 브랜드를 찾고, 해당 브랜드의 상품을 탐색할 수 있다 + +**인수 조건:** +- 폐점 브랜드(`closed_at IS NOT NULL`)는 목록에 노출되지 않는다 + +**예외:** +- 영업 중인 브랜드가 없으면 빈 목록을 반환한다 + +--- + +#### US-04: 브랜드 상세 조회 +- **As a** 사용자 +- **I want to** 특정 브랜드의 상세 정보를 조회한다 +- **So that** 브랜드의 이름과 설명을 확인할 수 있다 + +**예외:** +- 존재하지 않는 브랜드 ID → 404 Not Found +- 폐점 브랜드 → 404 Not Found + +--- + +#### US-05: 브랜드별 상품 목록 조회 +- **As a** 사용자 +- **I want to** 특정 브랜드에 소속된 상품 목록만 조회한다 +- **So that** 좋아하는 브랜드의 상품만 모아 볼 수 있다 + +**인수 조건:** +- 해당 브랜드가 영업 중이어야 조회 가능하다 +- 페이징이 적용된다 + +**예외:** +- 폐점 브랜드 → 404 Not Found +- 해당 브랜드에 상품이 없으면 빈 목록을 반환한다 + +--- + +### 2.2 좋아요 (회원) + +#### US-06: 좋아요 토글 +- **As a** 회원 +- **I want to** 상품에 좋아요를 등록하거나 취소한다 (토글) +- **So that** 관심 있는 상품을 표시하고, 나중에 다시 찾을 수 있다 + +**인수 조건:** +- 좋아요가 없는 상태에서 요청하면 등록, 있는 상태에서 요청하면 취소 +- 응답에 현재 좋아요 상태(`liked: true/false`)를 포함한다 +- 폐점 브랜드의 상품에는 좋아요를 등록할 수 없다 + +**예외:** +- 비회원 요청 → 401 Unauthorized +- 존재하지 않는 상품 → 404 Not Found +- 폐점 브랜드 상품 → 400 Bad Request + +--- + +#### US-07: 내 좋아요 목록 조회 +- **As a** 회원 +- **I want to** 내가 좋아요한 상품 목록을 조회한다 +- **So that** 관심 상품을 한눈에 보고 구매를 결정할 수 있다 + +**인수 조건:** +- 삭제된 상품의 좋아요는 목록에서 제외한다 (LEFT JOIN 필터링) +- 폐점 브랜드 상품의 좋아요 노출 정책: 제외 (폐점 브랜드 상품은 비노출) +- 페이징이 적용된다 + +**예외:** +- 비회원 요청 → 401 Unauthorized +- 좋아요한 상품이 없으면 빈 목록을 반환한다 + +--- + +### 2.3 주문 (회원) + +#### US-08: 주문 요청 +- **As a** 회원 +- **I want to** 여러 상품을 수량과 함께 지정하여 주문을 요청한다 +- **So that** 원하는 상품을 구매할 수 있다 + +**인수 조건:** +- 주문 요청: `[{productId, quantity}, ...]` 형태의 상품 목록을 전달한다 +- 각 상품에 대해 비관적 락(`SELECT FOR UPDATE`)으로 재고를 확인한다 +- 모든 상품의 재고가 충분하면: + - 각 상품의 재고를 수량만큼 차감한다 + - 주문 시점의 상품 정보(이름, 설명, 가격, 브랜드명, 수량)를 스냅샷으로 저장한다 + - 주문 상태를 ACCEPTED로 저장한다 +- 하나라도 재고가 부족하면: + - 전체 트랜잭션을 롤백한다 (이미 차감한 재고도 원복) + - 주문 상태를 REJECTED로 저장한다 +- 전체가 하나의 트랜잭션이다 (all-or-nothing) +- **데드락 방지**: 재고 차감 시 productId 오름차순으로 정렬하여 비관적 락을 획득한다 + +**예외:** +- 비회원 요청 → 401 Unauthorized +- 존재하지 않는 상품 ID 포함 → 404 Not Found +- 폐점 브랜드 상품 포함 → 400 Bad Request (폐점 브랜드 주문 불가) +- 수량이 0 이하 → 400 Bad Request +- 주문 상품 목록이 비어있음 → 400 Bad Request +- 재고 부족 → 400 Bad Request (**어떤 상품이 부족한지 구체적으로 응답**에 포함: productId, productName, requestedQuantity, availableStock) + +**열린 결정사항:** +- REJECTED 주문에 스냅샷을 저장할 것인지 여부 (저장: 사용자가 거절 이력 확인 가능 / 미저장: 스냅샷 = 계약 성립의 증거라는 본질 유지) + +--- + +#### US-09: 내 주문 목록 조회 +- **As a** 회원 +- **I want to** 내 주문 목록을 날짜 기간으로 필터링하여 조회한다 +- **So that** 특정 기간의 주문 내역을 확인할 수 있다 + +**인수 조건:** +- 시작일(startDate), 종료일(endDate) 파라미터로 필터링한다 +- 주문일시(`ordered_at`) 기준으로 필터링한다 +- 본인의 주문만 조회된다 +- 페이징이 적용된다 + +**예외:** +- 비회원 요청 → 401 Unauthorized +- 시작일이 종료일보다 뒤 → 400 Bad Request +- 해당 기간에 주문이 없으면 빈 목록 반환 + +--- + +#### US-10: 주문 상세 조회 +- **As a** 회원 +- **I want to** 특정 주문의 상세 내역을 확인한다 +- **So that** 주문한 상품, 수량, 당시 가격, 주문 상태를 확인할 수 있다 + +**인수 조건:** +- 주문 정보 + 스냅샷 목록이 함께 조회된다 +- 본인의 주문만 조회 가능하다 + +**예외:** +- 비회원 요청 → 401 Unauthorized +- 존재하지 않는 주문 ID → 404 Not Found +- 타인의 주문 → 403 Forbidden + +--- + +#### US-11: 주문 취소 +- **As a** 회원 +- **I want to** 수락된 주문을 취소한다 +- **So that** 구매를 철회하고 재고가 복원된다 + +**인수 조건:** +- ACCEPTED 상태의 주문만 취소할 수 있다 +- 취소 시 스냅샷에 기록된 수량만큼 각 상품의 재고를 복원한다 +- **원본 상품이 이미 삭제된 경우, 해당 상품의 재고 복원은 건너뛴다** (주문 상태는 정상적으로 CANCELLED로 변경) +- 주문 상태가 CANCELLED로 변경된다 + +**예외:** +- 비회원 요청 → 401 Unauthorized +- REJECTED/CANCELLED 상태 주문 취소 시도 → 400 Bad Request +- 타인의 주문 취소 시도 → 403 Forbidden + +--- + +### 2.4 관리자 — 브랜드 관리 + +#### US-A01: 브랜드 목록 조회 +- **As a** 관리자 +- **I want to** 전체 브랜드 목록을 조회한다 (폐점 포함) +- **So that** 등록된 브랜드 현황을 파악할 수 있다 + +**인수 조건:** +- 폐점 브랜드도 포함하여 조회된다 (사용자 조회와 다름) +- 페이징이 적용된다 + +--- + +#### US-A02: 브랜드 상세 조회 +- **As a** 관리자 +- **I want to** 특정 브랜드의 상세 정보를 조회한다 + +**인수 조건:** +- 폐점 상태(`closedAt`) 정보가 포함된다 + +--- + +#### US-A03: 브랜드 등록 +- **As a** 관리자 +- **I want to** 새로운 브랜드를 등록한다 + +**인수 조건:** +- 이름, 설명을 입력하여 등록한다 +- 등록 시 `closedAt`은 NULL (영업 중 상태) + +**예외:** +- 이름이 비어있음 → 400 Bad Request + +--- + +#### US-A04: 브랜드 수정 +- **As a** 관리자 +- **I want to** 브랜드의 이름, 설명을 수정한다 + +**예외:** +- 존재하지 않는 브랜드 → 404 Not Found + +--- + +#### US-A05: 브랜드 삭제 (연쇄) +- **As a** 관리자 +- **I want to** 브랜드를 삭제한다 +- **So that** 잘못 등록된 브랜드를 완전히 제거할 수 있다 + +**인수 조건:** +- 브랜드 물리 삭제 +- 소속 상품 물리 삭제 (연쇄) +- 해당 상품의 좋아요 물리 삭제 (연쇄) +- 삭제 순서: 좋아요 → 상품 → 브랜드 (FK 역순) +- 기존 주문의 스냅샷은 영향 없음 + +**예외:** +- 존재하지 않는 브랜드 → 404 Not Found + +--- + +#### US-A06: 브랜드 폐점 +- **As a** 관리자 +- **I want to** 브랜드를 폐점 처리한다 +- **So that** 해당 브랜드의 상품이 사용자에게 노출되지 않는다 + +**인수 조건:** +- `closedAt`에 현재 시각을 세팅한다 +- 소속 상품 데이터는 보존되며, 사용자 조회에서 자동 비노출된다 +- 좋아요 데이터도 보존된다 + +**예외:** +- 이미 폐점 상태 → 400 Bad Request (멱등성 vs 에러 — 정책 결정 필요) + +--- + +#### US-A07: 브랜드 재입점 +- **As a** 관리자 +- **I want to** 폐점된 브랜드를 재입점 처리한다 +- **So that** 해당 브랜드의 상품이 다시 사용자에게 노출된다 + +**인수 조건:** +- `closedAt`을 NULL로 되돌린다 +- 보존된 상품과 좋아요가 자동 복원된다 + +**예외:** +- 이미 영업 중 → 400 Bad Request + +--- + +### 2.5 관리자 — 상품 관리 + +#### US-A08: 상품 목록 조회 +- **As a** 관리자 +- **I want to** 전체 상품 목록을 조회한다 +- **So that** 등록된 상품 현황을 파악할 수 있다 + +**인수 조건:** +- 폐점 브랜드 상품도 포함하여 조회된다 +- 브랜드 ID로 필터링 가능 (선택) +- 페이징이 적용된다 + +--- + +#### US-A09: 상품 상세 조회 +- **As a** 관리자 +- **I want to** 특정 상품의 상세 정보를 조회한다 + +--- + +#### US-A10: 상품 등록 +- **As a** 관리자 +- **I want to** 새로운 상품을 등록한다 + +**인수 조건:** +- 이름, 설명, 가격, 재고, 브랜드ID를 입력한다 +- 브랜드가 존재해야 한다 (존재 검증) +- 폐점 브랜드에는 상품을 등록할 수 없다 + +**예외:** +- 존재하지 않는 브랜드 ID → 404 Not Found +- 폐점 브랜드 → 400 Bad Request +- 가격이 0 이하 → 400 Bad Request +- 재고가 0 미만 → 400 Bad Request + +--- + +#### US-A11: 상품 수정 +- **As a** 관리자 +- **I want to** 상품의 이름, 설명, 가격, 재고를 수정한다 + +**인수 조건:** +- **브랜드는 변경할 수 없다** (불변) + +**예외:** +- 존재하지 않는 상품 → 404 Not Found +- 브랜드 변경 시도 → 400 Bad Request + +--- + +#### US-A12: 상품 삭제 +- **As a** 관리자 +- **I want to** 상품을 삭제한다 + +**인수 조건:** +- 상품 물리 삭제 +- 해당 상품의 좋아요도 물리 삭제 (연쇄) +- 기존 주문 스냅샷은 영향 없음 + +**예외:** +- 존재하지 않는 상품 → 404 Not Found + +--- + +### 2.6 관리자 — 주문 모니터링 + +#### US-A13: 주문 목록 조회 +- **As a** 관리자 +- **I want to** 전체 주문 목록을 조회한다 (읽기 전용) + +**인수 조건:** +- 모든 회원의 주문이 조회된다 +- 페이징이 적용된다 + +--- + +#### US-A14: 주문 상세 조회 +- **As a** 관리자 +- **I want to** 특정 주문의 상세 내역을 조회한다 (읽기 전용) + +--- + +## 3. 주문 상태 전이 모델 + +``` +REQUESTED ──(재고 충분)──→ ACCEPTED ──(회원 취소 요청)──→ CANCELLED + │ (재고 복원) + └──(재고 부족)──→ REJECTED +``` + +| 전이 | 조건 | 부수 효과 | +|------|------|----------| +| REQUESTED → ACCEPTED | 모든 상품의 재고 >= 주문 수량 | 수량만큼 재고 차감, 스냅샷 저장 | +| REQUESTED → REJECTED | 하나 이상의 상품 재고 부족 | 전체 롤백 (차감 없음) | +| ACCEPTED → CANCELLED | 회원의 취소 요청 | 스냅샷 기준 수량만큼 재고 복원 | + +**불가능한 전이:** +- REJECTED → 어떤 상태로든 전이 불가 (최종 상태) +- CANCELLED → 어떤 상태로든 전이 불가 (최종 상태) +- ACCEPTED → REJECTED (논리적으로 불가)