단일 구현체를 가진 인터페이스

대규모 WPF 애플리케이션을 리팩토링하는 과정에서 질문을 만났습니다: “인터페이스가 단 하나의 구현체만 가지고 있다면, 이런 인터페이스가 필요한건가?”

이 질문은 개발자들이 처음 SOLID 원칙과 디자인 패턴을 접할 때 자주 발생한다고 합니다. IApiService와 같은 인터페이스가 ApiService라는 단 하나의 구체적인 구현체만 가지고 있는 것을 보고, 이러한 추상화가 실제 가치를 더하는지 아니면 단지 “architecture astronautics(억지스러운 추상화)“인지 의문을 갖게 됩니다.

단일 구현 인터페이스의 사례

잘 구조화된 코드베이스들을 구경하다 보면 이런 사례가 많습니다. 그리고 그러한 구조의 이유는 SOLID 원칙에 근거를 두고 있습니다:

1. 의존성 역전과 디커플링

의존성 역전 원칙(Dependency Inversion Principle)은 고수준 모듈이 저수준 모듈에 의존해서는 안 되며, 둘 다 추상화에 의존해야 한다고 명시합니다. 인터페이스는 구현체의 수에 관계없이 컴포넌트 사이에 명확한 경계를 만듭니다.

// 더 나음: 추상화에 의존
public class ContentViewModel
{
    private readonly IApiService _apiService;
    
    public ContentViewModel(IApiService apiService)
    {
        _apiService = apiService;
    }
}
 
// 덜 유연함: 구현체에 직접 결합
public class ContentViewModel
{
    private readonly ApiService _apiService;
    
    public ContentViewModel(ApiService apiService)
    {
        _apiService = apiService;
    }
}

이러한 디커플링은 인터페이스가 안정적으로 유지되는 한, 구현체의 변경이 사용자에게 영향을 미치지 않는다는 것을 의미합니다.

2. 단위 테스트 간소화

아마도 가장 즉각적인 이점은 단위 테스트에 있다고 봅니다. 인터페이스를 사용하면 테스트 목적으로 mock 구현체를 만들 수 있습니다:

[TestMethod]
public async Task LoadContent_ShouldDisplayContentTitle()
{
    // 준비
    var mockApiService = new Mock<IApiService>();
    mockApiService.Setup(x => x.GetContentAsync(It.IsAny<string>()))
                  .ReturnsAsync(new ContentItem { Title = "테스트 콘텐츠" });
    
    var viewModel = new ContentViewModel(mockApiService.Object);
    
    // 실행
    await viewModel.LoadContentCommand.ExecuteAsync("test");
    
    // 검증
    Assert.AreEqual("테스트 콘텐츠", viewModel.ContentTitle);
}

인터페이스 없이는 테스트가 훨씬 더 복잡해지며, 종종 테스트 서버나 데이터베이스를 사용한 복잡한 설정이 필요합니다.

3. 설계의 미래 보장

단일 구현으로 시작한 것이 요구사항이 변경됨에 따라 여러 구현체로 진화하는 경우가 자주 있습니다:

// 원래 구현체
public class ApiService : IApiService { /*...*/ }
 
// 나중에 테스트를 위해 추가
public class MockApiService : IApiService { /*...*/ }
 
// 오프라인 모드를 위한 또 다른 구현체
public class OfflineApiService : IApiService { /*...*/ }
 
// 다른 API 구조를 가진 특정 클라이언트를 위해 추가
public class LegacyApiService : IApiService { /*...*/ }

처음부터 인터페이스를 설정해두면 이러한 전환이 원활해집니다. 실제로 제가 작업중인 프로젝트에서 content frame에 실제 컨텐츠(이미지)를 로딩하는 방식이 4번이나 바뀌었는데, 리팩터링 전에는 이것을 일일이 수정하고 호출되는 함수 이름을 바꿔줬습니다(제가 설계한거 아님).

4. 계약에 의한 설계

인터페이스는 구현체가 충족해야 할 명확한 계약을 정의합니다. 이러한 API 경계의 명확성은 개발자가 컴포넌트가 어떻게 상호 작용해야 하는지 이해하는 데 도움을 주어 유지보수성을 향상시킵니다.

5. 데코레이터 패턴 활성화

인터페이스를 통해 가능해지는 가장 강력한 패턴 중 하나는 데코레이터 패턴으로, 기존 코드를 수정하지 않고도 동작을 추가할 수 있습니다:

// 원래 서비스
public class ApiService : IApiService { /*...*/ }
 
// 원래 구현체를 변경하지 않고 로깅 추가
public class LoggingApiService : IApiService 
{
    private readonly IApiService _inner;
    private readonly ILogger _logger;
    
    public LoggingApiService(IApiService inner, ILogger logger) 
    {
        _inner = inner;
        _logger = logger;
    }
    
    public async Task<T> GetAsync<T>(string endpoint) 
    {
        _logger.Log($"{endpoint} 호출 중");
        var result = await _inner.GetAsync<T>(endpoint);
        _logger.Log($"{endpoint} 완료");
        return result;
    }
}

이 패턴은 캐싱, 로깅, 성능 모니터링과 같은 cross-cutting concerns를 관리하는 데 매우 유용합니다.

실제 사례

최근 리팩토링 프로젝트에서 처음에는 단일 구현체만 가지고 있는 여러 인터페이스를 도입했습니다:

  • INavigationService: ViewModel에서 페이지/뷰 탐색을 분리
  • IApiService: API 상호작용을 위한 안정적인 규약 관리
  • IContentHierarchyService: UI와 독립적으로 컨텐츠 구성 관리
  • IMemoRepository: 메모 기능의 데이터 접근 추상화

이러한 인터페이스들은 대부분 하나의 구현체로 시작했지만, 코드베이스의 테스트 가능성과 유지보수성을 크게 향상시켰습니다.

인터페이스를 사용하는 경우

제 경험에 따르면, 단일 구현을 가진 인터페이스는 다음과 같은 경우에 가장 가치가 있습니다:

  1. 컴포넌트가 아키텍처 경계를 넘을 때 (예: UI에서 서비스 계층으로)
  2. 구현체의 소비자를 단위 테스트해야 할 때
  3. 컴포넌트가 진화하거나 대체 구현체를 가질 가능성이 있을 때
  4. cross-cutting issue를 적용해야 할 때 (로깅, 캐싱 등)
  5. 팀 환경에서 작업할 때 명확한 계약으로 협업에 도움

꼭 필요하지 않은 경우

물론, 인터페이스가 항상 필요한 것은 아닙니다. 다음과 같은 경우에는 생략할 수 있습니다:

  1. 클래스가 단순한 데이터 컨테이너인 경우(POCO/DTO)
  2. 구현체가 변경되거나 mock이 필요할 일이 없다고 확신하는 경우
  3. 추상화 오버헤드가 중요한 매우 성능이 중요한 코드에서 작업하는 경우
  4. 탐색 단계에 있고 설계가 여전히 유동적인 경우

이 주제와 관련된 다른 WPF 최적화 기법들도 참고해보세요: