Web/Kotlin & Spring

[Kotlin & Spring] - 자바 프로젝트에 코틀린 도입하기

Hyunseo😊 2023. 6. 28. 14:37

JvmStatic을 이용해 정적 함수 호출하기

  • 우선 동반 객체와 objet 키워드로 정의된 싱글턴 객체에서 정의된 함수를 코틀린에서 사용하면 자바의 static 메서드와 유사하게 사용할 수 있습니다.
package jvmstatic

/**
 * @author ihyeonseo
 */

class HelloClass {
    companion object {
        @JvmStatic
        fun hello() = "hello!"
    }
}

object HiObject {
    @JvmStatic
    fun hi() = "hi!"
}

fun main() {
    val hello = HelloClass.hello()
    println(hello)
    val hi = HiObject.hi()
    println(hi)
}
  • 이렇게 정의된 함수를 자바에서 사용할 경우 아래와 같이 호출해야 합니다.
  • 위에서는 각 함수 위에 @JvmStatic을 사용하였는데, 이러면 자바의 static 메서드처럼 사용할 수 있습니다.
package jvmstatic;

/**
 * @author ihyeonseo
 */
public class JvmStaticExample {

    public static void main(String[] args) {
        String hello = HelloClass.hello();
        System.out.println(hello);

        String hi = HiObject.hi();
        System.out.println(hi);
    }
}

잘 실행되는 것을 볼 수 있습니다.

 

JvmField로 정적 프로퍼티 호출하기

  • 코틀린은 기본적으로 static 프로퍼티를 지원하지 않습니다.
  • 동반 객체와 object키워드로 정의된 싱글턴 객체를 사용하면 static 프로퍼티와 유사하게 사용할 수 있습니다.
package jvmstatic

/**
 * @author ihyeonseo
 */
class JvmFieldClass {
    companion object {
        val id = 1234

        const val CODE = 1234
    }
}

object JvmFieldObject {
    val name = "tony"

    const val FAMILIY_NAME = "stark"
}

fun main() {
    val id = JvmFieldClass.id

    val name = JvmFieldObject.name
}

위와같은 코틀린 클래스와 그 안에 동반 객체를 생성했다 합시다. 코틀린에서 위와같이 호출할 수 있습니다.

package jvmstatic;

/**
 * @author ihyeonseo
 */
public class JvmFieldExample {
    public static void main(String[] args) {
        int id = JvmFieldClass.Companion.getId();
        int code = JvmFieldClass.CODE;
        System.out.println("id = " + id);
        System.out.println("code = " + code);
    }
}

  • 그리고 상수가 아닌 경우에도 자바의 static프로퍼티와 같이 사용하려면 @JvmField를 사용하면 됩니다.
package jvmstatic

/**
 * @author ihyeonseo
 */
class JvmFieldClass {
    companion object {
        @JvmField
        val id = 1234

        const val CODE = 1234
    }
}

object JvmFieldObject {
    @JvmField
    val name = "tony"

    const val FAMILIY_NAME = "stark"
}

fun main() {
    val id = JvmFieldClass.id
    val name = JvmFieldObject.name
}
package jvmstatic;

/**
 * @author ihyeonseo
 */
public class JvmFieldExample {
    public static void main(String[] args) {
        int id = JvmFieldClass.id;
        int code = JvmFieldClass.CODE;
        System.out.println("id = " + id);
        System.out.println("code = " + code);
    }
}

확장 함수 호출하기

package extensions

/**
 * @author ihyeonseo
 */


fun String.first(): Char {
    return this[0]
}

fun String.addFirst(char: Char): String {
    return char + this.substring(0)
}


fun main() {

    println("ABCD".first())
    println("ABCD".addFirst('Z'))

}
  • 자바에서 코틀린과 같이 사용하면 컴파일 에러가 발생합니다.
package extensions;

/**
 * @author ihyeonseo
 */
public class ExtensionExample {
    public static void main(String[] args) {
        char first = MyExtensionsKt.first("ABCD");
        System.out.println("first = " + first);

        String string = MyExtensionsKt.addFirst("ABCD", 'Z');
        System.out.println("string = " + string);
    }
}
  • 이렇게 호출해야 하는 이유는, 실제로 자바로 그냥 코틀린에서처럼 "ABCD".first()를 호출하게 되면 아래와 같이 디컴파일 되는데
public final class MyExtensionsKt {
   public static final char first(@NotNull String $this$first) {
      Intrinsics.checkNotNullParameter($this$first, "$this$first");
      return $this$first.charAt(0);
}
   @NotNull
   public static final String addFirst(@NotNull String $this$addFirst, char var1) {
      Intrinsics.checkNotNullParameter($this$addFirst, "$this$addFirst");
      byte var4 = 0;
      String var5 = $this$addFirst.substring(var4);
      Intrinsics.checkNotNullExpressionValue(var5, "this as java.lang.String).substring(startIndex)");
      return var1 + var5;
}
  • 자바에서 사용할 땐 파일명(클래스명).메서드명 형태로 사용해야 함을 알 수 있습니다.
  • 코틀린 확장함수가 변환된 static메서드의 첫번쨰 인자로 확장 대상 수신자 객체를 넘겨줘야 하는 것을 알 수 있습니다.
  • 코틀린에선 마치 기존의 String클래스에 존재하는 인스턴스 함수인 것 처럼 사용하지만 자바에선 static메서드로 호출 가능함을 알아야 합니다.

코틀린에서 롬복 사용시 발생하는 문제와 해결방법

package lombok;

/**
 * @author ihyeonseo
 */


@EqualsAndHashCode
@ToString
public class Hero {

