이 글은 「규칙을 사람 대신 기계가 지키게 할 수 있는가」를 실제로 돌려 본 기록이다. 먼저 단어부터 정리하고, 왜 이 실험을 했는지로 넘어간다.


먼저 알아야 할 말 — Lombok은 컴파일할 때 코드를 대신 써 주는 도구다

코딩 표준. 여러 사람(과 AI 도구)이 같은 모양으로 코드를 쓰도록 내가 정해 둔 규칙 모음이다. 규칙마다 왜 그렇게 정했는지를 블로그 글로 남겨 두었고, 이 글에서 「표준」 또는 「표준 글」은 그 규칙과 글을 말한다.

JDK와 컴파일. JDK는 자바 프로그램을 만드는 데 필요한 도구 묶음이다. 그 안의 javac라는 컴파일러가 사람이 읽는 .java 파일을 컴퓨터가 실행하는 .class 파일(바이트코드)로 바꾼다. 이 글에서 「클래스 파일」은 이 .class 파일이다. 클래스 안의 필드·메서드·생성자를 묶어 그 클래스의 멤버라고 부른다.

DTO, getter, setter. DTO는 프로그램의 한 부분에서 다른 부분으로 데이터를 담아 옮기기만 하는 클래스다. getter는 getProductName()처럼 필드 값을 꺼내 돌려주는 메서드이고, setter는 setProductName(String name)처럼 객체를 만든 뒤에 필드 값을 바꾸는 메서드다. 내 표준은 DTO의 값이 생성자 한 번에 다 차고, 그 뒤로는 바뀌지 않게 하려고 setter를 금지한다. 같은 이유로 기본 생성자도 금지한다 — 기본 생성자가 있으면 값이 빈 객체를 먼저 만들고 나중에 채우는 길이 열린다.

기본 생성자. new 클래스이름()처럼 인자 없이 부르는 생성자다.

private과 protected. 둘 다 「누가 부를 수 있나」를 정하는 접근 제어자다. private은 그 클래스 안에서만, protected는 같은 패키지와 그 클래스를 상속한 클래스에서만 부를 수 있다. 상속은 class B extends A처럼 한 클래스가 다른 클래스를 이어받아 그 필드와 메서드를 물려받는 것이다. 자식 클래스(B)의 생성자는 첫 줄에서 super(...)로 부모 클래스(A)의 생성자를 먼저 부른다.

어노테이션. 클래스나 필드 위에 붙이는 @Getter 같은 표시다. 그 자체로는 아무 일도 하지 않는다. 표시를 읽고 무언가를 해 주는 프로그램이 따로 있어야 한다. 클래스 선언 바로 위(이 글에서는 「클래스 머리」라고 부른다)에 붙일 수도 있고, 필드 하나하나 위에 붙일 수도 있다.

Lombok. 그 「읽고 무언가를 해 주는 프로그램」 중 하나다. 클래스에 @Getter를 붙이면 getter 메서드를 직접 쓰지 않아도, 컴파일하는 순간 Lombok이 getter를 만들어 끼워 넣는다. 금지 대상인 셋은 이렇다.

  • @Setter — setter를 만든다
  • @Data — getter·setter·equals·hashCode·toString을 한꺼번에 만든다. 뒤의 셋은 객체끼리 같은지 비교하고(equals·hashCode) 객체를 글자로 바꾸는(toString) 메서드다
  • @NoArgsConstructor — 기본 생성자를 만든다

@lombok.Getter처럼 앞에 lombok.을 붙여 쓴 것은 @Getter와 같은 어노테이션이다. import 문 없이 패키지 이름까지 적은 것뿐이다.

에러와 경고. 컴파일하다 문제를 찾으면 javac는 둘 중 하나를 낸다. 에러는 컴파일을 실패시킨다 — .class 파일이 만들어지지 않는다. 경고는 화면에 문장 한 줄을 띄울 뿐 컴파일은 성공한다. Lombok이 찾은 문제도 javac를 통해 이 둘 중 하나로 나온다.

종료 코드. 프로그램은 끝날 때 성공했는지를 숫자 하나로 남긴다. 화면에는 안 보이고, 그 프로그램을 부른 쪽이 받는다. javac는 실패하면 1, 성공하면 0을 남긴다. 이 실험의 판정은 대부분 이 숫자로 한다.

스크립트와 run.sh. 터미널은 마우스 대신 글자로 명령을 쳐서 컴퓨터를 움직이는 창이다. 스크립트는 거기 칠 명령을 순서대로 적어 둔 파일이다. run.sh는 이 실험 전체를 처음부터 끝까지 돌리는 스크립트이고, javac를 부르고 종료 코드를 받아 기록하는 것도 여기서 한다.

javap. .class 파일 안에 무엇이 들었는지 글자로 풀어 보여 주는 도구다. JDK에 javac와 함께 들어 있다.

원칙. 내 표준 글은 규칙을 「원칙 1」「원칙 5」처럼 번호를 붙여 하나씩 적는다. 이 글에서 「원칙 N」은 그 번호의 규칙을 가리킨다.


이 실험을 한 이유 — final이 금지 어노테이션을 막아 준다는 믿음이 틀렸다

내 코딩 표준에는 「DTO에 @Setter·@Data·@NoArgsConstructor를 붙이지 않는다」는 규칙이 있다(DTO 생성자 표준). 규칙을 적는 것과 규칙이 지켜지는 것은 다른 일이다. 여럿이 함께 일할 때는 코드를 한곳(저장소)에 모아 둔다. 각자 자기 컴퓨터에서 고친 코드를 저장소에 올리고, 다른 사람이 그 변경을 읽어 본 뒤 모두가 쓰는 본 코드에 합친다. 이 「합치기 전에 읽어 보고 문제를 찾는 일」을 리뷰라고 한다. 누군가 금지된 셋 중 하나를 붙인 코드를 올리면, 지금은 사람이 리뷰에서 눈으로 찾아내는 것 말고는 막을 방법이 없다.

