https://dev.blog.sellmate.co.kr/post/prototype-feedback-system-1/
해당 글에서 이어지는 내용입니다.
5. 왜 HTML의 성능을 따라잡을 수 없었나
React는 애초에 “한 번에 다 그리지 않으려고” 만들어졌다
4장에서 나는 하나의 기능을 찾고 있었다. 링크를 열자마자 React가 모든 화면의 DOM을 한 번에 다 지어놓고 멈춰주는 방법. 그게 왜 없는지는 React가 왜 만들어졌는지를 보면 답이 나온다.
React 이전의 방식을 떠올려보자. JS로 DOM을 직접 조작하는 방식이다. 함수를 실행하면 HTML에 DOM 객체가 추가되고 지워지고 값이 바뀐다. 데이터가 바뀔 때마다 개발자가 직접 이번엔 어느 DOM을 어떻게 고칠지를 계산해서 하나하나 손댄다. 화면이 단순할 때는 이게 문제가 안 된다. 그런데 화면이 복잡해지면 데이터와 화면이 어긋나기 시작한다. 데이터는 바뀌었는데 고치는 걸 빼먹은 화면 한 구석에만 옛날 값이 남아 있거나 갱신 순서가 꼬여서 앞뒤가 안 맞는 상태가 된다. 이렇듯 고칠 곳을 사람이 추적하는 한 화면이 커질수록 빼먹는 곳도 늘어난다.

React의 해법은 이랬다. “무엇을 고칠지 개발자가 일일이 계산하지 마라. 상태가 바뀌면 화면 전체를 다시 선언해라. 실제로 무엇을 고칠지는 프레임워크가 차이를 계산해서 달라진 부분만 바꾸겠다.”

