티스토리 뷰

안드로이드/코드

네이버 검색 API 사용

알렌보이스 2022. 6. 9. 21:58
728x90

이번에는 Naver Developers에서 제공해주는 API 중 네이버 검색 (뉴스)을 사용해보려고 합니다.

1. 활용신청

https://developers.naver.com/apps/#/register

 

애플리케이션 - NAVER Developers

 

developers.naver.com

다음과 같이 설정을 하여서 신청을 하시면 됩니다.

등록을 하고 나면 Application 텝에 들어가시면

이렇게 나오는 것을 확인할 수 있을 겁니다.

클릭해서 들어가면 Client ID와 Client Secret의 키 값을 확인할 수 있습니다.

 

2. Android에 Key 등록

local.properties 하단부에 다음과 같이 추가해줍니다.

client_id="위에서 나온 Client ID 키 값을 추가해줍니다."
client_secret="위에서 나온 Client Secret 키 값을 추가해줍니다."

app수준의 build.gradle에 다음의 내용을 추가합니다.

Properties properties = new Properties()
properties.load(project.rootProject.file('local.properties').newDataInputStream())

android {
    ...
    defaultConfig {
        ...
        
        buildConfigField "String", "CLIENT_ID", properties['client_id']
        buildConfigField "String", "CLIENT_SECRET", properties['client_secret']
    }
}

Sync Now하고 빌드 한 번 진행하면

BuildConfig.CLIENT_ID
BuildConfig.CLIENT_SECRET

이렇게 사용이 가능해집니다.

 

3. Retrofit 적용

이제 본격적으로 Network 통신을 위해 Retrofit을 적용해봅니다.

https://developers.naver.com/docs/serviceapi/search/news/news.md#%EB%89%B4%EC%8A%A4

 

뉴스 - Search API

뉴스 NAVER Developers - 검색 API 뉴스 검색 개발가이드 검색 > 뉴스 네이버 뉴스 검색 결과를 출력해주는 REST API입니다. 비로그인 오픈 API이므로 GET으로 호출할 때 HTTP Header에 애플리케이션 등록 시

developers.naver.com

해당 내용을 통해 어떤 데이터를 넣어줘야 하는지, 어떤 값을 받을 수 있는지 확인이 가능합니다.

data class NewsResult(
    val lastBuildDate : String, // 검색 결과를 생성한 시간이다.
    val total : Int, // 검색 결과 문서의 총 개수를 의미한다.
    val start : Int, // 검색 결과 문서 중, 문서의 시작점을 의미한다.
    val display : Int, // 검색된 검색 결과의 개수이다.
    val items : List<NewsItems>, //개별 검색 결과이며 title, originallink, link, description, pubDate를 포함한다.
) 

data class NewsItems(
    val title : String, // 개별 검색 결과
    @SerializedName("originallink")
    val originalLink: String, // 검색 결과 문서의 제공 언론사 하이퍼텍스트 link를 나타낸다.
    val link: String, // 검색 결과 문서의 제공 네이버 하이퍼텍스트 link를 나타낸다.
    val description : String, //검색 결과 문서의 내용을 요약한 패시지 정보이다. 문서 전체의 내용은 link를 따라가면 읽을 수 있다. 패시지에서 검색어와 일치하는 부분은 태그로 감싸져 있다.
    val pubDate : String // 검색 결과 문서가 네이버에 제공된 시간이다.
)

우선 결괏값을 data class로 만들어 줍니다.

그다음 호출을 위해 interface를 만들건대 그전에 호출 예시를 한번 보겠습니다.

curl "https://openapi.naver.com/v1/search/news.xml?query=%EC%A3%BC%EC%8B%9D&display=10&start=1&sort=sim" \ -H "X-Naver-Client-Id: {애플리케이션 등록 시 발급받은 client id 값}" \ -H "X-Naver-Client-Secret: {애플리케이션 등록 시 발급받은 client secret 값}" -v

