Java Spring 프로젝트에서 Kotlin 사용하기

Glen·2024년 7월 18일
1

배운것

목록 보기
36/37
post-thumbnail

서론

Spring으로 프로젝트를 만들면 대부분은 자바를 사용한다.

하지만 Spring은 자바뿐 아니라, Groovy, Scala, 코틀린 등 JVM 위에서 돌아가는 언어라면 굳이 자바가 아니어도 사용할 수 있다.

이때 주로 Kotiln을 주로 자바 대신 사용하는데, 코틀린의 특징 중 하나는 자바와 매우 높은 상호 호환성을 가지고 있다는 점이다.

따라서 자바로 만들어진 Spring 프로젝트에서 코틀린을 사용할 수 있고 역으로, 코틀린으로 된 프로젝트에서 자바 또한 사용 가능하다.

그러면 기존 자바로 만들어진 프로젝트에 코틀린을 사용하는 법을 알아보자.

본론

먼저 간단하게 자바로 만들어진 Spring 프로젝트가 있다고 가정한다.

자바와 코틀린이나 JVM 위에서 돌아가기에 코틀린 파일을 자바 프로젝트에 추가하고 임포트하여 사용하면 될 것 같지만, 실행해 보면 컴파일 에러가 발생하며 실행이 되지 않는다.

이유는 JVM이 실행하기 위해 필요한 언어는 자바가 아니라 바이트 코드(.class)가 필요하기 때문이다.

따라서 자바를 자바 컴파일러가 바이트 코드로 변환해 주듯이, 코틀린 파일 또한 바이트 코드로 변환하는 과정이 필요하다.

이를 위해 Gradle에서 컴파일 과정에 코틀린 파일도 컴파일하는 작업을 수행해야 한다.

기존 자바를 사용했을 때도 compileJava 라는 Gradle Task가 필요했다.

따라서 build.gradle에 코틀린 컴파일을 위한 코드를 추가하면 된다.

기존 build.gradle

plugins {  
    id 'java'  
    id 'org.springframework.boot' version '3.3.1'  
    id 'io.spring.dependency-management' version '1.1.5'  
}  
  
group = 'com'  
version = '0.0.1-SNAPSHOT'  
  
java {  
    toolchain {  
        languageVersion = JavaLanguageVersion.of(17)  
    }  
}  
  
configurations {  
    compileOnly {  
        extendsFrom annotationProcessor  
    }  
}  
  
repositories {  
    mavenCentral()  
}  
  
dependencies {  
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'  
    implementation 'org.springframework.boot:spring-boot-starter-jdbc'  
    implementation 'org.springframework.boot:spring-boot-starter-validation'  
    implementation 'org.springframework.boot:spring-boot-starter-web'  
    compileOnly 'org.projectlombok:lombok'  
    runtimeOnly 'com.h2database:h2'  
    runtimeOnly 'com.mysql:mysql-connector-j'  
    annotationProcessor 'org.projectlombok:lombok'  
    testImplementation 'org.springframework.boot:spring-boot-starter-test'  
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'  
}  
  
tasks.named('test') {  
    useJUnitPlatform()  
}  

수정된 build.gradle

plugins {  
    id 'java'  
    id 'org.springframework.boot' version '3.3.1'  
    id 'io.spring.dependency-management' version '1.1.5'  
    id 'org.jetbrains.kotlin.jvm' version '2.0.0'  
    id 'org.jetbrains.kotlin.plugin.lombok' version '2.0.0'  
    id 'org.jetbrains.kotlin.plugin.spring' version '2.0.0'  
    id 'org.jetbrains.kotlin.plugin.jpa' version '2.0.0'  
}  
  
group = 'com'  
version = '0.0.1-SNAPSHOT'  
  
java {  
    toolchain {  
        languageVersion = JavaLanguageVersion.of(17)  
    }  
}  
  
configurations {  
    compileOnly {  
        extendsFrom annotationProcessor  
    }  
}  
  
repositories {  
    mavenCentral()  
}  
  
compileKotlin {  
    kotlinOptions {  
        jvmTarget = '17'  
    }  
}  
  
