interface 안에 메소드 구현이 가능한 이유는 무엇일까요?

이번에 신입이 abstract와 interface의 차이를 물어서

“abstract는 클래스이기 때문에 내부에 추상 메소드도 되고 메소드 구현도 되는데

interface는 이렇게 메소드를 구현 하면 에러가 나”

라고 하는데.. 에러가 안납니다.

public interface I
{
    public void Test()
    {
        Console.WriteLine("A");
    }
}

그래서 머지..? 싶다가 일단 넘어 갔는데

테스트를 위해 다음과 같이 코드를 작성했습니다.

public class Program
{
    public static void Main()
    {
        A a = new();
        var i = a as I;
        i.Test();
        a.Test();
    }
}

public class A : B, I
{
}
public class B
{
    public void Test()
    {
        Console.WriteLine("B");
    }
}
public interface I
{
    public void Test()
    {
        Console.WriteLine("I");
    }
}

결과는 B 가 두줄 나왔습니다.

Test를 virtual로 바꿔도 결과는 같습니다.

위와 같은 중복 상속의 충돌을 방지 하기 위해

C#에서는 class 중복 상속은 안되고, interface 중복 상속만 허용 하는 것으로 알고 있는데

이렇게 interface안에 메소드 구현이 된다면 위 법칙이 깨지게 됩니다.

interface에서는 field 선언이 안되듯, 메소드 구현도 안되야 하는게 맞는거 같은데

되는 이유가 있을까요?

4개의 좋아요

.NET Core 3.0이 추가되던 C# 8.0 시절에 말씀하신 기능이 나왔습니다.

인터페이스에 기본 메서드를 구현하는 기능입니다.

저도 오브젝트라는 책을 읽고 과거에 아래처럼 썼었습니다.

기본 메서드 기능이 왜 추가되었는 지는 모르겠지만, 이 기능을 통해 C#에서도 Mix-In 을 사용할 수 있게 된 것 같습니다.

지금 다시보니 일종의 Glue 같은 느낌이네요.

3개의 좋아요

위 법칙은 깨지지 않습니다.

또한 상태의 상속도 추상 클래스만 가능하다는 원칙도 여전히 지켜지고 있습니다.

기억해야 할 것은 메서드 시그니처가 같을 때 어떤 것이 선택되느냐일 것입니다.

3개의 좋아요

@Vincent 님께서 말씀해주신 것과 같이 .NET Core로 넘어오고나서 본격적으로 .NET Framework와 궤를 달리하기 시작하면서 추가된 부분이 바로 이 부분입니다.

(1) 단순히 인터페이스에 대한 공통 로직을 확장하기 위함이라면 익스텐션 메서드를 쓰는게 opt-in/opt-out 관리가 쉽고 바람직한 것이지만, (2) 인터페이스 간에 상속이 있고, 그 관계안에서 “전문화”, "오버라이딩"이 발생하는 구조면 그 때 쓰는게 맞다고 생각합니다.

(1)의 예를 들어보면,

public interface ILogger
{
    void Log(string message);
}

// 계약에 포함되지 않는 편의 기능 (선택적으로 사용)
public static class LoggerExtensions
{
    public static void Info(this ILogger logger, string message)
        => logger.Log($"[INFO] {message}");

    public static void Error(this ILogger logger, Exception ex)
        => logger.Log($"[ERROR] {ex.Message}");
}

위와 같이 Log 메서드를 좀 더 쓰기 좋게 래핑하는 것이 전부라면 위와 같이 확장 메서드로 정의하는게 관리가 쉽고 직관적입니다. 대개는 여기에 많이 걸치고요,

(2)의 경우는

public interface IRepository<T>
{
    IQueryable<T> GetAll();

    // 모든 구현체가 공통적으로 사용할 기본 구현 제공
    T? GetById(int id)
        => GetAll().FirstOrDefault(e => e.Id == id);
}

// 캐시 적용 저장소: 상위 인터페이스의 기본 구현을 재정의
public interface ICachedRepository<T> : IRepository<T>
{
    T? IRepository<T>.GetById(int id)
    {
        // 캐시 조회
        var entity = Cache.Get<T>(id);
        if (entity != null) return entity;

        // 캐시에 없으면 기본 구현(for DB) 호출
        return ((IRepository<T>)this).GetById(id);
    }
}

이 경우에는 IRepository가 가진 기본적인 특성은 그대로 유지하면서도, “캐싱이 되는 리포지터리”라는 성격을 GetById 메서드의 원래 규약은 지키되, ICachedRepository만의 구현으로 전문화하고 있습니다.

같은 패턴을 추상 클래스로도 구현할 수 있지만, 인터페이스에 기본 구현을 두면 구현 타입이 다른 베이스 클래스를 상속받을 여지를 남겨 두면서도, 인터페이스 계층 안에서 기본 동작과 재정의를 동시에 표현할 수 있다는 것이 장점입니다. 추상 클래스로 같은 일을 하게 되면, 다른 클래스를 상속받을 여지가 사라지고 이는 @BigSquare 님께서 말씀하신 대로입니다.

그래서 실제로는 (1)의 방식만으로도 대부분의 문제를 충분히 해결할 수 있기 때문에, 해당 기능이 새로 추가되었다고 해서 굳이 의식적으로 익히고 적극적으로 사용하려고 노력할 필요까지는 없다고 생각합니다.

3개의 좋아요

모든분들 답변 감사합니다.

1개의 좋아요