proto-socket/PORTING_GUIDE.md

14 KiB

언어별 구현 포팅 가이드

공통 원칙

  • 레퍼런스 구현체: go/, dart/
  • 동시성·close-once 구조는 Go 구현체를 우선 참고하고, Dart 구현체는 public API와 parser map 사용 예시를 함께 참고한다
  • 프로토콜 정의는 PROTOCOL.md가 단일 기준이다
  • 각 언어의 관용적 패턴을 따르되, 아래 핵심 구조는 반드시 유지한다
  • Protocol Buffers는 이 프로젝트의 핵심 프로토콜 의존성으로 취급한다. 그 외 런타임 구현은 가능한 한 native platform API와 standard library를 우선한다
  • protobuf 외 외부 의존성은 구현하려는 기능에 정확히 대응하는 작고 focused된 라이브러리일 때만 허용한다. 넓은 프레임워크, 대형 런타임 계층, 부가 기능이 과도한 패키지를 좁은 기능 하나 때문에 추가하지 않는다
  • protobuf 외 외부 의존성을 추가해야 한다면 해당 언어에서 실용적인 native 대안이 없는지 먼저 검토하고, README에 이유와 범위를 명시한다
  • TCP/TLS/WebSocket, 바이너리 버퍼, 타이머, 동시성/비동기 처리처럼 플랫폼이 기본 제공하는 기능은 외부 패키지보다 기본 API를 우선한다

반드시 유지해야 하는 구조

요소 Go 기준 설명
Transport 분리 Transport interface WritePacket과 Close만 있으면 됨. 소켓 종류(TCP/WS)에 무관하게 Communicator가 동작
typeName 라우팅 TypeNameOf() protobuf 메시지 이름 기반. 언어마다 추출 방식이 다르므로 PROTOCOL.md의 언어별 표 확인
nonce 단조 증가 atomic.Int32 연결당 1에서 시작, 송신마다 +1. thread-safe하게 구현
connCloseOnce sync.Once Close가 여러 번 불려도 한 번만 수행되어야 함
addListener / addRequestListener 상호 배타 panic or throw 같은 typeName에 두 가지 동시 등록 금지. 프로토콜 계약
heartbeat 자동 처리 baseClient 내부 앱 코드에서 HeartBeat를 직접 등록하거나 처리하지 않음
inbound queue bounded FIFO (권장 capacity 64) 프레임 파서와 dispatch 사이에 연결당 하나의 수신 큐를 둔다. 큐가 가득 차면 읽기를 일시 중단해 backpressure를 적용한다. 단, Browser WS, OkHttp WS처럼 transport pause가 불가능한 환경에서는 64 capacity의 Bounded Input Gate를 두며, 이를 초과할 시 강제로 disconnect(connection close/destroy)를 수행해 memory exhaustion을 차단한다. response 패킷(responseNonce > 0)은 FIFO 순서 보장이 불필요하므로 큐를 거치지 않고 직접 handleResponse를 실행해 비동기 대기를 즉시 완료시킨다
receive coordinator 단일 루프 수신 큐에서 패킷을 순서대로 꺼내 request handler 호출 → listener 호출 순서로 처리한다. 자동 응답은 같은 루프 이터레이션에서 write queue에 enqueue한다
close/drain/cancel 정책 doClose + sync.Once Close 호출 시 이미 큐에 들어간 패킷을 drain한 뒤 coordinator를 종료한다. 전송 오류 발생 시에는 drain 없이 즉시 폐기한다. 미완료 sendRequest는 오류로 cancel한다. 상세 계약은 PROTOCOL.md의 Receive Ordering and Backpressure 섹션 참조

Worker gateway boundary

대량 raw packet 처리나 CPU 비용이 큰 decode/전처리를 병렬화할 때만 선택적으로 worker gateway를 둔다. gateway는 stateful dispatch를 소유하지 않으며, 순서 복원과 상태 접근은 receive coordinator가 단독으로 책임진다.

  • Gateway input/output carries an internal seq assigned by the read side.
  • Gateway may parse raw bytes or perform pure preprocessing only.
  • The receive coordinator owns reorder-by-seq, pending response matching, listener dispatch, request handler execution, and automatic response enqueue.
  • Gateway must NOT own or manage pending response maps, listener/request handlers, or write queue logic.
  • seq는 구현 내부 값이며 wire format(PacketBase/transport framing)에 추가하지 않는다. 상세는 PROTOCOL.md의 Worker Gateway and Internal seq 섹션을 따른다.
  • gateway를 아직 구현하지 않은 언어는 gateway off fallback에서도 동일한 coordinator path로 동작해야 한다.

