SpringBoot 프로젝트

Spring Boot - 국립 도서관 Open API를 활용한 도서 검색 구현

orin602 2024. 12. 10. 17:03

이번 글에서는 국립 도서관의 Open API를 활용하여 도서 검색 및 결과를 웹 페이지에 나타내는 방법을 소개해 볼게요. 부족한 실력 탓에 설명이 미흡합니다... 이해해주세요 :)

 

*** 지난번 글에서 api의 key에 대한 설명이 없었던 것 같아서 여기에 작성해 볼게요 ***

저는 api key를 application.properties에 따로 저장해서 사용했습니다.

application.properties 위치

  • library.api.key : 국립 중앙 도서관의 api key.
  • aladin.api.key : 알라딘 api key (이건 여기서 사용하지 않아서 필요 없어요!!!)

 

ItemDTO (저번과 똑같아요 수정할 필요 없습니다..)

package com.demo.dto;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
@JsonIgnoreProperties(ignoreUnknown = true) // 알 수 없는 속성을 무시
public class ItemDTO {
    // 알라딘 api용
    private String title;       // 책 제목
    private String author;      // 저자
    private String description; // 책 설명 (필요시 추가)
    private String cover;       // 책 표지 이미지 URL
    private String link;        // 도서 상세 페이지 링크
    
    // 국립중앙도서관 api용
    private String kwd;          // 검색어
    private String titleInfo;   // 제목
    private String authorInfo;  // 저자
    private String imageUrl;    // 이미지 URL
    private String kdcName1s;
}

 

+ RestTemplate 작성

package com.demo.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;

@Configuration
public class RestTemplateConfig {
	
	@Bean
	public RestTemplate restTemplate() {
		return new RestTemplate();
	}
}

 

+ Service 작성 (SearchService.java)

package com.demo.service;

import java.util.ArrayList;
import java.util.List;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

import com.demo.dto.ItemDTO;
import com.demo.dto.SearchResult;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

@Service
public class SearchService {
	private static final Logger logger = LoggerFactory.getLogger(SearchService.class);

    @Value("${library.api.key}")
    private String apiKey;

    private final RestTemplate restTemplate;
    private final ObjectMapper objectMapper;

    private static final String NLA_API_URL = "https://nlapi.nl.go.kr/api/collection/search";

    public SearchService(RestTemplate restTemplate, ObjectMapper objectMapper) {
        this.restTemplate = restTemplate;
        this.objectMapper = objectMapper;
    }
    
