아직 서버에 말을 걸지 않는
채팅 화면
사이드바·입력창·메시지 목록이 모두 동작한다. 그런데 HTTP 요청은 한 줄도 없다. 이 페이지는 “지금 무엇이 진짜이고 무엇이 껍데기인가”를 정확히 구분한다.
이 파일을 만든 이유
백엔드 채팅 API는 3~4단계에서 만든다. 그때까지 화면이 없으면 무엇을 향해 API를 만드는지 알 수 없다. 이 파일은 최종 화면의 모양을 먼저 정해 두어 API 계약을 구체적으로 상상할 수 있게 한다.
동시에 이 파일은 의도적으로 미완성이다. 전송 버튼은 사용자 문장을 화면에 추가할 뿐이고, 모델의 답변 자리에는 고정된 안내 문구가 들어간다. 이 경계를 흐릿하게 두면 “되는 것처럼 보이는데 안 되는” 상태가 되므로, 아래에서 명시적으로 구분한다.
지금 진짜인 것과 껍데기인 것
| 화면 요소 | 현재 상태 | 비고 |
|---|---|---|
| 입력창 타이핑 | 동작 | prompt state에 저장 |
| Enter 전송 · Shift+Enter 줄바꿈 | 동작 | handleKeyDown이 구분 |
| 빈 입력 거절 | 동작 | trim() 후 빈 문자열이면 무시 |
| 사용자 메시지 표시 | 동작 | messages 배열에 추가 |
| 새로운 채팅 | 동작 | 세 state를 초기화 |
| 사이드바 열고 닫기 | 동작 | sidebarOpen |
| 제안 카드 클릭 | 동작 | 입력창에 문구를 넣어 준다 |
| Gemma 답변 | 고정 문구 | “FastAPI가 연결되면…” — 모델과 무관 |
| 최근 대화 목록 | 고정 문구 | 저장소도 API도 없다 |
| 모델 선택 · 검색 · 보관 | 버튼만 | onClick이 없다 |
| 로컬 서버 상태 표시 | 고정 문구 | /readyz를 부르지 않는다 |
즉 state가 바뀌는 상호작용은 전부 진짜이고, 서버가 필요한 것은 전부 껍데기다. 경계가 정확히 네트워크 지점에 있다.
전체 코드
import { useState } from 'react'
import type { FormEvent, KeyboardEvent, ReactNode } from 'react'
import './App.css'
type IconName = 'archive' | 'chevron' | 'close' | 'code' | 'edit' | 'menu' | 'model' | 'plus' | 'search' | 'send' | 'sparkles'
const iconPaths: Record<IconName, ReactNode> = {
archive: <><path d="M4 7.5h16M6 4h12a1 1 0 0 1 1 1v2.5H5V5a1 1 0 0 1 1-1Z"/><path d="M6.5 7.5V19a1 1 0 0 0 1 1h9a1 1 0 0 0 1-1V7.5M10 11h4"/></>,
chevron: <path d="m9 10 3 3 3-3"/>, close: <path d="m6 6 12 12M18 6 6 18"/>,
code: <path d="m8.5 9-3 3 3 3M15.5 9l3 3-3 3M13.5 6.5l-3 11"/>,
edit: <><path d="M13.5 6.5 17.5 10.5M5 19l3.7-.8 9.6-9.6a1.4 1.4 0 0 0 0-2l-.9-.9a1.4 1.4 0 0 0-2 0l-9.6 9.6L5 19Z"/><path d="M13 19h6"/></>,
menu: <path d="M4 7h16M4 12h16M4 17h16"/>,
model: <><path d="M12 3.5 19 7.4v9.2L12 20.5 5 16.6V7.4L12 3.5Z"/><path d="m8.5 10 3.5 2 3.5-2M12 12v4"/></>,
plus: <path d="M12 5v14M5 12h14"/>, search: <><circle cx="11" cy="11" r="6"/><path d="m16 16 4 4"/></>,
send: <><path d="M12 19V5M7 10l5-5 5 5"/><path d="M5 19h14"/></>,
sparkles: <><path d="M12 3l.8 2.2A4.8 4.8 0 0 0 15.7 8l2.3.8-2.3.8a4.8 4.8 0 0 0-2.9 2.9L12 15l-.8-2.5a4.8 4.8 0 0 0-2.9-2.9L6 8.8 8.3 8a4.8 4.8 0 0 0 2.9-2.8L12 3Z"/><path d="m18.5 14 .4 1.1a2.3 2.3 0 0 0 1.4 1.4l1.2.5-1.2.4a2.3 2.3 0 0 0-1.4 1.5l-.4 1.1-.4-1.1a2.3 2.3 0 0 0-1.4-1.5l-1.2-.4 1.2-.5a2.3 2.3 0 0 0 1.4-1.4l.4-1.1Z"/></>,
}
function Icon({ name, size = 20 }: { name: IconName; size?: number }) {
return <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">{iconPaths[name]}</svg>
}
const suggestions = [
{ icon: 'code' as const, title: '코드를 함께 작성해요', detail: 'React 컴포넌트 설계와 디버깅' },
{ icon: 'sparkles' as const, title: '복잡한 개념을 이해해요', detail: 'MoE와 로컬 추론을 차근차근' },
{ icon: 'model' as const, title: '프로젝트를 분석해요', detail: '구조와 다음 작업을 정리하기' },
]
function App() {
const [sidebarOpen, setSidebarOpen] = useState(false)
const [prompt, setPrompt] = useState('')
const [messages, setMessages] = useState<string[]>([])
const send = () => { const text = prompt.trim(); if (!text) return; setMessages((current) => [...current, text]); setPrompt('') }
const submit = (event: FormEvent) => { event.preventDefault(); send() }
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); send() } }
const newChat = () => { setMessages([]); setPrompt(''); setSidebarOpen(false) }
return <div className="app-shell">
{sidebarOpen && <button className="sidebar-scrim" aria-label="사이드바 닫기" onClick={() => setSidebarOpen(false)} />}
<aside className={`sidebar ${sidebarOpen ? 'sidebar--open' : ''}`}>
<div className="sidebar-top"><div className="brand-mark">G</div><button className="icon-button sidebar-close" aria-label="사이드바 닫기" onClick={() => setSidebarOpen(false)}><Icon name="close" /></button></div>
<button className="new-chat" onClick={newChat}><span><Icon name="edit" size={19} />새로운 채팅</span><span className="shortcut">⌘ K</span></button>
<nav className="sidebar-nav" aria-label="채팅 메뉴"><button><Icon name="search" size={19} />채팅 검색</button><button><Icon name="archive" size={19} />보관된 대화</button></nav>
<div className="history"><p className="history-label">최근 대화</p><button className="history-item history-item--active">Gemma 서비스 만들기</button><button className="history-item">TurboFieldfare 구조 분석</button><button className="history-item">MoE Expert cache 실험</button></div>
<div className="sidebar-footer"><div className="server-dot" /><div><strong>로컬 서버</strong><span>연결 준비 중</span></div></div>
</aside>
<main className="chat-main">
<header className="topbar">
<button className="icon-button mobile-menu" aria-label="메뉴 열기" onClick={() => setSidebarOpen(true)}><Icon name="menu" /></button>
<button className="model-select"><span>Gemma 4</span><span className="model-caption">Local MoE</span><Icon name="chevron" size={18} /></button>
<div className="topbar-actions"><span className="status-pill"><i />Local</span><button className="profile-button" aria-label="프로필">M</button></div>
</header>
<section className={`conversation ${messages.length ? 'conversation--active' : ''}`}>
{messages.length === 0 ? <div className="welcome">
<div className="welcome-symbol"><Icon name="sparkles" size={28} /></div><p className="eyebrow">ON DEVICE</p><h1>무엇을 함께 해볼까요?</h1>
<div className="suggestions">{suggestions.map((item) => <button key={item.title} onClick={() => setPrompt(item.title)}><span className="suggestion-icon"><Icon name={item.icon} size={19} /></span><span><strong>{item.title}</strong><small>{item.detail}</small></span></button>)}</div>
</div> : <div className="message-list">{messages.map((message, index) => <div className="message-pair" key={`${message}-${index}`}><div className="user-message">{message}</div><div className="assistant-message"><div className="assistant-avatar">G</div><div><strong>Gemma</strong><p>FastAPI가 연결되면 이곳에 모델의 답변이 실시간으로 표시됩니다.</p></div></div></div>)}</div>}
</section>
<div className="composer-wrap"><form className="composer" onSubmit={submit}>
<textarea value={prompt} onChange={(event) => setPrompt(event.target.value)} onKeyDown={handleKeyDown} rows={1} placeholder="Gemma에게 무엇이든 물어보세요" aria-label="메시지" />
<div className="composer-actions"><button type="button" className="attach-button" aria-label="파일 추가"><Icon name="plus" size={21} /></button><span className="composer-hint">Enter로 전송 · Shift+Enter로 줄바꿈</span><button className="send-button" type="submit" disabled={!prompt.trim()} aria-label="메시지 보내기"><Icon name="send" size={19} /></button></div>
</form><p className="disclaimer">Gemma는 실수할 수 있습니다. 중요한 정보는 다시 확인하세요.</p></div>
</main>
</div>
}
export default App
70줄이지만 줄이 길다. JSX를 한 줄에 몰아 쓴 스타일이라 세로로는 짧고 가로로는 길다. 아래에서 의미 단위로 나누어 읽는다.
import와 type — 무엇을 왜 가져오는가
import { useState } from 'react'
import type { FormEvent, KeyboardEvent, ReactNode } from 'react'
import './App.css'
useState
React가 값을 기억하게 만드는 함수다. 보통 변수는 함수가 다시 실행되면 초기화되지만, useState로 만든 값은 다시 그려도 유지된다. 이런 함수를 Hook이라 부르며 컴포넌트 함수의 최상단에서만 부를 수 있다.
import type { ... }
type 키워드가 붙으면 type만 가져오고 실행 코드는 남기지 않는다. build 결과에서 이 줄은 통째로 사라진다. FormEvent·KeyboardEvent·ReactNode는 값이 아니라 “모양의 이름”이기 때문이다.
import './App.css'
변수를 가져오지 않는 부작용 import다. Vite가 이 파일을 CSS로 인식해 번들에 포함한다. 클래스 이름은 문자열로 쓰이므로 TypeScript가 오타를 잡아 주지 않는다.
type IconName = 'archive' | 'chevron' | 'close' | 'code' | 'edit' | 'menu' | 'model' | 'plus' | 'search' | 'send' | 'sparkles'
const iconPaths: Record<IconName, ReactNode> = { /* ... */ }
|로 이어진 문자열 목록은 “이 중 하나”를 뜻한다. Python의 Literal과 같은 개념이다. Record<IconName, ReactNode>는 “IconName의 모든 값이 열쇠로 있어야 하는 객체”다. 아이콘을 하나 빠뜨리면 npm run build가 실패한다. <Icon name="sned" /> 같은 오타도 마찬가지다.
state 세 개 — 화면이 기억하는 전부
const [sidebarOpen, setSidebarOpen] = useState(false)
const [prompt, setPrompt] = useState('')
const [messages, setMessages] = useState<string[]>([])
const send = () => { const text = prompt.trim(); if (!text) return; setMessages((current) => [...current, text]); setPrompt('') }
| state | type | 의미 |
|---|---|---|
sidebarOpen | boolean | 좁은 화면에서 사이드바가 열려 있는가 |
prompt | string | 입력창에 지금 적힌 글자 |
messages | string[] | 사용자가 보낸 문장만. 역할·시간·id가 없다 |
const [값, 설정함수] = useState(초기값)
배열 구조 분해다. useState는 두 칸짜리 배열을 돌려주고, 첫 칸은 현재 값, 둘째 칸은 값을 바꾸는 함수다. 이름은 자유지만 x/setX 관례를 따른다.
useState<string[]>([])
<string[]>을 명시한 이유는 빈 배열만으로는 무엇이 들어갈지 알 수 없기 때문이다. 생략하면 TypeScript가 never[]로 추론해 어떤 값도 넣을 수 없게 된다. useState(false)는 초기값에서 boolean이 분명하므로 생략했다.
setMessages((current) => [...current, text])
값을 직접 주지 않고 함수를 준다. React가 “가장 최신 값”을 인수로 넘겨 주므로 연달아 갱신해도 누락이 없다. [...current, text]는 기존 배열을 복사하고 뒤에 하나를 붙인 새 배열이다.
current.push(text)로 원본을 바꾸면 화면이 갱신되지 않는다. React는 배열의 내용이 아니라 객체가 바뀌었는지로 다시 그릴지 판단하기 때문이다. 이 규칙이 React에서 가장 흔한 함정이다.
if (!text) return
trim()으로 앞뒤 공백을 없앤 뒤 빈 문자열이면 아무것도 하지 않는다. 공백만 있는 메시지를 서버로 보내지 않기 위한 1차 방어선이다. 다만 브라우저 검사는 우회할 수 있으므로 FastAPI가 같은 검사를 다시 해야 한다.
이벤트 처리기 세 개
const submit = (event: FormEvent) => { event.preventDefault(); send() }
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); send() } }
const newChat = () => { setMessages([]); setPrompt(''); setSidebarOpen(false) }
event.preventDefault() — submit
HTML <form>의 기본 동작은 페이지 전체를 다시 불러오는 것이다. 그러면 React state가 전부 사라진다. 이 한 줄이 그것을 막는다. SPA에서 form을 쓸 때 반드시 필요하다.
event.key === 'Enter' && !event.shiftKey
Enter는 전송, Shift+Enter는 줄바꿈이라는 채팅 관례를 만든다. 여기서도 preventDefault()가 필요한데, 없으면 전송과 동시에 줄바꿈까지 들어간다.
KeyboardEvent<HTMLTextAreaElement>
꺾쇠 안의 type은 “이 이벤트가 어느 요소에서 났는가”를 알려 준다. 덕분에 event.currentTarget.value가 문자열임을 TypeScript가 안다. 이것은 React가 정의한 type이며 브라우저의 전역 KeyboardEvent와 이름만 같고 다르다 — 그래서 파일 위에서 import type으로 가져왔다.
newChat이 state 세 개를 모두 만지는 이유
“새 대화”는 화면의 모든 흔적을 지우는 동작이다. 메시지를 비우고, 쓰다 만 입력도 비우고, 좁은 화면에서는 사이드바를 닫아 대화 영역을 보여 준다. 서버에는 아무것도 알리지 않는다 — 저장되는 대화가 아직 없기 때문이다.
화면이 갈라지는 지점
{messages.length === 0 ? <div className="welcome"> ... </div>
: <div className="message-list"> ... </div>}
조건 ? A : B는 삼항 연산자다. messages가 비어 있으면 환영 화면을, 하나라도 있으면 메시지 목록을 그린다. JSX 안에서는 if문을 쓸 수 없어서 이 형태가 흔하다.
{messages.map((message, index) => <div className="message-pair" key={`${message}-${index}`}> ... </div>)}
key가 왜 필요한가
React가 목록을 다시 그릴 때 어느 항목이 그대로이고 어느 것이 새 것인지 구분하는 표식이다. 없으면 경고가 뜨고, 항목이 중간에 삽입·삭제될 때 엉뚱한 DOM이 재사용된다.
`${message}-${index}`의 한계
내용과 순번을 이어 붙여 만든 임시 열쇠다. 지금은 메시지가 뒤에만 추가되고 삭제·정렬이 없어서 문제가 없다. 하지만 대화 저장·삭제가 생기는 7단계에서는 서버가 준 id를 써야 한다. index 기반 열쇠는 항목이 사라질 때 잘못된 재사용을 일으킨다.
&&를 쓴 조건부 표시
{sidebarOpen && <button ... />}는 “참일 때만 그린다”는 뜻이다. 거짓이면 false가 되고 React는 false를 아무것도 아닌 것으로 취급한다.
실제 값으로 따라가기
① 타이핑 onChange → setPrompt('안녕')
state: prompt='안녕' messages=[]
화면: 전송 버튼이 활성화(disabled={!prompt.trim()})
② Enter handleKeyDown → preventDefault() → send()
text = '안녕' (공백 제거 후 비어 있지 않음)
setMessages(c => [...c, '안녕'])
setPrompt('')
③ 다시 그리기 state: prompt='' messages=['안녕']
화면: 환영 화면 → 메시지 목록으로 전환
사용자 말풍선 '안녕'
Gemma 자리에 고정 문구
입력창이 비고 전송 버튼이 다시 비활성화
④ 새로운 채팅 setMessages([]) setPrompt('') setSidebarOpen(false)
state: 처음 상태로 복귀 → 환영 화면
③에서 모델은 전혀 관여하지 않는다. 화면이 바뀐 것은 오직 messages 배열의 길이가 0에서 1이 되었기 때문이다.
관찰하는 방법
cd fe
npm install
npm run dev # http://localhost:5173
| 확인할 것 | 방법 | 기대 결과 |
|---|---|---|
| 네트워크 요청이 없다 | 개발자 도구 Network 탭을 열고 전송 | XHR/fetch 항목이 하나도 늘지 않는다 |
| state가 바뀐다 | React DevTools에서 App 선택 | prompt·messages가 실시간으로 변한다 |
| 빈 입력 거절 | 공백만 넣고 Enter | 아무 일도 일어나지 않는다 |
| Shift+Enter | 입력창에서 Shift+Enter | 전송되지 않고 줄이 늘어난다 |
| type 검사 | npm run build | tsc -b가 먼저 돌고 통과해야 build가 진행된다 |
Network 탭이 비어 있다는 사실이 이 단계의 가장 중요한 관찰이다. 화면이 그럴듯하게 동작하는 것과 서버에 연결된 것은 전혀 다른 일이다.
자주 발생하는 오류
| 증상 | 원인 | 진단 |
|---|---|---|
| Enter를 누르면 페이지가 새로고침 | submit에서 preventDefault() 누락 | form의 기본 동작이다. state가 전부 사라진다 |
| 메시지를 추가해도 화면이 그대로 | messages.push(...)로 원본을 변경 | 새 배열을 만들어야 React가 변화를 인식한다 |
Each child should have a unique key | map에 key 누락 | 항목마다 안정적인 열쇠가 필요하다 |
Type 'string' is not assignable to 'never' | useState([])에 type 미지정 | useState<string[]>([])로 고친다 |
| 아이콘이 안 보임 | IconName에 없는 이름 사용 | build 단계에서 type 오류로 잡힌다 |
| Enter가 두 줄을 만듦 | handleKeyDown에서 preventDefault() 누락 | 전송과 줄바꿈이 동시에 일어난다 |
설계 선택과 대안
왜 messages가 string[]인가?
지금은 사용자 문장만 저장하므로 이것으로 충분하다. 하지만 실제 대화는 { role, content } 형태여야 한다. 모델 답변, 생성 중 표시, 오류 메시지, 재생성이 모두 항목의 종류를 필요로 하기 때문이다. 4단계에서 이 type을 바꾸는 것이 첫 작업이 된다. 지금 미리 바꾸지 않은 이유는, 실제 API 응답 형태를 보기 전에 정하면 두 번 고치게 되기 때문이다.
왜 상태 관리 library를 쓰지 않는가?
state가 셋이고 모두 한 컴포넌트 안에서만 쓰인다. Redux나 Zustand는 여러 컴포넌트가 같은 state를 공유할 때 값을 한다. 인증과 대화 목록이 생기는 8~9단계에서 다시 판단할 문제다.
한 파일 70줄에 다 넣은 것은 괜찮은가?
지금은 읽기 쉬움이 더 크다. 다만 Icon·Sidebar·Composer·MessageList로 나눌 자연스러운 경계가 이미 보인다. streaming이 들어오면 MessageList만 자주 다시 그려야 하므로, 그때 분리하면 성능 이유까지 생긴다.
JSX를 한 줄에 몰아 쓴 스타일
세로 길이를 줄여 전체 구조를 한눈에 보게 한다. 대신 가로 스크롤이 생기고 diff에서 변경 지점을 찾기 어렵다. 여러 사람이 편집하기 시작하면 줄을 나누는 편이 낫다.
이전 단계와 다음 파일
이 컴포넌트를 화면에 붙이는 코드는 main.tsx, TSX를 브라우저용 JavaScript로 바꾸는 설정은 vite.config.ts에 있다. 여기서 비어 있는 “Gemma 답변” 자리를 실제로 채우려면 Backend의 채팅 route와 streaming이 필요하다. 그 요청을 받을 Adapter는 turbofieldfare.py에 이미 준비돼 있다.