본문으로 건너뛰기

사주고사 제작기 01. 사주 실력을 채점하는 모의고사 웹앱

·15 min read·1 / 5

내 사주 실력은 몇 점일까? 허구 인물의 사주를 풀이하면 AI가 채점해주는 사주 모의고사를 만들었다.


왜 만들었나

사주(四柱)에 관심 있는 사람은 많은데, 실력을 측정할 수 있는 서비스는 없었다. "내가 사주를 얼마나 잘 보는 걸까?"를 확인할 방법이 마땅치 않다.

기존 사주 서비스는 대부분 입력 → 결과 출력 구조다. 사용자가 생년월일을 넣으면 기계적으로 해석을 뱉어주는 방식. 실력 평가의 요소는 전혀 없다.

그래서 방향을 뒤집었다. 결과를 보여주는 게 아니라, 사용자가 직접 풀이하게 만들자. 허구 인물의 정보를 주고, 사용자가 사주 풀이를 쓰면, AI가 채점하는 모의고사 구조다.


기획: 두 번의 피봇

1차 기획 — 객관+서술 2단계 시험

처음 설계는 꽤 야심찼다.

  • STEP 1 (객관 40점): 사주팔자 4지선다, 오행 분포 선택, 용신 판단
  • STEP 2 (서술 60점): 성격/기질, 직업/재물운, 대인관계/건강을 각각 텍스트로 서술

100점 만점에 S/A/B/C/D 등급을 매기는 본격적인 시험 구조였다.

문제: 너무 교과서적이다

만들다 보니 한 가지 의문이 들었다. "이걸 누가 재미있어 할까?"

팔자를 4지선다로 맞추는 건 만세력 앱을 켜면 끝이다. 오행 분포 선택? 그냥 계산이다. 사주를 공부하는 사람한테는 의미가 있을 수 있지만, 가벼운 심심풀이로 즐기기엔 진입장벽이 높았다.

2차 피봇 — "이 사람은 어떻게 죽었을까?"

객관 파트를 전부 들어내고, 도발적인 시나리오 질문 하나에 에세이로 답하는 구조로 바꿨다.

기존: 년주를 고르세요 → 오행 개수를 넣으세요 → 용신은? → 성격을 서술하세요 → ...
변경: "이 사람은 어떻게 죽었을까?" → 자유롭게 서술

질문 자체가 콘텐츠가 됐다. "이 사람은 부자일까 거지일까?", "이 사람의 연애와 결혼은?" 같은 질문은 사주를 모르는 사람도 호기심을 느낄 수 있다. 사주 지식이 있으면 더 정확하게 쓸 수 있고, 없어도 프로필 정보만으로 추리할 수 있다.

핵심 변화:

  • 진입장벽 ↓ (누구나 글 하나 쓰면 됨)
  • 콘텐츠성 ↑ (질문 자체가 흥미로움)
  • 공유 가치 ↑ ("나 85점 나옴 ㅋㅋ" → 바이럴)

기술 선택

레이어선택이유
프레임워크Next.js 15 (App Router)RSC + 서버/클라이언트 분리 학습
스타일링Tailwind CSS v4 + shadcn/ui빠른 UI 개발, 다크 테마 기본
상태관리React useState단일 페이지 모의고사라 복잡한 상태관리 불필요
DB인메모리 Mock (→ Supabase 예정)MVP 우선, DB 연동은 후순위
배포VercelNext.js 최적, 무료 Hobby 플랜
OG 이미지@vercel/og (Satori)동적 결과 카드 이미지 생성

왜 DB 없이 시작했나

Supabase 연동은 기획에 있었지만, MVP 단계에서는 과했다. 인메모리 Map으로 submission을 저장하고, 문제 데이터는 TypeScript 파일에 하드코딩했다. 서버 재시작하면 제출 데이터는 날아가지만, **"동작하는 프로토타입을 빨리 만든다"**가 목표였으니 충분했다.

repository 레이어를 async 함수로 감싸둬서, 나중에 Supabase로 교체할 때 호출부는 건드리지 않아도 된다.

