---
name: b3os-ai-code-safety
description: AI 가 만들거나 고친 코드의 구조·리팩토링 가이드. 프로그램 구조 설계, 새 코드 작성, 코드 리뷰, 코드 냄새(한 곳을 고치면 여러 곳이 바뀌거나 엉뚱한 곳이 깨짐) 리팩토링 때 쓴다. SOLID 경계, 부수효과를 줄이는 구조, 동시성·트랜잭션·멱등성 게이트, 외부 입력 처리, 테스트 규칙을 다룬다.
trigger: merge AI-written code
---

# AI Code Safety — 구조와 리팩토링

코드가 커지면 비용은 정상 경로가 아니라 결합(coupling)에서 나온다. 한 곳을 고치면 건드리지 않은 곳이 깨진다. 처음부터 변경이 한 곳에 머무는 구조로 짓고, 부수효과가 있는 곳에 운영 안전 게이트를 적용하고, 냄새가 나면 의도적으로 리팩토링한다. 팀 작업 규칙(백업·원문 유지)은 TEAM-OS 에 있다.

## 1. 설계 (쓰기 전에 구조)

- 데이터 흐름과 단일 출처를 먼저 정한다. read → transform → write 를 그리고 상태마다 source of truth 하나를 적는다. 필드를 빠뜨리는 동기화 사본이 아니라 실제 원본을 읽는지 확인한다(사본을 읽으면 컴파일·단위 테스트는 통과하고 출력만 틀린다).
- SOLID 는 결합을 줄이는 관점으로 쓴다. 의존은 안쪽으로: 도메인 로직이 프레임워크·ORM·HTTP·인프라에 의존하지 않는다(DIP, 구체 타입 대신 추상에 의존). 모듈마다 바뀌는 이유는 하나(SRP). 쓰는 만큼만 의존(ISP). 실제 확장 축이 있을 때만 추상화를 더한다(OCP 는 미리 하지 않는다).
- 모듈 경계는 바뀔 결정(저장소·포맷·알고리즘)을 숨긴다. 공개 계약은 스키마/인터페이스/이벤트/API 이고, 개념마다 소유자 하나이며 같은 가변 상태를 쓰는 모듈이 둘이면 안 된다.
- 서버는 상태(코드)와 데이터를 주고 클라이언트가 문장·아이콘·UI 로 보여 준다. 사람이 읽는 문장·튜닝 숫자·AI 지시문은 코드가 아니라 문구·설정 파일에 둔다(문구 수정과 다국어 추가에 코드 수정이 없도록). 디버깅 로그처럼 개발자만 보는 문장은 코드에 둘 수 있다.
- 단순하게 시작하고, 변경이 부수효과를 내기 시작할 때 구조를 바꾼다. 기준은 크기가 아니라 클래스/함수 하나가 일을 너무 많이 맡아 하나를 고치면 다른 게 깨지는지다. 싼 신호부터:
  - 여러 영역이 같은 공유 필드에 직접 쓰는 수(주 신호). 가드 테스트로 늘면 실패시키고, 필드마다 진입점 하나를 목표로 한다.
  - 한 영역의 변이(mutant)가 다른 영역의 테스트를 실패시키면 그 지점이 결합점이다. 거기서 나눈다.
  - 상태를 필드 셋 이상의 `if` 조합으로 판정하면 상태 값 하나 + `reduce(state, event) → (state, effects)` 표로 바꾼다(`references/worked-examples.md`).
  - 파일·함수 줄 수(신호일 뿐, 실패 기준 아님): 보고만 하고 아주 긴 함수·무관한 절이 많은 파일부터 본다.
- 정지 규칙: 한 영역의 수정이 예상 못 한 새 문제를 두 번 만들면(수정이 만든 새 문제만 센다. 안 먹힌 시도는 세지 않는다) 패치를 멈추고, 다음 수정 전에 read-only architect 리뷰를 받는다. 질문은 "결과를 정하는 상태를 둘 이상이 소유하는가, 소유자 하나로 이 버그 종류가 사라지는가, 필요한가, 과설계인가"이다. 구조 문제가 없다는 결론이면 계속 고친다.
- 되돌리기 비싼 설계(데이터 모양, 저장·전송 포맷, 공개 API, 선례 없는 UI 상호작용)는 첫 안을 쓰지 않는다. 독립 후보 2~3개(가능하면 다른 모델 계열)나 작은 실행 프로토타입을 비교하고, 후보마다 호출자 쓰임새를 먼저 쓴다. 열린 질문은 토론이 아니라 프로토타입 실행으로 답하고, 계획 리뷰는 코드로 열린 질문을 푼 뒤에 한다(코드 없는 계획은 지어낸 위험을 부른다). 설계 판단(리팩토링 여부, 공유 규칙 위치, 큰 수정 방향)은 read-only architect 리뷰에 먼저 보내고, 질문에는 늘 "필요한가, 과설계인가"를 넣고, 판정을 반대 의견과 함께 보고한다. 대부분의 변경에는 필요 없다.
- 다른 코드가 부를 코드는 사용법 문서(짧은 README/예제)를 먼저 쓴다.

