Python Software Foundation이 배포하는 Python 로고(™ 표기 포함)를 원본 그대로 두고 오른쪽에 글 제목을 적은 이미지

프롬프트 템플릿 str.format() KeyError, 이스케이프냐 Jinja2냐

LLM 애플리케이션의 시스템 프롬프트에는 사용자 입력과 함께 JSON 형식의 출력 예시를 넣는 일이 흔합니다. 그런데 이 둘을 한 문자열에 담아 str.format()으로 채우면, 모델을 부르기도 전에 문자열을 만드는 단계에서 KeyError가 나며 프로그램이 멈춥니다. 예시를 감싼 중괄호와 입력 변수를 파이썬이 구별하지 못하기 때문입니다.

그래서 Debian 환경에서 이 오류를 그대로 재현한 다음, 두 가지 처방을 실제 코드로 비교했습니다. 하나는 이중 중괄호 이스케이프이고, 다른 하나는 Jinja2 템플릿 엔진입니다. 출발점은 str.format()이 템플릿을 해석하는 방식에 있는 구조적인 제약입니다.

프롬프트도 배포 전에 완성본을 눈으로 읽어 봐야 하는 문자열입니다. 사용자 입력만 갈아 끼울 때는 문제가 없어 보이다가, 응답 모양을 정해 주려고 JSON 예시를 붙이는 순간 템플릿 문법과 예시 내용이 부딪힙니다.

목표는 분명합니다. 모델에 보낼 지시문을 쓰는 실무 상황에서, 입력값은 제자리에 치환되고 예시의 중괄호는 손대지 않은 채 남게 만드는 것입니다.


JSON 예시를 붙인 프롬프트가 str.format()에서 멈추는 장면

재현 코드는 JSON 응답 예시와 user_input을 한 문자열에 넣었습니다. 사용자 입력값은 넘겼지만, 예시를 감싼 중괄호에는 아무 처리도 하지 않은 상태입니다. 예시의 score에 적힌 0.95는 모델이 매긴 점수가 아니라 프롬프트에 미리 써 둔 값입니다. 그러니 여기서 볼 것은 응답 품질이 아니라 치환이 끝까지 가느냐입니다.

# 에러가 발생하는 프롬프트 예시
prompt_template = """
다음 텍스트를 분석하여 아래 JSON 형식으로만 응답하세요.
예시 포맷: {"sentiment": "positive", "score": 0.95}

분석할 텍스트: {user_input}
"""

# 변수 치환 시도
rendered = prompt_template.format(user_input="오늘 서비스 배포가 성공적으로 끝났습니다.")

실행하면 곧바로 KeyError가 나고 문자열 생성이 중단됩니다. 눈여겨볼 곳은 예외에 찍힌 이름입니다. user_input이 아니라 JSON 예시에 있던 sentiment가 나옵니다. 입력을 제대로 넘겼는지만 의심하면 엉뚱한 곳을 뒤지게 되는 이유가 이것입니다.

Traceback (most recent call last):
  File "/tmp/test_prompt.py", line 10, in <module>
    rendered = prompt_template.format(user_input="오늘 서비스 배포가 성공적으로 끝났습니다.")
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
KeyError: '"sentiment"'

터미널 캡처에는 기본 포매팅에서 난 오류와 이중 중괄호로 처리한 결과가 함께 있습니다. 오류 메시지를 코드의 JSON 항목과 맞대어 보면, 입력 변수가 아닌 자리에서도 치환을 시도했다는 사실이 드러납니다. 고칠 때는 예시를 감싼 중괄호와 값을 넣을 중괄호를 하나씩 짚어 가며 구분합니다. 운영에 올리기 전에는 완성된 프롬프트에 JSON 예시와 사용자 문장이 둘 다 온전히 남았는지 읽어 봅니다.

직접 캡처(터미널 창), 2026-10-14 08:20 KST, Debian 13, Python 3.12.15 — str.format()의 KeyError와 이중 중괄호 이스케이프 실행 결과
2026-10-14 08:20 KST, Debian 13, Python 3.12.15 — str.format() 호출에서 KeyError: ‘”sentiment”‘를 재현합니다.

