티스토리 뷰

1. 안드로이드 인터파크 Open API 로 도서 리뷰 어플 만들기

인터파크에서 제공하고 있는 Open API 를 사용해서 안드로이드 앱에 적용하고 이를 통해 베스트 셀러 불러오기, 검색어로 특정도서를 검색, 책 별로 리뷰할 수 있는 어플리케이션을 개발해보았습니다. 주요 코드들에 대해서 남겨두기 위해(+복습) 글 작성합니다.

주요 기능

  • Open API로 베스트 셀러 목록을 받아 리스트로 보여준다
  • 사용자가 입력한 키워드로 도서를 검색하여 리스트로 보여준다
  • 사용자가 검색한 키워드 히스토리를 저장할 수 있다.
  • 사용자가 검색한 키워드 히스토리를 삭제할 수 있다.
  • 사용자가 검색한 키워드 히스토리를 클릭하여 재검색 할 수 있다.
  • 도서 마다 사용자 리뷰를 작성할 수 있고 영구적으로 저장할 수 있다.

사용 기술

  • RecyclerView
  • View Binding
  • Retrofit (API 도구)
  • Glide (이미지 쉽게 로드하기 위해)
  • Android Room (Local Database)
    • 마이그레이션
  • Open API (인터파크 book)

결과 화면

 

1.1. 인터파크 오픈 API 사용하기

Open API 란 말그대로 열려있는 API로 누구나 사용할 수 있게 공개 해놓은 API 입니다. 예를 들어 공공기관에서 제공하는 날씨 API를 사용하여 날씨정보를 받아올 수 있고 인터파크 Book open API를 사용하면 도서 정보를 받아올 수 있습니다.

이번 프로젝트에서는 인터파크 API 를 활용하여 도서 정보와 베스트 셀러 정보를 받아오기로 했습니다. 회원가입이 필요하며 로그인후 바로 인증키를 받아서 무료로 사용할 수 있습니다.

이후 사용 방법은 홈페이지에 자세히 나와있는데 간단하게 보자면 요청 URL을 바탕으로 인증키와 함께 요구하게 되고 책 검색의 경우 검ㅁ색할 키워드를 같이 보내어 요청하게 됩니다. 이후 응답이 오게되면 안드로이드 앱에서 사용하도록 구현하면 되겠습니다.

1.2. Postman

간단하게 API 가 동작하는 지 우선 확인해보고 싶다면 포스트맨을 사용해서 결과를 받아오는지 확인해봐도 좋습니다. 아래와 같이 키와 검색어를 주어 입력하면 아래 Body로 결과를 받아오는 것을 확인할 수 있습니다. 이때 output 키로 json 을 주었습니다.

json 으로 받아오는 이유는 안드로이드에서 API 를 보다 쉽게 사용할 수 있게 해주는 Retrofit 을 사용할 건데 여기에 gson 이라는 변환기가 json 을 변환하여 개발자가 사용할 수 있는 객체 형태로 변환해주기 때문에 json 형식으로 받아오도록 하겠습니다.

2. 기본 레이아웃 구성

레이아웃은 상단에는 검색창 그 아래에 도서 정보가 리사이클러 뷰로 표시되도록 하겠습니다. 리사이클러 뷰 같은 경우에는 이전에 오늘의 명언 어플을 만들 때 사용해 보았으니 자세한 설명은 생략하고 넘어가도록 하겠습니다. 아주 간단히만 설명하자면 말 그대로 뷰를 재활용하여 위아래 등으로 스크롤 할 때 데이터만 변경해서 보여줄 수 있도록 효율성을 좀더 높인 컴포넌트라고 생각하시면 되겠습니다.

2.1. 레트로핏(retrofit) 사용 방법

retrofit 을 사용해서 API를 호출해보겠습니다. 먼저 gradle 에 의존성을 추가해서 라이브러리를 사용할 수 있도록 한뒤 싱크해줍니다.

    implementation 'com.squareup.retrofit2:retrofit:2.9.0'
    implementation 'com.squareup.retrofit2:converter-gson:2.9.0'

인터넷 권한을 얻기 위해 메니페스트에 아래와 같은 권한사항을 추가해줍니다.

<uses-permission android:name="android.permission.INTERNET"/>

get 으로 api를 호출하기 위한 우리의 인터페이스를 정의합니다.

interface BookService {
    // get : 데이터 요청 시 반환 http
    // post : http body에 넣어 전달

    // 책 검색.
    @GET("/api/search.api?output=json")
    fun getBooksByName(
        @Query("key") apiKey: String,
        @Query("query") kweyWord: String
    ): Call<SearchBookDto>

    // best seller 받아오기
    @GET("/api/bestSeller.api?output=json&categoryId=100")
    fun getBestSellerBooks(
        @Query("key") apiKey: String,
    ): Call<BestSellerDto>

}

