본문으로 건너뛰기

"spring" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

모든 태그 보기

· 약 5분

오랜만에 Spring Boot 프로젝트를 멀티 모듈로 구성하려고 Gradle 문서를 읽다보니 멀티 프로젝트에서 subprojects {}, allprojects {}의 사용을 더이상 권장하지 않는다는 내용을 보게 되었다.

위와 같은 기존 방식을 cross project configuration 이라고 하는데, 다음과 같은 문제가 있다고 한다.

  • 서브프로젝트의 빌드 스크립트만 봐서는 부모 프로젝트에서 빌드 로직이 주입된다는 것이 분명하게 드러나지 않기 때문에 로직을 파악하기 힘들다.
  • 설정 시점에 프로젝트 간에 커플링이 생기기 때문에 configuration-on-demand와 같은 최적화가 제대로 작동하지 않는다.

큰 프로젝트가 아니라면 사실 별로 상관 없다고 생각하지만, 아무튼 일리가 있다고 생각되니 권장 방식인 Convention Plugins 방식을 사용해본다.

문제 1: UnknownPluginException

공식 문서를 Kotlin DSL 버전으로 따라해보았는데, 다음과 같은 에러가 나면서 잘 되지 않았다.

Build file '/Users/user/myproject/api/build.gradle.kts' line: 1

Plugin [id: 'myproject.java-conventions'] was not found in any of the following sources:

* Try:
> Run with --info or --debug option to get more log output.
> Run with --scan to get full insights.

* Exception is:
org.gradle.api.plugins.UnknownPluginException: Plugin [id: 'myproject.java-conventions'] was not found in any of the following sources:

- Gradle Core Plugins (plugin is not in 'org.gradle' namespace)
- Plugin Repositories (plugin dependency must include a version number for this source)
...

이 문제는, buildSrc/build.gradle.kts를 다음과 같이 만들어주면 해결된다.

plugins {
`kotlin-dsl`
}

repositories {
// for kotlin-dsl plugin
gradlePluginPortal()
}

해당 문서화 버그는 gradle/gradle#19667로 등록되어 있다. (이 자식들 ㅠㅠ)

문제 2: 플러그인 버전 지정 불가

Spring Initializr에서 만들어진 플러그인 설정을 그대로 사용했더니 다음과 같은 에러가 났다.

Invalid plugin request [id: 'org.springframework.boot', version: '2.6.4']. Plugin requests from precompiled scripts must not include a version number. Please remove the version from the offending request and make sure the module containing the requested plugin 'org.springframework.boot' is an implementation dependency of project ':buildSrc'.
Invalid plugin request [id: 'io.spring.dependency-management', version: '1.0.11.RELEASE']. Plugin requests from precompiled scripts must not include a version number. Please remove the version from the offending request and make sure the module containing the requested plugin 'io.spring.dependency-management' is an implementation dependency of project ':buildSrc'.
Invalid plugin request [id: 'org.jetbrains.kotlin.jvm', version: '1.6.10']. Plugin requests from precompiled scripts must not include a version number. Please remove the version from the offending request and make sure the module containing the requested plugin 'org.jetbrains.kotlin.jvm' is an implementation dependency of project ':buildSrc'.
Invalid plugin request [id: 'org.jetbrains.kotlin.plugin.spring', version: '1.6.10']. Plugin requests from precompiled scripts must not include a version number. Please remove the version from the offending request and make sure the module containing the requested plugin 'org.jetbrains.kotlin.plugin.spring' is an implementation dependency of project ':buildSrc'.

일단 plugins {} 블럭에서 다음과 같이 버전을 제거했다.

plugins {
id("org.springframework.boot")
id("io.spring.dependency-management")
kotlin("jvm")
kotlin("plugin.spring")
}

근데 그러면 플러그인 버전을 어떻게 지정해야 할까? 답은 buildSrc/build.gradle.kts에 다음과 같이 의존성을 추가하는 것이다.

