Kotlin 상속을 다루는 방법

코틀린을 배우기 위해서 인프런에서 강의를 구매하고 코틀린과 친해지고 기본기를 다지기 위해서 공부하는 중이다. 글 내용은 상속을 다루는 방법이고 최태현님의 자바 개발자를 위한 코틀린 입문(Java to Kotlin Starter Guide) 강의에 소금을 조금 친 내용이다

  • Kotlin에서 상속은Java와 유사하지만, 몇 가지 중요한 차이점이 있다

추상 클래스(Abstract Class)

추상 클래스는 불완전한 객체를 위한 청사진 역할을 한다. 하나 이상의 추상 메서드(미구현 메서드)를 포함할 수 있으며, 이 때문에 직접 인스턴스화 할 수 없다. 모든 추상멤버를 구현한 구체적인 서브클래스만이 인스턴스화 될 수 있다

  • Java와 동일: Kotlin의 추상 클래스도 Java와 마찬가지로 final이다. 즉, 별도의 지시어 없이는 오버라이드 될 수 없다
  • 인스턴스화 불가: 추상 클래스는 그 자체로 완전한 객체가 아니므로, 인스턴스를 생성할 수 없다
Kotlin의 특징: open 키워드
  • Kotlin에서는 모든 클래스, 함수, 프로퍼티가 기본적으로 final이다. 즉, 별도의 지시어 없이는 오버라이드 될 수 없다
  • 따라서 상속받는 클래스에서 프로퍼티나 함수를 오버라이드하려면 상위 클래스에서 해당 멤버에 open 키워드를 명시적으로 붙여야 한다
  • 장점
    • API 안정성: final-by-default 원칙은 “Fragile Base Class” 문제를 방지하여 상위 클래스의 변경이 하위 클래스에 의도치 않은 영향을 미치는 것을 막아준다
      • Fragile Base Class: 취약한 기본 클래스 문제는 파생 클래스에 의해 상속될 때 기본 클래스에 대한 겉보기에 안전한 수정으로 인해 파생 클래스가 오작동할 수 있기 때문에 기본 클래스가 “취약한” 것으로 간주되는 객체 지향 프로그래밍 시스템의 근본적인 아키텍처 문제입니다.
    • 우발적 오버라이딩 방지: 개발자의 의도를 명확히 하여 예상치 못한 오버라이딩으로 인한 오류를 줄여준다
    • 성능 최적화: 컴파일러가 final 멤버에 대해 디버추얼리제이션(Devirtualization)과 같은 최적화를 적용하여 런타임 성능을 향상시킬 수 있다
      • 디버추얼리제이션: 컴파일러가 가상 메서드 호출(Virtual Method Call)을 직접 메서드 호출(Direct Method Call)로 변환하는 최적화 기법
        • 가상 메서드 호출: 런타임에 실제 타입을 확인해서 메서드를 호출
        • 직접 메서드 호출: 컴파일 타임에 이미 어떤 메서드를 호출할지 확정
      • 캡슐화 강화: 상속 설계를 더욱 명확하고 안전하게 만든다
디버추얼리제이션
// 가상 메서드 호출 - "누구의 메서드를 호출해야 하는건가?" (런타임 결정)

open class Animal {
    open fun sound() = "동물 소리"
}

class Dog: Animal() {
    override fun sound() = "멍멍"
}

val anumal: Anumal = Dog() // 컴파일 타임에는 Animal 타입
animal.sound() // 런타임에 "실제로는 Dog구나" 확인 후 Dog의 sound() 호출

// 1. 객체의 vtable(가상 메서드 테이블) 조회
// 2. "이 책체는 실제로 Dog 타입이구나" 확인
// 3. Dog의 sound() 메서드 주소 찾아서 호출

// vtable 조회 필요(느림), 메모리 접근 2번, 추가 최적화 어려렵다



// 직접 메서드 호출 - "이미 정해져 있다" 컴파일 타임 결정

class Dog { // final 클래스
    fun sound() = "멍멍" // final 메서드
}

