골치아픈 공공데이터포털 serviceKey 인코딩 문제

작성: 2023.05.28

수정: 2023.05.28

읽는시간: 00 분

Development/Daily Error

반응형

Intro

처음 겪는 일은 아니지만 공공 데이터 포털 API를 쓸 때마다 고생을 해서 이번에 글로 정리해두려고 한다.

공공 데이터포털은 각 공공기관이 보유하고 있는 여러가지 다양한 공공 데이터들을 하나의 통합 창구에서 편리하게 사용할 수 있도록 만들어진, 정부에서 운영하는 서비스다. 제공하는 정보가 정말 다양하고 여러가지 유용한 정보가 있으며 무료로 사용할 수 있기 때문에 개발자라면 한번쯤은 사용 해 보았을 것이다.

아래 보이는 것 처럼, 공공 데이터 포털에서는 개인 API 인증키를 Encode / Decode 된 두가지 버전으로 제공한다. 여기까지는 좋다. 사용자가 편의에 따라서 URI 인코딩 하지 않고 인코딩 된 키를 바로 복사해서 써도 되고, 그게 아니면 Decode 된 키를 가지고 각자 알아서 요청을 보내면 되기 때문이다.

1

그런데, 이 서비스를 이용하는데 있어서 정말 골치아픈 문제가 있으니 serviceKey 인코딩이다.

serviceKey에 온갖 잡다한 캐릭터가 다 들어간다는 것이 참 고생하게 만든다.. 도대체 왜 디코딩된 서비스 키에 입이 떡 벌어지는 특수문자들이 들어간지 모르겠으나, 이게 URI 인코딩 과정에서 말썽을 많이 일으킨다. 보안을 위해서? URL에 쓰면 안되는 문자 들어간다고 보안이 얼마나 좋아지겠는가.

지금부터 이어지는 글은, 스프링 어플리케이션에서 WebClient를 사용해 공휴일 정보를 공공 API에서 받아오다 겪은 서비스 키 인코딩 문제를 해결하는 과정이다. 진짜 별것도 아닌 것 같은데 몇시간을 그냥 태우기 딱 좋은 골치 아픈 문제기 때문에 스스로와 다른 누군가의 시간 절약을 위해 작성한다.

문제

WebClient

평범하게 웹 클라이언트를 사용 해서 원하는 요청을 날려본다. 기대하는건 result로 응답 결과가 저장되는 것이다.

result를 파싱해서 의도한 요청이 온지 검증하는 테스트를 추가로 작성 해 두었다. 일단 인코딩 하지 않은 서비스 키를 그냥 queryParam으로 넣어서 테스트 한다. 왜냐면 uriBuilder가 자동으로 인코딩을 해 주는걸 기대하기 때문이다.

  val client = WebClient.create("https://apis.data.go.kr")
  val decodeServiceKey = "DECODE_KEY_HERE"

  val result = client.get()
      .uri { uriBuilder ->
          uriBuilder.path("B090041/openapi/service/SpcdeInfoService/getHoliDeInfo")
              .queryParam("ServiceKey", decodeServiceKey)
              .queryParam("solYear", year)
              .queryParam("numOfRows", 100)
              .build()
      }
      .retrieve()
      .bodyToMono(String::class.java)
      .block()

image-20230528150203201

성공한다.

그런데 같은 요청을 또 보내본다.

image-20230528150147285

실패한다.

테스트가 성공하려면 성공하고 실패하려면 실패해야지, 어쩔 땐 성공하고 어쩔 땐 실패한다. 심지어 응답 시간도 어마어마하다.

매번 스프링이 요청을 다르게 보내나 싶어서 디버거로 보내는 요청을 잡아 보니 보내는 요청은 일관성이 있다. 그런데 공공 데이터 포털에서 똑같은 요청을 어쩔때는 올바르게 응답 해 주고, 어쩔때는 키가 올바르지 않다면서 거절 한다.

스프링은 무죄 데이터포털 유죄라고 생각할 수 있는데, 양측 다 할 말이 있으니 변론을 못듣는 상황에서 판단은 보류하겠다.

요청에 보내지는 인코딩 된 ServiceKey 를 확인 해 보니, 공공 데이터포털에서 인코딩 해준 키와 다르다. 그럼에도 대강 반반 확률로 받아준다.

미리 인코딩

...
  .queryParam("ServiceKey", URLEncoder.encode(decodeServiceKey, "UTF-8"))
...

그래서 이번에는 서비스 키를 미리 인코딩 해 보았다. 인코딩 된 텍스트를 그대로 넣어도 된다.

