LODY/정리

애플 플랫폼 백과사전 / Alamofire 제대로 쓰기

Alamofire 제대로 쓰기

Alamofire는 Apple URLSession 위에 올라가는 Swift 네트워킹 프레임워크다. 기본 사용법(AF.request, responseDecodable)은 단순하지만, 실제 프로덕션 앱에서는 엔드포인트 관리, 인증 헤더 주입, 토큰 자동 갱신, 스트리밍, 업로드 등 고급 기능이 필요해진다.

이 글은 튜토리얼이 아니라 레퍼런스다. 각 개념이 왜 존재하는지, 어떻게 연결되는지를 코드와 도표로 정리한다.


Router 패턴

앱의 엔드포인트가 10개를 넘기 시작하면 URL 문자열이 곳곳에 흩어지고, 파라미터 조합 로직이 중복된다. Router 패턴은 각 엔드포인트를 하나의 타입으로 캡슐화하여 이 문제를 해결한다.

Alamofire는 URLRequestConvertible 프로토콜을 제공한다. 이 프로토콜을 채택하면 enum이 직접 URLRequest를 만들어서 AF.request()에 넘길 수 있다.

기본 구조

import Alamofire
 
enum DndRouter: URLRequestConvertible {
    case monsters
    case equipment(name: String)
 
    private var baseURL: URL {
        URL(string: "https://www.dnd5eapi.co/api")!
    }
 
    private var path: String {
        switch self {
        case .monsters:           return "/monsters"
        case .equipment:          return "/equipment"
        }
    }
 
    private var method: HTTPMethod {
        switch self {
        case .monsters, .equipment: return .get
        }
    }
 
    private var parameters: Parameters? {
        switch self {
        case .monsters:
            return nil
        case .equipment(let name):
            return ["name": name]
        }
    }
 
    func asURLRequest() throws -> URLRequest {
        let url = baseURL.appendingPathComponent(path)
        var request = URLRequest(url: url)
        request.method = method
        return try URLEncodedFormParameterEncoder.default
            .encode(parameters, into: request)
    }
}

사용

// Before — 문자열 직접 사용
AF.request("https://www.dnd5eapi.co/api/monsters")
 
// After — Router 사용
AF.request(DndRouter.monsters)
AF.request(DndRouter.equipment(name: "longsword"))

실전 예시: ImagesRouter

인증이 필요한 REST API는 헤더까지 Router에서 조립한다. Imgur API를 예로 들면:

enum ImagesRouter: URLRequestConvertible {
    case search(query: String)
    case upload
 
    private var baseURL: URL {
        URL(string: "https://api.imgur.com/3")!
    }
 
    private var path: String {
        switch self {
        case .search:  return "/gallery/search"
        case .upload:  return "/image"
        }
    }
 
    private var method: HTTPMethod {
        switch self {
        case .search:  return .get
        case .upload:  return .post
        }
    }
 
    func asURLRequest() throws -> URLRequest {
        let url = baseURL.appendingPathComponent(path)
        var request = URLRequest(url: url)
        request.method = method
 
        switch self {
        case .search(let query):
            return try URLEncodedFormParameterEncoder.default
                .encode(["q": query], into: request)
        case .upload:
            return request
        }
    }
}

upload는 파라미터 없이 URLRequest만 반환하고, 실제 바디는 Session.upload(multipartFormData:with:)에서 구성한다.

URLConvertible vs URLRequestConvertible

프로토콜구현 메서드역할
URLConvertibleasURL() throws -> URLURL만 생성
URLRequestConvertibleasURLRequest() throws -> URLRequest메서드·헤더·파라미터까지 포함한 전체 요청 생성

String, URL, URLComponents는 이미 URLConvertible을 채택하고 있어서 AF.request("https://...") 같은 단순 호출이 가능하다. 엔드포인트별로 설정이 필요하면 URLRequestConvertible을 채택한 Router를 쓴다.

// URLConvertible 예시 — URL만 필요할 때 (DataStreamRequest 등)
extension GalleryItem: URLConvertible {
    func asURL() throws -> URL {
        guard let url = URL(string: link) else {
            throw AFError.invalidURL(url: link)
        }
        return url
    }
}
 
// AF.streamRequest(item) 처럼 모델을 직접 전달 가능

Parameter Encoding

Alamofire는 Encodable 타입을 파라미터로 지원하며, 두 가지 ParameterEncoder를 제공한다.

