Sprint WebClient로 XML 응답을 처리해보자!

sinryuji·2024년 12월 19일
post-thumbnail

프로젝트를 진행 하던 중 공공데이터포털의 Open API를 호출 해야 하는 기능을 개발하게 되었는데 응답 데이터 형식이 Xml이었다. WebClient로 Json 형태의 응답은 처리하기에 비교적 간단하나 XML은 좀 더 수정이 필요하다.

의존성 추가

implementation("org.glassfish.jaxb:jaxb-runtime:4.0.4")
implementation("jakarta.xml.bind:jakarta.xml.bind-api:4.0.2")

XML 형태의 데이터를 DTO 클래스로 매핑해주기 위한 의존성들을 추가한다.

WebClient Bean 수정

import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.client.reactive.ReactorClientHttpConnector
import org.springframework.http.codec.xml.Jaxb2XmlDecoder
import org.springframework.web.reactive.function.client.ExchangeStrategies
import org.springframework.web.reactive.function.client.WebClient
import reactor.netty.http.client.HttpClient
import java.time.Duration


@Configuration
class WebClientConfig {
    @Bean
    fun webClient(): WebClient {
        val strategies =
            ExchangeStrategies.builder().codecs {
                it.defaultCodecs().maxInMemorySize(1024 * 1024)
                it.defaultCodecs().jaxb2Decoder(Jaxb2XmlDecoder())
            }.build()
        return WebClient.builder()
            .clientConnector(
                ReactorClientHttpConnector(
                    HttpClient.newConnection().responseTimeout(Duration.ofSeconds(30)),
                ),
            )
            .exchangeStrategies(strategies)
            .build()
}

이전 글과 비교하면 Jaxb2XmlDecoder를 기본 코덱으로 추가해주었다. 이 디코더를 통해 XML 형태로 오는 응답을 디코딩 할 수 있게 된다.

DTO 클래스 만들기

<response>
	<header>
		<resultCode>00</resultCode>
		<resultMsg>NORMAL SERVICE.</resultMsg>
	</header>
	<body>
		<items>
			<item>
				<actBeginTm>7</actBeginTm>
				<actEndTm>19</actEndTm>
				<actPlace>강북구 관내(타구활동 인정 불가)</actPlace>
				<adultPosblAt>Y</adultPosblAt>
				<gugunCd>3080000</gugunCd>
				<nanmmbyNm>서울특별시 강북구</nanmmbyNm>
				<noticeBgnde>20241001</noticeBgnde>
				<noticeEndde>20241227</noticeEndde>
				<progrmBgnde>20241001</progrmBgnde>
				<progrmEndde>20241231</progrmEndde>
				<progrmRegistNo>3195797</progrmRegistNo>
				<progrmSj>저탄소생활실천[플로깅]자원봉사 모집(※ 첨부파일 예시 꼭!!! 확인 후 활동)</progrmSj>
				<progrmSttusSe>2</progrmSttusSe>
				<sidoCd>6110000</sidoCd>
				<srvcClCode>환경보호 > 환경정화</srvcClCode>
				<url>https://1365.go.kr/vols/P9210/partcptn/timeCptn.do?type=show&progrmRegistNo=3195797</url>
				<yngbgsPosblAt>Y</yngbgsPosblAt>
			</item>
		</items>
		<numOfRows>1</numOfRows>
		<pageNo>1</pageNo>
		<totalCount>32</totalCount>
	</body>
</response>

내가 이용을 했던 API의 경우 위와 같은 형태로 응답이 왔다. 앞서 WebClient에 추가했던 Xml 디코더를 통해 이 응답을 매핑하기 위한 DTO 클래스들을 만들어주어야 한다.

이 때 Xml에서 하나의 태그마다 하나의 클래스로 만들어주어야 한다. 예를 들어 item 태그의 경우 actBeginTm과 같은 값들을 프로퍼티로 가지는 하나의 클래스로 만들고, items 태그의 경우 item에 해당하는 클래스를 프로퍼티로 가지는 식으로 만들어야 한다.

response, body, items

import jakarta.xml.bind.annotation.XmlAccessType
import jakarta.xml.bind.annotation.XmlAccessorType
import jakarta.xml.bind.annotation.XmlElement
import jakarta.xml.bind.annotation.XmlRootElement

@XmlRootElement(name = "response")
@XmlAccessorType(XmlAccessType.FIELD)
data class VolunteeringListApiResponse(
    @field:XmlElement(name = "body")
    val body: VolunteeringListApiResponseBody? = null,
)

@XmlAccessorType(XmlAccessType.FIELD)
data class VolunteeringListApiResponseBody(
    @field:XmlElement(name = "items")
    val items: VolunteeringListApiResponseItems? = null,
    @field:XmlElement(name = "totalCount")
    val totalCount: Int? = null,
)

@XmlAccessorType(XmlAccessType.FIELD)
data class VolunteeringListApiResponseItems(
    @field:XmlElement(name = "item")
    val item: List<VolunteeringListApiResponseItem>? = null,
)

각각 제일 최상위 태그인 response에 해당하는 클래스, 그 하위 태그인 body에 해당하는 클래스, 그 하위 태그인 itmes에 해당하는 클래스이다. VolunteeringListApiResponse의 경우에는 해당 클래스가 최상위 태그임을 지정하기 위해 @XmlRootElement 어노테이션을 써주고 하위 태그로 body 태그를 가지므로 그에 해당하는 DTO를 프로퍼티로 가진다. 나머지 클래스들도 각각 하위 태가들을 프로퍼티로 가지면 된다.

💡 프로퍼티를 Nullable로 선언하는 이유는?

이를 Nullable로 하지 않으면 앞서 추가했던 디코더에서 아무런 인자도 없는 기본 생성자가 없다는 에러를 띄운다. Java를 쓰고 있다면 LombokNoArgsConstructor를 쓰면 되겠지만 Kotlin의 data class의 경우에는 별도로 기본 생성자가 없다. 그렇기에 Nullable로 선언을 해주는 것이다!

item

import com.relogging.server.global.LocalDateAdapter
import jakarta.xml.bind.annotation.XmlAccessType
import jakarta.xml.bind.annotation.XmlAccessorType
import jakarta.xml.bind.annotation.XmlElement
import jakarta.xml.bind.annotation.adapters.XmlJavaTypeAdapter
import java.time.LocalDate

@XmlAccessorType(XmlAccessType.FIELD)
data class VolunteeringListApiResponseItem(
    // 봉사 등록 번호
    @field:XmlElement(name = "progrmRegistNo")
    val programRegistrationNumber: String? = null,
    // 봉사 제목
    @field:XmlElement(name = "progrmSj")
    val programSubject: String? = null,
    // 봉사 상태
    @field:XmlElement(name = "progrmSttusSe")
    val programStatus: String? = null,
    // 등록 기관
    @field:XmlElement(name = "nanmmbyNm")
    val registrationOrganization: String? = null,
    // 모집 시작일
    @field:XmlElement(name = "noticeBgnde")
    @field:XmlJavaTypeAdapter(LocalDateAdapter::class)
    val noticeBeginDate: LocalDate? = null,
    // 모집 종료일
    @field:XmlElement(name = "noticeEndde")
    @field:XmlJavaTypeAdapter(LocalDateAdapter::class)
    val noticeEndDate: LocalDate? = null,
    // 봉사 시작일
    @field:XmlElement(name = "progrmBgnde")
    @field:XmlJavaTypeAdapter(LocalDateAdapter::class)
    val programBeginDate: LocalDate? = null,
    // 봉사 종료일
    @field:XmlElement(name = "progrmEndde")
    @field:XmlJavaTypeAdapter(LocalDateAdapter::class)
    val programEndDate: LocalDate? = null,
    // 활동 시작 시간
    @field:XmlElement(name = "actBeginTm")
    val actBeginTime: Int? = null,
    // 활동 종료 시간
    @field:XmlElement(name = "actEndTm")
    val actEndTime: Int? = null,
    // 활동 장소
    @field:XmlElement(name = "actPlace")
    val actPlace: String? = null,
    // 성인 가능 여부(Y, N)
    @field:XmlElement(name = "adultPosblAt")
    val adultPossibleAt: String? = null,
    // 아동 청소년 가능 여부(Y, N)
    @field:XmlElement(name = "yngbgsPosblAt")
    val youngPossibleAt: String? = null,
    // 구, 군 코드
    @field:XmlElement(name = "gugunCd")
    val districtCode: String? = null,
    // 시, 도 코드
    @field:XmlElement(name = "sidoCd")
    val cityProvinceCode: String? = null,
    // 상위 - 하위 분야
    @field:XmlElement(name = "srvcClCode")
    val field: String? = null,
    // url
    @field:XmlElement(name = "url")
    val url: String? = null,
)

실질적으로 필요한 데이터들이 담기는 item 태그에 해당하는 클래스이다. Xml의 태그 이름과 프로퍼티의 이름을 동일하게 해줘도 되지만 @XmlElement 어노테이션에 name으로 태그의 이름을 지정해주면 프로퍼티의 이름은 좀 더 활용하기 좋은 이름으로 지정해 줄 수 있다. 그리고 데이터 타입들 역시 정수형으로 온다면 Int 등으로 맞춰준다. 다만 날짜 데이터를 LocalDateLocalTateTime으로 받고 싶다면 별도의 어댑터를 구현하여 @XmlJavaTypeAdapter를 통해 지정을 해주어야 한다. 다음과 같이 말이다.

import jakarta.xml.bind.annotation.adapters.XmlAdapter
import java.time.LocalDate
import java.time.format.DateTimeFormatter

class LocalDateAdapter : XmlAdapter<String, LocalDate>() {
    private val formatter = DateTimeFormatter.ofPattern("yyyyMMdd")

