코틀린 베이직 KB 22 object 와 companion — static 이 없는 자리
코틀린 베이직 · 22/26

KB 22 object 와 companion — static 이 없는 자리

gabury1고친 사람 github-actions[bot]

0. 코틀린에서는 되는 호출이 자바에서는 에러다

코틀린에는 static 이라는 키워드가 없습니다. 그런데 번호를 하나씩 나눠 주는 카운터처럼 프로그램에 하나만 있으면 되는 객체는 여전히 필요합니다. 코틀린은 이것을 object 한 단어로 씁니다.

Kotlin
object Counter {
    private var count = 0

    fun next(): Int {
        count += 1
        return count
    }
}

fun main() {
    println(Counter.next())  // 1
    println(Counter.next())  // 2
}
  • object Counter { ... } — 클래스를 선언하면서 그 클래스의 인스턴스를 딱 하나 만들어 두는 문법입니다. 이름 Counter 가 곧 그 인스턴스입니다
  • private var count = 0 — 다시 대입할 수 있는 변수(var)입니다. private 이라 Counter 밖에서는 못 봅니다
  • fun next(): Int — Int 를 돌려주는 함수입니다. 코틀린은 반환 타입을 괄호 뒤 콜론 다음에 씁니다
  • fun main() — 프로그램이 시작하는 함수입니다. 클래스 없이 파일 바로 아래에 둡니다

Counter.next() 는 겉보기에 자바의 static 메서드 호출과 똑같습니다. 인스턴스를 만든 줄이 없는데 이름으로 바로 불렀고, 두 번 부르니 값이 이어졌습니다.

터미널
$ kotlinc Counter.kt -d out
$ kotlin -cp out CounterKt

코틀린 컴파일러 kotlinc 로 컴파일하고 kotlin 명령으로 돌렸습니다. -d out 은 결과물을 out 폴더에 쓰라는 옵션입니다. 출력은 전부 Kotlin 2.4.10, JDK 21 에서 돌린 결과입니다. 그럼 자바에서도 Counter.next() 로 부르면 될까요. 같은 호출을 UseCounter.java 의 main 에 한 줄 넣었습니다.

터미널
$ javac -cp out -d jout UseCounter.java
UseCounter.java:3: error: non-static method next() cannot be referenced from a static context
        System.out.println(Counter.next());
                                  ^
1 error

-cp out 은 코틀린 결과물이 든 out 폴더에서 클래스를 찾으라는 옵션입니다. 에러는 next() 가 static 메서드가 아니라서 클래스 이름으로는 부를 수 없다는 뜻입니다.

자바 쪽에서 통하는 호출은 이것입니다.

Java
public class UseCounter2 {
    public static void main(String[] args) {
        // ↓ 1
        System.out.println(Counter.INSTANCE.next());
        // ↓ 2
        System.out.println(Counter.INSTANCE.next());
    }
}
터미널
$ javac -cp out -d jout UseCounter2.java
$ java -cp out:jout UseCounter2

컴파일도 되고 1, 2 가 찍혔습니다. INSTANCE 는 소스 어디에도 쓴 적 없는 이름입니다. 컴파일러가 Counter 클래스에 만들어 둔 static 필드이고, 그 안에 단 하나뿐인 인스턴스가 들어 있습니다.

코틀린의 Counter.next() 는 static 호출이 아니라 인스턴스 하나를 거친 호출입니다. 코틀린 소스는 그 인스턴스를 이름 뒤로 감춰 주고, 자바는 감추지 않습니다. 이 편은 이 차이에서 출발해 object·companion object·object 식(이름 없는 객체)을 차례로 보고, 6절에서 object 가 언제 만들어지는지를 찍어 봅니다.

1. object 선언 — 싱글턴을 한 줄로

자바에서 싱글턴(singleton)은 인스턴스가 하나만 있도록 짠 클래스입니다. 생성자를 private 으로 막고, static final 필드에 인스턴스를 하나 담고, 그것을 돌려주는 getInstance() 를 두는 모양이 흔합니다. 코틀린의 object 선언은 이 틀을 한 줄로 줄입니다. 이 절은 object 가 값으로 어떻게 쓰이는지와, 생성자를 부르려 하면 무엇이 막히는지를 봅니다.

object 는 타입이자 값 하나다

object 도 인터페이스를 구현할 수 있습니다. 문자열을 길이순으로 비교하는 인터페이스 Comparator 구현을 object 로 만들어, 변수에 담아 보고 정렬에도 넘겼습니다.

