---
name: radio-system-design
description: >-
    프론트엔드 기능과 화면, 시스템을 코드 작성 전에 설계하는 RADIO 방법론이다.
    Requirements(무엇을 보장할까), Architecture(상태의 원천과 데이터 흐름), Data
    Model(화면을 위한 모델), Interface(경계의 계약), Optimization(설계에 내장된
    성능과 장애 대응, 운영) 5단계를 순서대로 밟는다. "무한 스크롤 피드 만들자",
    "채팅 붙여줘", "검색 자동완성 넣자", "알림 기능 설계해줘", "이 기능 어떻게
    만들지", "아키텍처 잡아줘", "상태 관리 어떻게 할까", "설계 문서 써줘"처럼
    비자명한 기능을 새로 만들거나 구조를 잡는 맥락이면 설계라는 단어가 없어도
    사용하라. 실시간과 낙관적 업데이트, 대규모 목록, 복잡한 상태, race condition,
    오프라인, 접근성이 얽힌 UI일수록 반드시 사용하라. 색과 타이포, 간격 같은 시각
    디자인이나 이미 작성된 코드의 리팩토링, 버그 수정, 타입 에러 해결이 목적이면
    쓰지 마라.
---

# RADIO 시스템 설계

기능을 코드보다 먼저 설계하는 절차다. 출발점은 기술 선택이 아니라 사용자에게 보장할 것이다. "어떤 기술을 쓸까"에서 시작하면 기술이 요구사항을 재단하고, "무엇을 보장할까"에서 시작하면 요구사항이 기술을 고른다.

질문의 순서는 어떤 소재에서도 같지만 답은 소재마다 근본적으로 달라진다. 같은 순서로 물어도 자동완성에서는 디바운스와 stale 응답 폐기가 나오고 채팅에서는 낙관적 병합과 재전송 큐가 나온다. 순서가 프레임워크이고 답은 소재의 것이다.

## 진행 방식

다섯 단계를 순서대로 밟는다. 각 단계는 질문 하나에 답한다.

| 단계             | 질문                                          | 산출물                                              |
| ---------------- | --------------------------------------------- | --------------------------------------------------- |
| R Requirements   | 무엇을 보장할 것인가                          | 기능 목록, 숫자로 적은 보장, 범위 밖 목록           |
| A Architecture   | 상태의 원천은 어디이고 데이터는 어떻게 흐르나 | 상태 분류표, 통신 성질 결정, 데이터 흐름            |
| D Data Model     | 화면이 다루기 좋은 형태인가                   | 타입 정의, 병합과 정렬 규칙                         |
| I Interface      | 경계마다 계약이 있는가                        | 컴포넌트와 API, 이벤트, 텔레메트리, 접근성 계약     |
| O Optimization   | 성능과 장애 대응이 설계에 녹아 있는가         | 앞 단계 검산, 지표 스키마, 운영 계획                |

앞 단계의 답이 뒤 단계의 입력이다. 뒤 단계에서 앞의 결정이 뒤집히면 이유를 적고 그 단계부터 다시 내려온다.

간단한 기능이면 단계별 요약을 대화에 바로 적는다. 기능이 크거나(여러 종류의 상태, 실시간, 여러 팀이 걸림) 사용자가 문서를 원하면 마지막 절의 템플릿으로 설계 문서를 만든다. 저장 위치는 프로젝트 관례를 먼저 따르고 관례가 없으면 경로를 제안하고 확인받는다.

## R. Requirements: 무엇을 보장할 것인가

기능 목록을 적는 것으로 시작하되 거기서 끝내지 않는다. "다음 게시물을 불러온다"는 기능이고, "스크롤이 끊기지 않고 중복 요청 없이 읽던 자리를 잃지 않는다"가 보장이다. 설계를 결정하는 것은 보장 쪽이다.

기능을 보장으로 바꾸는 방법은 셋이다.

1. 기능 문장을 적는다
2. 그 기능이 실패하거나 어긋나는 장면을 떠올린다. 끊김, 중복, 위치 상실, 늦은 응답, 잘못된 순서
3. 그 장면이 일어나지 않는다는 문장으로 뒤집고 숫자를 붙인다

"빠르다"는 검증할 수 없다. 숫자와 측정 기준점(보통 p75)까지 적어야 보장이다. 공통 기준은 Core Web Vitals다.

| 지표 | 뜻                                    | 권장 기준 (p75) |
| ---- | ------------------------------------- | --------------- |
| LCP  | 메인 콘텐츠가 그려질 때까지           | 2.5초 이하      |
| INP  | 상호작용 후 화면이 반응할 때까지      | 200ms 이하      |
| CLS  | 예상하지 못한 레이아웃 이동의 누적    | 0.1 이하        |

