← Start here · Module 2B · chat-app
Projects/Module_02_chat-app/ 폴더(React + Flask + Claude)를 읽고 고치기 위한
기초 개념들. 1편과 같은 방식: ① 한 줄 요약 → ② 비유 → ③ 이 앱에서는 →
④ (펼치면) 더 깊이 → ⑤ 미니 퀴즈.
1편의 "프론트 + 백" 구조는 그대로인데, 이제 백엔드가 바깥의 LLM API(Claude)에게 한 번 더 물어봅니다.
ft_transcendence에서 SPA + 백엔드 + 외부 서비스(OAuth 등)를 엮었던 경험과 같은 모양이에요. 새로 끼어든 셋째 참가자(LLM)가 느리고, 가끔 실패하고, 돈이 든다는 점만 기억하면 됩니다.
chat-app이 1편 hello-world와 가장 크게 다른 점은?
핵심 변화는 외부 API 의존입니다. 그래서 "느림(로딩)", "실패(에러 처리)" 같은 새 고민이 생겨요. DB는 아직 없습니다(그래서 새로고침하면 대화가 날아가죠 — 이것도 빠진 것 중 하나).
LLM API는 이전 대화를 기억하지 않습니다. 매 요청마다 지난 대화 전부를 다시 보내줘야 "기억하는 것처럼" 보입니다.
지금 Projects/Module_02_chat-app/backend/app.py는 현재 메시지 한 개만 보냅니다:
resp = client.messages.create(
model="claude-sonnet-4-6",
messages=[{"role": "user", "content": user_message}], # ← 딱 한 줄, 과거 없음
)
그래서 "My name is Alex." → "What is my name?" 순서로 보내면, 두 번째 요청에는
첫 문장이 들어 있지 않아 봇이 모릅니다. 고치려면
messages 배열에 대화 전체를 쌓아 보내야 해요:
messages = [
{"role": "user", "content": "My name is Alex."},
{"role": "assistant", "content": "Nice to meet you, Alex!"},
{"role": "user", "content": "What is my name?"}, # 이제 위 맥락을 본다
]
1편에서 HTTP가 stateless(요청끼리 서로 모름)라고 했죠. LLM API도 똑같은
철학이에요. 서버가 대화 상태를 들고 있지 않으니, 상태를 들고 다닐
책임은 우리(앱)에게 있습니다. 보통은 프론트가 메시지 목록을 들고
있다가(이미 useState의 messages가 있죠!) 매번
백엔드로 함께 보내고, 백엔드는 그걸 그대로 messages에 넘깁니다.
대화가 길어지면 토큰(=비용·길이)이 계속 커진다는 트레이드오프가 생겨요.
"내 이름이 뭐죠?"에 봇이 답하게 하려면?
기억은 모델 똑똑함의 문제가 아니라 우리가 무엇을 보내느냐의 문제예요. API는 stateless라서, 과거를 보내지 않으면 아무리 좋은 모델도 모릅니다.
LLM 답변은 몇 초 걸립니다. 그 사이 화면이 "멈춘 듯" 보이지 않게 하려면 로딩 상태를 직접 만들어야 합니다.
지금 App.jsx의 send는 이렇게 끝납니다:
const res = await fetch('/api/chat', { ... }) // ← 여기서 몇 초 멈춤
const data = await res.json()
setMessages((m) => [...m, { role: 'assistant', text: data.reply }])
await는 "답이 올 때까지 기다린다"는 뜻이에요. 그 몇 초 동안
로딩 표시도, 입력칸 잠금도 없습니다. 사용자는 버튼을 또 누르거나
"고장 났나?" 싶어집니다. 보통 isLoading 같은 상태를 두고,
요청 직전 true, 응답 후 false로 바꿔 "..." 표시와
버튼 비활성화를 제어합니다.
fetch가 비동기(async)인 이유: 네트워크는
느리니까, 답을 기다리는 동안 브라우저가 멈추면 안 되거든요. 그래서
await로 "기다리되 화면은 살아있게" 합니다. 화면이 안 멈추는 건
좋은데, 기다리는 중이라는 사실을 사용자에게 보여주는 일은 우리가
따로 해줘야 해요. 이게 로딩 상태(loading state)예요.
긴 질문을 보내 답이 몇 초 걸릴 때, 지금 UI는?
지금 App.jsx엔 isLoading 같은 상태가 없어요.
그래서 await가 끝날 때까지 화면은 "정지처럼" 보입니다.
로딩 UX는 코드로 직접 만들어 넣어야 하는 부분이에요.
백엔드가 죽어 있으면 fetch가 거부(reject)되고,
지금 코드엔 그걸 잡는 장치가 없어 조용히 깨집니다.
const res = await fetch('/api/chat', { ... }) // 백엔드 down → 여기서 throw!
const data = await res.json() // ← 도달조차 못 함
fetch가 실패하면 예외가 던져지는데, send 함수엔
try/catch가 없습니다. 그래서 에러는 콘솔에만 찍히고 화면엔
아무 안내가 없어요. 고치는 자리는 App.jsx의
send 함수입니다 — try/catch로 감싸고,
실패 시 messages에 "⚠️ 연결 실패" 같은 메시지를 넣어 주면 됩니다.
실패는 둘로 나뉘어요. ① 아예 못 닿음(백엔드 다운, 인터넷
끊김) → fetch 자체가 reject → try/catch로 잡음.
② 닿았지만 에러 응답(서버가 500을 돌려줌) → fetch는
성공으로 친다! → res.ok를 직접 확인해야 함. 둘 다
처리해야 진짜 견고한 앱이에요. (②의 원인은 다음 섹션에서.)
백엔드를 끄고(Ctrl+C) 메시지를 보내면?
거부된 fetch를 try/catch로 잡지 않으면 처리되지
않은 예외가 되고, UI엔 안내가 없습니다. 고칠 자리는 App.jsx의
send 함수예요.
Claude API가 5xx를 주거나, 너무 자주 호출해 막히거나(429), 키가 틀리면(401)
messages.create()가 예외를 던집니다.
지금 app.py엔 이를 잡는 try/except가 없습니다.
예외를 안 잡으면 Flask가 500 + HTML 에러 페이지를 토해내고, 프론트의
res.json()도 깨질 수 있어요. anthropic SDK는
상황별로 타입이 다른 예외를 주니, 골라서 잡으면 됩니다:
import anthropic
try:
resp = client.messages.create(...)
except anthropic.RateLimitError: # 429: 너무 자주 호출
return jsonify(error="잠시 후 다시 시도해 주세요"), 429
except anthropic.AuthenticationError: # 401: 키가 틀림
return jsonify(error="API 키 설정을 확인하세요"), 500
except anthropic.APIStatusError as e: # 500/529 등 서버 쪽 문제
return jsonify(error="LLM 서버 오류, 잠시 후 재시도"), 502
anthropic SDK는 429와 5xx를 기본 2번까지 자동
재시도(지수 백오프)해요. 그래도 계속 실패하면 위 예외가 최종적으로
던져집니다. 그러니 우리가 할 일은 "재시도를 직접 짜는 것"이 아니라,
최종 실패를 사용자에게 친절히 알리는 것이에요. 또 하나: 예외를
잡을 땐 구체적인 타입부터 넓은 타입 순서로 잡아야 해요
(위에서 APIStatusError가 맨 뒤인 이유 — 가장 넓은 부모라서).
Claude API가 레이트리밋(429)을 돌려줄 때 지금 app.py는?
예외 처리가 없으니 어떤 API 실패든 똑같이 "500 폭발"로 끝나요.
타입별 except로 잡아 적절한 상태코드/메시지를 돌려주는 게
고칠 자리(Projects/Module_02_chat-app/backend/app.py)입니다.
request.json["message"]는 요청 모양이 조금만 어긋나도
터집니다. 그 외에도 영속성·스트리밍 등 "진짜 앱"의 조각들이
빠져 있어요.
user_message = request.json["message"] # 키가 없으면 KeyError → 500
대표적으로 더 빠진 것들:
message 키 누락, JSON 아님 → 친절한 400으로 막기
chat-app은 가장 작은, 동작하는 뼈대예요. 빠진 것들은
버그가 아니라 다음 주차에 하나씩 배울 주제입니다. 각 결함이 어느
파일·어느 줄의 문제인지 짚는 연습 자체가 "프로덕션 앱은 무엇으로 이루어지나"를
몸으로 익히는 과정이에요. 1편의 git init 권장과 이어집니다 —
고칠 때마다 커밋해서 변화를 눈으로 보세요.
빈 {} 를 /api/chat에 보내면 지금은?
대괄호 접근 ["message"]은 키가 없으면 바로 예외예요.
request.json.get("message")로 받고 비었는지 검사해 400을
돌려주는 게 "문지기"를 두는 방법입니다.
이제 개념을 무기로, 실제 연습 문제에 답해 봅니다. 먼저 스스로 답을 떠올린 뒤 펼쳐서 맞춰 보세요. 1번은 당신이 직접 채울 차례입니다.
✍️ 여기에 당신의 답을 적어 주세요 (TODO(human)).
아무것도 안 합니다 — 로딩 표시도, 입력 잠금도 없어요. App.jsx의
send 함수가 await fetch 동안 멈춰 있는데,
그 상태를 나타낼 isLoading 같은 값이 아예 없습니다.
제어가 "안 되는" 거예요. 고치려면 useState로 로딩 상태를 만들고,
요청 전후로 토글해 "..."와 버튼 비활성화를 그립니다.
App.jsx 어디서 처리해야 하나?
fetch가 거부(reject)되어 예외가 던져지는데, send에
try/catch가 없어 화면엔 안내가 없습니다(내 메시지만 뜨고 봇 답
없음). 처리 자리는 App.jsx의 send 함수 —
try/catch로 감싸고 실패 시 messages에 에러 메시지를
넣습니다. 추가로 res.ok도 확인해 "닿았지만 에러 응답"까지 잡으면 완성.
Projects/Module_02_chat-app/backend/app.py에서 Claude가 5xx · 레이트리밋 · 잘못된 키일 때 무슨 일이?
messages.create()가 예외를 던지는데(RateLimitError·
AuthenticationError·APIStatusError),
try/except가 없어 Flask가 500 + HTML 에러로 쓰러집니다.
(SDK가 429/5xx는 기본 2회 자동 재시도하지만, 그 뒤엔 결국 예외.) 고칠 자리는
app.py — 타입별로 except를 잡아 적절한 상태코드와
JSON 에러 메시지를 돌려줍니다. 구체적 타입 → 넓은 타입 순서로.
예: request.json["message"]의 입력 검증 부재(빈/누락 시 500),
새로고침하면 사라지는 대화(영속성/DB 없음), 한 번에 툭 나오는 답(스트리밍 없음),
누구나 호출 가능한 비용 위험(인증·레이트리밋 없음). 하나를 골라 "어느 파일의
무엇이 문제인지"까지 말할 수 있으면 충분합니다.