본문 바로가기

Language/Kotlin

[Kotlin] 코틀린 온보딩 10편 - object와 companion object: 싱글톤부터 value class까지 이해하기

지난 편에서는 생성자를 중심으로 프로퍼티의 정의와 초기화, 상속 시 생성자 호출 문법을 살펴봤습니다.

https://dmoritle.tistory.com/291

 

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

지금까지 프로퍼티를 클래스 본문에 바로 선언하는 방법과 커스텀 접근자, field를 통한 backing field까지 다뤘습니다. 다만 그때 "클래스를 생성자와 함께 선언하는 방법은 범위를 넘어선다"며 미

dmoritle.tistory.com

 

이번 편에서는 코틀린이 자바의 static과 싱글톤 패턴을 하나의 키워드로 묶어 처리하는 object를 정리합니다. 다루는 내용은 다음과 같습니다.

  • object 선언: 단 하나의 인스턴스만 만드는 싱글톤 문법
  • companion object: 클래스에 딸려서 static 대용 역할을 하는 객체
  • 객체 식: 평가될 때마다 새로 만들어지는 익명 객체
  • value class: object와는 별개 기능이지만, 래퍼 객체를 만들지 않는다는 점에서 객체 생성 비용이라는 주제로 함께 정리

예제는 Kotlin 2.2.20, JDK 21에서 직접 컴파일하고 실행해 확인했고, 설명은 Kotlin 공식 문서와 설계 문서(KEEP)로 대조했습니다.

 

한눈에 보기

구분 인스턴스 이름 주 용도
object 선언 1개 (싱글톤) 있음 싱글톤, 전역 설정, 유틸리티
companion object 클래스당 1개 생략 가능 (Companion) static 대용, 팩토리 메서드, 상수
객체 식 (object : ...) 평가될 때마다 새로 생성 없음 익명 구현체, 일회성 객체
value class 원칙적으로 래퍼 객체 없음 있음 타입 안정성 + 래퍼 비용 줄이기

 

1. object 선언 (Object Declaration)

클래스 정의와 동시에 단 하나의 인스턴스를 만듭니다. 자바에서 싱글톤 패턴을 직접 구현하던 일을 언어 차원에서 지원하는 셈입니다.

object AppConfig {
    val baseUrl = "https://api.example.com"
    var timeout = 3000

    fun printConfig() = println("url=$baseUrl, timeout=$timeout")
}

fun main() {
    AppConfig.printConfig()   // 이름으로 바로 접근
    AppConfig.timeout = 5000
}

특징

  • 생성자를 선언할 수 없고, 이름으로 바로 접근합니다.
  • 클래스를 상속하거나 인터페이스를 구현할 수 있습니다.
  • 처음 접근하는 시점에 초기화되고, 이 초기화는 스레드 안전합니다. 선언한 순간이 아니라 처음 사용하는 순간에 init 블록이 실행됩니다.
  • 함수 안(지역 위치)에서는 선언할 수 없습니다.

 

자바 개발자가 걸려 넘어지는 포인트: INSTANCE를 거쳐야 합니다

AppConfig.INSTANCE.printConfig();

자바에서는 INSTANCE를 거쳐야 합니다. 멤버에 @JvmStatic을 붙이면 AppConfig.printConfig()처럼 static 메서드로도 호출할 수 있습니다.

 

2. companion object

클래스 안에 선언하는 객체로, 클래스 이름만으로 멤버에 접근하게 해 줍니다. 코틀린에는 static 키워드가 없고, 자바의 static 멤버가 하던 역할(팩토리, 상수, 클래스 수준 상태)을 이 객체가 맡습니다.

class User private constructor(val name: String) {
    companion object {
        private var count = 0

        fun create(name: String): User {
            count++
            return User(name)
        }
    }
}

val user = User.create("모리")

기본 규칙

  • 클래스당 하나만 선언할 수 있습니다.
  • 이름을 생략하면 Companion이라는 이름이 붙고, 직접 지을 수도 있습니다. (companion object Loader { ... })
  • 바깥 클래스의 private 멤버(생성자 포함)에 접근할 수 있습니다.
  • 부모 클래스의 companion 멤버는 자식 클래스 이름으로 호출할 수 없습니다. (Child.create()는 컴파일 에러)

 

