8 minute read

코틀린에는 다양하고 편리한 기능들이 많이 있다. 오늘은 그 중에서 sealed 클래스와 인터페이스1에 대해 이야기해 보고자 한다.

여지껏 sealed 클래스, 인터페이스가 각각 무엇인지는 알고 있었고, 코드 가독성 면에서 조금 도움을 받는 것 외에는 유용하다는 느낌을 크게 받지 못했다. 그러던 중 sealed 인터페이스를 제대로 활용하고, 크게 도움을 받게 되었다.

덕분에 어떨 때 sealed를 쓰는 것이 확실히 편리하고 좋은지, 경험한 내용을 정리해 공유하고자 한다. sealed 한정자에 대한 전반적인 내용을 다루지만, 소개하는 예시는 sealed interface에 집중되어 있다. sealed class는 공유할 정도로 유용하게 활용한 적이 없다. 안 해본 걸 소개할 순 없으니… ㅋㅋ

목차

  1. 소개
  2. 유즈케이스 예시
    1. 계층 구조의 도메인 표현
    2. 다중 구현과 sealed
    3. 도메인을 라이브러리화(化)하여 안전하게 사용하기
  3. 결론

소개

sealed class, interface에 대해, 정확히는 sealed에 대해 코틀린 공식 문서2에서는 이렇게 설명하고 있다.

Sealed classes and interfaces provide controlled inheritance of your class hierarchies. All direct subclasses of a sealed class are known at compile time.

이를 한국어로 번역하면,

Sealed 클래스와 인터페이스는 클래스 계층의 상속을 제어할 수 있게 해 줍니다. Sealed 클래스를 직접 상속한 하위 클래스 (subclass)를 전부 컴파일 시간 (compile time)에 알 수 있습니다.

이 간단하고도 강력한 특징이 sealed 클래스의 핵심 기능이다.

조금 더 살펴 보자. 이펙티브 코틀린3에서는 sealed 클래스의 장점을 아래와 같이 설명한다.

…외부에서 추가적인 서브클래스를 만들 수 없으므로, 타입이 추가되지 않을 거라는 게 보장됩니다. 따라서 when을 사용할 때 else 브랜치를 따로 만들 필요가 없습니다. 이러한 장점을 이용해서 새로운 기능을 쉽게 추가할 수 있으며, when 구문에서 이를 처리하는 것을 잊어버리지 않을 수도 있습니다.

코틀린 인 액션 등 다른 책들의 sealed에 대한 설명도 크게 다르지 않다.

즉 기능과 특징을 나누어 정리해 보면, 우선 기능은

  • sealed 한정자가 붙은 클래스 혹은 인터페이스는 동일 패키지 내에서만 상속/구현할 수 있다.
    • sealed 한정자가 붙은 클래스/인터페이스를 를 상속/구현한 클래스는 동일 패키지 외에서도 상속/구현할 수 있다.

다음으로 특징은,

  • 컴파일 시간에 상속/구현한 하위 클래스를 전부 알 수 있다.
  • when 구문을 사용할 때 else를 추가할 필요가 없다.

이를 바탕으로, 어떻게 이 기능과 특징을 활용할 수 있을지 알아보자.

예시

계층 구조의 도메인 표현

아래와 같은 도메인이 있다고 가정해 보자.

  • 어떤 대학의 강의를 나타내는 Lecture 도메인이 존재한다.
  • 이 도메인의 하위 개념으로 Major (전공), General(일반), Graduate(대학원) 가 각각 존재한다.
  • 하위 도메인들은 각각 Attend(수강), Audit(청강)으로 나뉜다.

즉, 코드로 나타내면 이런 구조가 된다.

// 강의 도메인
sealed interface Lecture

// 강의 하위 도메인
sealed interface Major : Lecture

sealed interface General : Lecture

sealed interface Graduate : Lecture

// 강의 하위 도메인의 구현체
class MajorAttend : Major

class MajorAudit : Major

class GeneralAttend : General

class GeneralAudit : General

class GraduateAttend : Graduate

class GraduateAudit : Graduate

