대출 갈아타기 계산기, 반응형 웹 설계 회고
- 애니메이션 성능은 브라우저가 한 프레임에 무슨 일을 하는지 아는 데서 갈렸다. 라이브러리 선택은 그다음 문제였다.
- React Context는 상태 관리 도구가 아니라 상태 전달 구조다. 역할과 책임을 아무리 잘 나눠도 이 성질은 바뀌지 않는다.
0. 이 페이지가 하는 일
반응형 웹이라고 하면 보통 미디어쿼리로 레이아웃을 맞추는 일을 떠올린다. 이 글에서 그 부분은 배경으로만 다룬다.
대출 갈아타기 계산기는 지금 갚고 있는 대출을 등록하고, 갈아탈 조건을 조정하고, 두 경우를 비교해서 얼마를 아끼는지 보여주는 화면이다. 특징은 이 셋이 한 페이지 안에서 끝난다는 것이다. 페이지 이동이 없다.

점선을 눈여겨보면 좋겠다. 1단계에서 대출을 하나 더 추가하면 2단계 슬라이더의 하한이 바뀌고 3단계 결과가 다시 계산된다. 슬라이더를 드래그하는 동안에도 3단계는 계속 따라온다. 이 글에서 다룰 문제는 전부 이 점선에서 나온다.
마지막으로 슬라이더에서 손을 떼면 URL이 갱신되고, 그 URL을 공유하면 상대방 화면에 같은 숫자가 뜬다. 드래그하는 내내가 아니라 손을 뗄 때만 갱신된다는 점이 나중에 따로 다룰 이야기가 된다.
1. 계기
환경 세 개, 페이지 하나
입사 2년차에 업무로 맡은 3개월짜리 프로젝트였다. 요구사항의 첫 줄은 이 페이지가 PC 웹, 모바일 웹, 앱 웹뷰 세 환경에서 돌아야 한다는 것이었다.
PC 웹과 모바일 웹의 차이는 크지 않았다. 기능은 같고 레이아웃이 달라지는 정도, 그리고 모바일에만 붙는 애니메이션과 제스처가 있었다. 이 정도는 익숙한 작업이다.
갈리는 건 웹뷰였다. 여기서는 마이데이터 API를 연동해서 사용자가 보유 중인 계좌 목록을 자동으로 불러온다. 대신 계좌를 직접 추가하거나 삭제할 수 없다. 서버가 가진 정보가 진실이니 화면에서 편집할 대상이 아니다. 마이데이터 연동 화면으로 진입하는 버튼이 따로 있고, 사용자가 거기서 계좌를 추가하고 돌아오면 네이티브가 웹으로 이벤트를 쏜다. 그 이벤트를 받아 목록을 다시 그린다.
나머지는 세 환경이 전부 같았다.

붉은 영역만 웹뷰 전용이다.
이 그림을 그려놓고 보면 페이지를 쪼개지 않기로 한 이유가 자연스럽다. 다른 것은 대출 데이터가 어디서 오는가뿐이고, 검증부터 계산, 결과 표시, 공유까지는 완전히 같다. 진입 경로로 페이지를 나눴다면 파란 영역이 두 벌이 되고, 그때부터는 계산식 하나를 고칠 때마다 두 곳을 고쳐야 한다.
웹뷰 판별은 User-Agent 문자열로 하면 앱 버전이 올라가거나 OS가 업데이트될 때마다 깨진다. 네이티브가 웹뷰에 심어주는 명시적인 마커로 판별하는 게 맞고, 더 중요한 건 판별에 실패했을 때의 기본값이다. 기본값은 비로그인 웹이어야 한다. 반대로 두면 로그인하지 않은 사용자에게 마이데이터 UI가 뜬다.
한 가지는 분명히 해둔다. 이 마커는 화면을 가르는 데만 쓴다. 마커가 잘못 판별돼서 UI가 노출되는 것 자체는 서버 권한 우회로 이어지지 않는다. 대신 있어서는 안 될 화면 노출과 실패할 API 호출이 남고, 그건 그 자체로 별개의 사고다. 권한은 서버가 토큰을 보고 판단하고, 화면이 버튼을 숨기는 건 방어층 하나일 뿐 경계가 될 수 없다. 이 구분은 6절에서 다시 필요해진다.
2. 요구사항의 성격을 먼저 읽는다
무엇이 자주 일어나는가
설계를 시작할 때 기능 목록보다 먼저 본 건 이 페이지에서 무슨 일이 자주 일어나는지였다.
입력이 많다. 대출 한 건마다 금액, 금리, 기간, 상환방식, 만기일을 받고 그게 N건이다. 그런데 입력이 많다는 것 자체는 그렇게 어려운 문제가 아니다. 진짜 특징은 입력 하나가 다른 컴포넌트를 동시에 흔든다는 데 있었다.
구체적으로 이런 것들이다.
- 상환 방식을 바꾸면 그 아래에 뜨는 선택지 구성 자체가 바뀐다.
- 슬라이더로 금액이나 기간을 조정하면 결과 금액에 0.5초 뒤 반짝하는 이펙트가 붙는다.
- 한 카드의 입력 조건이 모두 채워지면 그 카드가 접히고 다음 카드가 펼쳐진다.
세 번째가 특히 부담이었다. 페이지 이동이었다면 그냥 라우팅이면 끝난다. 한 페이지 안에서 아코디언이 천천히 접히고 다음 카드가 천천히 펼쳐지는 연출은, 그 시간 동안 브라우저가 계속 무언가를 갱신한다는 뜻이다. 무엇을 어떻게 움직이느냐에 따라 그 비용이 어디서 나오는지가 갈린다는 건 한참 뒤에 알았다.
이 페이지에서 가장 자주 일어나는 일은 드래그 한 번에 프레임마다 벌어지는 상태 변화다. 60Hz면 초당 60회고, 요즘 모바일은 그 위다. 그래서 목표를 이렇게 잡았다. 60fps를 지키면서 세 환경에 한 벌로 대응한다.
슬라이더는 네이티브 <input type="range">가 아니라 포인터 이벤트로 직접 만든 것이었다. 이 선택 때문에 아래 제약이 곧바로 따라온다.
네이티브 <input type="range">가 공짜로 주는 role, aria 속성, 키보드 조작 계약도 이 선택과 함께 사라진다. 이 글은 성능 문제에 집중하느라 그 접근성 계약은 다루지 않는다.
모바일 제약도 하나 걸린다. 세로로 긴 페이지 안에 슬라이더를 놓으면 touch-action: none이 없을 때 세로 성분이 섞인 드래그를 브라우저가 페이지 스크롤로 가져간다. 손가락이 조금만 위아래로 흔들려도 슬라이더가 멈추고 페이지가 스크롤된다. 확정적으로 발생하는 문제라 처음부터 막고 시작해야 한다.
다만 막는 데도 대가가 있다. touch-action: none을 걸면 그 영역이 통째로 스크롤 데드존이 된다. 슬라이더가 화면 폭을 다 쓰는 모바일에서는 그 띠에 손가락을 얹은 사용자가 페이지를 못 내린다. 가로로만 움직이는 슬라이더라면 pan-y가 절충안이다. 세로 스크롤은 브라우저에 넘기고 가로 성분만 가져온다.
목표의 앞쪽 절반은 애니메이션 문제였고, 뒤쪽 절반은 리렌더 문제였다. 두 문제는 증상이 비슷해서 한동안 같은 문제인 줄 알았다.
3. 애니메이션이 버벅였다
문제는 라이브러리가 아니라 프레임이었다
3.1 처음엔 JS로 만들었다
아코디언이 접히고 펼쳐지는 걸 setInterval로 만들었다. 타이머를 16ms마다 돌리면서 요소의 높이를 조금씩 늘리고 줄이는 방식이다. 지금 보면 뻔한 실수지만 당시엔 가장 직관적인 방법이었다.
PC에서는 그럭저럭 돌았다. 모바일에서는 확연히 끊겼다. 처음엔 기기 성능 차이라고 생각하고 넘어가려 했는데, 그러면 저사양 기기에서는 계속 끊긴다는 뜻이다. 그래서 한 프레임 동안 브라우저가 정확히 무슨 일을 하는지부터 봤다. 이 글에서 가장 도움이 됐던 우회로다.
3.2 브라우저가 한 프레임에 하는 일
브라우저가 렌더링을 갱신할 때는 자바스크립트 실행, 스타일 계산, 레이아웃, 페인트, 합성이 차례로 놓인다. 매 프레임 이 단계가 전부 도는 건 아니다. 어떤 속성을 건드렸느냐에 따라 이 중 어디부터 다시 도는지가 달라진다.

