LLM 출력에 이모지·제어문자가 섞이면 DB 저장이 이렇게 깨집니다

LLM 출력에 이모지

LLM 출력에 이모지와 제어문자가 그대로 섞여 들어오면, 필터링은 DB에 넣기 직전 애플리케이션 계층에서 끝내야 사고를 막을 수 있습니다. DB 쪽 설정(utf8mb4 전환 등)만 손보고 끝내면 널 바이트나 서로게이트 페어처럼 DB 레이어가 못 잡는 값이 여전히 통과합니다. 이번 글에서는 어디서, 어떤 코드로 걸러야 실제로 안전한지 정리해 보겠습니다.

LLM 출력에 이모지가 왜 DB 저장을 깨뜨릴까요?

ChatGPT나 Claude 같은 API가 돌려주는 텍스트는 기본적으로 UTF-8 인코딩입니다. 문제는 이모지 대부분이 유니코드 U+1F300 이상, 이른바 astral plane 영역에 있어서 UTF-8로는 4바이트, UTF-16 기반 언어(자바스크립트, 자바)에서는 서로게이트 페어 두 개로 표현된다는 점입니다.

여기에 더해 스트리밍 응답을 그대로 이어붙이거나, 모델이 코드블록·표를 생성하는 과정에서 드물게 널 바이트(U+0000)나 ANSI 이스케이프 시퀀스(U+001B) 같은 제어문자가 섞여 나오는 경우도 있습니다. 유니코드 표준의 일반 카테고리(General Category) 정의에 따르면 이런 문자는 Cc(제어)나 Cf(서식) 범주에 속하는데, 이 분류 기준은 유니코드 공식 문서(Unicode Character Database)에서 확인할 수 있습니다. DB 컬럼, JSON 파서, CSV 익스포터마다 이 범주를 처리하는 방식이 달라서 같은 텍스트도 어디서는 통과하고 어디서는 에러를 냅니다.

MySQL과 PostgreSQL, 깨지는 지점이 다릅니다

MySQL은 charset을 utf8(사실상 utf8mb3, 3바이트 제한)로 설정한 테이블에 4바이트 이모지를 넣으면 즉시 오류를 냅니다. utf8mb4로 바꾸면 이모지 자체는 저장되지만, 널 바이트 같은 제어문자는 charset과 무관하게 별도 문제로 남습니다. 4바이트 UTF-8 지원 범위는 MySQL 공식 문서의 utf8mb4 설명에 나와 있습니다.

PostgreSQL은 애초에 text, varchar 계열 컬럼이 UTF-8 유효 문자열이면 이모지는 문제없이 저장합니다. 다만 값 안에 0x00(널 바이트)이 섞이면 인코딩 오류로 insert가 거부됩니다. C 문자열 관례를 따르는 내부 저장 구조 때문에 생기는 제약이라, utf8mb4 같은 옵션을 켠다고 해결되지 않습니다.

모니터에 데이터베이스 오류 코드를 띄워 놓고 디버깅하는 개발자

DB 이모지(4바이트 UTF-8) 제어문자(널 바이트 등)
MySQL (charset utf8) insert 시 즉시 오류 오류 또는 잘림
MySQL (charset utf8mb4) 정상 저장 여전히 오류 가능
PostgreSQL (UTF8) 정상 저장 널 바이트 포함 시 오류
SQLite 대부분 정상 저장 널 바이트 포함 시 문자열이 중간에서 잘림

표에서 보듯 “이모지 문제”와 “제어문자 문제”는 원인도 다르고 DB별 반응도 달라서, DB 설정 하나로 두 가지를 동시에 해결할 수 없습니다.

필터링은 DB 앞단, 애플리케이션 계층에서 먼저 걸러 보세요

DB 설정을 utf8mb4로 바꾸는 작업은 필요하긴 하지만 그것만으로는 부족합니다. 실무에서 안정적인 순서는 LLM 응답을 받는 즉시 새니타이즈 함수를 통과시키고, 그 결과만 insert 쿼리에 넘기는 구조입니다.

