proto-socket/agent-task/archive/2026/05/kotlin_impl/plan_0.log

807 lines
27 KiB
Text

<!-- task=kotlin_impl plan=0 tag=API -->
# Kotlin 구현체 신규 추가
## 이 파일을 읽는 구현 에이전트에게
각 항목의 체크리스트를 하나씩 완료 처리하고, 중간 검증 명령을 실제로 실행한 뒤 출력을 `CODE_REVIEW.md`의 `검증 결과` 섹션에 붙여 넣으세요.
계획과 다르게 구현한 부분이 있으면 `계획 대비 변경 사항`에 이유와 함께 기록하세요.
`CODE_REVIEW.md`의 모든 섹션을 실제 구현 내용으로 채운 뒤 코드 리뷰를 요청하세요.
---
## 배경
`PROTOCOL.md`에 기술된 Toki Socket 프로토콜의 Kotlin 구현체가 없다. Go/Dart 레퍼런스 구현체를 기준으로 동일한 wire format(TCP 4-byte big-endian framing, WebSocket binary frame), typeName 라우팅, request-response nonce 상관관계, heartbeat 자동 처리를 Kotlin coroutine 관용 패턴으로 구현한다. 구현 완료 기준은 같은 언어 단위 테스트 통과와 Go ↔ Kotlin 양방향 크로스테스트 통과이다.
---
## 의존 관계 및 구현 순서
API-1 → API-2 → API-3 → API-4 → API-5 → API-6 → API-7 → API-8
---
### [API-1] Kotlin 프로젝트 구조 및 protobuf 바인딩 설정
#### 문제
`kotlin/` 디렉터리가 없고 프로젝트 파일, proto 바인딩이 없다.
#### 해결 방법
Gradle 멀티플랫폼이 아닌 순수 JVM 라이브러리 프로젝트로 설정한다. 빌드 도구는 Gradle Kotlin DSL(`build.gradle.kts`)을 사용한다.
**디렉터리 구조:**
```
kotlin/
build.gradle.kts
settings.gradle.kts
gradlew (gradle wrapper)
gradlew.bat
gradle/wrapper/
src/
main/
kotlin/com/tokilabs/toki_socket/
proto/ ← canonical proto 복사본
test/
kotlin/com/tokilabs/toki_socket/
crosstest/
go_kotlin_client/ ← Kotlin subprocess (Go 서버 ↔ Kotlin 클라이언트)
kotlin_go.kt ← Kotlin 서버 오케스트레이터 (Kotlin 서버 ↔ Go 클라이언트)
```
**`build.gradle.kts` 핵심 의존성:**
```kotlin
plugins {
kotlin("jvm") version "2.0.0"
id("com.google.protobuf") version "0.9.4"
}
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
implementation("com.google.protobuf:protobuf-kotlin:4.27.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0") // WS client
implementation("org.java-websocket:Java-WebSocket:1.5.6") // WS server
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")
testImplementation(kotlin("test"))
}
protobuf {
protoc { artifact = "com.google.protobuf:protoc:4.27.0" }
generateProtoTasks {
all().forEach { task ->
task.builtins {
id("kotlin")
}
}
}
}
```
**proto 동기화:**
`dart/lib/src/packets/message_common.proto`에서 `kotlin/src/main/proto/message_common.proto`로 복사. Go 처럼 언어별 option(`option java_package`, `option java_outer_classname`)을 추가할 수 있으나 **message schema는 canonical proto와 동일하게 유지**. `tools/check_proto_sync.sh`가 message 필드 diff를 검출한다.
#### 수정 파일 및 체크리스트
- [ ] `kotlin/settings.gradle.kts` 생성
- [ ] `kotlin/build.gradle.kts` 생성 (위 의존성 포함)
- [ ] Gradle wrapper 생성 (`gradle wrapper` 실행 또는 수동 배치)
- [ ] `kotlin/src/main/proto/message_common.proto` 생성 (canonical proto 복사 + java options 추가)
- [ ] `./gradlew generateProto` 실행하여 바인딩 생성 확인
#### 테스트 작성
SKIP — 이 항목은 빌드 인프라 설정이며 동작 검증은 이후 항목의 테스트로 커버된다.
#### 중간 검증
```bash
cd kotlin
./gradlew compileKotlin
# 예상: BUILD SUCCESSFUL (proto 바인딩 포함 컴파일)
```
---
### [API-2] Communicator 구현
#### 문제
Kotlin에 `Communicator`, `Transport`, `ParserMap` 타입이 없다.
#### 해결 방법
Go `communicator.go` 구조를 Kotlin coroutine 관용 패턴으로 이식한다.
**파일:** `kotlin/src/main/kotlin/com/tokilabs/toki_socket/Communicator.kt`
**핵심 매핑:**
| Go | Kotlin |
|----|--------|
| `Transport` interface | `interface Transport` |
| `ParserMap` | `typealias ParserMap = Map<String, (ByteArray) -> MessageLite>` |
| `atomic.Int32` (nonce) | `AtomicInteger` |
| `atomic.Bool` (isAlive) | `AtomicBoolean` |
| `sync.RWMutex` | `ReentrantReadWriteLock` |
| `channel queuedPacket (64)` | `Channel<QueuedPacket>(capacity = 64)` |
| `closed chan struct{}` | `Job` (coroutine cancellation) |
| `sync.Once` (closeOnce) | 없음 — `shutdown()`은 `closeOnce` 없이 AtomicBoolean + coroutine cancel |
| goroutine writeLoop | `scope.launch { writeLoop() }` |
**typeName 추출:**
```kotlin
fun typeNameOf(m: MessageLite): String =
(m as com.google.protobuf.Message).descriptorForType.fullName
```
proto에 `package` 선언이 없으므로 `fullName` = `"TestData"`, `"HeartBeat"` 등 단순 이름. PROTOCOL.md Kotlin 행과 일치.
**`addListener` / `addRequestListener` 상호 배타:**
```kotlin
fun addRequestListener(typeName: String, fn: (MessageLite, Int) -> Unit) {
val lock = rwLock.writeLock()
lock.lock()
try {
check(handlers[typeName].isNullOrEmpty()) {
"type $typeName is already registered with addListener"
}
check(!reqHandlers.containsKey(typeName)) {
"type $typeName is already registered with addRequestListener"
}
reqHandlers[typeName] = fn
} finally { lock.unlock() }
}
```
위반 시 `IllegalStateException`(Go의 `panic` 대응).
**`writeLoop`:**
```kotlin
private suspend fun writeLoop() {
for (item in writeQueue) {
val err = runCatching { transport.writePacket(item.base) }
item.done.complete(err.exceptionOrNull())
if (err.isFailure) {
writeErrorHandler?.invoke(err.exceptionOrNull()!!)
}
}
}
```
**`sendRequest`:**
```kotlin
suspend fun sendRequest(
req: MessageLite,
resTypeName: String,
timeoutMs: Long = 30_000L
): MessageLite {
...
withTimeout(timeoutMs) {
select {
pending.ch.onReceive { it }
pending.errCh.onReceive { throw it }
}
}
}
```
#### 수정 파일 및 체크리스트
- [ ] `Communicator.kt` 생성
- [ ] `interface Transport { suspend fun writePacket(base: PacketBase); fun close() }`
- [ ] `typealias ParserMap = Map<String, (ByteArray) -> MessageLite>`
- [ ] `class Communicator(transport, parserMap, scope)` 생성자
- [ ] `initialize()`: HeartBeat 파서 자동 등록, writeLoop launch
- [ ] `isAlive(): Boolean`
- [ ] `nextNonce()`: AtomicInteger.incrementAndGet()
- [ ] `shutdown()`: isAlive=false, writeQueue.close(), scope 내부 채널 정리
- [ ] `close()`: shutdown() + transport.close()
- [ ] `queuePacket(base)`: writeQueue에 enqueue, done 대기
- [ ] `send(m)`: marshal → queuePacket
- [ ] `sendRequest(req, resTypeName, timeout)`: nonce 등록 → queuePacket → withTimeout select
- [ ] `addListener(typeName, fn)`: 상호 배타 체크
- [ ] `removeListeners(typeName)`
- [ ] `addRequestListener(typeName, fn)`: 상호 배타 체크
- [ ] `onReceivedData(typeName, data, nonce, responseNonce)`
- [ ] `handleResponse(typeName, data, responseNonce)`
- [ ] `parse(typeName, data)`
- [ ] `removePending(nonce)`
- [ ] 타입 헬퍼 함수 (Go의 제네릭 헬퍼 대응):
- [ ] `inline fun <reified T : Message> addListenerTyped(communicator, fn)`
- [ ] `inline fun <reified Req : Message, reified Res : Message> addRequestListenerTyped(communicator, fn)`
- [ ] `inline fun <reified Req : Message, reified Res : Message> sendRequestTyped(communicator, req, timeoutMs)`
#### 테스트 작성
**파일:** `kotlin/src/test/kotlin/com/tokilabs/toki_socket/CommunicatorTest.kt`
| 테스트명 | 검증 목표 |
|---------|---------|
| `testSendRequestTimeout` | timeout 경과 시 exception 발생 |
| `testSendRequestTypeMismatch` | 응답 typeName 불일치 시 exception 발생 |
| `testListenerAndRequestListenerConflict` | 동일 typeName 이중 등록 시 IllegalStateException |
| `testSendFireAndForget` | send 후 fakeTransport에 패킷 1개 기록됨 |
#### 중간 검증
```bash
cd kotlin
./gradlew test --tests "*.CommunicatorTest"
# 예상: 4개 테스트 PASS
```
---
### [API-3] HeartbeatTimer 구현
#### 문제
주기적 heartbeat 발송을 위한 취소 가능한 타이머가 없다.
#### 해결 방법
Go의 `HeartbeatTimer`를 coroutine `delay` 기반으로 이식한다.
**파일:** `kotlin/src/main/kotlin/com/tokilabs/toki_socket/HeartbeatTimer.kt`
```kotlin
class HeartbeatTimer(
private val scope: CoroutineScope,
private val delayMs: Long,
private val callback: suspend () -> Unit
) {
private var job: Job? = null
fun reset(delayMs: Long = this.delayMs) {
job?.cancel()
job = scope.launch {
delay(delayMs)
callback()
}
}
fun stop() {
job?.cancel()
job = null
}
}
```
`scope`는 `BaseClient`에서 생성된 `CoroutineScope(SupervisorJob() + Dispatchers.IO)`를 전달한다.
#### 수정 파일 및 체크리스트
- [ ] `HeartbeatTimer.kt` 생성
- [ ] `reset(delayMs)`: 기존 job cancel 후 새 delay job launch
- [ ] `stop()`: job cancel
#### 테스트 작성
**파일:** `kotlin/src/test/kotlin/com/tokilabs/toki_socket/HeartbeatTimerTest.kt`
| 테스트명 | 검증 목표 |
|---------|---------|
| `testCallbackFires` | delay 후 callback 호출됨 |
| `testStopPreventsCallback` | stop() 후 callback 미호출 |
| `testResetRestartsTimer` | reset() 후 이전 callback 미호출, 새 delay 후 호출 |
#### 중간 검증
```bash
cd kotlin
./gradlew test --tests "*.HeartbeatTimerTest"
# 예상: 3개 테스트 PASS
```
---
### [API-4] BaseClient 구현
#### 문제
heartbeat 로직, disconnect 리스너, connCloseOnce(close-once) 공통 구조가 없다.
#### 해결 방법
Go의 `baseClient[Self]`를 `abstract class BaseClient<Self : BaseClient<Self>>`로 이식한다.
**파일:** `kotlin/src/main/kotlin/com/tokilabs/toki_socket/BaseClient.kt`
**핵심 매핑:**
| Go | Kotlin |
|----|--------|
| `self Self` | 생성자 파라미터로 `self: Self` 수신 |
| `connCloseOnce sync.Once` | `AtomicBoolean` + `compareAndSet(false, true)` |
| `hbMu sync.Mutex` | `Mutex` (kotlinx.coroutines) |
| `disconnectListeners` | `CopyOnWriteArrayList<(Self) -> Unit>` |
| `doClose func() error` | 생성자 람다 `doClose: suspend () -> Unit` |
```kotlin
abstract class BaseClient<Self : BaseClient<Self>>(
private val self: Self,
intervalSec: Int,
waitSec: Int,
private val doClose: suspend () -> Unit
) {
protected val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
abstract val communicator: Communicator
private val heartbeatIntervalMs = intervalSec * 1000L
private val heartbeatWaitMs = waitSec * 1000L
private val closedOnce = AtomicBoolean(false)
private val hbMutex = Mutex()
private var hbTimer: HeartbeatTimer? = null
private var waitingHBResponse = false
private val disconnectListeners = CopyOnWriteArrayList<(Self) -> Unit>()
fun addDisconnectListener(handler: (Self) -> Unit) { disconnectListeners.add(handler) }
fun removeDisconnectListeners() { disconnectListeners.clear() }
suspend fun close() {
if (!closedOnce.compareAndSet(false, true)) return
communicator.shutdown()
stopHeartbeat()
doClose()
notifyDisconnected()
scope.cancel()
}
// sendHeartBeat, onHeartBeat, stopHeartbeat, onDisconnected, notifyDisconnected
// Go 로직과 동일하게 구현
}
```
`close()`는 `connCloseOnce.compareAndSet(false, true)` 패턴으로 멱등성 보장. Go의 `sync.Once`와 동일 의미.
#### 수정 파일 및 체크리스트
- [ ] `BaseClient.kt` 생성
- [ ] `scope`: `CoroutineScope(SupervisorJob() + Dispatchers.IO)`
- [ ] `close()`: compareAndSet + communicator.shutdown() + stopHeartbeat() + doClose() + notifyDisconnected() + scope.cancel()
- [ ] `sendHeartBeat()`: hbMutex 잠금, 이전 타이머 정지, 새 interval 타이머 → HeartBeat 전송 → waitingHBResponse=true → wait 타이머 → onDisconnected
- [ ] `onHeartBeat()`: waitingHBResponse 분기 처리 (Go 로직 그대로)
- [ ] `stopHeartbeat()`: hbTimer?.stop()
- [ ] `onDisconnected()`: close() 호출
- [ ] `notifyDisconnected()`: listener 복사 후 순회 호출
#### 테스트 작성
SKIP — heartbeat 통합 동작은 API-7의 HeartbeatTest에서 TcpClient를 통해 검증한다. BaseClient 자체는 추상 클래스여서 단독 단위 테스트가 어렵다.
#### 중간 검증
```bash
cd kotlin
./gradlew compileKotlin
# 예상: BUILD SUCCESSFUL (API-5 구현 전이므로 컴파일만 확인)
```
---
### [API-5] TcpClient / TcpServer 구현
#### 문제
Kotlin에 TCP transport가 없다.
#### 해결 방법
Go의 `TcpClient`, `TcpServer`를 `java.net.Socket` / `ServerSocket` + coroutine으로 이식한다.
**파일:**
- `kotlin/src/main/kotlin/com/tokilabs/toki_socket/TcpClient.kt`
- `kotlin/src/main/kotlin/com/tokilabs/toki_socket/TcpServer.kt`
**TcpClient:**
```kotlin
class TcpClient private constructor(
private val socket: Socket,
intervalSec: Int,
waitSec: Int,
) : BaseClient<TcpClient>(
self = /* 순환 참조 해결: lateinit + apply */ ...,
intervalSec = intervalSec,
waitSec = waitSec,
doClose = { socket.close() }
) {
override val communicator: Communicator = Communicator(this, parserMap, scope)
private val writeMutex = Mutex()
// TCP Transport 구현
suspend fun writePacket(base: PacketBase) {
val bytes = base.toByteArray()
val header = ByteBuffer.allocate(4).putInt(bytes.size).array()
writeMutex.withLock {
withContext(Dispatchers.IO) {
socket.getOutputStream().write(header)
socket.getOutputStream().write(bytes)
}
}
}
fun closeTransport() { socket.close() }
private fun readLoop() {
scope.launch(Dispatchers.IO) {
val input = socket.getInputStream()
val header = ByteArray(4)
while (communicator.isAlive()) {
try {
input.readFully(header)
val length = ByteBuffer.wrap(header).int
if (length == 0) continue
if (length > MAX_PACKET_SIZE) { onDisconnected(); return@launch }
val bytes = ByteArray(length)
input.readFully(bytes)
val base = PacketBase.parseFrom(bytes)
communicator.onReceivedData(base.typeName, base.data.toByteArray(), base.nonce, base.responseNonce)
sendHeartBeat()
} catch (e: Exception) {
onDisconnected(); return@launch
}
}
}
}
}
```
`InputStream.readFully`는 `java.io.DataInputStream` wrapping으로 구현 (`readFully` extension 함수 정의).
**TcpServer:**
Go의 `TcpServer`와 동일 구조. `ServerSocket.accept()`를 `Dispatchers.IO`에서 loop.
```kotlin
class TcpServer(
private val host: String,
private val port: Int,
private val newClient: (Socket) -> TcpClient,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val clients = CopyOnWriteArrayList<TcpClient>()
private var serverSocket: ServerSocket? = null
var onClientConnected: (TcpClient) -> Unit = {}
fun start() { ... }
fun stop() { ... }
fun broadcast(m: MessageLite) { ... }
fun clients(): List<TcpClient> { ... }
}
```
#### 수정 파일 및 체크리스트
- [ ] `TcpClient.kt` 생성
- [ ] `companion object { const val MAX_PACKET_SIZE = 64 * 1024 * 1024 }`
- [ ] `Transport` 구현: `writePacket` (4-byte BE header + proto bytes), `close`
- [ ] `readLoop()`: header 4바이트 읽기 → length=0 skip → length>MAX reject → bytes 읽기 → PacketBase.parseFrom → onReceivedData → sendHeartBeat
- [ ] `DialTcp(host, port, intervalSec, waitSec, parserMap)` 팩토리 함수
- [ ] HeartBeat 리스너 등록 (`communicator.addListener(HeartBeat typeName, ::onHeartBeat)`)
- [ ] WriteErrorHandler 등록 (`communicator.setWriteErrorHandler { onDisconnected() }`)
- [ ] `TcpServer.kt` 생성
- [ ] `start()`: ServerSocket bind → accept loop (Dispatchers.IO)
- [ ] `stop()`: serverSocket.close() → clients 복사 후 전부 close
- [ ] `broadcast(m)`: clients 순회하여 send
- [ ] 클라이언트 disconnect 시 clients 리스트에서 제거 (addDisconnectListener 활용)
#### 테스트 작성
**파일:** `kotlin/src/test/kotlin/com/tokilabs/toki_socket/TcpTest.kt`
| 테스트명 | 검증 목표 |
|---------|---------|
| `testTcpSendReceive` | client.send → server addListener 수신 |
| `testTcpRequestResponse` | sendRequestTyped → index*2, "echo: msg" |
| `testTcpBroadcast` | server.broadcast → 연결된 클라이언트 수신 |
| `testTcpServerStopDisconnectsClients` | server.stop() → client disconnect 콜백 |
| `testTcpClientCloseIdempotent` | close() 3회 호출 시 오류 없음 |
#### 중간 검증
```bash
cd kotlin
./gradlew test --tests "*.TcpTest"
# 예상: 5개 테스트 PASS
```
---
### [API-6] WsClient / WsServer 구현
#### 문제
Kotlin에 WebSocket transport가 없다.
#### 해결 방법
WebSocket 클라이언트는 OkHttp(`okhttp3.WebSocket`), 서버는 `org.java-websocket:Java-WebSocket`(`WebSocketServer`)을 사용한다. Android 호환을 위해 서버 쪽 `java-websocket`은 JVM 테스트/서버 전용으로 명시한다.
**파일:**
- `kotlin/src/main/kotlin/com/tokilabs/toki_socket/WsClient.kt`
- `kotlin/src/main/kotlin/com/tokilabs/toki_socket/WsServer.kt`
**WsClient (OkHttp 기반):**
```kotlin
class WsClient private constructor(
private val ws: okhttp3.WebSocket,
private val closeWs: () -> Unit,
intervalSec: Int,
waitSec: Int,
parserMap: ParserMap,
) : BaseClient<WsClient>(...) {
override val communicator = Communicator(this /* as Transport */, parserMap, scope)
// Transport 구현
fun writePacket(base: PacketBase) {
val bytes = base.toByteArray()
ws.send(ByteString.of(*bytes)) // OkHttp binary frame
}
// OkHttp WebSocketListener (onMessage에서 onReceivedData 호출)
}
```
OkHttp `WebSocket.send(ByteString)`은 thread-safe하므로 별도 writeMutex 불필요.
**WsServer (Java-WebSocket 기반):**
```kotlin
class WsServer(host: String, port: Int) : org.java_websocket.server.WebSocketServer(...) {
val clients = CopyOnWriteArrayList<WsClient>()
var onClientConnected: (WsClient) -> Unit = {}
override fun onOpen(conn: WebSocket, handshake: ClientHandshake) { ... }
override fun onMessage(conn: WebSocket, bytes: ByteBuffer) { ... }
override fun onClose(...) { ... }
override fun onError(...) { ... }
}
```
`java-websocket`의 `onMessage(conn, ByteBuffer)`로 binary frame을 수신하여 `PacketBase.parseFrom(bytes)` 후 해당 WsClient의 `communicator.onReceivedData(...)` 호출.
#### 수정 파일 및 체크리스트
- [ ] `WsClient.kt` 생성
- [ ] OkHttpClient + `Request.Builder().url(ws://...)` + `newWebSocket(request, listener)`
- [ ] `WebSocketListener.onMessage(ws, bytes: ByteString)`: PacketBase.parseFrom → onReceivedData → sendHeartBeat
- [ ] `WebSocketListener.onFailure(ws, t, response)`: onDisconnected
- [ ] `Transport.writePacket`: `ws.send(ByteString.of(*bytes))`
- [ ] `Transport.close`: `ws.close(1000, null)` + OkHttpClient.dispatcher.executorService.shutdown()
- [ ] HeartBeat 리스너 등록, WriteErrorHandler 등록
- [ ] `DialWs(host, port, path, intervalSec, waitSec, parserMap)` 팩토리
- [ ] `DialWss(host, port, path, sslContext, intervalSec, waitSec, parserMap)` 팩토리
- [ ] `WsServer.kt` 생성
- [ ] `WebSocketServer` 상속, binary 프레임 처리
- [ ] WsClient 인스턴스 생성 및 clients 리스트 관리
- [ ] `start()` / `stop()` / `broadcast(m)`
- [ ] 클라이언트 disconnect 시 clients 리스트에서 제거
#### 테스트 작성
**파일:** `kotlin/src/test/kotlin/com/tokilabs/toki_socket/WsTest.kt`
| 테스트명 | 검증 목표 |
|---------|---------|
| `testWsSendReceive` | client.send → server addListener 수신 |
| `testWsRequestResponse` | sendRequestTyped → index*2, "echo: msg" |
| `testWsBroadcast` | server.broadcast → 연결된 클라이언트 수신 |
| `testWsServerStopDisconnectsClients` | server.stop() → client disconnect 콜백 |
#### 중간 검증
```bash
cd kotlin
./gradlew test --tests "*.WsTest"
# 예상: 4개 테스트 PASS
```
---
### [API-7] Heartbeat 통합 테스트
#### 문제
heartbeat 타이머가 비활성 구간 후 올바르게 발송되고, 응답 없을 때 disconnect를 트리거하는지 검증되지 않았다.
#### 해결 방법
Go의 `heartbeat_test.go` 패턴을 Kotlin coroutine 테스트로 이식한다. `runTest` + `TestCoroutineScheduler`로 실제 시간 대기 없이 가상 시간 진행.
**파일:** `kotlin/src/test/kotlin/com/tokilabs/toki_socket/HeartbeatTest.kt`
| 테스트명 | 검증 목표 |
|---------|---------|
| `testHeartbeatSentAfterInactivity` | intervalSec 경과 → HeartBeat 패킷 전송 확인 |
| `testHeartbeatDisconnectOnNoResponse` | waitSec 경과 → onDisconnected 호출 확인 |
| `testHeartbeatResetOnReceive` | 메시지 수신 → heartbeat 타이머 리셋 확인 |
#### 수정 파일 및 체크리스트
- [ ] `HeartbeatTest.kt` 생성 (위 3개 테스트)
#### 테스트 작성
본 항목 자체가 테스트 작성이다.
#### 중간 검증
```bash
cd kotlin
./gradlew test --tests "*.HeartbeatTest"
# 예상: 3개 테스트 PASS
```
---
### [API-8] Go ↔ Kotlin 크로스테스트
#### 문제
다른 언어 구현체와의 wire format 호환성이 검증되지 않았다.
#### 해결 방법
`skills/add-toki-socket-crosstest-language/SKILL.md`의 runner 배치 규칙을 따른다.
**추가 파일:**
| 파일 | 역할 |
|------|------|
| `go/crosstest/go_kotlin.go` | Go 서버 오케스트레이터 (Kotlin subprocess 실행) |
| `kotlin/crosstest/go_kotlin_client/Main.kt` | Kotlin subprocess (Go 서버에 접속) |
| `kotlin/crosstest/kotlin_go.kt` | Kotlin 서버 오케스트레이터 (Go subprocess 실행) |
| `go/crosstest/kotlin_go_client/main.go` | Go subprocess (Kotlin 서버에 접속) |
**포트 배정 (기존과 충돌 없음):**
```
Go server / Kotlin client TCP: 29290
Go server / Kotlin client WS: 29292
Kotlin server / Go client TCP: 29390
Kotlin server / Go client WS: 29392
```
기존 포트 (충돌 없음 확인):
- 29090 (Dart server / Go client TCP)
- 29092 (Dart server / Go client WS)
- 29190 (Go server / Dart client TCP)
- 29192 (Go server / Dart client WS)
**시나리오 (TCP + WebSocket 각각):**
| 시나리오 | 내용 |
|---------|------|
| 1 | 클라이언트 fire-and-forget `TestData(index=101, message="fire from kotlin client")` → 서버 검증 |
| 2 | 서버 push `TestData(index=200, message="push from go server")` → 클라이언트 검증 |
| 3 | 클라이언트 `sendRequest` → 응답 `index=req.index*2`, `message="echo: req.message"` |
| 4 | 동시 5개 `sendRequest` → responseNonce 라우팅 검증 |
send-push/requests 페이즈 분리 패턴은 Go ↔ Dart 크로스테스트와 동일하게 적용한다.
**`kotlin/crosstest/go_kotlin_client/Main.kt` 실행 방법:**
```bash
cd kotlin
./gradlew run --args="--mode=tcp --port=29290 --phase=send-push"
```
`build.gradle.kts`에 `application { mainClass.set("com.tokilabs.toki_socket.crosstest.MainKt") }` 추가 필요.
**`go/crosstest/go_kotlin.go` 실행 방법:**
```bash
cd go
go run ./crosstest/go_kotlin.go
```
#### 수정 파일 및 체크리스트
- [ ] `go/crosstest/go_kotlin.go` 생성
- [ ] TCP send-push (port 29290), TCP requests, WS send-push (port 29292), WS requests
- [ ] Kotlin subprocess 실행: `./gradlew run --args="..."` (kotlin dir 기준)
- [ ] `validateResultLines` 활용 (go_dart.go와 동일 헬퍼)
- [ ] `kotlin/crosstest/go_kotlin_client/Main.kt` 생성
- [ ] `INFO typeName kotlin=TestData` 출력
- [ ] `--mode`, `--port`, `--phase` 인수 파싱
- [ ] send-push: TestData 전송 (시나리오 1) + 서버 push 수신 (시나리오 2)
- [ ] requests: 단일 request (시나리오 3) + 5개 concurrent request (시나리오 4)
- [ ] `PASS scenario=N detail=...` / `FAIL scenario=N error=...` 출력
- [ ] `kotlin/crosstest/kotlin_go.kt` 생성 (Kotlin 서버 오케스트레이터)
- [ ] TCP 서버 (29390) + WS 서버 (29392) 시작
- [ ] Go subprocess 실행: `go run ./crosstest/kotlin_go_client`
- [ ] PASS/FAIL 파싱 및 검증
- [ ] `go/crosstest/kotlin_go_client/main.go` 생성
- [ ] Kotlin 서버에 접속하는 Go 클라이언트 (dart_go_client/main.go와 동일 구조)
- [ ] `INFO typeName go=TestData` 출력
- [ ] 동일 4개 시나리오
#### 테스트 작성
본 항목 자체가 크로스테스트이다.
#### 중간 검증
```bash
# Go 서버 ↔ Kotlin 클라이언트
cd go && go run ./crosstest/go_kotlin.go
# 예상: PASS all go-server/kotlin-client crosstests passed
# Kotlin 서버 ↔ Go 클라이언트
cd kotlin && ./gradlew run -PmainClass=com.tokilabs.toki_socket.crosstest.KotlinGoKt
# 또는
cd kotlin && kotlinc -script crosstest/kotlin_go.kts
# 예상: PASS all kotlin-server/go-client crosstests passed
```
---
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `kotlin/settings.gradle.kts` | API-1 |
| `kotlin/build.gradle.kts` | API-1 |
| `kotlin/src/main/proto/message_common.proto` | API-1 |
| `kotlin/src/main/kotlin/.../Communicator.kt` | API-2 |
| `kotlin/src/test/kotlin/.../CommunicatorTest.kt` | API-2 |
| `kotlin/src/main/kotlin/.../HeartbeatTimer.kt` | API-3 |
| `kotlin/src/test/kotlin/.../HeartbeatTimerTest.kt` | API-3 |
| `kotlin/src/main/kotlin/.../BaseClient.kt` | API-4 |
| `kotlin/src/main/kotlin/.../TcpClient.kt` | API-5 |
| `kotlin/src/main/kotlin/.../TcpServer.kt` | API-5 |
| `kotlin/src/test/kotlin/.../TcpTest.kt` | API-5 |
| `kotlin/src/main/kotlin/.../WsClient.kt` | API-6 |
| `kotlin/src/main/kotlin/.../WsServer.kt` | API-6 |
| `kotlin/src/test/kotlin/.../WsTest.kt` | API-6 |
| `kotlin/src/test/kotlin/.../HeartbeatTest.kt` | API-7 |
| `go/crosstest/go_kotlin.go` | API-8 |
| `kotlin/crosstest/go_kotlin_client/Main.kt` | API-8 |
| `kotlin/crosstest/kotlin_go.kt` | API-8 |
| `go/crosstest/kotlin_go_client/main.go` | API-8 |
| `PROTOCOL.md` | Kotlin 행 status 업데이트 |
| `README.md` | Kotlin 행 status 업데이트 |
---
## 최종 검증
```bash
# 1. Kotlin 단위 테스트 전체
cd kotlin
./gradlew test
# 예상: 모든 테스트 PASS, BUILD SUCCESSFUL
# 2. Go 단위 테스트 (크로스테스트 헬퍼 추가로 인한 회귀 없음)
cd go
go test ./...
# 예상: ok toki-labs.com/toki_socket/go [no test files changed]
# 3. Kotlin linter
cd kotlin
./gradlew ktlintCheck # ktlint plugin 추가 시
# 예상: BUILD SUCCESSFUL
# 4. Go 서버 ↔ Kotlin 클라이언트 크로스테스트
cd go
go run ./crosstest/go_kotlin.go
# 예상: PASS all go-server/kotlin-client crosstests passed
# 5. Kotlin 서버 ↔ Go 클라이언트 크로스테스트
cd kotlin
./gradlew run -PmainClass=com.tokilabs.toki_socket.crosstest.KotlinGoKt
# 예상: PASS all kotlin-server/go-client crosstests passed
# 6. proto 동기화 검증
tools/check_proto_sync.sh
# 예상: no diff
```