dependencies {
implementation("org.springframework.boot:spring-boot-gradle-plugin:2.6.4")
implementation("io.spring.gradle:dependency-management-plugin:1.0.11.RELEASE")
implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.10")
implementation("org.jetbrains.kotlin:kotlin-allopen:1.6.10")
}

참고로 여기에는 플러그인 ID가 아니라, Maven 좌표를 찾아서 적어주었다. Maven 좌표는 plugins.gradle.org에서 플러그인 ID로 검색해서 찾는다.

예를 들어 org.springframework.boot를 찾아서 들어가보면 "Using legacy plugin application:" 아래에 다음과 같이 되어있다.

buildscript {
...
dependencies {
// v~~~~ 이 부분!
classpath "org.springframework.boot:spring-boot-gradle-plugin:2.6.4"
}
}

플러그인 ID만 가지고 할 수 있는 더 좋은 방법이 있을 수도 있는데 찾아보진 않았다. 끝!

· 약 10분

Kotlin에서 JPA 사용할 때 주의할 점을 쓴 이후로 직장에서 하는 프로젝트에도 Kotlin + JPA를 사용하게 되었습니다. 그러다보니 좀 더 고급 기능을 사용하게 되고 또 여러가지 새로운 어려움에 부딪혔습니다.

Embeddable

기간(시작 날짜, 끝 날짜)이나 좌표(X, Y) 등 항상 같이 다니는 값들을 객체로 묶어서 Entity의 속성으로 지정할 수 있습니다.

이러한 객체의 클래스에 @Embeddable 어노테이션을 붙여서 선언하고, Entity에서 @Embedded 어노테이션을 붙여서 사용합니다. 예제 코드를 보면,

@Embeddable
data class Coordinate(
var x: Int,
var y: Int
)

와 같이 선언하고

@Entity
data class Marker(
@get:Id
var id: Int,

@get:Embedded
var coordinate: Coordinate
)

처럼 가져다 쓸 수 있습니다.

데이터베이스 스키마에서는 Coordinate에 대한 새로운 테이블이 생기지 않고, Marker 테이블에 x, y 컬럼이 추가됩니다.

주의사항

Embeddable 클래스도 이전 글에서 설명한 Entity 클래스와 마찬가지로 다음 속성을 만족해야 합니다.

Embedded와 null

JPA 스펙에 의하면 Embedded 속성은 null이 될 수 없습니다. 하지만 Hibernate 같은 구현체들은 null을 지원합니다.

당연히 일단 Kotlin에서 nullable 타입으로 수정해야 null을 넣을 수 있습니다.

@Entity
data class Marker(
@get:Id
var id: Int,

@get:Embedded
var coordinate: Coordinate? // <- nullable 타입으로 수정
)

그리고 실제 데이터베이스 스키마에서도 컬럼을 nullable하게 만들어야 합니다.

그런데 Hibernate에서 자동으로 테이블을 생성하는 경우(hbm2ddl.auto 사용시), Embeddable에 속한 컬럼은 무조건 not null 컬럼이 되는 문제가 있습니다. 그런 경우 다음과 같이 수정하면 nullable 컬럼이 생성되게 할 수 있습니다.

@Embeddable
data class Coordinate(
@get:Column
var x: Int,
@get:Column
var y: Int
)

참고로, Embeddable 클래스의 어노테이션 위치 (필드 vs 프로퍼티)는 포함된 Entity 클래스의 어노테이션 위치를 따라갑니다. 앞의 예제에서 Marker는 프로퍼티(getter)에 어노테이션을 달았기 때문에 Embeddable에서도 getter에 달아야 인식이 됩니다.

Kotlin에서 JPA 관련 어노테이션은 무조건 @get:으로 달아야 한다고 기억해두면 혼란이 적은 것 같습니다.

같은 타입의 Embedded 속성을 여러 개 선언하기

Kotlin과는 무관하지만 알아두면 좋은 내용입니다.

