Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

재시도 로직 구현 및 테스트 추가 #7

Merged
merged 7 commits into from
Jan 10, 2025
82 changes: 81 additions & 1 deletion dummy/src/test/kotlin/MainIntegrationTest.kt
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
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
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 {

Expand All @@ -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)
}
Expand Down Expand Up @@ -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, "비활성화된 재시도에서 요청 횟수가 잘못되었습니다.")
}
}
2 changes: 0 additions & 2 deletions jvm-sdk/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
110 changes: 70 additions & 40 deletions jvm-sdk/src/main/kotlin/com/gamedatahub/network/JvmNetworkClient.kt
Original file line number Diff line number Diff line change
@@ -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) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

재시도 로직은 좋은 것 같은데요. 음 ... 만약 재시도를 해야하는 상황이라면, 요청을 받는 서버쪽에 문제가 생겼을 가능성이 높은데, 그 타이밍에 계속 재시도를 하는 것이 좋은가? 에 대해서 생각해보면, 좋을 것 같네요. (이 문제는 답은 없습니다만...) 그리고 재시도 하는 동안 (정확히는 sleep하는동안) 요청이 계속 들어올텐데, 그럼 그런 요청들도 문제가 생겨서 재시도 로직을 타게되면, 결과적으로 많은 로그들이 쌓이게 되지 않을까요?

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)
}
}
}
Loading