Kim-Baek 개발자 이야기

Kotlin 람다와 고차 함수 완벽 가이드 | 함수형 프로그래밍 심화 본문

개발/java basic

Kotlin 람다와 고차 함수 완벽 가이드 | 함수형 프로그래밍 심화

김백개발자 2025. 12. 26. 10:33
반응형

이 글을 읽으면: 함수를 값처럼 다루는 강력한 함수형 프로그래밍을 마스터할 수 있습니다. 람다 표현식, 고차 함수, 수신 객체 지정 람다를 활용해 더 간결하고 우아한 코드를 작성하는 방법을 실전 예제로 배워보세요.


📌 목차

  1. 들어가며 - 함수를 값처럼
  2. 람다 표현식 기본
  3. 고차 함수 - 함수를 인자로, 함수를 반환
  4. it 키워드와 underscore
  5. 수신 객체 지정 람다
  6. inline, crossinline, noinline
  7. 실전 DSL 패턴
  8. 마무리 - 다음 편 예고

들어가며 - 함수를 값처럼

Java로 이벤트 리스너를 작성할 때 이런 경험 있으신가요?

// Java - 익명 클래스로 콜백 구현
button.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        System.out.println("클릭됨!");
    }
});

// Java 8 람다 - 조금 나아짐
button.setOnClickListener(v -> {
    System.out.println("클릭됨!");
});

// 함수형 인터페이스 정의도 필요
@FunctionalInterface
public interface Callback {
    void execute();
}

Kotlin은 이렇게 간단합니다:

// Kotlin - 람다가 일급 시민
button.setOnClickListener { 
    println("클릭됨!") 
}

// 함수를 변수에 저장
val onClick: () -> Unit = { 
    println("클릭됨!") 
}

// 함수를 인자로 전달
fun performAction(action: () -> Unit) {
    action()
}

performAction { println("실행!") }

실제 프로젝트에서 람다를 사용한 사례:

// 데이터 처리 파이프라인
val result = users
    .filter { it.age >= 18 }          // 람다로 조건
    .map { it.name.uppercase() }      // 람다로 변환
    .sortedBy { it.length }           // 람다로 정렬 기준
    .take(5)

// 비동기 처리
launch {
    val data = fetchData()
    updateUI { showData(data) }       // 람다로 UI 업데이트
}

// DSL 스타일
html {
    body {
        h1 { +"제목" }
        p { +"내용" }
    }
}

오늘은 Kotlin의 람다와 고차 함수를 완전히 마스터해보겠습니다!


람다 표현식 기본

람다 문법

기본 형식: { 파라미터 -> 본문 }

fun main() {
    // 가장 간단한 람다
    val greet = { println("안녕하세요!") }
    greet()  // 안녕하세요!
    
    // 파라미터가 있는 람다
    val sum = { a: Int, b: Int -> a + b }
    println(sum(10, 20))  // 30
    
    // 여러 줄 람다 (마지막 표현식이 반환값)
    val calculate = { x: Int, y: Int ->
        val result = x * y
        println("계산 중...")
        result  // 반환값
    }
    println(calculate(5, 3))  // 15
}

타입 추론

fun main() {
    // 타입 명시
    val add: (Int, Int) -> Int = { a, b -> a + b }
    
    // 타입 추론 (파라미터 타입 생략)
    val multiply = { a: Int, b: Int -> a * b }
    
    // 사용
    println(add(10, 20))       // 30
    println(multiply(5, 3))    // 15
}

함수 타입

fun main() {
    // 파라미터 없고 반환 없음
    val action: () -> Unit = { println("실행") }
    
    // 파라미터 있고 반환 있음
    val transform: (String) -> Int = { it.length }
    
    // 여러 파라미터
    val combine: (String, Int) -> String = { str, num -> 
        "$str: $num" 
    }
    
    // Nullable 반환
    val findUser: (Long) -> User? = { id ->
        if (id > 0) User(id, "규철") else null
    }
}

data class User(val id: Long, val name: String)

Java와 비교

// Java - 함수형 인터페이스 필요
@FunctionalInterface
interface Operation {
    int execute(int a, int b);
}

// 사용
Operation add = (a, b) -> a + b;
int result = add.execute(10, 20);
// Kotlin - 함수 타입 직접 사용
val add: (Int, Int) -> Int = { a, b -> a + b }
val result = add(10, 20)

고차 함수 - 함수를 인자로, 함수를 반환

함수를 인자로 받기

// 고차 함수 정의
fun calculate(a: Int, b: Int, operation: (Int, Int) -> Int): Int {
    return operation(a, b)
}

fun main() {
    // 다양한 연산 전달
    println(calculate(10, 5, { a, b -> a + b }))  // 15
    println(calculate(10, 5, { a, b -> a - b }))  // 5
    println(calculate(10, 5, { a, b -> a * b }))  // 50
    
    // 미리 정의된 람다
    val divide: (Int, Int) -> Int = { a, b -> a / b }
    println(calculate(10, 5, divide))  // 2
}

