사내 프로토타입 피드백 도구를 만들면서 겪은 시행착오와, 그 과정에서 깨달은 것에 대한 기록

1. 시작은 Slack에 흩어진 HTML 파일들이었다
현재 제가 진행하고 있는 프로젝트와 팀의 프로토타입 공유 방식은 단순했다.
기획자나 개발자가 HTML로 프로토타입을 만들어 Slack에 파일로 올리고 확인한다. 끝.


flowchart LR
A["기획자
HTML 프로토타입 제작"] --> B["Slack에 파일 업로드"]
B --> C["며칠 뒤
'그 프로토타입 말인데요'"]
C --> D["Slack 검색
파일 재탐색"]
D --> E["수정할 위치를
말로 특정해야 함"]
E --> F1["장문의 Slack 메시지"]
E --> F2["결국 대면 미팅"]
F1 --> G["피드백은 시간순으로만 존재
버전 개념 없음"]
F2 --> G이 구조가 만든 불편은 크게 세 가지였다.
첫째, 프로토타입을 다시 찾는 비용.
다른 일을 하다가 “그 프로토타입 말인데요” 하는 이야기가 나오면, 그때부터 Slack을 거슬러 올라가며 파일을 검색해야 했다. 어느 채널이었는지, 몇 번째 버전이었는지부터 확인해야 대화가 시작됐다.
둘째, “여기 이 부분"을 말로 설명하는 비용.
수정 의견을 남기려면 위치를 특정해야 한다. 그런데 텍스트나 캡쳐 화면으로 위치를 특정하는 건 비효율적이었다. 슬랙과 프로토타입(html)을 두가지를 확인하면서 비교를 해야하기 때문이다. 결국 Slack 메시지가 길어지거나, 아니면 직접 만나서 화면을 같이 보며 이야기하게 된다. 비동기로 처리될 수 있었을 일이 동기 커뮤니케이션으로 승격되는 것이다.
셋째, 어느 버전에 대한 피드백이었는지가 남지 않는다.
나에게 프로토타입은 외부 미팅에 들고 나가는 물건이다. 보여주고, 피드백을 받고, 그걸 반영해 다음 버전을 만들고, 다음 미팅에서 다시 보여준다. 이 루프를 한 바퀴 돌 때마다 가설이 하나씩 걸러진다.
그래서 이 루프에서 진짜 자산은 프로토타입 파일 자체가 아니라 “v1에서 무엇이 지적됐고, v2에서 무엇이 달라졌는가” 라는 기록이다. 이게 있어야 “이 결정은 두 번의 미팅을 거쳐 이렇게 정해졌다"고 말할 수 있다.
그런데 Slack에는 버전이라는 개념이 없다. v1 파일과 v3 파일은 서로 다른 메시지, 서로 다른 스레드다. 둘이 같은 프로토타입의 1번과 3번이라는 사실을 아는 건 시스템이 아니라 사람의 기억뿐이다. v1에서 나온 지적이 v2에서 반영됐는지 확인하려면 두 스레드를 각각 찾아 눈으로 대조하는 수밖에 없다.
이건 외부 미팅만의 문제가 아니다. 팀 안에서 프로토타입을 돌려 보며 방향을 맞추는 과정도 구조가 같다. 만들고, 보여주고, 피드백을 받고, 고친다. 이 루프를 자주 돌수록 좋다는 게 애자일의 전제라면, 루프의 기록이 남지 않는 건 그 전제를 반쯤 포기하는 셈이다.
flowchart LR
P1["프로토타입 v1"] --> M1["미팅 · 피드백 수집"]
M1 --> P2["v2 · 반영 및 개선"]
P2 --> M2["미팅 · 재확인"]
M2 --> P3["v3"]
P3 --> M3["검증 완료"]
M1 -.-> R[("버전별 피드백 기록
= 검증의 근거")]
M2 -.-> R
M3 -.-> R그러려면 “어느 버전에서 어떤 피드백이 나왔는가” 가 기록으로 남아야 한다. 그런데 Slack의 파일 업로드와 스레드는 버전이라는 개념을 모른다. v1에 달린 피드백과 v3에 달린 피드백이 시간순으로만 섞여 있을 뿐이다. 그래서 만들기 시작했다.
프로토타입을 올리면, 그 화면 위에 직접 포인트를 찍고 스레드로 대화할 수 있고, 버전별로 피드백이 보존되는 도구.
2. 첫 번째 가설: “창 크기랑 x, y 좌표만 있으면 되는 거 아닌가?”
솔직하게 고백하자면, 나는 HTML과 CSS를 아주 오랫동안 만지지 않은 상태였다. 브라우저가 화면을 어떻게 그리는지에 대한 감각이 없었고, DOM 트리라는 개념도 제대로 몰랐다. 그래서 처음 세운 설계는 이랬다.
스레드를 찍을 때 그 사람의 화면 크기와 클릭 좌표 (x, y) 를 함께 저장한다.
나중에 다른 크기의 화면에서 열면, 화면 비율만큼 좌표를 스케일링해서 찍는다.
이 설계는 틀렸다. 그 이유에 있어서는 이어서 설명하겠다.
왜 그럴듯해 보였나
이 모델은 사실 틀린 게 아니다. 다만 다른 세계에서 맞는 모델이었다.
Figma, PowerPoint 이런 것들은 전부 캔버스 위에서 동작한다. 캔버스에서는 요소가 절대 좌표를 갖고 있고, 화면이 커지면 전체가 비례해서 확대된다. 좌표에 배율을 곱하는 것만으로 정확히 같은 지점을 가리킬 수 있다. 나는 무의식 중에 웹 페이지를 캔버스라고 가정하고 있었다. 이미지 위에 핀을 꽂는 문제라고 생각한 것이다.
그런데 웹 페이지는 사진이 아니다
여기서 내가 놓친 게 있다. 브라우저 창을 줄이는 건 화면을 축소하는 게 아니다.
사진을 반으로 줄이면 사진 안의 모든 것이 정확히 반이 된다. 사람 얼굴도, 배경도, 그 사이 거리도 전부. 그래서 “사진 왼쪽 위에서 오른쪽으로 30%, 아래로 50% 지점"이라고 기억해두면 사진 크기가 어떻게 바뀌어도 같은 곳을 찾을 수 있다.
웹 페이지는 그렇지 않다. 창이 좁아지면 브라우저는 축소하는 게 아니라, 지면을 처음부터 다시 짠다. 신문 편집자가 지면 폭이 줄었다고 기사 전체를 사진처럼 축소하지 않고, 글자 크기는 그대로 둔 채 단을 다시 나누고 사진 위치를 옮기는 것과 같다. 이 재배치 과정을 리플로우(reflow) 라고 부른다.
그래서 이런 일들이 벌어진다.
- 글자는 작아지지 않는다.
워드 문서에서 용지를 A4에서 B5로 바꿔본 적이 있다면 익숙할 것이다. 글씨가 작아지는 게 아니라 줄바꿈이 늘어나고 페이지 수가 많아진다. 웹도 똑같다. 폰트가 16px이면 창을 아무리 줄여도 16px이다. 대신 한 줄에 들어가던 문장이 두 줄이 되고, 세로 길이가 늘어난다. 여기가 결정적이다. 가로를 줄였는데 세로는 오히려 길어진다. 가로세로에 같은 배율을 곱하는 방식으로는 이걸 표현할 방법이 아예 없다. - 영역마다 반응이 다르다.
어떤 영역은 창이 커져도 일정 크기 이상으로는 안 늘어나고 양옆 여백만 넓어진다(max-width). 어떤 영역은 창을 따라 계속 늘어난다. 같은 페이지 안에서도 부분마다 배율이 다른 셈이다. - 어느 순간 배치가 접힌다.
가로로 나란히 있던 카드 세 장이, 창이 좁아지면 특정 지점에서 두 줄로 접힌다. 책장에 나란히 꽂힌 책 세 권 중 하나가 아래 칸으로 내려가는 것과 같다. 그 순간 아래에 있던 모든 것의 세로 위치가 통째로 밀린다. - 어떤 폭을 넘어가면 아예 다른 화면이 된다.
폰으로 웹사이트를 열면 사이드바가 사라지고 햄버거 메뉴로 바뀌는 걸 본 적이 있을 것이다. 브라우저 창을 천천히 줄이다 보면 어느 순간 툭 하고 그렇게 바뀐다. 서서히 변하는 게 아니라 계단처럼 점프한다.
이건 앞의 셋과 성격이 다르다. 1~3번은 같은 요소가 다른 자리로 옮겨간 것이라 “어긋났다"고 말할 수라도 있다. 하지만 여기서는 내가 좌표를 찍어둔 사이드바가 화면에서 통째로 사라진다. 배율을 아무리 정교하게 맞춰도, 없는 것을 가리킬 수는 없다.
정리하면 이렇다.
브라우저는 창 크기가 바뀔 때마다 “이 폭 안에서 이 규칙들을 전부 만족시키는 배치"를 매번 새로 푼다. 글자는 16px을 유지하고, 이 영역은 800px을 넘지 않고, 카드는 자리가 모자라면 아래로 접고, 900px 밑에서는 메뉴를 숨긴다. 이런 규칙 수십 개가 얽혀서 나온 답이 지금 내 눈앞의 화면이다.
나는 그 답을 다시 푸는 대신, 이전 답에 숫자 하나를 곱해서 맞히려고 했다. 첫 번째 가설이 실패한 이유는 그거였다.
flowchart TB
subgraph MY["내 가정 — 캔버스 모델"]
direction TB
M1["창 크기 W → W'"] --> M2["모든 좌표에 W'/W 배율을 곱한다"]
M2 --> M3(["같은 지점을 가리킴 ✓"])
end
subgraph REAL["실제 — 배치를 매번 다시 푸는 모델"]
direction TB
A1["창 크기 W → W'"] --> A2["레이아웃 전체 재계산 (reflow)"]
A2 --> A3["글자 크기는 그대로
줄바꿈이 늘어 높이는 오히려 증가"]
A2 --> A4["max-width 영역은 더 이상 안 늘어남"]
A2 --> A5["나란히 있던 카드가 두 줄로 접힘"]
A2 --> A6["특정 폭에서 화면 구성 자체가 교체됨"]
A3 --> A7(["좌표가 엉뚱한 곳을 가리킴 ✗"])
A4 --> A7
A5 --> A7
A6 --> A7
end전환점: “이 좌표 아래에 대체 뭐가 있지?”
왜 어긋나는지 눈으로 보려고 개발자 도구를 열었다. 저장된 좌표를 화면에 찍어놓고, 그 자리에 실제로 무엇이 있는지 확인해볼 참이었다. 그러다 document.elementFromPoint(x, y)를 알게 됐다. 좌표를 넣으면 그 자리에 있는 요소를 돌려주는 함수다. 그리고 반대 방향도 있었다. getBoundingClientRect()는 요소를 주면 그게 지금 화면 어디에 있는지 돌려준다. 이 둘을 나란히 놓고 보니 설계가 뒤집혔다.
| API | 하는 일 | 언제 쓰나 |
|---|---|---|
elementFromPoint | 좌표 → 요소 | 스레드를 찍을 때 |
getBoundingClientRect | 요소 → 좌표 | 스레드를 복원할 때 |
클릭 좌표를 요소로 바꿔서 저장해두고, 나중에 그 요소를 다시 좌표로 바꿔서 찍으면 된다. 브라우저는 창이 어떤 크기든 그 요소가 지금 어디 있는지 정확히 알고 있다. 내가 배율을 곱해가며 근사하려던 값을, 물어보기만 하면 알려주는 창구가 처음부터 있었던 것이다.
좌표를 저장할 게 아니라, 요소를 저장해야 한다.
좌표는 창 크기가 바뀔 때마다 달라지는 값이다. 하지만 “그 버튼"은 화면이 어떻게 바뀌든 그 버튼이다. 내가 저장하고 싶었던 건 처음부터 “화면의 이 픽셀"이 아니라 “이 버튼” 이었다.
그런데 곧바로 다음 질문이 왔다. 요소를 어떻게 저장하지?

elementFromPoint가 돌려주는 건 자바스크립트 객체다. 브라우저 메모리 안에만 있는 값이라 데이터베이스에 넣을 수가 없고, 새로고침하면 사라진다. 그러니 객체 자체가 아니라 “그 요소를 다시 찾아가는 길” 을 문자열로 적어둬야 했다. 그 길을 찾다가 마주친 게 DOM 트리였다. HTML은 태그가 태그를 감싸는 중첩 구조다. 브라우저는 이걸 그대로 트리로 만든다. body 아래에 div가 있고, 그 div 아래에 또 무언가가 있고. 개발자 도구 Elements 탭에서 화살표를 눌러 펼치던 그 계층이 바로 이 트리다. 트리이기 때문에, 루트에서 출발해 몇 번째 자식으로 내려갔는지를 순서대로 적으면 임의의 요소 하나를 정확히 지목할 수 있다.
| |
“몸통에서 두 번째 가지, 거기서 첫 번째 잔가지, 거기 달린 세 번째 잎.” 이건 문자열이니 저장할 수 있고, 나중에 이 길을 그대로 되짚어가면 같은 요소가 나온다. 늘 보던 Elements 탭의 계층 구조가 사실 이런 의미였다는 걸 그제야 이해했다.
두 번째 시도: DOM 경로 + 요소 안에서의 상대 위치
그래서 방식을 이렇게 바꿨다.

찍을 때
- 클릭한 좌표로
elementFromPoint를 호출해 그 자리의 요소를 찾는다. - 그 요소까지 가는 트리 경로를 문자열로 기록한다.
- 그 요소의 좌상단 좌표를 구해서, 클릭 지점이 거기서 얼마나 떨어져 있는지 (dx, dy) 를 함께 저장한다.
복원할 때
- 저장된 경로로 요소를 다시 찾는다.
- 그 요소의 현재 좌상단 좌표를 구한다.
- 거기에 (dx, dy)를 더한 자리에 포인트를 찍는다.
(dx, dy)가 필요한 이유는 요소가 점이 아니라 면적이기 때문이다. 가로로 긴 카드에 스레드를 달았다면 “카드 왼쪽 끝을 가리킨 것"과 “오른쪽 끝을 가리킨 것"은 전혀 다른 이야기일 수 있다. 요소만 기억하면 그 차이가 지워진다. 결국 바뀐 건 하나다. 기준점이 화면의 좌상단에서 요소의 좌상단으로 옮겨갔다. 화면은 창 크기에 따라 매번 달라지지만, 요소는 어디로 밀려나든 자기 안에서의 위치는 그대로다.
flowchart TB
subgraph BAD["v1 · 절대 좌표 — 기준점이 화면"]
B1["1440×900 화면의 (320, 480)"] --> B2["창을 1280으로 줄이면"]
B2 --> B3["레이아웃이 재배치되어
엉뚱한 요소를 가리킴 ✗"]
end
subgraph GOOD["v2 · 앵커 + 상대 좌표 — 기준점이 요소"]
G1["DOM 경로로 요소를 특정"] --> G2["그 요소의 좌상단에서
(dx, dy)만큼 떨어진 지점"]
G2 --> G3["창 크기가 변해도
포인트가 요소를 따라감 ✓"]
end이렇게 저장한 한 덩어리 — (경로, dx, dy) — 를 앞으로 앵커(anchor) 라고 부르겠다. 배가 닻을 내려 한자리에 머물듯, 스레드를 화면의 특정 지점에 붙들어두는 정보다. 이 글의 나머지는 대부분 이 앵커가 흔들리는 이야기다.
어쨌든 창 크기를 아무리 바꿔봐도 포인트가 버튼을 따라다녔다. 이제 됐다고 생각했다.
덤으로, 이 삽질을 하면서 DOM 트리가 무엇이고 왜 그런 구조인지를 처음으로 제대로 이해하게 됐다. 도구를 만들려다 도구가 딛고 선 바닥을 배운 셈이다.
3. 그리고 디자인 시스템을 얹고 싶어졌다
여기까지 만들고 나니 욕심이 생겼다.
우리가 쓰는 HTML 프로토타입은 대부분 Claude에게 시켜서 뽑는다. 원하는 걸 설명하면 몇 분 만에 동작하는 화면이 나온다. 빠르다. 그런데 나오는 화면마다 “AI가 만들었다"는 티가 난다. 어디서 본 듯한 카드 레이아웃, 습관처럼 얹히는 그라데이션, 과하게 둥근 모서리, 우리가 쓰지 않는 색. 화면마다 조금씩 다른 규칙으로 만들어지다 보니 여러 장을 이어 붙이면 한 제품처럼 보이지도 않는다. 문제는 이게 우리 제품의 인상과 확실히 다르다는 것이다. 그래서 프로토타입을 띄울 때마다 같은 단서를 달게 된다.
“이거 실제로는 좀 다르게 나올 거예요.”
이 한마디가 생각보다 비싸다. 그 순간부터 상대는 눈앞의 화면과 머릿속의 완성본을 동시에 상상하면서 이야기해야 한다. “이 버튼이 좀 약해 보이는데요"라는 말이 나와도, 그게 우리가 검증하려던 구조에 대한 지적인지 아니면 Claude가 골라준 색에 대한 지적인지 구분이 안 된다. 검증하려고 만든 화면인데, 검증의 정확도를 화면 스스로가 깎아먹는 셈이다. 그렇다고 프로토타입을 손으로 예쁘게 다듬고 앉아 있을 수는 없다. 그건 프로토타입의 유일한 장점인 속도를 갉아먹는 일이다. 필요한 건 다듬지 않아도 우리 것처럼 보이는 방법이었다.
여기에 우리 디자인 시스템을 씌우면 그 단서를 뗄 수 있다. 실제로 출시될 화면과 거의 같은 것을 놓고 이야기하게 된다.

문제는 우리 디자인 시스템이 React 컴포넌트로만 존재한다는 것이었다. HTML/CSS로 포팅된 버전은 없었다. 버튼 하나를 쓰려면 React를 띄워야 한다.
길은 두 개였다.
- 디자인 시스템을 HTML/CSS로 포팅한다. 도구는 지금 구조를 그대로 쓸 수 있다.
- 도구가 React 빌드 산출물을 그대로 받게 한다. 디자인 시스템은 손댈 필요가 없다. 대신 도구가 짊어질 짐이 늘어난다.
2번을 골랐다. 이유는 두 가지였다.
첫째, 디자인 시스템이 아직 만들어지는 중이다. 컴포넌트가 계속 추가되고 바뀐다. 지금 포팅해봐야 그건 이번 주의 스냅샷일 뿐이고, 다음 주면 원본과 어긋나기 시작한다. 원본이 멈춰 있다면 한 번 고생하고 끝날 일이지만, 움직이는 원본을 사본이 쫓아가는 일에는 끝이 없다. 그리고 어긋난 사본으로 만든 프로토타입은 결국 처음 문제로 되돌아간다. 실제 제품과 다른 화면을 놓고 이야기하게 되니까.
둘째, 프로토타입을 올리는 사람은 이런 사정을 알 필요가 없다. 기획자가 화면 하나를 공유하려고 “디자인 시스템이 React라서 HTML로 포팅된 버전이 따로 있고, 그건 어디에 있고, 최신인지는 어떻게 확인하고…“를 알아야 한다면 그건 도구가 아니라 숙제다. 사람은 파일을 올리고, 화면 위에 점을 찍고, 대화를 하면 된다. 그 아래가 React인지 HTML인지 몰라도 되게 만드는 것이 도구의 일이라고 생각했다.
그래서 도구를 확장했다. 빌드된 React 앱을 통째로 업로드하면, HTML 프로토타입과 똑같이 그 위에 스레드를 달 수 있게.
적어도 계획은 그랬다.
4. React가 무너뜨린 가정

React 앱을 빌드하면 index.html에 무엇이 남는가?
| |
root라는 빈 상자 하나. 그게 전부다.
HTML 프로토타입이 완공된 건물이라면, 이건 빈 땅과 인부 한 명이다. 화면에 보이는 모든 DOM은 저 스크립트가 실행되면서 그때그때 지어 올린다. 그리고 다 지어놓는 것도 아니다. 지금 필요한 층만 짓고, 다른 화면으로 넘어가면 방금 지은 층을 헐고 새 층을 올린다.
2장에서 내 앵커는 세 조각이었다. (DOM 경로, dx, dy).
이 중 나머지 둘을 지탱하던 첫 번째 조각이 통째로 무너진다.
문제 1. 저장한 경로가 존재하지 않는다
설정 화면의 어떤 버튼에 스레드를 달았다고 하자. ... > section > button:nth-child(3) 같은 경로가 저장된다. 며칠 뒤 그 링크를 연다. 앱은 홈 화면에서 시작한다. 설정 화면은? 아직 지어지지 않았다.

“3층 복도 끝 방"이라고 주소를 적어뒀는데, 도착해보니 건물이 1층까지만 올라가 있는 격이다. 3층은 누군가 올라가려고 해야 그때 지어진다. 주소가 틀린 게 아니다. 주소가 가리키는 곳이 아직 존재하지 않는 것이다.
HTML 프로토타입에서는 이런 일이 없었다. 파일을 파싱하는 순간 트리가 통째로 존재하니, 언제 물어봐도 같은 답이 나왔다.
flowchart TD
START["앱 진입 (초기 상태)"] --> HOME["홈 화면
DOM 존재 ✅"]
HOME -. "이 경로를 밟아야만
DOM이 생성됨" .-> SET["설정 화면
지금은 DOM 미존재 ❌"]
SET -.-> MODAL["권한 설정 모달
지금은 DOM 미존재 ❌"]
MODAL -.-> BTN["'확인' 버튼"]
ANCHOR[/"저장된 앵커
path = #root › … › button"/] -.->|"참조 대상이 존재하지 않음"| BTN방법이 없는 건 아니었다. 세 갈래가 보였다.
- 미리 다 돌아본다.
링크를 열 때 도구가 앱을 자동으로 훑는다. 모든 화면을 한 번씩 방문해 DOM을 전부 지어놓고 시작하는 것이다. 그럴듯한데, “모든 화면"의 목록을 아무도 모른다는 게 문제다. 주소가 있는 화면은 찾아갈 수 있지만, 버튼을 눌러야 열리는 모달, 특정 데이터가 있어야 나타나는 영역, 어떤 값을 고른 다음에만 보이는 다음 단계는 그 목록에 없다. 게다가 방문해봐야 소용이 없다. 떠나는 순간 그 층은 다시 헐린다. - 화면을 통째로 박제한다.
스레드를 찍는 순간의 DOM을 전부 저장해두고, 나중에는 실제 앱 대신 그 저장본을 띄운 다음 그 위에 포인트를 찍는다. 앵커는 100% 맞는다. 절대 안 틀린다. 대신 그 화면은 더 이상 프로토타입이 아니다. 버튼을 눌러도 아무 일도 일어나지 않고, 다음 화면으로 넘어갈 수도 없다. 피드백을 주는 사람이 “이거 눌러보면 어떻게 되는데요?“를 할 수 없다면, 애초에 HTML 파일 대신 캡처 이미지를 올렸어도 됐다는 뜻이다. - 어느 화면에 있었는지를 함께 적어둔다.
스레드를 찍을 때 “이 사람은 설정 화면에 있었다"를 기록해둔다. 복원할 때는 순서를 바꾼다. 요소를 찾기 전에 먼저 앱을 그 화면으로 데려간다. 3층부터 짓게 만든 다음 주소를 찾아가는 것이다.
C를 골랐다. A는 끝을 알 수 없는 일이었고, B는 도구가 지키려던 것을 포기하는 일이었다. C만이 앱을 살려둔 채로 문제를 푸는 길이었다. 대신 앵커에 조각이 하나 늘었다.
| |
이때는 이게 조각 하나 늘어난 정도의 일이라고 생각했다.
| |


문제 2. 다 그려질 때까지 기다려야 한다
화면을 이동시켰다고 끝이 아니다.
React는 “설정 화면으로 가라"고 시킨다고 그 자리에서 DOM을 만들지 않는다. 다시 그릴 일감을 예약해두고, 순서가 오면 그리고, 그 다음에 브라우저가 화면에 칠한다. 목록을 서버에서 받아오는 화면이라면 응답이 도착할 때까지 자리가 비어 있다가 나중에 채워진다. 그래서 기다렸다가 요소를 찾아야 한다. 얼마나 기다려야 하나?
알 방법이 없다. 100ms일 수도 있고, 네트워크가 느리면 2초일 수도 있다. 너무 일찍 찾으면 아직 없는 요소를 뒤지는 셈이고, 운 좋게 찾아내더라도 그 직후에 목록이 채워지면서 버튼이 아래로 밀려나면 포인트만 허공에 남는다.
결국 실무적으로는 “일단 좀 기다린다"가 된다. 근거 없는 숫자 하나를 코드에 박아 넣는 것이다. 이때부터 좀 찜찜했다.
문제 3. 모달 위에서는 더 어렵다
모달은 대개 portal로 만든다. 화면 구조상으로는 설정 페이지 안에 있는 부품이지만, 실제 DOM에서는 body 맨 아래에 따로 떼어 붙인다. 그래야 부모가 걸어둔 잘림(overflow)이나 쌓임 순서(z-index)에 갇히지 않고 화면 전체를 덮을 수 있기 때문이다.
즉 “어디에 속해 있는가"와 “어디에 매달려 있는가"가 다르다. 그리고 내 앵커가 적어둔 주소는 후자다.
flowchart LR
subgraph CT["컴포넌트 트리 · 논리적 구조"]
C1["App"] --> C2["SettingsPage"]
C2 --> C3["PermissionModal"]
C3 --> C4["확인 버튼"]
end
subgraph DT["실제 DOM 트리 · 물리적 구조"]
D1["body"] --> D2["div#root"]
D2 --> D3["설정 화면 DOM"]
D1 --> D4["div.portal-root"]
D4 --> D5["모달 DOM"]
D5 --> D6["확인 버튼"]
end
C4 -. "같은 버튼인데
DOM 경로는 형제 계층에 있음" .-> D6
문제는 여기서 끝나지 않는다. 모달은 열리고 닫히는 동안 미끄러지듯 움직인다. 그 0.2초 사이에 요소의 좌표는 계속 바뀐다. 게다가 “모달이 열려 있는가” 자체가 또 하나의 화면 상태다. 복원하려면 설정 화면으로 가고, 거기서 모달까지 열어줘야 한다. 지금은 어찌어찌 찾아내게 만들어놨다. 하지만 여기까지 오면서 확실히 느꼈다. 구멍 하나를 막을 때마다 앵커에 붙는 조건이 하나씩 늘고 있었다. 뭔가 잘못된 방향으로 가고 있다.
그래서 찾아봤다. “DOM을 한 번에 다 그리는 방법”
방법이 하나만 있으면 됐다. 링크를 열자마자 React가 모든 화면의 DOM을 한 번에 다 지어놓고 멈춰주는 방법. 그러면 HTML 프로토타입과 똑같은 조건이 되고, 앞의 세 문제가 전부 사라진다.
찾아봤다.

그리고 그건 아직 안 만들어진 기능도, 내가 못 찾은 옵션도 아니었다.
그렇게 안 되는 것이 React의 설계 목적 그 자체였다.
너무 길어질 것 같아 다음 글로 이어서 쓰겠다.