지난 편에서는 이름 있는 인자·디폴트 파라미터·vararg·infix까지 함수를 호출하는 다양한 문법과, 클래스 밖에 선언하는 최상위 함수·프로퍼티(const val 포함)를 살펴봤습니다.
https://dmoritle.tistory.com/279
[Kotlin] 코틀린 온보딩 6편 - 함수 호출: 이름 있는 인자부터 infix까지 이해하기
지난 편에서는 여전히 문으로 남아있는 for/while 루프의 순회 방식과, 식으로도 쓰이는 throw, 그리고 자바와 다른 코틀린의 예외 처리 방식(멀티캐치 없음, checked exception 없음)까지 살펴봤습니다.ht
dmoritle.tistory.com
이번 편에서는 6편에서 Int.add, to로 잠깐 맛봤던 확장 함수와 확장 프로퍼티가 실제로 어떻게 동작하는지, 바이트코드 레벨까지 파고들어 정리합니다.
- 확장 함수의 정의와 사용 목적
- 바이트코드 관점에서 본 확장 함수의 정체
this생략과 이름 충돌 우선순위- 확장 함수·프로퍼티의 import와
as이름 변경 - 확장 프로퍼티가 상태를 저장하지 못하는 이유
확장 함수란?
코틀린의 확장 함수는 기존 클래스를 상속받거나 소스 코드를 직접 수정하지 않고도, 마치 그 클래스에 원래 있던 메서드처럼 새로운 함수를 추가할 수 있게 해주는 기능입니다.
fun String.addExclamation(): String {
return this + "!"
}
fun main() {
val greeting = "안녕하세요"
println(greeting.addExclamation()) // 안녕하세요!
}
String.addExclamation()처럼 클래스명.함수명 형태로 선언하면, String 타입 위에서 .addExclamation()을 호출할 수 있게 됩니다.
이때 확장 대상이 되는 String을 수신 객체 타입(Receiver Type)이라고 부르고, 함수 내부의 this는 실제 호출된 인스턴스, 즉 수신 객체(Receiver Object)를 가리킵니다.
왜 사용하는가
- 자바 표준 라이브러리 클래스(
String,List등)처럼 소스 코드를 직접 수정할 수 없는 클래스에도 새 기능을 추가할 수 있습니다. - 유틸리티 클래스에 static 메서드를 잔뜩 넣는 대신, 관련 타입에 자연스럽게 붙는 API처럼 설계할 수 있어 가독성이 좋아집니다.
- 실제로 코틀린 표준 라이브러리 자체가 확장 함수로 가득 차 있습니다.
list.firstOrNull(),str.trim()등 상당수가 확장 함수로 구현되어 있습니다.
바이트코드 관점: 확장 함수는 정적 메서드다
확장 함수는 언어 차원의 마법이 아니라 컴파일 타임에 정적 메서드로 변환되는 문법 설탕(syntactic sugar)입니다. 6편에서 다룬 최상위 함수와 마찬가지로 파일명 기반 클래스의 정적 메서드가 되는데, 수신 객체를 첫 번째 파라미터로 받는다는 특징만 추가됩니다.
fun String.addExclamation(): String {
return this + "!"
}
위 코드는 바이트코드 상에서 대략 다음과 같이 컴파일됩니다.
public final class MyFileKt {
public static String addExclamation(String $this) {
return $this + "!";
}
}

여기서 실제로는 첫 번째 파라미터로 수신 객체를 받는 정적 메서드일 뿐입니다. 파라미터가 있는 확장 함수와 비교하면 이 구조가 더 명확해집니다.
fun String.repeat2(times: Int): String {
return this.repeat(times)
}
public static String repeat2(String $this, int times) {
return ...;
}
$this는 수신 객체가 실체화된 것이고, times는 코틀린 소스 코드에서 실제로 선언된 파라미터입니다. 코틀린 문법상으로는 이 둘이 확연히 다르게 취급되지만(this 키워드 vs 일반 파라미터), 런타임/바이트코드 관점에서는 둘 다 그냥 메서드의 파라미터일 뿐입니다.
| 구분 | 코틀린 소스에서 | 바이트코드에서 |
| 수신 객체 | this 키워드로만 접근 (파라미터 목록에 없음) |
첫 번째 파라미터로 실체화됨 |
| 일반 파라미터 | () 안에 명시적으로 선언 |
두 번째 파라미터부터 순서대로 대응 |
이 구조에서 몇 가지 중요한 결론이 나옵니다.
- 런타임 다형성이 없다 (정적 디스패치): 확장 함수는 변수의 실제(런타임) 타입이 아니라, 컴파일 타임에 결정된 정적 타입을 기준으로 어떤 함수가 호출될지 결정됩니다.
open class Animal
class Dog : Animal()
fun Animal.speak() = println("동물 소리")
fun Dog.speak() = println("멍멍")
val animal: Animal = Dog()
animal.speak() // "동물 소리" 출력! (Dog.speak()가 아님)

