Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ load(":deps.bzl", "MODULE_DEPS", "TEST_DEPS")

package(default_visibility = ["//visibility:public"])

kt_jvm_test(
name = "kakao_distance_test",
srcs = ["src/test/kotlin/hs/kr/entrydsm/application/administration/adapterout/distance/KakaoDistanceAdapterTest.kt"],
javac_opts = "//:javac_options",
kotlinc_opts = "//:kotlinc_options",
test_class = "hs.kr.entrydsm.application.administration.adapterout.distance.KakaoDistanceAdapterTest",
deps = MODULE_DEPS + TEST_DEPS + [":main"],
)

kt_jvm_test(
name = "first_screening_mysql_concurrency_test",
tags = ["manual"],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ KOTLIN_DEPS = [
]

TEST_DEPS = [
"@maven//:ch_qos_logback_logback_classic",
"@maven//:ch_qos_logback_logback_core",
"//systems/application/application-administration-application:main",
"@maven//:org_springframework_spring_test",
"@maven//:org_springframework_boot_spring_boot_data_jpa_test",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,19 @@ package hs.kr.entrydsm.application.administration.adapterout.distance
import hs.kr.entrydsm.admin.domain.enum.ErrorCode
import hs.kr.entrydsm.admin.domain.exception.AdminDomainException
import hs.kr.entrydsm.admin.domain.port.out.DistancePort
import java.io.IOException
import java.net.URI
import java.net.URLEncoder
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.net.http.HttpTimeoutException
import java.nio.charset.StandardCharsets
import java.time.Duration
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import tools.jackson.core.JacksonException
import tools.jackson.databind.ObjectMapper

@org.springframework.boot.autoconfigure.condition.ConditionalOnProperty(name = ["application.administration.enabled"], havingValue = "true")
Expand All @@ -24,54 +28,96 @@ class KakaoDistanceAdapter(
@Value("\${admin.maps.school-address}") private val schoolAddress: String,
@Value("\${admin.maps.timeout-ms}") private val timeoutMs: Long,
) : DistancePort {
private val logger = LoggerFactory.getLogger(javaClass)
private val client = HttpClient.newBuilder().connectTimeout(Duration.ofMillis(timeoutMs)).build()
private val schoolCoordinates by lazy { geocode(schoolAddress) }
private val schoolCoordinates by lazy { geocode(schoolAddress, "SCHOOL_GEOCODING") }

override fun distanceFromSchool(address: String): Long =
try {
val origin = geocode(address)
// 외부 응답과 예외 메시지 대신 단계와 안전한 원인만 남긴다.
override fun distanceFromSchool(address: String): Long {
var stage = "APPLICANT_GEOCODING"
return try {
val origin = geocode(address, stage)
stage = "SCHOOL_GEOCODING"
val destination = schoolCoordinates
stage = "DIRECTIONS"
val response = send(
"$directionsUrl?origin=${origin.x},${origin.y}&destination=${destination.x},${destination.y}&summary=true",
stage,
)
parseDistance(response)
} catch (_: Exception) {
unavailable()
} catch (exception: AdminDomainException) {
throw exception
} catch (exception: Exception) {
val reason = when (exception) {
is JacksonException -> "JSON_PARSING"
is HttpTimeoutException -> "TIMEOUT"
is IOException -> "NETWORK"
else -> "UNEXPECTED"
}
unavailable(stage, reason, exceptionClass = exception.javaClass.name)
}
}

// 카카오의 경로 결과 코드와 거리 필드 실패를 구분한다.
fun parseDistance(body: String): Long {
val route = objectMapper.readTree(body).path("routes").path(0)
if (route.path("result_code").asInt(-1) != 0 || !route.path("summary").path("distance").canConvertToLong()) {
unavailable()
val resultCode = route.path("result_code").asInt(-1)
if (resultCode != 0) {
unavailable("DIRECTIONS", "ROUTE_RESULT", resultCode = resultCode)
}
if (!route.path("summary").path("distance").canConvertToLong()) {
unavailable("DIRECTIONS", "DISTANCE_MISSING")
}
return route.path("summary").path("distance").asLong()
}

fun parseCoordinates(body: String): Coordinates {
val document = objectMapper.readTree(body).path("documents").path(0)
val x = document.path("x").asText().toDoubleOrNull() ?: unavailable()
val y = document.path("y").asText().toDoubleOrNull() ?: unavailable()
// 기존 좌표 파싱 호출 인터페이스를 유지한다.
fun parseCoordinates(body: String): Coordinates = parseCoordinates(body, "APPLICANT_GEOCODING")

// 검색 결과 없음과 좌표 형식 오류를 구분한다.
private fun parseCoordinates(body: String, stage: String): Coordinates {
val documents = objectMapper.readTree(body).path("documents")
if (documents.isArray && documents.isEmpty) unavailable(stage, "EMPTY_SEARCH")
val document = documents.path(0)
val x = document.path("x").asText().toDoubleOrNull() ?: unavailable(stage, "COORDINATE_FORMAT")
val y = document.path("y").asText().toDoubleOrNull() ?: unavailable(stage, "COORDINATE_FORMAT")
return Coordinates(x, y)
}

private fun geocode(address: String): Coordinates =
parseCoordinates(send("$geocodingUrl?query=${encode(address)}"))
// 주소는 요청에만 사용하며 로그에 남기지 않는다.
private fun geocode(address: String, stage: String): Coordinates =
parseCoordinates(send("$geocodingUrl?query=${encode(address)}", stage), stage)

private fun send(url: String): String {
// HTTP 실패 시 본문과 URL을 제외한 상태 코드만 기록한다.
private fun send(url: String, stage: String): String {
val request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofMillis(timeoutMs))
.header("Authorization", "KakaoAK $apiKey")
.GET()
.build()
val response = client.send(request, HttpResponse.BodyHandlers.ofString())
if (response.statusCode() !in 200..299) unavailable()
if (response.statusCode() !in 200..299) unavailable(stage, "HTTP_FAILURE", httpStatus = response.statusCode())
return response.body()
}

// 기존 검색어 인코딩을 유지한다.
private fun encode(value: String) = URLEncoder.encode(value, StandardCharsets.UTF_8)

private fun unavailable(): Nothing = throw AdminDomainException(ErrorCode.DISTANCE_SERVICE_UNAVAILABLE)
// 허용한 진단 필드만 기록하고 기존 외부 오류 계약을 유지한다.
private fun unavailable(
stage: String,
reason: String,
httpStatus: Int? = null,
resultCode: Int? = null,
exceptionClass: String? = null,
): Nothing {
logger.error(
"Kakao distance failure [stage={}, reason={}, httpStatus={}, resultCode={}, exceptionClass={}]",
stage, reason, httpStatus, resultCode, exceptionClass,
)
throw AdminDomainException(ErrorCode.DISTANCE_SERVICE_UNAVAILABLE)
}

data class Coordinates(val x: Double, val y: Double)
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ import hs.kr.entrydsm.admin.domain.exception.AdminDomainException
import org.junit.Assert.*
import org.junit.Test
import tools.jackson.databind.ObjectMapper
import ch.qos.logback.classic.Logger
import ch.qos.logback.classic.spi.ILoggingEvent
import ch.qos.logback.core.read.ListAppender
import com.sun.net.httpserver.HttpServer
import hs.kr.entrydsm.admin.domain.enum.ErrorCode
import java.net.InetSocketAddress
import java.util.concurrent.atomic.AtomicInteger
import org.slf4j.LoggerFactory

class KakaoDistanceAdapterTest {
@Test
Expand All @@ -29,4 +37,162 @@ class KakaoDistanceAdapterTest {
assertFalse(exception?.message.orEmpty().contains("super-secret"))
}

@Test
fun successfulCallsReuseLazySchoolCoordinates() = withServer { base, requests ->
val adapter = adapter(base)
capture { logs ->
assertEquals(1234L, adapter.distanceFromSchool(ADDRESS))
assertEquals(1234L, adapter.distanceFromSchool(ADDRESS))
assertEquals(5, requests.get())
assertTrue(logs.list.isEmpty())
}
}

@Test
fun httpFailuresIdentifyEachStageWithoutSensitiveResponse() {
for ((index, stage) in STAGES.withIndex()) {
withServer(failedRequest = index, status = 401, body = PRIVATE_BODY) { base, _ ->
capture { logs ->
assertUnavailable { adapter(base).distanceFromSchool(ADDRESS) }
assertDiagnostic(logs, stage, "HTTP_FAILURE", "httpStatus=401")
}
}
}
}

@Test
fun emptySearchIdentifiesApplicantAndSchool() {
for ((index, stage) in STAGES.take(2).withIndex()) {
withServer(failedRequest = index, body = """{"documents":[],"private":"$PRIVATE_BODY"}""") { base, _ ->
capture { logs ->
assertUnavailable { adapter(base).distanceFromSchool(ADDRESS) }
assertDiagnostic(logs, stage, "EMPTY_SEARCH")
}
}
}
}

@Test
fun routeFailureRecordsOnlyNumericCode() = withServer(
failedRequest = 2,
body = """{"routes":[{"result_code":104,"result_msg":"$PRIVATE_BODY"}]}""",
) { base, _ ->
capture { logs ->
assertUnavailable { adapter(base).distanceFromSchool(ADDRESS) }
assertDiagnostic(logs, "DIRECTIONS", "ROUTE_RESULT", "resultCode=104")
}
}

@Test
fun malformedJsonRecordsExceptionClassWithoutBody() {
for ((index, stage) in STAGES.withIndex()) {
withServer(failedRequest = index, body = PRIVATE_BODY) { base, _ ->
capture { logs ->
assertUnavailable { adapter(base).distanceFromSchool(ADDRESS) }
assertDiagnostic(logs, stage, "JSON_PARSING", "exceptionClass=tools.jackson.")
}
}
}
}

@Test
fun timeoutRecordsStageAndExceptionClass() = withServer(delayMs = 500) { base, _ ->
capture { logs ->
assertUnavailable { adapter(base, timeoutMs = 100).distanceFromSchool(ADDRESS) }
assertDiagnostic(logs, "APPLICANT_GEOCODING", "TIMEOUT", "exceptionClass=java.net.http.Http")
}
}

@Test
fun invalidCoordinatesAndMissingDistanceHaveSeparateReasons() {
for ((index, body, reason) in listOf(
Triple(0, """{"documents":[{"x":"$PRIVATE_BODY","y":"36.3"}]}""", "COORDINATE_FORMAT"),
Triple(2, """{"routes":[{"result_code":0,"summary":{}}]}""", "DISTANCE_MISSING"),
)) {
withServer(failedRequest = index, body = body) { base, _ ->
capture { logs ->
assertUnavailable { adapter(base).distanceFromSchool(ADDRESS) }
assertDiagnostic(logs, STAGES[index], reason)
}
}
}
}

private fun adapter(base: String, timeoutMs: Long = 3000) =
KakaoDistanceAdapter(ObjectMapper(), KEY, "$base/geocode", "$base/directions", SCHOOL, timeoutMs)

private fun assertUnavailable(action: () -> Unit) {
val failure = runCatching(action).exceptionOrNull()
assertTrue("Expected AdminDomainException, got $failure", failure is AdminDomainException)
assertEquals(ErrorCode.DISTANCE_SERVICE_UNAVAILABLE, (failure as AdminDomainException).errorCode)
assertEquals(503, failure.errorCode.status)
assertNull(failure.cause)
}

private fun capture(action: (ListAppender<ILoggingEvent>) -> Unit) {
val logger = LoggerFactory.getLogger(KakaoDistanceAdapter::class.java) as Logger
val appender = ListAppender<ILoggingEvent>()
appender.start()
logger.addAppender(appender)
try {
action(appender)
} finally {
logger.detachAppender(appender)
appender.stop()
}
}

private fun assertDiagnostic(logs: ListAppender<ILoggingEvent>, stage: String, reason: String, detail: String? = null) {
assertEquals(1, logs.list.size)
val event = logs.list.single()
val message = event.formattedMessage
assertTrue(message, message.contains("stage=$stage"))
assertTrue(message, message.contains("reason=$reason"))
if (detail != null) assertTrue(message, message.contains(detail))
assertNull(event.throwableProxy)
for (secret in listOf(ADDRESS, SCHOOL, KEY, PRIVATE_BODY, "127.123456", "36.654321", "http://", "query=", "origin=")) {
assertFalse("Sensitive value leaked: $secret", message.contains(secret))
}
}

private fun withServer(
failedRequest: Int = -1,
status: Int = 200,
body: String = "",
delayMs: Long = 0,
action: (String, AtomicInteger) -> Unit,
) {
val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
val requests = AtomicInteger()
server.createContext("/") { exchange ->
val index = requests.getAndIncrement()
try {
if (delayMs > 0) Thread.sleep(delayMs)
val payload = if (index == failedRequest) body else if (exchange.requestURI.path == "/directions") {
"""{"routes":[{"result_code":0,"summary":{"distance":1234}}]}"""
} else {
"""{"documents":[{"x":"127.123456","y":"36.654321"}]}"""
}
val bytes = payload.toByteArray(Charsets.UTF_8)
exchange.sendResponseHeaders(if (index == failedRequest) status else 200, bytes.size.toLong())
exchange.responseBody.use { it.write(bytes) }
} finally {
exchange.close()
}
}
server.start()
try {
action("http://127.0.0.1:${server.address.port}", requests)
} finally {
server.stop(0)
}
}

companion object {
private const val ADDRESS = "지원자-비공개주소"
private const val SCHOOL = "학교-비공개주소"
private const val KEY = "private-api-key"
private const val PRIVATE_BODY = "private-response-and-exception-message"
private val STAGES = listOf("APPLICANT_GEOCODING", "SCHOOL_GEOCODING", "DIRECTIONS")
}
}
Loading