저는 여기서 한 개의 API를 쓸 거라서 BaseUrl은 아무 곳에서 끊어도 무관하지만
다른 API들도 봤을 때 https://openapi.naver.com/v1/ 여기까지가 공통적이기 때문에

const val NAVER_API_BASE_URL = "https://openapi.naver.com/v1/"

BaseUrl은 위와 같이 설정하였습니다.

그다음 해당 API는 GET 방식이고 추가 주소로는"search/news.json"가 있습니다.

요청 변수는 query만 필수 값이고 나머지는 선택이므로 원하는 방식으로 넣어 줍니다.

저는 그냥 다 했습니다~ㅎㅎ

마지막으로 

\ -H "X-Naver-Client-Id: {애플리케이션 등록 시 발급받은 client id 값}" \ -H "X-Naver-Client-Secret: {애플리케이션 등록 시 발급받은 client secret 값}" -v

이 부분을 보면 네이버 API는 거의 모든 API에서 위와 같은 헤더 값을 요청하는 것으로 알고 있습니다.

interface NaverService {

    @Headers(value = [
        "X-Naver-Client-Id: ${BuildConfig.CLIENT_ID}",
        "X-Naver-Client-Secret: ${BuildConfig.CLIENT_SECRET}"
    ])
    @GET("search/news.json")
    suspend fun getNews(
        @Query("query") query : String, // 검색을 원하는 문자열로서 UTF-8로 인코딩한다.
        @Query("display") display : Int, // 검색 결과 출력 건수 지정, 기본값 10 최대값 100
        @Query("start") start : Int, // 검색 시작 위치 기본값 1, 최대값 1000까지 가능
        @Query("sort") sort : String // 정렬 옵션: sim (유사도순), date (날짜순)
    ) : Response<NewsResult>

}

이전 예제에서는 Header가 없었어서 이번에는 Headers를 이용하여 추가를 해보았습니다.

2022.04.28 - [안드로이드/코드] - 도로명 주소 개발센터 API 사용해보기1 : Api활용 신청, Retrofit

 

도로명 주소 개발센터 API 사용해보기1 : Api활용 신청, Retrofit

앱을 개발을 하다 보면 주소를 받아야 하는 경우가 종종 있습니다. 저 같은 경우 웹뷰를 이용한 다음 우편 번호 서비스를 이용해왔습니다. 이 방식을 이용하면 개발자 입장에서는 진짜 편하게

alanboyce.tistory.com

기본 사용법 관련해서는 도로명 주소 사용해보기 포스터에서 진행하였으므로 생략하겠습니다~

 

4. Hilt 적용

Hilt 역시 도로명 주소 사용해보기에서 다루었지만 이번 예제를 통해 내용을 빠진 보충 해보려고 합니다~

@Provides
@Singleton
fun provideAddressRetrofit(
    okHttpClient: OkHttpClient,
    gsonConverterFactory: GsonConverterFactory
) : Retrofit =
    Retrofit.Builder()
        .baseUrl(Constants.ADDRESS_API_BASE_URL)
        .client(okHttpClient)
        .addConverterFactory(gsonConverterFactory)
        .build()

@Provides
@Singleton
fun provideNaverRetrofit(
    okHttpClient: OkHttpClient,
    gsonConverterFactory: GsonConverterFactory
) : Retrofit =
    Retrofit.Builder()
        .baseUrl(Constants.NAVER_API_BASE_URL)
        .client(okHttpClient)
        .addConverterFactory(gsonConverterFactory)
        .build()

만약 이렇게 두 가지 BaseUrl을 사용해야 하는 경우라고 했을 때 Hilt를 적용하려고 하면 문제가 발생합니다.

같은 Retrofit을 반환하고 있기 때문에 Hilt입장에서는 어떤 것을 적용해야 할지 알 수 없습니다.

이런 경우를 대비하여 Hilt는 다음과 같이 준비를 해두었습니다.

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class AddressRetrofit

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class NaverRetrofit

