클린 아키텍처 보충 1 — 포트와 어댑터, 의존성 규칙

Junha Baek
junhabaek
Published in
16 min readJun 10, 2022

--

시리즈 소개

이 시리즈에서는 클린 아키텍처 책의 내용중 5부 22장 ‘클린 아키텍처’, 6부 전반적인 내용들 중 짧게 설명되어진 부분을 보충하거나, Spring Framework나 DDD와 함께 적용할 때 고려해야 할 점을 보충합니다.

클린 아키텍처 포스팅 시리즈는 6개의 포스팅으로 나누어 작성될 예정입니다.

  1. 클린 아키텍처 보충 1 — 포트와 어댑터, 의존성 규칙
    포트와 어댑터를 예시를 통해 상세히 설명하고, 클린 아키텍처의 핵심 이론과 의존성 규칙을 간략히 설명합니다.
  2. 클린 아키텍처 보충 2 — 엔티티, 도메인 주도 설계
    Enterprise Business Rule의 의미, EBI approach의 이해, 클린 아키텍처 엔티티와 DDD 엔티티와의 차이점을 설명합니다.
  3. 클린 아키텍처 보충 3 — 엔티티와 ORM(Hibernate/Spring)
    엔티티에 ORM Annotation을 사용하는게 만들 수 있는 문제와 해결책을 고민해봅니다.
  4. 클린 아키텍처 보충 4— 유즈케이스
    Input Port로 유즈케이스를 노출하는 이유, 도메인 서비스와 애플리케이션 서비스의 차이 등을 설명합니다.
  5. 클린 아키텍처 보충 5 — 어댑터
    Output Port로써의 Repository, Adapter 영역에서의 Repository를 깊게 설명합니다. Repository에 ORM Annotation을 사용할 때 유의할 점들을 알아봅니다.
  6. 클린 아키텍처 보충 6— 패키징 전략, 테스팅
    실제로 패키지 구조를 만들어보며, 클린 아키텍처의 여러가지 적용 수준에 대해서 설명합니다.

유의사항

  • 클린 아키텍처의 전체 내용을 커버하지는 않습니다.
  • 클린 아키텍처의 구성 요소중 프레임워크 영역은 엔티티, 유즈케이스, 어댑터 설명 시 포함됩니다.
  • 클린 아키텍처가 아키텍처로 언급될 때와, Robert C Martin의 책으로 언급될 때가 있습니다.
    대부분의 경우에는 아키텍처로 언급되기 때문에 책으로 언급되는 경우에만 특별히 ‘클린 아키텍처(책)’으로 표기합니다.
  • 예시로 주로 사용되는 언어/프레임워크는 Java/Spring이며, 간혹 Python 진영의 Django/Flask 예시가 사용될 수 있습니다.

0. 이 포스팅에서는?

이 포스팅에서는 크게 의존성 규칙, 클린 아키텍처, 포트와 어댑터에 대해서 설명합니다. 자세한 내용은 아래 목차를 참고해주세요.

0–1. 목차

시리즈 소개
유의사항
0. 이 포스팅에서는?
0–1. 목차

1. 클린 아키텍처
1–1. The Clean Architecture
1–2. 의존성 규칙

2. 포트와 어댑터
2–1. 포트(Port)와 어댑터(Adapter)?
2–2. 소프트웨어 영역에서의 포트와 어댑터
2–3. 포트와 어댑터 코드 예시
2–4. 영속성 외에도 적용이 가능한 포트-어댑터

3. 포트와 어댑터가 의존성 규칙을 보조하는 방법
3–1. 의존성 규칙이 적용되지 않는 상황
3–2. 의존성 규칙을 위해 포트, 어댑터 도입

4. 마치며

5. 참고 자료 정리
5–1. 서적
5–2. 포스팅

1. 클린 아키텍처

1–1. The Clean Architecture

클린 아키텍처(책)의 전반부가 설명하는 좋은 아키텍처의 토대인 절차지향, 객체지향 등의 프로그래밍 패러다임, 객체지향 SOLID 원칙도 중요하지만 대부분은 클린 아키텍처(책)중 핵심으로 다음 그림을 가장 먼저 떠올릴 것입니다.

