맛동산이

Swift) Decodable, custom Decoder 복잡한 데이터 모델을 나누는 방법에 대해서 본문

앱/Swift

Swift) Decodable, custom Decoder 복잡한 데이터 모델을 나누는 방법에 대해서

진ddang 2024. 6. 6. 14:58

Decodable

Decoder는 JSON type으로 오는 데이터를 네이티브한 포멧과 매핑이 가능하도록 하는 프로토콜이다.

보통 Codable이 Encodable, Decodable 두개를 타입엘리어싱 하기 때문에 Codable만 채택해도 큰 문제는 없다.

CodingKey

실제로 RESTFUL API와 실제로 내부에서 사용하는 데이터의 네이밍이 다른경우가 많은데 이를 코딩키를 통해서 매핑해줄수 있다.

struct DecodingModel: Decoding {
	let userName: String
	let userAge: Int
	
   enum CodingKeys: String, CodingKey {
      case userName = "user_name"
      case userAge = "user_age"
   }
}

CustomDecoder

사실 이번 포스팅에서 가장 하고싶었던 이야기이기도 하다.

보편적으로는 Decoder를 직접 구현하지는 않게 되지만, 예를들어~~( 실제로 이번에 작업한 내용이다.)~~

실제로 디코더를 구현하게되면 다음과 같은 장점이 있다.

  • 복잡한 데이터 구조 처리 : 데이터의 일부를 확인해서 데이터 모델을 다른 타입으로 매핑해서 사용해야 하거나 다른 타입으로 타입캐스팅을 해주어야 하는 상황이 생길수 있다.
  • 형변환을 미리 처리 해줌 : 디코딩을 할때 타입을 변경해주거나, 처리를 해준 뒤 데이터를 내려주게 되어서 사용하는곳에서는 그러한 귀찮은 작업 없이 한번에 처리하기 때문에 로직적으로 분리된 코드를 작성할수 있다.
  • 데이터 유효성 체크 : 위와 동일할수 있지만, 서버에서 받은 데이터의 유효성을 미리 체크하고 문제를 체크 해서 에러를 내려주는 등의 작업을 사전으로 처리할수 있다.

이처럼 디코더를 직접 구현하는건 귀찮지만 필수적인 작업이 되거나 할때가 있기때문에 알아두면 굉장히 도움이 될수 있는 작업~!

실제로 예시 코드

struct ModelA: Decodable {
    let userName: String
    let userAge: Int
    
    enum CodingKeys: String, CodingKey {
        case userName = "user_name"
        case userAge = "user_age"
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.userName = try container.decode(String.self, forKey: .userName)
        self.userAge = try container.decode(Int.self, forKey: .userAge)
    }
}

기본적으로 디코더를 수동으로 커스텀하게 하면 다음과 같이 init() 함수를 통해서 생성해준다.

  • 옵셔널의 경우에는 decodeIfPresent라는 메소드를 사용하면 된다.
try container.decodeIfPresent(String, forKey: .userName)

이때 container라는 개념이 등장을 하는데, container에 대해서 알아보자.

Container

container는 JSON이나 다른 데이터 형식을 swift타입으로 디코딩 하는 과정에서 데이터를 구조화 하는 방식을 의미한다. (그냥 데이터를 올바르게 추출하기 위한 형태라고 생각하자.)

Container의 종류는 총 3개이다.

  • Keyed Container : JSON 객체와 같이 키-값으로 구성된 데이터를 처리할 때 사용한다. 보편적으로 우리가 CodingKey를 통해서 매핑한 값이 존재할때 decoder.container(keyedBy: ) 메소드를 통해서 해당 컨테이너에 접근할수 있게 한다.
  • Unkeyed Container : 순서가 있는 데이터에 접근하여 처리할때 사용한다. 즉 배열의 데이터라면 해당 컨테이너를 통해서 한개의 값들에 대해서 순서대로 접근할수 있도록 하고 해당 값들을 추출해서 decoding할때 용이하다.
  • Single Value Container : 단일 값을 디코딩할때 사용하며, JSON에서 단일 숫자나 문자열 값만이 있을때 굳이 Container를 찾아서 Keyed를 찾아 매핑할 필요 없도록 하는 편의성을 지닌다.

실제로 사용하는 구문을 보면

struct ModelA: Decodable {
    let userName: String
    let userAge: Int
    
    enum CodingKeys: String, CodingKey {
        case userName = "user_name"
        case userAge = "user_age"
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.userName = try container.decode(String.self, forKey: .userName)
        self.userAge = try container.decode(Int.self, forKey: .userAge)
    }
}

이렇게 최초에는 Codingkey를 매핑해주고, 해당 매핑된 container에서 한개씩 매핑하여 decding을 하고, 해당 값을 Model의 프로퍼티에 할당해주는 과정을 거친다.

nested, unNested Container

