🛠️ 개발 환경
- 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)
이렇게 하면 청크 경계에서 잘린 멀티바이트 문자도 정상적으로 처리할 수 있다.
🤔 회고
스트리밍 시스템에서는 청크 경계뿐만 아니라 인코딩/디코딩 과정에서도 멀티바이트 문자 처리에 주의해야 한다. 이슈를 미리 인지하고 경계 처리를 구현하면 데이터 손실 없이 안정적인 스트리밍이 가능할 것 같다.
'Spring > 트러블 슈팅' 카테고리의 다른 글
| [Spring] - springdoc-openapi 구조 미노출 이슈 (0) | 2025.05.20 |
|---|---|
| [Spring] - 벌크 조회를 이용한 성능 최적화 (0) | 2025.01.29 |
| [Spring] - NoSuthMethod (0) | 2025.01.17 |
| [Spring] - SSLHandshakeException (4) | 2024.10.22 |
| [Spring] - 타임리프 경로 에러 (2) | 2024.08.21 |