Skip to main content
Summary

함수는 코드에 이름을 붙이는 방법이다

프로그램이 길어지면 같은 코드가 여러 번 나타납니다. 처음에는 복사해서 붙여 넣는 편이 빨라 보입니다. 하지만 조건이 바뀌거나 버그가 생기면 같은 코드를 여러 곳에서 고쳐야 합니다. 이때 실수가 생깁니다.

함수는 관련 있는 코드를 하나로 묶고, 그 코드에 이름을 붙이는 문법입니다.

fun sayHello() {
    println("안녕하세요")
}

sayHello()
sayHello()

sayHello라는 이름만 보면 이 함수가 인사를 출력한다는 의도를 알 수 있습니다. 함수의 장점은 단순히 코드를 줄이는 데 있지 않습니다. 더 중요한 장점은 코드의 의도를 드러내는 것입니다.

예를 들어 다음 코드는 계산식만 보고 의미를 추측해야 합니다.

val finalPrice = 10000 - (10000 * 10 / 100)

함수로 바꾸면 의미가 분명해집니다.

fun discount(price: Int, percent: Int): Int {
    return price - (price * percent / 100)
}

val finalPrice = discount(10000, 10)

이제 코드를 읽는 사람은 discount(10000, 10)을 보고 "10,000원에 10% 할인을 적용하는구나"라고 이해할 수 있습니다.

함수로 코드를 작게 나누고 재사용하기

함수 선언과 호출

코틀린에서 함수는 fun 키워드로 선언합니다.

fun 함수이름(매개변수이름: 타입): 반환타입 {
    return 반환값
}

예제를 보겠습니다.

fun add(a: Int, b: Int): Int {
    return a + b
}

val result = add(3, 5)
println(result) // 8

여기서 a와 b는 매개변수입니다. 함수를 호출할 때 전달하는 값은 인자라고 부릅니다.

add(3, 5)

위 코드에서 3과 5가 인자입니다.

매개변수는 함수 안에서 읽을 수 있지만, 기본적으로 다시 대입할 수 없습니다.

fun double(number: Int): Int {
    // number = number * 2 // 오류
    return number * 2
}

매개변수는 val처럼 다룬다고 생각하면 됩니다. 값을 바꿔야 한다면 새 변수를 만드는 편이 안전합니다.

fun double(number: Int): Int {
    val result = number * 2
    return result
}

반환 타입은 함수가 돌려주는 값의 약속이다

함수는 일을 한 뒤 값을 돌려줄 수 있습니다. 이때 반환 타입은 함수 이름 뒤에 씁니다.

fun isAdult(age: Int): Boolean {
    return age >= 19
}

이 함수는 Boolean 값을 반환합니다. 따라서 호출한 쪽에서는 결과를 조건문에 사용할 수 있습니다.

if (isAdult(20)) {
    println("입장할 수 있습니다")
}

반환 타입은 "이 함수는 어떤 값을 돌려주는가"를 알려 주는 약속입니다. 함수를 사용하는 사람은 함수 내부를 다 읽지 않아도 반환 타입을 보고 사용 방법을 알 수 있습니다.

값을 돌려주지 않는 함수와 Unit

모든 함수가 값을 돌려줘야 하는 것은 아닙니다. 화면에 출력하거나, 로그를 남기거나, 어떤 작업만 수행하는 함수도 있습니다.

fun printWelcome(name: String): Unit {
    println("${name}님, 환영합니다")
}

Unit은 "의미 있는 반환값이 없다"는 뜻입니다. 자바의 void와 비슷하다고 생각하면 됩니다.

코틀린에서는 반환 타입이 Unit이면 보통 생략합니다.

fun printWelcome(name: String) {
    println("${name}님, 환영합니다")
}

이쪽이 더 자주 쓰입니다.

표현식 본문 함수로 짧게 쓰기

함수의 내용이 하나의 식으로 끝난다면 중괄호와 return을 줄일 수 있습니다.

fun add(a: Int, b: Int): Int = a + b

이런 형태를 표현식 본문 함수라고 부릅니다. a + b라는 표현식의 결과가 그대로 함수의 반환값이 됩니다.

반환 타입도 추론할 수 있습니다.

fun add(a: Int, b: Int) = a + b

코틀린은 a와 b가 Int라는 사실을 알고 있으므로 결과도 Int라고 추론합니다.

