iOS Networking Study

네트워크 레이어 · 견고한 파싱 · 토큰 리프레시

화면마다 URLSession을 직접 쓰지 않고 계층으로 나누는 이유, DTO ↔ 도메인 모델 분리, 동시 401 토큰 리프레시(시니어 빈출), Codable 견고한 파싱(Lossy Decoding)을 문답으로 정리한 학습 문서.

§1. 네트워크 레이어를 계층으로 나누는 이유

Q. URLSession을 화면마다 직접 쓰면 뭐가 문제인가?

Q. 어떤 계층으로 나누나?

① Endpoint(요청 정의) — path, method, 파라미터, 헤더     "무엇을"
② Encoding(요청 조립) — URLRequest 생성, 쿼리/바디        "어떻게 URLRequest로"
③ Client(실행)       — URLSession, 공통 헤더/인증/재시도    "실제로 보냄"
④ Decoding(응답 파싱) — Codable, 상태코드 검사             "결과를 모델로"
⑤ Error Mapping      — HTTP/네트워크 에러 → 도메인 에러      "실패를 의미있게"

요청을 값(데이터)으로 정의

protocol Endpoint {
    var path: String { get }
    var method: HTTPMethod { get }
    var queryItems: [URLQueryItem]? { get }
    var body: Encodable? { get }
    var headers: [String: String]? { get }
}

struct GetUserEndpoint: Endpoint {
    let path = "/users/me"; let method = HTTPMethod.get
    var queryItems: [URLQueryItem]? { nil }
    var body: Encodable? { nil }
    var headers: [String: String]? { nil }
}

// 사용부: let user: User = try await client.request(GetUserEndpoint())

각 화면은 "무엇을 요청할지(Endpoint)만 선언" 하고, 인증/로깅/재시도/에러는 Client가 공통으로 담당한다. 정책을 바꿀 땐 Client 한 곳만 고치면 된다.

참고: Moya가 이 구조(Endpoint 정의 → Provider 실행)를 라이브러리로 제공한다. Alamofire는 주로 ③(실행) 계층을 대체하는 도구.
한 줄 요약: 계층 분리 = "무엇을(Endpoint)"과 "어떻게(Client)"를 떼어내는 것. 공통 처리를 한 곳에 모아 중복·변경 비용·테스트 불가를 동시에 해결한다.

§2. DTO ↔ 도메인 모델 분리

Q. 서버 응답 모델(DTO)과 앱 도메인 모델을 왜 분리하나?

  1. 변경 격리(핵심) — 서버 스펙 변경의 충격을 매핑 한 곳에 가둔다. DTO의 namenickname으로 바뀌어도 도메인 User.name은 그대로, 매핑 함수만 수정 → 화면 100개를 안 건드림.
  2. 모양이 다름 — 서버는 flat하거나 분할돼 있고, 앱은 화면에 맞게 합치거나 재구성한다(firstName + lastName → fullName).
  3. 서버의 지저분함 차단 — DTO가 String?·이상한 날짜·0/1 불리언을 흡수하고, 도메인은 안전한 타입(비옵셔널/Date/Bool/enum)으로 정제 → 옵셔널 지옥 회피.
  4. 테스트·격리 — 도메인 로직을 서버 없이 테스트. DTO는 네트워크 계층에만 머문다.
트레이드오프: 작은 앱에서는 매핑이 보일러플레이트가 된다 → DTO = 도메인 하나로 가기도 한다. 규모·변경 빈도에 따라 선택하라.

§3. 토큰 리프레시 동시성 (시니어 빈출)

Q. 여러 요청이 동시에 401을 받으면 뭐가 문제인가?

토큰 만료 순간 동시에 5개 요청이 나가면 모두 401을 받는다 → 각자 리프레시를 시도 → 리프레시 5번 중복. 서버가 리프레시 토큰을 1회용(rotation)으로 관리하면 첫째만 성공하고 나머지는 무효화 → 로그아웃 대참사.

목표: 리프레시는 딱 한 번만 하고, 나머지 요청은 그 결과를 기다렸다가 새 토큰으로 재시도한다.