캡처에는 test_prompt.py가 놓인 실제 작업 폴더가 찍혀 있고, 본문 트레이스백은 그 위치를 /tmp로 줄여 적었습니다. 줄 번호와 KeyError 내용은 같습니다.

KeyError에 sentiment가 찍히는 이유

str.format() 파서는 문자열 안의 단일 중괄호 {}를 전부 치환할 변수 플레이스홀더(Placeholder)로 봅니다. 개발자가 의도한 {user_input}만 골라 주지 않습니다. JSON 예시로 적어 둔 {“sentiment”: …}까지 변수 이름으로 받아들여 값을 찾다가 실패하는 것, 그것이 앞의 예외입니다.

패키지 없이 고치기: 예시 쪽 중괄호를 두 겹으로

첫 번째 처방은 외부 패키지 없이 예시를 지키는 방식입니다. JSON 예시에는 {{와 }}를 쓰고, 실제로 치환할 {user_input}은 그대로 둡니다. 포매팅을 거치고 나면 이스케이프한 중괄호는 평범한 중괄호로 돌아와 모델에 보낼 예시가 됩니다. 예시 가운데 한 곳이라도 이 처리를 빠뜨리면 앞에서 본 충돌이 고스란히 되살아납니다.

prompt_template = """
다음 텍스트를 분석하여 아래 JSON 형식으로만 응답하세요.
예시 포맷: {{"sentiment": "positive", "score": 0.95}}

분석할 텍스트: {user_input}
"""

rendered = prompt_template.format(user_input="오늘 서비스 배포가 성공적으로 끝났습니다.")
print(rendered)

장점은 별도의 패키지 의존성 없이 파이썬 내장 기능만으로 바로 동작한다는 데 있습니다. 대가는 가독성입니다. 프롬프트가 길어지거나 중첩된 JSON 스키마가 들어가면 읽기가 심하게 어려워지고, 외부 파일에서 불러온 JSON 예시를 결합할 때 실수가 나기 쉽습니다.

조건문이 필요해지면 Jinja2로 옮기기

프롬프트 안에서 조건에 따라 문장을 넣고 빼거나 예시를 반복해야 한다면, 그때가 Jinja2로 넘어갈 시점입니다. Jinja2는 변수 치환에 {{ variable }}을 쓰기 때문에 일반 JSON 예시의 단일 중괄호와 섞이지 않습니다. 다만 기존 포매팅 문자열을 옮길 때 입력 변수의 표기도 함께 바뀌므로, 예시와 치환 위치를 나란히 놓고 다시 점검해야 합니다.

설치하고 렌더링해 보기

예제에 쓸 Jinja2 패키지부터 설치합니다. 내장 포매팅과 달리 이쪽은 실행 환경에 외부 패키지가 있어야 합니다. 배포 이미지나 가상환경처럼 템플릿을 실제로 렌더링하는 파이썬 환경에 패키지가 들어가 있는지 챙겨 두고, 설치가 끝나면 진짜 템플릿을 불러와 문자열이 만들어지는지 확인합니다.

pip install Jinja2

다음 예제는 JSON 예시를 그대로 둔 채 사용자 입력과 조건부 지시문을 렌더링합니다. strict_mode=True는 템플릿의 조건문에 넘기는 값으로, 마크다운 없이 답하라는 문장을 끼워 넣는 용도입니다. 이름이 비슷해 보여도 미정의 변수 처리를 엄격하게 만드는 설정과는 하는 일이 다릅니다. 조건부 문장을 넣었다고 모델 응답의 형식까지 보장되지는 않으니, 이 단계에서 확인하는 대상은 렌더링된 프롬프트 내용입니다.

from jinja2 import Template

prompt_template = Template("""
다음 텍스트를 분석하여 아래 JSON 형식으로만 응답하세요.
예시 포맷: {"sentiment": "positive", "score": 0.95}

{% if strict_mode %}
주의: 마크다운 코드 블록 없이 순수 JSON 문자열만 출력하세요.
{% endif %}

분석할 텍스트: {{ user_input }}
""")

