-
4.1 클래스 계층 정의
-
4.1.1 코틀린 인터페이스
-
4.1.2 open, final, abstract 변경자: 기본적으로 final
-
4.1.3 가시성 변경자: 기본적으로 공개
-
4.1.4 내부 클래스와 중첩된 클래스: 기본적으로 중첩 클래스
-
4.1.5 봉인된 클래스: 클래스 계층 정의 시 계층 확장 제한
-
4.2 뻔하지 않은 생성자와 프로퍼티를 갖는 클래스 선언
-
4.2.1 클래스 초기화: 주 생성자와 초기화 블록
-
4.2.2 부 생성자: 상위 클래스를 다른 방식으로 초기화
-
4.2.3 인터페이스에 선언된 프로퍼티 구현
-
4.2.4 게터와 세터에서 뒷받침하는 필드에 접근
-
4.2.5 접근자의 가시성 변경
-
4.3 컴파일러가 생성한 메소드: 데이터 클래스와 클래스 위임
-
4.3.1 모든 클래스가 정의해야 하는 메소드
-
4.3.2 데이터 클래스: 모든 클래스가 정의해야 하는 메소드 자동 생성
-
4.3.3 클래스 위임: by 키워드 사용
-
4.4 object 키워드: 클래스 선언과 인스턴스 생성
-
4.4.1 객체 선언: 싱글턴을 쉽게 만들기
-
4.4.2 동반 객체: 팩토리 메소드와 정적 멤버가 들어갈 장소
-
4.4.3 동반 객체를 일반 객체처럼 사용
-
4.4.4 객체 식: 무명 내부 클래스를 다른 방식으로 작성
-
요약
-
도서 링크 바로가기
Kotlin in Action을 공부하며 정리한 내용입니다.
저작권에 문제가 될 시, 글을 모두 내리겠습니다.
제가 공부한 내용이 더 많은 분들에게도 도움이 되었으면 좋겠습니다. 부족한 부분은 댓글을 통해서 피드백을 주신다면 언제나 반영하겠습니다. 감사합니다.
책에 대한 링크는 맨 아래에 있습니다.
https://github.com/Kotlin-Android-Study-with-SSAFY/Kotlin_In_Action_1
GitHub - Kotlin-Android-Study-with-SSAFY/Kotlin_In_Action_1: SSAFY 13기 모바일 트랙 구미 5반 "코틀린 인 액션" 스
SSAFY 13기 모바일 트랙 구미 5반 "코틀린 인 액션" 스터디(A). Contribute to Kotlin-Android-Study-with-SSAFY/Kotlin_In_Action_1 development by creating an account on GitHub.
github.com
이 장에서는 코틀린의 클래스와 인터페이스를 깊이 이해하는 데 초점을 맞춘다. 코틀린의 클래스는 기본적으로 final이며 public이고, 중첩 클래스는 외부 클래스에 대한 참조가 없는 등 자바와 몇 가지 차이점이 있다. 또한 data class를 사용하면 표준 메서드가 자동 생성되며, delegation을 활용하면 직접 위임 코드를 작성할 필요가 없다.
또한 object 키워드를 활용해 싱글턴 패턴, 동반 객체(Companion Object), 객체 식(Object Expression), 익명 클래스(anonymous class) 등을 표현할 수 있다.
4.1 클래스 계층 정의
코틀린에서는 클래스 계층을 정의할 때 가시성과 접근 변경자(visibility modifiers)를 사용할 수 있다. 기본적으로 가시성은 public이며, 자바와 다른 점 중 하나는 sealed 변경자로 상속을 제한할 수 있다는 것이다.
4.1.1 코틀린 인터페이스
코틀린 인터페이스는 추상 메서드뿐만 아니라 구현이 포함된 메서드도 정의할 수 있다. 하지만 상태(필드)는 가질 수 없다.
// 간단한 인터페이스 선언
interface Clickable {
fun click()
}
인터페이스를 구현하는 클래스는 click() 메서드를 반드시 구현해야 한다.
// 단순한 인터페이스 구현
class Button : Clickable {
override fun click() = println("I was clicked")
}
>>> Button().click()
I was clicked
- 자바와 달리 코틀린에서는 :을 사용해 클래스 확장과 인터페이스 구현을 동시에 처리할 수 있다.
- override 변경자는 반드시 명시적으로 사용해야 하며, 실수로 상위 클래스 메서드를 오버라이드하는 오류를 방지한다.
디폴트 구현이 있는 인터페이스
코틀린 인터페이스는 디폴트 구현이 있는 메서드를 정의할 수 있다.
// 인터페이스 내부에 디폴트 구현 제공
interface Clickable {
fun click()
fun showOff() = println("I'm clickable")
}
- showOff() 메서드는 기본 구현을 제공하므로, 이를 오버라이드하지 않아도 된다.
다른 인터페이스에서도 동일한 showOff() 메서드를 정의할 수 있다.
interface Focusable {
fun setFocus(b: Boolean) = println("I ${if (b) "got" else "lost"} focus.")
fun showOff() = println("I'm focusable")
}
여러 인터페이스 구현 충돌 해결
하나의 클래스에서 두 인터페이스를 구현하면 컴파일러가 모호성을 해결하도록 강제한다.
// 두 인터페이스를 동시에 구현
class Button : Clickable, Focusable {
override fun click() = println("I was clicked")
override fun showOff() {
super<Clickable>.showOff() // Clickable의 showOff() 호출
super<Focusable>.showOff() // Focusable의 showOff() 호출
}
}
- super<인터페이스명>.메서드() 형식으로 명시적으로 호출할 인터페이스를 지정할 수 있다.
- 특정 인터페이스의 메서드만 호출하려면 다음과 같이 작성하면 된다.
override fun showOff() = super<Clickable>.showOff()
자바에서 코틀린의 인터페이스 사용
- 코틀린 인터페이스는 자바 6과 호환되도록 설계되어 디폴트 메서드를 직접 지원하지 않는다.
- 대신, 인터페이스는 선언만 포함하고, 디폴트 메서드는 정적 메서드로 분리된 별도 클래스로 생성된다.
- 따라서 자바에서 코틀린 인터페이스를 구현할 때는 모든 디폴트 메서드를 직접 구현해야 한다.
4.1.2 open, final, abstract 변경자: 기본적으로 final
자바에서는 final을 명시하지 않으면 모든 클래스가 기본적으로 상속 가능하다. 하지만 이는 취약한 기반 클래스(fragile base class) 문제를 초래할 수 있다. 기반 클래스를 변경하면 하위 클래스의 예상치 못한 동작 변경이 발생할 가능성이 있기 때문이다.
이 문제를 방지하기 위해 "상속을 위한 설계를 갖추거나, 그렇지 않다면 상속을 금지하라"는 원칙이 있다. 코틀린은 이를 반영하여 클래스와 메서드가 기본적으로 final이므로, 명시적으로 open을 선언해야만 상속이 가능하다.
// 열린 메소드를 포함하는 열린 클래스 정의하기
open class RichButton : Clickable { // 클래스가 open이므로 상속 가능
fun disable() {} // 기본적으로 final → 오버라이드 불가능
open fun animate() {} // open이므로 오버라이드 가능
override fun click() { } // 인터페이스 구현 → 기본적으로 open
}
취약한 기반 클래스 문제(Fragile base class)
주니어 개발자들의 흔히 하는 실수 중에 하나가 상속의 매력에 흠뻑 취해 모든 설계에 상속을 적용하려고 하는 것이다. 그러나 상속의 남용은 위험하다.
상속을 하면 자손클래스가 부모클래스의 구현 세부사항에 의존하도록 만든다. 따라서 상속을 사용한다면 취약한 기반클래스 문제(Fragile/Brittle Base Class Problem)를 피해갈 수 없다. 취약한 기반클래스 문제란, 부모 클래스가 변경되었을 때 자식 클래스가 영향을 받는 현상을 말한다. 자식클래스와 부모클래스의 결합도를 높아져, 캡슐화의 장점이 희석되어 버린다.
상속 관계를 추가하면 추가할 수록, 전체 시스템의 결합도는 높아진다. 부모클래스의 구현을 변경하는 작은 변경에도, 상속 계층에 속한 모든 자손클래스들이 영향을 받기 쉬워진다. 극단적인 경우, 부모클래스의 일부를 수정하고 싶은데 모든 자식클래스를 수정해야하는 상황이 되어버릴 수 있다.
오버라이드 금지하기
기반 클래스를 오버라이드할 때 메서드는 기본적으로 open이다. 이를 금지하려면 final을 명시해야 한다.
// 오버라이드 금지하기
open class RichButton : Clickable {
final override fun click() { } // 오버라이드 금지
}
열린 클래스와 스마트 캐스트
클래스를 기본적으로 final로 설정하면 스마트 캐스트(smart cast)가 더 많이 활용될 수 있다.
- 스마트 캐스트는 타입 검사 후 변경될 수 없는 변수에만 적용된다.
- 클래스의 프로퍼티도 스마트 캐스트를 적용하려면 val이며, final이고, 커스텀 접근자가 없어야 한다.
즉, 프로퍼티가 기본적으로 final이므로, 대부분의 프로퍼티에서 스마트 캐스트를 쉽게 활용할 수 있다.
추상 클래스와 추상 멤버
코틀린에서도 abstract 키워드를 사용하여 추상 클래스(abstract class)를 선언할 수 있다.
- 추상 클래스는 인스턴스화할 수 없다.
- 추상 멤버(메서드, 프로퍼티)는 반드시 오버라이드해야 하며, open을 명시할 필요가 없다.
abstract class Animated { // 추상 클래스 → 인스턴스 생성 불가
abstract fun animate() // 추상 함수 → 반드시 오버라이드해야 함
open fun stopAnimating() {} // open → 오버라이드 가능
fun animatedTwice() {} // 기본적으로 final → 오버라이드 불가능
}
인터페이스 멤버의 특성
- 인터페이스 멤버는 기본적으로 open이며 final로 변경할 수 없다.
- 본문이 없는 멤버는 자동으로 abstract이므로, 따로 abstract를 명시할 필요가 없다.
[코틀린 완전정복] 추상 클래스와 인터페이스
추상 클래스? 인터페이스? 뭐가 다르지? 🤔
velog.io
변경자 정리
변경자 | 이 변경자가 붙은 멤버는... | 설명 |
final | 오버라이드 불가능 | 클래스 멤버의 기본 변경자 |
open | 오버라이드 가능 | 명시적으로 선언해야 해야 오버라이드 가능 |
abstract | 반드시 오버라이드해야 함 | abstract class 내부에서만 abstract 변경자 사용 가능 추상 멤버는 구현이 있으면 안됨. |
override | 상위 클래스/인터페이스 멤버를 오버라이드 | 오버라이드하는 멤버는 기본적으로 open, 하위 클래스의 오버라이드를 금지하려면 final 명시 |
4.1.3 가시성 변경자: 기본적으로 공개
가시성 변경자(visibility modifier)는 클래스 외부에서 선언된 요소에 대한 접근을 제어한다.
이를 통해 외부 코드의 영향을 받지 않고 클래스 내부 구현을 안전하게 변경할 수 있다.
코틀린은 public, protected, private 변경자를 지원하며, 자바와 다른 점은 다음과 같다:
- 기본 가시성
- 자바의 기본 가시성은 패키지(private-package)이지만, 코틀린의 기본 가시성은 public이다.
- 즉, 별도로 변경자를 명시하지 않으면 모든 선언이 공개(public) 된다.
- 패키지 전용 가시성 없음
- 코틀린의 패키지는 네임스페이스 관리용이며, 자바처럼 가시성 제어에는 사용되지 않는다.
- internal: 모듈 내부에서만 접근 가능
- 코틀린은 패키지 전용 가시성의 대안으로 internal을 제공한다.
- internal은 같은 모듈 내에서만 접근 가능하므로, 모듈 외부에서 불필요한 접근을 차단할 수 있다.
- 최상위 선언에서 private 허용
- 코틀린에서는 최상위 클래스, 함수, 프로퍼티도 private으로 선언 가능하며,
해당 파일 내에서만 접근할 수 있도록 제한할 수 있다.
- 코틀린에서는 최상위 클래스, 함수, 프로퍼티도 private으로 선언 가능하며,
가시성 변경자의 동작 방식
변경자 | 클래스 멤버 | 최상위 선언 (클래스, 함수, 프로퍼티 등) |
public (기본값) | 모든 곳에서 접근 가능 | 모든 곳에서 접근 가능 |
internal | 같은 모듈 내에서만 접근 가능 | 같은 모듈 내에서만 접근 가능 |
protected | 하위 클래스에서만 접근 가능 | (최상위 선언에는 적용 불가) |
private | 같은 클래스 내부에서만 접근 가능 | 같은 파일 내에서만 접근 가능 |
internal open class TalkativeButton : Focusable {
private fun yell() = println("Hey!") // 클래스 내부에서만 접근 가능
protected fun whisper() = println("Let's talk") // 하위 클래스에서만 접근 가능
}
// public 확장 함수에서 internal 클래스의 private/protected 멤버에 접근하려 하면 오류 발생
fun TalkativeButton.giveSpeech(){
yell() // 오류 발생
whisper() // 오류 발생
}
- giveSpeech()는 public 함수이므로 internal 클래스의 private/protected 멤버를 참조할 수 없다.
- 이를 해결하려면 giveSpeech()의 가시성을 internal로 변경하거나, TalkativeButton의 가시성을 public으로 변경해야 한다.
코틀린과 자바의 protected 차이점
- 자바: 같은 패키지에 속한 클래스는 protected 멤버에 접근 가능.
- 코틀린: protected 멤버는 오직 하위 클래스 내부에서만 접근 가능하며, 같은 패키지라도 접근 불가.
- 확장 함수는 protected 멤버에 접근할 수 없음 → 오직 클래스 내부에서만 접근 가능.
코틀린의 가시성 변경자와 자바
- 코틀린의 public, protected, private은 자바에서도 동일하게 유지됨.
- 하지만 자바에서는 private 클래스를 선언할 수 없으므로,
코틀린의 private 클래스는 자바에서 "패키지 전용" 가시성으로 컴파일됨. - 코틀린의 internal은 바이트코드에서 public으로 변환됨.
- 따라서 자바 코드에서는 internal 멤버에 접근이 가능하지만,
코틀린 컴파일러는 이름을 난독화(매우 긴 이름으로 변환)하여 실수로 접근하는 것을 방지한다.
- 따라서 자바 코드에서는 internal 멤버에 접근이 가능하지만,
코틀린과 자바의 또 다른 차이점
- 자바: 외부 클래스는 중첩 클래스의 private 멤버에 접근 가능.
- 코틀린: 외부 클래스는 중첩 클래스의 private 멤버에 접근할 수 없음.
4.1.4 내부 클래스와 중첩된 클래스: 기본적으로 중첩 클래스
코틀린에서도 클래스 내부에 다른 클래스를 선언할 수 있으며, 이는 도우미 클래스 캡슐화 또는 코드의 가독성 향상에 유용하다.
코틀린과 자바의 차이점
- 자바: 중첩 클래스를 선언하면 기본적으로 내부 클래스(inner class)가 되며, 바깥 클래스에 대한 참조를 자동으로 포함한다.
- 코틀린: 기본적으로 static 중첩 클래스처럼 동작하며, 바깥쪽 클래스에 대한 참조를 포함하지 않는다.
→ inner 변경자를 사용해야 내부 클래스가 된다.
// Java 코드
public class A {
static class B {
int num;
}
}
B b = new A.B(); // 사용가능
// Kotlin 코드
class A {
class B {
var num: Int = 0
}
}
fun main() {
val b = A.B()
}
B 클래스는 바깥의 A 클래스의 인스턴스에 종속적이지 않기 때문에 A 클래스의 인스턴스 변수에 대해 접근할 수 없다.
class A {
val aNum = 10
fun getBnum() {
print(bNum) // impossible! <- Compile Error
}
class B {
var bNum = 0
fun getAnum() {
println(aNum) // impossible! <- Compile Error
}
}
}
A 클래스 내에 B 클래스를 정의하지만 사실상 서로 독립된 메모리를 사용
Inner를 사용
class A {
val aNum = 10
fun getBnum() {
print(bNum) // impossible!
}
inner class B {
var bNum = 0
fun getAnum() {
println(aNum) // possible!
}
}
}
내부 클래스로 변경하기 (inner 키워드)
바깥쪽 클래스의 인스턴스를 참조하도록 하려면 inner 키워드를 추가해야 한다.
클래스 B 안에 정의된 클래스 A | 자바에서는 | 코틀린에서는 |
중첩 클래스 (바깥쪽 클래스에 대한 참조 없음) | static class A | class A |
내부 클래스 (바깥쪽 클래스에 대한 참조 포함) | class A | inner class A |
내부 클래스에서 바깥쪽 클래스 참조하기
코틀린에서 내부 클래스가 바깥쪽 클래스를 참조하는 방법은 자바와 다름.
class Outer {
inner class Inner {
fun getOuterReference(): Outer = this@Outer
}
}
class A {
val num = 10
inner class B {
val num = 5
fun getAnum() {
println(this@A.num) // 10
println(this@B.num) // 5
}
}
}

