7 분 소요

만들면서 배우는 클린 아키텍쳐 3장 정리

코드 구성하기

처음에 프로젝트를 할 때 가장 먼저 신경쓰는 것이 패키지 구조입니다.

그래서 처음에 공들여서 패키지 구조를 만듭니다.

하지만 시간이 지남에 따라 다른 패키지에서 접근을 하면 안되는 패키지에 접근을 하게됨으로써 패키지 구분이 무의미해지는 경우가 발생합니다.

상황이 이 지경까지 오게 되면 처음에 공들여 만들어 놓은 패키지 구조는 그저 허울 좋은 빈 껍데기가 되고 맙니다.

계층으로 구성하기

코드를 구조화하는 첫 번째 방법은 계층을 이용하는 것입니다.

웹 계층(web 패키지), 도메인 계층(domain), 영속성 계층(persistence)을 둡니다.

저도 지금까지 강의를 들으면서 패키지를 구성할 때 이용했던 가장 흔한(?) 또는 정석적인 방식인 것으로 생각합니다.

하지만 3가지 이유로 인해 계층 구조는 최적의 구조가 될 수 없습니다.

1. 계층 패키지 아래에 기능이나 특성으로 구분을 짓는 추가적인 패키지가 없습니다.

이러한 상황에서는 예를 들어, 사용자를 관리하는 기능을 추가한다면 web package에 UserController, domain package에 UserService, UserRepository, User를 추가하고 persistence package에 UserRepositoryImpl을 추가하게 됩니다.

여기에 계좌를 관리하는 기능을 추가하게 되면 Account와 관련된 모든 기능들을 각 package에 추가적으로 다 추가해줘야하는데 이렇게 되면 하나의 기능을 추가할 때마다 각 패키지에 여러 클래스들이 추가되기 때문에 각 패키지들이 엉망진창이 될 수 있습니다.

스프링 강의을 들을 때, 각 package 밑에 추가적으로 package를 생성하여 기능별로 구분을 해주니까 훨씬 깔끔하게 관리할 수 있었습니다.

2. 애플리케이션이 어떤 유스케이스들을 제공하는지 파악할 수 없습니다.

특정 기능을 찾기 위해서는 어떤 서비스가 이를 구현했는지 추측하고, 해당 서비스 내에서 어떤 메서드가 그 기능을 수행하는지 찾아내야 합니다.

하지만 이러한 단순한 계층형 패키지 구조에서는 이러한 것들을 추측하는 것이 힘듭니다.

3. 패키지 구조를 통해서는 우리가 목표로 하는 아키텍처를 파악할 수 없습니다.

그저 육각형 아키텍처 스타일을 따랐다는 가정하에 web과 persistence 패키지의 클래스들을 조사해 볼 수는 있습니다.

하지만 포트와 어댑터가 패키지에는 표현되어 있지 않고, 코드 속에 숨어 있기 때문에 패키지에 들어가서 직접 코드를 면밀히 살펴보지 않고, 패키지만 보고서는 프로젝트에서 육각형 아키텍처 구조를 제대로 파악할 수 없습니다.

기능으로 구성하기

기능으로 패키지를 구성하면 계층으로 구성한 패키지 구조의 문제점을 해결할 수 있습니다.

이렇게 되면 기능 패키지 하나에 controller, repository, service 등이 모두 들어가게 됩니다.

이렇게 되면 다른 기능 패키지에서 현재 기능 패키지에 접근하는 것을 막음으로써 패키지 간의 경계를 강화할 수 있고, 이로 인해 각 기능들 사이의 불필요한 의존관계를 없앨 수 있습니다.

역효과

그러나 기능에 의한 패키징 방식 역시 아까 계층형 패키징 방식에서 문제로 제기했던 어댑터나 포트를 나타내는 패키지명이 없습니다.

게다가 계층 구분없이 기능에 의해 분류된 패키지로 인해 한 계층에서 다른 계층으로 바로 접근을 하면 안되는 상황에서도 같은 패키지 내에 있기 때문에 구조상 접근이 가능하여 실수가 발생할 수 있습니다.

즉, 애초에 이러한 실수가 일어나지 않게 접근을 못하게 막아야 하는데 이러한 기능에 의한 패키지는 계층들이 기능별로 같은 패키지에 묶이기 때문에 구조적으로 접근을 막는 것이 힘들고 개발자가 스스로 실수를 하지 않고 잘 구분해서 사용을 해야 하기 때문에 개발자의 부담이 늘어납니다.

