상속과 인터페이스 — open, override, 그리고 다중 상속 문제 해결
코틀린에서 모든 클래스는 기본적으로 final이다. 상속을 허용하려면 명시적으로 open을 붙여야 한다. "왜 이렇게 설계했을까?"라는 질문은 면접에서 단골로 나온다. 상속, 인터페이스, 그리고 다중 상속 문제까지 — 코틀린이 이 문제들을 어떻게 풀었는지 정리해보자.
open 키워드 — 기본이 final인 이유
// 기본적으로 final — 상속 불가
class Animal
// class Dog : Animal() // 컴파일 에러!
// open으로 상속 허용
open class Animal2 {
open fun speak() = "..." // 오버라이드 허용
fun breathe() = "숨 쉬기" // 오버라이드 불가
}
class Dog : Animal2() {
override fun speak() = "멍멍" // OK
// override fun breathe() = "..." // 컴파일 에러!
}
왜 기본이 final일까?
Effective Java에서 Joshua Bloch는 "상속을 위한 설계를 하거나, 아니면 상속을 금지하라"고 말했다. 코틀린은 이 원칙을 언어 차원에서 적용했다.
상속이 위험한 이유는 다음과 같다.
- 상위 클래스 변경 시 하위 클래스가 깨질 수 있다 (취약한 기반 클래스 문제)
- 개발자가 의도하지 않은 오버라이드가 발생할 수 있다
- 클래스의 불변 조건(invariant)이 하위 클래스에 의해 깨질 수 있다
상속의 기본 규칙
open class Shape(val name: String) {
open val area: Double = 0.0
open fun draw() {
println("$name 그리기")
}
}
class Circle(val radius: Double) : Shape("원") {
// 프로퍼티도 오버라이드 가능
override val area: Double
get() = Math.PI * radius * radius
override fun draw() {
println("반지름 $radius 인 원 그리기")
}
}
오버라이드 규칙 정리
open class Parent {
open fun method() { } // 오버라이드 가능
fun finalMethod() { } // 오버라이드 불가
open val property: Int = 0 // 오버라이드 가능
}
open class Child : Parent() {
override fun method() { } // 오버라이드 — 여전히 open
final override fun method() { } // 오버라이드하되, 하위에서는 금지
// val을 var로 오버라이드 가능 (getter에 setter 추가)
override var property: Int = 0
// 반대는 불가 — var를 val로 오버라이드할 수 없음
}
주의할 점이 있다. override한 멤버는 기본적으로 open이다. 더 이상 오버라이드하지 못하게 하려면 final override를 사용해야 한다.
val을 var로 오버라이드할 수 있는 이유
val은 getter만 있다. var로 오버라이드하면 setter를 추가하는 것이므로, 기존 계약을 위반하지 않는다. 반대로 var를 val로 바꾸면 setter가 사라지므로 계약 위반이다.
생성자에서의 상속
// 상위 클래스에 주 생성자가 있으면 바로 호출
open class Person(val name: String)
class Student(name: String, val grade: Int) : Person(name)
// 상위 클래스에 주 생성자가 없으면 보조 생성자에서 super 호출
open class View {
constructor(context: Context)
constructor(context: Context, attrs: AttributeSet)
}
class CustomView : View {
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
}
인터페이스 — 자바 8+와 비슷하지만 다르다
interface Clickable {
// 추상 메서드
fun click()
// default 구현이 있는 메서드 (자바처럼 default 키워드 불필요)
fun showOff() = println("클릭 가능!")
}
interface Focusable {
fun focus()
fun showOff() = println("포커스 가능!")
}
인터페이스 구현
class Button : Clickable, Focusable {
override fun click() = println("버튼 클릭")
override fun focus() = println("버튼 포커스")
// showOff()가 두 인터페이스에 모두 있으므로 반드시 오버라이드 필요
override fun showOff() {
super<Clickable>.showOff() // Clickable의 구현 호출
super<Focusable>.showOff() // Focusable의 구현 호출
}
}
다이아몬드 문제 해결 — super<T>
두 인터페이스에 같은 시그니처의 메서드가 있으면, 구현 클래스에서 반드시 오버라이드해야 한다. super<타입>으로 특정 상위 타입의 구현을 호출할 수 있다.
interface A {
fun greet() = println("A의 인사")
}
interface B {
fun greet() = println("B의 인사")
}
class C : A, B {
// 반드시 오버라이드 — 어떤 구현을 쓸지 명시해야 함
override fun greet() {
super<A>.greet() // "A의 인사"
}
}
인터페이스의 프로퍼티
interface Named {
val name: String // 추상 프로퍼티 — 구현 클래스에서 제공
val greeting: String // backing field 없는 computed 프로퍼티
get() = "안녕, $name"
// val state: String = "기본값" // 컴파일 에러! 인터페이스는 backing field 불가
}
class User(override val name: String) : Named
// user.greeting → "안녕, 코틀린"
인터페이스에서 프로퍼티를 선언할 수 있지만, backing field는 가질 수 없다. 이것이 abstract class와의 핵심 차이다.
abstract class — 상태를 가진 불완전한 클래스
abstract class Animal(val name: String) {
// 상태를 가질 수 있음 (backing field)
var energy: Int = 100
// 추상 메서드 — open 불필요 (기본이 open)
abstract fun speak(): String
// 일반 메서드
fun eat() {
energy += 10
println("$name 이(가) 먹이를 먹었다. 에너지: $energy")
}
}
class Cat(name: String) : Animal(name) {
override fun speak() = "야옹"
}
abstract class vs interface — 선택 기준
면접에서 이 질문이 나오면 다음 기준으로 답하면 된다.
interface를 선택하는 경우
- 여러 클래스에 공통 행위(계약)를 정의할 때
- 다중 구현이 필요할 때
- 상태가 필요 없을 때
abstract class를 선택하는 경우
- 공통 상태(필드)를 하위 클래스와 공유할 때
- 생성자 파라미터가 필요할 때
- 관련 클래스들 사이의 IS-A 관계가 명확할 때
// 인터페이스가 적합한 경우 — 행위의 계약
interface Drawable {
fun draw()
}
interface Resizable {
fun resize(factor: Double)
}
// 추상 클래스가 적합한 경우 — 공통 상태 + 템플릿
abstract class Shape(val color: String) {
var x: Double = 0.0 // 공통 상태
var y: Double = 0.0
abstract fun area(): Double
fun moveTo(newX: Double, newY: Double) {
x = newX
y = newY
}
}
// 둘 다 사용
class Circle(color: String, val radius: Double) : Shape(color), Drawable, Resizable {
override fun area() = Math.PI * radius * radius
override fun draw() = println("원 그리기")
override fun resize(factor: Double) { /* ... */ }
}
상속에서 주의할 점
생성자에서 open 멤버 사용 금지
open class Parent {
open val value: Int = 1
init {
// 위험! 하위 클래스에서 오버라이드한 프로퍼티가 아직 초기화되지 않았을 수 있음
println("Parent init: value = $value")
}
}
class Child : Parent() {
override val value: Int = 42
init {
println("Child init: value = $value")
}
}
// Child() 출력:
// Parent init: value = 0 ← 예상: 42지만 아직 초기화 안 됨!
// Child init: value = 42
상위 클래스의 init 블록이 실행될 때 하위 클래스의 프로퍼티는 아직 초기화되지 않았다. 이것은 면접에서도 자주 나오는 함정 문제다.
상속 깊이를 제한하자
// 과도한 상속 — 유지보수 어려움
open class A
open class B : A()
open class C : B()
open class D : C() // 상속 체인이 너무 깊음
// 더 나은 접근 — 조합(composition) 사용
class Engine { fun start() { } }
class Car(private val engine: Engine) {
fun start() = engine.start()
}
"상속보다 조합"이라는 원칙은 코틀린에서도 유효하다. 코틀린의 위임 패턴(by 키워드)이 이를 지원한다.
interface SoundPlayer {
fun play()
}
class DefaultSoundPlayer : SoundPlayer {
override fun play() = println("기본 재생")
}
// 위임 — SoundPlayer 구현을 player에게 맡김
class MusicApp(player: SoundPlayer) : SoundPlayer by player
정리 — 면접에서 기억할 포인트
- 기본 final — Effective Java 원칙. open을 붙여야 상속 가능
- override 멤버는 기본 open — 하위에서 금지하려면
final override - val → var 오버라이드 가능 — getter에 setter 추가. 반대는 불가
- super<T> — 다이아몬드 문제 해결. 두 인터페이스에 같은 메서드가 있으면 명시 필요
- interface vs abstract — 상태가 필요하면 abstract, 계약만 필요하면 interface
- 생성자에서 open 멤버 주의 — 상위 init 실행 시 하위 프로퍼티는 미초기화 상태