위와 같이 annotation을 생성해주시고

@AddressRetrofit
@Provides
@Singleton
fun provideAddressRetrofit(
...
) : Retrofit =
    ...

@NaverRetrofit
@Provides
@Singleton
fun provideNaverRetrofit(
...
) : Retrofit =
    ...

@Provides위에 만든 annotation을 각각 넣어 구분을 해줍니다.

@Provides
@Singleton
fun provideNaverService(@NaverRetrofit retrofit: Retrofit) : NaverService =
    retrofit.create(NaverService::class.java)

그 Retrofit을 불러올 때도 어떤 Retrofit을 불러오는 것이다라는 것을 확실하게 알려줍니다.

이렇게 하면 같은 리턴 값이라도 구분하여 사용이 가능해집니다.

5. 전체 코드

이것 이후에는 이전 글과 특별히 다른 부분이 없어서 혹시 필요하신 분을 위해 전체 코드를 올립니다~

1) data class

data class NewsResult(
    val lastBuildDate : String, // 검색 결과를 생성한 시간이다.
    val total : Int, // 검색 결과 문서의 총 개수를 의미한다.
    val start : Int, // 검색 결과 문서 중, 문서의 시작점을 의미한다.
    val display : Int, // 검색된 검색 결과의 개수이다.
    val items : List<NewsItems>, //개별 검색 결과이며 title, originallink, link, description, pubDate를 포함한다.
) {
    fun mapper() = items.map { it.mapper() }
}

data class NewsItems(
    val title : String, // 개별 검색 결과
    @SerializedName("originallink")
    val originalLink: String, // 검색 결과 문서의 제공 언론사 하이퍼텍스트 link를 나타낸다.
    val link: String, // 검색 결과 문서의 제공 네이버 하이퍼텍스트 link를 나타낸다.
    val description : String, //검색 결과 문서의 내용을 요약한 패시지 정보이다. 문서 전체의 내용은 link를 따라가면 읽을 수 있다. 패시지에서 검색어와 일치하는 부분은 태그로 감싸져 있다.
    val pubDate : String // 검색 결과 문서가 네이버에 제공된 시간이다.
) {
    fun mapper() = NewsContents(
        title = title,
        url = originalLink,
        description = description
    )
}

data class NewsContents(
    val title: String,
    val url: String,
    val description: String
)

2) NaverService.kt

interface NaverService {

    @Headers(value = [
        "X-Naver-Client-Id: ${BuildConfig.CLIENT_ID}",
        "X-Naver-Client-Secret: ${BuildConfig.CLIENT_SECRET}"
    ])
    @GET("search/news.json")
    suspend fun getNews(
        @Query("query") query : String, // 검색을 원하는 문자열로서 UTF-8로 인코딩한다.
        @Query("display") display : Int, // 검색 결과 출력 건수 지정, 기본값 10 최대값 100
        @Query("start") start : Int, // 검색 시작 위치 기본값 1, 최대값 1000까지 가능
        @Query("sort") sort : String // 정렬 옵션: sim (유사도순), date (날짜순)
    ) : Response<NewsResult>

}

3) NaverClient.kt

class NaverClient @Inject constructor(
    private val service: NaverService
) {

    suspend fun getNews(
        query: String,
        display: Int,
        start: Int,
        sort: String,
        successListener: (NewsResult) -> Unit,
        failureListener: () -> Unit
    ) = try {
        val result = service.getNews(
            query = query,
            display = display,
            start = start,
            sort = sort
        )

        if (result.isSuccessful) {
            result.body()?.let(successListener) ?: failureListener
        } else {
            failureListener()
        }

    } catch (e: Exception) {
        e.printStackTrace()
        failureListener()
    }

}

