이 글은 『서버 개발 수기』 시리즈의 여덟 번째 글이다.
Part 2 "해독"의 마지막 회다.
지난 회에서는 락 없이 포인터를 바꾸는 이야기를 했다.
오늘은 한 소켓에서 읽기와 쓰기를 동시에 하는 이야기.
※ 이 시리즈에서 다루는 서버 코드는 Project-AO(Ancient Origin)라는 가명으로 부른다.
워키토키 이야기
워키토키로 대화해본 적이 있다. 말하고 나면 "오버"하고 손을 떼야 한다. 그래야 상대가 말할 수 있다. 둘이 동시에 말하면 아무 소리도 안 들린다.
전화는 다르다. 둘이 동시에 말해도 서로 들린다. 끼어들 수도 있고, 말하면서 들을 수도 있다. 이걸 Full-duplex라고 한단다. 워키토키는 Half-duplex.
TCP는 전화기에 가깝다. 서버가 클라이언트한테 데이터를 쏟아붓는 와중에도 클라이언트한테서 오는 데이터를 동시에 받을 수 있다. 물리적으로는 가능하다.
그런데 이 전화기를 워키토키처럼 쓰는 서버들이 있단다.
OVERLAPPED가 뭔데
IOCP에서 비동기 I/O를 하려면 OVERLAPPED라는 구조체가 필요하다. "지금 이 작업이 진행 중이야"를 표시하는 태그 같은 거란다. 커널에 "이 버퍼로 읽어줘" 하고 부탁할 때 OVERLAPPED를 같이 넘긴다. 작업이 끝나면 커널이 그 OVERLAPPED를 돌려준다. 내가 넘긴 그 태그 그대로.
문제는 이걸 소켓에 하나만 두면 어떻게 되느냐다.
class BadSocket {
OVERLAPPED m_overlapped; // 하나만
};
읽기를 걸었다. OVERLAPPED가 "읽기 진행 중" 상태다. 아직 안 끝났는데 쓰기를 하고 싶다. 그럼 같은 OVERLAPPED를 또 쓸 수 있나? 못 쓴단다. 지금 읽기가 그 태그를 점유하고 있으니까.
결국 읽기가 끝날 때까지 쓰기를 못 걸거나, 쓰기가 끝날 때까지 읽기를 못 건다.
전화기를 쥐고 워키토키처럼 쓰는 꼴이다.
코드를 열었다
XIOSocket에는 OVERLAPPED가 두 개다.
class XIOSocket {
protected:
OVERLAPPED m_overlappedRead; // 읽기 전용
OVERLAPPED m_overlappedWrite; // 쓰기 전용
};
딱 이게 끝이다. 읽기용 하나, 쓰기용 하나.
이러면 읽기가 진행 중이어도 쓰기를 따로 걸 수 있다. 태그가 두 개니까 서로 안 부딪친다. 커널 입장에서는 "아, 이건 다른 작업이구나" 하고 독립적으로 처리한다.
코드만 보면 허무할 정도로 단순하다. 그런데 이 단순함이 왜 중요한지는 이어서.

MMORPG에서는 왜 결정적인가
유저가 맵을 달린다. 주변에 다른 유저 30명, NPC 50마리가 뛰어다닌다. 서버가 그 유저한테 쏟아낸다.
- 주변 유저 좌표 업데이트
- NPC 이동 브로드캐스트
- NPC 대사
- 아이템 드롭 알림
동시에 그 유저한테서도 계속 들어온다.
- 방향키 입력
- 공격 버튼
- 채팅 메시지
쓰기 중에 읽기가 막히면 어떤 일이 벌어지나. 유저가 공격 버튼을 누르는데, 서버는 NPC 좌표 보내느라 그 버튼 입력을 못 받고 있다. 지연이 쌓인다. 게임이 "끈적거린다".
OVERLAPPED를 둘로 나눠두면 이게 안 생긴다. 서버가 쏟아붓는 와중에도 들어오는 건 그냥 들어온다. 나가는 길과 들어오는 길을 따로 내놓은 셈이다.

