사용자 입력을 LLM에 그대로 넘기지 않는 4가지 패턴
프롬프트 인젝션과 데이터 유출을 막기 위해 우리 팀이 실제로 적용한 입력 전처리 패턴 네 가지를 공유한다.
LLM 을 제품에 붙이기 시작하면 초기에는 사용자 입력을 그대로 프롬프트에 넣는 코드를 쓰게 된다. 빠르고 편하기 때문이다. 문제는 그게 한동안 잘 동작하다가, 어느 날 누군가가 의도치 않은 방식으로 모델을 조작하거나 시스템 프롬프트를 노출시키는 순간이 온다는 것이다. 우리 팀이 직접 겪은 경험을 바탕으로 네 가지 패턴을 정리했다.
패턴 1 — 역할 구분 강화 (Role Separation)
가장 기본적인 방어다. 사용자 입력을 시스템 프롬프트와 명확히 분리한다. 많은 모델이 system, user, assistant 역할을 별도 메시지 객체로 받는다. 이를 활용하면 단순한 문자열 연결보다 인젝션 저항성이 높아진다.
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_input}, # 절대 system 에 삽입 X
]
시스템 프롬프트 안에 사용자 데이터를 f-string 으로 끼워넣는 패턴은 피한다. “Ignore previous instructions” 류 공격이 system 영역에 침투하게 된다.
패턴 2 — 입력 길이와 문자셋 제한
모델이 받아들이는 컨텍스트 창은 유한하고, 과도하게 긴 입력은 비용과 지연을 동시에 높인다. 우리는 엔드포인트별로 입력 최대 길이를 하드코딩한다. 단순 챗봇 입력이라면 500자, 문서 요약이라면 별도 파이프라인으로 분기한다.
특수문자 제한도 유효하다. 프롬프트 구조를 흉내내는 데 자주 쓰이는 패턴 — 예를 들어 XML 유사 태그, 마크다운 구분자 — 을 제거하거나 이스케이프 처리하면 공격 표면이 줄어든다.
패턴 3 — 출력 구조화와 검증
모델 출력을 자유 텍스트로 받아서 파싱하면 불안정하다. 대신 JSON 스키마를 지정하거나, function calling / tool use 형태로 구조화된 출력을 요청한다.
# 자유 텍스트 파싱 대신
output = call_llm(prompt)
parsed = json.loads(output["choices"][0]["text"]) # 위험: 형식 보장 없음
# 구조화 출력 요청
output = call_llm_with_schema(prompt, schema=ResponseSchema)
# 스키마 검증 통과한 객체만 downstream 으로 전달
구조화 출력은 프롬프트 인젝션으로 모델이 예상치 못한 텍스트를 반환할 때 이를 파싱 에러로 잡아내는 부수 효과도 있다.
패턴 4 — 민감 데이터 마스킹 후 전달
사용자가 입력 안에 이메일 주소, 전화번호, 카드번호 등을 포함할 수 있다. 이를 그대로 모델에 전달하면 로그에 남거나 모델이 응답에 재노출할 위험이 있다. 우리는 정규표현식 기반 마스킹 레이어를 입력 파이프라인 앞단에 넣는다.
def mask_pii(text: str) -> str:
text = re.sub(r'\d{3}-\d{4}-\d{4}', '[PHONE]', text)
text = re.sub(r'[\w.+-]+@[\w-]+\.[a-zA-Z]+', '[EMAIL]', text)
return text
마스킹 후 모델이 [PHONE] 이라는 플레이스홀더를 반환하면, 응답 후처리 단계에서 원본 값으로 복원하거나 아예 제거한다. 복원이 필요한 경우 마스킹 맵을 서버 메모리에 단기 유지하고 세션 종료 시 삭제한다.
요약
네 패턴 모두 완벽한 방어가 아니다. 정교한 공격은 이 방어선도 우회한다. 하지만 대부분의 실제 공격은 기본 패턴이 없는 시스템을 노린다. 역할 분리, 길이 제한, 구조화 출력, PII 마스킹 — 이 네 가지를 파이프라인에 끼워넣는 것만으로 위험 표면이 눈에 띄게 줄어든다는 것을 우리 팀이 직접 확인했다.
— by slecs