본문 바로가기
iOS

[iOS] Naver Search API로 지역 검색 구현하기

by DuncanKim 2024. 7. 22.
728x90

[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라고 있는데, 이게 문제가 받아온 데이터 중에 &amps; 같은 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이 더 낫다는 생각이 들기도 한다...)

 

끝!

728x90

댓글