
이 글에서는 Kotlin의 핵심 설계 철학이 JPA와 만나면서 어떤 부분에서 구조적인 타협이 생기는지 정리해요. 내용은 이론보다 실제 적용하면서 겪은 경험을 바탕으로 설명할게요.
이 글의 목적은 JPA를 쓰지 말자가 아니에요. Kotlin + JPA 조합이 어떤 비용을 포함하는지를 미리 알고, 그걸 감안한 상태에서 선택할 수 있도록 돕는 데 있어요.
Kotlin이 지키고자 하는 핵심 가치

Kotlin은 단순히 Java의 문법을 개선하는 데 그치지 않고, 코드를 더 안전하고 예측 가능하게 만드는 방향을 강하게 지향해요.
이를 위해 다음과 같은 핵심 가치를 중심에 두고 설계되었어요. 이 지점들이 JPA와 만날 때 종종 타협 포인트가 돼요.
JPA 사용 시 발생하는 구조적 충돌
1. Null 안정성 약화
JPA는 엔티티 생명주기 특성상 일시적으로 null 상태를 허용해요. 예를 들어 @GeneratedValue 식별자는 저장 전까지 값이 없고, 연관 관계도 지연 로딩이나 프록시 때문에 초기 상태가 불완전할 수 있어요.
그 결과 Kotlin 코드에서는 lateinit이나 nullable 타입(?)이 늘어나고, 컴파일러가 보장하던 안정성이 프레임워크의 런타임 규칙에 기대게 돼요.
실제 예시: 저장 전에는 id가 null일 수밖에 없어요
import jakarta.persistence.*
@Entity
class UserEntity(
@Column(nullable = false)
var email: String,
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null, // 저장 전엔 null
)
fun printId(user: UserEntity) {
// user.id는 Long? 이라서 매번 null 처리가 필요해요
println(user.id ?: error("아직 영속화되지 않았어요"))
}
실제 예시: 연관 관계를 non-null로 만들기 어려워져요 (lateinit 확산)
import jakarta.persistence.*
@Entity
class OrderEntity(
@ManyToOne(fetch = FetchType.LAZY)
lateinit var user: UserEntity, // JPA가 나중에 주입해줄 거라 가정해요
@Id @GeneratedValue
var id: Long? = null,
)
즉 안전성을 타입이 아니라 프레임워크가 결정하는 구조가 돼요.
2. 의미 없는 기본 생성자
JPA는 리플렉션으로 엔티티를 생성하기 때문에 no-arg constructor(기본 생성자) 를 요구해요.
하지만 Kotlin은 필수 값이 없으면 객체를 만들 수 없어야 한다는 쪽을 선호하는데, 기본 생성자는 이 원칙과 충돌해요.
결국 엔티티에 의미 없는 기본 생성자를 넣거나, 값을 임시로 채워서 불완전한 초기 상태를 만들게 돼요.
실제 예시: JPA 때문에 ‘의미 없는 기본 생성자’를 추가
import jakarta.persistence.*
@Entity
class MemberEntity(
@Column(nullable = false)
var email: String,
@Id @GeneratedValue
var id: Long? = null,
) {
// JPA용 기본 생성자(도메인 관점에서는 의미 없어요)
protected constructor() : this(email = "tmp@example.com")
}
실제 예시: kotlin-jpa(no-arg) 플러그인이 “숨겨주는” 형태
import jakarta.persistence.Entity
import jakarta.persistence.Id
// kotlin-jpa 플러그인을 쓰면,
// 코드에는 없지만 컴파일 결과물에 기본 생성자가 만들어져요.
@Entity
class ProductEntity(
@Id
var id: Long? = null,
)
즉 Kotlin이 막으려던 ‘불완전한 객체’를 JPA 때문에 허용하게 돼요.
3. 불변성 포기 비용
Hibernate는 프록시와 변경 감지(더티 체킹)를 위해 엔티티가 상속 가능(open) 하고, 필드가 변경 가능(var) 하길 기대하는 경우가 많아요.
그래서 Kotlin에서 자연스럽게 쓰는 val 중심의 불변 모델을 그대로 가져가기 어렵고, 변경 가능한 상태가 넓게 퍼지기 쉬워요. 이 과정에서 상태 변경 지점이 흐려지고, “어디서 값이 바뀌었는지” 추적하는 비용이 늘어나요.
실제 예시: 불변 모델처럼 보이게 만들기 어렵고, 결국 var로 흐르기 쉬워요
import jakarta.persistence.*
@Entity
open class AccountEntity( // 프록시 때문에 open이 필요해질 수 있어요
@Column(nullable = false)
open var balance: Long = 0, // 변경 감지를 위해 var
@Id @GeneratedValue
open var id: Long? = null,
)
실제 예시: 상태 변경이 코드 곳곳에서 발생하기 쉬움
fun transfer(from: AccountEntity, to: AccountEntity, amount: Long) {
from.balance -= amount // 여기서 바뀌고
to.balance += amount // 저기서도 바뀌어요
// 변경 지점을 메서드로 모으지 않으면 추적이 어려워져요
}
즉 불변 모델이 주는 예측 가능성을 일부 포기하게 돼요.
4. data class를 엔티티에 쓰기 어려움
엔티티는 equals/hashCode의 의미가 식별자 생성 시점(저장 전/후) 에 따라 흔들릴 수 있어요.
그런데 data class는 생성자 프로퍼티를 기준으로 동등성을 자동 생성하기 때문에, 엔티티의 동일성(identity) 모델과 잘 맞지 않아요. 저장 전에는 id가 없고 저장 후에 생기는 구조에서, 컬렉션 비교나 Set/Map 키로 사용할 때 버그로 이어질 수 있어요.
실제 예시: 저장 전/후에 equals/hashCode 의미가 바뀌는 문제
import jakarta.persistence.*
@Entity
data class PostEntity( // data class는 엔티티에 위험해요
@Column(nullable = false)
var title: String,
@Id @GeneratedValue
var id: Long? = null,
)
실제 예시: Set/Map에서 예상 못한 동작
fun demo(post: PostEntity) {
val set = hashSetOf(post)
// 저장 전에는 id=null 기준으로 hashCode가 계산돼요.
// 저장 후 id가 생기면 hashCode가 바뀔 수 있어서,
// set.contains(post)가 false가 되는 상황이 생길 수 있어요.
println(set.contains(post))
}
Kotlin의 강력한 도구인 data class를 엔티티에서는 사실상 포기하게 돼요.
그래서 Kotlin에서는 JPA를 쓰면 안 될까요?