this@Outer → 바깥쪽 클래스(Outer)의 참조를 가져오는 문법
4.1.5 봉인된 클래스: 클래스 계층 정의 시 계층 확장 제한
클래스 계층을 정의할 때 확장을 제한하고 싶다면 sealed 클래스를 사용하면 된다.
이는 when 식과 함께 사용하면 유용하며, 새로운 하위 클래스 추가 시 컴파일 오류를 발생시켜 실수로 빠뜨리는 경우를 방지할 수 있다.

기존 방식: interface를 사용한 클래스 계층
// 인터페이스 구현을 통해 식(expression) 표현하기
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 -> throw IllegalArgumentException("Unknown expression") // 디폴트 분기 필요
}
문제점
- when 식에서 else 분기가 필요하다.
- Expr의 새로운 하위 클래스를 추가해도 컴파일러가 when 식을 검사하지 않음.
- else 분기가 존재하면 새로운 하위 클래스를 고려하지 않아도 컴파일되기 때문에 실수할 위험이 큼.
해결 방법: sealed 클래스로 계층 확장 제한
sealed 클래스를 사용하면 해당 클래스를 상속하는 모든 하위 클래스를 제한할 수 있다.
// sealed 클래스로 식(expression) 표현하기
sealed class Expr {
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)
} // else 분기 필요 없음
장점
- when 식에서 모든 하위 클래스를 처리하면 else 분기 필요 없음.
- 새로운 하위 클래스를 추가하면 컴파일 오류 발생 → 실수 방지.
- sealed 클래스는 자동으로 open이므로 명시할 필요 없음.
sealed 클래스의 동작 방식
- 하위 클래스는 반드시 같은 파일 내에서 정의해야 함.
→ 다른 파일에서 하위 클래스를 추가할 수 없으므로 확장 범위를 제한할 수 있음. - 컴파일러가 when 식에서 모든 경우가 처리되었는지 검사 가능.
→ 새로운 하위 클래스를 추가하면 when이 자동으로 업데이트되지 않으면 컴파일 오류 발생. - sealed 클래스는 기본적으로 private 생성자를 가짐.
→ 외부에서 직접 인스턴스를 만들 수 없음.
제약 사항과 개선점
코틀린 1.0의 sealed 클래스 제약
- 모든 하위 클래스는 sealed 클래스 내부에 중첩(nested) 클래스로 선언해야 했음.
- 데이터 클래스로 sealed 클래스를 상속할 수 없었음.
코틀린 1.1 이후 개선점
- 같은 파일 내에서 하위 클래스를 정의할 수 있도록 변경됨.
- 데이터 클래스로 sealed 클래스를 상속할 수 있도록 허용됨.
sealed 클래스와 클래스 확장 문법
코틀린에서 클래스를 상속할 때는 콜론(:)을 사용한다.
class Num(val value: Int) : Expr()
- 여기서 Expr()에서 ()는 생성자 호출을 의미.
4.2 뻔하지 않은 생성자와 프로퍼티를 갖는 클래스 선언
코틀린에서는 주 생성자(primary constructor, class 선언부에 바로 정의) 와 부 생성자(secondary constructor, constructor 키워드를 사용하여 class 본문 내부에 정의) 를 구분하며, 초기화 블록(initializer block)을 통해 추가적인 초기화 로직을 설정할 수 있다. 먼저 주 생성자와 초기화 블록을 살펴보고, 이후 여러 생성자를 선언하는 방법과 프로퍼티에 대해 설명한다.
4.2.1 클래스 초기화: 주 생성자와 초기화 블록
코틀린의 주 생성자는 클래스 이름 뒤에 괄호로 선언되며, 생성자 파라미터와 초기화할 프로퍼티를 정의하는 역할을 한다. 아래 예제처럼 constructor 키워드와 init 블록을 사용하여 초기화를 수행할 수 있다.
class User constructor(_nickname: String) {
val nickname: String
init { // 초기화 블록에는 클래스의 객체가 만들어질 때 (인스턴스화될 때) 실행
nickname = _nickname
}
}
하지만 constructor 키워드는 필요하지 않으면 생략 가능하며, 초기화 블록 없이 간단하게 표현할 수도 있다.
class User(_nickname: String) {
val nickname = _nickname
}
더 나아가, 생성자 파라미터를 바로 프로퍼티로 선언하여 코드의 간결성을 높일 수 있다.
class User(val nickname: String)
생성자 파라미터의 디폴트 값
생성자 파라미터에는 디폴트 값을 설정할 수 있으며, 이를 활용하면 파라미터 없이도 객체를 생성할 수 있다.
class User(val nickname: String, val isSubscribed: Boolean = true)
이 경우, User("John")처럼 호출하면 isSubscribed의 기본값 true가 자동으로 설정된다.
기반 클래스의 생성자 호출
코틀린에서 클래스가 다른 클래스를 상속하면, 주 생성자를 통해 기반 클래스의 생성자를 호출해야 한다.
open class User(val nickname: String) {...}
class TwitterUser(nickname: String) : User(nickname) {...}
디폴트 생성자
클래스에 별도 생성자를 정의하지 않으면 컴파일러가 자동으로 인자가 없는 생성자를 만든다.
open class Button
class RadioButton : Button()
이처럼 기반 클래스의 생성자가 호출될 때, 괄호를 통해 명확하게 구분해야 한다. 반면, 인터페이스는 생성자가 없으므로 괄호 없이 상속 관계를 표시한다.
인스턴스화를 막는 private 생성자
클래스를 외부에서 인스턴스화하지 못하도록 하려면 private 생성자 를 사용할 수 있다.
class Secretive private constructor() {}
이 방식은 동반 객체(companion object) 나 싱글턴 패턴을 구현할 때 유용하다.
4.2.2 부 생성자: 상위 클래스를 다른 방식으로 초기화
코틀린에서는 자바보다 생성자 오버로딩이 적게 필요하다. 대부분의 경우 디폴트 파라미터 값과 이름 붙은 인자(named arguments) 를 사용하면 오버로드된 생성자가 필요 없어진다.
Tip: 여러 개의 부 생성자를 선언하는 대신, 생성자 파라미터에 디폴트 값을 명시하라.
하지만 부 생성자가 필요한 경우도 있다. 대표적인 예는 자바 프레임워크 클래스를 확장할 때 여러 방식으로 인스턴스를 초기화해야 하는 경우다. 예를 들어, 안드로이드의 View 클래스에는 두 개의 생성자가 있다.
open class View {
constructor(ctx: Context) {
// 코드
}
constructor(ctx: Context, attr: AttributeSet) {
// 코드
}
}
위 View 클래스는 주 생성자 없이 부 생성자만 두 개 선언되어 있다. 부 생성자는 constructor 키워드로 시작하며, 필요에 따라 여러 개 선언할 수 있다.
상위 클래스 생성자 호출
코틀린에서 부 생성자는 super() 키워드를 사용하여 상위 클래스의 생성자를 호출해야 한다.

class MyButton: View {
constructor(ctx: Context) : super(ctx) {
// 추가 초기화 코드
}
constructor(ctx: Context, attr: AttributeSet) : super(ctx, attr) {
// 추가 초기화 코드
}
}
이 코드에서 super(ctx)와 super(ctx, attr)을 호출하여 View 클래스의 생성자를 초기화한다.
자신의 다른 생성자 호출 (this 사용)
자바와 마찬가지로, this()를 사용하여 자신의 다른 생성자를 호출할 수도 있다.