원래는 막을 방법이 있다고 믿었다. 표준 글에 「필드를 final로 선언해두면 이 셋이 애초에 컴파일되지 않는다」고 적어 두었다. 생각의 순서는 이랬다. final 필드는 생성자에서 한 번 값을 넣은 뒤로는 바꿀 수 없다. setter는 값을 바꾸는 메서드다. 둘이 부딪히니 컴파일러가 거부할 것이다.

그런데 앞선 실험에서 그 믿음이 틀렸다.

  • @NoArgsConstructor만 컴파일이 막혔다. 막은 것은 Lombok이 아니라 자바다. final 필드에는 생성자가 끝날 때까지 반드시 값이 들어가야 하는데, 인자 없는 생성자에는 넣을 값이 없다. 그래서 javac가 에러를 냈다
  • @Setter와 @Data는 컴파일이 성공했다. Lombok이 final 필드에는 setter를 만들지 않고 넘어가서, 만들어진 setter는 0개였다. 에러가 아니라 「조용히 안 만든다」였던 것이다
  • 경고는 붙인 자리에 따라 달랐다. @Setter를 필드 위에 붙이면 「final 필드에는 setter를 만들 수 없다」는 경고가 필드마다 나왔지만, 클래스 머리에 붙이거나 @Data를 쓰면 경고조차 없었다

그래서 이슈 #170-DTO 금지 어노테이션 목록을 무엇으로 강제하는가를 열었다. 이슈는 아직 정하지 못한 고민을 적어 두는 게시판 글이고, GitHub는 앞에서 말한 저장소를 인터넷에 두고 이런 게시판까지 함께 쓰게 해 주는 사이트다. 거기서 든 후보는 셋이다.

  1. ArchUnit — 클래스 파일을 읽어 규칙을 검사하는 도구
  2. lombok.config — Lombok에게 「이 어노테이션을 쓰면 에러로 처리하라」고 알려 주는 설정 파일
  3. 정적 검사 — Checkstyle·PMD 같은 검사 도구나 훅(코드 변경을 기록하는 「커밋」 같은 일을 할 때 자동으로 도는 검사 스크립트)이 .java 파일의 글자를 직접 읽어 찾는 방법. 「정적」은 프로그램을 실행하지 않고 본다는 뜻이다. ArchUnit도 프로그램을 실행하지 않지만, 소스 글자가 아니라 컴파일된 클래스 파일을 읽는다는 점이 다르다

이슈 본문에 「확인 필요」(아직 사실을 재 보지 않았다는 표시)라고 적어 둔 것은 앞의 둘뿐이다. 둘은 「되는가, 안 되는가」가 돌려 보면 답이 나오는 사실 질문이다. 첫째(ArchUnit)는 「컴파일하고 나면 어노테이션 글자가 클래스 파일에서 사라지는가」에 답이 달려 있다. 사라진다면 클래스 파일을 읽는 ArchUnit은 그 글자를 볼 수 없다. 둘째(lombok.config)는 「설정 한 줄로 컴파일이 실제로 멈추는가」에 답이 달려 있다. 셋째는 사정이 다르다. .java 파일에는 사람이 적은 @Setter라는 글자가 그대로 있으니, 그 글자를 읽는 방법은 볼 수 있느냐를 따질 필요가 없다. 남은 것은 어느 도구를 고를지라는 선택이다. 실험을 돌리기 전에 주장과 판정 기준을 적어 두는 파일 claim.md에도 「어느 수단을 택할지는 재는 일이 아니다」라고 적었다. 이 글은 앞의 둘을 잰 실험이다.


주장 — 금지 어노테이션은 클래스 파일에 남지 않고, lombok.config의 ERROR는 컴파일을 실제로 멈춘다

claim.md에 돌리기 전에 적어 둔 주장은 세 조각이다.

  1. 금지 어노테이션 셋은 클래스 파일에 남지 않는다. 그래서 ArchUnit이 볼 수 없다
  2. lombok.config 파일에 lombok.setter.flagUsage = ERROR처럼 적으면, 그 어노테이션을 쓴 코드는 컴파일이 실패한다. flag는 「표시를 단다」는 뜻이고, flagUsage는 그 어노테이션을 쓴 자리에 어떤 표시를 달지(경고냐 에러냐) 정하는 설정이다. 설정 이름 가운데에 어노테이션 이름이 들어간다 — setter면 @Setter, data면 @Data, noArgsConstructor면 @NoArgsConstructor를 막는다
  3. 하위 폴더에 flagUsage = ALLOW(표시를 달지 않고 허용한다는 값)를 두면 그 폴더만 예외로 풀 수 있다

ArchUnit은 컴파일된 클래스 파일을 읽어서 「이 패키지의 클래스는 저 패키지를 쓰면 안 된다」 같은 규칙을 검사하는 도구다. 규칙은 테스트 코드, 즉 자동으로 돌려서 규칙이 지켜졌는지 확인하는 코드로 적는다. #170의 첫 번째 후보였다. 이 도구는 .java가 아니라 .class를 읽는다. 그래서 어노테이션이 .class에 남지 않으면 ArchUnit으로는 그 어노테이션을 찾을 수 없다. 1번은 이걸 확인하려는 주장이다.

lombok.config는 두 번째 후보다. Lombok은 컴파일할 때 소스 파일이 있는 폴더와 그 위 폴더들에서 이 이름의 설정 파일을 찾아 읽는다. 그래서 위 폴더에 적은 설정은 그 아래 폴더의 소스에도 그대로 적용된다. 여기에 「이 어노테이션을 쓰면 에러로 처리하라」고 적을 수 있다는 것이 2번이다.

