Claude APIRAG프롬프트엔지니어링Next.js리팩토링

같은 서비스 안에 이력서를 잘 쓰는 AI와 덜 잘 쓰는 AI가 따로 있었다

July 9, 20261 min read

"이게 더 잘 쓴 것 같아요" — 감각적 피드백이 알려준 것

사용자에게 이런 피드백을 받았다. "맞춤 이력서가 워크스페이스 이력서보다 잘 쓴 것 같아." 매치다에는 이력서를 채용공고에 맞춰 다시 써주는 화면이 두 군데 있는데, 그중 하나(리스트뷰의 "맞춤 이력서" 버튼)가 다른 하나(워크스페이스 메인 화면의 "이 공고에 맞춰 AI 분석")보다 결과물이 낫다는 이야기였다.

정성적인 "느낌" 피드백이었지만, 이런 종류의 감각적 비교는 종종 코드 구조의 문제를 정확히 짚어낸다. 실제로 코드를 열어보니 같은 서비스 안에 이력서를 재작성하는 AI 함수가 두 벌 따로 있었다.

조사 — 왜 두 개인가

1번. generateTailoredResume (원래부터 있던 함수)

  • 유저가 과거에 만든 맞춤 이력서 중 이번 공고와 관련된 것을 찾아 표현·스타일을 참고하는 RAG가 있음
  • 업로드한 원본 이력서 파일 텍스트까지 근거자료로 활용
  • "PROFESSIONAL SUMMARY / KEY SKILLS / WORK EXPERIENCE" 구조로 설득력 있게 쓰라는 지시가 프롬프트에 명시적으로 들어있음
  • 결과는 평문 통짜 텍스트로 나와서 모달(팝업창)에서 보여줌

2번. tailorResumeForJob (바로 전날 워크스페이스에 새로 만든 함수)

  • 워크스페이스 메인 화면의 필드별로 실시간 편집 가능한 구조화 이력서(이름·직함·요약·스킬·경력이 각각 독립된 항목)를 그 자리에서 재작성하는 용도
  • 회사명·직책·기간은 절대 건드리지 않고 description(성과 서술)만 재작성해야 해서, 프롬프트가 상대적으로 제약적
  • RAG 없음. 원본 업로드 파일도 활용 안 하고, 클라이언트에서 넘어온 현재 편집 상태만 근거자료로 사용

여기서 재밌는 사실도 하나 발견했다. 리스트뷰의 "커버레터"·"맞춤 이력서" 버튼과 워크스페이스 상단의 같은 이름 버튼이 완전히 같은 API(generateTailoredResume)를 쓰고 있었다 — 즉 이 둘은 그냥 순수한 중복이었다.

정리하면 이렇다.

generateTailoredResumetailorResumeForJob
근거자료원본 이력서 텍스트 + 구조화 데이터클라이언트의 현재 편집 상태만
RAG(과거 이력서 참고)OX
프롬프트 문체 지시"설득력 있게" 명시상대적으로 제약 위주
결과 형태평문 통짜 텍스트 (모달)구조화 필드 (실시간 편집 패널)
사용처리스트뷰 + 워크스페이스 상단 (중복!)워크스페이스 메인

품질 차이의 원인이 명확해졌다 — 하나는 풍부한 재료와 명시적인 문체 지시를 받았고, 다른 하나는 그렇지 않았다.

요청을 문자 그대로 따르면 안 되는 이유

사용자의 요청은 "워크스페이스도 여기서 사용하는 API 사용하게 해주고"였다. 문자 그대로 받아들이면 "tailorResumeForJob을 버리고 generateTailoredResume으로 완전히 갈아끼우자"는 뜻으로 읽을 수도 있었다.

그런데 그렇게 하면 문제가 생긴다. generateTailoredResume의 결과는 평문 통짜 텍스트라, 필드별로 나눠서 실시간 편집하는 지금의 워크스페이스 UI(이름 따로, 전화번호 따로, 경력 항목마다 따로 수정 가능한 구조)를 갈아끼울 수가 없다. 이건 바로 전날 공들여 만든 기능이었다 — 완전 교체는 그 기능을 통째로 버리는 셈이었다.

