Kotlin in Action 2/e 서적을 읽으면서 정리하고자 쓴 글입니다.

코틀린의 클래스와 인터페이스는 설계 단계부터 안정성과 간결함을 중시하도록 설계되었습니다. 인터페이스 내에 프로퍼티 선언이 가능해 구현체에 특정 상태를 강제할 수 있으며, 자바와 달리 모든 선언이 기본적으로 `public`이면서 동시에 상속이 제한된 `final` 상태라 의도치 않은 확장을 방지합니다. 또한 중첩 클래스는 별도의 키워드 없이 선언할 경우 외부 클래스에 대한 참조를 갖지 않아 메모리 누수 위험을 줄여줍니다.
클래스를 data 즉, `data class`로 선언하면 컴파일러가 일부 표준 함수를 생성해 주며, `by` 키워드를 활용한 위임 기능을 제공함으로써 개발자가 반복적이고 번거로운 준비 코드를 직접 작성할 필요 없이 핵심 로직에만 집중할 수 있도록 해줍니다.
클래스 계층 정의
1) 코틀린 인터페이스
코틀린 인터페이스는 자 8 인터페이스와 유사하며, 추상 메서드 뿐만 아니라 구현을 포함한 메서드도 정의할 수 있습니다. 이는 자바의 `default` 메서드와 같은 개념입니다.
코틀린은 자바의 `extends`와 `implements` 키워드 대신 콜론 (`:`) 하나로 클래스 확장과 인터페이스 구현을 모두 처리합니다. 자바와 동일하게 클래스는 오직 하나의 클래스만 확장할 수 있으며, 인터페이스는 개수 제한이 없습니다.
interface Clickable {
fun click() // 추상 메소드
fun showOff() = println("I'm clickable!") // 디폴트 구현
}
class Button : Clickable {
override fun click() = println("I was clicked")
}
1-1) 🤔 다중 인터페이스 상속 관계에서 동일한 함수가 구현되어 있다면?
이름이 동일한 `showOff` 함수가 있는 `Focusable` 인터페이스를 추가 생성해 봅시다. 그리고 `Button`에 상속한다면 코틀린은 중복된 상위 함수가 있음을 감지하고 반드시 하위 클래스에 구현되어야 한다는 컴파일 오류가 발생하게 됩니다.
interface Focusable {
fun setFocus(b: Boolean) =
println("I ${if (b) "got" else "lost"} focus.")
fun showOff() = println("I'm focusable!")
}
class Button : Clickable, Focusable { // showOff 함수를 가진 2개의 인터페이스 상속
override fun click() = println("I was clicked")
}

