본문 바로가기
Android/Project

인터파크 도서 API를 활용한 도서 (검색 && 리뷰) 앱 제작 과정

by KwakEuiJin 2022. 3. 30.

인터파크 Open API를 활용하여 베스트셀러, 도서 검색, 해당 도서 리뷰 작성 기능을 구현한 앱입니다.

  • 참조링크
 

GitHub - KwakEuiJin/InterParkBookService

Contribute to KwakEuiJin/InterParkBookService development by creating an account on GitHub.

github.com


1. 앱 설명

1.1 사용 기술

  • Retrofit,Gson-Converter (Retrofit을 사용하여 API에서 받아온 json 파일을 Gson Converter를 사용하여 파싱)
  • Room-DB (SQLite보다 쉽게 활용 가능한 앱 자체 데이터 베이스)
  • Glide (이미지를 효율적으로 Load시켜주는 라이브러리)
  • 인터파크 Open API
  • View Binding

1.2 주요 기능

  • 인터파크 Open API를 통해 베스트 셀러 정보를 받아와 RecyclerView에 업데이트 할 수 있다.
  • 인터파크 Open API를 통해 검색 키워드에 해당하는 도서정보를 불러올 수 있다.
  • Room Database를 활용하여 검색어 기록을 저장하고 View에 업데이트 할 수 있다.
  • Room Database를 활용하여 각 도서마다 개인 리뷰를 저장하는 기능을 사용 할 수 있다.

1.3 구현 화면

 

2. 앱 개발 과정

2.1 인터파크 Open API 신청하기

먼저 API에 접근하기 위해서는 Access Key가 필요합니다. 인터파크 도서에 로그인 후 북피니언의 관리 사이트에서 해당 API key를 발급받는 과정을 거칩니다.

API를 활용하기 위해서는 해당 가이드 문서나 지침을 필수적으로 참고해야 합니다.
 1) 여기서 저는 책검색, 베스트셀러 API만 이용했습니다.

 2)해당 참고문서에서는 요청 URL(Base URL)과 요청변수, 출력결과 등이 어떤 형식으로 나오는지 안내하고 있습니다.

 

2.2 Open API 참고문서를 토대로 Retrofit 라이브러리 사용해보기

먼저 요청한 API에 대한 결과 값을 Json 파일로 보기 위해서 크롬의 어플인 Post Man을 사용합니다. 
-요청 URL에 요청변수의 조합과 결과 값을 파악는데 용이한 프로그램입니다.

 

 

->Retrofit에 대한 세부적인 정보, 최신 버전은 공식 문서를 확인하시면 됩니다.

 

Retrofit

A type-safe HTTP client for Android and Java

square.github.io

 

또한 API 정보를 받아오기 위해 Manifest 파일 내에 인터넷 사용 권한을 추가해줍니다.

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

 

저는 총 2가지의 API를 요청할 것이기 때문에 Interface내에 2가지 Http 메소드와 요청변수를 통해 정의해 줍니다.

interface BookService {
	//키워드에 해당하는 도서 정보
    @GET("/api/search.api?output=json")
    fun getBooksByName(
        @Query("key") apiKey:String,
        @Query("query") keyword:String
        ): Call<SearchBookDto>
        

	//베스트셀러에 해당하는 도서 정보
    @GET("api/bestSeller.api?output=json&categoryId=100")
    fun getBestSellerBooks(
        @Query("key") apiKey:String
    ): Call<BestSellerDto>


    }

 

 

각 API 데이터를 받아올 데이터 객체를 data class를 통해 생성해줍니다.

 

  -> Post Man에서 확인한 Json 파일 형식이 item 내에 Lsit형식으로 도서정보가 담겨있었기 때문에 해당 구조로 data class를 생성하였습니다.


  -> @SerializedName은 Gson의 annotation으로 객체와 Json의 응답 변수와의 직렬화를 위하여 사용합니다. 즉 API호출시 Json 파일의 변수 이름은 item이지만 안드로이드 내에서 books:List<Book>의 객체 형태로 받을 수 있습니다.


  -> @Parcelize는 객체를 직렬화 하여  Intent에 전송하기 위해 사용된 annotation입니다. 

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,
    @SerializedName("title") val title: String,
    @SerializedName("description") val description: String,
    @SerializedName("coverSmallUrl") val coverSmallUrl: String,
    @SerializedName("coverLargeUrl") val coverLargeUrl: String

) : Parcelable

 

Service와 Data 객체를 생성 후 Main Activity 내에 Retrofit을 생성하여 베스트셀러 도서 정보를 받아옵니다.


->해당 로직은 베스트셀러 도서정보를 호출하는 코드입니다.
->call.enqueue()를 통해 인터페이스로부터 함수를 호출 후 콜백을 파라미터로 넣어 통신의 성공과 실패를 핸들링 할 수 있습니다.

