우리는 그동안 Moya를 통해서 URLSession, Alamofire를 직접 사용하지 않고 한 단계 추상화하여 사용해왔다. 하지만 Moya를 사용하는 데도 불편함이 있었는데…
중복된 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)
}
}
}
View Controller에 의존적인 API 함수
위와 같은 함수를 뷰컨트롤러 내에서 정의하다보니, 동일한 API를 다른 뷰컨트롤러에서 사용하는 경우에도 해당 함수를 재정의한 후 사용할 수 밖에 없었다.
Error Handling
기존 코드에서는 에러 처리 방식이 통일되지 않아, 동일한 상태 코드 처리와 에러 메시지 처리 코드가 API 함수마다 반복되었다. 이로 인해 네트워크 에러 대응이 어려워지고, 에러 관리 로직이 여러 곳에 분산되어 유지보수가 복잡해졌다. 데모데이에 앱을 시연하기 위해서 네트워크 에러를 모두 토스트메세지로 띄우는 코드를 추가했는데, 이 역시 매 뷰컨트롤러, API 함수마다 작성해주어야했다.(복붙 지옥)
프로젝트에서 Moya를 제대로 사용한 적이 없었고, Alamofire의 불편함을 Moya가 대체해줘서 그 당시에는 굉장한 혁신이라고 느꼈기 때문에 드링키지의 주요 기능이 추가되면서 API Controller의 개수가 10개를 넘어가니 Moya를 제대로 활용해서 네트워크 레이어를 분리하는 게 좋겠다는 생각이 들었다.
그래서 어떤 방식으로 네트워크 레이어를 구성했느냐?
일단 드링키지가 버전 2가 되면서, Tuist를 통해서 모듈화 구조로 리팩토링하였다. 이 과정에서 네트워크 모듈을 분리하여, 해당 네트워크 모듈에서 각 기능마다 Endpoint, Request, Response, Service를 개발하여, 다른 Feature 모듈에서는 Service만을 instance로 가져가 사용할 수 있도록 구조를 설계하였다.
해당 방식의 장점으로는,
정도를 꼽을 수 있겠다.
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
}
}
// 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
)
}
// 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: "네트워크 오류가 발생했습니다.")
}
}
}
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)
}
}
// 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
}
이렇게 정의된 네트워크 서비스를 실제로 Feature에서 활용하는 예시 코드를 작성해보았다. 레거시 코드와 비교했을 때, Present 단의 코드가 대폭 감소하였다.
import NetworkModule
private let authService = AuthService()
@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)
}
}
}
뷰 업데이트가 필요한 경우
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)
}
}