클래스 이름과 Companion의 관계

이름을 생략한 companion의 정식 이름은 Companion이어서 Account.Companion.open()이 원래 형태입니다. 코틀린은 여기서 .Companion을 생략하고 Account.open()처럼 클래스 이름만으로 접근하는 것도 허용합니다. 둘은 같은 객체를 가리킵니다.

class Account {
    companion object {
        fun open() = Account()
    }
}

Account.Companion.open()   // 정식 이름으로 접근
Account.open()             // Companion 생략 (같은 호출)

val a = Account.Companion
val b = Account            // 값 자리에 클래스 이름만 쓰면 companion 인스턴스
println(a === b)           // true

 

클래스 이름은 쓰이는 자리에 따라 의미가 달라집니다.

  • 타입 자리(: Account, <Account>): 클래스 타입
  • 값 자리(= Account, 인자로 전달): companion 인스턴스
  • Account(...)처럼 괄호가 붙을 때: 생성자 호출

companion이 없는 클래스는 이름만 단독으로 값 자리에 쓸 수 없고 컴파일 에러가 납니다. Order 클래스의 companion에 companion object Creator처럼 이름을 붙이면 Order.Companion은 컴파일 에러가 되고 Order.Creator로만 접근할 수 있지만, 클래스 이름만으로 호출하는 축약형(Order.make())은 그대로 동작합니다.

 

companion은 "진짜 객체"입니다

companion object는 static 블록처럼 보이지만, 실체는 일반 객체와 같은 인스턴스입니다. 그래서 인터페이스를 구현하고, 변수에 담고, 인자로 넘길 수 있습니다. 자바의 static 메서드는 인터페이스를 구현할 수 없다는 점과 가장 크게 다릅니다.

interface Factory<T> { fun create(): T }

class Product private constructor(val id: Int) {
    companion object : Factory<Product> {
        private var seq = 0
        override fun create() = Product(++seq)
    }
}

fun <T> build(f: Factory<T>): T = f.create()

fun main() {
    val f: Factory<Product> = Product   // 클래스 이름이 companion 객체를 가리킵니다
    println(f.create().id)              // 1
    println(build(Product).id)          // 클래스 이름을 인자로 전달할 수 있습니다
    println(f === Product.Companion)    // true: 같은 인스턴스
}

 

덕분에 "클래스 자체를 팩토리로 전달"하는 코드를 쓸 수 있습니다.

클래스가 직접 구현하지 않고 companion에 두는 이유

Person이 JsonDeserializer<Person>을 직접 구현하지 않고 companion이 구현하게 하는 경우를 생각해 보겠습니다.

  1. fromJson은 객체를 만들어 내는 기능입니다. Person이 직접 구현하면 이 함수를 호출하려고 이미 Person 인스턴스가 있어야 하는 순환이 생깁니다.
  2. 데이터를 담는 클래스에 "역직렬화기"라는 책임이 섞이지 않습니다.
  3. 제네릭 T로는 T.fromJson(...)처럼 호출할 수 없습니다. 타입 파라미터에는 정적 멤버가 없기 때문입니다. 위 build(Product)처럼 companion을 인자로 받으면 타입별 동작을 넘길 수 있습니다.

반대로 이미 존재하는 객체에서 출발하는 기능은 인스턴스 메서드가 맞습니다.

기능 위치 이유
toJson() (객체 → JSON) Person이 직접 구현 이미 존재하는 인스턴스에서 동작
fromJson() (JSON → 객체) Person의 companion이 구현 인스턴스를 만들어 내는 쪽이라 인스턴스가 없음

 

팩토리 메서드에 잘 맞습니다

companion은 바깥 클래스의 private 생성자에 접근할 수 있어서, 생성자를 막고 팩토리 메서드로만 생성하게 하는 패턴과 잘 맞습니다. 생성 시점에 검증, 정규화, 캐싱, 하위 타입 반환 같은 로직을 넣을 수 있고, 메서드 이름으로 의도를 드러낼 수 있습니다.

class Nickname private constructor(val value: String) {
    companion object {
        fun of(raw: String): Nickname {
            require(raw.isNotBlank()) { "닉네임이 비어 있습니다" }
            return Nickname(raw.trim())
        }
    }
}

