← Start here · Module 2B · chat-app

Module 2B · Project — chat-app

Projects/Module_02_chat-app/ 폴더(React + Flask + Claude)를 읽고 고치기 위한 기초 개념들. 1편과 같은 방식: ① 한 줄 요약 → ② 비유 → ③ 이 앱에서는 → ④ (펼치면) 더 깊이 → ⑤ 미니 퀴즈.

🎯 이 편의 목표 = "결함 5가지 찾기" 연습 풀기. chat-app은 동작은 하지만, 진짜 채팅앱이라면 꼭 필요한 것이 최소 5가지 빠져 있습니다. 봇이 이름을 기억 못 하고, 답을 기다리는 동안 화면이 멈춘 듯 보이고, 백엔드가 꺼지면 조용히 깨집니다. 아래 개념들이 그 답의 재료예요.
📚 ← Start here · 2A편이 궁금하면 → 기초편 (hello-world) · 커밋 이력은 변경 이력에서.
📊 이 편이 따라가는 슬라이드: Foundations 슬라이드 16–30 — What is an LLM(17) · First LLM app(23–27) · Critique(27) · Streaming(29–30).

2B.1 · The big picture — now three servers

1편의 "프론트 + 백" 구조는 그대로인데, 이제 백엔드가 바깥의 LLM API(Claude)에게 한 번 더 물어봅니다.

🍜 식당 비유 확장: 손님(브라우저)이 주문하면 홀(React)이 주방(Flask)에 전달하는 것까지는 똑같아요. 그런데 이 주방은 직접 요리하지 않고, 바깥의 전문 셰프(Claude API)에게 전화로 주문을 넘깁니다. 셰프가 만든 걸 받아 다시 손님에게 가져다줘요. 전화 상대가 늦거나, 안 받거나, "오늘 영업 안 함"이라 하면? 그게 이 편에서 다룰 문제들입니다.
flowchart LR B["Browser
(React UI)"] F["Backend
Flask :5001"] C["Anthropic API
(Claude)"] B -->|"POST /api/chat
{ message }"| F F -->|"messages.create(...)"| C C -->|"reply text"| F F -->|"{ reply }"| B

ft_transcendence에서 SPA + 백엔드 + 외부 서비스(OAuth 등)를 엮었던 경험과 같은 모양이에요. 새로 끼어든 셋째 참가자(LLM)가 느리고, 가끔 실패하고, 돈이 든다는 점만 기억하면 됩니다.

chat-app이 1편 hello-world와 가장 크게 다른 점은?

핵심 변화는 외부 API 의존입니다. 그래서 "느림(로딩)", "실패(에러 처리)" 같은 새 고민이 생겨요. DB는 아직 없습니다(그래서 새로고침하면 대화가 날아가죠 — 이것도 빠진 것 중 하나).

2B.2 · Memory — why the bot forgets your name (stateless)

LLM API는 이전 대화를 기억하지 않습니다. 매 요청마다 지난 대화 전부를 다시 보내줘야 "기억하는 것처럼" 보입니다.

🧠 기억상실 상담사 비유: Claude는 매번 처음 만나는, 기억을 못 하는 천재 상담사예요. "제 이름은 Alex예요"라고 말한 뒤 "제 이름이 뭐죠?"라고 물으면, 두 번째 질문만 받은 상담사는 알 길이 없어요. 기억하게 하려면 이전 대화 메모를 통째로 같이 건네줘야 합니다.

지금 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?"},   # 이제 위 맥락을 본다
]
더 깊이 (선택) — "stateless"는 1편에서 본 그 단어

1편에서 HTTP가 stateless(요청끼리 서로 모름)라고 했죠. LLM API도 똑같은 철학이에요. 서버가 대화 상태를 들고 있지 않으니, 상태를 들고 다닐 책임은 우리(앱)에게 있습니다. 보통은 프론트가 메시지 목록을 들고 있다가(이미 useStatemessages가 있죠!) 매번 백엔드로 함께 보내고, 백엔드는 그걸 그대로 messages에 넘깁니다. 대화가 길어지면 토큰(=비용·길이)이 계속 커진다는 트레이드오프가 생겨요.

"내 이름이 뭐죠?"에 봇이 답하게 하려면?

기억은 모델 똑똑함의 문제가 아니라 우리가 무엇을 보내느냐의 문제예요. API는 stateless라서, 과거를 보내지 않으면 아무리 좋은 모델도 모릅니다.

2B.3 · Async & loading — what does the UI do while it waits?

LLM 답변은 몇 초 걸립니다. 그 사이 화면이 "멈춘 듯" 보이지 않게 하려면 로딩 상태를 직접 만들어야 합니다.

