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를 사용할 수 없습니다.

최근 코틀린을 사용하는 서비스들이 많아지면서 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가지를 제시합니다.

  1. 클래스를 final로 선언해야 한다.
  2. 메소드(함수)를 final로 선언해야 한다.
  3. 필드(변수, 인스턴스 변수, 영속성 인스턴스 변수)를 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이면 지연 로딩이 잘 작동한다.
프록시 지연 로딩valvar
openOO
finalXX

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개의 질문을 만들고 답을 찾아봤습니다.

  1. setter가 없어도 괜찮을까?
  2. 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

  1. https://en.wikipedia.org/wiki/JavaBeans
  2. https://www.oracle.com/java/technologies/javase/javabeans-spec.html
  3. https://docs.oracle.com/javase/tutorial/javabeans/
  4. https://jakarta.ee/learn/docs/jakartaee-tutorial/current/persist/persistence-intro/persistence-intro.html#_requirements_for_entity_classes
  5. https://docs.jboss.org/hibernate/orm/6.5/userguide/html_single/Hibernate_User_Guide.html#entity-pojo
  6. https://jakarta.ee/learn/docs/jakartaee-tutorial/current/persist/persistence-intro/persistence-intro.html#_persistent_fields_and_properties_in_entity_classes
  7. https://docs.jboss.org/hibernate/orm/6.5/userguide/html_single/Hibernate_User_Guide.html#_overriding_the_default_access_strategy
  8. https://docs.jboss.org/hibernate/orm/6.5/userguide/html_single/Hibernate_User_Guide.html#access
  9. https://jakarta.ee/learn/docs/jakartaee-tutorial/current/persist/persistence-intro/persistence-intro.html#_persistent_properties
  10. https://kotlinlang.org/docs/annotations.html#annotation-use-site-targets
  11. https://docs.jboss.org/hibernate/orm/6.5/userguide/html_single/Hibernate_User_Guide.html#field-based-access
  12. https://stackoverflow.com/questions/594597/hibernate-annotations-which-is-better-field-or-property-access
  13. https://docs.jboss.org/hibernate/orm/6.4/userguide/html_single/Hibernate_User_Guide.html#embeddable-instantiator
  14. https://en.wikipedia.org/wiki/Value_object
  15. https://stackoverflow.com/questions/2455906/persistence-provider-for-java-that-supports-final-fields/26513566#26513566
  16. https://github.com/hibernate/hibernate-orm/blob/b7038b2294eecd124e51e25dc6f48b0ad6c66d36/hibernate-core/src/main/java/org/hibernate/property/access/spi/SetterFieldImpl.java#L57
  17. https://docs.oracle.com/en/java/javase/19/docs/api/java.base/java/lang/reflect/Field.html#set(java.lang.Object,java.lang.Object)
  18. https://kotlinlang.org/docs/properties.html#getters-and-setters
comments powered by Disqus

관련문서