그래서 방향을 바꿨다. API를 통째로 바꾸는 대신, generateTailoredResume이 쓰는 "기법"만 tailorResumeForJob에 이식하기로 했다. 구조화된 편집 UI는 그대로 유지하면서, 글쓰기 품질만 같은 수준으로 끌어올리는 절충안이다.

이런 상황에서는 요청을 곧이곧대로 구현하기 전에, 그게 기존에 이미 잘 만들어둔 다른 기능을 희생시키는지부터 확인하는 게 좋다. 이번엔 확인해보니 그랬고, "결과는 같은 품질로 맞추되 구조는 보존"하는 쪽이 실제 의도에 더 가까웠다.

실제 작업

Step 1. 공통 로직을 별도 lib로 추출

RAG 검색 로직(retrievePastResumes)이 app/actions.ts 안에 비공개 함수로 박혀 있어서, 다른 파일에서 재사용하려면 그대로 복붙해야 하는 상황이었다. 이번 기회에 공용 라이브러리 파일로 뽑아냈다.

// src/lib/resume-rag.ts
export 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)
  // ...키워드 매칭으로 관련도 점수 매기고 상위 3건만 반환
}

원본 이력서를 평문으로 직렬화하는 헬퍼(structuredResumeText)도 같은 이유로 src/lib/resume.ts로 옮겼다. 지금은 두 함수가 이걸 나눠 쓰지만, 나중에 세 번째 이력서 생성 기능이 생겨도 그대로 재사용할 수 있다.

Step 2. tailorResumeForJob에 같은 재료를 넣어주기

export async function tailorResumeForJob(
  jobId: string,   // RAG 검색에 필요해서 파라미터로 추가
  current: StudioResume,
  jobContext: { title: string; company: string; description: string | null }
) {
  // ...

  // generateTailoredResume과 동일하게, 원본 업로드 파일 텍스트를 보완 근거자료로 제공
  const extraSource = profile.resume_text
    ? `\n\n[원본 이력서 파일 (추가 근거자료 — 위 구조화 이력서에 없는 세부사항이 있으면 참고)]\n${profile.resume_text.slice(0, 4000)}`
    : ''

  // RAG: 과거 맞춤 이력서에서 표현·강조 스타일 참고
  const { retrievePastResumes } = await import('@/lib/resume-rag')
  const pastContext = await retrievePastResumes(profile.id, jobId, `${jobContext.title} ${jobContext.company} ${jd}`)

  // ...프롬프트에 extraSource, pastContext를 근거자료로 추가
}

프롬프트 지시문도 generateTailoredResume의 톤을 참고해 강화했다.

- summary는 공고와 가장 관련 높은 강점을 첫 문장에 배치해 설득력 있게 쓰세요
  (막연한 자기소개 나열 금지).
- experience의 description은 밋밋한 업무 나열이 아니라 성과·기여 중심으로 쓰세요.

Step 3. 빠뜨렸던 jobId를 되찾기

RAG가 동작하려면 "지금 만들려는 공고 자신은 검색 결과에서 빼야" 하는데, 그러려면 jobId가 필요하다. 그런데 확인해보니 바로 전날 다른 리팩토링을 하면서 워크스페이스 컴포넌트(WorkspaceResume)에서 jobId prop을 아예 빼버린 상태였다(다른 기능을 정리하면서 "이제 이 컴포넌트엔 jobId가 필요 없다"고 판단했던 흔적). 이번 기능을 위해 다시 되살렸다.

// src/app/workspace/page.tsx
<WorkspaceResume
  jobId={jobId}   // 되살림
  initialKo={...}
  // ...
/>

Step 4. 중복 버튼 정리

리스트뷰의 "커버레터"·"맞춤 이력서" 버튼은 워크스페이스 상단에 이미 똑같은 기능이 있었으니, 리스트뷰 쪽을 지웠다. 관련 상태(showCoverLetter, showTailoredResume)와 모달 렌더, 안 쓰는 import까지 함께 정리했다. "JD 입력"·"메모"·"제출 서류" 버튼은 워크스페이스에 아직 없는 기능이라 그대로 남겼다.

