상속은 아무나 받을 수 없다
코틀린에는 다양하고 편리한 기능들이 많이 있다. 오늘은 그 중에서 sealed 클래스와 인터페이스1에 대해 이야기해 보고자 한다.
여지껏 sealed 클래스, 인터페이스가 각각 무엇인지는 알고 있었고, 코드 가독성 면에서 조금 도움을 받는 것 외에는 유용하다는 느낌을 크게 받지 못했다. 그러던 중 sealed 인터페이스를 제대로 활용하고, 크게 도움을 받게 되었다.
덕분에 어떨 때 sealed를 쓰는 것이 확실히 편리하고 좋은지, 경험한 내용을 정리해 공유하고자 한다. sealed
한정자에 대한 전반적인 내용을 다루지만, 소개하는 예시는 sealed interface
에 집중되어 있다. sealed class는 공유할 정도로 유용하게 활용한 적이 없다. 안 해본 걸 소개할 순 없으니… ㅋㅋ
목차
- 소개
- 유즈케이스 예시
- 계층 구조의 도메인 표현
- 다중 구현과 sealed
- 도메인을 라이브러리화(化)하여 안전하게 사용하기
- 결론
소개
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
(외부 강의)를 추가해 보자.
when
조건문에 바로 빨간줄이 그어진다! is External
브랜치를 추가하거나, else
브랜치를 추가하라고 경고한다. 만약 일반 인터페이스였다면 else
브랜치가 반드시 필요하고, 자연스럽게 External
을 추가했다고 해서 빨간 줄이 그어질 일도 없게 된다.
실수를 하지 않으려 애쓰는 것보다, 실수를 할 수 없는 구조를 만드는 편이 안전하고 신뢰할 수 있다. 그런 면에서 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
기본적인 구조는 동일하다. 위 구조의 특징은, 각 도메인 계층은 인터페이스로 나타냈지만, Attend
와 Audit
은 인터페이스로 나타내지 않았다. 이 둘도 인터페이스로 묶을 수 있다.
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
이라는 도메인이 존재하며, 이 종류는 수십 가지이다.- 각
NoteDetail
은NoteResponseFactory
를 통해 응답 형태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>
을 가져오는 코드는 변함없이 사용할 수 있다.
sealedSubClasses
는 sealed
클래스에 사용할 경우 하위 클래스 목록을 리턴한다 (이 경우 List<KClass<out NoteDetail>>
). objectInstance
는 object declaration
일 경우 그 인스턴스를 리턴하고, 아닐 경우 null
을 리턴한다.
이 동작으로부터 얻는 결과는 마치 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
-
직역해서 봉인된 클래스, 봉인된 인터페이스라고도 부르는데, 개인적으로 이러한 용어는 번역해서 쓰지 않는 것을 선호한다. 이 글에서는 sealed로 유지한다. ↩
-
https://kotlinlang.org/docs/sealed-classes.html ↩
-
이펙티브 코틀린 한국어 번역판. p265 ↩
-
interface에 sealed를 붙일 수 있게 된 건 1.5부터다. 또, 1.0 당시의 sealed는 지금과는 제약이 조금 달랐다. https://discuss.kotlinlang.org/t/sealed-inner-classes/2371/2 ↩
Leave a comment