개요
코틀린은 모든 객체의 확장함수로 apply, let, run, also 등 제법 많은 편의기능을 제공합니다. 처음에는 익숙하지 않을 수 있지만 자주 사용하다보면 오히려 가독성을 높이고 커뮤니케이션을 편하게 할 수 있는 일종의 약속처럼 쓸 수 있습니다.
이번 글에서는 이 중 apply를 집중적으로 살펴보겠습니다.
값 객체의 문제
apply는 대상을 this 컨텍스트로 인식하는 블록을 실행하고 블록의 결과와 상관없이 this를 반환하는 함수입니다.
하지만 this가 값으로 식별되는 객체인가 아닌가에 따라 상당한 혼란을 일으킵니다. 예를 들어 다음과 같은 클래스가 있다고 생각해보죠.
class Person{ var name = "" var age = 0 }
이 경우 이 객체는 값으로 식별되지 않고 인스턴스 자체가 식별의 대상이 됩니다. 따라서 apply의 사용도 굉장히 자연스럽습니다.
val hika = Person().apply{ name = "hika" age = 20 }
왜냐면 apply의 반환값이 처음 주어진 Person()이고 이게 바르기 때문입니다.
하지만 값 객체는 다른 특성을 갖고 있습니다.
- 값으로 식별되며
- 불변값이고
- 복제된다
라는 거죠. 대표적으로 문자열을 들 수 있습니다. 문자열은 불변이며 코틀린에서는 == 를 통해 값으로 식별되며 매번 복사됩니다.
val result = "abcd".apply{substring(0,2)}
과연 이 결과는 어떻게 되는 것인가요. 당연히 result에는 “abcd”가 들어있습니다. 왜냐면 substring은 분명 문자열의 메소드가 맞지만 그 결과는 새로운 문자열을 만들어내기 때문입니다. 따라서 this인 “abcd”에는 아무런 변화가 없습니다.
값 객체의 특성을 띠는 클래스는 도메인을 분석하여 설계하다보면 자주 등장하기 마련입니다. 대표적으로 Money같은 클래스를 들 수 있습니다.
enum class Currency(val ratePerUSD:BigDecimal){ USD(BigDecimal(1.0)), KRW(BigDecimal(1126.0)), JPY(BigDecimal(104.06)); fun convert(value:BigDecimal, target:Currency):BigDecimal{ val convertUSD = value.divide(ratePerUSD) //우선 달러로 바꾼 뒤 return convertUSD.multiply(target.ratePerUSD) //해당 통화로 변환 } } data class Money(private val value:BigDecimal, private val currency:Currency){ fun plus(money:Money, currency:Currency) = Money(convert(currency).plus(money.convert(currency)).value, currency) fun minus(money:Money, currency:Currency) = Money(convert(currency).minus(money.convert(currency)).value, currency) fun convert($currency:Currency) = Money(currency.convert(value, $currency), $currency) }
이런 클래스들은 객체지만 실제적인 비교는 data class로서 값을 통해 하게 되므로 ==로 식별합니다. 즉 다른 인스턴스라 할지라도 value와 currency가 같다면 같은 것으로 처리되는 것이죠.
더불어 plus, minus, convert 연산을 보면 매번 새로운 Money를 만들어 반환하는 전형적인 값객체의 특성을 지니고 있습니다.
이런 값객체는 문자열과 마찬가지로 apply를 사용하면 대부분의 개발자가 기대했던 것과 다른 결과를 얻게 됩니다.
val money = Money(BigDecimal(1000), KRW).apply{ plus(Money(BigDecimal(2000), KRW)) plus(Money(BigDecimal(2000), KRW)) }
이 코드의 결과로 1000 + 2000 + 2000 = 5000원을 기대하게 되지만 Money는 매 연산마다 새 객체를 반환하게 되므로 최초의 1000에 전혀 변화가 없어 val money의 값은 그냥 1000원이 됩니다.
따라서 apply를 적용하려는 대상 객체가 값 객체의 특성을 따르는지 반드시 확인 후에 사용해야만 이러한 컨텍스트 에러를 방지할 수 있습니다.
특히 data클래스나 함수지향 코드가 점점 득세하는 현실에서 값 객체는 사방에 도사리고 있기 때문에 apply사용엔 항상 조심해야 합니다.
fluent interface
보통 메소드체이닝(method chaining)이라고도 알려져있는 형태에서는 참여하는 메소드가 지속적으로 this를 반환하여 인스턴스의 부속을 구성하는 인터페이스를 사용합니다. 주로 빌더 패턴이나 파라메터 생성 객체에 주로 사용되는 기법으로 다음과 같은 예제를 생각해볼 수 있습니다.
class Dialog{ var title = "" var showCancel = false fun title(v:String):Dialog{ title = v return this } fun showCancel():Dialog{ showCancel = true return this } fun build(){ } } Dialog().title("hello").showCancel().build()
근데 이건 고전적인 자바코드고 코틀린의 apply는 this를 유지할 수 있으므로 return this를 모두 제거한 뒤 다음과 같이 사용할 수 있습니다.
class Dialog{ var title = "" var showCancel = false fun title(v:String){ title = v } fun showCancel(){ showCancel = true } fun build(){ } } Dialog().apply{ title("hello") showCancel() }.build()
하지만 이렇게 되면 기존 빌더패턴처럼 명시적인 제약으로 Dialog가 빌더패턴을 따르고 있다는 것을 인식하기 힘들죠. 어떤 객체라도 apply로는 저렇게 쓸 수 있기 때문입니다. 그래서 여전히 기존 코드를 유지하고 싶다고 하면 각 메소드에 apply를 적용할 수 있습니다.
class Dialog{ var title = "" var showCancel = false fun title(v:String) = apply{ title = v } fun showCancel() = apply{ showCancel = true } fun build(){ } } Dialog().title("hello").showCancel().build()
apply의 람다를 별도로 공급하는 경우
이쯤에서 확장함수의 개념을 다시 생각해보면서 apply의 정의를 다시 한 번 살펴볼까요.
@kotlin.internal.InlineOnly public inline fun <T> T.apply(block: T.() -> Unit): T { contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) } block() return this }
이 정의에 따르면 block은 T.()->Unit 형의 람다를 인자로 받습니다.
헌데 모든 확장함수는 결국 수신객체가 첫번째 인자로 오는 람다와 동일합니다. 즉
- T.()->Unit은 (T)->Unit과 같고
- T.(Int)->Unit은 (T, Int)->Unit과 같습니다.
(실제로 인자로 받은 block은 위 코드에서 this.block()의 생략된 형태로 block()을 사용하고 있으나 block(this)로 바꿔도 정상작동합니다)
따라서 미리 특정 객체를 초기화하는 레시피를 람다로 만들어두고 적용하는 경우 어찌해야할지 생각해보죠.
class RequestParam{ var url = "" var method = "GET" var body = "" var header = hashMapOf<String, String>() }
통신하기 위해 간단히 통신용 인자를 정리하는 빌더객체를 만들었습니다. 보통은 매 통신마다 apply를 통해 설정하면 되겠지만, 대상 서버와 메소드, 기본 헤더값을 초기값으로 셋팅해준 상태에서 나머지를 전개하고 싶다면 어떻게 해야할까요?
무식하게 apply만 갖고 작성하면 얼추 다음과 같은 느낌적인 느낌입니다.
val getList = RequestParam().apply{ url = "localhost:8080/" method = "POST" header += "Content-Type" to "application/json; charset=utf-8" }.apply{ url += "list" body = """{"page":1}""" }
이 코드를 보면 강제로 두 번의 apply를 사용했는데 첫 부분의 apply내용은 많은 api들이 공유하게 될 기본 설정같은 거고 두 번째 apply는 해당 api만의 내용이 됩니다. 그렇다는건 저 기본 적용을 공통적으로 사용할거니 매번 람다를 기술하는건 웃기는 일이고 미리 만들어진 람다를 적용하는 게 나을 것 입니다.
헌데 apply에 공급할 람다는 RequestParam.()->Unit형이나 (RequestParam)->Unit형이 되면 됩니다.
val baseParam:RequestParam.()->Unit = { url = "localhost:8080/" method = "POST" header += "Content-Type" to "application/json; charset=utf-8" } //또는 val baseParam:(RequestParam)->Unit = { it.url = "localhost:8080/" it.method = "POST" it.header += "Content-Type" to "application/json; charset=utf-8" }
머 귀찮게 두 번째 형태로 람다를 만드는 짓을 왜하냐 싶겠지만 그게 다른 클래스의 메소드로 정의되어있는걸 빌려온다던가 하면 꽤나 자주 발생하게 되는 상황입니다. 그때 이 시그니쳐도 된다 안된다를 아는 건 큰 도움이 되죠.
일단 이렇게 공통 부분을 정의했다면 손쉽게 다 계층으로 적용할 수 있습니다.
//기본 설정 val base:RequestParam.()->Unit = { method = "POST" header += "Content-Type" to "application/json; charset=utf-8" } //로컬 테스트 val local:RequestParam.()->Unit = { base() url = "localhost:8080/" } //라이브 환경 val deploy:RequestParam.()->Unit = { base() url = "aws.live.com/" } //현재 환경 값 val env = local val getList = RequestParam().apply(env).apply{ url += "list" body = """{"page":1}""" } val getView = RequestParam().apply(env).apply{ url += "view" body = """{"page":1, "id":123}""" }
이제 env값만 바꿔주면 모든 Param은 거기에 맞춰 잘 설정될 것입니다.
결론
코틀린은 inline함수를 광범위하게 적용하는 언어입니다. apply도 inline함수이기 때문에 컴파일 타임에 람다의 내용은 전부 인라인화되어 직접 코드를 짠 것과 동일한 성능을 내면서 함수화의 장점을 취할 수 있게 해줍니다.
apply는 자신을 반환한다는 점에서 유의할 점이 많지만 손쉽게 객체 자신의 설정레시피나 다른 협력 객체와의 의존성 처리를 트렌젝션으로 인식할 수 있게 코드의 뉘앙스를 만들죠.
let, also, run 등 각각 특징을 갖는 보조 함수들은 많지만 이걸 또 언제 포스팅할지는.. =.,=;;;
recent comment