본문 바로가기

코틀린

코틀린(Kotlin) - 지연 계산(lazy) 컬렉션 연산 : Sequence

컬렉션 API 중, map이나 filter 같은 함수는 결과 컬렉션을 즉시 생성합니다.

이것은 즉, 컬렉션 함수를 연쇄하면 매 단계마다 중간 계산 결과를 새로운 컬렉션에 임시로 저장하고 있다는 말입니다.

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

 

간단한 예로, 성이 '김'씨인 사람만 추려내는 예제를 살펴보도록 하겠습니다.

 

/* 김씨인 사람만 추리기 */
people.map(Person::name).filter { it.startsWith("김") }

 

코틀린 레퍼런스 문서에는 filter와 map이 리스트를 반환한다고 명시되어 있는데요,

이 말은 이 연쇄 호출이 리스트를 총 2개 만든다는 것을 알 수 있습니다.

한 리스트는 map의 결과를 담고, 다른 하나는 filter의 결과를 담는 것이죠.

원본 리스트에 원소 개수가 적다면 리스트가 2개 정도 더 생겨도 큰 문제가 되지 않겠지만, 만약 원소 개수가 수천~수백만 개가 되면 훨씬 더 효율이 팍팍 떨어질 것입니다.

 

그렇기 때문에, 이런 경우를 더 효율적으로 만들기 위해서는 각 연산이 컬렉션을 직접 사용하는 대신 시퀀스를 사용하게 만들어줘야 합니다.

 

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

 

전체 연산을 수행하면서 중간 결과를 저장하는 컬렉션이 생기지 않기 때문에, 원소가 아무리 많은 경우에도 성능이 눈에 띄게 좋아집니다!

 

Sequence의 강점은 원소가 필요할 때 계산이 되기 때문에, 중간 처리 결과를 저장하지 않고도 연산을 연쇄적으로 적용해서 효율적으로 계산을 수행할 수 있습니다.

 

asSequence 확장 함수를 호출하면 어떤 컬렉션이든 시퀀스로 바꿀 수 있고, 시퀀스를 리스트로 만들 때는 toList를 사용합니다.

 

그런데 왜, 시퀀스를 다시 컬렉션으로 되돌려야 하는 걸까요? 컬렉션보다 시퀀스가 훨씬 더 좋다면 그냥 시퀀스를 쓰는 편이 좋지 않을까요?

이 질문에 대한 답은,

시퀀스의 원소를 차례로 이터레이션 해야 한다면 시퀀스를 직접 써도 되지만,

시퀀스 원소를 인덱스를 사용해 접근하는 등의 다른 API 메소드가 필요하다면 시퀀스를 리스트로 변환해서 사용해야 하기 때문입니다.

 

Tip!
사이즈가 큰 컬렉션에 대해서 연산을 연쇄시킬 때는 시퀀스를 사용하는 것을 습관화하고 규칙으로 삼는 것이 좋습니다.
컬렉션에 들어있는 원소가 많으면 많을수록 중간 원소를 재배열하는 비용이 상당히 커지기 때문에 지연 계산(sequence) 사용이 훨씬 낫습니다.

시퀀스 연산 실행 : 중간 연산과 최종 연산

시퀀스에 대한 연산은 중간(intermediate) 연산과 최종(terminal) 연산으로 나뉩니다.

중간 연산은 다른 시퀀스를 반환하고, 최종 연산은 결과를 반환합니다.

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

 

시퀀스에 대한 중간 연산과 최종 연산

중간 연산은 항상 지연 계산(lazy) 되며, 최종 연산을 호출하였을 때 비로소 모든 계산이 수행됩니다.

 

/* 최종 연산이 없는 경우 */
listOf(1, 2, 3, 4)
  .asSequence()
  .map {
    print("map : $it")
    it * it
  }
  .filter {
    print("filter : $it")
    it > 5
  }

 

위의 코드를 실행하면 아무런 내용도 출력되지 않습니다.

이유는, map과 filter 변환이 지연 계산되기 때문에 결과 얻을 필요가 있는 최종 계산이 호출될 때 비로소 적용이 된다는 뜻입니다.

 

/* 최종 연산이 호출되는 경우 */
val list = listOf(1, 2, 3, 4)
  .asSequence()
  .map {
    print("map : $it")
    it * it
  }
  .filter {
    print("filter : $it")
    it > 5
  }
  .toList()
  
>>> println("리스트 결과 : $list")
map : 1
filter : 1
map : 2
filter : 4
map : 3
filter : 9
map : 4
filter : 16
리스트 결과 : [9, 16]

이렇게 최종 연산인 toList()를 호출하면 그전까지 지연됐던 모든 계산들이 수행됩니다.

 

이 예제에서 시퀀스의 연산 수행 순서를 잘 알아둬야 합니다.