val dog = Dog()
dog.sound() // 컴파일 시점에 이미 "Dog의 sound() 호출"이 확정된다

// 1. 컴파일 타임에 메서드 주소 확정
// 2. 런타임에 바로 해당 주소로 점프해서 실행

// 바로 메서드 실행(빠름), 메모리 접근 1번, 인라이닝 등 추가 최적화 가능
abstract class Animal(
    protected val species: String,
    protected open val legCount: Int / legCount는 하위 클래스에서 오버라이드 될 수 있음을 명시
) {
    abstract fun move() // 추상 메서드
}

class Cat(
    species: String
) : Animal(species, 4) { // Cat 클래스는 Animal을 상속받는다
    override fun move() { // move 메서드를 오버라이드
        println("고양이가 걷고 있습니다")
    }
}

class Penguin(
    species: String
) : Animal(species, 2) {
    
    private val wingCount: Int = 2

    override fun move() {
        println("팽귄이 걷고 있습니다")
    }

    // legCount 프로퍼티를 오버라이드. getter를 커스터마이징하여 wingCount를 추가
    override val legCount: Int
        get() = super.legCount + this.wingCount
}
Java 코드와 비교 (상속 및 오버라이드 표기)
  • Java: public class Cat extends Animal { @Override public void move() { … } }
  • Kotlin: class Cat(…) : Animal(…) { override fun move() { … } }
    • Kotlin은 @Override 어노테이션 대신 override 지시어를 사용하며, 이는 필수이다
    • 상속 시 뒤에 상위 클래스를 호출한다

인터페이스(Interface)

인터페이스는 클래스가 구현해야 할 행동을 정의하는 계약이다. Java와 Kotlin 모두 인터페이스는 직접 인스턴스화 할 수 없다. 구현(메서드 본문)을 가질 수 있지만, 인스턴스 상태(필드)를 가질 수 없으며, 구현 클래스를 통해서만 객체를 만들 수 있다

  • 인스턴스화 불가: 인터페이스는 불완전한 계약이므로 직접 객체를 생성할 수 없다
  • Kotlin의 특징
    • backing field 없는 프로퍼티: Kotlin 인터페이스는 backing field 없이 프로퍼티를 선언할 수 있으며, 이 프로퍼티의 getter 구현은 인터페이스를 상속받는 클래스에게 위임된다
    • 기본 구현 제공: Java 8 이후와 마찬가지로, Kotlin 인터페이스도 메서드와 프로퍼티에 기본 구현을 제공할 수 있다. 이 경우 구현 클래스에서 해당 멤버를 오버라이드 하지 않아도 된다
interface Swimable {
    val swimAbility: Int // backing field 없는 프로퍼티. 구현 클래스에서 정의해야 한다
        // get() = 3 // 기본 구현 제공 시, 구현 클래스에서 오버라이즈하지 않아도 된다

    fun act() { // 기본 구현을 가진 메서드
        println(swimAbility)
        println("수영수영")
    }    
}

interface Flyable {
    fun act() { // 기본 구현을 가진 다른 메서드
        println("날아날아")
    }
}

class Penguin(
    species: String,
) : Animal(species, 2), Swimable, Flyable { // 여러 인터페이스 구현 가능

    private val wingCount: 2

    override fun move() {
        println("팽현숙 귄카")
    }

    override val legCount: Int
        get() = super.legCount + this.wingCount

    // act() 메서드가 Swimable과 Flyable 두 인터페이스에 모두 존재하므로 명시적으로 구현해야 한다
    override fun act() {
        super<Swimable>.act() // Swimable 인터페이스의 act() 호출
        super<Flyable>.act() // Flyable 인터페이스의 act() 호출
    }

    override val swimAbility: Int
        get() = 3 // Swimable 인터페이스의 swimAbility 프로퍼티 구현
}
Java 코드와 비교 (다중 상속 및 인터페이스 구현)
  • Java: 클래스 상속은 extends (확장), 인터페이스는 구현 implements (구현)을 사용한다
    • public class JavaPenguin extends JavaAnimal implements JavaFlyable, JavaSwimable { … }
  • Kotlin: 하나로 클래스 상속과 인터페이스 구현을 모두 처리한다
    • class Penguin (…) : Animal(…), Swimable, Flyable { … }
  • 중복되는 인터페이스 메서드: Kotlin에서는 여러 인터페이스에 동일한 이름의 기본 구현 메서드가 있을 경우, super<타입>.함수() 문법을 사용하여 어떤 인터페이스의 구현을 호출할지 명확히 지정할 수 있다

