SJ_Koding

[LLM] Surrogate pairs란? ('utf-8' codec can't encode character: surrogates not allowed 해결방법) 본문

LLM

[LLM] Surrogate pairs란? ('utf-8' codec can't encode character: surrogates not allowed 해결방법)

성지코딩 2025. 4. 30. 01:24

Llama-4 모델을 테스트하던 중 자꾸 surrogates not allowed 에러가 발생했다. 이모티콘을 내뱉으려고 하는건지, 가끔씩 한글이 크게 왜곡되면서 이상하게 답변이 오거나 이 에러가 발생했다.

Surrogates라는 개념을 처음 접하는데 이를 이해하기 쉽게 아래 차근차근 풀어서 정리한다. 

우선 UTF-16과 UTF-8의 차이를 알아봐야한다. 아주 쉽게.

UTF-16은 16비트의 고정 길이를 가진다. 따라서 0x10000이상의 코드포인트를 하나의 16비트 단위로 표현할 수 없어서, 이를 해결하기 위해 Surrogate Pair라는 개념을 사용한다. 간단히 말해 2개의 UTF-16의 surrogate 코드유닛 2개를 이용해 20비트의 코드포인트를 표현한다. *기억 (이는 하단에 자세히 서술한다.)

반면 UTF-8은 가변 길이 인코딩 방식이다. 즉 한 문자에 8비트, 16비트, 24비트, 32비트를 사용하여 유니코드의 모든 코드포인트를 표현할 수 있다. 이 때문에 별도의 surrogate를 필요로 하지 않으며 실제로 유효한 문자로 간주하지 않는다. 그래서, Surrogate의 코드유닛 범위를 UTF-8로 인코딩하면 에러가 발생하는 것이다. 에러 해결 보러오신분은 아래로 쭈우욱

UnicodeEncodeError: 'utf-8' codec can't encode character '\ud83d': surrogates not allowed

Surrogates Pair

우선, 코딩적으로 Surrogates는 Surrogates pair가 정확한 명칭이다. surrogate는 대신하다는 의미의 '대리'의 의미를 가지고있는데, Unicode를 대신하여 두 개의 UTF-16 인코딩으로 대신 표현한다 하여 붙여진 이름으로 추정된다.

Surrogate pair는 High Surrogate와 Low Surrogate로 쌍을 이룬다. 각 Surrogate의 코드유닛 범위는 다음과 같다.

{'High Surrogate' : "0xD800" ~ "0xDBFF"}
{'Low Surrogate' : "0xDC00" ~ "0xDFFF"}

High Surrogate와 Low Surrogate는 상위 6비트를 고정된 식별 패턴으로 사용하여, 어떤 코드 유닛이 High인지 Low인지 구분 가능하게 만든다.

 

High Surrogate의 0xD800 ~ 0xDBFF범위는 이진표현으로 `1101 10xx xxxx xxxx`(16bit)
즉, "110110"의 상위 6bit가 고정되어있을 때 가질 수 있는 범위이며

Low Surrogate의 0xDC00 ~ 0xDFFF범위는 이진표현으로 `1101 11yy yyyy yyyy`(16bit)

즉, "110111"의 상위  6bit가 고정되어있을 때 가질 수 있는 범위이다.

 

빨간 글씨들을 이해했다면 글 상단부에 UTF-16의 surrogate 코드유닛 2개를 이용해 20비트의 코드포인트를 표현한다. 에서 왜 20bit를 표현할 수 있는지 느낌이 온다. 위의 x와 y부분만 사용하여 xxxx xxxx xxyy yyyy yyyy(20bit, xy순서 중요)로 코드포인트를 생성하게된다. high surrogate 하위 10개 bit가 먼저오고 뒤이어 low surrogate 하위 10개 bit가 온다.

따라서, (high unit, 10it) + (low unit, 10bit) == unicode codepoint (20bit) 가 성립된다.

파이썬 코드로는 아래와 같이 구현된다.

CodePoint = chr(0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00))

어려워 보이지만 굉장히 쉬운 수식인데, 아래 비트단위로 보면 이해가 쉽다.

chr 
== Unicode to str

0x10000
== 0001 0000 0000 0000 0000
== 20bit codepoint 시작지점

((hi - 0xD800)
== 1101 10xx xxxx xxxx
   - 1101 1000 0000 0000
== 0000 00xx xxxx xxxx
== xx xxxx xxxx (10bit)

<<10)
xx xxxx xxxx << 10
== xxxx xxxx xx00 0000 0000 (A)

(lo - 0xDC00))
== 1101 11yy yyyy yyyy
   - 1101 1100 0000 0000
== 0000 00yy yyyy yyyy
== yy yyyy yyyy (B) 

0x10000 + (A) + (B) =
= 0001 0000 0000 0000 0000
+ xxxx xxxx xx00 0000 0000
+                      yy yyyy yyyy

== 20bit의 완성된 코드포인트 생성