아키텍처적으로 표현력 있는 패키지 구조

육각형 아키텍처에서 구조적으로 핵심적인 요소는 엔티티, 유스케이스, 인커밍 포트와 어댑터, 아웃고잉 포트와 어댑터입니다.

우선 최상위 패키지에 기능을 나타내는 패키지를 만듭니다.

계좌 관련 기능을 나타내는 account패키지를 생성합니다.

기능을 나타내는 패키지인 account 아래에 계층형 패키지를 구성합니다.

계층형 패키지로 domain, adapter, application 패키지를 구성합니다.

포트와 어댑터란?

여기서 책을 아무리 읽어도 잘 이해가 되지 않아서 추가적으로 구글링을 해봤습니다.

그러다가 지속 가능한 소프트웨어 설계 패턴: 포트와 어댑터 아키텍처 적용하기글을 읽게 되었습니다.

이 글 역시 다 이해하지 못했고, 이 글을 읽어도 여전히 책을 완전히 다 이해할 수는 없었지만 그래도 많은 도움이 되어 이해한 내용을 위주로 적어보려고 합니다.

포트는 인터페이스를 의미합니다.

예를 들면, 자바에서 메소드의 시그니처(메서드 명과 파라미터의 순서, 타입, 개수)나 인터페이스를 의미합니다.

웹의 경우 MVC 패턴을 채택하였을 때 컨트롤러가 바로 어댑터 역할을 합니다.

즉, 어댑터는 클라이언트의 요청을 인터페이스(포트)와 연결해주는 역할을 합니다.

이렇게 외부에서 클라이언트가 요청을 해야만 동작하는 포트와 어댑터를 주요소(primary)라고 하며, 주포트, 주어댑터라고 부릅니다.

좀 더 구체적으로 설명하면 웹에서 클라이언트의 요청이 왔을 때 이를 처리하기 위해서는 결국에 최종적으로는 애플리케이션(서비스, 도메인)의 로직을 이용해야만 해결이 됩니다.

그러나 웹 계층에서 바로 애플리케이션에 접근하는 것은 유지보수관점에서 좋지 않고, 지금 저희가 공부하고 있는 육각형 아키텍처와도 맞지 않습니다.

그래서 컨트롤러가 웹과 애플리케이션 사이를 연결해주는 역할을 합니다.

컨트롤러는 애플리케이션의 인터페이스, 예를 들어 Service라는 인터페이스에 의존하여 클라이언트의 요청에 따라 Service(포트)의 메소드를 호출하면 됩니다.

아마 이 Service는 추상팩토리 메소드패턴에 의하여 사용자의 요청에 맞게 생성된 구현체를 담고 있을 것입니다.

하지만 컨트롤러는 이 Service가 어떤 구현체인지 알 수 없고, 알 필요도 없이 그냥 Service의 메소드를 호출하면 되는 것입니다.

그래서 컨트롤러는 외부와 애플리케이션 내부를 연결해주는 어댑트 역할을 합니다.

이 어댑트는 수시로 바뀔 수 밖에 없습니다.

왜냐하면 사용자는 비슷한 상황에서도 한 가지 요청만 하지 않을 것이기 때문입니다.

좀 더 구체적으로 설명하기 위해 제가 예전에 만들었던 윈도우 메모장을 예시로 들어보겠습니다.

(저는 아직 웹프로젝트를 구현해본적이 없어서 데스크톱 애플리케이션을 바탕으로 제가 아는 것과 연관지어 이해하려고 노력했습니다.)

사용자는 메모장에서 캐럿을 이동시키기 위해 방향키로 위, 아래, 왼쪽, 오른쪽을 누를 수 있습니다.

클라이언트가 방향키를 누르면 여기서 컨트롤러는 KeyAction이라는 인터페이스 하나에만 의존하면 됩니다.

추상팩토리 메소드 패턴을 이용해 사용자가 왼쪽을 눌렀으면 이를 매개변수로 받아서 LeftKeyAction을 생성하고 다형성을 활용해 KeyAction 타입으로 이를 반환받습니다.

그러면 컨트롤러는 이 keyAction이 left인지 right인지 up인지 down인지 모르고, 알 필요없이 오버라이딩된 메소드인 onKeyDown을 호출하면 됩니다.

그럼 KeyAction 구현체가 LeftKeyAction이면 캐럿이 왼쪽으로 이동할 것입니다.

그리고 이는 기능 추가면에서도 굉장히 좋습니다.

