[Spring] Kotlin + Retrofit으로 SOAP 통신하기
SOAP(Simple Object Access Protocol)로 통신하는 방식은 최근에는 찾아보기 힘들다.
그래서 그런지 외부와 통신해야하는 업체가 SOAP로 통신을 주고 받아야하는 조건과 우리 서비스에서 사용하는 http 클라이언트 라이브러리인 Retrofit을 사용해야 하는 조건을 모두 만족시켜주는 시원한 해결방법이 없었다.
여기저기의 정보를 조합해서 통신하는데 성공했는데 그 방법을 정리하려 한다.
Retrofit에서 XML 통신을 위해 JAXB converter 라이브러리를 제공한다.
Retrofit으로 XML 통신을 하는 방법은 이 글을 참고하면 된다.
하지만, SOAP 통신을 할 때 namespace에 prefix를 부여하기도 할텐데 Retrofit에서 제공하는 JAXB converter 라이브러리를 사용하면 marshalling 하는 방식에 커스텀할 수 없다. (커스텀할 것이 없다면 그대로 써도 된다)
없으면 만들어야지.... 기존의 라이브러리 코드를 그대로 옮기고 jaxb에서 requestBody에 대한 marshalling을 수행하는 marshaller를 커스텀했다.
responseBody를 unmarshalling 하는 부분도 커스텀했다.
1. 예시 요청 데이터 클래스 생성
아래 XML 데이터를 예시로 진행 해보겠다.
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
<soapenv:Body>
<companySearchRQ xmlns="http://zorba-company.co.kr/">
<name>BBQ chicken</name>
<category>food</category>
</companySearchRQ>
</soapenv:Body>
</soapenv:Envelope>
위의 데이터를 보면 soapenv라는 prefix가 붙은 namespace를 포함하도록 해야한다.
java를 사용할 경우, @XmlSchema 애너테이션으로 쉽게 prefix를 붙일 수 있지만, 해당 애너테이션은 package annotation이라 kotlin에서 적용하기에는 손이 좀 간다.
kotlin에서는 package annotation이 없어서 package-info.java를 만들어 애너테이션을 넣어줘야하는데, 지금 서비스는 kotlin 코드만 있는 서비스인데, 이걸 위해 다른 방법이 있을 것 같은데 java 코드를 넣기 싫었고, 그것 말고도 잘못 했던건지 아무리해도 생각한대로 동작하지 않았다. (해결한 부분은 3번에서 설명)
먼저, SOAP의 envelope가 될 클래스를 만든다.
SoapRequestEnvelope.kt
import java.io.Serializable
import javax.xml.bind.annotation.XmlElement
import javax.xml.bind.annotation.XmlRootElement
@XmlRootElement(name = "Envelope", namespace = "http://schemas.xmlsoap.org/soap/envelope/")
data class SoapRequestEnvelope(
@field:XmlElement(name = "soapenv:Body")
val soapRequestBody: SoapRequestBody
) : Serializable {
private constructor() : this(Any() as SoapRequestBody)
}
soapenv라는 prefix가 붙기 때문에 rootElement의 name을 Envelope만 주었다. (prefix가 붙으면서 soapenv:Envelope로 완성될 것이다)
SoapRequestBody.kt
import java.io.Serializable
import javax.xml.bind.annotation.XmlElement
data class SoapRequestBody(
@field:XmlElement(name = "companySearchRQ", namespace = "http://zorba-company.co.kr/")
val companySearchRQ: CompanySearchRQ
) : Serializable {
private constructor() : this(Any() as CompanySearchRQ)
}
namespace가 붙을 엘리먼트가 될 필드 또는 루트가 될 클래스에 위와 같이 선언해준다.
CompanySearchRQ.kt
import java.io.Serializable
import javax.xml.bind.annotation.XmlAccessType
import javax.xml.bind.annotation.XmlAccessorType
@XmlAccessorType(XmlAccessType.FIELD)
data class CompanySearchRQ(
val name: String?,
val category: String?
) : Serializable {
private constructor() : this(null, null)
}
2. SOAP Converter를 만들어주는 Factory 클래스 생성
JAXB converter 라이브러리의 JaxbConverterFactory 클래스와 코드가 동일하다.
SoapConverterFactroy.kt
import okhttp3.MediaType
import okhttp3.RequestBody
import okhttp3.ResponseBody
import retrofit2.Converter
import retrofit2.Retrofit
import java.lang.reflect.Type
import javax.xml.bind.JAXBContext
import javax.xml.bind.JAXBException
import javax.xml.bind.annotation.XmlRootElement
class SoapConverterFactory(
val context: JAXBContext?
) : Converter.Factory() {
companion object {
val XML = MediaType.get("application/xml; charset=utf-8")
fun create(): SoapConverterFactory = SoapConverterFactory(null)
fun create(context: JAXBContext?): SoapConverterFactory =
context?.run { SoapConverterFactory(this) } ?: error("context must not be null")
}
// requestBody를 marshalling할 Converter 객체를 반환한다.
override fun requestBodyConverter(
type: Type,
parameterAnnotations: Array<out Annotation>,
methodAnnotations: Array<out Annotation>,
retrofit: Retrofit
): Converter<*, RequestBody>? {
// 타입이 클래스인지, 해당 클래스에 @XmlRootElement 애너테이션이 달려있는지 확인한다
return if (type is Class<*> && type.isAnnotationPresent(XmlRootElement::class.java)) {
SoapRequestConverter(contextForType(type), type)
} else null
}
// responseBody를 unmarshalling할 Converter 객체를 반환한다.
override fun responseBodyConverter(
type: Type,
annotations: Array<out Annotation>,
retrofit: Retrofit
): Converter<ResponseBody, *>? {
return if (type is Class<*> && type.isAnnotationPresent(XmlRootElement::class.java)) {
SoapResponseConverter(contextForType(type), type)
} else null
}
private fun contextForType(type: Class<*>): JAXBContext {
return try {
context ?: JAXBContext.newInstance(type)
} catch (e: JAXBException) {
throw IllegalArgumentException(e)
}
}
}
위의 factory 클래스는 marshalling, unmarshalling할 converter 객체를 반환해주는 역할을 수행한다.
3. NamespacePrefixMapper 사용해서 ReqeustBody에 Namespace prefix 적용하기
찾아낸 방법이 jaxb의 NamespacePrefixMapper를 이용하는 방법이다.
RequestNamespacePrefixMapper.kt
import com.sun.xml.bind.marshaller.NamespacePrefixMapper
class RequestNamespacePrefixMapper : NamespacePrefixMapper() {
companion object {
private const val SOAP_ENVELOPE_PREFIX = "soapenv"
private const val SOAP_ENVELOPE_URI = "http://schemas.xmlsoap.org/soap/envelope/"
}
override fun getPreferredPrefix(namespaceUri: String?, suggestion: String?, requirePrefix: Boolean): String? {
return when (namespaceUri) {
// namespace uri가 soap envelope에 붙어야할 namespace uri와 같으면 soapenv를 prefix로 붙는다
SOAP_ENVELOPE_URI -> SOAP_ENVELOPE_PREFIX
else -> suggestion
}
}
// root element에 선언되어야할 namespace uri들을 반환한다.
override fun getPreDeclaredNamespaceUris(): Array<String> {
// soap envelope에 붙어야할 namespace uri를 추가
return arrayOf(SOAP_ENVELOPE_URI)
}
}
NamespacePrefixMapper를 상속받아서 prefix를 커스텀해준다.
4. SoapRequestConverter 생성 및 커스텀한 NamespacePrefixMapper 적용하기
SoapConverterFactory에서 반환해줄 SoapRequestConverter를 생성해준다.
Retrofit의 Jaxb 라이브러리에 있는 JaxbRequestConverter와 코드가 동일하며, 위에서 만든 RequestNamespaceMapper를 설정하는 부분만 추가한 것이다.
SoapRequestConverter.kt
import okhttp3.RequestBody
import okio.Buffer
import retrofit2.Converter
import javax.xml.bind.JAXBContext
import javax.xml.bind.JAXBException
import javax.xml.stream.XMLOutputFactory
import javax.xml.stream.XMLStreamException
class SoapRequestConverter<T>(
val context: JAXBContext,
val type: Class<T>
) : Converter<T, RequestBody> {
private val xmlOutputFactory: XMLOutputFactory = XMLOutputFactory.newInstance()
override fun convert(value: T): RequestBody? {
val buffer = Buffer()
try {
val marshaller = context.createMarshaller()
// 커스텀한 namespacePrefixMapper를 사용하도록 세팅한다.
marshaller.setProperty("com.sun.xml.bind.namespacePrefixMapper", RequestNamespaceMapper())
val xmlWriter = xmlOutputFactory.createXMLStreamWriter(
buffer.outputStream(), SoapConverterFactory.XML.charset()!!.name()
)
marshaller.marshal(value, xmlWriter)
} catch (e: JAXBException) {
throw RuntimeException(e)
} catch (e: XMLStreamException) {
throw RuntimeException(e)
}
return RequestBody.create(SoapConverterFactory.XML, buffer.readByteString())
}
}
5. marshalling 테스트 해보기
SoapRequestConverter의 세팅과 동일하게 만들고 String으로 결과값을 보일 수 있도록 테스트 코드를 작성했다.
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:ns2="http://zorba-company.co.kr/">
<soapenv:Body>
<ns2:companySearchRQ>
<name>BBQ chicken</name>
<category>food</category>
</ns2:companySearchRQ>
</soapenv:Body>
</soapenv:Envelope>
결과
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:ns2="http://zorba-company.co.kr/">
<soapenv:Body>
<ns2:companySearchRQ>
<name>BBQ chicken</name>
<category>food</category>
</ns2:companySearchRQ>
</soapenv:Body>
</soapenv:Envelope>
companySearchRQ의 namespace가 Envelope으로 위치가 옮겨간 것 이외에는 의도했던대로 결과가 나온다.
n2라는 prefix가 companySearchRQ의 namespace prefix라는 걸 나타내주고 있다.
6. 예시 응답 데이터 생성
요청에 대한 응답을 받았을 때 아래와 같은 데이터라고 가정한다.
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
<soapenv:Body>
<companySearchRS xmlns="http://zorba-company.co.kr/">
<koreanName>BBQ 치킨</koreanName>
<englishName>BBQ chicken</englishName>
<category>food</category>
<totalStore>1800</totalStore>
<foundingYear>1995</foundingYear>
</companySearchRS>
</soapenv:Body>
</soapenv:Envelope>
실제로 사용될 정보에서 Envelope, Body 는 필요없기 때문에 companySearchRS만 생성한다.
(7번에서 Envelope, Body는 무시하도록 하는 방법 소개)
CompanySearchRS.kt
import java.io.Serializable
import javax.xml.bind.annotation.XmlElement
import javax.xml.bind.annotation.XmlRootElement
import javax.xml.bind.annotation.XmlAccessType
import javax.xml.bind.annotation.XmlAccessorType
@XmlRootElement(name = "companySearchRS")
@XmlAccessorType(XmlAccessType.FIELD)
data class CompanySearchRS(
val koreanName: String,
val englishName: String,
val category: String,
val totalStore: Int,
val foundingYear: Int
) : Serializable {
private constructor() : this("", "", "", 0, 0)
}
7. SoapResponseConverter 생성
ResponseBody를 unmarshalling할 SoapResponseConverter를 생성한다.
Retrofit의 Jaxb 라이브러리에 있는 JaxbResponseConverter와 코드가 동일하지만, Envelope와 Body 태그는 사용할 일이 없기 때문에 스킵한다.
SoapResponseConverter.kt
import okhttp3.ResponseBody
import retrofit2.Converter
import javax.xml.bind.JAXBContext
import javax.xml.bind.JAXBException
import javax.xml.stream.XMLInputFactory
import javax.xml.stream.XMLStreamException
class SoapResponseConverter<T>(
val context: JAXBContext,
val type: Class<T>
) : Converter<ResponseBody, T> {
private val xmlInputFactory = XMLInputFactory.newInstance()
init {
// Prevent XML External Entity attacks (XXE).
xmlInputFactory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false)
xmlInputFactory.setProperty(XMLInputFactory.SUPPORT_DTD, false)
}
override fun convert(value: ResponseBody): T? {
return try {
val unmarshaller = context.createUnmarshaller()
val streamReader =
xmlInputFactory.createXMLStreamReader(value.charStream())
// nextTag() : 다음 element를 발견할 때까지 스킵한다
streamReader.nextTag() // soap:Envelope
streamReader.nextTag() // soap:Body
streamReader.nextTag() // 사용할 바디가 들어있는 stream
// 8번에서 설명 : 엘리먼트들의 namespace를 제거해줄 역할 수행
val streamReaderDelegate = CustomStreamReaderDelegate(streamReader)
unmarshaller.unmarshal(streamReaderDelegate, type).value
} catch (e: JAXBException) {
throw RuntimeException(e)
} catch (e: XMLStreamException) {
throw RuntimeException(e)
} finally {
value.close()
}
}
}
8. 엘리먼트들의 namespace 제거를 위해 CustomStreamReaderDelegate 생성
namespace uri가 적용된 엘리먼트의 하위 엘리먼트들은 동일한 namespace uri가 적용되어야한다.
그러려면 아래처럼 필드마다 namespace uri를 선언해야한다.
import java.io.Serializable
import javax.xml.bind.annotation.XmlElement
import javax.xml.bind.annotation.XmlRootElement
@XmlRootElement(name = "companySearchRS", namespace = "http://zorba-company.co.kr/")
data class CompanySearchRS(
@field:XmlElement(name = "koreanName", namespace = "http://zorba-company.co.kr/")
val koreanName: String,
@field:XmlElement(name = "englishName", namespace = "http://zorba-company.co.kr/")
val englishName: String,
@field:XmlElement(name = "category", namespace = "http://zorba-company.co.kr/")
val category: String,
@field:XmlElement(name = "totalStore", namespace = "http://zorba-company.co.kr/")
val totalStore: Int,
@field:XmlElement(name = "foundingYear", namespace = "http://zorba-company.co.kr/")
val foundingYear: Int
) : Serializable {
private constructor() : this("", "", "", 0, 0)
}
받은 response를 객체로 변환하고 값을 사용하는데 namespace uri는 의미 없기도하고, 코드가 지저분해져서 namespace uri를 일일이 선언하지 않아도 되는 방법을 찾아봤다.
StreamReaderDelegate를 상속받아 stream을 읽을 때 namespace uri를 호출할 때 빈 값으로 만들어 namespace uri와 상관없이 element 이름만으로 매핑될 수 있도록 했다.
아래와 같은 커스텀한 StreamReaderDelegate를 만든다.
CustomStreamReaderDelegate.kt
import javax.xml.stream.XMLStreamReader
import javax.xml.stream.util.StreamReaderDelegate
class CustomStreamReaderDelegate(
xmlStreamReader: XMLStreamReader
): StreamReaderDelegate(xmlStreamReader) {
override fun getNamespaceURI(): String {
return ""
}
}
9. unmarshalling 테스트 해보기
Soap를 통해 받은 Response를 원하는 정보인 CompanySearchRS를 잘 가공해내는지 확인해보는 테스트코드를 작성했다.
@Test
fun `show result after unmarshalling`() {
val xmlInputFactory = XMLInputFactory.newInstance()
xmlInputFactory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false)
xmlInputFactory.setProperty(XMLInputFactory.SUPPORT_DTD, false)
val value = "<soapenv:Envelope xmlns:soapenv='http://schemas.xmlsoap.org/soap/envelope/'><soapenv:Body><companySearchRS xmlns='http://zorba-company.co.kr/'><koreanName>BBQ 치킨</koreanName><englishName>BBQ chicken</englishName><category>food</category><totalStore>1800</totalStore><foundingYear>1995</foundingYear></companySearchRS></soapenv:Body></soapenv:Envelope>"
.toInputStream(Charset.defaultCharset())
val unmarshaller = JAXBContext.newInstance(CompanySearchRS::class.java).createUnmarshaller()
val streamReader =
xmlInputFactory.createXMLStreamReader(value)
streamReader.nextTag() // soap:Envelope
streamReader.nextTag() // soap:Body
streamReader.nextTag() // 실제 바디가 들어있는 stream
val streamReaderDelegate = StreamReaderDelegate(streamReader)
val result = unmarshaller.unmarshal(streamReaderDelegate, CompanySearchRS::class.java).value
println(result)
}
결과
CompanySearchRS(koreanName=BBQ 치킨, englishName=BBQ chicken, category=food, totalStore=1800, foundingYear=1995)
출처:
www.intertech.com/jaxb-tutorial-customized-namespace-prefixes-example-using-namespaceprefixmapper/
docs.oracle.com/javase/8/docs/api/javax/xml/stream/XMLStreamReader.html
stackoverflow.com/questions/277502/jaxb-how-to-ignore-namespace-during-unmarshalling-xml-document