iOS 개발자를 목표로 한다면 반드시 익혀야 할 기본 기술들이 있습니다. 첫째, 테이블 뷰(table view)를 만드는 방법에 익숙해져야 합니다. 둘째, 그 테이블 뷰에 데이터를 채워 넣는 방법을 알아야 합니다. 셋째, API에서 데이터를 가져와 테이블 뷰에 활용할 수 있다면 더욱 좋습니다.
이 글에서는 세 번째 항목, 즉 API 호출 방법을 집중적으로 다룹니다. Swift 4에서 Codable이 도입된 이후 API 호출 작업은 한결 수월해졌습니다. 예전에는 대부분 Alamofire나 SwiftyJson 같은 외부 라이브러리(pod)를 사용했지만, 이제는 Swift 자체 기능만으로도 충분히 깔끔하게 구현할 수 있어 굳이 외부 라이브러리를 설치할 필요가 없습니다.
API 호출에 자주 사용되는 핵심 빌딩 블록들을 먼저 살펴보겠습니다. 이 개념들이 API 호출의 동작 원리를 이해하는 데 중요한 역할을 하기 때문입니다.
- 컴플리션 핸들러(Completion Handlers)
URLSessionDispatchQueue- 순환 참조(Retain Cycles)
마지막으로 이 모든 요소를 하나로 조합해 보겠습니다. 이 글의 프로젝트는 오픈소스인 스타워즈 API(Star Wars API)를 사용하며, 전체 코드는 GitHub에서 확인할 수 있습니다.
참고: 저는 코딩 경력이 짧고 대부분 독학으로 배운 입니다. 일부 개념이 부정확하게 설명되었다면 양해 부탁드립니다.
컴플리션 핸들러(Completion Handler)란?
친구들(Friends)에서 피비가 고객센터와 통화하기 위해 며칠 동안 전화기 앞에 붙어 있었던 에피소드를 기억하시나요? 만약 통화 시작부터 친절한 상담원이 "전화 주셔서 감사합니다. 대기 시간이 얼마나 될지 모르지만, 준비되면 제가 다시 연락드릴게요"라고 말했다면 어떨까요? 웃음 포인트는 사라졌겠지만, 그 상담원은 사실상 컴플리션 핸들러 역할을 해주는 것입니다.
컴플리션 핸들러는 실행에 오랜 시간이 걸릴 것으로 예상되는 함수에서 사용됩니다. 얼마나 걸릴지 알 수 없고, 결과가 나올 때까지 아무것도 못 하며 기다리고 싶지 않겠죠. 그래서 "준비되면 어깨를 두드려 답변을 알려달라"고 부탁하는 것입니다. 그렇게 하면 우리는 평소처럼 다른 일을 진행할 수 있습니다. 나중에 결과를 전달받으면 그 값을 활용하면 됩니다.
API 호출이 바로 이런 방식으로 동작합니다. 서버에 URL 요청을 보내 데이터를 요청하지만, 서버가 언제 응답할지는 알 수 없습니다. 사용자가 서버의 응답을 하염없이 기다리게 하는 대신 컴플리션 핸들러를 사용하면, 앱에게 "다른 작업을 계속 진행해도 된다"고 지시할 수 있습니다. 예를 들어 페이지의 나머지 부분을 로딩하는 식으로 말이죠.
필요한 정보를 받게 되면 컴플리션 핸들러가 앱의 어깨를 두드려 알려주도록 설정할 수 있습니다. 어떤 정보를 전달받을지도 지정할 수 있는데, 그러면 앱은 해당 정보를 받아 원하는 작업을 수행합니다. 일반적으로는 테이블 뷰를 리로드(reload)해서 사용자에게 데이터를 보여주는 작업을 하게 됩니다.
컴플리션 핸들러가 실제로 어떻게 생겼는지 예시를 살펴보겠습니다. 첫 번째 코드는 API 호출 자체를 설정하는 함수입니다:
func fetchFilms(completionHandler: @escaping ([Film]) -> Void) {
// lotsOfFilms 변수 선언
var lotsOfFilms: [Film]
// 코드로 API 호출
// API에서 받은 데이터로 lotsOfFilms에 값 할당
// 컴플리션 핸들러에 lotsOfFilms 변수 전달
completionHandler(lotsOfFilms)
}이제 fetchFilms 함수를 호출해 보겠습니다. 몇 가지 주의할 점이 있습니다:
- 함수를 호출할 때는
completionHandler라는 이름을 직접 언급할 필요가 없습니다.completionHandler를 참조하는 경우는 함수 선언부 내부뿐입니다. - 컴플리션 핸들러는 사용할 데이터를 돌려줍니다. 위에서 작성한 함수를 보면
[Film]타입의 데이터가 반환될 것이라는 것을 알 수 있습니다. 이 데이터를 참조하려면 이름을 붙여줘야 하는데, 아래 예시에서는films라고 명명했습니다. 물론randomData등 원하는 이름을 자유롭게 사용할 수 있습니다.
호출 코드는 다음과 같습니다:
fetchFilms() { (films) in
// 컴플리션 핸들러가 반환한 데이터로 작업 수행
print(films)
}URLSession
URLSession은 팀장(manager)과 같습니다. 팀장은 직접 일을 하지 않고, 팀원들에게 업무를 분배해서 일이 완료되도록 하는 역할을 합니다. 여기서 팀원에 해당하는 것이 바로 dataTask입니다. 데이터가 필요할 때마다 URLSession.shared.dataTask를 통해 팀장에게 업무를 요청하면 됩니다.
dataTask에는 목표 달성에 도움이 되는 다양한 정보를 전달할 수 있습니다. dataTask에 정보를 전달하는 과정을 초기화(initialization)라고 부르는데, 저는 URL로 dataTask를 초기화합니다. 또한 dataTask는 초기화 과정에서 컴플리션 핸들러를 함께 사용합니다. 예시를 보겠습니다:
let url = URL(string: "https://www.swapi.co/api/films")
let task = URLSession.shared.dataTask(with: url, completionHandler: { (data, response, error) in
// 여기에 코드 작성
})
task.resume()dataTask는 컴플리션 핸들러를 사용하며, 항상 동일한 세 가지 타입의 정보를 반환합니다: data, response, error. 이 값들의 이름은 (data, res, err) 또는 (someData, someResponse, someError)처럼 자유롭게 바꿀 수 있지만, 관례상 헷갈리지 않는 직관적인 이름을 유지하는 것이 좋습니다.
1. error 처리
먼저 error부터 살펴보겠습니다. dataTask가 에러를 반환한다면 가장 먼저 인지해야 합니다. 그래야 코드가 에러를 우아하게(gracefully) 처리하도록 흐름을 잡을 수 있고, 데이터가 정상적으로 반환되지 않았으니 굳이 데이터를 읽으려 시도하지 않을 수 있습니다.
아래 코드는 에러를 콘솔에 출력하고 함수를 종료하는 아주 간단한 방식으로 처리합니다. 물론 에러를 처리하는 방법은 이외에도 다양합니다. 이 데이터가 앱에 얼마나 중요한지 생각해 보세요. 예를 들어 은행 앱에서 이 API 호출이 사용자의 잔액을 보여준다면, "죄송합니다. 현재 문제가 발생했습니다. 나중에 다시 시도해 주세요."라는 안내 모달을 띄우는 방식으로 에러를 처리할 수 있을 것입니다.
if let error = error {
print("Error accessing swapi.co: \(error)")
return
}2. response 처리
다음은 응답(response)입니다. 응답을 HTTPURLResponse로 캐스팅하면 상태 코드(status code)를 확인하고 그에 따른 분기 처리를 할 수 있습니다. 예를 들어 상태 코드가 404라면 페이지를 찾지 못했다는 의미입니다.
아래 코드는 guard문으로 두 가지 조건을 검사합니다. 두 조건이 모두 충족되면 guard 절 이후의 코드가 실행되고, 하나라도 실패하면 함수를 종료합니다. 이것이 guard 절의 전형적인 사용 사례입니다. guard 절 뒤의 코드는 '모든 것이 순조로운 흐름'(즉, 에러 없는 정상 흐름)이라고 기대할 수 있습니다.
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
print("Error with the response, unexpected status code: \(response)")
return
}3. data 처리
마지막으로 데이터 자체를 처리합니다. 눈치채셨겠지만, 에러와 응답 단계에서는 컴플리션 핸들러를 사용하지 않았습니다. 그 이유는 컴플리션 핸들러가 기다리고 있는 것은 API로부터의 데이터이기 때문입니다. 코드가 데이터 처리 단계까지 도달하지 못했다면 핸들러를 호출할 필요가 없습니다.
데이터는 JSONDecoder를 사용해 깔끔하게 파싱합니다. 아주 유용한 기능이지만, 미리 모델(model)을 정의해 두어야 합니다. 여기서는 모델 이름을 FilmSummary로 했습니다. JSONDecoder가 처음이라면 사용법과 Codable 활용법을 온라인에서 찾아보시길 권합니다. Swift 3 시절에 비하면 Swift 4 이상에서는 정말 간단합니다.
아래 코드에서는 먼저 데이터가 존재하는지 확인합니다. 에러도 없고 이상한 HTTP 응답도 없었으므로 데이터가 있을 가능성이 높지만 확실히 검사하는 것이 좋습니다. 다음으로, 받은 데이터를 기대한 방식대로 파싱할 수 있는지 확인합니다. 성공하면 영화 목록을 컴플리션 핸들러에 반환합니다. 만약 API에서 반환할 데이터가 없는 경우에 대비해 빈 배열을 폴백(fallback)으로 준비해 뒀습니다.
if let data = data,
let filmSummary = try? JSONDecoder().decode(FilmSummary.self, from: data) {
completionHandler(filmSummary.results ?? [])
}지금까지의 내용을 종합하면 전체 API 호출 코드는 다음과 같습니다:
func fetchFilms(completionHandler: @escaping ([Film]) -> Void) {
let url = URL(string: domainUrlString + "films/")!
let task = URLSession.shared.dataTask(with: url, completionHandler: { (data, response, error) in
if let error = error {
print("Error with fetching films: \(error)")
return
}
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
print("Error with the response, unexpected status code: \(response)")
return
}
if let data = data,
let filmSummary = try? JSONDecoder().decode(FilmSummary.self, from: data) {
completionHandler(filmSummary.results ?? [])
}
})
task.resume()
}순환 참조(Retain Cycle)
참고: 저도 순환 참조 개념을 막 배우기 시작한 단계입니다! 온라인 자료를 조사한 내용의 핵심을 정리한 것입니다.
순환 참조는 메모리 관리를 위해 반드시 이해해야 하는 개념입니다. 기본적으로 앱은 더 이상 필요 없는 메모리 조각들을 정리해야 하며, 이것이 앱 성능 향상에 도움이 됩니다.
Swift는 이 작업을 자동으로 처리해 주는 많은 장치를 갖추고 있습니다. 하지만 개발자가 무심코 코드에 순환 참조를 만드는 경우도 많습니다. 순환 참조란 특정 코드 조각의 메모리를 앱이 영원히 놓아주지 못하는 상태를 말합니다. 일반적으로 두 객체가 서로를 강한(strong) 참조로 가리킬 때 발생합니다.
이를 해결하기 위해 흔히 weak 키워드를 사용합니다. 한쪽이 weak 참조라면 순환 참조가 발생하지 않고, 앱이 메모리를 정상적으로 해제할 수 있습니다.
API 호출 시에는 클로저에서 [weak self]를 사용하는 패턴이 일반적입니다. 이렇게 하면 컴플리션 핸들러가 코드를 반환한 이후에도 앱이 메모리를 잘 해제할 수 있습니다.
fetchFilms { [weak self] (films) in
// 여기에 코드 작성
}DispatchQueue
Xcode는 여러 스레드(thread)를 사용해 코드를 병렬로 실행합니다. 멀티 스레드의 장점은 하나의 작업이 끝나기를 기다리지 않고도 다음 작업으로 넘어갈 수 있다는 점입니다. 이쯤 되면 컴플리션 핸들러와의 연관성이 보이기 시작할 겁니다.
이런 스레드들은 디스패치 큐(dispatch queue)라고도 불립니다. API 호출은 보통 백그라운드 큐에서 처리됩니다. API 호출로 데이터를 받아오면 대개 그 데이터를 사용자에게 보여주고 싶어질 텐데, 그러려면 테이블 뷰를 새로고침해야 합니다.
테이블 뷰는 UI의 일부이며, 모든 UI 조작은 메인 디스패치 큐(main dispatch queue)에서 수행해야 합니다. 즉, 뷰 컨트롤러 파일 어딘가, 보통은 viewDidLoad 함수 안에서 테이블 뷰를 새로고침하라는 코드를 작성해야 합니다.
우리는 API로부터 새 데이터를 받은 후에만 테이블 뷰가 새로고침되기를 원합니다. 따라서 컴플리션 핸들러를 활용해 API 호출이 끝났을 때 '어깨를 두드려' 알려달라고 하고, 그 신호를 받은 후에 테이블을 새로고침하면 됩니다.
코드는 대략 다음과 같습니다:
fetchFilms { [weak self] (films) in
self.films = films
// 메인 디스패치 큐에서 테이블 뷰 새로고침
DispatchQueue.main.async {
tableView.reloadData()
}
}viewDidLoad vs viewDidAppear
마지막으로 fetchFilms 함수를 어디서 호출할지 결정해야 합니다. API 데이터를 사용하는 뷰 컨트롤러 내부에 위치하게 되는데, 호출하기 적합한 곳은 크게 두 곳입니다. 바로 viewDidLoad와 viewDidAppear입니다.
이 둘은 앱의 서로 다른 상태를 나타냅니다. 제가 이해하기로 viewDidLoad는 해당 뷰가 처음으로 포그라운드에 로드될 때 한 번 호출됩니다. 반면 viewDidAppear는 해당 뷰로 돌아올 때마다 호출됩니다. 예를 들어 뒤로 가기 버튼을 눌러 그 뷰로 되돌아올 때도 호출됩니다.
사용자가 뷰를 오갈 때마다 데이터가 변경될 것으로 예상된다면 API 호출을 viewDidAppear에 넣는 것이 좋습니다. 하지만 거의 모든 앱에서는 viewDidLoad로 충분하다고 생각합니다. Apple은 모든 API 호출을 viewDidAppear에서 하라고 권장하지만, 이는 과한 감이 있습니다. 불필요하게 많은 API 호출이 발생해 오히려 앱 성능이 떨어질 수 있기 때문입니다.
전체 과정 정리
첫째, API를 호출하는 함수를 작성합니다. 위 예시에서는 fetchFilms입니다. 이 함수는 컴플리션 핸들러를 포함하며, 원하는 데이터를 반환합니다. 제 예시에서는 영화 배열을 반환합니다.
둘째, 뷰 컨트롤러에서 이 함수를 호출합니다. API에서 받은 데이터로 뷰를 업데이트해야 하기 때문입니다. 제 예시에서는 API가 데이터를 반환하면 테이블 뷰를 새로고침합니다.
셋째, 뷰 컨트롤러에서 함수를 호출할 위치를 정합니다. 제 예시에서는 viewDidLoad에서 호출합니다.
넷째, API 데이터로 무엇을 할지 결정합니다. 제 예시에서는 테이블 뷰를 새로고침합니다.
NetworkManager.swift 내부 (MVVM 패턴을 사용하지 않는다면 뷰 컨트롤러에 직접 정의해도 됩니다):
func fetchFilms(completionHandler: @escaping ([Film]) -> Void) {
let url = URL(string: domainUrlString + "films/")!
let task = URLSession.shared.dataTask(with: url, completionHandler: { (data, response, error) in
if let error = error {
print("Error with fetching films: \(error)")
return
}
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
print("Error with the response, unexpected status code: \(response)")
return
}
if let data = data,
let filmSummary = try? JSONDecoder().decode(FilmSummary.self, from: data) {
completionHandler(filmSummary.results ?? [])
}
})
task.resume()
}FilmsViewController.swift 내부:
final class FilmsViewController: UIViewController {
private var films: [Film]?
override func viewDidLoad() {
super.viewDidLoad()
NetworkManager().fetchFilms { [weak self] (films) in
self?.films = films
DispatchQueue.main.async {
self?.tableView.reloadData()
}
}
}
// 뷰 컨트롤러의 나머지 코드
}드디어 끝냈습니다! 끝까지 읽어주셔서 감사합니다.