전체 데이터를 브라우저에 두는 광고 대시보드 만들기

김동희
2026년 8월 20일

안녕하세요. 토스에서 광고주가 쓰는 대시보드를 만들고 있는 프론트엔드 개발자 김동희입니다.

저희 팀이 만드는 건 광고주를 위한 화면이에요. 캠페인을 만들고, 예산을 넣고, 어제 쓴 돈이 얼마나 성과로 돌아왔는지 확인하는 곳이죠.

말하자면 '데이터를 보는 도구'를 만듭니다. 그리고 도구를 만드는 일은, 겉으로 보이는 것보다 훨씬 프론트엔드다운 문제들을 던져줘요. 오늘은 그중 하나, 필터 한 번 누를 때마다 서버를 기다리던 대시보드를 고친 이야기를 해보려고 해요.

결론부터 말하면 저희는 서버가 하던 필터링·정렬·검색·페이지네이션을 전부 브라우저로 가져왔습니다. 그런데 흥미로웠던 건, 이 결정으로 얻은 게 속도만이 아니었다는 점이에요.

광고주는 대시보드를 '한 번' 보지 않아요

먼저 광고주가 이 화면에서 실제로 무엇을 하는지 짚어볼게요.

광고 대시보드에는 캠페인 → 광고세트 → 소재라는 3단계 계층이 있어요. 캠페인은 ‘이 예산으로 이런 목표를 달성하겠다’를 정하는 단위, 광고세트는 그 안에서 누구에게 언제 보여줄지를 정하는 단위, 소재는 실제로 사용자 눈에 보이는 이미지와 문구예요. 캠페인 하나에 광고세트 여러 개가, 광고세트 하나에 소재 여러 개가 붙습니다.

이 화면은 성과를 깊게 파고드는 분석 도구는 아니에요. 캠페인·광고세트·소재를 관리하면서 상태를 확인하는 곳에 가깝습니다.

광고주는 여기서 이런 걸 합니다.

한 번 보고 끝나는 화면이 아니에요. 가설을 세우고, 걸러보고, 아니다 싶으면 되돌아가는 탐색 도구에 가깝습니다.

문제는 저 모든 동작이 서버 호출이었다는 것입니다.

사용자: 필터 변경
  클라이언트: API 호출 (필터 파라미터)
  서버: 수백 ms
  클라이언트: 화면 갱신

한 번의 기다림은 참을 만해요. 그런데 탐색은 한 번이 아니죠. 필터 걸고, 정렬 바꾸고, 페이지 넘기고, 다시 필터 풀고. 열 번이면 수 초가 대기로만 쌓입니다. 그리고 이 시간이 만드는 진짜 비용은 대기 그 자체가 아니라 탐색 포기예요. "이 조합도 한번 볼까?" 하는 시도를 사용자가 하지 않게 되거든요.

느린 도구는 덜 쓰이는 게 아니라, 다르게 쓰입니다.

미리 말씀드리면 이건 저희가 지표로 확인한 게 아니라, 화면을 만들고 써보면서 세운 가정이에요. 다만 이 가정을 받아들이면 고쳐야 할 대상이 달라집니다. 응답 시간이 아니라, 사용자가 시도해볼 수 있는 횟수가 되거든요.

그런데 문제는 속도만이 아니었어요

여기까지만 보면 흔한 성능 개선 이야기입니다. 저희가 이 구조를 바꾸기로 한 진짜 이유는 따로 있었어요.

성능과 무관해 보이는 버그·요구사항이 계속 쌓이고 있었는데, 하나씩 뜯어보니 전부 뿌리가 같았습니다. 클라이언트가 전체 데이터를 갖고 있지 않다는 것이에요.

1. 성과가 0인 항목이 목록에서 사라져요

노출수 기준으로 정렬하면, 노출수가 0인 캠페인이 결과에서 아예 빠졌어요. 버그처럼 보이지만 서버 입장에서는 자연스러운 동작이었습니다. 성과 데이터는 중간 집계 테이블에서 나오는데, 집행되지 않은 캠페인은 애초에 그 테이블에 행이 없거든요. 없는 항목을 전부 0으로 채워 내려주는 건 집계 성능에 좋지 않고요.