Kotlin
object ByLength : Comparator<String> {
    override fun compare(a: String, b: String): Int =
        a.length - b.length
}

fun main() {
    val words = listOf("ccc", "a", "bb")
    val sorter: Comparator<String> = ByLength
    println(ByLength === sorter)  // true
    // ↓ [a, bb, ccc]
    println(words.sortedWith(ByLength))
}
  • object ByLength : Comparator<String> — 콜론 뒤가 구현하는 인터페이스입니다. 자바의 implements Comparator<String> 에 해당합니다
  • override fun compare(...) — 인터페이스 메서드를 구현한다는 표시입니다. 코틀린은 override 를 빼면 컴파일 에러입니다
  • = a.length - b.length — 등호 뒤에 돌려줄 값을 바로 적는 한 줄 함수입니다
  • === — 같은 객체인지 보는 비교입니다. 자바 == 의 참조 비교에 해당합니다
터미널
$ kotlinc Single.kt -d out
$ kotlin -cp out SingleKt

ByLength 를 변수 sorter 에 담아도 같은 객체입니다. 정렬 함수 sortedWith 에는 ByLength 라는 이름을 그냥 넘겼습니다. 자바라면 new ByLength() 를 부르거나 ByLength.INSTANCE 를 넘겼을 곳입니다.

상태가 없는 인터페이스 구현은 object 로 두기 좋습니다. 비교 규칙처럼 필드가 없는 구현은 여러 개 만들 이유가 없고, 부르는 쪽은 이름 하나만 알면 됩니다.

object 에 괄호를 붙여 val c = Counter() 로 하나 더 만들려고 하면 kotlinc 가 error: unresolved reference 'Counter'. 를 냅니다. 「생성자가 private 이다」가 아니라 부를 수 있는 Counter 가 없다는 에러입니다. object 에는 코틀린 소스에서 호출할 생성자 자체가 없습니다.

조심할 것 — var 가 든 object 는 전역 변수다

0절의 Counter 는 var count 를 들고 있습니다. object 는 프로그램에 하나뿐이니, 이 변수는 모든 코드와 모든 스레드가 나눠 쓰는 전역 변수입니다.

count += 1 은 읽고, 더하고, 쓰는 세 단계입니다. 두 스레드가 동시에 부르면 둘 다 같은 값을 읽고 같은 값을 써서 한 번이 빠질 수 있습니다. 이런 사고를 경쟁 상태라 부릅니다. 여러 스레드가 부르는 object 에 셀 값을 둔다면 java.util.concurrent.atomic.AtomicInteger 처럼 스레드에 안전한 타입을 씁니다.

정리하면, object 는 생성자 없는 싱글턴이고 이름이 곧 인스턴스입니다. 상태가 없을 때 가장 편하고, 상태를 넣으면 전역 변수가 된다는 것을 기억해 둡니다.

2. companion object — 클래스에 붙는 이름

자바에서는 한 클래스 안에 인스턴스 멤버와 static 멤버를 섞어 씁니다. User 클래스 안에 static User of(String name) 같은 팩토리 메서드(객체를 대신 만들어 주는 메서드)나 static final int MAX_NAME 상수를 두는 식입니다.

코틀린에는 static 이 없으니, 클래스 안에 이름 없는 object 를 하나 두고 거기에 이런 멤버를 모읍니다. 이것이 companion object(동반 객체)입니다. 이 절에서는 팩토리와 상수를 가진 User 를 코틀린과 자바 양쪽에서 불러 봅니다.

Kotlin
class User private constructor(val name: String) {
    companion object {
        const val MAX_NAME = 5

        fun of(name: String): User =
            User(name.take(MAX_NAME))
    }
}

fun main() {
    println(User.of("kotlin").name) // kotli
    println(User.MAX_NAME)          // 5
    // ↓ ab
    println(User.Companion.of("ab").name)
}
  • class User private constructor(val name: String) — 클래스 이름 뒤 괄호가 주생성자이고, val name 은 생성자 인자이면서 프로퍼티(필드와 게터를 한 이름으로 묶은 것)입니다. private constructor 라 User 밖에서는 User("...") 로 못 만듭니다
  • const val MAX_NAME = 5 — 컴파일할 때 값이 정해지는 상수입니다. 3절에서 자바 쪽 모양을 봅니다
  • name.take(5) — 앞에서부터 다섯 글자까지 잘라 돌려줍니다
  • companion object — 이름을 안 붙이면 Companion 이라는 이름이 됩니다. companion object Factory 처럼 이름을 붙일 수도 있습니다
