언어별 구현 포팅 가이드
공통 원칙
- 레퍼런스 구현체:
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를 직접 등록하거나 처리하지 않음 |
새 언어 추가 절차
- 새 언어 디렉터리를 만들기 전에
agent-ops/skills/project/add-proto-socket-crosstest-language/templates/의 문서를 복사해 패키지 README와 체크리스트 초안으로 사용한다.
PROTOCOL.md와 VERSIONING.md의 현재 protocol version을 기준으로 지원 범위를 정한다.
- canonical proto인
proto/message_common.proto에서 언어별 protobuf binding을 생성한다. Go처럼 generator 전용 option이 필요하면 언어 패키지 안에 copy를 둘 수 있지만, message schema는 tools/check_proto_sync.sh로 검증 가능해야 한다.
Transport와 Communicator를 먼저 구현하고, 그 위에 TCP/TLS+TCP/WS/WSS client/server를 올린다.
- 같은 언어 단위 테스트를 작성한 뒤
agent-ops/skills/project/add-proto-socket-crosstest-language/SKILL.md의 runner 배치 규칙에 맞춰 양방향 crosstest를 추가한다.
- 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.framework의 NWConnection 사용
- 메모리 관리: 클로저에서
[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.Event의 set()은 멱등하므로 활용 가능하다
- 에러 처리: 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 필드로 노출 — 내부 상태는 캡슐화한다
onDisconnected와 dispose를 별도 메서드로 분리 — Go처럼 Close() 하나로 통일한다
- heartbeat 타이머를 재귀 호출로 구현 — 루프 또는 단순 타이머 재설정으로 구현한다
- TCP와 WebSocket 클라이언트에 heartbeat·disconnect 로직 중복 —
baseClient 패턴으로 공유한다