후행 람다 (Trailing Lambda)

마지막 파라미터가 람다면 괄호 밖으로 빼낼 수 있습니다.

fun repeat(times: Int, action: () -> Unit) {
    for (i in 1..times) {
        action()
    }
}

fun main() {
    // 일반 호출
    repeat(3, { println("안녕!") })
    
    // 후행 람다 (권장)
    repeat(3) {
        println("안녕!")
    }
    // 안녕!
    // 안녕!
    // 안녕!
}

함수를 반환하기

fun makeMultiplier(factor: Int): (Int) -> Int {
    return { number -> number * factor }
}

fun main() {
    val double = makeMultiplier(2)
    val triple = makeMultiplier(3)
    
    println(double(5))  // 10
    println(triple(5))  // 15
    
    // 직접 호출
    println(makeMultiplier(10)(5))  // 50
}

실전 예제 - 로깅 데코레이터

fun <T> withLogging(
    name: String,
    block: () -> T
): T {
    println("[$name] 시작")
    val startTime = System.currentTimeMillis()
    
    val result = block()
    
    val elapsed = System.currentTimeMillis() - startTime
    println("[$name] 완료 (${elapsed}ms)")
    
    return result
}

fun main() {
    val result = withLogging("데이터 처리") {
        // 시간이 걸리는 작업
        Thread.sleep(100)
        "완료"
    }
    
    println("결과: $result")
    // [데이터 처리] 시작
    // [데이터 처리] 완료 (100ms)
    // 결과: 완료
}

실전 예제 - 재시도 로직

fun <T> retry(
    times: Int = 3,
    delay: Long = 1000,
    block: () -> T
): T {
    var lastException: Exception? = null
    
    repeat(times) { attempt ->
        try {
            return block()
        } catch (e: Exception) {
            lastException = e
            println("시도 ${attempt + 1} 실패: ${e.message}")
            if (attempt < times - 1) {
                Thread.sleep(delay)
            }
        }
    }
    
    throw lastException!!
}

fun main() {
    var count = 0
    
    try {
        val result = retry(times = 3, delay = 100) {
            count++
            if (count < 3) {
                throw Exception("아직 실패")
            }
            "성공!"
        }
        
        println("최종 결과: $result")
    } catch (e: Exception) {
        println("모든 시도 실패")
    }
}

it 키워드와 underscore

it - 단일 파라미터의 암시적 이름

fun main() {
    val numbers = listOf(1, 2, 3, 4, 5)
    
    // 명시적 파라미터명
    val doubled1 = numbers.map { number -> number * 2 }
    
    // it 사용 (파라미터가 하나일 때)
    val doubled2 = numbers.map { it * 2 }
    
    println(doubled2)  // [2, 4, 6, 8, 10]
    
    // 체이닝에서 it
    val result = numbers
        .filter { it % 2 == 0 }  // 짝수만
        .map { it * it }          // 제곱
        .sum()
    
    println(result)  // 20 (4 + 16)
}

it 중첩 시 주의

fun main() {
    val nested = listOf(
        listOf(1, 2, 3),
        listOf(4, 5, 6)
    )
    
    // ❌ 혼란스러운 코드
    val bad = nested.map { 
        it.filter { it > 2 }  // 두 번째 it이 무엇?
    }
    
    // ✅ 명시적 이름 사용
    val good = nested.map { list ->
        list.filter { number -> number > 2 }
    }
    
    println(good)  // [[3], [4, 5, 6]]
}

underscore - 사용하지 않는 파라미터

fun main() {
    val map = mapOf(
        "국어" to 90,
        "영어" to 85,
        "수학" to 95
    )
    
    // 키는 사용하지 않고 값만 필요
    val scores = map.map { (_, score) -> score }
    println(scores)  // [90, 85, 95]
    
    // 여러 파라미터 중 일부만 사용
    val pairs = listOf(1 to "a", 2 to "b", 3 to "c")
    val letters = pairs.map { (_, letter) -> letter }
    println(letters)  // [a, b, c]
}

수신 객체 지정 람다

기본 개념

람다 내부에서 객체의 멤버를 직접 접근할 수 있습니다.

fun buildString(builder: StringBuilder.() -> Unit): String {
    val sb = StringBuilder()
    sb.builder()  // StringBuilder의 멤버 함수 호출
    return sb.toString()
}

fun main() {
    val result = buildString {
        // 여기서 this는 StringBuilder
        append("안녕")
        append("하세요")
        appendLine("!")
        append("Kotlin")
    }
    
    println(result)
    // 안녕하세요!
    // Kotlin
}

apply - 객체 설정