height를 바꾸면 첫 단계부터 다시 돈다. 높이가 바뀌면 그 아래 형제 요소들의 위치가 전부 밀리기 때문에, 리플로가 그 요소 하나가 아니라 서브트리로 번진다. 아코디언은 정의상 아래에 콘텐츠가 잔뜩 있는 구조라 최악의 조건이었다.
여기서 흔히 하는 결론이 “그러니까 transform을 쓰면 된다”인데, 절반만 맞다. transform이 합성 단계만 타려면 그 요소가 자기 레이어로 승격되어 있어야 한다. 승격되지 않은 요소는 transform을 바꿔도 다시 칠해진다. 위 그림의 마지막 두 줄이 그 차이다.
그럼 승격은 어떻게 얻는가. 여기서 오해하기 쉬운데, 승격은 내가 켜는 스위치가 아니라 브라우저의 최적화 판단이다. transform과 opacity는 합성 단계에서 처리될 수 있는 속성이지만, 실제로 별도 레이어가 만들어지느냐는 그 요소가 가진 다른 조건들에 함께 달려 있다. will-change: transform도 보장이 아니라 미리 준비할 기회를 주는 힌트다. 브라우저가 무시할 수 있다.
게다가 레이어는 공짜가 아니다. 하나마다 메모리를 차지하고, 그 대가를 가장 크게 치르는 곳이 정확히 문제가 됐던 모바일이다. 프로파일링으로 필요성을 확인한 요소에만 붙이고 끝나면 떼는 게 맞다. 전역 CSS에 will-change를 뿌려두면 문제를 다른 곳으로 옮기는 것에 가깝다.
승격과 별개로, CSS 애니메이션과 뒤에 나올 rAF 사이에는 차이가 하나 더 있다. transform과 opacity만 건드리는 CSS 애니메이션은 컴포지터 스레드로 넘어갈 수 있고, 그러면 메인 스레드 작업과 격리된 채 진행된다. rAF는 정의상 메인 스레드 콜백이라 이 격리를 얻지 못한다. 메인 스레드가 막히면 콜백이 안 불리고, 콜백이 안 불리면 값이 전진하지 않는다. 레이어가 있든 없든 그렇다.
이 차이가 3.6절에서 다시 나온다. 그때까지는 잊고 있어도 된다.
타이머 자체에도 문제가 있었다. setInterval(fn, 16)은 프레임 경계와 아무 상관 없이 발화한다. 16ms와 16.7ms의 차이가 쌓이면서 한 프레임 안에 두 번 들어오기도 하고 아예 건너뛰기도 한다. 게다가 태스크 큐가 밀려 있으면 밀린 채로 실행된다. 60fps를 노린 타이머가 60fps를 보장하지 못한다.
마지막으로 레이아웃 스래싱이 있었다. 현재 높이를 읽어서 거기에 값을 더해 다시 쓰는 코드였는데, 같은 프레임 안에서 읽기와 쓰기를 반복하면 브라우저가 읽을 때마다 강제로 레이아웃을 다시 계산한다. 요소가 여러 개면 그 횟수만큼 레이아웃이 돈다.
3.3 그래서 CSS로 옮겼다
transform 기반으로 옮길 수 있는 것들은 옮기자마자 해결됐다. 합성 단계까지 내려간 애니메이션은 메인 스레드와 분리된 채 진행되기 때문에, 메인 스레드가 바빠도 화면은 굴러간다. 3.2에서 본 승격 조건을 만족했을 때의 이야기다.
아코디언 높이는 그렇게 간단하지 않았다. height: auto로는 전이가 걸리지 않는다. 흔한 우회로가 max-height에 충분히 큰 값을 주는 건데, 지정한 값과 실제 높이의 차이만큼 타이밍이 어긋난다. 실제 높이가 200px인데 max-height를 1000px로 잡으면 애니메이션의 80%가 이미 다 펼쳐진 상태에서 흘러간다. 사용자 눈에는 “빨리 끝나고 멈춰 있는” 것처럼 보인다.
지금은 선택지가 늘었다. grid-template-rows를 0fr에서 1fr로 보내는 방법은 실제 높이를 브라우저가 계산하므로 타이밍이 어긋나지 않고, 주요 엔진에 모두 들어와 있다. 값이 0이어도 fr 단위를 붙여야 전이가 걸린다는 것만 주의하면 된다. interpolate-size: allow-keywords와 calc-size()는 auto에 직접 전이를 거는 정공법인데 2026년 8월 현재 Chromium 계열에만 있다. 이 글의 환경처럼 iOS 웹뷰가 포함되면 @supports로 감싸고 없는 쪽은 애니메이션 없이 토글되게 두는 정도가 현실적이다. 프로젝트 당시에는 어느 쪽도 쓸 수 없었다.
반짝 이펙트도 CSS로 되긴 한다. 걸리는 건 재시작이다. 같은 애니메이션을 다시 트리거하려면 클래스를 떼고, 리플로를 한 번 강제하고, 다시 붙여야 한다. 슬라이더처럼 값이 연속으로 바뀌는 자리에서는 이 재시작이 매번 일어난다. 지금이라면 el.getAnimations()로 실행 중인 애니메이션을 잡아 currentTime을 0으로 되돌리는 쪽이 깔끔하다. 클래스 토글도, 강제 리플로우도 필요 없다.

