DTO 생성자 표준은 금지 목록을 적은 뒤 그 목록이 저절로 지켜진다고 했다.

DTO에는 @Setter, @NoArgsConstructor, @Data를 쓰지 않는다.
필드를 final로 선언해두면 이 셋이 애초에 컴파일되지 않아, 실수 자체가 불가능해진다.

DTO는 데이터만 담아 옮기는 클래스다. 세 애너테이션은 모두 Lombok이 제공하는 표시다. @Setter는 필드마다 값을 바꾸는 메서드를 만들고, @NoArgsConstructor는 아무 값도 받지 않는 생성자를 만들고, @Data는 @Setter를 포함한 여러 애너테이션을 한 번에 묶은 것이다. @NoArgsConstructor는 그 묶음에 들어 있지 않다 — 아래 결과에서도 @Data를 붙인 변형에 기본 생성자가 0개였다(noargs=0).

같은 표준의 원칙 1(객체의 상태는 생성자에서 한 번에 완성된다)의 예시 주석도 같은 말을 한다 — 「final이므로 @Setter는 아예 컴파일되지 않는다」. 규율이 아니라 컴파일러가 막는다는 것이 이 규칙의 강제 수단이었다. 규율은 사람이 기억해서 지키는 것이라 잊으면 그대로 어겨진다. 컴파일러가 막으면 어긴 코드는 아예 빌드가 안 되니, 잊어도 어길 수가 없다. 이 차이가 이 글이 재려는 것이다.

표준을 증명하는 방식을 정할 때 이미 이 문장을 의심했다. 「내가 아는 Lombok 동작으로는 final 필드에 @Setter나 @Data를 붙여도 컴파일 에러 없이 setter만 생기지 않는다. 아직 확인하지 않았다.」 이 글은 그걸 확인한 실험이다.


주장 — final 필드 DTO에 @Setter·@NoArgsConstructor·@Data를 붙이면 컴파일되지 않는다

claim.md의 주장은 표준 글 문장 그대로다. 근거 종류는 1(기술 사실)이다.

주장이 「컴파일되지 않는다」이므로 판정은 javac 종료 코드로만 한다. setter가 실제로 생기는지는 기록만 했다. 「컴파일은 되는데 setter는 안 생긴다」와 「컴파일이 막힌다」는 강제 수단으로서 전혀 다르기 때문이다.


반증 조건 — 변형 넷 중 하나라도 컴파일되면 반증이다

판정은 셋 중 하나다. refuted는 주장이 틀렸다(반증됐다), supported는 주장이 뒷받침됐다, inconclusive는 어느 쪽인지 판단할 수 없다는 뜻이다. 변형 넷은 금지 애너테이션을 하나씩 붙인 DTO이고, 대조군은 아무것도 붙이지 않은 DTO다. 둘 다 아래 「설계」에서 코드로 보인다.

  • refuted — 변형 넷 중 하나라도 javac 종료 코드가 0이다
  • supported — 넷 모두 0이 아니다
  • inconclusive — 대조군이 컴파일되지 않는다

대조군을 둔 이유는 환경이 틀려서 전부 실패하는 경우를 supported로 읽지 않기 위해서다. 여기서 환경은 실험을 돌리는 도구 쪽이다. 예를 들어 Lombok을 내려받지 못해 javac에 걸리지 않으면 @Getter조차 해석되지 않으니, 금지 애너테이션과 상관없이 다섯 파일이 전부 실패한다. 대조군까지 실패하면 규칙이 맞은 게 아니라 컴파일러가 안 돈 것이다.


설계 — 빌드 도구 없이 javac에 Lombok만 걸어 다섯 파일을 따로 컴파일한다

대조군은 표준이 권하는 DTO 그대로다. @Getter, final 필드 둘, 2인자 생성자. 실제 파일은 이렇다.

package proof;

import lombok.Getter;

// 대조군 — 표준이 권하는 DTO 그대로다. 이게 컴파일되지 않으면 환경이 틀린 것이다
@Getter
public class Control {

    private final String productName;
    private final Integer quantity;

    public Control(String productName, Integer quantity) {
        this.productName = productName;
        this.quantity = quantity;
    }
}
  • @Getter — Lombok이 getProductName() · getQuantity()를 만들어 준다
  • private final String productName; · private final Integer quantity; — 값을 한 번만 넣을 수 있는 final 필드 둘이다. 뒤에 나오는 에러 메시지의 productName이 이 필드다
  • 생성자 — 두 값을 받아 두 필드에 한 번씩 넣는다. final 필드에 값을 넣는 곳은 여기 하나뿐이다

