두 프로젝트를 동시에 진행하면서 앱 아키텍처에 관한 중요한 경험을 얻을 수 있었다. 내가 공부했던 것이나 생각했던 것에서 적용해보고 싶은 개념을 두 프로젝트에 적용시켜 진행했다. 그중 하나는 최근에 내가 공부하기도 했고 가장 의미있다고 생각되는, 네트워크 레이어를 어떻게 만드는지에 관한 이야기이다.

요즘 모바일 앱은 클라이언트-서버 지향이고, 앱이 크든 작든 앱 어디에나 네트워크 레이어를 사용한다. 나는 수 많은 네트워크 레이어 구현을 보았지만, 다들 뭔가 단점들이 있었다. 내가 제일 마지막으로 만든것에서 단점이 아예 없을거라 생각하진 않지만 두 프로젝트에서 굉장히 잘 동작하는듯 보였고, 현재까지도 작업을 하고 있다. 또한 테스트 커버리지가 거의 100%에 이른다.

이 글에서는 그렇게 복잡한것 까지는 다루지 않을 것인데, 인코딩된 요청을 JSON으로 보내는 한 백엔드의 네트워크 레이어만 다룰 것이다. 이 레이어는 나중에 AWS와 함께 이야기해보고 거기에 몇몇 파일도 보내볼 것이지만 기능적으로 확장하기 쉬울 것임을 확신한다.

프로세스를 생각해보자
이 레이어를 만들기 전에 던졌던 질문들이다.
  • 백엔드 url에관한 정보는 코드 어디에 놔둘까?
  • url 끝부분은 어느 코드에서 알아야할까?
  • 요청을 어떻게 만드는지는 코드 어디에서 알아야 할까?
  • 요청을 보내기 위해 준비해야하는 파라미터는 코드 어디에 둘까?
  • 인증(authentication) 토큰은 어디에 저장해 둘까?
  • 어떻게 요청(request)을 실행할까?
  • 언제 그리고 어디서 요청을 실행할까?
  • 요청 취소에 대해서도 고려해야할까?
  • 잘못된 백엔드 응답이나 백엔드 버그도 고려해야할까?
  • 써드파티 라이브러리를 사용해야하나? 어떤것을 사용할까?
  • 코어데이터도 처리해야할 것이 있나?
  • 이 솔루션을 어떻게 테스트해볼까?

백엔드 url을 저장하기
먼저, 어디에 백엔드 url을 저장해 두어야할까? 어떻게 우리 시스템에서 어떤 다른 시스템에 요청을 날릴지 알까? 나는 BackendConfiguration 클래스를 만들어 그 정보(url)를 담아둔다.

테스트하기도 쉽고, 구성하기도 쉽다. shared라는 스태틱 변수에 할당해놓고 파라미터로 전달할 필요 없이 네트워크 레이어 어디에서나 접근할 수 있다.

끝부분(Endpoints)
이 토픽은 내가 준비-시작(ready-to-go) 솔루션을 찾기 전에 경험했던 것이다. NSURLSession을 구성하는 동안 끝부분을 하드코딩하려 했고, url의 끝부분을 알고 쉽게 객체로 만들 수 있거나 주입될 수 있는 더미의 Resource스러운 오브젝트를 시도했다. 그러나 내가 찾던 해답은 아니었다. 

결국 *Request라는 객체를 만들기로 했는데, 이 객체는 다음과 같은 것들을 가진다. 어떤 메소드를 사용하는지, GET, POST, PUT 혹은 다른 어떤것인지, 요청의 바디(body)가 어떻게 구성되는지, 헤더에는 무엇을 담아 보내는지 이 객체에 담겨있다.

프로토콜로 구현된 이 클래스는 요청을 만들때 필요한 기본 정보를 제공한다. NetworkService.Method는 GET, POST, PUT, DELETE를 표현하는 열거형이다.

url 뒷부분이 저장된 예제 요청은 아래와같이 생겼다.

헤더 어디에도 딕셔너리를 생성하지 않으려고 BackendAPIRequest를 위한 extension을 정의할 수 있다.

*Request 클래스는 요청에 성공하기위해 필요한 모든 파라미터를 받는다. 적어도 당신이 필요로한 모든 파라미터는 보내야하고, 그렇지 않으면 요청 객체는 생성할 수 없다.

url 끝부분을 선언하는 것은 간단하다. 만약 끝부분에 id를 포함해햐한다면 아주 쉽게 추가할 수 있다. 왜냐하면 프로퍼티로 저장해둔 id를 가지고 있기 때문이다.

요청의 메소드는 절때 바뀌지 않으며 파라미터 바디나 헤더가 쉽게 구성되고 쉽게 수장할 수 있다. 테스트하기에 모든것이 쉬워진다.

요청을 실행하기

백엔드와 소통하려면 써드파티 프레임워크가 필요한가?