왼쪽은 메인 스레드가 프레임마다 다섯 가지 일을 한다. 오른쪽은 컴포지터에서 처리될 조건을 만족했다면, 최초 한 번의 클래스 토글 이후로 메인 스레드가 하는 일이 없다.
3.4 react-spring을 뜯어보다 rAF를 알게 됐다
CSS로 안 되는 것들이 남았고, 그건 라이브러리로 해결된다는 걸 알고 있었다. 그런데 왜 해결되는지가 궁금해서 react-spring 내부를 들여다봤다. 거기서 requestAnimationFrame을 알게 됐다.
rAF는 다음 리페인트 전에 콜백을 한 번 실행해 달라고 요청한다. 일회성이라 반복 애니메이션은 콜백 안에서 다음 rAF를 다시 등록해 만든다. 실행 위치는 브라우저가 렌더링을 갱신하는 단계의 초입, 스타일 계산 전이다.
타이머와 갈리는 지점은 빈도가 아니라 발화 시점을 누가 정하느냐다. setInterval은 렌더링 루프와 무관한 타이머가 시점을 정하고, rAF는 렌더링 루프가 정한다. 그래서 rAF는 한 프레임에 두 번 들어오지도, 프레임과 어긋나 드리프트하지도 않는다. 대신 프레임이 만들어지지 않으면 아예 안 불린다. MDN도 호출 빈도가 주사율과 “대체로” 일치한다고 쓰지 일치한다고 쓰지 않는다. 메인 스레드 부하나 탭 상태에 따라 늦어지거나 생략될 수 있다.

그림이 보여주는 건 드리프트가 없다는 것뿐이다. 프레임이 만들어지지 않으면 rAF도 그 자리에서 그냥 건너뛴다.
쓸 때 걸리는 것들이 몇 가지 있다.
콜백은 DOMHighResTimeStamp를 인자로 받고, 같은 프레임에 등록된 모든 콜백이 같은 값을 받는다. 이 값을 써서 델타 시간으로 보간해야 한다. 프레임 수를 세서 진행률을 계산하면 120Hz 기기에서 애니메이션이 두 배 빨라진다. 주사율이 기기마다 다르다는 걸 잊기 쉽다.
let prev = null
let elapsed = 0
let rafId = null
const duration = 0.3 // 초
function step(now) {
// 첫 프레임의 델타는 정의되지 않는다. 0으로 두고 시작한다.
const dt = prev === null ? 0 : (now - prev) / 1000
prev = now
elapsed += dt
const t = Math.min(elapsed / duration, 1)
// 여기서 t로 값을 보간한다. 프레임 수가 아니라 흐른 시간 기준이다.
if (t < 1) rafId = requestAnimationFrame(step)
}
rafId = requestAnimationFrame(step) // 최초 1회대부분의 브라우저에서 백그라운드 탭의 rAF는 멈춘다. setInterval은 멈추지 않지만 대신 클램프된다. Chrome은 비활성 탭에서 최소 지연을 1초 단위로 낮추고, 페이지가 오래 숨겨져 있으면 1분에 한 번까지 떨어뜨린다. 다만 1분 단위까지 떨어지는 인텐시브 스로틀링은 탭이 5분 이상 숨겨져 있고, 타이머 체인이 일정 길이를 넘고, 오디오 재생 없이 무음 상태이고, WebRTC 연결이 없다는 네 조건이 동시에 맞을 때만 걸린다. 다른 브라우저도 클램프를 걸지만 수치와 발동 조건이 제각각이라, 어느 쪽이든 백그라운드에서 시간을 세는 용도로는 못 쓴다. 배터리 관점에서는 rAF가 옳고, 대신 탭이 다시 활성화됐을 때 델타가 크게 튀어 들어온다. 상한을 두는 게 안전하다.
cancelAnimationFrame을 언마운트에서 부르지 않으면 루프가 컴포넌트보다 오래 산다. 그리고 프레임 안에서 레이아웃을 읽어야 한다면 읽기를 먼저 몰아서 하고 쓰기를 나중에 몰아서 해야 한다. 3.2에서 본 스래싱이 rAF 안이라고 사라지지 않는다.
입력을 rAF에 태울 때 하나 더 챙길 게 있다. 포인터 이벤트는 프레임보다 자주 올 수 있다. 브라우저는 프레임 사이에 쌓인 이벤트를 합쳐서 전달할 수 있고, 합칠지 말지는 표준이 사용자 에이전트 재량으로 둔다. 합쳐진 원본은 getCoalescedEvents()로 꺼낼 수 있는데, 이 API는 아직 Baseline이 아니고 Safari에 없다. iOS 웹뷰가 대상에 들어 있으면 이걸 전제로 설계할 수 없다는 뜻이다. 다행히 슬라이더처럼 최종 위치만 중요한 경우라면 필요하지 않다. 핸들러에서 값만 ref에 담아두고 rAF에서 마지막 값 하나만 반영하면 된다. 궤적이 필요한 그리기 도구라면 반대로 합쳐진 것들을 다 펴야 하고, 그때는 이 API의 부재가 곧바로 제약이 된다.
react-spring이 실제로 하는 일은 이렇다. 기본 설정에서는 지속 시간을 두지 않고 스프링 물리로 값을 계산한다. 질량, 강성, 감쇠를 두고 매 프레임 적분한다. 그리고 결정적인 부분은 따로 있다. 계산한 값을 React 상태에 넣지 않고 ref로 DOM에 직접 쓴다. 그래서 애니메이션 60프레임이 리렌더 60회가 되지 않는다.
라이브러리를 쓰든 안 쓰든, 애니메이션 프레임이 리렌더를 유발하지 않게 만든다는 이 발상이 뒤에 이어질 이야기의 출발점이 됐다.
3.5 그럼 왜 CSS로 다 못 했나
요구사항별로 정리하면 경계가 뚜렷해진다.
| 요구사항 | 수단 | 이유 |
|---|---|---|
| 카드 접힘 · 펼침, 반짝 이펙트 | CSS | 시작과 끝이 정해져 있다 |
| 결과 금액이 굴러가며 바뀌는 연출 | rAF | CSS는 DOM 텍스트 값을 보간하지 못한다. 천 단위 구분 기호까지 붙여야 하면 더욱 |
| 드래그 중 실시간 추종 | rAF | 목표값이 매 프레임 바뀐다. CSS transition은 목표가 바뀌면 새 전이가 easing의 시작 기울기로 다시 출발해 속도 연속성이 끊긴다 |
| 카드 접힘 + 스크롤 위치 보정 | rAF | 레이아웃 값을 읽어서 같은 프레임에 반영해야 한다 |
세 번째가 스프링이 필요한 정확한 이유다. CSS transition은 목표가 바뀌면 현재 위치에서 새 전이를 시작하고, 이때 이전 전이가 갖고 있던 속도는 이어지지 않는다. 새 전이의 시작 속도는 지정한 easing이 t=0에서 갖는 기울기로 다시 정해진다. 우리가 쓰던 easing에서는 그 값이 낮아서 목표가 바뀔 때마다 움직임이 툭 끊겨 보였다. 스프링은 현재 속도를 유지한 채 목표만 갈아끼운다.
네 번째는 예상 못 했던 문제다. 접히는 카드가 화면 위쪽에 있으면 아래 콘텐츠가 위로 딸려 올라오면서 사용자가 보고 있던 지점이 튄다. 브라우저의 스크롤 앵커링이 개입하기도 하고 안 하기도 해서 기기마다 다르게 튄다. 접힘과 스크롤 보정을 같은 프레임에 맞춰야 이게 잡힌다.
끝점이 정해진 건 CSS, 값 자체가 매 프레임 새로 정해지는 건 rAF. 이 기준으로 나눈 뒤로는 헷갈릴 일이 없었다.
다만 이건 중간값을 누가 계산하느냐의 구분이지 어느 쪽이 더 싸다는 보증은 아니다. 비용은 결국 어떤 속성을 건드려서 Layout이나 Paint를 다시 돌게 하느냐에서 나온다. rAF로 transform만 건드리면 그것도 합성만 탈 수 있고, CSS로 height를 건드리면 그것도 전 구간을 돈다.
3.6 그런데 손가락은 여전히 늦었다
애니메이션은 부드러워졌는데 슬라이더 조작감은 그대로였다. 여기서 한동안 헤맸다.
합성 단계는 별도 스레드에서 돈다. 그래서 조건을 만족한 애니메이션은 메인 스레드가 막혀 있어도 굴러간다. 그런데 입력 이벤트는 메인 스레드에서 처리된다. 메인 스레드가 리렌더로 막혀 있으면 화면은 부드럽게 흐르는데 슬라이더 손잡이만 손가락에서 떨어진다.
사용자는 이것도 “버벅인다”고 말한다. 그림이 끊기는 것과 손가락이 안 따라오는 건 원인이 정반대에 있는데 증상을 부르는 말이 같다. 3절은 그림의 문제를 풀었고, 이제 손가락의 문제가 남았다.
4. 리렌더링을 프로파일러로 들여다봤다
계산이 싼데도 느렸다
이 절에는 두 시점이 섞여 있다. 무엇이 문제였는지는 당시 프로파일링에서 나왔고, 아래에 숫자로 제시하는 값은 최근에 같은 화면을 다시 만들면서 프로덕션 빌드로 다시 잰 것이다. 당시 개발 빌드에서 본 숫자는 절대값으로 인용하지 않는다.
4.1 무엇이 커밋되고 있었나
React DevTools Profiler로 슬라이더 드래그 한 번을 녹화해서 봤다. 나온 것들을 순서대로 정리한다.
대출 카드 리스트 전체가 매 프레임 다시 그려지고 있었다. 금리 슬라이더는 시뮬레이션 값만 바꾸는데 보유 대출 카드가 N개 전부 커밋됐다. 카드 하나 그리는 비용이 작아도 N배는 작지 않다.
원인 하나는 Provider의 value였다.
// 매 렌더마다 새 객체가 만들어진다.
// Object.is 비교가 항상 실패하므로 아래에 붙인 memo가 전부 무력해진다.
<SimContext.Provider value={{ rate, months, kind }}>
{/* ... */}
</SimContext.Provider>객체 리터럴을 그대로 넘기면 값이 같아도 참조가 매번 다르다. Context는 참조로 비교하므로 소비자가 전부 깨어난다. 이걸 모르고 React.memo를 여기저기 붙였는데 하나도 듣지 않았다.
두 번째 원인은 더 안 보이는 곳에 있었다. Provider가 자식을 인라인 JSX로 감싸고 있었다.
// Context를 아무리 쪼개도 이건 안 막힌다.
// 상태가 바뀌면 Providers가 리렌더되고, 그때 자식 element가 새로 만들어진다.
function Providers() {
const [sim, dispatch] = useReducer(simReducer, initial)
return (
<SimContext.Provider value={value}>
<LoanCardList />
<SimulationControls />
</SimContext.Provider>
)
}Context 분리는 Context를 경유하는 리렌더만 막는다. 부모를 경유하는 경로는 그대로 열려 있다. 상태를 들고 있는 컴포넌트가 자식을 직접 렌더하면 그 자식들은 Context와 무관하게 다시 그려진다.
나머지 셋은 이렇다.
- 합계와 가중평균, 비교 결과를 카드 리스트와 결과 뷰가 각각 계산하고 있었다. 같은 계산이 두세 벌이라 비용도 두세 배였고, 한쪽만 고치면 화면의 두 숫자가 어긋났다.
Intl.NumberFormat을 렌더할 때마다 새로 만들고 있었다. Intl 포맷터는 인스턴스 생성이 포맷 호출보다 훨씬 비싸서 재사용하도록 설계된 API다. 화면에 뜨는 숫자가 대출 건수만큼 있으니 프레임마다 그만큼 생성되고 있었다.- 슬라이더 입력마다 URL을 갱신했다. 드래그 한 번에 히스토리가 수십 개 쌓이고 라우터가 그때마다 리렌더를 유발했다. 뒤로 가기를 누르면 슬라이더가 한 칸씩 되감겼다.

