CodeGym /행동 /C# SELF /인터페이스와 추상 클래스의 차이점

인터페이스와 추상 클래스의 차이점

C# SELF
레벨 23 , 레슨 1
사용 가능

1. 동전의 양면

우리 이제 추상화라는 개념에 꽤 깊게 들어갔고, C#에서 추상 클래스인터페이스라는 두 개의 강력한 도구를 봤지. 둘 다 "행동의 뼈대"를 정의할 수 있다는 점에서 비슷하다고 느꼈을 거야. 근데 이건 망치랑 드라이버를 비교하는 거랑 비슷해. 둘 다 도구지만, 용도가 다르지.

가장 중요한 것부터 시작하자: 왜 같은 일을 하는 두 개의 도구가 필요할까? 프로그래밍에서도, 인생에서도, "그냥" 있는 건 거의 없어. 비슷한 도구가 두 개 있다면, 각각의 강점과 쓰임새가 따로 있다는 뜻이야.

바로 비교표로 정리해볼게. 이건 일종의 치트시트라서, 제일 중요한 포인트를 한눈에 볼 수 있어.

특징 추상 클래스 인터페이스
인스턴스 생성 직접 생성 불가 (
new AbstractClass()
).
직접 생성 불가 (
new IMyInterface()
).
멤버 구현 다음이 가능:
– 완전히 구현된 메서드/프로퍼티.
– 구현 없는 추상 메서드/프로퍼티.
– 필드, 생성자, static 메서드.
기본 구현이 있는 메서드, static abstract/비추상 static 멤버 가능.
인스턴스 필드, 생성자 불가.
접근 제한자 아무거나 가능 (public, protected, internal, private). 인터페이스 멤버는 기본적으로 public. 접근 제한자 안 씀 (예외: 기본 구현 메서드, static 멤버).
상속 클래스는 오직 하나의 추상 클래스만 상속 가능. 클래스는 여러 개의 인터페이스 구현 가능.
관계 타입 "~이다" (is-a). 기본 타입과 계층의 공통 부분 정의. "~할 수 있다" (has-a 또는 can-do). 행동/능력의 계약 정의.
상태 상태(인스턴스 필드) 저장 가능. static 필드는 가능하지만, 인스턴스 필드는 불가.
확장 상속자 안 건드리고 새 구현 메서드 추가 가능. 기본 구현 메서드 추가해도 상속자 안 깨짐.

어때, 좀 감이 오지? 이제 각 항목을 좀 더 자세히 볼게.

2. 다중 상속

이게 아마 제일 기본적이고 기억하기 쉬운 차이야. C#에서는 (Java도 마찬가지) 클래스는 오직 하나의 부모 클래스만 상속할 수 있어. 일반 클래스든 추상 클래스든, 딱 하나! 이건 "다이아몬드 문제"라고 불리는 유명한 문제를 피하려고 그런 거야. 여러 클래스를 상속하면, 같은 이름의 메서드가 있을 때 뭘 써야 할지 애매해지거든.

근데 인터페이스는 몇 개든 구현 가능! 네 클래스가 사람이라고 생각해봐. "학생"일 수도 있고(Student 상속), "요리할 수 있고"(ICookable), "운전할 수 있고"(IDriveable), "노래할 수 있다"(ISingable) 등등. 엄청 유연하지!


abstract class Animal
{
    public string Name;
    public abstract void MakeSound();
    public void Eat() => Console.WriteLine($"{Name} 먹는다.");
}

interface IFlyable { void Fly(); double MaxFlyingAltitude { get; } }
interface ISwimable { void Swim(); }

class Duck : Animal, IFlyable, ISwimable
{
    public double MaxFlyingAltitude => 1000;
    public override void MakeSound() => Console.WriteLine($"{Name} 꽥꽥!");
    public void Fly() => Console.WriteLine($"{Name} 날아간다!");
    public void Swim() => Console.WriteLine($"{Name} 수영한다!");
}

class Program
{
    static void Main()
    {
        var duck = new Duck { Name = "도날드" };
        duck.MakeSound();
        duck.Eat();
        duck.Fly();
        duck.Swim();

        // 다양한 타입으로 객체 사용:
        Animal a = duck;  a.Eat();
        IFlyable f = duck; f.Fly();
        ISwimable s = duck; s.Swim();
    }
}

클래스가 어떤 계층의 일원이어야 한다면 (예: Dog is Animal), 클래스 상속(추상/일반)을 써. 만약 클래스가 어떤 능력을 가져야 한다면 (예: Dog 뛸 수 있다, Cat 뛸 수 있다), 그리고 이 능력이 계층에 묶여 있지 않다면, 인터페이스를 써. 이게 바로 인터페이스의 큰 장점이야 – 완전 다른 클래스들끼리도 행동 계약을 만들 수 있거든.

