Kotlin에는 컬렉션을 다루기 편하게 확장함수들을 제공해준다. Java에 스트림 함수를 사용해본적이 있다면 친숙할 것이다. map, filter와 같은 메소드가 kotlin에서의 컬렉션 함수 API다.
만약에 Person 객체들을 리스트로 받아서 '키가 150인 사람들의 이름'만 추출해야하는 메소드를 작성해야한다면 어떻게 해야할까?
이번 포스팅에서는 리스트로 받은 데이터를 가공하고 처리하기 쉽게 하는 함수 API에 대해서 정리하려고 한다.
컬렉션 함수 API
위에서 들었던 메소드를 작성하기 위해서 어떻게 해야할까? 함수 API를 사용하지 않으면 반복문과 조건문을 활용해 코드를 작성할 것이다.
data class Person(
val name: String, // 이름
val height: Int // 키
)
// 일정 키보다 작은 사람들의 이름을 찾는 메소드
fun findNamesByHeightLessThan(persons: Collection<Person>, maxHeight: Int): Collection<String> {
val names = mutableListOf<String>()
for (person in persons) {
if (person.height < maxHeight) {
names.add(person.name)
}
}
return names
}
위 코드처럼 for문과 if문을 활용해 작성할 수 있다. 지금은 요구사항이 간단하기 때문에 6줄로 코드가 간결하지만, 요구사항이 많아진다면 가독성이 떨어질 수 있다. 그럼 이번에는 코틀린에서 제공하는 컬렉션 함수 API를 활용해 코드를 재작성해보겠다.
// 컬렉션 함수 API를 사용한 예시
fun findNamesByHeightLessThan(persons: Collection<Person>, maxHeight: Int): Collection<String> {
return persons.filter { it.height > maxHeight }
.map { it.name }
}
위 코드보다 훨씬 간결해진 것을 볼 수 있다. filter 함수를 통해 maxHeight보다 height가 작은 Person 객체만 남긴다. 그렇게 남은 Person 객체들에서 map 함수를 통해 name 값만 추출하는 것이다.
filter, map 구현 코드
이러한 filter, map 함수들이 어떻게 구현되어있는지 실제로 코드를 확인해보자.
filter 함수는 정확히는 Collection의 확장함수가 아닌 Iterable의 확장함수다. Collection 인터페이스는 Iterable 인터페이스를 구현하고 있으므로, Collection 객체에서도 이 확장함수를 사용할 수 있는 것이다.
filter 함수는 내부적으로 for 문을 사용해서 인수로 받은 predicate 함수를 실행한 뒤 결과에 따라 ArrayList에 담아서 반환한다. 즉, 처음에 작성했던 코드와 동일하게 동작하지만 코드는 상당히 간결해지게 된다.
map 함수 역시 filter와 동일하게 Iterable 확장함수이고, 내부적으로 for문을 사용해 결과를 반환하고 있다.
이런 컬렉션 함수 API를 사용했을 때 얻는 장점은 '간결함' 밖에 없는 것일까? 아니다. 앞서 설명했던 이름들에서 2번 이상 등장한 이름들만 추출해야하는 새로운 요구사항이 추가되었다고 가정해보자.
먼저, 컬렉션 함수 API를 사용하지 않았을 때 이런식으로 작성할 것이다.
// 일정 키보다 작은 사람들의 이름 중 2번 이상 등장한 이름을 찾는 메소드
fun findNamesByHeightLessThan(persons: Collection<Person>, maxHeight: Int): Collection<String> {
val nameCountMap = hashMapOf<String, Int>()
for (person in persons) {
if (person.height < maxHeight) {
nameCountMap[person.name] = (nameCountMap[person.name] ?: 0) + 1
}
}
val names = mutableListOf<String>()
for ((name, count) in nameCountMap.entries) {
if (count >= 2) {
names.add(name)
}
}
return names
}
정말 복잡하다. HashMap을 사용해서 등장 횟수를 카운트하고, Map을 순회하여 2번 이상 등장한 이름들을 names 리스트에 넣는다.
이번에는 컬렉션 함수 API를 사용했을 때의 코드를 비교해보자
// 컬렉션 함수 API를 사용한 예시
fun findNamesByHeightLessThan(persons: Collection<Person>, maxHeight: Int): Collection<String> {
return persons.filter { it.height > maxHeight }
.map { it.name }
.groupBy { it } // 이름별로 그룹화
.filter { (_, names) -> names.size >= 2 } // 그룹화된 이름이 2개 이상인 경우
.map { it.key } // 이름 추출
}
기존 코드에 체이닝해서 적은 코드로 요구사항을 만족시킬 수 있다. 게다가, 기존에 사용했던 filter, map 함수는 건드리지 않고도 수정할 수 있었다. 들여쓰기가 하나도 사용되지 않아서 읽기도 용이하고, 단계별로 결과를 유추하기 쉽기 때문에 이해도 빠르다.
이처럼 컬렉션 확장함수들을 잘 활용하면 함수형처럼 프로그래밍을 할 수 있게되고, 모던한 코드를 작성할 수 있다.
유용한 컬렉션 함수 API
다음은 알아두면 유용한 컬렉션 함수 API들이다.
확장 함수 | 용도 | 용례 |
filter() | 주어진 식에 true를 반환하는 Element만 남기고 나머지는 제거하고 싶을 때 사용 | .filter { it.height > 150 } |
map() | 컬렉션의 엘리먼트들을 다른 형태로 변환하고 싶을 때 사용 | .map { it.name } |
associateBy() | 컬렉션의 각 엘리먼트들을 특정 키에 매핑해서 Map 형태로 변환하고 싶을 때 사용 | .associateBy { it.name } // Map<String, Person> |
associateWith() | 컬렉션의 각 엘리먼트들을 키로 사용하고, 그에 맞는 Value와 매핑해서 Map 형태로 변환하고 싶을 때 사용 | .associateWith { it.name } // Map<Person, String> |
associate() | 컬렉션을 Map 형태로 변환하고 싶은데, 키와 밸류값을 전부 커스텀하고 싶은 경우 | .associate { it.name to it.height } // Map<String, Int> |
zip() | 리스트를 인자로 받아서 두개를 List<Pair<A, B>> 형태로 변환 | .zip(addresses) // List<Pair<Person, String>> ※ 결과에서 .toMap()을 사용하면 Map 형태로 변환된다. (Map<Person, String>) |
filterNotNull() | 컬렉션 내에서 Null 값을 제거한 컬렉션으로 만들고 싶은 경우 ※ NotNull 타입이 됨 (ex. List<String?> --> List<String>) |
listOf<String?>(null).filterNotNull() // List<String> |
mapNotNull() | map() 함수와 동일하지만, 변환결과가 Null인 것은 제거 | .mapNotNull { it.name } |
drop() | 컬렉션의 맨 앞에서 개수만큼 엘리먼트 제거 | .drop(3) // 맨앞에서 엘리먼트 3개 제거 |
dropLast() | 컬렉션의 맨 뒤에서 개수만큼 엘리먼트 제거 ※ 순서가 있는 컬렉션(List)에만 사용 가능 (ex. Set 사용 불가) |
.dropLast(3) |
find() | 컬렉션을 순회하여 가장 처음 주어진 식을 만족하는 element 반환 | .find { it.name == "홍길동" } |
findLast() | 컬렉션을 순회하여 가장 마지막에 주어진 식을 만족하는 element 반환 | .findLast { it.name == "홍길동" } |
컬렉션 함수 API의 단점
하지만, 언제나 그렇듯 은탄환(Silver bullet)은 없다.
컬렉션 함수 API을 무분별하게 사용하다보면 성능이 안좋아질 수 있다. 그 이유는 바로 컬렉션 함수 API는 모든 엘리먼트에 대해 한 단계를 전부 수행한 뒤에 다음 단계로 넘어간다.
val persons = listOf(
Person("홍길동", 163),
Person("헬렌켈러", 170),
Person("스티브 잡스", 188),
Person("두아 리파", 173),
)
persons.filter {
println("filter : $it")
it.height > 170
}
.map {
println("map : $it")
it.name
}
persons에서 키가 170을 넘는 사람의 이름만 추출하는 함수 API를 활용한 코드다. 중간에 println()을 통해 실제로 어떻게 동작하는지를 추적해보자. 결과는 아래와 같다.
filter 함수를 모두 거쳐서 결과를 걸러내고 컬렉션을 새로 만든 다음, 그 컬렉션에 대해서 map을 수행하고 있다.
예제 데이터는 데이터양이 작아서 문제가 없지만, 처리할 데이터가 엄청나게 많아서 특정 단계의 결과가 엄청나게 커지게 되면 메모리 릭(Out Of Memory) 이슈가 발생할 수 있다. 이렇게 처리하는 방식을 Eager Evaluation이라고 한다.
그렇다면 각각 Element에 대해 모든 단계를 거친 결과를 저장하도록 하려면 어떻게 해야할까? 그건 다음 포스팅에서 설명하도록 하겠다.
'Kotlin' 카테고리의 다른 글
Kotlin 컬렉션 함수 API - Lazy Evaluation을 위한 Sequence (0) | 2021.09.10 |
---|---|
Kotlin In Action - 3중 따옴표 문자열 (0) | 2020.03.25 |
Kotlin In Action - 가변길이 파라미터, 중위 호출, 구조 분해 선언 (0) | 2020.03.25 |
Kotlin In Action - 확장함수 (0) | 2020.03.25 |
Kotlin In Action - when 조건분기, enum 클래스 (0) | 2020.03.25 |