14장. Lambda와 함수 타입
지금까지 우리는 함수를 “어떤 일을 하는 코드 묶음“으로 배웠습니다.
그런데 코틀린에서는 함수를 조금 다르게도 볼 수 있습니다.
바로 함수를 “값처럼” 다루는 것입니다.
숫자를 변수에 담듯이,
함수도 변수에 담을 수 있습니다.
함수를 다른 함수에 넘겨줄 수도 있고,
함수가 함수를 돌려줄 수도 있습니다.
이 장에서는 그 출발점인 람다(Lambda)를 배웁니다.
그리고 함수를 값으로 다루는 여러 방법을 익힙니다.
13장에서 배운 컬렉션 함수들이
사실은 모두 이 개념 위에서 동작합니다.
이번 장을 지나면 그 코드들이 훨씬 선명하게 보일 것입니다.
14.1 Lambda Expression
Lambda 문법
먼저 람다(Lambda)라는 말부터 풀어 봅시다.
람다는 어렵게 말하면 “람다 표현식”,
쉽게 말하면 이름 없는 함수입니다.
우리가 지금까지 만든 함수에는 항상 이름이 있었습니다.
fun add(a: Int, b: Int): Int {
return a + b
}
이 함수의 이름은 add입니다.
그런데 어떤 함수는 딱 한 번만 쓰고 버립니다.
그럴 때마다 이름을 붙이는 것은 번거롭습니다.
이럴 때 이름 없이 “함수 그 자체“만 적는 방법이 람다입니다.
람다는
“이름표를 떼고 알맹이만 남긴 함수“입니다.
람다는 중괄호({ })로 감쌉니다.
기본 모양은 이렇습니다.
{ 매개변수 -> 실행할 코드 }
화살표(->)를 기준으로
왼쪽은 “받는 값”, 오른쪽은 “할 일“입니다.
앞의 add 함수를 람다로 바꾸면 이렇게 됩니다.
val add = { a: Int, b: Int -> a + b }
a와 b를 받아서a + b를 돌려주는 람다입니다.
여기서 눈여겨볼 점이 하나 있습니다.return을 쓰지 않았습니다.
람다에서는 마지막 줄의 값이
자동으로 결과가 됩니다.
val greet = { name: String ->
println("준비 중...")
"안녕하세요, $name 님" // 이 마지막 값이 결과가 됨
}
방금 만든 add 람다는
일반 함수처럼 괄호를 붙여 실행할 수 있습니다.
val add = { a: Int, b: Int -> a + b }
println(add(2, 3)) // 5
it: 매개변수가 하나일 때
매개변수가 하나뿐인 람다는 아주 자주 나옵니다.
그래서 코틀린은 편의 장치를 하나 준비해 두었습니다.
매개변수가 하나면 이름을 생략하고it이라는 정해진 이름으로 부를 수 있습니다.
먼저 이름을 직접 붙인 모습입니다.
val square = { x: Int -> x * x }
println(square(4)) // 16
이때 매개변수가 하나이므로x를 it으로 바꿔 쓸 수 있습니다.
이 축약은 람다를 함수의 인자로 넘길 때 특히 빛납니다.
13장에서 본 컬렉션 함수를 예로 들어 봅시다.
val numbers = listOf(1, 2, 3, 4)
val doubled = numbers.map { it * 2 }
println(doubled) // [2, 4, 6, 8]
여기서 it은
리스트의 각 원소를 하나씩 가리킵니다.
it은
“매개변수가 하나일 때만 쓸 수 있는 자동 이름“입니다.
편하다고 항상 it만 쓰는 것은 아닙니다.
의미가 헷갈릴 것 같으면
차라리 이름을 직접 붙이는 편이 읽기 좋습니다.
val users = listOf("홍길동", "이순신")
// it보다 이름이 더 분명한 경우
users.forEach { name -> println("환영합니다, $name") }
여러 매개변수
매개변수가 둘 이상이면it은 쓸 수 없습니다.
이때는 이름을 직접 적고
쉼표(,)로 구분합니다.
val sum = { a: Int, b: Int -> a + b }
val max = { a: Int, b: Int -> if (a > b) a else b }
println(sum(10, 20)) // 30
println(max(10, 20)) // 20
받는 값이 아예 없는 람다도 만들 수 있습니다.
이때는 화살표 왼쪽을 비워 두거나,
화살표 자체를 생략합니다.
val hello = { println("안녕하세요") }
hello() // 안녕하세요
자바를 조금 아는 분이라면
자바의 람다가 떠오를 수 있습니다.
자바에서는 람다를 이렇게 씁니다.
// 자바
Runnable r = () -> System.out.println("안녕하세요");
코틀린은 이렇습니다.
val r = { println("안녕하세요") }
큰 흐름은 비슷합니다.
다만 코틀린은 중괄호로 감싸고,
매개변수를 화살표 왼쪽에 둔다는 점이 다릅니다.
14.2 함수도 값이다
함수 타입
앞 절에서 우리는 람다를 변수에 담았습니다.
val add = { a: Int, b: Int -> a + b }
여기서 잠깐 멈춰 생각해 봅시다.
변수에는 늘 타입이 있었습니다. (2장을 떠올려 주세요.)
val name = "홍길동"에서name의 타입은 String이었습니다.
그렇다면 함수를 담은 add의 타입은 무엇일까요?
바로 함수 타입(function type)입니다.
“이 변수에는 함수가 들어 있다“를 나타내는 타입입니다.
함수 타입은 이렇게 적습니다.
(매개변수 타입들) -> 반환 타입
- 화살표 왼쪽 괄호 : 어떤 값을 받는가
- 화살표 오른쪽 : 어떤 값을 돌려주는가
add는 Int 두 개를 받아 Int를 돌려줍니다.
그래서 타입은 (Int, Int) -> Int입니다.
타입을 직접 적으면 이렇게 됩니다.
val add: (Int, Int) -> Int = { a, b -> a + b }
타입을 밝혀 두면
람다 안에서 a, b의 타입을 생략할 수 있습니다.
왼쪽 타입만 봐도 코틀린이 알아채기 때문입니다.
몇 가지 함수 타입을 더 살펴봅시다.
| 함수 타입 | 뜻 |
|---|---|
() -> Unit | 받는 값 없음, 돌려줄 값 없음 |
(Int) -> Int | 정수 하나 받고 정수 하나 돌려줌 |
(String) -> Boolean | 문자열 받고 참/거짓 돌려줌 |
(Int, Int) -> Int | 정수 둘 받고 정수 하나 돌려줌 |
여기서 Unit은 2장에서 배운
“돌려줄 값이 없음“을 뜻하는 타입입니다.
함수를 변수에 저장하기
이제 함수를 값처럼 다루는 그림이 그려집니다.
숫자를 변수에 담고 나중에 꺼내 쓰듯이,
함수도 변수에 담고 나중에 꺼내 실행합니다.
val discount: (Int) -> Int = { price -> price - 1000 }
val result = discount(5000)
println(result) // 4000
discount라는 상자 안에
“1000원을 빼는 동작“을 담아 둔 것입니다.
값이니까 다른 함수에도 담을 수 있습니다.
var policy: (Int) -> Int = { price -> price - 1000 }
// 나중에 다른 동작으로 바꿀 수도 있음 (var이므로)
policy = { price -> price / 2 }
println(policy(5000)) // 2500
함수를 값으로 다룬다는 것은
“동작 자체를 변수에 담아 옮긴다“는 뜻입니다.
이 발상이 이번 장 전체의 핵심입니다.
지금까지 우리는 값을 넘겼지만,
이제부터는 동작을 넘길 수 있습니다.
14.3 고차 함수
고차 함수란
함수가 값이라면,
함수를 다른 함수에 넘길 수도 있어야 합니다.
이렇게 함수를 인자로 받거나
함수를 결과로 돌려주는 함수를
고차 함수(higher-order function)라고 합니다.
말은 거창하지만 뜻은 간단합니다.
고차 함수는
“함수를 다루는 함수“입니다.
사실 우리는 이미 고차 함수를 써 봤습니다.
13장의 map, filter, forEach가 모두 고차 함수입니다.
이제 그 안이 어떻게 생겼는지 직접 만들어 봅시다.
함수를 인자로 받기
먼저 함수를 인자로 받는 고차 함수를 만들어 보겠습니다.
“두 숫자에 어떤 계산을 적용할지“를 밖에서 정하게 하는 함수입니다.
fun calculate(a: Int, b: Int, operation: (Int, Int) -> Int): Int {
return operation(a, b)
}
세 번째 매개변수 operation을 봅시다.
타입이 (Int, Int) -> Int입니다.
즉 operation은 값이 아니라
“정수 둘을 받아 정수를 돌려주는 함수“입니다.
이제 이 함수에 서로 다른 동작을 넘겨 봅시다.
val sum = calculate(3, 4) { a, b -> a + b }
val product = calculate(3, 4) { a, b -> a * b }
println(sum) // 7
println(product) // 12
같은 calculate인데
넘긴 동작에 따라 결과가 달라집니다.
여기서 코틀린만의 편의를 하나 짚고 갑시다.
함수의 마지막 매개변수가 함수 타입이면,
그 람다를 괄호 밖으로 뺄 수 있습니다.
이것을 후행 람다(trailing lambda)라고 합니다.
// 괄호 안에 넣은 모습
calculate(3, 4, { a, b -> a + b })
// 괄호 밖으로 뺀 모습 (더 자주 쓰는 방식)
calculate(3, 4) { a, b -> a + b }
13장에서 본 list.map { it * 2 }가
바로 이 후행 람다 문법이었습니다.
함수를 반환하기
이번에는 반대로,
함수를 결과로 돌려주는 고차 함수를 봅시다.
“할인율을 정해 두면, 그 할인을 적용하는 함수를 만들어 주는 함수“를
예로 들어 보겠습니다.
fun createDiscount(rate: Int): (Int) -> Int {
return { price -> price - (price * rate / 100) }
}
반환 타입을 봅시다.(Int) -> Int, 즉 함수를 돌려줍니다.
return 뒤에 람다가 통째로 놓여 있습니다.
이제 이 함수로 “10% 할인기“와 “20% 할인기“를
각각 만들어 낼 수 있습니다.
val discount10 = createDiscount(10)
val discount20 = createDiscount(20)
println(discount10(10000)) // 9000
println(discount20(10000)) // 8000
createDiscount(10)은
“10%를 빼는 함수“를 만들어서 돌려줍니다.
그 함수를 discount10에 담아 두고
필요할 때마다 꺼내 쓰는 것입니다.
고차 함수를 쓰면
“동작을 찍어내는 틀“을 만들 수 있습니다.
14.4 Closure
외부 변수 참조
앞 절의 createDiscount를 다시 봅시다.
fun createDiscount(rate: Int): (Int) -> Int {
return { price -> price - (price * rate / 100) }
}
여기서 신기한 점이 하나 있습니다.
돌려주는 람다가 rate를 쓰고 있습니다.
그런데 rate는 람다의 매개변수가 아닙니다.
바깥 함수 createDiscount의 매개변수입니다.
createDiscount는 함수를 돌려주고 나면 끝납니다.
그런데도 돌려준 람다는
그때의 rate 값을 계속 기억합니다.
이렇게 람다가 자기 바깥의 변수를
붙잡아 기억하는 성질을
클로저(Closure)라고 합니다.
우리말로는 “감싸 안는다” 정도의 느낌입니다.
클로저는
“람다가 태어난 곳의 변수를
함께 챙겨서 데리고 다니는 것“입니다.
비유하자면 이렇습니다.
여행을 떠난 사람이
고향에서 챙긴 물건을 배낭에 넣고
어디를 가든 그대로 쓰는 것과 같습니다.
간단한 예를 하나 더 보겠습니다.
val prefix = "[알림] "
val notify = { message: String -> prefix + message }
println(notify("서버 시작")) // [알림] 서버 시작
notify 람다는
바깥 변수 prefix를 붙잡아 쓰고 있습니다.
이것이 클로저입니다.
자바에서는 이런 바깥 변수를 사실상 바꿀 수 없었지만,
코틀린은 다음에서 보듯 조금 더 자유롭습니다.
상태 변경 시 주의점
코틀린의 클로저는
바깥 변수를 읽기만 하는 것이 아니라
값을 바꿀 수도 있습니다.
var로 만든 변수라면
람다 안에서 그 값을 고칠 수 있습니다.
var count = 0
val click = { count++ }
click()
click()
click()
println(count) // 3
click 람다가 실행될 때마다
바깥의 count가 하나씩 늘어납니다.
편리해 보이지만 여기에는 함정이 있습니다.
값이 바깥에서 조용히 바뀌기 때문입니다.
특히 여러 작업이 동시에 도는 서버에서는
이런 공유 변수가 위험합니다.
두 요청이 같은
count를 동시에 건드리면
값이 꼬일 수 있습니다.
이 문제를 동시성 문제라고 부릅니다.
(자세한 내용은 훨씬 뒤에서 다룹니다.)
지금은 원칙 하나만 기억해 두면 충분합니다.
클로저 안에서는
되도록 바깥 변수를 바꾸지 말자.
2장에서 배운 “일단 val” 습관이
여기서도 그대로 통합니다.
값을 바꾸기보다
새 값을 계산해서 돌려주는 편이 훨씬 안전합니다.
// 바깥 변수를 바꾸는 방식 (주의)
var total = 0
listOf(1, 2, 3).forEach { total += it }
// 새 값을 계산해서 돌려받는 방식 (권장)
val total2 = listOf(1, 2, 3).sum()
14.5 함수 참조
::function
람다가 이미 이름이 있는 함수와
똑같은 일을 할 때가 있습니다.
예를 들어 이런 함수가 있다고 합시다.
fun isEven(n: Int): Boolean {
return n % 2 == 0
}
이 함수를 filter에 넘기고 싶습니다.
람다로 감싸면 이렇게 됩니다.
val numbers = listOf(1, 2, 3, 4, 5, 6)
val evens = numbers.filter { isEven(it) }
println(evens) // [2, 4, 6]
그런데 { isEven(it) }은
“그냥 isEven을 부르는 것“뿐입니다.
이렇게 감싸는 것이 조금 번거롭습니다.
이때 함수 참조(function reference)를 쓸 수 있습니다.
함수 이름 앞에 콜론 두 개(::)를 붙이면
그 함수 자체를 값으로 가리킬 수 있습니다.
val evens = numbers.filter(::isEven)
println(evens) // [2, 4, 6]
::isEven은
“isEven이라는 함수 그 자체“를 뜻합니다.
::는
“이 함수를 실행하지 말고,
함수 자체를 값으로 건네라“는 표시입니다.
자바에도 비슷한 문법이 있습니다.
자바의 Integer::parseInt가
코틀린의 ::function과 같은 자리입니다.
Member Reference
함수 참조는
클래스 안에 속한 함수나 속성에도 쓸 수 있습니다.
이렇게 어떤 객체에 속한 것을 가리키는 참조를
멤버 참조(member reference)라고 합니다.
간단한 데이터 클래스를 하나 두겠습니다.
(클래스와 data class는 4부에서 자세히 다룹니다.)
data class User(val name: String, val age: Int)
val users = listOf(
User("홍길동", 30),
User("이순신", 45),
User("강감찬", 28),
)
각 유저의 이름만 뽑고 싶다고 합시다.
람다로는 이렇게 씁니다.
val names = users.map { it.name }
여기서 it.name은
“유저의 name 속성을 꺼내는 일“입니다.
이 동작을 멤버 참조로 바꾸면클래스::속성 형태가 됩니다.
val names = users.map(User::name)
println(names) // [홍길동, 이순신, 강감찬]
User::name은
“User의 name을 꺼내는 동작“을 값으로 가리킵니다.
정렬 같은 곳에서 특히 자주 쓰입니다.
val sorted = users.sortedBy(User::age)
println(sorted.map(User::name)) // [강감찬, 홍길동, 이순신]
Constructor Reference
객체를 새로 만드는 생성자(constructor)도
참조로 가리킬 수 있습니다.
생성자는 “객체를 찍어내는 특별한 함수“라고
생각하면 됩니다. (4부에서 자세히 배웁니다.)
생성자 참조는 클래스::클래스이름 형태입니다.
정확히는 클래스 이름 앞에 ::를 붙입니다.
이름 목록을 받아User 객체 목록으로 바꾸는 예를 보겠습니다.
data class User(val name: String)
val names = listOf("홍길동", "이순신")
// 람다로 만드는 방식
val users1 = names.map { name -> User(name) }
// 생성자 참조로 만드는 방식
val users2 = names.map(::User)
println(users2) // [User(name=홍길동), User(name=이순신)]
::User는
“이름을 받아 User를 만드는 생성자“를
값으로 가리킨 것입니다.
정리하면 이렇습니다.
| 참조 종류 | 문법 예시 | 가리키는 대상 |
|---|---|---|
| 함수 참조 | ::isEven | 톱레벨 함수 |
| 멤버 참조 | User::name | 클래스의 속성이나 메서드 |
| 생성자 참조 | ::User | 객체를 만드는 생성자 |
세 가지 모두 뿌리는 같습니다.
“동작이나 대상을 값으로 건넨다“는 것입니다.
14.6 백엔드 로직을 함수로 분리하기
지금까지 배운 것을
실제 서버 코드에 가깝게 엮어 보겠습니다.
핵심 생각은 하나입니다.
자주 바뀌는 “정책“을 함수로 빼두면,
큰 흐름은 그대로 두고 정책만 갈아 끼울 수 있다.
할인 정책
쇼핑몰에는 여러 할인 정책이 있습니다.
회원 등급마다, 이벤트마다 할인 방식이 다릅니다.
할인 방식이 바뀔 때마다
결제 코드 전체를 고치는 것은 힘듭니다.
그래서 “할인하는 동작“을 함수 타입으로 빼둡니다.
// 원래 가격을 받아 할인된 가격을 돌려주는 함수 타입
typealias DiscountPolicy = (Int) -> Int
여기서 typealias는
길고 복잡한 타입에 별명을 붙이는 기능입니다.
이제 (Int) -> Int 대신DiscountPolicy라고 부를 수 있어 읽기 좋아집니다.
정책들을 미리 만들어 둡니다.
val noDiscount: DiscountPolicy = { price -> price }
val tenPercent: DiscountPolicy = { price -> price - price / 10 }
val flat2000: DiscountPolicy = { price -> price - 2000 }
이제 결제 함수는
어떤 정책을 쓸지 인자로 받기만 하면 됩니다.
fun checkout(price: Int, policy: DiscountPolicy): Int {
val finalPrice = policy(price)
println("결제 금액: ${finalPrice}원")
return finalPrice
}
호출하는 쪽에서 정책만 바꿔 끼웁니다.
checkout(10000, noDiscount) // 결제 금액: 10000원
checkout(10000, tenPercent) // 결제 금액: 9000원
checkout(10000, flat2000) // 결제 금액: 8000원
checkout의 큰 흐름은 그대로입니다.
바뀌는 것은 넘기는 정책뿐입니다.
검증 정책
입력값 검증도 같은 방식으로 정리할 수 있습니다.
검증이란
“이 값이 규칙에 맞는가“를 확인하는 일입니다.
“값을 받아 참/거짓을 돌려주는 함수”,
즉 (String) -> Boolean 타입으로 표현할 수 있습니다.
val isNotBlank: (String) -> Boolean = { it.isNotBlank() }
val isEmail: (String) -> Boolean = { it.contains("@") }
val isShort: (String) -> Boolean = { it.length <= 20 }
이제 여러 규칙을 한꺼번에 검사하는
고차 함수를 만들어 봅시다.
fun validate(value: String, rules: List<(String) -> Boolean>): Boolean {
return rules.all { rule -> rule(value) }
}
rules는 검증 함수들의 목록입니다.all은 13장에서 배운 컬렉션 함수로,
“모든 원소가 조건을 만족하는가“를 확인합니다.
여기서는 “모든 규칙을 통과하는가“를 묻는 셈입니다.
val email = "test@example.com"
val result = validate(email, listOf(isNotBlank, isEmail, isShort))
println(result) // true
규칙을 추가하거나 빼고 싶으면
목록만 바꾸면 됩니다.validate 함수 자체는 손대지 않습니다.
상태별 처리 전략
마지막으로 주문 상태에 따라
다른 처리를 하는 경우를 봅시다.
주문에는 여러 상태가 있습니다.
“결제됨”, “배송중”, “취소됨” 같은 것입니다.
상태마다 할 일이 다릅니다.
이럴 때 if나 when을 길게 늘어놓기 쉽습니다.
대신 “상태 이름“과 “처리 동작“을
Map으로 짝지어 두면 깔끔해집니다.
val handlers: Map<String, (String) -> Unit> = mapOf(
"PAID" to { orderId -> println("$orderId: 상품을 준비합니다") },
"SHIPPING" to { orderId -> println("$orderId: 배송을 시작합니다") },
"CANCELED" to { orderId -> println("$orderId: 환불을 진행합니다") },
)
handlers는
“상태 이름 → 그 상태에서 할 동작“을 담은 Map입니다.
값 자리에 함수가 들어 있다는 점이 핵심입니다.
이제 상태에 맞는 동작을 꺼내 실행합니다.
fun handleOrder(status: String, orderId: String) {
val handler = handlers[status]
if (handler != null) {
handler(orderId)
} else {
println("$orderId: 알 수 없는 상태입니다")
}
}
실행해 보면 이렇게 동작합니다.
handleOrder("PAID", "ORD-001")
handleOrder("SHIPPING", "ORD-002")
handleOrder("CANCELED", "ORD-003")
ORD-001: 상품을 준비합니다
ORD-002: 배송을 시작합니다
ORD-003: 환불을 진행합니다
새로운 상태가 생기면handlers에 한 줄만 추가하면 됩니다.
handleOrder의 흐름은 그대로 둔 채
동작만 늘려 가는 것입니다.
함수를 값으로 다루면
“무엇을 할지“를 데이터처럼 관리할 수 있습니다.
이것이 백엔드에서 람다와 함수 타입이
힘을 발휘하는 지점입니다.
14장을 마치며
이 장에서 우리는 다음을 배웠습니다.
- 람다는 이름 없는 함수이고,
{ 매개변수 -> 코드 }모양으로 쓴다는 점 - 매개변수가 하나면
it으로 줄여 쓸 수 있다는 점 - 함수도 값이며,
(Int) -> Int같은 함수 타입을 가진다는 점 - 함수를 인자로 받거나 돌려주는
고차 함수를 만드는 방법 - 람다가 바깥 변수를 기억하는 클로저,
그리고 상태 변경의 위험성 ::function, 멤버 참조, 생성자 참조로
함수를 값처럼 건네는 방법- 할인, 검증, 상태 처리 같은 정책을
함수로 분리해 갈아 끼우는 방법
핵심은 하나입니다.
이제 우리는 값뿐 아니라 “동작“도 넘길 수 있습니다.
이 발상은 13장의 컬렉션 함수를 다시 보게 만들고,
다음 15장의 Scope Function에서
한 번 더 확장됩니다.
동작을 값으로 다루는 감각을 익혔으니,
이제 그 동작을 더 우아하게 쓰는 방법으로 넘어가 봅시다.