이번 주 목표

1주차 계획은 주 생성자와 부 생성자, init 블록, 프로퍼티의 관계를 파악하는 것이었다. 특히 Hilt를 사용하면서 늘 궁금했던 의존성 주입 시점을 이해하기 위해 생성자의 실행 순서를 디컴파일로 확인하는 것이 핵심 목표였다.
주 생성자의 실행 순서
코틀린 클래스의 생성 과정을 이해하기 위해 간단한 코드를 작성하고 바이트코드로 변환해봤다.
class User(
val name: String,
age: Int
) {
val isAdult = age >= 18
init {
println("User created: $name")
}
val greeting = "Hello, $name"
}
이 코드를 자바로 디컴파일하면 다음과 같은 형태가 나온다.
public final class User {
@NotNull
private final String name;
private final boolean isAdult;
@NotNull
private final String greeting;
public User(@NotNull String name, int age) {
this.name = name;
this.isAdult = age >= 18;
System.out.println("User created: " + name);
this.greeting = "Hello, " + name;
}
}
여기서 중요한 점은 실행 순서다. 주 생성자의 파라미터가 먼저 필드로 할당되고, 그다음 프로퍼티 초기화와 init 블록이 작성된 순서대로 실행된다. val isAdult가 먼저 초기화되고, init 블록이 실행된 뒤, val greeting이 초기화되는 순서인 거다.
생성자 본문을 자세히 보면 이렇다
public User(@NotNull String name, int age) {
this.name = name; // 1. 주 생성자 파라미터 할당
this.isAdult = age >= 18; // 2. 첫 번째 프로퍼티 초기화
System.out.println(...); // 3. init 블록 실행
this.greeting = "Hello, " + name; // 4. 두 번째 프로퍼티 초기화
}
코틀린은 이 모든 초기화 로직을 하나의 생성자 안에 순서대로 배치한다. 자바에서는 이런 초기화 순서가 암묵적이지만, 코틀린은 코드 작성 순서가 곧 실행 순서가 되도록 명시적으로 만들었다. 이게 Hilt 같은 의존성 주입 프레임워크를 사용할 때도 예측 가능한 초기화 순서를 보장하는 기반이 된다.
프로퍼티와 Backing Field
코틀린 공식 문서를 읽으면서 가장 흥미로웠던 부분은 프로퍼티의 field 키워드였다.
class Counter {
var count: Int = 0
set(value) {
if (value >= 0) field = value
}
}
이 코드의 field는 실제 값이 저장되는 backing field를 가리킨다. 자바로 변환하면 private int count라는 필드가 생기고, setter에서 this.count = value 형태로 변환된다.
자바에서는 필드와 getter/setter를 각각 선언해야 한다(귀찮다..)
public class Counter {
private int count = 0;
public int getCount() {
return count;
}
public void setCount(int value) {
if (value >= 0) {
this.count = value;
}
}
}
코틀린은 여기서 필드와 접근자를 분리하지 않고 하나의 프로퍼티 개념으로 통합했다.
왜 이런 선택을 했을까? 자바에서는 필드를 직접 노출하면 나중에 getter/setter로 바꾸기 어렵다. 그래서 항상 private 필드 + public getter/setter 패턴을 사용한다. 하지만 이건 보일러플레이트 코드가 많아진다는 단점이 있다.
코틀린은 "어차피 대부분의 경우 필드에 getter/setter가 필요하다"는 전제 아래, 프로퍼티 문법을 만들었다. 개발자는 프로퍼티만 선언하면 컴파일러가 필요에 따라 backing field와 접근자를 생성한다.
그런데 backing field가 없는 경우도 있다.
class Rectangle(val width: Int, val height: Int) {
val area: Int
get() = width * height
}
area 프로퍼티는 값을 저장하지 않고 매번 계산해서 반환한다. 디컴파일하면
public final class Rectangle {
private final int width;
private final int height;
public final int getArea() {
return this.width * this.height;
}
// area 필드는 생성되지 않음
}
getArea() 메서드만 생성되고 area 필드는 생기지 않는다. 코틀린 컴파일러는 커스텀 getter만 있고 값을 직접 참조하지 않으면 backing field가 필요 없다고 판단한다.
이게 중요한 이유는, 프로퍼티 문법으로 "필드처럼 보이지만 실제로는 계산된 값"을 만들 수 있기 때문이다. 외부에서는 rectangle.area로 접근하지만, 내부적으로는 메서드 호출이다. Java에서는 이걸 명시적으로 getArea() 메서드로 만들어야 했다.
코틀린이 backing field를 자동으로 관리하는 이유는 균일한 접근 원칙(Uniform Access Principle)을 따르기 위해서다. 저장된 값이든 계산된 값이든, 사용하는 쪽에서는 동일한 방식으로 접근한다. 구현을 나중에 바꿔도 호출 코드는 변경할 필요가 없다.
Expression과 Statement
코틀린이 자바와 다르게 if, when, try-catch를 식(Expression)으로 사용할 수 있다는 점도 이번 주에 확인했다.
val message = if (score >= 60) "Pass" else "Fail"
자바에서는 이런 코드를 삼항 연산자로 작성하거나 if문 안에서 변수를 할당해야 한다.
왜 코틀린은 모든 제어 구조를 Expression으로 만들었을까? 자바의 if는 Statement다. Statement는 값을 반환하지 않고 동작만 수행한다. 그래서 조건에 따라 변수에 다른 값을 할당하려면 변수를 미리 선언하고 if문 안에서 재할당해야 한다
String message;
if (score >= 60) {
message = "Pass";
} else {
message = "Fail";
}
이 방식의 문제는 message를 var로 선언해야 한다는 점이다. 불변성을 지키기 어렵다.
코틀린은 함수형 프로그래밍의 영향을 받아 "모든 제어 구조는 값을 반환해야 한다"는 원칙을 따른다. 이렇게 하면 불변 변수를 더 자연스럽게 사용할 수 있다.
바이트코드를 비교해보면 흥미로운 점이 있다.
val message = if (score >= 60) "Pass" else "Fail"
이걸 디컴파일하면
String message = score >= 60 ? "Pass" : "Fail";
삼항 연산자로 변환된다. 자바에서 Statement로 작성한 버전과 비교해보자
// Statement 버전
String message;
if (score >= 60) {
message = "Pass";
} else {
message = "Fail";
}
// Expression 버전 (코틀린 디컴파일 결과)
String message = score >= 60 ? "Pass" : "Fail";
바이트코드를 보면 두 방식의 차이가 명확하다.
Statement 버전은
0: aload_1 // 변수 선언 (초기화되지 않은 상태)
1: iload_2 // score 로드
2: bipush 60 // 60 로드
4: if_icmplt 15 // 비교 후 점프
7: ldc "Pass" // "Pass" 로드
9: astore_1 // message에 저장
10: goto 18
13: ldc "Fail" // "Fail" 로드
15: astore_1 // message에 저장
Expression 버전은
0: iload_1 // score 로드
1: bipush 60 // 60 로드
3: if_icmplt 12 // 비교 후 점프
6: ldc "Pass" // "Pass" 로드
8: goto 14
11: ldc "Fail" // "Fail" 로드
13: astore_0 // message에 한 번만 저장
Statement 버전은 변수에 값을 두 번 할당(astore)하지만, Expression 버전은 한 번만 할당한다. 코드 실행 경로가 더 단순하다.
더 중요한 건 컴파일러가 변수를 최적화할 수 있다는 점이다. Expression으로 선언된 변수는 딱 한 번만 할당되므로 컴파일러가 상수로 취급하거나 인라인할 수 있다. Statement 버전은 여러 경로에서 재할당 가능성이 있어서 최적화가 제한된다.
when도 마찬가지다
val result = when (type) {
"A" -> processA()
"B" -> processB()
else -> processDefault()
}
자바에서는 switch가 Expression이 아니라서 Java 14부터 switch expression이 추가됐다. 코틀린은 처음부터 이 방식을 택했다.
부 생성자의 위치
부 생성자(Secondary Constructor)는 주 생성자가 없거나 추가적인 초기화 로직이 필요할 때 사용한다.
class User(val name: String) {
var age: Int = 0
constructor(name: String, age: Int) : this(name) {
this.age = age
}
}
부 생성자는 반드시 주 생성자를 호출해야 한다. 디컴파일해보면
public final class User {
@NotNull
private final String name;
private int age;
// 주 생성자
public User(@NotNull String name) {
this.name = name;
this.age = 0;
}
// 부 생성자
public User(@NotNull String name, int age) {
this(name); // 주 생성자 호출
this.age = age;
}
}
부 생성자가 주 생성자를 먼저 호출하고, 그다음 자신의 본문을 실행한다.
자바에서는 생성자 오버로딩이 자유롭다
public class User {
private String name;
private int age;
public User(String name) {
this.name = name;
this.age = 0;
}
public User(String name, int age) {
this.name = name;
this.age = age;
}
}
코틀린은 왜 "반드시 주 생성자를 호출해야 한다"는 제약을 뒀을까?
주 생성자에 프로퍼티를 선언하는 코틀린의 특성 때문이다. 주 생성자의 파라미터가 프로퍼티가 되므로, 모든 생성 경로가 주 생성자를 거쳐야 프로퍼티 초기화가 보장된다. 자바는 필드를 클래스 본문에 선언하므로 각 생성자에서 자유롭게 초기화할 수 있지만, 코틀린은 프로퍼티 초기화를 주 생성자에 집중시켰다.
또한 기본값을 가진 파라미터가 있다면 부 생성자 대부분을 대체할 수 있다
class User(val name: String, val age: Int = 0)
// 두 가지 방식으로 생성 가능
val user1 = User("Alice") // age는 0
val user2 = User("Bob", 25) // age는 25
코틀린 공식 문서에서도 "가능하면 기본값 파라미터를 사용하고, 부 생성자는 자바 라이브러리 호환성이 필요할 때만 사용하라"고 권장한다. 부 생성자의 사용 빈도가 낮은 이유다.
이번 주 배운 것
1. 생성자 초기화는 순서가 아니라 원칙이다
코틀린의 생성자 실행 순서는 주 생성자 파라미터 → 프로퍼티 초기화/init 블록(작성 순서대로) → 부 생성자 본문이다. 중요한 건 이 순서가 "규칙"이 아니라 "예측 가능한 초기화를 보장하기 위한 설계"라는 점이다. 코드에 작성한 순서가 곧 실행 순서가 되므로, 초기화 의존성을 쉽게 파악할 수 있다.
2. Backing Field는 구현 세부사항을 숨긴다
프로퍼티는 backing field가 있을 수도, 없을 수도 있다. 커스텀 getter만 있고 값을 저장하지 않으면 backing field가 생성되지 않는다. 이건 단순한 최적화가 아니라 "균일한 접근 원칙"을 따르기 위한 설계다. 저장된 값이든 계산된 값이든, 호출하는 쪽에서는 구분할 필요가 없다. 나중에 구현을 바꿔도 인터페이스는 유지된다.
3. Expression 중심 설계는 불변성을 강제한다
코틀린은 if, when, try-catch를 Expression으로 만들어서 값을 반환하게 했다. 이렇게 하면 변수를 한 번만 할당하는 패턴을 자연스럽게 유도할 수 있다. 바이트코드 수준에서도 할당 횟수가 줄어들고 컴파일러 최적화 여지가 생긴다. 자바가 Java 14에서야 switch expression을 도입한 것과 비교하면, 코틀린은 처음부터 함수형 프로그래밍의 영향을 받았다는 걸 알 수 있다.
4. 부 생성자의 제약은 프로퍼티 초기화 보장을 위한 것이다
부 생성자가 반드시 주 생성자를 호출해야 하는 이유는, 주 생성자에 프로퍼티가 선언되기 때문이다. 모든 생성 경로가 주 생성자를 거쳐야 프로퍼티 초기화가 보장된다. 자바처럼 자유로운 생성자 오버로딩보다, 기본값 파라미터를 사용하는 게 코틀린스러운 방식이다.
다음 주 계획
2주차에는 상속, 인터페이스, 추상 클래스를 다룬다. 이번 주에 프로퍼티와 backing field를 이해했으니, 인터페이스에서 프로퍼티를 선언했을 때 어떻게 변환되는지 확인할 수 있을 것 같다. 인터페이스는 상태를 가질 수 없다는 자바의 제약을 코틀린이 어떻게 다루는지 보고 싶다.
또한 코틀린이 기본적으로 모든 클래스를 final로 만든 이유를 알아볼 예정이다. open, abstract 키워드가 왜 필요한지, 이게 "상속보다 조합"이라는 원칙과 어떻게 연결되는지 공식 문서를 통해 정리하려고 한다.
data class와 object의 내부 구현도 디컴파일로 확인해볼 계획이다. 특히 copy() 메서드가 어떻게 생성되고, object가 싱글톤을 어떤 방식으로 구현하는지 바이트코드 수준에서 파악하고 싶다.
'우아한테크코스 > 레벨0' 카테고리의 다른 글
| 레벨0 - 지연 초기화와 위임, 그리고 4주간의 마무리 (0) | 2026.02.24 |
|---|---|
| 레벨0 - 제네릭과 널, 그리고 3주간의 흐름 (0) | 2026.02.17 |
| 레벨0 - 2주차 회고, 그리고 회고를 회고하기 (0) | 2026.02.10 |
