[Kotlin in Action] 03. 함수 정의와 호출

주영진·2025년 7월 27일

Kotlin 스터디

목록 보기
3/4

1. Collection 만들기

2장에서 setOf 함수를 통해 집합을 생성했었다. 이 함수는 원소의 순서는 중요하지 않은 컬렉션을 만드는 함수이다. setOf 사용 예는 아래와 같다.

val set = setOf(1, 7, 53)

val list = listOf(1, 7, 53)
val map =  listOf(1 to "one", 7 to "seven", 53 to "fifty-three")

여기서 set, list, map이라는 컬렉션이 만들어지고, 각각의 컬렉션은 다 객체이다. 코틀린에서는 기본 값을 제외한 모든 값을 객체라고 보면 된다.

코틀린은 표준 자바 컬렉션 클래스를 사용한다. 즉, 자바와 코틀린 컬렉션을 서로 변환할 필요 없다. 코틀린 컬렉션은 자바 컬렉션과 같은 클래스이긴 하지만 코틀린에서는 자바보다 더 많은 기능을 쓸 수 있다.

fun main() {
    val strings = listOf("first", "second", "fourteenth")
    
    println(strings.last()) //fourteenth //마지막 원소 출력
    
    println(strings.shuffled()) //[fourteenth, second, first] //뒤섞기
    
    val numbers = setOf(1, 14, 2)
    println(numbers.sum()) //17 // 합계 계산
}

2. 함수 호출

자바 컬렉션에는 디폴트 toString 구현이 들어있고, 고정돼 있어 list 출력 시 자동으로 toString이 적용되어 출력된다. 하지만 디폴트 구현과 달리 원소 사이가 세미 콜론으로 되어 있는, (1; 2; 3)처럼 출력시키고 싶다면 어떻게 해야 할까.

코틀린에는 이런 요구사항을 처리할 수 있는 함수가 표준 라이브러리에 이미 들어있다. 다음은 원소 사이에 구분자(separator)를 추가하고, StringBuilder의 맨 앞과 맨 뒤에 접두사와 접미사를 추가하는 제네릭 함수이다.

fun <T> joinToString(
        collection: Collection<T>,
        separator: String,
        prefix: String,
        postfix: String
): String {

    val result = StringBuilder(prefix)

    for ((index, element) in collection.withIndex()) {
        if (index > 0) result.append(separator)
        result.append(element)
    }

    result.append(postfix)
    return result.toString()
}

fun main() {
    val list = listOf(1, 2, 3)
    println(joinToString(list, "; ", "(", ")")) //함수 호출 부분
}

책은 위의 코드에서, 함수 호출하는 구문을 더욱 간결하게 만들 수 없을지에 대한 고민을 제시한다.

2.1. 이름 붙인 인자

코틀린으로 작성한 함수를 호출할 때는 함수에 전달하는 인자 중 일부의 이름을 명시할 수 있으며, 인자의 순서를 변경할 수도 있다.

joinToString(collection, seperator="", prefix="", postfix=".")

2.2 디폴트 파라미터 값

자바에서는 일부 클래스에서 오버로딩한 메시지가 너무 많아지다는 문제가 자주 발생한다. 오버로딩 메서드들은 하위 호환성 유지 및 API 사용자에게 편의를 더한다는 다양한 이유로 만들어지지만, 무슨 이유든 간에 중복이라는 치명적인 단점을 제공한다.

코틀린에서는 바로 위에서 명시한 바와 같이 파라미터의 기본 값을 지정할 수 있어 이런 오버로드 중 상당수를 피할 수 있다.

fun <T> joinToString(
    collection: Collection<T>,
    separator: String = ", ", //기본값이 지정된 파라미터들
    prefix: String = "",
    postfix: String = ""
): String {

    val result = StringBuilder(prefix)

    for ((index, element) in collection.withIndex()) {
        if (index > 0) result.append(separator)
        result.append(element)
    }

    result.append(postfix)
    return result.toString()
}

val list = listOf(1, 2, 3)

fun main() {
    joinToString(list, ", ", "", "") //1, 2, 3
    joinToString(list, ", ", "", "") // 1, 2, 3
    joinToString(list, "; ") //1; 2; 3
}

이와 같이 코틀린에서는 기본값을 지정해놓으면 함수를 호출할 때 모든 인자를 쓸 수도 있고, 일부를 생략할 수도 있다. 추가적으로, 이름 붙은 인자를 사용하는 경우에는 인자 목록의 중간에 있는 인자를 생략하거나, 지정하고 싶은 인자에 이름을 붙여서 순서와 관계없이 지정할 수 있다.

