안드로이드 위클리 공부 노트

#749

companion object 대신 companion 블록 — Kotlin 2.5의 새 static

원문 The Evolution of Statics: Say Goodbye to Kotlin Companion Objects?, Nav Singh

한 줄 요약

Kotlin에서 "클래스에 딸린 상수·함수"를 둘 때 늘 쓰던 companion object 는 사실 객체 하나와 클래스 하나를 더 만드는 방식이다. Kotlin 2.5부터 실험 기능으로 들어온 companion 블록은 object 라는 단어만 빼면 되고, 컴파일하면 Java의 static 처럼 클래스에 바로 붙는다. 함께 들어온 companion 확장은 내가 만들지 않은 클래스에도 바깥에서 Task.fromLine(...) 같은 "클래스 이름으로 부르는 함수"를 붙일 수 있게 해 준다.

원문(Medium 글)에는 라이선스 표시가 없어서 코드를 그대로 옮기지 않았다. 원문의 코드 예제 다섯 개를 같은 구조로, 할 일(Task) 예제로 직접 다시 썼다. 원문의 그림 두 장(AI로 만든 비교 그림)도 같은 내용을 한국어 그림으로 다시 그렸다. 공식 설명은 원문이 참고한 JetBrains 블로그 글에서 함께 확인했다.

먼저 알아 둘 말

서론 — 지금까지는 companion object를 썼다

원문은 이렇게 시작한다.

원문의 첫 예제와 같은 구조로 쓰면 이렇다.

data class Task(val title: String, val done: Boolean) {
    companion object {
        @JvmStatic
        fun empty(): Task = Task("", false)
    }
}

직접 쓴 스케치 — 원문 서론 예제(User.createDefault())와 같은 구조

Kotlin 2.5는 이 문제를 풀려고 두 가지를 새로 들여온다.

1. companion 블록과 companion object는 무엇이 다른가

속 구조

인터페이스는 여전히 companion object만 구현할 수 있다

Java에서 부르는 방법

그림으로 보면

세 방식을 컴파일했을 때 생기는 클래스 비교. companion object 는 Task.class 에 Companion 객체 필드가 생기고 Task$Companion.class 가 하나 더 생긴다. companion 블록은 Task.class 에 바로 static 메서드가 생긴다. companion 확장은 확장을 적은 파일의 클래스 TaskExtKt.class 에 static 메서드가 생긴다.
세 방식을 컴파일했을 때 생기는 클래스. 그림을 누르면 크게 볼 수 있다. 원문의 비교 그림 두 장의 내용을 바탕으로, 컴파일 결과까지 보이도록 한국어로 다시 그렸다.

2. companion 확장과 보통 확장 함수는 무엇이 다른가

3. 직접 써 보기 — 프로젝트 설정

companion 블록과 확장은 Kotlin 2.5.0-Beta1부터 실험 기능으로 들어왔다. 써 보려면 두 가지를 설정한다.

① 미리보기용 저장소를 추가한다 (settings.gradle.kts)

② 컴파일러 버전과 실험 플래그를 지정한다 (build.gradle.kts)

plugins {
    kotlin("jvm") version "2.5.0-Beta1"
}

kotlin {
    jvmToolchain(21)

    compilerOptions {
        // companion 블록과 companion 확장을 모두 켠다
        freeCompilerArgs.add("-Xcompanion-blocks-and-extensions")
    }
}

직접 쓴 스케치 — 원문의 설정과 같은 내용을 공식 블로그 기준으로 정리

4. 예전 방식 — companion object와 컴파일 결과

data class Task(val title: String, val done: Boolean) {

    companion object {
        val EMPTY = Task("", false)
        fun sample() = Task("장보기", false)
    }
}

직접 쓴 스케치 — 원문 UserWithCompanionObj 와 같은 구조

이걸 디컴파일하면 대략 이런 모양이다.

public final class Task {
    private static final Task EMPTY = new Task("", false);
    public static final Companion Companion = new Companion(null);

    public static final class Companion {
        private Companion() {}

        public final Task getEMPTY() {
            return Task.EMPTY;
        }

        public final Task sample() {
            return new Task("장보기", false);
        }
    }
}

직접 쓴 스케치 — 원문의 디컴파일 결과를 바탕으로, 원문에서 생략된 Companion 필드 한 줄을 더해 정리

5. 새 방식 — companion 블록과 companion 확장

data class Task(val title: String, val done: Boolean) {
    // JVM에서 Task 클래스의 static 멤버가 된다. 추가 객체가 없다.
    companion {
        val EMPTY = Task("", false)

        fun sample(): Task = Task("장보기", false)
    }
}

