
몇 년간 잘 쓰던 Element.prototype을 AI와 개발하며 걷어낸 이유
2026.08.10
몇 년 동안, 내가 만드는 앱의 모든 DOM Element에는 브라우저가 만들지 않은 메서드 16개가 있었다.
아무 Element 뒤에 점만 찍으면 에디터의 판별 함수들이 자동완성으로 떠올랐고, import 없이 문장처럼 코드를 쓸 수 있었다. 혼자 개발하던 시절의 나에게 이 메서드들은 생산성이었다. 이것을 가능하게 한 장치가 Element.prototype 확장이다.1주 1
페이지의 모든 Element가 공유하는 공용 객체. hasAttribute나 closest 같은 브라우저 내장 메서드도 여기서 온다.
최근 1년간 대부분의 코드를 AI 에이전트와 함께 작성하면서 사정이 달라졌다. 얼마 전 나는 16개 중 15개를 걷어냈다. 호출부 315곳, 34개 파일을 고치는 작업이었다.
이 글은 그 과정의 기록이다. 어떻게 썼고, 무엇이 좋았으며, AI와 함께 개발하고 테스트가 쌓이는 지금은 왜 문제가 됐고, 어떻게 바꿨는지.
코드는 갑자기 나빠지지 않았다. 바뀐 것은 그 코드를 둘러싼 전제였다.
에디터는 문서를 어떻게 그리는가
내가 만드는 것은 Electron 기반의 마크다운 노트 앱이다. 데스크톱 앱이지만 화면과 에디터는 웹페이지처럼 HTML과 JavaScript로 그려진다.
브라우저의 화면은 DOM이라는 트리 구조로 표현되고, HTML 태그 하나가 Element 하나가 된다. 이 에디터는 문서의 한 줄을 Element 하나로 그린다. 대략 이런 구조다.
<!-- 구조와 attribute 이름은 글을 위해 단순화했다 -->
<div class="editorArea">
<div data-line>그냥 텍스트 한 줄</div>
<div data-line data-line-type="table_cell">표의 셀인 줄</div>
<div data-line>
<span data-md-type="bold_open">**</span>
<span data-md-type="bold_in">굵은 글씨</span>
<span data-md-type="bold_close">**</span>
</div>
</div>
에디터는 data-line, data-line-type, data-md-type 같은 attribute에 라인과 마크다운 요소의 상태를 기록한다. 그러다 보니 코드 곳곳에서 같은 판별이 반복된다. 이 Element가 라인인가? 어떤 타입의 라인인가? 이 span은 볼드 마커인가?
Element.prototype을 어떻게 썼나
이 판별을 함수로 만들되, 원한 조건이 두 가지 있었다. 어떤 파일에서든 import 없이 바로 호출할 수 있을 것. 그리고 VSCode에서 아무 Element 변수 뒤에 점을 찍으면 이 함수들이 자동완성 목록에 뜰 것. Element.prototype 확장이 그 답이었다.
판별 함수들은 이렇게 생겼다.
// md-constants.ts — 걷어내기 전 실제 코드(발췌)
// el.isLine()처럼 호출되면 this가 그 Element를 가리킨다
export function isLine(this: Element) {
return this.hasAttribute(Attribute.LINE)
}
export function getLineType(this: Element) {
return this.getAttribute(Attribute.LINE_TYPE)
}
export function hasMdType(this: Element, mdType: string) {
// data-md-type에 콤마로 기록된 값에서 mdType을 찾는다
}
// ... 이런 판별 함수가 16개
페이지의 모든 Element는 Element.prototype이라는 공용 객체를 공유한다. el.hasAttribute(...), el.closest(...) 같은 브라우저 내장 메서드도 여기에서 온다. 이 공용 객체에 함수를 추가하면, 페이지에 존재하는 Element와 앞으로 생길 Element 모두가 즉시 그 메서드를 갖게 된다. 브라우저가 만들어 둔 공용 부품함에 내 부품을 끼워 넣는 셈이다.
에디터는 부팅 시점에 정확히 그것을 했다.
// 전역 타입 선언과 설치 코드 — 걷어내기 전 실제 코드(발췌)
import * as helper from './md-constants'
declare global {
interface Element {
isLine(): boolean
getLineType(): string | null
hasMdType(mdType: string): boolean
setLineType(lineType: string): void
showMdElement(): void
hideMdElement(): void
// ... 총 16개 선언
}
}
Element.prototype.isLine = helper.isLine
Element.prototype.getLineType = helper.getLineType
Element.prototype.hasMdType = helper.hasMdType
// ... 총 16줄의 대입
실제 구현에서는 이 파일을 부팅 경로에서 불러 대입 코드가 실행되게 했다. declare global은 TypeScript에게 내장 Element 타입에 이 메서드들이 있는 것으로 간주하라고 알려준다. 이것이 타입 검사와 자동완성을 열었다.
이 파일이 한 번 실행되고 나면 앱 어디서든 이렇게 쓸 수 있다.
// 앱의 어떤 파일에서든, import 한 줄 없이
if (line.isLine() && line.getLineType() === 'table_cell') { ... }
if (anchor.hasMdType('bold_open')) { ... }
당시에는 무엇이 좋았나
이 구조는 혼자 개발하던 나에게 분명한 장점이 있었다.
첫째, 자동완성이 도메인 사전이 됐다. 당시 가장 컸던 장점이다. VSCode에서 아무 Element 변수 뒤에 점을 찍으면 브라우저 내장 메서드와 나란히 isLine, getLineType, hasMdType 같은 사용자 정의 함수가 나타났다. 함수 이름이나 파일 위치를 외울 필요가 없었다. 도구가 도메인 어휘를 대신 기억해 주는 셈이었다.
둘째, import가 사라졌다. 이 판별 함수들은 에디터의 기초 어휘라 거의 모든 파일에서 사용됐다. 걷어내기 직전에 세어 보니 호출부가 34개 파일에 315곳이었다. prototype 방식에서는 새 파일을 만들 때 아무 준비 없이 바로 line.isLine()을 쓸 수 있었다.
셋째, 문장처럼 읽혔다. isLine(line)보다 line.isLine()이 주어-동사 순서라 자연스러웠다. 브라우저 내장 API와 내 도메인 어휘가 같은 형태로 이어진다는 점도 매끄러웠다.
line.hasAttribute('data-line') // 브라우저가 준 메서드
line.isLine() // 내가 심은 메서드
그리고 이 장점들에는 공통의 전제가 하나 깔려 있었다.
코드의 맥락이 내 머릿속에 있다는 것.
“이 메서드들은 에디터 내부용이다”라는 경계는 코드 어디에도 적혀 있지 않았다. 쓰는 사람이 나 하나일 때는 문제가 아니었다. 경계는 내 기억이 지키고 있었다.
지금은 무엇이 문제인가
AI와 함께 개발하기 시작하고, 검증을 테스트에 맡기는 비중이 커지면서 같은 구조가 다르게 보이기 시작했다.
먼저 장점 쪽의 전제가 바뀌었다. 자동완성은 사람이 직접 타이핑할 때 작동하는 생산성 장치다. 코드의 대부분을 AI가 작성하는 지금, 에디터에서 점을 찍고 목록을 읽는 사람은 더 이상 내가 아니다. 가장 컸던 장점은 내 작업 흐름에서 거의 사라졌지만, 비용은 그대로 남았다.
1. AI에게 경계를 넘는 도구가 주어졌다
prototype 확장은 에디터 안의 함수를 바깥 코드가 쓰는 길을 하나 더 만든 것이기도 하다.
위쪽 길은 import라는 흔적을 남긴다. 사람도 분석 도구도 그것만 보고 “이 파일은 에디터에 의존한다”는 사실을 알 수 있다. 아래쪽 길에는 그런 기록이 없다. 부팅 때 앱의 모든 Element에 메서드를 심어 뒀으니, 어느 파일에서든 아무 선언 없이 에디터 내부 로직을 호출할 수 있다.
혼자 개발할 때는 “에디터 내부용”이라는 경계를 내 기억이 지켰다. 하지만 AI는 현재 작업 맥락에 적혀 있지 않은 내 머릿속의 규칙을 지속적으로 알 수 없다. 게다가 전역 타입 선언은 이 함수들을 모든 Element의 정식 메서드처럼 보여 준다. 타입과 주변 코드가 허용하는 패턴은 AI가 자연스럽게 반복할 수 있는 패턴이다. 실제로 에디터 밖의 파일 7개가 이 길로 에디터 내부 함수를 쓰고 있었다.
대가는 경계를 세우려 할 때 청구된다. 에디터를 캡슐화하려면 바깥이 무엇을 쓰는지부터 알아야 하는데, import graph만 봐서는 찾을 수 없다. 코드베이스 전체에서 메서드 호출을 별도로 찾아야 한다.
2. 단위 테스트는 확장 메서드 설치를 거치지 않았다
이 메서드들은 부팅 코드가 실행돼야 존재한다. 앱에서는 항상 실행되지만, 앱을 띄우지 않고 함수만 검증하는 단위 테스트는 그 과정을 거치지 않았다. 테스트 환경의 Element에는 isLine 같은 확장 메서드가 없었다.
그 균열은 제품 코드에 흔적을 남겼다.
// 세 가지 변형, 같은 불안 — “메서드가 있으면 부른다”
element.isLine && element.isLine()
typeof line.isLine === 'function' && line.isLine()
lastLine?.isLine?.() // 값이나 메서드가 없으면 조용히 건너뛴다
이런 방어 가드가 18곳 있었다. 앱에서는 확장 메서드가 항상 설치되므로 모두 항상 참이었다. 이 코드베이스에서 가드가 거짓이 되는 곳은 사실상 단위 테스트뿐이었다. 결국 가드 뒤의 호출은 단위 테스트에서 한 번도 실행되지 않았다. 테스트는 초록불인데 그 경로는 검증된 적이 없었다.
더 나간 사례도 있었다. 한 테스트는 전역 Element를 가짜 클래스로 바꿔치기하고, 가짜 라인에 판별 결과를 직접 달아 뒀다.
// services/__tests__/memo-draft-materializer.test.ts — 걷어내기 전
const fakeLine = { isLine: () => true, ... }
제품의 isLine은 data-line attribute를 확인한다. 하지만 이 테스트에서는 그 로직이 한 줄도 실행되지 않았다. 테스트가 판별의 정의를 스스로 지어낸 것이다. 실제 판별 로직이 바뀌어도 이 테스트는 알 수 없다.
테스트 준비 코드에서 prototype 설치 파일을 한 번 import하면 되지 않느냐고 할 수 있다. 물론 가능하다. 하지만 그러면 단위 테스트까지 전역 부팅 순서에 매이고, 의존이 기록되지 않는 문제도 그대로 남는다. 내가 원한 것은 테스트를 통과시키는 임시 조치가 아니라 의존을 보이게 만드는 것이었다.
걷어내기
다행히 이 함수들은 처음부터 독립 함수였고, prototype 대입은 그 위에 얹은 편의 한 겹이었다. 그래서 걷어내기는 재설계보다 치환에 가까웠다.
// before
export function isLine(this: Element) {
return this.hasAttribute(Attribute.LINE)
}
line.isLine()
// after — this가 첫 파라미터가 됐을 뿐이다
export function isLine(el: Element) {
return el.hasAttribute(Attribute.LINE)
}
import { isLine } from '$md/md-constants'
isLine(line)
문제는 규모였다. 315곳을 손으로 고치면 반드시 틀린다. 소스 코드를 구문 트리로 읽어 호출을 일괄 치환하는 1회용 스크립트2주 2 코드를 글자가 아니라 문법 구조로 읽어 고치는 방식. 문자열 치환과 달리 주석이나 문자열 안의 같은 이름을 건드리지 않는다.를 짜서 300여 곳을 바꾸고, 널 검사가 필요한 18곳만 손으로 고쳤다.
재발 방지 장치도 걸었다. 이제 Element.prototype에 무언가를 대입하면 lint가 막는다. 전역 타입 선언도 지웠기 때문에 el.isLine()이라고 쓰면 컴파일 에러가 난다.
이것으로 경계 위반 자체가 불가능해진 것은 아니다. 바깥 코드가 import { isLine } from '$md/md-constants'라고 적고 쓰는 것은 여전히 가능하다. 달라진 것은 그러려면 import라는 흔적을 반드시 남겨야 한다는 점이다. 의존이 사라진 것이 아니라 숨을 수 없게 됐다. 보여야 규칙을 세울 수 있고, 어긴 곳을 셀 수도 있다.
저울이 기울었다
Element.prototype 확장은 실수가 아니었다. 자동완성, import 없는 호출, 문장처럼 읽히는 코드에는 실제 가치가 있었고, 나는 몇 년 동안 그 이득을 누렸다.
다만 그 가치의 대부분은 사람이 코드를 직접 쓰고 읽을 때 체감된다. 반면 대가는 코드의 맥락을 사람 머리 밖으로 꺼내야 할 때 드러난다. 의존이 기록되지 않고, 앱에는 있는 메서드가 테스트에는 없으며, 무엇이 경계를 넘었는지 도구로 셀 수 없다.
혼자 개발할 때는 코드를 쓰는 사람도 맥락을 쥔 사람도 나였다. 지금은 타이핑의 상당 부분을 AI가 맡으면서 편의의 가치는 줄었고, 경계와 맥락을 코드에 적어 둘 필요는 커졌다. 코드는 그대로인데 저울이 기울었다.
Element.prototype만의 이야기가 아니다
전역 주입은 의존을 숨기는 설계의 한 사례일 뿐이다. 암묵적 전역 상태, 몽키패칭3주 3 런타임에 다른 코드의 객체를 고쳐 쓰는 것., 어디서든 꺼내 쓰는 숨은 인스턴스 저장소도 구조는 같다. 의존은 존재하지만 코드의 경계에는 나타나지 않고, 그 대가로 더 짧고 매끄러운 코드를 얻는다.
숨긴 의존을 기억하는 사람이 있고, 작은 팀이 코드 전체의 맥락을 공유한다면 이 거래는 실제로 이득일 수 있다. 나도 그랬다.
하지만 AI가 코드의 상당 부분을 쓰기 시작하면 조건이 달라진다. AI는 타입과 주변 코드가 허용하는 패턴을 자연스럽게 반복하고, 경계는 코드에 적힌 만큼만 보인다. 사람이 기억으로 지키던 규칙은 매 작업의 맥락에 다시 넣지 않는 한 전달되지 않으며, 리뷰도 드러나지 않은 의존을 잡기 어렵다. 의존을 숨기는 설계는 AI와 함께 개발할수록 더 비싸질 수 있다.
지금 내가 설계를 고를 때 보는 것은 코드가 얼마나 짧아지는지가 아니다. 그 편의를 얻는 대신, 무엇이 코드 밖으로 밀려나는가다.
315개의 호출을 바꿨지만 사용자에게 보이는 기능은 하나도 늘지 않았다. 대신 내 머릿속에만 있던 의존이 import로 드러났다. 테스트는 앱의 부팅을 흉내 내지 않고 실제 판별 함수를 검증할 수 있게 됐고, 에디터 밖에서 이 함수를 쓰는 코드도 이제 찾아내고 제한할 수 있다.
Element.prototype을 걷어낸 이유는 그것이 나쁜 설계였기 때문이 아니다. 나 혼자 맥락을 기억하던 시절에는 실제로 싸고 유용한 설계였다. 다만 AI가 코드를 쓰고 테스트가 검증을 맡는 지금, 사람이 기억해야만 유지되는 편의는 더 이상 싸지 않았다.
코드가 갑자기 나빠진 것이 아니라, 그 설계를 지탱하던 전제가 바뀌었다.