본문 바로가기
💻 뚝딱뚝딱/팀내도서대여시스템(OBRS)

[개발일지#014] 도서 API 적용하기 (Feat. 카카오 도서 API)

by 뚜루리 2025. 2. 20.
728x90
320x100
tmi.
정말 오랜만에 다시 시작한 사이드 프로젝트
작년 9월에 Ver1을 마무리 지었으니 5개월만에 다시 꺼내본다...!
5개월만에 코드를 다시보니 너무나 새로워...!

Ver1을 마치면서 앞으로 추가될 기능으로 위와 같이 적었었다. (5개월전에...!) 그래서 오늘은 1번 도서 API를 적용해보기로 함!

 


 

[개발목표]

  • 책 등록시, 도서 API 적용하기

 


 

[구현화면]

책등록 (도서검색)

  • 기존에 input으로 도서명, 작가, 출판사를 직접 입력받는 방식에서 무조건 도서검색을 통해 책을 등록하도록 변경함.

 

 

책검색 팝업

  • [도서 검색] 클릭시 뜨는 팝업.
  • 책제목, 저자, 출판사 검색조건으로 책 검색이 가능함
  • 선택한 책이 파란색으로 표시되고 이미지에는 없지만 하단에 선택 버튼을 클릭하면 책의 정보가 입력됨.

 

책등록 (도서검색 후)

  • 도서 검색 팝업에서 선택을 누르면 팝업이 닫히면서 책등록 input에 도서명, 작가, 출판사, 책 이미지 등 정보가 입력됨.

 

책등록 (등록 후)

  • 책정보와 책 이미지가 함께 보여지며 책 등록이 되었음을 보여줌.

 


[구현하기]

 

1. 도서 API 고르기

도서 API를 적용하려면 일단 어떤 도서 API를 사용할지 골라야 한다. 그래서 도서 API에 대해서 찾아보았는데....

API 장점 단점
카카오 도서 API 한국 도서 데이터가 많고 간편 ISBN 기반 도서 데이터 일부 부족 가능성
네이버 도서 API ISBN 기반 검색 가능, 국내 도서 많음 OAuth 인증 필요, 쿼터 제한
구글 북스 API 전 세계 책 데이터 제공 한국 도서 부족, ISBN 검색 부정확
알라딘 API 국내 도서 정보, 서점 데이터 연계 회원 가입 필요, API 키 승인 과정

요런 장 단점들이 있었음. 별도의 인증이나 회원가입이 필요없어 사용편의성이 좋고 무료(10,000건까지)인데다가 국내 도서 데이터 위주로 사용될 예정이라 카카오 도서 API를 사용함!

 

📌 카카오 책 API를 선택한 이유

  1. 간편한 사용
    • REST API로 간단하게 요청할 수 있고, JSON 형식으로 데이터를 받아서 처리하기 쉬움.
    • OAuth 인증 없이 REST API 키만 있으면 호출 가능.
  2. 무료 제공
    • 카카오 책 검색 API는 하루 10,000건까지 무료로 사용할 수 있음.
  3. 한국 도서 데이터 확보
    • 네이버, 구글 등 다른 API보다 국내 도서 데이터가 강점일 수 있음.
    • 한국 책 정보를 잘 반영하고 있음(예: 교보문고, 예스24 연계 데이터).
  4. 검색 기능이 강력함
    • 책 제목, 저자, 출판사 등 다양한 조건으로 검색 가능.
    • ISBN 기반 조회도 가능하여 데이터 정합성이 높음.
  5. 페이징 및 정렬 지원
    • page, size 등을 이용해서 페이지네이션이 쉬움.
    • 최신 순, 정확도 순 등으로 정렬 가능.

 

 


 

2. 책 등록화면 수정

addBookForm.html

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      layout:decorate="~{common/layout}"
      layout:fragment="content">
<head>
    <style>
    .is-invalid {
        border-color: #dc3545;
        color: #dc3545;
    }
    .form-floating{
        margin-top: 20px;
    }
    div.is-invalid{
        font-size: 0.87em;
    }
    #imagePreview {
        width: 150px;
        height: 150px;
        border-radius: 70%;
        overflow: hidden;
    }
    .row{
        margin-top: 20px;
    }
