이번 글에서는 국립 도서관의 Open API를 활용하여 도서 검색 및 결과를 웹 페이지에 나타내는 방법을 소개해 볼게요. 부족한 실력 탓에 설명이 미흡합니다... 이해해주세요 :)
*** 지난번 글에서 api의 key에 대한 설명이 없었던 것 같아서 여기에 작성해 볼게요 ***
저는 api key를 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>
++ 테스트
- '자바'입력 후 검색
- 생략했지만 페이지에 총 10개의 검색 결과값이 나오고 마지막에는 페이지네이션을 통해 페이지 이동 버튼.
** 중간에 설명을 생략한 인기 검색어, 연관 검색어에 해당하는 코딩은 시간이 나면 글로 추가 작성 할게요! **
'SpringBoot 프로젝트' 카테고리의 다른 글
Spring Boot - 국립 도서관 Open API를 활용한 사서 추천 도서 목록 구현 (0) | 2024.12.10 |
---|---|
Spring Boot - 관리자 페이지 (5) (0) | 2024.12.07 |
Spring Boot - 관리자 페이지 (4) (0) | 2024.12.07 |
Spring Boot - 관리자 페이지 (3) (0) | 2024.11.30 |
Spring Boot - 관리자 페이지 (2) (0) | 2024.11.25 |