Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,12 @@ public record BookDetailSearchResponse(
public static BookDetailSearchResponse of(BookDetailSearchResult result) {

return new BookDetailSearchResponse(
result.naverDetailBook().title(),
result.naverDetailBook().imageUrl(),
result.naverDetailBook().author(),
result.naverDetailBook().publisher(),
result.naverDetailBook().isbn(),
result.naverDetailBook().description(),
result.bookDetail().title(),
result.bookDetail().imageUrl(),
result.bookDetail().author(),
result.bookDetail().publisher(),
result.bookDetail().isbn(),
result.bookDetail().description(),
result.recruitingRoomCount(),
result.readCount(),
result.isSaved());
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
package konkuk.thip.book.adapter.in.web.response;

import konkuk.thip.book.adapter.out.api.dto.NaverBookParseResult;
import konkuk.thip.book.adapter.out.api.dto.BookSearchResult;

import java.util.List;

import static konkuk.thip.book.adapter.out.api.naver.NaverApiUtil.PAGE_SIZE;
import static konkuk.thip.book.adapter.out.api.dto.BookSearchResult.PAGE_SIZE;

public record BookSearchListResponse(
List<BookSearchDto> searchResult, // 책 목록
Expand All @@ -15,13 +15,13 @@ public record BookSearchListResponse(
boolean last, // 마지막 페이지 여부
boolean first // 첫 페이지 여부
) {
public static BookSearchListResponse of(NaverBookParseResult result, int page) {
public static BookSearchListResponse of(BookSearchResult result, int page) {
int totalElements = result.total();
int totalPages = (int) Math.ceil((double) totalElements / PAGE_SIZE);
boolean last = (page >= totalPages);
boolean first = (page == 1);

List<BookSearchDto> bookSearchDtos = result.naverBooks().stream()
List<BookSearchDto> bookSearchDtos = result.books().stream()
.map(BookSearchDto::of)
.toList();

Expand All @@ -42,13 +42,13 @@ public record BookSearchDto(
String publisher,
String isbn
) {
public static BookSearchDto of(NaverBookParseResult.NaverBook naverBook) {
public static BookSearchDto of(BookSearchResult.BookSummary book) {
return new BookSearchDto(
naverBook.title(),
naverBook.imageUrl(),
naverBook.author(),
naverBook.publisher(),
naverBook.isbn()
book.title(),
book.imageUrl(),
book.author(),
book.publisher(),
book.isbn()
);
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
package konkuk.thip.book.adapter.out.api;

import konkuk.thip.book.adapter.out.api.aladin.AladinApiClient;
import konkuk.thip.book.adapter.out.api.dto.NaverBookParseResult;
import konkuk.thip.book.adapter.out.api.dto.NaverDetailBookParseResult;
import konkuk.thip.book.adapter.out.api.naver.NaverApiClient;
import konkuk.thip.book.adapter.out.api.aladin.AladinApiUtil;
import konkuk.thip.book.adapter.out.api.dto.BookSearchResult;
import konkuk.thip.book.adapter.out.api.dto.BookDetailResult;
import konkuk.thip.book.application.port.out.BookApiQueryPort;
import konkuk.thip.book.domain.Book;
import lombok.RequiredArgsConstructor;
Expand All @@ -13,17 +13,16 @@
@RequiredArgsConstructor
public class CompositeBookApiAdapter implements BookApiQueryPort {

private final NaverApiClient naverApiClient;
private final AladinApiClient aladinApiClient;

@Override
public NaverBookParseResult findBooksByKeyword(String keyword, int start) {
return naverApiClient.findBooksByKeyword(keyword, start);
public BookSearchResult findBooksByKeyword(String keyword, int start) {
return aladinApiClient.findBooksByKeyword(keyword, start);
}

@Override
public NaverDetailBookParseResult findDetailBookByIsbn(String isbn) {
return naverApiClient.findDetailBookByIsbn(isbn);
public BookDetailResult findDetailBookByIsbn(String isbn) {
return aladinApiClient.findDetailBookByIsbn(isbn);
}

@Override
Expand All @@ -33,22 +32,19 @@ public Integer findPageCountByIsbn(String isbn) {

@Override
public Book loadBookWithPageByIsbn(String isbn) {
// 1. naver 상세정보 조회 api 로 책 상세정보(without page) load
NaverDetailBookParseResult detailBookByKeyword = findDetailBookByIsbn(isbn);
// 상세정보 + page 정보를 알라딘 ItemLookUp 한 번의 호출로 함께 조회
AladinApiUtil.AladinDetailResult result = aladinApiClient.findDetailBookWithPageCountByIsbn(isbn);
BookDetailResult detail = result.detail();

// 2. 알라딘으로부터 책 page 정보 load
Integer pageCount = findPageCountByIsbn(isbn);

// 3. pageCount 정보를 포함한 Book 반환
return Book.withoutId(
detailBookByKeyword.title(),
detail.title(),
isbn,
detailBookByKeyword.author(),
detail.author(),
false, // TODO : 추후 BestSeller 도입되면 고려해야함
detailBookByKeyword.publisher(),
detailBookByKeyword.imageUrl(),
pageCount,
detailBookByKeyword.description()
detail.publisher(),
detail.imageUrl(),
result.pageCount(),
detail.description()
);
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package konkuk.thip.book.adapter.out.api.aladin;

import konkuk.thip.book.adapter.out.api.dto.BookSearchResult;
import konkuk.thip.book.adapter.out.api.dto.BookDetailResult;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

Expand All @@ -9,7 +11,19 @@ public class AladinApiClient {

private final AladinApiUtil aladinApiUtil;

public BookSearchResult findBooksByKeyword(String keyword, int start) {
return aladinApiUtil.searchBooks(keyword, start);
}

public BookDetailResult findDetailBookByIsbn(String isbn) {
return aladinApiUtil.getBookDetail(isbn);
}

public Integer findPageCountByIsbn(String isbn) {
return aladinApiUtil.getPageCount(isbn);
}

public AladinApiUtil.AladinDetailResult findDetailBookWithPageCountByIsbn(String isbn) {
return aladinApiUtil.getBookDetailWithPageCount(isbn);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ public enum AladinApiParam {
OUTPUT("js"),
API_VERSION("20131101"),
SUB_INFO_PARSING_KEY("subInfo"),
PAGE_COUNT_PARSING_KEY("itemPage");
PAGE_COUNT_PARSING_KEY("itemPage"),
QUERY_TYPE("Keyword"),
SEARCH_TARGET("Book");

private final String value;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand All @@ -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(
Expand All @@ -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);
}
Comment on lines 70 to +80

Copy link
Copy Markdown

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:

#!/bin/bash
# Description: findDetailBookByIsbn과 findPageCountByIsbn이 동일 흐름에서 함께 호출되는지 확인
rg -n -C 5 'findDetailBookByIsbn|findPageCountByIsbn|findDetailBookWithPageCountByIsbn|loadBookWithPageByIsbn' --type=java

Repository: THIP-TextHip/THIP-Server

Length of output: 162


🏁 Script executed:

#!/bin/bash
set -e

echo "== repo files matching relevant names =="
git ls-files | rg '(^|/)(AladinApiUtil|.*ApiAdapter|.*Book.*(Service|Controller|Repository)|.*Detail|.*Page).*\.java$|book|aladin|api)' | head -200

echo
echo "== Java method definitions/names containing relevant terms =="
rg -n -C 3 'getPageCount|getBookDetail|getBookDetailWithPageCount|fetchDetail|ItemLookUp|AladinDetailResult|BookDetailResult|pageCount|findDetail|findPage|aloadBook|loadBook' --type=java . | head -300

echo
echo "== AladinApiUtil snippet =="
cat -n src/main/java/konkuk/thip/book/adapter/out/api/aladin/AladinApiUtil.java | sed -n '1,140p'

echo
echo "== call sites for AladinApiUtil methods (text search) =="
rg -n -C 4 'aladin(\.|)(get|find|getPage|getBook)::*|AladinApiUtil|getPageCount\(|getBookDetail\(|getBookDetailWithPageCount\(' --type=java .

Repository: THIP-TextHip/THIP-Server

Length of output: 50380


🏁 Script executed:

#!/bin/bash
set -e

echo "== BookApiQueryPort =="
cat -n src/main/java/konkuk/thip/book/application/port/out/BookApiQueryPort.java | sed -n '1,140p'

echo
echo "== CompositeBookApiAdapter =="
cat -n src/main/java/konkuk/thip/book/adapter/out/api/CompositeBookApiAdapter.java | sed -n '1,120p'

echo
echo "== AladinApiClient =="
cat -n src/main/java/konkuk/thip/book/adapter/out/api/aladin/AladinApiClient.java | sed -n '1,80p'

echo
echo "== Direct invocations of BookApiQueryPort query methods =="
rg -n -C 4 '\b(\b(BookApiQueryPort|#|\.))?\s*(findDetailBookByIsbn|findPageCountByIsbn|loadBookWithPageByIsbn)\(' --type=java .

Repository: THIP-TextHip/THIP-Server

Length of output: 17701


한 번 호출한 상세 정보는 페이지 수 조회에 재사용하세요.

BookSearchService, BookMostSearchRankServicefindDetailBookByIsbn으로 상세정보만 조회합니다. 이후 이 같은 흐름에서 findPageCountByIsbn을 또 호출하면 같은 ISBN으로 CompositeBookApiAdapterfetchDetail을 두 번 호출해서 알라딘 ItemLookUp 요청이 중복됩니다. 해당 경로에서는 loadBookWithPageByIsbn을 사용하거나, 상세정보를 캐시/전달해 중복 호출을 막아야 합니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/konkuk/thip/book/adapter/out/api/aladin/AladinApiUtil.java`
around lines 70 - 80, Update the flows in BookSearchService and
BookMostSearchRankService so page-count retrieval reuses the detail data already
loaded by findDetailBookByIsbn instead of calling findPageCountByIsbn and
triggering fetchDetail twice. Prefer the existing loadBookWithPageByIsbn path,
or pass/cache the fetched AladinDetailResult through the flow while preserving
the current result behavior.


private AladinDetailResult fetchDetail(String isbn) {
String url = buildLookupUrl(isbn);
String response = restTemplate.getForObject(url, String.class);

Expand All @@ -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

Copy link
Copy Markdown

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

🌐 Web query:

알라딘 API author 필드 지은이 옮긴이 형식

💡 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 -S

Repository: THIP-TextHip/THIP-Server

Length of output: 13611


알라딘 author 필드 정규화하거나 역할별 필드 사용하세요.

알라딘의 author는 지은이/옮긴이 정보가 하나의 문자열로 넘어올 수 있으므로, AladinApiUtil.java의 조회 및 검색 저자 매핑에 그대로 저장하면 저장·표시되는 저자 형식이 불일치할 수 있습니다. 필요 저자만 추출하도록 파싱하거나, API 문서의 역할 구분 필드는 다른 필드로 매핑하도록 변경하세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/konkuk/thip/book/adapter/out/api/aladin/AladinApiUtil.java`
around lines 102 - 109, Update the author mapping in the BookDetailResult
construction within AladinApiUtil so Aladin’s combined author string is
normalized to the required author role, or use the API’s role-specific field
when available. Apply the same rule consistently to both lookup and search
result mappings, preserving the other detail fields unchanged.


return new AladinDetailResult(detail, pageCount);
} catch (IOException e) {
throw new ExternalApiException(BOOK_ALADIN_API_PARSING_ERROR);
}
}
Comment on lines 90 to 115

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

subInfo/itemPage 누락 시 pageCount가 조용히 0으로 저장됩니다.

코드 내 TODO 주석은 "알라딘으로부터 page 정보가 없으면" 사용자에게 안내하거나 예외를 던지는 보상 시나리오를 계획하고 있다고 설명합니다. 그러나 실제 구현은 items가 배열이 아니거나 비어 있는 경우(ISBN 자체를 찾지 못한 경우)만 예외를 던집니다. item은 존재하지만 subInfo.itemPage가 없는 경우, subInfo.path(PAGE_COUNT_PARSING_KEY.getValue()).asInt()는 조용히 0을 반환하고, 이 값이 그대로 pageCount로 반환되어 Book에 저장됩니다.

TODO 주석이 명시한 의도와 실제 동작이 다릅니다. pageCount가 0으로 저장된 책은 이후 진행률 계산 등에서 예상치 못한 결과를 낼 수 있습니다. itemPage 존재 여부를 확인하고, 없을 경우 명시적으로 처리(null 허용, 별도 플래그, 또는 예외)하는 것을 권장합니다.

이 TODO 주석에서 계획한 보상 시나리오(사용자 안내 또는 예외 처리)를 구현하는 코드를 생성해 드릴까요?

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/konkuk/thip/book/adapter/out/api/aladin/AladinApiUtil.java`
around lines 90 - 115, Update the Aladin response parsing around subInfo and
PAGE_COUNT_PARSING_KEY so a missing itemPage is not converted to pageCount 0 by
asInt(). Explicitly detect the absent page value and apply the intended
compensation behavior, such as throwing the existing ExternalApiException, while
preserving normal parsing when itemPage is present.

}

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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import lombok.Builder;

@Builder
public record NaverDetailBookParseResult(
public record BookDetailResult(
String title,
String imageUrl,
String author,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,22 @@
import java.util.List;


public record NaverBookParseResult(
List<NaverBook> naverBooks,
public record BookSearchResult(
List<BookSummary> books,
int total,
int start) {

public static final int PAGE_SIZE = 10;

@Builder
public record NaverBook(
public record BookSummary(
String title,
String imageUrl,
String author,
String publisher,
String isbn
) {}
public static NaverBookParseResult of(List<NaverBook> books, int total, int start) {
return new NaverBookParseResult(books, total, start);
public static BookSearchResult of(List<BookSummary> books, int total, int start) {
return new BookSearchResult(books, total, start);
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package konkuk.thip.book.adapter.out.api.naver;

import konkuk.thip.book.adapter.out.api.dto.NaverBookParseResult;
import konkuk.thip.book.adapter.out.api.dto.NaverDetailBookParseResult;
import konkuk.thip.book.adapter.out.api.dto.BookSearchResult;
import konkuk.thip.book.adapter.out.api.dto.BookDetailResult;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

Expand All @@ -11,12 +11,12 @@ public class NaverApiClient {

private final NaverApiUtil naverApiUtil;

public NaverBookParseResult findBooksByKeyword(String keyword, int start) {
public BookSearchResult findBooksByKeyword(String keyword, int start) {
String xml = naverApiUtil.searchBook(keyword, start); // 네이버 API 호출
return NaverBookXmlParser.parseBookList(xml); // XML 파싱 + 페이징 정보 포함
}

public NaverDetailBookParseResult findDetailBookByIsbn(String isbn) {
public BookDetailResult findDetailBookByIsbn(String isbn) {
String xml = naverApiUtil.detailSearchBook(isbn); // 네이버 API 호출
return NaverBookXmlParser.parseBookDetail(xml); // XML 파싱
}
Expand Down
Loading
Loading