AI
AI

AI 코딩 에이전트가 일 잘하는 코드베이스 — NestJS 모놀리스 실측으로 본 구조 설계 원칙

조회 0
  • #AI 코딩 에이전트
  • #Claude Code
  • #NestJS
  • #Modular Monolith
  • #Vertical Slice
  • #리팩토링
  • #dependency-cruiser
  • #코드베이스 설계

AI 코딩 에이전트가 일 잘하는 코드베이스 — 구조가 성능을 결정한다

AI 코딩 도구를 도입한 팀들이 결과가 기대에 못 미치면 흔히 모델 탓을 한다. 하지만 같은 모델이라도 코드베이스 구조에 따라 결과물의 품질 차이가 극적으로 갈린다. 실제 운영 중인 NestJS 모놀리스를 정량 진단하고, "AI가 일 잘하는 구조"의 조건을 원리부터 정리했다.

1. AI의 작업 루프는 4단계다

AI 코딩 에이전트는 모든 작업을 같은 루프로 돈다.

탐색(어디 고치지?) → 컨텍스트 로드(관련 코드 읽기) → 변경 → 검증(뭘 깼나?)

여기서 사람과 결정적으로 다른 점이 세 가지 있다.

사람 개발자AI 에이전트
누적 기억으로 맥락 보완매 작업 기억 0에서 시작 — 컨텍스트 로드 세금을 매번 반복 지불
규칙을 대체로 지킴확률적 — 그럴듯하면 관례를 종종 어김
경험으로 회귀 감지조용한 회귀를 잘 못 잡음

결론부터 말하면: 사람에게 좋은 구조(작은 응집 단위, 명확한 경계)는 AI에서 효과가 증폭되고, 나쁜 구조에서는 AI가 사람보다 더 크게 망가진다.

2. 실측: 운영 수년차 NestJS 모놀리스의 민낯

레이어 기반 flat 구조의 실제 서비스 코드베이스를 진단한 수치다.

지표의미
서비스 클래스139개 / 5.2만 라인 (단일 폴더 flat)레이어-우선 구조
도메인 모듈0개루트 모듈 하나에 provider ~320개 일괄 등록
서비스 간 주입291건경계 없음 — big ball of mud
최대 서비스1,800~1,900라인, 생성자 주입 33개god-service 다수

여기서 흥미로운 발견: 라인수는 함정이다. 가장 큰 서비스(1,877라인)는 그것을 주입해 쓰는 파일(callers)이 3개뿐인 자기완결 덩어리라 방치해도 된다. 반면 1,801라인짜리 외부 API facade는 callers가 17개 — 시스템의 hub다. 리팩토링 ROI를 결정하는 건 크기가 아니라 blast radius(callers) 다. 많은 곳이 의존하는 클래스일수록 AI가 자주 지나가는 길목이고, 거기를 정리해야 이득이 매 작업 복리로 쌓인다.

3. 폴더는 관례, Module은 계약

"domains/ 폴더로 재편하면 AI가 잘 찾지 않을까?"라는 기대는 절반만 맞다. 폴더를 아무리 잘 나눠도 TypeScript import는 어디서든 가능하다.

// 도메인 경계 위반 — 그러나 컴파일✅ 빌드✅ 테스트✅ 배포✅
import { ReviewScoreCalculator } from "../review/internal/ReviewScoreCalculator"

어떤 도구도 막지 않는다. 지키는 건 작성자의 기억과 리뷰어뿐 — 이것이 관례다. 확률적으로 동작하는 AI는 관례를 종종 어기고, 에러가 없으니 "성공"으로 판단하고 지나간다.

반면 NestJS Module은 다르다.

@Module({
  providers: [ReviewService, ReviewScoreCalculator],
  exports: [ReviewService],   // Calculator는 내부용
})
export class ReviewModule {}

다른 모듈이 내부 provider를 주입하려 하면 Nest can't resolve dependencies 에러로 앱이 아예 안 뜬다 — 이것이 계약이다. AI는 "하지 마세요"라는 문서 지침보다 빨간 에러 메시지에 훨씬 확실하게 반응해 스스로 교정(self-correct)한다. 이 차이가 AI 안전성의 핵심이다.

4. 그린필드라면: vertical slice + 3중 기계 강제 경계

