알라딘 OpenAPI를 통해 XML 응답을 받아 도서 정보를 추출하는 기능을 구현하면서, 처음에는 가독성과 쉬운 호출을 위해 간단한 static
유틸리티 메서드를 중심으로 로직을 구성했었다. 코드리뷰도 받아보고, 추후 확장성을 생각해보니 구조적인 한계를 느꼈다.
그 과정에서 전략 패턴을 적용해 구조를 리팩토링했고, 파싱 방식과 데이터 변환 책임을 분리해 더 유연하고 확장 가능한 구조로 개선할 수 있었다.
이 글은 그 과정을 정리해 둔 기록이며, 공부하면서 직접 구조를 바꿔 본 경험을 바탕으로 작성했기 때문에 내용이 길고 다소 가독성이 떨어질 수도 있다.
public class BookXmlParser {
public static BookDetailResponse parseBookDetail(Document document) { ... }
public static List<BookSearchItemResponse> parseBookList(Document document) { ... }
}
public class XmlUtils {
public static Document parseXmlFromUrl(URL url) { ... }
public static String getTagValue(String tag, Element element) { ... }
public static int parseIntOrZero(String value) { ... }
}
초기에는 BookXmlParser
, XmlUtils
와 같이 모든 파싱 로직을 static
메서드로 구성한 유틸리티 클래스를 사용했다.
표면적으로는 간단하고 빠르게 동작하는 구조처럼 보였지만, 기능을 추가하거나 유지보수하는 상황을 가정해보면 다양한 구조적 한계가 드러났다.
가장 본질적인 문제는 책임이 명확히 분리되어 있지 않고, 여러 계층이 강하게 결합되어 있다는 점이다.
BookXmlParser
는 다음과 같은 로직으로 구성되어 있었다.
XML DOM 객체(Document)에서 데이터를 추출하는 로직
→ 즉, Document
로부터 특정 태그(<item>
)를 찾아 Element
로 접근하는 DOM 탐색 책임
추출한 데이터를 DTO 객체로 변환하는 메서드를 호출하는 로직
→ Element
를 어떻게 해석해 어떤 객체로 변환할지는 from(Element)
메서드에 위임되어 있지만, 그 메서드가 속한 구체 DTO 타입에 직접 의존하고 있기 때문에, BookXmlParser
는 해당 DTO 클래스들과 구조적으로 강하게 결합된 상태다.
노드 탐색, 문자열 파싱 등 유틸성 기능은 DTO 내부에서 호출되고 있기 때문에,
BookXmlParser → DTO 변환 메서드 → 유틸
로 이어지는 간접적인 호출 관계에 의해
유틸 내부 구현이 변경되면 BookXmlParser
까지 영향을 받을 수 있는 구조다.
예를 들어 다음과 같은 상황을 가정해보자.
// BookXmlParser 내부
BookDetailResponse.from((Element) node)
// BookDetailResponse 내부
.title(getTagValue("title", element))
// XmlUtils 내부
public static String getTagValue(String tag, Element element) {
NodeList nodeList = element.getElementsByTagName(tag);
if (nodeList.getLength() == 0) return ""; // 어느날 이 부분이 변경됨: "" → null
return nodeList.item(0).getTextContent();
}
// BookService 내부
BookDetailResponse response = BookXmlParser.parseBookDetail(document);
log.info("도서 제목: {}", response.getTitle().toUpperCase()); // NullPointerException 발생 가능
이처럼 BookXmlParser는 XmlUtils를 직접 호출하지 않더라도,
그걸 사용하는 DTO(BookDetailResponse)를 통해 간접적으로 영향을 받는 구조다.
유틸 내부 구현이 바뀌면, 그 결과가 BookXmlParser → Service → Controller까지도 전파될 수 있다.
또한 모든 메서드가 static으로 구성되어 있었기 때문에, 다음과 같은 문제가 있다.
단위 테스트가 거의 불가능하다.
static 메서드는 DI(의존성 주입)의 대상이 아니기 때문에 mocking이나 대체가 불가능하고, 테스트 환경에서도 격리된 테스트가 어렵다.
확장성도 매우 낮다.
예를 들어, 새로운 DTO 타입으로 XML을 파싱해야 할 경우 BookXmlParser
에 또 다른 static 메서드를 추가해야 하며, 파싱 방식이 다양해질수록 parseBookList()
, parseBookDetail()
, parseSpecialItemList()
등과 같이 메서드 수가 폭발적으로 증가한다. 이는 OCP(Open-Closed Principle)를 위반하는 구조다.
재사용성도 사실상 없다시피 하다.
각 메서드는 특정 DTO에 강하게 결합되어 있으며, 다른 타입의 객체로의 재사용이 거의 불가능하다. 공통 파싱 흐름 없이, 개별 상황에 맞게 다른 static 메서드를 직접 구현해야 했다.
결국 이 구조는 테스트 불가, 확장 어려움, 유지보수 부담 증가, 높은 결합도와 낮은 재사용성이라는 여러 문제가 있었다.
구조 개선의 필요성을 느꼈고, 이를 해결하기 위한 대안으로 전략 패턴을 도입해 리팩토링을 진행했다.
XML 파싱 방식(단건 vs 리스트)과 데이터 변환 책임을 명확히 분리한다.
각 책임을 인터페이스로 추상화하고, 조합 가능한 전략 객체로 만들어 유연하게 구성한다.
이를 위해 전략 패턴(Strategy Pattern)을 적용했고, 크게 세 가지 핵심 구조로 나눴다.
전략 패턴(Strategy Pattern)이란?
전략 패턴은 객체의 행위를 동적으로 바꿀 수 있도록 해주는 디자인 패턴이다. 알고리즘을 정의하고 캡슐화하여 각각을 교체 가능하게 만든다. 이를 통해 로직 변경에 유연하게 대응하고, 코드의 재사용성을 높일 수 있다.
XmlElementMapper<T>
– 엘리먼트 변환 전략public interface XmlElementMapper<T> {
T fromElement(Element element);
}
<item>
엘리먼트를 도메인 객체 T로 변환하는 전략을 정의한다.BookInfoMapper
와 같이 도메인별로 존재할 수 있다.@Component
public class BookInfoMapper implements XmlElementMapper<BookInfo> {
@Override
public BookInfo fromElement(Element element) {
return new BookInfo(
getText(element, "title"),
getText(element, "author"),
...
);
}
}
매퍼는 XML 엘리먼트를 파싱하여 객체를 반환하는 책임을 갖는다.
여기서 “어떤 태그를 추출하느냐”는 객체의 필드 구조에 따라 달라진다.
"title"
, "author"
와 같이 하드코딩된 문자열을 해당 객체의 필드 구조에 따라 자동으로 매핑될 수 있다면, 보다 확장성과 유지보수성이 높은 구조가 될 수 있다고 판단했다.
이런 자동 매핑 구조를 고민해보았지만, 현재 시점에서는 실패했다.
객체 구조를 동적으로 반영하는 방식에 대해 더 공부를 해보려고 한다.
XmlParseStrategy<T>
– 파싱 방식 전략public interface XmlParseStrategy<T> {
T parse(Document document);
}
이 인터페이스는 파싱 방식 자체를 전략화한다.
예를 들어, 단일 <item>
만 파싱하는 전략과 여러 개를 파싱하는 전략을 각각 별도 클래스로 구현할 수 있다.
public class ListParseStrategy<T> implements XmlParseStrategy<List<T>> {
private final XmlElementMapper<T> mapper;
@Override
public List<T> parse(Document document) {
NodeList nodes = document.getElementsByTagName("item");
List<T> list = new ArrayList<>();
for (int i = 0; i < nodes.getLength(); i++) {
...
list.add(mapper.fromElement((Element) node));
}
return list;
}
}
기존에는 BookXmlParser
가 이 파싱 로직을 직접 수행했지만, 이제는 파싱 책임을 완전히 전략 객체로 분리함으로써 재사용성과 테스트 가능성이 모두 향상되었다.
NodeList nodes = document.getElementsByTagName("item");
현재는 <item>
태그를 기준으로 파싱하고 있지만, 실제 API 명세에 따라 다른 루트나 구조를 파싱해야 할 경우 이 전략을 일반화하기 어려운 한계가 있었다.
예를 들어, <channel><items><item>
구조처럼 중첩된 경우,
혹은 리스트가 아닌 <book><title>
, <book><author>
처럼 하나의 루트 아래 여러 항목이 펼쳐진 구조는 지금 방식으로 대응하기 어렵다.
이를 해결하기 위해 범용적으로 사용할 수 있는 Element 추출 기준을 외부에서 주입해보는 것도 고려했지만, 현재 나의 기술 수준과 요구사항 범위를 고려해 적용하지는 못했다.
웹 API의 XML 구조를 범용적으로 추상화하는 건 생각보다 복잡한 문제인 것 같아 XML 구조에 더 유연하게 대응할 수 있는 방법을 공부해 볼 예정이다.
@Component
public class XmlParser {
public <T> T parse(Document document, XmlParseStrategy<T> strategy) {
return strategy.parse(document);
}
}
실제 파싱을 실행하는 XmlParser는 아무런 파싱 로직을 직접 갖고 있지 않다.
XmlParseStrategy<T>
를 실행하는 전략 실행 컨텍스트의 역할만을 수행한다.
변화하는 로직은 전략 객체가 담당하고, 컨텍스트는 고정된 흐름(위임 방식)만 유지한다.
서비스 단에서는 다음과 같이 파싱 전략과 매퍼를 조합하여 사용한다.
// 리스트 파싱 전략
List<BookInfo> bookInfos = xmlParser.parse(document, new ListParseStrategy<>(mapper));
// 단건 파싱 전략
BookInfo bookInfo = xmlParser.parse(document, new SingleParseStrategy<>(mapper));
ListParseStrategy
또는 SingleParseStrategy
로 선택XmlElementMapper<BookInfo>
의 구현체인 BookInfoMapper
기존에 parseBookList()
, parseBookDetail()
등 각각의 static 메서드를 개별적으로 작성해야 했던 코드를 하나의 공통된 흐름으로 만들 수 있었다.
이 구조를 통해 책임은 명확하게 분리되고, 전략 조합만 바꾸면 다양한 XML 파싱 시나리오를 유연하게 처리할 수 있게 되었다. 또한 각 전략은 단위 테스트도 별도로 작성할 수 있게 되었다.
전략 패턴은 확실히 확장성과 테스트 용이성 면에서는 많은 장점이 있다.
파싱 방식이 다양해지고, 매핑 대상이 바뀌어도 전략 객체만 교체하면 되므로, 기존 구조보다 훨씬 유연하고 재사용성이 높다.
하지만 전략 객체 구조가 무조건 옳거나 항상 효율적인 건 아니라는 점도 분명히 존재한다.
처음부터 구조를 잘게 나누고 객체로 쪼개다 보면, 오히려 전체 흐름을 파악하기 어려워질 수 있다.
XML을 한두 번 파싱해서 DTO 하나만 만들면 되는 간단한 기능에까지 전략 패턴을 도입하면,
구현체가 불필요하게 많아지고
로직이 여러 파일에 흩어져서
코드의 단순성이 사라지는 문제가 생긴다.
"기능은 간단한데 구조만 복잡해 보인다"는 느낌을 줄 수 있다.
전략 객체는 장점만큼 간접 호출과 추상화가 많다.
로직 흐름이 인터페이스 → 구현체 → 실행 컨텍스트로 흩어져 있기 때문에,
이것이 러닝 커브를 높이는 원인이 되기도 하는 것 같다.
실제로 익숙하지 않은 팀원에게 코드를 보여줬을 때 구조를 이해하는 데 시간이 걸렸고 나 또한 처음 접하는 패턴이라 명확하게 설명하지 못한 경험이 있었다.
처음엔 기능만 동작하면 된다고 생각했던 파싱 로직에서 책임 분리, 결합도, 테스트 가능성 같은 구조적인 요소들이 얼마나 중요한지 아직까지는 추상적이지만 어느정도 체감할 수 있는 기회였다.
전략 패턴을 적용하면서 구조가 유연해지고 확장 가능해졌지만, 동시에 "모든 기능에 패턴을 적용하는 것이 정말 필요한가?"를 고민하게 되기도 했다. 지금 돌아보면, 이번 구조는 나에게 조금 과한 설계였다는 점도 분명히 느낀다.
그래서 다음부터는 패턴을 적용하기 전에
"이 기능이 충분히 바뀌고 확장될 가능성이 있을까?" 아니면
"한 번 구현되고 끝나는 단순한 기능인가?"를 먼저 판단해보는 습관을 가지려고 한다.
그리고 개발은 정말 trade-off의 연속인 것 같다.
본인이 주관을 가지고 판단하려면 도대체 얼마나 많은 걸 알고 있어야 하는 것인지, 눈앞이 캄캄하다고 느껴지는 날이 요즘 특히 많다.
이 글은 언젠가 그런 판단을 내려야 할 나에게 도움이 되기 위해 정리한 글이다. 말로만 듣던 전략 패턴을 적용해 보니 뿌듯하긴 하다!