// Before: 5개 버튼 (JD 입력, 메모, 커버레터, 맞춤 이력서, 제출 서류)
// After:  3개 버튼 (JD 입력, 메모, 제출 서류)
//         커버레터·맞춤 이력서는 "워크스페이스"로 들어가서 사용

검증 — 정말 품질이 달라졌는지 확인

헤드리스 브라우저로 두 가지를 직접 확인했다.

① 버튼이 실제로 사라졌는지

const coverBtn = await page.locator('button:has-text("커버레터")').count()
const tailoredBtn = await page.locator('button:has-text("맞춤 이력서")').count()
console.log('커버레터 버튼:', coverBtn)     // 0
console.log('맞춤 이력서 버튼:', tailoredBtn) // 0

② 워크스페이스 결과물이 실제로 달라졌는지

수정 전에는 경력 서술이 평범한 나열이었는데, 수정 후에는 이런 식으로 바뀌었다.

구축했습니다 사내 첫 디자인 시스템(컴포넌트 42개)을 처음부터 설계·문서화하고,
개발자와 긴밀히 협업해 디자인-개발 핸드오프 시간을 단축했습니다
리드했습니다 다크모드 및 접근성(WCAG AA) 대응을 주도하며 제품 전반의
접근성 표준을 수립했습니다
개선했습니다 송금 플로우 전면 리디자인으로 단계별 이탈률을 35%에서 18%로
낮추며 퍼널 지표 기반 의사결정을 실증했습니다

행동동사로 문장을 시작하는 게 눈에 띄게 늘었다. 그리고 더 확실한 증거는, 이 결과에 **"funnel-based decision making"**이라는 표현이 다시 등장했다는 점이다 — 이건 이 유저가 과거에 다른 공고용으로 만들었던 맞춤 이력서에서 쓰던 표현이다. 즉 RAG가 실제로 과거 이력서를 참고해서 표현을 가져왔다는 뜻이고, 우연이 아니라 의도한 대로 동작하고 있다는 확실한 근거였다.

자주 쓰는 패턴 요약

상황처리 방법
비슷한 AI 기능을 서로 다른 시점에 만들다 프롬프트가 갈라짐근거자료 조회·직렬화 로직을 공용 lib로 뽑아 재사용
"여기서 쓰는 걸 저기서도 쓰게 해줘" 요청문자 그대로 API 교체 전에, 그게 기존 기능을 희생시키는지부터 확인
RAG로 "과거 결과를 참고했다"는 걸 증명하고 싶을 때결과물에 과거 산출물의 특징적 표현이 재등장하는지 확인(우연이 아님을 보여주는 증거)

정리

"이게 더 잘 쓴 것 같다"는 피드백 → 코드에 이력서 생성 API가 두 벌 있었음을 발견
  → 하나는 RAG+원본파일+설득력 있는 프롬프트, 하나는 구조 보존 위주 제약적 프롬프트
  → API 전체 교체 대신 "같은 기법만 이식" 절충안 선택(편집 기능은 보존)
  → RAG·직렬화 로직을 공용 lib로 추출 → tailorResumeForJob에 근거자료·프롬프트 강화
  → 빠졌던 jobId prop 복원 → 리스트뷰 중복 버튼 정리
  → 헤드리스 브라우저로 버튼 제거·품질 변화(행동동사 서술, RAG 표현 재등장) 검증

이번 작업에서 가장 크게 남은 건 두 가지다. 하나는 사용자의 감각적인 "이게 더 나은 것 같다"는 피드백이 실은 코드 중복이라는 구조적 문제를 가리키는 신호였다는 것. 다른 하나는 요청을 문자 그대로 구현하기 전에, 그게 이미 잘 만들어둔 다른 기능을 깨뜨리지 않는지 먼저 확인하는 습관이다. 이번엔 그 확인 덕분에 "품질은 맞추되 방금 만든 편집 기능은 지키는" 더 나은 결론에 도달할 수 있었다.

PM

backtodev

40대 PM, 다시 개발자로 돌아갑니다. 실패하고 배우며 성장하는 기록.

같은 서비스 안에 이력서를 잘 쓰는 AI와 덜 잘 쓰는 AI가 따로 있었다 | backtodev