diff --git a/dummy/src/test/kotlin/MainIntegrationTest.kt b/dummy/src/test/kotlin/MainIntegrationTest.kt index db13750..f7baada 100644 --- a/dummy/src/test/kotlin/MainIntegrationTest.kt +++ b/dummy/src/test/kotlin/MainIntegrationTest.kt @@ -1,6 +1,7 @@ import com.gamedatahub.network.JvmNetworkClient import com.gamedatahub.datacollection.DataCollector import com.gamedatahub.serialization.JsonSerializer +import okhttp3.OkHttpClient import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import org.junit.jupiter.api.AfterEach @@ -8,6 +9,8 @@ import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import serialization.JacksonSerializer +import java.io.File +import java.util.concurrent.TimeUnit class MainIntegrationTest { @@ -21,7 +24,12 @@ class MainIntegrationTest { mockWebServer = MockWebServer() mockWebServer.start() - networkClient = JvmNetworkClient() + networkClient = JvmNetworkClient.Builder() + .enableRetry(true) + .maxRetries(3) + .retryDelayMillis(10) + .backoffFactor(2.0) + .build() jsonSerializer = JacksonSerializer(clazz = User::class.java) dataCollector = DataCollector(networkClient, jsonSerializer) } @@ -55,4 +63,76 @@ class MainIntegrationTest { assertEquals("POST", request.method, "HTTP 메서드가 올바르지 않습니다.") assertTrue(request.body.readUtf8().contains("Test User"), "전송된 데이터가 올바르지 않습니다.") } + + @Test + fun `config yml 파일 설정이 제대로 적용된다`() { + val expectedEnabled = true + val expectedRetries = 3 + val expectedDelayMillis = 2000L + + val networkYml = """ + isRetryEnabled: $expectedEnabled + maxRetries: $expectedRetries + retryDelayMillis: $expectedDelayMillis + backoffFactor: 1.5 + """.trimIndent() + + val configFile = File("network.yml") + configFile.writeText(networkYml) + + val client = JvmNetworkClient.Builder() + .loadFromYaml("network.yml") + .build() + + val config = client.config + + assertEquals(expectedEnabled, config.isRetryEnabled, "재시도 설정이 올바르게 설정되지 않았습니다.") + assertEquals(expectedRetries, config.maxRetries, "재시도 횟수가 올바르게 설정되지 않았습니다.") + assertEquals(expectedDelayMillis, config.retryDelayMillis, "재시도 딜레이 값이 올바르게 설정되지 않았습니다.") + + configFile.delete() + } + + @Test + fun `서버 실패 시 정해진 횟수만큼 재시도 후 실패한다`() { + repeat(networkClient.config.maxRetries) { + mockWebServer.enqueue(MockResponse().setResponseCode(500)) + } + + val serverUrl = mockWebServer.url("/test").toString() + networkClient.postDataAsync(serverUrl, "{ \"key\": \"value\" }") + + Thread.sleep(200) + + assertEquals(4, mockWebServer.requestCount, "요청 횟수가 재시도 설정에 맞지 않습니다.") + } + + @Test + fun `재시도 중 서버가 성공하면 요청이 종료된다`() { + repeat(networkClient.config.maxRetries - 1) { + mockWebServer.enqueue(MockResponse().setResponseCode(500)) + } + mockWebServer.enqueue(MockResponse().setResponseCode(200)) + + val serverUrl = mockWebServer.url("/test").toString() + networkClient.postDataAsync(serverUrl, "{ \"key\": \"value\" }") + + Thread.sleep(200) + + assertEquals(3, mockWebServer.requestCount, "재시도 중간에 요청이 성공하지 않았습니다.") + } + + @Test + fun `재시도 설정이 비활성화된 경우 한 번만 요청한다`() { + val clientWithoutRetry = JvmNetworkClient.Builder() + .enableRetry(false) + .build() + mockWebServer.enqueue(MockResponse().setResponseCode(500)) + + val serverUrl = mockWebServer.url("/test").toString() + clientWithoutRetry.postDataAsync(serverUrl, "{ \"key\": \"value\" }") + + Thread.sleep(200) + assertEquals(1, mockWebServer.requestCount, "비활성화된 재시도에서 요청 횟수가 잘못되었습니다.") + } } \ No newline at end of file diff --git a/jvm-sdk/build.gradle.kts b/jvm-sdk/build.gradle.kts index b11ed8b..fa8268e 100644 --- a/jvm-sdk/build.gradle.kts +++ b/jvm-sdk/build.gradle.kts @@ -15,6 +15,4 @@ dependencies { implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:$jacksonVersion") implementation("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion") - // coroutine - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") } \ No newline at end of file diff --git a/jvm-sdk/src/main/kotlin/com/gamedatahub/network/JvmNetworkClient.kt b/jvm-sdk/src/main/kotlin/com/gamedatahub/network/JvmNetworkClient.kt index 781190b..042510f 100644 --- a/jvm-sdk/src/main/kotlin/com/gamedatahub/network/JvmNetworkClient.kt +++ b/jvm-sdk/src/main/kotlin/com/gamedatahub/network/JvmNetworkClient.kt @@ -1,62 +1,92 @@ package com.gamedatahub.network -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory +import com.fasterxml.jackson.module.kotlin.KotlinModule import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request -import kotlinx.coroutines.suspendCancellableCoroutine -import okhttp3.* import okhttp3.RequestBody.Companion.toRequestBody +import java.io.File import java.io.IOException -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException -class JvmNetworkClient( - private val client: OkHttpClient = OkHttpClient() +data class NetworkClientConfig( + val isRetryEnabled: Boolean = false, + val maxRetries: Int = 1, + val retryDelayMillis: Long = 1000, + val backoffFactor: Double = 2.0 +) + +class JvmNetworkClient private constructor( + private val client: OkHttpClient, + val config: NetworkClientConfig, ) : NetworkClient { - private val scope = CoroutineScope(Dispatchers.IO) override fun postDataAsync(url: String, data: String) { - scope.launch { - try { - val response = client.makePostRequest(url, data) - TODO("성공 핸들링") - } catch (e: Exception) { - TODO("실패 핸들링") - } - } + client.makePostRequestAsync(url, data) } -} - -private suspend fun OkHttpClient.makePostRequest(url: String, data: String): String { - val requestBody = data.toRequestBody("application/json".toMediaType()) - val request = Request.Builder() - .url(url) - .post(requestBody) - .build() - - return suspendCancellableCoroutine { continuation -> - val call = this.newCall(request) - - call.enqueue(object : Callback { - override fun onResponse(call: Call, response: Response) { - if (response.isSuccessful) { - continuation.resume(response.body?.string() ?: "") + + private fun OkHttpClient.makePostRequestAsync( + url: String, + data: String, + attempt: Int = 0 + ) { + val requestBody = data.toRequestBody("application/json".toMediaType()) + val request = Request.Builder() + .url(url) + .post(requestBody) + .build() + + val maxAttempts = config.maxRetries + var delay = config.retryDelayMillis + + this.newCall(request).enqueue(object : okhttp3.Callback { + override fun onFailure(call: okhttp3.Call, e: IOException) { + var tmpAttempt = attempt + tmpAttempt++ + if (tmpAttempt <= maxAttempts && config.isRetryEnabled) { + Thread.sleep(delay) + delay = (delay * config.backoffFactor).toLong() + makePostRequestAsync(url, data, tmpAttempt) } else { - continuation.resumeWithException(Exception("Error: ${response.code} - ${response.message}")) + println("Request failed after ${tmpAttempt} attempts: ${e.message}") } - response.close() } - override fun onFailure(call: Call, e: IOException) { - continuation.resumeWithException(e) + override fun onResponse(call: okhttp3.Call, response: okhttp3.Response) { + response.use { + if (!response.isSuccessful) + onFailure(call, IOException("HTTP ${response.code}: ${response.message}")) + } } }) + } + + class Builder { + private var client: OkHttpClient = OkHttpClient() + private var config: NetworkClientConfig = NetworkClientConfig() + + private val yamlMapper: ObjectMapper = ObjectMapper(YAMLFactory()) + .registerModule(KotlinModule()) + + fun loadFromYaml(filePath: String = "config.yml") = + apply { + val file = File(filePath) + config = if (!file.exists()) { + NetworkClientConfig() + } else { + yamlMapper.readValue(file, NetworkClientConfig::class.java) + } + } + + fun httpClient(client: OkHttpClient) = apply { this.client = client } + fun enableRetry(isEnabled: Boolean) = apply { this.config = this.config.copy(isRetryEnabled = isEnabled) } + fun maxRetries(maxRetries: Int) = apply { this.config = this.config.copy(maxRetries = maxRetries) } + fun retryDelayMillis(delayMillis: Long) = apply { this.config = this.config.copy(retryDelayMillis = delayMillis) } + fun backoffFactor(factor: Double) = apply { this.config = this.config.copy(backoffFactor = factor) } - continuation.invokeOnCancellation { - call.cancel() + fun build(): JvmNetworkClient { + return JvmNetworkClient(client, config) } } } \ No newline at end of file