3번이 필요한 이유는 표준에 예외가 둘 있기 때문이다. DB(데이터베이스)는 데이터를 엑셀처럼 표 모양으로 저장해 두는 프로그램이고, 그 표 하나를 테이블이라고 부른다. MyBatis와 JPA는 테이블의 한 줄을 자바 객체로 바꿔 주는 도구다. MapperResult는 MyBatis가, Entity는 JPA가 값을 채우는 클래스다. 둘 다 클래스 하나의 이름이 아니라 그 역할을 하는 클래스들을 묶어 부르는 말이다. 실제 프로젝트의 클래스는 OrderMapperResult, OrderEntity처럼 각자 이름을 가진다. 이 실험은 A·B·C 세 부분으로 나눠 설계했는데, 그 가운데 설계 C에서는 짧게 쓰려고 클래스 이름을 그냥 MapperResult로 지었다. 두 클래스는 내 코드가 아니라 도구가 객체를 만든다. 그래서 DTO 생성자 표준 원칙 5·6은 이 둘에만 예외를 둔다. 예외로 허용한 이 기본 생성자를 표준 원칙 6의 예시는 손으로 쓰지 않고 Lombok의 @NoArgsConstructor(access = AccessLevel.PRIVATE)로 만든다. 괄호 안은 「private 기본 생성자를 만들라」는 뜻이다(설계 C에서 한 줄씩 다시 본다). 반면 Entity의 예시는 protected OrderEntity() {}처럼 생성자를 손으로 쓴다. 손으로 쓴 생성자는 Lombok 어노테이션이 아니라서 금지에 걸리지 않는다. 그래서 반드시 풀어야 하는 곳은 MapperResult 하나다. 다만 팀에 따라 Entity 생성자도 Lombok으로 만들 수 있어서, 이 실험은 Entity(클래스 이름은 짧게 Order)도 같은 어노테이션에 PRIVATE 대신 PROTECTED를 넣어 만들고, 그것도 풀리는지 함께 봤다.

Entity의 예외 — protected 기본 생성자. 표준 글은 「JPA는 프록시 생성을 위해 기본 생성자를 요구한다」고 적었다. 프록시는 JPA가 내 클래스를 상속해서 만들어 쓰는 자식 클래스다. 예를 들어 주문 객체가 회원 객체를 가리키고 있으면, JPA는 주문을 읽을 때 회원까지 DB에서 바로 읽어 오지 않는다. 회원 자리에는 진짜 객체 대신 자리만 차지하는 이 자식 객체를 먼저 넣어 두고, 회원 정보를 실제로 꺼내 쓰는 순간에 읽어 온다. 이 자식 객체는 아직 값을 모르는 채로 만들어지니 부모 생성자에 넘길 인자가 없다. 그래서 자식 클래스의 생성자는 super()로 부모의 기본 생성자를 불러야 하는데, 부모 생성자가 private이면 자식이 부를 수 없다. 그래서 JPA 규칙은 기본 생성자를 public이나 protected로 두라고 정해 두었고, 표준은 그중 더 좁은 protected를 골랐다. protected로 두면 다른 패키지의 내 코드는 이 생성자로 객체를 만들 수 없고, 상속한 자식 클래스(프록시가 여기에 든다)만 super()로 부를 수 있다. 프록시를 언제 어떻게 만드는지는 이 실험 밖이다.

MapperResult의 예외 — private 기본 생성자. 먼저 MyBatis가 값을 넣는 방식이 둘이라는 것부터 본다. DB에서 줄 하나를 읽어 오면(조회하면) 칸(컬럼, 테이블의 세로 칸)마다 이름이 붙어 온다. 조회할 칸과 그 순서는 DB에게 무엇을 꺼내 달라고 적는 문장(SQL이라는 언어로 쓴다)에 내가 직접 적는다.

  • 값을 받는 생성자만 있으면 MyBatis는 칸을 순서대로 생성자 인자에 넣는다
  • 기본 생성자가 있으면 MyBatis는 그 생성자로 빈 객체를 만든 뒤, 칸 이름과 같은 이름의 필드를 찾아 값을 직접 넣는다. setter가 없어도, 필드가 private이어도 넣는다. 자바 기초 문법으로는 private 필드를 바깥에서 건드릴 수 없지만, 자바에는 그걸 하는 방법이 따로 있다. 그 방법은 이 실험 밖이다

컬럼 순서 실험이 둘을 견줬다. 테이블에 id=1, name='alpha', code='C-7'인 줄을 넣어 두고, SQL에 적는 칸 순서만 id, code, name으로 바꿔 읽어 왔다.

  • 값을 받는 생성자 (id, name, code) 하나만 둔 클래스는 생성자가 1, C-7, alpha를 받았다. name 자리에 code 값이 들어간 것이다. 둘 다 문자열이라 타입이 맞으니 에러 없이 조용히 바뀌었다
  • private 기본 생성자 하나만 두고 setter도 없는 클래스는 getter가 1, alpha, C-7을 돌려주었다. 순서를 바꿨는데도 이름대로 맞게 들어갔고, private 생성자로도 객체가 만들어졌다

그래서 표준 글 원칙 6은 MapperResult에 기본 생성자를 두되, 내 코드만 못 부르게 private으로 두라고 정했다.

JPA와 MyBatis가 어떤 방법으로 private·protected 생성자를 부르는지는 이 실험 밖이다. 금지를 걸었을 때 이 둘까지 막히면 쓸 수 없는 수단이라, 풀 수 있는지만 확인한다.

내 코딩 표준을 모아 둔 이 블로그는 표준의 근거를 네 종류로 나눠 종류마다 증명 방법을 정해 두었다. 이 실험은 1번 「기술 사실」이다 — 도구가 실제로 어떻게 동작하는지를 돌려 보고 확인하는 종류다. 이 실험이 받치는 표준은 DTO 생성자 표준이다.


반증 조건 — 네 가지 중 하나라도 걸리면 주장이 틀린 것이다