약간 더 구체화해 보자. 아래와 같은 조건을 추가한다.

  • Lecture 인터페이스는 모든 강의에 필요한 정보를 포함한다.
  • 각 하위 도메인은 아래와 같은 특징을 가진다.
    • Major는 해당 전공생의 학년 (grade)이 지정되어 있다.
    • General은 온라인 강의로 제공되는 경우도 있다.
    • Graduate는 풀타임과 파트타임으로 나뉜다.
  • Major, Graduate는 강의 목록이 정해져 있다. General은 해당 정보가 없다.
  • Attend 도메인은 해당 강의 내의 수강생 별 고유 번호가 있다. Audit 도메인은 고유 번호가 없다.

이 내용을 반영해서 각 도메인의 역할 정의를 추가해 보자. 우선 Lecture와 하위 인터페이스를 살펴 본다.

sealed interface Lecture {
    val lectureCode: String
    val isMandatory: Boolean
    val startDate: LocalDate
    val endDate: LocalDate

    fun isPresent(now: LocalDate = LocalDate.now()) = now in startDate..endDate

    fun courses(): List<Course> = when(this) {
        is Major -> courses
        is General -> emptyList()
        is Graduate -> courses
    }
}

sealed interface Major : Lecture {
    val targetGrade: StudentGrade
    val courses: List<Course>
}

sealed interface General : Lecture {
    val isOnline: Boolean
}

sealed interface Graduate : Lecture {
    val isPartTime: Boolean
    val courses: List<Course>
}

// 예시를 위한 정의값
data class Course(
    val name: String
)

enum class StudentGrade {
    FRESHMAN, SOPHOMORE, JUNIOR, SENIOR;
}

일반적인 인터페이스 설계와 크게 다르지 않다.

주목할 부분은 courses()이다. 종류별 하위 도메인에 대해 강의 목록을 리턴하는 함수이다. 앞에서 정의한대로, 강의 목록은 Major, Graduate에는 존재하고, General에는 존재하지 않는다. 이외 다른 경우는 존재하지 않는다. 덕분에 when 조건문 내에 else 브랜치는 존재하지 않는다.

만약 여기서 Lecture의 구현체를 하나 추가한다면? External(외부 강의)를 추가해 보자.

need-else

when 조건문에 바로 빨간줄이 그어진다! is External 브랜치를 추가하거나, else 브랜치를 추가하라고 경고한다. 만약 일반 인터페이스였다면 else 브랜치가 반드시 필요하고, 자연스럽게 External을 추가했다고 해서 빨간 줄이 그어질 일도 없게 된다.

not-sealed-when

실수를 하지 않으려 애쓰는 것보다, 실수를 할 수 없는 구조를 만드는 편이 안전하고 신뢰할 수 있다. 그런 면에서 sealed는 안정적인 코드 작성에 큰 보탬이 된다.

다중 구현과 sealed

각 하위 도메인 별 구현체도 살펴 보자. 내용이 너무 길어져서, 인터페이스의 필드를 오버라이드 (override) 하는 내용들은 주석으로 생략했다.

class MajorAttend(
    // override fields of Major
    val studentId: String
) : Major

class MajorAudit(
    // override fields of Major
) : Major

class GeneralAttend(
    // override fields of General
    val studentId: String
) : General

class GeneralAudit(
    // override fields of General
) : General

class GraduateAttend(
    // override fields of Graduate
    val studentId: String
) : Graduate

class GraduateAudit(
    // override fields of General
) : Graduate

기본적인 구조는 동일하다. 위 구조의 특징은, 각 도메인 계층은 인터페이스로 나타냈지만, AttendAudit은 인터페이스로 나타내지 않았다. 이 둘도 인터페이스로 묶을 수 있다.

sealed interface Attend {
    val studentId: String
}

sealed interface Audit

이제 각 구현체들을 다중 구현하도록 수정하자. 마찬가지로 override val들은 생략했다.

class MajorAttend() : Major, Attend

class MajorAudit() : Major, Audit

class GeneralAttend() : General, Attend

class GeneralAudit() : General, Audit

class GraduateAttend() : Graduate, Attend

class GraduateAudit() : Graduate, Audit

이제 Lecture에서 studentId를 구하는 함수를 작성한다면, 아래와 같이 간편해진다.

sealed interface Lecture {
    // 생략
    fun studentId(): String? = when(this) {
        is Attend -> this.studentId()
        is Audit -> null
    }
}

