1. 개요

자바에서 기존 클래스에 메서드를 하나 추가하고 싶으면 상속하거나, Utils.doSomething(obj) 같은 정적 유틸 메서드를 만드는 수밖에 없었다. 상속은 final 클래스거나 라이브러리 클래스면 애초에 안 되고, 유틸 메서드는 obj.doSomething() 이 아니라 Utils.doSomething(obj) 로 써야 해서 코드가 읽기 불편하다. 코틀린의 확장 함수는 상속 없이 기존 클래스에 메서드를 붙여 쓰게 해준다. 다만 이게 진짜로 클래스를 바꾸는 건 아니라서, 겉보기와 다르게 동작하는 지점이 하나 있다.


2. 핵심 내용

2-1. 문법과 동작

fun String.isValidEmail(): Boolean {
    return this.contains("@") && this.contains(".")
}
 
"a@b.com".isValidEmail()   // true

String 을 상속하거나 수정하지 않고도 String 타입 값에 .isValidEmail() 을 붙여 쓴다. 함수 안의 this 는 확장 대상(수신 객체, receiver)을 가리킨다. String 은 자바에서 final 이라 상속으로는 애초에 이런 걸 못 한다.

2-2. 실체는 정적 메서드다

확장 함수는 마법이 아니라 컴파일러가 만들어주는 문법 설탕이다. 위 코드는 바이트코드 레벨에서 StringUtilsKt.isValidEmail(String) 같은 정적 메서드 호출로 컴파일된다. String 클래스 자체엔 아무것도 추가되지 않는다. 그래서 확장 함수는 private/protected 멤버에 접근 못 하고, 리플렉션으로 클래스를 조회해도 안 보인다.

2-3. 확장 프로퍼티

함수만 되는 게 아니라 프로퍼티도 확장할 수 있다. 단 실제 필드를 추가하는 게 아니라서 backing field 를 가질 수 없다. getter 로 계산해서 돌려주는 값만 가능하다.

val String.lastChar: Char
    get() = this[this.length - 1]
 
"kotlin".lastChar   // 'n'

= "" 처럼 초기값을 주는 건 안 된다. 저장 공간이 없기 때문이다.

2-4. 정적 디스패치라는 함정

확장 함수의 진짜 함정은 여기다. 멤버 함수는 객체의 실제(런타임) 타입으로 호출되지만(동적 디스패치), 확장 함수는 변수의 선언(정적) 타입으로 호출된다(정적 디스패치).

open class Animal
class Dog : Animal()
 
fun Animal.speak() = "..."
fun Dog.speak() = "왈왈"
 
val a: Animal = Dog()
println(a.speak())   // "..." — Dog 가 아니라 Animal 의 확장 함수가 불린다

a 의 런타임 타입은 Dog 지만, 컴파일러는 a 를 선언된 타입인 Animal 로 보고 어떤 확장 함수를 부를지 컴파일 시점에 정해버린다. 멤버 함수였다면(override 로 오버라이딩했다면) 런타임에 Dog 의 구현이 불렸을 것이다. 확장 함수는 오버라이딩이 안 된다. 겉모습은 메서드처럼 보이지만 실제로는 어떤 함수를 부를지가 컴파일 시점의 정적 타입에 못박힌 정적 유틸 함수라는 걸 잊으면, 다형성을 기대하다가 틀린 결과를 받는다.

2-5. 언제 쓰나

기존 클래스(특히 라이브러리나 final 클래스)에 다형성이 필요 없는 헬퍼를 붙일 때, 그리고 “이 값에다 뭘 한다” 는 의미를 살리고 싶을 때 쓴다. 다형적으로 동작해야 하는 로직이면 확장 함수가 아니라 진짜 멤버 함수나 인터페이스로 만들어야 한다.


3. 마무리

요약

  • 확장 함수는 상속 없이 기존 클래스에 메서드를 붙이지만, 실제로는 컴파일러가 만든 정적 메서드 호출이다.
  • 확장 프로퍼티는 backing field 가 없어 계산된 getter 만 가능하고 초기값을 못 준다.
  • 확장 함수는 선언된(정적) 타입으로 디스패치된다. 런타임 타입에 따라 달라지는 멤버 함수의 동적 디스패치와 다르다.
  • 다형성이 필요한 로직에 확장 함수를 쓰면 기대와 다른 구현이 불릴 수 있다.
  • private/protected 멤버 접근 불가, 리플렉션에도 안 잡힌다는 점도 “진짜 멤버가 아니다” 라는 사실에서 나온다.