[Swift] JSON 디코딩 작업, 어떻게 간소화 해볼까?

Ryan (Geonhee) Son·2021년 4월 9일
1

오늘의 고민

목록 보기
4/10

결론: 결국 결론은 정반합! 1 안도 2 안도 아닌 제 3안의 탄생! 제네릭 메서드로 구현하였습니다..!

프로젝트를 수행하면서 JSON 데이터의 디코딩 작업이 아래와 같이 두 단계로 이루어져 있어서 간소화하고 싶었어요.

  1. NSDataAsset 타입의 인스턴스를 생성하여 JSON 파일의 데이터를 불러온다.
  2. JSONDecoder 타입의 decode(_:from:) 메서드를 NSDataAsset 인스턴스의 데이터를 디코딩한다.

저는 1 번 작업을 생략(은닉화)해서 JSON 데이터를 디코딩하고 싶어하는 사용자들이 NSDataAsset이라는 타입을 몰라도 메서드 이용이 가능하게끔 만들고 싶었어요. 그래서 2 가지 안을 준비해봤답니다.

  • 1 안. 제네릭 타입을 지원하는 타입 제공
    • 장점: 한 타입 안에서 여러 타입으로의 디코딩 작업이 가능하다. (이해가 안된다면 프로토콜의 단점 참고)
    • 단점: 디코딩할 타입이 여러 개인 경우 여러 개의 인스턴스를 생성하여 사용하여야 하므로 이 작업이 번거로울 수 있다.
  • 2 안. associatedtype을 활용한 초기 구현 프로토콜 제공
    • 장점: 사용할 타입에서 채택하고 typealias만 지정해주면 인스턴스 생성 없이 바로 메서드 활용이 가능하다.
    • 단점: 채택한 타입 내부에서 typealias를 한 번만 선언할 수 있기 때문에 채택한 타입 안에서 한 타입으로의 디코딩만 지원할 수 있다.

1 안과 2 안을 코드를 통해 살펴보실까요?


1 안. 제네릭 타입을 지원하는 타입 제공

import UIKit

struct CustomJSONDecoder<T> where T: Decodable {
  private var decodingResult: T?
  
  public mutating func decode(jsonFileName: String) -> T? {
    let jsonDecoder = JSONDecoder()
    
    guard let jsonData: NSDataAsset = NSDataAsset(name: jsonFileName) else { return nil }
    
    do  {
      self.decodingResult = try jsonDecoder.decode(T.self, from: jsonData.data)
    } catch {
      print(error.localizedDescription)
    }
    
    return self.decodingResult
  }
}

CustomJSONDecoder 타입은 아래와 같이 사용해요.

func test_artworks_jsonDecoding() {
  let artworksJSONDecoder = CustomJSONDecoder<[Artwork]>()

  sutArtworks = artworksJSONDecoder.decode(jsonFileName: "items")
  
  for index in 0...(sutArtworks.count - 1) {
    XCTAssertNotEqual(sutArtworks[index].name, nil)
    XCTAssertNotEqual(sutArtworks[index].imageName, nil)
    XCTAssertNotEqual(sutArtworks[index].shortDescription, nil)
    XCTAssertNotEqual(sutArtworks[index].description, nil)
  }
}

미리 언급했던 장단점이 느껴지시나요? 계속해서 2 안을 보시겠습니다.


2안. associatedtype을 활용한 초기 구현 프로토콜 제공

import UIKit

protocol JSONDecodable {
  associatedtype T: Decodable
  
  func decode(jsonFileName: String) -> T?
}

extension JSONDecodable {
  func decode(jsonFileName: String) ->T? {
    var decodedResult: T?
    let jsonDecoder = JSONDecoder()
    guard let jsonData: NSDataAsset = NSDataAsset(name: jsonFileName) else { return nil }
    
    do  {
      decodedResult = try jsonDecoder.decode(T.self, from: jsonData.data)
    } catch {
      print(error.localizedDescription)
    }
    
    return decodedResult
  }
}