주문서 비유: 주방에 주문을 넣고 음식이 나오기까지 시간이 걸리죠. 좋은 홀 직원은 "준비 중입니다 🍳"라고 알려줘요. 지금 앱은 아무 말 없이 가만히 있어서, 손님은 주문이 들어갔는지조차 알 수 없습니다.

지금 App.jsxsend는 이렇게 끝납니다:

const res = await fetch('/api/chat', { ... })  // ← 여기서 몇 초 멈춤
const data = await res.json()
setMessages((m) => [...m, { role: 'assistant', text: data.reply }])

await는 "답이 올 때까지 기다린다"는 뜻이에요. 그 몇 초 동안 로딩 표시도, 입력칸 잠금도 없습니다. 사용자는 버튼을 또 누르거나 "고장 났나?" 싶어집니다. 보통 isLoading 같은 상태를 두고, 요청 직전 true, 응답 후 false로 바꿔 "..." 표시와 버튼 비활성화를 제어합니다.

더 깊이 (선택) — 동기 vs 비동기

fetch비동기(async)인 이유: 네트워크는 느리니까, 답을 기다리는 동안 브라우저가 멈추면 안 되거든요. 그래서 await로 "기다리되 화면은 살아있게" 합니다. 화면이 안 멈추는 건 좋은데, 기다리는 중이라는 사실을 사용자에게 보여주는 일은 우리가 따로 해줘야 해요. 이게 로딩 상태(loading state)예요.

긴 질문을 보내 답이 몇 초 걸릴 때, 지금 UI는?

지금 App.jsxisLoading 같은 상태가 없어요. 그래서 await가 끝날 때까지 화면은 "정지처럼" 보입니다. 로딩 UX는 코드로 직접 만들어 넣어야 하는 부분이에요.

2B.4 · Network failure — what if the backend is down?

백엔드가 죽어 있으면 fetch거부(reject)되고, 지금 코드엔 그걸 잡는 장치가 없어 조용히 깨집니다.

☎️ 안 받는 전화 비유: 홀 직원이 주방에 전화했는데 아무도 안 받아요(백엔드 다운). 좋은 직원이라면 "주방에 연결이 안 돼요, 잠시 후 다시 시도해 주세요"라고 손님에게 말하겠죠. 지금 앱은 수화기를 든 채 멈춰 버립니다 — 사용자 메시지는 화면에 떴는데, 봇 답은 영영 안 오고요.
const res = await fetch('/api/chat', { ... })  // 백엔드 down → 여기서 throw!
const data = await res.json()                  // ← 도달조차 못 함

fetch가 실패하면 예외가 던져지는데, send 함수엔 try/catch가 없습니다. 그래서 에러는 콘솔에만 찍히고 화면엔 아무 안내가 없어요. 고치는 자리는 App.jsxsend 함수입니다 — try/catch로 감싸고, 실패 시 messages에 "⚠️ 연결 실패" 같은 메시지를 넣어 주면 됩니다.

더 깊이 (선택) — 두 종류의 "실패"

실패는 둘로 나뉘어요. ① 아예 못 닿음(백엔드 다운, 인터넷 끊김) → fetch 자체가 reject → try/catch로 잡음. ② 닿았지만 에러 응답(서버가 500을 돌려줌) → fetch성공으로 친다!res.ok를 직접 확인해야 함. 둘 다 처리해야 진짜 견고한 앱이에요. (②의 원인은 다음 섹션에서.)

백엔드를 끄고(Ctrl+C) 메시지를 보내면?

거부된 fetchtry/catch로 잡지 않으면 처리되지 않은 예외가 되고, UI엔 안내가 없습니다. 고칠 자리는 App.jsxsend 함수예요.

2B.5 · API errors — 5xx · rate limit · bad key

Claude API가 5xx를 주거나, 너무 자주 호출해 막히거나(429), 키가 틀리면(401) messages.create()예외를 던집니다. 지금 app.py엔 이를 잡는 try/except가 없습니다.

📞 전문 셰프에게 전화 비유: 바깥 셰프에게 주문을 넘기는데 — 셰프가 "지금 너무 바빠요"(429 레이트리밋), "주방에 불남"(5xx 서버 에러), "당신 누구세요? 거래처 아닌데요"(401 잘못된 키)라고 답할 수 있어요. 지금 주방(Flask)은 이런 답을 못 알아듣고 그대로 쓰러집니다.

예외를 안 잡으면 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
더 깊이 (선택) — SDK가 자동 재시도까지 해 준다