사람들이 AFNetworking(Objective-C)나 Alamofire(Swift)를 사용하는 것을 보았다. 나도 이것을 오랫동안 써왔으나 어떨때는 사용하지 않았다. NSURLSession이라는 것이 있는데, 이것이 굉장히 잘 되있기 때문에 나는 여러분이 굳이 써드파티 프레임워크를 사용하지 않아도 된다고 생각한다. 내 생각에는 이런 의존성이 여러분의 앱 구조를 더 복잡하게 만든다.

현재 솔루션은 NetworkService와 BackendService의 두 클래스로 구성된다.
  • NetworkService — 내부적으로 NSURLSession과 구성되어 HTTP 요청을 실행할 수 있게 한다. 모든 네트워크 서비스는 한번에 하나만 요청할 수 있고, 요청을 취소할 수도 있으며(큰 장점이다), 성공하거나 실패한 응답을 위한 콜백을 가지고 있는다.
  • BackendService — (좋은 이름은 아니지만 꽤 잘 들어 맞는다)백엔드와 관련있는 요청(위에서 설명한 *Request 객체)을 받는 클래스이다. NetworkService를 사용한다. 내가 현재 사용하는 버전에서는 응답 데이터를 NSJSONSerializer를 이용하여 json으로 만든다.

위에서 볼 수 있듯 BackendService는 인증 토큰을 헤더에 세팅할 수 있다. BackendAuth 객체는 NSUserDefault에 토큰을 저장하는 간단한 저장소이다. 필요에따라 토큰을 키체인에 저장할 수 있다.

BackendService는 request(_:success:failure:) 메소드의 파라미터로 BackendAPIRequest를 받고 request 객체로부터 필요한 정보를 얻어낸다. 이렇게 하면 캡슐화면에서도 좋고 백엔드 서비스도 꺼네온것만 사용한다.

NetworkService, BackendService, BackendAuth 모두 테스트하거나 유지보수하기 쉽다.

요청을 큐하기
어떤 방식으로 네트워크 요청을 날려야할까? 한번에 많은 네트워크 요청을 날려야 한다면 어떻게 할까? 일반적으로 요청의 성공과 실패를 처리하려면 어떻게 할까? 이 파트에서 위 질문들을 해결할 수 있을 것이다.

네트워크 요청을 실행하는 NSOperation과 NSOperationQueue를 사용하자.

나는 NSOperation으로 서브클래스를 만들고 asynchronous 프로퍼티를 오버라이드하여 true를 반환하게 한다.

다음으로, 네트워크 콜을 실행시키기위해 BackendService를 사용하고 싶으므로 NetworkOperation을 상속받은 ServiceOperation을 하나 만든다.

이 클래스 내부에는 BackendService를 생성하므로 이제 모든 서브클래스마다 이것을 생성할 필요가 없다.

이제 로그인 동작이 어떻게 구현되는지 보자.

서비스는 이 오퍼레이션의 초기화때 만들어놓은 요청을 start 메소드에서 실행시킨다. handleSuccess와 handleFailure 메소드는 서비스의 request(_:success:failure:) 메소드에 콜백으로 전달된다. 내 생각엔 이렇게하면 코드가 더 깔끔해지고 가독성도 좋아진다.

오퍼레이션들은 싱글톤 오브젝트인 NetworkQueue로 전달되며 모든 오퍼레이션이 이 큐에 들어갈 수 있다. 이제 나는 가능한 간단하게 유지한다.

한 곳에 오퍼링션 실행을 모아두면 어떤 이점이 있을까?
  • 모든 네트워크 오퍼레이션을 간편하게 취소할 수 있다.
  • 이미지를 다운받거나 열악한 네트워크 환경에서 많은 데이터를 소모해야하지만 앱 동작에는 크게 상관없는 다른 오퍼레이션들을 취소할 수 있다.
  • 큐를 필요한 순서대로 실행할 수 있고 빨리 답변 올 수 있는 것부터 실행할 수 있다.

코어데이터와 함께 작업하기
사실 이것 때문에 이 글의 발행이 늦어졌다. 이전 버전에서는 네트워크 레이어 오퍼레이션이 코어데이터 객체를 반환했었다. 응답을 받고 파싱한 뒤, 또다시 코어데이터 객체로 변환시켰다. 이 방법은 그다지 이상적이지 않았다.
  • 오퍼레이션에서 어떤 코어데이터인지 알고 잇어야했다. 왜냐하면 프레임워크를 분리하기 위해 떼어놓은 모델을 가지고 있고 네티워크 레이어 역시 프레임워크로부터 분리되 있었다. 네트워크 프레임워크는 모델 프레임워크에대해 알고 있어야 했다.
  • 각 오퍼레이션은 어떤 컨텍스트에서 동작하는지 알아야 했으므로 NSManagedObjectContext 파라미터를 받아야 했다.
  • 응답을 받고 성공(success) 블럭을 호출할 때마다 먼저 컨텍스트에 객체를 찾으려 하거나 디스크에 객체를 패치하기위해 디스크 검색을 해야만 했다. 내 생각엔 이 점이 매우 큰 단점이었고, 아마 당신도 항상 코어데이터 객체를 생성하고 싶지는 않았을 것이다.

