회사에서 나름 핫한 이슈였다.
웹/서버 팀에서는 풀스택으로 프론트와 백엔드를 같이하다보니 백엔드에서도 최대한 null이 아닌 빈 값으로라도 내려줄 수 있도록 한다고 하고.. 또 프론트에서도 혹시 모를 Null을 대비해서 처리를 하고 있다고 한다.
하지만, 휴먼 에러는 어디서든 발생한다.. 어쩌다가 Response값에 null이 내려올 수 있고.. 값이 필수로 내려오던 부분에서 어떤 영향으로 인해 null이 내려올 수도 있고 또는 클라이언트에서 항상 내려오던 값을 강제 언래핑해서 사용하다가 에러가 발생할 수 있다.
iOS에서 null(nil)값을 강제 언래핑하려는 시도는 앱 강제종료로 이어지게 된다.
서버 수정도 많이 발생하고 개발자 대부분이 주니어로 이루어진 개발팀에서는 이러한 사건들이 많이 발생했었다.
그럼 옵셔널 바인딩 사용하고 강제 언래핑 안하고 안전하게 쓰면 되는거 아냐?
맞는 말이다. 근데 진짜 예상하지 못했던 케이스들도 있어서 차라리 Codable 사용해서 파싱하면서 그때 값이 없는 경우에 빈 값을 넣어야겠다는 생각이 들어서 다음과 같이 기본 값을 넣어주고 있다.
struct Response: Decodable {
var title: String
enum CodingKeys: String, CodingKey {
case title = "TITLE"
}
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
title = (try? container.decode(String.self, forKey: .title)) ?? "제목"
}
만약 TITLE
에 대한 값이 nil인 경우에 생성자를 통해서 제목
이라는 값을 넣어 줄 수 있다.
근데 지금은 Response 구조체에 title이라는 프로퍼티가 한개인 경우지만 프로퍼티가 10개라면?
init 부분에 작성해야하는 양도 10개가 추가된다는 것이다.
그래서, init 부분을 작성하지 않고 JSONData를 Model로 파싱하는 JSONDecoder.decode(Type.self, from: JSONData)
에서 기본 값 처리를 해주면 어떨까? 하고 찾아보게 되었고 아래 나올 내용들을 발견하게 되었다.
파일 하나 생성해서 다음과 같은 내용들을 넣어주면 된다.
각 소스들의 역할은 주석 참고하면 좋다.
// Decodable 프로토콜을 준수하는 타입만 정의하도록 제약건 Types 타입 견본
// Types라는 타입을 갖는 읽기 전용 저장 프로퍼티 value
protocol DefaultDecodingValue {
associatedtype Types: Decodable
static var value: Types { get }
}
// DefaultValue 라는 네임스페이스 생성
enum DefaultValue { }
extension DefaultValue {
// 각 프로토콜을 준수하는 별칭 생성
typealias Array = Decodable & ExpressibleByArrayLiteral
typealias Dictionary = Decodable & ExpressibleByDictionaryLiteral
// 제네릭 T의 TypeWrapper 별칭 생성
typealias EmptyInt = TypeWrapper<ValueType.EmptyInt>
typealias EmptyString = TypeWrapper<ValueType.EmptyString>
typealias EmptyArray<T: Array> = TypeWrapper<ValueType.EmptyArray<T>>
typealias EmptyDictionary<T: Dictionary> = TypeWrapper<ValueType.EmptyDictionary<T>>
// Property Wrapper 정의
@propertyWrapper
struct TypeWrapper<Source: DefaultDecodingValue> {
typealias Types = Source.Types
var wrappedValue = Source.value
}
// ValueType 네임스페이스 안에 DefaultDecodingValue 준수하는 각각의 네임스페이스 생성
// Decodable을 준수하면 추가가 가능할 것으로 보임!? ex. Bool
enum ValueType {
enum EmptyInt: DefaultDecodingValue {
static var value: Int { 1 }
}
enum EmptyString: DefaultDecodingValue {
static var value: String { "" }
}
enum EmptyArray<T: Array>: DefaultDecodingValue {
static var value: T { [] }
}
enum EmptyDictionary<T: Dictionary>: DefaultDecodingValue {
static var value: T { [:] }
}
}
}
// TypeWrapper의 Types를 타입으로 갖는 값으로 디코딩
// wrappedValue에 해당 값 저장
extension DefaultValue.TypeWrapper: Decodable {
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
wrappedValue = try container.decode(Types.self)
}
}
// 값이 존재하면 해당 type과 key로 디코드, 없으면 init
extension KeyedDecodingContainer {
func decode<T>(
_ type: DefaultValue.TypeWrapper<T>.Type,
forKey key: Key
) throws -> DefaultValue.TypeWrapper<T> {
try decodeIfPresent(type, forKey: key) ?? .init()
}
}
위 코드들을 다 넣었으면 이제 사용하기만 하면 된다.
일반적인 property Wrapper처럼 사용해서 기본값 처리해줄 프로퍼티 앞에 넣어주기만 하면 된다!
(ex. @DefaultValue.EmptyString)
struct Response: Decodable {
@DefaultValue.EmptyString var title: String
enum CodingKeys: String, CodingKey {
case title = "TITLE"
}
}
조금 더 전체적인 그림으로 예시를 보여주면 다음과 같다.
struct ResponseTest: Decodable {
@DefaultValue.EmptyString var title: String
@DefaultValue.EmptyString var subTitle: String
enum CodingKeys: String, CodingKey {
case title = "TITLE"
case subTitle = "SUBTITLE"
}
func description() {
print("Response title: \(title), subTitle: \(subTitle)")
}
}
let exampleData = [
"TITLE": "테스트"
]
do {
let jsonData = try JSONSerialization.data(withJSONObject: exampleData)
let decodeData = try JSONDecoder().decode(ResponseTest.self, from: jsonData)
decodeData.description()
} catch {
print(error.localizedDescription)
}
위 코드를 Playground에서 실행 시키면 다음과 같은 결과 값이 나온다. SUBTITLE
에 대한 값이 exampleData
에 존재하지 않았음에도 불구하고 기본값 처리를 통해서 값을 출력 할 수 있다.
만약 해당 struct가 Codable이나 Encodable을 준수한다면 다음과 같은 코드도 추가해주면 좋다.
// Encodable 프로토콜 준수할 때 wrappedValue 인코드
extension DefaultValue.TypeWrapper: Encodable where Types: Encodable {
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(wrappedValue)
}
}
사내 안드로이드 개발자분이 Kotlin에서는 Serialization Converter를 사용해서 기본값 처리를 해줄 수 있다는 말을 듣고 iOS에서도 비슷하게 사용할 수 있는 방법이 없을까? 라는 고민도 있었다.
사실 회사에 어떤 Response 모델에는 모든 프로퍼티(약 15개??)가 옵셔널 및 init 부분에 처음 소개한 디폴트 처리가 되어있다. 너무나도 끔찍했다😭.
다행히도 참고자료에서 다음과 같은 내용을 발견할 수 있었고, 딱 내가 생각했던 그 내용이여서 잘 적용하여 활용해 볼수 있게 되었다.
BetterCodable
이라는 라이브러리도 비슷? 동일한 내용인 것 같은데 이런 간단간단한 부분은 직접 구현하는걸 선호해서 직접하기로 했다.
이번 공부를 통해서 Decoding시에 발생하는 보일러플레이트 코드들을 제거할 수 있었고 개인적으로도 Property Wrapper를 더 공부해보면 좋은 코드를 만들 수 있을 것 같은 생각이 들었다.
틀린 부분이 있다면 피드백 부탁드립니다🙏.
https://www.swiftbysundell.com/tips/default-decoding-values/