객체를 설정하고 그 객체를 반환합니다.

data class User(
    var id: Long = 0,
    var name: String = "",
    var email: String = ""
)

fun main() {
    val user = User().apply {
        id = 1
        name = "규철"
        email = "user@example.com"
    }
    
    println(user)
    // User(id=1, name=규철, email=user@example.com)
}

with - 객체로 여러 작업

객체로 여러 작업을 수행하고 마지막 표현식을 반환합니다.

fun main() {
    val numbers = mutableListOf(1, 2, 3)
    
    val result = with(numbers) {
        add(4)
        add(5)
        sum()  // 마지막 표현식 반환
    }
    
    println(numbers)  // [1, 2, 3, 4, 5]
    println(result)   // 15
}

run - apply + with

객체로 작업 수행 후 결과를 반환합니다.

data class Person(val name: String, val age: Int)

fun main() {
    val person = Person("규철", 30)
    
    val description = person.run {
        "이름: $name, 나이: $age"
    }
    
    println(description)  // 이름: 규철, 나이: 30
}

also - 체이닝 중 부가 작업

객체에 부가 작업을 수행하고 객체 자체를 반환합니다.

fun main() {
    val numbers = mutableListOf(1, 2, 3)
        .also { println("초기 리스트: $it") }
        .also { it.add(4) }
        .also { println("4 추가 후: $it") }
        .also { it.add(5) }
    
    println("최종: $numbers")
    // 초기 리스트: [1, 2, 3]
    // 4 추가 후: [1, 2, 3, 4]
    // 최종: [1, 2, 3, 4, 5]
}

let vs apply vs run vs also vs with

data class User(var name: String, var age: Int)

fun main() {
    val user = User("규철", 30)
    
    // let - it 사용, 결과 반환
    val length = user.let {
        println("User: ${it.name}")
        it.name.length
    }
    
    // apply - this 사용, 객체 반환
    user.apply {
        name = "김규철"
        age = 31
    }
    
    // run - this 사용, 결과 반환
    val description = user.run {
        "$name ($age세)"
    }
    
    // also - it 사용, 객체 반환
    user.also {
        println("로그: ${it.name}")
    }
    
    // with - this 사용, 결과 반환
    with(user) {
        println("$name, $age")
    }
}

비교 표

함수 객체 참조 반환 주 용도

let it 결과 null 체크, 변환
apply this 객체 객체 설정
run this 결과 객체로 계산
also it 객체 부가 작업 (로깅)
with this 결과 객체로 여러 작업

inline 함수

inline이란?

함수 호출을 함수 본문으로 대체하여 성능을 향상시킵니다.

// 일반 함수
fun normalFunction(action: () -> Unit) {
    action()
}

// inline 함수
inline fun inlineFunction(action: () -> Unit) {
    action()
}

fun main() {
    // 일반 함수 - 람다가 객체로 생성됨
    normalFunction { 
        println("일반")
    }
    
    // inline 함수 - 람다 코드가 직접 삽입됨 (객체 생성 없음)
    inlineFunction { 
        println("인라인")
    }
}

inline의 장점 - non-local return

fun main() {
    val numbers = listOf(1, 2, 3, 4, 5)
    
    // inline 함수인 forEach에서 return 가능
    numbers.forEach {
        if (it == 3) return  // main 함수에서 리턴!
        println(it)
    }
    
    println("이 줄은 실행 안 됨")
}
// 출력:
// 1
// 2

noinline - 일부만 inline 제외

inline fun doSomething(
    inlined: () -> Unit,
    noinline notInlined: () -> Unit
) {
    inlined()
    
    // noinline 람다는 변수에 저장 가능
    val stored = notInlined
    stored()
}

crossinline - non-local return 금지

inline fun runAsync(crossinline action: () -> Unit) {
    Thread {
        action()  // non-local return 불가
    }.start()
}

fun main() {
    runAsync {
        // return  // ❌ 컴파일 에러
        println("비동기 실행")
    }
}

실전 예제 - 측정 함수

inline fun <T> measureTime(
    name: String,
    block: () -> T
): T {
    val start = System.currentTimeMillis()
    val result = block()
    val elapsed = System.currentTimeMillis() - start
    println("[$name] ${elapsed}ms")
    return result
}

fun main() {
    val result = measureTime("복잡한 계산") {
        (1..1000000).sum()
    }
    
    println("결과: $result")
}

실전 DSL 패턴

HTML DSL

@DslMarker
annotation class HtmlDsl

@HtmlDsl
class HTML {
    private val children = mutableListOf<String>()
    
    fun body(init: Body.() -> Unit) {
        val body = Body()
        body.init()
        children.add(body.toString())
    }
    
    override fun toString() = "<html>\n${children.joinToString("\n")}\n</html>"
}