표현식 본문 함수는 간단한 계산이나 변환에 잘 어울립니다.

fun square(number: Int) = number * number

fun isEven(number: Int) = number % 2 == 0

fun grade(score: Int) = when (score) {
    in 90..100 -> "A"
    in 80..89 -> "B"
    in 70..79 -> "C"
    else -> "D"
}

when도 값을 만드는 표현식으로 사용할 수 있습니다. 이때 가능한 경우를 모두 처리해야 합니다. 위 예제에서는 else가 그 역할을 합니다.

다만 함수 내용이 길거나 여러 단계의 처리가 필요하다면 블록 본문이 더 읽기 쉽습니다.

fun calculateFinalPrice(price: Int, percent: Int): Int {
    val discountAmount = price * percent / 100
    val finalPrice = price - discountAmount
    return finalPrice
}

짧다고 무조건 좋은 코드는 아닙니다. 읽기 쉬운 쪽을 선택하세요.

기본 인자로 자주 쓰는 값을 미리 정하기

함수를 만들다 보면 대부분의 호출에서 같은 값을 사용하는 매개변수가 있습니다. 이때는 기본 인자를 사용할 수 있습니다.

fun greet(name: String, message: String = "안녕하세요") {
    println("$message, $name")
}

greet("민수")
greet("지아", "반가워요")

첫 번째 호출에서는 message를 전달하지 않았습니다. 그러면 기본값인 "안녕하세요"가 사용됩니다.

기본 인자는 호출 코드를 짧게 만들고, 자주 쓰는 설정을 함수 선언에 모아 둡니다.

fun createUser(
    name: String,
    age: Int = 0,
    active: Boolean = true
) {
    println("name=$name, age=$age, active=$active")
}

createUser("민수")
createUser("지아", 20)
createUser("하준", 25, false)

하지만 기본 인자를 너무 많이 넣으면 함수가 하는 일이 흐려질 수 있습니다. 매개변수가 많아졌다면 함수가 여러 책임을 갖고 있지 않은지 확인해야 합니다.

이름 있는 인자로 호출 의도를 드러내기

인자가 여러 개이고 타입이 같으면 호출 코드를 읽기 어려워집니다.

fun bookTicket(name: String, from: String, to: String, count: Int) {
    println("$name: $from -> $to, $count장")
}

bookTicket("민수", "서울", "부산", 2)

이 코드는 짧지만, 인자의 의미를 하나씩 확인해야 합니다. 이름 있는 인자를 사용하면 더 명확합니다.

bookTicket(
    name = "민수",
    from = "서울",
    to = "부산",
    count = 2
)

이제 각 값이 어떤 매개변수에 들어가는지 바로 보입니다.

이름 있는 인자는 기본 인자와 함께 사용할 때 특히 유용합니다.

fun sendMessage(
    to: String,
    text: String,
    urgent: Boolean = false,
    retryCount: Int = 3
) {
    println("to=$to, text=$text, urgent=$urgent, retry=$retryCount")
}

sendMessage(
    to = "[email protected]",
    text = "가입을 환영합니다",
    urgent = true
)

retryCount는 기본값을 사용하고, urgent만 명확히 바꿨습니다.

이름 있는 인자를 사용하면 인자의 순서를 바꿀 수도 있습니다.

sendMessage(
    text = "비밀번호를 변경해 주세요",
    to = "[email protected]"
)

다만 한 호출 안에서 위치 기반 인자와 이름 있는 인자를 섞어 쓸 때는 읽기 쉬운지 확인해야 합니다. 헷갈린다면 모두 이름 있는 인자로 쓰는 편이 낫습니다.

함수는 어디에 둘까: 최상위 함수와 지역 함수

코틀린에서는 함수를 클래스 안에 넣지 않아도 됩니다. 파일에 바로 선언할 수 있습니다. 이런 함수를 최상위 함수라고 합니다.

fun calculateDiscount(price: Int, percent: Int): Int {
    return price * percent / 100
}

fun main() {
    val discount = calculateDiscount(10000, 10)
    println(discount)
}

최상위 함수는 여러 곳에서 재사용할 수 있는 기능에 잘 어울립니다. 예를 들어 가격 계산, 문자열 변환, 입력값 검증처럼 특정 객체에 묶이지 않아도 되는 기능은 최상위 함수로 둘 수 있습니다.