그런데 광고주 입장에서 ‘노출이 0인 캠페인’은 없어져야 할 데이터가 아니라 가장 먼저 봐야 할 데이터입니다. 뭔가 잘못 세팅된 캠페인일 가능성이 높으니까요.

2. "캠페인 1234가 몇 페이지에 있지?"에 답할 수 없어요

특정 ID가 몇 번째 항목인지는 전체 순서를 알아야 계산할 수 있는데, 클라이언트는 지금 보고 있는 페이지밖에 갖고 있지 않았어요. 커서 기반 페이지네이션이라 서버에 물어볼 수단도 없었고요. 커서 방식은 ‘이 항목 다음 100개’처럼 직전 위치를 기준으로 다음 묶음을 받아오는 방식이라, 응답에 전체 순번이 담기지 않거든요.

물론 서버가 순번을 못 세는 건 아니에요. 순위를 계산해서 내려주는 API를 따로 만들면 됩니다. 다만 정렬 기준마다 그 API가 하나씩 필요했고, 그건 바로 다음에 나올 3번 문제와 정확히 같은 비용 구조였어요. 그래서 특정 ID로 바로 이동하는 기능은 계속 백로그에 남아 있었습니다.

3. 서버가 지원하지 않는 정렬은 불가능해요

"노출 상태(운영중/일시중지)로 정렬해주세요" 같은 요청이 올 때마다, 먼저 서버 API에 정렬 키를 추가해야 했어요. 화면에 이미 보이고 있는 값인데도요.

4. URL에 담긴 선택 상태를 검증할 수 없어요

저희 대시보드는 선택 상태를 URL에 담습니다. ?campaignIds=1&adsetIds=10&adIds=100 같은 식으로요. 링크로 공유하면 같은 화면이 열리게 하려고요.

그런데 클라이언트에 계층 정보가 없으니, 광고세트 10이 정말 캠페인 1의 자식인지 확인할 방법이 없었어요. 누가 URL을 손으로 고치거나, 오래된 링크를 열면 앞뒤가 안 맞는 화면이 나옵니다.

네 가지 문제를 나란히 놓고 보니 답이 명확해졌어요. 각각을 서버 API 스펙 추가로 하나씩 막는 건 가능했지만, 그건 같은 뿌리에서 계속 자라날 문제의 잎사귀만 잘라내는 일이었습니다.

클라이언트가 전체 데이터를 갖고, 목록의 기준을 클라이언트가 정하면 - 네 문제가 한꺼번에 정리됩니다.

결정: 진입할 때 전부 받고, 그다음은 브라우저에서

그래서 저희는 이렇게 바꿨어요.

AS-IS
TO-BE
API
단일 API
목록(Metadata) / 성과 / 부가 컬럼으로 분리
필터링·정렬·검색·페이지네이션
매번 서버 호출
메모리에서 즉시 처리
렌더링
전부 모인 뒤 한 번에
도착하는 순서대로 점진적으로

필터링·정렬·검색 응답 시간은 수백 ms에서, 기다린다는 느낌이 없는 수준으로 내려갔습니다.

"그거 데이터 많으면 안 되는 거 아니에요?"

맞아요. 그래서 이 결정에서 가장 중요한 건 아키텍처가 아니라 전제 조건이었어요. 전제가 깨지면 이건 좋은 결정이 아니라 나쁜 결정이 됩니다.

항목
근거
현재 최대 데이터
2만 건
데이터가 가장 많은 광고계정 기준
설계 목표
10만 건
현재 최대치의 5배 여유

즉 저희가 답해야 했던 질문은 "클라이언트 필터링이 좋은가?"가 아니라 "저희 데이터에 상한이 있는가, 그리고 그 상한을 숫자로 확정할 수 있는가?"였어요. 위의 2만 건은 캠페인·광고세트·소재를 전부 합한 수입니다.