터미널
$ kotlinc User.kt -d out
$ kotlin -cp out UserKt

코틀린에서는 User.of(...) 로 클래스 이름 바로 뒤에 동반 객체의 함수를 붙여 불렀습니다. User.Companion.of(...) 처럼 동반 객체 이름을 넣어도 같은 함수가 불렸습니다. 생성자를 private 으로 막고 동반 객체에 of 를 두는 모양은 자바의 static 팩토리 메서드와 같은 쓰임입니다. 이름 길이를 자르는 규칙을 of 한 곳에 두어, 규칙을 거치지 않은 User 가 만들어지지 않게 합니다.

자바에서는 Companion 을 거친다

두 호출을 자바로 옮겼습니다. 첫 줄은 동반 객체 이름을 넣었고, 둘째 줄은 코틀린처럼 클래스 이름 뒤에 바로 붙였습니다.

Java
public class UseUser {
    public static void main(String[] args) {
        System.out.println(User.Companion.of("kotlin").getName());
        System.out.println(User.of("kotlin").getName());
    }
}
터미널
$ javac -cp out -d jout UseUser.java
UseUser.java:4: error: cannot find symbol
        System.out.println(User.of("kotlin").getName());
                               ^
  symbol:   method of(String)
  location: class User
1 error

에러는 4번째 줄 하나뿐이고 User.Companion.of(...) 는 통과했습니다. 자바가 보기에 User 클래스에는 of 라는 메서드가 없습니다. of 는 User 안에 든 동반 객체 Companion 의 인스턴스 메서드입니다.

코틀린 문서도 같은 말을 합니다. 동반 객체의 멤버는 static 멤버처럼 보이지만 실은 동반 객체라는 인스턴스의 멤버라고 적습니다(공식 문서). name 이 자바에서 getName() 이 되는 것은 코틀린 프로퍼티가 자바에는 게터로 보이기 때문입니다.

정리하면, 동반 객체는 클래스에 하나씩 붙는 object 입니다. 코틀린에서는 클래스 이름으로 바로 부르고, 자바에서는 클래스.Companion(이름을 붙였으면 그 이름)을 거쳐 부릅니다.

3. @JvmStatic 과 const — 자바에서 static 처럼 부르고 싶을 때

자바 코드와 같이 쓰는 프로젝트라면 User.Companion.of(...) 는 거슬립니다. 자바 쪽에서도 User.of(...) 로 부르고 싶다면 코틀린 쪽 선언에 표시를 붙입니다. 함수에는 @JvmStatic, 상수에는 const, 상수가 아닌 값에는 @JvmField 입니다.

@ 로 시작하는 것은 애노테이션(annotation)으로, 선언에 붙여 컴파일러에게 정보를 주는 표시입니다. 이 절은 세 표시를 붙인 선언과 안 붙인 선언을 섞어 자바에서 부르고, 무엇이 통하는지를 봅니다.

Kotlin
class Price(val won: Int) {
    companion object {
        const val CURRENCY = "KRW"
        @JvmField val ZERO = Price(0)
        val FREE = Price(0)

        @JvmStatic fun of(won: Int): Price = Price(won)
        fun plain(won: Int): Price = Price(won)
    }
}

object Ids {
    @JvmStatic fun next(): Int = 7
}
  • const val — 문자열과 Int 같은 기본 타입 값에만 붙습니다. 조건은 KB 05 val 과 var — final 과 무엇이 같고 다른가 에서 에러로 확인했습니다
  • @JvmField val ZERO — 게터 없이 필드를 자바에 그대로 드러내라는 표시입니다
  • @JvmStatic fun of — 이 함수를 자바에서 static 메서드로도 부를 수 있게 하라는 표시입니다
  • FREE 와 plain 은 아무 표시도 없는 비교 대상입니다

자바 쪽입니다. p 는 한 줄 출력을 짧게 쓰려고 둔 도우미 메서드입니다.

Java
public class UsePrice {
    static void p(Object o) { System.out.println(o); }

    public static void main(String[] args) {
        p(Price.of(100).getWon()); // 100
        p(Price.CURRENCY);         // KRW
        p(Price.ZERO.getWon());    // 0
        p(Ids.next());             // 7
        // ↓ 200
        p(Price.Companion.of(200).getWon());
        // ↓ 0
        p(Price.Companion.getFREE().getWon());
    }
}
터미널
$ kotlinc Price.kt -d out
$ javac -cp out -d jout UsePrice.java
$ java -cp out:jout:kotlin-stdlib.jar UsePrice

