-Today's Learning Content-

  • 포켓몬 검색 기능 구현하기

1. 검색기능 구현하기

내용 정리

오늘은 개인과제인 포켓몬 도감에서, 포켓몬의 도감 번호, 이름 등으로 포켓몬을 검색할 수 있는 기능을 구현하고자 한다.

1) 검색 모델 만들기

포켓몬 검색기능을 만들기 위해서는 포켓몬의 정보를 모두 담고있는 데이터 파일이 필요하다.
만약 검색할 때마다 API 통신을 사용하게 된다면 API 할당량이 초과되는 문제가 발생하거나, 로딩이 오래 걸리면 사용자에게 부정적인 영향을 줄 수도 있다고 생각했기 때문에 모든 포켓몬 정보를 담고 있는 데이터 파일을 만들기로 하였다.

어떻게 하면 모든 포켓몬의 정보를 담은 데이터 파일을 만들 수 있을까 고민해 보았는데, PokemonTranslator가 가진 koreanNames가 모든 포켓몬의 이름을 담고 있었기 때문에 이 파일을 이용해 보자고 생각했다.

typealias Names = (String, String)

private static let koreanNames: [String: String] = [ ... ]

static let pokemonList: [(id: String, name: Names)] = {
	var list = [(id: String, name: Names)]()

	for (index, data) in koreanNames.enumerated() {
		let names: Names = (data.key, data.value)
		let item = (id: "\(index + 1)", name: names)
		list.append(item)
	}

	return list
}()

이렇게 하면 koreanNames가 가진 포켓몬 데이터를 Index에 따라 도감 번호를 부여하고, 영어 이름과 한국어 이름을 모두 가진 배열을 만들게 된다.
이제 pokemonList를 사용해서 검색 기능을 구현해보자

2) UI 구현

UI 구현은 전체 코드를 첨부하는 것으로 생략하겠다.
어차피 중요한건 로직이지, UI가 아니기 때문이다.

// 포켓몬 검색 뷰
final class SearchView: UIView {
    
    private let searchBar = UITextField() // 검색바
    
    private let searchResultsTableView = SearchTableView() // 검색 결과 테이블
    
    private let resultLabel = UILabel() // 검색 결과에 대한 레이블
    
    private let viewModel = SearchViewModel(pokemonManager: PokemonManager()) // 로직 및 데이터 바인딩 객체
    
    private let disposeBag = DisposeBag()
    
    // MARK: - SearchView Initializer
    override init(frame: CGRect) {
        super.init(frame: frame)
        
        setupUI()
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        
        setupUI()
    }
}

// MARK: - SearchView UI Setting Method
private extension SearchView {
    
    /// 모든 UI를 세팅하는 메소드
    func setupUI() {
        configure()
        setupSearchBar()
        setupResultLabel()
        setupLayout()
    }
    
    /// self에 대한 설정을 하는 메소드
    func configure() {
        self.backgroundColor = .clear
        [self.searchBar,
         self.searchResultsTableView,
         self.resultLabel
        ].forEach {
            self.addSubview($0)
        }
    }
    
    /// 검색바에 대한 세팅을 하는 메소드
    func setupSearchBar() {
        self.searchBar.backgroundColor = .white
        self.searchBar.placeholder = "포켓몬의 도감 번호나 이름을 입력해 주세요!"
        self.searchBar.textColor = .black
        self.searchBar.font = UIFont.systemFont(ofSize: 18, weight: .regular)
        self.searchBar.borderStyle = .none
        self.searchBar.layer.cornerRadius = 25
        self.searchBar.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 20, height: 10))
        self.searchBar.leftViewMode = .always
        self.searchBar.clearButtonMode = .whileEditing
        self.searchBar.autocapitalizationType = .none
    }
    
    /// 검색 결과 레이블에 대한 세팅을 하는 메소드
    func setupResultLabel() {
        self.resultLabel.text = "검색 결과가 없습니다"
        self.resultLabel.textColor = .personalDark
        self.resultLabel.numberOfLines = 1
        self.resultLabel.textAlignment = .center
        self.resultLabel.font = UIFont.boldSystemFont(ofSize: 30)
    }
    
    /// 모든 UI의 레이아웃을 설정하는 메소드
    func setupLayout() {
        self.searchBar.snp.makeConstraints {
            $0.top.equalToSuperview().offset(10)
            $0.leading.trailing.equalToSuperview().inset(15)
            $0.height.equalTo(50)
        }
        
        self.searchResultsTableView.snp.makeConstraints {
            $0.top.equalTo(self.searchBar.snp.bottom).offset(20)
            $0.trailing.leading.bottom.equalToSuperview().inset(10)
        }
        
        self.resultLabel.snp.makeConstraints {
            $0.center.equalToSuperview()
        }
    }
}

