코틀린 클린코드(3) – 짧고 읽기 쉬운 함수 만드는 방법

코틀린 함수 설계 3편 짧고 읽기 쉬운 함수 만드는 방법
코틀린 함수 설계 3편 짧고 읽기 쉬운 함수 만드는 방법

1편에서는 코틀린 클린코드의 기준을 먼저 잡았습니다. 2편에서는 이름 짓기를 다뤘습니다. 이번 3편에서는 그다음 단계인 함수 설계를 이야기해보겠습니다.

시리즈 전체 흐름이 궁금하시다면 코틀린 클린코드 허브 페이지를 참고해주세요. 앞선 글을 아직 읽지 않으셨다면 1편2편도 함께 보시면 좋습니다.

실무에서 코드를 읽기 어렵게 만드는 원인은 생각보다 단순합니다. 이름이 모호하거나, 함수가 너무 많은 일을 하거나, 매개변수가 많거나, 흐름이 깊게 중첩되어 있기 때문입니다.

특히 코틀린은 문법이 짧습니다. 그래서 함수 설계를 대충 하면 문제도 더 압축된 형태로 숨어버립니다.

좋은 함수는 짧아서 좋은 것이 아닙니다. 한 번에 읽히고, 수정 방향이 예측되기 때문에 좋은 것입니다.

이번 글에서는 실무에서 바로 적용할 수 있는 기준만 골라 정리해보겠습니다. 설명은 최대한 짧게 끊고, 예제는 충분히 넣었습니다.

다룰 주제는 분명합니다. 한 함수 한 책임, 매개변수 설계, default parameter, named argument, early return, expression body를 먼저 보겠습니다.

그리고 forEach를 남용할 때 생기는 문제도 함께 정리해보겠습니다.

왜 함수 설계가 중요할까요?

함수는 코드를 읽는 단위입니다. 클래스를 보더라도 결국 안으로 들어가면 함수를 읽게 됩니다. 그래서 함수가 불친절하면, 클래스 설계가 괜찮아도 코드 읽기가 힘들어집니다.

좋은 함수는 두 가지를 빠르게 보여줍니다. 무엇을 하는지, 그리고 어디까지 책임지는지입니다. 이 둘이 보이면 수정도 쉬워집니다. 어디를 고쳐야 하는지 알 수 있기 때문입니다.

반대로 함수 하나에 검증, 변환, 저장, 로깅, 외부 호출이 다 섞여 있으면 읽는 사람은 매번 본문을 끝까지 내려가야 합니다. 함수 이름만으로는 감이 오지 않습니다.

작은 수정도 부작용을 걱정하게 됩니다. 실무에서 피곤한 함수는 대개 이런 형태입니다.


fun registerOrder(request: OrderRequest): OrderResponse {
    if (request.items.isEmpty()) {
        throw IllegalArgumentException("주문 상품은 비어 있을 수 없습니다.")
    }

    if (request.userId <= 0) {
        throw IllegalArgumentException("사용자 정보가 올바르지 않습니다.")
    }

    val entity = OrderEntity(
        userId = request.userId,
        items = request.items,
        createdAt = LocalDateTime.now(),
    )

    val savedOrder = orderRepository.save(entity)
    auditLogger.write("order created: ${savedOrder.id}")
    eventPublisher.publish(OrderCreatedEvent(savedOrder.id))

    return OrderResponse(
        id = savedOrder.id,
        itemCount = savedOrder.items.size,
        createdAt = savedOrder.createdAt,
    )
}

이 함수는 얼핏 보면 짧아 보입니다. 하지만 안을 들여다보면 검증, 엔티티 생성, 저장, 감사 로그, 이벤트 발행, 응답 변환까지 모두 들어 있습니다.

수정 이유도 여러 개입니다. 그래서 이런 함수는 길이보다 책임이 많다는 점이 더 큰 문제입니다.

한 함수에는 한 단계의 일만 넣으세요

클린코드에서 자주 나오는 원칙입니다. 하지만 실무에서는 “한 가지 일”이라는 표현이 조금 추상적으로 느껴질 수 있습니다.

저는 이 기준을 이렇게 이해하는 편입니다. 한 함수는 한 단계의 사고만 요구해야 한다. 이 해석이 실무에서는 더 바로 와닿습니다.

예를 들어 주문 생성 함수라면, 바깥에서 보면 “주문을 생성한다”만 보여야 합니다. 세부 검증이나 엔티티 조립이 필요하더라도, 그 디테일까지 한 화면에 다 펼쳐놓을 필요는 없습니다.

