코틀린을 배우기 위해서 인프런에서 강의를 구매하고 코틀린과 친해지고 기본기를 다지기 위해서 공부하는 중이다. 글 내용은 상속을 다루는 방법이고 최태현님의 자바 개발자를 위한 코틀린 입문(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)로 변환하는 최적화 기법
- 가상 메서드 호출: 런타임에 실제 타입을 확인해서 메서드를 호출
- 직접 메서드 호출: 컴파일 타임에 이미 어떤 메서드를 호출할지 확정
- 캡슐화 강화: 상속 설계를 더욱 명확하고 안전하게 만든다
- 디버추얼리제이션: 컴파일러가 가상 메서드 호출(Virtual Method Call)을 직접 메서드 호출(Direct Method Call)로 변환하는 최적화 기법
- API 안정성: final-by-default 원칙은 “Fragile Base Class” 문제를 방지하여 상위 클래스의 변경이 하위 클래스에 의도치 않은 영향을 미치는 것을 막아준다
디버추얼리제이션
// 가상 메서드 호출 - "누구의 메서드를 호출해야 하는건가?" (런타임 결정)
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)