1. 시작
'클래스의 인스턴스(instance)'라는 간단명료한 정의에서 시작하여, '상태를 가지고 행동을 수행하며, 책임을 지고 다른 객체와 협력하는 프로그램의 구성 단위'라는 보다 능동적인 정의까지. 개발자들에게 객체에 대한 개념은 이제는 조금 친숙해지기도 했지만 여전히 그 개념을 충분히 이해하고 살려서 프로그래밍하는데는 어려움을 겪고 있다고 생각한다. 클래스와 객체를 '설계도-실체'의 관점으로 바라봤을 때, 객체를 생성할 수 있는 가장 간단한 방법은 다음과 같다.
class DaFaNew
// Hi~ 데페뉴~
val a = DaFaNew()
하지만 다양하고 복잡한 현실과 마찬가지로, 객체 역시 다양한 목적과 용도로 만들어질 필요성을 갖고 있으며 오늘은 이를 지원하는 몇 가지 수단들을 알아본다.
2. 누구냐 너흰
2.1 익명 클래스(Anonymous Class)
가장 기본적인 출발점이었던 클래스를 정의하고 인스턴스를 생성하는 방식은, 현실의 문제를 코드로 옮겨오는 과정에서 항상 최선인 것은 아니다. 어떤 객체는 한 번만 필요하고, 특정 문맥 안에서만 의미를 가지거나 굳이 별도의 클래스로 분리하기에는 너무 작고 임시적인 책임을 수행하는 경우도 많기 때문이다. 이 때 등장하는 개념이 바로 익명 클래스(Anonymous class)다.
익명 클래스는 말 그대로 이름이 없는 클래스로, 정의하는 순간 곧바로 인스턴스를 생성하며 재사용을 목적으로 하지 않는다. 즉, 객체가 필요한데 이를 위해 별도로 클래스를 선언하는 것이 과한 경우에 사용한다. 이를 테면 특정 인터페이스를 구현해야 하지만, 그 구현이 한 번만 쓰이고 끝난다면 굳이 클래스로 분리하지 않고 순간적인 요구를 코드 안에서 즉석으로 해결할 수 있도록 하는 것이다. Java는 오래전부터 익명 클래스 문법을 제공해왔다.
// 클래스를 따로 만들지 않았지만, Runnable을 구현하는 객체가 즉석에서 생성되었다
Runnable r = new Runnable() {
@Override
public void run() {
System.out.println("running");
}
};
Kotlin은 이런 익명 클래스 생성을 'object' 키워드를 통해 객체 표현식(object expression)이라는 문법으로 더욱 직관적으로 지원한다.
val instance = object {}
// Any()로도 가능
val instance2 = Any()
위 코드는 빈 객체를 하나 생성하는데(Bean 아님 주의), 어떠한 프로퍼티나 함수도 없으며 상속도 하고 있지 않다. 말 그대로 '내용이 없는 익명 객체'인 셈인데, 여기서 가장 중요한 점은 비어 있다는 것이 아니라 컴파일러가 내부적으로 이름 없는 클래스를 하나 만들어낸 결과라는 점이며, 이로 인해 '유일성'이 제공된다는 것이 핵심이라고 볼 수 있다. 이렇게 객체 표현식으로 만들어진 객체는 Java에서의 활용과 마찬가지로 본문을 가질 수 있으며, 클래스를 상속하거나 인터페이스를 구현할 수 있다.
val runnable = object: Runnable {
override fun run() {
println("running")
}
}
객체가 필요한데 그 객체가 재사용될 이유는 없고, 특정 위치에서만 의미를 갖는 경우(이벤트 리스너, 콜백 처리, 테스트 더블 구현 등)에 자주 사용하는데, 익명이기 때문에 당연하게 따라오는 제약들 역시 존재한다. 아래와 같이 객체 표현식이 직접 정의한 멤버 변수들은 객체 표현식이 정의된 클래스 외부에서는 보이지 않는다. 즉, 익명 객체는 해당 스코프 안에서만 구체적인 타입으로 인식되며, 외부로 나가는 순간 타입 정보가 사라진다고 볼 수 있다.
fun create() = object {
val x = 10
}
2.2 객체 선언(object declaration)
객체 표현식(object expression)이 '즉석에서 필요한 유일무이한 객체를 만들어내는 방식'이라면, 이 객체에 이름을 붙이면 어떻게 될까? 유일한 객체가 이름을 갖고 있다. 바로 싱글턴(Singleton) 패턴이다. 싱글턴 패턴을 구현한 객체 표현식에 이름을 붙이는 방법, 이것이 바로 객체 선언(object declaration)이다.
객체 선언은 이름 그대로 객체를 선언하는 문법으로, 선언과 동시에 단 하나의 인스턴스만 생성되며 이렇게 생성된 객체는 어디서든 동일한 인스턴스로 접근된다.
object Point {
var x = 0
var y = 0
}
fun main() {
println("${Point.x}, ${Point.y}")
}
// Decompile 시켜보자
public final class Point {
@NotNull
public static final Point INSTANCE = new Point();
private static int x;
private static int y;
private Point() {
}
public final int getX() {
return x;
}
public final void setX(int var1) {
x = var1;
}
public final int getY() {
return y;
}
public final void setY(int var1) {
y = var1;
}
}
// ...
public static final void main() {
System.out.println(Point.INSTANCE.getX() + ", " + Point.INSTANCE.getY());
}
클래스 확장, 인터페이스 구현 등 보통의 클래스가 지원하는 기능을 모두 지원하며, 실무에서 사용한다면 Redis 캐시 키를 관리하기 위한 Constants나 상태 관리가 필요없는 Utility, Factory에서 사용할 수 있겠다.
2.3 컴패니언 객체(Companion object)
앞서 소개한 객체 선언(object declaration)을 그럼 클래스 내부에서 선언하면 어떻게 될까?
class User {
companion object {
const val MIN_AGE = 19
fun create() = User()
}
}
fun main() {
println(User.MIN_AGE)
val user = User.create()
}
이름 그대로(동반, companion) 클래스와 생명주기를 함께하며, 해당 클래스의 인스턴스들과는 독립적으로 존재하지만 마치 하나처럼 움직이는 존재가 된다. Java의 static 키워드와 매우 흡사해 보이지만, 결정적인 차이는 컴패니언 객체는 정말 말그대로 '객체(object)'라는 점에 있다.
// Decompiled
public final class User {
public static final int MIN_AGE = 19;
public static final User.Companion Companion = new User.Companion((DefaultConstructorMarker)null);
public static final class Companion {
public final User create() {
return new User();
}
private Companion() { }
// ...
}
}
디컴파일된 코드를 보면 알 수 있듯, Kotlin은 클래스 내부에 Companion이라는 이름의 정적 내부 클래스를 만들고, 이를 Companion이라는 이름의 정적 필드로 관리한다. Java의 static이 단순히 클래스에 귀속된 멤버라고 한정해본다면, Kotlin의 Companion object는 메모리 상에 실재하는 단일 인스턴스인 셈이다.
'객체'이기 때문에 Companion Object는 다음의 특장점을 갖게 된다.
- 인터페이스 구현과 상속: static은 인터페이스를 구현할 수 없지만, Companion Object는 가능하다. 이를 통해 특정 인터페이스를 구현하는 팩토리 객체를 클래스 이름만으로 다룰 수 있게 된다.
- 확장 함수(Extension)의 활용: 클래스 외부에서도 특정 클래스의 Companion 객체에 함수를 붙일 수 있다. 라이브러리 코드를 수정하지 않고도 해당 클래스에 '정적 메서드' 같은 기능을 추가할 수 있게 된다.
- 일급 객체로서의 활용: 객체이기 때문에 함수의 인자로 전달하거나 변수에 할당할 수 있어, 보다 다형성 있는 구조를 설계할 수 있다.
2.4 데이터 객체와 상수
Kotlin 1.8부터 도입된 data object는 객체 선언 시 일반 object에 data class의 장점을 이식한 형태다. 데이터와 관련된 정보를 하나의 싱글톤 객체로 관리할 때 유용하게 사용할 수 있으며 객체 이름을 문자열로 반환하는 toString을 지원해준다(equals, hashCode 역시 지원). sealed 클래스와 함께 사용했을 때의 시너지가 좋다고 하는데 아직 와닿는 부분이 없어 이 내용은 추후 다시 다루도록 한다.
Companion Object나 최상위 프로퍼티가 원시타입(primitive type)이거나 String인 경우 const 제어자를 추가할 수 있다. 이는 컴파일 시점에 값이 코드를 직접 대체함에 따라 메서드 호출 오버헤드가 없으므로 상숫값은 const를 사용하는 것이 좋겠다.
REFERENCE
1. 마르친 모스카와. 『코틀린 아카데미 - 핵심편』. 프로그래밍인사이트, 2022.
'Language > Kotlin' 카테고리의 다른 글
| [Kotlin] 인라인 함수(inline function) (0) | 2026.02.10 |
|---|---|
| [Kotlin] 봉인된 클래스(Sealed Class)와 봉인에 대하여 (0) | 2026.02.09 |
| [Kotlin] 데이터 묶음을 표현하는 Data Class (0) | 2026.02.03 |