https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html

클린 아키텍처는 관심사를 분리하기 위해, 위 그림처럼 소프트웨어의 여러 영역들을 동심원의 형태로 분할합니다. 위 그림에서 나타나듯이 전체 영역은 크게 엔터프라이즈 비즈니스 규칙(황색), 애플리케이션 비즈니스 규칙(적색), 인터페이스 어댑터(녹색), 프레임워크 & 드라이버(청색) 4가지 영역으로 구분됩니다.

1–2. 의존성 규칙

클린 아키텍처에서 모든 구성 요소들은 의존성 규칙에 따라 안쪽 구성요소에 의존하도록 만들어져야 합니다. 특히 엔티티와 유즈케이스는 주요 비즈니스 규칙들을 가지고 있기 때문에, 비즈니스적인 이유로 코드나 구조 변경에 있어서의 장애물들을 최소화 해야 합니다.

의존성 규칙이 아키텍처 내에서 지켜질 때 주로 다음과 같은 이점을 가질 수 있습니다.

  1. 비즈니스 규칙들이 데이터베이스의 종류, 데이터베이스 활용 방식 등에 영향받지 않는 데이터베이스 독립성을 얻게 됩니다.
  2. 비즈니스 규칙들이 데이터베이스, UI 외에도 다른 외부 의존성들로부터 영향을 받지 않는 외부 에이전시 독립성을 얻게 됩니다. 예를 들면, 메시징 기능을 구현할 때, 비즈니스 규칙이 RabbitMQ나 Kafka등의 특정 Messaging Infra에 결합되지 않을 수 있습니다. 또한 외부에서 제공하는 API를 활용할 때에도, 비즈니스 규칙이 그 API의 제공자나 요청 형식에 결합되지 않을 수 있습니다.
  3. UI, DB 등 외부 요소 없이도 테스트가 가능해지는 테스트 용이성을 얻게 됩니다.
    뒤에서 설명되겠지만, 의존성 규칙은 의존성 역전과 포트와 어댑터를 이용해 달성 되기 때문에, 유즈케이스를 테스트 할 때, 실제 DB 구현체(어댑터)가 아닌 유즈케이스 관점에서 미리 정의한 인터페이스(포트)를 구현하는 임시 객체를 만들어 테스트할 수 있습니다.
  4. 전반적인 코드의 가독성이 좋아진다.
    가독성의 개선은 의존성 규칙만이 아니라, 관심사의 분리로 대부분 얻어지게 됩니다. 하지만, 의존성 규칙이 지켜지지 않는다면, 코드 내에는 여전히 다른 관심사에 영향을 받는 상태가 되므로 이를 의식하며 읽을 수 밖에 없습니다.

2. 포트와 어댑터

클린 아키텍처는 의존성 규칙을 지키기 위해 Port와 Adapter 개념을 도입합니다.

2–1. 포트(Port)와 어댑터(Adapter)?

일상생활에서도 전자 기기를 사용할 때 포트, 어댑터라는 용어를 자주 들어보셨을 것입니다.

image : https://www.geeksforgeeks.org/input-output-ports/

포트의 의미를 다시 생각해보면, 포트는 장치를 연결하기 위한 규격(인터페이스)입니다. 포트의 목적이 다르다면 포트의 모양도 다릅니다. 포트를 제공하는 장치(노트북)에서는 이 포트에 또 다른 장치(이어폰)가 연결되었을 때, 이 포트의 규격에 따라 알맞은 드라이버(오디오 드라이버)를 실행하여 장치를 동작시킵니다.

이번에는 어댑터의 의미를 다시 생각해보면, 어댑터는 규격이 다른 두 장치를 연결해 작동할 수 있도록 해주는 결합도구입니다.

https://www.lge.co.kr/care-accessories/laptop/eay65008701

노트북 전원 어댑터를 생각해보면, 일반적인 가정용 전기는 220V이지만, 노트북(제가 사용중인)이 사용하는 전압은 19V입니다. 만약 노트북에 220V 전압의 전기를 그대로 흘려보낸다면 바로 고장이 날 것입니다.