kotlin-stdlib.jar 는 코틀린 표준 라이브러리 jar 로, java 명령은 이것을 스스로 넣지 않아서 classpath(클래스를 찾을 경로 목록)에 적었습니다.

앞의 넷이 전부 클래스 이름 바로 뒤에 붙었습니다. object 안의 next 도 INSTANCE 없이 Ids.next() 가 됐습니다. 그런데 @JvmStatic 을 붙인 of 는 Price.Companion.of(200) 으로도 불렸습니다.

표시 없는 plain 과 FREE 를 클래스 이름으로 부르는 두 줄을 BadPrice.java 에 담았습니다.

터미널
$ javac -cp out -d jout BadPrice.java
BadPrice.java:3: error: cannot find symbol
        System.out.println(Price.plain(100).getWon());
                                ^
  symbol:   method plain(int)
  location: class Price
BadPrice.java:4: error: FREE has private access in Price
        System.out.println(Price.FREE.getWon());
                                ^
2 errors

plain 은 2절의 User.of 와 같은 cannot find symbol 입니다. 그런데 FREE 는 에러가 다릅니다. Price 에 FREE 가 없는 게 아니라 private 이라서 못 본다고 합니다. 동반 객체의 프로퍼티인데 필드는 바깥 클래스 Price 에 있다는 뜻입니다.

클래스 파일에 무엇이 생겼나

@JvmStatic 이 동반 객체의 함수를 Price 로 옮긴 것인지, 하나 더 만든 것인지는 소스만 봐서는 모릅니다. 필드가 어느 클래스에 들었는지도 마찬가지입니다. 그래서 이 편에서 한 번, javap 로 Price 의 선언 목록을 열었습니다. JDK 에 딸린 도구이고 -p 는 private 멤버까지 보여 달라는 옵션입니다.

터미널
$ javap -p out/Price.class
Compiled from "Price.kt"
public final class Price {
  public static final Price$Companion Companion;
  private final int won;
  public static final java.lang.String CURRENCY;
  public static final Price ZERO;
  private static final Price FREE;
  public Price(int);
  public final int getWon();
  public static final Price of(int);
  public static final Price access$getFREE$cp();
  static {};
}
  • Price$Companion — 동반 객체의 클래스 이름입니다. $ 는 클래스 안에 든 클래스(중첩 클래스)의 이름을 잇는 표기입니다
  • access$getFREE$cp() — 동반 객체의 게터가 바깥 클래스의 private 필드를 읽으려고 컴파일러가 만든 통로입니다. 직접 부를 일은 없습니다
  • static {} — 클래스가 처음 쓰일 때 한 번 도는 정적 초기화 블록입니다. 동반 객체의 인스턴스와 필드 값이 여기서 채워집니다

첫 줄의 Companion 이 2절의 User.Companion 의 정체로, 동반 객체의 인스턴스를 담은 static 필드입니다. 세 필드는 모두 Price 에 static 필드로 들었고, 표시에 따라 공개 여부만 갈렸습니다.

코틀린 선언 Price 에 생긴 것 자바에서 부르는 법
const val CURRENCY public static final 필드 Price.CURRENCY
@JvmField val ZERO public static final 필드 Price.ZERO
val FREE private static final 필드 Price.Companion.getFREE()
@JvmStatic fun of public static 메서드 Price.of(...) · Price.Companion.of(...)
fun plain 없음 Price.Companion.plain(...)

@JvmStatic 은 옮기는 것이 아니라 하나 더 만드는 것입니다. 목록에는 Price.of 가 static 으로 있고, 위 실행에서 Price.Companion.of 도 불렸습니다. 코틀린 문서도 @JvmStatic 을 붙이면 동반 객체의 멤버를 JVM 의 진짜 static 메서드와 필드로 만들 수 있다고 적습니다(공식 문서).

const 와 @JvmField 는 자바에서 보는 모양이 같지만 갈리는 곳이 있습니다. const 는 컴파일할 때 값이 정해지는 기본 타입과 문자열에만 붙고, 읽는 쪽 코드에 값이 복사됩니다(KB 02 자바에서 부르고 실행하기 — 파일 이름, const, kotlin-stdlib). Price(0) 같은 객체는 const 가 안 되니 @JvmField 를 씁니다.

