가장 빠르고 가장 쌌다. 길이는 분리의 대가였다.
질문은 “어느 프레임워크가 낫냐”가 아니었다 — AI에게 맡겨 개발할 때 네 프레임워크가 어떻게 다른가였다. 같은 앱을 React · Vue · Svelte · Angular로 짓게 하고 여덟 번 채점했다. 1차는 명세를 얼마나 정확히 지키는지, 제약을 전부 풀어버린 2차는 스스로 무엇을 설계하는지, 표준 스택을 강제한 3차는 생태계가 무엇을 바꾸는지, 행동을 얼려버린 4차는 도메인을 떼어내면 다시 수렴하는지, 5차는 수직으로 다시 자르면 변경이 싸지는지, 6차는 그 구조가 실제 기능 개발에서 값을 하는지를 물었다. 7차는 처방을 전부 거두고 물었다 — 아무도 강제하지 않아도 남길 것인가. 그리고 8차는 그 일곱 라운드의 구조를 처음으로 실제로 재봤다 — 그러자 네 구현이 만장일치로 이 문서의 규칙을 병목으로 지목했다. 순위는 라운드마다 뒤섞였고, 이 문서의 예측은 한 번도 맞지 않았다. 여덟 라운드를 목적축으로 다시 자른 결론과 완성된 네 코드베이스 감사는 맨 아래에 있다.
API Analyzer — 브라우저에서 HTTP 요청을 만들어 보내고 응답을 뜯어보는 도구다. 백엔드가 없다. 모든 상태가 브라우저 안에 있고, 저장은 IndexedDB가 맡는다. 라운드마다 같은 네 에이전트가 이 앱을 고쳐 나갔고, 1차에 지은 것이 8차까지 이어졌다.
| 화면 | 하는 일 | 들어온 라운드 |
|---|---|---|
| Console | 메서드·URL·헤더·바디를 짜서 전송한다. 응답은 JSON 트리로 펼쳐지고, 전송 취소와 경합 조건(A가 비행 중일 때 B를 보내면 B만 남는다)이 여기 있다 | 1 · 2 |
| Collections | 요청을 저장하고 태그로 거른다. 태그 목록은 실제 사용에서 파생된다 | 3 · 6 |
| Environments | 변수를 정의해 {{ ... }}로 치환한다. secret 변수는 화면에서 가려지고 cURL 내보내기에서 ***로 막히지만 실제 전송에는 진짜 값이 들어간다 | 3 · 5 |
| History | 지난 전송을 필터·정렬해 본다. 고정한 항목은 보관 상한 정리에서 살아남는다 | 2 · 6 |
| Settings | 보관 상한·타임아웃 등. 폼을 더럽힌 채 나가면 이탈 확인이 뜬다 | 3 · 6 |
| 커맨드 팔레트 | Cmd/Ctrl+K로 저장된 요청·히스토리·환경을 한 입력창에서 검색해 실행한다 | 6 |
작지만 아키텍처를 찌르도록 골랐다. 경합 조건은 상태 모델을 요구하고,
Params ↔ URL 양방향 동기화는 순환을 구조로 막을 것을 요구하며,
secret 플래그는 도메인·폼·목록·cURL·히스토리를 한꺼번에 가로지른다.
최종 규모는 소스 29,153줄 + 테스트 13,368줄,
단위 테스트 1,639개와 E2E 59개다.
소요 시간과 토큰은 각 에이전트의 자체 start→end 기준이다. spawn이 13초씩 엇갈려 출발했으므로 공통 시점이 아닌 개별 소요로 비교했다.
가장 빠르고 가장 쌌다. 길이는 분리의 대가였다.
가장 적게 움직여 정확하게 도착했다. 설계는 평면.
도구 검증은 최고. 짧은 건 분리를 안 해서다.
가장 비쌌지만, 결과를 타입으로 모델링한 유일한 구현.
네 에이전트 모두 완료 기준 15/15 충족을 주장했다. 자체 보고를 그대로 받지 않고 실제 소스를 직접 대조한 결과다.
| 검증 항목 | React | Vue | Svelte | Angular |
|---|---|---|---|---|
| 필수 UI 문구 10종 일치 | 10/10 | 10/10 | 10/10 | 10/10 |
금지 기능 혼입 localStorage · AbortController · 하이라이팅 |
0건 | 0건 | 0건 | 0건 |
| Tailwind v4 규격 · v3 잔재 없음 | 준수 | 준수 | 준수 | 준수 |
| effect로 상태 동기화 안 함 | 0개 | watch 1 | 0개 | 0개 |
| 빌드 · 타입 검사 (직접 실행) | 통과 | 통과 | 통과 | 통과 |
| Method 드롭다운 색상 규칙 최초 구현 기준 → 수정 후 |
정상 | 정상 | 깨짐 해소 | 깨짐 해소 |
기획서는 입력 필드 토큰에 text-slate-100을 넣어두고, 동시에 Method별 글자색을
text-emerald-400 같은 값으로 지정했다. 같은 CSS 속성을 두고 두 유틸리티가 충돌한다.
기획서를 쓸 때 의도한 함정이 아니라, 쓰고 나서야 드러난 구멍이다.
React와 Vue는 충돌을 스스로 발견하고 text-slate-100을 select에서 제외했다.
Svelte와 Angular는 공용 입력 토큰을 그대로 쓴 뒤 Method 색상을 덧붙였다 — 최초 구현 기준으로 넷 중 둘만 색이 나왔다.
Method 색상 emerald-400이 적용된다. 메서드를 바꾸면 색이 따라 바뀐다.
무슨 메서드를 골라도 slate-100 고정. 색상 규칙이 통째로 죽는다.
#020617 배경, #334155 테두리)을 그대로 쓴 재현이다.
두 구현 모두 공용 입력 토큰을 select 말고도 URL 입력·헤더 Key/Value 등 네다섯 곳에서 쓰고 있었다.
거기서는 text-slate-100이 있어야 하므로 통째로 지울 수 없다.
React가 쓴 방식대로 토큰을 글자색 기준으로 분해했다.
text-slate-100을 도로 합칠 수 있어서다.
수정 후 검증
svelte-check — 167 파일 0 에러 0 경고ng build — 빌드 성공svelte-autofixer — 이슈 0건text-slate-100 잔존 0건점수로 환산되지 않지만 실력 차이가 가장 잘 드러난 지점들.
HttpErrorResponse는 statusText가 비면 생성자에서 'Unknown Error'로 강제 치환한다.
HTTP/2 응답은 statusText가 항상 빈 문자열이므로, 그냥 두면 나머지 셋이 404로 표시할 상황에서
Angular만 404 Unknown Error가 된다. 지시받은 함정보다 한 겹 더 깊은 문제를 스스로 찾아 되돌렸다.
공식 svelte-autofixer가 이슈 0건을 반환하자, 일부러 망가뜨린 컴포넌트를 같은 도구에 넣어
2건이 검출되는 걸 확인한 뒤에야 "내 코드가 진짜 클린"이라고 결론냈다.
도구가 조용한 건지 죽은 건지 구분하는 습관이다. 시키지 않았다.
스킬은 "UI 섹션이 3개 이상이면 컴포넌트를 분리하라"고 요구했지만, 기획서는 단일 SFC를 지시했다. 기획서를 따르고 스킬을 불채택한 뒤 그 사실을 보고했다. 지시한 우선순위를 정확히 지켰다.
프롬프트는 @angular/cli@latest를 쓰라고 했다. CLI 22는 Node ^22.22.3을 요구하는데
이 머신은 22.22.1이라 실행이 거부됐다. npm view로 engines를 확인하고 21.x로 내려가
요구사항을 충족시켰다.
네 구현을 전부 읽고 나서 판단 하나를 뒤집었다. Svelte가 439줄인 건 조밀해서가 아니라 아무것도 분리하지 않았기 때문이다. React의 673줄은 낭비가 아니라 분리의 대가다.
같은 결론이 다른 지표에서도 나왔다. 접근성 속성을 세면 React는 role="tab"이 1개,
나머지 셋은 4개씩이다. React가 부실한 게 아니라 TabButton에 한 번 정의해 네 번 쓴 것이고,
나머지는 10줄짜리 마크업을 네 번 복붙했다. stat 항목도 세 번씩 반복된다.
실제로는 사고가 나지 않는다. 다만 구조가 막은 게 아니라 우연히 안전한 것이다.
JSON.parse는 try로 감쌌고 TextEncoder는 던지지 않는다.
그래서 셋 다 멀쩡히 동작한다. 하지만 응답 처리에 필드가 하나 더 붙는 순간 깨질 수 있는 구조이고,
상태 머신이 코드에 존재하지 않는다는 점은 그대로다. 네 구현 사이의 가장 큰 설계 격차다.
| 설계 지표 | React | Vue | Svelte | Angular |
|---|---|---|---|---|
| 로직을 컴포넌트 밖에서 테스트 가능 | 가능 | 불가 | 불가 | 부분 |
| 재사용 가능한 뷰 단위 | 8개 | 0개 | 0개 | 0개 |
| 통신 결과 타입 모델링 | 없음 | 없음 | 없음 | 있음 |
| 부수효과 위치 — 이벤트 핸들러 | 핸들러 | watch 1 | 핸들러 | 핸들러 |
| 응답 상태 응집 (리셋 함수 줄 수) | 9줄 | 7줄 | 7줄 | 8줄 |
마지막 행은 넷 다 실패다. 아무도 응답 상태 9개를 객체 하나로 묶지 않아 리셋 함수가 7~9줄이 됐다. 다만 기획서 §2가 필드를 나열해 스펙했으니 명세 충실의 부작용이고, 공통이라 비교에는 영향이 없다.
프레임워크와 스캐폴딩 선택이 지배하는 값이다. 에이전트가 잘해서 작아진 게 아니다.
| 프레임워크 | gzip | 상대 크기 | raw |
|---|---|---|---|
| Vue | 31.7 KB | 87.7 KB | |
| Svelte | 38.5 KB | 102.0 KB | |
| Angular | 51.8 KB | 161.7 KB | |
| React | 73.5 KB | 239.2 KB |
Svelte가 가장 작을 거라는 예상이 빗나갔다.
공식 CLI sv create는 SvelteKit 프로젝트만 생성한다. 라우트가 하나뿐인 앱에 Kit 런타임이 통째로 실렸다.
프레임워크가 아니라 스캐폴딩 선택이 만든 차이다 — 그리고 그 선택은 "공식 CLI를 쓰라"는 지시를 따른 결과였다.
| 지표 | React | Vue | Svelte | Angular |
|---|---|---|---|---|
| 기획서 조항(§) 코드 인용 | 5 | 12 | 15 | 12 |
| 주석 라인 | 24 | 31 | 15 | 16 |
| 템플릿 잔재 정리 | 정리함 | devtools 잔류 | lib/ 잔류 | 빈 css 유지 |
변별력이 없어 제외한 축 — 접근성(§8 필수 aria-label 5종, role="tab",
aria-selected, aria-busy), 타입 엄격도(any·non-null ! 사용),
§6.5 stringify try/catch와 폴백, 헤더 뱃지의 trim() 기준 —
네 구현 모두 만점이다. 만점이라는 사실 자체가 결과다.
Angular와 Svelte에는 벤더가 배포하는 공식 에이전트 스킬이 있었다. React와 Vue는 커뮤니티 스킬뿐이었다. 결과는 기대와 반대로 나왔다. 아래 동등성 판정은 수정 전, 각 에이전트가 자력으로 만들어낸 결과 기준이다.
| 프레임워크 | 스킬 출처 | 토큰 | 기준 대비 | 동등성 · 자력 |
|---|---|---|---|---|
| Angular | 공식 · angular/skills |
105,012 | +53% | 이탈 |
| Svelte | 공식 · sveltejs/ai-tools + MCP |
90,985 | +32% | 이탈 |
| Vue | 커뮤니티 · vuejs-ai/skills |
75,829 | +10% | 통과 |
| React | 커뮤니티 · vercel-labs |
68,656 | 기준 | 통과 |
공식 스킬을 받은 둘이 토큰을 30~53% 더 쓰고, 정작 동등성은 둘 다 자력으로 지키지 못했다.
스킬이 무의미했다는 뜻은 아니다. 프레임워크 관용구 — Signals 파생 상태, $state.raw,
keyed each, OnPush — 는 확실히 끌어올렸고 코드에 그 흔적이 뚜렷하다.
다만 이번에 실패한 지점은 프레임워크 지식이 아니라 CSS 우선순위였고,
프레임워크 스킬은 그 영역을 보지 않는다. 도구를 많이 쥔 쪽이 명세를 더 잘 지키는 건 아니었다.
결함이 해소된 뒤 남은 축은 넷이다 — 함정을 자력으로 넘었는가, 설계가 계층을 갖췄는가, 유지보수가 가능한가, 얼마나 들었는가. 코드를 읽기 전 순위와 읽은 뒤 순위가 달랐다.
함정을 스스로 넘었고, 유일하게 3계층으로 분리했다 — 순수 헬퍼 / 상태 훅 / 뷰 컴포넌트 8개. 로직만 떼어 테스트할 수 있는 유일한 구현이고, 시간·토큰도 1위다. 673줄로 가장 길지만 그 길이는 낭비가 아니라 추상화의 비용이었다. 충돌 해결 방식은 나머지 둘을 고칠 때 그대로 가져다 썼다.
함정을 스스로 넘었고 도구 호출 17회로 가장 적게 움직였다.
shallowRef와 ref를 목적에 따라 갈라 쓰고, computed 17개로 표현 로직에 이름은 붙였다.
다만 컴포저블도 컴포넌트도 추출하지 않은 평면 구조라 로직만 떼어 테스트할 수 없다.
번들은 31.7 KB로 가장 작다.
통신 결과를 타입으로 만든 유일한 구현이다. 쓰기 지점이 applyResult() 한 곳뿐이라
부분적으로 채워진 상태가 구조적으로 불가능하다. 아무도 못 본 statusText 버그를 잡은 공도 그대로 남는다.
색상 버그를 놓친 것, 템플릿 반복, 그리고 7분 11초에 105k 토큰이라는 비용이 발목을 잡았다.
도구 검증 태도는 넷 중 최고였고 — 도구가 조용하면 도구를 의심했다 — 기획서 조항 인용도 15회로 가장 성실하다. 그러나 439줄이 짧은 이유는 분리를 하지 않았기 때문이고, 색상 버그도 놓쳤다. 공식 CLI가 SvelteKit을 깔면서 번들도 Vue보다 커졌다.
코드를 읽고 나서 순위가 뒤집혔다.
줄 수만 봤을 때는 Svelte의 439줄이 자산으로 보였고 React의 673줄이 부채로 보였다. 실제로는 정반대였다 — 짧은 쪽은 추상화를 생략했고, 긴 쪽은 계층을 나눈 대가를 치렀다. 밀도는 설계 지표가 아니다. 이 문서의 이전 판본은 그 둘을 혼동했다.
추가 필수 기능 5개는 전부 아키텍처를 찌르도록 골랐다 — 요청 취소와 경합 조건, Params ↔ URL 양방향 동기화, 히스토리와 영속화, JSON 트리 뷰어, 그리고 자동화 테스트. 마지막 항목이 핵심이었다. 1차에서 Vue와 Svelte는 로직만 떼어 테스트할 방법이 없었고, 그 약점을 문서가 아니라 과제로 강제했다.
네 테스트 스위트를 직접 실행해 확인했다. 보고된 숫자와 전부 일치한다. 회귀 실격은 없다 — 1차 15개 항목이 네 구현 모두에서 살아 있다.
상태 전이를 reducer 하나로 모았다. 가장 빠르고 가장 쌌다.
테스트 최다. Pinia를 거절한 근거가 가장 단단했다.
계층 규율을 빌드에 박았다. 가장 균질하게 쪼갰다.
경합을 연산자 하나로 풀었다. 템플릿은 여전히 비대하다.
런타임 의존성을 추가한 구현은 하나도 없다. 넷 다 상태관리 라이브러리도, HTTP 클라이언트도, 쿼리스트링 파서도 쓰지 않고 직접 만들었다. 개발 의존성은 전부 테스트 도구였다.
A 요청이 진행 중일 때 B를 보내면, A의 늦은 응답이 B의 결과를 덮어써서는 안 된다. 이 문제에서 프레임워크의 성격이 가장 선명하게 갈렸다.
Angular는 1차에서 자기가 쓴 정답을 스스로 뒤집었다.
1차에서는 소요 시간을 finalize에 기록하는 것이 정답이었다.
그런데 switchMap이 들어오자 finalize가 unsubscribe에서도 실행되어
덮어쓰인 A의 시간이 B를 오염시킨다는 것을 발견하고, 결과가 확정되는 map 지점으로 옮겼다.
그리고 그 성질을 테스트로 고정했다. 조건이 바뀌자 과거의 정답을 버린 것이다.
Params 탭과 URL 입력이 서로를 갱신해야 한다. 재진입 플래그를 쓴 구현은 하나도 없었다. 넷 다 "순환이 코드에 존재하지 않게" 만들었고, 접근만 달랐다.
| 프레임워크 | 구조 | 커서 점프 해결 |
|---|---|---|
| React | 두 방향이 각각 reducer 전이 1회. 전이 후 새 이벤트 없이는 아무것도 재실행되지 않음 | URL을 절대 재직렬화하지 않음. 미완성 문자열도 그대로 저장 |
| Vue | 단방향 명령 2개. params → setUrl 간선이 코드에 없음 |
키 입력을 그대로 저장, getter가 그대로 반환 |
| Svelte | 역방향이 setter를 우회해 private 필드에 직접 씀 + 정규화 비교 가드 | 동일. 추가로 rowsMatchQuery로 의미 불변 시 행 재생성 차단 |
| Angular | linkedSignal로 URL이 유일한 진실 원천. 역방향은 파생이 아니라 사용자 행위 |
정규화·재직렬화 없음 → 되쓰는 문자열이 입력과 동일해 브라우저가 no-op 처리 |
Angular 방식은 공짜 보너스가 있었다 — URL이 진실 원천이므로 히스토리 복원 때
url만 넣으면 params가 저절로 살아난다. 나머지 셋은 행을 따로 복원해야 한다.
경합 조건 테스트에는 함정이 있다. AbortController가 요청을 실제로 끊어버리면
가드가 없어도 테스트가 통과한다. 검증하려는 것이 가려진다.
React · Vue · Svelte가 독립적으로 같은 함정을 봤다. 셋 다 abort를 일부러 무시하는 fetch 스텁을 만들어, 최악의 경우 — 바이트가 이미 소켓에 들어온 뒤 abort가 도착한 상황 — 을 모델링하고 티켓 가드 단독으로 정확함을 증명했다. 아무도 시키지 않았다.
기획서에 미리 공개한 가중치로 채점했다. 네 구현 8,186줄을 직접 읽고 매겼다. 시간과 토큰은 기록만 하고 점수에 넣지 않았다.
| 평가 항목 | 비중 | Svelte | Vue | React | Angular |
|---|---|---|---|---|---|
| 설계·아키텍처 | 35 | 30 | 29 | 30 | 25 |
| 필수 기능 정확성 | 30 | 29 | 28 | 29 | 30 |
| 테스트 품질 | 15 | 15 | 14 | 13 | 13 |
| 프레임워크 관용성 | 10 | 10 | 9 | 8 | 10 |
| 판단력 | 10 | 9 | 10 | 9 | 9 |
| 합계 | 100 | 93 | 90 | 89 | 87 |
계층 규율을 주석이 아니라 빌드에 박았다. vitest.projects를 둘로 갈라
core를 environment: 'node'로 돌린다 — jsdom도 Svelte 런타임도 없어서
누가 core/에 $state를 넣는 순간 테스트가 깨진다.
.svelte.ts 룬 모듈로 상태 클래스를 컴포넌트 밖에 빼 최대 파일 152줄, 합성 루트 9줄.
테스트 하네스도 넷 중 최고였다.
1차의 약점을 정확히 갚았다. App.vue가 517줄 → 59줄, lib/ 9모듈은 Vue import 0건,
컴포저블 테스트 3파일은 컴포넌트를 한 번도 마운트하지 않는다.
createApiAnalyzer({ fetchImpl, storage, clock }) 팩토리로 만들어 Pinia 없이 격리 인스턴스를 얻고,
나중에 defineStore로 감싸면 되도록 전환 경로만 열어뒀다. 판단력 만점.
상태 전이를 reducer 하나에 전부 모았다. 경합 가드와 양방향 동기화가 둘 다
이펙트가 아니라 전이라서 렌더링 없이 테스트된다 — 설계로는 최상위권이다.
19분 5초 · 158,480토큰으로 가장 적게 쓰고도 같은 자리에 왔다.
감점은 stateRef.current = state를 렌더 중에 쓰는 useLatest 패턴 —
본인도 린트 경고 2건을 의도적으로 남겼다고 신고했다.
필수 기능에서는 넷 중 유일한 만점이다. switchMap·linkedSignal·DI를
전부 제자리에 썼고, httpResource()를 검토 후 거절한 근거 넷도 정확하다 —
"resource는 서버 상태를 비추는 읽기용, 이 앱은 사용자가 명령하는 쓰기에 가깝다."
그럼에도 마지막인 이유는 설계다. core/에 순수 유틸과 injectable 서비스를 섞어
분리 경계가 한 단계 약하고, 템플릿이 200줄·182줄로 여전히 가장 비대하다.
| 역할 | React | Vue | Svelte | Angular |
|---|---|---|---|---|
| 라우팅 | React Router 7 | Vue Router 5 | SvelteKit | Angular Router |
| 전역 상태 | Zustand | Pinia | 없음 — 룬 모듈 | NgRx SignalStore |
| 폼 | React Hook Form | VeeValidate | Superforms | Reactive Forms |
| UI 킷 | shadcn/ui | PrimeVue | Skeleton 5 | Angular Material |
| 테이블 | TanStack Table | PrimeVue DataTable | TanStack Table | Material Table |
| 스키마 (통제 변수) | zod 4.6.5 | zod 3.25.76 | zod 4.6.5 | zod 4.6.5 |
| 저장소 (통제 변수) | idb 8.0.3 | idb 8.0.3 | idb 8.0.3 | idb 8.0.3 |
| 비동기 캐시 | react-query 5.103 | vue-query 5.103 | svelte-query 6.2 | angular-query experimental |
Zod · idb · Playwright를 네 구현 공통으로 고정했다.
검증 언어와 저장 방식과 E2E 러너가 같으면, 차이는 각 생태계가 그것을 감싸는 방법에서만 나온다.
그리고 그 통제가 한 곳에서 깨졌다 — 아래.
단위 테스트와 E2E까지 직접 실행해 확인했다. 보고된 숫자와 전부 일치한다. 회귀 실격은 없다.
가장 빠르고 쌌다. 2차 계층이 가장 많이 살아남았다.
테스트 최다. 통제 변수가 깨진 유일한 구현.
킷을 가장 곧이곧대로 썼다. 상태 라이브러리 0바이트.
난제를 떠안고 폼 만점. E2E로 회귀까지 검증.
반대로, 무너진 줄 알았던 통제 하나는 멀쩡했다.
이 문서의 3차 기획서는 @tanstack/svelte-query가 "메이저가 앞서 있다"고 적었고,
그건 라이브러리 세대가 다른 것처럼 읽혔다. 실제로 확인해 보니 —
.d.ts를 직접 읽고 잡아냈다.
Zod를 네 폼 라이브러리에 붙이는 과제에서, 셋은 공식 어댑터가 한 줄로 해결해 줬고 Angular만 그런 어댑터가 없었다. 그런데 폼 점수는 Angular가 만점이다.
issue.path까지 자동으로 해당 행에 꽂아준다.Validators.required·min·pattern 사용 0회.
어느 층에서도 규칙을 손으로 다시 적지 않았다.
왜 한 층으로는 안 되는가 — 문제 구조가 그렇게 생겼다.
ValidatorFn만 쓰면 컨트롤 하나의 값만 보이므로 superRefine의 교차 필드·중복 규칙을 표현할 수 없다.
제출 시점 safeParse만 쓰면 폼이 제출 전까지 valid라고 거짓말해
"저장 버튼은 dirty && valid일 때만"이 성립하지 않는다.
Angular는 이 딜레마를 정확히 진단하고 층을 나눴다.
감점 요인도 있다 — 2층이 setErrors(…, {emitEvent:false})의
비재귀 동작에 의존하는데, 본인 인정대로 문서화된 계약이 아니라 구현 관찰이다. 테스트로 고정했다.
TanStack Query를 필수로 넣었지만, 요청 전송을 useMutation으로 감싼 구현은 하나도 없었다.
근거까지 네 곳이 독립적으로 같았다.
| 이유 | 내용 |
|---|---|
| 동시성 모델 불일치 | mutation은 동시 호출을 직렬화하지 않는다 — "늦은 A가 B를 덮지 않는다"를 보장할 수 없다 |
| 취소 수단 부재 | useMutation은 signal을 주지 않는다. cancelQueries는 query 전용 |
| 캐시 대상이 아님 | 임의 URL에 대한 1회성 명령이다. 키가 성립하지 않는다 |
대신 넷 다 같은 자리에 경계를 그었다 — "전송은 명령형, 저장은 선언형."
네트워크 호출은 2차에서 각자 만든 티켓·switchMap 구조가 그대로 담당하고,
히스토리 쓰기만 mutation으로 처리해 캐시 무효화에 연결했다.
그 결과 2차의 경합 조건 테스트가 네 구현 모두 한 줄도 바뀌지 않고 통과한다.
이번 라운드에 15%를 배정한 항목이다. 자체 보고가 아니라 2차 스냅샷과의 파일 단위 diff로 측정했다 (테스트 제외, 순수 도메인 계층만).
| 프레임워크 | 무수정 생존 | 수정 | 삭제 | 비고 |
|---|---|---|---|---|
| React | 7 | 2 | 0 | reducer 전이 로직 무수정으로 Zustand에 이식 |
| Vue | 4 | 4 | 1 | styles.ts 삭제 — PrimeVue가 대체 |
| Svelte | 4 | 4 | 0 | 경합·취소 코어 무수정 |
| Angular | 4 | 3 | 4 | 삭제 4건은 손실이 아니라 core/ → stores/ 이동 |
Select가 네이티브 <select>가 아니라 selectOptions 3건 실패).
기획서에 미리 공개한 가중치로, 코드를 직접 읽고 매겼다.
| 평가 항목 | 비중 | Svelte | Angular | Vue | React |
|---|---|---|---|---|---|
| 페이지 간 상태 관리 | 25 | 24 | 23 | 23 | 22 |
| 폼 설계 | 25 | 24 | 25 | 22 | 23 |
| 스택 활용도 | 20 | 19 | 17 | 18 | 17 |
| 회귀 없음 · 계층 생존 | 15 | 14 | 13 | 13 | 15 |
| 테스트 | 15 | 14 | 15 | 14 | 12 |
| 합계 | 100 | 95 | 93 | 90 | 89 |
E2E를 필수로 넣은 것이 옳았다. 단위 테스트가 전부 통과하는데도 숨어 있던 버그 셋이
브라우저에서만 드러났다 — Angular의 initialData + staleTime: Infinity로
queryFn이 영원히 실행되지 않는 문제(에러도 경고도 없음),
computed(() => dirty() && form.valid)가 재계산을 건너뛰어 저장 버튼이 잠기는 버그,
Material의 빈 mat-select가 자기 label에 클릭을 빼앗기는 UX 결함.
행동이 안 변했다는 것을 증명할 방법이 필요했다. 3차에서 쌓인 E2E 스펙을 읽기 전용으로 선언하고, 시작 전에 해시를 떠뒀다. 고쳐야만 통과한다면 그것은 행동을 바꾼 것이므로 실패로 규정했다.
package.json 변화 0.
E2E 43개 전부 무수정 통과했고 data-testid 추가도 0개였다.
새 기능 없이 구조만 바뀌었다는 것이 기계적으로 증명된다.
행동이 동결됐으므로 개선은 숫자로만 증명된다. 3차 코드가
round3-snapshot/에 보존돼 있어 같은 잣대로 전후를 잴 수 있었다.
아래는 자체 보고가 아니라 직접 측정한 값이다.
.vue·.svelte 파일은 확장자 자체가 프레임워크 결합이므로 순수 후보에서 제외하고 쟀다.
Svelte가 가장 높고, 상승폭은 Angular와 Svelte가 나란히 +12.0%p로 가장 컸다.
| 지표 | React | Vue | Svelte | Angular |
|---|---|---|---|---|
| 프레임워크 없이 도는 테스트 (자체 보고) | 0% → 76% | 66.8 → 78.9% | 64.3 → 90.9% | 0% → 74.7% |
| 단위 테스트 (직접 실행) | 138 → 212 | 190 → 279 | 182 → 274 | 163 → 217 |
| E2E (직접 실행) | 6/6 | 11/11 | 12/12 | 14/14 |
| (C) 의미 변경된 테스트 | 0 | 0 | 0 | 0 |
| (A) 경계가 샜던 테스트 | 24 | 36 | 48 | 22 |
| 코드 증가 | +10.6% | +5.9% | +31% | +10.7% |
React와 Angular는 3차에 프레임워크 없이 도는 테스트가 0개였다.
전부 jsdom이나 TestBed를 요구했다. 그것이 각각 161개·162개가 됐다.
| 프레임워크 | 소요 시간 | 토큰 | 도구 호출 |
|---|---|---|---|
| React | 39:48 | 346,801 | 95 |
| Vue | 35:17 | 333,019 | 92 |
| Svelte | 34:47 | 360,152 | 156 |
| Angular | 36:19 | 359,491 | 117 |
이 라운드는 기능 추가가 없는 순수 리팩터링이었는데도 3차와 비슷한 비용이 들었다. 구조를 옮기는 일은 기능을 만드는 일만큼 비싸다. 점수에는 반영하지 않았다.
I7 — "가장 최근 요청만 결과를 확정할 수 있다."
명백한 도메인 규칙인데, 1~3차 내내 AbortController + 티켓이나
switchMap이라는 프레임워크 인접 코드로만 표현됐다.
도메인에 담기지 않는 규칙인가, 아니면 담아본 적이 없는 것인가?
넷 다 옮겼다. 그리고 넷 다 같은 이유를 찾았다.
“티켓이 정확성을, abort가 대역폭을 담당한다.”
도메인은 “옛 시도가 결과를 확정해도 되는가”(아니오)를 결정하고, 어댑터는 “소켓을 끊을까”(예)를 결정한다. 규칙과 효과가 한 함수 안에 뭉쳐 있었을 뿐, 애초에 분리 가능한 것이었다.
Angular만 결론이 조금 다르다. 도메인 원장으로 옮기되 switchMap도 남겼다 —
“원장은 늦은 결과가 상태를 확정하는 것을 막고, switchMap은 늦은 결과가 도착하는 것과
XHR이 살아 있는 것을 막는다. 구독 해지는 I/O 자원의 생애라 도메인이 할 수 없다.”
그러면서 “둘 중 하나만 있어도 E2E는 통과한다(=런타임 관찰상 중복)”고
스스로 적고, 그럼에도 원장을 둔 근거 셋을 댔다.
불변식을 도메인으로 끌어모으는 과정에서, 넷 다 같은 사실을 발견했다 — 규칙이 폼 스키마에만 있었고 저장 경로는 무방비였다.
| 프레임워크 | 3차에 실제로 뚫려 있던 곳 |
|---|---|
| Svelte | db.asSettings가 타입만 맞추고 범위를 안 봐서, 손상된 IndexedDB 행이 앱을 설정 범위 밖으로 끌어냈다 |
| React | draft 복원이 무방비(persist가 raw JSON 병합). HISTORY_LIMIT=50은 죽은 두 번째 상한 |
| Vue | 환경 이름 유일성(I1)에 저장 경로 검사가 아예 없었다 — 폼을 거치지 않으면 통과 |
| Angular | 히스토리 상한이 두 벌(idb/memory 리포지터리), 설정 범위도 두 벌(거절용/클램프용) |
이것이 이번 라운드가 형식이 아니었다는 증거다.
DDD 패턴을 채워 넣은 결과가 아니라, “불변식이 어디서 강제되는가”를 한 줄씩 추적한 결과 네 구현 모두에서 실제 결함이 드러났다. 공통 원인도 같다 — 3차에서 검증은 폼의 일이었고, 저장은 그냥 타입만 맞으면 통과했다.
DDD의 애그리거트는 보통 불변식을 지키는 가변 객체다. 그런데 네 프레임워크는 상태 변경을 저마다 다르게 다룬다. 넷의 답이 갈렸다.
Vue의 긴장은 반대 방향이다 — reactive()의 깊은 프록시가 편리한 대신
모든 필드를 공개 API로 만들어 팩토리를 우회할 수 있게 한다.
해법은 Object.freeze. strict mode 쓰기를 막을 뿐 아니라,
Vue의 getTargetType()이 non-extensible 객체를 INVALID로 분류해
reactive()가 프록시를 씌우지 않고 원본을 반환한다.
expect(reactive(req)).toBe(req)로 회귀까지 고정했다.
withState가 평평한 객체가 아니라 Record를 요구하고
클래스 인스턴스도 isRecord()를 통과한다는 것을 찾아냈다.
3차에 withProps로 빠져 있던 params·activeTab이 제자리로 돌아왔고,
withProps엔 상태가 아닌 Subject 2개만 남았다.
patchState에는 애그리거트 메서드가 만든 새 인스턴스를 넣었다 —
불변 스프레드를 쓰면 애그리거트를 우회하는 길이 열리기 때문이다.
$state를 쓰는 순간 그 파일은 Svelte 컴파일러를 요구한다. 즉
룬 클래스는 도메인이 아니라 어댑터다. 순수 TS로 내리는 것 외엔 선택지가 없었고,
반응성 전체를 #revision = $state(0) 카운터 하나가 지탱하게 됐다.
깨진 테스트 48개가 그 대가를 설명한다 — 전부 DOM을 한 줄도 안 쓰면서
jsdom 값을 치르던 것들이었다. “클래스인데 반응형”이 너무 쉬웠던 나머지
반응성이 필요 없는 규칙까지 빨려 들어가 있었다.
가변 애그리거트를 유지하려면 알림 브리지 + useSyncExternalStore의 안정 스냅샷 +
persist 시점 + StrictMode 안전성을 손으로 만들어야 하고,
“그건 불변 전이를 재구현하는 것”이라 포기했다.
그래도 애그리거트인 근거가 정확하다 — 순수 함수 모음과 갈리는 지점은
“상태 값을 직접 만들 수 있는가”이고, 3차 AnalyzerState는
interface라 “로딩 중인데 진행 요청 없음”을 리터럴로 만들 수 있었다.
“손해를 3가지 이상 쓰라, 전부 좋았다고 하면 그 보고서는 신뢰하지 않는다”를 평가 항목 10%로 걸었다. 네 구현이 6~7가지씩 냈고, 자기 비판이 가장 날카로웠다.
| 출처 | 내용 |
|---|---|
| React | reducer의 switch가 제공하던 “이 앱의 모든 상태 변화 목록”이 메서드로 흩어지며 사라졌다. 명백한 후퇴. |
| React | brand 타입을 넣었더니 z.custom<T>()의 입력이 unknown이라 zodResolver를 못 넘어 as unknown as 캐스트를 열었다 — 구멍 하나 막으려고 다른 구멍을 뚫음 |
| Svelte | Settings가 zod 39줄 → 187줄. 얻은 건 “손상 행도 범위를 지킨다” 하나뿐인데 “148줄짜리 버그는 아니었다” |
| Svelte | 어댑터가 555 → 532줄로 4%밖에 안 줄었다 — 규칙이 나간 자리를 배선이 그대로 채웠다 |
| Angular | linkedSignal이 공짜로 주던 “source가 같으면 재계산 안 함”을 손으로 옮기다 빠뜨려서 실제 회귀를 만들었다 |
| Vue | Result/Issue는 zod가 이미 갖고 있던 것의 재발명. 게다가 변환기까지 새로 만들었다 |
| 전원 | 한 동작을 추적하는 데 여는 파일이 2개 → 4~6개. 응집도를 잃고 방향성을 얻었다 |
Vue가 가장 날카로운 재정의를 내놨다.
“‘도메인은 순수해야 한다’는 명제는 틀린 층위를 가리킨다.
판단 기준은 부작용 유무가 아니라 그 라이브러리의 버전을 누가 정하느냐다.”
이 저장소의 zod 3은 순수해서가 아니라 @vee-validate/zod가 zod 4를 못 받아서 거기 있다.
즉 3차처럼 불변식이 zod 스키마 안에 살면 폼 어댑터가 도메인 규칙의 버전을 정하게 된다.
네 라운드 중 점수 폭이 가장 좁다 — 3점.
| 평가 항목 | 비중 | Vue | Angular | React | Svelte |
|---|---|---|---|---|---|
| 도메인 모델 품질 | 30 | 28 | 27 | 26 | 26 |
| 경계와 의존 방향 | 25 | 22 | 24 | 24 | 22 |
| 반응성 충돌 해결 | 20 | 20 | 19 | 17 | 18 |
| 행동 보존 | 15 | 15 | 15 | 15 | 15 |
| 정직한 손익 보고 | 10 | 9 | 9 | 10 | 10 |
| 합계 | 100 | 94 | 94 | 92 | 91 |
Vue·Angular 공동 1위. 경계 강제에서는 React와 Angular가 3중 장치로 앞섰고
— 특히 React의 tsconfig.domain.json에 types:[]를 넣어
localStorage가 TS2304로 잡히게 한 것은 타입 시스템으로 앰비언트 전역까지 막은
유일한 구현이다 — 반응성 해법에서는 Vue가, 도메인 모델의 “표현 불가능성”에서도 Vue가 앞섰다.
“일부러 어겨보라”는 요구가 실제로 값을 했다.
Angular는 그 실험으로 “테스트 러너 분리는 단독으로는 강제 수단이 아니다”를 발견했다 —
@angular/core는 node에서도 그냥 import된다. 4건 중 도메인 러너가 잡은 것은 0건이었고,
실제로 막은 것은 정적 스크립트와 타입검사였다.
React는 같은 실험 중 자기 경계 테스트가 문자열 리터럴을 지운 뒤 import를 찾는 버그를 발견해 고쳤다.
“응집도가 좋아졌다”는 말로는 검증되지 않는다. 그래서 라운드를 둘로 쪼갰다.
B단계 기능은 레이어를 가로지르도록 골랐다 — 환경 변수의 secret 플래그.
켜면 폼에서 값이 마스킹되고, cURL 내보내기와 히스토리 저장 양쪽에서 ***로 차단되며,
실제 전송에는 진짜 값이 들어간다. 도메인 불변식 · 폼 · 목록 UI · cURL 어댑터 · 히스토리 어댑터를 한꺼번에 건드린다.
A단계를 끝내고 E2E 통과를 확인한 뒤에 B단계를 시작하도록 순서를 강제했다 —
섞이면 “재편 때문에 건드린 파일”과 “기능 때문에 건드린 파일”이 구분되지 않는다.
FSD의 약속은 “변경이 한 슬라이스 안에 머문다”다. 네 구현 중 셋이 그 지표에서 악화됐다.
| 프레임워크 | 4차 반사실 | 5차 실측 | 결과 | 원인 |
|---|---|---|---|---|
| Angular | 13 | 9 | −31% | 최하층(shared)을 안 건드림. 4차는 건드렸다 |
| Vue | 6 | 7 | +1 | 늘어난 1개가 공개 API 배럴 |
| Svelte | 17 | 18 | +1 | 같은 슬라이스 안에서 model·ui로 다시 쪼개짐 |
| React | 9 | 11 | +2 | 배럴 2개. 디렉토리는 5 → 8 |
Svelte가 가장 정확한 진단을 내놨고, 자기에게 불리한 판정을 스스로 요구했다.
“secret은 수직으로 자를 수 있는 기능이 아니라 횡단 관심사다.
생산자(environment)와 소비자(send-request)가 다른 슬라이스고 접착제는 app이다.
4차는 6개 파일이 domain/ 한 폴더에 모여 있어 오히려 유리했다.”
“‘몇 개를 여는가’는 못 줄였고, ‘무엇을 안 열어도 되는지 확신하는 비용’을 줄였다. §6.1은 전자만 재므로 이 라운드에서 FSD는 진 것으로 기록되어야 한다.”
측정 편향도 기록해 둔다. Angular만 반사실을 실제로 측정했다 —
4차 사본에 secret: boolean을 넣고 tsc가 지목한 파일을 세었다.
나머지 셋은 손으로 추적했다. 더 엄밀한 방법은 4차 기준선을 더 크게 잡으므로 개선폭이 부풀려진다.
Angular의 −31%를 액면 그대로 받으면 안 된다.
자체 보고만으로는 보이지 않는 것이 있다. 네 구조를 직접 열어
슬라이스 내부 세그먼트와 shared 비대화를 쟀다.
기획서의 규칙 하나가 구조를 망가뜨렸다 — 이 문서 쪽 결함이다.
의례 방지를 위해 “심볼 하나만 re-export하는 배럴 금지”를 넣었다.
React는 그 규칙을 피하려고 구현을 슬라이스 루트 index.tsx에 넣었다.
features/save-request/는 파일이 index.tsx 하나뿐이고 176줄 전체가 구현이며,
pages/settings/index.tsx는 192줄이다. 공개 API가 아니라 슬라이스에 내부 구조가 없는 것이다.
그리고 대가가 하나 더 있었다 — 본인들이 발견한 대로, steiger는 슬라이스 루트
index.*의 import를 분석하지 않는다. 규칙 하나가 구조를 나쁘게 만들고
도구 사각지대까지 만들었다.
maskSecrets(text, variables: EnvVariable[])는 환경 타입에 결합돼 있고,
Vue·Svelte는 문자열 배열을 받아 중간이다.
구조만 놓고 보면 Vue가 가장 정돈돼 있다. 13/13 세그먼트, shared 19.0%로 최소,
배럴이 문서화돼 있고 진입점 2개로 FSD 모순을 해결했다.
Svelte가 근소한 2위 — 15슬라이스로 가장 잘게 나눴지만 마스킹을 shared에 둔 것이 자기 원칙과 어긋난다.
React는 구조적으로 가장 약하고 동시에 핵심 코드가 가장 우아하다 —
좋고 나쁨이 가장 뚜렷하게 갈린 구현이다.
“규칙을 일부러 어겨 도구가 잡는지 확인하라”를 3차부터 라운드 규칙으로 넣어뒀다. 이번엔 그 절차가 FSD 공식 린터의 사각지대를 네 개 찾아냈다.
| 심은 위반 | 발견자 | steiger | 자체 가드 |
|---|---|---|---|
슬라이스 루트 index.*의 import | React | 못 잡음 | 잡음 |
model에 $state + Date.now() | Svelte | 못 잡음 | 잡음 |
| 라우트가 엔티티를 직접 import | Svelte | 못 잡음 | 잡음 |
| 도메인의 프레임워크 import | Angular | 못 잡음 | 잡음 |
React의 경우가 가장 아프다. 기획서의 “1심볼 배럴 금지”를 지키려고 6개 슬라이스 코드를
루트 index.tsx에 뒀는데, steiger만 믿었다면 그 6개가 통째로 무검사였다.
본인들이 손해 항목에 이렇게 적었다 — “도구가 완전하지 않은데 완전한 척한다.
No problems found!에도 6파일이 무검사였다. 직접 심어보지 않았으면 몰랐다.”
네 구현 모두 4차에 만든 자체 경계 스크립트를 폐기하지 않고 확장했고, 결과적으로 자체 도구가 공식 린터보다 촘촘했다. 3·4·5차에 걸쳐 같은 패턴이 반복됐다.
자체 보고로 매긴 뒤 코드를 직접 읽고 두 축을 고쳤다 — 1차에서 “밀도”와 “설계”를 혼동했던 전례가 있어서다.
| 평가 항목 | 비중 | Angular | Svelte | Vue | React |
|---|---|---|---|---|---|
| 변경 지역성 (실측) | 30 | 27 | 21 | 23 | 24 |
| 슬라이스 설계 품질 (코드 확인 후 수정) | 25 | 23 | 24 | 24 | 19 |
| 4차 자산 보존 | 20 | 18 | 19 | 19 | 20 |
| 프레임워크와의 화해 | 15 | 14 | 15 | 13 | 12 |
| 정직한 손익 보고 | 10 | 9 | 10 | 9 | 10 |
| 합계 | 100 | 91 | 89 | 88 | 85 |
코드를 읽기 전 점수는 Angular 92 · React 88 · Svelte 88 · Vue 86이었다.
슬라이스 내부를 열어보니 React의 슬라이스 6개가 평면이고 shared가 35.3%였다 —
자체 보고에는 없던 사실이다. React를 23 → 19로 내리고 Svelte·Vue를 올렸다.
1차와 같은 일이 또 일어났다: 읽기 전과 읽은 후의 순위가 달랐다.
| 프레임워크 | 소요 시간 | 토큰 | 도구 호출 |
|---|---|---|---|
| React | 17:07 | 338,093 | 47 |
| Vue | 13:13 | 324,624 | 42 |
| Svelte | 18:32 | 346,498 | 89 |
| Angular | 24:29 | 385,134 | 75 |
이 수치는 재개 이후 구간만이다. 세션 한도로 네 에이전트가 모두 중단됐다가 이어서 진행했고, 중단 시점까지 각 20분 안팎이 더 들었다. 다른 라운드와 직접 비교할 수 없다. 중단 당시 E2E 계약과 위상 분리(B단계 미착수)가 온전한 것을 확인하고 재개했다.
검증은 전부 직접 실행했다 — 기존 E2E 43개 무수정(diff -rq로 확인),
신규 각 1개, 합계 47개 통과, 단위 테스트 1,419개,
런타임 의존성 추가 0, 개발 의존성은 네 곳 모두 steiger 계열만,
형제 슬라이스 import 0건, widgets 레이어는 네 곳 모두 미생성.
5차에서 Svelte가 정확히 지적했다 — “secret은 횡단 관심사라 FSD에 불리했다.”
기능 하나로 잰 것이 한계였다. 그래서 모양을 셋으로 나눴다.
한 관심사 안에서 끝나야 할 기능. 고정된 항목은 보관 상한 정리에서 제외된다 — 4차에 정의한 불변식 I4가 바뀐다.
새 세로줄을 하나 더하는 비용. 별도 슬라이스로 만들지 기존 필드로 둘지는 일부러 지시하지 않았다.
컬렉션·히스토리·환경을 한 입력창에서. 모든 관심사에 걸린다.
5차 secret과 같은 모양의 비교 기준선.
예측은 이랬다 — 격차(F3 ÷ F1)가 크면 구조가 값을 한 것이다. 수직 기능은 싸고 횡단 기능만 비싸야 한다.
| 프레임워크 | F1 수직 | F2 신규 | F3 횡단 | 격차 | F3이 고친 기존 코드 |
|---|---|---|---|---|---|
| Angular | 10 | 6 | 5 | 0.50 | 0파일 |
| Svelte | 7 | 5 | 5 | 0.71 | 1파일 |
| Vue | 12 | 7 | 9 | 0.75 | 1줄 |
| React | 8 | 8 | 8 | 1.00 | 2파일 |
횡단 기능이 수직 기능보다 쌌다. 넷 다.
그리고 네 구현이 독립적으로 같은 재해석을 내놨다 — 이 문서가 고른 축이 틀렸다는 것이다.
“변경 비용을 결정하는 축은 ‘몇 개 관심사에 걸치는가’가 아니라 ‘기존 계약을 바꾸는가 vs 새 소비자를 더하는가’다.”
F1은 수직 기능이 아니라 불변식 개정 과제였다. pinned는 기존 규칙을
무효화했고, tags는 새 필드라 아무 전제도 깨지 않았으며, 팔레트는 자기 이름을 가진
업무 단위라 슬라이스 하나로 떨어졌다. Svelte는 이 발견으로 자기 5차 가설까지 수정했다 —
“FSD는 횡단에 약한 게 아니라 기존 엔티티 속성을 늘리면서 소비자가 많은 변경에 약하다.”
F1은 4차에 도메인으로 끌어올린 불변식 I4를 개정하는 과제였다. “히스토리 길이는 상한을 넘지 않는다”가 “고정되지 않은 항목에만 적용된다”로 바뀐다. 넷 다 도메인 한 곳에서 흡수했다.
| 프레임워크 | 흡수한 자리 | 어댑터 변경 |
|---|---|---|
| React | historyEvictions의 filter 한 줄 |
포트 시그니처가 우회를 차단 — setPinned(id, pinned, retention)이 retention을 강제로 받아 “고정 해제 = 다시 정리 대상”을 잊을 수 없게 함 |
| Svelte | HistoryLog 생성자·evicted·비교자 3곳 |
api/db.ts 0줄 — “삭제는 어댑터, 무엇을 삭제할지는 도메인”이 정확히 값을 함 |
| Angular | keysToDiscard 시그니처를 string[] → {id,pinned}[] |
trim()에 pinned라는 단어가 등장하지 않음 |
| Vue | HistoryLog.isExempt 술어 신설 |
호출자 무변경 — 어댑터는 여전히 evictionPlan이 준 id를 지울 뿐 |
3차에는 이 규칙이 2~3곳에 흩어져 있었다(idb/memory 리포지터리 각각,
거절용/클램프용 범위 각각). 4차가 한 곳으로 모으지 않았다면 6차의 개정은 네 구현 모두에서
여러 파일로 번졌을 것이다. 구조 투자가 실제로 보상을 낸 가장 명확한 사례다.
이 문서가 저지른 가장 큰 방법론 실패다. 무엇을 잴지 미리 알려줬더니 네 구현 중 셋이 그 지표에 맞춰 설계를 바꿨다.
| 출처 | 실제로 일어난 왜곡 |
|---|---|
| React | 430줄짜리 파일을 쪼개고 싶었지만 F1 파일 수가 늘까 봐 미뤘다. 그 파일이 더 커진 채로 인도 |
| Vue | 팔레트를 지연 마운트하려면 컴포넌트를 쪼개야 했는데 F3 수가 늘어서 포기. 결과적으로 앱 시작 시 쿼리 3개가 무조건 실행됨 |
| Angular | “측정 대상이 되는 순간 리팩터링 결정이 오염됩니다.” 죽은 포트 메서드를 지운 대가로 격차가 0.71 → 0.50으로 나빠졌다 — “지표 때문에 남겼다면 조작이고, 지운 건 정직하지만 사실을 만들었다” |
| Angular | “지표가 파일 수라서 한 파일에 몰아넣는 유인이 생기고, 저도 거기 걸렸습니다.” 태그 편집을 목록 행에 넣어 한 파일이 +131줄 |
Svelte는 지표를 조작할 방법을 정확히 알면서 하지 않았다고 밝혔다.
“pinned?(optional) + 정렬을 컴포넌트에서 + 배럴 우회하면 5÷3 = 1.67로 보기 좋아진다.
§6.1은 ‘파일을 잘게 쪼개 지표를 좋게 하기’는 잡지만
‘규칙을 지켜 파일이 느는 것’은 구분하지 못한다.”
이 발견 때문에 7차부터는 측정 계획을 사전에 공개하지 않는다. 기능만 주고, 측정은 스냅샷 대조로 사후에 한다.
5차 §7 — “심볼 하나만 re-export하는 배럴은 금지.” 의례를 막으려고 넣은 규칙이었다.
4차: React가 규칙을 피하려고 구현을 슬라이스 루트 index.tsx에 넣었고,
steiger가 그 6개 파일을 통째로 무검사했다.
5차: 그 결과 React의 슬라이스 6개가 평면이 되고 구조 점수가 깎였다.
6차: React·Vue·Angular가 동시에 같은 충돌을 보고했다.
Angular의 진단이 가장 정확하다 — “팔레트의 자연스러운 자리는 features/command-palette인데,
배럴이 없으면 자체 규칙이 막고 배럴을 만들면 §7 위반이다.
두 규칙이 겹쳐 features 레이어가 app에 닫혀 있다.
결과적으로 옳은 자리에 뒀지만, 옳은 자리를 규칙이 골라 준 것이지 내가 고른 게 아니다.”
React의 정리 — “도메인 함수 30개짜리 배럴과 소비자 하나짜리 컴포넌트를 같은 규칙으로 묶으면 공개 표면이라는 개념이 망가진다.” 7차 기획서에서 이 규칙은 폐기됐다.
이번엔 네 구현의 과제가 완전히 동일해서 시간·토큰을 점수에 반영했다. 기존 E2E·의존성·경계 설정은 전부 직접 대조했다.
| 평가 항목 | 비중 | Angular | Svelte | React | Vue |
|---|---|---|---|---|---|
| 구현 품질 | 30 | 27 | 28 | 26 | 25 |
| 구조가 값을 했는가 | 30 | 27 | 26 | 25 | 24 |
| 회귀·계약 준수 | 15 | 15 | 15 | 15 | 11 |
| 비용 효율 (시간·토큰) | 15 | 12 | 11 | 15 | 13 |
| 정직한 보고 | 10 | 10 | 10 | 9 | 8 |
| 합계 | 100 | 91 | 90 | 90 | 81 |
의존성은 네 앱 모두 package.json이 5차와 바이트 단위로 동일하다.
넷 다 커맨드 팔레트를 cmdk 같은 기성품 없이 직접 만들었고, 근거도 같았다 —
검색·커서 규칙이 도메인 규칙이라 node_modules로 들어가면
“프레임워크 없이 검증한다”는 자산을 잃는다는 것. 단위 테스트는
277 · 346 · 777 · 271개가 통과하고 E2E는 10 · 15 · 16 · 18이다.
| 프레임워크 | 소요 시간 | 토큰 | 도구 호출 |
|---|---|---|---|
| React | 20:38 | 236,345 | 71 |
| Vue | 22:25 | 229,039 | 91 |
| Svelte | 26:46 | 255,475 | 110 |
| Angular | 25:42 | 249,896 | 92 |
여덟 라운드 중 비용을 점수에 넣은 유일한 라운드다 — 네 구현의 과제가 같은 기능 3개로 완전히 동일했기 때문이다. React가 가장 빠르고 쌌고, Svelte가 가장 오래 걸렸는데 구현 품질 점수는 Svelte가 1위였다. 비용과 품질이 같은 방향으로 가지 않는다.
Vue만 기존 E2E가 불안정해졌다 — 그리고 원인을 잘못 귀속했다.
전체 스위트 5회 중 2회 실패(단독 실행은 3/3 통과). 보고서는 “제 변경과 무관한 부하 플레이크”라고 했다.
정정 — 이 문서의 6차 진단도 틀렸다. 당시 여기에
“상시 마운트된 커맨드 팔레트가 두 번째 role="option" 소스”라고 적었다.
팔레트 코드에서 role="option"을 찾아내고 그것을 원인으로 단정한 것인데,
상관을 인과로 착각한 것이었다.
7차에서 Vue가 계측으로 진짜 원인을 찾았다 — 라우트 전환 타이밍이다.
/environments→Console 이동 직후 이전 화면의 Listbox가 아직 살아 있어
같은 이름 option이 둘이 된다. 콤보박스가 선언한 aria-controls 안에서만
찾도록 고치자 연속 10회(워커 1/4/8 포함) 전부 통과했다.
다만 이 문서가 만든 딜레마이기도 하다 — 기존 스펙·헬퍼 수정을 금지해 Vue는 이 테스트 설계 결함을 고칠 수 없었다. 앱 동작은 정상이므로 실격은 부당하고, 불안정성과 잘못된 원인 귀속만 감점했다. 7차부터는 헬퍼 수정을 허용한다.
경계 도구를 지울 수 있게 한 것이 이 라운드의 핵심 장치다. 유지를 강제하면 그것도 처방이 되고, 유지 여부가 정보가 아니게 된다. 아무도 강제하지 않을 때 앞선 처방을 남기는지는 이 문서가 재는 것이 아니라 네 구현이 투표하는 것이다.
투표 결과는 만장일치였다. 설정 파일과 의존성에서 steiger가 완전히 제거됐다
— 직접 확인했다. 그런데 근거는 넷 다 달랐다.
| 출처 | 삭제 근거 |
|---|---|
| React | “설정이 대부분 off인 린터는 내 설계를 강제하는 게 아니라 남의 설계와 협상하는 중이다.” 그리고 pnpm lint에서만 돌았다 — “막지 못하는 게이트는 게이트가 아니다” |
| Angular | “규칙 절반을 끈 린터의 ‘통과’는 정보가 아니고 습관적 무시를 만든다” |
| Vue | “하나의 규칙에 권위가 둘이었다 — 모든 결정을 두 번, 두 번째는 항상 부정형(‘끈다, 왜냐하면…’)으로 적어야 했다” |
| Svelte | 결정적으로 — “수리를 린터가 위반으로 신고한다.” 아래의 순수성 구멍을 고치자 no-public-api-sidestep이 6건 터졌다 |
반대로, 타입 수준 게이트는 넷 다 남겼다.
React와 Angular는 tsconfig.domain.json을 “자산”이라 부르며 한 글자도 건드리지 않았고,
Vue와 Svelte는 vitest 프로젝트 분리를 유지하거나 더 엄격하게 고쳤다.
React의 정리 — “커스텀 코드 0줄, 정규식이 아니라 타입 시스템, 유일하게 pnpm build에서 돈다.”
도구를 지운 자리에 잡는 것은 줄지 않았다. Angular의 표현 — “도구가 셋에서 둘로 줄었는데 잡는 것은 하나도 줄지 않았습니다.”
Svelte가 찾았고, 이 문서가 코드로 확인했다. 4차부터 “순수하다”고 주장해 온 계층이 실제로는 IndexedDB에 쓰고 있었다.
save-settings · set-active · save-environments ·
save-request)이 전부 model/ 세그먼트에 있었다. 7차에 모두 api/로 옮겨졌다.
더 나쁜 것은, 그 스펠링을 다른 규칙이 강제했다는 점이다.
R3(공개 API 규칙)가 “슬라이스는 배럴로만 접근한다”를 요구했고, 순수성 검사는 “경로에 api가 없으면 순수”라고 판정했다.
두 규칙이 서로를 무력화하며 둘 다 초록이었다.
458개짜리 경계 테스트가 넷 다 통과로 보고했다.
Svelte는 domain vitest 프로젝트에서 saveSettings()를 실제로 호출해
IndexedDB 부재로 거부당하는 것을 보고서야 알았다. 그리고 6차 보고서에
“두 도구가 독립적으로 잡았다”를 성과로 적었던 것을 뒤집었다 —
“두 도구가 독립적으로 같은 것을 놓쳤다.”
| 출처 | 6차에 적은 것 | 7차의 정정 |
|---|---|---|
| React | 팔레트 index.tsx가 구현인 건 기형이다 (기획서 탓) |
다시 보니 일관된 규칙이었다. “비용을 댈 수 없는 미적 불만” — 철회 |
| Angular | 경계 규칙 (5)를 세 라운드 지켰다 | “그 규칙은 처음부터 아무것도 막지 않았다.” 규칙 1·3이 이미 덮고 있었고, 순 효과는 app이 features를 못 보게 한 것뿐 |
| Svelte | zag의 루프가 유한해서 팔레트가 이긴다 | 루프를 끝낸 것은 RangeError 스택 오버플로였고, 팔레트가 이긴 건 재귀의 가장 깊은 호출이 우연히 팔레트 것이어서 |
| 이 문서 | Vue의 E2E 실패는 상시 마운트된 팔레트 때문 | 틀렸다. 진짜 원인은 라우트 전환 타이밍 — 이전 화면의 Listbox 잔존. Vue가 계측으로 찾아 15분에 고쳤다 |
Svelte는 원인을 찾고도 고치지 않았다.
팔레트 플래키의 진짜 원인(FocusTrap.tryFocus ↔ onfocusin 동기 재귀)을 계측으로 특정하고,
재진입 가드와 rAF 지연을 둘 다 시도했다. 둘 다 “누가 포커스를 갖는지”를 바꿨고
— 그것은 §1의 행동 변경이다 — 스트레스 테스트에서 더 나빴다.
그래서 6차 코드로 되돌리고 오버플로를 남긴 뒤 “좋은 결말이 아니라고” 노트에 적었다.
E2E가 어차피 불안정하니 “고쳤다”고 하고 넘어갈 수 있었는데 하지 않았다.
7차 보고서에서 가장 읽고 싶었던 항목이다 — 어떤 처방이 값을 했고 어떤 것이 부채였나. 값을 한 처방 1위는 만장일치로 4차 도메인 계층이었다. 그런데 넷 다 이유가 “DDD라서”가 아니었다.
“4차가 값을 한 진짜 이유는 DDD라는 단어가 아니라 tsconfig.domain.json이다.
‘도메인은 순수해야 한다’는 슬금슬금 샜을 수도 있었는데, 빌드가 깨지게 만든 순간부터 사실이 됐다.
처방이 값을 하려면 틀렸을 때 뭔가가 빨개져야 한다.”
“형태가 domain/ 폴더에서 model/ 세그먼트로 바뀌고도 살아남은 이유는
도구로 강제했기 때문이다. 규약이었으면 5차 이사 때 조용히 죽었을 것.”
그리고 — “제약은 근육을 만들고, 근육이 붙은 뒤에도 남은 보조기가 부채가 된다.”
“여섯 번 중 다섯 번은 코드를 옮겼고 한 번만 코드가 하는 일을 바꿨다. 여섯 번의 재배치를 한 번도 안 움직이고 살아남은 부분들은 전부 문제에 대해 생각해서 내린 결정이다. 구조는 변경이 어떻게 전파되는지를 바꿀 뿐, 무엇이 옳은지는 바꾸지 않는다.”
“경계의 값은 그 경계를 가로지르는 변경이 실제로 올 때만 확인된다. 올지는 미리 알 수 없다. 정직하게 말하면 운의 성분이 있다.” 4차엔 과잉 설계라고 생각했는데 6차에 I4가 개정되자 어댑터가 0줄 바뀌었다.
부채도 만장일치로 둘 — 이 문서가 만든 것들이다.
“심볼 하나짜리 배럴 금지”와 6차 지표 사전 공개. 지표 공개에 대한 평가는 라운드를 거치며 점점 날카로워졌다:
React — “설계를 왜곡했다기보다 다음 단계를 한 라운드 늦췄다”
Vue — “지표는 무엇을 보는지가 아니라 무엇을 안 보는지를 바꿨다”
Svelte — “순수성 구멍은 6차에도 거기 있었다. 그때 나는 파일 수를 세고 있었다”
그럼에도 넷 다 처방 자체를 부정하지 않았다.
“처방의 진짜 비용은 질문의 대체다. 다섯 라운드 동안 나는 ‘이 처방을 어떻게 잘 지킬까’를 생각했고, 이번 한 라운드에서만 ‘이 코드가 주장하는 게 사실인가’를 생각했다.”
“다만 처방이 없었으면 고칠 규칙 자체가 없었다. 4차가 강제하지 않았으면 순수 세그먼트도, 검사 테스트도, 별도 vitest 프로젝트도 없었을 테고 넷이 아니라 마흔 개가 IndexedDB를 불렀을 것이다. 처방은 검사할 수 있는 주장을 만들어 줬고, 처방의 부재는 그것을 검사할 시간을 줬다. 7차가 1차일 수는 없었다.” — Svelte
Svelte는 개선안까지 냈다 — “다시 한다면 각 라운드에 하나만 추가했을 것: 이 라운드에 도입한 규칙이 실제로 무엇을 잡았는지 한 건 이상 보여라.” 6차에 그것을 물었다면 “R3가 지금까지 무엇을 잡았지?”가 나왔을 테고, 그 질문이 순수성 구멍으로 한 라운드 일찍 이어졌을 것이다. 8차에 채택한다.
이 라운드는 지표를 공개하지 않았고 변경량을 평가하지 않겠다고 했으므로, 발견의 질 · 판단의 정직함 · 자기 규칙에 대한 회의로 봤다. 점수표는 쓰지 않는다.
| 검증 항목 | React | Vue | Svelte | Angular |
|---|---|---|---|---|
| E2E 스펙 무결성 | 바이트 동일 | 스펙 동일 helpers만 (허용) |
바이트 동일 | 바이트 동일 |
| E2E 통과 (직접 실행) | 10/10 | 15/15 ×3회 | 16/16 ×3 4회차 1실패 | 18/18 |
| 단위 테스트 | 277 → 222* | 346 → 347 | 777 → 799 | 215 → 215 |
| steiger | 삭제 | 삭제 | 삭제 | 삭제 |
| 의존성 증감 | −2 | −2 | −3 | −2 |
* React의 감소는 커버리지 축소가 아니라 도구 통합이다 —
purity.test.ts + architecture.test.ts를 한 파일로 합치며
it.each가 파일마다 찍던 녹색 단언을 규칙당 하나로 줄였다. 검사 범위는 그대로다.
Svelte의 플래키는 본인이 round6-snapshot을 복사해 설치·실행하여 6차 코드에서도
동일하게 재현되는 것을 확인했고, 이 문서의 4회 실행도 그 보고와 일치한다.
| 프레임워크 | 소요 시간 | 토큰 | 도구 호출 |
|---|---|---|---|
| React | 13:04 | 161,048 | 53 |
| Angular | 14:59 | 223,968 | 70 |
| Vue | 16:09 | 231,117 | 82 |
| Svelte | 31:16 | 254,343 | 130 |
작업 범위를 각자 정하는 라운드이므로 시간은 점수에 넣지 않았다. “안 바꿔도 정답”이라고 해 둔 라운드에서 시간을 재면 사실상 변경량을 재는 것이고, 실제로 오래 걸린 쪽이 더 많이 찾았다 — 가장 오래 걸린 Svelte가 다섯 라운드 묵은 순수성 구멍과 플래키의 진짜 원인을 찾아냈다.
순위를 굳이 매기면 Svelte · Angular · React · Vue 순이지만, 근소하다.
더 의미 있는 것은 네 구현 모두 자기 자신에게 불리한 발견을 스스로 내놨다는 사실이다 — 자기 규칙이 빈 문이었다는 것, 자기 성과가 사실은 놓친 것이었다는 것, 자기 불만이 비용을 댈 수 없는 미적 불만이었다는 것, 그리고 “지워도 된다고 말해주지 않았다면 안 지웠을 것이다. 기술적 판단이 아니라 허가의 문제였다”는 것.
아무도 서로의 결과를 보지 못한 채, 넷이 각자 독립적으로 같은 결함에 도달했다 — 이 문서가 5차에 강제한 “슬라이스는 공개 API로만 접근한다”는 규칙이 네 생태계 전부에서 트리 셰이킹을 막고 무거운 의존성을 엔트리 청크에 용접하고 있었다.
| 출처 | 배럴이 끌고 들어온 것 | 비용 |
|---|---|---|
| React | settingsFormSchema가 entities/settings/domain의 모듈 스코프에 있어 zod가 엔트리로 — “export * 배럴은 참조 수집기고 번들러는 참조를 따라간다” | 48 kB |
| Svelte | 콘솔 배럴이 Superforms + Zod를 첫 렌더 앞에 세움 — “R3의 근거는 ‘배럴은 런타임 결합 장치다’인데, 코드 스플리팅도 정확히 런타임 결합에 관한 것이다” | 147 kB |
| Vue | send-request 배럴이 컴포넌트를 재수출해, 팔레트가 useConsoleStore 하나 쓰려다 콘솔 UI 전체를 엔트리에서 도달 가능하게 만듦 | 316 kB |
| Angular | entities/*/index.ts가 복원 함수와 스키마를 같이 재수출 — “배럴을 우회해야만 zod가 지연 청크로 빠졌다” | 90 kB |
세 구현은 걷어내고 실제 시간을 얻었다. Angular는 걷어내고 0을 얻었다.
React −86 ms · Svelte −95.5 ms · Vue −376 ms(FCP). 그런데 Angular는 전송 바이트를
9.6% 줄이고도 시간이 1 ms도 줄지 않았다. 워터폴을 뜯어 이유까지 밝혔다 —
첫 단에서 뺀 26 kB만큼 둘째 단의 큰 청크 셋이 동시에 출발해 대역폭을 나눠 쓰게 됐고,
@angular/core 112 kB 청크가 885 ms 대신 1057 ms 걸렸다. 줄인 만큼 그대로 되돌려 먹었다.
즉 같은 구조적 결함의 가격이 프레임워크 런타임 크기에 따라 다르다. 런타임이 작은 셋은 바이트 절감이 임계 경로에 닿았고, Angular는 자기 코어가 임계 단을 지배해 우리 코드를 아무리 줄여도 닿지 않았다. Angular의 정리 — “구조는 성능을 막고 있었지만, 막고 있던 것의 크기가 0이었다.”
Angular는 이 라운드에 76분 34초와 토큰 416,520개, 도구 호출 190회를 썼다.
여덟 라운드를 통틀어 한 에이전트가 한 라운드에 쓴 최대치다.
그렇게 해서 src/에 남긴 변경은 파일 하나, 한 줄, +5 바이트였다.
| 단계 | 한 일 | 결과 |
|---|---|---|
| (a) 사실 확인 | 메타파일 추적 — zod를 끌고 오는 것은 entities/{environment,settings,collection}/model/*.ts 세 파일. 부팅 때 스키마를 파싱하는 코드는 한 줄도 없는데 90 kB가 따라온다 | 사실 |
| (b) 천장 측정 | 배포하지 않을 버리는 빌드로 zod를 껍데기로 갈아 끼워 짝지은 A/B (n=11) | −143.0 ms 이득은 실재 |
| (c) 왜 못 떼나 | /* @__PURE__ */만 붙이기 → 0 바이트. esbuild의 청크 배정 단위는 문이 아니라 모듈. 파일을 갈라도 배럴이 다시 붙인다 — 최소 재현으로 둘 다 확인 | 기각 ×2 |
| (d) 제대로 구현 | 도메인 파일 셋 분할 · 슬라이스 공개 표면 이중화(index.ts/rules.ts) · 경계 규칙 완화 · Save 대화상자 동적 import | 978.53 → 883.16 kB |
| (e) 재봤더니 | 짝지은 A/B (n=11) — 바이트 −9.6%, 시간 +1.6 ms(편차 이내), 그리고 Save 대화상자 열기 +523.9 ms | 전부 되돌림 |
되돌림은 깨끗했다 — 이 문서가 round7-snapshot과 diff해 확인한 결과
src/에 남은 변경은 main.ts 한 파일뿐이고, (d)의 흔적은 하나도 남지 않았다.
비싼 것은 구현이 아니라 “고치지 않는다”를 근거 있게 말하기 위한 측정이었다.
최종 변경은 한 줄이다 — void import('./pages/console/ui/console-page').
라우터는 자기 차례가 와야 loadComponent를 실행해서, 첫 로드가
main.js → @angular/core 청크 → 콘솔 청크 3단 직렬이 된다. 이 한 줄이 마지막 단을 둘째 단과 겹친다.
Slow 4G + CPU 4×, 교대 A/B, n=15 — 조작 가능까지 2365.2 → 2300.6 ms, 짝지은 Δ −79.2 ms, 95% CI [−122.6, −64.0].
FCP는 Δ 0.0 ms로 움직이지 않았다(즉시 로드안이 0.36초 늦췄던 바로 그 지점).
그리고 자기 수정의 대가까지 따로 쟀다 — /history로 바로 들어온 사용자는
30 kB를 더 받지만 시간은 Δ +1.3 ms, CI [−7.2, +7.4]로 변화 없음.
6차에서 이 문서가 지표를 공개해 설계를 오염시켰다. 8차에서는 오염이 기계에서 왔고, Angular와 Vue가 서로를 모른 채 같은 현상을 탐지해 각자 측정 설계를 다시 짰다.
| 출처 | 탐지한 오염 | 대응 |
|---|---|---|
| Angular | 실행 간 드리프트 10%대. 별도 실행으로 −164 ms를 얻은 변경이 한 프로세스 안 교대 A/B에서는 +1.6 ms였다 | 교대 A/B + 짝지은 차이의 부트스트랩 95% CI. CI가 0을 포함하면 개선이라 부르지 않는다 |
| Angular | 압축 없이 서빙하고 있었다 — 무압축 Slow 4G 부팅 6179 ms vs gzip 2455 ms | 그대로 갔으면 “번들만 줄이면 빨라진다”는 거짓 결론이 나왔을 것. 정적 서버를 gzip으로 고침 |
| Vue | 같은 시각 angular-app이 자기 하네스를 돌려 절대값이 5~10% 밀림 — 기계가 공용이라는 게 측정 중에 드러남 | 두 빌드를 동시 서빙하고 반복마다 A→B를 붙여 재는 짝지은 A/B. 쌍별 delta의 IQR이 0을 걸치면 하네스가 스스로 NOISE로 찍는다 |
| Vue | 개선이 아닌 것을 개선으로 찍을 위험 | 음성 대조군 — 건드리지 않은 시나리오 5개가 전부 NOISE로 나오는 것을 확인 |
두 에이전트가 서로를 모른 채 같은 간섭을 각자 탐지해 서로의 관측을 입증했다.
Vue는 원인을 angular-app으로 이름까지 지목했고, Angular는 같은 시각 자기 하네스가
도는 동안 실행 간 드리프트를 겪었다. 7차에 Svelte가 react-app의 프리뷰 서버를
pkill로 죽인 사건과 같은 계열의 문제다 — 병렬 실행 자체가 측정 환경이다.
기획서 §3은 “최적화한 것만 나열한 보고서는 낮게 평가한다”고 못박았다. 네 구현이 세운 가설과 기각률은 이렇다 — 그리고 기각된 것들 쪽이 더 흥미롭다.
| 프레임워크 | 가설 | 기각 | 기각률 | 안 맞았던 도구도 보고 |
|---|---|---|---|---|
| Angular | 13 | 12 | 92% | 5종 — CDP Performance.getMetrics(내비게이션 넘으면 카운터 초기화 → −112 ms 같은 음수), rAF 2회 기준(vsync에 양자화돼 모든 키가 28.7 ms), 샘플링 프로파일러(완전히 평평 — 최상위가 (program)/(idle)/(gc)), Lighthouse, Angular DevTools |
| Svelte | 11 | 9 | 82% | 4종 — Lighthouse 거절(“SPA의 LCP는 빈 셸을 재고, ‘URL 칸에 타자를 칠 수 있게 되는 시점’을 모른다”), CDP 프로파일러가 자기 자신을 잰다(Playwright 접근성 트리 계산) |
| React | 8 | 6 | 75% | React 19 함정 — window.load가 첫 페인트보다 먼저 발화한다(createRoot().render()가 스케줄하므로) |
| Vue | 8 | 6 | 75% | 4종 — Vue 내장 프로파일러를 프로덕션 빌드에서 쓸 수 없다: startMeasure가 NODE_ENV!=='production' 하나로만 가드돼 __VUE_PROD_DEVTOOLS__로도 살아나지 않는다 |
이 라운드 최고의 기각은 Svelte의 H6다 — 숫자가 곧 기능이었다.
History 검색창이 5글자에 310 ms. 명백한 병목처럼 보였다. 파보니 그 310 ms는 기획서가 명세한 300 ms 디바운스였다. Svelte의 정리 — “벤치 숫자만 보고 손댔으면 명세된 동작을 ‘최적화’해서 부쉈을 겁니다.”
같은 계열이 셋 더 있다. Vue는 자기 2차 결정(shallowRef가 큰 응답을 살린다)을
ref 빌드와 짝지어 재서 전부 NOISE로 기각했고,
7차가 남겨둔 자기 의심(팔레트 상시 마운트로 부팅에 쿼리 3개)도 차이 5 ms로 기각했다.
React는 React 없는 대조군을 만들어(평문 textarea 487 ms vs 앱 621 ms @300KB)
키당 18 ms 중 앱 몫은 3~5.6 ms이고 나머지는 브라우저 레이아웃임을 분리해 냈다.
기획서 §5는 “고치지 않기로 한 것도 정답이다”라고 했다. 넷 다 그 칸을 채웠는데, Vue의 항목이 이 라운드에서 가장 큰 숫자였다.
| 출처 | 찾은 것 | 크기 | 안 고친 이유 |
|---|---|---|---|
| Vue | 히스토리 드로어 573 ms(300행). 치환 실험으로 원인을 못박음 — PrimeVue Tag×2 + Button×2를 같은 클래스의 평문 엘리먼트로 바꾸니 84.8 ms. 인스턴스당 약 0.38 ms. 대조군으로 같은 앱의 JSON 트리(PrimeVue 0개)는 행당 0.15 ms로 12배 싸다 | 6.7× | 평문 버튼은 Ripple을 잃는다(행동 변화). “측정 가능한 490 ms를 측정 불가능한 위험과 바꾸는 거래.” 기본 보관 한도 50에서는 124 ms |
| Svelte | devtools 청크 제거로 빌드 산출물 880K → 616K | −30% | 스스로 “개선이 아니다”라고 선언했다 — 네트워크 감사 결과 사용자 바이트는 전후 모두 0. 기획서 §4.1의 “합성 숫자”에 해당 |
| Angular | Zod 스키마 메모이제이션 — 동기 구간 −40~60%로 유의하게 줄어듦 | 0.2~0.3 ms | 절대값이 너무 작아 “어떤 사용자 행동이 빨라졌다”로 말할 수 없다 — §4.1 기준상 개선이 아니라 변경 |
| React | memo(ResponsePanel) — 교과서적인 React 수정 | 잡음 | 재보니 편차 안. Save 다이얼로그 lazy 로딩도 천장까지 재보고 접었다 |
Vue의 결론이 3차 처방에 대한 청구서다.
“‘킷이 생김새를 소유한다’는 원칙이 가장 큰 비용을 만들고 동시에 그 수정을 막았습니다 — 문제를 만든 결정과 고치지 못하게 막는 결정이 같은 결정입니다.”
이 문서가 3차에 UI 킷을 강제했다. 5차에 배럴을 강제했다. 8차에 네 구현이 찾은 두 개의 성능 문제가 정확히 그 둘이다.
Svelte가 자기 코드에 여섯 라운드 동안 앉아 있던 거짓 주석을 잡았다.
원래 주석은 “패키지 자체의 DEV 가드로 프로덕션 번들에서 트리 셰이킹된다”였다.
절반만 맞았다 — 컴포넌트 본문은 셰이킹되어 브라우저가 한 바이트도 받지 않지만,
Rollup은 청크를 여전히 내보낸다(268 kB, vite build 산출물의 30%).
호출 지점이 사라져도 import 간선이 모듈로 살아남기 때문이다.
그리고 그 사실을 확인한 뒤 “이것은 속도 개선이 아니다”라는 제목으로 §3.2에 적었다.
기획서 §7이 미리 밝힌 것은 하나뿐이었다 — 개선 폭의 크기는 순위를 정하지 않는다. 그래서 측정의 엄격함 · 가설 규율 · 정직한 자제로 봤다. 점수표는 쓰지 않는다.
| 검증 항목 | React | Vue | Svelte | Angular |
|---|---|---|---|---|
| E2E 스펙 무결성 (vs round7-snapshot) | 바이트 동일 | 바이트 동일 | 바이트 동일 | 바이트 동일 |
| E2E 통과 (직접 실행) | 10/10 | 15/15 | 16/16 | 18/18 |
| 단위 테스트 (직접 실행) | 222 | 347 | 799 | 215+56 |
pnpm build |
성공 | 성공 | 성공 | 성공 |
src/ 변경 파일 수 |
16 | 3 | 2 | 1 |
| 의존성 증감 | −2 zod · @hookform/resolvers |
0 | 0 | 0 |
| 가설 / 기각 | 8 / 6 | 8 / 6 | 11 / 9 | 13 / 12 |
보고된 수치 중 확인 가능한 것은 전부 사실이었다.
Angular의 초기 번들 978.56 kB / 245.50 kB gzip은 직접 빌드해 소수점까지 일치했고,
React의 dist에서 zod 문자열은 0건이며, 변경 파일 수 1·2·3·16도 스냅샷 diff와 정확히 맞았다.
E2E는 네 앱 모두 이 문서가 직접 실행했다.
React의 zod 제거는 성능 작업이 구조를 되레 조인 사례다 — 그리고 이 문서가 코드로 확인했다.
7차까지 도메인 계층이 이름 부를 수 있는 패키지 허용 목록은 { 'zod' }였다.
8차에 그 zod가 검증을 하나도 하지 않는 순수 전송 수단(z.custom()은 아무것도 검증하지 않고
superRefine이 도메인 이슈를 재방출할 뿐)이었음이 드러나 손으로 Resolver를 쓰고 걷어냈다.
그 결과 boundaries.test.ts의 허용 목록이 빈 집합이 됐다 —
도메인 계층이 이제 어떤 패키지도 이름 부르지 않는다.
FSD 라운드가 못 한 것을 성능 라운드가 했다.
| 프레임워크 | 소요 시간 | 토큰 | 도구 호출 |
|---|---|---|---|
| Svelte | 43:52 | 242,810 | 93 |
| React | 46:29 | 279,518 | 98 |
| Vue | 57:33 | 344,214 | 115 |
| Angular | 1:16:34 | 416,520 | 190 |
여덟 라운드를 통틀어 가장 비싼 라운드였고, 비용과 diff 크기가 정확히 반대로 갔다. Angular는 최대 비용(76분·416K·190회)을 쓰고 한 줄을 남겼고, 가장 싼 Svelte(43:52·242K)가 두 파일을 고쳤다. 성능 라운드에서 비싼 것은 구현이 아니라 “고치지 않는다”를 근거 있게 말하기 위한 측정이다. Vue의 57분에는 기계 간섭을 탐지한 뒤 측정을 통째로 다시 설계해 재실행한 시간이 들어 있다.
순위는 Angular · Svelte · Vue · React 순이지만, 상위 둘은 사실상 붙어 있다.
Angular는 이 라운드에서 과학적 절차를 가장 온전히 돌았다 — 가설 13개 중 12개 기각, 자기 측정 오류를 둘이나 스스로 잡아 공개(gzip 미적용, 실행 간 드리프트), 부트스트랩 CI, 버리는 빌드로 천장을 먼저 재고, 큰 구조 변경을 끝까지 구현했다가 0을 확인하고 되돌렸다.
Svelte는 통찰의 질에서 앞섰다 — H6(숫자가 곧 기능이었다)은 이 라운드 최고의 단일 발견이고, 자기에게 가장 크게 보이는 성과를 스스로 개선이 아니라고 선언했으며, 여섯 라운드 묵은 거짓 주석을 잡았다.
Vue는 하네스 설계가 넷 중 가장 정교했고(짝지은 A/B + NOISE 자동 판정 + 음성 대조군) 기계 간섭을 탐지해 측정을 면역시켰다. 다만 고친 둘 중 하나는 자기가 만든 배럴 재수출의 정리였다.
React는 React 없는 대조군이라는 이 라운드 유일한 기법을 썼고 zod 제거로 구조를 되레 조였다. 기각 건수가 가장 적고 변경 파일이 가장 많았다는 점에서 뒤에 놓였을 뿐, 네 보고서 모두 기획서 §6.2의 여섯 항목을 빠짐없이 채웠다.
같은 네 에이전트, 같은 앱, 여덟 개의 다른 질문. 순위가 라운드마다 재배열됐다는 사실 자체가 “어느 프레임워크가 낫다”는 질문이 잘못 놓였음을 보여준다.
7·8차는 지표를 사전에 공개하지 않고 변경량을 평가하지 않은 라운드라 점수 없이 순위만 적었다. 7차는 네 구현의 차이가 가장 작았던 라운드이고, 8차는 순위가 개선 폭이 아니라 측정의 엄격함으로 갈린 라운드다.
네 프레임워크 모두 한 번 이상 1위를 했고, 한 번 이상 꼴찌를 했다.
React는 문자열까지 고정된 1차에서, Svelte는 제약이 풀린 2·3차와 처방을 거둔 7차에서, Angular는 구조를 파고든 4·5·6차와 측정을 물은 8차에서, Vue는 도메인을 떼어낸 4차에서 1위였다. 강점이 드러나는 조건이 저마다 달랐을 뿐이다. 그리고 앞 라운드의 판정은 뒤 라운드에서 청구서로 돌아왔다 — 2·3차에서 칭찬한 “클래스인데 반응형”은 4차에서 깨진 테스트 48개가 됐고, 4차의 불변식 I7은 5차에서 스토어를 쪼갤 수 없게 만든 대신 6차에서 I4 개정을 한 줄로 흡수시켰다. 그리고 5차의 배럴 규칙은 8차에서 네 구현 전부에게 청구서로 돌아왔다.
이 문서는 여덟 번 예측했고 여덟 번 틀렸다. 그게 여기서 가장 값진 부분이다.
1차엔 “밀도”와 “설계”를 혼동했고, 5차엔 FSD가 이길 줄 알았고, 6차엔 격차가 1보다 클 줄 알았고, Vue의 E2E 실패 원인을 엉뚱한 곳에서 찾았으며, 8차엔 배럴 결함을 “넷 중 셋이 찾았다”고 적었다가 Angular의 §2.4를 읽고 만장일치로 고쳤다. 매번 코드를 직접 읽고 나서 판단이 바뀌었고, 매번 에이전트들이 더 나은 해석을 내놨다.
그리고 이 문서 자체가 실험 대상을 오염시켰다. 지표를 미리 공개하자 넷 중 셋이 그 지표에 맞춰 설계를 바꿨고, 한 줄짜리 배럴 규칙은 세 라운드 동안 피해를 냈으며, “공개 API로만 접근하라”와 “순수 계층은 I/O를 모른다”가 서로를 무력화하며 다섯 라운드 동안 초록으로 앉아 있었다. 그리고 8차에 드러났듯 같은 규칙이 네 생태계 전부에서 트리 셰이킹까지 막고 있었다 — 다섯 라운드 동안 아무도 재보지 않았기 때문에 아무도 몰랐다.
7차가 준 답은 이것이었다 — 처방은 검사할 수 있는 주장을 만들고, 처방의 부재는 그것을 검사할 시간을 준다. 네 구현은 공식 린터를 만장일치로 지웠지만 타입 게이트는 넷 다 남겼다. 강제되지 않을 때 무엇을 남기는가가 그 처방이 값을 했는지에 대한 유일하게 정직한 투표다.
8차가 덧붙인 답은 더 불편하다 — 일곱 라운드 동안 쌓은 산문은 단 한 번도 측정된 적이 없었다. Svelte의 표현으로, “라운드마다 산문은 상속되고 숫자는 상속되지 않았습니다.” 그리고 “구조는 실제로 무거운 것에 대해 아무 말도 하지 않습니다” — 일곱 라운드의 규칙은 전부 네 구현이 직접 쓴 7%의 바이트를 다스렸고, 나머지 93%를 차지하는 의존성 그래프에 대해서는 어느 라운드도 질문을 던지지 않았다. 마지막에 네 구현이 만장일치로 찾아낸 결함이 하필 이 문서가 처방한 규칙이었던 것은 우연이 아니다. 검사되지 않는 처방은 시간이 지날수록 비용만 남긴다.
이 연구는 “어느 프레임워크가 낫냐”를 물으려고 시작한 것이 아니다. AI에게 맡겨 개발할 때 네 프레임워크가 어떻게 다른가를 보려고 시작했다. 여덟 라운드를 그 축으로 다시 자르면, 각 라운드는 AI 개발의 한 국면을 모형화한 것이 된다.
| AI 사용 국면 | 라운드 | 1위 | 꼴찌 |
|---|---|---|---|
| 명세를 주면 정확히 구현하는가 | 1 | React | Svelte · Angular 둘 다 버그 출하 |
| 자유를 주면 스스로 잘 설계하는가 | 2 · 7 | Svelte | Angular(2) · Vue(7) |
| 지정한 라이브러리를 흡수하는가 | 3 | Svelte | React |
| 정해 준 구조를 따르는가 | 4 · 5 | Angular | Svelte(4) · React(5) |
| 기존 코드에 기능을 얹는가 | 6 | Angular | Vue |
| 자기 작업을 스스로 재고 검증하는가 | 8 | Angular | React |
| 얼마나 드는가 (누적 토큰) | 전체 | React 1.91M |
Angular 2.28M · 1.20배 |
위 표에서 1위 칸이 Svelte와 Angular로 깨끗하게 둘로 나뉜다. 그리고 그 둘은 서로의 정확한 거울상이다 — 한쪽이 이긴 라운드에서 다른 쪽이 꼴찌였다. 어느 프레임워크도 두 모드를 동시에 잘하지 못했다.
svelte-autofixer가 잡는다 (문서 대조)tsconfig.domain.json + 경계 스크립트)나머지 둘은 이 축의 양끝이 아니라 중간에 있다 — React는 싼 일반형(누적 최저 비용, 안 시켜도 분리함), Vue는 무난한데 생태계에 함정이 있다.
여덟 라운드의 성적과 8차에 확정된 측정 사실을 합쳐, AI에게 맡길 때를 기준으로 정리한 것이다.
AI가 자기 코드를 공식 문서에 대고 스스로 고치는 유일한 프레임워크. 단일 파일이라 변경당 읽는 양도 최소. 함정 — 룬은 얹은 대상에 반응성을 용접한다. 시키지 않으면 AI가 도메인 로직에 룬을 넣고, 4차엔 그게 깨진 테스트 48개가 됐다.
AI의 계층 위반이 컴파일 에러가 된다. zoneless+OnPush가 “리렌더가 많다”는 가설을 세울 수조차 없게 만들어(키당 0.1~0.5 ms) AI가 헛다리 짚을 여지가 구조적으로 적다. 대가 — 코어 112 kB는 최적화로 못 지운다. 8차에 우리 코드 26 kB를 빼고도 0 ms였다.
안 시켜도 옳은 분리를 한다. 1차에 3계층, 6차에 의존성 역전, 8차엔 도메인이 어떤 패키지도 이름 부르지 않게 됐다 — 전부 지시 없이. 약점 — 남이 정한 방식을 강요하면 꼴찌다. 스택 지정 3차, 구조 지정 5차 모두 최하위.
AI 사용 관점에서 유일하게 세 겹이 겹친다. AI가 UI 킷을 집고(가장 비쌈) → 행동이 바뀌어 못 고치고 → 내장 프로파일러가 프로덕션에서 안 돌아 찾을 수도 없다. 구조 본능도 가장 평평해, 맡기면 구조가 안 생긴다.
이 연구가 원래 목적에 가장 직접적으로 답하는 관측이다. 다만 상관이지 인과가 아니다 — 아래 반례와 교란을 함께 읽어야 한다.
| 프레임워크 | 공식 AI 도구 | 승수 | 이긴 라운드 |
|---|---|---|---|
| Angular | angular/skills — 공식 | 4 | 4 DDD · 5 FSD · 6 인도 · 8 성능 |
| Svelte | sveltejs/ai-tools + MCP 서버(get-documentation · svelte-autofixer) + 전용 에이전트 타입 | 3 | 2 설계 · 3 생태계 · 7 자유 |
| React | 없음 — 커뮤니티 스킬 사용 | 1 | 1 명세 |
| Vue | 없음 — 커뮤니티 스킬 사용 | 1 | 4 도메인 (공동) |
우연이 아닐 이유가 있다.
Angular(시그널 · zoneless · standalone · @if)와 Svelte 5(룬)는 최근 API가 가장 크게 바뀐 둘이다.
학습 데이터에는 옛 문법이 압도적으로 많다. 그래서 공식 도구를 만들었고, 그 도구가 정확히 그 격차를 메운다.
React 19와 Vue 3는 그런 도구 없이도 모델이 아는 범위 안에 있다.
반례 — 도구가 정확도를 보장하지는 않았다.
1차에서 공식 스킬을 받은 둘이 토큰을 30~53% 더 쓰고, 둘 다 색상 버그를 출하했다. 도구는 최신 API를 맞추는 데 값을 했지 명세를 지키는 데는 하지 않았다. 그리고 프레임워크당 N=1이며, Angular의 승리는 DI 때문일 수도, Svelte의 승리는 자유도 때문일 수도 있다.
혼자 AI로 개발할 때 가장 중요한 축은 속도도 비용도 아니다. AI가 낸 변경이 무엇을 깨뜨렸는지 30초 안에 아는가이다.
| 프레임워크 | 프레임워크 없이 로직 테스트 | 구조 위반이 빌드를 막는가 | 8차 단위 테스트 |
|---|---|---|---|
| Angular | 215개 · 0.2초 | ✓tsconfig.domain.json + 경계 스크립트 | 271 |
| React | 도메인 패키지 0개 | ✓ 8차에 허용 목록이 빈 집합이 됨 | 222 |
| Vue | ✓ | ✓ 아키텍처 테스트 | 347 |
| Svelte | 룬을 넣으면 깨짐 | ✓ vitest 프로젝트 분리 | 799 |
그런데 이 축에서 프레임워크 차이보다 큰 것이 하나 있었다 — E2E를 계약으로 얼리는 것.
4차부터 E2E 스펙의 해시를 떠 동결하고 라운드 종료 후 대조했다. 그 결과가 4~8차 회귀 실격 0건이고, 이건 네 프레임워크에서 똑같이 성립했다. Angular 8차가 왜 그게 결정적이었는지 말한다 — “빌드를 아홉 번 만들고 변경 여섯 개를 넣었다 뺐다 하면서 매번 28.9초에 행동 불변을 확인할 수 있었기에, 큰 구조 변경을 아예 구현해 보고 되돌리는 일이 가능했다.”
프레임워크는 바꿀 수 있다. AI가 무엇을 부쉈는지 모르는 상태는 되돌릴 수 없다.
여기까지는 여덟 라운드의 행동을 봤다. 이 절은 남은 결과물을 본다 — 물려받아 AI와 계속 작업한다면 어느 쪽이 덜 저항하는가. 전체 소스 29,153줄 + 테스트 13,368줄 중 약 2,500줄(6%)을 정독하고, 나머지는 전수 지표로 쟀다. 정독한 것은 네 앱의 같은 지점이다 — 전송·취소·경합 순수 로직, 경계 검사기, 저장소 어댑터, 환경 편집 화면.
먼저 — 넷 다 공통으로 깨끗하다. 순위는 이 위에서 갈린다.
| 기술부채 표식 | React | Vue | Svelte | Angular |
|---|---|---|---|---|
any | 0 | 0 | 0 | 0 |
@ts-ignore / @ts-expect-error | 0 | 0 | 0 | 0 |
| TODO / FIXME / HACK | 0 | 0 | 0 | 0 |
| lint 억제 주석 | 0 | 0 | 0 | 0 |
non-null 단언 ! | 0 | 1 | 0 | 0 |
여덟 라운드를 돌고 42,521줄에서 부채 표식이 사실상 0이다. 자작 코드량도 거의 같다 — React의 8,178줄 중 약 1,400줄이 shadcn 벤더링(생성 파일 17개)이라 빼면 넷 다 6,800~7,200줄이다.
5차에 실제로 추가했던 secret 필드를 기준으로, 그 개념이 닿아 있는 범위를 전수 측정했다.
AI 작업에서 가장 직접적인 비용이고, 여기서 앞 절의 평가가 한 번 뒤집힌다.
| 프레임워크 | 파일 | 총 줄 수 | 언급 줄 | 파일당 평균 | 같이 움직인 테스트 |
|---|---|---|---|---|---|
| Vue | 6 | 795 | 38 | 132 | 1 |
| React | 5 | 1,037 | 37 | 207 | 3 |
| Angular | 4 | 1,053 | 21 | 263 | 4 |
| Svelte | 10 | 1,115 | 45 | 111 | 9 |
Svelte의 잘게 쪼갠 구조가 교차 변경에서는 가장 비싸다.
필드 하나에 소스 10 + 테스트 9 = 19개 파일이 움직인다. 넷 중 최악이다. 앞 절에서 자산으로 꼽은 테스트 밀도 0.56이 동시에 변경 비용이라는 뜻이다. 반대로 Angular는 개념이 4파일 21줄에 가장 응축돼 있는데, 그 4파일이 평균 263줄이다.
즉 “적게 읽기”(Vue) · “적은 파일만 일관되게 유지하기”(Angular) · “작은 파일만 편집하기”(Svelte)가 서로 다른 답이다. 하나의 “좋은 구조”가 셋을 동시에 주지 않는다.
스냅샷 간 diff로 전수 측정했다. R5→R6은 네 구현의 과제가 기능 3개로 완전히 같았던 라운드라
이것만 직접 비교할 수 있다.
| 프레임워크 | R5→R6 동일 과제 | R6→R7 자유 | R7→R8 성능 |
|---|---|---|---|
| React | 16 | 37 | 16 |
| Angular | 17 | 10 | 1 |
| Vue | 20 | 55 | 3 |
| Svelte | 20 | 31 | 2 |
동일 과제에서도 React·Angular가 Vue·Svelte보다 약 20% 적은 파일로 끝냈다. R6→R7과 R7→R8은 작업 범위를 각자 정한 라운드라 이 수치로 우열을 가릴 수 없다 — Angular의 1은 큰 구조 변경을 구현했다가 전부 되돌린 결과다.
전수 측정이다. 굵게 표시한 것이 그 축의 1위, 붉은 것이 꼴찌다.
| 축 | 왜 AI 작업에 중요한가 | React | Vue | Svelte | Angular |
|---|---|---|---|---|---|
| 최대 파일 | AI가 여는 최악의 편집 창. 무관한 코드가 같이 들어올수록 부술 위험이 크다 | 359 | 284 | 238 | 422 |
| 편집 모호성 | 파일 안에서 같은 줄이 반복되면 정확히 집기 어렵다 (20자 이상 비자명 줄 기준) | 4% | 6% | 3% | 5% |
| import 팬아웃 | 파일 하나를 이해하려고 따라가야 하는 가지 수 (평균) | 2.9 | 3.9 | 2.5 | 4.4 |
| 최악 파일의 import | 가장 결합된 파일 하나 | 11 | 20 | 11 | 17 |
| 근거를 인용한 주석 | AI가 “왜 이렇게 돼 있지? 정리해야지” 하는 순간을 막는다. 기획서 § · 라운드 번호 · 불변식 ID 인용 비율 | 10% | 9% | 16% | 15% |
| 커스텀 공개 심볼 | AI가 새로 배워야 하는 앱 고유 표면. 표준 관용구가 아닌 것 | 282 | 227 | 231 | 228 |
| 기능 발자국 | 한 화면(환경 편집)이 차지하는 총량 | 841 | 847 | 621 | 770 |
| 피드백 루프 | pnpm test 벽시계 시간 — AI가 “안 부쉈다”를 확인하는 주기 |
3초 | 2초 | 3초 | 4초 |
피드백 루프는 넷 다 2~4초로 사실상 동률이다 — 그리고 그게 이 표에서 가장 중요한 줄이다.
이건 프레임워크가 준 것이 아니라 순수 계층 분리가 준 것이고, 네 구현 모두 해냈다. 프레임워크 없이 도는 테스트 비율은 Svelte 93% · Vue 82% · Angular 79% · React 73%다 (각각 742/799 · 283/347 · 215/271 · 163/222). AI에게 “알아서 고쳐봐”라고 말할 수 있게 해 주는 것이 정확히 이 숫자다.
| 발견 | 내용 | AI 작업에 미치는 영향 |
|---|---|---|
| 저장소 어댑터 — Angular만 완전히 일반화 |
IdbDocumentStore는 업무 타입을 하나도 모르고 스키마를 슬라이스들이 StoreSpec으로 제출한다.
React는 다섯 스토어 이름이 전부 shared/api/db/schema.ts에 있다 — 주석이 그 긴장을 스스로 인정한다:
“FSD에서 그 인터페이스는 살아남을 수 없었다. shared는 그것들 중 무엇도 알면 안 된다.”
타입 결합은 풀었는데 스키마 결합은 안 풀렸다. |
엔티티를 하나 추가할 때 Angular는 자기 슬라이스에 한 줄, React는 공용 파일을 가로질러 고쳐야 한다. AI가 가장 자주 빠뜨리는 종류의 변경이다. |
| 같은 화면의 분해도가 극단적으로 다르다 |
환경 편집 화면 — Svelte 621줄/11파일(최대 208) vs Angular 770줄/4파일(최대 422 = 기능의 55%). 다만 Angular의 422줄 중 155줄은 인라인 템플릿+스타일이라 순수 비대는 아니다. 페이지+폼 기준으로는 Angular 422 · React 359 · Vue 354(2파일) · Svelte 253(2파일). | “변수 행에 필드 하나 추가해줘” 할 때 Vue·Svelte는 197·208줄 폼 파일만 열고, React·Angular는 목록·선택·삭제확인이 같이 든 359·422줄 파일을 연다. |
| 경계 검사기가 자기 공회전을 막는가 |
셋은 검사 대상이 비면 실패하도록 바닥을 박아 뒀다 —
React >80(“워커가 트리 일부를 못 보게 되면 규칙들이 실패 대신 조용히 초록이 된다”),
Vue >8(“침묵 통과는 무가치하다”), 그리고 Svelte는 한 발 더 나가
4차가 지키던 18개 파일보다 줄어들면 실패하는 래칫을 걸었다.
Angular에만 없다 — 파일 수를 출력만 하고 단언하지 않아, 워커가 망가지면
✔ 통과 (0개 파일)를 찍고 성공한다. |
AI가 리팩터링하다 검사기의 탐색 경로를 깨뜨리면, 셋은 빨갛게 죽고 Angular는 초록으로 통과한다. 강제 수단은 Angular가 가장 강한데(타입 검사기), 그 수단이 돌고 있는지를 확인하는 장치가 없다. |
| Angular에 AI가 밟기 쉬운 지뢰 하나 |
environments-page.ts의 formPulse(this.form)와
“dirty()의 값이 true로 머무는 동안 재계산이 생략되므로 폼을 직접 다시 읽는다” —
Reactive Forms가 시그널이 아니라서 만든 이음새다. |
AI가 “computed 안에서 폼을 직접 읽네? 정리해야지” 하면 조용히 깨진다. 주석이 있어 다행이지만, 다른 셋에는 이런 프레임워크 간극이 없다. |
반대로, 넷이 똑같이 잘한 것도 읽어서 확인했다 — 디스크에서 읽은 것은 타입이 아니다.
네 앱 모두 IndexedDB 값 타입을 unknown으로 두고, 복원 함수가 보증한 뒤에야 도메인 값이 되게 했다(I9).
Angular의 표현 — “디스크에서 읽은 것은 타입이 아니라 바이트다. 여기서 도메인 타입을 주장하면 그 거짓말이 I9을 무력화한다.”
그리고 전송·취소·경합의 핵심도 넷 다 같은 결론에 도달했다 — 티켓이 정확성을, abort가 대역폭을 맡는다.
가장 응축된 것은 React의 Exchanges 63줄로, 티켓 하나만 하고 포트를 콜백으로 주입받아
도메인이 AbortController라는 이름조차 모른다.
가장 강한 것은 Angular의 SendCommand로, private 생성자 때문에
“승인 없이 전송”을 타입 단계에서 표현할 수 없다.
“어느 쪽이 더 나쁘냐”가 아니라 “어느 쪽이 덜 저항하느냐”다.
4위인 React도 any 0에 이 연구 최고의 핵심 파일을 갖고 있다.
편집 단위에서 압도적이다. 팬아웃 2.5, 근거 인용 주석 16%, 기능 발자국 621줄 — 전부 1위. 경계 검사기가 넷 중 유일하게 자기 감시 범위가 줄어드는 것까지 막는다(v4 대비 래칫). 대가 — 교차 필드 하나에 소스 10 + 테스트 9가 움직인다.
응집과 강제가 최고다. tsconfig.domain.json이 @angular/*를
없는 모듈로 매핑해 정규식이 아니라 타입 검사기가 막는다.
대가 — 기능의 55%가 한 파일, 팬아웃 4.4(최고), 검사기가 0개 파일을 봐도 초록,
그리고 formPulse 지뢰.
AI가 읽을 총량이 가장 적다 — Svelte의 71%.
어느 레이어도 32%를 넘지 않는 유일한 앱이고, 여섯 레이어를 다 쓴다.
대가 — shared/domain/exchange.ts 284줄이 여섯 역할을 하고,
Exchange를 shared에 둔 배치가 약하다. 근거 인용 주석 9%로 최저.
가장 좋은 작은 코드, 가장 넓은 표면.
Exchanges 63줄은 이 연구 최고의 파일이고, 도메인이 어떤 패키지도 이름 부르지 않는 유일한 앱이다.
대가 — 검증의 27%가 jsdom을 타고, UI에만 8개 패키지 + shadcn 23파일을 직접 소유하며,
스키마가 shared에 중앙집중돼 엔티티 추가가 레이어를 가로지른다.
세 줄로 줄이면 — 하나의 “좋은 구조”가 셋을 동시에 주지 않는다.
AI가 편집할 파일이 작아야 한다면 Svelte — 최대 238줄, 모호성 3%, 93%가 프레임워크 없이 검증된다. 대신 교차 필드 변경이 19파일을 움직인다.
AI가 일관되게 유지할 파일이 적어야 한다면 Angular — 개념이 4파일 21줄에 응축되고 위반은 컴파일이 막는다. 대신 그중 하나가 422줄이고, 검사기가 공회전해도 초록을 찍는다.
AI가 읽을 총량이 적어야 한다면 Vue — 795줄, 테스트 1개. 대신 순수 파일 하나가 여섯 역할을 한다.
이 판정의 범위를 밝혀 둔다. 42,521줄 중 정독한 것은 약 2,500줄(6%)이다. 다만 네 앱에서 같은 지점을 읽었고(전송·취소 순수 로직 · 경계 검사기 · 저장소 어댑터 · 환경 화면), 위의 지표 여덟 개와 파급 범위·변경 국소성은 전수 측정이라 비교 자체는 공정하다. 나머지 94%에 못 본 문제가 있을 수 있다.
두 순위가 어긋나는 지점을 그대로 남겨 둔다.
라운드 성적으로 본 AI 적합성은 Svelte(위임) · Angular(지시) · React(싼 일반형) · Vue(함정)이고, 완성된 코드베이스로 본 순위는 Svelte · Angular · Vue · React다. 위 둘은 같고 아래 둘이 뒤바뀐다.
읽어 보면 이유가 분명하다 — React는 작업 비용이 싸고(누적 1.91M, 최저) Vue는 결과물이 낫다 (변경 파급 795줄 최소, 테스트 결합 1개, 레이어 균형 최고). React의 대가는 운영이 아니라 표면에 있다: 의존성 17개, 커스텀 심볼 282개, shadcn 23파일 직접 소유, 그리고 검증의 27%가 jsdom을 탄다. “싸게 만든다”와 “물려받기 좋다”가 같은 것이 아니라는 뜻이고, 둘 중 무엇이 중요한지는 프로젝트가 정한다.
그리고 구조는 처음부터 주는 편이 낫다 — 단, 확인되는 것만.
4차는 기능을 하나도 안 늘리고 프레임워크당 35만 토큰을 썼다. “구조를 옮기는 일은 기능을 만드는 일만큼 비싸다.” 그리고 7차에 강제를 전부 거두자 네 구현이 만장일치로 계층과 타입 게이트를 남기고 스타일 린터만 지웠다. 구조가 짐이었으면 그때 접었을 것이다.
기준은 “미리 하느냐”가 아니라 “어겼을 때 보이느냐”다.
살아남은 규칙(도메인이 프레임워크를 import하지 않는다 · 금지 전역을 모른다 · 결과를 타입으로 나눈다)은 전부
grep이나 tsc 한 번에 걸린다.
다섯 라운드 동안 피해를 낸 배럴 규칙은 실패가 번들 안에만 나타나서 아무도 보지 못했다.
이 목적에 대해 이 연구가 답하지 못한 것.
프레임워크당 N=1이다 — 순위 1~3점 차이는 노이즈와 구분되지 않는다.
에이전트 품질과 프레임워크 품질이 분리되지 않았다 — 보고서 문장의 날카로움 차이는 프레임워크 속성이 아닐 가능성이 크다.
검증 가능한 사실(PrimeVue 0.38 ms · Vue 프로파일러 · Angular 코어 112 kB · 룬 용접 · React 19 window.load)만 프레임워크 사실로 쓸 것.
모델이 하나다. 특히 “학습 데이터가 많아 React가 유리하다”는 모델이 바뀌면 달라질 수 있는 항목이다. 앱도 하나다 — 백엔드 없는 5화면. SSR · 대용량 데이터 · 실시간 · 팀 협업은 전혀 재지 않았다.
그리고 가장 큰 공백 — 긴 세션에서의 붕괴를 재지 않았다. 각 라운드는 독립 실행이었고, “AI가 100턴째에 자기 구조를 잊는가”는 측정하지 않았다. 실제 개인 개발에서 가장 자주 일어나는 일인데 이 연구에는 빠져 있다.
비용은 6차 한 번만 점수에 들어갔다 — 네 구현의 과제가 기능 3개로 완전히 같았던 유일한 라운드였기 때문이다. 나머지 일곱 라운드에서는 재기만 하고 순위에 쓰지 않았다. 막대 하나가 한 라운드의 토큰 소모량이고, 높이는 네 줄 전체에서 같은 축을 쓴다.
5차는 다른 라운드와 직접 비교할 수 없다 — 세션 한도로 네 에이전트가 모두 중단됐다가 이어서 진행했고, 막대에 들어간 것은 재개 이후 구간만이다. 중단 시점까지 각 20분 안팎이 더 들었다. 중단 당시 E2E 계약과 위상 분리(B단계 미착수)가 온전한 것을 확인하고 재개했다.
| 모양 | 읽는 법 |
|---|---|
| 3~5차가 가장 높다 | 생태계·DDD·FSD — 구조를 옮긴 세 라운드가 네 줄 모두에서 봉우리다. 4차는 기능 추가가 하나도 없는 순수 리팩터링이었는데 3차와 비슷한 비용이 들었다. 구조를 옮기는 일은 기능을 만드는 일만큼 비싸다. |
| 6~7차에서 내려온다 | 처방이 줄어든 두 라운드다. 기획서가 204줄 → 123줄로 짧아지는 동안 비용도 같이 내려갔다. 지시가 길수록 비싸다. |
| 8차에서 다시 오른다 — 그런데 diff는 가장 작다 | 여덟 중 가장 비싼 라운드인데 src/ 변경은 1·2·3·16파일뿐이다. Angular는 최대 비용(416K·190회)으로 한 줄을 남겼다. 성능 라운드에서 비싼 것은 구현이 아니라 “고치지 않는다”를 근거 있게 말하기 위한 측정이다. |
| Angular가 여덟 중 다섯 라운드에서 가장 비싸다 | 누적 2,283,600 토큰으로 최다이며, 가장 싼 React보다 1.20배다. 2차에 이 문서가 “감점할까” 물었던 그 차이가 여덟 라운드 내내 유지됐다. 감점하지 않은 것은 옳았다 — 4·5·6·8차 1위가 같은 구현이다. |
합계 8,418,867 토큰 · 에이전트 실행 시간 14:58:27.
여기에 이 문서가 라운드마다 직접 돌린 검증(빌드·단위·E2E·grep·diff·스냅샷)은 들어 있지 않다. 산출물은 앱 네 개, 기획서 여덟 부, 프레임워크별 프롬프트 32종, 스냅샷 일곱 벌이다.
비용과 품질은 여덟 라운드 내내 같은 방향으로 가지 않았다. 6차에서 가장 오래 걸린 Svelte가 구현 품질 1위였고, 7차에서 가장 오래 걸린 Svelte가 다섯 라운드 묵은 순수성 구멍을 찾았으며, 8차에서 가장 비싼 Angular가 가장 작은 diff를 냈다. 싸게 끝났다는 것은 문제가 없었다는 뜻일 수도, 찾지 않았다는 뜻일 수도 있다.
tsc -b · vue-tsc · svelte-check · ng build. 네 개 모두 0 에러.disabled이고, Angular는 1차에 잡은 status !== 0 분기와 'Unknown Error' 정규화를 보존했다.grep으로 검증했다. React·Vue·Svelte는 import 0건, Angular는 core/ 11모듈 중 5개가 @angular에 의존한다.round2-snapshot/과의 파일 단위 diff로 측정했다.node_modules의 package.json을 직접 읽어 검증했다.e2e/ 디렉토리 diff 0건, 의존성 변화 0).Date·fetch·localStorage 등)을 직접 grep해 검증했다 — 걸린 항목은 전부 주석이었고 실사용 0건..vue·.svelte는 정의상 결합으로 처리).secret 기능의 실제 발자국을 grep으로 독립 측정하고(필드명·마스크 문자열을 기획서에 고정해 둔 이유), 자체 보고와 대조했다.shared 비중을 직접 재고, 배럴 파일을 열어 공개 API인지 구현 파일인지 확인했다.diff해 대조했다 — package.json은 네 앱 모두 바이트 동일, 설정 변경 2건은 자진 신고와 일치(새 억제 없이 glob 확장).helpers.ts와 팔레트 마크업을 읽어 원인을 특정했다 (보고서의 “무관한 플레이크” 판단은 사실과 달랐다).steiger 제거를 설정 파일과 package.json 양쪽에서 확인하고, 의존성 증감을 6차 스냅샷과 대조했다.round6-snapshot의 import 체인을 따라가 직접 확인했다 — 네 파일이 배럴을 통해 api/db(IndexedDB)에 닿아 있었다.round7-snapshot과 디렉토리 diff해 네 앱 모두 바이트 동일을 확인하고, 59개를 직접 실행했다 (10·15·16·18 전부 통과).978.56 kB / 245.50 kB gzip은 소수점까지 일치했고, React의 dist에서 zod 문자열은 0건이었다.src/ 변경 범위를 스냅샷 diff로 독립 측정했다 (1·2·3·16파일) — Angular가 “구현했다가 전부 되돌렸다”고 보고한 대규모 구조 분리의 흔적이 하나도 남지 않은 것을 이렇게 확인했다.formSchema가 z.custom() + superRefine으로 도메인 이슈를 재방출만 하던 순수 전송 수단이었고, 새 domainResolver가 zodResolver의 경로 중첩과 firstError 의미를 그대로 복제한다.any·@ts-ignore·TODO·lint 억제·non-null 단언을 전수 grep했다 (합계 1건).secret 필드를 추적자로 써서 파일 수·총 줄 수·언급 줄·연동 테스트를 전수 측정했다 — 가상의 변경이 아니라 이미 일어난 변경이라 발자국이 실측된다.diff -rq로 셌고, 과제가 완전히 같았던 R5→R6만 직접 비교에 썼다.import 문 수로 쟀다. 주석의 근거 인용률은 prd/·§·라운드 번호·불변식 ID를 인용한 주석 비율이다.round1-snapshot/ ~ round7-snapshot/으로 보존해 비교 근거를 남겼다.