공통 지표만으로는 부족하다. 서비스마다 사용자가 실제로 체감하는 순간을 지표로 하나 이상 정의한다. 피드는 첫 게시물이 보일 때까지, 검색은 입력을 멈춘 뒤 결과가 보일 때까지, 채팅은 전송 버튼을 누른 뒤 내 말풍선이 보일 때까지다.

이 단계에서 설계를 가르는 질문을 찾는다. 답에 따라 이후 단계가 통째로 달라지는 질문들이다.

- **주도권이 누구에게 있나.** 클라이언트가 묻고 서버가 답하는가, 서버가 먼저 말을 거는가. 이 한 문장이 아키텍처 전체를 가른다
- 실패를 사용자에게 어떻게 보이나. 조용히 접는가, 상태를 남기고 사용자가 정하게 하는가
- 데이터가 얼마나 신선해야 하나. 초 단위 지연이 허용되는가
- 책임이 어디 소속인가. 예를 들어 추천어 랭킹은 서버 몫인가 클라이언트 몫인가

범위 밖도 명시한다. 하지 않기로 한 것이 적혀 있어야 나중에 조용히 범위가 늘지 않는다.

## A. Architecture: 상태의 원천과 데이터 흐름

아키텍처는 폴더 구조가 아니라 책임 분리와 데이터 흐름이다. 화면에 보이는 모든 값을 한 배열에 넣고 시작하면 서버 데이터와 로딩 중인 페이지, 낙관적 항목, UI 상태, 필터가 뒤섞여 "진짜 데이터가 뭐고 어디로 롤백하지"라는 질문에 답할 수 없게 된다.

상태를 생명주기로 분류한다. "어디에 저장할까"보다 "이 상태의 원천(Source of Truth)이 어디인가"를 먼저 묻는다.

| 분류             | 원천               | 예                                  |
| ---------------- | ------------------ | ----------------------------------- |
| Server State     | 서버               | 게시물, 프로필, 검색 결과           |
| Local State      | 현재 화면          | 모달 열림, 입력 중인 텍스트         |
| URL State        | 주소창             | 검색어, 필터, 페이지 번호           |
| Optimistic State | 클라이언트의 의도  | 서버 응답 전에 눌러 둔 좋아요       |
| Derived State    | 다른 상태의 계산   | 미읽음 개수, 장바구니 총액          |

URL State는 공유와 새로고침 복구가 걸린 상태다. 이걸 Local에 두면 링크를 받은 사람이 다른 화면을 본다.

통신은 프로토콜이 아니라 성질부터 정한다. 기준은 R에서 정한 주도권이다.

- 클라이언트가 묻고 서버가 답하면 요청 응답이다. HTTP에 디바운스와 취소, 캐시 제어를 더한다. 무상태라 수평 확장과 CDN, AbortController 취소가 표준으로 따라온다
- 서버가 먼저 말을 걸면 지속 연결이 필요하다. 수신만 필요하면 SSE 성질, 송수신 모두면 WebSocket 성질이다

실시간처럼 보인다고 지속 연결이 필요한 것은 아니다. 자동완성은 타이핑마다 반응해도 클라이언트가 묻는 쪽이라 요청 응답이 맞다. 성질만 여기서 고정하고 구체 프로토콜은 교체 가능하게 남긴다.

입력에서 화면까지의 경로가 여러 갈래로 갈리면 중앙 컨트롤러 하나로 모은다. 캐시 확인과 디바운스, 이전 요청 취소, 응답 검증, 상태 갱신이 같은 길을 지나야 race condition의 해결점이 한 곳에 모인다. 컴포넌트가 제각기 요청을 쏘면 경합을 막을 자리가 없다.

지속 연결을 골랐다면 연결은 boolean이 아니다. 끊김, 재연결, 인증, 누락 동기화, 준비됨의 생명주기를 상태 기계로 적는다. 재연결 중에 보낸 메시지가 어디에 쌓이는지도 여기서 정한다.

## D. Data Model: 화면을 위한 모델

서버 스키마를 그대로 옮기지 않는다. 화면이 실제로 쓰는 형태로 설계한다.

페이로드 크기는 참여 규모와 무관해야 한다. 화면이 묻는 것은 "몇 명이 골랐고 나는 골랐나"이지 "누가 골랐나"의 전체 목록이 아니다.

| 화면의 질문             | 규모에 비례하는 모델        | 규모와 무관한 모델          |
| ----------------------- | --------------------------- | --------------------------- |
| 투표 현황과 내 참여     | 투표자 User 배열            | voteCount와 viewerState     |
| 단체방 메시지 읽음      | 읽은 사람 목록              | unreadCount 하나            |