상한을 확정할 수 있으면 그다음은 계산 문제가 돼요. 최악의 경우가 정해지니 메모리도, 연산 시간도, 초기 로딩도 미리 재볼 수 있거든요. 반대로 상한을 못 정하는 서비스라면 — 데이터가 사용자 수만큼 무한정 늘어나는 구조라면 — 이 결정은 하면 안 돼요. 언젠가 반드시 깨지기 때문입니다.

그래서 저희가 제일 먼저 한 일은 아키텍처를 그리는 게 아니라, 저희 데이터가 실제로 어디까지 가는지 세어보는 것이었습니다. 이 숫자를 먼저 확정하지 않고 구조부터 그렸다면, 잘 만든 틀린 설계가 나왔을 거예요.

API를 쪼갠 이유

전체 데이터를 받느라 첫 화면이 느려지면 아무 의미가 없죠. 그래서 응답 성격에 따라 API를 쪼갰어요.

쪼개고 나니 캐시 수명도 따로 정할 수 있게 됐어요. Metadata는 5분, 성과는 5초입니다. 캠페인 이름이나 계층은 자주 바뀌지 않지만 성과 숫자는 계속 갱신되니까요. 하나의 API였다면 둘 중 하나에 맞춰야 했을 텐데, 대개는 짧은 쪽에 맞추게 되고 그러면 잘 바뀌지도 않는 데이터까지 계속 다시 받게 됩니다.

이렇게 나누니 세 가지가 자연스럽게 풀렸습니다.

첫째, 점진적 렌더링이 가능해졌어요. Metadata가 도착하는 순간 표를 그리고, 나머지 컬럼은 스켈레톤 UI로 둡니다. 사용자는 가장 느린 API를 기다리는 대신 가장 빠른 API의 속도로 화면을 보게 돼요.

둘째, 성과가 0인 항목이 사라지지 않게 됐어요. 표에 어떤 행이 있는지는 Metadata가 정하고, 성과는 그 행에 ID로 붙는 값일 뿐이에요. 그래서 성과가 없는 캠페인도 행은 그대로 남습니다.

문제를 "성과가 0인 항목을 서버가 내려주게 하자"가 아니라 "목록의 주인이 누구인지 정하자"로 바꿔서 푼 셈이에요.

셋째, 컬럼을 덧붙일 자리가 생겼어요.

"목록의 주인은 Metadata이고, 나머지는 ID 기준으로 붙는다"는 규칙을 세우고 나니, 부가 컬럼 API는 특별한 존재가 아니라 그 규칙을 따르는 또 하나의 컬럼 공급자가 되더라고요. 네 번째, 다섯 번째가 와도 똑같고요.

그래서 표에 새 정보를 붙이는 일이 구조 변경이 아니라 추가가 됐어요. API를 하나 더 호출하고, ID로 병합하고, 도착하면 스켈레톤을 풀면 끝입니다. 표의 골격도, 필터·정렬 로직도 건드리지 않아요. 거꾸로 그 API가 실패하거나 느려도 나머지 화면은 멀쩡하고요. 성과 API가 실패하면 "최신 성과를 불러오지 못하고 있어요"라는 안내만 띄우고 표는 그대로 둡니다. 단일 API였다면 화면 전체가 에러로 갔을 상황이, 이젠 컬럼 하나가 비는 일이 된 거예요.

API를 쪼갠 동기는 속도였는데, 실제로 남은 건 표에 무엇이든 덧붙일 수 있는 자리였습니다.

그런데 여기엔 청구서가 딸려 와요

Metadata를 먼저 그리기로 한 순간, 데이터가 여러 번에 나누어 도착하는 화면이 됩니다.

표는 이미 떠 있는데, 데이터는 아직 도착하는 중이에요.

화면 설계를 그리는 단계에서 바로 보이는 문제였어요. 표는 떠 있는데 클라이언트가 가진 데이터는 계속 불어나죠. 이 어긋남은 두 가지 모양으로 나타났습니다.

첫째, 조작이 데이터보다 앞설 수 있어요. 성과 컬럼이 아직 스켈레톤인데 성과 기준 정렬을 누르면, 존재하지 않는 값으로 줄을 세우게 됩니다. 그래서 아직 도착하지 않은 컬럼은 정렬을 잠가둡니다. 이미 도착한 Metadata 컬럼은 그대로 정렬할 수 있고, 나머지는 값이 도착하는 대로 하나씩 풀려요.

