지속 가능한 소프트웨어 설계 패턴: 포트와 어댑터 아키텍처 적용하기

들어가며

육각형 설계(Hexagonal Architecture)로 더 잘 알려져 있는 포트와 어댑터 설계(Ports and Adapters Architecture)는, 인터페이스나 기반 요소(infrastructure)의 변경에 영향을 받지 않는 핵심 코드를 만들고 이를 견고하게 관리하는 것이 목표입니다. 저는 예전에 프로젝트를 진행하면서 이 설계를 적용해 작은 모듈을 만든 적이 있습니다. 당시 모듈은 HTTP API와 웹소켓 인터페이스를 모두 지원했는데요. 포트와 어댑터 설계를 적용한 덕분에 인터페이스를 다중화하더라도 함께 변경되는 코드가 거의 없었고, 많은 코드를 공유할 수 있어서 코드 재사용성도 높았습니다(지금 생각해도 그때의 결과물은 감동입니다). 반면에 그 프로젝트를 테스트하기 위한 봇을 만들 때에는 전통적인 설계를 적용했는데요. 초반 개발 속도는 정말 빨랐지만, 나중에 동료와 함께 쓰려고 인터페이스를 개선하다 보니 결국 코드 대부분을 수정하게 되었습니다. 

포트와 어댑터 설계에 어떤 효과가 있기에 이런 차이가 발생했을까요? 이해를 돕기 위해 예를 하나 들어보겠습니다. 어떤 개발자가 Command Line Interface를 통해 간단한 기능을 제공하는 애플리케이션을 만들어서 유용하게 쓰고 있었습니다. 그런데 어느 순간 동료 개발자들이 애플리케이션에 눈독을 들이기 시작합니다. 결국 개발자는 보다 많은 사람들이 좀 더 쉽게 사용할 수 있도록 웹 인터페이스를 추가하기로 결정합니다. 개발자는 웹 인터페이스를 제공하기 위해 애플리케이션에 많은 코드를 새로 붙였지만, 동료와 함께 사용하고자 했던 기능을 수행하는 코드엔 손도 대지 않았습니다. 애플리케이션 사용자가 늘어나면 트랜잭션 관리도 어려워지기 시작하겠죠. 개발 초기엔 파일 시스템으로 영속성을 관리해도 문제가 없었겠지만, 점점 관리가 어려워지면서 개발자는 MySQL을 사용하기로 결정합니다. 개발자는 영속성 관리 시스템을 변경하면서 관련 코드 역시 많이 수정해야 했지만, 여전히 애플리케이션의 주된 목적을 수행하는 코드는 변경할 필요가 없었습니다.

이와 같이 포트와 어댑터 설계를 적용하면 인터페이스나 기반 요소가 사용자의 요구 사항 혹은 수용 능력에 영향을 받아 변경된다고 하더라도 애플리케이션의 주요 동작(도메인 로직 혹은 비즈니스 로직)에는 아무런 영향을 주지 않습니다. 도메인 로직을 견고하게 유지하며 소프트웨어의 지속 가능성을 높일 수 있는 것이죠. 저는 이번 글을 통해 포트와 어댑터가 무엇인지, 이 설계를 따르면 코드가 어떤 식으로 조직되는지, 실제 인터페이스나 기반 요소 등 한 번 변경되면 작업량이 많은 일에도 어떻게 쉽게 적용되는지, 설계에 따라 개발된 업무 로직을 얼마나 쉽게 테스트할 수 있는지를 보여드리겠습니다. 이를 통해 포트와 어댑터 설계의 장점을 실제로 보여드리고자 합니다.

 

포트와 어댑터란?

먼저 포트와 어댑터란 게 대체 무엇일까요? 설계 이름에 떡 하니 들어가 있는 걸 보면 뭔가 핵심 용어 같은데요. 막상 정체를 알고 나면 특별할 게 없습니다. 포트는 바로 인터페이스입니다. 예를 들면 클래스의 메서드 시그니처나 Java의 인터페이스가 바로 포트라고 할 수 있습니다. 다음으로 어댑터는, 디자인 패턴에도 있듯이 클라이언트에 제공해야 할 인터페이스를 따르면서도 내부 구현은 서버의 인터페이스로 위임하는 것입니다. 설명이 조금 추상적인데요. 이해를 돕기 위해 예제 코드와 함께 더 살펴보겠습니다. 아래 코드는 책이나 DVD 같은 대여물의 대여와 반납을 관리하는 애플리케이션의 일부분입니다.

