티스토리 뷰

반응형

문제 상황 인식하기

API에서 암호화폐 코인 별 정보를 받아오려고 해요.

빗썸의 open API에서 Ticker 정보를 받아오는데 그 정보는 아래와 같이 들어와요.

 

// URL: https://api.bithumb.com/public/ticker/{order_currency}_{payment_currency}

{
	"status" : "0000",
	"data" : {
		"opening_price" : "504000",
		"closing_price" : "505000",
		"min_price" : "504000",
		"max_price" : "516000",
		"units_traded" : "14.71960286",
		"acc_trade_value" : "16878100",
		"prev_closing_price" : "503000",
		"units_traded_24H" : "1471960286",
		"acc_trade_value_24H" : "16878100",
		"fluctate_24H" : "1000",
		"fluctate_rate_24H" : 0.19,
		"date" : "1417141032622"
	}
}

 

위의 예시에서 order_currency는 주문할 코인의 종류예요. 그리고 payment_currency는 결제 통화예요.

그런데 한 번에 모든 코인에 대한 정보를 불러오려면 저 order_currency에 "ALL"을 기입해줘야 해요.

All을 통해 모든 코인 정보를 불러오면 Response는 아래와 같이 와요.

 

{
    "status": "0000",
    "data": {
        "BTC": {
            "opening_price": "47210000",
            "closing_price": "44200000",
            "min_price": "43811000",
            "max_price": "47658000",
            "units_traded": "4191.62494387",
            "acc_trade_value": "190106140365.3991",
            "prev_closing_price": "47210000",
            "units_traded_24H": "5295.54430112",
            "acc_trade_value_24H": "242460257780.325",
            "fluctate_24H": "-3825000",
            "fluctate_rate_24H": "-7.96"
        },
        ...
        ,
        "date": "1642841146417"
    }
}

 

모든 코인 정보는 저렇게 시작가로 시작하여 공통된 형태를 띠게 되는데 문제는 저 마지막에 있는 date에요.

 

문제 상황은 코인 정보를 개별적으로 부르는 것이 아니라 일괄적으로 부를 때에만 date라는 값이 들어온다는 것이에요.

저 값이 없다면 코인 정보를 Decodable로 만들어 [String: Decodable 타입]으로 Decoding이 가능한데 저 Date 때문에 모든 코인을 case로 나누어야 해요.

 

또 그렇게 처리한다고 해도 모든 코인 정보가 Optional로 선언되어야 전체가 아니라 일부 코인만 가져올 때 사용할 수 있는 Entity가 돼요.

 

 

해결 방법 결정하기

위의 문제를 정리하면 방법은 아래 중 한 가지예요.

 

1. date를 포함하여 Entity를 만들고 모든 코인 종류를 옵셔널 프로퍼티로 설정
2. date를 빼고 Entity를 만들고 [String: Decodable Data] 형태로 Decoding
3. ALL로 데이터를 한꺼번에 불러오지 말고 모든 코인을 Array로 갖게 한 뒤 일일이 모두 부르기

 

3번의 경우 date가 오지 않기 때문에 단순 Entity를 만들어 JSON을 Decoding 가능해요.

하지만 200개의 가까운 코인 정보를 가져오기 위해 request를 200번 보내는 것은 너무나 비효율이라고 판단했어요.

 

1번의 경우 프로퍼티의 종류가 너무 많아져 가독성이 떨어진다고 판단했어요.

 

2번으로 선택을 했는데 가장 큰 이유는 저 date 프로퍼티를 절대 사용하지 않는다는 게 중요했어요. 현업이었다면 서버와 이 Response 구조 자체를 변경하는 것에 대해 회의했겠지만 이 open API 그대로 현업에서 사용한다고 생각하지 않고, 지금은 토이 프로젝트이기 때문에 date를 제외하는 전처리 작업을 진행하기로 결정했어요.

 

 

문제 해결하기

Alamofire의 경우 responseDecodable을 통해서 Decoding 된 response 자체를 가져올 수 있는데 현재는 전처리 작업이 필요하므로 단순 response를 통해 data를 넘겨받고, 그 data를 처리하는 구조로 작성할 계획이에요.

 

우선, Data 타입으로 넘어온 정보 중 마지막 data: "~~"만 제거를 해주어야 하는데 방법은 크게 2가지로 생각했어요.

 

1. Data를 문자열로 변환한 뒤, date의 index를 찾아 제거한 후 다시 Data 타입으로 변환
2. Data를 딕셔너리 형태로 변환한 뒤 Data의 data에서 key가 "date"인 값만 제거한 후 다시 Data로 변환

 

