<!-- task=kotlin_impl plan=1 tag=REVIEW_API -->

# Kotlin 구현 후속 수정 — WARN 항목 해소

## 이 파일을 읽는 구현 에이전트에게

각 항목의 체크리스트를 완료 처리하고, 중간 검증 명령을 실제로 실행한 뒤 출력을 `CODE_REVIEW.md`의 `검증 결과` 섹션에 붙여 넣으세요.
계획과 다르게 구현한 부분이 있으면 `계획 대비 변경 사항`에 이유와 함께 기록하세요.

---

## 배경

`plan_0.log` 의 WARN 판정에서 발견된 4개 Suggested 항목을 수정한다. Required 항목은 없었고, Nit 1개(`Communicator.parse()` visibility)는 아래 포함한다. JVM 환경 부재로 인한 Gradle 미검증 상태는 이 항목들과 무관하므로 별도 다루지 않는다.

---

## 의존 관계 및 구현 순서

REVIEW_API-1 → REVIEW_API-2 (sourceset 변경이 선행돼야 kotlin 빌드 구조에서 REVIEW_API-3, 4 검증 가능) → REVIEW_API-3, REVIEW_API-4 (병렬 가능)

---

### [REVIEW_API-1] `WsServer.stop()` — `InterruptedException` 미처리 수정

#### 문제

`kotlin/src/main/kotlin/com/tokilabs/toki_socket/WsServer.kt:41`

`super.stop(1000)` 은 `WebSocketServer.stop(int)` 로, Java 검사 예외 `InterruptedException` 을 선언한다. `runBlocking` 내에서 호출될 때 이 예외가 전파되면 현재 coroutine이 취소 신호로 처리하여 `stop()` 이후 정리 코드가 실행되지 않을 수 있다.

#### 해결 방법

`super.stop(1000)` 을 `runCatching` 으로 감싼다.

**Before (`WsServer.kt:36-41`):**
```kotlin
override fun stop() {
    if (!startedFlag.compareAndSet(true, false)) return
    val snapshot = clients.toList()
    clients.clear()
    snapshot.forEach { it.close() }
    super.stop(1000)
}
```

**After:**
```kotlin
override fun stop() {
    if (!startedFlag.compareAndSet(true, false)) return
    val snapshot = clients.toList()
    clients.clear()
    snapshot.forEach { it.close() }
    runCatching { super.stop(1000) }
}
```

#### 수정 파일 및 체크리스트

- [x] `kotlin/src/main/kotlin/com/tokilabs/toki_socket/WsServer.kt` — `super.stop(1000)` → `runCatching { super.stop(1000) }`

#### 테스트 작성

SKIP — 기존 `WsTest.testWsServerStopDisconnectsClients` 가 stop 경로를 커버한다. `InterruptedException` 을 강제로 발생시키는 단위 테스트는 `java-websocket` 내부 구현 의존성이 높아 추가하지 않는다.

#### 중간 검증

```bash
cd kotlin
./gradlew compileKotlin
# 예상: BUILD SUCCESSFUL
```

---

### [REVIEW_API-2] `crosstest/` 를 main sourceset에서 분리

#### 문제

`kotlin/build.gradle.kts:14`

```kotlin
named("main") {
    kotlin.srcDir("crosstest")
}
```

`kotlin_go.kt`, `go_kotlin_client/Main.kt` 가 라이브러리 main sourceset에 포함되어 프로덕션 JAR에 crosstest 코드가 실린다.

#### 해결 방법

`build.gradle.kts` 에서 `crosstest` 를 sourceSets.main 에서 제거하고, 대신 별도 `crosstest` sourceset 을 정의한다. `application` 플러그인의 `mainClass` 는 기본값을 크로스테스트 runner로 유지하되 `crosstest` sourceset을 통해 공급한다.

**Before (`build.gradle.kts:10-17`):**
```kotlin
kotlin {
    jvmToolchain(17)
    sourceSets {
        named("main") {
            kotlin.srcDir("crosstest")
        }
    }
}
```

