C#

C# 객체지향 문법 [C#의 클래스 확장 - 인터페이스]

devrabbit22 2025. 5. 27. 02:37

인터페이스(interface)는 간단하게 계약(contract)이라고 정의되며, 구현 없이 메서드 선언만 포함된 클래스 문법과 비슷한 면이 있다.

접근_제한자 interface 인터페이스_명
{
    //[메서드 선언];
}
// 설명: 인텊페이스에는 메서드 선언을 0개 이상 포함할 수 있다. 관례적으로 인터페이스의 이름에는
// I 접두사를 붙인다.

인터페이스를 '추상 메서드만 0개 이상 담고 있는 추상 클래스'라고 생각해도 무방하다.

다음의 두 가지 표현은 몇 가지 특징을 제외하고는 완전히 동일하다.

abstract class DrawingObject
{
    public abstract void Draw();
    public abstract voidi Move();
}

interface IDrawingObject;
{
    void Draw();
    void Move(int offSet);
}

추상 클래스가 구현할 수 있는 것을 왜 굳이 interface라는 새로운 예약어를 만들어 표현하는가?

  • 그 유일한 이유는 '클래스는 다중 상속이 불가능하다.'라는 특징으로 설명이 가능하다.
  • 추상 클래스는 말 그대로 클래스로 정의된 타입이라 다중 상속을 할 수 없지만, 인터페이스는 클래스가 아니기 때문에 다중 상속이 허용되어 다음과 같은 표현이 가능하다.
class Computer
{
}

interface IMonitor	//메서드 시그니처만을 포함하고 있는 인터페이스
{
    void TurnOn();
}

interface IKeyboard{}    //비어 있는 인터페이스 정의 가능

//클래스 상속과 함께 인터페이스로부터 다중 상속 가능
class Notebook : Computer, IMonitor, IKeyboard
{
    public void TurnOn(){}	//추상 메서드와는 달리 override 예약어가 필요 없다.
}
  • 인터페이스를 자식 클래스에서 구현할 때는 반드시 public 접근 제한자를 명시해야 한다. 
  • 인터페이스 명을 직접 붙이는 경우 접근제한자를 생략해도 된다.

주의할 점

  • public이 없다고 해서 private가 되는 건 아니다.
class Notebook : Computer, IMonitor, IKeyboard
{
    void IMonitor.TurnOn(){}
}

두 가지 메서드 구현 방식에는 호출하는 방법에 따른 차이점이 있다.

전자의 방식으로 구현하면 해당 클래스의 멤버로 정의되어 다음과 같이 호출할 수 있다.

notebook notebook = new Notebook();
notebook.TurnOn();
  • 접근 제한자를 생략하고 인터페이스명을 붙이는 후자의 경우에는 명시적으로 인터페이스의 멤버에 종속시킨다고 표시하는 것과 같다.
  • 따라서 Notebook의 멤버로서 호출하는 것이 불가능하고 반드시 인터페이스로 형 변환해야 호출할 수 있다.
Notebook notebook = new Notebook;
notebook.TurnOn();	//IMonitor.TurnOn 메서드는 Notebook 인스턴스로 호출 불가능
	                //따라서 이 코드는 컴파일 오류가 발생한다.

IMonitor mon = notebook as IMonitor;
mon.TurnOn();	//반드시 IMonitor 인터페이스로 형 변환해서 호출
  • 인터페이스가 '메서드의 묶음;이고 C# 프로퍼티가 내부적으로는 메서드로 구현되기 때문에 인터페이스에는 프로퍼티 역시 포함할 수 있다.
interface IMonitor
{
    void TurnOn();
    int inch{ get; set; }	//프로퍼티 get/set포함
    int width{ get; }	//get만 포함하는 것도 가능
}
class Notebook : IMonitor
{
    int Inch;
    public int inch
    {
        get { return inch; }
        set { Inch = value; }
    }
    int Width;
    public int width { get { return Width; } }
}

인터페이스의 유용성

상속으로서의 인터페이스

인터페이스의 가장 기본적인 역할은 상속이다.

 

해당 인터페이스를 구현한 것과 상속받았다는 것은 같은 의미를 가진다.

  • 클래스 상속은 아니기 때문에 구현 코드를 이어받은 것은 아니지만 적어도 메서드의 묶음에 대한 정의를 이어받은 것에 해당한다.
  • 따라서 서로 다른 클래스라도 인터페이스만 공통으로 구현되어 있다면 해당 구현 클래스의 인스턴스에 대해 인터페이스로 접근하는 것이 가능하다.

Line과 Rectangle 타입이 별도의 클래스 상속을 받지 않으므로 인터페이스가 없었다고 해도 abstract 타입으로 바꿀 수 있다.