    @Getter @Setter
    private String name;

    @Getter @Setter
    private int age;

    private String address;

    public String getAddress() {
        return address;
    }

    public void setAddress(String address) {
        this.address = address;
    }

    public static void main(String[] args) {
        Hero hero = new Hero();
        hero.setName("아이언맨");
        hero.setAge(53);
        hero.setAddress("스타크타워");


    }
}
  • 위와같은 롬복으로 작성된 자바 코드는 코틀린과 자바를 상호 운영하는 경우 정상 작동하지 않습니다.
  • 하지만 이때 직접 만든 게터, 세터를 사용한 address는 코틀린에서 잘 작동하게 되는데, 그 이유와 마이그레이션 방법에 대해 알아보겠습니다.

  • 가장 먼저 코틀린 컴파일러가 코틀린 코드와 코틀린 코드에서 참조하는 자바 코드를 컴파일해서 바이트코드를 생성합니다.
  • 그 다음 자바 컴파일러가 자바 코드를 컴파일하는데 이때 애노테이션 프로세싱 단계가 동작합니다. 
  • 애노테이션 프로세서(Annotation Processor)는 컴파일 타임에 애노테이션을 동적으로 읽어서 코드를 생성하거나 변경하는 기능을 말합니다.
  • 이러한 컴파일 순서 이슈로 인해 기본적으로 롬복을 사용하지 못합니다.
package lombok

/**
 * @author ihyeonseo
 */
data class HeroKt(
    val name: String,
    val age: Int,
    val address: String,
)
  • 가장 간단한 해결방법은 위와같이 그냥 data class를 만드는 방법입니다.
  • 이 외에도 코틀린에서 제공하는 롬복 플러그인을 사용하면 됩니다.

애노테이션 프로세서와 CGlib의 차이는 뭘까??

애노테이션 프로세서와 CGLib는 Java에서 코드를 생성하거나 조작하는 데 사용되는 두 가지 방법입니다. 이 두 가지 기술은 각각 서로 다른 목적과 사용 사례를 가지고 있습니다.

  • 애노테이션 프로세서
    • 위와같이 java 컴파일러에 의해 호출되며, 애노테이션이 붙은 코드 요소를 분석하고 추가적인 코드를 생성하거나 변경하는 데 사용됩니다.
  • CGLib
    • 이는 런타임에 Java클래스의 바이트 코드를 직접 조작하는 라이브러리 입니다. 이를 통해 런타임에 새로운 클래스를 생성하거나 기존 클래스의 동작을 변경하는 것이 가능합니다. CGLib는 주로 프록시 객체 생성, AOP(Aspect-Oriented Programming) 기능 제공 등에 사용됩니다.

여기서 프록시 패턴이란( 스프링에서), 원래 소스코드를 수정하지 않고 프록시 객체를 생성하여 흐름을 제어해 기능을 삽입하는 것을 말합니다. 오리지널 객체의 메서드 호출 결과를 바꿀 순 없습니다.

 

JDK에서 제공하는 Dynamic Proxy또한 있습니다. 이는 Interface를 기반으로 Proxy를 만들어 주는 방식입니다. Interface를 기반으로 Proxy를 만들어 주기 때문에 인터페이스의 존재가 필수적입니다. 자바에서는 리플렉션을 활용한 Proxy 클래스를 제공해주고 있습니다. Java.lang.reflect.Proxy클래스의 newProxyInstance() 메소드를 이용해 프록시 객체를 생성합니다. Reflection이란 구체적인 클래스 타입을 알지 못해도 그 클래스의 정보(메서드, 타입, 변수 등등)에 접근하게 할 수 있는 API입니다. 자바에서는 JVM이 실행되면 사용자가 작성한 자바 모드가 컴파일러를 거쳐 바이트 코드로 변환되어 static영역에 존재하게 됩니다. Reflection API는 이 정보를 활용해 필요한 정보를 가져옵니다. 이는 값비싼 API이기 때문에 Dynamic Proxy는 리플렉션을 하는 과정에서 성능이 좀 떨어진다는 단점이 있기도 합니다.

 

하지만 모든 클래스가 인터페이스를 구현하는 것은 아니므로, 이런 제한으로 인해 Dynamic Proxy를 생성할 수 없는 경우도 많습니다. 이런 경우에 CGLib와 같이 우리가 위에서 본 바이트 코드 정적 분석 라이브러리를 사용하면 클래스의 정보만을 이용하여 프록시 객체를 생성할 수 있습니다. 이는 실제로 런타임에 바이트코드를 조작하여 실제 클래스를 상속받는 새로운 클래스를 만드는 방식으로 프록시를 생성합니다.  

 

Gradle Kotlin DSL