새 언어 추가 절차

  1. 새 언어 디렉터리를 만들기 전에 agent-ops/skills/project/add-proto-socket-crosstest-language/templates/의 문서를 복사해 패키지 README와 체크리스트 초안으로 사용한다.
  2. PROTOCOL.mdVERSIONING.md의 현재 protocol version을 기준으로 지원 범위를 정한다.
  3. canonical proto인 proto/message_common.proto에서 언어별 protobuf binding을 생성한다. Go처럼 generator 전용 option이 필요하면 언어 패키지 안에 copy를 둘 수 있지만, message schema는 tools/check_proto_sync.sh로 검증 가능해야 한다.
  4. TransportCommunicator를 먼저 구현하고, 그 위에 TCP/TLS+TCP/WS/WSS client/server를 올린다.
  5. 같은 언어 단위 테스트를 작성한 뒤 agent-ops/skills/project/add-proto-socket-crosstest-language/SKILL.md의 runner 배치 규칙에 맞춰 양방향 crosstest를 추가한다.
  6. README의 Implementations 표를 Available로 바꾸기 전에 formatter/linter, 단위 테스트, cross-language tests를 모두 통과시키고, protobuf 외 런타임 의존성이 있다면 native 대안 검토 결과, 기능 범위와 라이브러리 범위가 맞는 이유, 필요성을 문서화한다.

템플릿은 시작점과 완료 조건만 고정한다. 실제 소스 구조, 패키지 매니저, async runtime은 각 언어의 관용적 선택을 따른다.


C# (Unity / .NET)

핵심 매핑

Go C#
Transport interface ITransport interface
baseClient[Self] BaseClient<TSelf> generic class
ParserMap Dictionary<string, Func<byte[], IMessage>>
goroutine Task + CancellationToken
context.Context CancellationToken
sync.Once Interlocked 또는 SemaphoreSlim(1,1)
atomic.Bool volatile bool 또는 Interlocked.Exchange
channel (writeQueue) Channel<T> (System.Threading.Channels)

주의사항

  • Unity 환경: System.Threading.Channels가 Unity 버전에 따라 미지원일 수 있다. 대안으로 ConcurrentQueue<T> + ManualResetEvent 조합을 사용한다
  • 메인 스레드 제약: Unity는 UnityEngine API를 메인 스레드에서만 호출할 수 있다. 수신 콜백을 메인 스레드로 dispatch하는 래퍼(SynchronizationContext)가 필요하다. Communicator 코어는 스레드 무관하게 유지하고, 래퍼 레이어에서 처리한다
  • protobuf: Google.Protobuf NuGet 패키지 사용. typeof(T).FullName이 proto qualified name과 일치하는지 반드시 검증한다. PROTOCOL.md의 typeName 표 참고
  • 제네릭 헬퍼: AddListenerTyped<T>, SendRequestTyped<TReq, TRes> 패턴은 C# 제네릭으로 그대로 구현 가능하다
  • doClose 패턴: C#의 람다 캡처 동작이 Go와 동일하므로 그대로 이식 가능하다

Kotlin (Android)

핵심 매핑

Go Kotlin
Transport interface interface Transport
baseClient[Self] abstract class BaseClient<Self> 또는 generic open class
goroutine CoroutineScope + launch
context.Context CoroutineContext + Job
channel (writeQueue) Channel<T> (kotlinx.coroutines)
sync.Once AtomicBoolean + compareAndSet
atomic.Bool AtomicBoolean
sync.RWMutex ReentrantReadWriteLock 또는 Mutex (coroutines)

주의사항

  • Coroutine Scope 관리: 클라이언트 생성 시 CoroutineScope(SupervisorJob() + Dispatchers.IO)를 만들고, Close()scope.cancel()로 정리한다. SupervisorJob을 사용해야 하위 Job 하나 실패가 전체를 취소하지 않는다
  • protobuf: com.google.protobuf:protobuf-kotlin 사용. typeName은 descriptor.fullName으로 추출. PROTOCOL.md 표에서 Kotlin 행 확인
  • Android 메인 스레드: Unity와 동일하게 UI 콜백은 withContext(Dispatchers.Main)으로 전환한다. Communicator 코어는 IO Dispatcher에서 동작
  • 직렬화된 쓰기: Go의 writeQueue(buffered channel 64) 패턴을 Channel(capacity = 64) + 별도 write coroutine으로 구현한다
  • Self 타입 전달: Kotlin 제네릭의 타입 소거(type erasure) 때문에 런타임에 Self 인스턴스를 직접 넘겨야 한다. Go의 self Self 필드 패턴을 그대로 사용한다

Swift (iOS / macOS)

핵심 매핑

Go Swift
Transport interface protocol Transport
baseClient[Self] class BaseClient<Self> 또는 protocol + associated type
goroutine Task (Swift Concurrency)
context.Context Task 취소 (cooperative cancellation)
channel (writeQueue) AsyncStream 또는 actor 기반 queue
sync.Once DispatchOnce 또는 actor 격리
atomic.Bool OSAllocatedUnfairLock 또는 actor

