Skip to content
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

책을 검색한다. #83

Merged
merged 18 commits into from
Feb 10, 2025
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
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
1 change: 1 addition & 0 deletions api/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ jar {

dependencies {
implementation project(':core')
implementation project(':clients')

implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.dnd.sbooky.api.book;

import com.dnd.sbooky.api.book.response.SearchBookResponse;
import com.dnd.sbooky.api.docs.spec.SearchBookApiSpec;
import com.dnd.sbooky.api.support.response.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class SearchBookController implements SearchBookApiSpec {

private final SearchBookUseCase searchBookUseCase;

@GetMapping("/books")
public ApiResponse<SearchBookResponse> searchBook(
@RequestParam(required = true) String query,
@RequestParam(defaultValue = "accuracy", required = false) String sort,
@RequestParam(defaultValue = "1", required = false) int page,
@RequestParam(defaultValue = "10", required = false) int size,
@RequestParam(required = false) String target) {

// todo: 무작위한 검색을 막기 위해 사용자 검증이 필요할까?

return ApiResponse.success(searchBookUseCase.search(query, sort, size, page, target));
}
}
17 changes: 17 additions & 0 deletions api/src/main/java/com/dnd/sbooky/api/book/SearchBookUseCase.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.dnd.sbooky.api.book;

import com.dnd.sbooky.api.book.response.SearchBookResponse;
import com.dnd.sbooky.clients.kakao.KakaoApiClient;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class SearchBookUseCase {

private final KakaoApiClient kakaoApiClient;

public SearchBookResponse search(String query, String sort, int size, int page, String target) {
return SearchBookResponse.from(kakaoApiClient.searchBooks(query, sort, page, size, target));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.dnd.sbooky.api.book.response;

import com.dnd.sbooky.clients.kakao.response.KakaoSearchBookResponseDTO;
import com.dnd.sbooky.clients.kakao.response.KakaoSearchBookResponseDTO.Meta;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDate;
import java.util.List;

@Schema(name = "SearchBookResponse", description = "책 검색 결과")
public record SearchBookResponse(
// spotless:off
@Schema(name = "책 목록", description = "검색된 책 목록")
List<Book> books,

@Schema(name = "페이지 정보", description = "페이지 정보")
PageInfo pageInfo) {

public static SearchBookResponse from(KakaoSearchBookResponseDTO dto) {
List<Book> books = dto.documents().stream()
.map(document -> new Book(
document.title(),
document.authors(),
document.datetime() == null
? LocalDate.EPOCH : document.datetime().toLocalDate(),
document.extractThumbnailFileName())
).toList();

Meta meta = dto.meta();
PageInfo pageInfo = new PageInfo(meta.is_end(), meta.pageable_count(), meta.total_count());

return new SearchBookResponse(books, pageInfo);
}

public record Book(
@Schema(name = "책 제목") String title,
@Schema(name = "저자 리스트") List<String> authors,
@Schema(name = "출판일") LocalDate publishedAt,
@Schema(name = "썸네일 URL") String thumbnail) {

}

public record PageInfo(
@Schema(name = "마지막 페이지") boolean isEnd,
@Schema(name = "중복된 책 제외 노출 가능 책 수") int pageableCount,
@Schema(name = "검색된 책의 수") int totalCount) {}
// spotless:on

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.dnd.sbooky.api.docs.spec;

import com.dnd.sbooky.api.book.response.SearchBookResponse;
import com.dnd.sbooky.api.support.response.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import org.hibernate.validator.constraints.Range;

@Tag(name = "[Book API]", description = "책에 관련된 API")
public interface SearchBookApiSpec {

// spotless:off
@Operation(summary = "책 검색", description = "책을 검색한다.")
ApiResponse<SearchBookResponse> searchBook(
@NotBlank(message = "검색어를 입력해주세요.")
String query,

@Pattern(regexp = "^(accuracy|latest)$",
message = "정렬 방식은 accuracy 또는 latest 중 하나여야 합니다.")
String sort,

@Range(min = 1, max = 50,
message = "페이지는 1 ~ 50 사이의 값이어야 합니다.")
int page,

@Range(min = 1, max = 50,
message = "한 페이지당 문서 수는 1 ~ 50 사이의 값이어야 합니다.")
int size,

@Pattern(regexp = "^(title|isbn|publisher|person)$",
message = "검색 필드는 title, isbn, publisher, person 중 하나여야 합니다.")
String target
);
// spotless:on

}
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
import com.dnd.sbooky.api.support.response.ApiResponse;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.MessageSourceResolvable;
import org.springframework.context.support.DefaultMessageSourceResolvable;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingRequestCookieException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;

@Slf4j
Expand All @@ -31,7 +33,6 @@ public ResponseEntity<ApiResponse<?>> handleApiException(ApiException e) {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<?>> handleMethodArgumentNotValidException(
MethodArgumentNotValidException e) {
log.info("MethodArgumentNotValidException = {}", e.getMessage());

List<String> errorMessages =
e.getBindingResult().getFieldErrors().stream()
Expand Down Expand Up @@ -73,6 +74,17 @@ public ResponseEntity<ApiResponse<?>> handleMissingServletRequestParameterExcept
.body(ApiResponse.error(ErrorType.INVALID_PARAMETER));
}

@ExceptionHandler(HandlerMethodValidationException.class)
public ResponseEntity<ApiResponse<?>> handleHandlerMethodValidationException(
HandlerMethodValidationException e) {

List<String> errors =
e.getAllErrors().stream().map(MessageSourceResolvable::getDefaultMessage).toList();

return ResponseEntity.status(ErrorType.INVALID_PARAMETER.getStatus())
.body(ApiResponse.error(ErrorType.INVALID_PARAMETER, errors));
}

@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<?>> handleException(Exception e) {
log.error("Exception = {}", e.getMessage(), e);
Expand Down
1 change: 1 addition & 0 deletions api/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ spring:
- "core.yml"
- "swagger-yml"
- "security.yml"
- "feign.yml"


---
Expand Down
7 changes: 7 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ subprojects {
bootJar.enabled = false // 실행가능한 jar
jar.enabled = true // plain jar

dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudDependenciesVersion}"
}
}

dependencies {
// lombok
compileOnly 'org.projectlombok:lombok' // 컴파일 시점에만 사용
Expand Down Expand Up @@ -58,6 +64,7 @@ subprojects {
.reorderImports(false) // import문 재정렬 비활성화
.groupArtifact('com.google.googlejavaformat:google-java-format') // 사용할 구글 포맷터 버전 지정

toggleOffOn() // 코드 포맷팅 on/off 설정
indentWithTabs(2) // 탭을 사용한 들여쓰기 설정 (탭 크기 2)
indentWithSpaces(4) // 공백을 사용한 들여쓰기 설정 (공백 4칸)
importOrder() // import문 순서 설정 (기본값 사용)
Expand Down
5 changes: 4 additions & 1 deletion clients/build.gradle
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
dependencies {
implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
implementation 'io.github.openfeign:feign-hc5'
implementation 'io.github.openfeign:feign-micrometer'
Copy link
Collaborator

Choose a reason for hiding this comment

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

해당 의존성들은 어디에서 사용되는지 궁금합니다.

Copy link
Member Author

@f1v3-dev f1v3-dev Feb 10, 2025

Choose a reason for hiding this comment

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

엇 의존성 찾아보면서 추가하다가 지워야되는걸 까먹었네요 😅

그런김에 찾아봤어요!

1. Apache HttpClient 5

implementation 'io.github.openfeign:feign-hc5'

이 의존성은 Feign에서 Apache HttpClient 5를 사용하는 의존성인거 같아요.

관련해서 찾아보니 토스에 이런 글이 있더라구요 (https://toss.tech/article/engineering-note-3)

요약하자면, Feign 내부적으로 기본 HTTP Client로 HttpURLConnection 을 사용하고 있어 동시성 문제가 발생할 수 있다고 하는데, JDK17 버전을 쓰고있는 상황에서는 이 문제가 해결되어서 괜찮다고 하네요 !

2. Micrometer

implementation 'io.github.openfeign:feign-micrometer'

이 의존성은 Feign 클라이언트의 메트릭을 수집하고 모니터링을 쉽게 적용할 수 있게 해주는 의존성이라고 해요.

Spring Boot Actuator와 함께 사용할 경우에 자동으로 애플리케이션 메트릭을 수집하고 보여준다고 하는데, 필요한 시점에 도입하는게 좋아보기인 합니다


두 의존성 모두 필요한 시점에 도입하는게 좋을 것 같아요! 의존성 제외하도록 하겠습니다 ~


}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.dnd.sbooky.clients.config;

import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.context.annotation.Configuration;

@Configuration
@EnableFeignClients(basePackages = "com.dnd.sbooky.clients")
class FeignClientConfig {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.dnd.sbooky.clients.config;

import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;

@Getter
@Setter
@Configuration
@ConfigurationProperties(prefix = "kakao.api")
public class KakaoProperties {

private String authorization;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.dnd.sbooky.clients.kakao;

import com.dnd.sbooky.clients.kakao.response.KakaoSearchBookResponseDTO;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;

@FeignClient(
name = "kakao-book-api",
url = "https://dapi.kakao.com/v3",
configuration = KakaoFeignClientConfig.class)
public interface KakaoApiClient {

/**
* 카카오 책 검색 API
*
* @param query 검색어 (필수)
* @param sort 정렬 방식 (accuracy, recency) - 기본값 accuracy
* @param page 결과 페이지 번호 (1 ~ 50) - 기본값 1
* @param size 한 페이지에 보여질 문서의 개수 (1 ~ 50) - 기본값 10
* @param target 검색 필드 (title, isbn, publisher, person) - 기본값 X
*/
@GetMapping("/search/book")
KakaoSearchBookResponseDTO searchBooks(
@RequestParam(value = "query", required = true) String query,
@RequestParam(value = "sort", defaultValue = "accuracy", required = false) String sort,
@RequestParam(value = "page", defaultValue = "1", required = false) int page,
@RequestParam(value = "size", defaultValue = "10", required = false) int size,
@RequestParam(value = "target", required = false) String target);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.dnd.sbooky.clients.kakao;

import com.dnd.sbooky.clients.config.KakaoProperties;
import feign.RequestInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;

@RequiredArgsConstructor
public class KakaoFeignClientConfig {

private final KakaoProperties kakaoProperties;

/**
* Kakao API 호출 시 Authorization Header 추가하는 Interceptor
*/
@Bean
public RequestInterceptor authorizationInterceptor() {
return requestTemplate ->
requestTemplate.header("Authorization", "KakaoAK " + kakaoProperties.getAuthorization());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.dnd.sbooky.clients.kakao.response;

import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.time.OffsetDateTime;
import java.util.List;
import org.apache.logging.log4j.util.Strings;

public record KakaoSearchBookResponseDTO(List<Document> documents, Meta meta) {

public record Document(
String title,
String contents,
String isbn,
String publisher,
List<String> authors,
String thumbnail,
OffsetDateTime datetime,
Copy link
Collaborator

Choose a reason for hiding this comment

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

저희 Time도 필요한가요??

Copy link
Member Author

Choose a reason for hiding this comment

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

여기의 datetime = 출판일을 뜻하는거에요!

아래 사진처럼 검색 결과에서 출판일이 필요해요

image

Copy link
Collaborator

@hwangdaesun hwangdaesun Feb 10, 2025

Choose a reason for hiding this comment

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

제가 말하고자 한 것은 OffsetDateTime 클래스가 LocalDateTime에 Offset을 추가한 것인데, 여기서 LocalDate는 필요하지만 LocalTime도 필요한지가 궁금하다는 의미였습니다.

Copy link
Member Author

Choose a reason for hiding this comment

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

해당 KakaoSearchResponseDTO 자체는 단순히 카카오에서 넘겨주는 정보들을 매핑하는 형태의 DTO로서 클라이언트에게 응답할 때는 api 모듈의 SearchBookResponse 에서 필요한 값과 형태로만 빼오는 방식으로 구성을 했어요

[카카오 응답] "datetime": "2014-11-17T00:00:00.000+09:00",

만약, 요구사항이 변경되어서 필요한 값이 추가된다면 단순히 api 모듈 내의 Reponse 객체만 수정하면 되도록 구성하는게 더 낫지 않을까? 라고 생각을 했습니다

Copy link
Collaborator

Choose a reason for hiding this comment

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

아 확인했습니다!

int price,
int sale_price,
String status,
String url,
List<String> translators) {

/**
* 썸네일 이미지 추출 코드
*/
public String extractThumbnailFileName() {

if (Strings.isBlank(thumbnail)) {
return "";
}

try {
String fileName = thumbnail.substring(thumbnail.indexOf("fname=") + 6);
return URLDecoder.decode(fileName, StandardCharsets.UTF_8);
} catch (Exception e) {
return thumbnail;
}
}
}

public record Meta(boolean is_end, int pageable_count, int total_count) {}
}
3 changes: 3 additions & 0 deletions clients/src/main/resources/feign.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kakao:
api:
authorization: ${KAKAO_CLIENT_ID}
1 change: 1 addition & 0 deletions gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ javaVersion=17
### Spring Dependencies ###
springBootVersion=3.3.7
springDependencyManagementVersion=1.1.7
springCloudDependenciesVersion=2023.0.5