[Spring] - Surrogate 깨짐 이슈

🛠️ 개발 환경

  • Kotlin: 2.2.0
  • Spring Boot: 3.5.3

💬 상황 설명

회사에서는 WebFlux 기반의 서버를 사용하고 있어서, 컨트롤러 호출 전 Netty의 고수준에서 요청 데이터를 받아 내부 객체 형태로 변환한다. 이 과정에서 요청 본문을 UTF-8로 인코딩한 ByteArray로 저장한 뒤, 이를 문자열로 디코딩하여 누적(concatenate)하는 방식으로 처리한다.

 

대량의 데이터가 요청될 경우, Netty가 기본적으로 8KB(8192바이트) 단위로 청크로 나누어 수신하는데, 이때, 8KB 경계에서 UTF-8 멀티바이트 문자가 중간에 잘리면, 디코딩 과정에서 유효하지 않은 시퀀스로 간주되어 (U+FFFD)로 표시된다. 이를 서로게이트 페어가 깨졌다고 표현한다.

 

예를 들어, 8KB(8192바이트) 기준으로 청크를 나눈다고 가정해보면, 한글은 UTF-8 기준으로 3바이트로 표현되므로, 8192 ÷ 3 = 2730.666…라는 결과가 나온다. 따라서 첫 번째 청크에는 2730자의 한글(2730 × 3 = 8190바이트)과 그다음 글자의 앞 2바이트가 포함되고, 나머지 1바이트는 두 번째 청크로 넘어가게 되어, 청크 경계에 걸친 멀티바이트 문자는 깨져 디코딩이 된다.

✅ 해결 과정

가장 단순한 해결책은 현재 청크의 ByteArray에서 완전한 UTF-8 시퀀스가 끝나는 마지막 인덱스를 찾는 것이다.

internal fun findLastCompleteUtf8CharBoundary(buffer: ByteArray): Int {
    var i = buffer.size - 1
    while (i >= 0) {
        val byte = buffer[i].toInt() and 0xFF
        // 0xxxxxxx: ASCII 코드 (완전한 문자)
        if (byte and 0x80 == 0x00) return i + 1
        // 10xxxxxx: 멀티바이트 중간 바이트
        if (byte and 0xC0 == 0x80) {
            i--
            continue
        }
        // 110xxxxx: 2바이트 시퀀스 시작
        if (byte and 0xE0 == 0xC0) {
            return if (buffer.size - i >= 2) i + 2 else i
        }
        // 1110xxxx: 3바이트 시퀀스 시작
        if (byte and 0xF0 == 0xE0) {
            return if (buffer.size - i >= 3) i + 3 else i
        }
        // 11110xxx: 4바이트 시퀀스 시작
        if (byte and 0xF8 == 0xF0) {
            return if (buffer.size - i >= 4) i + 4 else i
        }
        // 그 외: 비정상 바이트
        return i
    }
    return 0
}

boundaryIndex를 구한 뒤, 해당 위치까지의 바이트만 문자열로 디코딩해 누적하고, 남은 바이트는 다음 청크 처리 시 연결한다.

val validBytes = byteBuffer.sliceArray(0 until boundaryIndex)
text += String(validBytes, charset)
byteBuffer = byteBuffer.sliceArray(boundaryIndex until byteBuffer.size)

이렇게 하면 청크 경계에서 잘린 멀티바이트 문자도 정상적으로 처리할 수 있다.

🤔 회고

스트리밍 시스템에서는 청크 경계뿐만 아니라 인코딩/디코딩 과정에서도 멀티바이트 문자 처리에 주의해야 한다. 이슈를 미리 인지하고 경계 처리를 구현하면 데이터 손실 없이 안정적인 스트리밍이 가능할 것 같다.