사주고사 제작기 08. 내 사주 보기 플로팅 챗봇, 동적 파비콘, 운세 클라이언트 계산
"내 사주 보기" 플로팅 챗봇을 만들었다. manseryeok 패키지로 사주팔자를 계산하고, Gemini AI가 7섹션(종합/성격/직업/대인/오늘운세/올해운세/조언)으로 해석한다. 그리고 파비콘을 실사 로고에서 占 문자 동적 생성으로 교체했다.
1. 왜 만들었나
기존 서비스는 "허구 인물의 사주를 맞추는" 콘텐츠만 있었다. 재미있지만, 사용자 자신의 사주는 볼 수 없었다. "내 사주는 어때?"라는 자연스러운 궁금증을 해소할 수 있으면 서비스 가치가 올라간다. 별도 페이지를 만들기보다는, 어떤 페이지에서든 접근 가능한 플로팅 챗봇이 적합했다.
2. 만세력 계산: manseryeok
패키지 선택
npm에 사주/만세력 패키지가 몇 개 있지만, manseryeok을 고른 이유:
- TypeScript 네이티브 — 타입 정의가 패키지에 포함
- API가 깔끔 —
calculateFourPillars(BirthInfo)하나로 사주팔자 + 오행 + 음양 전부 반환 - 분 단위 시주 계산 — 시(時)만이 아니라 분(分)까지 지원
- 1900~2100년 범위 — 충분
래퍼 설계
manseryeok의 FourPillarsDetail 타입은 풍부하지만, UI에서 쓰기엔 과하다. SajuPillar로 단순화했다.
// manseryeok 원본
interface FourPillarsDetail {
year: Pillar; // { heavenlyStem, earthlyBranch }
yearElement: { stem: FiveElement; branch: FiveElement };
yearYinYang: { stem: YinYang; branch: YinYang };
// ... month, day, hour 동일 구조
}
// 우리 래퍼
type SajuPillar = {
stem: string; // 천간
branch: string; // 지지
element: string; // 오행 (천간 기준)
yinYang: string; // 음양
};lib/saju.ts는 server-only를 붙이지 않았다. 사주 계산 자체는 순수 연산이라 클라이언트에서도 쓸 수 있게 열어뒀다. AI 해석(lib/gemini-saju.ts)만 server-only.
시주 선택적 처리
태어난 시간을 모르는 사람이 많다. hour를 optional로 만들고:
- 입력 시: 4주(년·월·일·시) 전부 계산
- 미입력 시: 3주(년·월·일)만 계산, 시주 칸은 "?" 표시
- AI 프롬프트에 "시주 없음" 명시 → 3주만으로 해석하도록 유도
3. Gemini AI 사주 해석
기존 패턴 재사용
lib/gemini.ts(에세이 채점)의 패턴을 그대로 따랐다.
공통 패턴:
getClient() → GoogleGenerativeAI 인스턴스
sanitizeForPrompt() → XSS/injection 방어
buildPrompt() → 구조화된 프롬프트
parseResponse() → JSON 파싱 + 필드 검증
30s AbortController timeout
responseMimeType: 'application/json'
실패 시 null 반환 → 호출부에서 폴백 처리7섹션 해석
처음에는 6섹션(종합/성격/직업/대인/올해운세/조언)이었는데, "오늘의 운세"를 추가해서 7섹션이 됐다. 프롬프트에 오늘 날짜를 주입해서 일운을 해석하게 했다.
type SajuInterpretation = {
overview: string; // 종합운
personality: string; // 성격
career: string; // 직업/재물
relationships: string; // 대인관계
dailyFortune: string; // 오늘의 운세 ← 추가
yearFortune: string; // 올해 운세
advice: string; // 조언
};폴백 전략
AI가 실패하면(타임아웃, API 에러, 파싱 실패) 사주팔자 계산 결과만 반환한다. 팔자 카드 UI는 보여주고, 해석 섹션 자리에 "AI 해석을 불러오지 못했습니다" 메시지를 표시한다. 에세이 채점과 달리 키워드 폴백 같은 건 없다 — 사주 해석은 AI 없이는 의미가 없으니까.
4. API 설계
Rate Limiting
에세이 채점 API는 Supabase DB 기반 rate limit을 쓰지만, 사주 해석은 DB에 저장할 게 없다. 인메모리 Map으로 간단하게 구현했다.
// IP당 3req/10min
const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000;
const RATE_LIMIT_MAX = 3;
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();1분마다 setInterval로 만료된 엔트리를 정리한다. Vercel 서버리스 환경에서는 인스턴스가 콜드 스타트되면 Map이 초기화되지만, 남용 방지 목적으로는 충분하다.
입력 검증
year: 1900~2100 (number)
month: 1~12 (number)
day: 1~31 (number)
hour: 0~23 (number, optional)
gender: '남' | '여'manseryeok의 calculateFourPillars가 내부적으로 날짜 유효성을 검증하지만, 우리 API에서도 기본 범위 체크를 먼저 한다. 2월 30일 같은 케이스는 manseryeok이 에러를 던지면 catch해서 400 응답한다.
5. 플로팅 챗봇 UI
컴포넌트 구조
saju-chatbot.tsx — 최상위 래퍼 (open/close useState)
├── saju-chat-fab.tsx — 플로팅 버튼 (占 + 보라 그라디언트)
└── saju-chat-panel.tsx — 패널 (useReducer: input → loading → result → error)
├── saju-input-form.tsx — 년/월/일/시/성별 입력
│ └── custom-select.tsx — Portal 기반 커스텀 셀렉트
├── saju-pillars-card.tsx — 사주팔자 4열 카드
└── saju-reading-sections.tsx — AI 해석 아코디언 7섹션FAB 아이콘 변천사
처음엔 Phosphor Sparkle 아이콘을 썼는데, 제미나이 로고와 너무 비슷했다. 그래서 프로젝트 로고 이미지(logo.png)를 넣었는데, 실사 스타일이라 56px 원형 안에서 부담스러웠다. 최종적으로 占 한자 + radial-gradient로 정착했다.
Sparkle 아이콘 → 제미나이 로고 같음
logo.png 이미지 → 실사라 부담스러움
占 문자 + 그라디언트 → 심플하고 프로젝트 아이덴티티와 일치배경도 단색 bg-primary에서 그라디언트로 바꿨다.
background: radial-gradient(circle at 30% 30%, #c084fc, #7c3aed 50%, #4c1d95);
box-shadow: 0 0 20px rgba(168, 85, 247, 0.4); /* 보라 글로우 */좌상단에 하이라이트를 줘서 입체감을 만들었다.
모바일 풀스크린 모달
처음에는 모바일에서도 작은 플로팅 패널을 썼다. 380px 폭에 max-height 70vh. 하지만 셀렉트 드롭다운이 열리면 공간이 부족하고, 스크롤도 어색했다. 모바일에서는 inset-0 풀스크린 모달로 전환했다.
// 데스크톱: 플로팅 패널
'sm:bottom-24 sm:right-6 sm:w-[380px] sm:max-h-[600px] sm:rounded-2xl sm:border'
// 모바일: 풀스크린 모달
'max-sm:inset-0'모바일에서 모달이 열리면 FAB도 max-sm:hidden으로 숨긴다. 상단 "닫기" 버튼으로 충분하다.
커스텀 셀렉트의 Portal 문제
네이티브 <select>의 드롭다운은 OS 스타일이라 다크 테마가 안 먹는다. 커스텀 셀렉트를 만들었는데, 첫 버전에서 문제가 있었다.
문제: absolute 드롭다운이 패널의 overflow-y-auto에 잘림
해결: createPortal로 document.body에 fixed 렌더링트리거 버튼의 getBoundingClientRect()로 위치를 계산하고, Portal로 뷰포트 기준 fixed 위치에 띄운다. 외부 클릭/ESC로 닫히고, 열릴 때 선택된 항목으로 자동 스크롤한다.
사주팔자 카드 시각화
4열 그리드(시주·일주·월주·년주), 각 열에 천간과 지지를 상하로 배치했다. 오행별 색상:
목(木) → 초록 (green-400/green-900)
화(火) → 빨강 (red-400/red-900)
토(土) → 노랑 (yellow-400/yellow-900)
금(金) → 회색 (gray-300/gray-700)
수(水) → 파랑 (blue-400/blue-900)시주를 모르면 첫 번째 열에 "?" + bg-muted로 표시한다.
6. 파비콘 교체
왜 바꿨나
기존 파비콘(app/icon.png)은 실사 풍 오행 소용돌이 로고였다. 16px~32px 파비콘으로 축소되면 뭉개져서 식별이 안 됐다. FAB에서 占 + 그라디언트가 잘 먹혔으니 파비콘도 통일했다.
동적 생성
정적 PNG 대신 Next.js의 ImageResponse로 동적 생성한다.
// app/icon.tsx (64x64)
export default function Icon() {
return new ImageResponse(
<div style={{
background: 'radial-gradient(circle at 30% 30%, #c084fc, #7c3aed 50%, #4c1d95)',
borderRadius: '14px',
}}>
<span style={{ fontSize: 36, fontWeight: 700, color: 'white' }}>占</span>
</div>,
{ width: 64, height: 64 },
);
}app/apple-icon.tsx도 동일 디자인, 180x180. layout.tsx의 수동 icons 메타데이터는 제거했다 — Next.js가 icon.tsx/apple-icon.tsx를 자동 감지한다.
7. 오늘/올해 운세 클라이언트 계산 전환
문제
사주 해석 7섹션 전부를 Gemini AI가 생성하고 localStorage에 캐시하고 있었다. 그런데 "오늘의 운세"와 "올해 운세"는 날짜에 따라 바뀌어야 하는 가변 데이터다. 어제의 "오늘의 운세"가 오늘도 그대로 표시되는 문제가 있었다. 갱신하려면 매번 API를 호출해야 하는데, rate limit(3req/10min)에 걸리기 쉽다.
해결: 불변/가변 분리
사주 해석을 불변(평생사주, AI 생성)과 가변(일/연 운세, 클라이언트 계산)으로 분리했다.
불변 5섹션 (AI): overview, personality, career, relationships, advice
가변 2섹션 (클라이언트): dailyFortune, yearFortune가변 섹션은 만세력 기반 십신(十神) 관계를 클라이언트에서 계산한다. 일간(나)과 오늘/올해 천간의 오행 상생·상극 + 음양 일치 여부로 10가지 관계(비견·겁재·식신·상관·편재·정재·편관·정관·편인·정인)를 판별하고, 관계별 운세 템플릿을 매칭한다.
// 오행 관계 → 십신 판별
getSipsin('목', '양', '화', '양') // → '식신' (내가 생 + 같은 음양)
getSipsin('목', '양', '금', '양') // → '편관' (나를 극 + 같은 음양)타입 분리
type SajuPermanentInterpretation = { overview, personality, career, relationships, advice };
type SajuTemporalFortune = { dailyFortune, yearFortune };
type SajuInterpretation = SajuPermanentInterpretation & SajuTemporalFortune; // 렌더링용API 응답(SajuReadingResponse)은 SajuPermanentInterpretation만 반환한다. 클라이언트에서 temporal을 합성해서 7섹션 완성.
캐시 v2 마이그레이션
기존 캐시(v1)에는 version 필드가 없고 dailyFortune/yearFortune이 포함되어 있다. v1 감지 시 temporal 필드를 제거하고 version: 2로 업그레이드한다.
한국어 조사 처리: josa
운세 템플릿에서 ${오행}이(가) 같은 수동 조사 분기가 필요했다. 오행 이름에 따라 "목이", "화가"처럼 받침 유무에 따라 조사가 달라진다. josa 라이브러리를 도입해서 #{이}, #{을} 플레이스홀더로 자동 처리했다.
josa(`${meElement}#{이} ${otherElement}#{을} 극하는`)
// '목이 토를 극하는' / '화가 금을 극하는'동작 흐름
최초 방문: 사주 입력 → API → 5섹션(AI) 캐시 → 오늘/올해 운세 계산 → 7섹션 렌더링
재방문(같은 날): 캐시 로드 → 운세 재계산 → 렌더링 (API 호출 없음)
재방문(다음 날): 캐시 로드 → 새 일주로 운세 재계산 → 새 운세 (API 호출 없음)8. 사주 결과 localStorage 캐싱
왜 필요했나
사주보기를 하고 페이지를 이탈했다가 돌아오면 결과가 초기화되어 입력 폼부터 다시 시작해야 했다. 같은 생년월일로 다시 요청하면 API 호출도 낭비된다. 사용자 피드백에서 "다시 오면 사라져 있어서 번거롭다"는 의견이 있었다.
설계
reducer 패턴에 캐시 계층을 끼워넣었다. 별도 훅이나 컨텍스트 없이 reducer 안에서 처리한다.
저장 시점: SUCCESS 액션 → localStorage에 { birthInput, data } 저장
로드 시점: useReducer 초기화 함수(getInitialState)에서 캐시 확인
삭제 시점: RESET 액션 → localStorage 클리어// reducer 안에서 부수효과를 직접 실행
case 'SUCCESS':
saveCache(action.birthInput, action.data);
return { step: 'result', data: action.data, birthInput: action.birthInput };
case 'RESET':
clearCache();
return { step: 'input' };
// useReducer의 세 번째 인자(초기화 함수)로 캐시 로드
const [state, dispatch] = useReducer(reducer, undefined, getInitialState);최신 1건만 유지한다. 사주는 생년월일이 바뀌지 않으니 한 사람당 하나면 충분하다. "다시 보기"를 누르면 캐시를 지우고 새로 입력할 수 있다.
localStorage 에러 처리
loadCache, saveCache, clearCache 모두 try-catch로 감쌌다. Safari 프라이빗 모드에서는 localStorage 쓰기가 실패할 수 있고, quota exceeded도 가능하다. 실패하면 캐시 없이 동작할 뿐 기능에 영향 없다.
커밋 로그
ead9ae1 feat: 내 사주보기 플로팅 챗봇 + 동적 파비콘
b7969ef feat: 사주보기 결과 localStorage 캐싱
(pending) feat: 오늘/올해 운세 클라이언트 계산 전환 (십신 기반)남은 과제
- 추리 결과 Supabase 저장 (통계/리더보드)
- 추리 결과 전용 URL (공유 링크에서 직접 결과 확인)
- generated-questions analysis 콘텐츠 품질 보강
- 문제/인물 데이터 Supabase 이관
- 힌트-정답 겹침 자동 검증 스크립트
- 사주 해석 결과 공유 기능
- 사주 해석 결과 localStorage 캐싱 (완료)
- 오늘/올해 운세 클라이언트 계산 전환 (완료)