낙관적 업데이트가 있으면 식별자를 둘로 나눈다.

```typescript
type Message = {
	clientMessageId: string; // 클라이언트가 즉시 발급. 매칭과 중복 제거의 키
	serverSeq: number | null; // 서버가 발급하는 순번. 정렬의 유일한 기준. 확정 전에는 null
	status: 'sending' | 'sent' | 'failed';
	createdAt: string; // 표시 전용. 기기 시계는 정렬 기준이 될 수 없다
};
```

병합 규칙이 모델의 절반이다. 서버 확정(ack)이 오면 clientMessageId로 기존 항목을 찾아 갱신한다. 새로 추가하면 같은 내용이 두 개 뜬다.

순서와 중복, 누락은 서버 순번 하나로 통합해 처리한다. 받은 seq가 마지막 seq와 같으면 중복이니 무시하고, 바로 다음이면 그대로 처리하고, 갭이 있으면 그 구간을 동기화 요청한다. 재연결 복구는 이 갭 동기화의 극단적인 사례일 뿐이라 별도 규칙이 필요 없다.

요청 응답형이면 응답에 요청 값을 에코해 담는다. 응답이 도착했을 때 현재 입력값과 대조해 다르면 버린다. 늦게 도착한 이전 검색어의 결과가 화면을 덮는 사고를 이 한 필드가 막는다.

## I. Interface: 경계의 계약

인터페이스는 서버 API 하나가 아니라 경계마다 있는 계약이다.

| 계약           | 내용                                                 |
| -------------- | ---------------------------------------------------- |
| 컴포넌트       | props와 hook의 인자, 반환 타입                       |
| 서버 API       | 요청과 응답의 형태, 에러 형태                        |
| 이벤트         | 이벤트 이름과 페이로드                               |
| 텔레메트리     | 어떤 사건을 어떤 필드로 남기는지                     |
| 접근성         | 역할(role)과 속성, 키보드 동작                       |

UI가 브라우저 API나 프로토콜을 직접 만지지 않게 엔진 인터페이스를 하나 둔다. 엔진은 무엇을 할 수 있는지만 드러내고 어떻게 하는지는 감춘다. 재연결과 큐, 순서 로직이 엔진 안에 모이고 UI는 구독만 한다.

```typescript
interface ChatEngine {
	connect(roomId: string): Promise<void>;
	send(body: MessageBody): ClientMessageId; // 서버 응답을 기다리지 않고 즉시 반환한다
	subscribe(event: ChatEvent, listener: Listener): Unsubscribe;
	disconnect(): void;
}
```

send가 Promise가 아니라 식별자를 즉시 반환하는 것이 낙관적 UI의 계약이다. UI는 반환받은 식별자로 바로 그리고, 확정은 이벤트로 받는다.

프로토콜 교체가 예상되면 엔진 아래에 전송 계층을 한 겹 더 둔다.

| 계층      | 아는 것                | 모르는 것            |
| --------- | ---------------------- | -------------------- |
| UI        | 엔진 인터페이스        | 프로토콜, 재연결     |
| 엔진      | 전송 계층의 추상 계약  | 구체 프로토콜        |
| 전송 구현 | WebSocket이나 SSE 등   | 도메인 의미          |

이벤트 종류가 여럿이면 공통 봉투로 통일한다. `{ roomId, seq, type, payload }`처럼 바깥 필드를 고정하면 종류가 늘어도 정렬과 중복 제거는 바깥 필드만 보고 같은 규칙으로 처리한다.

텔레메트리는 기능 완성 후가 아니라 여기서 정의한다. 관측할 수 없는 시스템은 고칠 수 없다. 어떤 사건(전송 성공과 실패, 캐시 히트, stale 응답 폐기, 재연결)을 어떤 필드로 남길지 스키마로 적는다.

접근성도 계약이다. 해당하는 표준 패턴(예: 자동완성은 WAI-ARIA combobox)을 찾아 역할과 속성, 키보드 동작을 표로 적는다. 포커스를 어디에 유지하고 활성 항목을 무엇으로 전달하는지까지가 계약이다. 화면 갱신을 스크린리더에 알릴 자리(aria-live)도 여기서 정한다.

## O. Optimization과 운영: 설계의 검산

이 단계는 새 작업 목록이 아니라 검산이다. 디바운스와 캐시, 요청 취소, 낙관적 처리는 이미 앞 단계에서 결정됐어야 한다. 여기서 최적화 항목이 새로 쏟아지면 앞 단계가 부실했다는 신호이니 해당 단계로 돌아간다.

