Application Layering 아키텍처 - ports and adpaters로 layer를 만든 아키텍처
“변수 이름을 어떻게 지어야 하지?” ChatGPT와 Copilot이 도와주고 있지만 여전히 어려운 문제다. 규모가 어느 정도 되는 Codebase에서 작업을 한다면 이름 짓기 이전에 넘어야 할 산이 하나 더 있다. “어디에 코드를 넣어야 할까?”
그래서 우연히 읽은 ’Application Layering - A Pattern for Extensible Elixir Application Design’ 글이 반가웠다. 사이드 프로젝트에는 이 패턴을 사용해서 코드 구성(organizing code)을 하고 있다. Layered architecture를 기본으로 비즈니스 로직 계층과 외부 서비스에 연결하는 layer 사이에는 Ports and adapters architecture를 재귀적으로 사용하는 방식이다.
Phoenix Context를 고민없이 쓴 구조와 Application Layering 비교
웹서비스에서 계정 생성과 친구 초대를 만들려고 한다. Elixir의 웹프레임워크는 Phoenix로 천하통일이 된 상태다. 반강제적으로 Phoenix 웹프레임워크를 선택했다면 계정에 관련된 기능을 그룹화하는 Phoenix Context를 정의하게 된다.
ZoneMealTrackerWeb
모듈에서 계정 생성 등이 필요하면 ZoneMealTracker.Accounts
모듈을 사용하고 친구 초대가 필요하면 ZoneMealTracker.Notifications
모듈을 사용한다. ZoneMealTracker.Accounts
모듈은 DB에 있는 계정 정보를 사용한다. 계정을 생성하면 ZoneMealTracker.Notifications
모듈을 사용해서 환영 메일도 보낸다. ZoneMealTrackerWeb
에서 계정 생성에 필요한 정보를 넘겨주면 유효한 입력인지 체크도 한다. 계층 정보가 있다기보다는 모두 평등한 느낌이다.
이런 구조를 처음에 쉽게 잡아주는 Phoenix Context가 무조건 해로운 건 아니다. 소규모로는 빠르게 개발할 수 있는 복잡하지 않은 구조이다. 하지만 규모가 커진다면 엔트로피를 낮출 수 있는 다른 설계를 고민해야 한다.
Application Layering은 아래와 같은 구조를 제안한다.
대기업 회사 조직도 같다. Layered architecture를 기본으로 한다. 여기에 Ports and adapters architecture를 적용한다. 한 번이 아니라 원하는 만큼이다. API Modules와 Implementations(Adapters)가 번갈아 가며 층을 이루는 걸 볼 수 있다. 지나치게 layer를 나눈 거 아닌가? 맞다. 예제라서 저렇게 힘을 줬다. 테스트에 Mock이 필요 없다면 layer를 더 나누지 않는다. 필요하다면 그때 나눠도 늦지 않다.
Application Layering - Layered + Ports and Adapters
api
모듈과 implementation
모듈로 재귀적으로 구성한다. 여기서 api
모듈은 ports
이고 implementation
모듈은 adapters
가 된다. Ports and adapters architecture로 Layered architecture를 만든 아키텍처 스타일이다.
The pattern of Application Layering consists of two parts:
- Breaking an app into a tree of layers based on the app’s various levels of abstraction
Allowing each layer’s implementation to be easily swapped with an alternative implementation to improve testability and increase adaptability to changing business requirements
애플리케이션 레이어링의 패턴은 두 부분으로 구성됩니다:
- 앱의 다양한 추상화 수준에 따라 앱을 레이어 트리로 나누기
- 각 계층의 구현을 대체 구현으로 쉽게 교체할 수 있도록 하여 테스트 가능성을 개선하고 변화하는 비즈니스 요구 사항에 대한 적응력을 높입니다.
낯설지 않은 아키텍처다. Layer에 Ports and adapters architecture를 잘 끼워 넣었다. 대체 구현으로 쉽게 교체할 수 있게 구성하는 게 핵심이다. Pimpl 생각이 났다. C++에서는 컴파일 속도 때문에 사용했다면 Application Layering에서는 구현 교체를 가능하게 하려고 api 모듈과 implementation 모듈을 분리한다.
- The current module is the API. Child modules are either
- Structs referenced by the current module’s API
- Internal modules that are used by the API’s implementation
- Modules should only access their direct children. No accessing siblings, grandchildren, great grandchildren, etc.
- If you need to access siblings, then the logic should go in the next higher namespace
- If you need to access grandchildren, then the child module needs to provide an API for that functionality.
- 현재 모듈은 API입니다. 하위 모듈은 다음 중 하나입니다.
- 현재 모듈의 API가 참조하는 구조체입니다.
- API의 구현에서 사용되는 내부 모듈
- 모듈은 직계 자식에만 액세스해야 합니다. 형제자매, 손자, 증손자 등에는 접근할 수 없습니다.
- 형제자매에게 액세스해야 하는 경우 로직은 다음 상위 네임스페이스에 있어야 합니다.
- 손자녀에 액세스해야 하는 경우 자식 모듈에서 해당 기능에 대한 API를 제공해야 합니다.
1번은 layering 하면 자연히 지켜지는 규칙이다. 액세스 규칙인 2번은 왜 저런 규칙을 만들었을까? 손자와 증손자에 접근하지 말라는 건 api
모듈과 implementation
모듈이 번갈아 가며 쌓이는 구조라서 이해가 된다. 자식이 아니라 손자나 증손자에 접근한다면 implementation
모듈 사이에 의존 관계가 생길 수 있다. 대체 구현으로 쉽게 교체하려고 이런 구조를 만들었는데, 접근 규칙 때문에 노력이 수포가 된다. 그렇다면 형제자매 접근을 막는 건 너무 지나치지 않는가? 모듈화에 방해가 된다. 형제자매에게 접근하는 코드를 짠다는 건 짠 코드가 현재 모듈이 아니라 형제자매에게 있어도 된다는 뜻이다. 여기에 있어도 되고 저기에 있어도 되고. 어려운 모듈화를 더 어렵게 만들고 모듈의 경계를 계속 허물게 된다. 좀 더 중요한 이유가 있을 것 같은데, 내가 생각해 본 형제자매 접근을 막는 이유다.
액세스 규칙을 지키다 보면 중복 코드가 많아지는 게 괴롭다. 최상위(top level)에 정의한 구조체(struct)를 손자와 증손자가 쓰는데, 이게 맞나 싶다. 그렇다고 멤버가 거의 같은데, 조금만 다르게 정의하고 이걸 다시 변환해야 하나 싶기도 하다. Application Layering 패턴을 쓰면서 이게 제일 괴로웠다. 관련 코드를 다른 라이브러리로 분리하거나 Layered architecture의 안티패턴인 요청이 레이어를 넘을 때, 아무 처리도 하지 않고 그냥 넘기는 아키텍처 싱크홀(architecture sinkhole) 안티패턴이 생기지 않게 설계를 바꿔야 한다.
Layer를 언제 추가할까?
신나서 Application Layering을 하면 엄청나게 많은 Layer를 만들어야 한다. 좋다고 한 일이 쓸데없이 복잡해진 코드로 돌아온다.
When first starting out, it’s tempting to add swapping mechanisms between every module. Unfortunately, too many swapping mechanisms will only bring needless pain and frustration. Only insert swapping between:
- A business logic layer and a layer that reaches out to an external service (API, database, etc)
- Distinct business logic layers. For example, a developer could insert a swapping mechanism for ZoneMealTracker.Notifications so its parent layer, ZoneMealTracker, could be tested in isolation. When testing ZoneMealTracker, we don’t care how notifications are sent, just that the appropriate function is called on ZoneMealTracker.Notifications.
처음 시작할 때는 모든 모듈 사이에 스와핑 메커니즘을 추가하고 싶을 수 있습니다. 하지만 너무 많은 스와핑 메커니즘은 불필요한 고통과 좌절감을 가져올 뿐입니다. 스와핑은 꼭 필요한 곳에만 삽입하세요:
- 비즈니스 로직 계층과 외부 서비스(API, 데이터베이스 등)에 연결되는 계층 간
- 뚜렷한 비즈니스 로직 레이어. 예를 들어, 개발자는 ZoneMealTracker.Notifications에 스와핑 메커니즘을 삽입하여 상위 레이어인 ZoneMealTracker를 따로 테스트할 수 있습니다. ZoneMealTracker를 테스트할 때는 알림이 전송되는 방식은 신경 쓰지 않고 ZoneMealTracker.Notifications에서 적절한 함수가 호출되는지만 신경 쓰면 됩니다.
유닛 테스트에서 Mock이 필요한 곳이 Ports and adapters architecture를 적용할 좋은 지점이다. 외부 서비스로 분류해서 Ports and adapters architecture를 적용하기 만만한 게 데이터베이스다. PostgreSQL만 쓰는 게 아니라 in memory 저장소로 교체할 수도 있다고 미리 고려한다. 복잡하게 생각할 필요가 없다. 테스트를 기준으로 잡으면 된다. ExMachina와 Ecto Adapters SQL Sandbox 덕분에 Mock을 쓰지 않고 데이터베이스를 연결해도 손쉽게 테스트할 수 있다. 좋은 테스트 라이브러리가 있으면 기준이 바뀔 수 있다. PostgreSQL를 다른 걸로 교체하면 그때 가서 layer를 추가하면 된다.
공개 API 모듈은 단 한 개
공개 API 모듈이 한 개면 외부 라이브러리나 Umbrella Projects의 자식 프로젝트 코드를 읽을 때 도움이 된다. 구현 세부 사항을 신경 쓰지 않고 모듈 하나만 보면 공개 API를 모두 확인할 수 있기 때문이다.
It is very important to only have a single module (and its related structs) as the application’s public API for a couple of reasons:
- If additional modules are made public, it becomes very difficult to understand if a child module is part of the Public API or a lower-level implementation detail that’s only meant to be called internally.
- When adding a new business process that spans multiple components (like user registration which creates and account and sends a welcome notification), the top-level Public API module serves as a place to tie these components together into a single business process. Without a single, agreed upon place for the top level business logic, it becomes unclear where this logic should go.
몇 가지 이유로 애플리케이션의 공개 API는 단 하나의 모듈(및 관련 구조)만 사용하는 것이 매우 중요합니다:
- 추가 모듈이 공개되면 하위 모듈이 공용 API의 일부인지 아니면 내부적으로만 호출해야 하는 하위 수준의 구현 세부 사항인지 파악하기가 매우 어려워집니다.
- 여러 구성 요소에 걸쳐 있는 새로운 비즈니스 프로세스를 추가할 때(예: 계정을 만들고 환영 알림을 보내는 사용자 등록) 최상위 공개 API 모듈은 이러한 구성 요소를 하나의 비즈니스 프로세스로 묶는 장소 역할을 합니다. 최상위 비즈니스 로직을 위한 합의된 단일 장소가 없으면 이 로직이 어디로 이동해야 할지 불분명해집니다.
이렇게 하기가 어렵지 한다면 분명 도움이 되는 패턴이다. 자료구조, 알고리즘 모음을 제공하는 그런 기반 라이브러리 정도는 예외로 둘 수 있다. 공개 API 모듈 하나로 구성하기엔 모듈이 담는 함수가 너무 많아지고 Application Behaviour로 나누자니 Applications가 너무 많아진다. 공개 API 모듈이 여러 개인 다른 방식으로 구성해야 한다.
예전에 다닌 회사에서 본 in house 게임 엔진 구조가 생각났다. 한 14년 전이었던 것 같다. 그 당시에는 상상도 못 할 정도로 C++ 솔루션 안에 많은 C++ 프로젝트가 있었다. 200개가 훨씬 넘었다. 프로젝트마다 최상위 파일이 하나씩 있었다. 공개 API를 정의한 파일이었다. 모듈화가 강제됐고 일정한 코드 퀄리티가 유지됐다. 코드 유지보수가 무척이나 수월했던 기억이다. 수많은 프로젝트로 나누고 공개 API를 정의한 파일을 하나씩 뒀다. 기억도 가물가물한 옛날이다. 그때에도 이런 걸 고민하고 방법을 찾아서 실천하고 프로덕션에 적용한 프로그래머들이 있었다. 세상에는 참 훌륭한 프로그래머들이 많다.
폴더 구조를 깊게 가져가지 말고 라이브러리로 나눠라
라이브러리로 나누고 라이브러리마다 공개 API 모듈을 하나씩 두는 식으로 모듈화를 권장한다. Elixir에서는 디렉터리 구조를 모듈 이름에 .
로 반영하는 게 관례이다. 모듈 이름에 .
문자가 많이 붙는 건 복잡해지고 있다는 나쁜 냄새일 수 있다.
최근에 읽은 책 중에 코드 구성(organizing code)에 대한 책으로는 ’Designing Elixir Systems with OTP (James Edward Gray II, Bruce A. Tate, 2019)’가 있다. Layer 구성 방법이 Elixir에 맞다고 생각했는데, 폴더 구조로만 Layer를 해서 모듈 이름에 .
문자가 엄청나게 붙는다. 라이브러리를 나누고 공개 API 모듈을 하나씩만 두는 걸 기본으로 하되 이 책에서 설명하는 코드 구성을 적절히 녹이면 괜찮겠다고 생각했다.
실제 디렉터리 구조
실제 디렉터리 구조는 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
Elixir에서 모듈 이름에 디렉터리 구조를 반영하는 게 관례다. account_store/postgres_impl/repo.ex
경로에 있는 모듈 이름은 AccountStore.PostgresImpl.Repo
이다.
외부로 노출하는 구조체(struct)가 있을 수 있지 않나? User
모듈처럼 유저 정보를 담은 구조체 같은 거 말이다. 이런 모듈은 account_store/user.ex
처럼 공개 API 하위에 두고 같이 노출한다. Elixir에서는 모듈 안에 모듈을 정의할 수 있다. 즉 account_store.ex
파일에 AccountStore
모듈과 해당 모듈 안에서 AccountStore.User
모듈을 정의할 수 있다. 이렇게 하면 공개 API 파일 하나만 두는 것으로 더 깔끔한 규칙을 만들 수 있다. 하지만 공개 API가 길어지는 게 부담스럽고 파일당 모듈 하나인 규칙을 만들지 못해 파일 내비게이션이 헷갈려서 이렇게 하지는 않은 것 같다.
마치며
’이 코드 어디에 넣어야 할까?’ 이런 고민을 해봤다면 ’Application Layering - A Pattern for Extensible Elixir Application Design’ 글을 추천한다. 코드 구성에 대한 힌트가 있는 ’Designing Elixir Systems with OTP (James Edward Gray II, Bruce A. Tate, 2019)’ 책보다 더 좋은 건 같다.
사이드 프로젝트에서 Application Layering 패턴을 사용해서 아키텍처를 잡았다. Layer간 데이터를 전달할 때, 중복코드가 생기는 게 괴롭지만 좀 더 경험을 쌓으면 중복 코드를 잘 해결할 수 있을 것 같다. 완전히 없애진 못할 것 같고 용납 가능한 수준으로 유지하는 게 목표다. 확실히 코드 구성이 잘 되니깐 코드 구조를 유지하는 데 비용이 적게 든다.
링크
- Hexagonal architecture (software) - en.wikipedia.org(archive)
- thoughtbot/ex_machina - github.com(archive)
- Application Layering - A Pattern for Extensible Elixir Application Design - A…(archive)
- Ecto.Adapters.SQL.Sandbox — Ecto SQL v3.12.1 - hexdocs.pm(archive)
- Contexts — Phoenix v1.7.20 - hexdocs.pm(archive)
- #cpp Pimpl 관용구 - ohyecloudy’s pnotes - ohyecloudy.com(archive)
- 단위 테스트(Unit Testing)에서 단위의 경계는 무엇인가? (feat. 고전파와 런던파) - ohyecloudy’s pnotes -…(archive)
- 모의 객체 - ko.wikipedia.org(archive)
- Application — Elixir v1.17.2 - hexdocs.pm(archive)
- Designing Elixir Systems with OTP (James Edward Gray II, Bruce A. Tate, 2019)…(archive)