다만 한 가지, 나는 검색바를 구현할 때까지만 해도 UISearchBarUISearchController 등 검색바가 기본적으로 구현되어 있는 것을 모른 채 진행했기 때문에 UITextFeild로 기능을 구현했다.

큰 문제나 어려움은 없었기 때문에 상관 없지만, 기왕이면 UISearchBar를 사용해서 구현하는 것을 추천한다.

3) 검색 로직 구현하기

검색 로직을 구현하기 전 우선 모든 포켓몬 정보를 담을 배열을 만들고, 뷰가 초기화될 때 모든 값을 가지도록 구현해둔다.

private var pokemonList = PokemonTranslator.pokemonList // 모든 포켓몬에 대한 정보가 담긴 배열

그리고 검색 결과를 담아줄 배열을 만들어준다.

let searchPokemonList = PublishSubject<[(id: Int, name: String)]>()

이 배열은 나중에 테이블뷰에서 구독할 객체이기 때문에 RxSwift의 PublishSubject로 만들어 주었고, 테이블뷰에서 필요한 데이터는 포켓몬의 도감 번호와 이름 뿐이기 때문에 튜플 타입으로 id, name을 담도록 만들었다.

이제 검색바에 입력한 텍스트를 기준으로 포켓몬을 필터링하는 로직을 추가한다.

func search(text: String) {
	if let result = Int(text) {
		var searchList: [(Int, String)] = []

		let list = self.pokemonList.filter { $0.id.contains("\(result)") }
		guard list.count > 0 else { return }

		searchList += addList(list)

		self.searchPokemonList.onNext(searchList)

	} else {
		var searchList: [(Int, String)] = []

		let enList = self.pokemonList.filter { $0.name.0.contains(text.lowercased()) }
		let koList = self.pokemonList.filter { $0.name.1.contains(text) }
		guard enList.count > 0 || koList.count > 0 else { return }

		searchList += addList(enList)
		searchList += addList(koList)

		self.searchPokemonList.onNext(searchList)
	}
}

private func addList(_ list: [(id: String, name: PokemonTranslator.Names)]) -> [(Int, String)] {
	var searchList: [(Int, String)] = []

	list.forEach {
		let id: Int = Int($0.id) ?? 0
		let name: String = $0.name.1
		let item = (id, name)

		searchList.append(item)
	}

	return searchList
}

위 코드가 필터링을 담당하는 전체 코드이다. 하나씩 살펴보자

if let result = Int(text) { ... }
else { ... }

먼저 if문을 만든 이유는 사용자가 입력한 값이 숫자인지 문자인지 구분하기 위해서이다.
만약 숫자를 입력했다면 Int 타입으로 형변환이 되기 때문에 if 내부 코드가 실행되고, 문자를 입력했다면 형변환이 불가능하기 때문에 else 내부 코드가 실행된다.

let list = self.pokemonList.filter { $0.id.contains("\(result)") }
guard list.count > 0 else { return }

숫자를 입력하여 넘어온 경우, pokemonList의 id값과 result의 값이 유사한 모든 데이터를 필터링 하여 list에 담아준다.
여기서 pokemonList의 id는 String 타입이기 때문에 contains 메소드의 사용이 가능하다.

