|
311 | 311 |
|
312 | 312 | ## 9. Recent Changes (최근 변경 사항) |
313 | 313 |
|
314 | | -### 2026-02-23: Chronicle ConversationBlock 가독성 개선 |
| 314 | +### 2026-02-23 세션 작업 로그 |
315 | 315 |
|
316 | | -**파일:** `frontend/src/components/chronicle/ConversationBlock.tsx` |
| 316 | +이 섹션은 2026-02-23 세션에서 수행한 모든 작업을 시간순으로 상세 기록한다. |
| 317 | + |
| 318 | +--- |
| 319 | + |
| 320 | +#### 작업 1: Chronicle ConversationBlock 가독성 개선 |
| 321 | + |
| 322 | +**커밋:** `78b8348` — `feat: improve Chronicle ConversationBlock readability and add project status doc` |
| 323 | + |
| 324 | +**문제 분석:** |
| 325 | +- 에이전트 이름과 대화 내용이 한 줄에 `flex items-start`로 나열 → 시각적 밀도가 높아 읽기 어려움 |
| 326 | +- 메시지 간 간격 `space-y-1.5`이 너무 좁아 경계가 불분명 |
| 327 | +- 팩션 색상 좌측 보더 불투명도 `30` → 거의 안 보임 |
| 328 | +- LLM 생성 장문 텍스트(줄바꿈, `*이탤릭*` 등)가 한 줄짜리 `<span>`에 담겨 서식 무시 |
| 329 | + |
| 330 | +**변경 파일 및 내용:** |
| 331 | + |
| 332 | +| 파일 | 변경 | |
| 333 | +|------|------| |
| 334 | +| `frontend/src/components/chronicle/ConversationBlock.tsx` | 채팅 버블 레이아웃으로 전면 개편 | |
| 335 | +| `frontend/src/stores/simulation.ts` | `loadChronicleFromDB()` 함수 추가 | |
| 336 | +| `frontend/src/components/chronicle/ChronicleView.tsx` | `AnimatePresence` 래퍼 제거 | |
| 337 | +| `frontend/src/components/divine/InterventionBar.tsx` | Whisper API 바디 필드명 수정 | |
| 338 | +| `docs/PROJECT_STATUS.md` | 프로젝트 현황 종합 문서 신규 작성 | |
| 339 | + |
| 340 | +**ConversationBlock 상세 변경:** |
| 341 | + |
| 342 | +``` |
| 343 | +Before After |
| 344 | +───────────────────────────── ───────────────────────────── |
| 345 | +flex items-start (한 줄) → 에이전트 이름 별도 줄 (상단) |
| 346 | + 메시지 내용 블록 (하단) |
| 347 | +border opacity 30 → border opacity 80 |
| 348 | +<span> (서식 무시) → <p whitespace-pre-wrap> + *italic* → <em> |
| 349 | +space-y-1.5 → space-y-4 |
| 350 | +text-[11px] font-mono (토픽) → text-sm font-serif |
| 351 | +slice(0, 2) (기본 2개 표시) → slice(0, 3) (기본 3개 표시) |
| 352 | +bg 없음 → bg-void/50 블록 배경 |
| 353 | +``` |
| 354 | + |
| 355 | +**`renderContent()` 함수 추가:** |
| 356 | +- `*text*` 패턴을 `<em>text</em>`으로 변환 |
| 357 | +- LLM이 자주 사용하는 이탤릭 마크다운을 실제 HTML로 렌더링 |
| 358 | + |
| 359 | +**`loadChronicleFromDB()` 함수 추가:** |
| 360 | +- 월드 진입 시 DB에서 기존 대화 50개 + 위키 페이지를 Chronicle로 로드 |
| 361 | +- `conversations` API와 `wiki` API를 `Promise.all`로 병렬 호출 |
| 362 | +- WebSocket으로 수신한 기존 아이템과 ID 기반 중복 제거 후 병합 |
| 363 | +- 타임스탬프 역순 정렬 (최신 항목 상단) |
| 364 | + |
| 365 | +**검증:** `npm run build` 성공 |
| 366 | + |
| 367 | +--- |
| 368 | + |
| 369 | +#### 작업 2: CSS 400 Bad Request 이슈 대응 |
| 370 | + |
| 371 | +**증상:** |
| 372 | +- `https://null.moss.land/en` 접속 시 `_next/static/css/cb96dbb2f870df51.css` → 400 Bad Request |
| 373 | +- JS 파일도 동일하게 400 에러 발생 |
| 374 | + |
| 375 | +**원인 분석:** |
| 376 | +- 빌드 출력의 CSS 해시: `9ecfc2f30213e6fa` |
| 377 | +- 브라우저가 요청하는 CSS 해시: `cb96dbb2f870df51` (이전 빌드) |
| 378 | +- HTML이 브라우저/CDN에 캐시되어 있어 이전 빌드의 해시를 참조 |
| 379 | +- 서버에는 새 빌드 파일만 존재 → 404/400 반환 |
| 380 | + |
| 381 | +**해결:** |
| 382 | +1. `npm run build` 실행 → 새 CSS 해시 생성 |
| 383 | +2. `pm2 restart null-frontend` → 새 빌드 파일 서빙 시작 |
| 384 | +3. 브라우저 강제 새로고침 (`Cmd+Shift+R`) 안내 |
| 385 | + |
| 386 | +**검증:** `curl -s -o /dev/null -w "%{http_code}" https://null.moss.land/_next/static/css/9ecfc2f30213e6fa.css` → 200 OK |
| 387 | + |
| 388 | +**참고:** 향후 Nginx에서 HTML의 `Cache-Control: no-cache` 설정 권장 (정적 에셋은 해시 기반이므로 장기 캐시 가능) |
| 389 | + |
| 390 | +--- |
| 391 | + |
| 392 | +#### 작업 3: WebSocket 재연결 강화 |
| 393 | + |
| 394 | +**커밋:** `4436387` — `fix: add exponential backoff and retry limit to WebSocket reconnection` |
| 395 | + |
| 396 | +**문제 분석 (`frontend/src/lib/wsClient.ts`):** |
| 397 | + |
| 398 | +| 문제 | 위험도 | |
| 399 | +|------|--------| |
| 400 | +| 3초 고정 재연결 간격 | 서버 다운 시 3초마다 무한 폭격 | |
| 401 | +| 최대 재시도 제한 없음 | 클라이언트 리소스 무한 소모 | |
| 402 | +| `onerror`에서 에러 상세 무시 | 디버깅 불가능 | |
| 403 | +| `onmessage` parse 에러 무시 | 프로토콜 오류 탐지 불가 | |
| 404 | +| `onclose` 시 `wsRef` 미초기화 | stale reference 위험 | |
| 405 | +| `disconnect` 시 retry 카운터 미리셋 | 재접속 시 즉시 한도 도달 | |
317 | 406 |
|
318 | 407 | **변경 내용:** |
319 | 408 |
|
320 | | -1. **채팅 버블 레이아웃** — 에이전트 이름을 별도 줄로 분리하고, 메시지를 `bg-void/50` 블록으로 감싸 시각적 분리 |
321 | | -2. **보더 가시성 강화** — 팩션 색상 좌측 보더 불투명도 `30` → `80` |
322 | | -3. **서식 렌더링** — `whitespace-pre-wrap`으로 줄바꿈 지원, `*text*` → `<em>` 이탤릭 변환 |
323 | | -4. **간격 확대** — 메시지 간 간격 `space-y-1.5` → `space-y-4` |
324 | | -5. **토픽 헤더** — `text-[11px] font-mono` → `text-sm font-serif`로 확대 |
325 | | -6. **기본 메시지 수** — 접힌 상태에서 2개 → 3개 표시 |
| 409 | +```typescript |
| 410 | +// Before |
| 411 | +ws.onclose = () => { |
| 412 | + reconnectTimer.current = setTimeout(() => connect(worldId), 3000); |
| 413 | +}; |
| 414 | + |
| 415 | +// After |
| 416 | +const MAX_RETRIES = 10; |
| 417 | +const BASE_DELAY_MS = 2_000; |
| 418 | +const MAX_DELAY_MS = 30_000; |
| 419 | + |
| 420 | +ws.onopen = () => { retryCount.current = 0; }; |
| 421 | + |
| 422 | +ws.onclose = (event) => { |
| 423 | + wsRef.current = null; |
| 424 | + if (retryCount.current >= MAX_RETRIES) { |
| 425 | + console.error(`[WS] Gave up after ${MAX_RETRIES} attempts`); |
| 426 | + return; |
| 427 | + } |
| 428 | + const delay = Math.min(BASE_DELAY_MS * Math.pow(2, retryCount.current), MAX_DELAY_MS); |
| 429 | + retryCount.current += 1; |
| 430 | + console.log(`[WS] Reconnecting in ${delay}ms (${retryCount.current}/${MAX_RETRIES})`); |
| 431 | + reconnectTimer.current = setTimeout(() => connect(worldId), delay); |
| 432 | +}; |
| 433 | +``` |
326 | 434 |
|
327 | | -### 2026-02-23: Chronicle DB 로딩 + 기타 수정 |
| 435 | +**재연결 지연 시퀀스:** 2s → 4s → 8s → 16s → 30s → 30s → ... (최대 10회) |
328 | 436 |
|
329 | | -**파일들:** |
| 437 | +**검증:** `npm run build` 성공, `git push` 성공 |
330 | 438 |
|
331 | | -- `frontend/src/stores/simulation.ts` — `loadChronicleFromDB()` 추가: 월드 진입 시 DB에서 기존 대화/위키를 Chronicle로 로드, WebSocket 아이템과 중복 제거 후 병합 |
332 | | -- `frontend/src/components/chronicle/ChronicleView.tsx` — `AnimatePresence` 래퍼 제거 (불필요한 레이아웃 애니메이션 오버헤드 제거) |
333 | | -- `frontend/src/components/divine/InterventionBar.tsx` — Whisper API 바디 필드명 수정 (`message` → `type` + `description`) |
| 439 | +--- |
| 440 | + |
| 441 | +#### 작업 4: Chronicle 블록 React.memo 적용 (성능 최적화) |
| 442 | + |
| 443 | +**커밋:** `f5e0fa0` — `perf: memoize chronicle blocks and add error logging to store fetches` |
| 444 | + |
| 445 | +**문제 분석:** |
| 446 | +- Chronicle은 최대 500개 아이템을 렌더링 |
| 447 | +- 4개 블록 컴포넌트(`ConversationBlock`, `HeraldBlock`, `EventBlock`, `WikiCrystal`)가 모두 일반 함수 컴포넌트 |
| 448 | +- 부모(`ChronicleView`) state 변경 시 모든 자식이 re-render → props 미변경인 아이템도 불필요하게 렌더링 |
| 449 | + |
| 450 | +**변경 내용:** |
| 451 | + |
| 452 | +| 파일 | Before | After | |
| 453 | +|------|--------|-------| |
| 454 | +| `ConversationBlock.tsx` | `export function ConversationBlock(...)` | `export const ConversationBlock = memo(function ConversationBlock(...))` | |
| 455 | +| `HeraldBlock.tsx` | `export function HeraldBlock(...)` | `export const HeraldBlock = memo(function HeraldBlock(...))` | |
| 456 | +| `EventBlock.tsx` | `export function EventBlock(...)` | `export const EventBlock = memo(function EventBlock(...))` | |
| 457 | +| `WikiCrystal.tsx` | `export function WikiCrystal(...)` | `export const WikiCrystal = memo(function WikiCrystal(...))` | |
| 458 | + |
| 459 | +**효과:** |
| 460 | +- props(item, dimmed, onAgentClick)가 동일한 아이템은 re-render 스킵 |
| 461 | +- 500개 목록에서 1개 아이템 추가 시: Before → 500개 전체 렌더, After → 신규 1개만 렌더 |
| 462 | +- `WikiCrystal`은 내부 `useState(expanded)`가 있지만 memo가 외부 props 변경만 체크하므로 유효 |
| 463 | + |
| 464 | +--- |
| 465 | + |
| 466 | +#### 작업 5: Store Fetch 에러 처리 개선 |
| 467 | + |
| 468 | +**커밋:** `f5e0fa0` (작업 4와 동일 커밋) |
| 469 | + |
| 470 | +**문제 분석:** |
| 471 | +- `simulation.ts` 내 12개 fetch 함수에서 에러를 무시하는 패턴이 반복: |
| 472 | + ```typescript |
| 473 | + catch { |
| 474 | + // endpoint may not exist yet ← 모든 에러를 무시 |
| 475 | + } |
| 476 | + ``` |
| 477 | +- `createWorld`, `fetchWorld`, `fetchAgents` 등 핵심 함수에 try/catch 자체가 없음 |
| 478 | +- 네트워크 장애, 서버 500 에러, JSON 파싱 실패 등이 모두 무시됨 |
| 479 | +- 프로덕션에서 문제 발생 시 디버깅 불가능 |
| 480 | + |
| 481 | +**변경 내용:** |
| 482 | + |
| 483 | +| 함수 | Before | After | |
| 484 | +|------|--------|-------| |
| 485 | +| `createWorld` | try/catch 없음 | `try/catch` + `console.error("[Store] createWorld error:", err)` | |
| 486 | +| `fetchWorld` | try/catch 없음 | `try/catch` + `resp.ok` 체크 + `console.error` | |
| 487 | +| `fetchAgents` | try/catch 없음 | `try/catch` + `resp.ok` 체크 + `console.warn` | |
| 488 | +| `fetchFactions` | `catch { }` (무시) | `catch (err) { console.warn("[Store] fetchFactions:", err) }` | |
| 489 | +| `fetchRelationships` | `catch { }` (무시) | `catch (err) { console.warn("[Store] fetchRelationships:", err) }` | |
| 490 | +| `fetchWikiPages` | `catch { }` (무시) | `catch (err) { console.warn("[Store] fetchWikiPages:", err) }` | |
| 491 | +| `fetchAutoWorlds` | `catch { }` (무시) | `catch (err) { console.warn(...) }` + `resp.ok` 체크 | |
| 492 | +| `startSimulation` | try/catch 없음 | `try/catch` + `resp.ok` 체크 | |
| 493 | +| `stopSimulation` | try/catch 없음 | `try/catch` + `resp.ok` 체크 | |
| 494 | +| `fetchConversations` | `catch { }` (무시) | `catch (err) { console.warn(...) }` | |
| 495 | +| `fetchFeed` | `catch { }` (무시) | `catch (err) { console.warn(...) }` | |
| 496 | +| `exportWorld` | try/catch 없음 | `try/catch` + `resp.ok` 체크 + early return | |
| 497 | + |
| 498 | +**로깅 규칙:** |
| 499 | +- `console.error` — 사용자 액션이 실패한 경우 (createWorld, startSimulation, stopSimulation, exportWorld, fetchWorld) |
| 500 | +- `console.warn` — 백그라운드/보조 데이터 로드 실패 (factions, relationships, wiki 등) |
| 501 | +- 모든 로그에 `[Store] functionName:` 프리픽스 → 브라우저 콘솔에서 빠른 필터링 |
| 502 | + |
| 503 | +--- |
| 504 | + |
| 505 | +#### 작업 6: 프로덕션 배포 검증 |
| 506 | + |
| 507 | +**검증 항목 및 결과:** |
| 508 | + |
| 509 | +| 항목 | 방법 | 결과 | |
| 510 | +|------|------|------| |
| 511 | +| PM2 프로세스 상태 | `pm2 list` | null-backend: online (5h), null-frontend: online (2h) | |
| 512 | +| 프론트엔드 헬스 | `curl localhost:6001/en` | 200 OK | |
| 513 | +| 백엔드 헬스 | `curl localhost:6301/health` | `{"status":"ok"}` | |
| 514 | +| 도메인 프론트엔드 | `curl https://null.moss.land/en` | 200 OK | |
| 515 | +| 도메인 API | `curl https://null.moss.land/api/worlds` | 200 OK (9개 월드, 5개 running) | |
| 516 | +| CSS 파일 정상 로드 | `curl https://null.moss.land/_next/static/css/9ecfc2f30213e6fa.css` | 200 OK | |
| 517 | +| 백엔드 서비스 로그 | `pm2 logs null-backend` | semantic_indexer, convergence 정상 주기 실행 | |
| 518 | +| WebSocket 연결 | 백엔드 로그 확인 | WS open/close 정상 사이클 | |
| 519 | + |
| 520 | +**발견 사항:** |
| 521 | +- 백엔드 실제 포트는 `6301` (start-null-backend.sh의 `NULL_BACKEND_PORT` 기본값) |
| 522 | +- Docker Compose 설정의 `3301`은 컨테이너 내부 포트, PM2 직접 실행 시 `6301` 사용 |
| 523 | +- 리버스 프록시(Nginx/Caddy)가 `null.moss.land` → `localhost:6301` 프록시 중 |
| 524 | + |
| 525 | +--- |
334 | 526 |
|
335 | 527 | ### 이전 주요 커밋 |
336 | 528 |
|
|
343 | 535 |
|
344 | 536 | --- |
345 | 537 |
|
| 538 | +### 향후 개선 과제 (분석 완료, 미착수) |
| 539 | + |
| 540 | +아래 항목들은 코드 분석 과정에서 식별된 개선 사항으로, 다음 작업 세션에서 우선순위에 따라 진행 예정: |
| 541 | + |
| 542 | +| # | 과제 | 카테고리 | 예상 시간 | |
| 543 | +|---|------|----------|-----------| |
| 544 | +| 1 | 홈페이지 `/api/seeds` fetch에 에러 핸들링 추가 | 에러 처리 | 10분 | |
| 545 | +| 2 | `OraclePanel` index-based key → timestamp key | 성능 | 5분 | |
| 546 | +| 3 | `ChronicleView.isDimmed` 메모이제이션 | 성능 | 20분 | |
| 547 | +| 4 | `OraclePanel`, `ExportPanel`, `BookmarkDrawer` lazy loading (`dynamic()`) | 번들 최적화 | 30분 | |
| 548 | +| 5 | 주요 버튼에 `aria-label` 추가 (접근성) | 접근성 | 15분 | |
| 549 | +| 6 | `addChronicleItem`의 배열 spread + slice → circular buffer | 성능 | 30분 | |
| 550 | +| 7 | `TimelineRibbon`의 `getEpochColor` O(n) 검색 → Map 인덱싱 | 성능 | 15분 | |
| 551 | +| 8 | 네트워크 연결 상태 감지 UI (`navigator.onLine`) | UX | 30분 | |
| 552 | + |
| 553 | +--- |
| 554 | + |
346 | 555 | ## 10. Key Concepts (핵심 개념 정리) |
347 | 556 |
|
348 | 557 | ### Simulation Concepts |
|
0 commit comments