본문 바로가기

iOS

Kakao 다음(Daum) 검색 API 구현 (feat. Expandable Cell)

728x90

Kakao Developers에서 활용할 수 있는 API 중 검색 API를 구현해보자.

 

관련 가이드는 아래 링크를 통해서 보다 자세하게 확인할 수 있다.

https://developers.kakao.com/docs/latest/ko/daum-search/dev-guide#search-blog

 

Kakao Developers

카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.

developers.kakao.com

 

검색 중에서 블로그와 카페 검색 값을 하는 프로젝트를 만들어보자.

 

먼저 두 가지 API를 확인하면,

- 블로그 : GET /v2/search/blog

- 카페 : GET /v2/search/cafe

로 URL에 겹치는 부분이 많은 것을 확인할 수 있다.

 

이는 EndPoint로 구분하여 관리할 수 있는 것을 말한다.

그 전에 사전에 필요한 작업은 더보기를 통해서 확인할 수 있다. (URL+Extension)

더보기

URL을 확장해서 BaseURL을 작성해두고, EndPoint 값에 따라서 다른 URL을 반환하도록 할 수 있다.

import Foundation

extension URL {
    static let baseURL = "https://dapi.kakao.com/v2/search/"
    
    static func makeEndPointString(_ endPoint: String) -> String {
        return baseURL + endPoint
    }
}
import Foundation

enum EndPoint {
    static let blog = "\(URL.baseURL)/blog"
    static let cafe = "\(URL.baseURL)/cafe"
}

이렇게 되어 있는 것에서 공통된 것을 묶어서

import Foundation

enum EndPoint {
    case blog
    case cafe
    
    var requestURL: String {
        switch self {
        case .blog:
            return URL.makeEndPointString("blog?query=")
        case .cafe:
            return URL.makeEndPointString("cafe?query=")
        }
    }
}

 

위와 같이 작성할 수 있다.

 

그리고 서버 통신 부분을 ViewController와 구분하여 APIManager로 분리한다.

이 때, 주의할 점은 싱글톤 패턴을 사용한다는 것과 탈출 클로저를 통해 서버 request를 보낸 뒤 이에 대한 응답값을 ViewController에서 사용할 수 있도록 한다. 

import Foundation

import Alamofire
import SwiftyJSON

class KakaoAPIManager {
    static let shared = KakaoAPIManager()
    
    private init() { }
    
    private let header: HTTPHeaders = ["Authorization" : "KakaoAK \(APIKey.kakao)"]
    
    func callRequest(type: EndPoint, query: String, completionHandler: @escaping (JSON) -> ()) {
        guard let query = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { return }
        
        let url = type.requestURL + query
        
        // 비동기적으로 처리되는 부분 
        AF.request(url, method: .get, headers: header).validate(statusCode: 200...500).responseData { response in
            switch response.result {
            case .success(let value):
                let json = JSON(value)
                
                completionHandler(json)
                
            case .failure(let error):
                print(error)
            }
        }
    }
}

여기서, AF.request 범위에서는 코드가 비동기적으로 실행된다.

그러므로 순차적으로 코드 실행이 필요한 경우 이 범위 내에서 작성할 것이 아니라, 응답값을 모두 받고 난 다음에 실행하는 것이 맞다. (비동기적으로 실행되는 곳에서 데이터에 접근하려고 할 경우, 데이터를 아직 다 받아오지 않은 상태에서 접근할 수 있기 때문에 오류가 날 수 있다.)

 

그래서 만약 같은 검색 키워드에 대해서 블로그에 대한 검색값을 받은 뒤에 -> 카페에 대한 검색값을 받고 싶다면?

// MARK: - Network

extension ViewController {
    func searchBlog(query: String) {
        KakaoAPIManager.shared.callRequest(type: .blog, query: query) { json in
            self.blogList = json["documents"].arrayValue.map { $0["contents"].stringValue.replacingOccurrences(of: "<b>", with: "").replacingOccurrences(of: "</b>", with: "") }
            print("======================== ✅ 블로그 내용 검색 결과 ✅ ========================")
            print(self.blogList)
            
            // 응답값을 받은 뒤에 다음 서버 통신 시작 
            self.searchCafe(query: "고등어회")
        }
    }
    
    func searchCafe(query:String) {
        KakaoAPIManager.shared.callRequest(type: .cafe, query: query) { json in
            self.cafeList = json["documents"].arrayValue.map { $0["contents"].stringValue.replacingOccurrences(of: "<b>", with: "").replacingOccurrences(of: "</b>", with: "") }
            print("======================== ✅ 카페 내용 검색 결과 ✅ ========================")
            print(self.cafeList)
        }
    }
}

위와 같이 데이터를 받은 뒤에 다음 작업을 순차적으로 진행하면 된다. 

데이터 맵핑 과정을 보다 자세하게 보고 싶다면 더보기를 통해서 확인할 수 있다.

더보기

데이터 맵핑에서 중요한 것은 형태와 타입이다.

- 객체인가? 배열인가? 값인가?

- 값이라면, 어떤 타입인가?

 

