Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,6 @@ out/

### Kotlin ###
.kotlin

### Study ###
docs/study/
200 changes: 200 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
# CLAUDE.md

## 프로젝트 개요

Spring Boot 기반 커머스 멀티모듈 템플릿 프로젝트 (`loopers-java-spring-template`).
REST API, 배치 처리, 이벤트 스트리밍을 위한 마이크로서비스 아키텍처 패턴을 제공한다.

## 기술 스택 및 버전

| 구분 | 기술 | 버전 |
|------|------|------|
| Language | Java | 21 |
| Language | Kotlin | 2.0.20 |
| Framework | Spring Boot | 3.4.4 |
| Framework | Spring Cloud | 2024.0.1 |
| Dependency Mgmt | spring-dependency-management | 1.1.7 |
| Database | MySQL | 8.0 |
| ORM | Spring Data JPA + QueryDSL | (Spring 관리) |
| Cache | Redis (Master-Replica) | 7.0 |
| Messaging | Apache Kafka (KRaft) | 3.5.1 |
| API Docs | SpringDoc OpenAPI | 2.7.0 |
| Monitoring | Micrometer + Prometheus | (Spring 관리) |
| Tracing | Micrometer Brave | (Spring 관리) |
| Logging | Logback + Slack Appender | 1.6.1 |
| Testing | JUnit 5, Mockito 5.14.0, SpringMockK 4.0.2, Instancio 5.0.2 |
| Testing Infra | TestContainers (MySQL, Redis, Kafka) | (Spring 관리) |
| Code Coverage | JaCoCo | (Gradle 관리) |
| Build Tool | Gradle (Kotlin DSL) | Wrapper 포함 |

## 모듈 구조

```
root
├── apps/ # 실행 가능한 Spring Boot 애플리케이션
│ ├── commerce-api # REST API 서버 (Tomcat)
│ ├── commerce-batch # Spring Batch 배치 처리
│ └── commerce-streamer # Kafka Consumer 스트리밍 서비스
├── modules/ # 재사용 가능한 인프라 모듈 (java-library)
│ ├── jpa # JPA + MySQL + QueryDSL + HikariCP
│ ├── redis # Redis Master-Replica (Lettuce)
│ └── kafka # Kafka Producer/Consumer 설정
├── supports/ # 횡단 관심사 모듈
│ ├── jackson # Jackson ObjectMapper 커스터마이징
│ ├── logging # 구조화 로깅 + Slack 연동
│ └── monitoring # Prometheus 메트릭 + Health Probe
└── docker/
├── infra-compose.yml # MySQL, Redis, Kafka, Kafka UI
└── monitoring-compose.yml # Prometheus, Grafana
```

## 아키텍처 레이어 (commerce-api 기준)

```
interfaces/api/ → REST Controller, DTO, ApiSpec
application/ → Facade (유즈케이스 오케스트레이션), Info DTO
domain/ → Entity, Repository 인터페이스, Service (비즈니스 로직)
infrastructure/ → Repository 구현체
support/error/ → CoreException, ErrorType
```

## 빌드 및 실행

```bash
# 인프라 구동
docker compose -f docker/infra-compose.yml up -d

# 모니터링 스택 (Grafana: localhost:3000, admin/admin)
docker compose -f docker/monitoring-compose.yml up -d

# 빌드
./gradlew clean build

# 실행
./gradlew :apps:commerce-api:bootRun
./gradlew :apps:commerce-batch:bootRun --args='--job.name=demoJob'
./gradlew :apps:commerce-streamer:bootRun

# 테스트
./gradlew test

# 코드 커버리지
./gradlew test jacocoTestReport
```

## 테스트 설정

- JUnit 5 기반, 테스트 프로파일: `test`, 타임존: `Asia/Seoul`
- TestContainers로 MySQL/Redis/Kafka 통합 테스트
- 모듈별 `testFixtures`로 테스트 유틸리티 공유 (`DatabaseCleanUp`, `RedisCleanUp` 등)
- 테스트 병렬 실행 없음 (`maxParallelForks = 1`)

## 프로파일

`local`, `test`, `dev`, `qa`, `prd` — 환경별 설정은 각 모듈의 yml 파일에서 프로파일 그룹으로 관리.
운영 환경은 환경변수로 주입: `MYSQL_HOST`, `REDIS_MASTER_HOST`, `BOOTSTRAP_SERVERS` 등.

## 주요 패턴

- **BaseEntity**: ID 자동생성, `createdAt`/`updatedAt` 감사, `deletedAt` 소프트 삭제
- **ApiResponse**: 통일된 응답 래퍼 (`meta.result`, `meta.errorCode`, `data`)
- **CoreException + ErrorType**: 타입 기반 에러 처리 (400, 404, 409, 500)
- **별도 관리 포트**: 메트릭/헬스체크는 8081 포트로 분리
- **Kafka 배치 소비**: 3000건 배치, 수동 커밋 (Manual ACK)
- **Redis 읽기 분산**: Master 쓰기, Replica 읽기 분리

