AI 코딩 어시스턴트와 SaaS 만들기 ③: 임베딩 없이 만든 RAG 맞춤 이력서
시작하며
1편에서 URL과 플로우 정리를, 2편에서 데이터 유실 사고를 다뤘다. 이번 편은 같은 세션에서 진행한 기능들 중 가장 "제품다운" 부분 — 이력서를 다루는 세 가지 기능을 정리한다.
- 이력서 파일 업로드 → AI 자동 분석 → 기본 정보 자동 채우기
- 과거에 써둔 맞춤 이력서를 검색해 참고하는 RAG 방식 맞춤 이력서 생성
- 화면 디자인 그대로 반영한 DOCX/PDF 다운로드
세 기능은 따로 노는 게 아니라 서로 데이터를 주고받는다. 업로드한 이력서가 구조화 프로필을 채우고, 그 프로필이 맞춤 이력서 생성의 재료가 되고, 맞춤 이력서가 쌓이면 다음 맞춤 이력서의 RAG 참고 자료가 되는 식이다.
왜 필요했나
MatchDa는 "한국어 이력서를 영어로, 공고에 맞춰 재구성"해주는 서비스다. 그런데 초기 버전은 사용자가 이력서 내용을 폼에 하나하나 직접 입력해야 했다. 실제로 써보니 이 단계에서부터 이탈이 컸다 — 이미 있는 PDF/DOCX 이력서를 다시 타이핑하고 싶은 사람은 없다.
또 하나, 맞춤 이력서를 여러 공고에 대해 반복해서 만들다 보면 "지난번엔 이 경력을 어떻게 강조했더라?"하는 스타일 일관성 문제가 생긴다. 매번 완전히 새로 쓰기보다, 과거에 잘 썼던 표현을 재사용하는 게 자연스럽다.
Step 1. 이력서 파일 업로드 → 자동 분석
e597828 커밋에서 구현한 기능이다. 이력서 스튜디오(/profile) 상단에 업로드 배너를 두고, PDF/DOCX를 올리면 자동으로 분석해서 편집기 필드를 채운다.
feat: 이력서 파일 업로드 → AI 자동 분석 → 스튜디오 기본 정보 자동 채우기
- 이력서 스튜디오 상단에 PDF·DOCX 업로드 배너 추가
- analyzeResumeFile 액션: 파일 텍스트 추출 → Haiku 구조화(이름·직함·요약·경력·스킬·학력)
→ onboarding_ko/en 저장 → 결과 반환
- 업로드 즉시 에디터 좌측 항목이 자동으로 채워지고, 확인·수정 후 저장 가능
- DOCX 원본은 스토리지에 보관해 맞춤 이력서 DOCX 생성에 활용
핵심 판단은 모델 선택이었다. 이력서를 구조화하는 작업은 창의적인 글쓰기가 아니라 "텍스트에서 필드를 뽑아내는" 추출 작업에 가깝다. 그래서 무거운 모델 대신 Claude Haiku를 썼다. 응답 속도가 빠르고 비용도 낮아서, 업로드하자마자 몇 초 안에 결과가 채워지는 사용자 경험을 만들 수 있었다.
흐름은 이렇다.
PDF/DOCX 업로드
│
▼
파일에서 텍스트 추출
│
▼
Haiku에게 "이름/직함/요약/경력/스킬/학력으로 구조화해줘" 요청
│
▼
onboarding_ko / onboarding_en 컬럼에 저장
│
▼
에디터 필드 자동 채움 (사용자가 확인·수정 후 저장)
DOCX 원본 파일 자체도 스토리지에 남겨뒀는데, 이건 나중에 맞춤 이력서를 DOCX로 다시 만들 때 서식 참고 자료로 쓰기 위해서였다.
Step 2. RAG로 맞춤 이력서 강화 — 임베딩 없이
이번 세션에서 가장 흥미로웠던 부분이다. "RAG(Retrieval-Augmented Generation)"라고 하면 보통 임베딩 모델, 벡터 DB(pgvector, Pinecone 등)를 떠올리기 쉽다. 하지만 사이드 프로젝트 단계에서 별도 벡터 인프라를 놓는 건 과했다. 그래서 키워드 오버랩 기반의 단순 스코어링으로 RAG를 구현했다.
2a3b0b2 커밋의 핵심 로직을 보면 이렇다.
// src/app/actions.ts
// JD에서 핵심 키워드 추출 (불용어 제거, 소문자)
const RAG_STOPWORDS = new Set([
'the', 'and', 'for', 'with', 'you', 'our', 'are', 'will', 'that', 'this', ...
])
function ragKeywords(text: string): Set<string> {
const words = text.toLowerCase().match(/[a-z][a-z+#.-]{2,}/g) ?? []
return new Set(words.filter(w => !RAG_STOPWORDS.has(w)))
}
function ragScore(text: string, kws: Set<string>): number {
const words = text.toLowerCase().match(/[a-z][a-z+#.-]{2,}/g) ?? []
let s = 0
for (const w of words) if (kws.has(w)) s++
return s
}
동작 방식을 단계별로 풀면 이렇다.
- 새 공고(JD)에서 불용어를 뺀 핵심 키워드 집합을 만든다.
- 사용자가 과거에 다른 공고용으로 만들어둔 맞춤 이력서들(
tailored_resumes테이블, 현재 공고 제외)을 최대 30건 가져온다. - 각 과거 이력서 텍스트에 새 JD의 키워드가 몇 번 등장하는지 세서 점수를 매긴다.
- 점수가 0보다 크면서 상위 3건만 골라 "참고 컨텍스트"로 프롬프트에 주입한다.
async function retrievePastResumes(profileId: string, jobId: string, jd: string): Promise<string> {
const { data: past } = await supabaseAdmin
.from('tailored_resumes')
.select('job_id, content')
.eq('user_id', profileId)
.neq('job_id', jobId)
.not('content', 'is', null)
.limit(30)
if (!past?.length) return ''
const kws = ragKeywords(jd)
const ranked = past
.map(p => ({ ...p, score: ragScore(p.content ?? '', kws) }))
.sort((a, b) => b.score - a.score)
.filter(p => p.score > 0)
.slice(0, 3)
// ... 상위 3건을 "참고 이력서"로 텍스트 조립해서 반환
}
이 방식의 장점은 명확하다.
| 임베딩+벡터DB 방식 | 키워드 오버랩 방식 | |
|---|---|---|
| 추가 인프라 | 벡터 DB, 임베딩 API 호출 필요 | 없음 (기존 Postgres 쿼리로 충분) |
| 지연시간 | 임베딩 계산 + 유사도 검색 | in-memory 스코어링, 수 ms |
| 정확도 | 의미적 유사도까지 포착 | 표면적 키워드 일치만 포착 |
| 데이터 규모 | 수천~수만 건에서 유리 | 수십~수백 건 규모에 적합 |
사용자 한 명당 맞춤 이력서가 많아야 수십 건인 서비스 초기 단계에서는, 키워드 오버랩만으로도 "관련도 높은 과거 이력서 상위 3건"을 골라내기에 충분했다. 나중에 데이터가 쌓이고 정확도가 부족해지면 그때 임베딩으로 교체하면 된다 — 지금 단계에서 벡터 DB부터 놓는 건 과잉 설계였을 것이다.
RAG를 쓸 때 가장 중요했던 규칙 하나: 참고 자료는 어디까지나 "표현·강조 스타일" 참고용이고, 사실의 출처는 반드시 원본 이력서로 한정한다는 것이었다. 프롬프트에도 이 제약을 명시적으로 박아뒀다.
## 원본 이력서 (사실의 유일한 출처)
${baseResume}
## 과거에 유사 공고에 작성한 맞춤 이력서 (표현·강조 방식만 참고, 새로운 사실 추가 금지)
${pastContext}
## 작성 요구사항
- 사실은 오직 "원본 이력서"에서만 가져올 것. 경력·스킬·수치·회사명을
절대 지어내거나 과장하지 말 것
- "과거 맞춤 이력서"는 강조점·표현·bullet 스타일을 참고하는 용도이며,
거기서 새로운 사실을 끌어오지 말 것
이렇게 하지 않으면, 과거 맞춤 이력서에서 (그 자체로 이미 살짝 각색된) 표현이 반복 재생산되면서 원본 사실과 점점 멀어지는 "환각의 복리 효과"가 생길 수 있다. RAG 소스와 사실 소스를 프롬프트 레벨에서 분리해두는 게 핵심이었다.
또 하나 신경 쓴 부분은 resume_text(업로드 원문)가 없는 사용자도 대응하는 것이었다. 온보딩 때 입력한 구조화 프로필(onboarding_en)만 있어도 아래처럼 사실 기반 텍스트를 조립해서 대체할 수 있게 했다.
function structuredResumeText(onboardingEn: unknown): string {
// name, title, summary, experience[], skills[], education[] 를
// 섹션별 텍스트로 조립
}
Step 1의 자동 채우기 기능과 여기서 자연스럽게 연결된다 — 업로드로 채워진 구조화 프로필이 있으면, 원본 텍스트가 없어도 맞춤 이력서를 만들 수 있는 것이다.
Step 3. 서식이 살아있는 DOCX/PDF 다운로드
RAG로 내용을 잘 만들어도, 다운로드했을 때 완전 민짜 텍스트 파일이면 실제로 쓰기가 애매하다. 원래 DOCX 다운로드는 plain text 수준이었는데, fa033c3과 2b54721 커밋에서 화면 디자인을 그대로 반영하도록 개선했다.
feat: 이력서 DOCX에 화면 디자인 서식 적용 + 프로필에 PDF/DOCX 다운로드
- DOCX를 평문 대신 서식 있는 문서로 생성 (이름/직함/섹션 굵기·색,
기간 우측정렬, 불릿 리스트, 섹션 구분선, 브랜드 포인트 컬러)
- studioToRender/docToRender/renderResumeHtml 공통 렌더 모델 추가
여기서 좋았던 설계는 **"공통 렌더 모델"**을 하나 두고, 화면 미리보기·DOCX·PDF가 전부 같은 모델을 소비하게 만든 것이다.
구조화 이력서 데이터
│
▼
renderResumeHtml (공통 렌더 모델)
├──▶ 화면 미리보기 (React)
├──▶ DOCX 생성 (docx 라이브러리)
└──▶ PDF 생성 (html2canvas + jsPDF)
이렇게 하면 "화면에서는 이렇게 보이는데 다운로드하면 다르게 나온다"는 흔한 불일치 문제가 원천적으로 사라진다.
PDF 쪽은 좀 더 근본적인 변경이 있었다. 원래는 브라우저 인쇄 다이얼로그(window.print())를 썼는데, 이건 URL 푸터가 찍히고 사용자가 매번 "PDF로 저장"을 수동으로 골라야 하는 불편함이 있었다. 2b54721에서 html2canvas로 화면을 래스터화한 뒤 jsPDF로 실제 파일을 직접 만드는 방식으로 바꿨다.
feat: 이력서 PDF를 인쇄 대신 실제 파일로 다운로드
- 인쇄 다이얼로그(URL 푸터 포함) 대신 html2canvas + jsPDF로 PDF 파일 직접 생성
- 한글은 브라우저 렌더링을 래스터화해 정상 표시, A4 페이지 자동 분할
- 미사용 printResumeHtml 제거, html2canvas 의존성 추가
한글 폰트를 PDF 라이브러리에 직접 임베딩하는 대신, "브라우저가 이미 잘 그려주는 화면을 통째로 이미지로 캡처해서 PDF에 붙이는" 방식을 택한 점이 실용적이었다. 폰트 임베딩·서브셋 문제를 아예 우회한 셈이다. 다만 페이지가 길어지면 A4 한 장을 넘기 때문에, 캡처한 이미지를 페이지 높이 단위로 잘라 여러 페이지에 나눠 붙이는 처리가 필요했다.
정리
이번 편의 세 기능을 관통하는 원칙은 하나였다 — "지금 규모에 맞는 가장 단순한 해법을 고른다."
| 기능 | 흔한 선택 | 실제로 고른 선택 | 이유 |
|---|---|---|---|
| 이력서 구조화 | 무거운 모델로 정밀 추출 | Claude Haiku | 추출 작업엔 속도·비용이 더 중요 |
| 맞춤 이력서 참고 검색 | 임베딩 + 벡터 DB | 키워드 오버랩 스코어링 | 사용자당 수십 건 규모엔 충분, 인프라 0 |
| PDF 생성 | PDF 라이브러리에 한글 폰트 임베딩 | 화면을 캡처해 이미지로 삽입 | 폰트 문제 우회, 화면과 100% 동일 보장 |
세 기능 모두 "이론적으로 더 정교한 방법"이 존재하지만, 지금 단계에서는 오히려 단순한 방법이 유지보수하기 쉽고 사용자 경험도 충분했다. RAG라고 해서 반드시 벡터 DB가 필요한 건 아니라는 걸 직접 구현하면서 체감했다.
다음 편에서는 추천 기업 프리셋으로 원클릭 채용공고 수집을 구현한 이야기, 고객센터 챗봇, 그리고 진행 중인 Stripe 구독 결제 이야기를 다룬다.
backtodev
40대 PM, 다시 개발자로 돌아갑니다. 실패하고 배우며 성장하는 기록.