AI로 빨라진 개발에 부족한 것
AI에게 코드를 작성하게 하면, 작동하는 결과물을 단시간 내에 얻을 수 있습니다. 엔지니어 출신이 아니더라도 화면, DB, API가 연결된 애플리케이션을 하루 만에 구동할 수 있게 되었습니다.
빨라진 것은 코드 작성 작업뿐만이 아닙니다. 테스트를 작성하는 작업도, 로그를 출력하는 처리를 넣어 실행하고 나온 로그를 읽는 작업도, 코드를 수정하는 작업까지 빨라졌습니다. 개발은 '작성하고, 확인하고, 수정하는' 사이클을 반복하며 진행되는데, 이 전체 사이클 자체가 빨라지고 있습니다.
하지만, 확인하는 작업이 빨라진 것과 품질이 보장되는 것은 별개의 문제입니다. AI가 확인할 수 있는 범위는 해당 세션 내에서 AI가 떠올린 범위에 국한됩니다. 테스트로 확인할 수 있는 것은 테스트에 작성된 조건만이며, 로그로 보이는 것은 로그 출력을 넣은 부분에 한정됩니다. 구동하며 확인하는 방식으로는 준비된 환경과 데이터, 투입된 시간의 범위 내에서만 확인할 수 있습니다. 따라서 사이클을 돌릴 때마다 놓치는 부분이 남아있고, 사이클이 빨라질수록 확인 누락이 쌓이는 속도도 빨라집니다. 고객에게 제공하여 운영에 들어가면, 결함 수정과 기능 추가가 있을 때마다 이 사이클이 계속되므로, 확인 누락의 축적은 멈추지 않습니다.
속도를 떨어뜨리지 않으면서 품질을 유지하려면, AI가 인지하는지에 의존하지 않고 확인할 수 있는 메커니즘이 필요합니다. 그 메커니즘에 요구되는 조건은 세 가지입니다. 첫째, AI가 떠올린 범위에 국한되지 않고 전체 코드를 대상으로 할 것. 둘째, 코드를 실행하지 않고 판별할 수 있을 것. 셋째, 동일한 코드에는 매번 동일한 판정을 내릴 것(결정론적일 것).
코드를 확인하는 수단은 구동하며 확인하는 것과, 구동하지 않고 확인하는 것으로 크게 나뉩니다. 테스트, 수동 동작 확인, 로그 확인은 전자에 해당합니다. 후자에는 사람에 의한 코드 리뷰도 포함되지만, 그중 타입 검사(Type checking), 린트(Lint), 의존성 규칙 검사처럼 도구로 코드를 분석하여 기계적으로 판별하는 것을 정적 분석(Static Analysis)이라고 부릅니다. 앞 단락에서 언급된 세 가지 조건을 모두 충족하는 것이 정적 분석이므로, 이 글에서는 이를 다룹니다.
왜 정적 분석인가
앞 절에서 언급한 조건 중, 사람이 개입하지 않아도 AI가 사이클을 돌릴 수 있는지 결정하는 것은 판정이 결정론적인 것입니다. 정적 분석은 동일한 코드에는 반드시 동일한 결과를 반환합니다. AI에게 코드를 확인하게 하면, 동일한 코드라도 요청할 때마다 놓치는 부분이 달라질 수 있지만, 정적 분석에서는 그러지 않습니다. 검사에 실패했을 때, 우연히 실패한 것인지 사람이 가려낼 필요도 없으므로, AI는 출력된 위반 위치와 규칙을 읽고 수정하여 다시 검사를 실행하기만 하면 됩니다. 게다가 코드를 실행하지 않기 때문에, 실행 환경을 준비할 필요 없이 이 사이클을 몇 번이고 빠르게 돌릴 수 있습니다.
전체 코드를 대상으로 한다는 점에서 정적 분석은 테스트와 다릅니다. 테스트가 확인하는 것은 테스트에 작성된 입력과 조건만이며, 그 테스트마저 AI가 작성하므로 확인할 범위는 AI가 떠올린 범위에 머무르게 됩니다. 반면에 타입 검사는 타입에 관한 한 모든 호출 위치를, 의존성 규칙 검사는 모든 import를 확인합니다.
설계상의 규칙을 대화나 CLAUDE.md로 전달해 두는 방법과 비교했을 때, 정적 분석은 AI가 그 규칙을 고려했는지 여부에 좌우되지 않는다는 점이 다릅니다. 대화나 CLAUDE.md로 전달한 규칙이라도, AI가 코드 작성 방식을 결정할 때 이를 고려하고 있을지는 미지수입니다. 반면 정적 분석은 커밋 전 훅(hook)이나 CI에 통합하면 변경할 때마다 실행됩니다. 변경할 때마다 전체 코드를 확인하므로, 검사의 대상이 되는 속성에는 확인 누락이 발생하지 않습니다.
실행하지 않고도 보장할 수 있는 범위를 넓히려는 움직임은 프로그래밍 언어 선택에도 나타납니다. Rust는 소유권(Ownership)과 빌림 검사(Borrowing Check)를 통해 unsafe 코드를 사용하지 않는 범위 내에서 메모리 안전성(Memory Safety)과 데이터 경쟁(Data Race)이 발생하지 않음을 컴파일 시점에 보장합니다. Linux 커널은 6.1 버전에서 Rust를 통합했으며(The Initial Rust Infrastructure Has Been Merged Into Linux 6.1), 미국 국가사이버국장실(NSA)은 2024년 보고서에서 메모리 안전 언어로의 전환을 요구했습니다(Back to the Building Blocks). Android에 Rust를 채택한 Google은 메모리 안전성으로 인한 취약점 비율이 감소했다고 보고했습니다(Eliminating Memory Safety Vulnerabilities at the Source). 또한 Google은 다른 보고서에서, Rust로 작성된 변경 사항은 C++로 작성된 변경 사항에 비해 코드 리뷰 시간이 약 25% 단축되었으며, 중규모 이상의 변경에서는 롤백(rollback) 비율도 약 4분의 1이라고 밝혔습니다(Rust in Android: move fast and fix things).
이 모든 흐름은 AI 이전부터 진행되어 왔지만, AI가 코드를 작성하게 할 경우, 보장되는 범위의 넓이가 개발 주기(cycle)의 속도에 직결됩니다. 검사 도구가 보장하는 범위가 넓을수록, AI가 놓치더라도 보호받는 특성이 증가하여 주기를 빠른 상태로 유지할 수 있기 때문입니다.
정적 분석이 확인하는 영역
정적 분석(Static Analysis) 도구는 무엇을 확인하느냐에 따라 나뉩니다. 대표적인 영역은 다음과 같습니다.
| 영역 | 확인 내용 | 도구 |
|---|
| 서식 | 들여쓰기나 줄 바꿈 등 시각적 일관성 | formatter (Prettier, Biome, rustfmt) |
| ... | | |
서식(Formatting), 작성 방식(Style), 타입(Type) 검사는 많은 프로젝트에서 처음부터 포함되어 있습니다. 메모리 안전성 검사는 Rust와 같은 언어를 선택하면 기본적으로 제공됩니다. 이에 비해 의존성의 방향과 경계에 대한 검사는 누군가 넣기로 결정하지 않는 한 들어오지 않습니다. 이 검사가 대상으로 하는 것은 아키텍처(Architecture)입니다. 타입이나 메모리 안전성에 비해서는 덜 인식될 수 있지만, 아키텍처 역시 정적으로 검사할 수 있습니다.
린터(Linter)로 아키텍처 보호하기
계층 분리가 무너지는 이유
최근 AI에게 클린 아키텍처(Clean Architecture)처럼 계층을 나눈 프로젝트에서 기능 추가를 요청하면, 작성되는 코드는 대부분 기존의 계층 구조를 따릅니다. 하지만 엔지니어가 아닌 사람이 AI로 개발을 지속한 프로젝트에서는, 계층 분리 규칙을 벗어난 부분이 곳곳에 섞이기 쉽습니다. 예를 들어 다음과 같은 경우입니다.
- 목록 화면은 리포지토리(repository)를 통해 데이터를 가져오는데, 나중에 추가된 상세 화면에서는 화면 컴포넌트(component) 내에서 DB 클라이언트를 직접 호출한다.
- 주문 기능이 회원 기능의 내부에 있는 훅(hook)을 공개용 진입점(entry point)을 거치지 않고 직접 임포트(import)한다.
- 금액 계산 로직이 화면 컴포넌트 파일 안에 작성되어 있고, 이를 다른 화면이나 API 처리에서 임포트하여 사용한다.
계층 분리 규칙의 이러한 불일치는 AI와의 세션이 전환되면서 발생합니다. AI는 세션마다 현재 컨텍스트(context)에 포함된 파일을 기반으로 그 범위 내에서 적절한 방식을 선택합니다. 상세 화면을 추가하는 세션에서, 목록 화면이나 리포지토리 파일이 컨텍스트에 포함되어 있지 않다면, 화면 컴포넌트에서 DB 클라이언트를 직접 호출하는 것이 가장 간편하기 때문입니다. 이전 세션에서 'DB는 리포지토리를 통해'라고 결정했더라도, 최신 모델조차도 그 결정이 다음 세션으로 계승된다고 보장할 수는 없습니다. 게다가 어떤 변경 사항도 개별적으로 작동하고 테스트를 통과하므로, 불일치는 드러나지 않은 채 남아 있게 됩니다.
이러한 불일치는 엔지니어가 작성하게 해도 발생하지만, 엔지니어라면 리뷰(review) 과정에서 발견하고 바로잡을 기회가 있습니다. 설계 지식이 부족한 사람이 작성하게 한 프로젝트에서는 이를 알아차리는 사람이 없기 때문에 불일치가 쌓이고, 어느 파일이 어디에 의존하는지 추적하기 어려운 스파게티 코드(Spaghetti Code)와 같은 상태가 됩니다.
무엇을 어떻게 제약할 것인가
저희는 어떤 프로젝트에도 사용할 수 있는 범용 템플릿 레포지토리(template repository)를 만들고 있습니다. 템플릿이기 때문에 기술 스택은 가볍게 유지하고 싶었고, 아키텍처를 지키기 위해서만 의존성 검사 도구를 추가하거나 관리하는 도구와 설정을 하나 더 늘리는 것은 피하고 싶었습니다. 앞서 제시된 표에서 보았듯이, 린터(linter)는 작성 방식의 줄에만 배치되어 있습니다. 하지만 의존성의 방향과 경계 또한 린터 규칙으로 제한할 수 있습니다. 대부분의 프로젝트에는 이미 이 도구들이 갖춰져 있기 때문에, 저희는 템플릿에서는 이 방법을 채택했습니다.
제한 대상으로 삼으면 보통 다음 세 가지가 충분합니다.
- 의존성의 방향: UI에서 Application, Application에서 Domain처럼 정해진 방향으로만 의존해야 합니다. Domain은 외부 계층에 의존해서는 안 됩니다.
- 모듈의 경계: Feature 간에는 각각 공개용 엔트리 포인트(entry point) 파일을 통해서만 서로 임포트(import)해야 합니다.
- 금지된 임포트: DB 클라이언트는 리포지토리 계층 밖에서 사용하지 않아야 합니다. HTTP 클라이언트는 API 클라이언트 외에서는 호출되어서는 안 됩니다.
이후 설정은 다음 구조의 프로젝트를 예시로 들겠습니다. 화살표는 임포트가 허용되는 방향이며, 화살표가 없는 방향으로의 임포트는 모두 금지됩니다. 앞서 언급된 예(상세 화면 컴포넌트에서 DB 클라이언트를 직접 호출하는 경우)는 UI에서 DB로 가는 화살표가 없기 때문에 금지입니다.
이 구조에서는 Application이 Repositories의 함수를 직접 임포트하여 데이터를 읽고 쓰게 됩니다. Domain에 리포지토리 인터페이스(interface)를 두고 Infrastructure에서 구현하는 구성(의존성 역전, Dependency Inversion)은 채택하지 않았습니다. 의존성 역전을 채택하는 구조라 하더라도, 규칙에 작성할 임포트 원본과 임포트 대상을 다이어그램에 맞춰 수정하면 이후와 같은 형태의 규칙으로 제한할 수 있습니다.
이후 설정 및 명령어는 ESLint 10.13.0, typescript-eslint 8.72.0, TypeScript 6.0.3, dependency-cruiser 18.7.0, lefthook 2.2.2, @eslint-community/eslint-plugin-eslint-comments 4.8.1을 사용하여 작동 여부를 확인했습니다. 2026년 10월 기준으로 최신 TypeScript 7.0.2에서는 typescript-eslint가 미지원이라는 오류로 인해 중단되었습니다. dependency-cruiser는 경고를 출력한 후 TypeScript 파일을 하나도 읽지 않고 종료되었으며, 위반 사항이 있어도 검사가 통과했습니다. TypeScript 7 시리즈로 올릴 때는 두 도구의 호환성 상태를 먼저 확인해 주십시오.
ESLint로 제한하기
ESLint를 사용하면 의존성의 방향과 금지된 임포트는 본체에 내장된 no-restricted-imports 규칙으로 작성할 수 있습니다. 플러그인(plugin)은 필요하지 않습니다. 예를 들어, UI에서 Infrastructure로의 임포트를 금지하는 설정 블록은 다음과 같습니다. message 필드에는 '왜 금지했는지'와 '대신 어떻게 해야 하는지'를 작성합니다.
import { defineConfig } from "eslint/config";
import tseslint from "typescript-eslint";
export default defineConfig([
...
Domain이나 Application 역시 같은 형태의 블록을 계층별로 추가하면 제한할 수 있습니다. 모듈 경계까지 세밀하게 제한하고 싶다면, eslint-plugin-boundaries와 같은 플러그인도 있습니다.
앞서 언급된 예처럼, 상세 화면 컴포넌트에서 DB 클라이언트를 직접 임포트한 후 ESLint를 실행하면 다음과 같이 출력됩니다.
$ npx eslint src
/path/to/project/src/ui/OrderDetail.tsx
1:1 error '../infrastructure/db/client' import is restricted from being used by a pattern. ui から infrastructure を直接 import できません。application の関数を経由してください。 no-restricted-imports
...
ESLint 표준 에러 메시지에 이어서, message
전체 내용이 나옵니다. exit code는 1이 되므로, 커밋 전 hook이나 CI에 통합해 두면 커밋이나 CI가 그 지점에서 멈춥니다. 에디터 내에서는 작성하는 시점에 빨간 줄도 표시됩니다.
이 출력에는 금지 이유와 수정 방법이 포함되어 있습니다. AI는 실패한 command의 출력을 읽고 다음 행동을 결정하므로, 린트(lint) 메시지는 위반을 감지한 그 자리에서, 위반을 작성한 AI에게 전달되는 지시서가 됩니다. CLAUDE.md에 작성된 내용은 AI가 코드 작성 방식을 결정할 때 고려된다는 보장이 없지만, 린트 메시지는 실패한 command의 출력으로, 위반 직후 AI의 컨텍스트(context)에 들어갑니다. 메시지에 설계 문서의 해당 위치로 연결되는 링크를 첨부해 두면, 위반이 발생할 때마다 AI가 설계 문서를 읽을 계기를 만들 수 있습니다.
이러한 메커니즘이 있다면, 아키텍처 지식을 AI에게 작성하게 하는 사람의 머릿속이 아닌 설정 파일 안에 둘 수 있습니다. 위반을 포함하는 변경은 커밋 시점에서 멈추고, 무엇을 어떻게 고쳐야 할지는 메시지에 적혀 있으므로, AI는 그것을 읽고 수정합니다. 따라서 엔지니어가 아닌 사람이 AI에게 작성하게 하더라도, 검사 대상으로 삼은 의존성의 방향과 경계에 대해서는 아키텍처가 무너지지 않습니다. linter 설정에 몇 개의 블록만 추가하면 이 상태를 만들 수 있어, 저희 회사 템플릿에서도 유용합니다.
dependency-cruiser로 순환 의존 및 간접적인 의존까지 제어하기
순환 의존(circular)이나 간접적인 의존까지 제어하고 싶다면, dependency-cruiser와 같은 의존성 검사 도구를 추가합니다. no-restricted-imports는 import 문을 하나씩 path의 패턴과 대조하므로, 여러 파일을 거쳐야 알 수 있는 의존성은 검사할 수 없습니다. dependency-cruiser는 프로젝트 전체의 import graph를 만든 후 검사하기 때문에, 방향성 외에도 순환 의존(circular) 및 다른 파일을 경유한 간접적인 import까지 포함하여 의존해서는 안 되는 곳(reachable)을 검사할 수 있습니다. 순환 의존만이라면 ESLint에서도 eslint-plugin-import-x의 no-cycle 같은 플러그인 규칙으로 감지할 수 있지만, dependency-cruiser에서는 graph를 사용하는 검사와 계층(layer) 의존 규칙을 동일한 형태로 설정에 모아서 작성할 수 있습니다. 그림의 project라면, 설정은 다음과 같습니다.
/** @type {import('dependency-cruiser').IConfiguration} */
module.exports = {
forbidden: [
...
앞서 설명드린 상세 화면의 위반에 더하여, domain의 금액 계산이 ui 표시용 함수를 import하는 상태로 실행하면 다음과 같이 출력됩니다. --output-type err-long을 추가하면, 위반마다 규칙 이름, 위반한 import의 from과 to, comment의 전체 내용이 나옵니다.
$ npx depcruise src --output-type err-long
error domain-must-not-depend-on-outer-layers: src/domain/price.ts → src/ui/formatPrice.ts
domain은 application, infrastructure, ui를 import
...
from과 to의 쌍을 프로젝트 전체의 import graph에 대해 작성하므로, ESLint처럼 계층별로 블록을 나눌 필요가 없습니다. dependency-cruiser를 추가한다고 해서 ESLint를 제거할 필요는 없습니다. ESLint는 에디터 내에서 작성하는 시점에 위반을 보여주고, dependency-cruiser는 import graph가 필요한 검사를 담당합니다.
의존성 규칙을 적용한 후 운영
의존성 규칙은 한 번 작성하면 다음 프로젝트에도 가져갈 수 있습니다. 지키고 싶은 속성과 그것을 확인하는 도구, 그리고 수정 방법을 적은 메시지가 설정 파일에 모아서 남기기 때문입니다. 저희 회사처럼 템플릿 리포지토리(template repository)에 넣어두면, 템플릿에서 만든 프로젝트는 처음부터 이 규칙을 가지고 시작합니다.
운영상 절대 놓쳐서는 안 될 점이 세 가지 있습니다.
첫 번째는 검사를 커밋 전 후크(hook)와 CI 양쪽 모두에서 실행하는 것입니다. AI가 위반 사항을 수정할 계기를 얻는 것은 후크 단계이며, 커밋 시점에서 검사가 실패하면 AI는 그 자리에서 출력 내용을 읽고 수정할 수 있습니다. lefthook과 같은 후크 관리 도구를 사용하고 다음 설정을 추가한 뒤 npx lefthook install를 한 번 실행하면, 커밋할 때마다 두 가지 검사 모두가 실행됩니다.
pre-commit:
jobs:
- name: eslint
...
두 경우 모두 전체 소스 코드(src)를 전달하므로, 커밋할 때마다 모든 코드를 검사합니다. 위반 사항이 있으면 커밋이 중단되고, 앞서 나온 출력이 그대로 표시됩니다.
후크는 --no-verify로 건너뛸 수 있으며, 만약 환경에 후크가 빠져있는 경우 실행되지 않기 때문에 CI에서도 동일한 검사를 수행하여 후크를 통과하지 못한 변경 사항을 그곳에서 막습니다.
두 번째는 기존 프로젝트에 나중에 적용하는 경우의 처리 방식입니다. 현재 존재하는 위반 사항들을 기록해 두고, 이 기록은 줄어드는 방향으로만 변화시켜야 합니다. 도입한 날 모든 위반 사항을 수정하는 것은 비현실적인 경우가 많기 때문입니다. 기록을 늘리는 것은 규칙을 변경할 때에만 해야 합니다.
ESLint의 경우, --suppress-all로 현재 존재하는 위반 사항들을 eslint-suppressions.json에 기록하면, 이후에는 새로운 위반 사항만 보고됩니다. 기존 위반 사항을 수정했을 때 ESLint가 더 이상 사용되지 않는 기록이 남아있다고 판단하여 실패하는 것을 막기 위해 --prune-suppressions로 기록에서 삭제합니다. dependency-cruiser의 경우, 현재 존재하는 위반 사항들을 baseline 파일에 작성하고, 이후에는 --ignore-known을 붙여 실행하면 기록된 위반 사항을 제외하고 검사합니다. baseline을 사용할 때는 후크와 CI의 dependency-cruiser 모두에 --ignore-known을 추가해야 합니다.
# ESLint: 현재 존재하는 위반 사항을 eslint-suppressions.json에 기록
npx eslint src --suppress-all
# ESLint: 수정된 위반 사항을 기록에서 삭제
...
세 번째는 규칙이 잘못되었다면, 무효화 주석(disable comment)으로 규칙을 우회하는 대신, 규칙 자체를 변경하는 PR을 내는 것입니다. AI에게 단순히 “lint를 통과하게 해줘”라고만 의존하면, 무효화 주석을 사용하여 통과시키려고 하는 경우가 있습니다. CLAUDE.md에 “무효화 주석을 작성하지 말 것”이라고 적는 것보다, 무효화 주석 자체를 lint에서 금지하는 것이 더 확실합니다. ESLint의 경우 @eslint-community/eslint-plugin-eslint-comments의 no-use로 전면 금지가 가능합니다. 앞서 설정에 다음 블록을 추가합니다.
import comments from "@eslint-community/eslint-plugin-eslint-comments";
export default defineConfig([
// ...종속성 규칙 블록
...
규칙을 변경하거나 추가하는 PR이 쌓이면, 설정 파일은 AI가 어려움을 겪었던 지점과 해결 방법을 기록하게 됩니다. Cursor의 엔지니어가 공개한 pstack에도 같은 실수를 반복적으로 수정했던 이력을 통해 실수 유형을 파악하고, 유형별로 가장 상위 수준에서(상위부터 순서대로 아키텍처, 타입, lint와 CI, 테스트, 문서) 수정하는 스킬이 있습니다. 대책을 적용할 곳에는 문서나 스킬도 있지만, lint 규칙으로 만들어두면 AI가 그 설명을 읽었는지 여부와 관계없이, 다음에 같은 위반 사항이 발생하면 커밋 시점에서 멈춥니다.
이러한 기록은 개인이나 프로젝트 내에만 국한되지 않고 조직 차원에서 지속적으로 업데이트될 때 가치가 높아집니다. template에서 만든 프로젝트는 처음부터 규칙을 가지고 시작하므로, 각 프로젝트에서 추가된 규칙을 template로 되돌리면, 조직이 발견한 실수와 해결 방법이 다음 프로젝트로 계승됩니다. 이처럼 규칙을 template로 되돌리는 흐름을 조직 차원에서 만들 수 있다면, 프로젝트마다 같은 실수를 처음부터 수정할 필요가 없어집니다.
정적 분석으로 판단할 수 없는 것
정적 분석으로 판단할 수 있는 것은 코드를 실행하지 않고 기계적으로 결정되는 속성뿐입니다. 종속성의 방향은 지킬 수 있지만, 함수의 책임이 적절한지, 이름이 의미를 나타내는지, 응집도가 높은지는 판단할 수 없습니다. 그 부분은 리뷰의 영역으로 남습니다. 검사가 보장하는 것은 품질의 하한선입니다. 이 하한선을 검사가 지키고 있다면, 사람의 리뷰는 책임이나 명명 규칙처럼 기계로는 판단하기 어려운 점에 집중할 수 있습니다.
지키고 싶은 것은 문서에 적어 읽히기를 기다리는 것이 아니라, 검사(inspection)를 통해 위반을 막는 것입니다. 타입 안전성(Type Safety)이나 메모리 안전성뿐만 아니라, 아키텍처도 그 대상이 될 수 있습니다. AI로 빨라진 사이클을 품질 저하 없이 계속 돌릴 수 있는지는, 실행하지 않고 보장할 수 있는 범위에 크게 좌우됩니다. 먼저 linter에 의존 규칙(dependency rule)을 하나 추가하여, 위반이 포함된 커밋(commit)이 멈추는 지점까지 만들어 보세요. 거기서부터 검사하는 영역을 하나씩 넓혀가면, AI가 알아차리는지에 의존하지 않고 지킬 수 있는 범위가 확장됩니다.
Discussion