Clean Architecture Packaging Strategy
TL; DR
Clean Architecture 를 구성할 때에도 Package by Feature 방식을 고려해봅시다.
What is Package?
(일반적으로) 사람들은 잘 정돈된 책상을 보면 기분이 좋아집니다. 물론 사람에 따라 책상을 보는 것 자체가 기분이 안 좋을 수도 있지만, 여전히 펜과 커피잔, 컴퓨터와 칫솔이 마구잡이로 놓여있는 책상을 보는 것 보다는 잘 정돈된 책상이 기분이 좋을 것입니다. 잘 정돈되어 있는 책상에서는 원하는 물건을 바로 찾을 수 있고, 새로운 물건을 배치할 때 어디에 배치해야 하는지도 쉽게 알 수 있습니다. 우리 코드도 그렇습니다.
package 는 필요한 코드의 묶음입니다. 비슷한 코드를 한 군데 두어서 찾기 쉽고 수정하기 쉽게 만들어 줍니다. 언어에 따라서 package 의 정의와 관리 및 사용하는 방법이 다르지만, package 라는 개념 자체에 대한 접근은 거의 비슷합니다. 이후 사용할 package 라는 단어가 java 의 package 와 동일한 개념은 아니지만, 많은 부분 적용 가능한 개념이라고 볼 수 있습니다.
책상 정리에 여러 방법이 있듯이, package 구성 방법도 여러 방법이 있습니다. 대표적으로 비교되는 방식이 package by layer 와 package by feature 방식입니다. 각각이 어떤 방식인지에 대해서는 folder 구조를 가볍게 비교하는 정도로 설명하고 넘어가겠습니다.
Package by Layer
Class 의 역할에 따라서 package를 나누는 방식입니다. rails 를 사용하는 (혹은 사용했던) 개발자들에게는 매우 익숙한 형태입니다.
controllers/
ArticlesController.java
UsersController.java
models/
Article.java
User.java
adapters/
ArticlesAdapter.java
UsersAdapter.java
Package by Feature
Class 의 기능에 따라서 package 를 나누는 방식입니다.
article/
Article.java
ArticlesAdapter.java
ArticlesController.java
user/
User.java
UsersAdapter.java
UsersController.java
Package by Feature is Better, in most cases.
모든 선택이 tradeoff 가 있듯이 두 방식 모두 각자의 장단점을 가지고 있습니다만, feature 중심의 packaging 방식이 진영 싸움에서 우위를 가져가고 있습니다. Package by feature 방식이 Package Principles 들을 더 잘 지키는 방향이고, 이를 통해 얻을 수 있는 이점이 충분히 많기 때문입니다.
Package Principles
- Reuse-release Equivalence Principle (REP)
- Common-Reuse Principle (CRP)
- Common-Closure Principle (CCP)
- Acyclic Dependencies Principle (ADP)
- Stable-Dependencies Principle (SDP)
- Stable-Abstractions Principle (SAP)
프로그램이 커지고 복잡해짐에 따라 package by layer 가 가지는 한계 역시 package by feature 를 선호하는 이유 중에 하나이기도 합니다. 프로그램이 커지다 보면 점차 아래와 같은 모습을 띠게 되고, 점점 뭔가 잘못되고 있다는 느낌을 받게 됩니다.
controllers/
ArticlesController.java
CampaignsController.java
LoginController.java
PreferencesController.java
UsersController.java
...
models/
Article.java
Campaign.java
Session.java
Preferences.java
User.java
...
adapters/
ArticlesAdapter.java
UsersAdapter.java
...
Wait a minute, I’m used to package by layer than feature.
우리는 개념을 익힐 때 기능 별로 공부하는 것이 아니라 역할 별로 공부합니다. 예를 들어 Adapter Pattern 을 익힌다고 했을 때 User Adapter 와 Article Adapter 를 각각 공부하지 않죠. Adapter 를 공부한 뒤에 User 와 Article 에 적용합니다. 우리가 배운 방식대로 코드에 적용하려니 자연스럽게 package by layer 가 익숙하게 느껴집니다. “Controller 는 어디서 찾지? Controller folder 안에서 찾아야지.”
초기에 프로젝트를 구성하면 이런 방식이 어색하게 느껴지지 않습니다. 직관적이니까요. 덕분에 처음 이해가 쉬운 장점이 있습니다. 하지만 프로젝트가 커짐에 따라 하나의 기능 수정을 위해 여러 package 를 수정해야 하는 상황들이 자주 발생하고, package 간에 dependency 가 명확하지 않기 때문에 tightly-coupling 된 구성을 가지게 됩니다.
Clean Architecture Packages
Robert C. Martin 이 제안한 Clean architecture 는 layer 를 명확하게 나누고 implementation 과 interface 를 확실하게 구분함으로써 유연하고 깔끔한 소프트웨어 구현을 가능하게 합니다. 유행하는(?) framework 들이 직접적으로 제안하거나 미리 준비를 해주지 않기 때문에 대다수의 사람들에게 친근하게 사용되고 있지는 않지만, 잘 사용되었을 경우 더욱 깨끗하고 아름다우며 변화에 유연하고 안정적인 구현을 가능하게 도와줍니다.
한편 실제 구현하는 방식에서 ‘이걸 따라하면 된다’ 하는 정답에 가까운 가이드라인은 없습니다. Class 를 어떤 식으로 나눠야 하는지, Usecase 를 모아놔야 할지 아니면 기능마다 파일을 만들어야 할지, package 를 어떻게 구성해야 하는지 같은 것들 말이죠.
Clean Architecture: Package by Layer
그래서 그런지 Clean architecture 를 구성한 예제 코드들을 보면 package by layer 로 구성되어 있는 경우가 종종 보입니다. domain, data, presentation layer package 를 나누고 Usecase, Repository interface 등을 domain package 안에 넣고, repository 구현체는 data package 안에 넣는 방식입니다. (e.g go-cleanarchitecture)
(아래에서는 좀 더 명확한 차이를 보기 위해 package 사용 구분이 명확하고 활용도가 높은 go project 를 예시로 들겠습니다.)
domain/
repo/
article_repo.go
user_repo.go
usecase/
article_usecase.go
article_usecase_test.go
user_usecase.go
user_usecase_test.go
data/
article_pgrepo.go
article_pgrepo_test.go
user_pgrepo.go
user_pgrepo_test.go
개념을 이해한 대로 package 를 구성했기 때문에 clean architecture 를 잘 적용한 것으로 보이지만, 위에서 언급한 package by layer 의 문제점이 존재하게 됩니다.
Clean Architecture: Split by Feature, Package by Feature
한편 feature 단위로 나눈 뒤에 내부 구성을 package by layer 로 구성한 예제들도 있습니다. (e.g go-clean-arch)
article/
usecase/
article_usecase.go
article_usecase_test.go
repo/
pgrepo.go
entity.go
repo.go
usecase.go
user/
usecase/
user_usecase.go
user_usecase_test.go
repo/
pgrepo.go
entity.go
repo.go
usecase.go
이 경우 root package 가 feature 단위로 되어있기 때문에 package by feature 형식으로 구성된 것처럼 보입니다. 또한 내부 package 가 layer 단위로 나눠져 있기 때문에 개념적으로 이해도 쉬워 보이죠.
하지만 이렇게 구성될 경우 실질적으로 usecase 나 repo folder 안에 두 개 이상의 구현이 들어갈 일이 거의 없게 될 것임을 추측할 수 있습니다. 이미 feature 단위로 나누었기 때문에 내부 folder 안에 여러 구현체가 들어갈 일이 별로 없는 것이죠. 혹, 오히려 여러 구현체가 들어가야 하는 상황이 될 경우 다시 package by layer 의 문제가 발생합니다.
package 를 사용하는 시점에도 여전히 문제가 발생합니다. layer 이름으로 package 를 import 해야하기 때문에 어떤 feature 를 사용하는지 알 수 없습니다. method 이름으로 구분하는 방법(e.g usecase.NewArticleUsecase()
)을 선택할 수도 있지만 여전히 package 이름이 겹치기 때문에 alias 를 해야만 하는 문제도 있습니다.
r := repo.New() // What kind of repository implementation?
u := usecase.New(r) // Which usecase?
Clean Architecture: Package by Feature
Clean architecture 의 개념은 명확합니다. 잘 적용되면 깨끗하고 유연한 코드를 작성할 수 있습니다. 하지만 package 가 직접적으로 그 개념을 표현해야만 하는 것은 아닙니다. 오히려 개념이 명확하기 때문에 package 를 layer 단위로 구분해 주지 않아도 충분히 표현이 가능합니다.
article/
pgrepo/
pgrepo.go
entity.go
repo.go
usecase.go
usecase_test.go
user/
pgrepo/
pgrepo.go
entity.go
repo.go
usecase.go
usecase_test.go
위 구성에서는 feature 중심으로 packaging 하고 내부 구성은 파일 이름으로 분류합니다. repository 는 interface 로 구성하고, 실제 implementation 은 따로 package 를 구성하여 그 둘의 구분을 분리합니다. interface 와 implementation 을 구분함으로써 data layer 의 수정이나 변경이 domain layer 에 영향을 미치지 않게 합니다.
pgrepo 는 postgres 로 구성한 repository 의 구현 package 입니다. sqliterepo, memrepo, mockrepo 등 다른 구현체를 구성할 수 있습니다. data layer package 를 따로 구성하지 않고 package 안쪽에 배치함으로써 package 간의 dependency 를 더 명확하게 의도할 수 있습니다.
package 를 사용하는 입장에서도 불필요한 이름이 반복되지 않고 명확하게 의도를 전달할 수 있습니다.
r := pgrepo.New()
u := article.NewUsecase(r)
Consideration
pgrepo 라는 package 이름은 postgres 라는 기능적인 의미를 가지고는 있지만 article 의 pgrepo 와 user 의 pgrepo 간의 구분이 이루어지지 않습니다. 때문에 package 를 import 하는 시점에 alias 를 해야만 구분하여 사용 가능합니다. 그렇다고 userpgrepo 라는 이름을 쓰기에는 user package 내부 package 에 user 라는 이름이 반복적으로 사용되는 아쉬움이 남습니다. (e.g https://github.com/gogs/gogs/tree/master/pkg/auth 같은 경우를 살펴보면 auth 안에 github 이라는 이름의 package 가 github 을 사용하는 auth 구현을 담고있지만, github 이라는 package 이름이 auth 를 담고 있지는 않습니다.)
Clean Architecture: Split by Layer, Package by Feature
한편 제일 상위 레이어를 layer 기준으로 구분하고, 내부 packaging 을 feature 를 기준으로 작성하는 방법도 있습니다.
domain/
article/
entity.go
repo.go
usecase.go
usecase_test.go
user/
entity.go
repo.go
usecase.go
usecase_test.go
data/
articlerepo/
repo.go
repo_test.go
userrepo/
repo.go
repo_test.go
이 방식은 java 를 사용할 때 더 장점이 두드러집니다. 상위 layer 구분을 java package — 앞에서 사용하던 package 와 같은 단어는 아닙니다 — 대신 module 로 구성하고 내부는 package 로 구성함으로써 java package 가 물리적으로 나누지 못하는 경계를 나눠주게 됩니다. (e.g module 간에는 circular dependency 가 불가능합니다.)
사용하는 시점에도 좀 더 명확하게 의미를 전달해줄 수 있습니다.
r := articlerepo.New()
u := article.NewUsecase(r)
다만 이렇게 구성할 경우 feature 단위의 구성이 여러 군데로 흩어지는 아쉬움이 남을 수 있습니다.
Conclusion
개념을 이해하는 것과 코드로 옮기는 것 사이에는 분명한 간극이 있습니다. 문서를 읽을 때는 명확해 보이던 것들도 직접 코드로 옮기다 보면 많은 예외 상황을 마주하게 되죠. 같은 생각을 구현할 때 다양한 방법이 존재하고, 방법에 따른 tradeoff 가 발생합니다. 직관적으로 보이는 방법이 항상 옳은 방법은 아닙니다. 마치 Square 가 Rectangle 을 상속 받으면 안되는 것 처럼 말이죠.
많은 software architecture 가 계속해서 제안되고 개선되고 있죠. 어떤 architecture 를 사용할 것인가도 중요하지만, 그 architecture 를 구현하는 시점에도 역시 많은 고민과 선택이 필요합니다. 물론 지금 내린 선택이 시간이 흐르고 나면 그른 선택이 될 수도 있고 내일 생각이 달라져서 다른 선택을 하고 싶은 마음이 들 수도 있습니다. 하지만 지금 하고 있는 이 고민과 결정이 분명히 팀과 개인 모두의 성장을 가져올 것이라고 믿습니다.
ps. Buzzvil 에서는 Package Principles 를 충분히 고려하면서 상황에 맞는 packaging 전략을 선택하고 있습니다. 물론 여전히 많은 경우 하나의 유일한 답이 나오지는 않습니다. 하지만 깊은 고민을 통해 하나씩 원칙을 세워 나가며 더욱 직관적이고 깨끗한 구현을 향해 달려가고 있습니다. 즐거운 여정을 함께 할 인재를 찾고 있으니 언제든 마음껏 연락 부탁 드립니다.
pps. Medium follow 와 박수 좀 눌러주세요. 기분이 좋아져요. 제 기분이요 😉
References
- Package by feature, not layer
- PBF(Package by Feature), no more PBL(Package by Layer)
- Package Principles
- Package Oriented Design
- https://github.com/android10/Android-CleanArchitecture/tree/core/refactor-project-structure
- https://github.com/bxcodec/go-clean-arch
- https://github.com/manuelkiessling/go-cleanarchitecture