여기에 금지 애너테이션을 하나씩 더해 변형 넷을 만들었다. 나머지 줄은 대조군과 같다. 예를 들어 SetterOnField는 두 필드 위에 @Setter를 한 줄씩 더했고, NoArgsConstructor는 클래스 위 @Getter 아래에 @lombok.NoArgsConstructor 한 줄을 더했다. 이 줄이 이 파일의 6번째 줄이다. 앞에 lombok.을 붙인 것은 이 변형 클래스 이름이 NoArgsConstructor라 애너테이션 이름과 겹쳐서, import 대신 패키지까지 적은 전체 이름으로 쓴 것이다. Data 변형도 같은 이유로 @lombok.Data라고 적었다.

Control             대조군
SetterOnClass       클래스에 @Setter
SetterOnField       final 필드마다 @Setter
NoArgsConstructor   클래스에 @NoArgsConstructor
Data                클래스에 @Data

@Setter를 클래스와 필드 두 자리에 나눈 이유가 있다. 필드에 붙이면 「이 필드에 setter를 만들어라」는 콕 집은 요청이고, 클래스에 붙이면 「이 클래스 필드 전체에」라는 묶음 요청이다. 그래서 Lombok이 클래스에 붙인 애너테이션을 「만들 수 있는 필드에만」 적용하는 식으로 다룰 수 있고, 두 자리의 진단(컴파일러가 내는 에러 · 경고 메시지)이 다를 수 있었다.

컴파일은 빌드 도구인 Gradle을 거치지 않고 JDK 21(21은 버전 번호다)의 javac에 Lombok 1.18.48을 애너테이션 프로세서로 걸어 직접 불렀다. 빌드 도구의 경고 설정이 끼어들지 않게 하려는 것이다. 옵션은 명령 뒤에 -로 시작해 붙여서 그 명령의 동작을 바꾸는 설정이다. 빌드 설정에 따라 javac에 붙는 옵션이 달라지는데, 예를 들어 -Werror(경고가 하나라도 나면 컴파일을 멈춘다)가 붙으면 경고만 난 변형도 종료 코드가 0이 아니게 된다. 그러면 Lombok이 아니라 설정이 판정을 정한다. 컴파일된 변형은 javap로 public setter 수를 셌다.


결과 — 막힌 것은 @NoArgsConstructor 하나였다

run.sh(이 실험을 명령 하나로 처음부터 다시 돌리는 셸 스크립트)가 쓴 원본 출력이다.

PROBE_ENV lombok=1.18.48 javac=21.0.9
PROBE variant=Control exit=0 errors=0 warnings=0 setters=0 noargs=0
PROBE variant=Data exit=0 errors=0 warnings=0 setters=0 noargs=0
PROBE variant=NoArgsConstructor exit=1 errors=1 warnings=0 setters=- noargs=-
DIAG NoArgsConstructor | variants/NoArgsConstructor.java:6: error: variable productName might not have been initialized
PROBE variant=SetterOnClass exit=0 errors=0 warnings=0 setters=0 noargs=0
PROBE variant=SetterOnField exit=0 errors=0 warnings=2 setters=0 noargs=0
DIAG SetterOnField | variants/SetterOnField.java:10: warning: Not generating setter for this field: Setters cannot be generated for final fields.

(DIAG는 한 줄씩만 옮겼다. 전문은 result.md에 있다.)

한 줄씩 읽으면 이렇다.

  • PROBE_ENV — 실험 환경이다. Lombok 1.18.48, javac 21.0.9로 돌렸다
  • PROBE — 변형 하나의 결과가 한 줄이다. exit는 javac 종료 코드(0이면 컴파일 성공), errors와 warnings는 에러 수와 경고 수, setters는 javap로 센 public setter 수, noargs는 public 기본 생성자(아무 값도 받지 않는 생성자) 수다
  • setters=- noargs=- — 0이 아니라 「세지 못했다」다. javac는 컴파일에 성공하면 바이트코드를 담은 .class 파일을 만든다. 컴파일이 실패해 .class 파일이 없으니 javap로 열 것이 없다
  • DIAG — javac가 낸 진단 메시지다. NoArgsConstructor의 에러는 「productName 변수가 초기화되지 않았을 수 있다」, 즉 final 필드 productName에 값이 안 들어간다는 뜻이다. 에러가 가리킨 6번째 줄은 @lombok.NoArgsConstructor 줄이다 — Lombok이 그 표시를 보고 기본 생성자를 만들어 넣었고, 그 생성자가 문제라서다. quantity도 같은 처지인데 에러는 productName 하나만 찍혔다. javac가 왜 하나만 보고했는지는 확인하지 않았다
  • SetterOnField의 warnings=2 — 필드 둘에 하나씩이다. 옮긴 것은 첫째 필드(10번째 줄) 것이고, 둘째 필드(12번째 줄)에도 같은 경고가 났다. 경고 문장은 「이 필드에는 setter를 만들지 않는다: final 필드에는 setter를 만들 수 없다」다
