Theme:

코틀린의 컬렉션 함수들은 정말 편리한데, 한 가지 함정이 있습니다. list.filter { }.map { }.first()처럼 체이닝하면, filter와 map이 각각 새 리스트를 만들어서 전체를 순회합니다. 10만 건짜리 데이터라면? 시퀀스(Sequence)를 알아야 할 때입니다.

Iterable vs Sequence — 즉시 평가 vs 지연 평가

코틀린의 List, Set, Map은 모두 Iterable을 기반으로 합니다. 이들의 map, filter 같은 연산은 **즉시 평가(eager evaluation)**됩니다.

KOTLIN
val result = listOf(1, 2, 3, 4, 5)
    .filter { println("filter: $it"); it % 2 == 0 }  // 전체 순회 → 새 리스트 [2, 4]
    .map { println("map: $it"); it * 10 }              // 전체 순회 → 새 리스트 [20, 40]
    .first()                                            // 첫 번째 값 20

// 출력:
// filter: 1, filter: 2, filter: 3, filter: 4, filter: 5
// map: 2, map: 4
// 총 7번 연산

같은 코드를 Sequence로 바꾸면 어떻게 될까요?

KOTLIN
val result = listOf(1, 2, 3, 4, 5)
    .asSequence()  // Sequence로 변환
    .filter { println("filter: $it"); it % 2 == 0 }
    .map { println("map: $it"); it * 10 }
    .first()

// 출력:
// filter: 1
// filter: 2
// map: 2
// 총 3번 연산! (2를 찾자마자 중단)

Sequence는 요소를 하나씩 파이프라인을 통과시킵니다. first()가 값을 찾으면 나머지 요소는 처리하지 않습니다.

동작 방식 비교

구분Iterable (즉시 평가)Sequence (지연 평가)
처리 방식연산별로 전체 순회 (수평적)요소별로 전체 파이프라인 통과 (수직적)
중간 결과각 단계마다 새 컬렉션 생성중간 컬렉션 없음
단락 평가불가 (항상 전체 처리)가능 (first, take 등에서 조기 종료)
오버헤드낮음 (단순 반복)약간 있음 (람다 호출 체인)

언제 Sequence를 써야 하나

Sequence가 항상 좋은 건 아닙니다. 기준을 정리하면 이렇습니다.

Sequence가 유리한 경우:

  • 요소가 많을 때 (대략 수천 개 이상)
  • 체이닝 연산이 여러 단계일 때
  • first(), take() 등 단락 평가가 가능할 때
  • 중간 컬렉션 생성으로 인한 메모리가 부담될 때

일반 컬렉션이 나은 경우:

  • 요소가 적을 때 (수십 개 수준)
  • 연산이 한두 단계일 때
  • sorted() 같은 전체 요소가 필요한 연산이 있을 때
KOTLIN
// Sequence 생성 방법들
val seq1 = listOf(1, 2, 3).asSequence()
val seq2 = sequenceOf(1, 2, 3)
val seq3 = generateSequence(1) { it + 1 }  // 무한 시퀀스
val seq4 = sequence {
    yield(1)
    yieldAll(listOf(2, 3))
    yield(4)
}

면접에서 "코틀린의 Sequence와 Java의 Stream의 차이"도 나올 수 있습니다. 둘 다 지연 평가를 지원하지만, Sequence는 단일 스레드, Java Stream은 parallelStream()으로 병렬 처리 가능하다는 차이가 있습니다.

실무에서 자주 쓰는 컬렉션 함수들

map과 flatMap

KOTLIN
// map: 1:1 변환
val names = users.map { it.name }  // [User] → [String]

// flatMap: 1:N 변환 후 평탄화
val allTags = posts.flatMap { it.tags }
// Post(tags=[A, B]), Post(tags=[B, C]) → [A, B, B, C]

// mapNotNull: null을 걸러내면서 변환
val validEmails = users.mapNotNull { it.email }
// null인 이메일은 자동으로 제외됨

filter 계열

KOTLIN
// filter: 조건에 맞는 요소만
val adults = users.filter { it.age >= 18 }

// filterNot: 조건에 맞지 않는 요소만
val minors = users.filterNot { it.age >= 18 }

// filterIsInstance: 특정 타입만 필터링
val strings = mixedList.filterIsInstance<String>()

// partition: 조건에 따라 두 그룹으로 분리
val (adults, minors) = users.partition { it.age >= 18 }

groupBy — 그룹핑의 핵심

KOTLIN
data class Student(val name: String, val grade: Int)

val students = listOf(
    Student("김철수", 1), Student("이영희", 2),
    Student("박민수", 1), Student("정수진", 2)
)

// 학년별 그룹핑
val byGrade: Map<Int, List<Student>> = students.groupBy { it.grade }
// {1=[김철수, 박민수], 2=[이영희, 정수진]}

// 값도 변환하고 싶다면
val namesByGrade: Map<Int, List<String>> = students.groupBy(
    keySelector = { it.grade },
    valueTransform = { it.name }
)
// {1=["김철수", "박민수"], 2=["이영희", "정수진"]}

associate — 리스트를 Map으로 변환

이건 정말 자주 쓰는데 의외로 모르는 분이 많습니다.

KOTLIN
data class User(val id: Int, val name: String)
val users = listOf(User(1, "김철수"), User(2, "이영희"))

// associateBy: 키 생성 함수 지정
val userById: Map<Int, User> = users.associateBy { it.id }
// {1=User(1, "김철수"), 2=User(2, "이영희")}

// associateWith: 값 생성 함수 지정
val nameLength: Map<User, Int> = users.associateWith { it.name.length }

// associate: key-value 쌍을 직접 지정
val idToName: Map<Int, String> = users.associate { it.id to it.name }
// {1="김철수", 2="이영희"}

zip — 두 컬렉션 합치기

KOTLIN
val names = listOf("김철수", "이영희", "박민수")
val scores = listOf(95, 87, 92)

// zip: 같은 인덱스의 요소를 Pair로 묶음
val paired: List<Pair<String, Int>> = names.zip(scores)
// [("김철수", 95), ("이영희", 87), ("박민수", 92)]

// 변환 함수도 전달 가능
val results = names.zip(scores) { name, score ->
    "$name: ${score}점"
}
// ["김철수: 95점", "이영희: 87점", "박민수: 92점"]

// unzip: Pair 리스트를 두 리스트로 분리
val (unzippedNames, unzippedScores) = paired.unzip()

chunked와 windowed — 데이터를 묶어서 처리

KOTLIN
val numbers = (1..10).toList()

// chunked: 고정 크기로 분할
numbers.chunked(3)
// [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10]]

// 변환도 동시에 가능
numbers.chunked(3) { chunk -> chunk.sum() }
// [6, 15, 24, 10]

// windowed: 슬라이딩 윈도우
numbers.windowed(3)
// [[1, 2, 3], [2, 3, 4], [3, 4, 5], ..., [8, 9, 10]]

// step과 partialWindows 옵션
numbers.windowed(size = 3, step = 2, partialWindows = true)
// [[1, 2, 3], [3, 4, 5], [5, 6, 7], [7, 8, 9], [9, 10]]

fold와 reduce — 값을 하나로 합치기

KOTLIN
val numbers = listOf(1, 2, 3, 4, 5)

// reduce: 첫 번째 요소를 초기값으로 사용
val sum = numbers.reduce { acc, n -> acc + n }  // 15

// fold: 초기값을 직접 지정
val sumWithInit = numbers.fold(100) { acc, n -> acc + n }  // 115

// 실무 예: 장바구니 총 금액 계산
val total = cartItems.fold(0) { acc, item ->
    acc + (item.price * item.quantity)
}

자주 빠지는 함정들

1. map 안에서 nullable 처리

KOTLIN
// 나쁜 예: map 후 filterNotNull
val emails = users.map { it.email }.filterNotNull()

// 좋은 예: mapNotNull 한 번에 처리
val emails = users.mapNotNull { it.email }

2. find vs first

KOTLIN
val list = listOf(1, 2, 3)

// find: 없으면 null 반환
val found = list.find { it > 5 }  // null

// first: 없으면 NoSuchElementException 발생!
val first = list.first { it > 5 }  // 예외!

// firstOrNull: find와 동일하게 null 반환
val safe = list.firstOrNull { it > 5 }  // null

3. 불변 컬렉션과 가변 컬렉션

KOTLIN
// 기본은 불변 (읽기 전용)
val list: List<Int> = listOf(1, 2, 3)
// list.add(4)  // 컴파일 에러!

// 가변이 필요하면 Mutable 사용
val mutableList: MutableList<Int> = mutableListOf(1, 2, 3)
mutableList.add(4)  // OK

// 주의: List로 선언해도 내부가 MutableList이면 캐스팅 가능
val sneaky = mutableList as List<Int>  // 읽기 전용으로 보이지만
(sneaky as MutableList).add(5)         // 이렇게 하면 수정됨!

면접에서 "코틀린의 컬렉션은 진짜 불변인가요?"라는 질문이 나오면, 인터페이스 수준에서 읽기 전용일 뿐, 런타임에서 진짜 불변을 보장하지는 않는다고 답할 수 있어야 합니다. 진정한 불변이 필요하면 Collections.unmodifiableList()나 Kotlinx Immutable Collections를 사용합니다.

정리

코틀린 컬렉션의 핵심 포인트를 면접용으로 요약하면 이렇습니다.

  • Iterable: 즉시 평가. 각 연산마다 중간 컬렉션 생성
  • Sequence: 지연 평가. 요소를 하나씩 파이프라인 통과. 대량 데이터 + 체이닝에 유리
  • associate/groupBy: 리스트를 Map으로 변환할 때 필수. 면접에서도 자주 등장
  • chunked/windowed: 배치 처리나 슬라이딩 윈도우 패턴에 유용
  • 불변 vs 가변: 기본은 읽기 전용이지만 런타임 불변은 아님
댓글 로딩 중...