## API 응답 규칙
- 모든 응답은 `ApiResponse<T>`로 래핑
- 성공: `ApiResponse.success(data)` 반환
- 실패: `CoreException(ErrorType)` throw → GlobalExceptionHandler에서 처리
- 생성 API: `@ResponseStatus(HttpStatus.CREATED)`

## 의존성 방향 (외부 → 내부)
```
interfaces → application → domain ← infrastructure
```
- domain 계층은 다른 계층에 의존하지 않음
- infrastructure는 domain의 Repository 인터페이스를 구현

## 문서 작성
### 다이어그램 작성
- ERD, 시퀀스 다이어그램, 클래스 다이어그램 등 작성 시 mermaid를 이용한 마크다운으로 작성.

## 개발 규칙
### 진행 Workflow - 증강 코딩
- **대원칙** : 방향성 및 주요 의사 결정은 개발자에게 제안만 할 수 있으며, 최종 승인된 사항을 기반으로 작업을 수행.
- **중간 결과 보고** : AI 가 반복적인 동작을 하거나, 요청하지 않은 기능을 구현, 테스트 삭제를 임의로 진행할 경우 개발자가 개입.
- **설계 주도권 유지** : AI 가 임의판단을 하지 않고, 방향성에 대한 제안 등을 진행할 수 있으나 개발자의 승인을 받은 후 수행.
- 구현은 한 단계씩 순서대로 진행 및 단계가 끝날 때 마다 핵심 개념/키워드 설명.
- API는 RESTFul API로 구현
### 인증 요청
- 유저 정보가 필요한 모든 요청은 아래 헤더를 통해 요청
* X-Loopers-LoginId : 로그인 ID
* X-Loopers-LoginPw : 비밀번호
- Admin 기능은 아래 헤더를 통해 Admin 식별 후 제공
* X-Loopers-Ldap : loopers.admin

### 개발 Workflow - TDD (Red > Green > Refactor)
- 모든 테스트는 3A 원칙으로 작성할 것 (Arrange - Act - Assert)
#### 1. Red Phase : 실패하는 테스트 먼저 작성
- 요구사항을 만족하는 기능 테스트 케이스 작성
- 테스트 예시
#### 2. Green Phase : 테스트를 통과하는 코드 작성
- Red Phase 의 테스트가 모두 통과할 수 있는 코드 작성
- 오버엔지니어링 금지
#### 3. Refactor Phase : 불필요한 코드 제거 및 품질 개선
- 불필요한 private 함수 지양, 객체지향적 코드 작성
- unused import 제거
- 성능 최적화
- 모든 테스트 케이스가 통과해야 함

## 주의사항
### 1. Never Do
- 실제 동작하지 않는 코드, 불필요한 Mock 데이터를 이용한 구현을 하지 말 것
- null-safety 하지 않게 코드 작성하지 말 것 (Java 의 경우, Optional 을 활용할 것)
- println 코드 남기지 말 것
- 객체지향 5원칙을 어기지 말 것

### 2. Recommendation
- 실제 API 를 호출해 확인하는 E2E 테스트 코드 작성
- 재사용 가능한 객체 설계
- 성능 최적화에 대한 대안 및 제안
- 개발 완료된 API 의 경우, `.http/**.http` 에 분류해 작성
- Domain Entity와 Persistence Entity는 구분하여 구현
- 필요한 의존성은 적절히 관리하여 최소화
- 통합 테스트는 테스트 컨테이너를 이용해 진행
- 테스트 코드 작성 시 MIN, MAX, EDGE 케이스를 고려하여 작성
- Lombok 활용이 가능한 부분은 Lombok을 활용하여 코드를 간결하게 작성
- VO (Value Object) 활용이 가능한 부분은 VO를 활용하여 코드를 간결하게 작성하되 남발하지 말것

### 3. Priority
1. 실제 동작하는 해결책만 고려
2. null-safety, thread-safety 고려
3. 테스트 가능한 구조로 설계
4. 기존 코드 패턴 분석 후 일관성 유지

## 깃 커밋 컨벤션
- feat: 새로운 기능 추가
- fix: 버그 수정
- docs: 문서만 수정 (예: README, 주석은 아님)
- style: 코드 포맷팅 (공백, 세미콜론 등 기능 변화 없음)
- refactor: 기능 변화 없이 코드 개선
- test: 테스트 코드 추가/수정
- chore: 빌드/패키지 설정 등 기능과 직접 관련 없는 작업
- 커밋 메세지는 한국어로 작성할 것