class MyButton: View {
constructor(ctx: Context) : this(ctx, MY_STYLE) { // MYSTYLE은 어딘가에 정의되어있다고 가정
// 추가 초기화 코드
}
constructor(ctx: Context, attr: AttributeSet) : super(ctx, attr) {
// 추가 초기화 코드
}
}
- 첫 번째 부 생성자는 this(ctx, MY_STYLE)을 호출하여 같은 클래스의 두 번째 생성자를 사용한다.
- 두 번째 생성자는 super(ctx, attr)을 호출하여 상위 클래스 생성자를 초기화한다.
부 생성자의 필수 규칙
주 생성자가 없는 하위 클래스에서는 모든 부 생성자가 반드시 상위 클래스 생성자를 호출하거나(super()), 다른 부 생성자에게 생성을 위임(this())해야 한다. 즉, 어떤 경로로든 결국 상위 클래스 생성자가 호출되어야 한다.
부 생성자가 필요한 이유
- 자바 상호운용성: 자바에서는 부 생성자가 필수적인 경우가 많아, 이를 지원해야 할 때 필요하다.
- 여러 초기화 방법 제공: 클래스 인스턴스를 생성할 때 다양한 방식이 필요하면 부 생성자가 필요할 수 있다.
4.2.3 인터페이스에 선언된 프로퍼티 구현
코틀린에서는 인터페이스에 추상 프로퍼티를 선언할 수 있다. 다음은 예제다.
interface User {
val nickname: String
}
이 인터페이스를 구현하는 클래스는 nickname 값을 제공해야 한다. 하지만 인터페이스 자체는 상태를 저장할 수 없으므로, 실제 저장소(필드)는 하위 클래스에서 관리해야 한다. 이제 User 인터페이스를 구현하는 방법을 살펴보자.
인터페이스 프로퍼티 구현 방식
// 인터페이스의 프로퍼티 구하기
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) // 프로퍼티 초기화 식
}
구현 방식별 차이점
- PrivateUser
- nickname을 주 생성자의 프로퍼티로 직접 선언.
- 간결한 구문을 사용하며 override 키워드로 인터페이스 프로퍼티를 구현.
- SubscribingUser
- 커스텀 게터를 사용하여 nickname을 동적으로 계산.
- nickname이 호출될 때마다 email.substringBefore('@')를 실행.
- FacebookUser
- 객체 초기화 시 nickname을 한 번만 계산하여 저장.
- getFacebookName(accountId) 호출 비용이 크므로, 생성 시 한 번만 실행하여 필드에 저장.
인터페이스에서 프로퍼티 게터 구현
인터페이스에서는 추상 프로퍼티뿐만 아니라 게터와 세터를 가진 프로퍼티도 선언할 수 있다.
interface User {
val email: String
val nickname: String
get() = email.substringBefore('@') // 매번 계산하여 반환
}
동작 방식
- email: 추상 프로퍼티 → 하위 클래스에서 반드시 override해야 함.
- nickname: 기본적으로 제공되는 커스텀 게터 → 하위 클래스에서 override하지 않아도 사용 가능.
인터페이스에는 상태(필드)가 없으므로, 인터페이스의 프로퍼티에는 뒷받침하는 필드가 없다. 따라서 매번 계산하는 방식이 필요하다.
4.2.4 게터와 세터에서 뒷받침하는 필드에 접근
프로퍼티의 두 가지 유형
- 값을 저장하는 프로퍼티 → 내부에 뒷받침하는 필드(backing field) 를 가짐.
- 커스텀 접근자로 값을 계산하는 프로퍼티 → 호출될 때마다 새로운 값을 계산.
이번에는 이 두 가지 유형을 조합하여, 값을 저장하면서도 읽거나 변경할 때 특정 로직을 실행하는 프로퍼티를 만드는 방법을 살펴보자.
뒷받침하는 필드(backing field) 접근 방법
// 세터에서 뒷받침하는 필드 접근하기
class User(val name: String) {
var address: String = "unspecified"
set(value: String) {
println("Address was changed for $name : \"$field\" -> \"$value\".")
field = value
}
}
동작 방식
- user.address = "new value" 같은 코드가 실행되면, 세터(setter) 가 호출됨.
- 세터 안에서 field 키워드를 사용하여 기존 값을 가져오거나 새 값을 설정 가능.
- println(...)을 통해 값이 변경될 때 로그를 출력하도록 구현.
field의 역할
- 게터(getter) 에서는 읽기 전용으로 사용 가능.
- 세터(setter) 에서는 읽고 변경 가능.
- 뒷받침하는 필드가 있는 프로퍼티에서만 field 사용 가능.
뒷받침하는 필드(backing field) 존재 여부
- 프로퍼티에 field를 사용하면, 컴파일러가 자동으로 뒷받침하는 필드를 생성.
- 하지만 field를 사용하지 않는 커스텀 접근자만 존재한다면, 뒷받침하는 필드는 생성되지 않음.
- 예를 들어, val 프로퍼티의 게터(getter)에서 field를 사용하지 않으면 뒷받침하는 필드가 없음.
- var 프로퍼티에서는 게터와 세터 모두에서 field가 없어야 뒷받침하는 필드가 생성되지 않음.
프로퍼티의 가시성 변경
- 기본적으로 프로퍼티의 게터와 세터는 동일한 가시성 수준을 가짐.
- 하지만 필요에 따라 세터의 가시성을 변경할 수도 있음 (예: 내부적으로만 값을 변경 가능하게 함
4.2.5 접근자의 가시성 변경
기본적으로 프로퍼티의 접근자(게터, 세터)는 프로퍼티와 동일한 가시성을 가진다. 하지만 필요에 따라 세터(setter)의 가시성을 변경하여 특정 조건에서만 값을 수정할 수 있도록 설정할 수 있다.
// 비공개 세터가 있는 프로퍼티 선언하기
class LengthCounter {
var counter: Int = 0
private set
fun addWord(word: String) {
counter += word.length
}
}
동작 방식
- counter 프로퍼티의 게터는 기본적으로 public 이므로, 외부에서 값을 읽을 수 있음.
- 하지만 세터는 private이므로 외부에서 직접 값을 변경할 수 없음.
- addWord() 메서드를 통해서만 counter 값을 증가시킬 수 있도록 제한.
>>> val lengthCounter = LengthCounter()
>>> lengthCounter.addWord("Hi!")
>>> println(lengthCounter.counter)
3
- "Hi!"라는 문자열을 추가하면 counter 값이 3으로 증가.
- 하지만 lengthCounter.counter = 10 같은 직접 수정은 불가능 (private set 때문).
4.3 컴파일러가 생성한 메소드: 데이터 클래스와 클래스 위임
자바에서는 equals, hashCode, toString 같은 메소드를 직접 구현해야 하지만, 이는 반복적인 작업이 많아 코드가 번잡해진다. 코틀린은 컴파일러가 자동으로 이러한 메소드를 생성하여 코드의 가독성을 높이고 유지보수를 쉽게 만들어 준다.
4.3.1 모든 클래스가 정의해야 하는 메소드
toString(): 객체의 문자열 표현
객체를 문자열로 표현하는 toString()을 직접 구현하지 않으면 기본적으로 Client@5e9f234 같은 형식이 된다. 이를 유용한 형태로 변경하려면 toString()을 오버라이드해야 한다.
// Client에 toString() 구현하기
class Client(val name: String, val postalCode: Int) {
override fun toString() = "Client(name=$name, postalCode=$postalCode)"
}
이제 println(client1)을 호출하면 "Client(name=오현석, postalCode=4122)"가 출력된다.
equals(): 객체의 동등성 비교
코틀린에서 == 연산자는 내부적으로 equals()를 호출하여 객체를 비교한다. 따라서 equals()를 오버라이드하면 ==를 통해 안전하게 객체 비교가 가능하다.
class Client(val name: String, val postalCode: Int) {
override fun equals(other: Any?): Boolean {
if (other == null || other !is Client) return false
return name == other.name && postalCode == other.postalCode
}
override fun toString() = "Client(name=$name, postalCode=$postalCode)"
}
== vs ===
- == → equals()를 호출하여 값 비교
- === → 참조 비교 (객체의 메모리 주소가 같은지 확인)
이제 동일한 데이터를 가진 Client 객체를 비교하면 client1 == client2는 true를 반환한다.
hashCode(): 해시 컨테이너 사용을 위한 해시값 계산
자바에서는 equals()를 오버라이드하면 반드시 hashCode()도 함께 오버라이드해야 한다. 그렇지 않으면 해시 컨테이너(Set, Map)에서 예기치 않은 동작이 발생할 수 있다.
>>> val processed = hashSetOf(Client("오현석", 4122))
>>> println(processed.contains(Client("오현석", 4122)))
false // 예상과 달리 존재하지 않는다고 판단됨
이는 hashCode()를 정의하지 않았기 때문이다. 해결하려면 hashCode()를 오버라이드해야 한다.
class Client(val name: String, val postalCode: Int) {
override fun equals(other: Any?): Boolean {
if (other == null || other !is Client) return false
return name == other.name && postalCode == other.postalCode
}
override fun toString() = "Client(name=$name, postalCode=$postalCode)"
override fun hashCode(): Int = name.hashCode() * 31 + postalCode
}
이제 hashSet.contains(Client("오현석", 4122))는 true를 반환한다.
4.3.2 데이터 클래스: 모든 클래스가 정의해야 하는 메소드 자동 생성
데이터를 저장하는 역할의 클래스는 toString, equals, hashCode 같은 메소드를 반드시 오버라이드해야 한다.
하지만 코틀린에서는 data 변경자를 사용하면 컴파일러가 자동으로 필요한 메소드를 생성해준다.
// Client를 데이터 클래스로 선언하기
data class Client(val name: String, val postalCode: Int)
데이터 클래스가 자동으로 생성하는 메소드
- equals → 모든 프로퍼티 값이 동일하면 true 반환.
- hashCode → 모든 프로퍼티를 기반으로 해시 코드 생성.
- toString → "Client(name=이름, postalCode=우편번호)" 형태의 문자열 반환.
주의: equals와 hashCode는 주 생성자에 선언된 프로퍼티만 고려한다.
클래스 본문에서 선언된 프로퍼티는 포함되지 않는다.
// Client Data 클래스를 Decompile 하여 Java Code로 변환
public final class Client {
@NotNull
private final String name;
private final int postalCode;
public Client(@NotNull String name, int postalCode) {
Intrinsics.checkNotNullParameter(name, "name");
super();
this.name = name;
this.postalCode = postalCode;
}
@NotNull
public final String getName() {
return this.name;
}
public final int getPostalCode() {
return this.postalCode;
}
@NotNull
public final String component1() {
return this.name;
}
public final int component2() {
return this.postalCode;
}
@NotNull
public final Client copy(@NotNull String name, int postalCode) {
Intrinsics.checkNotNullParameter(name, "name");
return new Client(name, postalCode);
}
// $FF: synthetic method
public static Client copy$default(Client var0, String var1, int var2, int var3, Object var4) {
if ((var3 & 1) != 0) {
var1 = var0.name;
}
if ((var3 & 2) != 0) {
var2 = var0.postalCode;
}
return var0.copy(var1, var2);
}
@NotNull
public String toString() {
return "Client(name=" + this.name + ", postalCode=" + this.postalCode + ')';
}
public int hashCode() {
int result = this.name.hashCode();
result = result * 31 + Integer.hashCode(this.postalCode);
return result;
}
public boolean equals(@Nullable Object other) {
if (this == other) {
return true;
} else if (!(other instanceof Client)) {
return false;
} else {
Client var2 = (Client)other;
if (!Intrinsics.areEqual(this.name, var2.name)) {
return false;
} else {
return this.postalCode == var2.postalCode;
}
}
}
}
데이터 클래스와 불변성: copy() 메소드
데이터 클래스의 프로퍼티는 var로 선언할 수도 있지만, val로 선언하여 불변(immutable)하게 만드는 것이 권장됨.
- 불변 객체는 안전하고 다중 스레드 환경에서 안정적인 동작을 보장한다.
- 해시 기반 컨테이너(HashMap 등)에 데이터 클래스 객체를 키로 사용할 때, 프로퍼티 변경이 없도록 해야 한다.
copy() 메소드 자동 생성
불변성을 유지하면서 일부 프로퍼티만 변경하려면 copy() 메소드를 활용하면 된다.
이 메소드는 원본 객체를 변경하지 않고, 특정 값만 변경한 새로운 객체를 반환한다.
class Client(val name: String, val postalCode: Int) {
override fun equals(other: Any?): Boolean {
if (other == null || other !is Client) return false
return name == other.name && postalCode == other.postalCode
}
override fun toString() = "Client(name=$name, postalCode=$postalCode)"
override fun hashCode(): Int = name.hashCode() * 31 + postalCode
fun copy(name: String = this.name, postalCode: Int = this.postalCode) = Client(name, postalCode)
}
이제 copy()를 활용하면 기존 객체를 기반으로 일부 값만 변경한 새로운 객체를 쉽게 만들 수 있다.
>>> val lee = Client("이계영", 4122)
>>> println(lee.copy(postalCode = 4000))
Client(name=이계영, postalCode=4000)
데이터 클래스에서 자동 생성되는 copy()
위에서 직접 copy()를 구현했지만, 데이터 클래스(data class)에서는 copy() 메소드가 자동 생성된다.
data class Client(val name: String, val postalCode: Int)
>>> val lee = Client("이계영", 4122)
>>> println(lee.copy(postalCode = 4000))
Client(name=이계영, postalCode=4000)
4.3.3 클래스 위임: by 키워드 사용
객체지향 시스템에서 구현 상속(implementation inheritance) 은 하위 클래스가 상위 클래스의 내부 구현에 의존하는 문제를 야기할 수 있다.
코틀린은 이런 문제를 방지하기 위해 모든 클래스를 기본적으로 final로 지정하며, open 키워드를 사용해야만 상속이 가능하도록 설계했다.
그러나 상속이 허용되지 않는 클래스에 새로운 기능을 추가해야 하는 경우도 있다. 이를 해결하는 방법 중 하나가 데코레이터(Decorator) 패턴이다.
데코레이터 패턴
데코레이터 패턴은 기존 클래스를 직접 상속하지 않고, 해당 클래스를 필드로 유지하면서 동일한 인터페이스를 구현하는 방식이다.
하지만 이 방법은 반복적인 코드가 많아지는 단점이 있다.
class DelegatingCollection<T> : Collection<T> {
private val innerList = arrayListOf<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)
}
문제점: 단순히 Collection 인터페이스를 구현할 뿐인데도, 모든 메소드를 하나하나 직접 정의해야 한다.
by 키워드를 활용한 클래스 위임
코틀린에서는 by 키워드를 사용해 인터페이스의 구현을 다른 객체에 위임할 수 있다.
즉, 필요한 메소드를 자동으로 생성해 반복적인 코드를 줄일 수 있다.
class DelegatingCollection<T>(
innerList: Collection<T> = ArrayList<T>()
) : Collection<T> by innerList {}
장점: 기존 코드보다 훨씬 간결하며, 컴파일러가 내부적으로 필요한 전달 메소드를 자동 생성한다.
특정 메소드의 동작 변경
by 키워드를 사용하면 기본적으로 모든 메소드를 위임하지만, 일부 메소드의 동작을 변경할 수도 있다.
아래 예제는 리스트의 원소 추가 횟수를 기록하는 컬렉션을 구현한 것이다.
// 리스트 위임 사용하기
class CountingSet<T>(
val innerSet: MutableCollection<T> = HashSet<T>()
) : MutableCollection<T> by innerSet {
var objectsAdded = 0
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)
}
}
>>> val cset = CountingSet<Int>()
>>> cset.addAll(listOf(1, 1, 2))
>>> println("${cset.objectsAdded} objects were added, ${cset.size} remain")
3 objects were added, 2 remain
코드 분석
- MutableCollection<T> by innerSet
- CountingSet의 대부분의 메소드는 innerSet에게 위임됨.
- add()와 addAll()만 직접 오버라이드
- add()와 addAll()을 오버라이드하여 추가된 원소의 개수를 기록함.
- 나머지 메소드는 자동으로 innerSet에게 위임됨.
by 키워드를 활용한 클래스 위임의 장점
- 반복적인 코드 없이 쉽게 위임을 구현할 수 있음.
- 필요한 메소드만 오버라이드하여 동작을 변경할 수 있음.
- 위임 대상 클래스(내부 컨테이너)의 구현 방식 변화에 덜 의존적임.
- 예를 들어, MutableCollection 내부적으로 addAll()이 add()를 호출할 수도 있고, 다른 방식으로 최적화할 수도 있음.
- CountingSet에서는 addAll()이 add()를 호출하는지 신경 쓸 필요 없이 독립적으로 동작할 수 있음.
4.4 object 키워드: 클래스 선언과 인스턴스 생성
코틀린의 object 키워드는 클래스를 정의하면서 동시에 인스턴스를 생성할 때 사용된다.
이를 활용하면 싱글턴 패턴, 동반 객체, 무명 객체(익명 클래스) 등을 쉽게 구현할 수 있다.
object 키워드를 사용하는 주요 경우
- 객체 선언(Object Declaration) → 싱글턴(Singleton) 생성
- 동반 객체(Companion Object) → 클래스 관련 정적 메소드 제공
- 객체 식(Object Expression) → 익명 객체(자바의 익명 내부 클래스 대체)
4.4.1 객체 선언: 싱글턴을 쉽게 만들기
객체 선언(Object Declaration) - 싱글턴 패턴 구현
코틀린에서는 object 키워드를 사용하여 싱글턴 객체를 쉽게 만들 수 있다.
객체 선언은 클래스를 정의하고 즉시 인스턴스를 생성하는 방식으로 동작하며, 생성자가 필요 없다.
- 객체 선언의 특징:
- 프로퍼티, 메소드, 초기화 블록을 포함할 수 있음
- 주 생성자, 부 생성자는 가질 수 없음
- 객체 선언이 사용된 위치에서 즉시 생성됨
// 객체 선언을 사용해 Comparator 구현하기
object CaseInsensitiveFileComparator : Comparator<File> {
override fun compare(file1: File, file2: File): Int {
return file1.path.compareTo(
file2.path,
ignoreCase = true
)
}
}
객체 선언의 활용 예
- Comparator 같은 상태를 저장할 필요 없는 객체 생성
- 프로그램 전체에서 하나의 인스턴스만 존재해야 하는 경우 (싱글턴 패턴)
싱글턴과 의존관계 주입
싱글턴 객체는 간편하지만, 객체 생성을 제어할 수 없고, 생성자 파라미터를 지정할 수 없다는 단점이 있다.
- 대규모 시스템에서는 싱글턴보다 의존관계 주입(DI, Dependency Injection)이 더 적합함.
- 코틀린에서도 Guice 같은 DI 프레임워크를 활용하는 것이 좋음.
언제 객체 선언을 사용할까?
의존성이 적고 전역적으로 하나만 존재해야 하는 경우 → 객체 선언이 적합
동적으로 객체를 생성하거나 테스트 환경에서 변경해야 하는 경우 → DI가 필요
클래스 내부의 객체 선언 (중첩 객체)
클래스 안에 object를 선언하면 그 클래스에 속한 싱글턴 객체를 만들 수 있다.
이때 객체는 외부 클래스의 인스턴스와 관계없이 단 하나만 존재한다.
// 중첩 객체를 사용해 Comparator 구현
data class Person(val name: String) {
object NameComparator : Comparator<Person> {
override fun compare(p1: Person, p2: Person): Int =
p1.name.compareTo(p2.name)
}
}
특징:
- NameComparator 객체는 Person 클래스 내부에 선언되었지만, 모든 Person 객체에서 하나만 존재한다.
- Person.NameComparator처럼 클래스 이름을 통해 접근 가능.
4.4.2 동반 객체: 팩토리 메소드와 정적 멤버가 들어갈 장소
코틀린은 static 키워드를 지원하지 않으며, 대신 최상위 함수 또는 객체 선언(Object Declaration) 을 활용한다.
하지만 클래스 내부의 비공개(private) 멤버에 접근해야 하는 경우, 동반 객체(Companion Object) 를 사용해야 한다.