그래서 나는 네트워크 레이어를 코어데이터로부터 완전히 떼어냈고, 응답을 파싱하여 오브젝트를 만드는 중간 레이어를 만들었다.
  • 이렇게 파싱하고 오브젝트를 생성하면 디스크에 접근하지 않으므로 빠르다.
  • 오퍼레이션에 NSManagedObjectContext를 전달할 필요가 없다.
  • 성공 블럭에서 파싱된 아이템으로 코어데이터 객체를 갱신할 수도 있고, 오퍼레이션을 생성하는 곳 어디에든 있을 수 있는 코어데이터 객체를 참조할 수 있다. — 오퍼레이션이 큐에 추가될 때의 내 경우이다.

응답을 맵핑하기
유용한 아이템을 위해 JSON을 파싱하는 로직과 매핑하는 로직을 분리해주는 응답 맵퍼(response mapper)가 있다.

우리는 두가지 타입으로 파서를 구별할 수 있다. 첫번재는 특정 타입의 한 객체를 반환한다. 두번째는 어떤 항목의 배열을 파싱하는 파서이다.

먼저 모든 항복에 해당하는 일반적인 프로토콜을 선언하자.

이제 모델과 맵핑하는 몇 객체이다.

그리고 파싱하다가 에러가 발생시 던지는(throw) 에러타입을 선언하자.
  • Invalid — 파싱된 json이 nil이고, nil을 반환하면 안될때나, json이 하나의 객체가 아니라 객체의 배열일때 던지는 에러타입이다.
  • MissingAttribute — 이름이 의미하는 바와 같다. json에서 키를 잃어버리거나 파싱 후에 값이 nil이면 안되는데 nil일때 던지는 에러타입이다.

ResponseMapper는 이렇게 생겼을 것이다.

ResponseMapper는 백엔드로부터 받은 obj(우리의 경우 JSON이다)와 parse라는 메소드를 받는데, parse는 obj을 이용해 ParsedItem을 따르는 A 객체를 반환한다.

이제 우리가 세부적으로 구현한 제네릭 매퍼를 만든다. 아래 매퍼는 로그인 오퍼레이션에대한 응답을 파싱하는데 사용되는 매퍼이다.

ResponseMapperProtocol은 세부적인 매퍼에의해 구현된 프로토콜로서 메소드를 공유하여 응답을 파싱한다.

그러면 이 매퍼를 오퍼레이션의 성공 블락에서 사용하고, 이것을 딕셔너리 대신에 특정 타입의 객체로 사용할 수 있다. 이전보다 훨씬 사용하기 쉽고 테스트하기도 쉽다.

마지막으로 배열을 파싱하기위한 응답 매퍼이다.

모든것이 정상적으로 파싱되면 매핑할 함수를 받아 항목의 배열을 반환한다. 매퍼의 결과가 당신이 예상한 것에 따라 한 아이템이 파싱되지 않을 경우 에러를 던질 수도 있고, 문제가 있을 때는 빈 배열을 반환할 수도 있다. 매퍼는 백엔드로부터 받은 응답인 obj가 JSON 요소의 배열이라 예상한다.

이 도표는 네트워크 레이어의 구조를 보여준다.




예제 프로젝트
여러분은 내 깃헙에서 예제 프로젝트를 확인해 볼 수 있다. 프로젝트에서 사용된 백엔드 url은 모두 가짜이며 모든 요청은 실패할 것이다. 나는 여러분에게 네트워크 레이어의 파운데이션이 어떻게 생겼는지 보여주기만을 위해 이것을 만들었다.

요약
나는 이러한 네트워크 레이어 방식이 굉장히 유용하고 간편하며 작업에 편리하다는 것을 깨닭았다.

  • 가장 큰 이점은 비슷한 설계의 다른 새 오퍼레이션을 쉽게 추가할 수 있고, 이 레이어가 코어데이터에대해 전혀 모른다는 점이다.
  • 큰 노력없이 코드 커버리지를 거의 100%에 근접하도록 만들 수 있다. 완전 어려운 케이스를 어떻게 커버할지 생각할 필요도 없다. 어디에도 케이스가 없기 때문이다(원문: because there is no such cases at all).
  • 이 네트워크 레이어의 핵심은 비슷한 복잡성을 가진 다른 앱에서도 다시 사용할 수 있다는 점이다.



WRITTEN BY
tucan.dev
개인 iOS 개발, tucan9389

,