지금까지 프로퍼티를 클래스 본문에 바로 선언하는 방법과 커스텀 접근자, field를 통한 backing field까지 다뤘습니다. 다만 그때 "클래스를 생성자와 함께 선언하는 방법은 범위를 넘어선다"며 미뤄둔 부분이 있었죠. 이번 편에서는 그 약속을 지켜 다음 내용을 다룹니다.
- 주 생성자로 프로퍼티를 정의와 동시에 초기화하는 세 가지 방법
- 상속할 때 기반 클래스의 생성자를 호출하는 문법
private생성자로 인스턴스화를 막는 방법- 주 생성자가 없는 클래스에서 부 생성자가 상속을 책임지는 규칙
- 인터페이스의 추상 프로퍼티를 구현하는 세 가지 방식
- 지난 편에서 다룬 접근자 가시성 규칙 다시 정리하기
1. 정의와 초기화를 한 번에 - 세 가지 방법
코틀린에서 프로퍼티를 "정의하면서 동시에 초기화"하는 방법은 크게 세 가지로 나뉩니다.
1. 주 생성자 파라미터에 val/var 붙이기
class Person(val name: String, var age: Int)
파라미터 앞에 val이나 var를 붙이면, 그 파라미터가 별도 코드 없이 곧바로 프로퍼티로 승격되면서 초기화까지 끝납니다.
2. 프로퍼티 선언부에서 바로 초기화
class Person(name: String) {
val upperName: String = name.uppercase()
}
여기서 name은 val/var가 없으므로 프로퍼티가 아니라, 생성자 안에서만 쓰이는 단순 파라미터입니다.
3. init 블록 사용
검증이나 로깅처럼 한 줄로 끝나지 않는 로직이 필요할 때는 init 블록에 작성합니다.
class Person(name: String) {
val name: String
init {
require(name.isNotBlank()) { "이름은 비어있을 수 없습니다" }
this.name = name
}
}
실행 순서: 코드에 쓰인 순서 그대로
init 블록과 프로퍼티 초기화식은 클래스 본문에 작성된 순서대로 실행됩니다.
class Example(name: String) {
val firstProperty = "First: $name".also { println(it) }
init {
println("초기화 블록: $firstProperty")
}
val secondProperty = "Second".also { println(it) }
}
Example("모리")를 호출하면 firstProperty 초기화 → init 블록 → secondProperty 초기화 순서로 실행됩니다. 여러 개의 init 블록과 프로퍼티 초기화가 섞여 있을 때는 위에서 아래로 읽으면 실행 순서를 그대로 알 수 있다는 뜻입니다.

2. 상속과 생성자: 괄호 안에 인자를 넘긴다
코틀린은 자바와 달리 상속 시 기반 클래스의 생성자 호출을 클래스 선언부에서 명시적으로 처리합니다. super(...)를 본문에 따로 쓰는 대신, 기반 클래스 이름 뒤에 괄호를 붙이고 그 자리에 인자를 넘기는 방식입니다.
open class Base(val name: String)
class Derived(name: String) : Base(name)
: Base(name)이 "기반 클래스의 생성자를 이 인자로 호출한다"는 의미입니다. Derived의 주 생성자가 받은 name을 그대로 Base의 생성자에 전달하는 구조입니다.
파라미터가 없어도 괄호는 필요하다
기반 클래스에 생성자를 따로 선언하지 않으면 컴파일러가 파라미터 없는 기본 생성자를 만들어 주는데, 이 경우에도 상속할 때는 빈 괄호를 반드시 붙여야 합니다.
open class Base
class Derived : Base() // 괄호 필수
괄호를 쓰는 이유는 코틀린이 "클래스 상속"과 "인터페이스 구현"을 문법적으로 구분하기 때문입니다. 괄호가 있으면 클래스의 생성자 호출, 괄호가 없으면 인터페이스 구현으로 해석됩니다.
interface Drawable {
fun draw()
}
class Circle : Drawable { // 인터페이스이므로 괄호 없음
override fun draw() { /* ... */ }
}
class Shape : Base(), Drawable { // 클래스는 괄호, 인터페이스는 괄호 없이 나열
override fun draw() { /* ... */ }
}

