TL;DR — Packet Tool로 패킷의 DTO와 codec을 생성해도, 받은 패킷을 검증하고 적절한 서비스 로직에 연결하는 작업은 여전히 남아 있습니다. Service Host는 입력 검증·Decode부터 공통 middleware, 요청 Decode, 응답 Encode까지 이어지는 파이프라인을 제공하여 개발자가 도메인 로직에 집중할 수 있는 환경을 만들어 줍니다.
Table of contents
Open Table of contents
들어가며
앞선 Packet Tool 글에서는 PrivateServer의 MovementInput을 예로 들어 패킷의 타입, 바이트 배치와 Encode·Decode를 생성하는 구조를 정리했습니다. 패킷의 정의에 따라 기계적으로 결정되는 코드를 자동으로 만들도록 구성한 것입니다.
그다음 남은 작업은 생성된 패킷을 실제 게임 로직까지 연결하는 작업 입니다. 받은 패킷에 맞는 Decode를 호출하고, 실패한 입력을 차단하고, 처리할 함수를 선택해 적절한 실행 영역으로 넘기는 코드는 패킷을 사용하는 쪽에서 매번 추가로 작성해야 했습니다. 이 흐름에서 반복되는 처리를 모으기 위해 Service Host를 만들었습니다.
이 글에서 다루는 내용:
- 패킷 코드 생성 이후에도 남는 연결 작업
NetworkRuntime,Service Host,WorldRuntime의 책임 관계- 서비스 등록과 패킷·handler 바인딩의 포함 관계
TimeSync요청·응답으로 살펴보는 공통 처리 흐름
사전 지식: C++ 함수와 템플릿에 대한 기본 이해를 전제로 합니다.
1. 패킷 생성 이후에도 남는 반복 작업
PrivateServer에는 이미 패킷 분기와 입력 검증을 담당하는 코드가 있었습니다. 예를 들어 이동 입력을 처리하는 경로에서는 패킷 종류를 확인한 뒤 MovementInput::Decode를 호출하고, 접속한 사용자의 상태와 조작 대상, 입력을 반영할 tick을 검사했습니다. 기본 연산과 처리 경로를 재사용하고 있었지만, 새 패킷을 어느 경로에 연결할지는 직접 작성해야 했습니다.
그리고 이 과정에서 반복되는 작업을 정리해 보면 다음과 같습니다.
| 작업 | 패킷을 사용하는 쪽에서 연결하던 내용 | Service Host에서 공통화한 부분 |
|---|---|---|
| 처리 대상 선택 | 패킷 ID에 따라 처리 함수를 선택합니다. | 등록된 binding에서 처리 대상을 찾습니다. |
| 입력 해석 | 해당 패킷의 Decode를 호출하고 실패를 처리합니다. | 입력 단계에서 생성된 Decode를 호출하고 실패 시 중단합니다. |
| 공통 정책 | 여러 처리 함수 앞에 필요한 검사를 연결합니다. | 서비스에 등록한 middleware 목록을 순서대로 실행합니다. |
| 실행 전달 | 요청 데이터와 호출 작업을 실행 영역에 전달합니다. | 요청을 소유한 Service Job을 executor에 제출합니다. |
| 응답 연결 | 응답 타입의 Encode와 송신 경로를 연결합니다. | binding의 응답 정보와 공통 출력 adapter를 사용합니다. |
게임 상태를 어떻게 바꿀지, 특정 사용자가 해당 동작을 할 수 있는지는 서비스마다 다릅니다. 따라서 패킷을 처리 함수까지 전달하는 공통 과정은 Host가 담당하고, 요청의 게임 내 의미는 서비스 로직이 판단하도록 나누었습니다.
새로운 패킷을 처리할 때 개발자가 작성해야 하는 코드의 중심도 이 경계에 맞췄습니다. 개발자는 생성된 패킷 타입을 받는 handler를 작성하고, 그 패킷과 handler의 연결을 선언합니다. 그리고 이 연결된 정보를 Host에 등록하여 공용 파이프라인을 이용하게 됩니다.
2. NetworkRuntime과 WorldRuntime 사이의 처리 경계
Service Host는 개념적으로 두 Runtime 사이를 이어 주는 역할을 합니다. NetworkRuntime은 네트워크 I/O 처리를 담당하는 레이어이고, WorldRuntime은 게임의 핵심 도메인 로직을 처리하는 레이어입니다.
Service Host는 이 두 레이어 사이에 위치하여 각 레이어가 각자의 작업에만 집중할 수 있는 환경을 만들어 줍니다.
2.1 입력 검증 - Decode에서 시작하는 파이프라인
NetworkRuntime은 socket I/O, TCP framing과 통신 buffer의 수명을 관리합니다. 그 결과로 얻은 완성된 패킷은 입력 adapter를 통해 Service Host에 전달됩니다.
따라서 Host의 첫 처리 구간은 입력 검증·Decode가 됩니다. 패킷을 식별하고 크기를 확인한 뒤, 생성된 Decode를 호출해 전달된 패킷이 올바른 형식과 데이터를 가지고 있는지 검증합니다.
아래 그림은 각 영역의 책임과 요청·응답의 이동 경로를 함께 보여줍니다.
flowchart TB
Network["NetworkRuntime
Socket I/O · TCP framing
통신 buffer 수명"]
subgraph Host["Service Host"]
InputPipeline["1. 입력 검증·Decode
PacketId 조회
payload 크기 확인
2. 공통 middleware
3. Service Job 전달"]
Encode["5. 응답 Encode"]
end
World["WorldRuntime 실행 영역
4. Typed Service Handler
게임 규칙 검사 · 상태 처리"]
Network -->|"Input Adapter"| InputPipeline
InputPipeline -->|"Executor
요청을 소유한 Job"| World
World -->|"응답 DTO"| Encode
Encode -->|"Output Adapter"| Network
Service Host는 두 runtime 사이에서 패킷과 서비스 호출을 연결합니다. 입력 adapter는 통신 쪽의 패킷과 연결 정보를 Host의 입력으로 바꾸고, executor는 Host가 만든 작업을 게임 로직이 실행되는 영역에 맡깁니다.
WorldRuntime에서 처리된 데이터는 출력 adapter로 전달되어 인코딩이 되고, 이를 응답 객체로 만들어 해당 연결의 송신 경로로 전달합니다. 이후 송신은 다시 NetworkRuntime의 책임으로 넘어갑니다.
2.2 형식 검증 - 공통 정책과 게임 규칙
입력 처리 순서는 그림과 같이 입력 검증·Decode → middleware → 실행 작업 전달 → handler입니다. 각 단계가 확인하는 대상은 다릅니다.
| 단계 | 확인하는 대상 | 실패했을 때의 처리 |
|---|---|---|
| 입력 검증·Decode | 등록된 PacketId, 정확한 payload 크기, 생성된 codec의 version·바이트 해석 계약을 확인합니다. | middleware와 handler로 진행하지 않습니다. |
| 서비스 공통 middleware(optional) | 연결 정보와 패킷 ID를 이용해 서비스 진입 정책을 확인합니다. | 실행 작업을 executor에 넘기지 않습니다. |
| 서비스 handler | 사용자의 상태, 조작 대상, tick과 게임 규칙을 확인합니다. | 서비스가 정한 실패 결과를 반환합니다. |
middleware는 입력 형식 검증이 끝난 뒤 실행됩니다. 현재 각 middleware가 받는 정보는 Host Connection Key와 PacketId이며, raw payload나 Decode한 요청 객체는 전달하지 않습니다. 따라서 패킷의 필드 해석은 codec에 두고, 게임 상태를 바탕으로 한 판단은 handler에 둔 채, 연결과 요청 종류에 따른 공통 정책을 서비스 handler 앞 middleware로 배치해 먼저 확인이 필요한 작업을 진행할 수 있습니다.
2.3 실행 시점까지 요청을 보존하는 Service Job
검증에 통과한 요청을 WorldRuntime과 같은 다른 실행 영역으로 넘기려면 요청 데이터의 수명도 함께 고려해야 합니다. 수신 buffer를 가리키는 view만 queue에 저장하면, 나중에 handler가 실행될 때 원본 데이터가 남아 있다는 보장을 할 수 없습니다.
Host는 Service Job의 저장소에 요청을 직접 Decode하고 할당합니다. 그래서 Job은 요청 데이터와 handler 호출에 필요한 정보를 자체적인 메모리 영역에 가지고 있으며, 이 시점부터 입력 buffer의 수명과 분리된 라이프사이클을 가지게 됩니다. 또한 Service Job은 Decode, Middleware를 모두 통과한 뒤에 executor에 전달됩니다.
Executor가 작업을 수락하면 작업의 실행과 정리 책임이 실행 측으로 넘어갑니다. 반대로 거절하면 Host가 작업을 정리합니다. 따라서 작업을 넘기는 경계에서도 요청 데이터를 누가 보존하고 누가 정리하는지가 결정됩니다.
%%{init: {"flowchart": {"useMaxWidth": false}}}%%
flowchart TB
Host["Host · 요청을 소유한 Service Job"] --> Submit{"Executor 제출 결과"}
Submit -->|"실패"| Cleanup["Host가 Job 정리"]
Submit -->|"성공"| Owner["Consumer가 Job 소유"]
Owner --> Execute["한 번 실행 후 정리<br/>TkServiceJobExecute<br/>TkServiceJobDestroy"]
Owner --> Discard["실행 없이 정리<br/>TkServiceJobDestroy"]
화살표는 제출 결과에 따라 Job의 소유권과 실행·정리 책임이 나뉘는 흐름입니다.
Job의 실행 시점은 consumer가 제공한 executor가 결정하며, callback 안에서 즉시 실행하거나 queue에 넣어 나중에 실행할 수 있습니다. 제출에 성공한 Job은 consumer가 한 번 실행한 뒤 TkServiceJobDestroy로 정리하거나, 실행 없이 정리할 책임을 가집니다.
3. 서비스 등록과 패킷 바인딩의 포함 관계
3.1 서비스는 처리 함수를 묶고 binding은 호출 대상을 연결한다
Service Host의 서비스는 특정 패킷과 관련된 handler(함수)를 묶은 C++ 클래스/객체입니다. 그래서 Service Binding은 패킷의 타입을 특정 서비스의 handler와 연결하기 위한 선언이라고 볼 수 있습니다. 서비스 등록은 서비스와 그 서비스에 함께 연결할 바인딩 정보(서비스 handler, 패킷 타입, 미들웨어, Executor 등)를 하나로 묶어 Service Host에 전달하는 과정입니다.
하나의 서비스에는 여러 binding을 둘 수 있습니다. 아래의 A와 B는 서로 다른 패킷과 handler의 관계를 설명하기 위한 이름입니다.
flowchart TB
subgraph Host["Service Host"]
subgraph Registration["서비스 하나의 등록 정보"]
Settings["서비스 객체 참조
middleware 목록 · executor"]
subgraph Bindings["여러 패킷의 binding"]
BindingA["Request A → Handler A"]
BindingB["Request B → Handler B
응답: Response B"]
end
Settings -.->|"공통 적용"| Bindings
end
end
Service["애플리케이션 소유
서비스 객체
Handler A · Handler B"]
Settings -.->|"객체 참조"| Service
Bindings -.->|"호출 대상"| Service
등록 정보에는 실제 요청 패킷 객체가 들어 있지 않습니다. Binding에는 어떤 요청 타입을 어떤 handler에 연결할지가 기록됩니다. 실제 요청 객체는 패킷이 들어왔을 때 Service Job 안에 만들어집니다.
3.2 서비스 공통 설정과 패킷별 연결
서비스 등록 하나에는 executor 하나와 middleware 목록 하나가 연결됩니다. 같은 서비스에 속한 binding은 이 공통 설정을 사용합니다. 요청이 들어오면 해당 서비스의 middleware 목록을 등록 순서대로 거치고, 선택된 binding의 handler를 실행할 작업이 executor에 전달됩니다.
따라서 현재 구조에서는 하나의 서비스를 Host에 등록하고, 이 서비스를 여러 WorldRuntime의 Worker가 접근하여 작업을 할 수 있습니다. Job의 콜백으로 호출되는 handler는 여러 Worker Thread에서 동시에 접근해도 각 호출의 매개변수와 일반 지역 변수는 별도이기 때문에 실행 자체만으로 경합이 발생하지는 않습니다.
다만 handler 작업을 처리하는 도중에 서비스 내부의 같은 상태를 여러 Job이 동시에 변경하는 로직이 포함되어 있다면, 이 경우에는 서비스 자체에서 동시성 처리를 위한 조치를 취해야 할 필요가 있습니다.
요청 하나가 서비스의 모든 handler를 호출하지 않습니다. 같은 Host 안에서 요청 PacketId 하나는 handler 하나에만 연결됩니다. 같은 서비스 안에서 중복 선언하거나 다른 서비스가 이미 등록한 요청 PacketId를 사용하면 등록을 거절합니다.
Binding에 필요한 정보 중 패킷 ID, payload 크기와 codec은 생성된 패킷 타입에서 가져옵니다. 서비스 작성자가 이 값을 등록 코드에 다시 적지 않도록 구성했습니다.
| 개발자가 선언하는 내용 | Binding이 패킷 타입에서 가져오는 정보 |
|---|---|
| 요청 패킷 타입과 서비스 handler | 요청 PacketId, PayloadBytes, Decode |
| 응답이 있는 경우 응답 패킷 타입 | 응답 PacketId, PayloadBytes, Encode |
서비스 등록은 요청을 받기 전 설정 단계에서 수행하고, 등록을 확정한 뒤에는 같은 binding 정보를 사용합니다. Host는 등록 정보를 복사해 보관하지만 서비스 객체는 계속 외부 소유이므로, 그 서비스를 참조하는 작업이 모두 정리될 때까지 객체의 수명을 유지해야 합니다.
4. TimeSync로 살펴보는 요청·응답 처리
4.1 서비스 로직에 남는 코드
TimeSync는 요청에 담긴 probeSequence와 서버 tick을 응답하는 예제입니다. probeSequence는 요청과 응답을 대응시키기 위해 패킷에 담은 값입니다. 여기서는 생성된 WorldTimeSyncRequest와 WorldTimeSyncResponse를 사용합니다.
기존 PrivateServer의 TimeSync 경로에는 요청 Decode, 사용자 상태 확인, 응답 작성, 응답 Encode와 송신 대상 조회가 연결되어 있었습니다. Host의 공통 파이프라인을 사용하는 서비스에서는 요청 타입을 인자로 받고 응답 값을 작성하는 부분을 다음과 같이 표현합니다.
struct TimeService final
{
std::uint32_t currentServerTick = 0U;
TkResult OnTimeSync(
const TkServiceContext&,
const WorldTimeSyncRequest& request,
WorldTimeSyncResponse* outResponse) noexcept
{
outResponse->probeSequence = request.probeSequence;
outResponse->serverTick = currentServerTick;
return TK_SUCCESS;
}
};
이 코드는 TimeSync 예제에서 서비스 로직에 해당하는 부분을 추린 것입니다. currentServerTick은 서비스가 읽는 값이고, 어떤 시점의 tick을 제공할지는 World 실행 측에서 정합니다. 게임 상태에 따른 허용 여부가 필요한 서비스라면 그 판단도 handler에 작성합니다.
Handler는 raw payload를 받거나 Decode를 호출하지 않습니다. 호출되었다는 것은 앞선 입력 검증·Decode와 middleware를 통과했다는 의미입니다. outResponse에는 Host가 준비한 응답 객체가 전달되며, handler는 응답을 작성하고 결과를 반환합니다.
4.2 패킷 타입과 handler의 명시적인 연결
다음은 애플리케이션의 초기화 함수에서 TimeSync를 등록하는 부분입니다. host, timeService, executor, middlewares는 초기화 과정에서 준비한 Host, 서비스 객체, 실행 전달 callback과 middleware 배열입니다.
constexpr pstk::service::BindingSet timeServiceBindings =
pstk::service::MakeBindings(
pstk::service::BindRequestResponse<
TimeService,
WorldTimeSyncRequest,
WorldTimeSyncResponse,
&TimeService::OnTimeSync>());
const TkResult result = pstk::service::RegisterService(
host, timeService, executor, middlewares, timeServiceBindings);
if (result != TK_SUCCESS)
{
return result;
}
return TkServiceHostFinalizeRegistration(host, {});
BindRequestResponse에는 서비스 타입, 요청 타입, 응답 타입과 호출할 멤버 함수를 명시합니다. MakeBindings는 이러한 선언을 묶고, RegisterService는 실제 서비스 객체와 공통 실행·정책 설정을 연결합니다. 다른 서비스도 함께 구성한다면 모든 등록을 마친 뒤 마지막에 등록을 확정합니다.
이 선언에는 요청 패킷 번호나 payload 크기, Decode·Encode 함수 포인터를 반복해서 적지 않습니다. Facade가 생성된 패킷 타입에서 정보를 가져와 Host가 사용할 등록 정보로 구성합니다. 응답이 없는 패킷에는 BindOneWay로 요청 타입과 handler만 연결합니다.
4.3 같은 파이프라인에서 이어지는 응답
TimeSync 요청이 들어오면 앞서 설명한 처리 순서가 그대로 적용됩니다.
%%{init: {"sequence": {"actorMargin": 20, "width": 110, "messageMargin": 35, "mirrorActors": false}}}%%
sequenceDiagram
participant Input as 입력 Adapter
participant Host as Service Host
participant Executor as Executor
participant Service as TimeService
participant Output as 출력 Adapter
Input->>Host: 요청 패킷
Host->>Host: 1. 입력 검증·Decode
Host->>Host: 2. 공통 middleware
Host->>Executor: 3. Job 제출
Executor-->>Host: 작업 접수
Host-->>Input: 접수 결과
Executor->>Host: JobExecute
Host->>Service: 4. OnTimeSync
Service-->>Host: 응답 DTO
Host->>Host: 5. Encode
Host->>Output: 연결 정보 · 응답 패킷
Output-->>Host: 송신 접수 결과
첫 입력 구간에서 Host는 요청의 binding을 찾고 payload 크기를 확인한 다음 생성된 Decode를 호출합니다. 이후 middleware를 통과한 작업을 executor에 제출합니다. 실행 측이 작업을 실행하면 OnTimeSync에 타입이 있는 요청이 전달됩니다.
Handler가 성공하면 Host는 binding에 지정된 응답 codec으로 Encode를 수행합니다. 출력에는 원래 요청의 연결 정보와 WorldTimeSyncResponse::PacketId가 사용됩니다. 서비스 함수마다 응답 codec을 호출하고 송신 대상을 연결하던 부분을 같은 경로에서 처리하는 것입니다.
Handler가 실패하면 응답 Encode로 진행하지 않고, Encode가 실패하면 출력 adapter를 호출하지 않습니다. 실패한 단계 뒤의 처리를 중단하는 순서도 파이프라인에 포함됩니다.
출력 adapter가 받는 인코딩된 바이트는 callback이 실행되는 동안 유효합니다. 반환 이후 송신에 사용할 데이터는 adapter가 통신 측의 저장소에 복사하거나 전달을 완료해야 합니다. 출력 성공은 송신 요청이 접수되었다는 의미이며, 상대가 응답을 받았다는 의미와는 구분합니다.
이 구조에서 TimeSync의 서비스 코드는 요청을 해석하는 바이트 연산이나 송신 연결을 반복하지 않습니다. 요청에 대응하는 응답 값을 작성하고, 패킷 타입과 handler의 관계를 선언하면 그 연결 정보가 공통 파이프라인의 입력과 출력에 사용됩니다.
정리하며
Service Host를 만든 이유는 패킷 코드 생성 이후에도 남아 있던 연결 작업을 공통화하기 위해서였습니다. Packet Tool이 패킷의 표현과 codec을 만들고, Service Host는 그 codec과 서비스 handler를 입력 검증부터 응답 출력까지 이어지는 처리 흐름에 연결합니다.
개발자가 서비스마다 작성해야 할 내용은 요청이 게임에서 어떤 의미를 갖는지와 그 결과를 어떻게 만들지에 모입니다. 패킷별 연결은 binding으로 선언하고, 공통 정책과 실행 영역은 서비스 단위로 구성합니다.
핵심 요약:
- 입력 검증·Decode를 Host의 첫 처리 구간에 두어, 잘못된 입력이 middleware나 handler로 진행하지 않도록 합니다.
- 서비스 등록은 객체 참조, middleware, executor와 여러 binding을 묶으며, 요청
PacketId하나는 handler 하나를 선택합니다. Service Job이 요청 데이터를 소유하여 수신 buffer의 수명과 서비스 실행 시점을 분리합니다.- 응답이 있는 binding은 생성된
Encode와 공통 출력 경로까지 연결하며, 게임 규칙의 판단은 handler에 남깁니다.
참고 자료
- Packet Tool: JSON 스키마 기반 패킷 코드 자동화 — 패킷의 DTO와 codec을 생성하는 앞선 글입니다.
- Service Host core — 서비스 등록, 입력 파이프라인, Service Job과 응답 출력 구현입니다.
- C++ 서비스 바인딩 facade — 패킷과 handler의 타입 검사, binding 선언과 서비스 등록 API입니다.
- TimeSync 요청·응답 예제 — 생성된 요청·응답 타입을 서비스와 연결한 예제입니다.
- PrivateServer의 TimeSync 처리 — 요청 처리와 응답 인코딩, 송신 연결을 살펴본 기존 구현입니다.
이 게시물은 학습한 내용을 바탕으로 초안을 작성한 뒤, LLM의 도움을 받아 내용을 검수하고 다듬어 완성되었습니다.