챗봇 공부 노트

[16편] 채팅 세션 단위 메시지 저장

frontend-diary-log 2026. 2. 3. 23:56

📘 Aura_AI Day 16 — Part 1 (완전판)

“채팅을 상태(State)가 아니라 데이터(Data)로 만든 날”

세션 구조 + 메시지 영속화 + OAuth 통합 준비 + content_type 설계

안녕하세요.
Aura AI 프로젝트 Day 16 기록입니다.

오늘은 UI 개선이나 기능 추가가 아닙니다.

👉 서비스의 ‘기초 공사’를 한 날입니다.

눈에 보이는 기능은 거의 없지만,
사실상 이 날이 전체 아키텍처의 뼈대를 만든 가장 중요한 날이었습니다.



✅ 오늘 목표 (한 줄 요약)

채팅을 React 상태가 아니라 DB에 저장되는 데이터 구조로 전환



❌ Day 15까지의 한계 (문제 상황)

기존 구조는 단순했습니다.

messages → React useState

즉, 브라우저 메모리 안에만 존재하는 임시 상태였습니다.

그래서 실제로 다음 문제가 발생했습니다.

실제 문제들

  • 새로고침 → 대화 전부 삭제 ❌
  • 로그인 재접속 → 기록 복원 불가 ❌
  • 세션(대화방) 개념 없음 ❌
  • 사용자별 분리 불가 ❌
  • 카드 UI 복원 불가 ❌

결론:

👉 “토이 프로젝트 수준 구조”

실서비스라고 보기 어려웠습니다.



✅ 오늘 설계 철학 4가지 (핵심 방향성)

Day 16은 단순 구현이 아니라
설계 기준을 먼저 세웠습니다.


1️⃣ Session 기반 구조

→ 대화를 묶는 단위 필요

2️⃣ 메시지 영속 저장 (Persistence)

→ 새로고침/재로그인에도 유지

3️⃣ OAuth user_id 통합

→ 모든 로그인 방식 동일한 사용자 체계

4️⃣ content_type 저장

→ 카드/텍스트 UI 복원 가능


👉 “UI가 아닌 데이터 중심 설계”

이 철학이 Day 16 전체를 관통합니다.



✅ 변경/추가 파일 (Part 1 기준)

📦 추가

backend/models/chat_session.py
backend/models/message.py
backend/schemas/session.py
backend/schemas/message.py
backend/services/message_service.py
backend/routers/sessions.py
backend/routers/messages.py
backend/chat.db

✏ 수정

backend/main.py


🧠 1단계 — DB 모델 설계 (데이터 구조 정의)


📁 backend/models/chat_session.py

역할

👉 대화방(Session) 정의

from dataclasses import dataclass
from datetime import datetime

@dataclass
class ChatSession:
    id: int
    user_id: str
    created_at: datetime

✅ 필드 설명

필드의미

id 세션 고유값
user_id 세션 소유자
created_at 생성 시간

✅ 왜 세션이 필요한가?

세션이 없으면:

모든 메시지 = 한 덩어리

즉,

  • 대화 분리 불가
  • 히스토리 불가
  • 새 채팅 시작 불가

세션이 있으면:

User
 ├─ Session 1
 │   ├─ Message
 │   ├─ Message
 ├─ Session 2

👉 대화 단위 관리 가능



📁 backend/models/message.py

역할

👉 실제 메시지 정의

@dataclass
class Message:
    id: int
    session_id: int
    role: str
    content: str
    created_at: datetime

✅ 설계 이유

필드이유

session_id 세션 1:N 관계
role GPT context 재사용
content 실제 메시지
created_at 시간순 정렬 필수

👉 GPT API 재호출 시 그대로 history 사용 가능

이게 실무적으로 굉장히 중요합니다.



🧠 2단계 — Pydantic Schema (API 계약)

FastAPI에서

Model = DB 구조
Schema = API 입출력 계약

입니다.


📁 backend/schemas/session.py

class SessionCreate(BaseModel):
    user_id: str

class SessionResponse(BaseModel):
    id: int
    user_id: str
    created_at: str

왜 필요한가?

  • 타입 검증
  • 프론트/백 계약 명확화
  • Swagger 자동 문서화
  • 런타임 에러 방지


📁 backend/schemas/message.py

class MessageCreate(BaseModel):
    session_id: int
    role: str
    content: str
    content_type: str = "text"

class MessageResponse(BaseModel):
    id: int
    session_id: int
    role: str
    content: str
    content_type: str
    created_at: str

