Spring Batch 입문 31편 — XML Reader · Writer · StAX 기반 streaming

2026-05-17Spring Batch 입문에서 운영까지

Spring Batch 입문 31편. XML 처리의 두 축 — StaxEventItemReader · StaxEventItemWriter. 왜 DOM·SAX 가 아닌 StAX 인가, fragment-based 처리의 의미, Spring OXM Unmarshaller (JAXB · Jackson · XStream), `addFragmentRootElements`·`rootTagName`·namespace 처리, fragment 별 마샬링·overwriteOutput 같은 옵션·함정까지 정리한 학습 노트.

📚 Spring Batch 입문에서 운영까지 · 31편 — XML Reader · Writer · StAX 기반 streaming

이 글은 Spring Batch 입문에서 운영까지 시리즈 48편 중 31편이에요. 30편까지 flat file 시리즈(27~30편)를 끝냈다면, 이번 31편은 XML 처리 차례예요. StaxEventItemReaderStaxEventItemWriter를 다룹니다.

왜 DOM·SAX 가 아닌 StAX 인가

XML 파싱 API는 크게 세 종류예요.

API 동작 메모리 batch 적합
DOM 전체 트리 메모리 적재 O(파일 크기) X (대용량 불가)
SAX 콜백 기반 (push) O(1) △ (제어권 불편)
StAX (Streaming API for XML, 커서 기반 pull 파서) 커서 기반 (pull) O(1)

DOM은 대량 batch에서 메모리가 터지고, SAX는 파싱 흐름 통제권이 콜백 안에 갇혀서 불편해요. 반면 StAX는 pull 방식이라 ItemReader의 read() 계약과 자연스럽게 맞물려요.

Spring Batch가 StaxEventItemReader를 표준으로 채택한 이유가 여기에 있어요.

Fragment-Based 처리

XML 처리에서는 line 단위 record 대신 fragment라는 단위를 써요. — 공식 reference

<?xml version="1.0" encoding="UTF-8"?>
<records>
    <trade xmlns="...">                  ← fragment 시작
        <isin>XYZ0001</isin>
        <quantity>5</quantity>
        <price>11.39</price>
        <customer>Customer1</customer>
    </trade>                              ← fragment 끝 = item 1개
    <trade xmlns="...">
        <isin>XYZ0002</isin>
        ...
    </trade>
    ...
</records>

<trade> ~ </trade> 사이가 fragment(Java 객체 1개에 매핑되는 단위)예요.

Reader는 <trade>를 만나면 fragment 시작으로 인식하고, fragment 전체를 standalone XML로 분리해 Unmarshaller(XML을 Java 객체로 풀어주는 컴포넌트)에 넘겨요. 그러면 Java 객체가 나오죠.

이 fragment 추출과 OXM 매핑이 한 몸으로 결합된 게 StaxEventItemReader의 핵심이에요.

Spring OXM — Object/XML Mapping 추상화

Spring의 MarshallerUnmarshaller 인터페이스는 OXM(Object/XML Mapping, 객체와 XML 변환) 라이브러리를 통일하는 추상화예요.

지원하는 OXM 구현은 이렇게 다양해요.

구현 특징
JAXB (Jakarta XML Binding, 표준 OXM) 표준, annotation 기반, 가장 흔함
XStream (annotation 없이 alias로 매핑) annotation 없이 동작, 빠른 시작
Jackson XML (Jackson의 XML 모듈) Jackson XML 모듈, JSON 코드 재사용
JiBX (매핑 파일 기반 OXM) 매핑 파일 기반
Castor (옛 OXM 표준) 옛 표준 (deprecated)

Spring Batch는 어떤 구현도 강제하지 않아요. MarshallerUnmarshaller 인터페이스만 충족하면 무엇이든 끼울 수 있죠.

StaxEventItemReader — 3가지 필수 의존성

