본문으로 건너뛰기

Kotlin으로 지연평가 구현하기

· 약 11분
Jake Son

지연평가는 함수형 프로그래밍에서 자주 등장하는 개념이며 어떤 표현식의 값을 필요로 할 때까지 평가를 미루는 방법이다. 함수형 언어에서는 기본으로 모든 계산이 지연평가로 동작하기에 따로 구현할 필요는 없지만 그렇지 않은 언어에서는 각자의 언어 기능을 활용해 구현한다.

코틀린에서는 표준 위임 클래스 중 하나인 Lazy 를 통해 지연평가를 사용할 수 있다. 하지만 이는 완벽한 지연평가라기 보다 프로퍼티의 지연 초기화에 가깝다. 이번 글에서는 메모이제이션 기능이 있는 커스텀 Lazy 클래스를 만드는 과정을 기술하려고 한다.

Lazy 위임 클래스​

먼저 코틀린의 Lazy 위임 클래스를 사용한 코드를 살펴보자.

internal class LazyTest {
@Test
fun `내장 Lazy 테스트`() {
// given
class Foo {
val bar: String by lazy {
println("lazy")
"bar"
}

init {
println("init")
println(bar)
}
}

// when
val foo = Foo()
println(foo.bar)
}
}

위 테스트 코드를 실행한 후 로그를 살펴보면 다음과 같이 출력된다.

init
lazy
bar
bar

Foo 인스턴스 생성 시 init 이 실행되고 "init" 이 출력된다. 이후 print(bar) 에서 처음 bar 를 참조하며 by lazy 에 지정한 메소드가 실행된다. 따라서 "lazy", "bar" 가 출력되고 이후 foo.bar 를 참조할 때에는 "lazy" 는 출력되지 않는것을 확인할 수 있다.

추가기능 구현​

이처럼 Lazy 위임 클래스는 프로퍼티의 초기화를 지연시킬 수 있는 기능을 제공해주고 이를 확장해 다음 기능을 추가로 구현하려고 한다.

메모이제이션​

정보

메모이제이션(memoization)은 컴퓨터 프로그램이 동일한 계산을 반복해야 할 때, 이전에 계산한 값을 메모리에 저장함으로써 동일한 계산의 반복 수행을 제거하여 프로그램 실행 속도를 빠르게 하는 기술이다. (출처: wikipedia)

이전 예제코드에서 by lazy 에 기술한 초기화 로직은 한 번만 실행되고 이후에는 bar 의 값을 바로 사용하듯이 함수의 로직은 한 번만 실행되고 이후에는 결과를 바로 제공한다.

합성​

지연평가 인스턴스가 반환하는 결과에 새로운 함수를 합성해 새로운 지연평가 인스턴스를 만들어준다. 합성하는 함수는 일반적인 값을 반환하거나 새로운 지연 클래스를 반환하는 형태를 가진다. 예를들면 아래와 같은 타입의 함수를 말한다.

fun normal(a: Int): String
fun newLazy(a: String): Lazy<Boolean>

커스텀 Lazy 클래스​

선언​

먼저 아래와 같은 커스텀 Lazy 클래스를 작성한다.

class Lazy<out A>(fn: () -> A) {
private val value by lazy(fn)
operator fun invoke(): A {
return value
}
}

Lazy 는 generic 클래스로 생성자 파라미터로 함수를 받으며 내부 value 변수의 초기화를 위해 사용된다. invoke 메소드는 클래스의 인스턴스를 마치 함수호출 하듯이 작성하면 수행되는 로직으로 단순히 value 를 반환한다. 이 메소드가 처음 수행되면 초기화 함수가 실행되며 이후에는 할당을 완료한 value 를 바로 반환한다. 이처럼 코틀린의 기본문법만 사용해도 지연평가와 메모이제이션을 손쉽게 구현할 수 있다는 것을 알 수 있다.

코틀린을 통해 얻을 수 있는 또 하나의 장점은 Lazy 인스턴스 생성코드가 마치 함수 선언문처럼 보이게 만들 수 있다는 것이다. 이거는 개인적인 취향이긴 하지만 마치 Lazy 로 동작하는 block 을 만드는 것처럼 보여 더 깔끔해보인다.

val lazy = Lazy {
println("lazy")
true
}
val result = lazy() // prints "lazy", returns true

논리연산​

프로그래밍 언어에서 논리연산로 등장하는 or, and 는 보통 첫 번째 표현식의 결과에 따라 다음 표현식을 평가하지 않도록 동작한다. 예를들면 or 는 첫 번째 표현식이 true 이면 다음 표현식은 평가하지 않는다. Lazy 인스턴스로 감싼 경우에도 같은 방식으로 동작하게 된다.