붉은 노드가 커밋된 노드다. 오른쪽에 남은 둘은 값이 실제로 바뀌는 것들이라 다시 그려지는 게 맞다. 사라진 것들은 값이 바뀌지도 않는데 그려지고 있던 것들이다.
4.2 계산이 싸다는 것과 리렌더를 좁혀야 한다는 것은 별개다
같은 페이지를 다시 만들면서 계산 비용만 따로 재봤다.
| 항목 | 측정값 | 프레임 예산(16.7ms) 대비 |
|---|---|---|
| 대출 10건 포트폴리오 구성 + 비교 | 평균 2.6µs (p99 3.4µs) | 0.016% |
| 상환방식 요약 1회 | 평균 0.2µs | 0.001% |
Node 24에서 잰 값이고, 잔여기간이 전부 다른 대출 10건을 픽스처로 썼다. 계산 경로 중 무거운 쪽을 일부러 타게 한 조건이다.
경계를 하나 그어둔다. Node와 브라우저는 V8을 공유하지만 같은 런타임은 아니다. 오른쪽 열은 도메인 연산이 프레임 예산을 잠식할 가능성이 낮다는 참고치이지, 브라우저에서 잰 프레임 기여도가 아니다.
그 한계를 감안해도 자릿수가 다르다. 드래그 한 프레임에 도는 도메인 계산 전체가 프레임 예산의 0.02% 수준이다. 느렸던 이유는 계산이 아니라 커밋에 있었다.
이 표를 만들면서 하마터면 논리가 뒤집힐 뻔했다는 걸 알았다. 만약 내가 “계산이 무거우니 리렌더를 줄여야 한다”는 식으로 최적화를 정당화했다면, 이 표가 나온 순간 근거 전체가 무너진다. 두 명제는 서로 독립이고, 각각 따로 측정해야 한다.
4.3 검증 명제를 반증 가능한 형태로 바꿨다
“프레임 드롭이 없다”는 통과인지 실패인지 애매하다. 기기를 바꾸면 결론도 바뀐다. 반증 가능한 형태로 좁히면 이렇게 된다.
금리 슬라이더를 드래그하는 한 프레임에서 대출 카드 리스트가 커밋되면 실패다.
| 명제 | 측정 방법 | 결과 |
|---|---|---|
| 드래그 중 카드 리스트 커밋 | 슬라이더 포커스 후 ArrowRight 20회, 커밋 카운트 | 커밋 0회 (마운트 시 1회) |
| 메인 스레드 렌더링 갱신 간격 | CPU 4배 스로틀, 슬라이더 값 변경 60회 동안 rAF 콜백 간격 | p95 17.6ms |
두 번째 줄은 원래 “기준 33ms를 넘지 않았다”고 써두었는데, 표를 정리하다 그렇게 쓸 수 없다는 걸 알았다. p95는 하위 95%가 어디에 있는지만 말한다. 남은 5%가 33ms를 넘었는지, 넘은 것들이 연속이었는지는 이 값으로 알 수 없다. 두 프레임 연속 누락을 주장하려면 33ms 초과 횟수와 최댓값, 연속 초과 최대 길이를 따로 집계해야 하고 표본도 60회보다 늘려야 한다. 지금 이 측정으로 말할 수 있는 건 콜백 간격의 95%가 17.6ms 이하였다는 것까지다.
용어도 구분해야 한다. rAF 콜백 간격은 메인 스레드가 렌더링 갱신을 얼마나 자주 돌았는지이고, 화면에 프레임이 실제로 표시됐는지와는 다르다. 표시 단위의 누락은 DevTools Performance 패널의 dropped frame 표시로 따로 봐야 한다. 이걸 그냥 “프레임 드롭”이라고 부르면 안 되는 이유다.
커밋 0회를 확인할 때 한 가지를 같이 봤다. “0이 나왔다”와 “계측이 살아 있는데 0이다”는 다르다. 계측이 죽어 있어도 0은 나온다. 그래서 같은 테스트가 마운트 시점의 커밋 1회를 먼저 단언하게 했다. 계측이 죽으면 그 단언에서 먼저 실패한다.
입력을 화살표로 준 건 자동화가 쉬웠기 때문인데, 결과적으로 하나를 더 덮었다. 4.4에서 보겠지만 화살표 20회는 커밋 경로를 20번 두드리는 입력이다. 커밋 쪽 방어가 빠져 있으면 카드 리스트 말고 다른 데서라도 반응이 나온다.
간격을 잴 때 CPU 스로틀을 건 이유도 같은 맥락이다. 개발용 맥에서 잰 좋은 수치는 아무것도 증명하지 못한다. 문제가 생기는 곳은 항상 그보다 느린 기기다. 다만 이걸 실기기 수치로 환산할 공식은 없다.
스로틀은 로컬 CPU 성능을 상대적으로 낮출 뿐, 실기기의 GPU와 발열, 입력과 디스플레이 파이프라인, 웹뷰 환경까지 재현하지 못한다. Chrome 문서도 저사양·중급 모바일의 성능을 가늠해 보는 수단이라고 설명한다. 여기서 확인한 건 느린 환경에서도 이번 측정(p95) 범위에서는 메인 스레드가 프레임을 통째로 밀어내는 징후가 보이지 않았다는 것까지다.
덧붙여 흔히 쓰는 Long Task 기준 50ms는 한 프레임 예산 16.7ms와 다른 지표다. 50ms를 안 넘겼다고 60fps가 지켜진 게 아니다.
수치의 출처도 구분해야 한다. 당시 분석은 개발 빌드로 했는데, React 개발 빌드는 렌더 시간이 크게 부풀려져서 그대로 블로그에 쓰기 어렵다. 위 표의 숫자는 프로덕션 빌드에서 얻었다. 여기에 함정이 하나 있다.
표준 프로덕션 React는 <Profiler>의 onRender 콜백을 아예 호출하지 않는다. 트리는 정상적으로 그려지는데 콜백만 죽어 있어서 카운터가 계속 비어 있었다. 프로파일링 전용 빌드로 별칭을 걸어야 하는데, 별칭 대상 이름이 React 버전에 따라 다르다는 것도 같이 챙겨야 한다.
4.4 매 프레임 갱신과 커밋 시 1회를 가른다
상태 갱신은 매 프레임 필요하고, URL 쓰기는 사용자가 조작을 끝냈을 때 한 번만 필요하다. 빈도가 다른 두 작업을 같은 이벤트에 걸면 안 된다.
처음엔 pointerup에 URL 쓰기를 걸었다. 이걸로는 부족하다. 키보드 화살표로 슬라이더를 움직이면 pointerup이 발생하지 않는다. 그러니까 키보드만 쓰는 사용자는 공유 링크를 만들 수 없다. 드래그 중에 전화가 와서 pointercancel이 나거나, 탭을 전환해버려도 마찬가지다.
여기에 실무 함정이 하나 더 있다. 우리 슬라이더는 커스텀이었지만, 이 함정은 네이티브 <input type="range">에서 훨씬 뚜렷하게 드러난다. 이벤트 이름과 시점이 어긋나는 구조 자체가 같으니 range로 설명한다.
// React의 onChange는 조작 중 값이 바뀔 때마다 연속으로 호출된다.
<input type="range" onChange={handleDrag} />
// 커밋 시점의 네이티브 change는 ref로 직접 걸어야 잡힌다.
useEffect(() => {
const el = ref.current
if (!el) return
el.addEventListener('change', handleCommit)
return () => el.removeEventListener('change', handleCommit)
}, [handleCommit])<input type="range">는 포인터로 조작을 끝내면 네이티브 change 이벤트를 쏜다. 그런데 React의 onChange는 네이티브 change가 아니라 input처럼 동작한다. 공식 문서도 “브라우저의 input 이벤트처럼 동작한다”고 적는다. 이름은 같은데 시점이 다르다.
onInput (React onChange) → 값이 바뀔 때마다: 상태 갱신
change (네이티브) → 커밋 시: URL 쓰기다만 이 대응만으로는 안 된다. 포인터 드래그에서는 세 엔진 모두 드래그를 놓을 때 change를 한 번 쏘지만, 키보드 화살표에서는 세 엔진 모두 키를 누를 때마다 쏜다. 그러니까 change를 “조작 종료”의 동의어로 믿으면, 화살표를 스무 번 두드린 사용자에게 URL을 스무 번 쓴다. HTML 표준이 range의 커밋 시점을 포인터 드래그 예시로만 설명하고, 무엇을 커밋으로 볼지를 사용자 에이전트 재량으로 남겨둔 결과다.
그래서 커밋 경로를 이벤트 하나가 아니라 입력 방식별로 나눠야 한다.
| 역할 | 수단 |
|---|---|
| 실시간 값 반영 | 포인터 이동 핸들러 (네이티브 range라면 React onChange) |
| 포인터 종료 | pointerup, pointercancel |
| 키보드 종료 | keyup + 짧은 트레일링 debounce |
| 이탈 안전망 | blur, visibilitychange, pagehide |
| 중복 제거 | 마지막 기록값과의 dirty 비교 |
키보드에서 keyup만으로 부족한 이유가 있다. 화살표를 꾹 누르면 keydown이 반복되고 keyup은 뗄 때 한 번이라 이건 잡힌다. 문제는 톡톡 스무 번 두드리는 경우로, 이때는 keyup도 스무 번 난다. 150~300ms 정도의 트레일링 debounce를 얹으면 연타는 묶이고 사용자가 멈춘 뒤의 반응은 늦지 않는다.
dirty 비교만으로는 이게 안 풀린다는 걸 짚어둔다. 화살표를 스무 번 누르면 값이 스무 번 다 다르니 dirty 비교는 스무 번 다 통과한다. dirty 비교가 없애주는 건 같은 최종값에 대한 중복 트리거뿐이다. 예를 들어 keyup으로 이미 쓴 값에 blur가 한 번 더 걸리는 경우다. 연속된 서로 다른 값을 하나로 묶는 건 debounce의 일이고, 두 장치는 역할이 다르다.
기록 방식도 정해야 한다. 커밋마다 새 히스토리 엔트리를 쌓으면 사용자가 슬라이더를 열 번 만졌을 때 뒤로 가기를 열 번 눌러야 계산기를 벗어난다. 공유용 URL은 사용자가 어딘가로 이동한 결과가 아니다. 현재 상태의 표현일 뿐이니 replaceState 계열로 써야 맞다. 되돌리기가 필요하다면 브라우저 히스토리 대신 도메인 undo로 따로 만드는 게 낫다.
4.5 시도해봤지만 오답이었던 것
리렌더 이야기가 나오면 useDeferredValue가 따라온다. 슬라이더 값에 걸어봤는데 이건 오답이다.
// 제어 컴포넌트의 value가 지연되면 커밋 시 DOM이 뒤처진 값으로 되돌아간다.
const deferredRate = useDeferredValue(rate)
<input type="range" value={deferredRate} onChange={...} /> // 손잡이가 손가락에서 떨어진다제어 컴포넌트는 값이 바뀌는 이벤트에서 backing state를 동기적으로 갱신해야 한다는 게 전제다. 그 전제를 깨면 React가 입력을 이전 값으로 되돌린다. 같은 이유로 공식 문서는 “Transition으로 텍스트 입력을 제어할 수 없다”고 못 박고, 그 대안으로 useDeferredValue를 제시한다. 지연시켜도 되는 대상은 결과 쪽의 무거운 뷰다. 입력의 value는 항상 즉시 반응해야 한다.
5. Context만으로 해결하려 했다
그리고 Context 지옥에 도착했다
5.1 왜 라이브러리를 안 썼나
상태 관리 생태계가 빠르게 바뀌던 시기였다. 그전 프로젝트들에서 도구를 갈아탈 때마다 마이그레이션 비용을 치렀고, 그게 기능 개발보다 오래 걸린 적도 있었다. 그래서 이번엔 React가 제공하는 것만으로 풀어보기로 했다.
당시 판단으로는 합리적이었다고 지금도 생각한다. 문제는 이 결정 자체가 아니라, 이 결정이 어떤 조건에서 무너지는지를 몰랐다는 데 있었다.
5.2 역할과 책임으로 나눴고, 실제로 줄었다
대출 목록, 시뮬레이션 값, 결과, UI 열림 상태, 마이데이터, 저장소를 각각의 Context로 나눴다. 관심사별로 갈랐으니 구독 범위도 갈린다는 계산이었고, 실제로 리렌더가 줄었다.
당시 구현에서는 세 가지가 함께 작용했다. 하나씩 빼보면 그때마다 리렌더가 돌아왔다.
첫째, Provider value를 useMemo로 안정화한다. 4.1에서 본 인라인 객체 문제다.
둘째, 상태와 dispatch를 다른 Context로 분리한다. useReducer가 주는 dispatch는 참조가 안정이라, 값을 읽지 않고 쓰기만 하는 컴포넌트는 값이 바뀌어도 리렌더되지 않는다. 대출 추가 버튼 같은 것들이 여기 해당한다.
셋째가 결정적이었다. Provider가 자식을 children으로 받는다.
// Provider 자신이 리렌더돼도 children은 부모가 만들어 넘긴 element 참조 그대로다.
// 참조가 같으면 React가 그 서브트리 렌더를 건너뛴다.
function CalculatorProviders({ initialLoans, initialSim, children }) {
return (
<LoanListProvider initial={initialLoans}>
<SimProvider initial={initialSim}>{children}</SimProvider>
</LoanListProvider>
)
}여기에 조건이 하나 붙는다. 상태는 각자의 Provider 안에 넣는 편이 안전하다. 부모가 두 useReducer를 다 들고 있으면 한쪽이 바뀔 때 부모가 리렌더되고, 그때 두 value가 어떻게 만들어지느냐에 따라 결과가 갈린다.
정확히 하면 부모 리렌더가 곧 value 재생성은 아니다. 각 value가 자기 상태만 의존성으로 갖는 useMemo로 감싸여 있으면 무관한 쪽의 참조는 유지된다. 문제는 그 규율을 부모 한 곳에서 계속 지켜야 한다는 것이다. 상태를 각 Provider 안으로 내리면 규율이 아니라 구조가 그걸 보장한다. 당시 우리 코드는 규율 쪽이었고, 의존성 배열 하나가 어긋나면서 두 value가 같이 새로 만들어지고 있었다.
5.3 의존 그래프는 그래프인데 Provider는 트리다
여기서 무너지기 시작했다.
계산기라는 도메인의 특성상 값이 계속 파생된다. 결과는 대출 목록과 시뮬레이션 값 둘 다에 의존한다. 그러면 결과 Provider는 그 둘 아래에 있어야 한다. 슬라이더의 하한은 보유 대출의 잔여기간에서 나오므로 시뮬레이션 Provider도 대출 목록 아래여야 한다.
이런 게 하나씩 쌓이면 Provider 중첩 순서가 곧 의존 그래프가 된다. 트리에서의 위치가 계약이 되고, 컴포넌트는 자기가 어디에 놓이는지에 강하게 묶인다.
요구사항이 붙을 때마다 이 순서가 흔들렸다. 마이데이터 계좌가 대출 목록에 합쳐지면 마이데이터 Provider가 위로 올라가야 한다. 그런데 마이데이터 로딩 상태와 실패 안내를 그리려면 UI Context를 봐야 하고, 반대로 UI 쪽은 열려 있는 카드에만 연동 버튼을 띄우려고 마이데이터를 봐야 한다.