둘째, 화면이 데이터를 따라가야 해요. 이쪽이 더 성가셨습니다. 새 응답이 병합되는 순간, 지금 보고 있는 100건은 다시 계산돼야 해요. 스켈레톤 자리에 값이 들어가야 하니까요. 그다음 응답이 도착하면 또 한 번. 데이터가 도착할 때마다 "다시 그려"라고 호출하는 방법도 있었지만, 그건 호출을 빠뜨리면 조용히 낡은 화면이 남는 코드입니다. 표에 붙는 API가 늘어날수록 잊지 말아야 할 지점도 같이 늘어나고요.

그래서 이 갱신을 캐시 키에 맡겼습니다. 새 데이터가 병합될 때마다 데이터 버전이 올라가고, 화면이 결과를 보관할 때 쓰는 캐시 키에 그 버전을 넣어뒀어요. 버전이 바뀌면 키가 바뀌고, 키가 바뀌면 이전 결과는 더 이상 쓰이지 않아서 화면은 데이터를 다시 요청합니다. 누구도 "데이터 왔으니 다시 그려"라고 말해주지 않아도요. 스켈레톤이 풀리는 것도 그 결과로 따라오는 일이 됐어요.

점진적 렌더링은 ‘먼저 보여주기’라기보다 ‘불완전한 상태를 정식 상태로 인정하기’에 가깝습니다. 로딩 스피너 하나를 스켈레톤으로 바꾸는 일이 아니라, 데이터가 절반만 있는 시간을 설계에 포함시키는 일이에요.

(이 방식이 최선인지는 저희도 계속 들여다보고 있습니다.)

10만 건을 메인 스레드에서 정렬하면 어떻게 될까요?

여기서부터가 프론트엔드의 영역입니다.

10만 건 배열을 filtersearchsortslice 하는 일을 메인 스레드에서 하면 오래 걸려요. 그동안 클릭도 입력도 큐에 쌓인 채 처리되지 않고, 리렌더도 멈춥니다. 그리고 이건 한 번으로 끝나지 않아요. 검색어를 한 글자 칠 때마다, 필터를 하나 더 걸 때마다 반복되고, 그 위에 React 리렌더가 얹혀요.

서버를 기다리는 시간과는 성격이 다릅니다. 응답을 기다리는 동안 화면은 살아 있어요. 스크롤도 되고 다른 버튼도 눌립니다. 반면 연산이 메인 스레드를 잡고 있는 동안은 화면이 통째로 굳어요.

그래서 데이터와 연산을 통째로 Web Worker로 보냈습니다. 한 가지 짚고 갈 게 있어요. Worker가 연산을 빠르게 만들어주지는 않습니다. 같은 코드가 다른 스레드에서 돌 뿐이에요. 달라지는 건 그동안 메인 스레드가 비어 있다는 점입니다. 연산이 얼마나 걸리든 스크롤도 되고 버튼도 눌리고, 리렌더도 멈추지 않아요.

Worker를 고른 이유는 연산을 줄이려는 게 아니라, 연산하는 시간과 화면이 굳는 시간을 떼어놓으려는 것이었습니다. 참고로 Worker 안에서 한 번의 필터링·정렬·검색·페이지네이션은 10ms 남짓에 끝나요.

메인 스레드                              Web Worker
─────────────                          ──────────────
UI
 요청
조율 계층     어떤 API를 언제 부를지 결정
 
통신 계층  ── 초기 데이터 (진입 , 대용량) ──▶  전체 데이터 보관
        ◀─ 현재 페이지 (조작마다, 소량) ─────  필터링·정렬·검색·페이지네이션

이 구조는 저희가 자체 개발한 데이터 계층이에요. 역할은 세 가지로 나눴어요.

조율과 통신을 굳이 나눈 이유는 하나예요. Worker가 죽는다는 사실을 조율 계층이 몰라도 되게 하려고요. 재생성·타임아웃·상태 확인이 전부 통신 계층 안에서 끝나서, 바깥에서는 그냥 데이터를 요청하는 코드로 보입니다.

