엄코딩의 개발 일지

이번 포스팅에서는 시퀀스(sequence) 연산을 통한 속도 개선에 대해 정리해보겠습니다.

 

본글은 Kotlin In Action을 보며 학습 내용을 정리한 글입니다.

 

지연 개발 (lazy) 컬렉션 연산

 

map이나 flilter 같은 함수는 결과 컬렉션을 즉시 생성합니다. 이는 컬렉션 함수를 연쇄하면 매 단계마다 계산 중간 결과를 새로운 컬렉션에 임시로 담는것입니다.

 

시퀀스(sequence)를 사용하면 중간 임시 컬렉션을 사용하지 않고도 컬렉션 연산을 할 수 있습니다.

 

 	val p1 = Person(28,"seonoh")
    val p2 = Person(28,"bitna")
    val p3 = Person(26,"seongyu")
    val p4 = Person(3,"daon")

    val pList = listOf(p1,p2,p3,p4)

    println(pList.map(Person::name).filter { it.startsWith("s") })

 

위와 같은 코드에서 map 결과에 대한 리스트와 filter 결과에 대한 리스트까지 총 2개의 리스트가 반환됩니다.

 

map과 filter는 리스트를 반환합니다.

 

/**
 * Returns a list containing the results of applying the given [transform] function
 * to each element in the original collection.
 */
public inline fun <T, R> Iterable<T>.map(transform: (T) -> R): List<R> {
    return mapTo(ArrayList<R>(collectionSizeOrDefault(10)), transform)
}
/**
 * Returns a list containing only elements matching the given [predicate].
 */
public inline fun <T> Iterable<T>.filter(predicate: (T) -> Boolean): List<T> {
    return filterTo(ArrayList<T>(), predicate)
}

원본 리스트에 원소가 2개밖에 없다면 리스트가 2개 더 생겨도 큰 문제가 되지는 않지만, 원소가 많아질수록 훨씬 더 효율이 떨어지게됩니다.

 

이를 해결할 방법으로 각 연산이 컬렉션을 직접 사용하는 대신 시퀀스를 사용하게 만들어야 합니다.

 

pList.asSequence() // 원본 컬렉션을 시퀀스로 변환한다.
        .map { Person::name }
        .filter { it.name.startsWith("s") }
        .toList() // 결과 시퀀스를 다시 리스트로 변환한다.

위 코드는 중간 결과를 저장하는 컬렉션이 생기지 않기 때문에 원소가 많은 경우 성능이 좋아집니다.

 

코틀린 시쿠컨스는 Sequence 인터페이스에서 시작합니다. 이 인터페이스는 단지 한번에 하나씩 열거될 수 있는 원소의 시퀀스를 표현합니다.

 

Sequence 안에는 iterator라는 단 하나의 메소드가 존재하는데 이 메소드를 통해 시퀀스로부터 원소 값을 얻을 수 있습니다.

 

Sequence 인터페이스의 강점은 그 인터페이스 위에 구현된 연산이 계산을 수행하는 방법때문에 생깁니다.

 

시퀀스의 원소는 필요할 때 계산됩니다. 따라서 중간 처리 결과를 저장하지 않고도 연산을 연쇄적으로 적용해서 효율적으로 계산을 수행할 수 있습니다.

 

왜 시퀀스를 다시 컬렉션으로 되돌려야 할까?

 

컬렉션보다 시퀀스가 훨씬 낫다면 그냥 시퀀스를 쓰는 편이 낫지 않을까? 생각할 수 있습니다.

 

본인도 마찬가지로 책을 보면서 그런 생각을 했습니다. 하지만 자연스럽게 생각이 나더군요.

 

개발을 하다보면 원소하나하나에 인덱스를 사용해 접근하는 등 다른 API 메소드가 필요하다면 시퀀스를 리스트로 변환해야 한다는 것을...

 

시퀀스 연산 실행 순서

시퀀스에 대한 연산은 중간연산과 최종연산으로 나뉩니다. 중간 연산은 다른 시퀀스를 반환합니다.

 

