[Kotlin in Action 2/e] 10장 고차 함수: 람다를 파라미터와 반환값으로 사용

왕왕조현·2026년 2월 11일

Kotlin in Action 2/e

목록 보기
10/18
post-thumbnail

안녕하세요!

고차 함수에 대한 정의와 사용 방법에 대한 정리글로 돌아온 개발자 꿈나무 김조현입니다.

이번 글에서는 고차 함수가 무엇인지, 고차 함수의 비용을 줄이기 위한 방법 등에 대해 정리해보겠습니다.


고차 함수란?

고차 함수는 다른 함수를 인자로 받거나 함수를 반환하는 함수를 말합니다.


함수 타입을 지정하는 방법은?

함수 타입을 지정하려면 함수 파라미터의 타입을 괄호 안에 넣고 그 뒤에 화살표를 추가한 다음, 함수의 반환 타입을 지정하면 됩니다.

val sum: (Int, Int) -> Int = { x, y -> x + y }
val action: () -> Unit = { println(42) }

Unit 타입은 의미 있는 값을 반환하지 않는 함수 반환 타입에 쓰는 타입입니다. 그냥 함수를 정의한다면 Unit 반환 타입 지정을 생략해도 되지만 함수 타입을 선언할 때는 Unit을 생략할 수 없습니다.

또한 널이 될 수 있는 함수 타입 변수를 정의할 수도 있습니다.

val canReturnNull: (Int, Int) -> Int? = { x, y -> null }

다만 함수의 반환 타입이 아니라 함수 타입 전체가 널이 될 수 있는 타입을 선언하기 위해서는 함수 타입을 괄호로 감싸고 그 뒤에 물음표를 붙여야 합니다.

val funOrNull: ((Int, Int) -> Int)? = null

인자로 받은 함수를 호출하는 방법은?

인자로 받은 함수를 호출하는 구문은 일반 함수를 호출하는 구문과 같습니다. 또한 함수 타입에서 파라미터의 이름을 지정할 수도 있습니다.

다만, 함수 타입의 람다를 정의할 때 파라미터의 이름이 꼭 함수 타입 선언의 파라미터 이름과 일치하지 않아도 됩니다.

fun twoAndThree(operation: (Int, Int) -> Int) {
	val result = operation(2, 3)
	println("The result is $result")
}

fun main() {
	twoAndThree{ a, b -> a + b }
	// The result is 5
	twoAndThree{ a, b -> a * b }
	// The result is 6
}

자바에서 코틀린 함수 타입을 사용하기?

자바에서 코틀린 람다를 사용할 때는 자동 SAM(Single Abstract Method) 변환을 사용해 자바에서 사용할 수 있게끔 변환합니다.

// 코틀린 함수
fun processTheAnswer(f: (Int) -> Int) {
	println(f(42))
}

// 자바 호출
processTheAnswer(number -> number + 1);
// 43

그렇기에 자바에서 람다를 인자로 받는 코틀린 표준 라이브러리의 확장 함수를 쉽게 사용할 수 있지만, 수신 객체를 명시적으로 전달해야합니다.

또한 Unit을 반환하는 함수나 람다를 자바로 작성할 수도 있습니다. 하지만 코틀린 Unit 타입에는 값이 존재하므로 자바에서는 그 값을 명시적으로 반환해줘야 합니다.

import kotlin.collections.CollectionsKt;

