프론트엔드 시스템 디자인 문제 접근법 (RADIO) - 자동완성 검색창 설계 예제
목차
- 프론트엔드 시스템 디자인 문제 접근법 (RADIO)
- 왜 RADIO 순서로 접근해야 할까?
- Requirements (R)
- Architecture (A)
- Data Model (D)
- Interface (I)
- Optimization & Observability (O)
- 미니 케이스: 자동완성 검색창 설계
- 마무리
프론트엔드 시스템 디자인 문제 접근법 (RADIO)
프론트엔드 시스템 디자인 문제를 받으면 대부분 바로 기술을 떠올리기 시작합니다.
"React Query를 써야 하나?"
"Debounce를 넣어야 하나?"
"Next.js가 좋을까?"
하지만 좋은 시스템 디자인은 기술 선택이 아니라 문제 정의부터 시작합니다.
이를 위해 사용할 수 있는 접근법이 RADIO 입니다.
- R — Requirements
- A — Architecture
- D — Data Model
- I — Interface
- O — Optimization & Observability
이 순서대로 생각하면 대부분의 프론트엔드 시스템 디자인 문제를 논리적으로 접근할 수 있습니다.
왜 RADIO 순서로 접근해야 할까?
결국 시스템 디자인은 아래 세 질문에 대한 답을 찾는 과정입니다.
- 사용자가 원하는 결과물은 무엇인가?
- 그 결과물이 좋은 품질이라고 판단하는 기준은 무엇인가?
- 그 품질을 유지하려면 어떤 구조가 필요한가?
예를 들어 자동완성 검색창이라면 단순히 입력 → API 호출 → 결과 표시로 끝나는 문제가 아닙니다.
실제로는 다음과 같은 질문들이 따라옵니다.
- 입력 후 몇 ms 안에 결과가 보여야 하는가?
- 키를 입력할 때마다 API를 호출할 것인가?
- 이전 요청이 늦게 도착하면 최신 결과를 덮어쓰지 않는가?
- 네트워크가 느리면 어떻게 처리할 것인가?
- 모바일 키보드는? 접근성은? 검색 결과 클릭률도 측정해야 하는가?
이처럼 기능보다 먼저 요구사항을 정의하는 것이 중요합니다.
Requirements (R)
기능(Function) vs 요구사항(Requirement)
Requirements는 단순히 기능 목록을 작성하는 단계가 아닙니다. 사용자가 기대하는 경험과 품질 기준을 정의하는 단계입니다.
기능은 "검색 결과를 보여준다"지만, 요구사항은 "사용자가 입력하는 동안 빠르고 안정적으로 최신 검색 결과를 제공하며, 오류나 지연 상황에서도 UX가 깨지지 않아야 한다"처럼 정의됩니다.
즉, 기능(Function) 과 요구사항(Requirement) 은 다릅니다.
비기능 요구사항도 반드시 정의해야 한다
대부분의 서비스는 기능보다 비기능 요구사항이 더 어렵습니다. "얼마나 빨라야 하는가?", "얼마나 안정적이어야 하는가?", "얼마나 많은 사용자를 처리해야 하는가?" — 이를 수치로 정의해야 합니다.
예시)
- 추천어는 300ms 이내에 표시
- API 실패율 1% 이하
- 모바일에서도 동일한 UX 제공
- 접근성 지원
- 검색창은 API 장애에도 계속 동작
빠르다의 기준은 무엇인가?
"빠르다"는 추상적인 표현입니다. 시스템 디자인에서는 가능한 한 측정 가능한 수치로 표현해야 합니다. 대표적으로 사용하는 것이 Core Web Vitals 입니다.
| 지표 | 의미 | 좋은 기준 |
|---|---|---|
| LCP | 가장 큰 콘텐츠가 화면에 나타나는 시간 | 2.5초 이하 |
| INP | 사용자의 입력 후 화면이 반응하기까지의 시간 | 200ms 이하 |
| CLS | 예상하지 못한 레이아웃 이동 | 0.1 이하 |
또한 Google은 일반적으로 p75(75 Percentile) 기준을 사용합니다. 즉, 사용자의 75% 이상이 이 기준 안에 들어와야 좋은 성능이라고 판단합니다.
서비스마다 중요한 성능 지표는 다르다
모든 서비스가 LCP만 중요한 것은 아닙니다. 서비스에 따라 실제로 중요한 지표는 달라집니다.
- 리스트 → Time To First Item: 첫 번째 아이템이 얼마나 빨리 보이는가
- 검색 → Input To Result Time: 사용자가 입력한 순간부터 결과가 보일 때까지
- 장바구니 → Add To Cart Feedback Time: 버튼을 눌렀을 때 피드백이 얼마나 빠른가
즉 서비스마다 핵심 KPI가 달라집니다.
Architecture (A)
Architecture 단계에서는 책임 분리와 데이터 흐름을 설계합니다. 가장 중요한 전제는 "모든 상태는 같은 생명주기를 가지지 않는다"는 것입니다.
쇼핑몰을 예로 들면 상태는 크게 다섯 종류로 나뉩니다.
| 상태 종류 | 설명 | 예시 |
|---|---|---|
| 서버 상태 | 서버가 보유한 데이터의 클라이언트 사본 | cart, cart count, price, stock |
| UI 상태 | 사용자 인터페이스의 일시적 상태 | modal open, loading, selected item |
| 낙관적 업데이트 상태 | 서버 응답 전 미리 반영한 임시 상태 | pending quantity |
| URL 상태 | URL에 동기화되어 공유 가능한 상태 | page, sort, keyword |
| 계산 가능한 상태 | 다른 상태로부터 도출되는 파생 상태 | total price |
이들은 모두 서로 다른 생명주기를 갖습니다. 그래서 React 생태계에는 Redux, Zustand, React Query, URL State, Context 등 다양한 상태 관리 도구가 존재합니다. 하나의 상태 관리 라이브러리로 모든 문제를 해결하려고 하면 오히려 구조가 복잡해질 수 있습니다.
Data Model (D)
좋은 시스템 디자인은 좋은 데이터 모델링에서 시작합니다. 처음에는 대부분 하나의 객체에 필요한 정보를 모두 담으려고 합니다.
type Product = {
id: string
title: string
price: number
imageUrl: string
}
처음 보면 별문제가 없어 보입니다. 하지만 실제 쇼핑몰은 생각보다 훨씬 복잡합니다. 하나의 상품에는 여러 옵션이 존재할 수 있고, 판매자가 여러 명일 수도 있으며, 판매자마다 가격이 다를 수도 있고, 배송 정책도 다를 수 있으며, 재고도 각각 다르게 관리됩니다.
즉, 하나의 Product가 모든 정보를 가지고 있는 구조는 현실의 비즈니스 모델을 제대로 표현하지 못합니다. 그래서 실제 서비스에서는 역할에 따라 데이터를 4개 계층으로 분리합니다.
역할별 데이터 모델 분리
| 모델 | 의미 | 예시 |
|---|---|---|
| Product | 상품 자체 | MacBook Pro, Nike Air Force 1 |
| SKU | 구매 가능한 옵션 단위 | Silver / 16GB / 512GB |
| Offer | 판매 조건 (판매자별 가격 · 재고) | 판매자 A · 2,990,000원 · 무료배송 · 재고 3 |
| CartItem | 장바구니 항목 (SKU + Offer + 수량) | SKU-001 + Offer-A + qty 2 |
Product
상품 자체를 의미합니다. 예를 들어 "MacBook Pro", "Nike Air Force 1"처럼 사용자가 보는 상품입니다.
SKU (Stock Keeping Unit)
실제로 구매 가능한 옵션 단위입니다. MacBook Pro라면 "Silver / 16GB / 512GB", "Silver / 32GB / 1TB", "Space Gray / 16GB / 512GB"처럼 옵션마다 서로 다른 SKU가 됩니다.
Offer
판매 조건을 의미합니다. 같은 SKU라도 판매자에 따라 가격, 배송비, 재고, 할인 등이 모두 달라질 수 있습니다. 즉 Offer는 누가 어떤 조건으로 판매하는가를 나타내는 모델입니다.
CartItem
사용자가 장바구니에 담은 정보입니다. 일반적으로 어떤 SKU를 선택했는지, 어떤 Offer로 구매하는지, 수량은 몇 개인지를 가지고 있습니다.
데이터를 역할에 맞게 분리하면 변경에 훨씬 유연한 구조를 만들 수 있습니다. 가격이 변경되어도 Product를 수정할 필요 없이 Offer만 변경하면 되고, 새로운 옵션이 추가되어도 SKU만 추가하면 됩니다.
필요한 데이터만 내려주는 API를 설계하자
데이터 모델링은 서버 응답에도 큰 영향을 줍니다. 다음 모델을 보겠습니다.
type Post = {
id: string
text: string
likedUsers: User[]
}
처음에는 문제가 없어 보입니다. 하지만 게시글 하나에 좋아요가 100,000개라면 어떨까요? 게시글을 한 번에 20개 조회한다고 가정하면, 클라이언트로 전송해야 하는 User ID만 무려 200만 개에 달합니다.
하지만 화면에서 정말 필요한 정보는 무엇일까요? 실제로는 대부분 다음 두 가지뿐입니다.
- 좋아요가 몇 개인지
- 현재 사용자가 좋아요를 눌렀는지
그렇다면 API는 아래처럼 설계하는 것이 훨씬 효율적입니다.
type Post = {
id: string
text: string
likeCount: number
viewerState: {
liked: boolean
}
}
이렇게 하면 전송해야 하는 데이터 크기가 크게 줄어들고, 네트워크 비용이 감소하며, 렌더링 성능도 좋아집니다. 좋은 API는 모든 데이터를 내려주는 API가 아니라, 화면에서 필요한 데이터만 내려주는 API입니다.
Interface (I)
Interface는 단순히 컴포넌트 Props를 설계하는 것을 의미하지 않습니다. 시스템 디자인에서는 다양한 인터페이스를 함께 설계해야 합니다.
- 컴포넌트 인터페이스 — UI 컴포넌트 간 계약
- 서버 API 인터페이스 — 클라이언트와 서버 간 계약
- 이벤트 인터페이스 — 사용자 상호작용과 Telemetry 이벤트
- Telemetry(관측) 인터페이스 — 측정 지표와 로깅
각 인터페이스가 명확할수록 컴포넌트 간 결합도가 낮아지고 유지보수가 쉬워집니다.
Optimization & Observability (O)
마지막 단계는 성능 최적화와 관측입니다. 여기서 중요한 것은 최적화보다 먼저 측정할 수 있어야 한다는 것입니다. 측정하지 않는 성능은 개선할 수도 없고, 문제가 생겨도 원인을 찾기 어렵습니다.
대표적인 최적화 대상은 다음과 같습니다.
- 네트워크 요청
- JavaScript 실행 시간
- 이미지 및 비디오 로딩
- 캐시 전략
- 상태 업데이트 범위
- 브라우저 렌더링 성능
Optimization은 성능을 개선하는 과정이고, Observability는 서비스가 실제 사용자 환경에서 어떻게 동작하는지를 지속적으로 측정하고 관찰하는 과정입니다.
미니 케이스: 자동완성 검색창 설계
이제 지금까지 살펴본 RADIO 프레임워크를 실제 문제에 적용해보겠습니다. 예제로는 프론트엔드 면접에서도 자주 등장하는 자동완성 검색창(Autocomplete) 을 설계해보겠습니다.
단순히 추천 검색어를 보여주는 기능처럼 보이지만, 실제로는 입력 이벤트 처리, API 호출 최적화, 캐싱, Race Condition, 접근성 등 다양한 요소를 함께 고려해야 하는 좋은 시스템 디자인 문제입니다.
자동완성 - Requirements (R)
먼저 요구사항부터 정의합니다.
기능 요구사항 (Functional Requirements)
- 사용자가 검색어를 입력하면 추천 검색어를 보여준다.
- 입력값이 변경될 때마다 추천 검색어를 업데이트한다.
- 추천 검색어를 클릭하면 해당 검색어로 검색한다.
- 키보드의 ↑, ↓ 방향키로 추천 검색어를 이동할 수 있다.
- Enter를 누르면 선택된 추천 검색어 또는 현재 입력값으로 검색한다.
- Escape를 누르면 추천 검색어 목록을 닫는다.
- 입력값이 너무 짧으면 추천 검색어를 표시하지 않는다.
비기능 요구사항 (Non-functional Requirements)
기능만 구현한다고 좋은 검색창이 되는 것은 아닙니다. 다음과 같은 품질도 함께 만족해야 합니다.
- 추천 검색어는 빠르게 표시되어야 한다.
- 불필요한 API 호출을 최소화해야 한다.
- 느린 네트워크에서도 안정적으로 동작해야 한다.
- 모바일과 데스크톱 모두 지원해야 한다.
- 키보드 및 스크린 리더를 고려한 접근성을 지원해야 한다.
- API가 실패하더라도 검색창 자체는 계속 사용할 수 있어야 한다.
자동완성 - Architecture (A)
다음은 전체 데이터 흐름을 설계합니다.
각 단계는 서로 다른 책임을 가집니다.
- Search Input — 입력 이벤트만 관리
- Debounce — 과도한 API 호출을 줄임
- Autocomplete Controller — 전체 흐름을 제어
- Cache — 동일한 검색어에 대한 중복 요청을 방지
- API Client — 서버와 통신
- Dropdown UI — 결과를 화면에 표시
역할을 분리하면 각 계층을 독립적으로 수정하거나 테스트하기 쉬워집니다.
자동완성 - Data Model (D)
자동완성 검색창에서는 다음 상태를 관리해야 합니다.
| 상태명 | 타입 | 설명 |
|---|---|---|
| query | string | 현재 사용자가 입력한 검색어 |
| suggestions | Suggestion[] | 추천 검색어 목록 |
| loading | boolean | 추천 검색어를 불러오는 중인지 여부 |
| error | Error or null | API 호출 중 발생한 오류 |
| highlightedIndex | number | 방향키로 현재 선택된 추천 검색어 위치 |
| cache | Map<string, Suggestion[]> | 이미 조회한 검색어를 저장해 중복 요청 방지 |
Debounce
사용자가 입력을 멈춘 후 일정 시간이 지나면 API를 호출합니다. Debounce 시간을 300ms로 설정했다면, 사용자가 a → ap → app처럼 빠르게 입력하는 동안에는 요청을 보내지 않고, 입력이 멈춘 뒤 한 번만 요청을 보내게 됩니다. 이를 통해 서버 부하를 크게 줄일 수 있습니다.
Cache
이미 조회한 검색어는 캐시에 저장해 두었다가 다시 입력하면 API 대신 캐시 데이터를 사용합니다. 예를 들어 apple을 검색한 뒤 삭제하고 다시 apple을 입력하면 서버 요청 없이 즉시 결과를 보여줄 수 있습니다.
Race Condition
자동완성에서 가장 자주 발생하는 문제 중 하나입니다. 사용자가 a → ap → app을 매우 빠르게 입력했는데, 네트워크 상황 때문에 app 응답이 먼저 도착하고 이후 ap 응답이 도착할 수도 있습니다. 이 경우 이전 요청인 ap의 결과가 최신 결과인 app을 덮어쓰게 됩니다.
이를 방지하기 위해서는 다음 방식들을 사용할 수 있습니다.
AbortController를 이용해 이전 요청을 취소- 응답이 현재 입력값과 일치하는지 확인한 뒤 반영
자동완성 - Interface (I)
컴포넌트 구조도 함께 설계합니다.
Autocomplete
├── SearchInput
├── SuggestionDropdown
│ ├── SuggestionItem
│ ├── Loading
│ ├── Empty
│ └── Error
└── useAutocomplete
각 컴포넌트는 하나의 역할만 담당하도록 구성합니다.
useAutocomplete Hook
비즈니스 로직은 Hook으로 분리하면 컴포넌트는 UI에만 집중할 수 있습니다. Hook에서는 Debounce, API 호출, Cache 관리, Race Condition 처리, 추천어 선택 관리, 키보드 이벤트 처리를 담당합니다.
UI 상태
사용자가 보는 화면도 상태별로 정의합니다.
| 상태 | 화면 |
|---|---|
| 입력 중 | Loading Indicator 표시 |
| 결과 있음 | 추천 검색어 Dropdown 표시 |
| 결과 없음 | "추천 검색어가 없습니다." 표시 |
| API 오류 | 조용히 Fallback 처리 또는 Retry |
| 추천어 선택 | Highlight 표시 |
키보드 인터랙션
자동완성은 마우스뿐 아니라 키보드도 지원해야 합니다.
| 키 | 동작 |
|---|---|
| ↓ | 다음 추천어 선택 |
| ↑ | 이전 추천어 선택 |
| Enter | 현재 선택된 추천어 검색 |
| Escape | 추천 검색어 목록 닫기 |
| Tab | 자연스럽게 다음 Focus로 이동 |
접근성을 고려한다면 ARIA 속성과 Screen Reader 지원도 함께 고려해야 합니다.
자동완성 - Optimization & Observability (O)
마지막으로 성능과 운영 지표를 정의합니다. 대표적으로 다음과 같은 지표를 측정할 수 있습니다.
| Metric | 의미 |
|---|---|
| Time To First Suggestion | 첫 추천 검색어가 표시되기까지 걸린 시간 |
| Suggestion API Latency | 추천 API 응답 시간 |
| Suggestion API Error Rate | API 실패율 |
| Cache Hit Rate | 캐시 적중률 |
| Drop-off Rate | 추천창을 열고 검색하지 않고 이탈한 비율 |
| Search Conversion Rate | 추천어를 클릭하여 실제 검색으로 이어진 비율 |
또한 Debounce 시간을 150ms, 200ms, 300ms 등으로 변경하며 A/B 테스트를 수행해 가장 좋은 사용자 경험을 제공하는 값을 찾을 수도 있습니다.
마무리
자동완성 검색창은 단순한 UI 컴포넌트처럼 보이지만, 실제로는 요구사항 정의부터 아키텍처, 데이터 모델, 인터페이스, 성능 측정까지 다양한 요소를 함께 고려해야 하는 대표적인 시스템 디자인 문제입니다.
중요한 것은 특정 라이브러리를 사용하는 것이 아니라, RADIO 순서대로 문제를 구조화하여 사고하는 습관을 만드는 것입니다. 앞으로 장바구니, 채팅, 피드, 댓글, 대시보드와 같은 다른 기능을 설계할 때도 같은 방식으로 접근하면 보다 논리적이고 일관된 시스템 디자인을 할 수 있습니다.
자동완성을 직접 구현하거나 더 깊이 이해하고 싶다면 아래 레퍼런스를 꼭 살펴보세요.
- Downshift — WAI-ARIA compliant React autocomplete/combobox primitives. 접근성과 키보드 네비게이션을 추상화한 hook 기반 라이브러리로, 이 글의 useAutocomplete 설계와 거의 1:1로 매칭됩니다.
- React-Autosuggest — Debounce, 캐싱, Race Condition 처리까지 구현된 완성형 컴포넌트. 이 글에서 다룬 "이전 요청이 늦게 도착하면?" 문제를 실제 코드로 어떻게 해결하는지 참고할 수 있습니다.
- WAI-ARIA Combobox Pattern — W3C 공식 ARIA 명세. 자동완성에서 필수적인 접근성 속성을 언제, 어떻게 적용해야 하는지 정확한 가이드가 있습니다.