사주고사 제작기 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: 오늘/올해 운세 클라이언트 계산 전환 (십신 기반)배운 것
-
패키지 래퍼는 프로젝트 타입으로 좁혀야 한다.
manseryeok의FourPillarsDetail은 필드가 20개가 넘는다. UI에서 필요한 건 4개(stem, branch, element, yinYang)뿐이다. 래퍼를 통해 외부 타입 의존을 줄이면, 패키지를 교체하더라도 래퍼만 고치면 된다. -
AI 해석의 폴백은 "없음"이 맞을 때가 있다. 에세이 채점은 키워드 매칭 폴백이 가능하지만, 사주 해석은 AI 없이 의미 있는 결과를 줄 수 없다. 억지로 폴백을 만드는 것보다, 계산 결과(팔자 카드)만 보여주고 "해석 불가"를 명시하는 게 사용자에게 정직하다.
-
모바일 플로팅 패널은 풀스크린이 정답이다. 380px 패널을 모바일에서
inset-x-4로 넣으면 양옆 패딩 빼면 실제 콘텐츠 영역이 너무 좁다. 특히 셀렉트 드롭다운이나 카드 그리드처럼 수평 공간이 필요한 UI에서는 풀스크린이 압도적으로 낫다. -
Portal은 overflow 문제의 정답이다.
overflow: auto컨테이너 안에서absolute드롭다운을 띄우면 잘린다.z-index를 아무리 올려도 소용없다.createPortal로document.body에fixed렌더링하면 DOM 계층과 무관하게 뷰포트 위에 뜬다. -
아이콘은 단순할수록 좋다. Sparkle → 실사 로고 → 占 문자로 3번 바뀌었다. 결국 가장 단순한 한자 한 글자가 가장 기억에 남고, 16px 파비콘에서도 깨지지 않고, 프로젝트 정체성도 가장 잘 전달했다.
-
reducer에 캐시를 끼워넣으면 별도 상태 관리가 필요 없다. useEffect나 커스텀 훅 없이 reducer의 액션 핸들러에서 localStorage를 직접 읽고 쓰면 상태 흐름이 한 곳에 모인다. 초기화 함수(
useReducer의 세 번째 인자)를 활용하면 마운트 시점 캐시 로드도 깔끔하다. -
AI 생성 데이터에서 불변/가변을 분리하면 캐시 효율이 극대화된다. 평생 사주(불변)는 한 번 AI가 생성하면 영구 캐시, 일/연 운세(가변)는 규칙 기반으로 클라이언트에서 매번 계산. AI 호출 없이도 매일 새로운 운세를 보여줄 수 있다. 핵심은 "무엇이 변하고 무엇이 변하지 않는가"를 먼저 파악하는 것.
-
한국어 조사 처리는 라이브러리에 맡겨라.
이(가),을(를)같은 수동 분기는 읽기 어렵고 빠뜨리기 쉽다.josa라이브러리의#{이},#{을}플레이스홀더는 받침을 자동 판별해서 정확한 조사를 선택한다. 동적 한국어 텍스트를 생성하는 모든 곳에서 유용하다.
남은 과제
- 추리 결과 Supabase 저장 (통계/리더보드)
- 추리 결과 전용 URL (공유 링크에서 직접 결과 확인)
- generated-questions analysis 콘텐츠 품질 보강
- 문제/인물 데이터 Supabase 이관
- 힌트-정답 겹침 자동 검증 스크립트
- 사주 해석 결과 공유 기능
- 사주 해석 결과 localStorage 캐싱 (완료)
- 오늘/올해 운세 클라이언트 계산 전환 (완료)