public static void main(String[] args) {
	List<String> strings = nuwArrayList();
	strings.add("42);
	CollectionsKt.forEach(strings, s -> {
		System.out.println(s);
		return Unit.INSTANCE;
	});
}

함수 타입의 자세한 구현은?

내부에서 코틀린 함수 타입은 일반 인터페이스입니다. 함수 타입의 변수는 FunctionN 인터페이스를 구현합니다. 이 때 인터페이스는 함수 파라미터 개수에 따라 다른 인터페이스를 구현합니다. Function0<R>은 인자를 받지 않으며, Function1<P1, R>은 인자를 하나 받는 등으로 구현됩니다.

각 인터페이스는 invoke라는 유일한 메소드가 정의돼 있습니다.

FunctionN 인터페이스는 컴파일러가 생성한 합성 타입으로, 코틀린 표준 라이브러리에서 정의를 찾을 수 없습니다. 대신 필요할 때마다 컴파일러는 이런 인터페이스를 생성해주며, 이는 개수 제한 없이 원하는 만큼 파라미터를 사용하는 함수에 대한 인터페이스를 사용할 수 있다는 의미입니다.

interface Function<P1, out R> {
	operator fun invoke(p1: P1): R
}

fun processTheAnswer(f: Function1<Int, Int>) {
	println(f.invoke(42))
}

함수 타입의 파라미터에 대해 기본값을 지정하는 방법은?

파라미터를 함수 타입으로 선언할 때도 마찬가지로 기본값을 지정할 수 있습니다.

fun <T> Collection<T>.joinToString(
	separator: String = ", ",
	prefix: String = "",
	postfix: String = "",
	transform: (T) -> String = { it.toString() }
): String {
	val result = StringBuilder(prefix)
	
	for ((index, element) in this.withIndex())
		if (index > 0) result.append(separator)
		result.append(transform(element))
	}
	
	result.append(postfix)
	return result.toString()
}

fun main() {
	val letters = listOf("Alpha", "Beta")
	println(letters.joinToString())
	// Alpha, Beta
	println(letters.joinToString{ it.lowercase() })
	// alpha, beta
	println(letters.joinToString(separator = "! ", postfix = "! ",
		transform = { it.uppercase() }))
	// ALPHA!, BETA!
}

람다의 기본값을 지정하지 않았다면 위 코드처럼 유연하게 각 원소를 문자열로 변환하는 것을 제어할 수 없었을 것입니다. 이처럼 람다에 기본값을 지정하는 것은 함수의 유연성을 늘릴 수 있게 됩니다.

다른 접근 방법으로 널이 될 수 있는 함수 타입을 사용할 수도 있습니다. 널이 될 수 있는 함수 타입으로 함수를 받으면 그 함수를 직접 호출할 수 없기 때문에 null 여부를 명시적으로 검사하여 구현할 수 있습니다.

fun <T> Collection<T>.joinToString(
	separator: String = ", ",
	prefix: String = "",
	postfix: String = "",
	transform: ((T) -> String)? = null
): String {
	val result = StringBuilder(prefix)
	
	for ((index, element) in this.withIndex())
		if (index > 0) result.append(separator)
		val str = transform?.invoke(element)
			?: element.toString()
		result.append(str)
	}
	
	result.append(postfix)
	return result.toString()
}

함수를 함수에서 반환하기?

함수를 반환하는 함수는 프로그램의 상태나 다른 조건에 따라 달라질 수 있는 로직이 있을 경우에 유용하게 사용할 수 있습니다.

예를 들면 사용자가 선택한 배송 수단에 따라 배송비를 계산하는 방법이 달라질 경우에도 이를 활용할 수 있습니다.

다른 함수를 반환하는 함수를 정의하려면 함수의 반환 타입으로 함수 타입을 지정해야 합니다. 그리고 return 식에 람다, 멤버 참조, 함수 타입의 값을 계산하는 식 등을 넣으면 됩니다.

enum class Delivery{ STANDARD, EXPEDITED }

class Order(val itemCount: Int)

fun getShippingCostCalculator(delivery: Delivery): (Order) -> Double {
	if (delivery == Devlivery.EXPEDITED) {
		return { order -> 6 + 2.1 * order.itemCount }
	}
	return { order -> 1.2 * order.itemCount }
}

fun main() {
	val calculator = getShippingCostCalculator(Delivery.EXPEDITED)
	println("Shipping costs ${calculator(Order(3))}")
	// Shipping costs 12.3
}

람다를 활용해 중복을 줄여 코드 재사용성을 높이는 방법은?

함수 타입과 람다식은 재사용하기 좋은 코드를 만들 때 아주 유용합니다. 람다를 사용하면 코드 중복을 간결하고 쉽게 제거할 수 있습니다.