이렇게 바뀌었어도 이들을 사용한 위의 예제코드는 전혀 영향을 받지 않고 잘 실행된다.

public abstract class IDrawingObject
{
    public abstract void Draw();
}
class Line : IDrawingObject
{
    public override void Draw() { Console.WriteLine("Line");
}
class Rectangle : IDrawingObject
{
    public override void Draw() { Console.WriteLine("Rectangle");
}

인터페이스 자체로 의미 부여

인터페이스에 메서드가 포함되어 있지 않은 상태  → 비어있는 인터페이스를 상속받는 것으로도 의미가 부여될 수 있다.

ex) System.Object 클래스의 ToString을 재정의한 클래스만을 구분하고 싶다면?

인터페이스가 없다면 별도의 불린형 필드를 둬서 개발자가 명싱해야 한다. 하지만 인터페이스를 활용하면 다음과 같이 구분 가능

namespace ConsoleApp2;
interface IObjectToString { }   //ToString을 재정의한 클래스에만 
                                //사용될 빈 인터페이스 재정의
class Computer { }  //ToString을 재정의하지 않은 예제 타입
class Person : IObjectToString  //ToString을 재정의했다는 의미로 인터페이스 상속
{
    string name;
    public Person(string name)
    {
        this.name = name;
    }
    public override string ToString()
    {
        return "Person: " + this.name;
    }
}
class Program
{
    public static void DisplayObject(object obj)
    {
        if (obj is IObjectToString)  //인터페이스로 형 변환이 가능한가?
        {
            Console.WriteLine(obj.ToString());
        }
    }
    static void Main(string[] args)
    {
        DisplayObject(new Computer());
        DisplayObject(new Person("홍길동"));
    }
}

이로인해 인터페이스가 계약이라고 정의했던 이유를 이해 가능하다.

  • Person 클래스는 IObjectToString 인터페이스가 요구하는 '암시적인 계약'을 ToString을 재정의함으로써 지켰다.
  • 반면 COmputer 클래스는 ToString 메서드를 재정의하지 않았으므로 IObjectToString 인터페이스의 계약을 지키지 못했고 따라서 상속을 받지 않았으며, 그 차이를 DisplayObject 메서드 안에서 활용하고 있다.
  • 한마디로 인터페이스는 코드에서 자유롭게 정의할 수 있는 '계약'이다.

인터페이스를 이용한 콜백 구현

  • 인터페이스에 포함된 메서드는 상속된 클래스에서 반드시 구현한다는 보장이 있다. 
  • 이 점을 이용해 인터페이스를 이용한 콜백 구현이 가능하다.
interface ISource
{
    int GetResult();	//콜백용으로 사용될 메서드를 인터페이스로 분리한다.
}
class Source : ISource
{
    public int GetResult() { return 10; }
    
    public void Test()
    {
        Target target = new target();
        target.Do(this);
    }
}

class Target
{
    public void Do(ISource obj)	//Source 타입이 아닌 ISource 인터페이스를 받는다.
    {
        Console.WriteLine(obj.GetResult());	//콜백 메서드 호출
    }
}

델리게이트를 사용하기보다는 오히려 '상속'이라는 이미 익숙한 개념으로 콜백을 구현하는 것이므로 이해하기가 더 쉽다.

 

  • 다른 언어인 자바와 비교해보면, 콜백을 구현할 수 있는 중요 수단인 함수 포인터에 대해 C#은 델리게이트를 제공하지만 자바에는 이와 동등한 개념이 없다. 
  • 자바가 델리게이트와 유사한 문법을 제공하지는 않지만 크게 문제가 안되는 이유는 인터페이스가 있기 때문이다.

콜백을 구현할 때 델리게이트와 인터페이스 중에 어떤 것을 선택할지에 관한 적당한 기준이 있는가?

  • 대부분의 콜백 패턴에 대해 인터페이스를 사용하는 방법이 더 선호된다. 왜냐하면 델리게이트는 각 메서드마다 정의해야 하는 불편함이 있지만 인터페이스는 하나의 타입에서 여러 개의 메서드 계약을 담을 수 있기 때문이다.
  • 대신 델리게이트는 '여러 개의 메서드'를 담을 수 있어서 한 번의 호출을 통해 다중으로 등록된 콜백 메서드를 호출할 수 있다는 고유의 장점이 있다.

따라서 다중 호출에 대한 필요성만 없다면 인터페이스를 이용해 콜백을 구현하는 것이 더 일반적이다.

실제로 .NET에서 제공되는 타입 가운데 델리게이트보다 종종 인터페이스가 사용된 예를 볼 수 있다. ex) Array 타입의 멤버인 Sort 메서드가 그 예시이다.