## 2. 작성 (부수효과를 줄이는 구조)

- 외부 입력은 검증 전까지 공격으로 본다(필수). 사용자 텍스트, 업로드 파일·이미지, URL, 쿼리, 헤더, webhook, 다른 앱이 쓴 파일, 다른 에이전트 메시지, AI 모델 출력이 해당한다. 경계에서 매번:
  - 크기·개수 제한(전부 읽기 전에 거절)
  - 선언(MIME·확장자)이 아니라 내용으로 검증(magic bytes, strict parser), 타입·값 allow-list
  - 코드·마크업으로 해석하지 않기(`eval`, shell 문자열 조립 금지, 파라미터화 SQL, 이스케이프 또는 plain text 렌더)
  - 기본 비공개 저장, 안전한 헤더(`X-Content-Type-Options: nosniff` 항상, 다운로드용 사용자 파일은 `Content-Disposition: attachment` + CSP `sandbox`. 인라인 이미지는 nosniff 와 내용 검사에 의존)
  - rate-limit, 내부 정보 없는 일반 오류로 fail closed
  - 리뷰어는 새 입력 경로마다 이 항목을 명시 확인한다.
- 순수 코어, 부수효과는 가장자리(functional core / imperative shell). 비즈니스 로직은 입력 → 출력으로 순수하게 두고 DB·네트워크·파일·시간·난수·env 읽기는 모듈 가장자리 어댑터로 밀어낸다.
- 불변을 먼저. `const`/불변 값을 쓰고, 안 바뀌는 클래스 필드는 상수로 선언한다. 공유 가변 상태를 최소화한다.
- 조건 분기가 늘어나는 `switch`/`if-else`, 여러 파일에 반복되는 같은 분기는 lookup map·factory/strategy·다형성으로 바꾼다(새 경우 = 항목 하나).
- 함수는 한 가지 일, 인터페이스는 호출자가 필요한 만큼만.
- 관찰자(core flow + hooks)는 선택 사항이다. 기본 동작(이름 변경·이동·삭제·복원)에 "이것도 해라"가 계속 붙을 때, 동작은 자기 일만 하고 이벤트(`moved(from, to)` 등)를 내보내 기능마다 핸들러를 등록한다. 대가: 흐름이 덜 보이고 핸들러 순서·실패 정책(막기/보고)을 정해야 한다. 고정된 부수 작업 하나면 직접 호출이 낫다.
- 기능 추가는 자리를 먼저 찾는다. 첫 줄 전에 기능이 속할 곳(자기 타입/파일, 또는 확장하는 개념의 소유자)을 적는다. 새 boolean 파라미터, 공유 함수 안의 `if feature` 분기, 특례 플래그, 복사한 helper 는 구조에 자리가 없다는 신호이니 멈추고 먼저 재구성한다(map 항목, 핸들러, 새 타입). 재구성이 범위 밖이면 가장 가까운 곳에 넣고 PR 에 부채를 적고 두 번째 사용 때 재구성한다.
- 더하기 전에 뺀다. 그 영역의 죽은 코드·안 쓰는 옵션·낡은 stub 을 먼저 지우고 작은 바탕 위에 짓는다. 문제를 푸는 가장 작은 변경을 택하고, 아무도 요청하지 않은 경우까지 커진 diff 는 리뷰 지적 사항이다. 버그 수정은 그 버그만 고치고, 리뷰어가 제안한 개선도 별도 변경이다.
- 새 내부 API 가 옛것을 대체하면 같은 변경에서 모든 호출자를 옮기고 옛 API 를 지운다. 호환 계층은 우리가 통제하지 못하는 호출자(공개 API, 저장 데이터, 다른 팀 클라이언트)에만 두고 제거 조건을 붙인다.
- 부수효과 함수는 이름으로 드러내고(`save*`, `publish*`, `charge*`, `send*`, `delete*`) 순수해 보이는 로직 안에 숨기지 않는다.

