XMLParser로 XML 파싱하기

Zeto·2023년 3월 2일
0

Swift_Framework

목록 보기
5/6

이번에 공공기관의 API를 이용해 사이드 프로젝트를 진행하던 중, XML 형식의 데이터를 처음으로 마주하게 되었다. 보통 서버와 API 통신을 통해 받아오는 데이터는 항상 JSON 형식이었기에 처음으로 XML을 다뤄야 하는 상황에서 적잖이 당황할 수 밖에 없었다.
그도그럴 것이 JSON은 Codable을 채택한 Struct를 이용해서 손쉽게 변환 작업을 할 수 있었는데, XML은 이런 방식의 사용이 불가하여 일일히 값을 할당해서 데이터 변환을 해줘야 하기 때문이었다. 그래도 Foundation 프레임워크에서 파싱하는 기능은 구현해놓았다는 점은 위안이었다.

그래서 오늘은 처음으로 다뤄본 XML 파싱을 정리해보고자 한다.

XMLParser를 활용한 데이터 파싱

1. 개요

먼저 XML을 파싱하기 위해서는 XMLParser라는 객체와 파싱 중에 태그 분기나 값 할당 등의 작업을 진행해줄 XMLParserDelegate에 대한 채택이 필요하다.

let parser: XMLParser = .init(data: data)
parser.delegate = self
parser.parse()

XMLParser를 생성한 뒤, XMLParserDelegate의 위임자를 설정하고 parse() 메서드를 통해 파싱을 진행하도록 해준다. 예시에서는 XMLParser의 이니셜라이저에 API 통신으로 받아온 데이터를 넣어주고 있는데, URL을 직접 넣어주는 형태로도 생성이 가능하다.

func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String : String] = [:]) { }

func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) { }

func parser(_ parser: XMLParser, foundCharacters string: String) { }

func parserDidEndDocument(_ parser: XMLParser) { }

XMLParserDelegate의 대표적인 메서드이다. didStartElement는 이름 그대로 XML의 태그가 시작될 때, 호출이 되며 해당 태그의 이름을 확인할 수가 있다. 당연히 didEndElement는 이와 반대로 작동할 거라 예측할 수 있다. foundCharacters는 앞서 언급한 메서드들의 중간 순서로 호출되는데 현재 파싱 중인 XML 태그의 값을 이 메서드에서 알 수 있다. 즉, didStartElement, didEndElement에서 알 수 있는 것은 XML 태그의 Key 값이고 foundCharacters에서는 해당 태그의 Value 값을 확인할 수 있다.

하나의 태그를 만날 때마다 위의 순서가 계속 반복이 되고, 이에 맞춰서 사용자가 데이터 변환을 해주면 된다. 기본 개념 자체는 이정도가 끝이니, 이를 활용한 예시로 좀 더 깔끔하게 사용할 수 있는 방법을 정리해보고자 한다.

2. 구현 예시 (초기)

XML을 파싱할 때, 가장 골치 아팠던 점이 태그의 Key 값과 Value 값을 동시에 확인할 수 없다는 점이었다. 그렇기에 어떤 Value 값을 변환하고자할 때, 해당 Vlaue 값의 Key 값이 무엇인지 알 수 있게 장치를 남겨주어야 했다.

enum TagType {
	case none
    case id
    case name
    case xCoor
    case yCoor
}

private let apiType: APIParsingTask
private var parsingDTO: StationDTO?
private var dtoArray: [StationDTO]?
private var tagType: TagType = .none

init(from data: Data, with apiType: APIParsingTask) {
	self.apiType = apiType
    super.init()

    let parser: XMLParser = .init(data: data)
    parser.delegate = self
    parser.parse()
}

결국 해당 객체 내부에 각 태그의 Key 값이 무엇인지 구분할 수 있도록 Enum을 구현해주었고, 이를 할당해줄 수 있는 변수까지 선언해주었다.

func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String : String] = [:]) {
	if elementName == apiType.bodyTag {
    	self.dtoArray = []

   	} else if elementName == apiType.stationsListTag {
		self.parsingDTO = .init(id: "", name: "", xCoordinate: 0.0, yCoordinate: 0.0)

	} else if elementName == apiType.stationIdTag {
		self.tagType = .id

   	} else if elementName == apiType.stationNameTag {
		self.tagType = .name

	} else if elementName == apiType.stationXCoorTag {
		self.tagType = .xCoor

	} else if elementName == apiType.stationYCoorTag {
		self.tagType = .yCoor
	}
}

func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) {
	if elementName == apiType.bodyTag {
		guard let dtoArray else { return }

        self.parsedDTORelay.accept(dtoArray)
        self.dtoArray = nil

	} else if elementName == apiType.stationsListTag {
		guard let parsingDTO else { return }

        self.dtoArray?.append(parsingDTO)
        self.parsingDTO = nil
        self.tagType = .none
	}
}

