프로젝트/오늘의 커밋

[SwiftUI] 네트워크 요청 로직 분기처리

iris3455 2025. 9. 23. 20:34

 

네트워크 요청 로직 분기처리

 

서버를 만든 후 설계한 API 를 앱에 반영하면서 저번에 만든 collecter앱 처럼 모든 네트워크 로직을 async/await로 통일하려고 하였다. 하지만 이번 앱 "오늘의커밋" 에서는 소셜 로그인 기능을 추가하였는데 소셜 로그인은 단순한 데이터 요청이 아니라 사용자가 버튼을 누른 시점부터 시작되는 하나의 이벤트 흐름이어서 (버튼 액션 → 로그인 요청 → 성공/실패 → UI 전환) 성공과 실패를 명확하게 구분해 뷰 레이어에 바로 전달해야 했다. 

또한 로그인 과정은 일반적인 네트워크 요청과 달리 여러 예외 상황이 발생한다. 예를 들어 사용자가 로그인 팝업을 직접 취소한다거나, 카카오톡이 설치되어 있지 않아 웹 브라우저 기반 로그인으로 fallback이 된다거나, 로그인 완료 후 다시 앱으로 돌아오는 콜백 함수가 필요한 상황이 추가적으로 발생할 수 밖에 없다.

따라서 발생할 수 있는 에러 역시 단순히 “401 Unauthorized” 같은 서버 에러가 아니라 UX와 직결되는 도메인 에러이기 때문에 KakaoLoginError, AppleLoginError 같은 별도의 에러 타입을 정의하고, 이를 Result에 묶어 호출부에서 바로 처리할 수 있도록 했다. 실제로 이렇게 나누니 “사용자가 로그인 취소” → “토스트 메시지 노출” 처럼 UI 반응을 명확하게 연결할 수 있었다.

반대로, 일반 API는 토큰 갱신이나 동시성 관리가 핵심이었는데, 이걸 콜백 방식으로 처리하면 코드가 복잡해지고 재사용이 어려워지는 문제가 있었다. 따라서 소셜 로그인 로직과는 다르게 actor APIClient와 async/await 기반 구조로 진행하였다.

  • 소셜 로그인 (Kakao / Apple) → Result<T, Error> 기반 콜백 방식
  • 일반 API 요청 → actor APIClient + async/await 기반

 

로직

1. 소셜 로그인: Result 기반

 

소셜 로그인은 “ 버튼 탭 → 인증 → 앱 복귀 → UI 전환 ” 까지가 하나의 이벤트 흐름이다. 데이터 페치가 아니라 UI 상태 전환을 동반하는 상호작용이 핵심이므로, 성공/실패 결과를 즉시 호출부에 넘기고 UI를 분기해야 한다.

 

 

2. 일반 API: 데이터 중심(actor + async/await)

 

일반 API는 반복 호출, 병렬 호출, 401 → 토큰 갱신 같은 동시성 이슈가 핵심이다. 여기선 UI 이벤트보다 안정성 / 일관성 / 유지보수성이 우선이므로 actor로 상태를 보호하고 async/await로 호출부를 단순화한다.

 

코드 설명

1. 소셜 로그인

AuthService → UserAPI → View 호출부

 

AuthService

  • 카카오/애플 SDK를 직접 호출하는 진입점
  • SDK에서 토큰(accessToken / authorizationCode) 을 얻은 뒤 → 서버 API 호출로 넘김
  • 결과는 completion(Result<UserResponse, LoginError>)로 반환

 

final class AuthService {
  static let shared = AuthService()
  private init() {}

  func kakaoAuth(completion: @escaping (Result<UserResponse, KakaoLoginError>) -> Void) {
    if UserApi.isKakaoTalkLoginAvailable() {
     
     ...
     
        UserAPI.loginWithKakao(token, completion: completion)
      }
    } else {
    
      ...
      
        UserAPI.loginWithKakao(token, completion: completion)
      }
    }
  }

 

UserAPI

  • 실제 서버와 통신하는 계층
  • 카카오/애플에서 얻은 토큰을 백엔드에 전달해 최종적으로 UserResponse를 받아옴
  • Overlay 표시/숨김도 여기서 담당 → 네트워크 대기 시간 동안 UI 제어
enum UserAPI {
  static func loginWithKakao(_ token: String, completion: @escaping (Result<UserResponse, KakaoLoginError>) -> Void) {
    // api 응답 받을때까지 로딩 화면 보여주기
    let overlayVC = Overlay.show(LoadingView())
    guard let url = URL(string: "\(AppConfig.baseURL)/user/login/kakao?access_token=\(token)") else {
      return completion(.failure(.networkError(NSError(domain: "URL 생성 실패", code: -1))))
    }

    URLSession.shared.dataTask(with: url) { data, _, error in
    // 응답 받으면 오버레이 dismiss
      DispatchQueue.main.async { overlayVC.dismiss(animated: true) }
      if let error { return completion(.failure(.serverError(error))) }
      guard let data else { return completion(.failure(.noData)) }

      do {
        let user = try JSONDecoder().decode(UserResponse.self, from: data)
        completion(.success(user))
      } catch {
        completion(.failure(.decodingError(error)))
      }
    }.resume()
  }
}

 

 

 

2. 일반 API

APIClient
  • actor로 선언해서 동시성 안전을 확보
  • isRefreshing 플래그로 토큰 갱신이 동시에 여러 번 일어나는 걸 막음
 
actor APIClient {
  static let shared = APIClient()
  private let baseURL = AppConfig.baseURL
  private var isRefreshing = false
}

 