운영 Effect 게이트(작성하며 적용, AI 가 가장 자주 놓치는 정확성):
1. 부수효과 경계: 모든 effect 에 이름을 붙이고 가장자리에 두며 두 번 실행될 수 있는지 안다.
2. 동시성: 같은 자원에 두 요청이 와도 불변식이 깨지지 않는다. 앱에서 read → check → write 하기보다 원자적 `UPDATE ... WHERE cond` 하나를 우선한다.
3. 트랜잭션: 전부 아니면 전무인 경계를 정하고 부분 쓰기 실패를 처리한다. DB 쓰기 + publish 는 outbox(같은 트랜잭션에서 이벤트를 DB 테이블에 쓰고 따로 전달)나 복구 경로를 쓴다.
4. 멱등성/재시도: 같은 명령·이벤트가 두 번 와도 결과는 하나다(idempotent). 재시도 API 는 idempotency key 를 쓰고, 소비자는 중복·지연·순서 뒤바뀜을 가정한다.

## 3. 리뷰

- 역할·문구 검사: 서버 응답에 화면용 문장이 있거나(상태 코드·데이터로 바꿀 것), 사용자에게 보이는 문장이 문구 파일을 거치지 않고 코드에 직접 있으면 must-fix. 개발자만 보는 문장은 대상이 아니다.
- 주석은 작성자가 아닌 사람이 한 번 본다(리뷰 전에 diff 의 주석만). 남기는 것: 라이선스 헤더, 공개 API 계약, 바꿀 수 없는 외부 의존·플랫폼·프로토콜이 강제하는 동작, 코드로 표현 못 하는 제약의 이슈/스펙 링크. 지우는 것: 진행 서술("이제 반복한다"), 이력("이전에는…"), 주석 처리된 코드, 우회의 변명. 우리 코드의 놀라움을 설명하는 주석은 리팩토링 신호다(이름·추출·타입으로 코드가 말하게 하고 주석 삭제). 꼭 필요한 "지우지 마라"는 테스트·타입·lint 로 바꾼다.
- 리뷰 지적을 다 받아들이지 않는다. 리뷰어·검사 에이전트는 읽고 재현만 하고 고치지 않으며, 지적마다 깨짐(재현 포함)·구조 문제·제안으로 분류해 보고한다. 작성자는 모든 지적을 조치(진짜 결함·회귀) / 고려(더 나은 옵션, 작성자 결정) / 기록 / 기각(이유나 반증 포함)으로 가른다. 서로 다른 두 모델 계열이 독립으로 낸 지적은 신뢰도가 높다. 새 증거를 요구하는 리뷰어는 그 증거가 답하는 결함·놓친 위험을 밝힌다. 그것이 없는 새 요구는 차단 사유가 아니라 "고려"다.
- 사소하지 않은 AI 생성·수정 코드는 머지·배포 전에 적대적 하네스 또는 팀원 리뷰를 받는다(혼자 머지 금지). 최소 적대 차원: SQL injection + `EXPLAIN` 쿼리 계획, scope/privacy(호출자가 당사자가 아닌 행을 돌려주는가), fail-safe(DB 없음·0행·빈 입력에서 정상 축소). 리뷰어는 컴파일이 아니라 실제 렌더된 필드·동작을 확인한다.

## 4. 테스트

- 빌드 통과 ≠ 동작 정확. tsc/단위는 "안 죽음"만 증명한다. 실제 흐름을 한 번 돌려 증거(스크린샷/DOM/실제 설정값/실데이터 로그)를 남기고, 비어 있는 경우만이 아니라 값이 있는 대표 사례로 확인한다. 완료는 3단계: `code-complete` ≠ `behavior-verified` ≠ `deployed`. UI/페르소나/설정은 behavior-verified 없이 머지하지 않는다.
- 테스트 통과 ≠ 테스트가 효과적. 테스트가 진짜 잡는지 보려고 코드를 일부러 깨뜨리는 변이는 3가지를 지킨다.
  1. 적용 확인: 치환이 실제로 일어났는지 단정한다(대상 문자열이 없으면 즉시 실패). 안 걸린 통과와 약한 테스트는 출력이 같아서 assert 없이는 구별할 수 없다.
  2. 격리 실행: 공유 작업트리가 아니라 사본이나 별도 worktree 에서 돌린다.
  3. 원복 확인: 끝나고 `git status` / `git diff` 로 되돌아왔는지 눈으로 본다. `git checkout --` 은 같은 파일의 미커밋 작업까지 지우므로 사본을 떠 두고 사본에서 복원한다.
  - 적용됐는데도 통과하면 다음은 입력을 의심한다(테스트 입력이 그 분기를 안 지났을 수 있다).