anthropic SDK는 429와 5xx를 기본 2번까지 자동 재시도(지수 백오프)해요. 그래도 계속 실패하면 위 예외가 최종적으로 던져집니다. 그러니 우리가 할 일은 "재시도를 직접 짜는 것"이 아니라, 최종 실패를 사용자에게 친절히 알리는 것이에요. 또 하나: 예외를 잡을 땐 구체적인 타입부터 넓은 타입 순서로 잡아야 해요 (위에서 APIStatusError가 맨 뒤인 이유 — 가장 넓은 부모라서).

Claude API가 레이트리밋(429)을 돌려줄 때 지금 app.py는?

예외 처리가 없으니 어떤 API 실패든 똑같이 "500 폭발"로 끝나요. 타입별 except로 잡아 적절한 상태코드/메시지를 돌려주는 게 고칠 자리(Projects/Module_02_chat-app/backend/app.py)입니다.

2B.6 · Input validation & other gaps

request.json["message"]는 요청 모양이 조금만 어긋나도 터집니다. 그 외에도 영속성·스트리밍 등 "진짜 앱"의 조각들이 빠져 있어요.

🚪 문지기 없는 입구 비유: 주방이 "주문서엔 항상 message 칸이 있겠지"라고 믿고 바로 꺼내 씁니다. 빈 주문서나 엉뚱한 양식이 오면 그 자리에서 넘어져요. 입구에 문지기(검증)를 둬야 합니다.
user_message = request.json["message"]  # 키가 없으면 KeyError → 500

대표적으로 더 빠진 것들:

더 깊이 (선택) — 왜 "일부러" 빼놨을까

chat-app은 가장 작은, 동작하는 뼈대예요. 빠진 것들은 버그가 아니라 다음 주차에 하나씩 배울 주제입니다. 각 결함이 어느 파일·어느 줄의 문제인지 짚는 연습 자체가 "프로덕션 앱은 무엇으로 이루어지나"를 몸으로 익히는 과정이에요. 1편의 git init 권장과 이어집니다 — 고칠 때마다 커밋해서 변화를 눈으로 보세요.

{}/api/chat에 보내면 지금은?

대괄호 접근 ["message"]은 키가 없으면 바로 예외예요. request.json.get("message")로 받고 비었는지 검사해 400을 돌려주는 게 "문지기"를 두는 방법입니다.

2B.7 · Find the 5 bugs (read the code)

이제 개념을 무기로, 실제 연습 문제에 답해 봅니다. 먼저 스스로 답을 떠올린 뒤 펼쳐서 맞춰 보세요. 1번은 당신이 직접 채울 차례입니다.

Q1. "My name is Alex." → "What is my name?" — 봇이 기억할까? 왜? 어느 파일을 어떻게 고칠까?

✍️ 여기에 당신의 답을 적어 주세요 (TODO(human)).

Q2. 답이 몇 초 걸리는 긴 질문을 보내면 UI는 무엇을 하나? 어디서 제어되나(혹은 안 되나)?

정답 보기

아무것도 안 합니다 — 로딩 표시도, 입력 잠금도 없어요. App.jsxsend 함수가 await fetch 동안 멈춰 있는데, 그 상태를 나타낼 isLoading 같은 값이 아예 없습니다. 제어가 "안 되는" 거예요. 고치려면 useState로 로딩 상태를 만들고, 요청 전후로 토글해 "..."와 버튼 비활성화를 그립니다.

Q3. 백엔드를 끄고(Ctrl+C) 메시지를 보내면? App.jsx 어디서 처리해야 하나?

정답 보기

fetch가 거부(reject)되어 예외가 던져지는데, sendtry/catch가 없어 화면엔 안내가 없습니다(내 메시지만 뜨고 봇 답 없음). 처리 자리는 App.jsxsend 함수 — try/catch로 감싸고 실패 시 messages에 에러 메시지를 넣습니다. 추가로 res.ok도 확인해 "닿았지만 에러 응답"까지 잡으면 완성.

Q4. 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 에러 메시지를 돌려줍니다. 구체적 타입 → 넓은 타입 순서로.

Q5. (보너스) 위 4개 말고 "진짜 앱"에 더 빠진 것 하나를 코드에서 짚어 보세요.

힌트 / 예시 보기

예: request.json["message"]의 입력 검증 부재(빈/누락 시 500), 새로고침하면 사라지는 대화(영속성/DB 없음), 한 번에 툭 나오는 답(스트리밍 없음), 누구나 호출 가능한 비용 위험(인증·레이트리밋 없음). 하나를 골라 "어느 파일의 무엇이 문제인지"까지 말할 수 있으면 충분합니다.