3. 데이터는 어디에, 약속은 어디에?

추상 클래스는 뭐든 다 담을 수 있어:

  • 일반(비추상) 메서드/프로퍼티(완전 구현).
  • 구현 없는 추상 메서드/프로퍼티(이걸 오버라이드함).
  • 필드(인스턴스 변수) – 객체 상태 저장.
  • 생성자 – 상태 초기화용.
  • static 메서드/프로퍼티도 가능.
  • 그리고 접근 제한자도 public, protected, private 등 다 가능.

이렇게 추상 클래스는 부분적으로 구현된 행동공통 상태를 모든 상속자에게 줄 수 있는 강력한 도구야.


abstract class Employee
{
    public string FirstName, LastName;
    public decimal Salary { get; protected set; }
    public Employee(string first, string last) { FirstName = first; LastName = last; }
    public void GetPaid(decimal sum)
    {
        Salary += sum;
        Console.WriteLine($"{FirstName} {LastName} {sum:C} 받음. 월급: {Salary:C}");
    }
    public abstract void PerformWork();
    public abstract void TakeBreak();
}

class Developer : Employee
{
    public Developer(string f, string l) : base(f, l) { }
    public override void PerformWork() => Console.WriteLine($"{FirstName} 코드 작성 중.");
    public override void TakeBreak() => Console.WriteLine($"{FirstName} 커피 마심.");
}

class Tester : Employee
{
    public Tester(string f, string l) : base(f, l) { }
    public override void PerformWork() => Console.WriteLine($"{FirstName} 버그 찾는 중.");
    public override void TakeBreak() => Console.WriteLine($"{FirstName} 축구함.");
}

class Program
{
    static void Main()
    {
        Employee[] team = {
            new Developer("이반", "페트로프"),
            new Tester("마리아", "시도로바")
        };

        foreach (var emp in team)
        {
            Console.WriteLine($"\n--- {emp.FirstName} {emp.LastName}의 근무일 ---");
            emp.PerformWork();
            emp.GetPaid(2000);
            emp.TakeBreak();
        }
    }
}

인터페이스는 완전 달라. C# 8 이전엔 선언만 가능했어. 메서드, 프로퍼티, 인덱서, 이벤트 선언만! 필드, 생성자, 구현 X! 그냥 "메서드 시그니처"만 있고, 몸통 없음. 그리고 모든 멤버는 public이야(public 안 써도). 이건 인터페이스가 "순수 계약"임을 보장해. 구현이나 상태는 없음.

C# 8부터는 기본 구현 메서드(Default Interface Methods)랑 static 멤버도 가능해졌어. 이건 이미 쓰고 있는 인터페이스에 새 메서드를 추가해도 기존 코드 안 깨지게 하려고 생긴 거야. 그래도 여전히 인스턴스 필드, 생성자 불가야. 이건 인터페이스가 "행동 계약"이지 "상태 저장소"가 아니라는 걸 지키기 위한 거지.


interface ISaveable
{
    void Save(string file); 
    bool IsDirty { get; }
}

interface ILoadable
{
    void Load(string file);
}

class GameProgress : ISaveable, ILoadable
{
    public int Level { get; set; }
    public string PlayerName { get; set; }
    bool _isDirty = true;
    public bool IsDirty => _isDirty;

    public GameProgress(string name, int level)
    {
        PlayerName = name; Level = level;
    }
    public void Save(string file)
    {
        Console.WriteLine($"저장: {PlayerName}, 레벨 {Level} -> {file}");
        _isDirty = false;
    }
    public void Load(string file)
    {
        Console.WriteLine($"{file}에서 불러옴");
        PlayerName = "새 플레이어"; Level = 5; _isDirty = true;
    }
    public void UpdateProgress(int newLevel)
    {
        Level = newLevel; _isDirty = true;
        Console.WriteLine($"{Level}로 업데이트됨.");
    }
}

class Program
{
    static void Main()
    {
        var game = new GameProgress("영웅", 1);
        game.UpdateProgress(3);

        ISaveable saver = game;
        if (saver.IsDirty) saver.Save("save.dat");

        ILoadable loader = game;
        loader.Load("save.dat");
        Console.WriteLine($"불러온 후: {game.PlayerName}, {game.Level}");
    }
}

정리: 만약 계약뿐 아니라 이미 구현된 코드(공통 로직)나 상태 저장이 필요하면 추상 클래스를 써. 그냥 "체크리스트"나 "행동 계약"만 필요하면 인터페이스가 딱이야.