왼쪽 아래의 점선 두 개가 서로를 가리킨다. 오른쪽 트리에는 이 관계를 놓을 자리가 없다. UIProvider에 붙은 물음표가 그 자리다.
당시 나는 이걸 순환 의존이라고 불렀는데, 지금은 정확한 표현이 아니라고 생각한다. 트리는 순환을 표현할 수 없다. 실제로 생긴 건 A는 B 아래 있어야 하고 B도 A 아래 있어야 한다는 모순된 제약이고, 트리가 그걸 담지 못하니 뭔가를 포기해야 하는 상황이었다. 당시 시도한 우회는 세 가지였다.
값을 prop으로 우회시키면 Context를 쓴 의미가 줄어든다. 두 Context를 하나로 합치면 합친 순간 그 안의 소비자가 전부를 구독하게 되어 분리한 효과가 사라진다. 한쪽을 런타임으로 미루면 초기값이 없는 시점을 화면이 감당해야 한다.
그렇게 우회하다 보니 Provider가 열 겹이 됐다. 순서를 읽을 수 있는 사람은 나뿐인 wrapper 컴포넌트가 남았다.
5.4 Context가 못 한 것과 내가 Context 주변에 설계하지 않은 것
역할과 책임을 아무리 잘 나눠도 남는 문제가 둘 있었다. 처음엔 둘 다 Context 탓이라고 생각했는데, 나중에 보니 성격이 달랐다.
selector가 없다. 시뮬레이션 Context에 금리, 기간, 상환방식이 함께 들어 있으면 금리만 쓰는 컴포넌트도 기간이 바뀔 때 리렌더된다. Context는 값이 바뀌면 구독자를 전부 깨우고, 그중 누가 무엇을 읽었는지는 보지 않는다.
이건 Context의 설계 자체다. React가 아직 안 만들어준 기능이 아니라 이 API가 그렇게 생겼다. 그렇다고 속성마다 Context를 하나씩 둘 수는 없다. 잘 나눈 구조가 성능을 보장하지 못한다는 게 이 도구의 성질이었다.
정면으로 푸는 방법은 외부 스토어를 두고 selector로 구독하는 것이고, React 18의 useSyncExternalStore가 그 표준 진입점이다. 다만 이 훅만으로 끝나지는 않는다. 이 훅에는 selector 인자 자체가 없고, 넘기는 건 subscribe와 getSnapshot뿐이다. React는 스냅샷을 Object.is로 비교하므로, getSnapshot이 호출마다 새 객체를 반환하면 무한 리렌더가 나고 “getSnapshot의 결과는 캐시되어야 한다”는 에러를 받는다. selector와 커스텀 동등 비교가 필요하면 use-sync-external-store/shim/with-selector의 useSyncExternalStoreWithSelector를 써야 한다. 상태 관리 라이브러리들이 실제로 얹고 있는 층이 바로 이 selector와 동등 비교다.
상태 전이가 기록되지 않았다. 어떤 조작이 어떤 상태를 바꿨는지 남는 흔적이 없었다. setState가 컴포넌트마다 흩어져 있으니 “결과 금액이 왜 이 값이 됐지”를 되짚을 방법이 없었다. 상태가 복잡한 페이지에서는 이게 성능보다 큰 비용이다.
그런데 이건 Context가 못 하는 일이 아니다. useReducer로 전이에 이름을 붙이고 dispatch를 단일 경로로 모으면 Context 위에서 그대로 된다. React 공식 문서가 안내하는 조합이기도 하다. 내가 그 설계를 안 했을 뿐이다.
둘을 갈라놓고 나서야 결론이 정리됐다. 앞의 것은 도구를 바꿔야 풀리는 문제였고, 뒤의 것은 도구는 그대로 두고 내가 설계했어야 하는 문제였다. 라이브러리를 안 쓴 게 잘못이었다면 답은 라이브러리를 쓰는 것이겠지만, 절반은 그렇지 않았다.
6. 실패의 정의
동료가 못 고치는 코드
6.1 고칠 수 있는 사람이 나뿐이었다
리뷰에서 “여기는 왜 이 순서예요”라는 질문을 받으면 트리 전체를 설명해야 했다. 새 요구사항이 들어왔을 때 어느 Provider 아래에 넣어야 하는지가 어디에도 적혀 있지 않고 내 머릿속에만 있었다.
구조를 만든 사람만 고칠 수 있는 코드는 동작하더라도 실패한 코드다. 이게 이 프로젝트에서 내가 얻은 가장 비싼 교훈이다. 구조는 만든 사람이 없어도 읽을 수 있어야 다음 사람이 고칠 수 있다.
6.2 예상하지 못한 요구사항이 붙었다
처음 설계할 때 놓친 게 하나 더 있었다. 요구사항이 계속 늘었다.
대출 목록을 localStorage에 저장하고 다음 방문에 불러오는 기능이 붙었다. 상태 수명주기가 한 겹 더 생겼고, 여기에 하이드레이션 문제가 딸려 왔다. localStorage는 서버에 없다. 초기 렌더에 반영하면 서버가 그린 결과와 클라이언트가 그린 결과가 어긋난다. 마운트 이후에 반영해야 하고, 그러면 반영되기 전까지의 화면을 따로 설계해야 한다.
덧붙이면 5.4절에서 나온 useSyncExternalStore의 세 번째 인자 getServerSnapshot이 정확히 이 문제를 위해 있다. 서버 렌더링뿐 아니라 클라이언트의 하이드레이션 시점에도 이 값을 쓰고, 하이드레이션이 끝난 뒤에 getSnapshot으로 넘어간다. 양쪽이 같은 초기 스냅샷을 보게 해서 불일치를 구조적으로 막는 셈이다. 당시엔 없던 API다.
웹뷰에서는 마이데이터로 받아온 계좌를 절대 localStorage에 저장하면 안 됐다. 보안 요구사항이었다. 그런데 이 규칙이 정책 문서에만 있으면 6개월 뒤에는 지켜지지 않는다. 당시엔 리뷰어의 기억으로 막고 있었다. 사람의 기억에 기대는 방어는 사람이 바뀌면 같이 사라진다.
네이티브 이벤트 구독도 여기서 문제가 됐다. 네이티브에서 웹으로 이벤트를 보내는 방식은 보통 전역 함수를 등록해두는 형태라 React 생명주기와 어긋난다. 구독 해제를 놓치면 중복 구독되고, 화면이 백그라운드에 있는 동안 발생한 이벤트는 그냥 사라진다. 그래서 이벤트만 믿으면 안 되고, 포그라운드로 돌아왔을 때 재조회를 함께 걸어야 한다.

