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
| 프로토콜 | 구현 메서드 | 역할 |
|---|---|---|
URLConvertible | asURL() throws -> URL | URL만 생성 |
URLRequestConvertible | asURLRequest() 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 |
|---|---|---|
URLEncodedFormParameterEncoder | key=value&key2=value2 | GET/HEAD/DELETE → query string, 나머지 → HTTP body |
JSONParameterEncoder | JSON object | HTTP 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 | 반환 타입 | 용도 |
|---|---|---|
DataResponseSerializer | Data | 커스텀 파싱, 바이너리 처리 |
StringResponseSerializer | String | 텍스트 응답, HTML |
DecodableResponseSerializer | T: Decodable | REST 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로 처리된다. 실제 에러 분기는 responseDecodable의 result에서 별도로 해야 한다. 거의 항상 붙이는 것이 맞다.
커스텀 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 Auth | username + password를 Base64 인코딩 | 탈취 시 계정 노출 |
| API Key | 고정 키 헤더 전송 | 키 유출 시 교체 필요 |
| OAuth 2.0 | 단기 access token + 장기 refresh token | token 만료로 피해 최소화 |
OAuth 2.0 흐름
RequestInterceptor
RequestInterceptor는 RequestAdapter와 RequestRetrier 두 프로토콜의 합성이다.
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 커스터마이징
AF는 Session.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 | 전체 누적 | — | 이미지·파일 업로드 |
각 Request는 RequestInterceptor가 생성한 것을 포함하여 모든 URLRequest와 URLSessionTask의 복사본을 유지한다.
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는 데이터가 도착하는 즉시 핸들러를 반복 호출한다. 전체 응답을 메모리에 모으지 않기 때문에 대용량 이미지나 긴 연결에 적합하다. GalleryItem이 URLConvertible을 채택하면 모델을 직접 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 상태 머신
startRequestsImmediately가true(기본값)이면 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 토글 시 복구 이벤트를 정확히 보고하지 않는다.