처음부터 새로 만든다면 제1원칙은 변경의 지역성(locality of change) — 기능 하나 바꿀 때 건드리는 파일이 전부 한 폴더에 모이게 한다. 레이어-우선이 아니라 도메인-우선(vertical slice)이다.

src/
├── domains/
│   └── order/
│       ├── index.ts       ← 유일한 공개 출입구 (도메인의 계약)
│       ├── OrderModule.ts ← DI 경계
│       ├── api/           ← controller + DTO
│       ├── app/           ← use-case 서비스
│       ├── domain/        ← 엔티티·도메인 규칙 (순수, 프레임워크 무관)
│       ├── infra/         ← repository, 외부 클라이언트
│       └── __tests__/
├── shared/                ← 진짜 공통만
└── AppModule.ts           ← 도메인 Module import 나열뿐

작업 루프 4단계별 설계 장치:

루프 단계설계 장치
탐색이름 = 책임 (use-case 단위 파일), 같은 일을 하는 방법은 레포에 하나만, 동적 로딩·마법 금지(greppability)
로드파일 300500라인·주입 57개를 lint로 강제, 도메인별 README co-locate
변경3중 경계: Module exports(DI 위반→부팅 에러) + dependency-cruiser/eslint-boundaries(내부 import→lint 에러) + 방향 규칙(domain→infra 역방향·순환→에러)
검증도메인 단위 테스트가 수 초에 완주, 스키마→코드젠(이중 정의 제거), domain/ 순수 함수화

핵심 규칙은 한 줄이다: "다른 도메인은 domains/*/index.ts로만 import할 수 있다." 이 한 줄이 폴더 관례를 계약으로 승격시킨다.

5. 반직관: MSA가 아니라 modular monolith

구조를 잘게 나누라는 말이 서비스를 나누라는 뜻은 아니다. 논리 경계는 잘게, 물리 배포는 하나로 — modular monolith가 AI에 최적이다. MSA는 크로스레포 컨텍스트 수집, 버전 스큐, 분산 트레이싱까지 로드해야 해서 AI에 오히려 불리하다. 모듈 경계가 부팅·lint 타임에 검증되는 monolith에서 시작하고, 진짜 스케일 필요가 생긴 도메인만 나중에 떼면 된다. 경계가 이미 있으니 그때 가서도 저비용이다.

6. AI를 위한 메타데이터 — 에러 메시지가 곧 문서

  • 루트 CLAUDE.md(또는 AGENTS.md)는 짧게: 명령어·금기·패턴 위치 포인터만
  • 도메인별 README에 불변식과 용어집
  • 커스텀 lint 에러 메시지에 "왜"와 "대안"을 적는다: "domain/은 infra/를 import할 수 없습니다. repository 인터페이스를 domain/에 정의하고 infra/에서 구현하세요." AI는 에러 메시지를 그대로 지침 삼아 교정하므로, 에러 메시지가 가장 잘 읽히는 문서다.

7. 레거시라면: 빅뱅 금지, strangler + ratchet

기존 코드베이스에 이 그림을 적용할 때의 함정은 빅뱅 재편이다. 전 서비스 폴더 이동은 import 전면 수정, 진행 중인 브랜치와의 대규모 충돌, git blame 단절을 부르는데 정작 경계 강제력은 얻지 못한다. 대신:

  1. 파일은 옮기지 않는다. NestJS Module은 파일 위치와 무관하므로, 모듈 파일만 추가해 경계부터 긋는다
  2. callers 높은 hub부터 클러스터 단위로 분해한다 (1싸이클 = 1 PR = 동작 무변경 move-only)
  3. 신규 추출 파일과 신규 기능만 domains/에 생성한다 — ratchet 규칙으로 flat 폴더가 자연 감소한다

결론

AI 에이전트의 성능을 결정하는 것은 프롬프트가 아니라 구조다. 탐색을 돕는 응집된 이름, 로드 가능한 크기, 변경을 막아주는 기계 강제 경계, 수 초짜리 검증 루프 — 이 네 가지를 갖춘 modular monolith가 정답에 가깝다. 그리고 이 조건은 전부 사람에게도 좋은 구조다. AI 시대의 아키텍처란 결국, 좋은 설계 원칙의 강제력을 문화에서 기계로 옮기는 일이다.