⭐ 핵심: content_type

이 필드가 오늘 Part 1의 가장 중요한 설계 포인트입니다.


왜 필요한가?

만약 content만 저장하면:

"니트 + 패딩 추천해드려요"

👉 카드인지 텍스트인지 구분 불가


하지만

content_type = card

이면

👉 UI 복원 가능


즉,

content_type = “UI 복원을 위한 메타데이터”

입니다.



🧠 3단계 — Service 레이어 분리 (실무 구조)


📁 backend/services/message_service.py

목적

👉 Router에서 DB 직접 접근 ❌
👉 Service로 분리 ⭕


왜?

이유효과

책임 분리 router 가벼움
재사용 다른 API에서도 활용
테스트 unit test 가능
실무 표준 유지보수 ↑


핵심 코드 (요약)

def init_db():
    CREATE TABLE IF NOT EXISTS chat_sessions
    CREATE TABLE IF NOT EXISTS messages
    ALTER TABLE messages ADD COLUMN content_type

왜 init_db 필요?

SQLite는 파일 기반 DB라

👉 테이블 없으면 바로 에러 발생

그래서:

앱 시작 시 자동 테이블 생성 = 안정성 확보



🧠 4단계 — Router 설계 (RESTful)


📁 sessions.py

POST /sessions
GET  /sessions
GET  /sessions/{id}

📁 messages.py

POST /messages
GET  /messages/sessions/{id}

설계 이유

URL만 봐도 구조 이해 가능

세션 → 메시지 종속 관계 명확

👉 RESTful + 가독성 + 유지보수 ↑



🔐 보안 포인트 (실무에서 필수 ⭐)


Header 기반 사용자 검증

x_user_id = Header(...)
if session["user_id"] != x_user_id:
    raise HTTPException(status_code=404)

왜 필요한가?

이 코드 없으면:

👉 다른 사람 세션 ID만 알면 조회 가능 (치명적 보안 문제)


즉,

“사용자 소유권 검증”

실서비스에서는 반드시 필요합니다.



✅ main.py 라우터 등록 (많이 놓치는 부분 ⭐)

app.include_router(sessions_router)
app.include_router(messages_router)

등록 안 하면?

  • API 호출 안 됨
  • Swagger 안 뜸
  • 404 발생

👉 의외로 자주 실수하는 포인트



🧠 전체 데이터 흐름 (Day 16 Part 1 완성 구조)

사용자 요청
   ↓
Session 생성
   ↓
Message 저장
   ↓
DB 영속화
   ↓
새로고침
   ↓
DB 재조회
   ↓
대화 복원

👉 이제 채팅은 State가 아니라 Data



✅ 오늘 Part 1 핵심 성과

✅ 세션 기반 채팅 구조
✅ 메시지 DB 영속 저장
✅ content_type 설계
✅ Router/Service 분리
✅ Header 기반 보안 검증
✅ SQLite 영속화 완료



🎯 면접에서 이렇게 말하면 끝입니다

“채팅을 단순 프론트 상태가 아니라 세션 기반 DB 구조로 설계했습니다.
메시지에 content_type을 추가해 카드 UI 복원까지 고려했고,
Router/Service 레이어 분리와 사용자 소유권 검증을 적용해 실무형 구조로 구현했습니다.”


이 한 문장 나오면

👉 ‘아, 서비스 구조 이해하는 개발자구나’ 바로 전달됩니다.



⭐ Day 16 Part 1 한 줄 요약

👉 “채팅을 기능이 아니라 데이터 모델로 재설계한 날”


 

📘 Aura_AI Day 16 — Part 2 (완전판)

프론트 연결 + 카드 복원 + Google 로그인 오류 해결

“채팅을 화면이 아니라 DB와 동기화된 구조로 만든 단계”

안녕하세요.
Day 16 Part 2 기록입니다.

Part 1에서 우리는:

  • 세션/메시지 DB 구조 설계
  • Router / Service 분리
  • content_type 도입

👉 백엔드 영속 구조를 완성했습니다.


그렇다면 이제 질문이 하나 생깁니다.

❓ “DB는 만들었는데… 프론트는 어떻게 연결하지?”


Part 2는 바로
👉 프론트 ↔ DB 실제 연결 작업입니다.

즉,

채팅을 “React state 앱” → “DB 기반 서비스”로 바꾸는 단계

입니다.



✅ Part 2에서 해결한 핵심

오늘 한 일을 한 줄로 요약하면:

프론트 = 단순 UI
DB = 진짜 데이터

이 구조로 전환했습니다.