정리하면, 자바 쪽 코드가 부르는 동반 객체 멤버에만 표시를 붙입니다. 함수는 @JvmStatic, 상수는 const, 객체 값은 @JvmField 입니다. 코틀린끼리만 쓰는 코드라면 표시가 없어도 부르는 모양이 같으니 붙일 이유가 없습니다.

4. object 식 — 이름 없는 객체

object 에는 이름 없이 쓰는 모양도 있습니다. 식 가운데에 object : 인터페이스 { ... } 를 쓰면 그 인터페이스를 구현한 객체가 그때 하나 만들어집니다. 이것을 object 식(object expression)이라 부르고, 자바의 익명 클래스 new Greeter() { ... } 에 해당합니다.

이 절은 인사말을 만드는 인터페이스 Greeter 를 두고, object 식이 바깥 변수를 바꿀 수 있는지와 부를 때마다 새로 만들어지는지를 봅니다.

Kotlin
interface Greeter {
    fun greet(name: String): String
}

fun polite(): Greeter = object : Greeter {
    override fun greet(name: String) = "Hello, $name"
}

fun main() {
    var calls = 0
    val counting = object : Greeter {
        override fun greet(name: String): String {
            calls += 1
            return "Hi, $name"
        }
    }
    // ↓ Hi, kim
    println(counting.greet("kim"))
    // ↓ Hi, lee
    println(counting.greet("lee"))
    println(calls)                 // 2
    println(polite() === polite()) // false
}
  • object : Greeter { ... } — Greeter 를 구현한 이름 없는 객체를 만드는 식입니다. 식이라 변수에 담거나 함수에서 돌려줄 수 있습니다
  • "Hi, $name" — 문자열 템플릿입니다. 문자열 안의 $name 에 변수 값이 끼워집니다
터미널
$ kotlinc Anon.kt -d out
$ kotlin -cp out AnonKt

두 가지가 보입니다. 객체 안에서 바깥의 var calls 를 두 번 올렸고 2 가 찍혔습니다. polite() 를 두 번 부르니 서로 다른 객체가 나왔습니다.

뒤의 것이 1절의 object 선언과 갈리는 점입니다. object 선언은 프로그램에 하나지만, object 식은 그 식이 실행될 때마다 새 객체를 만듭니다. 코틀린 문서도 object 식은 쓰인 곳에서 곧바로 실행되고 초기화된다고 적습니다(공식 문서).

자바 익명 클래스는 바깥 변수를 못 바꾼다

앞의 것을 자바로 옮겼습니다.

Java
interface Greeter {
    String greet(String name);
}

public class Anon {
    public static void main(String[] args) {
        int calls = 0;
        Greeter counting = new Greeter() {
            @Override
            public String greet(String name) {
                calls += 1;
                return "Hi, " + name;
            }
        };
        System.out.println(counting.greet("kim"));
    }
}
터미널
$ javac -d jout Anon.java
Anon.java:11: error: local variables referenced from an inner class must be final or effectively final
                calls += 1;
                ^
1 error

자바 익명 클래스는 바깥 지역 변수를 읽을 수만 있습니다. 그 변수가 final 이거나, 한 번 대입된 뒤 바뀌지 않아야(effectively final) 합니다. 자바에서 같은 일을 하려면 int[] calls = {0}; 처럼 한 칸짜리 배열을 두거나 AtomicInteger 를 두고 그 안의 값을 바꿉니다.

코틀린은 이 제한이 없어서 calls += 1 이 그냥 됩니다. 다만 이 객체를 다른 스레드에 넘기면 calls 도 여러 스레드가 나눠 쓰는 변수가 됩니다. 메서드가 하나뿐인 인터페이스라면 object 식보다 짧은 람다를 쓰는 경우가 많습니다. 그 이야기는 뒤에 나올 람다 편에서 합니다.

정리하면, object 식은 자바 익명 클래스를 대신합니다. 부를 때마다 새로 만들어지고, 바깥 var 를 바꿀 수 있다는 점이 자바와 다릅니다.

5. 언제 톱레벨 함수, 언제 object 인가

static 메서드를 모아 둔 자바 유틸 클래스를 코틀린으로 옮길 때 고를 것이 셋입니다. 톱레벨 함수, object, 동반 객체입니다. 톱레벨(top-level) 함수는 클래스 밖, 파일 바로 아래에 쓴 함수입니다. 코틀린 컴파일러는 이것을 파일 이름 + Kt 클래스의 static 메서드로 넣습니다. Strings.kt 에 쓴 fun slug(s: String) 은 자바에서 StringsKt.slug(s) 로 부릅니다. 0절의 CounterKt 가 그렇게 생긴 이름입니다.