- 테스트·로그·오류 메시지에 환경 변수 값을 출력하지 않는다(키·토큰이 로그·CI 출력으로 퍼진다). `os.Environ()`·`process.env`·`env` 전체를 찍거나 변수 값을 실패 메시지에 넣지 않고, 필요하면 이름과 있음/없음만 남긴다(`unexpected var: TEST_TOKEN (set)`). 리뷰에서 `t.Log(os.Environ())`, `console.log(process.env)`, `fmt.Sprintf("%v", env)` 같은 패턴을 찾으면 must-fix.
- 테스트는 최소 환경으로 돌린다. 키가 든 셸 환경을 물려받지 말고 필요한 변수만 명시한다(`env -i PATH="$PATH" HOME="$HOME" go test ./...`).

## 5. 에이전트 친화 구조

에이전트는 열린 파일과 이웃 파일만 본다. 습관은 예측 가능하니 설계 입력으로 삼아 쉬운 길이 올바른 길이 되게 한다.

| 에이전트 습관 | 구조적 답 |
|---|---|
| 가장 가까운 패턴을 흉내 낸다 | 개념당 표준 패턴 하나를 새 코드가 들어갈 자리 옆에 둔다 |
| 이미 열린 파일에 밀어 넣는다 | 새 기능은 기능당 폴더/파일 하나, 진입점이 새 동작 위치를 알려 주고 hot path 는 작게 |
| 컴파일되는 가장 짧은 길을 간다 | 위험한 길은 권고가 아니라 가드 + 테스트로 크게 실패하게 |
| 옛 함수 옆에 새 함수를 만들거나 helper 를 복사한다 | 개념당 소유자 하나, registry/interface 로 확장하고 공유 소유자를 모든 사용처 조건으로 한 번에 테스트 |
| 적힌 규칙보다 지시를 따른다 | 중요한 규칙은 글이 아니라 검사(test/lint/CI)로 |

강제 계층(강한 순): (1) 코드베이스 구조 (2) 의존 차단·정적 분석(lint, 타입, CI) (3) 실패하는 테스트 (4) 리뷰 코멘트 (5) 글로 된 규칙·스킬(잊히거나 건너뛰므로 단독으로 믿지 않는다). 리뷰에서 같은 말을 두 번 하면 한 계층 올린다. 계층을 올릴수록 오탐 비용이 커서 정당한 변경을 막는 검사는 결국 삭제되므로, 올리기 전에 깨진 사례에서 실패하고 정당한 변경에서 통과하는지 돌려 본다.

규모별 적용: 구조·코드 항목은 위 표의 구조적 답이 늘 적용된다(solo·프로토타입 포함). 의존 차단 CI, 프레임워크 금지, 반복되는 리뷰 코멘트의 lint 화는 오래 가는 코드베이스를 여러 에이전트가 고칠 때 더한다. 검증 항목(기능 지도, 기능별 앱 확인, runner, baseline)은 `b3os-verification` 의 "Apply by size" 에 있다. 속도가 우선인 solo·프로토타입은 구조 항목만 쓴다.

적용하지 않을 때: 동작하는 영역을 표에 맞추려고만 재구성하지 않는다(그 영역을 이미 바꾸는 중이거나 해당 종류 버그가 났을 때 적용). 한 번 본 패턴으로 CI 규칙을 만들지 않는다. "에이전트를 위한" 전면 재작성은 별도 비용 대비 효과 근거가 필요하다. 원 발표 노트와 항목별 판정: `references/agent-friendly-codebase-talk.md`.

## 6. 리팩토링 (냄새 → 대응)

