본문 바로가기

Language/Kotlin

[Kotlin] Collection API & 함수형 프로그래밍

- 람다/고차함수/스코프 함수 복습
- 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 }}")
✔partition이 하는 일
books.partition { it.price >= 15000 } 코드가 실행되면 컴퓨터는 책을 한 권씩 검사한다.
  • "코틀린 입문" (18,000원) ➡️ 조건 성립! (true 그룹으로 이동)
  • "알고리즘" (25,000원) ➡️ 조건 성립! (true 그룹으로 이동)
  • "일기" (8,000원) ➡️ 조건 불성립! (false 그룹으로 이동)
  • "시집" (12,000원) ➡️ 조건 불성립! (false 그룹으로 이동)
그 결과, partition은 두 개의 리스트를 반환한다.
  • 첫 번째 리스트 (조건이 true인 것들): [코틀린 입문 객체, 알고리즘 객체]
  • 두 번째 리스트 (조건이 false인 것들): [일기 객체, 시집 객체]
이후에 변수에 나눠 담기 (구조 분해 할당)를 진행한다.
코드 좌측에 적은 val (expensive, cheap) = ... 문법 덕분에 두 리스트가 각각 이름표를 가진다.
  • expensive 변수 ➡️ true 그룹 리스트가 저장됨
  • cheap 변수 ➡️ false 그룹 리스트가 저장됨
 
그럼 화면에 출력할 때 (.map { it.title }) 출력문 안을 보면 그냥 expensive를 출력한 것이 아니라 .map { it.title }을 붙였다.
 
println("비싼 책: ${expensive.map { it.title }}")
  • expensive 안에는 책 객체들이 들어있다.
  • 여기에 .map { it.title }을 수행하면서 "책 객체에서 제목(title)만 쏙쏙 뽑아낸 새 리스트"를 만든다.
  • 결과적으로 [코틀린 입문, 알고리즘]이라는 글자 리스트가 만들어져 출력된 것이다.

만약 진짜 true/false 결과만 보고 싶다면?

조건에 맞는지 안 맞는지 유무(Boolean 값)만 리스트로 나열하고 싶다면 partition이 아니라 아래처럼 map을 사용해야 한다.
// 가격이 15000원 이상인지 여부(true/false)로만 이루어진 리스트를 만듦
val checkResult = books.map { it.price >= 15000 }
println(checkResult) // 출력 예시: [true, true, false, false]
 
 
요약하자면, partition은 데이터를 '조건 충족 그룹'과 '조건 미달 그룹'으로 이쁘게 쪼개서 담아주는 바구니 역할을 하기 때문에 제목들이 출력될 수 있었던 것이다.
 

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로 만들어라"라는 변환 함수이다.

[1단계: { it?.toIntOrNull() }의 처리 결과]
컴퓨터는 리스트 안의 글자(String)들을 하나씩 숫자로 바꾸려고 시도한다.
  • "10" ➡️ 숫자로 바꿀 수 있네? ➡️ 10 (Int)
  • "abc" ➡️ 이건 숫자로 못 바꾸는데? ➡️ null
  • "20" ➡️ 숫자로 바꿀 수 있네? ➡️ 20 (Int)
  • null ➡️ 애초에 값이 없네? ➡️ null
  • "30" ➡️ 숫자로 바꿀 수 있네? ➡️ 30 (Int)
이 변환 과정 직후에는 결과가 [10, null, 20, null, 30]이 된다.
[ 2단계: mapNotNull이 null을 정리]
앞에 붙어있는 함수 이름이 그냥 map이 아니라 mapNotNull입니다. 이 함수의 임무는 "결과물 중에서 null은 전부 쓰레기통에 버리고 알맹이만 남기는 것"이다.
  • 10 ➡️ 살림
  • null ➡️ 삭제 ("abc"가 변환된 것)
  • 20 ➡️ 살림
  • null ➡️ 삭제 (원래 null이었던 것)
  • 30 ➡️ 살림
결국 null들이 전부 걸러지면서 최종적으로 [10, 20, 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