@Entity
data class Line(
@get:Id
var id: Int,

@get:Embedded
var start: Coordinate,

@get:Embedded
var end: Coordinate
)

위와 같이 선언하면 startend가 동일한 컬럼 x, y를 가지려고 해서 다음과 같은 오류가 발생합니다.

org.hibernate.MappingException: Repeated column in mapping for entity: org.sapzil.jpa.Line column: x (should be mapped with insert="false" update="false")

정석 해결 방법은 @AttributeOverride를 사용하는 것이지만 이런 속성이 많아지면 일일히 달기는 귀찮습니다. 이 때 ImplicitNamingStrategy를 이용하면 자동으로 start_x, end_x와 같이 prefix 붙은 컬럼을 지정할 수 있습니다.

Spring에서는 spring.jpa.hibernate.naming.implicit-strategy 프로퍼티를 org.hibernate.boot.model.naming.ImplicitNamingStrategyComponentPathImpl로 지정하거나, ImplicitNamingStrategyComponentPathImpl을 Bean으로 주입하여 설정하면 됩니다.

IdClass

JPA에서 복합 기본키(composite primary key)를 매핑하기 위해서는 @IdClass 어노테이션을 사용합니다. 다음과 같이 PK가 될 속성에 모두 @Id를 붙이고, PK의 속성을 모두 가진 클래스를 만들어서 @IdClass 어노테이션으로 지정합니다.

@Entity
@IdClass(Name::class)
data class Person(
@get:Id
var firstName: String,
@get:Id
var lastName: String,
var phoneNumber: String
)

어떤 클래스를 IdClass로 사용하려면 Serializable 인터페이스를 구현해야 돼서 처음에 다음과 같이 선언해 봤습니다.

data class Name(var firstName: String, var lastName: String) : Serializable

그랬더니 객체를 저장하려고 할 때 이런 오류가 발생했습니다.

java.lang.IllegalArgumentException: No argument provided for a required parameter: parameter #0 firstName of fun <init>(kotlin.String, kotlin.String): org.sapzil.jpa.Name
at kotlin.reflect.jvm.internal.KCallableImpl.callDefaultMethod(KCallableImpl.kt:138)
at kotlin.reflect.jvm.internal.KCallableImpl.callBy(KCallableImpl.kt:110)
at org.springframework.beans.BeanUtils$KotlinDelegate.instantiateClass(BeanUtils.java:765)
at org.springframework.beans.BeanUtils.instantiateClass(BeanUtils.java:170)
at org.springframework.beans.BeanUtils.instantiateClass(BeanUtils.java:124)
...

메시지를 보니 아무래도 Spring이 생성자에 인자를 넘기지 않고 객체를 만들려고 해서 그런 것 같습니다. 그래서 인자를 받지 않는 생성자를 추가해봤습니다.

data class Name(var firstName: String, var lastName: String) : Serializable {
constructor() : this("", "")
}

그랬는데도 같은 오류가 발생했습니다. 스택 트레이스에 나타난 BeanUtils의 코드를 보면...

Constructor<T> ctor = (KotlinDetector.isKotlinType(clazz) ?
KotlinDelegate.getPrimaryConstructor(clazz) : clazz.getDeclaredConstructor());
return instantiateClass(ctor);

Kotlin 클래스일 경우 Primary Constructor를 찾게 되어있습니다. Primary Constructor는 클래스 선언 헤더에 같이 선언되는 생성자를 말합니다. 방금 추가한 인자 없는 생성자는 Secondary Constructor이기 때문에 인식되지 않은 것입니다.

그래서 data class를 포기해야 하나... 생각하고 있었는데, 구글링하던 중 Gist를 하나 발견했습니다. 결론은 생성자의 모든 파라미터에 기본값을 지정하면 된다는 것입니다.

data class Name(var firstName: String = "", var lastName: String = "") : Serializable

이렇게 하면 Primary Constructor를 인자 없이 호출할 수 있게 되어서 정상적으로 저장이 됩니다.

IdClass와 ImplicitNamingStrategy

