Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
0d4f66e
[feat] BookApiAdapter 구현 (#32)
hd0rable Jun 27, 2025
23c9591
[feat] BookDto 작성 (#32)
hd0rable Jun 27, 2025
a785e94
[feat] BookQueryController 구현 (#32)
hd0rable Jun 27, 2025
ae8d7ff
[feat] BookSearchService 구현 (#32)
hd0rable Jun 27, 2025
30972bb
[remove] 더미 파일 삭제 (#32)
hd0rable Jun 27, 2025
30f235d
[remove] BookSearchUseCase 구현(#32)
hd0rable Jun 27, 2025
54df077
[refactor] BusinessException 에러메세지 리펙 (#32)
hd0rable Jun 27, 2025
48b213f
[feat] book 도메인 관련 에러코드 추가 (#32)
hd0rable Jun 27, 2025
546260b
[refactor] 오타 수정 (#32)
hd0rable Jun 27, 2025
683c964
[feat] GetBookSearchListResponse dto 작성 (#32)
hd0rable Jun 27, 2025
7a08730
[feat] NaverApiUtil 구현 (#32)
hd0rable Jun 27, 2025
5f53721
[feat] NaverBookParseResult 작성 (#32)
hd0rable Jun 27, 2025
c677a30
[feat] NaverBookXmlParser 작성 (#32)
hd0rable Jun 27, 2025
246d03f
[feat] SearchBookQueryPort 작성 (#32)
hd0rable Jun 27, 2025
fd16a79
[test] BookQueryControllerTest 테스트 코드 작성 (#32)
hd0rable Jun 27, 2025
02a8d41
[test] NaverApiUtilTest 테스트 코드 작성 (#32)
hd0rable Jun 27, 2025
f12ecc4
[refactor] NaverApiUtil 위치 변경 (#32)
hd0rable Jun 29, 2025
2a3b2a8
[refactor] NaverApiUtil 위치 변경 및 검색 url 환경변수 설정(#32)
hd0rable Jun 29, 2025
41e2ec4
[refactor] 테스트코드 수정 (#32)
hd0rable Jun 29, 2025
1864fc3
[refactor] BookDto이너클래스로 수정및 컨트롤러,서비스 response값 분리 (#32)
hd0rable Jun 29, 2025
4536fdc
[refactor] 서비스 로직 수정 (#32)
hd0rable Jun 29, 2025
3ddd511
[refactor] 컨트롤러 로직 수정 (#32)
hd0rable Jun 29, 2025
f7a5ce8
[refactor] UseCase 반환값 분리 (#32)
hd0rable Jun 29, 2025
0e752fe
[refactor] NaverBookParseResult 반환값 NaverBook로 수정 (#32)
hd0rable Jun 29, 2025
7f884da
[refactor] NaverBookParseResult 반환값 NaverBook로 수정 (#32)
hd0rable Jun 29, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,10 +1,23 @@
package konkuk.thip.book.adapter.in.web;

import konkuk.thip.book.adapter.in.web.response.GetBookSearchListResponse;
import konkuk.thip.book.adapter.out.api.dto.NaverBookParseResult;
import konkuk.thip.book.application.port.in.BookSearchUseCase;
import konkuk.thip.common.dto.BaseResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.*;

@RestController
@RequiredArgsConstructor
public class BookQueryController {

private final BookSearchUseCase bookSearchUseCase;

@GetMapping("/books")
public BaseResponse<GetBookSearchListResponse> getBookSearchList(@RequestParam final String keyword,
@RequestParam final int page) {
NaverBookParseResult result = bookSearchUseCase.searchBooks(keyword, page);
return BaseResponse.ok(GetBookSearchListResponse.of(result, page));
}

}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package konkuk.thip.book.adapter.in.web.response;

import konkuk.thip.book.adapter.out.api.dto.NaverBookParseResult;
import lombok.Builder;

import java.util.List;

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

@Builder
public record GetBookSearchListResponse(
List<BookDto> searchResult, // 책 목록
int page, // 현재 페이지 (1부터 시작)
int size, // 한 페이지에 포함되는 데이터 수 (페이지 크기)
Copy link
Collaborator

Choose a reason for hiding this comment

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

LGTM

long totalElements, // 전체 데이터 개수
int totalPages, // 전체 페이지 수
boolean last, // 마지막 페이지 여부
boolean first // 첫 페이지 여부
) {
public static GetBookSearchListResponse of(NaverBookParseResult result, int page) {
int totalElements = result.total();
int totalPages = (int) Math.ceil((double) totalElements / PAGE_SIZE);
boolean last = (page >= totalPages);
boolean first = (page == 1);
Comment on lines +20 to +24
Copy link

@coderabbitai coderabbitai bot Jun 29, 2025

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

페이지네이션 로직에서 엣지 케이스 처리가 필요합니다.

다음과 같은 잠재적 문제들이 있습니다:

  • totalElements가 0일 때 totalPages도 0이 되어 last 계산에 오류가 발생할 수 있습니다
  • page 매개변수에 대한 유효성 검증이 없습니다 (음수나 0값 처리)

다음과 같이 개선할 수 있습니다:

 public static GetBookSearchListResponse of(NaverBookParseResult result, int page) {
+    if (page <= 0) {
+        throw new IllegalArgumentException("페이지 번호는 1 이상이어야 합니다");
+    }
+    
     int totalElements = result.total();
-    int totalPages = (int) Math.ceil((double) totalElements / PAGE_SIZE);
-    boolean last = (page >= totalPages);
+    int totalPages = totalElements == 0 ? 1 : (int) Math.ceil((double) totalElements / PAGE_SIZE);
+    boolean last = (page >= totalPages);
     boolean first = (page == 1);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public static GetBookSearchListResponse of(NaverBookParseResult result, int page) {
int totalElements = result.total();
int totalPages = (int) Math.ceil((double) totalElements / PAGE_SIZE);
boolean last = (page >= totalPages);
boolean first = (page == 1);
public static GetBookSearchListResponse of(NaverBookParseResult result, int page) {
if (page <= 0) {
throw new IllegalArgumentException("페이지 번호는 1 이상이어야 합니다");
}
int totalElements = result.total();
int totalPages = totalElements == 0
? 1
: (int) Math.ceil((double) totalElements / PAGE_SIZE);
boolean last = (page >= totalPages);
boolean first = (page == 1);
// …rest of method…
}
🤖 Prompt for AI Agents
In
src/main/java/konkuk/thip/book/adapter/in/web/response/GetBookSearchListResponse.java
around lines 20 to 24, the pagination logic does not handle edge cases properly.
Fix this by adding validation for the page parameter to ensure it is at least 1,
and adjust the calculation of totalPages so that when totalElements is 0,
totalPages is set to 1 to avoid incorrect last page determination. Update the
last and first boolean calculations accordingly to reflect these changes.

Copy link
Collaborator

Choose a reason for hiding this comment

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

검색 keyword에 대한 totalElements 값이 0일 경우, totalPages 또한 0이 되는데, 이 경우 response dto는

searchResult = emptyList, page = 1, size = 10, totalElements = 0, totalPages = 0, last = true, first = true

이므로 문제없을 것 같습니다

Copy link

Choose a reason for hiding this comment

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

@seongjunnoh 맞는 말씀입니다! totalElements = 0일 때의 응답 구조를 정확히 분석해주셨네요.

totalPages = 0으로 설정하는 것은 수학적으로 올바르고, 응답 DTO의 각 필드들이 일관성 있게 설정됩니다:

  • last = true (1페이지가 0페이지보다 크거나 같으므로 마지막 페이지)
  • first = true (1페이지이므로 첫 페이지)

이는 유효한 설계 선택입니다. 빈 결과에 대해 totalPages = 0을 사용하는 시스템과 totalPages = 1을 사용하는 시스템이 모두 존재하며, 현재 구현은 수학적으로 정확합니다.

다만 제가 언급한 page 매개변수의 음수/0 값에 대한 유효성 검증 부분은 여전히 고려해볼 만한 것 같습니다. 하지만 이미 BookSearchService에서 검증하고 있다면 중복 검증일 수도 있겠네요.

🐰
╭─────╮
│ 분석 │
│ 완료 │
╰─────╯


List<BookDto> bookDtos = result.naverBooks().stream()
.map(BookDto::of)
.toList();

return new GetBookSearchListResponse(
bookDtos,
page,
PAGE_SIZE,
totalElements,
totalPages,
last,
first
);
}
public record BookDto(
String title,
String imageUrl,
String authorName,
String publisher,
String isbn
) {
public static BookDto of(NaverBookParseResult.NaverBook naverBook) {
return new BookDto(
naverBook.title(),
naverBook.imageUrl(),
naverBook.author(),
naverBook.publisher(),
naverBook.isbn()
);
}
}
}
19 changes: 19 additions & 0 deletions src/main/java/konkuk/thip/book/adapter/out/api/BookApiAdapter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package konkuk.thip.book.adapter.out.api;

import konkuk.thip.book.adapter.out.api.dto.NaverBookParseResult;
import konkuk.thip.book.application.port.out.SearchBookQueryPort;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class BookApiAdapter implements SearchBookQueryPort {

private final NaverApiUtil naverApiUtil;

@Override
public NaverBookParseResult findBooksByKeyword(String keyword, int start) {
String xml = naverApiUtil.searchBook(keyword, start); // 네이버 API 호출
return NaverBookXmlParser.parse(xml); // XML 파싱 + 페이징 정보 포함
}
Comment on lines +15 to +18
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

입력 매개변수 검증과 예외 처리 개선이 필요합니다.

현재 구현에서 다음 사항들을 고려해보세요:

  1. keyword 매개변수가 null이거나 빈 문자열일 경우에 대한 검증
  2. start 매개변수가 음수일 경우에 대한 검증
  3. naverApiUtil.searchBook() 호출 시 발생할 수 있는 예외에 대한 명시적 처리
@Override
public NaverBookParseResult findBooksByKeyword(String keyword, int start) {
+   if (keyword == null || keyword.trim().isEmpty()) {
+       throw new IllegalArgumentException("검색 키워드는 필수입니다.");
+   }
+   if (start < 1) {
+       throw new IllegalArgumentException("시작 인덱스는 1 이상이어야 합니다.");
+   }
+   
    String xml = naverApiUtil.searchBook(keyword, start); // 네이버 API 호출
    return NaverBookXmlParser.parse(xml);                // XML 파싱 + 페이징 정보 포함
}
🤖 Prompt for AI Agents
In src/main/java/konkuk/thip/book/adapter/out/api/BookApiAdapter.java around
lines 17 to 20, add input validation to check if the keyword is null or empty
and if start is negative, throwing appropriate exceptions if invalid. Also, wrap
the call to naverApiUtil.searchBook(keyword, start) in a try-catch block to
explicitly handle any exceptions it may throw, and handle or rethrow them with
meaningful messages.

}
108 changes: 108 additions & 0 deletions src/main/java/konkuk/thip/book/adapter/out/api/NaverApiUtil.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package konkuk.thip.book.adapter.out.api;

import konkuk.thip.common.exception.BusinessException;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.io.*;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLEncoder;
import java.util.HashMap;
import java.util.Map;

import static konkuk.thip.common.exception.code.ErrorCode.*;

@RequiredArgsConstructor
@Component
public class NaverApiUtil {

@Value("${naver.clientId}")
private String clientId;
@Value("${naver.clientSecret}")
private String clientSecret;
@Value("${naver.bookSearchUrl}")
private String bookSearchUrl;

public static final int PAGE_SIZE = 10;

public String searchBook(String keyword, int start){
String query = keywordToEncoding(keyword);
String url = buildSearchApiUrl(query, start);

Map<String, String> requestHeaders = new HashMap<>();
requestHeaders.put("X-Naver-Client-Id", clientId);
requestHeaders.put("X-Naver-Client-Secret", clientSecret);

return get(url,requestHeaders);
}

private String buildSearchApiUrl(String query,Integer start) {
return bookSearchUrl+query+"&display="+PAGE_SIZE+"&start="+start;
Copy link
Collaborator

Choose a reason for hiding this comment

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

LGTM

}

private String keywordToEncoding(String keyword) {
String text = null;
try {
text = URLEncoder.encode(keyword, "UTF-8");
} catch (UnsupportedEncodingException e) {
throw new BusinessException(BOOK_KEYWORD_ENCODING_FAILED);
}
return text;
}


String get(String apiUrl, Map<String, String> requestHeaders){
HttpURLConnection con = connect(apiUrl);
try {
con.setRequestMethod("GET");
for(Map.Entry<String, String> header :requestHeaders.entrySet()) {
con.setRequestProperty(header.getKey(), header.getValue());
}

int responseCode = con.getResponseCode();
if (responseCode == HttpURLConnection.HTTP_OK) { // 정상 호출
return readBody(con.getInputStream());
} else { // 오류 발생
return readBody(con.getErrorStream());
}
Comment on lines +66 to +70
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

HTTP 에러 응답 처리 방식을 개선해주세요.

현재 HTTP 에러 상태 코드를 받았을 때 에러 스트림의 내용을 그대로 반환하고 있습니다. 이는 상위 계층에서 에러 응답을 정상 응답으로 오해할 수 있습니다.

int responseCode = con.getResponseCode();
if (responseCode == HttpURLConnection.HTTP_OK) { // 정상 호출
    return readBody(con.getInputStream());
} else { // 오류 발생
-    return readBody(con.getErrorStream());
+    String errorBody = readBody(con.getErrorStream());
+    throw new BusinessException(BOOK_NAVER_API_REQUEST_ERROR);
}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (responseCode == HttpURLConnection.HTTP_OK) { // 정상 호출
return readBody(con.getInputStream());
} else { // 오류 발생
return readBody(con.getErrorStream());
}
int responseCode = con.getResponseCode();
if (responseCode == HttpURLConnection.HTTP_OK) { // 정상 호출
return readBody(con.getInputStream());
} else { // 오류 발생
String errorBody = readBody(con.getErrorStream());
throw new BusinessException(BOOK_NAVER_API_REQUEST_ERROR);
}
🤖 Prompt for AI Agents
In src/main/java/konkuk/thip/book/adapter/out/api/NaverApiUtil.java lines 66 to
70, the method returns the error stream content as a normal response when the
HTTP status is not OK, which can cause confusion in upper layers. Modify the
code to detect error responses and throw an exception or return an error
indicator instead of returning the error stream content directly, so that error
handling can be properly distinguished from successful responses.

} catch (IOException e) {
throw new BusinessException(BOOK_NAVER_API_REQUEST_ERROR);
} finally {
con.disconnect();
}
}


private HttpURLConnection connect(String apiUrl){
try {
URL url = new URL(apiUrl);
return (HttpURLConnection)url.openConnection();
} catch (MalformedURLException e) {
throw new BusinessException(BOOK_NAVER_API_URL_ERROR);
} catch (IOException e) {
throw new BusinessException(BOOK_NAVER_API_URL_HTTP_CONNECT_FAILED);
}
}


private String readBody(InputStream body){
InputStreamReader streamReader = new InputStreamReader(body);

try (BufferedReader lineReader = new BufferedReader(streamReader)) {
StringBuilder responseBody = new StringBuilder();

String line;
while ((line = lineReader.readLine()) != null) {
responseBody.append(line);
}

return responseBody.toString();
} catch (IOException e) {
throw new BusinessException(BOOK_NAVER_API_RESPONSE_ERROR);
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package konkuk.thip.book.adapter.out.api;

import konkuk.thip.book.adapter.out.api.dto.NaverBookParseResult;
import konkuk.thip.common.exception.BusinessException;
import konkuk.thip.common.exception.code.ErrorCode;
import org.w3c.dom.*;
import javax.xml.parsers.*;
import java.io.StringReader;
import java.util.ArrayList;
import java.util.List;
import org.xml.sax.InputSource;

public class NaverBookXmlParser {

public static NaverBookParseResult parse(String xml) {
List<NaverBookParseResult.NaverBook> Naverbooks = new ArrayList<>();
int total = -1;
int start = -1;
try {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
DocumentBuilder builder = factory.newDocumentBuilder();
InputSource is = new InputSource(new StringReader(xml));
Document doc = builder.parse(is);

NodeList channelNodes = doc.getElementsByTagName("channel");
if (channelNodes.getLength() > 0) {
Element channel = (Element) channelNodes.item(0);
total = Integer.parseInt(getTagValue(channel, "total"));
start = Integer.parseInt(getTagValue(channel, "start"));

NodeList itemNodes = channel.getElementsByTagName("item");
for (int i = 0; i < itemNodes.getLength(); i++) {
Element item = (Element) itemNodes.item(i);
String title = getTagValue(item, "title");
String imageUrl = getTagValue(item, "image");
String author = getTagValue(item, "author");
String publisher = getTagValue(item, "publisher");
String isbn = getTagValue(item, "isbn");
NaverBookParseResult.NaverBook naverBook = NaverBookParseResult.NaverBook.builder()
.title(title)
.imageUrl(imageUrl)
.author(author)
.publisher(publisher)
.isbn(isbn)
.build();
Naverbooks.add(naverBook);
}
}
} catch (Exception e) {
throw new BusinessException(ErrorCode.BOOK_NAVER_API_PARSING_ERROR);
}
return NaverBookParseResult.of(Naverbooks, total, start);
}

private static String getTagValue(Element element, String tag) {
NodeList nodeList = element.getElementsByTagName(tag);
if (nodeList.getLength() > 0 && nodeList.item(0).getFirstChild() != null) {
return nodeList.item(0).getFirstChild().getNodeValue();
}
return "";
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package konkuk.thip.book.adapter.out.api.dto;

import lombok.Builder;

import java.util.List;


public record NaverBookParseResult(
List<NaverBook> naverBooks,
int total,
int start) {
@Builder
public record NaverBook(
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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package konkuk.thip.book.application.port.in;

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

public interface BookSearchUseCase {

NaverBookParseResult searchBooks(String keyword, int page);

}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package konkuk.thip.book.application.port.out;

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

public interface SearchBookQueryPort {
NaverBookParseResult findBooksByKeyword(String keyword, int start);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package konkuk.thip.book.application.service;

import konkuk.thip.book.adapter.out.api.dto.NaverBookParseResult;
import konkuk.thip.book.application.port.in.BookSearchUseCase;
import konkuk.thip.book.application.port.out.SearchBookQueryPort;
import konkuk.thip.common.exception.BusinessException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import static konkuk.thip.book.adapter.out.api.NaverApiUtil.PAGE_SIZE;
import static konkuk.thip.common.exception.code.ErrorCode.*;

@Service
@RequiredArgsConstructor
public class BookSearchService implements BookSearchUseCase {

private final SearchBookQueryPort searchBookQueryPort;

@Override
public NaverBookParseResult searchBooks(String keyword, int page) {

if (keyword == null || keyword.isBlank()) {
throw new BusinessException(BOOK_KEYWORD_REQUIRED);
}

if (page < 1) {
throw new BusinessException(BOOK_PAGE_NUMBER_INVALID);
}

//유저의 최근검색어 로직 추가

int start = (page - 1) * PAGE_SIZE + 1; //검색 시작 위치
NaverBookParseResult result = searchBookQueryPort.findBooksByKeyword(keyword, start);

int totalElements = result.total();
int totalPages = (totalElements + PAGE_SIZE - 1) / PAGE_SIZE;
if ( totalElements!=0 && page > totalPages) {
throw new BusinessException(BOOK_SEARCH_PAGE_OUT_OF_RANGE);
}

return result;
}

}

This file was deleted.

2 changes: 1 addition & 1 deletion src/main/java/konkuk/thip/common/dto/ErrorResponse.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
@JsonPropertyOrder({"success", "code", "message"})
public class ErrorResponse {

@JsonProperty("isSuccess:")
@JsonProperty("isSuccess")
Copy link
Contributor

Choose a reason for hiding this comment

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

👍🏻

private final boolean success;

private final int code;
Expand Down
Loading