(위의 출력 결과를 눈여겨 봐주세요! map 다음 filter, map 다음 filter...)

 

만약 컬렉션으로 구현한다면 map 함수를 각 원소에 대해 먼저 수행해서 새 컬렉션을 만들고, 그 컬렉션에 대해 다시 filter를 수행할 것입니다. 

 

/** 컬렉션을 사용하는 경우 */
val list = listOf(1, 2, 3, 4)
  .map {
    print("map : $it")
    it * it
  }
  .filter {
    print("filter : $it")
    it > 5
  }
  .toList()
            
>>> println("리스트 결과 : $list")
map : 1
map : 2
map : 3
map : 4
filter : 1
filter : 4
filter : 9
filter : 16
리스트 결과 : [9, 16]

보이시나요? 

map 연산을 먼저 수행한 다음 그 결과가 filter 연산으로 넘어가는 것을 알 수 있습니다.

이렇듯, 컬렉션을 사용하면 리스트가 다른 리스트로 변환된 다음, 그 리스트 전체가 다음 연산으로 넘어가게 됩니다.

반면에 시퀀스의 경우 각 원소에 대해 순차적으로 하나씩 적용이 됩니다. 즉, 첫 번째 원소가 처리되고, 다시 두 번째 원소가 처리되는 형태입니다.

 

이러한 연산 수행 순서는 프로그램의 성능에도 영향을 끼칠 수가 있습니다.

예로, carList 라는 컬렉션이 있는데 가격이 4,000만원 이하인 매물만 얻고 싶다고 하겠습니다.

이를 처리하기 위해서는 각 매물(Car 객체)의 가격(price)으로 map 한 다음에 4,000만원 초과인 매물을 제외시켜야 합니다.

이 경우 map 다음에 filter를 하는 경우와 filter 다음에 map을 하는 경우 결과는 같아도 수행해야 하는 변환의 횟수에서 크게 차이가 납니다.

컬렉션의 크기가 클수록 이 차이는 성능상에서 크게 차이가 날 것입니다.

 

val carList = listOf(Car("현대", "그랜저", 2,090), Car("기아", "K7", 2,300),
			Car("벤츠", "S클래스", 5,000), Car("볼보", "XC90", 8,500))

// map 다음에 filter 수행
>>> println(carList.asSequence().map(Car::price).filter{it <= 4000}.toList())
[2090, 2300]

// filter 다음에 map 수행
>>>  println(carList.asSequence().filter{it.price <= 4000}.map(Car::price).toList())
[2090, 2300]

 

 

filter를 먼저 적용하면 변환 횟수가 줄어든다.

 

위의 그림과 같이, map을 먼저 하면 모든 원소를 이터레이션 하게 됩니다.

반면에 filter를 먼저 하게 되면 조건에 맞는 원소만 이터레이션 하기 때문에 성능상 더 효율적인 이점이 있습니다.

시퀀스 만들기 - asSequence(), generateSequence()

지금까지 살펴본 시퀀스 예제는 모두 컬렉션에 대해 asSequence()를 호출해서 만들었습니다.

시퀀스를 만드는 다른 방법으로 generateSequence 함수도 사용할 수 있습니다.

이 함수는 이전의 원소를 인자로 받아 다음 원소를 계산하는 방식으로 동작합니다.

 

다음은 generateSequence 함수를 사용해서 0부터 100까지 자연수의 합을 구하는 예제입니다.

 

val numbers = generateSequence(0) { it + 1 }
val numbersTo100 = numbers.takeWhile { it <= 100 }
println(numbersTo100.sum()) //모든 연산은 "sum()"이 호출될 때 수행된다. (최종 연산)

>>> 5050

 

또 다른 예제로, 어떤 파일의 상위 디렉터리를 탐색하면서 숨김(Hidden) 속성을 가진 디렉토리가 있는지 검사함으로써 해당 파일이 숨김 디렉토리 안에 들어있는지 찾아내는 예제입니다.

 

/** 상위 디렉토리 시퀀스를 생성하고 사용하는 예제 */

//확장함수
fun File.isInsideHiddenDirectory() = generateSequence(this) { it.parentFile }.any { it.isHidden } //최종 연산

val file = File("/Users/.HiddenDir/a.txt")

println(file.isInsideHiddenDirectory())

>>> true

 

여기서도 첫 번째 원소를 지정하고, 시퀀스의 인자로 받아 다음 원소를 계산하는 방법을 제공함으로써 시퀀스를 만들고 있습니다.

이렇게 시퀀스를 사용하면 조건을 만족하는 디렉토리를 찾은 뒤에는 더 이상 상위 디렉토리를 탐색하지 않게 되며,

any를 find로 바꾸면 원하는 디렉터리도 찾을 수 있습니다.