헥사고날 아키텍처를 ArchUnit으로 강제하고, ktlint와 빌드 검증을 로컬 커밋·푸시 시점으로 끌어당긴 이야기. "사람의 선의"에 기대던 컨벤션을 빌드가 기계적으로 지키게 만든 과정과, 그 작업을 하며 다시 확인한 "AI 시대에도 기본기는 왜 필요한가"에 대한 내 생각을 정리했다.
Intro
- 레이어 경계 위반은 모듈 의존성에는 안 드러나고 패키지 import 수준에서만 새는데, 코드리뷰와 import-grep으로는 자꾸 놓친다.
- 그래서 헥사고날(Ports & Adapters) 규칙을 ArchUnit으로 빌드 단계에서 강제했다. 사람이 깜빡해도 빌드가 막는다.
- 도입하자마자 import-grep으로는 0건이던 domain 순수성 위반(Spring 의존) 을 ArchUnit이 잡아냈다. 기계 가드의 가치가 곧바로 증명됐다.
- 동시에 ktlint와 빌드 검증을 pre-commit / pre-push 훅으로 로컬에 당겼다. 공용 CI 러너에서야 깨지는 걸 확인하느라 다른 사람 파이프라인까지 막던 병목을 줄이기 위해서다.
- 규칙은 baseline으로 동결하지 않고 도입 시점 위반 0건을 목표로, 먼저 코드를 규칙에 맞게 정리한 뒤 가드를 켰다.
- AI가 코드를 대신 써주는 시대일수록, 무엇을 만들고 있는지 아는 것과 구조를 결정하는 능력은 더 중요해진다고 본다. 이 작업은 그 신념의 실천이었다.
출발점: "리뷰에서 또 놓쳤다"
우리 백엔드 프로젝트(이하 가명으로 프로젝트 X)는 헥사고날 아키텍처를 채택하고 있었다. 그런데 아키텍처를 "채택했다"는 말과 "지켜진다"는 말은 전혀 다른 문제였다.
가장 골치 아픈 건 레이어 경계 위반이었다. 예를 들어 application 계층이 adapter 계층을 import하면 안 되는데, 이런 위반은 Gradle 모듈 의존성 그래프에는 안 나타난다. 같은 모듈 안에서 패키지만 가로질러 import하면 빌드는 멀쩡히 통과하기 때문이다. 결국 위반은 패키지 import 수준에서만 조용히 발생하고, 그걸 잡아내는 건 사람 눈, 즉 코드리뷰뿐이었다.
문제는 사람 눈은 일관적이지 않다는 점이다. 리뷰어가 바쁘면 놓치고, PR이 크면 놓치고, 금요일 오후면 놓친다. grep으로 import를 훑어보는 것도 해봤지만, 규칙이 늘어날수록 grep 패턴이 누더기가 됐다. "이번 한 번은 괜찮겠지" 하고 넘어간 위반 하나가, 6개월 뒤에 도메인 모델이 프레임워크에 묶여 테스트조차 못 짜는 상황으로 돌아온다.
여기에 두 번째 문제가 겹쳤다. 로컬에서 검증을 건너뛰는 습관이다. ktlint를 안 돌리고 커밋하거나, 빌드가 깨지는 줄 모르고 푸시한 뒤, 공용 GitHub Actions 러너가 그걸 대신 확인해주기를 기다리는 흐름. 개인 입장에선 "러너가 알려주겠지" 싶지만, 공용 러너에서 build/test/lint가 한 번 도는 동안 다른 사람의 CI는 큐에서 대기한다. 한 명의 "로컬에서 안 돌림"이 팀 전체의 파이프라인 병목이 된다.
그래서 목표를 두 갈래로 잡았다.
- 아키텍처 규칙을 사람이 아니라 빌드가 지키게 한다 (ArchUnit).
- 스타일·빌드 검증을 공용 러너가 아니라 로컬 커밋·푸시 시점으로 당긴다 (git hook).
ArchUnit이란 무엇인가
ArchUnit은 한 줄로 말하면 "아키텍처 규칙을 테스트 코드로 작성하는 라이브러리" 다. JUnit으로 비즈니스 로직을 검증하듯, ArchUnit으로는 "이 패키지가 저 패키지를 의존하면 안 된다" 같은 구조적 제약을 검증한다.
동작 원리는 단순하다. 컴파일된 바이트코드(.class)를 스캔해서 클래스 간 의존 관계 그래프를 만들고, 그 위에서 우리가 선언한 규칙을 평가한다. 규칙을 어기면 일반 테스트가 실패하듯 빌드가 실패한다.
가장 기본적인 형태는 이렇게 생겼다.
@ArchTest
val applicationMustNotDependOnAdapter: ArchRule =
noClasses()
.that()
.resideInAPackage("com.example.app.application..")
.should()
.dependOnClassesThat()
.resideInAPackage("com.example.app.adapter..")
.because("application 은 adapter 를 알아서는 안 된다 (의존성 역전)")
읽히는 그대로다. "application 패키지에 사는 클래스 중 어느 것도 adapter 패키지를 의존해선 안 된다." 핵심은 이게 문서가 아니라 실행되는 코드라는 것이다. 위키에 적힌 컨벤션은 시간이 지나면 현실과 어긋나지만, 빌드에서 도는 규칙은 어긋나는 순간 빨갛게 터진다.
ArchUnit이 제공하는 표현 방식은 크게 세 갈래로 나눠서 익히면 편하다.
| 문법 | 용도 | 언제 쓰나 |
|---|---|---|
noClasses().should().dependOnClassesThat() |
특정 의존을 금지 | "A는 B를 import하면 안 된다" 같은 단방향 금지 규칙 |
layeredArchitecture() |
레이어 전체의 의존 방향을 한 번에 선언 | "adapter → application → domain" 같은 계층 구조 강제 |
slices().beFreeOfCycles() |
패키지 간 순환 의존 탐지 | 모듈 전체에서 사이클이 생기는 걸 막을 때 |
이 셋만 손에 익으면 웬만한 아키텍처 가드는 다 표현된다.
헥사고날 아키텍처란 무엇인가
ArchUnit이 "규칙을 어떻게 강제하느냐"라면, 헥사고날 아키텍처는 "무슨 규칙을 강제할 것이냐"에 대한 답이다.
헥사고날 아키텍처(Hexagonal Architecture), 다른 이름으로 포트 앤 어댑터(Ports & Adapters) 는 Alistair Cockburn이 제안한 구조다. 핵심 발상은 한 문장으로 압축된다.
비즈니스 로직(도메인)을 가운데 두고, 바깥세상(DB, 웹, 메시지큐)과는 "계약(포트)"으로만 대화한다.
조금 풀어보자. 전통적인 레이어드 아키텍처에서는 보통 위에서 아래로 흐른다. Controller → Service → Repository, 그리고 Service는 Repository 구현체에 직접 의존한다. 문제는 이 흐름에서 비즈니스 로직이 DB나 프레임워크에 묶인다는 것이다. JPA를 걷어내려면 Service까지 뜯어고쳐야 하고, DB 없이는 테스트도 못 짠다.
헥사고날은 이 의존 방향을 뒤집는다. 등장인물은 셋이다.
- 도메인(domain): 비즈니스 규칙 그 자체. 가장 안쪽이고, 아무것도 의존하지 않는다. 순수 코틀린/자바 객체.
- 포트(port): 도메인이 바깥세상과 주고받기 위한 인터페이스(계약). "나는 회원을 저장할 무언가가 필요하다"는 선언만 있고 구현은 없다.
- 어댑터(adapter): 포트라는 계약을 실제로 구현하는 쪽. JPA로 저장하는 어댑터, REST로 요청을 받는 어댑터 등.
포트는 다시 방향에 따라 둘로 나뉜다.
- 인바운드 포트(InPort): 바깥에서 도메인을 "호출하는" 입구. 보통 UseCase의 계약이다. (웹 요청 → 비즈니스 로직)
- 아웃바운드 포트(OutPort): 도메인이 바깥을 "필요로 하는" 출구. 영속화·외부연동의 계약이다. (비즈니스 로직 → DB)
그림으로 보면 의존 방향이 한눈에 들어온다.