@HtmlDsl
class Body {
    private val children = mutableListOf<String>()
    
    fun h1(text: String) {
        children.add("<h1>$text</h1>")
    }
    
    fun p(text: String) {
        children.add("<p>$text</p>")
    }
    
    override fun toString() = "<body>\n${children.joinToString("\n")}\n</body>"
}

fun html(init: HTML.() -> Unit): HTML {
    val html = HTML()
    html.init()
    return html
}

fun main() {
    val page = html {
        body {
            h1("Kotlin DSL")
            p("강력한 DSL을 만들 수 있습니다")
            p("타입 안전하게!")
        }
    }
    
    println(page)
}

테스트 DSL

class TestSuite(val name: String) {
    private val tests = mutableListOf<Test>()
    
    fun test(name: String, block: () -> Unit) {
        tests.add(Test(name, block))
    }
    
    fun run() {
        println("=== $name ===")
        tests.forEach { it.run() }
    }
}

class Test(val name: String, val block: () -> Unit) {
    fun run() {
        try {
            block()
            println("✓ $name")
        } catch (e: AssertionError) {
            println("✗ $name: ${e.message}")
        }
    }
}

fun describe(name: String, init: TestSuite.() -> Unit) {
    val suite = TestSuite(name)
    suite.init()
    suite.run()
}

fun assertEquals(expected: Any?, actual: Any?) {
    if (expected != actual) {
        throw AssertionError("Expected: $expected, Actual: $actual")
    }
}

fun main() {
    describe("계산기 테스트") {
        test("덧셈") {
            assertEquals(4, 2 + 2)
        }
        
        test("뺄셈") {
            assertEquals(0, 2 - 2)
        }
        
        test("실패 테스트") {
            assertEquals(5, 2 + 2)  // 실패
        }
    }
}

설정 DSL

class ServerConfig {
    var host: String = "localhost"
    var port: Int = 8080
    var ssl: Boolean = false
    
    fun database(init: DatabaseConfig.() -> Unit) {
        val db = DatabaseConfig()
        db.init()
        println("DB 설정: $db")
    }
}

class DatabaseConfig {
    var url: String = ""
    var username: String = ""
    var password: String = ""
    
    override fun toString() = "Database(url=$url, user=$username)"
}

fun server(init: ServerConfig.() -> Unit): ServerConfig {
    val config = ServerConfig()
    config.init()
    return config
}

fun main() {
    val config = server {
        host = "example.com"
        port = 443
        ssl = true
        
        database {
            url = "jdbc:postgresql://localhost/mydb"
            username = "admin"
            password = "secret"
        }
    }
    
    println("서버: ${config.host}:${config.port}, SSL: ${config.ssl}")
}

마무리 - 다음 편 예고

오늘 배운 것 ✅

  • 람다 표현식 - { 파라미터 -> 본문 }
  • 고차 함수 - 함수를 인자로, 함수를 반환
  • it 키워드 - 단일 파라미터의 암시적 이름
  • 수신 객체 지정 람다 - apply, run, with, let, also
  • inline 함수 - 성능 최적화와 non-local return
  • DSL 패턴 - 타입 안전한 빌더

다음 편에서 배울 것 📚

9편: 확장 함수와 프로퍼티 완벽 가이드 | 기존 클래스 확장하기

  • Extension Functions 심화
  • Extension Properties
  • infix 함수로 DSL 만들기
  • 연산자 오버로딩
  • 제네릭 확장 함수
  • 실전 유틸리티 라이브러리 만들기

실습 과제 💪

// 1. 고차 함수 만들기
// - 조건을 만족할 때까지 재시도
// - 실행 시간 측정 데코레이터

// 2. 수신 객체 지정 람다 활용
// - 설정 빌더 패턴
// - HTML 생성기

// 3. DSL 만들기
// - JSON 빌더
// - SQL 쿼리 빌더

// 4. inline 함수
// - 성능 측정 함수
// - 트랜잭션 헬퍼

자주 묻는 질문 (FAQ)

Q: 람다와 익명 함수의 차이는?
A: 람다는 간결한 문법, 익명 함수는 명시적 반환 타입 가능. 대부분 람다를 씁니다.

Q: apply와 also 중 뭘 써야 하나요?
A: 객체 설정은 apply (this), 부가 작업은 also (it)를 쓰세요.

Q: inline은 항상 좋나요?
A: 아닙니다! 고차 함수에만 유용하고, 일반 함수는 코드 크기만 늘어납니다.

Q: DSL은 언제 만드나요?
A: 설정, 테스트, 빌더 등 반복적인 패턴이 있을 때 유용합니다.


관련 글


💬 댓글로 알려주세요!

  • 어떤 고차 함수를 자주 사용하시나요?
  • DSL을 만들어본 경험이 있나요?
  • 이 글이 도움이 되셨나요?
반응형
Comments