참조
https://betterprogramming.pub/parsing-in-swift-a-dto-based-approach-5edca55eb57a
Codable 방식과 DTO 방식의 비교로 글이 주로 작성되었슴니다.
위 글을 보고 번역한 글. 자세한 내용은 위 굴 참 조 봐람
만약 트위터데이터를 JSON으로 받았다고 할 때
let json = """
{
"name": "Luis",
"twitter": "@luisrecuenco"
}
"""
// 단순히 아래처럼 접근할 수 도 있슴니다
struct Person: Decodable {
var name: String
var twitter: String
}
// 하지만 트위터라는 타입이 있다면 더 좋지 않을가요 ?
struct Person: Decodable {
var name: String
var twitter: Twitter
}
struct Twitter: Decodable {
var handler: String
}
// 하지만 위 코드는 파싱되지 않슴다 아래처럼 바꾸면 댐다
struct Twitter: RawRepresentable, Decodable {
var rawValue: String
// 그리고 여기서 유효성 검사도 가능 !
// @로 시작하지 않는다면 실패함니다
init?(rawValue: String) {
guard rawValue.first == "@" else { return nil }
self.rawValue = rawValue
}
}
만약 실패한다면(유효하지 않은 트위터 핸들러를 받는다던지) 이에 맞는 처리도 해줘야 함니다
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
name = try container.decode(String.self, forKey: .name)
twitter = try container.decodeIfPresent(String.self, forKey: .twitter).flatMap(Twitter.init)
}
이러한 프로세스는 Codable
에서 많이 발생합니다.
도메인을 Codable
과 결합하면 컴파일러가 아무 말도 하지 않고 기본 디코딩을 손상시킬 가능성이 매우 높습니다.(이러한 문제를 포착하기 위해 테스트에 의존해야 함)
이러한 새로윤 유형은 Decodable
을 준수하는 한 컴파일러가 새로운 디코딩 마법을 합성하게 되어 ㄹ너타임에 충돌이 발생할 가능성이 높습니다.
init(from decoder:)
를 구현하면 해결되지만(이제 컴파일러가 모든 프로퍼티를 올바르게 설정하도록 강제함) 때로는 스키마 유형과 도메인 유형을 적절히 분리하는 것이 더 좋습니다.
이제 다른 대안을 살펴봅시다. Person.DTO
가 JSON과 일치하는 스키마 유형인 반면 Person, Twitter
는 일반 구조체입니다.
struct Person {
var name: String
var twitter: Twitter?
}
struct Twitter {
var handler: String
init?(handler: String) {
guard handler.first == "@" else { return nil }
self.handler = handler
}
}
extension Person {
struct DTO: Decodable {
var name: String
var twitter: String?
}
init(from dto: DTO) {
name = dto.name
twitter = dto.twitter.flatMap(Twitter.init)
}
}
도메인의 모양을 변경하면 컴파일러는 우리가 원하는 대로 init(from dto:)
함수를 강제로 업데이트 합니다. Person.DTO
구조가 변경되지 않는 한(JSON 스키마를 유지하는 한)문제가 없으며, 컴파일러가 리팩토링을 도와줄 것입니다.
하지만 DTO를 사용하는 것이 항상 더 나은 것은 아닙니다. 항상 그렇듯이 상황에 따라 다릅니다. 따라서 두 가지 접근 방식을 좀 더 자세히 분석해 보겠습니다.
목적은 다음 JSON을 Beer
유형으로의 변환입니다.
{
"id": 123,
"name": "Endeavor",
"brewery": {
"id": "sa001",
"name": "Saint arnold"
}
}
struct Beer {
var id: Int
var name: String
var breweryId: String
var breweryName: String
}
먼저 Codable
방식을 살펴보겠습니다.
let json = """
{
"id": 123,
"name": "Endeavor",
"brewery": {
"id": "sa001",
"name": "Saint arnold"
}
}
""".data(using: .utf8)!
struct Beer: Decodable {
var id: Int
var name: String
var breweryId: String
var breweryName: String
enum CodingKeys: String, CodingKey {
case id
case name
case brewery
}
enum BreweryCodingKeys: String, CodingKey {
case id
case name
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(Int.self, forKey: .id)
name = try container.decode(String.self, forKey: .name)
let breweryContainer = try container.nestedContainer(keyedBy: BreweryCodingKeys.self, forKey: .brewery)
breweryId = try breweryContainer.decode(String.self, forKey: .id)
breweryName = try breweryContainer.decode(String.self, forKey: .name)
}
}
let beer = try! JSONDecoder().decode(Beer.self, from: json)
dump(beer)
DTO 접근 방식을 살펴보기 전에 작업을 단순화하기 위한 코드를 살펴보겠습니다. DTO에서 디코딩할 수 있는 객체의 모양과 이를 쉽게 디코딩하는 방법을 정의하겠슴니다.
protocol DecodableFromDTO {
associatedtype DTO: Decodable
init(from dto: DTO) throws
}
// 아래 코드 이해가 좀 힘들었는데
// T가 결국 init임. 인자에 디코딩해서 넣는 것
// return 문은 생략 ~ 한 줄만 있기 때문 ~
extension JSONDecoder {
func decode<T>(_ type: T.Type, from data: Data) throws -> T where T : DecodableFromDTO {
try T(from: decode(T.DTO.self, from: data))
}
}
위 코드를 통해 다음을 수행할 수 있습니다.
struct Beer {
var id: Int
var name: String
var breweryId: String
var breweryName: String
}
extension Beer: DecodableFromDTO {
struct DTO: Decodable {
struct Brewery: Decodable {
var id: String
var name: String
}
var id: Int
var name: String
var brewery: Brewery
}
init(from dto: DTO) throws {
id = dto.id
name = dto.name
breweryId = dto.brewery.id
breweryName = dto.brewery.name
}
}
let beer = try! JSONDecoder().decode(Beer.self, from: json)
dump(beer)
이제 위에 Codable
방식과 DTO
방식을 비교하겠습니다. SNR(데이터 전송시 잡음? 오류를 유발하는 정도를 나타내는 수치인듯합니다. 정확한지는 잘 모르게슴다,,)은 두 방식이 거의 동일하였습니다.
다음 살펴볼 점은 변경사항이 생겼을 때입니다. JSON/모델이 변경되면 어떤 곳들을 업데이트 해야 할까요 ?
CodingKeys
와 init(from decoder:) throw
를 업데이트 해야 합니다.init(fromdto:) throw
를 업데이트합니다.이도 역시 비슷합니다. 따라서 다음과 같은 해석을 할 수 있습니다.
어떤 코드가 더 간단한지 결정하는 것은 상당히 주관적입니다. 어떤 사람들은 init(from decoder:) throw
안에 구문 분석 코드를 매우 자연스럽게 작성할 수 있고, 그런 사람들에게는 Codable
컨테이너는 자연스러운(second-nature, 몸에 벤 일, 익숙한 것, 자연스러운 것 이라네요) 것입니다.
또 다른 사람들은 DTO 방식이 더 간단한 접근 방식이라고 생각할 수 있습니다. 이 글의 작성자의 경우 DTO 접근방식을 선호하는데 이유로는, 스키마 유형을 도메인 유형에서 분리하면 도메인을 더 안전하고 빠르게 발전시킬 수 있다고 생각해서 랍니다.
DTO 방식을 제대로 본 건 처음인데 이것도 꽤 좋아보이네요 !
다른 상황으로 넘어가겠습니다.
이제 JSON을 두 가지 모델로 나누는 상황입니다.
{
"id": 123,
"name": "Endeavor",
"brewery_id": "sa001",
"brewery_name": "Saint Arnold"
}
struct Beer {
var id: Int
var name: String
var brewery: Brewery
}
struct Brewery {
var id: String
var name: String
}
Codable
방식
struct Beer: Decodable {
var id: Int
var name: String
var brewery: Brewery
enum CodingKeys: String, CodingKey {
case id
case name
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(Int.self, forKey: .id)
name = try container.decode(String.self, forKey: .name)
brewery = try Brewery(from: decoder)
}
}
struct Brewery: Decodable {
var id: String
var name: String
enum CodingKeys: String, CodingKey {
case id = "brewery_id"
case name = "brewery_name"
}
}
let beer = try! JSONDecoder().decode(Beer.self, from: json)
dump(beer)
DTO 방식
struct Beer {
var id: Int
var name: String
var brewery: Brewery
}
struct Brewery {
var id: String
var name: String
}
extension Beer: DecodableFromDTO {
struct DTO: Decodable {
var id: Int
var name: String
var brewery_id: String
var brewery_name: String
}
init(from dto: DTO) {
id = dto.id
name = dto.name
brewery = Brewery(id: dto.brewery_id, name: dto.brewery_name)
}
}
let beer = try! JSONDecoder().decode(Beer.self, from: json)
dump(beer)
DTO 접근 방식이 좀 더 나아보입니다. 하지만 장점은 미미한편입니다.
이번에는 다음 JSON을 Feed
유형으로 변환하는 것입니다.
{
"items": [
{
"type": "text",
"id": 55,
"text": "This is a text feed item"
},
{
"type": "image",
"id": 56,
"image_url": "http://placekitten.com/200/300"
}
]
}
struct Feed {
let items: [FeedItem]
}
class FeedItem {
let id: Int
}
class TextFeedItem: FeedItem {
let text: String
}
class ImageFeedItem: FeedItem {
let imageUrl: URL
}
Codable
방식
struct AnyCodingKey: CodingKey {
let stringValue: String
init?(stringValue: String) {
self.stringValue = stringValue
}
var intValue: Int?
init?(intValue: Int) {
fatalError()
}
}
extension AnyCodingKey: ExpressibleByStringLiteral {
init(stringLiteral value: StringLiteralType) {
self.init(stringValue: value)!
}
}
class FeedItem: Decodable {
let id: Int
init(id: Int) {
self.id = id
}
}
class TextFeedItem: FeedItem {
let text: String
init(text: String, id: Int) {
self.text = text
super.init(id: id)
}
enum CodingKeys: String, CodingKey {
case text
}
required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
text = try container.decode(String.self, forKey: .text)
try super.init(from: decoder)
}
}
class ImageFeedItem: FeedItem {
let url: URL
init(url: URL, id: Int) {
self.url = url
super.init(id: id)
}
enum CodingKeys: String, CodingKey {
case imageUrl = "image_url"
}
required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
url = try container.decode(URL.self, forKey: .imageUrl)
try super.init(from: decoder)
}
}
struct Feed: Decodable {
let items: [FeedItem]
enum CodingKeys: String, CodingKey {
case items
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
var itemsContainer = try container.nestedUnkeyedContainer(forKey: .items)
var itemsContainerCopy = itemsContainer
var items: [FeedItem] = []
while !itemsContainer.isAtEnd {
let typeContainer = try itemsContainer.nestedContainer(keyedBy: AnyCodingKey.self)
let type = try typeContainer.decode(String.self, forKey: "type")
let feedItem: FeedItem
switch type {
case "text":
feedItem = try itemsContainerCopy.decode(TextFeedItem.self)
case "image":
feedItem = try itemsContainerCopy.decode(ImageFeedItem.self)
default:
fatalError()
}
items.append(feedItem)
}
self.items = items
}
}
let feed = try! JSONDecoder().decode(Feed.self, from: json)
dump(feed)
이 경우에는 DTO 방식이 더 까다롭습니다. 이전 예제에서는 JSON 스키마가 명확했고, 해당 스키마와 일치하는 올바른 DTO 유형을 만들 수 있었습니다.
하지만 이제 스키마는 유형 키 값에 따라 달라집니다. type
이 텍스트인지 이미지인지에 따라 다릅니다. 이런 동적인 특성을 DTO 방식에 맞추기 위해 이전 글에서 구현한 JSON 유형을 활용하려고 합니다. 링크는 다음과 같습니다. (다음 링크 참조, 해당 코드는 글 맨위 링크 참조)
https://medium.com/jobandtalenteng/statically-typed-json-payload-in-swift-bd193a9e8cf2
class FeedItem {
let id: Int
init(id: Int) {
self.id = id
}
}
class TextFeedItem: FeedItem {
let text: String
init(text: String, id: Int) {
self.text = text
super.init(id: id)
}
}
class ImageFeedItem: FeedItem {
let url: URL
init(url: URL, id: Int) {
self.url = url
super.init(id: id)
}
}
struct Feed {
let items: [FeedItem]
}
extension Feed: DecodableFromDTO {
struct DTO: Decodable {
var items: [JSON]
struct Text: Decodable {
var id: Int
var text: String
}
struct Image: Decodable {
var id: Int
var image_url: URL
}
struct DecodingError: Error {}
}
init(from dto: DTO) throws {
items = try dto.items.map { json in
guard case .dictionary(let item) = json, case .string(let type) = item["type"] else { throw DTO.DecodingError() }
switch type {
case "text":
let textDTO = try json.decode(DTO.Text.self)
return TextFeedItem(text: textDTO.text, id: textDTO.id)
case "image":
let imageDTO = try json.decode(DTO.Image.self)
return ImageFeedItem(url: imageDTO.image_url, id: imageDTO.id)
default:
throw DTO.DecodingError()
}
}
}
}
let feed = try! JSONDecoder().decode(Feed.self, from: json)
dump(feed)
변경사항이 있을시,
Feed.DTO.Text, Feed.DTO.Image, init(from dto:) throw
업데이트 하면 됩니다.위 경우 DTO의 장점은 Codable
의 생성자 코드보다 훨씬 간단하다는 점입니다.
DecodableFromDTO
객체를 Decodable
객체와 결합해야 하는 경우 프로퍼티 래퍼를 사용할 수 있습니다.
let json = """
{
"name": "Luis",
"beer": {
"id": 123,
"name": "Endeavor",
"brewery": {
"id": "sa001",
"name": "Saint arnold"
}
}
}
""".data(using: .utf8)!
@propertyWrapper
struct DecodeFromDTO<T>: Decodable where T: DecodableFromDTO {
var wrappedValue: T
init(from decoder: Decoder) throws {
// T.DTO 도 결국 init~ 인스턴스를 생성해줌
wrappedValue = try T(from: T.DTO(from: decoder))
}
}
struct Person: Decodable {
var name: String
@DecodeFromDTO var beer: Beer
}
let person = try! JSONDecoder().decode(Person.self, from: json)
dump(person)
어떤 접근 방식이든 SNR이나 변경사항이 있을 때 크게 다르지는 않지만, 스키마 유형을 도메인 유형에서 분리하는 것이 더 잘 확장되고 리팩토링을 용이하게 하는 좋은 방법이라고 작성자는 생각한다고 합니다.
또한 JSON 스키마와 일치하는 구조(Quicktype 같은 도구로 자동화가능)를 가진 DTO 접근 방식의 선언적 특성은 복잡한 명령형 구문 분석 코드보다 더 좋게 느껴집니다. 하지만 이 또한 도구일 뿐이므로 작업에 적합한 도구를 사용해야 합니다.
코드가 되게 많았습니다만, 이번엔 오히려 많아서 비교할 수 있고 좀 더 이해가 잘 되었던 것 같습니다. 중간에 DTO을 활용할 때 JSON을 커스텀해서 사용하였는데 이를 나중에 더 살펴봐야 할 듯 합니다.
JSON을 디코딩하여 실제 프로젝트에 써본 경험이 많이 없어서 유익했습니다. 웬만한 JSON 형태는 이 글에서 본 방식으로 디코딩할 수 있을거라고 생각합니다. 다음 프로젝트 때 DTO를 사용하는 것도 생각을 해봐야겠네요 장점이 많아보여서 좋아보임니당.