본문 바로가기

Language/Kotlin

[Kotlin] 코틀린 온보딩 9편 - 생성자: 정의와 초기화를 한 번에 이해하기

지금까지 프로퍼티를 클래스 본문에 바로 선언하는 방법과 커스텀 접근자, 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)』을 참고하여 작성했습니다.

이번 편에서 다룬 내용은 아래 문서로도 검증했습니다.