Kotlin Compiler Plugin
Say hello to Kotlin Compiler Plugin
KSP 보다 훨씬 강력한 Kotlin Compiler Plugin 개발 시작하기 그리고 왜?
Kotlin Compiler Plugin 시리즈는 총 3편으로 구성하고 있으며 이 글은 해당 시리즈의 첫 번째 글입니다. 이번 글에서는 코틀린 컴파일러의 구조를 간단하게 알아보고, 코틀린 컴파일러 플러그인으로 “Hello, World!”를 만들어 보겠습니다.
What is Kotlin Compiler Plugin?
아마 이 글을 보고 계신 분들 중 반 이상이 코틀린 컴파일러 플러그인 학습을 처음 도전하시는 거라 생각합니다. 코틀린 컴파일러 플러그인은 당연하게도 몰라도 전혀 문제가 되지 않으며 API 문서도 대부분 없습니다. 하지만 제가 소개하는 이유는 코틀린 컴파일러 플러그인을 활용하면 언어 차원에서 불가능한 기능을 가능으로 만들 수 있기 때문입니다.
코틀린 컴파일러 플러그인은 말 그대로 코틀린 컴파일러에 플러그인 기능을 추가하는 기술입니다.
저는 KSP의 단점을 극복하기 위해 코틀린 컴파일러 플러그인을 사용하게 됐습니다. 제가 구현하고자 하는 기능은 함수 인자의 default value를 조회하는 기능이 필요했는데, KSP API로는 함수 인자의 default value에 접근할 수 없었습니다. (KSP Internal API를 사용하면 가능하긴 하지만 제가 원하는 기능을 구현할 수는 없습니다.)
위 기능이 자세히 궁금하신 분은 아래 글을 확인해 주세요.
저는 코틀린 컴파일러 플러그인을 공부한 지 3일 만에 원하는 기능을 바로 구현할 수 있었습니다. 단지 문서가 거의 없을 뿐이지 핵심 흐름만 이해하면 생각보다 쉽게 코드를 작성할 수 있으니 천천히 학습해 봅시다.
Kotlin Compiler Plugin vs. Kotlin Symbol Processing
KSP API를 사용해 보신 분은 코틀린 컴파일러 플러그인을 비교적 쉽게 이해하실 수 있습니다. KSP 공식 문서에서는 KSP를 다음과 같이 설명하고 있습니다.
Kotlin Symbol Processing (KSP) is an API that you can use to develop lightweight compiler plugins. KSP provides a simplified compiler plugin API that leverages the power of Kotlin while keeping the learning curve at a minimum. Compared to kapt, annotation processors that use KSP can run up to 2 times faster.
즉, KSP API를 사용해 본 경험이 있다면 코틀린 컴파일러 플러그인의 일부를 이미 사용해 본거라 말할 수 있습니다. 그렇다면 KSP와 코틀린 컴파일러 플러그인의 차이점은 무엇일까요?
이번에는 KSP 공식 문서에서 말하는 KSP의 한계을 보겠습니다.
- Examining expression-level information of source code.
- Modifying source code.
- 100% compatibility with the Java Annotation Processing API.
코틀린 컴파일러 플러그인과 연관 있는 항목은 첫 번째와 두 번째 항목인데요, 코틀린 컴파일러 플러그인은 언어 컴파일러의 플러그인으로 작동되기에 이 한계들이 모두 가능해집니다.
다시 말하면, 코틀린 컴파일러 플러그인을 사용하면 컴파일러의 입장에서 코드를 볼 수 있고, 없던 코드를 컴파일 과정에서 만들어낼 수도 있으며, 기존 코드를 수정할 수도 있고, 코틀린 언어 기능 중 일부를 마음대로 바꿀 수도 있습니다. 하지만 KSP에서는 이 모든게 불가능합니다.
코틀린 컴파일러 플러그인은 이런 상황에서 유용하게 사용할 수 있습니다.
- 기능 구현에 투자할 수 있는 시간이 충분히 있고, (API 문서가 대부분 없어 학습에 오랜 시간이 걸릴 수 있습니다.)
- 낮은 추상화의 수준에서 언어 정보에 접근해야 한다.
- 또한 컴파일러 시점에서 기존 코드를 수정하거나 새로운 코드를 생성해야 한다.
하지만 이런 상황에서는 KSP가 더 좋은 선택지가 됩니다.
- 기능 구현에 투자할 수 있는 시간이 충분하지 않고, (문서가 많아 쉽게 학습할 수 있습니다.)
- 높은 추상화의 수준에서 쉽고 빠르게 언어 정보에 접근해야 한다.
- 또한 새로운 코드를 생성하기만 하고 기존 코드를 수정하지는 않는다.
지금까지 코틀린 컴파일러 플러그인의 의미와 왜 사용하는지를 알아보았습니다.
Kotlin Compiler Under the Hood
이제부터는 코틀린 컴파일러 플러그인 개발에 필요한 사전 지식인 코틀린 컴파일러의 내부 작동을 알아보겠습니다. (코틀린 컴파일러를 이미 어느 정도 아시는 분께 말하자면 이 글은 현재 시점에서 기본 백엔드인 New IR Backend를 기준으로 작성하였습니다.)
코틀린 컴파일러는 역할에 따라 frontend와 backend 단계로 나뉩니다.
- frontend: PSI Tree 구축 및 BindingContext 구성
- backend: IR 생성 및 타겟/머신 코드 생성
frontend 단계부터 보겠습니다. 코틀린 컴파일러는 frontend 단계에서 가장 먼저 PSI Tree를 구축하는 과정을 시작합니다. PSI란 Program Structure Interface의 약어이며, 소스 코드 구문 분석의 결과를 나타냅니다. PSI는 구문 분석의 결과일 뿐이지 의미 정보(semantic info)를 포함하지 않는다는 게 중요합니다.
의미 정보란 코드에 사용된 모든 데이터의 세부 정보를 의미하며, ‘이 함수의 출처는 어디인가?’, ‘이 변수가 모두 동일한 값을 참조하는가?’, ‘이 타입은 무엇인가?’와 같은 물음에 답을 제공합니다.
예를 들어 다음과 같은 간단한 코드가 있습니다.
fun main() {
if (pet is Dog) {
pet.woof()
} else {
println("*")
}
}
위 코드는 PSI Tree가 아래와 같이 구축됩니다.
위 트리에서 리프 노드에 해당하는 'pet'
, 'Dog'
, 'pet'
, 'woof'
, 'println'
, '*'
노드는 단순히 문자열을 나타내지 해당 노드가 어떤 의미 정보를 가지고 있는지는 알 수 없습니다. 다시 말해서 위 PSI Tree를 코드로 바꿔보면 아래와 같이 됩니다.
fun main() {
if ("pet" is "Dog") {
"pet"."woof()"
} else {
"println"("*")
}
}
각각 노드가 나타내는 의미 정보는 BindingContext라는 특수 맵에 저장됩니다.
코틀린 컴파일러의 frontend 단계에서는 코틀린 소스 코드를 분석하여 PSI Tree를 구축하고, 각각 노드별 의미 정보를 BindingContext에 저장하는 절차가 진행됩니다.
frontend 단계가 모두 완료되면 해당 결과를 가지고 backend 단계가 진행됩니다.
JS IR Backend
→IR generator
→JavaScript
(js file)JVM IR Backend
→IR generator
→JVM Bytecode
(class file)Native Backend
→IR generator
→LLVM Bitcode
(so file)
위 과정을 보면 사용하는 백엔드 엔진이 js
, jvm
, native
로 3가지이고, 중간에 IR generator
라는 과정은 3가지 엔진 모두에 공통되는 걸 볼 수 있습니다.
코틀린은 멀티플랫폼 언어이기에 각각 플랫폼에 맞는 개별 엔진을 두고 있으며, 플랫폼 엔진끼리 로직을 공유하기 위해 IR을 사용합니다.
IR은 Intermediate Representation의 약어이며, 코틀린 소스 코드와 대상 코드(.js, .class, .so) 사이의 중간 표현입니다. IR을 사용하면 대상 코드 플랫폼별로 모두 공통되는 중간 표현을 가져올 수 있기에, 이 중간 표현에 맞는 로직을 구현하는 걸로 많은 중복 코드를 예방할 수 있습니다.
코틀린 컴파일러의 backend 단계에서는 frontend 단계에서 준비되는 PSI Tree와 BindingContext를 기반으로 IR을 생성하고, 생성한 IR을 활용하여 대상 코드를 생성하는 절차가 진행됩니다.
지금까지 코틀린 컴파일러의 내부 동작을 간단히 알아보았습니다.
Hello, World!
이제 코틀린 컴파일러 플러그인을 만들기 위한 기초 개념이 모두 준비되었습니다. 코틀린 컴파일러 플러그인은 다음과와 같은 구조를 띕니다.
CommandLineProcessor
는 컴파일러 인자로 볼 수 있고, CompilerPluginRegistrar
는 컴파일러 플러그인의 통합 지점으로 볼 수 있습니다. 컴파일러 플러그인을 의미하는 Extension
을 CompilerPluginRegistrar
에 등록하는 과정으로 코틀린 컴파일러 플러그인이 구성됩니다.
이제 나만의 코틀린 컴파일러 플러그인을 만들어 보겠습니다. 모듈에 정의된 모든 함수의 시그니처를 출력하는 간단한 플러그인을 만들어 봅시다.
먼저 코틀린 컴파일러와 autoservice 의존성이 필요합니다.
dependencies {
compileOnly("org.jetbrains.kotlin:kotlin-compiler-embeddable:1.8.20")
compileOnly("com.google.auto.service:auto-service-annotations:1.0.1")
kapt("com.google.auto.service:auto-service:1.0.1")
}
코틀린 컴파일러는 코틀린 컴파일 과정에서만 유효하므로 compileOnly
로 추가해 주었고, 앞서 소개한 CommandLineProcessor
와 CompilerPluginRegistrar
는 ServiceLoader로 등록되므로 쉬운 서비스 추가를 위해 autoservice를 추가해 주었습니다.
CommandLineProcessor
부터 구현해 보겠습니다. CommandLineProcessor
는 컴파일러 인자라고 하였습니다. 따라서 다음과 같은 2가지 항목이 필요합니다.
- 컴파일러 플러그인 아이디
- 컴파일러 인자 정보
이 글에서는 컴파일러 플러그인 아이디로 land.sungbin.function.printer
를 사용하고, 컴파일러 인자로는 String
타입의 tag
를 추가하도록 하겠습니다. 결과를 미리 보자면 다음과 같이 컴파일러 인자를 제공하게 됩니다.
tasks.withType<KotlinCompile> {
val functionPrinterPluginId = "land.sungbin.function.printer"
kotlinOptions {
freeCompilerArgs = freeCompilerArgs + listOf(
"-P",
"plugin:$functionPrinterPluginId:tag=FP",
)
}
}
각각 항목에 맞는 변수를 만들어 주겠습니다.
const val PluginId = "land.sungbin.function.printer"
val KEY_TAG = CompilerConfigurationKey<String>("Tags to use for logging")
val OPTION_TAG = CliOption(
optionName = "tag",
valueDescription = "String",
description = KEY_TAG.toString(),
)
컴파일러 인자 키는 CompilerConfigurationKey<인자 타입>(인자 설명)
으로 정의할 수 있고, 인자 키의 옵션은 CliOption(optionName = 인자명, valueDescription = 인자 값 설명, description = 인자 설명)
으로 정의할 수 있습니다.
OPTION_TAG
를 보면 CliOption#description
값으로 KEY_TAG.toString()
을 주고 있는데, CompilerConfigurationKey#toString
으로 해당 인자 키에 부여된 인자 설명을 가져올 수 있습니다.
이제 각각 변수를 CommandLineProcessor
에 제공해 줍시다.
@AutoService(CommandLineProcessor::class)
class FPCommandLineProcessor : CommandLineProcessor {
override val pluginId = PluginId
override val pluginOptions = listOf(OPTION_TAG)
override fun processOption(
option: AbstractCliOption,
value: String,
configuration: CompilerConfiguration,
) {
when (val optionName = option.optionName) {
OPTION_TAG.optionName -> configuration.put(KEY_TAG, value)
else -> error("Unknown plugin option: $optionName")
}
}
}
컴파일러 인자로 제공된 값은 CommandLineProcessor
의 processOption
콜백으로 전달됩니다. processOption
콜백의 option
과 value
인자는 각각 컴파일러 인자 옵션과 제공된 값을 의미합니다. 그리고 마지막 configuration
인자는 컴파일러에서 전역으로 사용할 구성이 담긴 맵을 의미합니다.
processOption
으로 제공된 컴파일러 인자가 OPTION_TAG
에 해당하는 인자가 맞다면 제공된 인자 값을 configuration
맵에 KEY_TAG
키로 저장하게 구현하였습니다.
만약 알 수 없는 컴파일러 인자가 제공됐다면 IllegalStateException
을 throw 합니다.
다음으로 CompilerPluginRegistrar
를 봅시다. CompilerPluginRegistrar
는 컴파일러 플러그인의 통합 지점이라고 하였습니다. 따라서 이 곳에서 컴파일러 플러그인을 의미하는 Extension
등록이 진행됩니다.
@AutoService(CompilerPluginRegistrar::class)
class FPCompilerPluginRegistrar : CompilerPluginRegistrar() {
override val supportsK2 = false
override fun ExtensionStorage.registerExtensions(configuration: CompilerConfiguration) {
// configuration.get(key: CompilerConfigurationKey<T>, defaultValue: T (optional))
val logger = configuration.get(CLIConfigurationKeys.MESSAGE_COLLECTOR_KEY, MessageCollector.NONE)
val loggingTag = requireNotNull(configuration.get(KEY_TAG))
}
}
CompilerPluginRegistrar
는 supportsK2
변수와 ExtensionStorage.registerExtensions
확장 함수로 구성된 추상 클래스입니다.
supportsK2
변수는 코틀린 컴파일러의 새로운 버전을 지원할지를 나타내는 변수입니다. 이 글에서는 간단한 설명을 위해 K2
지원은 하지 않는걸로 하겠습니다. ExtensionStorage.registerExtensions
확장 함수는 Extension
을 등록할 수 있는 환경을 열어줍니다. 즉, ExtensionStorage.registerExtensions
안에서 Extension
등록이 진행됩니다.
ExtensionStorage.registerExtensions
본문을 보면 함수 인자인 configuration
으로부터 CLIConfigurationKeys.MESSAGE_COLLECTOR_KEY
키와 KEY_TAG
키로 logger
와 loggingTag
를 가져오고 있습니다. KEY_TAG
는 CommandLineProcessor
에서 등록해 준 키므로 익숙한 반면, CLIConfigurationKeys.MESSAGE_COLLECTOR_KEY
는 처음 등장하였습니다. 해당 키는 코틀린 컴파일러에서 기본으로 제공되는 키이며, 코틀린 컴파일러 환경에서 사용할 로거를 가져와줍니다.
이제 Extension
을 등록할 차례입니다. Extension
에는 다양한 종류가 있습니다. 대표적으로는 바이트코드 생성 과정에 접근할 수 있는 ExpressionCodegenExtension
과 IR 생성 과정에 접근할 수 있는 IrGenerationExtension
이 있습니다.
이 글의 목표는 모듈에 정의된 모든 함수의 시그니처를 출력하는 것입니다. 모듈에 존재하는 모든 함수를 조회하려면 코틀린 소스코드가 모두 해석되고 의미 정보 조회가 모두 끝난 시점인 IR generator
과정에 접근하는 게 가장 좋아 보입니다. 따라서 IrGenerationExtension
을 사용하겠습니다.
class FPIrExtension(
private val logger: MessageCollector,
private val loggingTag: String,
) : IrGenerationExtension {
override fun generate(
moduleFragment: IrModuleFragment,
pluginContext: IrPluginContext,
) {
moduleFragment.accept(FPIrVisitor(logger, loggingTag), null)
}
}
IrGenerationExtension
은 generate
함수를 갖는 인터페이스이며, generate
를 구현하는 것으로 IR 생성 과정에 접근할 수 있습니다. generate
함수의 인자로는 IrModuleFragment
와 IrPluginContext
가 있는데, 각각 컴파일러가 작동 중인 모듈의 IR 정보와 IR 작업에 도움이 될 수 있는 context를 제공합니다.
IrModuleFragment
와 같이 모든 IR 요소에는 accept
과 transform
함수가 있습니다. accept
은 IR 변경 없이 IR 방문만 하는 경우에 해당하고, transform
은 IR 방문과 동시에 변경까지 하는 경우에 해당합니다. 이 글의 경우라면 IR 방문만 하면 되니 accept
함수를 사용해 줍시다.
accept
함수의 인자로는 IrElementVisitor
구현체와 해당 구현체에 부과적으로 전달할 객체를 제공해야 합니다. 이 글에서는 IrElementVisitor
구현으로 FPIrVisitor
라는 클래스를 만들어 사용하고, 부과적으로 전달한 객체는 없으니 null
을 전달해 주겠습니다.
FPIrVisitor
는 IrElementVisitorVoid
를 상속받는 클래스입니다.
class FPIrVisitor(
private val logger: MessageCollector,
private val loggingTag: String,
) : IrElementVisitorVoid {
override fun visitModuleFragment(declaration: IrModuleFragment) {
TODO()
}
override fun visitFile(declaration: IrFile) {
TODO()
}
override fun visitFunction(declaration: IrFunction) {
TODO()
}
}
부과적으로 전달할 객체가 없으니 아무것도 전달받지 않는 용도로 설계된 IrElementVisitor
인 IrElementVisitorVoid
를 상속해 주었습니다.
IrElementVisitor
는 모든 상황에 적합하도록 엄청 섬세한 단위로 IR 접근 콜백을 제공합니다. 모듈에 존재하는 모든 함수의 IR에 접근하기 위해선 우선 모듈 IR에 접근하고, 해당 모듈에 포함된 모든 파일을 순회하며, 해당 파일에 정의된 함수의 IR에 방문해야 합니다. 이를 위해 FPIrVisitor
에서는 visitModuleFragment
, visitFile
, visitFunction
콜백을 사용합니다.
visitModuleFragment
콜백에서 인자로 주어진 모듈에 포함된 모든 코틀린 파일에 방문하도록 구현해 봅시다.
override fun visitModuleFragment(declaration: IrModuleFragment) {
declaration.files.forEach { file ->
file.accept(this, null)
}
}
IrModuleFragment
에 files
포로퍼티를 사용하여 파일의 IR을 나타내는 IrFile
로 구성된 목록을 가져올 수 있습니다. IrFile
목록을 순회하며 해당 파일의 IR에 방문할 수 있도록 FPIrVisitor
자체를 다시 accept
해주고 있습니다. 그러면 FPIrVisitor
가 다시 실행되면서 주어진 IrFile
로 visitFile
콜백이 호출됩니다.
override fun visitFile(declaration: IrFile) {
declaration.declarations.forEach { item ->
item.accept(this, null)
}
}
visitFile
콜백에서는 해당 파일에 정의된 모든 요소를 조회하기 위해 인자로 주어지는 IrFile
에 declarations
프로퍼티를 사용할 수 있습니다. declarations
프로퍼티로 얻어지는 IrDeclaration
목록을 순회하며 해당 요소가 함수에 속한다면 visitFunction
콜백이 실행될 수 있도록 FPIrVisitor
자체를 다시 한번 accept
해줍니다.
override fun visitFunction(declaration: IrFunction) {
val render = buildString {
append(declaration.fqNameWhenAvailable!!.asString() + "(")
val parameters = declaration.valueParameters.iterator()
while (parameters.hasNext()) {
val parameter = parameters.next()
append(parameter.name.asString())
append(": ${parameter.type.classFqName!!.shortName().asString()}")
if (parameters.hasNext()) append(", ")
}
append("): " + declaration.returnType.classFqName!!.shortName().asString())
}
logger.report(CompilerMessageSeverity.WARNING, "[$loggingTag] $render")
}
최종적으로 도달하게 될 visitFunction
콜백에서는 더 이상 accept
은 없고 인자로 주어지는 IrFunction
의 시그니처 조회를 진행합니다. 함수 시그니처를 조회함과 동시에 문자열로 나타내기 위해 buildString
안에서 로직을 작성해 봅시다.
먼저 함수의 fully-qualified name을 작성해 주고, 인자를 작성하기 위한 소괄호를 열어줍니다.
append(declaration.fqNameWhenAvailable!!.asString() + "(")
인자를 작성하기 위해 함수의 value parameter를 모두 조회합니다. 단, 마지막 인자 전까지만 쉼표를 표시하기 위해 iterator
로 조회하였습니다.
함수 인자는 type parameter와 value parameter로 나뉩니다. 제네릭 부분을 type parameter라고 하고, 인자 부분을 value parameter라고 합니다.
val parameters = declaration.valueParameters.iterator()
이제 parameters
를 순회하며 인자의 이름과 타입을 작성해 보겠습니다.
while (parameters.hasNext()) {
val parameter = parameters.next()
append(parameter.name.asString())
append(": ${parameter.type.classFqName!!.shortName().asString()}")
if (parameters.hasNext()) append(", ")
}
마지막으로 인자 괄호를 닫아주고, 함수의 반환 타입을 작성해 줍시다.
append("): " + declaration.returnType.classFqName!!.shortName().asString())
이렇게 해서 함수의 시그니처를 문자열로 나타내 보았습니다. 이제 출력할 차례입니다. 로그 출력은 FPIrVisitor
클래스의 인자로 받는 MessageCollector
에 report
함수를 사용하여 가능합니다. report
함수의 인자로는 로그 레벨과 로그 메시지가 있습니다.
logger.report(CompilerMessageSeverity.WARNING, "[$loggingTag] $render")
FPIrVisitor
가 완성되었습니다!
class FPIrVisitor(
private val logger: MessageCollector,
private val loggingTag: String,
) : IrElementVisitorVoid {
override fun visitModuleFragment(declaration: IrModuleFragment) {
declaration.files.forEach { file ->
file.accept(this, null)
}
}
override fun visitFile(declaration: IrFile) {
declaration.declarations.forEach { item ->
item.accept(this, null)
}
}
override fun visitFunction(declaration: IrFunction) {
val render = buildString {
append(declaration.fqNameWhenAvailable!!.asString() + "(")
val parameters = declaration.valueParameters.iterator()
while (parameters.hasNext()) {
val parameter = parameters.next()
append(parameter.name.asString())
append(": ${parameter.type.classFqName!!.shortName().asString()}")
if (parameters.hasNext()) append(", ")
}
append("): " + declaration.returnType.classFqName!!.shortName().asString())
}
logger.report(CompilerMessageSeverity.WARNING, "[$loggingTag] $render")
}
}
이제 FPIrVisitor
를 accept
하는 FPIrExtension
을 CompilerPluginRegistrar
에 등록해 줍시다.
override fun ExtensionStorage.registerExtensions(configuration: CompilerConfiguration) {
val logger = configuration.get(CLIConfigurationKeys.MESSAGE_COLLECTOR_KEY, MessageCollector.NONE)
val loggingTag = requireNotNull(configuration[KEY_TAG])
// new!
IrGenerationExtension.registerExtension(FPIrExtension(logger, loggingTag))
}
FPIrExtension
은 IrGenerationExtension
을 상속하고 있었으므로 IrGenerationExtension.registerExtension
으로 등록할 수 있습니다.
코틀린 컴파일러 플러그인 개발이 모두 끝났습니다. 이제 실행만 남았습니다.
코틀린 컴파일러 플러그인은 kotlinCompilerPluginClasspath
configuration으로 추가할 수 있습니다. 하드코딩을 피하고 싶다면 코틀린 그레이들 플러그인에서 제공하는 상수인 PLUGIN_CLASSPATH_CONFIGURATION_NAME
을 사용할 수 있습니다.
import org.jetbrains.kotlin.gradle.plugin.PLUGIN_CLASSPATH_CONFIGURATION_NAME
dependencies {
PLUGIN_CLASSPATH_CONFIGURATION_NAME(project(":function-printer-plugin"))
}
CommandLineProcessor
에 등록한 컴파일러 인자 추가도 잊지 말고 해줍니다.
tasks.withType<KotlinCompile> {
val functionPrinterPluginId = "land.sungbin.function.printer"
kotlinOptions {
freeCompilerArgs = freeCompilerArgs + listOf(
"-P",
"plugin:$functionPrinterPluginId:tag=FP",
)
}
}
다음으로 테스트할 함수를 추가해 줍시다.
package land.sungbin.sample
fun helloWorld() = Unit
fun helloWorld2(arg: Any) = arg
fun helloWorld3(arg: Int, arg2: Float) = arg2
이제 ./gradlew build
를 실행하면 지금까지 만든 코틀린 컴파일러 플러그인이 정상적으로 작동하는 걸 확인할 수 있습니다.
이 글에 사용된 프로젝트는 아래 깃허브에서 확인하실 수 있습니다.
긴 글 끝까지 읽어주셔서 감사합니다. 다음 편에서는 IR 변경을 해보도록 하겠습니다.
Reference