[Kotlin in Action 2/e] 5장 람다를 사용한 프로그래밍

왕왕조현·2026년 2월 4일

Kotlin in Action 2/e

목록 보기
5/18
post-thumbnail

안녕하세요!

코틀린의 큰 특징 중 하나인 람다에 대한 정리로 돌아온 개발자 꿈나무 김조현입니다.

이번 글에서는 람다가 무엇인지, 람다를 어떻게 사용하는지에 대해 정리해볼 것입니다.


람다란?

람다 또는 람다식이라고 불리는 이 개념은 다른 함수에 넘길 수 있는 작은 코드 조각을 의미합니다.

람다를 사용하면 쉽게 공통 코드 구조를 함수로 뽑아낼 수 있다는 장점이 있습니다.

자바에서는 익명 내부 클래스를 통해 코드를 함수에 넘기거나 변수에 저장하는 동작을 하였습니다. 하지만 익명 내부 클래스를 이용해 위의 기능을 구현하는건 상당히 번거롭다는 단점이 있습니다.

코틀린은 람다를 활용해 함수를 값처럼 다룹니다. 클래스의 인스턴스를 함수에 넘기는 대신 함수를 직접 다른 함수에 전달하는 것입니다.

람다를 활용하면 코드가 더욱 간결해지며, 함수를 선언할 필요가 없습니다. 이런 장점들은 함수형 프로그래밍의 특성을 잘 활용할 수 있게 됩니다.

⭐️ 함수형 프로그래밍의 특징

  • 일급 시민인 함수: 함수를 값으로 다룰 수 있어, 변수에 저장하거나 파라미터로 전달할 수 있다.
  • 불변성: 객체가 만들어진 후에 내부 상태가 변하지 않음을 보장한다.
  • 부수 효과 없음: 함수가 똑같은 입력에 대해 항상 같은 출력을 반환한다.

이런 식으로 람다를 활용하면 간단하게 구현할 수 있습니다.

// object 선언으로 리스너 구현
button.setOnClickListener(object: OnClickListener {
	override fun onClick(v: View) {
		println("I was clicked!")
	}
})

// 람다로 리스너 구현
button.setOnClickListener {
	println("I was clicked!")
}

사람들 중에 가장 나이가 많은 사람을 찾고 싶다면 아래와 같은 함수를 만들 수 있습니다. 하지만 함수를 직접 만들 때 비교연산자가 잘못 사용하거나, 로직을 작성하다 실수를 할 수도 있습니다.

코틀린은 다양한 표준 라이브러리 함수를 제공합니다. 그 중 하나로 maxByOrNull 함수가 있습니다. 코틀린의 표준 라이브러리를 잘 활용하면 훨씬 깔끔한 코드를 작성할 수 있습니다.

fun findTheOldest(people: List<Person>) {
	var maxAge = 0
	var theOldest: Person? = null
	for (person in people) {
		if (person.age > maxAge) {
			maxAge = person.age
			theOldest = person
		}
	}
	println(theOldest)
}

fun main() {
	val people = listOf(Person("Alice", 29), Person("Bob", 31))
	findTheOldest(people)
	// Person(name=Bob, age=31)
}
fun main() {
	val people = listOf(Person("Alice", 29), Person("Bob", 31))
	println(people.maxByOrNull { it.age })
	// Person(name=Bob, age=31)
	
	println(people.maxByOrNull(Person::age))
	// Person(name=Bob, age=31)
}

람다가 단순히 함수나 프로퍼티에 위임할 경우에는 멤버 참조를 사용할 수 있습니다.

어째서 이런 식으로 람다식이 작성되는지는 람다식의 문법에 대한 설명 후에 정리하겠습니다.


람다식의 문법은?

코틀린의 람다석인 항상 중괄호에 둘러싸여 있습니다. 인자 목록 주변에 괄호가 없는 대신 본문과 인자 목록을 화살표(->)를 사용해 구분해줍니다.

