
외부 시스템은 우리가 통제할 수 없는 영역입니다.
따라서 연동 로직의 핵심은 "상대방이 무엇을 보내든 우리 시스템은 무너지지 않아야 한다"는 방어적 태세에 있습니다.
개발 초기, 표준 가이드를 준수했음에도 불구하고 시스템 곳곳에서 예외가 터져 나왔습니다.
문제: /json/ 경로로 요청했으나, 서버 내부 오류나 특정 상황에서 Content-Type: application/xml로 에러 메시지나 데이터를 반환되었습니다.
결과: JSON 디코더로 고정된 WebClient가 즉시 UnsupportedMediaTypeException을 발생시키며 중단되었습니다.
문제: 검색 결과가 없을 때 items 필드를 빈 객체({})나 빈 배열([])이 아닌, 빈 문자열("")로 반환되는증상이보였습니다.
결과: Jackson 라이브러리가 "객체가 와야 할 자리에 문자열이 왔다"며 파싱을 거부하는 에러증상을 보였습니다.
문제: 필수적이라고 생각한 법정동 코드나 시군구 코드가 특정 행에서만 누락되었습니다.
결과: 프론트엔드의 지역 선택 필터가 깨지거나 상세 검색이 불가능해졌습니다.
이러한 불규칙성에 대응하기 위해 세 가지 철학을 수립했습니다.
실패하지 않고 흡수하기: 파싱 에러 하나 때문에 전체 배치 프로세스가 멈춰서는 안 됩니다. 부분적인 데이터 손실이 있더라도 전체 서비스는 가동되어야 합니다.
타입이 아닌 내용으로 판단하기: 문서에 적힌 "Type"을 맹신하지 않고, 실제 들어온 토큰의 형태에 따라 동적으로 대응합니다.
파싱은 관대하게, 검증은 엄격하게: 입력 단계에서는 최대한 많은 형태를 받아내고, DB 저장 직전 toDomain() 단계에서 비즈니스 규칙에 맞는 데이터만 필터링합니다.
bodyToMono(DTO::class.java)를 사용하면 미디어 타입 불일치 시 대처가 불가능합니다.
일단 원시 문자열로 받은 뒤, 내용물을 살피는 '선 분석 후 파싱' 전략을 취합니다.
private fun fetchSjwEnvelope(uri: String, params: Map<String, Any>): SjwEnvelope {
val response = client.get()
.uri { /* ... */ }
.retrieve()
.bodyToMono(String::class.java) // 1. 일단 String으로 받기
.block() ?: throw IllegalStateException("Empty Response")
// 2. 내용물 기반 타입 판별 (방어적 접근)
return if (response.trim().startsWith("<")) {
parseXml(response) // XML로 전환
} else {
objectMapper.readValue(response, SjwEnvelope::class.java) // JSON 파싱
}
}
Jackson의 유연함을 극대화하여 ""로 들어오는 응답을 빈 객체로 변환해주는 전용 처리기를 구현합니다.
class LenientFestivalItemsDeserializer : JsonDeserializer<FestivalItems>() {
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): FestivalItems {
return when (p.currentToken) {
JsonToken.VALUE_STRING -> {
// 문자열인데 비어있다면? 빈 목록을 가진 객체로 승화
if (p.valueAsString.isBlank()) FestivalItems(item = emptyList())
else ctxt.readValue(p, FestivalItems::class.java)
}
JsonToken.START_OBJECT -> ctxt.readValue(p, FestivalItems::class.java)
else -> FestivalItems(item = emptyList()) // NULL 등 기타 상황 대비
}
}
}
데이터가 비어있다면 가용한 다른 필드에서 정보를 유추하는 로직을 DTO 내부에 구현하여 데이터 품질을 보전합니다.
data class CodeItem(val code: String, val ldongCode2: String, val lDongRegnCd: String) {
fun toDomain(): Code {
// 우선순위에 따른 코드 결정 (ldongCode2 -> lDongRegnCd -> code)
val finalCode = ldongCode2.takeIf { it.isNotBlank() }
?: lDongRegnCd.takeIf { it.isNotBlank() }
?: code
return Code(code = finalCode, ...)
}
}
현재 IdolGlow의 코드베이스를 점검한 결과, XML 파싱 시 XXE 공격에 노출될 가능성이 확인되었습니다.
외부 API라 할지라도 악의적인 XML이 주입될 수 있으므로, DocumentBuilderFactory 설정 시 반드시 보안 옵션을 활성화해야 합니다.
private fun safeDocumentBuilder(): DocumentBuilder {
val factory = DocumentBuilderFactory.newInstance().apply {
// XXE 방어의 핵심 설정들
setFeature("http://apache.org/xml/features/disallow-doctype-decl", true)
setFeature("http://xml.org/sax/features/external-general-entities", false)
setFeature("http://xml.org/sax/features/external-parameter-entities", false)
isXIncludeAware = false
isExpandEntityReferences = false
}
return factory.newDocumentBuilder()
}
Tip: 이 로직을 공통 유틸리티로 분리하여
SeoulSjwPerformApiClient,CultureInfoApiClient등 모든 XML 파서에 적용하는 것을 권장합니다.
방어적 설계의 완성은 "언제, 왜 실패했는가"를 기록하는 것입니다.
지표화(Metrics): 파싱 성공/실패율을 카운터로 기록하여 대시보드를 구성하게되었습니다.
특정 API의 실패율이 5%를 넘어가면 알림을 받도록 설정합니다.
맥락 있는 로깅: 단순히 warn을 남기는 것이 아니라, 문제가 된 cultCode나 응답의 길이를 함께 남겨야 추후 제공처에 "이런 케이스에서 에러가 납니다"라고 구체적인 피드백을 보낼 수 있습니다.
외부 API 통합은 기술적인 구현보다 예외 상황에 대한 철학이 더 중요하다는것을 배웠습니다.
"설마 그러겠어?"라는 낙관을 버리고, "반드시 그럴 것이다"라는 의심으로 작성된 코드가 서비스의 안정성을 만드는것을 한번더 생각하게되었습니다.
오늘 소개한 패턴들을 통해 공공 API의 불규칙성 앞에서도 흔들리지 않는 견고한 백엔드를 구축해 보는시간을 갖게된것을 기록하게되었는데
나름의미가있는 시간이였습니다.