4. 언제 인터페이스를 써야 할까?

인터페이스는 능력이나 행동을 완전 다른 객체들끼리 정의하고 싶을 때 딱이야. 예를 들면:

  • IDisposable: 사용 후 정리해야 하는 객체(파일, 네트워크, DB 등).
  • IEnumerable<T>: foreach로 순회 가능한 객체.
  • IComparable<T>: 같은 타입끼리 비교 가능한 객체.

중요한 건 FileStream이랑 SqlConnection 둘 다 IDisposable 구현할 수 있고, List<T>Dictionary<TKey, TValue>IEnumerable<T> 구현할 수 있다는 거야. 이 클래스들은 계층이 완전 다르지만, 공통 능력이 인터페이스로 정의돼.

예시: 자동차비행기가 있다고 해보자. 둘 다 운송수단일 수 있어(추상 클래스일 수도). 근데 자동차, 비행기, 보트까지(추가하면) – 움직일 수 있다. 이 "움직일 수 있다"는 능력은 IMovable 인터페이스로 딱이야.


interface IMovable
{
    void Move(int distance);
}

class Car : IMovable
{
    public string Brand;
    public Car(string brand) => Brand = brand;
    public void Move(int d) => Console.WriteLine($"{Brand} {d}km 달린다.");
}

class Airplane : IMovable
{
    public string Model;
    public Airplane(string model) => Model = model;
    public void Move(int d) => Console.WriteLine($"{Model} {d}km 비행한다.");
}

class Human : IMovable
{
    public string Name;
    public Human(string name) => Name = name;
    public void Move(int d) => Console.WriteLine($"{Name} {d}m 걷는다.");
}

class Program
{
    static void Main()
    {
        IMovable[] movers = { new Car("Toyota"), new Airplane("Boeing 747"), new Human("아르투르") };
        foreach (var item in movers)
            item.Move(100);
    }
}

봤지? IMovable 리스트를 만들고, Move()를 호출할 수 있어. 그게 Car든, Airplane이든, Human이든 상관없어. 이게 바로 인터페이스를 통한 다형성의 힘이야.

5. 언제 추상 클래스를 써야 할까?

추상 클래스는 서로 밀접하게 관련된 클래스 그룹공통 기능을 정의하고 싶을 때 딱이야. 모든 상속자에게 똑같이 필요한 코드를 주고, 각자 다르게 구현해야 하는 부분만 강제할 수 있지.

예를 들어, 여러 종류의 은행 계좌가 있다고 해보자: SavingAccount(저축), CheckingAccount(입출금), CreditAccount(신용). 이들은 모두 BankAccount 이다. 다 Balance가 있고, Deposit도 할 수 있어. 근데 Withdraw 규칙은 다르지. 이럴 때 추상 클래스 BankAccount가 딱이야!


abstract class BankAccount
{
    public string AccountNumber { get; }
    public decimal Balance { get; protected set; }
    public BankAccount(string acc) { AccountNumber = acc; }
    public void Deposit(decimal sum)
    {
        if (sum > 0)
        {
            Balance += sum;
            Console.WriteLine($"{AccountNumber}: +{sum:C}, 잔액: {Balance:C}");
        }
    }
    public abstract bool Withdraw(decimal sum);
}

class CheckingAccount : BankAccount
{
    public CheckingAccount(string acc) : base(acc) { }
    public override bool Withdraw(decimal sum)
    {
        if (Balance >= sum)
        {
            Balance -= sum;
            Console.WriteLine($"{AccountNumber}: -{sum:C}, 잔액: {Balance:C}");
            return true;
        }
        Console.WriteLine($"{AccountNumber}: 잔액 부족");
        return false;
    }
}

class CreditAccount : BankAccount
{
    public decimal CreditLimit { get; }
    public CreditAccount(string acc, decimal limit) : base(acc) => CreditLimit = limit;
    public override bool Withdraw(decimal sum)
    {
        if (Balance - sum >= -CreditLimit)
        {
            Balance -= sum;
            Console.WriteLine($"{AccountNumber}: -{sum:C}, 잔액: {Balance:C}");
            return true;
        }
        Console.WriteLine($"{AccountNumber}: 한도 초과");
        return false;
    }
}

class Program
{
    static void Main()
    {
        var checking = new CheckingAccount("12345");
        checking.Deposit(1000);
        checking.Withdraw(300);
        checking.Withdraw(800);

        Console.WriteLine("\n--- 신용 계좌 ---");
        var credit = new CreditAccount("67890", 500);
        credit.Deposit(200);
        credit.Withdraw(400);
        credit.Withdraw(400);
    }
}