람다식은 변수에 할당할 수 있고, 저장된 변수를 다른 일반 함수와 마찬가지로 다룰 수 있습니다.

fun main() {
	val sum = { x: Int, y: Int -> x + y }
	println(sum(1, 2))
	// 3
}

아니면 직접 람다식을 호출해도 됩니다. 하지만 이런 방식은 그다지 쓸모가 없어 보입니다.

fun main() {
	{ println(42) }()
	// 42
}

코드의 일부분을 블록으로 둘러싸 실행할 필요가 있다면 run을 사용하면 됩니다. run은 인자로 받은 람다를 실행해 주는 라이브러리 함수입니다. run을 사용해서 위의 코드를 고쳐보면 아래와 같습니다.

fun main() {
	run() { println(42) }
	// 42
}

람다식이 줄여지는 과정은?

위의 람다식을 줄여쓸 수 있는 기능을 제공하고 정식으로 람다를 작성하면 아래와 같습니다.

people.maxByOrNull({ p: Person -> p.age })

중괄호 안에 있는 람다식을 maxByOrNull 함수에게 넘기는 식으로 동작하는 것입니다. 이러한 코드를 줄일 수 있는 방법을 하나씩 적용해보겠습니다.

코틀린에서는 함수 호출 시 맨 뒤에 있는 인자가 람다식이라면 그 람다를 괄호 밖으로 빼낼 수 있는 관습이 있습니다. 이를 적용하면 아래와 같이 변하게 됩니다.

people.maxByOrNull() { p: Person -> p.age }

또한 람다가 유일한 인자이고 괄호 뒤에 람다를 썼다면 호출 시 빈 괄호를 없앨 수 있습니다.

people.maxByOrNull { p: Person -> p.age }

그러면 위의 예시와 똑같은 방식으로 람다를 작성할 수 있게 됩니다. 설명을 위해 지나온 이 단계들은 전부 같은 결과값을 반환하는 코드입니다.

인자가 여러개 있어도 마지막 인자만 람다인 경우에는 위처럼 람다식을 함수 바깥으로 빼낼 수 있습니다.

그렇다면 만약 둘 이상의 람다를 인자로 받는다면 어떻게 될까요? 이 경우에는 둘 이상의 람다를 밖으로 빼낼 수 없고, 모든 람다를 괄호 안에 넣는 것을 권장합니다.

로컬 변수처럼 컴파일러는 람다 파라미터의 타입을 추론할 수 있습니다. 따라서 람다식에서 파라미터 타입을 명시할 필요가 없어 생략할 수 있습니다.

people.maxByOrNull{ p: Person -> p.age }
people.maxByOrNull{ p -> p.age }

마지막으로 람다의 파라미터가 하나뿐이고 타입을 컴파일러가 추론할 수 있는 경우에 it을 사용하여 파라미터의 이름을 명시하지 않아도 됩니다.

people.maxByOrNull{ it.age }

람다를 변수에 저장할 때는 파라미터의 타입을 추론할 문맥이 존재하지 않기 때문에 파라미터의 타입을 명시해야 한다는 점을 주의해야합니다.

val getAge = { p: Person -> p.age }
people.maxByOrNull(getAge)

지금까지는 한 줄로 이뤄진 람다만을 살펴봤습니다. 람다의 본문이 길어질 경우에는 여러 줄 중에 가장 마지막에 있는 식이 람다의 결과값이 됩니다. 이때 명시적인 return이 필요하지 않습니다.

fun main() {
	val sum = { x: Int, y: Int ->
		println("Computing the sum of $x and $y...")
		x + y
	}
	println(sum(1, 2))
	// Computing the sum of 1 and 2...
	// 3
}

변수 캡쳐란?

람다를 함수 안에서 정의하면 함수의 파라미터뿐 아니라 람다 정의보다 앞에 선언된 로컬 변수까지 람다에서 모두 사용할 수 있는 기능을 변수 캡쳐라고 합니다.