    public SearchResult searchBooks(String query, int pageNum, String srchTarget) {
        final int resultsPerPage = 10; // 페이지당 결과 수
        final int startIndex = (pageNum - 1) * resultsPerPage + 1; // start를 계산 (1부터 시작)

        // 검색 조건 설정
        String searchField = srchTarget.equals("title") ? "title" : srchTarget.equals("author") ? "author" : "total";

        String url = String.format("https://www.nl.go.kr/NL/search/openApi/search.do?key=%s&kwd=%s&detailSearch=true&f1=%s&pageNum=%d&maxResults=%d&apiType=json&category=도서",
                apiKey, query, searchField, pageNum, resultsPerPage);

        // API 호출
        String response = restTemplate.getForObject(url, String.class);

        List<ItemDTO> items = new ArrayList<>();
        int totalPages = 0;  // 전체 페이지 수
        int totalResults = 0;  // 전체 검색 결과 수

        try {
            if (response != null && !response.isEmpty()) {
                JsonNode root = objectMapper.readTree(response);
                totalResults = root.path("total").asInt(); // 전체 검색 결과 수
                totalPages = totalResults > 0 ? (int) Math.ceil((double) totalResults / resultsPerPage) : 0; // 전체 페이지 수 계산

                JsonNode resultNode = root.path("result");
                for (JsonNode itemNode : resultNode) {
                    ItemDTO item = new ItemDTO();
                    item.setTitleInfo(itemNode.path("titleInfo").asText().replaceAll("<[^>]*>", "").trim());
                    item.setAuthorInfo(itemNode.path("authorInfo").asText().replaceAll("<[^>]*>", "").trim());
                    String imageUrl = itemNode.path("imageUrl").asText().trim();
                    item.setImageUrl(imageUrl.isEmpty() ? "/images/no_img.jpg" : "http://cover.nl.go.kr/" + imageUrl);
                    items.add(item);
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }

        return new SearchResult(items, pageNum, totalPages, totalResults); // searchResult 반환
    }




}
  • private static final Logger logger = LoggerFactory.getLogger(SearchService.class); : 검색 중 발생하는 이벤트나 오류를 기록하기 위해 Logger를 사용.
  • @Value("${library.api.key}") : 위의 application.properties에서 작성한 library.api.key로 저장한 API 키를 주입.
  • private static final String NLA_API_URL = "https://nlapi.nl.go.kr/api/collection/search"; : 검색 시 API의 기본 URL >> 
  • RestTemplate restTemplate : 외부 API 호출 처리, ObjectMapper objectMapper : JSON 데이터를 객체로 변환하거나 구조 탐색을 위해 사용.
  • SearchResult searchBooks(String query, int pageNum, String srchTarget) : 검색 결과를 반환하는 메서드 >> 쿼리(검색어), 페이지 번호, 검색 대상을 받아서 API를 호출.
  • final int resultsPerPage = 10; : 한 페이지에 표시할 검색 결과 개수.
    final int startIndex = (pageNum - 1) * resultsPerPage + 1; : 현재 페이지에서 첫 번째 검색 결과 인덱스.
String url = String.format("https://www.nl.go.kr/NL/search/openApi/search.do?key=%s&kwd=%s&detailSearch=true&f1=%s&pageNum=%d&maxResults=%d&apiType=json&category=도서",
                apiKey, query, searchField, pageNum, resultsPerPage);
  • API 호출에 필요한 URL을 구성.
  • 변수 : key=%s(apiKey), kwd=%s(query=검색어), f1=%s(searchField=검색필드), pageNum=%d(pageNum=요청하는 페이지 번호), maxResults=%d(resultsPerPage=페이지당 최대 결과 수), apiType=json(응답을 json형식으로 받음.), category=도서(도서 카테고리 제한).
  • String response = restTemplate.getForObject(url, String.class); : HTTP 클라이언트를 이용해서 API를 호출. (응답은 JSON 문자열)
List<ItemDTO> items = new ArrayList<>();
int totalPages = 0;  // 전체 페이지 수
int totalResults = 0;  // 전체 검색 결과 수
  • items : 검색 결과를 저장할 리스트 초기화.
  • 페이지 수 (totalPages), 검색 결과 수 (totalResults)를 초기화.
if (response != null && !response.isEmpty()) {
    JsonNode root = objectMapper.readTree(response);
    totalResults = root.path("total").asInt(); // 전체 검색 결과 수
    totalPages = totalResults > 0 ? (int) Math.ceil((double) totalResults / resultsPerPage) : 0; // 전체 페이지 수 계산
  • JsonNode root = objectMapper.readTree(response); : API 호출을 통해 응답받은 JSON 문자열을 JsonNode 객체로 변환.
  • totalResults = root.path("total").asInt(); : total 키를 통해 전체 검색 결과 수를 가져와서 저장.
  • totalPages = totalResults > 0 ? (int) Math.ceil((double) totalResults / resultsPerPage) : 0; : 전체 페이지 수 계산.
JsonNode resultNode = root.path("result");
for (JsonNode itemNode : resultNode) {
    ItemDTO item = new ItemDTO();
    item.setTitleInfo(itemNode.path("titleInfo").asText().replaceAll("<[^>]*>", "").trim());
    item.setAuthorInfo(itemNode.path("authorInfo").asText().replaceAll("<[^>]*>", "").trim());
    String imageUrl = itemNode.path("imageUrl").asText().trim();
    item.setImageUrl(imageUrl.isEmpty() ? "/images/no_img.jpg" : "http://cover.nl.go.kr/" + imageUrl);
    items.add(item);
}
  • JsonNode resultNode = root.path("result"); : 검색 결과(result)를 포함하는 JSON 배열.
  •  각 아이템(itemNode) 처리 
    item.setTitleInfo(itemNode.path("titleInfo").asText().replaceAll("<[^>]*>", "").trim()); : 책 제목 추출, HTML 태그 제거 및 트림 처리.
    item.setAuthorInfo(itemNode.path("authorInfo").asText().replaceAll("<[^>]*>", "").trim()); : 저자 추출, HTML 태그 제거 및 트림 처리.
    String imageUrl = itemNode.path("imageUrl").asText().trim(); : 이미지 URL 추출, item.setImageUrl(imageUrl.isEmpty() ? "/images/no_img.jpg" : "http://cover.nl.go.kr/" + imageUrl); >>값이 없으면 기본 이미지 설정.
  • return new SearchResult(items, pageNum, totalPages, totalResults); : 검색 결과를 담는 객체.

검색 HTML

<form class="search-form" id="search-form" method="get">
    <!-- 검색 대상 선택 -->
    <select name="srchTarget" id="srchTarget" class="searchTarget">
        <option value="total">전체</option>
        <option value="title">제목</option>
        <option value="author">저자</option>
    </select>
    <input type="search" name="query" id="query" placeholder="도서 검색..." aria-label="도서 검색" />
    <button class="search-btn" type="button" onclick="search_book()">검색</button>
</form>

 

검색 함수 JavaScript

// 도서 검색
function search_book() {
    if ($("#query").val() === "") {
        swal.fire({
            title: '검색어를 입력하세요.',
            icon: 'warning',
            confirmButtonText: '확인'
        });
        $("#query").focus();
        return false;
    } else {
        let srchTarget = $("#srchTarget").val();
        $("#search-form").attr("action", `/search-book?srchTarget=${srchTarget}`).submit();
    }
}
  • let srchTarget = $("#srchTarget").val(); : 입력한 검색내용을 가져옴.
  • $("#search-form").attr("action", `/search-book?srchTarget=${srchTarget}`).submit(); : 동적으로 url을 설정하고 폼을 제출.

Controller

// 도서 검색
@GetMapping("/search-book")
public String searchBooks(@RequestParam("query") String query, HttpSession session,
        @RequestParam(value = "page", defaultValue = "1") int pageNum, Model model,
        @RequestParam(value = "srchTarget", defaultValue = "total") String srchTarget) {

    System.out.println("Received pageNum: " + pageNum);  // pageNum 값 확인

    Member loginUser = (Member) session.getAttribute("loginUser");
    SearchHistory searchHistory = new SearchHistory();

    if (loginUser != null) {
        searchHistory.setMember(loginUser);
    }

    searchHistory.setQuery(query);
    searchHistory.setSearch_date(new Date());

    searchHisService.saveSearchHistory(searchHistory);

    // 검색 서비스 호출
    SearchResult result = searchService.searchBooks(query, pageNum, srchTarget);
    List<ItemDTO> items = result.getItems();  // 아이템 목록
    int totalPages = result.getTotalPages();  // 전체 페이지 수
    int totalResults = result.getTotalResults();  // 전체 검색 결과 수

    // 검색 결과를 세션에 저장
    session.setAttribute("searchResults", items);
    model.addAttribute("items", items);
    model.addAttribute("query", query);
    model.addAttribute("pageNum", pageNum);
    model.addAttribute("totalPages", totalPages);
    model.addAttribute("totalResults", totalResults);

    // 인기 검색어 가져오기
    List<Object[]> topSearchQueries = searchHisService.getTopSearchQueries();
    List<Object[]> limitedTopSearchQueries = topSearchQueries.size() > 5
            ? topSearchQueries.subList(0, 5)
            : topSearchQueries;
    model.addAttribute("topSearchQueries", limitedTopSearchQueries);
    model.addAttribute("showMore", topSearchQueries.size() > 5);

    // 연관 검색어 추가
    List<Object[]> relatedSearches = searchHisService.getRelatedSearchQueries(query);
    model.addAttribute("relatedSearches", relatedSearches);

    // 결과를 보여줄 HTML 템플릿 이름
    return "searchBook/searchBookMain";
}
  • @GetMapping("/search-book") : /search-book로 들어오는 GET 요청 처리
  • @RequestParam("query") String query : 입력한 검색어를 받아옴.
    @RequestParam(value = "page", defaultValue = "1") int pageNum : 페이지 번호를 받아오고, 기본값은 1.
    @RequestParam(value = "srchTarget", defaultValue = "total") String srchTarget : 검색 범위를 받아오고, 기본값은 total(전체).
  • SearchResult result = searchService.searchBooks(query, pageNum, srchTarget); : Service에서 작성한 메서드를 호출해서 @RequestParam으로 받은 검색어, 페이지 번호, 검색 범위 전달. API를 통해 받은 결과에서 다음 값들을 추출.
  • List<ItemDTO> items = result.getItems(); : 검색된 아이템 목록.
    int totalPages = result.getTotalPages(); : 전체 페이지 수.
    int totalResults = result.getTotalResults(); : 전체 결과 수.
session.setAttribute("searchResults", items);
model.addAttribute("items", items);
model.addAttribute("query", query);
model.addAttribute("pageNum", pageNum);
model.addAttribute("totalPages", totalPages);
model.addAttribute("totalResults", totalResults);
  • session.setAttribute("searchResults", items); : 검색 결과를 session에 저장.
  • model.addAttribute() : model에 저장하고 view에 보여줌.
  • items : 검색 결과 목록
    query : 검색어
    pageNum : 현재 페이지 번호
    totalPages : 전체 페이지 수
    totalResults : 검색 결과 전체 개수

검색 결과 HTML (searchBookMain.html)

<div class="search-book-container">
    <div class="search-history-container">
    	<p>인기 검색어 :
            <span th:each="queryData, iterStat : ${topSearchQueries}">
                <span th:text="${queryData[0]}"></span>
                <span th:if="${iterStat.index < 5}">, </span>
            </span>
            <span th:if="${showMore}">...</span>
    	</p>
        <p>연관 검색어 : 
            <span th:each="related : ${relatedSearches}">
            	<a th:href="@{'/search-book?query=' + ${related[0]}}" th:text="${related[0]}"></a>
            </span>
        </p>
    </div>
    <div class="items">
        <h1>도서 검색 결과</h1>
        <h3>검색어 : [[${query}]]</h3>

        <div class="results" id="results" th:each="item : ${items}">
            <img th:src="${item.imageUrl}" alt="Book Cover" style="width:200px;">
            <h4 th:text="${item.titleInfo}"></h4>
            <p th:text="${item.authorInfo}"></p>
            <p th:text="${item.kdcName1s}"></p>
        </div>

        <div class="pagination">
            <span th:if="${pageNum > 1}">
            	<a th:href="@{|/search-book?query=${query}&srchTarget=${srchTarget}&page=1|}" class="pagination-button first">첫 페이지</a>
            </span>
            <span th:if="${pageNum > 1}">
            	<a th:href="@{|/search-book?query=${query}&srchTarget=${srchTarget}&page=${pageNum - 1}|}" class="pagination-button prev">이전</a>
            </span>

        	<span class="page-info">페이지 [[${pageNum}]] / [[${totalPages}]]</span>

        	<span th:if="${pageNum < totalPages}">
            	<a th:href="@{|/search-book?query=${query}&srchTarget=${srchTarget}&page=${pageNum + 1}|}" class="pagination-button next">다음</a>
            </span>
            <span th:if="${pageNum < totalPages}">
            	<a th:href="@{|/search-book?query=${query}&srchTarget=${srchTarget}&page=${totalPages}|}" class="pagination-button last">마지막 페이지</a>
            </span>
        </div>
    </div>
</div>

 

++ 테스트

검색어를 입력하지 않았을 때

 - '자바'입력 후 검색

1페이지

 - 생략했지만 페이지에 총 10개의 검색 결과값이 나오고 마지막에는 페이지네이션을 통해 페이지 이동 버튼.

다음 버튼을 통한 2페이지

 

** 중간에 설명을 생략한 인기 검색어, 연관 검색어에 해당하는 코딩은 시간이 나면 글로 추가 작성 할게요! **

생략한 부분.