- 클래스의 private/protected 멤버에 접근할 수 없다: 확장 함수는 외부의 정적 함수일 뿐이므로 클래스의 캡슐화를 깨지 못합니다.
- 기존 클래스에 실제로 필드를 추가하지 않는다: 뒤에서 다룰 확장 프로퍼티도 마찬가지로 실제 상태를 저장하지 못합니다.
이 차이를 멤버 함수와 비교해서 표로 정리하면 다음과 같습니다.
| 구분 | 멤버 함수 | 확장 함수 |
| 컴파일 결과 | 클래스의 인스턴스 메서드 | 별도 클래스의 정적 메서드 |
| 디스패치 방식 | 동적 디스패치 (오버라이드 적용) | 정적 디스패치 (선언 타입 기준) |
| private/protected 접근 | 가능 | 불가능 |
| 상태 저장 | 필드 추가 가능 | 불가능 |
주의할 점: 확장 함수와 같은 이름·시그니처의 멤버 함수가 클래스에 이미 있다면, 항상 멤버 함수가 우선합니다. 확장 함수는 멤버 함수를 오버라이드하거나 가릴 수 없습니다.
this 생략하기
확장 함수 본문 안에서는 수신 객체가 암묵적 수신 객체(implicit receiver)로 취급되기 때문에, 마치 클래스 내부의 멤버 함수를 작성하는 것처럼 this를 생략하고 바로 멤버에 접근할 수 있습니다.
class Person(val name: String, val age: Int)
fun Person.isAdult(): Boolean {
return age >= 18 // this.age 와 동일
}
fun Person.introduce(): String {
return "저는 ${name}이고, ${age}살입니다." // this.name, this.age 생략
}
여기서 age, name은 컴파일러가 자동으로 this.age, this.name으로 해석합니다. 확장 함수 본문의 스코프 자체가 마치 그 클래스 안에 들어와 있는 것처럼 동작하기 때문입니다.
이름 충돌 시 우선순위
확장 함수가 정의된 파일(또는 클래스) 스코프에 같은 이름의 변수·프로퍼티가 있으면, 기본적으로 수신 객체 쪽이 우선순위를 가집니다.
class Person(val name: String)
val name = "바깥 변수" // 파일 레벨 프로퍼티
fun Person.printName() {
println(name) // Person의 name이 출력됨 (바깥 변수 아님!)
println(this.name) // 명시적으로 써도 동일한 결과
println(this@printName.name) // 확실하게 하고 싶다면 라벨 사용
}
바깥 스코프의 name을 참조하고 싶다면, 확장 함수 밖의 스코프를 가리키는 라벨(this@함수명 또는 this@클래스명)을 써야 합니다. 특히 확장 함수를 클래스 멤버로 선언한 경우(멤버 확장 함수)에는 디스패치 수신 객체와 확장 수신 객체가 동시에 존재하기 때문에 이 라벨 문법이 실질적으로 중요해집니다.
class Host {
fun Person.describe(): String {
return "${this@Host}가 아는 사람: $name" // this@Host: Host 인스턴스 (디스패치 수신 객체)
// name: Person의 name (확장 수신 객체)
}
}