**After:**
```kotlin
kotlin {
    jvmToolchain(17)
}

sourceSets {
    create("crosstest") {
        kotlin.srcDir("crosstest")
        compileClasspath += sourceSets["main"].output + configurations["runtimeClasspath"]
        runtimeClasspath += output + compileClasspath
    }
}
```

`application` 블록의 `mainClass` 는 `crosstest` sourceset의 classpath 에서 로드되도록 `run` task를 재구성한다.

**Before (`build.gradle.kts:42-47`):**
```kotlin
application {
    mainClass.set(
        (findProperty("mainClass") as String?)
            ?: "com.tokilabs.toki_socket.crosstest.MainKt",
    )
}
```

**After:**
```kotlin
val crosstestSourceSet = sourceSets["crosstest"]

tasks.named<JavaExec>("run") {
    classpath = crosstestSourceSet.runtimeClasspath
    mainClass.set(
        (findProperty("mainClass") as String?)
            ?: "com.tokilabs.toki_socket.crosstest.MainKt",
    )
}
```

#### 수정 파일 및 체크리스트

- [x] `kotlin/build.gradle.kts` — `sourceSets.main.kotlin.srcDir("crosstest")` 제거
- [x] `kotlin/build.gradle.kts` — `crosstest` sourceset 정의 추가
- [x] `kotlin/build.gradle.kts` — `tasks.named<JavaExec>("run")` 블록에서 classpath를 `crosstestSourceSet.runtimeClasspath` 로 지정
- [x] `kotlin/build.gradle.kts` — `kotlin { sourceSets { named("main") { ... } } }` 블록 제거 또는 빈 상태로 정리

#### 테스트 작성

SKIP — 빌드 구조 변경이므로 컴파일 성공으로 충분하다.

#### 중간 검증

```bash
cd kotlin
./gradlew compileKotlin compileCrosstestKotlin
# 예상: BUILD SUCCESSFUL
# crosstest 클래스가 main JAR에 포함되지 않는지 확인:
./gradlew jar
jar tf build/libs/toki-socket-kotlin-0.1.0.jar | grep crosstest
# 예상: 출력 없음 (crosstest 클래스 미포함)
```

---

### [REVIEW_API-3] `kotlin_go.kt` listener 내 `runBlocking` 제거

#### 문제

`kotlin/crosstest/kotlin_go.kt:56-63`, `104-111`

`addListenerTyped` 의 callback은 `TcpClient.readLoop` 의 `Dispatchers.IO` coroutine에서 동기 호출된다. 여기서 `runBlocking { client.send(...) }` 를 호출하면 IO 스레드를 블로킹한다.

#### 해결 방법

`runBlocking { ... }` 을 `scope.launch { ... }` 로 교체한다. `kotlin_go.kt` 파일은 `runBlocking` scope 안에서 실행되므로, 서버 scope를 직접 사용하거나 `GlobalScope` 대신 로컬 `CoroutineScope` 를 생성한다. 가장 단순한 방법은 listener에서 별도 coroutine을 시작한다.

**Before (`kotlin_go.kt:51-63`):**
```kotlin
server.onClientConnected = { client ->
    addListenerTyped<TestData>(client.communicator) { data ->
        println("SERVER_RECEIVED index=${data.index} message=${data.message}")
        val valid = data.index == 101 && data.message == "fire from go client"
        received.complete(valid)
        if (valid) {
            runBlocking {
                client.send(
                    TestData.newBuilder()
                        .setIndex(200)
                        .setMessage("push from kotlin server")
                        .build(),
                )
            }
        }
    }
}
```

**After:**
```kotlin
server.onClientConnected = { client ->
    addListenerTyped<TestData>(client.communicator) { data ->
        println("SERVER_RECEIVED index=${data.index} message=${data.message}")
        val valid = data.index == 101 && data.message == "fire from go client"
        received.complete(valid)
        if (valid) {
            client.communicator.scope.launch {
                client.send(
                    TestData.newBuilder()
                        .setIndex(200)
                        .setMessage("push from kotlin server")
                        .build(),
                )
            }
        }
    }
}
```