동반 객체(Companion Object)란?
- 클래스 내부에 선언된 객체로, 해당 클래스의 정적 멤버처럼 동작한다.
- 동반 객체의 프로퍼티나 메소드에 접근할 때는 클래스 이름을 사용한다.
- 객체의 이름을 따로 지정할 필요 없이 기본적으로 클래스와 연결된다.
class A {
companion object {
fun bar() {
println("Companion object called")
}
}
}
>>> A.bar()
Companion object called
// Companion Object를 Decompile한 Java Code
public final class A {
@NotNull
public static final Companion Companion = new Companion((DefaultConstructorMarker)null);
public static final class Companion {
private Companion() {
}
public final void bar() {
String var1 = "Companion object called";
System.out.println(var1);
}
// $FF: synthetic method
public Companion(DefaultConstructorMarker $constructor_marker) {
this();
}
}
}
동반 객체의 주요 역할
- 클래스의 비공개(private) 생성자 호출
- 팩토리 패턴 구현 (객체 생성을 관리)
- 정적 필드 및 메소드 정의
Java Static과 무엇이 다른걸까?
다만 companion object는 Java static과 달리 객체로써 생성이 된다는 것 이다. 또한 싱클턴 인스턴스로 생성이 된다.
참고
Kotlin의 object 및 companion object 와 Java의 static 의 차이점
정의: Java에서, static은 '클래스 레벨'에서 변수나 메서드를 정의할 때 사용되는 수식어입니다.특징:메모리: static 멤버는 클래스가 메모리에 로드될 때 초기화됩니다. 그 결과, 클래스의 모든 인
velog.io
동반 객체를 활용한 팩토리 패턴 구현
부 생성자(secondary constructor)를 활용하는 기존 방식:
// 부 생성자가 여러 개 있는 클래스 정의
class User {
val nickname: String
constructor(email: String) { // 부 생성자
nickname = email.substringBefore('@')
}
constructor(facebookAccountId: Int) { // 부 생성자
nickname = getFacebookName(facebookAccountId)
}
}
=> 동반 객체를 활용하여 팩토리 메소드로 개선
class User private constructor(val nickname: String) {
companion object {
fun newSubscribingUser(email: String) = User(email.substringBefore('@'))
fun newFacebookUser(accountId: Int) = User(getFacebookName(accountId))
}
}
>>> val subscribingUser = User.newSubscribingUser("bob@gmail.com")
>>> val facebookUser = User.newFacebookUser(4)
>>> println(subscribingUser.nickname)
bob
팩토리 메소드(Factory Method)란?
- 객체 생성을 단순화하는 정적 메소드의 일종.
- 여러 개의 생성자를 대신할 수 있음.
- 하위 클래스 객체를 반환할 수도 있음.
- 객체 캐싱을 활용하여 불필요한 인스턴스 생성을 방지할 수도 있음.
팩토리 메소드의 장점
- 목적이 명확한 이름을 가질 수 있음
- User.newSubscribingUser(email) vs. User(email)
- 생성자의 역할이 모호한 것보다 메소드 이름을 통해 의미를 명확히 표현 가능.
- 객체 생성을 최적화할 수 있음
- 동일한 email을 가진 인스턴스를 캐싱하여 불필요한 생성 방지 가능.
- 필요 없는 객체 생성을 막을 수 있음.
- 서브클래스를 반환할 수도 있음
- 상황에 따라 적절한 하위 클래스를 반환하는 로직을 구현할 수 있음.
동반 객체 vs. 생성자
구분 | 동반 객체 (Companion Object) | 부 생성자 (Secondary Constructor) |
목적 | 객체 생성 로직을 캡슐화 | 단순한 추가 생성자 제공 |
이름 지정 가능 | newSubscribingUser() 같은 명확한 이름 가능 | 생성자 오버로딩만 가능 |
객체 캐싱 가능 | 동일한 객체를 반환 가능 | 매번 새로운 객체 생성 |
하위 클래스 반환 가능 | 필요에 따라 다른 클래스 인스턴스 반환 가능 | 반드시 자기 자신만 반환 |
4.4.3 동반 객체를 일반 객체처럼 사용
동반 객체(Companion Object) 는 클래스 내부에 정의된 일반 객체로,
이름을 가질 수 있고, 인터페이스를 구현할 수 있으며, 확장 함수도 정의할 수 있다
1. 동반 객체에 이름 지정하기
일반적으로 동반 객체는 클래스 이름을 통해 접근 가능하므로 이름을 명시할 필요가 없다.
하지만 필요하면 companion object 뒤에 이름을 붙일 수 있다.
class Person(val name: String) {
companion object Loader {
fun fromJson(jsonText: String): Person = ...
}
}
>>> val person = Person.Loader.fromJson("{name: 'Dmitry'}")
>>> val person2 = Person.fromJson("{name: 'Brent'}")
특징:
- Person.Loader.fromJson(...) → 명시적으로 객체 이름을 사용하여 접근
- Person.fromJson(...) → 기본적으로 클래스 이름을 통해 접근 (이름이 없을 경우 Companion 사용)
2. 동반 객체에서 인터페이스 구현하기
동반 객체는 인터페이스를 구현할 수 있다.
예를 들어, Person 객체를 JSON으로 변환하는 팩토리 객체를 동반 객체로 만들 수 있다.
// 동반 객체에서 인터페이스 구현하기
interface JSONFactory<T> {
fun fromJSON(jsonText: String): T
}
class Person(val name: String) {
companion object : JSONFactory<Person> {
override fun fromJson(jsonText: String): Person = ...
}
}
fun loadFromJSON<T>(factory: JSONFactory<T>): T {
// ...
}
loadFromJSON(Person) // Person의 동반 객체가 JSONFactory를 구현하므로 바로 전달 가능
특징:
- Person 클래스 자체가 JSONFactory를 구현한 것처럼 동작
- loadFromJSON(Person)에서 Person.Companion을 직접 지정하지 않아도 됨
3. 동반 객체와 자바 정적 멤버(Java static member)
코틀린 동반 객체는 컴파일 시 정적 필드로 변환되며, 자바에서 접근할 때는 Companion이라는 이름으로 사용된다.
// 자바에서 코틀린의 동반 객체 메소드 호출
Person.Companion.fromJSON("...");
Person.Loader.fromJSON("...");
자바와의 상호 운용성 개선
- 정적 메소드가 필요하면 @JvmStatic 애노테이션을 추가
- 정적 필드가 필요하면 @JvmField 애노테이션 사용
4. 동반 객체 확장
기존 클래스의 동반 객체에 확장 함수를 추가하여 새로운 기능을 추가할 수 있다.
// 동반 객체에 대한 확장 함수 정의하기
class Person(val firstName: String, val lastName: String) {
companion object { } // 비어있는 동반 객체 선언
}
// 확장 함수 정의
fun Person.Companion.fromJSON(json: String): Person {
// JSON 파싱 로직
...
}
// 확장 함수 호출
val p = Person.fromJSON(json)
특징:
- Person.fromJSON(json) 형태로 클래스의 정적 메소드처럼 사용 가능.
- 하지만 실제로는 동반 객체에 정의된 멤버가 아니라 외부에서 추가된 확장 함수.
- 동반 객체가 반드시 선언되어 있어야 확장 함수 추가 가능 (비어 있어도 OK).
4.4.4 객체 식: 무명 내부 클래스를 다른 방식으로 작성
코틀린에서 object 키워드는 싱글턴을 정의할 때뿐만 아니라 무명 객체(anonymous object)를 만들 때도 사용된다.
이는 자바의 무명 내부 클래스(anonymous inner class) 를 대체하는 기능이다.
1. 무명 객체 사용 예시
이벤트 리스너를 무명 객체로 구현하는 예제:
// 무명 객체로 이벤트 리스너 구현하기
window.addMouseListener(
object : MouseAdapter() { // MouseAdapter를 확장하는 무명 객체 선언
override fun mouseClicked(e: MouseEvent) {
// 클릭 이벤트 처리
}
override fun mouseEntered(e: MouseEvent) {
// 마우스 진입 이벤트 처리
}
}
)
특징:
- 객체 선언과 동일한 구문이지만 객체에 이름을 붙이지 않음.
- 즉시 클래스를 정의하고 해당 클래스의 인스턴스를 생성.
- 보통 함수 호출 시 인자로 전달하므로 별도의 이름이 필요하지 않음.
2. 무명 객체에 변수 할당
무명 객체에 이름을 부여하려면 변수에 대입하면 된다.
val listener = object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) { /* ... */ }
override fun mouseEntered(e: MouseEvent) { /* ... */ }
}
이제 listener 변수를 사용하여 이벤트 리스너를 등록할 수 있다.
3. 여러 인터페이스 구현
코틀린의 무명 객체는 여러 인터페이스를 구현하거나, 클래스를 확장하면서 인터페이스도 함께 구현할 수 있다.
val myObject = object : Runnable, AutoCloseable {
override fun run() { /* 실행 로직 */ }
override fun close() { /* 자원 해제 로직 */ }
}
자바의 무명 클래스와 차이점:
- 자바: 한 개의 클래스만 확장하거나 한 개의 인터페이스만 구현 가능.
- 코틀린: 여러 개의 인터페이스를 동시에 구현 가능
4. 무명 객체는 싱글턴이 아니다
- 객체 선언(object declaration)은 싱글턴이지만, 무명 객체는 싱글턴이 아니다.
- 객체 식이 실행될 때마다 새로운 인스턴스가 생성됨.
fun createListener() = object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) { /* ... */ }
}
val listener1 = createListener()
val listener2 = createListener()
println(listener1 === listener2) // false (새로운 인스턴스가 생성됨)
5. 무명 객체 내부에서 함수의 지역 변수 사용
무명 객체 내부에서 함수의 지역 변수에 접근 가능하며, 값 변경도 가능하다.
(자바에서는 final 변수만 접근 가능하지만, 코틀린에서는 var도 사용 가능)
// 무명 객체 안에서 로컬 변수 사용하기
fun countClicks(window: Window) {
var clickCount = 0
window.addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
clickCount++ // 무명 객체 내부에서 지역 변수 변경 가능
}
})
}
6. 무명 객체 vs. SAM 변환 (람다 사용)
- 무명 객체 → 여러 개의 메소드를 오버라이드해야 할 때 적합.
- SAM 변환 (Single Abstract Method, 단일 추상 메소드 변환) → 메소드가 하나뿐인 경우 람다(lambda) 사용 가능.
// Runnable 인터페이스는 메소드가 하나이므로 람다 사용 가능
val runnable = Runnable { println("Running...") }
요약
- 코틀린의 인터페이스는 자바 인터페이스와 비슷하지만 디폴트 구현을 포함할 수 있고(자바 8부터는 자바에서도 가능함), 프로퍼티도 포함할 수 있다(자바에서는 불가능).
- 모든 코틀린 선언은 기본적으로 final이며 public이다.
- 선언이 final이 되지 않게 만들려면 상속과 오버라이딩이 가능하게 하려면 앞에 open을 붙여야 한다.
- internal 선언은 같은 모듈 안에서만 볼 수 있다.
- 중첩 클래스는 기본적으로 내부 클래스가 아니다. 바깥쪽 클래스에 대한 참조를 중첩 클래스 안에 포함시키려면 inner 키워드를 중첩 클래스 선언 앞에 붙여서 내부 클래스로 만들어야 한다.
- sealed 클래스를 상속하는 클래스를 정의하려면 반드시 부모 클래스 정의 안에 중첩(또는 내부) 클래스로 정의해야 한다(코틀린 1.1부터는 같은 파일 안에만 있으면 된다.
- 초기화 블록과 부 생성자를 활용해 클래스 인스턴스를 더 유연하게 초기화할 수 있다.
- field 식별자를 통해 프로퍼티 접근자 게터와 세터) 안에서 프로퍼티의 데이터를 저장하는 데 쓰이는 뒷받침하는 필드를 참조할 수 있다.
- 데이터 클래스를 사용하면 컴파일러가 equals, hashCode, toString, copy 등의 메소드를 자동으로 생성해준다.
- 클래스 위임을 사용하면 위임 패턴을 구현할 때 필요한 수많은 성가신 준비 코드를 줄일 수 있다.
- 객체 선언을 사용하면 코틀린답게 싱글턴 클래스를 정의할 수 있다. (패키지 수준 함수와 프로퍼티 및 동반 객체와 더불어) 동반 객체는 자바의 정적 메소드 와 필드 정의를 대신한다.
- 동반 객체도 다른 (싱글턴) 객체와 마찬가지로 인터페이스를 구현할 수 있다. 외부에서 동반 객체에 대한 확장 함수와 프로퍼티를 정의할 수 있다.
- 코틀린의 객체 식은 자바의 무명 내부 클래스를 대신한다. 하지만 코틀린 객체식은 여러 인스턴스를 구현하거나 객체가 포함된 영역5cope에 있는 변수의 값을 변경할 수 있는 등 자바 무명 내부 클래스보다 더 많은 기능을 제공한다.
도서 링크 바로가기
https://product.kyobobook.co.kr/detail/S000001804588
Kotlin in Action | 드미트리 제메로프 - 교보문고
Kotlin in Action | 코틀린이 안드로이드 공식 언어가 되면서 관심이 커졌다. 이 책은 코틀린 언어를 개발한 젯브레인의 코틀린 컴파일러 개발자들이 직접 쓴 일종의 공식 서적이라 할 수 있다. 코틀
product.kyobobook.co.kr
'Programming Language > Kotlin' 카테고리의 다른 글
[Kotlin In Action] 6장. 코틀린 타입 시스템 정리 feat. 내가 정리한 부분만 (0) | 2025.04.03 |
---|---|
[Kotlin In Action] 5장. 람다로 프로그래밍 정리 (1) | 2025.03.21 |
[Kotlin In Action] 3장. 함수 정의와 호출 정리 (0) | 2025.03.20 |
[Kotlin In Action] 2장. 코틀린 기초 스터디 정리 (0) | 2025.03.01 |
[Kotlin In Action] 1장. 코틀린이란 무엇이며, 왜 필요한가? 정리 (0) | 2025.02.23 |
Kotlin in Action을 공부하며 정리한 내용입니다.
저작권에 문제가 될 시, 글을 모두 내리겠습니다.
제가 공부한 내용이 더 많은 분들에게도 도움이 되었으면 좋겠습니다. 부족한 부분은 댓글을 통해서 피드백을 주신다면 언제나 반영하겠습니다. 감사합니다.
책에 대한 링크는 맨 아래에 있습니다.
https://github.com/Kotlin-Android-Study-with-SSAFY/Kotlin_In_Action_1
GitHub - Kotlin-Android-Study-with-SSAFY/Kotlin_In_Action_1: SSAFY 13기 모바일 트랙 구미 5반 "코틀린 인 액션" 스
SSAFY 13기 모바일 트랙 구미 5반 "코틀린 인 액션" 스터디(A). Contribute to Kotlin-Android-Study-with-SSAFY/Kotlin_In_Action_1 development by creating an account on GitHub.
github.com
이 장에서는 코틀린의 클래스와 인터페이스를 깊이 이해하는 데 초점을 맞춘다. 코틀린의 클래스는 기본적으로 final이며 public이고, 중첩 클래스는 외부 클래스에 대한 참조가 없는 등 자바와 몇 가지 차이점이 있다. 또한 data class를 사용하면 표준 메서드가 자동 생성되며, delegation을 활용하면 직접 위임 코드를 작성할 필요가 없다.
또한 object 키워드를 활용해 싱글턴 패턴, 동반 객체(Companion Object), 객체 식(Object Expression), 익명 클래스(anonymous class) 등을 표현할 수 있다.
4.1 클래스 계층 정의
코틀린에서는 클래스 계층을 정의할 때 가시성과 접근 변경자(visibility modifiers)를 사용할 수 있다. 기본적으로 가시성은 public이며, 자바와 다른 점 중 하나는 sealed 변경자로 상속을 제한할 수 있다는 것이다.
4.1.1 코틀린 인터페이스
코틀린 인터페이스는 추상 메서드뿐만 아니라 구현이 포함된 메서드도 정의할 수 있다. 하지만 상태(필드)는 가질 수 없다.
// 간단한 인터페이스 선언
interface Clickable {
fun click()
}
인터페이스를 구현하는 클래스는 click() 메서드를 반드시 구현해야 한다.
// 단순한 인터페이스 구현
class Button : Clickable {
override fun click() = println("I was clicked")
}
>>> Button().click()
I was clicked
- 자바와 달리 코틀린에서는 :을 사용해 클래스 확장과 인터페이스 구현을 동시에 처리할 수 있다.
- override 변경자는 반드시 명시적으로 사용해야 하며, 실수로 상위 클래스 메서드를 오버라이드하는 오류를 방지한다.
디폴트 구현이 있는 인터페이스
코틀린 인터페이스는 디폴트 구현이 있는 메서드를 정의할 수 있다.
// 인터페이스 내부에 디폴트 구현 제공
interface Clickable {
fun click()
fun showOff() = println("I'm clickable")
}
- showOff() 메서드는 기본 구현을 제공하므로, 이를 오버라이드하지 않아도 된다.
다른 인터페이스에서도 동일한 showOff() 메서드를 정의할 수 있다.
interface Focusable {
fun setFocus(b: Boolean) = println("I ${if (b) "got" else "lost"} focus.")
fun showOff() = println("I'm focusable")
}
여러 인터페이스 구현 충돌 해결
하나의 클래스에서 두 인터페이스를 구현하면 컴파일러가 모호성을 해결하도록 강제한다.
// 두 인터페이스를 동시에 구현
class Button : Clickable, Focusable {
override fun click() = println("I was clicked")
override fun showOff() {
super<Clickable>.showOff() // Clickable의 showOff() 호출
super<Focusable>.showOff() // Focusable의 showOff() 호출
}
}
- super<인터페이스명>.메서드() 형식으로 명시적으로 호출할 인터페이스를 지정할 수 있다.
- 특정 인터페이스의 메서드만 호출하려면 다음과 같이 작성하면 된다.
override fun showOff() = super<Clickable>.showOff()
자바에서 코틀린의 인터페이스 사용
- 코틀린 인터페이스는 자바 6과 호환되도록 설계되어 디폴트 메서드를 직접 지원하지 않는다.
- 대신, 인터페이스는 선언만 포함하고, 디폴트 메서드는 정적 메서드로 분리된 별도 클래스로 생성된다.
- 따라서 자바에서 코틀린 인터페이스를 구현할 때는 모든 디폴트 메서드를 직접 구현해야 한다.
4.1.2 open, final, abstract 변경자: 기본적으로 final
자바에서는 final을 명시하지 않으면 모든 클래스가 기본적으로 상속 가능하다. 하지만 이는 취약한 기반 클래스(fragile base class) 문제를 초래할 수 있다. 기반 클래스를 변경하면 하위 클래스의 예상치 못한 동작 변경이 발생할 가능성이 있기 때문이다.
이 문제를 방지하기 위해 "상속을 위한 설계를 갖추거나, 그렇지 않다면 상속을 금지하라"는 원칙이 있다. 코틀린은 이를 반영하여 클래스와 메서드가 기본적으로 final이므로, 명시적으로 open을 선언해야만 상속이 가능하다.
// 열린 메소드를 포함하는 열린 클래스 정의하기
open class RichButton : Clickable { // 클래스가 open이므로 상속 가능
fun disable() {} // 기본적으로 final → 오버라이드 불가능
open fun animate() {} // open이므로 오버라이드 가능
override fun click() { } // 인터페이스 구현 → 기본적으로 open
}
취약한 기반 클래스 문제(Fragile base class)
주니어 개발자들의 흔히 하는 실수 중에 하나가 상속의 매력에 흠뻑 취해 모든 설계에 상속을 적용하려고 하는 것이다. 그러나 상속의 남용은 위험하다.
상속을 하면 자손클래스가 부모클래스의 구현 세부사항에 의존하도록 만든다. 따라서 상속을 사용한다면 취약한 기반클래스 문제(Fragile/Brittle Base Class Problem)를 피해갈 수 없다. 취약한 기반클래스 문제란, 부모 클래스가 변경되었을 때 자식 클래스가 영향을 받는 현상을 말한다. 자식클래스와 부모클래스의 결합도를 높아져, 캡슐화의 장점이 희석되어 버린다.
상속 관계를 추가하면 추가할 수록, 전체 시스템의 결합도는 높아진다. 부모클래스의 구현을 변경하는 작은 변경에도, 상속 계층에 속한 모든 자손클래스들이 영향을 받기 쉬워진다. 극단적인 경우, 부모클래스의 일부를 수정하고 싶은데 모든 자식클래스를 수정해야하는 상황이 되어버릴 수 있다.
오버라이드 금지하기
기반 클래스를 오버라이드할 때 메서드는 기본적으로 open이다. 이를 금지하려면 final을 명시해야 한다.
// 오버라이드 금지하기
open class RichButton : Clickable {
final override fun click() { } // 오버라이드 금지
}
열린 클래스와 스마트 캐스트
클래스를 기본적으로 final로 설정하면 스마트 캐스트(smart cast)가 더 많이 활용될 수 있다.
- 스마트 캐스트는 타입 검사 후 변경될 수 없는 변수에만 적용된다.
- 클래스의 프로퍼티도 스마트 캐스트를 적용하려면 val이며, final이고, 커스텀 접근자가 없어야 한다.
즉, 프로퍼티가 기본적으로 final이므로, 대부분의 프로퍼티에서 스마트 캐스트를 쉽게 활용할 수 있다.
추상 클래스와 추상 멤버
코틀린에서도 abstract 키워드를 사용하여 추상 클래스(abstract class)를 선언할 수 있다.
- 추상 클래스는 인스턴스화할 수 없다.
- 추상 멤버(메서드, 프로퍼티)는 반드시 오버라이드해야 하며, open을 명시할 필요가 없다.
abstract class Animated { // 추상 클래스 → 인스턴스 생성 불가
abstract fun animate() // 추상 함수 → 반드시 오버라이드해야 함
open fun stopAnimating() {} // open → 오버라이드 가능
fun animatedTwice() {} // 기본적으로 final → 오버라이드 불가능
}
인터페이스 멤버의 특성
- 인터페이스 멤버는 기본적으로 open이며 final로 변경할 수 없다.
- 본문이 없는 멤버는 자동으로 abstract이므로, 따로 abstract를 명시할 필요가 없다.
[코틀린 완전정복] 추상 클래스와 인터페이스
추상 클래스? 인터페이스? 뭐가 다르지? 🤔
velog.io
변경자 정리
변경자 | 이 변경자가 붙은 멤버는... | 설명 |
final | 오버라이드 불가능 | 클래스 멤버의 기본 변경자 |
open | 오버라이드 가능 | 명시적으로 선언해야 해야 오버라이드 가능 |
abstract | 반드시 오버라이드해야 함 | abstract class 내부에서만 abstract 변경자 사용 가능 추상 멤버는 구현이 있으면 안됨. |
override | 상위 클래스/인터페이스 멤버를 오버라이드 | 오버라이드하는 멤버는 기본적으로 open, 하위 클래스의 오버라이드를 금지하려면 final 명시 |
4.1.3 가시성 변경자: 기본적으로 공개
가시성 변경자(visibility modifier)는 클래스 외부에서 선언된 요소에 대한 접근을 제어한다.
이를 통해 외부 코드의 영향을 받지 않고 클래스 내부 구현을 안전하게 변경할 수 있다.
코틀린은 public, protected, private 변경자를 지원하며, 자바와 다른 점은 다음과 같다:
- 기본 가시성
- 자바의 기본 가시성은 패키지(private-package)이지만, 코틀린의 기본 가시성은 public이다.
- 즉, 별도로 변경자를 명시하지 않으면 모든 선언이 공개(public) 된다.
- 패키지 전용 가시성 없음
- 코틀린의 패키지는 네임스페이스 관리용이며, 자바처럼 가시성 제어에는 사용되지 않는다.
- internal: 모듈 내부에서만 접근 가능
- 코틀린은 패키지 전용 가시성의 대안으로 internal을 제공한다.
- internal은 같은 모듈 내에서만 접근 가능하므로, 모듈 외부에서 불필요한 접근을 차단할 수 있다.
- 최상위 선언에서 private 허용
- 코틀린에서는 최상위 클래스, 함수, 프로퍼티도 private으로 선언 가능하며,
해당 파일 내에서만 접근할 수 있도록 제한할 수 있다.
- 코틀린에서는 최상위 클래스, 함수, 프로퍼티도 private으로 선언 가능하며,
가시성 변경자의 동작 방식
변경자 | 클래스 멤버 | 최상위 선언 (클래스, 함수, 프로퍼티 등) |
public (기본값) | 모든 곳에서 접근 가능 | 모든 곳에서 접근 가능 |
internal | 같은 모듈 내에서만 접근 가능 | 같은 모듈 내에서만 접근 가능 |
protected | 하위 클래스에서만 접근 가능 | (최상위 선언에는 적용 불가) |
private | 같은 클래스 내부에서만 접근 가능 | 같은 파일 내에서만 접근 가능 |
internal open class TalkativeButton : Focusable {
private fun yell() = println("Hey!") // 클래스 내부에서만 접근 가능
protected fun whisper() = println("Let's talk") // 하위 클래스에서만 접근 가능
}
// public 확장 함수에서 internal 클래스의 private/protected 멤버에 접근하려 하면 오류 발생
fun TalkativeButton.giveSpeech(){
yell() // 오류 발생
whisper() // 오류 발생
}
- giveSpeech()는 public 함수이므로 internal 클래스의 private/protected 멤버를 참조할 수 없다.
- 이를 해결하려면 giveSpeech()의 가시성을 internal로 변경하거나, TalkativeButton의 가시성을 public으로 변경해야 한다.
코틀린과 자바의 protected 차이점
- 자바: 같은 패키지에 속한 클래스는 protected 멤버에 접근 가능.
- 코틀린: protected 멤버는 오직 하위 클래스 내부에서만 접근 가능하며, 같은 패키지라도 접근 불가.
- 확장 함수는 protected 멤버에 접근할 수 없음 → 오직 클래스 내부에서만 접근 가능.
코틀린의 가시성 변경자와 자바
- 코틀린의 public, protected, private은 자바에서도 동일하게 유지됨.
- 하지만 자바에서는 private 클래스를 선언할 수 없으므로,
코틀린의 private 클래스는 자바에서 "패키지 전용" 가시성으로 컴파일됨. - 코틀린의 internal은 바이트코드에서 public으로 변환됨.
- 따라서 자바 코드에서는 internal 멤버에 접근이 가능하지만,
코틀린 컴파일러는 이름을 난독화(매우 긴 이름으로 변환)하여 실수로 접근하는 것을 방지한다.
- 따라서 자바 코드에서는 internal 멤버에 접근이 가능하지만,
코틀린과 자바의 또 다른 차이점
- 자바: 외부 클래스는 중첩 클래스의 private 멤버에 접근 가능.
- 코틀린: 외부 클래스는 중첩 클래스의 private 멤버에 접근할 수 없음.
4.1.4 내부 클래스와 중첩된 클래스: 기본적으로 중첩 클래스
코틀린에서도 클래스 내부에 다른 클래스를 선언할 수 있으며, 이는 도우미 클래스 캡슐화 또는 코드의 가독성 향상에 유용하다.
코틀린과 자바의 차이점
- 자바: 중첩 클래스를 선언하면 기본적으로 내부 클래스(inner class)가 되며, 바깥 클래스에 대한 참조를 자동으로 포함한다.
- 코틀린: 기본적으로 static 중첩 클래스처럼 동작하며, 바깥쪽 클래스에 대한 참조를 포함하지 않는다.
→ inner 변경자를 사용해야 내부 클래스가 된다.
// Java 코드
public class A {
static class B {
int num;
}
}
B b = new A.B(); // 사용가능
// Kotlin 코드
class A {
class B {
var num: Int = 0
}
}
fun main() {
val b = A.B()
}
B 클래스는 바깥의 A 클래스의 인스턴스에 종속적이지 않기 때문에 A 클래스의 인스턴스 변수에 대해 접근할 수 없다.
class A {
val aNum = 10
fun getBnum() {
print(bNum) // impossible! <- Compile Error
}
class B {
var bNum = 0
fun getAnum() {
println(aNum) // impossible! <- Compile Error
}
}
}
A 클래스 내에 B 클래스를 정의하지만 사실상 서로 독립된 메모리를 사용
Inner를 사용
class A {
val aNum = 10
fun getBnum() {
print(bNum) // impossible!
}
inner class B {
var bNum = 0
fun getAnum() {
println(aNum) // possible!
}
}
}
내부 클래스로 변경하기 (inner 키워드)
바깥쪽 클래스의 인스턴스를 참조하도록 하려면 inner 키워드를 추가해야 한다.
클래스 B 안에 정의된 클래스 A | 자바에서는 | 코틀린에서는 |
중첩 클래스 (바깥쪽 클래스에 대한 참조 없음) | static class A | class A |
내부 클래스 (바깥쪽 클래스에 대한 참조 포함) | class A | inner class A |
내부 클래스에서 바깥쪽 클래스 참조하기
코틀린에서 내부 클래스가 바깥쪽 클래스를 참조하는 방법은 자바와 다름.
class Outer {
inner class Inner {
fun getOuterReference(): Outer = this@Outer
}
}
class A {
val num = 10
inner class B {
val num = 5
fun getAnum() {
println(this@A.num) // 10
println(this@B.num) // 5
}
}
}

