안녕하세요! 이번 글에서는 iOS 앱에서 UISearchController를 사용하는 방법을 단계별로 알아보겠습니다.
무엇을 만들어 볼까요?
이번 튜토리얼에서는 TMDB API를 활용해 영화 정보를 가져오고, 사용자가 입력한 검색어를 기반으로 UICollectionView에 검색 결과를 표시하는 영화 검색 앱을 만들어 보겠습니다.
프로젝트 설정
Xcode를 열고 새로운 빈 iOS App 프로젝트를 생성합니다. 이때 반드시 SwiftUI가 아닌 UIKit을 선택해야 합니다.
이 앱은 MVC 패턴을 기반으로 구성하므로, 프로젝트를 깔끔하게 관리할 수 있도록 아래와 같이 그룹과 Swift 파일을 미리 만들어 둡니다.

이제 Xcode 프로젝트를 닫고 터미널을 연 뒤, 프로젝트 디렉터리로 이동합니다. 여기서는 영화 포스터 이미지를 비동기적으로 다운로드하고 캐싱하기 위해 SD WebImage CocoaPods를 추가해야 합니다.
터미널에 다음 명령어를 입력하세요:
pod init명령어 실행 후 디렉터리 내용을 확인하면 새로 생성된 Podfile을 볼 수 있습니다. 이 파일을 텍스트 에디터(여기서는 Vim 사용)로 열어 아래 이미지와 비슷한 형태로 수정한 뒤 저장하고 닫습니다.