dependencies {  
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'  
    implementation 'org.springframework.boot:spring-boot-starter-jdbc'  
    implementation 'org.springframework.boot:spring-boot-starter-validation'  
    implementation 'org.springframework.boot:spring-boot-starter-web'  
    compileOnly 'org.projectlombok:lombok'  
    runtimeOnly 'com.h2database:h2'  
    runtimeOnly 'com.mysql:mysql-connector-j'  
    annotationProcessor 'org.projectlombok:lombok'  
    testImplementation 'org.springframework.boot:spring-boot-starter-test'  
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'  
  
    implementation "org.jetbrains.kotlin:kotlin-stdlib"  
    implementation "org.jetbrains.kotlin:kotlin-reflect"  
}  
  
tasks.named('test') {  
    useJUnitPlatform()  
}  
  
allOpen {  
    annotation("jakarta.persistence.Entity")  
    annotation("jakarta.persistence.Embeddable")  
    annotation("jakarta.persistence.MappedSuperclass")  
}

추가한 설정들을 조금 자세히 알아보자.

plugin

plugin 블럭에 다음과 같은 설정이 추가되었다.

id 'org.jetbrains.kotlin.jvm' version '2.0.0'  
id 'org.jetbrains.kotlin.plugin.lombok' version '2.0.0'  
id 'org.jetbrains.kotlin.plugin.spring' version '2.0.0'  
id 'org.jetbrains.kotlin.plugin.jpa' version '2.0.0'  

org.jetbrains.kotlin.jvm

코틀린 파일을 바이트 코드로 컴파일 해주고, 빌드 스크립트 설정 등 코틀린을 사용한다면 필수로 적용해야 할 플러그인이다.

org.jetbrains.kotlin.plugin.lombok

필수는 아니지만, 기존 프로젝트에서 Lombok을 사용하고 있었다면 적용해야 한다.

만약 적용하지 않는다면 코틀린에서 자바 클래스의 필드에 접근할 때, @Getter와 같은 어노테이션을 인식하지 못해 컴파일이 불가능한 상황이 발생한다.

org.jetbrains.kotlin.plugin.spring

스프링을 사용하면 적용해야 하는 플러그인이다.

스프링이 제공하는 기능 중 하나는 AOP인데, 이는 동적 프록시 라이브러리를 통해 구현된다.

이는 원본 클래스를 상속하는 프록시 객체를 만들어 사용하는데, 코틀린은 기본적으로 모든 클래스에 대해 상속이 막혀있다.

따라서 스프링의 특정 어노테이션이 붙어있는 경우 상속이 가능하게 만들어, 스프링의 동작에 문제가 없도록 한다.

org.jetbrains.kotlin.plugin.jpa

JPA를 사용하면 @Entity 어노테이션을 사용하여 엔티티 클래스를 구성해야 한다.

엔티티 클래스가 반드시 만족해야 하는 것 중 하나는 인자가 없는 생성자를 반드시 구현해야 한다.

하지만 코틀린을 사용하면 다음과 같이 생성자를 만들 수 있다.

@Entity  
class MyEntity(  
    @Id  
    @GeneratedValue(strategy = GenerationType.IDENTITY)  
    private var id: Long? = null,  
    private var name: String  
) {  
  
}

하지만 이 경우 생성자가 하나 밖에 생성되지 않으므로 엔티티의 조건을 만족시킬 수 없다.

따라서 다음과 같이 코드를 작성해야 한다.

@Entity  
class MyEntity(
    
) {  
    constructor()  
  
    constructor(name: String) {  
        this.name = name  
    }  
  
    @Id  
    @GeneratedValue(strategy = GenerationType.IDENTITY)  
    val id: Long? = null  
    var name: String? = null
}

하지만, 이 경우 모든 필드가 nullable하게 되므로 코틀린이 제공하는 장점을 제대로 활용할 수 없다.

따라서 해당 플러그인을 적용하면 다음과 같이 엔티티를 구성할 수 있다.

@Entity  
class MyEntity(name: String) {  
  
    @Id  
    @GeneratedValue(strategy = GenerationType.IDENTITY)  
    val id: Long? = null  
    var name: String = name  
        protected set  
}

JPA에서 영속된 엔티티를 불러올 때 인자가 없는 생성자로 객체를 생성 후 리플렉션으로 값을 주입하기에 인자가 없는 생성자가 반드시 필요하다.

compileKotlin

자바를 컴파일하듯이 코틀린도 컴파일을 해야한다.

이 과정에서 어떠한 JVM 버전으로 컴파일할지 명시한다.

dependencies

implementation "org.jetbrains.kotlin:kotlin-stdlib"  
implementation "org.jetbrains.kotlin:kotlin-reflect" 