1-2) 자바에서 코틀린의 메서드가 있는 인터페이스 구현
코틀린은 구버전인 자바 6도 호환되도록 설계되었습니다. 자바 6는 인터페이스의 `default` 메서드를 지원하지 않기 때문에 코틀린 컴파일러는 이를 해결하기 위해 독특한 방식을 사용합니다.
- 인터페이스: 메서드 선언부만 포함합니다.
- 정적 보조 클래스: 인터페이스와 함께 생성되며, `default` 메서드의 실제 구현 본문을 정적(static) 메서드로 담고 있습니다.
이러한 구조로 자바 클래스에서 코틀린 인터페이스를 상속받아 구현할 때 코틀린의 `default` 메서드 구현에 의존할 수 없습니다. 즉, 자바에서는 코틀린에 이미 구현된 본문이 있더라도 직접 `override`하여 본문을 작성해야 합니다.
2) open, final, abstract 변경자
코틀린은 설계 시 상속을 엄격하게 관리합니다. 이는 취약한 기반 클래스 문제를 방지하고 더 안전한 코드를 작성하기 위함입니다.
2-1) 🤔왜 코틀린은 기본적으로 `final`인가?
자바에서 상속을 명시적으로 금지하지 않는 한 모든 클래스를 상속할 수 있습니다. 하지만 이는 기반 클래스가 변경될 때 하위 클래스의 동작이 예기치 않게 깨지는 위험을 초래합니다.
이펙티브 자바에서 "상속을 위한 설계와 문서를 갖추거나, 그럴 수 없다면 상속을 금지하라"에 따라, 코틀린은 모든 클래스와 메서드를 기본적으로 `final`로 설정하였습니다. 상속이나 `override`를 허용하려면 개발자가 명시적으로 `open`을 붙여야 합니다.
2-2) `open`과 `override` 사용법
어떤 클래스의 상속을 허용하려면 클래스 앞에 `open`을 붙여야 하며, 메서드나 프로퍼티 역시 `open`이 붙어 있어야만 `override`가 가능합니다.
open class RichButton : Clickable {
fun disable() {} // final: 하위 클래스에서 오버라이드 불가
open fun animate() {} // open: 하위 클래스에서 오버라이드 가능
override fun click() {} // 오버라이드한 메소드는 기본적으로 open 상태
}
만약 `override` 중인 메서드를 더 이상 하위 클래스에 재정의하지 못하게 막고 싶다면, 앞에 `final`을 명시해야 합니다.
open class RichButton : Clickable {
final override fun click() {} // 앞에 final을 붙여 오버라이드 금지
}
💡 열린 클래스와 스마트 캐스트
클래스와 프로퍼티가 기본적으로 final이기 때문에 얻는 큰 이점 중 하나는 스마트 캐스트입니다. 스마트 캐스트는 타입 검사 후 변수가 변하지 않는다는 보장이 있어야만 작동합니다. 코틀린 프로퍼티는 기본적으로 final이므로, 다른 클래스가 상속받아 커스텀 접근자를 정의하거나 값을 바꿀 위험이 적어 대부분의 프로퍼티를 고민 없이 스마트 캐스트에 활용할 수 있습니다.
2-3) 추상 클래스와 인터페이스
자바처럼 코틀린에서도 `abstract` 클래스를 선언할 수 있습니다.
- 추상 클래스: 인스턴스화할 수 없으며, `abstract` 멤버는 구현이 없으므로 항상 `open` 상태입니다.
- 인터페이스: 멤버 앞에 `final`, `open`, `abstract`를 붙이지 않습니다. 인터페이스 멤버는 항상 열려있으며, 본문이 없으면 자동으로 `abstract`가 됩니다.
abstract class Animated {
abstract fun animate() // 반드시 오버라이드 해야 함
open fun stopAnimating() {} // 비추상 함수지만 오버라이드 허용
fun animateTwice() {} // 기본적으로 final
}
2-4) 스프링 프레임워크와 같이 사용하면 클래스와 메서드는 `open`으로 열어두어야 한다.
프록시 기반의 기능 확장 (AOP)
스프링은 `@Transactional`, `@Cacheable` 같은 기능을 제공할 때, 내가 작성한 클래스를 직접 사용하는 대신 이를 상속받은 가짜 객체(프록시)를 만들어 실행합니다. 코틀린의 기본값인 `final` 상태로는 상속이 불가능하여 이런 부가 기능을 덧붙일 수 없습니다.
빈(Bean) 관리와 싱글톤 보장
`@Configuration`이 붙은 설정 클래스 역시 스프링이 내부적으로 상속을 통해 관리합니다. 클래스가 열려 있어야만 스프링이 객체 생성 과정을 제어하고 싱글톤 패턴을 유지할 수 있습니다.
JPA 지연 로딩 (Lazy Loading)
JPA(Hibernate)는 데이터가 실제로 필요할 때까지 로딩을 미루기 위해 엔티티를 상속받은 프록시 객체를 생성합니다. 엔티티 클래스가 `final`이면 이 기능을 사용할 수 없어 성능 최적화가 어려워집니다.
💡all-open 플러그인 실무에서는 매번 open을 붙이는 번거로움을 피하기 위해 kotlin-spring 플러그인을 사용합니다. 이 플러그인은 스프링 주요 애노테이션이 붙은 클래스들을 컴파일 시점에 자동으로 open으로 만들어 줍니다.
| 변경자 | 해당 멤버 | 설명 |
| `final` | 오버라이드 불가 | 클래스 멤버의 기본 상태입니다. |
| `open` | 오버라이드 가능 | 명시적으로 선언해야만 상속 및 재정의가 가능합니다. |
| `abstract` | 반드시 오버라이드 필요 | 추상 클래스 내에서만 사용하며, 구현체가 없습니다. |
| `override` | 상위 멤버 재정의 | 오버라이드 중인 멤버는 기본적으로 `open`입니다. |
3) 내부 클래스와 중첩된 클래스
코틀린은 자바처럼 클래스 안에 클래스를 선언할 수 있습니다. 하지만 자바와 코틀린은 이 기능을 다루는 기본 방식이 반대로 되어있습니다. 이 차이를 모르면 예상치 못한 메모리 누수나 직렬화 오류를 겪을 수 있습니다.
3-1) 자바에서의 실수: `NotSerializableException` 발생 원인
View의 상태를 저장하는 간단한 예시를 살펴봅시다. 자바에서 `Button` 클래스 내부에 상태 정보를 담는 `ButtonState`를 선언하면 다음과 같은 형태가 됩니다.
public class Button implements View {
@Override
public State getCurrentState() {
return new ButtonState();
}
// Button 클래스 안에 ButtonState 클래스
public class ButtonState implements State { ... }
}
이 상황에 `ButtonState`를 직렬화하려고 하면 `java.io.NotSerializableException: Button` 예외가 발생됩니다. 자바는 중첩 클래스를 그냥 선언하면 내부 클래스(Inner Class)가 되며 자신을 둘러싼 바깥쪽 클래스(`Button`)에 대한 묵시적인 참조를 포함합니다. 이로 인해 `ButtonState`를 직렬화하면 `Button` 객체까지 함께 직렬화해야 하는데, `Button`은 직렬화가 불가능하여 예외가 발생하게 됩니다.
자바는 이를 해결하기 위해선 클래스 앞에 `static` 키워드를 붙여야 합니다.
3-2) 코틀린은 기본 값이 반대
코틀린은 이러한 자바의 번거로움과 실수를 방지하기 위해 기본 설정을 반대로 뒤집었습니다.
코틀린은 클래스 내부에 선언된 클래스는 명시적으로 요청하지 않은 한 바깥쪽 클래스에 대한 참조가 없습니다. 즉, 자바의 `static class`와 같은 상태가 기본 값으로 사용됩니다.
class Button : View {
override fun getCurrentState(): State = ButtonState()
// 아무 수식어가 없으면 자바의 static 중첩 클래스와 동일함
class ButtonState : State { ... }
}
이 `ButtonState`는 바깥쪽 `Button`을 참조하지 않으며, 직렬화 시에도 문제가 발생하지 않습니다.
3-3) 바깥쪽 클래스의 참조가 필요하다면? `inner` 키워드 사용
- inner 클래스: 바깥쪽 클래스에 대한 참조를 가집니다.
- 바깥쪽 참조 방법: 내부 클래스 안에서 바깥쪽 클래스의 인스턴스를 가리키려면 `this@OuterClassName` 문법을 사용합니다.
class Outer {
val outerValue = "Outer String"
inner class Inner {
fun getOuterReference(): Outer {
// 바깥쪽 클래스의 멤버에 접근하거나 인스턴스 참조
println(outerValue)
return this@Outer
}
}
}
| 클래스 안의 클래스 | 자바 | 코틀린 |
| 바깥쪽 참조 없는 클래스 | `static class A` | `class A` (기본값) |
| 바깥쪽 참조 있는 클래스 | `class A` (기본값) | `inner class A` |
4) 봉인된 클래스 `sealed` 키워드
상위 클래스인 `Expr`에는 숫자를 표현하는 `Num`과 덧셈 연산을 표현하는 `Sum`이라는 두 하위 클래스가 있습니다. `when` 식을 통해 이 모든 하위 클래스를 처리하면 다음과 같이 사용할 수 있습니다.
interface Expr
class Num(val value: Int) : Expr
class Sum(val left: Expr, val right: Expr) : Expr
fun eval(e: Expr): Int =
when (e) {
is Num -> e.value
is Sum -> eval(e.right) + eval(e.left)
else -> // "else" 분기가 꼭 있어야 한다.
throw IllegalArgumentException("Unknown expression")
}
그러나 위와 같이 `when` 식을 사용할 경우 `Num` 또는 `Sum` 클래스가 아니라면 반드시 `else` 분기를 추가해야 합니다. 또한 새로운 하위 클래스가 추가되더라도 컴파일러는 누락된 분기를 감지해주지 못합니다. 만약 특정 하위 클래스에 대한 처리를 실수로 빠뜨릴 경우 의도하지 않게 `else` 분기가 실행되며 이는 런타임 시점에 발견되기 어려운 치명적인 버그로 이어질 수 있습니다.
코틀린은 해당 문제를 `sealed` 키워드를 통해 해결할 수 있습니다. 상위 클래스에 `sealed`를 붙이면 그 상위 클래스를 상속한 하위 클래스 정의를 제한할 수 있습니다. 덕분에 `sealed` 클래스의 하위 클래스를 정의할 때는 반드시 상위 클래스 안에 중첩시켜야 합니다.
sealed class Expr { // sealed 사용
class Num(val value: Int) : Expr()
class Sum(val left: Expr, val right: Expr) : Expr()
}
fun eval(e: Expr): Int =
when (e) {
is Expr.Num -> e.value
is Expr.Sum -> eval(e.right) + eval(e.left)
// sealed 덕분에 else 분기를 생성할 필요가 없다.
}

