
AI가 코드를 쓰기 시작하자, 내가 소유해야 할 것이 달라졌다
2026.08.09
나는 Electron 기반의 마크다운 노트 앱을 만들고 있다. 에디터 부분만 4만 줄이 넘고, 전체 제품 코드는 16만 줄을 넘었다. 처음에는 혼자 개발했다. 최근 1년 정도는 대부분의 코드를 AI agent와 함께 작성하고 있다.
AI가 작성한 코드는 당연히 검수해야 한다. 나도 알고 있었다.
그런데 실제로 개발하다 보면 그 원칙은 생각보다 쉽게 무너진다. AI에게 기능을 맡기면 빠르게 결과가 나온다. 설명도 그럴듯하다. 모델의 성능은 계속 좋아지고 있다.
처음에는 꼼꼼히 코드를 읽다가도 어느 순간부터는 테스트가 통과하고 동작이 맞으면 받아들이는 경우가 늘어난다. 몇 번을 다시 꼼꼼하게 보겠다고 생각해도 오래 유지되지 않았다.
그동안 제품 코드는 16만 줄을 넘었다. 어느 순간부터는 그중 몇 퍼센트를 내가 직접 설명할 수 있는지 따지는 것 자체가 별 의미가 없어졌다.
모르는 코드 위에 또 모르는 코드가 쌓였다.
나는 이것을 일종의 인지 부채라고 생각하게 됐다.
이해하지 못하는 코드를 그대로 받아들이는 것은 불안했다. 그 불안을 나는 테스트로 달래기 시작했다.
불안을 테스트로 보상했다
기능을 추가하거나 버그를 수정할 때마다 강한 검증을 요구했다. 실제 앱을 실행하는 E2E 테스트가 계속 추가됐다. 단위 테스트로 검증할 수 있는 로직도 E2E로 올라갔다.
어떤 테스트가 이미 존재하는지, 무엇을 어느 계층에서 검증하고 있는지 내가 전체적으로 파악하지 못한 상태에서 테스트가 계속 쌓였다. 결국 E2E 테스트만 약 6만 줄, 118개 파일이 됐다.
나중에 측정해보니 상황은 숫자로도 나빴다.
- 전체 검증 시간의 86%를 E2E가 사용하고 있었다.
- E2E 실패를 분석해보니 실제 제품 동작이 잘못되어 실패한 경우는 약 4%였다. 나머지는 대부분 타이밍과 동기화 문제였다.
- 한 테스트 파일은 앱을 62번 실행하고 종료하면서 272초를 사용하고 있었다.
더 나쁜 발견도 있었다. 12가지 서식을 검증하는 E2E 테스트 하나에서, 3개 케이스의 핵심 단언들이 if 가드에 걸려 몇 달 동안 단 한 번도 실행되지 않은 채 초록불을 켜고 있었다. 케이스에 적힌 기대값은 실제 동작과 달랐지만, 가드가 그 사실까지 가리고 있었다.
이 테스트는 AI가 작성했다. 코드를 충분히 이해하지 못한다는 불안을 줄이기 위해 만든 안전망을, 다시 내가 충분히 이해하지 못하는 방식으로 만들고 있었던 것이다.
뭐 하나 고칠 때마다 E2E가 시간을 잡아먹었다. 이해하지 못하는 코드는 계속 늘어났다. 코드를 정리하려면 테스트가 안전망이 되어야 하는데, 그 테스트부터가 정리 대상이었다. 어디부터 손대야 할지 알기 어려웠다.
그리고 느려진 개발 속도보다 더 심각한 문제는, 내가 프로젝트에 대한 통제권을 잃어가고 있다는 감각이었다.
모든 코드를 이해하는 대신, 무엇을 소유할지 정하기
여기서 문제를 다른 방향으로 보기 시작했다.
내가 모든 코드를 이해하지 못하는 상황 자체를 없애려고 하지 않기로 했다.
AI가 더 많은 코드를 작성할수록 내가 모든 구현을 같은 깊이로 읽고 이해하는 것은 점점 어려워질 가능성이 높다. 그렇다면 모든 코드를 직접 이해하는 것만을 안전성의 전제로 둘 수는 없다.
그래서 내가 소유해야 하는 것을 두 종류로 나눠보기 시작했다.
코드 소유
구현을 직접 읽고 이해하며 변경할 수 있는 상태다. 작고, 역할이 명확하고, 의존성이 적은 코드라면 가능하다.
동작 소유
구현 전체를 이해하지 않아도 경계의 계약과 검증을 통해 기대하는 동작을 통제할 수 있는 상태다. 무엇을 입력받고, 무엇을 반환하며, 어떤 외부 시스템에 의존하고, 어떤 행동을 보장해야 하는지를 알고 있는 것이다.
16만 줄 전체를 같은 깊이로 코드 소유하는 것은 내게 현실적인 목표가 아니었다. 대신 코드 소유가 필요한 부분을 작게 만들고, 나머지는 동작을 소유할 수 있는 구조로 바꾸는 것이 더 현실적이었다.
이를 위해서는 경계가 필요했다. 외부와 연결되는 지점이 명확하고, 공개되는 표면이 작고, 내부의 의존성이 경계를 우회해서 새어나가지 않아야 했다.
그 조건이 충족되면 내부가 스파게티가 되더라도, 그 비용이 경계 밖으로 전파되지 않는다. 내부를 고칠 때의 비용까지 사라지는 것은 아니다. 다만 그 비용이 경계를 넘어 프로젝트 전체로 번지지는 않는다.
내부 구현을 매번 전부 따라가지 않아도 된다는 뜻이다. AI가 구현의 상당 부분을 작성하더라도, 경계를 벗어나지 않고 계약이 검증된다면 내가 통제할 수 있는 범위 안에 둘 수 있다.
내가 해결해야 할 것은 인지 부채 그 자체가 아니라 어디까지 이해해야 하는가를 결정하는 것이었다.
테스트를 어디에 둘지도 기준을 정했다
개발은 계속되고 기능과 버그 수정이 추가될 때마다 새로운 테스트도 생긴다. 그리고 그 테스트 역시 대부분 AI가 작성할 가능성이 높다.
그렇다면 “이번에는 어떤 테스트를 만들까?”에 대한 일관된 기준이 없다면 같은 문제가 반복될 수 있다. 이미 그 결과가 E2E 6만 줄이었다.
그래서 테스트를 배치하는 기준도 정했다.
E2E는 실제 브라우저나 OS가 있어야만 검증할 수 있는 행동에 사용한다. 그 외의 판단 로직은 가능한 한 순수 함수로 분리해 단위 테스트까지 내려간다.
판단은 질문 하나로 한다.
“이 테스트가 실패하려면 제품에서 무엇이 바뀌어야 하는가?”
순수 함수의 반환값이 바뀌어야 실패한다면 단위 테스트다. 렌더링된 DOM 구조나 DOM API 수준의 동작이 바뀌어야 실패한다면 jsdom 같은 DOM 환경이면 충분할 수 있다. 실제 브라우저의 편집 동작이나 OS의 입력 시스템이 개입해야만 실패한다면 그때 E2E를 사용한다.
하지만 이 전략을 세우고 바로 한 가지 문제를 발견했다.
단위 테스트를 작성하려면 코드가 ‘테스트할 수 있는 단위’로 나뉘어 있어야 한다. 외부 의존이 적고 경계가 명확해서, 앱 전체를 띄우지 않고 따로 떼어 실행할 수 있는 조각이어야 한다는 뜻이다. 그런 단위가 없는 코드는 전략을 아무리 잘 세워도 검증이 계속 더 높은 계층으로 밀려난다. 대부분은 결국 E2E다.
내 코드가 그랬다. DOM을 탐색하고 DB를 조회하고 판단까지 한 함수 안에서 해버리면 판단만 따로 떼어낼 수 없다. “이 테스트가 실패하려면 무엇이 바뀌어야 하는가?”라는 질문의 답이 계속 “앱 전체”로 나온다.
그래서 테스트를 바꾸기 전에 구조부터 조사했다.
문제는 숨은 의존성이었다
4만 줄이 넘는 에디터 폴더에서 외부 코드가 import해 사용하는 심볼을 전부 조사했다.
45개였다. 생각보다 많지 않았다.
문제는 공개 인터페이스의 크기가 아니라 그 인터페이스를 거치지 않는 의존성이었다.
미리 말해두면, 이 의존성들은 AI가 만든 것이 아니다. 이 앱의 기본 구조는 AI를 본격적으로 사용하기 전에 내가 혼자 설계했다. 당시 우선시한 것은 두 가지, 코드를 보고 쉽게 구조를 파악할 수 있어야 한다는 것과 짧고 작성하기 쉬워야 한다는 것이었다. 아래 세 가지는 모두 그 기준에서는 합리적이었던 결정이다.
1. 전역 prototype
에디터는 DOM 요소를 라인 단위로 다룬다. “이 요소가 라인인가?”, “어떤 타입의 라인인가?” 같은 질문을 코드 곳곳에서 해야 했기 때문에, 에디터는 부팅할 때 자주 쓰는 판별 함수들을 Element.prototype에 추가하고 있었다. 이렇게 하면 어디서든 import 없이 문장처럼 읽히기 때문이다.
// 에디터 부팅 코드 — 앱의 "모든" DOM 요소에 이 메서드들이 생긴다
Element.prototype.isLine = isLine
Element.prototype.getLineType = getLineType
// ... 이런 판별 함수가 16개
// 의도한 효과: 어디서든 import 없이, 문장처럼 읽힌다
if (line.isLine() && line.getLineType() === 'list') {
// ...
}
// 의도하지 않은 효과: 에디터와 무관한 서비스 파일에서, 에디터 관련 import 한 줄 없이
const enabled = !line.getLineType()?.startsWith('code')
서비스 파일 쪽에는 에디터 관련 import가 하나도 없다. 그런데도 에디터 내부 함수를 쓰고 있다. prototype에 설치된 메서드는 앱 어디서든 호출할 수 있기 때문이다.
주입된 메서드는 16개였고 내부에서 약 350번 사용되고 있었다. 그중 일부는 에디터 밖의 코드에서도 사용되고 있었고 테스트 코드까지 같은 방식으로 접근했다.
이 구조를 처음 설계했던 나는 “이 함수는 에디터 내부에서만 사용한다”는 맥락을 기억하고 있었다. AI에게는 그런 암묵적인 경계가 없다. 호출할 수 있는 함수라면 사용할 수 있는 함수다.
당시에는 한 줄 한 줄이 쉽게 읽히는 것을 중요하게 봤다. 지금 돌아보면 그 선택이 개별 코드의 가독성은 높였지만, 시스템 전체에서 어떤 코드가 무엇에 의존하는지는 오히려 덜 보이게 만들었다.
문제는 함수 자체가 아니라 의존성이 import에 나타나지 않는다는 것이었다.
2. 흩어진 DB 호출
Electron에서는 화면을 그리는 renderer가 DB를 직접 소유하지 않고 main 프로세스와 IPC(프로세스 간 통신)를 통해 통신한다. 에디터가 DB를 읽고 쓰는 모든 경로가 이 IPC 호출을 거친다는 뜻이다.
이 IPC 호출도 조사했다.
나는 이 호출을 별도의 계층이나 관문으로 모으지 않았다. 데이터가 필요한 바로 그 자리에서 호출하는 방식이었다. 혼자 개발하고 있고 코드 전체의 구조를 내가 알고 있을 때는 굳이 한 단계를 더 만드는 것이 오히려 과하다고 생각했다.
조사 결과, 고유한 IPC 메서드 48종을 19개 파일이 직접 호출하고 있었다.
그중에는 이런 함수도 있었다.
export const getNewLineOrder = async (line: Element, fileId: string) => {
const prev = line.previousElementSibling // ① DOM 탐색
const prevLine = await window.api.getLineById(/* ... */) // ② DB 조회
if (prevLine.type === 'table') { // ③ 조회 결과로 분기
const next = await window.api.getNextLineByLineOrder(/* ... */) // ④ 또 DB 조회
// ⑤ 정렬값 계산 — 이 함수의 진짜 내용물
// ⑥ 충돌 시 재귀적으로 위치 조정 — DOM과 DB를 다시 만진다
}
}
이름은 유틸리티 함수였지만 실제로는 DOM 탐색, DB 조회, 조건 판단, 정렬값 계산이 모두 들어 있었다. 한 유틸리티 파일에서 DB 호출이 15번 일어나기도 했다.
이 상태에서는 “에디터가 외부에서 무엇을 필요로 하는가?”를 인터페이스만 보고 알 수 없다. 19개 파일을 읽어야 한다.
그리고 이 함수의 진짜 내용물인 정렬값 계산(⑤) 하나를 검증하려고 해도, ①~④의 DOM과 DB를 전부 준비해야 했다.
3. 이벤트와 판단 로직의 결합
브라우저 이벤트 코드에서도 같은 문제가 있었다. 이벤트를 받고, DOM을 읽고, 어떤 행동을 할지 판단하고, 다시 DOM을 수정하는 것까지 하나의 흐름에서 처리하고 있었다. 혼자 개발할 때는 이것이 가장 짧고 자연스러운 형태였다.
하지만 판단 로직 자체는 단순해도 그것만 따로 호출할 방법이 없었다. 결국 실제 브라우저를 띄우는 E2E 테스트가 가장 쉬운 선택이 됐다.
캡슐화가 주는 중요한 효과 중 하나는 내부 구현을 모두 알지 않아도 코드를 사용할 수 있게 해주는 것이다. 그런데 혼자 개발하던 당시에는 그 효과가 절실하지 않았다. 코드의 중요한 맥락이 대부분 내 머릿속에 있었기 때문이다.
돌아보면, 세 가지 문제의 공통점은 하나였다.
함수가 무엇에 의존하는지 코드의 경계에서 알기 어려웠다.
그리고 이 구조가 앞에서 정한 테스트 전략을 그대로 막고 있었다.
- 판단과 DOM이 붙어 있으니 순수 함수에 단위 테스트를 붙일 수 없었다.
- 전역 의존성은 import에 나타나지 않으니 컴포넌트의 계약을 파악하기 어려웠다.
- DB 접근은 여러 파일에 흩어져 있어 테스트에서 대체할 경계도 명확하지 않았다.
무엇을 바꿔야 하는지가 그제야 명확해졌다.
구조를 바꾼 세 가지
목적은 새로운 아키텍처를 만드는 것이 아니었다.
의존성을 없애는 것이 아니라, 의존성이 어디에 있는지 보이게 만드는 것이었다.
1. 전역 의존성을 명시적인 import로 바꿨다
prototype에 올라가 있던 함수들은 실제로는 이미 독립 함수였다. 문제는 그 함수를 전역에서 호출할 수 있게 만든 한 겹의 편의 기능이었다.
// before: 전역 메서드 — 이 파일이 에디터에 의존한다는 사실이 어디에도 안 보인다
line.isLine()
// after: import 한 줄이 의존을 드러낸다
import { isLine } from '…/editor'
isLine(line)
큰 재설계가 필요한 일이 아니었다. 전역 주입을 제거하고 import를 통해서만 접근하게 했다.
그리고 lint 규칙을 추가했다. 이제 Element.prototype에 새로운 함수를 추가하면 AI가 작성했든 내가 작성했든 코드 단계에서 막힌다.
2. 외부 호출을 한곳으로 모았다
DB 호출 자체는 없앨 수 없다. Electron에서 renderer와 main 프로세스가 나뉘어 있는 이상 IPC는 반드시 필요하다. 대신 여러 파일에 흩어진 호출을 몇 개의 명확한 경계로 모을 수는 있다.
재미있는 점은 내 코드 안에 이미 이런 구조가 하나 있었다는 것이다. 저장 경로였다.
// 저장이 필요한 에디터 코드 여러 곳 — window.api를 모른다. 큐에 "값"만 넣는다
saveQueue.push({ fileId, lineId, text, html })
// save-queue.ts 내부 — 이 앱에서 window.api.save*를 아는 유일한 곳
async function flush(job: SaveJob) {
await window.api.saveLineUpdate(job)
await window.api.saveLineHtml(job.fileId, job.lineId, job.html)
}
저장을 호출하는 쪽은 큐만 알고, IPC를 아는 곳은 큐 구현 하나뿐이다. 저장에 대한 IPC 의존이 앱 전체에서 파일 하나로 줄어든 것이다. 저장 방식이 바뀌어도 이 파일만 고치면 되고, 테스트에서는 진짜 IPC 대신 가짜 큐를 꽂으면 된다.
다른 IPC 호출도 이런 형태의 경계로 모으기 시작했다. 그러면 “이 컴포넌트가 외부에서 무엇을 필요로 하는가?”를 몇 개의 파일에서 확인할 수 있다. 테스트할 때도 전역 API 전체를 패치하는 대신 해당 경계만 교체할 수 있다.
3. 외부 상태와 판단을 분리했다
가장 큰 변화는 브라우저 이벤트 코드였다. 예전에는 이런 형태였다.
function onBeforeInput(e: InputEvent) {
const line = (e.target as Element).closest('.line') // DOM 읽기
if (
e.inputType.includes('Backward') && // 판단
line?.getAttribute('type') === 'list'
) {
// 삭제 범위 계산 — 판단
// DOM 수정 — 실행
}
}
// 판단만 따로 실행할 방법이 없다 → 검증하려면 브라우저째로 띄워야 한다
이 코드에서 판단 로직을 테스트하려면 브라우저 상태가 필요하다. 그래서 구조를 세 단계로 나눴다.
관찰 → 판단 → 실행
// ① 관찰: 브라우저에서 판단에 필요한 "사실"만 값으로 읽는다
type Observation = {
inputType: string
lineType: string | null
caretAtStart: boolean
}
// ② 판단: 값을 받아 값을 반환하는 순수 함수 — 브라우저 없이 테스트된다
function decide(obs: Observation): DeletePlan | null
// ③ 실행: 판단이 반환한 계획을 DOM에 적용한다
function onBeforeInput(e: InputEvent) {
const obs = observe(e) // ①
const plan = decide(obs) // ②
if (plan) apply(plan) // ③
}
브라우저에서 필요한 사실을 먼저 읽는다. 판단 함수에는 DOM 요소 자체가 아니라 그 판단에 필요한 값만 전달한다. 판단 결과를 받은 뒤 실제 DOM 변경은 다시 바깥에서 수행한다. 이벤트 핸들러에는 위의 세 줄만 남는다.
여기서 몇 가지를 배웠다.
decide(line: HTMLElement)처럼 DOM 요소를 그대로 넘기는 것은 충분한 분리가 아니었다. Element를 가지고 있으면 ownerDocument 등을 통해 다시 살아 있는 페이지 전체에 접근할 수 있기 때문이다.
판단 함수가 필요로 하는 것은 Element가 아니라 Element에서 관찰한 사실이었다.
의존성 주입도 한 단계의 개선이었다. 예를 들어 decide(id, api)라고 만들면 적어도 API에 의존한다는 사실은 명시된다.
하지만 내가 원했던 것은 단순히 mock하기 쉬운 함수가 아니었다. 가능하다면 mock 자체가 필요 없는 판단 함수를 만들고 싶었다.
decide(id, api)에서는 판단 과정에서 API를 몇 번 호출하고 어떤 상태를 읽는지 결국 구현을 봐야 한다. 반면 필요한 데이터를 먼저 값으로 만들고 decide(observation)에 전달하면 판단 함수가 다뤄야 할 세계의 크기가 훨씬 작아진다. 그리고 전역 상태 접근을 lint와 구조 규칙으로 함께 제한하면 함수의 시그니처와 테스트가 계약에 가까워진다.
같은 판정을 기존 E2E에서 실행했을 때는 35.2초가 걸렸다. 값을 입력받아 값을 반환하는 함수로 분리한 뒤에는 0.8초였다.
알고 보니 오래된 패턴이었다
나중에 찾아보니 내가 만든 구조에는 이미 이름이 있었다.
Functional Core / Imperative Shell.
판단과 계산은 가능한 한 순수한 코어에 두고, DOM이나 DB, 네트워크처럼 외부 세계와 접촉하는 코드는 바깥의 얇은 껍질에 둔다. 외부 시스템을 몇 개의 경계로 모으는 방식 역시 Ports & Adapters 같은 오래된 설계 원리와 맞닿아 있었다.
새로운 아이디어를 발견한 것이 아니었다. 오히려 이미 잘 알려진 구조를 뒤늦게 다시 발견한 셈이었다.
그런데 흥미로운 점이 하나 있었다.
예전에도 이런 패턴을 알고 있었다. “테스트하기 쉬워진다”는 장점도 알고 있었다. 그런데 혼자 개발할 때의 나는 그 이유만으로 기존 코드를 굳이 쪼갤 필요성을 크게 느끼지 못했다. 내 머릿속에 코드의 맥락이 있었기 때문이다.
나를 다시 이 구조로 돌아오게 만든 것은 AI였다.
코드 전체를 직접 읽지 않고도 동작을 통제해야 하는 상황이 되자 순수한 코어는 단순히 “테스트하기 좋은 코드”가 아니었다. 내가 이해하고 검증할 수 있는 소유의 단위가 됐다.
외부 세계와 접촉하는 코드가 얇아질수록 내가 직접 확인해야 할 영역은 작아진다. 판단 로직이 값과 값 사이의 변환으로 표현될수록 테스트와 타입이 더 많은 역할을 대신한다.
오래된 설계 원리가 내게는 AI와 개발하면서 새로운 의미를 갖게 됐다.
그래도 E2E가 필요한 영역은 남는다
모든 코드를 순수 함수로 바꿀 수 있는 것은 아니다. 커서의 실제 화면 좌표를 계산하는 코드, IME 한글 조합, 브라우저의 기본 편집 동작처럼 브라우저와 네이티브 상태 자체가 기능의 일부인 영역이 있다.
이런 코드는 억지로 순수 함수처럼 만들려고 해도 결국 다른 이름의 DOM 코드가 될 뿐이었다.
대신 이런 부분은 외부에 노출되는 경계를 좁히고 그 경계의 행동을 테스트로 고정한다. 내부 구현 전체를 항상 읽는 대신 기대하는 동작을 소유한다.
그리고 바로 이런 부분이 내가 말했던 “E2E가 꼭 필요한 경우”다.
목표는 E2E를 없애는 것이 아니다. 실제 브라우저와 OS가 있어야만 증명할 수 있는 행동만 E2E에 남기는 것이다.
이 구조는 코드의 양을 줄여주지 않았다
이렇게 코드를 나눈다고 전체 코드가 줄어들지는 않는다. 오히려 늘어날 때가 많다.
실제 리팩터링한 사례 하나를 보면 기존 57줄짜리 함수가
- 관찰 코드 40줄
- 판단 코드 51줄
- 테스트 103줄
이 됐다. 전체 줄 수만 보면 3배 이상 늘었다.
예전의 나는 이 숫자를 보고 비슷한 리팩터링을 멈춘 적도 있다. 지금은 판단 기준이 달라졌다.
AI와 개발할 때 내가 중요하게 보는 것은 단순한 코드의 양이 아니다.
내가 이해해야 할 범위가 얼마나 명확하고 작은가다.
더 적은 코드를 막연하게 소유하는 것보다, 코드가 조금 늘더라도 내가 이해해야 할 경계와 계약이 작아지는 편이 낫다고 생각하게 됐다.
그 대가로 하나의 함수가 책임져야 하는 복잡도는 작아졌다. 검증 속도도 크게 빨라졌다.
그리고 무엇보다 어떤 경계를 지켜야 하는지를 사람의 기억에만 맡기지 않을 수 있게 됐다. 컴파일러와 lint, 테스트가 그 역할의 일부를 대신한다.
AI에게 코드를 맡기면서 처음에는 “내가 더 꼼꼼하게 검수해야 한다”고 생각했다. 몇 달 동안 그렇게 해보면서 그것만으로는 오래 유지하기 어렵다는 걸 배웠다.
그래서 지금은 질문이 바뀌었다.
모든 코드를 어떻게 계속 이해할 것인가가 아니라, 내가 반드시 이해하고 통제해야 하는 범위를 어떻게 작게 만들 것인가.
사람에게 계속 지키라고 요구하는 규칙은 언젠가 무너질 수 있다. 가능하다면 구조가 그 규칙을 어기기 어렵게 만들어야 한다.
AI에게 더 많은 코드를 맡기기 위해서, 나는 오히려 내가 소유해야 할 경계를 다시 그리고 있다.