(Kotlin과는 무관한 내용이지만) 앞서 설명한대로 ImplicitNamingStrategy를 변경하면 IdClass를 사용할 때 문제가 생길 수 있습니다.

ImplicitNamingStrategyComponentPathImpl을 사용할 때, 위의 예제대로 모델을 선언한 후 저장하려고 하면 이렇게 됩니다.

org.h2.jdbc.JdbcSQLException: NULL not allowed for column "_IDENTIFIER_MAPPER_FIRST_NAME"; SQL statement:
insert into person (phone_number, id_first_name, id_last_name) values (?, ?, ?) [23502-197]

먼저 컬럼 이름 앞에 id_가 붙은 것도 이상하고, _identifier_mapper_first_name이라는 이상한 컬럼이 생긴 것도 문제입니다.

IdClass를 지정하면 Hibernate는 내부적으로 id, _identifierMapper라는 숨은 속성을 생성합니다. 즉 id.firstName, _identifierMapper.firstName 같은 속성이 생기는 건데요. 기본 ImplicitNamingStrategy에서는 속성 경로의 마지막 부분만 취하기 때문에 문제가 없지만, 우리는 속성 경로를 모두 나타내는 전략으로 변경하였기 때문에 이런 이상한 일이 벌어진 겁니다. (결국은 HHH-11427 버그 때문입니다.)

이것을 제대로 해결하려면 ImplicitNamingStrategy의 구현을 수정해야겠지만, 간단하게는 컬럼명을 직접 지정해서 해결할 수 있습니다.

@Entity
@IdClass(Name::class)
data class Person(
@get:Id
@get:Column(name = "first_name") // <- 컬럼명 지정
var firstName: String,
@get:Id
@get:Column(name = "last_name") // <- 컬럼명 지정
var lastName: String,
var phoneNumber: String
)

정리

  • kotlin-jpa 컴파일러 플러그인 사용하자.
  • kotlin-allopen 컴파일러 플러그인 사용하자. (javax.persistence.Embeddable 추가해주자.)
  • Embeddable의 속성은 var로 선언하자.
  • 속성에 어노테이션 붙일 때는 getter에 붙이자. (@get:)
  • IdClass의 생성자에는 모두 기본값을 달아주자.
  • ImplicitNamingStrategyComponentPathImpl 쓰면 편리하다.
  • ImplicitNamingStrategyComponentPathImplIdClass 같이 쓰려면 컬럼명을 지정해주자.

· 약 9분

Kotlin에서 JPA를 사용해봅시다! Java에서 쓸 때와 별로 다를 것은 없습니다. 하지만 엔티티 클래스를 데이터 클래스로 선언하였을 때 런타임 프록시 객체를 사용하는 Hibernate/JPA의 기능들이 잘 작동하지 않을 수 있어 주의가 필요합니다.

프로젝트 세팅

예제 프로젝트는 Spring Boot를 사용하겠습니다. 하지만 다른 프레임워크에도 마찬가지로 적용되는 내용입니다.

build.gradle, Application.kt

크게 중요하지 않아서 Gist 링크로 대체합니다.

User.kt

엔티티 클래스입니다.

package org.sapzil

import javax.persistence.*

@Entity
data class User(@Id
@GeneratedValue(strategy = GenerationType.AUTO)
var id: Int? = null,
var name: String)

UserRepository.kt

package org.sapzil

import org.springframework.data.repository.CrudRepository

interface UserRepository : CrudRepository<User, Int> {
fun findByName(name: String): User?
}

Demo.kt

package org.sapzil

import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.test.context.junit4.SpringRunner
import javax.persistence.EntityManager

@RunWith(SpringRunner::class)
@DataJpaTest(showSql = true)
open class Demo {
@Autowired lateinit var userRepository: UserRepository
@Autowired lateinit var entityManager: EntityManager

@Before
fun setup() {
userRepository.save(User(name = "alice"))
userRepository.save(User(name = "bob"))
entityManager.clear()
}

@Test
fun simple() {
println(userRepository.findByName("bob"))
}
}