💡sealed 클래스
`sealed` 클래스는 강력한 타입 안전성을 제공하지만, 초기에는 제약이 다소 많았습니다. 예를 들어 모든 하위 클래스가 반드시 중첩 클래스여야 했고, `data class`로 `sealed` 클래스를 상속하는 것도 불가능했습니다.
그러나 Kotlin 1.1부터 이러한 제약이 완화되었습니다. 이제는 같은 파일 내라면 위치와 관계없이 `sealed` 클래스를 상속한 하위 클래스를 정의할 수 있으며, `data class` 또한 하위 클래스로 선언할 수 있습니다. 이로 인해 `sealed` 클래스는 여전히 컴파일 타임 안전성을 유지하면서도, 실무에서 훨씬 유연하게 활용할 수 있게 되었습니다.
뻔하지 않는 생성자와 프로퍼티를 갖는 클래스 선언
코틀린은 클래스를 선언하는 동시에 생성자를 정의하는 매우 간결한 문법을 제공합니다. 하지만 자바와의 호환성이나 다양한 초기화 상황을 대응하기 위해 주 생성자와 부 생성자를 구분하여 사용합니다.
1) 클래스 초기화: 주 생성자와 초기화 블록
주 생성자는 생성자 파라미터를 지정하고 그 생성자 파라미터에 의해 초기화되는 프로퍼티를 정의하는 2가지 목적으로 쓰입니다.
class User constructor(_nickname: String) {
val nickName: String
// 주 생성자는 코드를 가질 수 없으므로, 초기화 로직이 필요할 때 init 블록을 사용합니다.
init {
nickName = _nickname
println("유저가 초기화되었습니다: $nickName")
}
}
`init {}` 초기화 블록은 주 생성자와 함께 사용됩니다. 주 생성자는 제한적이기 때문에 별도의 코드를 포함할 수 없으므로 초기화 블록이 필요합니다.
2) 부 생성자: 상위 클래스를 다른 방식으로 초기화
코틀린은 자바와 달리 디폴트 파라미터를 지원하므로 생성자를 여러 개 만들 일이 적습니다. 하지만 다음과 같은 경우에는 부 생성자가 필요합니다.
- 자바 상호운용성: 여러 생성자를 가진 자바 라이브러리 클래스를 상속받아 확장할 때
- 다양한 인자 조합: 인스턴스를 생성하는 방법이 여러 가지인 경우
open class View {
// 부 생성자 1
constructor(ctx: Context) { /* ... */ }
// 부 생성자 2
constructor(ctx: Context, attr: AttributeSet) { /* ... */ }
}
class MyButton : View {
// 상위 클래스의 특정 생성자를 호출해야 할 때
constructor(ctx: Context) : super(ctx)
constructor(ctx: Context, attr: AttributeSet) : super(ctx, attr)
}
3) 인터페이스에 선언된 프로퍼티 구현
코틀린은 자바와 달리 인터페이스에 프로퍼티를 선언할 수 있습니다. 하지만 인터페이스는 본래 상태를 가질 수 없기 때문에 선언된 프로퍼티를 하위 클래스에서 어떻게 구현하느냐에 따라 동적 방식이 달라집니다.
interface User {
val nickName: String
}
class PrivateUser(override val nickname: String) : User // 주 생성자에 있는 프로퍼티
class SubscribingUser(val email: String) : User {
override val nickname: String // 커스텀 게터
get() = email.substringBefore('@')
}
class FacebookUser(val accountId: Int) : User {
override val nickname = getFacebookName(accountId) // 프로퍼티 초기화 식
}
인터페이스에 `val nickname: String`이라고 선언하는 것은, 하위 클래스에 "값을 얻을 수 있는 방법을 제공하라"고 강제하는 것과 같습니다.
- 주 생성자 활용 (`PrivateUser`): 주 생성자 안에서 직접 오버라이드하여 필드에 값을 저장합니다.
- 커스텀 게터 활용 (`SubscribingUser`): 별도의 필드를 만들지 않고, 프로퍼티에 접근할 때마다 로직을 실행하여 결과를 계산합니다.
- 초기화 식 활용 (`FacebookUser`): 객체가 생성되는 시점에 단 한 번 함수를 호출하여 그 결괏값을 필드에 저장해 둡니다.
인터페이스 안에도 직접 게터(` get() `)를 가진 프로퍼티를 정의할 수 있습니다. 단, 인터페이스는 상태를 가질 수 없으므로 뒷받침하는 필드(field)를 가질 수는 없습니다.
interface User {
val email: String
val nickName: String
get() = email.substringBefore('@') // 매번 호출 시점에 계산
}
- `email`: 추상 프로퍼티이므로 하위 클래스에서 반드시 구현해야 합니다.
- `nickName`: 인터페이스에 이미 게터 로직이 정의되어 있으므로, 하위 클래스에서 따로 구현하지 않고 그대로 상속받아 사용할 수 있습니다.
3) `get`과 `set`에서 뒷받침하는 필드에 접근
값을 저장하는 동시에 로직을 실행할 수 있게 하기 위해서는 접근자 안에서 프로퍼티를 뒷받침하는 필드에 접근할 수 있어야 합니다. 프로퍼티에 저장된 값의 변경 이력을 로그에 남기고 싶은 경우 다음과 같이 사용할 수 있습니다.
class User(val name: String) {
var address: String = "unspecified"
set(value: String) {
// field는 프로퍼티의 실제 저장된 값을 가리킵니다.
println("""
Address was changed for $name:
"$field" -> "$value".""".trimIndent())
// 값을 업데이트합니다.
// 주의: 여기서 'address = value'라고 쓰면 다시 세터를 호출하여 무한 루프가 걸린다.
field = value
}
}
>>> val u = User("alice")
>>> u.address = "city 11-12"
Address was changed for alice:
"unspecified" -> "city 11-12"
4) 접근자의 가시성 변경
때로는 프로퍼티의 값을 읽는 것은 자유롭게 허용하되, 수정은 클래스 내부에서만 하고 싶을 때가 있습니다. 이때 `private set`을 활용하면 아주 깔끔하게 캡슐화를 구현할 수 있습니다.
class LengthCounter {
var counter: Int = 0
private set // 외부에서는 수정 불가 (읽기 전용처럼 보임)
fun addWord(word: String) {
counter += word.length // 내부에서는 수정 가능
}
}
외부에서는 `counter`를 조회할 수만 있고, 값의 조작은 반드시 `addWord`라는 검증된 메서드를 통해서만 이루어지도록 강제할 수 있습니다.
코틀린은 초기화 시점을 유연하게 관리할 수 있는 기능들도 제공합니다.
- lateinit: 널이 될 수 없는(non-null) 프로퍼티를 생성자에서 초기화하지 않고 나중에(예: Dependency Injection, Setup 메서드) 초기화하겠다고 선언할 때 사용합니다.
- 지연 초기화(by lazy): 프로퍼티가 처음 사용되는 시점에 딱 한 번 초기화 로직을 실행합니다. 이는 더 넓은 개념인 위임 프로퍼티(Delegated Property)의 한 종류입니다.
- 자바 호환성: @JvmField, @JvmStatic 등의 애노테이션을 사용하여 코틀린 프로퍼티가 자바에서 일반 필드처럼 보이게 에뮬레이션 할 수 있습니다.
컴파일러가 생성한 메서드: 데이터 클래스와 클래스 위임
자바에서는 객체의 비교나 출력을 위해 `equals`, `hashCode`, `toString` 같은 메서드를 매번 구현해야 했습니다. 코틀린은 이러한 '보일러플레이트(반복적이고 번거로운 코드)'를 컴파일러가 대신 생성해 주는 강력한 기능을 제공합니다.
1) 데이터 클래스 (`Data Class`)
코틀린은 클래스 앞에 `data` 변경자를 붙이면 컴파일러가 `equals`, `hashCode`, `toString` 메서드를 자동으로 만들어줍니다.
data class Client(val name: String, val postalCode: Int)
fun main() {
val c1 = Client("alice", 100)
val c2 = Client("bella", 100)
println(c1.name == c2.name) // false (String 비교)
println(c1.hashCode() == c2.hashCode()) // false (hashCode)
println(c1.toString()) // Client(name=alice, postalCode=100)
}
2) 불변성과 `copy()` 메서드
코틀린은 데이터 클래스의 프로퍼티를 `val`로 선언하여 불변(Immutable) 객체로 만드는 것을 권장합니다. 특히 객체를 `HashMap`의 키로 쓸 때 데이터가 변하면 데이터 무결성이 깨지기 때문입니다.
이때 불변 객체의 내용을 일부만 바꿔서 새로 만들고 싶다면 `copy()` 메서드를 활용합니다.
val bob = Client("Bob", 110)
// name은 유지하고 postalCode만 바꾼 복사본 생성
val olderBob = bob.copy(postalCode = 220)
원본 객체는 그대로 유지되므로 프로그램의 다른 부분에 영향을 주지 않아 안전합니다.
3) 클래스 위임(Class Delegation): `by` 키워드의 마법 🌟
상속을 허용하지 않는(final) 클래스에 새로운 기능을 추가하고 싶을 때 보통 데코레이터(Decorator) 패턴을 사용합니다. 하지만 이 패턴은 기존 클래스의 모든 기능을 다시 작성해야 하는 번거로운 준비 코드가 필요합니다.
// 수동으로 기능을 전달하는 방식
class DelegatingCollection<T>(
private val innerList: Collection<T> = arrayListOf<T>()
) : Collection<T> {
// 단순 연결임에도 불구하고 모든 추상 멤버를 직접 작성해야 함 (보일러플레이트)
override val size: Int get() = innerList.size
override fun isEmpty(): Boolean = innerList.isEmpty()
override fun contains(element: T): Boolean = innerList.contains(element)
override fun iterator(): Iterator<T> = innerList.iterator()
override fun containsAll(elements: Collection<T>): Boolean = innerList.containsAll(elements)
}
fun main() {
val list = listOf("Kotlin", "Java")
val delegating = DelegatingCollection(list)
println("Size: ${delegating.size}") // 결과: Size: 2
}
코틀린은 `by` 키워드를 통해 이 위임 로직을 단 한 줄로 해결합니다.
// 클래스 위임(by)을 활용한 기능 확장
class CountingSet<T>(
private val innerSet: MutableCollection<T> = HashSet<T>()
) : MutableCollection<T> by innerSet { // MutableCollection의 모든 메서드 구현을 innerSet에 맡김
var objectsAdded = 0 // 새롭게 추가된 요소의 총 개수를 저장하는 상태
// 요소 하나를 추가할 때 카운트 증가 로직만 재정의(Override)
override fun add(element: T): Boolean {
objectsAdded++
return innerSet.add(element)
}
// 여러 요소를 한꺼번에 추가할 때 카운트 증가 로직만 재정의
override fun addAll(c: Collection<T>): Boolean {
objectsAdded += c.size
return innerSet.addAll(c)
}
// 나머지 size, isEmpty, remove, contains 등의 메서드는 작성하지 않아도
// 자동으로 innerSet의 기능을 그대로 사용함
}
fun main() {
val cset = CountingSet<Int>()
cset.add(1)
cset.addAll(listOf(2, 3, 4))
println("${cset.objectsAdded} objects were added, ${cset.size} remain in the set.")
// 출력 결과: 4 objects were added, 4 remain in the set.
// 위임된 메서드(remove)를 사용해봄
cset.remove(1)
println("After removal - Size: ${cset.size}, Total Added: ${cset.objectsAdded}")
// 출력 결과: After removal - Size: 3, Total Added: 4
}
`MutableCollection` 인터페이스의 수많은 메서드들(`size`, `isEmpty`, `contains` 등)을 직접 구현할 필요가 없습니다. `by innerSet`이라고 명시하면, 컴파일러가 나머지 모든 메서드를 `innerSet`의 메서드를 호출하도록 자동으로 연결해 줍니다.
즉, 상속의 위험성(기반 클래스 변경에 따른 취약점)은 피하면서, 필요한 기능만 깔끔하게 확장할 수 있습니다.
`object` 키워드: 클래스 선언과 인스턴스 생성
코틀린에서 `object` 키워드는 "클래스를 정의하면서 동시에 단 하나의 인스턴스(객체)를 생성"할 때 사용합니다. 자바의 싱글턴 패턴, 정적(static) 메서드, 익명 클래스를 코틀린만의 방식으로 해결합니다.
1) 객체 선언(Object Declaration): 완벽한 싱글톤
자바에서는 보통 클래스의 생성자를 `priavte`으로 제한하고 정적인 필드에 그 클래스의 유일한 객체를 저장하는 싱글톤 패턴을 통해 이를 구현합니다. 반면에 코틀린은 `object` 키워드를 통해 싱글톤 객체를 생성할 수 있습니다.
object DatabaseConfig {
val url = "jdbc:mysql://localhost:3306/db"
fun connect() = println("$url 에 연결합니다.")
}
// 사용 시점: 클래스 이름처럼 접근하면 이미 생성된 단일 객체를 참조함
fun main() {
DatabaseConfig.connect()
}
특정 클래스 내부에만 밀접하게 연관된 싱글톤 객체가 필요하다면 클래스 안에 선언할 수도 있습니다. 이 경우에도 해당 객체의 인스턴스는 단 하나뿐입니다.
data class Person(val name: String) {
// Person 클래스 내부에 이름 비교를 위한 전용 컴퍼레이터 선언
object NameComparator : Comparator<Person> {
override fun compare(p1: Person, p2: Person): Int =
p1.name.compareTo(p2.name)
}
}
fun main() {
val persons = listOf(Person("Bob"), Person("Alice"))
println(persons.sortedWith(Person.NameComparator)) // '클래스명.객체명'으로 접근
}
2) 동반 객체: 팩토리 메서드와 정적 멤버가 들어갈 장소
코틀린을 처음 접하면 당황스러운 점 중 하나가 클래스 내부에 `static` 키워드가 없다는 것입니다. 코틀린은 전역적인 상태를 지양하기 위해 자바의 `static`을 없앴지만, 그 역할을 더 안전하고 구조적으로 대체하기 위해 동반 객체(Companion Object)를 도입했습니다.
2-1) 동반 객체란 무엇인가?
클래스 내부에 정의된 객체 앞에 `companion`이라는 키워드를 붙이면 해당 클래스의 동반 객체가 됩니다. 이 객체의 프로퍼티나 메서드는 클래스 인스턴스 생성 없이 클래스 이름을 통해 직접 접근할 수 있어, 자바의 정적 멤버와 유사하게 동작합니다.
class A {
companion object {
fun bar() {
println("Companion object called")
}
}
}
fun main() {
A.bar() // 클래스 A의 인스턴스를 만들지 않고도 호출 가능
}
2-2) 🤔 왜 동반 객체를 사용하는가? (팩토리 메서드)
동반 객체의 가장 강력한 용도는 팩토리 메서드(Factory Method)를 만드는 것입니다.
class User private constructor(val nickname: String) { // 주 생성자를 private으로 보호
companion object {
// 이메일을 통해 유저를 생성하는 팩토리 메소드
fun newSubscribingUser(email: String) =
User(email.substringBefore('@'))
// 페이스북 ID를 통해 유저를 생성하는 팩토리 메소드
fun newFacebookUser(accountId: Int) =
User(getFacebookName(accountId))
}
}
fun main() {
// 생성자 대신 목적이 분명한 메소드 이름을 사용하므로 가독성이 높아짐
val subscribingUser = User.newSubscribingUser("bob@gmail.com")
val facebookUser = User.newFacebookUser(4)
}
2-3) 자바 `static`과의 결정적 차이
자바의 `static` 멤버는 클래스에 속한 정적인 데이터일 뿐이지만, 코틀린의 동반 객체는 실제 객체입니다. 따라서 다음과 같은 차별화된 기능을 가집니다.
- 인터페이스 구현: 동반 객체도 인터페이스를 상속받아 구현할 수 있습니다.
- 확장 함수: 클래스 외부에서 동반 객체에 대한 확장 함수를 정의하여 기능을 덧붙일 수 있습니다.
3) 동반 객체를 일반 객체처럼 사용
동반 객체는 클래스 내부에 정의되는 일반 객체이므로, 이름을 붙일 수 있고 인터페이스를 구현할 수 있으며, 그 안에 확장 함수와 프로퍼티를 정의할 수도 있습니다.
class Person(val name: String) {
companion object Parser { // "Parser" 동반 객체 이름
fun fromJSON(jsonText: String) : Person = ...
}
}
fun main() {
val p1 = Person.Parser.fromJSON("{name: 'bella'}")
val p2 = Person.fromJSON("{name: 'alice'}") // 생략도 가능
println(p1.name) // bella
println(p2.name) // alice
}
다른 객체 선언과 마찬가지로 동반 객체로 인터페이스를 구현할 수도 있습니다.
interface JsonFactory<T> {
fun fromJson(jsonText: String): T
}
class Person(val name: String) {
companion object : JsonFactory<Person> {
// 동반 객체의 인터페이스 구현
override fun fromJson(jsonText: String): Person { ... }
}
}
위처럼 JSON으로부터 각 원소를 다시 만들어내는 추상 팩토리가 있다면 `Person` 객체를 해당 팩토리에 넘길 수 있습니다.
💡코틀린 동반 객체와 정적 멤버
코틀린에서 클래스와 동반 객체는 컴파일 시 클래스에 포함된 정적 필드로 변환되며, 일반 객체와 유사한 방식으로 취급됩니다. 동반 객체에 이름을 붙이지 않은 경우, 자바 코드에서는 `Companion`이라는 이름을 통해 해당 객체를 접근할 수 있습니다.
4) 객체 식: 무명 내부 클래스를 다른 방식으로 작성
무명 객체(anonymous object)를 정의할 때도 `object` 키워드를 씁니다. 자바의 익명 내부 클래스와 유사하지만, 코틀린의 무명 객체는 훨씬 더 자유롭고 강력한 기능을 제공합니다.
4-1) 무명 객체란?
이름이 없는 객체를 선언과 동시에 생성하는 방식입니다. 주로 인터페이스의 구현체나 특정 클래스를 확장한 객체가 일회성으로 필요할 때 사용합니다.
// MouseAdapter를 상속하는 무명 객체를 생성하여 전달
window.addMouseListener(
object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) { ... }
override fun mouseEntered(e: MouseEvent) { ... }
}
)
만약 생성한 무명 객체를 재사용하고 싶다면 변수에 담아 사용할 수 있습니다.
val listener = object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) { ... }
override fun mouseEntered(e: MouseEvent) { ... }
}
window.addMouseListener(listener) // 무명 객체 인자로 전달
4-2) 코틀린 무명 객체의 차별점
자바의 익명 클래스는 단 하나의 클래스를 상속하거나 하나의 인터페이스만 구현할 수 있습니다. 하지만 코틀린의 무명 객체를 여러 개의 인터페이스를 동시에 구현하거나, 클래스 상속과 인터페이스 구현을 한 번에 처리할 수 있습니다.
// 클래스 상속과 인터페이스 구현을 동시에 하는 무명 객체
val complexObject = object : MyBaseClass(), Runnable, Serializable {
override fun run() {
println("다중 구현된 무명 객체가 실행 중입니다.")
}
}
자바에서 익명 클래스 외부의 변수를 사용하려면 그 변수가 `final`이어야만 했습니다. 그러나 코틀린의 객체 식 안에서는 `final`이 아닌 변수도 자유롭게 접근하고 수정할 수 있습니다.
fun countClicks(window: Window) {
var clickCount = 0 // final이 아님
window.addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
clickCount++ // 외부 변수의 값을 직접 수정 가능!
}
override fun mouseEntered(e: MouseEvent) { ... }
})
}
무명 객체는 람다(Lamda)와 혼동될 수 있습니다. 만약 구현해야 할 메서드가 단 하나뿐인 인터페이스(SAM)라면 무명 객체 대신 람다를 쓰는 것이 훨씬 간결해집니다. 하지만 위 예제처럼 여러 메서드를 오버라이드해야 한다면 반드시 `object` 식을 사용해야 합니다.
| 구분 | 형태 | 주요 특징 |
| 객체 선언 | `object Name { ... }` | 클래스 전체에서 단 하나의 싱글톤 |
| 동반 객체 | `companion object { ... }` | 클래스 당 하나, 정적 멤버/팩토리 역할 |
| 무명 객체 | `object : Type { ... }` | 일회성 인스턴스, 다중 구현 가능 |
요약
- 코틀린의 인터페이스는 자바 인터페이스와 유사하지만, 자바와 달리 프로퍼티 선언을 포함할 수 있다.
- 코틀린의 선언은 기본적으로 `final`이며 `public`이다.
- 선언이 `final`이 되지 않으려면(상속과 오버라이딩이 가능) 앞에 `open`을 붙여야 한다.
- 코틀린의 중첩 클래스는 기본적으로 내부 클래스가 아니다. 바깥쪽 클래스에 대해 참조를 중첩 클래스 안에 포함시키려면 `inner` 키워드를 중첩 클래스 선언 앞에 붙여야 내부 클래스가 된다.
- `sealed class`를 상속하는 클래스를 정의하려면 반드시 부모 클래스 정의 안에 중첩(또는 내부) 클래스로 정의되어야 한다. (코틀린 1.1부터 같은 파일에만 있으면 되도록 개선되었다.)
- 초기화 블록(`init {}`)과 부 생성자를 활용해 인스턴스를 더 유연하게 초기화할 수 있다.
- `data class`를 사용하면 `equals`, `hashCode`, `toString`, `copy` 등 메서드를 자동으로 생성해 준다.
- 클래스 위임(`by`)을 사용하면 객체 위임 기반 설계를 구현할 때 불필요한 준비 코드를 크게 줄일 수 있다.
- 객체 선언(`object`)을 사용하면 싱글톤 클래스를 쉽게 구현할 수 있다.
- 동반 객체(`comanion object`)는 자바의 정적 메서드와 필드 정의를 대신한다.
- 동반 객체도 다른 객체와 마찬가지로 인터페이스를 구현할 수 있다. 외부에서 동반 객체에 대한 확장 함수와 프로퍼티를 정의할 수 있다.
- 코틀린의 객체 식은 자바의 무명 내부 클래스를 대신한다. 또한 여러 인스턴스를 구현하거나 객체가 포함된 영역(scope)에 있는 변수의 값을 변경할 수 있는 등 자바 무명 클래스보다 더 많은 기능을 제공한다.
'Kotlin' 카테고리의 다른 글
| [Kotlin in action 2/e] 코틀린 타입 시스템 (0) | 2026.01.07 |
|---|---|
| [Kotlin in action 2/e] 람다로 프로그래밍 (0) | 2025.12.31 |
| [Kotlin in Action 2/e] 함수 정의와 호출 (1) | 2025.12.30 |
| [Kotlin in Action 2/e] 코틀린의 기초 (0) | 2025.12.12 |
| [Kotlin in Action 2/e] 코틀린이란 무엇이며, 왜 필요한가? (1) | 2025.12.12 |