반대로 어떤 함수 안에서만 필요한 작은 도우미 함수라면 지역 함수로 만들 수 있습니다.

fun registerUser(name: String, age: Int) {
    fun isValidName(value: String): Boolean {
        return value.isNotBlank()
    }

    fun isValidAge(value: Int): Boolean {
        return value >= 0
    }

    if (!isValidName(name)) {
        println("이름을 입력해 주세요")
        return
    }

    if (!isValidAge(age)) {
        println("나이는 0 이상이어야 합니다")
        return
    }

    println("등록 완료: $name, $age")
}

isValidName과 isValidAge는 registerUser 안에서만 필요합니다. 밖으로 꺼내면 오히려 파일의 범위가 복잡해질 수 있습니다.

지역 함수는 바깥 함수의 매개변수와 변수를 사용할 수도 있습니다.

fun printOrderSummary(price: Int, count: Int) {
    fun totalPrice(): Int {
        return price * count
    }

    println("총액: ${totalPrice()}원")
}

지역 함수는 관련 코드를 가까이 두는 데 좋습니다. 하지만 너무 많은 지역 함수가 한 함수 안에 들어가면 바깥 함수가 커졌다는 신호일 수 있습니다. 그럴 때는 역할을 다시 나누는 편이 좋습니다.

기준은 간단합니다.

  • 여러 곳에서 쓰이면 최상위 함수로 둡니다.

  • 한 함수 안에서만 쓰이면 지역 함수로 둡니다.

  • 특정 데이터와 행동이 강하게 묶이면 나중에 배울 클래스의 메서드로 옮길 수 있습니다.

가변 인자로 여러 값을 유연하게 받기

함수에 전달할 값의 개수가 매번 달라질 수 있습니다. 예를 들어 여러 점수의 합계를 구하는 함수를 생각해 봅시다.

fun sum(a: Int, b: Int): Int {
    return a + b
}

이 함수는 두 개만 더할 수 있습니다. 세 개, 네 개를 더하려면 함수를 계속 새로 만들어야 합니다. 이때 vararg를 사용합니다.

fun sum(vararg numbers: Int): Int {
    var total = 0

    for (number in numbers) {
        total += number
    }

    return total
}

println(sum(1, 2))
println(sum(1, 2, 3, 4, 5))

vararg numbers: Int는 Int 값을 여러 개 받을 수 있다는 뜻입니다. 함수 안에서 numbers는 배열처럼 다룰 수 있습니다.

문자열에도 사용할 수 있습니다.

fun printTags(vararg tags: String) {
    for (tag in tags) {
        println("#$tag")
    }
}

printTags("kotlin", "backend", "android")

vararg는 편리하지만 아무 곳에나 쓰는 문법은 아닙니다. 값의 개수가 자연스럽게 달라지는 경우에 사용하세요. 예를 들어 태그 목록, 숫자 목록, 출력할 메시지 목록처럼 "여러 개가 올 수 있음"이 자연스러운 상황에 잘 맞습니다.

배열을 넘길 때는 스프레드 연산자를 사용한다

이미 배열이 있다면 그대로 vararg 매개변수에 넘길 수 없습니다.

fun sum(vararg numbers: Int): Int {
    var total = 0
    for (number in numbers) {
        total += number
    }
    return total
}

val values = intArrayOf(1, 2, 3)

// sum(values) // 오류
println(sum(*values))

배열 앞에 붙은 *를 스프레드 연산자라고 합니다. 배열의 원소를 펼쳐서 전달한다는 뜻입니다.

vararg 매개변수 뒤에 다른 매개변수가 오면, 그 매개변수는 이름 있는 인자로 전달하는 편이 안전합니다.

fun printAll(vararg messages: String, prefix: String) {
    for (message in messages) {
        println("$prefix$message")
    }
}

printAll("오류", "경고", prefix = "[시스템] ")

이렇게 쓰면 어디까지가 가변 인자이고 어디부터가 일반 인자인지 명확합니다.

순수 함수처럼 생각하기

함수를 잘 설계하려면 "이 함수의 입력과 출력이 무엇인가?"를 자주 물어야 합니다.

좋은 함수는 입력이 분명하고 출력이 예측 가능합니다.