테스트 코드를 실행해보면 오류가 발생합니다.

org.springframework.orm.jpa.JpaSystemException: No default constructor for entity:  : org.sapzil.User; nested exception is org.hibernate.InstantiationException: No default constructor for entity:  : org.sapzil.User

기본 생성자가 없다고 합니다.

기본 생성자를 만들자

사실, 위의 엔티티 선언에는 문제가 있습니다. JPA 엔티티 클래스에는 기본 생성자(다른 말로는, 인자 없는 생성자)가 반드시 필요합니다.

당연히, 기본 생성자를 추가해주면 됩니다.

User.kt (수정 - 예시)

@Entity
data class User(...) {
constructor() : this(null, "")
}

하지만 모든 필드에 기본값을 채워줘야 하니 귀찮습니다. 어차피 JPA가 객체를 생성한 다음에 알아서 값을 채워줄텐데요.

kotlin-jpa 컴파일러 플러그인을 쓰면 @Entity 등의 어노테이션이 붙은 클래스에 자동으로 기본 생성자를 만들도록 할 수 있습니다. build.gradle에 다음 내용을 추가합니다.

buildscript {
dependencies {
classpath "org.jetbrains.kotlin:kotlin-noarg:$kotlin_version"
}
}

apply plugin: "kotlin-jpa"

실제로 만들어지는지 바이트코드를 까서 확인해봅시다. IntelliJ에서요. Gradle 임포트 후 Build > Rebuild Project 한 다음 User.kt에서 Tools > Kotlin > Show Kotlin Bytecode를 실행하면...

  // access flags 0x1
public <init>()V
L0
ALOAD 0
INVOKESPECIAL java/lang/Object.<init> ()V
RETURN
L1
LOCALVARIABLE this Lkotlin/Unit; L0 L1 0
MAXSTACK = 1
MAXLOCALS = 1

기본 생성자가 추가된 것을 알 수 있습니다. 이제는 테스트도 통과합니다.

Hibernate: select user0_.id as id1_1_, user0_.name as name2_1_ from user user0_ where user0_.name=?
User(id=2, name=bob)

데이터 클래스로 선언했으니 toString()도 자동으로 구현된 것을 알 수 있습니다. 매우 편리하네요.

@ManyToOne과 지연 로딩 문제

이제 새로운 Post 엔티티를 추가해봅시다.

Post.kt

package org.sapzil

import javax.persistence.*

@Entity
data class Post(@Id
@GeneratedValue(strategy = GenerationType.AUTO)
var id: Int? = null,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
var user: User,
var content: String)

PostRepository.kt

package org.sapzil

import org.springframework.data.repository.CrudRepository

interface PostRepository : CrudRepository<Post, Int>

Demo.kt (코드 추가)

@Autowired lateinit var postRepository: PostRepository

@Test
fun lazy() {
val bob = userRepository.findByName("bob")!!
val postId = postRepository.save(Post(user = bob, content = "Hello world")).id!!
entityManager.clear()
println("*** EntityManager cleared")

val post = postRepository.findOne(postId)
println("... Accessing post.user.id")
println(post.user.id)
println("... Accessing post.user.name")
println(post.user.name)
}

Post를 가져온 뒤, 연관된 User에 접근하는 테스트 코드입니다. 실행해봅시다.

*** EntityManager cleared
Hibernate: select post0_.id as id1_0_0_, post0_.content as content2_0_0_, post0_.user_id as user_id3_0_0_ from post post0_ where post0_.id=?
Hibernate: select user0_.id as id1_1_0_, user0_.name as name2_1_0_ from user user0_ where user0_.id=? <- ???
... Accessing post.user.id
4
... Accessing post.user.name
bob

쿼리 로그에서 알 수 있듯이 Post를 가져올 때 User까지 동시에 가져와져 버렸습니다. 즉 지연 로딩이 작동하지 않은 것입니다.