BookService 인터페이스를 만들고 안에 베스트 셀러를 받아오는 부분 과 키워드로 책을 검색하는 부분을 작성합니다. 베이스 url을 book.interpark 닷컴 으로 만들어줄 것이기 때문에 이후 주소를 각각 에 맞게 입력해주고 json으로 받아오기 위해 위와 같이 작성합니다.

@Query 에 들어가는 스트링은 실제 우리가 받아온 값의 네임이 되는데 이는 공식 홈페이지를 참고하시면 되겠습니다.

데이터 전송 객체(data transfer object, DTO) 작성

data class BestSellerDto (
    @SerializedName("title") val title: String,
    @SerializedName("item") val books: List<Book>,

)

data class SearchBookDto(
    @SerializedName("title") val title: String,
    @SerializedName("item") val books: List<Book>,
)

@Parcelize // 직렬화 가능하도록 수정 (인텐트로 넘겨주기 위해서)
data class Book(
    @SerializedName("itemId") val id: Long = 0,
    @SerializedName("title") val title: String = "",
    @SerializedName("description") val description: String = "",
    @SerializedName("coverSmallUrl") val coverSmallUrl: String = "",
    @SerializedName("coverLargeUrl") val coverLargeUrl: String = ""
): Parcelable

데이터를 받아올 객체인 DTO 를 작성합니다. 각 자료형 또한 공식 페이지를 참조하여 작성하시면 되겠습니다. 이때 아이템으로 북 리스트가 전송되오는데 이를 마찬가지로 받기위해 book 모델을 정의 해주었습니다.

커버 이미지 url 을 받아와 북 커버를 이미지로 이후에 보여주도록 할 것이며, 북 모델 자체를 인텐트로 넘겨야하는 경우도 있기 때문에 파셜라이즈 가능하도록 해주었습니다.

2.2. API 호출 하기

위에서 정의해준 북서비스를 레트로핏으로 빌드하기 위해 아래와 같은 코드를 작성합니다.

    private fun initBookService() {
        val retrofit = Retrofit.Builder()
            .baseUrl("https://book.interpark.com/") // 인터파크 베이스 주소;
            .addConverterFactory(GsonConverterFactory.create()) // Gson 변환기 사용;
            .build()

        bookService = retrofit.create(BookService::class.java)
    }

addConverterFactory(GsonConverterFactory.create()) 도 추가해서 gson 변환을 사용할 수 있게 하여 북서비스 하나를 생성해주었습니다.

    private fun bookServiceLoadBestSellers() {
        // 베스트 셀러 가져오기;
        bookService.getBestSellerBooks(getString(R.string.interparkAPIKey))
            .enqueue(object : Callback<BestSellerDto> {
                // 응답이 온 경우;
                override fun onResponse(
                    call: Call<BestSellerDto>,
                    response: Response<BestSellerDto>
                ) {
                    // 받은 응답이 성공한 응답일 때;
                    if (response.isSuccessful.not()) {
                        Log.e(M_TAG, "NOT!! SUCCESS")
                        return
                    }

                    // 받은 응답의 바디가 채워져 있는 경우만 진행;
                    response.body()?.let {
                        Log.d(M_TAG, it.toString())

                        it.books.forEach { book ->
                            Log.d(M_TAG, book.toString())
                        }

                        // 새 리스트로 갱신;
                        bookRecyclerViewAdapter.submitList(it.books)
                    }
                }

                // 응답에 실패한 경우
                override fun onFailure(call: Call<BestSellerDto>, t: Throwable) {
                    Log.e(M_TAG, t.toString())
                }
            })
    }

이후 bookService 를 사용하여 API 를 호출하고 응답이 성공하고 바디가 채워져 있는 경우에는 리사이클러 뷰에 뿌려줘서 베스트 셀러 데이터가 쫙 보여지도록 코드를 작성합니다.

API key 의 경우 민감 데이터로 외부에 노출을 하지 않는 것이 보안상 좋기 때문에 블로그나 깃허브에서 노출을 피하도록 했습니다. (또는 키 삭제) 위 코드와 같은 경우에는 따로 스트링 자원으로 빼서 중복을 피하고자 했습니다.

로그를 심어서 리사이클러 구현 전에 미리 데이터를 확인해볼 수도 있으니 이또한 참고하시면 좋겠습니다.

3. 뷰 바인딩(ViewBinding)

뷰 바인딩을 통해 좀더 편하게 레이아웃 속성에 접근할 수 있습니다. 사용하기 위해 아래와 같은 코드를 앱수준 gradle에 추가합니다. (이런 속성 같은 경우에는 이후 최신 업데이트 버전에서 변경될 수 있으니 그때 마다 최신의 권장 사항을 따르도록 하는게 좋겠죠.)

    viewBinding{
        enabled = true
    }