this@Outer → 바깥쪽 클래스(Outer)의 참조를 가져오는 문법
4.1.5 봉인된 클래스: 클래스 계층 정의 시 계층 확장 제한
클래스 계층을 정의할 때 확장을 제한하고 싶다면 sealed 클래스를 사용하면 된다.
이는 when 식과 함께 사용하면 유용하며, 새로운 하위 클래스 추가 시 컴파일 오류를 발생시켜 실수로 빠뜨리는 경우를 방지할 수 있다.

기존 방식: interface를 사용한 클래스 계층
// 인터페이스 구현을 통해 식(expression) 표현하기
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 -> throw IllegalArgumentException("Unknown expression") // 디폴트 분기 필요
}
문제점
- when 식에서 else 분기가 필요하다.
- Expr의 새로운 하위 클래스를 추가해도 컴파일러가 when 식을 검사하지 않음.
- else 분기가 존재하면 새로운 하위 클래스를 고려하지 않아도 컴파일되기 때문에 실수할 위험이 큼.
해결 방법: sealed 클래스로 계층 확장 제한
sealed 클래스를 사용하면 해당 클래스를 상속하는 모든 하위 클래스를 제한할 수 있다.
// sealed 클래스로 식(expression) 표현하기
sealed class Expr {
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)
} // else 분기 필요 없음
장점
- when 식에서 모든 하위 클래스를 처리하면 else 분기 필요 없음.
- 새로운 하위 클래스를 추가하면 컴파일 오류 발생 → 실수 방지.
- sealed 클래스는 자동으로 open이므로 명시할 필요 없음.
sealed 클래스의 동작 방식
- 하위 클래스는 반드시 같은 파일 내에서 정의해야 함.
→ 다른 파일에서 하위 클래스를 추가할 수 없으므로 확장 범위를 제한할 수 있음. - 컴파일러가 when 식에서 모든 경우가 처리되었는지 검사 가능.
→ 새로운 하위 클래스를 추가하면 when이 자동으로 업데이트되지 않으면 컴파일 오류 발생. - sealed 클래스는 기본적으로 private 생성자를 가짐.
→ 외부에서 직접 인스턴스를 만들 수 없음.
제약 사항과 개선점
코틀린 1.0의 sealed 클래스 제약
- 모든 하위 클래스는 sealed 클래스 내부에 중첩(nested) 클래스로 선언해야 했음.
- 데이터 클래스로 sealed 클래스를 상속할 수 없었음.
코틀린 1.1 이후 개선점
- 같은 파일 내에서 하위 클래스를 정의할 수 있도록 변경됨.
- 데이터 클래스로 sealed 클래스를 상속할 수 있도록 허용됨.
sealed 클래스와 클래스 확장 문법
코틀린에서 클래스를 상속할 때는 콜론(:)을 사용한다.
class Num(val value: Int) : Expr()
- 여기서 Expr()에서 ()는 생성자 호출을 의미.
4.2 뻔하지 않은 생성자와 프로퍼티를 갖는 클래스 선언
코틀린에서는 주 생성자(primary constructor, class 선언부에 바로 정의) 와 부 생성자(secondary constructor, constructor 키워드를 사용하여 class 본문 내부에 정의) 를 구분하며, 초기화 블록(initializer block)을 통해 추가적인 초기화 로직을 설정할 수 있다. 먼저 주 생성자와 초기화 블록을 살펴보고, 이후 여러 생성자를 선언하는 방법과 프로퍼티에 대해 설명한다.
4.2.1 클래스 초기화: 주 생성자와 초기화 블록
코틀린의 주 생성자는 클래스 이름 뒤에 괄호로 선언되며, 생성자 파라미터와 초기화할 프로퍼티를 정의하는 역할을 한다. 아래 예제처럼 constructor 키워드와 init 블록을 사용하여 초기화를 수행할 수 있다.
class User constructor(_nickname: String) {
val nickname: String
init { // 초기화 블록에는 클래스의 객체가 만들어질 때 (인스턴스화될 때) 실행
nickname = _nickname
}
}
하지만 constructor 키워드는 필요하지 않으면 생략 가능하며, 초기화 블록 없이 간단하게 표현할 수도 있다.
class User(_nickname: String) {
val nickname = _nickname
}
더 나아가, 생성자 파라미터를 바로 프로퍼티로 선언하여 코드의 간결성을 높일 수 있다.
class User(val nickname: String)
생성자 파라미터의 디폴트 값
생성자 파라미터에는 디폴트 값을 설정할 수 있으며, 이를 활용하면 파라미터 없이도 객체를 생성할 수 있다.
class User(val nickname: String, val isSubscribed: Boolean = true)
이 경우, User("John")처럼 호출하면 isSubscribed의 기본값 true가 자동으로 설정된다.
기반 클래스의 생성자 호출
코틀린에서 클래스가 다른 클래스를 상속하면, 주 생성자를 통해 기반 클래스의 생성자를 호출해야 한다.
open class User(val nickname: String) {...}
class TwitterUser(nickname: String) : User(nickname) {...}
디폴트 생성자
클래스에 별도 생성자를 정의하지 않으면 컴파일러가 자동으로 인자가 없는 생성자를 만든다.
open class Button
class RadioButton : Button()
이처럼 기반 클래스의 생성자가 호출될 때, 괄호를 통해 명확하게 구분해야 한다. 반면, 인터페이스는 생성자가 없으므로 괄호 없이 상속 관계를 표시한다.
인스턴스화를 막는 private 생성자
클래스를 외부에서 인스턴스화하지 못하도록 하려면 private 생성자 를 사용할 수 있다.
class Secretive private constructor() {}
이 방식은 동반 객체(companion object) 나 싱글턴 패턴을 구현할 때 유용하다.
4.2.2 부 생성자: 상위 클래스를 다른 방식으로 초기화
코틀린에서는 자바보다 생성자 오버로딩이 적게 필요하다. 대부분의 경우 디폴트 파라미터 값과 이름 붙은 인자(named arguments) 를 사용하면 오버로드된 생성자가 필요 없어진다.
Tip: 여러 개의 부 생성자를 선언하는 대신, 생성자 파라미터에 디폴트 값을 명시하라.
하지만 부 생성자가 필요한 경우도 있다. 대표적인 예는 자바 프레임워크 클래스를 확장할 때 여러 방식으로 인스턴스를 초기화해야 하는 경우다. 예를 들어, 안드로이드의 View 클래스에는 두 개의 생성자가 있다.
open class View {
constructor(ctx: Context) {
// 코드
}
constructor(ctx: Context, attr: AttributeSet) {
// 코드
}
}
위 View 클래스는 주 생성자 없이 부 생성자만 두 개 선언되어 있다. 부 생성자는 constructor 키워드로 시작하며, 필요에 따라 여러 개 선언할 수 있다.
상위 클래스 생성자 호출
코틀린에서 부 생성자는 super() 키워드를 사용하여 상위 클래스의 생성자를 호출해야 한다.