여기서 가장 중요한 성질은 의존의 화살표가 전부 안쪽(도메인)을 향한다는 것이다. 어댑터는 application을 알지만, application은 어댑터를 모른다. domain은 그 누구도 모른다. 이 방향이 깨지지 않는 한, DB를 바꾸든 웹 프레임워크를 갈아치우든 도메인은 멀쩡하다. 그리고 OutPort를 모킹하면 DB 없이도 비즈니스 로직 테스트가 깔끔하게 떨어진다.
문제는, 이 "화살표 방향"이라는 게 선의에만 맡기면 너무 쉽게 깨진다는 것이다. 신입 개발자가 급하게 application 안에서 JPA 엔티티를 직접 import해버리면 그 순간 헥사고날은 종이호랑이가 된다. 빌드는 통과하니까. 바로 이 지점에서 ArchUnit과 헥사고날이 만난다.
프로젝트에 가드를 도입하다
설계는 단순하다. 위에서 본 헥사고날의 "당연한 규칙들"을 ArchUnit 규칙으로 하나씩 번역했다. 적용 범위는 레이어 구조가 명확한 순수 비즈니스 컨텍스트(BC) 모듈 4개로 한정했다. (공용 라이브러리 모듈은 레이어가 없는 flat 구조라 대상에서 뺐다. 이 결정이 나중에 함정이 되는데, 뒤에서 다룬다.)
각 모듈의 src/test/kotlin/.../architecture/HexagonalArchitectureTest.kt에 다음 규칙들을 넣었다.
| ID | 규칙 |
|---|---|
| R1 | application 은 adapter 를 의존하지 않는다 |
| R2 | domain 은 application/adapter 를 의존하지 않는다 |
| R3 | domain 은 Spring/JPA(org.springframework.., jakarta.persistence..)를 의존하지 않는다 |
| R4 | 레이어 의존 방향: adapter → application → domain (layeredArchitecture) |
| R5-a | port/inbound 인터페이스는 *InPort 네이밍 |
| R5-b | port/outbound 인터페이스는 *OutPort 네이밍 |
| R5-c | port/outbound(OutPort)는 port/inbound(InPort)를 의존하지 않는다 |
| R6 | service 직하의 public 클래스는 *UseCase 네이밍 |
| R7 | 패키지 순환 의존 금지 (slices().beFreeOfCycles()) |
대부분은 3절에서 설명한 헥사고날의 정의를 코드로 옮긴 것이다. 그런데 이 표에서 가장 배울 게 많았던 건 R5-c, 즉 "OutPort는 InPort를 의존하지 않는다"였다.
R5-c: 리뷰에서 발견한 "포트 간 방향성"
R5-c는 내가 처음부터 넣은 규칙이 아니다. PR 리뷰 과정에서 리뷰어가 짚어준 지적에서 출발했다.
InPort와 OutPort는 둘 다 application 계층 안에 있다. 그래서 처음엔 "같은 계층끼리니까 서로 참조해도 되는 것 아닌가" 싶었다. 실제로 InPort → OutPort 방향은 정상이다. UseCase(InPort 구현)가 OutPort를 호출해서 DB에 저장하는 건 당연한 흐름이니까.
문제는 그 반대 방향이다. OutPort가 InPort를 의존한다는 건, 영속화·외부연동을 담당하는 쪽이 거꾸로 UseCase를 역호출한다는 뜻이 된다. 저장소가 "나 저장 끝났으니 이제 비즈니스 로직 좀 실행해줘" 하고 application의 입구를 호출하는 그림이다. 이건 헥사고날이 그토록 지키려던 책임 분리를 정면으로 깨는 안티패턴이다.

