컬렉션과 시퀀스 — map과 filter가 즉시 실행되지 않게 하려면
코틀린의 컬렉션 함수들은 정말 편리한데, 한 가지 함정이 있습니다.
list.filter { }.map { }.first()처럼 체이닝하면, filter와 map이 각각 새 리스트를 만들어서 전체를 순회합니다. 10만 건짜리 데이터라면? 시퀀스(Sequence)를 알아야 할 때입니다.
Iterable vs Sequence — 즉시 평가 vs 지연 평가
코틀린의 List, Set, Map은 모두 Iterable을 기반으로 합니다. 이들의 map, filter 같은 연산은 **즉시 평가(eager evaluation)**됩니다.
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로 바꾸면 어떻게 될까요?
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()같은 전체 요소가 필요한 연산이 있을 때
// 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
// 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 계열
// 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 — 그룹핑의 핵심
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으로 변환
이건 정말 자주 쓰는데 의외로 모르는 분이 많습니다.
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 — 두 컬렉션 합치기
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 — 데이터를 묶어서 처리
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 — 값을 하나로 합치기
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 처리
// 나쁜 예: map 후 filterNotNull
val emails = users.map { it.email }.filterNotNull()
// 좋은 예: mapNotNull 한 번에 처리
val emails = users.mapNotNull { it.email }
2. find vs first
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. 불변 컬렉션과 가변 컬렉션
// 기본은 불변 (읽기 전용)
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 가변: 기본은 읽기 전용이지만 런타임 불변은 아님