---
title: "같은 호출인데 객체마다 다르게 동작한다"
description: "부모 타입으로 불러도 그 객체가 갈아 끼운 메서드가 실행됩니다. 새 자식을 추가해도 부르는 쪽은 안 고칩니다."
author: "유니얼"
pubDate: 2026-09-09
category: "programming/csharp/basics"
tags: ["C#", "기초"]
canonical: https://blog.unialgames.com/posts/csharp-polymorphism/
---
앞 글에서 전사와 마법사를 한 목록에 담았습니다.
이제 각자 공격하게 해 봅시다.

```csharp
for (int i = 0; i < party.Count; i++)
{
    if (party[i] is Warrior w) w.Slash();
    else if (party[i] is Mage m) m.Fireball();
}
```

돌아갑니다. 그런데 궁수를 추가하면 `else if` 를 하나 더 적어야 합니다.
그리고 이런 갈라지기가 코드 여기저기에 흩어집니다.
공격할 때 한 벌, 이동할 때 한 벌, 저장할 때 한 벌입니다.

**직업이 하나 늘 때마다 고칠 자리가 여러 곳으로 흩어집니다.**

> **부르는 이름을 하나로 하고, 무엇이 실행될지는 객체가 정하게 합니다.**

## virtual 과 override

부모에 `virtual` 을 붙이면 "자식이 갈아 끼울 수 있다" 는 표시가 됩니다.
자식은 `override` 로 갈아 끼웁니다.

```csharp
class Character
{
    public virtual void Attack()
    {
        Console.WriteLine("공격");
    }
}

class Warrior : Character
{
    public override void Attack()
    {
        Console.WriteLine("칼로 벤다");
    }
}

class Mage : Character
{
    public override void Attack()
    {
        Console.WriteLine("불덩이를 던진다");
    }
}
```

이제 앞의 반복문이 이렇게 됩니다.

```csharp
for (int i = 0; i < party.Count; i++)
{
    party[i].Attack();
}
```

`party[i]` 는 `Character` 타입입니다. 그런데 실행되는 것은 **그 자리에 든 객체의 것**입니다.

<x-flow>
<script type="application/json">
{
  "label": "같은 줄인데 다르게 동작한다",
  "dwell": 1.5,
  "lines": [
    "for (int i = 0; i < party.Count; i++)",
    "{",
    "    party[i].Attack();",
    "}"
  ],
  "cases": [
    {
      "label": "party 에 전사와 마법사",
      "state": "party = [Warrior, Mage]",
      "path": [
        { "line": 0, "why": "첫 바퀴입니다.", "state": "i = 0 · party[0] 은 Warrior" },
        { "line": 2, "why": "Character 로 보고 있지만 실제로 든 것은 전사입니다. 전사의 것이 실행됩니다.", "out": "칼로 벤다" },
        { "line": 0, "why": "다음 바퀴.", "state": "i = 1 · party[1] 은 Mage" },
        { "line": 2, "why": "코드는 한 글자도 안 바뀌었는데 마법사의 것이 실행됩니다.", "out": "불덩이를 던진다" },
        { "line": 0, "why": "다 돌았습니다.", "state": "i = 2 · 조건이 거짓" }
      ],
      "note": "무엇이 실행될지는 컴파일할 때가 아니라 실행 중에 그 객체를 보고 정해집니다."
    }
  ]
}
</script>
</x-flow>

이걸 다형성이라고 합니다. 같은 호출이 객체에 따라 다르게 동작한다는 뜻입니다.

앞에서 오버로딩은 **컴파일할 때** 정해진다고 했습니다.
오버라이딩은 **실행 중에** 정해집니다. 그 자리에 실제로 무엇이 들었는지를 보고 고릅니다.

## 진짜 값어치는 부르는 쪽에 있다

궁수를 추가해 봅니다.

```csharp
class Archer : Character
{
    public override void Attack()
    {
        Console.WriteLine("화살을 쏜다");
    }
}
```

**부르는 쪽 코드는 한 글자도 안 고칩니다.** `party[i].Attack()` 그대로입니다.

이게 다형성을 쓰는 이유입니다. 갈라지기를 없앤 것이 아니라 **한 곳으로 모은 것**입니다.
어느 공격을 할지는 각 클래스 안에 한 번씩 적혀 있고,
부르는 쪽은 그 판단을 아예 안 합니다.

새 종류를 추가할 때 **고칠 자리가 아니라 더할 자리만 생깁니다.**

## virtual 을 안 붙이면

`virtual` 없이 자식이 같은 이름의 메서드를 만들면 다르게 동작합니다.

```csharp
class Character
{
    public void Attack() { Console.WriteLine("공격"); }
}

class Warrior : Character
{
    public new void Attack() { Console.WriteLine("칼로 벤다"); }
}

Character c = new Warrior();
c.Attack();      // 공격
```

