ewhk9887/korean_code_reviews_from_github huggingface dataset:
@@ -4,12 +4,23 @@ import { useRouter } from "next/navigation";
 import { useState } from "react";
 import ReviewContent from "./ReviewContent";
 import ArrowDown from "@/assets/images/icons/arrow_down.svg";
+import MenuCircle from "@/assets/images/icons/menu-circle.svg";
+import Dropdown from "@/components/common/Dropdown";
 import { type ReviewInfoProps } from "@/types/review";
+import { type RatingStyle } from "@/types/review";
 
 export default function Review({ reviewInfo }: ReviewInfoProps) {
   const router = useRouter();
   const [isOpen, setIsOpen] = useState(false);
 
+  const reviewTextStyle: RatingStyle = {
+    0: "text-purple-200",
+    1: "text-blue-200",
+    2: "text-orange-200",
+  };
+
+  const ratingArr = ["그냥그래요", "괜찮아요", "추천해요"];
+
   //클릭 시 모임 상세로 이동
   const handleClickReview = () => {
     if (reviewInfo.isMyReview) {
@@ -19,18 +30,58 @@ export default function Review({ reviewInfo }: ReviewInfoProps) {
     return;
   };
 
+  const handleClickModify = () => {
+    alert("리뷰 수정입니다.");
+  };
+
+  const handleClickDelete = () => {
+    alert("리뷰 삭제입니다.");
+  };
+
   const handleClickDetail = (e: React.MouseEvent<HTMLButtonElement>) => {
     e.stopPropagation();
     setIsOpen((prev) => !prev);
   };
   return (
     <div className='rounded-[12px] bg-gray-900 p-4' onClick={handleClickReview}>
       <div className='relative'>
-        <button onClick={handleClickDetail} className='absolute right-0 top-0'>
-          <ArrowDown
-            className={`size-6 text-gray-200 ${isOpen ? "rotate-180" : ""}`}
-          />
-        </button>
+        <div className='flex justify-between'>
+          <span
+            className={`inline-block px-2 py-[3px] text-caption-normal ${reviewTextStyle[reviewInfo.rating]} rounded-[20px] bg-gray-700`}
+          >
+            {ratingArr[reviewInfo.rating]}
+          </span>
+          {reviewInfo.isMyWritten ? (
+            <Dropdown
+              content={[
+                {
+                  label: "수정하기",
+                  value: "modify",
+                  onClick: () => handleClickModify(),
+                },
+                {
+                  label: "삭제하기",
+                  value: "delete",
+                  onClick: () => handleClickDelete(),
+                },
+              ]}
+              isReview={true}
+            >
+              <div className='z-10 cursor-pointer'>
+                <MenuCircle />
+              </div>
+            </Dropdown>
+          ) : (
+            <button
+              onClick={handleClickDetail}
+              className='absolute right-0 top-0'
+            >
+              <ArrowDown
+                className={`size-6 text-gray-200 ${isOpen ? "rotate-180" : ""}`}
+              />
+            </button>
+          )}
+        </div>
         <ReviewContent reviewContent={reviewInfo} isOpen={isOpen} />
       </div>
     </div>

리뷰: 수정, 삭제는 일단 임시로 alert을 띄워놨습니다
@@ -54,11 +54,20 @@ export const REVIEW_GROUP_API_PARAMS = {
   },
 };
 
+export const WRITTEN_REVIEW_PARAMS = {
+  resource: 'reviews/authored',
+  queryString: {
+    lastReviewId: 'lastReviewId',
+    size: 'size',
+  },
+};
+
 export const REVIEW_WRITING_API_URL = `${serverUrl}/${VERSION2}/${REVIEW_WRITING_API_PARAMS.resource}`;
 export const REVIEW_LIST_API_URL = `${serverUrl}/${VERSION2}/${REVIEW_LIST_API_PARAMS.resource}`;
 export const DETAILED_REVIEW_API_URL = `${serverUrl}/${VERSION2}/${DETAILED_REVIEW_API_PARAMS.resource}`;
 export const REVIEW_GROUP_DATA_API_URL = `${serverUrl}/${VERSION2}/${REVIEW_GROUP_DATA_API_PARAMS.resource}`;
 export const REVIEW_GROUP_API_URL = `${serverUrl}/${VERSION2}/reviews/gather`;
+export const WRITTEN_REVIEW_LIST_API_URL = `${serverUrl}/${VERSION2}/${WRITTEN_REVIEW_PARAMS.resource}`;
 
 const endPoint = {
   postingReview: `${serverUrl}/${VERSION2}/reviews`,
@@ -80,6 +89,13 @@ const endPoint = {
   gettingGroupedReviews: (sectionId: number) =>
     `${REVIEW_GROUP_API_URL}?${REVIEW_GROUP_API_PARAMS.queryString.sectionId}=${sectionId}`,
   postingHighlight: `${serverUrl}/${VERSION2}/highlight`,
+
+  gettingWrittenReviewList: (lastReviewId: number | null, size: number) => {
+    const defaultEndpoint = `${WRITTEN_REVIEW_LIST_API_URL}?${WRITTEN_REVIEW_PARAMS.queryString.size}=${size}`;
+
+    if (!lastReviewId) defaultEndpoint;
+    return defaultEndpoint + '&' + `${WRITTEN_REVIEW_PARAMS.queryString.lastReviewId}=${lastReviewId}`;
+  },
 };
 
 export default endPoint;

리뷰: ```suggestion
    if (!lastReviewId) return defaultEndpoint;
    
      return defaultEndpoint + '&' + `${WRITTEN_REVIEW_PARAMS.queryString.lastReviewId}=${lastReviewId}`;

  },
```
이런 구조로 바꾸면 lastReviewId가 없을 때 endpoint 값을 파악하는데 더 쉬울 것 같네요
@@ -6,11 +6,17 @@ import {
   GroupedSection,
   GroupedReviews,
   ReviewInfoData,
+  WrittenReviewList,
 } from '@/types';
 
 import createApiErrorMessage from './apiErrorMessageCreator';
 import endPoint from './endpoints';
 
+export interface GetInfiniteReviewListApiParams {
+  lastReviewId: number | null;
+  size: number;
+}
+
 export const getDataToWriteReviewApi = async (reviewRequestCode: string) => {
   const response = await fetch(endPoint.gettingDataToWriteReview(reviewRequestCode), {
     method: 'GET',
@@ -58,11 +64,11 @@ export const getReviewInfoDataApi = async () => {
   return data as ReviewInfoData;
 };
 
-interface GetDetailedReviewApi {
+interface GetDetailedReviewApiParams {
   reviewId: number;
 }
 // 상세 리뷰
-export const getDetailedReviewApi = async ({ reviewId }: GetDetailedReviewApi) => {
+export const getDetailedReviewApi = async ({ reviewId }: GetDetailedReviewApiParams) => {
   const response = await fetch(endPoint.gettingDetailedReview(reviewId), {
     method: 'GET',
     headers: {
@@ -79,12 +85,7 @@ export const getDetailedReviewApi = async ({ reviewId }: GetDetailedReviewApi) =
   return data as DetailReviewData;
 };
 
-interface GetReviewListApi {
-  lastReviewId: number | null;
-  size: number;
-}
-
-export const getReviewListApi = async ({ lastReviewId, size }: GetReviewListApi) => {
+export const getReviewListApi = async ({ lastReviewId, size }: GetInfiniteReviewListApiParams) => {
   const response = await fetch(endPoint.gettingReviewList(lastReviewId, size), {
     method: 'GET',
     headers: {
@@ -138,3 +139,20 @@ export const getGroupedReviews = async ({ sectionId }: GetGroupedReviewsProps) =
   const data = await response.json();
   return data as GroupedReviews;
 };
+
+export const getWrittenReviewList = async ({ lastReviewId, size }: GetInfiniteReviewListApiParams) => {
+  const response = await fetch(endPoint.gettingWrittenReviewList(lastReviewId, size), {
+    method: 'GET',
+    headers: {
+      'Content-Type': 'application/json',
+    },
+    credentials: 'include',
+  });
+
+  if (!response.ok) {
+    throw new Error(createApiErrorMessage(response.status));
+  }
+
+  const data = await response.json();
+  return data as WrittenReviewList;
+};

리뷰: 함수의 파라미터 타입이라서, 저는 이럴 때 타입명 뒤에 params를 추가하는 편이에요.
@@ -6,11 +6,17 @@ import {
   GroupedSection,
   GroupedReviews,
   ReviewInfoData,
+  WrittenReviewList,
 } from '@/types';
 
 import createApiErrorMessage from './apiErrorMessageCreator';
 import endPoint from './endpoints';
 
+export interface GetInfiniteReviewListApiParams {
+  lastReviewId: number | null;
+  size: number;
+}
+
 export const getDataToWriteReviewApi = async (reviewRequestCode: string) => {
   const response = await fetch(endPoint.gettingDataToWriteReview(reviewRequestCode), {
     method: 'GET',
@@ -58,11 +64,11 @@ export const getReviewInfoDataApi = async () => {
   return data as ReviewInfoData;
 };
 
-interface GetDetailedReviewApi {
+interface GetDetailedReviewApiParams {
   reviewId: number;
 }
 // 상세 리뷰
-export const getDetailedReviewApi = async ({ reviewId }: GetDetailedReviewApi) => {
+export const getDetailedReviewApi = async ({ reviewId }: GetDetailedReviewApiParams) => {
   const response = await fetch(endPoint.gettingDetailedReview(reviewId), {
     method: 'GET',
     headers: {
@@ -79,12 +85,7 @@ export const getDetailedReviewApi = async ({ reviewId }: GetDetailedReviewApi) =
   return data as DetailReviewData;
 };
 
-interface GetReviewListApi {
-  lastReviewId: number | null;
-  size: number;
-}
-
-export const getReviewListApi = async ({ lastReviewId, size }: GetReviewListApi) => {
+export const getReviewListApi = async ({ lastReviewId, size }: GetInfiniteReviewListApiParams) => {
   const response = await fetch(endPoint.gettingReviewList(lastReviewId, size), {
     method: 'GET',
     headers: {
@@ -138,3 +139,20 @@ export const getGroupedReviews = async ({ sectionId }: GetGroupedReviewsProps) =
   const data = await response.json();
   return data as GroupedReviews;
 };
+
+export const getWrittenReviewList = async ({ lastReviewId, size }: GetInfiniteReviewListApiParams) => {
+  const response = await fetch(endPoint.gettingWrittenReviewList(lastReviewId, size), {
+    method: 'GET',
+    headers: {
+      'Content-Type': 'application/json',
+    },
+    credentials: 'include',
+  });
+
+  if (!response.ok) {
+    throw new Error(createApiErrorMessage(response.status));
+  }
+
+  const data = await response.json();
+  return data as WrittenReviewList;
+};

리뷰: 아이고 놓쳤네요 😅 추가할게요!
@@ -0,0 +1,78 @@
+import { useMemo } from 'react';
+
+import { useGetDetailedReview, useReviewId } from '@/hooks';
+import { substituteString } from '@/utils';
+
+import QuestionAnswerSection from './QuestionAnswerSection';
+import ReviewDescription from './ReviewDescription';
+import * as S from './styles';
+
+interface DetailedReviewProps {
+  $layoutStyle?: React.CSSProperties;
+  selectedReviewId?: number;
+}
+
+const DetailedReview = ({ selectedReviewId, $layoutStyle }: DetailedReviewProps) => {
+  const reviewId = useReviewId(selectedReviewId);
+
+  const { data: detailedReview } = useGetDetailedReview({
+    reviewId: reviewId,
+  });
+
+  const parsedDetailedReview = useMemo(() => {
+    return {
+      ...detailedReview,
+      sections: detailedReview.sections.map((section) => {
+        const newHeader = substituteString({
+          content: section.header,
+          variables: { revieweeName: detailedReview.revieweeName, projectName: detailedReview.projectName },
+        });
+
+        const newQuestions = section.questions.map((question) => {
+          const newContent = substituteString({
+            content: question.content,
+            variables: { revieweeName: detailedReview.revieweeName, projectName: detailedReview.projectName },
+          });
+
+          return {
+            ...question,
+            content: newContent,
+          };
+        });
+
+        return {
+          ...section,
+          header: newHeader,
+          questions: newQuestions,
+        };
+      }),
+    };
+  }, [detailedReview]);
+
+  return (
+    <S.DetailedReview $layoutStyle={$layoutStyle}>
+      <ReviewDescription
+        projectName={parsedDetailedReview.projectName}
+        date={new Date(parsedDetailedReview.createdAt)}
+        revieweeName={parsedDetailedReview.revieweeName}
+      />
+      <S.Separator />
+      <S.DetailedReviewContainer>
+        {parsedDetailedReview.sections.map((section) =>
+          section.questions.map((question) => (
+            <S.ReviewContentContainer key={question.questionId}>
+              <QuestionAnswerSection
+                question={question.content}
+                questionType={question.questionType}
+                answer={question.answer}
+                options={question.optionGroup?.options}
+              />
+            </S.ReviewContentContainer>
+          )),
+        )}
+      </S.DetailedReviewContainer>
+    </S.DetailedReview>
+  );
+};
+
+export default DetailedReview;

리뷰: 만약 이 컴포넌트를 리팩토링한다면 저는 useMemo를 아예 뺄 것 같아요. 이점에 비해 코드가 복잡해지는 단점이 큰 것 같아서요. 제거하는 방향에 대해서는 어떻게 생각하시나요?

1. 리뷰는 수정 불가능하므로 리뷰이 이름 문자열 치환도 1번만 일어남
2. `substituteString`은 useMemo를 사용할 만큼 비싼 동작이 아님
3. 1과 같은 맥락에서, 어차피 리뷰는 갱신되지 않기 때문에 유저도 리뷰 상세 페이지를 계속 새로고침해서 리렌더링을 추가로 트리거할 것 같지 않음
@@ -0,0 +1,50 @@
+package com.ll.nbe342team8.domain.book.book.dto;
+
+import com.ll.nbe342team8.domain.book.category.entity.Category;
+import jakarta.validation.constraints.AssertTrue;
+import jakarta.validation.constraints.DecimalMin;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.Pattern;
+import lombok.Data;
+
+import java.time.LocalDate;
+
+@Data
+public class BookPatchRequestDto {
+    private String title;
+    private String author;
+
+    @Pattern(regexp = "^[0-9]{10}$", message = "ISBN은 10자리 숫자여야 합니다.")
+    private String isbn;   // 10자리 숫자만 허용
+
+    @Pattern(regexp = "^[0-9]{13}$", message = "ISBN13은 13자리 숫자여야 합니다.")
+    private String isbn13; // 13자리 숫자만 허용
+
+    private LocalDate pubDate;
+
+    @Min(value = 0, message = "가격은 0 이상이어야 합니다.")
+    private Integer priceStandard;
+
+    @Min(value = 0, message = "가격은 0 이상이어야 합니다.")
+    private Integer priceSales;
+
+    @Min(value = 0, message = "재고는 0 이상이어야 합니다.")
+    private Integer stock;
+
+    @AssertTrue(message = "status 값은 0 또는 1이어야 합니다.")
+    public boolean isValidStatus() {
+        return status == null || status == 0 || status == 1;
+    }
+
+    private Integer status;
+
+    @DecimalMin(value = "0.0", message = "평점은 0 이상이어야 합니다.")
+    private Float rating;
+
+    private String toc;
+    private String cover;
+    private String description;
+    private String descriptionImage;
+    private Category categoryId;
+
+}

리뷰: - Controller 에서 `@Valid` 를 안 쓰고 있어서, Validation 이 수행되지 않을 것으로 보여요.
- Dto는 가변일 필요가 없고, (아마 이 텍스트를 보고 제가 말하겠지만) 모던 자바로 넘어가면서 데이터의 불변성이 아주 중요한 이슈로 떠오른 상황이에요.`@Data` 대신, Record로 대체해 주세요.
@@ -0,0 +1,50 @@
+package com.ll.nbe342team8.domain.book.book.dto;
+
+import com.ll.nbe342team8.domain.book.category.entity.Category;
+import jakarta.validation.constraints.AssertTrue;
+import jakarta.validation.constraints.DecimalMin;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.Pattern;
+import lombok.Data;
+
+import java.time.LocalDate;
+
+@Data
+public class BookPatchRequestDto {
+    private String title;
+    private String author;
+
+    @Pattern(regexp = "^[0-9]{10}$", message = "ISBN은 10자리 숫자여야 합니다.")
+    private String isbn;   // 10자리 숫자만 허용
+
+    @Pattern(regexp = "^[0-9]{13}$", message = "ISBN13은 13자리 숫자여야 합니다.")
+    private String isbn13; // 13자리 숫자만 허용
+
+    private LocalDate pubDate;
+
+    @Min(value = 0, message = "가격은 0 이상이어야 합니다.")
+    private Integer priceStandard;
+
+    @Min(value = 0, message = "가격은 0 이상이어야 합니다.")
+    private Integer priceSales;
+
+    @Min(value = 0, message = "재고는 0 이상이어야 합니다.")
+    private Integer stock;
+
+    @AssertTrue(message = "status 값은 0 또는 1이어야 합니다.")
+    public boolean isValidStatus() {
+        return status == null || status == 0 || status == 1;
+    }
+
+    private Integer status;
+
+    @DecimalMin(value = "0.0", message = "평점은 0 이상이어야 합니다.")
+    private Float rating;
+
+    private String toc;
+    private String cover;
+    private String description;
+    private String descriptionImage;
+    private Category categoryId;
+
+}

리뷰: Float은 정확도 문제로 권장되지 않습니다. Double로 대체해 주세요.
@@ -0,0 +1,50 @@
+package com.ll.nbe342team8.domain.book.book.dto;
+
+import com.ll.nbe342team8.domain.book.category.entity.Category;
+import jakarta.validation.constraints.AssertTrue;
+import jakarta.validation.constraints.DecimalMin;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.Pattern;
+import lombok.Data;
+
+import java.time.LocalDate;
+
+@Data
+public class BookPatchRequestDto {
+    private String title;
+    private String author;
+
+    @Pattern(regexp = "^[0-9]{10}$", message = "ISBN은 10자리 숫자여야 합니다.")
+    private String isbn;   // 10자리 숫자만 허용
+
+    @Pattern(regexp = "^[0-9]{13}$", message = "ISBN13은 13자리 숫자여야 합니다.")
+    private String isbn13; // 13자리 숫자만 허용
+
+    private LocalDate pubDate;
+
+    @Min(value = 0, message = "가격은 0 이상이어야 합니다.")
+    private Integer priceStandard;
+
+    @Min(value = 0, message = "가격은 0 이상이어야 합니다.")
+    private Integer priceSales;
+
+    @Min(value = 0, message = "재고는 0 이상이어야 합니다.")
+    private Integer stock;
+
+    @AssertTrue(message = "status 값은 0 또는 1이어야 합니다.")
+    public boolean isValidStatus() {
+        return status == null || status == 0 || status == 1;
+    }
+
+    private Integer status;
+
+    @DecimalMin(value = "0.0", message = "평점은 0 이상이어야 합니다.")
+    private Float rating;
+
+    private String toc;
+    private String cover;
+    private String description;
+    private String descriptionImage;
+    private Category categoryId;
+
+}

리뷰: status를 숫자가 아닌 Enum 으로 관리할 순 없을까요?
@@ -0,0 +1,56 @@
+package com.ll.nbe342team8.domain.book.book.dto;
+
+import com.ll.nbe342team8.domain.book.book.entity.Book;
+import com.ll.nbe342team8.domain.book.category.entity.Category;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+public record BookResponseDto(Long id,
+                              String title,
+                              String author,
+                              String isbn,
+                              String isbn13,
+                              String publisher,
+                              LocalDate pubDate,
+                              int priceStandard,
+                              int priceSales,
+                              long salesPoint,
+                              int stock,
+                              int status,
+                              float rating,
+                              String toc,
+                              long reviewCount,
+                              String coverImage,
+                              Integer categoryId,
+                              String description,
+                              String descriptionImage) {
+
+    public static BookResponseDto from(Book book){
+        return new BookResponseDto(
+                book.getId(),
+                book.getTitle(),
+                book.getAuthor(),
+                book.getIsbn(),
+                book.getIsbn13(),
+                book.getPublisher(),
+                book.getPubDate(),
+                book.getPriceStandard(),
+                book.getPricesSales(),
+                book.getSalesPoint(),
+                book.getStock(),
+                book.getStatus(),
+                book.getRating(),
+                book.getToc(),
+                book.getReviewCount(),
+                book.getCoverImage(),
+                book.getCategoryId().getCategoryId(),
+                book.getDescription(),
+                book.getDescriptionImage()
+        );
+    }
+}

리뷰: 어떤 곳에서는 record를 쓰고, 어떤 곳에서는 @Data 를 쓰는걸 보니, 팀 내 컨벤션이 정확하게 확정되지 않은 것 같아요.
@@ -1,23 +0,0 @@
-package com.ll.nbe342team8.domain.book.book.review.entity;
-
-import com.ll.nbe342team8.global.jpa.entity.BaseTime;
-import jakarta.persistence.Entity;
-import lombok.AllArgsConstructor;
-import lombok.Builder;
-import lombok.Getter;
-import lombok.NoArgsConstructor;
-
-@Entity
-@Getter
-@Builder
-@NoArgsConstructor
-@AllArgsConstructor
-public class Review extends BaseTime {
-
-
-    String content;
-
-    int rating;
-
-
-}

리뷰: 요 공백들도 컨벤션에 맞게 정리해주세요 ㅎㅎ
@@ -0,0 +1,38 @@
+package com.ll.nbe342team8.domain.book.book.service;
+
+import com.ll.nbe342team8.domain.book.book.dto.ExternalBookDto;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.web.reactive.function.client.WebClient;
+
+@Service
+public class ExternalBookApiService {
+
+    private final WebClient webClient;
+    private final String ttbkey;
+
+    public ExternalBookApiService(WebClient.Builder webClientBuilder,
+                                  @Value("${aladin.ttbkey}") String ttbkey) {
+        this.webClient = webClientBuilder.baseUrl("http://www.aladin.co.kr/ttb/api/").build();
+        this.ttbkey = ttbkey;
+    }
+
+    public ExternalBookDto searchBook(String isbn13) {
+        if (isbn13 == null || isbn13.isEmpty()) {
+            throw new IllegalArgumentException("ISBN13 값은 필수입니다.");
+        }
+
+        String url = "http://www.aladin.co.kr/ttb/api/ItemLookUp.aspx?"
+                + "ttbkey=ttbcameogu1634001" // 하드코딩 (추후수정)
+                + "&itemIdType=ISBN13"
+                + "&ItemId=" + isbn13
+                + "&output=js"
+                + "&Cover=Big";
+
+        return webClient.get()
+                .uri(url) // 호출할 URL
+                .retrieve() // 응답 데이터를 가져옴
+                .bodyToMono(ExternalBookDto.class) // 응답 데이터를 ExternalBookDto 로 변환
+                .block(); // 비동기 호출을 동기적으로 처리
+    }
+}

리뷰: UriComponentBuilder 사용
@@ -0,0 +1,38 @@
+package com.ll.nbe342team8.domain.book.book.service;
+
+import com.ll.nbe342team8.domain.book.book.dto.ExternalBookDto;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.web.reactive.function.client.WebClient;
+
+@Service
+public class ExternalBookApiService {
+
+    private final WebClient webClient;
+    private final String ttbkey;
+
+    public ExternalBookApiService(WebClient.Builder webClientBuilder,
+                                  @Value("${aladin.ttbkey}") String ttbkey) {
+        this.webClient = webClientBuilder.baseUrl("http://www.aladin.co.kr/ttb/api/").build();
+        this.ttbkey = ttbkey;
+    }
+
+    public ExternalBookDto searchBook(String isbn13) {
+        if (isbn13 == null || isbn13.isEmpty()) {
+            throw new IllegalArgumentException("ISBN13 값은 필수입니다.");
+        }
+
+        String url = "http://www.aladin.co.kr/ttb/api/ItemLookUp.aspx?"
+                + "ttbkey=ttbcameogu1634001" // 하드코딩 (추후수정)
+                + "&itemIdType=ISBN13"
+                + "&ItemId=" + isbn13
+                + "&output=js"
+                + "&Cover=Big";
+
+        return webClient.get()
+                .uri(url) // 호출할 URL
+                .retrieve() // 응답 데이터를 가져옴
+                .bodyToMono(ExternalBookDto.class) // 응답 데이터를 ExternalBookDto 로 변환
+                .block(); // 비동기 호출을 동기적으로 처리
+    }
+}

리뷰: Apache Commons StringUtils 사용을 해 보세요. 
`StringUtils.isEmpty(isbn13)`
@@ -1,18 +1,47 @@
 package com.ll.nbe342team8.domain.book.category.entity;
 
+import com.ll.nbe342team8.domain.book.book.entity.Book;
 import com.ll.nbe342team8.global.jpa.entity.BaseEntity;
+import jakarta.persistence.CascadeType;
 import jakarta.persistence.Entity;
+import jakarta.persistence.OneToMany;
+import jakarta.validation.constraints.NotNull;
 import lombok.AllArgsConstructor;
 import lombok.Builder;
 import lombok.Getter;
 import lombok.NoArgsConstructor;
 
+import java.util.ArrayList;
+import java.util.List;
+
 @Entity
 @Getter
 @Builder
 @NoArgsConstructor
 @AllArgsConstructor
 public class Category extends BaseEntity {
+    @NotNull
+    private Integer categoryId;
+
+    @NotNull
+    private String categoryName;
+
+    @NotNull
+    private String mall;
+
+    @NotNull
+    private String depth1;
+
+    private String depth2;
+
+    private String depth3;
+
+    private String depth4;
+
+    private String depth5;
+
+    @OneToMany(mappedBy = "categoryId", cascade = CascadeType.ALL)
+    private List<Book> books = new ArrayList<>();
 
     private String category; // 카테고리 종류 ex) 국내도서 > 경제/경영 > 재테크/금융 > 재테크 > 부자되는법
 }

리뷰: 해당 카테고리에 책이 한 6만건 있다고 해 봅시다. 그러면 카테고리를 삭제하게 되면 데이터 6만건이 같이 삭제 될텐데요. 좀 불안정하지 않을까요?
@@ -0,0 +1,22 @@
+package com.ll.nbe342team8.domain.book.review.type;
+
+import org.springframework.data.domain.Sort;
+
+public enum SortType {
+    CREATE_AT_DESC("createDate", Sort.Direction.DESC), // 최근 등록순
+    CREATE_AT_ASC("createDate", Sort.Direction.ASC),   // 과거 등록순
+    RATING_DESC("rating", Sort.Direction.DESC),      // 평점 높은 순
+    RATING_ASC("rating", Sort.Direction.ASC);         // 평점 낮은 순
+
+    private final String field;
+    private final Sort.Direction direction;
+
+    SortType(String field, Sort.Direction direction) {
+        this.field = field;
+        this.direction = direction;
+    }
+
+    public Sort.Order getOrder() {
+        return new Sort.Order(direction, field);
+    }
+}

리뷰: 비슷한 코드가 있는 것 같은데요, 묶을 순 없을까요?