func parser(_ parser: XMLParser, foundCharacters string: String) {
	guard let _ = parsingDTO else { return }

    switch tagType {
    case .none:
    	return

	case .id:
    	self.parsingDTO?.id = string

	case .name:
    	self.parsingDTO?.name = string

	case .xCoor:
    	guard let coor = Float(string) else { return }
        self.parsingDTO?.xCoordinate = coor

	case .yCoor:
    	guard let coor = Float(string) else { return }
        self.parsingDTO?.yCoordinate = coor
	}
}

이렇게 파싱을 진행하면서 원하는 Key 값의 파싱이 시작될 때, Enum의 값을 변경해주고 이를 토대로 foundCharacters에서 분기 처리를 통해 적절한 값을 변환할 데이터 구조에 넣어주는 방식으로 구현했다.
별다른 문제 없이 작동은 하지만 좀 더 로직을 간결하게 가지면서 파싱에 필요한 조건들을 최대한 가지지 않도록 할 수 있는 방법이 없을까라는 고민이 생겼다. 추가적으로 일일히 데이터 타입을 생성해서 값을 넣어주는 방식말고 JSON도 함께 활용할 수 없을까 싶어 이리저리 찾아보고 만져보았다.

3. 구현 예시 (Codable 활용)

따지고 보면 XML의 데이터에서 보통 필요한 Key-Value 값을 가지고 있는 태그는 itemList와 같은 하나의 태그가 시작되면서 파싱되기 시작한다. 따라서 해당 태그의 값을 주입받고 이를 기준으로 파싱 작업을 진행하도록 하면 괜찮지 않을까라는 생각이 들었다.

private let itemsTag: String
    
// 본격적으로 Item 관련 태그 시작점 판단 여부
private var canParsingString: Bool = false
    
private var currentKey: String = ""
private var currentValue: String = ""
    
private var jsonObject: [String: Any] = [:]
private var itemArray: [[String: Any]] = []
private var itemObject: [String: Any] = [:]
    
let jsonObjectSubject: PublishSubject<[String: Any]> = .init()
    
init(with itemsTag: String) {
	self.itemsTag = itemsTag
        
    super.init()
}
    
func parse(from data: Data) {
	let parser: XMLParser = .init(data: data)
    parser.delegate = self
    parser.parse()
}

이와 함께 해당 객체에서 데이터 변환 작업을 진행하는 것이 아닌, JSON 객체와 동일한 형태로 만들어서 리턴해주도록 구현했다. 그러다보니 하나의 Item이 될 태그의 내부 태그(즉, 프로퍼티가 될 요소들)를 딕셔너리로 만들고, 이를 배열로 담아 마지막으로 파싱 작업이 시작된 태그를 Key로 담아 딕셔너리 형태를 다시 만들어주는 다소 복잡한 작업이 필요했다.

func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String : String] = [:]) {
	if itemsTag == elementName {
    	self.canParsingString = true
            
        return
	}
        
	guard self.canParsingString else { return }
        
    self.currentKey = elementName
}
    
func parser(_ parser: XMLParser, foundCharacters string: String) {
	guard self.canParsingString else { return }
        
    self.currentValue = string
}
    
func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) {
	if self.canParsingString {
    	self.itemObject.updateValue(self.currentValue, forKey: self.currentKey)
	}
        
    guard self.itemsTag == elementName else { return }
        
    self.canParsingString = false
    self.itemArray.append(self.itemObject)
    self.itemObject.removeAll()
}
    
func parserDidEndDocument(_ parser: XMLParser) {
	guard !(itemArray.isEmpty) else { return }
        
    self.jsonObject.updateValue(self.itemArray, forKey: self.itemsTag)
    self.jsonObjectSubject.onNext(self.jsonObject)
}

주입받은 태그 값을 기준으로 didStartElement에서 파싱을 시작할 지에 대한 여부를 판단하고 파싱이 필요한 Key 값일 경우에는 할당을 해준다. 이후 foundCharacters에서도 이를 통해 Value 값을 할당하거나 넘어가도록 해주었다. 이후 didEndElement에서 딕셔너리나 배열에 추가해주고, 모든 파싱이 끝나면 parserDidEndDocument에서 최종값을 전달해주는 형태가 된다.

이렇게 수정을 해주면 XML 형식이 매우 복잡하고 통일성이 없는 경우를 제외하곤 어떤 프로젝트에서도 사용할 수 있게 된다. 뿐만 아니라 JSON 형태의 Object를 리턴해주기 때문에 이를 Data 타입으로 바꾸고 디코딩을 해주면 손쉽게 원하는 DTO를 뽑아낼 수도 있다.

4. 마무리

XML 파싱을 작업하면서 중간중간 외부 라이브러리를 쓸까 고민도 많이 되었다. 하지만 이렇게 직접 구현도 하고, 개선도 해보면서 전반적인 XML 파싱의 방식을 더 잘 알게 되었다. 공공기관 데이터를 사용하지 않으면 거의 볼 일이 없지 않을까 싶지만, 사람 일은 또 모르는 것이니 이런 기회가 있을 때 내장 라이브러리를 활용해서 작업해보는 방식을 먼저 시도해보는 것이 좋을 거 같다.

profile
중2병도 iOS가 하고싶어

0개의 댓글