val nickname = Nickname.of("  모리 ")

 

다만 companion이 항상 정답은 아닙니다.

  • 기본 인자와 이름 있는 인자로 해결된다면 생성자 하나로 충분합니다.
  • 클래스의 private 멤버가 필요 없다면 최상위 함수가 더 가볍습니다.
  • companion에 operator fun invoke를 정의하면 Nickname("...")처럼 생성자 호출 모양으로 팩토리를 쓸 수 있습니다.

 

확장 함수로 확장할 수 있습니다

companion 객체는 타입이므로 확장 함수를 붙일 수 있습니다. 확장 함수가 붙는 대상 타입(수신 타입)은 이름이 없는 companion이면 Companion, 이름을 지었다면 그 이름입니다. 대상 클래스에 companion이 선언되어 있어야 합니다.

// 앞에서 만든 Product의 이름 없는 companion → 수신 타입은 Product.Companion
fun Product.Companion.createPair() = create() to create()

val (a, b) = Product.createPair()   // 호출은 클래스 이름으로

 

클래스를 직접 수정할 수 없거나, 수정하고 싶지 않을 때 "클래스에 정적 팩토리가 있는 것처럼" 쓰기 위해 사용합니다. 대표적인 경우는 도메인 클래스가 인프라 계층(엔티티, DTO)을 알지 못하게 하면서 변환 함수를 제공하는 것입니다.

// domain 모듈
class User(val id: Long, val name: String) {
    companion object
}

// infra 모듈 (domain에 의존, 반대는 아님. UserEntity는 infra의 엔티티라고 가정)
fun User.Companion.from(entity: UserEntity) = User(entity.id, entity.name)

코틀린 표준 라이브러리의 Int, String 등도 companion을 가지고 있어서 fun Int.Companion.parseOrZero(s: String) = ...처럼 확장할 수 있습니다.

 

주의할 점은 private 멤버에 접근할 수 없다는 것입니다. 생성자를 private으로 막아 둔 클래스는 외부 확장 함수에서 생성할 수 없습니다.

class Locked private constructor(val v: Int) { companion object }

fun Locked.Companion.build() = Locked(1)
// error: cannot access 'constructor(v: Int): Locked': it is private in 'Locked'

그래서 생성자를 막고 쓰는 팩토리는 companion 안에 직접 정의하고, 확장은 public 생성자나 public 팩토리가 이미 열려 있는 클래스에 쓰는 것이 맞습니다.

 

자바 개발자가 걸려 넘어지는 포인트: companion 멤버는 기본적으로 static이 아닙니다

companion object의 멤버는 기본적으로 진짜 static이 아니라 Companion 인스턴스의 메서드입니다. 자바에서는 Product.Companion.create()처럼 Companion을 거쳐야 합니다. 아래 어노테이션으로 이를 바꿀 수 있습니다.

class Constants {
    companion object {
        const val MAX_SIZE = 100             // static 필드
        @JvmStatic fun helper() {}           // static 메서드
        @JvmField val DEFAULT_NAME = "모리"  // static 필드 (getter 없음)
    }
}

자바에서 호출될 가능성이 있는 API라면 @JvmStatic, @JvmField, const를 의식해서 쓰는 편이 좋습니다.

 

3. 객체 식 (Object Expression)

이름 없는 익명 객체를 그 자리에서 만드는 문법입니다. 자바의 익명 클래스에 대응합니다. 앞의 두 가지와 달리 싱글톤이 아니고, 평가될 때마다 새 인스턴스가 만들어집니다.

// 인터페이스 구현 (OnClickListener는 onClick() 하나만 가진 인터페이스라고 가정)
val listener = object : OnClickListener {
    override fun onClick() = println("클릭됨")
}

// 클래스 상속 + 생성자 인자 전달
val task = object : Thread("worker") {
    override fun run() = println("실행: $name")
}

// 상위 타입 없이 프로퍼티 묶음만
val point = object {
    val x = 10
    val y = 20
}
println(point.x + point.y)   // 30