    override fun unmarshal(value: String?): LocalDate? {
        return value?.let { LocalDate.parse(it, formatter) }
    }

    override fun marshal(value: LocalDate?): String? {
        return value?.format(formatter)
    }
}

// 모집 시작일
@field:XmlElement(name = "noticeBgnde")
@field:XmlJavaTypeAdapter(LocalDateAdapter::class)
val noticeBeginDate: LocalDate? = null,

API 요청하기

return this.webClient.get()
    .uri { uriBuilder ->
        uriBuilder
            .scheme("http")
            .host(API_HOST)
            .path(LIST_API_PATH)
            .queryParam("serviceKey", apiKey)
            .queryParam("progrmBgnde", oneYearAgoStart)
            .queryParam("progrmEndde", oneYearLaterEnd)
            .queryParam("adultPosblAt", "Y")
            .queryParam("yngbgsPosblAt", "Y")
            .queryParam("numOfRows", "1000")
            .queryParam("pageNo", "1")
            .queryParam("keyword", keyword)
            .queryParam("schCateGu", "all")
            .queryParam("actBeginTm", "00")
            .queryParam("actEndTm", "24")
            .queryParam("noticeBgnde", oneYearAgoStart)
            .queryParam("noticeEndde", oneYearLaterEnd)
            .build()
    }
    .retrieve()
    .bodyToMono(VolunteeringListApiResponse::class.java)
    .flatMap { response ->
        if ((response.body?.totalCount ?: 0) > 0) {
            val items = response.body!!.items?.item ?: emptyList()

            saveFetchedPloggingEventList(items)
        } else {
            Mono.empty<Void>()
        }
    }
    .onErrorResume { error ->
        throw GlobalException(GlobalErrorCode.PLOGGING_EVENT_FETCH_ERROR)
    }

그럼 이제 위와 같이 API를 요청하여 응답 값을 DTO 클래스로 매핑하여 받을 수 있다! queryParam 부분은 API 마다 다르니 신경 쓰지 않아도 되고 bodyToMono() 부분만 보면 된다.

앞서 만들었던 최상위 태그인 response에 해당하는 클래스인 VolunteeringListApiResponse의 클래스 타입을 인자로 넘겨주는 것을 확인 할 수 있다. 그러면 API가 정상적으로 응답하였을 시 Mono<VolunteeringListApiResponse> 타입의 값을 리턴해주게 되므로 Xml 형식의 응답을 객체화 하여 활용을 할 수 있게 된다.

MonoflatMap 부분은 WebClient의 비동기 처리에 관련된 내용이고 이는 다음 글에서 자세히 다룰 예정이다! 만약에 비동기 처리가 필요하지 않다면 flatMap 대신 block()을 호출해서 동기 형식으로 API를 호출하면 된다. 그러면 Mono<VolunteeringListApiResponse> 타입이 아닌 VolunteeringListApiResponse 타입으로 바로 값을 응답 받을 수 있게 된다!

후기

아무래도 현재 웹에서 여러 데이터 형식 중 Json이 가장 보편적이다. 그렇기에 WebClient도 Json을 디폴트 형식으로 취급하며 Xml 형식의 데이터를 처리하기에는 빌드나 DTO 클래스 구현에서 조금 번거로운 면이 있다.

하지만 그렇다고 Xml이 무조건적으로 Json에 비해 오래되었고 열등한 데이터 형식은 아니다. Json이 Xml에 비해 높은 파싱 편의성을 제공하는 것은 맞지만 그만큼 간결하고 단순한 데이터들에 적합한 타입이지 Xml만큼 메타 데이터를 표현 할 수 있거나 다양한 데이터 형식을 지원하지 못한다.

그렇기에 단순히 우리나라 공공기관 API들이 너무 레거시하여 Xml 형식으로 응답을 준다라는 생각은 잘못된 생각인 것 같고(사실 Xml에 대해 자세히 알아보기 전에 본인이 가졌던 생각...), 각자 용도에 맞는 데이터 형식들이 있고 그 데이터 형식들의 특징을 이해하고 있으며 모두 잘 다룰 수 있어야 하는 것 같다!

profile
응애 개발자입니다.

0개의 댓글