FastAPI를 쓴다면 Pydantic의 field_validator에서, Django라면 모델의 clean()이나 시리얼라이저 validate_* 메서드에서, SQLAlchemy를 쓴다면 before_insert 이벤트 리스너에서 이 로직을 한 곳에 모아두는 편이 낫습니다. 여러 엔드포인트마다 각자 문자열을 다듬게 두면 어느 한 곳에서 누락됐을 때 그 요청만 500 에러를 내고, 원인 파악에 시간이 걸립니다.

정규식과 unicodedata로 만드는 새니타이저 함수

파이썬 표준 라이브러리 reunicodedata만으로도 제어문자와 이모지를 함께 걸러낼 수 있습니다. 외부 패키지 없이 동작하는 예시는 다음과 같습니다.

import re
import unicodedata

CONTROL_CHARS_RE = re.compile(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]')
EMOJI_RE = re.compile(
    "["
    "\U0001F300-\U0001FAFF"
    "\U00002600-\U000027BF"
    "\U0001F1E6-\U0001F1FF"
    "\uFE0F"
    "]+",
    flags=re.UNICODE,
)

def sanitize_for_db(text: str) -> str:
    text = CONTROL_CHARS_RE.sub('', text)
    text = EMOJI_RE.sub('', text)
    text = ''.join(ch for ch in text if unicodedata.category(ch) != 'Cf')
    return text.strip()

sample = "완료했습니다! 🎉\x00 다음 단계로 진행하세요\u200b."
print(sanitize_for_db(sample))

화면에 깨진 문자와 인코딩 오류가 표시된 컴퓨터

위 코드를 실행하면 출력은 다음과 같습니다.

완료했습니다!  다음 단계로 진행하세요.

CONTROL_CHARS_RE는 탭(\x09)과 줄바꿈(\x0a, \x0d)만 남기고 나머지 제어문자를 지웁니다. EMOJI_RE는 자주 쓰이는 이모지 블록과 국기 이모지, 변형 선택자(\uFE0F)를 제거합니다. 마지막 줄은 unicodedata.category로 Cf(서식 문자, 예: 폭 없는 공백)까지 한 번 더 걸러줍니다. 유니코드가 매년 새 이모지 블록을 추가하기 때문에 범위를 넓게 잡아뒀지만, 최신 이모지를 놓칠 수 있다는 점은 감안해야 합니다.

이 방식이 안 통하는 경우도 있을까요?

디버깅이나 감사 목적으로 LLM의 원문 응답을 그대로 보관해야 하는 로그 테이블에는 새니타이즈된 값만 남기면 곤란합니다. 나중에 “왜 이런 출력이 나왔는지” 재현할 근거가 사라지기 때문입니다. 이런 경우엔 서비스용 컬럼과 별개로 원문 저장용 컬럼을 하나 더 두고, 그쪽은 JSON이나 base64로 인코딩해 저장하는 방식이 안전합니다.

또한 채팅 UI처럼 사용자에게 이모지를 그대로 보여줘야 하는 화면이라면, 이모지를 지우는 대신 DB를 처음부터 utf8mb4로 맞추고 제어문자만 걸러내는 쪽이 맞습니다. 정규식으로 만든 이모지 범위는 관리형 목록이 아니라서, 신규 유니코드 버전에 추가된 이모지를 놓치는 오탐도 생길 수 있다는 점을 감안해야 합니다.

지금 배포 중인 서비스라면 이 순서로 점검해 보세요

먼저 DB charset이 MySQL이라면 utf8mb4인지 확인하고, 아니라면 마이그레이션 계획을 세워야 합니다. 그다음 LLM 응답을 insert하는 코드 경로가 몇 군데인지 파악하고, 위 새니타이저 같은 함수를 한 곳(미들웨어, ORM 이벤트, 시리얼라이저 검증 단계 중 하나)에 모으는 작업이 필요합니다. 원문 보존이 필요한 로그성 데이터는 서비스용 컬럼과 분리해 별도로 저장하면, 이모지·제어문자 문제와 감사 요구사항을 동시에 만족시킬 수 있습니다.

LLM 타임아웃과 재시도 설계, 지수 백오프가 비용을 키우는 구간

Leave a Comment