Q. 해결 = in-flight dedup을 actor로

actor AuthManager {
    private var currentToken: Token
    private var refreshTask: Task<Token, Error>?      // 진행 중인 리프레시

    func validToken() async throws -> Token {
        if let task = refreshTask { return try await task.value }
        return currentToken
    }

    func refreshToken(afterFailedWith usedToken: Token) async throws -> Token {
        if let task = refreshTask { return try await task.value }   // ① 진행중이면 공유
        if usedToken != currentToken { return currentToken }        // ② 그새 갱신됨 → 새 토큰만

        let task = Task { () throws -> Token in                      // ③ 첫 주자
            defer { refreshTask = nil }
            let new = try await performRefresh(currentToken.refresh)
            currentToken = new
            return new
        }
        refreshTask = task           // ★ await 전에 박아둠 → 뒤에 온 401들이 공유
        return try await task.value
    }
}

Client 재시도 흐름

func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
    let token = try await auth.validToken()
    var res = try await send(endpoint, token: token)
    if res.statusCode == 401 {
        let newToken = try await auth.refreshToken(afterFailedWith: token)  // 딱 1회만 진짜 리프레시
        res = try await send(endpoint, token: newToken)                     // 재시도 1회
        if res.statusCode == 401 { throw AuthError.loggedOut }              // 그래도 401 → 로그아웃
    }
    return try decode(res)
}

시니어 포인트 3가지

  1. in-flight deduprefreshTaskawait 전에 박아둔다 → 동시 401 중 첫째만 실제 리프레시하고 나머지는 그 Task를 공유 → 정확히 1회.
  2. actor serial executor — actor 진입은 한 번에 하나뿐 → "체크 → 박기" 사이의 레이스를 원천 차단(별도 락 불필요).
  3. "이미 갱신됨" 체크(usedToken != currentToken) — 리프레시 직후, 낡은 토큰으로 뒤늦게 도착한 401이 또 리프레시하는 것을 방지. 이 시점엔 refreshTask가 이미 nil이라 ①에 안 걸리므로 ②가 필요하다.
추가: 재시도는 1회만(무한 루프 방지), 재시도도 401이면 로그아웃.

§4. Codable 견고한 파싱

Q. 피드 배열 100개 중 몇 개가 깨졌을 때 나머지를 살리려면? (Lossy Decoding)

기본 [Post].self원소 1개만 깨져도 전체가 throw된다. 각 원소를 실패 허용 래퍼로 감싸라.

struct Failable<T: Decodable>: Decodable {
    let value: T?
    init(from decoder: Decoder) throws {
        let c = try decoder.singleValueContainer()
        self.value = try? c.decode(T.self)      // 이 원소만 실패해도 nil
    }
}

let wrapped = try JSONDecoder().decode([Failable<Post>].self, from: data)
let posts = wrapped.compactMap(\.value)         // 정상 항목만 살아남음

100개 중 3개가 깨져도 97개는 표시된다.

⚠️ 조용히 버리지 마라: 깨진 개수를 로깅/모니터링하라. 안 그러면 서버 이슈를 감지 못 한다.

Q. 필드 누락/null 흡수?

title = try c.decodeIfPresent(String.self, forKey: .title) ?? ""
likeCount = try c.decodeIfPresent(Int.self, forKey: .likeCount) ?? 0
isPinned = try c.decodeIfPresent(Bool.self, forKey: .isPinned) ?? false

Q. 서버 네이밍/날짜 자동 변환?

decoder.keyDecodingStrategy = .convertFromSnakeCase   // like_count → likeCount
decoder.dateDecodingStrategy = .iso8601               // 문자열 → Date

Q. 타입이 오락가락(문자열 "123" vs 숫자 123)?

커스텀 init(from:)에서 여러 타입을 순차 시도한다: try? decode(Int)try? decode(String) → 변환.

요약 원칙: DTO에서 서버의 지저분함(누락/null/타입 불일치/부분 실패)을 흡수하고, 도메인은 안전한 타입으로. 부분 실패 허용 + 깨진 것 로깅이 운영 감각이다.