fun main() {
    joinToString(list, postfix="; ", prefix="# ") //#1, 2, 3;
}

위의 경우엔 separator 파라미터가 생략되어 원래 지정된 디폴트 값(,)로 설정되고, 나머지 파라미터들만 순서를 바꿔 지정해준 결과이다. 즉, 중요한 점은 함수의 디폴트 파라미터 값은 함수를 호출하는 쪽이 아니라 함수 선언 쪽에 인코딩 된다는 점이다.

2.3. 정적인 유틸리티 클래스 없애기: 최상위 함수와 property

객체지향 언어인 자바에서는 모든 코드를 클래스의 메서드로 작성해야만 한다는 사실을 알고 있다. 이런 경우에는 특별한 상태나 인스턴스 메서드가 없는 클래스가 생겨나는데, 보통 이런 클래스는 여러 정적 메서드를 모아두는 역할만 담당한다.

코틀린에서는 이런 무의미한 클래스를 필요로 하지 않는다. 함수를 소스 파일의 최상위 수준, 모든 다른 클래스의 밖에 위치시키면 된다.

package strings //join.kt 파일

fun joinToString(): String{}

이 join.kt 파일을 컴파일한 결과를 자바로 작성하면 다음과 같다.

package strings;

public class Joinkt {
  public static String joinToString(){}
}

코틀린 컴파일러가 생성하는 클래스의 이름이 최상위 함수가 들어있던 코틀린 소스 파일의 이름과 대응한다는 사실을 확인할 수 있다.

최상위 property
*property: 객체가 가지는 변수 또는 속성

프로퍼티 또한 함수와 마찬가지로 파일 최상위 수준에 위치시킬 수 있다. 최상위 수준에 위치시킨다는 말은 함수 바깥의 맨 위에서 정의되어 이 값이 정적 필드에 저장된다는 뜻이고, 이를 이용해 코드에서 상수를 정의할 수 있다.

디폴트 최상위 프로퍼티도 다른 모든 프로퍼티처럼 접근자 메서드를 통해 자바 코드에 노출된다(val은 getter, var은 getter와 setter). 만들어진 상수를 자바 코드에 public static final 필드로 노출시키고 싶다면, const 변경자를 추가하면 된다.

3. 확장 함수와 확장 프로퍼티

완전히 코틀린으로만 이뤄진 프로젝트조차도 JDK나 안드로이드 프레임워크와 같은 자바 라이브러리 기반으로 만들어진다. 또한 코틀린을 기존 자바 프로젝트에 통합하는 경우에는 코트린으로 직접 변환할 수 없거나 미처 변환하지 못한 자바 코드를 처리할 수 있어야 한다.

코틀린의 확장 함수(extension function)가 이런 기존의 자바 API를 재작성하지 않고도 편리한 여러 기능을 제공할 수 있도록 해준다.

쉽게 풀어 말하자면, 기존 자바 클래스(String, List, File 등)에 기능을 더하고 싶을 때 자바처럼 새 클래스를 만들거나 상속하거나 유틸 클래스를 만들 필요 없이, 코틀린에서는 그냥 확장 함수로 “간편하게” 추가할 수 있다는 뜻이다. 개념적으로 정의하자면, 어떤 클래스의 멤버 메서드인 것처럼 호출할 수 있지만, 그 클래스의 밖에 선언된 함수다. 선언의 예시는 아래와 같다.

그냥 추가 함수 이름 앞에 그 함수가 확장할 클래스의 이름을 덧붙이면 된다. 클래스에 우리의 메서드를 추가하는 것과 같다. 수신 객체를 통해 사용자가 확장 중인 타입의 메서드와 프로퍼티에 접근할 수 있다. 자바 클래스로 컴파일된 클래스 파일이 있는 한, 이 클래스에 우리가 원하는 대로 확장을 추가할 수 있다.

하지만 확장 함수가 캡슐화를 깨지는 않는다는 사실을 기억해야 한다. 클래스 안에 정의된 메서드와 달리 확장 함수 안에서는 클래스 내부에서만 사용할 수 있는 private이나 protected 멤버는 사용불가하다.

3.1. import와 확장 함수

확장 함수를 정의했다고 해도 자동으로 프로젝트 안의 모든 소스코드에서 그 함수를 사용할 수는 없다. 다른 클래스나 함수와 마찬가지로 해당 함수를 import 해와야 한다.

주의할 점은, 상황에 따라 임포트 해올 때 확장 함수의 이름 충돌을 대비해 적절히 이름을 바꿔주는 것이 필요하다.

3.2. 자바에서 확장 함수 호출