data class SiteVisit(
	val path: String,
	val duration: Double,
	val os: OS
)

enum class OS { WINDOWS, LINUX, MAC, IOS, ANDROID }

val log = listOf(
	SiteVisit("/", 34.0, OS.WINDOWS),
	SiteVisit("/", 22.0, OS.MAC),
	SiteVisit("/login", 12.0, OS.WINDOWS),
	SiteVisit("/signup", 8.0, OS.IOS),
	SiteVisit("/", 16.3, OS.ANDROID)
)
val averageWindowsDuration = log
	.filter{ it.os == OS.WINDOWS }
	.map(SiteVisit::duration)
	.average()
	
fun main() {
	println(averageWindowsDuration)
	// 23.0
}

위의 코드는 윈도우 사용자의 평균 방문 시간을 출력하는 코드입니다. 만약 맥 사용자의 평균 방문 시간을 알고 싶다면, 또한 특정 페이지의 평균 방문 시간을 알고 싶다면 위의 함수를 계속 람다와 인자만 바꿔 재사용해야합니다.

고차함수를 사용하면 이런 문제를 해결할 수 있습니다.

fun List<SiteVisit>.averageDurationFor(predicate: (SiteVisit) -> Boolean) =
	filter(predicate).map(SiteVisit::duration).average()

fun main() {
	println(
		log.averageDurationFor {
			it.os in setOf(OS.ANDROID, OS.IOS)
		}
	)
	// 12.15
	println(
		log.averageDurationFor {
			it.os == OS.IOS && it.path == "/signup"
		}
	)
	// 8.0
}

인라인 함수를 사용해 람다의 부가 비용 없애기

어떤 함수를 inline으로 선언하면 그 함수의 본문이 인라인됩니다. 이는 함수를 호출하는 코드를 함수를 호출하는 바이트코드 대신에 함수 본문을 번역한 바이트코드로 컴파일 한다는 의미입니다.

import java.util.concurrent.locks.Lock
import java.util.concurrent.locks.ReentrantLock

inline fun <T> synchronized(lock: Lock, action: () -> T): T {
	lock.lock()
	try {
		return action()
	}
	finally {
		lock.unlock()
	}
}

fun foo(l: Lock) {
	println("Before sync")
	synchronized(1) {
		println("Action")
	}
	println("After sync")
}

위는 synchronized함수를 inline으로 선언하여 호출한 코드입니다. 이 함수를 호출하면 컴파일할 때는 아래와 같이 실행됩니다.

fun __foo__(l: Lock) {
	println("Before sync")
	l.lock()
	try {
		println("Action")
	}	finally {
		l.unlock()
	}
	println("After sync")
}

synchronized 함수와 람다 코드의 본문이 인라이닝되어 foo 함수를 변경한 후 실행합니다.

class LockOwner(val lock: Lock) {
	fun runUnderLock(body: () -> Unit) {
		synchronized(lock, body)
	}
}

또한 인라인 함수를 호출하면서 람다를 넘기는 대신에 함수 타입의 변수를 넘길 수도 있습니다. 하지만 이런 경우 변수에 저장된 람다의 코드를 알 수 없습니다. 그렇기 때문에 람다 본문은 인라이닝되지 않습니다.

class LockOwner(val lock: Lock) {
	fun __runUnderLock__(body: () -> Unit) {
		lock.lock()
		try {
			body()
		}
		finally {
			lock.unlock()
		}
	}
}

인라인 함수의 제약

일반적으로 인라인 함수의 본문에서 람다식을 바로 호출하거나 다른 인라인 함수의 인자로 전달하는 경우에는 그 람다를 인라이닝할 수 있지만, 그런 경우가 아니라면 오류 메시지와 함께 컴파일되지 않습니다.

이런 문제를 해결하기 위해 noinline 변경자를 파라미터 앞에 붙이면 인라이닝을 금지시킬 수 있어 인라이닝이 안되는 파라미터를 구분할 수 있습니다.