@Bean
public StaxEventItemReader<Trade> tradeReader() {
    return new StaxEventItemReaderBuilder<Trade>()
        .name("tradeReader")
        .resource(new FileSystemResource("trades.xml"))     // ★ Resource
        .addFragmentRootElements("trade")                    // ★ root element 이름
        .unmarshaller(tradeMarshaller())                     // ★ Unmarshaller
        .build();
}

필수 세 가지는 이렇게 정리돼요.

  1. resource — 입력 XML 파일
  2. addFragmentRootElements — fragment 의 root element 이름 (예: trade)
  3. unmarshaller — XML → Java 변환 (Spring OXM)

Fragment Root Element 의 의미

<records>
    <trade>...</trade>          ← root element = "trade"
    <trade>...</trade>
</records>

바깥의 <records>는 그냥 wrapper라 무시돼요. <trade>가 등장하면 fragment가 시작되고, </trade>가 닫히면 fragment가 끝나면서 item 한 개가 read돼요.

addFragmentRootElements("trade", "order")처럼 이름을 여러 개 지정하면 두 종류 fragment를 모두 read할 수 있어요.

Namespace 처리

<trade xmlns="https://springframework.org/batch/sample/io/oxm/domain">
    ...
</trade>

XML namespace(XML 요소를 구분하는 이름 공간)가 붙어도 root element 이름은 그대로 써요. namespace는 자동으로 처리되거든요.

복잡한 namespace 환경이면 이렇게 풀어쓸 수 있어요.

.addFragmentRootElements("trade")
.fragmentRootElementsContexts(Map.of(
    "trade", "https://springframework.org/batch/sample/io/oxm/domain"
))

이런 명시적 지정은 드물게 필요해요. 대부분 자동으로 풀려요.

Unmarshaller 예제 — JAXB

@Bean
public Jaxb2Marshaller tradeMarshaller() {
    Jaxb2Marshaller marshaller = new Jaxb2Marshaller();
    marshaller.setClassesToBeBound(Trade.class);
    return marshaller;
}

@XmlRootElement(name = "trade")
@XmlAccessorType(XmlAccessType.FIELD)
public class Trade {
    private String isin;
    private long quantity;
    private BigDecimal price;
    private String customer;
    // getter/setter
}

JAXB annotation을 쓰면 domain class가 XML 매핑 정보를 직접 들고 있게 돼요. 가장 흔한 패턴이에요.

Unmarshaller 예제 — XStream