**렌더링.** 메인 스레드에서 50ms를 넘는 작업은 INP를 무너뜨린다. 목록이 크면 가상화한다. 만 개를 다 그리면 DOM 노드가 수십만 개지만 보이는 영역에 여유분(overscan)을 더해 그리면 수백 개로 준다. 항목 높이가 제각각이면 렌더 후 측정해 캐시하고, 위로 붙는 목록(과거 로드)은 스크롤 위치를 보정(anchoring)한다.

**장애.** 원칙은 셋이다.

- 타임아웃을 정한다. 응답이 없을 때 무한정 기다리는 화면을 남기지 않는다
- 부가 기능은 조용히 접고 핵심 기능은 유지한다. 추천이 안 떠도 검색은 되어야 한다
- 사용자가 만든 데이터는 조용히 버리지 않는다. 실패 상태를 남기고 재시도를 사용자가 정하게 한다

**몰림.** 재시도는 지수 백오프에 무작위(jitter)를 반드시 더한다. 백오프만 있으면 모두가 같은 시각에 재시도해 복구 중인 서버를 다시 무너뜨린다.

```typescript
const maxDelay = Math.min(CAP_MS, BASE_MS * 2 ** retryCount);
const delay = Math.random() * maxDelay; // full jitter
```

서버가 재시도 시점을 내려주면(retryAfter) 그것이 우선한다. 자동 재시도는 네트워크 오류에만 한다. 인증 실패를 재시도하면 안 된다. 받는 쪽이 감당 못 할 만큼 이벤트가 쏟아지면 프레임 단위로 모아 반영하고, 최신만 의미 있는 이벤트(타이핑 표시)는 마지막 것만 남긴다.

**지표.** 두 층으로 적는다.

| 층        | 성격                              | 예                                                      |
| --------- | --------------------------------- | ------------------------------------------------------- |
| 체감 지표 | R의 보장 문장을 그대로 잰다       | 입력 멈춤부터 결과 표시까지, 전송부터 내 말풍선까지     |
| 시스템 지표 | 설계 가정이 새는지 검산한다     | 재연결율, 전송 실패율, 중복률, stale 폐기율             |

0이어야 정상인 지표가 검산 장치다. 중복률이 0이 아니면 병합 설계가 샌 것이니 D 단계로 돌아간다. 지표는 교차로 읽는다. stale 폐기율이 높은데 지연이 정상이면 문제는 서버가 아니라 짧은 디바운스다.

**운영.** 디바운스 값 같은 파라미터는 A/B로 조정하고, 남용은 rate limit으로 막고, 사용자 입력을 렌더링하면 sanitization을 넣고, 새 기능은 feature flag로 점진 배포한다.

## 설계 문서 템플릿

```markdown
# {기능 이름} 설계

## R. Requirements

- 기능:
- 보장 (숫자와 p75 기준):
- 설계를 가르는 질문과 답:
- 범위 밖:

## A. Architecture

- 상태 분류 (분류, 원천, 예):
- 통신 성질과 근거:
- 데이터 흐름:

## D. Data Model

- 타입 정의:
- 병합과 정렬 규칙:

## I. Interface

- 컴포넌트 (props, hook 반환):
- 서버 API:
- 이벤트:
- 텔레메트리 스키마:
- 접근성 (역할, 키보드):

## O. Optimization과 운영

- 렌더링:
- 장애 대응:
- 재시도와 몰림 제어:
- 지표 (체감, 시스템):
- 운영 (A/B, rate limit, flag):
```

## 사례로 배우기

같은 다섯 질문이 소재에 따라 전혀 다른 설계를 낳는다.

| 항목      | 자동완성 (클라이언트 주도) | 채팅 (서버 주도)          |
| --------- | -------------------------- | ------------------------- |
| 통신      | HTTP 요청 응답             | 지속 연결                 |
| 정확성    | stale 응답 폐기            | 낙관적 병합과 순서 보장   |
| 실패 처리 | 조용한 폴백                | 재전송 큐                 |
| 몰림 제어 | 디바운스                   | 백오프와 jitter           |

설계 대상이 입력에 반응해 서버에 묻는 형이면 `references/case-request-response.md`를 읽고 진행한다. 서버가 먼저 밀어주는 실시간형이면 `references/case-realtime.md`를 읽는다. 두 파일은 각 단계의 질문이 실제 답으로 이어진 전체 과정을 담은 작업 예제다.

## 쓰지 않을 때

색과 타이포, 간격 같은 시각 디자인은 이 절차의 대상이 아니다. 이미 작성된 코드의 리팩토링과 버그 수정, 타입 에러 해결도 마찬가지다. 그 작업들에 이 절차를 강요하면 설계 문서만 늘고 코드는 그대로다.