public class TotalRentalServiceImpl implements TotalRentalService {
 
    private final CustomerRepository customerRepository;
    private final RentalRepository rentalRepository;
    private final InventoryService inventoryService;
    private final RentalHistoryRepository rentalHistoryRepository;
 
    public TotalRentalServiceImpl(CustomerRepository customerRepository,
                                  RentalRepository rentalRepository,
                                  InventoryService inventoryService,
                                  RentalHistoryRepository rentalHistoryRepository) {
        this.customerRepository = customerRepository;
        this.rentalRepository = rentalRepository;
        this.inventoryService = inventoryService;
        this.rentalHistoryRepository = rentalHistoryRepository;
    }
 
    @Override
    public RentalHistory rent(RentalTarget target) {
        Customer borrower = customerRepository.find(target.customerId())
                                              .orElseThrow(() -> new NotFoundException(target.customerId()));
        Rental rental = rentalRepository.find(target.rentalId())
                                        .orElseThrow(() -> new NotFoundException(target.rentalId()));
        Item rentedItem = inventoryService.rent(rental, borrower)
                                          .orElseThrow(AlreadyRentedException::new);
        RentalHistory history = RentalHistory.of(UUID.randomUUID().toString(),
                                                 RentalSpec.of(borrower, rental),
                                                 rentedItem);
        rentalHistoryRepository.save(history);
        return history;
    }
}

이 서비스는 TotalRentalService를 구현하여 RentalHistory rent(RentalTarget)라는 인터페이스를 제공하고 있습니다. 만약 MVC 패턴을 채택했다면 이 서비스의 rent()를 사용하는 것은 컨트롤러입니다. 컨트롤러는 다시 HTTP를 통한 인터페이스를 클라이언트에게 제공하여 클라이언트가 TotalRentalService를 이용할 수 있도록 중간 역할을 합니다. 이를 그림으로 나타내면 다음과 같습니다.

그림 1. 클라이언트와 애플리케이션의 통신 간 컨트롤러의 위치와 역할

이때 TotalRentalService는 인터페이스를 제공하므로 포트이며, 위 코드에선 rent()가 포트가 됩니다. 컨트롤러는 클라이언트의 HTTP API 요청을 받아 rent()라는 인터페이스를 연결해주고 있으므로 어댑터입니다. 이렇게 외부에서 요청해야 동작하는 포트와 어댑터를 주요소(primary)라고 하며, 포트와 어댑터에 따라 주포트 혹은 주어댑터라고도 부릅니다.

한편 TotalRentalService의 구현체는 내부적으로 CustomerRepository나 RentalRepositoryInventoryService 인터페이스를 사용합니다. 만약 Repository가 데이터의 영속을 위해 Redis를 사용한다면 아래 그림과 같이 표현할 수 있습니다.

그림 2. 애플리케이션과 기반 요소의 통신 간 Repository의 위치와 역할

Repository나 Service는 TotalRentalService가 사용할 인터페이스를 제공하고 있기 때문에 포트입니다. 위 코드의 rentalRepository.find()나 inventoryService.rent()를 예로 들 수 있습니다. 그리고 Repository가 Redis와 프로토콜을 이용해 통신하고 있다면 RedisRepository와 같은 구현체가 있을 겁니다. 이 구현체는 Repository라는 인터페이스를 따르면서 내부적으로 Redis 프로토콜과 연결해 주므로 어댑터입니다. 이렇듯 애플리케이션이 호출하면 동작하는 포트와 어댑터를 부요소(secondary)라고 합니다. 역시 부포트 또는 부어댑터라고 부를 수 있습니다.

아래는 포트와 어댑터 설계를 따른 소프트웨어와 인터페이스, 기반 요소와의 관계를 표현한 그림입니다. 

그림 3. 포트와 어댑터의 추상적인 개념

앞서 설명드렸던 요소들이 모두 담겨 있는 위 그림을 통해 서로 간의 의존 관계를 파악할 수 있습니다. 먼저 어댑터가 애플리케이션과는 겹치지 않고 포트와 겹쳐 있는 모습으로 미루어 보아 어댑터가 애플리케이션을 직접 참조하지 않고 포트에 의존하고 있다는 것을 알 수 있습니다. 여기서 포트는 변경이 잦은 어댑터와 애플리케이션의 결합도를 낮추는 역할을 합니다. 애플리케이션은 핵심 로직에 가까우므로 결합도를 낮추는 것이 매우 중요합니다. 또한 애플리케이션은 도메인에 의존하지만 도메인은 애플리케이션과 어댑터에 전혀 의존하지 않습니다. 따라서 애플리케이션이나 어댑터가 변경되어도 핵심 로직인 도메인은 아무런 영향을 받지 않습니다.