그런데 왜 Worker가 직접 데이터를 받아오지 않나요?

위 그림을 보면 바로 걸리는 지점이 있어요. 어차피 데이터를 Worker가 들고 있을 거면, 처음부터 Worker가 API를 부르면 되지 않나? 메인 스레드를 한 번도 안 거쳐도 될 텐데요.

저희도 그 구조를 먼저 그려봤어요. 그런데 막힌 건 성능이 아니라 API 클라이언트였습니다. 저희 API 호출은 공통 HTTP 클라이언트를 거쳐요. 인증뿐 아니라 공통 에러 처리, 재시도 같은 게 전부 여기에 얹혀 있고요. Worker에서 직접 요청을 보내려면 이 계층을 Worker 쪽에 한 벌 더 두거나 별도 경로를 만들어야 했습니다. 같은 규칙을 두 곳에서 관리하게 되는 거죠. 전송 몇 번을 아끼려고 낼 값은 아니라고 봤어요.

대신 지금 구조에서 메인 스레드가 실제로 치르는 값을 따져봤습니다. 응답을 받아 Worker로 넘기는 일, 그게 전부예요. 본문을 해석하고 다루는 건 전부 Worker 몫이고요. 게다가 그 넘기는 일조차 API마다 한 번씩, 진입할 때가 전부입니다. 그 한 번을 어떻게 싸게 넘기는지는 바로 뒤에서 이야기할게요.

그래서 지금은 이 구조를 유지하되, 다음 개선 때는 HTTP 클라이언트를 Worker에서도 쓸 수 있는 형태로 정리해보려고 해요. 이건 "이게 정답이다"가 아니라, 지금 구조에서 메인 스레드가 치르는 값이 충분히 작다고 판단한 결과입니다.

Worker를 쓰면 따라오는 세 가지 함정

Worker는 ‘무거운 일 넘기면 끝’이 아니에요. 넘기는 비용이 따라옵니다.

1️⃣ 직렬화 비용. postMessage는 기본적으로 구조화 복제(Structured Clone)를 합니다. 보내는 쪽 데이터를 통째로 복사해서 받는 쪽에 새로 만드는 방식이에요. 10만 건을 매번 복사하면 Worker로 옮긴 이득이 사라집니다.

→ 그래서 초기 데이터는 응답을 res.arrayBuffer()로 받아 Transferable(ArrayBuffer)로 소유권만 넘깁니다. 메인 스레드는 본문을 파싱조차 하지 않아요. 복사 없이 버퍼의 주인을 바꿔버리는 방식이라, 보낸 쪽은 그 버퍼를 더 이상 쓸 수 없게 되는 대신 복사 비용이 0이에요. 이후 Worker가 돌려주는 건 현재 페이지 100건뿐입니다. 처음에 넘어간 전체 데이터와 비교하면 무시해도 될 크기예요. 위 그림의 화살표 두 개가 비대칭인 이유예요. 큰 건 진입할 때만 건너가고, 그다음부터는 작은 것만 오갑니다.

2️⃣ 메모리 이중화. 메인 스레드와 Worker가 같은 데이터를 각각 들고 있으면 메모리를 두 배로 씁니다.

전체 데이터는 Worker에만 둡니다. 메인 스레드는 지금 화면에 그릴 100건만 알아요. 전체 데이터를 클라이언트로 가져왔는데도 React가 들고 있는 양은 이전과 똑같은 셈이에요.

3️⃣ 비동기 복잡도. Worker 통신은 요청과 응답이 자동으로 짝지어지지 않는 이벤트 기반이에요.

→ 통신 계층이 요청 ID와 Promise를 짝짓는 일을 전담해서, 바깥에서는 그냥 await 하는 함수처럼 보이게 감쌌습니다.

Worker가 죽으면요?

이게 실무에서 제일 중요한 질문이었어요. 데이터를 Worker에 다 넣어뒀는데 Worker가 죽으면 화면이 통째로 빈 상태가 되니까요.

