티스토리 뷰
헥사고날 아키텍처를 구현하는 여러가지 방법 (1): Java 접근제한자와 ArchUnit
KimDoubleB 2024. 6. 6. 23:40서버 개발자들간에는 논쟁이 일어날 수 있는 주제가 여러가지가 있다.
그 중 대표적인 두 가지를 뽑자면 “Monolithic과 MSA”이고, 나머지 하나가 오늘 이야기 할 “Clean/Hexagonal Architecture”이다.
나는 보통 애플리케이션/프로젝트를 구성할 때 별 다른 고민없이 Layered Architecture로 구성해왔다.
그러는데는 아주 지극히 개인적인 여러 이유가 있다.
- 익숙하다. 나에게도 익숙하고, 남에게도 익숙하다. 러닝커브가 낮아 누가와도 개발할 수 있다.
- 개발이 빠르다. 익숙해서 개발이 빠른 것도 있지만, 계층별로만 역할이 나뉘고 의존성/구현 방향이 단순해 생성되는 클래스/파일의 양이 적어 결과물을 보기까지 개발속도가 빠르다.
- Layered Architecture 구조의 단점이 와닿지 않는 경우가 있다.
- Layer 간 책임을 제대로 지키지 않으므로써 발생할 수 있는 문제들은 사실 구조의 문제보단 개발자 책임으로 느껴진다.
- 영속성 계층과 도메인 객체의 강결합 문제를 겪어볼 수 있는 DB 교체, Persistent framework 교체는 거의 경험하기 어렵다.
- 등등…
사실 근데 위에서 말한건 다 변명일 뿐이다.
왜냐? 이론적으로만 배워봤고, 직접 서비스에 해당 구조를 녹여본 적이 없었기 때문이다.
그래서 항상 Hexagonal Architecture에 대한 궁금증이 있었고, 간단한 프로젝트를 구성할 기회가 생겨 이를 사용해보고자 했다.
(직접 해봐야 욕할 수 있다)
이전에 Hexagonal Architecture에 대해서는 책을 읽고 공부해본 적이 있기에, 살짝 다시 살펴보고 구현을 시작했다.
- 그 시절 아주 짧은 이해로 막 작성해봤던 글: 지금 보면 조금 그렇다… 이해도 잘 안됬었는데…
Hexagonal Architecture에 대해 설명한 글이 아니므로 개념에 대해 잘 모른다면 이 글이 읽어도 이해가 안될 수 있다.
검색해보면 자료가 많으니 구조에 대한 이해를 하고 읽기를 바란다.
근데 직접 구현을 하다보니 이론적인 구조는 알겠는데, “이 구조를 어떻게 구현하느냐”는 여러가지 선택지가 있어보였다.
단순하게 생각한다면 하나의 프로젝트/모듈로 Adapter, Domain, Application, Common으로 나눠 구성을 할 수 있다.
하지만, 이를 멀티 모듈로 나눠 아예 의존성을 따로 분리할 수도 있고, Java 접근제한자라던지, JPMS(Java Platform Module System)를 이용한다던지 등 여러 구조로 만들어볼 수 있을 것 같았다.
그래서 여러 시도를 해보았고, 구현과 그 느낀 점들을 공유해보고자 한다.
[참고]
앞에서 이야기했듯 첫 구현하며 알게된 여러 가지 사례들을 소개하는 글 입니다.
그렇다보니 더 다양한 방법이 존재할 수 있고, 소개한 구조가 사실 상 운영에는 적합하지 않은 구조일 수도 있습니다.
이 점을 유의바라며, 혹시 잘못된 부분이 있다면 언제든 편하게 댓글 부탁드립니다.
예시 구현을 보여주기 위해선 잘 정립된 예제가 필요하다. 그래서 아래의 소스를 이용하였다.
- 국내 “만들면서 배우는 클린 아키텍처” 번역 서적에서 활용하고 있는 소스이다.
위 소스를 이용해서 각 구현 별로 브랜치를 구성해놓았다. 이를 참고하자.
1. package와 Java 접근제한자 (Access Modifier)로 구현하기
참조한 코드 자체가 Java package, Java 접근제한자를 이용한 예라고 볼 수 있다.
프로젝트 내 모듈은 하나로 이루어져있고, Hexagonal Architecture 구조를 package로 분리해 구현한다.
간략히 특징을 설명해보면
- “모든 의존은 domain으로만 향하게 만든다”라는 규칙에 맞게 application.domain package에서는 외부 의존을 최대한 줄인다.
- domain 내에서는 어떤 영속성 프레임워크를 이용하는지? UI 프레임워크를 이용하는지? 알 수 없어야 한다. 특정 프레임 워크에 특화된 코드를 가지지 않고, 비즈니스 규칙에 집중한 로직만을 가져야한다.
- 다시 말하면, 최대한 POJO. 순수한 자바 객체 형태여야 한다.
- application.domain.service에서는 port를 의존하되 그 구현체를 직접적으로 의존해서는 안된다. 즉, adapter package는 알 필요가 없고, 의존해서는 안된다.
- adpater package에서도 마찬가지로 application.domain.service에 대해서 알 필요가 없으며, 의존해서는 안된다.
여기까지만 보면 “아, 구조대로 구현이 되었구나” 싶겠지만, 이대로만 둔다면 문제가 있다.
잘못된 코드 작성으로 인해 전체적인 구조가 망가질 수 있다는 것이다. 몇 가지 질문을 던져보자.
- 만약 adapter.in 로직 코드를 작성하다가 application.domain.service 클래스를 의존했다면?
- 반대로 application.domain.service 로직에서 adapter.out 클래스를 의존했다면?
- application.domain.model에서 외부 패키지 소스를 의존하거나, Spring Data JPA 같은 라이브러리 소스를 의존했다면?
코드작성 중 생각지 못한 import로 언급한 문제들이 쉽게 일어날 수 있으며, 이는 Hexagonal 구조에 어긋날 수 있다.
- “코드를 잘 작성하면 되는 것 아닌가?” 하는 의문이 들 수 있지만, 앞서 이야기한 것처럼 그러한 실수조차 막아줘야 하는 것이 구조적인 기능이라고 생각한다.
- 프로젝트가 소규모이거나, 팀이 작거나, 리뷰가 미친듯이 활발하다면 이러한 문제를 최대한 막을 수 있겠지만, 완벽히 막지는 못한다.
- 큰 팀 일수록 이게 더 어렵다. 큰 팀 일수록 이러한 문제가 발생하지 않도록 강력한 컨벤션이 필요한데 그것이 꽤나 어렵기 때문이다 (프로그래밍의 규칙, 12장 큰 팀에는 강력한 컨벤션이 필요하다)
그럼 이러한 문제를 어떻게 막을 수 있을까? 바로 여기에서 Java 접근 제한자를 이용할 수 있다.
Java Access Level에서 default (package-private 혹은 no modifier) 접근제한자는 최대 package 단위이다.
다시 말해서, 접근제한자를 명시하지 않은 클래스는 package 단위에서만 의존하고 사용할 수 있다는 것이다.
@RequiredArgsConstructor
class GetAccountBalanceService implements GetAccountBalanceUseCase {
...
}
위 언급했던 문제들을 해결하기 위해 port 구현 클래스들에 대해 default 접근제한자로 구성해보자.
- [이전질문] 만약 adapter.in 로직 코드를 작성하다가 application.domain.service 클래스를 의존했다면?
- application.domain.service 클래스들은 application.domain.service 패키지 내에서만 접근 가능하므로 의존할 수 없다.
- [이전질문] 반대로 application.domain.service 로직에서 adapter.out 클래스를 의존했다면?
- adapter.out 클래스들은 adapter.out 패키지 내에서만 접근 가능하므로 의존할 수 없다.
- [이전질문] application.domain.model에서 외부 패키지 소스를 의존하거나, Spring Data JPA 같은 라이브러리 소스를 의존했다면?
- 모듈 내 구현체 클래스들은 해당 패키지에서만 접근 가능하게 변했으므로 application.domain.model에서 패키지 내 타 소스를 의존할 수 없다.
- Spring Data JPA 같은 프레임워크에 대한 의존은 막을 수 없다. 계속 접근이 가능하다.
어느정도 앞선 문제들은 해결했다. 하지만, 아직 남은 문제가 있다.
- application.domain package 내 클래스들에서 외부 프레임워크 의존하는 것을 막을 수 없다.
- default 접근 제한자를 사용한다 한들, 앞서 말한 개발자의 실수로 인해 구조가 무너지는 문제는 아직까지도 발생할 수 있다.
2. ArchUnit: Test로 문제 해결하기
ArchUnit은 Architecture Unit test framework로, 테스트 코드를 이용하여 아키텍처를 테스트 할 수 있도록 지원하는 프레임워크다. 더 자세한 내용은 Naver 블로그 글을 참고하자.
보통 CI (Continuous Integration) 과정에서 Build를 수행하게 되고, 함께 Test를 수행한다.
이 Test 과정에서 개발자의 실수로 인해 잘못 의존 관계를 가지고 있는 코드에 대해 확인하여 실패하도록 한다면 앞선 문제를 해결할 수 있을 것이다.
예제 코드를 한번 살펴보자.
@Test
void domainModelDoesNotDependOnOutside() {
noClasses()
.that()
.resideInAPackage("io.reflectoring.buckpal.application.domain.model..")
.should()
.dependOnClassesThat()
.resideOutsideOfPackages(
"io.reflectoring.buckpal.application.domain.model..",
"lombok..",
"java.."
)
.check(new ClassFileImporter()
.importPackages("io.reflectoring.buckpal.."));
}
application.domain.model 패키지 내 클래스에서는 resideOutsideOfPackages 에서 정의된 패키지들에 대한 의존만 허용하고, 나머지 의존은 허용하지 않는다.
만약 application.domain.model 패키지에서 Spring Data 프레임워크에 대한 의존을 가지게 되면 어떻게 될까?
package io.reflectoring.buckpal.application.domain.model;
import org.springframework.data.annotation.Id;
...
public class Account {
@Id // Spring Data
private final AccountId id;
...
}
위 사진처럼 테스트에 실패하는 것을 볼 수 있다.
예제의 테스트 코드를 보면 알겠지만, 아예 Hexagonal Architecture 구조에 대한 ArchUnit 테스트를 구성해놓은 것도 존재한다. HexagonalArchitecture 클래스를 만들어두고, 이를 활용하는데 흥미롭다. 한번 쯤 확인해봐도 좋을 것 같다.
public class HexagonalArchitecture extends ArchitectureElement {
private Adapters adapters;
private ApplicationLayer applicationLayer;
private String configurationPackage;
private List<String> domainPackages = new ArrayList<>();
public static HexagonalArchitecture basePackage(String basePackage) {
return new HexagonalArchitecture(basePackage);
}
public HexagonalArchitecture(String basePackage) {
super(basePackage);
}
public Adapters withAdaptersLayer(String adaptersPackage) {
this.adapters = new Adapters(this, fullQualifiedPackage(adaptersPackage));
return this.adapters;
}
ß
public HexagonalArchitecture withDomainLayer(String domainPackage) {
this.domainPackages.add(fullQualifiedPackage(domainPackage));
return this;
}
public ApplicationLayer withApplicationLayer(String applicationPackage) {
this.applicationLayer = new ApplicationLayer(fullQualifiedPackage(applicationPackage), this);
return this.applicationLayer;
}
public HexagonalArchitecture withConfiguration(String packageName) {
this.configurationPackage = fullQualifiedPackage(packageName);
return this;
}
private void domainDoesNotDependOnAdapters(JavaClasses classes) {
denyAnyDependency(
this.domainPackages, Collections.singletonList(adapters.basePackage), classes);
}
public void check(JavaClasses classes) {
this.adapters.doesNotContainEmptyPackages();
this.adapters.dontDependOnEachOther(classes);
this.adapters.doesNotDependOn(this.configurationPackage, classes);
this.applicationLayer.doesNotContainEmptyPackages();
this.applicationLayer.doesNotDependOn(this.adapters.getBasePackage(), classes);
this.applicationLayer.doesNotDependOn(this.configurationPackage, classes);
this.applicationLayer.incomingAndOutgoingPortsDoNotDependOnEachOther(classes);
this.domainDoesNotDependOnAdapters(classes);
}
}
아무튼, 구조에 대한 테스트를 지원함으로서 개발자가 실수하는 것을, 잘못 작성된 코드가 프로덕션에 반영되는 것을 방지할 수 있다.
하지만 나는 이렇게 구성함에도 아직 몇 가지 문제가 남아있다고 생각한다.
[1] 개발 중에는 아직 실수할 수 있다.
모든 외부 라이브러리 의존성을 모든 패키지가 동일하게 가져가기 때문에 알 필요가 없는 의존성에 대한 접근이 열려있으므로, 개발 중에는 실수를 할 수 있다.
테스트에서 확인해 문제를 수정하면 해결되겠지만, 수정을 위한 추가 작업 및 피로감 등이 발생할 수 있다.
[2] 테스트를 수행하지 않는다면 기존의 문제점은 계속해서 발생한다.
“테스트를 왜 안돌려?” 할 수 있겠지만, hotfix/긴급배포 등으로 인해 테스트를 잠시 무시하고 빌드/배포를 수행해야하는 경우가 (흔하지는 않지만) 발생한다.
테스트를 살리는 것을 까먹거나, 테스트 무시하는 것에 익숙(실용주의 프로그래머: Topic 3 깨진 창문)해진다면 ArchUnit으로 얻을 수 있는 이점이 없어지는 것이다.
이런 문제는 어떻게 막을 수 있을까?
다음 글에서 알아보자.
'Development > Java, Kotlin, Frameworks' 카테고리의 다른 글
헥사고날 아키텍처를 구현하는 여러가지 방법 (2): 멀티모듈, JPMS 그리고 Kotlin (1) | 2024.06.07 |
---|---|
[Kotlin] Enum.values() 사용하지 말자, 이제 (1) | 2023.08.22 |
Serverless - Spring (with GraalVM Native) (1) | 2023.04.17 |
Spring boot - API Versioning (0) | 2023.04.16 |
[springdoc-openapi / Swagger] Failed to load remote configuration 이슈 해결 (1) | 2023.01.11 |
- Total
- Today
- Yesterday
- HTTP
- 쿠버네티스
- docker
- java
- 하루
- 알고리즘
- 일상
- 비동기
- Spring
- Intellij
- 로그
- Istio
- boj
- Spring boot
- 클린 아키텍처
- Kubernetes
- gradle
- k8s
- hexagonal architecture
- c++
- WebFlux
- Log
- python
- 백준
- tag
- jasync
- Clean Architecture
- container
- MySQL
- Algorithm
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |