Network Layer Architecture

김도연·2024년 12월 26일

iOS

목록 보기
1/8
post-thumbnail

📝 기존 레거시 코드 설명

우리는 그동안 Moya를 통해서 URLSession, Alamofire를 직접 사용하지 않고 한 단계 추상화하여 사용해왔다. 하지만 Moya를 사용하는 데도 불편함이 있었는데…

  1. 중복된 request 함수

    기존 코드는 API 호출을 위한 함수를 API 별로 반복 정의해야 했다. 이로 인해 코드의 재사용성이 낮고, 동일한 비동기 처리 및 에러 처리 코드가 중복되어 관리 비용이 증가하는 문제가 있었다.

    // Copyright © 2024 DRINKIG. All rights reserved 
        private func callJoinAPI(completion: @escaping (Bool) -> Void) {
            if let data = self.joinDTO {
                provider.request(.postRegister(data: data)) { result in
                    switch result {
                    case .success(let response):
                        do {
                            let data = try response.map(APIResponseString.self)
    //                        print("User Created: \(data)")
                            completion(data.isSuccess)
                        } catch {
                            print("Failed to map data: \(error)")
                            completion(false)
                        }
                    case .failure(let error):
                        print("Request failed: \(error)")
                        completion(false)
                    }
                }
            } else {
                print("User Data가 없습니다.")
                completion(false)
            }
        }
    // Copyright © 2024 DRINKIG. All rights reserved
        private func assignUserData() {
            self.userID = self.idTextField.text
            self.userPW = self.pwTextField.text
            self.joinDTO = JoinNLoginRequest(username: self.userID ?? "", password: self.userPW ?? "")
        }
        
            @objc private func joinButtonTapped() {
            assignUserData()
            callJoinAPI { [weak self] isSuccess in
                if isSuccess {
                    self?.goToLoginView()
                } else {
                    print("회원가입 실패")
                    Toaster.shared.makeToast("400 Bad Request: Failed to Register", .short)
                }
            }
        }
  1. View Controller에 의존적인 API 함수

    위와 같은 함수를 뷰컨트롤러 내에서 정의하다보니, 동일한 API를 다른 뷰컨트롤러에서 사용하는 경우에도 해당 함수를 재정의한 후 사용할 수 밖에 없었다.

  2. Error Handling

    기존 코드에서는 에러 처리 방식이 통일되지 않아, 동일한 상태 코드 처리와 에러 메시지 처리 코드가 API 함수마다 반복되었다. 이로 인해 네트워크 에러 대응이 어려워지고, 에러 관리 로직이 여러 곳에 분산되어 유지보수가 복잡해졌다. 데모데이에 앱을 시연하기 위해서 네트워크 에러를 모두 토스트메세지로 띄우는 코드를 추가했는데, 이 역시 매 뷰컨트롤러, API 함수마다 작성해주어야했다.(복붙 지옥)

프로젝트에서 Moya를 제대로 사용한 적이 없었고, Alamofire의 불편함을 Moya가 대체해줘서 그 당시에는 굉장한 혁신이라고 느꼈기 때문에 드링키지의 주요 기능이 추가되면서 API Controller의 개수가 10개를 넘어가니 Moya를 제대로 활용해서 네트워크 레이어를 분리하는 게 좋겠다는 생각이 들었다.

🌐 네트워크 레이어 추상화

그래서 어떤 방식으로 네트워크 레이어를 구성했느냐?

일단 드링키지가 버전 2가 되면서, Tuist를 통해서 모듈화 구조로 리팩토링하였다. 이 과정에서 네트워크 모듈을 분리하여, 해당 네트워크 모듈에서 각 기능마다 Endpoint, Request, Response, Service를 개발하여, 다른 Feature 모듈에서는 Service만을 instance로 가져가 사용할 수 있도록 구조를 설계하였다.

해당 방식의 장점으로는,

  1. 뷰컨트롤러에서의 API 코드 호출 단순화
  2. 하나의 네트워크 컨트롤러 내의 모든 API를 하나의 서비스로 관리 가능
  3. 뷰컨트롤러 입장에서는 사용하고자 하는 Service만 인스턴스로 의존성을 주입하면 모든 서비스의 API에 접근 가능
  4. 피쳐를 개발하는 입장에서 직관적인 API 호출이 가능
  5. Network Model과 Data Model 강제 분리
  6. 기능 모듈에서 Moya를 import할 필요 없음
  7. Decoding, Error handling 방식 추상화 편리

