ewhk9887/korean_code_reviews_from_github huggingface dataset:
@@ -0,0 +1,16 @@
+import { useSuspenseQuery } from '@tanstack/react-query';
+
+import { getReviewLinksApi } from '@/apis/review';
+import { REVIEW_QUERY_KEY } from '@/constants';
+
+const useGetReviewLinks = () => {
+  const result = useSuspenseQuery({
+    queryKey: [REVIEW_QUERY_KEY.reviewLinks],
+    queryFn: () => getReviewLinksApi(),
+    staleTime: 60 * 60 * 1000,
+  });
+
+  return result;
+};
+
+export default useGetReviewLinks;

리뷰: 오 리뷰 링크를 받아올 때 infiniteQuery를 쓸 줄 알았는데 일반 query를 쓰네요?!
서버에서 isLastPage나 lastReviewGroupId를 내려주길래 inf query를 쓸 줄 알았어요. 애초에 요청에 id를 보내지 않으니 무한 구현은 안 될 것 같긴 한데 왜 저 값들을 내려주는지는 궁금하네요...
@@ -1,2 +1,3 @@
 export { default as useGetDetailedReview } from './useGetDetailedReview';
 export { default as useGetReviewList } from './useGetReviewList';
+export { default as useGetReviewLinks } from './useGetReviewLinks';

리뷰: 맥락을 까먹어서 그러는데, ReviewList 가져오는 훅(외에 한 페이지에서만 쓰는 쿼리 훅들)을 공통 훅으로 뺐던 이유가 뭐였는지 기억하시나요? depth 이슈였나...
@@ -0,0 +1,24 @@
+import { renderHook, waitFor } from '@testing-library/react';
+
+import QueryClientWrapper from '@/queryTestSetup/QueryClientWrapper';
+import { testWithAuthCookie } from '@/utils';
+
+import useGetReviewLinks from '.';
+
+describe('회원이 생성한 리뷰 그룹 목록 조회 테스트', () => {
+  it('회원이 생성한 리뷰 그룹 목록을 성공적으로 불러온다.', async () => {
+    const testReviewLinksAPI = async () => {
+      const { result } = renderHook(() => useGetReviewLinks(), {
+        wrapper: QueryClientWrapper,
+      });
+
+      await waitFor(() => {
+        expect(result.current.isSuccess).toBe(true);
+      });
+
+      expect(result.current.data).toBeDefined();
+    };
+
+    await testWithAuthCookie(testReviewLinksAPI);
+  });
+});

리뷰: 테스트까지 만들어주셨네요 👍
추후에 회원/비회원 쿠키를 나누고 그에 따라 테스트 유틸 함수도 나눠보면 좋겠네요!
@@ -1,5 +1,10 @@
+import { useLocation } from 'react-router';
+
 import Breadcrumb from '@/components/common/Breadcrumb';
+import NavigationTab from '@/components/common/NavigationTab';
+import { ROUTE } from '@/constants';
 import useBreadcrumbPaths from '@/hooks/useBreadcrumbPaths';
+import useNavigationTabs from '@/hooks/useNavigationTabs';
 import { EssentialPropsWithChildren } from '@/types';
 
 import Footer from '../Footer';
@@ -11,16 +16,28 @@ import * as S from './styles';
 interface PageLayoutProps {
   isNeedBreadCrumb?: boolean;
 }
