
분명히 설계 문서에 적어뒀습니다.
"도메인 간 직접 참조 금지. 다른 도메인은 이벤트로만 통신한다." ADR 에 또박또박 적혀 있었고, 팀원도 다 읽었고, 저도 그게 우리 시스템의 약속이라고 믿었습니다.
AI 는 ADR 을 읽지 않습니다. 읽어도 지킨다는 보장이 없습니다. AI 에게 "outbound 의 재고를 참조해서 inbound 검수를 처리해줘" 라고 하면, AI 는 가장 그럴듯하고 가장 빠른 길 — 옆 도메인 repository 를 직접 부르는 길 — 을 택합니다. 문서에 적힌 경계는 AI 의 입장에서는 존재하지 않는 벽이었습니다.
설로인에서 WMS 를 AI 와 함께 개발하면서 인식이 크게 바뀐 지점이 있습니다. AI 를 쓰기 때문에 아키텍처가 덜 중요해진 게 아니라, 오히려 더 중요해졌다는 것. 그리고 그 아키텍처를 지켜내는 주역이 ADR 같은 문서 가 아니라 detekt·ktlint·ArchUnit 같은 도구 로 바뀌었다는 것. 그 이야기를 정리해 보았습니다.
ADR(Architecture Decision Record)을 비롯한 아키텍처 문서는 본질적으로 약속입니다. "우리는 이렇게 하기로 했다" 를 기록하고, 그 기록을 사람이 읽고, 이해하고, 자발적으로 따를 것 을 전제합니다.
이 전제는 사람만 코드를 쓰던 시절에는 그럭저럭 작동했습니다. 코드를 쓰는 속도와 리뷰하는 속도가 비슷했고, 경계를 넘는 코드를 짜려면 사람이 "어, 이거 ADR 에 하지 말라고 했던 건데" 하고 잠깐 멈칫하는 순간이 있었으니까요. 문서가 사람의 머릿속에서 한 번 작동할 틈이 있었습니다.
문제는 그 전제가 깨졌다는 것 입니다. AI 는 ADR 을 양심으로 삼지 않습니다. 멈칫하지도 않습니다. 그리고 사람보다 압도적으로 빠르게, 압도적으로 많은 코드를 쏟아냅니다. 약속을 지킬 주체가 사라졌는데 약속만 벽에 붙어 있는 상황 — 그게 AI 와 개발하는 현장의 기본값이었습니다.
기존 코드 품질 관리는 암묵적으로 두 가지를 가정하고 있었습니다.
AI 가 들어오면서 두 가정이 동시에 무너졌습니다.
첫째, 생성 속도가 리뷰 속도를 추월합니다. AI 는 한 번에 수백 줄을 만듭니다. 사람 리뷰어는 그 속도를 따라가지 못합니다. 따라가려고 대충 보면, 리뷰는 "동작하는가" 만 확인하는 통과 의례가 됩니다.
둘째, AI 가 만든 코드는 "그럴듯하게 틀립니다". 사람이 실수하면 대체로 티가 납니다 — 변수명이 이상하거나, 구조가 어색하거나. 그런데 AI 가 만든 코드는 표면적으로 매끄럽습니다. 함수가 길어도 들여쓰기가 깔끔하고, 도메인 경계를 넘어도 import 한 줄이라 눈에 안 띄고, 매직 넘버를 박아도 주변 코드와 자연스럽게 어울립니다. "동작하고, 깔끔해 보이는" 코드는 사람 리뷰어의 방어선을 가장 쉽게 통과합니다.
여기서 결론은 하나였습니다. 사람의 눈에 의존하는 규칙은 AI 시대에 살아남지 못한다. 규칙이 의미를 가지려면, 사람이 안 봐도 빌드를 깨뜨리는 실행 가능한 제약 이어야 합니다. 문서에 적힌 "하지 말자" 가 아니라, 하면 컴파일/테스트/CI 가 빨갛게 터지는 벽이어야 합니다.
그래서 무게중심을 문서 → 도구 로 옮겼습니다.
가장 먼저 박은 가드레일은 정적 분석이었습니다.
# detekt.yml — AI 가 만든 코드의 비대함에 상한을 건다
complexity:
LongMethod:
threshold: 40 # 40줄 넘는 메서드 = 빌드 실패
CyclomaticComplexMethod:
threshold: 12 # 분기가 너무 깊어지면 차단
NestedBlockDepth:
threshold: 4 # 중첩 4단계 이상 금지
TooManyFunctions:
thresholdInClasses: 15
style:
MagicNumber:
active: true # 의미 없는 상수 박기 금지
ReturnCount:
max: 3
특히 NestedBlockDepth 는 개인적으로 의미가 큰 룰입니다. 도메인을 분리하면서 절반쯤 지워버린 그 보상 트랜잭션 코드 — "A 실패하면 B 롤백, B 롤백 실패하면 또 처리" 같은 깊은 분기 더미 — 가 정확히 NestedBlockDepth 가 잡았어야 할 코드였습니다. 사람이 한 줄씩 끼워 넣을 때는 "어쩔 수 없지" 하고 넘어갔지만, 중첩 4단계에서 빌드가 터졌다면 그 코드는 애초에 자라지 못했을 겁니다. 도구는 "이 정도면 됐어" 라고 타협하지 않습니다.
AI 시대에 이 룰들의 가치는 배가됩니다. AI 는 요구사항을 받으면 일단 한 메서드 안에서 다 해결하려는 경향이 있습니다. 분기를 추가하라고 하면 기존 메서드에 if 를 한 단 더 파묻습니다. detekt 의 상한이 없으면 이 비대화가 무한히 진행됩니다. 상한이 있으면 AI 는 (또는 AI 에게 지시하는 제가) 함수를 쪼개거나 책임을 분리하는 방향으로 강제됩니다. 도구가 좋은 설계를 유도 하는 셈입니다.
ktlint·detekt 가 "한 파일 안" 의 품질을 본다면, ArchUnit 은 "파일과 파일 사이", 즉 아키텍처 그 자체를 강제합니다. 그리고 이게 AI 개발에서 가장 큰 차이를 만들었습니다.
도입부에서 말한 그 사건 — inbound 가 outbound repository 를 직접 부르고 있던 일 — 을 다시는 못 일어나게 만든 게 ArchUnit 입니다. 경계를 테스트 코드 로 박아두면, AI 가 그 경계를 넘는 순간 테스트가 빨갛게 터집니다.
@AnalyzeClasses(packages = ["com.example.myproject"])
class ArchitectureTest {
// 도메인끼리 서로를 직접 참조하지 않는다 — 통신은 이벤트로만
@ArchTest
val 도메인은_서로_직접_의존하지_않는다 =
slices()
.matching("com.example.myproject.(*)..")
.should().notDependOnEachOther()
// 컨트롤러가 리포지토리를 건너뛰어 직접 호출하지 못한다
@ArchTest
val 레이어_의존성_방향을_지킨다 =
noClasses()
.that().resideInAPackage("..presentation..")
.should().dependOnClassesThat()
.resideInAPackage("..persistence..")
// 도메인 모델은 프레임워크/인프라에 오염되지 않는다
@ArchTest
val 도메인은_스프링에_의존하지_않는다 =
noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAnyPackage("org.springframework..", "..persistence..")
}
이 테스트가 CI 에 박혀 있으면 무슨 일이 벌어지냐면 — AI 에게 "inbound 검수에서 재고를 확인해줘" 라고 시켰을 때, AI 가 옆 도메인 repository 를 직접 부르는 코드를 만들면 PR 의 CI 가 그냥 실패합니다. 그러면 저는(또는 AI 는) 재고를 직접 부르는 대신 이벤트를 구독하는 올바른 방향으로 갈 수밖에 없습니다.
핵심은 이겁니다. 도메인 분리는 비싼 작업입니다. 보상 트랜잭션을 걷어내고, Kafka 를 깔고, outbox/inbox 를 붙이고 — 그렇게 어렵게 경계를 세워놓습니다. 그런데 그 경계는 가만 두면 침식됩니다. 특히 AI 가 매일 코드를 쏟아내는 환경에서는 빠르게 침식됩니다. 누군가(사람이든 AI 든) "이번 한 번만 옆 도메인 직접 부르면 편한데" 하는 순간, 비싸게 세운 경계가 무너집니다.
ArchUnit 은 그 경계를 실행 가능한 불변식 으로 만듭니다. 문서는 "우리 이렇게 하기로 했어" 라고 말하지만, ArchUnit 은 "이렇게 안 하면 머지 못 해" 라고 말합니다. AI 시대에 경계를 지키는 건 후자뿐입니다.
오해를 막자면, ADR 을 버리자는 이야기가 아닙니다. ADR 과 도구는 다른 질문에 답합니다.
| 답하는 질문 | 강제력 | AI 에 대한 효과 | |
|---|---|---|---|
| ADR / 문서 | 왜 이렇게 결정했나 | 없음 (약속) | AI 는 잘 안 읽음 |
| detekt / ktlint | 이 코드가 너무 복잡/지저분한가 | 빌드 실패 | 자동 차단 |
| ArchUnit | 이 의존성이 경계를 넘었나 | 테스트 실패 | 자동 차단 |
ADR 이 답하는 "왜" 는 도구가 절대 답하지 못합니다. 왜 도메인을 분리했는지, 왜 보상 트랜잭션을 버리고 상태 동기화를 택했는지 — 이 맥락은 문서에만 남습니다. 그리고 이 맥락은 AI 에게 프롬프트로 떠먹여 줄 때 오히려 더 중요해집니다. AI 에게 "왜 이렇게 설계됐는지" 를 알려주면 더 좋은 코드를 만듭니다.
하지만 "그래서 그 결정이 코드에서 지켜지고 있는가" 는 ADR 이 보장하지 못합니다. 그건 도구의 몫입니다.
문서는 의도를 기록하고, 도구는 의도를 강제한다. 사람만 코드를 쓰던 시절엔 기록만으로도 어느 정도 강제가 됐기 때문에 이 둘의 경계가 흐릿했습니다. AI 가 들어오면서 그 경계가 선명해졌고, 강제 쪽의 무게가 압도적으로 커졌습니다.
이번 경험을 한 줄로 줄이면 이렇습니다.
AI 는 가드레일 안에서는 무섭게 빠르고, 가드레일 밖에서는 무섭게 위험하다.
가드레일이 없는 상태에서 AI 에게 코드를 맡기면, 단기적으로는 빠르지만 시스템이 조용히 무너집니다. 경계가 침식되고, 복잡도가 비대해지고, 스타일이 파편화됩니다. 그리고 이 모든 게 "동작하고 깔끔해 보이는" 가면을 쓰고 있어서 리뷰를 통과합니다. 부채가 보이지 않게 쌓입니다.
반대로 가드레일 — detekt 의 복잡도 상한, ArchUnit 의 경계 불변식 — 을 코드로 박아두면, AI 는 그 안에서 엄청난 생산성을 냅니다. 잘못된 방향으로 가면 CI 가 즉시 빨갛게 막아주니까, AI 의 속도를 안심하고 쓸 수 있습니다. AI 를 잘 쓰는 법은 더 똑똑한 프롬프트가 아니라, AI 가 어길 수 없는 벽을 코드로 세워두는 것 이었습니다.
그리고 이 벽들도 — 지난 글에서 분산 시스템 패턴들이 그랬듯 — 짝을 이룰 때 의미가 생깁니다.
| 짝 | 막아주는 것 |
|---|---|
| ktlint + detekt | 한 파일 안의 스타일 파편화 + 복잡도 비대화 |
| ArchUnit + 도메인 분리 | 비싸게 세운 경계의 조용한 침식 |
| 도구(강제) + ADR(맥락) | "지켜지는가" + "왜 그렇게 정했나" |
| 가드레일 + AI | 안전한 속도 |
ArchUnit 만 있고 도메인을 안 나눴으면 강제할 경계가 없고, 도메인만 나누고 ArchUnit 이 없으면 그 경계가 침식됩니다. 도구만 있고 ADR 이 없으면 "왜" 가 사라져 6개월 뒤 아무도 이유를 모르고, ADR 만 있고 도구가 없으면 그 결정이 코드에서 지켜지는지 아무도 모릅니다.
AI 와 개발하기 전에는 "아키텍처는 좋으면 좋고" 정도의 영역이었습니다. 문서로 약속해두고 사람이 적당히 지키면 됐으니까요. AI 와 개발하면서 그게 바뀌었습니다. 약속을 지킬 사람이 루프에서 빠지고, 약속을 모르는 빠른 생성기가 그 자리에 들어왔습니다.
그래서 아키텍처는 "문서로 적어두는 의도" 에서 "코드로 강제되는 불변식" 으로 형태를 바꿔야 했습니다. detekt 가 복잡도를, ktlint 가 스타일을, ArchUnit 이 경계를 — 사람의 양심이 아니라 빌드의 빨간불이 지킵니다.
ADR 은 여전히 씁니다. 다만 ADR 에 "도메인 간 직접 참조 금지" 라고 적었다면, 이제는 반드시 그 옆에 그것을 강제하는 ArchUnit 테스트가 한 개 붙어 있어야 합니다. 강제되지 않는 결정은, AI 시대에는 결정이 아니라 희망사항입니다.
빠르게 코드를 쏟아내는 AI 를 두려워할 필요는 없습니다. 두려워해야 할 건 가드레일 없이 그 속도를 쓰는 것 이고, 그 가드레일을 세우는 일이 바로 우리가 할 아키텍처입니다.
감사합니다.. 제가 영감을 얻어가도될까요 ㅠ.ㅠ