# 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("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("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(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(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() }) delay(700) assertTrue(transport.packets.any { it.typeName == typeNameOf() }) 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. ```