옮길 것 고를 것 까닭
상태 없는 유틸 함수 (StringUtils.slug) 톱레벨 함수 인스턴스가 필요 없다. 코틀린에서 이름만으로 부른다
인터페이스 구현 하나로 충분한 것 (Comparator) object 값으로 넘겨야 하니 객체가 있어야 한다
특정 클래스에 딸린 팩토리·상수 (User.of) 동반 객체 private 생성자에 닿을 수 있고, 클래스 이름으로 찾기 쉽다
다른 객체에 기대는 서비스 (OrderService) 보통 class 아래에서 본다

유틸 함수를 object StringUtils { fun slug(...) } 로 감싸도 동작은 합니다. 다만 부를 때마다 StringUtils. 가 붙고, 자바에서는 INSTANCE 까지 붙습니다. 묶을 이름이 필요한 게 아니라면 톱레벨 함수가 짧습니다.

스프링 빈을 object 로 만들면 안 되는 까닭

스프링 빈(bean)은 스프링 컨테이너가 만들어 들고 있다가 필요한 곳에 넣어 주는 객체입니다. 빈이 다른 빈을 쓸 때는 보통 생성자 파라미터로 받습니다. 이것을 생성자 주입이라 부릅니다. 스프링 빈은 컨테이너 안에서 기본으로 하나만 만들어지니, 싱글턴인 object 로 쓰면 될 것 같아 보입니다.

object 에 생성자 파라미터를 달아 보면 첫 문제가 드러납니다.

Kotlin
class Repo

// ↓ 컴파일 오류
object Service(val repo: Repo)
터미널
$ kotlinc Service.kt -d out
Service.kt:3:15: error: objects cannot have constructors.
object Service(val repo: Repo)
              ^^^^^^^^^^^^^^^^

object 는 생성자를 가질 수 없어서 생성자 주입을 못 받습니다. 기대는 것을 밖에서 넣어 줄 길이 막히니, 테스트에서 가짜 Repo 로 바꿔 끼우기도 어렵습니다.

둘째 문제는 인스턴스가 둘이 된다는 것입니다. @Component 를 붙인 object 를 스프링 컨테이너에 등록하고, 컨테이너가 준 빈이 object 와 같은 객체인지 찍었습니다.

Kotlin
import org.springframework.context.annotation.*
import org.springframework.stereotype.Component

@Component
object Counter {
    init {
        println("Counter init")
    }
}

fun main() {
    val ctx = AnnotationConfigApplicationContext(Counter::class.java)
    val bean = ctx.getBean(Counter::class.java)
    println(bean === Counter)
}
  • import ...annotation.* — 그 패키지의 이름을 전부 가져옵니다. 자바의 import ....*; 와 같습니다
  • @Component — 스프링에게 이 클래스를 빈으로 만들라고 알리는 애노테이션입니다. init { ... } 은 초기화 때 도는 블록입니다(6절에서 다시 봅니다)
  • AnnotationConfigApplicationContext(Counter::class.java) — 스프링 컨테이너를 코드로 띄우며 Counter 를 등록합니다. Counter::class.java 는 자바의 Counter.class 입니다
  • ctx.getBean(...) — 컨테이너가 들고 있는 그 타입의 빈을 꺼냅니다
터미널
$ kotlinc Bean.kt -cp "$SPRING" -d out
$ kotlin -cp "out:$SPRING" BeanKt
Counter init
false

$SPRING 은 Spring Framework 6.2.19 의 jar 들(context·beans·core·aop·expression·jcl)과 그것이 쓰는 micrometer jar 둘을 이은 classpath 입니다.

false 입니다. 그런데 Counter init 은 한 번뿐이라, 컨테이너가 두 번째 인스턴스를 만들 때는 init 이 다시 돌지 않았습니다. 스프링은 private 생성자를 리플렉션(실행 중에 클래스의 생성자·메서드를 찾아 부르는 기능)으로 불러 인스턴스를 하나 더 만들었습니다. 코드에서 이름 Counter 로 부르는 객체와 컨테이너가 관리하는 빈이 서로 다른 객체입니다. 스프링이 빈에 씌우는 트랜잭션 같은 프록시도 컨테이너 쪽 빈에 붙으니, 이름으로 직접 부르면 거치지 않게 됩니다.