필요한 부분은 작은 함수로 숨기면 됩니다.


fun registerOrder(request: OrderRequest): OrderResponse {
    validateOrderRequest(request)

    val order = createOrderEntity(request)
    val savedOrder = orderRepository.save(order)

    publishOrderCreatedEvent(savedOrder)
    return savedOrder.toResponse()
}

private fun validateOrderRequest(request: OrderRequest) {
    require(request.items.isNotEmpty()) { "주문 상품은 비어 있을 수 없습니다." }
    require(request.userId > 0) { "사용자 정보가 올바르지 않습니다." }
}

private fun createOrderEntity(request: OrderRequest): OrderEntity {
    return OrderEntity(
        userId = request.userId,
        items = request.items,
        createdAt = LocalDateTime.now(),
    )
}

private fun publishOrderCreatedEvent(order: OrderEntity) {
    auditLogger.write("order created: ${order.id}")
    eventPublisher.publish(OrderCreatedEvent(order.id))
}

private fun OrderEntity.toResponse(): OrderResponse {
    return OrderResponse(
        id = id,
        itemCount = items.size,
        createdAt = createdAt,
    )
}

이렇게 바꾸면 흐름이 먼저 보입니다. 세부 구현은 필요할 때만 내려가서 보면 됩니다. 코드 리뷰도 쉬워집니다.

“주문 생성 흐름”을 볼 때와 “검증 로직”을 볼 때의 초점이 분리되기 때문입니다.

여기서 중요한 점이 하나 있습니다. 무조건 함수를 잘게 나누는 것이 목표는 아닙니다.

읽는 흐름이 더 선명해질 때만 나누셔야 합니다. 의미 없는 한 줄짜리 래퍼 함수를 늘리면 탐색 비용만 높아질 수 있습니다.

함수 길이보다 더 중요한 질문

함수 길이를 기계적으로 정할 필요는 없습니다. 대신 아래 질문을 해보시면 좋습니다.

  • 함수 이름만 읽고도 본문이 대체로 예상되는가
  • 이 함수를 수정해야 하는 이유가 하나에 가까운가
  • 중간에 “아, 이것도 여기 있네”라는 놀람이 반복되지 않는가
  • 다른 사람이 코드 리뷰할 때 한 번에 이해할 수 있는가

이 질문에 자신 있게 답하기 어렵다면, 보통은 함수를 나눌 시점입니다.

매개변수는 적게, 의미는 분명하게 가져가세요

함수 설계가 어려워지는 가장 빠른 길은 매개변수를 계속 늘리는 것입니다. 인자가 많아지면 호출부가 읽기 힘들어집니다. 순서도 외워야 합니다.

특히 같은 타입이 반복되거나, Boolean 값이 섞이면 더 위험합니다.

코틀린은 이 문제를 줄일 수 있는 문법을 이미 많이 제공합니다. 대표적인 것이 default parameternamed argument입니다.

1. 오버로드를 늘리기보다 default parameter를 먼저 생각하세요

코틀린 함수 문서는 기본값이 있는 매개변수를 통해 여러 오버로드를 줄일 수 있다고 설명합니다. 실무에서도 이 방식이 훨씬 읽기 좋습니다. 호출부도 단순해집니다.


class NotificationService {
    fun send(message: String) {
        send(message, retryCount = 3, urgent = false)
    }

    fun send(message: String, retryCount: Int) {
        send(message, retryCount = retryCount, urgent = false)
    }

    fun send(message: String, retryCount: Int, urgent: Boolean) {
        // ...
    }
}

class NotificationService {
    fun send(
        message: String,
        retryCount: Int = 3,
        urgent: Boolean = false,
    ) {
        // ...
    }
}

두 번째 쪽이 훨씬 단순합니다. 기본 동작도 바로 보입니다. 함수 개수도 줄어듭니다. 유지보수할 때도 시그니처를 한 곳만 보면 됩니다.

2. Boolean과 null이 섞인 호출에는 named argument를 쓰세요

코틀린 코딩 컨벤션은 Boolean 타입이나 같은 원시 타입이 여러 개 들어가는 경우 named argument를 권장합니다. 이유는 단순합니다.

값만 보고는 의미를 알기 어렵기 때문입니다.


notificationService.send("결제 완료", 5, true)

이 호출은 문법적으로는 맞습니다. 하지만 읽는 사람은 잠깐 멈춥니다. 5가 무엇인지, true가 무엇인지 바로 알기 어렵기 때문입니다.