완료 통보를 어떻게 구분하지
여기서 막혔다. OVERLAPPED가 두 개면, 작업이 끝났을 때 "이게 읽기였나 쓰기였나"를 어떻게 구분하지?
IOCP는 GetQueuedCompletionStatus라는 함수로 완료를 받는단다. 이 함수가 돌려주는 값 중에 OVERLAPPED 포인터가 있다. 어떤 OVERLAPPED가 끝났는지를 주소로 알려준다.
그러면 주소를 비교하면 된다.
void OnIOComplete(OVERLAPPED* pOverlapped, DWORD bytes) {
if (pOverlapped == &m_overlappedRead) {
OnRead(bytes); // 읽기가 끝났다
} else if (pOverlapped == &m_overlappedWrite) {
OnWrite(bytes); // 쓰기가 끝났다
}
}
소켓 객체 안에 두 OVERLAPPED가 고정된 위치에 박혀 있으니까, 주소만 보면 어느 쪽인지 알 수 있다. 별도의 플래그도, enum도, ID도 필요 없다. 구조체의 주소 자체가 식별자가 된다.
여기서 살짝 감탄했다. 태그에 이름을 적어두는 게 아니라, 태그가 어디 꽂혀 있느냐로 구분하는 발상.
나는 왜 이걸 몰랐을까
델파이로 개발할 때는 소켓 컴포넌트가 이벤트를 던져줬다. "읽기 이벤트 왔어요", "쓰기 이벤트 왔어요". 내부적으로 어떻게 돌아가는지는 내가 알 바 아니었다. 그냥 썼다.
웹 개발도 마찬가지. HTTP는 기본적으로 요청 하나에 응답 하나. 요청과 응답이 동시에 오갈 일이 거의 없다. Full-duplex가 필요 없는 세계에서 일하고 있었던 거다.
MMORPG 서버는 다르다. 한 소켓에서 양쪽으로 끊임없이 데이터가 흐른다. 이 상황에서 OVERLAPPED 분리 같은 기법은 "있으면 좋은" 게 아니라 "없으면 안 되는" 쪽에 가깝다.
지금이라면 어떻게 할까
io_uring에서는 SQE(Submission Queue Entry)를 커널에 제출한단다. Read용 SQE 하나, Write용 SQE 하나를 각각 제출하면, 커널이 알아서 둘을 병렬로 돌린다. OVERLAPPED 분리와 개념이 포개진다.
epoll은 좀 다르단다. EPOLLIN(읽기 가능)과 EPOLLOUT(쓰기 가능)을 독립적으로 감시한다. 병렬성은 확보되지만, IOCP가 "완료 통보" 모델인 반면 epoll은 "준비 통보" 모델이라 쓰는 쪽 코드 결이 달라진다.
도구는 바뀌어도 발상은 그대로다. 읽기와 쓰기를 구조적으로 갈라놓고, 각자 독립적으로 굴린다. 2006년에 OVERLAPPED 두 개로 구현했던 이 발상은 20년이 지난 지금도 네트워크 I/O의 기본 자세다.
여섯 개가 하나로
Part 2에서 여섯 개의 기법을 열어봤다.
스핀락이 락을 빠르게 잡는다. 버퍼 풀이 락 경합을 쪼갠다. 참조 카운팅이 객체를 안전하게 지킨다. Write 병합이 보내는 비용을 줄인다. 락-프리 포인터가 읽기 비용을 없앤다. 그리고 오늘, OVERLAPPED 분리가 읽기와 쓰기를 동시에 돌린다.
처음 이 코드를 열었을 때는 이게 다 제각각의 최적화로 보였다. 여섯 개를 하나씩 들여다보고 나니 알겠다. 방향이 같다. 수만 명이 동시에 몰려드는 상황에서 어디 하나도 병목이 되지 않게.
SI에서는 이런 설계를 본 적이 없다. 아니, 볼 일이 없었다. 한 요청 안에서 다 끝나는 구조였으니까. 여러 스레드가 같은 객체를 찌르고, 소켓이 양방향으로 쏟아지고, 초당 수십만 번의 접근이 들이닥치는 상황이 없었다.
이 코드는 그 상황을 전제하고 설계되어 있었다. 그래서 이 여섯 개가 다 필요했다.
다음 회에서는
Part 2 "해독"이 여기서 끝난다. 다음부터 Part 3 "변환". 이 코드를 리눅스 io_uring으로 옮기는 이야기가 시작된다.
왜 epoll이 아니라 io_uring을 골랐는지부터.
→ EP.09: 왜 io_uring인가 — epoll 대신 이걸 고른 이유
'그냥 글을 써 봅니다 > 서버 개발 수기' 카테고리의 다른 글
| EP.10 — 창의는 기본에서 온다: 2006년 MMORPG 서버와 2025년 LLM 인프라 (0) | 2026.05.21 |
|---|---|
| EP.09 — 왜 io_uring인가: epoll 대신 이걸 고른 이유 (3) | 2026.05.05 |
| EP.07 — 락-프리 스마트 포인터: mutex 없이 포인터를 바꿀 수 있다고? (0) | 2026.04.08 |
| EP.06 — Write 버퍼 병합: send()를 덜 부를수록 빠른 이유 (0) | 2026.03.25 |
| EP.05 — 다목적 참조 카운팅: shared_ptr 하나면 되는 걸 왜 7개로 나누는가 (0) | 2026.03.18 |