diff --git a/systems/application/application-administration-adapter-out/BUILD.bazel b/systems/application/application-administration-adapter-out/BUILD.bazel index dd8554b9..cb35f9ec 100644 --- a/systems/application/application-administration-adapter-out/BUILD.bazel +++ b/systems/application/application-administration-adapter-out/BUILD.bazel @@ -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"], diff --git a/systems/application/application-administration-adapter-out/deps.bzl b/systems/application/application-administration-adapter-out/deps.bzl index 044f18b9..bbea9913 100644 --- a/systems/application/application-administration-adapter-out/deps.bzl +++ b/systems/application/application-administration-adapter-out/deps.bzl @@ -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", diff --git a/systems/application/application-administration-adapter-out/src/main/kotlin/hs/kr/entrydsm/application/administration/adapterout/distance/KakaoDistanceAdapter.kt b/systems/application/application-administration-adapter-out/src/main/kotlin/hs/kr/entrydsm/application/administration/adapterout/distance/KakaoDistanceAdapter.kt index 57e33e46..217a8e96 100644 --- a/systems/application/application-administration-adapter-out/src/main/kotlin/hs/kr/entrydsm/application/administration/adapterout/distance/KakaoDistanceAdapter.kt +++ b/systems/application/application-administration-adapter-out/src/main/kotlin/hs/kr/entrydsm/application/administration/adapterout/distance/KakaoDistanceAdapter.kt @@ -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") @@ -24,40 +28,68 @@ 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)) @@ -65,13 +97,27 @@ class KakaoDistanceAdapter( .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) } diff --git a/systems/application/application-administration-adapter-out/src/test/kotlin/hs/kr/entrydsm/application/administration/adapterout/distance/KakaoDistanceAdapterTest.kt b/systems/application/application-administration-adapter-out/src/test/kotlin/hs/kr/entrydsm/application/administration/adapterout/distance/KakaoDistanceAdapterTest.kt index 0bc482d9..2b8501bc 100644 --- a/systems/application/application-administration-adapter-out/src/test/kotlin/hs/kr/entrydsm/application/administration/adapterout/distance/KakaoDistanceAdapterTest.kt +++ b/systems/application/application-administration-adapter-out/src/test/kotlin/hs/kr/entrydsm/application/administration/adapterout/distance/KakaoDistanceAdapterTest.kt @@ -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 @@ -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) -> Unit) { + val logger = LoggerFactory.getLogger(KakaoDistanceAdapter::class.java) as Logger + val appender = ListAppender() + appender.start() + logger.addAppender(appender) + try { + action(appender) + } finally { + logger.detachAppender(appender) + appender.stop() + } + } + + private fun assertDiagnostic(logs: ListAppender, 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") + } }