Lecture를 구현하는 Major, General, Graduate 조건을 안 넣었는데도 else 브랜치가 필요 없다. 이유를 정리해 보면,

  • Attend, Audit의 모든 구현체가 Major, General, Graduate중 하나의 구현체이다.
  • 따라서 Attend, Audit 내에서 Lecture의 모든 하위 인터페이스가 충족된다.

이는 간단하게 확인해 볼 수 있다.

  • Attend 인터페이스에서 sealed 한정자를 제거한다
    • studentId 메서드의 when 조건문에 빨간 줄이 그어진다.
    • 앞서 작성한 courses()에도 빨간 줄이 그어진다.
  • Graduate의 구현체를 하나 작성한다. Attend 혹은 Audit을 구현하지 않는다.
    • studentId 메서드의 when 조건문에 빨간 줄이 그어진다.
    • 앞서 작성한 courses()에는 빨간 줄이 그어지지 않는다.

2개 이상의 인터페이스를 구현하게 되면, 구조 간 결합이 복잡해진다. 자연스레 간단한 수정이 끼칠 영향을 파악하기 어려워지는데, sealed를 사용하면 이를 쉽게 파악할 수 있도록 도와 준다. 필요한 곳에만 적절하게 빨간 줄이 그어지는 것은 매우 감사한 일이다.

도메인을 라이브러리화(化)하여 안전하게 사용하기

sealed를 붙인다는 것은, 같은 패키지가 아닌 곳에서는 해당 클래스/인터페이스를 상속/구현할 수 없다는 말이다. 이 점은 같은 프로젝트 내에서 코드를 작성할 때도 가독성 등 유지보수에 작지 않게 도움이 되지만, 라이브러리를 작성할 때도 도움이 된다.

많은 경우 라이브러리를 작성할 때 인터페이스를 활용한다. 이 인터페이스는 사용자가 구현을 원한다면 직접 구현해서 사용하는 경우도 있고, 라이브러리 내에 있는 구현체만을 사용하는 경우도 있다. 이 두 경우를 어떻게 구분할 수 있을까?

sealed를 사용하면 이런 걱정이 줄어든다. 임의의 구현을 제한해야 할 때 sealed 한정자를 붙여 주자. 위의 예시로 작성한 도메인 내용을 라이브러리에 담고, 다른 프로젝트에서 의존 관계를 맺고 사용하는 경우, Lecture를 임의로 구현해서 사용할 수 없다. 오로지 라이브러리 내에서 정의된 구현체들만 사용할 수 있다.

이 점은 해당 인터페이스 및 구현체의 기능이 임의로 훼손되지 않도록 보장하는 역할로 이어진다. 훼손이라는 표현이 적합한지는 모르겠지만… sealed 없이 when 조건문을 else 브랜치와 함께 작성했다면, 추가되는 구현체에 따라 메서드의 기능이 변경될 것이다. 이를 훼손이라고 본다면, sealed 한정자를 붙여 두는 일은 임의로 훼손되지 않도록 막는 작업인 것이다. (어찌 보면 코틀린의 불변성이 가져다 주는 장점과도 유사하게 느껴진다.)

구현체별 로직 분기

다른 예시를 들어 보자. 아래와 같은 조건이 있다.

  • NoteDetail 이라는 도메인이 존재하며, 이 종류는 수십 가지이다.
  • NoteDetailNoteResponseFactory를 통해 응답 형태 NoteResponse로 변환된다.
  • 위 과정을 거치는 웹 API가 존재한다.

위 조건을 코드로 나타내 보자. 아래와 같은 도메인, 응답 클래스, 팩토리가 각각 있다.

sealed interface NoteDetail

interface NoteResponse

object NoteResponseFactory {
    fun create(noteDetail: NoteDetail): NoteResponse {
        return when(noteDetail) {
            TODO()
        }
    }
}

아직 NoteDetail의 구현체가 없어 TODO()로 처리하였다. 다음으로, Controller를 작성한다.

@RestController
class NoteController {
    companion object {
        private val noteDetails = NoteDetail::class.sealedSubclasses.mapNotNull { it.objectInstance }
    }

    fun note(parameter: String): NoteResponse {
        val detail = requireNotNull(noteDetails.find { parameter == it.parameter })
        return NoteResponseFactory.create(detail)
    }
}

