Kotlin에서 Lombok 적용이 안되는 이유 (feat. 코틀린 Slf4j)
문제 상황
왓캠퍼스 프로젝트를 Java/Spring -> Kotlin/Spring 으로 마이그레이션 하는 과정에서 하나의 문제를 마주했다.
바로 'Kotlin 코드에서 Lombok 적용이 안 된다는 점' 이었다.
이에 따라 간편한 로깅을 위해 사용하던 Lombok의 @Slf4j 애노테이션까지 사용이 불가능해졌다.
때문에 로깅을 해야하기도 하고, 이유도 궁금해서 해당 문제의 원인에 대해 찾아보게 되었다.
Lombok이란?
Lombok 공식문서의 소개에 따르면 Lombok은 아래와 같은 라이브러리이다.
Project Lombok is a java library that automatically plugs into your editor and build tools, spicing up your java.
Never write another getter or equals method again, with one annotation your class has a fully featured builder, Automate your logging variables, and much more.
"Project Lombok은 편집기와 빌드 도구에 자동으로 플러그인되는 Java 라이브러리로, Java를 더욱 즐겁게 해줍니다.
다시는 getter나 equals 메서드를 쓰지 마세요. 하나의 주석으로 클래스에 모든 기능을 갖춘 빌더가 생깁니다. 로깅 변수를 자동화하고, 훨씬 더 많은 작업을 할 수 있습니다."
즉 Lombok이란 Java의 Getter, Setter, Eqauls&Hashcode, LoggingFactory 등과 같은 매번 작성해야하는 보일러플레이트 코드를 애노테이션 설정을 통해 간편화 시켜주는 라이브러리이다.
Slf4j란?
Slf4j란 Simple Logging Facade for Java의 약자이다.
로깅 프레임워크의 추상화 계층으로, 프레임워크의 종류에 상관없이 일관된 코드로 로깅을 할 수 있도록 해준다.
Logback, Log4j, java.util.logging 등 다양한 로깅 프레임워크와 연동 가능하게 설계되어있다.
(참고로 스프링부트에서 사용되는 기본 로깅 프레임워크 구현체는 Logback이다)
@Slf4j 란?
@Slf4j는 Lombok에서 제공하는 애노테이션이다.
Slf4j를 사용하기 위해 필요한 보일러플레이트 코드를 애노테이션을 통해 Lombok이 컴파일 시점에 대신 작성해준다.
@Slf4j
class Sample {
private val log = LoggerFactory.getLogger(this::class.java)
//...
}
원래는 로깅을 위해 위와 같은 코드를 직접 작성해야 했다면
@Slf4j
class Sample {
//...
}
Lombok을 사용하면 위와 같이 애노테이션만으로 손쉽게 보일러플레이트 코드를 제거할 수 있도록 만들어준다.
원인 분석
그럼 이렇게 간편한 Lombok의 @Slf4j 애노테이션을 사용해서 쉽게 Kotlin 코드에서도 로깅을 하려했으나 실패한 이유는 무엇일까?
이는 'Lombok이 어떻게 동작하는 지' 와 'Java, Kotlin의 컴파일 과정의 차이' 에 대해서 이해하면 빠르게 이해할 수 있다.
- Lombok은 javac의 컴파일 과정 중 Annotation Processing 과정에서 클래스에 선언된 Lombok 애노테이션을 바탕으로 실제 코드를 작성해주는 방식으로 동작한다.
- Kotlin은 Java의 컴파일러인 Javac와는 별도의 컴파일러인 Kotlinc를 사용하며, Kotlinc는 Javac보다 먼저 컴파일을 수행한다.
위 2가지 사실을 바탕으로 문제의 원인을 분석해보자.
Annotation Processing 등의 Javac의 동작 과정에 대해 자세히 알고 싶다면 이 글을 참조하자
Kotlin 코드에서 Lombok 적용이 안 된 이유
Javac의 동작 과정 중 Annotation Processing 과정에서 Lombok의 보일러플레이트 처리 작업이 수행된다고 했다.
또한 Kotlinc가 Javac보다 더 먼저 동작한다고 했다.
때문에 Kotlinc의 컴파일 시점에는 Lombok에 의한 코드가 작성되지 않는다.
이로 인해 Lombok 애노테이션으로 아무리 선언하더라도 적용이되지 않던 것이었다.
단독으로 Kotlin만 사용하는 경우에도 마찬가지로 Javac가 동작할 일이 당연히 없으니, Lombok을 사용할 수 없다.
정말 그럴까?
내가 해보기 전까진 믿지 않는다. 직접 테스트해서 결과를 얻어보자.
먼저 자바부터 실험해보자.
실제로 @Getter를 설정해둔 Campus.java 를 컴파일하게 되면
위와 같이 Getter들이 생성되는 것을 알 수 있었다.
해당 내용은 build 이후 생성되는 build - classes 패키지의 .class 파일을 통해 확인할 수 있다.
이번엔 코틀린으로 실험해보겠다.
똑같이 @Getter를 선언해둔 Member.kt 파일을 컴파일하는 경우
위와 같이 별도의 Getter 메서드는 생성되지 않은 것을 확인할 수 있었다.
@Slf4j 와 같은 애노테이션도 마찬가지였다.
문제 해결
1. Kotlin Lombok Plugin (실패)
문제 해결을 위해 코틀린 공식문서를 찾아보는 과정에서 Lombok 관련 Kotlin의 플러그인이 있는 것을 발견했다.
공식문서에서는 Kotlin에서 Lombok을 사용할 수 있도록 도와주는 Plugin을 제시하고 있으나,
완벽한 버전은 아니며 아직은 모든 Lombok 애노테이션이 호환되고 있지는 않는다고 한다.
아쉽게도 여기에 @Slf4j 애노테이션은 없었다.
궁금한 사람은 해당 공식문서 링크를 참조하자
2. Logger 흭득 코드 직접 작성
class SampleClass {
private val log = LoggerFactory.getLogger(this::class.java)
//...
}
@Slf4j 애노테이션 없이, Logger를 흭득하려면 위와 같이 로그를 찍는 모든 클래스마다 일일히 LoggerFactory 코드를 작성해주어야 한다.
하지만 이는 너무 번거롭고 복잡하지 않은가.
@EnableScheduling
@SpringBootApplication
class SampleClass
inline fun <reified T> T.logger() = LoggerFactory.getLogger(T::class.java)!!
fun main(args: Array<String>) {
SpringApplication.run(SampleClass::class.java, *args)
}
이 문제는 최상위 클래스 Application 클래스에 inline, reified 키워드를 통한 LoggerFactory.getLogger() 함수를 선언해주어 문제를 해결할 수 있다.
class SampleClass {
private val log = logger()
//...
}
위와 같이 선언해두게 되면, 하위 클래스에서 위와 같이 간단하게 logger를 흭득하여 사용할 수 있다.
inline 키워드
Kotlin에서의 함수는 일급 함수이다.
컴퓨터 과학에서 프로그래밍 언어는 함수를 일급 객체로 취급하는 경우 일급 함수(first-class function)를 갖는다고 한다. 이는 언어가 함수를 다른 함수에 대한 인수로 전달하고, 이를 다른 함수의 값으로 반환하고, 변수에 할당하거나 자료 구조에 저장하는 것을 지원한다는 것을 의미한다.[1] 일부 프로그래밍 언어 이론가들은 익명 함수(함수 리터럴)에 대한 지원도 요구한다.[2] 일급 함수를 사용하는 언어에서는 함수 이름에 특별한 상태가 없다. 이는 함수 유형의 일반 변수처럼 취급된다
위키피디아 (https://en.wikipedia.org/wiki/First-class_function)
자바의 함수형 프로그래밍을 사용해봤다면 함수를 저장할 수 있는 Supplier, Consumer 등의 객체를 알고 있을 것이다.
일급이라는 의미는 함수의 결과로 함수를 return하거나, 변수에 함수가 할당되는 등 함수가 일반 변수처럼 취급되는 것을 의미한다.
private val log = logger()
이러한 이유로 위와 같은 코드를 그냥 사용하게 되는 경우 logger() 함수를 저장하기 위한 메모리 공간이 필요해지는 등 오버헤드가 발생하게 된다.
이러한 단점을 해결하기 위해 inline 키워드가 존재한다.
logger() 함수에 inline 키워드를 사용하게 되면 logger()가 일급 함수로서 특정 메모리 공간에 할당되는 것이 아니라 코드로 작성되어 컴파일된다.
그럼 logger() 함수 호출 시점에 호출한 함수를 찾아서 동작을 수행시키고 return 받은 값을 가져와 메모리 공간에 올리는 등의 오버헤드를 제거할 수 있게 된다.
반면, 바이트코드의 크기가 커지는 등의 단점도 존재한다.
자세한 내용은 공식문서 참조
reified 키워드
reified 키워드는 inline함수에만 사용할 수 있으며,
제네릭을 사용해서 함수를 호출 시 매개변수로 Class 정보를 전달하는 것보다 더 쉽고 간편한 방법을 제공합니다.
제네릭과의 차이점은 타입 소거가 발생하지 않는다는 점이다.
fun <T> isString(value: T): Boolean {
return value is String // 경고: 'Unchecked cast'
}
제네릭은 런타임 시점에 타입 정보가 제거되는 타입 소거가 발생한다.
JVM은 모든 제네릭 타입을 기본적으로 Object 로 간주한다.
때문에 런타임에는 타입 체크가 불가능하다.
inline fun <reified T> isInstance(value: Any): Boolean {
return value is T // 런타임에 T 타입 체크 가능
}
println(isInstance<String>("abc")) // true
println(isInstance<Int>("abc")) // false
반면 reified를 사용하면 제네릭 타입 정보를 런타임에도 유지하도록 만들어서 제네릭의 타입 소거 제한을 우회할 수 있다.
제네릭만 사용해서 logger() 메서드를 구현하는 경우,
런타임 시점에 호출되는 메서드이기 때문에 컴파일 시점 이후 타입 소거가 발생하는 제네릭으론 구현이 불가능하다.
반면 inline, reified는 타입 소거가 발생하지 않기 때문에 이렇게 에러가 나지 않는다.
좀 더 자세한 내용은 공식문서 참조
3. Kotlin-Logging 사용
Kotlin-Logging 을 사용하는 방식도 하나의 해결책으로 존재한다.
내부적으로 SLF4J API를 사용하여 다른 로깅 프레임워크와 호환되며, logback Xml 파일을 만들어 로깅을 커스터마이징하는 것도 가능하다.
사용법
private val logger = KotlinLogging.logger {}
위와 같이 가볍고 간단하게 KotlinLogging.logger {} 만 입력하면 logger를 흭득할 수 있다.
logger.debug { "Some $expensive message!" }
기존 사용하던 로깅 방법과는 다르게 중괄호를 사용해서 로깅한다는 차이점이 존재한다.
장점
가장 큰 장점은 로깅 시에 로그 수준 활성화 체크를 자동으로 해준다는 점이다.
만약 로깅을 할 때, 로그 수준 활성화 체크를 처리하지 않으면 오버헤드가 발생할 수 있다.
logger.debug("User data: " + user.toString());
예를 들어 위와 같이
로그 내용에 객체가 담겼을 경우 출력할 수 있는 형태로 변경하는 비용, 문자열 연산이 존재할 경우 해당 연산 비용 등이 존재한다.
또한 사용되지도 않을 데이터에 대해서 메모리 할당, GC 등 자원 사용까지 오버헤드에 포함된다.
logger.debug { "Some $expensive message!" }
반면 kotlin-logging 프레임워크를 사용하면 위와 같은 로깅 로직이 존재할 때
// This is what happens when you write the above ^^^
if (logger.isDebugEnabled) logger.debug("Some $expensive message!")
위와 같이 로그 수준 활성화에 대한 검사를 자동으로 처리해준다는 이점이 있다.
자세한 내용은 공식 깃허브 참조
결론
개발에 정답은 없다. 다만 트레이드 오프는 존재한다.
여러 기술들의 트레이드 오프를 고려해서 프로젝트 상황에 가장 적합한 방식을 사용하자!