만약 필터링된 결과(count)가 하나도 없다면 로직은 종료된다.

searchList += addList(list)

self.searchPokemonList.onNext(searchList)

addList(_:) 메소드는 [(id: String, name: PokemonTranslator.Names)] 타입을 [(Int, String)] 타입으로 변환시켜주는 메소드이다.
위에서 필터링한 list를 [(Int, String)] 타입으로 바꿔주고, searchPokemonList에 값을 전달하여 이벤트를 방출하도록 한다.

문자를 입력한 경우에도 실행 순서는 동일하다.
다만, 영어이름과 한국어 이름 모두를 대응하기 위해 list를 2개 생성하는 차이점이 있다.

이제 데이터를 바인딩하여 실제로 빌드를 해보자

4) 데이터 바인딩하기

func bindSearchPokemonList() {
	self.viewModel.searchPokemonList
		.withUnretained(self)
		.subscribe(on: MainScheduler.instance)
		.subscribe(onNext: { owner, data in
                
			owner.searchResultsTableView.searchPokemonList = data
			owner.searchResultsTableView.reloadData()
                
		}, onError: { error in
			print(error)
                
		}).disposed(by: self.disposeBag)
}

func reloadData() {
	self.searchPokemonList.sort(by: { $0.id < $1.id })
        
	DispatchQueue.main.async {
		self.tableView.reloadData()
	}
}

위의 코드를 통해 테이블뷰의 데이터소스가 searchPokemonList를 구독하여 이벤트를 방출할 때마다 테이블뷰도 같이 변화하도록 구현해 주었다.

이제 빌드를 통해 구현 결과를 확인해보자.

구현 결과물

오... 의도한 대로 포켓몬 도감 번호나 이름을 검색했을 때 무사히 결과가 출력되긴 하지만 포켓몬의 ID가 이상하다.

피카츄는 No.25 의 도감번호를 가진 포켓몬인데 왜인지 217번으로 표시가 되는 것이다.

원인을 파악하고자 몇 번 빌드를 해보며 테스트를 해 본 결과...

PokemonTranslator에서 만든 pokemonList가 문제였다. 이 배열은 koreanNames라는 포켓몬의 영어 이름과 한국어 이름을 가진 [String: String] 타입의 데이터를 for-in문으로 가져오는 배열이었는데, 딕셔너리는 순서를 보장하지 않기 때문에 포켓몬이 항상 순서대로 들어오지 않고 뒤죽박죽 섞여서 들어오는 것이다.

ID 순으로 정렬하기 배열에 Int 타입을 추가할 수도 있지만, 1025마리나 되는 포켓몬에게 모두 ID를 부여하기에는 너무 많은 시간이 필요하다.

어떻게 수정할 수 있을까 고민하다가 결국 API 통신으로 1025마리의 포켓몬 리스트를 불러오고 불러온 데이터를 ID 값으로 정렬하는 방식을 이용하기로 하였다.

5) 검색 모델 다시 만들기!

우선 검색 모델부터 수정을 해야하기 때문에 기존의 검색 모델을 삭제하고 새로운 검색 모델을 만들어준다.

private var pokemonList: [PokemonData] = [] // 모든 포켓몬에 대한 정보가 담긴 배열

/// 모든 포켓몬의 정보를 불러오는 메소드
func dataLoad() {
	self.pokemonManager.fetchPokemonData(urlType: .pokemonList(limit: 1025, offset: 0), modelType: PokemonDataModel.self)
		.observe(on: ConcurrentDispatchQueueScheduler(qos: .default))
		.subscribe(onSuccess: { [weak self] data in
			guard let self else { return }
                
			self.pokemonList = data.results
                
		}, onFailure: { error in
        
			print(error)
                
		}).disposed(by: self.disposeBag)      
}

위 메소드는 포켓몬의 정보를 offset부터 limit까지 불러오고, 불러온 데이터 배열을 pokemonList에 추가해주는 메소드이다. 얼핏보면 1025번이나 API 호출을 요청하는 것 같지만, 사실 API 통신은 한 번만 발생한다.

