번역: Application Layering - A Pattern for Extensible Elixir Application Design (Aaron Renner)

Tags
Published
Author
엘릭서로 프로젝트를 진행하면서 구조를 잡는데 도움이 되었던 포스팅을 한국어로 번역한 내용을 남깁니다. 번역은 번역툴 (Deepl) 에게 맡겼습니다.
원문: Application Layering - A Pattern for Extensible Elixir Application Design (Aaron Renner) https://aaronrenner.io/2019/09/18/application-layering-a-pattern-for-extensible-elixir-application-design.html

소개

애플리케이션을 설계할 때는 필연적으로 다음과 같은 질문이 제기됩니다: “이 코드는 어디로 가야 할까?” 많은 경우 이 질문에 대한 답을 쉽게 찾을 수 없기 때문에 코드는 프로젝트의 정크 서랍(예: 유틸리티 또는 모델)에 남게 됩니다. 이런 일이 자주 발생하면 코드베이스가 엉키게 되고 시간이 지나면서 팀의 소프트웨어 유지 관리 능력이 현저히 저하됩니다. 이는 개발자의 경험이 부족하거나 “이름 짓기가 어렵다”는 신호가 아니라 애플리케이션의 구조가 부족하기 때문에 나타나는 증상일 가능성이 높습니다.
이 백서의 목표는 Elixir 개발자가 상호 의존성과 기술적 균열로 얽힌 웹에 얽매이지 않고 유지 관리, 적응 및 확장이 가능하도록 대규모 코드베이스를 구조화하는 방법을 배우는 데 도움을 드리는 것입니다.
 

배경

Phoenix 컨텍스트는 앱 정리를 위한 좋은 첫 단계이며 소규모 앱에 적합할 수 있습니다. 그러나 앱이 계속 성장함에 따라 코드베이스 내에서 다양한 추상화 수준을 나타내는 방법 없이 모든 컨텍스트를 단일 계층의 형제자매로 유지하는 경향이 있습니다. 비즈니스 로직에 여러 컨텍스트의 데이터를 조합해야 하고 이 로직이 어디로 이동해야 할지 명확하지 않은 경우 상황이 더욱 복잡해집니다.
진짜 문제는 여러 수준의 추상화가 모두 하나의 컨텍스트로 그룹화되어 있다는 것입니다. 추상화 수준이 같은 모듈에 섞여 있거나 중요한 비즈니스 로직을 넣을 곳이 없는 경우 코드베이스가 복잡해지고 추론하기 어려워집니다.
 

대안적 접근 방식

때때로 대안을 찾기 위해서는 몇 걸음 물러서서 다른 관점에서 문제를 살펴봐야 할 때가 있습니다.
대부분의 라이브러리는 제공하는 기능에 대한 추상화 계층입니다. 개발자는 API 클라이언트 라이브러리를 작성하기 때문에 HTTP 요청, 응답 구문 분석, 데이터 직렬화 및 기타 API 클라이언트에 들어가는 모든 것에 대해 생각할 필요가 없습니다. 데이터베이스 어댑터, 웹 서버, 하드웨어 드라이버 및 잠재적으로 복잡한 작업을 래핑하기 위해 깔끔한 API를 제공하는 기타 많은 라이브러리에도 동일한 개념이 적용됩니다. 이러한 라이브러리는 모두 복잡한 계층을 숨기므로 개발자는 웹 서버에 소켓을 여는 것과 같은 낮은 수준의 문제에 대해 걱정할 필요 없이 애플리케이션 작성에만 집중할 수 있습니다.
우리가 매일 사용하는 라이브러리조차도 낮은 수준의 추상화에 초점을 맞춘 다른 라이브러리에 복잡성을 위임합니다. 예를 들어 ecto_sql의 종속성 트리를 살펴봅시다.
ecto_sql ~> 3.0 (Hex package) ├── db_connection ~> 2.0 (Hex package) │ └── connection ~> 1.0.2 (Hex package) ├── ecto ~> 3.1.0 (Hex package) │ └── decimal ~> 1.6 (Hex package) ├── postgrex ~> 0.14.0 or ~> 0.15.0 (Hex package) └── telemetry ~> 0.4.0 (Hex package)
ecto_sql은 애플리케이션이 데이터베이스와 상호 작용할 수 있는 API를 제공합니다. ecto_sql 내부의 코드는 애플리케이션 데이터를 데이터베이스에 쓰고 다시 읽는 데 중점을 둡니다. 그러나 데이터베이스 연결 유지 관리, 연결 풀링, 메트릭 및 데이터베이스별 통신과 같은 낮은 수준의 문제에 관해서는 이러한 낮은 수준의 추상화에 중점을 둔 다른 라이브러리를 호출합니다.
notion image
특정 수준의 추상화에 초점을 맞춘 라이브러리를 만든다는 아이디어는 Elixir 생태계에서 일상적으로 실천되고 있습니다. 라이브러리 작성자는 라이브러리가 감싸고자 하는 추상화 수준에 얽매이지 않고 다른 애플리케이션이 그 위에 구축할 수 있는 사용하기 쉬운 API를 제공하려고 노력합니다. 이러한 종류의 모듈화와 관심사 분리는 개발자가 비즈니스 로직에 집중하고 하위 수준의 관심사는 Elixir 에코시스템 내의 다른 라이브러리에 위임할 수 있기 때문에 더 우수하고 유지 관리가 쉬운 애플리케이션을 구축할 수 있도록 하는 핵심 요소입니다.
모듈식 종속성을 계층화하여 추상화 수준을 분리하는 패턴은 매우 효과적으로 작동하므로, 이 패턴을 애플리케이션 코드베이스로 확장하여 로직을 적절한 추상화 수준으로 분리하는 모듈식 구성 요소 트리를 보유하는 것은 어떨까요?
 

패턴