단, `Communicator.scope` 가 현재 `private` 이다. `BaseClient.scope` 가 `protected` 이므로, `kotlin_go.kt` 에서 접근 가능한 방법이 필요하다. `TcpClient` 가 `BaseClient` 를 상속하므로 `client.scope` (protected)를 `internal` 또는 직접 접근 가능한 방식으로 노출하거나, `client.communicator` 를 통해 접근하는 대신 `kotlinx.coroutines.GlobalScope` 를 임시 사용한다.

가장 깔끔한 방법: `BaseClient` 의 `scope` 를 `internal` 로 노출하여 같은 모듈 내 crosstest 코드에서 사용 가능하게 한다.

**`BaseClient.kt` 변경:**
```kotlin
// Before
protected val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

// After
internal val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
```

`kotlin_go.kt` 의 `runBlocking { client.send(...) }` 두 곳 (TCP send-push:56-63, WS send-push:104-111) 을 `client.scope.launch { client.send(...) }` 로 교체한다.

#### 수정 파일 및 체크리스트

- [x] `kotlin/src/main/kotlin/com/tokilabs/toki_socket/BaseClient.kt` — `scope` 를 `protected` → `internal` 로 변경
- [x] `kotlin/crosstest/kotlin_go.kt:56-63` — `runBlocking { client.send(...) }` → `coroutineScope` 의 `launch { client.send(...) }`
- [x] `kotlin/crosstest/kotlin_go.kt:104-111` — 동일 변경

#### 테스트 작성

SKIP — crosstest 코드 수정이고 기능 변경이 없다. 크로스테스트 자체가 검증이다.

#### 중간 검증

```bash
cd kotlin
./gradlew compileCrosstestKotlin
# 예상: BUILD SUCCESSFUL (runBlocking 제거로 인한 compile error 없음)
```

---

### [REVIEW_API-4] `HeartbeatTest.testHeartbeatResetOnReceive()` — 수신 경로 검증 추가

#### 문제

`kotlin/src/test/kotlin/com/tokilabs/toki_socket/HeartbeatTest.kt:69-81`

현재 테스트는 `sendHeartBeat()` 를 두 번 직접 호출한다. 실제 경로인 `communicator.onReceivedData()` → `sendHeartBeat()` 를 거쳐 타이머가 리셋되는지 검증하지 않는다.

#### 해결 방법

기존 테스트를 두 개로 분리한다:
1. `testHeartbeatTimerResetBySendHeartBeat` — 기존 로직 유지 (sendHeartBeat 재호출 → 타이머 리셋)
2. `testHeartbeatTimerResetOnReceivedData` — `communicator.onReceivedData()` 를 호출한 뒤 heartbeat가 지연되는지 확인

**새 테스트 (`testHeartbeatTimerResetOnReceivedData`):**

```kotlin
@Test
fun testHeartbeatTimerResetOnReceivedData() = runBlocking {
    val transport = HeartbeatTransport()
    val client = HeartbeatClient(transport, 1, 1)
    var received = false
    client.communicator.addListener(typeNameOf(testData())) {
        received = true
    }

    client.sendHeartBeat()
    delay(700)
    // 데이터 수신 시뮬레이션 — readLoop가 onReceivedData 후 sendHeartBeat() 호출하는 경로
    client.communicator.onReceivedData(typeNameOf(testData()), testData().toByteArray(), incomingNonce = 1)
    assertTrue(received)
    client.sendHeartBeat()  // readLoop가 호출하는 sendHeartBeat()
    delay(500)
    assertTrue(transport.packets.none { it.typeName == typeNameOf<HeartBeat>() })
    delay(700)
    assertTrue(transport.packets.any { it.typeName == typeNameOf<HeartBeat>() })
    client.close()
}
```

`testData()` 헬퍼: `TestHelpers.kt` 에 `fun testData() = TestData.newBuilder().setIndex(1).setMessage("ping").build()` 추가.

`HeartbeatClient` 의 `communicator` parserMap 에 `TestData` 가 포함되도록 `testParserMap()` 을 사용한다. 테스트에서는 `TestData` 리스너를 등록하고 `received` 를 확인해 `onReceivedData()` 가 listener dispatch까지 실제로 진행됐는지 검증한다.