rendered = prompt_template.render(
    user_input="오늘 서비스 배포가 성공적으로 끝났습니다.",
    strict_mode=True
)
print(rendered)
직접 캡처(터미널 창), 2026-10-16 05:54 KST, Python 3.13.5, Jinja2 3.1.6 — render.py 실행 출력
2026-10-16 05:54 KST, Python 3.13.5, Jinja2 3.1.6 — render.py를 실제로 실행하면 JSON 예시의 중괄호는 그대로 남고 strict_mode 안내문과 user_input만 채워집니다.

캡처를 보면 JSON 예시의 중괄호는 손대지 않은 채 남고, 조건부 안내문과 사용자 입력만 채워졌습니다. 템플릿 문법({{ }} 변수, {% if %} 조건문)의 전체 규칙은 Jinja 공식 문서 Template Designer Documentation에 정리되어 있습니다(확인일 2026-10-16).

설치되는 배포 버전은 PyPI Jinja2 패키지 페이지에서 맞춰 봤습니다. pip install Jinja2로 받는 버전은 3.1.6이었습니다(확인일 2026-10-16). 소스 코드와 릴리스 기록은 pallets/jinja GitHub 저장소에 있습니다(확인일 2026-10-16).

두 번째 캡처는 JSON 예시와 조건문을 함께 쓰는 Jinja2 코드입니다. 볼 부분은 예시의 중괄호를 그대로 둔 채 사용자 입력만 다른 치환 문법으로 표현했다는 점입니다. 조건을 켜면 순수 JSON 문자열을 요구하는 지시문이 프롬프트에 들어가도록 짰기 때문에, 템플릿 구성과 넘긴 값을 한 화면에서 대조하기 좋습니다.

직접 캡처(터미널 창), 2026-10-14 08:20 KST, Debian 13, Jinja2 3.1.6 — strict_mode=True로 렌더한 결과
2026-10-14 08:20 KST, Debian 13, Jinja2 3.1.6 — strict_mode=True로 JSON 예시·조건문을 render하는 코드를 제시합니다.

Jinja2로 옮기고 나서 달라지는 것

첫 번째 차이는 JSON 스키마를 손보지 않아도 된다는 점입니다. 일반 JSON 포맷({“key”: “value”})을 고치지 않고 프롬프트에 붙여 넣으면 되니 유지보수가 편해집니다. {% if %}, {% for %} 구문으로 상황에 맞춰 지시사항이나 Few-shot 예제를 넣고 빼는 동적 분기도 템플릿 안에서 처리됩니다. 변수 관리 쪽에서는 StrictUndefined 옵션이 쓸모 있습니다. 이 옵션을 켜 두면 필수 변수가 빠졌을 때 빈 문자열로 조용히 넘어가지 않고 분명한 예외를 내 주기 때문에, 디버깅할 때 원인을 바로 짚게 됩니다.

이 오류에서 먼저 볼 곳은 예외에 찍힌 이름입니다. 이번 KeyError에는 입력 변수 대신 JSON 예시의 항목 이름이 나왔습니다. str.format()이 예시를 감싼 중괄호까지 치환 필드로 읽고, 거기에 맞는 값을 찾다가 실패했다는 뜻입니다.

고치는 순서도 정해 두면 편합니다. 먼저 바뀔 값과 글자 그대로 나가야 할 예시를 나눕니다. 기본 포매팅을 계속 쓴다면 예시 쪽 중괄호를 두 겹으로 바꾸고, 수정한 뒤 완성된 문자열에서 예시와 입력값을 같이 읽어 봅니다. 예시가 길거나 중첩되어 있으면 놓친 중괄호가 숨어 있기 쉬우니 한 번 더 훑습니다.

Jinja2로 옮겼다고 변수 이름 점검이 필요 없어지지는 않습니다. 필수 변수가 빠지는 문제는 앞에서 말한 엄격한 미정의 변수 처리로 막는 편이 맞습니다. JSON 중괄호 충돌과 입력 변수 누락은 겉모습이 비슷해도 원인이 다르니, 같은 문제로 묶어서 고치지 않습니다.

템플릿을 고친 배포도 렌더링으로 확인하기