위 코드는 아래 과정을 거치게 된다.

  • Spring 애플리케이션이 시작될 때, NoteController 빈이 생성된다.
  • NoteController 빈이 생성될 때, NoteDetail의 모든 구현체가 인스턴스화 된다. List<NoteDetail> 형태로 noteDetails 내에 담긴다.
  • API가 호출되면, noteDetails에서 parameter의 값에 대응하는 NoteDetail을 찾는다.
  • 찾은 NoteDetail의 구현체를 NoteResponseFactory를 통해 NoteResponse로 변환한다.
    • NoteDetail - NoteResponse 쌍의 변환은 NoteResponse 구현체의 팩토리 메서드에서 처리한다.
  • 변환한 값을 API의 응답값으로 리턴한다.

이 예제는 sealed의 장점을 적극 활용하였다. 컴파일 타임에 모든 하위 클래스를 알 수 있다는 점이 얼마나 강력한 장점인지 자세히 보자.

  • (앞의 예제와 동일하게) ReportDetail의 구현체를 추가하면 NoteResponseFactory에 빨간 줄이 그어진다. when 내의 브랜치를 누락할 수 없다.
  • NoteDetail의 모든 하위 클래스를 확인하고, 이들의 구현체를 List 형태로 만들어 싱글톤으로 보유한다.

두 번째 장점이 주목할 만하다. 모든 하위 클래스를 알 수 있으니, 이들의 인스턴스를 하나씩 만들어 두는 것도 어려운 일이 아니다. 위 구조에서, 수십 개의 NoteDetail 구현체를 작성하게 되어도, List<NoteDetail>을 가져오는 코드는 변함없이 사용할 수 있다.

sealedSubClassessealed 클래스에 사용할 경우 하위 클래스 목록을 리턴한다 (이 경우 List<KClass<out NoteDetail>>). objectInstanceobject declaration일 경우 그 인스턴스를 리턴하고, 아닐 경우 null을 리턴한다.

sealed-sub-classes

이 동작으로부터 얻는 결과는 마치 Spring을 사용할 때, 모든 NoteDetail의 구현체에 @Component 어노테이션을 붙여 두고. 특정 컴포넌트의 생성자에 noteDetails: List<NoteDetail>을 의존관계 주입 (DI) 받아 사용하는 것과 같다. 차이점이 있다면, @Component 어노테이션을 실수로 붙이지 못해 생기는 이슈를 방지할 수 있고, else 브랜치가 없는 when 조건문과도 자연스럽게 함께 사용할 수 있다.

유지보수하기 쉽고 실수를 방지할 수 있는 구조를 작성한다면 sealed 인터페이스가 크게 도움이 된다.

결론

코틀린의 여러 장점들 덕분에 더 안정적인 서비스를 운영하고, 실수를 줄일 수 있다. 이 중 상대적으로 덜 알려졌지만, 코틀린 1.0 시절부터 있었던 sealed 한정자, 그 중에서도 sealed interface에 대해 소개했다4. 물론 코틀린만 sealed 한정자를 제공하지는 않는다. 자바 등 다른 언어들도 이런 좋은 기능을 제공하고 있으며, 지향하는 목적은 모두 같다. 하지만 코틀린은 다른 다양한 장점들과 맞물려, 안정적인 서비스 개발에 큰 도움이 되는 언어임은 분명하다.

코틀린을 쓰고 있지 않지만 관심이 생긴다면, 당장 도입해 보자. 도입할 수 없다면, 코틀린을 적극적으로 쓰는 회사들이 많이 있다.

This post is also available in English.

References

  1. 직역해서 봉인된 클래스, 봉인된 인터페이스라고도 부르는데, 개인적으로 이러한 용어는 번역해서 쓰지 않는 것을 선호한다. 이 글에서는 sealed로 유지한다. 

  2. https://kotlinlang.org/docs/sealed-classes.html 

  3. 이펙티브 코틀린 한국어 번역판. p265 

  4. interface에 sealed를 붙일 수 있게 된 건 1.5부터다. 또, 1.0 당시의 sealed는 지금과는 제약이 조금 달랐다. https://discuss.kotlinlang.org/t/sealed-inner-classes/2371/2 

Leave a comment