[Kotlin] Kotlin식 문법: 클래스, 객체, 인터페이스 -3

박준규·2022년 2월 19일
0

코틀린

목록 보기
12/19

4. 내부 클래스와 중첩된 클래스: 기본적으로 중첩으로

Kotlin식 문법: 클래스, 객체, 인터페이스 -1에서 작성했던 코드처럼 kotlin도 class 내부에 class를 작성할 수 있다. 다만 kotlin의 경우 java와 다르게 inner를 작성하지 않으면 기본적으로 Nested(중첩)클래스가 된다는 사실을 잊지말자. 여기서 중첩과 내부 클래스의 특징은 java와 같다. inner 클래스의 경우 클래스가 생성되면서 같이 생성되기 때문에 외부 클래스의 field나 method, property에 접근할 수 있으나, 중첩 클래스는 외부 클래스가 생성되어도 추가로 instance나 클래스를 사용하지 않는 이상 생성되지 않기 때문에 외부 클래스의 정보를 이용할 수 없다. 즉 중첩 클래스는 명시적으로 요청하지 않는 이상 외부 클래스 인스턴스에 대한 접근 권한이 없다는 점이 가장 큰 특징이다.

android의 경우 View의 요소를 만든다고 생각해보자. 그리고 해당 View의 상태를 직렬화 해야된다. 물론 View를 직렬화 하는 것은 쉽지 않다고 한다. 그래도 필요한 모든 데이터를 다른 클래스로 복사할 수 있다. 다음의 예제를 보자

interface State: Serializable

interface View {
    fun getCurrentState(): State
    fun restoreState(state: State) {}
}

State interface를 선언하고 Serializable을 구현한다. View 인터페이스 안에는 View의 상태를 가져와 저장할 때 사용할 getCurrentState와 restoreState메소드 선언이 있다.

이제 View의 일종인 Button 클래스의 상태를 저장하는 클래스는 Button 클래스 내부에 선언하면 편하다. 이때 자바에서는 다음과 같이 코드를 작성한다.

public class Button implements View {
	@override
	public State getCurrentState() {
		return new ButtonState();
	}
	
	@override
	public void restoreState(State: state) {/* ... */}
	
	public class ButtonState implements State {/* ... */}
}

State interface를 구현한 ButtonState 클래스를 정의해서 Button에 대한 구체적인 정보를 저장한다. getCurrentState 메소드 안에서는 ButtonState의 새 instance를 만든다. 실제로는 ButtonState 안에 필요한 모든 정보를 추가해야 한다.

그리고 무엇보다 위 코드는 잘못되었다. 어디가 잘못된 것일까?

오류는 다음과 같다.

NotSerializableException: Button이라는 오류가 발생한다. 왜? 그럴까?

즉 직렬화하려는 변수는 ButtonState 타입의 state였는데, 왜 Button을 직렬화할 수 없다는 예외가 발생할까?

자바의 특성을 알고 있으면 그 이유를 찾기 쉽다. ButtonState class는 Button의 inner class이기 때문이다. 그렇기 때문에 암묵적으로 Button 클래스를 참조한다. 이로 인해 ButtonState를 직렬화할 수 없다.

왜? inner class이면 직렬화 할 수 없다는 것인가?

왜냐하면 inner class의 Button class 참조가 직렬화를 방해하기 때문이다. 따라서 해당 직렬화 문제를 해결하기 위해서는 static으로 만들어야 한다. static을 선언하여 중첩클래스로 변경하면 Button class에 대한 참조가 사라지기 때문에 직렬화가 가능해진다.

여하튼 수정된 자바 코드를 그대로 코틀린에 반영하게 되면 다음과 같다.

interface State: Serializable

interface View {
    fun getCurrentState(): State
    fun restoreState(state: State) {}
}

class Button : View {
    override fun getCurrentState() : State = ButtonState()
    override fun restoreState(state: State) {/* ... */}
    class ButtonState: State {/* ... */}
}

코틀린에선 Static으로 반영하기 위해서는 아무것도 붙이지 않은 것과 같다. 따라서 직렬화와 같은 특성을 반영하기 위해서 작성할 코드가 자바에 비해서 현저히 줄어들게 된다.

참고로 내부 클래스로 만들고 싶다면 class 앞에 inner를 붙이면 된다.

직렬화라는 예제를 통해서 중첩과 내부 클래스의 차이를 설명하면서 java와 kotlin의 차이까지 알아보면 다음과 같다.

클래스 B 안에서 정의된 클래스 AJavaKotlin
중첩 클래스 (바깥쪽 클래스에 대한 참조를 저장하지 않음)static class Aclass A
내부 클래스 (바깥쪽 클래스에 대한 차조를 저장함)class Ainner class A

Kotlin에서의 중첩과 내부 클래스를 java와 더 비교해보자.

