서비스 학습
SOURCE · fe/src/App.tsx · 2026-09-06

아직 서버에 말을 걸지 않는
채팅 화면

사이드바·입력창·메시지 목록이 모두 동작한다. 그런데 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('') }
statetype의미
sidebarOpenboolean좁은 화면에서 사이드바가 열려 있는가
promptstring입력창에 지금 적힌 글자
messagesstring[]사용자가 보낸 문장만. 역할·시간·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 buildtsc -b가 먼저 돌고 통과해야 build가 진행된다

Network 탭이 비어 있다는 사실이 이 단계의 가장 중요한 관찰이다. 화면이 그럴듯하게 동작하는 것과 서버에 연결된 것은 전혀 다른 일이다.

자주 발생하는 오류

증상원인진단
Enter를 누르면 페이지가 새로고침submit에서 preventDefault() 누락form의 기본 동작이다. state가 전부 사라진다
메시지를 추가해도 화면이 그대로messages.push(...)로 원본을 변경새 배열을 만들어야 React가 변화를 인식한다
Each child should have a unique keymapkey 누락항목마다 안정적인 열쇠가 필요하다
Type 'string' is not assignable to 'never'useState([])에 type 미지정useState<string[]>([])로 고친다
아이콘이 안 보임IconName에 없는 이름 사용build 단계에서 type 오류로 잡힌다
Enter가 두 줄을 만듦handleKeyDown에서 preventDefault() 누락전송과 줄바꿈이 동시에 일어난다

설계 선택과 대안

messagesstring[]인가?

지금은 사용자 문장만 저장하므로 이것으로 충분하다. 하지만 실제 대화는 { 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에 이미 준비돼 있다.

← 이전React 진입점다음 →전역 토큰과 기본값