// 지금: 인메모리
export async function getQuestionById(id: string): Promise<Question | null> {
  return mockQuestions.find((q) => q.id === id) ?? null;
}
 
// 나중에: Supabase
export async function getQuestionById(id: string): Promise<Question | null> {
  const { data } = await supabase.from('questions').select().eq('id', id).single();
  return data;
}

채점 시스템: 키워드 매칭에서 AI 채점으로

1차 — 키워드 매칭 (MVP)

처음에는 비용과 지연을 피하기 위해 키워드 + 동의어 매칭으로 시작했다.

type Keyword = { term: string; synonyms: string[] };
 
// 예시
{ term: '과로사', synonyms: ['과로', '일하다 쓰러', '무리', '밤새 일', '야근'] }

사용자가 쓴 에세이에서 string.includes()로 키워드 포함 여부를 확인하는 단순한 방식이었다. 문제당 15개 키워드, 키워드당 7점, 최대 100점.

문제: 점수가 잘 안 나온다

키워드 매칭의 근본적 한계가 드러났다:

  • 같은 의미를 다르게 표현하면 못 잡음 ("일에 매몰되어 건강을 잃었다" → "과로사" 미매칭)
  • 동의어를 아무리 늘려도 한국어 표현의 다양성을 커버할 수 없음
  • 짧은 키워드("간")가 다른 단어("시간") 안에서 오탐되는 문제

2차 전환 — Gemini API 채점

결국 Gemini API를 활용한 LLM 채점으로 전환했다. 사용자의 에세이를 정답 해설과 함께 Gemini에 보내고, 맥락을 이해한 채점 결과를 받는 구조다.

  • 맥락 이해: "일에 매몰되어 건강을 잃었다"도 과로사 맥락으로 정확히 채점
  • 비용: Gemini Flash 기준 건당 ₩10~50 수준으로 충분히 감당 가능
  • 사용자 경험: 점수가 합리적으로 나오니 만족도와 공유율 모두 상승

난이도 시스템: 같은 인물, 다른 정보량

난이도를 단순히 "쉬운 문제 / 어려운 문제"로 나누지 않았다. 같은 인물인데 제공하는 프로필 정보량이 다르다.

profile: {
  easy: '서울 강북 출신. 편모 가정에서 자랐고, 고교 시절 수학 올림피아드 입상...(상세)',
  medium: '서울 강북 출신. 편모 가정에서 자랐다. 대학교를 중퇴했고...(요약)',
  hard: '서울 출신 남성.',
}
  • 입문: 상세한 배경 스토리 → 프로필만 읽어도 추리 가능
  • 보통: 최소한의 배경 → 사주 지식이 어느 정도 필요
  • 고수: 성별과 출신지만 → 거의 순수 사주 실력으로 승부

이 구조 덕분에 사주를 모르는 사람도 "입문" 난이도에서 재미있게 풀 수 있고, 사주를 공부하는 사람은 "고수" 난이도에서 실력을 시험할 수 있다.


문제 콘텐츠: 도발적이되 근거 있게

문제의 핵심은 질문이 흥미롭고, 정답 해설이 사주학적으로 타당해야 한다는 것이다.

"이 사람은 어떻게 죽었을까?"라는 질문이 자극적으로 보일 수 있지만, 정답 해설을 보면 사주 원리에 기반한 논리적 추론이다:

壬水 일간이 봄(卯月)에 태어나 목(木)의 기운이 왕성하고, 火 기운이 극도로 부족한 사주입니다. 火는 심장과 혈관을 관장하므로 선천적으로 심혈관계가 취약합니다...

도발적인 질문으로 흥미를 끌되, 해설에서 사주학적 근거를 제시해 학습 효과를 만드는 구조다.

현재 2명의 캐릭터(김하늘, 이서연)에 각 3개 질문, 총 6문제가 있다. 추후 Claude API를 활용한 배치 생성 스크립트로 100~200개 문제를 사전 생성할 계획이다.


UI/UX: 다크 테마 + 미니멀

모바일 퍼스트, 다크 테마 기반의 미니멀한 디자인을 택했다.

