[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 다섯 단계로 처음부터 끝까지 설계해봅니다.
💡 RADIO가 뭔가요?
프론트엔드 시스템 디자인을 다섯 단계로 나눠 접근하는 프레임워크입니다.
각 단계의 앞 글자(Requirements, Architecture, Data Model, Interface, Optimization)를 따서 RADIO라고 부릅니다.
실무 설계에서 "내가 지금 무엇을 놓치고 있지?"를 점검하는 체크리스트로 좋아요.
R — Requirements (요구사항)
무엇을 만들 것인가가 아니라, 무엇을 보장할 것인가.
Requirements 단계에서 가장 흔히 하는 실수는 요구사항을 기능 목록에서 끝내버리는 겁니다.
RADIO에서 말하는 요구사항은 기능 목록에서 끝나지 않아요. 핵심 기능을 추린 뒤, 그 기능이 어떤 품질 기준을 만족해야 하는지까지 정하는 단계입니다.
기능 목록과 품질 기준의 차이
채팅 앱의 핵심, 메시지 전송을 만든다고 해볼게요.
단순하게 생각하면 이렇게 정리됩니다.
메시지를 입력하면 → 서버로 전송하고 → 상대방 화면에 띄운다.
이건 기능입니다. 틀린 건 아니지만, 이대로 만들면 실제 서비스에서는 금방 한계에 부딪혀요.
품질 기준으로 다시 적어보면 고려할 것이 훨씬 많아집니다.
- 전송 버튼을 누른 뒤 몇 ms 안에 내 화면에 메시지가 보여야 하는가? (서버 응답을 기다릴 것인가?)
- 전송에 실패하면 어떻게 알리고, 재시도는 어떻게 할 것인가?
- 네트워크가 끊겼다 다시 연결되면, 못 보낸 메시지를 어떻게 처리할 것인가?
- 같은 메시지가 두 번 전송되지 않도록 어떻게 막을 것인가?
- 메시지가 도착한 순서와 화면에 표시되는 순서를 어떻게 일치시킬 것인가?
- 전송 성공률이나 전송 지연 시간을 관측할 것인가?
같은 기능이라도, 결국 사용자가 받는 경험은 이런 품질 기준에서 갈립니다.
그래서 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는 사용자가 페이지와 상호작용한 뒤, 브라우저가 다음 화면 업데이트를 보여주기까지 걸린 시간입니다.
쉽게 말해 반응 속도예요.
장바구니 버튼을 클릭하거나, 좋아요를 누르거나, 검색어를 입력하는 것 같은 상호작용을 했을 때, 다음 화면이 그려지기까지 걸린 시간을 측정합니다.
채팅 앱이라면 전송 버튼을 누른 직후 "sending" 말풍선이나 로딩 피드백이 다음 paint까지 나타나는 시간이 INP에 해당합니다.
다만 서버 ACK나 상대방이 실제로 메시지를 받기까지의 시간은 INP가 아니라 뒤에서 이야기할 별도의 도메인 지표로 봐야 합니다. INP는 어디까지나 "내 상호작용에 화면이 얼마나 빨리 반응했는가"만 보거든요.
이 값이 200ms 이하여야 사용자가 "반응이 빠르다"고 느낍니다.
💡 INP는 FID를 대체한 지표입니다
예전에는 FID(First Input Delay)를 썼는데, FID는 첫 상호작용의 지연만 측정했어요.
INP는 세션 동안 일어나는 모든 상호작용을 측정하기 때문에 훨씬 엄격합니다.
그래서 자바스크립트 작업이 많고 상호작용이 복잡한 SPA에서는 특히 까다로운 지표예요.
CLS
CLS는 예상하지 못한 레이아웃 이동이 얼마나 발생했는지를 나타내는 점수입니다.
LCP나 INP와 달리 시간이 아니라 점수라는 점이 특징이에요.
화면이 로딩 중에 갑자기 아래로 밀리거나, 버튼이 움직이거나, 읽던 글이 튀는 경우를 측정합니다.
예를 들어 채팅방을 열었을 때 이전 메시지를 불러오면서 높이를 미리 잡아두지 않으면, 내가 읽던 메시지가 갑자기 위로 밀려 올라갑니다.
사용자 입장에서는 "방금 그 메시지 어디 갔지?" 하고 당황하게 되죠.
CLS가 나쁘면 사용자는 화면을 신뢰하지 못합니다. 그래서 0.1 이하를 권장합니다.
도메인마다 다른 "체감 속도" 지표
Core Web Vitals가 범용 지표라면, 서비스 성격에 맞는 별도의 체감 지표도 함께 봐야 합니다.
채팅 앱이라면 이런 것들이 핵심 지표가 됩니다.
- 메시지 전송 → send to echo time (전송 버튼부터 내 화면에 뜨기까지)
- 채팅방 진입 → time to first message (방에 들어가서 첫 메시지가 보이기까지)
- 메시지 수신 → delivery latency (상대가 보낸 메시지가 내 화면에 도착하기까지)
"이 서비스에서 사용자가 가장 답답해하는 순간"을 숫자로 정의해두면, 나중에 최적화할 때 무엇을 측정하고 무엇을 개선할지 이 숫자부터 들여다보게 됩니다.
A — Architecture (아키텍처)
아키텍처는 폴더 구조가 아니다.
"아키텍처를 설계하라"고 하면 많은 분들이 폴더 구조부터 떠올립니다.
src/
├── components/
├── hooks/
├── utils/
└── pages/
물론 폴더 구조도 중요하지만, 프론트엔드 시스템 디자인에서 말하는 아키텍처는 이게 아닙니다.
아키텍처는 책임 분리와 데이터 흐름 설계예요.
하나의 상태로 시작하면 생기는 일
채팅 메시지 목록. 처음엔 이렇게 간단하게 시작합니다.
const [messages, setMessages] = useState([]);쉽고 직관적인 것 같아요. 하지만 서비스가 자라면서 점점 이런 상태들이 붙기 시작합니다.
- 서버에 확정 저장된 메시지
- 전송 중인(sending) 메시지
- 전송 실패한(failed) 메시지
- 입력창에 작성 중인 draft
- 상대방이 타이핑 중이라는 표시
이쯤 되면 질문이 쏟아집니다.
무엇이 진짜 메시지 목록인가? 전송이 실패하면 어디로 롤백하지? 서버에서 받은 메시지와 내가 방금 낙관적으로 띄운 메시지를 어떻게 합치지?
이 질문들에 답하지 못하면 상태가 꼬이기 시작합니다.
상태를 생명주기로 나누기
좋은 아키텍처는 상태를 생명주기에 따라 나눕니다.
| 상태 종류 | 설명 |
|---|---|
| Server State | 서버와 동기화되는 상태 (확정 저장된 메시지 목록) |
| Local State | 현재 화면에서만 필요한 UI 상태 (입력창 draft, 스크롤 위치) |
| URL State | 공유하거나 복구할 수 있는 상태 (현재 열려 있는 채팅방 ID) |
| Optimistic State | 서버 응답 전에 미리 보여주는 임시 상태 (전송 중인 메시지) |
| Derived State | 원본 상태에서 계산되는 값 (안 읽은 메시지 개수) |
왜 이렇게 잘게 나눌까요?
모든 상태가 같은 생명주기를 갖지 않기 때문입니다.
확정 저장된 메시지는 서버와 동기화되어 여러 기기에서 같은 내용을 봐야 합니다.
하지만 입력창에 쓰다 만 draft는 지금 이 기기, 이 화면에서만 필요하죠.
이 둘을 같은 상태로 묶어버리면 롤백, refetch, 캐시, 기기 간 동기화에서 문제가 꼬입니다.
특히 채팅에서는 Optimistic State가 중요합니다.
전송 버튼을 누르는 순간 서버 응답을 기다리지 않고 메시지를 "sending" 상태로 화면에 먼저 띄워야 빠르게 느껴지거든요.
그런데 이 낙관적 메시지를 서버 확정 메시지와 같은 배열에 그냥 섞어두면, 전송 실패 시 무엇을 되돌려야 하는지 알 수 없게 됩니다.
그래서 아키텍처 단계의 핵심 질문은 "어디에 저장할까?"가 아니라 "이 상태의 Source of Truth(원천)는 어디인가?"입니다.
데이터의 근본 출처를 정하는 것, 그게 아키텍처 설계의 시작입니다.
D — Data Model (데이터 모델)
프론트엔드 데이터 모델은 백엔드 DB 스키마가 아니다.
프론트엔드 시스템 디자인에서 필요한 데이터 모델은 화면이 안정적으로 동작하기 위한 모델입니다.
DB 테이블을 그대로 가져오는 게 아니라, UI가 다루기 좋은 형태로 다시 설계해야 해요.
나쁜 모델 vs 좋은 모델
채팅 메시지 하나를 어떻게 표현할까요? 처음엔 이렇게 만들기 쉽습니다.
type Message = {
id: string;
text: string;
senderId: string;
createdAt: string;
};작은 앱이라면 충분합니다. 하지만 실제 메신저에서는 이 모델로 감당이 안 됩니다.
- 텍스트뿐 아니라 이미지·파일·이모지 등 여러 콘텐츠 타입
- 전송 상태 (sending / sent / delivered / failed)
- 답장이나 스레드로 연결된 부모 메시지
- 수정·삭제 여부
- 반응(이모지) 정보
이런 요소들을 하나의 Message에 욱여넣으면 모델이 금방 터집니다.
그래서 개념을 분리해줘야 해요.
Message : 메시지의 핵심 (id, sender, createdAt, 전송 상태)
MessageBody : 콘텐츠 타입별 본문 (text / image / file)
Reaction : 메시지에 달린 이모지 반응
ThreadRef : 답장/스레드로 연결된 부모 메시지 참조
⚠️ 나쁜 데이터 모델의 비용
잘못된 데이터 모델은 나중에 UI 복잡도, 상태 버그, 전송 실패 처리 오류, 장애 대응 비용으로 돌아옵니다.
처음에 모델을 제대로 잡지 않으면, 그 빚을 나중에 몇 배로 갚게 됩니다.
데이터 모델이 성능을 좌우하는 경우
데이터 모델은 단순히 "정리"의 문제가 아닙니다. 성능에도 직접 영향을 줍니다.
단체 채팅방의 읽음 표시가 좋은 예입니다.
type Message = {
id: string;
text: string;
/** 이 메시지를 읽은 유저 전체 리스트 */
readBy: User[];
};채팅방 목록 화면에서 정말 필요한 게 메시지를 읽은 모든 유저일까요?
아닙니다. 보통 화면에서 필요한 건 읽지 않은 사람 수와 내가 읽었는지 여부뿐입니다.
type Message = {
id: string;
text: string;
unreadCount: number; // 아직 안 읽은 사람 수
viewerState: {
read: boolean; // 내가 읽었는지
};
};이 차이가 얼마나 클까요? 숫자로 보면 확실합니다.
readBy 방식 (1,000명 단톡방)
→ 화면에 메시지 50개 × 읽은 사람 1,000명 = 50,000개의 user id 전송
viewerState 방식
→ 메시지 50개 × (숫자 1개 + boolean 1개) = 100개 값 전송
readBy 배열을 내려보내면 참여 인원 N에 비례해 메시지당 페이로드가 커집니다. 반면 unreadCount와 viewerState.read만 내려보내면 참여 인원과 무관하게 메시지당 O(1) 크기를 유지합니다.
데이터 모델을 어떻게 설계하느냐가 전송량과 렌더링 성능을 결정합니다.
I — Interface (인터페이스)
인터페이스는 API만이 아니다.
"인터페이스 설계 = API 명세"라고 생각하기 쉽지만, 프론트엔드 시스템 디자인에서 인터페이스는 네 가지입니다.
- 컴포넌트 인터페이스 — Props, Hook, Return Type
- 서버 API 인터페이스 — 요청/응답 계약
- 이벤트 인터페이스 — Event Contract
- 관측/Telemetry 인터페이스 — 측정 스키마
단순한 Props로는 부족할 때
채팅 메시지 입력창을 만든다고 해볼게요. 단순하게 생각하면 이 정도면 될 것 같습니다.
<MessageInput onSend={handleSend} />하지만 실시간 채팅을 제대로 지원하려면, 그 뒤에 실시간 연결을 다루는 전혀 다른 수준의 인터페이스가 필요합니다.
interface ChatEngine {
connect(roomId: string): Promise<void>;
send(message: DraftMessage): Promise<MessageId>;
subscribe(event: ChatEvent, listener: Listener): Unsubscribe;
markAsRead(messageId: MessageId): void;
disconnect(): void;
}여기서 ChatEngine이 감추는 게 바로 전송 계층입니다. 내부 transport는 WebSocket일 수도, SSE·MQTT·WebRTC DataChannel일 수도 있어요. UI는 그걸 몰라도 되니 전송 방식을 바꿔도 손대지 않습니다.
왜 이렇게 나눌까요? 각자 담당하는 책임이 다르기 때문입니다.
- UI는 입력창과 말풍선, 스크롤을 담당하고
- 엔진은 연결, 재연결, 메시지 큐잉, 순서 보장을 담당하고
- Telemetry는 연결 성공률, 전송 지연, 재연결 횟수를 담당합니다.
좋은 인터페이스는 이렇게 팀과 시스템을 분리할 줄 압니다. 그래서 협업·테스트·장애 격리가 가능해지죠.
Telemetry도 인터페이스다
"API 명세만 잘 잡으면 인터페이스는 끝 아닌가?"라고 생각할 수 있습니다.
하지만 컴포넌트 Props, Hook의 Return Type, Event Contract, 그리고 Telemetry 스키마까지 모두 인터페이스입니다.
특히 Telemetry는 운영팀과 제품팀이 실제 사용자 경험을 이해하려고 맺는 계약입니다.
⚠️ 모니터링할 수 없는 시스템은 고칠 수 없다
"지금 당장 Telemetry가 필요한가? 설계 단계부터 넣어야 하나?"라고 물을 수 있습니다.
하지만 나중에 문제가 터졌을 때, 관측 이벤트가 없으면 원인을 찾을 수가 없습니다.
그래서 Telemetry는 기능을 다 만든 뒤가 아니라 설계 단계에서 인터페이스로 함께 정의해야 합니다.
O — Optimization (최적화)
최적화는 나중에 하는 게 아니라, 설계의 일부다.
"기능 다 만들고 나중에 빠르게 만들면 되지"라는 생각, 많이들 하십니다.
소규모 프로젝트라면 그래도 됩니다. 하지만 대규모 프론트엔드에서는 이미 늦습니다.
- 렌더링 구조가 잘못되면 나중에 최적화하기 어렵고
- 상태 모델이 잘못되면 refetch나 rollback이 꼬이고
- 메시지 높이 정보가 없으면 CLS를 나중에 잡기 어렵습니다.
그래서 최적화의 조건들은 설계 단계에서부터 고려되어야 합니다.
메인 스레드와 롱 태스크
브라우저의 메인 스레드는 대부분의 자바스크립트 실행과 렌더링 작업을 처리하는데, 한 번에 하나의 태스크만 처리할 수 있습니다.
💡 롱 태스크(Long Task)란?
web.dev에서는 50ms를 넘는 태스크를 롱 태스크로 정의합니다.
메인 스레드가 오래 막히면 사용자에게는 화면이 응답하지 않는 것처럼 느껴집니다.
앞서 본 INP가 나빠지는 주된 원인이기도 합니다.
그래서 프론트엔드 성능은 단순히 "자바스크립트를 줄이고 렌더링을 줄이는" 문제가 아닙니다.
네트워크, 자바스크립트 실행, 이미지·비디오 로딩, 캐시, 상태 업데이트 범위, 브라우저 자체 최적화까지 여러 축이 얽힌 종합 문제예요.
Virtualization으로 긴 메시지 목록 다루기
채팅 로그를 렌더링하는 흔한 코드를 볼게요.
messages.map((message) => <MessageBubble message={message} />);메시지가 10개라면 문제없습니다. 그런데 오래된 단톡방을 스크롤해서 10,000개를 다 띄운다면 어떨까요?
전체 렌더링 방식
→ messages = 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/virtual같은 라이브러리가 대표적이에요.
다만 채팅은 메시지 높이가 제각각이고 위로 무한 스크롤되기 때문에, 일반 리스트보다 가상화 구현이 까다로운 편입니다.
결국 프론트엔드 시스템 디자인은 리액트 코드만 설계하는 게 아닙니다.
브라우저의 실행 모델과 최적화 조건까지 함께 고려해야 비로소 완성됩니다.
마무리
지금까지 채팅 앱 하나를 RADIO 다섯 단계로 따라가 봤습니다.
메시지 전송 품질을 숫자로 못 박았고(R), 상태는 생명주기별로 갈랐습니다(A). 읽음 표시는 O(1) 모델로, 전송 계층은 인터페이스로 책임을 떼어냈죠(D·I). 마지막으로 긴 로그는 가상화로 덜어냈고요(O).
처음에 이야기했듯, 보통은 "어떤 기술을 쓸까?"부터 시작합니다.
하지만 RADIO는 그 순서를 뒤집습니다.
사용자가 무엇을 기대하는지 → 그 품질을 어떻게 보장할지 → 그러려면 어떤 구조가 필요한지를 먼저 묻습니다. 기술은 그 답을 구현하기 위한 수단으로 따라옵니다.
소재가 채팅 앱이든 검색이든 커머스든 사고의 틀은 똑같습니다.
무엇보다 실무에서 설계할 때 "내가 지금 무엇을 놓치고 있지?"를 점검하는 체크리스트로 쓰기 좋습니다.