4) NetworkModule.kt

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
    @Provides
    @Singleton
    fun provideOkHttpClient(
        loggingInterceptor: HttpLoggingInterceptor
    ) : OkHttpClient =
        OkHttpClient.Builder()
            .readTimeout(10, TimeUnit.SECONDS)
            .connectTimeout(10, TimeUnit.SECONDS)
            .writeTimeout(15, TimeUnit.SECONDS)
            .addInterceptor(loggingInterceptor)
            .build()

    @Provides
    @Singleton
    fun provideGsonConvertFactory() : GsonConverterFactory = GsonConverterFactory.create()

    @AddressRetrofit
    @Provides
    @Singleton
    fun provideAddressRetrofit(
        okHttpClient: OkHttpClient,
        gsonConverterFactory: GsonConverterFactory
    ) : Retrofit =
        Retrofit.Builder()
            .baseUrl(Constants.ADDRESS_API_BASE_URL)
            .client(okHttpClient)
            .addConverterFactory(gsonConverterFactory)
            .build()

    @NaverRetrofit
    @Provides
    @Singleton
    fun provideNaverRetrofit(
        okHttpClient: OkHttpClient,
        gsonConverterFactory: GsonConverterFactory
    ) : Retrofit =
        Retrofit.Builder()
            .baseUrl(Constants.NAVER_API_BASE_URL)
            .client(okHttpClient)
            .addConverterFactory(gsonConverterFactory)
            .build()

    @Provides
    @Singleton
    fun provideLoggingInterceptor() : HttpLoggingInterceptor =
        HttpLoggingInterceptor().apply {
            level = if(BuildConfig.DEBUG) {
                HttpLoggingInterceptor.Level.BODY
            } else {
                HttpLoggingInterceptor.Level.NONE
            }
        }

    @Provides
    @Singleton
    fun provideAddressService(@AddressRetrofit retrofit: Retrofit) : AddressService =
        retrofit.create(AddressService::class.java)

    @Provides
    @Singleton
    fun provideAddressClient(addressService: AddressService) : AddressClient =
        AddressClient(addressService)

    @Provides
    @Singleton
    fun provideNaverService(@NaverRetrofit retrofit: Retrofit) : NaverService =
        retrofit.create(NaverService::class.java)

    @Provides
    @Singleton
    fun provideNaverClient(naverService: NaverService) : NaverClient =
        NaverClient(naverService)

    @Qualifier
    @Retention(AnnotationRetention.BINARY)
    annotation class AddressRetrofit

    @Qualifier
    @Retention(AnnotationRetention.BINARY)
    annotation class NaverRetrofit
}

5) NewsRepository.kt

class NewsRepository @Inject constructor(
    private val client: NaverClient
) {

    suspend fun getNews(
        query: String,
        display: Int,
        start: Int,
        sort: String,
        successListener: (NewsResult) -> Unit,
        failureListener: () -> Unit
    ) {

        client.getNews(
            query = query,
            display = display,
            sort = sort,
            start = start,
            successListener = successListener,
            failureListener = failureListener
        )

    }

}

6) NewsViewModel.kt

@HiltViewModel
class NewsViewModel @Inject constructor(
    private val repository: NewsRepository
) : BaseViewModel<NewsViewModel.Event>() {

    private var start = 1
    private val display = 20
    private var isMore = true

    fun getNews(

    ) = viewModelScope.launch {
        if (isMore.not()) return@launch

        repository.getNews(
            query = "포켓몬",
            display = display,
            start = start,
            sort = SIMILAR,
            successListener = { result ->
                if(result.total > start) {
                    isMore = true
                    start += display
                }
                event(Event.Success(result.mapper()))
            },
            failureListener = {
                event(Event.Failure)
            }
        )

    }

    sealed class Event {
        data class Success(val result: List<NewsContents>): Event()
        object Failure : Event()
    }

    companion object {
        const val SIMILAR = "sim"
    }

}

7) NewsActivity.kt

@AndroidEntryPoint
class NewsActivity : BaseViewModelActivity<ActivityNewsBinding, NewsViewModel>(R.layout.activity_news) {

