본문으로 건너뛰기

사주고사 제작기 08. 내 사주 보기 챗봇과 운세 클라이언트 계산

·16 min read·8 / 13

허구 인물의 사주를 맞추는 콘텐츠는 있는데 정작 내 사주는 볼 수 없었다. 내 사주는 어떤지 궁금해지는 게 자연스러운 흐름인데 갈 데가 없었다. 별도 페이지를 만드는 대신 어느 화면에서든 열리는 플로팅 챗봇으로 붙였다.

만세력 계산은 패키지에 맡겼다

npm에 사주 패키지가 몇 개 있는데 manseryeok을 골랐다. 타입 정의가 패키지에 들어 있고, calculateFourPillars(BirthInfo) 하나로 사주팔자와 오행, 음양이 전부 나오고, 시(時)만이 아니라 분(分)까지 계산하고, 1900년부터 2100년까지 지원한다.

원본 타입은 풍부한데 화면에서 쓰기엔 과했다.

// 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;    // 음양
};

기둥 하나에 세 객체로 흩어져 있던 걸 평평한 타입 하나로 모았다. 화면에서 pillar.element로 바로 꺼낼 수 있다.

lib/saju.ts에는 server-only를 안 붙였다. 사주 계산은 순수 연산이라 클라이언트에서 돌려도 문제가 없고, 나중에 실제로 클라이언트에서 쓰게 된다. AI 해석을 담당하는 lib/gemini-saju.ts만 서버 전용으로 잠갔다.

태어난 시간을 모르는 사람이 많아서 hour를 optional로 뒀다. 입력하면 네 기둥을 다 계산하고, 안 하면 세 기둥만 계산해서 시주 칸에 물음표를 띄운다. AI 프롬프트에도 시주가 없다고 명시해서 세 기둥만으로 해석하게 했다. 이걸 안 적으면 없는 시주를 있는 것처럼 지어낸다.

해석은 기존 채점 코드의 패턴을 그대로 따랐다

에세이 채점에 쓰던 lib/gemini.ts와 같은 뼈대로 짰다. 클라이언트 인스턴스를 만드는 함수, 프롬프트 인젝션 방어용 이스케이프, 프롬프트 조립, JSON 파싱과 필드 검증, 30초 타임아웃, 응답을 JSON으로 강제하는 설정, 실패 시 null 반환까지 같다. 이미 한 번 다듬어둔 흐름이라 새로 고민할 게 없었다.

처음엔 여섯 섹션이었는데 오늘의 운세를 넣어 일곱이 됐다.

type SajuInterpretation = {
  overview: string;       // 종합운
  personality: string;    // 성격
  career: string;         // 직업·재물
  relationships: string;  // 대인관계
  dailyFortune: string;   // 오늘의 운세
  yearFortune: string;    // 올해 운세
  advice: string;         // 조언
};

AI가 실패하면 사주팔자 계산 결과만 돌려준다. 팔자 카드는 그대로 보여주고 해석 자리에만 안내 문구를 띄운다. 에세이 채점처럼 키워드 폴백을 두지는 않았다. 사주 해석은 AI 없이 대체할 방법이 없다.

rate limit은 인메모리 Map으로 간단히 걸었다. 채점 API는 Supabase에 기록이 남아서 DB 기반으로 세는데, 사주 해석은 저장할 게 없다.

// IP당 10분에 3회
const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000;
const RATE_LIMIT_MAX = 3;
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();

1분마다 만료된 항목을 지운다. 안 지우면 IP가 쌓이는 만큼 Map이 계속 커진다. 서버리스에서 콜드 스타트되면 초기화되지만 남용을 늦추는 목적으로는 충분하다.

입력은 API에서 먼저 범위를 검사한다. 연도 19002100, 월 112, 일 131, 시 023에 성별. 2월 30일 같은 건 범위 검사를 통과하니 manseryeok이 던지는 에러를 잡아서 400으로 돌려준다.

FAB 아이콘을 세 번 갈아엎었다

처음엔 Phosphor의 Sparkle을 썼는데 제미나이 로고와 너무 비슷했다. 프로젝트 로고 이미지로 바꿨더니 실사 스타일이라 56px 원 안에서 부담스러웠다. 결국 占 한자에 그라디언트를 깔았다.

