KB 22 object 와 companion — static 이 없는 자리
고친 사람 github-actions[bot]
0. 코틀린에서는 되는 호출이 자바에서는 에러다
코틀린에는 static 이라는 키워드가 없습니다. 그런데 번호를 하나씩 나눠 주는 카운터처럼 프로그램에 하나만 있으면 되는 객체는 여전히 필요합니다. 코틀린은 이것을 object 한 단어로 씁니다.
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 메서드가 아니라서 클래스 이름으로는 부를 수 없다는 뜻입니다.
자바 쪽에서 통하는 호출은 이것입니다.
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 로 만들어, 변수에 담아 보고 정렬에도 넘겼습니다.
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 를 코틀린과 자바 양쪽에서 불러 봅니다.
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 을 거친다
두 호출을 자바로 옮겼습니다. 첫 줄은 동반 객체 이름을 넣었고, 둘째 줄은 코틀린처럼 클래스 이름 뒤에 바로 붙였습니다.
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)으로, 선언에 붙여 컴파일러에게 정보를 주는 표시입니다. 이 절은 세 표시를 붙인 선언과 안 붙인 선언을 섞어 자바에서 부르고, 무엇이 통하는지를 봅니다.
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 는 한 줄 출력을 짧게 쓰려고 둔 도우미 메서드입니다.
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 식이 바깥 변수를 바꿀 수 있는지와 부를 때마다 새로 만들어지는지를 봅니다.
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 식은 쓰인 곳에서 곧바로 실행되고 초기화된다고 적습니다(공식 문서).
자바 익명 클래스는 바깥 변수를 못 바꾼다
앞의 것을 자바로 옮겼습니다.
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 에 생성자 파라미터를 달아 보면 첫 문제가 드러납니다.
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 와 같은 객체인지 찍었습니다.
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 과 부생성자 — 초기화는 선언 순서대로).
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 를 두고, 스레드 여덟이 동시에 닿게 했습니다.
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 로 두면 어긋나는 스프링 기능
스프링 빈 · 스프링 컨테이너 · 의존성 주입 · 생성자 주입 · 프록시 · 리플렉션