확장 함수 import
확장 함수는 최상위(top-level) 함수로 선언되면 특정 패키지에 속하게 되고, 다른 패키지에서 쓰려면 클래스를 import하듯이 그 함수를 import 해야 합니다.
// file: utils/StringExtensions.kt
package com.example.utils
fun String.addExclamation(): String {
return this + "!"
}
// file: Main.kt
package com.example
import com.example.utils.addExclamation // 함수를 import
fun main() {
val greeting = "안녕하세요"
println(greeting.addExclamation()) // import 없으면 컴파일 에러
}
일반 함수를 import하는 것과 문법이 완전히 동일합니다. 앞서 살펴봤듯 String.addExclamation()은 결국 파일명 기반 클래스(StringExtensionsKt)의 정적 메서드로 컴파일되므로, 자바에서 import static com.example.utils.StringExtensionsKt.addExclamation;을 하는 것과 본질적으로 같은 일을 더 간결한 문법으로 제공하는 것입니다.
같은 패키지 안에 있다면 import 없이 바로 사용할 수 있고, 와일드카드 import(import com.example.utils.*)도 가능합니다.
만약 확장 함수가 최상위가 아니라 다른 클래스나 객체 내부에 멤버로 선언되어 있다면, import만으로는 부족하고 그 객체를 수신 객체로 갖고 있어야 호출할 수 있습니다.
object StringUtils {
fun String.shout(): String = this.uppercase() + "!"
}
fun main() {
with(StringUtils) {
println("hi".shout()) // StringUtils 스코프 안에서만 호출 가능
}
}
잠깐:
object로 선언한 싱글턴과, 그 스코프 안으로 들어가는with함수는 아직 이 시리즈에서 다루지 않았습니다. 지금은 "이렇게 하면 멤버로 선언된 확장 함수도 호출할 수 있다" 정도로만 봐두면 되고, 자세한 내용은 각각 별도 편에서 다루겠습니다.
as로 이름 바꿔 import하기
코틀린의 import는 as 키워드로 이름을 바꿔서 가져올 수 있고, 이는 확장 함수에도 동일하게 적용됩니다.
import com.example.utils.addExclamation as shout
fun main() {
println("안녕하세요".shout()) // addExclamation을 shout라는 이름으로 사용
}
이 기능이 특히 유용한 상황은 서로 다른 패키지에 같은 이름의 확장 함수가 있을 때입니다.
// package com.example.utils
fun String.addExclamation(): String = this + "!"
// package com.example.utils2
fun String.addExclamation(): String = this + "!!!"
import com.example.utils.addExclamation as addExclamationSingle
import com.example.utils2.addExclamation as addExclamationTriple
fun main() {
println("hi".addExclamationSingle()) // hi!
println("hi".addExclamationTriple()) // hi!!!
}
이렇게 하지 않으면 두 확장 함수를 동시에 import할 때 이름이 충돌해서 컴파일 에러가 납니다.
바이트코드 관점에서 보면, as로 이름을 바꿔도 실제로 호출되는 메서드(StringExtensionsKt.addExclamation(String))는 그대로이고, 코틀린 소스 코드 레벨에서 참조하는 심볼 이름만 바뀝니다. 순수하게 컴파일 타임 관점의 문법 설탕이며 런타임 동작에는 전혀 영향을 주지 않습니다.
참고로 as 문법은 확장 함수 전용이 아니라 코틀린의 모든 import(클래스, 최상위 함수, 프로퍼티, object)에 공통으로 적용되는 규칙입니다.
import java.util.List as JavaList
import com.example.MyClass as EC
확장 프로퍼티
확장 함수와 같은 원리로, 기존 클래스에 프로퍼티가 있는 것처럼 접근 문법을 추가할 수 있습니다.
val String.lastChar: Char
get() = this[length - 1]
fun main() {
println("Kotlin".lastChar) // n
}
확장 프로퍼티는 상태를 저장할 수 없다
확장 함수와 마찬가지로, 확장 프로퍼티도 대상 클래스에 실제로 필드를 추가하지 않습니다. 오직 getter(그리고 var인 경우 setter)만 정의할 수 있고, backing field(field 키워드)는 사용할 수 없습니다.
val String.lastChar: Char
get() = this[length - 1]
바이트코드 레벨에서는 이 프로퍼티가 결국 getter 정적 메서드로 컴파일됩니다.
public static char getLastChar(String $this) {
return $this.charAt($this.length() - 1);
}
즉 프로퍼티처럼 보이는 문법이지만 실제로는 파라미터 없는(수신 객체만 받는) 정적 메서드 호출일 뿐이며, 상태를 저장할 공간 자체가 존재하지 않습니다. 그래서 다음과 같은 코드는 컴파일 에러가 납니다.
val String.lastChar: Char
get() = field // 에러: backing field가 없음
6편에서 살펴본 최상위 프로퍼티가 private static 필드를 실제로 갖는 "진짜 상태를 저장하는 프로퍼티"였다면, 확장 프로퍼티는 필드 없이 getter/setter만 존재하는 "접근 문법만 빌려온 프로퍼티"인 셈입니다.
var로 선언하는 확장 프로퍼티
var 확장 프로퍼티도 선언할 수 있지만, setter에서 실제로 어딘가에 값을 저장하려면 대상 객체가 이미 그 상태를 담을 공간을 갖고 있어야 합니다.
var StringBuilder.lastChar: Char
get() = this[length - 1]
set(value) {
this.setCharAt(length - 1, value)
}
이 예시에서는 StringBuilder가 이미 가변 상태(내부 문자 배열)를 갖고 있기 때문에 setter가 그 상태를 변경하는 방식으로 동작합니다. 확장 프로퍼티 자체가 새로운 저장 공간을 만든 것이 아닙니다.
import 방식도 동일
확장 프로퍼티 역시 확장 함수와 마찬가지로 최상위에 선언되면 특정 패키지에 속하고, import(및 as를 이용한 이름 변경)가 동일하게 적용됩니다.
import com.example.utils.lastChar as endChar
fun main() {
println("Kotlin".endChar)
}
정리
| 핵심 개념 | 요약 |
| 정체 | 수신 객체를 첫 번째 파라미터로 받는 정적 메서드(또는 getter/setter) |
| 디스패치 | 정적 디스패치 — 런타임 타입이 아닌 컴파일 타임 타입 기준으로 결정 |
| 멤버 함수와의 관계 | 이름·시그니처가 같은 멤버 함수가 있으면 항상 멤버 함수가 우선 |
| 캡슐화 | 클래스 외부의 함수이므로 private/protected 멤버 접근 불가 |
| 상태 | 실제 필드를 추가하지 못함 — 확장 프로퍼티는 backing field 사용 불가 |
| this 생략 | 확장 함수 본문에서는 멤버 함수처럼 this 없이 프로퍼티·함수 접근 가능 |
| 이름 충돌 | 수신 객체 쪽 멤버가 바깥 스코프보다 우선순위를 가짐 |
| import | 최상위 확장 함수·프로퍼티는 일반 함수처럼 import 필요, as로 이름 변경 가능 |
마무리
확장 함수와 확장 프로퍼티가 실제로는 수신 객체를 첫 번째 파라미터로 받는 정적 메서드·getter/setter일 뿐이라는 사실에서 출발해서, 정적 디스패치, this 생략과 이름 충돌 우선순위, import까지 바이트코드 레벨로 짚어봤습니다. 결국 확장 함수와 확장 프로퍼티는 코틀린 컴파일러가 문법적으로 제공하는 착시이며, 실제로는 정적 메서드 호출이라는 사실을 기억하면 위 특징들이 자연스럽게 설명됩니다.
이번 편으로 함수·변수·클래스 기초를 다루는 트랙을 마무리합니다. 다음 트랙에서는 인터페이스, 중첩 클래스, sealed class 같은 클래스/타입 중심 주제들을 이어서 다뤄보겠습니다.
이 시리즈는 『코틀린 인 액션(Kotlin in Action)』을 참고하여 작성했습니다.
이번 편에서 다룬 내용은 아래 문서로도 검증했습니다.
'Language > Kotlin' 카테고리의 다른 글
| [Kotlin] 코틀린 온보딩 6편 - 함수 호출: 이름 있는 인자부터 infix까지 이해하기 (0) | 2026.09.26 |
|---|---|
| [Kotlin] 코틀린 온보딩 5편 - 루프와 예외 처리: for/while과 try-catch (0) | 2026.09.26 |
| [Kotlin] 코틀린 온보딩 4편 - enum과 when: 완전성 검사 다시 보기 (0) | 2026.09.26 |
| [Kotlin] 코틀린 온보딩 3편 - 클래스와 프로퍼티: 커스텀 접근자로 이해하기 (0) | 2026.09.26 |
| [Kotlin] 코틀린 온보딩 2편 - 변수: val/var와 문자열 템플릿으로 이해하기 (1) | 2026.09.06 |