fun printProblemCounts(responses: Collection<String>, perfix: String) {
	var clientErrors = 0
	var serverErrors = 0
	responses.forEach {
		if (it.startsWith("4")) {
			clientErrors++
		} else if (it.startsWith("5")) {
			serverErrors++
		}
		println("$prefix $clientErrors client errors, $serverErrors server errors")
	}
}

fun main() {
	val responses = listOf("200 OK", "403 Forbidden", "500 Internal Server Error")
	printProblemCounts(responses, "Errors Count:")
	// "Errors Count: 0 client errors, 0 server errors"
	// "Errors Count: 1 client errors, 0 server errors"
	// "Errors Count: 1 client errors, 1 server errors"
}

위 코드처럼 각 원소에 대한 작업을 람다로 수행하며, 람다 안에서 함수의 파라미터인 prefix를 사용할 수 있습니다.

⭐️ 자바와 코틀린의 차이점
자바와 다르게 코틀린은 람다 안에서는 파이널 변수가 아닌 변수에도 접근할 수 있다. 따라서 람다 안에서 바깥의 변수를 변경할 수 있습니다.

위의 코드처럼 람다식 안에서 clientErrors와 serverErrors의 변수를 변경할 수 있습니다.

prefix, clientErrors, server와 같이 람다 안에서 접근할 수 있는 외부 변수를 ‘람다가 캡쳐한 변수’라고 부릅니다.

일반 함수의 로컬 변수의 생명주기는 함수가 반환되면 끝나게 됩니다. 하지만 어떤 함수가 자신의 로컬 변수를 캡처한 람다를 반환하거나 다른 변수에 저장한다면 로컬 변수의 생명주기가 달라질 수 있습니다.

캡처한 변수가 람다를 저장해서 함수가 끝난 뒤에 실행해도 람다의 본문 코드가 여전히 캡처한 변수를 사용할 수 있는 이유는 특별한 저장 방법 때문입니다.

class Ref<T>(var value: T)

fun main() {
	val counter = Ref(0)
	val inc = { counter.value++ }
}
fun main() {
	var counter = 0
	val inc = { counter++ }
}

파이널 변수를 캡처한 경우에는 람다 코드를 변수 값과 함께 저장하고, 파이널이 아닌 변수를 캡처한 경우에는 변수를 특별한 래퍼로 감싸서 나중에 변경하거나 읽을 수 있게 하여 래퍼에 대한 참조를 람다 코드와 함께 저장하는 것입니다.

특별한 래퍼로 감싼 것이 Ref클래스 입니다. 첫 번째 코드는 두 번째 코드의 동작 내부를 보여줍니다. 첫 번째 코드는 변경 불가능한 변수를 캡쳐했지만 변수가 가리키는 필드의 값을 바꿀 수 있는 것입니다.

람다가 파이널 변수를 캡처하면 그 변수의 값이 복사되고, 변경 가능한 변수를 캡처하면 변수를 Ref 클래스 인스턴스에 넣어 저장합니다.

다만 람다를 비동기적으로 실행되는 코드로 활용할 경우 로컬 변수 변경은 람다가 실행될 때만 일어납니다.

fun tryToCountButtonClicks(button: Button): Int {
	var clicks = 0
	button.onClick { clicks++ }
	return clicks
}

이 함수는 항상 0을 반환합니다. onClick 람다식은 clicks를 반환한 다음에 호출되기 때문입니다. 이 코드를 제대로 동작시키기 위해서는 클릭 횟수를 세는 카운터 변수를 함수 밖으로 옮겨야 합니다.


멤버 참조란?

위쪽 코드에서 람다식을 멤버 참조로 줄일 수 있는 예제를 보여드렸습니다.

멤버 참조는 정확히 한 메소드를 호출하거나 한 프로퍼티에 접근하는 함수 값을 만들어주는 식입니다.

