proto-socket/PORTING_GUIDE.md
toki 5a2a21e75a 기능: TypeScript 패키지를 devDependencies로 통합하고 WebSocket 기능을 개선했다
- @bufbuild/protobuf를 dependencies에서 devDependencies로 이동
- ws를 peerDependencies에서 devDependencies로 이동
- node_ws_server.ts에 TLS 및 라우팅 기능 추가
- node_ws_client.ts에 TCP 클라이언트 통합 기능 추가
- message_common_pb.ts 프로토콜 버퍼 코드 재생성
- 크로스테스트 클라이언트 파일들 경로 업데이트
- 테스트 파일들 설정 업데이트
- 문서(PORTING_GUIDE, README) 업데이트
2026-05-20 11:22:04 +09:00

12 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를 직접 등록하거나 처리하지 않음

새 언어 추가 절차

  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 패턴으로 공유한다