- 람다/고차함수/스코프 함수 복습
- Collection API
- Sequence
- 함수형 프로그래밍 핵심 개념
1. 람다/고차함수/스코프 함수 복습
먼저, 오늘 학습 목표를 시작하기 전에 위 3가지를 확실히 복습하면 Collection API를 이해하기 쉬울 것같아서 정리한다.
- 람다 (Lambda)
- 고차함수 (Higher-Order Function)
- 스코프 함수 (Scope Function)
1) 람다 (Lambda)
이름 없는 함수를 값처럼 넘기거나 저장하는 문법이다.
// 일반 함수
fun add(a: Int, b: Int): Int {
return a + b
}
// 람다 (같은 의미)
val addLambda: (Int, Int) -> Int = { a, b -> a + b }
println(add(1, 2)) // 3
println(addLambda(1, 2)) // 3
람다 문법을 하나씩 쪼개보면 아래와 같다.
- {} 안이 함수 본문
- a,b 는 매개변수
- -> 오른쪽으로 반환값
만약 매개변수가 하나라면 아래처럼 it을 사용해서 람다 함수를 더 짧게 작성할 수도 있다.
val numbers = listOf(1, 2, 3, 4, 5)
// 매개변수 1개면 it 로 줄일 수 있음
val doubled = numbers.map { number -> number * 2 }
val doubledShort = numbers.map { it * 2 } // 위와 같음
println(doubledShort) // [2, 4, 6, 8, 10]
또한, 람다를 변수에 담을 수도 있다.
val isEven: (Int) -> Boolean = { number -> number % 2 == 0 }
println(isEven(4)) // true
println(isEven(3)) // false
2) 고차함수 (Higher-Order Function)
고차함수는 매개변수를 받거나, 함수를 반환하는 함수이다.
map, filter와 같은 Collection 함수들이 전부 고차함수이다.
// 고차함수 예시: 숫자 리스트에 "연산 함수"를 넘겨서 처리
fun calculate(numbers: List<Int>, operation: (Int) -> Int): List<Int> {
val result = mutableListOf<Int>()
for (number in numbers) {
result.add(operation(number)) // 넘겨받은 함수 실행
}
return result
}
val numbers = listOf(1, 2, 3)
val doubled = calculate(numbers) { it * 2 }
val plusTen = calculate(numbers) { it + 10 }
println(doubled) // [2, 4, 6]
println(plusTen) // [11, 12, 13]
- operation: (Int) -> Int 는 Int를 받아서 Int를 반환하는 함수 타입을 의미함
- {it * 2} 는 그 자리에서 람다를 넘긴다.
코틀린 표준 라이브러리가 있다. 아래는 filter, map 모두 고차함수이고, 람다를 인자로 받고 있는 것을 볼 수 있다.
val numbers = listOf(1, 2, 3, 4, 5)
// filter: 조건에 맞는 것만 남김
val evens = numbers.filter { it % 2 == 0 }
// map: 각 요소를 변환
val strings = numbers.map { "번호$it" }
println(evens) // [2, 4]
println(strings) // [번호1, 번호2, ...]
- filter: 조건 추출
- map: 형태 변환
3) 스코프 함수 (Scope Function)
스코프 함수는 객체(this 또는 it)를 기준으로 블록을 실행하는 함수이다.
Collection API와 같이 쓰면 코드가 짧아진다.
| 함수 | 참조 | 반환 | 주 용도 |
|
let
|
it
|
람다 결과
|
null 체크, 변환
|
|
run
|
this
|
람다 결과
|
객체 설정 + 결과
|
|
with
|
this
|
람다 결과
|
객체 여러 작업 (비확장)
|
|
apply
|
this
|
객체 자신
|
초기 설정
|
|
also
|
it
|
객체 자신
|
로그, 부가 작업
|
data class Order(val id: Long, var status: String, var total: Int)
// apply: 객체 만들면서 속성 설정
val order = Order(1L, "CREATED", 0).apply {
total = 15000
status = "PAID"
}
// let: null이 아닐 때만 처리
val message: String? = "안녕"
message?.let { text ->
println("받은 메시지: $text")
}
// also: 부수효과(로그) — 반환은 원본 객체
order.also { println("주문 로그: $it") }
2. Collection API
컬렉션은 여러 데이터를 묶어 두는 자료형이다.
그래서 아래 코드를 보면 이해하기 쉽다.
val list = listOf(1, 2, 3) // 순서 O, 중복 O, 수정 X (불변 리스트)
val mutableList = mutableListOf(1, 2) // 수정 O
val set = setOf(1, 2, 2, 3) // 중복 X → [1, 2, 3]
val map = mapOf("a" to 1, "b" to 2) // 키-값
1) 기본 5개
순서는 filter -> map -> sorted -> distinct -> groupBy가 좋다.
- filter: 조건 추출
- map: 형태 변환
- sortedBy: 정렬
- distinctBy: 중복 제거
- firstOrNull: 그룹 생성
data class Book(val title: String, val price: Int, val category: String)
val books = listOf(
Book("코틀린 입문", 25000, "IT"),
Book("알고리즘", 30000, "IT"),
Book("일기", 8000, "문학"),
Book("시집", 12000, "문학")
)
// 1) filter — 조건에 맞는 것만
val expensiveBooks = books.filter { it.price >= 15000 }
// 2) map — 형태 변환 (제목만 추출)
val titles = books.map { it.title }
// 3) sortedBy — 정렬
val sortedByPrice = books.sortedBy { it.price }
// 4) distinctBy — 기준으로 중복 제거
val onePerCategory = books.distinctBy { it.category }
// 5) firstOrNull — 없으면 null (안전)
val firstCheap = books.firstOrNull { it.price < 10000 }
2) 집계, 검사
val totalPrice = books.sumOf { it.price }
val hasCheapBook = books.any { it.price < 10000 } // 하나라도?
val allExpensive = books.all { it.price > 5000 } // 전부?
println(totalPrice) // 75000
println(hasCheapBook) // true
3) groupBy : 그룹으로 묶기
📌실무에서 자주 사용하는 자료형이다.
val booksByCategory = books.groupBy { it.category }
// { IT=[...], 문학=[...] }
booksByCategory["IT"]?.forEach { book ->
println("${book.title}: ${book.price}원")
}
4) associateBy: Map으로 바꾸기
// 제목을 키로, Book을 값으로
val bookMap = books.associateBy { it.title }
println(bookMap["코틀린 입문"]?.price) // 25000
5) partition: 결과로 조건이 true인 데이터 모음(List)과 false인 데이터 모음(List)을 통째로 갈라준다.
val (expensive, cheap) = books.partition { it.price >= 15000 }
println("비싼 책: ${expensive.map { it.title }}")
println("저렴한 책: ${cheap.map { it.title }}")
- "코틀린 입문" (18,000원) ➡️ 조건 성립! (true 그룹으로 이동)
- "알고리즘" (25,000원) ➡️ 조건 성립! (true 그룹으로 이동)
- "일기" (8,000원) ➡️ 조건 불성립! (false 그룹으로 이동)
- "시집" (12,000원) ➡️ 조건 불성립! (false 그룹으로 이동)
- 첫 번째 리스트 (조건이 true인 것들): [코틀린 입문 객체, 알고리즘 객체]
- 두 번째 리스트 (조건이 false인 것들): [일기 객체, 시집 객체]
- expensive 변수 ➡️ true 그룹 리스트가 저장됨
- cheap 변수 ➡️ false 그룹 리스트가 저장됨
println("비싼 책: ${expensive.map { it.title }}")
- expensive 안에는 책 객체들이 들어있다.
- 여기에 .map { it.title }을 수행하면서 "책 객체에서 제목(title)만 쏙쏙 뽑아낸 새 리스트"를 만든다.
- 결과적으로 [코틀린 입문, 알고리즘]이라는 글자 리스트가 만들어져 출력된 것이다.
✔ 만약 진짜 true/false 결과만 보고 싶다면?
// 가격이 15000원 이상인지 여부(true/false)로만 이루어진 리스트를 만듦
val checkResult = books.map { it.price >= 15000 }
println(checkResult) // 출력 예시: [true, true, false, false]
6) flatMap : 리스트 안의 리스트를 펼치기
data class Student(val name: String, val subjects: List<String>)
val students = listOf(
Student("민수", listOf("수학", "영어")),
Student("지영", listOf("코틀린", "수학"))
)
val allSubjects = students.flatMap { it.subjects }
println(allSubjects) // [수학, 영어, 코틀린, 수학]
7) mapNotNull: null은 결과에서 제외
val inputs = listOf("10", "abc", "20", null, "30")
val numbers = inputs.mapNotNull { it?.toIntOrNull() }
println(numbers) // [10, 20, 30]
✔ toIntOrNull()은 "Int거나 Null이면 출력하라"는 뜻이 아니라, "이 글자를 숫자로 바꿀 수 있으면 숫자로 바꾸고, 바꿀 수 없으면 null로 만들어라"라는 변환 함수이다.
- "10" ➡️ 숫자로 바꿀 수 있네? ➡️ 10 (Int)
- "abc" ➡️ 이건 숫자로 못 바꾸는데? ➡️ null
- "20" ➡️ 숫자로 바꿀 수 있네? ➡️ 20 (Int)
- null ➡️ 애초에 값이 없네? ➡️ null
- "30" ➡️ 숫자로 바꿀 수 있네? ➡️ 30 (Int)
- 10 ➡️ 살림
- null ➡️ 삭제 ("abc"가 변환된 것)
- 20 ➡️ 살림
- null ➡️ 삭제 (원래 null이었던 것)
- 30 ➡️ 살림
- toIntOrNull()은 숫자로 못 바꾸는 못생긴 글자("abc")를 null로 바꾸어 버리는 역할을 했고,
- mapNotNull은 그렇게 만들어진 null들을 리스트에서 완전히 제외해 버렸기 때문에 숫자만 남은 것이다.
3. Sequence
List에 filter -> map -> filter를 연속적으로 쓰면 중간 리스트가 여러번 생긴다.
asSequence()는 필요할 때만 다음 단계를 계산한다.
val result = (1..1_000_000)
.asSequence()
.filter { it % 2 == 0 }
.map { it * 2 }
.take(5) // 5개만 필요하면 여기서 멈춤
.toList()
println(result) // [4, 8, 12, 16, 20]
✔ 중간 리스트가 여러 번 생긴다"의 진짜 의미
일반 List는 각 단계(filter, map 등)마다 새로운 결과 리스트를 통째로 메모리에 새로 만들어 낸다. 이로 인해 List를 쓸 때 단계별로 생기는 임시 메모리 낭비 공간이 생긴다.
예를 들어 100만 명의 회원 목록이 있는 일반 List에서 필터를 여러 번 거친다고 상상해보자.
val result = members
.filter { it.age >= 20 } // [1단계] 20세 이상 50만 명의 '새 리스트'를 메모리에 만듦 (메모리 낭비 시작)
.map { it.name } // [2단계] 그 50만 명의 이름만 담은 '또 다른 새 리스트'를 메모리에 만듦
.filter { it.startsWith("김") } // [3단계] '김'씨만 담은 '또 다른 새 리스트'를 최종적으로 만듦
이렇게 되면 지연문제가 발생될 가능성이 크다.
최종 결과물 1개만 있으면 되는데, 중간 과정에서 50만 개짜리 쓰레기 리스트들이 메모리를 가득 채우게 된다. 데이터가 적을 땐 상관없지만, 데이터가 100만개쯤 되면 스마트폰 앱이 버벅거리거나 퍽 꺼질 수 있다. (이를 OOM에러라고 한다.)
그래서 Sequence는 중간 리스트를 단 하나도 만들지 않고, 데이터 1개씩을 끝까지 통과시켜 최종 리스트 딱 1개만 만든다.
✔ 체이닝(Chaining)이란
쇠사슬처럼 연결한다는 뜻이다. 프로그래밍에서는 함수 뒤에 점(.)을 찍어서 다른 함수를 줄줄이 이어 붙이는 코딩 방식을 말한다.
(1..1_000_000)
.asSequence() // 쇠사슬 1번
.filter { ... } // 쇠사슬 2번
.map { ... } // 쇠사슬 3번
.take(5) // 쇠사슬 4번
.toList() // 쇠사슬 5번
위처럼 함수들이 길게 늘어뜨려 놓은 모양을 체이닝 같다고 표현한다.
✔ "take/first처럼 일부만 필요할 때"의 의미는
Sequence(시퀀스)의 가장 강력한 무기인 지연 연산을 설명하는 말이다.
- take(5): 앞에서부터 딱 5개만 가져오고 나머지는 버리기
- first(): 조건에 맞는 가장 첫 번째 데이터 딱 1개만 찾고 끝내기
✔ 시퀀스 작동 방식에 대해 컴퓨터 내부가 어떻게 돌아가는지 눈으로 확인하는 코드 (List, Sequence)
- 일반 List의 작동 방식
fun main() {
val list = listOf(1, 2, 3, 4)
println("=== 일반 List 시작 ===")
val result = list
.filter {
println(" [Filter] 숫자 $it 검사 중")
it % 2 == 0
}
.map {
println(" [Map] 짝수 $it에 2를 곱하는 중")
it * 2
}
.take(1) // 딱 1개만 필요함!
println("최종 결과: $result")
}
//출력 결과
=== 일반 List 시작 ===
[Filter] 숫자 1 검사 중
[Filter] 숫자 2 검사 중
[Filter] 숫자 3 검사 중
[Filter] 숫자 4 검사 중
[Map] 짝수 2에 2를 곱하는 중
[Map] 짝수 4에 2를 곱하는 중
최종 결과: [4]
filter가 1부터 4까지 끝까지 다 돌아서 [2, 4]라는 중간 리스트를 만든 후에야 map 단계가 시작된다. 결국 최종 결과로 딱 1개(take(1))만 쓰는데도, 숫자 4까지 굳이 검사하고 2를 곱하는 쓸데없는 연산을 전부 다 수행했다.
- Sequence의 작동 방식
반면 Sequence는 데이터 1개씩을 잡고 끝까지 통과시키며, 목표를 달성하면 그 즉시 일을 끝낸다.
fun main() {
val list = listOf(1, 2, 3, 4)
println("=== Sequence 시작 ===")
val result = list
.asSequence()
.filter {
println(" [Filter] 숫자 $it 검사 중")
it % 2 == 0
}
.map {
println(" [Map] 짝수 $it에 2를 곱하는 중")
it * 2
}
.take(1) // 딱 1개만 필요함!
.toList() // 최종 리스트로 변환 (여기서 실행 버튼이 눌림)
println("최종 결과: $result")
}
// 출력 결과
=== Sequence 시작 ===
[Filter] 숫자 1 검사 중
[Filter] 숫자 2 검사 중
[Map] 짝수 2에 2를 곱하는 중
최종 결과: [4]
순서는 다음과 같다.
1. 숫자 1이 들어옵니다. filter에서 탈락한다.
2. 숫자 2가 들어옵니다. filter를 통과하자마자 바로 아래 단계인 map으로 직행한다!
3. map에서 2를 곱해 4를 만든다.
4. 다음 단계인 take(1)이 "어? 나 1개 다 찾았다! 끝!"을 외치며 전체 프로세스를 종료해 버린다.
그 결과, 뒤에 남아있던 숫자 3과 4는 구경조차 하지 않고 컴퓨터가 계산을 멈췄다. 메모리 낭비도 없고 계산 속도도 압도적으로 빨라진다.
💡요약
- 일반 List: Filter 다 하고 ➡️ Map 다 하고 ➡️ Take 함 (가로 방향으로 한 줄씩 일 처리)
- Sequence: 숫자 1 끝까지 보내보고 ➡️ 숫자 2 끝까지 보내보고 ➡️ 조건 만족하면 뒤는 생략 (세로 방향으로 한 놈씩 일 처리)
4. 함수형 프로그래밍
1) 불변성: 데이터를 수정하지 말고 복사해서 쓰기
// 나쁜 예: 원본 리스트를 직접 수정
val mutable = mutableListOf(1, 2, 3)
mutable.add(4)
// 좋은 예: 새 리스트를 만들어 반환
val original = listOf(1, 2, 3)
val added = original + 4 // [1, 2, 3, 4]
원본 리스트를 직접 수정하면, 언제 어디서 바뀌었는지 추적하기가 힘들다.
그래서 새 리스트를 만들어서 원본을 안전하게 보존해야한다.
2) 순수 함수: 매개변수(인자)만 보고 계산하고, 전역 변수는 건드리지 말기
// 순수 함수: 같은 입력 → 항상 같은 출력, 외부 상태에 의존 X
fun discountPrice(price: Int, rate: Double): Int {
return (price * (1 - rate)).toInt()
}
// 비순수 예: 전역 변수를 바꿈 (디버깅 어려움)
var globalCount = 0
fun badIncrement() {
globalCount++
}
함수가 실행될 때마다 바깥의 전역변수를 변경하면 디버깅과 테스트가 불가능해진다.
3) 부수효과: 안전한 계산 코딩과 위험한 서버/DB/로그 코딩을 섞지 말고 따로 격리하기
fun processOrders(orders: List<Order>): List<Order> {
// 변환은 순수하게
return orders.map { it.copy(status = "DONE") }
}
fun saveAndLog(orders: List<Order>) {
// DB 저장, 로그는 여기서 (부수효과)
orders.forEach { println("저장: ${it.id}") }
}
오직 데이터만 '완료'상태로 복사/변환하는 안전함 함수와 DB 저장, 화면 출력 등 위험하지만 필요한 일을 몰아서 하는 함수인 부수 효과 영역 함수를 분리해서 사고를 줄일 수 있도록 해야한다.
[AI의 도움을 받은 Collection API 핵심 요약]
1) 가장 자주 쓰는 8개
- filter : 조건에 맞는 요소만 남김
- map : 요소를 다른 형태로 변환
- mapNotNull : 변환 + null 결과 제거
- firstOrNull : 첫 요소 반환, 없으면 null
- any / all : 하나라도/전부 조건 만족?
- sumOf : 합계 계산
- groupBy : 기준값으로 그룹화
- associateBy : 키 기준으로 Map 생성
val result = items
.filter { it.price >= 10000 }
.map { it.name }
2) 패턴별 공식
- 조건 추출: filter { 조건 }
- 형태 변환: map { 변환 }
- null/빈값 안전 처리: mapNotNull { ... }, firstOrNull
- 집계: sumOf, count, any, all
- 분류: groupBy, partition
- 키 기반 조회 준비: associateBy
3) 실무에서 자주 보는 체이닝
val summary = orders
.filter { it.status == "PAID" }
.groupBy { it.category }
.mapValues { (_, list) -> list.sumOf { it.amount } }
[함수형 프로그래밍 핵심 요약]
1) 꼭 필요한 개념 4개
- 람다: 이름 없는 함수 ({ x -> x * 2 })
- 고차함수: 함수를 인자로 받는 함수 (map, filter)
- 불변성: var보다 val, 원본 수정보다 새 값 생성
- 순수 함수: 입력 같으면 출력 동일, 외부 상태 변경 없음
2) 실무에서 중요한 기준
- 데이터 변환은 순수하게, 저장/로그는 경계에서만
- 원본 변경 대신 copy() 활용
- null 가능성은 미리 제거 (mapNotNull, ?., ?:)
- 읽기 어려운 한 줄 체이닝보다 의도 명확한 코드가 우선
3) 좋은 코드 습관
val discounted = products.map { product ->
product.copy(price = (product.price * 0.9).toInt())
}
- 원본 불변 유지
- 목적이 명확
- 테스트/디버깅 쉬움
[성능 포인트]
- 작은 데이터: 그냥 List 체이닝
- 큰 데이터 + 긴 체이닝 + 일부만 필요: asSequence()
val topNames = users.asSequence()
.filter { it.active }
.map { it.name }
.take(10)
.toList()'Language > Kotlin' 카테고리의 다른 글
| [복습] 1~3강까지 미션 (0) | 2026.05.27 |
|---|---|
| [Kotlin] Coroutine 기초 (0) | 2026.05.20 |
| [Kotlin] Scope Function 완전 정리 (0) | 2026.05.20 |
| [Kotlin] Kotlin 핵심 문법 3가지 (0) | 2026.05.20 |
| [Kotlin] 코틀린 이해해보기 (0) | 2026.05.20 |