</style>
    <title>책 등록</title>
    <script th:src="@{/js/book/addBookForm.js}"></script>
</head>
<body>
<div class="container" style="max-width: 800px">
    <div class="mb-3 text-center" style="margin-top: 20px;">
        <h4 class="mb-3">책 등록</h4>
    </div>

    <form id="joinForm" action="member.html" th:action th:object="${book}" method="post">

        <!-- 썸네일 이미지 미리보기 -->
        <div class="mb-3 text-center">
            <img id="imagePreview" src="" alt="책 이미지"
                 style="width: 150px; height: auto; border-radius: 10px; margin: 0 auto; display: none;">
        </div>

        <!-- 숨겨진 썸네일 이미지 URL 저장 필드 -->
        <input type="hidden" id="bookThumbnail" name="thumbnail_img" th:field="*{thumbnailImg}">

        <div class="input-group mb-3 input-group-lg" style="margin-bottom: 20px;">
            <span class="input-group-text">도서명</span>
            <input type="text" class="form-control" id="bookName" th:field="*{bookName}"
                   placeholder="" aria-label="Recipient's username" aria-describedby="searchBookPopupOpen" readonly>
            <button class="btn btn-outline-secondary" type="button" id="searchBookPopupOpen">도서 검색</button>
        </div>
        <div class="input-group input-group-lg" style="margin-bottom: 20px;">
            <span class="input-group-text">작 가</span>
            <input type="text" id="bookWriter" th:field="*{bookWriter}" class="form-control" readonly>
        </div>
        <div class="input-group input-group-lg" style="margin-bottom: 20px;">
            <span class="input-group-text">출판사</span>
            <input type="text" id="publisher" th:field="*{publisher}" class="form-control" readonly>
        </div>
        <div class="input-group input-group-lg">
            <span class="input-group-text">ISBN</span>
            <input type="text" id="bookIsbn" th:field="*{isbn}" class="form-control" readonly>
        </div>
        <div class="row">
            <div class="col">
                <button class="w-100 btn btn-primary btn-lg" type="button" id="saveBtn">책 등록</button>
            </div>
            <div class="col">
                <button class="w-100 btn btn-secondary btn-lg"
                        onclick="location.href='members.html'"
                        th:onclick="|location.href='@{/book}'|" type="button">취소</button>
            </div>
        </div>
    </form>
</div> <!-- /container -->
</body>
</html>

 

 

addBookForm.js


function searchBookPopupOpen(){
    let url = "/book/searchBookPopupOpen";
    let windowName = "도서검색 API";
    let windowFeatures = "width=1200,height=550";
    window.open(url, windowName, windowFeatures);
}
function addBookBtn(){
    let bookName = document.getElementById("bookName").value;
    let bookWriter = document.getElementById("bookWriter").value;
    let bookIsbn = document.getElementById("bookIsbn").value;
    let publisher = document.getElementById("publisher").value;

    if (bookName === "" || bookWriter === "" || publisher === "" || bookIsbn === ""){
        alert("검색을 통해 도서를 선택해주세요.");
        return;
    }
    document.getElementById("joinForm").submit();
}

function handleSaveClick(event) {
    event.preventDefault(); // 기본 동작 방지
    addBookBtn();
}
document.addEventListener('DOMContentLoaded', function (){
    const saveBtn = document.getElementById("saveBtn");

    document.getElementById("searchBookPopupOpen").addEventListener("click", function (event) {
        event.preventDefault();  // 기본 동작 방지
        searchBookPopupOpen();
    });
    // 기존 이벤트가 존재하면 제거하고 다시 추가
    saveBtn.removeEventListener("click", handleSaveClick);
    saveBtn.addEventListener("click", handleSaveClick);

})
  • 기존에 Input으로 직접 입력받던 도서 정보들을 모두 도서 검색을 통해서만 등록할 수 있도록 수정함.
  • 책이름, 저자, 출판사, ISBN 등이 입력되지 않았을 때는 등록되지 않도록 유효성 검사 진행.
  • 도서검색 클릭시, 도서검색 팝업이 뜨도록 구현.
  • 도서검색 팝업에서 가져온 정보 중에 책 이미지가 있다면 책 썸네일도 보이게끔 구현.
  • 등록기능은 Ver1과 동일!

 


 