fun add(a: Int, b: Int): Int {
    return a + b
}

이 함수는 같은 입력을 넣으면 언제나 같은 결과를 돌려줍니다.

add(2, 3) // 항상 5

이런 함수를 순수 함수라고 부릅니다. 순수 함수는 보통 다음 특징을 가집니다.

  • 같은 입력에는 항상 같은 출력을 돌려줍니다.

  • 함수 밖의 값을 몰래 바꾸지 않습니다.

  • 출력, 파일 저장, 네트워크 요청 같은 외부 작업을 직접 하지 않습니다.

반대로 다음 함수는 순수 함수가 아닙니다.

var total = 0

fun addToTotal(value: Int) {
    total += value
}

addToTotal은 함수 밖에 있는 total을 바꿉니다. 이런 코드는 프로그램이 작을 때는 괜찮아 보이지만, 커질수록 추적하기 어려워집니다. 어디에서 값이 바뀌었는지 찾기 힘들기 때문입니다.

순수 함수 스타일로 바꾸면 입력과 출력이 분명해집니다.

fun addToTotal(currentTotal: Int, value: Int): Int {
    return currentTotal + value
}

val total1 = addToTotal(0, 10)
val total2 = addToTotal(total1, 20)

이제 함수는 기존 합계와 더할 값을 받아 새 합계를 반환합니다. 함수 밖의 값을 직접 바꾸지 않습니다.

물론 모든 함수를 순수 함수로 만들 수는 없습니다. println으로 출력해야 할 때도 있고, 파일을 저장해야 할 때도 있으며, 서버에 요청을 보내야 할 때도 있습니다. 중요한 것은 계산과 외부 작업을 섞지 않는 습관입니다.

fun calculateTotal(price: Int, count: Int): Int {
    return price * count
}

fun printReceipt(price: Int, count: Int) {
    val total = calculateTotal(price, count)
    println("총액: ${total}원")
}

calculateTotal은 계산만 합니다. printReceipt는 출력까지 담당합니다. 이렇게 나누면 계산 로직은 테스트하기 쉽고, 출력 방식이 바뀌어도 계산 함수는 그대로 둘 수 있습니다.

함수 이름은 구현보다 의도를 말해야 한다

함수 이름은 내부 구현을 설명하기보다, 호출하는 쪽에서 알고 싶은 의도를 말해야 합니다.

fun check(age: Int): Boolean {
    return age >= 19
}

check라는 이름은 너무 넓습니다. 무엇을 확인하는지 알 수 없습니다.

fun isAdult(age: Int): Boolean {
    return age >= 19
}

isAdult는 성인 여부를 확인한다는 뜻이 분명합니다.

Boolean을 반환하는 함수는 보통 is, has, can 같은 말로 시작하면 읽기 쉽습니다.

fun isEmpty(text: String) = text.isEmpty()

fun hasPermission(role: String) = role == "admin"

fun canEnter(age: Int) = age >= 19

동작을 수행하는 함수는 동사로 시작하는 경우가 많습니다.

fun printError(message: String) {
    println("오류: $message")
}

fun calculateFee(price: Int, rate: Int): Int {
    return price * rate / 100
}

이름을 잘 지으면 주석이 줄어듭니다. 함수 이름 자체가 설명이 되기 때문입니다.

함수가 커진다는 신호

함수는 무조건 짧아야 하는 것은 아닙니다. 하지만 다음 신호가 보이면 나눌 때가 된 것입니다.

  • 함수 이름에 "그리고"가 들어갈 것 같습니다.

  • 중첩된 if나 when이 많아졌습니다.

  • 같은 코드가 두 번 이상 반복됩니다.

  • 함수 안의 일부 코드에 따로 이름을 붙이고 싶습니다.

  • 매개변수가 너무 많아 호출할 때 의미가 헷갈립니다.

예를 들어 다음 함수는 검증, 계산, 출력이 섞여 있습니다.

fun processOrder(price: Int, count: Int) {
    if (price <= 0) {
        println("가격이 올바르지 않습니다")
        return
    }

    if (count <= 0) {
        println("수량이 올바르지 않습니다")
        return
    }

    val total = price * count
    println("총액: ${total}원")
}

작은 예제라서 큰 문제는 없어 보입니다. 하지만 기능이 늘어나면 빠르게 복잡해집니다. 역할별로 나누면 읽기 쉬워집니다.