항상 그렇지는 않아요. Kotlin + JPA 조합에도 충분히 현실적인 선택지가 되는 상황이 있어요.
- 레거시 DB 환경이라 스키마 변경이 어렵고, ORM 기반 매핑이 필요한 경우
- 서비스 특성이 CRUD 중심이라 복잡한 도메인 모델링보다 생산성이 중요한 경우
- 팀이 JPA/Hibernate에 매우 숙련되어 있고, 프록시/지연 로딩/엔티티 설계 제약을 이미 운영 수준에서 다루고 있는 경우
이런 조건이라면 Kotlin에서 JPA를 쓰는 선택도 충분히 합리적이에요.
다만 내가 이 프로젝트에서 중요하게 생각하는 게 Kotlin의 가치(불변성, null 안정성, 명시적 상태 변경, 타입 안전성) 인지, 아니면 JPA가 주는 생산성/관성/생태계인지요. 이 기준이 정리되어 있지 않으면, 나중에는 “왜 이렇게까지 타협했지?”라는 비용만 남을 수 있어요.
Kotlin 환경에서 고려할 수 있는 대안
JPA의 제약이 부담된다면, Kotlin의 설계 철학과 더 잘 맞는 선택지도 함께 검토해볼 수 있어요.
- Spring Data JDBC
단순한 매핑 모델을 기반으로 해서, 불필요한 프록시나 생명주기 개념이 없어요. data class와 불변 모델을 자연스럽게 사용할 수 있고, “엔티티는 그냥 데이터”라는 감각에 가까워요. - Exposed
Kotlin-first로 설계된 DSL 기반 ORM이에요. SQL이 코드 안에서 명시적으로 드러나기 때문에, 숨겨진 동작 없이 데이터 접근 흐름을 이해하기 쉬워요. - jOOQ
타입 안전성을 갖춘 SQL 중심 라이브러리로, 쿼리의 명확성과 성능을 우선하는 설계예요. ORM 추상화보다는 “SQL을 잘 쓰는 Kotlin 코드”를 선호하는 경우에 잘 맞아요.
정리하며
Kotlin과 JPA의 문제는 단순한 호환성 이슈라기보다는, 지향하는 철학의 불일치에 더 가까워요.
Kotlin을 선택한다는 건
컴파일 타임 안전성과 명확한 모델링을 중시하겠다는 선택이에요.
반대로 JPA를 기존 방식 그대로 사용한다는 건,
그 선택의 일부를 현실적인 이유로 타협하겠다는 결정이기도 해요.
물론 은탄환은 없어요.
하지만 어떤 가치를 얻고, 어떤 비용을 감수하는지는 의식적으로 선택할 수 있어요.
이 글이 여러분의 선택을 조금 더 명확하게 만드는 기준점이 되길 바랍니다!
안녕하세요, 저는 주니어 개발자 박석희 입니다. 언제든 하단 연락처로 연락주세요 😆