`Warrior` 를 넣었는데 부모의 것이 실행됐습니다.
`new` 는 갈아 끼운 것이 아니라 **가린 것**입니다. 부모 것은 그대로 있고 이름만 겹칩니다.

그래서 **보고 있는 타입이 무엇이냐**로 결정됩니다.

```csharp
Warrior w = new Warrior();
w.Attack();      // 칼로 벤다

Character c = w;
c.Attack();      // 공격, 같은 객체인데 다릅니다
```

같은 객체인데 어느 이름으로 부르냐에 따라 다르게 동작합니다. 거의 항상 버그입니다.
`new` 로 가리는 것은 실무에서 쓸 일이 드뭅니다.

**자식이 갈아 끼울 수 있게 하려면 부모에 `virtual` 을 붙여야 합니다.**
C# 이 이렇게 만든 것은 일부러입니다.
아무 메서드나 갈아 끼울 수 있으면, 부모를 만든 사람이 예상 못 한 자리가 바뀝니다.
`virtual` 은 "여기는 바뀌어도 된다" 고 미리 허락한 자리입니다.

## base 로 부모 것을 부를 수 있다

갈아 끼우면서 부모 것도 하고 싶을 때가 있습니다.

```csharp
class Warrior : Character
{
    public override void Attack()
    {
        base.Attack();                     // 부모의 Attack 을 먼저
        Console.WriteLine("칼로 벤다");
    }
}
```

`base` 는 부모를 가리킵니다. 생성자에서 `: base(...)` 를 쓴 것과 같은 `base` 입니다.

공통 준비는 부모에 두고 자식은 자기 것만 더하는 식으로 쓸 수 있습니다.

## 오버로딩과 오버라이딩은 다른 것이다

이름이 비슷해서 헷갈리는데 관계가 없습니다.

| | 오버로딩 | 오버라이딩 |
| --- | --- | --- |
| 어디서 | 한 클래스 안에서 | 부모와 자식 사이에서 |
| 무엇이 | 이름은 같고 매개변수가 다름 | 이름도 매개변수도 같음 |
| 언제 정해지나 | 컴파일할 때 | 실행 중에 |
| 하는 일 | 이름 하나로 여러 재료를 받음 | 부모의 것을 자식이 갈아 끼움 |

```csharp
void Print(int n)          // 오버로딩
void Print(string s)

public override void Attack()   // 오버라이딩
```

## ToString 은 이미 오버라이딩이었다

모든 클래스는 `object` 를 물려받습니다. 안 적어도 그렇습니다.
`object` 에 `ToString` 이 `virtual` 로 들어 있습니다.

```csharp
Console.WriteLine(w);      // Warrior
```

객체를 그대로 출력하면 타입 이름이 나오는데, 그게 `object` 의 기본 `ToString` 입니다.
갈아 끼우면 원하는 대로 나옵니다.

```csharp
class Warrior : Character
{
    public override string ToString()
    {
        return "전사 " + name;
    }
}

Console.WriteLine(w);      // 전사 바둑
```

앞에서 해시 테이블을 볼 때 "직접 만든 타입은 왜 내용이 같아도 다른가" 를 남겨 뒀습니다.
답이 같은 자리에 있습니다. `object` 에는 `Equals` 와 `GetHashCode` 도 `virtual` 로 있고,
기본 구현이 **내용이 아니라 만들어진 개체**를 기준으로 판단합니다.

내용으로 같다고 보게 하려면 둘 다 갈아 끼워야 합니다.

```csharp
class Point
{
    public int X, Y;

    public override bool Equals(object obj)
    {
        if (obj is Point p) return X == p.X && Y == p.Y;
        return false;
    }

    public override int GetHashCode()
    {
        return HashCode.Combine(X, Y);
    }
}
```

둘을 **같이** 갈아 끼워야 합니다. 하나만 하면 해시 테이블이 깨집니다.
같다고 볼 값이 다른 칸으로 가 버리니까요.

## 정리

- 부모에 `virtual` 을 붙이면 자식이 `override` 로 갈아 끼울 수 있습니다.
- 부모 타입으로 불러도 그 자리에 든 객체의 것이 실행됩니다. 실행 중에 정해집니다.
- 새 자식을 추가해도 부르는 쪽 코드는 안 고칩니다. 이게 다형성을 쓰는 이유입니다.
- `virtual` 없이 같은 이름을 만들면 갈아 끼운 것이 아니라 가린 것입니다. 거의 버그입니다.
- `base` 로 부모의 것을 부를 수 있습니다.
- 오버로딩은 컴파일할 때, 오버라이딩은 실행 중에 정해집니다. 이름만 비슷합니다.
- 모든 클래스는 `object` 를 물려받습니다. `ToString`, `Equals`, `GetHashCode` 가 거기 있습니다.
- 내용으로 같다고 보려면 `Equals` 와 `GetHashCode` 를 **같이** 갈아 끼웁니다.