본문 바로가기

이상/iOS

[iOS] Generic을 사용해보자 (feat. ApiManager)

반응형

평소 앱을 만들 때 서버와 통신하는 부분은

 

ApiManager 클래스에 request 종류만큼 메소드를 만들어 사용했다.

 

그런데 어떤 회사의 기술면접에서 이런 상황이 있었다.

 

 

면접관: 보통 api 부분은 어떤 식으로 구현하나요?

 

나: 중복되는 부분을 제외하고 필요한 api 개수만큼 만듭니다.

 

면접관: 서로 다른 api가 만 개면 만개 다 만드나요?

 

나: (? 멘붕...) 네...

 

면접관: 그렇죠... 돌아는 가죠...

 

 

사실 언젠가 잠깐 고민을 했던 부분인데 그때는 왜 그냥 넘겼을까.

 

면접이 끝나고 집에 돌아오는 길에

 

T라는 것이 타입에 대해 자유로운 느낌이었다는 게  문득 생각이 났고

 

집에 오자마자 검색을 해보니 처음 Swift 공부할 때 강의에서 본 Generic이라는 놈이 있었다.

 

그리고 기존에 만들었던 싱글톤 패턴의 ApiManager 클래스에 적용해봤다.

 

참고로 Alamofire를 사용했다.

 

 

 

1. Generic

 

Swift Doc에 Generic은 이렇게 나와있다.

 

Swift Docs - Generic

 

Generic 코드는 니가 정의한 요구 사항에 따라 어떤 type이든 사용할 수 있는

flexible하고 reusable한 function과 type을 작성할 수 있다.

Swift에서 가장 강력한 것 중의 하나이며,

Swift의 Standard Library 대부분이 Generic으로 만들어졌다.

예로 Swift의 Array와 Dictionary 타입은 Generic Collection이다.

 

Swift Doc에서도 가장 강력하다고 말하니 사용하면 좋긴 좋은가 보다.

 

예제는 각각 다른 타입의 파라미터를 받지만

 

기능은 똑같은 function을 다 만들 경우를 예를 들고 있다.

 

타입 두 파라미터를 Swap 기능을 하는 함수를

 

 Int, Double, String 세 가지 타입에서 사용하기 위해 파라미터 타입만 다른 함수 3개를 만든다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func swapTwoInts(_ a: inout Int, _ b: inout Int) {
    let temporaryA = a
    a = b
    b = temporaryA
}
 
func swapTwoStrings(_ a: inout String, _ b: inout String) {
    let temporaryA = a
    a = b
    b = temporaryA
}
 
func swapTwoDoubles(_ a: inout Double, _ b: inout Double) {
    let temporaryA = a
    a = b
    b = temporaryA
}
cs

 

만약 Int, Double, String 말고 또 다른 타입에서 Swap을 하려고 할 때

 

함수를 추가적으로 더 만들어 사용하면 끝도 없을 것이다.

 

이럴 때 아래처럼 Generic Function으로 만들면 함수 하나로 끝이다.

 

1
2
3
4
5
func swapTwoValues<T>(_ a: inout T, _ b: inout T) {
    let temporaryA = a
    a = b
    b = temporaryA
}
cs

 

 

 

2. Type Parameter

 

위의 Generic Function에서 중요한 것은 T이다.

 

T는 Type Parameter라고 부르며

 

함수명 뒤에 <T>를 붙여주고 파라미터의 타입을 T로 지정한다.

 

Type Parameter를 지정한 후에는 함수의 매개 변수 타입을 정의하거나

 

반환 타입으로 정의하거나 함수 본문 내의 형식 주석으로 정의할 수 있고,

 

여러 Type Parameter를 쉼표로 구분하여 <> 안에 작성하여

 

둘 이상의 Type Parameter를 사용할 수 있다고 한다.

 

Naming 룰도 있는데 대부분 Key-Value나 Array의 Element와 같이 설명적인 이름을 가지며

 

둘 사이의 관계를 이름을 통해 유추할 수 있게 해주는데

 

관계가 없을 경우 T, U, V와 같이 이니셜을 사용한다고 한다.

 

그리고 Type Parameter는 Upper Camel 방식으로 네이밍 해야 한다고 한다.

 

위 내용으로 Generic Function을 구현해보면 이런 식인 것 같다.

 

1
2
3
4
func test<FirstType, SecondType, ThirdType>(p1: FirstType, p2: SecondType) -> ThirdType {
    ...
    return ThirdTypeValue
}
cs

 

주의할 점은 동일한 Type Parameter 변수에 다른 타입의 값이 들어가면 안 된다.

 

1
2
3
func test<MyType>(p1: MyType, p2: MyType) {
}
test(p1: 100, p2: "")// 에러발생 Cannot convert value of type 'String' to expected argument type 'Int'
cs

 

결론적으로 Type Parameter는

 

Generic Function에서만 유효한 Custom Type명이라고 보면 될 것 같다.

 

 

3. ApiManager 적용

 

ApiManager class에는 3개의 데이터 조회 api가 있다.

 

func getRoutineList(callback: @escaping ([Routine]) -> ())

func getExerciseList(callback: @escaping ([Exercise]) -> ())

func getExerciseList(with requestData: ExerciseListRequest, callback: @escaping ([Exercise]) -> ())

 

이 3개의 함수를 공통 함수 selectData()로 통합할 것이다.

 

내부 내용은 일단 제외하고 함수의 생김새부터 바꾼다.

 

api 호출 시 보낼 파라미터인 requestData가 없는 것도 있고 있는 것도 있으므로

 