예를 들어 단어단위 왼쪽 및 오른쪽이동이나 줄단위 왼쪽 및 오른쪽이동을 하는 구현체를 만들고 이를 추가하더라도 컨트롤러의 코드는 전혀 손댈 것이 없이 아까처럼 keyAction의 onKeyDownd을 호출해주기만 하면 됩니다.

책에서는 외부로부터 값을 전달받는 포트를 인커밍(incoming)포트라고 하는데, 주포트와 같은 의미인 것 같습니다.

Service라는 인터페이스가 있고, 이를 구현하는 ServiceImpl에서 Repository 인터페이스를 이용해 로직을 수행한다고 가정해봅시다.

Repositry는 ServiceImpl이 사용할 인터페이스를 제공하고 있기 때문에 포트입니다.

그리고 ServiceImpl은 다형성으로 인해 Repository의 구현체가 무엇인지 알 필요가 없이 그냥 사용하면 됩니다.

여기서 Redis를 이용한다고 하면 Repository의 구현체는 RedisRepository가 됩니다.

이 Repository의 구현체인 RedisRepository는 Repository 인터페이스(포트)를 구현하면서 내부적으로는 Redis와 연결해주는 역할을 하고 있기 때문에 어댑터입니다.

이처럼 애플리케이션이 호출하면 동작하는 포트와 어댑터를 부요소(secondary)라고 하며, 부포트, 부어댑터라고 부릅니다.

책에서는 외부로 값을 내보내는 포트를 아웃고잉(outgoing) 포트라고 하는데 부포트와 같은 의미인 것 같습니다.

아까 primary, incoming에서는 외부(클라이언트)에서 먼저 요청이 오면 어댑터가 이를 포트와 연결해서 내부 애플리케이션으로 흘러 들어가는 것과는 반대로, secondary, outgoing은 애플리케이션 내부에서 호출을 하여 포트를 통해 어댑터에 전달되고, 어댑터가 Redis와 같은 외부 요소(값을 출력하는 역할)와 포트를 연결해주는 역할을 합니다.

어댑터는 애플리케이견을 직접 참조하지 않고, 포트에 의존합니다.

즉, 애플리케이션과 직접 연결되는 것은 포트입니다.

그래서 포트의 역할을 변경이 잦은 어댑터와 변경이 상대적으로 잦지 않은 애플리케이션의 결합도를 낮추는 역할을 합니다.

이로 인해 어댑터를 바꾸도 애플리케이션의 로직은 손댈 필요가 없어지는 것입니다

즉, 예를 들어 Repository는 인터페이스(포트)이고, Service가 Repository를 멤버로 가지고 있습니다.

Service에서는 다형성으로 인해 Repository의 구현체는 알 필요없이 Repository의 메소드를 호출하여 필요한 로직을 수행합니다.

그리고 현재 Repository의 구현체는 예를 들어 메모리 방식인 InMemoryRepository입니다.

이는 앞에서 말한 어댑터 역할을 수행합니다.

즉, Repository(포트)와 메모리(외부자원) 사이에서 연결해주는 역할을 하기 때문입니다.

그런데 아까 앞에서 언급했듯이 어댑터는 변경이 잦을 수 있습니다.

즉, 이제 인메모리 방식으로 하면 프로그램을 종료하면 데이터가 휘발되니까 그게 싫어서 데이터베이스에 저장하는 방법으로 바꿀 수 있습니다.

그렇게 되면 이제 DataBaseRepository라는 클래스를 만들고 Repository를 implement(구현)합니다.

그러면 이제 어댑터는 InMemoryRepository에서 DataBaseRepository로 변경됩니다.

하지만 애플리케이션(Service)의 로직은 아무것도 손댈 것이 없습니다.

왜냐하면 Service는 Repository라는 인터페이스(추상화, 포트)에 의존하고 있지 어댑터(구체화)에는 의존하고 있지 않기 때문입니다.

Service는 Repository의 구현체가 InMemoryRepository에서 DataBaseRepository인지 알 수 없고, 알 필요도 없이 그냥 Repository의 메소드를 실행하면 됩니다.

이 내용은 제가 김영한님의 스프링 강의를 보면서 공부했던 내용과 비슷한 것 같아서 이를 적용하여 설명했습니다.

다시 책으로 돌아가서(아키텍처적으로 표현력 있는 패키지 구조)

다시 책에서 설명했던 패키지 구조를 설명하면 최상위 패키지가 account이고, 그 아래에 3개의 패키지가 있는데 adapter, domain, application이 있습니다.