자바에서는 정적 메서드를 호출하면서 첫 번째 인자로 수신 객체를 넣는 방식으로 확장 함수를 직접 호출한다.

// MyExtensions.kt
package myutils

fun String.hello(): String { //확장 함수
    return "Hello, $this"
}

이를 자바에서는 정적 함수와 같이 호출해주면 된다.

public static String hello(String receiver)

즉, 자바에서는 Kotlin의 확장 함수가 일반 static 함수처럼 컴파일되기 때문에, 원래 객체(=수신 객체)를 첫 번째 인자로 직접 전달해서 호출해야 한다는 뜻이다.

3.3. 확장 함수는 오버라이드 할 수 없다

코틀린의 메서드 오버라이드도 일반적인 객체지향의 오버라이드와 마찬가지지만, 확장 함수는 오버라이드 할 수 없다.

뒤에서 더 자세히 다루지만, 코틀린에서는 하위 클래스에서 오버라이딩이 가능하게 하려면 상위 클래스 코드에 'open' 변경자를 추가해야 한다.

open class View {
    open fun click() = println("view clicked")
}

open class Button: View(){
    override fun click() = println("Button Clicked") //click override
}

책에서 이 부분을 좀 어럽게 설명하는 것 같은데, 바꿔 말하면 "확장 함수는 '외부에서 덧붙인 함수'라서, 상속 구조와 아무 관련이 없기 때문에 오버라이드가 안 된다" 는 뜻이다. 확장 함수를 기반 클래스와 하위 클래스에 대해 정의할 수는 있지만, 실제 호출될 함수는 그 변수에 저장된 객체의 타입에 결정되는 것이 아닌, 확장 함수를 호출할 때 수신 객체로 지정한 변수의 컴파일 시점의 타입에 의해 결정된다. 라고 책에서 말함; 어렵게도 말한다

open class Shape

class Circle : Shape() //오버라이딩

// 확장 함수 정의
fun Shape.getName() = "Shape"
fun Circle.getName() = "Circle"

fun printName(shape: Shape) {
    println(shape.getName())
}

fun main() {
    val shape: Shape = Circle()
    printName(shape)  // ❗ 출력: Shape
}

여기서의 shape.getName()은 클래스 타입을 기준으로 호출된다. 실제 객체가 Circle 이어도, 변수 타입이 Shape이면 Shape 확장 함수가 호출된다. 확장 함수가 오버라이딩 되지 않는다는 말은 이 말이다.

3.4. 확장 프로퍼티

확장 프로퍼티의 사용 예는 아래 코드와 같다. 크게 어려운 점은 없다.

val String.lastChar: Char
    get() = this.get(length - 1)
var StringBuilder.lastChar: Char
    get() = this.get(length - 1)
    set(value) {
        this.setCharAt(length - 1, value)
    }

fun main() {
    val sb = StringBuilder("Kotlin?")
    println(sb.lastChar)
    sb.lastChar = '!'
    println(sb)
}

4. 컬렉션 처리

이번 절에서는 컬렉션 처리에서 가변 길이 인자, 중위 함수 호출, 라이브러리 지원에 대해 다룬다.

4.1. 자바 컬렉션 API 확장

fun main() {
    val strings = listOf("first", "second", "fourteenth")
    
    strings.last() //fourteenth //마지막 원소 출력
    
    println(strings.shuffled()) //[fourteenth, second, first] //뒤섞기
    
    val numbers = setOf(1, 14, 2)
    println(numbers.sum()) //17 // 합계 계산
}

이번 챕터의 맨 앞장에서 다뤘던 코드이다. 이제는 의문을 가져보자. 자바 라이브러리 클래스의 인스턴스인 컬렉션에 대해 코틀린이 어떻게 새로운 기능을 추가할 수 있었을까?

답은 명확하다. 모두 확장 함수이다.

코틀린 표준 라이브러리는 수많은 확장 함수를 포함한다. 이 부분에 대해서 굳이 모든 표준 라이브러리를 다 알 필요는 없다.

4.2. 가변 인자 함수

컬렉션을 만들어내는 함수가 가지고 있는 특징은, 인자의 개수가 그때그때 달라질 수 있다는 점이다.

가변 인자를 만들 때는 가변 길이 인자, 'vararg' 변경자를 붙여서 만든다. 이미 배열에 들어있는 원소를 가변 길이 인자로 넘길 때도 코틀린에서는 스프레드 연산자(*)를 활용한다. 배열 앞에 *를 붙여서 해당 배열을 펼쳐 배열을 넘길 수 있다.

4.3. 쌍 튜플 다루기: 중위 호출과 구조 분해 선언