+
 const PageLayout = ({ children, isNeedBreadCrumb = true }: EssentialPropsWithChildren<PageLayoutProps>) => {
+  // TODO: NavigationTab은 사용자가 홈 페이지와 연결 페이지가 아닌 다른 페이지에서 로그인 상태일 때만 보여준다.
+  // 임시로 true 설정 (로그인 기능 추가하면서 여기도 수정해야 한다.)
+  const isUserLoggedIn = true;
+
+  const { pathname } = useLocation();
+
   const breadcrumbPathList = useBreadcrumbPaths();
-  const isShowBreadCrumb = isNeedBreadCrumb && breadcrumbPathList.length > 1;
+  const navigationTabList = useNavigationTabs();
+  const isShowBreadCrumb = !isUserLoggedIn && isNeedBreadCrumb && breadcrumbPathList.length > 1;
+  const isShowNavigationTab = isUserLoggedIn && pathname !== '/' && !pathname.includes(ROUTE.reviewZone);
 
   return (
     <S.Layout>
       <S.Wrapper>
         <Topbar />
+        {isShowNavigationTab && <NavigationTab tabList={navigationTabList} />}
         {isShowBreadCrumb && <Breadcrumb pathList={breadcrumbPathList} />}
-        <Main isShowBreadCrumb={isShowBreadCrumb}>{children}</Main>
+        <Main isShowBreadCrumb={isShowBreadCrumb} isShowNavigationTab={isShowNavigationTab}>
+          {children}
+        </Main>
         <Footer />
       </S.Wrapper>
     </S.Layout>

리뷰: 주석 좋아요~~
@@ -0,0 +1,24 @@
+import { renderHook, waitFor } from '@testing-library/react';
+
+import QueryClientWrapper from '@/queryTestSetup/QueryClientWrapper';
+import { testWithAuthCookie } from '@/utils';
+
+import useGetReviewLinks from '.';
+
+describe('회원이 생성한 리뷰 그룹 목록 조회 테스트', () => {
+  it('회원이 생성한 리뷰 그룹 목록을 성공적으로 불러온다.', async () => {
+    const testReviewLinksAPI = async () => {
+      const { result } = renderHook(() => useGetReviewLinks(), {
+        wrapper: QueryClientWrapper,
+      });
+
+      await waitFor(() => {
+        expect(result.current.isSuccess).toBe(true);
+      });
+
+      expect(result.current.data).toBeDefined();
+    };
+
+    await testWithAuthCookie(testReviewLinksAPI);
+  });
+});

리뷰: 모킹값을 넣는 대신 toBeDefined로 대체한 이유가 궁금합니다!
@@ -1,24 +1,33 @@
-import useNavigationTabs from '@/hooks/useNavigationTabs';
+import { useLocation } from 'react-router';
 
 import NavItem from './NavItem';
 import * as S from './styles';
+export interface Tab {
+  label: '리뷰 링크 관리' | '작성한 리뷰 확인';
+  activePathList: string[];
+  handleTabClick: () => void;
+}
 
-const NavigationTab = () => {
-  const { currentTabIndex, tabList } = useNavigationTabs();
+interface NavigationTabProps {
+  tabList: Tab[];
+}
+
+const NavigationTab = ({ tabList }: NavigationTabProps) => {
+  const { pathname } = useLocation();
+
+  const isActiveTab = (activePaths: string[]) => activePaths.some((activePath) => pathname.includes(activePath));
 
   return (
     <S.NavContainer>
       <S.NavList>
-        {tabList.map((tab, index) => {
-          return (
-            <NavItem
-              key={tab.label}
-              label={tab.label}
-              $isSelected={currentTabIndex === index}
-              onClick={tab.handleTabClick}
-            />
-          );
-        })}
+        {tabList.map((tab) => (
+          <NavItem
+            key={tab.label}
+            label={tab.label}
+            $isActiveTab={isActiveTab(tab.activePathList)}
+            onClick={tab.handleTabClick}
+          />
+        ))}
       </S.NavList>
     </S.NavContainer>
   );

리뷰: 공통 컴포넌트라면 label에 올 수 있는 타입을 열어둬야 하지 않을까요?!
@@ -1,5 +1,10 @@
+import { useLocation } from 'react-router';
+
 import Breadcrumb from '@/components/common/Breadcrumb';
+import NavigationTab from '@/components/common/NavigationTab';
+import { ROUTE } from '@/constants';
 import useBreadcrumbPaths from '@/hooks/useBreadcrumbPaths';
+import useNavigationTabs from '@/hooks/useNavigationTabs';
 import { EssentialPropsWithChildren } from '@/types';
 
 import Footer from '../Footer';
@@ -11,16 +16,28 @@ import * as S from './styles';
 interface PageLayoutProps {
   isNeedBreadCrumb?: boolean;
 }
+
 const PageLayout = ({ children, isNeedBreadCrumb = true }: EssentialPropsWithChildren<PageLayoutProps>) => {
+  // TODO: NavigationTab은 사용자가 홈 페이지와 연결 페이지가 아닌 다른 페이지에서 로그인 상태일 때만 보여준다.
+  // 임시로 true 설정 (로그인 기능 추가하면서 여기도 수정해야 한다.)
+  const isUserLoggedIn = true;
+
+  const { pathname } = useLocation();
+
   const breadcrumbPathList = useBreadcrumbPaths();
-  const isShowBreadCrumb = isNeedBreadCrumb && breadcrumbPathList.length > 1;
+  const navigationTabList = useNavigationTabs();
+  const isShowBreadCrumb = !isUserLoggedIn && isNeedBreadCrumb && breadcrumbPathList.length > 1;
+  const isShowNavigationTab = isUserLoggedIn && pathname !== '/' && !pathname.includes(ROUTE.reviewZone);
 
   return (
     <S.Layout>
       <S.Wrapper>
         <Topbar />
+        {isShowNavigationTab && <NavigationTab tabList={navigationTabList} />}
         {isShowBreadCrumb && <Breadcrumb pathList={breadcrumbPathList} />}
-        <Main isShowBreadCrumb={isShowBreadCrumb}>{children}</Main>
+        <Main isShowBreadCrumb={isShowBreadCrumb} isShowNavigationTab={isShowNavigationTab}>
+          {children}
+        </Main>
         <Footer />
       </S.Wrapper>
     </S.Layout>

리뷰: 렌더링 로직 빡세네요...ㅋㅋㅋㅋㅋ 개발자살려~!
@@ -1,2 +1,3 @@
 export { default as useGetDetailedReview } from './useGetDetailedReview';
 export { default as useGetReviewList } from './useGetReviewList';
+export { default as useGetReviewLinks } from './useGetReviewLinks';

리뷰: 초반에 만든 훅이라 공통 훅 자리에 있는것 같아요
@@ -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) return 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 getWrittenReviewListApi = 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를 추가하는 편이에요.
@@ -0,0 +1,57 @@
+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 { data } = useGetDetailedReview({ reviewId: useReviewId(selectedReviewId) });
+
+  const transformedSections = data.sections.map(({ header, questions, ...rest }) => ({
+    ...rest,
+    header: substituteString({
+      content: header,
+      variables: { revieweeName: data.revieweeName, projectName: data.projectName },
+    }),
+    questions: questions.map(({ content, ...rest }) => ({
+      ...rest,
+      content: substituteString({
+        content,
+        variables: { revieweeName: data.revieweeName, projectName: data.projectName },
+      }),
+    })),
+  }));
+
+  return (
+    <S.DetailedReview $layoutStyle={$layoutStyle}>
+      <ReviewDescription
+        projectName={data.projectName}
+        date={new Date(data.createdAt)}
+        revieweeName={data.revieweeName}
+      />
+      <S.Separator />
+      <S.DetailedReviewContainer>
+        {transformedSections.flatMap(({ questions }) =>
+          questions.map(({ questionId, content, questionType, answer, optionGroup }) => (
+            <S.ReviewContentContainer key={questionId}>
+              <QuestionAnswerSection
+                question={content}
+                questionType={questionType}
+                answer={answer}
+                options={optionGroup?.options}
+              />
+            </S.ReviewContentContainer>
+          )),
+        )}
+      </S.DetailedReviewContainer>
+    </S.DetailedReview>
+  );
+};
+
+export default DetailedReview;

리뷰: sections 관련된 부분 코드가 길어서, header와 questions를 분리하고 각각 useMemo를 사용하는 것은 어떨까요'?
@@ -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 getWrittenReviewListApi = 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,57 @@
+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 { data } = useGetDetailedReview({ reviewId: useReviewId(selectedReviewId) });
+
+  const transformedSections = data.sections.map(({ header, questions, ...rest }) => ({
+    ...rest,
+    header: substituteString({
+      content: header,
+      variables: { revieweeName: data.revieweeName, projectName: data.projectName },
+    }),
+    questions: questions.map(({ content, ...rest }) => ({
+      ...rest,
+      content: substituteString({
+        content,
+        variables: { revieweeName: data.revieweeName, projectName: data.projectName },
+      }),
+    })),
+  }));
+
+  return (
+    <S.DetailedReview $layoutStyle={$layoutStyle}>
+      <ReviewDescription
+        projectName={data.projectName}
+        date={new Date(data.createdAt)}
+        revieweeName={data.revieweeName}
+      />
+      <S.Separator />
+      <S.DetailedReviewContainer>
+        {transformedSections.flatMap(({ questions }) =>
+          questions.map(({ questionId, content, questionType, answer, optionGroup }) => (
+            <S.ReviewContentContainer key={questionId}>
+              <QuestionAnswerSection
+                question={content}
+                questionType={questionType}
+                answer={answer}
+                options={optionGroup?.options}
+              />
+            </S.ReviewContentContainer>
+          )),
+        )}
+      </S.DetailedReviewContainer>
+    </S.DetailedReview>
+  );
+};
+
+export default DetailedReview;

리뷰: 만약 이 컴포넌트를 리팩토링한다면 저는 useMemo를 아예 뺄 것 같아요. 이점에 비해 코드가 복잡해지는 단점이 큰 것 같아서요. 제거하는 방향에 대해서는 어떻게 생각하시나요?

1. 리뷰는 수정 불가능하므로 리뷰이 이름 문자열 치환도 1번만 일어남
2. `substituteString`은 useMemo를 사용할 만큼 비싼 동작이 아님
3. 1과 같은 맥락에서, 어차피 리뷰는 갱신되지 않기 때문에 유저도 리뷰 상세 페이지를 계속 새로고침해서 리렌더링을 추가로 트리거할 것 같지 않음
@@ -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 getWrittenReviewListApi = 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;
+};

리뷰: 우리의 코드 컨벤션~ 잊지 말아요~
![스크린샷 2025-02-13 오후 4 02 32](https://github.com/user-attachments/assets/44efa353-3161-4b52-b8c9-24922fd1736f)


근데 확인해보니 안 붙인 함수들이 꽤나 있네요😂
@@ -0,0 +1,57 @@
+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 { data } = useGetDetailedReview({ reviewId: useReviewId(selectedReviewId) });
+
+  const transformedSections = data.sections.map(({ header, questions, ...rest }) => ({
+    ...rest,
+    header: substituteString({
+      content: header,
+      variables: { revieweeName: data.revieweeName, projectName: data.projectName },
+    }),
+    questions: questions.map(({ content, ...rest }) => ({
+      ...rest,
+      content: substituteString({
+        content,
+        variables: { revieweeName: data.revieweeName, projectName: data.projectName },
+      }),
+    })),
+  }));
+
+  return (
+    <S.DetailedReview $layoutStyle={$layoutStyle}>
+      <ReviewDescription
+        projectName={data.projectName}
+        date={new Date(data.createdAt)}
+        revieweeName={data.revieweeName}
+      />
+      <S.Separator />
+      <S.DetailedReviewContainer>
+        {transformedSections.flatMap(({ questions }) =>
+          questions.map(({ questionId, content, questionType, answer, optionGroup }) => (
+            <S.ReviewContentContainer key={questionId}>
+              <QuestionAnswerSection
+                question={content}
+                questionType={questionType}
+                answer={answer}
+                options={optionGroup?.options}
+              />
+            </S.ReviewContentContainer>
+          )),
+        )}
+      </S.DetailedReviewContainer>
+    </S.DetailedReview>
+  );
+};
+
+export default DetailedReview;

리뷰: useMemo는 기존 코드에 적용되어있었기 때문이라, 필요가 없다고 느낀다면 사용하지 않아도 돼요. 저의 코멘트는 '긴 코드를 분리해 가독성을 높여보자'에 초점이 있던거라서요.
@@ -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) return defaultEndpoint;
+    return defaultEndpoint + '&' + `${WRITTEN_REVIEW_PARAMS.queryString.lastReviewId}=${lastReviewId}`;
+  },
 };
 
 export default endPoint;

리뷰: `if (!lastReviewId) defaultEndpoint;`

에 early return이 빠졌네요