AI를 안 쓰기로 했다
Article
DevTI의 카드 상세에는 "개인 맞춤 강점 분석"이 뜬다. 설문 응답(직군·스킬·관심사·목표·MBTI)을 넣으면 당신만의 칭호와 강점 세 가지, 숨은 무기, 성장 서사가 나온다.
지금 같으면 LLM API를 부르면 된다. 프롬프트 하나로 끝난다.
한 번도 안 불렀다. 문장 조각 124개와 가중치 규칙만으로 만들었다.
타협이 아니었다. 여러 축에서 그쪽이 나았다.
AI를 부르면 잃는 것들
LLM API 호출
프롬프트 하나면 끝
- 매 호출마다 결과가 다르다
- 환각(hallucination) 가능
- 호출당 과금
- 수 초 걸린다
- 설문 내용이 외부 서버로 나간다
- 왜 이 결과인지 설명할 수 없다
규칙 엔진
조각 124개 + 가중치
- 같은 입력이면 언제나 같은 출력
- 검증된 문장만 나간다 — 부적절 문구 0
- 0원 · 무한 확장
- 밀리초
- 데이터가 밖으로 안 나간다
- 왜 이 결과인지 추적 가능하다
특히 두 가지가 결정적이었다.
첫째, 결과가 매번 바뀌면 안 된다. DevTI의 카드는 자랑하고 공유하라고 만든 것이다. 링크를 열 때마다 내 칭호가 바뀌면 그건 내 카드가 아니다.
둘째, 사용자가 프로필을 반복해서 고친다. 설문을 여러 번 다시 하며 결과를 보는 구조다. LLM이라면 그때마다 과금되고, 어뷰징 방지도 따로 짜야 한다. 규칙 엔진은 몇 번을 다시 계산해도 0원이다.
그래서 분석 결과를 데이터베이스에 저장하지도 않는다. 같은 입력이면 같은 출력이니, 읽을 때마다 다시 계산하면 그만이다. 저장 안 하니 정합성 문제도 없다.
다양성은 조합에서 나온다
문장을 몇 개나 써야 하나. 이게 규칙 엔진의 진짜 비용이다.
MBTI 16타입에 글 하나씩 쓰면 16개다. 그러면 같은 타입인 사람은 전부 같은 글을 본다. 뻔하다.
그래서 MBTI를 16타입이 아니라 4축으로 쪼갰다. E/I · N/S · T/F · J/P를 각각 0과 1로 두고, 그 축을 직군·관심사·목표와 교차시킨다.
조각은 다섯 개의 슬롯으로 나뉜다. 화면의 각 섹션이 슬롯 하나다.
| 슬롯 | 무엇을 채우나 | 조각 수 |
|---|---|---|
headline |
커스텀 칭호 | 31 |
strength |
핵심 강점 | 40 |
hidden_weapon |
숨은 무기 (흔치 않은 조합을 짚는다) | 17 |
growth |
성장 서사 (현재 → 목표) | 27 |
catchphrase |
한 줄 캐치 | 9 |
합계 124개.
콘텐츠를 코드에서 떼어냈다
핵심 설계는 하나다. 규칙과 콘텐츠를 코드가 아니라 데이터로 둔다.
조각 하나는 이렇게 생겼다.
{
"id": "hl-backend-architect",
"slot": "headline",
"conditions": [
{ "feature": "jobRole", "op": "eq", "value": "BACKEND" },
{ "feature": "mbti.T", "op": "gte", "value": 1 }
],
"weight": 10,
"rarity": 3,
"template": "구조를 빚어내는 {mbtiAdj}형 백엔드 설계자"
}
conditions는 AND다. 전부 만족해야 후보가 된다. 백엔드 직군이면서 T축인 사람에게만 이 칭호가 걸린다.
weight는 후보끼리의 우선순위, rarity는 희소도 기여, template의 {mbtiAdj}는 나중에 채워진다.
이게 fragments.json 하나에 들어 있다. 엔진 코드를 한 줄도 안 건드리고 문구를 추가·수정할 수 있다. 문구를 고쳐도 재배포가 필요 없다 — 콘텐츠가 코드가 아니기 때문이다.
파이프라인 일곱 단계
각 단계는 단일 책임을 가진 순수 함수다. 같은 입력이면 같은 출력이라 단위 테스트가 쉽다.
- 벡터화
ProfileVectorizer— 설문을 정규화한다. MBTI 는 여기서 4축으로 쪼개진다 - 특성 점수
TraitScorer— 능력치 5축을 기여도 행렬로 계산 - 희소성
RarityScorer— "상위 7%의 조합" 같은 백분위 - 규칙 매칭
RuleMatcher— 슬롯별로 조건을 전부 만족하는 조각만 추린다 - 결정적 선택
FragmentSelector— 랜덤이 아니라 프로필 해시로 고른다 - 합성
NarrativeComposer—template에 변수를 채워 문장으로 - 렌더칭호 · 강점 · 숨은 무기 · 성장 서사 · 레이더 차트
능력치 계산은 이렇게 생겼다. 마법이 아니라 덧셈이다.
designScore = 50 (기본)
+ (skill: Figma → +15)
+ (interest: 디자인시스템 → +10)
+ (mbti.N → +5)
→ clamp(0, 100)
왜 이 점수가 나왔는지 한 줄씩 짚을 수 있다. LLM이었다면 "그렇게 나왔다"밖에 못 한다.
랜덤이 아니라 해시다
후보가 여럿일 때 하나를 고르는 자리 — 여기가 이 설계의 심장이다.
랜덤으로 고르면 새로고침할 때마다 칭호가 바뀐다. 그건 내 카드가 아니다.
그래서 프로필 해시로 고른다.
int idx = stableHash(seed) % topTier(ranked).size();
같은 프로필이면 같은 해시가 나오고, 같은 인덱스가 나오고, 같은 조각이 뽑힌다. 사람마다 다르지만 같은 사람은 언제나 같다.
여기에 한 겹을 더 뒀다. weight 동률 그룹 안에서만 섞는다.
// weight 동률 그룹 단위로 해시 회전 → 높은 weight 우선, 동률 안에서만 섞는다.
가중치가 높은 조각이 무조건 먼저다. 그 안에서 여럿이 비기면, 그때 해시로 고른다. 우선순위는 지키면서 다양성을 얻는다.
정직하게 — 이 방식의 한계
규칙 엔진은 공짜가 아니다. 다양성이 콘텐츠 큐레이션에 묶인다.
조각을 안 쓰면 결과도 안 늘어난다. LLM은 문장을 무한히 만들어내지만, 우리는 124개가 전부다. 그 이상의 표현은 사람이 더 써야 나온다.
실제로 그 한계를 밟았다. 처음엔 헤드라인이 직군당 두 개뿐이었다. MBTI가 달라도 칭호가 거의 똑같이 보였다. 조각 풀이 얇으면 조합의 힘이 안 나온다.
그래서 헤드라인을 직군 × 기질(NT/NF/SJ/SP) 16종으로 다시 짜고, 형용사도 기질 × E/I로 8종을 뒀다. 칭호 어간 16 × 형용사 8 = 최대 128 조합이다.
확인은 눈이 아니라 테스트로 했다. 8 MBTI × 4 직군 = 32 프로필을 돌려 서로 다른 헤드라인이 몇 개 나오는지 셌다. 28개. 이전엔 사실상 직군당 2개였다.
- 01
조각이 얇으면 조합이 안 산다
헤드라인이 직군당 2개뿐이던 시절, MBTI 가 달라도 칭호가 거의 같았다. 조합의 힘은 풀의 두께에서 나온다.
- 02
축을 늘려 조각을 교차시킨다
직군 × 기질 16종 + 기질 × E/I 형용사 8종 → 어간 16 × 형용사 8 = 최대 128 조합.
- 03
눈으로 보지 말고 세어본다
32개 프로필을 돌려 서로 다른 헤드라인 28개를 확인했다. "다양해 보인다"가 아니라 숫자로.
배운 것
"AI를 안 쓴다"가 뒤처진 선택처럼 들리던 때가 있었다. 실제로는 서비스의 성질이 도구를 정한다.
DevTI의 결과물은 자랑하고 공유하는 것이다. 그러면 결과가 안 바뀌어야 한다. 그리고 사용자가 설문을 반복해서 고친다. 그러면 계산이 공짜여야 한다. 그 둘이 정해지자 답은 규칙 엔진 하나뿐이었다.
이 시리즈에서 계속 나온 패턴이 여기서도 나온다. 1편에서 서버액션을 안 쓴 것도 "못 써서"가 아니라 인증 구조가 그렇게 정해줬기 때문이었다. 도구를 먼저 고르고 구조를 맞추는 게 아니라, 구조가 도구를 고른다.
그리고 하나 더. 규칙 엔진의 진짜 값은 설명할 수 있다는 것이다.
designScore = 50 + Figma 15 + 디자인시스템 10 + N축 5.
누가 "왜 제 디자인 점수가 80이죠?"라고 물으면 답할 수 있다. 그게 이 서비스가 사람에게 주는 신뢰의 정체였다.
다음 편에서는 라이트하우스 이야기를 쓴다. 도구가 시킨 대로 고친 것들과, 알면서도 안 고친 것 하나. 그리고 aria-hidden으로 숨겼는데 접근성 경고가 사라지지 않던 이유.