Array.Sort는 단순히 배열을 오름차순 정렬하지만, 인터페이스 인자를 사용하는 경우 내림차순 정렬도 가능하다. 

이유 : Sort에는 다음과 같이 IComparer  인터페이스를 인자로 받는 메서드가 오버로드되어 제공되기 때문이다.

public static void Sort(Array array);
public static void Sort(Array array, IComparer comparer);

Array와 마찬가지로 IComparer 인터페이스도 .NEt에 정의되어 있고 단순히 COmpare라는 메서드 형을 선언하고 있다.

namespace System.Collections;
public interface IComparer
{
    //x가 y보다 크면1, 같으면 0, 작다면 -1을 반환하는 것으로 약속된 메서드
    int Compare(object x, object y);
}

따라서 Array.Sort 정적 메서드를 이용해 내림차순 정렬을 하고 싶다면 약속된 동작을 반대로 수행하는 Compare 메서드를 준비하면 된다.

intArray를 전달받은 Array.Sort 정적 메서드는 배열 안의 각 요소를 정렬하기 위해 값을 비교해야 한다.

만약 IComparer를 구현한 인스턴스를 함께 인자로 넘기면 Array.Sort는 요소의 값을 비교하기 위해 IComparer.Compare 메서드에 2개의 값을 전달한다.

즉, Compare 메서드는 Array.Sort 메서드가 한번 호출될 때 내부에서는 요소의 수에 비례해 여러 번에 걸쳐 호출된다.

IEnumerable 인터페이스

foreach 문법을 더 깊이 있게 알아보기 위해 IEnumerable 인터페이스를 확인한다.

IEnumerable은 닷넷 내부에서 제공되며 다음과 같이 정의되어 있다.

//닷넷에 정의되어 있는 IEnumerable 인터페이스
namespace System.Collections
public interface IEnumerable
{
    IEnumerator GetEnumerator();
}

인터페이스에 정의된 유일한 메서드인 GetEnumerator는 열거자(enumerator)라고 하는 객체를 반환하도록 약속되어 있다.

열거자란 IEnumerator 인터페이스를 구현한 객체를 일컫는데, 다시 IEnumerator 인터페이스의 정의를 살펴보면 다음과 같다.

//닷넷에 정의되어 있는 Ienumerator 인터페이스
namespace System.Collections;

public interface IEnumerator
{
    object Current{ get; }	//현재ㅔ 요소를 반환하도록 약속된 get property
    bool MoveNext();	//다음 순서의 요소로 넘아가도록 약속된 메서드
    void Reset();	//열거 순서를 처음으로 되돌릴 때 호출하면 되는 메서드
}

IEnumerable 인터페이스를 구현한 전형적인 예는 System.Array다.

배열은 모두 System.Array를 상속받는다. 따라서 배열은 다음과 같이 요소를 열람하는 것이 가능하다.

//IEnumerable 인터페이스를 구현한 객체의 요소를 열거하는 방법
int[] intArray = new int[] {1, 2, 3, 4, 5};

IEnumerator enumerator = intArray.GetEnumerator();

while(enumerator.MoveNext())	//더 이상 열거할 수 없을 때 false를 반환
{
    Console.Write(enumerator.Currnet + ", ");
}

  • C#에서는 IEnumerable 인터페이스를 구현하고 있는 객체에 대해 좀 더 쉽게 열람할 수 있는 열거 문법을 제공한다.
  • 제어 구문에 있는 foreach가 바로 그것이다.
  • C#은 foreach로 열거되는 문법을 컴파일 시점에 자동으로 동일한 코드로 변형한다.
foreach(int elem in intArray)
{
    Console.WriteLine(elem + ", ");
}

foreach 제어문은 배열과 컬렉션의 요소를 열거하긴 하지만, 더 정확하게는 foreach의 in 다음에 오는 객체가 IEnumerable 인터페이스를 구현하고 있다면 어떤 객체든 요소를 열거할 수 있다.

인터페이스의 내부 구현은 다양할 수 있지만, 결국 메서드의 '약속된 작업'만 보장해준다면 인터페이스를 사용하는 측에서는 동일한 방법으로 만든 객체를 다룰 수 있다.

using System.Collections;   //IComparer가 정의된 네임스페이스를 사용

namespace ConsoleApp2;

class Hardware { }
class USB
{
    string name;
    public USB(string name)
    {
        this.name = name;
    }
    public override string ToString()
    {
        return name;
    }
}
class Notebook : Hardware, IEnumerable  //IEnumerable 인터페이스 구현
{
    USB[] usbList = new USB[] { new USB("USB1"), new USB("USB2") };

