[iOS] Naver Search API로 지역 검색 구현하기
이전에 MapKit을 활용하여 장소를 검색하는 기능을 만들어본 적이 있었다.
2024.01.07 - [iOS] - [iOS] MapKit 주소 자동검색 구현하기
[iOS] MapKit 주소 자동검색 구현하기
[iOS] MapKit 주소 자동검색 구현하기 카뮤 프로젝트에서 iOS 앱으로 주소검색을 하고, 검색된 주소를 탭 하면 다음화면으로 넘어가서 그 위치를 맵에 찍어주는 기능을 개발해야 했다. 카뮤는 네이
masterpiece-programming.tistory.com
이번에는 MapKit이 아니라, Naver Search API를 활용하여, 체육 시설만을 검색하여, 화면에 나타내보는 것을 해보려고 한다. 이 기능을 구현하기 위해서는 Naver Search API 사용을 위한 신청을 해야 하며, 통신을 위한 Key를 발급받아야 한다. 이 과정은 이번 포스팅에서는 생략한다(타 블로그에 너무 많이 나와있으니 그것을 참고하는 것이 더 나을 것이다).
1. TODO
- 체육관 명을 입력하고, 검색 버튼을 누르면 그와 관련된 검색 정보들을 불러온다.
- 검색된 정보들을 여러 개의 셀에 담고, ScrollView로 나타낸다. 셀은 상호명과 주소를 보여준다.
- 검색을 하여 정보가 없을 경우에는 "검색 결과가 없습니다."라는 텍스트를 보여준다.
- 검색된 셀을 누르면, 그 정보를 보여주는 디테일뷰로 전환된다.
- 자동 완성 기능은 넣지 않는다.
이 정도의 TODO를 가지고, 작업에 들어가도록 한다.
2. Service 코드 구현
먼저, 뷰를 구현하기 전에 검색어를 Naver API에 전달하고, 그 응답을 받는 코드부터 구현을 해보겠다. Search API의 경우, SDK를 지원하지 않고, HTTP로 요청, 응답을 받는 방식으로 진행해야 된다. 나는 Alamofire 같은 라이브러리는 쓰지 않고, URLRequest, URLSession 등 iOS에서 기본 제공되는 것만을 가지고 구현했다.
1) 기본 Model 구성
데이터를 주고 받을 때, 데이터를 내가 사용하고 싶은 양식으로 파싱해야 응답을 받아와서 가져온 데이터를 앱 내에서 사용할 때 훨씬 용이하게 사용할 수 있다. json 방식으로 가져온 데이터를 struct 모양으로 파싱하기 위해서 다음과 같은 Model, DTO를 선언해준다.
// Model
struct Gym: Hashable {
var gymName: String
var gymDescrption: String
var gymAddress: String
var gymPositionLat: Double
var gymPositionLng: Double
var gymLink: String?
var gymOpeningHours: String?
var gymType: String
}
// DTO
struct GymItem: Codable {
let category: String
let address: String
let roadAddress: String
let mapx: String
let title: String
let link: String
let mapy: String
let description: String
}
Gym의 경우, 내부 앱에서 사용하는 것이기 때문에, 자유롭게 프로퍼티 이름을 설정해주었다. 하지만, GymItem의 경우, Naver API가 응답을 보내는 양식과 동일하게 만들어 일치될 수 있도록 만들어주었다.
응답을 주는 것 중 item의 하위의 것들이 우리가 필요한 정보들이다. 그래서, 이와 관련된 부분들 중, 내가 필요한 것들만 가지고 오면 되는데, 필요한 것만 GymItem에 넣으면 된다. 필요없는 것은 GymItem에 쓰지 않으면 자동으로 그 데이터는 변환하지 않게 된다.
2) Response 객체 구성
Response도 디코딩해야 하는 객체 중 하나이다.
응답이 올 경우, 이런식으로 오게 되는데, item은 하나만 담겨오는 것이 아니라, 여러개 뭉탱이로 담긴 items로 전달되어 오게 된다. 그래서 Response 전체를 디코딩할 수 있도록 객체로 만들어두고, 손쉽게 사용할 수 있도록 만드는 것이 좋다.
struct SearchResponse: Codable {
let lastBuildDate: String
let display: Int
let items: [GymItem]
}
위의 GymItem 타입으로 저장될 수 있도록 items를 만들어 둔다. 그리고 필요한 정보들을 프로퍼티로 선언해준다.
3) 요청과 응답받기
기본적인 데이터 타입을 정의했으면, 직접 요청과 응답을 받을 수 있는 함수를 만들어야 한다. 나의 경우, 이 코드들을 SearchPlaceService라는 Service 내부에 배치했다.
(1) 요청하기
이렇게 하라고 하는 Naver의 공식문서가 있다.
이것을 iOS에서 활용할 수 있도록 변경한 것이라고 보면 된다.
/// 네이버 API에서 장소에 대한 데이터를 받아오는 메서드
func search(query: String, completion: @escaping (Result<Data, Error>) -> Void) {
guard let encodedQuery = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
let url = URL(string: "https://openapi.naver.com/v1/search/local.json?query=\(encodedQuery)&display=10&start=1&sort=random") else {
completion(.failure(NetworkError.invalidURL)) // 에러타입은 커스텀 정의
return
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue(Bundle.main.naverAPIClientID, forHTTPHeaderField: "X-Naver-Client-Id")
request.setValue(Bundle.main.naverAPIClientSecret, forHTTPHeaderField: "X-Naver-Client-Secret")
let task = URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
completion(.failure(error))
return
}
guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else {
completion(.failure(NetworkError.invalidResponse))
return
}
if let data = data {
completion(.success(data))
} else {
completion(.failure(NetworkError.invalidData))
}
}
task.resume()
}
쉽게 이야기 하면, URLRequest 할 수 있는 코드를 만들고, URLSession으로 보내는 방식이다.
맨 위에서 url을 만들어주는데, 요청 예에 따라서 검색어, 검색 요건 등을 넣어서 url을 만든다. 이는 요청 바디를 만든 것이라고 할 수 있다.
이를 요청 헤더와 같이 만들어서 보내야 하는데, URLRequest 부분은 요청 헤더를 만들고, 이를 전체 요청 데이터로 만든 것이라고 생각하면 된다. 헤더에는 다음과 같은 정보가 들어있다. GET 방식으로 보내고, json으로 받을 것이며, 클라이언트 아이디, 시크릿 번호가 무엇인지를 세팅해주고, request를 날려라!
이렇게 하면, 요청을 Naver에 하게 되고, 받아오게 된다.
(2) 응답 처리
위의 요청에 따라 받아온 데이터를 아래와 같이 디코딩하고 파싱하는 작업을 거치면, 앱에서 쓸 수 있는 데이터로 변환할 수 있다.
/// 네이버에서 받아온 JSON 데이터를 파싱하는 메서드
func getData(searchText: String, completion: @escaping (Result<[Gym], Error>) -> Void) {
search(query: searchText) { result in
switch result {
case .success(let data):
do {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase // 디코딩 규칙 선언
let response = try decoder.decode(SearchResponse.self, from: data)
let gyms = response.items
.filter { item in
// category에 "스포츠"라는 단어가 포함된 항목만 필터링
item.category.contains("스포츠")
}
.map { item -> Gym in
return Gym(
gymName: item.title.removingHTMLTags(),
gymDescrption: item.description.removingHTMLTags(),
gymAddress: item.roadAddress,
gymPositionLat: Int(item.mapy)?.formattedCoordinate(precision: 2) ?? 0.0,
gymPositionLng: Int(item.mapx)?.formattedCoordinate(precision: 3) ?? 0.0,
gymLink: item.link,
gymOpeningHours: nil,
gymType: item.category.removingHTMLTags()
)
}
completion(.success(gyms))
} catch {
completion(.failure(error))
print("Error parsing JSON:", error)
}
case .failure(let error):
print("Error:", error)
}
}
}
아까 만들었던 search를 활용하여 클로저 내부에서 받아온 데이터를 처리하는 방식이다.
result가 .success라면, 데이터를 함께 받아오는데, 그 데이터를 이제 디코딩하여 Model에 맞게 세팅해주는 것이라고 보면된다.
items를 filter와 map을 활용해서 자유롭게 세팅해줄 수 있다. 나 같은 경우에는 검색 결과 중에, 체육관만 필요했기 때문에 필터로 카테고리가 "스포츠"인 것만을 골랐다. 놀라운 것은 이 카테고리 기준은 네이버에서 알려주지 않는다는 점이었다... 그래서 여러 번 통신을 시도해보면서 골라내야할 것들을 골라낼 수 밖에 없었다. 이 카테고리의 경우 뭐 바뀔 수도 있기 때문에 알려주지 않는다나...
그리고 map으로 GymItem을 Gym 타입으로 변경해준다. 여기에 보면 removingHTMLTags라고 있는데, 이게 문제가 받아온 데이터 중에 &s; 같은 HTML 태그가 달려 있어서 없애기 위한 방법으로 사용한 것이다. String Extension 안에 메서드로 구현해주었다. 이것은 왜 같이 오는지는 아직 파악을 하지 못했다.
이렇게 구현을 하면, getData를 ViewModel에서 호출했을 때, 특정 프로퍼티 안에 데이터들을 넣어줄 수 있다.
4) ViewModel에서 Service 부르기
이제 getData 함수를 ViewModel에서 불러서 사용해보면 된다.
class SearchPlaceViewModel: ObservableObject {
@Published var searchState: SearchState = .search
@Published var searchText: String = ""
@Published var gymList: [Gym] = []
let searchService = SearchPlaceService()
/// 입력한 텍스트를 기반으로 네이버 검색 API에서 데이터를 받아오는 메서드
func searchGym(searchText: String) {
searchService.getData(searchText: searchText) { result in
switch result {
case .success(let gyms):
DispatchQueue.main.async {
// 검색결과가 없을 경우 .notFound 상태로 변경
if gyms.isEmpty {
self.gymList = []
self.searchState = .notFound
} else {
self.gymList = gyms
}
}
case .failure(let error):
print("Failed to fetch gym data:", error)
}
}
}
}
searchState의 경우, 검색 중, 검색 전, 검색 후, 검색 결과 없음을 구분해주고, 이를 View에 반영하기 위한 프로퍼티이다.
searchGym을 통해서 체육관 정보를 불러오고 gymList에 데이터를 넣어준다.
그러면 이 gymList를 가지고, ScrollView에서 이 데이터의 정보를 가진 Cell들을 차례로 보여주면 되는 것이다.
5) View에 표시
var resultBody: some View {
ScrollView {
ForEach(viewModel.gymList, id: \.self) { gym in
Button {
navigationRouter.push(to: .gymDetail(gym: gym))
} label: {
ResultCell(gym: gym)
}
}
}
}
gymList를 순회하면서, ResultCell이라는 뷰의 형식으로 셀을 표시하게 된다. ResultCell은 UI 표현이기 때문에, 각자의 방식 그리고 디자인대로 구현하여 이를 보여주면 될 것이다.
3. 마무리
이렇게 하면, Naver Search API 지역 검색을 바탕으로 체육관 정보 검색 화면을 만들 수 있다. 자동 완성 검색은 지원되는 것이 없어 직접 구현을 해야되고, 요청량이 많아져 부하가 많이 걸릴 수 있기 때문에 솔직히 구현하기 힘들 수도 있을 것이라고 생각된다.
현재 문제점은 검색어 일부를 넣었을 때에도 잘 검색이 되게끔 하고 싶은데, 그것이 안 된다. 요청 데이터의 양을 어느 정도 조절을 해야 사용자가 편할 정도로 검색이 될 수 있을지가 요건인데 이 부분은 여러 번 실험을 해보면서 요청 데이터의 양을 늘리면 해결이 될 것 같다.
(사실 Naver API 보다는 Apple MapKit이 더 낫다는 생각이 들기도 한다...)
끝!
'iOS' 카테고리의 다른 글
[iOS] 앱 버전 체크 기능 만들기(강제종료) (0) | 2024.08.07 |
---|---|
[iOS] Firebase만 가지고 리더보드 만들어보기 (0) | 2024.07.29 |
[iOS] 커스텀 버튼 컴포넌트 하이라이트 처리 (0) | 2024.01.08 |
[iOS] MapKit 주소 자동검색 구현하기 (0) | 2024.01.07 |
[iOS] CoreData Attribute 변경 시 나타나는 Migration 문제 해결(code=134140) (1) | 2024.01.07 |
댓글