XML Unmarshalling, Xstream 어렵지 않게 사용하기

2022. 6. 12. 23:45Spring

Spring에서 XStream을 통해 XML unmarshaller 등록하는 방법에 대한 포스팅입니다.

 

 

안녕하세요. 오늘은 XStream을 통해 XML unmarshaller를 생성하는 방법에 대해 다뤄보겠습니다.

사실 Spring Batch를 학습하고 있는데 XML Parsing 하다가 버전업 문제로 책에 나와있는 내용이 하나도 실행이 안돼서 ,,,

고생 좀 하다가 그냥 새로 만들어 버렸습니다,,,

덕분에 주말 동안 Spring Batch를 마스터하자는 호기로운 계획이 사르륵 녹아버렸네요 ^~^

 

 

배경

jaxb 제거 👉🏻 Xstream 사용

 

처음 문제의 시작은... 책에서 나온 XML unmarshaller는 com.sun.xml.~ 의 jaxb 를 사용한 것이었습니다.

java.xml.bind를 사용하는데 java 11부터 jakarta.xml.bind 로 이전되었다고 하더라구요.

 

그래서 공식 문서를 확인해서 의존성을 수정했습니다.

 

  <dependency>
      <groupId>jakarta.xml.bind</groupId>
      <artifactId>jakarta.xml.bind-api</artifactId>
      <version>4.0.0</version>
  </dependency>

  <dependency>
      <groupId>com.sun.xml.bind</groupId>
      <artifactId>jaxb-impl</artifactId>
      <version>4.0.0</version>
      <scope>runtime</scope>
  </dependency>

 

그러나,,, 아래와 같은 예외가 떴습니다.

 

javax.xml.bind.JAXBException: Implementation of JAXB-API has not been found on module path or classpath.

 

그래서 검색 결과 glassfish의 jaxb:jaxb-runtime를 사용했는데 이번에는 아래와 같은 오류가 발견됩니다.

 

Caused by: java.lang.ClassNotFoundException: javax.xml.bind.JAXBException
// and
java.lang.NoClassDefFoundError: javax/xml/bind/JAXBException

 

그래서 javax.xml.bind 패키지의 jaxb-api 를 추가하라는데 이 마저도 저한테는 안되더라구요 🥲

혹시 해결하신 분 있으면 댓글 부탁드립니다 🤣

 

그래서 결론은 Xml Reader 중 Xstream이 있는 것을 확인하고 이를 구현하자였습니다.

 

 

이제 본문을 시작해보도록 하겠습니다.

 

이하 내용은 먼저, Job의 한 Step으로 구성될 XML 임포트 기능에 reader와 writer를 등록합니다.

그 다음 reader로 등록될 StaxEventItemReader를 구현하면서 오늘 다룰 Xstream Unmarchaller를 등록합니다.

그 이후 writer로 등록될 JdbcBatchItemWriter를 등록합니다.

 

추가로 Xstream을 통해 XML을 unmarshalling 한 결과물을 살펴본 이후 겪었던 에러들을 소개하고자 합니다.

 

 

참고 

Job & Step

이 부분은 Xstream의 Unmarshalling만을 사용할 목적으로,

혹시 필자와 동일하게 ItemReader 등록 시 사용할 수 있을 것 같아 참고용으로 담아보았습니다.

 

@Configuration
@RequiredArgsConstructor
public class ImportJobConfiguration {
    // ...

    @Bean
    public Job job() throws Exception {
        return this.jobBuilderFactory
            .get("importJob")
            .start(importCustomerUpdates()) // 해당 포스팅에서 다루지 않음
            .next(importTransactions())     // 해당 포스팅에서 다루는 STEP
            .build();
    }
    
    @Bean(IMPORT_TRANSACTIONS_STEP)
    public Step importTransactions() throws Exception {
        return this.stepBuilderFactory.get(IMPORT_TRANSACTIONS_STEP)
            .<Transaction, Transaction> chunk(100)
            .reader(transactionItemReader(null))
            .writer(transactionItemWriter(null))
            .build();
    }
}

 

기본적으로 구현되어 있는 Job 과 Step입니다.

 

 

 

StaxEventItemReader