지연 로딩을 하려면 프록시 객체를 만들어야 하는데, Kotlin의 모든 클래스는 final이라 상속을 받을 수 없습니다. 일반 클래스는 open할 수 있지만 데이터 클래스는 불가능합니다.

사실, JPA 표준에서는 엔티티 클래스가 final이면 안됩니다. 하지만 이 예제에서는 Hibernate를 JPA 구현체로 사용하기 때문에 어떻게든 작동하긴 하는 것 같네요.

아무튼 이번에도 컴파일러 플러그인의 도움을 받아서 엔티티 클래스를 확장할 수 있도록 만듭니다. build.gradle에 다음 내용을 추가합니다.

buildscript {
dependencies {
classpath "org.jetbrains.kotlin:kotlin-allopen:$kotlin_version"
}
}

apply plugin: "kotlin-allopen"

allOpen {
annotation "javax.persistence.Entity"
}

Gradle 임포트와 Rebuild를 한 다음 테스트를 다시 실행해보면...

*** EntityManager cleared
Hibernate: select post0_.id as id1_0_0_, post0_.content as content2_0_0_, post0_.user_id as user_id3_0_0_ from post post0_ where post0_.id=?
... Accessing post.user.id
Hibernate: select user0_.id as id1_1_0_, user0_.name as name2_1_0_ from user user0_ where user0_.id=?
4
... Accessing post.user.name
bob

post.user에 접근할 때가 돼서야 User를 불러왔습니다. 이번에는 제대로 지연 로딩이 작동한 것을 확인할 수 있습니다.

연관 객체의 ID에 쿼리 없이 접근

아래 내용은 이제 사실이 아닙니다. Hibernate 5.2.13/5.3에서 문제가 수정되어 필드 접근 모드에서도 ID 접근시 엔티티가 로드되지 않습니다. 읽을 때 참고 바랍니다.

post.user.id는 사실 User를 쿼리해보지 않아도 post.user_id 컬럼으로 알아낼 수 있습니다. 알고보면 Hibernate에서 원래 지원하는 기능입니다. 하지만 위의 예제 코드에서는 작동하지 않았죠. 왜일까요?

데이터 클래스의 필드에 어노테이션을 달면, getter 메소드가 아니라 JVM 필드에 어노테이션이 달립니다. 그러면 프로퍼티 접근 모드가 아니라 필드 접근 모드가 되고, 이 경우 Hibernate는 지연 로딩을 지원하지 않습니다.

어쨌든 getter에 어노테이션이 붙도록 수정하면 됩니다.

User.kt (수정)

@Entity
data class User(@get:Id
@get:GeneratedValue(strategy = GenerationType.AUTO)
var id: Int? = null,
var name: String)

Rebuild 후 테스트를 다시 실행해보면...

*** EntityManager cleared
Hibernate: select post0_.id as id1_0_0_, post0_.content as content2_0_0_, post0_.user_id as user_id3_0_0_ from post post0_ where post0_.id=?
... Accessing post.user.id <- 이때는 쿼리가 안날아감
4
... Accessing post.user.name
Hibernate: select user0_.id as id1_1_0_, user0_.name as name2_1_0_ from user user0_ where user0_.id=?
bob

post.user.name에 접근할 때 User를 가져오는 것을 확인할 수 있습니다.

세 줄 요약

  • kotlin-jpa 컴파일러 플러그인을 써서 기본 생성자를 자동으로 추가하자.
  • kotlin-allopen 컴파일러 플러그인을 써서 엔티티 클래스를 상속 가능하게 만들자.
  • JPA 어노테이션은 getter에 달자.

고민해볼 점

  • 데이터 클래스를 쓸 때도 equals()hashCode()를 오버라이드 해야하나? toString()은?
  • 모든 필드를 데이터 클래스의 생성자에 선언해야 하나? @OneToMany 붙은 콜렉션은?
  • 이쯤되면 JPA가 문제인 것 같다. 굳이 JPA를 써야하나? 😂