다음으로 어댑터를 사용하는 쪽을 살펴보겠습니다. 주요소 쪽의 HTTP와 RPC는 각각의 어댑터를 통해 애플리케이션을 이용합니다. 그러나 각각의 어댑터는 결국 하나의 포트를 사용합니다. 만약 웹소켓이 필요하다면 웹소켓 어댑터를 새로 만들어서 추가하면 됩니다. 이렇게 새로운 어댑터를 추가하는 동안 포트가 애플리케이션과 도메인을 보호합니다. 반면에 부요소 쪽에는 애플리케이션이 이용하는 기반 요소들이 있습니다. 위 그림에서는 저장소로 MySQL을 사용하고 있는 것을 확인할 수 있습니다. 앞서 주요소 쪽에서 본 것과 다르게 기반 요소의 포트와 어댑터는 일반적으로 1:1 관계입니다. 이것은 하나의 포트에 여러 어댑터가 있다거나 새로 추가될 일은 거의 없다는 뜻입니다. 그러나 기존에 사용하던 어댑터가 교체될 가능성은 충분합니다. 예를 들어 빠른 속도가 필요하다면 MySQL을 Redis로 교체할 수 있겠죠. 이때 Redis의 어댑터를 포트의 인터페이스에 준해서 만들고, 교체하면 됩니다. 이때도 역시 포트가 애플리케이션과 도메인을 보호합니다.

잠깐 Spring 얘기를 해 보겠습니다. Spring Data JPA를 쓸 때 보통 인터페이스는 만들지만 구현체는 만들지 않습니다. Spring Data JPA가 만들어 주기 때문이죠. 그래서 Spring Data JPA에 익숙하신 분들은 포트와 어댑터를 구분하는 데에 조금 어려움을 겪을 수도 있습니다. 하지만 겁내지 마세요. 여러분이 포트만 만들면, 어댑터는 Spring Data JPA가 만들어 준다는 것만 기억하고 있으면 됩니다. 만약 Spring Data Redis를 도입하더라도 먼저 만들어 둔 Repository들은 인터페이스, 즉 포트이므로 거의 손댈 일이 없을 겁니다. 대신 Spring Data Redis가 Redis와 통신하는 어댑터를 만들어 주겠죠. 이것 또한 포트와 어댑터 설계 패턴이라고 할 수 있습니다.

 

포트와 어댑터 설계 구성

애플리케이션의 코드를 조직하기 위해 흔히 패키지나 네임스페이스 등을 활용합니다. 포트와 어댑터 설계는 패키지 조직에도 영향을 미치는데요. 앞에서 살펴봤던 코드를 이용해 패키지 구조를 하나 소개하겠습니다.

public class TotalRentalServiceImpl implements TotalRentalService {
 
    private final CustomerRepository customerRepository;
    private final RentalRepository rentalRepository;
    private final InventoryService inventoryService;
    private final RentalHistoryRepository rentalHistoryRepository;
 
    // ...
 
    public RentalHistory rent(RentalTarget target) {
        // ...
        return history;
    }
}
└── com
    └── linecorp
        └── rentalapp
            ├── application
            │   ├── AlreadyRentedException.java
            │   ├── NotFoundException.java
            │   ├── RentalTarget.java
            │   ├── TotalRentalService.java
            │   └── TotalRentalServiceImpl.java
            ├── domain
            │   ├── model
            │   │   ├── customer
            │   │   │   ├── Customer.java
            │   │   │   └── CustomerRepository.java
            │   │   ├── history
            │   │   │   ├── RentalHistory.java
            │   │   │   └── RentalHistoryRepository.java
            │   │   └── rental
            │   │       ├── Item.java
            │   │       ├── Rental.java
            │   │       └── RentalRepository.java
            │   └── service
            │       └── rental
            │           └── InventoryService.java
            │
            ├── infrastructure
            └── interfaces