정도를 꼽을 수 있겠다.

네트워크 레이어 구성

Moya 코드

  1. Endpoint

    Endpoint는 기존에 Moya에서 가이드하는 대로 작성해준다.

    // Copyright © 2024 DRINKIG. All rights reserved
    
    import CoreModule
    import Foundation
    import Moya
    
    enum AuthorizationEndpoints {
        case postLogin(data : LoginDTO)
        case postLogout
        case postJoin(data : JoinDTO)
        case postAppleLogin(data : AppleLoginRequestDTO)
        case postKakaoLogin
        
        case postReIssueToken
        case patchMemberInfo(data : MemberRequestDTO)
    }
    
    extension AuthorizationEndpoints: TargetType {
        var baseURL: URL {
            guard let url = URL(string: Constants.API.baseURL) else {
                fatalError("잘못된 URL")
            }
            return url
        }
        
        var path: String {
            switch self {
            case .postLogin:
                return "/login"
            case .postLogout:
                return "/logout"
            case .postJoin:
                return "/join"
            case .postAppleLogin:
                return "/login/apple"
                // TODO : 카카오 로그인 명세서 나오면 수정하기
            case .postKakaoLogin:
                return ""
            case .postReIssueToken:
                return "/reissue"
            case .patchMemberInfo:
                return "/member"
            }
        }
        
        var method: Moya.Method {
            switch self {
            case .patchMemberInfo :
                return .patch
            default:
                return .post
            }
        }
        
        var task: Task {
            switch self {
            case .postLogin(let data):
                return .requestJSONEncodable(data)
            case .postLogout, .postReIssueToken:
                return .requestPlain
            case .postJoin(let data):
                return .requestJSONEncodable(data)
            case .postAppleLogin(let data):
                return .requestJSONEncodable(data)
            case .postKakaoLogin:
                // TODO : 아마도 dto -> return .requestJSONEncodable(data)
                return .requestPlain
            case .patchMemberInfo(let data):
                return .requestJSONEncodable(data)
            }
        }
        
        var headers: [String : String]? {
            var headers: [String: String] = [
                "Content-type": "application/json"
            ]
            
            switch self {
            case .patchMemberInfo, .postReIssueToken:
                if let cookies = HTTPCookieStorage.shared.cookies {
                    let cookieHeader = HTTPCookie.requestHeaderFields(with: cookies)
                    for (key, value) in cookieHeader {
                        headers[key] = value // 쿠키를 헤더에 추가
                    }
                }
            default:
                break
            }
            return headers
        }
        
        var validationType: ValidationType {
            return .successCodes
        }
    }
    