SD WebImage를 지정했다면, 아래 명령어로 의존성을 설치합니다:
pod install설치가 완료되면 SD WebImage pod가 프로젝트에 성공적으로 추가된 것입니다. 이제 아래 명령어로 Xcode에서 프로젝트를 엽니다.
open PROJECT_NAME.xcworkspaceXcode가 열리면 Command+B를 눌러 프로젝트를 빌드하고 정상적으로 설정되었는지 확인하세요.
UIKit과 프로그래매틱 UI로 화면 구성하기
이 앱에는 세 가지 UI 요소가 필요합니다. 검색 바를 담을 네비게이션 바, 실제 검색을 담당하는 UISearchController, 그리고 검색 결과를 보여줄 UICollectionView입니다.
SceneDelegate.swift 파일을 열고 세션 연결 메서드 안에 다음 코드를 추가합니다:
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let scene = (scene as? UIWindowScene) else { return }
window = UIWindow(windowScene: scene) window?.rootViewController=UINavigationController(rootViewController:HomeVC())
window?.makeKeyAndVisible()
}프로그래매틱 UI 방식을 사용하기 때문에, 앱 실행 시 가장 먼저 표시될 루트 뷰 컨트롤러(Root View Controller)를 직접 지정해 주어야 합니다.
이 앱에서는 하나의 뷰 컨트롤러만 사용하므로, 해당 컨트롤러를 UINavigationController로 감싸 줍니다. 이렇게 하면 UISearchController를 배치할 수 있는 네비게이션 바가 함께 제공됩니다.
HomeVC.swift 파일을 열고 다음 프로퍼티들을 추가합니다:
private var SearchBar: UISearchController = {
let sb = UISearchController()
sb.searchBar.placeholder = "Enter the movie name"
sb.searchBar.searchBarStyle = .minimal
return sb
}()
private var MovieCollectionView: UICollectionView = {
let layout = UICollectionViewFlowLayout()
layout.scrollDirection = .vertical
layout.itemSize = CGSize(width: UIScreen.main.bounds.width/3 - 10, height: 200)
let cv = UICollectionView(frame: .zero, collectionViewLayout: layout)
cv.register(MovieCell.self, forCellWithReuseIdentifier: MovieCell.ID)
return cv
}()먼저 UISearchController를 생성하고 플레이스홀더 문구나 스타일 같은 속성들을 설정합니다.
그다음 UICollectionView를 생성하고, 컬렉션 뷰가 사용할 레이아웃 타입을 지정합니다. 여기서는 UICollectionViewFlowLayout을 사용했으며, 스크롤 방향, 셀 크기 등의 속성과 함께 나중에 만들 커스텀 셀 클래스도 등록합니다.
HomeVC 클래스 안에 새로운 함수를 만들고, UICollectionView의 오토레이아웃 제약 조건을 프로그래밍 방식으로 설정하는 다음 코드를 추가합니다:
//MARK: - HELPERS
func configureUI(){
MovieCollectionView.translatesAutoresizingMaskIntoConstraints = false
MovieCollectionView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
MovieCollectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
MovieCollectionView.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true
MovieCollectionView.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true
}
먼저 autoresizing mask를 제약 조건으로 변환하지 않도록 설정한 뒤, 컬렉션 뷰를 뷰 컨트롤러의 네 면에 모두 고정(pin)합니다.
이어서 viewDidLoad() 메서드 안에 다음 코드를 추가합니다:
override func viewDidLoad() {
super.viewDidLoad()
navigationItem.title = "Movie Search"
view.backgroundColor = .systemBackground
SearchBar.searchResultsUpdater = self
navigationItem.searchController = SearchBar
view.addSubview(MovieCollectionView)
MovieCollectionView.delegate = self
MovieCollectionView.dataSource = self
configureUI()
}여기서는 먼저 뷰 컨트롤러의 타이틀을 지정하고, 배경색을 systemBackground로 설정했습니다. systemBackground는 기기가 라이트 모드일 때는 흰색, 다크 모드일 때는 어두운 색상으로 자동 전환되는 편리한 색상입니다.
그다음 현재 뷰 컨트롤러를 검색 결과 업데이터(searchResultsUpdater)로 지정하고, 네비게이션 바에 서치 컨트롤러를 부착한 후, 뷰 컨트롤러에 UICollectionView를 추가하고 델리게이트와 데이터소스를 설정합니다. 마지막으로 오토레이아웃을 통해 컬렉션 뷰의 위치를 확정합니다.
이제 HomeVC의 extension을 만들어 UISearchResultsUpdating 프로토콜을 채택하고, 필수 메서드인 updateSearchResults를 구현합니다.
extension HomeVC: UISearchResultsUpdating{
func updateSearchResults(for searchController: UISearchController) {
guard let query = searchController.searchBar.text else{return}
}
}
}updateSearchResults() 메서드는 검색 바에 입력된 텍스트가 변경되거나, 사용자가 키보드의 검색 버튼을 누를 때마다 호출됩니다.
다음으로 커스텀 UICollectionView 셀을 만들어야 합니다. MovieCell.swift 파일에 아래 코드를 추가하세요:
import Foundation
import UIKit
import SDWebImage
class MovieCell: UICollectionViewCell{
static let ID = "MovieCell"
private var MoviePosterImageView: UIImageView = {
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFit
// imageView.image = UIImage(systemName: "house")
return imageView
}()
override init(frame: CGRect) {
super.init(frame: frame)
addSubview(MoviePosterImageView)
configureUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extension MovieCell{
func configureUI(){
MoviePosterImageView.translatesAutoresizingMaskIntoConstraints = false
MoviePosterImageView.topAnchor.constraint(equalTo: topAnchor).isActive = true
MoviePosterImageView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true
MoviePosterImageView.leftAnchor.constraint(equalTo: leftAnchor).isActive = true
MoviePosterImageView.rightAnchor.constraint(equalTo: rightAnchor).isActive = true
}
func updateCell(posterURL: String?){
if let posterURL = posterURL {
guard let CompleteURL = URL(string: "https://image.tmdb.org/t/p/w500/\(posterURL)") else {return}
self.MoviePosterImageView.sd_setImage(with: CompleteURL)
}
}
}여기서는 UICollectionViewCell을 상속받아 커스텀 셀 클래스를 만들고 init() 함수들을 구현했습니다.
영화 포스터를 표시할 UIImageView를 생성하고 오토레이아웃 제약 조건을 설정한 뒤, 포스터 URL 문자열을 파라미터로 받아 메인 스레드(UI 스레드)에 영향을 주지 않고 비동기적으로 이미지를 다운로드하는 사용자 정의 함수를 만들었습니다. 이 비동기 처리는 앞서 추가한 SD WebImage CocoaPod가 담당합니다.
API 설정하기
본격적인 개발에 앞서 TMDB API의 API 키가 필요합니다. TMDB 사이트에서 무료 계정을 생성하면 발급받을 수 있습니다. 우리는 API 키와 영화 이름을 파라미터로 받는 'Movie Search' 엔드포인트를 사용할 예정입니다.
https://api.themoviedb.org/3/search/movie?api_key=API_KEY_HERE&query=batman
Postman에서 위 URL을 실행하면 API 응답 형태를 미리 확인해 볼 수 있습니다.

API 응답용 모델 만들기
API로부터 JSON 응답을 받게 되는데, 이를 Swift에서 사용하려면 Codable 프로토콜을 채택한 모델 구조체를 만들어 디코딩해야 합니다.
JSON to Swift 변환 사이트를 활용하면 모델 구조체를 손쉽게 생성할 수 있습니다. 아래는 API 응답에 대한 모델 코드이며, 그대로 복사해서 Model.swift 파일에 붙여넣으면 됩니다:
import Foundation
struct TrendingTitleResponse: Codable {
let results: [Title]
}
struct Title: Codable {
let id: Int
let media_type: String?
let original_name: String?
let original_title: String?
let poster_path: String?
let overview: String?
let vote_count: Int
let release_date: String?
let vote_average: Double
}
struct YoutubeSearchResponse: Codable {
let items: [VideoElement]
}
struct VideoElement: Codable {
let id: IdVideoElement
}
struct IdVideoElement: Codable {
let kind: String
let videoId: String
}
Swift로 HTTP 요청 수행하기
이제 API의 JSON 응답을 반환하는 HTTP GET 요청을 수행하는 Swift 코드를 작성해야 합니다.
Swift는 URLSession 클래스를 기본 제공하기 때문에 AFNetworking이나 AlamoFire 같은 서드파티 라이브러리 없이도 네트워킹 코드를 간편하게 작성할 수 있습니다.
APIService.swift 파일을 열고 다음 코드를 추가합니다:
import Foundation
class APIService{
static var shared = APIService()
let session = URLSession(configuration: .default)
func getMovies(for Query: String,completion:@escaping([Title]?,Error?)->Void){
guard let FormatedQuery = Query.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) else{return}
guard let SEARCH_URL = URL(string: "https://api.themoviedb.org/3/search/movie?api_key=API_KEY_HERE&query=\(FormatedQuery)") else {print("INVALID")
return}
let task = session.dataTask(with: SEARCH_URL) { data, response, error in
if let error = error {
print(error.localizedDescription)
completion(nil,error)
}
if let data = data {
do{
let decodedData = try JSONDecoder().decode(TrendingTitleResponse.self, from: data)
// print(decodedData)
completion(decodedData.results,nil)
}
catch{
print(error)
}
}
}
task.resume()
}
}
여기서는 싱글턴 패턴을 적용한 APIService 클래스를 만들고, 클래스의 static 멤버로 인스턴스를 선언했습니다. 그런 다음 기본(default) 설정으로 네트워킹 세션을 생성하고, 사용자 정의 메서드인 getMovies()를 구현했습니다.
네트워킹 작업은 URLSession 클래스의 dataTask() 메서드로 수행합니다. 이 메서드는 URL을 파라미터로 받으며, 완료 핸들러(completion handler)를 통해 API가 반환한 데이터, 오류 발생 시의 에러 정보, 그리고 상태 코드와 메시지 등 HTTP 응답 정보를 전달해 줍니다.
오류가 있다면 에러 데이터와 함께 함수를 빠져나가고, 그렇지 않다면 Swift 모델을 기준으로 JSON 데이터를 디코딩한 뒤 디코딩된 결과와 함께 함수를 종료합니다.
UICollectionView에 검색 결과 표시하기
HomeVC.swift에서 Title 객체 배열을 담는 private 프로퍼티를 생성합니다. 이 배열은 API가 반환한 각 영화의 정보를 저장합니다.
private var Movies = [Title]()이어서 HomeVC 클래스의 extension을 만들어 UICollectionViewDelegate와 UICollectionViewDataSource 프로토콜을 채택합니다. 그리고 API가 반환한 영화 수만큼 항목 개수를 반환하는 numberOfItemsInSection과, 실제로 셀에 포스터 이미지를 다운로드·설정하는 cellForItemAt 메서드를 구현합니다.
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return Movies.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
if let cell = collectionView.dequeueReusableCell(withReuseIdentifier: MovieCell.ID, for: indexPath) as? MovieCell{
// cell.backgroundColor = .systemBackground
cell.updateCell(posterURL: Movies[indexPath.row].poster_path)
return cell
}
return UICollectionViewCell()
}마지막으로 실제 API 호출을 수행해야 합니다. 이 작업은 앞서 구현한 updateSearchResults() 델리게이트 메서드 내부에서 진행하며, 아래 코드를 추가합니다:
func updateSearchResults(for searchController: UISearchController) {
guard let query = searchController.searchBar.text else{return}
APIService.shared.getMovies(for:query.trimmingCharacters(in: .whitespaces)) { titles, error in
if let titles = titles {
self.Movies = titles
DispatchQueue.main.async {
self.MovieCollectionView.reloadData()
}
}
}
}사용자가 검색 바에 입력하거나 검색 버튼을 누를 때마다, 입력된 영화 이름을 기반으로 HTTP GET 요청을 보내 영화 정보를 가져옵니다. 그런 다음 CollectionView를 리로드하여 영화 포스터로 셀을 갱신합니다.
주의할 점은 UI 갱신 작업은 반드시 메인 스레드(UI 스레드)에서 수행해야 한다는 것입니다. iOS는 기본적으로 HTTP 요청을 백그라운드 스레드에서 처리하기 때문에, UI 요소를 업데이트할 때는 메인 스레드를 사용해야 합니다.
이제 시뮬레이터에서 앱을 실행해 결과를 확인해 보세요:

축하합니다! 이제 iOS 앱에서 UISearchController를 사용하는 방법을 익히셨습니다.