fun isValidOrder(price: Int, count: Int): Boolean {
    return price > 0 && count > 0
}

fun calculateOrderTotal(price: Int, count: Int): Int {
    return price * count
}

fun printOrderTotal(total: Int) {
    println("총액: ${total}원")
}

fun processOrder(price: Int, count: Int) {
    if (!isValidOrder(price, count)) {
        println("주문 정보가 올바르지 않습니다")
        return
    }

    val total = calculateOrderTotal(price, count)
    printOrderTotal(total)
}

함수가 나뉘면서 processOrder는 전체 흐름을 보여 주는 역할을 합니다. 세부 계산과 출력은 각각의 함수가 맡습니다.

실전 예제: 점수 목록 처리하기

지금까지 배운 내용을 하나의 예제로 묶어 보겠습니다.

목표는 여러 점수를 받아 평균을 구하고, 평균에 따라 등급을 출력하는 것입니다.

fun average(vararg scores: Int): Double {
    if (scores.isEmpty()) {
        return 0.0
    }

    var total = 0
    for (score in scores) {
        total += score
    }

    return total.toDouble() / scores.size
}

fun gradeOf(average: Double): String = when {
    average >= 90.0 -> "A"
    average >= 80.0 -> "B"
    average >= 70.0 -> "C"
    else -> "D"
}

fun printReport(name: String, vararg scores: Int, showGrade: Boolean = true) {
    val avg = average(*scores)

    println("학생: $name")
    println("평균: $avg")

    if (showGrade) {
        println("등급: ${gradeOf(avg)}")
    }
}

printReport는 학생 이름, 여러 개의 점수, 등급 표시 여부를 받습니다. 여기서 scores는 vararg이므로 점수를 원하는 만큼 나열할 수 있습니다.

fun main() {
    printReport("민수", 90, 85, 100)
}

마지막 매개변수인 showGrade를 바꾸고 싶다면 이름 있는 인자로 전달하면 됩니다.

fun main() {
    printReport("민수", 90, 85, 100, showGrade = false)
}

이미 배열에 점수가 들어 있다면 스프레드 연산자 *로 펼쳐서 전달합니다.

fun main() {
    val scores = intArrayOf(90, 85, 100)

    printReport("민수", *scores, showGrade = true)
}

이 예제에서 중요한 점은 문법 하나하나보다 함수의 역할입니다.

  • average는 평균 계산만 합니다.

  • gradeOf는 평균을 등급으로 바꿉니다.

  • printReport는 출력 흐름을 담당합니다.

역할을 나누면 각 함수는 작고 분명해집니다. 문제가 생겨도 어디를 봐야 할지 쉽게 찾을 수 있습니다.

정리

함수는 코드를 재사용하기 위한 문법이면서, 프로그램을 이해하기 쉬운 단위로 나누는 도구입니다.

이번 장에서 배운 핵심은 다음과 같습니다.

  • 함수는 fun으로 선언하고, 매개변수와 반환 타입을 가질 수 있습니다.

  • 반환값이 없으면 Unit을 사용하며, 보통은 생략합니다.

  • 짧은 함수는 표현식 본문으로 간결하게 쓸 수 있습니다.

  • 기본 인자는 자주 쓰는 값을 함수 선언에 담아 호출을 단순하게 만듭니다.

  • 이름 있는 인자는 호출 코드의 의미를 분명하게 합니다.

  • 최상위 함수는 여러 곳에서 쓰는 기능에, 지역 함수는 한 함수 안에서만 필요한 도우미 기능에 어울립니다.

  • vararg는 개수가 달라지는 인자를 받을 때 사용하고, 배열을 넘길 때는 *를 붙입니다.

  • 순수 함수처럼 입력과 출력을 분명히 하면 코드를 테스트하고 고치기 쉬워집니다.

좋은 함수는 짧은 함수가 아니라, 책임이 분명한 함수입니다. 함수를 만들 때는 먼저 물어보세요.

"이 함수는 무엇을 입력받고, 무엇을 돌려주며, 어떤 책임을 맡는가?"

이 질문에 답할 수 있다면 코드는 자연스럽게 읽기 쉬워집니다.

책 목록

처음 배우는 코틀린: 문법에서 실전 프로젝트까지

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기