이렇게 코드포인트가 완성되었으면, 해당 값은 표준 Unicode 문자 영역에 속하는 실제 문자로 인식됩니다.
예를 들어 😀(U+1F600) 이모지는 다음과 같이 계산됩니다.

  • High Surrogate: 0xD83D
  • Low Surrogate: 0xDE00
    이를 수식에 대입하면

 

 

codepoint = 0x10000 + ((0xD83D - 0xD800) << 10) + (0xDE00 - 0xDC00)
          = 0x10000 + (0x03D << 10) + (0x200)
          = 0x10000 + 0xF400 + 0x200
          = 0x1F600

chr(0x1F600)을 호출하면 바로 😀가 생성됩니다.



바쁘신분은 여기만! 'utf-8' codec can't encode character: surrogates not allowed

위 설명과 같이 utf-8에서 surrogate범위의 코드유닛을 인코딩할 수 없다. 한글이 덜 학습된 LLM의 response문제로 surrogate문제가 발생했을 가능성이 있다. 그리고 surrogate가 pair를 이루지 않은 경우(Unpaired surrogate)도 문제가 되는데, 해결방법은 아래와 같다.

1. 정규표현식 사용하여 surrogates pair대응 및 unpair surrogates제거(복잡하지만, O(n)으로 가장 확실한 방법)

import re

def __normalize(s: str) -> str:
    """
    '\\\\uXXXX' --> '\\uXXXX'
    다 될때 까지 반복
    """
    while True:
        # '\\\\uXXXX' → '\\uXXXX'
        n = re.sub(r'\\\\u([0-9a-fA-F]{4})', r'\\u\1', s)

        if n == s:
            return s

        s = n

def __decode_unicode_escapes(t: str) -> str:
    """
    utf-8 인코딩을 사용하기 전에 서러게이트 쌍(high/low surrogate)이 존재할 경우 
    이를 합쳐 20비트 코드포인트로 변환
    이때 유효하지 않은 서러게이트는 U+FFFD(�)로 대체
    """

    _HI_MIN, _HI_MAX = 0xD800, 0xDBFF  # High surrogate 범위
    _LO_MIN, _LO_MAX = 0xDC00, 0xDFFF  # Low surrogate 범위
    
    # '\\uXXXX' 추출
    _RE_U4 = re.compile(r'\\u([0-9a-fA-F]{4})')

    # 이스케이프 처리
    t = __normalize(t)

    i = 0        # 현재 처리 위치 인덱스
    out = []     # 변환된 문자들을 담을 리스트

    # 문자열 끝까지 순회
    while i < len(t):
        m = _RE_U4.match(t, i)
        # '\\u' 패턴이 아니면 그대로 추가
        if not m:
            out.append(t[i])
            i += 1
            continue

        # '\\uXXXX' → 16진수 숫자로 변환
        hi = int(m.group(1), 16)
        i += 6  # '\\u' + 4자리 코드포인트 길이만큼 인덱스 이동

        # High surrogate일 경우 뒤에 Low surrogate가 이어지는지 확인
        if _HI_MIN <= hi <= _HI_MAX and _RE_U4.match(t, i):
            lo = int(_RE_U4.match(t, i).group(1), 16)
            # Low surrogate 범위가 유효하면 둘을 합쳐 실제 코드포인트로 변환
            if _LO_MIN <= lo <= _LO_MAX:
                # 0x10000 + (high_offset << 10) + low_offset (블로그 상단 수식 참고)
                codepoint = 0x10000 + ((hi - _HI_MIN) << 10) + (lo - _LO_MIN)
                out.append(chr(codepoint))
                i += 6  # Low surrogate 길이만큼 추가 이동
                continue

        # High/Low surrogate 범위를 벗어나면 U+FFFD로 대체하거나 단일 코드포인트로 처리
        if _HI_MIN <= hi <= _LO_MAX:
            # 서러게이트 범위지만 짝이 맞지 않는 경우
            out.append('\uFFFD')
        else:
            out.append(chr(hi))

    # 리스트를 합쳐 최종 문자열 반환
    return ''.join(out)
    
    if __name__ == '__main__': # 테스트 해보세요!!
        examples = {
            "BMP 문자열 (가나다)": r"\uAC00\uB098\uB2E4",
            "이모지 (😀)":        r"\uD83D\uDE00",
            "유효하지 않은 서러게이트": r"\uD800\u0041",
        }

        for desc, esc in examples.items():
            decoded = __decode_unicode_escapes(esc)
            print(f"{desc})
            print(f"원본: {esc}
            print(f"변환: {decoded}")

주의: streaming response으로 진행할 경우, 처음 surrogate가 나왔을 때 이후 low surrogate가 나올때 까지 대기하고, 확인이 되고 나서 변환하는 별도의 로직이 필요합니다. LLM의 토크나이저는 surrogate pair를 대부분 하나의 토큰으로 두지 않습니다.

2. .encode함수의 replace 기능 사용

text = text.encode('utf-8', errors='replace').decode('utf-8')

해당 함수는 surrogate를 pair가 존재하던 말던 무조건 '�'로 대체한다. 임시적으로 에러는 해결 되겠지만,
이모지와 같은 surrogates쌍으로 이루어진 문자는 utf-8에서 나타낼 수 없게된다.