대출 갈아타기 계산기, 렌더링 전략 분리
미디어쿼리가 나눌 수 없는 축이 있다
읽는 데 15분 정도 걸린다. 대출 갈아타기 계산기 글은 이 글을 포함해서 총 3개를 썼다. 처음 글은 2년차때 세 환경을 한 페이지로 설계한 이야기를, 이전 글에서 그 코드베이스를 5년차에서 FSD 기반 세 층으로 재설계한 이야기를 썼다. 이 글은 그 구조 위에서, 로딩 성능 개선을 위해 한 페이지에 눌려 있던 세 환경을 렌더링 전략 축으로 분리한 이야기다.
- 미디어쿼리는 레이아웃을 나누는 도구다. 데이터 출처와 렌더링 전략은 나누지 못한다.
getServerSideProps는 페이지 단위 선언이다. 렌더링 전략이 달라야 하는 두 화면이 한 페이지에 있으면, 더 무거운 전략이 전체를 지배한다.- 페이지를 쪼개는 비용은 그 페이지가 들고 있는 로직의 양에 비례한다. 지난 글의 구조가 그 비용을 미리 치러뒀다.
0. 한 페이지였던 이유
처음 이 페이지를 설계했던 글의 결정을 요약하면 이렇다. 대출 갈아타기 계산기는 PC 웹, 모바일 웹, 앱 웹뷰 세 환경에서 돌아야 했다. 세 환경이 다른 건 대출 데이터가 어디서 오는가뿐이고, 검증부터 계산, 결과 비교, URL 공유까지는 완전히 같았다. 페이지를 진입 경로별로 나누면 그 공통 로직이 두 벌이 된다. 그래서 한 페이지로 갔고, 레이아웃 차이는 미디어쿼리로 갈랐다.
그 결정은 당시 조건에서 옳았다고 지금도 생각한다. 이 글은 그 결정이 언제, 왜 수명을 다했는지, 그리고 무엇이 그 결정을 뒤집을 수 있게 만들었는지에 대한 이야기다.
1. 미디어쿼리로 버틴 시간
그때는 맞았다
한 페이지 + 미디어쿼리 구성의 장점은 명확하다. 소스가 하나, URL이 하나, 배포가 하나다. 공유 링크와 SEO가 단순해지고, 창 크기가 바뀌면 레이아웃이 연속적으로 따라온다. 태블릿 같은 경계 뷰포트도 별도 처리 없이 커버된다. 특히 우리에게 유리했던 건 모바일 웹과 웹뷰의 레이아웃이 99% 같았다는 점이다. 웹뷰가 모바일 웹 화면을 그대로 재사용했다.
균열은 PC 화면에서 시작됐다. PC 웹은 배치만 다른 게 아니라 구성이 달랐다. 카드가 옆으로 눕는 수준이면 CSS로 끝나는데, 보여주는 컴포넌트 트리 자체가 달라지는 지점들이 시간이 갈수록 정책이 추가되면서 생겼다. CSS가 가릴 수 없는 차이는 JS로 내려온다. matchMedia를 감싼 훅이 컴포넌트 곳곳에 박히기 시작했다.
JS 미디어 분기는 CSS 미디어쿼리와 비용이 다르다. 숨긴 쪽 트리도 번들에 들어 있고 하이드레이션 비용을 낸다. SSR과 만나면 더 나빠지는데, 서버는 뷰포트를 모르기 때문에 첫 렌더가 어긋나거나 UA로 추정해야 한다. 무엇보다 “이 화면이 어디서 열렸는가”라는 질문을 코드 곳곳에서 반복해서 묻게 된다. 같은 질문에 대한 답이 여러 곳에 흩어지면 언젠가 서로 어긋난다.
그래도 여기까지는 리팩토링 사유였지 분리 사유는 아니었다. 분리를 강제한 건 성능이었다.
2. 400ms의 딜레마
웹뷰에는 마이데이터로 사용자의 계좌 목록을 불러오는 구간이 있는데, 이 API가 상당히 느렸다. (2~3초) 첫 화면의 핵심 콘텐츠가 계좌 목록이니 LCP가 이 API에 묶인다. 순서로 쓰면 이렇다. HTML 다운로드, JS 다운로드와 실행, 그다음에야 API 왕복, 그다음에야 렌더. 전형적인 클라이언트 페칭 워터폴이다.
해법 자체는 교과서에 있다. SSR이다. 서버가 마이데이터를 미리 받아 HTML에 실어 보내면 워터폴이 접힌다.
문제는 getServerSideProps가 페이지 단위 선언이라는 것이다(Next.js getServerSideProps 참고). 컴포넌트 하나에만 적용할 수 없다. 선언하는 순간 이 페이지의 모든 요청이 매번 오리진에서 렌더되고, CDN 정적 캐시를 잃는다. sentry 트레이스로 재보니 정적 + CDN 히트 대비 응답이 약 400ms 느렸다.
400ms 자체보다 나쁜 건 그 비용을 내는 사람이다. 마이데이터는 웹뷰에만 있다. 그런데 페이지가 하나라서 마이데이터와 상관없는 PC 웹, 모바일 웹 사용자까지 전부 같은 400ms를 냈다. 소수를 위한 최적화의 청구서를 다수가 나눠 내는 구조다.
표로 정리하면 문제가 선명해진다.
| 환경 | 첫 화면에 필요한 데이터 | 맞는 렌더링 전략 |
|---|---|---|
| PC 웹 | 없음 (직접 입력) | 정적 + CDN |
| 모바일 웹 | 없음 (직접 입력) | 정적 + CDN |
| 앱 웹뷰 | 마이데이터 계좌 목록 (개인화) | SSR |
우회로들도 검토했다. s-maxage로 SSR 응답을 CDN에 캐시하는 건 마이데이터가 개인화 데이터라 불가능하다. ISR도 같은 이유로 무의미하다. 미들웨어에서 환경을 보고 rewrite하는 방법은 rewrite할 목적지 페이지가 둘 있어야 하니 결국 분리와 같은 말이다. 클라이언트 페칭을 유지하는 건 웹뷰 LCP를 포기하는 것이다.
남는 결론은 하나였다. 렌더링 전략은 페이지의 속성이다. 전략이 두 개 필요하면 페이지도 두 개여야 한다. (이건 getServerSideProps의 페이지 단위 제약에서 나온 결론이다.)
3. 분기 축이 두 개였다
분리를 결정하고 나니, 애초에 분기 축이 두 개였다는 게 보였다.
하나는 뷰포트 축이다. PC냐 모바일이냐. 레이아웃의 문제고, CSS가 볼 수 있는 축이다. 다른 하나는 환경 축이다. 웹이냐 웹뷰냐. 데이터 출처와 렌더링 전략의 문제고, CSS는 이 축을 볼 수 없다.
모바일 웹과 웹뷰의 레이아웃이 99% 같다는 사실이 두 축이 독립이라는 증거다. 환경이 달라도 레이아웃은 같을 수 있고, 환경이 같아도(PC 웹과 모바일 웹) 레이아웃은 다를 수 있다.
| PC | 모바일 | |
|---|---|---|
| 웹 | 정적 + CDN · PC 레이아웃 | 정적 + CDN · 모바일 레이아웃 |
| 웹뷰 | (없음) | SSR · 모바일 레이아웃 |
레이아웃은 열이 정하고 렌더링 전략은 행이 정한다. 미디어쿼리는 열만 본다. 우리는 두 축을 미디어쿼리 하나에 눌러 담고 있었고, 뷰포트 축은 잘 눌렸지만 환경 축이 삐져나온 것이 1절의 JS 분기와 2절의 400ms였다.
그래서 분리는 축을 따라갔다. 환경 축으로 페이지를 나눈다. 웹 페이지와 웹뷰 페이지. 뷰포트 축은 웹 페이지 안에서 계속 미디어쿼리 소관으로 남긴다.
뷰포트 축까지 페이지로 나누는 안, 그러니까 PC, 모바일 웹, 웹뷰 세 페이지로 가는 안도 검토했다. 기각했다. PC 웹과 모바일 웹은 렌더링 전략이 같아서 나눠도 성능에서 얻는 게 없고, 서버에서 둘을 가르려면 UA 판별이 필요한데 그건 1편에서 신뢰하지 않기로 한 도구다. 경계 뷰포트 처리도 다시 문제가 된다. 렌더링 전략이 같으면 한 페이지, 다르면 다른 페이지. 기준을 이렇게 두면 판단이 흔들리지 않는다.
부수 효과가 하나 있다. 웹뷰 판별 문제가 대부분 사라졌다. 웹뷰 페이지는 앱이 여는 URL 자체가 다르다. 진입이 판별이 아니라 주소로 결정된다. 1편에서 네이티브 마커로 조심스럽게 풀던 문제의 상당 부분이 라우팅 층위에서 증발했다.
4. 1편의 결정을 뒤집을 자격
여기까지 읽으면 물을 수 있다. 1편에서 페이지를 안 나눈 이유가 공통 로직 두 벌이었는데, 지금 나누면 그 문제가 돌아오지 않나.
돌아오지 않는다. 그 이유가 2편에서 사라졌기 때문이다.
검증과 계산과 정책은 entities와 shared에 있다. 화면 블록은 widgets와 features에 있다.
페이지 슬라이스에 남아 있는 건 조립뿐이다.
FSD에서 페이지는 얇은 조립 레이어이고, 얇은 것은 복제 비용이 싸다.
pages/
├─ loan-change/ # 웹 (PC·모바일). 정적 + CDN. 뷰포트 축은 CSS가 처리
│ └─ ui/Page.tsx # 위젯 조립 + 반응형 배치
└─ loan-change-webview/ # 웹뷰. SSR + 마이데이터 프리페치. 모바일 레이아웃 고정
└─ ui/Page.tsx # 같은 위젯, 다른 조립
widgets/ · features/ · entities/ · shared/ # 두 페이지가 공유한다. 코드의 대부분이 여기다분리하면서 실제로 한 일은 페이지를 복사한 게 아니라, 페이지에 남아 있던 것들을 FSD 레이어로 마저 내려보낸 것이다. 재사용할 수 있는 조각은 전부 widgets와 features로 내려가고, 두 페이지에는 서로 다른 조립만 남았다.
이 과정에서 흩어져 있던 JS 미디어 분기도 정리됐다. CSS로 되는 배치 차이는 CSS로 되돌리고, 컴포넌트 구성이 달라지는 차이는 조립 지점, 그러니까 페이지와 위젯 경계에서 결정하게 했다. 분기 자체는 남는다. 다만 위치가 컴포넌트 내부에서 조립으로 올라갔다. 같은 질문을 곳곳에서 묻던 코드가, 한 번 묻고 답을 내려보내는 코드가 됐다.
웹뷰 페이지 쪽은 오히려 단순해졌다. 웹뷰는 모바일뿐이니 미디어쿼리와 PC 분기가 통째로 필요 없다. 마이데이터 전용 코드, 그러니까 네이티브 이벤트 구독과 복귀 시 재조회 같은 것들은 웹뷰 페이지 슬라이스로 응집됐고, 웹 번들에서는 빠졌다. 1편 6절에서 “웹뷰에서는 마이데이터를 절대 localStorage에 저장하면 안 된다”던 정책도 지키기 쉬워졌다. 그 코드가 모여 있는 곳이 한 군데니까.
5. 결과
웹뷰 페이지는 SSR로 마이데이터를 프리페치한다. 이 프리페치가 성립하려면 첫 요청 시점에 서버가 이미 사용자를 알고 있어야 한다. 1편에서 세운 것과 같은 원칙이 여기도 적용된다: 권한은 서버가 토큰으로 판단하지, 화면이나 URL이 판단하지 않는다.
이 URL 자체도 권한이 아니다. 어떤 경로로 들어오든 마이데이터 접근 여부는 서버가 토큰으로 판단한다.
계좌 목록이 HTML과 함께 도착하니 클라이언트 워터폴이 접혔고, 웹뷰 진입 기준 LCP가 sentry 트레이스에서 45% 안팎으로 개선됐다. 다만 이 개선이 공짜는 아니다. 클라이언트에서 기다리던 API 응답을 서버가 대신 기다리는 동안, 그 시간은 TTFB로 옮겨간다.
웹 페이지는 정적 + CDN으로 돌아갔다. 마이데이터와 무관한 대다수 트래픽이 내던 400ms가 회수됐다.
유지보수 관점의 변화도 있다. “이 코드는 어느 환경의 것인가”라는 질문에 파일 위치가 답한다. 웹뷰 정책이 바뀌면 웹뷰 슬라이스만 열면 되고, 그 PR에 웹 코드가 딸려 들어오지 않는다. 2편에서 변경 이유로 층을 나눴을 때 얻은 것과 정확히 같은 종류의 이득을, 이번에는 환경 축에서 얻었다.
6. 닫는 글
시리즈를 관통한 건 결국 하나의 질문이었다. 무엇을 기준으로 나누는가.
1편에서는 로직 중복을 피하려고 페이지를 합쳤다. 2편에서는 변경 이유를 기준으로 로직을 층으로 나눴다. 3편에서는 렌더링 전략을 기준으로 페이지를 나눴다. 세 결정이 모순처럼 보이지만 순서가 중요하다. 로직이 페이지에 눌어붙어 있는 동안에는 페이지를 나누는 비용이 너무 컸다. 로직이 제자리를 찾고 나서야 페이지는 마음껏 나눌 수 있는 얇은 조립이 됐다.
미디어쿼리가 틀렸던 것도 아니다. 미디어쿼리는 자기 축에서 여전히 일하고 있다. 문제는 축이 하나 더 있다는 걸 늦게 알아챈 것이고.
지금 App Router의 Suspense 스트리밍을 쓸 수 있었다면 페이지를 나누지 않고도 느린 개인화 영역만 스트리밍하는 다른 해법이 있었을 것이다. 다만 당시 제약 안에서는 페이지 분리가 가장 단순했다.
이제 환경이 하나 늘거나 전략이 갈리는 일은 페이지 슬라이스를 하나 더 만드는 일이 된다. 그 정도면 이 시리즈의 구조가 할 일을 다 한 것 같다.