3. 책 검색 팝업

searchBook.html

<!DOCTYPE html>
<html lang="ko"
      xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
  <meta charset="UTF-8">
  <link href="/css/bootstrap.min.css" rel="stylesheet">
  <title>도서검색</title>
  <script th:src="@{/js/book/searchBook.js}"></script>
</head>
<body>
<div class="container" id="content">

  <div class="input-group input-group-lg" style="margin-top: 20px;">
    <select class="form-select" id="searchSelect" aria-label="Default select example" style="width: 100px;">
      <option selected>책제목</option>
      <option value="1">작가명</option>
      <option value="2">출판사</option>
    </select>
    <input type="text" class="form-control" placeholder=""  aria-describedby="searchBtn" id="searchBookKeyword">
    <button class="btn btn-outline-secondary" type="button" id="searchBookBtn">검색</button>
  </div>

  <table class="table" style="margin-top: 20px;">
    <span id="totalCnt">총 0 건</span>
      <thead>
      <tr style="text-align: center;">
        <th scope="col">No</th>
        <th scope="col">이미지</th>
        <th scope="col">첵제목</th>
        <th scope="col">작가</th>
        <th scope="col">출판사</th>
        <th scope="col" style="display: none;">ISBN</th>
      </tr>
      </thead>
      <tbody class="table-group-divider">
      <tr>
        <th scope="row"></th>
        <td></td>
        <td></td>
        <td></td>
        <td style="display: none;"></td>
      </tr>
      </tbody>
  </table>

  <nav aria-label="Page navigation example">
    <ul class="pagination justify-content-center">
      <li class="page-item disabled">
        <a class="page-link">Previous</a>
      </li>
      <li class="page-item"><a class="page-link" href="#">1</a></li>
      <li class="page-item disabled">
        <a class="page-link">Next</a>
      </li>
    </ul>
  </nav>
  <button class="btn btn-primary" type="button" id="selectBookBtn">책 선택</button>
</div> <!-- /container -->
</body>

 

searchBook.js

let currentPage = 1;
const pageSize = 10;
let selectedBook = null; // 선택된 책 저장

function searchBooks(page = 1) {

    currentPage = page; // 현재 페이지 저장

    const targetMap = {
        "책제목": "title",
        "작가명": "person",
        "출판사": "publisher"
    };

    const targetSelect = document.querySelector("#searchSelect").value;
    const query = document.querySelector('#searchBookKeyword').value;
    const target = targetMap[targetSelect] || "title";

    const REST_API_KEY = 'API키는 공개 하지 않습니다 ㅎㅎ';

    fetch(`https://dapi.kakao.com/v3/search/book?target=${target}&query=${encodeURIComponent(query)}&page=${page}&size=${pageSize}`, {
        headers: {
            'Authorization': `KakaoAK ${REST_API_KEY}`
        }
    })
        .then(response => {
            if (!response.ok) {
                throw new Error('Network response was not ok ' + response.statusText);
            }
            return response.json();
        })
        .then(data => {
            displayBooks(data.documents);
            updatePagination(data.meta);
        })
        .catch(error => {
            console.error('There has been a problem with your fetch operation:', error);
        });
}

