JSON 데이터, DTO 기반 접근방식

rbw·2023년 3월 2일
0

TIL

목록 보기
73/97

참조

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를 사용하는 것이 항상 더 나은 것은 아닙니다. 항상 그렇듯이 상황에 따라 다릅니다. 따라서 두 가지 접근 방식을 좀 더 자세히 분석해 보겠습니다.

Comparing the Codable vs DTO approaches

  1. Nested JSON to flatten type

목적은 다음 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/모델이 변경되면 어떤 곳들을 업데이트 해야 할까요 ?

  • Codable: CodingKeysinit(from decoder:) throw를 업데이트 해야 합니다.
  • DTO: DTO 구조체와 init(fromdto:) throw를 업데이트합니다.

이도 역시 비슷합니다. 따라서 다음과 같은 해석을 할 수 있습니다.

  • 따라서 DTO 접근 방식은 실제로 많은 이점을 얻지 못하기에 그만한 가치가 없습니다.
  • 일반적으로 보일러플레이트, 추가 DTO 유형 등을 추가 하는 것으로 간주되는 DTO 접근 방식은 실제로 코더블 방식보다 나쁘진 않습니다.

어떤 코드가 더 간단한지 결정하는 것은 상당히 주관적입니다. 어떤 사람들은 init(from decoder:) throw 안에 구문 분석 코드를 매우 자연스럽게 작성할 수 있고, 그런 사람들에게는 Codable 컨테이너는 자연스러운(second-nature, 몸에 벤 일, 익숙한 것, 자연스러운 것 이라네요) 것입니다.

또 다른 사람들은 DTO 방식이 더 간단한 접근 방식이라고 생각할 수 있습니다. 이 글의 작성자의 경우 DTO 접근방식을 선호하는데 이유로는, 스키마 유형을 도메인 유형에서 분리하면 도메인을 더 안전하고 빠르게 발전시킬 수 있다고 생각해서 랍니다.

DTO 방식을 제대로 본 건 처음인데 이것도 꽤 좋아보이네요 !

다른 상황으로 넘어가겠습니다.

  1. Flat JSON to nested type

이제 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 접근 방식이 좀 더 나아보입니다. 하지만 장점은 미미한편입니다.

  1. Heterogeneous arrays

이번에는 다음 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)

변경사항이 있을시,

  • Codable: 텍스트, 이미지 그리고 피드의 코딩키를 업데이트하고 init 업데이트,(피드 아이템도 포함될 수 있습니다)
  • DTO: 새로 정의한 JSON 유형은 모든 종류의 모양에 적응되므로, Feed.DTO.Text, Feed.DTO.Image, init(from dto:) throw 업데이트 하면 됩니다.

위 경우 DTO의 장점은 Codable의 생성자 코드보다 훨씬 간단하다는 점입니다.

Mix and match

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를 사용하는 것도 생각을 해봐야겠네요 장점이 많아보여서 좋아보임니당.

profile
hi there 👋

0개의 댓글