자유롭게 코드를 쓰되, 정해진 경계 안에서만
컨텍스트 엔지니어링이 에이전트에게 “무엇을 해야 하는가”를 알려주는 것이라면, 아키텍처 제약은 “무엇을 해서는 안 되는가”를 강제하는 것이다.
차이는 결정적이다. 컨텍스트는 정보를 제공하지만, 에이전트가 그 정보를 무시하거나 다르게 해석할 수 있다. AGENTS.md에 “UI에서 DB를 직접 호출하지 마세요”라고 적어두어도, 에이전트가 “이 경우에는 직접 호출하는 게 더 효율적”이라고 판단하면 규칙을 어긴다. 컨텍스트는 안내이고, 안내는 무시될 수 있다.
아키텍처 제약은 다르다. 규칙을 위반하는 코드가 물리적으로 저장소에 들어갈 수 없게 만든다. 린터가 차단하고, 테스트가 실패하고, CI가 거부한다. 에이전트의 판단과 무관하게, 경계를 넘는 코드는 존재할 수 없다.
의존성 방향 강제
아키텍처 제약의 가장 기본적인 형태는 의존성 방향 강제다. 코드베이스의 모듈 간 의존 관계를 한 방향으로만 허용하고, 역방향 의존을 기계적으로 차단하는 것이다.
OpenAI가 Codex 실험에서 적용한 의존성 계층은 다음과 같다.
Types → Config → Repository → Service → Runtime → UI
허용:
UI → Service → Repository → Config → Types (정방향)
금지:
Service → UI (서비스가 UI를 알면 안 됨)
Config → Repository (설정이 저장소를 참조하면 안 됨)
Types → 어떤 것이든 (타입은 순수해야 함)
이 규칙이 왜 중요한가? 에이전트가 10개 동시에 작업할 때를 생각해보자. 에이전트 A가 Service 레이어를 수정하면서 UI 컴포넌트를 직접 import하면, 에이전트 B가 UI를 리팩토링할 때 Service가 깨진다. 의존성 방향이 지켜지면 이런 교차 영향이 차단된다. 각 에이전트는 자기 레이어 안에서 자유롭게 작업하되, 경계를 넘지 못한다.
아키텍처 테스트로 구현하기
이 규칙을 문서에 적어두는 것과 테스트로 강제하는 것은 완전히 다른 문제다.
// tests/architecture.test.ts
import * as fs from 'fs';
import * as path from 'path';
// 파일에서 import 문을 추출하는 유틸
function extractImports(filePath: string): string[] {
const content = fs.readFileSync(filePath, 'utf-8');
const importRegex = /import\s+.*\s+from\s+['"](.+?)['"]/g;
const imports: string[] = [];
let match;
while ((match = importRegex.exec(content)) !== null) {
imports.push(match[1]);
}
return imports;
}
// 특정 디렉토리의 모든 .ts 파일을 재귀적으로 수집
function getTypeScriptFiles(dir: string): string[] {
const files: string[] = [];
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory() && entry.name !== 'node_modules') {
files.push(...getTypeScriptFiles(fullPath));
} else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts')) {
files.push(fullPath);
}
}
return files;
}
// 금지된 의존성 매핑
const FORBIDDEN_DEPENDENCIES: Record<string, string[]> = {
'src/domain': ['src/application', 'src/infrastructure', 'src/presentation'],
'src/application': ['src/infrastructure', 'src/presentation'],
'src/presentation': ['src/infrastructure'],
};
describe('Architecture: Dependency Direction', () => {
for (const [sourceLayer, forbiddenLayers] of Object.entries(FORBIDDEN_DEPENDENCIES)) {
for (const forbiddenLayer of forbiddenLayers) {
test(`${sourceLayer} must not import from ${forbiddenLayer}`, () => {
const files = getTypeScriptFiles(sourceLayer);
const violations: string[] = [];
for (const file of files) {
const imports = extractImports(file);
for (const imp of imports) {
if (imp.includes(forbiddenLayer.replace('src/', ''))) {
violations.push(`${file} imports "${imp}"`);
}
}
}
if (violations.length > 0) {
fail(
`의존성 방향 위반 발견 (${violations.length}건):\n` +
violations.map(v => ` - ${v}`).join('\n')
);
}
});
}
}
});
이 테스트가 CI에서 실행되면, 어떤 에이전트가 어떤 이유로든 레이어 경계를 넘는 import를 추가하면 빌드가 실패한다. 에이전트의 의도와 무관하게 규칙이 강제된다.
순환 의존성 감지
의존성 방향만큼 중요한 것이 순환 의존성 차단이다. 모듈 A가 B를 참조하고, B가 C를 참조하고, C가 다시 A를 참조하면 순환이 발생한다. 인간 개발자도 때때로 만드는 실수인데, 여러 에이전트가 동시에 작업하면 발생 확률이 높아진다.
// tests/circular-dependency.test.ts
import { execSync } from 'child_process';
describe('Architecture: No Circular Dependencies', () => {
test('no circular dependencies in src/', () => {
try {
// madge: 순환 의존성 탐지 도구
const result = execSync('npx madge --circular --extensions ts src/', {
encoding: 'utf-8',
});
if (result.trim().length > 0) {
fail(`순환 의존성 발견:\n${result}`);
}
} catch (error: any) {
if (error.stdout && error.stdout.trim().length > 0) {
fail(`순환 의존성 발견:\n${error.stdout}`);
}
}
});
});
커스텀 린터 규칙
아키텍처 테스트는 CI 단계에서 위반을 잡아내지만, 더 빠른 피드백을 원한다면 린터 규칙으로 만들 수 있다. 코드 작성 시점에서 바로 위반을 감지하는 것이다.
ESLint 커스텀 규칙 예시
// eslint-rules/no-cross-layer-import.js
module.exports = {
meta: {
type: 'problem',
docs: {
description: '레이어 간 금지된 import를 차단한다',
},
messages: {
forbidden: '{{sourceLayer}}에서 {{targetLayer}}를 import할 수 없습니다. 의존성 방향: domain → application → presentation',
},
},
create(context) {
const filename = context.getFilename();
const LAYER_ORDER = ['domain', 'application', 'infrastructure', 'presentation'];
function getLayer(filePath) {
for (const layer of LAYER_ORDER) {
if (filePath.includes(`/src/${layer}/`)) return layer;
}
return null;
}
// 허용되는 의존성 방향 정의
const ALLOWED_DEPS = {
domain: [], // 어디에도 의존하지 않음
application: ['domain'], // domain만
infrastructure: ['domain', 'application'], // domain, application
presentation: ['application'], // application만
};
return {
ImportDeclaration(node) {
const sourceLayer = getLayer(filename);
if (!sourceLayer) return;
const importPath = node.source.value;
const targetLayer = getLayer(
require.resolve(importPath, { paths: [context.getFilename()] }).toString()
).catch(() => null);
if (!targetLayer || targetLayer === sourceLayer) return;
const allowed = ALLOWED_DEPS[sourceLayer] || [];
if (!allowed.includes(targetLayer)) {
context.report({
node,
messageId: 'forbidden',
data: { sourceLayer, targetLayer },
});
}
},
};
},
};
// .eslintrc.js — 커스텀 규칙 등록
module.exports = {
plugins: ['local-rules'],
rules: {
'local-rules/no-cross-layer-import': 'error',
},
};
OpenAI의 Codex 실험에서 흥미로운 점은, 이 커스텀 린터 규칙 자체도 Codex가 작성했다는 것이다. 에이전트가 만든 린터가 에이전트를 감시하는 구조다.
Pre-commit 훅: 커밋 자체를 막는 게이트
아키텍처 테스트와 린터를 CI에서만 돌리면, 위반된 코드가 일단 커밋되고 푸시된 후에야 실패를 알게 된다. Pre-commit 훅을 설정하면 커밋 단계에서 차단할 수 있다.
#!/bin/sh
# .husky/pre-commit
echo "🔍 아키텍처 검증 중..."
# 1. 코드 스타일 검사
npx lint-staged
if [ $? -ne 0 ]; then
echo "❌ 코드 스타일 위반. 커밋이 거부되었습니다."
exit 1
fi
# 2. 아키텍처 규칙 검증
npm run test:architecture -- --silent
if [ $? -ne 0 ]; then
echo "❌ 아키텍처 규칙 위반. 커밋이 거부되었습니다."
exit 1
fi
# 3. 타입 검사
npx tsc --noEmit
if [ $? -ne 0 ]; then
echo "❌ 타입 에러. 커밋이 거부되었습니다."
exit 1
fi
echo "✅ 모든 검증 통과. 커밋 진행."
// package.json — lint-staged 설정
{
"lint-staged": {
"src/**/*.ts": [
"eslint --fix",
"prettier --write"
],
"src/**/*.test.ts": [
"jest --findRelatedTests"
]
}
}
이 구조에서 에이전트가 레이어 경계를 위반하는 코드를 작성하면, 커밋 자체가 실패한다. 에이전트는 위반 사항을 확인하고 코드를 수정한 후 다시 커밋을 시도한다. 이 피드백 루프가 자동으로 돌아가면서, 규칙을 위반하는 코드가 저장소에 들어갈 가능성이 구조적으로 차단된다.
데이터 경계 파싱 강제
OpenAI 팀이 적용한 또 다른 중요한 제약은 데이터 경계에서의 파싱 강제다.
외부 API 응답, 사용자 입력, 데이터베이스 쿼리 결과 등 시스템 경계를 넘는 데이터는 반드시 파싱과 검증을 거쳐야 한다. 타입 시스템만으로는 런타임에 실제로 올바른 데이터가 들어오는지 보장할 수 없기 때문이다.
// src/shared/validation.ts — 경계 파싱 유틸
import { z } from 'zod';
// 외부 API 응답 스키마
export const PaymentGatewayResponseSchema = z.object({
transactionId: z.string().uuid(),
status: z.enum(['success', 'failed', 'pending']),
amount: z.number().positive(),
currency: z.string().length(3),
timestamp: z.string().datetime(),
});
export type PaymentGatewayResponse = z.infer<typeof PaymentGatewayResponseSchema>;
// 사용자 입력 스키마
export const CreateOrderRequestSchema = z.object({
items: z.array(z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive().max(100),
})).min(1).max(50),
shippingAddress: z.object({
street: z.string().min(1).max(200),
city: z.string().min(1).max(100),
zipCode: z.string().regex(/^\d{5}(-\d{4})?$/),
}),
});
// src/infrastructure/payment-gateway.ts — 경계에서 파싱 적용
import { PaymentGatewayResponseSchema } from '../shared/validation';
export class PaymentGatewayClient {
async charge(amount: number, token: string) {
const rawResponse = await fetch('https://api.pg.com/charge', {
method: 'POST',
body: JSON.stringify({ amount, token }),
});
const rawData = await rawResponse.json();
// 경계 파싱: raw 데이터를 검증된 타입으로 변환
const parsed = PaymentGatewayResponseSchema.safeParse(rawData);
if (!parsed.success) {
throw new ExternalServiceError(
'PG 응답 파싱 실패',
{ errors: parsed.error.flatten(), rawData }
);
}
return parsed.data; // 이제 타입이 보장된 데이터
}
}
핵심은 “무엇을 구현해야 하는가”는 유연하게 두되, “어떤 경계에서는 반드시 파싱해야 한다”는 것은 강제하는 것이다. 어떤 파싱 라이브러리를 쓸지(Zod, io-ts, Yup 등)는 에이전트의 재량이지만, 파싱 자체를 건너뛰는 것은 허용되지 않는다.
이를 테스트로 강제할 수도 있다.
// tests/boundary-parsing.test.ts
describe('Architecture: Boundary Parsing', () => {
test('infrastructure 레이어의 모든 외부 호출은 스키마 검증을 포함해야 한다', () => {
const infraFiles = getTypeScriptFiles('src/infrastructure');
const violations: string[] = [];
for (const file of infraFiles) {
const content = fs.readFileSync(file, 'utf-8');
// fetch나 axios 호출이 있으면
if (content.match(/\b(fetch|axios\.(get|post|put|delete))\b/)) {
// safeParse나 parse 호출이 있어야 함
if (!content.match(/\.(safeParse|parse)\(/)) {
violations.push(file);
}
}
}
if (violations.length > 0) {
fail(
`외부 호출에 스키마 검증이 누락된 파일:\n` +
violations.map(v => ` - ${v}`).join('\n')
);
}
});
});
제약의 철학: 규범이 아니라 구현을 강제하라
아키텍처 제약을 설계할 때 중요한 철학적 원칙이 있다.
불변 규칙(invariant)을 강제하되, 구현 방식(implementation)은 자유롭게 두라.
강제해야 할 것 (불변 규칙):
✅ 의존성 방향
✅ 데이터 경계에서의 파싱
✅ 에러 타입의 계층 구조
✅ 테스트 커버리지 최소 기준
✅ 금지된 패턴 (전역 상태 변경, 직접 DB 접근 등)
자유롭게 둘 것 (구현 세부사항):
🔓 함수 내부 구현 로직
🔓 알고리즘 선택
🔓 변수명 (컨벤션 범위 내에서)
🔓 파싱 라이브러리 선택
🔓 테스트 작성 방식
제약이 너무 촘촘하면 에이전트의 유연성이 사라지고, 모델이 업데이트될 때마다 제약을 다시 작성해야 한다. 제약이 너무 느슨하면 일관성이 무너진다. 적절한 수준은 “경계는 엄격하게, 경계 안에서는 자유롭게”다.
OpenAI 팀의 표현을 빌리면, “에이전트는 엄격한 경계와 예측 가능한 구조를 가진 환경에서 가장 효과적이다.” 제약은 에이전트를 제한하는 것이 아니라, 에이전트가 안전하게 자율적으로 작업할 수 있는 공간을 정의하는 것이다.