Encoder인코딩 결과기본 destination
URLEncodedFormParameterEncoderkey=value&key2=value2GET/HEAD/DELETE → query string, 나머지 → HTTP body
JSONParameterEncoderJSON objectHTTP body
// URL 쿼리 스트링으로 명시
try URLEncodedFormParameterEncoder(
    destination: .queryString
).encode(parameters, into: &request)
 
// JSON body로 명시
try JSONParameterEncoder.default.encode(body, into: &request)

두 인코더 모두 Content-Type 헤더가 설정되지 않은 경우 자동으로 적절한 값을 세팅한다 (application/x-www-form-urlencoded, application/json).


Response Serialization

서버 응답을 앱 모델로 변환하는 과정이다. Alamofire는 세 가지 내장 ResponseSerializer를 제공한다.

Serializer반환 타입용도
DataResponseSerializerData커스텀 파싱, 바이너리 처리
StringResponseSerializerString텍스트 응답, HTML
DecodableResponseSerializerT: DecodableREST API JSON 응답

validate()

응답 직렬화 전에 상태코드와 Content-Type을 검증하는 체이닝 메서드다.

AF.request(DndRouter.monsters)
    .validate()               // 기본: 상태코드 200-299만 성공 처리
    .responseDecodable(of: MonsterList.self) { response in ... }
 
// 허용 범위 직접 지정
AF.request(url)
    .validate(statusCode: 200..<300)
    .validate(contentType: ["application/json"])
    .responseDecodable(of: Model.self) { ... }

validate()를 붙이지 않으면 서버가 500을 반환해도 .success로 처리된다. 실제 에러 분기는 responseDecodableresult에서 별도로 해야 한다. 거의 항상 붙이는 것이 맞다.

커스텀 Error Serializer 구현

API가 에러 응답을 별도 모델로 내려주는 경우 커스텀 ResponseSerializer가 필요하다.

// 서버 에러 응답 모델
struct APIErrorResponse: Decodable {
    let status: Int
    let error: APIErrorBody
 
    struct APIErrorBody: Decodable {
        let code: Int
        let message: String
    }
}
 
enum ResponseError: Error {
    case authentication          // 401, 403
    case server(APIErrorResponse)
    case connectivity            // 인터넷 없음
}
 
struct ErrorResponseSerializer: ResponseSerializer {
    func serialize(
        request: URLRequest?,
        response: HTTPURLResponse?,
        data: Data?,
        error: Error?
    ) throws -> ResponseError {
 
        // 1. Adaptation 에러 먼저 처리
        if let error = error as? AFError,
           case .requestAdaptationFailed(let underlying) = error {
            throw underlying
        }
 
        // 2. 데이터 없음 = 연결 에러
        guard let data = data else {
            return .connectivity
        }
 
        // 3. 서버 에러 파싱
        // ⚠️ error: nil을 명시적으로 전달해야 디코딩을 시도함
        let apiError = try DecodableResponseSerializer<APIErrorResponse>()
            .serialize(request: request, response: response, data: data, error: nil)
 
        let statusCode = response?.statusCode ?? 0
        if statusCode.isAuthError {
            return .authentication
        }
        return .server(apiError)
    }
}
 
private extension Int {
    var isAuthError: Bool { self == 401 || self == 403 }
}

요청에 연결

AF.request(ImagesRouter.search(query: "dragons"))
    .validate()
    .responseDecodable(of: Images.self) { response in
        switch response.result {
        case .success(let images):
            self.items = images.data
        case .failure:
            break // ErrorResponseSerializer가 처리
        }
    }
    .response(responseSerializer: ErrorResponseSerializer()) { response in
        if let error = response.value {
            self.showError(error)
        }
    }

API 인증: OAuth 2.0

세 가지 API 보안 방식 중 OAuth 2.0이 가장 안전하다.

방식특징위험도
HTTP Basic Authusername + password를 Base64 인코딩탈취 시 계정 노출
API Key고정 키 헤더 전송키 유출 시 교체 필요
OAuth 2.0단기 access token + 장기 refresh tokentoken 만료로 피해 최소화

OAuth 2.0 흐름


RequestInterceptor

RequestInterceptorRequestAdapterRequestRetrier 두 프로토콜의 합성이다.

RequestAdapter: 헤더 주입

모든 요청이 서버로 나가기 전에 실행된다. Bearer 토큰을 Authorization 헤더에 주입하기에 적합하다.

final class ImagesRequestInterceptor: RequestInterceptor {
 