외부 클래스의 인스턴스를 가리키는 참조 표기법도 java와 다르다.

class Outer {
    inner class Inner {
        fun getOuterReference(): Outer = this@Outer
    }
}

내부 클래스에서 외부 클래스의 Outer의 참조에 접근하려면 this@Outer라고 작성해야 한다. 물론 저 표기는 class에 한정해서 사용하지 않는다.

이제는 kotlin에서 중첩 클래스를 유용하게 사용하는 방법에 대해서 알아보자. 클래스 계층을 만들되 그 계층에 속한 클래스의 수를 제한하고 싶은 경우 중첩 클래스를 사용하면 편하다.

sealed class: 클래스 계층 정의 시 계층 확장 제한

지금까지 nested와 inner class에 대해 이야기를 나누면서 계층이라는 말을 하였다. inner class의 경우 계층을 확장하는 것이고 nested의 경우 계층을 확장하지 않는 것을 알아보았지만, 여기서 문제가 하나 있을 수 있다.

만약에 inner class를 계속 사용한다면 어떻게 될까?

계층은 무한정 확장될 것이다. 그러면 여기서 발생할 수 있는 문제는 무엇인가? 당연히 코드의 복잡도는 늘어날 것이고 inner class의 특성상 외부 클래스의 데이터를 모두 참조할 것이므로 메모리 상으로도 좋지 않은 코드가 탄생할 것이다. 더욱이 전에 작성했던 코드를 보면 클래스 계층을 확실히 정하지 않은 문제로 인해 별로 작성하고 싶지 않은 코드를 작성해야할 위험도 내포한다.

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.left) + eval(e.right)
    else -> {
        throw IllegalArguementException("Unknown Expression")
    }
}

위 코드에서 kotlin compiler는 when을 사용해 Expr 타입의 값을 검사할 때 꼭 default 분기인 else를 강제로 덧붙이게 강제한다. 이 예제의 else에서는 반환할 만한 의미 있는 값이 없으므로 Exception을 던지게 된다. 여기서 중요한 것은 항상 default분기를 추가하는 게 편하지는 않다. 그리고 default 분기가 있으면 이런 클래스 계층에 새로운 하위 클래스를 추가하더라도 compiler가 when이 모든 경우를 처리하는지 제대로 검사할 수 없다.

이때 만약에 새로운 클래스를 생성하여 해당 클래스의 처리를 잊어버렸다면 당연히 else가 선택될 것이기 때문에 버그를 야기할 수 있다.

kotlin은 이런 문제를 sealed class를 통해 해법을 제공한다.

class 앞에 sealed를 붙이면 super class를 상속한 sub class 정의를 제한할 수 있다.

sealed 클래스의 sub class를 정의할 때는 반드시 super class 안에 중첩시켜야 한다.

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 // "when"이 모든 하위 클래스를 검사하므로 별도의 "else" 분기가 없어도 된다.
    is Expr.Sum -> eval (e.right) + eval(e.left)
}

super class인 Expr를 sealed를 통해 봉인한다. 이때 사용할 클래스는 모두 Expr class 안에서 nested class 형식으로 만들어야 한다. 이후 eval function에서 when을 통해 Expr에 있는 모든 sub class를 검사한다. 따라서 어떤 클래스를 분기처리하거나 특정 기능을 담당하는 클래스의 경우 sealed를 통해 nested class로 만든 이후로 처리하면 전에 작성했던 else 구문을 작성하지 않아도 될뿐만 아니라 코드의 가독성 또한 올라가게 된다.

참고: sealed는 자동으로 open된 class임을 기억하자.

잠깐! 근데 왜 sealed일까?

정말 재밌는 것은 sealed class의 경우 해당 클래스 내부에서 자신을 상속하는 것은 허락하지만, 외부에서 자신을 상속하는 것은 허락하지 않는다. 즉 자기 자신에 대해서만 열러있으며 나머지는 열리지 않았다. 그렇기 때문에 open이라는 default값을 갖고 있음에도 불구하고 sealed(봉인)이라는 의미를 갖게된 것이다.

추가적으로 sealed class는 private 생성자를 갖는다. 생성자는 무조건 class 내부에서만 호출할 수 있다. 더욱이 sealed의 경우 interface를 정의할 수 없다.

왜? sealed는 interface를 정의할 수 없는가?

sealed interface를 만들면 해당 인터페이스를 java로 구현하지 못하게 막는 수단이 kotlin comiler에게는 없기 때문이다.

또한 sealed class에 속한 값에 대해 default 분기를 사용하지 않고 when 식을 사용하면 나중에 sealed class의 상속 계층에 새로운 sub class를 추가해도 when이 컴파일되지 않는다. 그렇기 때문에 when을 고쳐야된다는 것을 쉽게 알 수 있다.

profile
'개발'은 '예술'이고 '서비스'는 '작품'이다

0개의 댓글