background: radial-gradient(circle at 30% 30%, #c084fc, #7c3aed 50%, #4c1d95);
box-shadow: 0 0 20px rgba(168, 85, 247, 0.4);

원 중심이 아니라 좌상단 30% 지점을 그라디언트 중심으로 잡아서 빛이 위에서 오는 것처럼 보이게 했다.

컴포넌트는 이렇게 나눴다.

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섹션

모바일에서는 처음에 380px 폭 패널을 그대로 썼는데, 셀렉트 드롭다운이 열리면 공간이 모자랐다. 풀스크린 모달로 바꿨다.

// 데스크톱: 플로팅 패널
'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으로 숨긴다. 화면을 다 덮었는데 그 위에 열기 버튼이 떠 있을 이유가 없다.

드롭다운이 패널에 잘렸다

네이티브 <select>의 드롭다운은 OS가 그려서 다크 테마가 안 먹는다. 커스텀 셀렉트를 만들었더니 다른 문제가 생겼다. absolute로 띄운 드롭다운이 패널의 overflow-y-auto에 잘렸다.

부모에 overflow: hidden이나 auto가 걸려 있으면 자식이 아무리 absolute여도 그 경계를 못 넘는다. createPortaldocument.bodyfixed로 렌더링해서 빠져나왔다.

트리거 버튼의 getBoundingClientRect()로 화면상 위치를 구하고, 그 좌표에 맞춰 뷰포트 기준으로 띄운다. 바깥 클릭과 ESC로 닫히고, 열릴 때 현재 선택된 항목으로 스크롤한다. 1900부터 2100까지 있는 연도 셀렉트에서 이게 없으면 매번 맨 위부터 찾아야 한다.

사주팔자 카드는 4열 그리드로 시주·일주·월주·년주를 놓고 각 열에 천간과 지지를 위아래로 배치했다. 오행별로 색을 다르게 줬다. 목은 초록, 화는 빨강, 토는 노랑, 금은 회색, 수는 파랑이다. 시주를 모르면 첫 열에 물음표를 띄운다.

파비콘도 같은 방식으로 만들었다

기존 파비콘은 실사 풍 오행 소용돌이 로고였는데 16px로 줄면 뭉개져서 뭔지 알 수 없었다. 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가 알아서 찾아간다.

어제의 오늘 운세가 오늘도 떠 있었다

일곱 섹션 전부를 AI가 생성하고 localStorage에 캐싱하고 있었다. 그런데 오늘의 운세와 올해 운세는 날짜가 바뀌면 같이 바뀌어야 하는 값이다. 캐시를 그대로 쓰니 어제 받은 운세가 오늘도 그대로 나왔다.

매번 API를 다시 부르면 10분에 3회 제한에 금방 걸린다.

해석을 안 바뀌는 것과 바뀌는 것으로 갈랐다. 종합운·성격·직업·대인관계·조언 다섯은 평생 안 바뀌니 AI가 만들고 캐싱한다. 오늘 운세와 올해 운세 둘은 클라이언트에서 계산한다.

계산은 십신(十神) 관계로 한다. 내 일간과 오늘 천간의 오행이 서로 생하는지 극하는지, 음양이 같은지 다른지를 보면 열 가지 관계 중 하나가 나온다.

getSipsin('목', '양', '화', '양') // → '식신' (내가 생 + 같은 음양)
getSipsin('목', '양', '금', '양') // → '편관' (나를 극 + 같은 음양)

관계가 정해지면 거기 맞는 운세 템플릿을 붙인다. 규칙이 명확해서 AI를 부를 이유가 없는 계산이었다.

타입도 그에 맞춰 쪼갰다.

type SajuPermanentInterpretation = { overview, personality, career, relationships, advice };
type SajuTemporalFortune = { dailyFortune, yearFortune };
type SajuInterpretation = SajuPermanentInterpretation & SajuTemporalFortune; // 렌더링용

API는 앞의 것만 반환하고 클라이언트가 뒤의 것을 합쳐 일곱 섹션을 만든다.

기존 캐시에는 version 필드가 없고 운세 두 개가 들어 있다. 그걸 그대로 읽으면 또 옛날 운세가 뜬다. version 필드가 없으면 예전 캐시로 보고 운세 필드를 떼어낸 뒤 version: 2로 올린다.

한국어 조사 때문에 템플릿이 지저분해졌다

운세 문장에 오행 이름이 들어가는데 받침 유무에 따라 조사가 달라진다. 목은 "목이", 화는 "화가"다. 조건 분기를 문자열마다 넣으면 템플릿이 읽을 수 없게 된다.

josa 라이브러리를 넣어 플레이스홀더로 처리했다.

josa(`${meElement}#{이} ${otherElement}#{을} 극하는`)
// '목이 토를 극하는' / '화가 금을 극하는'

나갔다 오면 처음부터 다시 입력해야 했다

사주를 보고 페이지를 떠났다 돌아오면 결과가 사라져 입력 폼부터 다시였다. 같은 생년월일로 또 요청하니 API도 낭비였다.

별도 훅이나 컨텍스트 없이 reducer 안에서 캐시를 다뤘다.

// 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);

useReducer의 세 번째 인자는 초기 상태를 만드는 함수다. 첫 렌더에 한 번만 실행되기 때문에 여기서 localStorage를 읽으면 useEffect로 읽고 다시 setState하는 과정 없이 처음부터 결과 화면으로 들어간다. 잠깐 입력 폼이 보였다가 결과로 바뀌는 깜빡임이 없다.

캐시는 최신 한 건만 둔다. 생년월일은 안 바뀌니 한 사람당 하나면 충분하다.

loadCachesaveCache, clearCache는 전부 try-catch로 감쌌다. Safari 프라이빗 모드에서는 localStorage 쓰기가 실패하고 용량 초과도 날 수 있다. 실패하면 캐시 없이 동작할 뿐 기능은 그대로다.

막힌 지점과 원인

문제원인해결
어제 운세가 오늘도 표시됨날짜에 따라 바뀌는 값까지 캐싱불변 5섹션은 AI 캐싱, 가변 2섹션은 십신 기반 클라이언트 계산
옛 캐시를 읽으면 다시 옛 운세캐시에 버전 구분이 없음version 없으면 운세 필드 제거 후 v2로 승격
셀렉트 드롭다운이 패널에 잘림부모의 overflow-y-auto가 자식 absolute를 자름createPortal로 body에 fixed 렌더링
모바일에서 드롭다운 공간 부족380px 패널을 모바일에서도 사용max-sm:inset-0 풀스크린 모달로 전환
파비콘이 작아지면 식별 불가실사 로고를 16px로 축소ImageResponse로 占 문자 동적 생성
조사 분기로 템플릿이 지저분받침 유무에 따라 조사가 달라짐josa 라이브러리 플레이스홀더
재방문 시 결과가 사라짐상태가 메모리에만 존재reducer 초기화 함수에서 localStorage 로드

추리 결과를 저장해 통계와 공유 URL을 만드는 일, 문제 해설 품질 보강, 힌트와 정답 겹침을 자동으로 검사하는 스크립트가 남았다. 사주 해석 결과를 공유하는 기능도 아직 없다.