첫 번째 인자는 optional 해야 하며 외부 표현으로 인코딩할 수 있어야 한다.

 

Codable 타입으로 바꿔주면 되는데 더 세부적으로 들어가서

 

외부로 보낼 파라미터이기 때문에 Encodable 타입으로 지정한다.

(Encodable: external representation으로 자신을 encode 할 수 있는 타입)

 

selectData(param: Encodable? = nil)

 

 

기존에는 ApiManager의 함수마다 그 기능이 정해져있었기 때문에

 

url도 정해져있어 파라미터로 url을 보낼 필요가 없었지만

 

공통 함수로 바꾸면서 파라미터로 url이 필요하다.

 

selectData(with param: Encodable? = nil, from url: String)

 

 

api로 data를 받아 온 후 기존과 같이 escaping closure를 이용하여

 

data를 callback 함수로 return 하려고 한다.

 

callback에 들어갈 내용은 서버로부터 받은 data와 error이다.

 

이때 받은 데이터는 어떤 타입일지 모르기 때문에 Type Parameter로 사용하고

 

data와 error 각각 nil 일 수 있으므로 optional 타입으로 지정한다.

 

selectData<T>(with param: Encodable? = nil, from url: String, callback: @escaping (_ data: T?, _ error: String?) -> ())

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func getRoutineList(callback: @escaping ([Routine]) -> ()) {
    let url = Constants().BASE_URL+Constants().ROUTINE_GET_LIST
    AF.request(url, method: .post).responseJSON { response in
        var routines = [Routine]()
        do {
            let decoder = JSONDecoder()
            switch (response.result) {
            case .success:
                routines = try decoder.decode([Routine].self, from: response.data!)
                print("routines: \(routines)")
            case .failure(let error):
                print("errorCode: \(error._code)")
                print("errorDescription: \(error.errorDescription!)")
            }
        } catch let parsingError {
            print("Error:", parsingError)
        }
        DispatchQueue.main.async {
            callback(routines)
        }
    }.resume()
}
cs

 

내부 내용을 보면 url을 지정하고 request를 던진 다음

 

JSONDecoder()로 [Routine]으로 변환하고 callback으로 던졌는데

 

selectData에서는 decoding 후 바로 callback을 호출할 것이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func selectData<T>(with param: Encodable? = nil, from url: String, callback: @escaping (_ data: T?, _ error: String?) -> ()) {
    AF.request(url, method: .post, parameters: param?.dictionary).responseJSON { response in
        do {
            guard let resData = response.data else {
                callback(nil, "emptyData")
                return
            }
            // 에러발생  Instance method 'decode(_:from:)' requires that 'T' conform to 'Decodable'
            let data = try JSONDecoder().decode(T.self, from:resData)
            callback(data, nil)
 
        } catch {
            callback(nil, error.localizedDescription)
        }
    }
}
cs

 

AF.request()의 parameters는 Parameters 타입인데

 

typealias를 이용하여 [String: Any] 타입에 이름을 붙여준 것으로 어쨌든 Dictionary 타입이다.

 

때문에 Encodable 타입을 Dictionary 타입으로 변환해야 하므로

 

이를 위해 Encodable의 Extension으로 변환하는 기능을 추가했다.

 

1
2
3
4
5
6
7
8
9
extension Encodable {
    var dictionary: [String: Any]? {
        guard let data = try? JSONEncoder().encode(selfelse {
            print("Dictionary is nil")
            return nil
        }
        return (try? JSONSerialization.jsonObject(with: data, options: .allowFragments)).flatMap { $0 as? [String: Any] }
    }
}
cs

 

 

 

여기까지 구현하면 JSONDecoder().decode()에서

 

T가 Decodable을 conform 하도록 하세요.라며 에러가 발생한다.

 

애플 문서를 찾아보니 decode(_:from:)의 파라미터는 Decodable 타입이어야 한다고 한다.

 

JSONDecoder의 decode(_:from:)

 

T가 Decodable을 준수하도록 수정한다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func selectData<T: Decodable>(with param: Encodable? = nil, from url: String, callback: @escaping (_ data: T?, _ error: String?) -> ()) {
    AF.request(url, method: .post, parameters: param?.dictionary).responseJSON { response in
        do {
            guard let resData = response.data else {
                callback(nil, "emptyData")
                return
            }
            let data = try JSONDecoder().decode(T.self, from:resData)
            callback(data, nil)
 
        } catch {
            callback(nil, error.localizedDescription)
        }
    }
}
cs

 

 

selectData를 호출하는 부분은 이렇게 구현했다.

 

1
2
3
4
5
6
7
8
9
10
func getExerciseList() {
    let url = Constants.BASE_URL+Constants.EXERCISE_GET_LIST
    ApiManager.shared.selectData(from: url) { (data: [Exercise]?, error) in
        guard let data = data else {               // ↑ 타입 지정
            print("error: \(error?.debugDescription)")
            return
        }
        // Do something
    }
}
cs

 

request 파라미터 없이 url를 파라미터로 selectData를 호출한 후

 

클로저가 반환하는 값 중 data의 타입을 지정하고 error와 함께 받아 후처리해 준다.

 

 

Generic Function을 사용하니 ApiManager의 소스가 대폭 줄어들었다.

 

면접을 보러 다니기 전에는 나에게 부족한 부분이 있긴 한데

 

그게 뭔지 정확하게 알지 못했는데,

 

여러 기술 면접을 경험해보니 나에게 어떤 부분이 부족한지

 

좀 더 명확해지는 것 같아 떨어지더라도 얻어 가는 기분이었다.

 

아무리 얻어 가는 게 있어도 탈락은 마상이다

반응형