public class ImportJobConfiguration {
    @Bean
    @StepScope
    public StaxEventItemReader<Transaction> transactionItemReader(
        @Value("#{jobParameters['transactionFile']}") Resource transactionFile
    ) {
        return new StaxEventItemReaderBuilder<Transaction>()
            .name("fooReader")
            .resource(transactionFile)
            .addFragmentRootElements("transaction")
            .unmarshaller(unmarshaller())
            .build();
    }
}

 

jobParameters로 transactionFile을 받아 Resource로 받은 후,

ItemReader를 구현한 StaxEventItemReader 생성 시 unmarshaller로 등록해줍니다.

 

 

XStreamMarshaller

이제 unmashaller를 구현해보도록 합시다.

 

먼저, xml 파일은 아래와 같이 구성되어있습니다.

 

<?xml version='1.0' encoding='UTF-8'?>
<transactions>
	<transaction>
		<transactionId>2462744</transactionId>
		<accountId>405</accountId>
		<description>Skinix</description>
		<credit/>
		<debit>-438</debit>
		<timestamp>2018-06-01 19:39:53</timestamp>
	</transaction>
	<transaction>
		<transactionId>4252844</transactionId>
		<accountId>296</accountId>
		<description>Mydeo</description>
		<credit/>
		<debit>-3488</debit>
		<timestamp>2018-06-25 17:36:05</timestamp>
	</transaction>
	<transaction>
		<transactionId>7396381</transactionId>
		<accountId>336</accountId>
		<description>Jaloo</description>
		<credit/>
		<debit>-3562</debit>
		<timestamp>2018-06-14 11:49:18</timestamp>
	</transaction>
    ...
</transactions>

 

transaction를 청크 단위로 쪼개서 읽어드릴 예정입니다.

여기서 credit 혹은 debitnullable하다는 점을 확인해야 하며,

timestampyyyy-MM-dd HH:mm:ss 의 포맷을 가진 다는 점을 확인하시길 바랍니다.

 

 

Dependencies

xstream 을 사용하기 위해서는 아래와 같은 의존성이 필요합니다.

 

<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-oxm</artifactId>
</dependency>
<dependency>
    <groupId>com.thoughtworks.xstream</groupId>
    <artifactId>xstream</artifactId>
    <version>1.4.19</version>
</dependency>

 

이 데이터를 담을 도메인 객체를 정의하면 아래와 같습니다.

 

 

Domain

@Data
public class Transaction {
    private long transactionId;
    private long accountId;
    private String description;
    private BigDecimal credit;
    private BigDecimal debit;
    private Date timestamp;
}

 

 

Unmashaller

@Bean
public XStreamMarshaller unmarshaller() {
    Map<String, Class<?>> aliases = new HashMap<>();
    aliases.put("transaction", Transaction.class);

    aliases.put("transactionId", Long.class);
    aliases.put("accountId", Long.class);
    aliases.put("description", String.class);
    aliases.put("credit", BigDecimal.class);
    aliases.put("debit", BigDecimal.class);
    aliases.put("timestamp", Date.class);

    XStreamMarshaller marshaller = new XStreamMarshaller();
    marshaller.setAliases(aliases);

    return marshaller;
}

 

위와 같이 매핑할 alias를 설정합니다.

이 과정이 번거러울 수 있는데요.

그래서 xstream은 annotation을 지원해서 unmarshalling할 객체에 설정할 수 있게 합니다.

이 부분은 아래에서 소개하도록 하겠습니다.

 

 

ForbiddenClassException

그런데 이렇게만 설정하면 아래와 같이 xstream.security.ForbiddenClassException 에러가 납니다.

 

org.springframework.oxm.UnmarshallingFailureException: XStream unmarshalling exception; 
nested exception is com.thoughtworks.xstream.security.ForbiddenClassException: com.gngsn.apressbatch.domain.Transaction

 

저도 알고 싶지 않았는데요 ,,ㅎㅎ

xstream 내의 보안 정책으로 등록된 타겟 객체 내에서만 접근할 수 있도록 한다고 합니다.

참고: Stackoverflow

 

그래서 아래와 같은 설정 Bean을 생성해야 합니다.

 

@Configuration
public class XStreamConfiguration {

    public XStreamConfiguration(XStreamMarshaller marshaller) {
        XStream xstream = marshaller.getXStream();
        xstream.allowTypesByWildcard(new String[]{"com.gngsn.**"});
    }
}

 

정책을 등록하는 방식은 다양한데, 필자는 패키지 명 기준으로 와일드카드를 정의했습니다.

 

 

 