왜냐하면 API 링크가 offsetlimit라는 쿼리 아이템 값을 통해 범위 내의 값을 한 번에 반환해주기 때문이다.

어쨌든 위에서 만든 dataLoad() 메소드를 초기화시 실행되도록 하면 pokemonList가 모든 포켓몬 데이터를 가지게 된다.
이제 이 데이터를 가지고 다시 검색 결과를 필터링 하는 로직을 작성하자

6) 필터링 로직 수정하기

우선 위에서 로직을 만들었을 때와 마찬가지로 검색 결과를 담아줄 배열을 생성한다

let searchPokemonList = PublishSubject<[(Int, String)]>() // 데이터 바인딩 객체

사실 기존과 다른 점이 없기 때문에 달라지는 건 없다.

이제 필터링 로직을 추가해야 하는데, 기존에는 pokemonList[(id: String, name: Names)] 타입이었기 때문에 id와 name을 사용해서 필터링을 하면 됐는데, 지금은 [PokemonData]타입이기 때문에 조금 다른 로직이 필요하다.

우선 PokemonData의 구성은 아래와 같다.

// 각 포켓몬의 데이터를 담은 데이터 타입
struct PokemonData: Decodable {
    let name: String
}

name 밖에 없다... 즉 id가 없기 때문에 id를 만들어줄 필요가 있는데, 이를 위해서는 또 한 번의 API 통신이 필요했다. 그렇게 되면 그만큼 네트워킹 요청이 많아지게 되고, 이에 따라 네트워킹 시간이 길어지게 되는 문제가 발생한다.

이는 사용자 경험에 부정적으로 작용하기 때문에 다른 방법을 모색해야 했는데, 어떤 방법으로 구현할 수 있을까 고민을 많이 했다.

그런데 단순하게 생각해보니 위에서 dataLoad()메소드로 불러온 데이터가 이미 정렬된 데이터가 아닐까? 하는 생각이 들었다.
이를 검증하기 위해 브레이크 포인트를 걸고 데이터를 확인해 보았는데...

예상대로 순서대로 정렬되어 있는 모습을 확인할 수 있었다!!

그렇다면 pokemonList에서 name을 꺼내고, 꺼낸 name의 인덱스를 사용해서 id를 만들면 되지 않을까? 생각했고 이를 실제로 구현해 보았다.

/// 검색한 값과 관련있는 포켓몬 데이터를 이벤트로 방출하는 메소드
/// - Parameter text: 검색창 입력 값
func search(text: String) {
	let list = containsSearchResult(text: text)
        
	guard list.count > 0 else { return }
        
	self.searchPokemonList.onNext(list)
}

/// 입력된 값과 연관된 포켓몬 리스트를 반환하는 메소드
/// - Parameter text: 입력 값
/// - Returns: 포켓몬 ID, Name 배열
func containsSearchResult(text: String) -> [(Int, String)] {
	var searchList: [(Int, String)] = []
        
	let list = self.pokemonList.enumerated().filter { index, data in
		PokemonTranslator.getKoreanName(for: data.name).contains(text) ||
		data.name.contains(text.lowercased()) ||
		(index + 1).contains(Int(text) ?? -1)
	}.map {
		($0.offset, $0.element)
	}
        
	searchList += addData(datas: list)
        
	return searchList
}
    
/// 포켓몬 데이터 배열을 ID, Name 배열로 반환하는 메소드
/// - Parameter datas: 포켓몬 ID와 포켓몬 데이터가 포함된 배열
/// - Returns: 포켓몬 ID와 포켓몬 이름을 담은 튜플 배열
func addData(datas: [(Int, PokemonData)]) -> [(id: Int, name: String)] {
	var result: [(Int, String)] = []
        
	datas.forEach { index, data in
		let id = index + 1
		let name = PokemonTranslator.getKoreanName(for: data.name)
		let item = (id, name)
		result.append(item)
	}
        
	return result
}

