Backend 15
Lombok으로 Entity 반복 코드 줄이기
Lombok이 코드를 안 보이게 숨기는 것 같아서 Entity와 충돌하지 않는지 불안하다.
근거 · 교안 p94-p99
1장: Lombok, 코드를 숨기는 마법이 아닌 이유
솔라는 모니터 옆에 붙여 둔 포스트잇을 노려봤다. 깔끔하게 정리된 필기 한가운데, 유독 한 문장이 눈에 걸렸다.
'Lombok: Java의 반복적인 Getter/Setter, 생성자 코드를 어노테이션으로 대체한다.'
‘대체한다’는 단어가 자꾸만 마음을 불편하게 했다. 마치 마술사가 손수건으로 동전을 가렸다가 짠, 하고 사라지게 만드는 장면 같았다. 솔라의 화면에는 직접 작성한 Book 엔티티 코드가 떠 있었다. 수십 줄에 걸쳐 필드마다 getId(), setId(), getTitle(), setTitle()… 끝없이 이어지는 반복적인 코드들. 이걸 어노테이션 몇 개로 바꿀 수 있다는 건 분명 매력적이었다. 하지만 데이터베이스와 직접 맞닿는 중요한 Entity 클래스에서 코드가 ‘보이지 않게’ 되는 건 어쩐지 불안했다.
“언니.”
솔라는 옆자리에서 코드를 보고 있던 루나를 불렀다.
“이것 좀 봐봐. Lombok이 코드를 ‘대체’한다고 하잖아. 그럼 원래 있어야 할 코드들은 다 어디로 가는 거야? 그냥 눈에만 안 보이는 건가? 특히 JPA 같은 프레임워크는 Entity에 기본 생성자가 꼭 있어야 하는 것처럼 특정 규칙을 요구하잖아. 그런데 Lombok이 그걸 그냥 마음대로 숨겨버리면, 실행할 때 뭔가 충돌이 생기지 않을까?”
솔라의 목소리에는 걱정이 가득했다. 보이지 않는 곳에서 예기치 못한 오류가 터져 나올 것 같은 막연한 불안감이었다.
루나는 솔라의 화면을 잠시 들여다보더니, 고개를 끄덕였다. 그리고는 솔라가 보고 있던 Book 클래스 파일을 복사해 BookManual.java라는 이름으로 저장했다.
“네가 걱정하는 게 뭔지 알 것 같아. ‘대체’라는 말이 꼭 마법처럼 코드를 사라지게 만든다고 느껴질 수 있지. 그럼 직접 확인해보면 어떨까? 사라지는 건지, 아니면 우리가 보지 못하는 곳에 다른 형태로 존재하는 건지.”
루나는 이어서 비어 있는 새 파일을 하나 만들고 BookLombok.java라고 이름 붙였다. 그리고는 BookManual.java에서 필드 선언 부분만 복사해 붙여 넣었다.
// BookLombok.java
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String author;
}
“여기는 어노테이션만 붙여두고, 수십 줄이 넘던 getter나 생성자 코드는 모두 지워보자.”
솔라는 루나의 말대로 코드를 수정했다. 몇십 줄이 넘던 BookManual.java 파일과 달리, BookLombok.java 파일은 열 줄 남짓으로 믿을 수 없을 만큼 간결해졌다.
“와… 정말 깔끔해지긴 했는데, 이래서 더 불안한 거야. 이 짧은 코드가 어떻게 저 긴 코드를 전부 대신한다는 건지 도무지 상상이 안 가.”
“우리가 애플리케이션을 실행할 때 필요한 건 .java 소스 코드가 아니라, 그걸 컴파일한 .class 바이트코드 파일이잖아. 그 결과물을 직접 비교해보는 거지.”
루나의 말에 솔라는 프로젝트 폴더를 열었다. 그리고 컴파일된 클래스 파일들이 모여있는 build/classes/java/main/... 디렉토리로 이동했다. 그곳에는 BookManual.class와 BookLombok.class 파일이 나란히 놓여 있었다. 솔라는 두 파일의 크기를 확인하고는 눈을 동그랗게 떴다.
“어? 이상하다. 소스 코드 길이는 수십 배 차이 났는데… 클래스 파일 크기는 거의 똑같아.”
“이제 마지막 단계야. 그 BookLombok.class 파일의 속을 들여다보자.”
루나는 IDE의 기능을 이용하거나, 터미널에 javap 명령어를 입력해 BookLombok.class 파일의 바이트코드를 사람이 읽을 수 있는 형태로 보여주었다. 솔라의 눈앞에 익숙한 메서드 이름들이 나타나기 시작했다.
...
public com.example.Book();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public java.lang.Long getId();
Code:
0: aload_0
1: getfield #2 // Field id:Ljava/lang/Long;
4: areturn
public java.lang.String getTitle();
Code:
0: aload_0
1: getfield #3 // Field title:Ljava/lang/String;
4: areturn
...
솔라는 화면에 출력된 내용을 한 줄 한 줄 짚어가며 읽었다. 자신이 BookLombok.java 파일에서 분명히 지웠던 기본 생성자와 getId(), getTitle() 같은 메서드들이 버젓이 존재하고 있었다. 사라진 것이 아니었다.
“아…! 이제 알겠다! Lombok은 런타임에 코드를 마법처럼 숨기는 게 아니었어. 우리가 코드를 저장하고 컴파일하는 시점에, 어노테이션을 보고 필요한 코드들을 만들어서 .class 파일 안에 직접 넣어주는 거였구나.”
솔라의 얼굴에 번졌던 불안감이 안도감으로 바뀌었다. 보이지 않는다는 막연한 두려움은, ‘컴파일 시점 코드 생성’이라는 명확한 원리를 이해하자 눈 녹듯 사라졌다. 자바 가상 머신(JVM)이 실행하는 .class 파일에는 모든 코드가 정상적으로 포함되어 있으니, 런타임에 갑자기 문제가 생길 리 없었다. ‘대체한다’는 말의 진짜 의미를 이제야 깨달은 것이다.
안도의 한숨을 내쉰 솔라의 머릿속에 새로운 질문이 떠올랐다.
“그럼 Lombok이 컴파일할 때 코드를 자동으로 만들어준다는 건 알겠어. 그런데… 그냥 기계적으로 Getter, Setter만 만드는 거라면 괜찮지만, JPA Entity는 지켜야 할 규칙이 좀 더 까다롭잖아. 예를 들어, 기본 생성자의 접근 제어자는 public이나 protected여야 한다는 규칙 같은 거. Lombok이 그런 Entity의 세세한 규칙까지 다 알고 제대로 된 코드를 만들어주는 건지, 그건 아직 확신이 안 드네.”
2장: Entity 필수 요소를 지키는 Lombok의 컴파일 기술
솔라의 시선이 BookLombok.java 파일의 한 줄에 꽂혔다.
@NoArgsConstructor(access = AccessLevel.PROTECTED)
이전의 막연한 불안감은 사라졌지만, 그 자리에 더 구체적이고 날카로운 의심이 자리 잡았다. 컴파일 시점에 코드가 생성된다는 사실은 이제 받아들일 수 있었다. 하지만 컴파일러가 어떻게 JPA의 까다로운 규칙까지 맞춰준다는 말인가? 마치 누군가 ‘자동차를 만들어 드립니다’라고 해놓고, 내가 원하는 특정 모델의 세세한 규격까지 알아서 맞춰줄 거라고 기대하는 것처럼 막연하게 느껴졌다.
솔라는 화면을 손가락으로 가리키며 옆자리의 루나에게 물었다.
“언니, 지난번에 .class 파일을 열어봤을 때 생성자 코드가 있는 건 확인했어. 그런데 JPA Entity는 그냥 기본 생성자가 있기만 하면 되는 게 아니잖아. 프록시 같은 기술 때문에 외부에서는 함부로 객체를 만들지 못하도록 접근 제어자를 protected로 설정해야 한다고 배웠어. Lombok이 @NoArgsConstructor 어노테이션 하나로 그런 세부 규칙까지 알아서 지켜준다는 게 신기하면서도 믿기지가 않아. 저 access = AccessLevel.PROTECTED라는 부분이 그냥 주석 같은 장식은 아닐까?”
루나는 솔라의 질문에 말없이 고개를 끄덕였다. 그리고는 이전처럼 터미널 창을 열었다. 하지만 이번에는 단순히 .class 파일의 존재나 크기를 확인하지 않았다.
“네가 직접 만든 BookManual.java에는 생성자를 어떻게 썼지?”
“이렇게 썼지.”
솔라가 BookManual.java 파일을 열어 해당 부분을 보여주었다.
protected Book() {
}
“그래. 그럼 Lombok이 만든 클래스 파일에는 이 코드가 어떻게 구현되어 있는지 다시 확인해 보자. 이번에는 그냥 ‘존재’ 여부가 아니라, ‘어떻게’ 만들어졌는지에 집중해서 보는 거야.”
루나는 지난번과 같은 javap 명령어를 입력했지만, 이번에는 솔라에게 특정 부분을 찾아보라고 했다.
javap -c build/classes/java/main/com/example/BookLombok.class
솔라는 익숙한 바이트코드 출력 결과 속에서 생성자(constructor) 부분을 샅샅이 훑었다. 잠시 후, 솔라의 눈이 한 줄에 멈췄다.
...
protected com.example.BookLombok();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
...
“어…! 여기 protected라고 명시되어 있어!”
솔라는 소스 코드에 직접 작성한 protected Book() { }와, Lombok이 어노테이션을 보고 만들어준 바이트코드의 결과물이 사실상 동일하다는 것을 제 눈으로 확인했다. @NoArgsConstructor(access = AccessLevel.PROTECTED)는 단순한 장식이 아니라, 생성될 코드의 접근 제어자까지 명시하는 ‘정밀한 설계도’였던 것이다.
“어떻게 이런 게 가능해? Lombok이 그냥 텍스트를 복사해서 붙여 넣는 수준이 아니었네.”
“맞아. Lombok은 컴파일 과정에 더 깊이 관여하거든.”
루나는 책상 위 메모지에 간단한 그림을 그리기 시작했다. 네모난 상자를 그리고 Book.java라고 썼다. 그리고 그 상자에서 여러 개의 선이 뻗어 나가는 나무 모양의 구조를 그렸다.
“자바 컴파일러는 우리가 쓴 .java 코드를 바로 기계가 읽는 바이트코드로 바꾸지 않아. 먼저 코드의 구조를 분석해서 이런 ‘추상 구문 트리(AST, Abstract Syntax Tree)‘라는 설계도를 만들어. 클래스가 무엇인지, 그 안에 어떤 필드와 메서드가 있는지, 관계는 어떤지 전부 파악하는 거지.”
루나는 나무 그림의 ‘Book’이라는 줄기 옆에 @NoArgsConstructor라는 스티커를 붙이는 시늉을 했다.
“Lombok은 바로 이 단계에 참여해. 컴파일러가 만든 코드 설계도를 훑어보다가 @NoArgsConstructor 같은 어노테이션을 발견하면, 설계도에 직접 새로운 요소를 그려 넣는 거야. ‘아, 이 Book 클래스 설계도에는 기본 생성자가 필요하구나. 그리고 접근 제어자는 protected로 설정하라고 되어 있네.’ 하면서 설계도 자체를 수정하는 거지. 그리고 컴파일러는 Lombok이 수정한 최종 설계도를 바탕으로 .class 파일을 생성해.”
솔라는 이제야 모든 조각이 맞춰지는 것을 느꼈다. Lombok은 런타임 마법도, 단순한 코드 복사기도 아니었다. 컴파일 과정의 핵심인 ‘코드 구조’를 이해하고, 어노테이션이라는 명확한 지시사항에 따라 그 구조를 안전하게 변경하는 ‘컴파일 시점의 건축가’였던 것이다. 이것이 Entity의 본질적인 의미나 동작을 변경할 수 있다는 우려를 잠재워 주었다. 변경하는 것이 아니라, 정해진 규칙에 따라 ‘만들어주는’ 것이었다.
“이제 알겠다. Lombok은 그냥 반복적인 코드를 대신 타이핑해주는 비서가 아니라, 코드의 설계도를 직접 다룰 줄 아는 전문가였구나. 어노테이션으로 정확하게 지시만 내리면, Entity가 필요로 하는 어떤 까다로운 규칙이라도 정확하게 만들어낼 수 있겠네.”
솔라는 간결해진 BookLombok.java 코드를 다시 바라보았다. 이제 더 이상 불안하지 않았다. 오히려 잘 짜인 설계도처럼 느껴졌다. 하지만 이내 새로운 질문이 고개를 들었다.
“좋아, Lombok이 설계도대로 부품(메서드, 생성자)을 정확하게 만들어준다는 건 알겠어. 그런데… 그 부품들로 조립된 최종 결과물, 즉 이 Entity 객체가 실제로 JPA와 함께 동작할 때도, 내가 손으로 한 땀 한 땀 만든 객체와 완벽하게 똑같이 움직일 거라고 보장할 수 있을까? 구조적으로는 동일해 보여도, 실제 데이터베이스에 저장되고 조회되는 과정에서 미묘한 차이가 생기지는 않을까?“
3장: Lombok Entity, 핵심은 보존하고 간결함은 더하다
솔라의 책상 위, 두 개의 찻잔이 나란히 놓여 있었다. 하나는 솔라가 직접 손으로 빚어 만든 투박한 멋이 있는 잔, 다른 하나는 정교한 틀로 찍어낸 매끈한 잔이었다. 모양은 거의 같았지만, 만드는 방식은 전혀 달랐다. 솔라의 시선은 찻잔이 아닌, 모니터에 떠 있는 두 개의 테스트 코드 파일에 고정되어 있었다.
BookManualTest.java와 BookLombokTest.java.
두 파일의 내용은 놀랍도록 똑같았다. 새로운 책 객체를 만들고, JPA 리포지토리를 이용해 데이터베이스에 저장한 뒤, 다시 ID로 조회해서 원래 값과 같은지 확인하는 간단한 테스트. 유일한 차이점은 한쪽은 손으로 모든 코드를 작성한 BookManual 클래스를, 다른 한쪽은 Lombok 어노테이션을 사용한 BookLombok 클래스를 사용한다는 것뿐이었다. 마치 책상 위의 두 찻잔처럼.
이전의 대화 끝에 남았던 솔라의 마지막 질문—“부품(메서드)이 완벽해도, 조립된 최종 결과물(Entity 객체)이 실제로 똑같이 동작할까?”—에 대한 답을 찾기 위해 루나가 준비해 둔 실험대였다.
솔라는 마우스를 쥔 채 망설였다.
“언니, 솔직히 아직도 조금은 의심스러워. 건축가가 설계도대로 완벽하게 부품을 만들었다고 해도, 막상 그 부품들로 조립한 자동차가 정말 도로에서 문제없이 달릴지는 다른 문제잖아. 엔진에서 미세한 소음이 난다거나, 핸들이 약간 쏠린다거나… JPA가 이 Lombok으로 만든 Entity를 다룰 때, 우리가 눈치채지 못하는 미묘한 차이를 만들어내지는 않을까?”
솔라의 불안은 더 이상 ‘보이지 않는 코드’에 대한 막연한 공포가 아니었다. 런타임 환경에서 다른 프레임워크와 상호작용할 때 발생할지도 모를 ‘동작의 동등성’에 대한 구체적인 의문이었다.
루나는 말없이 BookManualTest.java 파일의 실행 버튼을 가리켰다. 솔라는 먼저 수동으로 만든 클래스에 대한 테스트를 실행했다. 잠시 후, 화면 하단에 초록색 막대가 나타나며 ‘TEST PASSED’라는 문구가 떴다. 콘솔 창에는 JPA가 실행한 INSERT와 SELECT 쿼리가 선명하게 찍혔다. 모든 것이 예상대로였다.
“좋아, 이건 당연히 통과해야지. 내가 한 땀 한 땀 만든 거니까.”
이제 남은 것은 BookLombokTest.java였다. 솔라는 심호흡을 하고 실행 버튼을 눌렀다. 이전 테스트보다 더 길게 느껴지는 몇 초의 시간이 흘렀다. 이윽고, 아까와 똑같은 초록색 막대와 ‘TEST PASSED’ 문구가 나타났다. 솔라는 재빨리 콘솔 로그를 위아래로 스크롤하며 BookManualTest의 결과와 비교했다.
“어…?”
솔라의 입에서 나지막한 탄성이 터져 나왔다.
-- BookManualTest 실행 로그
Hibernate: insert into book (author, title) values (?, ?)
Hibernate: select book0_.id as id1_0_0_, book0_.author as author2_0_0_, book0_.title as title3_0_0_ from book book0_ where book0_.id=?
-- BookLombokTest 실행 로그
Hibernate: insert into book (author, title) values (?, ?)
Hibernate: select book0_.id as id1_0_0_, book0_.author as author2_0_0_, book0_.title as title3_0_0_ from book book0_ where book0_.id=?
두 테스트가 만들어낸 SQL 쿼리는 토씨 하나 다르지 않았다. 데이터베이스에 값을 저장하고 조회하는 과정 전체가 완벽하게 동일했다. Lombok이 만든 Entity는 솔라가 직접 만든 Entity와 똑같이 JPA의 규칙에 따라 움직여 주었다. 미세한 소음도, 핸들 쏠림도 없었다. 완벽하게 똑같은 주행 성능을 보여준 것이다.
솔라는 허탈한 웃음을 터뜨렸다.
“이제야 확실히 알겠어. JPA 같은 프레임워크는 우리 .java 소스 코드를 보는 게 아니었어. 오직 컴파일이 끝난 .class 바이트코드만 보고 판단하는 거였지. 그리고 지난번에 확인했듯이, Lombok은 컴파일 시점에 이미 모든 규칙을 지켜서 완벽한 .class 파일을 만들어 줬으니까… JPA 입장에서는 두 클래스를 전혀 구별할 수가 없었던 거야. 만드는 방식이 달랐을 뿐, 최종 결과물은 본질적으로 같은 객체였던 거네.”
‘Lombok은 Java의 반복적인 코드를 어노테이션으로 대체한다’는 문장이 이제야 비로소 편안하게 다가왔다. 그것은 무언가를 없애거나 숨기는 ‘대체’가 아니라, 더 나은 도구를 사용해 같은 결과물을 만드는 ‘대체’였다. 삽으로 땅을 파던 것을 포크레인으로 파는 것처럼. 목적과 결과는 같지만 과정이 훨씬 효율적으로 변한 것뿐이었다. Entity의 핵심적인 역할과 동작은 조금도 변하지 않았다.
그 순간, 솔라는 자신이 왜 그토록 반복적인 Getter, Setter 코드를 기계처럼 타이핑하고 있었는지 깨달았다. 그 코드들이 Entity의 본질을 지켜줄 것이라는 막연한 믿음 때문이었다. 하지만 이제는 아니었다. 진짜 중요한 것은 반복적인 코드가 아니라, ‘이 클래스가 데이터베이스 테이블과 연결되는 객체’라는 선언과 그 안에 담길 ‘데이터 필드’라는 것을 알게 되었다.
솔라는 미련 없이 기존에 작업하던 Book.java 파일을 열었다. 그리고 수십 줄에 달하던 Getter, Setter, 생성자 코드를 망설임 없이 선택해 Delete 키를 눌렀다. 텅 비어버린 공간에 그녀는 담담하게 어노테이션 몇 개를 추가했다.
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String author;
// 비즈니스 로직에만 집중할 수 있는 공간
}
이제 솔라의 눈에는 간결해진 코드 너머의 것이 보였다. 반복적인 잡무가 사라진 자리에, 이 Book Entity만이 수행해야 할 고유한 비즈니스 로직을 고민할 수 있는 여유가 생겨나고 있었다. Lombok은 코드를 줄여주는 도구를 넘어, 개발자가 더 중요한 것에 집중할 수 있도록 돕는 강력한 지원군이었다.