Skip to content

3.4 응용 프로그램을 통해 이해하기: 강한 결합→느슨한 결합→클린 아키텍처 체감하기 205 #1544

@jongfeel

Description

@jongfeel

3.4 응용 프로그램을 통해 이해하기: 강한 결합→느슨한 결합→클린 아키텍처 체감하기

3.4.1 들어가기 전에

여기서부터는 간단한 애플리케이션 예제를 통해 클린 아키텍처를 설명합니다.
클린 아키텍처가 아닌(비클린 아키텍처) 애플리케이션을 대상으로 점점 클린 아키텍처에 가까워지도록 리팩터링합니다.

전제조건

C#

https://github.com/nuitsjp/SoftwareDesign202306

3.4.2 애플리케이션의 사례

이번 예제는 주변의 레스토랑을 목록으로 보여 주는 콘솔 애플리케이션인 핫페퍼 입니다.

3.4.3 강한 결합 애플리케이션의 구조와 그 과제

강한 결합 구조

대표적인 컴포넌트로 다음 네 가지가 있습니다.

  • HatPepper: 애플리케이션의 메인 HatPepper.exe
  • FluentTextTable: 오픈소스 콘솔의 표를 유연하게 다루기 위한 라이브러리
  • System.Device: 디바이스의 위치정보
  • System.Net.Http: 웹 API 호출

코드 확인하기

유스케이스 계층의 FindNearbyRestaurants 클래스를 인스턴스화하여 레스토랑을 검색하고 FluentTextTable을 이용하여 콘솔에 결과를 표시하고 있습니다.

https://github.com/nuitsjp/SoftwareDesign202306/blob/eb6f80ae23821b255e938ffab5d9aa92edbf8925/01.%E5%AF%86%E7%B5%90%E5%90%88/HatPepper/Presentation/NearbyRestaurantsConsole.cs#L15-L29

GeoCoordinateWatcher를 이용하여 현재 위치 정보를 수집하고, 현재 시각에서 점심 시간인지를 판단합니다.
이 정보를 GourmetSearchApi에 전달하여 레스토랑을 검색하고 결과를 Restaurant로 변환하여 반환합니다.

https://github.com/nuitsjp/SoftwareDesign202306/blob/eb6f80ae23821b255e938ffab5d9aa92edbf8925/01.%E5%AF%86%E7%B5%90%E5%90%88/HatPepper/UseCase/FindNearbyRestaurants.cs#L17-L32

GourmetSearchApi는 인수에 위치 정보 좌표와 점심으로 한정할지의 여부를 받아 웹 API를 호출하고 있습니다.

https://github.com/nuitsjp/SoftwareDesign202306/blob/eb6f80ae23821b255e938ffab5d9aa92edbf8925/01.%E5%AF%86%E7%B5%90%E5%90%88/HatPepper/Infrastructure/GourmetSearchApi.cs#L22-L29

3.4.4 강한 결합의 과제

실제로는 ‘테스트가 너무 어렵다’라는 문제가 있습니다.
앞의 유스케이스의 메서드 FindRestaurantsAsync를 보면, 여기에는 다음과 같은 문제가 있습니다.

  • 위치 정보가 단말의 위치 기능에 직접 의존하고 있다.
  • 점심 시간 여부의 판단이 실행 시간에 의존하고 있다.
  • 검색 결과가 어떤 웹 API를 쓰는가에 따라 달라진다.

이에 대한 답은 결국 ‘느슨한 결합’으로 외부 환경에 대한 의존성을 분리하는 것입니다.
위치, 시간, 웹 API와 유스케이스 로직의 결합을 느슨하게 유지함으로써 유스케이스를 테스트할 때 위치나 시간을 임의로 지정할 수 있도록 합니다.

3.4.5 느슨한 결합과 리팩터링

두 가지 의존성이 있는 부분

// 레스토랑을 검색한다
var gourmetSearchApi = new GourmetSearchApi();
GourmetSearchResults result = await gourmetSearchApi.FindRestaurantsAsync(location, lunchOnly);

이용 관계 제거하기

클래스 간 직접적인 이용을 막기 위해 인터페이스를 호출합니다.

관계를 인터페이스로 옮기는 코드

// 레스토랑을 검색한다
var gourmetSearchApi = (IGourmetSearchApi)new GourmetSearchApi();
GourmetSearchResults result = await gourmetSearchApi.FindRestaurantsAsync(location, lunchOnly);

FindRestaurantsAsync는 구현 클래스가 아닌 인터페이스를 통해 호출되므로, FindNearbyRestaurants 클래스가 GourmetSearchApi 클래스를 직접 이용하는 강한 결합을 막았습니다.

생성 관계 제거하기

코드를 제거하기 위한 방법으로 다음 두 가지의 대표적인 디자인 패턴이 있습니다.

  • 의존성 주입(DI, Dependency Injection) 패턴
  • 서비스 로케이터(Service Locator) 패턴

아래 코드처럼 외부에서 IGourmetSearchApi 인스턴스를 생성자 주입(Constructor Injection)으로 받아와서, 필드에 저장하고 필요할 때 사용하는 방식입니다.

private readonly IGourmetSearchApi _api;
public FindNearbyRestaurants(IGourmetSearchApi api)
{
    _api = api;
}

public async Task<IEnumerable<Restaurant>> FindRestaurantsAsync()
{
    ...생략...
    // 레스토랑을 검색한다
}
    var result = await _api.FindRestaurantsAsync(location, lunchOnly);
    return result.Shops
        .Select(x => new Restaurant(x.Name, x.Genre.Name));
}

생성자가 변경되었으므로 아래 코드와 같이 변경합니다.

