ESLint와 AGENTS.md로 AI 협업 경계 세우기
작성일:2026.06.19|수정일:2026.06.19|조회수:2
처음에는 단순히 ESLint를 설정하는 과제라고 생각했다. 프론트엔드 프로젝트에서 ESLint와 Prettier를 붙이고, Husky와 lint-staged를 연결하고, 커밋 전에 어느 정도 코드 품질을 확인하게 만드는 일. 익숙한 일까지는 아니어도, 적어도 낯선 일은 아니라고 생각했다.
그런데 막상 설정을 하다 보니 생각보다 다른 문제가 보였다. 이건 단순히 “코드 스타일을 맞추자”의 문제가 아니었다. 특히 AI 에이전트와 같이 코드를 작성한다고 생각하면 더 그렇다. 사람은 프로젝트 맥락을 어렴풋이 기억하고, 리뷰 과정에서 “이건 우리 스타일이 아닌데?”라고 말할 수 있다. 하지만 에이전트는 그런 암묵지를 기본값으로 갖고 있지 않다. 규칙이 없으면 가장 그럴듯한 코드를 만든다. 그리고 그럴듯한 코드가 항상 내가 원하는 코드는 아니다.
그래서 이번에는 ESLint를 “코드 스타일 도구”라기보다 “AI와 사람이 공유하는 경계선”으로 보고 설정했다. 어떤 코드는 허용하고, 어떤 코드는 막을 것인가. 어떤 판단은 기계적으로 처리하고, 어떤 판단은 문서와 리뷰로 남길 것인가. 이 질문에 답하는 과정이 생각보다 재미있었다.
내가 ESLint로 막고 싶었던 것들
ESLint 설정을 하다 보면 룰 이름을 먼저 보게 된다. no-explicit-any, exhaustive-deps, no-console 같은 이름들. 하지만 실제로 중요한 것은 룰 이름 자체가 아니라, 그 룰로 막고 싶은 행동이다. 나는 이번 설정에서 특히 “당장은 편하지만 나중에 설명하기 어려워지는 코드”를 막고 싶었다.
타입 문제를 숨기는 코드
가장 먼저 막고 싶었던 것은 타입 문제를 숨기는 코드였다. TypeScript를 쓰면서도 any나 @ts-ignore로 문제를 눌러버리면, 타입 시스템은 더 이상 안전망이 아니라 장식에 가까워진다. 특히 AI가 코드를 생성할 때 이런 우회는 꽤 위험하다. 에이전트는 일단 통과하는 코드를 만들기 위해 any를 넣거나, 타입 에러를 주석으로 덮는 선택을 할 수 있다. 물론 사람도 한다. AI만의 문제는 아니다. 다만 AI는 그 선택을 더 빠르고 자연스럽게 할 수 있다는 점이 다르다.
그래서 TypeScript 쪽에서는 우회로를 최대한 닫았다.
'@typescript-eslint/ban-ts-comment': [
'error',
{
'ts-check': true,
'ts-expect-error': true,
'ts-ignore': true,
'ts-nocheck': true,
},
],
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-unnecessary-type-assertion': 'error',여기서 중요한 것은 “타입 에러가 나면 무조건 고생해라”가 아니다. 타입 에러를 만났을 때 우회하지 말고, 왜 타입이 맞지 않는지 드러내라는 쪽에 가깝다. 모르면 any로 덮는 대신 타입을 세워야 하고, 외부 데이터가 불확실하다면 런타임 검증을 해야 한다. 그래서 Zod 관련 규칙도 문서에 같이 넣었다.
- 외부에서 들어오는 데이터는 신뢰하지 않는다.
- 서버 응답, route/search params, form payload, localStorage/sessionStorage,
환경 변수처럼 런타임에 깨질 수 있는 값은 경계에서 Zod schema로 검증한다.
- schema에서 파생되는 타입은 `z.infer<typeof Schema>`로 만든다.타입은 컴파일 타임의 약속이고, Zod는 런타임의 확인이다. 이 둘을 섞어 쓰지 않으면 외부 데이터 앞에서 타입은 쉽게 거짓말이 된다. 이 지점은 AI 에이전트에게도 명확히 알려줘야 한다고 생각했다. “응답 타입을 믿고 그냥 쓰지 말고, 경계에서 검증하라”는 규칙은 문서로만 남기면 놓치기 쉽지만, 적어도 any나 불필요한 assertion을 막아두면 쉽게 우회하기는 어려워진다.
남은 import와 정리되지 않은 코드
두 번째로 막고 싶었던 것은 사용하지 않는 코드와 흐트러진 import였다. 이런 문제는 하나하나 보면 사소하다. 사용하지 않는 import 하나, 순서가 뒤섞인 import 몇 줄, 남아 있는 변수 하나. 하지만 이런 것들은 리뷰의 집중력을 갉아먹는다. 리뷰어가 봐야 하는 것은 “이 구조가 맞는가”, “이 상태 전이가 자연스러운가”, “이 API 경계가 괜찮은가” 같은 것인데, import 정렬이나 unused variable 같은 것을 계속 보고 있으면 정작 중요한 문제를 놓치기 쉽다.
그래서 이 부분은 기계가 처리하게 했다.
'unused-imports/no-unused-imports': 'error',
'unused-imports/no-unused-vars': [
'error',
{
args: 'after-used',
argsIgnorePattern: '^_',
caughtErrors: 'all',
caughtErrorsIgnorePattern: '^_',
vars: 'all',
varsIgnorePattern: '^_',
},
],
'simple-import-sort/imports': 'error',
'simple-import-sort/exports': 'error',의도적으로 쓰지 않는 값은 _ prefix로 표시할 수 있게 했다. 이건 나름의 타협이다. 모든 unused parameter를 무조건 지우라고 하면 인터페이스를 맞추는 코드나 callback signature에서 오히려 불편해지는 경우가 있다. 대신 “안 쓰지만 의도한 것이다”를 코드에 표시하게 만들었다.
React에서 나중에 문제가 되는 패턴
React 쪽에서는 당장 화면이 나오는 것과 나중에 유지보수 가능한 것 사이의 간극을 줄이고 싶었다. 예를 들어 Hook dependency는 처음에는 잘 돌아가는 것처럼 보이다가 나중에 이상한 타이밍에 터진다. target="_blank"에 rel을 빼먹는 것은 화면상으로는 티가 나지 않지만 보안 문제다. children이 없는 컴포넌트를 닫는 방식이나 boolean prop 표현은 기능에 영향을 주지는 않지만, 프로젝트 전체의 리듬을 흐리게 만든다.
'react-hooks/exhaustive-deps': 'error',
'react/jsx-no-target-blank': 'error',
'react/self-closing-comp': 'error',
'react/function-component-definition': [
'error',
{
namedComponents: 'function-declaration',
unnamedComponents: 'function-expression',
},
],컴포넌트 선언은 function declaration을 기본으로 했다. 이건 취향의 영역처럼 보일 수 있다. 실제로 어느 정도는 취향이다. 하지만 프로젝트에서는 취향도 합의되면 규칙이 된다. AI 에이전트 입장에서도 “컴포넌트는 대충 아무 방식으로나 만들면 된다”보다 “이 프로젝트에서는 이렇게 만든다”가 훨씬 다루기 쉽다.
그리고 React Compiler를 전제로 두면서 수동 memoization에 대한 규칙도 문서에 넣었다.
- React 컴포넌트 최적화는 React Compiler가 기본 전제다.
- `useMemo`, `useCallback`, `React.memo`는 측정된 병목이나 참조 안정성이
실제 API 계약인 경우에만 사용한다.예전에는 useMemo, useCallback을 성능 최적화 습관처럼 붙이는 경우가 많았다. 하지만 React Compiler를 전제로 하면, 기본 전략은 조금 달라진다. 먼저 컴포넌트와 훅을 순수하고 예측 가능하게 만들고, 수동 memoization은 이유가 있을 때만 쓴다. 이 규칙은 ESLint 하나만으로 완전히 표현하기는 어렵지만, AGENTS.md와 /docs 문서에서 계속 강조할 수는 있다.
FSD 경계를 흐리는 export
이번 설정에서 마음에 들었던 부분 중 하나는 export *를 막은 것이다. export *는 편하다. 하지만 편한 만큼 경계를 흐린다. 특히 FSD처럼 slice의 public API를 중요하게 보는 구조에서는 어떤 것이 외부로 노출되는지 명시적으로 보여야 한다.
const restrictedSyntaxExplicitIndexExports = [
...restrictedSyntaxNoSrcDefaultExport,
{
message:
'FSD public APIs must explicitly list exports. Do not use export * from index.ts.',
selector: 'ExportAllDeclaration',
},
]index.ts는 단순히 re-export를 모아두는 파일이 아니라, slice가 바깥에 공개하는 계약에 가깝다. 이 계약을 export *로 열어버리면 내부 구현이 외부로 흘러나가기 쉽고, 나중에 무엇이 public API인지 알기 어려워진다. 그래서 귀찮더라도 명시적으로 쓰게 했다.
비슷한 이유로 src 내부의 default export도 제한했다.
const restrictedSyntaxNoSrcDefaultExport = [
...restrictedSyntaxBase,
{
message:
'Default exports hide module intent. Use named exports unless this file is a framework/bootstrap entry point with documented justification.',
selector: 'ExportDefaultDeclaration',
},
]default export는 import하는 쪽에서 이름을 마음대로 붙일 수 있다. 작은 프로젝트에서는 별 문제가 아닐 수 있지만, 규모가 커지면 이름의 일관성이 무너진다. 물론 Vite 설정이나 ESLint 설정처럼 도구가 default export를 요구하는 파일은 예외다. 그래서 “무조건 금지”라기보다 “src 내부의 일반 모듈에서는 금지”에 가깝게 잡았다.
JSX 안에 숨어드는 조건문과 반복문
가장 취향이 강하게 들어간 부분은 JSX 안의 조건부 렌더링과 목록 렌더링을 제한한 것이다.
const restrictedSyntaxReactRendering = [
...restrictedSyntaxNoSrcDefaultExport,
{
message:
'Use <Show /> from @ilokesto/utilinent instead of inline logical conditional rendering in JSX children.',
selector: 'JSXElement > JSXExpressionContainer > LogicalExpression',
},
{
message:
'Use <Show /> from @ilokesto/utilinent instead of inline ternary conditional rendering in JSX children.',
selector: 'JSXElement > JSXExpressionContainer > ConditionalExpression',
},
{
message:
'Use <For /> from @ilokesto/utilinent instead of inline array.map rendering in JSX children.',
selector:
"JSXElement > JSXExpressionContainer > CallExpression[callee.property.name='map']",
},
]이건 일반적인 ESLint 설정이라기보다는 프로젝트 철학에 가깝다. JSX 안에서 condition && <Component />, items.map(...), 중첩 ternary가 늘어나면 컴포넌트는 금방 읽기 어려워진다. 처음에는 짧아서 편하지만, 조건이 늘고 fallback이 생기고 loading/error/empty state가 붙으면 JSX가 화면 구조인지 제어 흐름인지 애매해진다.
그래서 조건은 <Show />, 목록은 <For />처럼 렌더링 의도를 명시적으로 드러내고 싶었다. 물론 이 선택은 호불호가 있을 수 있다. 어떤 사람은 오히려 map이 더 익숙하고 직접적이라고 느낄 수 있다. 나도 이 규칙이 항상 옳다고 생각하지는 않는다. 다만 이번에는 “AI가 JSX 안에 제어 흐름을 계속 밀어 넣는 것”을 막고 싶었고, 그 목적에는 꽤 맞는 규칙이라고 판단했다.
최종 ESLint 설정은 어떤 모양이 되었나
최종 설정은 단일 룰 목록이라기보다 여러 층을 쌓는 방식이 되었다. 먼저 JavaScript와 TypeScript recommended 설정을 깔고, React와 Hooks, 접근성, React Refresh 규칙을 얹었다. 그 위에 프로젝트 고유의 금지 패턴을 no-restricted-syntax와 no-restricted-imports로 추가했다. 마지막에는 Prettier 설정을 붙여 포맷 관련 충돌을 제거했다.
구조만 줄이면 대략 이런 모양이다.
const eslintConfig = defineConfig([
globalIgnores(['dist/**', 'coverage/**']),
js.configs.recommended,
...withFiles(tseslint.configs.strictTypeChecked, tsFiles),
react.configs.flat.recommended,
reactHooks.configs.flat['recommended-latest'],
jsxA11y.flatConfigs.recommended,
reactRefresh.configs.vite,
{
files: ['src/**/*.{ts,tsx}'],
rules: {
'no-restricted-syntax': ['error', ...restrictedSyntaxReactRendering],
},
},
{
files: ['**/index.ts'],
rules: {
'no-restricted-syntax': [
'error',
...restrictedSyntaxExplicitIndexExports,
],
},
},
prettier,
])여기서 내가 신경 쓴 것은 “설정이 강하다”보다 “설정의 층위가 보인다”였다. 기본적으로 많은 프로젝트에서 동의할 수 있는 규칙이 있고, React 프로젝트라면 가져갈 만한 규칙이 있고, 이 프로젝트에서만 강하게 가져가고 싶은 규칙이 있다. 이 셋이 섞여 있으면 나중에 규칙을 수정할 때도 어렵다. 그래서 공통 규칙, React 규칙, 프로젝트 고유 규칙을 최대한 나눠두려고 했다.
ESLint만으로는 부족했다
하지만 ESLint만으로는 부족했다. ESLint는 코드의 모양을 막을 수 있지만, 에이전트가 어떤 문서를 먼저 읽어야 하는지, 어떤 기준으로 판단해야 하는지, 검증을 어디까지 해야 하는지는 알려주지 못한다.
예를 들어 export *를 막는 것은 ESLint가 할 수 있다. 하지만 “왜 public API를 명시적으로 관리해야 하는가”는 ESLint 메시지 한 줄로 설명하기 어렵다. useMemo를 습관적으로 쓰지 말자는 것도 마찬가지다. 어떤 경우에 예외가 되는지, 왜 React Compiler를 전제로 하는지, 이 프로젝트에서는 어느 문서를 봐야 하는지 같은 것은 별도의 문서가 필요하다.
그래서 AGENTS.md를 만들었다. 처음에는 여기에 모든 규칙을 다 넣어야 하나 고민했다. 그런데 그렇게 하면 문서가 너무 커진다. 게다가 ESLint 설정, TypeScript 설정, Prettier 설정과 내용이 중복되기 시작한다. 중복된 문서는 언젠가 어긋난다. 그리고 어긋난 문서는 없는 것보다 나쁠 때도 있다.
결국 AGENTS.md는 모든 규칙의 본문이 아니라, 라우터 문서로 설계했다.
## 상황별 참조 문서
- 코드 작성 방식, React/TypeScript 스타일, export 기준이 필요하면 `docs/conventions.md`를 본다.
- ESLint, Prettier, TypeScript 규칙의 의도나 금지 패턴을 확인해야 하면 `docs/lint-and-format.md`를 본다.
- 기능 배치, FSD 레이어, slice public API, `index.ts` export 기준이 필요하면 `docs/fsd-architecture.md`를 본다.
- UI, form, interaction, loading/error/empty state를 바꾸면 `docs/accessibility.md`를 본다.
- 작업 완료 전 어떤 명령으로 검증할지 판단해야 하면 `docs/testing.md`를 본다.
- 변경 범위를 감사하거나 리뷰 기준을 맞춰야 하면 `docs/audit.md`를 본다.모든 작업에서 모든 문서를 읽으라고 하지 않았다. 그건 현실적이지 않다. 사람도 그렇게 안 한다. 대신 상황별로 어디를 봐야 하는지 적었다. 코드 스타일이면 conventions, lint 의도면 lint-and-format, 구조 문제면 fsd-architecture, UI 변경이면 accessibility. 이런 식으로 나누면 에이전트도 필요한 문서를 찾아가기 쉽고, 사람도 문서의 역할을 이해하기 쉽다.
Source of Truth를 나누기
문서가 많아질수록 중요한 것은 source of truth를 나누는 일이다. 같은 규칙이 README에도 있고, AGENTS.md에도 있고, ESLint 설정에도 있으면 언젠가 셋 중 하나는 낡는다. 그래서 AGENTS.md에는 우선순위를 명시했다.
## Source of Truth
- TypeScript 규칙은 `tsconfig*.json`을 우선한다.
- ESLint 규칙은 `eslint.config.mjs`를 우선한다.
- Prettier 규칙은 `.prettierrc`를 우선한다.
- React Compiler 설치와 Vite 연결은 `package.json`, `pnpm-lock.yaml`, `vite.config.ts`를 우선한다.
- `docs/*` 문서는 설정 파일로 표현하기 어려운 의도, 아키텍처, 리뷰 기준을 설명한다.
- `.opencode/*` 문서는 OpenCode 전용 커맨드와 감사 에이전트 지침이다.설정 파일은 실행 가능한 진실이다. 실제로 lint가 무엇을 막는지는 eslint.config.mjs가 결정한다. TypeScript가 어디까지 엄격한지는 tsconfig가 결정한다. Prettier가 어떤 포맷을 적용하는지는 .prettierrc가 결정한다.
반면 설정 파일은 “왜”를 설명하는 데 약하다. 그래서 docs/* 문서에는 의도와 판단 기준을 담았다. AGENTS.md는 그 문서들로 들어가는 입구다. 이 구조가 마음에 들었다. AGENTS.md가 커다란 규칙집이 아니라, 에이전트가 길을 잃지 않게 하는 표지판에 가까워졌기 때문이다.
절대 규칙 정하기
물론 라우터 역할만으로는 부족하다. 어떤 규칙은 너무 중요해서 입구에 바로 보여야 한다. 그래서 절대 규칙을 따로 뒀다.
## 절대 규칙
- 본인이 설명할 수 없는 코드는 커밋하거나 제출하지 않는다.
- `any`, `as any`, `@ts-ignore`, `@ts-expect-error`, `@ts-nocheck`, `eslint-disable`로 문제를 숨기지 않는다.
- 실패하는 lint, typecheck, build를 우회하지 않는다.
- `--no-verify`로 Git hook을 우회하지 않는다.
- 빈 `catch` 블록을 두지 않는다. 에러는 처리하거나 명시적으로 전파한다.
- 새 기능은 FSD 경계를 따라 배치하고, `App.tsx`나 라우트 파일에 비즈니스 로직을 누적하지 않는다.
- UI 변경은 접근성 요구사항을 함께 만족해야 한다.여기에는 내가 에이전트에게 가장 먼저 말하고 싶은 것을 넣었다. 특히 “본인이 설명할 수 없는 코드는 커밋하거나 제출하지 않는다”는 문장은 이 프로젝트의 제1원칙에 가깝다. AI가 만들었든 사람이 만들었든, 머지되는 순간 그 코드는 작성자의 코드가 된다. 설명할 수 없다면 아직 내 코드가 아니다.
/audit 커맨드와 감사 에이전트
마지막으로 /audit 커맨드를 만들었다. 내가 원한 것은 단순히 “AI가 알아서 고쳐주는 것”만은 아니었다. 오히려 먼저 읽고, 의심하고, 근거를 들어 지적하는 에이전트가 필요했다. 코드를 바로 고치는 에이전트보다, 고치기 전에 무엇이 문제인지 말해주는 에이전트가 더 유용할 때가 있다.
/audit은 기본적으로 read-only다. 파일을 수정하지 않고, 변경 범위를 읽고, 규칙에 비춰서 결과를 보고한다.
/audit
/audit --changed
/audit --full
/audit src/features/cart범위도 나눴다. 인자가 없으면 변경 파일을 기준으로 보고, --full이면 src, 설정 파일, docs, .opencode까지 넓게 본다. 특정 경로만 넘겨서 좁게 볼 수도 있다.
감사 관점도 나눴다.
- `fsd-auditor`: FSD 레이어, slice 책임, import 경계, public API 점검
- `convention-auditor`: TypeScript/React/스타일/AI 협업 컨벤션 점검
- `a11y-auditor`: UI 변경의 semantic HTML, keyboard, focus, aria, contrast 리스크 점검
- `quality-auditor`: lint/type/build/test 검증 계획과 설정 우회 여부 점검이 구조는 꽤 마음에 든다. 하나의 에이전트에게 “전체적으로 봐줘”라고 하면 답이 넓고 흐려지기 쉽다. 반면 FSD, 컨벤션, 접근성, 품질 게이트처럼 관점을 나누면 리뷰도 더 선명해진다. 실제 팀 리뷰에서도 비슷하다. 한 사람이 모든 것을 같은 깊이로 보기는 어렵고, 관점이 나뉘면 놓치는 부분이 줄어든다.
물론 이것도 완성된 구조라고 생각하지는 않는다. 에이전트가 정말로 좋은 감사를 하려면 문서가 더 구체적이어야 하고, 예외 상황도 더 많이 쌓여야 한다. 하지만 적어도 “무엇을 기준으로 볼 것인가”를 코드 밖으로 꺼내놓는 출발점은 만들었다고 생각한다.
결국 만들고 싶었던 것
이번 작업을 하면서 ESLint가 단순히 코드를 괴롭히는 도구가 아니라는 생각을 다시 했다. 물론 ESLint는 귀찮다. 특히 처음 강한 규칙을 넣으면 기존 코드가 우르르 깨진다. 실제로 이번 브랜치에서도 기존 템플릿 코드가 새 규칙에 걸린다. 하지만 이건 실패라기보다 경계가 생겼다는 신호에 가깝다. 이제 이 프로젝트에서는 무엇이 허용되고 무엇이 허용되지 않는지 기계가 말해줄 수 있다.
AI 에이전트와 협업할 때 이 경계는 더 중요해진다. 에이전트에게 매번 “any 쓰지 마”, “export * 하지 마”, “조건부 렌더링은 이렇게 해”, “이 문서 먼저 읽어”라고 말할 수는 없다. 반복해서 말해야 하는 규칙은 설정과 문서로 옮겨야 한다. ESLint는 코드 레벨의 경계이고, AGENTS.md는 작업 방식의 경계이며, /audit은 그 경계를 다시 리뷰 루틴으로 돌려보내는 장치다.
정리하면 이번에 만든 것은 완벽한 규칙 모음이 아니다. 오히려 시작점에 가깝다. 앞으로 코드를 더 작성하고, 에이전트가 실제로 어떤 실수를 하는지 보고, 과한 규칙과 부족한 규칙을 조정해야 한다. 다만 방향은 분명해졌다. AI에게 더 많은 일을 맡기려면, 먼저 AI가 지킬 수 있는 형태로 프로젝트의 기준을 만들어야 한다. 그 기준은 말로만 있으면 쉽게 사라진다. 코드와 문서에 남겨야 한다.
댓글
댓글을 불러오는 중...