KB 14 상속 — 클래스는 기본이 final 이다
고친 사람 github-actions[bot]
0. 상속 한 줄이 거절된다
자바 개발자에게 제일 익숙한 상속 예제를 코틀린으로 옮겼습니다. 동물 클래스 하나와, 그걸 물려받는 개 클래스 하나입니다.
class Animal {
fun sound(): String {
return "..."
}
}
class Dog : Animal()
class Animal { ... }— 클래스 선언입니다. 수식어를 안 쓰면public입니다fun sound(): String— 함수 선언입니다. 괄호 뒤의: String이 반환 타입입니다class Dog : Animal()—Dog가Animal을 물려받는다는 선언으로, 자바의extends Animal에 해당합니다. 뒤의 괄호가 무슨 뜻인지는 2절에서 풉니다
이 파일을 코틀린 컴파일러 kotlinc 에 넣었습니다. -d out 은 결과물을 out 폴더에 쓰라는 옵션입니다.
$ kotlinc Animal.kt -d out
Animal.kt:7:13: error: this type is final, so it cannot be extended.
class Dog : Animal()
^^^^^^
「이 타입은 final 이라 확장할 수 없다」는 에러입니다. Animal 어디에도 final 을 쓴 적이 없는데요.
같은 두 클래스를 자바로 옮겨 Animal.java 와 Dog.java 에 나눠 담았습니다.
public class Animal {
public String sound() {
return "...";
}
}
public class Dog extends Animal {
}
javac -d jout Animal.java Dog.java 는 아무 말 없이 통과합니다. 자바에서는 final 을 써야 상속을 막는데, 코틀린은 아무것도 안 쓰면 상속할 수 없습니다.
이 편은 이 차이 하나에서 출발합니다. 무엇이 막혔는지와 어떻게 여는지(1~2절), 연 뒤에 무엇을 오버라이드할 수 있는지(3~4절)를 봅니다. 클래스를 물려받아 동작하는 스프링과 부딪히는 곳(5절), 열어 둔 대가로 생기는 함정(6절)까지 갑니다. 출력은 전부 Kotlin 2.4.10, JDK 21 에서 직접 돌린 결과입니다.
1. 기본 final 과 open
0절의 에러가 말한 final 이 클래스 파일에 정말 적혀 있는지 봅니다. 상속을 허용하는 키워드 open 도 함께 써서, 붙인 곳과 안 붙인 곳을 나란히 놓습니다.
open class Animal {
open fun sound(): String {
return "..."
}
fun name(): String {
return "animal"
}
}
class Dog : Animal()
open class— 물려받을 수 있는 클래스라는 표시입니다open fun sound()— 하위 클래스가 오버라이드할 수 있는 함수라는 표시입니다fun name(),class Dog— 아무 수식어도 붙이지 않았습니다
이번에는 Dog 줄까지 통과합니다. 0절에서 거절당한 그 한 줄이 open 한 낱말로 열렸습니다. 자바 쪽은 final 을 하나도 안 썼는데도 0절의 javac 가 class Dog extends Animal 을 통과시켰으니, 열고 닫는 기본값이 정반대입니다.
컴파일러가 말한 그 final 이 클래스 파일에도 정말 적혀 있는지 봅니다. JDK 에 딸린 javap 는 클래스 파일의 모양을 자바 선언문처럼 보여 주는 도구이고, -p 는 private 멤버까지 보여 달라는 옵션입니다.
$ javap -p out/Animal.class out/Dog.class
Compiled from "Animal.kt"
public class Animal {
public Animal();
public java.lang.String sound();
public final java.lang.String name();
}
Compiled from "Animal.kt"
public final class Dog extends Animal {
public Dog();
}
final 이 붙은 줄은 둘입니다. 수식어 없이 선언한 name() 함수와 Dog 클래스입니다. 수식어를 안 쓰면 클래스에도 함수에도 final 이 붙습니다(코틀린 문서). open 을 붙인 Animal 과 sound() 에서만 final 이 빠졌습니다.
Dog 는 한 번 더 봅니다. Animal 에 open 을 붙였어도 Dog 는 public final class 입니다. open 은 하위 클래스로 이어지지 않습니다. Dog 를 또 물려받게 하려면 Dog 에도 open 을 붙여야 합니다.
왜 기본을 뒤집었을까요. 상속을 염두에 두지 않은 클래스를 누가 물려받으면, 부모를 고칠 때 자식이 깨질 수 있습니다. 『Effective Java』 3판 아이템 19의 제목 「Design and document for inheritance or else prohibit it」은 상속을 고려해 설계하지 않았다면 상속을 금지하라는 권고입니다. 코틀린의 기본값은 이 권고의 금지 쪽과 같은 방향입니다.
코틀린의 클래스와 멤버는 기본이 final 이고, open 을 붙인 곳만 상속하거나 오버라이드할 수 있습니다. open 을 붙인 코틀린 선언은 클래스 파일에서 final 없이 쓴 자바 선언과 같아집니다.
2. 콜론 문법, 상위 생성자 호출, override
open 으로 열었으니 이제 물려받는 쪽입니다. 자바라면 컴파일러가 알아서 해 주거나 눈감아 주던 것을 코틀린은 에러로 막습니다.
생성자 인자를 받는 Animal 과, 일부러 틀리게 쓴 하위 클래스 셋을 Errors.kt 에 담았습니다.
open class Animal(val name: String) {
open fun sound(): String {
return "..."
}
fun eat(): String {
return "eat"
}
}
class NoCall : Animal
class NoOverride(name: String) : Animal(name) {
fun sound(): String {
return "Woof"
}
}
class EatMore(name: String) : Animal(name) {
override fun eat(): String {
return "eat more"
}
}
class Animal(val name: String)— 클래스 이름 뒤 괄호가 주 생성자(primary constructor)입니다. 생성자 인자를 클래스 헤더(class header)에 바로 적습니다val name—val은 다시 대입할 수 없는 값이라는 표시입니다(자바의final필드와 비슷). 주 생성자 인자에val을 붙이면 그 인자가 프로퍼티가 됩니다. 프로퍼티는 필드와 게터를 한 이름으로 묶은 선언입니다class NoCall : Animal— 0절과 달리Animal뒤 괄호를 뺐습니다class NoOverride(name: String) : Animal(name)— 받은name을Animal의 생성자에 넘깁니다.val이 없으니 이name은 생성자 인자일 뿐입니다. 안에서는sound()를override없이 다시 썼습니다override fun eat()—override는 상위 클래스의 함수를 오버라이드한다는 표시입니다.EatMore는open을 안 붙인eat()에 이 표시를 달았습니다
Errors.kt 를 컴파일했습니다. 같은 줄을 가리키는 첫 에러의 소스 줄과 표시는 ... 로 줄였습니다.
$ kotlinc Errors.kt -d out
Errors.kt:11:16: error: no value passed for parameter 'name'.
...
Errors.kt:11:16: error: this type has a constructor, so it must be initialized here.
class NoCall : Animal
^^^^^^
Errors.kt:14:9: error: 'sound' hides member of supertype 'Animal' and needs an 'override' modifier.
fun sound(): String {
^^^^^
Errors.kt:20:5: error: 'eat' in 'Animal' is final and cannot be overridden.
override fun eat(): String {
^^^^^^^^
하위 클래스 셋이 에러 넷으로 모두 거절됐습니다.
no value passed for parameter 'name'—NoCall이Animal의 생성자 인자name에 값을 안 넘겼다는 뜻입니다this type has a constructor, so it must be initialized here— 상위 클래스에 생성자가 있으니 여기서 부르라는 뜻입니다'sound' hides member of supertype 'Animal' and needs an 'override' modifier—NoOverride의sound()가 상위 클래스의 같은 함수를 가리고 있으니override를 붙이라는 뜻입니다'eat' in 'Animal' is final and cannot be overridden—EatMore가 오버라이드하려 한eat()은open이 없어final이라는 뜻입니다
이것이 콜론 뒤 괄호의 정체입니다. : Animal(name) 은 extends Animal 과 super(name) 을 한 줄에 쓴 것입니다. 0절의 Animal() 괄호도 인자 없는 생성자 호출이었습니다.
자바가 통과시키는 둘
같은 셋을 자바로 옮겼습니다. Animal 은 이렇게 생겼습니다.
public class Animal {
public Animal(String name) {
}
public String sound() {
return "...";
}
public final String eat() {
return "eat";
}
}
하위 클래스도 한 줄씩 옮겼습니다. NoCall 은 super(...) 호출 없이 extends Animal 만 합니다. NoOverride 는 @Override 없이 sound() 를 다시 씁니다. EatMore 는 eat() 를 다시 씁니다.
$ javac -d jout j/*.java
j/EatMore.java:6: error: eat() in EatMore cannot override eat() in Animal
public String eat() {
^
overridden method is final
j/NoCall.java:1: error: constructor Animal in class Animal cannot be applied to given types;
public class NoCall extends Animal {
^
required: String
found: no arguments
reason: actual and formal argument lists differ in length
2 errors
에러는 둘뿐입니다. NoOverride 는 통과했습니다. 자바의 @Override 는 붙이면 검사해 주는 선택 사항이라, 빠져도 오버라이드는 됩니다.
자바 NoCall 이 거절된 이유는 Animal 에 인자 없는 생성자가 없어서입니다. 생성자를 따로 쓰지 않은 하위 클래스에는 javac 가 인자 없는 생성자를 만들어 넣고, 그 안에서 super() 를 부르게 합니다.
그래서 인자 없는 판으로 다시 재 봤습니다. 코틀린은 open class Animal 과 class Dog : Animal 을 컴파일했습니다. 자바는 빈 Animal 과 class Dog extends Animal {} 을 컴파일했습니다.
$ kotlinc NoArg.kt -d out
NoArg.kt:3:13: error: this type has a constructor, so it must be initialized here.
class Dog : Animal
^^^^^^
$ javac -d jout j/*.java
자바는 아무 말 없이 통과합니다. 코틀린은 인자 없는 생성자라도 괄호를 빼면 에러입니다. 자바가 알아서 넣어 주던 super() 를 코틀린은 () 로 직접 쓰게 합니다.
| 틀리게 쓴 클래스 | kotlinc |
javac |
|---|---|---|
NoCall — 인자 있는 상위 생성자를 안 부름 |
에러 | 에러 |
Dog — 인자 없는 상위 생성자를 안 부름 |
에러 | 통과 |
NoOverride — 오버라이드 표시가 없음 |
에러 | 통과 |
EatMore — final 메서드를 오버라이드 |
에러 | 에러 |
클래스 헤더에 쓰는 이 규칙에는 예외가 하나 있습니다. 부 생성자는 클래스 본문 안에 constructor 키워드로 쓰는 생성자입니다(KB 13). 주 생성자 없이 부 생성자만 둔 클래스는 각 부 생성자가 constructor() : super() 처럼 상위 생성자를 부릅니다.
final override — 손자 클래스의 오버라이드를 막는다
override 를 붙인 함수는 open 을 따로 쓰지 않아도 다시 오버라이드할 수 있습니다. final 없이 override fun sound() 만 쓴 open class Dog 를 javap -p 로 열면 public java.lang.String sound(); 로 final 이 없습니다.
손자 클래스의 오버라이드까지 막으려면 final override fun sound() 로 씁니다. 그렇게 쓴 open class Dog 를 Puppy 가 물려받아 override fun sound() 를 쓰면 'sound' in 'Dog' is final and cannot be overridden. 으로 거절됩니다. 자바라면 public final String sound() 로 쓰는 일입니다.
코틀린은 상위 생성자 호출을 클래스 헤더에 쓰게 하고, override 를 빼먹는 것을 에러로 막습니다. override 한 함수는 다시 오버라이드할 수 있으니, 거기서 멈추려면 final override 를 씁니다.
3. super 호출과 프로퍼티 오버라이드
오버라이드한 함수에서 부모 구현을 부르는 법은 자바와 같습니다. 자바에 없는 것은 값을 오버라이드하는 것입니다.
open class Animal {
open val sound: String = "hmm"
open fun describe(): String {
return "I say " + sound
}
}
class Dog : Animal() {
override val sound: String = "Woof"
override fun describe(): String {
return super.describe() + "!"
}
}
fun main() {
val a: Animal = Dog()
println(a.describe()) // I say Woof!
println(a.sound) // Woof
}
open val sound: String = "hmm"— 오버라이드할 수 있게 연 프로퍼티입니다override val sound— 하위 클래스에서 같은 프로퍼티를 다른 값으로 오버라이드합니다super.describe()— 상위 클래스의 구현을 부릅니다. 자바의super.describe()와 같습니다val a: Animal = Dog()— 이름 뒤 콜론에 변수 타입을 씁니다. 객체를 만들 때new는 없습니다
클래스 밖, 파일 바로 아래 최상위(top-level)에 쓴 main 은 컴파일러가 파일 이름 + Kt 로 지은 클래스에 들어갑니다. 그래서 Dog.kt 의 main 은 DogKt 로 돌립니다. kotlin 은 코틀린 설치에 딸린 실행 명령으로, 표준 라이브러리 jar 를 classpath 에 스스로 넣어 줍니다.
$ kotlinc Dog.kt -d out
$ kotlin -cp out DogKt
describe() 안의 sound 는 Animal 의 코드인데 Dog 의 Woof 를 읽었습니다. 타입이 Animal 인 변수 a 로 읽은 a.sound 도 Woof 입니다.
같은 모양을 자바 필드로 썼습니다. Main 의 main 에서 Animal a = new Dog() 로 만든 뒤 a.describe() 와 a.sound 를 찍습니다.
class Animal {
String sound = "hmm";
String describe() {
return "I say " + sound;
}
}
class Dog extends Animal {
String sound = "Woof";
@Override
String describe() {
return super.describe() + "!";
}
}
$ javac -d jout Main.java
$ java -cp jout Main
I say hmm!
hmm
자바는 둘 다 hmm 입니다. 자바의 필드는 오버라이드되지 않습니다. 같은 이름의 필드가 부모 것을 가릴 뿐입니다. 그래서 Animal 의 코드와 Animal 타입 변수는 Animal 의 필드를 읽습니다.
같은 이름인데 왜 자바에서만 값이 안 바뀌나
갈린 자리는 sound 라는 이름이 무엇으로 컴파일됐는지입니다. 코틀린 쪽 두 클래스의 모양부터 봅니다.
$ javap -p out/Animal.class out/Dog.class
...
Compiled from "Dog.kt"
public final class Dog extends Animal {
private final java.lang.String sound;
public Dog();
public java.lang.String getSound();
public java.lang.String describe();
}
두 클래스 모두 private 필드 sound 와 게터 getSound() 를 가집니다. Dog 객체 안에는 hmm 이 든 Animal 의 필드와 Woof 가 든 Dog 의 필드가 함께 있습니다. Animal 쪽은 public class Animal 에 같은 멤버 넷이라 ... 로 줄였습니다.
필드는 private 이라 오버라이드와 상관이 없습니다. 오버라이드된 것은 Dog 의 getSound() 입니다. 자바 쪽 sound 는 게터 없는 맨 필드였고, 그래서 Animal 의 코드가 Animal 의 필드를 읽어 hmm 이 나왔습니다.
그러면 자바도 게터로 바꾸면 되는지 재 봤습니다. 앞의 자바 두 클래스에서 sound 필드 대신 getSound() 메서드를 두고 Dog 에서 그 메서드를 오버라이드하니, 자바 main 도 I say Woof! 와 Woof 를 찍었습니다.
코틀린의 프로퍼티 오버라이드는 게터 오버라이드입니다. 부모 코드가 프로퍼티를 읽는 곳도 게터를 거치니 하위 클래스의 값이 보입니다. 자바에서는 손으로 게터를 두어야 얻는 동작을, 코틀린은 프로퍼티라는 이름으로 처음부터 해 둡니다.
4. abstract 클래스
부모가 구현을 정할 수 없는 함수가 있을 때 쓰는 것이 abstract 입니다. 키워드는 자바와 같으니, abstract 클래스 안의 일반 함수를 오버라이드할 수 있는지만 확인합니다.
abstract class Shape {
abstract fun area(): Double
fun describe(): String {
return "area=" + area()
}
}
class Square(val side: Double) : Shape() {
override fun area(): Double {
return side * side
}
}
abstract class Shape— 객체를 직접 만들 수 없는 클래스입니다.open을 안 붙여도 물려받을 수 있습니다abstract fun area(): Double— 본문이 없는 함수입니다.open없이도 오버라이드할 수 있습니다. 하위 클래스는 이 함수를 반드시 구현해야 합니다Double— 자바의double에 해당하는 코틀린 타입입니다
이대로는 통과합니다. 여기에 Square 가 override fun describe() 를 한 줄 더 쓰게 고쳐 컴파일했습니다.
$ kotlinc Shape.kt -d out
Shape.kt:14:5: error: 'describe' in 'Shape' is final and cannot be overridden.
override fun describe(): String {
^^^^^^^^
area() 는 열려 있는데 describe() 는 막혔습니다. abstract 클래스 안이라도 일반 함수는 기본 규칙대로 final 이 붙습니다. 그 한 줄을 도로 빼고 컴파일한 클래스 파일에도 그대로 적혀 있습니다.
$ javap -p out/Shape.class
Compiled from "Shape.kt"
public abstract class Shape {
public Shape();
public abstract double area();
public final java.lang.String describe();
}
같은 Shape 를 자바로 썼습니다. area() 는 public abstract double area();, describe() 는 public String describe() 로 선언하고, Square 가 둘 다 오버라이드하게 했습니다.
$ javac -d jout j/*.java
아무 말 없이 통과합니다. 자바의 일반 메서드는 abstract 클래스 안에서도 오버라이드할 수 있습니다. 코틀린은 open 을 붙여야 합니다. abstract 는 클래스와 추상 함수를 열어 주지만 일반 함수까지 열지는 않습니다.
Shape() 로 객체를 만들면 자바처럼 막힙니다. 코틀린의 에러는 cannot create an instance of an abstract class. 입니다.
인터페이스는 뒤 편에서 다룹니다.
5. 스프링 프록시와 plugin.spring
기본 final 이 실무에서 크게 부딪히는 곳 하나가 스프링입니다. 스프링은 @Transactional 같은 기능을 흔히 대상 클래스를 물려받은 하위 클래스로 구현하기 때문입니다.
프록시(proxy)는 진짜 객체 앞에 서서 호출을 대신 받는 객체입니다. 받은 호출의 앞뒤에 트랜잭션 시작과 커밋 같은 일을 끼우고, 가운데서 진짜 객체의 코드를 부릅니다.
스프링 프레임워크는 대상 클래스가 인터페이스를 하나도 구현하지 않으면 CGLIB 로 프록시를 만듭니다. CGLIB 는 실행 중에 클래스를 만들어 내는 라이브러리이고, 그렇게 만든 프록시는 대상 클래스의 하위 클래스입니다(스프링 문서).
인터페이스를 구현했다고 안심할 수는 없습니다. 스프링 부트는 설정에 따라 인터페이스가 있어도 클래스 기반 프록시를 기본으로 켤 수 있습니다(스프링 문서). 그래서 부트에서는 인터페이스 유무와 상관없이 이 문제를 만날 수 있습니다.
이 절이 쓸 예제를 먼저 세웁니다. 스프링 jar 없이 재 보려고, 스프링과 패키지·이름이 같은 애노테이션 org.springframework.stereotype.Component 를 직접 만들었습니다. 이름만 흉내 낸 스텁이고 스프링 코드는 하나도 없습니다. 이 스텁을 붙인 클래스 하나와 아무것도 안 붙인 클래스 하나를 app/OrderService.kt 에 담았습니다.
import org.springframework.stereotype.Component
@Component
class OrderService {
fun place(): String {
return "ok"
}
}
class Plain {
fun hello(): String {
return "hi"
}
}
스프링이 OrderService 에 프록시를 씌우면 이런 모양이 됩니다.
flowchart TD
C["호출하는 코드"] --> P["프록시<br/>OrderService 를 물려받아<br/>실행 중에 만든 하위 클래스"]
P --> T1["트랜잭션 시작"]
T1 --> S["super.place()<br/>원래 OrderService 의 코드"]
S --> T2["커밋 또는 롤백"]
하위 클래스가 place() 를 오버라이드해야 앞뒤에 일을 끼울 수 있습니다. 그러니 final 클래스는 물려받을 수 없어 프록시를 못 만들고, final 메서드는 오버라이드할 수 없어 앞뒤에 일을 끼울 수 없습니다(스프링 문서). 코틀린 클래스를 그대로 두면 클래스도 함수도 final 입니다.
미확인 — 스프링을 실제로 띄웠을 때 나오는 에러 문구는 확인하지 않았습니다.
allopen 플러그인 — 애노테이션이 붙은 클래스만 연다
컴파일러 플러그인은 kotlinc 에 끼워 컴파일 결과를 바꾸는 확장입니다. allopen 은 그중 하나로, 지정한 애노테이션이 붙은 클래스와 그 멤버를 컴파일할 때 open 으로 만듭니다. 스프링 클래스마다 open 을 손으로 붙이지 않아도 됩니다.
앞의 OrderService.kt 를 플러그인 없이 컴파일하면 두 클래스 모두 public final class 로 나옵니다. 애노테이션만으로는 아무것도 안 열립니다. 이번에는 allopen 플러그인을 켜고 out2 에 컴파일했습니다. 스텁을 컴파일해 둔 stubout 폴더는 -cp 로 classpath 에 넣었습니다.
$ kotlinc -cp stubout app/OrderService.kt -d out2 -Xplugin=$KOTLIN_LIB/allopen-compiler-plugin.jar -P plugin:org.jetbrains.kotlin.allopen:preset=spring
$KOTLIN_LIB—kotlinc설치 폴더의lib/경로를 담으려고 직접 만든 셸 변수입니다. allopen 플러그인 jar 가 거기 들어 있습니다-Xplugin=— 컴파일러에 플러그인 jar 를 끼우는 옵션입니다-P plugin:org.jetbrains.kotlin.allopen:preset=spring— allopen 에 스프링용 애노테이션 목록을 쓰라고 넘기는 옵션입니다
결과 클래스 둘을 열었습니다.
$ javap -p out2/OrderService.class out2/Plain.class
Compiled from "OrderService.kt"
public class OrderService {
public OrderService();
public java.lang.String place();
}
Compiled from "OrderService.kt"
public final class Plain {
public Plain();
public final java.lang.String hello();
}
OrderService 에서만 final 이 빠졌습니다. 클래스에서도, place() 에서도 없습니다. Plain 은 public final 그대로입니다. 플러그인은 전체를 여는 게 아니라 애노테이션이 붙은 클래스만 엽니다.
preset=spring 은 열 애노테이션 목록을 미리 정해 둡니다. 애노테이션에 붙은 애노테이션, 곧 메타 애노테이션까지 따라갑니다(코틀린 문서).
preset=spring 이 여는 것 |
메타 애노테이션으로 따라 열리는 것 |
|---|---|
@Component |
@Configuration |
@Async |
@Controller |
@Transactional |
@RestController |
@Cacheable |
@Service |
@SpringBootTest |
@Repository |
스텁 @Component 를 붙인 스텁 @Service 로 재 보니 실제로 열렸습니다.
Gradle 빌드 스크립트(build.gradle.kts)에서는 명령줄 옵션 대신 plugins 블록에 kotlin("plugin.spring") version "2.4.10" 한 줄을 씁니다. plugin.spring 은 이 allopen 을 스프링용 설정으로 켜 주는 Gradle 플러그인이고, 버전은 프로젝트의 코틀린 버전과 맞춥니다.
자바에는 이런 플러그인이 필요 없습니다. 0절의 자바 Animal 처럼 클래스와 메서드에 처음부터 final 이 없어서, 개발자가 final 을 쓴 경우에만 같은 문제가 생깁니다. 스프링 부트에서 인터페이스를 붙인 클래스도 하위 클래스 프록시가 될 수 있다는 점은 두 언어가 같습니다.
6. 생성자에서 open 함수를 부르는 함정
열어 두면 대가도 있습니다. 부모의 생성자가 open 함수를 부르면, 아직 준비가 안 된 자식의 함수가 불립니다.
open class Base {
init {
// ↓ Base: label=null
println("Base: " + describe())
}
open fun describe(): String {
return "base"
}
}
class Derived : Base() {
val label: String = "derived"
override fun describe(): String {
return "label=" + label
}
}
fun main() {
val d = Derived()
// ↓ after: label=derived
println("after: " + d.describe())
}
$ kotlinc Derived.kt -d out
$ kotlin -cp out DerivedKt
init { ... }— 초기화 블록입니다. 객체를 만들 때 생성자 본문처럼 실행됩니다val label: String— 코틀린의String은null을 받지 않는 타입입니다.null을 허용하려면String?로 씁니다
null 을 받지 않는 String 에서 null 이 찍혔습니다. Kotlin 2.4.10 의 kotlinc 는 컴파일할 때 경고를 내지 않았습니다. describe() 가 label.length 를 읽게 바꾸면 NullPointerException 으로 멈춥니다. 메시지 끝은 because "this.label" is null 입니다.
생성 순서
원인은 객체를 만드는 순서입니다. javac 와 kotlinc 가 만든 생성자는 첫 일로 상위 생성자를 부르고, 필드 초기값 대입은 그 뒤에 둡니다.
flowchart TD
A["Derived() 호출"] --> B["Base 생성자 실행<br/>init 블록"]
B --> C["describe() 호출<br/>객체가 Derived 라 오버라이드된 쪽"]
C --> D["label 을 읽는다<br/>아직 대입 전이라 null"]
D --> E["Base 생성자 끝"]
E --> F["Derived 생성자 나머지<br/>label = derived 대입"]
클래스 파일을 자바 코드 모양으로 다시 적어 주는 디컴파일러 CFR 0.152 로 Derived 를 되돌리면 label 대입은 public Derived() { this.label = "derived"; } 로 생성자 안에 있습니다. CFR 은 생성자 첫 줄의 super() 호출을 자바 소스처럼 생략해 적습니다. 이 대입은 super() 가 끝난 뒤에 실행되므로, 그 전에 불린 describe() 는 필드의 기본값 null 을 읽습니다.
자바도 같다
자바로 같은 모양을 썼습니다. Base 는 생성자에서 System.out.println("Base: " + describe()) 를 부릅니다. Base 의 describe() 는 "base" 를 돌려줍니다. Derived 는 이렇습니다.
class Derived extends Base {
String label = "derived";
@Override
String describe() {
return "label=" + label;
}
}
Main 의 main 은 new Derived() 한 줄만 부릅니다.
$ javac -d jout Main.java
$ java -cp jout Main
Base: label=null
자바도 null 입니다. 두 컴파일러 모두 필드 초기값 대입을 super() 뒤에 두기 때문에 결과가 같습니다. 코틀린에서는 null 이 안 된다고 약속한 타입에 null 이 들어온다는 점이 더 아픕니다.
이 결과는 JDK 21 기준입니다. JDK 25 부터는 자바 소스에서도 초기값 없이 선언한 필드를 super() 앞에서 대입할 수 있습니다(JEP 513). 자바 쪽은 그렇게 피할 수 있습니다.
자바에는 빠져나갈 길이 하나 더 있습니다. 필드를 final String label = "derived"; 상수로 선언하면 javac 가 그 값을 읽는 곳마다 값을 그대로 박아 넣습니다. 이것을 상수 접기라고 부릅니다. 그러면 필드를 읽는 일 자체가 없어져 Base: label=derived 가 찍힙니다.
부모 생성자에서 부른 open 함수는 자식의 필드가 채워지기 전에 실행됩니다. 그래서 상위 클래스를 설계할 때는 생성자·프로퍼티 초기값·init 블록에서 open 멤버를 피합니다(코틀린 문서). 생성 중에 부를 함수는 final 함수로 둡니다. 열 함수를 고를 때 먼저 물어야 합니다 — 이 함수가 생성 중에 불리는가. 컴파일러 경고는 안 나옵니다.
7. 한 장 요약
코틀린의 클래스와 멤버는 기본이
final이다. 상속하려면 클래스에, 오버라이드하려면 함수와 프로퍼티에open을 붙인다.open을 붙인 코틀린 선언은 클래스 파일에서final없이 쓴 자바 선언과 같다. 프로퍼티 오버라이드는 게터 오버라이드다. 스프링 프록시가 물려받을 클래스는plugin.spring이 컴파일할 때 열어 준다.
| 항목 | 자바 | 코틀린 | 확인한 방법 |
|---|---|---|---|
| 클래스 기본 | 상속 가능 | final |
kotlinc 에러 · javap -p |
| 메서드 기본 | 오버라이드 가능 | final |
kotlinc 에러 · javap -p |
| 상속 문법 | extends Animal + super(name), 인자 없으면 super() 자동 |
: Animal(name), 인자 없어도 () 필수 |
kotlinc · javac 에러 |
| 오버라이드 표시 | @Override 선택 |
override 필수 |
javac · kotlinc 에러 |
| 오버라이드한 메서드 | 오버라이드 가능 | 오버라이드 가능, final override 로 막음 |
kotlinc 에러 · javap -p |
| 같은 이름의 값 | 필드는 가려질 뿐, 게터 메서드는 오버라이드 | 프로퍼티 오버라이드 = 게터 오버라이드 | 실행 · javap -p |
abstract 클래스의 일반 메서드 |
오버라이드 가능 | final |
kotlinc 에러 · javap -p |
| 스프링 프록시 대상 | 처음부터 final 아님 |
allopen preset=spring 이 애노테이션 붙은 클래스만 연다 |
javap -p (스프링 실행은 미확인) |
| 생성자에서 오버라이드된 메서드 호출 | 필드가 기본값(참조형은 null), 상수로 접히는 final 필드는 예외 |
String 에도 null · 2.4.10 kotlinc 경고 없음 |
실행 · CFR |
관련 항목
상속을 열고 닫는 코틀린 수식어
open · final · override · final override · abstract
상속 선언을 이루는 코틀린 문법 요소
주 생성자 · 상위 생성자 호출 · init 블록 · 프로퍼티 · 게터 · super
코틀린 상속과 맞세워지는 자바 규칙
extends · Override 애노테이션 · 필드 숨김 · 메서드 오버라이딩 · 컴파일 타임 상수
상속 대신 쓸 수 있는 설계 수단
인터페이스 · 추상 클래스 · 합성 · 취약한 기반 클래스 문제 · 템플릿 메서드 패턴
기본 final 을 푸는 컴파일러 플러그인
allopen · kotlin-spring · 컴파일러 플러그인 · 메타 애노테이션 · 스텁
코틀린 클래스를 물려받는 스프링 프록시
프록시 · 스프링 AOP · CGLIB · JDK 동적 프록시 · 트랜잭션
생성 순서에서 터지는 오류
생성자에서 오버라이드 가능 메서드 호출 · 초기화 순서 · NullPointerException · 널 안전성