우리는 서버와 통신을 해서든 로컬에서든 종종 데이터를 받아와 사용해야할 때가 생긴다. 그럴 경우 보통 JSON 데이터를 많이 접하게 되는데 요녀석이 또 swift에서 바로 사용할 수 있는 타입이 아니기 때문에 어쩔 수 없이 decoding을 통해 유효한 타입으로 변환해주어야 한다.
다만 decoding은 말 그대로 데이터를 유효한 타입으로 변환해주는 역할만 하기 때문에 어느 한 타입에 종속적인 방식으로 사용하기 보다는 범용적으로 사용할 수 있도록 해주는 것이 좋다.
따라서 오늘은 제네릭을 활용하여 decode/encode의 코드를 좀 더 범용적으로 작성할 수 있는 방식을 내 멋대로 써내려 가보고자 한다.
Custom JSONConverter
이번에는 구글의 배너 이미지를 담고 있는 doodle이라는 JSON 파일을 로컬에 저장하고 이를 디코딩하는 형태로 구현해보고자 한다.
물론 직접 서버에서 데이터를 받아와서 화면에 출력하는 일련의 과정들까지 전부 해보는 것이 좋을 듯 하지만, JSONConverter를 범용적으로 작성하는 것이 중점이니 단순히 디코딩 후에 올바르게 타입 변환이 이뤄졌는지만 확인할 수 있도록 작성해보려 한다.
위와 같은 JSON 파일이 있다면, 다음으로 우리가 해야할 일은 무엇일까? 당연히 프로퍼티명과 타입이 일치하는 swift의 데이터 타입을 만들어야 될 것이다.
struct Doodle: Codable, CustomStringConvertible{
var description: String{
return "Title: \(self.title), URL: \(self.image), Date: \(self.date)"
}
let title: String
let image: URL
let date: String
}
JSON파일과 동일하게 title / image / date라는 프로퍼티 명을 작성하고, 각각의 타입을 String과 URL 등으로 적절하게 선언한 Doodle 모델을 작성하였다.
우리는 적절하게 디코딩이 이뤄졌는지에 대한 출력 화면을 각 프로퍼티의 description을 활용할 예정이므로 CustomStringConvertible 또한 채택해주었다.
이제 디코딩해줄 데이터 타입도 만들어겠다 슬슬 커스텀 JSONConverter를 작성할 타이밍!이라고 생각이 들지만, 천리길도 한 걸음이라고 먼저 우리가 기본적으로 사용하는 디코딩 형태로 구현해보고자 한다.
final class JsonConverter{
static func decodeJsonArray(data: Data) -> [Doodle]?{
do{
let result = try JSONDecoder().decode([Doodle].self, from: data)
return result
} catch{
return nil
}
}
}
WOW!!
static 함수로서 어느 곳에서도 손쉽게 부를 수 있고 그저 Data 타입만 틱 던져주면 알아서 디코딩해주는 범용적인 JSONConverter가 완성...이 되면 좋겠지만 아쉽게도 해당 함수는 오로지! Doodle 배열만 뱉어내는 2% 아쉬운 범용성을 지닌 함수일 뿐이다.
물론 여기까지도 충분히 사용하는 데에 있어 불편함은 없겠지만 우리가 어떤 민족인가. 시작했으면 끝을 봐야되지 않겠는가. 따라서 해당 함수를 사용할 때, 디코딩되는 타입을 마음대로 지정할 수 있도록 다음 스텝을 밟아보도록 하자.
그렇다면 여기서 어떻게 타입에 자유로운 디코딩 함수로 만들어줄 것인가에 대한 고민이 있을 수 있다. 어찌됐든 우리는 decoder에 정확한 타입을 보내줘야 하는데... 어? 그럼 Any를 쓰면 되지 않을까?
그럴 수 있다면 참 좋을 뻔 했지만 애석하게도 Any는 Codable하지 않기에 사용할 경우 무조건 컴파일 에러가 뜨게 된다.
하지만 우리의 swift는 이런 고민을 하는 우리에게 아주 유용한 기능을 제공해준다. 바로 제네릭!!
타입에 의존하지 않게 해주고 코드의 유연성을 높여주는 기능으로 swift의 표전 라이브러리의 대부분이 제네릭으로 선언되어 있다.
따라서 우리는 마음 편하게 제네릭을 활용하여 부족했던 2%를 채워주면 된다.
final class JsonConverter{
static func decodeJsonArray<T: Codable>(data: Data) -> [T]?{
do{
let result = try JSONDecoder().decode([T].self, from: data)
return result
} catch{
return nil
}
}
}
이렇게 제네릭을 사용하니 어떠한 컴파일 에러 없이, 한 가지 타입에 의존하지 않고 디코딩을 할 수 있는 코드가 완성됐다.
그렇다면 이제는 적절하게 디코딩이 되는지를 확인할 수 있도록 간단한 View를 아래에 작성해보았다.
class ViewController: UIViewController {
var doodles: [Doodle]!
var uiLabel: UILabel!
override func viewDidLoad() {
super.viewDidLoad()
convertData()
configureLabel()
}
func convertData(){
guard let dataURL = Bundle.main.url(forResource: "doodle", withExtension: "json") else { return }
do{
let data = try Data(contentsOf: dataURL)
doodles = JsonConverter.decodeJsonArray(data: data)
} catch{
return
}
}
func configureLabel(){
self.uiLabel = UILabel(frame: CGRect(x: 0, y: 0, width: self.view.frame.width, height: self.view.frame.height))
uiLabel.textColor = .black
uiLabel.numberOfLines = 0
uiLabel.text = doodles[0].description
self.view.addSubview(uiLabel)
}
}
여기서 주목할 부분은 바로 우리가 만들어준 커스텀 JsonConverter를 호출한 부분이다. 이미 doodles를 [Doodle]로 타입 선언을 한 터라, 아무런 컴파일 에러 없이 깔끔하게 실행이 가능하다.
다만 해당 코드를 호출할 때 유의할 점은 어떤 타입인지 해당 메서드가 알고 있어야 하기 때문에 타입 추론 형태로 리턴 받을 프로퍼티를 생성하면 무조건 컴파일 에러가 나게 된다. 따라서 무조건 리턴받을 프로퍼티에 타입을 선언해주어야 한다.
이처럼 깔끔하게 JSON파일을 적절한 타입으로 변환해서 출력해준다. 위와 같이 제네릭을 사용하게 되면 높은 범용성을 가진 JSONConverter를 만들 수 있으니, 이를 활용하여 인코딩 코드도 개인적으로 작성해보길 추천드린다.
여담으로 decode의 매개변수에 선언한 타입 형식은 범용적으로 작성하기 어려워 혹여 단일 타입 형태의 리턴이 필요할 경우(서버와 통신 후, response 데이터 같이)에는 단일 타입용의 디코드 로직을 추가로 작성할 필요가 있다.