그 시퀀스는 최초 시퀀스의 연산을 반환하는 방법을 알고 있고, 최종 연산은 결과를 반환합니다.

 

pList.asSequence() // sequence
        .map { Person::name } // 중간 연산
        .filter { it.name.startsWith("s") } // 중간 연산
        .toList() // 최종 연산

 

결과는 최초 컬렉션에 대해 변환을 적용한 시퀀스로부터 일련의 계산을 수행해 얻을 수 있는 컬렉션이나 원소, 숫자 또는 객체입니다.

 

 listOf(1,2,3,4)
        .asSequence()
        .map { print("map($it)  ");it*it }
        .filter { print("filter($it)  "); it%2 == 0 }

 

위 코드를 실행해보면 아무 내용도 출력되지 않습니다. 왜그럴까요?

 

sequence 중간 연산은 항상 지연 계산됩니다. 위 코드에서 map과 filter 변환이  늦춰져서 결과를 얻을 필요가 있을 때 ( 최종 연산이 호출되는 경우) 적용됩니다.

 

아래 코드와 같이 toList() (최종연산) 을 호출하게되면 늦춰졌던 모든 계산이 수행됩니다.

 

listOf(1,2,3,4)
        .asSequence()
        .map { print("map($it)  ");it*it }
        .filter { print("filter($it)  "); it%2 == 0 }
        .toList()

 

출력 결과 : map(1)  filter(1)  map(2)  filter(4)  map(3)  filter(9)  map(4)  filter(16) 

 

직접 연산을 구현한다면 map 함수를 각 원소에 대해 먼저 수행해서 새 시퀀스를 얻고, 그 시퀀스에 대해 다시 filter를 수행할 것입니다.

 

컬렉션에 대한 map과 filter는 그런 방식으로 작동합니다.

 

하지만 시퀀스에 대한 map과 filter는 그렇지 않습니다. 시퀀스의 경우 모든 연산은 각 원소에 대해 순차적으로 적용됩니다.

 

즉, 첫번째 원소가 (변환된 다음에 걸러지면섴0 처리되고, 다시 두번째 원소가 처리되며 모든 원소가 이렇게 처리됩니다.

 

따라서 원소에 연산을 차례대로 적용하다가 결과가 얻어지면 그 이후의 원소에 대해서는 변환이 이뤄지지 않을 수도 있습니다.

 

아래 예를 통해 살펴보겠습니다.

 

 println(listOf(1,2,3,4).map { print("access $it  ");it*it }.find { print("result $it  "); it>3 })

 

위 코드 같은 경우 map의 결과값들인 1,4,9,16 을 먼저 구하고, 1은 3보다 작으니 해당하지 않으며 4는 3보다 크니 조건에 해당되며 결과값이 됩니다.

 

실행 결과

access 1  access 2  access 3  access 4  result 1  result 4  4

원소 1,2,3,4 모두 접근하고 있는 것을 확인 할 수 있습니다.

 

이와는 다르게 sequence는 어떨까요?

println(listOf(1,2,3,4)
	.asSequence()
    .map { print("access $it  ");it*it }
    .find { print("result $it  "); it>3 })

 

시퀀스를 사용하면 지연 계산으로 인해 원소 중 일부의 계산은 이루어지지 않습니다. 말 그대로 시퀀스 (순차적)

 

실행 결과

access 1  result 1  access 2  result 4  4

 

그리고 컬렉션에 대해 수행하는 연산의 순서도 성능에 영향을 끼칩니다.

 

map을 먼저 한 경우에는 모든 원소가 변환된 후에 filter처리를 하지만, filter를 먼저 하게되면 불필요한 원소를 제외시키고 map 처리를 하게됩니다.

 

아래 이미지가 이해에 큰 도움이 되어 똑같이 그려봤습니다.

map 연산 후 filter

 

filter 연산 후 map

 

 

참고 자료 : Kotlin In Action, https://kotlinlang.org/