From 3562127dd89899cfb3677664a5f2e854f0e0145d Mon Sep 17 00:00:00 2001 From: SukheeChoi <95064440+SukheeChoi@users.noreply.github.com> Date: Mon, 2 Feb 2026 23:40:01 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MemberModel 엔티티 및 MemberRepository 인터페이스 추가 - MemberService: 로그인 ID 중복 검증, 비밀번호 규칙 검증, 암호화 저장 - MemberV1Controller: POST /api/v1/members API - PasswordEncoderConfig: BCrypt 설정 - ApiControllerAdvice: @Valid 검증 예외 핸들러 추가 - 단위 테스트 6개 추가 (Service 4개, Controller 2개) Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 245 ++++++++++ apps/commerce-api/build.gradle.kts | 4 + .../loopers/domain/member/MemberModel.java | 58 +++ .../domain/member/MemberRepository.java | 9 + .../loopers/domain/member/MemberService.java | 44 ++ .../member/MemberJpaRepository.java | 11 + .../member/MemberRepositoryImpl.java | 30 ++ .../interfaces/api/ApiControllerAdvice.java | 9 + .../api/member/MemberV1Controller.java | 42 ++ .../interfaces/api/member/MemberV1Dto.java | 28 ++ .../support/auth/PasswordEncoderConfig.java | 15 + .../domain/member/MemberServiceTest.java | 108 +++++ .../api/member/MemberV1ControllerTest.java | 86 ++++ plans/week1.md | 448 ++++++++++++++++++ 14 files changed, 1137 insertions(+) create mode 100644 CLAUDE.md create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/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/main/java/com/loopers/support/auth/PasswordEncoderConfig.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ControllerTest.java create mode 100644 plans/week1.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..72947388 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,245 @@ +# CLAUDE.md + +이 파일은 Claude Code가 이 저장소에서 작업할 때 참고하는 가이드입니다. + +## 프로젝트 개요 + +Loopers에서 제공하는 Spring + Java 기반 멀티 모듈 템플릿 프로젝트입니다. 커머스 도메인을 위한 API, Batch, Streamer 애플리케이션을 포함합니다. + +## 기술 스택 및 버전 + +| 기술 | 버전 | +|------|------| +| Java | 21 | +| Gradle | 8.13 | +| Spring Boot | 3.4.4 | +| Spring Cloud | 2024.0.1 | +| Spring Dependency Management | 1.1.7 | +| QueryDSL | (Spring Boot BOM 관리) | +| SpringDoc OpenAPI | 2.7.0 | +| Lombok | (Spring Boot BOM 관리) | +| Jackson | (Spring Boot BOM 관리) | +| MySQL Connector | (Spring Boot BOM 관리) | +| Redis | (Spring Boot BOM 관리) | +| Kafka | (Spring Boot BOM 관리) | +| Micrometer (Prometheus) | (Spring Boot BOM 관리) | +| Testcontainers | (Spring Boot BOM 관리) | +| JUnit 5 | (Spring Boot BOM 관리) | +| Mockito | 5.14.0 | +| SpringMockK | 4.0.2 | +| Instancio | 5.0.2 | + +## 모듈 구조 + +``` +Root +├── apps (실행 가능한 SpringBootApplication) +│ ├── commerce-api - REST API 서버 (Web, JPA, Redis, OpenAPI) +│ ├── commerce-batch - Spring Batch 애플리케이션 +│ └── commerce-streamer - Kafka Consumer 애플리케이션 +├── modules (재사용 가능한 설정 모듈) +│ ├── jpa - JPA/QueryDSL 설정, MySQL 연동 +│ ├── redis - Redis 설정 (Master-Replica 구조) +│ └── kafka - Kafka 설정 +└── supports (부가 기능 모듈) + ├── jackson - Jackson 직렬화 설정 + ├── logging - 로깅 설정 (Slack Appender 포함) + └── monitoring - Prometheus/Micrometer 메트릭 설정 +``` + +### 모듈 의존성 + +- **commerce-api**: jpa, redis, jackson, logging, monitoring +- **commerce-streamer**: jpa, redis, kafka, jackson, logging, monitoring +- **commerce-batch**: jpa, redis, jackson, logging, monitoring + +## 패키지 구조 (Layered Architecture) + +commerce-api 기준 패키지 구조: + +``` +com.loopers +├── CommerceApiApplication.java - 애플리케이션 진입점 +├── application/ - 유스케이스, Facade 계층 +│ └── example/ExampleFacade.java +├── domain/ - 도메인 모델, 서비스, 리포지토리 인터페이스 +│ └── example/ +│ ├── ExampleModel.java +│ ├── ExampleService.java +│ └── ExampleRepository.java +├── infrastructure/ - 리포지토리 구현체, 외부 연동 +│ └── example/ +│ ├── ExampleJpaRepository.java +│ └── ExampleRepositoryImpl.java +├── interfaces/ - API 컨트롤러, DTO +│ └── api/ +│ ├── ApiControllerAdvice.java +│ ├── ApiResponse.java +│ └── example/ +│ ├── ExampleV1Controller.java +│ ├── ExampleV1ApiSpec.java +│ └── ExampleV1Dto.java +└── support/ - 공통 유틸리티, 예외 처리 + └── error/ + ├── CoreException.java + └── ErrorType.java +``` + +## 빌드 및 실행 명령어 + +```bash +# 전체 빌드 +./gradlew build + +# 특정 모듈 빌드 +./gradlew :apps:commerce-api:build + +# 테스트 실행 +./gradlew test + +# 특정 모듈 테스트 +./gradlew :apps:commerce-api:test + +# 애플리케이션 실행 +./gradlew :apps:commerce-api:bootRun +./gradlew :apps:commerce-batch:bootRun +./gradlew :apps:commerce-streamer:bootRun + +# Clean +./gradlew clean +``` + +## 테스트 + +- 테스트 프레임워크: JUnit 5, Mockito, SpringMockK, Instancio +- 테스트 컨테이너: Testcontainers (MySQL, Redis, Kafka) +- 테스트 프로파일: `test` (자동 적용) +- 타임존: `Asia/Seoul` +- JaCoCo 코드 커버리지 리포트 생성 (XML 포맷) + +### 테스트 Fixtures + +modules 하위 모듈들은 `java-test-fixtures` 플러그인을 사용하여 테스트 픽스처 제공: +- `modules:jpa`: `MySqlTestContainersConfig`, `DatabaseCleanUp` +- `modules:redis`: `RedisTestContainersConfig`, `RedisCleanUp` +- `modules:kafka`: Kafka Testcontainers 설정 + +## 로컬 개발 환경 + +### 인프라 실행 + +```bash +# MySQL, Redis (Master-Replica), Kafka, Kafka-UI 실행 +docker-compose -f ./docker/infra-compose.yml up + +# 모니터링 (Prometheus, Grafana) 실행 +docker-compose -f ./docker/monitoring-compose.yml up +``` + +### 인프라 포트 + +| 서비스 | 포트 | +|--------|------| +| MySQL | 3306 | +| Redis Master | 6379 | +| Redis Replica | 6380 | +| Kafka | 9092 (내부), 19092 (외부) | +| Kafka UI | 9099 | +| Grafana | 3000 (admin/admin) | + +## 코드 스타일 + +- 빌드 시스템: Gradle Kotlin DSL +- Java 21 toolchain 사용 +- Lombok 적극 활용 +- QueryDSL로 타입 안전한 쿼리 작성 +- API 버전닝: URL 기반 (`/v1/...`) +- OpenAPI(Swagger) 문서화 (`springdoc-openapi`) +--- +## Git 브랜치 전략 + +### 브랜치 구조 + +``` +main + └── week{N} (주차 브랜치, PR 대상) + ├── feature/{기능명1} + ├── feature/{기능명2} + └── feature/{기능명3} +``` + +### Workflow + +1. `main`에서 `week{N}` 브랜치 생성 +2. `week{N}`에서 각 기능별 `feature/*` 브랜치 생성 +3. 기능 완료 시 `feature/*` → `week{N}`로 머지 +4. 주차 전체 기능 완료 후 `week{N}` → `main`으로 PR + +### 브랜치 생성/병합/커밋 시점 + +#### 브랜치 생성 +- `week{N}` 브랜치: 해당 주차 작업 시작 전 `main`에서 생성 +- `feature/*` 브랜치: 해당 기능 구현 시작 전 `week{N}`에서 생성 + +#### 커밋 시점 +- TDD 사이클 완료 시 (Red → Green → Refactor 한 사이클) +- 의미 있는 단위의 작업 완료 시 +- 테스트가 모두 통과하는 상태에서만 커밋 + +#### 병합 시점 +- `feature/*` → `week{N}`: 해당 기능의 모든 테스트 통과 후 +- `week{N}` → `main`: 주차 전체 기능 완료 및 테스트 통과 후 PR + +### 주차별 기능 목록 + +| 주차 | 기능 | 브랜치 | +|------|------|--------| +| 1주차 | 회원가입 | `feature/sign-up` | +| 1주차 | 내 정보 조회 | `feature/my-info` | +| 1주차 | 비밀번호 수정 | `feature/change-password` | + +--- +## 개발 규칙 +### 진행 Workflow - 증강 코딩 +- **대원칙** : 방향성 및 주요 의사 결정은 개발자에게 제안만 할 수 있으며, 최종 승인된 사항을 기반으로 작업을 수행. +- **중간 결과 보고** : AI 가 반복적인 동작을 하거나, 요청하지 않은 기능을 구현, 테스트 삭제를 임의로 진행할 경우 개발자가 개입. +- **설계 주도권 유지** : AI 가 임의판단을 하지 않고, 방향성에 대한 제안 등을 진행할 수 있으나 개발자의 승인을 받은 후 수행. +- **적극적인 질문** : 구현 중 불확실한 사항, 여러 선택지가 있는 경우, 요구사항이 모호한 경우 반드시 개발자에게 질문하여 확인할 것. + +### 개발 Workflow - TDD (Red > Green > Refactor) +- 모든 테스트는 3A 원칙으로 작성할 것 (Arrange - Act - Assert) +- **매 테스트/구현마다 아래 내용을 설명할 것:** + 1. **목적**: 무엇을 검증하려는지 + 2. **구현 방법**: 테스트를 어떻게 작성했는지 + 3. **결과**: 테스트 실행 결과 (성공/실패) + 4. **다음 단계**: 결과에 따라 어떻게 진행할지 + +#### 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 diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 03ce68f0..6d6b8bf4 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -8,6 +8,10 @@ dependencies { // web implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-validation") + + // security (password encryption only) + implementation("org.springframework.security:spring-security-crypto") 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/MemberModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java new file mode 100644 index 00000000..21c6b9ee --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java @@ -0,0 +1,58 @@ +package com.loopers.domain.member; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +import java.time.LocalDate; + +@Entity +@Table(name = "member") +public class MemberModel extends BaseEntity { + + @Column(nullable = false, unique = true, length = 20) + private String loginId; + + @Column(nullable = false) + private String password; + + @Column(nullable = false, length = 50) + private String name; + + @Column(nullable = false) + private LocalDate birthDate; + + @Column(nullable = false, length = 100) + private String email; + + protected MemberModel() {} + + public MemberModel(String loginId, String password, String name, LocalDate birthDate, String email) { + this.loginId = loginId; + this.password = password; + this.name = name; + this.birthDate = birthDate; + this.email = email; + } + + public String getLoginId() { + return loginId; + } + + public String getPassword() { + return password; + } + + public String getName() { + return name; + } + + public LocalDate getBirthDate() { + return birthDate; + } + + public String getEmail() { + return email; + } +} 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..b03d7b64 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java @@ -0,0 +1,9 @@ +package com.loopers.domain.member; + +import java.util.Optional; + +public interface MemberRepository { + MemberModel save(MemberModel member); + Optional findByLoginId(String loginId); + boolean existsByLoginId(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 new file mode 100644 index 00000000..50fbb4e4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java @@ -0,0 +1,44 @@ +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.Service; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +@RequiredArgsConstructor +@Service +public class MemberService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + private static final String PASSWORD_PATTERN = "^[A-Za-z0-9!@#$%^&*()_+=-]{8,16}$"; + + public MemberModel register(String loginId, String password, String name, LocalDate birthDate, String email) { + if (memberRepository.existsByLoginId(loginId)) { + throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 로그인 ID입니다."); + } + + if (!password.matches(PASSWORD_PATTERN)) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 8~16자의 영문 대소문자, 숫자, 특수문자만 허용됩니다."); + } + + if (containsBirthDate(password, birthDate)) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호에 생년월일을 포함할 수 없습니다."); + } + + String encodedPassword = passwordEncoder.encode(password); + MemberModel member = new MemberModel(loginId, encodedPassword, name, birthDate, email); + return memberRepository.save(member); + } + + private boolean containsBirthDate(String password, LocalDate birthDate) { + String yyyyMMdd = birthDate.format(DateTimeFormatter.ofPattern("yyyyMMdd")); + String yyMMdd = birthDate.format(DateTimeFormatter.ofPattern("yyMMdd")); + return password.contains(yyyyMMdd) || password.contains(yyMMdd); + } +} 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..6a2558d6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.MemberModel; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MemberJpaRepository extends JpaRepository { + Optional findByLoginId(String loginId); + boolean existsByLoginId(String 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 new file mode 100644 index 00000000..c96e969a --- /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.MemberModel; +import com.loopers.domain.member.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@RequiredArgsConstructor +@Repository +public class MemberRepositoryImpl implements MemberRepository { + + private final MemberJpaRepository memberJpaRepository; + + @Override + public MemberModel save(MemberModel member) { + return memberJpaRepository.save(member); + } + + @Override + public Optional findByLoginId(String loginId) { + return memberJpaRepository.findByLoginId(loginId); + } + + @Override + public boolean existsByLoginId(String loginId) { + return memberJpaRepository.existsByLoginId(loginId); + } +} 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..f24379f0 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.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -46,6 +47,14 @@ public ResponseEntity> handleBadRequest(MissingServletRequestPara return failureResponse(ErrorType.BAD_REQUEST, message); } + @ExceptionHandler + public ResponseEntity> handleBadRequest(MethodArgumentNotValidException e) { + String message = e.getBindingResult().getFieldErrors().stream() + .map(error -> String.format("'%s' %s", error.getField(), error.getDefaultMessage())) + .collect(Collectors.joining(", ")); + 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 new file mode 100644 index 00000000..b9909c12 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java @@ -0,0 +1,42 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.domain.member.MemberModel; +import com.loopers.domain.member.MemberService; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/members") +public class MemberV1Controller { + + private final MemberService memberService; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse signUp(@Valid @RequestBody MemberV1Dto.SignUpRequest request) { + MemberModel member = memberService.register( + request.loginId(), + request.password(), + request.name(), + request.birthDate(), + request.email() + ); + + MemberV1Dto.SignUpResponse response = new MemberV1Dto.SignUpResponse( + member.getId(), + member.getLoginId(), + member.getName(), + member.getEmail() + ); + + return ApiResponse.success(response); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java new file mode 100644 index 00000000..afd921ca --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java @@ -0,0 +1,28 @@ +package com.loopers.interfaces.api.member; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Past; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import java.time.LocalDate; + +public class MemberV1Dto { + + public record SignUpRequest( + @NotBlank @Pattern(regexp = "^[A-Za-z0-9]+$") String loginId, + @NotBlank @Size(min = 8, max = 16) String password, + @NotBlank String name, + @NotNull @Past LocalDate birthDate, + @NotBlank @Email String email + ) {} + + public record SignUpResponse( + Long id, + String loginId, + String name, + String email + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/auth/PasswordEncoderConfig.java b/apps/commerce-api/src/main/java/com/loopers/support/auth/PasswordEncoderConfig.java new file mode 100644 index 00000000..b8cf0547 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/auth/PasswordEncoderConfig.java @@ -0,0 +1,15 @@ +package com.loopers.support.auth; + +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(); + } +} 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..6ccdbe8a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceTest.java @@ -0,0 +1,108 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.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.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MemberServiceTest { + + @Mock + private MemberRepository memberRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + @InjectMocks + private MemberService memberService; + + @DisplayName("유효한 정보로 회원가입하면 회원이 저장된다") + @Test + void register_withValidInfo_savesMember() { + // arrange + String loginId = "testuser1"; + String password = "Password1!"; + String name = "홍길동"; + LocalDate birthDate = LocalDate.of(1990, 1, 15); + String email = "test@example.com"; + + when(memberRepository.existsByLoginId(loginId)).thenReturn(false); + when(passwordEncoder.encode(password)).thenReturn("encodedPassword"); + when(memberRepository.save(any(MemberModel.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // act + MemberModel result = memberService.register(loginId, password, name, birthDate, email); + + // assert + assertThat(result.getLoginId()).isEqualTo(loginId); + assertThat(result.getName()).isEqualTo(name); + assertThat(result.getBirthDate()).isEqualTo(birthDate); + assertThat(result.getEmail()).isEqualTo(email); + verify(memberRepository).save(any(MemberModel.class)); + } + + @DisplayName("이미 존재하는 로그인 ID로 가입하면 예외가 발생한다") + @Test + void register_withDuplicateLoginId_throwsException() { + // arrange + String loginId = "existinguser"; + String password = "Password1!"; + String name = "홍길동"; + LocalDate birthDate = LocalDate.of(1990, 1, 15); + String email = "test@example.com"; + + when(memberRepository.existsByLoginId(loginId)).thenReturn(true); + + // act & assert + assertThatThrownBy(() -> memberService.register(loginId, password, name, birthDate, email)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("비밀번호가 8자 미만이면 예외가 발생한다") + @Test + void register_withShortPassword_throwsException() { + // arrange + String loginId = "testuser1"; + String password = "Pass1!"; // 7자 + String name = "홍길동"; + LocalDate birthDate = LocalDate.of(1990, 1, 15); + String email = "test@example.com"; + + when(memberRepository.existsByLoginId(loginId)).thenReturn(false); + + // act & assert + assertThatThrownBy(() -> memberService.register(loginId, password, name, birthDate, email)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("비밀번호에 생년월일(yyyyMMdd)이 포함되면 예외가 발생한다") + @Test + void register_withBirthDateInPassword_throwsException() { + // arrange + String loginId = "testuser1"; + String password = "Pass19900115!"; // 생년월일 포함 + String name = "홍길동"; + LocalDate birthDate = LocalDate.of(1990, 1, 15); + String email = "test@example.com"; + + when(memberRepository.existsByLoginId(loginId)).thenReturn(false); + + // act & assert + assertThatThrownBy(() -> memberService.register(loginId, password, name, birthDate, email)) + .isInstanceOf(CoreException.class); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ControllerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ControllerTest.java new file mode 100644 index 00000000..48e895d3 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ControllerTest.java @@ -0,0 +1,86 @@ +package com.loopers.interfaces.api.member; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.member.MemberModel; +import com.loopers.domain.member.MemberService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDate; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(MemberV1Controller.class) +class MemberV1ControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private MemberService memberService; + + @DisplayName("유효한 요청으로 회원가입하면 201 Created 응답을 받는다") + @Test + void signUp_withValidRequest_returnsCreated() throws Exception { + // arrange + MemberV1Dto.SignUpRequest request = new MemberV1Dto.SignUpRequest( + "testuser1", + "Password1!", + "홍길동", + LocalDate.of(1990, 1, 15), + "test@example.com" + ); + + MemberModel savedMember = new MemberModel( + "testuser1", + "encodedPassword", + "홍길동", + LocalDate.of(1990, 1, 15), + "test@example.com" + ); + + when(memberService.register(anyString(), anyString(), anyString(), any(LocalDate.class), anyString())) + .thenReturn(savedMember); + + // act & assert + mockMvc.perform(post("/api/v1/members") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.loginId").value("testuser1")) + .andExpect(jsonPath("$.data.name").value("홍길동")) + .andExpect(jsonPath("$.data.email").value("test@example.com")); + } + + @DisplayName("로그인 ID에 영문/숫자 외 문자가 포함되면 400 Bad Request 응답을 받는다") + @Test + void signUp_withInvalidLoginIdFormat_returnsBadRequest() throws Exception { + // arrange + MemberV1Dto.SignUpRequest request = new MemberV1Dto.SignUpRequest( + "테스트유저", // 한글 포함 + "Password1!", + "홍길동", + LocalDate.of(1990, 1, 15), + "test@example.com" + ); + + // act & assert + mockMvc.perform(post("/api/v1/members") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } +} diff --git a/plans/week1.md b/plans/week1.md new file mode 100644 index 00000000..49cf0c8c --- /dev/null +++ b/plans/week1.md @@ -0,0 +1,448 @@ +# Week 1 - 회원 기능 + +## 요구사항 + +### 1. 회원가입 + +| 항목 | 내용 | +|------|------| +| 필요 정보 | 로그인 ID, 비밀번호, 이름, 생년월일, 이메일 | +| 로그인 ID | 영문 + 숫자만 허용, 중복 불가 | +| 포맷 검증 | 이름, 이메일, 생년월일 | +| 비밀번호 규칙 | 8~16자, 영문 대소문자 + 숫자 + 특수문자만 허용 | +| 비밀번호 제약 | 생년월일 포함 불가 | +| 저장 방식 | 비밀번호 암호화 저장 | + +### 2. 내 정보 조회 + +| 항목 | 내용 | +|------|------| +| 인증 방식 | HTTP 헤더 (`X-Loopers-LoginId`, `X-Loopers-LoginPw`) | +| 반환 정보 | 로그인 ID, 이름, 생년월일, 이메일 | +| 이름 마스킹 | 마지막 글자를 `*`로 마스킹 (예: 홍길동 → 홍길*) | + +### 3. 비밀번호 수정 + +| 항목 | 내용 | +|------|------| +| 인증 방식 | HTTP 헤더 (`X-Loopers-LoginId`, `X-Loopers-LoginPw`) | +| 필요 정보 | 기존 비밀번호, 새 비밀번호 | +| 비밀번호 규칙 | 8~16자, 영문 대소문자 + 숫자 + 특수문자만 허용 | +| 비밀번호 제약 | 생년월일 포함 불가, 현재 비밀번호 사용 불가 | + +--- + +## 기술 결정 사항 + +| 항목 | 결정 | 근거 | +|------|------|------| +| 비밀번호 암호화 | `spring-security-crypto` | 전체 Spring Security는 과한 의존성, crypto만 사용 | +| 인증 처리 | `HandlerMethodArgumentResolver` | 대중적, 확장성 좋음, 컨트롤러 코드 깔끔 | +| 엔티티 네이밍 | `MemberModel` | 기존 프로젝트 패턴(`ExampleModel`) 유지 | +| DTO | Record 사용 | Java 21 기본 기능, boilerplate 감소 | +| 검증 | `@Valid` 기본 어노테이션 + 서비스 레벨 검증 | 오버엔지니어링 방지 | + +--- + +## 구현 계획 (소스 레벨) + +### Phase 1: 공통 기반 구축 + +#### 1-1. 의존성 추가 (`apps/commerce-api/build.gradle.kts`) + +```kotlin +// 비밀번호 암호화 (spring-security-crypto만 사용) +implementation("org.springframework.security:spring-security-crypto") +``` + +#### 1-2. MemberModel 엔티티 + +**파일**: `domain/member/MemberModel.java` + +```java +@Entity +@Table(name = "member") +public class MemberModel extends BaseEntity { + + @Column(nullable = false, unique = true, length = 20) + private String loginId; + + @Column(nullable = false) + private String password; + + @Column(nullable = false, length = 50) + private String name; + + @Column(nullable = false) + private LocalDate birthDate; + + @Column(nullable = false, length = 100) + private String email; + + // 생성자, getter, 비밀번호 변경 메서드 +} +``` + +**설계 근거**: +- `loginId`: unique 제약, 영문+숫자만 허용 +- `password`: BCrypt 해시값 저장 (길이 제한 없음) +- `birthDate`: `LocalDate` 타입으로 날짜만 저장 +- `BaseEntity` 상속으로 id, createdAt, updatedAt, deletedAt 자동 관리 + +#### 1-3. MemberRepository + +**파일**: `domain/member/MemberRepository.java` + +```java +public interface MemberRepository { + MemberModel save(MemberModel member); + Optional findByLoginId(String loginId); + boolean existsByLoginId(String loginId); +} +``` + +#### 1-4. MemberRepositoryImpl + +**파일**: `infrastructure/member/MemberRepositoryImpl.java` + +```java +@RequiredArgsConstructor +@Component +public class MemberRepositoryImpl implements MemberRepository { + private final MemberJpaRepository memberJpaRepository; + + // 구현 +} +``` + +#### 1-5. MemberJpaRepository + +**파일**: `infrastructure/member/MemberJpaRepository.java` + +```java +public interface MemberJpaRepository extends JpaRepository { + Optional findByLoginId(String loginId); + boolean existsByLoginId(String loginId); +} +``` + +#### 1-6. PasswordEncoder 설정 + +**파일**: `support/auth/PasswordEncoderConfig.java` + +```java +@Configuration +public class PasswordEncoderConfig { + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} +``` + +**설계 근거**: +- `spring-security-crypto`의 `PasswordEncoder` 인터페이스 사용 +- BCrypt 알고리즘 (업계 표준) + +#### 1-7. PasswordValidator + +**파일**: `domain/member/PasswordValidator.java` + +```java +@Component +public class PasswordValidator { + + // 8~16자, 영문 대소문자 + 숫자 + 특수문자만 허용 + private static final String PASSWORD_PATTERN = "^[A-Za-z0-9!@#$%^&*()_+=-]{8,16}$"; + + public void validate(String password, LocalDate birthDate) { + // 1. 길이 및 문자 규칙 검증 + // 2. 생년월일 포함 여부 검증 (yyyyMMdd, yyMMdd 등) + } +} +``` + +**검증 항목**: +- 길이: 8~16자 +- 허용 문자: 영문 대소문자, 숫자, 특수문자 +- 생년월일 포함 불가: `19900101`, `900101` 등 패턴 체크 + +#### 1-8. 인증 컴포넌트 (HandlerMethodArgumentResolver) + +**파일**: `support/auth/AuthMember.java` (어노테이션) + +```java +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface AuthMember { +} +``` + +**파일**: `support/auth/AuthMemberResolver.java` + +```java +@RequiredArgsConstructor +@Component +public class AuthMemberResolver implements HandlerMethodArgumentResolver { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(AuthMember.class); + } + + @Override + public Object resolveArgument(...) { + String loginId = request.getHeader("X-Loopers-LoginId"); + String password = request.getHeader("X-Loopers-LoginPw"); + + // 1. 헤더 존재 여부 검증 + // 2. 회원 조회 + // 3. 비밀번호 일치 검증 + // 4. MemberModel 반환 + } +} +``` + +**파일**: `support/auth/WebMvcConfig.java` + +```java +@RequiredArgsConstructor +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + private final AuthMemberResolver authMemberResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(authMemberResolver); + } +} +``` + +--- + +### Phase 2: 회원가입 기능 + +#### 2-1. API 설계 + +| 항목 | 내용 | +|------|------| +| Method | `POST` | +| Path | `/api/v1/members` | +| Request | `{ loginId, password, name, birthDate, email }` | +| Response | `201 Created` + `{ id, loginId, name, email }` | + +#### 2-2. DTO + +**파일**: `interfaces/api/member/MemberV1Dto.java` + +```java +public class MemberV1Dto { + + public record SignUpRequest( + @NotBlank @Pattern(regexp = "^[A-Za-z0-9]+$") String loginId, + @NotBlank @Size(min = 8, max = 16) String password, + @NotBlank String name, + @NotNull @Past LocalDate birthDate, + @NotBlank @Email String email + ) {} + + public record SignUpResponse( + Long id, + String loginId, + String name, + String email + ) { + public static SignUpResponse from(MemberInfo info) { ... } + } +} +``` + +#### 2-3. 계층별 구현 + +**Controller** → **Facade** → **Service** → **Repository** + +``` +MemberV1Controller.signUp(SignUpRequest) + ↓ +MemberFacade.signUp(SignUpCommand) + ↓ +MemberService.register(SignUpCommand) + - 로그인 ID 중복 검증 + - 비밀번호 검증 (PasswordValidator) + - 비밀번호 암호화 + - 저장 + ↓ +MemberRepository.save(MemberModel) +``` + +#### 2-4. TDD 사이클 + +| 사이클 | 테스트 케이스 | 구현 내용 | +|--------|-------------|----------| +| 1 | 정상 가입 시 201 응답 | 기본 API 흐름 구현 | +| 2 | 로그인 ID 중복 시 409 응답 | 중복 검증 로직 추가 | +| 3 | 로그인 ID 포맷 오류 시 400 응답 | @Pattern 검증 | +| 4 | 비밀번호 규칙 위반 시 400 응답 | PasswordValidator 연동 | +| 5 | 비밀번호에 생년월일 포함 시 400 응답 | 생년월일 검증 로직 | +| 6 | 이메일 포맷 오류 시 400 응답 | @Email 검증 | + +--- + +### Phase 3: 내 정보 조회 기능 + +#### 3-1. API 설계 + +| 항목 | 내용 | +|------|------| +| Method | `GET` | +| Path | `/api/v1/members/me` | +| Headers | `X-Loopers-LoginId`, `X-Loopers-LoginPw` | +| Response | `200 OK` + `{ loginId, name, birthDate, email }` | + +#### 3-2. DTO + +```java +public record MyInfoResponse( + String loginId, + String name, // 마스킹 적용 + LocalDate birthDate, + String email +) { + public static MyInfoResponse from(MemberModel member) { + return new MyInfoResponse( + member.getLoginId(), + maskName(member.getName()), + member.getBirthDate(), + member.getEmail() + ); + } + + private static String maskName(String name) { + if (name == null || name.length() < 2) return name; + return name.substring(0, name.length() - 1) + "*"; + } +} +``` + +#### 3-3. 계층별 구현 + +``` +MemberV1Controller.getMyInfo(@AuthMember MemberModel member) + ↓ +MyInfoResponse.from(member) // 직접 변환 (Facade 생략 가능) +``` + +**설계 근거**: 단순 조회이므로 Facade 없이 Controller에서 직접 DTO 변환 + +#### 3-4. TDD 사이클 + +| 사이클 | 테스트 케이스 | 구현 내용 | +|--------|-------------|----------| +| 1 | 정상 조회 시 200 응답 | 기본 API 흐름 | +| 2 | 이름 마스킹 검증 | maskName 로직 | +| 3 | 인증 헤더 없음 시 401 응답 | AuthMemberResolver 예외 처리 | +| 4 | 잘못된 비밀번호 시 401 응답 | 비밀번호 검증 | + +--- + +### Phase 4: 비밀번호 수정 기능 + +#### 4-1. API 설계 + +| 항목 | 내용 | +|------|------| +| Method | `PATCH` | +| Path | `/api/v1/members/me/password` | +| Headers | `X-Loopers-LoginId`, `X-Loopers-LoginPw` | +| Request | `{ currentPassword, newPassword }` | +| Response | `200 OK` | + +#### 4-2. DTO + +```java +public record ChangePasswordRequest( + @NotBlank String currentPassword, + @NotBlank @Size(min = 8, max = 16) String newPassword +) {} +``` + +#### 4-3. 계층별 구현 + +``` +MemberV1Controller.changePassword(@AuthMember MemberModel member, ChangePasswordRequest) + ↓ +MemberFacade.changePassword(member, ChangePasswordCommand) + ↓ +MemberService.changePassword(member, currentPassword, newPassword) + - 현재 비밀번호 일치 검증 + - 새 비밀번호 규칙 검증 + - 새 비밀번호 ≠ 현재 비밀번호 검증 + - 비밀번호 암호화 후 업데이트 +``` + +#### 4-4. TDD 사이클 + +| 사이클 | 테스트 케이스 | 구현 내용 | +|--------|-------------|----------| +| 1 | 정상 수정 시 200 응답 | 기본 API 흐름 | +| 2 | 현재 비밀번호 불일치 시 400 응답 | 비밀번호 검증 | +| 3 | 새 비밀번호 규칙 위반 시 400 응답 | PasswordValidator | +| 4 | 새 비밀번호 = 현재 비밀번호 시 400 응답 | 동일 비밀번호 검증 | +| 5 | 새 비밀번호에 생년월일 포함 시 400 응답 | 생년월일 검증 | + +--- + +## 브랜치 전략 + +``` +main + └── week1 + ├── feature/sign-up (Phase 1 + Phase 2) + ├── feature/my-info (Phase 3) + └── feature/change-password (Phase 4) +``` + +--- + +## 패키지 구조 (최종) + +``` +com.loopers +├── application/member/ +│ ├── MemberFacade.java +│ └── MemberInfo.java +├── domain/member/ +│ ├── MemberModel.java +│ ├── MemberService.java +│ ├── MemberRepository.java +│ └── PasswordValidator.java +├── infrastructure/member/ +│ ├── MemberJpaRepository.java +│ └── MemberRepositoryImpl.java +├── interfaces/api/member/ +│ ├── MemberV1Controller.java +│ ├── MemberV1ApiSpec.java +│ └── MemberV1Dto.java +└── support/ + └── auth/ + ├── AuthMember.java + ├── AuthMemberResolver.java + ├── PasswordEncoderConfig.java + └── WebMvcConfig.java +``` + +--- + +## ErrorType 추가 (필요시) + +```java +// 기존 ErrorType에 추가 +UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "Unauthorized", "인증이 필요합니다."), +DUPLICATE_LOGIN_ID(HttpStatus.CONFLICT, "Duplicate Login ID", "이미 존재하는 로그인 ID입니다."), +INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "Invalid Password", "비밀번호 규칙에 맞지 않습니다."), +PASSWORD_MISMATCH(HttpStatus.BAD_REQUEST, "Password Mismatch", "비밀번호가 일치하지 않습니다."), +``` From f47a7d952e83910041a27e80158446d6f6c7324d Mon Sep 17 00:00:00 2001 From: SukheeChoi <95064440+SukheeChoi@users.noreply.github.com> Date: Tue, 3 Feb 2026 00:22:06 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=EB=82=B4=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AuthMember 어노테이션 및 AuthMemberResolver 추가 - 헤더 기반 인증 (X-Loopers-LoginId, X-Loopers-LoginPw) - GET /api/v1/members/me API 추가 - 이름 마스킹 로직 (홍길동 → 홍길*) - ErrorType.UNAUTHORIZED 추가 - 단위 테스트 5개 추가 (Controller 3개, DTO 2개) Co-Authored-By: Claude Opus 4.5 --- .../api/member/MemberV1Controller.java | 7 ++ .../interfaces/api/member/MemberV1Dto.java | 25 +++++++ .../com/loopers/support/auth/AuthMember.java | 11 +++ .../support/auth/AuthMemberResolver.java | 57 ++++++++++++++++ .../loopers/support/auth/WebMvcConfig.java | 20 ++++++ .../com/loopers/support/error/ErrorType.java | 1 + .../api/member/MemberV1ControllerTest.java | 67 +++++++++++++++++++ .../api/member/MemberV1DtoTest.java | 50 ++++++++++++++ 8 files changed, 238 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/support/auth/AuthMember.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/support/auth/AuthMemberResolver.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/support/auth/WebMvcConfig.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1DtoTest.java 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 b9909c12..e0532291 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,9 +3,11 @@ import com.loopers.domain.member.MemberModel; import com.loopers.domain.member.MemberService; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.auth.AuthMember; import jakarta.validation.Valid; 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.RequestMapping; @@ -39,4 +41,9 @@ public ApiResponse signUp(@Valid @RequestBody Member return ApiResponse.success(response); } + + @GetMapping("/me") + public ApiResponse getMyInfo(@AuthMember MemberModel member) { + return ApiResponse.success(MemberV1Dto.MyInfoResponse.from(member)); + } } 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 afd921ca..c5313e0f 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 @@ -7,6 +7,8 @@ import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; +import com.loopers.domain.member.MemberModel; + import java.time.LocalDate; public class MemberV1Dto { @@ -25,4 +27,27 @@ public record SignUpResponse( String name, String email ) {} + + public record MyInfoResponse( + String loginId, + String name, + LocalDate birthDate, + String email + ) { + public static MyInfoResponse from(MemberModel member) { + return new MyInfoResponse( + member.getLoginId(), + maskName(member.getName()), + member.getBirthDate(), + member.getEmail() + ); + } + + private static String maskName(String name) { + if (name == null || name.length() < 2) { + return name; + } + return name.substring(0, name.length() - 1) + "*"; + } + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthMember.java b/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthMember.java new file mode 100644 index 00000000..9089d89d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthMember.java @@ -0,0 +1,11 @@ +package com.loopers.support.auth; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface AuthMember { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthMemberResolver.java b/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthMemberResolver.java new file mode 100644 index 00000000..40d6bba6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthMemberResolver.java @@ -0,0 +1,57 @@ +package com.loopers.support.auth; + +import com.loopers.domain.member.MemberModel; +import com.loopers.domain.member.MemberRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.core.MethodParameter; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@RequiredArgsConstructor +@Component +public class AuthMemberResolver implements HandlerMethodArgumentResolver { + + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(AuthMember.class); + } + + @Override + public Object resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) { + HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); + + String loginId = request.getHeader(HEADER_LOGIN_ID); + String password = request.getHeader(HEADER_LOGIN_PW); + + if (loginId == null || loginId.isBlank() || password == null || password.isBlank()) { + throw new CoreException(ErrorType.UNAUTHORIZED, "인증 헤더가 필요합니다."); + } + + MemberModel member = memberRepository.findByLoginId(loginId) + .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED, "인증에 실패했습니다.")); + + if (!passwordEncoder.matches(password, member.getPassword())) { + throw new CoreException(ErrorType.UNAUTHORIZED, "인증에 실패했습니다."); + } + + return member; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/auth/WebMvcConfig.java b/apps/commerce-api/src/main/java/com/loopers/support/auth/WebMvcConfig.java new file mode 100644 index 00000000..edb1b604 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/auth/WebMvcConfig.java @@ -0,0 +1,20 @@ +package com.loopers.support.auth; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@RequiredArgsConstructor +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + private final AuthMemberResolver authMemberResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(authMemberResolver); + } +} 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..bc9fb4c7 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/interfaces/api/member/MemberV1ControllerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ControllerTest.java index 48e895d3..aaa4abed 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ControllerTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ControllerTest.java @@ -2,7 +2,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.loopers.domain.member.MemberModel; +import com.loopers.domain.member.MemberRepository; import com.loopers.domain.member.MemberService; +import org.springframework.security.crypto.password.PasswordEncoder; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -13,9 +15,13 @@ import java.time.LocalDate; +import java.util.Optional; + import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -32,6 +38,12 @@ class MemberV1ControllerTest { @MockBean private MemberService memberService; + @MockBean + private MemberRepository memberRepository; + + @MockBean + private PasswordEncoder passwordEncoder; + @DisplayName("유효한 요청으로 회원가입하면 201 Created 응답을 받는다") @Test void signUp_withValidRequest_returnsCreated() throws Exception { @@ -83,4 +95,59 @@ void signUp_withInvalidLoginIdFormat_returnsBadRequest() throws Exception { .content(objectMapper.writeValueAsString(request))) .andExpect(status().isBadRequest()); } + + @DisplayName("인증 헤더 없이 내 정보 조회하면 401 Unauthorized 응답을 받는다") + @Test + void getMyInfo_withoutAuthHeaders_returnsUnauthorized() throws Exception { + // act & assert + mockMvc.perform(get("/api/v1/members/me")) + .andExpect(status().isUnauthorized()); + } + + @DisplayName("잘못된 비밀번호로 내 정보 조회하면 401 Unauthorized 응답을 받는다") + @Test + void getMyInfo_withWrongPassword_returnsUnauthorized() throws Exception { + // arrange + MemberModel member = new MemberModel( + "testuser1", + "encodedPassword", + "홍길동", + LocalDate.of(1990, 1, 15), + "test@example.com" + ); + + when(memberRepository.findByLoginId("testuser1")).thenReturn(Optional.of(member)); + when(passwordEncoder.matches("wrongPassword", "encodedPassword")).thenReturn(false); + + // act & assert + mockMvc.perform(get("/api/v1/members/me") + .header("X-Loopers-LoginId", "testuser1") + .header("X-Loopers-LoginPw", "wrongPassword")) + .andExpect(status().isUnauthorized()); + } + + @DisplayName("올바른 인증 헤더로 내 정보 조회하면 200 OK와 마스킹된 이름을 받는다") + @Test + void getMyInfo_withValidAuth_returnsOkWithMaskedName() throws Exception { + // arrange + MemberModel member = new MemberModel( + "testuser1", + "encodedPassword", + "홍길동", + LocalDate.of(1990, 1, 15), + "test@example.com" + ); + + when(memberRepository.findByLoginId("testuser1")).thenReturn(Optional.of(member)); + when(passwordEncoder.matches("Password1!", "encodedPassword")).thenReturn(true); + + // act & assert + mockMvc.perform(get("/api/v1/members/me") + .header("X-Loopers-LoginId", "testuser1") + .header("X-Loopers-LoginPw", "Password1!")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.loginId").value("testuser1")) + .andExpect(jsonPath("$.data.name").value("홍길*")) + .andExpect(jsonPath("$.data.email").value("test@example.com")); + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1DtoTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1DtoTest.java new file mode 100644 index 00000000..c0dbd1f6 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1DtoTest.java @@ -0,0 +1,50 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.domain.member.MemberModel; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; + +class MemberV1DtoTest { + + @DisplayName("MyInfoResponse 생성 시 이름 마지막 글자가 *로 마스킹된다") + @Test + void myInfoResponse_masksLastCharacterOfName() { + // arrange + MemberModel member = new MemberModel( + "testuser1", + "encodedPassword", + "홍길동", + LocalDate.of(1990, 1, 15), + "test@example.com" + ); + + // act + MemberV1Dto.MyInfoResponse response = MemberV1Dto.MyInfoResponse.from(member); + + // assert + assertThat(response.name()).isEqualTo("홍길*"); + } + + @DisplayName("이름이 1글자면 마스킹하지 않는다") + @Test + void myInfoResponse_doesNotMaskSingleCharacterName() { + // arrange + MemberModel member = new MemberModel( + "testuser1", + "encodedPassword", + "홍", + LocalDate.of(1990, 1, 15), + "test@example.com" + ); + + // act + MemberV1Dto.MyInfoResponse response = MemberV1Dto.MyInfoResponse.from(member); + + // assert + assertThat(response.name()).isEqualTo("홍"); + } +} From eeb137c9aabe6e78cefdefc48ac98599c2c763ce Mon Sep 17 00:00:00 2001 From: SukheeChoi <95064440+SukheeChoi@users.noreply.github.com> Date: Tue, 3 Feb 2026 00:50:28 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=88=98=EC=A0=95=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MemberModel에 changePassword() 메서드 추가 - MemberService에 비밀번호 변경 로직 구현 - 현재 비밀번호 검증 - 동일 비밀번호 사용 불가 - 비밀번호 규칙 검증 (8~16자, 영문/숫자/특수문자) - 생년월일 포함 불가 - MemberV1Controller에 PATCH /me/password 엔드포인트 추가 - CLAUDE.md를 .gitignore에 추가 (git 추적 제외) Co-Authored-By: Claude Opus 4.5 --- .codeguide/loopers-1-week.md | 87 +++++++ .gitignore | 3 + CLAUDE.md | 245 ------------------ .../loopers/domain/member/MemberModel.java | 4 + .../loopers/domain/member/MemberService.java | 21 ++ .../api/member/MemberV1Controller.java | 10 + .../interfaces/api/member/MemberV1Dto.java | 5 + .../domain/member/MemberServiceTest.java | 98 +++++++ .../api/member/MemberV1ControllerTest.java | 32 +++ 9 files changed, 260 insertions(+), 245 deletions(-) delete mode 100644 CLAUDE.md diff --git a/.codeguide/loopers-1-week.md b/.codeguide/loopers-1-week.md index a8ace53e..60520114 100644 --- a/.codeguide/loopers-1-week.md +++ b/.codeguide/loopers-1-week.md @@ -43,3 +43,90 @@ - [ ] 포인트 조회에 성공할 경우, 보유 포인트를 응답으로 반환한다. - [ ] `X-USER-ID` 헤더가 없을 경우, `400 Bad Request` 응답을 반환한다. + +--- + +## 📋 구현 기록 + +### 1. 회원가입 기능 (`feature/sign-up`) + +**구현 파일:** +| 파일 | 역할 | +|------|------| +| `MemberModel.java` | 회원 엔티티 | +| `MemberRepository.java` | Repository 인터페이스 | +| `MemberService.java` | 비즈니스 로직 (중복 검증, 비밀번호 검증, 암호화) | +| `MemberJpaRepository.java` | Spring Data JPA 인터페이스 | +| `MemberRepositoryImpl.java` | Repository 구현체 | +| `MemberV1Controller.java` | REST API 컨트롤러 | +| `MemberV1Dto.java` | 요청/응답 DTO | +| `PasswordEncoderConfig.java` | BCrypt Bean 설정 | + +**설계 근거:** +- `spring-security-crypto`만 사용: 전체 Spring Security는 과한 의존성 +- Layered Architecture: Domain → Infrastructure → Interface 분리 +- 비밀번호 검증을 Service에 위치: PasswordEncoder 의존성 필요 + +**TDD 테스트 목록:** +| 테스트 | 검증 내용 | +|--------|----------| +| `register_withValidInfo_savesMember` | 정상 회원가입 | +| `register_withDuplicateLoginId_throwsException` | 로그인 ID 중복 검증 | +| `register_withShortPassword_throwsException` | 비밀번호 8자 미만 검증 | +| `register_withBirthDateInPassword_throwsException` | 생년월일 포함 검증 | +| `signUp_withValidRequest_returnsCreated` | API 201 응답 | +| `signUp_withInvalidLoginIdFormat_returnsBadRequest` | API 400 응답 | + +--- + +### 2. 내 정보 조회 기능 (`feature/my-info`) + +**구현 파일:** +| 파일 | 역할 | +|------|------| +| `AuthMember.java` | 인증 어노테이션 | +| `AuthMemberResolver.java` | 헤더 기반 인증 처리 | +| `WebMvcConfig.java` | Resolver 등록 | +| `MemberV1Dto.MyInfoResponse` | 응답 DTO (마스킹 로직 포함) | +| `MemberV1Controller.getMyInfo()` | API 추가 | +| `ErrorType.UNAUTHORIZED` | 401 에러 타입 | + +**설계 근거:** +- `HandlerMethodArgumentResolver` 사용: 컨트롤러 코드 깔끔, 인증 로직 집중 +- Facade 생략: 단순 조회이므로 Controller에서 직접 DTO 변환 +- 마스킹 로직을 DTO에 위치: 표현 계층 관심사 + +**TDD 테스트 목록:** +| 테스트 | 검증 내용 | +|--------|----------| +| `myInfoResponse_masksLastCharacterOfName` | 이름 마스킹 (홍길동 → 홍길*) | +| `myInfoResponse_doesNotMaskSingleCharacterName` | 1글자 이름 마스킹 안함 | +| `getMyInfo_withoutAuthHeaders_returnsUnauthorized` | 인증 헤더 없음 401 | +| `getMyInfo_withWrongPassword_returnsUnauthorized` | 잘못된 비밀번호 401 | +| `getMyInfo_withValidAuth_returnsOkWithMaskedName` | 정상 조회 200 | + +--- + +### 3. 비밀번호 수정 기능 (`feature/change-password`) + +**구현 파일:** +| 파일 | 역할 | +|------|------| +| `MemberModel.changePassword()` | 비밀번호 변경 메서드 | +| `MemberService.changePassword()` | 검증 로직 + 암호화 | +| `MemberV1Controller.changePassword()` | PATCH API | +| `MemberV1Dto.ChangePasswordRequest` | 요청 DTO | + +**설계 근거:** +- 기존 비밀번호 검증 로직 재사용 (`PASSWORD_PATTERN`, `containsBirthDate`) +- Facade 생략: 단순 흐름 (Controller → Service → Entity) + +**TDD 테스트 목록:** +| 테스트 | 검증 내용 | 상태 | +|--------|----------|------| +| `changePassword_withWrongCurrentPassword_throwsException` | 현재 비밀번호 불일치 | ✅ | +| `changePassword_withSamePassword_throwsException` | 동일 비밀번호 | ✅ | +| `changePassword_withInvalidNewPassword_throwsException` | 규칙 위반 | ✅ | +| `changePassword_withBirthDateInNewPassword_throwsException` | 생년월일 포함 | ✅ | +| `changePassword_withValidInput_updatesPassword` | 정상 변경 | ✅ | +| `changePassword_withValidAuth_returnsOk` | PATCH API 200 응답 | ✅ | diff --git a/.gitignore b/.gitignore index 5a979af6..cbf7c4d1 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ out/ ### Kotlin ### .kotlin + +### Claude Code ### +CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 72947388..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,245 +0,0 @@ -# CLAUDE.md - -이 파일은 Claude Code가 이 저장소에서 작업할 때 참고하는 가이드입니다. - -## 프로젝트 개요 - -Loopers에서 제공하는 Spring + Java 기반 멀티 모듈 템플릿 프로젝트입니다. 커머스 도메인을 위한 API, Batch, Streamer 애플리케이션을 포함합니다. - -## 기술 스택 및 버전 - -| 기술 | 버전 | -|------|------| -| Java | 21 | -| Gradle | 8.13 | -| Spring Boot | 3.4.4 | -| Spring Cloud | 2024.0.1 | -| Spring Dependency Management | 1.1.7 | -| QueryDSL | (Spring Boot BOM 관리) | -| SpringDoc OpenAPI | 2.7.0 | -| Lombok | (Spring Boot BOM 관리) | -| Jackson | (Spring Boot BOM 관리) | -| MySQL Connector | (Spring Boot BOM 관리) | -| Redis | (Spring Boot BOM 관리) | -| Kafka | (Spring Boot BOM 관리) | -| Micrometer (Prometheus) | (Spring Boot BOM 관리) | -| Testcontainers | (Spring Boot BOM 관리) | -| JUnit 5 | (Spring Boot BOM 관리) | -| Mockito | 5.14.0 | -| SpringMockK | 4.0.2 | -| Instancio | 5.0.2 | - -## 모듈 구조 - -``` -Root -├── apps (실행 가능한 SpringBootApplication) -│ ├── commerce-api - REST API 서버 (Web, JPA, Redis, OpenAPI) -│ ├── commerce-batch - Spring Batch 애플리케이션 -│ └── commerce-streamer - Kafka Consumer 애플리케이션 -├── modules (재사용 가능한 설정 모듈) -│ ├── jpa - JPA/QueryDSL 설정, MySQL 연동 -│ ├── redis - Redis 설정 (Master-Replica 구조) -│ └── kafka - Kafka 설정 -└── supports (부가 기능 모듈) - ├── jackson - Jackson 직렬화 설정 - ├── logging - 로깅 설정 (Slack Appender 포함) - └── monitoring - Prometheus/Micrometer 메트릭 설정 -``` - -### 모듈 의존성 - -- **commerce-api**: jpa, redis, jackson, logging, monitoring -- **commerce-streamer**: jpa, redis, kafka, jackson, logging, monitoring -- **commerce-batch**: jpa, redis, jackson, logging, monitoring - -## 패키지 구조 (Layered Architecture) - -commerce-api 기준 패키지 구조: - -``` -com.loopers -├── CommerceApiApplication.java - 애플리케이션 진입점 -├── application/ - 유스케이스, Facade 계층 -│ └── example/ExampleFacade.java -├── domain/ - 도메인 모델, 서비스, 리포지토리 인터페이스 -│ └── example/ -│ ├── ExampleModel.java -│ ├── ExampleService.java -│ └── ExampleRepository.java -├── infrastructure/ - 리포지토리 구현체, 외부 연동 -│ └── example/ -│ ├── ExampleJpaRepository.java -│ └── ExampleRepositoryImpl.java -├── interfaces/ - API 컨트롤러, DTO -│ └── api/ -│ ├── ApiControllerAdvice.java -│ ├── ApiResponse.java -│ └── example/ -│ ├── ExampleV1Controller.java -│ ├── ExampleV1ApiSpec.java -│ └── ExampleV1Dto.java -└── support/ - 공통 유틸리티, 예외 처리 - └── error/ - ├── CoreException.java - └── ErrorType.java -``` - -## 빌드 및 실행 명령어 - -```bash -# 전체 빌드 -./gradlew build - -# 특정 모듈 빌드 -./gradlew :apps:commerce-api:build - -# 테스트 실행 -./gradlew test - -# 특정 모듈 테스트 -./gradlew :apps:commerce-api:test - -# 애플리케이션 실행 -./gradlew :apps:commerce-api:bootRun -./gradlew :apps:commerce-batch:bootRun -./gradlew :apps:commerce-streamer:bootRun - -# Clean -./gradlew clean -``` - -## 테스트 - -- 테스트 프레임워크: JUnit 5, Mockito, SpringMockK, Instancio -- 테스트 컨테이너: Testcontainers (MySQL, Redis, Kafka) -- 테스트 프로파일: `test` (자동 적용) -- 타임존: `Asia/Seoul` -- JaCoCo 코드 커버리지 리포트 생성 (XML 포맷) - -### 테스트 Fixtures - -modules 하위 모듈들은 `java-test-fixtures` 플러그인을 사용하여 테스트 픽스처 제공: -- `modules:jpa`: `MySqlTestContainersConfig`, `DatabaseCleanUp` -- `modules:redis`: `RedisTestContainersConfig`, `RedisCleanUp` -- `modules:kafka`: Kafka Testcontainers 설정 - -## 로컬 개발 환경 - -### 인프라 실행 - -```bash -# MySQL, Redis (Master-Replica), Kafka, Kafka-UI 실행 -docker-compose -f ./docker/infra-compose.yml up - -# 모니터링 (Prometheus, Grafana) 실행 -docker-compose -f ./docker/monitoring-compose.yml up -``` - -### 인프라 포트 - -| 서비스 | 포트 | -|--------|------| -| MySQL | 3306 | -| Redis Master | 6379 | -| Redis Replica | 6380 | -| Kafka | 9092 (내부), 19092 (외부) | -| Kafka UI | 9099 | -| Grafana | 3000 (admin/admin) | - -## 코드 스타일 - -- 빌드 시스템: Gradle Kotlin DSL -- Java 21 toolchain 사용 -- Lombok 적극 활용 -- QueryDSL로 타입 안전한 쿼리 작성 -- API 버전닝: URL 기반 (`/v1/...`) -- OpenAPI(Swagger) 문서화 (`springdoc-openapi`) ---- -## Git 브랜치 전략 - -### 브랜치 구조 - -``` -main - └── week{N} (주차 브랜치, PR 대상) - ├── feature/{기능명1} - ├── feature/{기능명2} - └── feature/{기능명3} -``` - -### Workflow - -1. `main`에서 `week{N}` 브랜치 생성 -2. `week{N}`에서 각 기능별 `feature/*` 브랜치 생성 -3. 기능 완료 시 `feature/*` → `week{N}`로 머지 -4. 주차 전체 기능 완료 후 `week{N}` → `main`으로 PR - -### 브랜치 생성/병합/커밋 시점 - -#### 브랜치 생성 -- `week{N}` 브랜치: 해당 주차 작업 시작 전 `main`에서 생성 -- `feature/*` 브랜치: 해당 기능 구현 시작 전 `week{N}`에서 생성 - -#### 커밋 시점 -- TDD 사이클 완료 시 (Red → Green → Refactor 한 사이클) -- 의미 있는 단위의 작업 완료 시 -- 테스트가 모두 통과하는 상태에서만 커밋 - -#### 병합 시점 -- `feature/*` → `week{N}`: 해당 기능의 모든 테스트 통과 후 -- `week{N}` → `main`: 주차 전체 기능 완료 및 테스트 통과 후 PR - -### 주차별 기능 목록 - -| 주차 | 기능 | 브랜치 | -|------|------|--------| -| 1주차 | 회원가입 | `feature/sign-up` | -| 1주차 | 내 정보 조회 | `feature/my-info` | -| 1주차 | 비밀번호 수정 | `feature/change-password` | - ---- -## 개발 규칙 -### 진행 Workflow - 증강 코딩 -- **대원칙** : 방향성 및 주요 의사 결정은 개발자에게 제안만 할 수 있으며, 최종 승인된 사항을 기반으로 작업을 수행. -- **중간 결과 보고** : AI 가 반복적인 동작을 하거나, 요청하지 않은 기능을 구현, 테스트 삭제를 임의로 진행할 경우 개발자가 개입. -- **설계 주도권 유지** : AI 가 임의판단을 하지 않고, 방향성에 대한 제안 등을 진행할 수 있으나 개발자의 승인을 받은 후 수행. -- **적극적인 질문** : 구현 중 불확실한 사항, 여러 선택지가 있는 경우, 요구사항이 모호한 경우 반드시 개발자에게 질문하여 확인할 것. - -### 개발 Workflow - TDD (Red > Green > Refactor) -- 모든 테스트는 3A 원칙으로 작성할 것 (Arrange - Act - Assert) -- **매 테스트/구현마다 아래 내용을 설명할 것:** - 1. **목적**: 무엇을 검증하려는지 - 2. **구현 방법**: 테스트를 어떻게 작성했는지 - 3. **결과**: 테스트 실행 결과 (성공/실패) - 4. **다음 단계**: 결과에 따라 어떻게 진행할지 - -#### 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 diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java index 21c6b9ee..23add16a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java @@ -55,4 +55,8 @@ public LocalDate getBirthDate() { public String getEmail() { return email; } + + public void changePassword(String encodedPassword) { + this.password = encodedPassword; + } } 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 50fbb4e4..257deace 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 @@ -36,6 +36,27 @@ public MemberModel register(String loginId, String password, String name, LocalD return memberRepository.save(member); } + public void changePassword(MemberModel member, String currentPassword, String newPassword) { + if (!passwordEncoder.matches(currentPassword, member.getPassword())) { + throw new CoreException(ErrorType.BAD_REQUEST, "현재 비밀번호가 일치하지 않습니다."); + } + + if (currentPassword.equals(newPassword)) { + throw new CoreException(ErrorType.BAD_REQUEST, "새 비밀번호는 현재 비밀번호와 달라야 합니다."); + } + + if (!newPassword.matches(PASSWORD_PATTERN)) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 8~16자의 영문 대소문자, 숫자, 특수문자만 허용됩니다."); + } + + if (containsBirthDate(newPassword, member.getBirthDate())) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호에 생년월일을 포함할 수 없습니다."); + } + + String encodedNewPassword = passwordEncoder.encode(newPassword); + member.changePassword(encodedNewPassword); + } + private boolean containsBirthDate(String password, LocalDate birthDate) { String yyyyMMdd = birthDate.format(DateTimeFormatter.ofPattern("yyyyMMdd")); String yyMMdd = birthDate.format(DateTimeFormatter.ofPattern("yyMMdd")); 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 e0532291..2a63a5df 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.RequestMapping; @@ -46,4 +47,13 @@ public ApiResponse signUp(@Valid @RequestBody Member public ApiResponse getMyInfo(@AuthMember MemberModel member) { return ApiResponse.success(MemberV1Dto.MyInfoResponse.from(member)); } + + @PatchMapping("/me/password") + public ApiResponse changePassword( + @AuthMember MemberModel member, + @Valid @RequestBody MemberV1Dto.ChangePasswordRequest request + ) { + memberService.changePassword(member, request.currentPassword(), request.newPassword()); + return ApiResponse.success(); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java index c5313e0f..0d692f4d 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 @@ -50,4 +50,9 @@ private static String maskName(String name) { return name.substring(0, name.length() - 1) + "*"; } } + + public record ChangePasswordRequest( + @NotBlank String currentPassword, + @NotBlank @Size(min = 8, max = 16) String newPassword + ) {} } 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 6ccdbe8a..a8180d97 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 @@ -105,4 +105,102 @@ void register_withBirthDateInPassword_throwsException() { assertThatThrownBy(() -> memberService.register(loginId, password, name, birthDate, email)) .isInstanceOf(CoreException.class); } + + @DisplayName("비밀번호 변경 시 현재 비밀번호가 일치하지 않으면 예외가 발생한다") + @Test + void changePassword_withWrongCurrentPassword_throwsException() { + // arrange + MemberModel member = new MemberModel( + "testuser1", + "encodedPassword", + "홍길동", + LocalDate.of(1990, 1, 15), + "test@example.com" + ); + + when(passwordEncoder.matches("wrongPassword", "encodedPassword")).thenReturn(false); + + // act & assert + assertThatThrownBy(() -> memberService.changePassword(member, "wrongPassword", "NewPassword1!")) + .isInstanceOf(CoreException.class); + } + + @DisplayName("비밀번호 변경 시 새 비밀번호가 현재 비밀번호와 동일하면 예외가 발생한다") + @Test + void changePassword_withSamePassword_throwsException() { + // arrange + MemberModel member = new MemberModel( + "testuser1", + "encodedPassword", + "홍길동", + LocalDate.of(1990, 1, 15), + "test@example.com" + ); + + when(passwordEncoder.matches("Password1!", "encodedPassword")).thenReturn(true); + + // act & assert + assertThatThrownBy(() -> memberService.changePassword(member, "Password1!", "Password1!")) + .isInstanceOf(CoreException.class); + } + + @DisplayName("비밀번호 변경 시 새 비밀번호가 규칙을 위반하면 예외가 발생한다") + @Test + void changePassword_withInvalidNewPassword_throwsException() { + // arrange + MemberModel member = new MemberModel( + "testuser1", + "encodedPassword", + "홍길동", + LocalDate.of(1990, 1, 15), + "test@example.com" + ); + + when(passwordEncoder.matches("Password1!", "encodedPassword")).thenReturn(true); + + // act & assert - 7자 비밀번호 + assertThatThrownBy(() -> memberService.changePassword(member, "Password1!", "Short1!")) + .isInstanceOf(CoreException.class); + } + + @DisplayName("비밀번호 변경 시 새 비밀번호에 생년월일이 포함되면 예외가 발생한다") + @Test + void changePassword_withBirthDateInNewPassword_throwsException() { + // arrange + MemberModel member = new MemberModel( + "testuser1", + "encodedPassword", + "홍길동", + LocalDate.of(1990, 1, 15), + "test@example.com" + ); + + when(passwordEncoder.matches("Password1!", "encodedPassword")).thenReturn(true); + + // act & assert - 생년월일 포함 + assertThatThrownBy(() -> memberService.changePassword(member, "Password1!", "Pass19900115!")) + .isInstanceOf(CoreException.class); + } + + @DisplayName("비밀번호 변경이 정상적으로 완료되면 암호화된 새 비밀번호가 저장된다") + @Test + void changePassword_withValidInput_updatesPassword() { + // arrange + MemberModel member = new MemberModel( + "testuser1", + "encodedOldPassword", + "홍길동", + LocalDate.of(1990, 1, 15), + "test@example.com" + ); + + when(passwordEncoder.matches("OldPassword1!", "encodedOldPassword")).thenReturn(true); + when(passwordEncoder.encode("NewPassword1!")).thenReturn("encodedNewPassword"); + + // act + memberService.changePassword(member, "OldPassword1!", "NewPassword1!"); + + // assert + assertThat(member.getPassword()).isEqualTo("encodedNewPassword"); + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ControllerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ControllerTest.java index aaa4abed..0fa72925 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ControllerTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ControllerTest.java @@ -21,7 +21,9 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.doNothing; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -150,4 +152,34 @@ void getMyInfo_withValidAuth_returnsOkWithMaskedName() throws Exception { .andExpect(jsonPath("$.data.name").value("홍길*")) .andExpect(jsonPath("$.data.email").value("test@example.com")); } + + @DisplayName("올바른 인증 헤더로 비밀번호 변경하면 200 OK 응답을 받는다") + @Test + void changePassword_withValidAuth_returnsOk() throws Exception { + // arrange + MemberModel member = new MemberModel( + "testuser1", + "encodedPassword", + "홍길동", + LocalDate.of(1990, 1, 15), + "test@example.com" + ); + + MemberV1Dto.ChangePasswordRequest request = new MemberV1Dto.ChangePasswordRequest( + "OldPassword1!", + "NewPassword1!" + ); + + when(memberRepository.findByLoginId("testuser1")).thenReturn(Optional.of(member)); + when(passwordEncoder.matches("OldPassword1!", "encodedPassword")).thenReturn(true); + doNothing().when(memberService).changePassword(any(MemberModel.class), anyString(), anyString()); + + // act & assert + mockMvc.perform(patch("/api/v1/members/me/password") + .header("X-Loopers-LoginId", "testuser1") + .header("X-Loopers-LoginPw", "OldPassword1!") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } } From 533790c64b1944d9d5ce1e7a4affa6cdcacc6322 Mon Sep 17 00:00:00 2001 From: SukheeChoi <95064440+SukheeChoi@users.noreply.github.com> Date: Thu, 5 Feb 2026 23:42:18 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20DDD=20=EB=A6=AC=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A7=81=20+=20Value=20Object=20=EB=8F=84=EC=9E=85=20+=20?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=A1=B0=ED=9A=8C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MemberModel → Member 엔티티 리네이밍 (DDD 네이밍) - Value Object 도입: LoginId, Email, BirthDate, Password (@Embeddable, 자가 검증) - Gender enum 추가 및 회원가입 시 성별 필수 처리 - PasswordPolicy 도메인 정책 분리 (순수 함수) - Service 얇은 조율 계층으로 리팩토링 (검증 로직 VO/Policy로 이동) - 포인트 조회 API 신규 구현 (GET /api/v1/points) - AuthMemberResolver 보안 에러 메시지 통일 - 단위 테스트 (LoginIdTest, EmailTest, BirthDateTest 등) - 통합 테스트 (MemberServiceIntegrationTest, @SpyBean) - E2E 테스트 (MemberV1ApiE2ETest, PointV1ApiE2ETest) Co-Authored-By: Claude Opus 4.5 --- .../com/loopers/domain/member/Gender.java | 5 + .../com/loopers/domain/member/Member.java | 70 ++++++ .../loopers/domain/member/MemberModel.java | 62 ------ .../domain/member/MemberRepository.java | 8 +- .../loopers/domain/member/MemberService.java | 64 +++--- .../domain/member/policy/PasswordPolicy.java | 45 ++++ .../loopers/domain/member/vo/BirthDate.java | 56 +++++ .../com/loopers/domain/member/vo/Email.java | 41 ++++ .../com/loopers/domain/member/vo/LoginId.java | 40 ++++ .../loopers/domain/member/vo/Password.java | 49 +++++ .../member/MemberJpaRepository.java | 8 +- .../member/MemberRepositoryImpl.java | 13 +- .../api/member/MemberV1Controller.java | 16 +- .../interfaces/api/member/MemberV1Dto.java | 30 ++- .../api/point/PointV1Controller.java | 33 +++ .../interfaces/api/point/PointV1Dto.java | 5 + .../support/auth/AuthMemberResolver.java | 13 +- .../member/MemberServiceIntegrationTest.java | 126 +++++++++++ .../domain/member/MemberServiceTest.java | 206 ------------------ .../com/loopers/domain/member/MemberTest.java | 75 +++++++ .../member/policy/PasswordPolicyTest.java | 76 +++++++ .../domain/member/vo/BirthDateTest.java | 48 ++++ .../loopers/domain/member/vo/EmailTest.java | 46 ++++ .../loopers/domain/member/vo/LoginIdTest.java | 53 +++++ .../domain/member/vo/PasswordTest.java | 59 +++++ .../api/member/MemberV1ApiE2ETest.java | 162 ++++++++++++++ .../api/member/MemberV1ControllerTest.java | 185 ---------------- .../api/member/MemberV1DtoTest.java | 50 ----- .../api/point/PointV1ApiE2ETest.java | 102 +++++++++ 29 files changed, 1168 insertions(+), 578 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/Gender.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/policy/PasswordPolicy.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/vo/BirthDate.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Email.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/vo/LoginId.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Password.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/policy/PasswordPolicyTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/vo/BirthDateTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/vo/EmailTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/vo/LoginIdTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/member/vo/PasswordTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ApiE2ETest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ControllerTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1DtoTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/point/PointV1ApiE2ETest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/Gender.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/Gender.java new file mode 100644 index 00000000..114e74cc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/Gender.java @@ -0,0 +1,5 @@ +package com.loopers.domain.member; + +public enum Gender { + MALE, FEMALE +} 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..40d57263 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java @@ -0,0 +1,70 @@ +package com.loopers.domain.member; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.member.vo.BirthDate; +import com.loopers.domain.member.vo.Email; +import com.loopers.domain.member.vo.LoginId; +import com.loopers.domain.member.vo.Password; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; + +@Entity +@Table(name = "member") +public class Member extends BaseEntity { + + @Embedded + private LoginId loginId; + + @Embedded + private Password password; + + @Column(nullable = false, length = 50) + private String name; + + @Embedded + private BirthDate birthDate; + + @Embedded + private Email email; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 10) + private Gender gender; + + @Column(nullable = false) + private long point; + + protected Member() {} + + public Member(LoginId loginId, Password password, String name, + BirthDate birthDate, Email email, Gender gender) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이름은 필수입니다."); + } + this.loginId = loginId; + this.password = password; + this.name = name; + this.birthDate = birthDate; + this.email = email; + this.gender = gender; + this.point = 0L; + } + + public LoginId getLoginId() { return loginId; } + public Password getPassword() { return password; } + public String getName() { return name; } + public BirthDate getBirthDate() { return birthDate; } + public Email getEmail() { return email; } + public Gender getGender() { return gender; } + public long getPoint() { return point; } + + public void changePassword(Password newPassword) { + this.password = newPassword; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java deleted file mode 100644 index 23add16a..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.loopers.domain.member; - -import com.loopers.domain.BaseEntity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; - -import java.time.LocalDate; - -@Entity -@Table(name = "member") -public class MemberModel extends BaseEntity { - - @Column(nullable = false, unique = true, length = 20) - private String loginId; - - @Column(nullable = false) - private String password; - - @Column(nullable = false, length = 50) - private String name; - - @Column(nullable = false) - private LocalDate birthDate; - - @Column(nullable = false, length = 100) - private String email; - - protected MemberModel() {} - - public MemberModel(String loginId, String password, String name, LocalDate birthDate, String email) { - this.loginId = loginId; - this.password = password; - this.name = name; - this.birthDate = birthDate; - this.email = email; - } - - public String getLoginId() { - return loginId; - } - - public String getPassword() { - return password; - } - - public String getName() { - return name; - } - - public LocalDate getBirthDate() { - return birthDate; - } - - public String getEmail() { - return email; - } - - public void changePassword(String encodedPassword) { - this.password = encodedPassword; - } -} 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 b03d7b64..923b7f0d 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,9 +1,11 @@ package com.loopers.domain.member; +import com.loopers.domain.member.vo.LoginId; + import java.util.Optional; public interface MemberRepository { - MemberModel save(MemberModel member); - Optional findByLoginId(String loginId); - boolean existsByLoginId(String loginId); + Member save(Member member); + Optional findByLoginId(LoginId loginId); + boolean existsByLoginId(LoginId 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 257deace..005a30d2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java @@ -1,13 +1,16 @@ package com.loopers.domain.member; +import com.loopers.domain.member.vo.BirthDate; +import com.loopers.domain.member.vo.Email; +import com.loopers.domain.member.vo.LoginId; +import com.loopers.domain.member.vo.Password; 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.Service; -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; +import java.util.Optional; @RequiredArgsConstructor @Service @@ -16,50 +19,43 @@ public class MemberService { private final MemberRepository memberRepository; private final PasswordEncoder passwordEncoder; - private static final String PASSWORD_PATTERN = "^[A-Za-z0-9!@#$%^&*()_+=-]{8,16}$"; + public Member register(String loginId, String plainPassword, String name, + String birthDate, String email, Gender gender) { + LoginId loginIdVo = new LoginId(loginId); - public MemberModel register(String loginId, String password, String name, LocalDate birthDate, String email) { - if (memberRepository.existsByLoginId(loginId)) { - throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 로그인 ID입니다."); + if (memberRepository.existsByLoginId(loginIdVo)) { + throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 ID입니다."); } - if (!password.matches(PASSWORD_PATTERN)) { - throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 8~16자의 영문 대소문자, 숫자, 특수문자만 허용됩니다."); - } - - if (containsBirthDate(password, birthDate)) { - throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호에 생년월일을 포함할 수 없습니다."); - } + BirthDate birthDateVo = BirthDate.from(birthDate); + Password password = Password.create(plainPassword, birthDateVo.value(), passwordEncoder); + Email emailVo = new Email(email); - String encodedPassword = passwordEncoder.encode(password); - MemberModel member = new MemberModel(loginId, encodedPassword, name, birthDate, email); + Member member = new Member(loginIdVo, password, name, birthDateVo, emailVo, gender); return memberRepository.save(member); } - public void changePassword(MemberModel member, String currentPassword, String newPassword) { - if (!passwordEncoder.matches(currentPassword, member.getPassword())) { - throw new CoreException(ErrorType.BAD_REQUEST, "현재 비밀번호가 일치하지 않습니다."); - } + public Optional findByLoginId(String loginId) { + return memberRepository.findByLoginId(new LoginId(loginId)); + } - if (currentPassword.equals(newPassword)) { - throw new CoreException(ErrorType.BAD_REQUEST, "새 비밀번호는 현재 비밀번호와 달라야 합니다."); - } + public Optional findPointByLoginId(String loginId) { + return memberRepository.findByLoginId(new LoginId(loginId)) + .map(Member::getPoint); + } - if (!newPassword.matches(PASSWORD_PATTERN)) { - throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 8~16자의 영문 대소문자, 숫자, 특수문자만 허용됩니다."); + public void changePassword(Member member, String currentPlain, String newPlain) { + if (!member.getPassword().matches(currentPlain, passwordEncoder)) { + throw new CoreException(ErrorType.BAD_REQUEST, "현재 비밀번호가 일치하지 않습니다."); } - if (containsBirthDate(newPassword, member.getBirthDate())) { - throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호에 생년월일을 포함할 수 없습니다."); + if (member.getPassword().matches(newPlain, passwordEncoder)) { + throw new CoreException(ErrorType.BAD_REQUEST, + "새 비밀번호는 현재 비밀번호와 달라야 합니다."); } - String encodedNewPassword = passwordEncoder.encode(newPassword); - member.changePassword(encodedNewPassword); - } - - private boolean containsBirthDate(String password, LocalDate birthDate) { - String yyyyMMdd = birthDate.format(DateTimeFormatter.ofPattern("yyyyMMdd")); - String yyMMdd = birthDate.format(DateTimeFormatter.ofPattern("yyMMdd")); - return password.contains(yyyyMMdd) || password.contains(yyMMdd); + Password newPassword = Password.create( + newPlain, member.getBirthDate().value(), passwordEncoder); + member.changePassword(newPassword); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/policy/PasswordPolicy.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/policy/PasswordPolicy.java new file mode 100644 index 00000000..6f4db2d8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/policy/PasswordPolicy.java @@ -0,0 +1,45 @@ +package com.loopers.domain.member.policy; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.regex.Pattern; + +public class PasswordPolicy { + + private static final Pattern FORMAT_PATTERN = + Pattern.compile("^[A-Za-z0-9!@#$%^&*()_+=-]{8,16}$"); + + public static void validate(String plain, LocalDate birthDate) { + validateFormat(plain); + validateNotContainsSubstrings(plain, + extractBirthDateStrings(birthDate), + "비밀번호에 생년월일을 포함할 수 없습니다."); + } + + public static void validateFormat(String plain) { + if (plain == null || !FORMAT_PATTERN.matcher(plain).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, + "비밀번호는 8~16자의 영문, 숫자, 특수문자만 허용됩니다."); + } + } + + public static void validateNotContainsSubstrings( + String plain, List forbidden, String errorMessage) { + for (String s : forbidden) { + if (plain.contains(s)) { + throw new CoreException(ErrorType.BAD_REQUEST, errorMessage); + } + } + } + + public static List extractBirthDateStrings(LocalDate birthDate) { + return List.of( + birthDate.format(DateTimeFormatter.ofPattern("yyyyMMdd")), + birthDate.format(DateTimeFormatter.ofPattern("yyMMdd")) + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/BirthDate.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/BirthDate.java new file mode 100644 index 00000000..cd972596 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/BirthDate.java @@ -0,0 +1,56 @@ +package com.loopers.domain.member.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.Objects; + +@Embeddable +public class BirthDate { + + private static final DateTimeFormatter FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + @Column(name = "birth_date", nullable = false) + private LocalDate value; + + protected BirthDate() {} + + public BirthDate(LocalDate value) { + if (value == null) { + throw new CoreException(ErrorType.BAD_REQUEST, + "생년월일은 필수입니다."); + } + this.value = value; + } + + public static BirthDate from(String dateString) { + if (dateString == null || dateString.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, + "생년월일은 필수입니다."); + } + try { + return new BirthDate(LocalDate.parse(dateString, FORMATTER)); + } catch (DateTimeParseException e) { + throw new CoreException(ErrorType.BAD_REQUEST, + "생년월일은 yyyy-MM-dd 형식이어야 합니다."); + } + } + + public LocalDate value() { return value; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof BirthDate birthDate)) return false; + return Objects.equals(value, birthDate.value); + } + + @Override + public int hashCode() { return Objects.hash(value); } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Email.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Email.java new file mode 100644 index 00000000..7562e18a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Email.java @@ -0,0 +1,41 @@ +package com.loopers.domain.member.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +import java.util.Objects; +import java.util.regex.Pattern; + +@Embeddable +public class Email { + + private static final Pattern PATTERN = + Pattern.compile("^[\\w-.]+@[\\w-]+(\\.[a-z]{2,})+$"); + + @Column(name = "email", nullable = false, length = 100) + private String value; + + protected Email() {} + + public Email(String value) { + if (value == null || !PATTERN.matcher(value).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, + "올바른 이메일 형식이 아닙니다."); + } + this.value = value; + } + + public String value() { return value; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Email email)) return false; + return Objects.equals(value, email.value); + } + + @Override + public int hashCode() { return Objects.hash(value); } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/LoginId.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/LoginId.java new file mode 100644 index 00000000..d003c520 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/LoginId.java @@ -0,0 +1,40 @@ +package com.loopers.domain.member.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +import java.util.Objects; +import java.util.regex.Pattern; + +@Embeddable +public class LoginId { + + private static final Pattern PATTERN = Pattern.compile("^[A-Za-z0-9]{1,10}$"); + + @Column(name = "login_id", nullable = false, unique = true, length = 20) + private String value; + + protected LoginId() {} + + public LoginId(String value) { + if (value == null || !PATTERN.matcher(value).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, + "ID는 영문 및 숫자 10자 이내여야 합니다."); + } + this.value = value; + } + + public String value() { return value; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof LoginId loginId)) return false; + return Objects.equals(value, loginId.value); + } + + @Override + public int hashCode() { return Objects.hash(value); } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Password.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Password.java new file mode 100644 index 00000000..d44acd58 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Password.java @@ -0,0 +1,49 @@ +package com.loopers.domain.member.vo; + +import com.loopers.domain.member.policy.PasswordPolicy; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDate; +import java.util.Objects; + +@Embeddable +public class Password { + + @Column(name = "password", nullable = false) + private String encoded; + + protected Password() {} + + public Password(String encoded) { + if (encoded == null || encoded.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 필수입니다."); + } + this.encoded = encoded; + } + + public static Password create(String plain, LocalDate birthDate, + PasswordEncoder encoder) { + PasswordPolicy.validate(plain, birthDate); + return new Password(encoder.encode(plain)); + } + + public boolean matches(String plain, PasswordEncoder encoder) { + return encoder.matches(plain, this.encoded); + } + + public String encoded() { return encoded; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Password password)) return false; + return Objects.equals(encoded, password.encoded); + } + + @Override + public int hashCode() { return Objects.hash(encoded); } +} 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 6a2558d6..edaadac0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java @@ -1,11 +1,11 @@ package com.loopers.infrastructure.member; -import com.loopers.domain.member.MemberModel; +import com.loopers.domain.member.Member; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; -public interface MemberJpaRepository extends JpaRepository { - Optional findByLoginId(String loginId); - boolean existsByLoginId(String loginId); +public interface MemberJpaRepository extends JpaRepository { + Optional findByLoginIdValue(String loginId); + boolean existsByLoginIdValue(String 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 c96e969a..a8be0aea 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java @@ -1,7 +1,8 @@ package com.loopers.infrastructure.member; -import com.loopers.domain.member.MemberModel; +import com.loopers.domain.member.Member; import com.loopers.domain.member.MemberRepository; +import com.loopers.domain.member.vo.LoginId; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @@ -14,17 +15,17 @@ public class MemberRepositoryImpl implements MemberRepository { private final MemberJpaRepository memberJpaRepository; @Override - public MemberModel save(MemberModel member) { + public Member save(Member member) { return memberJpaRepository.save(member); } @Override - public Optional findByLoginId(String loginId) { - return memberJpaRepository.findByLoginId(loginId); + public Optional findByLoginId(LoginId loginId) { + return memberJpaRepository.findByLoginIdValue(loginId.value()); } @Override - public boolean existsByLoginId(String loginId) { - return memberJpaRepository.existsByLoginId(loginId); + public boolean existsByLoginId(LoginId loginId) { + return memberJpaRepository.existsByLoginIdValue(loginId.value()); } } 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 2a63a5df..07c61241 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java @@ -1,6 +1,6 @@ package com.loopers.interfaces.api.member; -import com.loopers.domain.member.MemberModel; +import com.loopers.domain.member.Member; import com.loopers.domain.member.MemberService; import com.loopers.interfaces.api.ApiResponse; import com.loopers.support.auth.AuthMember; @@ -25,32 +25,34 @@ public class MemberV1Controller { @PostMapping @ResponseStatus(HttpStatus.CREATED) public ApiResponse signUp(@Valid @RequestBody MemberV1Dto.SignUpRequest request) { - MemberModel member = memberService.register( + Member member = memberService.register( request.loginId(), request.password(), request.name(), request.birthDate(), - request.email() + request.email(), + request.gender() ); MemberV1Dto.SignUpResponse response = new MemberV1Dto.SignUpResponse( member.getId(), - member.getLoginId(), + member.getLoginId().value(), member.getName(), - member.getEmail() + member.getEmail().value(), + member.getGender() ); return ApiResponse.success(response); } @GetMapping("/me") - public ApiResponse getMyInfo(@AuthMember MemberModel member) { + public ApiResponse getMyInfo(@AuthMember Member member) { return ApiResponse.success(MemberV1Dto.MyInfoResponse.from(member)); } @PatchMapping("/me/password") public ApiResponse changePassword( - @AuthMember MemberModel member, + @AuthMember Member member, @Valid @RequestBody MemberV1Dto.ChangePasswordRequest request ) { memberService.changePassword(member, request.currentPassword(), request.newPassword()); 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 0d692f4d..a9f9571c 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,31 +1,29 @@ package com.loopers.interfaces.api.member; -import jakarta.validation.constraints.Email; +import com.loopers.domain.member.Gender; +import com.loopers.domain.member.Member; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Past; -import jakarta.validation.constraints.Pattern; -import jakarta.validation.constraints.Size; - -import com.loopers.domain.member.MemberModel; import java.time.LocalDate; public class MemberV1Dto { public record SignUpRequest( - @NotBlank @Pattern(regexp = "^[A-Za-z0-9]+$") String loginId, - @NotBlank @Size(min = 8, max = 16) String password, + @NotBlank String loginId, + @NotBlank String password, @NotBlank String name, - @NotNull @Past LocalDate birthDate, - @NotBlank @Email String email + @NotBlank String birthDate, + @NotBlank String email, + @NotNull Gender gender ) {} public record SignUpResponse( Long id, String loginId, String name, - String email + String email, + Gender gender ) {} public record MyInfoResponse( @@ -34,12 +32,12 @@ public record MyInfoResponse( LocalDate birthDate, String email ) { - public static MyInfoResponse from(MemberModel member) { + public static MyInfoResponse from(Member member) { return new MyInfoResponse( - member.getLoginId(), + member.getLoginId().value(), maskName(member.getName()), - member.getBirthDate(), - member.getEmail() + member.getBirthDate().value(), + member.getEmail().value() ); } @@ -53,6 +51,6 @@ private static String maskName(String name) { public record ChangePasswordRequest( @NotBlank String currentPassword, - @NotBlank @Size(min = 8, max = 16) String newPassword + @NotBlank String newPassword ) {} } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java new file mode 100644 index 00000000..5355e6e0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java @@ -0,0 +1,33 @@ +package com.loopers.interfaces.api.point; + +import com.loopers.domain.member.MemberService; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/points") +public class PointV1Controller { + + private final MemberService memberService; + + @GetMapping + public ApiResponse getPoint( + @RequestHeader(value = "X-USER-ID", required = false) String userId + ) { + if (userId == null || userId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "X-USER-ID 헤더가 필요합니다."); + } + + Long point = memberService.findPointByLoginId(userId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "회원을 찾을 수 없습니다.")); + + return ApiResponse.success(new PointV1Dto.PointResponse(point)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java new file mode 100644 index 00000000..3252af4d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java @@ -0,0 +1,5 @@ +package com.loopers.interfaces.api.point; + +public class PointV1Dto { + public record PointResponse(long point) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthMemberResolver.java b/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthMemberResolver.java index 40d6bba6..978e677f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthMemberResolver.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthMemberResolver.java @@ -1,7 +1,8 @@ package com.loopers.support.auth; -import com.loopers.domain.member.MemberModel; +import com.loopers.domain.member.Member; import com.loopers.domain.member.MemberRepository; +import com.loopers.domain.member.vo.LoginId; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import jakarta.servlet.http.HttpServletRequest; @@ -45,11 +46,13 @@ public Object resolveArgument( throw new CoreException(ErrorType.UNAUTHORIZED, "인증 헤더가 필요합니다."); } - MemberModel member = memberRepository.findByLoginId(loginId) - .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED, "인증에 실패했습니다.")); + Member member = memberRepository.findByLoginId(new LoginId(loginId)) + .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED, + "아이디 또는 비밀번호가 일치하지 않습니다.")); - if (!passwordEncoder.matches(password, member.getPassword())) { - throw new CoreException(ErrorType.UNAUTHORIZED, "인증에 실패했습니다."); + if (!member.getPassword().matches(password, passwordEncoder)) { + throw new CoreException(ErrorType.UNAUTHORIZED, + "아이디 또는 비밀번호가 일치하지 않습니다."); } return member; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java new file mode 100644 index 00000000..d11c80c3 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java @@ -0,0 +1,126 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; + +@SpringBootTest +class MemberServiceIntegrationTest { + + @MockitoSpyBean + private MemberRepository memberRepository; + + @Autowired + private MemberService memberService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("회원 가입") + @Nested + class Register { + + @DisplayName("회원 가입시 User 저장이 수행된다") + @Test + void register_savesUser_verifiedBySpy() { + // act + Member result = memberService.register( + "user1", "Password1!", "홍길동", "1990-01-15", "test@example.com", Gender.MALE); + + // assert + verify(memberRepository).save(any(Member.class)); + assertThat(result.getId()).isNotNull(); + } + + @DisplayName("이미 가입된 ID로 회원가입 시도 시 실패한다") + @Test + void register_withDuplicateId_throwsException() { + // arrange + memberService.register( + "user1", "Password1!", "홍길동", "1990-01-15", "test@example.com", Gender.MALE); + + // act & assert + assertThatThrownBy(() -> memberService.register( + "user1", "Password2!", "김철수", "1995-05-20", "other@example.com", Gender.MALE)) + .isInstanceOf(CoreException.class); + } + } + + @DisplayName("내 정보 조회") + @Nested + class FindByLoginId { + + @DisplayName("해당 ID의 회원이 존재할 경우 회원 정보가 반환된다") + @Test + void findByLoginId_whenExists_returnsMember() { + // arrange + memberService.register( + "user1", "Password1!", "홍길동", "1990-01-15", "test@example.com", Gender.MALE); + + // act + Optional result = memberService.findByLoginId("user1"); + + // assert + assertThat(result).isPresent(); + assertThat(result.get().getLoginId().value()).isEqualTo("user1"); + } + + @DisplayName("해당 ID의 회원이 존재하지 않을 경우 null이 반환된다") + @Test + void findByLoginId_whenNotExists_returnsEmpty() { + // act + Optional result = memberService.findByLoginId("nobody"); + + // assert + assertThat(result).isEmpty(); + } + } + + @DisplayName("포인트 조회") + @Nested + class FindPointByLoginId { + + @DisplayName("해당 ID의 회원이 존재할 경우 보유 포인트가 반환된다") + @Test + void findPointByLoginId_whenExists_returnsPoint() { + // arrange + memberService.register( + "user1", "Password1!", "홍길동", "1990-01-15", "test@example.com", Gender.MALE); + + // act + Optional point = memberService.findPointByLoginId("user1"); + + // assert + assertThat(point).isPresent(); + assertThat(point.get()).isEqualTo(0L); + } + + @DisplayName("해당 ID의 회원이 존재하지 않을 경우 null이 반환된다") + @Test + void findPointByLoginId_whenNotExists_returnsEmpty() { + // act + Optional point = memberService.findPointByLoginId("nobody"); + + // assert + assertThat(point).isEmpty(); + } + } +} 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 deleted file mode 100644 index a8180d97..00000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceTest.java +++ /dev/null @@ -1,206 +0,0 @@ -package com.loopers.domain.member; - -import com.loopers.support.error.CoreException; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.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.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class MemberServiceTest { - - @Mock - private MemberRepository memberRepository; - - @Mock - private PasswordEncoder passwordEncoder; - - @InjectMocks - private MemberService memberService; - - @DisplayName("유효한 정보로 회원가입하면 회원이 저장된다") - @Test - void register_withValidInfo_savesMember() { - // arrange - String loginId = "testuser1"; - String password = "Password1!"; - String name = "홍길동"; - LocalDate birthDate = LocalDate.of(1990, 1, 15); - String email = "test@example.com"; - - when(memberRepository.existsByLoginId(loginId)).thenReturn(false); - when(passwordEncoder.encode(password)).thenReturn("encodedPassword"); - when(memberRepository.save(any(MemberModel.class))).thenAnswer(invocation -> invocation.getArgument(0)); - - // act - MemberModel result = memberService.register(loginId, password, name, birthDate, email); - - // assert - assertThat(result.getLoginId()).isEqualTo(loginId); - assertThat(result.getName()).isEqualTo(name); - assertThat(result.getBirthDate()).isEqualTo(birthDate); - assertThat(result.getEmail()).isEqualTo(email); - verify(memberRepository).save(any(MemberModel.class)); - } - - @DisplayName("이미 존재하는 로그인 ID로 가입하면 예외가 발생한다") - @Test - void register_withDuplicateLoginId_throwsException() { - // arrange - String loginId = "existinguser"; - String password = "Password1!"; - String name = "홍길동"; - LocalDate birthDate = LocalDate.of(1990, 1, 15); - String email = "test@example.com"; - - when(memberRepository.existsByLoginId(loginId)).thenReturn(true); - - // act & assert - assertThatThrownBy(() -> memberService.register(loginId, password, name, birthDate, email)) - .isInstanceOf(CoreException.class); - } - - @DisplayName("비밀번호가 8자 미만이면 예외가 발생한다") - @Test - void register_withShortPassword_throwsException() { - // arrange - String loginId = "testuser1"; - String password = "Pass1!"; // 7자 - String name = "홍길동"; - LocalDate birthDate = LocalDate.of(1990, 1, 15); - String email = "test@example.com"; - - when(memberRepository.existsByLoginId(loginId)).thenReturn(false); - - // act & assert - assertThatThrownBy(() -> memberService.register(loginId, password, name, birthDate, email)) - .isInstanceOf(CoreException.class); - } - - @DisplayName("비밀번호에 생년월일(yyyyMMdd)이 포함되면 예외가 발생한다") - @Test - void register_withBirthDateInPassword_throwsException() { - // arrange - String loginId = "testuser1"; - String password = "Pass19900115!"; // 생년월일 포함 - String name = "홍길동"; - LocalDate birthDate = LocalDate.of(1990, 1, 15); - String email = "test@example.com"; - - when(memberRepository.existsByLoginId(loginId)).thenReturn(false); - - // act & assert - assertThatThrownBy(() -> memberService.register(loginId, password, name, birthDate, email)) - .isInstanceOf(CoreException.class); - } - - @DisplayName("비밀번호 변경 시 현재 비밀번호가 일치하지 않으면 예외가 발생한다") - @Test - void changePassword_withWrongCurrentPassword_throwsException() { - // arrange - MemberModel member = new MemberModel( - "testuser1", - "encodedPassword", - "홍길동", - LocalDate.of(1990, 1, 15), - "test@example.com" - ); - - when(passwordEncoder.matches("wrongPassword", "encodedPassword")).thenReturn(false); - - // act & assert - assertThatThrownBy(() -> memberService.changePassword(member, "wrongPassword", "NewPassword1!")) - .isInstanceOf(CoreException.class); - } - - @DisplayName("비밀번호 변경 시 새 비밀번호가 현재 비밀번호와 동일하면 예외가 발생한다") - @Test - void changePassword_withSamePassword_throwsException() { - // arrange - MemberModel member = new MemberModel( - "testuser1", - "encodedPassword", - "홍길동", - LocalDate.of(1990, 1, 15), - "test@example.com" - ); - - when(passwordEncoder.matches("Password1!", "encodedPassword")).thenReturn(true); - - // act & assert - assertThatThrownBy(() -> memberService.changePassword(member, "Password1!", "Password1!")) - .isInstanceOf(CoreException.class); - } - - @DisplayName("비밀번호 변경 시 새 비밀번호가 규칙을 위반하면 예외가 발생한다") - @Test - void changePassword_withInvalidNewPassword_throwsException() { - // arrange - MemberModel member = new MemberModel( - "testuser1", - "encodedPassword", - "홍길동", - LocalDate.of(1990, 1, 15), - "test@example.com" - ); - - when(passwordEncoder.matches("Password1!", "encodedPassword")).thenReturn(true); - - // act & assert - 7자 비밀번호 - assertThatThrownBy(() -> memberService.changePassword(member, "Password1!", "Short1!")) - .isInstanceOf(CoreException.class); - } - - @DisplayName("비밀번호 변경 시 새 비밀번호에 생년월일이 포함되면 예외가 발생한다") - @Test - void changePassword_withBirthDateInNewPassword_throwsException() { - // arrange - MemberModel member = new MemberModel( - "testuser1", - "encodedPassword", - "홍길동", - LocalDate.of(1990, 1, 15), - "test@example.com" - ); - - when(passwordEncoder.matches("Password1!", "encodedPassword")).thenReturn(true); - - // act & assert - 생년월일 포함 - assertThatThrownBy(() -> memberService.changePassword(member, "Password1!", "Pass19900115!")) - .isInstanceOf(CoreException.class); - } - - @DisplayName("비밀번호 변경이 정상적으로 완료되면 암호화된 새 비밀번호가 저장된다") - @Test - void changePassword_withValidInput_updatesPassword() { - // arrange - MemberModel member = new MemberModel( - "testuser1", - "encodedOldPassword", - "홍길동", - LocalDate.of(1990, 1, 15), - "test@example.com" - ); - - when(passwordEncoder.matches("OldPassword1!", "encodedOldPassword")).thenReturn(true); - when(passwordEncoder.encode("NewPassword1!")).thenReturn("encodedNewPassword"); - - // act - memberService.changePassword(member, "OldPassword1!", "NewPassword1!"); - - // assert - assertThat(member.getPassword()).isEqualTo("encodedNewPassword"); - } -} 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..14cbe05d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java @@ -0,0 +1,75 @@ +package com.loopers.domain.member; + +import com.loopers.domain.member.vo.BirthDate; +import com.loopers.domain.member.vo.Email; +import com.loopers.domain.member.vo.LoginId; +import com.loopers.domain.member.vo.Password; +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class MemberTest { + + @DisplayName("유효한 정보로 Member를 생성할 수 있다") + @Test + void create_withValidInfo_succeeds() { + Member member = new Member( + new LoginId("user1"), + new Password("encodedPw"), + "홍길동", + BirthDate.from("1990-01-15"), + new Email("test@example.com"), + Gender.MALE + ); + + assertThat(member.getLoginId().value()).isEqualTo("user1"); + assertThat(member.getName()).isEqualTo("홍길동"); + assertThat(member.getGender()).isEqualTo(Gender.MALE); + assertThat(member.getPoint()).isEqualTo(0L); + } + + @DisplayName("이름이 null이면 생성에 실패한다") + @Test + void create_withNullName_throwsException() { + assertThatThrownBy(() -> new Member( + new LoginId("user1"), + new Password("encodedPw"), + null, + BirthDate.from("1990-01-15"), + new Email("test@example.com"), + Gender.MALE + )).isInstanceOf(CoreException.class); + } + + @DisplayName("이름이 빈 문자열이면 생성에 실패한다") + @Test + void create_withBlankName_throwsException() { + assertThatThrownBy(() -> new Member( + new LoginId("user1"), + new Password("encodedPw"), + " ", + BirthDate.from("1990-01-15"), + new Email("test@example.com"), + Gender.MALE + )).isInstanceOf(CoreException.class); + } + + @DisplayName("비밀번호를 변경할 수 있다") + @Test + void changePassword_updatesPassword() { + Member member = new Member( + new LoginId("user1"), + new Password("oldEncodedPw"), + "홍길동", + BirthDate.from("1990-01-15"), + new Email("test@example.com"), + Gender.MALE + ); + + member.changePassword(new Password("newEncodedPw")); + assertThat(member.getPassword().encoded()).isEqualTo("newEncodedPw"); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/policy/PasswordPolicyTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/policy/PasswordPolicyTest.java new file mode 100644 index 00000000..f94b787f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/policy/PasswordPolicyTest.java @@ -0,0 +1,76 @@ +package com.loopers.domain.member.policy; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PasswordPolicyTest { + + @DisplayName("유효한 비밀번호는 검증을 통과한다") + @Test + void validate_withValidPassword_succeeds() { + assertThatNoException().isThrownBy(() -> + PasswordPolicy.validate("Password1!", LocalDate.of(1990, 1, 15))); + } + + @DisplayName("8자 미만 비밀번호는 실패한다") + @Test + void validateFormat_withShortPassword_throwsException() { + assertThatThrownBy(() -> PasswordPolicy.validateFormat("Pass1!")) + .isInstanceOf(CoreException.class); + } + + @DisplayName("16자 초과 비밀번호는 실패한다") + @Test + void validateFormat_withLongPassword_throwsException() { + assertThatThrownBy(() -> PasswordPolicy.validateFormat("A".repeat(17))) + .isInstanceOf(CoreException.class); + } + + @DisplayName("null 비밀번호는 실패한다") + @Test + void validateFormat_withNull_throwsException() { + assertThatThrownBy(() -> PasswordPolicy.validateFormat(null)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("생년월일(yyyyMMdd) 포함 비밀번호는 실패한다") + @Test + void validate_withBirthDateYYYYMMDD_throwsException() { + assertThatThrownBy(() -> + PasswordPolicy.validate("Pass19900115!", LocalDate.of(1990, 1, 15))) + .isInstanceOf(CoreException.class); + } + + @DisplayName("생년월일(yyMMdd) 포함 비밀번호는 실패한다") + @Test + void validate_withBirthDateYYMMDD_throwsException() { + assertThatThrownBy(() -> + PasswordPolicy.validate("Pass900115!!", LocalDate.of(1990, 1, 15))) + .isInstanceOf(CoreException.class); + } + + @DisplayName("금지 문자열이 포함되면 실패한다") + @Test + void validateNotContainsSubstrings_withForbidden_throwsException() { + assertThatThrownBy(() -> + PasswordPolicy.validateNotContainsSubstrings( + "hello_forbidden_world", + List.of("forbidden"), + "금지 문자열 포함")) + .isInstanceOf(CoreException.class); + } + + @DisplayName("extractBirthDateStrings는 yyyyMMdd와 yyMMdd를 반환한다") + @Test + void extractBirthDateStrings_returnsTwoFormats() { + List result = PasswordPolicy.extractBirthDateStrings(LocalDate.of(1990, 1, 15)); + org.assertj.core.api.Assertions.assertThat(result).containsExactly("19900115", "900115"); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/BirthDateTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/BirthDateTest.java new file mode 100644 index 00000000..42428864 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/BirthDateTest.java @@ -0,0 +1,48 @@ +package com.loopers.domain.member.vo; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class BirthDateTest { + + @DisplayName("yyyy-MM-dd 형식의 문자열로 BirthDate를 생성할 수 있다") + @Test + void from_withValidFormat_succeeds() { + BirthDate birthDate = BirthDate.from("1990-01-15"); + assertThat(birthDate.value()).isEqualTo(LocalDate.of(1990, 1, 15)); + } + + @DisplayName("생년월일이 yyyy-MM-dd 형식에 맞지 않으면 User 객체 생성에 실패한다") + @Test + void from_withInvalidFormat_throwsException() { + assertThatThrownBy(() -> BirthDate.from("19900115")) + .isInstanceOf(CoreException.class); + } + + @DisplayName("null이면 생성에 실패한다") + @Test + void from_withNull_throwsException() { + assertThatThrownBy(() -> BirthDate.from(null)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("슬래시 형식이면 생성에 실패한다") + @Test + void from_withSlashFormat_throwsException() { + assertThatThrownBy(() -> BirthDate.from("1990/01/15")) + .isInstanceOf(CoreException.class); + } + + @DisplayName("빈 문자열이면 생성에 실패한다") + @Test + void from_withEmpty_throwsException() { + assertThatThrownBy(() -> BirthDate.from("")) + .isInstanceOf(CoreException.class); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/EmailTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/EmailTest.java new file mode 100644 index 00000000..5976980f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/EmailTest.java @@ -0,0 +1,46 @@ +package com.loopers.domain.member.vo; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class EmailTest { + + @DisplayName("유효한 이메일 형식으로 Email을 생성할 수 있다") + @Test + void create_withValidFormat_succeeds() { + Email email = new Email("test@example.com"); + assertThat(email.value()).isEqualTo("test@example.com"); + } + + @DisplayName("이메일이 xx@yy.zz 형식에 맞지 않으면 User 객체 생성에 실패한다") + @Test + void create_withInvalidFormat_throwsException() { + assertThatThrownBy(() -> new Email("invalid-email")) + .isInstanceOf(CoreException.class); + } + + @DisplayName("@가 없으면 생성에 실패한다") + @Test + void create_withoutAtSign_throwsException() { + assertThatThrownBy(() -> new Email("testexample.com")) + .isInstanceOf(CoreException.class); + } + + @DisplayName("null이면 생성에 실패한다") + @Test + void create_withNull_throwsException() { + assertThatThrownBy(() -> new Email(null)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("도메인 부분이 없으면 생성에 실패한다") + @Test + void create_withoutDomain_throwsException() { + assertThatThrownBy(() -> new Email("test@")) + .isInstanceOf(CoreException.class); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/LoginIdTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/LoginIdTest.java new file mode 100644 index 00000000..382e5bb1 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/LoginIdTest.java @@ -0,0 +1,53 @@ +package com.loopers.domain.member.vo; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class LoginIdTest { + + @DisplayName("유효한 영문 및 숫자 조합으로 LoginId를 생성할 수 있다") + @Test + void create_withValidFormat_succeeds() { + LoginId loginId = new LoginId("user1234"); + assertThat(loginId.value()).isEqualTo("user1234"); + } + + @DisplayName("ID가 영문 및 숫자 10자 이내 형식에 맞지 않으면 User 객체 생성에 실패한다") + @Test + void create_withInvalidFormat_throwsException() { + assertThatThrownBy(() -> new LoginId("한글아이디")) + .isInstanceOf(CoreException.class); + } + + @DisplayName("10자를 초과하면 생성에 실패한다") + @Test + void create_withTooLong_throwsException() { + assertThatThrownBy(() -> new LoginId("abcdefghijk")) + .isInstanceOf(CoreException.class); + } + + @DisplayName("특수문자가 포함되면 생성에 실패한다") + @Test + void create_withSpecialChars_throwsException() { + assertThatThrownBy(() -> new LoginId("user!@#")) + .isInstanceOf(CoreException.class); + } + + @DisplayName("null이면 생성에 실패한다") + @Test + void create_withNull_throwsException() { + assertThatThrownBy(() -> new LoginId(null)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("빈 문자열이면 생성에 실패한다") + @Test + void create_withEmpty_throwsException() { + assertThatThrownBy(() -> new LoginId("")) + .isInstanceOf(CoreException.class); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/PasswordTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/PasswordTest.java new file mode 100644 index 00000000..e47be0ad --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/vo/PasswordTest.java @@ -0,0 +1,59 @@ +package com.loopers.domain.member.vo; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +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; + +class PasswordTest { + + private final PasswordEncoder encoder = new BCryptPasswordEncoder(); + + @DisplayName("유효한 비밀번호로 Password를 생성할 수 있다") + @Test + void create_withValidPassword_succeeds() { + Password password = Password.create("Password1!", LocalDate.of(1990, 1, 15), encoder); + assertThat(password.encoded()).isNotBlank(); + } + + @DisplayName("비밀번호가 형식에 맞지 않으면 생성에 실패한다") + @Test + void create_withInvalidFormat_throwsException() { + assertThatThrownBy(() -> Password.create("short", LocalDate.of(1990, 1, 15), encoder)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("비밀번호에 생년월일이 포함되면 생성에 실패한다") + @Test + void create_withBirthDate_throwsException() { + assertThatThrownBy(() -> Password.create("Pass19900115!", LocalDate.of(1990, 1, 15), encoder)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("matches로 평문 비밀번호를 검증할 수 있다") + @Test + void matches_withCorrectPassword_returnsTrue() { + Password password = Password.create("Password1!", LocalDate.of(1990, 1, 15), encoder); + assertThat(password.matches("Password1!", encoder)).isTrue(); + } + + @DisplayName("matches로 틀린 비밀번호를 거부할 수 있다") + @Test + void matches_withWrongPassword_returnsFalse() { + Password password = Password.create("Password1!", LocalDate.of(1990, 1, 15), encoder); + assertThat(password.matches("WrongPass1!", encoder)).isFalse(); + } + + @DisplayName("encoded가 null이면 생성에 실패한다") + @Test + void constructor_withNull_throwsException() { + assertThatThrownBy(() -> new Password(null)) + .isInstanceOf(CoreException.class); + } +} 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..95634e12 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ApiE2ETest.java @@ -0,0 +1,162 @@ +package com.loopers.interfaces.api.member; + +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.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class MemberV1ApiE2ETest { + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private ResponseEntity> signUp(Map body) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity> request = new HttpEntity<>(body, headers); + return testRestTemplate.exchange( + "/api/v1/members", + HttpMethod.POST, + request, + new ParameterizedTypeReference<>() {} + ); + } + + private Map validSignUpBody() { + return Map.of( + "loginId", "user1", + "password", "Password1!", + "name", "홍길동", + "birthDate", "1990-01-15", + "email", "test@example.com", + "gender", "MALE" + ); + } + + @DisplayName("POST /api/v1/members (회원 가입)") + @Nested + class SignUp { + + @DisplayName("회원 가입이 성공할 경우, 생성된 유저 정보를 응답으로 반환한다") + @Test + void signUp_withValidRequest_returnsCreatedWithUserInfo() { + // act + ResponseEntity> response = signUp(validSignUpBody()); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data().loginId()).isEqualTo("user1"), + () -> assertThat(response.getBody().data().name()).isEqualTo("홍길동"), + () -> assertThat(response.getBody().data().email()).isEqualTo("test@example.com"), + () -> assertThat(response.getBody().data().gender()).isNotNull() + ); + } + + @DisplayName("회원 가입 시에 성별이 없을 경우, 400 Bad Request") + @Test + void signUp_withoutGender_returnsBadRequest() { + // arrange + Map body = Map.of( + "loginId", "user1", + "password", "Password1!", + "name", "홍길동", + "birthDate", "1990-01-15", + "email", "test@example.com" + ); + + // act + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity> request = new HttpEntity<>(body, headers); + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/members", + HttpMethod.POST, + request, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @DisplayName("GET /api/v1/members/me (내 정보 조회)") + @Nested + class GetMyInfo { + + @DisplayName("내 정보 조회에 성공할 경우, 해당하는 유저 정보를 응답으로 반환한다") + @Test + void getMyInfo_withValidAuth_returnsUserInfo() { + // arrange - 회원가입 + signUp(validSignUpBody()); + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "user1"); + headers.set("X-Loopers-LoginPw", "Password1!"); + + // act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/members/me", + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data().loginId()).isEqualTo("user1"), + () -> assertThat(response.getBody().data().email()).isEqualTo("test@example.com") + ); + } + + @DisplayName("존재하지 않는 ID로 조회할 경우, 404 Not Found") + @Test + void getMyInfo_withNonExistentId_returnsNotFound() { + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "nobody"); + headers.set("X-Loopers-LoginPw", "Password1!"); + + // act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/members/me", + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + // assert - AuthMemberResolver에서 UNAUTHORIZED 반환 (보안 정책: 아이디/비밀번호 불일치 모호 처리) + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ControllerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ControllerTest.java deleted file mode 100644 index 0fa72925..00000000 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ControllerTest.java +++ /dev/null @@ -1,185 +0,0 @@ -package com.loopers.interfaces.api.member; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.loopers.domain.member.MemberModel; -import com.loopers.domain.member.MemberRepository; -import com.loopers.domain.member.MemberService; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.http.MediaType; -import org.springframework.test.web.servlet.MockMvc; - -import java.time.LocalDate; - -import java.util.Optional; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.when; -import static org.mockito.Mockito.doNothing; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -@WebMvcTest(MemberV1Controller.class) -class MemberV1ControllerTest { - - @Autowired - private MockMvc mockMvc; - - @Autowired - private ObjectMapper objectMapper; - - @MockBean - private MemberService memberService; - - @MockBean - private MemberRepository memberRepository; - - @MockBean - private PasswordEncoder passwordEncoder; - - @DisplayName("유효한 요청으로 회원가입하면 201 Created 응답을 받는다") - @Test - void signUp_withValidRequest_returnsCreated() throws Exception { - // arrange - MemberV1Dto.SignUpRequest request = new MemberV1Dto.SignUpRequest( - "testuser1", - "Password1!", - "홍길동", - LocalDate.of(1990, 1, 15), - "test@example.com" - ); - - MemberModel savedMember = new MemberModel( - "testuser1", - "encodedPassword", - "홍길동", - LocalDate.of(1990, 1, 15), - "test@example.com" - ); - - when(memberService.register(anyString(), anyString(), anyString(), any(LocalDate.class), anyString())) - .thenReturn(savedMember); - - // act & assert - mockMvc.perform(post("/api/v1/members") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isCreated()) - .andExpect(jsonPath("$.data.loginId").value("testuser1")) - .andExpect(jsonPath("$.data.name").value("홍길동")) - .andExpect(jsonPath("$.data.email").value("test@example.com")); - } - - @DisplayName("로그인 ID에 영문/숫자 외 문자가 포함되면 400 Bad Request 응답을 받는다") - @Test - void signUp_withInvalidLoginIdFormat_returnsBadRequest() throws Exception { - // arrange - MemberV1Dto.SignUpRequest request = new MemberV1Dto.SignUpRequest( - "테스트유저", // 한글 포함 - "Password1!", - "홍길동", - LocalDate.of(1990, 1, 15), - "test@example.com" - ); - - // act & assert - mockMvc.perform(post("/api/v1/members") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isBadRequest()); - } - - @DisplayName("인증 헤더 없이 내 정보 조회하면 401 Unauthorized 응답을 받는다") - @Test - void getMyInfo_withoutAuthHeaders_returnsUnauthorized() throws Exception { - // act & assert - mockMvc.perform(get("/api/v1/members/me")) - .andExpect(status().isUnauthorized()); - } - - @DisplayName("잘못된 비밀번호로 내 정보 조회하면 401 Unauthorized 응답을 받는다") - @Test - void getMyInfo_withWrongPassword_returnsUnauthorized() throws Exception { - // arrange - MemberModel member = new MemberModel( - "testuser1", - "encodedPassword", - "홍길동", - LocalDate.of(1990, 1, 15), - "test@example.com" - ); - - when(memberRepository.findByLoginId("testuser1")).thenReturn(Optional.of(member)); - when(passwordEncoder.matches("wrongPassword", "encodedPassword")).thenReturn(false); - - // act & assert - mockMvc.perform(get("/api/v1/members/me") - .header("X-Loopers-LoginId", "testuser1") - .header("X-Loopers-LoginPw", "wrongPassword")) - .andExpect(status().isUnauthorized()); - } - - @DisplayName("올바른 인증 헤더로 내 정보 조회하면 200 OK와 마스킹된 이름을 받는다") - @Test - void getMyInfo_withValidAuth_returnsOkWithMaskedName() throws Exception { - // arrange - MemberModel member = new MemberModel( - "testuser1", - "encodedPassword", - "홍길동", - LocalDate.of(1990, 1, 15), - "test@example.com" - ); - - when(memberRepository.findByLoginId("testuser1")).thenReturn(Optional.of(member)); - when(passwordEncoder.matches("Password1!", "encodedPassword")).thenReturn(true); - - // act & assert - mockMvc.perform(get("/api/v1/members/me") - .header("X-Loopers-LoginId", "testuser1") - .header("X-Loopers-LoginPw", "Password1!")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.loginId").value("testuser1")) - .andExpect(jsonPath("$.data.name").value("홍길*")) - .andExpect(jsonPath("$.data.email").value("test@example.com")); - } - - @DisplayName("올바른 인증 헤더로 비밀번호 변경하면 200 OK 응답을 받는다") - @Test - void changePassword_withValidAuth_returnsOk() throws Exception { - // arrange - MemberModel member = new MemberModel( - "testuser1", - "encodedPassword", - "홍길동", - LocalDate.of(1990, 1, 15), - "test@example.com" - ); - - MemberV1Dto.ChangePasswordRequest request = new MemberV1Dto.ChangePasswordRequest( - "OldPassword1!", - "NewPassword1!" - ); - - when(memberRepository.findByLoginId("testuser1")).thenReturn(Optional.of(member)); - when(passwordEncoder.matches("OldPassword1!", "encodedPassword")).thenReturn(true); - doNothing().when(memberService).changePassword(any(MemberModel.class), anyString(), anyString()); - - // act & assert - mockMvc.perform(patch("/api/v1/members/me/password") - .header("X-Loopers-LoginId", "testuser1") - .header("X-Loopers-LoginPw", "OldPassword1!") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()); - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1DtoTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1DtoTest.java deleted file mode 100644 index c0dbd1f6..00000000 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1DtoTest.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.loopers.interfaces.api.member; - -import com.loopers.domain.member.MemberModel; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import java.time.LocalDate; - -import static org.assertj.core.api.Assertions.assertThat; - -class MemberV1DtoTest { - - @DisplayName("MyInfoResponse 생성 시 이름 마지막 글자가 *로 마스킹된다") - @Test - void myInfoResponse_masksLastCharacterOfName() { - // arrange - MemberModel member = new MemberModel( - "testuser1", - "encodedPassword", - "홍길동", - LocalDate.of(1990, 1, 15), - "test@example.com" - ); - - // act - MemberV1Dto.MyInfoResponse response = MemberV1Dto.MyInfoResponse.from(member); - - // assert - assertThat(response.name()).isEqualTo("홍길*"); - } - - @DisplayName("이름이 1글자면 마스킹하지 않는다") - @Test - void myInfoResponse_doesNotMaskSingleCharacterName() { - // arrange - MemberModel member = new MemberModel( - "testuser1", - "encodedPassword", - "홍", - LocalDate.of(1990, 1, 15), - "test@example.com" - ); - - // act - MemberV1Dto.MyInfoResponse response = MemberV1Dto.MyInfoResponse.from(member); - - // assert - assertThat(response.name()).isEqualTo("홍"); - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/point/PointV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/point/PointV1ApiE2ETest.java new file mode 100644 index 00000000..e451b6b4 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/point/PointV1ApiE2ETest.java @@ -0,0 +1,102 @@ +package com.loopers.interfaces.api.point; + +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.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class PointV1ApiE2ETest { + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private void registerMember() { + Map body = Map.of( + "loginId", "user1", + "password", "Password1!", + "name", "홍길동", + "birthDate", "1990-01-15", + "email", "test@example.com", + "gender", "MALE" + ); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + testRestTemplate.exchange( + "/api/v1/members", + HttpMethod.POST, + new HttpEntity<>(body, headers), + new ParameterizedTypeReference>() {} + ); + } + + @DisplayName("GET /api/v1/points (포인트 조회)") + @Nested + class GetPoint { + + @DisplayName("포인트 조회에 성공할 경우, 보유 포인트를 응답으로 반환한다") + @Test + void getPoint_withValidUserId_returnsPoint() { + // arrange + registerMember(); + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-USER-ID", "user1"); + + // act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/points", + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data().point()).isEqualTo(0L) + ); + } + + @DisplayName("X-USER-ID 헤더가 없을 경우, 400 Bad Request") + @Test + void getPoint_withoutUserIdHeader_returnsBadRequest() { + // act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/points", + HttpMethod.GET, + new HttpEntity<>(null), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } +} From 24c7c1788b6026197ccf29003e0c5a71af1ed5f4 Mon Sep 17 00:00:00 2001 From: SukheeChoi <95064440+SukheeChoi@users.noreply.github.com> Date: Fri, 6 Feb 2026 00:15:50 +0900 Subject: [PATCH 5/7] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20Gender,=20=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기능 요구사항에 해당하지 않는 Gender enum, 포인트 조회 API를 제거한다. Member 엔티티에서 gender/point 필드를 제거하고 관련 테스트를 정리한다. Co-Authored-By: Claude Opus 4.5 --- .../com/loopers/domain/member/Gender.java | 5 - .../com/loopers/domain/member/Member.java | 15 +-- .../loopers/domain/member/MemberService.java | 9 +- .../api/member/MemberV1Controller.java | 6 +- .../interfaces/api/member/MemberV1Dto.java | 8 +- .../api/point/PointV1Controller.java | 33 ------ .../interfaces/api/point/PointV1Dto.java | 5 - .../member/MemberServiceIntegrationTest.java | 38 +------ .../com/loopers/domain/member/MemberTest.java | 14 +-- .../api/member/MemberV1ApiE2ETest.java | 41 ++----- .../api/point/PointV1ApiE2ETest.java | 102 ------------------ 11 files changed, 21 insertions(+), 255 deletions(-) delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/member/Gender.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/point/PointV1ApiE2ETest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/Gender.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/Gender.java deleted file mode 100644 index 114e74cc..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/Gender.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.loopers.domain.member; - -public enum Gender { - MALE, FEMALE -} 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 40d57263..1cad2769 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 @@ -10,8 +10,6 @@ import jakarta.persistence.Column; import jakarta.persistence.Embedded; import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; import jakarta.persistence.Table; @Entity @@ -33,17 +31,10 @@ public class Member extends BaseEntity { @Embedded private Email email; - @Enumerated(EnumType.STRING) - @Column(nullable = false, length = 10) - private Gender gender; - - @Column(nullable = false) - private long point; - protected Member() {} public Member(LoginId loginId, Password password, String name, - BirthDate birthDate, Email email, Gender gender) { + BirthDate birthDate, Email email) { if (name == null || name.isBlank()) { throw new CoreException(ErrorType.BAD_REQUEST, "이름은 필수입니다."); } @@ -52,8 +43,6 @@ public Member(LoginId loginId, Password password, String name, this.name = name; this.birthDate = birthDate; this.email = email; - this.gender = gender; - this.point = 0L; } public LoginId getLoginId() { return loginId; } @@ -61,8 +50,6 @@ public Member(LoginId loginId, Password password, String name, public String getName() { return name; } public BirthDate getBirthDate() { return birthDate; } public Email getEmail() { return email; } - public Gender getGender() { return gender; } - public long getPoint() { return point; } public void changePassword(Password newPassword) { this.password = newPassword; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java index 005a30d2..42dc040a 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 @@ -20,7 +20,7 @@ public class MemberService { private final PasswordEncoder passwordEncoder; public Member register(String loginId, String plainPassword, String name, - String birthDate, String email, Gender gender) { + String birthDate, String email) { LoginId loginIdVo = new LoginId(loginId); if (memberRepository.existsByLoginId(loginIdVo)) { @@ -31,7 +31,7 @@ public Member register(String loginId, String plainPassword, String name, Password password = Password.create(plainPassword, birthDateVo.value(), passwordEncoder); Email emailVo = new Email(email); - Member member = new Member(loginIdVo, password, name, birthDateVo, emailVo, gender); + Member member = new Member(loginIdVo, password, name, birthDateVo, emailVo); return memberRepository.save(member); } @@ -39,11 +39,6 @@ public Optional findByLoginId(String loginId) { return memberRepository.findByLoginId(new LoginId(loginId)); } - public Optional findPointByLoginId(String loginId) { - return memberRepository.findByLoginId(new LoginId(loginId)) - .map(Member::getPoint); - } - public void changePassword(Member member, String currentPlain, String newPlain) { if (!member.getPassword().matches(currentPlain, passwordEncoder)) { throw new CoreException(ErrorType.BAD_REQUEST, "현재 비밀번호가 일치하지 않습니다."); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java index 07c61241..4be5598d 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 @@ -30,16 +30,14 @@ public ApiResponse signUp(@Valid @RequestBody Member request.password(), request.name(), request.birthDate(), - request.email(), - request.gender() + request.email() ); MemberV1Dto.SignUpResponse response = new MemberV1Dto.SignUpResponse( member.getId(), member.getLoginId().value(), member.getName(), - member.getEmail().value(), - member.getGender() + member.getEmail().value() ); return ApiResponse.success(response); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java index a9f9571c..13113ae1 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,9 +1,7 @@ package com.loopers.interfaces.api.member; -import com.loopers.domain.member.Gender; import com.loopers.domain.member.Member; import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; import java.time.LocalDate; @@ -14,16 +12,14 @@ public record SignUpRequest( @NotBlank String password, @NotBlank String name, @NotBlank String birthDate, - @NotBlank String email, - @NotNull Gender gender + @NotBlank String email ) {} public record SignUpResponse( Long id, String loginId, String name, - String email, - Gender gender + String email ) {} public record MyInfoResponse( diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java deleted file mode 100644 index 5355e6e0..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.loopers.interfaces.api.point; - -import com.loopers.domain.member.MemberService; -import com.loopers.interfaces.api.ApiResponse; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestHeader; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RequiredArgsConstructor -@RestController -@RequestMapping("/api/v1/points") -public class PointV1Controller { - - private final MemberService memberService; - - @GetMapping - public ApiResponse getPoint( - @RequestHeader(value = "X-USER-ID", required = false) String userId - ) { - if (userId == null || userId.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "X-USER-ID 헤더가 필요합니다."); - } - - Long point = memberService.findPointByLoginId(userId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "회원을 찾을 수 없습니다.")); - - return ApiResponse.success(new PointV1Dto.PointResponse(point)); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java deleted file mode 100644 index 3252af4d..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.loopers.interfaces.api.point; - -public class PointV1Dto { - public record PointResponse(long point) {} -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java index d11c80c3..5f592560 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java @@ -43,7 +43,7 @@ class Register { void register_savesUser_verifiedBySpy() { // act Member result = memberService.register( - "user1", "Password1!", "홍길동", "1990-01-15", "test@example.com", Gender.MALE); + "user1", "Password1!", "홍길동", "1990-01-15", "test@example.com"); // assert verify(memberRepository).save(any(Member.class)); @@ -55,11 +55,11 @@ void register_savesUser_verifiedBySpy() { void register_withDuplicateId_throwsException() { // arrange memberService.register( - "user1", "Password1!", "홍길동", "1990-01-15", "test@example.com", Gender.MALE); + "user1", "Password1!", "홍길동", "1990-01-15", "test@example.com"); // act & assert assertThatThrownBy(() -> memberService.register( - "user1", "Password2!", "김철수", "1995-05-20", "other@example.com", Gender.MALE)) + "user1", "Password2!", "김철수", "1995-05-20", "other@example.com")) .isInstanceOf(CoreException.class); } } @@ -73,7 +73,7 @@ class FindByLoginId { void findByLoginId_whenExists_returnsMember() { // arrange memberService.register( - "user1", "Password1!", "홍길동", "1990-01-15", "test@example.com", Gender.MALE); + "user1", "Password1!", "홍길동", "1990-01-15", "test@example.com"); // act Optional result = memberService.findByLoginId("user1"); @@ -93,34 +93,4 @@ void findByLoginId_whenNotExists_returnsEmpty() { assertThat(result).isEmpty(); } } - - @DisplayName("포인트 조회") - @Nested - class FindPointByLoginId { - - @DisplayName("해당 ID의 회원이 존재할 경우 보유 포인트가 반환된다") - @Test - void findPointByLoginId_whenExists_returnsPoint() { - // arrange - memberService.register( - "user1", "Password1!", "홍길동", "1990-01-15", "test@example.com", Gender.MALE); - - // act - Optional point = memberService.findPointByLoginId("user1"); - - // assert - assertThat(point).isPresent(); - assertThat(point.get()).isEqualTo(0L); - } - - @DisplayName("해당 ID의 회원이 존재하지 않을 경우 null이 반환된다") - @Test - void findPointByLoginId_whenNotExists_returnsEmpty() { - // act - Optional point = memberService.findPointByLoginId("nobody"); - - // assert - assertThat(point).isEmpty(); - } - } } 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 14cbe05d..c3e94a14 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 @@ -21,14 +21,11 @@ void create_withValidInfo_succeeds() { new Password("encodedPw"), "홍길동", BirthDate.from("1990-01-15"), - new Email("test@example.com"), - Gender.MALE + new Email("test@example.com") ); assertThat(member.getLoginId().value()).isEqualTo("user1"); assertThat(member.getName()).isEqualTo("홍길동"); - assertThat(member.getGender()).isEqualTo(Gender.MALE); - assertThat(member.getPoint()).isEqualTo(0L); } @DisplayName("이름이 null이면 생성에 실패한다") @@ -39,8 +36,7 @@ void create_withNullName_throwsException() { new Password("encodedPw"), null, BirthDate.from("1990-01-15"), - new Email("test@example.com"), - Gender.MALE + new Email("test@example.com") )).isInstanceOf(CoreException.class); } @@ -52,8 +48,7 @@ void create_withBlankName_throwsException() { new Password("encodedPw"), " ", BirthDate.from("1990-01-15"), - new Email("test@example.com"), - Gender.MALE + new Email("test@example.com") )).isInstanceOf(CoreException.class); } @@ -65,8 +60,7 @@ void changePassword_updatesPassword() { new Password("oldEncodedPw"), "홍길동", BirthDate.from("1990-01-15"), - new Email("test@example.com"), - Gender.MALE + new Email("test@example.com") ); member.changePassword(new Password("newEncodedPw")); 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 95634e12..b861baea 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 @@ -54,8 +54,7 @@ private Map validSignUpBody() { "password", "Password1!", "name", "홍길동", "birthDate", "1990-01-15", - "email", "test@example.com", - "gender", "MALE" + "email", "test@example.com" ); } @@ -75,36 +74,8 @@ void signUp_withValidRequest_returnsCreatedWithUserInfo() { () -> assertThat(response.getBody()).isNotNull(), () -> assertThat(response.getBody().data().loginId()).isEqualTo("user1"), () -> assertThat(response.getBody().data().name()).isEqualTo("홍길동"), - () -> assertThat(response.getBody().data().email()).isEqualTo("test@example.com"), - () -> assertThat(response.getBody().data().gender()).isNotNull() - ); - } - - @DisplayName("회원 가입 시에 성별이 없을 경우, 400 Bad Request") - @Test - void signUp_withoutGender_returnsBadRequest() { - // arrange - Map body = Map.of( - "loginId", "user1", - "password", "Password1!", - "name", "홍길동", - "birthDate", "1990-01-15", - "email", "test@example.com" - ); - - // act - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - HttpEntity> request = new HttpEntity<>(body, headers); - ResponseEntity> response = testRestTemplate.exchange( - "/api/v1/members", - HttpMethod.POST, - request, - new ParameterizedTypeReference<>() {} + () -> assertThat(response.getBody().data().email()).isEqualTo("test@example.com") ); - - // assert - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); } } @@ -115,7 +86,7 @@ class GetMyInfo { @DisplayName("내 정보 조회에 성공할 경우, 해당하는 유저 정보를 응답으로 반환한다") @Test void getMyInfo_withValidAuth_returnsUserInfo() { - // arrange - 회원가입 + // arrange signUp(validSignUpBody()); HttpHeaders headers = new HttpHeaders(); @@ -139,9 +110,9 @@ void getMyInfo_withValidAuth_returnsUserInfo() { ); } - @DisplayName("존재하지 않는 ID로 조회할 경우, 404 Not Found") + @DisplayName("존재하지 않는 ID로 조회할 경우, 401 Unauthorized") @Test - void getMyInfo_withNonExistentId_returnsNotFound() { + void getMyInfo_withNonExistentId_returnsUnauthorized() { // arrange HttpHeaders headers = new HttpHeaders(); headers.set("X-Loopers-LoginId", "nobody"); @@ -155,7 +126,7 @@ void getMyInfo_withNonExistentId_returnsNotFound() { new ParameterizedTypeReference<>() {} ); - // assert - AuthMemberResolver에서 UNAUTHORIZED 반환 (보안 정책: 아이디/비밀번호 불일치 모호 처리) + // assert assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/point/PointV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/point/PointV1ApiE2ETest.java deleted file mode 100644 index e451b6b4..00000000 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/point/PointV1ApiE2ETest.java +++ /dev/null @@ -1,102 +0,0 @@ -package com.loopers.interfaces.api.point; - -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.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; - -import java.util.Map; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; - -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -class PointV1ApiE2ETest { - - @Autowired - private TestRestTemplate testRestTemplate; - - @Autowired - private DatabaseCleanUp databaseCleanUp; - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - private void registerMember() { - Map body = Map.of( - "loginId", "user1", - "password", "Password1!", - "name", "홍길동", - "birthDate", "1990-01-15", - "email", "test@example.com", - "gender", "MALE" - ); - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - testRestTemplate.exchange( - "/api/v1/members", - HttpMethod.POST, - new HttpEntity<>(body, headers), - new ParameterizedTypeReference>() {} - ); - } - - @DisplayName("GET /api/v1/points (포인트 조회)") - @Nested - class GetPoint { - - @DisplayName("포인트 조회에 성공할 경우, 보유 포인트를 응답으로 반환한다") - @Test - void getPoint_withValidUserId_returnsPoint() { - // arrange - registerMember(); - - HttpHeaders headers = new HttpHeaders(); - headers.set("X-USER-ID", "user1"); - - // act - ResponseEntity> response = testRestTemplate.exchange( - "/api/v1/points", - HttpMethod.GET, - new HttpEntity<>(headers), - new ParameterizedTypeReference<>() {} - ); - - // assert - assertAll( - () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), - () -> assertThat(response.getBody()).isNotNull(), - () -> assertThat(response.getBody().data().point()).isEqualTo(0L) - ); - } - - @DisplayName("X-USER-ID 헤더가 없을 경우, 400 Bad Request") - @Test - void getPoint_withoutUserIdHeader_returnsBadRequest() { - // act - ResponseEntity> response = testRestTemplate.exchange( - "/api/v1/points", - HttpMethod.GET, - new HttpEntity<>(null), - new ParameterizedTypeReference<>() {} - ); - - // assert - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); - } - } -} From 4e2522fa6f884f856f4cc1da781bf8f4d93a8f7d Mon Sep 17 00:00:00 2001 From: SukheeChoi <95064440+SukheeChoi@users.noreply.github.com> Date: Fri, 6 Feb 2026 00:41:36 +0900 Subject: [PATCH 6/7] =?UTF-8?q?refactor:=20Example=20=ED=8C=A8=ED=82=A4?= =?UTF-8?q?=EC=A7=80=20=EB=B0=8F=20=EA=B4=80=EB=A0=A8=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=20=EC=A0=84=EC=B2=B4=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/example/ExampleFacade.java | 17 --- .../application/example/ExampleInfo.java | 13 -- .../loopers/domain/example/ExampleModel.java | 44 ------- .../domain/example/ExampleRepository.java | 7 -- .../domain/example/ExampleService.java | 20 --- .../example/ExampleJpaRepository.java | 6 - .../example/ExampleRepositoryImpl.java | 19 --- .../api/example/ExampleV1ApiSpec.java | 19 --- .../api/example/ExampleV1Controller.java | 28 ----- .../interfaces/api/example/ExampleV1Dto.java | 15 --- .../domain/example/ExampleModelTest.java | 65 ---------- .../ExampleServiceIntegrationTest.java | 72 ----------- .../interfaces/api/ExampleV1ApiE2ETest.java | 114 ------------------ http/commerce-api/example-v1.http | 2 - 14 files changed, 441 deletions(-) delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java delete mode 100644 http/commerce-api/example-v1.http diff --git a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java deleted file mode 100644 index 552a9ad6..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.loopers.application.example; - -import com.loopers.domain.example.ExampleModel; -import com.loopers.domain.example.ExampleService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -@RequiredArgsConstructor -@Component -public class ExampleFacade { - private final ExampleService exampleService; - - public ExampleInfo getExample(Long id) { - ExampleModel example = exampleService.getExample(id); - return ExampleInfo.from(example); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java deleted file mode 100644 index 877aba96..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.loopers.application.example; - -import com.loopers.domain.example.ExampleModel; - -public record ExampleInfo(Long id, String name, String description) { - public static ExampleInfo from(ExampleModel model) { - return new ExampleInfo( - model.getId(), - model.getName(), - model.getDescription() - ); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java deleted file mode 100644 index c588c4a8..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.domain.BaseEntity; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; - -@Entity -@Table(name = "example") -public class ExampleModel extends BaseEntity { - - private String name; - private String description; - - protected ExampleModel() {} - - public ExampleModel(String name, String description) { - if (name == null || name.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "이름은 비어있을 수 없습니다."); - } - if (description == null || description.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "설명은 비어있을 수 없습니다."); - } - - this.name = name; - this.description = description; - } - - public String getName() { - return name; - } - - public String getDescription() { - return description; - } - - public void update(String newDescription) { - if (newDescription == null || newDescription.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "설명은 비어있을 수 없습니다."); - } - this.description = newDescription; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java deleted file mode 100644 index 3625e566..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.loopers.domain.example; - -import java.util.Optional; - -public interface ExampleRepository { - Optional find(Long id); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java deleted file mode 100644 index c0e8431e..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -@RequiredArgsConstructor -@Component -public class ExampleService { - - private final ExampleRepository exampleRepository; - - @Transactional(readOnly = true) - public ExampleModel getExample(Long id) { - return exampleRepository.find(id) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + id + "] 예시를 찾을 수 없습니다.")); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java deleted file mode 100644 index ce6d3ead..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.loopers.infrastructure.example; - -import com.loopers.domain.example.ExampleModel; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface ExampleJpaRepository extends JpaRepository {} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java deleted file mode 100644 index 37f2272f..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.loopers.infrastructure.example; - -import com.loopers.domain.example.ExampleModel; -import com.loopers.domain.example.ExampleRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.util.Optional; - -@RequiredArgsConstructor -@Component -public class ExampleRepositoryImpl implements ExampleRepository { - private final ExampleJpaRepository exampleJpaRepository; - - @Override - public Optional find(Long id) { - return exampleJpaRepository.findById(id); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java deleted file mode 100644 index 219e3101..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.loopers.interfaces.api.example; - -import com.loopers.interfaces.api.ApiResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.tags.Tag; - -@Tag(name = "Example V1 API", description = "Loopers 예시 API 입니다.") -public interface ExampleV1ApiSpec { - - @Operation( - summary = "예시 조회", - description = "ID로 예시를 조회합니다." - ) - ApiResponse getExample( - @Schema(name = "예시 ID", description = "조회할 예시의 ID") - Long exampleId - ); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java deleted file mode 100644 index 91737601..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.loopers.interfaces.api.example; - -import com.loopers.application.example.ExampleFacade; -import com.loopers.application.example.ExampleInfo; -import com.loopers.interfaces.api.ApiResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RequiredArgsConstructor -@RestController -@RequestMapping("/api/v1/examples") -public class ExampleV1Controller implements ExampleV1ApiSpec { - - private final ExampleFacade exampleFacade; - - @GetMapping("/{exampleId}") - @Override - public ApiResponse getExample( - @PathVariable(value = "exampleId") Long exampleId - ) { - ExampleInfo info = exampleFacade.getExample(exampleId); - ExampleV1Dto.ExampleResponse response = ExampleV1Dto.ExampleResponse.from(info); - return ApiResponse.success(response); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java deleted file mode 100644 index 4ecf0eea..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.loopers.interfaces.api.example; - -import com.loopers.application.example.ExampleInfo; - -public class ExampleV1Dto { - public record ExampleResponse(Long id, String name, String description) { - public static ExampleResponse from(ExampleInfo info) { - return new ExampleResponse( - info.id(), - info.name(), - info.description() - ); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java deleted file mode 100644 index 44ca7576..00000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertThrows; - -class ExampleModelTest { - @DisplayName("예시 모델을 생성할 때, ") - @Nested - class Create { - @DisplayName("제목과 설명이 모두 주어지면, 정상적으로 생성된다.") - @Test - void createsExampleModel_whenNameAndDescriptionAreProvided() { - // arrange - String name = "제목"; - String description = "설명"; - - // act - ExampleModel exampleModel = new ExampleModel(name, description); - - // assert - assertAll( - () -> assertThat(exampleModel.getId()).isNotNull(), - () -> assertThat(exampleModel.getName()).isEqualTo(name), - () -> assertThat(exampleModel.getDescription()).isEqualTo(description) - ); - } - - @DisplayName("제목이 빈칸으로만 이루어져 있으면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequestException_whenTitleIsBlank() { - // arrange - String name = " "; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new ExampleModel(name, "설명"); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @DisplayName("설명이 비어있으면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequestException_whenDescriptionIsEmpty() { - // arrange - String description = ""; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new ExampleModel("제목", description); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java deleted file mode 100644 index bbd5fdbe..00000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.infrastructure.example.ExampleJpaRepository; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertThrows; - -@SpringBootTest -class ExampleServiceIntegrationTest { - @Autowired - private ExampleService exampleService; - - @Autowired - private ExampleJpaRepository exampleJpaRepository; - - @Autowired - private DatabaseCleanUp databaseCleanUp; - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @DisplayName("예시를 조회할 때,") - @Nested - class Get { - @DisplayName("존재하는 예시 ID를 주면, 해당 예시 정보를 반환한다.") - @Test - void returnsExampleInfo_whenValidIdIsProvided() { - // arrange - ExampleModel exampleModel = exampleJpaRepository.save( - new ExampleModel("예시 제목", "예시 설명") - ); - - // act - ExampleModel result = exampleService.getExample(exampleModel.getId()); - - // assert - assertAll( - () -> assertThat(result).isNotNull(), - () -> assertThat(result.getId()).isEqualTo(exampleModel.getId()), - () -> assertThat(result.getName()).isEqualTo(exampleModel.getName()), - () -> assertThat(result.getDescription()).isEqualTo(exampleModel.getDescription()) - ); - } - - @DisplayName("존재하지 않는 예시 ID를 주면, NOT_FOUND 예외가 발생한다.") - @Test - void throwsException_whenInvalidIdIsProvided() { - // arrange - Long invalidId = 999L; // Assuming this ID does not exist - - // act - CoreException exception = assertThrows(CoreException.class, () -> { - exampleService.getExample(invalidId); - }); - - // assert - assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java deleted file mode 100644 index 1bb3dba6..00000000 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java +++ /dev/null @@ -1,114 +0,0 @@ -package com.loopers.interfaces.api; - -import com.loopers.domain.example.ExampleModel; -import com.loopers.infrastructure.example.ExampleJpaRepository; -import com.loopers.interfaces.api.example.ExampleV1Dto; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.web.client.TestRestTemplate; -import org.springframework.core.ParameterizedTypeReference; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpMethod; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; - -import java.util.function.Function; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertTrue; - -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -class ExampleV1ApiE2ETest { - - private static final Function ENDPOINT_GET = id -> "/api/v1/examples/" + id; - - private final TestRestTemplate testRestTemplate; - private final ExampleJpaRepository exampleJpaRepository; - private final DatabaseCleanUp databaseCleanUp; - - @Autowired - public ExampleV1ApiE2ETest( - TestRestTemplate testRestTemplate, - ExampleJpaRepository exampleJpaRepository, - DatabaseCleanUp databaseCleanUp - ) { - this.testRestTemplate = testRestTemplate; - this.exampleJpaRepository = exampleJpaRepository; - this.databaseCleanUp = databaseCleanUp; - } - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @DisplayName("GET /api/v1/examples/{id}") - @Nested - class Get { - @DisplayName("존재하는 예시 ID를 주면, 해당 예시 정보를 반환한다.") - @Test - void returnsExampleInfo_whenValidIdIsProvided() { - // arrange - ExampleModel exampleModel = exampleJpaRepository.save( - new ExampleModel("예시 제목", "예시 설명") - ); - String requestUrl = ENDPOINT_GET.apply(exampleModel.getId()); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is2xxSuccessful()), - () -> assertThat(response.getBody().data().id()).isEqualTo(exampleModel.getId()), - () -> assertThat(response.getBody().data().name()).isEqualTo(exampleModel.getName()), - () -> assertThat(response.getBody().data().description()).isEqualTo(exampleModel.getDescription()) - ); - } - - @DisplayName("숫자가 아닌 ID 로 요청하면, 400 BAD_REQUEST 응답을 받는다.") - @Test - void throwsBadRequest_whenIdIsNotProvided() { - // arrange - String requestUrl = "/api/v1/examples/나나"; - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is4xxClientError()), - () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST) - ); - } - - @DisplayName("존재하지 않는 예시 ID를 주면, 404 NOT_FOUND 응답을 받는다.") - @Test - void throwsException_whenInvalidIdIsProvided() { - // arrange - Long invalidId = -1L; - String requestUrl = ENDPOINT_GET.apply(invalidId); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is4xxClientError()), - () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) - ); - } - } -} diff --git a/http/commerce-api/example-v1.http b/http/commerce-api/example-v1.http deleted file mode 100644 index 2a924d26..00000000 --- a/http/commerce-api/example-v1.http +++ /dev/null @@ -1,2 +0,0 @@ -### 예시 조회 -GET {{commerce-api}}/api/v1/examples/1 \ No newline at end of file From a0bba7945658b63285629c021d081f9a40d12f7b Mon Sep 17 00:00:00 2001 From: SukheeChoi <95064440+SukheeChoi@users.noreply.github.com> Date: Fri, 6 Feb 2026 01:43:43 +0900 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20MemberService=EC=97=90=20@Transactio?= =?UTF-8?q?nal=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/loopers/domain/member/MemberService.java | 4 ++++ 1 file changed, 4 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 42dc040a..43e8a899 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 @@ -9,16 +9,19 @@ import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.Optional; @RequiredArgsConstructor @Service +@Transactional(readOnly = true) public class MemberService { private final MemberRepository memberRepository; private final PasswordEncoder passwordEncoder; + @Transactional public Member register(String loginId, String plainPassword, String name, String birthDate, String email) { LoginId loginIdVo = new LoginId(loginId); @@ -39,6 +42,7 @@ public Optional findByLoginId(String loginId) { return memberRepository.findByLoginId(new LoginId(loginId)); } + @Transactional public void changePassword(Member member, String currentPlain, String newPlain) { if (!member.getPassword().matches(currentPlain, passwordEncoder)) { throw new CoreException(ErrorType.BAD_REQUEST, "현재 비밀번호가 일치하지 않습니다.");