객체 하나가 어떤 상태로 만들어질 수 있는지 알아내려면 코드를 몇 군데나 봐야 할까. setter가 있는 DTO라면 답은 “전부”다. 어디서든 필드 하나를 바꿀 수 있으니, 이 객체의 가능한 상태를 알려면 이 타입을 쓰는 모든 코드를 확인해야 한다.
// ❌ 이 객체는 몇 가지 상태로 존재할 수 있는가?
OrderRequest request = new OrderRequest();
request.setProductName("무선 마우스");
// ← 여기서 quantity는 null이다
request.setQuantity(3);
생성 시점과 완성 시점이 벌어져 있으면, 그 사이의 불완전한 객체가 다른 곳으로 넘어갈 수 있다. 컴파일러는 아무것도 막아주지 않는다. setQuantity()를 빠뜨려도 코드는 잘 돌아가고, 문제는 한참 뒤 quantity가 null인 채로 계산에 들어갈 때 드러난다.
이 글의 원칙은 하나다. 객체의 상태가 완성되는 지점을 생성자 한 곳으로 모은다. 생성자가 끝나는 순간 객체는 완전하고, 그 이후로 바뀌지 않는다.
원칙 1. 상태가 완성되는 지점은 생성자 하나뿐이다
세 가지를 없애면 규칙이 강제된다.
- setter를 만들지 않는다 — 생성 이후 상태 변경 차단
- 기본 생성자를 만들지 않는다 — 빈 껍데기 상태 차단
- 필드를
final로 선언한다 — 컴파일러가 위 둘을 검증
// ✅ 생성자가 끝나면 객체는 완전하다
@Getter
public class CreateOrderRequest {
private final String productName;
private final Integer quantity;
public CreateOrderRequest(String productName, Integer quantity) {
this.productName = productName;
this.quantity = quantity;
}
}
필드를 final로 두면 setter를 만들 수도, 기본 생성자를 둘 수도 없다. 규율이 아니라 컴파일러가 강제한다는 점이 중요하다.
값 노출은 @Getter로, 이름은 getXxx()로
setter는 막되 조회는 평범한 getXxx()를 그대로 쓴다. fetchXxx() 같은 다른 접두사를 붙이면 JavaBeans 관례에서 벗어나 Lombok @Getter도, Jackson 기본 직렬화도 쓸 수 없게 된다. 그러면서 얻는 것은 없다 — fetchDisplayName()이 getDisplayName()보다 의도를 더 드러내지 않기 때문이다.
@Getter // 조회는 이걸로 충분하다
public class CreateOrderRequest {
private final String productName; // final이므로 @Setter는 아예 컴파일되지 않는다
}
다만 상태를 바꾸는 메서드는 다르다. 거기서는 이름이 의도를 드러내야 한다.
// ❌ 무엇을 하는지 알 수 없다
user.setEmail("a@b.com");
// ✅ 의도가 드러난다
user.changeEmailAndVerify("a@b.com");
즉 의도를 드러내야 하는 것은 상태를 바꾸는 행위이지, 값을 꺼내는 일이 아니다. 자세한 논의는 Null-free 객체 설계에 정리해두었다.
“생성자로 만든다”와 “생성자에서 검증한다”는 다르다
혼동하기 쉬운 지점이라 먼저 짚는다.
| 의미 | Request DTO | |
|---|---|---|
| 생성자로만 만든다 | 상태가 완성되는 경로가 하나 | ✅ 적용 |
| 생성자에서 검증한다 | 값이 유효한지 확인 | ❌ 적용 안 함 |
Request DTO는 앞의 것만 취한다. 값의 유효성은 계층별 검증 표준에 따라 Bean Validation이 맡는다. 생성자에서 검증하면 Jackson 역직렬화 단계에서 터져 errors 응답이 아니라 HttpMessageNotReadableException이 나가기 때문이다.
경로를 하나로 만드는 것과 값을 검증하는 것은 별개의 문제이고, 이 글은 앞의 것만 다룬다.
원칙 2. record가 아니라 class를 쓴다
record는 이 규칙을 공짜로 준다. 불변이고, setter가 없고, canonical constructor 하나만 존재한다. 그럼에도 이 표준은 class를 기본으로 한다.
이유는 하나다. record는 클래스를 상속할 수 없다. 상속 vs 합성에서 정한 “DTO 공통 필드는 1레벨 상속으로 관리한다”와 양립하지 않는다.
// 여러 조회 API가 공유하는 페이징 필드
@Getter
public abstract class PageableRequest {
@Min(0)
@Schema(description = "페이지 번호 (0부터)", example = "0")
private final Integer page;
@Min(1)
@Max(100)
@Schema(description = "페이지 크기", example = "20")
private final Integer size;
protected PageableRequest(Integer page, Integer size) {
this.page = page;
this.size = size;
}
}
@Getter
public class SearchOrderRequest extends PageableRequest {
@NotBlank
@Schema(description = "검색 키워드", example = "마우스")
private final String keyword;
@JsonCreator
public SearchOrderRequest(
@JsonProperty("keyword") String keyword,
@JsonProperty("page") Integer page,
@JsonProperty("size") Integer size
) {
super(page, size);
this.keyword = keyword;
}
}
일부만 record로 두면 “이건 왜 record고 저건 왜 class인가”를 매번 판단해야 하고, 나중에 공통 필드가 생겨 record를 class로 바꿀 때 파급이 커진다. 전부 class로 통일하는 편이 규칙으로서 단순하다.
대신 record가 공짜로 주던 것을 규율로 지켜야 한다
이게 class 선택의 실제 비용이다.
- 생성자를 직접 써야 한다
final필드를 직접 선언해야 한다- Jackson이 생성자를 쓰도록 명시해야 한다 (원칙 3)
- 상속 시 빌더가 달라진다 (원칙 4)
코드가 길어지는 건 사실이다. 다만 길어지는 쪽은 쓰는 비용이고, 줄어드는 쪽은 읽는 비용이다. 생성 지점이 하나면 “이 객체가 어떤 상태로 존재할 수 있는가”를 확인할 때 그 한 곳만 보면 된다. 작성은 도구의 도움을 받을 수 있지만, 읽고 추적하는 일은 결국 사람이 한다.
원칙 3. Jackson이 생성자를 쓰도록 명시한다
Controller가 받는 Request는 내가 만드는 객체가 아니다. Jackson이 만든다. 그래서 이 규칙이 지켜지는지 여부도 Jackson에 달려 있다.
record는 canonical constructor를 자동으로 쓰지만, class는 명시해야 한다.
// ✅ Jackson이 이 생성자로만 객체를 만든다
@JsonCreator
public CreateOrderRequest(
@JsonProperty("productName") String productName,
@JsonProperty("quantity") Integer quantity
) {
this.productName = productName;
this.quantity = quantity;
}
가장 위험한 것은 조용한 되돌아감이다
@NoArgsConstructor나 @Setter가 하나라도 붙어 있으면, Jackson은 빈 객체를 만들고 필드를 하나씩 채우는 경로를 쓸 수 있다.
// ❌ 컴파일도 되고, 요청도 정상 처리된다. 그런데 규칙은 무너져 있다
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class CreateOrderRequest {
private String productName;
private Integer quantity;
}
에러도, 경고도 없다. 동작은 똑같아 보이는데 객체가 불완전한 상태로 존재하는 구간이 생기고, 다른 코드가 setter로 상태를 바꿀 수 있게 된다. Service 계층 assert 표준에서 경계했던 “문제를 숨기는 코드”와 정확히 같은 성격이다.
그래서 금지 목록을 명시적으로 둔다. DTO에는 @Setter, @NoArgsConstructor, @Data를 쓰지 않는다. @Data는 @Setter를 포함하므로 특히 위험하다.
필드를 final로 선언해두면 이 셋이 애초에 컴파일되지 않아, 실수 자체가 불가능해진다.
원칙 4. 빌더는 정적 팩토리 안에 가둔다
필드가 많아지면 생성자 인자 순서를 헷갈리기 쉽다. 빌더가 필요한 지점인데, 빌더를 그대로 노출하면 원칙 1이 무너진다.
// ❌ 여러 곳에서 서로 다른 조합으로 만들 수 있다
OrderDomain.builder().productName("마우스").build(); // quantity 누락
OrderDomain.builder().quantity(3).unitPrice(BigDecimal.TEN).build(); // productName 누락
빌더는 필드를 빼먹어도 컴파일된다. 생성자를 쓰는 이유가 사라지는 셈이다.
그래서 빌더는 감추고, 정적 팩토리를 유일한 입구로 만든다.
@Getter
public class OrderDomain {
private final Long idOrNull;
private final String productName;
private final int quantity;
private final BigDecimal unitPrice;
@Builder(access = AccessLevel.PRIVATE)
private OrderDomain(Long idOrNull, String productName, int quantity, BigDecimal unitPrice) {
this.idOrNull = idOrNull;
this.productName = Objects.requireNonNull(productName, "productName");
this.quantity = quantity;
this.unitPrice = Objects.requireNonNull(unitPrice, "unitPrice");
}
// 외부에서 쓸 수 있는 유일한 입구
public static OrderDomain create(String productName, int quantity, BigDecimal unitPrice) {
return OrderDomain.builder()
.idOrNull(null)
.productName(productName)
.quantity(quantity)
.unitPrice(unitPrice)
.build();
}
public static OrderDomain restore(Long id, String productName, int quantity, BigDecimal unitPrice) {
return OrderDomain.builder()
.idOrNull(id)
.productName(productName)
.quantity(quantity)
.unitPrice(unitPrice)
.build();
}
}
@Builder(access = AccessLevel.PRIVATE)가 핵심이다. 이게 없으면 Lombok은 private 생성자에 붙여도 public builder() 메서드를 만든다. 빌더는 클래스 내부의 조립 도구일 뿐이고, 밖에서 보이는 것은 create()와 restore()처럼 의도가 드러나는 이름이어야 한다.
이 구조는 팩토리 메소드 패턴에서 정리한 것과 같다. 다른 점은 빌더를 명시적으로 막는다는 것뿐이다.
상속이 있으면 @SuperBuilder를 쓴다
Lombok의 일반 @Builder는 부모 클래스 필드를 다루지 못한다. 상속 구조에서는 부모와 자식 모두에 @SuperBuilder를 붙여야 부모 필드까지 빌더에 포함된다.
원칙 5. 프레임워크가 강제하는 예외는 두 곳뿐이다
Jackson이 생성"] -->|"@JsonCreator"| OK1["생성자 통과"] C["XxxCommand / XxxResponse
내 코드가 생성"] -->|"정적 팩토리"| OK2["생성자 통과"] D["XxxDomain
내 코드가 생성"] -->|"정적 팩토리 + private 빌더"| OK3["생성자 통과"] E["XxxEntity
JPA가 생성"] -->|"protected 기본 생성자 불가피"| EX1["예외"] M["XxxMapperResult
MyBatis가 생성"] -->|"기본 생성자 + setter"| EX2["예외"] style OK1 fill:#2d3748,stroke:#48bb78,stroke-width:2px,color:#e2e8f0 style OK2 fill:#2d3748,stroke:#48bb78,stroke-width:2px,color:#e2e8f0 style OK3 fill:#2d3748,stroke:#48bb78,stroke-width:2px,color:#e2e8f0 style EX1 fill:#2d3748,stroke:#ed8936,stroke-width:2px,color:#e2e8f0 style EX2 fill:#2d3748,stroke:#ed8936,stroke-width:2px,color:#e2e8f0
JPA Entity
JPA는 프록시 생성을 위해 기본 생성자를 요구한다. protected로 두어 접근을 최소화하고, 외부에서는 정적 팩토리만 쓴다. Null-free 객체 설계에서 정리한 타협과 같다.
@Entity
public class OrderEntity {
@Id @GeneratedValue
private Long id;
@Column(nullable = false)
private String productName;
protected OrderEntity() {} // JPA 요구사항. 직접 호출하지 않는다.
public static OrderEntity create(String productName) {
OrderEntity entity = new OrderEntity();
entity.productName = productName;
return entity;
}
}
MyBatis MapperResult
MyBatis는 기본적으로 기본 생성자 + setter/필드 주입으로 결과를 매핑한다. 생성자 매핑을 쓰려면 resultMap의 <constructor>나 별도 설정이 필요한데, 이 표준은 그 설정을 도입하지 않고 예외로 둔다.
// MyBatis 예외: 기본 생성자와 setter를 허용한다
@Getter
@Setter
@NoArgsConstructor
public class OrderMapperResult {
private Long id;
private String productName;
private int quantity;
private BigDecimal unitPrice;
}
예외를 허용해도 되는 이유는 MapperResult가 패키지 밖으로 나가지 않기 때문이다. 이는 패키지 구조 표준에서 package-private으로 강제된다. DTO 네이밍 표준에 따라 Repository 구현체가 즉시 Domain으로 변환하므로, 이 느슨한 객체의 수명은 메서드 하나 안에서 끝난다.
반면 MapperParam은 내가 만드는 객체이므로 예외가 아니다. 정상적으로 생성자 규칙을 따른다.
계층별 정리
| 타입 | 누가 만드는가 | 생성 방식 |
|---|---|---|
XxxRequest |
Jackson | @JsonCreator 생성자 |
XxxCommand / XxxQuery |
Request.toCommand() |
private 생성자 + 정적 팩토리 |
XxxDomain |
Service, Mapper | private 생성자 + private 빌더 + 정적 팩토리 |
XxxResponse |
Response.from() |
private 생성자 + 정적 팩토리 |
XxxMapperParam |
Repository 구현체 | private 생성자 + 정적 팩토리 |
XxxEntity |
JPA | protected 기본 생성자(불가피) + 정적 팩토리 |
XxxMapperResult |
MyBatis | 예외 — 기본 생성자 + setter |
금지 목록은 계층과 무관하게 동일하다. @Setter, @NoArgsConstructor, @Data는 MapperResult를 제외한 모든 DTO에서 쓰지 않는다.
이 표준을 정하기까지
시작은 상태 추적의 어려움이었다. DTO를 여러 곳에서 조금씩 채워 만들면, 나중에 “이 객체가 어떤 상태일 수 있는가”를 알아내기 위해 그 타입을 쓰는 코드를 전부 봐야 한다. 생성 지점을 한 곳으로 모으면 그 확인이 한 곳에서 끝난다. 이게 출발점이었다.
곧바로 부딪힌 게 Request DTO였다. Service에서 만드는 객체는 내가 통제할 수 있지만, Controller가 받는 Request는 Jackson이 만든다. 내가 생성자를 호출하지 않는데 어떻게 생성자를 통과하게 만들 것인가.
확인해보니 이미 방법이 있었다. record는 Jackson이 canonical constructor를 호출하고, class도 @JsonCreator를 붙이면 그 생성자로만 만들어진다. 문제는 반대쪽이었다 — @NoArgsConstructor나 @Setter가 붙어 있으면 Jackson이 아무 에러 없이 필드 주입 방식으로 돌아간다. 동작은 똑같아 보이는데 규칙만 조용히 무너진다. assert 표준에서 경계했던 것과 같은 종류의 위험이라, 금지 목록을 명시적으로 두기로 했다.
여기서 검증 표준과 충돌하는 것처럼 보였다. 앞서 “Request DTO 생성자에서는 검증하지 마라”고 정해뒀기 때문이다. 정리해보니 두 개는 다른 이야기였다 — 생성자로만 만드는 것은 경로의 문제이고, 생성자에서 검증하는 것은 값의 문제다. Request는 앞의 것만 취하고, 값 검증은 Bean Validation에 맡긴다.
record와 class 사이에서 오래 고민했다. record는 이 규칙을 공짜로 주지만 클래스를 상속할 수 없다. 이미 상속 vs 합성에서 “DTO 공통 필드는 1레벨 상속으로”라고 정해둔 터라, 페이징 필드처럼 여러 요청이 공유하는 구조를 record로는 만들 수 없었다. 일부만 record로 두는 것도 고려했지만 “이건 왜 record인가”를 매번 판단해야 하고 나중에 갈아탈 때 파급이 크다. 전부 class로 통일하는 쪽이 규칙으로서 단순했다.
대신 코드가 길어지는 걸 받아들여야 했다. 여기서 판단 기준이 하나 더 필요했는데 — 길어지는 것은 쓰는 비용이고, 줄어드는 것은 읽는 비용이다. 작성은 도구의 도움을 받을 수 있지만 읽고 추적하는 일은 결국 사람이 한다. 그래서 이 교환은 남는 장사라고 봤다.
빌더는 예상 못 한 구멍이었다. 필드가 많으면 빌더가 필요한데, 빌더는 필드를 빼먹어도 컴파일된다. 생성자를 강제한 이유가 그대로 사라지는 것이다. 게다가 Lombok은 private 생성자에 @Builder를 붙여도 public builder()를 만들어버린다. @Builder(access = AccessLevel.PRIVATE)로 막고 정적 팩토리만 노출하기로 했다.
마지막으로 프레임워크가 강제하는 예외를 정리했다. JPA는 기본 생성자를 요구하니 protected로 최소화하고, MyBatis는 생성자 매핑 설정을 도입하지 않고 예외로 두기로 했다. MyBatis를 예외로 둘 수 있었던 근거는 MapperResult가 패키지 밖으로 안 나간다는 것이다. 수명이 메서드 하나 안에서 끝나므로 느슨해도 영향 범위가 좁다. 같은 이유로 MapperParam은 내가 만드는 객체라 예외가 아니다.
정리
- 상태가 완성되는 지점을 생성자 하나로 모은다. 생성자가 끝나면 객체는 완전하고 이후 바뀌지 않는다
- 필드를
final로 선언한다. 그러면 setter와 기본 생성자가 컴파일되지 않아 규칙이 강제된다 - 조회는
@Getter와getXxx()를 그대로 쓴다. 의도를 드러낼 대상은 상태를 바꾸는 행위이지 값을 꺼내는 일이 아니다 @Setter,@NoArgsConstructor,@Data를 쓰지 않는다. 하나만 있어도 Jackson이 조용히 필드 주입으로 돌아간다- record가 아니라 class를 쓴다. DTO 공통 필드를 상속으로 관리해야 하기 때문이다
- Jackson에는
@JsonCreator로 생성자를 명시한다. class는 record와 달리 자동이 아니다 - 빌더는
private으로 막고 정적 팩토리만 노출한다. 빌더는 필드를 빼먹어도 컴파일된다 - 예외는 JPA Entity와 MyBatis MapperResult뿐이다. 프레임워크가 강제하고, 영향 범위가 좁다
- “생성자로 만든다”와 “생성자에서 검증한다”는 다르다. Request는 앞의 것만 따른다
AI 코드 어시스턴트에 바로 적용하기
Claude Code — .claude/skills/dto-construction-standard/SKILL.md
---
name: dto-construction-standard
description: DTO 객체 생성 규칙. Request, Response, Command, Query, Domain, Entity 클래스를 만들거나 리뷰할 때 반드시 적용한다.
---
# DTO 생성 표준
객체의 상태가 완성되는 지점을 **생성자 한 곳으로** 모은다. 생성자가 끝나면 객체는 완전하고, 이후 상태가 바뀌지 않는다.
## 기본 규칙
- 모든 필드를 `final`로 선언한다.
- setter를 만들지 않는다.
- 기본 생성자를 만들지 않는다.
- `@Setter`, `@NoArgsConstructor`, `@Data`를 쓰지 않는다. `@Data`는 `@Setter`를 포함하므로 특히 금지한다.
- `record`가 아니라 `class`로 만든다. DTO 공통 필드를 1레벨 상속으로 관리해야 하므로, 클래스를 상속할 수 없는 record는 쓰지 않는다.
## 값 노출
- 값 노출은 `@Getter`로 처리한다.
- 조회 메서드 이름은 `getXxx()`로 만든다. `fetchXxx()`처럼 다른 접두사를 쓰지 않는다. JavaBeans 관례를 벗어나면 Lombok `@Getter`와 Jackson 기본 직렬화를 쓸 수 없고, 얻는 것은 없다.
- 반면 상태를 바꾸는 메서드는 의도가 드러나는 이름을 쓴다. `setEmail()`이 아니라 `changeEmailAndVerify()`처럼 무엇을 하는지 드러낸다.
- 즉 의도를 드러낼 대상은 상태를 바꾸는 행위이지, 값을 꺼내는 일이 아니다.
## 계층별 생성 방식
- `XxxRequest`: Jackson이 생성하므로 생성자에 `@JsonCreator`를, 각 파라미터에 `@JsonProperty`를 붙인다. 생성자는 public으로 둔다.
- `XxxCommand`, `XxxQuery`, `XxxResponse`, `XxxMapperParam`: private 생성자 + 정적 팩토리로 만든다.
- `XxxDomain`: private 생성자 + `@Builder(access = AccessLevel.PRIVATE)` + 의도가 드러나는 이름의 정적 팩토리(`create`, `restore` 등)로 만든다.
## 빌더
- 빌더를 외부에 노출하지 않는다. `@Builder(access = AccessLevel.PRIVATE)`로 감추고 정적 팩토리만 공개한다.
- 이유: 빌더는 필수 필드를 빠뜨려도 컴파일되므로, 노출하면 생성자를 강제한 의미가 사라진다.
- 상속 구조에서는 `@Builder`가 부모 필드를 다루지 못하므로 부모와 자식 모두에 `@SuperBuilder`를 쓴다.
## 예외
- `XxxEntity`: JPA가 기본 생성자를 요구하므로 `protected` 기본 생성자를 둔다. 외부 생성은 정적 팩토리로만 한다.
- `XxxMapperResult`: MyBatis 매핑을 위해 기본 생성자와 setter를 허용한다. 패키지 밖으로 나가지 않고 Repository 구현체에서 즉시 Domain으로 변환되므로 영향 범위가 좁다.
- 위 둘 외에는 예외를 만들지 않는다.
## 혼동 주의
- "생성자로만 만든다"와 "생성자에서 검증한다"는 다른 규칙이다.
- Request DTO는 생성자로만 만들되, 생성자에서 검증하지 않는다. 값 검증은 Bean Validation 어노테이션이 담당한다.
GitHub Copilot — .github/instructions/dto-construction-standard.instructions.md
---
description: DTO 객체 생성 규칙
applyTo: "**/*.java"
---
- DTO의 모든 필드는 `final`로 선언한다.
- DTO에 setter와 기본 생성자를 만들지 않는다.
- DTO에 `@Setter`, `@NoArgsConstructor`, `@Data`를 사용하지 않는다.
- DTO는 `record`가 아니라 `class`로 만든다.
- 값 노출은 `@Getter`로 처리한다.
- 조회 메서드는 `getXxx()`로 만들고 `fetchXxx()` 같은 다른 접두사를 쓰지 않는다.
- 상태를 바꾸는 메서드는 `changeEmailAndVerify()`처럼 의도가 드러나는 이름으로 만든다.
- `XxxRequest`는 생성자에 `@JsonCreator`, 파라미터에 `@JsonProperty`를 붙이고 생성자를 public으로 둔다.
- `XxxCommand`, `XxxQuery`, `XxxResponse`, `XxxMapperParam`은 private 생성자와 정적 팩토리로 만든다.
- `XxxDomain`은 private 생성자와 `@Builder(access = AccessLevel.PRIVATE)`, 그리고 의도가 드러나는 정적 팩토리로 만든다.
- 빌더를 외부에 노출하지 않는다. 정적 팩토리만 공개한다.
- 상속 구조에서는 `@Builder` 대신 `@SuperBuilder`를 쓴다.
- `XxxEntity`는 JPA 요구사항인 `protected` 기본 생성자만 허용하고 생성은 정적 팩토리로 한다.
- `XxxMapperResult`는 예외로 기본 생성자와 setter를 허용한다.
- Request DTO의 생성자에서는 값을 검증하지 않는다. 검증은 Bean Validation이 담당한다.
자신만의 철학을 만들어가는 중입니다.
댓글남기기