인터페이스

이정현
∙∙조회 4

20261014 인터페이스

개요

상속은 "무엇인가"를 물려주는 문법임. Dog는 Animal의 멤버를 가짐.

그런데 프로그램을 만들다 보면 서로 아무 관계도 없는 클래스들이 같은 일을 할 수 있어야 하는 경우가 나옴. 플레이어도 저장할 수 있고, 설정 화면도 저장할 수 있고, 세이브 슬롯 목록도 저장할 수 있음. 이 셋은 공통 부모를 둘 만한 사이가 아님.

인터페이스는 이럴 때 쓰는 문법임. “이 멤버들을 가지고 있어야 한다”는 목록만 정해 두고, 실제 내용은 각 클래스가 알아서 채움. 물려주는 것이 아니라 약속을 강제하는 것임.

Mermaid
렌더링 중...

Player와 Settings는 서로 남남이지만, 둘다 ISavable을 구현했으므로 저장하는 코드 하나로 양쪽을 다룰 수 있음.

인터페이스가 필요한 자리

상속으로는 묶이지 않는 공통 능력

개임에 저장기능을 넣는다고 하자. 저장해야 할 것이 셋 있음.

csharp
// 안좋은 예시
Player player = new Player("용사", 42);
Settings settings = new Settings(0.8, true);
Inventory inventory = new Inventory(17);

player.Save();
settings.Save();
inventory.Save();

class Player
{
    public string Name;
    public int Level;

    public Player(string name, int level)
    {
        Name = name;
        Level = level;
    }

    public void Save()
    {
        Console.WriteLine($"플레이어 저장 — {Name} Lv.{Level}");
    }
}

class Settings
{
    public double Volume;
    public bool FullScreen;

    public Settings(double volume, bool fullScreen)
    {
        Volume = volume;
        FullScreen = fullScreen;
    }

    public void Save()
    {
        Console.WriteLine($"설정 저장 — 음량 {Volume}, 전체화면 {FullScreen}");
    }
}

class Inventory
{
    public int ItemCount;

    public Inventory(int itemCount)
    {
        ItemCount = itemCount;
    }

    public void Save()
    {
        Console.WriteLine($"인벤토리 저장 — 아이템 {ItemCount}개");
    }
}

셋 다 Save()를 가지고 있지만 공통 타입에 Save계약이 없음. object[]에는 함께 담을 수 있어도 요소에 곧바로 Save()를 호출할 수는 없음. 공통 계약 없이 각각 호출하면 클래스가 늘어날 때마다 호출 코드도 늘어남.

억지로 부모를 만들면

상속으로 해결하려면 공통 부모를 두어야 함. 이 경우 코드는 동작하지만 두 가지 문제가 생김.

문제내용
의미가 어긋남Player는 SavableThing이다 라고 말하는 것인데, 플레이어는 "저장 가능한 것"의 한 종류가 아님
부모 자리가 하나 뿐임클래스는 부모를 하나만 가질 수 있음.Player가 이미 Character를 물려받고 있으면 SavableThing을 추가로 물려받을 수 없음

계약이라는 관점

인터페이스는 관계를 만들지 않고 능력만 선언함. 상속이 "is-a(이다)"라면 인터페이스는 "can-do(할 수 있다)"임.

구분클래스 상속인터페이스 구현
관계is-a-Dog는 Animal이다can-do-Player는 저장할 수 있다.
개수부모 하나만여러 개
내용구현을 물려받음목록만 받고 직접 채움
목적코드 재사용약속의 통일

인터페이스 선언

interface키워드

class자리에 interface를 쓰고, 멤버는 본문 없이 세미콜론으로 끝냄. 관례적으로 인터페이스의 이름은 맨 앞에 대문자I를 붙인다. .NET에서 제공하는 인터페이스도 모두 이 규칙을 따르므로, 이름만 보고 인터페이스라고 알 수 있음.

csharp
interface ISavable
{
    void Save();
}

메서드 이름과 매개변수, 반환 타입만 적혀있는 이 부분을 시그니처 라고 부름. 무엇을 어떻게 할지는 적지 않음.

