그냥 글을 써 봅니다/서버 개발 수기

EP.08 — OVERLAPPED 분리: 읽기와 쓰기를 동시에 하는 방법

Tiboong 2026. 4. 20. 23:42
728x90

이 글은 『서버 개발 수기』 시리즈의 여덟 번째 글이다.
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가 두 개면, 작업이 끝났을 때 "이게 읽기였나 쓰기였나"를 어떻게 구분하지?

IOCPGetQueuedCompletionStatus라는 함수로 완료를 받는단다. 이 함수가 돌려주는 값 중에 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 대신 이걸 고른 이유

728x90