Android 네이버 검색 API 연동 방법 및 샘플 코드

개요

네이버 검색 API를 적용하기 전에 애플리케이션 등록(API 이용신청)이 필요합니다.

API신청주소 이 사이트에 접속하여 API 등록 신청

네이버 검색API 가이드 이 사이트에 접속해서 검색 API 개발 가이드 확인 가능합니다.

네이버 블로그 검색을 한 결과 목록을 RecyclerView 로 보여주는 샘플 코드입니다.


1. gradle 설정 추가

사용 라이브러리

1-1. libs.version.toml 추가

// libs.version.toml 
[versions]
okhttp = "4.11.0"
lifecycleLivedataKtx = "2.8.7"
lifecycleViewmodelKtx = "2.8.7"
coroutines = "1.7.3"
retrofit = "2.9.0"
lifecycle = "2.6.1"

[libraries]
androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycleLivedataKtx" }
androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycleViewmodelKtx" }

coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }

lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" }

retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
retrofit-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" }
okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }

1-2 build.gradle.kts 추가

// liveData
implementation(libs.androidx.lifecycle.livedata.ktx)
implementation(libs.androidx.lifecycle.viewmodel.ktx)

// Retrofit
implementation(libs.retrofit)
implementation(libs.retrofit.gson)

// Coroutines
implementation(libs.coroutines.android)
implementation(libs.coroutines.core)

// Lifecycle
implementation(libs.lifecycle.viewmodel)
implementation(libs.lifecycle.runtime)

// HttpLoggingInterceptor 추가
implementation(libs.okhttp.logging.interceptor)

2. Android Manifest 권한 추가

// Android Manifest 선언
<!-- 인터넷 권한 추가 -->
<uses-permission android:name="android.permission.INTERNET" />

3. 레이아웃 소스 코드

3-1 activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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"
                                                   android:id="@+id/main"
                                                   android:layout_width="match_parent"
                                                   android:layout_height="match_parent"
                                                   tools:context=".MainActivity">

    <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/list"
            android:layout_width="0dp"
            android:layout_height="0dp"
            android:layout_margin="4dp"
            app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

3-2. item_blog.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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"
                                                   android:layout_width="match_parent"
                                                   android:layout_height="100dp"
                                                   android:orientation="horizontal"
                                                   android:padding="8dp">

    <!-- 검색 키워드 -->
    <TextView
            android:id="@+id/textViewTitle"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginHorizontal="8dp"
            android:ellipsize="end"
            android:maxLines="1"
            android:textSize="16sp"
            android:textStyle="bold"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            tools:text="블로그 제목" />

    <!-- 검색 날짜 -->
    <TextView
            android:id="@+id/textViewDescription"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginHorizontal="8dp"
            android:layout_marginTop="2dp"
            android:ellipsize="end"
            android:maxLines="2"
            android:textSize="14sp"
            app:layout_constraintBottom_toTopOf="@id/textViewBloggerName"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/textViewTitle"
            tools:text="블로그 설명" />


    <TextView
            android:id="@+id/textViewBloggerName"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:textSize="14sp"
            app:layout_constraintStart_toStartOf="@id/textViewTitle"
            app:layout_constraintTop_toBottomOf="@id/textViewDescription"
            tools:text="뚜벅뚜벅이" />

    <TextView
            android:id="@+id/textViewDot"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginStart="2dp"
            android:text="ㆍ"
            android:textSize="14sp"
            app:layout_constraintStart_toEndOf="@id/textViewBloggerName"
            app:layout_constraintTop_toBottomOf="@id/textViewDescription" />

    <TextView
            android:id="@+id/textViewPostDate"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginStart="2dp"
            android:textSize="14sp"
            app:layout_constraintStart_toEndOf="@id/textViewDot"
            app:layout_constraintTop_toBottomOf="@id/textViewDescription"
            tools:text="yyyy-mm-dd" />

</androidx.constraintlayout.widget.ConstraintLayout>

4. 네트워크 관련 소스 코드

4.1 data class

import com.google.gson.annotations.SerializedName

data class BlogSearchResponse(
    val lastBuildDate: String,
    val total: Int,
    val start: Int,
    val display: Int,
    val items: List<BlogItem>,
)

data class BlogItem(
    val title: String,
    val link: String,
    val description: String,
    @SerializedName("bloggername")
    val bloggerName: String,
    @SerializedName("bloggerlink")
    val bloggerLink: String,
    val postdate: String,
    var thumbnail: String = ""
)

4.2 RetrofitService.kt

import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Query

interface RetrofitService {