맵을 만드려면 mapOf 함수를 사용한다.

val map = mapOf(1 to "one", 7 to "seven", 53 to "fifty-three")

여기서 to는 코틀린 키워드가 아닌, 중위 호출(infix call)이라는 특별한 방식으로 to라는 일반 메서드를 호출한 것이다.

1.to("one")
1 to "one"

위의 두 호출은 동일하다. 중위 호출은 두 개의 값 사이에 함수처럼 “연산자처럼” 함수를 호출하는 문법이다. 인자가 하나뿐인 일반 메서드나 인자가 하나뿐인 확장 함수에만 중위 호출을 사용할 수 있다. 함수를 중위 호출에 사용하게 허용하고 싶으면 infix 변경자를 함수 선언 앞에 추가해주면 된다.

구조 분해 선언(destructuring declaration)은 데이터 클래스나 Pair, Triple 같은 객체를 → 변수 여러 개로 나눠서 받는 문법이다.

data class Person(val name: String, val age: Int)

fun main() {
    val person = Person("Alice", 25)

    val (n, a) = person  // 구조 분해 선언!
    println(n)  // Alice
    println(a)  // 25
}

5. 문자열과 정규식 다루기


코틀린 문자열은 자바 문자열과 똑같다. 코틀린 코드와 자바 코드 사이에서 각 메서드와 호출에 넘겨도 전혀 문제가 없다.

5.1. 문자열 나누기

정규식 개념을 먼저 짚고 넘어가자면,

정규식(Regular Expression, Regex)이란 문자열에서 특정 패턴을 찾거나 처리하기 위한 방법이다. 즉, 텍스트를 찾고, 자르고, 검사하고, 치환하는 데 쓰는 패턴 언어다.

자바의 split은 정규식을 구분 문자열로 받아 그 정규식에 따라 문자열을 나눈다.

String input = "a.b.c";
String[] result = input.split(".");  // ❌ .은 정규식에서 "모든 문자"를 의미함

String input = "a.b.c";
String[] result = input.split("\\.");  // ✅

이런 뜻이다.설명 대충한 거 아니다. 즉, 점이 아닌 모든 문자 기준으로 잘린다.

코틀린에서는 자바의 split 대신에 split 확장 함수를 제공하며, String이 아닌 Regex 타입의 값을 받는다.

fun main() {
    println("12.345-6.A".split("\\.|-".toRegex())) //정규식을 명시적으로 만듬
}

이와 같이 toRegrex 확장 함수를 사용하여 문자열을 정규식으로 변환한다. 이외에도 코틀린 확장 함수는 여러 문자를 받을 수 있어 자바에 있는 단 하나의 문자만 받을 수 있는 메서드를 대신한다.

따라서 코틀린에서는 split 함수에 전달하는 값의 타입에 따라 정규식이나 일반 텍스트 중 어느 것으로 문자열을 분리하는지 쉽게 알 수 있다.

5.2. 정규식과 3중 따옴표로 묶은 문자열

파일의 전체 경로명을 디렉터리, 파일 이름, 확장자로 구분하는 코드를 작성해보자. 코틀린 표준 라이브러리에는 어떤 문자열에서 구분 문자열이 맨 나중에 나타난 곳 뒤의 부분 문자열을 반환하는 함수가 있다. 코드는 아래와 같다.

fun parsePath(path: String) {
    val directory = path.substringBeforeLast("/")
    val fullName = path.substringAfterLast("/")

    val fileName = fullName.substringBeforeLast(".")
    val extension = fullName.substringAfterLast(".")

    println("Dir: $directory, name: $fileName, ext: $extension")
}

fun main() {
    parsePath("/Users/yole/kotlin-book/chapter.adoc")
}

코틀린에서는 정규식을 사용하지 않고도 문자열을 쉽게 파싱할 수 있다. 정규식은 강력하지만, 알아보기가 쉽진 않으므로 정규식이 필요할 때는 코틀린 라이브러리를 사용하면 도움이 된다.

fun parsePathRegex(path: String) {
    val regex = """(.+)/(.+)\.(.+)""".toRegex()
    val matchResult = regex.matchEntire(path)
    if (matchResult != null) {
        val (directory, filename, extension) = matchResult.destructured
        println("Dir: $directory, name: $filename, ext: $extension")
    }
}

fun main() {
    parsePathRegex("/Users/yole/kotlin-book/chapter.adoc")
}

이 예제에서 3중 따옴표를 썼는데, 3중 따옴표 문자열에서는 문자열을 그대로 해석하므로, 복잡한 정규식도 이스케이프 없이 깔끔하게 쓸 수 있게 해주는 효과를 가진다. 위의 코드는 ch9에서 좀 더 자세히 다룰 예정이다.