- "하나 고쳤더니 무관한 게 깨짐"(ripple) = 강한 결합/숨은 의존(두 곳이 가변 상태를 공유하거나 한쪽이 다른 쪽 내부를 만짐). 의존을 명시하고 경계/인터페이스로 통하게 하며 공유 개념에 단일 소유자를 둔다.
- "변경 하나가 여러 파일을 고치게 함"(shotgun surgery) = 개념이 한곳에 없음. 흩어진 로직을 한 모듈로 모은다.
- 한 타입이 모든 걸 함(god object, 수천 줄) = 새 기능을 가까운 store 에 붙여 옴. 먼저 동작을 고정(영역별 테스트 + 변이)하고, 같은 외부 API 뒤에서 책임별로 나눈다: 가장 많이 호출되는 영역(보통 모두가 의존하는 상태)을 상태 머신으로 먼저, 이어 독립 영역을 병렬로, 영역당 PR 하나, 기존 테스트는 그대로, 조각마다 설계 문서 갱신.
- 중복 코드 → 함수/모듈 추출. 긴 함수/큰 클래스(SRP 위반) → 바뀌는 이유별로 분리. 타입 switch 반복 → 다형성/lookup map/strategy.
- 상태 × 입력 분기(`if (stateA && key===X) … else if …`, 같은 상태를 여러 파일에서 판정) → 판정 함수 하나 + 상태표 + 작은 실행 함수(`references/worked-examples.md`).
- 다른 모듈 데이터에 기대는 함수(feature envy) → 데이터가 있는 곳으로 옮긴다. 원시 값 파라미터가 곳곳에 → 작은 타입/객체를 계약으로.

동작을 보존하며 리팩토링:
1. 먼저 테스트로 현재 동작을 고정한다. 손으로 튜닝한 표시 값(간격·들여쓰기·정렬)도 동작이다: 마지막 릴리스의 픽셀/기하 baseline 으로 고정하고, 리팩토링을 맡길 때 "표시 값을 바꾸지 마라"를 말한다. 새 값을 기대하도록 고친 테스트는 아무것도 증명하지 못한다.
2. 구조 이동은 한 번에 하나, 매번 테스트.
3. 리팩토링과 기능/동작 변경을 한 커밋에 섞지 않는다(회귀 원인을 못 가린다).
4. 동작 불변을 확인(같은 테스트 + 실제 실행)하고 커밋한다.

## 7. 위험 등급과 리뷰 보고

다음 중 하나라도 해당하면 전체 게이트: 돈·권한·삭제·되돌릴 수 없는 상태 / 외부 효과(발송·게시·결제) / webhook·큐·cron·재시도 / 일관돼야 하는 다중 쓰기 / 한 자원 동시 접근 / 잘못된 데이터 소스가 조용히 틀린 출력을 만듦 / AI 가 썼고 실패 경로를 아무도 따져 보지 않음. 그 밖에는 짧은 게이트: 데이터 소스, 부수효과, 모듈 경계, 의미 있는 테스트 하나(보이는 출력이 있으면 동작 확인 하나).

보고 형식:
```text
AI code safety:
- data source / source-of-truth:
- structure (coupling/cohesion, side-effect isolation):
- concurrency / transaction / idempotency decision:
- refactor applied (smell → move), if any:
- tests + behavior evidence (real run, not just tsc/unit):
- reviewer (harness or member) + what they verified:
- unverified scope · rollback path:
```

## 8. 안티패턴

- "tsc/unit 통과했으니 동작한다", "빈 경우를 확인했다"(값이 있는 경우를 확인).
- 실제 변동 경계 없이 모든 것에 인터페이스를 만들기, "트랜잭션을 썼으니 안전하다".
- 리팩토링 + 기능 변경 한 커밋, 동작을 고정하지 않은 리팩토링, 버그 수정에 공유 입력 경로 "개선"을 끼우기.
- 커지는 switch 에 분기를 또 더하기(map/strategy 대신).
- 서버가 화면 문장을 만들어 보내기, 언어마다 화면을 복사하기.

예제(DIP, functional core, 공유 상태 제거, lookup map, 상태표, 개념 모으기)와 출처는 `references/worked-examples.md`. 출처 요약: SOLID, functional core / imperative shell, Fowler *Refactoring*, Feathers *Working Effectively with Legacy Code*, Nomad Coders(YouTube `ThYV4Kpf9Bk`), pstack(cursor/plugins `pstack`, MIT, Lauren Tan; 2026-10-17 도입, 우리 흐름에 맞게 다시 씀), Cursor 엔지니어 발표(YouTube `o_7vTaHOL28`).
