Unity C#/개념 및 문법 정리

[Unity C#] 객체 지향 _ 상속(추상 클래스, 인터페이스, 다중 상속)

routine96 2025. 1. 17. 23:30

이전에 포스팅 중 나왔던 상속에 대해서 좀 더 자세하게 알아보자.

 

상속

다시 한 번 상속에 대해 간단하게 정리하고 추가적인 부분을 살펴보자. 상속이란 프로그래밍에서 부모 클래스의 기능을 자식 클래스(파생 클래스)가 물려받아서 쓰는 것이다. 부모 클래스의 멤버(변수, 함수)를 자식 클래스에서 사용이 가능하다. 또한 virtual, override 키워드를 통해 부모 클래스에 메서드에서 기능을 추가하거나 변환하여 사용이 가능하다.

 

또한 부모 클래스에 virtual 메서드가 있어도 자식 클래스에서 필요하지 않으면 사용하지 않아도 된다.

    public class Animal
    {
        public virtual void Shout() { }
    }

    public class Tiger : Animal
    {
		// 필요하지 않을 경우 Shout()를 사용하지 않아도 된다.
    }

 

위 코드와 반대로 자식 클래스에서 반드시 재정의 해야 될 경우에는 어떻게 할까? 그래서 사용하는 개념이 추상 클래스이다. 

 

 

 

 

추상 클래스(abstract class)

추상 클래스란 간단하게 설명하면 인스턴스(객체)를 만들 수 없는 특별한 클래스이다. 추상 클래스의 목적은 추상 메서드를 만들고 상속을 통해 자식 클래스(파생 클래스)에서 구현하도록 하는 것이다.

위 사진처럼 꼭 사용해야 하는 메서드에는 앞에 abstract 키워드로 사용한다. 이는 추상 메서드(abstract method)로 추상 클래스에서 반드시 재정의 해야 될 메소드로 자식 클래스에서 반드시 재정의 해야 될 메소드이다. 이렇게 추상 메서드를 자식 클래스에서 구현하지 않는 경우 에러가 발생한다. 

 

위 사진처럼 반드시 재정의를 해줘야 한다.

 

또한 추상 클래스는 말 그대로 추상적인 클래스이기 때문에 따로 기능이 들어가 있지 않고, 자식 클래스에서 재정의해서 사용한다. 그리고 추상 클래스 자체는 인스턴스로 생성하지 못한다.(어차피 아무 기능이 없기 떄문에 막아놨다)

 

즉, 추상 클래스 Animal은 그 자체로 사용할 수 없고, 자식 클래스에서 재정의하여 사용해야 한다.

 

 

 

 

클래스를 이용한 다중 상속 (불가능)

다중 상속이란 말 그대로 부모 클래스를 여러 개 상속받아서 자식 클래스를 생성할 수 있다는 것이다. 결론부터 말하자면 C#에선 클래스를 여러 개 상속받는 건 불가능하다(C++은 가능하다). 차근차근 예시를 통해 이유를 알아보자.

using System.Collections;
using System.Collections.Generic;
using UnityEditorInternal.VersionControl;
using UnityEngine;

public class Multipleinheritance : MonoBehaviour
{
    public abstract class Animal
    {
        public abstract void Shout();
    }

    public class Tiger : Animal
    {
        public override void Shout()
        {
            Debug.Log("Tiger Shout");
        }
    }

    public class Lion : Animal
    {
        public override void Shout()
        {
            Debug.Log("Lion Shout");
        }
    }

    public class Liger : Lion, Tiger
    {
        
    }
}

Animal 클래스를 상속 받는 Tiger, Lion을 제작해 보았다. 그리고 Lion과 Tiger를 둘 다 상속받아서 Liger 클래스를 작성했다. 이렇게 다중 상속을 시도해봤다. 그러나 이 코드를 작성하게 되면 밑에와 같은 에러가 발생한다.

 

왜 에러가 발생할까? 그 이유는 Tiger 클래스에도 Shout 함수가 있고, Lion 클래스에도 Shout 함수가 존재하기 떄문이다.

위 그림에서 보듯이 Liger 클래스의 Shout는 어느 쪽의 Shout를 호출할 지 모르기 떄문이다. 이를 죽음의 다이아몬드 구조라고 한다. 이는 하나의 부모 클래스를 두 개의 자식 클래스가 상속받은 후 다시 하나의 자식 클래스가 상속 받는 것을 의미한다. 그래서 C#에서는 다중 상속을 막아 놨다(C++에선 가능하다).

 

그렇다면 다중 상속을 하기 위해선 어떤 방법이 있을까?

 

이런 단점을 보완하기 위해 인터페이스에 대해 알아보자

 

 

 

인터페이스(Interface)

인터페이스란 간단하게 말해 프로그래밍상 계약 혹은 약속으로 정의한다. 이는 추상 클래스의  '다중 상속'을 사용할 수 없다는 단점을 보완하기 위해 만들어진 개념이다. 결론적으로 클래스는 여러 클래스를 상속받지 못하지만 여러 개의 인터페이스를 상속 받을 수 있다.

 

인터페이스는 추상 클래스와 비슷한 구조를 가지고 있으며 공통점이 많다. 인터페이스 멤버는 모두 추상화만 가능하며(멤버 선언만 가능), 추상 클래스와 마찬가지로 인터페이스 자체로 인스턴스(객체)를 생성할 수 없다.

 

나머지 특징들은 예시 코드를 보며 알아보자

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class InterfaceEx : MonoBehaviour
{

    // 인터페이스
    // interface로 생성하고 인터페이스 이름에는 맨 앞에 I를 붙인다.(규칙)
    // 이벤트, 인덱서, 프로퍼티, 메소드 (자주 사용하는 것은 프로퍼티와 메소드)
    // 추상 클래스 단점 보완 (다중 상속 가능)
    // 인스턴스(객체) 생성 불가능
    public interface IShout
    {
        public void Shout(); // 인터페이스 메소드는 상속을 받는다면 구현해줘야 한다(강제성)
    }

    public interface IMove
    {
        public void Move();
    }

    public class Lion : IShout
    {
        public void Shout() // Shout() 재정의
        {
            // Lion의 Shout() 구현
        }
    }

    public class Tiger : IShout, IMove // 다중 상속 가능
    {
        public void Shout()
        {
            // Tiger의 Shout() 구현
        }

        public void Move()
        {
            // Tiger의 Move() 구현
        }
    }
    
}

위 코드를 통해 인터페이스의 특징을 하나씩 알아보자.

  • 인터페이스를 생성할 때는 맨 앞에 I를 붙인다.(개발자들의 약속)
  • 인터페이스가 가질 수 있는 멤버는 이벤트, 인덱서 ,프로퍼티, 메소드이다. 보통 프로퍼티와 메소드를 많이 사용한다. 변수는 사용 못하며, 변수를 사용하고 싶을 경우 프로퍼티를 활용해 변수처럼 사용한다.
  • 인터페이스 안에 멤버들은 구현없이 선언만 가능하다.
  • 인터페이스는 여러 개를 상속받을 수 있다. 즉 다중 상속이 가능하다.
  • 인터페이스를 상속받는다면 인터페이스 안에 있는 메소드는 꼭 구현을 해줘야 한다. (강제성)

Tiger를 보면 알 수 있듯이 IShout와 IMove 인터페이스 두 개를 상속받아 구현했다. 

그러면 추상 클래스와 인터페이스는 어떻게 활용할까? 

 

예를 들어 이동 메소드, 공격 메소드, 데미지를 받는 메소드를 가지고 있는 추상 클래스 Monster를 제작하고 Player를 이용해 모든 몬스터에게 데미지를 주는 코드를 작성해보자.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class InterfacePracrice : MonoBehaviour
{
    public abstract class Monster
    {
        public abstract void Attack();

        public abstract void Move();

        public abstract void OnDamage();

    }

    public class Orc : Monster
    {
        public override void Attack()
        {
            // 공격 기능 
        }

        public override void Move()
        {
            // 이동 기능
        }

        public override void OnDamage()
        {
            Debug.Log("Orc : OnDamage");
        }
    }

    public class Goblin : Monster
    {
        public override void Attack()
        {
            
        }

        public override void Move()
        {
            
        }

        public override void OnDamage()
        {
            Debug.Log("Goblin : OnDamage");
        }
    }

    public class Player
    {
        public void Attack(Monster target)
        {
            target.OnDamage();
        }
    }

    public void Start()
    {
        Player player = new Player();
        Monster orc = new Orc();
        Monster goblin = new Goblin();
        
        List<Monster> monsters = new List<Monster>();
        
        monsters.Add(orc);
        monsters.Add(goblin);
        
        foreach (var monster in monsters)
        {
            player.Attack(monster);
        }
    }
}

실행 결과

 

추상 클래스 Monster 생성 후 Player에서 Attack 메소드 매개변수로 Monster 타입을 받아서, Monster 타입이라면 데미지를 입히는 코드를 작성했다.

 

그렇다면 추후에 나는 동물들에게도 공격 할 수 있는 기능을 만들고 싶어져서 제작한다면 어떻게 할까?

 

동물들은 이동 메소드와 데미지를 받는 메소드만 필요하다고 가정하자 그럼 이동 기능과 데미지를 받는 기능이 있는 Monster 클래스를 상속 받아 사용하면 되지 않을까?

 

Monster 클래스를 상속 받는다면 필요하지 않은 기능인 공격 기능도 강제성으로 구현을 해줘야 한다. 그렇다면 새로운 추상 클래스 Animal을 이용해서 이동 기능과 데미지를 받는 기능이 있는 사자와 호랑이를 구현하고 Player로 공격 하는 코드를 만들어보자.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class InterfacePracrice : MonoBehaviour
{

    public abstract class Monster
    {
        public abstract void Attack();
        public abstract void Move();
        public abstract void OnDamage();
    }

    public class Orc : Monster
    {
        public override void Attack() { }

        public override void Move() { }

        public override void OnDamage()
        {
            Debug.Log("Orc : OnDamage");
        }
    }

    public class Goblin : Monster
    {
        public override void Attack() { }

        public override void Move() { }

        public override void OnDamage()
        {
            Debug.Log("Goblin : OnDamage");
        }
    }
    
    public abstract class Animal
    {
        public abstract void Move();

        public abstract void OnDamage();
    }

    public class Tiger : Animal
    {
        public override void Move() { }

        public override void OnDamage()
        {
            Debug.Log("Tiger : OnDamage");
        }
    }
    
    public class Lion : Animal
    {
        public override void Move() { }

        public override void OnDamage()
        {
            Debug.Log("Lion : OnDamage");
        }
    }

    public class Player
    {
        public void Attack(Monster target)
        {
            target.OnDamage();
        }

        public void Attack(Animal target)
        {
            target.OnDamage();
        }
    }

    public void Start()
    {
        Player player = new Player();
        Monster orc = new Orc();
        Monster goblin = new Goblin();
        Animal tiger = new Tiger();
        Animal Lion = new Lion();
        
        List<Monster> monsters = new List<Monster>();
        List<Animal> animals = new List<Animal>();
        
        monsters.Add(orc);
        monsters.Add(goblin);
        animals.Add(tiger);
        animals.Add(Lion);
        
        foreach (var monster in monsters)
        {
            player.Attack(monster);
        }
        
        foreach (var animal in animals)
        {
           player.Attack(animal);
        }
    }
}

실행 결과

 

코드가 너무 길어지는 느낌이다. Player 클래스에서 공격 기능은 똑같지만 Animal 타입 매개변수로 받는 Attack 메소드를 하나 더 작성하고, Animal 타입의 List를 만들고, foreach를 한번 더 작성해야 한다.  한 눈에 봐도 효율적이지 못하다는 것을 알 수 있다. 

 

 

그러면 이 코드를 인터페이스를 이용해 리펙토링 해보자

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class InterfacePracrice : MonoBehaviour
{
    public interface IOnDamage
    {
        public void OnDamage();
    }

    public interface IMove
    {
        public void Move();
    }

    public abstract class Monster
    {
        public abstract void Attack();
    }

    public class Orc : Monster, IMove, IOnDamage
    {
        public override void Attack() { }
        
        // IMove로 상속 받은 Move 메소드
        public void Move() { }

        // IOnDamage로 상속 받은 OnDamage 메소드
        public void OnDamage()
        {
            Debug.Log("Orc : OnDamage");
        }
    }

    public class Goblin : Monster, IMove, IOnDamage
    {
        public override void Attack() { }
        
        public void Move() { }

        public void OnDamage()
        {
            Debug.Log("Goblin : OnDamage");
        }
    }

    public class Tiger : IMove, IOnDamage
    {
        public void Move() { }

        public void OnDamage()
        {
            Debug.Log("Tiger : OnDamage");
        }
    }
    
    public class Lion : IMove, IOnDamage
    {
        public void Move() { }

        public void OnDamage()
        {
            Debug.Log("Lion : OnDamage");
        }
    }
 

    public class Player
    {
        public void Attack(IOnDamage target)
        {
            target.OnDamage();
        }
    }

    public void Start()
    {
        Player player = new Player();
        Orc orc = new Orc();
        Goblin goblin = new Goblin();
        Tiger tiger = new Tiger();
        Lion Lion = new Lion();
        
        List<IOnDamage> targets = new List<IOnDamage>();
        
        targets.Add(orc);
        targets.Add(goblin);
        targets.Add(tiger);
        targets.Add(Lion);
        
        foreach (var target in targets)
        {
            player.Attack(target);
        }

    }
}

실행 결과

 

public interface IOnDamage
{
    public void OnDamage();
}

public interface IMove
{
    public void Move();
}

public abstract class Monster
{
    public abstract void Attack();
}

코드를 본다면 Monster 클래스에서 더 이상 필요하지 않은 기능 Move와 OnDamage를 삭제하고 orc, goblin, tiger, lion이 모두 겹치는 기능인 Move, OnDamage를 인터페이스로 구현하고 다중 상속을 통해 클래스를 작성하였다.

 

 

 

public class Player
{
    public void Attack(IOnDamage target)
    {
        target.OnDamage();
    }
}

또한 Player에 Attack 메소드 하나로 줄였다. IOnDamage 타입 매개변수로 받아 IOnDamage를 상속 받는 모든 클래스는 player가 공격할 수 있게 작성했다. 

 

 

List<IOnDamage> targets = new List<IOnDamage>();

targets.Add(orc);
targets.Add(goblin);
targets.Add(tiger);
targets.Add(Lion);

foreach (var target in targets)
{
    player.Attack(target);
}

IOnDamage 타입의 List를 생성해서 IOnDamage 인터페이스를 상속 받는 모든 객체를 한 번에 관리 할 수 있게 작성해서 foreach문도 하나로 줄였다.

 

 

만약 나중에 데미지를 받는 나무나 주변 소품들을 구현한다면 IOnDamage 인터페이스만 상속받아서 똑같이 사용할 수 있다. 그리고 새롭게 기능을 추가하고 싶다면 새로운 인터페이스를 구현해 활용하면 되겠다.

 

이런 식으로 추상 클래스와 인터페이스를 이용한다면 코드를 훨씬 효율적으로 관리할 수 있고, 자식 클래스에서 재정의를 하기 때문에 자식 클래스만의 기능 구현이 가능하다(확장성이 좋다).

 

 

마지막으로 상속을 받을 때 인터페이스는 항상 마지막으로 작성해야 한다.

잘못 된 예

 

아직까지 완벽하게 사용할 순 없겠지만 다른 프로그래머들의 코드를 본다면 정말 많이 사용하는 코드로 알고 있다. 응용을 해보면서 익혀보자. 


(혹시 포스팅을 보시고 잘못된 점이나 추가해야 할 사항이 있다면 댓글로 말씀해주시면 정말 감사하겠습니다!)