객체 하나가 어떤 상태로 만들어질 수 있는지 알아내려면 코드를 몇 군데나 봐야 할까. setter가 있는 DTO라면 답은 “전부”다. 어디서든 필드 하나를 바꿀 수 있으니, 이 객체의 가능한 상태를 알려면 이 타입을 쓰는 모든 코드를 확인해야 한다.
// ❌ 이 객체는 몇 가지 상태로 존재할 수 있는가?
OrderRequest request = new OrderRequest();
request.setProductName("무선 마우스");
// ← 여기서 quantity는 null이다
request.setQuantity(3);
생성 시점과 완성 시점이 벌어져 있으면, 그 사이의 불완전한 객체가 다른 곳으로 넘어갈 수 있다. 컴파일러는 아무것도 막아주지 않는다. setQuantity()를 빠뜨려도 코드는 잘 돌아가고, 문제는 한참 뒤 quantity가 null인 채로 계산에 들어갈 때 드러난다.
이 글의 원칙은 하나다. 객체의 상태가 완성되는 지점을 생성자 한 곳으로 모은다. 생성자가 끝나는 순간 객체는 완전하고, 그 이후로 바뀌지 않는다.
원칙 1. 상태가 완성되는 지점은 생성자 하나뿐이다
반증: Lombok 1.18.48 final 필드 DTO — @Data 컴파일됨(경고 0·setter 0) · @NoArgsConstructor 컴파일 실패(에러 1) · @Setter(클래스) 컴파일됨(경고 0·setter 0) · @Setter(필드) 컴파일됨(경고 2·setter 0) — 증명
이 반증 줄은 Lombok이 제공하는 네 가지 표시 — @Data, @NoArgsConstructor, @Setter(클래스 전체에 붙인 경우와 필드 하나에만 붙인 경우) — 를 각각 붙여 컴파일해 본 결과다. 괄호 안 숫자를 풀면, “컴파일됨/컴파일 실패”는 javac가 그 코드를 통과시켰는지이고, “에러 N”은 컴파일을 막은 에러 개수, “경고 N”은 컴파일은 됐지만 남은 경고 개수다. “setter N”은 컴파일 결과물을 javap로 열어 실제로 만들어진 public setter 메서드 개수를 센 것이다 — final 필드라 @Setter를 붙여도 setter가 0개로 나온다는 뜻이다.
세 가지를 없애면 규칙이 강제된다.
- 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;
}
}
클래스 위에 붙은 @Getter는 Lombok이라는 도구의 애너테이션이다. Lombok은 컴파일할 때 애너테이션을 보고 반복되는 코드를 대신 만들어 주는 도구인데, @Getter를 클래스에 붙이면 모든 필드마다 getProductName()처럼 값을 그대로 돌려주는 조회 메서드를 자동으로 만들어 준다. 그래서 이 클래스에는 생성자 말고는 손으로 쓴 메서드가 없다.
필드를 final로 두면 setter가 생기지 않고, 기본 생성자는 컴파일되지 않는다.
오래 「컴파일러가 셋 다 막는다」고 적어뒀는데 실측에서 갈렸다. 막히는 것은 @NoArgsConstructor 하나뿐이고 — 그것도 Lombok이 아니라 Java가 막는다. final 필드가 초기화되지 않은 채 남아서다 — @Setter와 @Data는 컴파일되고 setter만 조용히 빠진다. 클래스에 붙이면 경고조차 없다.
그래서 이 규칙이 실제로 지켜주는 것은 불변이고, 금지 목록은 지켜주지 않는다. 금지 어노테이션이 코드에 남아 있어도 컴파일은 통과한다. 그 목록을 대신 무엇이 지키는지는 원칙 7에서 정했다.
값 노출은 @Getter로, 이름은 getXxx()로
setter는 막되 조회는 평범한 getXxx()를 그대로 쓴다. fetchXxx() 같은 다른 접두사를 붙이면 JavaBeans 관례에서 벗어나 Lombok @Getter도, Jackson 기본 직렬화도 쓸 수 없게 된다. 그러면서 얻는 것은 없다 — fetchDisplayName()이 getDisplayName()보다 의도를 더 드러내지 않기 때문이다.
이 규칙은 이미 갖고 있는 필드 값을 꺼내는 메서드에만 적용된다. 외부를 조회하거나 폴백 정책을 태우는 메서드는 값 노출이 아니라 행위이므로 여기 해당하지 않는다. Service 계층의 assert에서 기본값 대체가 실제 정책이라면 fetchOrFallbackToListPrice()처럼 이름으로 밝히라고 한 것이 그 경우다. 가르는 기준은 접두사가 아니라 필드를 반환하는가, 일을 하는가다.
@Getter // 조회는 이걸로 충분하다
public class CreateOrderRequest {
private final String productName; // final이라 @Setter 를 붙여도 setter 가 안 생긴다 (컴파일은 된다)
}
다만 상태를 바꾸는 메서드는 다르다. 거기서는 이름이 의도를 드러내야 한다.
// ❌ 무엇을 하는지 알 수 없다
user.setEmail("a@b.com");
// ✅ 의도가 드러난다 — user를 고치지 않고, 바뀐 값을 담은 새 객체를 돌려준다
UserDomain verified = user.changeEmailAndVerify("a@b.com");
이름은 「바꾼다」여도 원래 객체의 필드는 그대로다. XxxDomain도 필드가 전부 final이라, 상태가 바뀐 결과는 새 객체로 돌려준다 — 이유는 애그리거트 경계 표준 원칙 3에 있다. 그래서 원칙 7의 검사도 Domain까지 final을 본다.
즉 의도를 드러내야 하는 것은 상태를 바꾸는 행위이지, 값을 꺼내는 일이 아니다. 자세한 논의는 Null-free 객체 설계에 정리해두었다.
“생성자로 만든다”와 “생성자에서 검증한다”는 다르다
혼동하기 쉬운 지점이라 먼저 짚는다.
| 의미 | Request DTO | |
|---|---|---|
| 생성자로만 만든다 | 상태가 완성되는 경로가 하나 | ✅ 적용 |
| 생성자에서 검증한다 | 값이 유효한지 확인 | ❌ 적용 안 함 |
Request DTO는 앞의 것만 취한다. 값의 유효성은 계층별 검증 표준에 따라 Bean Validation이 맡는다. 생성자에서 검증하면 Jackson 역직렬화 단계에서 터져, 어느 필드가 왜 틀렸는지 필드별로 담아 내려주는 errors 응답이 아니라 HttpMessageNotReadableException이 나가기 때문이다.
경로를 하나로 만드는 것과 값을 검증하는 것은 별개의 문제이고, 이 글은 앞의 것만 다룬다.
원칙 2. record가 아니라 class를 쓴다
record는 이 규칙을 공짜로 준다. 불변이고, setter가 없고, canonical constructor(record가 선언한 필드를 그대로 받는 생성자. record Point(int x, int y)라고만 적어도 자바가 자동으로 만들어 준다) 하나만 존재한다. 그럼에도 이 표준은 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;
}
}
한 줄씩 보면 — @Min(0)·@Max(100)·@NotBlank는 Bean Validation이 값의 범위나 빈 문자열 여부를 자동으로 검사하게 하는 애너테이션이다(“0 이상”, “100 이하”, “비어 있으면 안 된다”는 뜻). @Schema는 코드 동작에는 영향을 주지 않고, Swagger 문서 화면에 그 필드의 설명과 예시 값을 보여주기 위한 것이다. extends PageableRequest는 SearchOrderRequest가 PageableRequest를 상속받는다는 뜻이다. 그래서 page·size 필드를 다시 선언하지 않아도 부모가 가진 것을 그대로 쓴다. @JsonCreator·@JsonProperty는 Jackson에게 역직렬화할 때 이 생성자를 쓰고, 어떤 파라미터가 어떤 JSON 키와 짝인지 알려주는 애너테이션이다. 자세한 쓰임은 원칙 3에서 다룬다.
일부만 record로 두면 “이건 왜 record고 저건 왜 class인가”를 매번 판단해야 하고, 나중에 공통 필드가 생겨 record를 class로 바꿀 때 파급이 커진다. 전부 class로 통일하는 편이 규칙으로서 단순하다.
대신 record가 공짜로 주던 것을 규율로 지켜야 한다
이게 class 선택의 실제 비용이다.
- 생성자를 직접 써야 한다
final필드를 직접 선언해야 한다- Jackson이 생성자를 쓰도록 명시해야 한다 (원칙 3)
- 상속 시 빌더가 달라진다 (원칙 4)
코드가 길어지는 건 사실이다. 다만 길어지는 쪽은 쓰는 비용이고, 줄어드는 쪽은 읽는 비용이다. 생성 지점이 하나면 “이 객체가 어떤 상태로 존재할 수 있는가”를 확인할 때 그 한 곳만 보면 된다. 작성은 도구의 도움을 받을 수 있지만, 읽고 추적하는 일은 결국 사람이 한다.
원칙 3. Jackson이 생성자를 쓰도록 명시한다
반증: @JsonCreator 없는 2인자 생성자 class 역직렬화 — Boot 3.5.16(Jackson 2.21.4) 성공 · Boot 4.1.1(Jackson 3.1.5) 성공 — 증명
반증: @Setter·@NoArgsConstructor·@AllArgsConstructor class 역직렬화 경로 — Boot 3.5.16(Jackson 2.21.4) 기본 생성자+setter·WARN 0건 · Boot 4.1.1(Jackson 3.1.5) 전 인자 생성자·WARN 0건 — 증명
반증: Lombok 1.18.48 final 필드 DTO — @Data 컴파일됨(경고 0·setter 0) · @NoArgsConstructor 컴파일 실패(에러 1) · @Setter(클래스) 컴파일됨(경고 0·setter 0) · @Setter(필드) 컴파일됨(경고 2·setter 0) — 증명
Controller가 받는 Request는 내가 만드는 객체가 아니다. Jackson이 만든다. 그래서 이 규칙이 지켜지는지 여부도 Jackson에 달려 있다.
record는 canonical constructor를 자동으로 쓴다. class에도 @JsonCreator를 명시한다 — 다만 이유가 「그래야만 되기 때문」은 아니다.
실측에서는 명시 없이도 됐다. Spring Boot 3.5.16과 4.1.1 모두 @JsonCreator 없는 2인자 생성자로 역직렬화에 성공했다. Boot Gradle 플러그인이 컴파일할 때 -parameters라는 옵션을 자동으로 켜 주기 때문으로 보인다 — 이 옵션이 있으면 컴파일된 클래스 파일에 “몇 번째 파라미터 이름이 productName이다”라는 정보가 그대로 남아서, Jackson이 그 이름을 보고 JSON 키와 생성자 파라미터를 바로 짝지을 수 있다(이 이름 정보를 읽는 모듈을 “파라미터 이름 모듈”이라 부른다). 설정 없는 Jackson 2 매퍼에서만 실패했다.
그래도 명시하는 이유는 그 조건이 우리 것이 아니기 때문이다. 컴파일 옵션이나 스타터(Spring Boot가 관련 라이브러리를 한데 묶어 주는 의존성 꾸러미) 구성이 바뀌면 조용히 깨지고, 깨지는 자리는 요청을 받는 순간이다. 한 줄로 그 의존을 끊는다.
// ✅ 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;
}
에러도, 경고도 없다. 두 Boot 버전에서 WARN 로그가 0건이었다 — 여기서 말하는 WARN은 앞서 나온 “컴파일 경고”(javac가 코드를 보고 찍는 경고)와는 다르다. 로그 레벨 중 하나로, 프로그램이 돌아가는 중에 “문제가 될 수도 있다”는 메시지를 남기는 등급이다. 이번 반증에서는 역직렬화가 진행되는 동안 그런 메시지가 하나도 안 찍혔다는 뜻이다. 다만 어느 경로로 만들어지는지는 버전마다 달랐다 — Boot 3.5.16은 기본 생성자와 setter로, Boot 4.1.1은 전 인자 생성자(@AllArgsConstructor가 만들어 주는, 필드를 하나도 빠뜨리지 않고 전부 받는 생성자)로 만들었다. Boot 4에서는 JSON에 없는 필드의 초기값이 null로 덮이는 차이까지 생긴다.
경로가 무엇이든 결과는 같다. 동작은 똑같아 보이는데 객체가 불완전한 상태로 존재하는 구간이 생기고, 다른 코드가 setter로 상태를 바꿀 수 있게 된다. Service 계층 assert 표준에서 경계했던 “문제를 숨기는 코드”와 정확히 같은 성격이다.
그래서 금지 목록을 명시적으로 둔다. DTO에는 @Setter, @NoArgsConstructor, @Data를 쓰지 않는다. @Data는 @Setter를 포함해서, 누가 final을 빼는 순간 setter까지 만들어 준다.
이 금지 목록은 컴파일러가 지켜주지 않는다. final 필드가 막는 것은 @NoArgsConstructor 하나뿐이고, @Setter와 @Data는 컴파일되고 setter만 안 생긴다(원칙 1). 객체가 불변이라는 성질은 final이 지키지만, 금지된 어노테이션이 코드에 남는 것은 못 막는다. 그 빈자리는 원칙 7에서 메웠다 — 어노테이션 대신 그 결과인 가변성을 ArchUnit으로 검사한다.
원칙 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();
}
}
Objects.requireNonNull(productName, "productName")은 productName이 null이면 그 자리에서 바로 NullPointerException을 던지고, null이 아니면 값을 그대로 돌려주는 자바 표준 메서드다. 두 번째 인자(“productName”)는 예외 메시지에 들어가 어떤 필드가 null이었는지 바로 알 수 있게 해 준다. quantity·idOrNull처럼 애초에 null이 될 수 있거나 원시 타입(int)인 필드에는 쓰지 않는다.
AccessLevel.PRIVATE는 Lombok이 자동으로 만들어 주는 builder() 메서드의 접근 범위를 private으로 낮추라는 지정이다. @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가 생성"] -->|"private 기본 생성자, 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와 MyBatis는 둘 다 자바 객체와 DB 사이를 이어 주는 도구다. JPA는 메서드 호출만으로 SQL을 자동으로 만들어 실행하고, MyBatis는 SQL을 직접 쓰되 그 결과를 객체에 채워 넣는 일을 대신해 준다.
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;
}
}
한 줄씩 보면 — @Id는 이 필드가 테이블의 기본 키(PK)라는 뜻이고, @GeneratedValue는 그 값을 DB가 1씩 자동으로 늘려 채우게 한다는 뜻이다. @Column(nullable = false)는 이 값이 비어 있으면 안 된다는 제약이다. protected OrderEntity() {}는 JPA가 프록시를 만들 때 쓰는 빈 생성자이고, 주석대로 직접 호출하지 않는다. create()가 밖에서 쓸 수 있는 유일한 생성 통로다.
원칙 6. MyBatis MapperResult는 private 기본 생성자만 두고 setter는 두지 않는다
반증: setter 없는 타입으로 같은 조회를 받을 수 있는가 — Boot 3.5.16 MyBatis 3.5.17 (대조군)기본생성자+setter 채워짐(1,alpha,700) (가)final필드+전인자생성자만 채워짐(1,alpha,700) (나)기본생성자+setter없음 채워짐(1,alpha,700) — 증명
실측(supported): SELECT 순서만 id,code,name 으로 바꾸면 생성자가 무엇을 받는가 — MyBatis 3.5.17 (가)class·final 생성자(1,C-7,alpha) getter(1,alpha,C-7) · (나)record 생성자(1,C-7,alpha) getter(1,C-7,alpha) · (다)이름기준설정=true 생성자(1,alpha,C-7) getter(1,alpha,C-7) · (라)private기본생성자 생성자(noarg) getter(1,alpha,C-7) — 증명
인용줄 읽는 법. (대조군)·(가)·(나)·(다)·(라)는 같은 조회를 받도록 시도해 본 서로 다른 결과 타입 모양에 붙인 이름표다 — 아래 “안 A”·”안 C” 절에서 각 모양을 코드로 보여준다. 첫 번째 인용줄의 괄호 안 값은 테스트로 넣어 둔 행(id=1, name=’alpha’, amount=700)이 세 필드 모두 기대한 값으로 “채워졌는지”다. 두 번째 인용줄은 name과 code 컬럼의 SELECT 순서를 일부러 바꾼 뒤(id=1, name=’alpha’, code=’C-7’) 잰 것인데, 생성자(...)는 생성자가 실제로 받은 인자 순서이고 getter(...)는 객체가 다 만들어진 뒤 getter로 읽은 값이다. (가)만 봐도 둘이 다르다 — 생성자는 컬럼 순서대로 C-7, alpha를 받아 이름과 값이 뒤바뀌었는데, getter는 alpha, C-7로 맞게 나온다. MyBatis가 생성자로 다 채운 뒤 이름 기준으로 필드를 한 번 더 덮어써서, 생성자 안의 뒤바뀜이 겉보기엔 안 보이게 된다는 뜻이다. 반면 (나) record는 생성자 값을 덮어쓸 방법이 없어 뒤바뀐 채로 남는다.
이 네 글자가 아래 “안 A”·”안 B”·”안 C”와 이렇게 짝지어진다. (가) class·final과 (나) record는 둘 다 “final 필드 + 생성자만 둔다”는 같은 모양이라 안 A가 비교한 결과이고, (다) 이름기준설정=true는 안 C, (라) private기본생성자는 안 B다. 즉 안 A가 뒤바뀜을 막지 못한다고 말할 때 그 증거가 (가)·(나)이고, 안 C가 “설정이 빠지면 깨진다”고 말할 때의 (다), 안 B가 “순서를 바꿔도 맞았다”고 말할 때의 (라)가 바로 이 결과다.
원칙 5의 두 번째 예외다. 이 표준은 MapperResult를 예외로 두고 @Setter·@NoArgsConstructor를 허용해왔다. 근거는 「MyBatis는 기본 생성자와 setter로 매핑하고, 생성자 매핑을 쓰려면 별도 설정이 필요하다」였다.
그 전제가 실측에서 두 번 깨졌다. 첫 실험에서는 설정 없이 final 필드에 전 인자 생성자만 둔 타입도, setter가 없는 타입도 값이 다 찼다. 그런데 그 실험은 SELECT 컬럼 순서를 생성자 파라미터 순서와 똑같이 맞춰 두었다. 순서만 id, code, name으로 바꿔 다시 쟀더니 생성자가 name과 code를 뒤바꿔 받았다(MyBatis 3.5.17). 예외는 없었다. MyBatis 문서대로 기본 설정의 생성자 자동 매핑은 이름이 아니라 컬럼 순서를 본다(argNameBasedConstructorAutoMapping 기본값 false, MyBatis 설정 문서).
정해야 했던 건 이거였다 — MapperResult를 어떤 모양으로 만들어야 MyBatis가 값을 이름대로 채우는가.
안 A — 예외를 없애고 다른 DTO처럼 final 필드 + 생성자만 둔다. 얻는 것은 금지 목록에서 예외가 사라진다는 점이다. 버린 이유는 조인 쿼리에 컬럼을 하나 넣거나 순서를 바꾸는 순간 같은 타입 두 값이 조용히 뒤바뀐다는 것이다. class는 매핑 뒤 이름 기준으로 필드를 다시 덮어써 getter가 맞게 나왔는데, 그래서 더 나쁘다 — 생성자 안의 검증은 뒤바뀐 값을 보고, getter만 확인하는 테스트는 못 잡는다. record는 뒤바뀐 채로 남았다.
안 C — 생성자를 쓰되 이름 기준 설정을 강제한다. mybatis.configuration.arg-name-based-constructor-auto-mapping=true와 -parameters 컴파일을 둘 다 켜면 class도 record도 이름대로 맞았다. 얻는 것은 MapperResult까지 final을 지킨다는 점이다. 버린 이유는 둘 중 하나가 빠진 프로젝트에서 경고 없이 안 A의 뒤바뀜으로 돌아간다는 것이다. 설정은 프로젝트마다 사람이 기억해야 하고, 빠져도 테스트가 통과한다.
골랐다 — 안 B. setter는 없애고 private 기본 생성자만 둔다. 기본 생성자가 있으면 MyBatis는 생성자에 인자를 넣지 않고 이름으로 필드를 찾아 채운다. setter가 없어도 private 필드에 직접 넣는다(MyBatis 매핑 문서 — “MyBatis will look for a field of the given name”). 컬럼 순서를 바꾼 실측에서도 값이 맞았다. 안 A가 못 막는 순서 뒤바뀜을 막고, 안 C와 달리 설정이 빠져서 깨질 자리가 없다. 기존 규칙보다 @Setter 하나가 줄어 금지 목록의 구멍도 좁아진다.
// MyBatis 예외: private 기본 생성자만 허용한다. setter는 두지 않는다
@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class OrderMapperResult {
private Long id;
private String productName;
private int quantity;
private BigDecimal unitPrice;
}
대신 MapperResult의 필드는 final이 아니다. 생성자가 끝나도 상태가 완성되지 않는다는 원칙 1의 약속을 이 타입에서만 포기한다.
예외를 허용해도 되는 이유는 MapperResult가 패키지 밖으로 나가지 않기 때문이다. 이는 패키지 구조 표준에서 package-private으로 강제된다. DTO 네이밍 표준에 따라 Repository 구현체가 즉시 Domain으로 변환하므로, 이 느슨한 객체의 수명은 메서드 하나 안에서 끝난다.
반면 MapperParam은 내가 만드는 객체이므로 예외가 아니다. 정상적으로 생성자 규칙을 따른다.
원칙 7. 금지 목록은 어노테이션이 아니라 가변성으로 검사한다
원칙 1의 반증으로 금지 목록을 지키는 것이 컴파일러가 아니라는 것이 드러났다. @Setter와 @Data는 final 필드 DTO에서 그냥 컴파일된다. 그러면 남는 것은 리뷰인데, 리뷰는 패키지 구조 표준 원칙 6이 「파일이 수백 개면 되는 척만 하는 규칙」이라며 버린 모양이다. 더 나쁜 것은 금지 어노테이션이 남아 있는 DTO에서 누가 final 하나를 지우는 순간 setter가 생긴다는 점이다. 그때도 컴파일은 통과한다.
안건(#170)에는 수단 후보가 셋 올라 있었다. ArchUnit, Lombok 설정 파일(lombok.config에서 @Setter·@Data를 쓰면 오류로 만드는 설정), 그리고 Checkstyle·PMD·grep 같은 정적 검사다. 첫 후보에는 「Lombok 어노테이션은 바이트코드에 없을 수 있다 — 확인 필요」가 붙어 있었다.
확인해 보니 정말 없었다. Lombok 소스에서 세 어노테이션이 모두 @Retention(RetentionPolicy.SOURCE)다(lombok의 src/core/lombok/Setter.java:54, Data.java:46, NoArgsConstructor.java:43). 소스 단계에만 남고 컴파일된 .class 파일에는 남지 않는다는 뜻이다. ArchUnit은 .class 파일을 읽는 도구라 「이 클래스에 @Setter가 붙었나」를 물을 수가 없다.
그래서 묻는 것을 바꿨다(2026-10-21). 금지 목록이 막으려던 것은 어노테이션 자체가 아니라 생성 뒤에 상태가 바뀌는 것이다. 그것은 .class 파일에 그대로 남는다 — final이 아닌 필드와 setXxx 메서드다.
private static final String DTO = ".*(Request|Response|Command|Query|Domain|Snapshot|View|MapperParam)";
@ArchTest
static final ArchRule DTO_필드는_final이다 =
fields().that().areDeclaredInClassesThat().haveNameMatching(DTO)
.and().areNotStatic()
.should().beFinal();
@ArchTest
static final ArchRule DTO에는_setter가_없다 =
noMethods().that().areDeclaredInClassesThat().haveNameMatching(DTO)
.should().haveNameMatching("set[A-Z].*");
이 코드는 ArchUnit 테스트 클래스 안에 둔다. @ArchTest가 붙은 ArchRule 필드 하나가 테스트 하나다. 테스트를 돌리면 ArchUnit이 컴파일된 클래스를 훑고, 규칙을 어긴 클래스가 하나라도 있으면 그 테스트가 실패한다.
한 줄씩 따라가기. DTO는 클래스 이름이 이 접미사들 중 하나로 끝나는지 보는 정규식이다. 접미사는 아래 계층별 정리 표의 타입에, 이 표준의 규칙 파일(에이전트가 코드를 쓸 때 읽는 coding-standards/.claude/rules/dto-constructor-only-standard.md)이 정한 XxxSnapshot·XxxView를 더한 것이고, 예외인 XxxEntity·XxxMapperResult는 접미사가 달라 저절로 빠진다. 첫 규칙의 fields().that().areDeclaredInClassesThat().haveNameMatching(DTO)는 「그런 클래스 안에 선언된 필드 전부」이고, .and().areNotStatic()으로 static 상수는 뺀다. .should().beFinal()이 그 필드가 전부 final이어야 한다는 뜻이다. 둘째 규칙은 같은 클래스 안에 이름이 set 다음 대문자로 시작하는 메서드가 하나도 없어야 한다는 뜻이다. set[A-Z].*로 적은 것은 settle()처럼 우연히 set으로 시작하는 행위 메서드를 잘못 잡지 않으려는 것이다.
어노테이션 목록과 견주면 잡는 범위가 오히려 넓다. @Setter로 만든 setter든 손으로 쓴 setter든, @Data가 남은 클래스에서 final을 지웠든, 결과가 가변이면 똑같이 걸린다. 반대로 금지 어노테이션이 붙어 있어도 final 덕에 아무것도 생기지 않았다면 걸리지 않는다 — 원칙 1의 반증이 보여 준 대로 그 어노테이션은 그 순간 아무 일도 안 하고 있다. @NoArgsConstructor는 따로 볼 필요가 없다. 필드가 final이면 원칙 1의 반증대로 컴파일이 실패하고, final을 지워 컴파일을 통과시키면 첫 규칙에 걸린다.
나머지 두 후보는 이 차이에서 갈렸다. lombok.config는 Lombok 어노테이션만 막는다. 손으로 쓴 setter는 Lombok을 거치지 않으니 그대로 지나간다. Checkstyle·PMD·grep은 소스를 읽으니 어노테이션을 직접 찾을 수 있지만, 검사 도구가 하나 더 는다. 이 표준은 이미 패키지 경계를 ArchUnit 테스트로 지키고 있어서(패키지 구조 표준 원칙 6), 같은 자리에 두 줄을 더하면 도구가 늘지 않는다. 그리고 ArchUnit은 어노테이션이 아니라 결과(가변성)를 보므로 손으로 쓴 setter까지 잡는다.
두 줄을 가짜 클래스에 돌려 봤다. ArchUnit 1.4.1(이 표준을 적용하는 실제 프로젝트가 쓰는 버전)로 걸어 보니, final이 아닌 필드와 setName()이 있는 OrderResponse만 걸렸다. static 필드와 settle()을 가진 OrderRequest, Lombok 빌더 모양의 중첩 클래스(OrderDomain.OrderDomainBuilder — 이름이 Builder로 끝난다)를 둔 OrderDomain, setter가 있는 OrderEntity, final이 아닌 OrderMapperResult는 걸리지 않았다. 그 프로젝트의 코드에 돌려 본 것은 아니다. 이 안건을 열 때 센 바로는 그 프로젝트에서 두 줄에 걸릴 곳이 setter 메서드 1개, final이 아닌 필드를 가진 DTO 3개다. 고치는 것은 그 프로젝트의 몫이다 — 표준은 규칙과 그것을 지키는 장치까지를 정한다.
대가는 둘이다. 검사가 이름에 기댄다. 접미사 규칙을 어긴 클래스는 처음부터 이 검사 밖이고, DTO 접미사가 새로 생기면 이 정규식을 함께 고쳐야 한다. 그리고 final은 필드가 다른 객체로 바뀌는 것만 막는다. final List의 안에 원소를 넣고 빼는 것은 이 두 줄이 보지 않는다.
계층별 정리
| 타입 | 누가 만드는가 | 생성 방식 |
|---|---|---|
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 | 예외 — private 기본 생성자, setter 없음 |
금지 목록은 계층과 무관하게 동일하다. @Setter와 @Data는 모든 DTO에서 쓰지 않는다. @NoArgsConstructor는 MapperResult에만 access = PRIVATE로 쓴다.
이 표준을 정하기까지
시작은 상태 추적의 어려움이었다. 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은 내가 만드는 객체라 예외가 아니다.
근거 셋은 나중에 실측으로 무너졌고, 규칙은 남았다(2026-09-16). 「class는 @JsonCreator가 없으면 안 된다」는 Boot 매퍼에서 명시 없이도 됐다. 「setter가 있으면 조용히 setter 경로로 돌아간다」는 Boot 4가 전 인자 생성자를 골랐다. 「final이면 금지 어노테이션이 컴파일되지 않는다」는 @NoArgsConstructor 하나만 막혔다. 셋 다 규칙이 시키는 행동은 그대로 두고 이유만 바꿨다 — 남의 설정에 기대지 않는다, setter가 있다는 사실 자체가 원칙 1을 깬다, 금지 목록은 컴파일러가 아니라 다른 수단이 지켜야 한다. 그 다른 수단은 2026-10-21에 정했다(원칙 7).
MyBatis 예외의 모양은 실측으로 다시 정했다(2026-09-16). 「설정 없이도 생성자만으로 매핑된다」는 첫 실측이 나와 예외를 없애는 쪽으로 기울었는데, 그 실험이 컬럼 순서를 파라미터 순서와 맞춰 둔 것이 걸렸다. 순서를 바꾸자 생성자가 값을 뒤바꿔 받았다. 없애는 안(A)은 뒤바뀜을 못 막고, 이름 기준 설정을 강제하는 안(C)은 설정이 빠지면 조용히 A로 돌아간다. private 기본 생성자만 두고 setter를 없앤 안(B) 이 설정 없이 이름대로 채워졌고, 예외의 폭도 전보다 좁아졌다.
금지 목록을 지킬 장치는 결과를 보는 쪽으로 정했다(2026-10-21, #170). 근거 셋이 무너진 뒤 「컴파일러가 강제한다」가 지워진 자리가 비어 있었다. 안건에 오른 후보 중 ArchUnit으로 어노테이션을 직접 보려 하니, Lombok 소스에서 세 어노테이션이 모두 소스에만 남는 어노테이션이었다. 묻는 것을 「무엇이 붙었나」에서 「결과가 가변인가」로 바꾸자 같은 ArchUnit으로 지킬 수 있었고, 손으로 쓴 setter까지 함께 잡혔다(원칙 7). 그 프로젝트에서 걸릴 곳은 그 프로젝트가 고친다.
정리
- 상태가 완성되는 지점을 생성자 하나로 모은다. 생성자가 끝나면 객체는 완전하고 이후 바뀌지 않는다
- 필드를
final로 선언한다. 그러면 손으로 쓴 setter와 기본 생성자는 컴파일되지 않는다. 금지 어노테이션은 막지 못한다 - 조회는
@Getter와getXxx()를 그대로 쓴다. 의도를 드러낼 대상은 상태를 바꾸는 행위이지 값을 꺼내는 일이 아니다 @Setter와@Data는 쓰지 않고,@NoArgsConstructor는MapperResult에만access = PRIVATE로 쓴다. 하나만 있어도 생성 뒤에 상태를 바꾸는 길이 열린다. 컴파일러는 이 목록을 지켜주지 않는다- 금지 목록은 어노테이션이 아니라 가변성으로 검사한다. Lombok 어노테이션은
.class에 안 남는다. ArchUnit이 DTO 접미사 클래스의 필드가 전부final이고setXxx가 없는지 본다 - record가 아니라 class를 쓴다. DTO 공통 필드를 상속으로 관리해야 하기 때문이다
- Jackson에는
@JsonCreator로 생성자를 명시한다. Boot 설정에서는 없어도 되지만, 그 설정(-parameters, 파라미터 이름 모듈)은 우리 것이 아니다 - 빌더는
private으로 막고 정적 팩토리만 노출한다. 빌더는 필드를 빼먹어도 컴파일된다 - 예외는 JPA Entity와 MyBatis MapperResult뿐이다. 프레임워크가 강제하고, 영향 범위가 좁다.
MapperResult는 private 기본 생성자만 두고 setter는 두지 않는다 — 생성자 매핑은 컬럼 순서를 봐서 값이 조용히 뒤바뀐다 - “생성자로 만든다”와 “생성자에서 검증한다”는 다르다. Request는 앞의 것만 따른다
자신만의 철학을 만들어가는 중입니다.
댓글남기기