  • 이전에도 starter 모듈을 작성할 때 미리 공부한 내용이지만 다시 정리하겠습니다.
  • Gradle은 빌드 스크립트를 작성할 때 기본적으로 Groovy언어를 사용해 작성합니다.
  • 익숙하지 않은 Groovy대신 Kotlin기반으로 빌드 스크립트를 작성할 수 있게 도와주는데 이를 Gradle Kotlin DSL이라고 합니다.
  • Kotlin DSL로 작성한 빌드 스크립트는 .kts 확장자를 가집니다 KTS (Kotlin Script)의 약자입니다.
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
    id ("org.springframework.boot") version "2.7.0"
    id ("io.spring.dependency-management") version "1.0.11.RELEASE"
    id ("org.jetbrains.kotlin.jvm") version "1.6.21"
}
group = "com.fastcampus"
version = "0.0.1-SNAPSHOT"
java.sourceCompatibility = JavaVersion.VERSION_17
repositories {
    mavenCentral()
}
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    runtimeOnly("com.h2database:h2")
    testImplementation ("org.springframework.boot:spring-boot-starter-test")
}
tasks.withType<KotlinCompile> {
    kotlinOptions {
        freeCompilerArgs = listOf("-Xjsr305=strict")
        jvmTarget = "17"
    }
}
  • Kotlin DSL로 작성된 빌드 스크립트는 순수 Groovy로 작성한 빌드 스크립트보단 조금 느린건 사실이지만 점점 개선되고 있다고 합니다.

Spring 플러그인

  • 코틀린의 클래스는 애초에 final 즉, 상속이 불가능한 클래스라고 했습니다.
  • 상속을 열어뒀을 때 발생하는 부작용으로 인해 코틀린은 상속이 꼭 필요한 경우에만 적용하도록 open키워드를 통해 상속을 허용한다고 했습니다.
  • 문제는 스프링은 기본적으로 CGLIB 프록시를 사용해 애노테이션이 붙으 클래스에 대한 프록시 객체를 생성하는데 CGLIB프록시는 대상 객체를 상속해서 프록시 객체를 만든다고 위에어 했ㅅ브니다.
  • 그럼 매번 open 키워드를 붙이는건 불편하므로 코틀린은 All-open 컴파일러 플러그인을 제공합니다.
  • build.gradle.kts에 all-open 플러그인을 아래와 같이 추가하면 됩니다. 
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
    id ("org.springframework.boot") version "2.7.0"
    id ("io.spring.dependency-management") version "1.0.11.RELEASE"
    id ("org.jetbrains.kotlin.jvm") version "1.6.21"
    id ("org.jetbrains.kotlin.plugin.allopen") version "1.6.21" //
}
// allOpen {
    annotations("org.springframework.boot.autoconfigure.SpringBootApplication")
}
/ ... /
  • @Transactional 애노테이션을 추가하는 경우는??,, 너무 불필요한 작업이 될 거 같습니다.
  • 이처럼 매번 문제가 생길때마다 allopen에 추가하기 어려우므로 allopen 플러그인을 래핑한 kotlin-spring플러그인을 사용하면 매우 간편해집니다.
plugins {
    id("org.springframework.boot") version "2.7.0"
    id("io.spring.dependency-management") version "1.0.11.RELEASE"
    id("org.jetbrains.kotlin.jvm") version "1.6.21"
    kotlin("plugin.spring") version "1.6.21"
}
  • 이는 스프링에서 CGLIB 프록시를 사용하는 모든 애노테이션에 대해 자동으로 open처리를 해준다는 장점이 있습니다.

 

JPA 플러그인

  • JPA에서 엔티티 클래스를 생성하려면 매개 변수가 없는 기본 생성자가 필요합니다.
  • 실제로 아래와 같은 엔티티를 작성하면 컴파일 에러가 발생합니다
@Entity
@Table
class Person(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long?,
    @Column
    var name: String,
@Column
    var age: Int,
)
  • 코틀린은 매개 변수가 없는 기본 생성자를 자동으로 만들어주는 no-args 컴파일러 플러그인을 제공합니다.
plugins {
    id("org.springframework.boot") version "2.7.0"
    id("io.spring.dependency-management") version "1.0.11.RELEASE"
    id("org.jetbrains.kotlin.jvm") version "1.6.21"
    kotlin("plugin.spring") version "1.6.21"
    kotlin("plugin.noarg") version "1.6.21"
}
noArg {
    annotation("javax.persistence.Entity")
}
  • JPA를 쓸 경우 Spring플러그인과 마찬가지로 kotlin-jpa플러그인을 제공합니다.
  • JPA 플러그인을 쓰면 @Entity, @Embeddable, @MappedSuperclass에 대한 기본 생성자를 자동으로 생성해줍니다.
plugins {
    id("org.springframework.boot") version "2.7.0"
    id("io.spring.dependency-management") version "1.0.11.RELEASE"
    id("org.jetbrains.kotlin.jvm") version "1.6.21"
    kotlin("plugin.spring") version "1.6.21"
    kotlin("plugin.jpa") version "1.6.21"
}