//MainActivity... onCreate내부에 구현

val retrofit = Retrofit.Builder()
    .baseUrl("https://book.interpark.com")
    .addConverterFactory(GsonConverterFactory.create())
    .build()
    
bookService = retrofit.create(BookService::class.java)
bookService.getBestSellerBooks(getString(R.string.interParkAPIKey))
            .enqueue(object : Callback<BestSellerDto> {
                override fun onResponse(
                    call: Call<BestSellerDto>,
                    response: Response<BestSellerDto>
                ) {
                    if (response.isSuccessful.not()) {
                        return
                    }
                    //검색이 성공했을 때
                    response.body()?.let {
                        Log.d(TAG, it.toString())
                        bookAdapter.submitList(it.books)
                    }
                }

                override fun onFailure(call: Call<BestSellerDto>, t: Throwable) {
                    Log.d(TAG, t.toString())
                }
            })



2.3 RecyclerView에 도서 정보 업데이트 하기

View Binding


레이아웃의 객체에 접근하기 위한 방법으로 FindViewbyId를 대체하여 사용할 수 있는 기능이며    build.gradle(Module)에 해당 코드를 추가하여 사용 가능합니다

build.gradle(Module)
android {
    viewBinding{
        enabled = true
    }
}

 

 

RecyclerView의 bookAdapter


1)ListAdapter의 상속을 받아 diffUtil을 사용하여 리스트뷰의 재활용성을 더욱 높일 수 있으며 adapter에 submitList()메소드를 사용하여 쉽게 데이터를 연결시킬 수 있습니다.
 ->상세한 정보는 추후 포스팅하여 링크로 삽입하겠습니다.

2)Click Listener를 Adapter의 인자로 받도록 구성하여 쉽게 클릭 이벤트를 구현할 수 있습니다.
3)Gilde를 활용하여 Url을 통해 이미지를 쉽게 업로드 하였습니다.

class BookAdapter(private val itemClickedListener: (Book) -> (Unit)) :
    ListAdapter<Book, BookAdapter.BookItemViewHolder>(diffUtil) {
    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)
            }

            Glide.with(binding.coverImageView.context)
                .load(bookModel.coverSmallUrl)
                .into(binding.coverImageView)
        }
    }

    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
            }

        }
    }
}

 

RecyclerView의 item_book.xml


1) maxLine과 ellipsize를 활용하여 View상 한 줄이 넘어가는 도서의 이름을 효율적으로 처리했습니다.
 ->ellipsize에 end를 주게 되면 일정 길이가 넘어가는 글자를 "..."으로 처리합니다

<?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="wrap_content"
    android:padding="16dp">

    <ImageView
        android:id="@+id/coverImageView"
        android:layout_width="100dp"
        android:layout_height="100dp"
        android:background="@drawable/background_gray_stroke_raidus16"
        android:padding="8dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/titleTextView"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginStart="12dp"
        android:ellipsize="end"
        android:maxLines="1"
        android:textColor="@color/black"
        android:textSize="16sp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toEndOf="@+id/coverImageView"
        app:layout_constraintTop_toTopOf="parent"
        tools:text="인드로이드 마스터하기" />

    <TextView
        android:id="@+id/descriptionTextView"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:layout_marginTop="12dp"
        android:ellipsize="end"
        android:maxLines="3"
        android:textSize="12sp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="@id/titleTextView"
        app:layout_constraintTop_toBottomOf="@id/titleTextView" />


</androidx.constraintlayout.widget.ConstraintLayout>

 


2.4 도서 검색기능 구현하기

 

2.2에서 베스트셀러 도서 정보를 받아오는 코드와 동일한 구조로 이루어집니다.

private fun search(keyword: String) {
    bookService.getBooksByName(getString(R.string.interParkAPIKey), keyword)
        .enqueue(object : Callback<SearchBookDto> {
            override fun onResponse(
                call: Call<SearchBookDto>,
                response: Response<SearchBookDto>
            ) {
                hideHistoryRecyclerView() // 검색 기록창 숨기기
                saveSearchKeyword(keyword) // db에 검색기록을 저장하는 코드
                
                if (response.isSuccessful.not()) {
                    return
                }
                bookAdapter.submitList(response.body()?.books.orEmpty())
            }

            override fun onFailure(call: Call<SearchBookDto>, t: Throwable) {
                hideHistoryRecyclerView()
            }
        })
}

 

2.5 상세정보 페이지 구성하기

 1)상세정보 페이지는 Detail Activity를 새로 구성하여 개발하였습니다.

 2)RecyclerView에서 각 도서를 클릭시 해당하는 도서 정보 Model 객체를 Detail Activity로 Intent하였습니     다.

class MainActivity...