담을 수 있는 멤버

메서드뿐 아니라 프로퍼티와 인덱서도 시그니처만 적어 넣을 수 있음. 프로퍼티는 { get; set; }처럼 접근자 목록만 적음. 자동 구현 프로퍼티와 표기가 같아 보이지만 인터페이스에서는 "getter와 setter를 만들어라"는 요구 사항임. { get; }만 적었으면 구현 쪽에서는 최소한 getter를 두어야 함. setter를 추가로 두는 것은 막지 않음.

담을 수 없는 것

인터페이스는 상태를 가지지 않음. 인스턴스 필드를 선언하려 하면 컴파일되지 않음. 생성자도 둘수 없음. 그러므로 인터페이스 자체를 new로 만들 수도 없음. 추상 클래스와 같은 이유임 - 구현이 없으니 실체가 될 수 없음. 멤버에 접근 모디하이어를 적지 않으면 public임. 그래서 구현 클래스가 public으로 만들지 않으면 계약을 못 지킨 것이 됨.

항목인터페이스에서
인스턴스 필드둘 수 없음(CS0525)-상태는 구현 클래스의 몫임
생성지둘 수 없음(CS0526)
new로 만들기안 됨(CS0144)
접근 모디파이어생략하면 public
메서드 본문적지 않고 시그니처만 둠

인터페이스 구현

클래스가 계약을 이행함

부모를 적을 때와 똑같이 콜론 뒤에 인터페이스 이름을 적음. 그리고 목록에 있는 멤버를 전부 만들어야 함.

csharp
// 기존에 여러줄 늘어놓아야 했던 것과 다르게 인터페이스로 foreach문 하나로 줄인 예시
ISavable[] targets = { new Player("용사", 42), new Settings(0.8, true) };

foreach (ISavable target in targets)
{
    target.Save();
}

interface ISavable
{
    void Save();
}

class Player : ISavable
{
    public string Name;
    public int Level;

    public Player(string name, int level)
    {
        Name = name;
        Level = level;
    }

    public void Save()
    {
        Console.WriteLine($"플레이어 저장 — {Name} Lv.{Level}");
    }
}

class Settings : ISavable
{
    public double Volume;
    public bool FullScreen;

    public Settings(double volume, bool fullScreen)
    {
        Volume = volume;
        FullScreen = fullScreen;
    }

    public void Save()
    {
        Console.WriteLine($"설정 저장 — 음량 {Volume}, 전체화면 {FullScreen}");
    }
}

public을 반드시 붙임

인터페이스 멤버는 public인데, 구현하는 쪽에서 public을 빼면 클래스 멤버의 기본 접근 수준인 private이 되어 계약을 못 지킨 것이 됨.

빠뜨리면 컴파일 오류

목록 중 하나만 빠져도 오류임. 이것이 인터페이스의 핵심 효용임. - 약속을 잊으면 실행 전에 알려 줌. 프로퍼티도 같음. { get; set; }으로 요구한 것을 읽기 전용으로만 만들면 오류임.

시그니처가 정확히 맞아야 함

이름이 같아도 매개변수 타입이 다르면 아예 다른 메서드로 취급됨. 그래서 "구헌하지 않았다"는 오류가 발생함. 이름과 매개변수는 같은데 반환 타입만 다르면 다른 오류가 남. 이때는 컴파일러가 그 메서드를 후보로 보고 반환 타입이 안 맞는다고 짚어 줌.

인터페이스 타입 변수

구현 객체를 담는 인터페이스 변수

인터페이스 이름을 변수 타입으로 쓸 수 있음. 그 인터페이스를 구현한 객체는 무엇이든 담김. 인터페이스로도 다형성이 성립함. 단 인터페이스 타입 변수로는 그 인터페이스에 적힌 멤버만 보임. 객체는 그 메서드를 가지고 있지만 쓸 수 있는 멤버의 범위는 해당 변수의 타입이 정함.

인터페이스 배열로 묶기