// MARK: - Int Extension Method
extension Int {
    /// Int 타입끼리의 contains 메소드
    /// - Parameter digit: 비교할 값
    /// - Returns: digit이 self에 포함된 경우 true, 그렇지 않은 경우 false
    func contains(_ digit: Int) -> Bool {
        let number = String(self)
        let digitNumber = String(digit)
        
        return number.contains(digitNumber)
    }
}

위의 메소드를 하나하나 살펴보자

let list = self.pokemonList.enumerated().filter { index, data in
	PokemonTranslator.getKoreanName(for: data.name).contains(text) ||
	data.name.contains(text.lowercased()) ||
	(index + 1).contains(Int(text) ?? -1)
}.map {
	($0.offset, $0.element)
}

사실상 이 코드가 가장 핵심적인 코드인데, 파라미터로 들어오는 String 데이터를 통해 값을 필터링하는 코드이다.

먼저 enumerated()메소드를 통해 pokemonList의 인덱스와 멤버를 함께 받아오도록 한다. 그리고 filter의 클로저에서 indexdata라는 매개변수를 두고 이를 활용하여 필터링을 진행한다.

한국어 검색에 대한 필터링을 위해 pokemonList가 가진 name(영어) 값을 PokemonTranslator.getKoreanName(for:) 메소드를 통해 한국어로 바꿔준 뒤 contains 메소드로 입력한 값이 pokemonList가 가진 name값과 연관되어 있는지 확인한다.

영어로 입력하는 경우, 포켓몬 번역기의 key 값이 모두 소문자로 구현되어 있기 때문에 lowercased()를 통해 입력값을 모두 영어로 바꿔주고 pokemonList의 name 값과 연관되어 있는지 확인한다.

마지막으로 숫자로 입력하는 경우를 대응하기 위해 파라미터 값을 Int 타입으로 형변환을 시도하고, 실패하는 경우 -1을 사용하여 pokemonList의 인덱스 값과 비교한다. -1을 하는 이유는 배열의 인덱스가 음수가 되는 경우는 없기 때문에 이상한 값을 입력하는 경우 값을 반환하지 않도록 하기 위해 사용하였다.
여기서 Int타입끼리 contains를 통해 값을 비교했는데, 이는 Int타입에 extension으로 contains 메소드를 추가했기 때문에 사용할 수 있었다.

위의 단계를 모두 완료한 결과의 타입은

이런 형식이 되는데, 이대로는 값을 사용할 수 없기 때문에 map을 사용해서 한 번 더 데이터를 가공해준 것이다.
그럼 list의 데이터 타입은

이렇게 사용할 수 있는 튜플 타입이 된다.

이제 이렇게 만들어진 튜플의 배열을 addData(datas:) 메소드를 사용하여 [(id: Int, name: String)] 타입으로 변환해준다.

이렇게 완성된 [(id: Int, name: String)] 타입을 반환하여 배열의 수가 0보다 크다면 self.searchPokemonList.onNext(list) 코드를 통해 searchPokemonList에 값을 전달하여 이벤트를 방출하게 되고, 이를 구독하는 테이블뷰의 값이 업데이트 되게 된다.

로직을 모두 완료했으니 빌드를 통해 결과를 확인해 보자.

구현 결과

완성이다!

7) 결론

검색바를 만들어 보는 것은 처음인데, 생각보다 어려운 작업은 아니었다.
중요한 것은 검색했을 때 표시할 데이터 모델에 대한 정의가 아닐까? 하는 생각이 들었다.

실제로 검색바가 어떻게 구현되는지 찾아보고 비교해보며 공부해보면 좋을 것 같다는 생각이 들었다.

-Today's Lesson Review-

다 구현한 후에 걱정되는 점은 고차함수와 반복문을 많이 사용한 탓에
시간 복잡도가 엄청나게 커졌을 것 같은데...
이 코드 괜찮은걸까
profile
이유있는 코드를 쓰자!!

0개의 댓글