class MyButton: View {
constructor(ctx: Context) : super(ctx) {
// 추가 초기화 코드
}
constructor(ctx: Context, attr: AttributeSet) : super(ctx, attr) {
// 추가 초기화 코드
}
}
이 코드에서 super(ctx)와 super(ctx, attr)을 호출하여 View 클래스의 생성자를 초기화한다.
자신의 다른 생성자 호출 (this 사용)
자바와 마찬가지로, this()를 사용하여 자신의 다른 생성자를 호출할 수도 있다.

class MyButton: View {
constructor(ctx: Context) : this(ctx, MY_STYLE) { // MYSTYLE은 어딘가에 정의되어있다고 가정
// 추가 초기화 코드
}
constructor(ctx: Context, attr: AttributeSet) : super(ctx, attr) {
// 추가 초기화 코드
}
}
- 첫 번째 부 생성자는 this(ctx, MY_STYLE)을 호출하여 같은 클래스의 두 번째 생성자를 사용한다.
- 두 번째 생성자는 super(ctx, attr)을 호출하여 상위 클래스 생성자를 초기화한다.
부 생성자의 필수 규칙
주 생성자가 없는 하위 클래스에서는 모든 부 생성자가 반드시 상위 클래스 생성자를 호출하거나(super()), 다른 부 생성자에게 생성을 위임(this())해야 한다. 즉, 어떤 경로로든 결국 상위 클래스 생성자가 호출되어야 한다.
부 생성자가 필요한 이유
- 자바 상호운용성: 자바에서는 부 생성자가 필수적인 경우가 많아, 이를 지원해야 할 때 필요하다.
- 여러 초기화 방법 제공: 클래스 인스턴스를 생성할 때 다양한 방식이 필요하면 부 생성자가 필요할 수 있다.
4.2.3 인터페이스에 선언된 프로퍼티 구현
코틀린에서는 인터페이스에 추상 프로퍼티를 선언할 수 있다. 다음은 예제다.
interface User {
val nickname: String
}
이 인터페이스를 구현하는 클래스는 nickname 값을 제공해야 한다. 하지만 인터페이스 자체는 상태를 저장할 수 없으므로, 실제 저장소(필드)는 하위 클래스에서 관리해야 한다. 이제 User 인터페이스를 구현하는 방법을 살펴보자.
인터페이스 프로퍼티 구현 방식
// 인터페이스의 프로퍼티 구하기
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) // 프로퍼티 초기화 식
}
구현 방식별 차이점
- PrivateUser
- nickname을 주 생성자의 프로퍼티로 직접 선언.
- 간결한 구문을 사용하며 override 키워드로 인터페이스 프로퍼티를 구현.
- SubscribingUser
- 커스텀 게터를 사용하여 nickname을 동적으로 계산.
- nickname이 호출될 때마다 email.substringBefore('@')를 실행.
- FacebookUser
- 객체 초기화 시 nickname을 한 번만 계산하여 저장.
- getFacebookName(accountId) 호출 비용이 크므로, 생성 시 한 번만 실행하여 필드에 저장.
인터페이스에서 프로퍼티 게터 구현
인터페이스에서는 추상 프로퍼티뿐만 아니라 게터와 세터를 가진 프로퍼티도 선언할 수 있다.
interface User {
val email: String
val nickname: String
get() = email.substringBefore('@') // 매번 계산하여 반환
}
동작 방식
- email: 추상 프로퍼티 → 하위 클래스에서 반드시 override해야 함.
- nickname: 기본적으로 제공되는 커스텀 게터 → 하위 클래스에서 override하지 않아도 사용 가능.
인터페이스에는 상태(필드)가 없으므로, 인터페이스의 프로퍼티에는 뒷받침하는 필드가 없다. 따라서 매번 계산하는 방식이 필요하다.
4.2.4 게터와 세터에서 뒷받침하는 필드에 접근
프로퍼티의 두 가지 유형
- 값을 저장하는 프로퍼티 → 내부에 뒷받침하는 필드(backing field) 를 가짐.
- 커스텀 접근자로 값을 계산하는 프로퍼티 → 호출될 때마다 새로운 값을 계산.
이번에는 이 두 가지 유형을 조합하여, 값을 저장하면서도 읽거나 변경할 때 특정 로직을 실행하는 프로퍼티를 만드는 방법을 살펴보자.
뒷받침하는 필드(backing field) 접근 방법
// 세터에서 뒷받침하는 필드 접근하기
class User(val name: String) {
var address: String = "unspecified"
set(value: String) {
println("Address was changed for $name : \"$field\" -> \"$value\".")
field = value
}
}
동작 방식
- user.address = "new value" 같은 코드가 실행되면, 세터(setter) 가 호출됨.
- 세터 안에서 field 키워드를 사용하여 기존 값을 가져오거나 새 값을 설정 가능.
- println(...)을 통해 값이 변경될 때 로그를 출력하도록 구현.
field의 역할
- 게터(getter) 에서는 읽기 전용으로 사용 가능.
- 세터(setter) 에서는 읽고 변경 가능.
- 뒷받침하는 필드가 있는 프로퍼티에서만 field 사용 가능.
뒷받침하는 필드(backing field) 존재 여부
- 프로퍼티에 field를 사용하면, 컴파일러가 자동으로 뒷받침하는 필드를 생성.
- 하지만 field를 사용하지 않는 커스텀 접근자만 존재한다면, 뒷받침하는 필드는 생성되지 않음.
- 예를 들어, val 프로퍼티의 게터(getter)에서 field를 사용하지 않으면 뒷받침하는 필드가 없음.
- var 프로퍼티에서는 게터와 세터 모두에서 field가 없어야 뒷받침하는 필드가 생성되지 않음.
프로퍼티의 가시성 변경
- 기본적으로 프로퍼티의 게터와 세터는 동일한 가시성 수준을 가짐.
- 하지만 필요에 따라 세터의 가시성을 변경할 수도 있음 (예: 내부적으로만 값을 변경 가능하게 함
4.2.5 접근자의 가시성 변경
기본적으로 프로퍼티의 접근자(게터, 세터)는 프로퍼티와 동일한 가시성을 가진다. 하지만 필요에 따라 세터(setter)의 가시성을 변경하여 특정 조건에서만 값을 수정할 수 있도록 설정할 수 있다.
// 비공개 세터가 있는 프로퍼티 선언하기
class LengthCounter {
var counter: Int = 0
private set
fun addWord(word: String) {
counter += word.length
}
}
동작 방식
- counter 프로퍼티의 게터는 기본적으로 public 이므로, 외부에서 값을 읽을 수 있음.
- 하지만 세터는 private이므로 외부에서 직접 값을 변경할 수 없음.
- addWord() 메서드를 통해서만 counter 값을 증가시킬 수 있도록 제한.
>>> val lengthCounter = LengthCounter()
>>> lengthCounter.addWord("Hi!")
>>> println(lengthCounter.counter)
3
- "Hi!"라는 문자열을 추가하면 counter 값이 3으로 증가.
- 하지만 lengthCounter.counter = 10 같은 직접 수정은 불가능 (private set 때문).
4.3 컴파일러가 생성한 메소드: 데이터 클래스와 클래스 위임
자바에서는 equals, hashCode, toString 같은 메소드를 직접 구현해야 하지만, 이는 반복적인 작업이 많아 코드가 번잡해진다. 코틀린은 컴파일러가 자동으로 이러한 메소드를 생성하여 코드의 가독성을 높이고 유지보수를 쉽게 만들어 준다.
4.3.1 모든 클래스가 정의해야 하는 메소드
toString(): 객체의 문자열 표현
객체를 문자열로 표현하는 toString()을 직접 구현하지 않으면 기본적으로 Client@5e9f234 같은 형식이 된다. 이를 유용한 형태로 변경하려면 toString()을 오버라이드해야 한다.
// Client에 toString() 구현하기
class Client(val name: String, val postalCode: Int) {
override fun toString() = "Client(name=$name, postalCode=$postalCode)"
}
이제 println(client1)을 호출하면 "Client(name=오현석, postalCode=4122)"가 출력된다.
equals(): 객체의 동등성 비교
코틀린에서 == 연산자는 내부적으로 equals()를 호출하여 객체를 비교한다. 따라서 equals()를 오버라이드하면 ==를 통해 안전하게 객체 비교가 가능하다.
class Client(val name: String, val postalCode: Int) {
override fun equals(other: Any?): Boolean {
if (other == null || other !is Client) return false
return name == other.name && postalCode == other.postalCode
}
override fun toString() = "Client(name=$name, postalCode=$postalCode)"
}
== vs ===
- == → equals()를 호출하여 값 비교
- === → 참조 비교 (객체의 메모리 주소가 같은지 확인)
이제 동일한 데이터를 가진 Client 객체를 비교하면 client1 == client2는 true를 반환한다.
hashCode(): 해시 컨테이너 사용을 위한 해시값 계산
자바에서는 equals()를 오버라이드하면 반드시 hashCode()도 함께 오버라이드해야 한다. 그렇지 않으면 해시 컨테이너(Set, Map)에서 예기치 않은 동작이 발생할 수 있다.
>>> val processed = hashSetOf(Client("오현석", 4122))
>>> println(processed.contains(Client("오현석", 4122)))
false // 예상과 달리 존재하지 않는다고 판단됨
이는 hashCode()를 정의하지 않았기 때문이다. 해결하려면 hashCode()를 오버라이드해야 한다.
class Client(val name: String, val postalCode: Int) {
override fun equals(other: Any?): Boolean {
if (other == null || other !is Client) return false
return name == other.name && postalCode == other.postalCode
}
override fun toString() = "Client(name=$name, postalCode=$postalCode)"
override fun hashCode(): Int = name.hashCode() * 31 + postalCode
}
이제 hashSet.contains(Client("오현석", 4122))는 true를 반환한다.
4.3.2 데이터 클래스: 모든 클래스가 정의해야 하는 메소드 자동 생성
데이터를 저장하는 역할의 클래스는 toString, equals, hashCode 같은 메소드를 반드시 오버라이드해야 한다.
하지만 코틀린에서는 data 변경자를 사용하면 컴파일러가 자동으로 필요한 메소드를 생성해준다.
// Client를 데이터 클래스로 선언하기
data class Client(val name: String, val postalCode: Int)
데이터 클래스가 자동으로 생성하는 메소드
- equals → 모든 프로퍼티 값이 동일하면 true 반환.
- hashCode → 모든 프로퍼티를 기반으로 해시 코드 생성.
- toString → "Client(name=이름, postalCode=우편번호)" 형태의 문자열 반환.
주의: equals와 hashCode는 주 생성자에 선언된 프로퍼티만 고려한다.
클래스 본문에서 선언된 프로퍼티는 포함되지 않는다.
// Client Data 클래스를 Decompile 하여 Java Code로 변환
public final class Client {
@NotNull
private final String name;
private final int postalCode;
public Client(@NotNull String name, int postalCode) {
Intrinsics.checkNotNullParameter(name, "name");
super();
this.name = name;
this.postalCode = postalCode;
}
@NotNull
public final String getName() {
return this.name;
}
public final int getPostalCode() {
return this.postalCode;
}
@NotNull
public final String component1() {
return this.name;
}
public final int component2() {
return this.postalCode;
}
@NotNull
public final Client copy(@NotNull String name, int postalCode) {
Intrinsics.checkNotNullParameter(name, "name");
return new Client(name, postalCode);
}
// $FF: synthetic method
public static Client copy$default(Client var0, String var1, int var2, int var3, Object var4) {
if ((var3 & 1) != 0) {
var1 = var0.name;
}
if ((var3 & 2) != 0) {
var2 = var0.postalCode;
}
return var0.copy(var1, var2);
}
@NotNull
public String toString() {
return "Client(name=" + this.name + ", postalCode=" + this.postalCode + ')';
}
public int hashCode() {
int result = this.name.hashCode();
result = result * 31 + Integer.hashCode(this.postalCode);
return result;
}
public boolean equals(@Nullable Object other) {
if (this == other) {
return true;
} else if (!(other instanceof Client)) {
return false;
} else {
Client var2 = (Client)other;
if (!Intrinsics.areEqual(this.name, var2.name)) {
return false;
} else {
return this.postalCode == var2.postalCode;
}
}
}
}
데이터 클래스와 불변성: copy() 메소드
데이터 클래스의 프로퍼티는 var로 선언할 수도 있지만, val로 선언하여 불변(immutable)하게 만드는 것이 권장됨.
- 불변 객체는 안전하고 다중 스레드 환경에서 안정적인 동작을 보장한다.
- 해시 기반 컨테이너(HashMap 등)에 데이터 클래스 객체를 키로 사용할 때, 프로퍼티 변경이 없도록 해야 한다.
copy() 메소드 자동 생성
불변성을 유지하면서 일부 프로퍼티만 변경하려면 copy() 메소드를 활용하면 된다.
이 메소드는 원본 객체를 변경하지 않고, 특정 값만 변경한 새로운 객체를 반환한다.
class Client(val name: String, val postalCode: Int) {
override fun equals(other: Any?): Boolean {
if (other == null || other !is Client) return false
return name == other.name && postalCode == other.postalCode
}
override fun toString() = "Client(name=$name, postalCode=$postalCode)"
override fun hashCode(): Int = name.hashCode() * 31 + postalCode
fun copy(name: String = this.name, postalCode: Int = this.postalCode) = Client(name, postalCode)
}
이제 copy()를 활용하면 기존 객체를 기반으로 일부 값만 변경한 새로운 객체를 쉽게 만들 수 있다.
>>> val lee = Client("이계영", 4122)
>>> println(lee.copy(postalCode = 4000))
Client(name=이계영, postalCode=4000)
데이터 클래스에서 자동 생성되는 copy()
위에서 직접 copy()를 구현했지만, 데이터 클래스(data class)에서는 copy() 메소드가 자동 생성된다.
data class Client(val name: String, val postalCode: Int)
>>> val lee = Client("이계영", 4122)
>>> println(lee.copy(postalCode = 4000))
Client(name=이계영, postalCode=4000)
4.3.3 클래스 위임: by 키워드 사용
객체지향 시스템에서 구현 상속(implementation inheritance) 은 하위 클래스가 상위 클래스의 내부 구현에 의존하는 문제를 야기할 수 있다.
코틀린은 이런 문제를 방지하기 위해 모든 클래스를 기본적으로 final로 지정하며, open 키워드를 사용해야만 상속이 가능하도록 설계했다.
그러나 상속이 허용되지 않는 클래스에 새로운 기능을 추가해야 하는 경우도 있다. 이를 해결하는 방법 중 하나가 데코레이터(Decorator) 패턴이다.
데코레이터 패턴
데코레이터 패턴은 기존 클래스를 직접 상속하지 않고, 해당 클래스를 필드로 유지하면서 동일한 인터페이스를 구현하는 방식이다.
하지만 이 방법은 반복적인 코드가 많아지는 단점이 있다.
class DelegatingCollection<T> : Collection<T> {
private val innerList = arrayListOf<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)
}
문제점: 단순히 Collection 인터페이스를 구현할 뿐인데도, 모든 메소드를 하나하나 직접 정의해야 한다.
by 키워드를 활용한 클래스 위임
코틀린에서는 by 키워드를 사용해 인터페이스의 구현을 다른 객체에 위임할 수 있다.
즉, 필요한 메소드를 자동으로 생성해 반복적인 코드를 줄일 수 있다.
class DelegatingCollection<T>(
innerList: Collection<T> = ArrayList<T>()
) : Collection<T> by innerList {}
장점: 기존 코드보다 훨씬 간결하며, 컴파일러가 내부적으로 필요한 전달 메소드를 자동 생성한다.
특정 메소드의 동작 변경
by 키워드를 사용하면 기본적으로 모든 메소드를 위임하지만, 일부 메소드의 동작을 변경할 수도 있다.
아래 예제는 리스트의 원소 추가 횟수를 기록하는 컬렉션을 구현한 것이다.
// 리스트 위임 사용하기
class CountingSet<T>(
val innerSet: MutableCollection<T> = HashSet<T>()
) : MutableCollection<T> by innerSet {
var objectsAdded = 0
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)
}
}
>>> val cset = CountingSet<Int>()
>>> cset.addAll(listOf(1, 1, 2))
>>> println("${cset.objectsAdded} objects were added, ${cset.size} remain")
3 objects were added, 2 remain
코드 분석
- MutableCollection<T> by innerSet
- CountingSet의 대부분의 메소드는 innerSet에게 위임됨.
- add()와 addAll()만 직접 오버라이드
- add()와 addAll()을 오버라이드하여 추가된 원소의 개수를 기록함.
- 나머지 메소드는 자동으로 innerSet에게 위임됨.
by 키워드를 활용한 클래스 위임의 장점
- 반복적인 코드 없이 쉽게 위임을 구현할 수 있음.
- 필요한 메소드만 오버라이드하여 동작을 변경할 수 있음.
- 위임 대상 클래스(내부 컨테이너)의 구현 방식 변화에 덜 의존적임.
- 예를 들어, MutableCollection 내부적으로 addAll()이 add()를 호출할 수도 있고, 다른 방식으로 최적화할 수도 있음.
- CountingSet에서는 addAll()이 add()를 호출하는지 신경 쓸 필요 없이 독립적으로 동작할 수 있음.
4.4 object 키워드: 클래스 선언과 인스턴스 생성
코틀린의 object 키워드는 클래스를 정의하면서 동시에 인스턴스를 생성할 때 사용된다.
이를 활용하면 싱글턴 패턴, 동반 객체, 무명 객체(익명 클래스) 등을 쉽게 구현할 수 있다.
object 키워드를 사용하는 주요 경우
- 객체 선언(Object Declaration) → 싱글턴(Singleton) 생성
- 동반 객체(Companion Object) → 클래스 관련 정적 메소드 제공
- 객체 식(Object Expression) → 익명 객체(자바의 익명 내부 클래스 대체)
4.4.1 객체 선언: 싱글턴을 쉽게 만들기
객체 선언(Object Declaration) - 싱글턴 패턴 구현
코틀린에서는 object 키워드를 사용하여 싱글턴 객체를 쉽게 만들 수 있다.
객체 선언은 클래스를 정의하고 즉시 인스턴스를 생성하는 방식으로 동작하며, 생성자가 필요 없다.
- 객체 선언의 특징:
- 프로퍼티, 메소드, 초기화 블록을 포함할 수 있음
- 주 생성자, 부 생성자는 가질 수 없음
- 객체 선언이 사용된 위치에서 즉시 생성됨
// 객체 선언을 사용해 Comparator 구현하기
object CaseInsensitiveFileComparator : Comparator<File> {
override fun compare(file1: File, file2: File): Int {
return file1.path.compareTo(
file2.path,
ignoreCase = true
)
}
}
객체 선언의 활용 예
- Comparator 같은 상태를 저장할 필요 없는 객체 생성
- 프로그램 전체에서 하나의 인스턴스만 존재해야 하는 경우 (싱글턴 패턴)
싱글턴과 의존관계 주입
싱글턴 객체는 간편하지만, 객체 생성을 제어할 수 없고, 생성자 파라미터를 지정할 수 없다는 단점이 있다.
- 대규모 시스템에서는 싱글턴보다 의존관계 주입(DI, Dependency Injection)이 더 적합함.
- 코틀린에서도 Guice 같은 DI 프레임워크를 활용하는 것이 좋음.
언제 객체 선언을 사용할까?
의존성이 적고 전역적으로 하나만 존재해야 하는 경우 → 객체 선언이 적합
동적으로 객체를 생성하거나 테스트 환경에서 변경해야 하는 경우 → DI가 필요
클래스 내부의 객체 선언 (중첩 객체)
클래스 안에 object를 선언하면 그 클래스에 속한 싱글턴 객체를 만들 수 있다.
이때 객체는 외부 클래스의 인스턴스와 관계없이 단 하나만 존재한다.
// 중첩 객체를 사용해 Comparator 구현
data class Person(val name: String) {
object NameComparator : Comparator<Person> {
override fun compare(p1: Person, p2: Person): Int =
p1.name.compareTo(p2.name)
}
}
특징:
- NameComparator 객체는 Person 클래스 내부에 선언되었지만, 모든 Person 객체에서 하나만 존재한다.
- Person.NameComparator처럼 클래스 이름을 통해 접근 가능.
4.4.2 동반 객체: 팩토리 메소드와 정적 멤버가 들어갈 장소
코틀린은 static 키워드를 지원하지 않으며, 대신 최상위 함수 또는 객체 선언(Object Declaration) 을 활용한다.
하지만 클래스 내부의 비공개(private) 멤버에 접근해야 하는 경우, 동반 객체(Companion Object) 를 사용해야 한다.