관계없는 클래스들을 한 배열에 담아 같은 방식으로 다루는 것이 인터페이스의 가장 흔한 용도임. 인터페이스로 묶이면 관련 클래스가 늘어도 인터페이스의 배열에 추가하면 foreach 문의 안쪽에서 한꺼번에 실행하는 부분은 수정할 필요 없어짐.

구현 여부 확인하기

어떤 객체가 특정 인터페이스를 구현했는지는 is로 확인함. 클래스 타입을 확인할 때와 표기가 같음. as도 그대로 쓸 수 있음. 구현하지 않은 객체를 넣으면 null을 반환함.

여러 인터페이스 구현

능력을 조합함

부모는 하나뿐이지만 인터페이스는 개수 제한이 없음. 쉼표로 나열함.

csharp
// 다중 인터페이스 예시
Hero hero = new Hero();
hero.Move(3);
hero.Attack();

Turret turret = new Turret();
turret.Attack();

Crate crate = new Crate();
crate.Move(1);

interface IMovable
{
    void Move(int distance);
}

interface IAttacker
{
    void Attack();
}

// 이동도 공격도 함
class Hero : IMovable, IAttacker
{
    public void Move(int distance)
    {
        Console.WriteLine($"영웅이 {distance}칸 이동");
    }

    public void Attack()
    {
        Console.WriteLine("영웅이 검을 휘두름");
    }
}

// 공격만 함
class Turret : IAttacker
{
    public void Attack()
    {
        Console.WriteLine("포탑이 발사");
    }
}

// 이동만 함
class Crate : IMovable
{
    public void Move(int distance)
    {
        Console.WriteLine($"상자가 {distance}칸 밀림");
    }
}

능력을 잘게 나눠 두면 클래스마다 필요한 것만 골라 조립할 수 있음. 상속으로 이렇게 하려면 "이동만 하는 것", "공격만 하는 것", "둘 다 하는 것"에 각각 부모가 필요해짐. 위와 같이 작성한다면 Hero는 두 인터페이스 타입 어느 쪽으로도 담김. 같은 객체라도 어떤 타입의 변수로 보느냐에 따라 쓸 수 있는 멤버가 달라짐.

클래스 생속과 함께 쓰기

부모 클래스와 인터페이스를 동시에 적을 수 있음. 이때 부모 클래스를 먼저 쓰는 것이 규칙임.

csharp
// 상속과 인터페이스를 함께 쓰는 예시
class Animal
{
    public string Name;

    public Animal(string name)
    {
        Name = name;
    }

    public void Breathe()
    {
        Console.WriteLine($"{Name}이(가) 숨을 쉼");
    }
}

interface IFlyable
{
    void Fly();
}

// 부모 클래스가 먼저, 인터페이스가 뒤
class Bird : Animal, IFlyable
{
    public Bird(string name) : base(name) { }

    public void Fly()
    {
        Console.WriteLine($"{Name}이(가) 날아감");
    }
}

이 순서를 뒤집으면 컴파일 되지 않음.

이름이 겹칠 때 - 명시적 구현

인터페이스 둘이 같은 이름의 멤버를 요구하면 메서드 하나로 양쪽을 동시에 만족시킴.

csharp
// 메서드 하나로 두개의 인터페이스를 모두 만족시키는 예시
interface IEngine
{
    void Run();
}

interface IProgram
{
    void Run();
}

class Robot : IEngine, IProgram
{
    // 하나의 Run() 이 두 인터페이스를 모두 만족시킴
    public void Run()
    {
        Console.WriteLine("작동");
    }
}

동작이 서로 달라야 할 때는 명시적 구현을 씀. 메서드 이름 앞에 인터페이스 이름을 붙임.

csharp
// 이름이 같지만 동작이 서로 다른 인터페이스를 구현하는 예시
Robot robot = new Robot();

IEngine engine = robot;
engine.Run();

IProgram program = robot;
program.Run();

// 캐스팅해서 바로 부르는 것도 됨
((IEngine)robot).Run();

interface IEngine
{
    void Run();
}

interface IProgram
{
    void Run();
}