먼저 죽었다는 걸 알아야 해요. Worker에 5초마다 Ping을 보내고, 3초 안에 답이 없으면 죽은 걸로 봅니다. 사용자가 보낸 요청이 처리 중일 때는 이 검사를 건너뛰어요. 그 요청에는 이미 10초 타임아웃이 걸려 있으니까요.

죽은 걸 확인하면 Worker를 정리하는데, 그다음이 두 갈래로 갈립니다. 기준은 "지금 답을 기다리는 사람이 있는가"예요.

  • 대기 중인 요청이 있으면 → 그 요청들을 전부 실패시켜 ErrorBoundary로 올려보냅니다. 사용자가 "다시 시도"를 누를 때 복구해요. 누군가 화면 앞에서 기다리고 있는 상황이라면, 조용히 처리하는 것보다 무슨 일이 있었는지 알리는 게 맞다고 봤어요.
  • 대기 중인 요청이 없으면 → 사용자가 눈치채기 전에 알아서 복구합니다. 캐시를 무효화하면 데이터 로딩이 다시 돌고, API를 다시 호출해서 받은 버퍼를 새 Worker에 넣어요.

두 번째 경로를 보면 알 수 있듯이, 복구는 ‘캐시만 비우면 끝’이 아니에요. 데이터가 Worker에만 있으니, Worker가 죽었다는 건 데이터가 없어졌다는 뜻입니다. 서버에서 다시 받아오는 것 말고는 방법이 없어요. 진입할 때 했던 일을 그대로 한 번 더 하는 셈입니다. 이게 "전체 데이터를 클라이언트에 둔다"는 결정에 딸려 온 비용이에요.

그리고 죽은 순간의 정황을 같이 남깁니다. 대기 중이던 요청이 무엇이었는지, 탭이 백그라운드였는지, 그 시점 메모리 사용량은 얼마였는지. 메모리 부족으로 죽은 건지 다른 이유인지 나중에 판단하려면 이게 있어야 하거든요.

못 만들던 기능이 만들 수 있는 기능이 됐어요

여기까지가 구조 이야기예요. 이제 앞에서 세운 네 가지 문제로 돌아가볼게요. 저는 이 작업에서 이 부분이 제일 재미있었습니다.

1. 성과가 0인 항목이 사라지지 않아요

목록의 주인을 Metadata로 정하면서 자동으로 해결됐어요. 서버에 "성과가 없는 항목도 0으로 채워서 내려달라"고 요청할 필요가 없어졌고, 집계 테이블은 집계 테이블대로 남겨둘 수 있었습니다.

2. "캠페인 1234가 몇 페이지에 있지?"에 답할 수 있어요

전체 배열을 갖고 있으니 답이 두 줄이에요. 배열을 들고 있는 쪽이 Worker이니, 이 계산도 Worker 안에서 돌아갑니다.

const index = rows.findIndex((row) => row.id === targetId);
const page = Math.floor(index / PAGE_SIZE) + 1;

광고주가 어디선가 받은 캠페인 ID를 붙여넣으면, 그게 몇 페이지에 있든 바로 그 행으로 갑니다. 이 기능을 위해 추가한 서버 API는 없어요. 예전엔 이 두 줄을 쓸 수 없어서 기능 자체가 백로그에 있었습니다.

3. 서버가 모르는 기준으로도 정렬할 수 있어요

"노출 상태로 정렬해주세요" 같은 요청이 오면 비교 함수 하나를 추가하면 끝이에요. 서버 API 스펙 변경도, 배포 순서 조율도 필요 없습니다. 화면에 이미 보이는 값이라면 그 값으로 정렬할 수 있어요.

4. URL에 담긴 선택 상태를 검증할 수 있어요

Metadata에 부모 ID가 있으니, 진입할 때 ?campaignIds=1&adsetIds=10 같은 조합이 실제 계층과 맞는지 한 번 확인합니다. 광고세트 10의 부모가 캠페인 1이 아니면 그 선택은 버리고요. 오래된 링크를 열어도 앞뒤가 맞는 화면이 뜹니다.