정리하면, 스프링이 관리할 객체는 보통 class 로 두고 생성자로 의존을 받습니다. 하나만 만드는 일은 컨테이너가 맡습니다. object 는 스프링 밖에서 쓰는, 기대는 것이 없는 값에 씁니다.

6. 성능 — object 는 처음 쓸 때 한 번 만들어진다

object 는 프로그램에 하나라고 했습니다. 그럼 그 하나는 언제 만들어질까요. 프로그램이 뜰 때 미리일까요, 처음 부를 때일까요. init 블록에 println 을 넣은 object 와 동반 객체를 두고, main 의 어느 줄 사이에서 찍히는지 봤습니다. init 블록은 인스턴스가 만들어질 때 도는 코드입니다(KB 13 init 과 부생성자 — 초기화는 선언 순서대로).

Kotlin
object Registry {
    const val MAX = 3

    init {
        println("Registry init")
    }

    fun hello(): String = "hello"
}

class Shop {
    companion object {
        init {
            println("Shop.Companion init")
        }
    }
}

fun main() {
    println("main start")
    println(Registry.MAX)
    println("before hello")
    println(Registry.hello())
    println(Registry.hello())
    Shop()
    println("main end")
}

Registry init 은 몇 번째 줄에 찍힐까요. Registry.MAX 를 읽는 줄도 Registry 를 쓰는 줄입니다.

터미널
$ kotlinc Order.kt -d out
$ kotlin -cp out OrderKt
main start
3
before hello
Registry init
hello
hello
Shop.Companion init
main end

Registry init 은 before hello 다음, hello() 를 처음 부를 때 한 번 찍혔습니다. 두 번째 hello() 에서는 다시 찍히지 않았습니다. 코틀린 문서도 object 선언의 초기화는 처음 접근할 때 일어난다고 적습니다(공식 문서).

Registry.MAX 를 읽은 줄에서는 init 이 돌지 않았습니다. const val 은 읽는 쪽 코드에 값 3 이 복사돼서, 그 줄은 Registry 를 건드리지 않습니다(KB 05 에서 본 성질입니다).

동반 객체는 조건이 다릅니다. Shop() 은 동반 객체를 한 번도 부르지 않았는데 Shop.Companion init 이 찍혔습니다. 동반 객체는 바깥 클래스가 처음 쓰일 때 같이 초기화됩니다. 문서는 이것을 자바의 static 초기화 블록과 같은 규칙이라고 적습니다(위 공식 문서).

여러 스레드가 동시에 처음 부르면

처음 쓸 때 만든다면, 두 스레드가 동시에 처음 부를 때 둘 다 만들지는 않을까요. init 에서 일부러 200 밀리초를 쉬는 object 를 두고, 스레드 여덟이 동시에 닿게 했습니다.

Kotlin
object Heavy {
    init {
        println("Heavy init")
        Thread.sleep(200)
    }
}

fun main() {
    val got = arrayOfNulls<Any>(8)
    val threads = List(8) { i ->
        Thread { got[i] = Heavy }
    }
    threads.forEach { it.start() }
    threads.forEach { it.join() }
    println(got.all { it === Heavy })
}
  • arrayOfNulls<Any>(8) — null 이 든 여덟 칸 배열입니다. 자바의 new Object[8] 에 해당합니다
  • List(8) { i -> ... } — i 를 0 부터 7 까지 넣어 블록을 불러 만든 여덟 개짜리 리스트입니다
  • Thread { ... } — 자바의 new Thread(() -> ...) 입니다. 스레드마다 Heavy 를 배열 칸에 담습니다
  • got.all { it === Heavy } — 모든 칸이 Heavy 와 같은 객체인지 봅니다
터미널
$ kotlinc Threads.kt -d out
$ kotlin -cp out ThreadsKt
Heavy init
true

세 번 돌렸고 세 번 다 이 두 줄이었습니다. init 은 한 번만 돌았고, 여덟 스레드가 받은 것은 모두 같은 객체입니다. 까닭은 JVM 의 클래스 초기화 규칙에 있습니다. JVM 은 한 클래스의 초기화를 한 스레드에게만 맡기고, 그동안 같은 클래스에 닿은 다른 스레드는 끝날 때까지 기다리게 합니다(자바 언어 명세 12.4.2). 코틀린 문서도 object 선언의 초기화가 스레드에 안전하다고 적습니다. 자바에서 synchronized 와 이중 검사(잠금 앞뒤로 인스턴스가 비었는지 두 번 보는 방식)로 짜던 지연 생성 싱글턴을 손으로 짤 필요가 없습니다.