동반 객체(Companion Object)란?
- 클래스 내부에 선언된 객체로, 해당 클래스의 정적 멤버처럼 동작한다.
- 동반 객체의 프로퍼티나 메소드에 접근할 때는 클래스 이름을 사용한다.
- 객체의 이름을 따로 지정할 필요 없이 기본적으로 클래스와 연결된다.
class A {
companion object {
fun bar() {
println("Companion object called")
}
}
}
>>> A.bar()
Companion object called
// Companion Object를 Decompile한 Java Code
public final class A {
@NotNull
public static final Companion Companion = new Companion((DefaultConstructorMarker)null);
public static final class Companion {
private Companion() {
}
public final void bar() {
String var1 = "Companion object called";
System.out.println(var1);
}
// $FF: synthetic method
public Companion(DefaultConstructorMarker $constructor_marker) {
this();
}
}
}
동반 객체의 주요 역할
- 클래스의 비공개(private) 생성자 호출
- 팩토리 패턴 구현 (객체 생성을 관리)
- 정적 필드 및 메소드 정의
Java Static과 무엇이 다른걸까?
다만 companion object는 Java static과 달리 객체로써 생성이 된다는 것 이다. 또한 싱클턴 인스턴스로 생성이 된다.
참고
Kotlin의 object 및 companion object 와 Java의 static 의 차이점
정의: Java에서, static은 '클래스 레벨'에서 변수나 메서드를 정의할 때 사용되는 수식어입니다.특징:메모리: static 멤버는 클래스가 메모리에 로드될 때 초기화됩니다. 그 결과, 클래스의 모든 인
velog.io
동반 객체를 활용한 팩토리 패턴 구현
부 생성자(secondary constructor)를 활용하는 기존 방식:
// 부 생성자가 여러 개 있는 클래스 정의
class User {
val nickname: String
constructor(email: String) { // 부 생성자
nickname = email.substringBefore('@')
}
constructor(facebookAccountId: Int) { // 부 생성자
nickname = getFacebookName(facebookAccountId)
}
}
=> 동반 객체를 활용하여 팩토리 메소드로 개선
class User private constructor(val nickname: String) {
companion object {
fun newSubscribingUser(email: String) = User(email.substringBefore('@'))
fun newFacebookUser(accountId: Int) = User(getFacebookName(accountId))
}
}
>>> val subscribingUser = User.newSubscribingUser("bob@gmail.com")
>>> val facebookUser = User.newFacebookUser(4)
>>> println(subscribingUser.nickname)
bob
팩토리 메소드(Factory Method)란?
- 객체 생성을 단순화하는 정적 메소드의 일종.
- 여러 개의 생성자를 대신할 수 있음.
- 하위 클래스 객체를 반환할 수도 있음.
- 객체 캐싱을 활용하여 불필요한 인스턴스 생성을 방지할 수도 있음.
팩토리 메소드의 장점
- 목적이 명확한 이름을 가질 수 있음
- User.newSubscribingUser(email) vs. User(email)
- 생성자의 역할이 모호한 것보다 메소드 이름을 통해 의미를 명확히 표현 가능.
- 객체 생성을 최적화할 수 있음
- 동일한 email을 가진 인스턴스를 캐싱하여 불필요한 생성 방지 가능.
- 필요 없는 객체 생성을 막을 수 있음.
- 서브클래스를 반환할 수도 있음
- 상황에 따라 적절한 하위 클래스를 반환하는 로직을 구현할 수 있음.
동반 객체 vs. 생성자
구분 | 동반 객체 (Companion Object) | 부 생성자 (Secondary Constructor) |
목적 | 객체 생성 로직을 캡슐화 | 단순한 추가 생성자 제공 |
이름 지정 가능 | newSubscribingUser() 같은 명확한 이름 가능 | 생성자 오버로딩만 가능 |
객체 캐싱 가능 | 동일한 객체를 반환 가능 | 매번 새로운 객체 생성 |
하위 클래스 반환 가능 | 필요에 따라 다른 클래스 인스턴스 반환 가능 | 반드시 자기 자신만 반환 |
4.4.3 동반 객체를 일반 객체처럼 사용
동반 객체(Companion Object) 는 클래스 내부에 정의된 일반 객체로,
이름을 가질 수 있고, 인터페이스를 구현할 수 있으며, 확장 함수도 정의할 수 있다
1. 동반 객체에 이름 지정하기
일반적으로 동반 객체는 클래스 이름을 통해 접근 가능하므로 이름을 명시할 필요가 없다.
하지만 필요하면 companion object 뒤에 이름을 붙일 수 있다.
class Person(val name: String) {
companion object Loader {
fun fromJson(jsonText: String): Person = ...
}
}
>>> val person = Person.Loader.fromJson("{name: 'Dmitry'}")
>>> val person2 = Person.fromJson("{name: 'Brent'}")
특징:
- Person.Loader.fromJson(...) → 명시적으로 객체 이름을 사용하여 접근
- Person.fromJson(...) → 기본적으로 클래스 이름을 통해 접근 (이름이 없을 경우 Companion 사용)
2. 동반 객체에서 인터페이스 구현하기
동반 객체는 인터페이스를 구현할 수 있다.
예를 들어, Person 객체를 JSON으로 변환하는 팩토리 객체를 동반 객체로 만들 수 있다.
// 동반 객체에서 인터페이스 구현하기
interface JSONFactory<T> {
fun fromJSON(jsonText: String): T
}
class Person(val name: String) {
companion object : JSONFactory<Person> {
override fun fromJson(jsonText: String): Person = ...
}
}
fun loadFromJSON<T>(factory: JSONFactory<T>): T {
// ...
}
loadFromJSON(Person) // Person의 동반 객체가 JSONFactory를 구현하므로 바로 전달 가능
특징:
- Person 클래스 자체가 JSONFactory를 구현한 것처럼 동작
- loadFromJSON(Person)에서 Person.Companion을 직접 지정하지 않아도 됨
3. 동반 객체와 자바 정적 멤버(Java static member)
코틀린 동반 객체는 컴파일 시 정적 필드로 변환되며, 자바에서 접근할 때는 Companion이라는 이름으로 사용된다.
// 자바에서 코틀린의 동반 객체 메소드 호출
Person.Companion.fromJSON("...");
Person.Loader.fromJSON("...");
자바와의 상호 운용성 개선
- 정적 메소드가 필요하면 @JvmStatic 애노테이션을 추가
- 정적 필드가 필요하면 @JvmField 애노테이션 사용
4. 동반 객체 확장
기존 클래스의 동반 객체에 확장 함수를 추가하여 새로운 기능을 추가할 수 있다.
// 동반 객체에 대한 확장 함수 정의하기
class Person(val firstName: String, val lastName: String) {
companion object { } // 비어있는 동반 객체 선언
}
// 확장 함수 정의
fun Person.Companion.fromJSON(json: String): Person {
// JSON 파싱 로직
...
}
// 확장 함수 호출
val p = Person.fromJSON(json)
특징:
- Person.fromJSON(json) 형태로 클래스의 정적 메소드처럼 사용 가능.
- 하지만 실제로는 동반 객체에 정의된 멤버가 아니라 외부에서 추가된 확장 함수.
- 동반 객체가 반드시 선언되어 있어야 확장 함수 추가 가능 (비어 있어도 OK).
4.4.4 객체 식: 무명 내부 클래스를 다른 방식으로 작성
코틀린에서 object 키워드는 싱글턴을 정의할 때뿐만 아니라 무명 객체(anonymous object)를 만들 때도 사용된다.
이는 자바의 무명 내부 클래스(anonymous inner class) 를 대체하는 기능이다.
1. 무명 객체 사용 예시
이벤트 리스너를 무명 객체로 구현하는 예제:
// 무명 객체로 이벤트 리스너 구현하기
window.addMouseListener(
object : MouseAdapter() { // MouseAdapter를 확장하는 무명 객체 선언
override fun mouseClicked(e: MouseEvent) {
// 클릭 이벤트 처리
}
override fun mouseEntered(e: MouseEvent) {
// 마우스 진입 이벤트 처리
}
}
)
특징:
- 객체 선언과 동일한 구문이지만 객체에 이름을 붙이지 않음.
- 즉시 클래스를 정의하고 해당 클래스의 인스턴스를 생성.
- 보통 함수 호출 시 인자로 전달하므로 별도의 이름이 필요하지 않음.
2. 무명 객체에 변수 할당
무명 객체에 이름을 부여하려면 변수에 대입하면 된다.
val listener = object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) { /* ... */ }
override fun mouseEntered(e: MouseEvent) { /* ... */ }
}
이제 listener 변수를 사용하여 이벤트 리스너를 등록할 수 있다.
3. 여러 인터페이스 구현
코틀린의 무명 객체는 여러 인터페이스를 구현하거나, 클래스를 확장하면서 인터페이스도 함께 구현할 수 있다.
val myObject = object : Runnable, AutoCloseable {
override fun run() { /* 실행 로직 */ }
override fun close() { /* 자원 해제 로직 */ }
}
자바의 무명 클래스와 차이점:
- 자바: 한 개의 클래스만 확장하거나 한 개의 인터페이스만 구현 가능.
- 코틀린: 여러 개의 인터페이스를 동시에 구현 가능
4. 무명 객체는 싱글턴이 아니다
- 객체 선언(object declaration)은 싱글턴이지만, 무명 객체는 싱글턴이 아니다.
- 객체 식이 실행될 때마다 새로운 인스턴스가 생성됨.
fun createListener() = object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) { /* ... */ }
}
val listener1 = createListener()
val listener2 = createListener()
println(listener1 === listener2) // false (새로운 인스턴스가 생성됨)
5. 무명 객체 내부에서 함수의 지역 변수 사용
무명 객체 내부에서 함수의 지역 변수에 접근 가능하며, 값 변경도 가능하다.
(자바에서는 final 변수만 접근 가능하지만, 코틀린에서는 var도 사용 가능)
// 무명 객체 안에서 로컬 변수 사용하기
fun countClicks(window: Window) {
var clickCount = 0
window.addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
clickCount++ // 무명 객체 내부에서 지역 변수 변경 가능
}
})
}
6. 무명 객체 vs. SAM 변환 (람다 사용)
- 무명 객체 → 여러 개의 메소드를 오버라이드해야 할 때 적합.
- SAM 변환 (Single Abstract Method, 단일 추상 메소드 변환) → 메소드가 하나뿐인 경우 람다(lambda) 사용 가능.
// Runnable 인터페이스는 메소드가 하나이므로 람다 사용 가능
val runnable = Runnable { println("Running...") }
요약
- 코틀린의 인터페이스는 자바 인터페이스와 비슷하지만 디폴트 구현을 포함할 수 있고(자바 8부터는 자바에서도 가능함), 프로퍼티도 포함할 수 있다(자바에서는 불가능).
- 모든 코틀린 선언은 기본적으로 final이며 public이다.
- 선언이 final이 되지 않게 만들려면 상속과 오버라이딩이 가능하게 하려면 앞에 open을 붙여야 한다.
- internal 선언은 같은 모듈 안에서만 볼 수 있다.
- 중첩 클래스는 기본적으로 내부 클래스가 아니다. 바깥쪽 클래스에 대한 참조를 중첩 클래스 안에 포함시키려면 inner 키워드를 중첩 클래스 선언 앞에 붙여서 내부 클래스로 만들어야 한다.
- sealed 클래스를 상속하는 클래스를 정의하려면 반드시 부모 클래스 정의 안에 중첩(또는 내부) 클래스로 정의해야 한다(코틀린 1.1부터는 같은 파일 안에만 있으면 된다.
- 초기화 블록과 부 생성자를 활용해 클래스 인스턴스를 더 유연하게 초기화할 수 있다.
- field 식별자를 통해 프로퍼티 접근자 게터와 세터) 안에서 프로퍼티의 데이터를 저장하는 데 쓰이는 뒷받침하는 필드를 참조할 수 있다.
- 데이터 클래스를 사용하면 컴파일러가 equals, hashCode, toString, copy 등의 메소드를 자동으로 생성해준다.
- 클래스 위임을 사용하면 위임 패턴을 구현할 때 필요한 수많은 성가신 준비 코드를 줄일 수 있다.
- 객체 선언을 사용하면 코틀린답게 싱글턴 클래스를 정의할 수 있다. (패키지 수준 함수와 프로퍼티 및 동반 객체와 더불어) 동반 객체는 자바의 정적 메소드 와 필드 정의를 대신한다.
- 동반 객체도 다른 (싱글턴) 객체와 마찬가지로 인터페이스를 구현할 수 있다. 외부에서 동반 객체에 대한 확장 함수와 프로퍼티를 정의할 수 있다.
- 코틀린의 객체 식은 자바의 무명 내부 클래스를 대신한다. 하지만 코틀린 객체식은 여러 인스턴스를 구현하거나 객체가 포함된 영역5cope에 있는 변수의 값을 변경할 수 있는 등 자바 무명 내부 클래스보다 더 많은 기능을 제공한다.
도서 링크 바로가기
https://product.kyobobook.co.kr/detail/S000001804588
Kotlin in Action | 드미트리 제메로프 - 교보문고
Kotlin in Action | 코틀린이 안드로이드 공식 언어가 되면서 관심이 커졌다. 이 책은 코틀린 언어를 개발한 젯브레인의 코틀린 컴파일러 개발자들이 직접 쓴 일종의 공식 서적이라 할 수 있다. 코틀
product.kyobobook.co.kr
'Programming Language > Kotlin' 카테고리의 다른 글
[Kotlin In Action] 6장. 코틀린 타입 시스템 정리 feat. 내가 정리한 부분만 (0) | 2025.04.03 |
---|---|
[Kotlin In Action] 5장. 람다로 프로그래밍 정리 (1) | 2025.03.21 |
[Kotlin In Action] 3장. 함수 정의와 호출 정리 (0) | 2025.03.20 |
[Kotlin In Action] 2장. 코틀린 기초 스터디 정리 (0) | 2025.03.01 |
[Kotlin In Action] 1장. 코틀린이란 무엇이며, 왜 필요한가? 정리 (0) | 2025.02.23 |