네 개를 나란히 놓고 보면 공통점이 있어요. 전부 "서버에 무엇을 더 요청할까"가 아니라 "이미 가진 데이터로 무엇을 계산할까"의 문제가 됐습니다. 데이터를 어디에 두느냐를 바꿨을 뿐인데요.

고려했지만 선택하지 않은 것들

결정만큼 중요한 게 안 한 결정이라고 생각해서, 기각한 선택지도 남겨뒀어요.

Virtual Scrolling

큰 목록 이야기가 나오면 거의 자동으로 따라오는 카드죠. 하지만 저희는 100건 단위 페이지네이션을 씁니다. 한 번에 그리는 건 100행이고, 이 정도면 컬럼이 수십 개여도 DOM(Document Object Model) 부하가 병목이 아니라는 걸 확인했어요. Virtual Scrolling은 수천 개 행을 한 화면에 스크롤로 펼칠 때 필요한 도구고, 저희 UX는 그런 모양이 아니었습니다.

"데이터가 많다"와 "DOM이 많다"는 다른 문제인데, 자주 같은 문제로 취급돼요.

IndexedDB 캐싱

전체 데이터를 받는다면 브라우저에 저장해뒀다가 다음에 재활용하고 싶어지죠. 그런데 기각했습니다.

지금 필요 없는 걸 만들지 않는 것도 설계라고 봤어요.

감수하기로 한 것

트레이드오프가 없는 결정은 대개 결정이 아니라 취향이라고 생각해요. 저희는 위 세 가지를 알고 골랐습니다.

광고 도메인 프론트엔드는 이런 문제를 풀어요

이 글은 결국 대시보드 하나를 고친 이야기지만, 광고 도메인에서 프론트엔드가 마주치는 문제의 생김새를 꽤 잘 보여준다고 생각해요.

프론트엔드가 화면만 그리는 일처럼 느껴져 아쉬웠다면, 이런 문제들이 꽤 반가우실 거예요. 저는 그래서 이 도메인이 재밌습니다.

결정은 문서로 남습니다

저희는 이렇게 내린 판단을 코드와 문서에 남깁니다. 결정 문서에는 고른 선택지뿐 아니라 기각한 선택지와 그 이유가 같이 들어가고, 코드 주석에는 ‘왜 이 조건에서는 검사를 건너뛰는지’ 같은 판단이 남아요. 아직 확정되지 않은 부분에는 ‘변경 가능성 있음’이라고 적어두고요.

사실 이 글의 절반은 그때 남겨둔 문서에서 그대로 나왔습니다. 아티클을 쓰려고 새로 정리한 게 아니라, 이미 있던 걸 꺼낸 거예요.

아직 못 푸는 문제

지금 구조에서 가장 아픈 곳은 첫 진입이에요. 필터도 정렬도 즉시 끝나지만, 그 즉시가 시작되기까지가 느립니다. 진입할 때 전체 데이터를 받아야 하니까요.

점진적 렌더링으로 ‘표가 뜨는 시점’은 앞당겼지만, ‘표가 완전히 살아나는 시점’은 여전히 늦어요. 그리고 데이터가 많은 계정일수록 더 느립니다. 이 구조의 이득을 가장 크게 보는 계정이 비용도 가장 많이 내는 셈이에요.

곤란한 건 이걸 쉽게 버릴 수 없다는 점입니다. 이 글에서 이야기한 이득이 전부 ‘클라이언트가 전체 데이터를 갖고 있다’는 한 가지에서 나오거든요. 첫 페이지만 먼저 받는 식으로 진입을 빠르게 만들면, 그 순간 ID로 페이지를 계산하는 것도 URL 계층을 검증하는 것도 다시 불가능해집니다.

그래서 지금은 ‘전제를 지키면서 진입만 빠르게 할 수 있는가'를 보고 있어요. 아직 답은 없습니다.

이 글을 읽으면서 "나라면 이렇게 했을 텐데" 싶은 지점이 있었다면, 그 얘기를 듣고 싶어요.

읽어주셔서 감사합니다.

뉴스레터가 발행되면
이메일로 알려드릴게요
구독하기