언제 신경 쓰고 언제 무시하나

초기화가 끝난 뒤 object 를 쓰는 것은 static 필드 하나를 읽는 일입니다. 부를 때마다 잠금을 거는 것도 아니니, 반복문 안에서 object 를 부르는 비용은 신경 쓰지 않아도 됩니다.

신경 쓸 것은 init 이 무거울 때입니다. 파일을 읽거나 연결을 여는 일을 init 에 두면, 그 일은 처음 부른 스레드의 시간에 벌어집니다. 서버라면 첫 요청이 느려지고, 위 실험처럼 같은 순간 닿은 다른 스레드도 그만큼 기다립니다. 동반 객체의 init 은 인스턴스를 만들기만 해도 돈다는 것도 같이 기억해 둡니다.

init 이 예외를 던지면 더 오래 갑니다. init 에서 error("boom") 을 던지는 object Broken 을 두 번 불렀더니, 첫 번째는 java.lang.ExceptionInInitializerError, 두 번째는 java.lang.NoClassDefFoundError: Could not initialize class Broken 이 났습니다. 초기화에 한 번 실패한 클래스는 그 프로세스가 끝날 때까지 다시 초기화되지 않습니다. 실패할 수 있는 일은 init 보다 명시적으로 부르는 함수에 두는 편이 낫습니다.

정리하면, object 는 처음 쓸 때 한 번, 스레드에 안전하게 만들어지고 그 뒤로는 비용이 없습니다. 조심할 것은 init 이 무겁거나 실패할 수 있을 때뿐입니다.

7. 한 장 요약

코틀린에는 static 이 없다. 하나만 있으면 되는 객체는 object, 클래스에 딸린 멤버는 companion object 에 둔다. 코틀린에서는 둘 다 이름으로 바로 부르지만, 실은 인스턴스 하나를 거친 호출이라 자바에서는 INSTANCE·Companion 이 보인다. 자바 쪽을 static 처럼 만들려면 @JvmStatic·const·@JvmField 를 붙인다. object 는 처음 쓸 때 한 번 스레드에 안전하게 만들어지고, 생성자가 없어서 스프링 빈으로는 맞지 않는다.

하는 일 자바 코틀린 확인한 방법
싱글턴 private 생성자 + static final 인스턴스 object Counter { ... }. 자바에서는 Counter.INSTANCE.next() 실행 · javac 에러
클래스에 딸린 팩토리·상수 static 메서드·필드 companion object { ... } 실행
동반 객체를 자바에서 부르기 — User.Companion.of(...). 이름을 붙이면 그 이름 javac 에러
자바에서 static 처럼 — 함수 @JvmStatic · 상수 const · 객체 값 @JvmField javac · javap -p
익명 객체 익명 클래스. 바깥 변수는 읽기만 object : Greeter { ... }. 바깥 var 를 바꿀 수 있고 실행마다 새 객체 실행 · javac 에러
스프링 빈 @Component 클래스 보통 class. object 는 생성자가 없고 빈이 따로 생긴다 kotlinc 에러 · Spring 6.2.19 실행
만들어지는 때 static 초기화 블록 object 는 처음 접근 때 한 번, 동반 객체는 바깥 클래스가 처음 쓰일 때 실행 출력

관련 항목

object 가 대신하는 자바 문법

싱글턴 패턴 · 정적 메서드 · 정적 필드 · 정적 초기화 블록 · 익명 클래스 · 정적 팩토리 메서드

object 와 함께 쓰는 코틀린 선언

object 선언 · companion object · object 식 · init 블록 · 톱레벨 함수 · 중첩 클래스 · const val

자바에서 부르는 모양을 바꾸는 애노테이션

@JvmStatic · @JvmField · @JvmOverloads · @JvmName · 애노테이션

object 초기화를 지키는 JVM 규칙과 그 실패

클래스 초기화 · 클래스 로더 · 지연 초기화 · ExceptionInInitializerError · NoClassDefFoundError

object 의 상태를 여러 스레드가 나눌 때 쓰는 도구

경쟁 상태 · 스레드 안전성 · AtomicInteger · 락 · 이중 검사 잠금

object 로 두면 어긋나는 스프링 기능

스프링 빈 · 스프링 컨테이너 · 의존성 주입 · 생성자 주입 · 프록시 · 리플렉션

이 편의 결과를 확인한 도구

kotlinc · javac · javap · 클래스 파일 · 컴파일러 · JVM