멤버 참조를 사용하기 위해서는 클래스 이름과 멤버를 이중 콜론(::)으로 구분하여 사용하면 됩니다.

다만 멤버 참조 뒤에는 괄호를 넣으면 안됩니다. 멤버 참조는 해당 대상을 참조할 뿐이지 호출하려는 것은 아니기 때문입니다.

Person::age // 클래스::멤버로 사용됨

people.maxByOrNull(Person::age)
people.maxByOrNull({ person: Person -> person.age })
// 이 두 문장은 같다

최상위에 선언된 함수나 프로퍼티 참조의 경우에는 클래스 이름을 생략하고 참조를 할 수 있습니다.

fun salute() = println("Salute!")

fun main() {
	run(::salute)
	// Salute!
}

람다가 인자를 여러개 가진다면 다른 함수에게 작업을 위임하는 경우 멤버 참조를 사용하면 편리합니다.

val action = { person: Person, message: String -> 
	sendEmail(person, message) 
}

val nextAction = ::sendEmail

생성자 참조를 사용하면 클래스 생성 작업을 연기하거나 저장해둘 수 있습니다. 생성자 참조는 :: 뒤에 클래스 이름을 넣으면 됩니다.

fun main() {
	val createPerson = ::Person
	val p = createPerson("Alice", 29)
	println(p)
	// Person(name=Alice, age=29)
}

확장 함수도 멤버 함수와 똑같은 방식으로 참조할 수 있습니다. 확장 함수를 호출할 때 person.isAdult()처럼 인스턴스 멤버 호출 구문을 쓸 수 있는 것처럼 멤버 참조 구문을 사용할 수 있습니다.

fun Person.isAdult() = age >= 21
val predicate = Person::isAdult

값과 엮인 호출 가능 참조는?

값과 엮인 호출 가능 참조는 같은 멤버 참조 구문을 사용해 특정 객체 인스턴스에 대한 메소드 호출에 대한 참조를 만들 수 있는 기능을 의미합니다.

personAgeFunction은 일반적인 멤버 참조로 Person 인자를 받았을 때 age를 반환해줍니다. 반면 sebsAgeFunction은 지정된 객체의 age가 반환되는 것입니다.

fun main() {
	val sub = Person("Sebastian", 26)
	val personAgeFunction = Person::age
	println(personAgeFunction(seb))
	// 26
	val sebsAgeFunction = seb::age
	println(sebsAgeFunction())
	// 26
}

자바의 함수형 인터페이스 사용은?

코틀린 람다는 자바 API와 완전히 호환됩니다. 자바의 인터페이스 구현은 버전에 따라 다릅니다. 자바 8 이전에는 익명 클래스 인스턴스를 만들어서 메소드에 전달해야했습니다.

public class Button {
	public void setOnCLickListener(OnCLickListener l) { ... }
}

public interface OnClickListener {
	void onCLick(View v);
}