특징

  • 여러 상위 타입을 동시에 지정할 수 있습니다. (object : A(), B, C { ... })
  • 선언이 아니라 식이라서, 실행되는 시점에 바로 만들어집니다. (지연 초기화되는 object 선언과의 가장 큰 차이입니다.)
  • 바깥 스코프의 변수를 읽고 수정할 수 있습니다. 자바 익명 클래스의 effectively final 제약이 없습니다.

 

어디에 쓰나요?

이름 있는 클래스를 만들기엔 과하고, 람다로는 표현이 안 되는 일회용 구현이 필요할 때 씁니다. 람다(SAM 변환)는 추상 메서드가 하나인 인터페이스에만 가능하므로, 다음 경우에는 객체 식을 씁니다.

  • 메서드가 여럿인 인터페이스
  • 추상 클래스 (SAM 변환은 인터페이스에만 적용됩니다.)
  • 상태(프로퍼티)가 필요한 구현
interface Lifecycle {
    fun onStart()
    fun onFinish()
}

val lifecycle = object : Lifecycle {
    override fun onStart() = println("시작")
    override fun onFinish() = println("끝")
}

재사용할 대상이라면 이름 있는 클래스나 객체 선언으로 빼는 편이 낫고, 그 자리에서 한 번 쓰고 끝나는 구현이면 객체 식이 적합합니다.

 

타입 추론 규칙

익명 객체의 실제 타입은 선언이 어디에 노출되느냐에 따라 유지되거나 사라집니다. 지역 변수나 private 선언이라면 익명 객체 타입이 그대로 유지되어 멤버에 접근할 수 있고, public 선언이 되면 상위 타입(없으면 Any)으로 취급됩니다.

class Test {
    private fun privateObj() = object { val a = 1 }
    fun publicObj() = object { val a = 1 }

    fun run() = privateObj().a   // OK
    // publicObj().a             // 컴파일 에러: Any 타입으로 취급
}

 

4. value class (구 inline class)

object 키워드와는 별개의 기능입니다. 다만 "래퍼 객체를 만들지 않는다"는 점에서 객체 생성 비용을 다루는 주제와 함께 묶어 공부하기 좋아서 같이 정리합니다.

inline class라는 이름은 deprecated입니다. inline class로 선언하면 컴파일러가 value를 쓰라는 경고를 냅니다. 현재는 value class를 사용하고, JVM에서는 @JvmInline을 함께 붙여야 합니다.

 

단일 값을 감싸는 타입을 만들되, 가능한 한 런타임에는 래퍼 객체 없이 내부 값으로 대체해 주는 기능입니다.

@JvmInline
value class UserId(val value: Long)

@JvmInline
value class Email(val value: String) {
    init { require("@" in value) { "잘못된 이메일" } }
    fun domain() = value.substringAfter("@")
}

fun findUser(id: UserId) = id.value

findUser(UserId(1L))
// findUser(1L)   // 컴파일 에러: 타입 안정성 확보

왜 쓰나요?

fun transfer(from: Long, to: Long)처럼 원시 타입을 그대로 받으면 인자 순서를 바꿔 넣어도 컴파일러가 잡아내지 못합니다. value class로 감싸면 컴파일 타임에는 서로 다른 타입으로 구분하면서, 런타임 오버헤드는 줄일 수 있습니다. (타입 별칭 typealias는 원래 타입과 호환되므로 이런 구분이 되지 않습니다.)

 

제약 조건

  • 주 생성자에 val 프로퍼티가 정확히 1개여야 합니다.
  • 다른 클래스를 상속할 수 없고, 상속당할 수도 없습니다. 인터페이스 구현은 가능합니다.
  • 백킹 필드를 가진 추가 프로퍼티를 선언할 수 없습니다. 계산 프로퍼티는 가능합니다.
  • lateinit, 위임 프로퍼티는 사용할 수 없습니다.

 

DDD 관점: 값 객체와 잘 맞습니다

value class는 DDD의 값 객체(Value Object)를 코틀린답게 표현하는 도구로 자주 쓰입니다. 값 객체의 특징과 대응이 잘 됩니다.

값 객체의 특징 value class
식별자 없이 값으로만 동등성 판단 equals/hashCode가 감싼 값 기준으로 동작
불변 프로퍼티를 val로만 선언 가능
도메인 의미를 타입으로 표현 원시 타입을 의미 있는 타입으로 감쌈 (UserId, OrderId는 둘 다 Long이어도 서로 다른 타입)
생성 시점에 유효성 검증 init 블록에서 검증 가능

 