돌리기 전에 적어 둔 기준이다. 결과를 보고 기준을 정하면 어떤 결과든 주장대로 읽을 수 있어서, 순서를 지켰다.

  • refuted(반증) — 아래 중 하나라도 걸린다
    • 금지 어노테이션 이름(lombok/Setter·lombok/Data·lombok/NoArgsConstructor)이 클래스 파일에 한 번이라도 남는다. 클래스 파일 안에서는 이름의 .이 /로 적힌다
    • ERROR를 걸었는데 컴파일이 성공한다(종료 코드 0)
    • 상위 폴더에 ERROR를 걸었는데 그 아래 폴더의 금지 어노테이션이 통과한다(위 폴더 설정이 아래로 내려오지 않는다는 뜻이다)
    • 하위 폴더에 ALLOW를 걸었는데 예외 두 개가 통과하지 못한다(아래 폴더에서 위의 금지를 풀 수 없다는 뜻이다). 이 두 조건을 재는 폴더 구조는 설계 C에서 보인다
  • supported(지지) — 넷 다 걸리지 않는다
  • inconclusive(판단 불가) — 설계 A의 변형 넷(아래 설계 A에서 보일, 금지 어노테이션을 붙였지만 금지 설정은 걸지 않아 그냥 컴파일되어야 하는 클래스 네 개)이 컴파일되지 않거나, 대조군(바로 아래에서 설명하는 비교용 클래스)이 컴파일되지 않는다. ERROR를 건 폴더가 실패하는 것은 예상한 결과라 여기 들지 않는다

대조군은 「금지 어노테이션을 하나도 쓰지 않은, 표준이 권하는 DTO」다. 세 어노테이션을 전부 ERROR로 걸어도 이건 통과해야 한다. 만약 이것까지 실패하면 lombok.config가 특정 어노테이션만 막는 게 아니라 컴파일 자체를 망가뜨린 것이라서, 주장이 맞았다고 읽으면 안 된다.


설계 A — 클래스 파일에 어노테이션 이름이 남는지 javap로 센다

첫 번째 질문은 「금지 어노테이션이 .class에 남는가」다. 두 방향으로 확인했다.

하나, 어노테이션 자신의 설정을 읽었다. 자바 어노테이션에는 @Retention이라는 설정이 붙어 있고, 이 값이 「어디까지 살아남는가」를 정한다.

  • SOURCE — .java에만 있고 컴파일하면 사라진다
  • CLASS — .class에는 남지만, 프로그램이 실행되는 동안 코드로 꺼내 볼 수는 없다. 다만 .class 파일을 파일로 직접 열어 읽는 도구는 볼 수 있다. ArchUnit이 그런 도구다 — 테스트로 실행되지만, 검사할 클래스를 실행 중에 물어보는 게 아니라 클래스 파일을 열어 읽는다
  • RUNTIME — 실행 중에도 남아서, 실행 중인 코드가 「이 메서드에 이 어노테이션이 붙어 있나?」를 물어볼 수 있다. 예를 들어 테스트 도구 JUnit은 실행 중에 @Test가 붙은 메서드를 찾아 돌린다

어노테이션도 사실 자바 파일로 정의되고 컴파일되어 .class 파일로 존재한다. Lombok은 이런 .class 파일 수백 개를 jar라는 압축 파일 하나로 묶어 배포한다. 이번에 그 jar 안에서 꺼내 읽은 것은 lombok.Setter·lombok.Data·lombok.NoArgsConstructor, 그리고 lombok.Generated까지 네 어노테이션이다. @lombok.Generated는 Lombok이 자기가 만들어 넣은 메서드와 생성자에 스스로 붙이는 표시다. 사람이 쓰는 금지 셋은 사라지더라도, Lombok이 만든 흔적은 클래스 파일에 남는지 견주려고 함께 읽었다. 이 네 어노테이션의 .class를 javap로 열어 이 값을 읽었다. 소스 코드가 아니라 실제로 배포된 파일을 봤다.

둘, 직접 컴파일해서 셌다. 변형 넷을 만들었다.

// retention/FinalData.java
@lombok.Getter
@lombok.Data
public class FinalData {

    private final String productName;
    private final Integer quantity;

    public FinalData(String productName, Integer quantity) {
        this.productName = productName;
        this.quantity = quantity;
    }
}

한 줄씩 보면 이렇다.

  • @lombok.Getter, @lombok.Data — 클래스에 두 어노테이션을 붙였다. @Data가 표준이 금지한 것이다. @Data도 getter를 만드니 @Getter는 없어도 된다. 표준대로 만든 DTO(@Getter + final 필드 + 생성자)에 누가 @Data를 덧붙인 모양을 흉내 내려고 그대로 뒀다
  • private final String productName; — final이 붙은 필드다. final 필드는 한 번 값이 들어가면 바꿀 수 없다
  • public FinalData(String productName, Integer quantity) { ... } — 생성자에서 두 필드에 값을 넣는다. 표준이 권하는 「생성자로만 만든다」 모양이다

나머지 셋도 같은 틀이다.

  • FinalSetter — final 필드에 @Data 대신 @Setter. 클래스 머리에 붙였다
  • MutableSetter — final을 뺀 필드에 @Setter. 이것도 클래스 머리에 붙였다. 이 경우에는 setter가 실제로 생긴다
  • MutableNoArgs — final을 뺀 필드에 @Getter와 @NoArgsConstructor. 직접 쓴 생성자는 없다. 기본 생성자가 실제로 생긴다

final을 뺀 둘을 넣은 이유가 있다. #170은 「누가 final을 빼는 순간 setter가 생긴다」를 걱정했다. 어노테이션 이름은 사라져도 Lombok이 만든 결과물(setter 메서드, 기본 생성자)은 클래스 파일에 남을 것이다. 그게 보이는지도 같이 봐야 ArchUnit에 남은 길이 있는지 알 수 있다.

넷을 컴파일한 뒤 javap -v로 풀어서(-v는 자세히 보여 달라는 옵션이다), 출력에 lombok/Setter·lombok/Data·lombok/NoArgsConstructor라는 글자가 몇 줄에 나오는지 셌다.


설계 B — 폴더마다 lombok.config를 하나씩 두고 javac를 직접 부른다

두 번째 질문은 「ERROR가 정말 컴파일을 멈추는가」다. 실험마다 폴더를 따로 만들고, 그 폴더에 lombok.config와 Probe.java를 하나씩 뒀다. Probe는 「시험해 보는 클래스」라는 뜻으로 붙인 이름이다. @Setter를 막는 폴더는 이렇게 생겼다.