inline fun foo(inline: () -> Unit, noinline notInlined: () -> Unit) {}

컬렉션 연산 인라이닝

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

val people = listOf(Person("Alice", 29), Person("Bob", 31))

fun main() {
	println(people.filter{ it.age < 30 })
	// [Person(name=Alice, age=29)]
}
fun main() {
	val result = mutableListOf<Person>()
	for (person in people) {
		if (person.age < 30) result.add(person)
	}
	println(result)
	// [Person(name=Alice, age=29)]
}

첫 번째 코드는 람다식을 활용한 filter 코드이고, 두 번째 코드는 람다식을 사용하지 않은 filter 구현 코드입니다. 코틀린에서 filter 함수는 인라인 함수이기 때문에 첫 번째 코드에서 함수에 전달된 람다 본문은 인라이닝 됩니다. 그렇기에 filter를 사용한 코드와 사용하지 않은 코드의 바이트코드는 거의 같아 성능에 차이가 거의 없습니다.

하지만 filter와 map을 연쇄해서 사용한다면 이야기가 다릅니다.

fun main() {
	prinltn(
		people.filter{ it.age > 30 }
			.map(Person::name)
	)
	// [Bob]
}

이 때는 리스트를 걸러낸 결과를 저장하는 중간리스트를 만들며, filter 함수에서 걸러낸 원소를 중간 리스트에 추가하며, map 함수에서 중간 리스트를 읽어 사용합니다.

이렇게 되면 원소가 많아질수록 부가 비용도 커지게 되는 것입니다. asSequence를 사용하면 부가 비용을 줄일 수 있습니다.

하지만 시퀀스 연산으로 성능을 향상시킬 수 있는 경우는 컬렉션의 크기가 큰 경우 뿐이기 떄문에 모든 컬렉션 연산에 asSequence를 붙이면 안됩니다.


언제 함수를 인라인으로 선언할까?

일반 함수 호출의 경우 JVM이 코드 실행을 분석해 가장 이익이 되는 방햐응로 호출을 인라이닝합니다. 반면 람다를 인자로 받는 함수를 인라이닝할 경우 이익이 더 많습니다.

첫 번째로 함수 호출 비용을 줄일 수 있을 뿐 아니라 람다를 표현하는 클래스와 객체를 만들 필요가 없어지기 때문에 인라이닝을 통해 없앨 수 있는 부가 비용이 상당합니다. 둘째로 JVM이 함수 호출과 람다를 인라이닝해주지 못합니다. 마지막으로 인라이닝을 사용하면 일반 람다에서는 사용할 수 없는 몇 가지 기능을 사용할 수 있습니다.

하지만 inline 변경자를 함수에 붙일 때는 코드 크기에 주의를 기울여야 합니다. 인라이닝하는 함수가 큰 경우는 함수의 모든 호출 부분에 함수의 본문이 들어가 바이트 코드가 전체적으로 아주 커질 수도 있기 때문입니다.


자원 관리를 위해 인라인된 람다를 사용하기

람다로 중복을 없앨 수 없는 일반적인 패턴 중 한 가지는 어떤 작업을 하기 전에 자원을 획득하고 작업을 마친 후 자원을 해제하는 자원 관리입니다.

자원 관리 패턴을 만들 때 보통 사용하는 방법은 try/finally 문을 사용하여 try 블록을 시작하기 전에 자원을 획득하고, finally 블록에서 자원을 해제하는 것입니다.

코틀린에서 제공하는 자원 관리 함수는 withLock, use, userLines 함수가 있습니다.


람다에서 반환하는 방법은?

람다를 사용할 때 람다의 본문 안에서 사용한 return은 다양한 역할로 사용될 수 있습니다.


람다를 둘러싼 함수에서 반환하기

fun lookForAlice(people: List<Person>) {
	people.forEach {
		if (it.name == "Alice") {
			println("Found!")
			return
		}
	}
	println("Alice is not found")
}