1번 방법의 경우 date에 따라오는 정보의 길이가 항상 일정하다면 위치 값이 뒤에서부터 고정되기 때문에 손쓰기 쉽다고 생각했어요.

하지만 문제는 이 길이가 같다는 보장이 없고, 만약 직접 substring의 위치 검색한다면 쓸데없이 O(N)의 연산이 추가된다고 판단했어요. 그래서 2번 방법으로 결정했어요.

 

 

Data 타입 <-> Dictionary 타입 변환하기

"date" 값만 없애기 위해 Dictionary로 변경하는 로직을 private method로 구현했어요.

또 반대로 편집된 딕셔너리를 다시 Data로 변환하는 로직 역시 private method로 구현했어요.

// Data -> Dictionary
private func convertToDictionary(from data: Data) -> [String: Any]? {
  return try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any]
}

// Dictionary -> Data
private func convertToData(from dictionary: [String: Any]) -> Data? {
  return try? JSONSerialization.data(withJSONObject: dictionary, options: [])
}

 

여기서 "Data를 Dictionary로 변환하는 것을 내부 메서드로 처리하는 것이 아니라 extension을 통해 정의할까" 고민했어요.

코드로 작성하면 아래와 같아요.

 

extension Data {
  var convertedDictionary: [String: Any]? {
    return try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any]
  }
}

 

이렇게 하면 NetworkManager의 코드를 줄일 수 있다는 장점이 있지만 문제는 Dictionary를 Data로 바꾸는 구조는 이렇게 할 수 없다는 것이었어요.

 

그래서 통일성을 위해 extension이 아니라 메서드로 처리했어요.

date 값만 삭제해주기

우선 작성한 코드부터 살펴볼게요.

func removeDateInformation(from data: Data) -> Data? {
  if var data = self.convertToDictionary(from: data),
     var tickerData = data["data"] as? [String: Any],
     let _ = tickerData.removeValue(forKey: "date") {
    data["data"] = tickerData
    return self.convertToData(from: data)
    }
  return nil
}

 

statusdata를 키로 갖는 JSON이 들어오게 되고, 그 데이터를 딕셔너리 형태로 변경해요.

그리고 그 데이터에서 data를 키로 갖는 value를 다시 딕셔너리로 캐스팅 해준 뒤, 거기에서 "date"를 제거해요.

그리고 다시 data에 변경사항을 반영한 뒤 그 변경된 값을 Data 타입으로 캐스팅해서 넘겨주는 로직이에요.

 

이 중 하나라도 문제가 있을 경우 nil을 반환하도록 Optional로 반환하는 메서드를 만들었어요.

 

동작 테스트 하기

작성한 코드가 정상적으로 동작하는지 XCTest를 진행했어요.

 

func test_Response에_date가_포함될_경우_제거한_후_정상적으로_decode_되는지_확인() throws {
    //given
    let testDataString = """
{
  "status": "0000",
  "data": {
    "BTC": {
      "opening_price": "47210000",
      "closing_price": "44200000",
      "min_price": "43811000",
      "max_price": "47658000",
      "units_traded": "4191.62494387",
      "acc_trade_value": "190106140365.3991",
      "prev_closing_price": "47210000",
      "units_traded_24H": "5295.54430112",
      "acc_trade_value_24H": "242460257780.325",
      "fluctate_24H": "-3825000",
      "fluctate_rate_24H": "-7.96"
    },
    "ETH": {
      "opening_price": "3421000",
      "closing_price": "3066000",
      "min_price": "3037000",
      "max_price": "3489000",
      "units_traded": "54186.53430297",
      "acc_trade_value": "174928381737.8696",
      "prev_closing_price": "3421000",
      "units_traded_24H": "68907.9612685",
      "acc_trade_value_24H": "225860082390.8483",
      "fluctate_24H": "-480000",
      "fluctate_rate_24H": "-13.54"
    },
    "date": "1642841146417"
  }
}
"""
    let testData = Data(testDataString.utf8)
    
    //when
    let dateRemovedData = networkManager.removeDateInformation(from: testData)
    let decodedData = try? JSONDecoder().decode(TickerResponse.self, from: dateRemovedData!)
    
    //then
    expect(decodedData).toNot(beNil())
    expect(decodedData?.data).toNot(beNil())
  }

 

테스트할 가짜 데이터를 만들고, 그 데이터에는 date를 포함했어요.

그리고 정상적으로 데이터의 변환이 일어나 애초에 목표했던 Entity 타입으로 decoding이 되는지 확인하는 테스트를 설계했어요.

반응형
댓글