# 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 MessageLite>` | | `atomic.Int32` (nonce) | `AtomicInteger` | | `atomic.Bool` (isAlive) | `AtomicBoolean` | | `sync.RWMutex` | `ReentrantReadWriteLock` | | `channel queuedPacket (64)` | `Channel(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 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 addListenerTyped(communicator, fn)` - [ ] `inline fun addRequestListenerTyped(communicator, fn)` - [ ] `inline fun 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>`로 이식한다. **파일:** `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>( 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( 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() private var serverSocket: ServerSocket? = null var onClientConnected: (TcpClient) -> Unit = {} fun start() { ... } fun stop() { ... } fun broadcast(m: MessageLite) { ... } fun clients(): List { ... } } ``` #### 수정 파일 및 체크리스트 - [ ] `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(...) { 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() 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 ```