이 구조는 포트와 어댑터 설계를 할 때 반드시 필요한 형태는 아닙니다. 하지만 포트와 어댑터 설계를 따르다 보면 자연스럽게 이와 비슷한 형태를 갖추게 됩니다. 위 패키지 구조에서 domain엔 주로 업무 로직을 포함하는 클래스들이 들어섭니다. application은 주로 유스케이스(usecases)가 작성된 클래스를 포함합니다. 이 계층엔 업무 로직이 거의 없고, domain의 여러 업무 로직을 조합하는 역할을 합니다. interfaces는 클라이언트와 약속한 통신 방식의 어댑터를 포함합니다. 이곳에 포함되는 어댑터는 주어댑터이며, 주로 MVC의 컨트롤러나 RPC 서비스 등입니다. infrastructure는 기반 요소, 즉 다른 서비스를 사용하는 어댑터를 포함합니다. 이곳에 포함되는 어댑터는 부어댑터입니다. 예를 들면 Kafka나 Redis, MySQL 또는 다른 서비스의 API를 사용하는 구현체가 포함되는 패키지입니다.

이러한 구성이 생소한 분들이 많을 거라고 생각합니다. 이해를 돕기 위해 제가 흔히 봤던 패키지 구조를 하나 소개하겠습니다.

└── com
    └── linecorp
        └── sally
            ├── controller
            │   ├── MembershipController.java
            │   └── StoreController.java
            ├── dto
            │   ├── RegisterRequest.java
            │   ├── RegisterResponse.java
            │   ├── StoreRequest.java
            │   ├── StoredItemDto.java
            │   └── UserDto.java
            ├── entity
            │   ├── User.java
            │   ├── Item.java
            ├── persistence
            │   ├── ItemRepository.java
            │   └── UserRepository.java
            └── service
                ├── InventoryService.java
                ├── MembershipService.java
                ├── TotalRentalService.java
                └── TotalRentalServiceImpl.java

위 패키지 구조는 어떤가요? 먼저 controller의 MembershipController와 StoreController는 서로 전혀 참조하지 않을 것 같지만 같은 패키지에 들어 있습니다. persistence의 ItemRepository와 UserRepository도 서로 참조할 것 같지 않습니다. 패키지는 서로 연관이 있는 클래스를 한 데 모으고 응집도를 높이는 역할을 해야 합니다. 그러므로 패키지 조직만 잘해도 응집도가 높은 패키지 구조(참고)를 작성할 수 있습니다. 이 패키지 구조를 포트와 어댑터 설계에 따른 패키지 구조로 리팩터링 한다면 어떻게 될까요? 코드를 보고 리팩터링 한 것은 아니지만, 이름으로 어림짐작했을 때 아래와 같은 형태로 바꿀 수 있을 것입니다.

└── com
    └── linecorp
        └── sally
            ├── application
            │   ├── impl
            │   │   └── TotalRentalServiceImpl.java
            │   ├── InventoryService.java
            │   └── TotalRentalService.java
            ├── domain
            │   ├── item
            │   │   ├── Item.java
            │   │   └── ItemRepository.java
            │   └── member
            │       ├── MembershipService.java
            │       ├── User.java
            │       └── UserRepository.java
            └── interfaces
                ├── common
                │   ├── StoredItemDto.java
                │   └── UserDto.java
                ├── member
                │   ├── MembershipController.java
                │   ├── RegisterRequest.java
                │   └── RegisterResponse.java
                └── store
                    ├── StoreController.java
                    └── StoreRequest.java

RegisterRequest와 RegisterResponse는 아마도 MembershipController외에는 사용할 것 같지 않습니다. 그렇다면 같은 패키지에 넣어둡니다. 이렇게 하면 RegisterRequest와 RegisterResponse의 접근 제어자를 패키지 수준으로 제한할 수 있습니다. 접근 제어자를 제한해 놓으면 두 클래스는 다른 패키지에서 전혀 관심 가질 필요가 없다는 의도를 명확히 표현할 수 있습니다. 또 클래스가 격리되므로 불필요한 의존성을 막거나 불특정 다수에게 참조될 위험성을 미연에 방지할 수 있습니다. 패키지 구조를 바꿈으로써 우리는 응집도를 높이고 모듈화라는 패키지 본연의 기능을 극대화할 수 있습니다.

 

포트와 어댑터 설계 적용 사례

사례를 하나씩 짚어보면서 어떤 경우에 어떻게 해야 포트와 어댑터 설계를 지켜 나갈 수 있는지 알아보겠습니다. 예제 코드를 다시 볼까요?