주의사항

  • Actor 활용: Swift 5.5+ actor를 사용하면 mutex/lock 없이 상태 보호가 가능하다. Communicator를 actor로 구현하면 sync.RWMutex 없이 동일한 안전성을 확보할 수 있다
  • protobuf: SwiftProtobuf 패키지 사용. typeName은 Message.protoMessageName으로 추출. PROTOCOL.md 표에서 Swift 행 확인
  • connCloseOnce 패턴: Swift에는 DispatchOnce가 deprecated됐다. actor의 상태 변수(var isClosed = false) + actor 격리로 대체한다
  • WebSocket: URLSessionWebSocketTask (iOS 13+) 또는 Network.frameworkNWConnection 사용
  • 메모리 관리: 클로저에서 [weak self] 캡처를 빠뜨리면 retain cycle이 발생한다. doClose, heartbeat 타이머 클로저 모두 [weak self]를 사용한다
  • Self 제약: Swift에서 제네릭 Self는 프로토콜 associated type으로 처리하거나, 구체 타입을 생성자에서 전달하는 Go의 self Self 필드 패턴을 그대로 사용한다

Python

핵심 매핑

Go Python
Transport interface Protocol (typing) 또는 ABC
baseClient[Self] Generic[Self] + TypeVar
goroutine asyncio.Task
context.Context asyncio.Event 또는 CancelScope (anyio)
channel (writeQueue) asyncio.Queue(maxsize=64)
sync.Once asyncio.Lock + bool flag
atomic.Bool asyncio 단일 스레드 내에서는 plain bool 가능

주의사항

  • asyncio 기반: async/await + asyncio.Queue로 Go의 goroutine + channel 구조를 근사한다. 동기 API는 제공하지 않는다
  • 타입 안전성 한계: Python에는 런타임 제네릭이 없다. AddListenerTyped처럼 완전한 타입 안전 헬퍼 구현이 어렵다. TypeVar@overload로 가능한 범위까지만 표현하고, 나머지는 docstring으로 보완한다
  • protobuf: protobuf 패키지 사용. typeName은 message.DESCRIPTOR.name으로 추출. PROTOCOL.md 표에서 Python 행 확인
  • 스레드 안전: asyncio는 단일 스레드이므로 대부분의 lock이 불필요하다. 단, 멀티스레드 환경에서 쓸 경우 asyncio.Lock을 사용한다
  • connCloseOnce 패턴: bool flag + asyncio.Lock으로 구현한다. asyncio.Eventset()은 멱등하므로 활용 가능하다
  • 에러 처리: Go의 명시적 error 반환 대신 exception을 사용하되, public API에서는 구체 exception 타입을 정의해서 문서화한다

TypeScript

핵심 매핑

Go TypeScript
Transport interface interface Transport
baseClient[Self] abstract class BaseClient<T>
goroutine Promise + async/await
context.Context AbortSignal (AbortController)
channel (writeQueue) Async Queue (배열 기반 큐 또는 async iterator)
sync.Once boolean 플래그 (단일 스레드이므로 충분)
atomic.Bool boolean

주의사항

  • 크로스 환경 (Browser & Node.js): 코어 로직(Communicator, BaseClient)은 런타임 환경에 독립적으로 작성한다. 브라우저에서는 내장 WebSocket을 사용하고, Node.js에서는 net, tls, http, https 등 Node.js 내장 모듈 기반 Transport를 사용한다. TypeScript 런타임에 외부 WebSocket 패키지를 추가하지 않는다
  • protobuf: protobuf는 핵심 프로토콜 계층이므로 예외로 취급한다. 현재 TypeScript 구현은 canonical schema에 맞춘 native codec을 사용하지만, protobuf binding/runtime을 도입해야 한다면 프로토콜 계층 용도로만 제한한다. typeName은 로컬 MessageType.typeName 값이 PROTOCOL.md 표와 일치해야 한다
  • 단일 스레드 (Event Loop): JS/TS는 단일 스레드 기반이므로 메모리 동시 접근에 대한 Mutex/Lock(sync.RWMutex)은 불필요하다. 단, 비동기 컨텍스트(await) 간의 논리적 상태 오염은 주의한다
  • 바이너리 처리: Node.js의 Buffer 대신 표준 웹 API인 Uint8Array를 기준으로 작성하여 브라우저 환경 호환성을 확보한다
  • connCloseOnce 패턴: 단순 isClosed: boolean 플래그로 멱등성을 보장한다. 만약 Close() 내에 await가 포함될 경우 중복 실행(re-entrancy) 방지에 유의한다
  • 제네릭 헬퍼: TypeScript의 강력한 타입 시스템을 활용해 addListener<T>, sendRequest<TReq, TRes> 등을 완벽히 타입 안전한 API로 구현한다

공통 금지사항

다음은 Dart 구현체의 레거시 패턴이다. 새 구현체에 복제하지 않는다.

  • 생성자·에러 핸들러에서 print() / console.log() 직접 호출 — 로거 인터페이스를 두거나 생략한다
  • isAlive, nonce를 public mutable 필드로 노출 — 내부 상태는 캡슐화한다
  • onDisconnecteddispose를 별도 메서드로 분리 — Go처럼 Close() 하나로 통일한다
  • heartbeat 타이머를 재귀 호출로 구현 — 루프 또는 단순 타이머 재설정으로 구현한다
  • TCP와 WebSocket 클라이언트에 heartbeat·disconnect 로직 중복 — baseClient 패턴으로 공유한다