여기서 BankAccount는 모든 계좌에 공통적인 입금(Deposit) 로직, 계좌번호, 잔액을 관리해. 근데 출금(Withdraw) 로직은 다르니까 추상으로 남겨둔 거지.

6. 같이 쓰면 더 좋은 조합

가장 세련되고 강력한 디자인은 추상 클래스와 인터페이스를 같이 쓰는 경우가 많아. 추상 클래스가 인터페이스 하나 이상을 직접 구현할 수도 있어!

예를 들어: abstract Animal이 기본적인 걸 정의해. 근데 어떤 AnimalIMovable, ICarnivore, IPredator 등등이 될 수 있지. AnimalIMovable의 기본 구현(Move(int speed) 같은 메서드)도 줄 수 있고, Lion이나 Fish 같은 구체 클래스가 그걸 자기 스타일대로 오버라이드할 수도 있어.


public interface ISaveable
{
    void SaveState(string path);
    bool HasChanges { get; }
}

public class Vector3
{
    public float X, Y, Z;
    public Vector3(float x, float y, float z) { X = x; Y = y; Z = z; }
    public override string ToString() => $"({X}, {Y}, {Z})";
}

public abstract class GameObject
{
    public string Id { get; }
    public Vector3 Position { get; }
    protected GameObject(string id, Vector3 pos) { Id = id; Position = pos; }
    public abstract void Update();
    public void Destroy() => Console.WriteLine($"{Id} 파괴됨");
}

public class Player : GameObject, ISaveable
{
    public int Health { get; private set; }
    private bool _hasChanges = true;
    public bool HasChanges => _hasChanges;

    public Player(string id, Vector3 pos, int health) : base(id, pos) => Health = health;

    public override void Update() =>
        Console.WriteLine($"{Id} 업데이트, HP: {Health}, 위치: {Position}");

    public void TakeDamage(int dmg)
    {
        Health -= dmg;
        _hasChanges = true;
        Console.WriteLine($"{Id} {dmg} 데미지 받음. HP: {Health}");
    }

    public void SaveState(string path)
    {
        Console.WriteLine($"저장 {Id} (HP:{Health}) -> {path}");
        _hasChanges = false;
    }
}

class GameEngine
{
    static void Main()
    {
        var player = new Player("P1", new Vector3(0, 0, 0), 100);
        player.Update();
        player.TakeDamage(20);

        ISaveable saver = player;
        if (saver.HasChanges) saver.SaveState("save.json");

        GameObject obj = player;
        obj.Update();
        player.Destroy();
    }
}

여기서 PlayerGameObject 이다(공통 필드 Id, Position, Destroy 메서드 사용). 동시에 Player저장 가능(ISaveable 구현)해. 이게 진짜 유연하고 강력한 방식이야!

7. 디테일과 흔한 실수

추상 클래스 인스턴스 생성 시도:

Animal myAnimal = new Animal();
– 이건 초보들이 자주 하는 실수야. 추상 클래스는 템플릿이지, 완성된 객체가 아니야. 컴파일러가 바로 에러 내줄 거야.

추상 메서드 구현 안 함: 추상 클래스를 상속받고, 네 클래스가 추상이 아니라면, 반드시 모든 추상 메서드를 override 해야 해. 안 그러면 컴파일러가 또 에러 내.

인터페이스에 필드 추가 시도: 이건 "절대 안 됨" 중 하나야. C# 8+에서 static 필드는 가능해졌지만, 인스턴스 필드(즉, 객체마다 다른 값)는 여전히 불가야. 인터페이스는 행동 계약이지, 데이터 저장소가 아니야.

virtualabstract 혼동:

  • virtual 메서드/프로퍼티는 기본 구현이 있고, 오버라이드 가능해.
  • abstract 메서드/프로퍼티는 구현 없음 + 첫 비추상 상속자에서 반드시 오버라이드해야 해.
  • 항상 다른 로직이 필요한데 virtual을 쓰면, 빈 구현을 써야 하고, 그걸 또 오버라이드해야 해서 계약이 애매해져. abstract가 더 명확해.

이 차이를 이해하고, 추상 클래스와 인터페이스 중 뭘 쓸지 잘 고르는 게 C# 개발자에겐 핵심 스킬이야. 이건 단순 문법 문제가 아니라, 설계 마인드의 문제야. 시스템 설계할 때 "이 클래스들이 공통 계층의 '이다' 관계인가?" "이 클래스들이 서로 다른데도 공통 '능력'이 필요한가?" 이런 질문을 해봐. 답이 뭔지에 따라 선택이 달라질 거야.

다음 강의에서는 최신 C# 버전에 추가된 더 고급 인터페이스 기능도 파볼 거야. 계속 함께 하자!

코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION