
코틀린 object companion object 차이는 문법보다 역할로 이해하는 편이 훨씬 덜 헷갈립니다. 이 글에서는 object를 언제 싱글턴처럼 보고, companion object를 언제 static 비슷한 클래스 수준 API로 봐야 하는지 실무 기준으로 정리합니다.
object와 companion object를 한 문장으로 구분하면
가장 짧게 구분하면 object는 이미 만들어진 하나의 객체이고, companion object는 특정 클래스를 위한 클래스 수준 진입점입니다. 둘 다 바깥에서 바로 접근할 수 있지만, 하나는 객체 자체이고 다른 하나는 클래스 쪽 API라는 점이 다릅니다.
object AppConfig {
const val DEFAULT_TIMEOUT = 3_000
fun isDebug(): Boolean = true
}
class User private constructor(
val name: String
) {
companion object {
fun create(name: String): User {
require(name.isNotBlank())
return User(name.trim())
}
}
}AppConfig는 단일 객체이고, User.create()는 User 인스턴스를 만드는 클래스 수준 입구입니다. 즉 companion object가 있다고 해서 그 클래스가 싱글턴이 되는 것은 아닙니다.
object는 객체 자체이고, companion object는 클래스 수준 API를 두는 자리입니다.
object는 언제 싱글턴처럼 보면 될까
Kotlin 공식 문서는 object declaration이 클래스를 정의하면서 인스턴스를 함께 만드는 방식이며 singleton 구현에 유용하다고 설명합니다. 그래서 자바식 private constructor, static field, getInstance() 패턴을 코틀린에서는 훨씬 짧게 표현할 수 있습니다.
object BuildInfo {
const val VERSION_NAME = "1.4.2"
const val VERSION_CODE = 142
fun summary(): String = "$VERSION_NAME ($VERSION_CODE)"
}앱 버전 정보처럼 정말 여러 개일 이유가 없는 대상이라면 object는 아주 좋은 선택이 됩니다. 역할이 작고, 읽기 전용이며, 앱 전역에서 같은 정보를 보는 편이 자연스럽기 때문입니다.
object EventLogger {
private val buffer = mutableListOf<String>()
fun log(message: String) {
buffer += message
println(message)
}
}반대로 로거처럼 정책이 커지고 테스트 대역이 필요해질 수 있는 대상은 조심해야 합니다. 이때 문제는 singleton 문법이 아니라 전역 구체 구현에 시스템 전체가 직접 묶인다는 점입니다.
companion object는 왜 싱글턴과 다를까
companion object는 팩토리 메서드, 클래스 소속 상수, 파싱 함수처럼 클래스 수준 기능을 두기에 좋습니다. 하지만 특정 클래스의 인스턴스를 하나만 보장하는 도구는 아닙니다.
class Money private constructor(
val amount: Long
) {
companion object {
val ZERO = Money(0)
fun of(amount: Long): Money {
require(amount >= 0)
return Money(amount)
}
}
}Money.ZERO는 클래스에 잘 붙는 상수이고, Money.of()는 생성 규칙을 담은 입구입니다. 둘 다 클래스와 관련은 있지만, Money 인스턴스를 하나만 쓰겠다는 뜻은 아닙니다.
상수는 object에 둘까, companion object에 둘까
상수는 먼저 어디에 의미상 속하는지 보는 편이 좋습니다. 그 클래스의 규칙이라면 companion object가 자연스럽고, 앱 전역 기본값이라면 별도 object가 더 읽기 좋을 수 있습니다.
class User private constructor(
val name: String
) {
companion object {
const val MIN_NAME_LENGTH = 2
}
}
object HttpDefaults {
const val CONNECT_TIMEOUT_MS = 3_000
const val READ_TIMEOUT_MS = 5_000
}User.MIN_NAME_LENGTH는 클래스 규칙처럼 읽히고, HttpDefaults.CONNECT_TIMEOUT_MS는 전역 설정처럼 읽힙니다. 문법보다 소속감이 중요하다는 뜻입니다.
상수의 자리는 static 흉내보다 그 값이 어디에 속하는지가 더 중요합니다.
팩토리 메서드는 companion object가 잘 맞는 경우가 많다
생성 규칙이 있는 클래스는 생성자를 그대로 열어두기보다, companion object의 팩토리 메서드로 의도를 드러내는 편이 읽기 좋을 때가 많습니다.
class User private constructor(
val name: String,
val age: Int
) {
companion object {
fun from(name: String, age: Int): User {
require(name.isNotBlank())
require(age >= 0)
return User(name.trim(), age)
}
fun guest(): User = User(name = "guest", age = 0)
}
}이 패턴의 핵심은 singleton이 아니라 생성 통제입니다. 검증, 기본값, 이름 있는 생성 진입점이 중요할 때는 singleton보다 factory method를 먼저 떠올리는 편이 맞습니다.
private constructor는 언제 같이 떠올려야 할까
private constructor를 본다고 해서 곧바로 singleton이라고 보면 안 됩니다. object는 언어 차원에서 단일 객체를 만들고, private constructor는 주로 허용된 경로로만 생성하게 만들기 위해 쓰입니다.
class Email private constructor(
val value: String
) {
companion object {
fun from(raw: String): Email {
require("@" in raw)
return Email(raw.lowercase())
}
}
}이 예제의 핵심은 인스턴스를 하나로 제한하는 것이 아니라, 검증된 값만 만들게 하는 것입니다. 그래서 private constructor는 singleton보다 생성 통제 도구로 이해하는 편이 정확합니다.
실무에서는 이렇게 고르면 덜 헷갈린다
- 정말 하나만 있어야 하면 object를 먼저 검토한다
- 생성 규칙이 중요하면 companion object의 팩토리 메서드를 검토한다
- 클래스 규칙 상수면 companion object, 전역 기본값이면 별도 object를 본다
- 구현 교체와 테스트가 중요하면 전역 object 고정보다 인터페이스와 DI를 먼저 본다
많은 전역 object는 사실 singleton이라기보다 편의상 꺼내 쓴 전역 상태에 가깝습니다. 특히 테스트 초기화 코드가 늘어나거나 환경별 분기가 많아진다면 object 남용 신호일 가능성이 큽니다.
마무리
정리하면 object는 단일 객체 자체를 표현하고, companion object는 클래스 수준 API를 두는 자리입니다. 상수는 어디에 속하는 값인지 먼저 보고, 생성 규칙이 중요하면 팩토리 메서드와 private constructor를 함께 떠올리는 편이 좋습니다.
관련 글로는 코틀린 data class: 실무에서 어디까지 쓸까, 코틀린 sealed class vs enum: 상태 모델링 기준, 코틀린 let, run, apply, also, with 차이를 함께 보면 좋습니다. 외부 참고 자료로는 Kotlin object declarations 공식 문서와 Singleton 패턴 레퍼런스를 참고했습니다.