// TaskExt.kt — 다른 파일. 다른 모듈이어도 된다.
companion val Task.WELCOME: Task
    get() = Task("Aide 둘러보기", false)

companion fun Task.fromLine(line: String): Task {
    val done = line.startsWith("[x] ")
    return Task(line.removePrefix("[x] ").removePrefix("[ ] "), done)
}

fun main() {
    // 1. companion 블록 멤버
    val empty = Task.EMPTY
    val sample = Task.sample()

    // 2. companion 확장 멤버 — 클래스 안에 있는 것과 똑같이 부른다
    val welcome = Task.WELCOME
    val parsed = Task.fromLine("[x] 보고서 제출")
}

직접 쓴 스케치 — 원문 User 예제(ANONYMOUS · createDefault() · SYSTEM_ADMIN · fromJson())와 같은 구조

디컴파일하면 이런 모양이 된다.

// Task.java
public final class Task {
    private static final Task EMPTY = new Task("", false);

    public static final Task getEMPTY() {
        return EMPTY;
    }

    public static final Task sample() {
        return new Task("장보기", false);
    }
}

// TaskExtKt.java — 확장을 적은 파일 TaskExt.kt 의 이름을 딴 클래스
public final class TaskExtKt {
    public static final Task getWELCOME() {
        return new Task("Aide 둘러보기", false);
    }

    public static final Task fromLine(String line) {
        ...
    }
}

직접 쓴 스케치 — 원문의 디컴파일 결과(User-decompiled.java, UserKt.java)와 같은 구조로 정리

6. 공식 블로그가 덧붙인 것

원문이 참고 자료로 단 JetBrains 공식 글에는 원문에 없는 중요한 내용이 있다.

원문을 읽을 때 주의할 점

배운 점

Aide에 적용하면

Aide는 지금 Kotlin 2.2.10을 쓰고 있고, 아직 companion object 를 쓰는 코드가 하나도 없다. 그래서 "지금 바꿀 것"보다 "앞으로 짤 코드에서 무엇을 쓸지"의 문제다.

  1. companion 블록·확장 도입 — 지금은 안 한다.
    • Kotlin 2.5.0-Beta1 이상이 필요하고, 아직 실험 기능이다. 쓰려면 모든 모듈에 실험 플래그를 넣어야 한다.
    • Aide는 KSP로 코드를 만들어 내는 Hilt·Room을 쓴다. 공식 글이 "다른 도구가 아직 준비되지 않았을 수 있다"고 하니, 이 도구들이 새 문법을 지원한다고 확인되기 전에는 위험하다.
    • 1-a를 직접 손으로 짜며 배우는 지금, 실험 컴파일러에서 생기는 문제까지 겹치면 원인을 찾기 어려워진다.
  2. 상수는 companion object 대신 최상위에 둔다 — 지금 방식으로 바꿔서 가져온다.
    • 1-a 계획에는 상수가 필요한 곳이 여럿 있다. 예: DataStore(설정을 저장하는 Android 라이브러리)의 키(Task 5의 VisibleCalendarStore, Task 9의 SettingsRepository), 알림 채널 이름(Task 12).
    • 습관처럼 companion object { const val KEY = ... } 를 쓰기보다, 파일 최상위의 private const val 이나 internal object 로 둔다.
      • 최상위 const val 은 컴파일하면 이미 static이라 추가 객체가 없다.
      • Aide에는 Java 코드가 없어서 @JvmStatic 이 필요한 경우도 없다.
    • companion object는 "인터페이스를 구현해야 할 때"처럼 정말 필요할 때만 쓴다. 나중에 companion 블록으로 옮길 때도 옮길 곳이 적어진다.
  3. LocalTime.BRIEFING 같은 companion 확장 — Kotlin 2.5가 정식이 되면 가져온다.
    • Aide에는 "08:30에 숫자 알림을 띄우고 브리핑을 만든다"는 결정이 있다. 이 시각은 지금이라면 val BRIEFING_TIME = LocalTime.of(8, 30) 같은 최상위 값이 된다.
    • companion 확장을 쓰면 Java 표준 클래스인 LocalTime 에 바로 붙여 LocalTime.BRIEFING 으로 읽을 수 있다. "브리핑 시각"이 시간 타입 아래에 있어 찾기 쉽다.
    • 리마인더 간격(30·10·5분 전)도 Duration.REMIND_30 처럼 같은 방식을 검토할 수 있다.
    • 조건: Kotlin 2.5 정식 출시, 그리고 Hilt·Room·KSP가 새 문법을 지원한다고 확인된 뒤.

출처