@agentsmd/preset-spring 0.0.0-stage → 0.6.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/LICENSE +21 -0
- package/README.md +14 -2
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +43 -0
- package/dist/index.js.map +1 -0
- package/files/docs/spring-api.md +72 -0
- package/files/docs/spring-architecture.md +82 -0
- package/files/docs/spring-data-jpa.md +88 -0
- package/files/docs/spring-testing.md +72 -0
- package/package.json +43 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 이태경
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @agentsmd/preset-spring
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[`@agentsmd/cli`](https://www.npmjs.com/package/@agentsmd/cli)의 Spring Boot 서버 프리셋입니다. Spring Boot 4(Spring Framework 7) 기준입니다.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx @agentsmd/cli init --preset git,java,spring # Git·Java 규칙과 함께 (권장)
|
|
7
|
+
npx @agentsmd/cli init --preset java,spring # Java 규칙과 함께
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
- 구조·설정: 기능 단위 패키지, 레이어 의존 방향, 생성자 주입, DTO 경계, `@ConfigurationProperties`, JSpecify
|
|
11
|
+
- JPA·트랜잭션: 엔티티 규칙, 지연 로딩과 N+1, `@Transactional`·`readOnly`, open-in-view, Flyway 마이그레이션
|
|
12
|
+
- API: URL·상태 코드, Bean Validation, `ProblemDetail` 오류 응답, 페이징
|
|
13
|
+
- 테스트: `@WebMvcTest`·`MockMvcTester`, `@DataJpaTest`, `@MockitoBean`, Testcontainers `@ServiceConnection`
|
|
14
|
+
- Java 코드 규칙, 검증 명령, 포맷 설정은 넣지 않습니다. [Java 프리셋](https://www.npmjs.com/package/@agentsmd/preset-java)을 함께 고르면 더해집니다.
|
|
15
|
+
- Git 훅·GitHub 설정은 설치하지 않습니다. `AGENTS.md`는 고른 프리셋에 맞춰 만들어집니다.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spring Boot 서버용 프리셋. Spring Boot 4(Spring Framework 7) 기준이다.
|
|
3
|
+
*
|
|
4
|
+
* 패키지 구조·설정, JPA·트랜잭션, API·테스트 문서를 담는다.
|
|
5
|
+
* Java 코드 규칙과 검증 명령, 포맷 설정은 넣지 않는다. Java 프리셋을 함께 고르면 더해진다.
|
|
6
|
+
* 프리셋끼리 의존하지 않으므로, 이 프리셋만 설치해도 문서의 링크가 깨지지 않게 다른 프리셋 문서로 링크하지 않는다.
|
|
7
|
+
* `files/`는 빌드 결과(dist/)와 소스(src/) 어디서 실행해도 패키지 루트 기준으로 같은 위치다.
|
|
8
|
+
*/
|
|
9
|
+
export declare const springPreset: {
|
|
10
|
+
name: string;
|
|
11
|
+
description: string;
|
|
12
|
+
filesDir: string;
|
|
13
|
+
docs: {
|
|
14
|
+
path: string;
|
|
15
|
+
when: string;
|
|
16
|
+
}[];
|
|
17
|
+
rules: string[];
|
|
18
|
+
nextSteps: string[];
|
|
19
|
+
};
|
|
20
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA;;;;;;;GAOG;AACH,eAAO,MAAM,YAAY;;;;;;;;;;CAgCP,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
/**
|
|
3
|
+
* Spring Boot 서버용 프리셋. Spring Boot 4(Spring Framework 7) 기준이다.
|
|
4
|
+
*
|
|
5
|
+
* 패키지 구조·설정, JPA·트랜잭션, API·테스트 문서를 담는다.
|
|
6
|
+
* Java 코드 규칙과 검증 명령, 포맷 설정은 넣지 않는다. Java 프리셋을 함께 고르면 더해진다.
|
|
7
|
+
* 프리셋끼리 의존하지 않으므로, 이 프리셋만 설치해도 문서의 링크가 깨지지 않게 다른 프리셋 문서로 링크하지 않는다.
|
|
8
|
+
* `files/`는 빌드 결과(dist/)와 소스(src/) 어디서 실행해도 패키지 루트 기준으로 같은 위치다.
|
|
9
|
+
*/
|
|
10
|
+
export const springPreset = {
|
|
11
|
+
name: "spring",
|
|
12
|
+
description: "Spring Boot 서버용 구조·JPA·트랜잭션·API·테스트 규칙",
|
|
13
|
+
filesDir: fileURLToPath(new URL("../files", import.meta.url)),
|
|
14
|
+
docs: [
|
|
15
|
+
{
|
|
16
|
+
path: "docs/spring-architecture.md",
|
|
17
|
+
when: "클래스·패키지를 만들 때. 패키지 구조, 의존 방향, 주입, DTO 경계, 설정",
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
path: "docs/spring-data-jpa.md",
|
|
21
|
+
when: "엔티티·Repository·트랜잭션을 다룰 때. 지연 로딩, N+1, 스키마 마이그레이션",
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
path: "docs/spring-api.md",
|
|
25
|
+
when: "컨트롤러·API 를 만들 때. URL, 상태 코드, 요청 검증, 오류 응답, 페이징",
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
path: "docs/spring-testing.md",
|
|
29
|
+
when: "Spring 이 필요한 테스트를 쓸 때. 슬라이스 테스트, @MockitoBean, Testcontainers",
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
rules: [
|
|
33
|
+
"필드 주입 대신 생성자 주입을 쓴다.",
|
|
34
|
+
"엔티티를 API 요청·응답으로 쓰지 않는다. DTO(record)로 바꾼다.",
|
|
35
|
+
"다른 기능의 repository·엔티티를 직접 쓰지 않고 그 기능의 service 를 통한다.",
|
|
36
|
+
"`@Transactional` 은 service 에 두고, 조회는 `readOnly = true`. 트랜잭션 안에서 외부 API 를 부르지 않는다.",
|
|
37
|
+
],
|
|
38
|
+
nextSteps: [
|
|
39
|
+
"검증 명령은 java 프리셋이 넣습니다. 함께 설치하지 않았다면 agentsmd init --preset java 를 실행하세요.",
|
|
40
|
+
"application.yml 에 docs/spring-data-jpa.md 의 open-in-view·batch fetch 설정을 넣으세요.",
|
|
41
|
+
],
|
|
42
|
+
};
|
|
43
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAGzC;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,YAAY,GAAG;IAC1B,IAAI,EAAE,QAAQ;IACd,WAAW,EAAE,wCAAwC;IACrD,QAAQ,EAAE,aAAa,CAAC,IAAI,GAAG,CAAC,UAAU,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAC7D,IAAI,EAAE;QACJ;YACE,IAAI,EAAE,6BAA6B;YACnC,IAAI,EAAE,8CAA8C;SACrD;QACD;YACE,IAAI,EAAE,yBAAyB;YAC/B,IAAI,EAAE,mDAAmD;SAC1D;QACD;YACE,IAAI,EAAE,oBAAoB;YAC1B,IAAI,EAAE,gDAAgD;SACvD;QACD;YACE,IAAI,EAAE,wBAAwB;YAC9B,IAAI,EAAE,+DAA+D;SACtE;KACF;IACD,KAAK,EAAE;QACL,sBAAsB;QACtB,4CAA4C;QAC5C,sDAAsD;QACtD,oFAAoF;KACrF;IACD,SAAS,EAAE;QACT,0EAA0E;QAC1E,gFAAgF;KACjF;CACe,CAAC"}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# API
|
|
2
|
+
|
|
3
|
+
Spring MVC 로 REST API 를 만들 때의 규칙이다.
|
|
4
|
+
|
|
5
|
+
## 컨트롤러
|
|
6
|
+
|
|
7
|
+
- 컨트롤러는 요청 검증, 서비스 호출, 응답 변환만 한다. 업무 규칙은 서비스와 도메인에 둔다.
|
|
8
|
+
- 요청·응답은 DTO(record)로 받고 돌려준다. → [spring-architecture.md](spring-architecture.md)
|
|
9
|
+
- 컨트롤러마다 try-catch 로 예외를 응답으로 바꾸지 않는다. 전역 예외 처리에 맡긴다.
|
|
10
|
+
|
|
11
|
+
## URL 과 메서드
|
|
12
|
+
|
|
13
|
+
- 자원은 복수 명사로 쓴다. (`/orders`, `/orders/{orderId}`) URL 에 동사를 넣지 않는다. (`/getOrders`)
|
|
14
|
+
- CRUD 로 표현하기 어려운 상태 전이는 하위 경로로 쓴다. (`POST /orders/{orderId}/cancel`)
|
|
15
|
+
|
|
16
|
+
| 메서드 | 용도 | 성공 응답 |
|
|
17
|
+
| --- | --- | --- |
|
|
18
|
+
| `GET` | 조회 | `200` |
|
|
19
|
+
| `POST` | 생성, 상태 전이 | 생성은 `201` 과 `Location` 헤더, 상태 전이는 `200`·`204` |
|
|
20
|
+
| `PUT` | 전체 교체 | `200`·`204` |
|
|
21
|
+
| `PATCH` | 일부 수정 | `200`·`204` |
|
|
22
|
+
| `DELETE` | 삭제 | `204` |
|
|
23
|
+
|
|
24
|
+
| 실패 상황 | 상태 코드 |
|
|
25
|
+
| --- | --- |
|
|
26
|
+
| 요청 형식·값 검증 실패 | `400` |
|
|
27
|
+
| 인증 없음 | `401` |
|
|
28
|
+
| 권한 없음 | `403` |
|
|
29
|
+
| 자원 없음 | `404` |
|
|
30
|
+
| 현재 상태와 충돌 (이미 취소된 주문 취소 등) | `409` |
|
|
31
|
+
| 서버 오류 | `500` |
|
|
32
|
+
|
|
33
|
+
## 요청 검증
|
|
34
|
+
|
|
35
|
+
- 요청 DTO 필드에 Bean Validation(`@NotBlank`, `@Positive`, `@Size` 등)을 붙이고, 컨트롤러 파라미터에 `@Valid` 를 붙인다.
|
|
36
|
+
- 형식 검증(필수, 길이, 범위)은 DTO 에서, 업무 규칙 검증(재고, 상태)은 도메인·서비스에서 한다.
|
|
37
|
+
|
|
38
|
+
```java
|
|
39
|
+
public record CreateOrderRequest(@NotNull Long productId, @Positive int quantity) {}
|
|
40
|
+
|
|
41
|
+
@PostMapping("/orders")
|
|
42
|
+
public ResponseEntity<OrderResponse> create(@Valid @RequestBody CreateOrderRequest request) {
|
|
43
|
+
OrderResponse response = orderService.create(request.productId(), request.quantity());
|
|
44
|
+
return ResponseEntity.created(URI.create("/orders/" + response.id())).body(response);
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## 오류 응답
|
|
49
|
+
|
|
50
|
+
- 오류 응답은 RFC 9457 Problem Details 형식으로 통일한다. Spring 의 `ProblemDetail` 을 쓴다.
|
|
51
|
+
- `spring.mvc.problemdetails.enabled: true` 로 Spring 이 던지는 기본 예외(검증 실패 등)도 같은 형식으로 응답한다.
|
|
52
|
+
- 기능 예외는 `@RestControllerAdvice` 한 곳에서 상태 코드와 오류 코드로 바꾼다.
|
|
53
|
+
- 오류 코드(`ORDER_NOT_CANCELABLE`)를 함께 내려 클라이언트가 메시지 문구가 아니라 코드로 분기하게 한다.
|
|
54
|
+
- 응답에 스택 트레이스, SQL, 내부 클래스 이름을 넣지 않는다. 예상하지 못한 예외는 로그로 남기고 일반 메시지만 돌려준다.
|
|
55
|
+
|
|
56
|
+
```java
|
|
57
|
+
@RestControllerAdvice
|
|
58
|
+
public class GlobalExceptionHandler {
|
|
59
|
+
|
|
60
|
+
@ExceptionHandler(OrderNotCancelableException.class)
|
|
61
|
+
public ProblemDetail handle(OrderNotCancelableException e) {
|
|
62
|
+
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, e.getMessage());
|
|
63
|
+
problem.setProperty("code", "ORDER_NOT_CANCELABLE");
|
|
64
|
+
return problem;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## 목록과 페이징
|
|
70
|
+
|
|
71
|
+
- 개수가 늘어날 수 있는 목록은 페이징한다. `Pageable` 을 받고 최대 크기를 제한한다. (`spring.data.web.pageable.max-page-size`)
|
|
72
|
+
- `Page` 객체를 그대로 응답하지 않는다. 내용과 페이지 정보를 담은 응답 DTO 로 감싼다.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Spring 구조 · 설정
|
|
2
|
+
|
|
3
|
+
Spring Boot 4(Spring Framework 7) 기준이다. Java 코드 규칙과 검증 명령은 Java 프리셋 문서를 따른다.
|
|
4
|
+
이미 다른 구조를 쓰는 프로젝트는 기존 구조를 따르고, 새로 만드는 부분만 이 문서에 맞춘다.
|
|
5
|
+
|
|
6
|
+
## 패키지
|
|
7
|
+
|
|
8
|
+
기능(도메인) 단위로 먼저 나누고, 그 안을 레이어로 나눈다.
|
|
9
|
+
|
|
10
|
+
```text
|
|
11
|
+
com.example.app/
|
|
12
|
+
order/ 기능 하나
|
|
13
|
+
controller/ HTTP 요청·응답
|
|
14
|
+
dto/ 요청·응답 DTO
|
|
15
|
+
service/ 유스케이스, 트랜잭션 경계
|
|
16
|
+
repository/ Spring Data 인터페이스
|
|
17
|
+
domain/ 엔티티, 값 객체, 도메인 규칙
|
|
18
|
+
exception/ 이 기능의 예외
|
|
19
|
+
member/
|
|
20
|
+
global/ 설정, 전역 예외 처리, 보안 같은 기술 기반
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
- 필요 없는 하위 패키지는 만들지 않는다. (API 가 없는 기능에는 `controller/` 를 두지 않는다)
|
|
24
|
+
- `global` 에는 비즈니스 규칙을 두지 않는다. 두 기능이 같은 개념을 쓴다고 `global` 로 올리지 않고, 기능 경계를 먼저 다시 본다.
|
|
25
|
+
- 외부 시스템 연동(결제, 메일 등)은 서비스가 쓰는 인터페이스를 `service` 에 두고, 구현은 `global/external/<시스템>` 에 둔다.
|
|
26
|
+
|
|
27
|
+
## 의존 방향
|
|
28
|
+
|
|
29
|
+
```text
|
|
30
|
+
controller → service → repository
|
|
31
|
+
│ │
|
|
32
|
+
└──▶ domain ◀┘
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
- `domain` 은 다른 레이어를 모른다. Spring 에 의존하지 않는다. (JPA 매핑 어노테이션만 예외)
|
|
36
|
+
- `global` 은 기능 패키지에 의존하지 않는다.
|
|
37
|
+
- 다른 기능의 `repository`·엔티티를 직접 쓰지 않는다. 그 기능의 `service` 를 통한다.
|
|
38
|
+
즉시 결과가 필요 없으면 도메인 이벤트(`ApplicationEventPublisher`, `@TransactionalEventListener`)로 나눈다.
|
|
39
|
+
- 다른 기능의 엔티티는 객체가 아니라 ID 로 참조한다.
|
|
40
|
+
- 의존 방향은 ArchUnit 테스트로 검사할 수 있다. → [spring-testing.md](spring-testing.md)
|
|
41
|
+
|
|
42
|
+
## 빈과 주입
|
|
43
|
+
|
|
44
|
+
- 생성자로 주입한다. 필드 주입(`@Autowired` 필드)을 쓰지 않는다. 생성자가 하나면 `@Autowired` 를 붙이지 않는다.
|
|
45
|
+
- 주입받는 필드는 `private final` 이다. Lombok 을 쓰면 `@RequiredArgsConstructor` 로 생성자를 만든다.
|
|
46
|
+
- 구현이 하나뿐인 서비스에 인터페이스를 만들지 않는다. (`XxxServiceImpl` 금지)
|
|
47
|
+
- 현재 시각은 `Clock` 빈을 주입받아 쓴다. 테스트에서 시각을 고정할 수 있다.
|
|
48
|
+
|
|
49
|
+
## DTO 경계
|
|
50
|
+
|
|
51
|
+
- 컨트롤러는 엔티티를 요청으로 받거나 응답으로 돌려주지 않는다. 지연 로딩 예외, 순환 참조, 내부 필드 노출이 생긴다.
|
|
52
|
+
- 요청·응답 DTO 는 `record` 로 만든다.
|
|
53
|
+
- 엔티티 → 응답 변환은 DTO 의 정적 팩토리(`OrderResponse.from(order)`)에 둔다. 엔티티가 DTO 를 모르게 한다.
|
|
54
|
+
|
|
55
|
+
```java
|
|
56
|
+
public record OrderResponse(Long id, String status, int totalPrice) {
|
|
57
|
+
|
|
58
|
+
public static OrderResponse from(Order order) {
|
|
59
|
+
return new OrderResponse(order.getId(), order.getStatus().name(), order.totalPrice());
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## 설정
|
|
65
|
+
|
|
66
|
+
- 설정 값은 `@ConfigurationProperties` 와 `record` 로 묶어 받는다. `@Value` 를 여러 클래스에 흩어 두지 않는다.
|
|
67
|
+
- `@Validated` 와 Bean Validation 으로 애플리케이션 시작 시점에 검증한다. 값이 틀리면 뜨지 않는다.
|
|
68
|
+
- 비밀값은 설정 파일에 적지 않고 `${DB_PASSWORD}` 처럼 환경변수를 참조한다.
|
|
69
|
+
- 환경별 설정은 `application-<profile>.yml` 로 나누고, 운영 값은 환경변수로 덮어쓴다.
|
|
70
|
+
|
|
71
|
+
```java
|
|
72
|
+
@Validated
|
|
73
|
+
@ConfigurationProperties(prefix = "payment")
|
|
74
|
+
public record PaymentProperties(@NotBlank String baseUrl, @NotNull Duration timeout) {}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
- 애플리케이션 클래스에 `@ConfigurationPropertiesScan` 을 붙여 등록한다.
|
|
78
|
+
|
|
79
|
+
## null
|
|
80
|
+
|
|
81
|
+
- Spring Boot 4 는 JSpecify 로 null 가능성을 표시한다. null 이 될 수 있는 반환값·파라미터에는 `org.jspecify.annotations.Nullable` 을 붙인다.
|
|
82
|
+
- Spring 의 `org.springframework.lang.Nullable` 은 새로 쓰지 않는다.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# JPA · 트랜잭션
|
|
2
|
+
|
|
3
|
+
Spring Data JPA(Hibernate) 를 쓰는 프로젝트의 규칙이다.
|
|
4
|
+
|
|
5
|
+
## 엔티티
|
|
6
|
+
|
|
7
|
+
- 도메인 모델과 JPA 엔티티는 같은 클래스로 쓴다. 매핑 전용 클래스를 따로 두지 않는다.
|
|
8
|
+
- JPA 용 기본 생성자는 `@NoArgsConstructor(access = AccessLevel.PROTECTED)` 로 만든다.
|
|
9
|
+
- setter 를 두지 않는다. 상태는 의도를 드러내는 메서드로 바꾼다. (`changeStatus()` 가 아니라 `cancel()`)
|
|
10
|
+
- getter 는 응답 변환 같은 읽기에만 쓴다. Lombok 을 쓰면 클래스에 `@Getter` 를 붙인다.
|
|
11
|
+
- 엔티티에 `@Setter`, `@Data`, `@ToString`, `@EqualsAndHashCode` 를 쓰지 않는다. 지연 로딩 필드를 건드리거나 순환 참조로 터진다.
|
|
12
|
+
- enum 컬럼은 `@Enumerated(EnumType.STRING)` 으로 저장한다. `ORDINAL` 은 상수 순서를 바꾸면 데이터가 틀어진다.
|
|
13
|
+
- 값 객체는 `@Embeddable` 로 매핑한다.
|
|
14
|
+
|
|
15
|
+
```java
|
|
16
|
+
@Entity
|
|
17
|
+
@Getter
|
|
18
|
+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
|
19
|
+
public class Order {
|
|
20
|
+
|
|
21
|
+
@Id
|
|
22
|
+
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
|
23
|
+
private Long id;
|
|
24
|
+
|
|
25
|
+
@Enumerated(EnumType.STRING)
|
|
26
|
+
private OrderStatus status;
|
|
27
|
+
|
|
28
|
+
@ManyToOne(fetch = FetchType.LAZY)
|
|
29
|
+
private Member member;
|
|
30
|
+
|
|
31
|
+
public void cancel() {
|
|
32
|
+
if (status != OrderStatus.PAID) {
|
|
33
|
+
throw new OrderNotCancelableException(id);
|
|
34
|
+
}
|
|
35
|
+
status = OrderStatus.CANCELED;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## 연관관계
|
|
41
|
+
|
|
42
|
+
- 모든 연관관계를 지연 로딩(`fetch = FetchType.LAZY`)으로 둔다. `@ManyToOne`, `@OneToOne` 은 기본값이 즉시 로딩이라 직접 적는다.
|
|
43
|
+
- 양방향 연관관계는 반대쪽에서 탐색할 이유가 있을 때만 만든다.
|
|
44
|
+
- 다른 기능(Aggregate)의 엔티티는 연관관계 대신 ID 로 참조한다.
|
|
45
|
+
- `cascade`, `orphanRemoval` 은 같은 Aggregate 안의 부모·자식 사이에만 쓴다.
|
|
46
|
+
|
|
47
|
+
## N+1 과 조회
|
|
48
|
+
|
|
49
|
+
- 목록을 읽은 뒤 연관 엔티티를 쓰면 행마다 쿼리가 나간다(N+1). fetch join(`join fetch`)이나 `@EntityGraph` 로 함께 읽는다.
|
|
50
|
+
- 컬렉션을 fetch join 하면서 페이징하면 Hibernate 가 메모리에서 페이징한다. 컬렉션은 `default_batch_fetch_size` 로 묶어 읽는다.
|
|
51
|
+
- 목록·검색·통계처럼 일부 컬럼만 필요한 조회는 엔티티 대신 DTO(record) projection 으로 읽는다.
|
|
52
|
+
- 단건 상세는 엔티티를 읽어 트랜잭션 안에서 응답 DTO 로 바꾼다.
|
|
53
|
+
- 개발 환경에서는 실행 SQL 을 로그로 보며 N+1 을 확인한다. (`logging.level.org.hibernate.SQL: debug`)
|
|
54
|
+
|
|
55
|
+
```yaml
|
|
56
|
+
spring:
|
|
57
|
+
jpa:
|
|
58
|
+
open-in-view: false
|
|
59
|
+
properties:
|
|
60
|
+
hibernate:
|
|
61
|
+
default_batch_fetch_size: 100
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Repository
|
|
65
|
+
|
|
66
|
+
- Aggregate 루트마다 하나 만든다. `JpaRepository<Xxx, ID>` 를 바로 상속하고 서비스가 주입받는다. 포트 인터페이스와 구현으로 나누지 않는다.
|
|
67
|
+
- 메서드 이름 쿼리가 길어지면 `@Query` 로 쓴다.
|
|
68
|
+
- 없을 때의 처리는 서비스에서 기능 예외로 바꾼다. (`findById(id).orElseThrow(() -> new OrderNotFoundException(id))`)
|
|
69
|
+
|
|
70
|
+
## 트랜잭션
|
|
71
|
+
|
|
72
|
+
- `@Transactional` 은 service 의 public 메서드에 둔다. 컨트롤러와 repository 에 두지 않는다.
|
|
73
|
+
- 조회만 하는 메서드는 `@Transactional(readOnly = true)` 로 둔다.
|
|
74
|
+
- 수정은 엔티티 메서드로 상태를 바꾸고 변경 감지에 맡긴다. 수정 뒤 `save()` 를 다시 부르지 않는다.
|
|
75
|
+
- 트랜잭션 안에서 외부 API 호출·메시지 발행을 하지 않는다. DB 커넥션을 오래 잡고, 롤백돼도 외부 호출은 되돌릴 수 없다.
|
|
76
|
+
커밋 뒤에 할 일은 `@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)` 로 처리한다.
|
|
77
|
+
- 같은 클래스 안에서 `this.method()` 로 부르면 `@Transactional` 이 적용되지 않는다. (프록시를 거치지 않는다)
|
|
78
|
+
- checked 예외는 기본으로 롤백하지 않는다. 기능 예외는 unchecked 로 만든다.
|
|
79
|
+
|
|
80
|
+
## open-in-view
|
|
81
|
+
|
|
82
|
+
- `spring.jpa.open-in-view` 를 `false` 로 둔다. 기본값(`true`)은 응답을 다 쓸 때까지 DB 커넥션을 잡고, 컨트롤러에서 지연 로딩이 일어나 쿼리 위치를 알기 어렵게 만든다.
|
|
83
|
+
|
|
84
|
+
## 스키마
|
|
85
|
+
|
|
86
|
+
- 스키마 변경은 Flyway(또는 Liquibase) 마이그레이션 파일로 한다.
|
|
87
|
+
- `spring.jpa.hibernate.ddl-auto` 는 운영에서 `validate` 나 `none` 이다. `create`, `update` 를 쓰지 않는다.
|
|
88
|
+
- 이미 적용된 마이그레이션 파일은 고치지 않는다. 바꿀 내용은 새 파일로 추가한다.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Spring 테스트
|
|
2
|
+
|
|
3
|
+
Spring Boot 4 기준이다. 테스트 원칙(given/when/then, AssertJ, 재현 테스트)은 Java 프리셋 문서를 따른다.
|
|
4
|
+
이 문서는 Spring 이 필요한 테스트를 어떻게 띄우는지 정한다.
|
|
5
|
+
|
|
6
|
+
## 테스트 종류
|
|
7
|
+
|
|
8
|
+
Spring 컨텍스트를 띄우는 테스트는 느리다. 가능한 한 아래 표의 위쪽 방법을 쓴다.
|
|
9
|
+
|
|
10
|
+
| 대상 | 방법 |
|
|
11
|
+
| --- | --- |
|
|
12
|
+
| 도메인 객체, 서비스 | Spring 없이 단위 테스트. 서비스의 Repository·외부 연동은 Mockito 로 대체 |
|
|
13
|
+
| 컨트롤러 (요청·검증·응답 형식) | `@WebMvcTest(OrderController.class)` 와 `MockMvcTester`. 서비스는 `@MockitoBean` |
|
|
14
|
+
| Repository, 쿼리 | `@DataJpaTest` 와 Testcontainers 로 띄운 운영과 같은 DB |
|
|
15
|
+
| 여러 레이어를 잇는 흐름 | `@SpringBootTest`. 핵심 시나리오에만 쓴다 |
|
|
16
|
+
|
|
17
|
+
## 규칙
|
|
18
|
+
|
|
19
|
+
- `@MockBean`·`@SpyBean` 은 Spring Boot 4 에서 제거됐다. `@MockitoBean`·`@MockitoSpyBean` 을 쓴다.
|
|
20
|
+
- 기술별 테스트 starter(`spring-boot-starter-webmvc-test`, `spring-boot-starter-data-jpa-test` 등)를 `testImplementation` 으로 둔다.
|
|
21
|
+
- `@SpringBootTest` 에서 MockMvc 를 쓰려면 `@AutoConfigureMockMvc` 를 붙인다.
|
|
22
|
+
- DB 는 H2 같은 다른 DB 로 대신하지 않는다. 방언·제약·함수가 달라 운영에서만 실패하는 쿼리를 놓친다.
|
|
23
|
+
- Testcontainers 컨테이너는 `@ServiceConnection` 을 붙인 `@TestConfiguration` 빈으로 두고 테스트에서 `@Import` 한다.
|
|
24
|
+
같은 설정의 테스트끼리 컨텍스트 캐시를 함께 써서 컨테이너를 한 번만 띄운다.
|
|
25
|
+
- `@DataJpaTest` 는 `@ServiceConnection` 으로 띄운 DB 를 내장 DB 로 바꾸지 않는다. (`@AutoConfigureTestDatabase` 기본값 `NON_TEST`)
|
|
26
|
+
- 테스트마다 `@MockitoBean` 조합이나 설정이 다르면 컨텍스트를 새로 띄운다. 공통 조합은 기반 클래스나 설정으로 모은다.
|
|
27
|
+
- 의존 방향 규칙은 ArchUnit 테스트로 검사할 수 있다. → [spring-architecture.md](spring-architecture.md)
|
|
28
|
+
|
|
29
|
+
## 예시
|
|
30
|
+
|
|
31
|
+
```java
|
|
32
|
+
@TestConfiguration(proxyBeanMethods = false)
|
|
33
|
+
public class TestcontainersConfiguration {
|
|
34
|
+
|
|
35
|
+
@Bean
|
|
36
|
+
@ServiceConnection
|
|
37
|
+
MySQLContainer mysqlContainer() {
|
|
38
|
+
return new MySQLContainer(DockerImageName.parse("mysql:8.4"));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@DataJpaTest
|
|
43
|
+
@Import(TestcontainersConfiguration.class)
|
|
44
|
+
class OrderRepositoryTest {
|
|
45
|
+
|
|
46
|
+
@Autowired
|
|
47
|
+
private OrderRepository orderRepository;
|
|
48
|
+
|
|
49
|
+
@Test
|
|
50
|
+
@DisplayName("회원의 결제 완료 주문만 최신순으로 조회한다")
|
|
51
|
+
void findPaidOrders() { ... }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
@WebMvcTest(OrderController.class)
|
|
55
|
+
class OrderControllerTest {
|
|
56
|
+
|
|
57
|
+
@Autowired
|
|
58
|
+
private MockMvcTester mvc;
|
|
59
|
+
|
|
60
|
+
@MockitoBean
|
|
61
|
+
private OrderService orderService;
|
|
62
|
+
|
|
63
|
+
@Test
|
|
64
|
+
@DisplayName("수량이 0 이면 400 을 돌려준다")
|
|
65
|
+
void rejectsZeroQuantity() {
|
|
66
|
+
assertThat(mvc.post().uri("/orders").contentType(MediaType.APPLICATION_JSON).content("""
|
|
67
|
+
{"productId": 1, "quantity": 0}
|
|
68
|
+
"""))
|
|
69
|
+
.hasStatus(HttpStatus.BAD_REQUEST);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentsmd/preset-spring",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "Spring Boot 서버용 AI 작업 지침 프리셋 (구조·JPA·트랜잭션·API·테스트 규칙)",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"agents.md",
|
|
7
|
+
"ai-agent",
|
|
8
|
+
"spring",
|
|
9
|
+
"spring-boot",
|
|
10
|
+
"jpa"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://github.com/xoruddl/agentsmd-kit#readme",
|
|
13
|
+
"bugs": "https://github.com/xoruddl/agentsmd-kit/issues",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/xoruddl/agentsmd-kit.git",
|
|
17
|
+
"directory": "packages/preset-spring"
|
|
18
|
+
},
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"author": "이태경 (https://github.com/xoruddl)",
|
|
21
|
+
"type": "module",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"default": "./dist/index.js"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist",
|
|
30
|
+
"files"
|
|
31
|
+
],
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=22"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@agentsmd/core": "0.6.0"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "tsc -p tsconfig.build.json",
|
|
43
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
44
|
+
"test": "vitest run"
|
|
45
|
+
}
|
|
6
46
|
}
|