org.springframework.oxm.xstream.XStreamMarshaller는 XStream 라이브러리를 Marshaller/Unmarshaller 추상화에 붙인 구현이다. 02장에서 본 AbstractMarshaller를 상속하므로, Source/Result 디스패치는 기반 클래스에 맡기고 종류별 실제 변환만 채운다 — Jaxb2Marshaller와 대비되는 구현 방식이다.
XStream은 JAXB와 성격이 다르다. JAXB가 애노테이션·스키마 기반의 데이터 바인딩이라면, XStream은 리플렉션으로 거의 모든 클래스를 XML로 바꾸는 범용 직렬화다. 설정 없이도 동작하지만, 바로 그 점이 보안상 위험하다. 이 챕터는 XStream 통합의 구조와 함께, 그 위험을 다루는 Spring의 안전장치, 그리고 config 패키지의 XML 네임스페이스 지원을 본다.
AbstractMarshaller (support)
│ extends
▼
┌──────────────────────────────────────────┐
│ XStreamMarshaller │
│ implements BeanClassLoaderAware, │
│ InitializingBean │
│──────────────────────────────────────────│
│ SingletonSupplier<XStream> xstream │ ← 지연·thread-safe 생성
│ = SingletonSupplier.of(this::buildXStream)
│ │
│ buildXStream() │
│ ├─ constructXStream() 생성 │
│ ├─ configureXStream() 프로퍼티 적용 │
│ └─ customizeXStream() 확장 훅(빈 구현) │
│ │
│ marshalDomNode / marshalSaxHandlers / ... │ ← AbstractMarshaller 추상 구현
│ unmarshalDomNode / unmarshalInputStream...│
└──────────────────────────────────────────┘
핵심 필드는 SingletonSupplier<XStream> xstream다. XStream 인스턴스 자체는 비싸고 thread-safe하게 구성돼야 하므로, SingletonSupplier로 첫 사용 시 한 번만, 스레드 안전하게 만든다. 그래서 afterPropertiesSet()은 의도적으로 비어 있다 — 초기화를 Supplier에 위임했기 때문이다.
getXStream()이 처음 불리면 buildXStream()이 한 번 실행된다.
buildXStream()
│
├─ constructXStream()
│ └─ new XStream(reflectionProvider, defaultDriver,
│ ClassLoaderReference, mapper,
│ converterLookup, converterRegistry) { wrapMapper(...) }
│ ※ defaultDriver 기본값 = DomDriver(encoding, nameCoder)
│
├─ configureXStream(xstream) ← 사용자 빈 프로퍼티를 XStream API 호출로 변환
│ ├─ converters → registerConverter
│ ├─ typePermissions → addPermission (보안)
│ ├─ aliases/aliasesByType/fieldAliases → alias/aliasType/aliasField
│ ├─ useAttributeFor → useAttributeFor
│ ├─ implicitCollections / omittedFields
│ └─ annotatedClasses → processAnnotations
│
└─ customizeXStream(xstream) ← protected, 기본 빈 구현 (하위 클래스 확장점)
configureXStream은 길지만 본질은 단순하다 — XStreamMarshaller의 수십 개 빈 프로퍼티(별칭, 속성 매핑, 생략 필드 등)를 대응되는 XStream 네이티브 API 호출로 옮기는 번역 계층이다. 덕분에 사용자는 XStream API를 직접 만지지 않고 Spring 빈 프로퍼티로 선언적으로 설정한다.
기본 드라이버가 6.0부터 DomDriver라는 점도 기억할 만하다. 설정된 인코딩과 NameCoder(기본 XmlFriendlyNameCoder)를 쓴다.
AbstractMarshaller가 종류 판별을 끝내고 추상 메서드를 호출하면, XStreamMarshaller는 각 종류를 XStream의 reader/writer로 변환한다. 모든 경로는 결국 doMarshal/doUnmarshal이라는 두 사적 메서드로 모인다.
marshalDomNode(graph, node)
└─ DomWriter 생성 ─────────────────┐
marshalSaxHandlers(graph, ch, lh) │
└─ SaxWriter 생성 ─────────────────┤
marshalOutputStream(graph, out) ├─▶ doMarshal(graph, hsWriter, dataHolder)
└─ streamDriver/CompactWriter ─────┤ │
marshalXmlStreamWriter(graph, w) │ ▼ getXStream().marshal(...)
└─ StaxWriter 생성 ────────────────┘ 예외 → convertXStreamException(ex, true)
unmarshalDomNode / unmarshalInputStream / unmarshalXmlStreamReader ...
└─ 각각 DomReader/StaxReader/드라이버 reader 생성
└─▶ doUnmarshal(hsReader, dataHolder)
│
▼ getXStream().unmarshal(...)
예외 → convertXStreamException(ex, false)
convertXStreamException은 흥미로운 디테일을 드러낸다. XStream은 marshal 실패와 unmarshal 실패를 예외 타입으로 구분하지 않는다. 그래서 Spring은 방향을 나타내는 boolean marshalling 플래그를 직접 넘겨 StreamException/ConversionException 등을 MarshallingFailureException 또는 UnmarshallingFailureException으로 갈라 번역한다. ForbiddenClassException(보안 위반)도 이 경로로 잡힌다.
한 가지 한계: unmarshalSaxReader는 UnsupportedOperationException을 던진다. XStream은 SAX XMLReader 기반 언마샬링을 지원하지 않는다.
XStreamMarshaller의 클래스 주석은 강한 경고로 시작한다. XStream은 기본적으로 클래스패스의 아무 클래스나 (역)직렬화할 수 있어, 외부에서 받은 XML을 언마샬링하면 보안 취약점(임의 객체 생성 → 원격 코드 실행 등)이 된다.
Spring이 제공하는 방어선은 세 가지다.
setSupportedClasses(Class[]):supports(Class)가 해당 클래스들에만true를 돌려주게 제한한다. 비어 있으면(기본) 모든 클래스를 지원한다.setTypePermissions(...): XStream 1.4.18+의 타입 권한 메커니즘을 빈 프로퍼티로 노출한다. 기본 권한은 핵심 JDK 타입으로 제한되며, 커스텀 타입은 명시 등록이 필요하다.CatchAllConverter:xstream패키지의 작은 클래스로, 모든 타입을 받지만 (un)marshal 시 무조건UnsupportedOperationException을 던지는 컨버터다.
CatchAllConverter의 용법이 영리하다. 도메인 클래스용 컨버터를 매우 높은 우선순위로 등록하고, CatchAllConverter를 PRIORITY_NORMAL로 마지막 방어선처럼 깔아 둔다.
xstream.registerConverter(new MyDomainClassConverter(), XStream.PRIORITY_VERY_HIGH);
xstream.registerConverter(new CatchAllConverter(), XStream.PRIORITY_NORMAL);이렇게 하면 명시적으로 다루기로 한 클래스는 전용 컨버터가 처리하고, 그 밖의 모든 타입은 CatchAllConverter에 걸려 예외로 거부된다. 결과적으로 우선순위가 더 낮은 XStream 기본 컨버터들(취약점 가능성 있는)이 아예 호출되지 않는다.
config 패키지는 클래식 XML 빈 설정에서 <oxm:...> 네임스페이스를 쓸 수 있게 한다.
spring.handlers (META-INF)
│ "http://www.springframework.org/schema/oxm" =
▼
OxmNamespaceHandler (NamespaceHandlerSupport)
│ init()
▼
registerBeanDefinitionParser(
"jaxb2-marshaller",
new Jaxb2MarshallerBeanDefinitionParser())
OxmNamespaceHandler.init()은 jaxb2-marshaller 요소를 Jaxb2MarshallerBeanDefinitionParser에 연결한다. 이 파서는 <oxm:jaxb2-marshaller context-path="..."/>나 <oxm:class-to-be-bound name="..."/> 자식 요소를 읽어 Jaxb2Marshaller 빈 정의를 만든다.
<oxm:jaxb2-marshaller id="marshaller" context-path="com.example.model"/>위 한 줄은 Jaxb2Marshaller 빈을 등록하고 contextPath 프로퍼티를 채우는 것과 같다. AbstractSingleBeanDefinitionParser를 상속해 getBeanClassName이 Jaxb2Marshaller를, doParse가 속성·자식 요소를 프로퍼티로 옮기는 일을 한다. (XStream용 파서는 없으므로, XStream은 일반 빈으로 등록한다.)
- 상속 기반 구현 vs 직접 구현:
XStreamMarshaller는AbstractMarshaller를 상속해 디스패치를 재사용하는 반면,Jaxb2Marshaller는 JAXB가 자체 분기를 가져 독립 구현했다. 같은 모듈 안의 두 전략을 비교하면 추상 기반 클래스의 효용이 분명해진다. - 지연·thread-safe 초기화:
SingletonSupplier로 무거운 XStream 인스턴스를 첫 사용 때 한 번만 만든다. - 선언적 설정 번역: 수십 개 빈 프로퍼티 → XStream 네이티브 API 호출로 변환해, 사용자가 라이브러리 API를 직접 다루지 않게 한다.
- 보안 확장점:
setSupportedClasses,setTypePermissions,customizeXStream재정의,CatchAllConverter조합으로 신뢰할 수 없는 입력에 대비한다.
XStreamMarshaller는 AbstractMarshaller를 상속해 디스패치를 재사용하고, SingletonSupplier로 XStream 인스턴스를 안전하게 지연 생성하며, 수많은 빈 프로퍼티를 XStream API 호출로 번역한다. XStream의 무제한 직렬화 위험에 맞서 supportedClasses·typePermissions·CatchAllConverter라는 방어선을 제공하는 것이 핵심이다. config 패키지의 OxmNamespaceHandler는 <oxm:jaxb2-marshaller/> 한 줄로 Jaxb2Marshaller를 등록하는 XML 설정 편의를 더한다. 이로써 spring-oxm은 통일된 추상화(Marshaller/Unmarshaller) 아래에 JAXB와 XStream이라는 성격이 다른 두 구현을, 일관된 예외와 보안 기본값과 함께 제공한다.