디자인 핸드오프를 진짜 제품으로 (4) — Claude로 공고별 이력서 최적화, 그리고 테스트가 잡아낸 버그
시리즈 마지막입니다. 워크스페이스 디자인에는 멋진 요소가 하나 있었어요 — 영어 이력서에서 공고 요구사항과 부합하는 문구를 그린으로 하이라이트하고, 그 아래 "이 공고의 'distributed systems' 요구사항에 맞춰 ○○ 경험을 강조했습니다" 같은 최적화 노트를 띄우는 것.
목업에선 이게 하드코딩이었습니다. 4편은 이걸 진짜 AI 기능으로 만든 이야기입니다. 일반 영어 이력서를 특정 공고 JD와 대조해, 하이라이트와 노트를 Claude로 생성하는 거죠.
사전 준비 — 결과를 캐싱할 자리
매번 페이지 열 때마다 AI를 호출하면 느리고 비쌉니다. 그래서 분석 결과를 캐싱하기로 했어요. 마침 matches 테이블이 (유저, 공고) 단위라, 여기에 JSONB 컬럼 하나면 충분했습니다.
-- migration 014
ALTER TABLE matches ADD COLUMN IF NOT EXISTS optimization JSONB;
저장 형태: { highlights: ["..."], note: { keyword, body }, generated_at }.
2편에서 "마이그레이션 적용 전후 모두 안전하게"라며
select('*')를 썼던 게 바로 이 컬럼 때문입니다. 컬럼이 아직 없어도 읽기는 안 터지죠.
Step 1. 프롬프트 설계 — JSON으로만, 부분문자열로만
영어 이력서 경력 문장들과 공고 JD를 주고, JSON으로 하이라이트와 노트를 받습니다. 가장 중요한 규칙은 "하이라이트는 이력서 원문에 그대로 있는 부분 문자열이어야 한다" — 안 그러면 프론트에서 하이라이트를 매칭할 수 없으니까요.
형식:
{
"highlights": ["채용공고 요구사항과 부합하는 이력서 문장 속 핵심 구절"],
"note": { "keyword": "'영문 핵심 키워드'", "body": "한국어 한 문장 설명" }
}
규칙:
- highlights 의 각 항목은 [영어 이력서 문장] 안에 그대로 존재하는 부분 문자열이어야 합니다.
- 새 문장을 지어내지 마세요.
모델은 기존 코드와 동일하게 claude-haiku-4-5를 썼습니다(빠르고 저렴, 분류·추출에 충분).
Step 2. 환각 방지 — 코드로 한 번 더 거른다
프롬프트로 "지어내지 마"라고 했어도, 코드에서 실제로 검증해야 안심입니다. 모델이 반환한 하이라이트 중 이력서 원문에 진짜 존재하는 것만 채택했어요.
const haystack = bullets.join('\n')
const highlights = (parsed.highlights ?? [])
.filter((h) => typeof h === 'string' && h.trim())
.filter((h) => haystack.includes(h)) // ← 원문에 없는 건 버림
.slice(0, 4)
실제 테스트에서 모델이 준 4개 하이라이트가 4개 모두 원문 부분문자열이라 환각 0개였지만, 이 필터가 있으면 어쩌다 지어낸 구절이 화면에 뜨는 사고를 막아줍니다.
Step 3. 캐싱 + UI 트리거
분석은 AI 비용이 드니 자동이 아니라 버튼으로 돌립니다. 클라이언트 버튼이 서버 액션을 호출하고, 끝나면 router.refresh()로 서버가 다시 렌더하죠.
'use client'
async function handleClick() {
setLoading(true)
const res = await generateWorkspaceOptimization(jobId)
if (res.error) { setError(res.error); setLoading(false); return }
router.refresh() // 서버가 캐시된 결과로 하이라이트·노트 렌더
}
이미 분석된 공고는 캐시를 읽어 즉시 표시되고 버튼은 사라집니다.
트러블슈팅 — 테스트가 잡아낸 노트 중복 버그
여기서 이 글의 진짜 교훈이 나옵니다. UI를 직접 클릭할 수 없는 환경이라, 버튼이 하는 로직을 실제 공고·실제 JD에 그대로 돌려 검증했어요. 그런데 노트 렌더 결과가 이랬습니다.
Vp Verbund Pflegehilfe 공고의 'product lifecycle…'
Vp Verbund Pflegehilfe 공고의 'product lifecycle…' 요구사항에 맞춰…
접두가 두 번 나옵니다. 원인은 이거였어요.
- 렌더 템플릿:
{회사} 공고의 {키워드} {body} - 그런데 모델이
body에 또"{회사} 공고의 {키워드} …"를 포함시킴 → 중복
고친 방법은 두 겹입니다.
- 프롬프트 강화 — "body에 회사명·키워드·'공고의'를 절대 포함하지 말고, 곧바로 이어지는 설명만"
- 코드 방어 — 그래도 반복하면 선행 접두를 제거
if (job.company) {
body = body.replace(new RegExp(`^${esc(job.company)}\\s*공고의\\s*${esc(keyword)}\\s*`), '')
}
body = body.replace(new RegExp(`^${esc(keyword)}\\s*`), '').trim()
재검증하니 깔끔하게 한 번만 렌더됐습니다. UI를 못 눌러봤다면 이 중복은 출시되고 나서야 발견했을 거예요.
덤으로, 테스트 중 가드를 빠뜨린 스크립트가 실데이터 한 행에 의미 없는 값을 잠깐 썼는데, 바로 optimization = null로 복구했습니다(실데이터 테스트는 늘 되돌릴 계획을 같이 세워둬야 합니다).
정리
AI 기능을 "정직하게" 붙이는 법.
- 결과는 캐싱 — (유저, 공고) 단위 JSONB 한 칸이면 충분
- 프롬프트로 시키고, 코드로 검증 — 환각은 부분문자열 필터로 차단
- AI 호출은 명시적 버튼 — 느리고 비싼 일은 사용자가 트리거하게
- 테스트가 버그를 잡는다 — UI를 못 누르면 로직을 실데이터에 직접 돌려서라도 확인
- 실데이터 테스트엔 복구 계획을 함께
시리즈를 마치며
HTML 한 장짜리 디자인 핸드오프에서 출발해, 재현 → 실데이터 연결 → 퍼널 연결 → AI 최적화까지 왔습니다. 돌아보면 매 단계의 공통 원칙은 같았어요 — 있는 걸 재사용하고, 없는 건 지어내지 않고, 의심스러우면 직접 돌려본다. 디자인을 코드로 옮기는 일은 결국, 예쁜 목업에 정직한 데이터와 동작을 한 겹씩 입히는 과정이었습니다.
backtodev
40대 PM, 다시 개발자로 돌아갑니다. 실패하고 배우며 성장하는 기록.