    private let authenticator: Authenticator
 
    init(authenticator: Authenticator) {
        self.authenticator = authenticator
    }
 
    // MARK: - RequestAdapter
 
    func adapt(
        _ urlRequest: URLRequest,
        for session: Session,
        completion: @escaping (Result<URLRequest, Error>) -> Void
    ) {
        guard let accessToken = authenticator.accessToken else {
            // 토큰 없음 → 인증 에러로 Request 중단
            completion(.failure(AuthError.missingToken))
            return
        }
 
        var request = urlRequest
        request.headers.add(.authorization(bearerToken: accessToken))
        completion(.success(request))
    }
}

HTTPHeader.authorization 팩토리 메서드:

메서드생성되는 헤더 값
.authorization(username:password:)Basic <Base64(user:pass)>
.authorization(_:)<raw value>
.authorization(bearerToken:)Bearer <token>

RequestRetrier: 토큰 자동 갱신

요청이 에러로 완료될 때 호출된다. 401 응답이면 refresh token으로 토큰을 갱신하고 재시도한다.

// MARK: - RequestRetrier
 
private var lastProceededResponse: HTTPURLResponse?
private let retryLimit = 2
 
func retry(
    _ request: Request,
    for session: Session,
    dueTo error: Error,
    completion: @escaping (RetryResult) -> Void
) {
    // 같은 응답에 대한 중복 실행 방지 + 재시도 횟수 제한 + 인증 에러 확인
    guard
        lastProceededResponse != request.response,
        request.retryCount < retryLimit,
        let statusCode = request.response?.statusCode,
        statusCode.isAuthError
    else {
        completion(.doNotRetry)
        return
    }
 
    lastProceededResponse = request.response
 
    authenticator.refreshToken { result in
        switch result {
        case .success:
            completion(.retry)               // 새 토큰으로 재시도
        case .failure(let error):
            completion(.doNotRetryWithError(error))
        }
    }
}

RetryResult 가능한 값:

동작
.retry즉시 재시도
.retryWithDelay(seconds)지연 후 재시도
.doNotRetry재시도 없이 원래 에러 반환
.doNotRetryWithError(error)재시도 없이 새 에러 반환

RequestRetrier가 발동하는 조건:


Session 커스터마이징

AFSession.default의 별칭이다. 공통 Interceptor나 서버 트러스트 정책 등 커스텀 설정이 필요하면 Session을 직접 생성한다.

// AF.request("https://...")
// ==
// Session.default.request("https://...")
 
final class ImagesSession {
    static let `default` = Session(
        interceptor: ImagesRequestInterceptor(
            authenticator: KeychainAuthenticator()
        )
    )
}

이후 모든 Imgur 요청을 ImagesSession.default를 통해 보내면 Interceptor가 자동으로 적용된다.

// fetchImages
ImagesSession.default.request(ImagesRouter.search(query: query))
    .validate()
    .responseDecodable(of: Images.self) { ... }
 
// upload
ImagesSession.default.upload(
    multipartFormData: { $0.append(data, withName: "image") },
    with: ImagesRouter.upload
)

Request 타입 계층

Alamofire Request는 URLSessionTask를 래핑하며, 용도에 따라 네 가지 서브클래스로 분기된다.

타입메모리저장소사용 시나리오
DataRequest전체 누적일반 REST API
DataStreamRequest누적 없음이미지 스트리밍, SSE
DownloadRequest스트리밍디스크대용량 파일 다운로드
UploadRequest전체 누적이미지·파일 업로드

RequestRequestInterceptor가 생성한 것을 포함하여 모든 URLRequestURLSessionTask의 복사본을 유지한다.


DownloadRequest: 파일 디스크 저장

DownloadRequest는 응답을 메모리에 누적하지 않고 디스크에 직접 저장한다. 대용량 파일 다운로드에 적합하다.

let destination: DownloadRequest.Destination = { _, response in
    let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
    let fileURL = documentsURL.appendingPathComponent(response.suggestedFilename ?? "download")
    return (fileURL, [.removePreviousFile, .createIntermediateDirectories])
}
 
AF.download("https://example.com/large-file.zip", to: destination)
    .downloadProgress { progress in
        print("다운로드 진행: \(progress.fractionCompleted)")
    }
    .responseURL { response in
        switch response.result {
        case .success(let fileURL):
            print("저장 경로: \(fileURL)")
        case .failure(let error):
            print("다운로드 실패: \(error)")
        }
    }