requestJSON 메서드

  • 실제 API 호출을 담당하는 메서드
  • authRequired = true일 경우 → Authorization 헤더에 Bearer 토큰을 붙임
  • 응답 코드 확인 → 200~299는 정상, 401은 토큰 만료, 나머지는 서버 에러 처리
func requestJSON<T: Decodable>(
  path: String,
  method: String = "GET",
  body: Data? = nil,
  response: T.Type,
  authRequired: Bool = false,
  attempt: Int = 0
) async throws -> T {
  let url = URL(string: baseURL + path)!
  var req = URLRequest(url: url)
  req.httpMethod = method

  if authRequired {
    guard let token = UserSessionManager.accessToken else {
      throw CustomError.tokenMissing
    }
    req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
  }

  let (data, resp) = try await URLSession.shared.data(for: req)
  guard let http = resp as? HTTPURLResponse else { throw CustomError.noData }

 

 

401 처리와 토큰 갱신

  • 만약 상태 코드가 401이면 → getRefreshToken()을 통해 토큰 갱신을 시도
  • 갱신에 성공하면 원래 요청 재시도
  if http.statusCode == 401 {
    guard attempt == 0 else { throw CustomError.unauthorized }
    try await getRefreshToken()
    return try await requestJSON(
      path: path,
      method: method,
      body: body,
      response: response,
      authRequired: authRequired,
      attempt: attempt + 1
    )
  }

  guard (200...299).contains(http.statusCode) else {
    throw CustomError.serverError(statusCode: http.statusCode, data: data)
  }
  return try JSONDecoder().decode(T.self, from: data)
}

 

토큰 갱신 로직

  • 토큰 갱신은 여러 Task가 동시에 들어올 수 있음
  • 이미 다른 요청이 갱신 중이면 Task.sleep으로 잠시 대기
  • 내가 선두라면 → TokenAPI.updateToken() 실행
  • 끝나면 defer로 플래그 초기화
private func getRefreshToken() async throws {
  if isRefreshing {
    while isRefreshing { try await Task.sleep(nanoseconds: 120_000_000) }
    return
  }
  isRefreshing = true
  defer { isRefreshing = false }
  try await TokenAPI.updateToken()
}

 

 

 

사용 코드

1) 소셜 로그인

  • Result 타입에 따라 분기 처리
struct KakaoLoginButton: View {
  @Environment(\.dismiss) private var dismiss
  @State private var tempUserData: UserResponse? = nil

  var body: some View {
    Button(action: {
      AuthService.shared.kakaoAuth { result in
        switch result {
        case let .success(user):
          if user.isFirstLogin {
            tempUserData = user
          } else {
            UserSessionManager.saveUserSession(user)
            dismiss()
          }
        case let .failure(error):
          print("❌ 로그인 실패: \(error.localizedDescription)")
        }
      }
    }

 

2) API 요청

  • api 요청후 반환값 리턴
  static func getMyGrass(_ mapId: Int) async throws -> GrassResponse {
    try await APIClient.shared.requestJSON(
      path: "/grass/mygrass",
      query: [URLQueryItem(name: "map_id", value: String(mapId))],
      response: GrassResponse.self,
      authRequired: true
    )
  }

 

 

 

전체 코드

https://github.com/irismake/todays-commit/blob/02f01f0793cac9a39a371d4eac1c9ea9282c682b/TodaysCommit/Service/Auth/AuthService.swift

 

todays-commit/TodaysCommit/Service/Auth/AuthService.swift at 02f01f0793cac9a39a371d4eac1c9ea9282c682b · irismake/todays-commit

Contribute to irismake/todays-commit development by creating an account on GitHub.

github.com

 

https://github.com/irismake/todays-commit/blob/02f01f0793cac9a39a371d4eac1c9ea9282c682b/TodaysCommit/Service/API/User.swift

 

todays-commit/TodaysCommit/Service/API/User.swift at 02f01f0793cac9a39a371d4eac1c9ea9282c682b · irismake/todays-commit

Contribute to irismake/todays-commit development by creating an account on GitHub.

github.com

 

https://github.com/irismake/todays-commit/blob/02f01f0793cac9a39a371d4eac1c9ea9282c682b/TodaysCommit/Service/API/APIClient.swift

 

todays-commit/TodaysCommit/Service/API/APIClient.swift at 02f01f0793cac9a39a371d4eac1c9ea9282c682b · irismake/todays-commit

Contribute to irismake/todays-commit development by creating an account on GitHub.

github.com

 

 

 

 

끝으로

 

이번 구현을 통해 모든 네트워크 로직을 하나의 패턴으로 통일할 필요는 없다는 걸 배웠다. 무엇보다 이렇게 두 가지 방식을 상황에 맞게 구조화하니 호출부 코드가 놀라울 만큼 단순해졌다. 뷰나 뷰모델에서는 복잡한 네트워크 처리 과정을 신경 쓸 필요 없이, 성공이면 데이터를 반영하고 실패면 메시지를 띄우는 식으로 성공/실패 분기만 깔끔하게 처리하면 충분했다.

즉, 네트워크 계층의 복잡성을 하위 레벨에서 책임지고, 상위 레벨은 “이벤트 중심 흐름”과 “데이터 중심 흐름”에만 집중할 수 있게 된 것이다. 이 경험을 통해 상황에 맞는 방식을 선택하는 것이 결국 유지보수성과 확장성, 그리고 개발 생산성까지 높인다는 걸 체감할 수 있었다.