<!-- 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
```