API 명세서를 통해서 응답값을 확인하면 아래와 같다.

 

여기서 지금 필요한 정보는 contents이다.

contents의 타입은 string이고 이를 배열로 받을 수 있다. (= [String] 형태로 받으면 된다.)

 

contents는 documents의 키 안의 배열에서 접근할 수 있다.

그래서 단계적으로 t 까주면(?) 된다.

 

먼저 응답값 전체를 받는다.

  • 이는 JSON 형태로 넘어올 것이고 completionHander로 json으로 받을 수 있다. (위의 서버통신 코드 참고)
  • 그리고 나서 documents 배열에 접근한다.
    json["documents"].arrayValue
  • 이 배열은 title ~ datetime의 프로퍼티를 갖고 있는 객체들의 모임이다.
    이 중에서 contents에 대한 값들만 모으면 되기 때문에 .map 함수를 이용해서 그 값들만 저장한다.
    .map { $0["contents"].stringValue }

이렇게 응답값에 대한 값을 까주면 원하는 contents만 담긴 문자열 배열을 얻을 수 있다. 

 


.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)

위의 코드는 어떤 역할을 하는 코드일까?

 

query string 으로 영어 또는 숫자를 입력하면 제대로 서버 통신이 이루어지지만, 만약 한글을 입력하게 되면 오류가 나는 것을 볼 수 있다.

invalidURL(url: "https://dapi.kakao.com/v2/search/blog?query=제주도")

위와 같은 오류는 한글이 포함된 url string으로 URL 컨버팅을 해줄 경우, nil 값이 반환되는 문제가 발생하는 것이다.

 

URL의 string: 은 영문, 숫자와 특정 문자만 인식 가능하고, 한글, 띄어쓰기 등은 인식하지 못한다고 한다. 

따라서 한글이 포함되어있는 url String을 따로 인코딩해주는 작업이 필요하다. 

 

만약 많이 사용한다면, Extension으로 분리할 수 있다.

더보기
extension String {
    func encodeUrl() -> String? {
        return self.addingPercentEncoding( withAllowedCharacters: .urlQueryAllowed)
    }
    
    func decodeUrl() -> String? {
        return self.removingPercentEncoding
    }
}

 

이를 위해서 Swift에서는 기본적으로 위와 같은 함수를 제공하는 것이다.

기본적으로 String의 addingPercentEncoding(withAllowedCharacters:)라는 메소드를 제공해주고 있다.

모든 문자들을 특정 Set에 있지 않는 경우는 새롭게 대체해서 String을 만들어서 리턴한다. 즉, 어떤 Set에 들어가지 않는 문자는 encoding을 해준다는 것을 의미한다. 

 

그래서 사용자가 검색할 검색어에 대해서 한글인지, 영어인지 .. 어떤 것인지 보장할 수 없으므로 

guard let query = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { return }

과 같이 작성하여 인코딩을 하는 것이다.

 

 

.replacingOccurrences(of: "<b>", with: "")

ViewController에서 서버 통신 이후로 받은 데이터에 대해 작업을 할 때, 위와 같이 stringValue에 대한 가공을 하고 있는 것을 확인할 수 있다.

self.blogList = json["documents"].arrayValue.map { $0["contents"].stringValue.replacingOccurrences(of: "<b>", with: "").replacingOccurrences(of: "</b>", with: "") }

 

이는 검색값을 가공 없이 받게 되면,

 

이런 식으로 데이터가 넘어오는 것을 확인할 수 있는데 이는 볼드체에 대한 정보가 html tag 형식으로 넘어오는 것이다. 

 

사용자가 원하는 데이터는 이렇게 태그값이 포함된 데이터가 아니므로 이를 교체할 함수가 필요하다.

그것이 바로 .replacingOccurrences(of: "<b>", with: "") 이다. <b>에 대해서 빈 값으로 변경하는 것이다.

 

함수를 사용하면 아래와 같이 변경되어서 값이 출력되는 것을 확인할 수 있다. 

 


이렇게 끝나기 아쉬우니까 .. (너무 짧잖아요 ..) 블로그 / 카페 검색 결과를 UITableView에 올리고 버튼을 누르면 접었다가 펴지는 .. cell을 만들어보도록 하자.

 

+ 거기에 이제 UISearchBar를 더해서 검색어를 입력하면 검색어에 대한 블로그/카페 응답이 UI에 반영되도록 해보자.

UISearchBar + UITableView 기본 세팅 

먼저 UI를 위와 같이 만들어준다. (해당 ViewController 앞, 최상단에는 UINavigationController가 연결되어 있다.)

 

그리고 각 UISearchBar와 UITableView에 대해서 delegate와 dataSource를 연결한다.

// MARK: - Custom Method

private func configureSearchBar() {
    searchBar.delegate = self
}

private func configureTableView() {
    tableView.delegate = self
    tableView.dataSource = self
}

 

// MARK: - UISearchBar Protocol

extension ViewController: UISearchBarDelegate {
    func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
        if let text = searchBar.text {
            searchBlog(query: text)
        }
    }
}

// MARK: - UITableView Protocol

