22. 세션별 수신 처리의 직렬화 부재 (동시/재진입 수신 콜백)
난이도 중해설 — 세션별 수신 처리의 직렬화 부재 (동시/재진입 수신 콜백)
난이도: 중상
요약
한 세션의 누적 수신 버퍼(RecvBuf)와 파싱 상태(RecvLen)는 "한 번에 한 곳"에서만 만져야 하는 상태인데, 코드가 다음 수신을 먼저 걸고(D) 처리는 스레드풀에 던진다(C). 결과적으로 같은 세션의 두 수신 완료가 동시에 OnData를 실행할 수 있어 프레임 경계가 깨지고, 누적 시 용량 검사도 없으며(A), 길이 프리픽스를 신뢰해 버퍼 밖을 읽거나 예외로 세션이 죽는다.
문제점
1. (동시성/재진입) 세션 수신 경로가 직렬화되지 않음 — (C)(D)
- 증상: 한 세션에서 프레임이 꼬인다. 같은 바이트가 두 번 처리되거나, 일부가 누락되고,
RecvLen이 음수/과대값이 되어 파싱이 폭주한다. - 재현조건: 데이터가 빠르게 여러 조각으로 도착해 첫
OnData가 끝나기 전에 두 번째 수신 완료가 들어오는 경우.BeginReceive(D)를 처리 전에 즉시 호출하므로 두 번째 콜백이 곧바로 또QueueUserWorkItem을 던진다. - 근본원인:
RecvBuf/RecvLen은 세션당 공유 가변 상태인데 접근이 직렬화되지 않는다. 스레드풀 작업 두 개가 같은 세션 상태를 동시에 읽고 쓴다(데이터 경합). (A)의Array.Copy와 (B)의 잔여 압축이 서로의 오프셋을 덮어쓴다.
2. (메모리/경계) 누적 시 버퍼 용량 미검사 — (A)
- 증상:
RecvLen + n > 8192이면Array.Copy가 대상 범위를 넘어IndexOutOfRangeException(또는 인접 영역 오염). - 재현조건: 소비가 느리거나 8KB에 근접하는 프레임이 누적될 때.
- 근본원인: 고정 8192 버퍼인데 누적 전에 남은 공간을 확인하지 않는다. 8KB보다 큰 단일 프레임은 영원히 완성되지 못한다.
3. (프로토콜/입력검증) 길이 프리픽스 미검증 — flen
- 증상: 음수
flen이면RecvLen - off - 4 < flen이 거짓이 되어ProcessFrame(..., off+4, 음수)로 진입 → 버퍼 밖 접근/예외. 과대flen은 버퍼를 점유한 채 진행을 막는다(최대 프레임 상한 부재). - 근본원인: 신뢰할 수 없는 입력에서 읽은
flen을[0, MaxFrame]범위로 검증하지 않는다.
4. (안정성) 예외 비격리 — 세션 사망
- 위 결함들이 던지는 예외가 잡히지 않아 잘못된 패킷 하나가 세션(또는 워커)을 죽인다.
수정안
핵심은 세션당 수신 처리를 직렬화하고, 누적 전 용량/길이 검증, 현재 청크를 다 처리한 뒤 다음 수신을 거는 것이다.
public class Session
{
public Socket Sock;
public byte[] RecvBuf = new byte[64 * 1024];
public int RecvLen;
public readonly object RecvGate = new(); // 세션당 수신 직렬화
public const int MaxFrame = 32 * 1024;
}
public void OnReceiveCompleted(Session s, byte[] buf, int n)
{
if (n <= 0) { Close(s); return; }
try
{
// 처리를 끝낸 뒤에 다음 수신을 건다(재진입 차단). 세션당 직렬화.
lock (s.RecvGate)
{
OnData(s, buf, n);
}
}
catch (ProtocolException) { Close(s); return; } // 프로토콜 오류 → 세션만 종료
BeginReceive(s); // 처리 완료 후 재무장
}
public void OnData(Session s, byte[] data, int n)
{
if (s.RecvLen + n > s.RecvBuf.Length)
throw new ProtocolException("recv overflow"); // 또는 버퍼 확장/백프레셔
Array.Copy(data, 0, s.RecvBuf, s.RecvLen, n);
s.RecvLen += n;
int off = 0;
while (s.RecvLen - off >= 4)
{
int flen = BitConverter.ToInt32(s.RecvBuf, off);
if (flen < 0 || flen > Session.MaxFrame)
throw new ProtocolException("bad frame length");
if (s.RecvLen - off - 4 < flen) break; // 아직 미완성
ProcessFrame(s, s.RecvBuf, off + 4, flen);
off += 4 + flen;
}
Buffer.BlockCopy(s.RecvBuf, off, s.RecvBuf, 0, s.RecvLen - off);
s.RecvLen -= off;
}
lock은 같은 세션의 콜백이 겹쳐도 한 번에 하나만 OnData를 실행하게 한다. 단, BeginReceive를 OnData 밖(처리 후)에서 부르는 것이 핵심이다 — 그래야 직전 청크 처리 전에 새 콜백이 쌓이지 않는다.
더 나은 설계
- 세션 = 액터/스트랜드(single-threaded executor): 한 세션의 모든 I/O 완료를 같은 직렬 큐로 보내 락 없이 직렬화한다. 락 경합이 사라지고 추론이 쉬워진다.
- SocketAsyncEventArgs 한 개를 세션당 재사용해 "동시에 단 하나의 outstanding recv"를 보장하면 애초에 콜백이 겹치지 않는다(가장 간단한 직렬화).
- 링 버퍼 + 길이 상한 + 백프레셔: 매 콜백
Array.Copy압축(O(n)) 대신 링 버퍼로 잔여를 관리하고, 과대 프레임/느린 소비는 상한·흐름제어로 막는다. - 트레이드오프: 액터/스트랜드는 세션당 처리량 상한이 단일 코어에 묶일 수 있다(보통 게임 세션엔 충분). 전역 락 하나로 묶으면 절대 안 된다 — 세션 간 병렬성을 죽인다.
면접 포인트
- "비동기 수신에서 다음
BeginReceive를 언제 거는가"가 정합성을 좌우한다 — 처리 전에 재무장하면 재진입/경합이 열린다. - 세션 상태 보호는 전역 락이 아니라 세션 단위 직렬화(스트랜드/액터/세션당 락)로 — 병렬성과 정합성을 동시에.
- 길이 프리픽스 프로토콜의 3대 검증:
flen >= 0,flen <= MaxFrame, 누적 버퍼 용량. 하나라도 빠지면 OOB/DoS.
해설 — 세션별 수신 처리의 직렬화 부재 (IOCP/완료 콜백 재진입) · C++
난이도: 중상
요약
완료 핸들러가 처리를 워커에 던지고(C) 곧바로 같은 세션 버퍼(ioBuf)로 다음 수신을 건다(D). C++에서는 이것이 두 가지 치명적 문제를 동시에 연다: ① recvBuf/recvLen에 대한 데이터 경합(C++ 미정의 동작), ② 워커가 아직 읽는 ioBuf를 다음 수신이 덮어쓰는 버퍼 수명/소유권 문제. 더해 누적 용량 미검사(A)와 신뢰 불가 flen 미검증으로 OOB·size_t 언더플로가 발생한다.
문제점
1. (동시성/UB) 세션 수신 경로 미직렬화 — (C)(D)
- 증상: 한 세션의 프레임이 꼬이고,
recvLen이 음수/과대값이 되며, 드물게 크래시. - 재현조건: 첫
OnData(워커)가 끝나기 전에 (D)가 건 다음 수신이 완료되어 또post→ 같은 세션에OnData두 개가 동시 실행. - 근본원인:
recvBuf/recvLen은 세션당 가변 공유 상태인데 두 스레드가 동기화 없이 읽고 쓴다. C# 같으면 "논리 오류"지만 C++에서는 데이터 경합 자체가 미정의 동작이라 찢어진 읽기·재배치·최적화로 임의 결과가 난다.
2. (수명/소유권) 워커가 읽는 버퍼를 다음 수신이 덮어씀 — (C)→(D)
- 증상: 워커가 파싱 중인 바이트가 갑자기 다른 내용으로 바뀐다(프레임 내용 오염).
- 재현조건: (C)의 람다가
s.ioBuf를 참조로 읽는데, (D)가 같은s.ioBuf로 다음 비동기 수신을 걸어 새 바이트로 덮어쓴다. - 근본원인: 비동기로 넘긴 버퍼의 소유권이 워커로 이전되지 않았다. "outstanding 수신 1개"가 보장되지 않고 버퍼가 공유된다.
3. (메모리/경계) 누적 용량 미검사 — (A)
s.recvLen + n > recvBuf.size()여도memcpy가 그대로 진행 → 힙 버퍼 오버플로(UB). 8KB 초과 단일 프레임은 완성 불가.- (참고) 만약 용량 확보를 위해 다른 경로에서
recvBuf.resize()로 키운다면, 그 순간 재할당으로 다른 스레드가 들고 있던data()포인터가 무효화되어 use-after-free가 된다 — 1번 경합과 결합되면 더 위험.
4. (프로토콜) flen 미검증 → size_t 언더플로/OOB — flen/memmove
- 음수
flen(공격자가 보낸0xFFFFFFFF등)이면s.recvLen - off - 4 < flen비교가 부호 있는 int 연산이라 거짓이 될 수 있고,off += 4 + flen로off가 음수/과대 → 다음 루프/memmove의s.recvLen - off가size_t언더플로로 거대한 길이가 되어 메모리를 짓밟는다.
5. (안정성) 예외/오류 비격리
- 잘못된 프레임이 크래시로 직결되어 한 패킷이 프로세스를 죽인다(세션만 끊어야 함).
수정안
struct Session {
int fd = -1;
std::vector<uint8_t> recvBuf = std::vector<uint8_t>(64 * 1024);
int recvLen = 0;
std::mutex gate; // 세션당 수신 직렬화
static constexpr int MaxFrame = 32 * 1024;
};
void OnRecvCompleted(Session& s, const uint8_t* filled, int n) {
if (n <= 0) { Close(s); return; }
{
std::lock_guard<std::mutex> lk(s.gate); // 같은 세션 직렬화
if (!OnData(s, filled, n)) { Close(s); return; } // 프로토콜 오류 → 세션만 종료
}
BeginReceive(s); // 처리 완료 후 재무장(새 버퍼로)
}
bool OnData(Session& s, const uint8_t* data, int n) {
if (s.recvLen + n > (int)s.recvBuf.size()) return false; // 용량 검사
std::memcpy(s.recvBuf.data() + s.recvLen, data, n);
s.recvLen += n;
int off = 0;
while (s.recvLen - off >= 4) {
int32_t flen;
std::memcpy(&flen, s.recvBuf.data() + off, 4);
if (flen < 0 || flen > Session::MaxFrame) return false; // 길이 검증
if (s.recvLen - off - 4 < flen) break;
ProcessFrame(s, s.recvBuf.data() + off + 4, flen);
off += 4 + flen;
}
std::memmove(s.recvBuf.data(), s.recvBuf.data() + off, s.recvLen - off);
s.recvLen -= off;
return true;
}
핵심은 ① 세션당 락(또는 스트랜드)으로 직렬화, ② 다음 수신은 처리 후에, 그리고 수신 버퍼를 워커로 소유권 이전하거나 outstanding 수신을 1개로 제한해 덮어쓰기를 차단, ③ flen/용량 검증으로 OOB·언더플로 봉쇄다.
더 나은 설계
- 세션 = 스트랜드(strand)/액터: 한 세션의 모든 완료를 같은 직렬 실행기로 보내면 락 없이 직렬화되고 버퍼 경합도 사라진다(Asio
strand가 대표). - 버퍼 소유권 명확화: 수신 버퍼를
unique_ptr/풀에서 꺼내 완료마다 워커로 이동(move), 다음 수신은 새 버퍼로 — 공유ioBuf재사용 금지. - 링 버퍼 + 프레임 상한 + 백프레셔: 매번
memmove압축(O(n)) 대신 링 버퍼, 과대 프레임/느린 소비는 상한·흐름제어로. - 트레이드오프: 스트랜드는 세션당 단일 코어에 묶이지만 게임 세션엔 충분하고 추론이 단순. 전역 락은 세션 간 병렬성을 죽이므로 금지.
면접 포인트
- C++에서 데이터 경합은 "논리 버그"가 아니라 미정의 동작 — 동기화 없는 공유 가변 상태는 그 자체로 결함.
- 비동기 I/O에서 버퍼 수명/소유권: 워커에 넘긴 버퍼를 다음 수신이 덮어쓰지 않게 "outstanding 1개" 또는 소유권 이전.
- 부호 있는 길이 프리픽스의 함정: 음수
flen→size_t언더플로. 검증은flen >= 0 && flen <= MaxFrame+ 용량.