디자인 포인트:

  • 보라~남색 그라데이션을 포인트 컬러로 (사주/점술의 신비로운 느낌)
  • glassmorphism 스타일 카드 (bg-white/[0.03] backdrop-blur-sm border-white/[0.06])
  • 부드러운 glow 배경 효과 (bg-purple-500/20 blur-[128px])
  • 에세이 작성 시 진행률 바 + 글자수 실시간 피드백

페이지 구성:

  1. 랜딩: 3단계 안내 + 난이도 선택 + 시작 버튼
  2. 시험: 인물 카드 + 질문 + 에세이 입력
  3. 결과: 점수/등급 + 매칭 키워드 하이라이트 + 해설 + 공유 버튼

서버/클라이언트 컴포넌트 분리

Next.js App Router의 RSC(React Server Components)를 적극 활용했다. 시험 페이지가 좋은 예시다:

quiz/[questionId]/
├── page.tsx          ← 서버 컴포넌트 (데이터 fetch)
└── quiz-client.tsx   ← 클라이언트 컴포넌트 (상태 관리, 인터랙션)

서버 컴포넌트(page.tsx)에서 문제와 캐릭터 데이터를 가져오고, 클라이언트 컴포넌트(quiz-client.tsx)에 props로 넘긴다. 클라이언트에서는 에세이 입력과 제출만 담당한다. 정답 데이터(키워드, 해설)는 서버에만 존재하므로 클라이언트에 노출되지 않는다.


공유 기능과 OG 이미지

결과 공유는 바이럴의 핵심이다. 세 가지 공유 방식을 구현했다:

1. 클립보드 복사

🔮 사주고사 결과
등급: A (85점)
"사주 고수" 인증!
 
나도 도전해보기 👉 [URL]

2. 카카오톡 공유 — Kakao SDK 연동으로 메시지 카드 전송

3. 동적 OG 이미지@vercel/og (Satori)로 등급 + 점수가 포함된 카드 이미지를 서버사이드에서 생성. 카카오톡이나 트위터에 링크를 붙여넣으면 결과가 미리보기로 뜬다.


Rate Limiting

남용 방지를 위해 Middleware에서 IP 기반 rate limiting을 구현했다. 분당 5회로 API 요청을 제한한다.

// middleware.ts
const WINDOW_MS = 60 * 1000; // 1분
const MAX_REQUESTS = 5;       // 분당 최대 5회

인메모리 Map을 사용한 단순한 구현이다. 프로덕션에서는 Vercel Edge Config나 Redis로 교체해야 하지만, MVP 단계에서는 충분하다.


남은 과제

단기 (출시 전)

  • Supabase DB 연동 (mock → 실 DB)
  • 문제 배치 생성 스크립트 (Claude API)
  • Vercel 배포 + 도메인 연결
  • Kakao SDK 앱 등록

중기 (출시 후)

  • Vercel Analytics 연동
  • localStorage로 풀었던 문제 exclude 처리
  • Google AdSense (결과 페이지)

장기

  • AI 상세 해설 (Claude Haiku) — 유저 풀이 vs 정답 비교 분석 유료 기능
  • 문제 100~200개로 확장
  • 랭킹/통계 기능

배운 것

  1. 피봇은 빠를수록 좋다. 객관 파트를 열심히 만들다가 "이거 재미없다"고 느꼈을 때 바로 방향을 틀었다. 덕분에 더 흥미로운 제품이 됐다.

  2. MVP에서 DB는 사치다. async 인터페이스만 맞춰두면 나중에 교체하기 어렵지 않다. 동작하는 프로토타입을 빨리 만드는 게 우선이다.

  3. 질문이 콘텐츠다. "성격을 서술하세요"보다 "이 사람은 어떻게 죽었을까?"가 100배 더 강력하다. 같은 채점 로직이라도 포장이 다르면 경험이 완전히 달라진다.

  4. MVP는 완벽할 필요 없다. 키워드 매칭으로 시작해서 동작을 검증하고, 한계를 확인한 뒤 Gemini API로 전환했다. 처음부터 AI 채점을 붙였으면 오버엔지니어링이었을 것이다.