adpter패키지 밑에는 in과 out 패키지가 있고, in에는 web패키지 out에는 persistence 패키지가 위치합니다.

web 패키지 밑에는 AccountController라는 클래스가 위치하고, persistence 패키지 밑에는 AccountPersistenceAdapter와 SpringDataAccountPolicy가 위치합니다.

domain 패키지 밑에는 따로 패키지 없이 바로 Account와 Activity라는 클래스가 있습니다.

application 패키지에는 SendMoneyService라는 클래스가 있고, port라는 패키지가 있습니다.

port라는 패키지 밑에는 다시 in과 out이라는 패키지가 위치합니다.

in패키지에는 SendMoneyUseCase라는 클래스가 존재하고, out 패키지에는 LoadAccountPort와 UpdateAccountStatePort 클래스가 존재합니다.

application 패키지는 도메임 모델을 활용하는 서비스 계층을 포함합니다.

SendMoneyUseCase 인터페이스를 구현하는 인커밍 포트, 즉, 주포트 역할을 합니다.

즉, adapter패키지의 in패키지의 web패키지의 AccountController를 통해 클라이언트로 입력이 들어오면 AccountController에서 SendMoneyUseCase 인터페이스(포트)의 메소드를 호출하고, 그러면 실제로는 SendMoneyUseCase 구현체인 SendMoneyService(애플리케이션)의 메소드를 실행합니다.

애플리케이션에서 필요한 처리를 한 후에 LoadAccountPort와 UpdateAccountStatePort는 아웃고잉 포트, 즉, 부포트이고 이들의 메소드를 호출하면 이들을 구현한 어탭터인 persistence 패키지의 AccountPersistenceAdapter와 SpringDataAccountRepository가 호출되어 외부에서 처리를 합니다.

이는 겉보기에는 헷갈려보이지만 실은 육각형 아키텍처를 구현하는데 도움이 되는 패키지 구조입니다.

만약 패키지 구조가 아키텍처를 반영하지 않는다면 시간이 지남에 따라 그 소프트웨어는 점점 목표로 했던 아키텍처와는 멀어지게 될 것입니다.

반대로 아키텍처를 반영한 패키지를 구성한다면 이는 아키텍처를 유지하는데 도움을 주는데 그 이유는 코드를 작성하고 클래스를 만들면서 이를 어떤 패키지에 위치시켜야할지 계속해서 고민을 하게 만들기 때문입니다.

이처럼 잘 만들어진 패키지 구조는 코드와 아키텍처 간의 갭을 줄여줍니다.

의존성 주입의 역할

클린 아키텍처의 가장 본질적인 요건은 애플리케이션 계층이 인커밍/아웃고잉 어댑터에 의존성을 갖지 않는 것입니다.

육각형 아키텍처 구조에서 부포트와 부어댑터인 경우 애플리케이션 계층에서 인터페이스(포트)에 의존하여 메소드를 호출합니다.

그러면 실제로는 이 인터페이스(포트)를 구현한 어댑터의 메소드가 실행됩니다.

그런데 여기서 포트 인터페이스를 구현하는 실제 객체인 어댑터를 누가 애플리케이션 계층에 제공해야 할까요?

포트는 인터페이스이기 때문에 객체를 생성할 수 없기 때문에 초기화(객체 생성 및 인터페이스 구현)를 애플리케이션에서 하는 실수를 범하기 쉬운데 이렇게 되면 애플리케이션은 포트(추상화)와 어댑터(인터페이스) 둘 다에 의존하게 됩니다.

포트는 인터페이스(포트)에만 의존해야 합니다.

그래서 이 인터페이스 초기화를 해줄 조립기(Assembler)가 필요합니다.

이 조립기가 아키텍처를 구성하는 대부분의 클래스를 초기화하는 역할을 수행합니다.

즉, 이 조립기가 대신해서 초기화(의존성 주입) 역할을 해주는데 이것이 바로 스프링 프레임워크의 역할과 비슷합니다.

유지보수 가능한 소프트웨어를 만드는 데 어떻게 도움이 될까?

육각형 아키텍처와 최대한 가깝게 패키지를 구성하면 좋은 점은 코드에서 아키텍처의 특정 요소를 찾기 쉽습니다.

그 이유는 패키지 구조를 탐색하면 바로 찾을 수 있기 때문입니다.

이로 인해 팀의 의사소통이나 유지보수 면에서 이점이 많습니다.

댓글남기기