## 도메인 & 객체 설계 전략
- 도메인 객체는 비즈니스 규칙을 캡슐화해야 합니다.
- 애플리케이션 서비스는 서로 다른 도메인을 조립해, 도메인 로직을 조정하여 기능을 제공해야 합니다.
- 규칙이 여러 서비스에 나타나면 도메인 객체에 속할 가능성이 높습니다.
- 각 기능에 대한 책임과 결합도에 대해 개발자의 의도를 확인하고 개발을 진행합니다.

## 아키텍처, 패키지 구성 전략
- 본 프로젝트는 레이어드 아키텍처를 따르며, DIP (의존성 역전 원칙) 을 준수합니다.
- API request, response DTO와 응용 레이어의 DTO는 분리해 작성하도록 합니다.
- 패키징 전략은 4개 레이어 패키지를 두고, 하위에 도메인 별로 패키징하는 형태로 작성합니다.
- 예시
> /interfaces/api (presentation 레이어 - API)
/application/.. (application 레이어 - 도메인 레이어를 조합해 사용 가능한 기능을 제공)
/domain/.. (domain 레이어 - 도메인 객체 및 엔티티, Repository 인터페이스가 위치)
/infrastructure/.. (infrastructure 레이어 - JPA, Redis 등을 활용해 Repository 구현체를 제공)
4 changes: 4 additions & 0 deletions apps/commerce-api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,12 @@ dependencies {
implementation(project(":supports:logging"))
implementation(project(":supports:monitoring"))

// security
implementation("org.springframework.security:spring-security-crypto")

// web
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}")

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.loopers.application.address;

public class AddressCommand {

public record Create(
String recipientName,
String phone,
String zipCode,
String address,
String addressDetail
) {
}

public record Update(
String recipientName,
String phone,
String zipCode,
String address,
String addressDetail
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package com.loopers.application.address;

import com.loopers.domain.address.Address;
import com.loopers.domain.address.AddressService;
import com.loopers.domain.member.Member;
import com.loopers.domain.member.MemberService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Component
@RequiredArgsConstructor
public class AddressFacade {

private final AddressService addressService;
private final MemberService memberService;

@Transactional(readOnly = true)
public List<AddressInfo> getAddresses(String loginId, String password) {
Member member = memberService.authenticate(loginId, password);
return addressService.getAddresses(member.getId())
.stream()
.map(AddressInfo::from)
.toList();
}

@Transactional
public AddressInfo register(String loginId, String password, AddressCommand.Create command) {
Member member = memberService.authenticate(loginId, password);
Address address = new Address(
member.getId(),
command.recipientName(),
command.phone(),
command.zipCode(),
command.address(),
command.addressDetail()
);
Address savedAddress = addressService.register(address);
return AddressInfo.from(savedAddress);
}

@Transactional
public AddressInfo update(String loginId, String password, Long addressId, AddressCommand.Update command) {
Member member = memberService.authenticate(loginId, password);
Address updatedAddress = addressService.update(
member.getId(),
addressId,
command.recipientName(),
command.phone(),
command.zipCode(),
command.address(),
command.addressDetail()
);
return AddressInfo.from(updatedAddress);
}

@Transactional
public void delete(String loginId, String password, Long addressId) {
Member member = memberService.authenticate(loginId, password);
addressService.delete(member.getId(), addressId);
}

@Transactional
public AddressInfo setDefault(String loginId, String password, Long addressId) {
Member member = memberService.authenticate(loginId, password);
Address updatedAddress = addressService.setDefault(member.getId(), addressId);
return AddressInfo.from(updatedAddress);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.loopers.application.address;

import com.loopers.domain.address.Address;

public record AddressInfo(
Long id,
String recipientName,
String phone,
String zipCode,
String address,
String addressDetail,
boolean isDefault
) {

public static AddressInfo from(Address address) {
return new AddressInfo(
address.getId(),
address.getRecipientName(),
address.getPhone(),
address.getZipCode(),
address.getAddress(),
address.getAddressDetail(),
address.isDefault()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.loopers.application.brand;

public class BrandCommand {

public record Create(
String name,
String description,
String logoImageUrl
) {}

public record Update(
String name,
String description,
String logoImageUrl
) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.loopers.application.brand;

import com.loopers.domain.brand.Brand;

import java.time.LocalDateTime;

public record BrandDetailInfo(
Long id,
String name,
String description,
String logoImageUrl,
Long likeCount,
LocalDateTime createdAt,
LocalDateTime updatedAt,
LocalDateTime deletedAt
) {
public static BrandDetailInfo from(Brand brand) {
return new BrandDetailInfo(
brand.getId(),
brand.getName(),
brand.getDescription(),
brand.getLogoImageUrl(),
brand.getLikeCount(),
brand.getCreatedAt(),
brand.getUpdatedAt(),
brand.getDeletedAt()
);
}
}
Loading