이렇게 끝날 줄 알았는데, 많은 내용을 더 배웠습니다.

물론 알고 싶진 않았지만요 ~

 

 

 

위와 같은 설정만으로는 XML 데이터를 가져올 때 BigDecimal 이나 Date 객체를 가져올 수 없습니다.

그래서 몇 가지의 Converter를 설정해줘야 합니다.

기본적으로 xstream에서 제공해주는 Converter가 있습니다.

다음 공식문서에서 이를 확인할 수 있습니다.

 

 

 

XStreamConfiguration

 

수정된 내용의 XStreamConfiguration입니다.

 

@Configuration
public class XStreamConfiguration {

    public XStreamConfiguration(XStreamMarshaller marshaller) {
        XStream xstream = marshaller.getXStream();
        xstream.allowTypesByWildcard(new String[]{"com.gngsn.**"});

        xstream.registerConverter(new BigDecimalConverter(), PRIORITY_NORMAL);
        xstream.registerConverter(new DateConverter("yyyy-MM-dd HH:mm:ss",
            new String[] {"yyyy-MM-dd hh:mm:ss"}), PRIORITY_NORMAL);
    }
}

 

 

먼저 DateConverter를 언급하자면, xml에 등록된 형식을 고려해서 format을 지정하면 됩니다.

defaultFormatacceptableFormat Array를 등록합니다.

 

이렇게만 등록하면 될 수도 있는데요.

 

위에서 언급했다시피, BigDecimal 타입인 credit과 debit은 nullable합니다.

기존 Converter는 이를 보완해주지 못합니다.

 

그래서 아래와 같은 Converter를 제작하여 등록합니다.

 

 

Custom Converter

BigDecimalConverter를 새로 정의해보겠습니다.

기존의 converter는 아래와 같습니다.

 

package package com.thoughtworks.xstream.converters.basic;

public class BigDecimalConverter extends AbstractSingleValueConverter {
    public BigDecimalConverter() {
    }

    public boolean canConvert(Class type) {
        return type == BigDecimal.class;
    }

    public Object fromString(String str) {
        return new BigDecimal(str);
    }
}

 

수정한 converter입니다.

 

package com.gngsn.apressbatch.job;

public class BigDecimalConverter extends AbstractSingleValueConverter {
    public BigDecimalConverter() {
    }

    @Override
    public boolean canConvert(Class type) {
        return type == BigDecimal.class;
    }

    @Override
    public Object fromString(String str) {
        if (StringUtils.hasText(str)) {
            return new BigDecimal(str);
        } else {
            return new BigDecimal(0);
        }
    }
}

 

 

이렇게 모든 설정을 마쳤습니다.

이상으로 정상적으로 실행되는 것을 확인할 수 있습니다.

 

 

하지만 추가적으로, 위에서 alias 맵을 사용한 부분을 개선해보고자 추가했던

Annotation을 통한 데이터 주입 방식에 대해서도 소개하겠습니다.

 

 

 

XStream with Annotation

간단히 세 가지 부분을 수정하면 됩니다.

 

@XStreamAlias

먼저, domain 객체를 수정합니다.

 

@Data
@XStreamAlias("transaction")
public class Transaction {

    @XStreamAlias("transactionId")
    private long transactionId;

    @XStreamAlias("accountId")
    private long accountId;

    @XStreamAlias("description")
    private String description;

    @XStreamAlias("credit")
    private BigDecimal credit;

    @XStreamAlias("debit")
    private BigDecimal debit;

    @XStreamAlias("timestamp")
    private Date timestamp;
}

 

 

두 번째로는 XStreamMarshaller 빈을 정의할 때를 등록했던 alias map을 제거합니다.

 

public class ImportJobConfiguration {
	// ...
    
    @Bean
    public XStreamMarshaller unmarshaller() {
        return new XStreamMarshaller();
    }
}

 

마지막으로, Configuration에서 annotation을 사용할 수 있도록 객체를 등록합니다.

 

public class XStreamConfiguration {

    public XStreamConfiguration(XStreamMarshaller marshaller) {
    	// ...
        
        xstream.processAnnotations(Transaction.class);
    }
}

 

 

 

 

이상으로 XSteam을 이용한 Xml Unmarshalling을 다뤘습니다.

오타나 잘못된 내용을 댓글로 남겨주세요!

감사합니다 ☺️