3. 인스턴스화를 막는 private 생성자
바깥에서 직접 인스턴스를 만들지 못하게 하려면 주 생성자에 private을 붙입니다. 이때 constructor 키워드는 생략할 수 없습니다.
class Singleton private constructor(val name: String) {
companion object {
val instance = Singleton("기본값")
}
}
private는 constructor 키워드 전체에 붙는 것이지, 개별 파라미터에 붙이는 게 아닙니다. 평소엔 class Foo(val x: Int)처럼 constructor 키워드를 생략할 수 있지만, 접근 제한자를 붙이는 순간 생략이 불가능해집니다.
class User private constructor(val id: Int, val name: String) {
companion object {
fun create(id: Int, name: String): User {
require(id > 0) { "id는 양수여야 합니다" }
return User(id, name)
}
}
}
val user = User.create(1, "철수") // 정상
// val user2 = User(1, "철수") // 컴파일 에러 - private
4. 주 생성자가 없는 클래스: 부 생성자가 상속을 책임진다
주 생성자를 아예 선언하지 않은 클래스도 만들 수 있습니다. 이 경우 상속 문법이 조금 달라집니다.
open class Base(val name: String)
class Derived : Base { // 주 생성자 없음 -> 클래스 이름 뒤 괄호 없음
constructor(name: String) : super(name)
}
주 생성자 자리(클래스 이름 뒤 괄호)가 아예 없으므로, 각 부 생성자가 개별적으로 super(...)를 호출해서 기반 클래스를 초기화해야 합니다.
여기서 헷갈리기 쉬운 부분: 판단 기준은 기반 클래스가 아니라 파생 클래스 쪽에 주 생성자가 있는지 여부입니다. 기반 클래스가 부 생성자만 가진 경우라도, 파생 클래스에 주 생성자가 있다면 여전히 괄호 방식(: Base(name))으로 호출할 수 있습니다.
부 생성자가 여러 개일 때: super 또는 this로 반드시 위임
주 생성자가 없는 클래스에서 부 생성자가 여러 개라면, 각 부 생성자는 다음 중 하나를 반드시 해야 합니다.
super(...)로 기반 클래스 생성자를 직접 호출this(...)로 같은 클래스의 다른 부 생성자에게 위임
class Derived : Base {
constructor(name: String) : super(name)
constructor(name: String, age: Int) : this(name) // 위 생성자에게 위임
}
위임 체인이 길어지더라도 결국 체인의 끝에서는 super(...)에 도달해야 하며, 두 개 이상의 부 생성자가 서로를 호출해 순환(delegation loop)을 이루는 것은 금지됩니다. 참고로 init 블록과 프로퍼티 초기화식은 주 생성자가 없어도 여전히 존재할 수 있는데, 이 코드들은 위임이 일어나는 즉시(부 생성자 본문보다 먼저) 실행됩니다.
| 파생 클래스 상태 | 기반 클래스 호출 방식 |
| 주 생성자 있음 | class Derived(...) : Base(...) |
| 주 생성자 없음, 부 생성자 1개 | constructor(...) : super(...) |
| 주 생성자 없음, 부 생성자 여러 개 | 각 부 생성자가 super(...) 또는 this(...)로 반드시 귀결 |
5. 인터페이스의 추상 프로퍼티, 세 가지 구현 방식
인터페이스는 backing field를 가질 수 없지만, 게터만 있는 추상 프로퍼티는 선언할 수 있습니다.
interface Named {
val name: String
}
이걸 구현하는 쪽은 세 가지 방식 중 하나를 고를 수 있습니다.
1. 프로퍼티 초기화로 구현 (backing field 생성)
class Person(name: String) : Named {
override val name: String = name
}
2. 커스텀 게터로 구현 (backing field 없음)
class Person(val firstName: String, val lastName: String) : Named {
override val name: String
get() = "$firstName $lastName"
}
값을 저장하지 않고 호출될 때마다 계산합니다.
3. 주 생성자 파라미터에서 바로 override
class Person(override val name: String) : Named
val 앞에 override만 붙이면, 파라미터 선언과 인터페이스 구현이 한 번에 끝납니다. 세 방식 모두 결국 "값을 그냥 저장할지, 다른 값으로부터 계산할지, 생성자에서 바로 받을지"의 차이일 뿐입니다.

