얼마전 PR리뷰 받을 때, 코드에서 `let` 보다는 `run`이 어울린다는 리뷰를 받은 적이 있습니다.
제가 이것을 크게 구별하지 않고 감(...)으로 사용하고 있었다는 것을 그제야 알아챘죠.
이번 기회에 kotlin 내장 확장함수를 어떻게 쓰면 좋고 어떻게 동작하는가 짚어보려고합니다.
코틀린을 쓰다 보면 함수형 프로그래밍을 자연스럽게 접하게 됩니다.
그중에서도 이 함수들은 특히나 유용해서 코드를 간결하게 만들어주고, 생산성을 엄청 높여주기 때문에 잘 알아두면 좋을 것 같아요.
Scope function 이란?
Kotlin 표준 라이브러리에 포함된 함수들입니다.
이 함수를 호출하면 임시 코드 범위가 형성되며 이 범위에서는 익명 객체 액세스할 수 있습니다.
공통적으로 참조한 객체의 원본은 건드리지 않고, 새로운 객체를 반환하기 때문에 더 유용하기도 합니다.
범위 함수는 거의 비슷비슷해보여서 저처럼 혼용해서 사용하기 쉬울 수 있습니다.
아래 가볍게 사용했던 사례와 헷갈리는 것들을 정리한 내용을 소개합니다.
물론 팀 내의 약속과 프로젝트 일관성을 맞추는 것도 중요합니다만, 아래 사례들로 함수 선택에 참고가 되기를 바랍니다.
1. filter
`filter`는 컬렉션 안에서 조건에 맞는 데이터만 쏙쏙 골라주는 함수입니다.
컬렉션 내부에서 조건에 맞는 요소들만 남기고 새로운 컬렉션으로 반환합니다.
내부적으로는 리스트나 집합을 순회하면서 조건을 확인한 뒤 새로운 리스트를 만들어줍니다.
반대로 조건에 맞지 않는 함수만 반환하는 `filterNot` 도 매우 유용합니다.
val numbers = listOf(1, 2, 3, 4, 5, 6)
val evenNumbers = numbers.filter { it % 2 == 0 } // [2, 4, 6]
필터링 작업은 데이터 처리할 때 정말 자주 쓰이는데, 불필요한 데이터를 깔끔하게 걸러낼 수 있어서 좋습니다.
그리고 순서도 원래 리스트와 같아서 정렬 걱정도 덜 수 있어요.
2. map
map은 컬렉션 안의 요소를 변형해 새로운 값으로 만든 후 새로운 컬렉션을 반환하는 함수입니다.
컬렉션의 각 요소를 순회하면서 변환 후 새로운 리스트를 반환합니다.
val numbers = listOf(1, 2, 3, 4, 5)
val squaredNumbers = numbers.map { it * it } // [1, 4, 9, 16, 25] 제곱수로 반환
map이랑 많이 쓰이는 것이 `mapNotNull` 인데요.
map과 동일하게 동작하지만 null 인 요소는 더하지 않는다는 차이점이 있습니다.
아래는 보통 자주 사용하는 용례입니다.
val userIds = listOf(1, 2, 3, 4, 5)
val users = userIds.mapNotNull { userService.getOrNull(it) } // 회원 정보 반환시 `null` 인 경우는 컬렉션 요소에서 제거함
3. let
단일 객체를 변형할 때 주로 사용합니다.
val yearMonth = "2024-10"
yearMonth.let {
plusMonth(1) // 코드블록 작성할 때 사용합니다.
}
//
yearMonth.plusMonth(1) // 단일 함수를 연쇄할때는 let을 쓰지 않습니다.
그런데 위 예시에서도 얘기하듯 굳이 `let` 을 사용하지 않아도 되는 경우가 많은데요. `let`을 많이 사용하는 부분은 따로 있습니다.
`let` 함수는 Nullable 타입 처리에 매우 유용합니다.
우리가 흔히 널 체크하면서 귀찮을 때 있잖아요? 그럴 때 let
을 쓰면 깔끔하게 null safe하게 코드를 작성할 수 있습니다.
객체가 null이 아닐 때만 실행되는 구조라서 실수할 일도 적고요.
val name: String? = "Alice"
name?.let { // null 일 경우에는 해당 블록은 동작하지 않습니다.
println("Name is not null: $it")
}
위 예시는 `name` 이 null이 아닐 때만 "Name is not null: Alice"라는 문장을 출력하는 방식이에요. 예전처럼 자바로 일일이 if (name != null)
쓰지 않아도 됩니다.
4. apply
apply는 객체의 여러 속성이나 함수를 호출하면서, 그 객체를 다시 반환하는 함수입니다.
주로 객체를 초기화하거나 설정할 때 자주 사용됩니다.
apply의 블록 안에서 this를 사용해 객체에 직접 접근하고, 마지막에 객체 자신을 반환하죠. 그래서 apply는 주로 객체 설정과 관련된 코드에서 자주 등장합니다.
val adam = Person("Adam").apply {
age = 32
city = "London"
}
apply는 여러 속성을 한 번에 설정해야 할 때 코드를 간결하게 만들어줘요. 코드를 읽을 때도, "이 객체에 대해 이런 설정을 적용한다"라고 쉽게 이해할 수 있습니다.
5. also
`also`는 `apply`와 비슷하지만 약간 다른 용도로 사용됩니다.
주로 객체를 그대로 반환하면서, 그 객체를 인자로 받아서 부가적인 작업을 할 때 사용됩니다.
객체 자체가 아니라, 그 객체에 대해 무언가를 하고 싶을 때 쓰입니다. 원본 객체는 그대로 두고요.
그래서 주로 객체 생성 후 로깅, 검증, 부가적인 작업이 필요할 때 사용해요.
val user = User("Charlie").also {
logger.info("User created: $it")
// 추가 작업 가능
}
6. run
run은 앞의 함수들과는 다르게 객체 참조 없이 실행할 수 있습니다.
val result = run {
val x = 10
val y = 20
x + y // 30 반환
}
이 코드는 run 블록 내에서 두 값을 더한 결과를 반환하는 방식이에요. 간단한 계산이나 로직을 처리할 때 run을 사용하면 코드가 더욱 깔끔해집니다.
근데 개인적으로 이보다 더 자주 사용하는 것은 `runCatching` 입니다.
7. runCatching
runCatching은 예외 처리를 위한 함수로, 코드 블록에서 발생할 수 있는 예외를 안전하게 처리할 수 있게 해줍니다.
내부적으로 블록을 실행한 후, 예외가 발생하면 Result.Failure를, 성공하면 Result.Success를 반환합니다.
이 함수를 사용하면 try-catch를 일일이 작성하지 않아도 됩니다.
내부 블록에서 예외가 발생한 것이 이를 호출한 코드의 진행에 영향을 미쳐 종료되는 일을 방지하고 싶을 때 사용합니다.
val result = runCatching {
val numerator = 10
val denominator = 0
numerator / denominator // 예외 발생
}.getOrElse {
println("An error occurred: $it") // 예외 메시지 출력
0 // 기본값 반환
}
이 코드에서 runCatching은 예외를 감싸서 안전하게 처리하고, 예외가 발생했을 때 기본값을 반환하는 구조를 갖고 있어요.
예외 처리를 더 깔끔하게 할 수 있는 방법입니다.
마무리하며
코틀린의 위 함수들은 코드를 간결하게 만들고, 가독성을 높여주는 역할을 톡톡히 해줍니다. 특히 함수형 프로그래밍의 개념을 활용하여 다양한 작업을 효율적으로 처리할 수 있어요. 각각의 함수는 특정 목적을 위해 최적화되어 있으니, 상황에 맞게 잘 활용하면 좋겠죠?
처음엔 생소할 수 있지만, 손에 익으면 코딩이 훨씬 편해진답니다.
사실 여기 있는 함수들보다 더 다양한 쓰임새에서 사용할 필요할 때마다 찾아보고 사용해보고 익숙해지면 더 좋을 것 같습니다.
👋
참고자료