애플리케이션 레이어링의 패턴은 두 부분으로 구성됩니다:
앱의 다양한 추상화 수준에 따라 앱을 레이어 트리로 나누기 각 계층의 구현을 대체 구현으로 쉽게 교체하여 테스트 가능성을 개선하고 변화하는 비즈니스 요구 사항에 대한 적응력을 높일 수 있도록 합니다. 이 패턴을 살펴보면서 설명한 기술을 사용하여 구축된 예제 리포지토리도 있습니다(https://github.com/aaronrenner/zone-meal-tracker). 이 앱은 이 문서의 뒷부분에서 설명하는 기법 중 일부를 사용하여 리팩토링되었으므로 이전 단계 중 일부를 보려면 커밋 기록으로 돌아가야 합니다.
 

앱을 여러 계층으로 나누기

Elixir/Phoenix 커뮤니티의 개발자는 Phoenix 컨텍스트를 사용하여 프로젝트를 별도의 웹 및 비즈니스 로직 앱으로 분할하는 것이 일반적입니다. 이는 원래 Lance Halvorsen의 강연 'Phoenix Is Not Your Application'에서 영감을 얻었으며, Phoenix 프레임워크의 코드 생성기가 이를 더욱 지지하고 있습니다. 이러한 분리는 웹 앱이 웹 관련 문제에만 집중하고 기본 비즈니스 애플리케이션에 대한 얇은 인터페이스가 될 수 있도록 함으로써 웹 앱을 크게 단순화합니다. 또한 개발자는 프로젝트의 핵심 로직, 즉 비즈니스 요구 사항을 해결하는 Elixir 애플리케이션에 별도로 집중할 수 있습니다.
웹 레이어를 애플리케이션의 나머지 부분과 분리하면 큰 이점을 얻을 수 있지만, 많은 프로젝트가 이 두 가지 레이어만으로 끝납니다. 이는 작은 코드베이스에서는 괜찮을 수 있지만, 코드베이스가 커지면 애플리케이션이 엉키고 복잡해질 수 있습니다.
애플리케이션 레이어링의 기본 개념은 애플리케이션의 다양한 추상화 수준을 분리하는 여러 레이어로 앱을 더 세분화하는 것입니다. 이 개념은 원래 1996년 '패턴 지향 소프트웨어 아키텍처 - 1권'에서 레이어 패턴으로 소개되었습니다. 이 패턴의 장점은 다음과 같습니다:
  • 이해 가능성 레이어는 단일 수준의 추상화에 초점을 맞추기 때문에 코드를 따라가기 쉬워집니다. 예를 들어, 비즈니스 로직 계층은 SQL 명령이나 이메일 전달과 같은 하위 수준의 세부 사항에 얽매이지 않고 사용자 등록 프로세스(데이터베이스에 사용자 만들기, 환영 이메일 보내기 등)에 집중할 수 있습니다.
  • 유지 관리 용이성 이해하기 쉬운 코드는 개발자가 업데이트하기가 더 쉽습니다. 애플리케이션이 여러 계층으로 분리되어 있으면 새 로직을 어디에 작성해야 하는지 훨씬 더 쉽게 이해할 수 있습니다. 데이터가 외부 API로 전송될 때 직렬화되는 방식에 대한 업데이트는 API 클라이언트와 같은 낮은 수준의 레이어에서 수행하는 것이 좋습니다. 마찬가지로 새로운 비즈니스 프로세스를 지원하는 로직은 상위 수준의 비즈니스 로직 계층에서 작성해야 합니다.
  • 적응성 상위 계층은 명확하게 정의된 API를 통해 하위 계층과 통신하기 때문에 하위 계층의 구현을 동일한 API를 준수하는 개선된 구현으로 대체할 수 있습니다. 따라서 상위 레이어까지 변경 사항이 파급되지 않고 전체 레이어의 구현을 교체할 수 있습니다.
애플리케이션 레이어링에서는 레이어 패턴의 엄격한 변형(레이어는 직접 하위 레이어에만 결합할 수 있음)을 사용하여 애플리케이션 전체 레이어를 만드는 대신 당면한 작업을 해결하는 데 필요한 특정 추상화 수준에 초점을 맞춘 하위 레이어의 트리를 생성합니다.
notion image
이 '계층 트리' 접근 방식의 가장 큰 장점은 각 구현을 논리적 하위 모듈로 분해할 수 있고, 필요한 경우 각 하위 모듈을 더 많은 하위 모듈로 분해할 수 있다는 점입니다. 이러한 분해는 자연스럽게 더 낮은 수준의 추상화를 향해 나아가는 계층을 만듭니다. 비즈니스 로직 레이어는 여러 개의 복잡한 하위 레이어를 조정하더라도 단순하고 가독성이 높으며 유연하게 유지될 수 있습니다. 또한 API 클라이언트와 같은 낮은 수준의 레이어는 상위 레이어와 분리되어 있기 때문에 자체 독립형 라이브러리로 쉽게 추출할 수 있습니다.

최상위 API 구축하기

이제 애플리케이션 계층화의 상위 수준 구조에 대해 설명했으니 이를 구현하는 방법을 살펴봅시다.
애플리케이션이 어떤 기능을 노출하는지 명확히 하기 위해 애플리케이션의 최상위 모듈을 외부 세계가 우리 코드와 상호작용해야 하는 유일한 장소인 공용 API로 취급하겠습니다. Elixir의 문서 작성 가이드에 따르면 문서에는 퍼블릭 API만 표시하고 내부 함수/모듈은 @moduledoc을 사용하여 숨겨야 한다고 명시되어 있습니다. 이를 통해 개발자는 실제로 구현 세부 사항인 다른 내부 모듈과 혼동하지 않고 애플리케이션과 외부 세계와의 계약을 명확하게 파악할 수 있습니다.
몇 가지 이유로 애플리케이션의 공개 API는 단 하나의 모듈(및 관련 구조)만 사용하는 것이 매우 중요합니다:
  • 추가 모듈이 공개되면 하위 모듈이 공용 API의 일부인지 아니면 내부적으로만 호출해야 하는 하위 수준의 구현 세부 사항인지 파악하기가 매우 어려워집니다.
  • 여러 구성 요소에 걸쳐 있는 새로운 비즈니스 프로세스를 추가할 때(예: 계정을 만들고 환영 알림을 보내는 사용자 등록) 최상위 공개 API 모듈은 이러한 구성 요소를 하나의 비즈니스 프로세스로 묶는 장소 역할을 합니다. 최상위 비즈니스 로직을 위한 합의된 단일 장소가 없으면 이 로직이 어디로 이동해야 할지 불분명해집니다.

네임스페이스를 활용하여 레이어 표시하기

최상위 모듈이 퍼블릭 API인 경우 하위 모듈은 두 가지 중 하나만 있어야 합니다:
  • 공용 API에서 참조하는 구조체 전용 모듈.
  • 공개 API의 구현 세부 사항이며 공개적으로 호출되지 않는 내부 헬퍼 모듈.
앞서 언급했듯이 공개 API의 일부인 모듈만 문서화하면 어떤 모듈과 함수가 공개인지 내부인지 쉽게 구분할 수 있습니다.
defmodule ZoneMealTracker do @moduledoc """ Public API for ZoneMealTracker application """ alias ZoneMealTracker.User @doc """ Registers a new user with email and password. """ @spec register_user(String.t(), String.t()) :: {:ok, User.t()} | {:error, :email_already_registered} def register_user(email, password), do: #... end
defmodule ZoneMealTracker.User do @moduledoc """ User struct """ # This is a struct module used by the public APIdefstruct [:id, :email] @type id :: String.t() @type t :: %__MODULE__{ id: id, email: String.t() } end
defmodule ZoneMealTracker.Notifications do @moduledoc false # This module is just an implementation detail of ZoneMealTracker # and not exposed on the public API. You can tell this by `@moduledoc false` # and it doesn't define a struct. # # No modules other than ZoneMealTracker should call this because it's # not part of the public API. @spec send_welcome_message(User.t) do end
이 접근 방식의 가장 큰 장점은 공용 API 계층(모듈)을 통해 기능을 세상에 노출하고, 하위 헬퍼 모듈은 공용에서 호출할 수 없는 하위 구현 계층의 일부라는 점입니다. 이를 통해 구현 계층에서 자식 모듈을 유연하게 구성하고 재구성할 수 있으며, 부모 모듈인 공용 API 이외의 다른 모듈에서 호출해서는 안 된다는 보안을 확보할 수 있습니다.
단순성을 유지하기 위해서는 부작용이 있는 함수를 구조체 전용 모듈에서 제외하는 것이 중요합니다. 데이터베이스에서 사용자를 검색하기 위해 ZoneMealTracker.User.fetch/1을 추가하면 퍼블릭 API를 여러 모듈로 효과적으로 분할하게 되므로 퍼블릭 API가 무엇인지 혼란스럽고 중요한 비즈니스 로직을 위한 명확한 위치가 없는 등 여러 문제가 발생할 수 있습니다. 대신 조회 로직을 함수에 직접 포함하거나 내부 데이터 저장소 모듈의 함수에 위임하는 ZoneMealTracker.fetch_user/1을 작성할 수 있습니다(예: ZoneMealTracker.UserStore.fetch/1).

네임스페이스를 완전히 활용하기

아래의 사실을 알리기 위해 이전에는 namespace 를 활용했음
  1. 최상위 모듈은 퍼블릭 API입니다.
  1. 하위 모듈은 다음과 같습니다:
    1. 공용 API에서 참조하는 구조체 전용 모듈.
    2. 공개 API의 구현 세부 사항이며 공개적으로 호출되지 않는 내부 헬퍼 모듈.
이 패턴의 가장 큰 장점은 애플리케이션의 여러 수준에서 반복할 수 있으며 여전히 동일한 구조와 보증을 제공한다는 것입니다.
notion image
실제로 우리가 설정하고 있는 패턴은 다음과 같습니다:
  1. 현재 모듈은 API입니다.
  1. 자식 모듈은 다음 중 하나입니다.
    1. 현재 모듈의 API가 참조하는 구조체
    2. API의 구현에서 사용되는 내부 모듈
  1. 모듈은 직계 자식에만 액세스해야 합니다. 형제자매, 손자, 증손자 등에는 액세스할 수 없습니다
    1. 형제자매에 액세스해야 하는 경우 로직은 다음 상위 네임스페이스로 이동해야 합니다.
    2. 손자녀에 액세스해야 하는 경우 자식 모듈에서 해당 기능을 위한 API를 제공해야 합니다.

자연스럽게 생성되는 레이어

네임스페이스를 이런 식으로 사용하면 자연스럽게 레이어가 생성된다는 점이 가장 큰 장점입니다. 예를 들어, ZoneMealTracker는 애플리케이션의 전반적인 비즈니스 로직에 초점을 맞추고 있는 반면, ZoneMealTracker.Notifications.NotificationPreferenceStore는 알림 시스템에 대한 사용자 기본 설정을 저장하는 데만 초점을 맞추고 있습니다. 개발자의 입장에서 이러한 계층화된 구조는 몇 가지 이점을 제공합니다:
  1. 모듈의 로직이 어떻게 구현되는지 신경 쓰지 않는 한 모듈의 자식을 살펴볼 필요가 없습니다. 예를 들어 ZoneMealTracker.register_user/2 함수를 호출하는 경우 사용자가 제대로 등록되었다는 것을 신뢰할 수 있어야 합니다. 해당 코드나 하위 모듈을 살펴봐야 하는 유일한 이유는 사용자를 등록하는 방법을 알아야 할 때입니다.
  1. 코드베이스를 이해하기 쉽게 유지합니다. 상사가 “이제 사용자가 등록되면 메트릭을 전송해야 한다”고 말하는 경우, 이 새로운 기능을 통합하기 위해 ZoneMealTracker.register_user/2가 논리적으로 좋은 곳입니다. 이 최상위 퍼블릭 API를 사용할 수 없다면 사용자 등록은 ZoneMealTracker.Accounts.register_user/1과 같은 기존 컨텍스트에 배치할 수 있습니다. 그러나 일부 기능은 비즈니스 로직 계층에서 작동하고 다른 기능은 지속성 계층에서 작동하므로 ZoneMealTracker.Accounts 모듈을 이해하기 더 어렵게 만듭니다. 이제 개발자는 어떤 함수가 상위 수준(비즈니스 로직)이고 어떤 함수가 하위 수준(지속성 로직)인지를 기억하고 현재 작업 중인 수준에 적합한 함수를 호출해야 합니다. 모듈의 API가 단일 추상화 수준에서만 작동한다면 훨씬 더 간단합니다.
notion image

구현을 스왑 가능하게 만들기

네임스페이스를 사용하여 애플리케이션을 계층화했고 각 계층에 잘 정의된 API가 있으므로 이제 한 단계 더 나아가 이러한 API 뒤에 있는 코드를 다른 구현으로 교체할 수 있습니다.
레이어 패턴에서 “교환 가능성”을 언급할 때 구현을 교체할 수 있음을 암시하지만, 이 개념은 Alistair Cockburn의 “육각형 아키텍처” 문서에서 더 자세히 다루고 있습니다. 육각형 아키텍처(일명 포트 및 어댑터)는 애플리케이션의 유연성을 유지하기 위해 비즈니스 로직이 잘 정의된 인터페이스를 통해 외부의 사물(데이터베이스, HTTP API 등)과 통신해야 한다고 말합니다. 잘 정의된 인터페이스가 만들어지면 현재 구현을 잘 정의된 인터페이스에 부합하는 다른 구현으로 대체할 수 있기 때문에 개발자에게 엄청난 유연성을 제공합니다. 저희의 경우 애플리케이션의 각 계층은 이러한 인터페이스를 통해 그 아래 계층과 통신하며, 이러한 하위 계층의 구현은 상위 계층의 코드에 영향을 주지 않고 쉽게 교체할 수 있습니다.
육각형 아키텍처 다이어그램에서는 상위 계층과 하위 계층에 대해 이야기하는 대신 다이어그램을 왼쪽으로 90도 돌려서 다음과 같이 말합니다:
  • 상위 레벨 레이어는 “왼쪽 포트”를 통해 육각형 내부의 로직을 호출하고
  • 육각형 내부의 로직은 “오른쪽 포트”를 통해 하위 레벨 레이어를 호출합니다.
육각형 모양은 중요하지 않으며, 각 평평한 면이 포트를 나타내는 그리기 쉬운 모양일 뿐입니다.
Original ZoneMealTracker app
Original ZoneMealTracker app
ZoneMealTracker with new implementations swapped in
ZoneMealTracker with new implementations swapped in
오른쪽 포트에서 구현을 교체할 수 있는 기능을 통해 가능합니다:
  • 외부 서비스를 호출하지 않고도 비즈니스 로직을 쉽게 단위 테스트할 수 있습니다.
  • 다른 팀이 하위 레이어를 구축하는 동안 앱의 상위 레이어를 개발할 수 있도록 가짜 데이터를 반환하는 하위 레이어의 구현을 빌드합니다.
  • 하위 레이어 구현 코드를 실제로 작성하지 않고도 하위 레이어 API의 기능을 강화할 수 있습니다. 이를 통해 어떤 데이터 저장소를 사용할지, 데이터베이스 테이블을 어떻게 구성할지 등과 같은 기술적 결정을 제공해야 하는 인터페이스에 대한 이해가 명확해질 때까지 연기할 수 있습니다.
  • 변화하는 비즈니스 요구 사항에 쉽게 적응할 수 있습니다. 예를 들어, 비즈니스에서 새로운 API 제공업체로 마이그레이션하기를 원할 경우 해당 하위 계층의 새로운 구현으로 교체하기만 하면 됩니다. 새 구현이 이전 구현과 동일한 코드 수준의 인터페이스를 제공할 수 있다면 업스트림에 영향을 주지 않고 새 구현을 교체할 수 있습니다.

엘릭서에서 자손 레이어를 교체하는 메커니즘

서로 다른 구현에서 스왑할 수 있는 기능이 있으면 좋겠지만 실제로 구현하는 것은 어려울 수 있습니다. 제가 시도해 본 몇 가지 방법을 소개합니다:
  • 협업 함수를 선택적 매개변수로 주입하기
  • 함수를 호출하기 전에 현재 구현을 조회하기
  • 현재 구현을 백그라운드에서 위임하는 API 모듈 호출하기
참고로, 다음은 구현을 교체하는 다양한 방법을 사용하여 수정할 register_user/2 함수 예제입니다.
@spec register_user(String.t(), String.t()) :: {:ok, User.t()} | {:error, :email_already_registered} def register_user(email, password) do case AccountStore.create_user(email, password) do {:ok, %User{id: user_id} = user} -> :ok = Notifications.set_user_email(user_id, email) :ok = Notifications.send_welcome_message(user_id) {:ok, user} {:error, :email_not_unique} -> {:error, :email_already_registered} end end

스와핑 접근 방식: 협업 함수를 선택적 매개변수로 삽입하기

이 접근 방식은 개발자가 테스트 중에 구현을 바꿀 수 있도록 새 매개 변수를 허용하도록 함수를 수정하는 것입니다.
@type register_user_opt :: {:create_user_fn, (String.t(), String.t() -> {:ok, User.t()} | {:error, Changeset.t}) | {:set_primary_notification_email_fn, (String.t(), String.t() -> :ok)} | {:send_welcome_message_fn, (String.t() -> :ok)} @spec register_user(String.t(), String.t()) :: {:ok, User.t()} | {:error, :email_already_registered} def register_user(email, password, opts \\ []) do create_user_fn = Keyword.get(opts, :create_user_fn, &AccountStore.create_user/2) set_primary_notification_email_fn = Keyword.get(opts, :set_primary_notification_email, &Notifications.set_user_email/2) send_welcome_message_fn = Keyword.get(opts, :send_welcome_message, &Notifications.send_welcome_message/1) case create_user_fn.(email, password) do {:ok, %User{id: user_id} user} -> :ok = set_primary_notification_email_fn.(user_id, email) :ok = send_welcome_message_fn.(user_id) {:ok, user} {:error, :email_not_unique} -> {:error, :email_already_registered} end end
혜택
  • 낮은 진입 장벽
  • 협업 기능을 다른 구현으로 교체할 수 있습니다. 추가 모듈을 정의할 필요가 없습니다.
  • 함수 호출마다 대체 구현을 교체할 수 있습니다. 애플리케이션 환경에서 글로벌 구성이 필요하지 않습니다.
단점
  • 함수를 빠르게 더 복잡하고 추론하기 어렵게 만듭니다.
  • 상당한 코드 변경이 필요함
  • 모의 구현과 대체 구현이 예상과 다른 방향으로 흐르기 쉬움
  • 다이얼라이저 지원 없음

스와핑 접근 방식: 함수를 호출하기 전에 현재 구현을 조회하기

이 접근 방식은 애플리케이션 환경에서 현재 구현이 포함된 모듈을 조회하는 것입니다. 이 작업은 함수를 호출하는 모듈에서 이루어집니다.
@spec register_user(String.t(), String.t()) :: {:ok, User.t()} | {:error, :email_already_registered} def register_user(email, password) do case account_store().create_user(email, password) do {:ok, %User{id: user_id} = user} -> :ok = notifications().set_user_email(user_id, email) :ok = notifications().send_welcome_message(user_id) {:ok, user} {:error, :email_not_unique} -> {:error, :email_already_registered} end end defp account_store do Application.get_env(:zone_meal_tracker, :account_store, AccountStore) end defp notifications do Application.get_env(:zone_meal_tracker, :notifications, Notifications) end
혜택
  • Mox와 호환
단점
  • 함수 호출 시 코드 변경이 필요함
  • 모듈 이름이 동적으로 확인되므로 다이얼라이저에서 작동하지 않음
  • 모든 호출자 모듈이 현재 구현을 직접 조회해야 합니다. 이는 상당한 부담이 됩니다.
  • 현재 구현은 애플리케이션 환경을 통해 전역적으로 제어됩니다. 따라서 여러 구현을 병렬로 실행하기가 어렵습니다.

스와핑 접근 방식: 백그라운드에서 현재 구현에 위임하는 API 모듈을 호출합니다.

이 접근 방식에서는 클라이언트 코드가 변경 없이 원래 모듈을 계속 호출할 수 있습니다. 대신 원래 모듈은 현재 구현에 함수 호출을 위임하도록 수정됩니다. 이 접근 방식은 기본적으로 모듈의 공용 API를 구현에서 분리합니다.
notion image
이 접근 방식에서는 클라이언트 코드가 동일하게 유지됩니다.
@spec register_user(String.t(), String.t()) :: {:ok, User.t()} | {:error, :email_already_registered} def register_user(email, password) do case AccountStore.create_user(email, password) do {:ok, %User{id: user_id} = user} -> :ok = Notifications.set_user_email(user_id, email) :ok = Notifications.send_welcome_message(user_id) {:ok, user} {:error, :email_not_unique} -> {:error, :email_already_registered} end end
공동 작업 모듈은 조정이 이루어지는 곳입니다.
defmodule ZoneMealTracker.AccountStore do @moduledoc false alias ZoneMealTracker.AccountStore.User @behaviour ZoneMealTracker.AccountStore.Impl @impl true @spec create_user(User.email(), User.password()) :: {:ok, User.t()} | {:error, :email_not_unique} def create_user(email, password) do impl().create_user(email, password) end defp impl do Application.get_env(:zone_meal_tracker, :account_store, __MODULE__.PostgresImpl) end end
그런 다음 실제 구현은 자체 모듈로 이동합니다.
defmodule ZoneMealTracker.AccountStore.PostgresImpl do @moduledoc false alias Ecto.Changeset alias ZoneMealTracker.AccountStore.PostgresImpl.InvalidDataError alias ZoneMealTracker.AccountStore.PostgresImpl.Repo alias ZoneMealTracker.AccountStore.User @behaviour ZoneMealTracker.AccountStore.Impl @impl true @spec create_user(User.email(), User.password()) :: {:ok, User.t()} | {:error, :email_not_unique} def create_user(email, password) when is_email(email) and is_password(password) do %User{} |> User.changeset(%{email: email, password: password}) |> Repo.insert() |> case do {:ok, %User{} = user} -> {:ok, user} {:error, %Changeset{errors: errors}} -> if Enum.any?(errors, &match?({:email, {"is_already_registered", _}}, &1)) do {:error, :email_not_unique} else raise InvalidDataError, errors: errors end end end end
마지막으로 API와 구현이 동작과 동기화된 상태로 유지됩니다.
defmodule ZoneMealTracker.AccountStore.Impl do @moduledoc false alias ZoneMealTracker.AccountStore.User @callback create_user(User.email(), User.password()) :: {:ok, User.t()} | {:error, :email_not_unique} end
notion image
이점
  • 클라이언트 코드를 변경할 필요가 없음
  • 다이얼라이저 작동
  • 한 곳에서 구현을 교체할 수 있음
  • Mox와 호환
  • 네임스페이스가 추가되어 여러 구현을 쉽게 분리할 수 있습니다. 각 구현에는 고유한 네임스페이스가 있으며, 구현을 삭제하는 것은 해당 네임스페이스를 삭제하는 것만큼이나 쉽습니다.
단점
  • 더 많은 스캐폴딩이 필요함
  • 네임스페이스 계층 구조에 추가 레벨 추가
  • 현재 구현은 애플리케이션 환경을 통해 전역적으로 제어됩니다. 따라서 여러 구현을 병렬로 실행하기가 어렵습니다.
“현재 구현을 백그라운드에서 위임하는 API 모듈 호출” 접근 방식이 가장 효과적이며 이 글의 나머지 부분에서 사용하게 될 접근 방식입니다.

단위 테스트

스와핑 메커니즘이 적용되었으므로 이제 ZoneMealTracker.AccountStore 또는 ZoneMealTracker.Notifications를 설정하거나 구현을 완료할 필요 없이 ZoneMealTracker를 개별적으로 쉽게 테스트할 수 있습니다.
아래는 ZoneMealTracker 모듈과 해당 테스트입니다.
# lib/zone_meal_tracker.ex defmodule ZoneMealTracker do @moduledoc """ Public API for ZoneMealTracker """ alias ZoneMealTracker.AccountStore alias ZoneMealTracker.Notifications @spec register_user(String.t(), String.t()) :: {:ok, User.t()} | {:error, :email_already_registered} def register_user(email, password) do case AccountStore.create_user(email, password) do {:ok, %User{id: user_id} = user} -> :ok = Notifications.set_user_email(user_id, email) :ok = Notifications.send_welcome_message(user_id) {:ok, user} {:error, :email_not_unique} -> {:error, :email_already_registered} end end end
# test/test_helper.exs Mox.defmock(ZoneMealTracker.MockAccountStore, for: ZoneMealTracker.AccountStore.Impl ) Application.put_env( :zone_meal_tracker, :account_store, ZoneMealTracker.MockAccountStore ) Application.put_env( :zone_meal_tracker, :notifications_impl, ZoneMealTracker.MockNotifications ) Mox.defmock(ZoneMealTracker.MockNotifications, for: ZoneMealTracker.Notifications.Impl )
# test/zone_meal_tracker.exs defmodule ZoneMealTrackerTest do use ExUnit.Case, async: true import Mox alias ZoneMealTracker alias ZoneMealTracker.MockAccountStore alias ZoneMealTracker.MockNotifications alias ZoneMealTracker.User setup [:set_mox_from_context, :verify_on_exit!] test "register_user/2 when email is unique" do email = "foo@bar.com" password = "password" user_id = "123" user = %User{id: user_id, email: email} expect(MockAccountStore, :create_user, fn ^email, ^password -> {:ok, user} end) MockNotifications |> expect(:set_user_email, fn ^user_id, ^email -> :ok end) |> expect(:send_welcome_message, fn ^user_id -> :ok end) assert {:ok, ^user} = ZoneMealTracker.register_user(email, password) end test "register_user/2 when email is already taken" do email = "foo@bar.com" password = "password" expect(MockAccountStore, :create_user, fn ^email, ^password -> {:error, :email_not_unique} end) assert {:error, :email_already_registered} = ZoneMealTracker.register_user(email, password) end end
각 종속성에 대한 명확한 API와 모의 구현에서 쉽게 교체할 수 있는 방법이 있으므로 코드가 작성된 것과 동일한 추상화 수준에서 비즈니스 로직을 단위 테스트하기가 매우 쉽습니다. 모의 구현을 교체하면 AccountStore.create_user/2가 {:error, :email_not_unique}를 반환할 때 알림이 전송되지 않는지 쉽게 테스트할 수 있습니다. 모의 구현을 사용하면 데이터베이스에 동일한 이메일을 가진 사용자를 먼저 만들 필요가 없으므로 두 번째 사용자 등록이 실패하고 실패 시 알림이 전송되지 않는지 확인할 수 있습니다.
대신 이 접근 방식을 사용하면 각 계층을 독립적으로 단위 테스트할 수 있으며, 모듈이 예상대로 함께 작동하는지 확인하기 위해 모듈 간 계약(API)을 다이얼라이저, AccountStore.Impl 동작에 의해 시행되고 통합 테스트에서 다시 확인합니다. 이 접근 방식을 사용하면 상위 계층은 기본 구현의 세부 사항에 얽매이지 않고 하위 계층의 알려진 응답을 테스트하는 데 집중할 수 있습니다. 또한 계정 저장소가 로컬 포스트그레스 기반 구현에서 고가용성 리악 기반 구현으로 마이그레이션되는 경우, ZoneMealTracker의 비즈니스 로직 테스트는 새로운 ZoneMealTracker.AccountStore 구현과 호환되도록 수정할 필요가 없습니다.

아래쪽까지 교체 가능한 레이어

notion image
육각형 아키텍처의 범위는 육각형 내부의 비즈니스 로직이 잘 정의된 API를 통해 각 외부 시스템과 통신해야 한다는 것으로 제한되지만, 이 패턴은 애플리케이션 전체에서 여러 계층에서 반복되는 데에도 적합합니다. 각 계층에서 스왑 가능한 구현을 허용하면 깔끔하게 계층화된 종속성 트리를 갖게 되며, 모든 구현은 언제든 쉽게 교체할 수 있습니다. 이는 테스트에 큰 도움이 될 뿐만 아니라 각 계층에 대한 테스트가 기본 구현에 연결되지 않고 자체적인 추상화 수준에 집중할 수 있게 해줍니다. 예를 들어 ZoneMealTracker.Notifications.send_welcome_message/1에 대한 테스트는 지정된 사용자 아이디에 이메일 주소가 등록되어 있으면 해당 주소로 환영 이메일이 전송되는지 확인합니다. NotificationPreferenceStore 및 이메일 모듈에 대한 모의 테스트를 교체할 수 있으므로 테스트는 알림 환경설정이 저장되는 방식이나 이메일이 전달되는 방식에 연결되지 않습니다. 대신 함수가 호출될 때 적절한 사용자에게 전송되는 이메일의 비즈니스 로직에 집중할 수 있습니다.

애플리케이션 구조를 반영하는 파일 구조

이 패턴의 또 다른 장점은 공용 API 모듈과 그 기본 구현을 더욱 명확하게 구분할 수 있다는 점입니다.
lib/zone_meal_tracker/ account_store.ex <- Top level API module account_store/ impl.ex <- Behaviour to be implemented by top level module and impls in_memory_impl.ex <- An in-memory implementation in_memory_impl/ <- modules used only by this specific implementation state.ex login.ex <- struct returned by public API postgres_impl.ex <- A postgres-backed implementation postgres_impl/ <- modules used only by this specific implementation domain_translator.ex exceptions.ex login.ex repo.ex supervisor.ex user.ex supervisor.ex <- Helper module used across all implementations (@moduledoc false) user.ex <- struct returned by public API
모듈의 폴더를 살펴볼 때 눈에 띄는 몇 가지 사항이 있습니다:
  • _impl로 끝나는 모든 것은 구현입니다. <impl_name>_impl/ 폴더 안에 있는 모든 것은 해당 구현에서만 사용되는 헬퍼 모듈입니다.
  • impl.ex는 최상위 모듈과 구현을 동기화 상태로 유지하는 역할을 합니다.
  • 폴더에 있는 대부분의 다른 모듈(application.ex 또는 supervisor.ex와 같이 잘 알려진 모듈 제외)은 API에서 참조하는 구조체 전용 모듈입니다.
이러한 구조와 통일성 덕분에 파일 트리를 훑어보는 것만으로도 레이어를 쉽게 이해할 수 있습니다. 또한, 제공해야 하는 API에 대한 이해도가 높아짐에 따라 InMemoryImpl로 시작하여 PostgresImpl로 확장했을 수도 있습니다. 이러한 구조 덕분에 InMemoryImpl을 계속 사용하면서 PostgresImpl을 독립적으로 개발할 수 있습니다. InMemoryImpl에서 PostgresImpl로 전환할 때가 되면 최상위 모듈에서 기본 구현을 PostgresImpl로 설정하는 한 줄의 변경만으로 가능합니다(애플리케이션 환경을 통해 변경할 수도 있으므로 쉽게 롤백할 수 있습니다). 마이그레이션이 완료되고 이전 구현을 제거할 준비가 되면, 이제 in_memory_impl.ex와 in_memory_impl/를 삭제하기만 하면 됩니다. 전체 구현이 해당 파일과 폴더에 포함되어 있으므로 제거는 매우 간단합니다. 이 아이디어는 원래 Johnny Winn의 강연에서 영감을 얻었습니다: 그냥 삭제하세요.

통합 테스트

각 계층이 완전히 단위 테스트를 거쳤고 투석기가 계층 간에 유형 검사를 실행하고 있더라도 모든 기본 구현이 예상대로 통합되는지 확인하기 위해 몇 가지 엔드투엔드 테스트를 수행하는 것이 중요합니다. 이는 모든 것이 예상대로 작동하는지 확인하기 위해 사용자 등록 흐름을 거치는 자동화된 테스트를 작성하는 것처럼 간단할 수 있습니다.
테스트는 애플리케이션 환경을 조작하여 다양한 계층의 모의 구현을 교체하기 때문에 일반적으로 깨끗한 환경에서 앱을 시작하는 통합 테스터를 엄브렐러 앱 외부에 두는 것이 가장 좋습니다.
존밀트래커 앱의 통합 테스터 폴더에 예제 통합 테스터가 있습니다.
브라우저 기반 통합 테스트를 수행해야 하는 경우 실제 웹 브라우저를 통해 앱을 구동할 수 있는 훌륭한 도구인 Wallaby와 Hound가 있습니다. API를 사용하여 앱을 작성하는 경우 통합 테스트를 더 쉽게 하기 위해 API 클라이언트를 구축하는 것을 고려해 볼 가치가 있습니다(다른 엘릭서 애플리케이션에서 API를 호출할 때에도 매우 유용합니다).

팁과 요령

이 패턴은 매우 잘 작동하고 수명이 긴 애플리케이션을 유지 관리할 때 유연성을 제공하지만, 이런 종류의 구조로 애플리케이션에서 작업할 때 염두에 두어야 할 몇 가지 사항이 있습니다.
필요할 때만 스와핑 메커니즘 추가 처음 시작할 때는 모든 모듈 사이에 스와핑 메커니즘을 추가하고 싶은 유혹에 빠질 수 있습니다. 하지만 너무 많은 스와핑 메커니즘은 불필요한 고통과 좌절감을 가져올 뿐입니다. 필요한 경우에만 스와핑을 삽입하세요:
  • 비즈니스 로직 레이어와 외부 서비스(API, 데이터베이스 등)에 연결되는 레이어
  • 뚜렷한 비즈니스 로직 레이어. 예를 들어, 개발자는 ZoneMealTracker.Notifications에 대한 스와핑 메커니즘을 삽입하여 상위 계층인 ZoneMealTracker를 따로 테스트할 수 있습니다. ZoneMealTracker를 테스트할 때는 알림이 전송되는 방식은 신경 쓰지 않고 ZoneMealTracker.Notifications에서 적절한 함수가 호출되는지만 신경 쓰면 됩니다.
많은 경우, 부모 모듈(ZoneMealTracker.Notifications.Logger)에서 추출한 순수 함수만 포함하는 ZoneMealTracker.Notifications.Formatter와 같은 다른 헬퍼 모듈이 있습니다. 이러한 모듈은 외부 서비스나 시스템 일부와 상호 작용하지 않으므로 스와핑 로직이 필요하지 않습니다. 이러한 모듈에 스와핑 로직을 추가하면 테스트와 전체 애플리케이션 구조가 복잡해질 뿐입니다.

구현 세부 정보를 API에 노출하지 않기

구현 세부 정보가 앱의 API로 유출되지 않도록 하는 것도 중요합니다. 개발자가 규율을 지키지 않고 API에서 엑토 스키마와 변경 집합을 반환하면, 엑토 기반 로컬 계정 저장소를 HTTP를 통해 계정 마이크로서비스에 도달하는 새로운 구현으로 교체하지 못할 수 있습니다. 핵심은 공개 API를 통해 수락되거나 반환되는 구조가 기본 구현과 연결되지 않도록 하는 것입니다. 즉, 엑토 스키마나 변경 집합이 공개 API로 유출되어서는 안 되는데, 이는 엑토가 아닌 데이터 저장소로 전환하는 것을 방해할 수 있기 때문입니다.

긴 네임스페이스로 작업하는 것

애플리케이션을 적절한 수준의 추상화(스왑 로직으로 완성)로 계층화하기 시작한 후에는 네임스페이스가 매우 깊어질 때가 있습니다. 스왑 가능한 레이어를 3개 이상 중첩하면 모듈 이름이 매우 길어지고 번거로워질 수 있습니다.
defmodule ZoneMealTracker.DefaultImpl .Notifications.DefaultImpl.NotificationPreferenceStore.PostgresImpl.PrimaryEmail do end
그 시점이 되면 ZoneMealTracker.DefaultImpl.Notifications와 같은 논리적인 독립형 레이어를 선택하고 그 아래에 있는 자체 애플리케이션으로 추출하는 것이 효과적입니다. 내부 앱임을 나타내기 위해 애플리케이션 앞에 프로젝트의 이니셜인 ZMTNotifications와 같은 접두사를 붙입니다. 이렇게 추출하면 네임스페이스의 길이가 짧아질 뿐만 아니라 몇 가지 다른 이점도 얻을 수 있습니다:
  • 각 내부 앱에는 자체 mix.exs가 있으므로 어떤 레이어가 종속성을 도입하는지 쉽게 알 수 있습니다.
  • API 클라이언트와 같은 독립형 라이브러리는 엄브렐러에서 자체 프로젝트로 쉽게 추출하여 Hex에 게시할 수 있습니다.
  • 내부 애플리케이션에서도 최상위 모듈은 나머지 시스템에서 빌드할 수 있는 API를 노출합니다. 이 모듈에는 추출할 가치가 있을 만큼 충분한 기능이 있으므로 이 최상위 내부 API에 대한 문서도 추가했습니다. 이제 개발자가 프로젝트에 대한 ExDoc을 생성할 때 기본 공개 API(ZoneMealTracker)뿐만 아니라 ZMTNotifications와 같이 접근 가능한 모든 내부 API도 볼 수 있습니다.
notion image

API 모듈에 로직 없음

API 모듈을 구축할 때 해당 함수는 현재 구현에 대한 패스스루로만 사용해야 합니다. 개발자가 API 모듈에 로직을 추가하는 경우 함수에서 특정 데이터를 반환하려는 경우에도 이 로직을 염두에 두어야 합니다. 다음은 예시입니다:
# API Module that contains logic defmodule ZMTNotifications do @spec fetch_registered_email(String.t()) | {:ok, String.t()} | {:error, :not_found} def fetch_registered_email(email) do current_impl().fetch_registered_email( end @spec email_registered?(String.t) :: boolean def email_registered?(email) do # This contains logic instead of a passthrough match?({:ok, _}, fetch_email_registered(email) end end # Module being tested defmodule ZoneMealTracker do @spec email_registered?(String.t) :: boolean def email_registered?(email) do # Code under test ZMTNotifications.email_registered?(email) end end # Test Case defmodule ZoneMealTrackerTest do use ExUnit.Case import Mox alias ZoneMealTracker.MockZMTNotifications test "email_registered?/1 returns true when email is registered" do # Although the code under test is calling # `ZMTNotifications.email_registered?/1`, we have to know to mock # `fetch_registered_email/1` because there is logic in # `ZMTNotifications.email_registered?/1` instead of being just a straight # passthrough. expect(MockZMTNotifications, :fetch_registered_email, fn email -> {:ok, email}) assert ZoneMealTracker.email_registered?("foo@bar.com") end end
위의 예제에서 단순히 MockZMTNotifications.email_registered?/1이 원하는 값을 반환할 것으로 예상할 수 있는 것이 아니라, 기본 구현이 MockZMTNotifications.fetch_registered_email/1을 호출한다는 사실을 알아야 합니다. MockZMTNotifications.email_registered?/1이 참을 반환하도록 하려면 실제로 MockZMTNotifications.fetch_registered_email/1이 {:ok, email}을 반환하도록 만들어야 합니다. 이는 단순해 보이는 함수를 테스트하기 위해 불필요한 결합과 복잡성을 초래합니다.
API 모듈에 넣을 유일한 로직은 적절한 데이터 유형이 기본 구현에 전달되도록 하는 가드 절뿐입니다. 이렇게 하면 API 모듈에서 잘못된 데이터로 함수를 호출할 때 오류 메시지가 더 보기 좋게 표시되고 실제 구현에서 가드 절을 잊어버리지 않게 됩니다. 그 외에도 API 모듈의 함수는 현재 구현에 대한 완전한 전달이어야 합니다.

이전 작업과의 관계

소프트웨어 디자인에서 대부분의 아이디어는 누군가의 이전 작업을 약간 다른 맥락에서 응용하는 것입니다. Greg Young의 “소프트웨어를 파괴하는 기술”은 작은 프로그램(컴포넌트) 모음으로 큰 시스템을 구성하는 방법에 대해 설명하는 훌륭한 강연으로, 각 프로그램은 약 일주일 안에 삭제하고 다시 작성할 수 있습니다. 이를 통해 대규모 시스템의 일부를 작고 이해하기 쉬우며 변화하는 비즈니스 요구사항에 쉽게 적응할 수 있도록 유지할 수 있습니다.
애플리케이션 레이어링의 개념은 이러한 철학과 매우 잘 맞닿아 있습니다. 애플리케이션 계층화를 사용하면 언제든지 교체할 수 있는 구성 요소의 트리로 애플리케이션을 구축할 수 있습니다. 예를 들어, 포스트그레스 데이터베이스 대신 리악 데이터베이스에 쓰도록 ZMTNotifications.DefaultImpl.NotificationPreferenceStore를 변경하려는 경우, 새로운 ZMTNotifications.DefaultImpl.NotificationPreferenceStore.RiakImpl 모듈을 작성하고 준비가 되면 이를 교체할 수 있습니다. 포스트그레스에서 리악으로 전환이 완료되면 ZMTNotifications.DefaultImpl.NotificationPreferenceStore.PostgresImpl과 그 하위 모듈을 모두 삭제할 수 있습니다. 데이터베이스 로직을 작은 컴포넌트로 분리함으로써 약 1주일 만에 컴포넌트를 삭제하고 다시 작성할 수 있다는 Greg의 아이디어를 쉽게 따를 수 있습니다.
여기서 한 걸음 더 나아가 비즈니스 요구 사항이 변경되면 알림 시스템의 설계를 완전히 변경해야 할 수도 있습니다. 기존의 스와핑 인프라가 준비되어 있으면 새로운 ZMTNotifications.EnhancedImpl 모듈을 정의하고 새롭고 개선된 알림 시스템 작업을 시작할 수 있습니다. 이 구현에는 ZMTNotifications.DefaultImpl 구현과는 별도의 데이터 저장소 및 서비스가 필요할 수 있지만, 모두 ZMTNotifications.EnhancedImpl 네임스페이스 아래에 넣을 수 있습니다. ZMTNotifications.Impl 동작에 정의된 계약을 이행할 수 있는 한, 업스트림 레이어에 영향을 주지 않고 이 컴포넌트 트리를 자유롭게 다시 작성할 수 있습니다. 또한 이 컴포넌트는 시스템의 나머지 부분과 격리되어 있기 때문에 대부분의 경우 일주일 정도면 다시 작성할 수 있습니다.
Greg의 강연에서 그는 애플리케이션 내의 작은 프로그램(컴포넌트)을 일주일 안에 다시 작성할 수 있는 이점에 대해 언급했습니다. 애플리케이션 레이어링에서 스왑 가능한 각 구현은 이러한 작은 프로그램 중 하나에 비유할 수 있습니다. Greg는 “훌륭한 코드와 형편없는 코드의 차이는 프로그램(레이어)의 크기”라고 언급합니다. 각 레이어가 단일 수준의 추상화(비즈니스 로직, 지속성 등)에만 초점을 맞춘다면 각 레이어는 작게 유지되고 요구 사항이 변경될 때 쉽게 교체할 수 있습니다. 이러한 구조는 개발자가 크고 복잡한 코드베이스의 족쇄를 풀고 변경이 필요할 때 시스템의 작은 부분을 자유롭게 다시 작성할 수 있게 해줍니다. 제 경험에 비추어 볼 때, 이러한 구조는 작업하기에 매우 즐거운 애플리케이션을 만들어 줍니다.

최종 조언

소프트웨어 구축은 하나의 과정이며, 진행하다 보면 올바른 소프트웨어 설계가 무엇인지 알게 될 것입니다. 저는 여러 번 단계를 건너뛰고 실제로 필요하기 전에 레이어를 만들려고 했다가 잘못된 선택을 한 것을 깨닫고 코드를 다시 작성해야 하는 경우가 많았습니다. 그래서 저는 다음 프로세스를 따르려고 노력합니다:
  • 비공개 함수를 추출하여 공개 함수를 더 읽기 쉽게 만들기
  • 여러 개의 관련 비공개 함수가 추출되었거나 이러한 비공개 함수에 대한 단위 테스트를 작성해야 하는 경우 하위 모듈로 추출합니다.
  • 내 비즈니스 로직에 대한 테스트가 외부 서비스 또는 별도의 비즈니스 로직 섹션에 대한 호출이 필요하여 작성하기 어려운 경우, 내 비즈니스 로직과 외부 서비스/별도의 도메인 사이의 경계 역할을 하는 스왑 가능한 계층을 모듈에 삽입합니다.
또한 모듈을 작성할 때 “이 모듈의 공용 API는 무엇인가?”라는 질문을 스스로에게 던지는 것도 도움이 됩니다. 이렇게 하면 나머지 시스템을 지원하기 위해 이행해야 하는 계약에 대해 생각하는 동시에 구현을 실험할 수 있는 유연성을 확보할 수 있습니다. 상위 계층에서 작업할 때는 하위 계층에 있었으면 하는 함수를 호출한 다음 하위 계층의 API를 확고히 한 후에 하위 수준 로직을 구현합니다. 이렇게 하면 자식 레이어 API가 합당한지 확인할 수 있습니다.
이런 방식으로 시스템을 설계하려면 생각과 규율이 필요하지만 놀랍도록 유연한 코드베이스를 만들 수 있고, 적응과 유지 관리가 쉽습니다. 사소하지 않은 애플리케이션을 사용하는 모든 분들이 애플리케이션의 가장자리에서 이 패턴을 시도해 보시기를 권하고 싶습니다. 외부 서비스와의 경계(예: API 클라이언트)일 수도 있고 애플리케이션의 최상위 경계(웹 계층과 비즈니스 로직 사이)일 수도 있습니다. 시간이 지나면 무엇이 효과가 있는지, 시스템을 가장 잘 구성하는 방법은 무엇인지 파악할 수 있습니다. 궁극적으로 올바르게 수행하면 유연하고 적응력이 뛰어난 코드베이스를 구축할 수 있습니다. 즉, 코드베이스를 재구성할 때 애플리케이션이 계속 작동하는지 확인하기 위해 전체 스택 통합 테스트를 추가하는 것을 잊지 마세요.