2026년 운세는 AI 무당에게? Next.js 16과 Groq로 만든 '팩트폭력' 운세 심판관 개발기
목차
매번 같은 결과가 나오는 운세는 재미가 없다
개발자로서 매년 연초가 되면 한 번쯤 호기심을 갖게 되는 도메인이 바로 '운세'입니다. 하지만 시중에 나와 있는 운세 서비스들은 대부분 정해진 데이터베이스에서 텍스트를 조합해 보여주는 방식이라, 몇 번 하다 보면 패턴이 읽히고 금세 지루해지기 마련입니다. 무엇보다 "올해는 다 잘 될 거예요"라는 영혼 없는 위로보다는, 차라리 **"지금처럼 살면 내년에도 똑같습니다"**라고 정신이 번쩍 들게 조언해 주는 친구 같은 서비스가 있으면 좋겠다는 생각이 들었습니다.
이 프로젝트는 "무당(Shaman) 페르소나를 가진 AI가 내 고민을 듣고 즉석에서 신탁을 내려준다면 어떨까?"라는 엉뚱한 상상에서 시작되었습니다. 기술적으로는 최근 화두가 되고 있는 Groq의 압도적인 인퍼런스(Inference) 속도를 직접 검증해보고 싶었고, 최신 Next.js 16 환경에서 생성형 AI를 어떻게 실무 레벨로 통합할 수 있을지 실험해보고자 했습니다.
단순한 토이 프로젝트로 시작했지만, 결과적으로는 'AI의 환각(Hallucination) 제어'와 '사용자 경험(UX) 최적화'라는 두 마리 토끼를 잡기 위해 꽤 치열하게 고민했던 과정을 공유하려 합니다.
무엇을 만들었나
서비스의 핵심은 '속도'와 '재미'입니다. 사용자가 지루함을 느낄 틈을 주지 않도록 설계했습니다.
등급을 붙인 이유
사용자가 입력하는 건 세 가지뿐입니다. 이름, 태어난 해, 그리고 지금 가장 마음에 걸리는 분야(재물·연애·커리어·건강 중 하나). 생년월일이나 성별은 받지 않습니다. 사주를 계산하지 않는데 계산에 쓸 것처럼 정보를 받는 건 속이는 것에 가깝다고 봤습니다.
기존 운세와 가장 큰 차별점은 결과가 게임 아이템 등급처럼 SSR(Super Special Rare), SR, R, N으로 나뉜다는 점입니다.
- SSR (10% 확률): AI가 인정하는 최고의 행운. 이걸 뽑으면 하루 종일 기분이 좋습니다.
- N (Normal): "노력하세요"라는 시니컬한 조언이 담긴, 꽝에 가까운 등급입니다.
단순히 텍스트만 보여주는 것이 아니라, 재물·연애·건강·일·행운 다섯 축을 0~100점으로 환산해 레이더 차트로 보여줍니다.
결과를 카드로 만든 이유
결과는 단순히 휘발되지 않고, 마치 게임에서 레어 카드를 뽑은 듯한 비주얼로 제공됩니다. html2canvas를 활용해 결과 화면을 즉시 이미지로 렌더링 하여, 사용자가 "나 SSR 떴음!" 하고 친구들에게 자랑하기 쉽게 만들었습니다. 여기에 "와이파이", "배달 쿠폰" 같은 현대적인 행운의 아이템을 매칭하여 소소한 위트도 놓치지 않았습니다.
무엇으로 만들었나
트렌디하면서도 실용적인 기술셋을 구성하기 위해 고민했습니다.
-
Next.js 16 (App Router): 현재 프론트엔드 씬의 표준입니다. 특히 이번 프로젝트에서는 서버 컴포넌트(RSC)를 적극 활용하여, API 키 노출 없이 안전하게 서버 사이드에서 LLM을 호출하고 결과를 클라이언트로 스트리밍 하기 위해 선택했습니다.
-
Groq SDK (Model: LLaMA 3.3 70B): 이 프로젝트의 '치트키'입니다. GPT-4o도 훌륭하지만, 실시간 상호작용이 중요한 엔터테인먼트 서비스에서 응답 대기 시간은 치명적입니다. Groq는 초당 수백 토큰을 쏟아내는 미친 속도를 보여주며, 사용자가 "로딩 중"을 느낄 새도 없이 결과를 보여줍니다. LLaMA 3.3 70B 모델은 한국어 이해도도 수준급이라 "시니컬한 무당" 페르소나를 완벽하게 소화했습니다.
-
Vanilla Extract: Zero-runtime CSS-in-JS 라이브러리입니다. 스타일드 컴포넌트의 편리함을 가져가면서도 런타임 오버헤드가 없고, 무엇보다 TypeScript와 결합했을 때의 타입 추론이 강력합니다. 테마 변수 관리가 용이해 다크 모드나 카드 디자인 시스템을 구축할 때 큰 도움을 받았습니다.
-
Zod & TypeScript: LLM의 출력을 신뢰할 수 있는 데이터 구조로 변환하기 위한 필수 도구입니다. 이 부분은 아래 딥 다이브에서 자세히 다루겠습니다.
AI가 약속을 어길 때를 대비하기
생성형 AI를 서비스에 붙일 때 가장 골치 아픈 문제는 **'출력의 불확실성'**입니다. 개발자는 AI에게 "반드시 JSON 포맷으로 줘"라고 명령하지만, AI는 가끔 마크다운 코드 블록(```json)을 씌워서 주거나, 심지어는 JSON 문법을 틀리기도 합니다. 운이 나쁘면 약속된 키 값(user_name)을 빼먹고 엉뚱한 키(name)를 보내기도 하죠. 이는 클라이언트에서 Undefined 에러를 유발하여 흰 화면을 보여주는 참사로 이어집니다.
이 과정을 해결하기 위해 저는 **이중 방어막(Double Defense Layer)**을 구축했습니다.
1단계: 모델 레벨의 강제 (Json Mode)
Groq API 호출 시 response_format: { type: 'json_object' } 옵션을 활성화하여 모델이 강제로 유효한 JSON 문자열만 뱉도록 설정했습니다.
2단계: 런타임 스키마 검증 (Zod Validation)
JSON으로 받았다고 끝이 아닙니다. 내용물이 우리가 원하는 스키마(Schema)와 일치하는지 확인해야 합니다. 저는 Zod를 이용해 들어오는 데이터를 현관문 앞에서 철저히 검문 검색했습니다.
// src/services/fortune.service.ts 실제 코드 로직
// 1. 우리가 원하는 데이터의 '모양'을 정의합니다.
const FortuneSchema = z.object({
tier: z.enum(['SSR', 'SR', 'R', 'N']), // 이 4개 중 하나가 아니면 에러!
main_copy: z.string(),
power_score: z.object({
money: z.number().min(0).max(100), // 점수가 105점? 에러!
love: z.number().min(0).max(100),
// ...
}),
// ...
});
// ... Service 로직 내부
try {
// 2. AI의 응답을 파싱하고 검증합니다.
const json = JSON.parse(completionContent);
const validated = FortuneSchema.safeParse(json);
// 3. 검증 실패 시 셧다운 대신 '우아한 실패'를 처리합니다.
if (!validated.success) {
console.error('AI가 이상한 데이터를 줬습니다:', validated.error);
return FALLBACK_DATA; // "신령님이 잠시 혼란스러워 하십니다" 같은 기본값 반환
}
return validated.data; // 완벽하게 타입이 보장된 데이터
} catch (error) {
// ...
}이 패턴을 적용한 덕분에, AI가 가끔 tier: "S등급" 처럼 엉뚱한 값을 뱉더라도 서비스가 멈추지 않고 안전한 기본값(Fallback)으로 대체되어 사용자 경험을 해치지 않게 되었습니다. 가장 좋은 에러 핸들링은 사용자가 에러가 났다는 사실조차 모르게 하는 것임을 다시 한번 깨달았습니다.
실패했을 때 무엇을 보여줄 것인가
검증층을 만들면서 같이 고민한 게 검증에 실패했을 때 뭘 내보낼 것인가였습니다.
보통 이럴 때 "오류가 발생했습니다. 잠시 후 다시 시도해 주세요"를 띄웁니다. 그런데 이 서비스에서 그 화면이 뜨는 순간, 낡은 오락실 기계 연출로 쌓아 둔 분위기가 통째로 깨집니다. 코인을 넣었는데 기계가 에러 코드를 뱉는 셈입니다.
그래서 폴백 데이터를 캐릭터를 유지한 채로 만들었습니다.
const FALLBACK_DATA: Fortune = {
tier: 'R',
tier_text: '운수 좋은 날?',
main_copy: '나쁘진 않은데 좋지도 않아.',
detail: '신령님이 잠시 자리를 비우셨나봅니다. 평범한 하루가 예상되니 무리하지 마세요. 그래도 접속은 성공했으니 운이 없지는 않군요.',
lucky_items: [
{ name: '새로고침', icon: '🔄' },
{ name: '와이파이', icon: '📶' },
],
power_score: { money: 50, love: 50, health: 50, work: 50, luck: 50 },
};등급은 한가운데인 R로, 다섯 축 점수는 전부 50으로 뒀습니다. 어느 쪽으로도 치우치지 않아야 "일부러 나쁘게 나온 것"으로 오해받지 않기 때문입니다. 럭키 아이템에 새로고침과 와이파이를 넣은 건 농담이지만, 사용자가 실제로 해야 할 행동을 은근히 알려주는 역할도 합니다.
에러 화면도 제품의 일부라는 걸 이때 정리했습니다. 사용자는 실패했다는 사실보다 실패했을 때 서비스가 어떻게 반응하는지를 기억합니다.
페르소나는 프롬프트로 되는데, 확률은 안 됐다
AI에게 단순히 "운세를 봐줘"라고 하면 너무 점잖고 교과서적인 답변만 돌아옵니다. 재미 요소를 위해 시스템 프롬프트(System Prompt)에 아주 구체적인 지시를 내렸습니다.
"너는 냉소적이지만 능력 있는 AI 무당이다. 말투는 시니컬하고 위트 있어야 하며, 약간의 신비로움을 풍겨야 한다. 무조건 좋은 말만 하지 말고, SSR 등급은 10% 확률로만 부여해라."
이 과정에서 여러 페르소나를 테스트했고, 사용자가 "뼈 맞았다"고 느끼면서도 기분 나쁘지 않을 정도의 **'적당한 매운맛'**을 찾아냈습니다. 어조를 잡는 데는 프롬프트가 잘 먹혔습니다.
그런데 "SSR은 10%만"은 지켜지지 않았다
문제는 같은 프롬프트에 넣은 다른 지시였습니다. SSR 등급은 10% 확률로만 부여해라라는 문장이요.
돌려 보면 SSR이 훨씬 자주 나옵니다. 당연한 일이었습니다. 언어 모델은 확률 분포를 관리하지 않습니다. 매 요청은 이전 요청을 모르고, 지금까지 SSR을 몇 번 줬는지도 모릅니다. "10%"라는 단어는 모델에게 통계적 제약이 아니라 그냥 문맥의 일부입니다. 게다가 생성 온도를 0.85로 높게 잡아 뒀으니 출력은 더 흔들립니다.
프롬프트로 통제할 수 있는 것과 없는 것의 경계가 여기 있었습니다.
- 되는 것: 어조, 문체, 출력 형식, 다뤄야 할 주제, 하지 말아야 할 표현
- 안 되는 것: 확률 분포, 여러 요청에 걸친 일관성, 정확한 수치 계산, 개수 세기
제대로 하려면 등급을 코드에서 먼저 뽑고 모델에게는 그 결과를 알려주는 방향이어야 합니다.
// 이렇게 했어야 했다
const tier = rollTier(); // 확률 분포는 코드가 소유
// SSR 10% / SR 25% / R 40% / N 25%
const prompt = `이 사용자의 등급은 ${tier}다. 그 등급에 어울리는 문장을 써라.`;이러면 분포는 정확히 지켜지고 모델은 자기가 잘하는 일(문장 쓰기)만 합니다. 모델에게 판단을 맡길 것과 코드가 쥐고 있어야 할 것을 나누는 것, 그게 이 프로젝트에서 제일 늦게 배운 것입니다. Zod로 출력 형식을 검증하는 데까지는 신경을 썼으면서, 정작 값 자체의 분포는 모델을 믿고 맡겨 뒀던 셈입니다.
아직 안 고쳤습니다. 오락용 서비스라 등급 분포가 틀려도 크게 문제될 건 없지만, 만약 이게 보상이 걸린 가챠였다면 심각한 문제였을 겁니다.
온도 0.85가 가져온 다른 비용
생성 온도를 높게 잡은 건 의도적이었습니다. 같은 사람이 다시 돌려도 다른 결과가 나와야 한다는 게 이 서비스의 전제니까요. 미리 써 둔 문장을 고르는 기존 운세 서비스와 다른 지점이 정확히 여기입니다.
대신 대가가 있었습니다.
- 캐싱을 못 합니다. 같은 입력에 같은 출력을 보장할 수 없으니 캐시가 의미를 잃습니다. 요청마다 모델을 호출해야 하고 그만큼 비용이 나갑니다.
- 버그를 재현하기 어렵습니다. "이상한 문장이 나왔다"는 제보를 받아도 같은 입력으로 재현이 안 됩니다. 그래서 검증 실패 로그를 남기는 게 더 중요해졌습니다.
- 테스트를 쓸 수 없습니다. 출력이 매번 달라지니 스냅샷 테스트가 불가능합니다. 검증할 수 있는 건 "스키마를 통과하는가"까지입니다.
결국 Zod 검증층이 테스트를 대신하는 구조가 됐습니다. 출력이 비결정적인 시스템에서는 결과를 검사하는 대신 결과의 형태를 검사한다는 것, 이게 실무에서 LLM을 붙일 때 가장 크게 달라지는 부분이라고 생각합니다.
코드에 남은 흔적: 받지 않기로 한 정보
정리하다가 발견한 게 하나 있습니다. 서버로 넘어가는 사용자 컨텍스트를 만드는 부분이 이렇게 돼 있습니다.
const { name, birthDate, gender, concern } = userData;
const userContext = `
Name: ${name || 'Anonymous'}
Birth: ${birthDate || 'Unknown'}
Gender: ${gender || 'Unknown'}
Concern: ${concern || 'General'}
`;birthDate와 gender가 있습니다. 그런데 화면에서는 이 둘을 묻지 않습니다. 항상 Unknown이 들어갑니다.
처음 설계할 때는 생년월일과 성별까지 받을 생각이었습니다. 운세 서비스라면 당연히 받는 정보니까요. 그러다 방향을 바꿨습니다. 사주를 계산하지 않으면서 사주에 필요한 정보를 받는 건 속이는 것에 가깝다고 판단했기 때문입니다. 생년월일을 입력하게 만들면 사용자는 그걸로 뭔가 계산이 이뤄진다고 믿습니다. 실제로는 문자열 하나가 프롬프트에 끼워지는 게 전부인데도요.
그래서 입력 화면에서 두 항목을 뺐습니다. 다만 서비스 코드의 구조체는 그대로 남았고, 지금은 아무 값도 채워지지 않는 필드로 지나갑니다.
이건 잘못된 게 아니라 결정의 흔적입니다. 그리고 이런 흔적은 시간이 지나면 위험해집니다. 나중에 누군가가 "이 필드가 비어 있네, 채워 넣어야겠다"고 생각하는 순간, 처음에 왜 뺐는지는 사라지고 없습니다. 그래서 지금은 정리해서 지우는 쪽이 맞다고 보고 있습니다. 개인정보를 안 받기로 한 결정은 코드에 자리를 남겨 두는 방식이 아니라, 그 자리를 없애는 방식으로 지켜져야 하니까요.
수집하지 않기로 한 것을 코드에서도 지우는 것. 개인정보 최소 수집이라는 게 결국 그런 사소한 정리의 반복이라는 생각이 듭니다.
남은 것
LLM을 서비스에 붙이면서 배운 걸 한 줄로 줄이면 이렇습니다. 모델은 문장을 쓰는 데 쓰고, 판단은 코드가 쥔다.
Zod 검증층을 만들 때는 이 원칙을 잘 지켰습니다. 출력 형식을 모델에게 맡기지 않고 코드가 검문했으니까요. 그런데 등급 확률에서는 같은 실수를 했습니다. "10%로 해줘"라고 부탁하고 지켜질 거라 믿었습니다. 형식은 의심하면서 값은 의심하지 않은 겁니다.
만들면서 하나 더 정리된 게 있습니다. 이 서비스는 운세를 흉내 내되 운세로 믿기는 어렵게 만드는 게 목적이었습니다. 그래서 사주 계산에 필요한 정보를 일부러 받지 않았고, 같은 입력에 다른 결과가 나오게 뒀습니다. 다시 돌리면 SSR이 N이 되는 걸 보면 누구도 이걸 예언으로 받아들이지 않습니다. 오락용 서비스에서 그 선을 어떻게 지킬지가, 기술적 난제보다 오래 고민한 부분이었습니다.