오늘 구현 목록

✅ Zustand 전역 상태(chatStore)
✅ 세션 복원/생성 로직
✅ 메시지 저장/조회
✅ 카드 UI 복원
✅ Google 로그인 user_id 불안정 문제 해결



🧠 1단계 — chatStore.ts (전역 상태 설계)


📁 store/chatStore.ts

역할

👉 세션 + 메시지를 전역으로 관리

import { create } from "zustand";

export type ChatMessage = {
  id: string;
  role: "user" | "assistant" | "system";
  content: string;
  cards?: { label: string; value: string }[];
};

interface ChatState {
  sessionId: number | null;
  messages: ChatMessage[];

  setSessionId: (sessionId: number | null) => void;
  setMessages: (messages: ChatMessage[]) => void;
  addMessage: (message: ChatMessage) => void;
  updateMessage: (id: string, patch: Partial<ChatMessage>) => void;
  clearMessages: () => void;
}

export const useChatStore = create<ChatState>((set) => ({
  sessionId: null,
  messages: [],

  setSessionId: (sessionId) => set({ sessionId }),

  setMessages: (messages) => set({ messages }),

  addMessage: (message) =>
    set((state) => ({
      messages: [...state.messages, message],
    })),

  updateMessage: (id, patch) =>
    set((state) => ({
      messages: state.messages.map((msg) =>
        msg.id === id ? { ...msg, ...patch } : msg
      ),
    })),

  clearMessages: () => set({ messages: [] }),
}));


✅ 이 코드가 하는 일

sessionId

→ 현재 대화방 ID

messages

→ 화면에 렌더링할 메시지 목록

상태 함수들

함수역할

setMessages 전체 교체 (복원 시 사용)
addMessage 메시지 추가
updateMessage 기존 메시지 수정
clearMessages 초기화


⭐ updateMessage가 중요한 이유

GPT 응답은 스트리밍/비동기입니다.

흐름:

1. "답변 생성중..." 메시지 먼저 표시
2. GPT 완료 후 실제 내용으로 교체

그래서:

updateMessage(id, { content: realAnswer })

👉 기존 메시지를 덮어쓰기 위해 필수



❓ 왜 전역 상태가 필요했을까?

처음엔 useState로 충분해 보였습니다.

하지만 실제로는:

  • ChatContainer
  • MessageList
  • MessageBubble
  • Summary
  • Sidebar

👉 여러 컴포넌트가 메시지를 공유


로컬 상태 문제

useState → 컴포넌트 이동 시 초기화

즉,

👉 복원 불가능


그래서:

“채팅 데이터는 전역 상태가 맞다”

결론: Zustand 채택



🧠 2단계 — ChatContainer (실제 데이터 흐름 핵심 ⭐)


📁 components/chat/ChatContainer.tsx

여기가 실제 컨트롤 타워입니다.

모든 흐름이 여기서 시작됩니다.



✅ 전체 흐름 한눈에 보기

[페이지 진입]
   ↓
세션 복원(localStorage)
   ↓
세션 없으면 생성
   ↓
DB에서 메시지 조회
   ↓
화면 렌더링
   ↓
사용자 입력
   ↓
DB 저장
   ↓
GPT 호출
   ↓
응답 DB 저장


① 세션 복원

const saved = window.localStorage.getItem(`chatSessionId:${userId}`);

if (saved) {
  const parsed = Number(saved);
  setSessionId(parsed);
}

왜 필요한가?

새로고침 시:

React state = 전부 초기화

그래서 마지막 세션을 기억해야 합니다.

👉 localStorage 사용


핵심 포인트

chatSessionId:${userId}

👉 사용자별로 분리 저장

구글/일반 로그인 충돌 방지



② 세션 확보 (ensureSession)

const list = await apiFetch("/sessions", {
  headers: { "x-user-id": userId },
});

의미

1️⃣ 기존 세션 존재 → 최신 세션 사용
2️⃣ 없으면 → 새 세션 생성


👉 항상 “유효한 세션 ID 확보”



③ 메시지 복원

const res = await apiFetch(`/messages/sessions/${id}`, {
  headers: { "x-user-id": userId },
});

의미

DB → 메시지 조회


이 코드 하나로:

✅ 새로고침 복원
✅ 재접속 복원
✅ 기기 변경 복원 가능


👉 DB가 Source of Truth



④ 사용자 메시지 저장

await apiFetch("/messages", {
  method: "POST",
  body: JSON.stringify({
    session_id: id,
    role: "user",
    content: message,
    content_type: "text",
  }),
});

