목차
이번 게시글은 Effective Kotlin — Chapter 8. Effective Collection에 대한 내용입니다.
애플리케이션을 만들면서 여러 리스트 처리를 많이 하게 됩니다. 리스트에 필터링을 걸기도 하고, 각 인스턴스를 변환하기 위해 매핑 하기도 합니다.
옛날 방식으로 리스트를 변환한다면 다음과 같이 만들어야 할 것입니다.
이건 한눈에 봐도 복잡하며, 의도가 잘 드러나지 않습니다. 하지만 Kotlin Collections
함수를 이용해서 보다 명확하게 표현할 수 있습니다.
단순하게 짧아지는 것 뿐만 아니라, 명확하게 의도가 드러나는 코드로 변경할 수 있습니다.
하지만 이렇게 파이프라인(List.filter()
, List.sortedByDescending()
등)이 많을 경우엔 Kotlin Sequence
를 사용하는 게 좋습니다.
1. 하나 이상의 처리 단계를 가진 경우에는 Sequence를 사용하라
많은 사람이 Iterable
과 Sequence
의 차이를 잊어 버립니다. 사실 정의가 거의 동일하므로 그럴 수 있습니다.
인터페이스는 둘 다 동일하지만, 완전히 다른 목적으로 설계되었습니다.
Sequence
는 lazy 하게 실행됩니다. Sequence
함수를 사용하면, 데코레이터 패턴으로 꾸며진 새로운 시퀀스가 리턴 됩니다. 그리고 최종적으로 toList()
, count()
등과 같이 sequence
실행 함수를 실행시키면 iterator
를 순회 하면서 실행되게 됩니다.
Iterable
은 즉시 실행됩니다. 또한 파이프라인이 실행 될 때 마다, 새로운 Collection
을 생성하게 됩니다.
그렇기 때문에 파이프라인이 많다면 매 파이프라인 마다 새로운 Collection
을 생성하고, 거기에 담고, 그 다음 파이프라인을 실행하는 형태 이므로, 오버 헤드가 있습니다.
순서의 중요성
Iterable
과 Sequence
는 연산 결과는 동일하지만, 동작 과정은 다릅니다.
Iterable
은 Step by Step 형태로 진행되며, Sequence
는 element by element 형태로 처리 됩니다.
좀 더 구체적으로 다음 예시를 보겠습니다.
최소 연산
컬렉션에 어떤 처리를 적용하고, 조건에 맞는 특정 데이터 하나를 찾는 상황은 굉장히 자주 접할 수 있는 상황입니다.
Iterable
은 step by step 이므로, 리스트 아이템 1000개 중 찾는다면, 1000개 모두 처리 후에 찾을 것 입니다.
Sequence
는 element by element 이므로, 리스트 아이템 1000개 중 찾는다면 , 찾을 때까지만 어떤 처리를 적용 할 것입니다.
그래서 이런 경우에 Sequence
를 사용하는 것이 훨씬 빠릅니다.
무한 시퀀스
시퀀스는 실제로 실행 함수를 호출하기 전까지는 컬렉션에 어떠한 처리도 하지 않습니다. 따라서 무한 시퀀스를 만들고, 필요한 부분 까지만 값을 추출하는 것이 가능합니다.
무한 시퀀스를 만드는 일반적인 방법은 generateSequence()
또는 sequence()
를 사용하는 것 입니다.
generateSequencegenerateSequence()
를 사용하려면 먼저 첫 번째 요소와 그 다음 요소를 계산하는 로직을 지정 해야 합니다.
sequencesequence()
는 suspend
람다를 전달합니다.. 그리고 리시버로 전달되는 시퀀스 빌더를 이용해 yield()
라는 suspend
함수로 값을 하나씩 방출 할 수 있습니다.
take()
의 갯수만큼 데이터를 방출하고 무한 루프는 종료됩니다.
이게 가능한 이유는 yield()
가 suspend
함수이므로, take
갯수만큼 데이터가 방출 됬을 때 코루틴을 cancel 시켜서 다시 해당 block으로 돌아가지 못하기 때문입니다.
물론 당연하게 take()
등의 함수로 sequence
의 방출 범위를 제한해 주지 않으면 무한루프에 빠집니다.
print(fibonacciSequence.toList()) // 무한루프에 빠진다.
각각의 단계에서 컬렉션을 만들어 내지 않음
Kotlin Collection
함수는 각 파이프라인 별로 새로운 컬렉션을 만들어 냅니다. 일반적으로 List
입니다.
각각의 단계에서 만들어진 결과를 활용하거나, 저장 할 수 있다는 장점이 있지만, 각각의 단계에서 새로운 컬렉션의 담는 시간(O(N))과 메모리를 그만큼 차지 한다는 단점이 있습니다.
그렇기 때문에 특히나 데이터양이 많고, 파이프라인이 많다면 Sequence
와 Collection
함수의 속도차이는 매우 심합니다.
위의 예제 같이 리스트의 사이즈가 10,000,000일 때 맥북 프로 2018(i7, 16G, 256G)기준 평균 2~3배 정도의 Sequence
가 빠릅니다.
시퀀스가 빠르지 않은 경우
컬렉션 전체를 순회 해야만 하는 연산은 오히려 Collection
함수가 더 빠릅니다.
Collection
함수는 inline
함수고, Sequence
는 inline
함수가 아니기 때문에 객체 생성의 비용이 발생합니다.
결과적으로 생성되는 컬렉션 갯수가 동일하다면 Collection
함수가 더 빠릅니다.
2. 컬렉션 파이프라인 수를 제한하라
Collection
함수는 매 파이프라인 마다 새로운 Collection
을 생성하기 때문에 비용이 많이 듭니다.
Sequence
도 파이프라인마다 Sequence
를 Wrapping하는 객체가 만들어 집니다.
그렇기 때문에 같은 결과라면 파이프라인 수를 최소화 하는 게 좋습니다.
3. 성능이 중요한 부분에는 기본 자료형 배열을 사용하라
코틀린은 기본 자료형을 선언할 수 없지만, 내부적으로 최적화를 위해서 변환 해줍니다.
기본자료형은 객체보다 더 가볍고, 값에 접근하기 위한 추가 비용이 들지 않으므로, 더 빠릅니다.
그래서 대규모의 데이터를 처리할 때, 기본 자료형을 사용하면, 상당히 큰 최적화가 이뤄집니다.
Java에서는 int[], float[], double[] 등이 있으며, Kotlin에는 IntArray, FloatArray, DoubleArray 등이 있습니다.
1,000,000개의 데이터를 가진 리스트에서 평균 값을 구하는 예제를 통해 속도를 측정해보도록 하겠습니다. (테스트 환경 — 2018 맥북프로i7, 16G, 256G)
4. mutable 컬렉션 사용을 고려하라
immutable 컬렉션 보다 mutable 컬렉션이 좋은 점은 성능이 더 빠르다는 점입니다. 컬렉션에 아이템을 추가하고자 하면 immutable 컬렉션은 새로운 컬렉션을 만들어야 하지만, mutable 컬렉션은 바로 아이템만 추가하면 되기 때문입니다.
객체 내부에서만 사용하려면 mutable 컬렉션을 사용하는 게 좋습니다.
내부에서 아이템이 add, remove을 해야할 때는 immutable, mutable 컬렉션 둘 다 동기화 이슈가 발생합니다. 그렇기 때문에 mutable 컬렉션을 사용하고 mutex 처리를 해줌으로써 성능과 멀티스레딩 문제를 둘 다 해결할 수 있습니다.
그리고 mutable 컬렉션을 객체 외부에 노출해야 할 때는 immutable 컬렉션으로 변경하여 외부에서 객체의 상태를 변경할 수 없게 해야합니다.
Chapter 8. Effective Collection을 마지막으로 Effective Kotlin에 대한 내용 요약은 끝이 났습니다.
이 책을 읽음으로써, 그간 가렵게 느껴졌던 부분이 해소되는 느낌을 많이 받았습니다.
블로그 글도 좋지만, 한글 번역본도 나왔으므로 책을 꼭 사셔서 한번 보시는 것을 권장 드립니다.
읽어주셔서 감사합니다.