단순히 한개의 정보일때 저렇게 접근하지만

중첩된 컨테이너에 접근할 필요가 있을때는 nestedUnkedyContainer와 nestedKeyedContainer를 사용한다.

struct MyStruct: Codable {
    var name: String
    var items: [String]

    enum CodingKeys: String, CodingKey {
        case name
        case items
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        name = try container.decode(String.self, forKey: .name)
        
        // 중첩된 Unkeyed Container에 접근하여 items 배열을 디코딩
        var itemsContainer = try container.nestedUnkeyedContainer(forKey: .items)
        var items = [String]()
        while !itemsContainer.isAtEnd {
            let item = try itemsContainer.decode(String.self)
            items.append(item)
        }
        self.items = items
    }
}

아까 설명한것처럼 unkeyed는 순서대로 한개의 값을 꺼내올수 있기 때문에, while문과 var isAtEnd: Bool { get }라는 프로퍼티 를 통해서 마지막값인지 확인하여

값을 하나씩 꺼내와 decoding을 하고, 해당 값을 다시 self.items라는 배열에 담아서 return해주는 형태이다.

몇개의 데이터값을 기준으로 모델을 나누는 방법

이제 가장 어려운!? 데이터 값중에 몇개를 먼저 까보고, 해당 데이터에 따라서 다른 모델을 설정할 필요가 있을때 어떻게 처리하는지 보자.

먼저 JSON이 다음과 같이 내려온다고 했을때

// isReported가 true일때의 모델
{
	totalcount: Int
	cursor: String?
	reviews [
		isReported: Bool == false
		reviewID: Int
		reviewTitle: String
		reviewMemo: String
		,
		
		isReported: Bool == false
		reviewID: Int
		reviewTitle: String
		reviewMemo: String
		,
		isReported: Bool == false
		reviewID: Int
		,
	]
}

이러한 형태의 JSON이 내려올때 reviews의 isReported값에 따라서 true일때는 reviewID, reviewTitle, reviewMemo라는 데이터가 내려오고 아니라면, reviewID만 주게 되는 경우라고 생각해보자.

이런경우에는 isReported만 확인해서 데이터디코딩을 다른 타입으로해서 해당 배열에 넣어줘야한다.

이를 코드로 변환하기 위해서는 다음과 같은 과정이 필요하다

1. 프로토콜 세팅

protocol Reportable {
	var isReportable: Bool
}

먼저 두개의 타입이 동일하게 가지는 모델을 프로토콜로 정의

2. 프로토콜을 채택한 모델 2개 정의

struct Reported: Reportable {
	var	isReported: Bool
	var reviewID: Int
}

struct Nomal: Reportable {
	var	isReported: Bool
	var reviewID: Int
	var reviewTitle: String
	var reviewMemo: String
}

3. 커스텀 디코더를 통해서 타입확인후 해당 타입으로 넣어주기

실제로 들어오는 값의 배열에는 [Reportable] 로 받아야함

init(from decoder: Decoder) throws {
	let container = try decoder.container(keyedBy: CodingKeys.self)
	var typeContainer = try container.nestedUnkeyedContainer(forkey: .reviews)
	var reviewsContariner = try container.nestedUnkeyedContainer(forkey: .reviews)
	var reviews: [ReviewType] = []
	while reviewsContainer.isAtEnd {
		let model = try typeContainer.decode(Reported.self)
		if model.isReported {
			let review = try reviewsContainer.decode(Reported.self)
			reviews.append(contentOF: review)
		}
		else {
			let review = try reviewsContainer.decode(Nomal.self)
			reviews.append(contentOF: review)
		}
	}
	self.reviews = reviews
}

여기에서 중요한점은 typeContainer와 reviewContainer 두개의 컨테이너를 생성하게 되는데 동일한 값을 뽑아온다는점이 독특하다.

왜그렇게 하냐하면

Container는 한번 decoding을 해버리면 다음 값으로 인덱스값을 +1 해버리기 때문에

위에서 type을뽑기 위해서 한번 디코딩을 행했기때문에 밑에서 동일한 값으로 한번더 디코딩을 해버리면, 이미 인덱스값이 올라갔기 때문에 값 하나를 놓칠수 있다.

 

4. 사용하는곳에서 enum 정의후 배열을 정의한후 데이터 사용하기

enum ReviewTypes {
	case reported(Reported)
	case nomal(Nomal)
}
API호출 결과
let reviews = response.reviews
reviews.map { 
	$0 == .Reported ? ReviewTypes(.reported($0)) : ReviewTypes(.nomal($0))
}

for review in reviews {
	switch review {
		case let .reported(review):
			//reported review사용하기
		case let .normal(review):
			//normal review사용하기
		}
	}

이렇게하면 복잡한 데이터도 타입을 변환해주고 사용할수 있게 된다.

반응형