destination을 지정하지 않으면 시스템 임시 디렉토리에 저장된다. .removePreviousFile은 같은 이름 파일이 있으면 덮어쓴다.


DataStreamRequest: 이미지 스트리밍

DataStreamRequest는 데이터가 도착하는 즉시 핸들러를 반복 호출한다. 전체 응답을 메모리에 모으지 않기 때문에 대용량 이미지나 긴 연결에 적합하다. GalleryItemURLConvertible을 채택하면 모델을 직접 Request에 전달할 수 있다 (Router 섹션 참고).

func streamImages(items: [GalleryItem]) {
    for item in items {
        AF.streamRequest(item)          // URLConvertible 직접 전달
            .validate()
            .cacheResponse(using: ResponseCacher(behavior: .cache))  // 캐시 적용
            .responseStream { stream in
                switch stream.event {
                case .stream(let result):
                    switch result {
                    case .success(let data):
                        item.appendData(data)   // 셀에 점진적으로 표시
                    case .failure:
                        break
                    }
                case .complete:
                    break
                }
            }
    }
}

ResponseCacher

// 그대로 캐시
.cacheResponse(using: ResponseCacher(behavior: .cache))
 
// 수정 후 캐시 (예: 민감 헤더 제거)
.cacheResponse(using: ResponseCacher(behavior: .modify { _, response in
    var modified = response
    // 헤더 수정 ...
    return modified
}))
 
// 캐시 방지
.cacheResponse(using: ResponseCacher(behavior: .doNotCache))

UploadRequest: 업로드 및 상태 관리

업로드 요청

func upload(_ item: GalleryItem, at indexPath: IndexPath) {
    guard let data = item.data else { return }
 
    let uploadRequest = ImagesSession.default.upload(
        multipartFormData: { form in
            form.append(data,
                        withName: "image",
                        fileName: "upload.jpg",
                        mimeType: "image/jpeg")
        },
        with: ImagesRouter.upload
    )
 
    // 에러 핸들링
    uploadRequest
        .response(responseSerializer: ErrorResponseSerializer()) { response in
            item.uploadRequest = nil
            if let error = response.value {
                self.showError(error)
            }
            self.collectionView.reloadItems(at: [indexPath])
        }
 
    item.uploadRequest = uploadRequest
    collectionView.reloadItems(at: [indexPath])
}

Request 상태 머신

startRequestsImmediatelytrue(기본값)이면 Response 핸들러가 추가되는 순간 자동으로 resume()이 호출된다.

진행 상황 모니터링

uploadRequest
    .uploadProgress { progress in
        // 업로드된 바이트 / 전체 바이트
        let fraction = progress.fractionCompleted
        DispatchQueue.main.async {
            cell.progressBar.progress = Float(fraction)
        }
    }

일시 정지 / 재개

func suspendOrResume(_ item: GalleryItem) {
    guard let req = item.uploadRequest else { return }
 
    switch req.state {
    case .suspended: req.resume()
    case .resumed:   req.suspend()
    default:         break
    }
}

NetworkReachabilityManager

네트워크 연결 상태를 모니터링한다. 연결 가능 여부로 요청 전송 여부를 판단하면 안 된다. 연결이 있어도 요청이 실패할 수 있고, 없어도 큐에 넣고 재시도하는 게 나을 때가 많다. 올바른 용도는 UI 상태 업데이트와 연결 복구 시 데이터 리프레시다.

final class MonstersViewController: UITableViewController {
 
    private let reachabilityManager = NetworkReachabilityManager(host: "www.apple.com")
 
    override func viewDidLoad() {
        super.viewDidLoad()
        fetchMonsters()
        listenToReachability()
    }
 
    private func listenToReachability() {
        reachabilityManager?.startListening(onQueue: .main) { [weak self] status in
            switch status {
            case .notReachable:
                self?.navigationItem.prompt = "인터넷 연결 없음"
            case .reachable(.cellular):
                self?.navigationItem.prompt = "셀룰러"
                self?.fetchMonsters()   // 연결 복구 시 리프레시
            case .reachable(.ethernetOrWiFi):
                self?.navigationItem.prompt = nil
                self?.fetchMonsters()
            case .unknown:
                break
            }
        }
    }
}

실기기에서 테스트하는 게 좋다. 시뮬레이터는 WiFi 토글 시 복구 이벤트를 정확히 보고하지 않는다.


전체 구조 요약


참고 자료