From 8f91639c6fb407d6990635b515fcf9d324699793 Mon Sep 17 00:00:00 2001 From: letter333 Date: Sun, 1 Feb 2026 20:43:32 +0900 Subject: [PATCH 01/22] Add CLAUDE.md for project documentation Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 142 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..69441686 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,142 @@ +# CLAUDE.md + +## 프로젝트 개요 + +Spring Boot 기반 커머스 멀티모듈 템플릿 프로젝트 (`loopers-java-spring-template`). +REST API, 배치 처리, 이벤트 스트리밍을 위한 마이크로서비스 아키텍처 패턴을 제공한다. + +## 기술 스택 및 버전 + +| 구분 | 기술 | 버전 | +|------|------|------| +| Language | Java | 21 | +| Language | Kotlin | 2.0.20 | +| Framework | Spring Boot | 3.4.4 | +| Framework | Spring Cloud | 2024.0.1 | +| Dependency Mgmt | spring-dependency-management | 1.1.7 | +| Database | MySQL | 8.0 | +| ORM | Spring Data JPA + QueryDSL | (Spring 관리) | +| Cache | Redis (Master-Replica) | 7.0 | +| Messaging | Apache Kafka (KRaft) | 3.5.1 | +| API Docs | SpringDoc OpenAPI | 2.7.0 | +| Monitoring | Micrometer + Prometheus | (Spring 관리) | +| Tracing | Micrometer Brave | (Spring 관리) | +| Logging | Logback + Slack Appender | 1.6.1 | +| Testing | JUnit 5, Mockito 5.14.0, SpringMockK 4.0.2, Instancio 5.0.2 | +| Testing Infra | TestContainers (MySQL, Redis, Kafka) | (Spring 관리) | +| Code Coverage | JaCoCo | (Gradle 관리) | +| Build Tool | Gradle (Kotlin DSL) | Wrapper 포함 | + +## 모듈 구조 + +``` +root +├── apps/ # 실행 가능한 Spring Boot 애플리케이션 +│ ├── commerce-api # REST API 서버 (Tomcat) +│ ├── commerce-batch # Spring Batch 배치 처리 +│ └── commerce-streamer # Kafka Consumer 스트리밍 서비스 +├── modules/ # 재사용 가능한 인프라 모듈 (java-library) +│ ├── jpa # JPA + MySQL + QueryDSL + HikariCP +│ ├── redis # Redis Master-Replica (Lettuce) +│ └── kafka # Kafka Producer/Consumer 설정 +├── supports/ # 횡단 관심사 모듈 +│ ├── jackson # Jackson ObjectMapper 커스터마이징 +│ ├── logging # 구조화 로깅 + Slack 연동 +│ └── monitoring # Prometheus 메트릭 + Health Probe +└── docker/ + ├── infra-compose.yml # MySQL, Redis, Kafka, Kafka UI + └── monitoring-compose.yml # Prometheus, Grafana +``` + +## 아키텍처 레이어 (commerce-api 기준) + +``` +interfaces/api/ → REST Controller, DTO, ApiSpec +application/ → Facade (유즈케이스 오케스트레이션), Info DTO +domain/ → Entity, Repository 인터페이스, Service (비즈니스 로직) +infrastructure/ → Repository 구현체 +support/error/ → CoreException, ErrorType +``` + +## 빌드 및 실행 + +```bash +# 인프라 구동 +docker compose -f docker/infra-compose.yml up -d + +# 모니터링 스택 (Grafana: localhost:3000, admin/admin) +docker compose -f docker/monitoring-compose.yml up -d + +# 빌드 +./gradlew clean build + +# 실행 +./gradlew :apps:commerce-api:bootRun +./gradlew :apps:commerce-batch:bootRun --args='--job.name=demoJob' +./gradlew :apps:commerce-streamer:bootRun + +# 테스트 +./gradlew test + +# 코드 커버리지 +./gradlew test jacocoTestReport +``` + +## 테스트 설정 + +- JUnit 5 기반, 테스트 프로파일: `test`, 타임존: `Asia/Seoul` +- TestContainers로 MySQL/Redis/Kafka 통합 테스트 +- 모듈별 `testFixtures`로 테스트 유틸리티 공유 (`DatabaseCleanUp`, `RedisCleanUp` 등) +- 테스트 병렬 실행 없음 (`maxParallelForks = 1`) + +## 프로파일 + +`local`, `test`, `dev`, `qa`, `prd` — 환경별 설정은 각 모듈의 yml 파일에서 프로파일 그룹으로 관리. +운영 환경은 환경변수로 주입: `MYSQL_HOST`, `REDIS_MASTER_HOST`, `BOOTSTRAP_SERVERS` 등. + +## 주요 패턴 + +- **BaseEntity**: ID 자동생성, `createdAt`/`updatedAt` 감사, `deletedAt` 소프트 삭제 +- **ApiResponse**: 통일된 응답 래퍼 (`meta.result`, `meta.errorCode`, `data`) +- **CoreException + ErrorType**: 타입 기반 에러 처리 (400, 404, 409, 500) +- **별도 관리 포트**: 메트릭/헬스체크는 8081 포트로 분리 +- **Kafka 배치 소비**: 3000건 배치, 수동 커밋 (Manual ACK) +- **Redis 읽기 분산**: Master 쓰기, Replica 읽기 분리 + +## 개발 규칙 +### 진행 Workflow - 증강 코딩 +- **대원칙** : 방향성 및 주요 의사 결정은 개발자에게 제안만 할 수 있으며, 최종 승인된 사항을 기반으로 작업을 수행. +- **중간 결과 보고** : AI 가 반복적인 동작을 하거나, 요청하지 않은 기능을 구현, 테스트 삭제를 임의로 진행할 경우 개발자가 개입. +- **설계 주도권 유지** : AI 가 임의판단을 하지 않고, 방향성에 대한 제안 등을 진행할 수 있으나 개발자의 승인을 받은 후 수행. + +### 개발 Workflow - TDD (Red > Green > Refactor) +- 모든 테스트는 3A 원칙으로 작성할 것 (Arrange - Act - Assert) +#### 1. Red Phase : 실패하는 테스트 먼저 작성 +- 요구사항을 만족하는 기능 테스트 케이스 작성 +- 테스트 예시 +#### 2. Green Phase : 테스트를 통과하는 코드 작성 +- Red Phase 의 테스트가 모두 통과할 수 있는 코드 작성 +- 오버엔지니어링 금지 +#### 3. Refactor Phase : 불필요한 코드 제거 및 품질 개선 +- 불필요한 private 함수 지양, 객체지향적 코드 작성 +- unused import 제거 +- 성능 최적화 +- 모든 테스트 케이스가 통과해야 함 + +## 주의사항 +### 1. Never Do +- 실제 동작하지 않는 코드, 불필요한 Mock 데이터를 이용한 구현을 하지 말 것 +- null-safety 하지 않게 코드 작성하지 말 것 (Java 의 경우, Optional 을 활용할 것) +- println 코드 남기지 말 것 + +### 2. Recommendation +- 실제 API 를 호출해 확인하는 E2E 테스트 코드 작성 +- 재사용 가능한 객체 설계 +- 성능 최적화에 대한 대 안 및 제안 +- 개발 완료된 API 의 경우, `.http/**.http` 에 분류해 작성 + +### 3. Priority +1. 실제 동작하는 해결책만 고려 +2. null-safety, thread-safety 고려 +3. 테스트 가능한 구조로 설계 +4. 기존 코드 패턴 분석 후 일관성 유지 \ No newline at end of file From f0a5513ca00b883abd1419a5681320f276c689fd Mon Sep 17 00:00:00 2001 From: letter333 Date: Mon, 2 Feb 2026 20:18:28 +0900 Subject: [PATCH 02/22] Add member signup design documents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - member-erd.md: 회원 테이블 ERD 설계 - member-signup-design.md: 시퀀스/클래스 다이어그램, 패키지 구조 - CLAUDE.md: 개발 규칙 및 문서 작성 가이드 반영 Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 7 +- docs/member-erd.md | 34 +++++ docs/member-signup-design.md | 262 +++++++++++++++++++++++++++++++++++ 3 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 docs/member-erd.md create mode 100644 docs/member-signup-design.md diff --git a/CLAUDE.md b/CLAUDE.md index 69441686..2d00fb8e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,6 +103,10 @@ docker compose -f docker/monitoring-compose.yml up -d - **Kafka 배치 소비**: 3000건 배치, 수동 커밋 (Manual ACK) - **Redis 읽기 분산**: Master 쓰기, Replica 읽기 분리 +## 문서 작성 +### 다이어그램 작성 +- ERD, 시퀀스 다이어그램, 클래스 다이어그램 등 작성 시 mermaid를 이용한 마크다운으로 작성. + ## 개발 규칙 ### 진행 Workflow - 증강 코딩 - **대원칙** : 방향성 및 주요 의사 결정은 개발자에게 제안만 할 수 있으며, 최종 승인된 사항을 기반으로 작업을 수행. @@ -132,8 +136,9 @@ docker compose -f docker/monitoring-compose.yml up -d ### 2. Recommendation - 실제 API 를 호출해 확인하는 E2E 테스트 코드 작성 - 재사용 가능한 객체 설계 -- 성능 최적화에 대한 대 안 및 제안 +- 성능 최적화에 대한 대안 및 제안 - 개발 완료된 API 의 경우, `.http/**.http` 에 분류해 작성 +- Domain Entity와 Persistence Entity는 구분하여 구현 ### 3. Priority 1. 실제 동작하는 해결책만 고려 diff --git a/docs/member-erd.md b/docs/member-erd.md new file mode 100644 index 00000000..b69baed5 --- /dev/null +++ b/docs/member-erd.md @@ -0,0 +1,34 @@ +# Member ERD + +## 회원 테이블 설계 + +```mermaid +erDiagram + MEMBER { + BIGINT id PK "AUTO_INCREMENT" + VARCHAR(30) login_id UK "NOT NULL, 로그인 ID" + VARCHAR(255) password "NOT NULL, 암호화 저장" + VARCHAR(50) name "NOT NULL, 이름" + DATE birthday "NOT NULL, 생년월일" + VARCHAR(100) email UK "NOT NULL, 이메일" + DATETIME created_at "NOT NULL, 생성일시" + DATETIME updated_at "NOT NULL, 수정일시" + DATETIME deleted_at "NULLABLE, 삭제일시" + } +``` + +## 제약조건 + +| 제약조건 | 대상 컬럼 | 설명 | +|----------|-----------|------| +| PRIMARY KEY | `id` | AUTO_INCREMENT | +| UNIQUE | `login_id` | 로그인 ID 중복 방지 | +| UNIQUE | `email` | 이메일 중복 방지 | +| NOT NULL | `login_id`, `password`, `name`, `birthday`, `email` | 필수 입력 | + +## 비고 + +- `password`는 BCrypt 등으로 암호화하여 저장 (VARCHAR 255) +- `birthday`는 `LocalDate` 매핑 (시분초 불필요) +- `created_at`, `updated_at`, `deleted_at`은 `BaseEntity`에서 자동 관리 +- `deleted_at`을 통한 소프트 삭제 지원 diff --git a/docs/member-signup-design.md b/docs/member-signup-design.md new file mode 100644 index 00000000..384f9a4e --- /dev/null +++ b/docs/member-signup-design.md @@ -0,0 +1,262 @@ +# 회원가입 기능 설계 + +## 검증 규칙 + +| 필드 | 규칙 | +|------|------| +| loginId | NOT NULL, NOT BLANK | +| password | 8~16자, 영문 대소문자 + 숫자 + 특수문자만 허용, 생년월일(yyyyMMdd) 포함 불가 | +| name | NOT NULL, NOT BLANK, 한글 2~20자 | +| birthday | NOT NULL, `yyyy-MM-dd` 형식, 미래 날짜 불가 | +| email | NOT NULL, 이메일 형식 (RFC 5322) | + +## 시퀀스 다이어그램 + +```mermaid +sequenceDiagram + actor Client + participant Controller as MemberV1Controller + participant Facade as MemberFacade + participant Service as MemberService + participant Member as Member (Domain) + participant Repository as MemberRepository + participant RepoImpl as MemberRepositoryImpl + participant Entity as MemberEntity (Persistence) + participant JpaRepo as MemberJpaRepository + participant PasswordEncoder as PasswordEncoder + + Client->>Controller: POST /api/v1/members (SignUpRequest) + Controller->>Facade: signUp(request) + Facade->>Service: signUp(loginId, password, name, birthday, email) + + Note over Service: 1. loginId 중복 검사 + Service->>Repository: existsByLoginId(loginId) + Repository->>RepoImpl: existsByLoginId(loginId) + RepoImpl->>JpaRepo: existsByLoginId(loginId) + JpaRepo-->>RepoImpl: boolean + RepoImpl-->>Service: boolean + + alt loginId 중복 + Service-->>Controller: CoreException (CONFLICT) + Controller-->>Client: 409 Conflict + end + + Note over Service: 2. email 중복 검사 + Service->>Repository: existsByEmail(email) + Repository->>RepoImpl: existsByEmail(email) + RepoImpl->>JpaRepo: existsByEmail(email) + JpaRepo-->>RepoImpl: boolean + RepoImpl-->>Service: boolean + + alt email 중복 + Service-->>Controller: CoreException (CONFLICT) + Controller-->>Client: 409 Conflict + end + + Note over Service: 3. 도메인 객체 생성 (필드 검증은 생성자에서 수행) + Service->>Member: new Member(loginId, rawPassword, name, birthday, email) + Note over Member: 이름 형식 검증 (한글 2~20자)
생년월일 형식 검증 (미래 날짜 불가)
이메일 형식 검증
비밀번호 규칙 검증
(8~16자, 문자종류, 생년월일 포함 여부) + + alt 검증 실패 + Member-->>Service: CoreException (BAD_REQUEST) + Service-->>Controller: CoreException (BAD_REQUEST) + Controller-->>Client: 400 Bad Request + end + + Note over Service: 4. 비밀번호 암호화 + Service->>PasswordEncoder: encode(rawPassword) + PasswordEncoder-->>Service: encodedPassword + Service->>Member: encryptPassword(encodedPassword) + + Note over Service: 5. 저장 + Service->>Repository: save(member) + Repository->>RepoImpl: save(member) + Note over RepoImpl: Domain → Persistence 변환 + RepoImpl->>Entity: MemberEntity.from(member) + RepoImpl->>JpaRepo: save(memberEntity) + JpaRepo-->>RepoImpl: MemberEntity + Note over RepoImpl: Persistence → Domain 변환 + RepoImpl->>Entity: toDomain() + RepoImpl-->>Service: Member + + Service-->>Facade: Member + Note over Facade: Domain → Info 변환 + Facade-->>Controller: MemberInfo + Controller-->>Client: 201 Created (SignUpResponse) +``` + +## 클래스 다이어그램 + +```mermaid +classDiagram + direction TB + + %% === Interfaces Layer === + class MemberV1Controller { + -MemberFacade memberFacade + +signUp(SignUpRequest) ApiResponse~SignUpResponse~ + } + + class MemberV1ApiSpec { + <> + +signUp(SignUpRequest) ApiResponse~SignUpResponse~ + } + + class MemberV1Dto { + <> + } + + class SignUpRequest { + <> + +String loginId + +String password + +String name + +String birthday + +String email + } + + class SignUpResponse { + <> + +Long id + +String loginId + +String name + +String email + +from(MemberInfo) SignUpResponse + } + + %% === Application Layer === + class MemberFacade { + -MemberService memberService + +signUp(SignUpRequest) MemberInfo + } + + class MemberInfo { + <> + +Long id + +String loginId + +String name + +String email + +from(Member) MemberInfo + } + + %% === Domain Layer === + class Member { + <> + -Long id + -String loginId + -String password + -String name + -LocalDate birthday + -String email + +Member(loginId, rawPassword, name, birthday, email) + +encryptPassword(encodedPassword) + -validateLoginId(loginId) + -validatePassword(rawPassword, birthday) + -validateName(name) + -validateBirthday(birthday) + -validateEmail(email) + } + + class MemberRepository { + <> + +save(Member) Member + +existsByLoginId(String) boolean + +existsByEmail(String) boolean + } + + class MemberService { + -MemberRepository memberRepository + -PasswordEncoder passwordEncoder + +signUp(loginId, password, name, birthday, email) Member + } + + %% === Infrastructure Layer === + class MemberEntity { + <> + -String loginId + -String password + -String name + -LocalDate birthday + -String email + +from(Member)$ MemberEntity + +toDomain() Member + } + + class BaseEntity { + <> + -Long id + -ZonedDateTime createdAt + -ZonedDateTime updatedAt + -ZonedDateTime deletedAt + } + + class MemberRepositoryImpl { + -MemberJpaRepository memberJpaRepository + +save(Member) Member + +existsByLoginId(String) boolean + +existsByEmail(String) boolean + } + + class MemberJpaRepository { + <> + +existsByLoginId(String) boolean + +existsByEmail(String) boolean + } + + class JpaRepository~T~ { + <> + } + + %% === Relationships === + MemberV1ApiSpec <|.. MemberV1Controller + MemberV1Controller --> MemberFacade + MemberV1Controller ..> MemberV1Dto + MemberV1Dto *-- SignUpRequest + MemberV1Dto *-- SignUpResponse + SignUpResponse ..> MemberInfo + + MemberFacade --> MemberService + MemberFacade ..> MemberInfo + MemberInfo ..> Member + + MemberService --> MemberRepository + MemberService ..> Member + + MemberRepository <|.. MemberRepositoryImpl + MemberRepositoryImpl --> MemberJpaRepository + MemberRepositoryImpl ..> MemberEntity + MemberRepositoryImpl ..> Member + BaseEntity <|-- MemberEntity + JpaRepository~T~ <|-- MemberJpaRepository +``` + +## 패키지 구조 + +``` +com.loopers +├── interfaces/api/member/ +│ ├── MemberV1Controller ← REST 엔드포인트 +│ ├── MemberV1Dto ← SignUpRequest, SignUpResponse (record) +│ └── MemberV1ApiSpec ← Swagger 스펙 인터페이스 +├── application/member/ +│ ├── MemberFacade ← 유즈케이스 오케스트레이션 +│ └── MemberInfo ← 응답 변환용 record +├── domain/member/ +│ ├── Member ← 도메인 엔티티 (순수 Java 객체, 검증 로직 포함) +│ ├── MemberRepository ← 도메인 레포지토리 인터페이스 +│ └── MemberService ← 비즈니스 로직 (중복 검사, 암호화 위임) +└── infrastructure/member/ + ├── MemberEntity ← JPA 영속성 엔티티 (BaseEntity 상속) + ├── MemberRepositoryImpl ← 레포지토리 구현체 (Domain ↔ Entity 변환) + └── MemberJpaRepository ← Spring Data JPA 인터페이스 +``` + +## Domain Entity vs Persistence Entity 분리 + +| 구분 | Member (Domain) | MemberEntity (Persistence) | +|------|-----------------|---------------------------| +| 위치 | `domain/member/` | `infrastructure/member/` | +| 역할 | 비즈니스 검증 로직 | DB 영속화 | +| 상속 | 없음 (순수 Java 객체) | `BaseEntity` 상속 | +| JPA 어노테이션 | 없음 | `@Entity`, `@Table`, `@Column` | +| 변환 | - | `from(Member)`, `toDomain()` | From 050dc5b89485a2f0b907b12980911d765e7effa3 Mon Sep 17 00:00:00 2001 From: letter333 Date: Mon, 2 Feb 2026 20:52:37 +0900 Subject: [PATCH 03/22] feat: add Member domain entity with field validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Member 도메인 객체 구현 (순수 Java, JPA 어노테이션 없음) - 필드 검증: loginId, password, name, birthday, email - 비밀번호 규칙: 8~16자, 영문+숫자+특수문자, 생년월일 포함 불가 - encryptPassword()로 암호화된 비밀번호 교체 지원 test: add MemberTest with 14 unit test cases Co-Authored-By: Claude Opus 4.5 --- .../com/loopers/domain/member/Member.java | 116 ++++++++ .../com/loopers/domain/member/MemberTest.java | 252 ++++++++++++++++++ 2 files changed, 368 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java new file mode 100644 index 00000000..b73d6606 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java @@ -0,0 +1,116 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.regex.Pattern; + +public class Member { + + private static final Pattern PASSWORD_PATTERN = Pattern.compile("^[a-zA-Z0-9!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?`~]+$"); + private static final Pattern NAME_PATTERN = Pattern.compile("^[가-힣]{2,20}$"); + private static final Pattern EMAIL_PATTERN = Pattern.compile("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"); + private static final DateTimeFormatter BIRTHDAY_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + + private Long id; + private String loginId; + private String password; + private String name; + private LocalDate birthday; + private String email; + + public Member(String loginId, String password, String name, LocalDate birthday, String email) { + validateLoginId(loginId); + validateBirthday(birthday); + validatePassword(password, birthday); + validateName(name); + validateEmail(email); + + this.loginId = loginId; + this.password = password; + this.name = name; + this.birthday = birthday; + this.email = email; + } + + Member(Long id, String loginId, String password, String name, LocalDate birthday, String email) { + this.id = id; + this.loginId = loginId; + this.password = password; + this.name = name; + this.birthday = birthday; + this.email = email; + } + + public void encryptPassword(String encodedPassword) { + this.password = encodedPassword; + } + + private void validateLoginId(String loginId) { + if (loginId == null || loginId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 비어있을 수 없습니다."); + } + } + + private void validatePassword(String password, LocalDate birthday) { + if (password == null || password.length() < 8 || password.length() > 16) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 8~16자여야 합니다."); + } + if (!PASSWORD_PATTERN.matcher(password).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 영문 대소문자, 숫자, 특수문자만 입력 가능합니다."); + } + if (birthday != null) { + String birthdayStr = birthday.format(BIRTHDAY_FORMATTER); + if (password.contains(birthdayStr)) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호에 생년월일을 포함할 수 없습니다."); + } + } + } + + private void validateName(String name) { + if (name == null || !NAME_PATTERN.matcher(name).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이름은 한글 2~20자여야 합니다."); + } + } + + private void validateBirthday(LocalDate birthday) { + if (birthday == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 비어있을 수 없습니다."); + } + if (birthday.isAfter(LocalDate.now())) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 미래 날짜일 수 없습니다."); + } + } + + private void validateEmail(String email) { + if (email == null || !EMAIL_PATTERN.matcher(email).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "올바른 이메일 형식이 아닙니다."); + } + } + + public Long getId() { + return id; + } + + public String getLoginId() { + return loginId; + } + + public String getPassword() { + return password; + } + + public String getName() { + return name; + } + + public LocalDate getBirthday() { + return birthday; + } + + public String getEmail() { + return email; + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java new file mode 100644 index 00000000..5b2edc03 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java @@ -0,0 +1,252 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class MemberTest { + + private static final String VALID_LOGIN_ID = "testuser1"; + private static final String VALID_PASSWORD = "Test1234!"; + private static final String VALID_NAME = "홍길동"; + private static final LocalDate VALID_BIRTHDAY = LocalDate.of(1995, 3, 15); + private static final String VALID_EMAIL = "test@example.com"; + + @DisplayName("회원을 생성할 때,") + @Nested + class Create { + + @DisplayName("모든 값이 유효하면, 정상적으로 생성된다.") + @Test + void createsMember_whenAllFieldsAreValid() { + // arrange & act + Member member = new Member(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); + + // assert + assertAll( + () -> assertThat(member.getLoginId()).isEqualTo(VALID_LOGIN_ID), + () -> assertThat(member.getPassword()).isEqualTo(VALID_PASSWORD), + () -> assertThat(member.getName()).isEqualTo(VALID_NAME), + () -> assertThat(member.getBirthday()).isEqualTo(VALID_BIRTHDAY), + () -> assertThat(member.getEmail()).isEqualTo(VALID_EMAIL) + ); + } + } + + @DisplayName("로그인 ID를 검증할 때,") + @Nested + class ValidateLoginId { + + @DisplayName("null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenLoginIdIsNull() { + // act + CoreException result = assertThrows(CoreException.class, () -> + new Member(null, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("빈 문자열이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenLoginIdIsBlank() { + // act + CoreException result = assertThrows(CoreException.class, () -> + new Member(" ", VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("비밀번호를 검증할 때,") + @Nested + class ValidatePassword { + + @DisplayName("8자 미만이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPasswordIsTooShort() { + // act + CoreException result = assertThrows(CoreException.class, () -> + new Member(VALID_LOGIN_ID, "Test12!", VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("16자 초과이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPasswordIsTooLong() { + // act + CoreException result = assertThrows(CoreException.class, () -> + new Member(VALID_LOGIN_ID, "Test1234!Test1234", VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("허용되지 않는 문자(한글 등)가 포함되면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPasswordContainsInvalidCharacters() { + // act + CoreException result = assertThrows(CoreException.class, () -> + new Member(VALID_LOGIN_ID, "Test한글1234!", VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("생년월일(yyyyMMdd)이 포함되면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPasswordContainsBirthday() { + // arrange + LocalDate birthday = LocalDate.of(1995, 3, 15); + + // act + CoreException result = assertThrows(CoreException.class, () -> + new Member(VALID_LOGIN_ID, "A19950315!", VALID_NAME, birthday, VALID_EMAIL) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("이름을 검증할 때,") + @Nested + class ValidateName { + + @DisplayName("한글 1자이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameIsTooShort() { + // act + CoreException result = assertThrows(CoreException.class, () -> + new Member(VALID_LOGIN_ID, VALID_PASSWORD, "홍", VALID_BIRTHDAY, VALID_EMAIL) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("한글 20자를 초과하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameIsTooLong() { + // arrange + String longName = "가나다라마바사아자차카타파하가나다라마바사"; + + // act + CoreException result = assertThrows(CoreException.class, () -> + new Member(VALID_LOGIN_ID, VALID_PASSWORD, longName, VALID_BIRTHDAY, VALID_EMAIL) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("한글이 아닌 문자가 포함되면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameContainsNonKorean() { + // act + CoreException result = assertThrows(CoreException.class, () -> + new Member(VALID_LOGIN_ID, VALID_PASSWORD, "홍gildong", VALID_BIRTHDAY, VALID_EMAIL) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("생년월일을 검증할 때,") + @Nested + class ValidateBirthday { + + @DisplayName("null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenBirthdayIsNull() { + // act + CoreException result = assertThrows(CoreException.class, () -> + new Member(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, null, VALID_EMAIL) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("미래 날짜이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenBirthdayIsFuture() { + // arrange + LocalDate futureDate = LocalDate.now().plusDays(1); + + // act + CoreException result = assertThrows(CoreException.class, () -> + new Member(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, futureDate, VALID_EMAIL) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("이메일을 검증할 때,") + @Nested + class ValidateEmail { + + @DisplayName("null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenEmailIsNull() { + // act + CoreException result = assertThrows(CoreException.class, () -> + new Member(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, null) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("이메일 형식이 아니면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenEmailFormatIsInvalid() { + // act + CoreException result = assertThrows(CoreException.class, () -> + new Member(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, "invalid-email") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("비밀번호를 암호화할 때,") + @Nested + class EncryptPassword { + + @DisplayName("암호화된 비밀번호로 교체된다.") + @Test + void replacesPasswordWithEncoded() { + // arrange + Member member = new Member(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); + String encodedPassword = "$2a$10$encodedPasswordHash"; + + // act + member.encryptPassword(encodedPassword); + + // assert + assertThat(member.getPassword()).isEqualTo(encodedPassword); + } + } +} From 74ff0579df3188f956417da31385e2e226f36e4a Mon Sep 17 00:00:00 2001 From: letter333 Date: Mon, 2 Feb 2026 22:42:31 +0900 Subject: [PATCH 04/22] feat: add Member infrastructure layer with repository and persistence entity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Member 도메인의 Infrastructure 레이어 구현: - MemberEntity (JPA 영속성 엔티티, Domain↔Entity 변환) - MemberRepository 인터페이스 (도메인 레이어) - MemberJpaRepository (Spring Data JPA) - MemberRepositoryImpl (Repository 구현체) - MemberEntityTest, MemberRepositoryImplIntegrationTest - spring-security-crypto 의존성 추가 - docker-java.properties (Docker Engine 29 TestContainers 호환) Co-Authored-By: Claude Opus 4.5 --- apps/commerce-api/build.gradle.kts | 3 + .../com/loopers/domain/member/Member.java | 2 +- .../domain/member/MemberRepository.java | 7 ++ .../infrastructure/member/MemberEntity.java | 65 ++++++++++ .../member/MemberJpaRepository.java | 8 ++ .../member/MemberRepositoryImpl.java | 30 +++++ .../member/MemberEntityTest.java | 60 +++++++++ .../MemberRepositoryImplIntegrationTest.java | 118 ++++++++++++++++++ .../src/test/resources/docker-java.properties | 1 + 9 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/infrastructure/member/MemberEntityTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/infrastructure/member/MemberRepositoryImplIntegrationTest.java create mode 100644 apps/commerce-api/src/test/resources/docker-java.properties diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 03ce68f0..9ad4d8ea 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -6,6 +6,9 @@ dependencies { implementation(project(":supports:logging")) implementation(project(":supports:monitoring")) + // security + implementation("org.springframework.security:spring-security-crypto") + // web implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-actuator") diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java index b73d6606..ce87998a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java @@ -35,7 +35,7 @@ public Member(String loginId, String password, String name, LocalDate birthday, this.email = email; } - Member(Long id, String loginId, String password, String name, LocalDate birthday, String email) { + public Member(Long id, String loginId, String password, String name, LocalDate birthday, String email) { this.id = id; this.loginId = loginId; this.password = password; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java new file mode 100644 index 00000000..835d8915 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java @@ -0,0 +1,7 @@ +package com.loopers.domain.member; + +public interface MemberRepository { + Member save(Member member); + boolean existsByLoginId(String loginId); + boolean existsByEmail(String email); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberEntity.java new file mode 100644 index 00000000..b4c9b419 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberEntity.java @@ -0,0 +1,65 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.member.Member; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +import java.time.LocalDate; + +@Entity +@Table(name = "member") +public class MemberEntity extends BaseEntity { + + @Column(name = "login_id", nullable = false, unique = true, length = 30) + private String loginId; + + @Column(name = "password", nullable = false) + private String password; + + @Column(name = "name", nullable = false, length = 50) + private String name; + + @Column(name = "birthday", nullable = false) + private LocalDate birthday; + + @Column(name = "email", nullable = false, unique = true, length = 100) + private String email; + + protected MemberEntity() {} + + public static MemberEntity from(Member member) { + MemberEntity entity = new MemberEntity(); + entity.loginId = member.getLoginId(); + entity.password = member.getPassword(); + entity.name = member.getName(); + entity.birthday = member.getBirthday(); + entity.email = member.getEmail(); + return entity; + } + + public Member toDomain() { + return new Member(getId(), loginId, password, name, birthday, email); + } + + public String getLoginId() { + return loginId; + } + + public String getPassword() { + return password; + } + + public String getName() { + return name; + } + + public LocalDate getBirthday() { + return birthday; + } + + public String getEmail() { + return email; + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java new file mode 100644 index 00000000..aafea509 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java @@ -0,0 +1,8 @@ +package com.loopers.infrastructure.member; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MemberJpaRepository extends JpaRepository { + boolean existsByLoginId(String loginId); + boolean existsByEmail(String email); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java new file mode 100644 index 00000000..adceffd4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java @@ -0,0 +1,30 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class MemberRepositoryImpl implements MemberRepository { + + private final MemberJpaRepository memberJpaRepository; + + @Override + public Member save(Member member) { + MemberEntity entity = MemberEntity.from(member); + MemberEntity saved = memberJpaRepository.save(entity); + return saved.toDomain(); + } + + @Override + public boolean existsByLoginId(String loginId) { + return memberJpaRepository.existsByLoginId(loginId); + } + + @Override + public boolean existsByEmail(String email) { + return memberJpaRepository.existsByEmail(email); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/member/MemberEntityTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/member/MemberEntityTest.java new file mode 100644 index 00000000..d75929da --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/member/MemberEntityTest.java @@ -0,0 +1,60 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.Member; +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.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +class MemberEntityTest { + + @DisplayName("MemberEntity 변환 시,") + @Nested + class Convert { + + @DisplayName("Member 도메인 객체로부터 MemberEntity를 생성한다.") + @Test + void createsMemberEntity_fromDomain() { + // arrange + Member member = new Member("testuser1", "Test1234!", "홍길동", LocalDate.of(1995, 3, 15), "test@example.com"); + member.encryptPassword("$2a$10$encodedHash"); + + // act + MemberEntity entity = MemberEntity.from(member); + + // assert + assertAll( + () -> assertThat(entity.getLoginId()).isEqualTo("testuser1"), + () -> assertThat(entity.getPassword()).isEqualTo("$2a$10$encodedHash"), + () -> assertThat(entity.getName()).isEqualTo("홍길동"), + () -> assertThat(entity.getBirthday()).isEqualTo(LocalDate.of(1995, 3, 15)), + () -> assertThat(entity.getEmail()).isEqualTo("test@example.com") + ); + } + + @DisplayName("MemberEntity를 Member 도메인 객체로 변환한다.") + @Test + void convertsToDomain() { + // arrange + Member member = new Member("testuser1", "Test1234!", "홍길동", LocalDate.of(1995, 3, 15), "test@example.com"); + member.encryptPassword("$2a$10$encodedHash"); + MemberEntity entity = MemberEntity.from(member); + + // act + Member domain = entity.toDomain(); + + // assert + assertAll( + () -> assertThat(domain.getLoginId()).isEqualTo("testuser1"), + () -> assertThat(domain.getPassword()).isEqualTo("$2a$10$encodedHash"), + () -> assertThat(domain.getName()).isEqualTo("홍길동"), + () -> assertThat(domain.getBirthday()).isEqualTo(LocalDate.of(1995, 3, 15)), + () -> assertThat(domain.getEmail()).isEqualTo("test@example.com") + ); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/member/MemberRepositoryImplIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/member/MemberRepositoryImplIntegrationTest.java new file mode 100644 index 00000000..eb640e08 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/member/MemberRepositoryImplIntegrationTest.java @@ -0,0 +1,118 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberRepository; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +class MemberRepositoryImplIntegrationTest { + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private Member createMember(String loginId, String email) { + Member member = new Member(loginId, "Test1234!", "홍길동", LocalDate.of(1995, 3, 15), email); + member.encryptPassword("$2a$10$encodedHash"); + return member; + } + + @DisplayName("회원을 저장할 때,") + @Nested + class Save { + + @DisplayName("정상적으로 저장되고, ID가 부여된다.") + @Test + void savesMember_andAssignsId() { + // arrange + Member member = createMember("testuser1", "test@example.com"); + + // act + Member saved = memberRepository.save(member); + + // assert + assertAll( + () -> assertThat(saved.getId()).isNotNull(), + () -> assertThat(saved.getLoginId()).isEqualTo("testuser1"), + () -> assertThat(saved.getPassword()).isEqualTo("$2a$10$encodedHash"), + () -> assertThat(saved.getName()).isEqualTo("홍길동"), + () -> assertThat(saved.getBirthday()).isEqualTo(LocalDate.of(1995, 3, 15)), + () -> assertThat(saved.getEmail()).isEqualTo("test@example.com") + ); + } + } + + @DisplayName("로그인 ID 중복을 확인할 때,") + @Nested + class ExistsByLoginId { + + @DisplayName("존재하는 loginId이면, true를 반환한다.") + @Test + void returnsTrue_whenLoginIdExists() { + // arrange + memberRepository.save(createMember("testuser1", "test@example.com")); + + // act + boolean result = memberRepository.existsByLoginId("testuser1"); + + // assert + assertThat(result).isTrue(); + } + + @DisplayName("존재하지 않는 loginId이면, false를 반환한다.") + @Test + void returnsFalse_whenLoginIdDoesNotExist() { + // act + boolean result = memberRepository.existsByLoginId("nonexistent"); + + // assert + assertThat(result).isFalse(); + } + } + + @DisplayName("이메일 중복을 확인할 때,") + @Nested + class ExistsByEmail { + + @DisplayName("존재하는 email이면, true를 반환한다.") + @Test + void returnsTrue_whenEmailExists() { + // arrange + memberRepository.save(createMember("testuser1", "test@example.com")); + + // act + boolean result = memberRepository.existsByEmail("test@example.com"); + + // assert + assertThat(result).isTrue(); + } + + @DisplayName("존재하지 않는 email이면, false를 반환한다.") + @Test + void returnsFalse_whenEmailDoesNotExist() { + // act + boolean result = memberRepository.existsByEmail("nonexistent@example.com"); + + // assert + assertThat(result).isFalse(); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/resources/docker-java.properties b/apps/commerce-api/src/test/resources/docker-java.properties new file mode 100644 index 00000000..e1af86b4 --- /dev/null +++ b/apps/commerce-api/src/test/resources/docker-java.properties @@ -0,0 +1 @@ +api.version=1.44 \ No newline at end of file From 65fffcaae9d953fbef315e5e5d7163810293da6f Mon Sep 17 00:00:00 2001 From: letter333 Date: Mon, 2 Feb 2026 22:54:11 +0900 Subject: [PATCH 05/22] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EB=B9=84=EC=A6=88=EB=8B=88=EC=8A=A4=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B5=AC=ED=98=84=20(MemberService,=20PasswordEnco?= =?UTF-8?q?derConfig)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MemberService.signUp(): 중복 검사 → 도메인 생성 → 비밀번호 암호화 → 저장 - PasswordEncoderConfig: BCryptPasswordEncoder Bean 등록 - MemberServiceTest: 정상 가입, loginId 중복, email 중복 통합 테스트 Co-Authored-By: Claude Opus 4.5 --- .../loopers/domain/member/MemberService.java | 32 ++++++ .../support/config/PasswordEncoderConfig.java | 15 +++ .../domain/member/MemberServiceTest.java | 97 +++++++++++++++++++ 3 files changed, 144 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/support/config/PasswordEncoderConfig.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java new file mode 100644 index 00000000..d60fe755 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java @@ -0,0 +1,32 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +@RequiredArgsConstructor +@Component +public class MemberService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional + public Member signUp(String loginId, String password, String name, LocalDate birthday, String email) { + if (memberRepository.existsByLoginId(loginId)) { + throw new CoreException(ErrorType.CONFLICT); + } + if (memberRepository.existsByEmail(email)) { + throw new CoreException(ErrorType.CONFLICT); + } + + Member member = new Member(loginId, password, name, birthday, email); + member.encryptPassword(passwordEncoder.encode(password)); + return memberRepository.save(member); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/support/config/PasswordEncoderConfig.java b/apps/commerce-api/src/main/java/com/loopers/support/config/PasswordEncoderConfig.java new file mode 100644 index 00000000..60dcc143 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/config/PasswordEncoderConfig.java @@ -0,0 +1,15 @@ +package com.loopers.support.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class PasswordEncoderConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceTest.java new file mode 100644 index 00000000..485c4f8f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceTest.java @@ -0,0 +1,97 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +class MemberServiceTest { + + @Autowired + private MemberService memberService; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("회원가입을 할 때,") + @Nested + class SignUp { + + @DisplayName("정상적인 정보로 가입하면, 회원이 저장되고 비밀번호가 암호화된다.") + @Test + void signUp_withValidInfo_savesMemberWithEncryptedPassword() { + // arrange + String loginId = "testuser1"; + String rawPassword = "Test1234!"; + String name = "홍길동"; + LocalDate birthday = LocalDate.of(1995, 3, 15); + String email = "test@example.com"; + + // act + Member savedMember = memberService.signUp(loginId, rawPassword, name, birthday, email); + + // assert + assertAll( + () -> assertThat(savedMember.getId()).isNotNull(), + () -> assertThat(savedMember.getLoginId()).isEqualTo(loginId), + () -> assertThat(savedMember.getName()).isEqualTo(name), + () -> assertThat(savedMember.getBirthday()).isEqualTo(birthday), + () -> assertThat(savedMember.getEmail()).isEqualTo(email), + () -> assertThat(savedMember.getPassword()).isNotEqualTo(rawPassword), + () -> assertThat(passwordEncoder.matches(rawPassword, savedMember.getPassword())).isTrue() + ); + } + + @DisplayName("이미 존재하는 loginId로 가입하면, CONFLICT 예외가 발생한다.") + @Test + void signUp_withDuplicateLoginId_throwsConflict() { + // arrange + Member existing = new Member("testuser1", "Test1234!", "홍길동", LocalDate.of(1995, 3, 15), "test@example.com"); + existing.encryptPassword(passwordEncoder.encode("Test1234!")); + memberRepository.save(existing); + + // act & assert + assertThatThrownBy(() -> memberService.signUp("testuser1", "Other1234!", "김철수", LocalDate.of(1990, 1, 1), "other@example.com")) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.CONFLICT)); + } + + @DisplayName("이미 존재하는 email로 가입하면, CONFLICT 예외가 발생한다.") + @Test + void signUp_withDuplicateEmail_throwsConflict() { + // arrange + Member existing = new Member("testuser1", "Test1234!", "홍길동", LocalDate.of(1995, 3, 15), "test@example.com"); + existing.encryptPassword(passwordEncoder.encode("Test1234!")); + memberRepository.save(existing); + + // act & assert + assertThatThrownBy(() -> memberService.signUp("testuser2", "Other1234!", "김철수", LocalDate.of(1990, 1, 1), "test@example.com")) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.CONFLICT)); + } + } +} \ No newline at end of file From fd224587a3e378ed1e4b7aaaf00018558b6b522f Mon Sep 17 00:00:00 2001 From: letter333 Date: Mon, 2 Feb 2026 23:05:22 +0900 Subject: [PATCH 06/22] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20Application=20=EB=A0=88=EC=9D=B4=EC=96=B4=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(MemberFacade,=20MemberInfo)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MemberInfo: Domain → 응답 변환 record (password, birthday 제외) - MemberFacade: MemberService 위임 및 MemberInfo 변환 - MemberInfoTest, MemberFacadeTest 추가 Co-Authored-By: Claude Opus 4.5 --- .../application/member/MemberFacade.java | 20 +++++++ .../application/member/MemberInfo.java | 14 +++++ .../application/member/MemberFacadeTest.java | 56 +++++++++++++++++++ .../application/member/MemberInfoTest.java | 37 ++++++++++++ 4 files changed, 127 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/member/MemberInfoTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java new file mode 100644 index 00000000..7e852b9c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java @@ -0,0 +1,20 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; + +@RequiredArgsConstructor +@Component +public class MemberFacade { + + private final MemberService memberService; + + public MemberInfo signUp(String loginId, String password, String name, LocalDate birthday, String email) { + Member member = memberService.signUp(loginId, password, name, birthday, email); + return MemberInfo.from(member); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java new file mode 100644 index 00000000..97562bad --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java @@ -0,0 +1,14 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.Member; + +public record MemberInfo(Long id, String loginId, String name, String email) { + public static MemberInfo from(Member member) { + return new MemberInfo( + member.getId(), + member.getLoginId(), + member.getName(), + member.getEmail() + ); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeTest.java new file mode 100644 index 00000000..9faa95f5 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeTest.java @@ -0,0 +1,56 @@ +package com.loopers.application.member; + +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +class MemberFacadeTest { + + @Autowired + private MemberFacade memberFacade; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("회원가입을 요청할 때,") + @Nested + class SignUp { + + @DisplayName("정상적인 정보로 가입하면, MemberInfo를 반환한다.") + @Test + void signUp_withValidInfo_returnsMemberInfo() { + // arrange + String loginId = "testuser1"; + String password = "Test1234!"; + String name = "홍길동"; + LocalDate birthday = LocalDate.of(1995, 3, 15); + String email = "test@example.com"; + + // act + MemberInfo info = memberFacade.signUp(loginId, password, name, birthday, email); + + // assert + assertAll( + () -> assertThat(info.id()).isNotNull(), + () -> assertThat(info.loginId()).isEqualTo(loginId), + () -> assertThat(info.name()).isEqualTo(name), + () -> assertThat(info.email()).isEqualTo(email) + ); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/application/member/MemberInfoTest.java b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberInfoTest.java new file mode 100644 index 00000000..073b100e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberInfoTest.java @@ -0,0 +1,37 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.Member; +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.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +class MemberInfoTest { + + @DisplayName("MemberInfo 변환 시,") + @Nested + class From { + + @DisplayName("Member 도메인 객체로부터 MemberInfo를 생성하면, password와 birthday는 포함되지 않는다.") + @Test + void createsMemberInfo_fromDomain_withoutSensitiveFields() { + // arrange + Member member = new Member(1L, "testuser1", "$2a$10$encodedHash", "홍길동", LocalDate.of(1995, 3, 15), "test@example.com"); + + // act + MemberInfo info = MemberInfo.from(member); + + // assert + assertAll( + () -> assertThat(info.id()).isEqualTo(1L), + () -> assertThat(info.loginId()).isEqualTo("testuser1"), + () -> assertThat(info.name()).isEqualTo("홍길동"), + () -> assertThat(info.email()).isEqualTo("test@example.com") + ); + } + } +} \ No newline at end of file From cf164daa5e675709ee25cb29217eb3310f002141 Mon Sep 17 00:00:00 2001 From: letter333 Date: Mon, 2 Feb 2026 23:11:25 +0900 Subject: [PATCH 07/22] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20API=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=20(Controller,=20DTO,=20ApiSpec,=20E2E=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/v1/members → 201 Created - MemberV1Dto: SignUpRequest/SignUpResponse record - MemberV1ApiSpec: Swagger 스펙 인터페이스 - MemberV1Controller: Facade 위임 및 응답 변환 - MemberV1ApiE2ETest: 정상 가입(201), 검증 실패(400), 중복(409) E2E 테스트 Co-Authored-By: Claude Opus 4.5 --- .../api/member/MemberV1ApiSpec.java | 15 +++ .../api/member/MemberV1Controller.java | 39 ++++++ .../interfaces/api/member/MemberV1Dto.java | 20 +++ .../api/member/MemberV1ApiE2ETest.java | 119 ++++++++++++++++++ 4 files changed, 193 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ApiE2ETest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java new file mode 100644 index 00000000..5ed05f9e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java @@ -0,0 +1,15 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Member V1 API", description = "회원 API 입니다.") +public interface MemberV1ApiSpec { + + @Operation( + summary = "회원가입", + description = "새로운 회원을 등록합니다." + ) + ApiResponse signUp(MemberV1Dto.SignUpRequest request); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java new file mode 100644 index 00000000..eb1fd257 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java @@ -0,0 +1,39 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.application.member.MemberFacade; +import com.loopers.application.member.MemberInfo; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDate; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/members") +public class MemberV1Controller implements MemberV1ApiSpec { + + private final MemberFacade memberFacade; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @Override + public ApiResponse signUp( + @RequestBody MemberV1Dto.SignUpRequest request + ) { + MemberInfo info = memberFacade.signUp( + request.loginId(), + request.password(), + request.name(), + LocalDate.parse(request.birthday()), + request.email() + ); + MemberV1Dto.SignUpResponse response = MemberV1Dto.SignUpResponse.from(info); + return ApiResponse.success(response); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java new file mode 100644 index 00000000..8c69ff3f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java @@ -0,0 +1,20 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.application.member.MemberInfo; + +public class MemberV1Dto { + + public record SignUpRequest(String loginId, String password, String name, String birthday, String email) { + } + + public record SignUpResponse(Long id, String loginId, String name, String email) { + public static SignUpResponse from(MemberInfo info) { + return new SignUpResponse( + info.id(), + info.loginId(), + info.name(), + info.email() + ); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ApiE2ETest.java new file mode 100644 index 00000000..eff61555 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ApiE2ETest.java @@ -0,0 +1,119 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberRepository; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class MemberV1ApiE2ETest { + + private static final String ENDPOINT = "/api/v1/members"; + + private final TestRestTemplate testRestTemplate; + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public MemberV1ApiE2ETest( + TestRestTemplate testRestTemplate, + MemberRepository memberRepository, + PasswordEncoder passwordEncoder, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.memberRepository = memberRepository; + this.passwordEncoder = passwordEncoder; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("POST /api/v1/members") + @Nested + class SignUp { + + @DisplayName("정상적인 정보로 가입하면, 201 Created와 회원 정보를 반환한다.") + @Test + void returnsCreated_whenValidRequest() { + // arrange + MemberV1Dto.SignUpRequest request = new MemberV1Dto.SignUpRequest( + "testuser1", "Test1234!", "홍길동", "1995-03-15", "test@example.com" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(response.getBody().data().id()).isNotNull(), + () -> assertThat(response.getBody().data().loginId()).isEqualTo("testuser1"), + () -> assertThat(response.getBody().data().name()).isEqualTo("홍길동"), + () -> assertThat(response.getBody().data().email()).isEqualTo("test@example.com") + ); + } + + @DisplayName("필수 필드가 누락되면, 400 Bad Request를 반환한다.") + @Test + void returnsBadRequest_whenFieldMissing() { + // arrange + MemberV1Dto.SignUpRequest request = new MemberV1Dto.SignUpRequest( + "testuser1", "Test1234!", "홍길동", "1995-03-15", null + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("이미 존재하는 loginId로 가입하면, 409 Conflict를 반환한다.") + @Test + void returnsConflict_whenDuplicateLoginId() { + // arrange + Member existing = new Member("testuser1", "Test1234!", "홍길동", LocalDate.of(1995, 3, 15), "test@example.com"); + existing.encryptPassword(passwordEncoder.encode("Test1234!")); + memberRepository.save(existing); + + MemberV1Dto.SignUpRequest request = new MemberV1Dto.SignUpRequest( + "testuser1", "Other1234!", "김철수", "1990-01-01", "other@example.com" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } + } +} \ No newline at end of file From 4b80ed35e0ec1e90259f75b053f6a3a9f9cdb2ea Mon Sep 17 00:00:00 2001 From: letter333 Date: Mon, 2 Feb 2026 23:25:59 +0900 Subject: [PATCH 08/22] =?UTF-8?q?fix:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20API=20birthday=20null-safety=20=EB=B3=B4=EC=99=84?= =?UTF-8?q?=20=EB=B0=8F=20.http=20=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Controller에서 birthday null/빈 문자열/잘못된 형식 시 400 BAD_REQUEST 반환 - birthday 관련 E2E 테스트 2건 추가 - 회원가입 API .http 파일 생성 - CLAUDE.md 프로젝트 규칙 보강 Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 15 +++++++- .../api/member/MemberV1Controller.java | 17 +++++++++- .../api/member/MemberV1ApiE2ETest.java | 34 +++++++++++++++++++ http/commerce-api/member-v1.http | 11 ++++++ 4 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 http/commerce-api/member-v1.http diff --git a/CLAUDE.md b/CLAUDE.md index 2d00fb8e..0f5df10f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -132,6 +132,7 @@ docker compose -f docker/monitoring-compose.yml up -d - 실제 동작하지 않는 코드, 불필요한 Mock 데이터를 이용한 구현을 하지 말 것 - null-safety 하지 않게 코드 작성하지 말 것 (Java 의 경우, Optional 을 활용할 것) - println 코드 남기지 말 것 +- 객체지향 5원칙을 어기지 말 것 ### 2. Recommendation - 실제 API 를 호출해 확인하는 E2E 테스트 코드 작성 @@ -139,9 +140,21 @@ docker compose -f docker/monitoring-compose.yml up -d - 성능 최적화에 대한 대안 및 제안 - 개발 완료된 API 의 경우, `.http/**.http` 에 분류해 작성 - Domain Entity와 Persistence Entity는 구분하여 구현 +- 필요한 의존성은 적절히 관리하여 최소화 +- 통합 테스트는 테스트 컨테이너를 이용해 진행 ### 3. Priority 1. 실제 동작하는 해결책만 고려 2. null-safety, thread-safety 고려 3. 테스트 가능한 구조로 설계 -4. 기존 코드 패턴 분석 후 일관성 유지 \ No newline at end of file +4. 기존 코드 패턴 분석 후 일관성 유지 + +## 깃 커밋 컨벤션 +- feat: 새로운 기능 추가 +- fix: 버그 수정 +- docs: 문서만 수정 (예: README, 주석은 아님) +- style: 코드 포맷팅 (공백, 세미콜론 등 기능 변화 없음) +- refactor: 기능 변화 없이 코드 개선 +- test: 테스트 코드 추가/수정 +- chore: 빌드/패키지 설정 등 기능과 직접 관련 없는 작업 +- 커밋 메세지는 한국어로 작성할 것 \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java index eb1fd257..abb74b04 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java @@ -3,6 +3,8 @@ import com.loopers.application.member.MemberFacade; import com.loopers.application.member.MemberInfo; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.PostMapping; @@ -12,6 +14,7 @@ import org.springframework.web.bind.annotation.RestController; import java.time.LocalDate; +import java.time.format.DateTimeParseException; @RequiredArgsConstructor @RestController @@ -26,14 +29,26 @@ public class MemberV1Controller implements MemberV1ApiSpec { public ApiResponse signUp( @RequestBody MemberV1Dto.SignUpRequest request ) { + LocalDate birthday = parseBirthday(request.birthday()); MemberInfo info = memberFacade.signUp( request.loginId(), request.password(), request.name(), - LocalDate.parse(request.birthday()), + birthday, request.email() ); MemberV1Dto.SignUpResponse response = MemberV1Dto.SignUpResponse.from(info); return ApiResponse.success(response); } + + private LocalDate parseBirthday(String birthday) { + if (birthday == null || birthday.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 비어있을 수 없습니다."); + } + try { + return LocalDate.parse(birthday); + } catch (DateTimeParseException e) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일 형식이 올바르지 않습니다. (yyyy-MM-dd)"); + } + } } \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ApiE2ETest.java index eff61555..492a926a 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ApiE2ETest.java @@ -95,6 +95,40 @@ void returnsBadRequest_whenFieldMissing() { assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); } + @DisplayName("생년월일이 누락되면, 400 Bad Request를 반환한다.") + @Test + void returnsBadRequest_whenBirthdayMissing() { + // arrange + MemberV1Dto.SignUpRequest request = new MemberV1Dto.SignUpRequest( + "testuser1", "Test1234!", "홍길동", null, "test@example.com" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("생년월일 형식이 잘못되면, 400 Bad Request를 반환한다.") + @Test + void returnsBadRequest_whenBirthdayFormatInvalid() { + // arrange + MemberV1Dto.SignUpRequest request = new MemberV1Dto.SignUpRequest( + "testuser1", "Test1234!", "홍길동", "19950315", "test@example.com" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + @DisplayName("이미 존재하는 loginId로 가입하면, 409 Conflict를 반환한다.") @Test void returnsConflict_whenDuplicateLoginId() { diff --git a/http/commerce-api/member-v1.http b/http/commerce-api/member-v1.http new file mode 100644 index 00000000..17e5f983 --- /dev/null +++ b/http/commerce-api/member-v1.http @@ -0,0 +1,11 @@ +### 회원가입 +POST {{commerce-api}}/api/v1/members +Content-Type: application/json + +{ + "loginId": "testuser1", + "password": "Test1234!", + "name": "홍길동", + "birthday": "1995-03-15", + "email": "test@example.com" +} \ No newline at end of file From 3ba45ed9b44db358f4f55f19be07bf6b4b029730 Mon Sep 17 00:00:00 2001 From: letter333 Date: Tue, 3 Feb 2026 21:07:32 +0900 Subject: [PATCH 09/22] =?UTF-8?q?docs:=20=EB=82=B4=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=84=A4=EA=B3=84=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=8B=9C=ED=80=80=EC=8A=A4=20=EB=8B=A4=EC=9D=B4?= =?UTF-8?q?=EC=96=B4=EA=B7=B8=EB=9E=A8=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 내 정보 조회 기능 시퀀스/클래스 다이어그램 작성 - 회원가입 시퀀스 다이어그램 Entity 반환 화살표 누락 수정 Co-Authored-By: Claude Opus 4.5 --- docs/member-profile-lookup-design.md | 174 +++++++++++++++++++++++++++ docs/member-signup-design.md | 2 + 2 files changed, 176 insertions(+) create mode 100644 docs/member-profile-lookup-design.md diff --git a/docs/member-profile-lookup-design.md b/docs/member-profile-lookup-design.md new file mode 100644 index 00000000..7e11db1f --- /dev/null +++ b/docs/member-profile-lookup-design.md @@ -0,0 +1,174 @@ +# 내 정보 조회 기능 설계 + +## 요청/응답 스펙 + +| 항목 | 값 | +|------|---| +| Method | GET | +| URL | `/api/v1/members/me` | +| 인증 | `X-Loopers-LoginId`, `X-Loopers-LoginPw` 헤더 | +| 응답 코드 (성공) | 200 OK | +| 응답 코드 (인증 실패) | 401 Unauthorized | + +### 응답 필드 + +| 필드 | 타입 | 설명 | +|------|------|------| +| loginId | String | 로그인 ID | +| name | String | 이름 | +| birthday | String | 생년월일 (yyyy-MM-dd) | +| email | String | 이메일 | + +## 시퀀스 다이어그램 + +```mermaid +sequenceDiagram + actor Client + participant Controller as MemberV1Controller + participant Facade as MemberFacade + participant Service as MemberService + participant Repository as MemberRepository + participant RepoImpl as MemberRepositoryImpl + participant Entity as MemberEntity (Persistence) + participant JpaRepo as MemberJpaRepository + participant PasswordEncoder as PasswordEncoder + + Client->>Controller: GET /api/v1/members/me
X-Loopers-LoginId / X-Loopers-LoginPw + + Controller->>Facade: getMyInfo(loginId, password) + + Note over Facade: 1. 회원 조회 + Facade->>Service: authenticate(loginId, password) + Service->>Repository: findByLoginId(loginId) + Repository->>RepoImpl: findByLoginId(loginId) + RepoImpl->>JpaRepo: findByLoginId(loginId) + JpaRepo-->>RepoImpl: Optional~MemberEntity~ + Note over RepoImpl: Persistence → Domain 변환 + RepoImpl->>Entity: toDomain() + Entity-->>RepoImpl: Member + RepoImpl-->>Service: Optional~Member~ + + alt 회원 미존재 + Service-->>Controller: CoreException (UNAUTHORIZED) + Controller-->>Client: 401 Unauthorized + end + + Note over Service: 2. 비밀번호 검증 + Service->>PasswordEncoder: matches(rawPassword, encodedPassword) + PasswordEncoder-->>Service: boolean + + alt 비밀번호 불일치 + Service-->>Controller: CoreException (UNAUTHORIZED) + Controller-->>Client: 401 Unauthorized + end + + Service-->>Facade: Member + + Note over Facade: Domain → Info 변환 + Facade-->>Controller: MemberInfo + Controller-->>Client: 200 OK (MyInfoResponse) +``` + +## 클래스 다이어그램 + +```mermaid +classDiagram + direction TB + + %% === Interfaces Layer === + class MemberV1Controller { + -MemberFacade memberFacade + +getMyInfo(loginId, password) ApiResponse~MyInfoResponse~ + } + + class MemberV1ApiSpec { + <> + +getMyInfo(loginId, password) ApiResponse~MyInfoResponse~ + } + + class MyInfoResponse { + <> + +String loginId + +String name + +String birthday + +String email + +from(MemberInfo) MyInfoResponse + } + + %% === Application Layer === + class MemberFacade { + -MemberService memberService + +getMyInfo(loginId, password) MemberInfo + } + + class MemberInfo { + <> + +Long id + +String loginId + +String name + +LocalDate birthday + +String email + +from(Member) MemberInfo + } + + %% === Domain Layer === + class Member { + <> + -Long id + -String loginId + -String password + -String name + -LocalDate birthday + -String email + } + + class MemberRepository { + <> + +findByLoginId(String) Optional~Member~ + } + + class MemberService { + -MemberRepository memberRepository + -PasswordEncoder passwordEncoder + +authenticate(loginId, password) Member + } + + %% === Infrastructure Layer === + class MemberEntity { + <> + -String loginId + -String password + -String name + -LocalDate birthday + -String email + +toDomain() Member + } + + class MemberRepositoryImpl { + -MemberJpaRepository memberJpaRepository + +findByLoginId(String) Optional~Member~ + } + + class MemberJpaRepository { + <> + +findByLoginId(String) Optional~MemberEntity~ + } + + %% === Relationships === + MemberV1ApiSpec <|.. MemberV1Controller + MemberV1Controller --> MemberFacade + MemberV1Controller ..> MyInfoResponse + MyInfoResponse ..> MemberInfo + + MemberFacade --> MemberService + MemberFacade ..> MemberInfo + MemberInfo ..> Member + + MemberService --> MemberRepository + MemberService ..> Member + + MemberRepository <|.. MemberRepositoryImpl + MemberRepositoryImpl --> MemberJpaRepository + MemberRepositoryImpl ..> MemberEntity + MemberRepositoryImpl ..> Member +``` \ No newline at end of file diff --git a/docs/member-signup-design.md b/docs/member-signup-design.md index 384f9a4e..ec9cdf7b 100644 --- a/docs/member-signup-design.md +++ b/docs/member-signup-design.md @@ -73,10 +73,12 @@ sequenceDiagram Repository->>RepoImpl: save(member) Note over RepoImpl: Domain → Persistence 변환 RepoImpl->>Entity: MemberEntity.from(member) + Entity-->>RepoImpl: MemberEntity RepoImpl->>JpaRepo: save(memberEntity) JpaRepo-->>RepoImpl: MemberEntity Note over RepoImpl: Persistence → Domain 변환 RepoImpl->>Entity: toDomain() + Entity-->>RepoImpl: Member RepoImpl-->>Service: Member Service-->>Facade: Member From d2dcf7a87c4176cd98ebe57ece24de9ae06dffed Mon Sep 17 00:00:00 2001 From: letter333 Date: Tue, 3 Feb 2026 21:28:46 +0900 Subject: [PATCH 10/22] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EB=A5=BC=20=EC=9C=84=ED=95=9C=20findByLoginId=20Repos?= =?UTF-8?q?itory=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MemberRepository 인터페이스에 findByLoginId 추가 - MemberJpaRepository, MemberRepositoryImpl 구현 - 통합 테스트 2건 추가 (존재/미존재 케이스) Co-Authored-By: Claude Opus 4.5 --- .../domain/member/MemberRepository.java | 3 ++ .../member/MemberJpaRepository.java | 3 ++ .../member/MemberRepositoryImpl.java | 8 +++++ .../MemberRepositoryImplIntegrationTest.java | 35 +++++++++++++++++++ 4 files changed, 49 insertions(+) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java index 835d8915..40e0b0e4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java @@ -1,7 +1,10 @@ package com.loopers.domain.member; +import java.util.Optional; + public interface MemberRepository { Member save(Member member); boolean existsByLoginId(String loginId); boolean existsByEmail(String email); + Optional findByLoginId(String loginId); } \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java index aafea509..840bca72 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java @@ -2,7 +2,10 @@ import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface MemberJpaRepository extends JpaRepository { boolean existsByLoginId(String loginId); boolean existsByEmail(String email); + Optional findByLoginId(String loginId); } \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java index adceffd4..fb7d77c5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java @@ -5,6 +5,8 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import java.util.Optional; + @RequiredArgsConstructor @Component public class MemberRepositoryImpl implements MemberRepository { @@ -27,4 +29,10 @@ public boolean existsByLoginId(String loginId) { public boolean existsByEmail(String email) { return memberJpaRepository.existsByEmail(email); } + + @Override + public Optional findByLoginId(String loginId) { + return memberJpaRepository.findByLoginId(loginId) + .map(MemberEntity::toDomain); + } } \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/member/MemberRepositoryImplIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/member/MemberRepositoryImplIntegrationTest.java index eb640e08..50133151 100644 --- a/apps/commerce-api/src/test/java/com/loopers/infrastructure/member/MemberRepositoryImplIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/member/MemberRepositoryImplIntegrationTest.java @@ -11,6 +11,7 @@ import org.springframework.boot.test.context.SpringBootTest; import java.time.LocalDate; +import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; @@ -88,6 +89,40 @@ void returnsFalse_whenLoginIdDoesNotExist() { } } + @DisplayName("로그인 ID로 회원을 조회할 때,") + @Nested + class FindByLoginId { + + @DisplayName("존재하는 loginId이면, 회원을 반환한다.") + @Test + void returnsMember_whenLoginIdExists() { + // arrange + memberRepository.save(createMember("testuser1", "test@example.com")); + + // act + Optional result = memberRepository.findByLoginId("testuser1"); + + // assert + assertAll( + () -> assertThat(result).isPresent(), + () -> assertThat(result.get().getLoginId()).isEqualTo("testuser1"), + () -> assertThat(result.get().getName()).isEqualTo("홍길동"), + () -> assertThat(result.get().getBirthday()).isEqualTo(LocalDate.of(1995, 3, 15)), + () -> assertThat(result.get().getEmail()).isEqualTo("test@example.com") + ); + } + + @DisplayName("존재하지 않는 loginId이면, 빈 Optional을 반환한다.") + @Test + void returnsEmpty_whenLoginIdDoesNotExist() { + // act + Optional result = memberRepository.findByLoginId("nonexistent"); + + // assert + assertThat(result).isEmpty(); + } + } + @DisplayName("이메일 중복을 확인할 때,") @Nested class ExistsByEmail { From 5284c21b3d97fc43d290580a66709c42b6b2c610 Mon Sep 17 00:00:00 2001 From: letter333 Date: Tue, 3 Feb 2026 21:39:18 +0900 Subject: [PATCH 11/22] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84=20(authenti?= =?UTF-8?q?cate,=20ErrorType.UNAUTHORIZED)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MemberService.authenticate() 메서드 추가 (loginId 조회 + 비밀번호 검증) - ErrorType에 UNAUTHORIZED(401) 추가 - 통합 테스트 3건 추가 (성공, 회원 미존재, 비밀번호 불일치) Co-Authored-By: Claude Opus 4.5 --- .../loopers/domain/member/MemberService.java | 12 +++++ .../com/loopers/support/error/ErrorType.java | 1 + .../domain/member/MemberServiceTest.java | 48 +++++++++++++++++++ 3 files changed, 61 insertions(+) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java index d60fe755..c4a1299f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java @@ -16,6 +16,18 @@ public class MemberService { private final MemberRepository memberRepository; private final PasswordEncoder passwordEncoder; + @Transactional(readOnly = true) + public Member authenticate(String loginId, String rawPassword) { + Member member = memberRepository.findByLoginId(loginId) + .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED)); + + if (!passwordEncoder.matches(rawPassword, member.getPassword())) { + throw new CoreException(ErrorType.UNAUTHORIZED); + } + + return member; + } + @Transactional public Member signUp(String loginId, String password, String name, LocalDate birthday, String email) { if (memberRepository.existsByLoginId(loginId)) { diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java index 5d142efb..8d493491 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java @@ -10,6 +10,7 @@ public enum ErrorType { /** 범용 에러 */ INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), "일시적인 오류가 발생했습니다."), BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "잘못된 요청입니다."), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, HttpStatus.UNAUTHORIZED.getReasonPhrase(), "인증에 실패했습니다."), NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 요청입니다."), CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다."); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceTest.java index 485c4f8f..50a39996 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceTest.java @@ -94,4 +94,52 @@ void signUp_withDuplicateEmail_throwsConflict() { .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.CONFLICT)); } } + + @DisplayName("인증을 할 때,") + @Nested + class Authenticate { + + @DisplayName("올바른 loginId와 password로 인증하면, 회원을 반환한다.") + @Test + void authenticate_withValidCredentials_returnsMember() { + // arrange + String rawPassword = "Test1234!"; + Member existing = new Member("testuser1", rawPassword, "홍길동", LocalDate.of(1995, 3, 15), "test@example.com"); + existing.encryptPassword(passwordEncoder.encode(rawPassword)); + memberRepository.save(existing); + + // act + Member result = memberService.authenticate("testuser1", rawPassword); + + // assert + assertAll( + () -> assertThat(result.getLoginId()).isEqualTo("testuser1"), + () -> assertThat(result.getName()).isEqualTo("홍길동"), + () -> assertThat(result.getEmail()).isEqualTo("test@example.com") + ); + } + + @DisplayName("존재하지 않는 loginId로 인증하면, UNAUTHORIZED 예외가 발생한다.") + @Test + void authenticate_withNonExistentLoginId_throwsUnauthorized() { + // act & assert + assertThatThrownBy(() -> memberService.authenticate("nonexistent", "Test1234!")) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED)); + } + + @DisplayName("비밀번호가 일치하지 않으면, UNAUTHORIZED 예외가 발생한다.") + @Test + void authenticate_withWrongPassword_throwsUnauthorized() { + // arrange + Member existing = new Member("testuser1", "Test1234!", "홍길동", LocalDate.of(1995, 3, 15), "test@example.com"); + existing.encryptPassword(passwordEncoder.encode("Test1234!")); + memberRepository.save(existing); + + // act & assert + assertThatThrownBy(() -> memberService.authenticate("testuser1", "Wrong1234!")) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED)); + } + } } \ No newline at end of file From c429856e90b8b25c596632268cdcc27be5658dec Mon Sep 17 00:00:00 2001 From: letter333 Date: Tue, 3 Feb 2026 21:59:05 +0900 Subject: [PATCH 12/22] =?UTF-8?q?feat:=20=EB=82=B4=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=EC=9E=90=20=EC=A3=BC=EC=9E=85=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/v1/members/me 엔드포인트 추가 (X-Loopers-LoginId, X-Loopers-LoginPw 헤더 인증) - MemberInfo에 birthday 필드 추가, MyInfoResponse DTO 추가 - MemberFacade.getMyInfo(), MemberV1ApiSpec, MemberV1Controller 구현 - ApiControllerAdvice에 MissingRequestHeaderException 핸들러 추가 - E2E 테스트 4건 추가 (200, 401×2, 400) - 통합 테스트 생성자 주입으로 리팩터링 (필드 주입 → 생성자 주입) - member-v1.http에 내 정보 조회 요청 추가 Co-Authored-By: Claude Opus 4.5 --- .../application/member/MemberFacade.java | 5 ++ .../application/member/MemberInfo.java | 5 +- .../interfaces/api/ApiControllerAdvice.java | 8 ++ .../api/member/MemberV1ApiSpec.java | 6 ++ .../api/member/MemberV1Controller.java | 13 +++ .../interfaces/api/member/MemberV1Dto.java | 11 +++ .../application/member/MemberFacadeTest.java | 49 +++++++++++- .../application/member/MemberInfoTest.java | 5 +- .../domain/member/MemberServiceTest.java | 24 +++--- .../MemberRepositoryImplIntegrationTest.java | 12 ++- .../api/member/MemberV1ApiE2ETest.java | 79 +++++++++++++++++++ http/commerce-api/member-v1.http | 7 +- 12 files changed, 205 insertions(+), 19 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java index 7e852b9c..dd2137c9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java @@ -17,4 +17,9 @@ public MemberInfo signUp(String loginId, String password, String name, LocalDate Member member = memberService.signUp(loginId, password, name, birthday, email); return MemberInfo.from(member); } + + public MemberInfo getMyInfo(String loginId, String password) { + Member member = memberService.authenticate(loginId, password); + return MemberInfo.from(member); + } } \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java index 97562bad..a1d722f7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java @@ -2,12 +2,15 @@ import com.loopers.domain.member.Member; -public record MemberInfo(Long id, String loginId, String name, String email) { +import java.time.LocalDate; + +public record MemberInfo(Long id, String loginId, String name, LocalDate birthday, String email) { public static MemberInfo from(Member member) { return new MemberInfo( member.getId(), member.getLoginId(), member.getName(), + member.getBirthday(), member.getEmail() ); } 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..405b0055 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 @@ -8,6 +8,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MissingRequestHeaderException; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -38,6 +39,13 @@ public ResponseEntity> handleBadRequest(MethodArgumentTypeMismatc return failureResponse(ErrorType.BAD_REQUEST, message); } + @ExceptionHandler + public ResponseEntity> handleBadRequest(MissingRequestHeaderException e) { + String name = e.getHeaderName(); + String message = String.format("필수 요청 헤더 '%s'이(가) 누락되었습니다.", name); + return failureResponse(ErrorType.BAD_REQUEST, message); + } + @ExceptionHandler public ResponseEntity> handleBadRequest(MissingServletRequestParameterException e) { String name = e.getParameterName(); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java index 5ed05f9e..9da54940 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java @@ -12,4 +12,10 @@ public interface MemberV1ApiSpec { description = "새로운 회원을 등록합니다." ) ApiResponse signUp(MemberV1Dto.SignUpRequest request); + + @Operation( + summary = "내 정보 조회", + description = "인증된 회원의 정보를 조회합니다." + ) + ApiResponse getMyInfo(String loginId, String password); } \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java index abb74b04..12c1e45d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java @@ -7,8 +7,10 @@ import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; @@ -41,6 +43,17 @@ public ApiResponse signUp( return ApiResponse.success(response); } + @GetMapping("/me") + @Override + public ApiResponse getMyInfo( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password + ) { + MemberInfo info = memberFacade.getMyInfo(loginId, password); + MemberV1Dto.MyInfoResponse response = MemberV1Dto.MyInfoResponse.from(info); + return ApiResponse.success(response); + } + private LocalDate parseBirthday(String birthday) { if (birthday == null || birthday.isBlank()) { throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 비어있을 수 없습니다."); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java index 8c69ff3f..6308d6a1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java @@ -17,4 +17,15 @@ public static SignUpResponse from(MemberInfo info) { ); } } + + public record MyInfoResponse(String loginId, String name, String birthday, String email) { + public static MyInfoResponse from(MemberInfo info) { + return new MyInfoResponse( + info.loginId(), + info.name(), + info.birthday().toString(), + info.email() + ); + } + } } \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeTest.java index 9faa95f5..10ddb106 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeTest.java @@ -8,6 +8,10 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberRepository; +import org.springframework.security.crypto.password.PasswordEncoder; + import java.time.LocalDate; import static org.assertj.core.api.Assertions.assertThat; @@ -16,11 +20,23 @@ @SpringBootTest class MemberFacadeTest { - @Autowired - private MemberFacade memberFacade; + private final MemberFacade memberFacade; + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + private final DatabaseCleanUp databaseCleanUp; @Autowired - private DatabaseCleanUp databaseCleanUp; + public MemberFacadeTest( + MemberFacade memberFacade, + MemberRepository memberRepository, + PasswordEncoder passwordEncoder, + DatabaseCleanUp databaseCleanUp + ) { + this.memberFacade = memberFacade; + this.memberRepository = memberRepository; + this.passwordEncoder = passwordEncoder; + this.databaseCleanUp = databaseCleanUp; + } @AfterEach void tearDown() { @@ -49,8 +65,35 @@ void signUp_withValidInfo_returnsMemberInfo() { () -> assertThat(info.id()).isNotNull(), () -> assertThat(info.loginId()).isEqualTo(loginId), () -> assertThat(info.name()).isEqualTo(name), + () -> assertThat(info.birthday()).isEqualTo(birthday), () -> assertThat(info.email()).isEqualTo(email) ); } } + + @DisplayName("내 정보를 조회할 때,") + @Nested + class GetMyInfo { + + @DisplayName("올바른 자격 증명으로 조회하면, MemberInfo를 반환한다.") + @Test + void getMyInfo_withValidCredentials_returnsMemberInfo() { + // arrange + String rawPassword = "Test1234!"; + Member member = new Member("testuser1", rawPassword, "홍길동", LocalDate.of(1995, 3, 15), "test@example.com"); + member.encryptPassword(passwordEncoder.encode(rawPassword)); + memberRepository.save(member); + + // act + MemberInfo info = memberFacade.getMyInfo("testuser1", rawPassword); + + // assert + assertAll( + () -> assertThat(info.loginId()).isEqualTo("testuser1"), + () -> assertThat(info.name()).isEqualTo("홍길동"), + () -> assertThat(info.birthday()).isEqualTo(LocalDate.of(1995, 3, 15)), + () -> assertThat(info.email()).isEqualTo("test@example.com") + ); + } + } } \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/application/member/MemberInfoTest.java b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberInfoTest.java index 073b100e..34b580ed 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/member/MemberInfoTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberInfoTest.java @@ -16,9 +16,9 @@ class MemberInfoTest { @Nested class From { - @DisplayName("Member 도메인 객체로부터 MemberInfo를 생성하면, password와 birthday는 포함되지 않는다.") + @DisplayName("Member 도메인 객체로부터 MemberInfo를 생성하면, password를 제외한 정보를 포함한다.") @Test - void createsMemberInfo_fromDomain_withoutSensitiveFields() { + void createsMemberInfo_fromDomain_withoutPassword() { // arrange Member member = new Member(1L, "testuser1", "$2a$10$encodedHash", "홍길동", LocalDate.of(1995, 3, 15), "test@example.com"); @@ -30,6 +30,7 @@ void createsMemberInfo_fromDomain_withoutSensitiveFields() { () -> assertThat(info.id()).isEqualTo(1L), () -> assertThat(info.loginId()).isEqualTo("testuser1"), () -> assertThat(info.name()).isEqualTo("홍길동"), + () -> assertThat(info.birthday()).isEqualTo(LocalDate.of(1995, 3, 15)), () -> assertThat(info.email()).isEqualTo("test@example.com") ); } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceTest.java index 50a39996..8faf03ed 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceTest.java @@ -20,17 +20,23 @@ @SpringBootTest class MemberServiceTest { - @Autowired - private MemberService memberService; - - @Autowired - private MemberRepository memberRepository; + private final MemberService memberService; + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + private final DatabaseCleanUp databaseCleanUp; @Autowired - private PasswordEncoder passwordEncoder; - - @Autowired - private DatabaseCleanUp databaseCleanUp; + public MemberServiceTest( + MemberService memberService, + MemberRepository memberRepository, + PasswordEncoder passwordEncoder, + DatabaseCleanUp databaseCleanUp + ) { + this.memberService = memberService; + this.memberRepository = memberRepository; + this.passwordEncoder = passwordEncoder; + this.databaseCleanUp = databaseCleanUp; + } @AfterEach void tearDown() { diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/member/MemberRepositoryImplIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/member/MemberRepositoryImplIntegrationTest.java index 50133151..dd0410bf 100644 --- a/apps/commerce-api/src/test/java/com/loopers/infrastructure/member/MemberRepositoryImplIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/member/MemberRepositoryImplIntegrationTest.java @@ -19,11 +19,17 @@ @SpringBootTest class MemberRepositoryImplIntegrationTest { - @Autowired - private MemberRepository memberRepository; + private final MemberRepository memberRepository; + private final DatabaseCleanUp databaseCleanUp; @Autowired - private DatabaseCleanUp databaseCleanUp; + public MemberRepositoryImplIntegrationTest( + MemberRepository memberRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.memberRepository = memberRepository; + this.databaseCleanUp = databaseCleanUp; + } @AfterEach void tearDown() { diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ApiE2ETest.java index 492a926a..21cd2bf4 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ApiE2ETest.java @@ -13,6 +13,7 @@ import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -150,4 +151,82 @@ void returnsConflict_whenDuplicateLoginId() { assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); } } + + @DisplayName("GET /api/v1/members/me") + @Nested + class GetMyInfo { + + private Member saveMember(String loginId, String rawPassword) { + Member member = new Member(loginId, rawPassword, "홍길동", LocalDate.of(1995, 3, 15), "test@example.com"); + member.encryptPassword(passwordEncoder.encode(rawPassword)); + return memberRepository.save(member); + } + + private HttpEntity createAuthHeaders(String loginId, String password) { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", loginId); + headers.set("X-Loopers-LoginPw", password); + return new HttpEntity<>(headers); + } + + @DisplayName("올바른 인증 정보로 조회하면, 200 OK와 회원 정보를 반환한다.") + @Test + void returnsOk_whenValidCredentials() { + // arrange + saveMember("testuser1", "Test1234!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT + "/me", HttpMethod.GET, createAuthHeaders("testuser1", "Test1234!"), responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().loginId()).isEqualTo("testuser1"), + () -> assertThat(response.getBody().data().name()).isEqualTo("홍길동"), + () -> assertThat(response.getBody().data().birthday()).isEqualTo("1995-03-15"), + () -> assertThat(response.getBody().data().email()).isEqualTo("test@example.com") + ); + } + + @DisplayName("존재하지 않는 loginId로 조회하면, 401 Unauthorized를 반환한다.") + @Test + void returnsUnauthorized_whenLoginIdNotFound() { + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT + "/me", HttpMethod.GET, createAuthHeaders("nonexistent", "Test1234!"), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @DisplayName("비밀번호가 일치하지 않으면, 401 Unauthorized를 반환한다.") + @Test + void returnsUnauthorized_whenPasswordWrong() { + // arrange + saveMember("testuser1", "Test1234!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT + "/me", HttpMethod.GET, createAuthHeaders("testuser1", "Wrong1234!"), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @DisplayName("인증 헤더가 누락되면, 400 Bad Request를 반환한다.") + @Test + void returnsBadRequest_whenHeaderMissing() { + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT + "/me", HttpMethod.GET, null, responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } } \ No newline at end of file diff --git a/http/commerce-api/member-v1.http b/http/commerce-api/member-v1.http index 17e5f983..f3519da9 100644 --- a/http/commerce-api/member-v1.http +++ b/http/commerce-api/member-v1.http @@ -8,4 +8,9 @@ Content-Type: application/json "name": "홍길동", "birthday": "1995-03-15", "email": "test@example.com" -} \ No newline at end of file +} + +### 내 정보 조회 +GET {{commerce-api}}/api/v1/members/me +X-Loopers-LoginId: testuser1 +X-Loopers-LoginPw: Test1234! \ No newline at end of file From 98e979e45d6af318dd8a0f5e86e11ed30796fd69 Mon Sep 17 00:00:00 2001 From: letter333 Date: Tue, 3 Feb 2026 22:09:13 +0900 Subject: [PATCH 13/22] =?UTF-8?q?test:=20Member=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EA=B2=BD=EA=B3=84=EA=B0=92=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - password MIN(8자), MAX(16자) 성공 테스트 추가 - name MIN(한글 2자), MAX(한글 20자) 성공 테스트 추가 - birthday 오늘 날짜(경계값) 성공 테스트 추가 Co-Authored-By: Claude Opus 4.5 --- .../com/loopers/domain/member/MemberTest.java | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java index 5b2edc03..5187b164 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java @@ -74,6 +74,26 @@ void throwsBadRequest_whenLoginIdIsBlank() { @Nested class ValidatePassword { + @DisplayName("8자(MIN)이면, 정상적으로 생성된다.") + @Test + void createsSuccessfully_whenPasswordIsMinLength() { + // act + Member member = new Member(VALID_LOGIN_ID, "Test123!", VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); + + // assert + assertThat(member.getPassword()).isEqualTo("Test123!"); + } + + @DisplayName("16자(MAX)이면, 정상적으로 생성된다.") + @Test + void createsSuccessfully_whenPasswordIsMaxLength() { + // act + Member member = new Member(VALID_LOGIN_ID, "Test12345678901!", VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); + + // assert + assertThat(member.getPassword()).isEqualTo("Test12345678901!"); + } + @DisplayName("8자 미만이면, BAD_REQUEST 예외가 발생한다.") @Test void throwsBadRequest_whenPasswordIsTooShort() { @@ -130,6 +150,29 @@ void throwsBadRequest_whenPasswordContainsBirthday() { @Nested class ValidateName { + @DisplayName("한글 2자(MIN)이면, 정상적으로 생성된다.") + @Test + void createsSuccessfully_whenNameIsMinLength() { + // act + Member member = new Member(VALID_LOGIN_ID, VALID_PASSWORD, "홍길", VALID_BIRTHDAY, VALID_EMAIL); + + // assert + assertThat(member.getName()).isEqualTo("홍길"); + } + + @DisplayName("한글 20자(MAX)이면, 정상적으로 생성된다.") + @Test + void createsSuccessfully_whenNameIsMaxLength() { + // arrange + String maxName = "가나다라마바사아자차카타파하가나다라마바"; + + // act + Member member = new Member(VALID_LOGIN_ID, VALID_PASSWORD, maxName, VALID_BIRTHDAY, VALID_EMAIL); + + // assert + assertThat(member.getName()).isEqualTo(maxName); + } + @DisplayName("한글 1자이면, BAD_REQUEST 예외가 발생한다.") @Test void throwsBadRequest_whenNameIsTooShort() { @@ -174,6 +217,19 @@ void throwsBadRequest_whenNameContainsNonKorean() { @Nested class ValidateBirthday { + @DisplayName("오늘 날짜(경계값)이면, 정상적으로 생성된다.") + @Test + void createsSuccessfully_whenBirthdayIsToday() { + // arrange + LocalDate today = LocalDate.now(); + + // act + Member member = new Member(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, today, VALID_EMAIL); + + // assert + assertThat(member.getBirthday()).isEqualTo(today); + } + @DisplayName("null이면, BAD_REQUEST 예외가 발생한다.") @Test void throwsBadRequest_whenBirthdayIsNull() { From c81b9d3e9906df848ce245f15d7896e48a7e0db1 Mon Sep 17 00:00:00 2001 From: letter333 Date: Wed, 4 Feb 2026 23:50:01 +0900 Subject: [PATCH 14/22] =?UTF-8?q?feat:=20Member=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84=20=EB=B0=8F?= =?UTF-8?q?=20=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Member.changePassword() 메서드를 통해 현재 비밀번호 검증, 동일 비밀번호 방지, 새 비밀번호 룰 검증(길이/패턴/생년월일), 암호화까지 도메인 엔티티에서 캡슐화 Co-Authored-By: Claude Opus 4.5 --- .../com/loopers/domain/member/Member.java | 12 ++ .../com/loopers/domain/member/MemberTest.java | 119 ++++++++++++++++++ 2 files changed, 131 insertions(+) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java index ce87998a..1f9cec9b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java @@ -2,6 +2,7 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +import org.springframework.security.crypto.password.PasswordEncoder; import java.time.LocalDate; import java.time.format.DateTimeFormatter; @@ -48,6 +49,17 @@ public void encryptPassword(String encodedPassword) { this.password = encodedPassword; } + public void changePassword(String currentPassword, String newPassword, PasswordEncoder passwordEncoder) { + if (!passwordEncoder.matches(currentPassword, this.password)) { + throw new CoreException(ErrorType.UNAUTHORIZED, "현재 비밀번호가 일치하지 않습니다."); + } + if (passwordEncoder.matches(newPassword, this.password)) { + throw new CoreException(ErrorType.BAD_REQUEST, "새 비밀번호는 현재 비밀번호와 달라야 합니다."); + } + validatePassword(newPassword, this.birthday); + this.password = passwordEncoder.encode(newPassword); + } + private void validateLoginId(String loginId) { if (loginId == null || loginId.isBlank()) { throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 비어있을 수 없습니다."); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java index 5187b164..6865a0df 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java @@ -5,6 +5,8 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; import java.time.LocalDate; @@ -305,4 +307,121 @@ void replacesPasswordWithEncoded() { assertThat(member.getPassword()).isEqualTo(encodedPassword); } } + + @DisplayName("비밀번호를 변경할 때,") + @Nested + class ChangePassword { + + private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); + + private Member createMemberWithEncryptedPassword() { + Member member = new Member(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); + member.encryptPassword(passwordEncoder.encode(VALID_PASSWORD)); + return member; + } + + @DisplayName("유효한 새 비밀번호로 변경하면, 비밀번호가 변경된다.") + @Test + void changesPassword_whenNewPasswordIsValid() { + // arrange + Member member = createMemberWithEncryptedPassword(); + String newPassword = "NewPass123!"; + + // act + member.changePassword(VALID_PASSWORD, newPassword, passwordEncoder); + + // assert + assertThat(passwordEncoder.matches(newPassword, member.getPassword())).isTrue(); + } + + @DisplayName("현재 비밀번호가 일치하지 않으면, UNAUTHORIZED 예외가 발생한다.") + @Test + void throwsUnauthorized_whenCurrentPasswordIsWrong() { + // arrange + Member member = createMemberWithEncryptedPassword(); + + // act + CoreException result = assertThrows(CoreException.class, () -> + member.changePassword("WrongPass1!", "NewPass123!", passwordEncoder) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED); + } + + @DisplayName("새 비밀번호가 현재 비밀번호와 동일하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNewPasswordIsSameAsCurrent() { + // arrange + Member member = createMemberWithEncryptedPassword(); + + // act + CoreException result = assertThrows(CoreException.class, () -> + member.changePassword(VALID_PASSWORD, VALID_PASSWORD, passwordEncoder) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("새 비밀번호가 8자 미만이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNewPasswordIsTooShort() { + // arrange + Member member = createMemberWithEncryptedPassword(); + + // act + CoreException result = assertThrows(CoreException.class, () -> + member.changePassword(VALID_PASSWORD, "New12!", passwordEncoder) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("새 비밀번호가 16자 초과이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNewPasswordIsTooLong() { + // arrange + Member member = createMemberWithEncryptedPassword(); + + // act + CoreException result = assertThrows(CoreException.class, () -> + member.changePassword(VALID_PASSWORD, "NewPass12345678!!", passwordEncoder) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("새 비밀번호에 생년월일이 포함되면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNewPasswordContainsBirthday() { + // arrange + Member member = createMemberWithEncryptedPassword(); + + // act + CoreException result = assertThrows(CoreException.class, () -> + member.changePassword(VALID_PASSWORD, "A19950315!", passwordEncoder) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("새 비밀번호에 허용되지 않는 문자가 포함되면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNewPasswordContainsInvalidCharacters() { + // arrange + Member member = createMemberWithEncryptedPassword(); + + // act + CoreException result = assertThrows(CoreException.class, () -> + member.changePassword(VALID_PASSWORD, "New한글1234!", passwordEncoder) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } } From 5adb1126bccd48b3a965cf41d43ce775ac5179b2 Mon Sep 17 00:00:00 2001 From: letter333 Date: Wed, 4 Feb 2026 23:57:03 +0900 Subject: [PATCH 15/22] =?UTF-8?q?feat:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EB=B3=80=EA=B2=BD=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EB=B0=8F=20Repository=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MemberService.updatePassword() 추가, MemberRepository에 updatePassword 메서드 정의, MemberRepositoryImpl에서 JPA dirty checking 기반 UPDATE 구현, 통합 테스트 추가 Co-Authored-By: Claude Opus 4.5 --- .../domain/member/MemberRepository.java | 1 + .../loopers/domain/member/MemberService.java | 8 +++ .../infrastructure/member/MemberEntity.java | 4 ++ .../member/MemberRepositoryImpl.java | 11 ++++ .../domain/member/MemberServiceTest.java | 61 +++++++++++++++++++ 5 files changed, 85 insertions(+) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java index 40e0b0e4..5fc68114 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java @@ -7,4 +7,5 @@ public interface MemberRepository { boolean existsByLoginId(String loginId); boolean existsByEmail(String email); Optional findByLoginId(String loginId); + void updatePassword(String loginId, String encodedPassword); } \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java index c4a1299f..ac8b77f8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java @@ -41,4 +41,12 @@ public Member signUp(String loginId, String password, String name, LocalDate bir member.encryptPassword(passwordEncoder.encode(password)); return memberRepository.save(member); } + + @Transactional + public void updatePassword(String loginId, String currentPassword, String newPassword) { + Member member = memberRepository.findByLoginId(loginId) + .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED)); + member.changePassword(currentPassword, newPassword, passwordEncoder); + memberRepository.updatePassword(loginId, member.getPassword()); + } } \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberEntity.java index b4c9b419..74a75fc4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberEntity.java @@ -43,6 +43,10 @@ public Member toDomain() { return new Member(getId(), loginId, password, name, birthday, email); } + public void updatePassword(String password) { + this.password = password; + } + public String getLoginId() { return loginId; } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java index fb7d77c5..090d714d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java @@ -2,8 +2,11 @@ import com.loopers.domain.member.Member; import com.loopers.domain.member.MemberRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; import java.util.Optional; @@ -35,4 +38,12 @@ public Optional findByLoginId(String loginId) { return memberJpaRepository.findByLoginId(loginId) .map(MemberEntity::toDomain); } + + @Override + @Transactional + public void updatePassword(String loginId, String encodedPassword) { + MemberEntity entity = memberJpaRepository.findByLoginId(loginId) + .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED)); + entity.updatePassword(encodedPassword); + } } \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceTest.java index 8faf03ed..60d94f47 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceTest.java @@ -148,4 +148,65 @@ void authenticate_withWrongPassword_throwsUnauthorized() { .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED)); } } + + @DisplayName("비밀번호를 변경할 때,") + @Nested + class UpdatePassword { + + private Member saveMember(String loginId, String rawPassword) { + Member member = new Member(loginId, rawPassword, "홍길동", LocalDate.of(1995, 3, 15), "test@example.com"); + member.encryptPassword(passwordEncoder.encode(rawPassword)); + return memberRepository.save(member); + } + + @DisplayName("올바른 현재 비밀번호와 유효한 새 비밀번호로 변경하면, 새 비밀번호로 인증이 가능하다.") + @Test + void updatesPassword_whenValidCurrentAndNewPassword() { + // arrange + String currentPassword = "Test1234!"; + String newPassword = "NewPass123!"; + saveMember("testuser1", currentPassword); + + // act + memberService.updatePassword("testuser1", currentPassword, newPassword); + + // assert + Member authenticated = memberService.authenticate("testuser1", newPassword); + assertThat(authenticated.getLoginId()).isEqualTo("testuser1"); + } + + @DisplayName("존재하지 않는 loginId로 변경하면, UNAUTHORIZED 예외가 발생한다.") + @Test + void throwsUnauthorized_whenLoginIdNotFound() { + // act & assert + assertThatThrownBy(() -> memberService.updatePassword("nonexistent", "Test1234!", "NewPass123!")) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED)); + } + + @DisplayName("현재 비밀번호가 일치하지 않으면, UNAUTHORIZED 예외가 발생한다.") + @Test + void throwsUnauthorized_whenCurrentPasswordIsWrong() { + // arrange + saveMember("testuser1", "Test1234!"); + + // act & assert + assertThatThrownBy(() -> memberService.updatePassword("testuser1", "Wrong1234!", "NewPass123!")) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED)); + } + + @DisplayName("새 비밀번호가 현재 비밀번호와 동일하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNewPasswordSameAsCurrent() { + // arrange + String password = "Test1234!"; + saveMember("testuser1", password); + + // act & assert + assertThatThrownBy(() -> memberService.updatePassword("testuser1", password, password)) + .isInstanceOf(CoreException.class) + .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + } } \ No newline at end of file From 65b637e499f76e58920239006c1548dc757d7030 Mon Sep 17 00:00:00 2001 From: letter333 Date: Thu, 5 Feb 2026 00:16:47 +0900 Subject: [PATCH 16/22] =?UTF-8?q?feat:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EB=B3=80=EA=B2=BD=20API=20=EC=97=94=EB=93=9C?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EB=B0=8F=20E2E=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PATCH /api/v1/members/me/password 엔드포인트 추가, 헤더 PW와 Body currentPassword 일치 검증, E2E 테스트 8건 추가 Co-Authored-By: Claude Opus 4.5 --- .../application/member/MemberFacade.java | 4 + .../api/member/MemberV1ApiSpec.java | 6 + .../api/member/MemberV1Controller.java | 15 ++ .../interfaces/api/member/MemberV1Dto.java | 3 + .../api/member/MemberV1ApiE2ETest.java | 174 ++++++++++++++++++ http/commerce-api/member-v1.http | 13 +- 6 files changed, 214 insertions(+), 1 deletion(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java index dd2137c9..6ac44911 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java @@ -22,4 +22,8 @@ public MemberInfo getMyInfo(String loginId, String password) { Member member = memberService.authenticate(loginId, password); return MemberInfo.from(member); } + + public void updatePassword(String loginId, String currentPassword, String newPassword) { + memberService.updatePassword(loginId, currentPassword, newPassword); + } } \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java index 9da54940..faaec90b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java @@ -18,4 +18,10 @@ public interface MemberV1ApiSpec { description = "인증된 회원의 정보를 조회합니다." ) ApiResponse getMyInfo(String loginId, String password); + + @Operation( + summary = "비밀번호 변경", + description = "인증된 회원의 비밀번호를 변경합니다." + ) + ApiResponse updatePassword(String loginId, String password, MemberV1Dto.UpdatePasswordRequest request); } \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java index 12c1e45d..476be179 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java @@ -8,6 +8,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; @@ -54,6 +55,20 @@ public ApiResponse getMyInfo( return ApiResponse.success(response); } + @PatchMapping("/me/password") + @Override + public ApiResponse updatePassword( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, + @RequestBody MemberV1Dto.UpdatePasswordRequest request + ) { + if (!password.equals(request.currentPassword())) { + throw new CoreException(ErrorType.BAD_REQUEST, "인증 정보가 일치하지 않습니다."); + } + memberFacade.updatePassword(loginId, request.currentPassword(), request.newPassword()); + return ApiResponse.success(); + } + private LocalDate parseBirthday(String birthday) { if (birthday == null || birthday.isBlank()) { throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 비어있을 수 없습니다."); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java index 6308d6a1..177a8572 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java @@ -18,6 +18,9 @@ public static SignUpResponse from(MemberInfo info) { } } + public record UpdatePasswordRequest(String currentPassword, String newPassword) { + } + public record MyInfoResponse(String loginId, String name, String birthday, String email) { public static MyInfoResponse from(MemberInfo info) { return new MyInfoResponse( diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ApiE2ETest.java index 21cd2bf4..83ead0dc 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ApiE2ETest.java @@ -16,6 +16,7 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.crypto.password.PasswordEncoder; @@ -229,4 +230,177 @@ void returnsBadRequest_whenHeaderMissing() { assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); } } + + @DisplayName("PATCH /api/v1/members/me/password") + @Nested + class UpdatePassword { + + private Member saveMember(String loginId, String rawPassword) { + Member member = new Member(loginId, rawPassword, "홍길동", LocalDate.of(1995, 3, 15), "test@example.com"); + member.encryptPassword(passwordEncoder.encode(rawPassword)); + return memberRepository.save(member); + } + + private HttpEntity createRequest( + String loginId, String headerPassword, String currentPassword, String newPassword + ) { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", loginId); + headers.set("X-Loopers-LoginPw", headerPassword); + headers.setContentType(MediaType.APPLICATION_JSON); + return new HttpEntity<>(new MemberV1Dto.UpdatePasswordRequest(currentPassword, newPassword), headers); + } + + @DisplayName("올바른 인증 정보와 유효한 새 비밀번호로 변경하면, 200 OK를 반환한다.") + @Test + void returnsOk_whenValidRequest() { + // arrange + saveMember("testuser1", "Test1234!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/me/password", HttpMethod.PATCH, + createRequest("testuser1", "Test1234!", "Test1234!", "NewPass123!"), + responseType + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @DisplayName("비밀번호 변경 후, 새 비밀번호로 내 정보 조회가 가능하다.") + @Test + void canLoginWithNewPassword_afterPasswordUpdate() { + // arrange + saveMember("testuser1", "Test1234!"); + testRestTemplate.exchange( + ENDPOINT + "/me/password", HttpMethod.PATCH, + createRequest("testuser1", "Test1234!", "Test1234!", "NewPass123!"), + new ParameterizedTypeReference>() {} + ); + + // act + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser1"); + headers.set("X-Loopers-LoginPw", "NewPass123!"); + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/me", HttpMethod.GET, new HttpEntity<>(headers), responseType + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().loginId()).isEqualTo("testuser1") + ); + } + + @DisplayName("존재하지 않는 loginId로 변경하면, 401 Unauthorized를 반환한다.") + @Test + void returnsUnauthorized_whenLoginIdNotFound() { + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/me/password", HttpMethod.PATCH, + createRequest("nonexistent", "Test1234!", "Test1234!", "NewPass123!"), + responseType + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @DisplayName("현재 비밀번호가 일치하지 않으면, 401 Unauthorized를 반환한다.") + @Test + void returnsUnauthorized_whenCurrentPasswordWrong() { + // arrange + saveMember("testuser1", "Test1234!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/me/password", HttpMethod.PATCH, + createRequest("testuser1", "Wrong1234!", "Wrong1234!", "NewPass123!"), + responseType + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @DisplayName("헤더 비밀번호와 Body currentPassword가 다르면, 400 Bad Request를 반환한다.") + @Test + void returnsBadRequest_whenHeaderPasswordMismatchesBody() { + // arrange + saveMember("testuser1", "Test1234!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/me/password", HttpMethod.PATCH, + createRequest("testuser1", "Test1234!", "Different1!", "NewPass123!"), + responseType + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("새 비밀번호가 현재 비밀번호와 동일하면, 400 Bad Request를 반환한다.") + @Test + void returnsBadRequest_whenNewPasswordSameAsCurrent() { + // arrange + saveMember("testuser1", "Test1234!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/me/password", HttpMethod.PATCH, + createRequest("testuser1", "Test1234!", "Test1234!", "Test1234!"), + responseType + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("새 비밀번호가 8자 미만이면, 400 Bad Request를 반환한다.") + @Test + void returnsBadRequest_whenNewPasswordTooShort() { + // arrange + saveMember("testuser1", "Test1234!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/me/password", HttpMethod.PATCH, + createRequest("testuser1", "Test1234!", "Test1234!", "New12!"), + responseType + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("인증 헤더가 누락되면, 400 Bad Request를 반환한다.") + @Test + void returnsBadRequest_whenHeaderMissing() { + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>( + new MemberV1Dto.UpdatePasswordRequest("Test1234!", "NewPass123!"), headers + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/me/password", HttpMethod.PATCH, request, responseType + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } } \ No newline at end of file diff --git a/http/commerce-api/member-v1.http b/http/commerce-api/member-v1.http index f3519da9..b00d6462 100644 --- a/http/commerce-api/member-v1.http +++ b/http/commerce-api/member-v1.http @@ -13,4 +13,15 @@ Content-Type: application/json ### 내 정보 조회 GET {{commerce-api}}/api/v1/members/me X-Loopers-LoginId: testuser1 -X-Loopers-LoginPw: Test1234! \ No newline at end of file +X-Loopers-LoginPw: Test1234! + +### 비밀번호 변경 +PATCH {{commerce-api}}/api/v1/members/me/password +X-Loopers-LoginId: testuser1 +X-Loopers-LoginPw: Test1234! +Content-Type: application/json + +{ + "currentPassword": "Test1234!", + "newPassword": "NewPass123!" +} \ No newline at end of file From 7b50186216bc43bcb6b260c0240450623e3f5cd3 Mon Sep 17 00:00:00 2001 From: letter333 Date: Thu, 5 Feb 2026 00:20:44 +0900 Subject: [PATCH 17/22] =?UTF-8?q?docs:=20CLAUDE.md=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EC=A0=9D=ED=8A=B8=20=EA=B7=9C=EC=B9=99=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API 응답 규칙, 의존성 방향, 인증 헤더 규칙, TDD 단계별 진행 규칙, 테스트 경계값 케이스 가이드 추가 Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 0f5df10f..1ac9cbe4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,6 +103,19 @@ docker compose -f docker/monitoring-compose.yml up -d - **Kafka 배치 소비**: 3000건 배치, 수동 커밋 (Manual ACK) - **Redis 읽기 분산**: Master 쓰기, Replica 읽기 분리 +## API 응답 규칙 +- 모든 응답은 `ApiResponse`로 래핑 +- 성공: `ApiResponse.success(data)` 반환 +- 실패: `CoreException(ErrorType)` throw → GlobalExceptionHandler에서 처리 +- 생성 API: `@ResponseStatus(HttpStatus.CREATED)` + +## 의존성 방향 (외부 → 내부) +``` +interfaces → application → domain ← infrastructure +``` +- domain 계층은 다른 계층에 의존하지 않음 +- infrastructure는 domain의 Repository 인터페이스를 구현 + ## 문서 작성 ### 다이어그램 작성 - ERD, 시퀀스 다이어그램, 클래스 다이어그램 등 작성 시 mermaid를 이용한 마크다운으로 작성. @@ -112,6 +125,12 @@ docker compose -f docker/monitoring-compose.yml up -d - **대원칙** : 방향성 및 주요 의사 결정은 개발자에게 제안만 할 수 있으며, 최종 승인된 사항을 기반으로 작업을 수행. - **중간 결과 보고** : AI 가 반복적인 동작을 하거나, 요청하지 않은 기능을 구현, 테스트 삭제를 임의로 진행할 경우 개발자가 개입. - **설계 주도권 유지** : AI 가 임의판단을 하지 않고, 방향성에 대한 제안 등을 진행할 수 있으나 개발자의 승인을 받은 후 수행. +- 구현은 한 단계씩 순서대로 진행 및 단계가 끝날 때 마다 핵심 개념/키워드 설명. +- API는 RESTFul API로 구현 +### 인증 요청 +- 유저 정보가 필요한 모든 요청은 아래 헤더를 통해 요청 +* X-Loopers-LoginId : 로그인 ID +* X-Loopers-LoginPw : 비밀번호 ### 개발 Workflow - TDD (Red > Green > Refactor) - 모든 테스트는 3A 원칙으로 작성할 것 (Arrange - Act - Assert) @@ -142,6 +161,7 @@ docker compose -f docker/monitoring-compose.yml up -d - Domain Entity와 Persistence Entity는 구분하여 구현 - 필요한 의존성은 적절히 관리하여 최소화 - 통합 테스트는 테스트 컨테이너를 이용해 진행 +- 테스트 코드 작성 시 MIN, MAX, EDGE 케이스를 고려하여 작성 ### 3. Priority 1. 실제 동작하는 해결책만 고려 From 8e8d396c0240d28cda3084f9f1834832c4e6440f Mon Sep 17 00:00:00 2001 From: letter333 Date: Thu, 5 Feb 2026 00:55:43 +0900 Subject: [PATCH 18/22] =?UTF-8?q?refactor:=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=B2=80=EC=A6=9D=20=EC=B1=85?= =?UTF-8?q?=EC=9E=84=EC=9D=84=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 도메인 계층의 PasswordEncoder 의존성을 제거하고, 유즈케이스 검증(현재 비밀번호 확인, 동일 비밀번호 확인)을 MemberService로 이동하여 의존성 방향 규칙 준수 Co-Authored-By: Claude Opus 4.5 --- .../com/loopers/domain/member/Member.java | 13 +--- .../loopers/domain/member/MemberService.java | 11 +++- .../com/loopers/domain/member/MemberTest.java | 64 ++++--------------- 3 files changed, 25 insertions(+), 63 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java index 1f9cec9b..43ceac03 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java @@ -2,7 +2,6 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; -import org.springframework.security.crypto.password.PasswordEncoder; import java.time.LocalDate; import java.time.format.DateTimeFormatter; @@ -49,15 +48,9 @@ public void encryptPassword(String encodedPassword) { this.password = encodedPassword; } - public void changePassword(String currentPassword, String newPassword, PasswordEncoder passwordEncoder) { - if (!passwordEncoder.matches(currentPassword, this.password)) { - throw new CoreException(ErrorType.UNAUTHORIZED, "현재 비밀번호가 일치하지 않습니다."); - } - if (passwordEncoder.matches(newPassword, this.password)) { - throw new CoreException(ErrorType.BAD_REQUEST, "새 비밀번호는 현재 비밀번호와 달라야 합니다."); - } - validatePassword(newPassword, this.birthday); - this.password = passwordEncoder.encode(newPassword); + public void changePassword(String newRawPassword, String newEncodedPassword) { + validatePassword(newRawPassword, this.birthday); + this.password = newEncodedPassword; } private void validateLoginId(String loginId) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java index ac8b77f8..112785f9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java @@ -46,7 +46,16 @@ public Member signUp(String loginId, String password, String name, LocalDate bir public void updatePassword(String loginId, String currentPassword, String newPassword) { Member member = memberRepository.findByLoginId(loginId) .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED)); - member.changePassword(currentPassword, newPassword, passwordEncoder); + + if (!passwordEncoder.matches(currentPassword, member.getPassword())) { + throw new CoreException(ErrorType.UNAUTHORIZED, "현재 비밀번호가 일치하지 않습니다."); + } + if (passwordEncoder.matches(newPassword, member.getPassword())) { + throw new CoreException(ErrorType.BAD_REQUEST, "새 비밀번호는 현재 비밀번호와 달라야 합니다."); + } + + String encodedNewPassword = passwordEncoder.encode(newPassword); + member.changePassword(newPassword, encodedNewPassword); memberRepository.updatePassword(loginId, member.getPassword()); } } \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java index 6865a0df..ac10a347 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java @@ -5,8 +5,6 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.security.crypto.password.PasswordEncoder; import java.time.LocalDate; @@ -312,67 +310,29 @@ void replacesPasswordWithEncoded() { @Nested class ChangePassword { - private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); - - private Member createMemberWithEncryptedPassword() { - Member member = new Member(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); - member.encryptPassword(passwordEncoder.encode(VALID_PASSWORD)); - return member; - } - @DisplayName("유효한 새 비밀번호로 변경하면, 비밀번호가 변경된다.") @Test void changesPassword_whenNewPasswordIsValid() { // arrange - Member member = createMemberWithEncryptedPassword(); - String newPassword = "NewPass123!"; - - // act - member.changePassword(VALID_PASSWORD, newPassword, passwordEncoder); - - // assert - assertThat(passwordEncoder.matches(newPassword, member.getPassword())).isTrue(); - } - - @DisplayName("현재 비밀번호가 일치하지 않으면, UNAUTHORIZED 예외가 발생한다.") - @Test - void throwsUnauthorized_whenCurrentPasswordIsWrong() { - // arrange - Member member = createMemberWithEncryptedPassword(); - - // act - CoreException result = assertThrows(CoreException.class, () -> - member.changePassword("WrongPass1!", "NewPass123!", passwordEncoder) - ); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED); - } - - @DisplayName("새 비밀번호가 현재 비밀번호와 동일하면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequest_whenNewPasswordIsSameAsCurrent() { - // arrange - Member member = createMemberWithEncryptedPassword(); + Member member = new Member(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); + String newEncodedPassword = "$2a$10$newEncodedPasswordHash"; // act - CoreException result = assertThrows(CoreException.class, () -> - member.changePassword(VALID_PASSWORD, VALID_PASSWORD, passwordEncoder) - ); + member.changePassword("NewPass123!", newEncodedPassword); // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(member.getPassword()).isEqualTo(newEncodedPassword); } @DisplayName("새 비밀번호가 8자 미만이면, BAD_REQUEST 예외가 발생한다.") @Test void throwsBadRequest_whenNewPasswordIsTooShort() { // arrange - Member member = createMemberWithEncryptedPassword(); + Member member = new Member(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); // act CoreException result = assertThrows(CoreException.class, () -> - member.changePassword(VALID_PASSWORD, "New12!", passwordEncoder) + member.changePassword("New12!", "encoded") ); // assert @@ -383,11 +343,11 @@ void throwsBadRequest_whenNewPasswordIsTooShort() { @Test void throwsBadRequest_whenNewPasswordIsTooLong() { // arrange - Member member = createMemberWithEncryptedPassword(); + Member member = new Member(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); // act CoreException result = assertThrows(CoreException.class, () -> - member.changePassword(VALID_PASSWORD, "NewPass12345678!!", passwordEncoder) + member.changePassword("NewPass12345678!!", "encoded") ); // assert @@ -398,11 +358,11 @@ void throwsBadRequest_whenNewPasswordIsTooLong() { @Test void throwsBadRequest_whenNewPasswordContainsBirthday() { // arrange - Member member = createMemberWithEncryptedPassword(); + Member member = new Member(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); // act CoreException result = assertThrows(CoreException.class, () -> - member.changePassword(VALID_PASSWORD, "A19950315!", passwordEncoder) + member.changePassword("A19950315!", "encoded") ); // assert @@ -413,11 +373,11 @@ void throwsBadRequest_whenNewPasswordContainsBirthday() { @Test void throwsBadRequest_whenNewPasswordContainsInvalidCharacters() { // arrange - Member member = createMemberWithEncryptedPassword(); + Member member = new Member(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); // act CoreException result = assertThrows(CoreException.class, () -> - member.changePassword(VALID_PASSWORD, "New한글1234!", passwordEncoder) + member.changePassword("New한글1234!", "encoded") ); // assert From d5d4041032d9c4f9f502b919d782c1046540d14a Mon Sep 17 00:00:00 2001 From: letter333 Date: Thu, 5 Feb 2026 01:06:51 +0900 Subject: [PATCH 19/22] =?UTF-8?q?chore:=20.gitignore=EC=97=90=20docs/study?= =?UTF-8?q?/=20=ED=95=99=EC=8A=B5=20=ED=8F=B4=EB=8D=94=20=EC=A0=9C?= =?UTF-8?q?=EC=99=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.5 --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 5a979af6..1a1ad415 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ out/ ### Kotlin ### .kotlin + +### Study ### +docs/study/ From e622564dc7a15334aef4f1841647016e35dfb92b Mon Sep 17 00:00:00 2001 From: letter333 Date: Thu, 5 Feb 2026 22:48:12 +0900 Subject: [PATCH 20/22] =?UTF-8?q?feat:=20=EB=82=B4=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20=EC=9D=B4=EB=A6=84=20=EB=A7=88?= =?UTF-8?q?=EC=A7=80=EB=A7=89=20=EA=B8=80=EC=9E=90=20=EB=A7=88=EC=8A=A4?= =?UTF-8?q?=ED=82=B9=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MemberInfo에 withMaskedName() 메서드 추가하여 이름의 마지막 글자를 *로 마스킹하는 기능 구현 Co-Authored-By: Claude Opus 4.5 --- .../application/member/MemberFacade.java | 2 +- .../application/member/MemberInfo.java | 5 +++ .../application/member/MemberFacadeTest.java | 2 +- .../application/member/MemberInfoTest.java | 33 +++++++++++++++++++ .../api/member/MemberV1ApiE2ETest.java | 2 +- 5 files changed, 41 insertions(+), 3 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java index dd2137c9..c0cd57f6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java @@ -20,6 +20,6 @@ public MemberInfo signUp(String loginId, String password, String name, LocalDate public MemberInfo getMyInfo(String loginId, String password) { Member member = memberService.authenticate(loginId, password); - return MemberInfo.from(member); + return MemberInfo.from(member).withMaskedName(); } } \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java index a1d722f7..d5b491d2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java @@ -14,4 +14,9 @@ public static MemberInfo from(Member member) { member.getEmail() ); } + + public MemberInfo withMaskedName() { + String maskedName = name.substring(0, name.length() - 1) + "*"; + return new MemberInfo(id, loginId, maskedName, birthday, email); + } } \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeTest.java index 10ddb106..9d5aae35 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeTest.java @@ -90,7 +90,7 @@ void getMyInfo_withValidCredentials_returnsMemberInfo() { // assert assertAll( () -> assertThat(info.loginId()).isEqualTo("testuser1"), - () -> assertThat(info.name()).isEqualTo("홍길동"), + () -> assertThat(info.name()).isEqualTo("홍길*"), () -> assertThat(info.birthday()).isEqualTo(LocalDate.of(1995, 3, 15)), () -> assertThat(info.email()).isEqualTo("test@example.com") ); diff --git a/apps/commerce-api/src/test/java/com/loopers/application/member/MemberInfoTest.java b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberInfoTest.java index 34b580ed..a704ec10 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/member/MemberInfoTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberInfoTest.java @@ -35,4 +35,37 @@ void createsMemberInfo_fromDomain_withoutPassword() { ); } } + + @DisplayName("이름 마스킹 시,") + @Nested + class MaskName { + + @DisplayName("3자 이상 이름이면, 마지막 글자가 *로 마스킹된다.") + @Test + void masksLastCharacter_whenNameHasThreeOrMoreCharacters() { + // arrange + Member member = new Member(1L, "testuser1", "$2a$10$encodedHash", "홍길동", LocalDate.of(1995, 3, 15), "test@example.com"); + MemberInfo info = MemberInfo.from(member); + + // act + MemberInfo masked = info.withMaskedName(); + + // assert + assertThat(masked.name()).isEqualTo("홍길*"); + } + + @DisplayName("2자 이름이면, 마지막 글자가 *로 마스킹된다.") + @Test + void masksLastCharacter_whenNameHasTwoCharacters() { + // arrange + Member member = new Member(1L, "testuser1", "$2a$10$encodedHash", "홍길", LocalDate.of(1995, 3, 15), "test@example.com"); + MemberInfo info = MemberInfo.from(member); + + // act + MemberInfo masked = info.withMaskedName(); + + // assert + assertThat(masked.name()).isEqualTo("홍*"); + } + } } \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ApiE2ETest.java index 21cd2bf4..205c0e78 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ApiE2ETest.java @@ -184,7 +184,7 @@ void returnsOk_whenValidCredentials() { assertAll( () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), () -> assertThat(response.getBody().data().loginId()).isEqualTo("testuser1"), - () -> assertThat(response.getBody().data().name()).isEqualTo("홍길동"), + () -> assertThat(response.getBody().data().name()).isEqualTo("홍길*"), () -> assertThat(response.getBody().data().birthday()).isEqualTo("1995-03-15"), () -> assertThat(response.getBody().data().email()).isEqualTo("test@example.com") ); From 7da2806dcb105b27df751bff516c25a77a2cec2d Mon Sep 17 00:00:00 2001 From: letter333 Date: Thu, 5 Feb 2026 22:59:20 +0900 Subject: [PATCH 21/22] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20ID?= =?UTF-8?q?=20=EC=98=81=EB=AC=B8/=EC=88=AB=EC=9E=90=EB=A7=8C=20=ED=97=88?= =?UTF-8?q?=EC=9A=A9=ED=95=98=EB=8A=94=20=EA=B2=80=EC=A6=9D=20=EA=B7=9C?= =?UTF-8?q?=EC=B9=99=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LOGIN_ID_PATTERN을 추가하여 영문 대소문자와 숫자만 허용 Co-Authored-By: Claude Opus 4.5 --- .../com/loopers/domain/member/Member.java | 4 +++ .../com/loopers/domain/member/MemberTest.java | 34 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java index ce87998a..d2f5a64f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java @@ -9,6 +9,7 @@ public class Member { + private static final Pattern LOGIN_ID_PATTERN = Pattern.compile("^[a-zA-Z0-9]+$"); private static final Pattern PASSWORD_PATTERN = Pattern.compile("^[a-zA-Z0-9!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?`~]+$"); private static final Pattern NAME_PATTERN = Pattern.compile("^[가-힣]{2,20}$"); private static final Pattern EMAIL_PATTERN = Pattern.compile("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"); @@ -52,6 +53,9 @@ private void validateLoginId(String loginId) { if (loginId == null || loginId.isBlank()) { throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 비어있을 수 없습니다."); } + if (!LOGIN_ID_PATTERN.matcher(loginId).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 영문과 숫자만 입력 가능합니다."); + } } private void validatePassword(String password, LocalDate birthday) { diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java index 5187b164..48507346 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java @@ -68,6 +68,40 @@ void throwsBadRequest_whenLoginIdIsBlank() { // assert assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } + + @DisplayName("영문과 숫자로만 구성되면, 정상적으로 생성된다.") + @Test + void createsSuccessfully_whenLoginIdIsAlphanumeric() { + // act + Member member = new Member("testUser123", VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); + + // assert + assertThat(member.getLoginId()).isEqualTo("testUser123"); + } + + @DisplayName("한글이 포함되면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenLoginIdContainsKorean() { + // act + CoreException result = assertThrows(CoreException.class, () -> + new Member("test유저1", VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("특수문자가 포함되면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenLoginIdContainsSpecialCharacters() { + // act + CoreException result = assertThrows(CoreException.class, () -> + new Member("test@user!", VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } } @DisplayName("비밀번호를 검증할 때,") From d2aa7cf4686ca1796c0591554f6aeceb32e449ec Mon Sep 17 00:00:00 2001 From: letter333 Date: Thu, 5 Feb 2026 23:18:52 +0900 Subject: [PATCH 22/22] =?UTF-8?q?refactor:=20DTO=EC=97=90=20Bean=20Validat?= =?UTF-8?q?ion=20=EC=A0=81=EC=9A=A9=20=EB=B0=8F=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EA=B2=80=EC=A6=9D=20=EC=B1=85=EC=9E=84=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DTO: @Valid + Bean Validation으로 형식 검증 (null, 패턴, 길이) - Domain: 비즈니스 규칙만 유지 (생년월일 미래 불가, 비밀번호에 생년월일 포함 불가) - ApiControllerAdvice에 MethodArgumentNotValidException 핸들러 추가 Co-Authored-By: Claude Opus 4.5 --- apps/commerce-api/build.gradle.kts | 1 + .../com/loopers/domain/member/Member.java | 50 +--- .../interfaces/api/ApiControllerAdvice.java | 10 + .../api/member/MemberV1Controller.java | 18 +- .../interfaces/api/member/MemberV1Dto.java | 27 ++- .../com/loopers/domain/member/MemberTest.java | 229 +----------------- 6 files changed, 47 insertions(+), 288 deletions(-) diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 9ad4d8ea..dae7d09a 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -11,6 +11,7 @@ dependencies { // web implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}") diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java index d2f5a64f..60b40828 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java @@ -5,14 +5,9 @@ import java.time.LocalDate; import java.time.format.DateTimeFormatter; -import java.util.regex.Pattern; public class Member { - private static final Pattern LOGIN_ID_PATTERN = Pattern.compile("^[a-zA-Z0-9]+$"); - private static final Pattern PASSWORD_PATTERN = Pattern.compile("^[a-zA-Z0-9!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?`~]+$"); - private static final Pattern NAME_PATTERN = Pattern.compile("^[가-힣]{2,20}$"); - private static final Pattern EMAIL_PATTERN = Pattern.compile("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"); private static final DateTimeFormatter BIRTHDAY_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); private Long id; @@ -23,11 +18,8 @@ public class Member { private String email; public Member(String loginId, String password, String name, LocalDate birthday, String email) { - validateLoginId(loginId); validateBirthday(birthday); - validatePassword(password, birthday); - validateName(name); - validateEmail(email); + validatePasswordNotContainsBirthday(password, birthday); this.loginId = loginId; this.password = password; @@ -49,23 +41,14 @@ public void encryptPassword(String encodedPassword) { this.password = encodedPassword; } - private void validateLoginId(String loginId) { - if (loginId == null || loginId.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 비어있을 수 없습니다."); - } - if (!LOGIN_ID_PATTERN.matcher(loginId).matches()) { - throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 영문과 숫자만 입력 가능합니다."); + private void validateBirthday(LocalDate birthday) { + if (birthday != null && birthday.isAfter(LocalDate.now())) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 미래 날짜일 수 없습니다."); } } - private void validatePassword(String password, LocalDate birthday) { - if (password == null || password.length() < 8 || password.length() > 16) { - throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 8~16자여야 합니다."); - } - if (!PASSWORD_PATTERN.matcher(password).matches()) { - throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 영문 대소문자, 숫자, 특수문자만 입력 가능합니다."); - } - if (birthday != null) { + private void validatePasswordNotContainsBirthday(String password, LocalDate birthday) { + if (password != null && birthday != null) { String birthdayStr = birthday.format(BIRTHDAY_FORMATTER); if (password.contains(birthdayStr)) { throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호에 생년월일을 포함할 수 없습니다."); @@ -73,27 +56,6 @@ private void validatePassword(String password, LocalDate birthday) { } } - private void validateName(String name) { - if (name == null || !NAME_PATTERN.matcher(name).matches()) { - throw new CoreException(ErrorType.BAD_REQUEST, "이름은 한글 2~20자여야 합니다."); - } - } - - private void validateBirthday(LocalDate birthday) { - if (birthday == null) { - throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 비어있을 수 없습니다."); - } - if (birthday.isAfter(LocalDate.now())) { - throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 미래 날짜일 수 없습니다."); - } - } - - private void validateEmail(String email) { - if (email == null || !EMAIL_PATTERN.matcher(email).matches()) { - throw new CoreException(ErrorType.BAD_REQUEST, "올바른 이메일 형식이 아닙니다."); - } - } - public Long getId() { return id; } 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 405b0055..a004f5ca 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 @@ -8,6 +8,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingRequestHeaderException; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; @@ -54,6 +55,15 @@ public ResponseEntity> handleBadRequest(MissingServletRequestPara return failureResponse(ErrorType.BAD_REQUEST, message); } + @ExceptionHandler + public ResponseEntity> handleValidation(MethodArgumentNotValidException e) { + String message = e.getBindingResult().getFieldErrors().stream() + .findFirst() + .map(error -> error.getDefaultMessage()) + .orElse("입력값이 올바르지 않습니다."); + return failureResponse(ErrorType.BAD_REQUEST, message); + } + @ExceptionHandler public ResponseEntity> handleBadRequest(HttpMessageNotReadableException e) { String errorMessage; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java index 12c1e45d..f3535d06 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java @@ -3,8 +3,7 @@ import com.loopers.application.member.MemberFacade; import com.loopers.application.member.MemberInfo; import com.loopers.interfaces.api.ApiResponse; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.GetMapping; @@ -16,7 +15,6 @@ import org.springframework.web.bind.annotation.RestController; import java.time.LocalDate; -import java.time.format.DateTimeParseException; @RequiredArgsConstructor @RestController @@ -29,9 +27,9 @@ public class MemberV1Controller implements MemberV1ApiSpec { @ResponseStatus(HttpStatus.CREATED) @Override public ApiResponse signUp( - @RequestBody MemberV1Dto.SignUpRequest request + @Valid @RequestBody MemberV1Dto.SignUpRequest request ) { - LocalDate birthday = parseBirthday(request.birthday()); + LocalDate birthday = LocalDate.parse(request.birthday()); MemberInfo info = memberFacade.signUp( request.loginId(), request.password(), @@ -54,14 +52,4 @@ public ApiResponse getMyInfo( return ApiResponse.success(response); } - private LocalDate parseBirthday(String birthday) { - if (birthday == null || birthday.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 비어있을 수 없습니다."); - } - try { - return LocalDate.parse(birthday); - } catch (DateTimeParseException e) { - throw new CoreException(ErrorType.BAD_REQUEST, "생년월일 형식이 올바르지 않습니다. (yyyy-MM-dd)"); - } - } } \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java index 6308d6a1..8f465017 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java @@ -1,10 +1,35 @@ package com.loopers.interfaces.api.member; import com.loopers.application.member.MemberInfo; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; public class MemberV1Dto { - public record SignUpRequest(String loginId, String password, String name, String birthday, String email) { + public record SignUpRequest( + @NotBlank(message = "로그인 ID는 비어있을 수 없습니다.") + @Pattern(regexp = "^[a-zA-Z0-9]+$", message = "로그인 ID는 영문과 숫자만 입력 가능합니다.") + String loginId, + + @NotBlank(message = "비밀번호는 비어있을 수 없습니다.") + @Size(min = 8, max = 16, message = "비밀번호는 8~16자여야 합니다.") + @Pattern(regexp = "^[a-zA-Z0-9!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?`~]+$", message = "비밀번호는 영문 대소문자, 숫자, 특수문자만 입력 가능합니다.") + String password, + + @NotBlank(message = "이름은 비어있을 수 없습니다.") + @Pattern(regexp = "^[가-힣]{2,20}$", message = "이름은 한글 2~20자여야 합니다.") + String name, + + @NotBlank(message = "생년월일은 비어있을 수 없습니다.") + @Pattern(regexp = "^\\d{4}-\\d{2}-\\d{2}$", message = "생년월일 형식이 올바르지 않습니다. (yyyy-MM-dd)") + String birthday, + + @NotBlank(message = "이메일은 비어있을 수 없습니다.") + @Email(message = "올바른 이메일 형식이 아닙니다.") + String email + ) { } public record SignUpResponse(Long id, String loginId, String name, String email) { diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java index 48507346..e5dff2fd 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java @@ -41,129 +41,10 @@ void createsMember_whenAllFieldsAreValid() { } } - @DisplayName("로그인 ID를 검증할 때,") - @Nested - class ValidateLoginId { - - @DisplayName("null이면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequest_whenLoginIdIsNull() { - // act - CoreException result = assertThrows(CoreException.class, () -> - new Member(null, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL) - ); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @DisplayName("빈 문자열이면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequest_whenLoginIdIsBlank() { - // act - CoreException result = assertThrows(CoreException.class, () -> - new Member(" ", VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL) - ); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @DisplayName("영문과 숫자로만 구성되면, 정상적으로 생성된다.") - @Test - void createsSuccessfully_whenLoginIdIsAlphanumeric() { - // act - Member member = new Member("testUser123", VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); - - // assert - assertThat(member.getLoginId()).isEqualTo("testUser123"); - } - - @DisplayName("한글이 포함되면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequest_whenLoginIdContainsKorean() { - // act - CoreException result = assertThrows(CoreException.class, () -> - new Member("test유저1", VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL) - ); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @DisplayName("특수문자가 포함되면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequest_whenLoginIdContainsSpecialCharacters() { - // act - CoreException result = assertThrows(CoreException.class, () -> - new Member("test@user!", VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL) - ); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - } - @DisplayName("비밀번호를 검증할 때,") @Nested class ValidatePassword { - @DisplayName("8자(MIN)이면, 정상적으로 생성된다.") - @Test - void createsSuccessfully_whenPasswordIsMinLength() { - // act - Member member = new Member(VALID_LOGIN_ID, "Test123!", VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); - - // assert - assertThat(member.getPassword()).isEqualTo("Test123!"); - } - - @DisplayName("16자(MAX)이면, 정상적으로 생성된다.") - @Test - void createsSuccessfully_whenPasswordIsMaxLength() { - // act - Member member = new Member(VALID_LOGIN_ID, "Test12345678901!", VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL); - - // assert - assertThat(member.getPassword()).isEqualTo("Test12345678901!"); - } - - @DisplayName("8자 미만이면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequest_whenPasswordIsTooShort() { - // act - CoreException result = assertThrows(CoreException.class, () -> - new Member(VALID_LOGIN_ID, "Test12!", VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL) - ); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @DisplayName("16자 초과이면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequest_whenPasswordIsTooLong() { - // act - CoreException result = assertThrows(CoreException.class, () -> - new Member(VALID_LOGIN_ID, "Test1234!Test1234", VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL) - ); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @DisplayName("허용되지 않는 문자(한글 등)가 포함되면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequest_whenPasswordContainsInvalidCharacters() { - // act - CoreException result = assertThrows(CoreException.class, () -> - new Member(VALID_LOGIN_ID, "Test한글1234!", VALID_NAME, VALID_BIRTHDAY, VALID_EMAIL) - ); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - @DisplayName("생년월일(yyyyMMdd)이 포함되면, BAD_REQUEST 예외가 발생한다.") @Test void throwsBadRequest_whenPasswordContainsBirthday() { @@ -180,73 +61,6 @@ void throwsBadRequest_whenPasswordContainsBirthday() { } } - @DisplayName("이름을 검증할 때,") - @Nested - class ValidateName { - - @DisplayName("한글 2자(MIN)이면, 정상적으로 생성된다.") - @Test - void createsSuccessfully_whenNameIsMinLength() { - // act - Member member = new Member(VALID_LOGIN_ID, VALID_PASSWORD, "홍길", VALID_BIRTHDAY, VALID_EMAIL); - - // assert - assertThat(member.getName()).isEqualTo("홍길"); - } - - @DisplayName("한글 20자(MAX)이면, 정상적으로 생성된다.") - @Test - void createsSuccessfully_whenNameIsMaxLength() { - // arrange - String maxName = "가나다라마바사아자차카타파하가나다라마바"; - - // act - Member member = new Member(VALID_LOGIN_ID, VALID_PASSWORD, maxName, VALID_BIRTHDAY, VALID_EMAIL); - - // assert - assertThat(member.getName()).isEqualTo(maxName); - } - - @DisplayName("한글 1자이면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequest_whenNameIsTooShort() { - // act - CoreException result = assertThrows(CoreException.class, () -> - new Member(VALID_LOGIN_ID, VALID_PASSWORD, "홍", VALID_BIRTHDAY, VALID_EMAIL) - ); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @DisplayName("한글 20자를 초과하면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequest_whenNameIsTooLong() { - // arrange - String longName = "가나다라마바사아자차카타파하가나다라마바사"; - - // act - CoreException result = assertThrows(CoreException.class, () -> - new Member(VALID_LOGIN_ID, VALID_PASSWORD, longName, VALID_BIRTHDAY, VALID_EMAIL) - ); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @DisplayName("한글이 아닌 문자가 포함되면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequest_whenNameContainsNonKorean() { - // act - CoreException result = assertThrows(CoreException.class, () -> - new Member(VALID_LOGIN_ID, VALID_PASSWORD, "홍gildong", VALID_BIRTHDAY, VALID_EMAIL) - ); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - } - @DisplayName("생년월일을 검증할 때,") @Nested class ValidateBirthday { @@ -264,18 +78,6 @@ void createsSuccessfully_whenBirthdayIsToday() { assertThat(member.getBirthday()).isEqualTo(today); } - @DisplayName("null이면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequest_whenBirthdayIsNull() { - // act - CoreException result = assertThrows(CoreException.class, () -> - new Member(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, null, VALID_EMAIL) - ); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - @DisplayName("미래 날짜이면, BAD_REQUEST 예외가 발생한다.") @Test void throwsBadRequest_whenBirthdayIsFuture() { @@ -292,35 +94,6 @@ void throwsBadRequest_whenBirthdayIsFuture() { } } - @DisplayName("이메일을 검증할 때,") - @Nested - class ValidateEmail { - - @DisplayName("null이면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequest_whenEmailIsNull() { - // act - CoreException result = assertThrows(CoreException.class, () -> - new Member(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, null) - ); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @DisplayName("이메일 형식이 아니면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequest_whenEmailFormatIsInvalid() { - // act - CoreException result = assertThrows(CoreException.class, () -> - new Member(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTHDAY, "invalid-email") - ); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - } - @DisplayName("비밀번호를 암호화할 때,") @Nested class EncryptPassword { @@ -339,4 +112,4 @@ void replacesPasswordWithEncoded() { assertThat(member.getPassword()).isEqualTo(encodedPassword); } } -} +} \ No newline at end of file