
AI 시대의 Fitness Function — 아키텍처를 '부탁'에서 '위반 시 실패하는 제약'으로
요약
AI가 코드를 대량으로 생성하는 시대에 아키텍처 규칙을 단순한 지침이 아닌, 위반 시 빌드가 실패하는 기계적 제약으로 구현하는 방법을 다룹니다. TypeScript와 패키지 경계 설정을 통해 설계 원칙을 강제하는 다층 방어 전략을 제시합니다.
핵심 포인트
- 아키텍처 규칙을 '부탁'이 아닌 '위반 시 실패하는 제약'으로 전환해야 함
- 제약의 강도는 문장, Lint, 패키지 경계 순으로 강력해짐
- AI 코드 생성 시 설계 위반을 방지하기 위해 기계적 체크(CI)가 필수적임
- 다층 방어(Defense in Depth)를 통해 설계 원칙의 신뢰성을 확보함
아키텍처 규칙에는 사람이 읽는 규칙과 기계가 판정할 수 있는 규칙이 있습니다.
예를 들어 "domain은 infrastructure에 의존하지 않는다". 비즈니스 규칙과 같은 내부 방침이 DB나 프레임워크와 같은 외부의 상세 내용을 알지 못하도록 하는 방향의 제약입니다. 같은 한 문장이라도 설계 문서에 적는 것과, 위반하는 순간 빌드가 실패하는 구조로 만드는 것 사이에는 강제성의 강도가 차원이 다릅니다.
이 기사의 주제는 그 "강도"를 선택하는 방법입니다. 제약마다 그것이 거짓말을 할 수 없는 곳은 어디인가를 선택합니다. 그리고 정말 중요한 경계선은 성질이 다른 여러 가지 실패 방식(落とし方)으로 겹쳐서 만듭니다. AI에게 코드를 작성하게 하는 개발을 진행하다 보면, 이 「선택하기」와 「겹치기」가 필요한 상황에 몇 번이고 부딪히게 됩니다.
글을 다 읽고 나면, 당신은 자신에게 가장 중요한 제약을 하나 선택하여, 그것이 현재 "테스트가 초록색(Pass)일 뿐"인지 아니면 "위반하면 반드시 실패하는지"를 구분하고, 한 단계 더 설계할 수 있게 됩니다. 소재는 주로 TypeScript를 사용하며, 마지막에 C++를 대조군으로 조금 사용합니다.
이전에 Zenn에 쓴 두 편의 글과 이어지는 내용입니다. 첫 번째 글에서는 "구조를 판정하는 언어를 가진다"라고 썼으며, 의존의 방향은 기계적 체크로 넘기기 쉬운 관점이라고 언급했습니다. 두 번째 글에서는 아키텍처 판단을 "CI에서 기계적으로 중단 / AI 리뷰어에게 지적하게 함 / 사람에게 돌려줌"으로 분류했습니다. 이 기사는 거기서 "CI에서 중단한다"에 두었던 제약을 실제로 어떻게 기계로 구현했는지를, 리포지토리 구성을 바탕으로 직접 손을 움직여 확인해 보는 회차입니다.
이 기사의 코드는 자신의 프로덕트 개발에서 반복적으로 내렸던 설계 판단을, 주문이나 테넌트 경계라는 누구에게나 통용되는 소재로 번역한 것입니다. 실제 도메인이나 패키지명은 노출하지 않습니다. 판단의 내용만을 남깁니다.
먼저 전체상을 한 장으로 보여드립니다. 앞으로 나올 TypeScript 코드는 사고방식의 "증거"일 뿐, 암기 대상이 아닙니다. 이 기사는 다음 단계로 실례를 통해 내려갑니다.
문장으로 부탁하기(AGENTS.md)
│ 위반하기 어렵게 만들기
▼
...
AI에게 대량의 코드를 작성하게 할수록, 그 차이점(diff)을 매번 스스로 전부 다시 읽어보는 대신, "위반하면 기계가 빨간색(Fail)으로 표시하는" 구조에 무엇을 담느냐가 중요해집니다. 아래의 두 단계——다층 방어(Defense in Depth)가 초록색으로 거짓말을 하는 지점과 그 대처——가 이 기사의 핵심입니다.
같은 규칙이라도 배치하는 위치에 따라 강도가 달라진다
먼저, 가장 정적으로 적용하기 쉬운 제약부터 살펴보겠습니다. "domain은 infrastructure에 의존하지 않는다"를 예로 들어, 배치할 수 있는 위치를 강한 순서대로 보겠습니다.
문장 / AGENTS.md: 읽는 쪽의 선의에 의존한다. 읽지 않거나 따르지 않는 것만으로 위반할 수 있다.
정적 분석 (lint): no-restricted-imports 등으로 내부에서 외부로의 import를 error로 설정하면, lint 실행 시(로컬 또는 CI)에 실패한다. 다만 규칙은 // eslint-disable로 한 줄만 무시할 수 있다.
module / package 경계: 내부를 독립된 패키지로 분리하고, 그 package.json에 외부 패키지에 대한 의존을 단 하나도 적지 않는다.
세 번째가 이 제약에 있어서 가장 강력한 위치입니다.
// packages/core/package.json — 내부(방침) 계층. 외부 패키지에 대한 의존을 "적지 않음"
{
"name": "@app/core",
...
}
내부의 @app/core 안에서 import { ... } from "@app/infrastructure"라고 써도, @app/infrastructure는 core의 의존성에 존재하지 않습니다. 의존을 엄격하게 분리하는 workspace 구성(이 리포지토리의 설정)에서는 패키지 매니저가 core의 의존성만을 core에서 볼 수 있는 위치에 두기 때문에, 이 import는 해결할 대상을 찾지 못해 타입 검사(tsc)나 빌드 단계에서 실패합니다. ※ 어느 단계에서 실패하는지(타입 검사·빌드·런타임)는 workspace와 모듈 해석 설정에 따라 달라지며, "Node/TypeScript라면 항상 이렇다"라고 단정할 수는 없습니다. 역류를 성립시키려면 package.json에 의존성을 한 줄 추가해야만 하는데, 이는 eslint-disable처럼 "그 자리에서 무시하기"가 불가능하며 눈에 띄는 diff로 남게 됩니다.
지금까지는 "같은 규칙을 강제하는 곳으로 옮길수록 위반하기 어려워진다"는 이야기였습니다. 하지만 어디까지 밀어붙일지는 제약(constraint)의 성질에 따라 결정됩니다. 의존성 방향은 import 문이라는 **구조(structure)**만으로 판별할 수 있기에 패키지 경계나 컴파일러 단계까지 갈 수 있었습니다. 다음으로 살펴볼 것은 구조만으로는 판별할 수 없는, 더 까다로운 제약입니다.
본론: 실행 시에만 판별할 수 있는 경계선을 어디서 지킬 것인가
주제를 바꾸겠습니다. 멀티 테넌트(Multi-tenant)의 경계 침범 금지입니다. 특정 테넌트(조직)의 데이터를 다른 테넌트가 읽거나 쓸 수 있어서는 안 됩니다. 무너지면 끝장인 경계선입니다.
이는 의존성 방향과는 달리 정적 분석(static analysis)으로는 판별할 수 없습니다. "이 쓰기 작업이 경계 침범인지 아닌지"는 실행 시의 값, 즉 현재 접속 중인 테넌트와 쓰려고 하는 행(row)의 테넌트를 대조해 보지 않으면 알 수 없습니다. 타입(type)에도 컴파일러에도 닿지 않는 영역입니다. 그래서 지키는 위치가 달라집니다.
성질이 다른 계층을 겹치기
경계 침범 금지와 같은 경계선은 하나의 메커니즘에만 도박을 걸지 않습니다. 성질이 다른 계층을 겹쳐서 적용합니다.
제1 방어 = 애플리케이션 계층의 가드(Guard). 영속화(persistence) 직전에 "애그리거트(aggregate)가 주장하는 테넌트"와 "실행 중인 테넌트"가 일치하는지 검사하여, 불일치하면 즉시 실패(fail-fast)시킵니다.
// 애그리거트가 신고하는 tenantId가 실행 중인 ctx.tenantId와 일치하는지 영속화 전에 검사한다
export function assertSameTenant(ctx: TenantContext, aggregateTenantId: TenantId, label: string): void {
if (aggregateTenantId !== ctx.tenantId) {
...
제2 방어 = DB의 행 수준 보안(Row-Level Security, RLS). DB 측에서 "쓰려는 행의 테넌트가 접속한 테넌트와 일치할 것"을 강제합니다. 애플리케이션 계층의 가드를 어떤 이유로 통과하더라도, 마지막에 DB가 경계 침범 쓰기를 거부합니다.
제3 = 실제 DB를 띄운 통합 테스트. 실제로 PostgreSQL을 실행하여 다른 테넌트의 데이터를 읽거나 쓸 수 없음을 확인합니다.
왜 겹쳐서 사용할까요? 애플리케이션 계층의 가드는 읽기 쉬운 에러를 내며 빠르게 실패하므로 개발 경험(DX)이 좋습니다. 하지만 가드 자체에 버그가 있거나, 향후 다른 경로로 DB를 직접 호출하게 되면 그냥 통과될 수 있습니다. DB의 행 수준 보안은 마지막 방파제로, 애플리케이션을 우회하더라도 작동합니다. 빨리 실패하는 계층과 마지막에 반드시 잡아내는 계층이라는, 성질이 다른 실패 모드(failure mode)를 두 겹으로 겹쳐 놓은 것입니다.
"테스트는 통과(Green)"해도, 아래 계층은 헛돌고 있을지도 모른다
다층 구조로 만든 후, 구현한 경로와는 다른 리뷰를 하다가 불쾌한 사실을 깨달았습니다.
경계 침범 입력을 전달하는 테스트는 제1 방어(애플리케이션 가드)에서 멈추고 통과(Green)됩니다. DB까지 도달하지 않습니다. 이는 곧, 그 이면에서 제2 방어(DB의 행 수준 보안) 코드를 삭제해도 어떤 테스트도 실패(Red)하지 않는다는 뜻입니다. 제2 방어는 "코드는 존재하지만, 회귀 테스트(regression test)에서 한 번도 구동되지 않는" 상태였습니다. 테스트는 통과(Green)되었습니다. 하지만 그 Green이 말해주는 것은 "가장 상위 계층이 작동하고 있다"는 것뿐이었고, 아래 계층이 보호되고 있다는 보증은 되지 못했습니다.
이는 행 수준 보안에만 국한된 문제가 아닙니다. 유효성 검사(validation)를 여러 계층으로 겹치면, 상위 계층이 먼저 걸러내는 만큼 아래 계층은 테스트에서 발화(trigger)되지 않게 됩니다. Green은 가장 상위 계층에 대해서만 증언합니다. 다층 방어를 도입하는 순간, 아래 계층은 "도입했다는 착각"에 빠지기 쉽습니다.
대책은 상위 계층을 의도적으로 우회하여, 아래 계층 단독으로 제약이 작동하는지 확인하는 회귀 테스트를 추가하는 것입니다. 프로덕션 코드는 변경하지 않고, 테스트 전용으로 "애플리케이션 가드를 건너뛰고 DB 호출만 직접 실행하는" 복제본을 작성합니다.
// 우회 테스트용: 제1 방어(app 가드)를 거치지 않고, DB 호출만 직접 실행한다.
// 목적 = 제2 방어(RLS) "단독"으로 경계 침범 쓰기가 거부되는 것을 회귀 테스트로 고정한다.
// 이것을 Green으로 유지하면, 제2 방어 코드를 삭제하는 순간 이 테스트가 Red가 된다.
...
이렇게 하면 제2 방어 코드를 삭제했을 때 이 테스트만 Red가 됩니다. 비로소 아래 계층이 회귀 테스트를 통해 보호받게 됩니다.
구멍은 아직 있었다
이 우회 테스트를 작성하는 과정에서 또 두 가지 실수를 했습니다. 둘 다 "겉보기에는 보호되고 있는 것 같지만, 실제로는 보호되지 않는" 유형입니다.
첫 번째. 쓰기 작업이 upsert...
(있으면 업데이트, 없으면 삽입)일 때, 업데이트 분기에서 업데이트 값에 테넌트(tenant) 열을 포함하지 않았습니다. 그러면 UPDATE는 테넌트 열을 기존 상태 그대로(=연결된 테넌트와 일치) 유지한 채 다른 필드만 덮어쓰기 때문에, DB 검사를 그대로 통과하게 됩니다. 신규 삽입 경로는 새로운 행의 테넌트를 검사하여 경계 침범을 막고 있었지만, 업데이트 경로에만 구멍이 나 있었던 것입니다. 업데이트 분기에서도 테넌트 열을 쓰도록 수정하여, 양쪽 경로 모두에서 DB가 작동하도록 했습니다.
두 번째, 그리고 이것은 다음 이야기로 이어집니다. "제2 방어 코드를 삭제하면 테스트가 실패(red)해야 한다"——이 우회 테스트(bypass test)의 건전성을 측정하기 위해, 실제로 DB의 제약 조건(constraint)을 삭제했을 때 테스트가 실패하는지 시도해 보았더니, 삭제해도 실패하지 않았습니다. 사용 중인 PostgreSQL에는 행 수준 보안(Row-Level Security, RLS)의 UPDATE 정책에서 WITH CHECK를 생략하면, USING 식이 새로운 행의 체크에도 유용된다는 사양이 있었습니다. 그래서 WITH CHECK를 "통째로 삭제하는" 변이(mutation)로는 방어가 약해지지 않았고, 변이가 헛스윙을 하고 있었던 것입니다. 즉, "제약을 없애면 실패한다"는 전제의 테스트 자체가 검출력(detection power) 제로로 헛돌고 있었습니다. 올바르게 검출력을 측정하려면, 구문을 삭제하는 것이 아니라 WITH CHECK (true) (항상 통과)로 다시 써야 합니다——이것은 실제 DB를 돌려보고 나서야 알게 되었습니다.
검증 코드가 정말로 작동하고 있는지를 또 다른 검증을 통해 확인해야 합니다. 이 중첩 구조에 대해서는 나중에 다시 다루겠습니다.
핵심
여기까지를 한 문장으로 요약하면 다음과 같습니다.
AI가 지켜주길 바란다면, AI를 이해시키려 하기보다 위반했을 때 실패하는 환경을 만드는 것이 더 강력하다.
이해에 의존하는 것은 매번 출력되는 결과라는 가챠(gacha)에 도박을 거는 것과 같습니다. 환경에 심어두면, 누가, 어떤 AI가, 몇 번째 시도에서 작성하더라도, 규칙을 어기는 순간 실패하게 됩니다.
요점은 세 가지입니다. 제약 조건마다 그것이 거짓말을 할 수 없는 장소를 선택할 것(의존성 방향은 패키지 경계, 경계 침범 금지는 DB 및 실제 DB 테스트). 중요한 선은 성질이 다른 계층(layer)으로 겹칠 것. 그리고 겹쳤다면, 상위 계층에 가려져 하위 계층이 헛돌고 있지는 않은지를 우회 테스트로 확인할 것.
반대 방향의 선택도 있습니다. 복잡도나 함수의 길이와 같이 "절대 악은 아닌" 지표는 일부러 에러(error)로 처리하지 않고 경고(warn) 신호로만 남겨둡니다. 임계값을 하나 넘었다고 기계가 바로 차단해 버리면, AI의 생성 루프가 헛돌게 되기 때문입니다. 강도를 높이는 것도, 일부러 높이지 않는 것도 제약의 성질에 따라 결정합니다. 이것이 피트니스 함수(fitness function)를 설계한다는 의미입니다.
방론: 정적으로 판정할 수 있는 제약이라면 컴파일러까지 밀어넣을 수 있다
의존성 방향은 TypeScript의 패키지 경계에서 "모듈 해결(module resolution) 실패"로서 차단되었습니다. 컴파일 언어라면, 이러한 종류의 정적으로 판정할 수 있는 제약에 대해서는 위반을 한 단계 더 물리적인 장소——컴파일 에러(compile error)——까지 밀어넣을 수 있습니다.
C++에서 확실한 방법은 타겟 단위로 include의 가시성(visibility)을 닫는 것입니다. domain 타겟의 include 경로에 infra 헤더를 전달하지 않으면, domain 코드에서 일반적인 경로로는 infra 헤더를 #include 할 수 없게 되어, 컴파일 시점에 "file not found"가 됩니다.
# domain에는 infra의 include 경로를 전달하지 않음 = domain에서 infra의 헤더를 include 할 수 없음
add_library(domain STATIC domain/order.cpp)
target_include_directories(domain PRIVATE domain/) # ← infra/를 포함하지 않음
...
링크 의존 그래프(target_link_libraries)도 의존의 "방향"을 표현하지만, 이것만으로는 아키텍처 방화벽(architecture firewall)이 되지 않습니다. 정적 라이브러리(static library)는 생성 시점에는 링크되지 않고, 의존성은 최종 실행 파일 측의 링크로 전파되기 때문에, 최종 바이너리가 우연히 양쪽을 모두 링크하고 있다면 domain에서 infra의 심볼(symbol)이 해결되어 버릴 수도 있습니다. 그래서 C++에서는 "링크하지 않는 것"보다 "include의 가시성을 닫는 것"이 컴파일 시점에 확실히 막을 수 있는 방법입니다(이는 대조를 위한 합성 예시이며, 실제로 돌리고 있는 것은 TypeScript 쪽입니다).
강조하고 싶은 점은, 이것이 "결국에는 전부 컴파일 시점에 걸러내는 것이 이상적이다"라는 이야기가 아니라는 것입니다. 빌드 불가능 상태까지 밀어붙일 수 있는 것은 의존 방향(dependency direction)처럼 구조만으로 판정할 수 있는 제약에 한정됩니다. 경계 침범 금지와 같이 실행 시점(runtime)의 값으로 결정되는 제약은 컴파일러의 손이 닿지 않습니다. 따라서 밀어넣을 수 있는 제약은 밀어넣고, 닿지 않는 제약은 다른 곳에서 지킨다—즉, 선택과 집중이 필요하다는 이야기입니다.
Fitness Function은 특정 도구가 아니다
지금까지 문법(syntax), lint, 패키지 경계, 애플리케이션 계층 가드, DB의 행 레벨 보안(row-level security), 실제 DB 테스트, 컴파일러 등 다양한 곳에서 제약을 지켰습니다. 강도도 메커니즘도 다르지만, 하고 있는 일은 같습니다. 원하는 아키텍처 특성(architectural characteristics)이 유지되고 있는지를 지속적이고 객관적으로 평가하는 것입니다.
이것이 바로 Neal Ford 등이 말하는 fitness function입니다(두 번째 글에서 소개했습니다). fitness function은 특정 라이브러리나 프레임워크가 아닙니다. "이 특성이 유지되고 있는가"를 기계가 반복적으로 판정할 수 있다면, 그것이 lint이든, 패키지 경계이든, DB의 제약이든, 테스트이든, 컴파일러이든 전부 fitness function입니다. 도구의 이름이 아니라, 배치하는 방식의 성질을 가리킵니다.
따라서 "fitness function을 도입한다"는 것은 무언가를 설치하는 것이 아니라, 현재 인간의 머릿속이나 PR 리뷰에 있는 아키텍처 판단을, 그 제약이 거짓말을 할 수 없는 곳으로, 적절한 강도로 옮겨가는 것입니다.
"그럼 전부 컴파일러로 금지하면 되지 않을까?" — 아니오
강할수록 좋다면 모든 것을 타입(type)이나 컴파일러에 밀어넣으면 된다고 생각할 수 있습니다. 하지만 실제로는 그렇지 않습니다. 특성에 따라 걸러낼 수 있는 위치가 다르기 때문입니다.
타입으로 표현할 수 있는 것: 부적절한 상태를 표현 불가능하게 만들 수 있는 제약. 상태를 타입으로 나누는 설계나 값의 형태에 대한 제약이 여기에 해당합니다. -
module / package / 빌드 경계에서 막는 것: 의존 방향. 구조 그 자체를 의존성에 쓰지 않거나 include를 닫음으로써 성립하지 않게 만듭니다. -
정적 분석(static analysis)으로 잡아내는 것: 순환 의존(circular dependency), catch로 삼켜버린 예외, 명명 규칙의 일부 등, 값을 보지 않고 구조만으로 판정할 수 있는 것들입니다. -
property / 실행 시점 / 통합 테스트로 지키는 것: "어떤 입력에서도 성립하는" 불변 조건(invariant), 경계 침범 금지, 외부 I/O의 정합성, 병행 실행(concurrency)의 경합, 시간 의존성. 실행하지 않으면 나타나지 않는 실행 시점의 값이나 성질입니다.
경계 침범 금지는 우연히 강력한 제약이었지만 정적 검사의 밖에 있었습니다. 그래서 타입이나 컴파일러로는 지킬 수 없었기에, DB와 실제 DB 테스트로 지켰습니다. 무너지면 끝장인 경계선 중 상당수는 이 "정적 검사의 밖"에 있습니다.
앞으로: 타입으로 지킬 수 없는 경계선과, AI가 반환하는 "PASS"
또 다른 타입과 실행 시점의 경계 사례를 들어보겠습니다. 어떤 레코드가 반드시 가져야 할 참조 ID를 branded type과 non-null을 사용하여 "참조를 가지지 않는 상태를 타입으로 표현 불가능하게" 만들었습니다. 타입 상으로는 그 ID가 반드시 존재합니다. 그럼에도 불구하고, ID를 만드는 함수가 빈 문자열을 통과시켜서 id("")를 통해 "참조는 있지만 내용은 비어 있는" 레코드를 만들어버릴 수 있었습니다. 타입은 "null이 아님"은 지켜주지만, "비어 있지 않음"이라는 실행 시점의 불변 조건은 지켜주지 못합니다.
function isBlank(v: string | null | undefined): boolean {
return v === null || v === undefined || v.trim().length === 0;
}
...
여기가 타입과 실행 시점의 경계입니다. "확정된 주문 금액은 두 번 다시 변하지 않는다", "저장 후 다시 읽어오면 원래대로 돌아온다"와 같은 실행 시점의 불변 조건은 타입만으로는 다 표현할 수 없습니다. 구체적인 예시를 하나 써보고 만족하는 것이 아니라, "관계"로서 작성하고, 수많은 값을 부딪쳐보며 깨지지 않는지 확인하는 것—property based testing의 영역입니다. 다만, 이 부분을 깊게 파고들면 글 한 편으로는 감당할 수 없으므로 입구까지만 다루겠습니다.
그리고 마지막 질문이 남습니다. 그 검증 코드 자체를 AI가 작성하고, AI가 "PASS"라고 보고했을 때, 그 초록색(성공 신호)을 무엇을 근거로 신뢰할 것인가 하는 점입니다. 방금 "제약을 삭제하면 테스트가 빨간색(실패)이 되어야 하는데, 실제로는 삭제해도 빨간색이 되지 않았다"는 사례를 보았듯이, 검증은 효과가 있는 것처럼 보이지만 실제로는 헛돌고 있을 때가 있습니다. fitness function (적합도 함수)을 기계에 맡기더라도, 그 기계를 작성한 것이 AI라면, 초록색 신호는 "지켜지고 있다는 증거"가 될 수도 있지만, "구현을 그대로 따라간 확인(追認)"에 불과할 수도 있습니다. 제약을 검증 가능하게 만드는 것과 그 검증을 신뢰할 수 있는 것은 별개의 문제입니다.
이 문제를 실행 시점의 불변 조건(invariant)을 "값으로 공략하는" 또 다른 주제(TypeScript의 property based testing)를 통해 목차와 무료 선행 장의 형태로 정리하여 공개하고 있습니다. 타입(type)으로 지킬 수 없는 경계선을 어떻게 고정할지, 그리고 AI가 반환하는 초록색 신호를 어떻게 의심할지에 관심이 있다면 그쪽을 확인해 보시기 바랍니다.
Discussion

AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기