기존 R1~R7에는 이 "포트 사이의 방향" 제약이 없었다. 같은 계층 안의 일이라 레이어 규칙으로는 안 걸리는 사각지대였던 것이다. 그래서 규칙으로 명문화했다.
@ArchTest
val outboundMustNotDependOnInbound: ArchRule =
noClasses()
.that()
.resideInAPackage("com.example.app.application..port.outbound..")
.should()
.dependOnClassesThat()
.resideInAPackage("com.example.app.application..port.inbound..")
.because("OutPort 는 InPort 를 알 필요가 없다 (헥사고날 책임 분리)")
4개 BC 모듈에 동일하게 반영했고, 로컬과 CI 모두 위반 0건으로 통과했다. 이 경험에서 얻은 교훈이 있다. 좋은 규칙은 책상에서 다 나오지 않는다. 리뷰에서 발견한 한 번의 통찰을, 다음부터는 기계가 자동으로 지키도록 규칙화하는 것. 그게 사람 리뷰와 정적 가드가 협력하는 가장 건강한 형태다.
멀티모듈의 함정: DoNotIncludeJars와 정확한 앵커링
도입하면서 가장 크게 데인 부분이 있다. 우리 모듈들은 전부 같은 base package(com.example.app)를 공유한다. 그래서 의존하는 공용 라이브러리 클래스들도 classpath에 같은 패키지로 올라온다. 아무 생각 없이 스캔하면 규칙이 공용 모듈까지 잡아먹는다.
두 가지 기법으로 막았다.
첫째, DoNotIncludeJars로 스캔 범위를 현재 모듈의 컴파일 산출물(디렉터리)로 한정했다.
@AnalyzeClasses(
packages = ["com.example.app"],
importOptions = [ImportOption.DoNotIncludeTests::class, ImportOption.DoNotIncludeJars::class],
)
DoNotIncludeJars는 jar로 올라온 의존성(즉 공용 라이브러리)을 스캔에서 제외하므로, 현재 모듈 코드만 본다.
둘째, 패키지 앵커링을 ..adapter..(임의 위치 매칭)가 아니라 com.example.app.adapter..(최상위 정확 prefix) 로 고정했다. 공용 모듈에는 cache.adapter, metrics.adapter 같은 "어댑터 패턴" 패키지가 있는데, ..adapter..로 느슨하게 잡으면 이걸 레이어 어댑터로 오인식한다. ArchUnit 규칙을 쓸 때 패키지 패턴 하나가 이렇게 미묘하게 어긋날 수 있다는 걸 몸으로 배웠다.
baseline을 동결하지 않았다
흔한 선택지 중 하나는, 규칙 도입 시점에 기존 위반들을 baseline 파일로 "동결"해두고 신규 위반만 막는 방식이다. 나는 이걸 택하지 않았다. 대신 도입 시점 위반 0건을 목표로, 규칙을 켜기 전에 코드를 먼저 규칙에 맞게 정리했다.
- port 패키지 케이싱을 소문자 표준으로 정규화(
inBound → inbound). macOS의 대소문자 비구분 파일시스템 때문에clean재빌드로 검증해야 했다. service직하에 있던 비-UseCase 협력 클래스들을service/components로 옮겨 R6을 충족시켰다.- domain 순수성을 깨던 코드를 정리했다. 대표적으로, 응답 코드 enum이 Spring의
HttpStatus에 의존하던 걸 끊어내기 위해 프레임워크 비의존 상수를 새로 만들었다.
// 공용 web 모듈: com.example.app.constant.HttpStatusCode
object HttpStatusCode {
const val OK = 200
const val BAD_REQUEST = 400
const val FORBIDDEN = 403
const val CONFLICT = 409
// ...
}
이제 enum은 HttpStatus.BAD_REQUEST 대신 HttpStatusCode.BAD_REQUEST(단순 Int 상수)를 쓴다. R3가 이걸 강제한다. baseline을 안 쓴 이유는 명확하다. 동결은 "지금의 빚을 영원히 안고 가겠다"는 선언이고, 신규 위반이 생기면 그게 "원래 있던 빚"인지 "새로 진 빚"인지 구별이 흐려진다. 위반 0건에서 출발하면 규칙은 단순해진다. 빨갛게 터지면 그건 무조건 방금 들어온 새 코드의 문제다.
기계가 사람을 이긴 순간
이 작업의 가치를 단번에 증명한 사건이 있다.
ArchUnit 도입 전, "혹시 domain이 Spring에 의존하는 데 없나" 확인하려고 import-grep을 돌렸다. 0건이었다. 깨끗해 보였다. 그런데 막상 R3 규칙을 켜자 ArchUnit이 domain 순수성 위반을 잡아냈다. grep으로는 안 보이던 것이었다.
이유는 단순하다. grep은 텍스트 패턴을 본다. 직접 import org.springframework...라고 적힌 줄만 잡는다. 하지만 의존은 그렇게만 생기지 않는다. 어떤 enum이 구현한 인터페이스가, 그 메서드 시그니처에서 Spring 타입을 반환하고 있었다. 텍스트로는 안 드러나지만 바이트코드 수준의 의존 관계로는 명백히 존재하는 결합이었다. ArchUnit은 바이트코드 그래프를 보기 때문에 이런 간접 의존까지 잡는다.
이게 내가 "왜 정적 가드가 필요한가"에 대해 드는 가장 좋아하는 사례다. 사람의 눈도, grep 같은 단순 도구도 놓치는 결합을, 구조를 이해하는 도구는 잡는다. 컨벤션을 글로 적어두는 것과, 빌드가 강제하게 만드는 것 사이에는 이만큼의 간극이 있다.
가드를 로컬로 당기다: 공용 러너 병목 줄이기
아키텍처 규칙만 빌드에 넣은 게 아니다. 처음 문제 정의에서 두 번째 축이었던 로컬 검증 강제도 함께 손봤다.
스타일·빌드 게이트를 여러 겹으로 깔되, 가능한 한 앞단(개발자 로컬)에서 먼저 걸리도록 배치했다.