@Test
fun `Or 동작 테스트`() {
// given
val lazyA = Lazy {
true
}
val lazyB = Lazy<Boolean> {
throw Error("error")
}

// when
val result = lazyA() || lazyB()

// then
assertTrue(result)
}

만약 lazyB() 표현식을 평가한다면 에러가 발생하지만 lazyA() 가 true 를 반환하기 때문에 무시되고 테스트를 통과한다.

map​

이제 함수를 합성할 수 있는 기능을 추가해보자. map 메소드는 지연평가 기능이 없는 일반적인 함수를 받아 새로운 Lazy 인스턴스를 만든다. 마치 List 의 map 메소드가 배열형태는 그대로 유지한채로 내부 값들만 바꾸는것처럼 지연평가와 메모이제이션 기능은 그대로 유지한채로 최종 평가 결과물만 달라지게 해준다.

class Lazy<out A>(fn: () -> A) {
private val value by lazy(fn)

fun <B> map(fn: (A) -> B): Lazy<B> = Lazy { fn(value) }
}

코드를 보면 구현이 매우 간단한 것을 알 수 있다. 주어진 함수를 내부 value 를 전달해 호출하는 로직을 Lazy 로 감싸면된다. 테스트 코드를 살펴보자.

@Test
fun `Map 동작 테스트`() {
// given
var count = 0
val lazy = Lazy {
count++
10
}

// when
val result = lazy.map { a -> a + 10 }

// then
assertEquals(count, 0)
assertEquals(result(), 20)
assertEquals(count, 1)
}

lazy 인스턴스는 실행 시 count 를 1 증가시키고 10 을 반환한다. 이후 map 을 호출해 내부값을 10 증가시키는 새로운 Lazy 인스턴스를 생성한다. 따라서 result 호출 전까지는 count 는 0 이지만 호출이후 1 로 변하게된다. 또한 reulst() 는 20 을 반환하는 것을 알 수 있다.

flatMap​

map 이 일반함수를 합성하는 역할이었다면 flatMap 은 Lazy 인스턴스를 반환하는 함수를 합성하는 역할을한다. 만약 해당함수를 map 으로 합성하면 결과가 Lazy<Lazy<A>> 형태가 되기 때문에 사용하기 매우 불편해진다. 그래서 flatMap 은 이름 그대로 Lazy 를 한 단계 flatten 해주는 역할을 한다.

class Lazy<out A>(fn: () -> A) {
private val value by lazy(fn)

fun <B> flatMap(fn: (A) -> Lazy<B>): Lazy<B> = Lazy { fn(value)() }
}

이번에도 구현은 간단하다. fn(value) 가 Lazy 인스턴스를 반환하므로 바로 평가한 후 결과를 Lazy 로 다시 감싸면 된다.

@Test
fun `FlatMap 동작 테스트`() {
// given
var count = 0
val lazy = Lazy {
count++
10
}

// when
val result = lazy.flatMap { a -> Lazy { a + 10 } }

// then
assertEquals(count, 0)
assertEquals(result(), 20)
assertEquals(count, 1)
result()
assertEquals(count, 1)
}

테스트 코드도 map 과 거의 유사하며 result() 를 2번 수행해도 count 의 값은 1 로 유지되는 것을 알 수 있다.

sequence​

마지막으로 Lazy 인스턴스의 리스트를 다룰 때 사용하는 sequence 를 구현해보자. sequence 메소드는 node.js 개발자에게 친숙한 Promise.all 과 비슷한 역할을 한다.

Promise.all 은 promise 의 배열타입인 Array<Promise<A>> 를 Promise<Array<A>> 형태의 단일 promise로 만들어준다. 마찬가지로 sequence 는 List<Lazy<A>> 형태를 Lazy<List<A>> 의 단일 Lazy 인스턴스로 만들어준다.

fun <A> sequence(list: List<Lazy<A>>): Lazy<List<A>> = Lazy { list.map { it() } }

주어진 리스트를 순회하면서 각 Lazy 인스턴스를 평가해 List<A> 를 얻는 로직을 Lazy 로 감싼다.

@Test
fun `Sequence 동작 테스트`() {
// given
var count = 0
val list = (1..10).map {
Lazy {
count++
it
}
}

// when
val result = sequence(list)

// then
assertEquals(count, 0)
assertEquals(result(), (1..10).toList())
assertEquals(count, 10)
result()
assertEquals(count, 10)
}

list 변수는 Lazy 인스턴스들의 배열로 최종 평가되는 값을 얻으려면 각 배열을 순회에 일일히 값을 평가하는 로직을 작성해야한다. 하지만 sequence 함수를 활용해 단일 Lazy 인스턴스로 만들었고 그 결과인 result 를 수행하면 쉽게 결과를 얻을 수 있다.