이것을 변환해주는 것이 어댑터로 원하지 않던 형태/성질에서 원하는 형태/성질로 바꾸어주는 역할을 한다고 볼 수 있습니다.

2–2. 소프트웨어 영역에서의 포트와 어댑터

그렇다면 이 개념을 소프트웨어 영역에 적용한다는 것은 무슨 의미일까요?

엔티티와 유즈케이스는 애플리케이션의 핵심 로직을 담고 있기 때문에, 클린 아키텍처에서 동심원의 가장 안쪽을 차지하고 있습니다. ChangePriceUsecase는 사용자가 책의 가격을 바꾸는 사용사례를 정의하고 있으며, BookService는 이 유즈케이스를 구현한 구현체에 해당합니다.

때때로 유즈케이스를 수행할 때, 영속성, 메시징, 외부 API 등 외부 기능의 도움을 받아야 할 때가 있습니다.

예를 들어 BookService에서는 책의 가격을 갱신한 후 이 상태를 유지해야 합니다. 이 때, 엔티티와 유즈케이스 입장에서는 핵심 로직 영역이 외부 기능에 오염되기를 바라지 않습니다. 예를 들면, 유즈케이스 입장에서는 어떤 종류의 DB를 사용하는지, 어떤 종류의 Messaging 인프라를 사용하는지에 영향 받지 않기를 원합니다.

만약 노트북에 이어폰을 연결하는데 이어폰의 규격이 회사마다 전부 달랐다면, 각 노트북은 특정 회사 이어폰만을 사용할 수 있는 아주 의존적인 관계가 될 가능성이 높습니다. 이에 노트북은 이 규격을 만족하는 이어폰이라면 모두 연결할 수 있는 오디오 포트를 제공합니다.

2–3. 포트와 어댑터 코드 예시

마찬가지로, 유즈케이스 영역에서는 ‘유즈케이스 관점’에서 자신이 원하는 기능을 포트(인터페이스)로 정의합니다. 어댑터는 이 포트의 규격을 준수하도록 정의됩니다.

  1. 위 그림과 같이 BookService(ChangePriceUsecase)는 책의 갱신 내용을 저장하기 위해 다음과 같이 UpdateBookPort를 정의합니다.

참고로 유즈케이스 영역에서 다루고 있는 Book 객체는 Jpa 관련 annotation들이 없는 순수한 엔티티라고 가정합니다.(annotation을 사용하는 순수하지 않은 엔티티는 JpaBook으로 구분하며, 자세한 설명은 다음 포스팅 ‘엔티티’를 참고해주세요.)

2. BookService는 자신의 Book 객체를 ‘메서드 호출’이라는 방법으로 다음과 같이 자신이 가지고 있던 Book 인스턴스를 UpdateBookPort에 내보냅니다.

3. JpaBookPersistenceAdapterUpdateBookPortJpaRepository 사이의 규격(인터페이스) 차이를 메꾸어줍니다. 이 어댑터는 ‘port interface의 구현(implements)’이라는 방법으로, 다음과 같이 자신이 해당 포트의 규격을 받아들일 수 있음을 명시합니다.

어댑터는 UpdateBookPort가 전달한 Book 객체를 BookRepository가 이해할 수 있는 형태인 JpaBook으로 변환합니다.(어댑터의 핵심 목적) JpaBook 객체는 각종 ORM annotation들을 이용해 정의된 상태입니다.

4. JpaBookPersistenceAdapterBookRepository에 변환이 완료된 JpaBook 객체를 전달하여 JpaBook의 내용을 저장하도록 요청합니다.

5. BookRepositoryJpaBook 객체를 받아 내부에서 객체를 저장합니다.

이와 같이 포트와 어댑터 패턴을 적용함으로써, 유즈케이스 영역은 외부 요소가 어떤 기술로 정의되어있는지, 그것이 어떻게 동작하는지를 전혀 알 필요가 없게 되었습니다. 이는 유즈케이스의 수정에 있어서 외부 요소를 의식하지 않아도 된다는 이점과, 유즈케이스 테스팅이 용이해진다는 이점을 가져옵니다.