- pre-commit:
ktlintFormat. staged 파일을 자동 포맷한다. 손이 안 가게 만드는 게 핵심이다. 포맷은 사람이 신경 쓸 일이 아니다. - pre-push:
ktlintCheck. 스타일 위반이 있으면 푸시 자체를 막는다. (정 급하면--no-verify로 우회는 가능하되, CI가 최종 게이트로 다시 잡는다.) - CI: build 앞에
ktlintCheckfast-fail 스텝을 둬서, 스타일 위반이면 무거운 build·test로 가기 전에 빠르게 떨군다. ArchUnit은 별도 task 없이 JUnit5@ArchTest라 일반test/check에 자연히 포함돼 자동 실행된다.
git hook은 빌드 로직에서 gradle 빌드나 IDE sync 시점에 자동 설치되게 했다. "각자 알아서 훅 깔아라"는 절대 안 지켜진다는 걸 알기 때문이다.
이 구조의 진짜 의도는 단순한 품질 관리가 아니다. 공용 CI 러너의 병목을 줄이는 것이다. 앞서 말했듯, 누군가 로컬에서 안 돌리고 푸시한 빌드가 공용 러너 한 칸을 차지하고 도는 동안, 다른 사람들의 CI는 큐에서 기다린다. 스타일 위반 하나 확인하자고 공용 러너를 점유하는 건 명백한 낭비다.
검증을 로컬로 당기면, 깨질 게 뻔한 코드는 개발자 PC에서 푸시 전에 걸린다. 공용 러너는 "로컬을 통과한, 통과할 가능성이 높은" 빌드만 받는다. fast-fail 스텝도 같은 철학이다. 어차피 떨어질 거라면 30초 만에 떨어뜨려서 러너를 빨리 비워준다. 개인의 편의("러너가 알려주겠지")가 팀의 비용("러너 큐 대기")으로 전가되던 구조를, 비용이 발생한 자리(로컬)에서 처리하도록 되돌린 것이다.
그래서, AI 시대에 이런 게 의미가 있나
이 작업을 하면서 머릿속을 떠나지 않은 질문이 있었다. "AI가 코드를 다 짜주는 시대에, 코드 배치니 클린 아키텍처니 하는 게 굳이 필요한가?"
요즘 이런 회의적인 태도를 종종 본다. 어차피 AI한테 시키면 되는데 레이어를 나누고 포트를 만드는 게 과한 의식 아니냐는 것이다. 나는 정반대로 생각한다. AI를 쓰든 안 쓰든, 시스템적으로 올바르지 않은 컨벤션은 가드로 막아야 한다. 오히려 AI 시대일수록 더 그렇다.
이유는 이렇다.
첫째, AI는 유지보수와 의사결정까지 대신해주지 않는다. AI는 주어진 맥락 안에서 그럴듯한 코드를 생성한다. 하지만 "이 도메인을 프레임워크에서 떼어내야 하는가", "이 포트의 방향이 책임 분리를 깨고 있는가" 같은 구조적 판단은 결국 사람이 한다. 6개월 뒤 이 코드를 뜯어고칠 때, 무엇을 건드리면 어디가 무너지는지 아는 건 AI가 아니라 그 구조를 이해하는 개발자다. 아키텍처는 "지금 코드를 빨리 짜는" 문제가 아니라 "나중에 바꿀 수 있게" 만드는 문제이고, 그 나중을 책임지는 건 사람이다.
둘째, 팀의 체급(실력)을 착각하면 안 된다. AI가 생산성을 올려주는 건 맞다. 그런데 그건 기본기가 받쳐주는 팀에서의 이야기다. 헥사고날이 뭔지, 의존 방향이 왜 중요한지, 도메인 순수성이 뭘 보장하는지를 아는 사람이 AI를 쓰면 증폭기가 된다. 반대로 기본을 모르는 채 AI에만 의존하면, 자기가 뭘 만들고 있는지도 모르는 채로 시간만 날린다. AI가 뱉은 코드가 맞는지 틀린지 판단할 근거가 없으니, 그럴듯하면 그냥 넣는다. 그렇게 쌓인 코드는 생산성을 올리는 게 아니라 오히려 갉아먹는다. 디버깅도, 리뷰도, 다음 변경도 전부 더 느려진다.
셋째, 그래서 가드는 "기본을 강제하는 장치"다. ArchUnit 규칙은 헥사고날의 원칙을 모르는 사람(혹은 AI)이 짜도 구조가 무너지지 않게 막는 안전망이다. 동시에, 규칙이 빨갛게 터졌을 때 "왜 이게 위반인가"를 이해하는 과정에서 팀원들이 자연스럽게 기본기를 학습한다. 가드는 단순히 막는 게 아니라 가르친다. import-grep으로는 0건이던 위반을 ArchUnit이 잡았을 때, 나는 "아, 바이트코드 레벨 결합은 이렇게 새는구나"를 배웠다. 그 학습은 AI가 대신해줄 수 없다.
결국 내가 이 작업으로 하고 싶었던 말은 하나다. 자동화는 기본기를 대체하는 게 아니라, 기본기를 가진 팀이 그 기본을 흔들림 없이 유지하도록 돕는 도구여야 한다. 헥사고날을 ArchUnit으로 강제하고, ktlint를 로컬 훅으로 당긴 건, 사람과 AI 모두가 "올바른 기본" 위에서만 움직이도록 바닥을 깐 작업이었다.
정리하며
- 아키텍처 규칙은 문서가 아니라 실행되는 코드여야 지켜진다. ArchUnit은 그걸 가능하게 한다.
- 헥사고날의 핵심은 "의존의 화살표가 전부 도메인을 향한다"는 것. R1~R7, 특히 포트 간 방향까지 강제하는 R5-c로 이 화살표를 빌드가 지키게 했다.
- 검증은 가능한 한 앞단(로컬) 으로 당겨야 한다. 개인의 편의가 공용 러너 병목이라는 팀 비용으로 전가되는 구조를 되돌렸다.
- baseline 동결 대신 위반 0건에서 출발하면 규칙이 단순해지고, 신규 위반의 책임 소재가 명확해진다.
- AI 시대에도, 아니 AI 시대일수록, 무엇을 만드는지 아는 것과 구조를 결정하는 능력은 사람의 몫이다. 가드는 그 기본을 막아주는 동시에 가르치는 장치다.
'아키텍처' 카테고리의 다른 글
| API는 소비자의 언어로 말해야 한다: 헥사고날의 진짜 경계, 그리고 AI 시대에 놓지 말아야 할 기본기 (0) | 2026.07.11 |
|---|---|
| 팀 전원이 운영할 수 있는 CI/CD를 설계하다 - 망 분리 환경에서 GitHub Actions + Helm + ArgoCD로 (0) | 2026.03.07 |
| Transactional Outbox Pattern과 적용 회고 (from. BaaS) (0) | 2025.08.01 |