notificationService.send(
    message = "결제 완료",
    retryCount = 5,
    urgent = true,
)

같은 코드라도 이렇게 쓰면 의미가 바로 보입니다. 특히 시간이 지난 뒤 다시 볼 때 차이가 큽니다. 코드 리뷰에서도 질문이 줄어듭니다.

3. Boolean 플래그가 늘어나면 함수 책임을 의심하세요

includeDeleted, useCache, sendEmail, isTestMode 같은 값이 계속 늘어나는 함수는 경고 신호입니다.

이 함수가 여러 상황을 한 번에 처리하고 있을 가능성이 큽니다.

이럴 때는 플래그를 더 추가하기보다, 함수를 나누거나 역할을 분리하는 편이 낫습니다.


fun loadUsers(includeDeleted: Boolean, useCache: Boolean): List<User> {
    // ...
}

fun loadActiveUsers(): List<User> {
    // ...
}

fun loadAllUsers(): List<User> {
    // ...
}

fun loadUsersFromCache(): List<User> {
    // ...
}

항상 이런 식으로 나눌 수 있는 것은 아닙니다. 그래도 플래그가 늘어날수록 “이 함수가 지금 너무 많은 경우를 끌어안고 있지 않은가”를 점검하셔야 합니다.

4. 매개변수가 너무 많다면 파라미터 묶음을 고려하세요

여러 값이 항상 같이 다닌다면, 그 자체가 하나의 개념일 수 있습니다. 이때는 억지로 인자만 나열하지 말고, 작은 객체로 묶는 편이 좋습니다.

코틀린에서는 data class를 이런 용도로 깔끔하게 쓸 수 있습니다.


fun createDelivery(
    receiverName: String,
    phoneNumber: String,
    zipCode: String,
    address1: String,
    address2: String,
    memo: String?,
) {
    // ...
}

data class DeliveryAddress(
    val receiverName: String,
    val phoneNumber: String,
    val zipCode: String,
    val address1: String,
    val address2: String,
)

fun createDelivery(address: DeliveryAddress, memo: String?) {
    // ...
}

이 방식의 장점은 분명합니다. 호출부가 짧아집니다. 개념도 또렷해집니다. 테스트 데이터도 만들기 쉬워집니다.

깊은 중첩보다 early return이 더 읽기 쉽습니다

함수가 길어지는 이유 중 하나는 중첩입니다. if 안에 if가 들어가고, 그 안에 다시 if가 들어가면 눈이 금방 피로해집니다. 이런 구조는 핵심 로직을 아래로 계속 밀어냅니다.

이럴 때는 조건을 앞에서 걸러내는 early return이 효과적입니다. 흔히 guard clause라고 부르는 방식입니다.


fun approveOrder(order: Order?): ApprovalResult {
    if (order != null) {
        if (order.status == OrderStatus.PAID) {
            if (order.items.isNotEmpty()) {
                return ApprovalResult.Approved(order.id)
            }
        }
    }

    return ApprovalResult.Rejected
}

fun approveOrder(order: Order?): ApprovalResult {
    if (order == null) return ApprovalResult.Rejected
    if (order.status != OrderStatus.PAID) return ApprovalResult.Rejected
    if (order.items.isEmpty()) return ApprovalResult.Rejected

    return ApprovalResult.Approved(order.id)
}

두 번째가 더 읽기 쉽습니다. 실패 조건이 먼저 정리됩니다. 그래서 마지막에 남는 핵심 로직이 선명해집니다.

코틀린 코딩 컨벤션은 if, when, try의 표현식 형태를 선호합니다. 이런 스타일은 반환 흐름을 더 짧고 명확하게 만드는 데 도움이 됩니다.

다만 조건이 너무 길다면 무리해서 한 줄로 만들 필요는 없습니다. 핵심은 줄 수가 아니라 이해 속도입니다.

return이 여러 번 나오면 나쁜 코드일까요?

꼭 그렇지는 않습니다. 작은 함수에서 early return은 오히려 가독성을 높일 때가 많습니다. 문제는 반환 지점의 개수가 아니라, 흐름이 예측 가능한가입니다.

다만 반환이 너무 많아서 분기가 복잡해진다면 다시 생각해보셔야 합니다. 그때는 보통 함수 책임이 크거나 조건이 섞인 경우가 많습니다.

expression body는 짧을 때만 쓰세요

코틀린 코딩 컨벤션은 본문이 하나의 표현식으로 끝나는 함수에 expression body를 권장합니다. 이런 함수는 확실히 읽기 좋습니다. 의도도 바로 보입니다.