클래스 상속 시 주의할 점: 초기화 순서 문제

Kotlin에서 클래스 상속 시 초기화 순서로 인해 예상치 못한 동작이 발생할 수 있다

초기화 순서

  • 1. 상위 클래스 생성자 및 init 블록 실행
  • 2. 하위 클래스 생성자 및 init 블록 실행

문제의 원인: open 프로퍼티 접근

  • 상위 클래스의 생성자나 init 블록에서 open으로 선언된 프로퍼티에 접근할 때 문제가 발생할 수 있다. 상위 클래스가 초기화되는 시점에는 하위 클래스에서 오버라이드 될 프로퍼티가 아직 초기화되지 않은 상태이기 때문이다. 이때 해당 프로퍼티는 해당 타입의 기본값(예: Int의 경우 0)을 가진다
fun main() {
    Derived(300)
    // 예상 출력: Base Class, 300, Derived Class
    // 실제 출력: Base Class, 0, Derived Class
}

open class Base(
    open val number: Int = 100 // 하위 클래스에서 오버라이드 될 수 있음
) {

    init {
        println("Base Class")
        println(number) // 문제 발생 지점: 이때 numers는 아직 Derived에서 초기화되지 않음
    }

}

open class Derived(
    open val number: Int // 상위 클래스 생성자에 number를 오버라이드
) : Base(number) { // 상위 클래스 생성자에 number 값을 전달

    init {
        println("Derived Class")
    }

}
핵심 주의사항 및 해결챌
  • 상위 클래스 설계 시: 생성자나 init 블록 안에서는 하위 클래스에서 오버라이드 될 가능성이 있는 open 프로퍼티에 접근하지 않는 것이 안전하다. 이는 예측 불가능한 동작을 유발할 수 있다
  • 권장 사항: 상위 클래스의 초기화 로직에 사용되는 프로퍼티는 open 키워드를 사용하지 않거나, 생성자 파라미터로만 받아서 내부적으로 사용하도록 설계하는 것이 좋다
open class Base(val number: Int = 100) { // number를 final로 설계 (기본값)
    init {
        println("Base Class")
        println(number) // 안전하게 예상 값 출력 (Derived에서 오버라이드 할 수 없음)
    }
}

상속 관련 지시어 정리

  • final: (기본값) 오버라이드를 허용하지 않는다. 모든 클래스, 함수, 프로퍼티에 명시적으로 붙이지 않아도 기본적으로 적용된다
  • open: final의 반대이다. 클래스, 함수, 프로퍼티가 하위 클래스에서 오버라이드 될 수 있도록 허용한다
  • abstract: 추상 클래스나 인터페이스에서 사용되며, 반드시 하위 클래스나 구현 클래스에서 구현(오버라이드)해야 하는 멤버를 선언할 때 사용한다. abstract 멤버는 open으로 간주된다
  • override: 상위 타입(클래스 또는 인터페이스)의 멤버를 오버라이드하고 있음을 명시하는 지시어이다. Kotlin에서는 필수로 사용해야 한다

Kotlin의 상속은 Java의 개념을 기반으로 하지만 final-by-default 원칙과 open 키워드를 통해 더 안전하고 명확한 상속 설계를 유도한다. 초기화 순서에 대한 이해화 open 프로퍼티 사용 시 주의사항을 숙지하면, 견고하고 유지보수하기 쉬운 객체 지향 코드를 작성할 수 있다

출처 – 인프런 강의 중 자바 개발자를 위한 코틀린 입문(Java to Kotlin Starter Guide)