변형 컴파일 진단 생긴 setter
@Setter 클래스 된다 없음 0
@Setter 필드 된다 경고 2 0
@Data 된다 없음 0
@NoArgsConstructor 안 된다 에러 1 —

대조군이 컴파일됐고 변형 셋이 종료 코드 0이다. 판정은 refuted다.


예상과 달랐던 점 — 경고조차 필드에 붙였을 때만 나온다

반증은 예상했다. 예상 밖이었던 것은 경고가 나오는 자리다.

  • @Setter를 필드에 붙이면 필드마다 「final 필드에는 setter를 만들 수 없다」는 경고가 나온다
  • @Setter를 클래스에 붙이거나 @Data를 쓰면 경고도 없다. Lombok은 클래스 단위 애너테이션을 「만들 수 있는 필드에만 만든다」로 다루고, 만들 수 있는 필드가 없어도 조용히 넘어간다

실수로 섞여 들어오는 모양은 대개 클래스 머리에 붙은 @Data나 @Setter다. 그 모양이 가장 조용하다.

@NoArgsConstructor가 막힌 이유도 Lombok이 아니라 Java다. @NoArgsConstructor가 만드는 생성자가 아무 값도 받지 않는 기본 생성자다. 대조군의 2인자 생성자는 두 final 필드에 값을 넣지만, 기본 생성자는 넣을 값이 없다. 그 생성자로 객체를 만들면 final 필드가 초기화되지 않은 채 남으므로 javac가 거부한다. 위 DIAG 에러가 바로 그 말이다. 「final이 막아준다」는 설명은 이 하나에서만 참이었다.

버전도 적어둔다. 이 실험은 Lombok 1.18.48로 돌렸고, setter 되돌아감 실험은 Spring Boot(이하 Boot)가 관리하는 1.18.46을 썼다. 그 실험은 @NoArgsConstructor·@Setter가 붙은 DTO를 Jackson이 JSON에서 만들 때 생성자 대신 setter 쪽 경로로 조용히 되돌아가는지를 쟀고, 거기서도 Lombok을 썼다. 그래서 두 실험의 Lombok 버전을 나란히 적는다. 「Boot가 관리한다」는 그 실험의 빌드 파일(Gradle이 읽는 설정 파일 build.gradle, 쓸 라이브러리와 버전을 적는 곳)에 Lombok 버전을 적지 않았고, Boot가 정해 둔 버전이 대신 들어왔다는 뜻이다. Boot로 만든 프로젝트라면 실제로 쓰게 될 버전은 그쪽이다. 두 버전에서 같은 결과가 나오는지는 재지 않았다.


이 결과가 표준에 남기는 것 — 금지 목록을 무엇이 지킬지 이슈에서 정한다

결과만 보면 금지 애너테이션이 붙은 DTO도 setter는 생기지 않는다. 객체가 불변(만든 뒤에 내용을 바꿀 수 없음)이라는 원칙 1의 목표는 final이 여전히 지킨다. 무너진 것은 「실수 자체가 불가능하다」, 즉 금지 목록의 강제 수단이다. 금지된 애너테이션이 코드에 소리 없이 남을 수 있다.

표준 글 원칙 1과 원칙 3(Jackson이 생성자를 쓰도록 명시한다)에 > 반증: 줄을 달았다. 원칙 3에도 같은 금지 목록(「DTO에는 @Setter, @NoArgsConstructor, @Data를 쓰지 않는다」)이 적혀 있어서다. 원칙 바로 밑에 「이 원칙의 근거가 실험에서 반증됐다」는 요약과 이 글 링크를 인용문 한 줄로 붙인 것이다. 그리고 kind: 모순 라벨을 붙인 이슈로 넘겼다. 이 라벨은 원래 두 표준이 서로를 부정할 때 붙이는데, 표준을 증명하는 방식에서 정한 규칙은 반증이 나오면 이 라벨로 이슈를 열어 실험이 틀렸는지 규칙이 틀렸는지 따지게 한다. 여기서 어긋난 쪽은 표준 글의 문장과 실험 결과다. 거기서 정할 것은 금지 목록을 계속 둘 것인가, 둔다면 컴파일러 대신 무엇이 지킬 것인가다.


정리

  • final 필드 DTO에서 컴파일이 막히는 것은 @NoArgsConstructor 하나다. 막는 쪽도 Lombok이 아니라 Java다
  • @Setter와 @Data는 컴파일되고 setter만 빠진다. 클래스에 붙이면 경고도 없다
  • 불변은 지켜지지만 금지 목록은 지켜지지 않는다. 강제 수단을 다시 정해야 한다

자신만의 철학을 만들어가는 중입니다.
최상단으로 이동했습니다!
확대 이미지

댓글남기기