    @GET("v1/search/blog.json")
    suspend fun searchBlogs(
        @Header("X-Naver-Client-Id") clientId: String = "애플리케이션 등록 시 발급받은 클라이언트 아이디 값",
        @Header("X-Naver-Client-Secret") clientSecret: String = "애플리케이션 등록 시 발급받은 클라이언트 시크릿 값",
        @Query("query") query: String,      //UTF-8 Encoding
        @Query("display") display: Int = 10,//한번에 표시할 검색 결과 수 기본값 10 최댓값 100
        @Query("start") start: Int = 1,     //검색 시작 위치 기본값 1 최댓값 1000
        @Query("sort") sort: String = "sim" //sim:정확도순 내림차순 default, date: 날짜순 내림차순
    ): BlogSearchResponse
}

4.3 RemoteRepository.kt

import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow

class RemoteRepository(private val retrofitService: RetrofitService) {

    fun searchBlogs(query: String, display: Int = 10, start: Int = 1, sort: String = "sim"): Flow<BlogSearchResponse> = flow {
        val response = retrofitService.searchBlogs(query = query, display = display, start = start, sort = sort)
        emit(response) // API 응답 데이터를 방출
    }
}

5. UI 소스 코드

5.1 MainViewModel.kt

import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch

class MainViewModel(private val repository: RemoteRepository) : ViewModel() {

    private val _blogs = MutableStateFlow<List<BlogItem>>(emptyList())
    val blogs: StateFlow<List<BlogItem>> = _blogs

    fun searchBlogs(query: String, display: Int = 10, start: Int = 1, sort: String = "sim") {
        viewModelScope.launch {
            repository.searchBlogs(query, display, start, sort).collect { response ->
                _blogs.value = response.items // BlogItem 리스트 업데이트
            }
        }
    }
}

class BlogViewModelFactory(private val repository: RemoteRepository) : ViewModelProvider.Factory {
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(MainViewModel::class.java)) {
            @Suppress("UNCHECKED_CAST")
            return MainViewModel(repository) as T
        }
        throw IllegalArgumentException("Unknown ViewModel class")
    }
}

5.2 MainActivity.kt

import android.os.Bundle
import android.util.Log
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.js.sample.databinding.ActivityMainBinding
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding

    private val viewModel: MainViewModel by viewModels {
        BlogViewModelFactory(RemoteRepository(RetrofitClient.service))
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        val adapter = BlogListAdapter {
            Log.e("TAG", "clicked $it")
        }
        binding.list.adapter = adapter

        // ViewModel의 데이터 관찰
        lifecycleScope.launch {
            viewModel.blogs.collectLatest { blogs ->
                if (blogs.isNotEmpty()) {
                    adapter.submitList(blogs)
                }
            }
        }

        // 검색 실행
        viewModel.searchBlogs("강남역 맛집")
    }

}

5.3 BlogListAdapter.kt

import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.js.sample.databinding.ItemBlogBinding
import java.text.SimpleDateFormat
import java.util.Locale

class BlogListAdapter(
    private val onSelect: (BlogItem) -> Unit,
) : ListAdapter<BlogItem, BlogListAdapter.BlogViewHolder>(BlogDiffCallback()) {

    inner class BlogViewHolder(private val binding: ItemBlogBinding) :
        RecyclerView.ViewHolder(binding.root) {

        fun bind(item: BlogItem) {
            with(binding) {
                root.setOnClickListener { onSelect(item) }

                textViewTitle.text = item.title
                textViewDescription.text = item.description
                textViewBloggerName.text = item.bloggerName
                textViewPostDate.text = formatDate(item.postdate)
            }

        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BlogViewHolder {
        val binding = ItemBlogBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        return BlogViewHolder(binding)
    }

    override fun onBindViewHolder(holder: BlogViewHolder, position: Int) {
        val item = getItem(position)
        holder.bind(item)
    }

    class BlogDiffCallback : DiffUtil.ItemCallback<BlogItem>() {
        override fun areItemsTheSame(oldItem: BlogItem, newItem: BlogItem): Boolean {
            // 각 항목의 고유성을 비교 (예: 키워드)
            return oldItem.title == newItem.title
        }

        override fun areContentsTheSame(oldItem: BlogItem, newItem: BlogItem): Boolean {
            // 전체 내용 비교 (예: 키워드와 날짜)
            return oldItem == newItem
        }
    }

    fun formatDate(input: String): String {
        return try {
            val inputFormat = SimpleDateFormat("yyyyMMdd", Locale.getDefault())
            val outputFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
            val date = inputFormat.parse(input) ?: ""
            outputFormat.format(date)
        } catch (e: Exception) {
            input
        }
    }
}


Related Posts