스프링 공식 문서에서 Spring Boot와 Kotlin을 같이 사용할 수 있는 튜토리얼을 제공한다. 튜토리얼에서 Spring Web Application과 JPA를 사용하기 위해 기본적인 의존성과 플러그인, gradle task의 예시를 명시해 줬다. 이를 하나하나 살펴보자.
플러그인
plugins {
kotlin("plugin.jpa") version "1.4.32"
id("org.springframework.boot") version "2.4.4"
id("io.spring.dependency-management") version "1.0.11.RELEASE"
kotlin("jvm") version "1.4.32"
kotlin("plugin.spring") version "1.4.32"
}
의존성
dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
}
task
tasks.withType<KotlinCompile> {
kotlinOptions {
freeCompilerArgs = listOf("-Xjsr305=strict")
jvmTarget = "1.8"
}
}
문서에서는 스프링을 사용하기 위해서 플러그인 kotlin("plugin.spring")
JPA를 사용하기 위해 kotlin("plugin.jpa")
를 사용해야 한다고 명시되어있다.
우선 코틀린의 All-open 플러그인을 알아야 한다. 코틀린의 클래스와 멤버(프로퍼티, 함수)는 default
설정으로 final
로 설정되어 있다. 그래서 클래스를 상속받을 수 없고 프로퍼티의 getter, setter 및 메서드는 오버라이드 할 수 없다.
하지만 스프링 프레임워크와 Hibernate는 CGLIB(Code Generation Library)를 기본 바이트 코드 조작 라이브러리로 사용하고 있다. CGLIB는 상속(extends
)을 기반으로 프록시 기술을 사용하기 때문에 final class
는 프록시를 사용할 수 없어 에러가 발생하게 된다. 코틀린에서는 클래스와 멤버를 상속하거나 오버라이드하기 위해서는 open
키워드를 명시해줘야 한다. All-open 플러그인은 특정 어노테이션이 붙어있는 클래스와 멤버를 모두 open
키워드를 붙여주는 플러그인이다.
all-open 플러그인을 예제로 알아볼 수 있다. 일반 클래스의 경우 상속을 받을 수 없다.
class ParentClass {
var name: String? = null
fun changeName(name: String) {
this.name = name
}
}
class ChildClas : ParentClass() // 컴파일 에러
자바로 변환하면 아래와 같다. 클래스 및 getter, setter, method에 모두 final이 붙은걸 확인할 수 있다.
public final class ParentClass {
@Nullable
private String name;
@Nullable
public final String getName() {
return this.name;
}
public final void setName(@Nullable String var1) {
this.name = var1;
}
public final void changeName(@NotNull String name) {
Intrinsics.checkNotNullParameter(name, "name");
this.name = name;
}
}
all-open 플러그인을 연동해보자. anootation을 만들어서 부모 클래스에 붙여준다.
@com.example.ktdemo.annotation.MyAnnotation
annotation class MyAnnotation
@MyAnnotation
class ParentClass {
var name: String? = null
fun changeName(name: String) {
this.name = name
}
}
class ChildClass : ParentClass()
all-open 플러그인을 적용한 뒤 어노테이션의 경로를 붙여준다.
plugins {
id("org.jetbrains.kotlin.plugin.allopen") version "1.6.21"
}
allOpen {
annotation("com.example.ktdemo.annotation.MyAnnotation")
}
컴파일 에러가 사라졌다.
컴파일 과정에서 open
키워드를 붙여준다. 컴파일된 코드는 아래와 같다.
@com.example.ktdemo.annotation.MyAnnotation
public open class ParentClass public constructor() {
public open var name: kotlin.String? /* compiled code */
public open fun changeName(name: kotlin.String): kotlin.Unit { /* compiled code */ }
}
이를 자바로 변환하면 아래와 같다. final
키워드가 모두 사라졌다.
@MyAnnotation
public class ParentClass {
@Nullable
private String name;
@Nullable
public String getName() {
return this.name;
}
public void setName(@Nullable String <set-?>) {
this.name = var1;
}
public void changeName(@NotNull String name) {
Intrinsics.checkNotNullParameter(name, "name");
this.setName(name);
}
}
CGLIB는 클래스의 바이트코드를 조작하여 런타임에 동적으로 자바 클래스의 Proxy 객체를 생성해주는 라이브러리다. Spring Boot CGLIB를 default 프록시 설정으로 되어있고 AOP를 구현할 때 사용하고 있다. (스프링 4 버전부터 Spring-Core에 CGLIB가 default 설정) 또한 Hibernate에서 lazy loaded object를 사용하기 위한 용도로도 사용하고 있다.
CGLIB는 Target Class를 상속받아 생성을 하는 방식이다. 따라서 final로 선언된 클래스는 프록시 객체를 생성하지 못한다. 따라서 Spring 및 Hibernate 에서 사용하는 클래스들을 open을 해줘야 할 필요가 있다. 하지만 그 때 그 때 모든 클래스와 멤버에 open 키워드를 명시하는 것은 효율적이지 않으므로 all-open을 해줄 필요가 있다.
[Spring] Proxy (1) Java Dynamic Proxy vs. CGLIB
스프링 프록시 기반 AOP (2) - CGLIB Proxy
kotlin("plugin.spring") version "1.6.10"
스프링 플러그인은 내부적으로 특정 어노테이션에 대해 all-open이 적용되어 있다.
@Component
@Async
@Transactional
@Cacheable
@SpringBootTest
또한 @Component
를 사용하는 아래 어노테이션에도 all-open이 적용된다.
@Configuration, @Controller, @RestController, @Service, @Repository
kotlin("plugin.jpa") version "1.6.10"
JPA는 Entity를 사용할 객체에 반드시 기본 생성자가 있어야 한다. 이유는 Entity를 생성할 때 자바의 리플렉션(Reflection)을 사용하기 때문이다. Reflection API로 가져올 수 없는 정보 중 하나가 생성자의 인자 정보이다. 그래서 기본 생성자가 있어야만 객체를 생성할 수 있다. JPA는 기본 생성자로 객체를 만들어준 뒤 리플렉션으로 필드 정보를 만들어 DB의 값을 주입하는 방식으로 엔티티를 생성한다.
JPA requires that this constructor be defined as public or protected. Hibernate, for the most part, does not care about the constructor visibility, as long as the system SecurityManager allows overriding the visibility setting. That said, the constructor should be defined with at least package visibility if you wish to leverage runtime proxy generation.
Hibernate ORM 5.2.18.Final User Guide
jpa 플러그인은 엔티티 관련 어노테이션인 @Entity
, @Embeddable
, @MappedSuperClass
이 붙어있는 클래스에 자동으로 기본 생성자를 생성해준다. 내부적으로 기본생성자를 만들어주는 noarg 플러그인이 적용된 것과 같다고 볼 수 있다.
plugins {
kotlin("plugin.noarg") version "1.3.71"
}
noArg {
annotation("javax.persistence.Entity")
annotation("javax.persistence.MappedSuperclass")
annotation("javax.persistence.Embeddable")
}
JPA의 엔티티는 기본생성자를 pulbic
, protected
로 지정해야만 사용할 수 있다. Java에서는 롬복을 활용하여 @NoArgsConstructor(access = AccessLevel.PROTECTED)
으로 기본 생성자를 만들어주고 접근 제한을 public하지 않게 지정을 해주는데 코틀린의 jpa 플러그인은 리플렉션시에만 사용할 수 있는 기본 생성자를 제공해줘서 따로 접근 제한을 해주지 않아도 된다.
The generated constructor is synthetic so it can’t be directly called from Java or Kotlin, but it can be called using reflection. This allows the Java Persistence API (JPA) to instantiate a class although it doesn't have the zero-parameter constructor from Kotlin or Java point of view (see the description of kotlin-jpa plugin)
리플렉션은 런타임시 동작하기 때문에 플러그인 적용하지 않아도 컴파일 에러가 발생하지 않는다. 쿼리가 날라갈 때 에러가 발생할 것이다.
앞서 말했듯이 Hibernate는 CGLIB를 사용해서 프록시 객체를 만드는 방식으로 지연로딩 기능을 구현했다. final이 붙은 class는 프록시 객체를 생성하지 못하므로 open이 붙어있지 않은 엔티티는 LAZY 타입으로 설정을 해줘도 EAGER 방식으로 객체를 가져오기 때문에 연관관계가 맺어있는 모든 객체를 조회한다.
@Entity
class Foo {
@Id
var id: Long? = null
@OneToMany(fetch = FetchType.LAZY)
var people: List<Person>? = null
}
@Entity
class Person {
@Id
var id: Long? = null
}
따라서 Entity들을 모두 open 클래스로 만들어줘야 하는데 이때 allOpen 플러그인을 사용해준다.
plugins {
id("org.jetbrains.kotlin.plugin.allopen") version "1.6.21"
}
allOpen {
annotation("javax.persistence.Entity")
annotation("javax.persistence.MappedSuperclass")
annotation("javax.persistence.Embeddable")
}
Spring Initializr로 Spring Web MVC 프로젝트를 생성하면 아래 의존성이 자동으로 추가된다.
dependencies {
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
}
기존 Jackson으로 deserialize를 하기 위해서는 기본 생성자가 필요하다. 코틀린에서는 DTO로 data class를 사용해서 만들게 되는데 data class 객체를 deserialize하려면 기본 생성자가 없기 때문에 에러가 발생한다.
(no Creators, like default construct, exist): cannot deserialize from Object valu...
data class는 all argument 생성자만 생성하기 때문에 기본 생성자가 생성되지 않는다. 따라서 deserialize를 하지 못한다. jackson-module-kotlin를 사용하게 되면 단일 생성자로도 deserialize를 진행할 수 있다.
자동으로 ObjectMapper를 주입받으면 코틀린 모듈이 적용된 매퍼를 받지만 직접 등록하려면 위의 코드 처럼 registerKotlinModule()를 사용해줘야 한다.
자바에서 내부적으로 코틀린 디텍터가 코틀린 사용하고 있는지를 확인해준다.
dependencies {
implementation("org.jetbrains.kotlin:kotlin-reflect")
}
코틀린은 런타임 라이브러리 용량을 줄이기 위해 기본적으로 reflect를 제공하지 않는다. 그래서 의존성을 추가해줘야 사용할 수 있다. 코틀린은 자바와 호환이 되기 때문에 자바 reflection을 사용할 수는 있지만 코틀린 클래스에 사용하기에는 적합하지는 않는다. 코틀린 클래스 및 프로퍼티를 리플렉션할 수 있는 코틀린 리플렉션을 제공한다. 하지만 자바 리플렉션의 기능을 100% 지원하지는 못한다. Spring Framework 5 부터 리플렉션을 추가해야만 한다.
dependencies {
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
}
jdk8 기능과 호환되는 코틀린 표준 라이브러리를 제공한다.
kotlin-stdlib-jdk11가 없는이유
Why is there no kotlin-stdlib-jdk11?
tasks.withType<KotlinCompile> {
kotlinOptions {
freeCompilerArgs = listOf("-Xjsr305=strict")
jvmTarget = "11"
}
}
자바의 type-system에서는 null-safety를 표현하는 문법이 없다. 코틀린에서 사용되는 Java API의 유형은 널 검사가 완화된 플랫폼 타입으로 인식된다. 그런데 코틀린의 코드를 jvm에서 사용할 때 nullability check를 해줄 수 있는 문법을 지정해야 한다. nullablility를 check하는 API로는 아래의 방법이 있다.
JSR 305 어노테이션과 스프링 nullability 어노테이션은 Spring Framework API에 대한 null 안전성을 제공하여 컴파일 타임에서 null 관련 문제를 해결할 수 있다. strict 옵션과 -Xjsr305 컴파일러 플래그를 추가하면 사용할 수 있다.
All-open compiler plugin | Kotlin
No-arg compiler plugin | Kotlin
JDK Dynamic Proxy와 CGLib를 알아보자 #2
Kotlin으로 Spring 개발할 때 - Yun Blog | 기술 블로그
[JPA] 왜 JPA의 Entity는 기본 생성자를 가져야 하는가?
코틀린에서 하이버네이트를 사용할 수 있을까? | 우아한형제들 기술블로그
Java Reflection API에 대하여 (JPA에서 기본 생성자가 반드시 필요한 이유)
Reflection API 간단히 알아보자.
https://blog.junu.dev/37
코틀린과 Hibernate, CGLIB, Proxy 오해와 재대로된 사용법 - (1)
Kotlin으로 Spring 개발할 때 - Yun Blog | 기술 블로그