컬렉션 처리는 서버 개발에서 가장 자주 하는 작업이다. DB에서 꺼낸 엔티티 목록을 DTO로 변환하고, 필터링하고, 그룹핑하고, 정렬한다. Java의 Stream API가 이 작업을 함수형으로 할 수 있게 해줬지만, Kotlin의 컬렉션 함수는 한 단계 더 간결하고 자연스럽다.
문법 복습에서 기본적인 filter, map 등을 훑었다. 이 문서에서는 실무에서 자주 쓰이는 고급 함수들, Iterable과 Sequence의 차이, 성능 고려사항을 깊이 다룬다.
Iterable과 Sequence
Kotlin의 컬렉션 함수(filter, map 등)를 호출하면 각 단계마다 새로운 중간 컬렉션이 생성된다. 이것을 즉시 평가(eager evaluation)라 한다.
val result = posts // 10000개
.filter { it.isPublished } // → 새 List 생성 (5000개)
.map { it.title } // → 또 새 List 생성 (5000개)
.take(10) // → 또 새 List 생성 (10개)
10000개 중 10개만 필요한데, 중간에 5000개짜리 리스트를 두 번이나 만든다. 데이터가 적으면 문제 없지만, 수만 건 이상이면 메모리와 성능에 영향이 있을 수 있다.
Sequence는 이 문제를 지연 평가(lazy evaluation)로 해결한다.
val result = posts.asSequence() // Sequence로 변환
.filter { it.isPublished } // 아직 실행 안 됨
.map { it.title } // 아직 실행 안 됨
.take(10) // 아직 실행 안 됨
.toList() // 여기서 비로소 실행!
Sequence는 종단 연산(toList(), first(), count() 등)이 호출될 때 비로소 체인 전체가 실행된다. 그리고 요소를 하나씩 파이프라인 전체를 통과시킨다.
비유하자면 이렇다.
- Iterable(즉시 평가) — 공장의 컨베이어 벨트가 각 공정별로 따로 있다. 1단계에서 모든 제품을 처리한 후, 2단계로 전부 넘긴다.
- Sequence(지연 평가) — 하나의 컨베이어 벨트에서 제품이 하나씩 모든 공정을 순서대로 통과한다. 10개만 필요하면 10개 통과 후 멈춘다.
언제 Sequence를 쓸까?
항상 Sequence가 좋은 건 아니다.
- 데이터가 수백 건 이하면 Iterable이 오히려 빠르다. Sequence는 내부적으로 Iterator 객체와 상태 관리가 필요해서 오버헤드가 있다.
- 데이터가 수천 건 이상이고,
filter→map→take같은 체이닝이 길 때 Sequence가 유리하다. take,first같은 조기 종료가 있을 때 Sequence의 이점이 크다.
일반적인 서버 개발에서 DB 조회 결과를 처리할 때는 Iterable로 충분하다. 메모리에 수만 건을 올려서 복잡한 변환을 하는 배치 작업 같은 경우에 Sequence를 고려하자.
실전 컬렉션 함수
groupBy — 그룹핑
가장 자주 쓰이는 함수 중 하나다. Java의 Collectors.groupingBy()에 해당한다.
data class Post(val title: String, val category: String, val viewCount: Int)
val byCategory: Map<String, List<Post>> = posts.groupBy { it.category }
- 반환 타입은
Map<K, List<V>>다. 키는 그룹 기준, 값은 해당 그룹의 요소 리스트. - 카테고리별 글 목록, 날짜별 주문 목록 등에 활용한다.
그룹핑과 동시에 변환도 가능하다.
val titlesByCategory: Map<String, List<String>> = posts.groupBy(
keySelector = { it.category },
valueTransform = { it.title }
)
associate — Map 변환
리스트를 Map으로 변환할 때 쓴다.
// id → Post 맵
val postMap: Map<Long, Post> = posts.associateBy { it.id }
// id → title 맵
val idToTitle: Map<Long, String> = posts.associate { it.id to it.title }
associateBy— 키 추출 함수만 제공. 값은 요소 그대로.associate— 키-값 쌍을 직접 만든다.
같은 키가 여러 개면 마지막 값이 남는다. 중복이 있을 수 있는 데이터에서는 groupBy를 쓰는 게 안전하다.
partition — 둘로 나누기
조건을 만족하는 것과 그렇지 않은 것을 한 번에 분리한다.
val (published, drafts) = posts.partition { it.isPublished }
- 반환 타입은
Pair<List<T>, List<T>>다. 구조 분해로 바로 받을 수 있다. filter+filterNot을 두 번 돌리는 것보다 효율적이다. 한 번의 순회로 끝난다.
flatMap — 중첩 펼치기
"리스트의 리스트"를 하나의 리스트로 펼칠 때 쓴다.
data class Post(val title: String, val tags: List<String>)
val allTags: List<String> = posts.flatMap { it.tags }
각 Post의 tags를 꺼내서 하나의 리스트로 합친다. Java의 stream().flatMap()과 같은 역할이다.
중복을 제거하고 싶으면 distinct()를 뒤에 붙인다.
val uniqueTags = posts.flatMap { it.tags }.distinct()
fold와 reduce — 누적 연산
리스트의 모든 요소를 하나의 값으로 합칠 때 쓴다.
// fold — 초기값이 있다
val totalViews = posts.fold(0) { acc, post -> acc + post.viewCount }
// reduce — 초기값 없이 첫 번째 요소부터 시작
val totalViews = posts.map { it.viewCount }.reduce { acc, count -> acc + count }
fold는 초기값을 지정할 수 있다. 빈 리스트에서도 안전하다.reduce는 초기값이 없다. 빈 리스트에서 호출하면 예외가 발생한다.
간단한 합계라면 sumOf가 더 읽기 좋다.
val totalViews = posts.sumOf { it.viewCount }
fold의 진가는 단순 합계가 아닌 복잡한 누적 연산에서 나온다.
// 카테고리별 조회수 합계를 Map으로
val viewsByCategory = posts.fold(mutableMapOf<String, Int>()) { acc, post ->
acc[post.category] = (acc[post.category] ?: 0) + post.viewCount
acc
}
하지만 이 정도 복잡도면 groupBy + mapValues로 쓰는 게 더 읽기 좋다.
val viewsByCategory = posts
.groupBy { it.category }
.mapValues { (_, posts) -> posts.sumOf { it.viewCount } }
windowed와 chunked — 윈도우/청크 분할
데이터를 일정 크기로 나누는 함수들이다.
val pages = posts.chunked(10) // 10개씩 묶기
// [[Post1..10], [Post11..20], [Post21..25]]
val sliding = numbers.windowed(3) // 크기 3의 슬라이딩 윈도우
// [1,2,3] → [[1,2,3], [2,3,4], [3,4,5], ...]
chunked— 겹치지 않게 N개씩 나눈다. 페이지네이션이나 배치 처리에 유용.windowed— 슬라이딩 윈도우로 나눈다. 이동 평균 같은 연산에 유용.
Map 다루기
Map도 다양한 함수를 제공한다.
val postMap = mapOf(1L to "첫 글", 2L to "둘째 글", 3L to "셋째 글")
// 필터링
val filtered = postMap.filter { (id, _) -> id > 1 }
// 값 변환
val uppercased = postMap.mapValues { (_, title) -> title.uppercase() }
// 키 변환
val stringKeys = postMap.mapKeys { (id, _) -> "post-$id" }
// 기본값과 함께 접근
val title = postMap.getOrDefault(99L, "없는 글")
val title = postMap.getOrElse(99L) { "기본 제목" }
- Map의
filter,map등에서 람다 파라미터를(key, value)구조 분해로 받을 수 있다. _는 사용하지 않는 변수를 무시할 때 쓴다.
심화 분석
컬렉션 함수 성능 비교
같은 결과를 내는 코드라도 함수 선택에 따라 성능 차이가 날 수 있다.
// 나쁜 예 — filter 후 first
val firstPublished = posts.filter { it.isPublished }.first()
// 전체를 필터링한 후 첫 번째를 가져옴 (불필요한 연산)
// 좋은 예 — first에 조건을 바로 전달
val firstPublished = posts.first { it.isPublished }
// 조건 만족하는 첫 번째를 찾으면 즉시 중단
filter + first는 전체 리스트를 순회하지만, first { 조건 }은 조건을 만족하는 요소를 찾는 즉시 중단한다. count { 조건 } vs filter { 조건 }.size도 마찬가지다.
비슷한 패턴이 또 있다.
// 나쁜 예 — 존재 여부를 위해 전체를 필터링
val hasPublished = posts.filter { it.isPublished }.isNotEmpty()
// 좋은 예 — any로 즉시 반환
val hasPublished = posts.any { it.isPublished }
핵심 원칙은 "필요한 만큼만 연산한다"는 것이다.
Java Stream과의 근본적 차이
Java의 Stream은 한 번만 소비할 수 있다. 같은 Stream을 두 번 사용하면 IllegalStateException이 발생한다.
Stream<Post> stream = posts.stream().filter(p -> p.isPublished());
stream.count(); // OK
stream.toList(); // IllegalStateException!
Kotlin의 컬렉션 함수는 매번 새 컬렉션을 반환하므로 이런 제약이 없다. 원본 리스트를 몇 번이든 다시 가공할 수 있다.
Sequence는 Stream과 비슷하게 한 번만 소비할 수 있지만, toList()로 다시 물질화(materialize)하면 재사용 가능하다.
자주 하는 실수
map 안에서 부수 효과
// 나쁜 예
val responses = posts.map { post ->
log.info("변환: ${post.title}") // map 안에서 로깅 — 부수 효과
post.toResponse()
}
// 좋은 예 — onEach로 분리
val responses = posts
.onEach { log.info("변환: ${it.title}") }
.map { it.toResponse() }
map은 변환만 해야 한다. 부수 효과(로깅, 상태 변경)는 onEach나 also로 분리하면 역할이 명확해진다.
toList() 깜빡하기 (Sequence)
val result = posts.asSequence()
.filter { it.isPublished }
.map { it.title }
// .toList() ← 빠뜨리면 result는 Sequence<String>
Sequence를 반환하면 나중에 이 값을 쓸 때마다 체인이 재실행된다. 최종 결과가 필요하면 반드시 toList()로 물질화하자.
빈 리스트에서 reduce 호출
val sum = emptyList<Int>().reduce { acc, i -> acc + i } // UnsupportedOperationException!
reduce는 빈 컬렉션에서 예외를 던진다. fold를 쓰거나 reduceOrNull을 쓰자.