org.jetbrains.kotlin:kotlin-stdlib

코틀린 표준 라이브러리를 추가해준다.

하지만 사실 추가하지 않아도 되는데, 코틀린 플러그인 1.4 이상부터 해당 종속성은 기본으로 추가되기 때문이다.

하지만 명시적으로 포함을 시켰다.

Java 8을 사용한다면 org.jetbrains.kotlin:kotlin-stdlib-jdk8을 사용하면 될 것 같다

org.jetbrains.kotlin:kotlin-reflect

해당 의존을 추가하지 않으면 컴파일 에러가 발생한다.

정확한 이유는 알 수 없지만, 코틀린 컴파일을 수행할 때 리플렉션을 사용해야 하는데 기본적으로 리플렉션 기능을 사용할 수 없기에 해당 의존이 필요한 것 같다.

allOpen

annotation("jakarta.persistence.Entity")  
annotation("jakarta.persistence.Embeddable")  
annotation("jakarta.persistence.MappedSuperclass")  

allOpen 블럭에서 해당 코드를 추가하는 이유는 코틀린 클래스는 기본적으로 final이기 때문이다.

하지만 JPA에서 지연 로딩 구현을 위해 프록시 객체를 만드는데, final 키워드 때문에 프록시를 만들 수 없다. (상속불가)

따라서 JPA 어노테이션이 붙은 클래스는 final을 제거하여 상속이 가능하게 만들어 지연 로딩이 가능하게 해준다.

QueryDSL과 함께 사용 시 이슈

만약 기존 프로젝트에 QueryDSL이 사용되고 있다면 코틀린으로 작성한 엔티티가 QClass로 변환되지 않는 상황이 생긴다.

이는 자바 컴파일러가 Annotation Processing을 수행하는 과정에서 코틀린 코드를 인식할 수 없기 때문이다.

따라서 코틀린 코드의 Annotation Proccessing을 지원하도록 kapt 플러그인을 추가해야 한다.

plugins {  
    // ...
    id 'org.jetbrains.kotlin.kapt' version '2.0.0'  
}

그리고 dependencies 블럭에 다음과 같이 QueryDSL 의존을 추가한다.

dependencies {
    //...
    
    implementation "com.querydsl:querydsl-jpa:5.0.0:jakarta"  
    kapt "com.querydsl:querydsl-apt:5.0.0:jakarta"  
    kapt "jakarta.annotation:jakarta.annotation-api"  
    kapt "jakarta.persistence:jakarta.persistence-api"
}

그리고 어플리케이션을 실행하면 문제 없이 실행되는 것 같지만, 빌드를 수행하면 variable (변수명) not initialized in the default constructor 에러와 함께 빌드가 되지 않는다.

이유는 kapt 플러그인은 자바 컴파일러에 대한 Annotation Process 처리를 비활성화 시키기 때문이다.

이 때문에 롬복이 적용되지 않아 해당 에러가 발생하는 것이다.

따라서 다음 코드 블럭을 추가해야 한다.

kapt {  
    keepJavacAnnotationProcessors = true  
}

그리고 빌드를 하면 정상적으로 빌드가 되고, 자바와 코틀린 코드로 작성된 엔티티에 대해 QClass 파일 또한 생성되는 것을 확인할 수 있다.

compileJava를 수행해도 된다.

결론

간단하게 자바로 작성된 스프링 프로젝트에서 코틀린을 함께 사용하는 법을 알아봤다.

회사의 모든 프로젝트가 자바로 작성되어 있는데, 코틀린을 사용하다 자바 코드를 보니 구린 가독성과 자바 자체의 한계를 뛰어넘을 수 없는 슬픈 사실을 계속 마주한다.

기존 레거시는 적용하기 힘들어도, 최근 프로젝트는 자바 17을 사용하는데 여기서 추가로 코틀린을 사용하여 가독성과 생산성을 매우 높일 수 있을 것 같다.

한 가지 도구만 사용하라는 규칙은 없으니, 여러 도구를 함께 사용해서 문제를 해결하는 게 좋은 엔지니어가 가져야 할 덕목 중 하나가 아닐까 한다.

물론 잘못 사용할 확률이 높아지니 적절하게 사용하는 안목도 함께 갖출 필요도 있다. 😂

profile
꾸준히 성장하고 싶은 사람

0개의 댓글