class Robot : IEngine, IProgram
{
    // 인터페이스 이름을 앞에 붙임 — public 을 쓰지 않음
    void IEngine.Run()
    {
        Console.WriteLine("엔진이 돌아감");
    }

    void IProgram.Run()
    {
        Console.WriteLine("프로그램이 실행됨");
    }
}

명시적으로 구현한 멤버에는 특징이 셋 있음.

특징내용
public을 붙이지 않음붙이면 error CS0106:이 항목의 public 한 정자가 유효하지 않습니다.가 남
클래스 타입으로는 안 보임robot.Run()이 컴파일 되지 않음
인터페이스를 거쳐야 부름인터페이스 타입 변수에 담거나 캐스팅 함

인터페이스 상속

인터페이스도 다른 인터페이스를 물려받을 수 있음. 표기는 클래스 상속과 같음. 단 상속 받은 인터페이스를 구현하려고 적으면 부모 인터페이스의 멤버까지 함께 요구됨. 상속받은 목록이 합쳐지기 때문임. 부모 인터페이스까지 구현한 객체는 부모 인터페이스 타입 변수에도 담김. 인터페이스도 여러 개를 동시에 물려받을 수 있음. 클래스와 달리 개수 제한이 없음.

매개변수 타입으로 쓰기

어떤 구현체든 받는 함수

매개변수 타입을 인터페이스로 두면, 그 인터페이스를 구현한 것은 무엇이든 받음.

csharp
//하나의 인터페이스를 받는 함수로 해당 인터페이스를 구현한 여러개의 클래스를 입력받는 예시
Report(new ConsoleLogger(), "게임 시작");
Report(new PrefixLogger("[경고] "), "체력 부족");
Report(new CountingLogger(), "저장");

void Report(ILogger logger, string message)
{
    logger.Write(message);
}

interface ILogger
{
    void Write(string message);
}

class ConsoleLogger : ILogger
{
    public void Write(string message)
    {
        Console.WriteLine(message);
    }
}

class PrefixLogger : ILogger
{
    private string prefix;

    public PrefixLogger(string prefix)
    {
        this.prefix = prefix;
    }

    public void Write(string message)
    {
        Console.WriteLine(prefix + message);
    }
}

class CountingLogger : ILogger
{
    private int count;

    public void Write(string message)
    {
        count++;
        Console.WriteLine($"{count}. {message}");
    }
}

위 예시의 Report()는 로거가 몇 종류인지는 모르지만 ILogger라는 약속만 믿고 부름. 새로운 로거를 만들어도 Report()는 고칠 필요가 없음.

생성자로 부품을 갈아 끼우기

생성자 매개변수를 인터페이스로 두면, 클래스가 쓸 부품을 밖에서 결정할 수 있음.

csharp
// 생성자 매개변수를 인터페이스로 두고 생성할 때 결정하는 예시
Car sportsCar = new Car("스포츠카", new TurboEngine());
sportsCar.Start();

Car cityCar = new Car("경차", new EcoEngine());
cityCar.Start();

interface IEngine
{
    string Name { get; }
    int GetPower();
}

class TurboEngine : IEngine
{
    public string Name => "터보 엔진";

    public int GetPower()
    {
        return 350;
    }
}

class EcoEngine : IEngine
{
    public string Name => "절전 엔진";

    public int GetPower()
    {
        return 90;
    }
}

class Car
{
    private string model;
    private IEngine engine;

    // 어떤 엔진을 쓸지 밖에서 넣어 줌
    public Car(string model, IEngine engine)
    {
        this.model = model;
        this.engine = engine;
    }

    public void Start()
    {
        Console.WriteLine($"{model} — {engine.Name} {engine.GetPower()}마력으로 출발");
    }
}

  • 참고: 이렇게 쓸 부품을 밖에서 넣어주는 방식을 의존성 주입(Dependency Injection)이라고 부름. 클래스가 구체적인 타입 이름을 몰라도 되므로, 테스트 할 때 가짜 부품으로 바꿔 끼우기도 쉬움.

인터페이스와 추상 클래스

