[RADIO로 시스템 디자인하기 #1] RADIO 프레임워크 뜯어보기
RADIO로 시스템 디자인하기 시리즈의 첫 글입니다. 프론트엔드 시스템 디자인을 구조적으로 풀어내는 RADIO 프레임워크를 Requirements부터 Optimization까지 다섯 단계로 나눠, 각 단계가 무엇을 묻고 왜 그 질문이 필요한지 차근차근 살펴봅니다.
TL;DR
프론트엔드 설계는 보통 "리액트 쿼리 쓸까? Zustand 쓸까? Next.js로 SSR을 할까?"처럼 기술 선택부터 시작하곤 합니다.
RADIO는 그 순서를 뒤집어 무엇을 보장할지부터 묻고 기술은 마지막에 고르게 해주는 프레임워크예요.
| 단계 | 핵심 질문 | 한 줄 요약 |
|---|---|---|
| R — Requirements | 무엇을 보장할 것인가? | 기능 목록에서 끝나지 않는 품질 기준 (숫자로) |
| A — Architecture | 이 상태의 원천은 어디인가? | 폴더 구조가 아니라 책임 분리와 데이터 흐름 |
| D — Data Model | 화면이 다루기 좋은 형태인가? | DB 스키마가 아니라 UI를 위한 모델 |
| I — Interface | 협업·테스트·격리가 되는가? | API만이 아니라 컴포넌트·이벤트·관측까지 |
| O — Optimization | 설계에 녹아 있는가? | 나중이 아니라 처음부터 |
이 글에서는 다섯 단계가 각각 무엇을 묻는 단계인지, 왜 그 질문이어야 하는지를 하나씩 뜯어봅니다.
이 틀을 실제 소재 하나에 처음부터 끝까지 적용하는 건 다음 편들의 몫이에요. 이번 편은 그 전에 도구부터 손에 익히는 글입니다.
💡 RADIO가 뭔가요?
프론트엔드 시스템 디자인을 다섯 단계로 나눠 접근하는 프레임워크입니다.
각 단계의 앞 글자(Requirements, Architecture, Data Model, Interface, Optimization)를 따서 RADIO라고 부릅니다.
실무 설계에서 "내가 지금 무엇을 놓치고 있지?"를 점검하는 체크리스트로 좋아요.
R — Requirements (요구사항)
무엇을 만들 것인가가 아니라, 무엇을 보장할 것인가.
Requirements 단계에서 가장 흔히 하는 실수는 요구사항을 기능 목록에서 끝내버리는 겁니다.
RADIO에서 말하는 요구사항은 기능 목록에서 끝나지 않아요. 핵심 기능을 추린 뒤, 그 기능이 어떤 품질 기준을 만족해야 하는지까지 정하는 단계입니다.
기능 목록과 품질 기준의 차이
무한 스크롤 피드를 만든다고 해볼게요.
단순하게 생각하면 이렇게 정리됩니다.
스크롤이 바닥에 닿으면 → 다음 게시물을 불러와서 → 목록에 붙인다.
이건 기능입니다. 틀린 건 아니지만 이대로 만들면 실제 서비스에서는 금방 한계에 부딪혀요.
품질 기준으로 다시 적어보면 고려할 것이 훨씬 많아집니다.
- 다음 페이지는 스크롤이 어디쯤 왔을 때 미리 불러올 것인가?
- 로딩 중에 스크롤을 더 내리면 같은 페이지를 중복 요청하지 않는가?
- 불러오기에 실패하면 어떻게 알리고, 재시도는 어떻게 할 것인가?
- 다른 페이지에 갔다 돌아오면 읽던 위치가 복원되는가?
- 새 게시물이 위에 추가될 때 읽던 화면이 밀리지 않는가?
- 첫 게시물이 뜨기까지 걸리는 시간을 관측할 것인가?
같은 기능이라도, 결국 사용자가 받는 경험은 이런 품질 기준에서 갈립니다.
그래서 Requirements는 단순히 "다음 게시물을 불러온다"가 아니라
- 스크롤이 끊기지 않고,
- 같은 요청을 중복해서 보내지 않으며,
- 읽던 자리를 잃어버리지 않게 한다
가 되어야 합니다.
"빠르게"를 숫자로 증명하기
여기서 한 가지 더 중요한 게 있습니다.
"빠르게"라는 말은 사람마다 다르게 해석됩니다. 누군가에겐 1초가 빠른 겁니다. 또 누군가에겐 100ms도 느리고요.
그래서 프론트엔드에서도 품질 기준을 숫자로 증명해야 합니다.
이때 기준점이 되어주는 게 Core Web Vitals입니다.
💡 Core Web Vitals란?
구글이 정의한 대표적인 사용자 경험 지표입니다.
로딩(LCP), 상호작용(INP), 시각적 안정성(CLS) 세 가지 축으로 사용자 경험을 측정합니다.
| 지표 | 측정하는 것 | 권장 기준 (p75) |
|---|---|---|
| LCP (Largest Contentful Paint) | 로딩 속도 | 2.5초 이하 |
| INP (Interaction to Next Paint) | 반응 속도 | 200ms 이하 |
| CLS (Cumulative Layout Shift) | 시각적 안정성 | 0.1 이하 |
⚠️ 왜 p75 기준일까요?
좋은 사용자 경험은 보통 75번째 백분위(p75) 기준으로 봅니다.
정확히는 실제 사용자 환경에서 수집된 페이지 로드 데이터의 75%가 이 기준 안에 들어와야 "좋은 상태"라고 판단해요.
내 맥북에서 빠른 건 의미가 없습니다. 중간 사양 안드로이드 폰에서 4G로 접속한 경우까지 기준을 만족해야 합니다.
세 지표를 조금 더 풀어볼게요.
LCP
LCP는 페이지에서 가장 큰 주요 콘텐츠가 화면에 렌더링되기까지 걸린 시간입니다.
사용자가 페이지를 열었을 때 "아, 이제 메인 콘텐츠가 보이네"라고 느끼는 시점이라고 보면 됩니다.
쇼핑몰 상품 페이지라면 상품 대표 이미지나 큰 히어로 배너, 상품 제목 같은 것들이 LCP 대상이 됩니다.
이 시간이 2.5초 이하여야 사용자 경험이 좋습니다.
INP
INP는 사용자가 페이지와 상호작용한 뒤, 브라우저가 다음 화면 업데이트를 보여주기까지 걸린 시간입니다.
쉽게 말해 반응 속도예요.
장바구니 버튼을 클릭하거나, 좋아요를 누르거나, 검색어를 입력하는 것 같은 상호작용을 했을 때, 다음 화면이 그려지기까지 걸린 시간을 측정합니다.
주의할 점은 INP가 "작업이 끝나기까지"가 아니라 "다음 paint까지"를 본다는 거예요. 좋아요 버튼을 눌렀을 때 하트가 채워지거나 로딩 표시가 뜨기까지가 INP고 서버 처리가 끝나 실제 결과가 반영되기까지는 별도의 도메인 지표로 봐야 합니다. INP는 어디까지나 "내 상호작용에 화면이 얼마나 빨리 반응했는가"만 보거든요.
이 값이 200ms 이하여야 사용자가 "반응이 빠르다"고 느낍니다.
💡 INP는 FID를 대체한 지표입니다
예전에는 FID(First Input Delay)를 썼는데, FID는 첫 상호작용의 지연만 측정했어요.
INP는 세션 동안 일어나는 모든 상호작용을 측정하기 때문에 훨씬 엄격합니다.
그래서 자바스크립트 작업이 많고 상호작용이 복잡한 SPA에서는 특히 까다로운 지표예요.
CLS
CLS는 예상하지 못한 레이아웃 이동이 얼마나 발생했는지를 나타내는 점수입니다.
LCP나 INP와 달리 시간이 아니라 점수라는 점이 특징이에요.
화면이 로딩 중에 갑자기 아래로 밀리거나, 버튼이 움직이거나, 읽던 글이 튀는 경우를 측정합니다.
예를 들어 상품 목록에서 이미지 높이를 미리 잡아두지 않으면, 이미지가 뒤늦게 로드되면서 제목과 버튼이 아래로 밀립니다.
사용자 입장에서는 "버튼 누르려고 했는데 갑자기 밀렸네"가 되는 거죠.
CLS가 나쁘면 사용자는 화면을 신뢰하지 못합니다. 그래서 0.1 이하를 권장합니다.
도메인마다 다른 "체감 속도" 지표
Core Web Vitals가 범용 지표라면, 서비스 성격에 맞는 별도의 체감 지표도 함께 봐야 합니다.
- 피드 → 첫 게시물이 화면에 뜨기까지 걸리는 시간
- 검색 → 입력을 멈춘 뒤 결과가 나오기까지 걸리는 시간
- 장바구니 → 담기 버튼을 누른 뒤 담겼다는 반응이 뜨기까지 걸리는 시간
- 채팅 → 전송 버튼을 누른 뒤 내 메시지가 화면에 뜨기까지 걸리는 시간
소재가 무엇이든 방식은 같습니다. "이 서비스에서 사용자가 가장 답답해하는 순간"을 찾아 숫자로 정의하는 거예요.
이렇게 정의해두면, 나중에 최적화할 때 무엇을 측정하고 무엇을 개선할지 이 숫자부터 들여다보게 됩니다.
A — Architecture (아키텍처)
아키텍처는 폴더 구조가 아니다.
"아키텍처를 설계하라"고 하면 많은 분들이 폴더 구조부터 떠올립니다.
src/
├── components/
├── hooks/
├── utils/
└── pages/
물론 폴더 구조도 중요하지만 프론트엔드 시스템 디자인에서 말하는 아키텍처는 이게 아닙니다.
아키텍처는 책임 분리와 데이터 흐름 설계예요.
하나의 상태로 시작하면 생기는 일
앞에서 본 무한 스크롤 피드로 돌아가볼게요. 처음엔 이렇게 간단하게 시작합니다.
const [posts, setPosts] = useState([]);쉽고 직관적인 것 같아요. 하지만 서비스가 자라면서 점점 이런 상태들이 붙기 시작합니다.
- 서버에서 받아온 확정 게시물
- 지금 불러오는 중인 다음 페이지
- 좋아요를 눌러 낙관적으로 바꿔둔 게시물
- 스크롤 위치와 펼쳐둔 댓글창
- 적용 중인 필터와 정렬
이쯤 되면 질문이 쏟아집니다.
무엇이 진짜 게시물 목록인가? 좋아요 요청이 실패하면 어디로 롤백하지? 필터를 바꾸면 어떤 상태를 버리고 어떤 상태를 유지하지?
이 질문들에 답하지 못하면 상태가 꼬이기 시작합니다.
상태를 생명주기로 나누기
좋은 아키텍처는 상태를 생명주기에 따라 나눕니다.
| 상태 종류 | 설명 |
|---|---|
| Server State | 서버와 동기화되는 상태 (피드 게시물, 사용자 프로필) |
| Local State | 현재 화면에서만 필요한 UI 상태 (모달 열림, 입력 중인 텍스트) |
| URL State | 공유하거나 복구할 수 있는 상태 (검색어, 필터, 페이지) |
| Optimistic State | 서버 응답 전에 미리 보여주는 임시 상태 (누르자마자 채워지는 좋아요 하트) |
| Derived State | 원본 상태에서 계산되는 값 (안 읽은 알림 개수, 장바구니 총액) |
왜 이렇게 잘게 나눌까요?
모든 상태가 같은 생명주기를 갖지 않기 때문입니다.
피드 게시물은 서버와 동기화되어 어느 기기에서 봐도 같아야 합니다.
하지만 열려 있는 모달은 지금 이 화면에서만 필요하죠.
이 둘을 같은 상태로 묶어버리면 롤백, refetch, 캐시, 기기 간 동기화에서 문제가 꼬입니다.
그래서 아키텍처 단계의 핵심 질문은 "어디에 저장할까?"가 아니라 "이 상태의 Source of Truth(원천)는 어디인가?"입니다.
데이터의 근본 출처를 정하는 것, 그게 아키텍처 설계의 시작입니다.
사실 이 질문은 프론트엔드 상태 관리에만 통하는 게 아닙니다.
서버를 설계할 때도, 테스트를 짤 때도 결국 같은 걸 묻게 돼요. "이 값을 판단하는 진짜 기준은 어디에 있는가, 그 책임은 어느 레이어가 져야 하는가."
특히 AI가 코드를 빠르게 만들어주는 요즘은 구현 자체보다 이 판단이 설계자의 몫으로 남습니다. 코드는 다시 생성하면 되지만 원천과 책임을 잘못 정한 구조는 생성할수록 더 꼬이거든요.
D — Data Model (데이터 모델)
프론트엔드 데이터 모델은 백엔드 DB 스키마가 아니다.
프론트엔드 시스템 디자인에서 필요한 데이터 모델은 화면이 안정적으로 동작하기 위한 모델입니다.
DB 테이블을 그대로 가져오는 게 아니라, UI가 다루기 좋은 형태로 다시 설계해야 해요.
화면이 필요로 하는 것만 내려보내기
투표(Poll) 기능을 만든다고 해볼게요. 처음엔 이렇게 만들기 쉽습니다.
type Poll = {
id: string;
question: string;
options: string[];
/** 투표한 유저 전체 리스트 */
votedUsers: User[];
};작은 앱이라면 충분합니다. 하지만 화면이 실제로 필요로 하는 걸 생각해보면 이야기가 달라져요.
투표 카드가 보여줘야 하는 건 딱 두 가지입니다.
- 옵션별 득표 수(또는 비율)
- 내가 투표했는지, 했다면 어디에 했는지
투표한 유저 전체 목록이 아니라요. 그래서 화면 기준으로 다시 설계합니다.
type Poll = {
id: string;
question: string;
options: {
id: string;
text: string;
voteCount: number; // 옵션별 득표 수
}[];
viewerState: {
votedOptionId: string | null; // 내가 투표한 옵션
};
};이 차이가 얼마나 클까요? 참여자 10만 명짜리 투표를 피드에 20개 띄운다고 해보면 확실해집니다.
votedUsers 방식
→ 투표 20개 × 참여자 100,000명 = 2,000,000개의 user id 전송
viewerState 방식
→ 투표 20개 × (옵션별 숫자 몇 개 + id 1개)
votedUsers는 참여자 수 N에 비례해 페이로드가 커지지만 voteCount와 viewerState는 참여자 수와 무관하게 O(1) 크기를 유지합니다.
데이터 모델을 어떻게 설계하느냐가 전송량과 렌더링 성능을 결정합니다.
⚠️ 나쁜 데이터 모델의 비용
잘못된 데이터 모델은 나중에 UI 복잡도, 상태 버그, 장애 대응 비용으로 돌아옵니다.
처음에 모델을 제대로 잡지 않으면, 그 빚을 나중에 몇 배로 갚게 됩니다.
I — Interface (인터페이스)
인터페이스는 API만이 아니다.
"인터페이스 설계 = API 명세"라고 생각하기 쉽지만 프론트엔드 시스템 디자인에서 인터페이스는 네 가지입니다.
- 컴포넌트 인터페이스 — Props, Hook, Return Type
- 서버 API 인터페이스 — 요청/응답 계약
- 이벤트 인터페이스 — Event Contract
- 관측/Telemetry 인터페이스 — 측정 스키마
단순한 Props로는 부족할 때
파일 업로더를 만든다고 해볼게요. 단순하게 생각하면 이 정도면 될 것 같습니다.
<FileUpload onUpload={handleUpload} />하지만 대용량 파일을 안정적으로 다루려면, 그 뒤에 업로드 과정을 책임지는 전혀 다른 수준의 인터페이스가 필요합니다.
interface UploadEngine {
enqueue(file: File): UploadId;
pause(id: UploadId): void;
resume(id: UploadId): void;
retry(id: UploadId): void;
subscribe(event: UploadEvent, listener: Listener): Unsubscribe;
}왜 이렇게 나눌까요? 각자 담당하는 책임이 다르기 때문입니다.
- UI는 드래그 앤 드롭 영역과 진행 바를 담당하고
- 엔진은 청크 분할, 동시 업로드 제한, 실패 재시도를 담당하고
- Telemetry는 업로드 성공률, 평균 소요 시간, 실패 원인을 담당합니다.
좋은 인터페이스는 이렇게 팀과 시스템을 분리할 줄 압니다. 그래서 협업·테스트·장애 격리가 가능해지죠.
UI 팀이 진행 바 디자인을 갈아엎어도 엔진은 그대로고 엔진이 청크 크기를 바꿔도 UI는 모릅니다.
Telemetry도 인터페이스다
"API 명세만 잘 잡으면 인터페이스는 끝 아닌가?"라고 생각할 수 있습니다.
하지만 컴포넌트 Props, Hook의 Return Type, Event Contract, 그리고 Telemetry 스키마까지 모두 인터페이스입니다.
특히 Telemetry는 운영팀과 제품팀이 실제 사용자 경험을 이해하려고 맺는 계약입니다.
⚠️ 모니터링할 수 없는 시스템은 고칠 수 없다
"지금 당장 Telemetry가 필요한가? 설계 단계부터 넣어야 하나?"라고 물을 수 있습니다.
하지만 나중에 문제가 터졌을 때, 관측 이벤트가 없으면 원인을 찾을 수가 없습니다.
그래서 Telemetry는 기능을 다 만든 뒤가 아니라 설계 단계에서 인터페이스로 함께 정의해야 합니다.
O — Optimization (최적화)
최적화는 나중에 하는 게 아니라, 설계의 일부다.
"기능 다 만들고 나중에 빠르게 만들면 되지"라는 생각, 많이들 하십니다.
소규모 프로젝트라면 그래도 됩니다. 하지만 대규모 프론트엔드에서는 이미 늦습니다.
- 렌더링 구조가 잘못되면 나중에 최적화하기 어렵고
- 상태 모델이 잘못되면 refetch나 rollback이 꼬이고
- 이미지 크기 정보가 없으면 CLS를 나중에 잡기 어렵습니다.
그래서 최적화의 조건들은 설계 단계에서부터 고려되어야 합니다.
메인 스레드와 롱 태스크
브라우저의 메인 스레드는 대부분의 자바스크립트 실행과 렌더링 작업을 처리하는데 한 번에 하나의 태스크만 처리할 수 있습니다.
💡 롱 태스크(Long Task)란?
web.dev에서는 50ms를 넘는 태스크를 롱 태스크로 정의합니다.
메인 스레드가 오래 막히면 사용자에게는 화면이 응답하지 않는 것처럼 느껴집니다.
앞서 본 INP가 나빠지는 주된 원인이기도 합니다.
그래서 프론트엔드 성능은 단순히 "자바스크립트를 줄이고 렌더링을 줄이는" 문제가 아닙니다.
네트워크, 자바스크립트 실행, 이미지·비디오 로딩, 캐시, 상태 업데이트 범위, 브라우저 자체 최적화까지 여러 축이 얽힌 종합 문제예요.
Virtualization으로 긴 목록 다루기
긴 목록을 렌더링하는 흔한 코드를 볼게요.
items.map((item) => <Card item={item} />);아이템이 10개라면 문제없습니다. 그런데 무한 스크롤로 쌓인 피드 게시물 10,000개를 다 띄운다면 어떨까요?
전체 렌더링 방식
→ items = 10,000개
→ 카드 1개당 DOM 노드 = 30개
→ 총 DOM 노드 = 10,000 × 30 = 300,000개
Virtualization 방식
→ 화면에 보이는 아이템 = 10개
→ overscan(여유분) = 위아래 5개씩
→ 렌더링 노드 = (10 + 2 × 5) × 30 = 600개
300,000개 vs 600개. 차이가 엄청나죠.
화면에 보이는 영역만 렌더링하고 나머지는 그리지 않는 것, 이게 Virtualization(가상화)입니다.
💡 Virtualization이란?
화면(viewport)에 실제로 보이는 아이템만 DOM에 그리고, 스크롤에 따라 그 영역을 갈아끼우는 기법입니다.
react-window,@tanstack/react-virtual같은 라이브러리가 대표적이에요.
다만 아이템 높이가 제각각인 목록은 일반 리스트보다 가상화 구현이 까다로운 편입니다.
결국 프론트엔드 시스템 디자인은 리액트 코드만 설계하는 게 아닙니다.
브라우저의 실행 모델과 최적화 조건까지 함께 고려해야 비로소 완성됩니다.
마무리
지금까지 RADIO 다섯 단계를 하나씩 뜯어봤습니다. 각 단계가 묻는 질문만 다시 모아보면 이렇습니다.
- R — 무엇을 만들 것인가가 아니라, 무엇을 보장할 것인가? 그리고 그걸 숫자로 말할 수 있는가?
- A — 이 상태의 Source of Truth는 어디인가?
- D — 이 모델은 DB가 아니라 화면을 위해 설계됐는가?
- I — 이 경계의 계약은 협업·테스트·장애 격리를 가능하게 하는가?
- O — 최적화와 관측이 설계 단계에 녹아 있는가?
보통은 "어떤 기술을 쓸까?"부터 시작합니다. 하지만 RADIO는 그 순서를 뒤집습니다.
사용자가 무엇을 기대하는지 → 그 품질을 어떻게 보장할지 → 그러려면 어떤 구조가 필요한지를 먼저 묻습니다. 기술은 그 답을 구현하기 위한 수단으로 따라옵니다.
소재가 피드든 검색이든 채팅이든 사고의 틀은 똑같습니다.
무엇보다 실무에서 설계할 때 "내가 지금 무엇을 놓치고 있지?"를 점검하는 체크리스트로 쓰기 좋아요.
참고자료
이런 글도 읽어보세요
[RADIO로 시스템 디자인하기 #3] 실시간 채팅 앱 설계 부숴보기
RADIO로 시스템 디자인하기 시리즈의 세 번째 글입니다. 서버가 먼저 말을 거는 실시간 채팅을 소재로, WebSocket 선택부터 낙관적 병합, 순서 보장, 가변 높이 가상화까지 RADIO 다섯 단계로 처음부터 끝까지 설계해봅니다.
[RADIO로 시스템 디자인하기 #2] 자동완성 검색창 설계 부숴보기
RADIO로 시스템 디자인하기 시리즈의 두 번째 글입니다. Input과 Dropdown뿐인 작은 UI 안에 디바운스, 캐시, race condition, 접근성까지 자동완성 검색창을 RADIO 다섯 단계로 처음부터 끝까지 설계해봅니다.
강결합된 React 앱을 독립적인 SDK로 분리하기
React 앱 컴포넌트에 강결합된 코어 로직을 독립 SDK로 분리하기까지의 설계 고민. 라이브러리와 SDK의 차이, 인터페이스 설계, 의존성 역전 원칙을 RemoteVS 실무 경험으로 정리한 아키텍처 의사결정 기록.