Kotlin JPA의 Entity에 val 필드를 사용해도 될까?
TL;DR
- 프록시 기능을 사용할 수 없기 때문에 프로퍼티에
final
을 붙이면 안됩니다. val
이냐var
이냐 상관없이 프록시 기능은 작동합니다.- 필드 접근 방식이 아닌 프로퍼티 접근 방식이면
val
을 쓸 수 없습니다. 필드 접근 방식인 경우 아래 결론으로 이어집니다. val
을 붙이면 자바에서 필드를final
로 선언하는 것과 같은데 JPA 스펙에서는 이를 허용하지 않고 Hibernate 역시 마찬가지입니다.- 그런데 Hibernate는 내부적으로 reflection을 사용하여 final 필드의 값도 변경할 수 있습니다. 하지만 확실하게 안전하다는 보장은 찾지 못했습니다.
- 결론: 종종 있는 오해와 다르게
val
을 써도 프록시 기능은 잘 작동하고 필드 접근도 가능합니다. 하지만 JPA, Hibernate 스펙 상 안전하다는 보장은 찾지 못했습니다. 따라서 대안으로var
+protected
setter를 사용할 수 있습니다.- JPA 엔티티는
open
이어야 해서private
setter를 사용할 수 없습니다.
- JPA 엔티티는
최근 코틀린을 사용하는 서비스들이 많아지면서 Spring과 함께 JPA 역시 코틀린에서 설정하는 방법에 대해 설명하는 글이 많습니다. 관련하여 정리한 글이 많지만
JPA 엔티티의 필드(인스턴스 변수)를 val
로 선언해도 괜찮은지 확실하게 언급한 글이 없어 직접 실험하고 확인한 과정을 정리했습니다.
우선 코틀린은 val
로 변수를 선언해 불변(immutable)함을 보장할 수 있습니다. 이는 간단한 개념이지만 실행 중이 값이 변하지 않으므로 프로그램의 동작을
추론하기 쉬워집니다. 따라서 val
사용을 권장하는데 JPA 엔티티에서 사용해도 괜찮은지 알아보겠습니다.
Kotlin에서 val, var, open, final을 붙이면 일어나는 일
- 세 줄 요약
val
프로퍼티는 자바의private final
필드 +public
getter 선언과 같다.var
프로퍼티는 자바의private
필드 +public
getter/setter 선언과 같다.open
,final
제어자를 명시적으로 프로퍼티에 붙일 수 있는데, 이는 자바 관점에서 getter/setter에만 영향을 주고 필드에는 영향을 주지 않는다.
일단 JPA에 대해 자세히 조사하기 전에 자료 대부분이 자바를 기준으로 설명하기 때문에 코틀린에서 val
, var
을 붙이는 행위가 자바에서 어떤 상황인지
대응해서 설명하겠습니다.
먼저 코틀린은 클래스 안에서 선언하는 변수를 자바에서 field라고 부르는 것과 다르게 property라고 부릅니다. 해당 용어는 Sun Microsystems에서 정의한 표준인 JavaBeans[1][2][3]에서 유래했습니다. 제가 이해한 바를 간단히 설명하면, 프로퍼티는 빈(인스턴스)의 상태를 표현하는 추상적인 개념으로 자바에서는 field, getter, setter로 구현했습니다. 그런데 코틀린은 일종의 보일러 플레이트로 볼 수 있는 “필드 + getter/setter” 구성을 간단한 변수 선언으로 통합해 프로퍼티라고 부릅니다. 따라서 코틀린에서 프로퍼티 변수를 하나 선언하면 이는 자바 코드에서 필드 선언 뿐만 아니라 getter/setter까지 동시에 선언하는 효과를 갖습니다.
이를 염두에 두고 val
, var
의 차이를 먼저 보면 val
프로퍼티를 선언하는 경우, 이는 자바에서 private final
필드와 public
getter 선언과
같습니다. var
로 선언하는 경우, 자바에서 private
필드와 public
getter/setter 선언과 같습니다. (자바 디컴파일 기능을 사용하시면 코드를 확인할 수 있습니다.)
또한 코틀린에서 프로퍼티 앞에 final
, open
키워드를 붙일 수 있는데 이는 자바에서 필드 앞에 붙이는 final
과 다른 효과를 갖습니다. 코틀린에서 프로퍼티 선언이
자바의 필드 + getter/setter와 대응하는데, final
을 붙이면 getter/setter에만 final
이 붙고 필드에는 final
이 붙지 않습니다. 필드에 final
이
붙는지 여부는 오직 val
/var
이냐에 따라 결정됩니다.
💡 코틀린은 기본적으로 모든 클래스, 메소드, 프로퍼티가
final
입니다. 하지만 JPA를 사용하기 위해 allOpen 플러그인을 사용하시는 경우open
으로 바뀝니다. 자세한 설정한 제가 쓴 Kotlin JPA 설정 글을 참고해주세요.
JPA, Hibernate 문서 까보기
JPA와 Hibernate 문서는 엔티티가 지켜야 하는 조건 3가지를 제시합니다.
- 클래스를
final
로 선언하면 안된다. - 메소드(함수)를
final
로 선언하면 안된다. - 필드(변수, 인스턴스 변수, 영속성 인스턴스 변수)를
final
로 선언하면 안된다.
먼저 JPA 문서에서 엔티티 클래스 필요조건 중 이런 내용이 있습니다. [4]
- The class must not be declared final. No methods or persistent instance variables must be declared final.
- 클래스를
final
로 선언하면 안된다. 어떠한 메소드다 영속성 인스턴스 변수도final
로 선언하면 안된다.- 여기서 영속성 인스턴스 변수는 엔티티의 transient 필드를 제외하고 DB에 저장하는 필드를 말합니다.
- 즉, 위 조건 3가지를 모두 지켜야 합니다.
여기서 Hibernate 유저 가이드는 다소 완화된 조건을 제시합니다. [5]
- Technically Hibernate can persist final classes or classes with final persistent state accessor (getter/setter) methods. However, it is generally not a good idea as doing so will stop Hibernate from being able to generate proxies for lazy-loading the entity.
- Hibernate는
final
클래스,final
영속성 상태 접근자(getter/setter) 메소드를 허용한다. 하지만 프록시를 통한 지연 로딩을 지원하지 않아 좋은 생각은 아니다. - Hibernate는 1, 2번 조건을 꼭 지키지 않아도 됩니다. 다만 3번 조건에 대한 언급이 없으므로 JPA 문서대로 조건을 지켜야 합니다.
따라서 문서를 읽어봤을 때 클래스와 메소드는 final
이 붙으면 안됩니다. 보통 JPA를 사용할 때 allOpen 플러그인을 사용하므로 이 점은 자동으로 지켜집니다.
그런데 프로퍼티를 val
로 선언하면 위에서 언급했듯이 필드에 final
이 붙으므로 스펙에 어긋납니다. 그럼 정확히 어떤 점이 문제일까요?
프록시 실험해보기
- 한 줄 요약:
val
,var
관계없이open
이면 지연 로딩이 잘 작동한다.
프록시 지연 로딩 | val | var |
---|---|---|
open | O | O |
final | X | X |
val
을 써도 괜찮은지 확인하기 위해 스택 오버플로우 질문들을 읽다보면 종종 프록시 기능을 언급하며 val
을 사용하면 지연 로딩을 사용할 수 없다고 말합니다.
실제로 그런지 실험을 통해 확인하고 프록시가 어떻게 동작하는지 더 정확히 알아봤습니다.
아래 코드를 이용해 간단히 실험을 진행했습니다. 엔티티의 프로퍼티에 open
, final
, val
, var
을 바꿔가며 지연 로딩이 작동하는지 확인합니다. 참고로
저는 allOpen 플러그인을 적용해 기본적으로 open
이 적용된 상태로 실험했습니다.
실험 결과는 위에서 테이블에 정리한 대로 val
, var
과는 상관 없이 open
, final
여부에 따라 지연 로딩이 작동합니다. 그 이유를 이해하기 위해 프록시를
이용한 지연 로딩이 어떻게 동작하는지 설명하겠습니다.
Hibernate는 지연 로딩을 구현하기 위해 EntityManager#find
호출 시 데이터를 조회하지 않고 엔티티 클래스를 상속한 프록시 객체를 생성합니다. Hibernate의
AbstractLazyInitializer
에서
좀 더 자세한 로직을 확인할 수 있는데 생각보다 간단합니다. 프록시 객체는 메소드 실행을 원본 엔티티에게 위임해야 하므로 내부에 원본 엔티티 참조를 보관하고 있습니다.
데이터를 로딩하기 전에 이 참조는 null
이었다가 나중에 getter/setter 등의 영속성 필드에 접근하는 메소드를 실행하면 데이터를 로딩해 원본 엔티티를 생성하고
내부 참조를 업데이트합니다. 이후 프록시 객체는 모든 메소드 호출을 원본 엔티티에게 위임합니다.
사람들이 val
을 쓰면 지연로딩이 작동하지 않을 것이라 오해하는 이유는 val
이 불변성(immutability)와 관계있기 때문입니다. 지연 로딩은 필요할 때 데이터를
로딩하여 값을 변경하니 불변성을 어기는 행위로 보입니다. 하지만 이는 프록시 객체의 동작 방식을 이해하면 틀린 생각임을 쉽게 알 수 있습니다. 프록시 객체는 내부에
빈 껍데기 상태의 원본 엔티티를 생성해서 나중에 데이터 로딩 후 setter 함수 등으로 값을 변경하는 방식이 아닌 원본 엔티티 생성 자체를 뒤로 미룹니다. 따라서
지연 로딩을 완료하여 원본 엔티티가 생성된 이후 엔티티 내부 val
프로퍼티의 불변성은 깨지지 않습니다.
프로퍼티에 final
을 붙이면 지연 로딩이 동작하지 않는 이유도 쉽게 이해할 수 있습니다. 프록시 객체는 클래스, 메소드를 상속해 내부 영속 필드에 접근하려는
동작을 변경해야 하기 때문에 반드시 open
이어야 합니다. 앞 절에서 언급했듯이 프로퍼티에 final
을 붙이는 행위는 자바에서 getter/setter에 final
이 붙는
것과 같기 때문에 프록시 객체가 동작을 변경할 수 없습니다.
EntityManagerFactory
를 초기화할 때 모든 엔티티를 검사하여 프록시를 사용할 수 있는지 확인합니다. 아래 코드를 보면 Hibernate에서 getter/setter가
존재하는데 메소드가 final
이면 프록시를 사용할 수 없다는 경고 메시지를 출력합니다.
재밌는 점은 자바의 경우 엔티티의 영속성 필드에 직접 접근하는 모든 메소드가 final
이면 안됩니다. 그런데 코틀린에서 프로퍼티만 final
이 아니면 다른 메소드가
final
이어도 지연 로딩이 잘 작동합니다. 이유는 마찬가지로 코틀린의 프로퍼티는 필드 + getter/setter가 합쳐진 형태이기 때문입니다. 코틀린은 프로퍼티
변수를 사용하거나 =
로 값을 변경하면 자동으로 getter/setter 호출로 변환합니다. 따라서 코틀린은 자바처럼 필드 직접 접근이 없고 항상 getter/setter 메소드를
거칩니다. 그러므로 프로퍼티만 final
이 아니라면 프록시 객체에서 getter/setter를 상속할 수 있어 그 외 메소드가 final
이어도 결국 getter/setter를
거치면서 지연 로딩 기능이 잘 작동합니다.
Field Access vs Property Access
- 한 줄 요약: 프로퍼티 접근 방식을 쓴다면
val
을 쓰면 안된다. 하지만 필드 접근 방식이 더 낫다는 의견이 꽤 있다.
프록시 측면에서 ‘val’을 써도 별 문제가 되지 않는다는 사실을 알았습니다. 그 외 다른 문제는 없을까요? 이번엔 필드 접근과 프로퍼티 접근 방식에 대해 알아보고 비교하여 어떤 차이가 있고 ‘val’을 써도 되는지 확인해보겠습니다.
JPA에서 엔티티의 영속 상태에 접근할 때 2가지 방식이 있습니다. 엔티티의 필드(인스턴스 변수)로 접근하거나 프로퍼티로 접근할 수 있습니다. [6]
엔티티는 영속성 필드와 영속성 프로퍼티 접근 방식을 같이 쓸 수 있는데 Hibernate 문서에서 자세한 방법을 설명합니다. [7]
기본적으로 Hibernate는 @Id
어노테이션이 필드에 붙으면 영속성 필드, getter에 붙으면 영속성 프로퍼티 방식으로 엔티티에 접근합니다. [8]
그런데 JPA 공식문서를 더 읽어보면 각 접근 방식에 따라 지켜야 하는 규칙이 있습니다. 정확히는 프로퍼티 접근 방식일 경우 반드시 JavaBeans 메소드 컨벤션을
따라야 합니다. [9]
그 조건은 어렵지 않고 private
필드 + public
getter/setter 조합으로 프로퍼티를 표현하는 것입니다. 따라서 프로퍼티 접근 방식이면 반드시 setter가
있어야 합니다.
그럼 우리가 코틀린에서 @Id
를 프로퍼티 변수에 붙이면 이는 필드 접근 방식일까요? 프로퍼티 접근 방식일까요? 놀랍게도 코틀린에서 프로퍼티라는 이름을 쓰고 있지만
필드 접근 방식입니다. 자바로 디컴파일하면 @Id
가 필드에 붙은 것을 확인할 수 있습니다. 만약 @Id
를 getter에 붙이고 싶다면 @get:Id
로 바꿔 사용하면 됩니다.
코틀린 문서에서 더 자세한 사용법을 확인할 수 있습니다. [10]
그래서 JPA 스펙에 맞게 만약 val
프로퍼티에 @get:Id
를 붙인 후 프로그램을 실행시키면 엔티티 검사 단계에서 setter가 없다는 예외를 발생시킵니다. 따라서
프로퍼티 접근 방식을 사용하고 싶다면 val
을 사용하면 안됩니다. 그런데 Hibernate 문서를 보면 필드 접근 방식의 소소한 장점에 대해 이야기합니다. [11]
필드 접근 방식은 getter/setter를 강요하지 않기 때문에 더 유연하게 클래스를 디자인할 수 있습니다. 또한 스택 오버플로우에 두 방식 중 어떤 것이 나은지 토론한
질문이 있습니다. [12]
가장 좋아요가 많은 답변을 요약하면 필드 접근 방식은 OOP의 캡슐화를 위반하는 것 같지만 엄밀히 그 의미를 따져보면 그렇지 않고 접근 방식 결정은 DB와 애플리케이션 사이에서
어떻게 직렬화할지에 가까운 문제라고 말합니다. 따라서 필드를 그대로 DB에 저장하는 필드 접근 방식이 더 낫다고 말합니다. 이에 대해 각자 의견이 다르므로 읽고 적절한
방식을 선택하면 되겠습니다.
정리하면 프로퍼티 접근 방식을 쓴다면 getter/setter가 모두 있어야 하므로 반드시 var
을 써야 합니다. 필드 접근 방식이라면 val
이어도 문제는 없습니다.
그럼 그 외 val
을 썼을 때 문제가 있을까요?
지연 로딩도 잘되고 Field Access 방식인데 val 써도 되는 거 아니야?
- 한 줄 요약:
val
이여서 setter가 없는 점은 괜찮다. 다만 backing field가final
일 때 괜찮은지 100% 확신할 수 없다. Hibernate는 괜찮아 보이지만 다른 구현체는 위험할 수 있다.val
대안으로var
+protected set
을 쓸 수 있다.
저는 val
을 써도 괜찮다는 증거를 찾기 위해 크게 2개의 질문을 만들고 답을 찾아봤습니다.
- setter가 없어도 괜찮을까?
final
필드를 사용해도 괜찮을까?
먼저 setter의 여부는 프로퍼티 접근 방식이 아니면 필수가 아닙니다. 필드 접근을 통해 데이터를 읽고 쓸 수 있기 때문에 getter/setter가 없어도 문제가 되지 않습니다. 혹시 이 지점에서 프록시와 헷갈리실 수 있는데 앞 절에서 이야기했듯 프록시의 지연 로딩은 메소드를 상속해서 실제로 호출하는 시점에 데이터를 조회하여 엔티티를 생성하도록 지연할 뿐 엔티티가 생성된 후 내부 영속 상태에 접근하는 방식과 무관합니다. 기존 자바 환경에서 JPA를 사용하는 사람들 중에 필드 접근 방식인데 setter가 없어 문제가 된다는 질문은 찾아볼 수 없습니다. 따라서 setter는 없어도 괜찮다고 봅니다.
그럼 final
필드는 괜찮을까요? 위에서 val
프로퍼티를 사용하면 자바에서 필드를 final
로 선언한 것과 같다고 이야기했습니다. 이에 대해 JPA 문서에서
확실히 안된다고 말하고 있고 Hibernate에서 조건을 완화했다는 언급도 없습니다. Hibernate의 예시 코드를 봐도 final
필드를 사용하는 코드는 찾아볼 수
없습니다. (Hibernate 문서에 @Embeddable
을 저장할 때 커스텀 생성자와 함께 final
필드를 사용하는 예시가 있습니다. [13]
하지만 이는 DDD 관점에서 엔티티가 아닌 값 객체(Value Object)[14]에 가까운 개념입니다.)
Hibernate와 같은 JPA 구현체 중 하나인 Apache OpenJPA는 final
이 붙으면 transient 필드로 취급해 DB에 저장하지 않습니다. 반면 Hibernate는
엔티티 생성 이후 final 필드를 변경할 수 있다고 합니다. [15]
Hibernate 내부 코드 중 SetterFieldImpl
클래스[16]를
보면 자바 리플렉션의 Field#set
을 사용하므로 final
필드의 값을 변경할 수 있습니다. 그런데 Field#set
의 javadoc[17]을
보면 값을 변경할 수 없는 경우도 존재하고 그 위험성에 대해 경고하고 있습니다. Hibernate 역시 이 점을 알고 no-arg 생성자로 엔티티 생성 직후 비어있는 필드
값을 채워줄 때만 해당 메소드를 쓸 것 같지만 Hibernate 문서에서 확실한 언급이 없어 안심할 수 없습니다.
Hibernate에서 final
필드 값을 바꾸는 코드 외에 val
을 써도 괜찮다는 증거를 더 찾아보려 했지만 더 이상 확실히 괜찮다는 자료를 찾을 수 없었습니다.
스택 오버플로우 질문과 답변을 봤을 때 치명적인 버그가 있다는 말이 없는 것으로 보아 어느 정도 안전성을 담보할 수 있지만 Hibernate 공식 답변은 없었습니다.
찝찝함이 남아 찾아본 대안으로 val
대신 var
을 쓰고 protected set
을 설정하는 방법이 있습니다. [18]
(private set
을 설정하면 더 좋겠지만 JPA 엔티티는 open
이여서 코틀린 제약 때문에 private set
을 사용할 수 없습니다.)
정리하면 필드 접근 방식인 경우 setter는 없어도 괜찮습니다. 하지만 필드가 final
이면 Hibernate에서는 괜찮아보이지만 공식 문서에 언급이 없어 안심할 수 없습니다.
val
의 대안으로 다소 번거롭지만 var
+ protected set
조합으로 접근을 제어할 수 있습니다.
간단 정리 & 느낀 점
코틀린을 사용하는 프로젝트가 늘어나고 있지만 아직 코틀린 전용 라이브러리가 많지 않아 자바 라이브러리를 사용하는 경우가 많습니다. 호환성에 큰 문제는 없지만 자바와
다른 몇 가지 특성 때문에 고려할 점이 있습니다. 대표적으로 JPA 엔티티의 영속성 필드를 val
로 선언해도 괜찮은지 여부입니다.
공식 문서, 프록시의 지연로딩, 영속성 상태 접근 방식을 하나씩 탐구하면서 val
을 써도 괜찮은지 단서를 수집했습니다. 아쉽게도 글의 결론은 다소 찝찝하게 끝났습니다.
공식 문서에서 final
필드를 사용하지 말라고 써있어서 안쓰는 게 맞아보이나 Hibernate는 final
을 수정할 수 있는 로직을 보유하고 있었습니다. 하지만 내부
구현은 언제나 바뀔 수 있기에 안심하고 사용하기 어렵습니다. 따라서 대안으로 var
+ protected set
조합으로 사용하는 게 최선이라 생각합니다.
글을 쓰면서 이렇게 긴 글이 될 것이라 생각하지 못했는데요. 공식 문서나 스택 오버플로우에서 확실한 답이 있을 것이라 생각했지만 조사하다보니 모호한 부분이 많아 조사를 멈출 수 없었습니다. 생각보다 힘든 과정이었지만 과정에서 배운 점도 많았습니다. 아마 이 글을 읽으시는 분들은 코틀린을 사용하실텐데 아직 코틀린은 자바 라이브러리를 사용할 때 광고와는 다르게 빈틈이 생각보다 많아보입니다. 직접 코틀린으로 개발하시면서 발견한 빈 틈들을 정리하여 공유해주시면 많은 분들께 도움을 줄 수 있는 상황이라고 생각합니다. 감사합니다!
References
- https://en.wikipedia.org/wiki/JavaBeans
- https://www.oracle.com/java/technologies/javase/javabeans-spec.html
- https://docs.oracle.com/javase/tutorial/javabeans/
- https://jakarta.ee/learn/docs/jakartaee-tutorial/current/persist/persistence-intro/persistence-intro.html#_requirements_for_entity_classes
- https://docs.jboss.org/hibernate/orm/6.5/userguide/html_single/Hibernate_User_Guide.html#entity-pojo
- https://jakarta.ee/learn/docs/jakartaee-tutorial/current/persist/persistence-intro/persistence-intro.html#_persistent_fields_and_properties_in_entity_classes
- https://docs.jboss.org/hibernate/orm/6.5/userguide/html_single/Hibernate_User_Guide.html#_overriding_the_default_access_strategy
- https://docs.jboss.org/hibernate/orm/6.5/userguide/html_single/Hibernate_User_Guide.html#access
- https://jakarta.ee/learn/docs/jakartaee-tutorial/current/persist/persistence-intro/persistence-intro.html#_persistent_properties
- https://kotlinlang.org/docs/annotations.html#annotation-use-site-targets
- https://docs.jboss.org/hibernate/orm/6.5/userguide/html_single/Hibernate_User_Guide.html#field-based-access
- https://stackoverflow.com/questions/594597/hibernate-annotations-which-is-better-field-or-property-access
- https://docs.jboss.org/hibernate/orm/6.4/userguide/html_single/Hibernate_User_Guide.html#embeddable-instantiator
- https://en.wikipedia.org/wiki/Value_object
- https://stackoverflow.com/questions/2455906/persistence-provider-for-java-that-supports-final-fields/26513566#26513566
- https://github.com/hibernate/hibernate-orm/blob/b7038b2294eecd124e51e25dc6f48b0ad6c66d36/hibernate-core/src/main/java/org/hibernate/property/access/spi/SetterFieldImpl.java#L57
- https://docs.oracle.com/en/java/javase/19/docs/api/java.base/java/lang/reflect/Field.html#set(java.lang.Object,java.lang.Object)
- https://kotlinlang.org/docs/properties.html#getters-and-setters