프로토콜이니 이 메서드를 사용하기를 원하는 타입에서 채택하면 바로 초기 구현된 decode(jsonFileName:)를 사용할 수 있을거에요. 사용하는 모습을 볼까요?

class Expo1900Tests: XCTestCase, JSONDecodable { // 프로토콜을 채택!
  typealias T = [Artwork]
// .. 중략 .. 

  func test_jsonDecodable_decode() {
    sutArtworks = decode(jsonFileName: "items")
  
    for index in 0...(sutArtworks.count - 1) {
      XCTAssertNotEqual(sutArtworks[index].name, nil)
      XCTAssertNotEqual(sutArtworks[index].imageName, nil)
      XCTAssertNotEqual(sutArtworks[index].shortDescription, nil)
      XCTAssertNotEqual(sutArtworks[index].description, nil)
    }
  }
}

그럼 저의 결론은요..!


3 안. 제네릭 메서드로 구현!

매 번 인스턴스를 생성할 때마다 타입을 정해주는 방식보다 메서드를 사용할 때 디코딩할 타입을 지정하는 것이 편의성 측면에서 더 낫다고 생각하여 아래 코드와 같이 제네릭 타입을 제네릭 메서드로 변경했습니다. 아울러 기존 JSONDecoder 타입의 decode(_:from:) 메서드의 에러 처리를 호출하는 쪽에서 함께 담당하는 형식으로 변경하여 테스트를 수행할 때 새로 작성한 메서드에서 에러를 검출할 수 있게끔 만들었어요.

/// Decodable 프로토콜을 준수하는 타입에 대해 타입 이름과 파일 이름 입력만으로 JSON 디코딩을 도와주는 메서드를 제공하는 타입.
class CustomJSONDecoder {
  /// 변환할 타입과 JSON 파일 이름을 전달인자로 받아 지정된 타입으로 디코딩 결과를 반환한다.
  /// - 전달인자에 유효하지 않은 JSON 파일을 입력할 경우 `ExpoAppError.invalidJSONFile` 에러를 던진다.
  /// - Parameter type: 변환할 타입. 모델 타입의 인스턴스를 원하면, `모델타입.self`로 작성한다.
  /// -  Parameter jsonFileName: JSON 파일 이름을 `String` 타입으로 작성한다.
  public func decode<Decoded>(to type: Decoded.Type,
                              from jsonFileName: String) throws -> Decoded? where Decoded: Decodable {
    var decodedResult: Decoded?
    let jsonDecoder = JSONDecoder()
    guard let jsonData: NSDataAsset = NSDataAsset(name: jsonFileName) else {
      throw ExpoAppError.invalidJSONFile
    }
    
    decodedResult = try jsonDecoder.decode(Decoded.self, from: jsonData.data)

    return decodedResult
  }
}

사용하는 모습도 보실까요?

func test_customJSONDecoder_decode() {
  let jsonDecoder = CustomJSONDecoder() // 이제 인스턴스는 한 번만 생성!

  // 한 번의 인스턴스 생성으로 여러 가지 타입 디코딩이 가능!
  XCTAssertNoThrow(try jsonDecoder.decode(to: ExpoIntroduction.self, from: "exposition_universelle_1900"))
  XCTAssertNoThrow(try jsonDecoder.decode(to: [Artwork].self, from: "items"))
}

func test_customJSONDecoder_decode_withInvalidJSONFile() {
  let jsonDecoder = CustomJSONDecoder()

  XCTAssertThrowsError(try jsonDecoder.decode(to: ExpoIntroduction.self, from: "invalidJSONFile")) { (error) in
    XCTAssertEqual(error as? ExpoAppError, ExpoAppError.invalidJSONFile)
  } // 유효하지 않은 파일 이름을 입력하면 에러를 던질텐데, 던지는 에러가 `ExpoAppError.invalidJSONFile`가 맞는지 검증!
}

훨씬 낫네요..! 에러 처리 방식만 다듬어주면 더 좋아질 것 같습니다!

profile
합리적인 해법 찾기를 좋아합니다.

0개의 댓글