extension ViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        // 분기 처리를 해서도 조정할 수 있다.
        // 더 우선적으로 호출된다. (메서드가 우선한다. - viewDidLoad에서 호출되는 것과 비교했을 때!!)
        return UITableView.automaticDimension
    }
}

extension ViewController: UITableViewDataSource {
    func numberOfSections(in tableView: UITableView) -> Int {
        return 2
    }
    
    func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
        return section == 0 ? "블로그 검색 결과" : "카페 검색 결과"
    }
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return section == 0 ? blogList.count : cafeList.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: "KakaoCell", for: indexPath) as? KakaoCell else {
            return UITableViewCell()
        }
        return cell
    }
}

그리고 UITableView의 경우 Custom Cell을 만들어서 연결한다.

테이블 뷰의 경우 블로그와 카페 정보를 Section을 나누어서 UI에 보여줄 것이므로 섹션도 2개로 나눠 UI를 만들어준다.

 

검색 결과 (응답값) UI에 반영 

동작 흐름은 크게 3단계로 나눌 수 있다.

1. UISearchBar의 검색어를 받는다.

2. 해당 검색어를 query string으로 보내 서버통신을 한다.

3. 서버에서 받은 응답값을 테이블 뷰에 올린다.

 

UISearchBar의 검색어로 검색 

// MARK: - UISearchBar Protocol

extension ViewController: UISearchBarDelegate {
    func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
    	// 검색어가 있는 경우에 서버 통신을 구현한다.
        if let text = searchBar.text {
            searchBlog(query: text)
        }
    }
}

 

서버 통신 및 결과 UI에 반영 

// MARK: - Network

extension ViewController {
	// 서치바의 검색어로 블로그를 검색한다.
    func searchBlog(query: String) {
        KakaoAPIManager.shared.callRequest(type: .blog, query: query) { json in
            self.blogList = json["documents"].arrayValue.map { $0["contents"].stringValue.replacingOccurrences(of: "<b>", with: "").replacingOccurrences(of: "</b>", with: "") }
            // 블로그 검색이 끝나면 카페도 검색한다. 
            self.searchCafe(query: query)
        }
    }
    
    func searchCafe(query:String) {
        KakaoAPIManager.shared.callRequest(type: .cafe, query: query) { json in
            self.cafeList = json["documents"].arrayValue.map { $0["contents"].stringValue.replacingOccurrences(of: "<b>", with: "").replacingOccurrences(of: "</b>", with: "") }
            
            // 검색어에 대한 응답값을 바탕으로 UI에 반영한다.
            self.tableView.reloadData()
        }
    }
}

 

Expandable Cell 구현 

블로그, 카페 검색 후 contents에 따라서 다른 높이를 갖고 + 접었다가 펼 수 있는 유동적인 cell을 만들어보자.

 

유동적인 높이값 설정

이를 위해서 설정할 것들이 있는데, 먼저 UITableViewCell의 높이를 정적인 값으로 설정하면 안된다.

즉, 특정한 값을 넣어서 높이값을 설정하는 것이 아니라 셀 안의 컨텐츠에 따라서 높이값이 달라질 수 있도록 설정해야 한다.

 

프로퍼티로 설정할 수도 있고,

tableView.rowHeight = UITableView.automaticDimension

메서드로 설정할 수도 있다.

extension ViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        return UITableView.automaticDimension
    }
}

 

셀의 expand 유무 확인

현재 셀이 접힌 상태인지/펼친 상태인지 구분할 수 있는 변수를 만들어야 한다.

 

var isExpanded = false // false면 2줄, true면 0으로

위와 같이 Bool형의 변수를 생성한다.

만약 접힌 상태라면 고정된 영역만큼 보여야 하므로(= 접힌 상태) label의 줄 수를 2로 고정하고, 펼친 상태라면 유동적으로 보여야 하므로 줄 수를 0으로 한다

extension ViewController: UITableViewDataSource {
    ... 
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: "KakaoCell", for: indexPath) as? KakaoCell else {
            return UITableViewCell()
        }
        
        // isExpanded 유무에 따라서 유동적으로 label의 줄 수를 설정하고 이에 맞게 셀의 UI가 설정되도록 한다.
        cell.contentLabel.numberOfLines = isExpanded ? 0 : 2
        
        cell.contentLabel.text = indexPath.section == 0 ? blogList[indexPath.row] : cafeList[indexPath.row]
        return cell
    }
    
    ... 
}

 

그리고 버튼을 눌렀을 때 이 과정이 실행되어야 하므로, 아래와 같이 코드를 작성하여 버튼에 대한 이벤트 처리를 하면 된다.

@IBAction func showTotalButtonClicked(_ sender: Any) {
    isExpanded.toggle()
    tableView.reloadData()
}

'iOS' 카테고리의 다른 글

0812 Q&A 정리  (0) 2022.08.12
required init VS override init  (0) 2022.08.10
Cell안의 UIButton 이벤트 처리 (+WebKit View)  (0) 2022.08.08
TMDB - GET / Pagenation  (0) 2022.08.08
APIManager  (1) 2022.08.08