핵심

이제부터:

❌ state 기준
⭕ DB 기준


👉 “대화는 항상 DB에 먼저 저장”



⑤ GPT 응답 저장

await apiFetch("/messages", {
  method: "POST",
  body: JSON.stringify({
    session_id: id,
    role: "assistant",
    content: replyResult.raw,
    content_type: replyResult.cards ? "card" : "text",
  }),
});

핵심 설계

카드면 → content_type = card
텍스트면 → text

👉 이게 Part 3 카드 복원의 기반



🧠 3단계 — 카드 UI 복원


if (item.content_type === "card") {
  cards = parseCardsFromContent(item.content);
}

왜 이렇게 했을까?

카드는 단순 문자열이 아닙니다.

JSON 구조 데이터

그래서:

1️⃣ JSON 그대로 저장
2️⃣ content_type으로 구분
3️⃣ 복원 시 JSON.parse


👉 새로고침 후에도 100% 동일 UI 복원



🧠 4단계 — Google 로그인 버그 해결 (실무 트러블슈팅 ⭐)

이게 오늘 진짜 실무 경험 포인트입니다.


❌ 문제

구글 로그인 시:

  • 새로고침하면 대화 사라짐

❓ 원인

session.user.id

이 값이

👉 로그인할 때마다 랜덤 UUID


결과:

세션 저장 user_id ≠ 조회 user_id

세션 매칭 실패 ❌



✅ 해결 전략

방법 1 (실무 정석)

OAuth 매핑 테이블 생성

providerAccountId ↔ 내부 user_id

항상 동일 id 사용


방법 2 (현재 적용)

Google은 email 고정

const userId = user?.id || user?.email || "";

👉 email fallback 사용



결과

✅ 새로고침 유지
✅ 재로그인 유지
✅ 구글 로그인 정상 복원



✅ Part 2 최종 성과

오늘 프론트에서 바뀐 것:


✅ sessionId 복원
✅ 메시지 DB 저장
✅ DB 기준 복원
✅ 카드 UI 복원
✅ Google OAuth 안정화
✅ 프론트 = 단순 View 구조 완성



🎯 면접에서 이렇게 말하면 좋습니다

“프론트는 단순 UI 역할만 하도록 하고,
세션 기반으로 백엔드 DB와 동기화하도록 구조를 변경했습니다.
OAuth 사용자도 동일 user_id 체계를 유지해
새로고침 후에도 대화가 복원되는 구조로 설계했습니다.”


👉 이 한 문장 = 실무 경험 인증 멘트



⭐ Part 2 한 줄 요약

👉 “프론트를 상태 앱이 아니라 DB 동기화 클라이언트로 만든 날”

.


📘 Aura_AI Day 16 — Part 3 (완전판)

카드형 응답 저장/복원 & 실제 데이터 흐름 완전 정리

“UI를 그린 것이 아니라, UI가 복원되도록 설계한 날”

안녕하세요.
Aura AI 프로젝트 Day 16 Part 3 기록입니다.


Day 16은 단순 기능 추가가 아닙니다.

  • Part 1 → DB 구조 설계
  • Part 2 → 프론트 ↔ DB 연결
  • Part 3 → UI 복원 설계 (UX 완성)

즉,

👉 “보여주는 채팅”이 아니라
👉 “언제 다시 와도 동일하게 복원되는 채팅”을 만드는 단계

입니다.



✅ 오늘 해결한 핵심 문제

Day 15까지는 이런 상태였습니다.

❌ 문제점

  • GPT 응답을 카드 UI로 예쁘게 렌더링
  • 하지만 새로고침하면 전부 텍스트로 깨짐
  • 카드 디자인 사라짐
  • UX 일관성 붕괴

왜 그럴까요?

👉 UI는 상태(state)였고, 데이터가 아니었기 때문입니다.



🎯 오늘 목표 (한 줄 요약)

카드 UI를 "렌더링 결과"가 아니라
"저장 가능한 데이터 구조"로 만든다

즉,

👉 UI = 데이터 기반 복원 가능 구조



🧠 1단계 — 왜 카드형 저장이 필요한가?

GPT 응답은 실제로 이런 형태입니다.

[
  { "label": "상의", "value": "니트" },
  { "label": "하의", "value": "슬랙스" },
  { "label": "신발", "value": "로퍼" }
]

이건 이미 구조화된 데이터(JSON) 입니다.


그런데 만약 이렇게 저장하면?

content: "니트 + 슬랙스 + 로퍼 추천입니다"