# flagusage/setter-error/lombok.config
lombok.setter.flagUsage = ERROR

첫 줄의 #으로 시작하는 줄은 설명용 메모(주석)라 Lombok이 무시한다. 어느 파일인지 알려 주려고 이 글에서 붙였다. 실제 설정은 두 번째 줄 하나이고, 「@Setter를 쓴 곳이 있으면 에러로 처리하라」는 뜻이다. 설정 파일의 한 줄은 이름 = 값 모양이다. 왼쪽 이름을 키, 오른쪽을 값이라고 부른다. 여기서 키는 lombok.setter.flagUsage이고 값은 ERROR다. 키 가운데의 setter가 어느 어노테이션인지를 가리킨다.

아래는 flagusage/setter-error/Probe.java 파일 내용을 그대로 옮긴 것이다. 줄 번호는 package 줄이 1번이고, @lombok.Setter가 4번 줄이다.

package probe;

@lombok.Getter
@lombok.Setter
public class Probe {
    private String productName;
    private Integer quantity;
}
  • package probe; — 이 클래스가 속한 패키지 이름이다
  • @lombok.Getter, @lombok.Setter — getter와 setter를 만들어 달라는 표시다. 두 번째가 막혀야 한다
  • 필드 두 개에는 final이 없다. 막히지 않으면 setter가 실제로 생기는 모양이다

이런 폴더를 여섯 개 만들었다.

폴더 lombok.config 쓴 어노테이션 판정에 쓰나
setter-error setter 키 ERROR @Setter 쓴다
data-error data 키 ERROR @Data 쓴다
noargs-error noArgsConstructor 키 ERROR @NoArgsConstructor 쓴다
three-error-unused 세 키 모두 ERROR 없음 (대조군) 대조군
setter-warning setter 키 WARNING @Setter 기록만
data-under-setter-error setter 키만 ERROR @Data 기록만

마지막 둘은 판정과 상관없이 궁금해서 넣었다. WARNING으로도 막히는지, 그리고 @Data는 setter를 품고 있는데 setter 키 하나로도 걸리는지다.

컴파일은 Gradle 같은 빌드 도구(여러 파일의 컴파일과 필요한 외부 코드 내려받기를 대신 해 주는 프로그램)를 거치지 않고 javac를 직접 불렀다. 빌드 도구의 설정이 결과에 끼어들지 않게 하려는 것이다. 명령은 이렇다.

javac -cp lombok-1.18.48.jar -processorpath lombok-1.18.48.jar -d <출력 폴더> Probe.java
  • -cp lombok-1.18.48.jar — 컴파일하는 코드가 @lombok.Setter 같은 다른 클래스 이름을 쓸 때, 그 클래스 파일을 찾아볼 jar를 알려 준다. 이것이 없으면 javac는 lombok.Setter가 무엇인지 몰라 에러를 낸다
  • -processorpath lombok-1.18.48.jar — javac에는 컴파일 도중에 끼어들어 어노테이션을 읽고 코드를 더하는 프로그램(annotation processor)을 넣는 자리가 있다. 이 옵션으로 Lombok을 그 자리에 넣는다. Lombok이 getter·setter를 만들고 lombok.config를 읽는 것이 이때다
  • -d <출력 폴더> — .class 파일을 쓸 폴더다. <출력 폴더>처럼 꺾쇠로 감싼 자리는 실제 폴더 이름으로 바꿔 넣는 자리라는 표시다

실험 폴더(proofs/lombok-forbidden-annotation-enforcement/app/)는 아래와 같이 생겼다. 그림의 ├──·└──는 「바로 위 폴더 안에 든 것」을, │는 위아래 줄이 같은 폴더 안이라는 것을 나타내는 선이다. 각 줄 오른쪽에 띄어 쓴 글은 파일 이름이 아니라 그 파일·폴더에 무엇이 들었는지 내가 붙인 설명이다. 이 글의 폴더 그림은 모두 이 방식으로 읽는다.

app/
├── lombok.config   config.stopBubbling = true (그 위 줄에 주석 한 줄이 있다)
├── retention/      설계 A 의 변형 넷
├── flagusage/      설계 B 의 폴더 여섯 (각자 lombok.config 를 가진다)
└── override/       설계 C (아래에서 설명한다)