5.3. 여러 줄 3중 따옴표 문자열

3중 따옴표 문자열에는 줄 바꿈을 포함해 아무 문자열이나 그대로(이스케이프 없이) 들어간다.

따라서 3중 따옴표를 쓰면 쉽게 줄 바꿈이 들어있는 텍스트를 문자열에 포함시킬 수 있다. 여러 줄 문자열이 요긴하게 쓰일 수 있는 분야에는 테스트가 있다. 테스트에서는 보통 여러 줄의 텍스트 출력을 만들어내는 연산을 실행하고, 그 결과를 예상 결과와 비교해야 하는 경우가 있기 때문에, 이 분야에서 3중 따옴표가 좋은 해법이다.

fun main() {
    val expectedPage = """
        <html lang="en">
            <head>
            <title>A page</title>
            </head>
        <body>
            <p>Hello, Kotlin!</p>
        </body>
        </html>
    """.trimIndent()

    val expectedObject = """
        {
            "name": "Sebastian",
            "age": 27,
            "homeTown": "Munich"
        }
    """.trimIndent() //여러 줄 문자열의 공통된 들여쓰기만 자동으로 제거해주는 확장 함수
}

참고로 인텔리제이에서는 3중 따옴표를 썼을 경우에는 따로 문법 하이라이팅 기능도 지원해 코딩을 더 편리하게 할 수 있다.

6. 로컬 함수와 확장

코딩을 할 때 주요 원칙 중 하나는 "반복을 피하라"이다. 하지만 자바의 경우에는 이 원칙을 따르기는 쉽지 않다. 많은 경우 메서드 추출 리팩터링을 통해 긴 메서드를 부분부분 나눠 재활용 가능하다. 하지만 이런 경우에는 코드를 리팩터링하면 클래스 안에 작은 메서드가 많아져 코드를 이해하기 힘들어질 수 있다.

코틀린은 이 문제를 다음과 같이 해결한다.

함수에서 추출한 함수를 원래의 함수 내부에 내포시켜 문법적인 부가 비용을 들이지 않고도 깔끔하게 코드를 조직할 수 있다.

예시를 들어 이해해보자. 아래는 중복된 코드의 예시이다.

class User(val id: Int, val name: String, val address: String)

fun saveUser(user: User) {
    if (user.name.isEmpty()) {
        throw IllegalArgumentException(
            "Can't save user ${user.id}: empty Name")
    }

    if (user.address.isEmpty()) {
        throw IllegalArgumentException(
            "Can't save user ${user.id}: empty Address")
    } //필드 검증의 중복

    // Save user to the database
}

fun main() {
    saveUser(User(1, "", ""))
}

이런식으로, 사용자의 필드를 검증할 때 만약 필요한 인자가 더 늘어난다면 더욱 코드는 복잡해질 것이다. 이런 경우 검증 코드를 로컬 함수로 분리해 코드 구조를 깔끔하게 유지할 수 있다. 어떻게 하는지 살펴보자.

class User(val id: Int, val name: String, val address: String)

fun saveUser(user: User) { 

//한 필드를 검증하는 로컬 함수 정의
    fun validate(user: User, 
                 value: String,
                 fieldName: String) {
        if (value.isEmpty()) {
            throw IllegalArgumentException(
                "Can't save user ${user.id}: empty $fieldName")
        }
    }

    //로컬 함수 호출해 각 필드 검증
    validate(user, user.name, "Name")
    validate(user, user.address, "Address")

    // Save user to the database
}

fun main() {
    saveUser(User(1, "", ""))
}

중복을 더 피할 수도 있다. 로컬 함수는 자신이 속한 바깥 함수의 모든 파라미터와 변수를 사용할 수 있다.

class User(val id: Int, val name: String, val address: String)

fun saveUser(user: User) {
    fun validate(value: String, fieldName: String) { //saveUser 함수의 User 파라미터를 중복 사용X
        if (value.isEmpty()) {
            throw IllegalArgumentException(
                "Can't save user ${user.id}: " +
                    "empty $fieldName")
        }
    }

    validate(user.name, "Name")
    validate(user.address, "Address")

    // Save user to the database
}

fun main() {
    saveUser(User(1, "", ""))
}

확장 함수를 로컬 함수로 정의하는 것도 가능하다. 하지만 일반적으로 한 단계만 함수를 내포시키라고 권장한다. 내포된 함수의 깊이가 깊어지면 코드 읽기는 더 어려워질 것이다.

profile
'개발사(社)' (주)영진

0개의 댓글