---
title: "타입을 고치지 않고 타입마다 다른 일을 시킨다"
description: "동작을 하나 추가할 때마다 모든 클래스를 열지 않아도 됩니다. 대신 타입을 하나 추가할 때 모든 동작을 고쳐야 합니다."
author: "유니얼"
pubDate: 2026-09-09
category: "game-dev/design-patterns"
tags: ["Unity"]
canonical: https://blog.unialgames.com/posts/unity-visitor/
---
아이템이 세 종류 있습니다. 무기, 방어구, 소모품.

여기에 기능을 붙입니다. 세이브 파일에 쓰기, 툴팁 문구 만들기, 상점 판매가 계산하기,
인벤토리 정렬 기준 뽑기. 앞으로도 계속 늘어날 예정입니다.

가장 먼저 떠오르는 방법은 각 클래스에 메서드를 넣는 것입니다.

```csharp
public class Weapon : IItem
{
    public string ToSaveString() { ... }
    public string BuildTooltip() { ... }
    public int GetSellPrice() { ... }
    public int GetSortKey() { ... }
}
```

`Armor` 와 `Potion` 에도 똑같이 넣습니다. **동작 하나를 추가할 때마다
세 파일을 열어 메서드를 하나씩 씁니다.** 종류가 열 개면 열 파일입니다.

그리고 `Weapon` 클래스에는 이제 무기와 아무 상관 없는 것들이 섞입니다.
세이브 형식, UI 문구, 상점 정책. **무기를 고치려고 열었는데 UI 코드가 나옵니다.**

두 번째로 떠오르는 방법은 타입을 검사하는 것입니다.

```csharp
string BuildTooltip(IItem item) => item switch
{
    Weapon w => $"공격력 {w.Attack}",
    Armor a => $"방어력 {a.Defense}",
    _ => "",
};
```

동작별로 한 파일에 모이는 것은 좋아졌습니다.
그런데 아이템 종류를 하나 추가하면 **이런 `switch` 를 전부 찾아 고쳐야 하고,
하나 빠뜨려도 컴파일이 됩니다.** 상태 편에서 본 것과 같은 문제입니다.

## 두 방향으로 늘어난다

여기서 알아야 할 것은 확장에 **두 방향**이 있다는 사실입니다.

| | 타입 추가가 쉬운가 | 동작 추가가 쉬운가 |
| --- | --- | --- |
| 클래스마다 메서드 | **쉽다.** 파일 하나 새로 만든다 | 어렵다. 모든 클래스를 연다 |
| 타입 검사 `switch` | 어렵다. 모든 `switch` 를 찾는다 | 쉽다. 함수 하나 새로 쓴다 |
| **비지터** | 어렵다. 모든 비지터를 고친다 | **쉽다.** 클래스 하나 새로 만든다 |

보통의 다형성은 **타입을 늘리기 쉽게** 만들어져 있습니다.
비지터는 정확히 반대로, **동작을 늘리기 쉽게** 만듭니다.

> 그래서 판단 기준은 하나입니다.
> **타입이 더 자주 늘어나는가, 동작이 더 자주 늘어나는가.**
> 타입이 자주 늘면 비지터는 최악의 선택입니다.

## 비지터는 이렇게 생겼다

동작 하나를 클래스 하나로 만듭니다. 그 클래스가 **타입별 처리를 전부 들고 있습니다.**

```csharp
public interface IItemVisitor
{
    void Visit(Weapon weapon);
    void Visit(Armor armor);
    void Visit(Potion potion);
}

public class TooltipVisitor : IItemVisitor
{
    public string Result { get; private set; }

    public void Visit(Weapon w) => Result = $"공격력 {w.Attack}";
    public void Visit(Armor a) => Result = $"방어력 {a.Defense}";
    public void Visit(Potion p) => Result = $"회복 {p.HealAmount}";
}
```

아이템 쪽에는 딱 한 줄만 추가합니다.

```csharp
public interface IItem
{
    void Accept(IItemVisitor visitor);
}

public class Weapon : IItem
{
    public int Attack;
    public void Accept(IItemVisitor v) => v.Visit(this);   // 이 한 줄이 전부
}
```

이제 새 동작은 `IItemVisitor` 를 구현한 클래스 하나입니다.
`Weapon` 도 `Armor` 도 열지 않습니다.

## `Accept` 는 왜 있어야 하나

여기가 이 패턴에서 가장 헷갈리는 부분입니다.
`visitor.Visit(item)` 을 그냥 부르면 안 되는지 궁금해집니다. 안 됩니다.

<x-flow>
<script type="application/json">
{
  "label": "어느 Visit 가 불릴지 언제 정해지는가",
  "lines": [
    "IItem item = new Weapon();   // 변수 타입은 IItem, 담긴 것은 Weapon",
    "",
    "visitor.Visit(item);         // 직접 부르면",
    "",
    "item.Accept(visitor);        // Accept 를 거치면",
    "",
    "// Weapon 안에서",
    "public void Accept(IItemVisitor v) => v.Visit(this);"
  ],
  "cases": [
    {
      "label": "직접 부르면",
      "state": "변수 타입 = IItem",
      "path": [
        { "line": 0, "why": "변수의 타입은 IItem 입니다. 담긴 것은 Weapon 이고요.", "state": "변수 타입 = IItem" },
        { "line": 2, "why": "오버로드는 컴파일할 때 정해집니다. 컴파일러가 보는 것은 변수 타입뿐입니다.", "state": "Visit(IItem) 을 찾는다", "out": "그런 것이 없어 컴파일 에러" }
      ],
      "skip": [4, 5, 6, 7]
    },
    {
      "label": "Accept 를 거치면",
      "state": "변수 타입 = IItem",
      "path": [
        { "line": 0, "why": "여기까지는 같습니다.", "state": "변수 타입 = IItem" },
        { "line": 4, "why": "어느 Accept 가 불릴지는 실행할 때 담긴 것을 보고 정해집니다.", "state": "Weapon.Accept 로 간다" },
        { "line": 7, "why": "이 안에서 this 의 타입은 Weapon 입니다. 이제 정확한 Visit 가 골라집니다.", "state": "this = Weapon", "out": "Visit(Weapon) 호출" }
      ],
      "skip": [2, 3]
    }
  ],
  "note": "C# 은 어느 메서드를 부를지 정할 때 인자의 실제 타입을 보지 않습니다. Accept 를 한 번 거치는 것은 그 한계를 우회하려고 실행 시점의 타입 정보를 한 번 꺼내는 일입니다."
}
</script>
</x-flow>