@Bean
public XStreamMarshaller tradeMarshaller() {
    Map<String, Class<?>> aliases = new HashMap<>();
    aliases.put("trade", Trade.class);
    aliases.put("price", BigDecimal.class);
    aliases.put("isin", String.class);
    aliases.put("customer", String.class);
    aliases.put("quantity", Long.class);

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

XStream은 annotation 없이 alias만으로 매핑해요. 덕분에 domain class가 POJO 그대로 유지돼요.

다만 XStream은 deserialization 보안 이슈가 자주 보고되는 편이에요. 신규 프로젝트라면 JAXB나 Jackson XML을 권장해요.

Unmarshaller 예제 — Jackson XML

@Bean
public Jackson2XmlObjectMapperBuilder xmlBuilder() {
    return Jackson2XmlObjectMapperBuilder.xml();
}

@Bean
public XmlMapper xmlMapper(Jackson2XmlObjectMapperBuilder builder) {
    return builder.createXmlMapper(true).build();
}

Jackson의 XML 모듈이에요. JSON 코드와 같은 ObjectMapper 패턴을 그대로 재사용할 수 있어서 신규 프로젝트에 권장돼요.

StaxEventItemReader 작동 흐름

1. open(context)
   → ExecutionContext 위치 복구 (재시작)
   → XMLEventReader 열기

2. read()
   → XMLEventReader 가 다음 fragment 시작 element 탐색
   → fragment 발견 시:
     a. fragment 의 모든 event 를 임시 buffer 에 모음
     b. buffer 의 XML 을 standalone XML 로 wrap
     c. Unmarshaller.unmarshal(buffer) → Java 객체
   → 객체 반환 (또는 null = 종료)

3. update(context)
   → 현재 fragment 위치 ExecutionContext 저장

4. close()
   → XMLEventReader 닫기

24편 ItemStream(reader/writer의 열기·갱신·닫기 라이프사이클 인터페이스)을 그대로 적용한 형태예요. open·update·close가 모든 자리에 박혀 있죠.

StaxEventItemWriter — 역방향

@Bean
public StaxEventItemWriter<Trade> tradeWriter(Resource outputResource) {
    return new StaxEventItemWriterBuilder<Trade>()
        .name("tradesWriter")
        .resource(outputResource)
        .marshaller(tradeMarshaller())                       // ★ Marshaller
        .rootTagName("trades")                               // ★ 최상위 wrapper
        .overwriteOutput(true)
        .build();
}

Writer의 필수 세 가지는 이렇게 정리돼요.

  1. resource — 출력 XML 파일
  2. marshaller — Java → XML 변환
  3. rootTagName — 최상위 wrapper element 이름 (예: <trades>)

출력 구조

<?xml version="1.0" encoding="UTF-8"?>
<trades>                              ← rootTagName
    <trade>                           ← marshaller 가 fragment 생성
        <isin>XYZ0001</isin>
        ...
    </trade>
    <trade>
        <isin>XYZ0002</isin>
        ...
    </trade>
</trades>

item 하나하나는 Marshaller(Java 객체를 XML로 바꿔주는 컴포넌트)가 fragment로 만들어요. Writer는 이 fragment들을 rootTagName wrapper 안에 차곡차곡 쌓아요.

Writer 의 핵심 옵션

옵션 동작
marshaller Java → XML 변환
rootTagName 최상위 wrapper
overwriteOutput 기존 파일 덮어쓰기 (default = false)
version XML version (default = "1.0")
encoding XML 인코딩 (default = UTF-8)
standalone standalone declaration
headerCallback header 추가 callback
footerCallback footer 추가 callback
transactional chunk transaction 안 buffer

Trade 예제 — End-to-end

도메인

@XmlRootElement(name = "trade")
@XmlAccessorType(XmlAccessType.FIELD)
public class Trade {
    private String isin;
    private long quantity;
    private BigDecimal price;
    private String customer;
    // getter/setter
}

Marshaller (JAXB)

@Bean
public Jaxb2Marshaller tradeMarshaller() {
    Jaxb2Marshaller marshaller = new Jaxb2Marshaller();
    marshaller.setClassesToBeBound(Trade.class);
    return marshaller;
}

Reader

@Bean
@StepScope
public StaxEventItemReader<Trade> tradeReader(
        @Value("#{jobParameters['input.file']}") Resource resource) {
    return new StaxEventItemReaderBuilder<Trade>()
        .name("tradeReader")
        .resource(resource)
        .addFragmentRootElements("trade")
        .unmarshaller(tradeMarshaller())
        .build();
}

Writer

@Bean
@StepScope
public StaxEventItemWriter<Trade> tradeWriter(
        @Value("#{jobParameters['output.file']}") Resource resource) {
    return new StaxEventItemWriterBuilder<Trade>()
        .name("tradeWriter")
        .resource(resource)
        .marshaller(tradeMarshaller())
        .rootTagName("trades")
        .overwriteOutput(true)
        .build();
}

Job

@Bean
public Job tradeJob(JobRepository repo, Step tradeStep) {
    return new JobBuilder("tradeJob", repo).start(tradeStep).build();
}

@Bean
public Step tradeStep(JobRepository repo, PlatformTransactionManager tx,
                      StaxEventItemReader<Trade> reader,
                      StaxEventItemWriter<Trade> writer) {
    return new StepBuilder("tradeStep", repo)
        .<Trade, Trade>chunk(100, tx)
        .reader(reader)
        .writer(writer)
        .build();
}

이렇게 XML → XML 가공 pipeline이 완성돼요.

ExecutionContext 위치 추적

StaxEventItemReader가 ExecutionContext(step 실행 상태를 저장하는 영속 컨텍스트)에 저장하는 정보는 두 가지예요.

  • 현재까지 read한 item 수 (StaxEventItemReader.read.count)
  • fragment의 stream 위치 (StAX가 추적)

덕분에 재시작 시 같은 fragment부터 이어서 읽어요. 17편과 24편에서 다룬 ItemStream 메커니즘이 그대로 적용된 거죠.

Transactional Output

StaxEventItemWriter는 기본적으로 transactional이에요. chunk transaction 안에서 buffer에 누적했다가 commit 시점에 flush해요. 30편 FlatFileItemWriter와 똑같은 방식이죠.

rollback이 일어나면 XML 파일 변경도 함께 취소돼요. DB에 가까운 일관성이 나오는 셈이에요.

자주 만나는 사고

사고 1: fragment root element 안 만남

원인addFragmentRootElements("trade") 인데 실제 XML 의 element 가 trades (s 붙음).

해결 — XML 의 element 이름 정확히 확인. 단수·복수 헷갈림 주의.

사고 2: namespace mismatch

원인 — XML 에 xmlns:t="..." 같은 namespace 가 있는데 root element 이름이 unqualified.

해결addFragmentRootElements 가 qualified name 인식하도록. 대부분 element local name 만으로 매칭돼요.

사고 3: 한글 깨짐

원인 — XML declaration 의 encoding 과 실제 파일 encoding 불일치.

해결 — 파일 encoding (UTF-8) 과 XML declaration <?xml version="1.0" encoding="UTF-8"?> 일치.

사고 4: JAXB ClassNotFoundException

원인 — Java 11+ 에서 JAXB 가 JDK 에서 제거됨.

해결jakarta.xml.bind:jakarta.xml.bind-api + org.glassfish.jaxb:jaxb-runtime dependency 추가.

사고 5: XStream 보안 경고

원인 — XStream 의 deserialization 취약점.

해결 — trusted source 만 XStream 사용 또는 JAXB·Jackson 으로 마이그레이션.

사고 6: rootTagName 만 다른 출력

원인rootTagName("trades") 인데 각 item 의 element 가 trade 가 아닌 다른 이름.

해결 — marshaller 가 fragment element 이름을 결정해요. annotation (@XmlRootElement(name = "trade")) 또는 alias 확인.

사고 7: Resource 가 InputStream 인 경우 재시작 안 됨

원인InputStreamResource 는 seek 불가.

해결FileSystemResource·ClassPathResource 사용. 또는 재시작 안 함 정책으로.

운영 권장 패턴

Pattern 1: 표준 JAXB reader

@Bean
@StepScope
public StaxEventItemReader<Trade> tradeReader(
        @Value("#{jobParameters['input.file']}") Resource resource,
        Jaxb2Marshaller marshaller) {
    return new StaxEventItemReaderBuilder<Trade>()
        .name("tradeReader")
        .resource(resource)
        .addFragmentRootElements("trade")
        .unmarshaller(marshaller)
        .build();
}

가장 흔한 패턴이에요. JAXB annotation에 Late Binding을 얹은 형태죠.

Pattern 2: Multi-fragment reader

.addFragmentRootElements("trade", "order", "payment")

여러 종류 fragment를 공통 super type으로 한꺼번에 read해요.

Pattern 3: Custom Unmarshaller chain

public class LoggingUnmarshaller implements Unmarshaller {
    private final Unmarshaller delegate;

    @Override
    public Object unmarshal(Source source) throws XmlMappingException, IOException {
        long start = System.nanoTime();
        try {
            return delegate.unmarshal(source);
        } finally {
            log.debug("Unmarshal took {} ns", System.nanoTime() - start);
        }
    }
}

기존 Marshaller를 wrapping해서 메트릭을 끼우는 방식이에요.

Pattern 4: XML → CSV 변환 Step

@Bean
public Step xmlToCsvStep(JobRepository repo, PlatformTransactionManager tx,
                          StaxEventItemReader<Trade> xmlReader,
                          FlatFileItemWriter<Trade> csvWriter) {
    return new StepBuilder("xmlToCsv", repo)
        .<Trade, Trade>chunk(100, tx)
        .reader(xmlReader)
        .writer(csvWriter)
        .build();
}

format 변환 batch예요. XML 원본을 읽어서 CSV로 떨궈요.

Pattern 5: 큰 XML 의 partition 처리

37편 Partitioning과 XML을 엮으려면, 큰 XML을 N등분 후 partition별로 처리하기가 어려워요. XML은 byte offset으로 자르면 fragment가 잘릴 위험이 있거든요.

대안은 XML을 논리 단위(예: 회사별)로 사전 분할한 뒤 partition을 거는 방식이에요.

시험 직전 한 번 더 — XML Reader/Writer 함정 압축 노트

  • StAX 선택 이유 = pull-based, 메모리 O(1), ItemReader read() 와 매칭
  • DOM = 메모리 폭발, SAX = 콜백 제어권 불편
  • Fragment-based 처리 = line 대신 XML fragment 가 record 단위
  • fragment = root element ~ closing tag
  • StaxEventItemReader 3가지 필수 = resource · addFragmentRootElements · unmarshaller
  • StaxEventItemWriter 3가지 필수 = resource · marshaller · rootTagName
  • rootTagName = 최상위 wrapper element (모든 fragment 가 그 안에)
  • Spring OXM = Marshaller·Unmarshaller 통일 인터페이스
  • OXM 구현 = JAXB (가장 흔함, Jakarta XML Binding) · XStream (annotation X, 보안 주의) · Jackson XML (JSON 재사용) · JiBX · Castor (deprecated)
  • JAXB annotation = @XmlRootElement(name) · @XmlAccessorType · @XmlElement
  • XStream alias map 으로 매핑 (annotation X)
  • Jackson XMLJackson2XmlObjectMapperBuilder
  • 작동 흐름 — XMLEventReader → fragment 추출 → standalone XML → Unmarshaller → 객체
  • addFragmentRootElements("trade", "order") = 여러 fragment type
  • namespace 자동 처리 (local name 매칭)
  • ExecutionContext 위치 = fragment count + StAX stream 위치
  • overwriteOutput Writer 옵션 = 기존 파일 덮어쓰기
  • 기본 transactional output (chunk commit 시 flush)
  • 함정 — element 이름 단복수 mismatch (trade vs trades)
  • 함정 — namespace 불일치
  • 함정 — 한글 깨짐 (XML declaration 의 encoding 과 파일 encoding)
  • 함정 — Java 11+ JAXB JDK 제거 → dependency 추가 (jakarta.xml.bind-api + jaxb-runtime)
  • 함정 — XStream deserialization 보안 → JAXB·Jackson 마이그레이션 권장
  • 함정 — rootTagName 과 fragment element 이름 혼동
  • 함정 — InputStreamResource 의 재시작 불가
  • 패턴 — JAXB + Late Binding (표준)
  • 패턴 — Multi-fragment (addFragmentRootElements 다중)
  • 패턴 — Custom Unmarshaller wrapping (메트릭)
  • 패턴 — XML → CSV 변환 (format 변환 batch)
  • 패턴 — 큰 XML 의 사전 논리 분할 후 partition

공식 문서: XML Item Readers and Writers 에서 원문을 확인할 수 있어요.

시리즈 다른 편 (앞뒤 글 모음)

이전 글:

다음 글:

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!