Lombok은 소스 파일이 있는 폴더에서 시작해 위 폴더로 올라가며 lombok.config를 모은다. 맨 위 app/lombok.config의 config.stopBubbling = true는 「여기서 더 위로 올라가지 마라」는 뜻이다. 이 줄이 없으면 Lombok은 app/보다 위 폴더까지 올라가며 찾는다. 이 파일의 첫 줄 주석(# 이 실험 트리 밖의 lombok.config 를 읽지 않게 한다)대로, 실험 폴더 밖에 놓인 설정이 결과에 섞이지 않게 막아 둔 줄이다. 이 파일은 금지나 허용을 하나도 적지 않아서, 아래 폴더들의 설정(flagusage/의 각 폴더, 설계 C의 override/)에는 영향을 주지 않는다.


설계 C — 위 폴더의 금지를 아래 폴더에서 풀 수 있는지 본다

세 번째 질문은 「예외 둘을 비켜 갈 수 있는가」다. 폴더를 이렇게 쌓았다.

override/
├── lombok.config          세 키 모두 ERROR
├── dto/Forbidden.java     @NoArgsConstructor 를 씀 — 막혀야 한다
├── mapper/
│   ├── lombok.config      lombok.noArgsConstructor.flagUsage = ALLOW
│   └── MapperResult.java  @NoArgsConstructor(access = AccessLevel.PRIVATE)
└── entity/
    ├── lombok.config      같은 ALLOW
    └── Order.java         @NoArgsConstructor(access = AccessLevel.PROTECTED)
  • override/lombok.config — 맨 위에서 세 어노테이션을 모두 금지한다
  • dto/ — 아래에 설정 파일이 없다. 위의 금지가 내려오는지 보는 자리다
  • mapper/, entity/ — 아래에 ALLOW를 둬서 @NoArgsConstructor만 다시 허용한다. 위의 금지를 덮어쓰는지 보는 자리다

mapper/MapperResult.java는 이렇게 생겼다.

package mapper;

import lombok.AccessLevel;
import lombok.NoArgsConstructor;

@lombok.Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class MapperResult {
    private String productName;
    private Integer quantity;
}
  • import 두 줄 — 아래에서 NoArgsConstructor와 AccessLevel을 패키지 이름 없이 쓰려고 불러온다. AccessLevel은 Lombok이 접근 범위(private·protected 등)를 고르라고 미리 정해 둔 값 목록(enum)이다
  • @NoArgsConstructor(access = AccessLevel.PRIVATE) — 어노테이션 뒤 괄호 안은 그 어노테이션에 설정값을 넘기는 자리다(이름 = 값). 여기서는 인자 없는 생성자를 만들되 private으로 만들라는 뜻이다. 클래스 밖의 코드는 new MapperResult()를 부를 수 없다. 표준이 MapperResult에만 허용한 첫 번째 예외다
  • 필드에 final이 없고 setter도 없다. 그럼 값은 어떻게 들어가나? 앞에서 말한 대로 MyBatis가 이 생성자로 빈 객체를 만든 뒤 필드 이름을 보고 private 필드에 값을 직접 넣는다. 내 코드는 getter로 읽기만 한다. 이 실험은 MyBatis를 돌리지 않고 컴파일만 한다

entity/Order.java는 같은 모양에 AccessLevel.PROTECTED만 다르다. protected는 같은 패키지와 이 클래스를 상속한 클래스에서만 부를 수 있다는 뜻이다. 앞에서 본 대로 JPA가 기본 생성자를 요구해서 두는 모양이고, 표준이 허용한 두 번째 예외다.


결과 A — 금지 어노테이션 이름은 클래스 파일에 한 번도 남지 않았다

run.sh(실험 전체를 처음부터 돌리는 명령들을 적어 둔 파일)가 결과 파일 result.md에 쓴 판정과 요약이다. 손대지 않고 옮긴다.

판정: supported
요약: Lombok 1.18.48 — @Retention 은 @Setter=SOURCE·@Data=SOURCE·@NoArgsConstructor=SOURCE 이고 클래스 파일 잔존 0(final 필드에서 @Data·@Setter 가 생성한 setter 0개) · @lombok.Generated=CLASS · flagUsage=ERROR 컴파일 실패 3/3 · WARNING 은 통과(경고 1·setter 2 개 생성) · setter 키만 ERROR 면 @Data 통과(setter 2 개 생성) · 하위 폴더 ALLOW 로 private·protected 예외 되돌림 2/2

실험은 Docker 컨테이너 안에서 돌렸다. 컨테이너는 내 컴퓨터 안에 따로 떼어 만든 작은 실행 환경으로, 누가 돌려도 같은 JDK와 도구가 깔린 상태에서 시작한다. 쓴 이미지(컨테이너의 설계도) 이름이 gradle:8.14.3-jdk21인 것은 JDK 21과 Gradle이 함께 깔려 있어서다. Gradle은 Lombok jar를 인터넷에서 내려받는 데만 썼고, 컴파일은 위의 javac 명령으로 했다. 요약의 말을 풀면 이렇다. 「잔존 0」은 클래스 파일에 남은 것이 0개라는 뜻이다. 「3/3」은 세 폴더 중 세 폴더가 실패했다는 뜻이고, 「되돌림 2/2」는 위에서 금지한 것을 아래 폴더에서 다시 허용한 두 곳이 둘 다 통과했다는 뜻이다. 나머지는 아래 결과 A·B·C에서 하나씩 풀고, 「WARNING 은 통과」와 「setter 키만 ERROR 면 @Data 통과」 두 항목은 「예상과 달랐던 점」에서 푼다.

환경은 이 컨테이너, Lombok 1.18.48, javac 21.0.9다. 반복은 1회다. 같은 소스를 같은 버전으로 컴파일하면 결과가 바뀌지 않아서 한 번이면 된다고 봤다.

A 부분만 풀면 이렇다.

어노테이션 @Retention
@Setter SOURCE
@Data SOURCE
@NoArgsConstructor SOURCE
@lombok.Generated CLASS

금지 셋은 모두 SOURCE다. 컴파일하는 순간 사라진다는 뜻이다. 변형 넷을 실제로 컴파일해 셌을 때도 금지 어노테이션 이름은 네 파일 모두 0번 나왔다.

그래서 「이 클래스에 @Setter가 붙어 있다」를 ArchUnit으로 잡는 길은 없다. ArchUnit이 읽는 .class에는 그 글자가 없다.

대신 표에 없던 이름 하나가 남았다. @lombok.Generated다. Lombok이 자기가 만든 메서드와 생성자마다 붙이는 표시이고, CLASS라서 .class에 남는다. 그리고 Lombok이 만든 결과물도 남았다. javap -p로 멤버 목록을 뽑았을 때 나온 줄 가운데, 이 이야기에 필요한 setter와 생성자 줄만 골라 옮기면 이렇다(필드와 getter 줄은 뺐다). 앞의 예시 코드에서는 줄였지만 변형 넷은 모두 첫 줄이 package proof;라서 이름 앞에 proof.가 붙는다(-p는 private 멤버까지 모두 보여 달라는 옵션이다. java.lang.String은 String의 패키지까지 붙인 이름이다. javap는 매개변수의 이름은 빼고 타입만 보여 준다. setProductName(java.lang.String)은 소스에서 setProductName(String productName)이다).

MutableSetter  public void setProductName(java.lang.String);
MutableSetter  public void setQuantity(java.lang.Integer);
MutableNoArgs  public proof.MutableNoArgs();

줄 맨 앞의 MutableSetter·MutableNoArgs는 어느 클래스의 줄인지 알아보라고 내가 붙인 이름표이고, 그 뒤가 javap의 출력이다. javap는 메서드의 몸통({ } 안의 코드)은 빼고 머리만 보여 주기 때문에 줄이 ;로 끝난다.

  • final이 있는 FinalData·FinalSetter에서는 setter가 0개였다. 앞선 실험과 같은 결과다
  • final을 뺀 MutableSetter에는 setter 메서드 둘이 실제로 있다
  • final을 뺀 MutableNoArgs에는 인자 없는 생성자가 실제로 있다. 그런데 생성자를 하나도 직접 쓰지 않은 클래스에는 javac도 인자 없는 생성자를 알아서 넣는다. 결과는 생성자 하나(아래 기록 줄의 noargs=1)였다. javac는 생성자가 하나도 없는 클래스에만 기본 생성자를 넣으므로, Lombok이 넣은 생성자를 보고 따로 넣지 않은 것이다. 그래서 「인자 없는 생성자가 있다」만으로는 Lombok이 만든 것인지 javac가 넣은 것인지 가를 수 없다. 갈라 줄 수 있는 것은 @lombok.Generated의 개수다. result.md에서 두 클래스를 견주면 이렇다
PROBE part=A name=MutableNoArgs exit=0 errors=0 warnings=0 srcann=0 genann=3 setters=0 noargs=1
PROBE part=B name=setter-warning exit=0 errors=0 warnings=1 srcann=0 genann=4 setters=2 noargs=1

원본 줄을 그대로 옮겼다. PROBE는 클래스 하나를 컴파일하고 잰 결과 줄이라는 표시이고, part=A·part=B는 그 클래스가 설계 A·B 중 어디에 속하는지다. name은 설계 A에서는 클래스 이름이고, 설계 B에서는 폴더 이름이다 — 설계 B의 클래스는 폴더마다 이름이 모두 Probe라서 폴더로 구별했다. exit는 종료 코드, errors·warnings는 에러와 경고의 개수, setters는 클래스 파일에 생긴 setter 메서드의 개수다. srcann은 클래스 파일에 금지 셋(@Setter·@Data·@NoArgsConstructor)의 이름이 남은 줄 수다. 둘 다 0이라 이름은 남지 않았다.

genann은 클래스 파일에 붙은 @lombok.Generated의 개수, noargs는 인자 없는 생성자의 개수다. MutableNoArgs는 Lombok이 만든 멤버가 getter 둘과 생성자 하나로 셋이고, genann도 3이다. setter-warning 폴더의 Probe는 설계 B에서 본 setter-error의 Probe와 코드가 글자 하나까지 같다(다른 것은 lombok.config뿐이다). @NoArgsConstructor 없이 @Getter·@Setter만 붙였고 직접 쓴 생성자도 없으므로, 인자 없는 생성자는 javac가 넣은 것이다. Lombok이 만든 멤버는 getter 둘과 setter 둘로 넷이고, genann도 4다. 개수로 보면 Lombok이 만든 생성자에는 @lombok.Generated가 붙고, javac가 넣은 생성자에는 붙지 않았다. 다만 run.sh는 표시가 몇 개인지만 셌고 각 표시가 어느 멤버에 붙었는지는 기록하지 않았다. 그래서 이건 개수에서 끌어낸 추론이다

그러니 ArchUnit으로 「@Setter를 쓰지 마라」는 검사할 수 없지만, 「DTO에 setter 메서드가 있으면 안 된다」는 검사할 수 있을 것이다. ArchUnit에서 검사할 클래스는 이름이나 패키지로 고른다. 계층별 DTO 이름 표준이 DTO 이름 끝을 Request·Response·Command처럼 정해 두었으니, 「이름이 이런 말로 끝나는 클래스」를 DTO로 고르면 된다. 규칙의 모양은 「이름이 Request로 끝나는 클래스는 set으로 시작하는 메서드를 가지면 안 된다」 같은 문장을 테스트 코드로 옮긴 것이다. 이 실험은 그 코드를 쓰지도 돌리지도 않았다. 「DTO에 기본 생성자가 있으면 안 된다」도 검사할 수 있을 것이다. 생성자를 직접 쓰지 않은 DTO에는 javac가 기본 생성자를 넣으니 그런 DTO도 걸리는데, 그것도 잡는 게 맞다. 표준이 DTO를 값을 받는 생성자로만 만들라고 하니, 누가 넣었든 기본 생성자가 있으면 위반이다. 그러니 Lombok이 만든 것인지 가를 필요가 없고, 예외인 MapperResult와 Entity만 이름이나 패키지로 빼면 된다. 앞의 setter 검사 쪽이 #170이 걱정한 「final을 빼는 순간 setter가 생긴다」를 정확히 잡는 모양이다. 다만 ArchUnit을 실제로 돌리지는 않았다. 클래스 파일에 메서드가 남는다는 것까지만 쟀다.


결과 B — ERROR는 세 어노테이션 모두 컴파일을 멈췄다

ERROR를 건 세 폴더는 모두 종료 코드 1로 실패했다. javac가 남긴 에러 문장이다.

flagusage/setter-error/Probe.java:4: error: Use of @Setter is flagged according to lombok configuration.
flagusage/data-error/Probe.java:3: error: Use of @Data is flagged according to lombok configuration.
flagusage/noargs-error/Probe.java:4: error: Use of @NoArgsConstructor is flagged according to lombok configuration.

「lombok 설정에 따라 @Setter 사용이 금지로 표시됐다」는 뜻이다. 파일 이름 뒤의 숫자는 어노테이션이 적힌 줄 번호다. setter-error의 4번 줄은 설계 B에서 보인 코드의 @lombok.Setter다. data-error는 @lombok.Getter 없이 @lombok.Data만 붙여서 3번 줄이다. noargs-error는 3번 줄에 @lombok.Getter, 4번 줄에 @lombok.NoArgsConstructor를 붙였다.

대조군 three-error-unused는 세 키를 모두 ERROR로 걸었는데도 종료 코드 0, 경고 0으로 통과했다. lombok.config는 컴파일 전체를 막는 게 아니라 적어 둔 어노테이션만 막는다.


결과 C — 하위 폴더의 ALLOW로 예외 두 개가 풀렸다

폴더 아래에 둔 lombok.config 결과
dto/ 없음 종료 코드 1 — 위의 ERROR가 내려왔다
mapper/ noArgsConstructor 키 ALLOW 종료 코드 0, private mapper.MapperResult(); 생성(javap -p로 확인)
entity/ 같은 ALLOW 종료 코드 0, protected entity.Order(); 생성

dto/의 에러 문장은 결과 B와 같은 모양이다(Use of @NoArgsConstructor is flagged ...). 위 폴더의 설정이 아래 폴더까지 적용됐다.

mapper/와 entity/는 통과했고, 만들어진 생성자의 접근 범위도 요청한 대로 private과 protected였다. 예외 두 개를 폴더로 비켜 갈 수 있다.

네 가지 반증 조건 중 어느 것도 걸리지 않았고, 대조군과 설계 A의 변형 넷이 모두 정상으로 컴파일됐다. 판정은 supported다.


예상과 달랐던 점 — setter 키 하나로는 @Data가 빠져나간다

판정과 상관없이 기록만 하려고 넣은 두 폴더에서 주의할 결과가 나왔다.

하나, WARNING은 막는 수단이 아니다. setter-warning은 경고 1개를 내고 종료 코드 0으로 통과했다. 이 경고는 앞에서 본 final 필드 경고가 아니라 lombok.config의 WARNING 설정이 낸 것이다 — Probe.java:4: warning: Use of @Setter is flagged according to lombok configuration.(설정에 따라 @Setter 사용이 표시됐다) 그리고 setter 2개가 그대로 만들어졌다. 경고는 화면에 한 줄 뜨고 끝이다. 강제하려면 ERROR여야 한다.

둘, @Data는 setter를 만들지만 setter 키로는 안 걸린다. data-under-setter-error 폴더는 lombok.setter.flagUsage = ERROR만 걸고 @Data를 썼다.

@lombok.Data
public class Probe {
    private String productName;
    private Integer quantity;
}
  • @Data 하나만 붙였다. @Data는 getter·setter 등을 한꺼번에 만든다
  • 필드에 final이 없으니 setter가 생길 수 있는 모양이다

나는 @Data가 속으로 @Setter를 쓰니까 setter 키에 걸릴 거라고 예상했다. 결과는 종료 코드 0으로 통과했고 setter 2개가 생겼다. 적어도 @Data는 setter 키에 걸리지 않았다. 그래서 금지 목록을 닫으려면 세 키를 하나도 빠짐없이 적어야 한다. 하나를 빠뜨리면 그 구멍으로 setter가 생긴다.


이 결과가 이슈 #170에 남기는 것 — 후보 하나는 탈락했고 무엇으로 강제할지는 아직 정하지 않았다

#170이 든 후보는 셋이었다. 이 실험으로 둘의 사실이 정해졌다. ArchUnit은 무엇을 찾느냐에 따라 답이 갈려서 표에서 세 줄로 나눴다.

후보 이 실험이 확인한 것
ArchUnit으로 어노테이션을 찾는다 안 된다. 어노테이션 이름이 클래스 파일에 없다
ArchUnit으로 setter를 찾는다 될 것이다. setter 메서드는 클래스 파일에 남는다. 돌려 보지는 않았다
ArchUnit으로 기본 생성자를 찾는다 될 것이다. 생성자는 클래스 파일에 남는다. javac가 넣은 것도 걸리지만 DTO에서는 그것도 위반이다. 예외 둘은 이름이나 패키지로 뺀다. 돌려 보지는 않았다
lombok.config의 flagUsage = ERROR 된다. 세 키를 다 적어야 하고, 예외는 하위 폴더 ALLOW로 푼다
정적 검사(.java 파일의 글자를 직접 읽는 방법) 이 실험에서 재지 않았다. 앞에서 말한 대로 사실이 아니라 선택의 문제다

어느 수단을 표준으로 삼을지, 배포본(이 표준을 다른 프로젝트에 설치해 쓰도록 내보내는 규칙 파일 묶음)의 어느 자리에 실을지는 이 실험이 정하는 것이 아니다. #170에서 정한다. 그래서 DTO 생성자 표준 본문에는 아직 이 결과를 인용하지 않았다. 이 블로그는 실험으로 받쳐진 원칙 첫머리에 > 실측(supported): <요약> — [증명](링크) 모양의 줄을 하나 달아 둔다. 줄 맨 앞의 >는 블로그 화면에서 그 줄을 인용 상자로 보여 주라는 표시이고, [증명](링크)는 「증명」이라는 글자를 눌렀을 때 링크한 글로 가라는 표시다. 금지 목록과 「무엇으로 막을지는 아직 안 정했다」를 적은 원칙(원칙 3)에 어떤 수단을 붙일지 정해지면 그 자리에 이 줄을 단다.


정리

  • 금지한 Lombok 어노테이션 셋은 클래스 파일에 남지 않는다. 전부 SOURCE라서, 클래스 파일을 읽는 ArchUnit으로는 어노테이션 자체를 찾을 수 없다
  • Lombok이 만든 결과물은 남는다. final을 빼면 생기는 setter는 클래스 파일에 보인다. 기본 생성자도 보인다. javac가 넣은 것과 겉모양은 같지만, DTO에서는 둘 다 위반이라 가를 필요가 없다
  • lombok.config의 flagUsage = ERROR는 세 어노테이션 모두 컴파일을 멈췄다. 표준대로 쓴 DTO는 그대로 통과했다
  • WARNING은 막지 않고, 키를 하나라도 빠뜨리면 @Data처럼 새어 나간다
  • 예외는 하위 폴더의 ALLOW로 풀린다. MapperResult의 private, Entity의 protected 기본 생성자 둘 다 통과했다. 표준대로 Entity 생성자를 손으로 쓰면 Entity 쪽은 풀 필요조차 없다

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

댓글남기기