URLEncoder.encode를 이용해서 인코딩 키를 대조 해 보면 공공 데이터포털에서 제공하는 인코딩된 키와 일치하는 키를 만들어준다.

    @Test
    fun `uriEncoderTest`() {
        val decodeServiceKey ="디코딩 키"
        val encode = URLEncoder.encode(decodeServiceKey, "UTF-8")

        Assertions.assertThat(encode).isEqualTo("인코딩 키")
    }

그런데, 이렇게 했을 때도 처음 decodeKey를 넣었을 때와 똑같은 요청을 만들어낸다.

원인

퍼센트 인코딩이라고 알려진 이 문제는 webClient가 URL 인코딩을 하는 과정에서 serviceKey가 두번 인코딩 되었기 때문에 발생한 문제다.

슬래시("/")는 %F2로, 등호("=")는 %3D로 인코딩 된다. 또, 퍼센트("%")는 %25가 된다. 이제 예를 들어보자.

/가 처음에 %2F로 인코딩 되었다. 그 상황에서 다시 한번 %2F를 인코딩 하면서 %252F가 된다. 이중 인코딩이 된 것이다.

WebClient의 .uri()는 주어진 모든 매개변수를 URL 인코딩 하는데, 이미 인코딩 된 문자열이 전달되기 때문에 이중 인코딩이 발생하고 있다.

해결

URLEncoder.encode 를 이용해 직접 인코딩 한 뒤에, UriComponentBuilder로 uri를 직접 만들어준다.

그러고 나서 build 할 때, 파라미터로 true를 주는데, 이건 이미 인코딩 된 상태기 때문에 다시 인코딩 할 필요가 없다고 알려주는 것 이다.

val client = WebClient.create()
val decodeServiceKey ="디코딩 키"
val encodeServiceKey = URLEncoder.encode(decodeServiceKey, "UTF-8")

val uri = UriComponentsBuilder.fromUriString("https://apis.data.go.kr/B090041/openapi/service/SpcdeInfoService/getHoliDeInfo")
    .queryParam("serviceKey", encodeServiceKey)
    .queryParam("solYear", year)
    .queryParam("numOfRows", 100)
    .build(true)
    .toUri()

val result = client.get()
    .uri(uri)
  .retrieve()
  .bodyToMono(String::class.java)
  .block()

이렇게 했을 때 계속해서 안정적으로 테스트에 성공 했고, 응답 속도도 균일했다.

image-20230528155918527

문제는 해결했지만 굳이 인코딩 해가며 불편하게 사용할 캐릭터가 키값에 들어가서 고생하게 만든건 아쉬움이 남는다.

HTTP interface

이번에는 WebClient로 작성한 코드를 HTTP Interface로 리팩터링 해본다.

Feign 클라이언트를 처음 봤을때 깔끔하게 작성되는 코드에 감탄했었는데, 스프링6에서도 어노테이션 및 인터페이스로 선언적 HTTP를 작성할 수 있는 HTTP interface가 추가되었다.

먼저 인터페이스를 정의 해 주고

${code:DataGoKrApi.kt}

interface DataGoKrApi {

    @GetExchange("/B090041/openapi/service/SpcdeInfoService/getHoliDeInfo?serviceKey={serviceKey}&solYear={solYear}&numOfRows=100")
    fun getHolidays(
        @PathVariable("serviceKey") serviceKey: String,
        @PathVariable("solYear") year: Int,
    ): String

}

Bean으로 등록한다.

${code:DataGoKrConfig.kt}

@Configuration
class DataGoKrConfig {

    @Bean
    fun dataGoApi(): DataGoKrApi {
        val client = WebClient.create("https://apis.data.go.kr")

        return HttpServiceProxyFactory
            .builder(WebClientAdapter.forClient(client))
            .build()
            .createClient(DataGoKrApi::class.java)
    }

}

그리고 이제 필요한곳에서 사용하면 된다.

@Service
class HolidayAPIDataGoKr(
    private val dataGoKrApi: DataGoKrApi,
) : HolidayAPI {

    @Value("\${dutypark.data-go-kr.service-key}")
    private lateinit var serviceKey: String

    override fun requestHolidays(year: Int): List<HolidayDto> {
        val result = dataGoKrApi.getHolidays(serviceKey = serviceKey, year = year)
        return parse(result)
    }
  ...
}

HTTP interface를 쓸 때는 위와 같이 코드를 작성 하면 인코딩 하지 않은 서비스키를 넣었을 때 알아서 인코딩 해서 문제 없이 요청이 되었다.

문제도 없고 코드도 깔끔하게 작성되니 HTTP interface를 사용하는 편이 좋겠다.

반응형