public class TotalRentalServiceImpl implements TotalRentalService {
 
    // ...
 
    public RentalHistory rent(RentalTarget target) {
        // ...
        return history;
    }
}

애플리케이션에 속하는 이 서비스는 rent() 메서드를 실행하기 위해 RentalTarget 객체를 요구하고 있습니다. 이는 인터페이스, 즉 이 메서드를 호출하는 클라이언트와의 약속입니다. 종종 어댑터인 컨트롤러의 매개변수가 그대로 애플리케이션이나 도메인 쪽으로 넘어오는 사례가 있습니다. 컨트롤러가 아래와 같이 호출하는 경우입니다.

public class RentalController {
     
    private final TotalRentalService totalRentalService;
 
    // ...
    public Response<RentalHistoryView> rent(@RequestBody RentParam param) {
        // ...
        totalRentalService.rent(param); // 애플리케이션이 어댑터를 알게 되는 상황
        // ...
    }
}

totalRentalService.rent()에 param을 넣는 것을 보니 rent()의 시그니처는 RentalHistory rent(RentParam param)입니다. 클라이언트와 컨트롤러 사이의 인터페이스가 컨트롤러와 애플리케이션 간의 인터페이스로 스며 들었습니다. 이런 상황은 포트와 어댑터가 구분되어 있다고 할 수 없습니다. 클라이언트의 인터페이스가 애플리케이션까지 변경할 수 있습니다. 결합도가 높은 상황이죠. 여기에 RPC 서비스를 새로 붙여서 애플리케이션에 연동한다고 생각해 봅시다. RPC 서비스는 주로 IDL을 사용하고 DTO를 별도로 생성합니다. 아마 RentParam을 사용하지 않을 테지만, 애플리케이션을 사용하기 위해 RPC 서비스에선 굳이 RentParam을 생성하여 매개변수로 사용해야 합니다. 이때 HTTP 어댑터인 컨트롤러에서 요구 사항이 변경되어 RentParam을 변경해야 한다면, 애플리케이션뿐만 아니라 RPC 서비스까지 변경해야 합니다.

포트와 어댑터 설계에선 어댑터가 애플리케이션을 일방적으로 알고 있기 때문에 어댑터가 애플리케이션에 맞춰야 합니다. 아래는 RentParam을 RentalTarget으로 변경하여 메서드를 호출하는 예제입니다.

public class RentalController {
     
    private final TotalRentalService totalRentalService;
    // ...
 
    public Response<RentalHistoryView> rent(@RequestBody RentParam param) {
        // ...
        totalRentalService.rent(param.toRentTarget());
        // ...
    }
}

부포트와 어댑터 역시 크게 다르지 않습니다. 예제 코드의 다른 부분을 살펴보겠습니다.

public class TotalRentalServiceImpl implements TotalRentalService {
 
    private final CustomerRepository customerRepository;
    private final RentalRepository rentalRepository;
    private final InventoryService inventoryService;
    private final RentalHistoryRepository rentalHistoryRepository;
 
    // ...
 
    public RentalHistory rent(RentalTarget target) {
        //...
 
        Item rentedItem = inventoryService.rent(rental, borrower)
                                          .orElseThrow(AlreadyRentedException::new);
        // ...
        return history;
    }
}

inventoryService에 HttpInventoryService라는 어댑터를 주입했다고 가정해 봅시다. 우리는 흔히 네트워크를 통해 하나의 서비스에서 다른 서비스를 호출하며 이때 주로 HTTP 인터페이스를 사용합니다. 그런데 처음 서비스를 연동할 때에는 API가 추가되거나 변경된다고만 생각할 뿐, 연동하는 서비스 자체가 바뀔 거라는 생각은 잘 하지 않습니다. 그래서 연동한 서비스의 DTO를 바로 반환하는 일이 종종 있습니다. 아래 코드를 보겠습니다.

public class HttpInventoryService implements InventoryService {
    // ...
 
    @Override
    public Optional<StoredItem> rent(Rental rental, Customer borrower) {
        // ... HTTP 통신
        // ... JSON 역직렬화
        return Optional.of(storedItem);    
    }
}

HttpInventoryService는 HTTP를 이용해 받아 온 JSON을 역직렬화하여 StoredItem 객체를 만듭니다. 예제와 같이 애플리케이션 계층에서 사용하는 Item이 아니라 외부 InventoryService에서 얻어 온 StoredItem을 반환하는 경우가 많습니다. 그런데 어느 날 InventoryService를 고도화하여 향상된 InventoryService가 출시되었고, 모든 클라이언트에게 API를 변경할 것을 요구했습니다. 만약 포트와 어댑터 설계를 잘 따랐다면, 어댑터를 하나 더 생성하여 HttpInventoryService를 대체하면 됩니다. 하지만 새로 생성한 어댑터에서는 더 이상 StoredItem을 사용할 수 없습니다. 향상된 InventoryService에서 제공하는 JSON의 구조가 아래와 같이 변경되었기 때문이죠.

// 기존 JSON
{ "itemId": "ID", "itemStatus": "AVAILABLE", "rentalId": "RID", rentalName": "NAME" }
// 향상된 JSON
{ "item": { "id": "ID", "status": "AVAILABLE" }, "rental": { "id": "RID", "name": "NAME" } }

이렇게 되면 DTO를 변경해야 하고, 결국 애플리케이션 영역에 있는 TotalRentalService에도 영향을 줍니다. 이를 방지하기 위해서는 주어댑터와 마찬가지로 부어댑터가 부포트를 준수하면 됩니다.

public class HttpInventoryService implements InventoryService {
    // ...
 
    @Override
    public Optional<Item> rent(Rental rental, Customer borrower) {
        // ... HTTP 통신
        // ... JSON 역직렬화
        return Optional.of(storedItem.toItem());
    }
}

포트와 어댑터 설계를 따랐다면 향상된 InventoryService가 JSON 구조를 바꿨다고 해도 걱정할 필요 없습니다. 담고 있는 요소가 변경되지 않는 이상, 새로운 어댑터를 추가하는 것만으로도 기반 요소 변경에 쉽게 대응할 수 있습니다. 새로 추가된 어댑터는 여전히 JSON을 역직렬화하여 Item 객체를 만들 수 있고, 데이터를 애플리케이션에 제공할 수 있습니다.

이해를 돕기 위해 단순한 예제를 사용하여 설명했습니다. 하지만 주고받는 데이터 형태에만 신경 써선 안됩니다. 가령 MyBatis를 쓰고 있다고 해서 Repository의 인터페이스를 queryList()와 같이 작성하면 이 인터페이스는 애플리케이션이 아니라 MyBatis에 의존하게 됩니다. 저장소를 Redis로 바꾸게 되면 queryList()는 어색한 인터페이스로 남습니다. 이 메서드를 commandList()로 바꿔야 할 것 같지만 그러기 위해선 애플리케이션이나 도메인 영역을 함께 변경해야겠죠. 따라서 인터페이스 자체를 어느 한쪽에 치우치게 설계하지 말고 도메인 관점에서 도메인이 필요로 하는 인터페이스를 설계해야 합니다.

 

테스트

포트와 어댑터 설계로 만든 애플리케이션은 테스트하기가 매우 쉽습니다. 업무 로직을 포트가 감싸고 있기 때문에 모의 어댑터를 붙여 애플리케이션을 쉽게 구동해 볼 수 있어서 단순하게 테스트할 수 있습니다. 위에서 봤던 예제를 다시 소환해 보겠습니다.

public class TotalRentalServiceImpl implements TotalRentalService {
 
    private final CustomerRepository customerRepository;
    private final RentalRepository rentalRepository;
    private final InventoryService inventoryService;
    private final RentalHistoryRepository rentalHistoryRepository;
 
    public TotalRentalServiceImpl(CustomerRepository customerRepository,
                                  RentalRepository rentalRepository,
                                  InventoryService inventoryService,
                                  RentalHistoryRepository rentalHistoryRepository) {
        this.customerRepository = customerRepository;
        this.rentalRepository = rentalRepository;
        this.inventoryService = inventoryService;
        this.rentalHistoryRepository = rentalHistoryRepository;
    }
 
    @Override
    public RentalHistory rent(RentalTarget target) {
        Customer borrower = customerRepository.find(target.customerId())
                                              .orElseThrow(() -> new NotFoundException(target.customerId()));
        Rental rental = rentalRepository.find(target.rentalId())
                                        .orElseThrow(() -> new NotFoundException(target.rentalId()));
        Item rentedItem = inventoryService.rent(rental, borrower)
                                          .orElseThrow(AlreadyRentedException::new);
        RentalHistory history = RentalHistory.of(UUID.randomUUID().toString(),
                                                 RentalSpec.of(borrower, rental),
                                                 rentedItem);
        rentalHistoryRepository.save(history);
        return history;
    }
}

위 애플리케이션 서비스는 네 개의 포트를 이용하고 있습니다. 세 개의 Repository와 하나의 Service는 내부가 어떻게 구성되어 있는지 모릅니다. 저장소로 MySQL을 사용할 수도 있고 Redis를 사용할 수도 있습니다. Service는 RPC를 이용할 수도, HTTP를 이용할 수도 있습니다. 하지만 그런 사항들을 몰라도 애플리케이션 서비스를 실행하는 데에는 아무런 문제가 없습니다. 아래는 의사 코드로 테스트 코드를 작성한 것입니다.

@Test
fun `rent should return a history`() {
    val customer = Customer("CUSTOMER_ID")
    val rental = Rental("RENTAL_ID")
    val item = Item()
    var saved: RentalHistory
 
    // 모의 어댑터를 준비합니다.
    val customerRepository = CustomerRepository {
        override fun find(id) = customer
    }
    val rentalRepository = RentalRepository {
        override fun find(id) = rental
    }
    val inventoryService = InventoryService {
        override fun rent(rental, customer) = item
    }
    val rentalHistoryRepository = RentalHistoryRepository {
        override fun save(history) {
            saved = history
        }
    }
 
    // 테스트할 객체를 준비하고
    val service: TotalRentalService = TotalRentalServiceImpl(customerRepository, rentalRepository, inventoryService, rentalHistoryRepository)
 
    // 테스트할 대상을 실행합니다.
    val result = service.rent(RentalTarget("CUSTOMER_ID", "RENTAL_ID"))
 
    // 결과를 검증합니다.
    assertNotNull(result)
    assertNotNull(saved)
    assertEquals(saved, result)
    assertEquals(customer, result.rentalSpec.borrower)
    assertEquals(rental, result.rentalSpec.rental)
    assertEquals(item, result.rentedItem)
}

단위 테스트는 Whitebox 테스트이므로 각각의 모의 어댑터를 실행했을 때 어떻게 동작하고 어떤 값을 반환하는지 예상할 수 있습니다. 그러므로 모의 어댑터에 기대하는 동작을 정의하고 실제 서비스를 실행한 다음, 기대했던 결과와 일치하는지 확인할 수 있습니다.

그런데 이때 애플리케이션의 저장소로 MySQL을 사용했고 Repository가 MySQL에 강하게 결합하고 있다면 어떻게 될까요? 같은 코드를 테스트하기 위해선 개발 장비에 MySQL을 설치하고 애플리케이션이 동작할 수 있는 스키마로 테이블을 생성한 뒤 모의 데이터까지 삽입하고 나서야 테스트를 실행할 수 있습니다. 물론 모의 테스트 프레임워크를 사용하면 결합도 높은 클래스라도 쉽게 모의 객체를 만들어 주긴 합니다. 하지만 그렇다고 하더라도 클래스의 결합도가 높다면 단위 테스트를 할 때 ‘MySQL 쿼리에 오류가 있으면 어떡하지?’와 같은 고민을 하게 될 수 있겠죠. 그런 일이 늘어나면 결국 단위 테스트는 통합 테스트라는 산으로 가게 됩니다. 포트와 어댑터 설계를 따르면 이런 고민을 하지 않아도 됩니다. 업무 로직은 포트만 알면 됩니다.

 

마치며

여러분께서 맡고 있는 애플리케이션은 분명히 변합니다. 반드시 바뀌고 필히 확장됩니다. 그럴 때마다 포트와 어댑터 설계는 여러분이 어디를 수정해야 할지 직관적으로 알려주고, 무엇을 바꿔야 할지 고민할 시간을 줄여주기도 하며, 수정사항을 쉽게 적용할 수 있게 해줄 겁니다. 특히 인터페이스를 바꾸거나 저장소 또는 미들웨어를 바꿔야 할 때 업무 로직 속에서 관련 코드를 찾아 헤매는 어마어마한 시간과 어댑터를 하나 만들어서 추가하는 찰나의 차이는 정말 정말 클 것이라고 생각합니다. 기회가 된다면 포트와 어댑터 설계에 대해 많은 관심 가져주시고 꼭 실무에 적용해 보시기 바랍니다.