[RADIO로 시스템 디자인하기 #2] 자동완성 검색창 설계 부숴보기
RADIO로 시스템 디자인하기 시리즈의 두 번째 글입니다. Input과 Dropdown뿐인 작은 UI 안에 디바운스, 캐시, race condition, 접근성까지 — 자동완성 검색창을 RADIO 다섯 단계로 처음부터 끝까지 설계해봅니다.
TL;DR
자동완성 검색창은 겉으로 보면 Input과 Dropdown뿐인 작은 UI입니다. 하지만 막상 구현하려다 보면 여러 고민이 있어요.
- 입력 이벤트를 어떻게 제어할 것인가 (디바운스)
- 서버 호출을 어떻게 줄일 것인가 (캐시)
- 느린 네트워크 + 빠른 타이핑 사이의 race condition을 어떻게 막을 것인가
- 키보드/스크린리더 사용자는 어떻게 조작하는가 (접근성)
- 느려지거나 실패하면 어떻게 되는가 (운영)
그래서 자동완성 검색창은 프론트엔드 시스템 디자인의 작은 버전이에요.
이 글에서는 RADIO 프레임워크의 다섯 단계로 자동완성 검색창을 처음부터 끝까지 설계해봅니다.
💡 설계한 대로 직접 구현도 해봤어요
이 글은 설계에 관한 이야기지만, 글의 설계를 그대로 실제 동작하는 코드로 만들어봤습니다.
autocomplete-search-example (새 창에서 열림) 저장소에서 확인하실 수 있어요.
| 단계 | 핵심 질문 | 자동완성에서의 답 |
|---|---|---|
| R — Requirements | 무엇을 보장할 것인가? | 빠르고, 과호출 없이, 최신 입력에 맞는 추천어 |
| A — Architecture | 어떤 구조가 필요한가? | HTTP 요청-응답 + 디바운스 (웹소켓 아님) |
| D — Data Model | 데이터는 어떤 형태인가? | 응답에 query 포함, 캐시 + race condition 가드 |
| I — Interface | 경계의 계약은 무엇인가? | useAutocomplete 훅 + ARIA 접근성 계약 |
| O — Optimization | 운영에서 무엇을 재는가? | 타임아웃/폴백 + 검색 지표 관측 |
R — Requirements (요구사항)
무엇을 만들 것인가가 아니라, 무엇을 보장할 것인가.
문제 정의
검색 포털의 자동완성 검색창을 설계한다고 가정해볼게요.
사용자가 검색어를 입력하는 동안 추천 검색어 목록이 보여야 합니다. java를 입력하면 이런 식으로요.
java
ㄴ javascript
ㄴ java 다운로드
ㄴ javascript 배열
ㄴ java 설치
ㄴ javascript vs java
추천어를 클릭하거나 엔터를 누르면 검색 결과 페이지로 이동합니다.
"Input 컴포넌트를 만들고, onChange에서 API를 호출하고, Dropdown에 결과를 보여주면 되죠." 흔히 이렇게 접근하곤 합니다.
틀린 건 아니지만 이건 기능 목록에서 끝난 설계예요. 여기서 멈추면 실제 서비스에서 금방 한계에 부딪힙니다.
기능 요구사항 (Functional)
- 검색어를 입력하면 추천어 목록을 보여준다
- 추천어는 입력값이 바뀔 때마다 업데이트된다
- 추천어를 클릭하면 해당 검색어로 이동한다
- 키보드 ↑↓로 추천어 사이를 이동할 수 있다
- Enter를 누르면 현재 선택된 추천어(없으면 입력값)로 검색한다
- Escape를 누르면 추천어 목록을 닫는다
- 검색어가 너무 짧으면 추천어를 보여주지 않는다
비기능 요구사항 (Non-Functional)
- 추천어는 빠르게 보여야 한다 ("빠르게"를 곧 숫자로 정의합니다)
- API 호출을 과도하게 보내면 안 된다
- 느린 네트워크에서도 잘 동작해야 한다
- 모바일과 데스크톱을 모두 지원한다
- 접근성(키보드, 스크린리더)을 지원한다
- API가 실패해도 검색창 자체는 계속 사용할 수 있어야 한다 (추천이 안 뜰 뿐, 검색은 된다)
질문 하나하나가 설계 선택으로 이어져야 합니다
요구사항 단계에서 중요한 건 질문의 개수가 아니라, 질문 하나가 설계 결정 하나로 연결되는지입니다.
"몇 글자부터 추천어를 보여주나요?"
단순한 UX 질문처럼 보이지만 실제로는 서버 부하 문제입니다. 한 글자(j)부터 호출하면 결과 범위가 너무 넓어서 트래픽이 폭발할 수 있습니다. 그래서 보통 2~3글자 이상부터 요청하도록 제안합니다.
"개인화 추천인가요?"
캐싱 전략을 결정합니다. 개인화가 없으면 같은 쿼리 = 같은 응답이라 CDN/Edge 캐시를 공격적으로 쓸 수 있어요. 개인화가 있으면 사용자별로 응답이 달라져서 캐싱이 훨씬 복잡해집니다.
"추천어 랭킹은 누가 정하나요?"
서버가 정하면 프론트는 받아서 그대로 렌더링하면 됩니다. 프론트가 정하면(최근 검색어 섞기 등) 정렬/병합 로직이 클라이언트로 들어와요.
"빠르게"를 숫자로
빠르게라는 말은 사람마다 다르게 해석됩니다. 그래서 숫자로 못 박아야 해요.
자동완성에서 사용자가 체감하는 핵심 지표는 입력이 멈춘 시점부터 추천어가 화면에 뜨기까지 걸린 시간입니다.
여기엔 디바운스 시간 + 네트워크 + 렌더링이 전부 포함돼요.
목표치는 "p75 기준 500ms 이하"처럼 구체적인 숫자로 잡습니다.
A — Architecture (아키텍처)
"실시간처럼 보인다"고 전부 웹소켓이 아니다.
데이터 흐름
핵심은 Controller가 흐름의 중심이라는 점입니다. 입력값, 열림 상태, 하이라이트 인덱스, 캐시, 요청을 전부 한 곳에서 관장해요.
모든 키 입력이 같은 경로(캐시 확인 → 미스면 서버 → 응답을 캐시에 기록)를 타게 만들면 race condition과 stale 상태를 해결할 지점이 한 군데로 모입니다.
왜 WebSocket이 아니라 HTTP인가
이 부분이 이 설계에서 제일 중요한 판단입니다.
"실시간으로 추천어가 떠야 하니까 웹소켓을 써야죠!"라고 생각하기 쉽습니다. 그런데 자동완성에서 말하는 실시간은 채팅 같은 실시간과 달라요.
- 채팅: 서버가 클라이언트에 계속 push해야 하는 구조입니다. 상대가 언제 이벤트를 만들지 모르니 지속 연결이 필요해요
- 자동완성: 사용자가 먼저 입력 이벤트를 발생시키고 그에 대한 응답을 받아오는 구조입니다. 서버가 먼저 말을 걸 일이 없어요
즉 자동완성은 클라이언트 주도의 단방향 요청-응답 문제입니다. 지속 연결은 오버엔지니어링이고 HTTP GET + 디바운스가 더 단순하고 안정적이에요.
HTTP는 무상태라 수평 확장이 쉽고 CDN·브라우저 캐시를 그대로 쓸 수 있으며 AbortController로 요청 취소도 표준적으로 처리됩니다.
"실시간처럼 보이는 UI"라고 해서 전부 같은 아키텍처가 필요한 건 아닙니다.
요구사항(R)을 제대로 정리했기 때문에 이런 판단이 가능한 거예요. 기술부터 골랐다면 "실시간 = 웹소켓"으로 갔을 겁니다.
호출 제어는 디바운스로
매 키 입력마다 호출하면 j, ja, jav, java 네 글자에 요청이 네 번 나갑니다. 낭비죠.
디바운스는 입력이 멈춘 뒤 200~300ms가 지나면 그때 호출하는 기법입니다.
j입력 → 타이머 시작- 300ms 안에
a입력 → 이전 타이머를 취소하고 새 타이머 시작 - 300ms 동안 입력 없음 → 이제
ja로 API 호출
트레이드오프도 있습니다. 디바운스를 짧게(100ms) 잡으면 반응은 빨라지지만 서버 부하가 늘어나고 길게(400ms) 잡으면 부하는 줄지만 체감이 굼떠져요. 200~300ms가 통상적인 균형점입니다.
D — Data Model (데이터 모델)
어떤 응답을 믿을 것인가.
API 설계
HTTP GET /api/autocomplete?q=java&limit=10
limit을 쿼리에 넣어서 응답 크기를 클라이언트가 제어할 수 있게 합니다. 검색어에는 공백·한글·특수문자가 들어올 수 있으니 실제 요청을 만들 때는 URLSearchParams나 encodeURIComponent로 인코딩하는 걸 잊지 마세요.
응답은 이렇게 설계해요.
type Result = {
id: string;
text: string;
};
type AutocompleteResponse = {
query: string; // ← race condition 검증에 쓰는 필드
results: Result[];
};응답에 query가 들어있는 이유는 race condition 검증 때문입니다. 요청한 검색어를 응답이 그대로 실어 보내면 도착한 응답이 지금 입력값과 맞는지 대조해서 늦게 도착한 stale 응답(현재 입력과 맞지 않는 오래된 응답)을 걸러낼 수 있어요. 꼭 서버가 내려줘야 하는 건 아니고 클라이언트가 요청 당시의 query나 requestId를 들고 있다가 비교해도 됩니다. 다만 응답에 담아두면 네트워크 응답이든 캐시에서 꺼낸 결과든 "이 결과가 어떤 query 기준인지"를 데이터 자체가 설명하게 됩니다. 그래서 이 글에서는 응답에 담는 쪽으로 갑니다. 자세한 건 아래에서 다룹니다.
책임 분리도 여기서 결정됩니다.
- 서버 책임: 검색 인덱스, 인기 검색어, 개인화, 랭킹
- 프론트 책임: 추천어 렌더링, 선택 이벤트 처리, 캐싱, 로딩/에러 상태
상태 설계는 생명주기로 나누자
| 상태 | 종류 | 이유 |
|---|---|---|
inputValue | Local UI State | 지금 입력 중인 값. 이 화면에서만 유효 |
results | Server State | 서버 응답 기반. 캐시 대상 |
isLoading / error | Server State 부속 | 요청 생명주기에 종속 |
isOpen, highlightedIndex | Local UI State | 드롭다운 UI 조작 상태 |
canShowDropdown | Derived State | isOpen && results.length > 0 && !tooShort 같은 계산값 |
| 검색 결과 페이지 이동 | URL State | 공유/뒤로가기로 복구 가능해야 함 |
상태의 소유권과 수명 주기를 따라 나누면 "어디에 저장하지?"가 자연스럽게 결정됩니다. inputValue는 이 화면에서만 살고 results는 서버가 원천이며 검색 이동은 URL이 원천이에요.
자동완성 설계의 3가지 포인트
Debounce
서버 부하를 줄이는 장치입니다. 앞서 아키텍처에서 다뤘습니다.
Cache
중복 요청을 줄이는 장치입니다.
사용자는 java → javascript → 백스페이스 → 다시 java처럼 왔다 갔다 합니다. 같은 쿼리를 짧은 시간 안에 다시 서버에 물어볼 이유가 없죠. 쿼리 문자열을 키로 하는 인메모리 Map과 짧은 TTL이면 충분합니다.
cache = Map<query, { results, expiresAt }>
다만 query를 그대로 키로 쓰기보다 trim·소문자화 같은 정규화를 거치고 limit이나 locale·개인화 여부까지 키에 넣을지는 서비스 성격에 따라 정합니다.
Race Condition Guard
정확성을 위한 장치이자 자동완성 설계의 하이라이트입니다.
이런 시나리오를 생각해볼게요.
사용자가 java를 입력해서 요청 A가 나갔습니다. 이어서 javascript까지 입력해서 요청 B가 나갔어요. 그런데 네트워크 사정으로 B 응답이 먼저 오고 A 응답이 나중에 도착합니다.
아무 생각 없이 "마지막에 도착한 응답"을 렌더링하면 사용자는 javascript를 입력했는데 화면엔 java 기준 추천어가 뜨게 되죠.
응답 도착 순서는 절대 신뢰하면 안 됩니다. 시간순으로 보면 이렇습니다.
t0 ── "java" 입력 멈춤 ────────→ 요청 A 발사
t1 ── "javascript" 입력 멈춤 ──→ 요청 B 발사 (A는 아직 in-flight)
t2 ── 응답 B 도착 ──→ "javascript" 추천어 렌더링 ✅
t3 ── 응답 A 도착 ──→ 그대로 그리면 "java" 추천어가 화면을 덮어씀 ❌
방어는 두 겹으로 합니다.
- 새 요청이 생기면 이전 요청을 취소:
AbortController로 in-flight 요청을 끊습니다 - 응답이 도착하면 검증:
response.query === 현재 inputValue인지 확인합니다. 다르면 stale이므로 렌더링하지 않고 버립니다 (또는 캐시에만 기록)
// 요청 시
controller.abort(); // 이전 요청 취소
controller = new AbortController();
const res = await fetch(`/api/autocomplete?q=${query}`, { signal: controller.signal });
// 응답 시
if (res.query !== currentInputValue) return; // stale → 무시왜 두 겹이어야 할까요?
위 타임라인만 보면 t1에서 A를 취소하는 것으로 끝난 것 같습니다. 하지만 abort는 네트워크 요청이나 응답을 읽는 단계까지만 끊어줍니다. 이미 응답을 받아 애플리케이션 로직이 상태를 업데이트하려는 순간까지 자동으로 막아주지는 않아요. stale 응답을 화면엔 안 그리더라도 캐시에는 기록하고 싶어서 일부러 취소하지 않는 전략도 있고요. 취소를 빼먹는 코드 경로가 생길 수도 있습니다. 그래서 둘의 역할이 다릅니다.
- 취소: 자원 절약. 불필요한 응답 처리를 조기에 차단합니다
- 검증: 정확성의 최종 방어선. 응답이 어떤 경로로 왔든 렌더링 직전에 확인합니다
⚠️ "디바운스 했으니까 race condition은 해결된 거 아닌가요?"
아닙니다. 디바운스는 요청 개수를 줄일 뿐, 서버 응답 시간을 통제하지 못해요.
디바운스를 통과한 두 요청 사이에서도 얼마든지 순서가 뒤집힙니다. 취소 + 검증이 반드시 별도로 필요합니다.
I — Interface (인터페이스)
인터페이스는 API만이 아니다.
컴포넌트 구조는 책임 기준으로 나누자
"인풋이랑 드롭다운으로 나누면 되죠"보다 좋은 답은 컴포넌트의 책임을 기준으로 나누는 것입니다.
<Autocomplete> ← 전체 상태 조립 (Controller 역할)
<SearchInput /> ← 입력값, 포커스, 키보드 이벤트
<ResultDropdown> ← 추천어 목록 노출 + loading/empty/error 상태
<ResultItem /> ← 개별 추천어 렌더링, 하이라이트 표시
</ResultDropdown>
</Autocomplete>
useAutocomplete() ← 핵심 구현부. 디바운스/캐시/race guard/선택 로직이 여기 있음
포인트는 ResultDropdown이 목록만 그리는 게 아니라 loading, empty, error 상태까지 책임진다는 점입니다.
상태별 UI의 소속을 정해두지 않으면 로딩 스피너가 인풋에 붙었다가 드롭다운에 붙었다가 하며 떠돌게 되거든요.
UI 상태 흐름
사용자 입장에서 검색창이 거치는 상태를 나열해보면 이렇습니다.
| 상황 | 보여줄 것 |
|---|---|
| 기본 | 검색창만 표시 |
| 입력 중 (요청 대기) | loading indicator |
| 결과 있음 | 드롭다운에 추천어 목록 |
| 결과 없음 | "추천 검색어가 없습니다" |
| 에러 발생 | 검색은 유지 · 정책 따라 숨김 / 안내 / retry / 최근·인기 검색어 대체 |
| 추천어 탐색 중 | highlighted item 표시 — 지금 뭐가 선택돼 있는지 사용자에게 보여줌 |
이 표가 곧 ResultDropdown의 렌더링 분기가 됩니다. 데이터 모델에서 만든 canShowDropdown 같은 Derived State가 여기서 소비돼요.
useAutocomplete 훅 인터페이스
로직을 전부 컴포넌트 안에 넣으면 테스트하기 어렵고 재사용도 힘듭니다. 그래서 훅으로 분리해요.
이렇게 하면 검색창 UI가 통째로 바뀌어도 핵심 로직은 그대로 유지됩니다.
interface UseAutocompleteResult {
query: string;
setQuery: (value: string) => void;
results: Result[];
isLoading: boolean;
isError: boolean;
isOpen: boolean;
listboxId: string;
highlightedIndex: number;
highlightedResult?: Result;
handleKeyDown: (event: KeyboardEvent) => void;
}UI 컴포넌트는 이 반환값만 알면 됩니다. 내부에서 디바운스를 쓰는지, 캐시가 Map인지 LRU인지, AbortController를 쓰는지 몰라도 돼요. 덕분에 테스트도 훅 단위로 가능해집니다.
접근성도 인터페이스다
인터페이스라고 하면 API와 Props만 떠올리기 쉽습니다. 그런데 자동완성에서는 접근성도 인터페이스예요. 키보드/스크린리더 사용자와 맺는 계약이기 때문입니다.
검색창은 마우스로만 쓰는 게 아닙니다. 키보드만으로도 전부 조작할 수 있어야 하고 사용자가 지금 어떤 추천어가 선택돼 있는지 이해할 수 있어야 해요.
다행히 이 계약은 우리가 발명할 필요가 없습니다. 이미 표준이 있거든요. 바로 WAI-ARIA Combobox 패턴입니다.
- input에
role="combobox",aria-expanded(드롭다운 열림 여부),aria-controls(어떤 목록을 여는지),aria-autocomplete="list"를 부여합니다 - 드롭다운에는
role="listbox"를 부여하고 각 항목에는role="option"을 부여합니다. 활성/선택 상태를 표현해야 한다면aria-selected를 함께 씁니다 - 키보드로 항목을 이동할 때 DOM 포커스는 input에 유지하고
aria-activedescendant로 "지금 몇 번째 항목이 활성인지"를 보조기술에 알려줍니다 - 키보드 계약:
↓다음 항목,↑이전 항목,Enter선택,Escape닫기,Tab자연스러운 포커스 이동
aria-activedescendant 방식이 중요한 이유가 있습니다. 포커스를 실제로 옮기면 입력이 끊기거든요. 포커스는 input에 두고 "논리적 활성 항목"만 알려주는 것이 이 패턴의 핵심입니다. 대신 브라우저가 실제 포커스처럼 스크롤을 따라오게 해주지는 않으니, 하이라이트된 항목이 목록 밖으로 밀려나지 않도록 scrollIntoView 같은 처리를 직접 해줘야 합니다.
이런 디테일이 작아 보일 수 있지만 대규모 서비스는 모든 사용자가 접근할 수 있어야 합니다. 그래서 접근성은 시스템 디자인의 일부예요.
O — Optimization & Operation (최적화와 운영)
최적화는 나중에 하는 게 아니라, 설계의 일부다.
최적화는 이미 설계에 들어가 있습니다
눈치채셨겠지만 이 설계의 최적화 장치들은 O 단계에서 갑자기 등장한 게 아닙니다.
- 디바운스 → 아키텍처(A)에서 결정됐고
- 캐시 → 아키텍처와 데이터 모델(A, D)에서 결정됐고
- 요청 취소 → 데이터 모델(D)에서 결정됐습니다
최적화는 나중에 하는 게 아니라 설계의 일부라는 게 여기서 그대로 드러나요. O 단계에서 할 일은 남은 다듬기, 장애 대응, 관측, 운영입니다.
남은 다듬기
- 하이라이팅 비용: 추천어에서 입력 문자열 부분을 강조 표시할 때는, 무거운 정규식보다 단순 문자열 매칭 + CSS가 낫습니다.
dangerouslySetInnerHTML은 반드시 sanitize 후에만 사용해야 하고요 - 긴 목록이면 가상화: 추천어가 100개 이상 나오는 케이스(자동완성보단 콤보박스에 가깝죠)라면 화면에 보이는 항목만 렌더링하는 Virtualization을 적용합니다
- 빈 상태/에러 상태: 결과가 없으면 "검색 결과 없음"을 표시하고 입력을 지우면 추천어와 하이라이트 인덱스를 리셋합니다
장애 대응
요구사항(R)에서 정했던 조건을 회수할 차례입니다. "API가 실패해도 검색창 자체는 계속 사용할 수 있어야 한다"였죠.
- 타임아웃: 응답을 무한정 기다리지 않습니다. 일정 시간(예: 2~3초)이 넘으면 실패로 처리해요
- 폴백: 추천 API가 죽으면 드롭다운을 조용히 닫는 게 기본입니다. 다만 UX 정책에 따라 "추천을 불러오지 못했어요" 같은 짧은 안내나 retry를 줄 수도 있고 최근 검색어(로컬 저장)·인기 검색어 캐시가 있으면 그걸로 대체할 수도 있어요
- 핵심 원칙: 추천은 enhancement이지 필수 기능이 아닙니다. 추천이 안 떠도 사용자는 입력하고 엔터를 쳐서 검색할 수 있어야 해요. 추천 API 장애가 검색 기능 장애로 번지면 안 됩니다
관측
요구사항에서 정의한 지표를 실제 계측 이벤트로 떨어뜨립니다.
사용자가 실제로 체감하는 지표는 사실상 하나예요. 입력을 멈춘 순간부터 추천어가 화면에 뜨기까지 걸린 시간이죠. 요구사항(R)에서 숫자로 못 박았던 바로 그 지표입니다. 나머지는 전부 그 한 숫자를 설명하고 진단하기 위한 내부 계측이에요.
| 지표 | 무엇을 알려주나 |
|---|---|
api_latency_ms | 순수 서버 응답 시간. 아래 교차 읽기에 사용 |
api_error_rate / timeout_rate | 장애 감지 |
cache_hit_rate | 캐시 효율. 낮으면 TTL·캐시 키 전략 점검 |
stale_response_discard_rate | race guard가 실제로 얼마나 일하는지 |
stale_response_discard_rate는 재미있는 지표입니다. 다만 이 값 하나로 원인을 단정할 수는 없어요. 서버 지연, 네트워크 지연, 짧은 디바운스, 유난히 빠른 타이핑이 전부 같은 증상을 만들기 때문입니다. 그래서 다른 지표와 교차해서 읽어야 해요.
- discard rate ↑ +
api_latency_ms↑ = 서버/네트워크가 느린 것입니다. 타임아웃·캐시 쪽을 봐야 해요 - discard rate ↑ +
api_latency_ms정상 = 디바운스가 타이핑 패턴에 비해 짧다는 시그널입니다
지표는 단독으로는 증상이고 조합해야 진단이 됩니다. 그렇게 나온 진단이 다시 디바운스 시간이나 타임아웃 설계로 돌아오죠. 관측이 설계 튜닝으로 이어지는 루프입니다.
운영
많은 설계가 "기능이 동작하는 시점"에서 끝납니다. 하지만 배포 후에 어떻게 관리할 것인가까지가 설계예요.
- 디바운스 값: A/B 테스트 대상입니다. 100ms는 빠르지만 API 호출이 늘고 300ms는 부하를 줄이지만 사용자가 느리다고 느낄 수 있어요. 정답은 책상 위가 아니라 실사용자 데이터에 있습니다. 추천어가 뜨기까지 걸린 그 체감 시간으로 판정해요
- Bot / Abuse 방어: 추천 API는 로그인 없이 열려 있는 경우가 많아 봇의 표적이 되기 쉽습니다. rate limit 같은 방어를 붙여야 해요
- Feature Flag로 점진적 롤아웃: 새 랭킹 로직이나 새 디바운스 값을 전체에 바로 풀지 않고 일부 사용자에게 먼저 열어 지표를 확인한 뒤 확대합니다
마무리
지금까지 자동완성 검색창 하나를 RADIO 다섯 단계로 따라가 봤습니다. 설계 전체를 처음부터 끝까지 한 번에 훑으면 이렇게 돼요.
- 사용자가 검색어를 입력하면 로컬 input state가 업데이트됩니다
- 검색어가 너무 짧으면(2~3글자 미만) API를 호출하지 않습니다
- 디바운스를 적용해 입력이 멈춘 뒤 200~300ms가 지나면 요청을 보냅니다
- 요청 전에 클라이언트 캐시부터 확인합니다. 같은 쿼리의 최근 결과가 있으면 재사용해요
- 요청 시 이전 요청을 취소하고 응답이 도착하면 현재 입력값과 대조해 stale response를 차단합니다
- 드롭다운을 렌더링하되, 키보드 이동과 스크린리더 접근성까지 지원합니다
- 배포 후엔 추천어가 뜨기까지 걸린 시간, 에러율, 캐시 히트율 같은 지표를 측정하며 튜닝합니다
체감 속도를 숫자로 못 박았고(R), "실시간처럼 보인다"는 생각에 속지 않고 HTTP + 디바운스로 구조를 잡았습니다(A). 응답에 query를 실어 race condition을 막았고(D), 책임 기준의 컴포넌트와 ARIA로 경계 계약을 정의했으며(I), 폴백·지표·A/B 테스트로 운영까지 준비했죠(O).
Input과 Dropdown 두 개짜리 UI인데 설계할 게 이렇게 많습니다. 게다가 이 결정들은 대부분 코드를 치기 전에 내려진 것들이에요.
디바운스 시간, 캐시 정책, stale 응답 처리, 접근성 계약. 전부 요구사항에서 출발한 판단이지 기술 취향이 아닙니다.
"어떤 기술을 쓸까?"부터 시작했다면 "실시간 = 웹소켓"이라고 생각했을 겁니다. 무엇을 보장할지부터 물었기 때문에 더 단순하고 안정적인 답에 도착했어요. 이것이 프레임워크로 설계하는 이유입니다.