41. 채팅 패킷 문자열 길이의 단위 혼동 (C#)
난이도 중해설 — 채팅 패킷 문자열 길이의 단위 혼동 (C#)
난이도: 중
요약
스펙은 msgLen 을 UTF-8 바이트 수로 정의했는데, 구현은 곳곳에서 string.Length
(= UTF-16 코드유닛 수)를 그 자리에 대입한다. 한글은 1문자=3바이트, 이모지는
2코드유닛=4바이트이므로 두 값은 언제나 다르다. 그 결과 ① 인코더가 실제 바디 길이와
다른 msgLen 을 써서 수신 측 프레임 경계가 어긋나고, ② 디코더가 소비 바이트 수를
과소 계산해 같은 desync 를 스스로 만들고, ③ 상한 검사도 스펙과 다른 단위로 이뤄져
클라·서버가 서로 다른 규칙을 갖게 된다. 여기에 Substring 으로 자르면서 서로게이트
페어가 반으로 갈라지고, .NET 기본 인코더가 그걸 예외 없이 U+FFFD 로 치환하기 때문에
서버 로그에는 아무 흔적도 남지 않는다.
문제점
(A) 길이 단위 불일치 — 코드유닛 수를 바이트 수 자리에 쓴다
-
분류: 프로토콜 스펙 위반 · 프레임 desync · 입력 검증 누락
-
증상:
- "안녕하세요" (5코드유닛 / 15바이트)를 브로드캐스트하면
msgLen = 5, 바디는 15바이트로 나간다. 수신 측이 스펙대로 5바이트만 읽으면안녕(6바이트 중 5바이트)조차 온전하지 않은 깨진 문자열을 얻고, 남은 10바이트를 다음 패킷의 헤더로 해석한다. 이후 연결이 끊길 때까지 모든 패킷이 어긋난다. - 이모지 한 글자 "😀" 는 UTF-16 2코드유닛 / UTF-8 4바이트다. 문자 수로 세는 순간 항상 어긋난다.
TryDecode의p += text.Length도 같은 오류다.msgLen바이트를 읽어놓고 코드유닛 수만큼만 소비 위치를 전진시키므로consumed가2 + payloadLen보다 작아진다. 남은 꼬리 바이트가 누적 버퍼에 남아 다음TryDecode의payloadLen으로 읽힌다 — 자기 자신이 만든 desync.- 상한 검사
text.Length > 256은 스펙(256바이트)과 다른 단위다. 한글 200자(600바이트)는 통과하고, ASCII 300자(300바이트)는 거부된다. 클라이언트가 스펙대로 바이트 기준으로 자르면 서버가 통과시키고, 서버가 자르면 클라가 통과시킨다.
- "안녕하세요" (5코드유닛 / 15바이트)를 브로드캐스트하면
-
재현 조건: 다국어 클라 아무거나. ASCII 전용 테스트(영문 QA)에서는 두 값이 같아 절대 재현되지 않는다. 한국/일본 서비스 오픈 직후에만 터지는 전형적 유형이다.
-
근본 원인:
string.Length는 문자 수가 아니라 UTF-16 코드유닛 수다. "문자 수"라고 착각하기 쉬운 이름이 그대로 바이트 수 자리에 들어갔다.- 바이트 수를 구하는 API(
Encoding.UTF8.GetByteCount)가 존재하는데 쓰이지 않았다. - 직렬화·역직렬화·검증 세 곳이 같은 길이 계산을 각자 다시 구현한다. 한 곳만 고쳐도 나머지가 어긋난다.
-
추가로 경계 검증 자체가 빠져 있다.
TryDecode는count < 2 + payloadLen만 확인하고msgLen이payloadLen안에 들어가는지는 보지 않는다. 악의적 클라가payloadLen = 4, msgLen = 60000을 보내면Encoding.UTF8.GetString(buf, p, 60000)이 누적 버퍼의 다른 세션 데이터/미초기화 영역까지 읽거나ArgumentOutOfRangeException으로 워커 스레드를 죽인다. 길이 필드는 언제나 "상한"과 "상위 프레임 내부에 들어맞는가"를 둘 다 검사해야 한다.
(B) 자르기가 코드유닛 단위 — 서로게이트 페어 절단과 무음 치환
- 분류: 인코딩 처리 오류 · 무음 데이터 손상
- 증상:
text.Substring(0, 256)은 UTF-16 코드유닛 256번째에서 자른다. 그 자리가 서로게이트 페어의 한가운데면 결과 문자열의 마지막은 짝 없는 상위 서로게이트(lone high surrogate)가 된다.- 이어지는
Encoding.UTF8.GetBytes는 기본 대체 폴백(replacement fallback)을 쓰므로 예외를 던지지 않고 그 자리에 U+FFFD(EF BF BD)를 넣는다. 서버 로그에는 아무 것도 안 남고, 유저 화면에만 "…🐉" 대신 "…�" 가 뜬다. - 자른 뒤에도 바이트 길이는 전혀 줄지 않는다. 한글 256자를 잘라도 768바이트라 스펙 상한 256바이트를 3배 초과한 채 나간다. 상한을 지키려던 코드가 상한을 못 지킨다.
WriteRelayLog의BinaryWriter.Write(string)은 또 다른 단위를 쓴다. 이 API 는 문자열 앞에 7비트 가변 길이 인코딩(LEB128 류)으로 UTF-8 바이트 수를 붙인다. 즉 채팅 프로토콜의 고정 2바이트msgLen과 형식이 다르다. 릴레이 스트림을 채팅 코덱으로 읽으면 즉시 깨지고, 255바이트를 넘는 순간 길이 프리픽스가 1바이트에서 2바이트로 늘어 오프셋이 또 밀린다.- 수신 측
Encoding.UTF8.GetString역시 잘못된 바이트열을 예외 없이 U+FFFD 로 바꾼다. 깨진 입력이 정상 처리된 것처럼 통과해 DB·로그·다른 서버로 퍼진다.
- 재현 조건: 이모지가 포함된 긴 채팅(길드 공지 복붙 등), 또는 한글 100자 이상 메시지.
- 근본 원인:
- "자른다"의 단위가 스펙(바이트)이 아니라 코드유닛이다.
- 텍스트의 최소 단위는 코드유닛도 코드포인트도 아니고 사용자 인지 기준으로는 자소 클러스터(grapheme cluster) 다. 👨👩👧 같은 ZWJ 이모지는 코드포인트 단위로 잘라도 깨진다.
- 인코더/디코더가 관대 모드(replacement fallback)라 손상이 조용히 통과한다.
수정안
핵심은 ① 길이 계산을 한 곳으로 모으고 전부 바이트 기준으로, ② 길이 필드의 내부 정합성까지 검증, ③ 자르기는 UTF-8 시퀀스 경계(가능하면 자소 클러스터 경계)에서, ④ 인코딩은 엄격 모드.
public static class ChatProtocol
{
public const int MaxMessageLen = 256; // UTF-8 바이트 수
// 엄격 모드: 잘못된 바이트/서로게이트를 조용히 치환하지 않고 예외를 던진다.
public static readonly Encoding Utf8Strict =
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
}
public bool TryDecode(byte[] buf, int offset, int count, out ChatMessage msg, out int consumed)
{
msg = null;
consumed = 0;
if (count < 2) return false;
int p = offset;
ushort payloadLen = BitConverter.ToUInt16(buf, p);
if (payloadLen < 3) throw new InvalidDataException("payload too small");
if (count < 2 + payloadLen) return false; // 아직 다 안 옴
p += 2;
byte channel = buf[p]; p += 1;
ushort msgLen = BitConverter.ToUInt16(buf, p); p += 2;
// ① 상한 검사 + ② 상위 프레임 내부 정합성 검사 (둘 다 필요)
if (msgLen > ChatProtocol.MaxMessageLen)
throw new InvalidDataException($"chat message too long: {msgLen} bytes");
if (msgLen != payloadLen - 3)
throw new InvalidDataException("msgLen does not match payloadLen");
// 엄격 디코딩: 깨진 UTF-8 이면 예외 → 해당 세션만 끊는다
string text = ChatProtocol.Utf8Strict.GetString(buf, p, msgLen);
msg = new ChatMessage { Channel = channel, Text = text };
consumed = 2 + payloadLen; // 소비량은 프레임 길이로 확정
return true;
}
public byte[] Encode(ChatMessage msg)
{
// 자르기와 인코딩을 한 번에 — 바이트 배열이 곧 진실이다
byte[] body = EncodeTruncatedUtf8(msg.Text, ChatProtocol.MaxMessageLen);
int payloadLen = 1 + 2 + body.Length;
var outBuf = new byte[2 + payloadLen];
int p = 0;
BitConverter.GetBytes((ushort)payloadLen).CopyTo(outBuf, p); p += 2;
outBuf[p] = msg.Channel; p += 1;
BitConverter.GetBytes((ushort)body.Length).CopyTo(outBuf, p); p += 2; // 바이트 수
Buffer.BlockCopy(body, 0, outBuf, p, body.Length);
return outBuf;
}
// UTF-8 바이트 상한을 지키되, 멀티바이트 시퀀스 중간에서 자르지 않는다.
private static byte[] EncodeTruncatedUtf8(string s, int maxBytes)
{
var enc = ChatProtocol.Utf8Strict;
byte[] bytes = enc.GetBytes(s); // 서로게이트가 깨져 있으면 여기서 예외
if (bytes.Length <= maxBytes) return bytes;
int cut = maxBytes;
// 연속 바이트(10xxxxxx) 위라면 시작 바이트까지 되돌린다
while (cut > 0 && (bytes[cut] & 0xC0) == 0x80) cut--;
var result = new byte[cut];
Buffer.BlockCopy(bytes, 0, result, 0, cut);
return result;
}
자소 클러스터까지 지키려면 StringInfo.GetTextElementEnumerator 로 클러스터를 순회하며
바이트 예산을 소진하는 방식이 정확하다(👨👩👧 가 반토막 나지 않는다).
private static string TruncateByGrapheme(string s, int maxBytes)
{
var enc = ChatProtocol.Utf8Strict;
if (enc.GetByteCount(s) <= maxBytes) return s;
var sb = new StringBuilder();
int used = 0;
var e = StringInfo.GetTextElementEnumerator(s);
while (e.MoveNext())
{
string cluster = (string)e.Current;
int need = enc.GetByteCount(cluster);
if (used + need > maxBytes) break;
sb.Append(cluster);
used += need;
}
return sb.ToString();
}
릴레이 로그는 BinaryWriter.Write(string) 대신 채팅 프로토콜과 동일한 프레이밍을 쓰거나,
아예 별도 포맷임을 명시하고 그 포맷의 리더를 함께 두어야 한다.
byte[] body = EncodeTruncatedUtf8(msg.Text, ChatProtocol.MaxMessageLen);
w.Write(ChatProtocol.OpChatSay);
w.Write(msg.SenderId);
w.Write(msg.Channel);
w.Write((ushort)body.Length); // 프로토콜과 같은 고정 2바이트 바이트 수
w.Write(body);
더 나은 설계
(1) 문자열을 "길이+바이트"로만 다루는 타입을 만들고 string 을 프로토콜 경계에서 추방한다.
Utf8Text 같은 값 타입이 항상 검증된 UTF-8 바이트를 들고 다니면, string.Length 를 쓸
기회 자체가 사라진다. 코덱은 Utf8Text 만 읽고 쓴다.
- 트레이드오프: 로직 코드가
string을 원할 때마다 변환 비용이 생기고 타입이 하나 늘어난다. 대신 이 문제 유형이 컴파일 타임에 불가능해진다. 대규모 서버에서는 남는 장사다.
(2) 코덱을 손으로 쓰지 말고 IDL(Protobuf/FlatBuffers/커스텀 IDL) 에서 생성한다. 길이 단위·엔디안·경계 검증이 생성기 한 곳에만 존재하므로 서버/클라가 어긋날 수 없다.
- 트레이드오프: 빌드 파이프라인과 스키마 버저닝 관리가 필요하고, 패킷 레이아웃을 손으로 최적화하기 어려워진다. 반대로 스펙 문서와 코드가 자동으로 동기화되는 이점이 매우 크다.
(3) 상한을 "바이트 상한"과 "표시 길이 상한" 두 개로 분리한다. 전송 안전(버퍼 크기)은 바이트로, UX(너무 긴 채팅 금지)는 자소 클러스터 수로 각각 건다. 클라·서버가 같은 규칙을 공유하도록 상수와 자르기 함수를 공용 라이브러리에 둔다.
- 트레이드오프: 검사가 두 번이라 약간 느리고 개념이 하나 늘어난다. 대신 "이모지 도배로 1자당 4바이트씩 먹는" 어뷰징과 "긴 글 도배"를 서로 다른 축으로 막을 수 있다.
면접 포인트
- "
string.Length는 무엇을 세는가?" — UTF-16 코드유닛 수다. 문자 수도, 바이트 수도, 사용자가 보는 글자 수도 아니다. 이모지 하나가 2, ZWJ 가족 이모지는 7~8이 나온다. 프로토콜 길이 필드에는 반드시Encoding.UTF8.GetByteCount를 쓴다. - "UTF-8 문자열을 N바이트로 자르려면?" — 바이트 배열로 만든 뒤
(b & 0xC0) == 0x80(연속 바이트)를 만나면 시작 바이트까지 되돌아간다. 정확히 하려면 자소 클러스터 단위로 센다. - "잘못된 UTF-8 을 만나면 .NET 은 어떻게 되는가?" — 기본 인코딩은 예외 없이 U+FFFD 로 치환한다.
프로토콜 경계에서는
new UTF8Encoding(false, throwOnInvalidBytes: true)로 엄격 모드를 써서 손상을 즉시 드러내야 한다. 조용히 통과하는 손상이 가장 찾기 어렵다. - "길이 필드 검증은 상한만 보면 되는가?" — 아니다. 상한 + 상위 프레임 내부에 들어맞는가를 둘 다 봐야 한다. 후자를 빼면 인접 메모리 과다 읽기(정보 유출)로 이어진다.
해설 — 채팅 패킷 문자열 길이의 단위 혼동 (C++)
난이도: 중상
요약
스펙은 msgLen 을 UTF-8 바이트 수로 정의했고 std::string::size() 는 이미 바이트 수인데,
코드는 굳이 messageLength() 라는 자체 계산을 끼워 넣었다. 그런데 그 함수의
if (c >= 0xC0) 는 char 가 부호 있는 타입인 대부분의 플랫폼(x86/x86-64 Linux·Windows)에서
절대 참이 되지 않는다. 결과적으로 이 함수는 ASCII 문자 수만 세고, 한글/이모지는 하나도 세지
않는다. 이 값이 인코더의 msgLen 과 디코더의 소비 바이트 수로 그대로 쓰이면서 스트림 프레임이
통째로 어긋난다. 여기에 substr 바이트 절단이 UTF-8 시퀀스를 반으로 가르고,
memcpy(..., n * sizeof(wchar_t)) 는 소스 길이의 4배를 읽는 힙 과다 읽기다.
문제점
(A) 길이 계산 자체가 틀렸다 — 부호 있는 char 비교와 단위 혼동
-
분류: 정수 부호/승격 오류 · 프로토콜 스펙 위반 · 프레임 desync
-
증상:
char c = s[i];에서c는 대부분 signed char 다.c >= 0xC0에서c는int로 정수 승격되는데, UTF-8 시작 바이트 0xEC 는signed char로 -20 이므로-20 >= 192는 거짓이다. 한글·일본어·이모지의 시작 바이트가 한 번도 세어지지 않는다.- 반면 바로 위의
(c & 0x80) == 0은 비트 연산이라 의도대로 동작한다. 그래서 ASCII 만 세고 나머지는 전부 0으로 계산된다."안녕하세요"→messageLength()가 0 을 돌려준다. encode()는 이 0 을msgLen에 넣고 바디는 15바이트를 붙여 보낸다. 수신 측이 스펙대로msgLen바이트를 읽으면 빈 문자열을 얻고, 남은 15바이트를 다음 패킷 헤더로 해석한다. 연결이 끊길 때까지 모든 후속 패킷이 어긋난다.tryDecode()의p += len도 같다.msgLen바이트를 읽어놓고 0바이트만 전진하므로consumed가 프레임 길이보다 작고, 꼬리 바이트가 누적 버퍼에 남아 스스로 desync 를 만든다.- 상한 검사
len > kMaxMsgLen도 무력화된다. 한글 60,000바이트 메시지의len은 0 이라 어떤 길이 제한도 걸리지 않는다.
-
재현 조건: 비ASCII 문자가 하나라도 포함된 채팅. 영문 QA 환경에서는
len == s.size()라 절대 재현되지 않는다. -
근본 원인:
char의 부호는 구현 정의(implementation-defined) 다. ARM 리눅스는 기본이 unsigned 라 같은 코드가 플랫폼마다 다르게 동작한다. 바이트를 값으로 비교할 때는 반드시unsigned char(또는std::uint8_t)로 캐스팅해야 한다.- 애초에 이 계산이 필요 없다. 스펙 단위가 바이트인데
std::string::size()가 바로 그 값이다. 불필요한 자체 구현이 버그를 들여왔다. - C# 쪽과 달리 C++ 의
std::string은 이미 바이트 컨테이너라, 반대로 "코드포인트 수"가 필요한 상황(UI 표시 길이)과 헷갈리기 쉽다. 두 단위를 이름으로 구분하지 않은 것이 원인이다.
-
여기에 경계 검증이 빠져 있다.
tryDecode는avail < 2 + payloadLen만 보고msgLen이payloadLen안에 들어가는지는 확인하지 않는다. 악성 클라가payloadLen = 4, msgLen = 60000을 보내면std::string text(reinterpret_cast<const char*>(buf + p), 60000)이 수신 버퍼 밖 60KB 를 그대로 읽는다. 힙 과다 읽기 → 인접 세션 데이터 유출 또는 SIGSEGV. 길이 필드는 "상한"과 "상위 프레임 안에 들어맞는가"를 둘 다 검사해야 한다.
(B) 자르기와 복사가 바이트/요소 단위를 뒤섞는다
- 분류: 인코딩 처리 오류 · 버퍼 과다 읽기(out-of-bounds read) · 오프바이원
- 증상:
text.substr(0, kMaxMsgLen)은 정확히 256바이트에서 자른다. 그 자리가 3바이트 한글이나 4바이트 이모지의 중간이면 결과 문자열 끝에 잘린 시퀀스가 남는다. 관대한 클라는 `` 를 그리고, 엄격한 클라(throwOnInvalidBytes, RustString::from_utf8)는 역직렬화 예외로 접속을 끊는다. "한글 채팅을 길게 치면 가끔 튕긴다"는 형태로 보고된다.copyToWideBuffer는n을text.size()(바이트 수)로 잡아놓고memcpy(dst, text.data(), n * sizeof(wchar_t))로 복사한다. 소스는n바이트뿐인데4n바이트(리눅스wchar_t= 4바이트)를 읽는다. 소스 길이의 4배를 읽는 힙 과다 읽기다. ASAN 없이는 대부분 조용히 통과하다가, 문자열이 힙 페이지 끝에 놓이는 순간 크래시한다.- 목적지 쪽도 안전하지 않다.
n <= dstCount로만 제한하고4n바이트를 쓰므로dstCount요소짜리 버퍼(=4*dstCount바이트)를 딱 채우고, 이어지는dst[n] = L'\0'이n == dstCount일 때 버퍼 밖에 널을 쓴다(오프바이원 스택/힙 오버플로). - 설령 크래시하지 않아도 결과는 무의미하다. UTF-8 바이트를
wchar_t슬롯에 그대로 밀어 넣은 것이라 어떤 인코딩도 아니다. 운영툴에는 깨진 글자만 뜬다.
- 재현 조건: 256바이트를 넘는 다국어 메시지 / 힙 페이지 경계에 걸린 문자열 / ASAN 빌드.
- 근본 원인:
substr은 바이트 인덱스로 동작하며 UTF-8 경계를 전혀 모른다.memcpy의 세 번째 인자는 바이트 수인데, 소스와 목적지의 요소 크기가 다른데도sizeof(wchar_t)를 한쪽 기준으로만 곱했다. 요소 크기가 다른 두 버퍼 사이에는memcpy자체가 잘못된 도구다(변환이 필요하다).wchar_t는 크기가 플랫폼마다 다르다(Windows 2바이트/UTF-16, Linux 4바이트/UTF-32). 크로스 플랫폼 코드에서wchar_t를 쓰는 것 자체가 위험 신호다.
수정안
핵심은 ① 길이는 std::string::size()(바이트) 하나로 통일, ② 바이트 비교는 unsigned char 로,
③ 길이 필드의 내부 정합성 + UTF-8 유효성 검증, ④ 자르기는 시퀀스 경계에서,
⑤ 넓은 문자로 옮길 때는 memcpy 가 아니라 디코딩.
아래 코드는 g++ -std=c++17 -pthread -Wall 로 검증했다.
constexpr std::size_t kMaxMsgLen = 256; // UTF-8 바이트 수 상한
// 반드시 unsigned char 로 비교한다.
inline bool isContinuation(unsigned char b) { return (b & 0xC0) == 0x80; }
// 표시 길이용 코드포인트 수 (전송 길이가 아니다 — 이름으로 단위를 구분한다)
inline std::size_t codePointCount(const std::string& s) {
std::size_t n = 0;
for (unsigned char b : s) if (!isContinuation(b)) ++n;
return n;
}
// 엄격 UTF-8 검증: 잘린 시퀀스·오버롱·서로게이트·범위를 모두 본다.
inline bool validateUtf8(const std::string& s) {
std::size_t i = 0, n = s.size();
while (i < n) {
unsigned char b0 = static_cast<unsigned char>(s[i]);
std::size_t extra;
std::uint32_t cp;
if (b0 < 0x80) { extra = 0; cp = b0; }
else if ((b0 & 0xE0) == 0xC0) { extra = 1; cp = b0 & 0x1Fu; }
else if ((b0 & 0xF0) == 0xE0) { extra = 2; cp = b0 & 0x0Fu; }
else if ((b0 & 0xF8) == 0xF0) { extra = 3; cp = b0 & 0x07u; }
else return false;
if (i + extra >= n) return false; // 잘린 시퀀스
for (std::size_t k = 1; k <= extra; ++k) {
unsigned char bk = static_cast<unsigned char>(s[i + k]);
if (!isContinuation(bk)) return false;
cp = (cp << 6) | (bk & 0x3Fu);
}
if (extra == 1 && cp < 0x80) return false; // 오버롱
if (extra == 2 && cp < 0x800) return false;
if (extra == 3 && cp < 0x10000) return false;
if (cp > 0x10FFFF) return false;
if (cp >= 0xD800 && cp <= 0xDFFF) return false; // 서로게이트
i += extra + 1;
}
return true;
}
// 바이트 상한을 지키되 멀티바이트 시퀀스 중간에서 자르지 않는다.
inline std::string truncateUtf8(const std::string& s, std::size_t maxBytes) {
if (s.size() <= maxBytes) return s;
std::size_t cut = maxBytes;
while (cut > 0 && isContinuation(static_cast<unsigned char>(s[cut]))) --cut;
return s.substr(0, cut);
}
bool tryDecode(const std::uint8_t* buf, std::size_t avail,
ChatMessage& out, std::size_t& consumed)
{
consumed = 0;
if (avail < 2) return false;
std::uint16_t payloadLen = 0;
std::memcpy(&payloadLen, buf, 2);
if (payloadLen < 3) throw std::runtime_error("payload too small");
if (avail < static_cast<std::size_t>(2) + payloadLen) return false; // 아직 다 안 옴
std::size_t p = 2;
std::uint8_t channel = buf[p]; p += 1;
std::uint16_t msgLen = 0;
std::memcpy(&msgLen, buf + p, 2); p += 2;
// ① 상한 ② 상위 프레임 정합성 — 둘 다 필요
if (msgLen > kMaxMsgLen) throw std::runtime_error("message too long");
if (msgLen != payloadLen - 3) throw std::runtime_error("msgLen mismatch");
std::string text(reinterpret_cast<const char*>(buf + p), msgLen);
if (!validateUtf8(text)) throw std::runtime_error("invalid utf-8");
out.channel = channel;
out.text = std::move(text);
consumed = static_cast<std::size_t>(2) + payloadLen; // 소비량은 프레임 길이로 확정
return true;
}
std::vector<std::uint8_t> encode(const ChatMessage& msg)
{
const std::string text = truncateUtf8(msg.text, kMaxMsgLen);
const std::uint16_t msgLen = static_cast<std::uint16_t>(text.size()); // 바이트 수
const std::uint16_t payloadLen = static_cast<std::uint16_t>(1 + 2 + text.size());
std::vector<std::uint8_t> outBuf(static_cast<std::size_t>(2) + payloadLen);
std::size_t p = 0;
std::memcpy(outBuf.data() + p, &payloadLen, 2); p += 2;
outBuf[p] = msg.channel; p += 1;
std::memcpy(outBuf.data() + p, &msgLen, 2); p += 2;
std::memcpy(outBuf.data() + p, text.data(), text.size());
return outBuf;
}
넓은 문자 버퍼로 옮길 때는 바이트를 밀어 넣는 게 아니라 디코딩한다. 플랫폼마다 크기가 다른
wchar_t 대신 char32_t 를 쓰는 편이 안전하다.
inline std::vector<char32_t> toUtf32(const std::string& s) {
std::vector<char32_t> out;
out.reserve(s.size());
std::size_t i = 0;
while (i < s.size()) {
unsigned char b0 = static_cast<unsigned char>(s[i]);
std::size_t extra = (b0 < 0x80) ? 0 : ((b0 & 0xE0) == 0xC0) ? 1
: ((b0 & 0xF0) == 0xE0) ? 2 : 3;
std::uint32_t cp = (extra == 0) ? b0 : (b0 & (0x3Fu >> extra));
for (std::size_t k = 1; k <= extra; ++k)
cp = (cp << 6) | (static_cast<unsigned char>(s[i + k]) & 0x3Fu);
out.push_back(static_cast<char32_t>(cp));
i += extra + 1;
}
return out;
}
// 고정 크기 버퍼로 복사할 때는 "요소 수" 단위로 계산하고 널 자리를 남긴다.
inline std::size_t copyToWideBuffer(const std::string& text, char32_t* dst, std::size_t dstCount) {
if (dstCount == 0) return 0;
const std::vector<char32_t> cps = toUtf32(text); // 반드시 validateUtf8 통과 후 호출
std::size_t n = cps.size();
if (n > dstCount - 1) n = dstCount - 1; // 널 종료 자리 확보
std::memcpy(dst, cps.data(), n * sizeof(char32_t));
dst[n] = U'\0';
return n;
}
더 나은 설계
(1) 검증된 UTF-8 만 담는 타입(Utf8Text)을 만들어 프로토콜 경계에서 강제한다.
생성자에서만 validateUtf8 을 통과시키고, 이후 코드는 size()(바이트)와
codePointCount()(표시 길이)를 이름으로 구분해 쓴다. 검증되지 않은 std::string 이
직렬화기에 들어갈 길 자체를 없앤다.
- 트레이드오프: 타입이 하나 늘고 변환 지점이 생긴다. 대신 "검증했나?"를 매번 되묻지 않아도 되고, 단위 혼동이 타입 시스템에서 잡힌다.
(2) 코덱을 손으로 쓰지 말고 IDL(FlatBuffers/Protobuf/사내 IDL)에서 생성한다.
길이 단위·엔디안·경계 검증이 생성기 한 곳에만 있으므로 서버/클라가 어긋날 수 없다.
FlatBuffers 의 Verifier 처럼 신뢰할 수 없는 입력 검증이 기본 제공되는 것도 크다.
- 트레이드오프: 빌드 파이프라인과 스키마 버저닝이 필요하고, 바이트 레이아웃을 손으로 최적화하기 어렵다. 하지만 이 문제 유형(과다 읽기, 단위 혼동)이 통째로 사라진다.
(3) 텍스트 처리 전반을 ICU 또는 검증된 소형 라이브러리(utfcpp, simdutf)로 위임한다. 자소 클러스터 경계 자르기, 정규화, 유효성 검증을 직접 짜면 언젠가 반드시 틀린다. 특히 👨👩👧 같은 ZWJ 이모지는 코드포인트 경계로 잘라도 깨지므로 자소 클러스터 API 가 필요하다.
- 트레이드오프: ICU 는 바이너리가 크고 빌드가 무겁다. 게임서버라면 채팅 게이트웨이 프로세스에만 넣고 존 서버는 검증된 바이트만 다루게 분리하는 절충이 흔하다.
면접 포인트
- "
char로 UTF-8 바이트를 비교할 때 무엇이 위험한가?" —char의 부호는 구현 정의다. x86 Linux 는 signed 라c >= 0x80이 항상 거짓이고, ARM Linux 는 unsigned 라 참이 된다. 같은 코드가 플랫폼마다 다르게 동작한다. 바이트 값 비교는 항상unsigned char/std::uint8_t. - "
std::string::size()는 무엇을 세는가?" — 바이트 수다. 코드포인트 수도 자소 수도 아니다. 전송 길이는 바이트, UI 길이는 자소 — 단위를 함수 이름으로 구분하는 것이 실무 습관이다. - "요소 크기가 다른 두 버퍼 사이에
memcpy를 쓸 수 있는가?" — 없다.n * sizeof(dst_elem)로 소스를 읽는 순간 소스 범위를 넘는다. 인코딩이 다르면 복사가 아니라 변환이 필요하다. - "신뢰할 수 없는 입력의 길이 필드는 어떻게 검사하는가?" — 상한 검사만으로는 부족하고, 상위 프레임 안에 들어맞는지까지 봐야 한다. 이 검사 하나가 빠지면 힙 과다 읽기로 다른 세션의 평문이 그대로 흘러나간다.