Service 코드

  1. Network Manager Protocol 선언
    // Copyright © 2024 DRINKIG. All rights reserved
    
    import Moya
    import Foundation
    
    protocol NetworkManager {
        associatedtype Endpoint: TargetType
        
        var provider: MoyaProvider<Endpoint> { get }
        
        func request<T: Decodable>(
            target: Endpoint,
            decodingType: T.Type,
            completion: @escaping (Result<T, NetworkError>) -> Void
        )
    }
  1. Network Manager 공통 함수 구현
    • Response가 오는 API와, EmptyResponse가 오는 API를 분리하고, StatusCode를 처리하는 함수를 추가하였다.
    // Copyright © 2024 DRINKIG. All rights reserved
    
    import Moya
    import Foundation
    
    extension NetworkManager {
        func request<T: Decodable>(
            target: Endpoint,
            decodingType: T.Type,
            completion: @escaping (Result<T, NetworkError>) -> Void
        ) {
            provider.request(target) { result in
                switch result {
                case .success(let response):
                    let result: Result<T, NetworkError> = handleResponse(response, decodingType: decodingType)
                    completion(result)
                    
                case .failure(let error):
                    let networkError = handleNetworkError(error)
                    completion(.failure(networkError))
                }
            }
        }
        
        func requestStatusCode(
            target: Endpoint,
            completion: @escaping (Result<Void, NetworkError>) -> Void
        ) {
            provider.request(target) { result in
                switch result {
                case .success(let response):
                    let result: Result<ApiResponse<EmptyResponse>, NetworkError> = handleResponse(
                        response,
                        decodingType: ApiResponse<EmptyResponse>.self
                    )
                    
                    switch result {
                    case .success:
                        completion(.success(()))
                    case .failure(let error):
                        completion(.failure(error))
                    }
                    
                case .failure(let error):
                    let networkError = handleNetworkError(error)
                    completion(.failure(networkError))
                }
            }
        }
        
        // MARK: - 상태 코드 처리 처리 함수
        private func handleResponse<T: Decodable>(
            _ response: Response,
            decodingType: T.Type
        ) -> Result<T, NetworkError> {
            do {
                guard (200...299).contains(response.statusCode) else {
                    // 상태 코드별 기본 메시지 설정
                    let errorMessage: String
                    switch response.statusCode {
                    case 300..<400:
                        errorMessage = "리다이렉션 오류가 발생했습니다. 코드: \(response.statusCode)"
                    case 400..<500:
                        errorMessage = "클라이언트 오류가 발생했습니다. 코드: \(response.statusCode)"
                    case 500..<600:
                        errorMessage = "서버 오류가 발생했습니다. 코드: \(response.statusCode)"
                    default:
                        errorMessage = "알 수 없는 오류가 발생했습니다. 코드: \(response.statusCode)"
                    }
                    
                    // 서버 응답 메시지 디코딩
                    let errorResponse = try? JSONDecoder().decode(ErrorResponse.self, from: response.data)
                    let finalMessage = errorResponse?.message ?? errorMessage
                    
                    return .failure(.serverError(statusCode: response.statusCode, message: finalMessage))
                }
                
                let apiResponse = try JSONDecoder().decode(ApiResponse<T>.self, from: response.data)
                return .success(apiResponse.result)
                
            } catch {
                return .failure(.decodingError)
            }
        }
        
        // MARK: - 네트워크 오류 처리 함수
        private func handleNetworkError(_ error: Error) -> NetworkError {
            let nsError = error as NSError
            switch nsError.code {
            case NSURLErrorNotConnectedToInternet:
                return .networkError(message: "인터넷 연결이 끊겼습니다.")
            case NSURLErrorTimedOut:
                return .networkError(message: "요청 시간이 초과되었습니다.")
            default:
                return .networkError(message: "네트워크 오류가 발생했습니다.")
            }
        }
    }
    
  1. Service Class 구현

    네트워크매니저를 채택하여 API 케이스에 맞는 함수를 작성해주었다. 또한, DTO를 생성하는 함수도 함께 정의해주었다.

    // Copyright © 2024 DRINKIG. All rights reserved
    
    import Foundation
    import Moya
    
    final class AuthService : NetworkManager {
        typealias Endpoint = AuthorizationEndpoints
        
        // MARK: - Provider 설정
        let provider: MoyaProvider<AuthorizationEndpoints>
        
        init(provider: MoyaProvider<AuthorizationEndpoints>? = nil) {
            // 플러그인 추가
            let plugins: [PluginType] = [
                NetworkLoggerPlugin(configuration: .init(logOptions: .verbose)) // 로그 플러그인
            ]
            
            // provider 초기화
            self.provider = provider ?? MoyaProvider<AuthorizationEndpoints>(plugins: plugins)
        }
        
        // MARK: - DTO funcs
        
        /// 로그인 데이터 구조 생성
        public func makeLoginDTO(username: String, password: String) -> LoginDTO {
            return LoginDTO(username: username, password: password)
        }
        
        /// 자체 회원가입 데이터 구조 생성
        public func makeJoinDTO(username: String, password: String, rePassword: String) -> JoinDTO {
            return JoinDTO(username: username, password: password, rePassword: rePassword)
        }
    
        //MARK: - API funcs
        /// 자체 로그인 API
        public func login(data: LoginDTO, completion: @escaping (Result<LoginResponseDTO, NetworkError>) -> Void) {
            request(target: .postLogin(data: data), decodingType: LoginResponseDTO.self, completion: completion)
        }
        
        /// 로그아웃 API
        public func logout(completion: @escaping (Result<Void, NetworkError>) -> Void) {
            requestStatusCode(target: .postLogout, completion: completion)
        }
        
        /// 자체 회원가입 API
        public func join(data: JoinDTO, completion: @escaping (Result<Void, NetworkError>) -> Void) {
            requestStatusCode(target: .postJoin(data: data), completion: completion)
        }
        
        /// 토큰 재발급 API
        public func reissueToken(completion: @escaping (Result<Void, NetworkError>) -> Void) {
            requestStatusCode(target: .postReIssueToken, completion: completion)
        }
    }
    

