코틀린에는 컬렉션의 요소들을 문자열로 조합하여 반환하는 joinToString 이라는 함수가 존재한다. 흥미롭게도, 이 책에서는 함수를 정의하고, 다루는 전반적인 방법을 바로 이 joinToString 함수를 직접 구현하고 개선해 나가며 알려주고 있다.
이번 포스팅에서는 그러한 내용을 정리하여 설명하려고 한다.
joinToString에 대해 알아보자.
먼저 joinToString을 직접 구현하기 전에 어떤 녀석인지 알아볼 필요가 있을 것 같다.
우선 자바 컬렉션에는 디폴트로 toString 구현이 들어있다. 이게 어떤 의미냐면, 다음과 같은 코드를 예시로 들어보겠다.
fun main() {
val list = listOf(1, 2, 3)
println(list)
// [1, 2, 3]
}
여기서 println(list)만 하였는데, 대괄호, 콤마로 구분된 리스트를 출력해준다. 사실 객체를 출력하려고 하면 자동으로 toString 메서드가 호출된다. 때문에 컬렉션을 출력해보고 싶을 때 단순히 출력 함수에 넘겨주면 된다!
하지만 이런 형식이 아니라 다른 형식으로 출력해야 하는 경우는 분명 심심치 않게 존재할 것이다. 이러한 상황에서 방금 소개한 joinToString 함수를 사용해볼 수 있을 것이다.
혹은 책에서는 구아바, 아파치 커먼즈 같은 서드파티 프로젝트를 추가하는 방법도 있다고 소개한다.
이에 대해서는 추가적으로 더 조사하여 정리하려고 한다.
우선 우리는 joinToString 메서드를 구현해 보도록 하겠다.
joinToString 구현하기: 초기 구현
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, "; ", "(", ")"))
}
다음과 같이 collection을 받아서 separator로 구분하여 합치고, prefix, postfix를 붙인 문자열을 리턴하는 함수를 구현할 수 있다. <T> 는 제네릭스로 어떤 타입의 값을 원소로 하던 처리 가능하도록 만든 구현이다. 제네릭스에 대해서는 추후 더 자세히 설명하도록 하겠다.
결론적으로 이렇게 구현한 joinToString 함수는 코틀린에서 직접 제공하는 함수와는 사용 방법에는 약간의 차이가 있지만 그럭저럭 사용할만 하다. 잠깐 이 초기 구현 함수를 이용하여 코틀린의 함수에 대한 몇 가지를 소개하도록 하겠다.
인자에 이름 붙이기
joinToString(collection, " ", " ", ".")
위의 예제는 방금 만든 함수를 호출하는 부분이다. 각 인자가 어떤 역할인지 이 함수의 실제 구현을 보지 않고서는 알기 힘들다.
사실 IDE에서는 이러한 문제를 위해 각 인자가 어떤 매개변수에 전달되는지 표시해주기도 한다. 혹은 주석으로 각 인자 앞에 어떤 파라미터에 대응되는지 표시해줄수도 있을 것이다.,
하지만 코틀린에서는 코드레벨에서 이를 해결할 수 있다. 별다른 함수의 구현을 수정할 필요는 없다. 사용할 때 다음과 같이 사용하면 된다.
joinToString(collection, separator = " ", prefix = " ", postfix = ".")
joinToString(
postfix = ".",
seperator = " ",
collection = collection,
prefix = " "
)
다음과 같이 인자가 어떤 매개변수에 매칭될지 명시적으로 표시해줄 수 있으며, 심지어 전달하는 모든 인자의 이름을 지정하는 경우에는, 인자 순서를 변경할 수 있다.
디폴트 파라미터 값 지정하기
디폴트 파라미터가 필요한 이유는 무엇일까? 사실 나는 그냥 사용자에게 편의성을 제공하기 위해서라고 생각했었다. 그런데 책의 내용을 보고 오히려 편의성을 제공하는 기능이었구나를 느끼게 되었다.
바로 오버로딩 메서드를 만들지 않아도 된다는 것이다. 만약 디폴트 값을 지정하지 않고, 어떤 파라미터를 사용자가 선택적으로 입력하게 한다면, 두 가지의 함수를 모두 작성해야 할 것이다. 이는 좀 더 복잡한 요구사항을 구현할 수록 더 많아질 것이다.
위와 같은 이유로 디폴트 파라미터 값 지정은 개발자나, 사용자 입장에서 편의성을 제공하기에 실보다 득이 많은 기능이라고 생각한다.
fun <T> joinToString(
collection: Collection<T>,
separator: String = ", ",
prefix: String = "",
postfix: String = ""
): String
fun main() {
joinToString(list, ", ", "", "")
// 1, 2, 3
joinToString(list)
// 1, 2, 3
joinToString(list, "; ")
// 1; 2; 3
}
위와 같이 함수 구현 부분에서 각 파라미터에 등호를 통해 디폴트 값을 지정해줄 수 있다.
번외: 디폴트 파라미터 값을 사용하는 코틀린 함수를 자바에서 사용 가능하게 하기
사실 나는 자바에도 디폴트 파라미터 값을 지정하는 기능이 있을 줄 알았다. 하지만, 자바는 그러한 기능이 없다고 한다..
이러한 상황에서 코틀린 코드를 자바에서 동작시키기 위해서는 다음과 같은 어노테이션을 추가해야 한다고 한다.
@JvmOverloads
fun <T> joinToString(
collection: Collection<T>,
separator: String,
prefix: String,
postfix: String
): String { /* ... */ }
다음과 같은 어노테이션을 추가하면 자동으로 모든 경우의 오버로드 함수를 생성해주고, 생략된 파라미터에 대해서는 코틀린 함수에서의 디폴트 파라미터 값을 사용하는 식으로 변환된다고 한다.
정적인 유틸리티 클래스 없애기: 최상위 함수와 프로퍼티
객체지향 언어인 자바에서는 모든 코드를 클래스의 매서드로 작성해야만 한다. 책에서는 이러한 코드는 잘 동작하지만, 실전에서 사용할 때 어떤 클래스에 포함시켜야 할지 어려울 수 있다는 문제점을 지적한다.
이러한 경우 때문에 특별한 상태나 인스턴스 메서드가 없는 클래스가 생겨났다고 한다.
이 부분에 대한 추가적인 정보는 다음에 정리하도록 하겠다.
결론적으로 코틀린에서는 이런 무의미한 클래스가 필요 없다, 대신 함수를 직접 소스 파일의 최상위 수준, 즉 다른 클래스의 밖에 위치시키면 된다.
이러한 함수들을 다른 패키지에서 사용하고 싶을 때는 해당 패키지만 임포트 하면 된다.
나도 이번에 처음 안 사실이지만, JVM은 클래스 안에 들어있는 코드만을 실행할 수 있다고 한다. 그렇다면, 모든 클래스 밖에 있는 최상위 함수는 JVM에서 어떻게 실행되는 걸까?
// Join.kt
package strings
fun joinToString( /* ... */ ): String { /* ... */ }
코틀린 컴파일러는 컴파일 과정에서 파일 이름과 대응되는 public class를 생성하고 최상위 함수를 해당 클래스의 static 메서드로 만들어 준다, 정적으로 선언하였기 때문에 다른 패키지나 파일에서 쉽게 호출할 수 있게 된다.
package strings
public class JoinKt {
public static String joinToString( /* ... */ ) { /* ... */ }
}
함수 뿐만 아니라 프로퍼티 또한 파일 최상위 수준에 놓을 수 있다. 이때도 함수와 동일하게 파일 이름의 static 필드에 저장될 것이다.
메서드를 다른 클래스에 추가: 확장 함수와 확장 프로퍼티
확장 함수는 어떤 클래스의 멤버 메서드인 것 처럼 호출할 수 있지만 그 클래스의 밖에 선언된 함수이다. 책에서는 다음과 같은 예시를 들어주고 있다.
fun String.lastChar(): Char = this.get(this.length - 1)
사실 보다시피 기존의 함수 선언과 크게 다르지 않다. 다른 점은 이 함수가 확장할 클래스의 이름을 붙이기만 하면 된다. 예제의 경우 String 클래스에 대한 확장 함수이므로, String을 붙인 것을 볼 수 있다.
책에서는 이를 수신 객체 타입(receiver type), 실제로 함수 본문에서 사용되는 해당 객체(예제에서는 this)를 수신 객체(receiver object) 라고 설명하고 있다.
사실 이는 String 클래스에 lastChar라는 메서드를 추가한 것과 같은 셈이지만, 클래스 밖에서, 심지어 String 클래스의 구체적인 구현을 모르고도, 확장할 수 있다.
책에서는 심지어, 다른 JVM 언어, final로 상속을 할 수 없게 선언한 경우, 모두 사용할 수 있다고 소개하고 있다. 뿐만 아니라 예제에서 String 클래스의 length 프로퍼티를 사용하는 것을 볼 수 있듯이 수신 객체의 프로퍼티에도 접근이 가능하다고 소개하고 있다.
어찌보면 당연히 될 것이라고 생각해볼 수도 있으나, 꽤 대단한 기능인 것 같다.
하지만 물론 확장 함수가 만능은 아니다. 책에서는 확장 함수가 캡슐화를 깨지 않는다는 사실을 알려주고 있다. 클래스 안에서 정의한 메서드와 달리 확장 함수는 비공개 멤버나, 보호된 멤버를 사용할 수 없다고 한다.
근데 이는 어찌보면 관점에 따라 안전성의 측면에서 단점보단 장점이지 않을까 하는 생각이 들기도 했다.
joinToString 함수를 컬렉션의 확장 함수로 개선하기
package ch03.JoinToStringFinal
fun <T> Collection<T>.joinToString(
separator: String = ", ",
prefix: String = "",
postfix: String = ""
): String {
val result = StringBuilder(prefix)
for ((index, element) in this.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(list.joinToString(separator = "; ",
prefix = "(", postfix = ")"))
}
위와 같이 컬렉션에 대한 확장함수로 할 수 있다. 결론적으로 joinToString 함수를 클래스의 멤버인 것처럼 호출할 수 있다.
fun main() {
val list = listOf(1, 2, 3)
println(list.joinToString(" "))
// 1 2 3
}
확장 함수는 이전과 같이 살펴봤듯이 내부적으로 자바의 정적 메서드로 변환된다. 때문에 정적 메서드와 같은 특성을 가진다고 볼 수 있다.
정적 함수를 하위 클래스에서 오버라이드 할 수 없듯이 확장 함수도 하위 클래스에서 오버라이드 할 수 없다고 할 수 있겠다.
책에서는 다음과 같은 예제와 함께 설명해주고 있다.

이전에도 설명했듯이, 확장 함수는 엄밀히 말하면 클래스의 일부가 아니므로, 실제로 showOff 함수를 호출할 때, 수신 객체로 지정한 변수의 컴파일 시점의 타입에 의해 어떤 showOff 함수가 호출될지 결정된다
코드로 살펴보면 어떤 문제인지 좀 더 잘 와닿을 것이다.
open class View {
open fun click() = println("View clicked")
}
class Button: View() {
override fun click() = println("Button clicked")
}
fun View.showOff = println("I'm a view!")
fun Button.showOff = println("I'm a button!")
fun main() {
val view: View = Button()
view.click()
// Button clicked
view.showOff()
// I'm a view!
}
결과만 놓고 말하면, View를 상속한 Button 타입의 객체를 생성하고 Button의 실제 멤버 함수(오버라이드 된)를 호출하면, Button객체의 것이 잘 호출되는 것을 확인할 수 있다.
하지만, 확장 함수는 Button의 것이 아닌 View의 확장 함수가 호출되는 것을 확인할 수 있다. 이는 결국 View타입으로 Button을 선언하였기 때문에, 컴파일 타임에 결정되는 showOff 확장 함수의 수신 객체 타입은 View이므로, View의 확장 함수가 호출되게 된다.
이론적으로 뜯어보면, 당연한 구현이지만 이를 모르고 확장 함수를 통해 상속과 유사한 기능을 기대하는 경우에는 의도와 다르게 작동할 수 있겠다는 생각이 들었다.
확장 프로퍼티
val String.lastChar: Char
get() = this.get(length - 1)
위의 예제는 lastChar 확장 프로퍼티 선언이다. 앞에서 lastChar()라는 멤버 함수를 정의했었는데, 이를 사용할때는 함수를 호출하는 방식 즉 lastChar()로 호출헀어야 할 것이다.
하지만, 이전에 설명했듯이 lastChar는 동작(멤버 함수) 보다는 프로퍼티(특성)에 좀 더 가까운 녀석이라고 할 수 있다. 때문에 위와 같이 선언하게 하여 lastChar로 호출하게 하는 것이 좀 더 옳다고 할 수 있겠다.
확장 프로퍼티 사용에도 주의할 점이 있는데, 바로 커스텀 게터를 꼭 작성해야 한다는 것이다. 이는 기존 자바 객체 인스턴스에 필드를 추가할 수 있는 방법이 없기 때문에 발생하는 번거로움인데, 새로운 필드를 추가할 수 없으므로, 이 프로퍼티는 아무 상태, 즉 값을 가질 수 없다. 때문에 기본 게터 구현이 제공되지 않는다. 예제에서도 커스텀 게터를 구현한 것을 확인할 수 있다.
'Kotlin' 카테고리의 다른 글
| Kotlin in action 2/e: 클래스, 객체, 인터페이스(1) 인터페이스, 클래스, 생성자 (0) | 2026.02.09 |
|---|---|
| Kotlin in action 2/e: 함수 정의와 호출(2) 로컬 함수, 코드를 확장 함수로 추출하기 (0) | 2026.02.09 |
| Kotlin in Action 2/e: 코틀린 기초(2): 스마트 캐스트, if를 when으로 리팩터링, while, for, 예외처리 (0) | 2026.02.06 |
| Kotlin in Action 2/e: 코틀린 기초(1): 함수, 변수, 클래스, 프로퍼티, 이넘, when() (0) | 2026.02.05 |
| Kotlin in Action 2/e: 코틀린 코드의 컴파일 (0) | 2026.02.05 |