
내가 AI 구직 도구에 실망하여 직접 만든 이유
요약
기존 AI 이력서 생성 도구의 반복적인 문체 문제를 해결하기 위해 메모리 계층을 구축하고 아키텍처를 개선한 과정을 다룹니다. 단일 호출 방식과 신뢰도 기반 규칙 저장 방식이 가진 한계와 과소적합 문제를 분석합니다.
핵심 포인트
- 단일 LLM 호출 방식의 스타일 재현 한계
- 편집 차이(Edit diffs) 기반의 규칙 추출 메커니즘
- 신뢰도 임계값 설정에 따른 규칙 적용 지연 문제
- 사용자 피드백을 반영하기 위한 메모리 계층의 필요성

몇 달 전, 저는 제 자신의 구직 활동을 돕기 위해 AI 이력서 도구를 만들었습니다.
첫 번째 버전은 잘 작동했습니다. 채용 공고를 스크래핑(Scraping)하고, 점수를 매기며, 약 30초 만에 각 공고에 맞춤화된 이력서를 생성할 수 있었습니다. 저는 이를 출시했고, 직접 사용했습니다.
2주 후 저는 한 가지 사실을 깨달았습니다. 저는 똑같은 문단들을 계속해서 반복해서 다시 쓰고 있었습니다. 연구직(Research role)을 위해 생성된 모든 이력서는 똑같이 일반적이고, 3인칭 시점이며, 마케팅 느낌이 강했습니다. 저는 제가 실제로 말하는 방식처럼 능동적이고 목소리가 담긴 문체로 그것을 다시 작성했습니다. 그러고 나서 다음 이력서에서도 똑같은 작업을 반복했습니다. 그다음 이력서에서도요. 시스템은 제가 정확히 이 문단을 이미 15번이나 수정했다는 사실을 전혀 알지 못했습니다.
이를 위해 메모리 계층(Memory layer)을 구축했습니다. 하지만 작동하지 않았습니다.
이 포스트는 왜 그랬는지, 그리고 v1의 대부분을 삭제한 후 최종적으로 도달하게 된 아키텍처(Architecture)에 관한 이야기입니다.
원래의 아키텍처
시스템은 세 가지 요소를 가지고 있었습니다:
- 싱글샷 이력서 생성기 (Single-shot resume generator) — 사용자의 프로필, 목표 직무, 전략적 계획이 주어지면 단 한 번의 큰 LLM 호출을 통해 전체 이력서를 생성합니다.
writing_memory테이블 — 또 다른 LLM 호출을 통해 편집 차이점(Edit diffs)에서 추출된 "작성 규칙(Writing rules)"을 저장합니다.- 신뢰도 점수가 매겨진 주입 계층 (Confidence-scored injection layer) — 신뢰도가 0.6 이상인 규칙을 향후 생성 프롬프트(Prompt)에 가져옵니다.
흐름은 대략 다음과 같았습니다:
사용자 편집 → 백그라운드 작업이 (수정 전, 수정 후) 데이터를 분류기(Classifier)에 전달
LLM → 분류기가{rule_text: "'utilized' 대신 'built' 사용하기", category: "단어 선택", confidence: 0.5}를 반환 → 규칙이 데이터베이스에 신뢰도 0.5로 저장됨 → 3번의 강화(Reinforcements)를 거친 후 규칙이 0.6을 넘으면 향후 프롬프트에 나타나기 시작함.
이는 매우 합리적인 설계입니다. 다만 실제 문제를 해결하지 못했을 뿐입니다.
과소적합(Underfit)된 네 가지 이유
1. 스타일 규칙은 드러나기 위해 반복이 필요합니다.
0.6의 신뢰도 임계값(confidence threshold)을 적용했을 때, 규칙이 발동되기까지 약 3번의 강화(reinforcements)가 필요했습니다. 예를 들어, 사용자가 "leveraged"를 "used"로 한 번 수정한 뒤 두 번 더 수정할 기회를 얻지 못한다면, 해당 규칙은 0.5의 상태로 머물러 프롬프트에 전혀 나타나지 않게 됩니다. 시스템이 올바른 신호(signal)를 포착했음에도 불구하고 마치 망각한 것처럼 느껴진 것입니다.
임계값을 낮출 수도 있었지만, 임계값을 낮추면 분류기(classifier)의 노이즈(noise)가 지배적이게 됩니다.
2. 스타일 규칙(Style rules)은 잘못된 추상화입니다.
규칙이 올바르게 발동되었을 때조차 ("수동태 구문 지양" 등), 사용자가 특정 성과를 표현할 때 선호하는 방식을 포착할 수는 없었습니다. Cohere 지원서 작성을 위해 제가 직접 수동으로 조정(hand-tuned)한 MUSE에 대한 설명 — 구체적이고, 목소리가 일치하며, 제가 정확히 원했던 문구 — 은 새로운 이력서를 생성할 때마다 버려지고 처음부터 다시 생성되었습니다. LLM은 해당 텍스트가 존재한다는 사실을 알 방법이 없었습니다.
이것은 "사용자는 능동태 동사를 선호한다"(스타일 규칙)와 "지난번, 그리고 그 전번에 사용자가 MUSE에 대해 썼던 정확한 문단은 이것이다"(개별 엔티티별 콘텐츠)의 차이입니다. 제가 실제로 원했던 것은 후자였습니다.
3. 단발성 생성(Single-shot generation)이 중복 문제를 임시방편으로 가렸습니다.
재작성 전 생성 프롬프트는 LLM에게 다음과 같이 지시했습니다:
"만약 어떤 성과가
selected_research와 경력 사항의 불렛 포인트(bullet) 양쪽 모두에 나타난다면, 해당 불렛 포인트는 반드시 완전히 다른 내용을 말해야 합니다."
이것은 지시 사항(instruction)입니다. LLM은 지시 사항을 일관성 없게 따릅니다. 몇 번의 생성 주기마다 불렛 포인트는 동일한 성과에 대해 연구 설명(research description)을 단순히 바꾸어 말하는(paraphrase) 식이었고, 저는 이를 수동으로 수정해야 했습니다. 이는 버그가 아닙니다. LLM은 소프트 제약 조건(soft constraint) 하에서 최선을 다하고 있었던 것입니다.
4. 분류기(classifier)에 노이즈가 많았습니다.
추출용 LLM (extraction LLM)은 일반화할 수 없는 일회성 내용 재작성(content rewrites)을 포함하여, 어떠한 편집 사항으로부터도 기꺼이 규칙을 생성해 냈습니다. _"'RAND의 정성적 연구'를 '수동 정성적 연구 워크플로우'로 교체"_와 같은 규칙이 나타나곤 했습니다. 이는 내용 편집(content edit)이지 스타일 규칙(style rule)이 아니었지만, 분류기(classifier)는 그 차이를 알지 못했습니다.
새롭게 정립된 멘탈 모델 (The mental model that emerged)
실패 사례들을 한동안 응시한 끝에, 저는 두 가지 메모리 요구 사항이 하나의 레이어로 붕괴되어 있다는 사실을 깨달았습니다.
| 레이어 | 답변하는 질문 | 입도 (Granularity) | 도움이 되는 시점 |
|---|---|---|---|
| 스타일 (Style) | "사용자의 전반적인 어조는 무엇인가?" | 이력서 전체 | 콜드 스타트 (Cold start) — 해당 엔티티의 이전 버전이 아직 존재하지 않을 때 |
| 내용 (Content) | "이 성과 / 고용주 / 기술 범주에 대해 사용자가 선호하는 표현은 무엇인가?" | 엔티티별 (Per-entity) | 웜 스타트 (Warm) — 사용자가 이 엔티티를 이전에 다룬 적이 있을 때 |
writing_memory는 첫 번째 사항만을 다루고 있었습니다. 두 번째 사항을 위한 시스템은 없었으며, 바로 그 지점에서 대부분의 좌절감이 발생했습니다.
2단계 메모리 (Two-tier memory)
저는 하위 도메인 객체(underlying domain object)를 키(key)로 하여, 사용자가 직접 수정한 최종 텍스트를 **엔티티별(per entity)**로 저장하는 새로운 테이블인 content_memory를 추가했습니다.
| 엔티티 유형 (Entity type) | 키 (Key) | 저장되는 내용 |
|---|---|---|
research_description | accomplishment_id | 해당 연구 항목에 대한 사용자의 산문(prose) |
| ... |
고유 제약 조건(unique constraint)은 (entity_type, entity_key, source_doc_id)이며, 이 조합이 중요합니다. 하나의 이력서 내에서 동일한 엔티티에 대한 모든 편집은 동일한 행을 덮어씁니다. 따라서 세 번의 FINRA 불렛(bullet) 편집 세션은 세 개의 차이(diff) 기록이 아니라, 하나의 최종 상태 기록으로 압축됩니다. 여러 이력서에 걸쳐서는, 동일한 엔티티(예: rand-muse 연구 설명)가 이력서당 하나의 행을 누적합니다.
그렇게 누적된 이력이 에이전트가 생성 시점에 근거(grounding)를 두는 코퍼스(corpus)가 됩니다. 12개의 과거 이력서를 학습시킨 후, 저의 MUSE 성과 항목은 content_memory에 11개의 서로 다른 수동 조정 버전을 보유하게 되었으며, 각 버전에는 제가 그것을 작성했던 역할(role)이 태그로 지정되었습니다.
- Anthropic Research Engineer: "내부 인간-AI 연구 플랫폼을 설계 및 구축함…"
- Cohere Lead Data Scientist: "수동 정성 연구 워크플로우를 AI 지원 시스템으로 교체함…"
- Cohere MTS Data Analysis: "대부분 수동으로 이루어지던 것을 대체하기 위한 플랫폼을 설계 및 개발함…"
이 내용들이 새로운 이력서에 그대로 복사되는 것은 아닙니다. 대신 프롬프트(prompt)에 소프트 그라운딩 (soft grounding) 용도로 삽입됩니다:
## 이 콘텐츠를 위한 귀하의 과거 수동 조정 버전들
이 내용들을 어조(tone), 문구(phrasing), 강조(emphasis)를 위한 그라운딩(grounding) 자료로 사용하세요.
그대로 복사하지 마십시오 — 현재 직무 맥락에 맞게 조정하세요.
...
이제 에이전트(agent)는 주어진 모든 엔티티(entity)에 대해, 역할 맥락(role context)이 부착된 채로 _여러 역할에 걸친 사용자의 실제 목소리(voice)_를 보게 됩니다. 새로운 생성물은 콘텐츠를 새로운 역할에 맞게 조정하면서도, 코퍼스(corpus)로부터 목소리를 상속받습니다. 이 단 한 번의 변화만으로도, 어떤 이력서든 두 번째 생성된 버전이 훨씬 더 나다워진 느낌을 주었습니다.
writing_memory는 유지되었지만, 아직 content_memory 행이 없는 엔티티들을 위해 진정으로 추상적인 스타일 선호도(금지어, 문장 형식 규칙 등)를 처리하는 폴백 (fallback) 신호로서 작동합니다. 추상적인 스타일이 실제로 적절한 세밀도(granularity)를 갖는 경로는 요약(summary), 태그라인(tagline), 기술(skills) 편집뿐이기에, 추출(extraction) 범위를 이 경우에만 실행되도록 좁혔습니다.
구조적 교차 섹션 중복 제거를 포함한 단계별 생성 (Staged generation)
메모리는 문제의 절반이었습니다. 나머지 절반은 단일 샷 생성(single-shot generation) 방식이 LLM으로 하여금 다섯 개의 섹션을 한 번에 조정하게 만든다는 점이었습니다. 그리고 "동일한 성과가 연구(research) 섹션과 불렛 포인트(bullets) 섹션에 모두 나타나서는 안 된다"라는 제약 조건은 LLM이 약 80% 정도만 준수하는 지침이었습니다.
저는 단일 샷을 다음과 같은 파이프라인(pipeline)으로 분리했습니다:
1. 전략적 계획 (Strategic plan)
2. 선택 (어떤 성과 / 고용주를 이력서에 넣을 것인가)
3. 병렬 처리: 연구 항목(research entries) + 기술 범주(skill buckets) + 출판물 선택(publications selection)
...
4단계(Stage 4)가 핵심적인 변화입니다. 불렛 생성기(bullet generator)는 프롬프트의 일부로 최종 확정된 연구 항목(research entries)을 문자 그대로 전달받으며, 다음과 같은 명시적인 지침을 받습니다: "위에서 accomplishment_id가 나타난 불렛에 대해서는, 다른 관점을 취할 것." 교차 섹션 중복 제거(cross-section dedup) 제약 조건이 지시 이행(instruction-following) 방식에서 데이터 흐름 주도(data-flow-driven) 방식으로 바뀌었습니다. 이는 제약 조건이 실제로 유효하게 작동함을 의미합니다.
6단계(Stage 6, refiner) 역시 똑같이 중요했습니다. 초기 설계에는 초안 전체를 다시 쓰는 비평가(critic)가 있었습니다. 문제는 제가 수동으로 조정(hand-tuned)한 문구들(현재 content_memory에 저장되어 근거(grounding)로 사용됨)이 비평가에 의해 조용히 재작성되어, 메모리 작업의 결과가 무효화된다는 점이었습니다. 타겟 정제(Targeted refinement) 방식은 비평가가 지적하지 않은 문구들을 그대로 보존합니다.
모든 리프 LLM 호출(leaf LLM call)의 전체 프롬프트와 응답은 output/traces/{trace_id}/{stage}.txt에 저장됩니다. 결과가 이상하게 나올 때, 이 트레이스(trace) 파일들이 감사 추적(audit trail) 역할을 합니다.
예상치 못한 싸움: 목소리 미러링 (voice mirroring)
파이프라인 분리가 적용된 후, 저는 동일한 Surge 채용 공고에 맞춰 새로운 이력서를 생성해 보았습니다. 연구 설명(research description)은 다음과 같이 나왔습니다:
"우리 회사의 질적 연구(Qualitative research)는 대부분 수동적인 워크플로우에서 구조화된 코딩(structured coding), 주제별 합성(thematic synthesis), 그리고 대규모 생산 수준의 정책 분석(policy analysis)을 지원할 수 있는 인간-AI 혼합 시스템으로 전환되었습니다."
수동적입니다. 주어가 행위자가 아닌 작업(work)이 되어 있습니다. 어색합니다.
하지만 근거(grounding) 블록은 LLM에게 모두 능동태 동사로 시작하는 11개의 이전 버전을 보여주었습니다. 도대체 무슨 일이 일어나고 있는 걸까요?
시스템 프롬프트에는 다음과 같은 규칙이 있었습니다:
"문장 1: 변화(transformation) — 이 작업이 존재함으로써 지금 무엇이 달라졌는가."
모델은 "변화 중심(transformation-led)" 방식과 근거 예시 사이의 충돌을 수동태("연구가 ~에서 전환되었습니다")를 사용함으로써 해결했습니다. 이는 기술적으로는 변화 프레임워크를 준수하는 것이었습니다. 근거(grounding)가 명시적 규칙에 밀린 것입니다.
해결책은 리프 프롬프트(leaf prompt)에 단 한 단락을 추가하는 것이었습니다:
"사용자 메시지에 '당신이 이전에 직접 수정한 버전(Your past hand-tuned versions)' 블록이 나타나면, 이는 지원자가 동일한 성과에 대해 이전에 작성했던 표현들입니다. 이를 말투(voice)와 스타일(style)의 근거(source of TRUTH)로 취급하십시오. 도입부 동사 구조를 그대로 반영(MIRROR)하십시오. 수동태 변환 프레임워크(passive transformation framings)로 전환하지 마십시오. 그것은 지원자의 말투가 아니며 거부될 것입니다.""
그 후, 동일한 프롬프트는 다음과 같은 결과를 생성했습니다:
_"구조화된 코딩, 주제별 합성 및 정책 분석을 위해 혼합형 인간-AI 연구 플랫폼을 설계 및 구축하여, 무질서한 정성적 입력값을 신뢰할 수 있고 검토 가능한 출력값으로 변환함..."
이것은 마치 내가 내 업무에 대해 직접 쓰는 것처럼 들립니다. 근거(grounding)가 마침내 승리한 것입니다.
이런 일은 제품을 출시하기 전에는 예측하기 어렵습니다. 아키텍처(architecture)를 구축하고 데이터를 연결해 두면, 부드러운 프롬프트 규칙(soft prompt rule)과 강력한 데이터 신호(hard data signal)가 충돌하게 되는데, LLM(대규모 언어 모델)은 실제로 지침을 따르려고 노력하기 때문에 결국 부드러운 규칙이 승리하게 됩니다.
CI에서 이를 포착하기: 의미론적 평가 (semantic eval)
저는 다음에 수행할 프롬프트 변경 사항이 말투 반영(voice mirroring) 기능을 다시 조용히 망가뜨리는 것을 원치 않았습니다. 이는 평가 스위트(eval suite)를 구축해야 함을 의미했습니다. 하지만 일반적인 "함수가 예상된 값을 반환했는가"와 같은 종류는 아니었습니다. 말투의 품질은 단위 테스트(unit-test)가 불가능하기 때문입니다.
저는 사용자가 특정 섹션을 클릭하고, 재작성을 요청한 다음, 피드백을 통해 반복(iterate)하는 과정을 시뮬레이션하는 멀티 턴(multi-turn) 평가를 작성했습니다. 세 가지 시나리오를 설정했습니다:
- 불렛 포인트(bullet)를 간결하게 만들기 → 더 간결하게 만들기 → 마지막에 규모(scale) 수치로 마무리하기.
- 연구 기술(research description)을 재작성할 때 "'Replaced'로 시작할 것"이라는 명시적인 피드백을 주고, 중복된 마지막 문장 삭제하기.
- 기술 항목(skills bucket)을 정리할 때 항목 간 중복 제거(cross-bucket dedup) 피드백 주기.
각 턴(turn)은 판사 역할을 하는 **별도의 LLM 호출(separate LLM call)**에 의해 채점되며, 각 축(axis)별로 구조화된 통과/실패(pass/fail) 결과를 반환합니다:
respects_instruction— 재작성(rewrite)이 실제로 사용자가 요청한 대로 수행되었는가?no_fabrication— 새로운 값에 포함된 모든 사실이 기초가 되는 성과 데이터(accomplishment data)에 존재하는가?differs_from_prior— 재작성된 내용이 이전 턴(turn)과 실질적으로 다른가? (사소한 공백 수정은 실패로 간주함.)voice_matches_grounding— 이전 버전들이 표시될 때, 새로운 값이 해당 버전들의 시작 동사 패턴(opening-verb pattern)을 반영하는가?
평가는 CI(지속적 통합) 내에서 이루어집니다. 프롬프트(prompt), 에이전트(agent), 또는 메모리 레이어(memory layer)를 수정하는 PR(Pull Request)은 Postgres 서비스 컨테이너를 부팅하고, 가상의 샘플 프로필로 DB를 시딩(seed)하며, 평가를 실행한 뒤
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기