사주고사 제작기 01. 사주 실력을 채점하는 모의고사 웹앱
내 사주 실력은 몇 점일까. 허구 인물의 사주를 풀이하면 AI가 채점해주는 모의고사를 만들었다. 사주고사에 올려뒀다.
기존 서비스는 전부 결과만 보여줬다
사주에 관심 있는 사람은 많은데 실력을 측정할 수 있는 서비스는 없었다. 기존 서비스는 대부분 생년월일을 넣으면 해석을 돌려주는 입력과 출력 구조다. 사용자가 뭘 아는지는 평가되지 않는다.
방향을 뒤집었다. 결과를 보여주는 대신 사용자가 직접 풀이하게 만든다. 허구 인물의 정보를 주고 사용자가 사주 풀이를 쓰면 AI가 채점하는 모의고사 구조다.
객관식을 다 들어냈다
처음 설계는 야심찼다. 1단계에서 사주팔자 4지선다와 오행 분포, 용신 판단으로 40점, 2단계에서 성격과 직업운, 대인관계를 서술로 60점. 100점 만점에 S부터 D까지 등급을 매기는 본격적인 시험이었다.
만들다 보니 이걸 누가 재미있어 할까 싶었다. 팔자를 4지선다로 맞추는 건 만세력 앱을 켜면 끝난다. 오행 분포는 그냥 계산이다. 사주를 공부하는 사람에게는 의미가 있어도 가볍게 즐기기엔 진입장벽이 높았다.
객관 파트를 전부 들어내고 질문 하나에 에세이로 답하는 구조로 바꿨다.
기존: 년주를 고르세요 → 오행 개수를 넣으세요 → 용신은? → 성격을 서술하세요
변경: "이 사람은 어떻게 죽었을까?" → 자유롭게 서술질문 자체가 콘텐츠가 됐다. "이 사람은 부자일까 거지일까", "이 사람의 연애와 결혼은" 같은 질문은 사주를 몰라도 호기심이 생긴다. 사주 지식이 있으면 더 정확하게 쓸 수 있고, 없어도 프로필 정보만으로 추리할 수 있다. 글 하나 쓰면 되니 진입장벽이 낮고, 점수가 나오니 공유할 거리도 생긴다.
스택은 학습 목적과 배포 편의로 골랐다
| 레이어 | 선택 | 이유 |
|---|---|---|
| 프레임워크 | Next.js 15 (App Router) | RSC와 서버·클라이언트 분리 학습 |
| 스타일링 | Tailwind CSS v4 + shadcn/ui | 빠른 UI 개발, 다크 테마 기본 |
| 상태관리 | useState | 단일 페이지라 별도 라이브러리 불필요 |
| DB | 인메모리 Mock | MVP 우선, DB 연동은 후순위 |
| 배포 | Vercel | Next.js에 최적, 무료 플랜 |
| OG 이미지 | @vercel/og (Satori) | 동적 결과 카드 생성 |
Supabase 연동은 기획에 있었지만 MVP 단계에서는 과했다. 인메모리 Map으로 제출을 저장하고 문제 데이터는 TypeScript 파일에 하드코딩했다. 서버를 재시작하면 제출 데이터가 날아가지만 동작하는 프로토타입을 빨리 만드는 게 목표라 충분했다.
대신 나중에 갈아끼울 자리는 미리 만들어뒀다. repository 레이어를 async 함수로 감싸두면 내부 구현만 바꾸고 호출부는 그대로 둘 수 있다.
// 지금: 인메모리
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;
}지금은 동기적으로 찾을 수 있는데도 async로 만들어둔 게 요점이다. 나중에 네트워크 호출로 바뀌어도 호출부의 await가 이미 붙어 있다.
키워드로 채점하니 점수가 안 나왔다
처음에는 비용과 지연을 피하려고 키워드와 동의어 매칭으로 시작했다.
type Keyword = { term: string; synonyms: string[] };
{ term: '과로사', synonyms: ['과로', '일하다 쓰러', '무리', '밤새 일', '야근'] }사용자 에세이에서 string.includes()로 키워드 포함 여부를 확인하는 방식이다. 문제당 15개 키워드, 키워드당 7점, 최대 100점.
돌려보니 한계가 뚜렷했다. "일에 매몰되어 건강을 잃었다"라고 쓰면 과로사로 안 잡힌다. 동의어를 아무리 늘려도 한국어 표현의 가짓수를 따라갈 수 없다. 짧은 키워드도 문제였다. "간"이 "시간" 안에서 걸려 오탐이 났다. 부분 문자열 매칭이라 단어 경계를 모른다.
결국 Gemini API 채점으로 바꿨다. 사용자 에세이를 정답 해설과 함께 보내고 맥락을 이해한 채점 결과를 받는다. "일에 매몰되어 건강을 잃었다"도 과로사 맥락으로 잡힌다. Gemini Flash 기준 건당 10원에서 50원 수준이라 감당할 만했다.
난이도를 정보량으로 나눴다
쉬운 문제와 어려운 문제를 따로 만들지 않았다. 같은 인물인데 주는 프로필 정보량을 다르게 했다.
profile: {
easy: '서울 강북 출신. 편모 가정에서 자랐고, 고교 시절 수학 올림피아드 입상...(상세)',
medium: '서울 강북 출신. 편모 가정에서 자랐다. 대학교를 중퇴했고...(요약)',
hard: '서울 출신 남성.',
}입문은 배경 스토리만 읽어도 추리할 수 있고, 보통은 사주 지식이 어느 정도 필요하고, 고수는 성별과 출신지뿐이라 거의 순수 사주 실력으로 풀어야 한다. 문제를 세 배로 만들지 않고 난이도를 만들 수 있었다.
질문은 자극적으로, 해설은 근거 있게
"이 사람은 어떻게 죽었을까"라는 질문이 자극적으로 보일 수 있지만 정답 해설은 사주 원리에 기반한 추론이다.
壬水 일간이 봄(卯月)에 태어나 목(木)의 기운이 왕성하고, 火 기운이 극도로 부족한 사주입니다. 火는 심장과 혈관을 관장하므로 선천적으로 심혈관계가 취약합니다...
질문으로 흥미를 끌고 해설에서 근거를 제시해 학습 효과를 만드는 구조다. 지금은 김하늘과 이서연 두 캐릭터에 각 3개씩 총 6문제가 있다.
정답을 클라이언트로 안 보내는 구조
App Router의 RSC(React Server Components, 서버에서만 실행되고 클라이언트 번들에 포함되지 않는 컴포넌트)를 썼다. 시험 페이지가 좋은 예다.
quiz/[questionId]/
├── page.tsx ← 서버 컴포넌트 (데이터 fetch)
└── quiz-client.tsx ← 클라이언트 컴포넌트 (상태 관리, 인터랙션)page.tsx에서 문제와 캐릭터 데이터를 가져와 quiz-client.tsx에 props로 넘긴다. 클라이언트는 에세이 입력과 제출만 맡는다.
여기서 중요한 건 정답 데이터를 안 넘긴다는 것이다. 키워드와 해설은 서버 컴포넌트 안에만 있어서 클라이언트 번들에 포함되지 않는다. 전부 클라이언트 컴포넌트로 만들었다면 개발자 도구로 정답을 볼 수 있었다.
공유는 세 갈래로 만들었다
결과 공유가 이 서비스의 확산 경로라 세 가지를 붙였다. 클립보드 복사와 Kakao SDK 메시지 카드, 그리고 동적 OG 이미지다.
OG 이미지는 @vercel/og로 등급과 점수가 들어간 카드를 서버에서 생성한다. 카카오톡이나 트위터에 링크를 붙여넣으면 결과가 미리보기로 뜬다. 이미지를 미리 만들어둘 수 없는 종류라 요청 시점에 만들어야 했다.
남용을 막으려고 미들웨어에 IP 기반 rate limiting도 걸었다.
// middleware.ts
const WINDOW_MS = 60 * 1000; // 1분
const MAX_REQUESTS = 5; // 분당 최대 5회인메모리 Map을 쓰는 단순한 구현이다. 서버 인스턴스가 여러 개로 늘어나면 인스턴스마다 카운트가 따로 세어져서 실제 제한이 느슨해진다. 지금은 인스턴스가 하나라 문제없다.
남은 것
Supabase 연동과 문제 배치 생성 스크립트, Vercel 배포와 도메인 연결, Kakao SDK 앱 등록이 출시 전에 끝나야 한다.
출시 뒤로는 Analytics 연동, localStorage로 이미 푼 문제 제외하기, 결과 페이지 광고를 생각하고 있다. 문제를 100~200개로 늘리는 것과 랭킹 기능은 그다음이다.