배포 환경을 갱신할 때 패키지가 설치됐다는 사실만으로 정상이라고 판단하면 위험합니다. 헬스체크에 실제로 쓰는 템플릿을 읽어 대표 입력으로 문자열을 만들어 보는 단계를 넣어 두면, 렌더링이 깨진 배포를 요청이 들어오기 전에 걸러 냅니다. 템플릿 파일 위치나 설정을 읽는 경로를 바꿨다면 점검 코드가 보는 경로도 같이 바꿔야 합니다. 서비스 코드와 점검 코드가 서로 다른 템플릿을 읽고 있으면, 배포 직후의 문제가 점검을 그대로 통과해 버립니다.

미정의 변수 처리 방식을 바꾸는 작업도 배포 설정과 함께 봅니다. 예제의 조건부 지시문 값을 필수 변수 검사 설정으로 착각하지 않도록 이름부터 구분해 둡니다. 엄격한 변수 검사를 켰다면 정상 입력으로 렌더링되는 경우와 필수 입력이 빠진 경우를 따로 점검합니다. 패키지나 API를 올릴 때도 이 기준을 지켜 두면, 템플릿 문법 문제와 입력 전달 문제를 따로따로 추적하게 됩니다.

짧은 예시는 이중 중괄호, 자주 고치는 프롬프트는 Jinja2

구분Python str.format()string.Template ($변수)Jinja2 템플릿 엔진
JSON 중괄호 충돌발생함 ({{ }} 이스케이프 필수)발생 안 함발생 안 함
조건문/반복문 지원미지원미지원완벽 지원
외부 패키지 필요 여부불필요 (내장)불필요 (내장)필요 (pip install Jinja2)
추천 사용처한두 줄짜리 단순 프롬프트외부 의존성을 최소화해야 할 때실무 LLM 파이프라인 및 복잡한 프롬프트

표에서 갈리는 지점은 결국 프롬프트를 얼마나 자주 고치고 누가 관리하느냐입니다. 운영 중에 예시와 지시사항을 계속 손봐야 한다면, 그때마다 중괄호를 일일이 맞추는 수고까지 비용으로 셈해야 합니다.

조건에 따라 지시문을 넣고 빼거나 예시를 반복해서 구성해야 하는 담당자라면 Jinja2가 맞습니다. 본문의 분기 예시처럼 문장 구성 자체가 달라지는 작업이 여기에 해당합니다.

반대로 짧은 일회성 스크립트를 쓰는 분께는 템플릿 엔진까지 들이라고 하지 않겠습니다. 치환할 값이 단순하다면 기존 방식에 이스케이프 처리만 더해 끝내는 쪽을 고릅니다.

외부 의존성을 최소화해야 하는 환경이라면 내장 모듈부터 검토합니다. 기능이 많다는 이유만으로 설치할 패키지를 늘릴 까닭은 없다고 봅니다.

어떤 방식을 고르든 마지막에는 모델에 실제로 전달될 문자열을 사람이 읽을 수 있어야 합니다. 예시 형식과 입력값이 의도대로 남았는지 확인한 다음에 적용합니다.

단순한 일회성 스크립트라면 이중 중괄호({{ }})나 내장 모듈인 string.Template으로 충분합니다. 하지만 프롬프트를 계속 다듬어 가며 JSON 출력을 다루는 프로젝트라면, 처음부터 Jinja2 기반으로 프롬프트 템플릿 구조를 잡아 두기를 권합니다.

세 캡처에 남은 실행 결과를 기준으로 한 줄로 줄이면 이렇습니다. 짧고 고정된 예시는 이중 중괄호로 관리하고, 분기와 예시 수정이 잦은 프롬프트는 Jinja2로 구성합니다. 운영에서는 템플릿 변경도 배포 변경과 똑같이 다루고, 대표 입력을 넣은 최종 문자열을 검토 기록으로 남겨 둡니다. 실시간 서비스라면 첫 요청을 받기 전에 템플릿 로딩과 렌더링 점검이 끝나 있어야 합니다. 서버 사양은 이 문자열 예제로 정할 일이 아니고, 실제 요청 처리와 모델 호출에 드는 자원을 보고 판단합니다. 외부 의존성을 줄여야 하는 구성이라면 비교표의 내장 모듈 대안도 같이 놓고 고릅니다.

프롬프트 템플릿을 실제 모델 호출과 문서 처리로 이어 가는 글은 RAG·AI API 활용 허브에 순서대로 모아 두었습니다.

Similar Posts

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다