-
이코에코(Eco²) Scan API SSE BE-FE 연동: Eventual Consistency 대응 계획이코에코(Eco²)/Plans 2026. 1. 9. 18:47

프론트엔드 캐릭터 보유 확인 - Eventual Consistency 대응 계획
상태: Updated (Frontend-only 접근법으로 변경)
최종 수정: 2026-01-091. 현황 분석
1.1 백엔드 응답 구조 (현재)
# apps/scan_worker/application/classify/steps/reward_step.py:94-99 reward_response = { "name": reward.get("name"), "dialog": reward.get("dialog"), "match_reason": reward.get("match_reason"), "type": reward.get("type"), } # ⚠️ received, already_owned, character_id 등이 클라이언트 응답에 포함되지 않음1.2 프론트엔드 타입 정의 (현재)
// src/api/services/scan/scan.type.ts:34-40 reward?: { received: string; // ⚠️ 백엔드에서 제공하지 않음 already_owned: boolean; // ⚠️ 백엔드에서 제공하지 않음 name: string; dialog: string; match_reason: string; };1.3 프론트엔드 축하 효과 로직 (현재)
// src/pages/Camera/Answer.tsx:29-32 useEffect(() => { if (resultStatus === 'good' && reward?.received) { // ⚠️ reward.received가 undefined setShowCelebration(true); } }, [resultStatus, reward?.received]);1.4 캐릭터 보유 확인 로직 (현재)
// src/pages/Home/CharacterCollection.tsx:69-77 useEffect(() => { const getAcquiredCharacter = async () => { const { data } = await api.get('/api/v1/users/me/characters'); // ... setAcquiredList(names); }; getAcquiredCharacter(); }, []);
2. 발견된 문제점
2.1 🔴 Critical: Celebration Effect 버그
항목 설명 증상 캐릭터 획득 시 축하 효과가 표시되지 않음 원인 reward.received가 undefined (백엔드 미제공)영향 모든 신규 캐릭터 획득 시 축하 화면 미노출 Frontend 기대값: { received: "true", name: "일렉", ... } Backend 실제 응답: { name: "일렉", dialog: "...", ... } ↑ received 필드 없음!2.2 🟡 Eventual Consistency 문제
항목 설명 증상 캐릭터 획득 직후 컬렉션에 미표시 원인 Fanout 비동기 저장 → DB 반영 지연 영향 홈 화면 진입 시 방금 획득한 캐릭터가 안 보일 수 있음 Timeline: ┌─────────────────────────────────────────────────────────────────────────┐ │ Scan API 응답 Character Worker Users Worker │ │ │ │ │ │ │ t=0 │◄─ "reward: 일렉" │ │ │ │ │ │ │ │ │ t=50ms│ 프론트: /home 이동 │ │ │ │ │ API: /users/me/chars │ │ │ │ │ ↓ │ │ │ │ t=100ms│ "characters: []" ❌ │◄─ save ownership │ │ │ │ │ │ │ │ t=200ms│ │ DB 저장 완료 │◄─ save character │ │ │ │ │ │ │ t=300ms│ 새로고침 필요! │ │ DB 저장 완료 │ └─────────────────────────────────────────────────────────────────────────┘
3. 해결 방안
3.1 Option A: 백엔드에서
received,already_owned추가 (기각)⚠️ 기각 사유: 프론트엔드가 이미 /api/v1/users/me/characters로 보유 캐릭터 목록을 가지고 있으므로, 클라이언트 측에서 already_owned 체크 가능. 백엔드 변경 불필요.
3.2 Option B: 프론트엔드 Optimistic Update (기각)
⚠️ 기각 사유: Option D가 단순하며 강한 논리적 정합성이 보장되는 해결책.
3.3 Option D: Frontend-only (✅ 채택)
핵심 아이디어:
- 프론트엔드가 이미 보유 캐릭터 목록을 API로 조회함
- 해당 목록을 localStorage에 캐싱
- 스캔 결과의
reward.name이 캐시에 없으면 → 신규 캐릭터 → 축하 효과 표시 - 백엔드 변경 불필요
┌─────────────────────────────────────────────────────────────────────────┐ │ 변경 전: 백엔드가 already_owned 제공 필요 │ ├─────────────────────────────────────────────────────────────────────────┤ │ Backend → { received, already_owned, name, ... } │ │ Frontend → if (!already_owned) showCelebration() │ └─────────────────────────────────────────────────────────────────────────┘ ↓ 변경 ┌─────────────────────────────────────────────────────────────────────────┐ │ 변경 후: 프론트엔드가 직접 체크 (백엔드 변경 없음) │ ├─────────────────────────────────────────────────────────────────────────┤ │ Backend → { name, dialog, ... } (기존 그대로) │ │ Frontend → const isNew = !ownedCache.includes(reward.name) │ │ if (isNew) showCelebration() │ └─────────────────────────────────────────────────────────────────────────┘장점:
항목 설명 백엔드 변경 없음 received,already_owned추가 불필요즉각적 UX localStorage 조회는 동기식 → 즉시 판단 Eventual Consistency 해결 Optimistic Update로 즉시 반영 단순한 구현 별도 API 호출 없이 로컬 캐시 활용 단점:
- 다른 기기에서 획득한 캐릭터는 홈 화면 진입 시 서버 동기화로 해결
4. 구현 계획 (Frontend-only)
단일 Phase (프론트엔드만 변경)
Task 담당 1. CharacterCache.ts유틸 생성Frontend 2. CharacterCollection.tsx캐시 동기화 추가Frontend 3. Answer.tsx축하 효과 로직 수정Frontend 4. 타입 정의 정리 (불필요한 필드 제거) Frontend 5. 로그아웃 시 캐시 클리어 Frontend E2E 테스트 QA
5. 백엔드 변경 상세(불필요)
✅ 백엔드 변경 없음 - 기존 응답 그대로 사용
6. 프론트엔드 변경 상세
6.1 CharacterCache.ts (신규 유틸)
// src/util/CharacterCache.ts const OWNED_CHARACTERS_KEY = 'owned_characters'; /** * 보유 캐릭터 목록 조회 (localStorage) */ export const getOwnedCharacters = (): string[] => { try { return JSON.parse(localStorage.getItem(OWNED_CHARACTERS_KEY) || '[]'); } catch { return []; } }; /** * 보유 캐릭터 목록 저장 (서버 동기화 시 사용) */ export const setOwnedCharacters = (names: string[]) => { localStorage.setItem(OWNED_CHARACTERS_KEY, JSON.stringify(names)); }; /** * 캐릭터 추가 (Optimistic Update) */ export const addOwnedCharacter = (name: string) => { const list = getOwnedCharacters(); if (!list.includes(name)) { list.push(name); setOwnedCharacters(list); } }; /** * 캐시 클리어 (로그아웃 시) */ export const clearOwnedCharacters = () => { localStorage.removeItem(OWNED_CHARACTERS_KEY); }; /** * 신규 캐릭터 여부 확인 */ export const isNewCharacter = (name: string): boolean => { return !getOwnedCharacters().includes(name); };6.2 CharacterCollection.tsx 수정
// src/pages/Home/CharacterCollection.tsx import { setOwnedCharacters } from '@/util/CharacterCache'; useEffect(() => { const getAcquiredCharacter = async () => { const { data } = await api.get('/api/v1/users/me/characters'); if (!data) { console.error('획득한 캐릭터 리스트를 불러올 수 없습니다.'); return; } const names = data.map((item: MyCharacterResponse) => item.name); setAcquiredList(names); // ✅ 추가: localStorage 캐시 동기화 setOwnedCharacters(names); }; getAcquiredCharacter(); }, []);6.3 Answer.tsx 수정
// src/pages/Camera/Answer.tsx import { isNewCharacter, addOwnedCharacter } from '@/util/CharacterCache'; // 변경 전 useEffect(() => { if (resultStatus === 'good' && reward?.received) { setShowCelebration(true); } }, [resultStatus, reward?.received]); // 변경 후 (Frontend-only 체크) useEffect(() => { // reward가 있고 + 신규 캐릭터일 때만 축하 효과 if (reward?.name && isNewCharacter(reward.name)) { setShowCelebration(true); // Optimistic Update: 로컬 캐시에 즉시 추가 addOwnedCharacter(reward.name); } }, [reward?.name]);6.4 scan.type.ts 수정
// src/api/services/scan/scan.type.ts // 변경 전 (불필요한 필드 포함) reward?: { received: string; // ❌ 제거 already_owned: boolean; // ❌ 제거 name: string; dialog: string; match_reason: string; }; // 변경 후 (백엔드 실제 응답에 맞춤) reward?: { name: string; dialog: string; match_reason: string; type: string; };6.5 로그아웃 시 캐시 클리어
// src/components/myPage/LogoutDialog.tsx (또는 logout 처리 위치) import { clearOwnedCharacters } from '@/util/CharacterCache'; const handleLogout = async () => { await api.post('/api/v1/auth/logout'); // ✅ 추가: 캐릭터 캐시 클리어 clearOwnedCharacters(); clearStorageUserInfo(); window.location.replace('/#/login'); };
7. 테스트 시나리오
7.1 신규 캐릭터 획득
Given: - 사용자가 "일렉" 캐릭터를 보유하지 않음 - localStorage 캐시에 "일렉" 없음 When: 전기전자제품 이미지 스캔 성공, reward.name = "일렉" Then: - isNewCharacter("일렉") = true - 축하 효과 표시됨 ✅ - localStorage 캐시에 "일렉" 추가됨 - 홈 화면에서 "일렉" 캐릭터 표시됨7.2 기존 캐릭터 재획득
Given: - 사용자가 "일렉" 캐릭터를 이미 보유 - localStorage 캐시에 "일렉" 있음 When: 전기전자제품 이미지 스캔 성공, reward.name = "일렉" Then: - isNewCharacter("일렉") = false - 축하 효과 미표시 ✅ - 홈 화면에서 "일렉" 캐릭터 유지7.3 Eventual Consistency 시나리오
Given: 사용자가 "일렉" 캐릭터 방금 획득 When: 스캔 결과 화면에서 즉시 홈으로 이동 Then: - Optimistic Update로 localStorage에 "일렉" 이미 추가됨 - 서버 API 응답 전에도 홈 화면에서 "일렉" 표시 ✅ - 서버 동기화 후에도 "일렉" 유지7.4 로그아웃 후 재로그인
Given: 사용자가 로그아웃 When: 다른 계정으로 로그인 후 홈 화면 진입 Then: - localStorage 캐시 클리어됨 - 새 계정의 캐릭터 목록으로 캐시 갱신됨
8. 데이터 플로우 (수정 후)
┌─────────────────────────────────────────────────────────────────────────────┐ │ Frontend-only Ownership Check Flow │ ├─────────────────────────────────────────────────────────────────────────────┤ │ │ │ [Frontend] [Scan Worker] [Character Worker] │ │ │ │ │ │ │ ┌───┴───┐ │ │ │ │ │ Home │ GET /users/me/characters │ │ │ │ │ 진입 │──────────────────────────┼──────────────----──────► │ | │ │ │◄─────────────────────────┼─────────────────────────| │ │ │ │ ["이코", "페이피"] │ │ │ │ │ │ │ │ │ │ │ │ │ setOwnedCharacters() │ (localStorage 캐시) │ │ │ └───┬───┘ ▼ │ │ │ │ │ localStorage: ["이코","페이피"] │ │ │ │ │ │ │ │ ┌───┴───┐ │ │ │ │ │Camera │ POST /scan │ │ │ │ │ │────────────────────────► │ │ │ │ │ │ │ character.match │ │ │ │ │ │────────────────────────►│ │ │ │ │ │◄────────────────────────│ │ │ │ │ SSE: done │ │ │ │ │ │◄─────────────────────────│ │ │ │ │ │ {name: "일렉", ...} │ │ │ │ └───┬───┘ │ │ │ │ │ │ │ │ │ ┌───┴───┐ │ │ │ │ │Answer │ │ │ │ │ │ │ isNewCharacter("일렉") │ │ │ │ │ │ ↓ │ │ │ │ │ │ localStorage 조회 │ │ │ │ │ │ ["이코","페이피"]에 없음 │ │ │ │ │ │ ↓ │ │ │ │ │ │ ✅ 축하 효과 표시! │ │ │ │ │ │ addOwnedCharacter("일렉")│ │ │ │ │ │ ↓ │ │ │ │ │ │ localStorage: ["이코","페이피","일렉"] │ │ │ └───┬───┘ │ │ │ │ │ │ │ │ │ ┌───┴───┐ │ │ │ │ │ Home │ (즉시 이동해도 OK) │ │ │ │ │ │ localStorage에 "일렉" 이미 있음 ✅ │ │ │ └───────┘ │ │ │ │ │ └─────────────────────────────────────────────────────────────────────────────┘핵심 포인트:
- 홈 화면 진입 시 서버 데이터로 localStorage 캐시 동기화
- Answer 페이지에서 localStorage 캐시로 신규 여부 즉시 판단
- Optimistic Update로 캐시에 즉시 추가 → Eventual Consistency 해결
9. 결론 및 권장 사항
✅ 채택된 해결책: Frontend-only (Option D)
항목 내용 백엔드 변경 없음 프론트엔드 변경 5개 파일 핵심 원리 localStorage 캐시 + Optimistic Update 예상 효과
- Eventual Consistency 해결 - Optimistic Update로 즉각적인 UX
- 유지보수성 - 백엔드 변경 없이 프론트엔드 단독 해결