컨텍스트 엔지니어링: 무엇을 넣을지가 아니라 무엇을 버릴지의 문제
RAG 파이프라인을 붙이고도 답변 품질이 들쭉날쭉했던 이유는 검색이 아니라 컨텍스트 구성에 있었습니다. 토큰을 예산으로 보고 도구 정의·대화 히스토리·검색 결과를 재배분한 과정을 정리했습니다.

TL;DR
- 프롬프트를 다듬어도 답변이 안 좋아진다면, 문제는 문장이 아니라 컨텍스트 구성일 가능성이 큽니다.
- 컨텍스트 윈도는 용량이 아니라 예산입니다. 시스템 프롬프트·도구 정의·대화 히스토리·검색 결과가 같은 지갑을 씁니다.
- 무엇을 넣을지 고민하기 전에 지금 무엇이 들어가 있는지부터 재야 합니다. 대부분 도구 정의와 히스토리가 예상보다 많이 먹고 있습니다.
- 검색 결과를 늘리는 것보다 관련 없는 것을 빼는 쪽이 품질을 더 크게 올립니다.
pgvector로 RAG 파이프라인을 붙이고 나서 한동안 만족했습니다. 사내 문서를 넣으면 그럴듯한 답이 나왔으니까요.
그런데 실제로 쓰기 시작하니 답변 품질이 들쭉날쭉했습니다. 같은 종류의 질문인데 어떤 건 정확하고 어떤 건 문서에 분명히 있는 내용을 "찾을 수 없다"고 답했습니다. 처음엔 검색이 문제라고 생각해서 임베딩 모델을 바꾸고, 청크 크기를 조정하고, top-k를 5에서 10으로 올렸습니다.
더 나빠졌습니다.
검색이 더 많이 가져올수록 답변이 산만해졌습니다. 그제서야 문제가 "무엇을 찾아오는가"가 아니라 "찾아온 걸 어떻게 담는가"에 있다는 걸 알았습니다.
프롬프트 엔지니어링에서 컨텍스트 엔지니어링으로
몇 년 사이 이 영역의 관심사가 이렇게 옮겨왔습니다.
| 시기 | 관심사 | 핵심 질문 |
|---|---|---|
| 2023 | 프롬프트 엔지니어링 | 어떻게 물어볼 것인가 |
| 2024–2025 | RAG · 검색 증강 | 무엇을 찾아올 것인가 |
| 2026 | 컨텍스트 엔지니어링 | 무엇을 남기고 무엇을 버릴 것인가 |
프롬프트 엔지니어링은 "이렇게 쓰면 더 잘 답한다"는 문장 기술이었습니다. RAG는 "모르는 건 찾아다 주면 된다"는 접근이었고요. 둘 다 유효하지만 공통적으로 더하는 방향입니다.
컨텍스트 엔지니어링은 반대입니다. 모델에 들어가는 입력 전체를 하나의 설계 대상으로 보고, 한정된 자리에 무엇을 앉힐지 배분하는 일입니다. 더하기가 아니라 나누기에 가깝습니다.
컨텍스트 윈도는 용량이 아니라 예산이다
"컨텍스트 윈도 20만 토큰"이라는 숫자를 처음엔 여유로 읽었습니다. 문서 몇 개 넣는 정도는 아무것도 아니라고요.
착각이었습니다. 그 자리를 쓰는 게 검색 결과만이 아니었습니다.
| 구성 요소 | 성격 | 통제 가능성 |
|---|---|---|
| 시스템 프롬프트 | 매 호출 고정 | 높음 |
| 도구 정의(스키마) | 매 호출 고정, 도구 수에 비례 | 높음 — 그런데 대부분 방치 |
| 대화 히스토리 | 턴이 쌓일수록 선형 증가 | 중간 |
| 검색 결과 | top-k × 청크 크기 | 높음 |
| 사용자 입력 | 가변 | 낮음 |
| 출력 예약분 | 답변 길이만큼 미리 비워둬야 함 | 중간 |
여기서 중요한 건 이것들이 서로 경쟁한다는 점입니다. 도구를 하나 더 붙이면 그만큼 검색 결과 자리가 줄어듭니다. 대화가 길어지면 문서를 덜 넣게 됩니다. 각각을 따로 튜닝하면 전체가 망가집니다.
그래서 저는 이걸 예산표로 관리하기 시작했습니다.
먼저 무엇이 들어가 있는지부터 쟀다
튜닝하기 전에 현황을 찍었습니다. Spring AI에서는 어드바이저로 호출 직전 입력을 가로챌 수 있습니다.
@Component
public class TokenBudgetAdvisor implements CallAroundAdvisor {
private static final Logger log = LoggerFactory.getLogger(TokenBudgetAdvisor.class);
private final TokenCountEstimator estimator;
@Override
public AdvisedResponse aroundCall(AdvisedRequest request, CallAroundAdvisorChain chain) {
int system = estimator.estimate(request.systemText());
int tools = request.functionCallbacks().stream()
.mapToInt(callback -> estimator.estimate(callback.getInputTypeSchema()))
.sum();
int history = request.messages().stream()
.mapToInt(message -> estimator.estimate(message.getText()))
.sum();
int user = estimator.estimate(request.userText());
log.info("context budget - system={} tools={} history={} user={} total={}",
system, tools, history, user, system + tools + history + user);
return chain.nextAroundCall(request);
}
}찍어놓고 보니 예상과 달랐습니다. 저는 검색 결과가 대부분을 차지할 거라고 생각했는데, 도구 정의와 대화 히스토리가 합쳐서 절반 가까이 먹고 있었습니다.
숫자는 환경마다 크게 다릅니다. 중요한 건 제 비율이 아니라 직접 재보면 대개 예상과 다르다는 점입니다. 추측으로 튜닝하면 엉뚱한 곳을 건드리게 됩니다.
도구 정의가 생각보다 비쌌다
Tool Calling을 붙이면서 편하다는 이유로 도구를 계속 늘렸습니다. 조회용 메서드에 @Tool을 붙이면 바로 쓸 수 있으니 부담이 없었습니다.
문제는 도구 정의가 매 호출마다 전부 전송된다는 것입니다. 쓰지 않는 도구도 자리를 차지합니다. 설명을 성실하게 쓸수록, 파라미터 스키마가 중첩될수록 무거워집니다.
게다가 비용만 드는 게 아니었습니다. 도구가 많아지면 모델이 잘못 고르는 빈도도 같이 올라갑니다. 이름과 설명이 비슷한 도구가 여럿 있으면 엉뚱한 걸 호출했습니다.
정리하면서 세운 기준입니다.
| 기준 | 조치 |
|---|---|
| 최근 호출 기록이 없는 도구 | 제거 |
| 설명이 서로 비슷한 도구 | 하나로 합치거나 이름을 명확히 분리 |
| 파라미터가 5개 이상 | 객체 하나로 묶어 스키마 단순화 |
| 특정 상황에서만 쓰는 도구 | 그 상황에서만 동적으로 등록 |
마지막 항목이 효과가 컸습니다. 모든 도구를 항상 붙일 필요가 없습니다. 의도 분류를 한 번 거쳐 그 요청에 필요한 도구만 등록하면, 자리도 아끼고 오선택도 줄어듭니다.
대화 히스토리를 어디서 자를 것인가
히스토리는 턴이 쌓일수록 선형으로 늘어납니다. 그런데 오래된 턴이 항상 덜 중요한 건 아닙니다. 첫 턴에서 정한 제약("Java 17 기준으로", "우리 팀은 Kafka를 씁니다")이 뒤에서도 계속 유효한 경우가 많습니다.
단순히 최근 N턴만 남기면 이 제약이 사라집니다. 그래서 세 갈래로 나눴습니다.
| 구간 | 처리 |
|---|---|
| 첫 턴의 제약·설정 | 고정 보존 — 요약해서 시스템 쪽에 승격 |
| 중간 턴 | 요약 압축 |
| 최근 3~5턴 | 원문 유지 |
"첫 턴 승격"이 핵심이었습니다. 대화 초반에 사용자가 준 조건을 뽑아 시스템 프롬프트에 얹어두면, 히스토리를 아무리 잘라도 제약이 살아남습니다.
압축은 공짜가 아니다
요약으로 자리를 줄이는 건 효과적이지만 대가가 있습니다.
- 요약 자체가 LLM 호출입니다. 지연과 비용이 추가됩니다
- 요약은 손실 압축입니다. 구체적인 식별자(주문번호, 에러 코드, 파일 경로)가 잘 사라집니다
- 압축된 걸 다시 압축하면 원형에서 점점 멀어집니다
특히 두 번째가 아팠습니다. "아까 그 주문 건"이라고 물었는데 요약 과정에서 주문번호가 날아가 있으면 대화가 무너집니다.
그래서 요약 프롬프트에 보존 목록을 명시했습니다. 식별자·숫자·고유명사·사용자가 명시한 제약은 원문 그대로 남기고, 설명 문장만 줄이도록요. 이것만으로 체감 품질이 꽤 달라졌습니다.
검색 결과는 늘리는 것보다 거르는 게 낫다
처음에 top-k를 5에서 10으로 올렸다가 더 나빠졌다고 썼습니다. 이유는 단순했습니다.
관련도가 낮은 청크가 섞이면 모델이 그걸 근거로 삼으려고 합니다. 문서에 답이 있어도, 옆에 붙은 무관한 청크가 답변 방향을 흔듭니다. 자리도 먹고 품질도 깎는 이중 손해입니다.
바꾼 방식입니다.
- top-k를 넉넉히 뽑되 (20)
- 재순위(rerank)로 실제 관련도를 다시 매기고
- 관련도 임계값 미만은 버린 뒤
- 남은 것 중 상위 3~5개만 컨텍스트에 넣는다
3번이 중요합니다. "무조건 5개를 채운다"가 아니라 기준에 못 미치면 2개만 넣거나 아예 안 넣는 것입니다. 근거가 없으면 없다고 답하는 편이, 엉뚱한 근거로 그럴듯하게 답하는 것보다 낫습니다.
정리 — 컨텍스트 예산표
작업을 마치고 남긴 기준입니다. 절대적인 수치가 아니라 비율로 관리하는 게 요점입니다.
| 항목 | 배분 원칙 |
|---|---|
| 시스템 프롬프트 | 고정. 늘어나면 먼저 의심 |
| 도구 정의 | 요청 유형별로 동적 등록. 항상 전부 붙이지 않음 |
| 대화 히스토리 | 첫 턴 제약 승격 + 중간 요약 + 최근 원문 |
| 검색 결과 | 개수 고정이 아니라 관련도 임계값 기준 |
| 출력 예약분 | 최대 답변 길이만큼 항상 확보 |
그리고 운영 지표로 세 가지를 로그에 남깁니다.
- 호출당 구성 요소별 토큰 비중
- 도구별 실제 호출 빈도 (안 쓰이는 도구 탐지용)
- 검색 결과 중 임계값 통과 비율
이건 분산 추적을 붙였을 때와 같은 이야기입니다. 안 보이면 못 고칩니다. LLM 호출도 다르지 않았습니다.
다음 편
컨텍스트를 정리하고 나니 다음 문제가 보였습니다. 도구를 잘 고르게 만드는 건 모델이 아니라 그 주변 구조라는 것입니다. 도구 권한을 어디까지 줄지, 실패했을 때 어떻게 되돌릴지, 서브에이전트로 언제 쪼갤지.
다음 편에서는 에이전트 하네스 설계를 다룹니다.
참고
- RFC 9457 ProblemDetail 표준화 경험 — 계약을 표준으로 옮긴다는 점에서 결이 같습니다
- Spring AI Tool Calling 구현 과정
- pgvector RAG 파이프라인
- 에이전트 시대에 개발자 역할은 어떻게 바뀌는가