bookAdapter = BookAdapter(itemClickedListener = {
    val intent = Intent(this, DetailActivity::class.java)
    intent.putExtra("bookModel", it)
    startActivity(intent)
})
class DetailActivity : AppCompatActivity() {
    private lateinit var binding: ActivityDetailBinding

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



        val model =intent.getParcelableExtra<Book>("bookModel")
        binding.titleTextView.text = model?.title.orEmpty()
        binding.descriptionTextView.text=model?.description.orEmpty()
        Glide.with(binding.coverImageView.context)
            .load(model?.coverLargeUrl.orEmpty())
            .into(binding.coverImageView)
    }
}

 

2.6 Android Local Room Database 활용

개인 리뷰와 검색어 기록을 저장하기 위해 앱 내장 데이터베이스인 Room DB를 사용했습니다.

 

  • 먼저 의존성을 추가해줘야 합니다.
build.gradle(Module)

plugins {
    id 'kotlin-kapt'
    id 'kotlin-parcelize' //retrofit 활용시 생성했던 Book data class에서 활용
}
dependencies {
	implementation 'androidx.room:room-runtime:2.4.2'
	kapt 'androidx.room:room-compiler:2.4.2'

}

 

 

!!Room DB를 활용하기 위해서는 총 3가지가 필요합니다!!
 1)RoomDatabase(DB를 보유하고 DB와의 연결을 위한 기본 엑세스 포인트 역할)
 2)Dao(interface로 직접적인 insert, delete 함수를 담당하는 기능)
 3)Entity(Model, data class, 데이터 규격?, 형식 = 즉 데이터베이스의 테이블을 나타냄)

 

 

RoomDatabase

1) 추상클래스로 정의하며 실제 구현시 databaseBuilder라는 메소드를 사용하여 구현합니다.

 -> 공식 문서에서는 싱글톤을 활용하여 Room DB를 빌드하는 것을 권장하지만 초기 프로젝트임을 감안하여 onCreate내에 직접 객체를 생성하겠습니다.

2)  RoomDatabase 클래스를 상속받고, @Database 어노테이션을 사용하여 Model클래스를 정의하고 version을 표시합니다.

3) Dao를 연결하여 DB에 접근이 가능하도록 구성합니다.

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


Dao(데이터 액세스 객체)

DB와 상호작용하는 실질적인 메소드를 구현하는 interface입니다.

 1)추상클래스와 interface간의 관계에 대해서 더욱 공부한 후 링크를 삽입하겠습니다.

@Dao
interface HistoryDao {
    @Query("Select * from History")
    fun getAll():List<History>

    @Insert
    fun insertHistory(history: History)

    @Query("Delete from history where keyword == :keyword")
    fun delete(keyword: String)
}

@Dao
interface ReviewDao {
    @Query("Select * From Review where id == :id")
    fun getOneReview(id: Int) : Review?

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun saveReview(review: Review)

}

 

Entity

Entity또한 annotation을 통해 사용 가능하며, SQL 문법과 동일하게 기본키, Column을 지정할 수 있습니다.

 -> History는 검색어 기록 Model로 uid와 검색어인 keyword로 이루어져있습니다.

 ->Review는 개인 리뷰 Model로 id와 리뷰인 review로 이루어져있습니다.

@Entity
data class History(
    @PrimaryKey val uid: Int?,
    @ColumnInfo(name="keyword") val keyword:String?
)

@Entity
data class Review(
    @PrimaryKey val id:Int?,
    @ColumnInfo(name="review") val review: String?
)

 

MainActivity

 1)databaseBuilder 내에 context, Appdatabase, name을 넣어 Room DB 객체를 생성할 수 있습니다.

 2)saveSearchKeyword를 통해 Database에 keyword를 삽입할 수 있습니다.

 3)deleteSearchKeyword를 통해  Database에서 keyword를 삭제할 수 있습니다.

class MainActivity : AppCompatActivity() {

	private val db : AppDatabase

	 override fun onCreate(savedInstanceState: Bundle?) {
        	super.onCreate(savedInstanceState)
        	binding = ActivityMainBinding.inflate(layoutInflater)
            
            	val db = Room.databaseBuilder(
            	applicationContext,
            	AppDatabase::class.java, "database-name"
        		).build()
	}
    
     private fun saveSearchKeyword(keyword: String) {
        Thread {
            db.historyDao().insertHistory(History(null, keyword))
        }.start()
    }

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

    }
}

 

*추가적인 코드는 깃허브에 올라와 있으니 참고하시면 좋을 것 같습니다.

*처음 작성하는 개발블로그여서 많이 부족한 설명이고 다소 가독성이 떨어지는 것 같습니다. 더욱 노력해서 읽기, 알아듣기 쉬운 포스팅을 해보도록 노력하겠습니다. 감사합니다!!

댓글