이를 책을 표시하는 리사이클러뷰(책 커버 이미지, 책 이름, 소개글 표시)의 어뎁터에서 사용해보는 예시를 싣도록 하겠습니다. 먼저 item_book.xml 레이아웃을 추가하고 리사이클러 뷰 아이템으로 사용될 레이아웃을 작성하세요.

그리고 아래 어뎁터에서 해당 레이아웃에 해당하는 바인딩을 사용해서 코드를 작성하면 끝입니다.

class BookAdapter(private val itemClickedListener: (Book)->Unit) : ListAdapter<Book, BookAdapter.BookItemViewHolder>(diffUtil) {

    // 뷰 바인딩 (item_book.xml)
    inner class BookItemViewHolder(private val binding: ItemBookBinding): RecyclerView.ViewHolder(binding.root){

        fun bind(bookModel: Book){
            binding.titleTextView.text = bookModel.title
            binding.descriptionTextView.text = bookModel.description

            binding.root.setOnClickListener {
                itemClickedListener(bookModel)
            }

            
        }
    }

    // 미리 만들어진 뷰 홀더가 없을 경우 생성하는 함수.
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BookItemViewHolder {
        return BookItemViewHolder(ItemBookBinding.inflate(LayoutInflater.from(parent.context),parent,false))
    }

    // 실제 뷰 홀더가 뷰에 그려지게 됬을 때 데이터를 바인드하게 되는 함수.
    override fun onBindViewHolder(holder: BookItemViewHolder, position: Int) {
        holder.bind(currentList[position])
    }

    companion object{
        // 같은 값이 있으면 할당 해줄 필요 없다
        val diffUtil = object: DiffUtil.ItemCallback<Book>(){
            override fun areItemsTheSame(oldItem: Book, newItem: Book): Boolean {
                return oldItem == newItem
            }

            override fun areContentsTheSame(oldItem: Book, newItem: Book): Boolean {
                return oldItem.id == newItem.id
            }

        }
    }
}

BookItemViewHolder 에서 해당 바인딩을 받아 사용하고 binding 이라는 이름으로 레이아웃 속성에 간단하게 접근해서 텍스트뷰의 텍스트를 변경하거나 클릭리스너를 달아주는 모습을 확인할 수 있습니다.

이 어뎁터의 경우 onCreateViewHolder 에서 뷰홀더를 생성할 당시 바인딩을 사용하여 인플레이트(자원을 객체화) 시켜주고 있습니다.

3.1. Glide 로 이미지 쉽게 불러오기

이미지의 경우 ImageView 에 표시할 수 있는데 이때 소스로 자원에 있는 이미지 경로를 넣어주거나 하지만 현재 받아오는 것은 이미지의 Url 이기 때문에 이를 쉽게 받아오기 위해 Glide 를 사용할 수 있습니다.

implementation 'com.github.bumptech.glide:glide:4.12.0'
			// Glide 사용 하기
            Glide
                .with(binding.coverImageView.context)
                .load(bookModel.coverSmallUrl)
                .into(binding.coverImageView)
                
            //...    
                
            Glide.with(binding.coverImageView.context)
            .load(model?.coverLargeUrl.orEmpty())
            .into(binding.coverImageView)

위 사용 예시에서는 with 구문을 사용하여 url을 로드해 오고 이를 into로 어느 이미지 뷰에 뿌려줄지 정하는데 여기서 위에 활용한 바인딩을 사용하여 커버 이미지뷰에 뿌려주도록 했습니다. (코드의 일부분을 가져온 것 입니다.)

4. Android Room 로컬 데이터 베이스 사용

도서 리뷰와 검색어 히스토리를 저장하기 위해서 안드로이드 룸을 사용해 보도록 하겠습니다. app 수준 gradle 에 아래와 같이 추가해주었습니다. m1 맥북 에어의 경우 sqlite-jdbc를 추가하지 않으면 예상치 못한 에러가 계속 발생하여 이또한 추가해주었습니다.

plugins {
    id 'com.android.application'
    id 'kotlin-android'
    id 'kotlin-kapt'
    id 'kotlin-parcelize'
}

...

kapt 'androidx.room:room-compiler:2.3.0'
implementation 'androidx.room:room-runtime:2.3.0'
kapt "com.android.databinding:compiler:3.1.4"

kapt "org.xerial:sqlite-jdbc:3.34.0"

검색 히스토리 DAO

@Dao
interface HistoryDao {
    // 전부 가져오는 는
    @Query("SELECT * FROM history")
    fun getAll(): List<History>

    // 검색 작업이 일어날 때 insert
    @Insert
    fun insertHistory(history: History)

    // x 눌렀을 때 키워드 지워주
    @Query("DELETE FROM history WHERE keyword = :keyword")
    fun delete(keyword: String)
}