fun isAdult(age: Int): Boolean = age >= 20

fun Order.totalPrice(): Int = items.sumOf { it.price }

이 정도는 expression body가 잘 어울립니다. 하지만 모든 함수를 억지로 이렇게 바꿀 필요는 없습니다.

조건이 많거나, 중간 이름이 필요하거나, 줄바꿈이 과해지면 block body가 더 낫습니다.


fun findDisplayName(user: User): String =
    user.nickname
        ?: user.email
        ?: user.phoneNumber
        ?: "이름 없음"

위 코드는 아직 읽을 만합니다. 하지만 여기서 검증, 로깅, 예외 처리까지 붙기 시작하면 expression body가 더 이상 장점이 아니게 됩니다.


fun findDisplayName(user: User): String {
    val nickname = user.nickname
    if (nickname != null) return nickname

    val email = user.email
    if (email != null) return email

    val phoneNumber = user.phoneNumber
    if (phoneNumber != null) return phoneNumber

    return "이름 없음"
}

둘 중 무엇이 더 좋은지는 문맥에 따라 다릅니다. 저는 기준을 단순하게 잡는 편입니다. 한눈에 읽히면 expression body, 한 번 더 해석해야 하면 block body입니다.

또 한 가지 기억하실 점이 있습니다. 코틀린 함수 문서는 block body 함수에서는 반환 타입을 명시해야 한다고 설명합니다.

반대로 single-expression function은 반환 타입 추론이 가능합니다.

실무에서는 추론이 가능하더라도, 공개 API이거나 타입이 헷갈릴 수 있다면 명시해두는 편이 안전합니다.

람다와 체이닝으로 제어 흐름을 숨기지 마세요

코틀린을 쓰다 보면 map, filter, forEach, scope function을 많이 쓰게 됩니다. 이런 문법 자체는 좋습니다.

다만 제어 흐름이 중요한 코드에서는 남용하지 않는 편이 좋습니다.

특히 forEach는 반복문처럼 보여도 함수입니다. 그래서 continue, break, return을 다루는 방식이 일반 for문과 다릅니다.

Kotlin 코딩 컨벤션도 보통은 forEach보다 일반 for문을 선호하라고 안내합니다. 예외는 nullable receiver이거나, 긴 체인의 일부일 때 정도입니다.


fun printPaidOrders(orders: List<Order>) {
    orders.forEach { order ->
        if (order.status != OrderStatus.PAID) return@forEach
        println(order.id)
    }
}

이 코드는 틀린 코드는 아닙니다. 하지만 읽는 사람 입장에서는 return@forEach를 한 번 해석해야 합니다. 익숙하지 않은 팀이라면 더 그렇습니다.


fun printPaidOrders(orders: List<Order>) {
    for (order in orders) {
        if (order.status != OrderStatus.PAID) continue
        println(order.id)
    }
}

두 번째가 더 직관적입니다. 제어 흐름이 눈에 바로 들어옵니다. 반복을 돌다가 건너뛰는 코드라면 이런 형태가 더 안전할 때가 많습니다.

여기서 핵심은 함수형 스타일을 버리자는 뜻이 아닙니다. 데이터 변환에는 체이닝이 잘 맞습니다. 하지만 분기와 제어 흐름이 중심인 코드라면, 더 단순한 문법이 읽기 좋을 때가 많습니다.

람다 안의 labeled return도 줄이는 편이 좋습니다

코틀린 코딩 컨벤션은 람다에서 labeled return을 여러 번 쓰는 것을 피하라고 안내합니다. 흐름이 복잡해지기 쉽기 때문입니다.

labeled return이 계속 늘어난다면, 람다를 anonymous function으로 바꾸거나 아예 바깥 함수로 분리하는 편이 낫습니다.


fun parseNumbers(tokens: List<String>): List<Int> {
    val numbers = mutableListOf<Int>()

    tokens.forEach { token ->
        if (token.isBlank()) return@forEach
        val number = token.toIntOrNull() ?: return@forEach
        numbers += number
    }

    return numbers
}

fun parseNumbers(tokens: List<String>): List<Int> {
    val numbers = mutableListOf<Int>()

    for (token in tokens) {
        if (token.isBlank()) continue

        val number = token.toIntOrNull()
        if (number == null) continue

        numbers += number
    }

    return numbers
}

이 차이는 사소해 보일 수 있습니다. 하지만 실제 프로젝트에서는 이런 작은 선택이 코드베이스 전체의 피로도를 바꿉니다. 읽는 사람이 한 번 덜 멈추게 만드는 쪽을 고르시는 것이 좋습니다.