function displayBooks(books) {
    const tableBody = document.querySelector("tbody");
    tableBody.innerHTML = ""; // 기존 데이터 초기화
    const totalCnt = books.length;
    document.querySelector("#totalCnt").textContent = `총 ${totalCnt}건`;

    books.forEach((book, index) => {
        const globalIndex = (currentPage - 1) * pageSize + index + 1; // 전체 인덱스 계산
        const row = document.createElement("tr");
        row.innerHTML = `
            <th scope="row">${globalIndex}</th>
            <td>
                <img src="${book.thumbnail || 'https://via.placeholder.com/50'}" alt="책 이미지" style="width: 50px; height: auto; margin-right: 10px;">
            </td>
            <td>${book.title}</td>
            <td>${book.authors.join(", ")}</td>
            <td>${book.publisher}</td>
            <td style="display: none;">${book.isbn}</td>
        `;

        row.addEventListener("click", function () {
            document.querySelectorAll("tbody tr").forEach(tr => tr.style.backgroundColor = ""); // 기존 선택 해제
            row.style.backgroundColor = "#007BFF"; // 선택된 행 색상 변경 (파란색)
            selectedBook = {
                title: book.title,
                authors: book.authors.join(", "),
                publisher: book.publisher,
                isbn: book.isbn,
                thumbnail: book.thumbnail || 'https://via.placeholder.com/150'
            };
        });

        tableBody.appendChild(row);
    });
}

// 📌 선택된 책 정보를 부모 창으로 전달 후 팝업 닫기
function sendSelectedBookToParent() {
    if (selectedBook) {
        window.opener.document.getElementById("bookName").value = selectedBook.title;
        window.opener.document.getElementById("bookWriter").value = selectedBook.authors;
        window.opener.document.getElementById("publisher").value = selectedBook.publisher;
        window.opener.document.getElementById("bookIsbn").value = selectedBook.isbn;
        window.opener.document.getElementById("bookThumbnail").value = selectedBook.thumbnail;

        const imagePreview = window.opener.document.getElementById("imagePreview");
        imagePreview.src = selectedBook.thumbnail; // 선택한 책 이미지 적용
        imagePreview.style.display = "block"; // 선택 후 이미지 표시
        window.close();
    }
}

function updatePagination(meta) {
    const pagination = document.querySelector(".pagination");
    pagination.innerHTML = "";

    // 이전 버튼
    const prevItem = document.createElement("li");
    prevItem.classList.add("page-item");
    prevItem.innerHTML = `<a class="page-link" href="#">Previous</a>`;
    prevItem.onclick = () => {
        if (currentPage > 1) searchBooks(currentPage - 1);
    };
    if (currentPage === 1) prevItem.classList.add("disabled");
    pagination.appendChild(prevItem);

    // 페이지 번호
    for (let i = 1; i <= Math.min(meta.pageable_count / pageSize, 5); i++) {
        const pageItem = document.createElement("li");
        pageItem.classList.add("page-item");
        if (i === currentPage) pageItem.classList.add("active");
        pageItem.innerHTML = `<a class="page-link" href="#">${i}</a>`;
        pageItem.onclick = () => searchBooks(i);
        pagination.appendChild(pageItem);
    }

    // 다음 버튼
    const nextItem = document.createElement("li");
    nextItem.classList.add("page-item");
    nextItem.innerHTML = `<a class="page-link" href="#">Next</a>`;
    nextItem.onclick = () => {
        if (currentPage < meta.pageable_count / pageSize) searchBooks(currentPage + 1);
    };
    if (currentPage >= meta.pageable_count / pageSize) nextItem.classList.add("disabled");
    pagination.appendChild(nextItem);
}


document.addEventListener('DOMContentLoaded', function (){
    document.getElementById("searchBookBtn").addEventListener("click", function (event) {
        searchBooks();
    });

    document.querySelector("input").addEventListener("keypress", function (event) {
        if (event.key === "Enter") {
            event.preventDefault();
            searchBooks();
        }
    });

    document.getElementById("selectBookBtn").addEventListener("click", function () {
        if (selectedBook) {
            sendSelectedBookToParent();
        } else {
            alert("선택된 책이 없습니다.");
        }
    });
})
  • 책제목, 저자, 출판사 등의 검색조건으로 검색가능하게끔 구현
  • 검색된 책 목록이 하단에 페이징 처리되어 확인할 수 있음.
  • 등록하려는 책을 목록에서 선택시 파란색으로 표시되고 하단에 선택 버튼을 누르면 팝업이 닫히면서 선택한 책정보가 등록 화면에 입력됨.

 

 


 

728x90
320x100

뚜루리님의
글이 좋았다면 응원을 보내주세요!