XML 을 생성하고 저장하는 로직을 구현하는 중이었는데요. XmlCDATA 어노테이션을 쓰고, XmlJavaTypeAdapter를 적용해봐도 CDATA 꺽쇄까지 이스케이프 처리가 되는 것이었습니다. 지긋지긋한 <, '>.... 아무리 검색해봐도 CharacterEscapeHandler 를 사용하는 방법밖에 안 나왔는데, 이건 Java 9이상에서는 접근이 불가능하다고 하더라구요. Woodstox 라는 라이브러리도 써보고 구현체들 까볼 수 있는 건 다 까보면서 결국 성공했는데, 저와 같은 분이 있다면 시간을 절약할 수 있도록 정리해보겠습니다...
이 글에서 커스텀하는 방법에 대해 설명할 건 아래 2가지에 대한 내용입니다.
Java 버전이 8 이하라면, 아래 글을 참고하시면, 더 간단하게 해결하실 수 있을겁니다.
https://coderleaf.wordpress.com/2016/11/10/controlling-character-escaping-with-jaxb/
이 글까지 찾아들어온 분이라면, 마샬링과 언마샬링을 알고는 계시겠지만 그래도 한 번 짚고 넘어가봅니다.
마샬링은 Java 객체를 저장 및 전송 가능한 데이터의 형태로 변환하는 것을 말하고, 언마샬링은 반대로 변환된 데이터를 Java 객체로 변환하는 것을 말합니다.
JABX는 XML 바인딩을 위한 자바 아키텍쳐로 JAVA object를 XML로 바꾸거나 XML을 java object로 바꿀 수 있는 기능을 제공합니다.

우선 제가 하고 싶었던 걸 설명하자면, CDATA 태그를 온전히 보존하고 싶었습니다. CDATA는 character data를 의미하며, 문자 그대로 해석될 수 있도록 표시하기 위해 사용하는데요. < ![CDATA[ 로 시작하고, ]]>로 종료됩니다. 근데 문제는 마샬러가 CDATA에 있는 꺽쇄를 이스케이프 처리해버리는 것이었죠. 이러한 문제를 XmlCDATA 어노테이션과 XmlJavaTypeAdapter로 해결해보려 했지만, 실패했습니다. 제 삽질의 흔적을 잠시 보여드리겠습니다.
public void createXml(Object jaxbElement, String xmlPath, String encoding) throws JAXBException, IOException {
JAXBContext context = JAXBContext.newInstance(jaxbElement.getClass());
Marshaller marshaller = context.createMarshaller();
marshaller.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, true);
FileWriter fileWriter = new FileWriter(xmlPath);
marshaller.marshal(jaxbElement, fileWriter);
}
public class CdataAdapter extends XmlAdapter<String, String> {
@Override
public String unmarshal(String arg0) throws Exception {
return arg0;
}
@Override
public String marshal(String arg0) throws Exception {
return "<![CDATA[" + arg0 + "]]>";
}
}
XmlJavaTypeAdapter는 위와 같이 작성하였는데요. XmlCDATA 어노테이션과 어댑터 모두 마샬링하는 시점에 CDATA 태그를 붙이기는 하는데, 결국 이스케이프 처리돼서 결과로 나온 xml을 보면 "<![CDATA[" + arg0 + "]]>"; 이런식으로 변환되었습니다.
마샬링을 할 때는 아래처럼 목적이 다른 2개의 Writer가 필요합니다.
1. 결과를 출력하기 위해 사용하는 Writer
2. 1번 Writer에 어떤식으로 결과를 저장할 건지 명령하는 Writer
제가 삽집했던 코드에서는 제가 xml을 쓸 path를 기반으로 만든 FileWriter가 1번에 해당합니다. 그렇다면, 2번은 누굴까?!
사용하는 버전이나 라이브러리 사용여부 그리고 어디에 결과를 작성하느냐에 따라 디폴트 Writer가 다르긴 한데요. 저와 같이 별도 라이브러리 사용하지 않고, jaxb-api 의존성만 추가한 상태에서 파일을 작성하는 경우라면, XMLStreamWriterImpl 가 2번 Writer로 사용될 것입니다.
그럼 이제 도대체 어디서 이스케이프 처리를 하는 것인지 XMLStreamWriterImpl를 살펴보겠습니다.
public void writeCharacters(char[] data, int start, int len)
throws XMLStreamException {
try {
if (fStartTagOpened) {
closeStartTag();
}
writeXMLContent(data, start, len, fEscapeCharacters);
} catch (IOException e) {
throw new XMLStreamException(e);
}
}
writeCData 메서드도 있긴 한데, XmlCDATA 나 어댑터를 사용하더라도 호출이 안되더라구요. 호출 조건을 모르겠는데, gpt 에게 물어보니 MOXy 를 쓰지 않는 이상 자동으로 호출되지는 않는다고 하긴 합니다. 저는 MOXy를 써보려다가 포기했는데, 관심 있으신 분은 도전해보시길...
private void writeXMLContent(
String content,
boolean escapeChars,
boolean escapeDoubleQuotes)
throws IOException {
if (!escapeChars) {
fWriter.write(content);
return;
}
// Index of the next char to be written
int startWritePos = 0;
final int end = content.length();
for (int index = 0; index < end; index++) {
char ch = content.charAt(index);
if (fEncoder != null && !fEncoder.canEncode(ch)){...}
switch (ch) {
case '<':
fWriter.write(content, startWritePos, index - startWritePos);
fWriter.write("<");
startWritePos = index + 1;
break;
case '&':
fWriter.write(content, startWritePos, index - startWritePos);
fWriter.write("&");
startWritePos = index + 1;
break;
case '>':
fWriter.write(content, startWritePos, index - startWritePos);
fWriter.write(">");
startWritePos = index + 1;
break;
case '"':
fWriter.write(content, startWritePos, index - startWritePos);
if (escapeDoubleQuotes) {
fWriter.write(""");
} else {
fWriter.write('"');
}
startWritePos = index + 1;
break;
}
}
// Write any pending data
fWriter.write(content, startWritePos, end - startWritePos);
}
태그 안의 내용을 작성할 때는 wirteCharacters(), writeXMLContent() 메서드가 순차적으로 호출되고 writeXMLContent() 여기서 <, &, >, "를 이스케이프 처리합니다.
좀 더 간단하게 처리해보고 싶어서 escapeChars를 false로 하는 방법은 없는지, writeCData를 호출하는 조건이 뭔지 살펴봤지만 모두 실패했습니다.
물론, 어떤 문자를 이스케이프 처리하는지 알았으니 마샬링한 이후 cdata가 들어가는 부분만 이스케이프 처리되는 부분을 복구하도록 replace 처리해도 되긴 했는데요. 그렇게 되면, 의도와 다른 문자들도 replace 처리되지 않도록 예외 처리를 까다롭게 해야되니까 그냥 커스텀 Writer를 만드는 게 깔끔하고 여러 용도로 확장할 수 있을 것 같아 좋은 방식이라 판단했습니다.
커스텀한 Writer를 만들었더니 xml 들여쓰기가 안돼서 아래와 같이 구성했습니다.
IndentingAndNoEscapeXmlStreamWriter는
com.sun.xml.internal.txw2.output.IndentingXMLStreamWriter 참고해서 작성했고, 상태 값만 enum 으로 수정했습니다.
CDATA 이스케이프를 피하기 위한 메인 Writer의 코드인데요. 모든 메서드를 구현할 수는 없으니 디폴트로 만들어진 XMLStreamWriter를 받아서 나머지는 기존대로 수행하도록 했습니다.
public class DefaultXmlStreamWriter implements XMLStreamWriter {
private final XMLStreamWriter delegate; // XMLStreamWriter 인터페이스를 구현한 구현체
public DefaultXmlStreamWriter(XMLStreamWriter delegate) {
this.delegate = delegate;
}
/*-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=
| 커스텀한 메서드
| CDATA를 붙이더라도 마샬링 시 인식하지 못하고, 괄호를 이스케이프 처리함
| -> 이 단계에서 CDATA가 붙어있는지 확인 후 붙어있으면 CDATA로 처리하도록 커스텀함
|-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=*/
@Override
public void writeCharacters(String text) throws XMLStreamException {
if (isWrappedWithCDATA(text)) {
text = unwrapCDATA(text);
delegate.writeCData(text);
} else {
delegate.writeCharacters(text);
}
}
...
/**
* <![CDATA[ ]]로 감싸져 있는지 확인.
*/
private boolean isWrappedWithCDATA(String text) {
return text.startsWith("<![CDATA[") && text.endsWith("]]>");
}
/**
* <![CDATA[ ]]를 제거한 문자열 반환.
*/
private String unwrapCDATA(String text) {
return text.substring(9, text.length() - 3); // <![CDATA[ 길이: 9, ]]> 길이: 3
}
public void createXml(Object jaxbElement, String xmlPath, String encoding, boolean standaloneYn) throws JAXBException, IOException {
// 파일로 마샬링한 후 저장
JAXBContext context = JAXBContext.newInstance(jaxbElement.getClass());
Marshaller marshaller = context.createMarshaller();
marshaller.setListener(new CustomJaxbListener());
marshaller.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, true);
try {
StringWriter xmlOutput = new StringWriter();
XMLOutputFactory factory = XMLOutputFactory.newInstance();
XMLStreamWriter originalWriter = factory.createXMLStreamWriter(xmlOutput);
// CDATA에 들어가는 괄호까지 이스케이프처리를 하여 이를 피하기 위한 커스텀한 StreamWriter 생성함
// CharacterEscapeHandler 로도 가능하긴 하지만, 이는 Java 9이상에서 접근이 불가능함. 그래서 젠킨스 빌드 시 에러 발생.
XMLStreamWriter customWriter = new IndentingAndNoEscapeXmlStreamWriter(originalWriter);
marshaller.marshal(jaxbElement, customWriter);
String xml = xmlOutput;
writeToFile(xmlPath, xml);
} catch (Exception e) {
logger.error("=================== [writeXml error] : " + e.getMessage());
}
logger.info("=================== [writeXml END]");
readXml(xmlPath);
}
public class IndentingAndNoEscapeXmlStreamWriter extends DefaultXmlStreamWriter {
private enum WriterState {
SEEN_NOTHING, // 태그도 데이터도 없는 상태
SEEN_ELEMENT, // XML 태그가 열렸지만, 그 안에 데이터는 아직 작성되지 않은 상태
SEEN_DATA // 태그 안에 데이터(텍스트 또는 CDATA)가 작성된 상태
}
private WriterState state = WriterState.SEEN_NOTHING;
private Stack<WriterState> stateStack = new Stack<>();
private String indentStep = " ";
private int depth = 0;
public IndentingAndNoEscapeXmlStreamWriter(XMLStreamWriter writer) {
super(writer);
}
public int getIndentStep() {
return indentStep.length();
}
public void setIndentStep(int indentStep) {
StringBuilder s = new StringBuilder();
for (; indentStep > 0; indentStep--) s.append(' ');
setIndentStep(s.toString());
}
public void setIndentStep(String s) {
this.indentStep = s;
}
private void onStartElement() throws XMLStreamException {
stateStack.push(WriterState.SEEN_ELEMENT);
state = WriterState.SEEN_NOTHING;
if (depth > 0) {
super.writeCharacters("\n");
}
doIndent();
depth++;
}
private void onEndElement() throws XMLStreamException {
depth--;
if (state == WriterState.SEEN_ELEMENT) {
super.writeCharacters("\n");
doIndent();
}
state = stateStack.pop();
}
private void onEmptyElement() throws XMLStreamException {
state = WriterState.SEEN_ELEMENT;
if (depth > 0) {
super.writeCharacters("\n");
}
doIndent();
}
/**
* 현재 깊이에 맞춰서 들여쓰기
*/
private void doIndent() throws XMLStreamException {
if (depth > 0) {
for (int i = 0; i < depth; i++)
super.writeCharacters(indentStep);
}
}
@Override
public void writeStartDocument() throws XMLStreamException {
super.writeStartDocument();
super.writeCharacters("\n");
}
@Override
public void writeStartDocument(String version) throws XMLStreamException {
super.writeStartDocument(version);
super.writeCharacters("\n");
}
@Override
public void writeStartDocument(String encoding, String version) throws XMLStreamException {
super.writeStartDocument(encoding, version);
super.writeCharacters("\n");
}
@Override
public void writeStartElement(String localName) throws XMLStreamException {
onStartElement();
super.writeStartElement(localName);
}
@Override
public void writeStartElement(String namespaceURI, String localName) throws XMLStreamException {
onStartElement();
super.writeStartElement(namespaceURI, localName);
}
@Override
public void writeStartElement(String prefix, String localName, String namespaceURI) throws XMLStreamException {
onStartElement();
super.writeStartElement(prefix, localName, namespaceURI);
}
@Override
public void writeEmptyElement(String namespaceURI, String localName) throws XMLStreamException {
onEmptyElement();
super.writeEmptyElement(namespaceURI, localName);
}
@Override
public void writeEmptyElement(String prefix, String localName, String namespaceURI) throws XMLStreamException {
onEmptyElement();
super.writeEmptyElement(prefix, localName, namespaceURI);
}
@Override
public void writeEmptyElement(String localName) throws XMLStreamException {
onEmptyElement();
super.writeEmptyElement(localName);
}
@Override
public void writeEndElement() throws XMLStreamException {
onEndElement();
super.writeEndElement();
}
@Override
public void writeCharacters(String text) throws XMLStreamException {
state = WriterState.SEEN_DATA;
super.writeCharacters(text);
}
@Override
public void writeCharacters(char[] text, int start, int len) throws XMLStreamException {
state = WriterState.SEEN_DATA;
super.writeCharacters(text, start, len);
}
@Override
public void writeCData(String data) throws XMLStreamException {
state = WriterState.SEEN_DATA;
super.writeCData(data);
}
}
public class DefaultXmlStreamWriter implements XMLStreamWriter {
private final XMLStreamWriter delegate; // XMLStreamWriter 인터페이스를 구현한 구현체
public DefaultXmlStreamWriter(XMLStreamWriter delegate) {
this.delegate = delegate;
}
/*-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=
| 커스텀한 메서드
| CDATA를 붙이더라도 마샬링 시 인식하지 못하고, 괄호를 이스케이프 처리함
| -> 이 단계에서 CDATA가 붙어있는지 확인 후 붙어있으면 CDATA로 처리하도록 커스텀함
|-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=*/
@Override
public void writeCharacters(String text) throws XMLStreamException {
if (isWrappedWithCDATA(text)) {
text = unwrapCDATA(text);
delegate.writeCData(text);
} else {
delegate.writeCharacters(text);
}
}
@Override
public void writeCharacters(char[] text, int start, int len) throws XMLStreamException {
String textToStr = new String(text, start, len);
writeCharacters(textToStr);
}
/*-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=
| 기본 메서드
| 넘겨받은 구현체의 기본 동작을 따름
|-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=*/
@Override
public void writeStartElement(String localName) throws XMLStreamException {
delegate.writeStartElement(localName);
}
@Override
public void writeStartElement(String namespaceURI, String localName) throws XMLStreamException {
delegate.writeStartElement(namespaceURI, localName);
}
@Override
public void writeStartElement(String prefix, String localName, String namespaceURI) throws XMLStreamException {
delegate.writeStartElement(prefix, localName, namespaceURI);
}
@Override
public void writeEmptyElement(String namespaceURI, String localName) throws XMLStreamException {
delegate.writeEmptyElement(namespaceURI, localName);
}
@Override
public void writeEmptyElement(String prefix, String localName, String namespaceURI) throws XMLStreamException {
delegate.writeEmptyElement(prefix, localName, namespaceURI);
}
@Override
public void writeEmptyElement(String localName) throws XMLStreamException {
delegate.writeEmptyElement(localName);
}
@Override
public void writeEndElement() throws XMLStreamException {
delegate.writeEndElement();
}
@Override
public void writeEndDocument() throws XMLStreamException {
delegate.writeEndDocument();
}
@Override
public void close() throws XMLStreamException {
delegate.close();
}
@Override
public void flush() throws XMLStreamException {
delegate.flush();
}
@Override
public void writeAttribute(String localName, String value) throws XMLStreamException {
delegate.writeAttribute(localName, value);
}
@Override
public void writeAttribute(String prefix, String namespaceURI, String localName, String value) throws XMLStreamException {
delegate.writeAttribute(prefix, namespaceURI, localName, value);
}
@Override
public void writeAttribute(String namespaceURI, String localName, String value) throws XMLStreamException {
delegate.writeAttribute(namespaceURI, localName, value);
}
@Override
public void writeNamespace(String prefix, String namespaceURI) throws XMLStreamException {
delegate.writeNamespace(prefix, namespaceURI);
}
@Override
public void writeDefaultNamespace(String namespaceURI) throws XMLStreamException {
delegate.writeDefaultNamespace(namespaceURI);
}
@Override
public void writeComment(String data) throws XMLStreamException {
delegate.writeComment(data);
}
@Override
public void writeProcessingInstruction(String target) throws XMLStreamException {
delegate.writeProcessingInstruction(target);
}
@Override
public void writeProcessingInstruction(String target, String data) throws XMLStreamException {
delegate.writeProcessingInstruction(target, data);
}
@Override
public void writeCData(String data) throws XMLStreamException {
delegate.writeCData(data);
}
@Override
public void writeDTD(String dtd) throws XMLStreamException {
delegate.writeDTD(dtd);
}
@Override
public void writeEntityRef(String name) throws XMLStreamException {
delegate.writeEntityRef(name);
}
@Override
public void writeStartDocument() throws XMLStreamException {
delegate.writeStartDocument();
}
@Override
public void writeStartDocument(String version) throws XMLStreamException {
delegate.writeStartDocument(version);
}
@Override
public void writeStartDocument(String encoding, String version) throws XMLStreamException {
delegate.writeStartDocument(encoding, version);
}
@Override
public String getPrefix(String uri) throws XMLStreamException {
return delegate.getPrefix(uri);
}
@Override
public void setPrefix(String prefix, String uri) throws XMLStreamException {
delegate.setPrefix(prefix, uri);
}
@Override
public void setDefaultNamespace(String uri) throws XMLStreamException {
delegate.setDefaultNamespace(uri);
}
@Override
public void setNamespaceContext(NamespaceContext context) throws XMLStreamException {
delegate.setNamespaceContext(context);
}
@Override
public NamespaceContext getNamespaceContext() {
return delegate.getNamespaceContext();
}
@Override
public Object getProperty(String name) throws IllegalArgumentException {
return delegate.getProperty(name);
}
/**
* <![CDATA[ ]]로 감싸져 있는지 확인.
*/
private boolean isWrappedWithCDATA(String text) {
return text.startsWith("<![CDATA[") && text.endsWith("]]>");
}
/**
* <![CDATA[ ]]를 제거한 문자열 반환.
*/
private String unwrapCDATA(String text) {
return text.substring(9, text.length() - 3); // <![CDATA[ 길이: 9, ]]> 길이: 3
}
}