Kotlin 사고방식 (4/7)

이전 편: [Kotlin] 3. 함수는 일급 시민이다

다음 편: [Kotlin] 5. Null Safety 완전 정복

Kotlin 사고방식 시리즈 (4/7)

이전 편: 3 함수는 일급 시민이다

다음 편: 5 Null Safety 완전 정복

컬렉션 처리는 서버 개발에서 가장 자주 하는 작업이다. 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개 통과 후 멈춘다.
graph LR subgraph "Iterable (즉시 평가)" A1[전체 filter] --> A2[전체 map] --> A3[take 10] end subgraph "Sequence (지연 평가)" B1["요소 1개 → filter → map → take"] end

언제 Sequence를 쓸까?

항상 Sequence가 좋은 건 아니다.

  • 데이터가 수백 건 이하면 Iterable이 오히려 빠르다. Sequence는 내부적으로 Iterator 객체와 상태 관리가 필요해서 오버헤드가 있다.
  • 데이터가 수천 건 이상이고, filtermaptake 같은 체이닝이 길 때 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은 변환만 해야 한다. 부수 효과(로깅, 상태 변경)는 onEachalso로 분리하면 역할이 명확해진다.

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을 쓰자.