// 레스토랑을 검색한다
var findNearbyRestaurants = new FindNearbyRestaurants(new GourmetSearchApi());
var restaurants = await findNearbyRestaurants.FindRestaurantsAsync();

GourmetSearchApi에 대한 의존 계층이 유스케이스 계층에서 프레젠테이션 계층으로 바뀌었을 뿐, 문제가 근본적으로 해결된 것은 아닙니다.
그 때문에 프레젠테이션 계층도 DI를 통해 해결하도록 구현하였습니다.
IGourmetSearchApi가 아니라 유스케이스 자체(IFindNearbyRestaurants)를 주입해야 합니다.

private readonly IFindNearbyRestaurants _findNearbyRestaurants;
public NearbyRestaurantsConsole(IFindNearbyRestaurants findNearbyRestaurants)
{
    _findNearbyRestaurants = findNearbyRestaurants;
}

public async Task FindRestaurantsAsync()
{
    // 레스토랑을 검색한다
    var restaurants = await _findNearbyRestaurants.FindRestaurantsAsync();

코드 수정 후의 의존 관계 확인하기

책 그림 참고 필요

Restaurant 같은 객체도 인터페이스를 이용해야 할까요?
이것은 생산성과 유연성 사이의 트레이드오프 관계를 가집니다.
Restaurant 객체에 중요한 업무 로직이 있을 때는 테스트 용이성을 위해 인터페이스 분리를 고려해 볼 필요가 있습니다.

테스트 용이성이 비용보다 중요한 로직의 경우 인터페이스를 만들어야 합니다.
하지만 이번 예제에서는 그 정도의 가치가 없으니, 반환값 객체에 대한 별도의 인터페이스를 생성하지는 않습니다.

남은 과제 해결하기

  • 위치 정보나 시간의 테스트 용이성이 확보되어 있지 않다.
  • 프레젠테이션 계층이 유스케이스 계층에 강한 결합하고 있다.

시간과 위치 정보를 테스트하기 쉽게 만들기

책 그림 참고 필요

LocationProvider와 ITimeProvider라는 인터페이스를 각각 만들고, 하부 구현을 위한 인프라스트럭처 코드는 Api, Location, Time이라는 서브 패키지로 나누어 정리해 보겠습니다.

3.4.6 비클린 아키텍처의 과제

상위 계층은 하위 계층에 의존하므로, 하위 계층이 변경되면 상위 계층은 영향을 받습니다.
즉, 상위 계층은 하위 계층보다 안정성이 낮습니다.
반대로 상위 계층을 변경해도 하위 계층에는 영향이 발생하지 않으므로 상위 계층은 유연성이 높습니다.
유연성과 안정성은 의존 방향에 의해 결정되는 트레이드오프입니다.

유스케이스 계층의 안정성이 높고 유연성이 낮게 해야 하며, 프레젠테이션 계층과 인프라스트럭처 계층은 그 반대가 되도록 설계해야 합니다.

그런데 현재 의존성 방향은 제어 흐름(호출 방향)과 일치합니다. 이것은 인프라스트럭처 계층에서 유스케이스 계층을 호출할 수 없다는 의미입니다.

3.4.7 클린 아키텍처를 향한 리팩터링

제어 흐름과 의존 방향을 분리해 통제하려면 각각의 사이에 있는 인터페이스를 어느 쪽의 문맥(컨텍스트)으로 정의하는지가 중요합니다.

클린 아키텍처는 동심원 그림 모양대로 구현하라는 것이 아닙니다.
가장 중요한 도메인 모델을 중앙에 배치하고, 모든 의존 관계가 밖에서 안으로 향하는 형태의 아키텍처를 의미합니다.

3.4.8 불안정한 클린 아키텍처와 안정적인 클린 아키텍처

지금까지의 설계에서 모든 객체는 HatPepper.exe 컴포넌트에 포함되어 있습니다.
그 안의 계층은 어디까지나 논리적인 것으로 계층 간의 의존 관계는 개발자가 풀어 나가야 할 과제였습니다.

경험상 이것은 리스크가 매우 큽니다.
설계나 구현을 하다 보면 실수로 의존 관계를 잘못 설정하는 경우가 가끔 발생합니다.
시간이 지나도록 이런 상황을 아무도 눈치채지 못한다면 이것은 시스템의 안정성과 유연성이 체계적으로 관리되지 않고 있다는 의미입니다.
이런 상태에서 새로운 기능을 추가하거나 기존 기능을 수정한다면 영향 받는 범위를 명확하게 통제할 수가 없어, 결국 품질이 저하되는 현상이 생깁니다.

이런 문제를 방지하고자 개발 언어별로 제공하는 기능을 이용해 안정적인 클린 아키텍처를 구현하는 것이 좋습니다.
이번 예제는 닷넷이므로 계층을 컴포넌트(프로젝트) 단위로 분할하고 제어하는 방식으로 안정적인 의존 관계를 만들 수 있습니다.

3.4.9 요약

정말 중요한 것은 ‘가장 중요한 요소에 의존성이 향하도록 제어하는 것’입니다.
이것을 실현하기 위해 다음과 같은 방법이 있습니다.

  • 인터페이스와 구현을 분리하고 구현체를 직접 이용하지 않는다.
  • 객체를 직접 생성하지 않고 DI 패턴이나 서비스 로케이터(Service Locator) 등을 활용한다.
  • 인터페이스의 문맥을 통제함으로써 의존 방향을 제어 흐름과 분리한다.
  • 가능하면 원치 않는 의존이 발생하지 않는 기법을 도입한다.

Metadata

Metadata

Assignees

Projects

Status

Done

Relationships

None yet

Development

No branches or pull requests

Issue actions