Swift로 XML Parser 만들기, 설계부터 고민까지
1. 이야기의 시작
프로젝트에서 XML 응답을 파싱해야하는 일이 생겼습니다. 주로 API로부터 응답은 JSON 포맷으로 받게 되지만 어쩌다 한번씩 XML 포맷으로 응답을 받는 경우가 있습니다.
흔하지 않은 경우이기도 해서 그때마다 필요한 데이터나 정보에 맞게 Parser를 만들었습니다.
하지만 생각보다 XML 포맷의 응답이 많아지고 있었습니다.
Swift의 XMLParser의 불편한 문제
기본으로 제공하는 XMLParser는 실제로 사용하는데 몇가지 불편한 점이 있습니다.
XMLParser는 데이터를 한 줄, 한 줄 읽으면서 파싱을 하게 됩니다.
현재 어느 위치에서 파싱이 진행되는지 알 수 없기 때문에 이 위치를 알기 위해서는 수 많은 추가 작업들이 필요합니다. 특정 태그의 정보를 담는 프로퍼티를 만든다든가, 플래그를 만든다든가 하는 것이죠.
또한, 부모 - 자식 관계나 형제 관계에 대한 정보도 알기 어렵습니다.
그저 파싱 중인 한 태그, 한 태그의 정보만 전달해줄 뿐이죠.
그 외에도 몇 가지 불편한 점들이 있지만 이 이유가 Parser를 재사용하기 어렵고, 매번 필요한 정보에 따라 커스텀 Parser를 만들게 되는 이유가 됩니다.
API의 정보를 Codable을 준수하는 타입으로 바꾸는 작업이 필요하지만 매번 Client의 응답을 하나하나 커스텀해주는 과정은 없습니다.
최소한 다른 API만큼의 작업만 진행하고자 재사용을 위한 Parser를 만들기로 했습니다.
아, 라이브러리는 고려하지 않았습니다. 이건 저의 개발 환경의 특수성때문입니다.
2. 어떤 방식으로 만들 수 있을까?
2-1. subscript 방식
XML을 파싱하는 라이브러리들을 찾아보았습니다. SWXMLHash가 첫 번째로 눈에 보였습니다. Star의 수도 1.5k로 준수했습니다.
하지만 사용 예시를 보면 아래와 같았습니다.
xml["user"]["items"][0]["bag"].value
직관적이지만 하드코딩을 사용하는 방식이었습니다.
바로 패스했습니다.
2-2. [String : Any] 변환 방식
기존 작업들에서 사용하던 방식입니다. 필요한 태그를 key로, 필요한 값들을 value로 변환해서 사용하는 방식입니다.
하지만 여러 구조로 넘어오는 XML을 다 처리하기에는 쉽지 않을 것 같았습니다.
2-3. Codable 방식
XML -> JSON -> Codable 구조체로 변환하는 방식입니다.
변환 레이어가 추가되어야 하지만 사용하는 쪽은 JSONDecoder와 거의 동일한 방식으로 사용할 수 있게 됩니다.
3. 설계하기
최종 사용 형태를 먼저 정하고, 역으로 설계를 시작했습니다.
XMLNode → 파싱 결과를 담는 트리 노드
XMLDocumentParser → XMLParserDelegate, 노드 트리 생성
XMLNode+JSON → 노드 트리를 JSON으로 변환
XMLDecoder → 외부에 노출되는 최종 인터페이스
XMLNode — struct vs class
struct XMLNode {
let name: String
var value: String = ""
var attributes: [String: String] = [:]
var children: [XMLNode] = []
}
처음엔 class로 만들었습닌다. 트리 구조에서 자식을 추가할 때 참조로 접근하면 편하기 때문입니다.
하지만, 파싱이 끝난 이후엔 불변 데이터에 가깝고 외부에서 공유할 필요가 없어서 struct로 변경했습니다.
struct로 바꾸면서 파서 코드도 수정이 필요.
// class일 때 - 참조라서 직접 수정 가능
stack.last?.children.append(node) // ❌ struct에선 복사본 반환
// struct일 때 - 인덱스로 직접 접근해야 함
stack[stack.count - 1].children.append(node) // ✅
stack.last는 복사본을 반환하기 때문에 append해도 원본에 반영되지 않습니다.
XMLDocumentParser — 스택 기반 트리 생성
XMLParserDelegate를 이용해 스택으로 트리를 조립합니다. 핵심 로직은 didEndElement입니다.
func parser(_ parser: XMLParser, didEndElement elementName: String, ...) {
guard var node = stack.popLast() else { return }
node.value = currentValue
currentValue = ""
if stack.isEmpty {
root = node
} else {
stack[stack.count - 1].children.append(node)
}
}
4. 트러블슈팅
처음 구현에서 문제가 있었습니다.
// 잘못된 코드
let jsonData = try root.toJSONData()
return try jsonDecoder.decode(type, from: jsonData)
toJSONData()는 디버깅 용도로 루트 노드 이름을 키로 감싸서 반환합니다.
// <items> XML이라면
{ "items": { "item": [...] } }
items 구조체는 최상위에서 item 키를 찾는데 items만 있으니 디코딩이 항상 실패합니다.
// 수정 후
private func decode<T: Decodable>(_ type: T.Type, from root: XMLNode) throws -> T {
let jsonData = try JSONSerialization.data(withJSONObject: root.toJSONObject())
return try jsonDecoder.decode(type, from: jsonData)
}
toJSONObject()는 루트 이름 없이 내용만 반환하기 때문에 구조체와 정확히 매핑됩니다.
이로 인해 Codable 구조체를 정의할 때도 JSONDecoder와 동일한 방식으로 사용할 수 있습니다. 루트 태그명은 decode() 호출 시 타입으로 표현하고, struct는 그 내용물을 정의합니다.
<?xml version="1.0" encoding="utf-8"?>
<INFO>
<CODE>0</CODE>
<MESSAGE>success</MESSAGE>
</INFO>
// INFO 안의 내용물을 바로 정의
struct Info: Decodable {
let code: String
let message: String
enum CodingKeys: String, CodingKey {
case code = "CODE"
case message = "MESSAGE"
}
}
let result = try XMLDecoder().decode(Info.self, from: xmlString)
태그 속성 처리
속성과 값이 함께 있는 경우도 있습니다.
<price currency="USD" discount="10">39.99</price>
JSON으로 변환하면
{
"@attributes": { "currency": "USD", "discount": "10" },
"#value": "39.99"
}
@와 #를 prefix로 쓰는 이유는 XML 태그명으로 사용할 수 없는 문자라 실제 태그와 충돌하지 않기 때문입니다.
Codable에서 받으려면 커스텀 init(from:)이 필요합니다.
struct Price: Decodable {
let currency: String
let discount: String
let value: String
enum CodingKeys: String, CodingKey {
case attributes = "@attributes"
case value = "#value"
}
struct AttributeKeys: Decodable {
let currency: String
let discount: String
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let attrs = try container.decode(AttributeKeys.self, forKey: .attributes)
currency = attrs.currency
discount = attrs.discount
value = try container.decode(String.self, forKey: .value)
}
}
lazy var + struct 조합
파서를 다른 struct에서 사용하다가 에러가 발생했습니다.
struct SomeStruct {
private lazy var xmlDecoder: any XMLDecoding = XMLDecoder()
func fetch() {
someFunction {
try? xmlDecoder.decode(...) // Cannot use mutating getter on immutable value
}
}
}
무심코 사용한 lazy var 때문이었습니다.
lazy var는 처음 접근할 때 값을 초기화하고 self에 저장한다. 즉 getter가 내부적으로 self를 변경한다.
// lazy var가 실제로 하는 일
mutating get {
if _xmlDecoder == nil {
_xmlDecoder = XMLDecoder() // self를 변경
}
return _xmlDecoder!
}
func fetch()는 non-mutating이라 self가 immutable하고, self가 immutable하니 lazy var의 mutating getter를 호출할 수 없는 것 입니다.
해결 방법은 별 다른게 없습니다. 생성 비용이 크지 않기 때문에 lazy를 삭제 했습니다.
// lazy 제거
private let xmlDecoder: any XMLDecoding = XMLDecoder()
5. 마무리
만들면서 가장 많이 고민했던 부분은 구현보다 설계 결정이었습니다.
- subscript vs Codable
- class vs struct
- lazy의 사용
그 외에도
- 자동 타입 변환 여부
- 순서 보장에 대한 고민
등이 있었습니다.
단순해 보이는 XML 파서 하나를 만들면서도 Swift의 값 타입 동작 방식, lazy var의 내부 구조, Dictionary의 순서 보장 여부 같은 개념들을 다시 한번 짚어볼 수 있었습니다.
'programming > Swift(iOS)' 카테고리의 다른 글
| [Swift/iOS] Jailbreak Detection(탈옥 감지)의 필요성 (1) | 2026.04.25 |
|---|---|
| [macOS] .pkg(.dmg) 배포를 위한 서명 및 공증(Notarization) (0) | 2026.04.11 |
| [Swift/iOS] 토큰 데이터를 KeyChain에 안전하게 저장하기 (0) | 2023.05.06 |
| [Swift/iOS] 애플 로그인 후 반환 값 ASAuthorizationAppleIDCredential From 공식문서 (0) | 2023.05.06 |
| [Swift/iOS] CoreData 사용하기(4) - 속성값 유무에 따른 데이터 저장, 속성의 optional 타입 (0) | 2023.05.06 |