검색한 키워드 히스토리를 저장할 데이터 베이스 테이블에서 사용할 Dao 입니다. 검색 히스토리를 전부 가져오는 getAll, 그리고 삽입 삭제하는 쿼리를 정의해주었습니다. 삭제의 경우 일치하는 키워드를 지워주도록 합니다.

사용자 리뷰 DAO

@Dao
interface ReviewDao {

    @Query("SELECT * FROM review WHERE id = :id")
    fun getOneReview(id: Int): Review

    // 같은 값이오면 새로운 거로 대체.
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun saveReview(review: Review)
}

사용자 리뷰 DAO 로 아이디와 일치하는 리뷰를 가져오도록 해서 각각 도서에 맞는 리뷰를 가져오도록 할 것이고, 리뷰 저장 시에는 onConflict 의 replace 전략을 사용하여 새로운 데이터가 오면 덮어 씌워지게 만듭니다.

데이터 베이스 추상 클래스 작성

@Database(entities = [History::class, Review::class], version = 2)
abstract class AppDatabase : RoomDatabase(){
    abstract fun historyDao(): HistoryDao
    abstract fun reviewDao(): ReviewDao

}

이후 룸데이터베이스를 상속한 앱데이터베이스를 만들어주고 사용하도록 하겠습니다. 버전의 경우 최초 1로 지정해주시면 되겠습니다. 이후 데이터베이스가 수정된다면 버전을 올리고 이를 마이그레이션 시켜주는 코드를 작성하시면 됩니다.

4.1. room 마이그레이션 코드 작성

fun getAppDatabase(context: Context): AppDatabase {

    val migration_1_2 = object : Migration(1,2){
        override fun migrate(database: SupportSQLiteDatabase) {

            database.execSQL("CREATE TABLE `REVIEW` ('id' INTEGER, `review` TEXT," + "PRIMARY KEY(`id`))")
        }

    }

    return Room.databaseBuilder(
        context,
        AppDatabase::class.java,
        "BookSearchDB"
    )
        .addMigrations(migration_1_2)
        .build()
}

해당 데이터 베이스를 빌드해서 가져다 쓸 때 getAppDatabase 를 호출해서 사용하도록 하고 이때 migration 작업을 통해 기존 1 데이터 베이스가 존재했을 때 2로 마이그레이션 해주는 코드를 migrate 를 override 해줍니다.

리뷰 데이터 베이스가 이후에 추가되었는데 이를 위해 새로운 테이블을 만들어주는 코드를 SQL 문으로 작성해서 실행해주면 되겠습니다.

메인 엑티비티에서 사용 예시

메인에서 전역으로 db로 앱데이터베이스를 불러와서 사용하는 예시입니다.

검색하는 경우에는 검색한 키워드를 저장하는 함수를 실행하여 검색 히스토리 데이터베이스에 히스토리를 삽입합니다.

    private val db: AppDatabase by lazy {
        getAppDatabase(this)
    }
    
    //...
    
        private fun saveSearchKeyword(keyword: String) {
        Thread {
            db.historyDao().insertHistory(History(null, keyword))
        }.start()
    }
    
    //...

    private fun showHistoryView() {
        Thread {
            val keywords = db.historyDao().getAll().reversed()
            runOnUiThread {
                binding.historyRecyclerView.isVisible = true
                historyAdapter.submitList(keywords.orEmpty())
            }
        }.start()

    }
    
    //...
    
        private fun deleteSearchKeyword(keyword: String) {
        Thread {
            db.historyDao().delete(keyword)
            showHistoryView()
        }.start()
    }

이후 히스토리 뷰가 보여질 때 키워드를 전부 가져옵니다. 이때 최근 검색했던 내용을 보여주기 위해(최근검색기록) reversed() 를 통해 뒤집어주고 리사이클러 뷰를 건들기 위해 UI스레드를 사용하여 보이도록 하고 리스트를 갱신해줍니다.

키워드를 제거하는 경우에는 해당 키워드에 해당하는 키워드로 접근해서 해당 키워드를 제거해주도록 합니다.

보다 자세한 내용이나 코드가 궁금하시다면 저의 깃허브 프로젝트 저장소를 방문하셔서 확인하실 수 있습니다. 도움이 되셨다면 공감 버튼 부탁드리면서 글 마무리 짓겠습니다. 😺

2021.05.03 - [Android/App] - [안드로이드] 비밀 일기장 어플 다이어리 앱 만들기 (Kotlin)

2021.06.03 - [Android/App] - [Android] Push 알림 수신기 앱 만들어보기 with Kotlin (Firebase)

2021.06.07 - [Android/App] - [Android] 오늘의 명언 어플 만들기 (Firebase remote config)

댓글
최근에 올라온 글
최근에 달린 댓글
네이버 이웃추가
«   2024/03   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31
글 보관함