Error Handling 코드

  1. 앞선 NetworkManager extension에서 공통 함수를 사용하여 StatusCode와 Error를 처리해주었는데, Error는 Enum으로 처리하였다.
    // Copyright © 2024 DRINKIG. All rights reserved
    
    import Foundation
    
    enum NetworkError: Error {
        case networkError(message: String) // 네트워크 오류
        case decodingError // 디코딩 실패
        case serverError(statusCode: Int, message: String) // 서버 관련 오류
        case unknown // 알 수 없는 오류
    }
    
    extension NetworkError: LocalizedError {
        var errorDescription: String? {
            switch self {
            case .networkError(let message):
                return "네트워크 오류: \(message)"
            case .decodingError:
                return "데이터 디코딩에 실패했습니다."
            case .serverError(let statusCode, let message):
                return "[오류 \(statusCode)] \(message)"
            case .unknown:
                return "알 수 없는 오류가 발생했습니다."
            }
        }
    }
    
    struct ErrorResponse: Decodable {
        let message: String
    }
  • 이 부분은 나중에 네트워크 환경이 다시 연결되었을 때, 작업 큐에 있는 API 호출 작업을 재시도할 수 있도록(로컬 캐싱) 구조를 리팩토링할 때 더 유용하게 활용할 계획이다.

2025.01.26 버전 Concurrency 적용 리팩토링

  • Concurrency를 통해서 viewController(Present 단의 코드)에서의 api 호출 코드를 더 단순화하였다. 또한, 동시에 API를 요청할 수 있도록 구조를 개선하였다.(다른 포스팅으로 작성할 예정)

🧑‍💻 네트워크서비스 활용

이렇게 정의된 네트워크 서비스를 실제로 Feature에서 활용하는 예시 코드를 작성해보았다. 레거시 코드와 비교했을 때, Present 단의 코드가 대폭 감소하였다.

🛠️ 서비스 활용 예제

  1. 모듈 Import
    import NetworkModule
  1. authService 의존성 주입
    • DI(의존성 주입) 방식으로 AuthService 인스턴스를 생성.
    • API 호출 시 서비스에 접근하여 DTO 생성 및 요청 처리.
    private let authService = AuthService()
  1. DTO 생성 및 API 호출
    @objc private func signUpButtonTapped() {
    		...
        
        // DTO 생성 및 API 호출
        let joinDTO = authService.makeJoinDTO(username: username, password: password, rePassword: rePassword)
        authService.join(data: joinDTO) { [weak self] result in
            guard let self = self else { return }
            
            switch result {
            case .success:
                self.showAlert(message: "회원가입 성공!")
                self.navigateToLogin()
            case .failure(let error):
                self.showAlert(message: error.localizedDescription)
            }
        }
    }

