본문으로 건너뛰기

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 이 일반함수를 합성하는 역할이었다면 flatMapLazy 인스턴스를 반환하는 함수를 합성하는 역할을한다. 만약 해당함수를 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로 만들어준다. 마찬가지로 sequenceList<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 를 수행하면 쉽게 결과를 얻을 수 있다.