
Visitor 패턴은 sealed class와 어떻게 다를까라는 질문은 코틀린에서 디자인 패턴을 배울 때 꽤 중요합니다. 둘 다 여러 타입을 다루는 문제와 연결되지만, 해결하려는 방향은 다릅니다. Visitor는 새 연산을 밖으로 빼는 패턴이고, sealed class는 제한된 타입 집합을 컴파일 타임에 안전하게 다루는 도구에 가깝습니다.
앞 글인 Template Method 패턴과 함수형 대안에서는 상속으로 흐름을 고정할지, 함수형 대안으로 단순화할지를 봤습니다. 이번 글에서는 타입 계층을 유지한 채 연산을 늘릴지, sealed class와 when으로 더 단순하게 풀지 판단합니다.

먼저 문제 상황부터 보기
간단한 문서 구조가 있다고 해보겠습니다. 문서는 제목, 문단, 이미지 블록으로 구성됩니다. 처음에는 화면에 렌더링만 하면 됐지만, 시간이 지나면서 Markdown 내보내기, 글자 수 계산, 검색 색인 만들기 같은 연산이 계속 추가됩니다.
sealed class DocumentNode {
data class Heading(val text: String) : DocumentNode()
data class Paragraph(val text: String) : DocumentNode()
data class Image(val url: String, val alt: String) : DocumentNode()
}이 구조에서 중요한 질문은 두 가지입니다. 앞으로 Heading, Paragraph, Image 같은 타입이 더 자주 늘어날까요? 아니면 render, export, count, index 같은 연산이 더 자주 늘어날까요? 이 질문에 따라 sealed class가 더 단순할 수도 있고 Visitor가 더 나을 수도 있습니다.
sealed class와 when으로 푸는 방식
Kotlin 공식 sealed class 문서는 sealed class와 sealed interface가 제한된 상속 계층을 만들고, 직접 하위 타입들이 컴파일 타임에 알려진다고 설명합니다. 또 when expression과 함께 쓰면 가능한 하위 타입을 모두 다루는지 확인하기 좋습니다. 그래서 타입 집합이 작고 안정적이면 sealed class와 when은 매우 읽기 좋습니다.
fun render(node: DocumentNode): String {
return when (node) {
is DocumentNode.Heading -> "# ${node.text}"
is DocumentNode.Paragraph -> node.text
is DocumentNode.Image -> ""
}
}
fun plainText(node: DocumentNode): String {
return when (node) {
is DocumentNode.Heading -> node.text
is DocumentNode.Paragraph -> node.text
is DocumentNode.Image -> node.alt
}
}이 방식의 장점은 단순함입니다. 각 연산이 함수 하나로 보이고, when 안에서 어떤 타입을 어떻게 처리하는지 바로 읽힙니다. sealed class 덕분에 새 타입을 추가했을 때 when 분기가 빠졌는지도 컴파일러 도움을 받을 수 있습니다.
그럼 Visitor는 왜 필요할까
Visitor 패턴의 의도는 객체에 작동하는 알고리즘을 객체 구조와 분리하는 것입니다. Refactoring.Guru의 Visitor 설명도 Visitor를 객체에 작동하는 알고리즘을 객체와 분리하는 behavioral pattern으로 설명합니다. 즉, 타입 자체는 그대로 두고 새로운 연산을 별도 visitor 객체로 추가하는 방식입니다.
interface DocumentVisitor<R> {
fun visitHeading(node: DocumentNode.Heading): R
fun visitParagraph(node: DocumentNode.Paragraph): R
fun visitImage(node: DocumentNode.Image): R
}
sealed class DocumentNode {
abstract fun <R> accept(visitor: DocumentVisitor<R>): R
data class Heading(val text: String) : DocumentNode() {
override fun <R> accept(visitor: DocumentVisitor<R>): R {
return visitor.visitHeading(this)
}
}
data class Paragraph(val text: String) : DocumentNode() {
override fun <R> accept(visitor: DocumentVisitor<R>): R {
return visitor.visitParagraph(this)
}
}
data class Image(val url: String, val alt: String) : DocumentNode() {
override fun <R> accept(visitor: DocumentVisitor<R>): R {
return visitor.visitImage(this)
}
}
}처음 보면 sealed class와 when보다 훨씬 복잡합니다. 그래서 Visitor는 아무 때나 쓰면 안 됩니다. 이 복잡함은 새 연산이 계속 늘어날 때 의미가 생깁니다.
Visitor로 새 연산 추가하기
Visitor를 쓰면 새 연산을 새 클래스로 분리할 수 있습니다. 예를 들어 Markdown 렌더링과 plain text 추출을 각각 visitor로 둘 수 있습니다.
class MarkdownVisitor : DocumentVisitor<String> {
override fun visitHeading(node: DocumentNode.Heading): String {
return "# ${node.text}"
}
override fun visitParagraph(node: DocumentNode.Paragraph): String {
return node.text
}
override fun visitImage(node: DocumentNode.Image): String {
return ""
}
}
class PlainTextVisitor : DocumentVisitor<String> {
override fun visitHeading(node: DocumentNode.Heading): String = node.text
override fun visitParagraph(node: DocumentNode.Paragraph): String = node.text
override fun visitImage(node: DocumentNode.Image): String = node.alt
}
fun main() {
val nodes = listOf(
DocumentNode.Heading("Visitor 패턴"),
DocumentNode.Paragraph("새 연산을 분리할 때 쓴다."),
DocumentNode.Image("/visitor.png", "Visitor 구조")
)
val markdown = nodes.joinToString("\n") { it.accept(MarkdownVisitor()) }
val text = nodes.joinToString(" ") { it.accept(PlainTextVisitor()) }
}이제 새로운 연산이 필요하면 DocumentNode 자체보다 visitor 클래스를 추가하는 쪽으로 흐름이 갑니다. 예를 들어 WordCountVisitor, SearchIndexVisitor, HtmlRenderVisitor를 만들 수 있습니다. 타입 구조는 안정적인데 연산이 자주 늘어나는 경우라면 이 분리가 도움이 됩니다.
핵심 차이: 타입 추가와 연산 추가
Visitor와 sealed class의 차이는 외우는 것이 아니라 표로 보면 쉽습니다.
- 새 타입이 자주 늘어난다: sealed class + when이 더 단순한 경우가 많다
- 새 연산이 자주 늘어난다: Visitor가 기존 타입을 덜 건드리게 해 줄 수 있다
- 타입도 연산도 자주 늘어난다: 둘 다 불편하므로 모델링 자체를 다시 봐야 한다
- 타입 수가 적고 로직이 단순하다: Visitor는 과한 설계일 가능성이 높다
새 타입 추가가 많은 경우
예를 들어 DocumentNode에 Table, CodeBlock, Quote가 계속 추가된다면 Visitor는 부담이 커집니다. 새 타입이 생길 때마다 DocumentVisitor 인터페이스에 메서드를 추가하고, 모든 visitor 구현체를 고쳐야 하기 때문입니다.
새 연산 추가가 많은 경우
반대로 타입은 Heading, Paragraph, Image 정도로 안정적인데 연산만 계속 늘어난다면 Visitor가 유리해질 수 있습니다. 새 연산을 visitor 하나로 묶어 이름을 줄 수 있고, 관련 로직을 한 파일에 모을 수 있습니다.
코틀린에서는 언제 Visitor를 피하는 게 좋을까
코틀린에서는 sealed class와 when이 강력하기 때문에, 작은 모델에서 Visitor를 먼저 쓰면 코드가 무거워 보입니다. 특히 앱 내부의 UI 상태, 간단한 명령 타입, 네트워크 결과처럼 타입 집합이 작고 처리 로직이 몇 개 안 되는 경우에는 sealed class가 더 자연스럽습니다.
sealed interface UiState {
data object Loading : UiState
data class Success(val items: List<String>) : UiState
data class Error(val message: String) : UiState
}
fun messageOf(state: UiState): String {
return when (state) {
UiState.Loading -> "불러오는 중"
is UiState.Success -> "${state.items.size}개 항목"
is UiState.Error -> state.message
}
}이 정도 문제에 Visitor를 붙이면 설계가 선명해지기보다 읽어야 할 파일만 늘어납니다. 코틀린에서는 단순한 닫힌 타입 계층을 먼저 sealed class나 sealed interface로 보고, 연산이 실제로 많아질 때 Visitor를 검토하는 편이 좋습니다.
Visitor 패턴을 써도 되는 신호
- 도메인 타입 계층이 비교적 안정적이다
- 같은 타입 구조 위에 분석, 출력, 검증, 변환 같은 연산이 계속 늘어난다
- 각 연산을 독립적인 이름과 파일로 분리해야 이해가 쉬워진다
- 타입 클래스 안에 외부 관심사가 계속 섞여 들어오는 것이 불편하다
- 플러그인이나 컴파일러, AST, 문서 변환기처럼 구조 순회와 연산 분리가 중요한 영역이다
대표적으로 AST, 문서 트리, 규칙 엔진, 코드 분석기처럼 타입 구조를 순회하면서 여러 연산을 붙이는 영역에서는 Visitor가 여전히 의미가 있습니다. 반대로 일반 앱의 작은 상태 모델에서는 sealed class가 더 실용적인 경우가 많습니다.
남용하면 생기는 문제
Visitor는 구조가 선명해지는 대신 진입 장벽이 생깁니다. accept, visitor interface, visit 메서드, 구현체가 모두 필요합니다. 팀원이 패턴에 익숙하지 않으면 간단한 로직도 멀리 돌아가는 코드처럼 보일 수 있습니다.
- 간단한 when 분기로 충분한데 파일과 인터페이스가 늘어난다
- 새 타입을 추가할 때 모든 visitor 구현체를 고쳐야 한다
- 타입 계층과 visitor 계층을 함께 이해해야 해서 학습 비용이 생긴다
- 도메인 모델이 자주 바뀌는 초기 단계에서는 변경 비용이 커질 수 있다
그래서 Visitor는 ‘패턴을 배웠으니 써보자’가 아니라, 타입 구조와 연산 변화 방향을 확인한 뒤 선택해야 합니다.
테스트와 유지보수 관점에서 보기
Visitor와 sealed class의 차이는 테스트 코드에서도 드러납니다. sealed class와 when으로 작성한 함수는 연산 단위가 작아서 함수별 테스트를 쓰기 쉽습니다. 반면 Visitor는 연산 하나가 visitor 클래스로 묶이기 때문에, 여러 타입에 대한 동작을 한 테스트 묶음 안에서 확인하기 좋습니다.
예를 들어 MarkdownVisitor를 테스트한다면 Heading, Paragraph, Image를 한 번에 확인할 수 있습니다. 반대로 sealed class 함수 방식은 render, plainText, wordCount 같은 함수마다 when 분기를 테스트하게 됩니다. 어느 쪽이 더 좋은지는 테스트 단위가 도메인에서 어떻게 읽히는지에 따라 달라집니다.
- 연산 자체가 제품 기능 이름으로 오래 남는다면 visitor 클래스가 테스트 단위로 읽히기 쉽다
- 작은 변환 함수가 몇 개뿐이라면 sealed class와 when 함수 테스트가 더 단순하다
- 타입 추가가 잦다면 Visitor 테스트도 같이 많이 깨질 수 있다
- 연산 추가가 잦다면 Visitor가 새 테스트 파일을 추가하는 흐름으로 정리되기 쉽다
실무 판단 질문
마지막으로 실제 코드에서 선택할 때는 아래 질문을 순서대로 던지면 됩니다. 첫째, 타입 집합이 안정적인가. 둘째, 연산이 앞으로 계속 늘어날 가능성이 큰가. 셋째, 연산을 클래스 이름으로 분리했을 때 팀원이 더 빨리 이해하는가. 넷째, 새 타입을 추가할 때 모든 visitor를 고치는 비용을 감당할 수 있는가.
이 네 질문 중 앞의 세 질문에 ‘그렇다’가 많으면 Visitor를 검토할 수 있습니다. 반대로 타입이 계속 바뀌고 연산이 몇 개 안 된다면 sealed class와 when이 더 현실적입니다. 코틀린에서는 특히 작은 모델에 Visitor를 빨리 붙이는 것보다, sealed class로 시작하고 복잡도가 실제로 생겼을 때 분리해도 늦지 않은 경우가 많습니다.
정리
Visitor 패턴과 sealed class의 차이는 단순히 고전 패턴과 코틀린 문법의 차이가 아닙니다. 핵심은 변화의 방향입니다. 타입이 안정적이고 새 연산이 계속 늘어난다면 Visitor가 도움이 될 수 있습니다. 타입 집합이 작고 로직이 단순하다면 sealed class와 when이 더 읽기 좋습니다.
다음 24편에서는 지금까지 본 패턴들을 다시 묶어, 코틀린에서는 어떤 디자인 패턴이 더 단순해지는지 정리하겠습니다.
코틀린 디자인 패턴 시리즈 전체 링크
코틀린 디자인 패턴 시리즈(0) – 왜 아직도 디자인 패턴을 배워야 할까
코틀린 디자인 패턴(1) – Singleton 패턴은 언제 쓰고 object는 어떻게 다를까
코틀린 디자인 패턴(2) – Factory Method 패턴으로 생성 책임 나누기
코틀린 디자인 패턴(3) – Abstract Factory 패턴은 언제 필요할까
코틀린 디자인 패턴(4) – Builder 패턴과 named argument는 어떻게 다를까
코틀린 디자인 패턴(5) – Prototype 패턴과 data class copy 정리
코틀린 디자인 패턴(6) – Adapter 패턴으로 기존 코드를 새 인터페이스에 맞추기
코틀린 디자인 패턴(7) – Bridge 패턴은 상속 폭발을 어떻게 줄일까
코틀린 디자인 패턴(8) – Composite 패턴으로 트리 구조 다루기
코틀린 디자인 패턴(9) – Decorator 패턴은 상속 대신 어떻게 확장할까
코틀린 디자인 패턴(10) – Facade 패턴으로 복잡한 시스템 감추기
코틀린 디자인 패턴(11) – Flyweight 패턴은 메모리를 어떻게 아낄까
코틀린 디자인 패턴(12) – Proxy 패턴은 Decorator와 무엇이 다를까
코틀린 디자인 패턴(13) – Chain of Responsibility 패턴으로 조건 분기 줄이기
코틀린 디자인 패턴(14) – Command 패턴으로 실행과 취소를 다루는 법
코틀린 디자인 패턴(15) – Interpreter 패턴은 어디까지 써야 할까
코틀린 디자인 패턴(16) – Iterator 패턴과 sequence는 어떻게 연결될까
코틀린 디자인 패턴(17) – Mediator 패턴으로 객체 간 얽힘 줄이기
코틀린 디자인 패턴(18) – Memento 패턴으로 상태 저장과 복원하기
코틀린 디자인 패턴(19) – Observer 패턴과 Flow 감각 함께 이해하기
코틀린 디자인 패턴(20) – State 패턴은 조건문 폭발을 어떻게 막을까