backend-claude-code 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.json +42 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +35 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +33 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +32 -0
- package/.mcp.json +19 -0
- package/CLAUDE.md +126 -0
- package/README.md +142 -0
- package/agents/code-reviewer.md +84 -0
- package/agents/database-reviewer.md +91 -0
- package/agents/java-build-resolver.md +127 -0
- package/agents/java-performance-reviewer.md +262 -0
- package/agents/planner.md +99 -0
- package/agents/security-reviewer.md +119 -0
- package/agents/tdd-guide.md +189 -0
- package/bin/cli.js +144 -0
- package/commands/db-migrate.md +134 -0
- package/commands/dev-build.md +72 -0
- package/commands/dev-coverage.md +73 -0
- package/commands/dev-fix.md +75 -0
- package/commands/dev-plan.md +501 -0
- package/commands/dev-review.md +144 -0
- package/commands/dev-run.md +385 -0
- package/commands/dev-test.md +89 -0
- package/commands/dev-verify.md +95 -0
- package/commands/dev.md +45 -0
- package/commands/git-commit.md +112 -0
- package/commands/git-issue.md +74 -0
- package/commands/git-pr.md +184 -0
- package/commands/git-push.md +28 -0
- package/package.json +24 -0
- package/rules/architecture.md +33 -0
- package/rules/coding-style.md +113 -0
- package/rules/controller-patterns.md +63 -0
- package/rules/dto-patterns.md +76 -0
- package/rules/entity-patterns.md +70 -0
- package/rules/error-handling.md +56 -0
- package/rules/hooks.md +73 -0
- package/rules/repository-patterns.md +75 -0
- package/rules/security.md +101 -0
- package/rules/service-patterns.md +70 -0
- package/rules/testing.md +174 -0
- package/skills/api-design/SKILL.md +523 -0
- package/skills/architecture-decision-records/SKILL.md +179 -0
- package/skills/database-migrations/SKILL.md +429 -0
- package/skills/hexagonal-architecture/SKILL.md +276 -0
- package/skills/java-coding-standards/SKILL.md +236 -0
- package/skills/jpa-patterns/SKILL.md +218 -0
- package/skills/postgres-patterns/SKILL.md +147 -0
- package/skills/springboot-patterns/SKILL.md +255 -0
- package/skills/springboot-security/SKILL.md +241 -0
- package/skills/springboot-tdd/SKILL.md +236 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "**/entity/**/*.java"
|
|
4
|
+
- "**/domain/**/*.java"
|
|
5
|
+
---
|
|
6
|
+
# Entity 패턴
|
|
7
|
+
|
|
8
|
+
## 클래스 구조
|
|
9
|
+
|
|
10
|
+
```java
|
|
11
|
+
@Entity
|
|
12
|
+
@Table(name = "foos")
|
|
13
|
+
@Getter
|
|
14
|
+
@Setter
|
|
15
|
+
public class Foo extends BaseEntity {
|
|
16
|
+
@Id
|
|
17
|
+
@Column(name = "foo_id", length = 255)
|
|
18
|
+
private String id;
|
|
19
|
+
}
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## ID
|
|
23
|
+
|
|
24
|
+
- 타입: `String`
|
|
25
|
+
- 생성: `UUID.randomUUID().toString().replace("-", "")` 또는 프로젝트 공통 ID 생성기 사용
|
|
26
|
+
- DB auto-increment 사용 금지 — 서비스에서 생성 후 주입
|
|
27
|
+
|
|
28
|
+
## Enum 컬럼
|
|
29
|
+
|
|
30
|
+
```java
|
|
31
|
+
@Enumerated(EnumType.STRING)
|
|
32
|
+
@Column(length = 30, nullable = false)
|
|
33
|
+
private FooStatus status = FooStatus.ACTIVE;
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
- `EnumType.STRING` 필수 — `ORDINAL` 사용 금지
|
|
37
|
+
- `length = 30` 설정 (값 추가 시 마이그레이션 불필요)
|
|
38
|
+
|
|
39
|
+
## 기본값 처리
|
|
40
|
+
|
|
41
|
+
nullable 컬럼 기본값은 `@PrePersist`에서 처리한다.
|
|
42
|
+
|
|
43
|
+
```java
|
|
44
|
+
@PrePersist
|
|
45
|
+
void applyDefaults() {
|
|
46
|
+
if (content == null) content = "";
|
|
47
|
+
if (count == null) count = 0;
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## 연관관계
|
|
52
|
+
|
|
53
|
+
`@ManyToOne`, `@OneToMany` 매핑 대신 **ID 참조**를 선호한다.
|
|
54
|
+
조인이 필요하면 Repository(QueryDSL)에서 명시적으로 처리한다.
|
|
55
|
+
|
|
56
|
+
```java
|
|
57
|
+
// 권장
|
|
58
|
+
private String ownerId;
|
|
59
|
+
|
|
60
|
+
// 지양 (필요한 경우에만 사용)
|
|
61
|
+
@ManyToOne(fetch = FetchType.LAZY)
|
|
62
|
+
@JoinColumn(name = "owner_id")
|
|
63
|
+
private Owner owner;
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## 금지 사항
|
|
67
|
+
|
|
68
|
+
- `FetchType.EAGER` 사용 금지
|
|
69
|
+
- 엔티티에서 DTO import 금지
|
|
70
|
+
- 비즈니스 로직을 엔티티에 과도하게 집중시키지 않음 (상태 전이 메서드는 허용)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "**/*.java"
|
|
4
|
+
---
|
|
5
|
+
# 에러 처리 패턴
|
|
6
|
+
|
|
7
|
+
## 예외 클래스 구조
|
|
8
|
+
|
|
9
|
+
도메인 예외는 HTTP 상태코드 기반으로 분류하고 `@RestControllerAdvice`에서 중앙 처리한다.
|
|
10
|
+
|
|
11
|
+
```java
|
|
12
|
+
// 사용할 예외 클래스 (프로젝트에 맞게 정의)
|
|
13
|
+
throw new BadRequestException("잘못된 요청입니다.");
|
|
14
|
+
throw new NotFoundException("리소스를 찾을 수 없습니다.");
|
|
15
|
+
throw new ForbiddenException("접근 권한이 없습니다.");
|
|
16
|
+
throw new ConflictException("이미 존재합니다.");
|
|
17
|
+
throw new UnauthorizedException("인증이 필요합니다.");
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## GlobalExceptionHandler
|
|
21
|
+
|
|
22
|
+
```java
|
|
23
|
+
@RestControllerAdvice
|
|
24
|
+
public class GlobalExceptionHandler {
|
|
25
|
+
|
|
26
|
+
@ExceptionHandler(NotFoundException.class)
|
|
27
|
+
ResponseEntity<ErrorResponse> handleNotFound(NotFoundException ex) {
|
|
28
|
+
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
|
29
|
+
.body(ErrorResponse.of(404, ex.getMessage()));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
@ExceptionHandler(MethodArgumentNotValidException.class)
|
|
33
|
+
ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
|
|
34
|
+
String message = ex.getBindingResult().getFieldErrors().stream()
|
|
35
|
+
.map(e -> e.getField() + ": " + e.getDefaultMessage())
|
|
36
|
+
.collect(Collectors.joining(", "));
|
|
37
|
+
return ResponseEntity.badRequest().body(ErrorResponse.of(400, message));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## 에러 응답 포맷
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
{
|
|
46
|
+
"status": 404,
|
|
47
|
+
"code": "NOT_FOUND",
|
|
48
|
+
"message": "리소스를 찾을 수 없습니다."
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## 규칙
|
|
53
|
+
|
|
54
|
+
- 비즈니스 예외는 반드시 커스텀 예외 클래스 사용 — `RuntimeException` 직접 throw 금지
|
|
55
|
+
- catch 블록에서 예외 무시(swallow) 금지 — 반드시 로깅하거나 재throw
|
|
56
|
+
- 예외 메시지에 민감한 정보(비밀번호, 토큰 등) 포함 금지
|
package/rules/hooks.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "**/*.java"
|
|
4
|
+
- "**/pom.xml"
|
|
5
|
+
- "**/build.gradle"
|
|
6
|
+
- "**/build.gradle.kts"
|
|
7
|
+
---
|
|
8
|
+
# Java Hooks
|
|
9
|
+
|
|
10
|
+
> This file extends [common/hooks.md](../common/hooks.md) with Java-specific content.
|
|
11
|
+
|
|
12
|
+
## PostToolUse 훅
|
|
13
|
+
|
|
14
|
+
`.claude/settings.json`에 설정:
|
|
15
|
+
|
|
16
|
+
### 컴파일 검증
|
|
17
|
+
|
|
18
|
+
Java 파일 편집 후 자동 컴파일:
|
|
19
|
+
|
|
20
|
+
```json
|
|
21
|
+
{
|
|
22
|
+
"hooks": {
|
|
23
|
+
"PostToolUse": [
|
|
24
|
+
{
|
|
25
|
+
"matcher": "Edit|Write",
|
|
26
|
+
"command": "if echo \"$FILE_PATH\" | grep -q '\\.java$'; then (./mvnw compile -q --no-transfer-progress 2>&1 | tail -5 || ./gradlew compileJava -q 2>&1 | tail -5) && echo '[Hook] Compile OK' || echo '[Hook] Compile failed'; fi",
|
|
27
|
+
"description": "Java 파일 편집 후 컴파일 검증"
|
|
28
|
+
}
|
|
29
|
+
]
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Checkstyle 검사
|
|
35
|
+
|
|
36
|
+
```json
|
|
37
|
+
{
|
|
38
|
+
"hooks": {
|
|
39
|
+
"PostToolUse": [
|
|
40
|
+
{
|
|
41
|
+
"matcher": "Edit|Write",
|
|
42
|
+
"command": "if echo \"$FILE_PATH\" | grep -q '\\.java$'; then ./mvnw checkstyle:check -q 2>&1 | tail -3 || echo '[Hook] Checkstyle failed'; fi",
|
|
43
|
+
"description": "Java 파일 편집 후 스타일 검사"
|
|
44
|
+
}
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Stop 훅
|
|
51
|
+
|
|
52
|
+
### 최종 빌드 검증
|
|
53
|
+
|
|
54
|
+
세션 종료 시:
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{
|
|
58
|
+
"hooks": {
|
|
59
|
+
"Stop": [
|
|
60
|
+
{
|
|
61
|
+
"command": "(./mvnw verify -q --no-transfer-progress 2>&1 | tail -10 || ./gradlew check -q 2>&1 | tail -10) && echo '[Hook] Final verify OK' || echo '[Hook] Verify failed'",
|
|
62
|
+
"description": "세션 종료 시 전체 빌드/테스트 검증"
|
|
63
|
+
}
|
|
64
|
+
]
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## 권장 순서
|
|
70
|
+
|
|
71
|
+
1. 컴파일 검증 (빠른 피드백)
|
|
72
|
+
2. Checkstyle (스타일 강제)
|
|
73
|
+
3. 최종 `verify` (커버리지 포함 전체 검사)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "**/repository/**/*.java"
|
|
4
|
+
---
|
|
5
|
+
# Repository 패턴
|
|
6
|
+
|
|
7
|
+
## 3단계 구조
|
|
8
|
+
|
|
9
|
+
복잡한 쿼리가 있는 엔티티는 3개 파일로 구성한다.
|
|
10
|
+
|
|
11
|
+
```java
|
|
12
|
+
// 1. JPA 인터페이스
|
|
13
|
+
public interface FooRepository extends JpaRepository<Foo, String>, FooRepositoryCustom {
|
|
14
|
+
Optional<Foo> findByOwnerId(String ownerId);
|
|
15
|
+
List<Foo> findAllByAccountId(String accountId);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// 2. Custom 인터페이스
|
|
19
|
+
public interface FooRepositoryCustom {
|
|
20
|
+
List<Foo> findByCondition(String accountId, FooStatus status);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// 3. QueryDSL 구현체
|
|
24
|
+
@Repository
|
|
25
|
+
@RequiredArgsConstructor
|
|
26
|
+
public class FooRepositoryImpl implements FooRepositoryCustom {
|
|
27
|
+
private final JPAQueryFactory queryFactory;
|
|
28
|
+
|
|
29
|
+
@Override
|
|
30
|
+
public List<Foo> findByCondition(String accountId, FooStatus status) {
|
|
31
|
+
QFoo foo = QFoo.foo;
|
|
32
|
+
return queryFactory
|
|
33
|
+
.selectFrom(foo)
|
|
34
|
+
.where(foo.accountId.eq(accountId), foo.status.eq(status))
|
|
35
|
+
.fetch();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## QueryDSL 규칙
|
|
41
|
+
|
|
42
|
+
- Q-type은 메서드 내 지역 변수로 선언: `QFoo foo = QFoo.foo`
|
|
43
|
+
- `.fetchOne()` — 정확히 하나 기대 (없으면 null, 둘 이상이면 예외)
|
|
44
|
+
- `.fetchFirst()` — 첫 번째 또는 null (유니크 보장 쿼리에 사용 금지)
|
|
45
|
+
- `.fetch()` — 리스트
|
|
46
|
+
- null-safe 집계: `.sum()`, `.count()` 뒤에 `.coalesce(0)` 체이닝
|
|
47
|
+
|
|
48
|
+
```java
|
|
49
|
+
QFoo foo = QFoo.foo;
|
|
50
|
+
QBar bar = QBar.bar;
|
|
51
|
+
|
|
52
|
+
Integer total = queryFactory
|
|
53
|
+
.select(foo.amount.sum().coalesce(0))
|
|
54
|
+
.from(foo)
|
|
55
|
+
.join(bar).on(bar.id.eq(foo.barId))
|
|
56
|
+
.where(foo.accountId.eq(accountId))
|
|
57
|
+
.fetchOne();
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## JPA vs QueryDSL 분리 기준
|
|
61
|
+
|
|
62
|
+
```java
|
|
63
|
+
// JPA 네이밍으로 충분
|
|
64
|
+
Optional<Foo> findByBarId(String barId);
|
|
65
|
+
List<Foo> findAllByAccountIdOrderByCreatedAtDesc(String accountId);
|
|
66
|
+
|
|
67
|
+
// QueryDSL 사용 (조인, 집계, 동적 조건)
|
|
68
|
+
List<Foo> findByMultipleConditions(String accountId, List<FooStatus> statuses);
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## 금지 사항
|
|
72
|
+
|
|
73
|
+
- `FetchType.EAGER` 사용 금지
|
|
74
|
+
- 루프 내 지연 로딩 호출 금지 (N+1 유발)
|
|
75
|
+
- Repository에서 비즈니스 로직 작성 금지
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "**/*.java"
|
|
4
|
+
- "**/application*.yml"
|
|
5
|
+
- "**/application*.properties"
|
|
6
|
+
---
|
|
7
|
+
# Java Security
|
|
8
|
+
|
|
9
|
+
> This file extends [common/security.md](../common/security.md) with Java-specific content.
|
|
10
|
+
|
|
11
|
+
## 시크릿 관리
|
|
12
|
+
|
|
13
|
+
- 절대 소스 코드에 API 키, 토큰, 자격 증명을 하드코딩하지 않기
|
|
14
|
+
- 환경 변수 사용: `System.getenv("API_KEY")`
|
|
15
|
+
- 프로덕션 시크릿에는 시크릿 관리자 사용 (Vault, AWS Secrets Manager)
|
|
16
|
+
- 시크릿이 있는 로컬 설정 파일은 `.gitignore`에 포함
|
|
17
|
+
|
|
18
|
+
```java
|
|
19
|
+
// BAD
|
|
20
|
+
private static final String API_KEY = "sk-abc123...";
|
|
21
|
+
|
|
22
|
+
// GOOD — 환경 변수
|
|
23
|
+
String apiKey = System.getenv("PAYMENT_API_KEY");
|
|
24
|
+
Objects.requireNonNull(apiKey, "PAYMENT_API_KEY must be set");
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## SQL 인젝션 방지
|
|
28
|
+
|
|
29
|
+
- 항상 파라미터화된 쿼리 사용 — 사용자 입력을 SQL에 절대 연결하지 않기
|
|
30
|
+
- `PreparedStatement` 또는 프레임워크의 파라미터화 쿼리 API 사용
|
|
31
|
+
|
|
32
|
+
```java
|
|
33
|
+
// BAD — 문자열 연결로 SQL 인젝션 위험
|
|
34
|
+
String sql = "SELECT * FROM orders WHERE name = '" + name + "'";
|
|
35
|
+
|
|
36
|
+
// GOOD — PreparedStatement
|
|
37
|
+
PreparedStatement ps = conn.prepareStatement("SELECT * FROM orders WHERE name = ?");
|
|
38
|
+
ps.setString(1, name);
|
|
39
|
+
|
|
40
|
+
// GOOD — JDBC 템플릿
|
|
41
|
+
jdbcTemplate.query("SELECT * FROM orders WHERE name = ?", mapper, name);
|
|
42
|
+
|
|
43
|
+
// GOOD — Spring Data JPA 파라미터
|
|
44
|
+
@Query("select o from Order o where o.name = :name")
|
|
45
|
+
List<Order> findByName(@Param("name") String name);
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## 입력 검증
|
|
49
|
+
|
|
50
|
+
- 처리 전 시스템 경계에서 모든 사용자 입력 검증
|
|
51
|
+
- Bean Validation 사용 (`@NotNull`, `@NotBlank`, `@Size`) — DTO에 적용
|
|
52
|
+
- 파일 경로와 사용자 제공 문자열 새니타이즈
|
|
53
|
+
- 검증 실패 시 명확한 오류 메시지와 함께 거부
|
|
54
|
+
|
|
55
|
+
```java
|
|
56
|
+
// Spring Boot DTO 검증
|
|
57
|
+
public record CreateOrderRequest(
|
|
58
|
+
@NotBlank @Size(max = 100) String customerName,
|
|
59
|
+
@NotNull @Positive BigDecimal amount,
|
|
60
|
+
@NotNull @FutureOrPresent LocalDate deliveryDate
|
|
61
|
+
) {}
|
|
62
|
+
|
|
63
|
+
// 컨트롤러에서 @Valid 적용
|
|
64
|
+
@PostMapping("/orders")
|
|
65
|
+
ResponseEntity<OrderResponse> create(@Valid @RequestBody CreateOrderRequest request) {
|
|
66
|
+
return ResponseEntity.status(HttpStatus.CREATED)
|
|
67
|
+
.body(orderService.create(request));
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## 인증 및 인가
|
|
72
|
+
|
|
73
|
+
- 절대 커스텀 암호화 구현 금지 — 검증된 라이브러리 사용
|
|
74
|
+
- 비밀번호는 bcrypt 또는 Argon2로 저장, MD5/SHA1 금지
|
|
75
|
+
- 서비스 경계에서 인가 검사 강제
|
|
76
|
+
- 로그에서 민감 데이터 제거 — 비밀번호, 토큰, PII 절대 로깅 금지
|
|
77
|
+
|
|
78
|
+
## 의존성 보안
|
|
79
|
+
|
|
80
|
+
- `mvn dependency:tree` 또는 `./gradlew dependencies`로 전이 의존성 감사
|
|
81
|
+
- OWASP Dependency-Check 또는 Snyk으로 알려진 CVE 스캔
|
|
82
|
+
- 의존성 최신 상태 유지 — Dependabot 또는 Renovate 설정
|
|
83
|
+
|
|
84
|
+
## 오류 메시지
|
|
85
|
+
|
|
86
|
+
- API 응답에 스택 트레이스, 내부 경로, SQL 오류 절대 노출 금지
|
|
87
|
+
- 핸들러 경계에서 예외를 안전하고 일반적인 클라이언트 메시지로 매핑
|
|
88
|
+
- 서버 측에 상세 오류 로깅; 클라이언트에는 일반 메시지 반환
|
|
89
|
+
|
|
90
|
+
```java
|
|
91
|
+
@ExceptionHandler(Exception.class)
|
|
92
|
+
ResponseEntity<ApiResponse<Void>> handleGeneric(Exception ex) {
|
|
93
|
+
log.error("Unexpected error", ex); // 서버 로그에만 상세 정보
|
|
94
|
+
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
|
95
|
+
.body(ApiResponse.error("Internal server error")); // 클라이언트에 일반 메시지
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## 참고
|
|
100
|
+
|
|
101
|
+
skill: `springboot-security` — Spring Security 인증/인가 패턴
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "**/service/**/*.java"
|
|
4
|
+
---
|
|
5
|
+
# Service 레이어 패턴
|
|
6
|
+
|
|
7
|
+
## 클래스 구조
|
|
8
|
+
|
|
9
|
+
```java
|
|
10
|
+
@Service
|
|
11
|
+
@RequiredArgsConstructor
|
|
12
|
+
@Slf4j
|
|
13
|
+
public class FooService {
|
|
14
|
+
private final FooRepository fooRepository;
|
|
15
|
+
}
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
모든 의존성은 `@RequiredArgsConstructor` + `private final`. `@Autowired` 사용 금지.
|
|
19
|
+
|
|
20
|
+
## @Transactional
|
|
21
|
+
|
|
22
|
+
- `org.springframework.transaction.annotation.Transactional` 만 사용 — `jakarta.transaction.Transactional` 금지
|
|
23
|
+
- SELECT만 수행하는 메서드: `@Transactional(readOnly = true)` 필수
|
|
24
|
+
- INSERT/UPDATE/DELETE 또는 다른 `@Transactional` 메서드 호출: `@Transactional`
|
|
25
|
+
|
|
26
|
+
```java
|
|
27
|
+
@Transactional(readOnly = true)
|
|
28
|
+
public FooDtos.FooResponse getFoo(String id) { ... }
|
|
29
|
+
|
|
30
|
+
@Transactional
|
|
31
|
+
public FooDtos.FooResponse createFoo(String ownerId, FooDtos.CreateRequest request) { ... }
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## 조회 + 소유권 체크 패턴
|
|
35
|
+
|
|
36
|
+
엔티티 조회 후 소유권 검증이 반복되면 private 메서드로 분리한다.
|
|
37
|
+
|
|
38
|
+
```java
|
|
39
|
+
private Foo findOwnedFoo(String ownerId, String fooId) {
|
|
40
|
+
Foo foo = fooRepository.findById(fooId)
|
|
41
|
+
.orElseThrow(() -> new NotFoundException("리소스를 찾을 수 없습니다."));
|
|
42
|
+
if (!foo.getOwnerId().equals(ownerId)) {
|
|
43
|
+
throw new ForbiddenException("접근 권한이 없습니다.");
|
|
44
|
+
}
|
|
45
|
+
return foo;
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Service 분리 기준
|
|
50
|
+
|
|
51
|
+
단일 Service가 커질 때:
|
|
52
|
+
- **파이프라인 단계별**: `FooService` → `FooProcessingService`, `FooQueryService`
|
|
53
|
+
- **외부 서비스별**: `AuthService` → `AppleAuthService`, `KakaoAuthService`
|
|
54
|
+
- **내부 호출 전용**: `FooInternalService` — Kafka 컨슈머 등에서만 호출
|
|
55
|
+
|
|
56
|
+
## 입력 정규화
|
|
57
|
+
|
|
58
|
+
null 또는 유효하지 않은 값은 서비스 진입점에서 정규화한다.
|
|
59
|
+
|
|
60
|
+
```java
|
|
61
|
+
private int normalizeDuration(Integer duration) {
|
|
62
|
+
return (duration == null || duration < 0) ? 0 : duration;
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## 금지 사항
|
|
67
|
+
|
|
68
|
+
- `@Transactional(readOnly = true)` 메서드에서 엔티티 변경 금지
|
|
69
|
+
- Service 간 순환 의존 금지
|
|
70
|
+
- HTTP 관련 객체(HttpServletRequest 등) Service에서 직접 사용 금지
|
package/rules/testing.md
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "**/*.java"
|
|
4
|
+
- "**/pom.xml"
|
|
5
|
+
- "**/build.gradle"
|
|
6
|
+
- "**/build.gradle.kts"
|
|
7
|
+
---
|
|
8
|
+
# Java Testing
|
|
9
|
+
|
|
10
|
+
> This file extends [common/testing.md](../common/testing.md) with Java-specific content.
|
|
11
|
+
|
|
12
|
+
## 테스트 프레임워크
|
|
13
|
+
|
|
14
|
+
- **JUnit 5** (`@Test`, `@ParameterizedTest`, `@Nested`, `@DisplayName`)
|
|
15
|
+
- **AssertJ** — 유창한 단언문 (`assertThat(result).isEqualTo(expected)`)
|
|
16
|
+
- **Mockito** — 의존성 모킹
|
|
17
|
+
- **Testcontainers** — 데이터베이스나 서비스가 필요한 통합 테스트
|
|
18
|
+
|
|
19
|
+
## 테스트 구성
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
src/test/java/com/example/app/
|
|
23
|
+
service/ # 서비스 레이어 단위 테스트
|
|
24
|
+
controller/ # 웹 레이어 / API 테스트
|
|
25
|
+
repository/ # 데이터 접근 테스트
|
|
26
|
+
integration/ # 교차 레이어 통합 테스트
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`src/main/java` 패키지 구조를 `src/test/java`에 미러링.
|
|
30
|
+
|
|
31
|
+
## 단위 테스트 패턴
|
|
32
|
+
|
|
33
|
+
```java
|
|
34
|
+
@ExtendWith(MockitoExtension.class)
|
|
35
|
+
class OrderServiceTest {
|
|
36
|
+
|
|
37
|
+
@Mock
|
|
38
|
+
private OrderRepository orderRepository;
|
|
39
|
+
|
|
40
|
+
private OrderService orderService;
|
|
41
|
+
|
|
42
|
+
@BeforeEach
|
|
43
|
+
void setUp() {
|
|
44
|
+
orderService = new OrderService(orderRepository);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
@Test
|
|
48
|
+
@DisplayName("findById가 존재하는 주문을 반환")
|
|
49
|
+
void findById_existingOrder_returnsOrder() {
|
|
50
|
+
var order = new Order(1L, "Alice", BigDecimal.TEN);
|
|
51
|
+
when(orderRepository.findById(1L)).thenReturn(Optional.of(order));
|
|
52
|
+
|
|
53
|
+
var result = orderService.findById(1L);
|
|
54
|
+
|
|
55
|
+
assertThat(result.customerName()).isEqualTo("Alice");
|
|
56
|
+
verify(orderRepository).findById(1L);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
@Test
|
|
60
|
+
@DisplayName("findById가 주문 없을 때 예외 발생")
|
|
61
|
+
void findById_missingOrder_throws() {
|
|
62
|
+
when(orderRepository.findById(99L)).thenReturn(Optional.empty());
|
|
63
|
+
|
|
64
|
+
assertThatThrownBy(() -> orderService.findById(99L))
|
|
65
|
+
.isInstanceOf(OrderNotFoundException.class)
|
|
66
|
+
.hasMessageContaining("99");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## 웹 레이어 테스트 (MockMvc)
|
|
72
|
+
|
|
73
|
+
```java
|
|
74
|
+
@WebMvcTest(OrderController.class)
|
|
75
|
+
class OrderControllerTest {
|
|
76
|
+
@Autowired MockMvc mockMvc;
|
|
77
|
+
@MockBean OrderService orderService;
|
|
78
|
+
|
|
79
|
+
@Test
|
|
80
|
+
@DisplayName("POST /orders — 201 Created 반환")
|
|
81
|
+
void createOrder_returns201() throws Exception {
|
|
82
|
+
when(orderService.create(any())).thenReturn(orderResponse());
|
|
83
|
+
|
|
84
|
+
mockMvc.perform(post("/api/orders")
|
|
85
|
+
.contentType(MediaType.APPLICATION_JSON)
|
|
86
|
+
.content("""{"customerName":"Alice","amount":100}"""))
|
|
87
|
+
.andExpect(status().isCreated())
|
|
88
|
+
.andExpect(jsonPath("$.data.customerName").value("Alice"));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## 통합 테스트 (Testcontainers)
|
|
94
|
+
|
|
95
|
+
```java
|
|
96
|
+
@Testcontainers
|
|
97
|
+
class OrderRepositoryIT {
|
|
98
|
+
|
|
99
|
+
@Container
|
|
100
|
+
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
|
|
101
|
+
|
|
102
|
+
private OrderRepository repository;
|
|
103
|
+
|
|
104
|
+
@BeforeEach
|
|
105
|
+
void setUp() {
|
|
106
|
+
var dataSource = new PGSimpleDataSource();
|
|
107
|
+
dataSource.setUrl(postgres.getJdbcUrl());
|
|
108
|
+
dataSource.setUser(postgres.getUsername());
|
|
109
|
+
dataSource.setPassword(postgres.getPassword());
|
|
110
|
+
repository = new JdbcOrderRepository(dataSource);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
@Test
|
|
114
|
+
void save_and_findById() {
|
|
115
|
+
var saved = repository.save(new Order(null, "Bob", BigDecimal.ONE));
|
|
116
|
+
var found = repository.findById(saved.getId());
|
|
117
|
+
assertThat(found).isPresent();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## 테스트 슬라이스 선택
|
|
123
|
+
|
|
124
|
+
| 테스트 대상 | 사용할 어노테이션 |
|
|
125
|
+
|------------|----------------|
|
|
126
|
+
| 서비스 단위 테스트 | `@ExtendWith(MockitoExtension.class)` |
|
|
127
|
+
| 컨트롤러 테스트 | `@WebMvcTest` |
|
|
128
|
+
| JPA 레포지토리 테스트 | `@DataJpaTest` |
|
|
129
|
+
| 통합 테스트 | `@SpringBootTest` + `@AutoConfigureMockMvc` |
|
|
130
|
+
|
|
131
|
+
## 테스트 이름 규칙
|
|
132
|
+
|
|
133
|
+
- 메서드명: `methodName_scenario_expectedBehavior()`
|
|
134
|
+
- `@DisplayName`: 사람이 읽기 좋은 설명
|
|
135
|
+
|
|
136
|
+
## 커버리지 (JaCoCo)
|
|
137
|
+
|
|
138
|
+
Maven:
|
|
139
|
+
```xml
|
|
140
|
+
<plugin>
|
|
141
|
+
<groupId>org.jacoco</groupId>
|
|
142
|
+
<artifactId>jacoco-maven-plugin</artifactId>
|
|
143
|
+
<version>0.8.14</version>
|
|
144
|
+
<executions>
|
|
145
|
+
<execution><goals><goal>prepare-agent</goal></goals></execution>
|
|
146
|
+
<execution>
|
|
147
|
+
<id>report</id>
|
|
148
|
+
<phase>verify</phase>
|
|
149
|
+
<goals><goal>report</goal></goals>
|
|
150
|
+
</execution>
|
|
151
|
+
<execution>
|
|
152
|
+
<id>check</id>
|
|
153
|
+
<goals><goal>check</goal></goals>
|
|
154
|
+
<configuration>
|
|
155
|
+
<rules>
|
|
156
|
+
<rule>
|
|
157
|
+
<limits>
|
|
158
|
+
<limit>
|
|
159
|
+
<counter>LINE</counter>
|
|
160
|
+
<value>COVEREDRATIO</value>
|
|
161
|
+
<minimum>0.80</minimum>
|
|
162
|
+
</limit>
|
|
163
|
+
</limits>
|
|
164
|
+
</rule>
|
|
165
|
+
</rules>
|
|
166
|
+
</configuration>
|
|
167
|
+
</execution>
|
|
168
|
+
</executions>
|
|
169
|
+
</plugin>
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## 참고
|
|
173
|
+
|
|
174
|
+
skill: `springboot-tdd` — MockMvc와 Testcontainers를 사용한 Spring Boot TDD 패턴
|