네트워크 요청 로직 분기처리
서버를 만든 후 설계한 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
- 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
)
}
전체 코드
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
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
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
끝으로
이번 구현을 통해 모든 네트워크 로직을 하나의 패턴으로 통일할 필요는 없다는 걸 배웠다. 무엇보다 이렇게 두 가지 방식을 상황에 맞게 구조화하니 호출부 코드가 놀라울 만큼 단순해졌다. 뷰나 뷰모델에서는 복잡한 네트워크 처리 과정을 신경 쓸 필요 없이, 성공이면 데이터를 반영하고 실패면 메시지를 띄우는 식으로 성공/실패 분기만 깔끔하게 처리하면 충분했다.
즉, 네트워크 계층의 복잡성을 하위 레벨에서 책임지고, 상위 레벨은 “이벤트 중심 흐름”과 “데이터 중심 흐름”에만 집중할 수 있게 된 것이다. 이 경험을 통해 상황에 맞는 방식을 선택하는 것이 결국 유지보수성과 확장성, 그리고 개발 생산성까지 높인다는 걸 체감할 수 있었다.
'프로젝트 > 오늘의 커밋' 카테고리의 다른 글
| [FastAPI] 커서(cursor) 기반 페이지네이션 (0) | 2025.09.24 |
|---|---|
| [SwiftUI & UIKit] View 외부에서 띄울 수 있는 알림 & 로딩 오버레이 구현 (0) | 2025.09.21 |
| [SwiftUI] ObservableObject를 구독하는 ObservableObject (0) | 2025.08.24 |
| [Data] 내 손으로 만드는 맞춤형 지도 데이터 (2) (1) | 2025.07.13 |
| [서버] 라즈베리파이(4) [서버 마이그레이션 기록] (1) | 2025.07.09 |