"베끼지 마"는 프롬프트로 막을 수 없었다
들어가며
안녕하세요, 개발자 박재민이에요. 사내 뉴스 매거진 서비스의 뉴스 파이프라인을 만들면서 LLM이 원문을 얼마나 잘 바꿔 쓰는지 여러 방식으로 시험해 봤어요. 이번 글에서는 그중 기사 재구성 단계에서 원문 복제를 막으려다 겪은 문제와, 프롬프트 대신 구조로 해결한 과정을 이야기해 볼게요.
저희 파이프라인은 제휴 매체의 기사를 수집하고 LLM으로 사실만 추출한 뒤 그 사실을 바탕으로 새 기사를 작성해요. 작성한 기사는 영어·일본어·중국어 번체로 번역하고 자동 검사와 사람 또는 규칙의 승인을 거쳐 API로 발행해요.
2026년 여름에 만들기 시작해 몇 주 만에 첫 기사를 발행했고 이후 일부 소스는 자동 발행으로 전환했어요. 이 연재에서는 재구성과 번역 과정에서 부딪힌 문제를 다뤄요. 이 연재의 1~4편은 재구성, 5~7편은 번역 이야기예요.
재구성의 딜레마
재구성 단계에서 지켜야 할 조건은 간단해 보였어요. 원문의 수치·인명·기관·인과관계 같은 사실은 하나도 바뀌면 안 되고 표현은 원문과 달라야 했어요.
원문 문장을 그대로 옮기면 저작권 문제가 생길 수 있고 검색엔진은 이를 다른 글을 조금 바꾼 복제 콘텐츠로 볼 수 있어요. 구글의 scaled content 정책도 이런 문제와 관련돼요. 반대로 표현을 크게 바꾸려 하면 사실의 의미가 미끄러질 수 있어요.
뜻은 같게 유지하면서 문장은 다르게 써야 하는 셈이에요. 이런 요구는 LLM 작업에서 자주 만나요. ‘A를 지키면서 B는 바꿔라’처럼 서로 당기는 조건이 함께 나오거든요. 그래서 먼저 두 조건을 각각 측정할 수 있게 만들어야 했어요.
원문 전체를 주고 한 번에 다시 쓰기
처음에는 원문 기사 전체를 프롬프트에 넣고 LLM을 한 번 호출해 새 기사를 받았어요. 원문은 약 5,000자였고 프롬프트는 v1부터 v5까지 다섯 번 고쳤어요. 새 기사와 원문 길이의 비율은 0.27에서 1.02~1.22까지 맞췄어요.
겉으로는 결과가 괜찮아 보였지만 같은 원문으로 v5를 다섯 번 반복하자 문제가 드러났어요. 다섯 번 중 세 번에서 40자가 넘는 연속 복제가 나왔고 가장 심한 회차에서는 원문 약 5,000자 중 543자를 통째로 옮겼어요.
하필 그 버전에는 ‘열 글자 넘게 이어지는 같은 표현을 금지한다’는 문장도 프롬프트에 들어 있었어요. 543자를 복제한 실행은 다섯 번 중 한 번뿐이었어요. 한 번만 실행했다면 발견하지 못했을 문제예요.
다른 부작용도 있었어요. 원문에 없던 발화 동사를 12건이나 만들어 넣었어요. ‘밝혔다’, ‘언급했다’, ‘전했다’, ‘덧붙였다’, ‘평가했다’ 같은 표현이었어요. 원문은 평서문이었는데 새 기사에서는 누군가 한 말처럼 바뀐 거예요.
기사의 논지를 받치는 유일한 가격 근거가 세 번 연속 빠지기도 했어요. 실패한 경우에는 기사 전체를 다시 생성했는데 교정 정보를 반영하지 않은 채 다시 뽑는 재추첨에 가까웠어요. 그 결과 사실 보존율이 0.917에서 0.208로 떨어진 회차도 있었어요.
이 문제는 한 번으로 끝나지 않았어요. 나중에 문단 묶음 경로(2편)로 바꾼 뒤 8월 11일 측정에서도 다섯 번 중 한 번은 한 묶음이 두 문단을 79자 그대로 내보냈어요. 그때도 프롬프트에는 열 글자 넘게 같은 표현을 금지한다는 문장이 있었어요. 코드 주석에는 이 결과를 ‘선언으로 막히지 않는다는 증거’라고 적었어요.
더 무서운 점은 따로 있었어요. 베끼기 검사가 없던 동안 나머지 품질 검사는 원문 복제에 만점을 줬어요. 문단 수가 같고 사실이 하나도 빠지지 않았고 문체도 원문과 같았기 때문이에요. 금지해야 할 행동이 품질 검사의 정답이 되는 구조였어요.
이 과정을 거치며 두 가지를 배웠어요.
- 프롬프트의 금지 규칙은 복제 확률을 낮출 뿐, 0으로 만들지는 못해요.
- LLM 출력은 실행할 때마다 달라져요. 한 번 괜찮았다는 결과만으로는 부족하고 최소 다섯 번은 반복해서 확인해야 해요.
경로 자체를 없애기
프롬프트만으로 막기 어렵다면, 모델이 원문을 복제할 수 있는 경로 자체를 없애야 했어요. 팀 설계 결정 문서인 ADR(Architecture Decision Record)은 왜 이런 구조를 선택했는지 남기는 기록인데, 여기에 이런 문장을 적었어요.
프롬프트에 ‘원문을 복사하지 마라’라고 쓰는 것은 방어가 아니다. 경로 자체가 없어야 방어다.
구조는 두 단계로 나눴어요. 원문을 읽는 LLM은 팩트 추출기 하나뿐이에요. 이 단계에서는 문장과 수사를 버리고 수치·날짜·인명·기관·사건·발언 요지를 JSON 형태의 팩트 테이블로 만들어요.
그다음 기사를 쓰는 작성기는 팩트 테이블만 받아요. 원문을 전달받을 방법 자체가 없어요.
타입으로 원문 파라미터 제거하기
함수 시그니처는 다음처럼 단순화했어요.
# 단순화한 의사코드
def write_article(fact_table, template, lang, writer_mode) -> Draft:
... # 원문(raw text)을 받는 파라미터가 없다
작성기의 파라미터는 fact_table, template, lang, writer_mode 네 개뿐이에요. 테스트는 함수 시그니처를 직접 확인해요. raw, source_text, body_text 같은 파라미터가 추가되면 실패해요.
모듈 경계로 원문 접근 차단하기
작성기 폴더에서는 원문을 다루는 모듈을 import할 수 없게 했어요. 테스트는 작성기 폴더의 모든 .py 파일을 AST(Abstract Syntax Tree)로 읽어요. AST는 파이썬 코드를 트리 구조로 분석하는 방식이에요.
단순히 문자열을 검색하지 않고 AST를 사용하는 이유가 있어요. 예를 들어 from pipeline import db처럼 모듈 경로에는 pipeline만 보이지만 실제로는 금지한 DB 모듈을 가져오는 형태가 있을 수 있어요. 상대 import인 from ..db import x나 from . import db도 같은 방식으로 잡아야 해요.
가드가 실제로 위반을 잡는지 확인하기 위해 네거티브 컨트롤도 넣었어요. 위반 예시 문자열을 테스트에 직접 넣고 가드가 이를 실패로 처리하는지 확인하는 방식이에요. 검사 대상 파일이 0개가 되는 문제도 막았어요. 폴더 이름이 바뀌거나 폴더가 이동해 검사할 파일이 없어지면 실패하게 하는 empty-scan sentinel을 넣었어요.
그렇지 않으면 가드는 아무것도 검사하지 않은 채 통과할 수 있어요. 팀의 프로젝트 지도 문서에는 이런 규칙도 적혀 있어요.
이 테스트를 고쳐야 한다면 설계가 틀린 것이다.
예외가 필요할 때는 방을 따로 만들기
제휴 매체 기사를 재구성할 때는 원문을 봐야 해요. 문단 대응과 뉘앙스를 보존하려면 원문 접근이 필요하기 때문이에요.
그래서 작성기에 원문 파라미터를 다시 추가하지 않았어요. 대신 원문을 볼 수 있는 모듈을 따로 분리하고 작성기가 그 모듈을 import하는 것도 금지 목록에 넣었어요. 원문이 한 단계를 건너서 작성기에 들어오는 것도 같은 종류의 누출이기 때문이에요.
반대 방향은 허용했어요. 재구성 모듈이 작성기의 순수 함수를 가져다 쓰는 방식이에요. 이 방향까지 막으면 같은 함수를 복제해야 하고 복제한 두 정의는 언젠가 달라질 수 있어요. 실제로 그런 전례도 있었어요.
라우팅은 fail-closed로 설계하기
모듈 경계를 나눈 다음에는 원문을 보는 경로가 언제 켜질지도 제한했어요. 시장이 허용 목록에 있고 언어가 허용 목록에 있고 묶인 기사가 모두 제휴 라이선스를 가진 경우에만 원문을 보는 경로를 켜요.
제휴 기사와 제휴가 아닌 기사가 한 묶음에 섞이면 제휴 기사만 골라 쓰지 않아요. 전체를 원문을 보지 않는 경로로 강등하고 사유로 mixed_license를 남겨요.
여기서 공허참(vacuous truth)이라는 함정도 발견했어요. 파이썬에서 all([])은 True예요. 기사 목록이 비어 있어도 ‘모든 기사가 제휴 라이선스다’라는 조건이 참이 되는 셈이에요. 그러면 근거 없이 원문 투입 경로가 켜질 수 있어요.
그래서 빈 목록도 원문을 보지 않는 경로로 강등하고 사유로 no_items를 남겼어요. 애매한 경우에는 안전한 쪽으로 떨어지는 fail-closed 방식을 적용한 거예요.
이 과정을 거치며 지켜야 할 정책은 프롬프트보다 타입·모듈 경계·테스트로 강제하는 편이 안전하다는 걸 확인했어요. 특히 ‘0건이면 통과’하는 자리를 일부러 찾아 막아야 했어요.
마무리
경로를 나눠 작성기가 원문을 볼 수 없게 만들었지만 원문을 볼 수밖에 없는 재구성 모듈 안에서는 여전히 복제 문제를 해결해야 했어요.
다음 편에서는 기사를 문단으로 쪼개고 다시 묶는 과정을 다뤄요. 모델을 바꾼 뒤 LLM 호출(콜)을 둘로 나누기까지 했는데요. 콜 수는 65% 줄었는데 비용은 왜 20%만 줄었는지도 함께 살펴볼게요.
연재 목차
공개된 편부터 링크를 걸어 둘게요.
- "베끼지 마"는 프롬프트로 막을 수 없었다 (지금 읽는 글)
- 쪼개고, 묶고, 다시 나누기 — 콜은 65% 줄었는데 비용은 왜 20%만 줄었을까
- 검사기가 거꾸로 일하고 있었다 — LLM 품질 게이트를 의심하는 법
- 고친 자리 옆에 결함이 다시 생긴다 — 일본어 재구성과 “사람에게 넘기기”
- 1 billion은 100억이 아니다 — 숫자 번역을 LLM에게 맡기지 않는 이유
- 모델이 "다 했어요"라고 할 때 — 잘린 번역, 섞인 한글, 정규식 한 글자
- 검사기를 운영한다는 것 — 오탐 48%, 묻힌 기사, 그리고 자동 발행