6. 접근자 가시성 다시 보기
여기까지 생성자와 인터페이스 프로퍼티를 봤으니, 지난 편에서 다룬 field 키워드와 접근자 가시성도 한 번 더 짚고 넘어가겠습니다. 특히 접근자 가시성은 실무에서 캡슐화를 표현할 때 private constructor와 함께 자주 묶여 쓰이는 조합이라, 여기서 정확히 정리해두는 게 좋습니다.
커스텀 게터/세터 안에 있는 로직은 프로퍼티에 접근할 때마다 매번 실행됩니다.
class Counter {
var count: Int = 0
get() {
println("게터 호출됨")
return field
}
set(value) {
println("세터 호출됨, 새 값: $value")
field = value
}
}
field는 값을 저장하는 공간일 뿐이고, 그 값을 꺼내거나 저장하기 전후에 붙인 로직(로깅, 검증 등)은 호출될 때마다 재실행됩니다. 값 자체는 캐싱되어 있어도, 로직은 캐싱되지 않는다는 뜻입니다.
세터만 따로 가시성을 좁힐 수도 있다
프로퍼티 자체의 가시성과 별개로, 세터에만 독립적으로 더 좁은 가시성 제한자를 붙일 수 있습니다. 가장 흔한 패턴은 private set입니다.
class Account {
var balance: Int = 0
private set
fun deposit(amount: Int) {
balance += amount
}
}
val acc = Account()
acc.deposit(1000)
// acc.balance = 5000 // 컴파일 에러 - 세터는 private
println(acc.balance) // 1000, 읽기는 가능
클래스 내부에서는 자유롭게 값을 바꾸되, 외부에는 읽기 전용처럼 노출하고 싶을 때 유용합니다. 다만 이 방식은 세터에만 적용됩니다. 게터는 항상 프로퍼티 자체와 같은 가시성을 가지도록 강제되어 있어서, 게터만 따로 더 좁히는 것은 불가능합니다. private set으로 세터의 가시성만 좁히는 것이 코틀린이 제공하는 유일한 선택지입니다.
정리
| 구분 | 핵심 |
| 정의+초기화 | 생성자 파라미터에 val/var, 프로퍼티 선언부 초기화, init 블록 — 셋 중 상황에 맞게 조합 |
| 실행 순서 | 프로퍼티 초기화식과 init 블록은 코드에 쓰인 순서대로 실행 |
| 상속 문법 | Base(인자) — 클래스는 괄호로 생성자 호출, 인터페이스는 괄호 없이 나열 |
| 주 생성자 없는 클래스 | 모든 부 생성자가 super(...) 또는 this(...)로 귀결되어야 함 |
| private 생성자 | private constructor(...) — constructor 키워드 생략 불가, 인스턴스화 제한 |
| 인터페이스 프로퍼티 | 초기화 / 커스텀 게터 / 생성자 파라미터 override, 세 가지 구현 방식 |
| 접근자 가시성 | private set으로 세터만 프로퍼티보다 좁게 지정 가능. 게터는 항상 프로퍼티와 같은 가시성 |
마무리
이번 편에서는 생성자를 중심으로 프로퍼티의 정의와 초기화를 한 번에 처리하는 문법과, 상속·인터페이스 구현 시 생성자가 어떻게 얽히는지를 살펴봤습니다. 다음 편(10편)에서는 object와 companion object를 다뤄보겠습니다.
이 시리즈는 『코틀린 인 액션(Kotlin in Action)』을 참고하여 작성했습니다.
이번 편에서 다룬 내용은 아래 문서로도 검증했습니다.
'Language > Kotlin' 카테고리의 다른 글
| [Kotlin] 코틀린 온보딩 11편 - 널이 될 수 있는 값: null 안전성 한 번에 정리하기 (0) | 2026.10.05 |
|---|---|
| [Kotlin] 코틀린 온보딩 10편 - object와 companion object: 싱글톤부터 value class까지 이해하기 (0) | 2026.10.04 |
| [Kotlin] 코틀린 온보딩 8편 - 클래스 계층 구조: 인터페이스부터 sealed class까지 한눈에 정리하기 (0) | 2026.10.04 |
| [Kotlin] 코틀린 온보딩 7편 - 확장 함수: 바이트코드로 확장 프로퍼티까지 이해하기 (0) | 2026.09.26 |
| [Kotlin] 코틀린 온보딩 6편 - 함수 호출: 이름 있는 인자부터 infix까지 이해하기 (0) | 2026.09.26 |