#### 수정 파일 및 체크리스트

- [x] `kotlin/src/test/kotlin/com/tokilabs/toki_socket/HeartbeatTest.kt` — `testHeartbeatResetOnReceive` 를 `testHeartbeatTimerResetBySendHeartBeat` 로 rename
- [x] `kotlin/src/test/kotlin/com/tokilabs/toki_socket/HeartbeatTest.kt` — `testHeartbeatTimerResetOnReceivedData` 테스트 추가
- [x] `kotlin/src/test/kotlin/com/tokilabs/toki_socket/TestHelpers.kt` — `fun testData()` 헬퍼 추가

#### 테스트 작성

본 항목이 테스트 추가이다.

#### 중간 검증

```bash
cd kotlin
./gradlew test --tests "*.HeartbeatTest"
# 예상: 4개 테스트 PASS (기존 3개 + 신규 1개)
```

---

### [REVIEW_API-5] `Communicator.parse()` visibility — `internal` 로 제한 (Nit)

#### 문제

`kotlin/src/main/kotlin/com/tokilabs/toki_socket/Communicator.kt:266`

`parse()` 가 `public` 으로 노출되어 있다. Go 의 `parse` 는 unexported이다. inline helper들은 `parse()` 를 직접 호출하지 않는다.

#### 해결 방법

```kotlin
// Before
fun parse(typeName: String, data: ByteArray): MessageLite {

// After
internal fun parse(typeName: String, data: ByteArray): MessageLite {
```

#### 수정 파일 및 체크리스트

- [x] `kotlin/src/main/kotlin/com/tokilabs/toki_socket/Communicator.kt:266` — `fun parse` → `internal fun parse`

#### 테스트 작성

SKIP — `CommunicatorTest` 는 `parse()` 를 직접 호출하지 않는다. 같은 모듈이므로 `internal` 후에도 테스트 접근 가능하다.

#### 중간 검증

```bash
cd kotlin
./gradlew compileKotlin compileTestKotlin
# 예상: BUILD SUCCESSFUL
```

---

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `kotlin/src/main/kotlin/com/tokilabs/toki_socket/WsServer.kt` | REVIEW_API-1 |
| `kotlin/build.gradle.kts` | REVIEW_API-2 |
| `kotlin/src/main/kotlin/com/tokilabs/toki_socket/BaseClient.kt` | REVIEW_API-3 |
| `kotlin/crosstest/kotlin_go.kt` | REVIEW_API-3 |
| `kotlin/src/main/kotlin/com/tokilabs/toki_socket/WsClient.kt` | REVIEW_API-3 follow-up: crosstest sourceset에서 server factory 접근 가능하도록 공개 |
| `kotlin/src/test/kotlin/com/tokilabs/toki_socket/HeartbeatTest.kt` | REVIEW_API-4 |
| `kotlin/src/test/kotlin/com/tokilabs/toki_socket/TestHelpers.kt` | REVIEW_API-4 |
| `kotlin/src/test/kotlin/com/tokilabs/toki_socket/TcpTest.kt` | 최종 검증 follow-up: blocking accept를 `Dispatchers.IO`로 이동 |
| `kotlin/src/main/kotlin/com/tokilabs/toki_socket/Communicator.kt` | REVIEW_API-5 |

---

## 최종 검증

```bash
# 1. Kotlin 전체 단위 테스트
cd kotlin
./gradlew test
# 예상: 모든 테스트 PASS (HeartbeatTest 4개 포함)

# 2. JAR에 crosstest 미포함 확인
./gradlew jar
jar tf build/libs/toki-socket-kotlin-0.1.0.jar | grep crosstest
# 예상: 출력 없음

# 3. Go 단위 테스트 회귀 없음
cd ../go && go test ./...
# 예상: ok toki-labs.com/toki_socket/go/test

# 4. proto 동기화
cd .. && bash tools/check_proto_sync.sh
# 예상: Proto schemas are in sync.
```