    override val viewModel: NewsViewModel by viewModels()
    private lateinit var adapter : NewsAdapter

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        repeatOnStarted {
            viewModel.eventFlow.collect { event -> eventHandler(event) }
        }

        initViews()
        viewModel.getNews()
    }

    private fun initViews() = with(binding) {
        title = getString(R.string.news)
        layoutTop.btnBack.setOnClickListener { onBackClick(it) }
        adapter = NewsAdapter { url ->
            startActivity(
                createIntent(NewsWebViewActivity::class.java).also {
                    it.putExtra(Constants.URL, url)
                }
            )
        }
        recyclerView.adapter = adapter
        recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                super.onScrolled(recyclerView, dx, dy)
                if (!recyclerView.canScrollVertically(1) ) {
                    viewModel.getNews()
                }
            }
        })
    }

    private fun eventHandler(event: NewsViewModel.Event) {
        when(event) {
            is NewsViewModel.Event.Failure -> {
                toast(getString(R.string.error_news_search))
            }
            is NewsViewModel.Event.Success -> {
                adapter.addNewsItems(event.result)
            }
        }
    }

8) NewsAdapter.kt

class NewsAdapter(
    private val listener : (String) -> Unit
) : RecyclerView.Adapter<NewsAdapter.NewsViewHolder>() {

    private val list = mutableListOf<NewsContents>()

    inner class NewsViewHolder(private val binding : ItemNewsBinding) : RecyclerView.ViewHolder(binding.root) {
        fun bind(news : NewsContents) = with(binding) {
            newsContents = news
            if (adapterPosition % 2 == 0) {
                root.setBackgroundResource(R.color.black)
            } else {
                root.setBackgroundResource(R.color.light_black)
            }
            root.setOnClickListener {
                listener(news.url)
            }
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NewsViewHolder =
        NewsViewHolder(ItemNewsBinding.inflate(LayoutInflater.from(parent.context), parent, false))

    override fun onBindViewHolder(holder: NewsViewHolder, position: Int) {
        holder.bind(list[position])
    }

    override fun getItemCount(): Int = list.size

    fun addNewsItems(list: List<NewsContents>) {
        list.forEach {
            this.list.add(it)
            notifyItemChanged(list.lastIndex)
        }
    }

}

9) activity_news.xml

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <data>

        <variable
            name="activity"
            type="com.ezen.lolketing.view.main.news.NewsActivity" />

        <variable
            name="title"
            type="String" />

    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@color/black"
        tools:context=".view.main.news.NewsActivity">

        <include
            android:id="@+id/layoutTop"
            layout="@layout/layout_top"
            app:isBackVisible="@{true}"
            app:title="@{title}" />


        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/recyclerView"
            android:layout_width="0dp"
            android:layout_height="0dp"
            app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/layoutTop"
            tools:listitem="@layout/item_news" />

    </androidx.constraintlayout.widget.ConstraintLayout>


</layout>

10) item_news

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <data>
        <variable
            name="newsContents"
            type="com.ezen.lolketing.model.NewsContents" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:id="@+id/layout"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        tools:background="@color/black">

        <TextView
            android:id="@+id/txtTitle"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginHorizontal="20dp"
            android:layout_marginTop="8dp"
            android:ellipsize="end"
            android:maxLines="2"
            android:textColor="@color/sub_color"
            android:textSize="18sp"
            android:textStyle="bold"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:html="@{newsContents.title}"
            tools:text="기사 제목" />

        <TextView
            android:id="@+id/txtDescription"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginTop="6dp"
            android:layout_marginBottom="8dp"
            android:ellipsize="end"
            android:maxLines="2"
            app:html="@{newsContents.description}"
            android:textColor="@color/white"
            android:textSize="14sp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="@id/txtTitle"
            app:layout_constraintStart_toStartOf="@id/txtTitle"
            app:layout_constraintTop_toBottomOf="@id/txtTitle"
            tools:text="뉴스 정보" />

    </androidx.constraintlayout.widget.ConstraintLayout>

</layout>

 

728x90
댓글