람다 안에서 reutnr을 사용하면 람다에서만 반환되는 것이 아니라 그 람다를 호출하는 함수가 실행을 끝내고 반환하게 됩니다. 이런 식으로 자신을 둘러싸고 있는 블록보다 더 바깥에 있는 다른 블록을 반환하게 만드는 return 문을 비로컬 return이라고 부릅니다.

이런 경우 return은 반복문을 끝내지 않고 메소드를 반환시킵니다.


람다로부터 반환하기

로컬 return을 사용해 람다의 실행을 끝내고 람다를 호출했던 코드의 실행을 계속 이어나갈 수 있습니다. 람다 안에서 로컬 return은 for 루프의 continue와 비슷한 역할을 합니다.

로컬 return과 비로컬 return을 구분하기 위해서는 레이블을 사용해야 합니다. return으로 실행을 끝내고 싶은 람다식 앞에 레이블을 붙이고, return 키워드 뒤에 레이블을 추가하면 됩니다.

fun lookForAlice(people: List<Person>) {
    people.forEach label@{
        if (it.name != "Alice") return@label
        println("Found!")
    }
}

이 코드는 return이 실행되지 않은 경우에만 즉, 이름이 Alice인 경우에만 아래의 println문을 실행합니다.

또는 람다를 인자로 받는 인라인 함수의 이름을 return 뒤에 레이블로 사용해도 됩니다.

fun lookForAlice(people: List<Person>) {
    people.forEach {
        if (it.name != "Alice") return@forEach
        println("Found!")
    }
}

람다식에 레이블을 명시하면 함수 이름을 레이블로 사용할 수 없습니다.


익명 함수를 활용하기

익명 함수는 람다식을 작성하는 다른 문법적 형태지만 람다와 익명 함수는 return 식을 쓸 수 있다는 점에서 차이가 있습니다.

fun lookForAlice(people: List<Person>) {
	people.forEach(fun(person) {
		if(person.name == "Alice") return
		println("${person.name} is not Alice")
	})
}

fun main() {
	lookForAlice(people)
	// Bob is not Alice
}

익명 함수 안에서 레이블이 붙지 않은 return 식은 익명 함수 자체를 반환시킬 뿐 익명 함수를 둘러싼 다른 함수를 반환시키지 않습니다. 따라서 익명 함수 본문의 return은 익명 함수를 반환시키고, 익명 함수 밖의 다른 함수를 반환시키지 못합니다.

또한 익명 함수도 일반 함수와 같은 반환 타입 지정 규칙을 따르기 때문에 반환 타입을 명시해야 합니다. 하지만 식을 본문으로 하는 익명 함수의 반환 타입은 생략할 수 있습니다.

people.filter(fun(person): Boolean {
	return person.age < 30 
}

people.filter(fun(person) = person.age < 30)

익명 함수는 람다 구문으로 쓸 때 레이블을 많이 붙여야 하는 코드를 짧게 쓸 때 도움이 됩니다.


마무리입니다!

이번 글에서는 고차 함수와 함수의 비용을 줄이기 위한 인라인 함수에 대해 정리해봤습니다.

고차 함수의 개념에 대해서는 이번에 공부하면서 알게 되었지만 우테코 마지막 미션에서 앱을 만드는 과정에서 사용해본 경험이 있습니다.

그 당시에는 '함수에 함수를 넣는 문법이 있구나' 정도로만 알고 따로 공부를 하지 않은 상태로 사용하면서 어떤 때는 함수 자체를 파라미터로 넣고 어떨 때는 함수 이름만 넣는지 헷갈리며 고차 함수 사용할 때 엄청 애먹었던 기억이 있습니다.

이번 장을 공부하며 다음에 고차함수를 사용할 때는 확실히 그런 실수가 줄어들겠다는 생각과 인라인 함수도 활용하며 코드의 성능도 챙겨볼 수 있겠다는 자신감이 생겼습니다.

다음에는 제네릭스에 대한 정리글로 돌아오겠습니다.

읽어주셔서 감사합니다!🙂‍↕️

profile
천천히, 꾸준히, 한 걸음씩

0개의 댓글