25. 수신 Stream.Read 반환값 오처리(정상 종료·부분 수신)
난이도 하내 리뷰 · C#
내 리뷰 · C++
해설 · C#
해설 — 수신 Stream.Read 반환값 오처리(정상 종료·부분 수신)
난이도: 하
요약
Stream.Read 의 반환값을 잘못 해석한다. 반환 0 은 "데이터 없음"이 아니라
상대의 정상 종료(EOF) 인데 continue 로 재시도해 죽은 연결에서 바쁘게
무한 루프(CPU 100%·좀비 세션)를 돈다(A). 또 한 번의 Read 가 요청보다
적게 줄 수 있는데 헤더·본문 모두 부분 수신을 무시해, 헤더가 덜 차면 길이를
오해석하고(A), 본문이 덜 차면 반쯤 빈 버퍼를 그대로 디스패치한다(B)(C).
문제점
(1) [반환값 의미 오해] 0 = 정상 종료를 재시도로 처리 — (A)
- 증상: 상대가 연결을 닫으면
Read가 계속 0 을 반환.if (n <= 0) continue;가 이를 무한 반복 → 한 코어를 태우고 세션이 정리되지 않음(좀비). - 재현: 클라가 정상적으로 소켓을 close.
- 근본원인: 스트림 API 에서 0 은 EOF(더 이상 데이터 없음, 연결 종료) 라는 계약을 무시.
(2) [부분 수신 미처리] 헤더 — (A)
- 증상:
Read(header,0,4)가 1~3 만 반환하면 나머지 바이트는 이전/0 값.BitConverter.ToInt32가 엉뚱한bodyLen을 만든다(거대 할당/오파싱). - 근본원인: 4바이트가 다 모일 때까지 반복해서 읽지 않음.
(3) [부분 수신 미처리] 본문 — (B)(C)
- 증상:
got을 쓰지 않고body전체를 디스패치.got < bodyLen이면 뒷부분이 비어(0) 있는 채로 핸들러가 처리 → 프레임 경계 붕괴·오파싱. - 근본원인: 반환값(
got)을 무시하고 한 번의 Read 로 다 받았다고 가정.
수정안
"필요한 만큼 다 채울 때까지 읽는" 헬퍼를 만들고, 0 은 종료로 다룬다.
bodyLen 검증도 추가한다.
// false = 상대가 종료(EOF). 그 외엔 count 바이트를 모두 채움.
private bool ReadFull(byte[] buf, int count)
{
int off = 0;
while (off < count)
{
int n = _stream.Read(buf, off, count - off);
if (n == 0) return false; // EOF: 연결 종료
off += n; // 부분 수신 누적
}
return true;
}
public void ReceiveLoop()
{
var header = new byte[4];
while (_running)
{
if (!ReadFull(header, 4)) break; // (A') 0 → 루프 종료·정리
int bodyLen = BitConverter.ToInt32(header, 0);
if (bodyLen < 0 || bodyLen > MaxBody) break; // 길이 검증
var body = new byte[bodyLen];
if (!ReadFull(body, bodyLen)) break; // (B')(C') 본문 완전 수신
Dispatch(body);
}
Cleanup(); // 세션 해제·맵 제거·자원 반납
}
- .NET 7+ 이면
stream.ReadExactly(buf, off, count)로 대체 가능(EOF 시 예외). SocketException(연결 리셋 등)은 루프 밖에서 잡아 동일하게 정리한다.
더 나은 설계 (트레이드오프)
- 비동기 I/O:
ReadAsync+ 파이프라인(System.IO.Pipelines)으로 부분 수신· 버퍼 관리를 프레임워크에 위임하면 휴먼에러가 준다(러닝커브). - 수신 버퍼 누적기: 세션별 링버퍼에 도착분을 쌓고 "완전한 프레임이 모였을 때만" 디스패치하는 상태머신 — 여러 프레임이 한 번에 와도 안전.
면접 포인트
- 스트림
Read의 0 = EOF/연결 종료, 양수 = 그만큼 읽음, 예외 = 오류. 이 세 가지를 구분 못 하면 좀비 연결·바쁜 대기·오파싱이 모두 난다. - TCP 는 바이트 스트림이라 메시지 경계가 없다 → 항상 "필요한 만큼 다 읽기" 루프가 필요. 한 번의 Read 가 한 메시지라는 가정은 틀렸다.
해설 · C++
해설 — recv() 반환값·errno 오처리(종료·EWOULDBLOCK·부분 수신·OOB) (C++)
난이도: 하
요약
recv() 의 세 가지 결과를 모두 잘못 다룬다. 0 은 상대의 정상 종료(EOF)
인데 continue 로 재시도해 죽은 연결에서 바쁜 무한 루프(A). -1 은 errno
로 일시적(EAGAIN/EWOULDBLOCK/EINTR)인지 치명적인지 구분해야 하는데 무조건
재시도해 진짜 오류 연결도 못 끊고 돈다(A). 본문은 신뢰 불가 bodyLen 을
검증 없이 고정 body[4096] 에 받아 스택 버퍼 오버플로가 가능하고(B),
got(실제 수신량)을 무시해 부분 수신을 완전 프레임으로 오인한다(C).
문제점
(1) [반환값 의미 오해] 0 = 정상 종료를 재시도 — (A)
- 증상: 상대가 close 하면
recv가 계속 0 →continue무한 반복(CPU 100%, 좀비 세션).n < 0도 무조건continue라 치명적 오류 연결이 정리되지 않음. - 근본원인:
recv계약(0=EOF, -1=오류+errno, 양수=수신량) 무시.
(2) [errno 미구분] 블로킹/논블로킹 오류 처리 — (A)
- 증상: 논블로킹이면
EAGAIN/EWOULDBLOCK은 "지금은 데이터 없음"(정상),EINTR은 시그널 인터럽트(재시도). 이 둘과ECONNRESET같은 치명 오류를 같이continue처리 → 끊어야 할 연결을 못 끊고, 바쁜 대기. - 근본원인:
errno를 보지 않음.
(3) [경계검증 부재] 신뢰 불가 bodyLen → 스택 OOB — (B)
- 증상:
bodyLen이 음수거나 4096 초과여도recv(fd_, body, bodyLen, 0). 음수는size_t로 거대값이 되고, 큰 값은 스택 배열 밖으로 써 스택 오버플로 /메모리 손상(UB). - 근본원인:
bodyLen의 부호·상한·정렬(reinterpret_cast 비정렬 접근)을 검증 안 함.
(4) [부분 수신 미처리] — (C)
- 증상:
recv가bodyLen보다 적게 줘도got을 무시하고bodyLen만큼 처리 → 안 채워진 뒷부분(쓰레기 스택값)까지 디스패치, 프레임 경계 붕괴. - 근본원인: 반환값을 누적해 "다 받을 때까지" 읽지 않음.
C# 트윈은 관리 배열이라 OOB 가 예외로 끝나지만, C++ 는 스택 버퍼 오버플로 ·비정렬 접근 같은 진짜 메모리 손상이 가능해 더 위험하다.
수정안
"끝까지 읽는" 헬퍼에서 0/-1/errno 를 정확히 분기하고, bodyLen 을 검증한다.
#include <cerrno>
// 반환: 1=count 바이트 모두 수신, 0=정상 종료(EOF), -1=치명적 오류
int recvFull(int fd, uint8_t* buf, size_t count) {
size_t off = 0;
while (off < count) {
ssize_t n = recv(fd, buf + off, count - off, 0);
if (n > 0) { off += static_cast<size_t>(n); continue; } // 부분 수신 누적
if (n == 0) return 0; // EOF
if (errno == EINTR) continue; // 인터럽트 → 재시도
if (errno == EAGAIN || errno == EWOULDBLOCK) continue; // (또는 epoll 대기)
return -1; // 치명적 오류
}
return 1;
}
void receiveLoop() {
uint8_t header[4];
while (running_) {
if (recvFull(fd_, header, 4) != 1) break; // (A') 0/-1 → 종료·정리
int32_t bodyLen;
std::memcpy(&bodyLen, header, 4); // 비정렬 접근 회피
if (bodyLen < 0 || bodyLen > kMaxBody) break; // (B') 길이 검증
std::vector<uint8_t> body(static_cast<size_t>(bodyLen));
if (recvFull(fd_, body.data(), body.size()) != 1) break; // (C') 완전 수신
dispatch(body.data(), bodyLen);
}
cleanup();
}
더 나은 설계 (트레이드오프)
- epoll/io_uring 이벤트 루프: 논블로킹 +
EAGAIN시 다시 대기하도록 하고, 세션별 수신 버퍼 누적기에 쌓아 완전 프레임만 디스패치(바쁜 대기 제거). - 고정 스택 버퍼 금지: 본문은 검증된 크기로 힙/풀에서 확보.
면접 포인트
recv계약: 0=정상 종료, -1=오류(errno 확인), 양수=수신 바이트.EAGAIN/EWOULDBLOCK/EINTR은 오류가 아니라 재시도/대기 신호.- TCP 는 바이트 스트림 → 부분 수신은 정상. "한 번에 한 메시지" 가정 금지.
- 신뢰 불가 길이 → 고정 스택 버퍼 복사는 전형적 스택 오버플로 취약점.