    public IEnumerator GetEnumerator()  //IEnumerator를 구현한 열거자 인스턴스 반환
    {
        return new USBEnumerator(usbList);
    }
    public class USBEnumerator : IEnumerator  //중첩 클래스로 정의된 열거자 타입
    {
        int pos = -1;
        int length = 0;
        object[] list;
        public USBEnumerator(USB[] usb)
        {
            list = usb;
            length = usb.Length;
        }
        public object Current   //현재 요소를 반환하도록 약속된 접근자 메서드
        {
            get { return list[pos]; }
        }
        public bool MoveNext()  //다음 순서의 요소를 지정하도록 약속된 메서드
        {
            if (pos >= length - 1)
            {
                return false;
            }
            pos++;
            return true;
        }
        public void Reset() //처음부터 열거하고 싶을 때 호출하면 되는 메서드
        {
            pos = -1;
        }
    }
}
class Program
{
    static void Main(string[] args)
    {
        Notebook notebook= new Notebook();
        foreach(USB usb in notebook)
        {
            Console.WriteLine(usb);
        }
    }
}

Notebook 타입은 Hardware 타입으로 상속받으면서 동시에 IEnumerable 인터페이스를 구현한다.

인터페이스가 다중 상속을 지원하지 않았다면 가능하지 않은 구문이다.

상속과 함께 IEnumerable 타입을 구현함으로써 스스로 '열거 가능한 타입'이라는 약속을 지키고 있다.

그 혜택으로 foreach 구문을 사용할 수 있다.

느슨한 결합

느슨한 결합(loose coupling)은 인터페이스의 사용 사례로 절대 빼놓을 수 없는 중요한 특징 중 하나다.

느슨한 결합을 이해하기 위해 강력한 결합(tight coupling)이 무엇인지 이해할 필요가 있다.

보통 정의하는 클래스 간의 호출이 강력한 결합에 속한다.

ex) Computer와 Switch 타입은 강력한 결합 관계를 맺고 있다.

class Computer
{
    public void TurnOn()
    {
        Console.WriteLine("Computer: TurnOn");
    }
}
class Switch
{
    public void PowerOn(Computer machine)	//Computer 타입을 직접 사용한다.
    {
        machine.TurnOn();
    }
}

이 두 클래스가 왜 강력한 결합관계에 있다고 하는가?, 강력한 결합에는 어떤 특징이 있는가? 

  • 결합이 강력하게 되어있으면 유연성이 떨어진다는 약점이 있다.
  • 만약 Switch에 Monitor를 연결한다고 가정하면 이 말의 의미를 이해할 수 있다.
class Monitor
{
    public void TurnOn()
    {
        Console.WriteLine("Computer: TurnOn");
    }
}

class Switch
{
    public void PowerOn(Monitor machine)	//Computer를 Monitor로 교체
    {
        machine.TurnOn();
    }
}

Computer에서 Monitor로 변경했는데 Switch의 코드가 바뀌는 것이 당연한가? 

  • 소프트웨어 공학을 하는 이들에게는 당연하게 여겨지지 않는다.

위의 프로그램은 간단해서 Switch 타입에 대한 변경 사항을 추적하기 쉽지만, 수천/수만 줄의 코드로 이뤄진 소프트웨어에서 저런 변화가 발생하면어떻게 되는가?

  • 이것의 보완책으로 나온 것이 느슨한 결합이다.

느슨한 결합을 달성하는 수단이 바로 인터페이스를 사용하는 것이다.

interface IPower
{
    void TurnOn();
}
class Monitor : IPower
{
    public void TurnOn()
    {
        Console.WriteLine("Monitor: TurnOn");
    }
}

class Switch
{
    public void PowerOn(IPower machine)	//특정 타입->인터페이스
    {
        machine.TurnOn();
    }
}

이로써 결합에 대한 문제는 해결된다.

  • 위의 코드를 Monitor에서 다시 Computer로 바꾸더라도, 또는 아에 새롭게 LCD 타입을 정의해 PowerOn 메서드에 전달해도 IPower 인터페이스를 상속받는다는 약속만 지킨다면 내부의 코드는 전혀 변경할 필요가 없다.
  • 물론 정의하는 모든 타입에 인터페이스를 적용해 느슨한 결합이 되도록 만들 필요는 없으며, 그렇게 만들 강제성도 없다. 이에 대한 균형과 조화는 순전히 개발자의 몫이다.

- 구현 클래스를 영어로 Concrete 타입이라고 한다. 굳이 번역하자면 구현 타입 또는 실체화된 타입 등으로 불린다.

- 느슨한 결합이란 클래스 간에 구현 타입의 정보 없이 인터페이스 등의 방법을 이용해 상호 간에 맺은 계약만으로 동작하는 것을 의미한다.


Reference

시작하세요! C# 12 프로그래밍 기본 문법부터 실전 예제까지