-
Notifications
You must be signed in to change notification settings - Fork 1
[fix] 종료된 네이버 책 API를 알라딘으로 전환 (#372) #373
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
775bf14
c688938
3d93566
a91cfc5
4af46fd
d245fdf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,8 @@ | |
|
|
||
| import com.fasterxml.jackson.databind.JsonNode; | ||
| import com.fasterxml.jackson.databind.ObjectMapper; | ||
| import konkuk.thip.book.adapter.out.api.dto.BookSearchResult; | ||
| import konkuk.thip.book.adapter.out.api.dto.BookDetailResult; | ||
| import konkuk.thip.common.exception.ExternalApiException; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
|
|
@@ -10,16 +12,21 @@ | |
| import org.springframework.web.client.RestTemplate; | ||
|
|
||
| import java.io.IOException; | ||
| import java.util.ArrayList; | ||
| import java.util.List; | ||
|
|
||
| import static konkuk.thip.book.adapter.out.api.aladin.AladinApiParam.*; | ||
| import static konkuk.thip.common.exception.code.ErrorCode.BOOK_ALADIN_API_ISBN_NOT_FOUND; | ||
| import static konkuk.thip.common.exception.code.ErrorCode.BOOK_ALADIN_API_PARSING_ERROR; | ||
| import static konkuk.thip.book.adapter.out.api.dto.BookSearchResult.PAGE_SIZE; | ||
| import static konkuk.thip.common.exception.code.ErrorCode.*; | ||
|
|
||
| @Component | ||
| @RequiredArgsConstructor | ||
| @Slf4j | ||
| public class AladinApiUtil { | ||
|
|
||
| // 알라딘 정책상 한 검색어당 실제로 조회 가능한 결과는 최대 200건까지로 제한된다. | ||
| private static final int MAX_TOTAL_RESULTS = 200; | ||
|
|
||
| private final RestTemplate restTemplate; | ||
| private final ObjectMapper objectMapper; | ||
|
|
||
|
|
@@ -29,6 +36,10 @@ public class AladinApiUtil { | |
| @Value("${aladin.baseUrl}") | ||
| private String baseUrl; | ||
|
|
||
| @Value("${aladin.searchUrl}") | ||
| private String searchUrl; | ||
|
|
||
| public record AladinDetailResult(BookDetailResult detail, Integer pageCount) {} | ||
|
|
||
| private String buildLookupUrl(String isbn) { | ||
| return String.format( | ||
|
|
@@ -41,7 +52,34 @@ private String buildLookupUrl(String isbn) { | |
| ); | ||
| } | ||
|
|
||
| // Query 값은 RestTemplate가 URI 템플릿 변수로 한 번만 인코딩하도록 {query} 플레이스홀더로 남겨둔다. | ||
| // 여기서 미리 URLEncoder로 인코딩해버리면 RestTemplate이 다시 인코딩(이중 인코딩)하여 키워드가 깨진다. | ||
| private String buildSearchUrlTemplate(int page) { | ||
| return String.format( | ||
| searchUrl + "ttbkey=%s&Query={query}&QueryType=%s&SearchTarget=%s&MaxResults=%s&start=%d&output=%s&Version=%s", | ||
| ttbKey, | ||
| QUERY_TYPE.getValue(), | ||
| SEARCH_TARGET.getValue(), | ||
| PAGE_SIZE, | ||
| page, | ||
| OUTPUT.getValue(), | ||
| API_VERSION.getValue() | ||
| ); | ||
| } | ||
|
|
||
| public Integer getPageCount(String isbn) { | ||
| return fetchDetail(isbn).pageCount(); | ||
| } | ||
|
|
||
| public BookDetailResult getBookDetail(String isbn) { | ||
| return fetchDetail(isbn).detail(); | ||
| } | ||
|
|
||
| public AladinDetailResult getBookDetailWithPageCount(String isbn) { | ||
| return fetchDetail(isbn); | ||
| } | ||
|
|
||
| private AladinDetailResult fetchDetail(String isbn) { | ||
| String url = buildLookupUrl(isbn); | ||
| String response = restTemplate.getForObject(url, String.class); | ||
|
|
||
|
|
@@ -50,19 +88,63 @@ public Integer getPageCount(String isbn) { | |
| JsonNode items = jsonNode.path("item"); | ||
|
|
||
| // json 응답 결과에 item 키값이 없는 경우 | ||
| // TODO : 알라딘으로부터 page 정보가 없으면 ?? | ||
| // 보상 시나리오 : 유저에게 "page 정보를 찾을 수 없는 책입니다. 직접 page 정보를 입력하세요" 라고 안내 | ||
| // 일단 지금은 exception throw 만 진행 | ||
| if (!items.isArray() || items.isEmpty()) { | ||
| // TODO : 알라딘으로부터 page 정보가 없으면 ?? | ||
| // 보상 시나리오 : 유저에게 "page 정보를 찾을 수 없는 책입니다. 직접 page 정보를 입력하세요" 라고 안내 | ||
| // 일단 지금은 exception throw 만 진행 | ||
| throw new ExternalApiException(BOOK_ALADIN_API_ISBN_NOT_FOUND); | ||
| } | ||
|
|
||
| JsonNode subInfo = items.get(0).path(SUB_INFO_PARSING_KEY.getValue()); | ||
| JsonNode item = items.get(0); | ||
| JsonNode subInfo = item.path(SUB_INFO_PARSING_KEY.getValue()); | ||
| int pageCount = subInfo.path(PAGE_COUNT_PARSING_KEY.getValue()).asInt(); | ||
|
|
||
| return subInfo.path(PAGE_COUNT_PARSING_KEY.getValue()).asInt(); | ||
| BookDetailResult detail = BookDetailResult.builder() | ||
| .title(item.path("title").asText()) | ||
| .imageUrl(item.path("cover").asText()) | ||
| .author(item.path("author").asText()) | ||
| .publisher(item.path("publisher").asText()) | ||
| .isbn(item.path("isbn13").asText()) | ||
| .description(item.path("description").asText()) | ||
| .build(); | ||
|
Comment on lines
+102
to
+109
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🌐 Web query:
💡 Result: 알라딘 OpenAPI의 author 필드는 별도의 구조체로 분리되어 있지 않으며, 저자명, 옮긴이 등이 포함된 하나의 문자열(String) 형식으로 제공됩니다 [1][2][3]. 주요 특징은 다음과 같습니다: 1. 데이터 형식: author 필드는 작가가 여러 명인 경우를 포함하여 모든 저자 정보를 하나의 문자열로 묶어서 반환합니다 [3]. 2. 구성: 지은이(저자)와 옮긴이 등이 구분 기호와 함께 하나의 필드 내에 포함되어 있습니다 [3]. 별도의 '옮긴이' 필드가 독립적으로 존재하는 것이 아니므로, 필요한 경우 문자열 파싱(parsing)을 통해 데이터를 가공해야 합니다 [3]. 3. 추가 정보: 기본 API 응답 외에 더 상세한 작가 정보가 필요한 경우, API 설계에 따라 authors와 같은 별도의 상세 정보 목록을 요청할 수 있는 경우가 있으나, 기본적인 검색 및 상품 조회 결과에서 author 필드는 단일 문자열로 취급됩니다 [2][4]. 따라서 알라딘 API를 연동하여 지은이와 옮긴이를 각각 구분해야 한다면, 응답받은 author 문자열 내의 특정 구분자를 기준으로 파싱하는 처리가 필요합니다 [3]. Citations:
🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== locate file =="
fd -a 'AladinApiUtil\.java|BookDetailResult\.java|BookSearchResult\.java' . | sed 's#^\./##'
echo
echo "== relevant lines =="
cat -n src/main/java/konkuk/thip/book/adapter/out/api/aladin/AladinApiUtil.java | sed -n '70,170p'
echo
echo "== result author usages =="
rg -n "author\(|author:|BookDetailResult|BookSearchResult" src/main/java/konkuk/thip/book -SRepository: THIP-TextHip/THIP-Server Length of output: 13611 알라딘 알라딘의 🤖 Prompt for AI Agents |
||
|
|
||
| return new AladinDetailResult(detail, pageCount); | ||
| } catch (IOException e) { | ||
| throw new ExternalApiException(BOOK_ALADIN_API_PARSING_ERROR); | ||
| } | ||
| } | ||
|
Comment on lines
90
to
115
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
코드 내 TODO 주석은 "알라딘으로부터 page 정보가 없으면" 사용자에게 안내하거나 예외를 던지는 보상 시나리오를 계획하고 있다고 설명합니다. 그러나 실제 구현은 TODO 주석이 명시한 의도와 실제 동작이 다릅니다. 이 TODO 주석에서 계획한 보상 시나리오(사용자 안내 또는 예외 처리)를 구현하는 코드를 생성해 드릴까요? 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| public BookSearchResult searchBooks(String keyword, int start) { | ||
| // BookSearchService가 넘기는 start는 (page-1)*PAGE_SIZE+1 형태의 아이템 오프셋이므로 | ||
| // 알라딘이 요구하는 "페이지 번호"로 역산한다. | ||
| int page = ((start - 1) / PAGE_SIZE) + 1; | ||
| String urlTemplate = buildSearchUrlTemplate(page); | ||
| String response = restTemplate.getForObject(urlTemplate, String.class, keyword); | ||
|
|
||
| try { | ||
| JsonNode jsonNode = objectMapper.readTree(response); | ||
| // totalResults는 전체 매칭 건수를 그대로 보고하지만 실제로 조회 가능한 건 MAX_TOTAL_RESULTS까지뿐이므로, | ||
| // 페이지네이션(totalPages/last)이 정확히 그 지점에서 끝나도록 캡핑한다. | ||
| int total = Math.min(jsonNode.path("totalResults").asInt(), MAX_TOTAL_RESULTS); | ||
| int startIndex = jsonNode.path("startIndex").asInt(); | ||
|
|
||
| List<BookSearchResult.BookSummary> books = new ArrayList<>(); | ||
| JsonNode items = jsonNode.path("item"); | ||
| if (items.isArray()) { | ||
| for (JsonNode item : items) { | ||
| books.add(BookSearchResult.BookSummary.builder() | ||
| .title(item.path("title").asText()) | ||
| .imageUrl(item.path("cover").asText()) | ||
| .author(item.path("author").asText()) | ||
| .publisher(item.path("publisher").asText()) | ||
| .isbn(item.path("isbn13").asText()) | ||
| .build()); | ||
| } | ||
| } | ||
|
|
||
| return BookSearchResult.of(books, total, startIndex); | ||
| } catch (IOException e) { | ||
| throw new ExternalApiException(BOOK_ALADIN_API_PARSING_ERROR); | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: THIP-TextHip/THIP-Server
Length of output: 162
🏁 Script executed:
Repository: THIP-TextHip/THIP-Server
Length of output: 50380
🏁 Script executed:
Repository: THIP-TextHip/THIP-Server
Length of output: 17701
한 번 호출한 상세 정보는 페이지 수 조회에 재사용하세요.
BookSearchService,BookMostSearchRankService는findDetailBookByIsbn으로 상세정보만 조회합니다. 이후 이 같은 흐름에서findPageCountByIsbn을 또 호출하면 같은 ISBN으로CompositeBookApiAdapter가fetchDetail을 두 번 호출해서 알라딘ItemLookUp요청이 중복됩니다. 해당 경로에서는loadBookWithPageByIsbn을 사용하거나, 상세정보를 캐시/전달해 중복 호출을 막아야 합니다.🤖 Prompt for AI Agents