추상 클래스도 자식에게 구현을 강제할 수 있음. abstract메서드는 본문이 없고 자식이 반드시 채워야 함. 그래서 둘 중 어느 쪽을 써야 할지 고민이 생김.

무엇이 다른가

추상 클래스는 구현을 일부(필드, 메서드 구현 가능) 담을 수 있지만 인터페이스는 목록만 있음.

항목인터페이스추상 클래스
구현아래 비교에서는 선언만 둠일반 메서드로 담을 수 있음
인스턴스 필드가질 수 없음가질 수 있음
인스턴스 생성자없음있음(자식이 base(...)로 부름)
접근 모디파이어여기서 선언한 계약은publicprivate, protected등 사용 가능
몇 개까지여러개 구현하나만 상속
나타내는 것can-do - 할 수 있다is-a - 이다

어느쪽을 고를지

상황선택
관계없는 클래스들이 같은 능력을 가져야 함인터페이스
여러 능력을 조합해야 함인터페이스
공통 데이터(필드)를 물려주고 싶음추상 클래스
공통 구현을 물려주면서 일부만 강제하고 싶음추상 클래스
자식들이 실제로 같은 종류임추상 클래스

판단이 헷갈릴 때 기준 하나는 이것임. "~이다"로 읽어서 지연스러운가. 예를들어 Sword는 Weapon이다-자연스러움. Player는 Savable이다-어색함. 어색하면 인터페이스임.

둘을 함께 쓰기

베타적인 선택이 아님. 공통 데이터 구현은 추상 클래스가, 능력 표시는 인터페이스가 맡으면 됨.

csharp
// 추상클래스와 인터페이스를 함께 사용하는 예시
// 능력별 인터페이스
interface IAttackable
{
    void Attack();
}

interface IReloadable
{
    void Reload();
}

// 공통 데이터와 구현은 추상 클래스가 맡음
abstract class Weapon
{
    protected int durability = 100;

    public abstract void Attack();

    public void ShowDurability()
    {
        Console.WriteLine($"  내구도 {durability}");
    }
}

// 추상 클래스 상속 + 인터페이스 구현
class Sword : Weapon, IAttackable
{
    public override void Attack()
    {
        durability -= 5;
        Console.WriteLine("검으로 베기");
    }
}

class Bow : Weapon, IAttackable, IReloadable
{
    private int arrows = 3;

    public override void Attack()
    {
        arrows--;
        durability -= 2;
        Console.WriteLine($"화살 발사 (남은 화살 {arrows})");
    }

    public void Reload()
    {
        arrows = 10;
        Console.WriteLine("화살을 채움");
    }
}

Weapon이 내구도라는 상태를 관리하고, IReloadable은 재장전이라는 능력을 표시함. 검은 재장전 할 수 없으므로 그 인터페이스를 구현하지 않음. 상속만으로는 이 구분을 만들 수 없음.

추상 메서드로 인터페이스를 받기

인터페이스 멤버를 추상 클래스가 abstract로 남겨두고, 실제 구현은 자식에게 넘길 수도 있음.

csharp
// 추상클래스에 인터페이스를 선언하고 자식이 될 클래스에 구현을 맡기는 예시
interface IShape
{
    double GetArea();
    void Describe();
}

abstract class ShapeBase : IShape
{
    // 인터페이스 멤버를 추상으로 남김 — 자식이 채움
    public abstract double GetArea();

    // 인터페이스 멤버를 여기서 구현 — 모든 자식이 공유함
    public void Describe()
    {
        Console.WriteLine($"{GetType().Name} 면적 {GetArea():F2}");
    }
}

class Square : ShapeBase
{
    private double side;

    public Square(double side)
    {
        this.side = side;
    }

    public override double GetArea()
    {
        return side * side;
    }
}

class Circle : ShapeBase
{
    private double radius;

    public Circle(double radius)
    {
        this.radius = radius;
    }

    public override double GetArea()
    {
        return Math.PI * radius * radius;
    }
}

ShapeBase가 IShape를 구현한다고 선언했으므로 자식들도 자동으로 IShape임. Describe()는 한번만 적어 모든 자식이 나눠 씀.