참고 : 종종 Port-Adapter 대신 Repository — RepositoryImpl의 관계로 구성할 수 있습니다. Repository를 유즈케이스에 노출시키는 대신 Port- Adapter를 적용하는 이유에는 여러가지가 있지만 우선 “Repository가 가지는 너무 넓은 interface를 피하여, 테스트를 용이하게 한다”는 이유가 핵심 이유입니다. 또다른 이유들과 Repository 인터페이스를 Port로 사용하는 경우들은 시리즈 3편인 어댑터에서 상세히 다루어질 예정입니다.

2–4. 영속성 외에도 적용이 가능한 포트-어댑터

포트와 어댑터 패턴은 영속성 외에도 유즈케이스가 외부 요소를 필요로 할 때, 적용할 수 있습니다.

마찬가지로 책이 갱신되었다는 정보를 외부에 전달하고 싶을 때, RabbitMq/Kafka와 같은 메시징 인프라를 사용하게 됩니다. 위 그림에서는 책의 갱신이벤트를 전달하기 위해 유즈케이스 영역에 SendBookUpdatedPort를 정의합니다. 이 Port는 여러 어댑터에 의해 구현되기 때문에, 유즈케이스 입장에서는 어댑터가 어떤 기술을 기반으로 메시징을 수행하는지 신경 쓸 필요가 없습니다.

3. 포트와 어댑터가 의존성 규칙을 보조하는 방법

포트와 어댑터가 어떤 역할을 하는지, 어떻게 정의할 수 있는지 알아보았습니다. 여기에서는 포트와 어댑터가 어떤 식으로 의존성 규칙을 보조하게 되는지 살펴보겠습니다.

3–1. 의존성 규칙이 적용되지 않는 상황

실행 순서는 다음과 같습니다.

  1. BookController는 사용자의 요청(http)을 수신해 애플리케이션이 이해할 수 있는 형태(BookCommand)로 변환합니다.
  2. Book컨트롤러는 BookService.changePrice메서드 호출을 통해 BookService(ChangePriceUsecase)에 요청 내용을 전달합니다.
  3. BookServiceBook 인스턴스의 changePrice 메서드를 호출합니다.
  4. Book 인스턴스는 changePrice 메서드 내에서 Book의 가격을 변경합니다.
  5. BookService는 변경된 Book 인스턴스를 저장하기 위해 JpaBookRepository를 호출합니다.

먼저 Book 엔티티에 각종 Jpa annotation이 달려있는 경우라면, 클린 아키텍처상 다음과 같이 표현됩니다.

Book 엔티티는 핵심 도메인 로직을 가지고 있기 때문에 엔티티 영역이라고 볼 수 있지만, 데이터베이스의 종류가 한정되고, 어떻게 저장되는지에 대한 annotation들도 가지고 있기 때문에, 적어도 어댑터 영역까지 걸쳐있는 애매한 상태입니다.(위치는 정확하지 않을 수 있지만, 엔티티의 입장이 애매하다는 것은 분명합니다.)

위 그림에서 핵심 업무 규칙을 담고 있는 Book 엔티티가 데이터베이스와 관련된 세부사항에 결합되어 있고, 응용 업무 규칙을 담고 있는 BookService 또한, 이 Book 엔티티를 의존하여 데이터베이스와 관련된 세부사항에 간접적으로 영향받을 수 있는 불안한 상태입니다.

참고 : 이런 문제를 허용하고, 여전히 Book 엔티티에 Annotation을 적용하여 사용할 수 있습니다. 이것은 잘못된 것이 아니라 ORM이 제공하는 빠르고 간단한 개발이라는 이점(생각보다 많이 중요한)을 더 우선시 한 상황입니다. 하지만 현재 포스팅의 맥락에서는 ‘의존성 규칙의 준수’를 더 우선적으로 고려하고자 합니다. 다음 포스팅인 ‘엔티티’나 ‘어댑터’에서는 이 문제를 더 깊이 다룰 예정입니다.

만일 Book을 순수한 엔티티로 유지하고, ORM용 JpaBook을 분리한다면 아래와 같은 그림이 됩니다.

BookController —> BookService —> Book까지는 의존성 규칙이 적용되어있습니다. 이 덕분에 Book 엔티티 입장에서는 Book 의 도메인 로직들을 어떤 맥락(누가 요청했는지 등)에서 동작하는지 신경 쓸 필요가 없습니다. 또한 BookService 입장에서는 요청이 어떤 방식으로 들어왔는지를 신경 쓸 필요가 없습니다.

하지만, BookServiceJpaBookRepository를 호출함으로써, 의존성의 방향이 외부로 향하게 되고, BookService는 저장 방식에 의존적인 상태가 됩니다. 이는 JpaBookRepository가 다음과 같이 특정 저장 기술과 관련된 클래스를 상속하거나 관련 애너테이션(Query등)을 가지고 있기 때문입니다.

또한, JpaRepository 애너테이션은 flush, deleteAllInBatch등 Jpa환경에서만 사용되는 메서드들을 포함하고 있기 때문에, 이 메서드들이 자연스럽게 JpaBookRepository에도 노출됩니다.

3–2. 의존성 규칙을 위해 포트, 어댑터 도입

BookServiceJpaBookRepository 사이에 의존성 규칙을 보장하기 위해 포트와 어댑터를 도입해보겠습니다.

조금 전의 포트 도입 전 상황에서는 서비스가 JpaBookRepository에 대한 의존성을 가지고 있어, 의존성 규칙을 만족하지 않았었습니다.

하지만 아래 그림에서는 응용 업무 규칙 영역에 UpdateBookPort를 정의하여, BookService 입장(더 정확히는 유즈케이스 입장)에서 자신이 필요로 하는 영속성 기능의 spec을 정의합니다.

어댑터 영역에서는 이 포트를 구현(implements) 함으로써, 포트에 맞는 기능을 구현합니다. 이 어댑터 내부에서는 포트가 요구하는 기능만 만족한다면 Jdbc, Jpa, MongoDB, Redis 등 원하는 영속성 기술을 사용할 수 있습니다.

이 덕분에 의존성의 방향은 영속성 어댑터(어댑터 영역) → Output 포트(유즈케이스 영역)의 방향으로 바뀌게 되고 의존성 규칙을 만족하게 됩니다.

참고 : Port, Adapter 설명에도 언급했듯이, Port — Adapter는 Repository & RepositoryImpl로 만들어질 수도 있습니다. 또한, RepositoryImpl을 프레임워크/드라이버 영역에 놓아야 한다는 의견도 있는데, 이는 3번째 포스팅인 어댑터 보충에서 설명드리도록 하겠습니다.

4. 마치며

이 포스팅에서는

  1. 클린 아키텍처가 4가지 영역으로 나뉘어 있음을 확인했습니다.
  2. 이 구성 요소들 사이에는 의존성 규칙이 지켜져야 한다는 것을 설명했습니다.
  3. 이 의존성 규칙을 위해 포트와 어댑터 개념이 필요하다는 것을 설명했습니다.

다음 포스팅부터는 엔티티, 유즈케이스, 어댑터 등 클린 아키텍처의 주요 구성 요소들을 Java, Spring으로 실제로 구현해보며, 이들을 구현함에 있어 어떤 것들을 고려해야 하는지 설명드리도록 하겠습니다.

다음 포스팅

5. 참고 자료 정리

5–1. 서적

  • 클린 아키텍처 22장 — Robert C. Martin
    클린 아키텍처, 의존성 규칙의 개요 참고
  • 만들면서 배우는 클린 아키텍처 2장, 4장 — Tom Hombergs
    InputPort, OutputPort 디자인 참고

5–2. 포스팅

  • Ports and Adapters Architecture — JEFFREY VERRECKT ( 참고 )

수정 이력

  • 2022/06/16 : 각 단락에 번호를 붙여 가독성 개선, Entity와 ORM Annotation을 함께 사용하는 상황에 대한 설명이 다른 포스팅에서 이루어 진다는 언급 추가

--

--