작은 private 함수와 local function을 적절히 쓰세요

보조 로직이 한 함수 안에서만 쓰인다면 local function도 좋은 선택입니다. 코틀린 함수 문서도 local function을 지원한다고 설명합니다.

구현 디테일을 바깥으로 노출하지 않을 수 있다는 점이 장점입니다.

다만 local function도 남용하면 안 됩니다. 바깥 함수를 이해하기 어렵게 만들 정도로 많아지면, 그냥 private 함수로 빼는 편이 낫습니다.


fun registerUser(command: RegisterUserCommand): User {
    fun validate(command: RegisterUserCommand) {
        require(command.email.isNotBlank()) { "이메일은 비어 있을 수 없습니다." }
        require(command.password.length >= 8) { "비밀번호는 8자 이상이어야 합니다." }
    }

    validate(command)

    val user = User(
        email = command.email,
        password = passwordEncoder.encode(command.password),
    )

    return userRepository.save(user)
}

이 예제에서는 validate()가 외부에서 재사용될 필요가 없습니다. 이런 경우 local function은 꽤 자연스럽습니다.

반대로 여러 곳에서 공통으로 쓰이는 검증이라면 private 함수나 별도 객체로 옮기는 편이 더 좋습니다.

실무에서 자주 하는 함수 설계 실수

1. 이름은 단순한데 본문이 복잡한 경우

process(), handle(), execute() 같은 이름 아래에 로직이 길게 깔려 있으면 읽는 사람이 불안해집니다. 이름과 본문의 추상화 수준을 맞추셔야 합니다.

2. 매개변수는 많지만 호출부는 더 짧다고 착각하는 경우

함수 하나로 다 처리하면 편해 보일 수 있습니다. 하지만 호출부가 짧아진 대가로 의미가 사라지면, 결국 읽는 시간이 더 늘어납니다.

3. 한 줄 함수만 추구하는 경우

코틀린은 짧게 쓸 수 있습니다. 그렇다고 항상 짧게 쓰는 것이 좋은 것은 아닙니다. 짧음보다 중요한 것은 맥락이 보이는지입니다.

4. forEach, let, run으로 모든 흐름을 처리하는 경우

문법이 세련돼 보여도, 팀 전체가 빨리 읽지 못하면 좋은 설계가 아닙니다. 특히 제어 흐름이 섞이면 더 조심하셔야 합니다.

5. 작은 함수로 쪼갰지만 이름이 모두 추상적인 경우

함수 분리는 수단입니다. doProcess(), handleData(), runTask()처럼 이름이 모호하면, 나눈 효과가 거의 없습니다.

함수 설계 체크리스트

  • 함수 이름만 읽어도 무엇을 하는지 대체로 예상되는지 확인합니다.
  • 한 함수 안에 검증, 변환, 저장, 외부 호출이 한꺼번에 섞여 있지 않은지 봅니다.
  • 매개변수 개수가 많다면 default parameter, named argument, 작은 객체 묶음을 먼저 검토합니다.
  • Boolean 플래그가 늘어나고 있다면 함수 책임이 커진 것은 아닌지 의심합니다.
  • 실패 조건이 앞에서 걸러질 수 있다면 early return으로 흐름을 평평하게 만듭니다.
  • expression body가 정말 더 읽기 쉬운지, 아니면 단지 줄 수만 줄인 것인지 점검합니다.
  • 반복문에서 return@forEach, 여러 labeled return이 늘어나고 있다면 일반 for문이 더 나은지 확인합니다.
  • 보조 로직이 한 곳에서만 쓰인다면 local function이나 private 함수로 숨길 수 있는지 봅니다.

마무리

좋은 함수는 마법처럼 만들지 않습니다. 책임을 줄이고, 인자를 정리하고, 흐름을 평평하게 만들면 됩니다.

코틀린은 이를 돕는 문법을 이미 많이 갖고 있습니다.

default parameter, named argument, expression body, local function은 모두 그런 도구입니다.

하지만 도구가 있다고 해서 무조건 써야 하는 것은 아닙니다. 기준은 늘 같습니다. 더 짧은 코드가 아니라, 더 빨리 읽히는 코드를 선택하셔야 합니다.

다음 글에서는 코틀린 null safety를 다뤄보겠습니다.

!!를 피하는 기준, nullable을 경계에서 정리하는 방법, 실무에서 안전한 null 처리 패턴까지 이어서 정리해드리겠습니다.

함께보면 좋은 글