출처가 셋이 되면서 우선순위를 정해야 했다.
| 출처 | 신뢰 | 규칙 |
|---|---|---|
| 마이데이터 | 서버가 진실 | 저장하지 않는다. 매번 다시 받는다 |
| URL | 명시적 의도 | 있으면 이긴다 |
| localStorage | 암묵적 이력 | 덮어쓰지 않는다. 불러올지 묻는다 |
공유 링크로 들어온 사람의 화면을 로컬에 남아 있던 데이터로 덮으면 안 된다. 반대로 사용자가 저장해둔 데이터를 조용히 날려서도 안 된다. 둘 다 만족시키는 방법은 자동으로 불러오지 않고 “저장해둔 대출 3건 불러오기” 같은 버튼으로 남기는 것이었다.
여기서 하나 더 정해야 했다. URL에 무엇까지 담을 것인가.
쿼리스트링은 사용자에게만 보이는 값이 아니다. 브라우저 히스토리에 남고, 서버와 리버스 프록시 액세스 로그에 요청 대상 그대로 남고, 분석 도구가 페이지 주소를 보낼 때 쿼리까지 실려 가고, 페이지에 올라온 서드파티 스크립트는 location.search를 그냥 읽을 수 있다. 인코딩은 이 중 무엇도 막지 못하고 HTTPS도 로그 문제는 못 막는다. 대출 잔액과 금리는 사람에 따라 충분히 민감한 정보다.
Referer는 이 목록에서 생각보다 약한 축이다. 현재 브라우저 기본 정책인 strict-origin-when-cross-origin에서는 같은 출처 요청에만 경로와 쿼리가 실리고, 외부 출처로 나갈 때는 출처만 간다. 다만 기본값이라는 건 바뀔 수 있다는 뜻이기도 해서, 응답 헤더로 느슨한 정책을 내리고 있으면 외부에도 노출된다. 기본값에 기대는 것과 정책을 명시하는 것은 다른 일이다.
그래서 경계는 이렇게 그어야 했다. 계좌 식별자와 마이데이터에서 온 값은 URL에 넣지 않는다. 공유 링크에 실리는 건 사용자가 직접 입력한 시뮬레이션 조건뿐이고, 그것도 분석·오류 수집 도구에서는 쿼리를 마스킹한다.
1절의 웹뷰 마커도 같은 선 위에 있다. 마커는 화면을 가르는 데만 쓴다. 실제 권한은 서버가 토큰을 보고 판단한다. localStorage에 저장하지 않는 것도, URL에 담지 않는 것도, 서버가 매번 다시 내려주는 것도 전부 방어층이지 그 자체가 경계는 아니다.
6.3 플래그가 번졌다
출처를 구분하려고 대출 데이터에 불리언 플래그를 하나 넣었다. 처음에는 스타일 분기용이었다. 마이데이터에서 온 계좌는 다르게 보여야 했으니까.
그런데 이 플래그가 계산으로 번지고 저장으로 번졌다. 왜 번지는지가 중요하다. 모든 출처가 같은 Loan 타입이고 저장 함수도 Loan을 받는 구조라면, 컴파일러는 저장 전에 걸러야 한다는 사실을 강제할 수 없다. “이 대출은 저장하면 안 된다”가 런타임 필드 하나로만 표현돼 있으면, 저장 함수 앞에 if를 빠뜨렸는지 확인할 방법이 코드 리뷰밖에 없다.
정책이 명확할수록 코드로 강제해야 한다는 걸 이때 배웠다. 리뷰어의 기억이 아니라 타입이 막아야 6개월 뒤에도 지켜진다.
6.4 그래서 내린 결론
React Context는 상태 관리 도구가 아니라 상태 전달 구조다. 전달의 문제는 잘 푼다. 관리는 이 도구가 대신 해주지 않는다.
그러니까 내가 한 일은 전달 도구에게 관리를 기대한 것이었다. 그중 필요한 조각만 구독하는 일은 아무리 설계해도 이 도구로는 안 됐고, 전이를 추적하고 의존 관계를 명시하는 일은 내가 도구 옆에 지었어야 하는데 짓지 않았다. 그 결과가 열 겹의 Provider와 나만 읽을 수 있는 트리였다.
7. 3년 뒤, 문제의 성격이 바뀌었다
같은 코드가 3년 뒤에 다른 문제가 됐다
5년차에 리드를 맡았을 때 회사의 계산기는 10종이 넘어 있었다. 청년도약계좌 계산기, 대출 이자 계산기, 여윳돈 계산기처럼 정부 정책 발표 주기에 맞춰 빠르게 내야 하는 것들이었다. 게다가 계산기로 자기 숫자를 확인한 사용자가 상품 신청까지 이어지는 비율이 높다는 게 데이터로 확인되면서, 계산기의 위상 자체가 달라져 있었다.
이 문제는 계산기가 10종으로 늘면서 다른 규모로 돌아왔다. 계산기마다 계산식을 따로 갖고 있다 보니 정책이 바뀔 때마다 어디는 반영되고 어디는 반영되지 않는 장애로 이어졌고, 기능 결함을 넘어 팀 신뢰도 문제가 됐다.
3년 전의 문제와 성격이 다르다는 게 여기서 분명해졌다. 2년차의 문제는 한 페이지 안에서 프레임과 커밋을 어떻게 가르느냐였다. 5년차의 문제는 열 개의 페이지가 같은 산술을 언제 함께 틀리느냐였다.
원칙은 하나였다. 변경 이유가 다르면 층을 나눈다. 가장 자주 바뀌는 것과 가장 정확해야 하는 것이 서로 닿지 않게 한다는 뜻이다. 어떤 층으로 갈랐고 정책 데이터에 시행일자를 어떻게 붙였는지, 6.3절의 불리언 플래그를 타입으로 어떻게 옮겼는지, 5절에서 실패했던 구독 문제를 같은 도구로 어떻게 다시 풀었는지는 2편에서 쓴다.
8. 닫는 글
프레임, 커밋, 구독, 그리고 상태
1편에서 넘어온 건 결국 경계 네 개다: 프레임, 커밋, 구독, 그리고 상태가 어디 살아야 하는가.
프레임
애니메이션이 버벅인 원인은 라이브러리 선택이 아니라 한 프레임에 무엇을 다시 돌게 했는지였다. 끝점이 정해진 건 CSS에 맡기고 값 자체가 매 프레임 새로 정해지는 건 rAF로 가져오는 기준을 세운 뒤로 이 판단이 흔들리지 않았다. 다만 이건 중간값을 누가 계산하느냐의 구분이지, 어느 쪽이 더 싸다는 보증은 아니었다.
커밋
계산이 프레임 예산의 0.02%밖에 안 쓰는데도 느렸다. 계산이 무겁다는 명제와 리렌더를 좁혀야 한다는 명제는 서로 독립이고, 각각 따로 측정해야 했다. 그리고 매 프레임 필요한 갱신과 조작이 끝났을 때 한 번만 필요한 쓰기를 같은 이벤트에 걸면 안 됐다.
구독
Context는 전달의 문제를 잘 풀지만, 필요한 조각만 구독하는 일은 이 도구로는 안 됐다. 여기서 갈라야 했던 건 도구가 못 하는 일과 내가 도구 옆에 짓지 않은 일이었다. 전자는 도구를 바꿔야 풀리고 후자는 내가 설계하면 풀렸는데, 한동안 둘을 같은 문제로 묶어놓고 있었다.
상태
같은 값이라도 어디에 저장하느냐에 따라 위험이 달랐다. 마이데이터는 서버가 진실이니 저장하지 않고 매번 다시 받았고, URL에 실린 값은 명시적 의도라 우선했고, localStorage는 암묵적 이력이라 덮어쓰지 않고 불러올지부터 물었다. 계좌 식별자를 URL에 담지 않은 것도, 정책을 리뷰어의 기억이 아니라 타입으로 옮긴 것도 같은 원칙이었다. 방어층을 늘리는 것과 경계를 긋는 것은 다른 일이었다.
앞의 세 경계는 증상이 비슷했다. 사용자는 다 “버벅인다”고 말하고, 나는 한동안 다 “리렌더 문제”라고 불렀다. 원인을 가르는 이름을 따로 갖는 것 자체가 작업의 절반이었다.
네 경계를 관통하는 결론이 하나 더 있다. 동작하는데도 실패한 코드가 있다는 것이다. 구조를 만든 사람만 고칠 수 있는 코드가 그렇다.
2년차의 나는 코드를 “무엇이 같은가”로 나눴다. 같은 대출 정보를 다루니 한 Context에 넣고, 같은 UI 상태니 다른 Context에 넣었다. 분류 자체는 깔끔했고 리뷰에서도 설명이 잘 됐다.
지금은 “무엇이 같은 이유로 바뀌는가”로 나눈다. 정부가 발표하면 바뀌는 것, 상품이 늘 때 바뀌는 것, 거의 바뀌지 않는 것은 같은 층에 있으면 안 된다. 같은 주제를 다룬다는 이유로 묶으면, 한쪽을 고칠 때마다 다른 쪽이 흔들린다.
첫 설계가 틀렸던 건 아니라고 생각한다. 계산기가 하나뿐이던 시점에 연산 코어를 미리 분리했다면 그건 근거 없는 추상화였을 것이다. 요구사항이 변경 축을 늘렸고, 내가 만든 구조가 그걸 따라가지 못했다.