// 자바 8 이전
button.setOnClickListener(new OnClickListener() {
	@Override
	public void onClick(View v) {
		/* ... */
	}
}

// 자바 8 이후
button.setOnClickListener(view -> { /* ... */ })

// 코틀린
button.setOnClickListener{ view -> /* ... */ }

OnClickListener 인터페이스 안에 추상 메서도그 단 하나 뿐이여서 이런 코드가 가능합니다. 이런 인터페이스를 함수형 인터페이스나 단일 추상 메소드(Single Abstract Method) 인터페이스라고 부릅니다.

함수형 인터페이스를 파라미터로 받는 모든 자바 메소드에 코틀린은 람다를 전달할 수 있습니다.

/* 자바 코드 */
void postponeComputation(int delay, Runnable computation);

postponeComutation(1000) { println(42) }

Runnable을 명시적으로 구현하는 익명 객체를 만드는 것도 같은 효과를 가집니다.

postponeComutation(1000, object: Runnable {
	override fun run() {
		println(42)
	}
})

명시적으로 객체를 선언하면 매번 호출할 때마다 새 인스턴스를 만들게 됩니다. 람다가 자신이 정의된 함수의 변수에 접근하지 않는다면 함수가 호출될 때마다 람다에 해당하는 익명 객체가 재사용됩니다.

하지만 람다가 자신을 둘러싼 환경 변수를 캡쳐하면 더 이상 함수 호출에 같은 인스턴스를 재사용할 수 없게 됩니다.


람다를 함수형 인터페이스로 명시적 변환하는 것은?

SAM 생성자는 컴파일러가 생성한 함수로 람다를 단일 추상 메서드 인터페이스의 인스턴스로 명시적으로 변환해줍니다. 이를 컴파일러가 변환을 자동으로 수행하지 못하는 맥락에서 사용할 수 있습니다.

SAM 생성자의 이름은 사용하려는 함수형 인터페이스의 이름과 같습니다.

fun createAllDoneRunnable(): Runnable {
	return Runnable{ println("All done!") }
}

fun main() {
	createAllDoneRunnable().run()
	// All done!
}

값을 반환할 떄 외에 람다로 생성한 함수형 인터페이스 인스턴스를 변수에 저장해야하는 경우에도 SAM 생성자를 사용할 수 있습니다.

val listener = OnClickListener { view -> 
	val text = when (view.id) {
		button1.id -> "First button"
		button2.id -> "Second button"
		else -> "Unknown button"
	}
	toast(text)
}

button1.setOnClickListener(listener)
button2.setOnClickListener(listener)

위의 코드처럼 함수형 인터페이스를 listener에 저장해 활용할 수 있습니다.


코틀린에서 SAM 인터페이스 정의하는 방법?

코틀린에서 fun interface를 정의하면 함수형 인터페이스를 정의할 수 있습니다. 코틀린의 함수형 인터페이스는 정확히 하나의 추상 메소드만 포함하지만 다른 비추상 메소드를 여러개 가질 수 있습니다. 아래의 코드도 check라는 하나의 추상 메소드와 여러개의 비추상 메소드를 가집니다.

fun interface IntCondition {
	fun check(i: Int): Boolean
	fun checkString(s: String) = check(s.toInt())
	fun checkChar(c: Char) = check(c.digitToInt())
}

fun main() {
	val isOdd = IntCondition{ it % 2 != 0 }
	println(isOdd.check(1))
	// true
	println(isOdd.checkString("2"))
	// false
	println(isOdd.checkChar('3'))
	// true
}

또한 fun Interface라고 정의된 타입의 파라미터를 받는 함수가 있을 때 람다 구현이나 람다에 대한 참조를 직접 넘길 수도 있습니다.

fun checkCondition(i: Int, condition: IntCondition): Boolean {
	return condition.check(i)
}

fun main() {
	checkConditon(1) { it % 2 != 0 }
	// true
	val isOdd: (Int) -> Boolean = { it % 2 != 0 }
	checkCondition(1, isOdd)
	// true
}

수신 객체 지정 람다란?

수신 객체 지정 람다란 수신 객체를 명시하지 않고 람다의 본문 안에서 다른 객체의 메소드를 호출할 수 있게 하는 것입니다. 코틀린 표준 라이브러리에는 with, apply, also 등이 있습니다.


with 함수

with 함수는 어떤 객체의 이름을 반복하지 않고도 그 객체에 대해 다양한 연산을 수행하는 기능을 제공합니다.

fun alphabet(): String {
	val stringBuilder = StringBuilder()
	return with(stringBuilder) {
		for (letter in 'A'..'Z') {
			this.append(letter)
		}
		this.append("\nNow I know the alphabet!")
		this.toString()
	}
}

위 코드의 with는 stringBuilder와 람다, 두 개의 파라미터를 가집니다. 람다의 안에서는 명시적인 this 참조를 사용해 그 수신 객체에 접근할 수 있습니다.

this를 없애고 메소드나 프로퍼티 이름만 사용해 접근할 수도 있습니다.

또한 StringBuilder의 인스턴스를 만들고 즉시 with에 인자를 넘기는 식으로 코드를 줄일 수도 있습니다.

fun alphabet(): String {
	return with(StringBuilder()) {
		for (letter in 'A'..'Z') {
			append(letter)
		}
		append("\nNow I know the alphabet!")
		toString()
	}
}

with에 인자로 넘객 객체의 클래스와 with를 사용하는 코드가 들어있는 클래스 안에 이름이 같은 메소드가 있으면 메소드 이름 충돌이 일어날 수 있습니다. 그런 경우 this 참조 앞에 레이블을 붙이면 호출하고 싶은 메소드를 명확하게 정할 수 있습니다.

this@OuterClass.toString()

apply 함수

with와 동일하게 작동하지만 람다의 결과 대신 수신 객체가 필요한 경우 apply 함수를 사용할 수 있습니다. apply는 항상 자신에 전달된 객체(수신 객체)를 반환합니다.

즉, 인스턴스를 만들면서 즉시 프로퍼티 중 일부를 초기화해야 하는 경우 apply가 유용합니다.

fun alphaBet() = StringBuilder().apply {
	for (letter in 'A'..'Z') {
		append(letter)
	}
	append("\nNow I know the alphabet!")
}.toString()

also 함수

apply와 마찬가지로 also함수도 수신 객체를 받으며, 그 수신 객체에 대해 어떤 동작을 수행한 후 수신 객체를 돌려줍니다. apply와 주된 차이는 also의 람다 안에서는 수신 객체를 인자로 참조해야한다는 점입니다.

따라서 파라미터 이름을 부여하거나 디폴트 이름인 it을 사용해야합니다.

이런 특징으로 원래의 수신 객체를 인자로 받는 동작을 실행할 때 also가 유용합니다.

fun main() {
	val fruits = listOf("Apple", "Banana", "Cherry")
	val uppercaseFrutis = mutableListOf<String>()
	val reversedLongFruits = fruits
				.map{ it.uppercase() }
				.also{ uppercaseFruits.addAll(it) }
				.filter{ it.length > 5 }
				.also{ println(it) }
				.reversed()
	// [BANANA, CHERRY]
	println(uppercaseFruits)
	// [APPLE, BANANA, CHERRY]
	println(reversedLongFruits)
	// [CHERRY, BANANA]
}

마무리입니다!

이번 글에서는 람다에 대해 깊게 파보았습니다.

지금까지 다섯 단원에 대해 공부했지만 이번 장이 가장 재밌게 공부했던 단원인 것 같습니다.

'nameList.map{ it.uppercase() }.joinToString(" ")'로 사용했던 코드가 'nameList.joinToString(" ") { it.uppercase() }'로 바뀔 수 있는 것처럼 기존에 이해하기 힘들었던 문법을 이해할 수 있었습니다.

joinToString이 람다를 받는 것도 원래는 몰랐던 것도 있지만, 이 메소드를 사용할 때 왜 람다식을 밖으로 뺄 수 있는지 등을 알게 되었습니다.

또한 다른 사람들이 작성한 코드를 볼 때 가장 많이 보이던 메소드인 with, apply, also와 같은 수신 객체 지정 람다에 대해서도 제대로 알 수 있었습니다.

기존에는 이론보다는 사용해본 경험을 통해 사용하는 경향이 강했지만, 이제부터는 상황에 맞게 제대로 사용할 수 있을 것 같다는 자신감이 생겼습니다.

이처럼 코틀린 코드를 작성하면서 람다식을 가장 많이 활용했던 만큼 배울 수 있는 점이 많았던 단원이었습니다.

다음에는 컬렉션을 다루는 방법과 시퀀스에 대한 개념으로 돌아오겠습니다.

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

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

0개의 댓글