**같은 이름의 메서드 중 어느 것을 부를지는 컴파일할 때 변수의 타입만 보고 정해집니다.**
그래서 `IItem` 변수를 넘기면 `Visit(Weapon)` 이 골라지지 않습니다.

반면 `item.Accept(...)` 는 실행할 때 담긴 것을 보고 정해집니다.
`Weapon` 의 `Accept` 안으로 들어가면 그때부터 `this` 는 `Weapon` 입니다.
그 시점에 `v.Visit(this)` 를 부르면 정확한 것이 골라집니다.

**타입을 두 번 물어보는 것**이라 두 번 나눠 보낸다는 이름이 붙었습니다.
`Accept` 한 줄의 존재 이유가 이것뿐입니다.

## 대가

**타입을 하나 추가하면 모든 비지터를 고쳐야 합니다.** `Accessory` 를 추가하는 순간
`IItemVisitor` 에 `Visit(Accessory)` 가 늘고, **그 인터페이스를 구현한 클래스 전부**가
컴파일 에러를 냅니다.

이건 나쁘기만 한 건 아닙니다. **컴파일러가 빠뜨린 곳을 전부 알려 줍니다.**
`switch` 방식이었으면 조용히 통과했을 것들입니다. 대신 고칠 파일이 많습니다.

**비지터가 남의 속을 알아야 합니다.** `Visit(Weapon w)` 안에서 `w.Attack` 을 읽으려면
그 필드가 열려 있어야 합니다. 감춰 두려고 만든 것을 다시 열게 됩니다.

**코드가 장황합니다.** 인터페이스 하나, 아이템마다 `Accept` 한 줄,
동작마다 클래스 하나. 결과를 돌려받는 방법도 어색합니다.
위 예제에서 `Result` 프로퍼티를 쓴 것이 그 흔적입니다.

## 유니티에서 쓸 일이 있나

솔직히 말하면 **드뭅니다.** 이 시리즈에서 가장 덜 쓰이는 패턴이고, 마지막에 둔 이유입니다.

잘 맞는 자리는 **트리를 훑는 일**입니다. 노드 종류는 거의 안 바뀌는데
그 트리로 하는 일은 계속 늘어나는 구조입니다.

- 비헤이비어 트리: 노드 종류는 고정, 실행과 검증과 에디터 그리기가 각각 필요
- 대화 스크립트나 퀘스트 조건: 문법은 고정, 해석과 검사와 미리보기가 각각 필요
- 수식 파서: 연산자는 고정, 계산과 출력과 최적화가 각각 필요

**에디터 툴에서도 자주 나옵니다.** 같은 데이터에 검사, 통계, 일괄 수정 같은 것을
계속 붙이게 되는데, 그때마다 런타임 클래스를 열고 싶지는 않으니까요.

그리고 요즘 C# 이면 대안이 있습니다.

```csharp
string BuildTooltip(IItem item) => item switch
{
    Weapon w => $"공격력 {w.Attack}",
    Armor a => $"방어력 {a.Defense}",
    Potion p => $"회복 {p.HealAmount}",
};
```

패턴 매칭 `switch` 는 비지터가 하려던 일을 훨씬 짧게 합니다.
**타입을 빠뜨렸을 때 컴파일러가 경고해 주는 것**도 어느 정도 됩니다.
`Accept` 를 심을 수 없는 남의 타입에도 쓸 수 있습니다.

> **비지터를 쓰기 전에 패턴 매칭으로 되는지부터 봅니다.**
> 타입이 정말 고정이고, 동작이 계속 늘고, 빠뜨리는 것을 컴파일러가 확실히 잡아 주기를
> 바랄 때 비지터가 남습니다.

## 정리

- 동작을 추가할 때마다 모든 클래스를 여는 것도, 모든 `switch` 를 찾는 것도 문제다.
- 확장에는 두 방향이 있다. 타입이 느는 쪽과 동작이 느는 쪽이다.
- 보통의 다형성은 타입 추가가 쉽고, 비지터는 동작 추가가 쉽다. 정확히 반대다.
- 판단 기준은 **무엇이 더 자주 늘어나는가** 하나뿐이다.
- `Accept` 한 줄이 필요한 이유는 C# 이 인자의 실제 타입을 보고 오버로드를 고르지 않아서다.
- 타입을 추가하면 모든 비지터가 컴파일 에러를 낸다. 번거롭지만 빠뜨리지는 않는다.
- 비지터가 대상의 속을 알아야 해서 캡슐화가 약해진다.
- 유니티에서는 트리를 훑는 일과 에디터 툴 말고는 쓸 일이 드물다.
- 패턴 매칭 `switch` 로 되는지부터 본다.