❌ 문제

  • 구조 사라짐
  • 카드 복원 불가능
  • 텍스트만 표시 가능

그래서 선택한 방식

👉 JSON 원본 그대로 저장



🧠 2단계 — content_type 설계 (핵심 ⭐)

Part 1에서 이미 추가했던 필드입니다.

content_type: str = "text"

역할

타입의미

text 일반 텍스트
card 카드형 JSON
markdown 마크다운
summary 요약

즉,

👉 렌더링 방식을 결정하는 메타데이터



⭐ 왜 이게 중요할까?

만약 content_type이 없으면:

이 메시지가 카드인지? 텍스트인지?
→ 알 수 없음

👉 복원 불가능 ❌


그래서:

content_type = UI 복원의 핵심 키



🧠 3단계 — 저장 시 데이터 흐름 (DB 저장 과정)


📌 GPT 응답 수신

const replyResult = {
  raw: JSON.stringify(cards),
  cards,
};

📌 DB 저장 코드

await apiFetch("/messages", {
  method: "POST",
  body: JSON.stringify({
    session_id: id,
    role: "assistant",
    content: replyResult.raw, // JSON 원본
    content_type: replyResult.cards ? "card" : "text",
  }),
});


✅ 여기서 핵심 3가지

① JSON.stringify 그대로 저장

👉 구조 유지


② content_type = "card"

👉 복원 시 판단 기준


③ DB가 진짜 데이터

👉 새로고침해도 그대로 존재



🧠 4단계 — 복원 시 데이터 흐름 (새로고침 과정)


📌 DB 조회

const res = await apiFetch(`/messages/sessions/${id}`);

📌 복원 로직

if (item.content_type === "card") {
  cards = parseCardsFromContent(item.content);
}

📌 파싱

function parseCardsFromContent(content: string) {
  try {
    return JSON.parse(content);
  } catch {
    return undefined;
  }
}


✅ 전체 복원 과정

DB에서 JSON 문자열 조회
      ↓
content_type === card 확인
      ↓
JSON.parse
      ↓
cards 배열 생성
      ↓
카드 UI 렌더링

👉 결과: 새로고침해도 100% 동일 UI 복원



🧠 5단계 — MessageBubble 렌더링 구조


📁 MessageBubble.tsx

if (message.cards) {
  return <CardMessage cards={message.cards} />;
}

return <TextMessage content={message.content} />;


의미

카드 있으면

👉 카드 UI

없으면

👉 텍스트 UI


즉,

UI 분기 = 데이터 기반


❌ if(role === assistant && 뭐시기...) 이런 하드코딩 아님
⭕ 데이터 구조 기반 설계



🧠 6단계 — 이 설계의 실무적 장점


✅ 1. 새로고침 복원 가능

UX 안정성 ↑


✅ 2. 서버 재시작해도 유지

영속성(Persistence)


✅ 3. UI 확장 쉬움

향후:

  • carousel 카드
  • 이미지 카드
  • 버튼 카드
  • markdown

👉 content_type 추가만 하면 끝


✅ 4. 프론트/백 분리 설계

프론트:

👉 “어떻게 보여줄지만 결정”

백엔드:

👉 “무슨 데이터인지 정의”


👉 역할 분리 명확 (실무 아키텍처)



🧠 7단계 — 최종 데이터 흐름 한눈 정리


GPT 응답(JSON)
   ↓
JSON 그대로 DB 저장
   ↓
content_type = card
   ↓
새로고침
   ↓
DB 조회
   ↓
JSON.parse
   ↓
카드 UI 렌더링

👉 UI는 데이터에서 생성된다



✅ Day 16 Part 3 최종 성과

오늘 완성된 것:


✅ 카드형 응답 JSON 저장
✅ content_type 기반 UI 분기
✅ 새로고침 후 카드 100% 복원
✅ 구조화 데이터 기반 렌더링
✅ 확장 가능한 메시지 설계



🎯 면접에서 이렇게 말하면 좋습니다

“카드 UI를 단순히 렌더링하지 않고
JSON 데이터를 그대로 DB에 저장했습니다.
content_type으로 렌더링 방식을 구분해
새로고침 후에도 동일한 UI가 복원되도록 설계했습니다.
즉, UI를 상태가 아니라 데이터 기반으로 만들었습니다.”


👉 이 멘트 = 실무 설계 경험 어필 최강



⭐ Day 16 Part 3 한 줄 요약

👉 “UI를 그린 것이 아니라, 복원 가능한 데이터로 설계한 날”