이 글은 『서버 개발 수기』 시리즈의 열 번째 글이자, 1막의 마지막 글이다. ※ 이 시리즈에서 다루는 서버 코드는 Project-AO(Ancient Origin)라는 가명으로 부른다.
그날의 메시지
시리즈를 시작하고 얼마 안 됐을 때, Claude한테 슬쩍 던져본 질문이 있다.
이거... LLM이나 RAG랑 엮을 거 없을까?
가벼운 마음으로 던져본 거였는데, 20년 전 게임 서버 코드랑 요즘 LLM 인프라가 무슨 상관이 있겠나 싶어서 정말 그냥 한번 물어본 거였다.
그런데 Claude의 답은 의외였다.
이거 꽤 많아요.
배칭 (Write 버퍼 병합 ↔ Continuous Batching) — Project-AO가 응답 패킷 여러 개를 모아서 한 번에 보내는 거, LLM 서빙 서버(vLLM 같은)가 추론 요청 여러 개를 묶어서 GPU에 한 번에 돌리는 거랑 같은 발상이에요. 하나씩 처리하면 낭비니까 모아서 보낸다.
버퍼 풀 ↔ KV Cache 관리 — 16슬롯 버퍼 풀이 메모리를 미리 잡아두고 재활용하는 거, vLLM의 PagedAttention이 KV Cache를 페이지 단위로 미리 할당하고 재활용하는 거랑 원리가 같아요. 매번 새로 할당하면 느리니까.
소수 스레드 + 비동기 I/O ↔ LLM 서빙 아키텍처 — IOCP/io_uring으로 소수 스레드가 수만 소켓을 처리하는 구조, LLM 서빙 서버가 소수 GPU로 수천 동시 요청을 처리하는 구조. 자원(스레드/GPU)은 적고 요청은 많을 때의 해법이 20년 전이나 지금이나 같아요.
3계층 추상화 ↔ RAG 아키텍처 — Project-AO의 Layer 1(OS) / Layer 2(I/O) / Layer 3(게임로직) 구조가 RAG의 Retriever(검색) / Context Builder(조립) / LLM(생성) 계층이랑 닮았어요. 각 층이 아래를 몰라도 되게 만든 것까지.
"몰라도 되게" ↔ API 추상화 — OnRead()만 구현하면 됐던 것처럼, 지금 개발자는 client.messages.create() 한 줄이면 되잖아요. 밑에서 배칭이든 KV Cache든 돌아가는 건 몰라도 되고.
오호... 이것봐라.
다섯 개가 한꺼번에 쏟아져 나왔는데, 하나하나가 다 짚이긴 했다. 그런데 너무 강하게 짚이는 게 오히려 의심스러워서, 진짜 닮은 건지 아니면 Claude가 그럴듯하게 엮어낸 건지 분간이 잘 안 됐다.
그래서 일단 묻어뒀다. Part 2를 다 쓰고 나서 보면 그때는 보일 것 같았기 때문이다. 여섯 기법을 하나씩 풀어내는 동안에도 이 다섯 개가 머리 한구석에 계속 남아 있었다.
이제 1막을 닫으면서, 묻어뒀던 다섯 개를 다시 펼친다. 얼마나 연관이 있을까 기대하면서.
1. 배칭 — Write 병합과 Continuous Batching
EP.06에서 우리가 한 번 다루었듯이, Project-AO는 send를 한 번씩 부르지 않고 16개를 모아서 한 번에 보냈다. 시스템콜 수를 줄여서, 네트워크가 도는 동안 컴퓨팅에 집중하려는 거였다.
vLLM의 Continuous Batching도 여러 요청을 한 번의 GPU 연산으로 묶는데, 그 묶음을 고정시키지 않고 매 토큰 생성 iteration마다 재구성한다. 끝난 요청은 빠지고, 빈 자리에 새 요청이 들어오는 식이다.
펼쳐놓고 보니 둘이 완전히 같진 않았다. 우리는 나가는 데이터를 묶었는데 Continuous Batching은 들어오는 요청을 묶으니까, 방향이 반대인 셈이다. 그래서 처음엔 이게 정말 같은 매핑인가 의심스러웠다.
그런데 목적을 들여다보면 결국 같다. 비싼 호출을 아끼려고 병합하고, 한 번의 연산으로 최대한 많은 일을 하고, 그 병합을 고정된 형태가 아니라 계속 재조정하면서 한다는 점에서 그렇다.
방향은 반대인데 발상은 같다. 연관성은 꽤 높은 편이다. 완전히 같진 않지만.
2. 버퍼 풀과 KV Cache
EP.04에서 다룬 16슬롯 버퍼 풀을 떠올려보자. 미리 고정된 사이즈로 잘라놓고, 쓸 때 하나 꺼내고 다 쓰면 돌려놓는 방식이라 매번 할당하고 해제할 필요가 없었다.
그 기억을 안고 vLLM의 PagedAttention을 열어봤다.
여기서는 KV Cache를 고정된 사이즈의 블록으로 나누는데, 전형적으로 16개 토큰씩이다. 블록들은 미리 풀에 올려두고 요청이 올 때마다 필요한 만큼 꺼내 쓰며, logical 블록과 physical 블록을 매핑하는 블록 테이블이 따로 있다.
슬롯. 풀. 매핑 테이블. 그리고 16.
우리가 2006년에 쓰던 단어들이 2023년 vLLM 논문에 거의 그대로 들어 있었다.
흥미로운 건 vLLM 논문이 이 발상을 대놓고 밝힌다는 점이다. 저자들은 "OS의 가상 메모리와 페이징에서 영감을 받았다"고 명시하면서, 자기들이 발명한 게 아니라 OS에서 빌려온 거라고 분명히 말한다.
우리도 2006년에 OS에서 빌려 썼으니, 같은 우물에서 길어 올린 물이 서로 다른 시대에 따로 흐른 것뿐이다. 우리는 게임 서버를 만들 때 빌렸고, 저자들은 LLM을 돌릴 때 빌렸을 따름이다.
다섯 매핑 중에 가장 직접적이라, 연관성은 매우 강하다.
3. 비동기 I/O와 LLM 서빙 아키텍처
이건 처음 들었을 때 가장 의심스러웠다. "자원은 적고 요청은 많다"는 건 거의 모든 서버에 해당하는 일반론이라, 너무 추상적인 매핑처럼 느껴졌기 때문이다.
그런데 풀어보니 그렇지 않았다.
IOCP의 본질은 소수 스레드로 수만 소켓을 처리하는 것이었다. 동기 I/O로 했다면 한 소켓에 한 스레드를 묶어야 하는데, 수만 명이 접속하면 수만 개의 스레드가 필요해지고 컨텍스트 스위칭만으로 서버가 죽는다. 그래서 OVERLAPPED를 걸어두고 커널이 처리하게 한 다음, 소수의 워커 스레드가 완료된 일감을 가져가서 처리한다. 자원은 적고 일은 많으니, 그 사이를 비동기로 메우는 것이다.
vLLM도 똑같은 모양이다. 소수의 GPU로 수천의 동시 요청을 처리해야 하는데, 요청을 하나씩 처리하면 GPU가 놀고 GPU 하나에 요청 하나를 묶어두면 동시 처리량이 안 나온다. 그래서 vLLM은 요청을 입력 큐에 받아두고, GPU가 빌 때마다 스케줄러가 묶음을 만들어 GPU에 보낸다. 결과는 출력 큐로 빠지고 다음 묶음이 올라가는 식이다. 입력 큐, 출력 큐, 그 사이의 스케줄러, 그리고 소수의 비싼 자원.
도구는 다르다. 한쪽은 커널 스레드와 소켓이고 다른 쪽은 GPU와 토큰이다. 그런데 큐 두 개와 그 사이의 스케줄러라는 구조가 똑같았다. IOCP의 SQ/CQ, io_uring의 SQ/CQ, vLLM의 input/output queue가 모두 같은 모양인 것이다.
처음엔 추상적인 매핑이라고 생각했는데, 펼쳐놓고 보니 구조가 정확하게 겹쳐서 연관성은 매우 강했다.
4. 3계층과 RAG
이건 솔직히 좀 갖다 붙인 것 같았다.
EP.02에서 펼쳐봤던 Project-AO의 3계층은 네트워크 I/O에서 프로토콜로, 다시 게임 로직으로 올라가는 시스템 스택의 레이어링이다. 아래는 빠르고 위는 풍부하다. 반면 RAG는 Retriever에서 Context Builder로, 다시 LLM으로 흐르는 데이터 파이프라인이라, 검색해서 조립하고 생성하는 단계의 연속이다.
결이 다르다. 하나는 동작하는 내내 살아있는 계층이고, 다른 하나는 한 요청이 흘러가는 단계라서 매핑이 좀 어색했다.
그래도 Claude가 짚은 부분이 하나 있긴 했다. "각 층이 아래를 몰라도 되게 만든 것까지"라는 대목이다. 그게 진짜 공통점이긴 하다. 게임 로직은 패킷이 어떻게 암호화되는지 모르고, LLM은 문서가 어떻게 검색됐는지 모른 채로, 각자 자기 입력만 받아서 자기 일을 한다.
하지만 그건 거의 모든 잘 만들어진 소프트웨어의 공통점이기도 하다. 추상화로 아래를 가린다는 건 워낙 일반적인 원칙이라, 두 시스템이 특별히 닮았다고 말하기엔 약하다.
그래서 연관성은 약하다고 본다. 같은 원칙을 따른다는 정도까지만 인정할 수 있고, 거기까지가 정직한 평가다.
5. "몰라도 되게"
마지막 매핑은 4번과 살짝 결이 겹친다. 그런데 풀어보니, 4번보다 훨씬 깊은 데서 만나는 매핑이었다.
EP.07에서 함께 봤듯이, Project-AO에서 게임 로직을 짜는 개발자는 OnRead()만 구현하면 그만이었다. 어떤 스레드에서 어떻게 호출되는지, 락-프리 포인터가 어떻게 동기화하는지, 참조 카운팅이 어떻게 객체를 살려두는지는 알 필요가 없었다. 그게 EP.02부터 EP.08까지 이 시리즈가 다룬 여섯 기법의 결론이기도 했다. 복잡한 걸 다 아래에 묻어두고, 위에서는 깔끔하게 한 함수만 구현하게 만드는 것.
지금 LLM을 쓰는 개발자도 마찬가지다.
client.messages.create(model="claude-opus-4-7", messages=[...])
이게 한 줄이다. 이 한 줄 아래에서 요청이 입력 큐에 들어가고, 스케줄러가 다른 요청과 묶고, KV Cache가 페이지 단위로 할당되고, 소수의 GPU가 그 묶음을 한꺼번에 forward pass하고, 결과가 토큰 단위로 스트리밍되어 출력 큐로 나오는 그 모든 과정을, 개발자는 알 필요가 없다. 20년 전에 누군가가 OnRead() 한 줄을 구현하면서 그 아래에 OVERLAPPED와 IOCP와 버퍼 풀이 돌아가는 걸 몰랐던 것과 똑같다.
이 시리즈를 시작할 때, 나는 client.messages.create() 한 줄을 쓰는 개발자였다. 그 한 줄 아래에서 뭐가 굴러가는지 몰랐는데, Claude한테 옛날 코드를 물어보는 사이에 Claude 자신이 어떻게 굴러가는지에 대한 답도 함께 얻고 있었던 셈이다.
OnRead()만 구현하면 그만이던 사람이 20년 뒤에 client.messages.create() 한 줄을 쓴다. 그 사이에 변한 게 많지만, 변하지 않은 게 하나 있다. 복잡한 건 아래에 묻어두고 위에서는 한 함수만 구현하게 만든다는 그 원칙이, 시대를 건너온 것이다.
1번부터 4번까지가 기법의 닮음이라면, 5번은 그 기법들이 지향하는 방향의 닮음이다. 결이 다른 매핑이라, 마지막 자리에 따로 두었다.
다섯 개를 다 펼친 다음
처음에 Claude가 다섯 개를 한꺼번에 쏟아냈을 때는 너무 강하게 짚이는 게 의심스러웠는데, 막상 풀어보니 둘은 매우 강하고(2번, 3번), 하나는 꽤 닮았고(1번), 하나는 약했고(4번), 하나는 다른 결로 깊었다(5번).
평균을 내면 짚이는 매핑이었다. 모든 게 정확히 맞아떨어지는 건 아니지만, 우연으로 보기엔 너무 많이 겹친다.
겹치는 이유는 단순하다. 다섯 매핑이 다 한 곳에서 왔기 때문이다.
1960년대 OS 교과서에 있던 프로듀서-컨슈머 큐, 페이징, 비동기 I/O, 역할 분리, 인터페이스 추상화. 이게 그 시대 시스템 프로그래밍의 기본 어휘였다. 그 어휘가 2002년 Windows IOCP에 쓰였고, 2006년 게임 서버에 쓰였고, 2019년 io_uring에 쓰였고, 2023년 vLLM에 쓰였다. 도구도 대상도 시대도 다른데, 같은 어휘가 계속 살아남은 것이다.
vLLM 논문 저자들이 "OS 페이징에서 영감을 받았다"고 명시한 게 그래서 자연스럽다. 그들도 같은 우물에서 물을 길었고, 우리도 그랬으니까.
기본은 늘 거기 있었다
요즘 가장 핫한 게 LLM이고 RAG인데, 그 아래를 열어보면 과거의 기술과 크게 다르지 않다. 배칭, 풀링, 비동기, 추상화. 다 수십 년 된 기본기다. 새로운 시대의 기술이라고 해서 새로운 원리 위에 서 있는 게 아니라, 같은 기본 위에서 자란 것이다.
우리는 흔히 창의가 어느 날 하늘에서 뚝 떨어진다고, "유레카!" 하는 순간에 번쩍 나타난다고 생각한다. 그런데 vLLM을 만든 사람들은 유레카로 PagedAttention을 만든 게 아니다. OS 페이징이라는 오래된 기본을 새로운 문제에 가져다 댄 것이고, 그게 가능했던 건 기본을 깊이 알고 있었기 때문이다.
창의는 기본에서 발현된다. 기본을 모르면 가져다 댈 것도 없다. 20년 전의 우리가 OS에서 빌려 게임 서버를 만든 것도, 2023년의 그들이 OS에서 빌려 LLM 서버를 만든 것도, 결국 기본을 손에 쥐고 있었기 때문이다.
그래서 옛 코드를 다시 읽는 일에는 의미가 있다. 거기 기본이 있으니까.
시리즈를 닫으며
이 시리즈는 "20년 전 코드를 Claude와 다시 읽다"라는 제목으로 시작했다. 처음에는 그저 휴면 중이던 코드를 한 번 펼쳐보는 제목이라고 생각했는데, 다 읽고 나니 조금 다른 모양이 됐다.
2006년의 나는 이 코드를 다 이해하고 쓴 게 아니었다. 동료들한테 물어보면서, 자료를 찾아가면서, 대충 돌아가는 걸 확인하면서 썼다. 지금 다시 읽으면서도 Claude에게 계속 물어봤다. "이게 왜 이래?", "이게 지금도 유효한 패턴이야?" 하고. 묻고 답하는 건 20년 전이나 지금이나 똑같았다. 물어볼 대상이 동료에서 Claude로 바뀌었을 뿐이다.
달라진 건 하나다. 그때는 "이게 왜 돌아가지"를 물었고, 지금은 "이게 왜 이렇게 설계되었지"를 묻는다. 결과가 아니라 설계를 볼 여유가 생긴 것이다. 20년이라는 시간 덕이고, Claude라는 도구 덕이다.
그래서 결국 이 시리즈는 옮김에 관한 이야기가 아니라 읽기에 관한 이야기였다. 다시 읽는 행위가 무엇을 드러낼 수 있는지에 관한 이야기. 20년 전의 코드가 지금의 시스템과 연결되어 있었고, 어떤 발상은 어떤 시대에도 그때의 자국을 잊지 않는다는 것. 그게 1막이다.
그리고 언제가 될지 모르지만
제목은 아직 없지만, 방향은 있다. 이 코드를 실제로 옮겨보는 이야기다.
io_uring 위에 Layer 1을 올리고, 참조 카운팅을 C++ 스마트 포인터로 바꾸고, 스핀락을 std::atomic으로 재구축하는 일. 그때는 읽기가 아니라 만들기의 이야기가 될 것이다.
그때까지는 일단 이렇게 마무리한다. 1막은 이대로 닫는다.
읽어주셔서 감사합니다.
'그냥 글을 써 봅니다 > 서버 개발 수기' 카테고리의 다른 글
| EP.09 — 왜 io_uring인가: epoll 대신 이걸 고른 이유 (3) | 2026.05.05 |
|---|---|
| EP.08 — OVERLAPPED 분리: 읽기와 쓰기를 동시에 하는 방법 (2) | 2026.04.20 |
| EP.07 — 락-프리 스마트 포인터: mutex 없이 포인터를 바꿀 수 있다고? (0) | 2026.04.08 |
| EP.06 — Write 버퍼 병합: send()를 덜 부를수록 빠른 이유 (0) | 2026.03.25 |
| EP.05 — 다목적 참조 카운팅: shared_ptr 하나면 되는 걸 왜 7개로 나누는가 (0) | 2026.03.18 |