추가 활용 방안

  1. 뷰 업데이트가 필요한 경우

    Ex. 로딩 상태 표시, 상태에 따른 버튼 활성화/비활성화, 에러 메세지 표시, 데이터 바인딩 등

    import UIKit
    import Authentication
    
    final class JoinViewController: UIViewController {
    
        private let authService = AuthService()
        
        // 상태 관리(Enum)
        enum ViewState {
            case idle               // 초기 상태
            case loading            // 로딩 중
            case success            // 성공
            case error(String)      // 에러 메시지 포함
        }
        
        // 현재 상태 저장
        private var viewState: ViewState = .idle {
            didSet {
                updateUI(for: viewState)
            }
        }
        
        // UI 요소
        private let signUpButton = UIButton()
        private let statusLabel = UILabel() // 상태 표시
        
        override func viewDidLoad() {
            super.viewDidLoad()
            setupUI()
            setupActions()
        }
        
        // MARK: - Actions
        @objc private func signUpButtonTapped() {
            // 로딩 상태로 전환
            viewState = .loading
            
            // API 요청
            let joinDTO = authService.makeJoinDTO(username: "testUser", password: "testPass", rePassword: "testPass")
            authService.join(data: joinDTO) { [weak self] result in
                guard let self = self else { return }
                
                switch result {
                case .success:
                    self.viewState = .success
                case .failure(let error):
                    self.viewState = .error(error.localizedDescription)
                }
            }
        }
        
        // MARK: - UI 업데이트
        private func updateUI(for state: ViewState) {
            switch state {
            case .idle:
                statusLabel.text = ""
                signUpButton.isEnabled = true
                signUpButton.backgroundColor = .blue
            case .loading:
                statusLabel.text = "로딩 중..."
                signUpButton.isEnabled = false
                signUpButton.backgroundColor = .gray
            case .success:
                statusLabel.text = "회원가입 성공!"
                signUpButton.isEnabled = true
                signUpButton.backgroundColor = .green
            case .error(let message):
                statusLabel.text = message
                signUpButton.isEnabled = true
                signUpButton.backgroundColor = .red
            }
        }
        
        private func setupActions() {
            signUpButton.addTarget(self, action: #selector(signUpButtonTapped), for: .touchUpInside)
        }
    }

→ MVVM 패턴으로 구현한 경우

    import Foundation
    import Authentication
    
    final class JoinViewModel {
        
        // MARK: - Properties
        private let authService: AuthService
        private(set) var state: Observable<ViewState> = Observable(.ile)
        
        // 상태(Enum)
        enum ViewState {
            case idle
            case loading
            case success
            case error(String)
        }
        
        // 초기화
        init(authService: AuthService = AuthService()) {
            self.authService = authService
        }
        
        // 회원가입 API 호출
        func join(username: String, password: String, rePassword: String) {
            // 상태를 로딩으로 설정
            state.value = .loading
            
            // DTO 생성 및 API 호출
            let joinDTO = authService.makeJoinDTO(username: username, password: password, rePassword: rePassword)
            authService.join(data: joinDTO) { [weak self] result in
                guard let self = self else { return }
                
                switch result {
                case .success:
                    self.state.value = .success
                case .failure(let error):
                    self.state.value = .error(error.localizedDescription)
                }
            }
        }
    }
    import UIKit
    
    final class JoinViewController: UIViewController {
        
        // MARK: - Properties
        private let viewModel = JoinViewModel()
        private let usernameField = UITextField()
        private let passwordField = UITextField()
        private let rePasswordField = UITextField()
        private let signUpButton = UIButton()
        private let statusLabel = UILabel()
        
        override func viewDidLoad() {
            super.viewDidLoad()
            setupUI()
            setupActions()
            bindViewModel()
        }
        
        private func setupActions() {
            signUpButton.addTarget(self, action: #selector(signUpButtonTapped), for: .touchUpInside)
        }
        
        // MARK: - ViewModel 데이터 바인딩
        private func bindViewModel() {
            viewModel.state.bind { [weak self] state in
                guard let self = self else { return }
                switch state {
                case .idle:
                    self.statusLabel.text = ""
                    self.signUpButton.isEnabled = true
                    self.signUpButton.backgroundColor = .blue
                case .loading:
                    self.statusLabel.text = "로딩 중..."
                    self.signUpButton.isEnabled = false
                    self.signUpButton.backgroundColor = .gray
                case .success:
                    self.statusLabel.text = "회원가입 성공!"
                    self.signUpButton.isEnabled = true
                    self.signUpButton.backgroundColor = .green
                    self.navigateToLogin()
                case .error(let message):
                    self.statusLabel.text = message
                    self.signUpButton.isEnabled = true
                    self.signUpButton.backgroundColor = .red
                }
            }
        }
        
        // MARK: - Actions
        @objc private func signUpButtonTapped() {
            guard let username = usernameField.text,
                  let password = passwordField.text,
                  let rePassword = rePasswordField.text else { return }
            viewModel.join(username: username, password: password, rePassword: rePassword)
        }
        
        private func navigateToLogin() {
            let loginVC = LoginViewController()
            navigationController?.pushViewController(loginVC, animated: true)
        }
    }
  • RxSwift를 사용했으면 더 쉽게 설계했을텐데,,, 다음 프로젝트는 필히 Rx로 해야겠다...
profile
Kirby-like iOS developer

0개의 댓글