diff --git a/.claude/README.md b/.claude/README.md new file mode 100644 index 00000000..ce3ada79 --- /dev/null +++ b/.claude/README.md @@ -0,0 +1,45 @@ +# Claude AI 협업 가이드 + +이 디렉토리는 Claude Code와의 협업을 위한 규칙과 컨텍스트를 포함합니다. + +## 핵심 원칙 + +### Claude 역할 제한 +- **허용**: 제안, 대안 제시, 승인된 범위 내 구현 +- **금지**: 임의 설계 결정, 요구사항 확장, 테스트 삭제/약화 + +### TDD Workflow (필수) +1. 🔴 **Red**: 실패하는 테스트 먼저 작성 (로직 구현 금지) +2. 🟢 **Green**: 테스트 통과하는 최소 코드만 작성 +3. 🔵 **Refactor**: 동작 변경 없이 품질 개선 (새 기능 추가 금지) + +## 참고 문서 + +| 문서 | 내용 | +|------|------| +| **CLAUDE.md** | 전체 개발 가이드 (기술 스택, 아키텍처, 컨벤션, 테스트 전략) | +| **.codeguide/loopers-1-week.md** | Week 1 구현 요구사항 | + +## Week 1 범위 (Member 도메인) + +- 회원가입 (`POST /api/v1/members/register`) +- 내 정보 조회 (`GET /api/v1/members/me`) +- 비밀번호 수정 (`PATCH /api/v1/members/me/password`) + +## Never Do + +- ❌ 테스트 삭제, @Disabled, assertion 약화 +- ❌ 승인 없이 요구사항 확장/범위 초과 +- ❌ Mock 데이터로만 동작하는 구현 +- ❌ System.out.println 코드 + +## Priority + +1. 실제 동작하는 코드 +2. null-safety, thread-safety +3. 테스트 가능한 구조 +4. 기존 패턴과 일관성 유지 + +--- + +**Last Updated**: 2026-02-04 \ No newline at end of file diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..01239328 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(./gradlew:*)" + ] + } +} diff --git a/.gitignore b/.gitignore index 5a979af6..8e305926 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ out/ ### Kotlin ### .kotlin +nul diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..38ff5a86 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,691 @@ +# Loopers Commerce Platform - 개발 가이드 + +## 프로젝트 개요 + +Loopers에서 제공하는 Spring Boot 기반의 멀티모듈 커머스 플랫폼입니다. + +### 주요 기술 스택 및 버전 + +#### Core +- **Java**: 21 (LTS) +- **Spring Boot**: 3.4.4 +- **Spring Cloud**: 2024.0.1 +- **Gradle**: 8.x (Kotlin DSL) + +#### Framework & Libraries +- **Spring Data JPA**: 3.4.4 (with QueryDSL) +- **Spring Security**: Crypto 모듈 (BCrypt 암호화) +- **Spring Batch**: 5.x +- **Spring Kafka**: 3.x +- **Redis**: Lettuce 기반 +- **MySQL**: 8.x (Production), TestContainers (Test) + +#### API & Documentation +- **SpringDoc OpenAPI**: 2.7.0 (Swagger UI) +- **Jakarta Validation**: Bean Validation 3.0 + +#### Testing +- **JUnit 5**: Jupiter +- **AssertJ**: Fluent Assertions +- **Mockito**: 5.14.0 +- **SpringMockK**: 4.0.2 (Kotlin Mock 지원) +- **Instancio**: 5.0.2 (Test Fixture 생성) +- **TestContainers**: MySQL, Redis + +#### Monitoring & Logging +- **Spring Actuator**: Health Check, Metrics +- **Prometheus**: Metrics 수집 +- **Grafana**: 시각화 대시보드 +- **Logback**: 구조화된 로깅 (JSON/Plain) +- **Slack Appender**: 1.6.1 (알림) + +#### Build & Code Quality +- **Jacoco**: 코드 커버리지 +- **Lombok**: 보일러플레이트 제거 + +--- + +## 모듈 구조 + +### 전체 구조 +``` +Root +├── apps (실행 가능한 Spring Boot 애플리케이션) +│ ├── commerce-api # REST API 서버 +│ ├── commerce-batch # 배치 작업 +│ └── commerce-streamer # Kafka 스트리밍 +├── modules (재사용 가능한 인프라 설정) +│ ├── jpa # JPA, QueryDSL, DataSource 설정 +│ ├── redis # Redis Cluster 설정 +│ └── kafka # Kafka Producer/Consumer 설정 +└── supports (부가 기능 모듈) + ├── jackson # JSON 직렬화 설정 + ├── logging # Logback 설정 (JSON/Plain/Slack) + └── monitoring # Actuator, Prometheus 설정 +``` + +### 모듈 원칙 +- **apps**: 각 모듈은 독립적으로 실행 가능한 SpringBootApplication +- **modules**: 도메인에 의존하지 않는 재사용 가능한 인프라 설정 +- **supports**: 로깅, 모니터링 등 부가 기능 제공 + +### 의존성 규칙 +- apps → modules, supports (의존 가능) +- modules ↔ modules (상호 의존 금지) +- supports ↔ supports (상호 의존 금지) +- modules, supports → apps (의존 불가) + +--- + +## 아키텍처 및 레이어 구조 + +### 패키지 구조 (commerce-api 기준) +``` +com.loopers +├── domain # 도메인 레이어 +│ └── {domain-name} +│ ├── {Domain}Model.java # JPA Entity (도메인 모델) +│ ├── {Domain}Service.java # 도메인 비즈니스 로직 +│ ├── {Domain}Repository.java # Repository 인터페이스 +│ └── {ValueObject}.java # Value Object (record) +├── application # 애플리케이션 레이어 +│ └── {domain-name} +│ ├── {Domain}Facade.java # 여러 도메인 서비스 조합 +│ └── {Domain}Info.java # 애플리케이션 DTO +├── infrastructure # 인프라 레이어 +│ ├── {domain-name} +│ │ ├── {Domain}JpaRepository.java # Spring Data JPA +│ │ └── {Domain}RepositoryImpl.java # Repository 구현체 +│ ├── jpa/converter +│ │ └── {ValueObject}Converter.java # JPA AttributeConverter +│ ├── security +│ │ └── BCryptPasswordHasher.java # 암호화 구현체 +│ └── config +│ └── SecurityConfig.java # 설정 +├── interfaces # 인터페이스 레이어 +│ └── api +│ ├── {domain-name} +│ │ ├── {Domain}V1Controller.java # REST Controller +│ │ ├── {Domain}V1ApiSpec.java # OpenAPI 명세 (interface) +│ │ └── {Domain}V1Dto.java # API DTO (record) +│ ├── ApiResponse.java # 공통 응답 래퍼 +│ └── ApiControllerAdvice.java # 전역 예외 처리 +└── support # 공통 지원 + └── error + ├── CoreException.java # 도메인 예외 + └── ErrorType.java # 에러 타입 enum +``` + +### 레이어별 역할 + +#### 1. Domain Layer (도메인 레이어) +- **책임**: 핵심 비즈니스 로직과 규칙 +- **구성요소**: + - `{Domain}Model`: JPA Entity, BaseEntity 상속, 도메인 객체 + - `{Domain}Service`: 도메인 비즈니스 로직, 트랜잭션 관리 + - `{Domain}Repository`: 인터페이스 (구현체는 Infrastructure) + - Value Objects: record 타입, 불변 객체, 생성자에서 검증 + +#### 2. Application Layer (애플리케이션 레이어) +- **책임**: 유스케이스 조합, 여러 도메인 서비스 조율 +- **구성요소**: + - `{Domain}Facade`: 여러 도메인 서비스를 조합한 유스케이스 + - `{Domain}Info`: 애플리케이션 레벨 DTO + +#### 3. Infrastructure Layer (인프라 레이어) +- **책임**: 외부 시스템 연동, 기술적 구현 +- **구성요소**: + - `{Domain}RepositoryImpl`: Repository 인터페이스 구현 + - `{Domain}JpaRepository`: Spring Data JPA 인터페이스 + - JPA Converter: Value Object ↔ DB 컬럼 변환 + - 외부 API 클라이언트, 암호화 구현체 등 + +#### 4. Interfaces Layer (인터페이스 레이어) +- **책임**: 외부와의 통신 (REST API, gRPC 등) +- **구성요소**: + - `{Domain}V1Controller`: REST API 엔드포인트 + - `{Domain}V1ApiSpec`: OpenAPI 명세 인터페이스 (Swagger 어노테이션) + - `{Domain}V1Dto`: API 요청/응답 DTO (record) + - `ApiResponse`: 공통 응답 래퍼 (meta + data) + - `ApiControllerAdvice`: 전역 예외 처리 + +--- + +## 코드 컨벤션 + +### 1. 네이밍 규칙 + +#### 클래스/인터페이스 +- **Entity**: `{Domain}Model` (예: `MemberModel`, `OrderModel`) +- **Service**: `{Domain}Service` (예: `MemberService`) +- **Repository Interface**: `{Domain}Repository` (예: `MemberRepository`) +- **Repository Impl**: `{Domain}RepositoryImpl` (예: `MemberRepositoryImpl`) +- **JPA Repository**: `{Domain}JpaRepository` (예: `MemberJpaRepository`) +- **Controller**: `{Domain}V{version}Controller` (예: `MemberV1Controller`) +- **API Spec**: `{Domain}V{version}ApiSpec` (예: `MemberV1ApiSpec`) +- **DTO**: `{Domain}V{version}Dto` (예: `MemberV1Dto`) +- **Value Object**: `{Name}` (예: `MemberId`, `Email`, `BirthDate`) +- **Facade**: `{Domain}Facade` (예: `MemberFacade`) +- **Exception**: `{Name}Exception` (예: `CoreException`) + +#### 메서드 +- **조회**: `get{Entity}By{Condition}` (예: `getMemberByMemberId`) +- **저장**: `save`, `register`, `create` +- **수정**: `update`, `modify` +- **삭제**: `delete`, `remove` +- **존재 확인**: `existsBy{Condition}` (예: `existsByMemberId`) +- **검증**: `validate{Target}` (예: `validatePassword`) + +#### 변수 +- **상수**: `UPPER_SNAKE_CASE` (예: `VALID_MEMBER_ID`, `PASSWORD_PATTERN`) +- **일반 변수**: `camelCase` (예: `memberId`, `rawPassword`) + +### 2. 타입 사용 규칙 + +#### Value Object +- **타입**: `record` 사용 (Java 17+) +- **검증**: Compact Constructor에서 수행 +- **불변성**: 모든 필드 final (record 기본) +- **예시**: +```java +public record MemberId(String value) { + private static final Pattern PATTERN = Pattern.compile("^[A-Za-z0-9]{1,10}$"); + + public MemberId { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "memberId가 비어 있습니다"); + } + value = value.trim(); + if (!PATTERN.matcher(value).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "memberId는 영문+숫자, 1~10자로 이루어져야 합니다"); + } + } +} +``` + +#### DTO (Data Transfer Object) +- **타입**: `record` 사용 +- **검증**: Jakarta Validation 어노테이션 사용 +- **변환**: 정적 팩토리 메서드 `from()` 제공 +- **예시**: +```java +public class MemberV1Dto { + public record RegisterRequest( + @NotBlank String memberId, + @NotBlank String password, + @NotBlank String email, + @NotBlank String birthDate, + @NotBlank String name, + @NotNull Gender gender + ) {} + + public record MemberResponse( + Long id, + String memberId, + String email, + String birthDate, + String name, + Gender gender + ) { + public static MemberResponse from(MemberModel member) { + return new MemberResponse( + member.getId(), + member.getMemberId().value(), + member.getEmail().address(), + member.getBirthDate().asString(), + member.getName().value(), + member.getGender() + ); + } + } +} +``` + +#### Entity +- **타입**: `class` (JPA Entity) +- **상속**: `BaseEntity` 상속 (id, createdAt, updatedAt, deletedAt) +- **생성자**: protected 기본 생성자 + public 생성자 체이닝 +- **필드**: private, @Getter 사용 +- **예시**: +```java +@Entity +@Table(name = "member") +public class MemberModel extends BaseEntity { + @Getter + @Convert(converter = MemberIdConverter.class) + @Column(nullable = false, unique = true, length = 10) + private MemberId memberId; + + protected MemberModel() {} + + public MemberModel(String memberId, String password) { + this.memberId = new MemberId(memberId); + this.password = password; + } +} +``` + +### 3. 예외 처리 + +#### CoreException +- **용도**: 도메인 예외 표현 +- **구조**: `ErrorType` + 커스텀 메시지 +- **예시**: +```java +throw new CoreException(ErrorType.BAD_REQUEST, "이미 가입된 ID 입니다."); +``` + +#### ErrorType +- **타입**: enum +- **필드**: `HttpStatus status`, `String code`, `String message` +- **종류**: `INTERNAL_ERROR`, `BAD_REQUEST`, `NOT_FOUND`, `CONFLICT` + +#### 전역 예외 처리 +- **클래스**: `ApiControllerAdvice` (@RestControllerAdvice) +- **처리 대상**: + - `CoreException`: 도메인 예외 + - `MethodArgumentNotValidException`: Validation 실패 + - `HttpMessageNotReadableException`: JSON 파싱 실패 + - `NoResourceFoundException`: 404 Not Found + - `Throwable`: 예상치 못한 예외 + +### 4. API 응답 구조 + +#### ApiResponse +```java +public record ApiResponse(Metadata meta, T data) { + public record Metadata(Result result, String errorCode, String message) { + public enum Result { SUCCESS, FAIL } + } +} +``` + +#### 성공 응답 +```json +{ + "meta": { + "result": "SUCCESS", + "errorCode": null, + "message": null + }, + "data": { + "id": 1, + "memberId": "testuser1", + "email": "test@example.com" + } +} +``` + +#### 실패 응답 +```json +{ + "meta": { + "result": "FAIL", + "errorCode": "Bad Request", + "message": "이미 가입된 ID 입니다." + }, + "data": null +} +``` + +### 5. JPA 관련 + +#### BaseEntity +- **필드**: `id`, `createdAt`, `updatedAt`, `deletedAt` +- **기능**: + - `@PrePersist`: createdAt, updatedAt 자동 설정, guard() 호출 + - `@PreUpdate`: updatedAt 자동 갱신, guard() 호출 + - `delete()`: Soft Delete (멱등성 보장) + - `restore()`: 삭제 취소 (멱등성 보장) + - `guard()`: 엔티티 검증 (하위 클래스에서 오버라이드) + +#### JPA Converter +- **용도**: Value Object ↔ DB 컬럼 변환 +- **어노테이션**: `@Converter(autoApply = false)` (명시적 적용) +- **null-safety**: null 체크 필수 +- **예시**: +```java +@Converter(autoApply = false) +public class MemberIdConverter implements AttributeConverter { + @Override + public String convertToDatabaseColumn(MemberId attribute) { + return attribute == null ? null : attribute.value(); + } + + @Override + public MemberId convertToEntityAttribute(String dbData) { + return dbData == null ? null : new MemberId(dbData); + } +} +``` + +### 6. 의존성 주입 +- **방식**: 생성자 주입 (Constructor Injection) +- **어노테이션**: `@RequiredArgsConstructor` (Lombok) +- **필드**: `private final` 사용 + +### 7. 트랜잭션 +- **Service 레이어**: `@Transactional` 사용 +- **읽기 전용**: `@Transactional(readOnly = true)` +- **쓰기**: `@Transactional` (기본) + +--- + +## 테스트 전략 + +### 테스트 피라미드 (역할 분리) + +| 레벨 | 대상 | 환경 | 목적 | +|------|------|------|------| +| **Unit** | 도메인 모델, VO | Spring 없이 JVM | 순수 로직/규칙 검증 | +| **Integration** | Service, Facade | @SpringBootTest + TestContainers | 비즈니스 흐름 검증 | +| **E2E** | REST API | TestRestTemplate | HTTP 요청/응답 시나리오 | + +### 테스트 레벨 + +#### 1. 단위 테스트 (Unit Test) +- **대상**: Value Object, 도메인 로직 (Spring 없이 순수 JVM) +- **명명**: `{ClassName}UnitTest` +- **어노테이션**: `@Test`, `@DisplayName`, `@Nested` +- **패턴**: 3A (Arrange - Act - Assert) +- **예시**: +```java +@DisplayName("회원 모델을 생성할 때, ") +@Nested +class Create { + @DisplayName("ID 가 영문 및 숫자 10자 이내 형식에 맞지 않으면, User 객체 생성에 실패한다.") + @Test + void createsMemberModel_whenIdIsInvalid() { + // arrange + String memberId = "invalid_id!"; + + // act + CoreException result = assertThrows(CoreException.class, () -> + new MemberModel(memberId, "password123")); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } +} +``` + +#### 2. 통합 테스트 (Integration Test) +- **대상**: Service, Facade (여러 컴포넌트 연결 상태에서 비즈니스 흐름 검증) +- **명명**: `{ClassName}IntegrationTest` +- **어노테이션**: `@SpringBootTest` +- **인프라**: TestContainers (MySQL, Redis) +- **격리**: `DatabaseCleanUp.truncateAllTables()` (@AfterEach) +- **예시**: +```java +@SpringBootTest +class MemberServiceIntegrationTest { + @Autowired + private MemberService memberService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Test + void register_withValidInfo_createsMember() { + // arrange + String memberId = "testuser1"; + + // act + MemberModel result = memberService.register(...); + + // assert + assertThat(result.getMemberId().value()).isEqualTo(memberId); + } +} +``` + +#### 3. E2E 테스트 (End-to-End Test) +- **대상**: REST API (Controller → Service → DB 전체 흐름) +- **명명**: `{ClassName}E2ETest` +- **어노테이션**: `@SpringBootTest(webEnvironment = RANDOM_PORT)` +- **클라이언트**: `TestRestTemplate` +- **예시**: +```java +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public class MemberV1ApiE2ETest { + @Autowired + private TestRestTemplate testRestTemplate; + + @Test + void register_withValidRequest_returnsCreatedMember() { + // arrange + var request = new MemberV1Dto.RegisterRequest("testuser1", "Pass1234!", ...); + + // act + var response = testRestTemplate.postForEntity("/api/v1/members/register", request, ApiResponse.class); + + // assert + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + } +} +``` + +### 테스트 원칙 +1. **3A 패턴 준수**: Arrange - Act - Assert +2. **@DisplayName 필수**: 한글로 명확한 테스트 의도 표현 +3. **@Nested 활용**: 테스트 그룹화 (예: Create, Get, Update, Delete) +4. **AssertJ 사용**: `assertThat()`, `assertAll()` 활용 +5. **테스트 격리**: 각 테스트는 독립적으로 실행 가능해야 함 +6. **실제 동작 검증**: Mock 최소화, 실제 DB/API 호출 우선 + +--- + +## 개발 규칙 + +### 핵심 원칙: 속도보다 통제 +- AI는 코드의 의도, 변경영향, 책임에 대한 컨텍스트를 지속 유지할 수 없음 +- 개발자가 의도를 정의하고, AI가 승인된 범위 내에서만 구현 + +### Claude 역할 제한 +| 허용 | 금지 | +|------|------| +| 제안, 대안 제시 | 임의 설계 결정 | +| 승인된 범위 내 구현 | 요구사항 확장/범위 초과 | +| 테스트 작성 | 테스트 삭제, @Disabled, assertion 약화 | +| 승인 후 리팩토링 | 동작 변경, 기능 추가 | + +### 진행 Workflow - 증강 코딩 +- **대원칙**: 방향성 및 주요 의사 결정은 개발자에게 제안만 할 수 있으며, 최종 승인된 사항을 기반으로 작업을 수행 +- **중간 결과 보고**: AI가 반복적인 동작을 하거나, 요청하지 않은 기능을 구현, 테스트 삭제를 임의로 진행할 경우 개발자가 개입 +- **설계 주도권 유지**: AI가 임의판단을 하지 않고, 방향성에 대한 제안 등을 진행할 수 있으나 개발자의 승인을 받은 후 수행 + +--- + +### TDD Workflow (Red → Green → Refactor) + +> 테스트는 구현 검증이 아니라 **설계 단위 검증**이다. + +모든 테스트는 3A 원칙으로 작성할 것 (Arrange - Act - Assert) + +#### 🔴 Red Phase: 실패하는 테스트 먼저 작성 +- 요구사항을 테스트 케이스로 정의 +- **반드시 실패 확인** (컴파일 에러가 아닌 Assertion 실패) +- 프로덕션 코드 없으면 최소 껍데기만 생성 +- **이 단계에서 로직 구현 금지** + +#### 🟢 Green Phase: 테스트를 통과하는 최소 코드 작성 +- Red의 테스트가 **딱** 통과하는 코드만 작성 +- **오버엔지니어링 금지**: 미래 요구사항 예측 구현 금지 +- 기존 테스트도 모두 통과해야 함 + +#### 🔵 Refactor Phase: 코드 품질 개선 (동작 변경 없이) +- 중복 제거, 네이밍 개선, unused import 제거 +- **모든 테스트 케이스가 통과해야 함** +- **새 기능 추가 금지** (새 기능은 다시 Red부터) + +--- + +## 주의사항 + +### 1. Never Do (절대 금지) +- ❌ **실제 동작하지 않는 코드 작성 금지** + - Mock 데이터로만 동작하는 구현 금지 + - 실제 DB, API 호출 없이 가짜 응답 반환 금지 +- ❌ **null-safety 위반 금지** + - Java의 경우 `Optional` 활용 필수 + - Value Object는 생성자에서 null 검증 + - JPA Converter에서 null 체크 +- ❌ **println 코드 남기지 말 것** + - 디버깅용 `System.out.println()` 제거 + - 로깅이 필요하면 `@Slf4j` 사용 +- ❌ **테스트 임의 삭제/수정 금지** + - 실패하는 테스트를 삭제하지 말 것 + - `@Disabled`, `@Ignore` 사용 금지 + - 테스트를 통과시키기 위해 assertion 약화 금지 + +### 2. Recommendation (권장사항) +- ✅ **실제 API를 호출해 확인하는 E2E 테스트 코드 작성** + - TestRestTemplate 사용 + - 실제 HTTP 요청/응답 검증 +- ✅ **재사용 가능한 객체 설계** + - Value Object 활용 + - 불변 객체 우선 + - 정적 팩토리 메서드 제공 +- ✅ **성능 최적화에 대한 대안 및 제안** + - N+1 문제 해결 (Fetch Join, Batch Size) + - 인덱스 설계 + - 캐싱 전략 (Redis) +- ✅ **개발 완료된 API의 경우, `.http/**.http`에 분류해 작성** + - IntelliJ HTTP Client 파일 작성 + - 환경별 변수 관리 (`http-client.env.json`) + +### 3. Priority (우선순위) +1. **실제 동작하는 해결책만 고려** + - 이론적 해결책보다 실제 동작하는 코드 우선 +2. **null-safety, thread-safety 고려** + - Optional 활용 + - 불변 객체 사용 + - 동시성 이슈 고려 +3. **테스트 가능한 구조로 설계** + - 의존성 주입 + - 인터페이스 분리 + - Spy 패턴 활용 +4. **기존 코드 패턴 분석 후 일관성 유지** + - 네이밍 규칙 준수 + - 레이어 구조 준수 + - 기존 코드 스타일 따르기 + +--- + +## 도메인 분석 (User) - Week 1 범위 + +### 필드 검증 +- **loginId**: 영문+숫자만 허용 +- **email**: 이메일 형식 검증 +- **birthDate**: yyyy-MM-dd 형식 +- **name**: 1~50자 (조회 시 마지막 글자 `*` 마스킹) + +### 비즈니스 규칙 +- **비밀번호**: 8~16자, 영문 대소문자+숫자+특수문자 모두 포함 +- **비밀번호 제약**: 생년월일 포함 불가 +- **중복 가입 방지**: loginId 중복 체크 +- **암호화**: BCrypt + +### API 엔드포인트 (Week 1) +| API | 설명 | 인증 | +|-----|------|------| +| `POST /api/v1/users/register` | 회원가입 | 없음 | +| `GET /api/v1/users/me` | 내 정보 조회 | `X-Loopers-LoginId`, `X-Loopers-LoginPw` | +| `PATCH /api/v1/users/me/password` | 비밀번호 수정 | `X-Loopers-LoginId`, `X-Loopers-LoginPw` | + +--- + +## 환경 설정 + +### 프로파일 +- **local**: 로컬 개발 환경 +- **test**: 테스트 환경 (TestContainers) +- **dev**: 개발 서버 +- **qa**: QA 서버 +- **prd**: 운영 서버 + +### 인프라 실행 +```bash +# MySQL, Redis, Kafka 실행 +docker-compose -f ./docker/infra-compose.yml up + +# Prometheus, Grafana 실행 +docker-compose -f ./docker/monitoring-compose.yml up +``` + +### Swagger UI +- **URL**: http://localhost:8080/swagger-ui.html +- **활성화**: local, test 프로파일에서만 + +### Grafana +- **URL**: http://localhost:3000 +- **계정**: admin / admin + +--- + +## 참고사항 + +### Lombok 사용 +- `@Getter`: 필드별 적용 (클래스 레벨 지양) +- `@RequiredArgsConstructor`: 생성자 주입 +- `@Slf4j`: 로깅 + +### QueryDSL +- Q-Type 자동 생성 +- `build/generated/sources/annotationProcessor` 경로 + +### TestFixtures +- `modules:jpa`: `DatabaseCleanUp`, `MySqlTestContainersConfig` +- `modules:redis`: `RedisCleanUp`, `RedisTestContainersConfig` + +### Jacoco +- 테스트 커버리지 측정 +- XML 리포트 생성 (CI/CD 연동) + +--- + +## 추가 리소스 + +### 프로젝트 파일 +- `README.md`: 프로젝트 개요 및 시작 가이드 +- `.codeguide/loopers-1-week.md`: 1주차 구현 퀘스트 +- `http/commerce-api/example-v1.http`: API 테스트 예시 + +### 설정 파일 +- `gradle.properties`: 버전 관리 +- `build.gradle.kts`: 빌드 설정 +- `settings.gradle.kts`: 멀티모듈 설정 +- `application.yml`: 애플리케이션 설정 +- `jpa.yml`, `redis.yml`, `kafka.yml`: 모듈별 설정 + +--- + +## 버전 관리 + +### Git 전략 +- 버전: Git Hash 기반 (`getGitHash()`) +- 브랜치: feature, develop, main + +### 빌드 +```bash +# 전체 빌드 +./gradlew build + +# 특정 모듈 빌드 +./gradlew :apps:commerce-api:build + +# 테스트 실행 +./gradlew test + +# 커버리지 리포트 +./gradlew jacocoTestReport +``` + +--- + +이 문서는 프로젝트의 코드베이스를 분석하여 작성되었으며, 실제 구현된 패턴과 규칙을 반영합니다. diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 03ce68f0..cb54a44b 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -8,6 +8,7 @@ dependencies { // web implementation("org.springframework.boot:spring-boot-starter-web") + 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/config/SecurityConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/SecurityConfig.java new file mode 100644 index 00000000..52b04d23 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/SecurityConfig.java @@ -0,0 +1,15 @@ +package com.loopers.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class SecurityConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDateValidator.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDateValidator.java new file mode 100644 index 00000000..74dff473 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDateValidator.java @@ -0,0 +1,29 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.time.format.ResolverStyle; + +@Component +public class BirthDateValidator { + + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("uuuu-MM-dd") + .withResolverStyle(ResolverStyle.STRICT); + + public void validate(String birthDate) { + if (birthDate == null || birthDate.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 필수입니다."); + } + + try { + LocalDate.parse(birthDate, FORMATTER); + } catch (DateTimeParseException e) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 yyyy-MM-dd 형식이어야 합니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/EmailValidator.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/EmailValidator.java new file mode 100644 index 00000000..b227dec4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/EmailValidator.java @@ -0,0 +1,21 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.springframework.stereotype.Component; + +@Component +public class EmailValidator { + + private static final String EMAIL_PATTERN = "^[^@]+@[^@]+\\.[^@]+$"; + + public void validate(String email) { + if (email == null || email.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이메일은 필수입니다."); + } + + if (!email.matches(EMAIL_PATTERN)) { + throw new CoreException(ErrorType.BAD_REQUEST, "이메일 형식이 올바르지 않습니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/LoginIdValidator.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/LoginIdValidator.java new file mode 100644 index 00000000..70c02e3e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/LoginIdValidator.java @@ -0,0 +1,21 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.springframework.stereotype.Component; + +@Component +public class LoginIdValidator { + + private static final String ALPHANUMERIC_PATTERN = "^[a-zA-Z0-9]+$"; + + public void validate(String loginId) { + if (loginId == null || loginId.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 필수입니다."); + } + + if (!loginId.matches(ALPHANUMERIC_PATTERN)) { + throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 영문과 숫자만 허용됩니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/NameMasker.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/NameMasker.java new file mode 100644 index 00000000..bffdb8a1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/NameMasker.java @@ -0,0 +1,19 @@ +package com.loopers.domain.user; + +import org.springframework.stereotype.Component; + +@Component +public class NameMasker { + + public String mask(String name) { + if (name == null || name.isEmpty()) { + return name; + } + + if (name.length() == 1) { + return "*"; + } + + return name.substring(0, name.length() - 1) + "*"; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordPolicyValidator.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordPolicyValidator.java new file mode 100644 index 00000000..f67ab3d0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordPolicyValidator.java @@ -0,0 +1,44 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.springframework.stereotype.Component; + +@Component +public class PasswordPolicyValidator { + + private static final int MIN_LENGTH = 8; + private static final int MAX_LENGTH = 16; + private static final String ALLOWED_PATTERN = "^[a-zA-Z0-9!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?`~]+$"; + + public void validate(String password, String birthDate) { + validateLength(password); + validateAllowedCharacters(password); + validateNoBirthDate(password, birthDate); + } + + private void validateLength(String password) { + if (password.length() < MIN_LENGTH || password.length() > MAX_LENGTH) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 8자 이상 16자 이하여야 합니다."); + } + } + + private void validateAllowedCharacters(String password) { + if (!password.matches(ALLOWED_PATTERN)) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 영문 대소문자, 숫자, 특수문자만 허용됩니다."); + } + } + + private void validateNoBirthDate(String password, String birthDate) { + String birthDateNumbers = birthDate.replace("-", ""); + if (password.contains(birthDateNumbers)) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호에 생년월일을 포함할 수 없습니다."); + } + } + + public void validatePasswordChange(String oldPassword, String newPassword) { + if (oldPassword.equals(newPassword)) { + throw new CoreException(ErrorType.BAD_REQUEST, "새 비밀번호는 기존 비밀번호와 달라야 합니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java new file mode 100644 index 00000000..aa38051a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java @@ -0,0 +1,42 @@ +package com.loopers.domain.user; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; + +@Entity +@Table(name = "users") +@Getter +public class User extends BaseEntity { + + @Column(name = "login_id", nullable = false, unique = true) + private String loginId; + + @Column(name = "password", nullable = false) + private String password; + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "email", nullable = false) + private String email; + + @Column(name = "birth_date", nullable = false) + private String birthDate; + + protected User() {} + + public User(String loginId, String password, String name, String email, String birthDate) { + this.loginId = loginId; + this.password = password; + this.name = name; + this.email = email; + this.birthDate = birthDate; + } + + public void changePassword(String newPassword) { + this.password = newPassword; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java new file mode 100644 index 00000000..1bde08c8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java @@ -0,0 +1,12 @@ +package com.loopers.domain.user; + +import java.util.Optional; + +public interface UserRepository { + + User save(User user); + + Optional findByLoginId(String loginId); + + boolean existsByLoginId(String loginId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java new file mode 100644 index 00000000..c2af44aa --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -0,0 +1,68 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class UserService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final LoginIdValidator loginIdValidator; + private final EmailValidator emailValidator; + private final BirthDateValidator birthDateValidator; + private final PasswordPolicyValidator passwordPolicyValidator; + + @Transactional(readOnly = true) + public User authenticate(String loginId, String rawPassword) { + User user = userRepository.findByLoginId(loginId) + .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED, "아이디 또는 비밀번호가 일치하지 않습니다.")); + + if (!passwordEncoder.matches(rawPassword, user.getPassword())) { + throw new CoreException(ErrorType.UNAUTHORIZED, "아이디 또는 비밀번호가 일치하지 않습니다."); + } + + return user; + } + + @Transactional + public User register(String loginId, String rawPassword, String name, String email, String birthDate) { + loginIdValidator.validate(loginId); + emailValidator.validate(email); + birthDateValidator.validate(birthDate); + passwordPolicyValidator.validate(rawPassword, birthDate); + + if (userRepository.existsByLoginId(loginId)) { + throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 로그인 ID입니다."); + } + + String encodedPassword = passwordEncoder.encode(rawPassword); + User user = new User(loginId, encodedPassword, name, email, birthDate); + + return userRepository.save(user); + } + + @Transactional + public void changePassword(String loginId, String currentPassword, String newPassword) { + User user = userRepository.findByLoginId(loginId) + .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED, "사용자를 찾을 수 없습니다.")); + + if (!passwordEncoder.matches(currentPassword, user.getPassword())) { + throw new CoreException(ErrorType.UNAUTHORIZED, "현재 비밀번호가 일치하지 않습니다."); + } + + if (currentPassword.equals(newPassword)) { + throw new CoreException(ErrorType.BAD_REQUEST, "새 비밀번호는 현재 비밀번호와 달라야 합니다."); + } + + passwordPolicyValidator.validate(newPassword, user.getBirthDate()); + + String encodedPassword = passwordEncoder.encode(newPassword); + user.changePassword(encodedPassword); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java new file mode 100644 index 00000000..12cdd51b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java @@ -0,0 +1,13 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserJpaRepository extends JpaRepository { + + Optional findByLoginId(String loginId); + + boolean existsByLoginId(String loginId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java new file mode 100644 index 00000000..9a9ed24a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java @@ -0,0 +1,30 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class UserRepositoryImpl implements UserRepository { + + private final UserJpaRepository userJpaRepository; + + @Override + public User save(User user) { + return userJpaRepository.save(user); + } + + @Override + public Optional findByLoginId(String loginId) { + return userJpaRepository.findByLoginId(loginId); + } + + @Override + public boolean existsByLoginId(String loginId) { + return userJpaRepository.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..23b6c7e4 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,8 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingRequestHeaderException; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -46,6 +48,20 @@ public ResponseEntity> handleBadRequest(MissingServletRequestPara return failureResponse(ErrorType.BAD_REQUEST, message); } + @ExceptionHandler + public ResponseEntity> handleValidationException(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> handleMissingHeader(MissingRequestHeaderException e) { + String message = String.format("필수 헤더 '%s'가 누락되었습니다.", e.getHeaderName()); + 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/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java new file mode 100644 index 00000000..ded4af69 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -0,0 +1,63 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.domain.user.NameMasker; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserService; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/users") +public class UserV1Controller { + + private final UserService userService; + private final NameMasker nameMasker; + + @PostMapping("/register") + public ApiResponse register( + @Valid @RequestBody UserV1Dto.RegisterRequest request + ) { + User user = userService.register( + request.loginId(), + request.password(), + request.name(), + request.email(), + request.birthDate() + ); + return ApiResponse.success(UserV1Dto.UserResponse.from(user)); + } + + @GetMapping("/me") + public ApiResponse getMe( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String loginPw + ) { + User user = userService.authenticate(loginId, loginPw); + return ApiResponse.success(new UserV1Dto.MeResponse( + user.getLoginId(), + nameMasker.mask(user.getName()), + user.getEmail(), + user.getBirthDate() + )); + } + + @PatchMapping("/me/password") + public ApiResponse changePassword( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String loginPw, + @Valid @RequestBody UserV1Dto.ChangePasswordRequest request + ) { + userService.authenticate(loginId, loginPw); + userService.changePassword(loginId, request.currentPassword(), request.newPassword()); + return ApiResponse.success(null); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java new file mode 100644 index 00000000..8d53f3e6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java @@ -0,0 +1,57 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.domain.user.User; +import jakarta.validation.constraints.NotBlank; + +public class UserV1Dto { + + public record RegisterRequest( + @NotBlank(message = "필수 입력값입니다") + String loginId, + + @NotBlank(message = "필수 입력값입니다") + String password, + + @NotBlank(message = "필수 입력값입니다") + String name, + + @NotBlank(message = "필수 입력값입니다") + String email, + + @NotBlank(message = "필수 입력값입니다") + String birthDate + ) {} + + public record UserResponse( + Long id, + String loginId, + String name, + String email, + String birthDate + ) { + public static UserResponse from(User user) { + return new UserResponse( + user.getId(), + user.getLoginId(), + user.getName(), + user.getEmail(), + user.getBirthDate() + ); + } + } + + public record MeResponse( + String loginId, + String name, + String email, + String birthDate + ) {} + + public record ChangePasswordRequest( + @NotBlank(message = "필수 입력값입니다") + String currentPassword, + + @NotBlank(message = "필수 입력값입니다") + String newPassword + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java index 5d142efb..8d493491 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java @@ -10,6 +10,7 @@ public enum ErrorType { /** 범용 에러 */ INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), "일시적인 오류가 발생했습니다."), BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "잘못된 요청입니다."), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, HttpStatus.UNAUTHORIZED.getReasonPhrase(), "인증에 실패했습니다."), NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 요청입니다."), CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다."); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateValidatorTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateValidatorTest.java new file mode 100644 index 00000000..878e2fb1 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateValidatorTest.java @@ -0,0 +1,92 @@ +package com.loopers.domain.user; + +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.assertj.core.api.Assertions.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class BirthDateValidatorTest { + + private final BirthDateValidator validator = new BirthDateValidator(); + + @DisplayName("생년월일 형식 검증") + @Nested + class FormatValidation { + + @DisplayName("생년월일이 yyyy-MM-dd 형식이면 통과한다") + @Test + void passes_whenBirthDateIsValidFormat() { + // arrange + String birthDate = "1990-01-15"; + + // act & assert + assertThatCode(() -> validator.validate(birthDate)) + .doesNotThrowAnyException(); + } + + @DisplayName("생년월일이 dd-MM-yyyy 형식이면 예외가 발생한다") + @Test + void throwsException_whenBirthDateIsDdMmYyyyFormat() { + // arrange + String birthDate = "15-01-1990"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + validator.validate(birthDate); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("생년월일에 슬래시를 사용하면 예외가 발생한다") + @Test + void throwsException_whenBirthDateUsesSlashes() { + // arrange + String birthDate = "1990/01/15"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + validator.validate(birthDate); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("생년월일이 빈 문자열이면 예외가 발생한다") + @Test + void throwsException_whenBirthDateIsEmpty() { + // arrange + String birthDate = ""; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + validator.validate(birthDate); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("유효하지 않은 날짜이면 예외가 발생한다") + @Test + void throwsException_whenBirthDateIsInvalidDate() { + // arrange + String birthDate = "1990-13-45"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + validator.validate(birthDate); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailValidatorTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailValidatorTest.java new file mode 100644 index 00000000..94590559 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailValidatorTest.java @@ -0,0 +1,92 @@ +package com.loopers.domain.user; + +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.assertj.core.api.Assertions.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class EmailValidatorTest { + + private final EmailValidator validator = new EmailValidator(); + + @DisplayName("이메일 형식 검증") + @Nested + class FormatValidation { + + @DisplayName("이메일이 xx@yy.zz 형식이면 통과한다") + @Test + void passes_whenEmailIsValidFormat() { + // arrange + String email = "test@example.com"; + + // act & assert + assertThatCode(() -> validator.validate(email)) + .doesNotThrowAnyException(); + } + + @DisplayName("이메일에 @가 없으면 예외가 발생한다") + @Test + void throwsException_whenEmailHasNoAtSymbol() { + // arrange + String email = "testexample.com"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + validator.validate(email); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("이메일 도메인에 .이 없으면 예외가 발생한다") + @Test + void throwsException_whenEmailDomainHasNoDot() { + // arrange + String email = "test@examplecom"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + validator.validate(email); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("이메일이 빈 문자열이면 예외가 발생한다") + @Test + void throwsException_whenEmailIsEmpty() { + // arrange + String email = ""; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + validator.validate(email); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("이메일 @ 앞부분이 비어있으면 예외가 발생한다") + @Test + void throwsException_whenEmailLocalPartIsEmpty() { + // arrange + String email = "@example.com"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + validator.validate(email); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/LoginIdValidatorTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/LoginIdValidatorTest.java new file mode 100644 index 00000000..a8270c5e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/LoginIdValidatorTest.java @@ -0,0 +1,92 @@ +package com.loopers.domain.user; + +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.assertj.core.api.Assertions.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class LoginIdValidatorTest { + + private final LoginIdValidator validator = new LoginIdValidator(); + + @DisplayName("loginId 형식 검증") + @Nested + class FormatValidation { + + @DisplayName("loginId가 영문과 숫자로만 구성되면 통과한다") + @Test + void passes_whenLoginIdContainsOnlyAlphanumeric() { + // arrange + String loginId = "user123"; + + // act & assert + assertThatCode(() -> validator.validate(loginId)) + .doesNotThrowAnyException(); + } + + @DisplayName("loginId에 특수문자가 포함되면 예외가 발생한다") + @Test + void throwsException_whenLoginIdContainsSpecialCharacters() { + // arrange + String loginId = "user@123"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + validator.validate(loginId); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("loginId에 한글이 포함되면 예외가 발생한다") + @Test + void throwsException_whenLoginIdContainsKorean() { + // arrange + String loginId = "user한글"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + validator.validate(loginId); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("loginId에 공백이 포함되면 예외가 발생한다") + @Test + void throwsException_whenLoginIdContainsSpace() { + // arrange + String loginId = "user 123"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + validator.validate(loginId); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("loginId가 빈 문자열이면 예외가 발생한다") + @Test + void throwsException_whenLoginIdIsEmpty() { + // arrange + String loginId = ""; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + validator.validate(loginId); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/NameMaskerTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/NameMaskerTest.java new file mode 100644 index 00000000..208ef6f7 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/NameMaskerTest.java @@ -0,0 +1,69 @@ +package com.loopers.domain.user; + +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; + +class NameMaskerTest { + + private final NameMasker masker = new NameMasker(); + + @DisplayName("이름 마스킹") + @Nested + class Mask { + + @DisplayName("이름의 마지막 글자가 *로 변환된다") + @Test + void masksLastCharacterWithAsterisk() { + // arrange + String name = "홍길동"; + + // act + String result = masker.mask(name); + + // assert + assertThat(result).isEqualTo("홍길*"); + } + + @DisplayName("영문 이름의 마지막 글자가 *로 변환된다") + @Test + void masksLastCharacterOfEnglishName() { + // arrange + String name = "John"; + + // act + String result = masker.mask(name); + + // assert + assertThat(result).isEqualTo("Joh*"); + } + + @DisplayName("두 글자 이름의 마지막 글자가 *로 변환된다") + @Test + void masksLastCharacterOfTwoCharacterName() { + // arrange + String name = "길동"; + + // act + String result = masker.mask(name); + + // assert + assertThat(result).isEqualTo("길*"); + } + + @DisplayName("한 글자 이름은 *로 변환된다") + @Test + void masksSingleCharacterName() { + // arrange + String name = "동"; + + // act + String result = masker.mask(name); + + // assert + assertThat(result).isEqualTo("*"); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordPolicyValidatorTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordPolicyValidatorTest.java new file mode 100644 index 00000000..61b3e01d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordPolicyValidatorTest.java @@ -0,0 +1,180 @@ +package com.loopers.domain.user; + +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.assertj.core.api.Assertions.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class PasswordPolicyValidatorTest { + + private final PasswordPolicyValidator validator = new PasswordPolicyValidator(); + + @DisplayName("비밀번호 길이 검증") + @Nested + class LengthValidation { + + @DisplayName("비밀번호가 8자 미만이면 예외가 발생한다") + @Test + void throwsException_whenPasswordLessThan8Characters() { + // arrange + String password = "Abc123!"; // 7자 + String birthDate = "1990-01-01"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + validator.validate(password, birthDate); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("비밀번호가 16자 초과이면 예외가 발생한다") + @Test + void throwsException_whenPasswordMoreThan16Characters() { + // arrange + String password = "Abcdefgh123456!@#"; // 17자 + String birthDate = "1990-01-01"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + validator.validate(password, birthDate); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("비밀번호가 8자 이상 16자 이하이면 길이 검증을 통과한다") + @Test + void passes_whenPasswordLengthIsValid() { + // arrange + String password = "Abcd1234!"; // 9자 + String birthDate = "1990-01-01"; + + // act & assert + assertThatCode(() -> validator.validate(password, birthDate)) + .doesNotThrowAnyException(); + } + } + + @DisplayName("비밀번호 허용 문자 검증") + @Nested + class CharacterValidation { + + @DisplayName("비밀번호에 공백이 포함되면 예외가 발생한다") + @Test + void throwsException_whenPasswordContainsSpace() { + // arrange + String password = "Abcd 1234!"; + String birthDate = "1990-01-01"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + validator.validate(password, birthDate); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("비밀번호에 한글이 포함되면 예외가 발생한다") + @Test + void throwsException_whenPasswordContainsKorean() { + // arrange + String password = "Abcd1234한글!"; + String birthDate = "1990-01-01"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + validator.validate(password, birthDate); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("비밀번호가 영문 대소문자, 숫자, ASCII 특수문자로만 구성되면 통과한다") + @Test + void passes_whenPasswordContainsOnlyAllowedCharacters() { + // arrange + String password = "Abc123!@#$"; + String birthDate = "1990-01-01"; + + // act & assert + assertThatCode(() -> validator.validate(password, birthDate)) + .doesNotThrowAnyException(); + } + } + + @DisplayName("생년월일 포함 검증") + @Nested + class BirthDateValidation { + + @DisplayName("비밀번호에 생년월일(yyyyMMdd)이 포함되면 예외가 발생한다") + @Test + void throwsException_whenPasswordContainsBirthDate() { + // arrange + String password = "Abc19900101!"; // 생년월일 19900101 포함 + String birthDate = "1990-01-01"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + validator.validate(password, birthDate); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("비밀번호에 생년월일(yyyyMMdd)이 포함되지 않으면 통과한다") + @Test + void passes_whenPasswordDoesNotContainBirthDate() { + // arrange + String password = "Abc12345!@"; + String birthDate = "1990-01-01"; + + // act & assert + assertThatCode(() -> validator.validate(password, birthDate)) + .doesNotThrowAnyException(); + } + } + + @DisplayName("비밀번호 변경 시 동일 여부 검증") + @Nested + class SamePasswordValidation { + + @DisplayName("새 비밀번호가 기존 비밀번호와 동일하면 예외가 발생한다") + @Test + void throwsException_whenNewPasswordIsSameAsOldPassword() { + // arrange + String oldPassword = "Abcd1234!"; + String newPassword = "Abcd1234!"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + validator.validatePasswordChange(oldPassword, newPassword); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("새 비밀번호가 기존 비밀번호와 다르면 통과한다") + @Test + void passes_whenNewPasswordIsDifferentFromOldPassword() { + // arrange + String oldPassword = "Abcd1234!"; + String newPassword = "Efgh5678@"; + + // act & assert + assertThatCode(() -> validator.validatePasswordChange(oldPassword, newPassword)) + .doesNotThrowAnyException(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java new file mode 100644 index 00000000..9431c806 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java @@ -0,0 +1,89 @@ +package com.loopers.domain.user; + +import com.loopers.infrastructure.user.UserJpaRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.crypto.password.PasswordEncoder; + +import 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 UserServiceIntegrationTest { + + @Autowired + private UserService userService; + + @Autowired + private UserJpaRepository userJpaRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("회원가입 시") + @Nested + class Register { + + @DisplayName("정상 요청이면 User가 DB에 저장되고, 비밀번호는 BCrypt 해시로 저장된다") + @Test + void savesUserWithHashedPassword_whenValidRequest() { + // arrange + String loginId = "testuser2"; + String rawPassword = "Test1234!"; + String name = "홍길동"; + String email = "test@example.com"; + String birthDate = "1990-01-15"; + + // act + User savedUser = userService.register(loginId, rawPassword, name, email, birthDate); + + // assert + User foundUser = userJpaRepository.findById(savedUser.getId()).orElseThrow(); + assertAll( + () -> assertThat(foundUser.getLoginId()).isEqualTo(loginId), + () -> assertThat(foundUser.getName()).isEqualTo(name), + () -> assertThat(foundUser.getEmail()).isEqualTo(email), + () -> assertThat(foundUser.getBirthDate()).isEqualTo(birthDate), + () -> assertThat(foundUser.getPassword()).isNotEqualTo(rawPassword), + () -> assertThat(passwordEncoder.matches(rawPassword, foundUser.getPassword())).isTrue() + ); + } + + @DisplayName("이미 존재하는 loginId로 가입 시도 시 CONFLICT 예외가 발생한다") + @Test + void throwsConflictException_whenLoginIdAlreadyExists() { + // arrange + String loginId = "testuser1"; + String rawPassword = "Test1234!"; + String name = "홍길동"; + String email = "test@example.com"; + String birthDate = "1990-01-15"; + + userService.register(loginId, rawPassword, name, email, birthDate); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + userService.register(loginId, "Other1234!", "김철수", "other@example.com", "1985-05-20"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java new file mode 100644 index 00000000..b4b902c4 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java @@ -0,0 +1,460 @@ +package com.loopers.interfaces.api; + +import com.loopers.interfaces.api.user.UserV1Dto; +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.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class UserV1ApiE2ETest { + + private static final String ENDPOINT_REGISTER = "/api/v1/users/register"; + private static final String ENDPOINT_ME = "/api/v1/users/me"; + private static final String ENDPOINT_CHANGE_PASSWORD = "/api/v1/users/me/password"; + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public UserV1ApiE2ETest(TestRestTemplate testRestTemplate, DatabaseCleanUp databaseCleanUp) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("POST /api/v1/users/register - 회원가입") + @Nested + class Register { + + @DisplayName("유효한 정보로 요청하면, 회원이 생성되고 회원 정보를 반환한다") + @Test + void returnsCreatedUser_whenValidRequest() { + // arrange + var request = new UserV1Dto.RegisterRequest( + "testuser1", + "Test1234!", + "홍길동", + "test@example.com", + "1990-01-15" + ); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_REGISTER, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().loginId()).isEqualTo("testuser1"), + () -> assertThat(response.getBody().data().name()).isEqualTo("홍길동"), + () -> assertThat(response.getBody().data().email()).isEqualTo("test@example.com"), + () -> assertThat(response.getBody().data().birthDate()).isEqualTo("1990-01-15") + ); + } + + @DisplayName("이미 존재하는 loginId로 요청하면, CONFLICT 응답을 반환한다") + @Test + void returnsConflict_whenLoginIdAlreadyExists() { + // arrange + var request = new UserV1Dto.RegisterRequest( + "testuser1", + "Test1234!", + "홍길동", + "test@example.com", + "1990-01-15" + ); + testRestTemplate.exchange(ENDPOINT_REGISTER, HttpMethod.POST, new HttpEntity<>(request), + new ParameterizedTypeReference>() {}); + + var duplicateRequest = new UserV1Dto.RegisterRequest( + "testuser1", + "Other1234!", + "김철수", + "other@example.com", + "1985-05-20" + ); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_REGISTER, HttpMethod.POST, new HttpEntity<>(duplicateRequest), responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL) + ); + } + + @DisplayName("잘못된 loginId 형식으로 요청하면, BAD_REQUEST 응답을 반환한다") + @Test + void returnsBadRequest_whenInvalidLoginId() { + // arrange + var request = new UserV1Dto.RegisterRequest( + "invalid_id!", + "Test1234!", + "홍길동", + "test@example.com", + "1990-01-15" + ); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_REGISTER, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("잘못된 비밀번호 형식으로 요청하면, BAD_REQUEST 응답을 반환한다") + @Test + void returnsBadRequest_whenInvalidPassword() { + // arrange + var request = new UserV1Dto.RegisterRequest( + "testuser1", + "short", + "홍길동", + "test@example.com", + "1990-01-15" + ); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_REGISTER, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("비밀번호에 생년월일이 포함되면, BAD_REQUEST 응답을 반환한다") + @Test + void returnsBadRequest_whenPasswordContainsBirthDate() { + // arrange + var request = new UserV1Dto.RegisterRequest( + "testuser1", + "Test19900115!", + "홍길동", + "test@example.com", + "1990-01-15" + ); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_REGISTER, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("잘못된 이메일 형식으로 요청하면, BAD_REQUEST 응답을 반환한다") + @Test + void returnsBadRequest_whenInvalidEmail() { + // arrange + var request = new UserV1Dto.RegisterRequest( + "testuser1", + "Test1234!", + "홍길동", + "invalid-email", + "1990-01-15" + ); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_REGISTER, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("잘못된 생년월일 형식으로 요청하면, BAD_REQUEST 응답을 반환한다") + @Test + void returnsBadRequest_whenInvalidBirthDate() { + // arrange + var request = new UserV1Dto.RegisterRequest( + "testuser1", + "Test1234!", + "홍길동", + "test@example.com", + "19900115" + ); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_REGISTER, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @DisplayName("GET /api/v1/users/me - 내 정보 조회") + @Nested + class GetMe { + + @DisplayName("유효한 인증 정보로 요청하면, 내 정보를 반환한다 (이름 마스킹 포함)") + @Test + void returnsMyInfo_whenValidCredentials() { + // arrange + String loginId = "testuser1"; + String password = "Test1234!"; + registerUser(loginId, password, "홍길동", "test@example.com", "1990-01-15"); + + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, loginId); + headers.set(HEADER_LOGIN_PW, password); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_ME, HttpMethod.GET, new HttpEntity<>(headers), responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().loginId()).isEqualTo("testuser1"), + () -> assertThat(response.getBody().data().name()).isEqualTo("홍길*"), + () -> assertThat(response.getBody().data().email()).isEqualTo("test@example.com"), + () -> assertThat(response.getBody().data().birthDate()).isEqualTo("1990-01-15") + ); + } + + @DisplayName("잘못된 비밀번호로 요청하면, UNAUTHORIZED 응답을 반환한다") + @Test + void returnsUnauthorized_whenInvalidPassword() { + // arrange + String loginId = "testuser1"; + registerUser(loginId, "Test1234!", "홍길동", "test@example.com", "1990-01-15"); + + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, loginId); + headers.set(HEADER_LOGIN_PW, "WrongPassword1!"); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_ME, HttpMethod.GET, new HttpEntity<>(headers), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @DisplayName("존재하지 않는 loginId로 요청하면, UNAUTHORIZED 응답을 반환한다") + @Test + void returnsUnauthorized_whenLoginIdNotFound() { + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, "nonexistent"); + headers.set(HEADER_LOGIN_PW, "Test1234!"); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_ME, HttpMethod.GET, new HttpEntity<>(headers), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @DisplayName("X-Loopers-LoginId 헤더가 누락되면, BAD_REQUEST 응답을 반환한다") + @Test + void returnsBadRequest_whenLoginIdHeaderMissing() { + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_PW, "Test1234!"); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_ME, HttpMethod.GET, new HttpEntity<>(headers), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("X-Loopers-LoginPw 헤더가 누락되면, BAD_REQUEST 응답을 반환한다") + @Test + void returnsBadRequest_whenLoginPwHeaderMissing() { + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, "testuser1"); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_ME, HttpMethod.GET, new HttpEntity<>(headers), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + private void registerUser(String loginId, String password, String name, String email, String birthDate) { + var request = new UserV1Dto.RegisterRequest(loginId, password, name, email, birthDate); + testRestTemplate.exchange(ENDPOINT_REGISTER, HttpMethod.POST, new HttpEntity<>(request), + new ParameterizedTypeReference>() {}); + } + } + + @DisplayName("PATCH /api/v1/users/me/password - 비밀번호 변경") + @Nested + class ChangePassword { + + @DisplayName("유효한 요청으로 비밀번호를 변경하면, 200 OK를 반환한다") + @Test + void returnsOk_whenValidRequest() { + // arrange + String loginId = "testuser1"; + String currentPassword = "Test1234!"; + String newPassword = "NewPass1234!"; + registerUser(loginId, currentPassword, "홍길동", "test@example.com", "1990-01-15"); + + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, loginId); + headers.set(HEADER_LOGIN_PW, currentPassword); + + var request = new UserV1Dto.ChangePasswordRequest(currentPassword, newPassword); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_CHANGE_PASSWORD, HttpMethod.PATCH, new HttpEntity<>(request, headers), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @DisplayName("비밀번호 변경 후 이전 비밀번호로 인증하면 401, 새 비밀번호로 인증하면 200을 반환한다") + @Test + void returnsUnauthorizedWithOldPassword_andOkWithNewPassword_afterChange() { + // arrange + String loginId = "testuser1"; + String currentPassword = "Test1234!"; + String newPassword = "NewPass1234!"; + registerUser(loginId, currentPassword, "홍길동", "test@example.com", "1990-01-15"); + + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, loginId); + headers.set(HEADER_LOGIN_PW, currentPassword); + + var request = new UserV1Dto.ChangePasswordRequest(currentPassword, newPassword); + testRestTemplate.exchange(ENDPOINT_CHANGE_PASSWORD, HttpMethod.PATCH, new HttpEntity<>(request, headers), + new ParameterizedTypeReference>() {}); + + // act - 이전 비밀번호로 인증 시도 + HttpHeaders oldPasswordHeaders = new HttpHeaders(); + oldPasswordHeaders.set(HEADER_LOGIN_ID, loginId); + oldPasswordHeaders.set(HEADER_LOGIN_PW, currentPassword); + + ParameterizedTypeReference> meResponseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> oldPasswordResponse = + testRestTemplate.exchange(ENDPOINT_ME, HttpMethod.GET, new HttpEntity<>(oldPasswordHeaders), meResponseType); + + // act - 새 비밀번호로 인증 시도 + HttpHeaders newPasswordHeaders = new HttpHeaders(); + newPasswordHeaders.set(HEADER_LOGIN_ID, loginId); + newPasswordHeaders.set(HEADER_LOGIN_PW, newPassword); + + ResponseEntity> newPasswordResponse = + testRestTemplate.exchange(ENDPOINT_ME, HttpMethod.GET, new HttpEntity<>(newPasswordHeaders), meResponseType); + + // assert + assertAll( + () -> assertThat(oldPasswordResponse.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED), + () -> assertThat(newPasswordResponse.getStatusCode()).isEqualTo(HttpStatus.OK) + ); + } + + @DisplayName("currentPassword가 실제 비밀번호와 불일치하면, 401 UNAUTHORIZED를 반환한다") + @Test + void returnsUnauthorized_whenCurrentPasswordMismatch() { + // arrange + String loginId = "testuser1"; + String currentPassword = "Test1234!"; + registerUser(loginId, currentPassword, "홍길동", "test@example.com", "1990-01-15"); + + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, loginId); + headers.set(HEADER_LOGIN_PW, currentPassword); + + var request = new UserV1Dto.ChangePasswordRequest("WrongPassword1!", "NewPass1234!"); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_CHANGE_PASSWORD, HttpMethod.PATCH, new HttpEntity<>(request, headers), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @DisplayName("newPassword가 currentPassword와 같으면, 400 BAD_REQUEST를 반환한다") + @Test + void returnsBadRequest_whenNewPasswordSameAsCurrent() { + // arrange + String loginId = "testuser1"; + String currentPassword = "Test1234!"; + registerUser(loginId, currentPassword, "홍길동", "test@example.com", "1990-01-15"); + + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, loginId); + headers.set(HEADER_LOGIN_PW, currentPassword); + + var request = new UserV1Dto.ChangePasswordRequest(currentPassword, currentPassword); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_CHANGE_PASSWORD, HttpMethod.PATCH, new HttpEntity<>(request, headers), responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + private void registerUser(String loginId, String password, String name, String email, String birthDate) { + var request = new UserV1Dto.RegisterRequest(loginId, password, name, email, birthDate); + testRestTemplate.exchange(ENDPOINT_REGISTER, HttpMethod.POST, new HttpEntity<>(request), + new ParameterizedTypeReference>() {}); + } + } +} diff --git a/apps/commerce-batch/src/main/resources/application.yml b/apps/commerce-batch/src/main/resources/application.yml index 9aa0d760..7f9a7a55 100644 --- a/apps/commerce-batch/src/main/resources/application.yml +++ b/apps/commerce-batch/src/main/resources/application.yml @@ -4,51 +4,25 @@ spring: application: name: commerce-batch profiles: - active: local + active: test config: import: - jpa.yml - redis.yml - logging.yml - monitoring.yml + batch: job: - name: ${job.name:NONE} + enabled: false jdbc: - initialize-schema: never + initialize-schema: always management: health: defaults: enabled: false ---- -spring: - config: - activate: - on-profile: local, test - batch: - jdbc: - initialize-schema: always - ---- -spring: - config: - activate: - on-profile: dev - ---- -spring: - config: - activate: - on-profile: qa - ---- -spring: - config: - activate: - on-profile: prd - springdoc: api-docs: - enabled: false \ No newline at end of file + enabled: false diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java index dafe59a1..094a442a 100644 --- a/apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java +++ b/apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java @@ -1,8 +1,6 @@ package com.loopers.job.demo; import com.loopers.batch.job.demo.DemoJobConfig; -import lombok.RequiredArgsConstructor; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.batch.core.ExitStatus; @@ -34,14 +32,9 @@ class DemoJobE2ETest { @Qualifier(DemoJobConfig.JOB_NAME) private Job job; - @BeforeEach - void beforeEach() { - - } - @DisplayName("jobParameter 중 requestDate 인자가 주어지지 않았을 때, demoJob 배치는 실패한다.") @Test - void shouldNotSaveCategories_whenApiError() throws Exception { + void shouldFail_whenRequestDateMissing() throws Exception { // arrange jobLauncherTestUtils.setJob(job); @@ -50,8 +43,8 @@ void shouldNotSaveCategories_whenApiError() throws Exception { // assert assertAll( - () -> assertThat(jobExecution).isNotNull(), - () -> assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.FAILED.getExitCode()) + () -> assertThat(jobExecution).isNotNull(), + () -> assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.FAILED.getExitCode()) ); } @@ -63,8 +56,10 @@ void success() throws Exception { // act var jobParameters = new JobParametersBuilder() - .addLocalDate("requestDate", LocalDate.now()) - .toJobParameters(); + .addLocalDate("requestDate", LocalDate.now()) + .addLong("run.id", System.currentTimeMillis()) + .toJobParameters(); + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); // assert diff --git a/loopers-docker/.gitattributes b/loopers-docker/.gitattributes new file mode 100644 index 00000000..8af972cd --- /dev/null +++ b/loopers-docker/.gitattributes @@ -0,0 +1,3 @@ +/gradlew text eol=lf +*.bat text eol=crlf +*.jar binary diff --git a/loopers-docker/.gitignore b/loopers-docker/.gitignore new file mode 100644 index 00000000..c2065bc2 --- /dev/null +++ b/loopers-docker/.gitignore @@ -0,0 +1,37 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ diff --git a/loopers-docker/build.gradle b/loopers-docker/build.gradle new file mode 100644 index 00000000..e0f9f357 --- /dev/null +++ b/loopers-docker/build.gradle @@ -0,0 +1,37 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.5.6' + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'com.loopers' +version = '0.0.1-SNAPSHOT' +description = 'Demo project for Docker' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +repositories { + mavenCentral() +} + +dependencies { + // spring + implementation("org.springframework.boot:spring-boot-starter-web") + // jpa + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + // querydsl + implementation("com.querydsl:querydsl-jpa::jakarta") + implementation("com.querydsl:querydsl-apt::jakarta") + implementation("jakarta.persistence:jakarta.persistence-api") + implementation("jakarta.annotation:jakarta.annotation-api") + // jdbc-mysql + implementation("com.mysql:mysql-connector-j") + // redis + implementation("org.springframework.boot:spring-boot-starter-data-redis") +} + + diff --git a/loopers-docker/docker/infra-compose.yaml b/loopers-docker/docker/infra-compose.yaml new file mode 100644 index 00000000..8d4bcc0b --- /dev/null +++ b/loopers-docker/docker/infra-compose.yaml @@ -0,0 +1,43 @@ +version: '3' +services: + mysql: + image: mysql:8.0 + ports: + - "3306:3306" + environment: + - MYSQL_ROOT_PASSWORD=root + - MYSQL_USER=application + - MYSQL_PASSWORD=application + - MYSQL_DATABASE=loopers + - MYSQL_CHARACTER_SET=utf8mb4 + - MYSQL_COLLATE=utf8mb4_general_ci + volumes: + - mysql-8-data:/var/lib/mysql + + redis-master: + image: redis:7.0 + container_name: redis-master + ports: + - "6379:6379" + volumes: + - redis_master_data:/data + command: + [ + "redis-server", # redis 서버 실행 명령어 + "--appendonly", "yes", # AOF (AppendOnlyFile) 영속성 기능 켜기 + "--save", "", + "--latency-monitor-threshold", "100", # 특정 command 가 지정 시간(ms) 이상 걸리면 monitor 기록 + ] + healthcheck: + test: ["CMD", "redis-cli", "-p", "6379", "PING"] + interval: 5s + timeout: 2s + retries: 10 + +volumes: + mysql-8-data: + redis_master_data: + +networks: + default: + driver: bridge diff --git a/loopers-docker/gradle/wrapper/gradle-wrapper.jar b/loopers-docker/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..1b33c55b Binary files /dev/null and b/loopers-docker/gradle/wrapper/gradle-wrapper.jar differ diff --git a/loopers-docker/gradle/wrapper/gradle-wrapper.properties b/loopers-docker/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..d4081da4 --- /dev/null +++ b/loopers-docker/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/loopers-docker/gradlew b/loopers-docker/gradlew new file mode 100644 index 00000000..23d15a93 --- /dev/null +++ b/loopers-docker/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/loopers-docker/gradlew.bat b/loopers-docker/gradlew.bat new file mode 100644 index 00000000..db3a6ac2 --- /dev/null +++ b/loopers-docker/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/loopers-docker/settings.gradle b/loopers-docker/settings.gradle new file mode 100644 index 00000000..6bf1b83c --- /dev/null +++ b/loopers-docker/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'loopers-docker' diff --git a/loopers-docker/src/main/java/com/loopers/docker/ConnectionHealthLogger.java b/loopers-docker/src/main/java/com/loopers/docker/ConnectionHealthLogger.java new file mode 100644 index 00000000..0bce79b4 --- /dev/null +++ b/loopers-docker/src/main/java/com/loopers/docker/ConnectionHealthLogger.java @@ -0,0 +1,52 @@ +package com.loopers.docker; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.stereotype.Component; + +import javax.sql.DataSource; +import java.sql.Connection; + +@Component +public class ConnectionHealthLogger { + + private static final Logger log = LoggerFactory.getLogger(ConnectionHealthLogger.class); + + private final DataSource dataSource; + private final RedisConnectionFactory redisConnectionFactory; + + public ConnectionHealthLogger(DataSource dataSource, RedisConnectionFactory redisConnectionFactory) { + this.dataSource = dataSource; + this.redisConnectionFactory = redisConnectionFactory; + } + + @EventListener(ApplicationReadyEvent.class) + public void logConnectionStatus() { + logMySQLConnection(); + logRedisConnection(); + } + + private void logMySQLConnection() { + try (Connection connection = dataSource.getConnection()) { + String url = connection.getMetaData().getURL(); + String userName = connection.getMetaData().getUserName(); + log.info("✅ MySQL Connection SUCCESS - URL: {}, User: {}", url, userName); + } catch (Exception e) { + log.error("❌ MySQL Connection FAILED", e); + } + } + + private void logRedisConnection() { + try { + redisConnectionFactory.getConnection().ping(); + log.info("✅ Redis Connection SUCCESS"); + } catch (Exception e) { + log.error("❌ Redis Connection FAILED", e); + } + } + + +} diff --git a/loopers-docker/src/main/java/com/loopers/docker/LoopersDockerApplication.java b/loopers-docker/src/main/java/com/loopers/docker/LoopersDockerApplication.java new file mode 100644 index 00000000..25c067f7 --- /dev/null +++ b/loopers-docker/src/main/java/com/loopers/docker/LoopersDockerApplication.java @@ -0,0 +1,13 @@ +package com.loopers.docker; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class LoopersDockerApplication { + + public static void main(String[] args) { + SpringApplication.run(LoopersDockerApplication.class, args); + } + +} diff --git a/loopers-docker/src/main/java/com/loopers/docker/MySqlConnectionLogger.java b/loopers-docker/src/main/java/com/loopers/docker/MySqlConnectionLogger.java new file mode 100644 index 00000000..adaadca8 --- /dev/null +++ b/loopers-docker/src/main/java/com/loopers/docker/MySqlConnectionLogger.java @@ -0,0 +1,38 @@ +package com.loopers.docker; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +@Component +public class MySqlConnectionLogger { + + private static final Logger log = LoggerFactory.getLogger(MySqlConnectionLogger.class); + + private final JdbcTemplate jdbcTemplate; + + public MySqlConnectionLogger(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @EventListener(ApplicationReadyEvent.class) + public void logConnectionStatus() { + try { + // MySQL에 쿼리를 날려서 container 로그에 기록 남김 + String result = jdbcTemplate.queryForObject( + "SELECT CONCAT('Connected from Spring Boot at ', NOW()) as message", + String.class + ); + log.info("✅ MySQL Connection SUCCESS: {}", result); + + // 추가로 version도 확인 + String version = jdbcTemplate.queryForObject("SELECT VERSION()", String.class); + log.info(" └─ MySQL Version: {}", version); + } catch (Exception e) { + log.error("❌ MySQL Connection FAILED", e); + } + } +} diff --git a/loopers-docker/src/main/java/com/loopers/docker/RedisConnectionLogger.java b/loopers-docker/src/main/java/com/loopers/docker/RedisConnectionLogger.java new file mode 100644 index 00000000..dbff8257 --- /dev/null +++ b/loopers-docker/src/main/java/com/loopers/docker/RedisConnectionLogger.java @@ -0,0 +1,41 @@ +package com.loopers.docker; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +@Component +public class RedisConnectionLogger { + + private static final Logger log = LoggerFactory.getLogger(RedisConnectionLogger.class); + + private final RedisTemplate redisTemplate; + + public RedisConnectionLogger(RedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + @EventListener(ApplicationReadyEvent.class) + public void logConnectionStatus() { + try { + // Redis에 명령을 날려서 container 로그에 기록 남김 + String ping = redisTemplate.getConnectionFactory().getConnection().ping(); + log.info("✅ Redis Connection SUCCESS: {}", ping); + + // 테스트 데이터 쓰기/읽기 + String key = "spring-boot:connection-test"; + String value = "Connected at " + System.currentTimeMillis(); + redisTemplate.opsForValue().set(key, value); + String result = redisTemplate.opsForValue().get(key); + log.info(" └─ Redis Test Write/Read: {}", result); + + // 정리 + redisTemplate.delete(key); + } catch (Exception e) { + log.error("❌ Redis Connection FAILED", e); + } + } +} diff --git a/loopers-docker/src/main/resources/application.yaml b/loopers-docker/src/main/resources/application.yaml new file mode 100644 index 00000000..d9fef97b --- /dev/null +++ b/loopers-docker/src/main/resources/application.yaml @@ -0,0 +1,9 @@ +spring: + main: + web-application-type: servlet + application: + name: commerce-api + config: + import: + - jpa.yaml + - redis.yaml diff --git a/loopers-docker/src/main/resources/jpa.yaml b/loopers-docker/src/main/resources/jpa.yaml new file mode 100644 index 00000000..11633354 --- /dev/null +++ b/loopers-docker/src/main/resources/jpa.yaml @@ -0,0 +1,30 @@ +spring: + jpa: + open-in-view: false + generate-ddl: false + show-sql: false + hibernate: + ddl-auto: none + properties: + hibernate: + default_batch_fetch_size: 100 + timezone.default_storage: NORMALIZE_UTC + jdbc.time_zone: UTC + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://localhost:3306/loopers + username: application + password: application + pool-name: mysql-main-pool + maximum-pool-size: 40 + minimum-idle: 30 + connection-timeout: 3000 # 커넥션 획득 대기시간(ms) ( default: 3000 = 3sec ) + validation-timeout: 5000 # 커넥션 유효성 검사시간(ms) ( default: 5000 = 5sec ) + keepalive-time: 0 # 커넥션 최대 생존시간(ms) ( default: 0 ) + max-lifetime: 1800000 # 커넥션 최대 생존시간(ms) ( default: 1800000 = 30min ) + leak-detection-threshold: 0 # 커넥션 누수 감지 (주어진 ms 내에 반환 안 하면 로그 경고) ( default: 0 = 비활성화 ) + initialization-fail-timeout: 1 # DB 연결 실패 시 즉시 예외 발생 ( default: -1 = 무한대기 ) + data-source-properties: + rewriteBatchedStatements: true + + diff --git a/loopers-docker/src/main/resources/redis.yaml b/loopers-docker/src/main/resources/redis.yaml new file mode 100644 index 00000000..4979c1df --- /dev/null +++ b/loopers-docker/src/main/resources/redis.yaml @@ -0,0 +1,11 @@ +spring: + data: + redis: + repositories: + enabled: false + datasource: + redis: + database: 0 + master: + host: localhost + port: 6379 diff --git a/modules/jpa/src/main/resources/jpa.yml b/modules/jpa/src/main/resources/jpa.yml index 37f4fb1b..b847eac6 100644 --- a/modules/jpa/src/main/resources/jpa.yml +++ b/modules/jpa/src/main/resources/jpa.yml @@ -15,7 +15,7 @@ datasource: mysql-jpa: main: driver-class-name: com.mysql.cj.jdbc.Driver - jdbc-url: jdbc:mysql://${MYSQL_HOST}:${MYSQL_PORT} + jdbc-url: jdbc:mysql://${MYSQL_HOST:localhost}:${MYSQL_PORT:3306} username: ${MYSQL_USER} password: "${MYSQL_PWD}" pool-name: mysql-main-pool @@ -86,7 +86,13 @@ datasource: password: application --- -spring.config.activate.on-profile: prd +spring.config.activate.on-profile: test + +spring: + jpa: + show-sql: true + hibernate: + ddl-auto: create datasource: mysql-jpa: @@ -94,3 +100,6 @@ datasource: jdbc-url: jdbc:mysql://localhost:3306/loopers username: application password: application + maximum-pool-size: 10 + minimum-idle: 5 + diff --git a/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java b/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java index 9c41edac..c522e17d 100644 --- a/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java +++ b/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java @@ -1,10 +1,12 @@ package com.loopers.testcontainers; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; import org.testcontainers.containers.MySQLContainer; import org.testcontainers.utility.DockerImageName; @Configuration +@Profile("testcontainers") public class MySqlTestContainersConfig { private static final MySQLContainer mySqlContainer; diff --git a/modules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java b/modules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java index 35bf94f0..649fbc8c 100644 --- a/modules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java +++ b/modules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java @@ -2,9 +2,11 @@ import com.redis.testcontainers.RedisContainer; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; import org.testcontainers.utility.DockerImageName; @Configuration +@Profile("testcontainers") public class RedisTestContainersConfig { private static final RedisContainer redisContainer = new RedisContainer(DockerImageName.parse("redis:latest"));