Object, companion object 그리고 Kotlin의 싱글톤
1. 개요
Kotlin에는 static 키워드가 없습니다. 대신 언어는 object 키워드를 제공하며, 이는 세 가지 용법이 있습니다: object declaration, object expression그리고 companion object.
이 글은 각각의 용법을 설명하고, Kotlin이 이들을 JVM 바이트코드로 컴파일하는 방식, Java에서 호출하는 방법, 그리고 사용하지 말아야 할 때를 다룹니다. 이 글을 읽고 나면 스레드 안전한 싱글톤을 만드는 방법, 간결한 팩토리 메서드를 작성하는 방법, 그리고 테스트할 때 자주 하는 실수를 피하는 방법을 알게 될 것입니다.
2. 설정
이 예제들은 JVM 21에서 Kotlin 2.x를 사용합니다. 테스트를 실행하려면 pom.xml에 JUnit 5와 AssertJ를 추가하세요:
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.26.3</version>
<scope>test</scope>
</dependency>
</dependencies>
프로젝트에는 Kotlin 코드를 컴파일하기 위해 kotlin-maven-plugin이 이미 설정되어 있어야 합니다.
3. Object declaration – 언어에 내장된 싱글톤
최상위 레벨에서 또는 클래스 내부에 object를 선언하면, 싱글톤을 얻게 됩니다. Kotlin이 자동으로 이것의 정확히 하나의 인스턴스를 생성합니다. private 생성자, getInstance(), 또는 double-checked locking이 필요하지 않습니다.
import java.util.concurrent.atomic.AtomicLong
object OrderIdGenerator {
private val counter = AtomicLong(0)
fun next(): String = "ORD-${counter.incrementAndGet()}"
}
fun main() {
println(OrderIdGenerator.next())
println(OrderIdGenerator.next())
}
object의 이름으로 함수를 직접 호출합니다: OrderIdGenerator.next(). 여기에는 new 키워드도 없고 생성자 호출도 없습니다.
counter 변수는 AtomicLong을 사용합니다. 여러 스레드가 next()를 동시에 호출할 수 있기 때문입니다. 싱글톤은 정확히 하나의 인스턴스가 존재함을 보장할 뿐입니다. 그것이 내부 상태를 스레드 안전하게 만들어주지는 않습니다.
ORD-1
ORD-2
3.1. 지연 초기화와 스레드 안전성
Object는 프로그램이 시작할 때가 아니라 처음 접근할 때 초기화됩니다:
object AppConfig {
init {
println("AppConfig가 초기화됨")
}
val currency = "VND"
}
fun main() {
println("시작")
println(AppConfig.currency)
println(AppConfig.currency)
}
시작
AppConfig가 초기화됨
VND
VND
init 블록은 한 번만 실행되며, currency의 첫 번째 읽기 바로 전에 실행됩니다. 바이트코드 관점에서, Kotlin은 AppConfig를 public static final AppConfig INSTANCE 필드를 가진 final 클래스로 컴파일합니다. 이 필드는 static initializer에서 할당됩니다.
JVM은 클래스의 static initializer가 정확히 한 번만 실행되고 여러 스레드가 접근할 때 스레드 안전함을 보장합니다. 따라서 Kotlin의 싱글톤은 한 줄도 추가로 쓸 필요 없이 지연되면서 동시에 스레드 안전합니다.
3.2. Object는 상속할 수 있고 interface를 구현할 수 있습니다
Object는 진정한 타입입니다. Interface를 구현할 수 있고 다른 값처럼 전달될 수 있습니다:
interface DiscountPolicy {
fun apply(amount: Long): Long
}
object NoDiscount : DiscountPolicy {
override fun apply(amount: Long): Long = amount
}
class TenPercentOff : DiscountPolicy {
override fun apply(amount: Long): Long = amount * 90 / 100
}
fun checkout(total: Long, policy: DiscountPolicy): Long = policy.apply(total)
fun main() {
println(checkout(200_000, NoDiscount))
println(checkout(200_000, TenPercentOff()))
}
200000
180000
NoDiscount는 상태가 없으므로 여러 인스턴스를 만드는 것은 낭비입니다. 이것을 object로 선언하면 의도가 명확합니다: 정확히 하나의 인스턴스만 필요합니다.
4. Object expression – 무명 객체
object를 표현식 위치에 놓으면, 무명 객체를 만듭니다. 이 사용법은 Java의 anonymous class와 유사합니다:
data class Order(val id: String, val total: Long)
fun main() {
val byTotalDesc = object : Comparator<Order> {
override fun compare(a: Order, b: Order): Int = b.total.compareTo(a.total)
}
val orders = listOf(Order("A", 50), Order("B", 300), Order("C", 120))
println(orders.sortedWith(byTotalDesc).map { it.id })
}
[B, C, A]
중요한 차이점: object expression은 싱글톤이 아닙니다. 표현식이 실행될 때마다 새로운 인스턴스가 생성됩니다. 또한, object expression은 해당 위치에서 즉시 초기화되며, object declaration처럼 지연되지 않습니다.
또한 슈퍼타입 없이 object를 만들어 로컬 스코프에 몇 가지 값을 임시로 그룹화할 수 있습니다:
fun main() {
val stats = object {
val count = 3
val sum = 470L
}
println("평균: ${stats.sum / stats.count}")
}
stats의 타입은 함수 내부에서 또는 private 선언에서만 접근 가능합니다. public 함수를 통해 바깥으로 반환하면, 타입이 Any로 강제되고 property에 대한 접근 권한을 잃게 됩니다. 그런 경우에는 data class를 사용하세요.
주의: 추상 메서드가 하나뿐인 interface의 경우, object expression 대신 lambda (SAM conversion)를 사용하세요. 예:
Comparator<Order> { a, b -> b.total.compareTo(a.total) }. 여러 메서드를 override해야 하거나 상태를 유지해야 할 때만 object expression을 사용하세요.
5. Companion object – static의 대체물
때로는 인스턴스가 아니라 클래스에 연결된 함수가 필요합니다. 예를 들어 팩토리 메서드입니다. Java에서는 static을 사용할 것입니다. Kotlin에서는 companion object를 사용합니다:
class Customer private constructor(val email: String, val name: String) {
companion object {
private val EMAIL_REGEX = Regex("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$")
fun of(email: String, name: String): Customer {
require(EMAIL_REGEX.matches(email)) { "유효하지 않은 이메일: $email" }
return Customer(email.trim().lowercase(), name.trim())
}
}
}
fun main() {
val customer = Customer.of("An.Nguyen@Example.com", " Nguyễn An ")
println("${customer.email} | ${customer.name}")
}
an.nguyen@example.com | Nguyễn An
위의 코드는 다음과 같이 작동합니다:
- 생성자는
private이므로, 외부 코드는Customer.of(...)를 통해Customer를 만들어야 합니다. - Companion object는 그것을 포함하는 클래스의 private 생성자에 접근할 수 있습니다.
EMAIL_REGEX는 한 번만 컴파일되고 모든 호출에서 공유됩니다.
Companion object는 여전히 진정한 object입니다: 클래스 내부에 중첩된 싱글톤으로, 기본적으로 Companion이라는 이름을 가집니다. 그것을 포함하는 클래스가 로드될 때 초기화되며, Java의 static initializer와 동등합니다. 각 클래스는 최대 하나의 companion object만 가질 수 있습니다.
5.1. 이름 지정과 interface 구현
Object이기 때문에, companion은 이름을 지을 수 있고 interface를 구현할 수 있습니다. 이것은 Java의 static이 할 수 없는 것입니다:
interface Parser<T> {
fun parse(raw: String): T
}
data class Money(val amount: Long, val currency: String) {
companion object Factory : Parser<Money> {
override fun parse(raw: String): Money {
val (amount, currency) = raw.trim().split(" ")
return Money(amount.toLong(), currency)
}
}
}
fun <T> parseAll(parser: Parser<T>, lines: List<String>): List<T> = lines.map(parser::parse)
fun main() {
println(parseAll(Money, listOf("150000 VND", "20 USD")))
}
[Money(amount=150000, currency=VND), Money(amount=20, currency=USD)]
parseAll(Money, ...) 호출을 주목하세요. 클래스 이름 Money를 값으로 사용할 때, Kotlin은 이를 그것의 companion object로 이해합니다. 따라서 클래스 Money 자체가 Parser<Money> 역할을 합니다.
5.2. Companion에 대한 확장 함수
companion을 이미 선언한 클래스라면, 소스 코드를 수정하지 않고도 클래스에 “static 같은” 함수를 추가할 수 있습니다:
fun Money.Factory.zero(currency: String): Money = Money(0, currency)
fun main() {
println(Money.zero("VND"))
}
Money(amount=0, currency=VND)
Companion에 이름이 없다면 fun Money.Companion.zero(...)로 작성합니다.
6. Java에서 object와 companion 호출하기
기본적으로, Java는 object와 companion을 일반적인 인스턴스로 봅니다:
String id = OrderIdGenerator.INSTANCE.next();
Customer c = Customer.Companion.of("a@b.com", "An");
이 호출 방식은 장황하고 읽기 어렵습니다. Kotlin은 Java 코드를 더 자연스럽게 보이도록 하는 세 가지 도구를 제공합니다:
class PaymentService {
companion object {
const val MAX_RETRY = 3
@JvmField
val SUPPORTED_CURRENCIES = listOf("VND", "USD")
@JvmStatic
fun create(): PaymentService = PaymentService()
fun legacyCreate(): PaymentService = PaymentService()
}
}
Java 쪽에서는 다음과 같이 호출합니다:
int retry = PaymentService.MAX_RETRY;
List<String> currencies = PaymentService.SUPPORTED_CURRENCIES;
PaymentService a = PaymentService.create();
PaymentService b = PaymentService.Companion.legacyCreate();
각 도구의 의미:
const val은 primitive 타입과 컴파일 타임에 알려진 값을 가진String에만 사용할 수 있습니다. 상수는 사용 위치에 인라인되고public static final필드가 됩니다.@JvmField는 public static 필드를 생성하며, getter는 생성하지 않습니다. 이 annotation은List처럼 컴파일 타임 상수가 아닌 값에 적합합니다.@JvmStatic은PaymentService클래스에 추가 static 메서드를 생성합니다. 이 annotation이 없는 함수는 여전히Companion을 통해 호출되어야 합니다.
프로젝트에 Kotlin 코드만 있다면, 이 annotation들이 필요하지 않습니다. 예외는 const val입니다: annotation에서 사용될 수 있기 때문에 여전히 유용합니다. 예: @Retry(maxAttempts = MAX_RETRY).
7. 데이터 없는 상태를 위한 data object
Kotlin 1.9부터, data object를 선언할 수 있습니다. 이 사용법은 sealed hierarchy에서 자주 볼 수 있습니다:
sealed interface PaymentResult {
data class Success(val transactionId: String) : PaymentResult
data class Failed(val reason: String) : PaymentResult
data object Pending : PaymentResult
}
fun main() {
println(PaymentResult.Pending)
println(PaymentResult.Success("TX-42"))
}
Pending
Success(transactionId=TX-42)
data를 빼면, toString()은 PaymentResult$Pending@1b6d3586 형태로 출력되어 로그에서 읽기 어렵습니다. data object는 또한 일관된 equals/hashCode를 생성합니다. 이것은 object가 다른 인스턴스로 deserialize될 때 중요합니다.
8. 테스트 작성하기
Companion object의 팩토리 메서드에 대한 테스트:
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.jupiter.api.Test
class CustomerTest {
@Test
fun `of가 이메일과 이름을 정규화한다`() {
val customer = Customer.of("An.Nguyen@Example.com", " Nguyễn An ")
assertThat(customer.email).isEqualTo("an.nguyen@example.com")
assertThat(customer.name).isEqualTo("Nguyễn An")
}
@Test
fun `이메일이 유효하지 않으면 of가 예외를 던진다`() {
assertThatThrownBy { Customer.of("khong-phai-email", "An") }
.isInstanceOf(IllegalArgumentException::class.java)
.hasMessageContaining("khong-phai-email")
}
@Test
fun `companion이 여러 줄을 파싱한다`() {
val result = parseAll(Money, listOf("100 VND"))
assertThat(result).containsExactly(Money(100, "VND"))
}
@Test
fun `object declaration은 항상 같은 인스턴스를 반환한다`() {
val first: DiscountPolicy = NoDiscount
val second: DiscountPolicy = NoDiscount
assertThat(first).isSameAs(second)
}
}
Customer.of와 Money.parse는 상태를 유지하지 않으므로 이 테스트들은 독립적으로 실행됩니다. OrderIdGenerator는 다릅니다: 카운터는 JVM의 전체 수명 동안 살아있습니다. next() == "ORD-1"을 확인하는 테스트는 테스트 실행 순서에 따라 통과하거나 실패할 것입니다. 이것이 다음 섹션의 첫 번째 실수입니다.
9. 흔한 실수와 사용하지 말아야 할 때
Object가 가변 상태를 보유합니다. 전역 상태는 테스트가 서로 의존하게 하고 병렬로 실행할 때 문제를 일으킵니다. Object가 상태가 필요하다면, 이를 class로 분리하고 필요한 곳에 인스턴스를 주입하세요 (dependency injection).
주의: Object declaration은 매개변수가 있는 생성자를 가질 수 없습니다. 싱글톤이 구성이나 다른 서비스에 의존하는 경우, 예를 들어
DataSource나 결제 게이트웨이 URL,object는 적절한 선택이 아닙니다.
Spring 애플리케이션에서 object를 사용합니다. Spring의 Bean은 기본적으로 이미 ApplicationContext 범위 내에서 싱글톤입니다. object PaymentService를 작성하면 constructor injection을 잃게 되고, 테스트에서 mock을 만들기 어렵고, 두 개의 병렬 생명주기 관리 메커니즘을 만들게 됩니다. 이 경우 @Service class PaymentService(...)를 사용하세요. 오직 순수한 부분에만 object를 사용하세요: 상수, utility 함수, 상태 없는 policy.
Companion object를 static과 혼동합니다. Companion은 진정한 인스턴스입니다. Customer.of(...)를 호출하는 것은 실제로 Customer.Companion.of(...)를 호출하는 것입니다. 이것의 비용은 무시할 수 있지만, Java가 왜 @JvmStatic이 필요한지를 설명합니다.
모든 utility 함수를 companion에 넣습니다. 클래스와 직접 관련되지 않은 함수의 경우, fun formatVnd(amount: Long): String과 같은 top-level function이 더 간결하고 찾기 쉽습니다.
초기화에서 순환 의존성. Object A의 init이 object B를 읽는데, 동시에 B가 A를 읽으면, non-null로 선언된 property에 대해 null을 받을 수 있습니다. Object 초기화 블록을 정말 간단하게 유지하세요.
10. 결론
Kotlin의 object 키워드는 세 가지 역할을 합니다:
- Object declaration은 JVM의 클래스 초기화 메커니즘 덕분에 지연되고 스레드 안전한 싱글톤을 만듭니다.
- Object expression은 무명 객체를 만듭니다. 표현식이 실행될 때마다 새로운 인스턴스가 생성됩니다.
- Companion object는
static을 대체합니다. 이름을 지을 수 있고, interface를 구현하고, 확장 함수를 받을 수 있습니다.
Java로 작업할 때, 당신은 const val, @JvmField 그리고 @JvmStatic을 사용해서 API를 자연스럽게 보이도록 만듭니다. 가장 중요한 규칙은 object를 가변 상태 없이 유지하는 것입니다. 의존성이 필요하거나 테스트에서 mock이 필요할 때, 일반적인 class, 또는 Spring bean이 항상 더 나은 선택입니다.