다만 프로퍼티가 하나뿐이어야 하므로 필요에 따라 나눠 씁니다.

  • 단일 값을 감싸는 값 객체(UserId, Email, PhoneNumber): value class
  • 여러 필드로 구성된 값 객체(Money(amount, currency), Address(city, street, zip)): data class

 

항상 인라인되는 것은 아닙니다

value class가 다른 타입처럼 쓰이는 순간에는 실제 래퍼 객체가 만들어집니다. 이를 박싱이라고 합니다.

val id = UserId(1L)

val any: Any = id            // Any로 쓰이면 박싱
val list = listOf(id)        // 제네릭 타입 인자로 쓰이면 박싱
val nullable: UserId? = id   // nullable로 쓰이면 박싱 (아래 참고)

 

공식 문서가 정리한 박싱 조건은 "다른 타입으로 쓰일 때", 즉 Any·인터페이스 타입, 제네릭 인자, nullable입니다. 다만 nullable에는 예외가 있습니다. 공식 문서 페이지는 이 예외를 따로 설명하지 않지만, Kotlin 설계 문서(KEEP)에 규칙이 있습니다.

 

Foo는 임의의 value class, Foo?는 그 nullable 타입입니다.

감싼 타입 Foo?일 때 런타임에 실제로 담기는 것 예
원시 타입 박싱 UserId? (Long 기반)
non-null 참조 타입 기반 타입으로 직접 매핑 (박싱 없음) Email? (String 기반)
nullable 타입 박싱 String?을 감싼 경우

첫 두 줄은 직접 컴파일해서 확인했고, 마지막 줄은 KEEP 설계 문서의 규칙입니다. 이런 구현 세부 사항은 컴파일러 버전에 따라 달라질 수 있으니, 성능이 중요한 지점이라면 따로 확인해 보시기를 권합니다.

 

자바 개발자가 걸려 넘어지는 포인트: 이름에 해시 접미사가 붙습니다

value class를 파라미터로 받는 함수는 JVM 시그니처 충돌을 피하려고 이름 뒤에 해시 접미사가 붙습니다. (findUser-Bu7z9Ig 같은 형태) 자바에서 호출하기 까다로워지고, 필요하면 @JvmName으로 이름을 직접 지정할 수 있습니다.

알아 둘 점: Spring Data Commons에는 생성자에 value class 파라미터가 있는 클래스를 다룰 때 매핑 오류가 나는 이슈가 보고되어 있습니다. 확인한 시점에는 해결되지 않은 상태였으니, 엔티티에 쓰기 전에 사용 중인 Spring Data와 Hibernate 버전에서 동작을 확인해야 합니다.

 

정리

요소 요약
object 선언 싱글톤. 처음 접근할 때 스레드 안전하게 초기화되고, 함수 안에서는 선언할 수 없음
companion object 클래스에 딸린 진짜 객체. 값으로 전달·인터페이스 구현·확장 함수가 가능해 팩토리에 잘 맞음. 자바에서 static처럼 쓰려면 @JvmStatic, @JvmField, const 필요
객체 식 평가될 때마다 새로 만들어지는 익명 객체. 메서드가 여럿이거나 추상 클래스이거나 상태가 필요한 일회용 구현에 사용. public으로 노출되면 타입 정보가 사라짐
value class 단일 값 래퍼를 컴파일 타임 타입으로만 존재하게 함. Any·제네릭·nullable(원시 타입 기반)로 쓰이는 순간 박싱됨

 

마무리

이번 편에서는 코틀린이 자바의 static과 싱글톤 패턴을 object라는 한 키워드로 어떻게 묶어 처리하는지, 그리고 같은 맥락에서 래퍼 객체 비용을 줄이는 value class까지 살펴봤습니다. 다음 편에서는 널이 될 수 있는 값을 코틀린이 타입 시스템 차원에서 어떻게 다루는지 다뤄보겠습니다.

 

이 시리즈는 『코틀린 인 액션(Kotlin in Action)』을 참고하여 작성했습니다.

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