From f272b119fa992d57a6c4baee3acab3c4b05ad9a4 Mon Sep 17 00:00:00 2001 From: Hwan0518 Date: Fri, 30 Jan 2026 14:44:47 +0900 Subject: [PATCH] =?UTF-8?q?chore:=20md-ko-ver=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EA=B3=B5=EA=B0=9C=EC=9A=A9=20gitignore=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 +- md-ko-ver/AGENTS.ko.md | 918 ++++++++++++++++++ md-ko-ver/CLAUDE.ko.md | 918 ++++++++++++++++++ md-ko-ver/claude/commands/check-all.ko.md | 78 ++ md-ko-ver/claude/commands/check.ko.md | 54 ++ .../claude/commands/unit-test-generate.ko.md | 238 +++++ md-ko-ver/claude/skills/commit-ko.md | 43 + md-ko-ver/claude/skills/test-code.ko.md | 599 ++++++++++++ 8 files changed, 2851 insertions(+), 1 deletion(-) create mode 100644 md-ko-ver/AGENTS.ko.md create mode 100644 md-ko-ver/CLAUDE.ko.md create mode 100644 md-ko-ver/claude/commands/check-all.ko.md create mode 100644 md-ko-ver/claude/commands/check.ko.md create mode 100644 md-ko-ver/claude/commands/unit-test-generate.ko.md create mode 100644 md-ko-ver/claude/skills/commit-ko.md create mode 100644 md-ko-ver/claude/skills/test-code.ko.md diff --git a/.gitignore b/.gitignore index e07f59e..508eab8 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,9 @@ .claude/*.log .claude/transcripts/ .claude/skills/incentive/ -md-ko-ver/ +md-ko-ver/**/incentive*.ko.md +md-ko-ver/claude/skills/incentive-ko.md +md-ko-ver/**/skill.incentive.ko.md 계획.md ### gradle ### diff --git a/md-ko-ver/AGENTS.ko.md b/md-ko-ver/AGENTS.ko.md new file mode 100644 index 0000000..b542518 --- /dev/null +++ b/md-ko-ver/AGENTS.ko.md @@ -0,0 +1,918 @@ +# AGENTS.md (한국어) + +이 파일은 이 저장소에서 작업할 때 Claude Code (claude.ai/code)를 위한 가이드를 제공합니다. + +--- + +## 🚨 CRITICAL WARNING - E2E 테스트 데이터 삭제 금지 + +**E2E 테스트 작성 시 `deleteAllInBatch()` 절대 사용 금지!** + +- 2026-01-23: E2E 테스트의 `@AfterEach`에서 `deleteAllInBatch()` 사용으로 **실제 운영 DB 데이터 전체 삭제 사고 발생** +- E2E 테스트는 실제 DB를 사용하므로, 테스트에서 **생성한 데이터 ID만 추적하여 삭제**해야 함 +- 자세한 내용은 하단 **"E2E 테스트 주의사항"** 섹션 참조 +- **테스트 프로필은 반드시 분리된 DB만 사용** (기본: `greenfirst_test`) +- 테스트 프로필에서 **non-test DB 접속 시 부팅 실패**하도록 가드 추가됨 + (예외적으로 필요하면 `safety.testdb.allow-non-test=true` 설정) + +--- + +## Build & Development Commands + +이 프로젝트는 Spring Boot 3.3.9, Java 17, Gradle을 사용합니다. + +**Build and Run:** + +- `./gradlew build` - 애플리케이션 빌드 +- `./gradlew bootRun` - 로컬 실행 +- `./gradlew test` - 전체 테스트 실행 +- `./gradlew clean` - 빌드 산출물 정리 + +**Testing:** + +- `./gradlew test --tests "*.UnitTest"` - 유닛 테스트만 실행 +- `./gradlew test --tests "*.*IntegrationTest"` - 통합 테스트 실행 +- `./gradlew test --continuous` - 테스트를 연속 실행 모드로 실행 + +**Development:** + +- 기본 프로필은 `local` (see `application.yml`) +- 실행 시 Swagger UI는 `/swagger-ui.html`에서 제공 +- API 문서는 `/api-docs` + +## Architecture Overview + +이 애플리케이션은 **헥사고날 아키텍처(Ports & Adapters)** 및 DDD 원칙을 따릅니다. + +### Package Structure: + +``` +src/main/java/greenfirst/be/ +├── global/ # Cross-cutting concerns, configs, utilities +│ ├── common/ # Base entities, responses, exceptions, security +│ └── config/ # Spring configurations, security, CORS, etc. +└── [domain]/ # Business domains (user, item, trade, branch, stats) + ├── adapter/ + │ ├── in/web/ # Controllers, requests, responses + │ └── out/persistence/ # JPA entities, repositories, implementations + ├── application/ # Application services, DTOs, ports, facades + │ ├── dto/ # Input/Output DTOs + │ ├── port/out/ # Repository interfaces (ports) + │ ├── service/ # Application services + │ └── facade/ # Complex workflows + └── domain/ # Core domain logic, models, commands + ├── command/ # Command objects for domain operations + ├── entity/ # Domain entities + └── model/ # Domain models +``` + +### Key Domains: + +- **user** - Authentication, user management (Admin, Partner types) +- **item** - Item and ItemType management with hierarchical relationships +- **trade** - Trading operations, carbon emission calculations +- **branch** - Branch management for partners +- **stats** - Statistics tracking (daily, monthly, total) for partners and branches + +### Architecture Patterns: + +- **Ports & Adapters**: 어댑터(web, persistence)와 핵심 비즈니스 로직을 명확히 분리 +- **Command Pattern**: 도메인 연산에 Command 객체 사용 (e.g., `CreateItemTypeCommand`) +- **Facade Pattern**: 복잡한 워크플로우에 Facade 사용 (e.g., `UserSignUpFacade`, `CreateTradeFacade`) +- **Repository Pattern**: 도메인과 영속성 레이어 간 명확한 추상화 + +### Key Technologies: + +- Spring Boot 3.3.9 with Spring Security +- JPA with QueryDSL for complex queries +- MariaDB database +- JWT authentication +- ModelMapper for DTO mapping +- Swagger/OpenAPI documentation +- Redis for caching +- Slack integration for notifications + +### Development Patterns: + +- 외부 통신은 DTO만 사용 +- ModelMapper로 레이어 간 매핑 +- BaseEntity for audit fields +- Global exception handling with BaseException +- Spring Event 기반 이벤트 드리븐 아키텍처 +- Bean Validation을 통한 검증 + +### Web Layer (Controller) Patterns: + +**디렉토리 구조:** +``` +[domain]/adapter/in/web/ +├── controller/ # Controller 구현체 (OpenAPI 어노테이션 직접 사용) +│ └── GetXxxController.java +├── request/ # 요청 DTO +└── response/ # 응답 DTO +``` + +**IMPORTANT**: API 스펙 인터페이스를 만들지 말고, Controller 메서드에 OpenAPI 어노테이션을 직접 붙입니다. `api/` 또는 `api/spec/` 디렉토리는 사용하지 않습니다. +**IMPORTANT**: `@ApiResponse`/`@ApiResponses` 어노테이션은 사용하지 않습니다. + +**DTO → Response 변환:** +- Controller에서 직접 변환 로직을 작성하지 않습니다 +- Response 클래스에 `static from()` 메서드를 정의하여 변환합니다 +- 단순 매핑은 ModelMapper를 사용할 수 있습니다 + +**예시:** +```java +// Response 클래스에서 변환 메서드 정의 +public class IncentiveListResponse { + public static IncentiveListResponse from(IncentiveListOutDto outDto) { + // 변환 로직 + } +} + +// Controller에서 사용 +IncentiveListResponse response = IncentiveListResponse.from(outDto); +``` + +### Testing Structure: + +- 유닛 테스트는 'application'과 'domain' 레이어에만 작성 +- 유닛 테스트 네이밍 컨벤션: `*UnitTest.java` +- 테스트용 Fake 구현체 사용 (e.g., `FakeUserCommandRepository`) +- 테스트 데이터 fixture 사용 (e.g., `UserTestFixture`, `ItemTypeTestFixture`) +- 도메인 중심 테스트 + 의존성 mock + +### ⚠️ E2E 테스트 주의사항 (CRITICAL - 반드시 숙지): + +**❌ 절대 금지:** +```java +@AfterEach +void tearDown() { + // ❌ 절대 이렇게 하지 마세요! 실제 운영/개발 데이터가 모두 삭제됩니다! + userJpaRepository.deleteAllInBatch(); + tradeJpaRepository.deleteAllInBatch(); + // ... 기타 deleteAllInBatch() 호출 +} +``` + +**✅ 반드시 이렇게:** +```java +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") // test profile 사용 확인 +class SomeE2ETest { + + // 테스트에서 생성한 데이터 ID 추적 + private final List createdUserUuids = new ArrayList<>(); + private final List createdTradeIds = new ArrayList<>(); + + @Test + void someTest() { + // 데이터 생성 시 ID 추적 + UUID userId = UUID.randomUUID(); + UserEntity user = userJpaRepository.save(...); + createdUserUuids.add(userId); // ← 반드시 추적 + + TradeEntity trade = tradeJpaRepository.save(...); + createdTradeIds.add(trade.getId()); // ← 반드시 추적 + } + + @AfterEach + void tearDown() { + // ✅ 테스트에서 생성한 데이터만 삭제 (FK 역순 고려) + if (!createdTradeIds.isEmpty()) { + tradeJpaRepository.deleteAllById(createdTradeIds); + } + if (!createdUserUuids.isEmpty()) { + createdUserUuids.forEach(uuid -> { + userJpaRepository.findByUserUuid(uuid).ifPresent(userJpaRepository::delete); + }); + } + + // 추적 리스트 초기화 + createdTradeIds.clear(); + createdUserUuids.clear(); + } +} +``` + +**왜 중요한가:** +- E2E 테스트는 실제 DB를 사용합니다 (`@ActiveProfiles("test")` 사용 시에도) +- `deleteAllInBatch()`는 **테이블의 모든 데이터를 삭제**합니다 +- 2026년 1월 23일, E2E 테스트 실행으로 인해 **실제 운영 데이터가 전부 삭제되는 사고 발생** +- 복구 불가능한 데이터 손실이 발생할 수 있습니다 + +**필수 체크리스트:** +1. ✅ E2E 테스트는 반드시 `@ActiveProfiles("test")` 사용 +2. ✅ `application-test.yml`이 별도 테스트 DB를 가리키는지 확인 +3. ✅ `@AfterEach`에서 생성한 데이터 ID만 추적하여 삭제 +4. ✅ **절대 `deleteAllInBatch()` 사용 금지** +5. ✅ FK 제약조건을 고려하여 역순으로 삭제 (자식 → 부모) + +## Cross-Domain Communication Rules + +**CRITICAL**: Bounded Context 간의 느슨한 결합을 유지하기 위해 다음 규칙을 반드시 준수해야 합니다. + +### 1. 직접 호출 금지 + +다른 Bounded Context의 Service, Repository, Domain Model을 직접 호출/의존하지 않습니다. + +**DON'T**: +```java +// incentive 도메인에서 user 도메인 직접 의존 ❌ +@Service +public class IncentiveManagementService { + private final UserQueryRepository userQueryRepository; // 다른 도메인 Repository + private final GetUserDataService getUserDataService; // 다른 도메인 Service +} + +// trade 도메인에서 incentive 도메인 직접 호출 ❌ +@Service +public class CreateTradeFacade { + private final IncentiveManagementService incentiveManagementService; + + public void createTrade(...) { + incentiveManagementService.createEarnIncentives(trade); // 직접 호출 + } +} +``` + +### 2. Spring Event 기반 통신 + +도메인 간 통신은 Spring Event를 사용합니다. + +**DO**: +```java +// 이벤트 발행 (trade 도메인) +TradeCreatedEvent event = TradeCreatedEvent.of(trade, partnerRelations); +eventPublisher.publishEvent(event); + +// 이벤트 수신 (incentive 도메인) +@Component +public class TradeCreatedEventHandler { + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handleTradeCreatedEvent(TradeCreatedEvent event) { + incentiveManagementService.createEarnIncentivesFromEvent(event); + } +} +``` + +### 3. Anti-Corruption Layer (ACL) 사용 + +다른 도메인의 데이터가 필요한 경우, ACL Port/Adapter를 통해 격리합니다. + +**패턴**: +``` +[domain]/application/port/out/user/PartnerRelationQueryPort.java # Port 인터페이스 +[domain]/adapter/out/persistence/repository/user/PartnerRelationQueryPortImpl.java # Adapter 구현 +``` + +**DO**: +```java +// Port 인터페이스 (application 계층) +public interface PartnerRelationQueryPort { + List findPartnerRelationsBySellerUuid(UUID sellerUuid); +} + +// Adapter 구현 (adapter 계층) - 여기서만 다른 도메인 의존 +@Repository +public class PartnerRelationQueryPortImpl implements PartnerRelationQueryPort { + private final PartnerRelationshipQueryRepository partnerRelationshipQueryRepository; + private final GetUserDataService getUserDataService; + // ... +} +``` + +### 4. 이벤트에 필요한 정보 포함 + +이벤트는 가능한 Self-Contained 되어야 합니다. 이벤트 수신 측에서 다른 도메인을 조회하지 않도록 필요한 정보를 이벤트에 포함합니다. + +**DO**: +```java +@Getter +@Builder +public class TradeCreatedEvent { + private Long tradeId; + private String tradeNumber; + // ... trade 정보 + + // 인센티브 생성에 필요한 상위 파트너 정보도 포함 + private List partnerRelationInfos; +} +``` + +### 5. 디렉토리 구조 + +``` +[domain]/ +├── domain/event/ # Domain Event 클래스 +│ ├── TradeCreatedEvent.java +│ └── TradeCancelledEvent.java +├── adapter/ +│ ├── in/event/ # Event Handler (구독 측) +│ │ └── TradeCreatedEventHandler.java +│ └── out/ +│ ├── event/ # Event Publisher Adapter +│ │ └── TradeEventPublisherAdapter.java +│ └── persistence/repository/user/ # ACL Adapter +│ └── PartnerRelationQueryPortImpl.java +└── application/ + └── port/out/ + ├── util/ # Event Publisher Port + │ └── TradeEventPublisher.java + └── user/ # ACL Port + └── PartnerRelationQueryPort.java +``` + +### 6. 트랜잭션 전략 + +- **BEFORE_COMMIT**: 원본 트랜잭션과 동일 트랜잭션에서 처리 (데이터 정합성 보장) +- **AFTER_COMMIT**: 원본 트랜잭션 커밋 후 별도 처리 (느슨한 결합) + +## Minimal Boundary Enforcement (Worktree 적용 전 필수) + +병렬 작업 충돌을 줄이기 위한 **최소한의 경계 강화 규칙**입니다. Worktree 적용 전/후 모두 적용합니다. + +### 1. 도메인 단위 작업 고정 + +- 한 작업은 반드시 **한 도메인만** 수정합니다. (user, trade, incentive, item, branch, stats) +- 허용 경로: + - `src/main/java/greenfirst/be//**` + - `src/test/java/greenfirst/be//**` + +### 2. 공통/글로벌 수정은 승인 필수 + +다음 경로 수정은 **사전에 승인**을 받아야 합니다: +- `src/main/java/greenfirst/be/global/**` +- `build.gradle`, `settings.gradle` +- 공통 설정 파일 (`application*.yml`, `logback`, 보안 설정 등) + +### 3. 다른 도메인 접근 필요 시 절차 + +다른 도메인 코드가 필요하면: +1. 필요한 변경 사항을 먼저 제안한다. +2. **승인 받은 후에만** 다른 도메인을 수정한다. + +### 4. 허용되는 교차 도메인 방식 + +- Spring Event 기반 통신 +- ACL Port/Adapter 사용 +- 직접 Service/Repository/Domain 참조는 금지 + +## Domain Model & Persistence Model Design Rules + +이 애플리케이션은 **도메인 모델(순수 비즈니스 로직)** 과 **영속성 엔티티(JPA/DB 레이어)** 를 엄격히 분리합니다. 이 규칙은 클린 아키텍처 유지에 매우 중요합니다. + +### 1. Core Principles + +**Domain Layer** (`[domain]/domain/model/`): +- JPA 어노테이션이 없는 순수 Java POJO +- 비즈니스 로직과 검증을 포함 +- 다른 엔티티는 객체 참조가 아닌 ID로만 참조 +- DB/영속성 관심사로부터 독립 + +**Persistence Layer** (`[domain]/adapter/out/persistence/entity/`): +- JPA 어노테이션이 있는 엔티티 (@Entity, @Table, @Column) +- 비즈니스 로직 없음 (데이터 구조만) +- @OneToMany, @ManyToOne 등으로 관계 매핑 +- 감사 필드를 위한 BaseTimeEntity 상속 + +**Conversion happens at Repository Layer**: +- ModelMapper로 양방향 변환 +- Repository 구현체에서 도메인과 엔티티를 브릿지 +- 모든 변환은 명시적으로 수행 + +### 2. Naming Conventions + +| Layer | Pattern | Example | Location | +|-------|---------|---------|----------| +| **Domain Model** | Singular noun | `Users`, `Item`, `Trade` | `[domain]/domain/model/` | +| **Domain VO** | Descriptive name | `BranchOption`, `UserAddress` | `[domain]/domain/model/vo/` | +| **Persistence Entity** | Noun + "Entity" | `UserEntity`, `ItemEntity` | `[domain]/adapter/out/persistence/entity/` | +| **Persistence VO** | Name + "Vo" | `BranchOptionVo`, `UserAddressVo` | `[domain]/adapter/out/persistence/entity/vo/` | + +### 3. Domain Model Structure + +**Required Annotations**: +```java +@Getter +@NoArgsConstructor +@Builder(toBuilder = true) +public class Item { + // Fields with NO JPA annotations +} +``` + +**Key Characteristics**: +- @Entity나 @Table 같은 JPA 어노테이션 사용 금지 +- 순수 Java 타입 사용 (Long id, String name 등) +- 다른 엔티티는 ID로만 참조 (e.g., `Long itemTypeId`, not `ItemType itemType`) +- 비즈니스 로직 메서드 포함 (검증, 계산, 상태 변경) +- 도메인에서 타임스탬프 수동 관리 (createdAt, updatedAt, deletedAt for soft delete) + +**Example - Item Domain Model**: +```java +@Getter +@NoArgsConstructor +@Builder(toBuilder = true) +public class Item { + private Long id; + private String itemName; + private String itemCode; + private Boolean isEnabled; + private Integer itemTypeId; // ID reference, not object + private Integer unitPrice; + private String unit; + private Long carbonEmissionFactorId; // ID reference, not object + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + // Static factory method + public static Item from(CreateItemCommand command) { + return Item.builder() + .itemName(command.getItemName()) + .itemCode(command.getItemCode()) + .isEnabled(command.getIsEnabled()) + .itemTypeId(command.getItemTypeId()) + .unitPrice(command.getUnitPrice()) + .unit(command.getUnit()) + .carbonEmissionFactorId(command.getCarbonEmissionFactorId()) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .build(); + } + + // Business logic method + public void updateItemInfo(UpdateItemCommand command) { + this.itemName = command.getItemName(); + this.itemCode = command.getItemCode(); + this.isEnabled = command.getIsEnabled(); + this.updatedAt = LocalDateTime.now(); + } +} +``` + +### 4. Persistence Entity Structure + +**Required Annotations**: +```java +@Entity +@Table(name = "table_name") +@Getter +@Builder(toBuilder = true) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class ItemEntity extends BaseTimeEntity { + // Fields with JPA annotations +} +``` + +**Key Characteristics**: +- 반드시 `BaseTimeEntity` 상속 (createdAt, updatedAt 제공) +- 모든 필드에 @Column으로 DB 매핑 +- 필요 시 @OneToMany, @ManyToOne으로 관계 정의 +- 비즈니스 로직 메서드 금지 +- no-args constructor는 protected, all-args constructor는 private + +**Example - ItemEntity Persistence Model**: +```java +@Entity +@Table(name = "item") +@Getter +@Builder(toBuilder = true) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class ItemEntity extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "item_id") + private Long id; + + @Column(name = "item_name", length = 100, nullable = false) + private String itemName; + + @Column(name = "item_code", length = 50, unique = true, nullable = false) + private String itemCode; + + @Column(name = "is_enabled", nullable = false) + private Boolean isEnabled; + + @Column(name = "item_type_id") + private Integer itemTypeId; + + @Column(name = "unit_price") + private Integer unitPrice; + + @Column(name = "unit", length = 20) + private String unit; + + @Column(name = "carbon_emission_factor_id") + private Long carbonEmissionFactorId; +} +``` + +### 5. Value Objects (Embedded Objects) + +**Domain Value Objects** (`[domain]/domain/model/vo/`): +- @Embeddable 사용 금지 +- 단순 @Getter, @NoArgsConstructor, @Builder +- 검증 어노테이션 사용 (@NotNull, @NotBlank) + +**Persistence Value Objects** (`[domain]/adapter/out/persistence/entity/vo/`): +- 반드시 @Embeddable 사용 +- protected/private constructors +- 각 필드에 @Column 정의 + +**Example - BranchOption**: + +Domain VO: +```java +@Getter +@NoArgsConstructor +@Builder +public class BranchOption { + @NotNull + private Long branchId; + + @NotBlank + private String branchName; +} +``` + +Persistence VO: +```java +@Getter +@Builder +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class BranchOptionVo { + @Column(name = "branch_id") + private Long branchId; + + @Column(name = "branch_name", length = 100) + private String branchName; +} +``` + +Usage in Entity: +```java +@Entity +@Table(name = "users") +public class UserEntity extends BaseTimeEntity { + @Embedded + private BranchOptionVo branchOption; +} +``` + +### 6. Relationship Mapping Rules + +**CRITICAL**: 관계는 영속성 레이어에만 존재하며, 도메인 모델에는 존재하지 않습니다. + +**Domain Models use IDs**: +```java +@Getter +@Builder +public class TradeItem { + private Long id; + private Long tradeId; // Just the ID + private Long itemId; // Just the ID + private Integer quantity; +} +``` + +**Persistence Entities define relationships**: +```java +@Entity +@Table(name = "trade_item") +public class TradeItemEntity extends BaseTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "trade_id", nullable = false) + private TradeEntity trade; + + @Column(name = "item_id") + private Long itemId; + + @Column(name = "quantity") + private Integer quantity; + + // Helper method to extract ID + public Long getTradeId() { + return trade != null ? trade.getId() : null; + } +} +``` + +**Relationship Guidelines**: +- 성능을 위해 항상 `FetchType.LAZY` 사용 +- FK 컬럼명은 `@JoinColumn`으로 지정 +- 필요한 경우 ID 추출 helper 메서드 제공 +- 꼭 필요하지 않으면 양방향 매핑 지양 + +### 7. Repository Conversion Pattern + +**Command Repository** (save operations): +```java +@Repository +@RequiredArgsConstructor +public class ItemCommandRepositoryImpl implements ItemCommandRepository { + private final ItemJpaRepository itemJpaRepository; + private final ModelMapper modelMapper; + + @Override + public Item save(Item item) { + // Domain → Entity + ItemEntity itemEntity = modelMapper.map(item, ItemEntity.class); + + // Persist + ItemEntity savedEntity = itemJpaRepository.save(itemEntity); + + // Entity → Domain + return modelMapper.map(savedEntity, Item.class); + } +} +``` + +**Query Repository** (read operations): +```java +@Repository +@RequiredArgsConstructor +public class ItemQueryRepositoryImpl implements ItemQueryRepository { + private final ItemJpaRepository itemJpaRepository; + private final ModelMapper modelMapper; + + @Override + public Item findById(Long id) { + ItemEntity entity = itemJpaRepository.findById(id) + .orElseThrow(() -> new BaseException(BaseResponseStatus.NO_EXIST_ITEM)); + + // Entity → Domain + return modelMapper.map(entity, Item.class); + } + + @Override + public List findAll() { + return itemJpaRepository.findAll().stream() + .map(entity -> modelMapper.map(entity, Item.class)) + .toList(); + } +} +``` + +### 8. Static Factory Methods + +도메인 모델 생성 시 static factory method를 사용합니다: + +```java +// Domain Model +public class Item { + public static Item from(CreateItemCommand command) { + return Item.builder() + .itemName(command.getItemName()) + .itemCode(command.getItemCode()) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .build(); + } +} + +// Usage in Service +public class ItemService { + public void createItem(CreateItemCommand command) { + Item item = Item.from(command); + itemCommandRepository.save(item); + } +} +``` + +### 9. Audit Fields Management + +**Persistence Layer** (BaseTimeEntity로 자동 처리): +```java +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseTimeEntity { + @CreatedDate + @Column(updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime updatedAt; +} +``` + +**Domain Layer** (수동 관리): +```java +@Getter +@Builder(toBuilder = true) +public class Item { + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private LocalDateTime deletedAt; // For soft delete + + public void softDelete() { + this.deletedAt = LocalDateTime.now(); + } +} +``` + +### 10. UUID and Numeric Type Handling + +**UUID Fields (CRITICAL)**: + +영속성 엔티티의 UUID 필드는 저장 효율을 위해 반드시 `BINARY(16)` 컬럼을 사용합니다: + +```java +// Persistence Entity +@Entity +@Table(name = "users") +public class UserEntity extends BaseTimeEntity { + @Column(name = "user_uuid", nullable = false, columnDefinition = "BINARY(16)", unique = true) + private UUID userUuid; +} + +@Entity +@Table(name = "trade") +public class TradeEntity extends BaseTimeEntity { + @Column(name = "seller_uuid", columnDefinition = "BINARY(16)", nullable = false) + private UUID sellerUuid; + + @Column(name = "buyer_uuid", columnDefinition = "BINARY(16)", nullable = false) + private UUID buyerUuid; +} +``` + +**Domain models**는 어노테이션 없이 UUID 타입만 사용: +```java +@Getter +@Builder(toBuilder = true) +public class Users { + private UUID userUuid; +} + +@Getter +@Builder(toBuilder = true) +public class Trade { + private UUID sellerUuid; + private UUID buyerUuid; +} +``` + +**BigDecimal and BigInteger Usage**: + +`BigDecimal` 사용 대상: +- **Weights** (무게): `totalWeight`, `payloadWeight`, `curbWeight` +- **Rates/Ratios** (비율): `totalWeightLossRate`, `emissionFactorKgCo2ePerKg` +- **Carbon values** (탄소 관련): `carbonReductionAmount` +- **Prices with decimals** (소수점 가격): `userTradeItemUnitPrice` + +`BigInteger` 사용 대상: +- **Monetary amounts** (금액): `tradeAmount`, `totalBuyAmount`, `totalSellAmount` +- **Large integer values** without decimal points + +`Integer` 사용 대상: +- **Counts**: `totalBuyCount`, `totalSellCount` +- **Unit prices** when stored as integers: `unitPrice` +- **Small numeric IDs or codes** + +**Example - Trade Entity with proper numeric types**: +```java +@Entity +@Table(name = "trade") +public class TradeEntity extends BaseTimeEntity { + @Column(name = "total_weight", precision = 10, scale = 2) + private BigDecimal totalWeight; + + @Column(name = "total_weight_loss_rate", precision = 5, scale = 2) + private BigDecimal totalWeightLossRate; + + @Column(name = "trade_amount", nullable = false) + private BigInteger tradeAmount; // 금액은 BigInteger + + @Column(name = "carbon_reduction_amount", precision = 10, scale = 2) + private BigDecimal carbonReductionAmount; +} +``` + +**Example - Domain Model with numeric types**: +```java +@Getter +@Builder(toBuilder = true) +public class Trade { + private BigDecimal totalWeight; + private BigDecimal curbWeight; + private BigDecimal payloadWeight; + private BigDecimal totalWeightLoss; + private BigDecimal totalWeightLossRate; + private BigInteger tradeAmount; // 금액 + private BigDecimal carbonReductionAmount; + + // Business logic using BigDecimal + public BigDecimal calculateWeightLoss() { + if (totalWeight != null && payloadWeight != null) { + return totalWeight.subtract(payloadWeight); + } + return BigDecimal.ZERO; + } +} +``` + +**Guidelines**: +- 0 값은 `new BigDecimal(0)` 대신 `BigDecimal.ZERO` 사용 +- BigDecimal 비교는 `equals()` 대신 `compareTo()` 사용 +- BigDecimal 필드는 @Column에 `precision`과 `scale` 지정 +- BigDecimal 연산 전에 null 체크 + +### 11. Common Patterns Summary + +**DO**: +- ✅ 도메인 모델에 JPA 어노테이션을 붙이지 않는다 +- ✅ 도메인 모델에서 다른 엔티티는 ID로만 참조한다 +- ✅ 관계(@ManyToOne 등)는 영속성 엔티티에만 둔다 +- ✅ Domain ↔ Entity 변환은 ModelMapper 사용 +- ✅ 비즈니스 로직은 도메인 모델에 둔다 +- ✅ 도메인 생성은 static factory method 사용 (from, of) +- ✅ 모든 영속성 엔티티는 BaseTimeEntity 상속 +- ✅ protected/private 생성자 + Builder 패턴 사용 +- ✅ UUID는 BINARY(16) 컬럼 사용 +- ✅ BigDecimal은 무게/비율/탄소/소수점 가격에 사용 +- ✅ BigInteger는 금액에 사용 +- ✅ BigDecimal.ZERO 사용 및 compareTo() 비교 + +**DON'T**: +- ❌ 도메인 모델에 JPA 어노테이션 추가 금지 +- ❌ 다른 도메인 객체를 직접 참조 금지 (ID 사용) +- ❌ 영속성 엔티티에 비즈니스 로직 금지 +- ❌ 엔티티에 public no-args constructor 금지 +- ❌ 도메인 VO에 @Embeddable 사용 금지 (영속성 VO에만 사용) +- ❌ 특별한 이유 없이 양방향 관계 금지 +- ❌ FetchType.EAGER 사용 금지 (항상 LAZY) +- ❌ UUID를 VARCHAR/CHAR(36)로 저장 금지 +- ❌ 금액/정밀도가 필요한 값에 Float/Double 사용 금지 +- ❌ BigDecimal 비교에 equals() 사용 금지 + +## Writing Correct Unit Tests + +**CRITICAL: Tests must verify business logic, not just pass.** + +### Before Writing Tests: + +1. **Always read the actual implementation first** + - 실제 Repository 구현을 먼저 읽는다 (e.g., `*QueryRepositoryImpl.java`) + - QueryDSL 유틸리티 확인 (e.g., `*QuerydslUtil.java`) + - 예외 처리 및 엣지 케이스 확인 + - 반환 타입 및 오류 조건 포함 전체 동작 이해 + +2. **Fake implementations must mirror real behavior exactly** + - 예외 타입과 조건 일치 (e.g., 데이터 없으면 `BaseException`) + - 기본값 동일 (e.g., default orderField, orderType) + - 필터 로직 동일 (e.g., tradeType=ALL 시 userUuid로 필터) + - 경계 처리 동일 (e.g., date range between은 경계 포함) + +### Writing Test Cases: + +1. **Test business logic, not just happy paths** + - 접근 제어 검증 (e.g., `Trade.validateSearchAccess()`) + - 예외 시나리오 (e.g., 엔티티 없음) + - 엣지 케이스 (e.g., 빈 결과, 경계값) + - 복잡한 필터 조합 + +2. **Common mistakes to avoid** + - ❌ Fake가 null 반환, 실제는 예외 (오류) + - ❌ Fake가 전체 반환, 실제는 사용자 필터 + - ❌ Fake 기본값이 실제와 다름 + - ❌ Fake 로직이 실제와 달라 카운트가 틀림 + - ❌ 테스트가 실제 동작 검증 없이 통과 + +3. **Verification checklist** + - ✅ Fake가 실제와 같은 예외를 던지는가? + - ✅ Fake가 동일한 필터(특히 보안 관련)를 적용하는가? + - ✅ Fake의 기본값이 실제와 같은가? + - ✅ 테스트가 도메인 로직(접근 제어 등)을 검증하는가? + - ✅ 엣지/오류 케이스가 포함되었는가? + +### Example Pattern: + +```java +// WRONG: Fake that doesn't match real behavior +@Override +public Trade findById(Long id) { + return storage.get(id); // Returns null - WRONG! +} + +// CORRECT: Matches real implementation +@Override +public Trade findById(Long id) { + Trade trade = storage.get(id); + if (trade == null) { + throw new BaseException(BaseResponseStatus.NO_EXIST_TRADE); + } + return trade; +} +``` + +**Remember: If tests pass but Fake behavior differs from real implementation, the tests are wrong.** diff --git a/md-ko-ver/CLAUDE.ko.md b/md-ko-ver/CLAUDE.ko.md new file mode 100644 index 0000000..7ab83cc --- /dev/null +++ b/md-ko-ver/CLAUDE.ko.md @@ -0,0 +1,918 @@ +# CLAUDE.md (한국어) + +이 파일은 이 저장소에서 작업할 때 Claude Code (claude.ai/code)를 위한 가이드를 제공합니다. + +--- + +## 🚨 CRITICAL WARNING - E2E 테스트 데이터 삭제 금지 + +**E2E 테스트 작성 시 `deleteAllInBatch()` 절대 사용 금지!** + +- 2026-01-23: E2E 테스트의 `@AfterEach`에서 `deleteAllInBatch()` 사용으로 **실제 운영 DB 데이터 전체 삭제 사고 발생** +- E2E 테스트는 실제 DB를 사용하므로, 테스트에서 **생성한 데이터 ID만 추적하여 삭제**해야 함 +- 자세한 내용은 하단 **"E2E 테스트 주의사항"** 섹션 참조 +- **테스트 프로필은 반드시 분리된 DB만 사용** (기본: `greenfirst_test`) +- 테스트 프로필에서 **non-test DB 접속 시 부팅 실패**하도록 가드 추가됨 + (예외적으로 필요하면 `safety.testdb.allow-non-test=true` 설정) + +--- + +## Build & Development Commands + +이 프로젝트는 Spring Boot 3.3.9, Java 17, Gradle을 사용합니다. + +**Build and Run:** + +- `./gradlew build` - 애플리케이션 빌드 +- `./gradlew bootRun` - 로컬 실행 +- `./gradlew test` - 전체 테스트 실행 +- `./gradlew clean` - 빌드 산출물 정리 + +**Testing:** + +- `./gradlew test --tests "*.UnitTest"` - 유닛 테스트만 실행 +- `./gradlew test --tests "*.*IntegrationTest"` - 통합 테스트 실행 +- `./gradlew test --continuous` - 테스트를 연속 실행 모드로 실행 + +**Development:** + +- 기본 프로필은 `local` (see `application.yml`) +- 실행 시 Swagger UI는 `/swagger-ui.html`에서 제공 +- API 문서는 `/api-docs` + +## Architecture Overview + +이 애플리케이션은 **헥사고날 아키텍처(Ports & Adapters)** 및 DDD 원칙을 따릅니다. + +### Package Structure: + +``` +src/main/java/greenfirst/be/ +├── global/ # Cross-cutting concerns, configs, utilities +│ ├── common/ # Base entities, responses, exceptions, security +│ └── config/ # Spring configurations, security, CORS, etc. +└── [domain]/ # Business domains (user, item, trade, branch, stats) + ├── adapter/ + │ ├── in/web/ # Controllers, requests, responses + │ └── out/persistence/ # JPA entities, repositories, implementations + ├── application/ # Application services, DTOs, ports, facades + │ ├── dto/ # Input/Output DTOs + │ ├── port/out/ # Repository interfaces (ports) + │ ├── service/ # Application services + │ └── facade/ # Complex workflows + └── domain/ # Core domain logic, models, commands + ├── command/ # Command objects for domain operations + ├── entity/ # Domain entities + └── model/ # Domain models +``` + +### Key Domains: + +- **user** - Authentication, user management (Admin, Partner types) +- **item** - Item and ItemType management with hierarchical relationships +- **trade** - Trading operations, carbon emission calculations +- **branch** - Branch management for partners +- **stats** - Statistics tracking (daily, monthly, total) for partners and branches + +### Architecture Patterns: + +- **Ports & Adapters**: 어댑터(web, persistence)와 핵심 비즈니스 로직을 명확히 분리 +- **Command Pattern**: 도메인 연산에 Command 객체 사용 (e.g., `CreateItemTypeCommand`) +- **Facade Pattern**: 복잡한 워크플로우에 Facade 사용 (e.g., `UserSignUpFacade`, `CreateTradeFacade`) +- **Repository Pattern**: 도메인과 영속성 레이어 간 명확한 추상화 + +### Key Technologies: + +- Spring Boot 3.3.9 with Spring Security +- JPA with QueryDSL for complex queries +- MariaDB database +- JWT authentication +- ModelMapper for DTO mapping +- Swagger/OpenAPI documentation +- Redis for caching +- Slack integration for notifications + +### Development Patterns: + +- 외부 통신은 DTO만 사용 +- ModelMapper로 레이어 간 매핑 +- BaseEntity for audit fields +- Global exception handling with BaseException +- Spring Event 기반 이벤트 드리븐 아키텍처 +- Bean Validation을 통한 검증 + +### Web Layer (Controller) Patterns: + +**디렉토리 구조:** +``` +[domain]/adapter/in/web/ +├── controller/ # Controller 구현체 (OpenAPI 어노테이션 직접 사용) +│ └── GetXxxController.java +├── request/ # 요청 DTO +└── response/ # 응답 DTO +``` + +**IMPORTANT**: API 스펙 인터페이스를 만들지 말고, Controller 메서드에 OpenAPI 어노테이션을 직접 붙입니다. `api/` 또는 `api/spec/` 디렉토리는 사용하지 않습니다. +**IMPORTANT**: `@ApiResponse`/`@ApiResponses` 어노테이션은 사용하지 않습니다. + +**DTO → Response 변환:** +- Controller에서 직접 변환 로직을 작성하지 않습니다 +- Response 클래스에 `static from()` 메서드를 정의하여 변환합니다 +- 단순 매핑은 ModelMapper를 사용할 수 있습니다 + +**예시:** +```java +// Response 클래스에서 변환 메서드 정의 +public class IncentiveListResponse { + public static IncentiveListResponse from(IncentiveListOutDto outDto) { + // 변환 로직 + } +} + +// Controller에서 사용 +IncentiveListResponse response = IncentiveListResponse.from(outDto); +``` + +### Testing Structure: + +- 유닛 테스트는 'application'과 'domain' 레이어에만 작성 +- 유닛 테스트 네이밍 컨벤션: `*UnitTest.java` +- 테스트용 Fake 구현체 사용 (e.g., `FakeUserCommandRepository`) +- 테스트 데이터 fixture 사용 (e.g., `UserTestFixture`, `ItemTypeTestFixture`) +- 도메인 중심 테스트 + 의존성 mock + +### ⚠️ E2E 테스트 주의사항 (CRITICAL - 반드시 숙지): + +**❌ 절대 금지:** +```java +@AfterEach +void tearDown() { + // ❌ 절대 이렇게 하지 마세요! 실제 운영/개발 데이터가 모두 삭제됩니다! + userJpaRepository.deleteAllInBatch(); + tradeJpaRepository.deleteAllInBatch(); + // ... 기타 deleteAllInBatch() 호출 +} +``` + +**✅ 반드시 이렇게:** +```java +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") // test profile 사용 확인 +class SomeE2ETest { + + // 테스트에서 생성한 데이터 ID 추적 + private final List createdUserUuids = new ArrayList<>(); + private final List createdTradeIds = new ArrayList<>(); + + @Test + void someTest() { + // 데이터 생성 시 ID 추적 + UUID userId = UUID.randomUUID(); + UserEntity user = userJpaRepository.save(...); + createdUserUuids.add(userId); // ← 반드시 추적 + + TradeEntity trade = tradeJpaRepository.save(...); + createdTradeIds.add(trade.getId()); // ← 반드시 추적 + } + + @AfterEach + void tearDown() { + // ✅ 테스트에서 생성한 데이터만 삭제 (FK 역순 고려) + if (!createdTradeIds.isEmpty()) { + tradeJpaRepository.deleteAllById(createdTradeIds); + } + if (!createdUserUuids.isEmpty()) { + createdUserUuids.forEach(uuid -> { + userJpaRepository.findByUserUuid(uuid).ifPresent(userJpaRepository::delete); + }); + } + + // 추적 리스트 초기화 + createdTradeIds.clear(); + createdUserUuids.clear(); + } +} +``` + +**왜 중요한가:** +- E2E 테스트는 실제 DB를 사용합니다 (`@ActiveProfiles("test")` 사용 시에도) +- `deleteAllInBatch()`는 **테이블의 모든 데이터를 삭제**합니다 +- 2026년 1월 23일, E2E 테스트 실행으로 인해 **실제 운영 데이터가 전부 삭제되는 사고 발생** +- 복구 불가능한 데이터 손실이 발생할 수 있습니다 + +**필수 체크리스트:** +1. ✅ E2E 테스트는 반드시 `@ActiveProfiles("test")` 사용 +2. ✅ `application-test.yml`이 별도 테스트 DB를 가리키는지 확인 +3. ✅ `@AfterEach`에서 생성한 데이터 ID만 추적하여 삭제 +4. ✅ **절대 `deleteAllInBatch()` 사용 금지** +5. ✅ FK 제약조건을 고려하여 역순으로 삭제 (자식 → 부모) + +## Cross-Domain Communication Rules + +**CRITICAL**: Bounded Context 간의 느슨한 결합을 유지하기 위해 다음 규칙을 반드시 준수해야 합니다. + +### 1. 직접 호출 금지 + +다른 Bounded Context의 Service, Repository, Domain Model을 직접 호출/의존하지 않습니다. + +**DON'T**: +```java +// incentive 도메인에서 user 도메인 직접 의존 ❌ +@Service +public class IncentiveManagementService { + private final UserQueryRepository userQueryRepository; // 다른 도메인 Repository + private final GetUserDataService getUserDataService; // 다른 도메인 Service +} + +// trade 도메인에서 incentive 도메인 직접 호출 ❌ +@Service +public class CreateTradeFacade { + private final IncentiveManagementService incentiveManagementService; + + public void createTrade(...) { + incentiveManagementService.createEarnIncentives(trade); // 직접 호출 + } +} +``` + +### 2. Spring Event 기반 통신 + +도메인 간 통신은 Spring Event를 사용합니다. + +**DO**: +```java +// 이벤트 발행 (trade 도메인) +TradeCreatedEvent event = TradeCreatedEvent.of(trade, partnerRelations); +eventPublisher.publishEvent(event); + +// 이벤트 수신 (incentive 도메인) +@Component +public class TradeCreatedEventHandler { + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handleTradeCreatedEvent(TradeCreatedEvent event) { + incentiveManagementService.createEarnIncentivesFromEvent(event); + } +} +``` + +### 3. Anti-Corruption Layer (ACL) 사용 + +다른 도메인의 데이터가 필요한 경우, ACL Port/Adapter를 통해 격리합니다. + +**패턴**: +``` +[domain]/application/port/out/user/PartnerRelationQueryPort.java # Port 인터페이스 +[domain]/adapter/out/persistence/repository/user/PartnerRelationQueryPortImpl.java # Adapter 구현 +``` + +**DO**: +```java +// Port 인터페이스 (application 계층) +public interface PartnerRelationQueryPort { + List findPartnerRelationsBySellerUuid(UUID sellerUuid); +} + +// Adapter 구현 (adapter 계층) - 여기서만 다른 도메인 의존 +@Repository +public class PartnerRelationQueryPortImpl implements PartnerRelationQueryPort { + private final PartnerRelationshipQueryRepository partnerRelationshipQueryRepository; + private final GetUserDataService getUserDataService; + // ... +} +``` + +### 4. 이벤트에 필요한 정보 포함 + +이벤트는 가능한 Self-Contained 되어야 합니다. 이벤트 수신 측에서 다른 도메인을 조회하지 않도록 필요한 정보를 이벤트에 포함합니다. + +**DO**: +```java +@Getter +@Builder +public class TradeCreatedEvent { + private Long tradeId; + private String tradeNumber; + // ... trade 정보 + + // 인센티브 생성에 필요한 상위 파트너 정보도 포함 + private List partnerRelationInfos; +} +``` + +### 5. 디렉토리 구조 + +``` +[domain]/ +├── domain/event/ # Domain Event 클래스 +│ ├── TradeCreatedEvent.java +│ └── TradeCancelledEvent.java +├── adapter/ +│ ├── in/event/ # Event Handler (구독 측) +│ │ └── TradeCreatedEventHandler.java +│ └── out/ +│ ├── event/ # Event Publisher Adapter +│ │ └── TradeEventPublisherAdapter.java +│ └── persistence/repository/user/ # ACL Adapter +│ └── PartnerRelationQueryPortImpl.java +└── application/ + └── port/out/ + ├── util/ # Event Publisher Port + │ └── TradeEventPublisher.java + └── user/ # ACL Port + └── PartnerRelationQueryPort.java +``` + +### 6. 트랜잭션 전략 + +- **BEFORE_COMMIT**: 원본 트랜잭션과 동일 트랜잭션에서 처리 (데이터 정합성 보장) +- **AFTER_COMMIT**: 원본 트랜잭션 커밋 후 별도 처리 (느슨한 결합) + +## Minimal Boundary Enforcement (Worktree 적용 전 필수) + +병렬 작업 충돌을 줄이기 위한 **최소한의 경계 강화 규칙**입니다. Worktree 적용 전/후 모두 적용합니다. + +### 1. 도메인 단위 작업 고정 + +- 한 작업은 반드시 **한 도메인만** 수정합니다. (user, trade, incentive, item, branch, stats) +- 허용 경로: + - `src/main/java/greenfirst/be//**` + - `src/test/java/greenfirst/be//**` + +### 2. 공통/글로벌 수정은 승인 필수 + +다음 경로 수정은 **사전에 승인**을 받아야 합니다: +- `src/main/java/greenfirst/be/global/**` +- `build.gradle`, `settings.gradle` +- 공통 설정 파일 (`application*.yml`, `logback`, 보안 설정 등) + +### 3. 다른 도메인 접근 필요 시 절차 + +다른 도메인 코드가 필요하면: +1. 필요한 변경 사항을 먼저 제안한다. +2. **승인 받은 후에만** 다른 도메인을 수정한다. + +### 4. 허용되는 교차 도메인 방식 + +- Spring Event 기반 통신 +- ACL Port/Adapter 사용 +- 직접 Service/Repository/Domain 참조는 금지 + +## Domain Model & Persistence Model Design Rules + +이 애플리케이션은 **도메인 모델(순수 비즈니스 로직)** 과 **영속성 엔티티(JPA/DB 레이어)** 를 엄격히 분리합니다. 이 규칙은 클린 아키텍처 유지에 매우 중요합니다. + +### 1. Core Principles + +**Domain Layer** (`[domain]/domain/model/`): +- JPA 어노테이션이 없는 순수 Java POJO +- 비즈니스 로직과 검증을 포함 +- 다른 엔티티는 객체 참조가 아닌 ID로만 참조 +- DB/영속성 관심사로부터 독립 + +**Persistence Layer** (`[domain]/adapter/out/persistence/entity/`): +- JPA 어노테이션이 있는 엔티티 (@Entity, @Table, @Column) +- 비즈니스 로직 없음 (데이터 구조만) +- @OneToMany, @ManyToOne 등으로 관계 매핑 +- 감사 필드를 위한 BaseTimeEntity 상속 + +**Conversion happens at Repository Layer**: +- ModelMapper로 양방향 변환 +- Repository 구현체에서 도메인과 엔티티를 브릿지 +- 모든 변환은 명시적으로 수행 + +### 2. Naming Conventions + +| Layer | Pattern | Example | Location | +|-------|---------|---------|----------| +| **Domain Model** | Singular noun | `Users`, `Item`, `Trade` | `[domain]/domain/model/` | +| **Domain VO** | Descriptive name | `BranchOption`, `UserAddress` | `[domain]/domain/model/vo/` | +| **Persistence Entity** | Noun + "Entity" | `UserEntity`, `ItemEntity` | `[domain]/adapter/out/persistence/entity/` | +| **Persistence VO** | Name + "Vo" | `BranchOptionVo`, `UserAddressVo` | `[domain]/adapter/out/persistence/entity/vo/` | + +### 3. Domain Model Structure + +**Required Annotations**: +```java +@Getter +@NoArgsConstructor +@Builder(toBuilder = true) +public class Item { + // Fields with NO JPA annotations +} +``` + +**Key Characteristics**: +- @Entity나 @Table 같은 JPA 어노테이션 사용 금지 +- 순수 Java 타입 사용 (Long id, String name 등) +- 다른 엔티티는 ID로만 참조 (e.g., `Long itemTypeId`, not `ItemType itemType`) +- 비즈니스 로직 메서드 포함 (검증, 계산, 상태 변경) +- 도메인에서 타임스탬프 수동 관리 (createdAt, updatedAt, deletedAt for soft delete) + +**Example - Item Domain Model**: +```java +@Getter +@NoArgsConstructor +@Builder(toBuilder = true) +public class Item { + private Long id; + private String itemName; + private String itemCode; + private Boolean isEnabled; + private Integer itemTypeId; // ID reference, not object + private Integer unitPrice; + private String unit; + private Long carbonEmissionFactorId; // ID reference, not object + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + // Static factory method + public static Item from(CreateItemCommand command) { + return Item.builder() + .itemName(command.getItemName()) + .itemCode(command.getItemCode()) + .isEnabled(command.getIsEnabled()) + .itemTypeId(command.getItemTypeId()) + .unitPrice(command.getUnitPrice()) + .unit(command.getUnit()) + .carbonEmissionFactorId(command.getCarbonEmissionFactorId()) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .build(); + } + + // Business logic method + public void updateItemInfo(UpdateItemCommand command) { + this.itemName = command.getItemName(); + this.itemCode = command.getItemCode(); + this.isEnabled = command.getIsEnabled(); + this.updatedAt = LocalDateTime.now(); + } +} +``` + +### 4. Persistence Entity Structure + +**Required Annotations**: +```java +@Entity +@Table(name = "table_name") +@Getter +@Builder(toBuilder = true) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class ItemEntity extends BaseTimeEntity { + // Fields with JPA annotations +} +``` + +**Key Characteristics**: +- 반드시 `BaseTimeEntity` 상속 (createdAt, updatedAt 제공) +- 모든 필드에 @Column으로 DB 매핑 +- 필요 시 @OneToMany, @ManyToOne으로 관계 정의 +- 비즈니스 로직 메서드 금지 +- no-args constructor는 protected, all-args constructor는 private + +**Example - ItemEntity Persistence Model**: +```java +@Entity +@Table(name = "item") +@Getter +@Builder(toBuilder = true) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class ItemEntity extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "item_id") + private Long id; + + @Column(name = "item_name", length = 100, nullable = false) + private String itemName; + + @Column(name = "item_code", length = 50, unique = true, nullable = false) + private String itemCode; + + @Column(name = "is_enabled", nullable = false) + private Boolean isEnabled; + + @Column(name = "item_type_id") + private Integer itemTypeId; + + @Column(name = "unit_price") + private Integer unitPrice; + + @Column(name = "unit", length = 20) + private String unit; + + @Column(name = "carbon_emission_factor_id") + private Long carbonEmissionFactorId; +} +``` + +### 5. Value Objects (Embedded Objects) + +**Domain Value Objects** (`[domain]/domain/model/vo/`): +- @Embeddable 사용 금지 +- 단순 @Getter, @NoArgsConstructor, @Builder +- 검증 어노테이션 사용 (@NotNull, @NotBlank) + +**Persistence Value Objects** (`[domain]/adapter/out/persistence/entity/vo/`): +- 반드시 @Embeddable 사용 +- protected/private constructors +- 각 필드에 @Column 정의 + +**Example - BranchOption**: + +Domain VO: +```java +@Getter +@NoArgsConstructor +@Builder +public class BranchOption { + @NotNull + private Long branchId; + + @NotBlank + private String branchName; +} +``` + +Persistence VO: +```java +@Getter +@Builder +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class BranchOptionVo { + @Column(name = "branch_id") + private Long branchId; + + @Column(name = "branch_name", length = 100) + private String branchName; +} +``` + +Usage in Entity: +```java +@Entity +@Table(name = "users") +public class UserEntity extends BaseTimeEntity { + @Embedded + private BranchOptionVo branchOption; +} +``` + +### 6. Relationship Mapping Rules + +**CRITICAL**: 관계는 영속성 레이어에만 존재하며, 도메인 모델에는 존재하지 않습니다. + +**Domain Models use IDs**: +```java +@Getter +@Builder +public class TradeItem { + private Long id; + private Long tradeId; // Just the ID + private Long itemId; // Just the ID + private Integer quantity; +} +``` + +**Persistence Entities define relationships**: +```java +@Entity +@Table(name = "trade_item") +public class TradeItemEntity extends BaseTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "trade_id", nullable = false) + private TradeEntity trade; + + @Column(name = "item_id") + private Long itemId; + + @Column(name = "quantity") + private Integer quantity; + + // Helper method to extract ID + public Long getTradeId() { + return trade != null ? trade.getId() : null; + } +} +``` + +**Relationship Guidelines**: +- 성능을 위해 항상 `FetchType.LAZY` 사용 +- FK 컬럼명은 `@JoinColumn`으로 지정 +- 필요한 경우 ID 추출 helper 메서드 제공 +- 꼭 필요하지 않으면 양방향 매핑 지양 + +### 7. Repository Conversion Pattern + +**Command Repository** (save operations): +```java +@Repository +@RequiredArgsConstructor +public class ItemCommandRepositoryImpl implements ItemCommandRepository { + private final ItemJpaRepository itemJpaRepository; + private final ModelMapper modelMapper; + + @Override + public Item save(Item item) { + // Domain → Entity + ItemEntity itemEntity = modelMapper.map(item, ItemEntity.class); + + // Persist + ItemEntity savedEntity = itemJpaRepository.save(itemEntity); + + // Entity → Domain + return modelMapper.map(savedEntity, Item.class); + } +} +``` + +**Query Repository** (read operations): +```java +@Repository +@RequiredArgsConstructor +public class ItemQueryRepositoryImpl implements ItemQueryRepository { + private final ItemJpaRepository itemJpaRepository; + private final ModelMapper modelMapper; + + @Override + public Item findById(Long id) { + ItemEntity entity = itemJpaRepository.findById(id) + .orElseThrow(() -> new BaseException(BaseResponseStatus.NO_EXIST_ITEM)); + + // Entity → Domain + return modelMapper.map(entity, Item.class); + } + + @Override + public List findAll() { + return itemJpaRepository.findAll().stream() + .map(entity -> modelMapper.map(entity, Item.class)) + .toList(); + } +} +``` + +### 8. Static Factory Methods + +도메인 모델 생성 시 static factory method를 사용합니다: + +```java +// Domain Model +public class Item { + public static Item from(CreateItemCommand command) { + return Item.builder() + .itemName(command.getItemName()) + .itemCode(command.getItemCode()) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .build(); + } +} + +// Usage in Service +public class ItemService { + public void createItem(CreateItemCommand command) { + Item item = Item.from(command); + itemCommandRepository.save(item); + } +} +``` + +### 9. Audit Fields Management + +**Persistence Layer** (BaseTimeEntity로 자동 처리): +```java +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseTimeEntity { + @CreatedDate + @Column(updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime updatedAt; +} +``` + +**Domain Layer** (수동 관리): +```java +@Getter +@Builder(toBuilder = true) +public class Item { + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private LocalDateTime deletedAt; // For soft delete + + public void softDelete() { + this.deletedAt = LocalDateTime.now(); + } +} +``` + +### 10. UUID and Numeric Type Handling + +**UUID Fields (CRITICAL)**: + +영속성 엔티티의 UUID 필드는 저장 효율을 위해 반드시 `BINARY(16)` 컬럼을 사용합니다: + +```java +// Persistence Entity +@Entity +@Table(name = "users") +public class UserEntity extends BaseTimeEntity { + @Column(name = "user_uuid", nullable = false, columnDefinition = "BINARY(16)", unique = true) + private UUID userUuid; +} + +@Entity +@Table(name = "trade") +public class TradeEntity extends BaseTimeEntity { + @Column(name = "seller_uuid", columnDefinition = "BINARY(16)", nullable = false) + private UUID sellerUuid; + + @Column(name = "buyer_uuid", columnDefinition = "BINARY(16)", nullable = false) + private UUID buyerUuid; +} +``` + +**Domain models**는 어노테이션 없이 UUID 타입만 사용: +```java +@Getter +@Builder(toBuilder = true) +public class Users { + private UUID userUuid; +} + +@Getter +@Builder(toBuilder = true) +public class Trade { + private UUID sellerUuid; + private UUID buyerUuid; +} +``` + +**BigDecimal and BigInteger Usage**: + +`BigDecimal` 사용 대상: +- **Weights** (무게): `totalWeight`, `payloadWeight`, `curbWeight` +- **Rates/Ratios** (비율): `totalWeightLossRate`, `emissionFactorKgCo2ePerKg` +- **Carbon values** (탄소 관련): `carbonReductionAmount` +- **Prices with decimals** (소수점 가격): `userTradeItemUnitPrice` + +`BigInteger` 사용 대상: +- **Monetary amounts** (금액): `tradeAmount`, `totalBuyAmount`, `totalSellAmount` +- **Large integer values** without decimal points + +`Integer` 사용 대상: +- **Counts**: `totalBuyCount`, `totalSellCount` +- **Unit prices** when stored as integers: `unitPrice` +- **Small numeric IDs or codes** + +**Example - Trade Entity with proper numeric types**: +```java +@Entity +@Table(name = "trade") +public class TradeEntity extends BaseTimeEntity { + @Column(name = "total_weight", precision = 10, scale = 2) + private BigDecimal totalWeight; + + @Column(name = "total_weight_loss_rate", precision = 5, scale = 2) + private BigDecimal totalWeightLossRate; + + @Column(name = "trade_amount", nullable = false) + private BigInteger tradeAmount; // 금액은 BigInteger + + @Column(name = "carbon_reduction_amount", precision = 10, scale = 2) + private BigDecimal carbonReductionAmount; +} +``` + +**Example - Domain Model with numeric types**: +```java +@Getter +@Builder(toBuilder = true) +public class Trade { + private BigDecimal totalWeight; + private BigDecimal curbWeight; + private BigDecimal payloadWeight; + private BigDecimal totalWeightLoss; + private BigDecimal totalWeightLossRate; + private BigInteger tradeAmount; // 금액 + private BigDecimal carbonReductionAmount; + + // Business logic using BigDecimal + public BigDecimal calculateWeightLoss() { + if (totalWeight != null && payloadWeight != null) { + return totalWeight.subtract(payloadWeight); + } + return BigDecimal.ZERO; + } +} +``` + +**Guidelines**: +- 0 값은 `new BigDecimal(0)` 대신 `BigDecimal.ZERO` 사용 +- BigDecimal 비교는 `equals()` 대신 `compareTo()` 사용 +- BigDecimal 필드는 @Column에 `precision`과 `scale` 지정 +- BigDecimal 연산 전에 null 체크 + +### 11. Common Patterns Summary + +**DO**: +- ✅ 도메인 모델에 JPA 어노테이션을 붙이지 않는다 +- ✅ 도메인 모델에서 다른 엔티티는 ID로만 참조한다 +- ✅ 관계(@ManyToOne 등)는 영속성 엔티티에만 둔다 +- ✅ Domain ↔ Entity 변환은 ModelMapper 사용 +- ✅ 비즈니스 로직은 도메인 모델에 둔다 +- ✅ 도메인 생성은 static factory method 사용 (from, of) +- ✅ 모든 영속성 엔티티는 BaseTimeEntity 상속 +- ✅ protected/private 생성자 + Builder 패턴 사용 +- ✅ UUID는 BINARY(16) 컬럼 사용 +- ✅ BigDecimal은 무게/비율/탄소/소수점 가격에 사용 +- ✅ BigInteger는 금액에 사용 +- ✅ BigDecimal.ZERO 사용 및 compareTo() 비교 + +**DON'T**: +- ❌ 도메인 모델에 JPA 어노테이션 추가 금지 +- ❌ 다른 도메인 객체를 직접 참조 금지 (ID 사용) +- ❌ 영속성 엔티티에 비즈니스 로직 금지 +- ❌ 엔티티에 public no-args constructor 금지 +- ❌ 도메인 VO에 @Embeddable 사용 금지 (영속성 VO에만 사용) +- ❌ 특별한 이유 없이 양방향 관계 금지 +- ❌ FetchType.EAGER 사용 금지 (항상 LAZY) +- ❌ UUID를 VARCHAR/CHAR(36)로 저장 금지 +- ❌ 금액/정밀도가 필요한 값에 Float/Double 사용 금지 +- ❌ BigDecimal 비교에 equals() 사용 금지 + +## Writing Correct Unit Tests + +**CRITICAL: Tests must verify business logic, not just pass.** + +### Before Writing Tests: + +1. **Always read the actual implementation first** + - 실제 Repository 구현을 먼저 읽는다 (e.g., `*QueryRepositoryImpl.java`) + - QueryDSL 유틸리티 확인 (e.g., `*QuerydslUtil.java`) + - 예외 처리 및 엣지 케이스 확인 + - 반환 타입 및 오류 조건 포함 전체 동작 이해 + +2. **Fake implementations must mirror real behavior exactly** + - 예외 타입과 조건 일치 (e.g., 데이터 없으면 `BaseException`) + - 기본값 동일 (e.g., default orderField, orderType) + - 필터 로직 동일 (e.g., tradeType=ALL 시 userUuid로 필터) + - 경계 처리 동일 (e.g., date range between은 경계 포함) + +### Writing Test Cases: + +1. **Test business logic, not just happy paths** + - 접근 제어 검증 (e.g., `Trade.validateSearchAccess()`) + - 예외 시나리오 (e.g., 엔티티 없음) + - 엣지 케이스 (e.g., 빈 결과, 경계값) + - 복잡한 필터 조합 + +2. **Common mistakes to avoid** + - ❌ Fake가 null 반환, 실제는 예외 (오류) + - ❌ Fake가 전체 반환, 실제는 사용자 필터 + - ❌ Fake 기본값이 실제와 다름 + - ❌ Fake 로직이 실제와 달라 카운트가 틀림 + - ❌ 테스트가 실제 동작 검증 없이 통과 + +3. **Verification checklist** + - ✅ Fake가 실제와 같은 예외를 던지는가? + - ✅ Fake가 동일한 필터(특히 보안 관련)를 적용하는가? + - ✅ Fake의 기본값이 실제와 같은가? + - ✅ 테스트가 도메인 로직(접근 제어 등)을 검증하는가? + - ✅ 엣지/오류 케이스가 포함되었는가? + +### Example Pattern: + +```java +// WRONG: Fake that doesn't match real behavior +@Override +public Trade findById(Long id) { + return storage.get(id); // Returns null - WRONG! +} + +// CORRECT: Matches real implementation +@Override +public Trade findById(Long id) { + Trade trade = storage.get(id); + if (trade == null) { + throw new BaseException(BaseResponseStatus.NO_EXIST_TRADE); + } + return trade; +} +``` + +**Remember: If tests pass but Fake behavior differs from real implementation, the tests are wrong.** diff --git a/md-ko-ver/claude/commands/check-all.ko.md b/md-ko-ver/claude/commands/check-all.ko.md new file mode 100644 index 0000000..9a2c261 --- /dev/null +++ b/md-ko-ver/claude/commands/check-all.ko.md @@ -0,0 +1,78 @@ +# 작업 영역의 모든 변경사항 리뷰 + +작업 영역에서 수정되거나 새로 생성된 모든 코드를 다음 기준에 따라 리뷰합니다. 문제점을 식별하고 구체적인 해결책을 제시합니다. + +**대상:** git status에서 Modified(M), Added(A), Renamed(R)로 표시된 모든 파일 + +## 리뷰 기준 + +### 1. 기능 및 로직 +- 새로운/수정된 코드가 의도한 대로 작동하는가? +- 기존 기능을 깨뜨리지 않고 보존하는가? +- 변경사항 간 일관성이 유지되는가? +- 예외 처리가 적절한가? +- 엣지 케이스가 올바르게 처리되는가? + +### 2. 아키텍처 및 설계 (Hexagonal Architecture) +- 도메인 로직과 서비스 로직이 명확히 분리되어 있는가? +- 도메인 로직은 domain 레이어에, 서비스 로직은 application 레이어에 있는가? +- Ports & Adapters 패턴을 올바르게 따르는가? +- Command 패턴이 필요한 곳에서 적절히 사용되는가? +- 여러 파일에 걸친 변경이 아키텍처적으로 일관성이 있는가? + +### 3. 보안 및 권한 +- 인증/인가가 올바르게 구현되어 있는가? +- 접근 제어 로직(예: validateSearchAccess)이 올바르게 작동하는가? +- 민감한 데이터가 적절히 보호되는가? +- 사용자별 데이터 필터링이 올바르게 적용되는가? +- 새로운 API 엔드포인트에 적절한 보안 설정이 되어 있는가? + +### 4. 테스트 (application/domain 레이어만) +- adapter 레이어 외 새로운/수정된 코드에 대한 단위 테스트가 충분한가? +- 테스트가 그냥 통과만 하는 것이 아니라 의미있는 동작을 검증하는가? (엣지 케이스, 예외 처리, 도메인 로직) +- **Fake 구현이 실제 구현과 정확히 동일하게 동작하는가?** + - 동일한 예외 타입과 조건인가? (예: null 반환 vs BaseException throw) + - 동일한 필터링 로직인가? (특히 보안 필터: userUuid 등) + - 동일한 기본값인가? (예: orderField, orderType 기본값) + - 동일한 경계 처리인가? (예: between 조건의 경계 포함/제외) +- 비즈니스 로직이 올바르게 검증되는가? (예: 접근 제어, 상태 전환) +- 기존 테스트가 여전히 통과하는가? (회귀 테스트) + +### 5. 코드 품질 +- 코드 스타일이 프로젝트와 일관성이 있는가? (들여쓰기, 네이밍 규칙) +- 불필요한 코드나 중복이 있는가? +- 코드 가독성이 좋은가? +- 변경된 파일들 간 일관성이 유지되는가? + +### 6. 성능 +- N+1 쿼리 문제가 있는가? +- 불필요한 데이터베이스 쿼리나 연산이 있는가? +- 대량 데이터 처리 시 성능 문제가 있는가? +- 변경사항이 전체 시스템 성능에 부정적 영향을 미치는가? + +### 7. 통합 +- 여러 변경된 파일들이 올바르게 통합되는가? +- API 계약(request/response)이 일관성 있게 업데이트되었는가? +- 스키마 변경이 필요하다면 데이터베이스 마이그레이션이 고려되었는가? + +## 리뷰 프로세스 + +1. `git status` 또는 `git diff`로 변경된 파일 확인 +2. 각 파일을 읽고 변경사항 이해 +3. 위 기준에 따라 종합적으로 리뷰 +4. 파일 간 의존성 및 통합 검증 + +## 리뷰 결과 포맷 + +**중요: 모든 리뷰 결과는 한국어로 출력.** + +**변경사항 요약:** +- 수정된 파일 수와 주요 변경 내용 + +**리뷰 결과:** +각 기준별로: +- ✅ 문제 없음: 간단히 언급 +- ⚠️ 제안사항: 더 나은 접근 방법 +- ❌ 문제 발견: 구체적인 문제점과 해결 방법 + +수정이 필요한 경우 파일 경로와 라인 번호 명시 (예: `path/to/file.java:123`) diff --git a/md-ko-ver/claude/commands/check.ko.md b/md-ko-ver/claude/commands/check.ko.md new file mode 100644 index 0000000..e5b4d20 --- /dev/null +++ b/md-ko-ver/claude/commands/check.ko.md @@ -0,0 +1,54 @@ +# 선택한 파일에 대한 코드 리뷰 + +사용자가 지정한 코드를 다음 기준에 따라 리뷰합니다. 문제점을 식별하고 구체적인 해결책을 제시합니다. + +## 리뷰 기준 + +### 1. 기능 및 로직 +- 코드가 의도한 대로 작동하는가? +- 기존 기능을 깨뜨리지 않고 보존하는가? +- 예외 처리가 적절한가? +- 엣지 케이스가 올바르게 처리되는가? + +### 2. 아키텍처 및 설계 (Hexagonal Architecture) +- 도메인 로직과 서비스 로직이 명확히 분리되어 있는가? +- 도메인 로직은 domain 레이어에, 서비스 로직은 application 레이어에 있는가? +- Ports & Adapters 패턴을 올바르게 따르는가? +- Command 패턴이 필요한 곳에서 적절히 사용되는가? + +### 3. 보안 및 권한 +- 인증/인가가 올바르게 구현되어 있는가? +- 접근 제어 로직(예: validateSearchAccess)이 올바르게 작동하는가? +- 민감한 데이터가 적절히 보호되는가? +- 사용자별 데이터 필터링이 올바르게 적용되는가? + +### 4. 테스트 (application/domain 레이어만) +- adapter 레이어 외 레이어에 대한 단위 테스트가 충분한가? +- 테스트가 그냥 통과만 하는 것이 아니라 의미있는 동작을 검증하는가? (엣지 케이스, 예외 처리, 도메인 로직) +- **Fake 구현이 실제 구현과 정확히 동일하게 동작하는가?** + - 동일한 예외 타입과 조건인가? (예: null 반환 vs BaseException throw) + - 동일한 필터링 로직인가? (특히 보안 필터: userUuid 등) + - 동일한 기본값인가? (예: orderField, orderType 기본값) + - 동일한 경계 처리인가? (예: between 조건의 경계 포함/제외) +- 비즈니스 로직이 올바르게 검증되는가? (예: 접근 제어, 상태 전환) + +### 5. 코드 품질 +- 코드 스타일이 프로젝트와 일관성이 있는가? (들여쓰기, 네이밍 규칙) +- 불필요한 코드나 중복이 있는가? +- 코드 가독성이 좋은가? + +### 6. 성능 +- N+1 쿼리 문제가 있는가? +- 불필요한 데이터베이스 쿼리나 연산이 있는가? +- 대량 데이터 처리 시 성능 문제가 있는가? + +## 리뷰 결과 포맷 + +**중요: 모든 리뷰 결과는 한국어로 출력.** + +각 기준별로: +- ✅ 문제 없음: 간단히 언급 +- ⚠️ 제안사항: 더 나은 접근 방법 +- ❌ 문제 발견: 구체적인 문제점과 해결 방법 + +수정이 필요한 경우 파일 경로와 라인 번호 명시 (예: `path/to/file.java:123`) diff --git a/md-ko-ver/claude/commands/unit-test-generate.ko.md b/md-ko-ver/claude/commands/unit-test-generate.ko.md new file mode 100644 index 0000000..57cb5d1 --- /dev/null +++ b/md-ko-ver/claude/commands/unit-test-generate.ko.md @@ -0,0 +1,238 @@ +# 단위 테스트 생성 + +새로 생성되거나 수정된 모든 클래스에 대한 단위 테스트를 생성합니다. 프로젝트 테스팅 원칙을 따르고 실제 비즈니스 로직을 검증하는 의미있는 테스트를 작성합니다. + +**대상:** git status에서 Modified(M), Added(A), Renamed(R)로 표시된 모든 파일 중, 특히 application/domain 레이어 + +## 테스트 대상 레이어 + +- **application 레이어**: Service, Facade 클래스 +- **domain 레이어**: Domain Entity, Model의 비즈니스 로직 +- **adapter 레이어는 테스트하지 않음** (Controller, Repository 구현체 등) + +## 테스팅 원칙 + +### 1. 테스트 파일 네이밍 +- 파일명: `{ClassName}UnitTest.java` +- 예시: `PartnerRelationshipService` → `PartnerRelationshipServiceUnitTest.java` +- 위치: `src/test/java/{package-path}/` + +### 2. Fake 구현 사용 +- 실제 Repository 대신 Fake 구현을 사용 +- **CRITICAL**: Fake 구현은 실제 구현과 **완전히 동일하게** 동작해야 함 + - 동일한 예외 타입과 조건 (예: null 반환 vs BaseException throw) + - 동일한 필터링 로직 (특히 보안 필터: userUuid 등) + - 동일한 기본값 (예: orderField, orderType 기본값) + - 동일한 경계 처리 (예: between 조건의 경계 포함/제외) + +### 3. Fake 구현 생성 프로세스 + +**Step 1: 실제 구현 읽기** +- 반드시 먼저 실제 Repository 구현체(`*RepositoryImpl.java`)를 읽어야 함 +- QueryDSL 유틸리티가 사용되면 해당 파일도 읽어야 함 +- 예외 처리, 기본값, 필터링 로직을 정확히 이해해야 함 + +**Step 2: Fake 구현 작성** +- 위치: `src/test/java/greenfirst/be/{domain}/fake/Fake{RepositoryName}.java` +- 실제 구현과 동일한 동작 보장 +- 테스트 헬퍼 메서드 추가 (clear, size 등) + +**Step 3: 검증** +- Fake와 실제 구현 간 동작 차이가 없는지 확인 +- 특히 예외 발생 조건이 정확히 일치하는지 확인 + +### 4. 테스트 케이스 작성 + +**테스트해야 할 항목:** + +1. **Happy Path 시나리오** + - 기본 기능이 정상 작동하는지 검증 + +2. **예외 시나리오** + - 존재하지 않는 엔티티 조회 시 예외 발생 + - 잘못된 입력값에 대한 검증 + - 비즈니스 규칙 위반 시 예외 처리 + +3. **엣지 케이스** + - 빈 결과 반환 + - null 값 처리 + - 경계값 테스트 + - 0개, 1개, 여러 개의 데이터 + +4. **비즈니스 로직 검증** + - 접근 제어 (예: `validateSearchAccess()`) + - 상태 전환 + - 도메인 규칙 적용 + - 복잡한 필터 및 조합 + +5. **보안 관련** + - 사용자별 데이터 필터링 + - 권한 검증 + +### 5. 테스트 구조 + +```java +@DisplayName("{ClassName} 단위테스트") +class {ClassName}UnitTest { + + private {Service} service; + private Fake{Repository} fakeRepository; + + @BeforeEach + void setUp() { + // Fake 구현 초기화 + fakeRepository = new Fake{Repository}(); + fakeRepository.clear(); + + // 서비스 초기화 + service = new {Service}(fakeRepository); + } + + @Nested + @DisplayName("{MethodName} - {기능 설명}") + class {MethodName}Test { + + @Test + @DisplayName("happy path 시나리오 설명") + void test1() { + // given: 테스트 데이터 준비 + + // when: 테스트 실행 + + // then: 결과 검증 + } + + @Test + @DisplayName("예외 시나리오 설명") + void test2() { + // given + + // when & then: 예외 발생 검증 + assertThatThrownBy(() -> service.method()) + .isInstanceOf(BaseException.class) + .hasMessage("예상 메시지"); + } + } +} +``` + +### 6. Test Fixture 사용 + +- Fixture 클래스에 공통 테스트 데이터 정의 +- 위치: `src/test/java/greenfirst/be/{domain}/fixture/{Entity}TestFixture.java` +- 예시: `UserTestFixture`, `ItemTypeTestFixture` + +## 흔한 실수 방지 + +### ❌ 잘못된 예시: + +```java +// 실제 구현: 예외 발생 +public Trade findById(Long id) { + return repository.findById(id) + .orElseThrow(() -> new BaseException(NO_EXIST_TRADE)); +} + +// Fake 구현: null 반환 (잘못됨!) +public Trade findById(Long id) { + return storage.get(id); // null 반환 +} + +// 테스트: 통과하지만 잘못된 동작을 검증 +@Test +void test() { + Trade trade = service.findById(999L); + assertThat(trade).isNull(); // 실제로는 예외가 발생해야 함! +} +``` + +### ✅ 올바른 예시: + +```java +// Fake 구현: 실제 구현처럼 예외 발생 +public Trade findById(Long id) { + Trade trade = storage.get(id); + if (trade == null) { + throw new BaseException(NO_EXIST_TRADE); + } + return trade; +} + +// 테스트: 예외 발생 검증 +@Test +void test() { + assertThatThrownBy(() -> service.findById(999L)) + .isInstanceOf(BaseException.class); +} +``` + +## 검증 체크리스트 + +테스트 작성 후 확인: + +- [ ] Fake 구현이 실제 구현과 동일한 예외를 발생시키는가? +- [ ] Fake 구현이 실제와 동일한 필터링을 적용하는가? (특히 userUuid 같은 보안 필터) +- [ ] Fake 구현이 실제와 동일한 기본값을 사용하는가? +- [ ] 예외 시나리오가 테스트되었는가, happy path만 테스트하지 않았는가? +- [ ] 엣지 케이스(빈 결과, 경계값 등)가 테스트되었는가? +- [ ] 도메인 로직(접근 제어, 상태 전환 등)이 검증되었는가? +- [ ] 테스트가 실제 비즈니스 로직을 검증하는가, 아니면 그냥 통과만 하는가? + +## 테스트 실행 + +```bash +# 모든 단위 테스트 실행 +./gradlew test --tests "*.UnitTest" + +# 특정 클래스 테스트 실행 +./gradlew test --tests "*{ClassName}UnitTest" + +# 지속적 테스트 모드 +./gradlew test --continuous +``` + +## 작업 프로세스 + +1. `git status`로 수정된 파일 확인 +2. application/domain 레이어 클래스만 필터링 (adapter 레이어 제외) +3. 각 클래스별로: + - 실제 Repository 구현 분석 + - 필요한 Fake 구현 생성 또는 검증 + - 포괄적인 테스트 코드 생성 (happy path/예외/엣지케이스) +4. 전체 리뷰 요약 출력 + +## 출력 포맷 + +**한국어로 출력.** + +### 1. 테스트 코드 생성 요약 +``` +{N}개 클래스에 대한 테스트 생성 완료 + +생성된 테스트 파일: +- {TestFilePath1} ({ClassName1}) +- {TestFilePath2} ({ClassName2}) +- ... +``` + +### 2. 전체 리뷰 결과 + +각 생성된 테스트에 대해: + +``` +[{ClassName}UnitTest] +✅ 테스트 케이스: Happy path {N}개 / 예외 {N}개 / 엣지케이스 {N}개 +✅ Fake 구현: {상태 - 새로 생성/기존 사용/수정됨} +✅ 비즈니스 로직 검증: {커버한 주요 로직} +⚠️ 권장사항: {있다면} +``` + +### 3. 전체 평가 +``` +✅ 전체 테스트 커버리지: {high/medium/low} +✅ Fake 동작이 실제 구현과 일치: 검증 완료 +⚠️ 추가 권장 테스트: {있다면} + +테스트 실행: +./gradlew test --tests "*.UnitTest" +``` diff --git a/md-ko-ver/claude/skills/commit-ko.md b/md-ko-ver/claude/skills/commit-ko.md new file mode 100644 index 0000000..7fdbd38 --- /dev/null +++ b/md-ko-ver/claude/skills/commit-ko.md @@ -0,0 +1,43 @@ +# 커밋 메시지 컨벤션 + +이 프로젝트의 커밋 메시지 작성 규칙입니다. + +## 형식 + +``` +type: 간단한 설명 + +[선택] 상세 설명 (필요 시) +``` + +## 타입 + +- **feat**: 신규 기능 추가 +- **fix**: 버그 수정 +- **refactor**: 코드 리팩터링 (기능 변경 없이 구조/성능 개선) +- **test**: 테스트 추가/수정 +- **chore**: 기타 변경 (문서, 설정 등) + +## 작성 규칙 + +1. **한국어 사용**: 커밋 메시지는 한국어로 작성 +2. **간결성**: 한 줄로 무엇을 했는지 명확히 +3. **구체성**: API 이름, 기능 이름 등 구체적으로 작성 +4. **상세 설명**: 복잡한 변경은 본문에 추가 설명 (선택) + +## 예시 + +``` +✅ feat: 파트너 관계 조회 API 구현 +✅ fix: 새 품목 생성 시 기존 파트너 단가 미생성 버그 수정 +✅ refactor: 품목/품목종류 파사드 도입 및 검증 로직 정리 +✅ test: TradeManagementService 단위테스트 추가 + +✅ refactor: ItemValidationService 성능 개선 & 단위테스트 추가 + - 중복 ID 검증 로직을 HashSet 기반으로 최적화 (O(n²) → O(n)) + - 대량 데이터 처리 시 성능 99% 개선 + +❌ feat: 수정 +❌ fix: bug +❌ update user api +``` diff --git a/md-ko-ver/claude/skills/test-code.ko.md b/md-ko-ver/claude/skills/test-code.ko.md new file mode 100644 index 0000000..69638ff --- /dev/null +++ b/md-ko-ver/claude/skills/test-code.ko.md @@ -0,0 +1,599 @@ +--- +name: test-code +description: > + 단위 테스트 및 E2E 테스트를 작성, 리뷰, 디버깅할 때 사용하는 스킬입니다. + 중요한 안전 규칙(E2E 데이터 삭제 방지), 올바른 테스트 구조, + 실제 리포지토리 동작을 정확히 미러링하는 Fake 구현 패턴을 강제합니다. +--- + +# 테스트 코드 작성 및 리뷰 스킬 + +이 스킬은 올바르고 안전하며 의미있는 테스트를 작성하기 위한 **엄격한 규칙과 패턴**을 제공합니다. + +## 활성화 시점 + +다음 작업 시 이 스킬을 활성화: + +- application/domain 레이어를 위한 새로운 단위 테스트 작성 +- E2E 테스트 생성 +- 기존 테스트 리뷰 +- 테스트 실패 디버깅 +- 단위 테스트를 위한 Fake 리포지토리 구현 +- 테스트 픽스처 설정 + +## 범위 외 (이 스킬 사용 금지) + +- 통합 테스트 설정 +- 성능/부하 테스트 +- 수동 테스트 절차 +- 프로덕션 디버깅 + +--- + +# 🚨 중요: E2E 테스트 안전 규칙 (필수) + +## 문제점 + +**2026년 1월 프로덕션 데이터 손실 사고:** +- E2E 테스트가 `@AfterEach`에서 `deleteAllInBatch()` 사용 +- **모든 프로덕션/개발 데이터가 삭제됨** +- 데이터 복구 불가능 + +## 규칙 #1: E2E 테스트에서 deleteAllInBatch() 절대 사용 금지 + +**❌ 절대 금지:** +```java +@AfterEach +void tearDown() { + // ❌ 절대 이렇게 하지 마세요 - 테이블의 모든 데이터를 삭제합니다! + userJpaRepository.deleteAllInBatch(); + tradeJpaRepository.deleteAllInBatch(); + itemJpaRepository.deleteAllInBatch(); +} +``` + +## 규칙 #2: 생성한 테스트 데이터만 추적하여 삭제 + +**✅ 올바른 패턴:** +```java +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") // 반드시 test 프로필 사용 +class SomeE2ETest { + + // 생성한 데이터 ID 추적 + private final List createdUserUuids = new ArrayList<>(); + private final List createdTradeIds = new ArrayList<>(); + private final List createdItemIds = new ArrayList<>(); + + @Test + void someTest() { + // 테스트 데이터 생성 및 ID 추적 + UUID userId = UUID.randomUUID(); + UserEntity user = userJpaRepository.save( + UserEntity.builder() + .userUuid(userId) + .username("test-user") + .build() + ); + createdUserUuids.add(userId); // ← 반드시 추적 + + TradeEntity trade = tradeJpaRepository.save(...); + createdTradeIds.add(trade.getId()); // ← 반드시 추적 + } + + @AfterEach + void tearDown() { + // ✅ 추적한 테스트 데이터만 삭제 (FK 순서 고려: 자식 → 부모) + if (!createdTradeIds.isEmpty()) { + tradeJpaRepository.deleteAllById(createdTradeIds); + } + if (!createdItemIds.isEmpty()) { + itemJpaRepository.deleteAllById(createdItemIds); + } + if (!createdUserUuids.isEmpty()) { + createdUserUuids.forEach(uuid -> { + userJpaRepository.findByUserUuid(uuid) + .ifPresent(userJpaRepository::delete); + }); + } + + // 추적 리스트 초기화 + createdTradeIds.clear(); + createdItemIds.clear(); + createdUserUuids.clear(); + } +} +``` + +## 규칙 #3: 테스트 데이터베이스 분리 강제 + +**필수 체크리스트:** +1. ✅ E2E 테스트는 `@ActiveProfiles("test")` 사용 +2. ✅ `application-test.yml`이 별도 테스트 DB를 가리킴 (기본: `greenfirst_test`) +3. ✅ 테스트 프로필이 non-test DB 접속을 차단 (안전 장치 추가됨) +4. ✅ FK 역순으로 데이터 삭제 (자식 → 부모) +5. ✅ **절대 `deleteAllInBatch()` 사용 금지** + +**예외:** 특수한 경우 non-test DB가 절대적으로 필요하다면: +```yaml +safety.testdb.allow-non-test: true +``` +하지만 이는 **극히 드물고** **충분한 정당성**이 필요합니다. + +--- + +# 단위 테스트 구조 (필수) + +## 디렉토리 구조 + +``` +src/test/java/greenfirst/be/ +└── [domain]/ + ├── application/ + │ ├── service/ + │ │ └── SomeServiceUnitTest.java # Service 단위 테스트 + │ └── facade/ + │ └── SomeFacadeUnitTest.java # Facade 단위 테스트 + └── domain/ + ├── model/ + │ └── SomeModelUnitTest.java # Domain model 단위 테스트 + └── fixture/ + └── SomeTestFixture.java # 테스트 데이터 빌더 +``` + +## 네이밍 규칙 + +- **단위 테스트:** `*UnitTest.java` +- **통합 테스트:** `*IntegrationTest.java` +- **E2E 테스트:** `*E2ETest.java` +- **테스트 픽스처:** `*TestFixture.java` +- **Fake 구현:** `Fake*Repository.java` + +## 테스트 레이어 + +**단위 테스트 대상 레이어:** +- ✅ `application/` - Services, Facades +- ✅ `domain/` - Domain models, 비즈니스 로직 + +**단위 테스트 금지:** +- ❌ Controllers (E2E 또는 통합 테스트 사용) +- ❌ JPA Entities (통합 테스트로 테스트) +- ❌ Repositories (통합 테스트로 테스트) + +--- + +# 올바른 단위 테스트 작성 (중요) + +## 황금 규칙 + +**테스트는 비즈니스 로직을 검증해야 하며, 단순히 통과만 하면 안 됩니다.** + +## 테스트 작성 전 (필수) + +### 1단계: 실제 구현 읽기 + +**❌ 잘못됨:** 실제 코드를 읽지 않고 테스트 작성 +**✅ 올바름:** 항상 먼저 다음을 읽기: + +1. **실제 리포지토리 구현** (예: `TradeQueryRepositoryImpl.java`) +2. **QueryDSL 유틸리티** (사용된다면, 예: `TradeQuerydslUtil.java`) +3. **예외 처리** 및 엣지 케이스 +4. **반환 타입** 및 기본값 + +**예시:** +```java +// TradeSearchService 테스트 작성 전 읽어야 할 것: +// 1. TradeQueryRepositoryImpl.java (실제 구현) +// 2. TradeQuerydslUtil.java (필터링 로직) +// 3. Trade.java (도메인 검증 메서드) +``` + +### 2단계: Fake는 실제 동작을 정확히 미러링해야 함 + +**흔한 실수:** + +| 실제 구현 | ❌ 잘못된 Fake | ✅ 올바른 Fake | +|---------------------|---------------|-----------------| +| 없으면 `BaseException` 발생 | `null` 반환 | `BaseException` 발생 | +| `tradeType=ALL`일 때 `userUuid`로 필터링 | 모든 데이터 반환 | `userUuid`로 필터링 | +| `orderField="tradeAt"` 기본값 사용 | `orderField="id"` 사용 | `orderField="tradeAt"` 사용 | +| `between(start, end)` 사용 (포함) | `> start && < end` (제외) | `>= start && <= end` (포함) | + +**예시 - 예외 처리:** +```java +// ❌ 잘못됨: Fake가 null 반환 +@Override +public Trade findById(Long id) { + return storage.get(id); // 없으면 null 반환 +} + +// ✅ 올바름: Fake가 실제와 동일한 예외 발생 +@Override +public Trade findById(Long id) { + Trade trade = storage.get(id); + if (trade == null) { + throw new BaseException(BaseResponseStatus.NO_EXIST_TRADE); + } + return trade; +} +``` + +**예시 - 접근 제어:** +```java +// 실제 구현은 ALL 타입일 때 userUuid로 필터링 +// FakeTradeQueryRepository.java + +@Override +public List searchTrades(TradeSearchCondition condition) { + Stream stream = storage.values().stream(); + + // ❌ 잘못됨: ALL 타입일 때 userUuid로 필터링하지 않음 + if (condition.getTradeType() == TradeType.BUY) { + stream = stream.filter(t -> t.getBuyerUuid().equals(condition.getUserUuid())); + } else if (condition.getTradeType() == TradeType.SELL) { + stream = stream.filter(t -> t.getSellerUuid().equals(condition.getUserUuid())); + } + // ALL 타입: 모든 데이터 반환 - 잘못됨! + + // ✅ 올바름: ALL 타입일 때도 userUuid로 필터링 + if (condition.getTradeType() == TradeType.BUY) { + stream = stream.filter(t -> t.getBuyerUuid().equals(condition.getUserUuid())); + } else if (condition.getTradeType() == TradeType.SELL) { + stream = stream.filter(t -> t.getSellerUuid().equals(condition.getUserUuid())); + } else { // ALL + UUID userUuid = condition.getUserUuid(); + stream = stream.filter(t -> + t.getBuyerUuid().equals(userUuid) || t.getSellerUuid().equals(userUuid) + ); + } + + return stream.toList(); +} +``` + +## 테스트 케이스 작성 (필수) + +### 비즈니스 로직 테스트, Happy Path만 테스트하지 말 것 + +**필수 테스트 시나리오:** +1. ✅ **접근 제어 검증** (예: `Trade.validateSearchAccess()`) +2. ✅ **예외 시나리오** (예: 엔티티 없음) +3. ✅ **엣지 케이스** (예: 빈 결과, 경계값) +4. ✅ **복잡한 필터** 및 조합 +5. ✅ **기본값** (예: 정렬, 페이지네이션) + +**예시 테스트 스위트:** +```java +@DisplayName("TradeSearchService 단위테스트") +class TradeSearchServiceUnitTest { + + @Nested + @DisplayName("searchTrades") + class SearchTrades { + + @Test + @DisplayName("성공: BUY 타입으로 구매 거래 조회") + void success_buyType() { + // Happy path 테스트 + } + + @Test + @DisplayName("성공: SELL 타입으로 판매 거래 조회") + void success_sellType() { + // Happy path 테스트 + } + + @Test + @DisplayName("성공: ALL 타입은 구매+판매 모두 조회 (userUuid 필터링)") + void success_allType_filtersCorrectly() { + // ✅ 접근 제어 로직 테스트 + } + + @Test + @DisplayName("성공: 빈 결과 반환") + void success_emptyResult() { + // ✅ 엣지 케이스 테스트 + } + + @Test + @DisplayName("성공: 기본 정렬은 tradeAt 내림차순") + void success_defaultSorting() { + // ✅ 기본값 테스트 + } + + @Test + @DisplayName("실패: 권한 없는 사용자 접근 시 예외 발생") + void fail_accessDenied() { + // ✅ 검증 로직 테스트 + } + } +} +``` + +### 검증 체크리스트 + +테스트를 완료했다고 판단하기 전에 확인: + +1. ✅ Fake가 실제 구현과 **동일한 예외**를 발생시키는가? +2. ✅ Fake가 **동일한 필터**를 적용하는가? (특히 보안 관련) +3. ✅ Fake가 **동일한 기본값**을 사용하는가? +4. ✅ 테스트가 **도메인 검증 로직**을 검증하는가? (예: 접근 제어) +5. ✅ **엣지 케이스와 오류 조건**이 테스트되었는가? +6. ✅ assertion이 **올바른 동작**을 확인하는가, "테스트 통과"만 확인하지 않는가? + +--- + +# 테스트 픽스처 패턴 (필수) + +## 목적 + +테스트 픽스처는 **일관되고 재사용 가능한 테스트 데이터 빌더**를 제공합니다. + +## 구조 + +```java +public class TradeTestFixture { + + public static Trade createDefaultTrade() { + return Trade.builder() + .id(1L) + .tradeNumber("TR-2026-001") + .sellerUuid(UUID.randomUUID()) + .buyerUuid(UUID.randomUUID()) + .tradeType(TradeType.BUY) + .tradeStatus(TradeStatus.COMPLETED) + .tradeAt(LocalDateTime.now()) + .totalWeight(BigDecimal.valueOf(100.0)) + .tradeAmount(BigInteger.valueOf(50000)) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .build(); + } + + public static Trade createTradeWithSeller(UUID sellerUuid) { + return createDefaultTrade().toBuilder() + .sellerUuid(sellerUuid) + .build(); + } + + public static Trade createTradeWithStatus(TradeStatus status) { + return createDefaultTrade().toBuilder() + .tradeStatus(status) + .build(); + } +} +``` + +## 사용법 + +```java +@Test +void someTest() { + // 수동 빌더 대신 픽스처 사용 + Trade trade = TradeTestFixture.createDefaultTrade(); + + // 특정 필드 커스터마이징 + UUID sellerUuid = UUID.randomUUID(); + Trade sellerTrade = TradeTestFixture.createTradeWithSeller(sellerUuid); +} +``` + +--- + +# Fake 리포지토리 패턴 (필수) + +## 목적 + +Fake 리포지토리는 단위 테스트를 위해 실제 리포지토리 동작을 **인메모리**로 시뮬레이션합니다. + +## 구조 + +```java +public class FakeTradeQueryRepository implements TradeQueryRepository { + + private final Map storage = new ConcurrentHashMap<>(); + + // 스토리지 관리 + public void save(Trade trade) { + storage.put(trade.getId(), trade); + } + + public void clear() { + storage.clear(); + } + + // 실제 구현을 정확히 미러링해야 함 + @Override + public Trade findById(Long id) { + Trade trade = storage.get(id); + if (trade == null) { + // ✅ 실제 구현과 동일한 예외 + throw new BaseException(BaseResponseStatus.NO_EXIST_TRADE); + } + return trade; + } + + @Override + public List searchTrades(TradeSearchCondition condition) { + // ✅ 실제 필터링 로직, 기본값, 정렬을 미러링 + // TradeQueryRepositoryImpl과 TradeQuerydslUtil을 먼저 읽으세요! + + Stream stream = storage.values().stream(); + + // 필터 적용 (실제 구현과 동일) + if (condition.getTradeType() != null) { + // ... 타입별 필터링 + } + + // 기본 정렬 적용 (실제 구현과 동일) + String orderField = condition.getOrderField() != null + ? condition.getOrderField() + : "tradeAt"; // ✅ 실제와 동일한 기본값 + + // ... 정렬 및 반환 + return stream.toList(); + } +} +``` + +--- + +# 흔한 안티패턴 (반드시 피할 것) + +## ❌ 안티패턴 1: 항상 통과하는 테스트 + +```java +// ❌ 잘못됨: 테스트가 실제 동작을 검증하지 않음 +@Test +void testFindById() { + Trade trade = tradeQueryRepository.findById(1L); + assertThat(trade).isNotNull(); // 너무 약함 - Fake가 뭐든 반환하면 통과 +} + +// ✅ 올바름: 테스트가 특정 동작을 검증 +@Test +void testFindById() { + Trade expected = TradeTestFixture.createDefaultTrade(); + fakeRepository.save(expected); + + Trade actual = tradeQueryRepository.findById(expected.getId()); + + assertThat(actual.getId()).isEqualTo(expected.getId()); + assertThat(actual.getTradeNumber()).isEqualTo(expected.getTradeNumber()); + // ... 중요한 모든 필드 검증 +} +``` + +## ❌ 안티패턴 2: Fake가 실제와 일치하지 않음 + +```java +// ❌ 잘못됨: Fake가 실제와 다른 로직 사용 +public List searchTrades(TradeSearchCondition condition) { + return storage.values().stream() + .filter(t -> t.getTradeStatus() == TradeStatus.COMPLETED) // 실제는 기본적으로 COMPLETED로 필터링 안함! + .toList(); +} + +// ✅ 올바름: Fake가 실제 로직을 정확히 미러링 +public List searchTrades(TradeSearchCondition condition) { + Stream stream = storage.values().stream(); + + // 조건에서 지정한 경우에만 필터링 (실제와 동일) + if (condition.getTradeStatus() != null) { + stream = stream.filter(t -> t.getTradeStatus() == condition.getTradeStatus()); + } + + return stream.toList(); +} +``` + +## ❌ 안티패턴 3: 도메인 검증 테스트 누락 + +```java +// ❌ 잘못됨: 접근 제어를 테스트하지 않음 +@Test +void testSearchTrades() { + List trades = service.searchTrades(condition); + assertThat(trades).isNotEmpty(); // 접근 제어 로직을 놓침! +} + +// ✅ 올바름: 도메인 검증 테스트 +@Test +void testSearchTrades_accessControlApplied() { + UUID user1 = UUID.randomUUID(); + UUID user2 = UUID.randomUUID(); + + fakeRepository.save(TradeTestFixture.createTradeWithSeller(user1)); + fakeRepository.save(TradeTestFixture.createTradeWithSeller(user2)); + + TradeSearchCondition condition = new TradeSearchCondition(user1, TradeType.ALL, ...); + List trades = service.searchTrades(condition); + + // ✅ 접근 제어 검증: user1의 거래만 반환되어야 함 + assertThat(trades).hasSize(1); + assertThat(trades.get(0).getSellerUuid()).isEqualTo(user1); +} +``` + +## ❌ 안티패턴 4: 예외 테스트 안함 + +```java +// ❌ 잘못됨: 오류 시나리오를 테스트하지 않음 +@Test +void testFindById() { + Trade trade = service.findById(1L); + assertThat(trade).isNotNull(); +} + +// ✅ 올바름: 예외 동작 테스트 +@Test +void testFindById_notFound_throwsException() { + assertThatThrownBy(() -> service.findById(999L)) + .isInstanceOf(BaseException.class) + .hasFieldOrPropertyWithValue("status", BaseResponseStatus.NO_EXIST_TRADE); +} +``` + +--- + +# 테스트 실행 명령어 + +```bash +# 모든 테스트 실행 +./gradlew test + +# 단위 테스트만 실행 +./gradlew test --tests "*.UnitTest" + +# 통합 테스트 실행 +./gradlew test --tests "*.*IntegrationTest" + +# 특정 테스트 클래스 실행 +./gradlew test --tests "TradeSearchServiceUnitTest" + +# 지속적 모드로 테스트 실행 (코드 변경 시 자동 재실행) +./gradlew test --continuous +``` + +--- + +# 요약 체크리스트 + +테스트를 작성하거나 리뷰할 때 확인: + +## E2E 테스트: +- [ ] `@ActiveProfiles("test")` 사용 +- [ ] 테스트 DB가 분리됨 (`greenfirst_test`) +- [ ] 생성한 데이터 ID를 List로 추적 +- [ ] `@AfterEach`에서 추적한 ID만 삭제 +- [ ] **절대 `deleteAllInBatch()` 사용 안함** +- [ ] FK 역순으로 삭제 (자식 → 부모) + +## 단위 테스트: +- [ ] Fake 작성 전 실제 구현 읽음 +- [ ] Fake가 실제와 동일한 예외 발생 +- [ ] Fake가 실제와 동일한 필터 및 기본값 적용 +- [ ] 테스트가 비즈니스 로직 검증 (happy path만이 아님) +- [ ] 테스트에 접근 제어 검증 포함 +- [ ] 테스트에 예외 시나리오 포함 +- [ ] 테스트에 엣지 케이스 포함 +- [ ] 데이터 생성에 테스트 픽스처 사용 +- [ ] 올바른 네이밍 규칙 (`*UnitTest.java`) + +## 체크리스트 항목 중 하나라도 확인되지 않으면, 테스트가 잘못되었습니다. + +--- + +# 출력 기대사항 (Claude용) + +이 스킬이 활성화되면, 반드시: + +1. **테스트 작성 전:** 실제 구현을 먼저 읽기 +2. **Fake 생성 시:** 실제 동작을 정확히 미러링 (예외, 필터, 기본값) +3. **테스트 작성 시:** 비즈니스 로직, 접근 제어, 예외, 엣지 케이스 커버 +4. **E2E 테스트용:** 생성한 ID를 추적하고 해당 ID만 삭제 +5. **절대:** E2E 테스트에서 `deleteAllInBatch()` 사용 금지 +6. **항상:** 테스트가 실제로 올바른 동작을 테스트하는지 확인, 단순 통과만 하는지 확인 안함 + +**기억하세요:** 테스트는 통과하지만 Fake 동작이 실제 구현과 다르다면, **테스트가 잘못된 것입니다**.