이걸 한 줄로 요약한 것이 그 유명한 식이다.
| |
화면은 데이터가 아니라 함수의 결과값이다. 함수는 입력이 주어져야 값을 내놓는다. 그러니 그 화면 상태에 도달한 적이 없다면 그 화면의 DOM은 아직 계산되지 않은 값이다. 어딘가에 숨어 있는 게 아니라 존재한 적이 없다.
쉽게 말해 계산기다. 계산기 안에 세상 모든 덧셈의 답이 미리 저장되어 있는 게 아니다. 3 + 5 = 를 눌러야 그 순간 8이 만들어진다. 누르기 전의 8은 계산기 어딘가에 숨어 있는 게 아니라 아직 세상에 없는 값이다. 그리고 다음 계산을 시작하면 방금의 8도 지워진다.
4장에서 나는 이걸 건물에 비유했다. 3층은 누가 올라가려고 해야 그때 지어진다고. 절반만 맞은 비유였다. 건물은 한번 지으면 남지만 계산 결과는 다음 계산이 시작되면 사라진다. 설정 화면의 DOM은 어딘가에 지어져서 나를 기다리고 있던 게 아니라 그 화면 상태를 입력해야 그때 계산되어 나오는 결과 값이었다. 나는 아직 눌리지 않은 계산의 답을 찾고 있었던 것이다. 여기서 나는 깨달을 수 있었다. React가 존재하는 이유가 “필요한 것만, 필요한 때에, 조금씩” 이라는 것을 말이다. 내가 찾던 “전부, 한 번에, 지금” 은 그 정반대였다.
그래서 안 되었던 것이다. 그 기능은 아직 안 만들어진 것도, 내가 못 찾은 것도 아니었다. 그렇게 안 되는 것이 React의 설계 목적 그 자체였다. React에서 DOM을 한 번에 다 그리는 방법을 찾는 건 자동차에서 바퀴를 빼고 더 빨리 달리는 법을 찾는 일이었다.
4장에서 모든 화면의 목록을 아무도 모른다고 했던 것도 결국 같은 이야기다. 목록을 못 구하는 게 아니라 목록이라는 개념이 성립하지 않는다. 함수가 낼 수 있는 모든 출력값의 목록을 미리 뽑아달라는 요구이기 때문이다.
정리하면 이렇다. HTML은 문서이고, React 앱은 프로그램이다. 4장에서 HTML 프로토타입은 파싱하는 순간 트리가 통째로 존재하니 언제 물어봐도 같은 답이 나온다고 했다. 문서라서 그렇다. 문서에는 끝이 있다 — 브라우저는 파일을 다 읽으면 DOMContentLoaded, “다 폈다"는 신호를 보낸다. 프로그램에는 그런 끝이 없다. 이 문장 하나가 내가 겪은 모든 문제의 뿌리다.
flowchart TB
subgraph REACT["React 앱 — 지금 나는 소리"]
direction TB
R1["div#root — 비어 있음"] --> R2["JS 실행"]
R2 --> R3["현재 상태"]
R3 --> R4["이 상태의 화면을 통째로 다시 선언"]
R4 --> R5["이전 화면과 무엇이 다른지 비교"]
R5 --> R6["달라진 부분만 실제 DOM에 반영"]
R6 --> R7(["도달한 상태의 DOM만 존재
= 끝났다는 신호가 없음"])
R7 -. "상태가 바뀌면 다시" .-> R3
end
subgraph HTML["HTML 문서 — 다 인쇄된 책"]
direction TB
H1["파일을 처음부터 끝까지 읽는다"] --> H2["태그를 하나씩 노드로 만든다"]
H2 --> H3["문서 전체의 트리가 완성된다"]
H3 --> H4(["DOMContentLoaded
= 다 폈다는 신호"])
H4 --> H5["언제 물어봐도 같은 답"]
end이 관점에서 보면 내 앵커에 무엇이 빠져 있었는지도 보인다. CSS 선택자나 XPath는 변하지 않는 문서를 뒤지라고 만든 언어다. 이 조건에 맞는 노드를 가리킬 뿐 언제 그런지를 말하는 문법 자체가 없다. 문서에서는 말할 필요가 없던 것이 노드가 생겼다 사라졌다 하는 프로그램 안에서는 필수 좌표가 된다. 프로그램을 가리키는 주소는 이렇게 생겨야 했다.
| |
state는 4장에서 뒤늦게 붙였다. 문제는 t다. 이건 내가 정할 수 있는 값이 아니었다.
“이제 다 그려졌나?“는 밖에서 알 수 없다
4장에서 근거 없는 숫자 하나를 코드에 박아 넣으며 찜찜했다고 했다. 그 숫자가 바로 t의 자리에 들어간 임시값이었다. 그리고 그 찜찜함의 정체를 이제 말할 수 있다. 근거를 못 찾은 게 아니라 근거가 존재할 수 없는 문제였다. DOM이 변하는 걸 지켜보는 도구는 있다. MutationObserver를 걸어두면 DOM이 바뀔 때마다 알려준다. 그런데 이건 “방금 바뀌었다” 를 알려주는 것이지, “이제 안 바뀐다” 를 알려주는 게 아니다.
이 둘은 전혀 다른 문제다.
sequenceDiagram
autonumber
participant T as 도구 · 앵커 탐색
participant R as React 앱
participant M as MutationObserver
T->>R: 저장된 화면 상태로 이동
R->>R: 다시 그리기 → 반영 → 화면에 칠하기
R-->>M: DOM 변경 알림 계속 발생
Note over M: 100ms 동안 변경 없음
M-->>T: 이제 안정된 것으로 보임
T->>R: 앵커 탐색 → 포인트 표시
R->>R: 뒤늦게 API 응답 도착
R-->>M: 목록 다시 그려짐 → 버튼이 아래로 밀림
Note over T: 표시된 포인트가 어긋남
안 변한다는 판정 자체가 불가능누군가 말을 다 끝냈는지 어떻게 알 수 있을까? 말을 하다 3초 동안 조용했다고 말이 끝난 게 아니다. 생각 중일 수도 있다. 확실히 알려면 본인이 “다 말했어"라고 해줘야 한다.
React 앱은 위의 예시처럼 끝났다는 말을 해주지 않는다. 그리고 밖에서 지켜보는 쪽은 아무리 오래 봐도 알아낼 수 없다. 100ms 동안 아무 동작이 없는 것은 200ms 뒤에 API 응답이 도착해 목록이 다시 그려지지 않으리라고 보장하지 못한다. 그걸 확실히 알려면 그 앱이 앞으로 무슨 일을 할지 미리 알아야 하는데 임의의 프로그램의 행동에 관한 질문에 항상 정확히 답하는 알고리즘은 존재할 수 없다는 것이 계산 이론의 오래된 답이다. 그래서 실무의 대기 시간에는 정답이 있을 수 없다. 짧게 잡으면 뒤늦은 변경에 당하고 길게 잡으면 매번 그만큼을 허공에 버린다. 어느 값을 고르든 그건 판단이 아니라 예측이다. 왜냐하면 DOMContentLoaded처럼 “다 폈다"고 문서가 직접 말해주는 신호가 존재할 수 없기 때문이다.
느렸던 건 렌더링이 아니라 확신이었다
이제 이번 장의 주제에 답을 찾아 볼 것이다. 왜 React 쪽 복원은 HTML보다 눈에 띄게 느렸는가.
React가 느리게 그려서가 아니다. 요즘 기기에서 화면 하나 그리는 데 걸리는 시간은 HTML이든 React든 체감할 수준이 아니다. 차이는 그리는 속도가 아니라 앵커를 찍어도 된다는 확신이 오는 시점에 있었다. HTML 쪽 복원에 드는 시간은 사실상 하나다. “다 폈다"는 신호를 기다리는 시간. 신호가 오면 그 즉시 찍는다.
React 쪽은 셋이 항상 합산된다.
- 저장된 화면 상태까지 앱을 이동시키는 시간
- 신호가 없어서 대신 넣은 근거 없는 대기 시간
- 그래도 불안해서 얹은 안전 마진
그리고 2번과 3번은 줄일 수가 없다. 짧게 잡으면 앞 절에서 본 것처럼 뒤늦은 API 응답에 포인트가 밀려나고 그게 무서우니 넉넉하게 잡게 된다. 끝났다는 걸 모르면 끝났어도 기다려야 한다. 내가 본 그 잠깐의 멈칫은 렌더링 시간이 아니라 도구가 확신 없이 서성이는 시간이었다. 그러니 이 지연은 최적화 대상이 아니었다. 아무리 코드를 다듬어도 밖에서 추측하는 구조인 한 저 대기 시간은 사라지지 않는다.
6. 그런데 여기서 진짜 문제를 발견했다
기술적으로는 여기까지가 결론이다. 그런데 이 지점에서 뒤통수를 맞은 듯한 깨달음이 왔다.
나는 언제부터 렌더링 성능 이야기를 하고 있었지?

이 도구를 만든 이유는 명확했다.
프로토타입에 대한 피드백을 편하게 주고받고 버전별로 기록해서 검증 루프를 빠르게 돌리기 위해.
그런데 어느 순간 내 관심사는 이렇게 바뀌어 있었다.
React 렌더링 완료 시점을 어떻게 감지할 것인가. portal로 붙는 모달의 앵커를 어떻게 안정화할 것인가. HTML 대비 복원 지연을 몇 ms까지 줄일 수 있는가.
수단이 목적을 잠식했다.
한 걸음씩은 전부 합리적이었다
어쩌다 여기까지 왔는지 되짚어보면, 이상한 점이 하나 있다. 어느 한 걸음도 틀린 결정이 아니었다.
피드백을 제대로 받으려면 프로토타입이 실물에 가까워야 한다 ⇒ 맞는 말이다.
실물에 가까우려면 디자인 시스템을 써야 한다 ⇒ 맞는 말이다.
디자인 시스템이 React로 되어 있으니 React를 지원해야 한다 ⇒ 맞는 말이다.
React에서 앵커가 흔들리니 고쳐야 한다 ⇒ 맞는 말이다.
그런데 이 맞는 말들을 다 따라간 끝에 도착한 곳은 렌더링 타이밍을 몇 ms 줄일지 고민하는 자리였다.
flowchart TB
GOAL["목적: 피드백을 편하게 주고받고
검증 루프를 빠르게"] --> TOOL["도구를 만든다"]
TOOL --> REAL["더 실물에 가깝게
= 디자인 시스템 적용"]
REAL --> DS["디자인 시스템이 React 네이티브
→ React 지원 필요"]
DS --> ANC["React는 앵커가 불안정
→ 고쳐야 함"]
ANC --> OPT["렌더링 타이밍 최적화에 몰두"]
OPT -. "어느새 목적과 무관한 자리" .-> GOAL각 단계에서 나는 바로 앞의 문제만 봤다. 앞의 문제를 해결하면 다음 문제가 나왔고 또 해결해 나갔었다. 그러나 한 번도 처음의 목적에 대해 상기하지 않았었다. 방향을 잃는 건 이렇게 일어난다. 크게 꺾이는 순간은 없다. 조금씩 옳은 쪽으로 틀다가 어느새 전혀 다른 곳을 보고 있는 것이다.
그렇게 정신을 차리고, 이번에는 눈앞의 문제가 아니라 프로젝트 전체를 처음부터 다시 훑어봤다.
그러자 문제가 하나 더 눈에 들어왔다.
7. 그리고 또 하나 — 프론트를 다 만들고 백엔드를 붙이려 했다
사실 이 프로젝트에는 처음부터 깔려 있던 계획이 하나 더 있었다.
프론트 화면을 전부 구성한 다음, 거기에 맞춰 백엔드를 구현한다.
꽤 합리적으로 들렸다. 화면이 확정되면 필요한 데이터가 자동으로 도출되니까. 게다가 이건 프로토타입 도구 아닌가. 화면이 주인공인 물건이다. 진척도 눈에 잘 보인다.
6장과 똑같다. 한 걸음씩은 합리적이다. 그런데 이 순서에는 함정이 있다.

함정: 의존성이 거꾸로 흐른다
화면을 먼저 확정하고 그에 맞춰 백엔드를 만들면 자연스럽게 이런 일이 벌어진다.
- 화면이 보여주는 필드 조합이 그대로 API 응답 스키마가 된다.
- 화면의 정렬·필터·페이징 방식이 그대로 쿼리에 반영된다.
- 엔드포인트가 도메인이 아니라 화면 단위로 생긴다.
이렇게 되면 백엔드가 UI에 의존하는 시스템이 완성된다. 그리고 그 순간부터 이런 연쇄가 시작된다.
디자이너가 패널에서 필드 하나를 뺀다 → API 응답 스키마 수정 → 쿼리 수정 → 테이블 구조 검토 → 마이그레이션
UI를 손볼 때마다 시스템 안쪽까지 파장이 도달한다. 필드 하나를 빼는 사소한 변경이 스키마, 쿼리, 테이블 수정으로 불어나는 것이다. 이게 어쩌다 한 번 일어나는 사고가 아니라 구조가 보장하는 일상이 된다.
문제의 본질: 변경 주기가 다른 것을 같이 묶었다
핵심은 UI와 도메인의 변경 주기가 근본적으로 다르다는 데 있다.
| 변경 주기 | 성격 | |
|---|---|---|
| UI | 주 단위 ~ 월 단위 | 피드백에 반응해 계속 바뀐다. 바뀌는 게 정상이고, 오히려 자주 바뀌어야 좋다 |
| 도메인 모델 | 분기 단위 ~ 연 단위 | 프로토타입, 버전, 앵커, 스레드, 코멘트. 개념 자체는 좀처럼 변하지 않는다 |
그렇다면 경계는 기능이 아니라 변동성을 기준으로 그어야 한다. 자주 바뀌는 것끼리 모아두고 그 변화가 바깥으로 새어나가지 않게 감추는 것이다. UI는 변동성이 가장 높은 부분이다. 그런데 그걸 시스템의 기반으로 삼는 건 가장 흔들리는 것 위에 가장 안 흔들려야 할 것을 올려놓는 셈이다.
원칙: 안정된 쪽에 기대야 한다
Robert C. Martin이 쓴 《Clean Architecture》라는 책에서는 이걸 안정 의존성 원칙(Stable Dependencies Principle) 이라고 부른다.
의존은 안정성이 높은 방향으로 흘러야 한다.
불안정한 것이 안정된 것에 의존해야지 그 반대가 되면 안정된 쪽이 불안정한 쪽에 인질로 잡힌다.
flowchart TB
subgraph OK["올바른 방향 — 의존성 역전"]
direction TB
O1["UI (변동성 최상)"] --> O2["뷰 조립 계층 / BFF
(자주 바뀌어도 되는 곳)"]
O2 --> O3["도메인 모델
프로토타입·버전·앵커·스레드"]
O4["DB 어댑터"] --> O3
O5["UI 변경은
바깥 두 겹에서 흡수됨"]
end
subgraph NG["잘못된 방향 — 화면 우선 설계"]
direction TB
N1["UI (변동성 최상)"] --> N2["API 스키마
화면 단위로 설계됨"]
N2 --> N3["도메인 모델"]
N3 --> N4["DB 스키마"]
N5["UI를 바꾸면
DB까지 파장이 도달"]
endClean Architecture의 표현을 빌리면 UI와 DB는 ‘세부사항(detail)’ 이다. 여기서 정책(policy) 은 시스템이 지켜야 할 규칙이다. “주문은 결제가 끝나야 성립한다”, “배송이 시작되면 취소할 수 없다” 같은 것들. 세부사항은 그 규칙을 어떻게 보여주고 어디에 저장하느냐다. 주문 화면이 목록형에서 카드형으로 바뀌어도 DB를 다른 제품으로 갈아타도 저 규칙들은 그대로다. 그래서 규칙이 화면이나 저장 방식에 의존해서는 안 된다. 화면은 도메인이 표현되는 여러 방식 중 하나일 뿐 도메인 그 자체가 아니다.
프로토타입을 청사진으로 착각하지 않기
이 함정이 특히 위험했던 이유는 내가 다른 것도 아닌 프로토타입을 그 설계 근거로 쓰려 했다는 점이다.
“어차피 하나는 버리게 된다. 그러니 버릴 것을 계획하라(plan to throw one away).” 소프트웨어 공학의 고전인 《The Mythical Man-Month(맨먼스 미신)》에서 Frederick Brooks가 남긴 유명한 격언이다. Brooks 본인은 이후 프로토타입을 통째로 버리기엔 비용이 너무 크니 점진적으로 다듬어가라며 이 조언을 철회했지만 그래서 오히려 더 위험해졌다. 점진적으로 다듬는 개발에는 버린다는 명시적인 순간이 없다. 검증용 화면이 폐기되는 대신 조금씩 다듬어지다가 어느새 응답 스키마가 되고 테이블이 된다. 내가 하려던 “화면 다 만들고 백엔드 붙이기"가 정확히 그 경로였다. 버린다는 순간이 없다면 대신 필요한 건 무엇을 남길지 미리 정해두는 일이다.

프로토타입이 남겨야 하는 산출물은 화면이 아니다. 검증된 개념이다.
화면은 사라져도 개념은 남는다. 그리고 그 개념이 도메인 모델이 되어야 한다.
8. 남는 것
이 프로젝트에서 얻은 건 두 가지다.
첫째, 기술적으로.
DOM 트리가 뭔지도 모르고 시작한 프로젝트에서 브라우저 렌더링 파이프라인과 React 재조정 모델까지 파고들게 됐다. 만들지 않았으면 배우지 못했을 것들이다. 그 끝에 남은 결론은 이렇다.
HTML의 DOM과 React의 DOM은 이름만 같지 다른 물건이다. HTML의 DOM은 파일을 여는 순간 완성되어 계속 그 자리에 있는 것이고 React의 DOM은 그 화면에 도달해야 그때 계산되어 나오는 결과값이다. 그래서 “어느 노드"만 적어둔 주소는 React에서 통하지 않는다. “언제"가 빠져 있기 때문이다. 그리고 프레임워크가 일부러 그렇게 만들어둔 동작을 거슬러야만 풀리는 문제라면 우회로를 찾을 게 아니라 설계를 다시 봐야 한다는 것이었다.
돌아보니 같은 실수였다
글을 정리하다 보니 이 프로젝트에서 겪은 두 개의 큰 문제가 결국 비슷한 문제였다.
둘 다 변동성이 높은 것을 기준 삼아서 변동성이 낮아야 할 것을 만들고 있었다.
기준이 흔들리면, 그 위에 만든 것도 같이 흔들린다.
| # | 변동성이 낮아야 할 것 | 의존한 것 | 결과 |
|---|---|---|---|
| 1 | 스레드 앵커 | DOM 경로 | 화면이 다시 그려질 때마다 흔들림 |
| 2 | 서비스 백엔드 | 프로토타입의 화면 구조 | 화면을 고칠 때마다 DB까지 파장 |
변동성이 낮은 것을 찾아서 그쪽에 의존하게 만드는 것.
앵커는 렌더링 때마다 흔들리는 DOM 경로가 아니라 화면이 다시 그려져도 변하지 않는 식별자를 기준으로 삼았어야 했다. 백엔드도 다음 피드백에 갈아엎어질 화면이 아니라 화면이 열 번 바뀌어도 그대로인 “주문” 같은 개념을 기준으로 삼았어야 했다.
“무엇을 기준으로 삼을 것인가.” 설계에서 이 질문 하나가 생각보다 많은 것을 결정한다.
둘째, 해결하고 있는 문제가 원래 풀려던 문제인지 주기적으로 확인해야 한다.
기술적 난제는 중독성이 있다. 문제가 명확하고 진척이 눈에 보이고 풀면 기분이 좋다. 반면 “이게 정말 사용자에게 필요한가"라는 질문은 모호하고 불편하다. 그래서 손은 자연스럽게 명확하고 기분 좋은 기술 문제 쪽으로 간다.
이번 프로젝트에서 내가 정확히 그랬다. 개발중 렌더링 타이밍이라는 명확한 문제를 붙잡고 있는 동안 “피드백 주고받기가 편해졌는가"라는 원래 질문은 한 번도 꺼내지 않았다. 프로토타입은 가설을 빠르고 저렴하게 검증하려고 만드는 물건이다. 그런데 그 프로토타입을 위한 도구를 정작 아무 검증 없이 파고들며 만들고 있었던 것이다.
그래서 다음부터는 “이게 맞는 방향인가?” 를 다 만들고 나서가 아니라 만드는 동안 자주 물으려 한다.
