From 7a7d16158b8780d9f8effc58c2c7988f2677ab1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=84=ED=98=84?= <77152650+Creative-Lee@users.noreply.github.com> Date: Tue, 19 Dec 2023 10:05:16 +0900 Subject: [PATCH 1/3] =?UTF-8?q?Refactor/#511=20=ED=94=84=EB=A1=A0=ED=8A=B8?= =?UTF-8?q?=EC=9D=98=20=ED=86=A0=ED=81=B0=20=EB=A7=8C=EB=A3=8C=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=EB=A1=9C=EC=A7=81=EC=9D=84=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=ED=95=98=EA=B3=A0=20Axios=EB=A5=BC=20=EB=8F=84=EC=9E=85?= =?UTF-8?q?=ED=95=9C=EB=8B=A4.=20(#554)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * config: axios 설치 * feat: axios 인스턴스 및 인터셉터 구현 * refactor: 엑세스 토큰 refresh remote 함수 분리 * refactor: 분리되지 않은 type, remote 함수 분리 * refactor: remote 함수의 fetcher 의존성 제거 및 인스턴스 적용 * refactor: 로그인 remote 함수 분리 * feat: 메인 페이지 장르별 fetch msw 구현 누락된 구현 추가 * chore: 불필요한 파일 제거 * chore: remote 함수 중복 래핑 hook 삭제 및 코드 이동 * refactor: remote 함수 query parameter 처리 방식 통일 * chore: import 방식 변경 * chore: auth 관련 remote함수 auth/하위로 이동 * fix: refresh 요청 API 명세에 맞게 로직 수정 * refactor: 최종 만료시 로그인 페이지 리다이렉트 처리 * refactor: 타입 분리 * refactor: 인터셉터 refresh 중복 요청 방지 기능 추가 * refactor: 에러응답 타입 분리 * chore: fetcher 및 토큰 만료 검증 파일 제거 * refactor: 함수 네이밍 개선 * chore: 주석 수정 * refactor: promise 변수 null 초기화 코드 위치 이동 * style: promise 변수 라인 변경 * refactor: config type 변경 및 린트 주석 제거 --- frontend/package-lock.json | 26 ++++ frontend/package.json | 1 + frontend/src/features/auth/remotes/auth.ts | 16 +++ frontend/src/features/auth/types/auth.type.ts | 3 + .../comments/components/CommentForm.tsx | 14 +- .../comments/components/CommentList.tsx | 17 +-- .../src/features/comments/remotes/comments.ts | 12 ++ .../features/comments/types/comment.type.ts | 6 + .../killingParts/components/RegisterPart.tsx | 5 +- .../killingParts/hooks/killingPart.ts | 6 - .../killingParts/remotes/killingPart.ts | 18 +++ .../remotes/usePostKillingPart.ts | 8 -- .../features/member/components/MyPartList.tsx | 4 +- .../src/features/member/remotes/member.ts | 6 +- .../features/member/remotes/memberParts.ts | 6 +- .../src/features/member/remotes/myPage.ts | 15 ++- .../src/features/search/remotes/search.ts | 26 +++- .../src/features/singer/remotes/singer.ts | 8 +- .../songs/components/SongItem.stories.tsx | 4 +- frontend/src/features/songs/remotes/likes.ts | 4 +- frontend/src/features/songs/remotes/song.ts | 18 ++- frontend/src/features/songs/remotes/songs.ts | 38 +++--- ...{popularSongs.json => highLikedSongs.json} | 120 ++++++++++++------ frontend/src/mocks/handlers/songsHandlers.ts | 14 ++ frontend/src/pages/AuthPage.tsx | 22 +--- frontend/src/pages/EditProfilePage.tsx | 6 +- frontend/src/pages/PartCollectingPage.tsx | 9 +- frontend/src/shared/hooks/useExtraFetch.ts | 2 +- frontend/src/shared/hooks/useFetch.ts | 5 +- frontend/src/shared/hooks/useMutation.ts | 5 +- frontend/src/shared/remotes/axios.ts | 75 +++++++++++ frontend/src/shared/remotes/index.ts | 53 -------- .../src/shared/remotes/preCheckAccessToken.ts | 38 ------ frontend/src/shared/types/errorResponse.ts | 4 + 34 files changed, 370 insertions(+), 244 deletions(-) create mode 100644 frontend/src/features/auth/remotes/auth.ts create mode 100644 frontend/src/features/auth/types/auth.type.ts create mode 100644 frontend/src/features/comments/remotes/comments.ts create mode 100644 frontend/src/features/comments/types/comment.type.ts delete mode 100644 frontend/src/features/killingParts/hooks/killingPart.ts create mode 100644 frontend/src/features/killingParts/remotes/killingPart.ts delete mode 100644 frontend/src/features/killingParts/remotes/usePostKillingPart.ts rename frontend/src/mocks/fixtures/{popularSongs.json => highLikedSongs.json} (83%) create mode 100644 frontend/src/shared/remotes/axios.ts delete mode 100644 frontend/src/shared/remotes/index.ts delete mode 100644 frontend/src/shared/remotes/preCheckAccessToken.ts create mode 100644 frontend/src/shared/types/errorResponse.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f8e1f60a1..a8eefaf1b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -39,6 +39,7 @@ "@types/styled-components": "^5.1.26", "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", + "axios": "^1.6.1", "babel-loader": "^9.1.3", "copy-webpack-plugin": "^11.0.0", "core-js": "^3.31.1", @@ -7760,6 +7761,31 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/axios": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.1.tgz", + "integrity": "sha512-vfBmhDpKafglh0EldBEbVuoe7DyAavGSLWhuSm5ZSEKQnHhBf0xAAwybbNH1IkrJNGnS/VG4I5yxig1pCEXE4g==", + "dev": true, + "dependencies": { + "follow-redirects": "^1.15.0", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/axios/node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dev": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/babel-core": { "version": "7.0.0-bridge.0", "resolved": "https://registry.npmjs.org/babel-core/-/babel-core-7.0.0-bridge.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 45c878afd..dbbc95a82 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -43,6 +43,7 @@ "@types/styled-components": "^5.1.26", "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", + "axios": "^1.6.1", "babel-loader": "^9.1.3", "copy-webpack-plugin": "^11.0.0", "core-js": "^3.31.1", diff --git a/frontend/src/features/auth/remotes/auth.ts b/frontend/src/features/auth/remotes/auth.ts new file mode 100644 index 000000000..fd77a7a63 --- /dev/null +++ b/frontend/src/features/auth/remotes/auth.ts @@ -0,0 +1,16 @@ +import { client, clientBasic } from '@/shared/remotes/axios'; +import type { AccessTokenRes } from '../types/auth.type'; + +export const getAccessToken = async (platform: string, code: string) => { + const { data } = await client.get(`/login/${platform}`, { + params: { code }, + }); + + return data; +}; + +export const postRefreshAccessToken = async () => { + const { data } = await clientBasic.post('/reissue'); + + return data; +}; diff --git a/frontend/src/features/auth/types/auth.type.ts b/frontend/src/features/auth/types/auth.type.ts new file mode 100644 index 000000000..fef720746 --- /dev/null +++ b/frontend/src/features/auth/types/auth.type.ts @@ -0,0 +1,3 @@ +export interface AccessTokenRes { + accessToken: string; +} diff --git a/frontend/src/features/comments/components/CommentForm.tsx b/frontend/src/features/comments/components/CommentForm.tsx index 414f47a6d..97d3c60b5 100644 --- a/frontend/src/features/comments/components/CommentForm.tsx +++ b/frontend/src/features/comments/components/CommentForm.tsx @@ -8,23 +8,23 @@ import Avatar from '@/shared/components/Avatar'; import useModal from '@/shared/components/Modal/hooks/useModal'; import useToastContext from '@/shared/components/Toast/hooks/useToastContext'; import { useMutation } from '@/shared/hooks/useMutation'; -import fetcher from '@/shared/remotes'; +import { postComment } from '../remotes/comments'; interface CommentFormProps { - getComment: () => Promise; + getComments: () => Promise; songId: number; partId: number; } -const CommentForm = ({ getComment, songId, partId }: CommentFormProps) => { +const CommentForm = ({ getComments, songId, partId }: CommentFormProps) => { const [newComment, setNewComment] = useState(''); const { isOpen, closeModal: closeLoginModal, openModal: openLoginModal } = useModal(); const { user } = useAuthContext(); const isLoggedIn = !!user; - const { mutateData } = useMutation(() => - fetcher(`/songs/${songId}/parts/${partId}/comments`, 'POST', { content: newComment.trim() }) + const { mutateData: postNewComment } = useMutation(() => + postComment(songId, partId, newComment.trim()) ); const { showToast } = useToastContext(); @@ -38,11 +38,11 @@ const CommentForm = ({ getComment, songId, partId }: CommentFormProps) => { const submitNewComment: React.FormEventHandler = async (event) => { event.preventDefault(); - await mutateData(); + await postNewComment(); showToast('댓글이 등록되었습니다.'); resetNewComment(); - getComment(); + await getComments(); }; return ( diff --git a/frontend/src/features/comments/components/CommentList.tsx b/frontend/src/features/comments/components/CommentList.tsx index eca833919..8ddb99001 100644 --- a/frontend/src/features/comments/components/CommentList.tsx +++ b/frontend/src/features/comments/components/CommentList.tsx @@ -6,17 +6,10 @@ import useModal from '@/shared/components/Modal/hooks/useModal'; import Spacing from '@/shared/components/Spacing'; import SRHeading from '@/shared/components/SRHeading'; import useFetch from '@/shared/hooks/useFetch'; -import fetcher from '@/shared/remotes'; +import { getComments } from '../remotes/comments'; import Comment from './Comment'; import CommentForm from './CommentForm'; -interface Comment { - id: number; - content: string; - createdAt: string; - writerNickname: string; -} - interface CommentListProps { songId: number; partId: number; @@ -24,12 +17,12 @@ interface CommentListProps { const CommentList = ({ songId, partId }: CommentListProps) => { const { isOpen, openModal, closeModal } = useModal(false); - const { data: comments, fetchData: getComment } = useFetch(() => - fetcher(`/songs/${songId}/parts/${partId}/comments`, 'GET') + const { data: comments, fetchData: refetchComments } = useFetch(() => + getComments(songId, partId) ); useEffect(() => { - getComment(); + refetchComments(); }, [partId]); if (!comments) { @@ -73,7 +66,7 @@ const CommentList = ({ songId, partId }: CommentListProps) => { ))} - + ); diff --git a/frontend/src/features/comments/remotes/comments.ts b/frontend/src/features/comments/remotes/comments.ts new file mode 100644 index 000000000..ed4f68e85 --- /dev/null +++ b/frontend/src/features/comments/remotes/comments.ts @@ -0,0 +1,12 @@ +import { client } from '@/shared/remotes/axios'; +import type { Comment } from '../types/comment.type'; + +export const postComment = async (songId: number, partId: number, content: string) => { + await client.post(`/songs/${songId}/parts/${partId}/comments`, { content }); +}; + +export const getComments = async (songId: number, partId: number) => { + const { data } = await client.get(`/songs/${songId}/parts/${partId}/comments`); + + return data; +}; diff --git a/frontend/src/features/comments/types/comment.type.ts b/frontend/src/features/comments/types/comment.type.ts new file mode 100644 index 000000000..914d85798 --- /dev/null +++ b/frontend/src/features/comments/types/comment.type.ts @@ -0,0 +1,6 @@ +export interface Comment { + id: number; + content: string; + createdAt: string; + writerNickname: string; +} diff --git a/frontend/src/features/killingParts/components/RegisterPart.tsx b/frontend/src/features/killingParts/components/RegisterPart.tsx index e5a388415..bda701750 100644 --- a/frontend/src/features/killingParts/components/RegisterPart.tsx +++ b/frontend/src/features/killingParts/components/RegisterPart.tsx @@ -2,19 +2,20 @@ import { useNavigate } from 'react-router-dom'; import styled from 'styled-components'; import { useAuthContext } from '@/features/auth/components/AuthProvider'; import useCollectingPartContext from '@/features/killingParts/hooks/useCollectingPartContext'; -import { usePostKillingPart } from '@/features/killingParts/remotes/usePostKillingPart'; import useVideoPlayerContext from '@/features/youtube/hooks/useVideoPlayerContext'; import useModal from '@/shared/components/Modal/hooks/useModal'; import Modal from '@/shared/components/Modal/Modal'; import Spacing from '@/shared/components/Spacing'; +import { useMutation } from '@/shared/hooks/useMutation'; import { toPlayingTimeText } from '@/shared/utils/convertTime'; +import { postKillingPart } from '../remotes/killingPart'; const RegisterPart = () => { const { isOpen, openModal, closeModal } = useModal(); const { user } = useAuthContext(); const { interval, partStartTime, songId } = useCollectingPartContext(); const video = useVideoPlayerContext(); - const { createKillingPart } = usePostKillingPart(); + const { mutateData: createKillingPart } = useMutation(postKillingPart); const navigate = useNavigate(); // 현재 useMutation 훅이 response 객체를 리턴하지 않고 내부적으로 처리합니다. diff --git a/frontend/src/features/killingParts/hooks/killingPart.ts b/frontend/src/features/killingParts/hooks/killingPart.ts deleted file mode 100644 index d711edb72..000000000 --- a/frontend/src/features/killingParts/hooks/killingPart.ts +++ /dev/null @@ -1,6 +0,0 @@ -import fetcher from '@/shared/remotes'; -import type { KillingPartPostRequest } from '@/shared/types/killingPart'; - -export const postKillingPart = async (songId: number, body: KillingPartPostRequest) => { - return await fetcher(`/songs/${songId}/member-parts`, 'POST', body); -}; diff --git a/frontend/src/features/killingParts/remotes/killingPart.ts b/frontend/src/features/killingParts/remotes/killingPart.ts new file mode 100644 index 000000000..c5257d069 --- /dev/null +++ b/frontend/src/features/killingParts/remotes/killingPart.ts @@ -0,0 +1,18 @@ +import { client } from '@/shared/remotes/axios'; +import type { KillingPartPostRequest } from '@/shared/types/killingPart'; +import type { SongInfo } from '@/shared/types/song'; + +// PartCollectingPage에 존재하던 remote 함수입니다. +// useFetch(() => fetcher(`/songs/${songId}`, 'GET')) 로직에서 분리하였습니다. +// SongInfo type에는 killingPart[] 필드가 있는데, 마이파트 수집용 `노래 1개` 조회에서 해당 타입이 사용되고 있습니다. +// 추후 수정되어야 합니다. + +export const getSong = async (songId: number) => { + const { data } = await client.get(`/songs/${songId}`); + + return data; +}; + +export const postKillingPart = async (songId: number, body: KillingPartPostRequest) => { + await client.post(`/songs/${songId}/member-parts`, body); +}; diff --git a/frontend/src/features/killingParts/remotes/usePostKillingPart.ts b/frontend/src/features/killingParts/remotes/usePostKillingPart.ts deleted file mode 100644 index c0620378e..000000000 --- a/frontend/src/features/killingParts/remotes/usePostKillingPart.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { postKillingPart } from '@/features/killingParts/hooks/killingPart'; -import { useMutation } from '@/shared/hooks/useMutation'; - -export const usePostKillingPart = () => { - const { isLoading, error, mutateData: createKillingPart } = useMutation(postKillingPart); - - return { isLoading, error, createKillingPart }; -}; diff --git a/frontend/src/features/member/components/MyPartList.tsx b/frontend/src/features/member/components/MyPartList.tsx index fb95df958..f7106d2a4 100644 --- a/frontend/src/features/member/components/MyPartList.tsx +++ b/frontend/src/features/member/components/MyPartList.tsx @@ -19,8 +19,8 @@ export type LikeKillingPart = Pick< const MyPartList = () => { const [tab, setTab] = useState('Like'); - const { data: likes } = useFetch(getLikeParts); - const { data: myParts } = useFetch(getMyParts); + const { data: likes } = useFetch(getLikeParts); + const { data: myParts } = useFetch(getMyParts); if (!likes || !myParts) { return null; diff --git a/frontend/src/features/member/remotes/member.ts b/frontend/src/features/member/remotes/member.ts index b9df67e00..79a0c5abe 100644 --- a/frontend/src/features/member/remotes/member.ts +++ b/frontend/src/features/member/remotes/member.ts @@ -1,5 +1,5 @@ -import fetcher from '@/shared/remotes'; +import { client } from '@/shared/remotes/axios'; -export const deleteMember = (memberId: number | undefined) => () => { - return fetcher(`/members/${memberId}`, 'DELETE'); +export const deleteMember = async (memberId: number) => { + await client.delete(`/members/${memberId}`); }; diff --git a/frontend/src/features/member/remotes/memberParts.ts b/frontend/src/features/member/remotes/memberParts.ts index 28ef36132..ba11152ed 100644 --- a/frontend/src/features/member/remotes/memberParts.ts +++ b/frontend/src/features/member/remotes/memberParts.ts @@ -1,3 +1,5 @@ -import fetcher from '@/shared/remotes'; +import { client } from '@/shared/remotes/axios'; -export const deleteMemberParts = (partId: number) => fetcher(`/member-parts/${partId}`, 'DELETE'); +export const deleteMemberParts = async (partId: number) => { + await client.delete(`/member-parts/${partId}`); +}; diff --git a/frontend/src/features/member/remotes/myPage.ts b/frontend/src/features/member/remotes/myPage.ts index b0ab55df8..3569a3e58 100644 --- a/frontend/src/features/member/remotes/myPage.ts +++ b/frontend/src/features/member/remotes/myPage.ts @@ -1,5 +1,14 @@ -import fetcher from '@/shared/remotes'; +import { client } from '@/shared/remotes/axios'; +import type { LikeKillingPart } from '../components/MyPartList'; -export const getLikeParts = () => fetcher('/my-page/like-parts', 'GET'); +export const getLikeParts = async () => { + const { data } = await client.get('/my-page/like-parts'); -export const getMyParts = () => fetcher('/my-page/my-parts', 'GET'); + return data; +}; + +export const getMyParts = async () => { + const { data } = await client.get('/my-page/my-parts'); + + return data; +}; diff --git a/frontend/src/features/search/remotes/search.ts b/frontend/src/features/search/remotes/search.ts index 86208433c..f4e19c4a9 100644 --- a/frontend/src/features/search/remotes/search.ts +++ b/frontend/src/features/search/remotes/search.ts @@ -1,13 +1,25 @@ -import fetcher from '@/shared/remotes'; +import { client } from '@/shared/remotes/axios'; import type { SingerDetail } from '../../singer/types/singer.type'; import type { SingerSearchPreview } from '../types/search.type'; -export const getSingerSearchPreview = async (query: string): Promise => { - const encodedQuery = encodeURIComponent(query); - return await fetcher(`/search?keyword=${encodedQuery}&type=singer`, 'GET'); +export const getSingerSearchPreview = async (query: string) => { + const { data } = await client.get(`/search`, { + params: { + keyword: query, + type: 'singer', + }, + }); + + return data; }; -export const getSingerSearch = async (query: string): Promise => { - const encodedQuery = encodeURIComponent(query); - return await fetcher(`/search?keyword=${encodedQuery}&type=singer&type=song`, 'GET'); +export const getSingerSearch = async (query: string) => { + const params = new URLSearchParams(); + params.append('keyword', query); + params.append('type', 'singer'); + params.append('type', 'song'); + + const { data } = await client.get(`/search`, { params }); + + return data; }; diff --git a/frontend/src/features/singer/remotes/singer.ts b/frontend/src/features/singer/remotes/singer.ts index bea1d670b..0e538633f 100644 --- a/frontend/src/features/singer/remotes/singer.ts +++ b/frontend/src/features/singer/remotes/singer.ts @@ -1,6 +1,8 @@ -import fetcher from '@/shared/remotes'; +import { client } from '@/shared/remotes/axios'; import type { SingerDetail } from '../types/singer.type'; -export const getSingerDetail = async (singerId: number): Promise => { - return await fetcher(`/singers/${singerId}`, 'GET'); +export const getSingerDetail = async (singerId: number) => { + const { data } = await client.get(`/singers/${singerId}`); + + return data; }; diff --git a/frontend/src/features/songs/components/SongItem.stories.tsx b/frontend/src/features/songs/components/SongItem.stories.tsx index eb59ac905..a86d0cdcb 100644 --- a/frontend/src/features/songs/components/SongItem.stories.tsx +++ b/frontend/src/features/songs/components/SongItem.stories.tsx @@ -1,4 +1,4 @@ -import popularSongs from '@/mocks/fixtures/popularSongs.json'; +import highLikedSongs from '@/mocks/fixtures/highLikedSongs.json'; import SongItem from './SongItem'; import type { Meta, StoryObj } from '@storybook/react'; @@ -11,7 +11,7 @@ export default meta; type Story = StoryObj; -const { title, singer, albumCoverUrl, totalLikeCount } = popularSongs[0]; +const { title, singer, albumCoverUrl, totalLikeCount } = highLikedSongs[0]; export const Default: Story = { args: { diff --git a/frontend/src/features/songs/remotes/likes.ts b/frontend/src/features/songs/remotes/likes.ts index 7a766f0b8..d21a31116 100644 --- a/frontend/src/features/songs/remotes/likes.ts +++ b/frontend/src/features/songs/remotes/likes.ts @@ -1,5 +1,5 @@ -import fetcher from '@/shared/remotes'; +import { client } from '@/shared/remotes/axios'; export const putKillingPartLikes = async (songId: number, partId: number, likeStatus: boolean) => { - return await fetcher(`/songs/${songId}/parts/${partId}/likes`, 'PUT', { likeStatus }); + await client.put(`/songs/${songId}/parts/${partId}/likes`, { likeStatus }); }; diff --git a/frontend/src/features/songs/remotes/song.ts b/frontend/src/features/songs/remotes/song.ts index 9039cc549..c48a110b3 100644 --- a/frontend/src/features/songs/remotes/song.ts +++ b/frontend/src/features/songs/remotes/song.ts @@ -1,16 +1,20 @@ -import fetcher from '@/shared/remotes'; +import { client } from '@/shared/remotes/axios'; import type { Genre, Song } from '../types/Song.type'; import type { RecentSong } from '@/shared/types/song'; // 메인 케러셀 최신순 노래 n개 조회 api - 쿼리파람 없는경우, 응답 기본값은 5개입니다. -export const getRecentSongs = async (songCount?: number): Promise => { - const query = songCount ? `?size=${songCount}` : ''; +export const getRecentSongs = async (songCount?: number) => { + const { data } = await client.get(`/songs/recent`, { + params: { size: songCount }, + }); - return await fetcher(`/songs/recent${query}`, 'GET'); + return data; }; -export const getHighLikedSongs = async (genre: Genre): Promise => { - const query = genre === 'ALL' ? '' : `?genre=${genre}`; +export const getHighLikedSongs = async (genre: Genre) => { + const { data } = await client.get(`/songs/high-liked`, { + params: { genre: genre === 'ALL' ? null : genre }, + }); - return await fetcher(`/songs/high-liked${query}`, 'GET'); + return data; }; diff --git a/frontend/src/features/songs/remotes/songs.ts b/frontend/src/features/songs/remotes/songs.ts index 004ff60eb..21ec45c33 100644 --- a/frontend/src/features/songs/remotes/songs.ts +++ b/frontend/src/features/songs/remotes/songs.ts @@ -1,27 +1,27 @@ -import fetcher from '@/shared/remotes'; +import { client } from '@/shared/remotes/axios'; import type { Genre } from '../types/Song.type'; import type { SongDetail, SongDetailEntries } from '@/shared/types/song'; -export const getSongDetailEntries = async ( - songId: number, - genre: Genre -): Promise => { - const query = genre === 'ALL' ? '' : `?genre=${genre}`; - return await fetcher(`/songs/high-liked/${songId}${query}`, 'GET'); +export const getSongDetailEntries = async (songId: number, genre: Genre) => { + const { data } = await client.get(`/songs/high-liked/${songId}`, { + params: { genre: genre === 'ALL' ? null : genre }, + }); + + return data; }; -export const getExtraPrevSongDetails = async ( - songId: number, - genre: Genre -): Promise => { - const query = genre === 'ALL' ? '' : `?genre=${genre}`; - return await fetcher(`/songs/high-liked/${songId}/prev${query}`, 'GET'); +export const getExtraPrevSongDetails = async (songId: number, genre: Genre) => { + const { data } = await client.get(`/songs/high-liked/${songId}/prev`, { + params: { genre: genre === 'ALL' ? null : genre }, + }); + + return data; }; -export const getExtraNextSongDetails = async ( - songId: number, - genre: Genre -): Promise => { - const query = genre === 'ALL' ? '' : `?genre=${genre}`; - return await fetcher(`/songs/high-liked/${songId}/next${query}`, 'GET'); +export const getExtraNextSongDetails = async (songId: number, genre: Genre) => { + const { data } = await client.get(`/songs/high-liked/${songId}/next`, { + params: { genre: genre === 'ALL' ? null : genre }, + }); + + return data; }; diff --git a/frontend/src/mocks/fixtures/popularSongs.json b/frontend/src/mocks/fixtures/highLikedSongs.json similarity index 83% rename from frontend/src/mocks/fixtures/popularSongs.json rename to frontend/src/mocks/fixtures/highLikedSongs.json index d0667b641..477593cec 100644 --- a/frontend/src/mocks/fixtures/popularSongs.json +++ b/frontend/src/mocks/fixtures/highLikedSongs.json @@ -4,279 +4,319 @@ "title": "Super Shy", "singer": "New Jeans", "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/260/quality/80/optimize", - "totalLikeCount": 258200 + "totalLikeCount": 258200, + "genre": "DANCE" }, { "id": 2, "title": "Super Shy", "singer": "New Jeans", "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/260/quality/80/optimize", - "totalLikeCount": 121312 + "totalLikeCount": 1, + "genre": "DANCE" }, { "id": 3, "title": "노래 길이가 좀 많이 길어요. 노래 길이가 좀 많이 길어요. 노래 길이가 좀 많이 길어요. 노래 길이가 좀 많이 길어요.", "singer": "가수 이름이 좀 길어요. 가수 이름이 좀 길어요. 가수 이름이 좀 길어요. 가수 이름이 좀 길어요. 가수 이름이 좀 길어요.", "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/260/quality/80/optimize", - "totalLikeCount": 9000 + "totalLikeCount": 9000, + "genre": "DANCE" }, { "id": 4, "title": "Super Shy", "singer": "New Jeans", "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/260/quality/80/optimize", - "totalLikeCount": 8000 + "totalLikeCount": 8000, + "genre": "HIPHOP" }, { "id": 5, "title": "Super Shy", "singer": "New Jeans", "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/260/quality/80/optimize", - "totalLikeCount": 1 + "totalLikeCount": 1, + "genre": "HIPHOP" }, { "id": 6, "title": "Seven (feat. Latto) - Clean Ver.", "singer": "정국", "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/86/070/11286070_20230713181059_500.jpg/melon/resize/260/quality/80/optimize", - "totalLikeCount": 1 + "totalLikeCount": 1, + "genre": "HIPHOP" }, { "id": 7, "title": "노래 길이가 좀 많이 길어요. 노래 길이가 좀 많이 길어요. 노래 길이가 좀 많이 길어요. 노래 길이가 좀 많이 길어요.", "singer": "가수 이름이 좀 길어요. 가수 이름이 좀 길어요. 가수 이름이 좀 길어요. 가수 이름이 좀 길어요. 가수 이름이 좀 길어요.", "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/86/070/11286070_20230713181059_500.jpg/melon/resize/260/quality/80/optimize", - "totalLikeCount": 1 + "totalLikeCount": 1, + "genre": "HIPHOP" }, { "id": 8, "title": "Seven (feat. Latto) - Clean Ver.", "singer": "정국", "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/86/070/11286070_20230713181059_500.jpg/melon/resize/260/quality/80/optimize", - "totalLikeCount": 1 + "totalLikeCount": 1, + "genre": "BALLAD" }, { "id": 9, "title": "Seven (feat. Latto) - Clean Ver.", "singer": "정국", "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/86/070/11286070_20230713181059_500.jpg/melon/resize/260/quality/80/optimize", - "totalLikeCount": 1 + "totalLikeCount": 1, + "genre": "BALLAD" }, { "id": 10, "title": "Seven (feat. Latto) - Clean Ver.", "singer": "정국", "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/86/070/11286070_20230713181059_500.jpg/melon/resize/260/quality/80/optimize", - "totalLikeCount": 1 + "totalLikeCount": 1, + "genre": "BALLAD" }, { "id": 11, "title": "I AM", "singer": "IVE (아이브)", "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/11/297/11211297_20230410151046_500.jpg/melon/resize/260/quality/80/optimize", - "totalLikeCount": 1 + "totalLikeCount": 1, + "genre": "POP" }, { "id": 12, "title": "I AM", "singer": "IVE (아이브)", "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/11/297/11211297_20230410151046_500.jpg/melon/resize/260/quality/80/optimize", - "totalLikeCount": 1 + "totalLikeCount": 1, + "genre": "POP" }, { "id": 13, "title": "I AM", "singer": "IVE (아이브)", "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/11/297/11211297_20230410151046_500.jpg/melon/resize/260/quality/80/optimize", - "totalLikeCount": 1 + "totalLikeCount": 1, + "genre": "POP" }, { "id": 14, "title": "I AM", "singer": "IVE (아이브)", "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/11/297/11211297_20230410151046_500.jpg/melon/resize/260/quality/80/optimize", - "totalLikeCount": 1 + "totalLikeCount": 1, + "genre": "INDIE" }, { "id": 15, "title": "I AM", "singer": "IVE (아이브)", "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/11/297/11211297_20230410151046_500.jpg/melon/resize/260/quality/80/optimize", - "totalLikeCount": 1 + "totalLikeCount": 1, + "genre": "INDIE" }, { "id": 16, "title": "Super Shy", "singer": "New Jeans", "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/260/quality/80/optimize", - "totalLikeCount": 1 + "totalLikeCount": 1, + "genre": "INDIE" }, { "id": 17, "title": "Super Shy", "singer": "New Jeans", "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/260/quality/80/optimize", - "totalLikeCount": 1 + "totalLikeCount": 1, + "genre": "TROT" }, { "id": 18, "title": "Super Shy", "singer": "New Jeans", "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/260/quality/80/optimize", - "totalLikeCount": 1 + "totalLikeCount": 1, + "genre": "TROT" }, { "id": 19, "title": "Super Shy", "singer": "New Jeans", "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/260/quality/80/optimize", - "totalLikeCount": 1 + "totalLikeCount": 1, + "genre": "TROT" }, { "id": 20, "title": "Super Shy", "singer": "New Jeans", "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/260/quality/80/optimize", - "totalLikeCount": 1 + "totalLikeCount": 1, + "genre": "JAZZ" }, { "id": 21, "title": "Super Shy", "singer": "New Jeans", "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/260/quality/80/optimize", - "totalLikeCount": 1 + "totalLikeCount": 1, + "genre": "JAZZ" }, { "id": 22, "title": "Super Shy", "singer": "New Jeans", "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/260/quality/80/optimize", - "totalLikeCount": 1 + "totalLikeCount": 1, + "genre": "JAZZ" }, { "id": 23, "title": "Super Shy", "singer": "New Jeans", "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/260/quality/80/optimize", - "totalLikeCount": 1 + "totalLikeCount": 1, + "genre": "EDM" }, { "id": 24, "title": "Super Shy", "singer": "New Jeans", "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/260/quality/80/optimize", - "totalLikeCount": 1 + "totalLikeCount": 1, + "genre": "EDM" }, { "id": 25, "title": "Super Shy", "singer": "New Jeans", "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/260/quality/80/optimize", - "totalLikeCount": 1 + "totalLikeCount": 1, + "genre": "EDM" }, { "id": 26, "title": "Super Shy", "singer": "New Jeans", "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/260/quality/80/optimize", - "totalLikeCount": 1 + "totalLikeCount": 1, + "genre": "ETC" }, { "id": 27, "title": "Super Shy", "singer": "New Jeans", "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/260/quality/80/optimize", - "totalLikeCount": 1 + "totalLikeCount": 1, + "genre": "ETC" }, { "id": 28, "title": "Super Shy", "singer": "New Jeans", "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/260/quality/80/optimize", - "totalLikeCount": 1 + "totalLikeCount": 1, + "genre": "ETC" }, { "id": 29, "title": "Super Shy", "singer": "New Jeans", "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/260/quality/80/optimize", - "totalLikeCount": 1 + "totalLikeCount": 1, + "genre": "ETC" }, { "id": 30, "title": "Super Shy", "singer": "New Jeans", "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/260/quality/80/optimize", - "totalLikeCount": 1 + "totalLikeCount": 1, + "genre": "ROCK_METAL" }, { "id": 31, "title": "Super Shy", "singer": "New Jeans", "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/260/quality/80/optimize", - "totalLikeCount": 1 + "totalLikeCount": 1, + "genre": "ROCK_METAL" }, { "id": 32, "title": "Super Shy", "singer": "New Jeans", "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/260/quality/80/optimize", - "totalLikeCount": 1 + "totalLikeCount": 1, + "genre": "ROCK_METAL" }, { "id": 33, "title": "Super Shy", "singer": "New Jeans", "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/260/quality/80/optimize", - "totalLikeCount": 1 + "totalLikeCount": 1, + "genre": "CLASSIC" }, { "id": 34, "title": "Super Shy", "singer": "New Jeans", "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/260/quality/80/optimize", - "totalLikeCount": 1 + "totalLikeCount": 1, + "genre": "CLASSIC" }, { "id": 35, "title": "Super Shy", "singer": "New Jeans", "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/260/quality/80/optimize", - "totalLikeCount": 1 + "totalLikeCount": 1, + "genre": "CLASSIC" }, { "id": 36, "title": "Super Shy", "singer": "New Jeans", "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/260/quality/80/optimize", - "totalLikeCount": 1 + "totalLikeCount": 1, + "genre": "RHYTHM_AND_BLUES" }, { "id": 37, "title": "Super Shy", "singer": "New Jeans", "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/260/quality/80/optimize", - "totalLikeCount": 1 + "totalLikeCount": 1, + "genre": "RHYTHM_AND_BLUES" }, { "id": 38, "title": "Super Shy", "singer": "New Jeans", "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/260/quality/80/optimize", - "totalLikeCount": 1 + "totalLikeCount": 1, + "genre": "RHYTHM_AND_BLUES" }, { "id": 39, "title": "Super Shy", "singer": "New Jeans", "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/260/quality/80/optimize", - "totalLikeCount": 1 + "totalLikeCount": 1, + "genre": "FOLK_BLUES" }, { "id": 40, "title": "Super Shy", "singer": "New Jeans", "albumCoverUrl": "https://cdnimg.melon.co.kr/cm2/album/images/112/81/456/11281456_20230706180841_500.jpg/melon/resize/260/quality/80/optimize", - "totalLikeCount": 1 + "totalLikeCount": 1, + "genre": "FOLK_BLUES" } ] diff --git a/frontend/src/mocks/handlers/songsHandlers.ts b/frontend/src/mocks/handlers/songsHandlers.ts index fd56a3b2a..ca3d78e18 100644 --- a/frontend/src/mocks/handlers/songsHandlers.ts +++ b/frontend/src/mocks/handlers/songsHandlers.ts @@ -2,6 +2,7 @@ import { rest } from 'msw'; import comments from '../fixtures/comments.json'; import extraNextSongDetails from '../fixtures/extraNextSongDetails.json'; import extraPrevSongDetails from '../fixtures/extraPrevSongDetails.json'; +import highLikedSongs from '../fixtures/highLikedSongs.json'; import recentSongs from '../fixtures/recentSongs.json'; import songEntries from '../fixtures/songEntries.json'; import type { KillingPartPostRequest } from '@/shared/types/killingPart'; @@ -34,10 +35,23 @@ const songsHandlers = [ return res(ctx.status(201)); }), + rest.get(`${BASE_URL}/songs/high-liked`, (req, res, ctx) => { + const genre = req.url.searchParams.get('genre'); + + if (genre !== null) { + const targetGenreSongs = highLikedSongs.filter((song) => song.genre === genre); + + return res(ctx.status(200), ctx.json(targetGenreSongs)); + } + + return res(ctx.status(200), ctx.json(highLikedSongs)); + }), + rest.get(`${BASE_URL}/songs/high-liked/:songId`, (req, res, ctx) => { // const genre = req.url.searchParams.get('genre') return res(ctx.status(200), ctx.json(songEntries)); }), + rest.get(`${BASE_URL}/songs/high-liked/:songId/prev`, (req, res, ctx) => { // const genre = req.url.searchParams.get('genre'); const { songId } = req.params; diff --git a/frontend/src/pages/AuthPage.tsx b/frontend/src/pages/AuthPage.tsx index 700c451a9..1e6e47c81 100644 --- a/frontend/src/pages/AuthPage.tsx +++ b/frontend/src/pages/AuthPage.tsx @@ -1,21 +1,19 @@ import { useEffect } from 'react'; -import { Navigate, useNavigate, useParams, useSearchParams } from 'react-router-dom'; +import { Navigate, useNavigate, useSearchParams } from 'react-router-dom'; import { useAuthContext } from '@/features/auth/components/AuthProvider'; +import { getAccessToken } from '@/features/auth/remotes/auth'; import path from '@/shared/constants/path'; +import useValidParams from '@/shared/hooks/useValidParams'; import accessTokenStorage from '@/shared/utils/accessTokenStorage'; -interface AccessTokenResponse { - accessToken: string; -} - const AuthPage = () => { const [searchParams] = useSearchParams(); - const { platform } = useParams(); + const { platform } = useValidParams(); const { login } = useAuthContext(); const navigate = useNavigate(); - const getAccessToken = async () => { + const authLogin = async () => { const code = searchParams.get('code'); if (!code) { @@ -25,13 +23,7 @@ const AuthPage = () => { return; } - const response = await fetch(`${process.env.BASE_URL}/login/${platform}?code=${code}`, { - method: 'get', - credentials: 'include', - }); - - const data = (await response.json()) as AccessTokenResponse; - const { accessToken } = data; + const { accessToken } = await getAccessToken(platform, code); if (accessToken) { login(accessToken); @@ -39,7 +31,7 @@ const AuthPage = () => { }; useEffect(() => { - getAccessToken(); + authLogin(); }, []); return ; diff --git a/frontend/src/pages/EditProfilePage.tsx b/frontend/src/pages/EditProfilePage.tsx index 4191e8e0c..3f5500d82 100644 --- a/frontend/src/pages/EditProfilePage.tsx +++ b/frontend/src/pages/EditProfilePage.tsx @@ -12,16 +12,16 @@ import { useMutation } from '@/shared/hooks/useMutation'; const EditProfilePage = () => { const { user, logout } = useAuthContext(); const { isOpen, openModal, closeModal } = useModal(); - const { mutateData } = useMutation(deleteMember(user?.memberId)); + const { mutateData: withdrawal } = useMutation(deleteMember); const navigate = useNavigate(); if (!user) { navigate(ROUTE_PATH.LOGIN); - return; + return null; } const handleWithdrawal = async () => { - await mutateData(); + await withdrawal(user.memberId); logout(); navigate(ROUTE_PATH.ROOT); }; diff --git a/frontend/src/pages/PartCollectingPage.tsx b/frontend/src/pages/PartCollectingPage.tsx index 822f190b0..cf858359c 100644 --- a/frontend/src/pages/PartCollectingPage.tsx +++ b/frontend/src/pages/PartCollectingPage.tsx @@ -1,8 +1,8 @@ -import { useParams } from 'react-router-dom'; import { Flex } from 'shook-layout'; import { styled } from 'styled-components'; import RegisterPart from '@/features/killingParts/components/RegisterPart'; import VideoController from '@/features/killingParts/components/VideoController'; +import { getSong } from '@/features/killingParts/remotes/killingPart'; import { CollectingPartProvider } from '@/features/songs/components/CollectingPartProvider'; import SongInformation from '@/features/songs/components/SongInformation'; import { VideoPlayerProvider } from '@/features/youtube/components/VideoPlayerProvider'; @@ -10,13 +10,12 @@ import Youtube from '@/features/youtube/components/Youtube'; import Spacing from '@/shared/components/Spacing'; import SRHeading from '@/shared/components/SRHeading'; import useFetch from '@/shared/hooks/useFetch'; -import fetcher from '@/shared/remotes'; -import type { SongInfo } from '@/shared/types/song'; +import useValidParams from '@/shared/hooks/useValidParams'; const PartCollectingPage = () => { - const { id: songId } = useParams(); + const { id: songId } = useValidParams(); // TODO: 조회 API 만들어야함. - const { data: songInfo } = useFetch(() => fetcher(`/songs/${songId}`, 'GET')); + const { data: songInfo } = useFetch(() => getSong(Number(songId))); if (!songInfo) return; const { id, title, singer, videoLength, songVideoId, albumCoverUrl } = songInfo; diff --git a/frontend/src/shared/hooks/useExtraFetch.ts b/frontend/src/shared/hooks/useExtraFetch.ts index 758cc5783..d44bc3665 100644 --- a/frontend/src/shared/hooks/useExtraFetch.ts +++ b/frontend/src/shared/hooks/useExtraFetch.ts @@ -1,5 +1,5 @@ import { useCallback, useState } from 'react'; -import type { ErrorResponse } from '@/shared/remotes'; +import type { ErrorResponse } from '../types/errorResponse'; type FetchDirection = 'prev' | 'next'; diff --git a/frontend/src/shared/hooks/useFetch.ts b/frontend/src/shared/hooks/useFetch.ts index 410d22835..b4a96dfc7 100644 --- a/frontend/src/shared/hooks/useFetch.ts +++ b/frontend/src/shared/hooks/useFetch.ts @@ -2,11 +2,12 @@ import { useCallback, useEffect, useState } from 'react'; import { useAuthContext } from '@/features/auth/components/AuthProvider'; import { useLoginPopup } from '@/features/auth/hooks/LoginPopUpContext'; import AuthError from '@/shared/remotes/AuthError'; +import type { ErrorResponse } from '../types/errorResponse'; const useFetch = (fetcher: () => Promise, defaultFetch: boolean = true) => { const [data, setData] = useState(null); const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); + const [error, setError] = useState(null); const { popupLoginModal } = useLoginPopup(); const { logout } = useAuthContext(); @@ -28,7 +29,7 @@ const useFetch = (fetcher: () => Promise, defaultFetch: boolean = true) => popupLoginModal(error.code); return; } - setError(error as Error); + setError(error as ErrorResponse); } finally { setIsLoading(false); } diff --git a/frontend/src/shared/hooks/useMutation.ts b/frontend/src/shared/hooks/useMutation.ts index a7289a371..7d60fec65 100644 --- a/frontend/src/shared/hooks/useMutation.ts +++ b/frontend/src/shared/hooks/useMutation.ts @@ -2,12 +2,13 @@ import { useCallback, useState } from 'react'; import { useAuthContext } from '@/features/auth/components/AuthProvider'; import { useLoginPopup } from '@/features/auth/hooks/LoginPopUpContext'; import AuthError from '@/shared/remotes/AuthError'; +import type { ErrorResponse } from '../types/errorResponse'; // eslint-disable-next-line export const useMutation = (mutateFn: (...params: P) => Promise) => { const [data, setData] = useState(null); const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); + const [error, setError] = useState(null); const { popupLoginModal } = useLoginPopup(); const { logout } = useAuthContext(); @@ -30,7 +31,7 @@ export const useMutation = (mutateFn: (...params: P) => Prom popupLoginModal(error.code); return; } - setError(error as Error); + setError(error as ErrorResponse); } finally { setIsLoading(false); } diff --git a/frontend/src/shared/remotes/axios.ts b/frontend/src/shared/remotes/axios.ts new file mode 100644 index 000000000..dd54b8939 --- /dev/null +++ b/frontend/src/shared/remotes/axios.ts @@ -0,0 +1,75 @@ +import axios from 'axios'; +import { postRefreshAccessToken } from '@/features/auth/remotes/auth'; +import type { AccessTokenRes } from '@/features/auth/types/auth.type'; +import type { AxiosError, CreateAxiosDefaults, InternalAxiosRequestConfig } from 'axios'; + +const { BASE_URL } = process.env; + +const defaultConfig: CreateAxiosDefaults = { + baseURL: BASE_URL, + headers: { + 'Content-Type': 'application/json', + }, + withCredentials: true, +}; + +const clientBasic = axios.create(defaultConfig); +const client = axios.create(defaultConfig); + +// 요청 인터셉터 +const setToken = (config: InternalAxiosRequestConfig) => { + const accessToken = localStorage.getItem('userToken'); + + if (accessToken) { + config.headers.Authorization = `Bearer ${accessToken}`; + } + + return config; +}; + +// 응답 에러 인터셉터 +let reissuePromise: Promise | null = null; + +const reissueOnExpiredTokenError = async (error: AxiosError) => { + const originalRequest = error.config; + const isAuthError = error.response?.status === 401; + const hasAuthorization = !!originalRequest?.headers.Authorization; + + if (isAuthError && hasAuthorization) { + try { + const { accessToken } = await (reissuePromise ??= reissue()); + + originalRequest.headers.Authorization = `Bearer ${accessToken}`; + + return client(originalRequest); + } catch (error) { + return Promise.reject(error); + } finally { + reissuePromise = null; + } + } + + return Promise.reject(error); +}; + +const reissue = async () => { + try { + const response = await postRefreshAccessToken(); + const { accessToken } = response; + + localStorage.setItem('userToken', accessToken); + return response; + } catch (error) { + window.alert('세션이 만료되었습니다. 다시 로그인 해주세요'); + localStorage.removeItem('userToken'); + window.location.href = '/login'; + + throw error; + } +}; + +clientBasic.interceptors.request.use(setToken); +client.interceptors.request.use(setToken); +client.interceptors.response.use((response) => response, reissueOnExpiredTokenError); + +export { clientBasic, client }; diff --git a/frontend/src/shared/remotes/index.ts b/frontend/src/shared/remotes/index.ts deleted file mode 100644 index 45f26d4d9..000000000 --- a/frontend/src/shared/remotes/index.ts +++ /dev/null @@ -1,53 +0,0 @@ -import AuthError from '@/shared/remotes/AuthError'; -import preCheckAccessToken from '@/shared/remotes/preCheckAccessToken'; - -export interface ErrorResponse { - code: number; - message: string; -} - -const { BASE_URL } = process.env; - -const fetcher = async (url: string, method: string, body?: unknown) => { - const headers: Record = { - 'Content-type': 'application/json', - }; - - const accessToken = await preCheckAccessToken(); - - if (accessToken) { - headers['Authorization'] = `Bearer ${accessToken}`; - } - - const options: RequestInit = { - method, - headers, - }; - - if (body) { - options.body = JSON.stringify(body); - } - - const response = await fetch(`${BASE_URL}${url}`, options); - - if (!response.ok) { - const errorResponse: ErrorResponse = await response.json(); - - if (response.status >= 500) { - throw new Error(errorResponse.message); - } - - if (response.status === 401) { - throw new AuthError(errorResponse); - } - - throw new Error(errorResponse.message); - } - - const contentType = response.headers.get('content-type'); - if (!contentType || !contentType.includes('application/json')) return response; - - return response.json(); -}; - -export default fetcher; diff --git a/frontend/src/shared/remotes/preCheckAccessToken.ts b/frontend/src/shared/remotes/preCheckAccessToken.ts deleted file mode 100644 index 718e10bed..000000000 --- a/frontend/src/shared/remotes/preCheckAccessToken.ts +++ /dev/null @@ -1,38 +0,0 @@ -import accessTokenStorage from '@/shared/utils/accessTokenStorage'; - -const isTokenExpiredAfter60seconds = (tokenExp: number) => { - return tokenExp * 1000 - 30 * 1000 < Date.now(); -}; - -const preCheckAccessToken = async () => { - const accessTokenWithPayload = accessTokenStorage.getTokenWithPayload(); - - if (accessTokenWithPayload) { - const { - accessToken, - payload: { exp }, - } = accessTokenWithPayload; - - if (!isTokenExpiredAfter60seconds(exp)) { - return accessToken; - } - - const response = await fetch(`${process.env.BASE_URL}/reissue`, { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }); - - if (response.ok) { - const { accessToken } = await response.json(); - accessTokenStorage.setToken(accessToken); - return accessToken; - } - accessTokenStorage.removeToken(); - } - - return null; -}; - -export default preCheckAccessToken; diff --git a/frontend/src/shared/types/errorResponse.ts b/frontend/src/shared/types/errorResponse.ts new file mode 100644 index 000000000..c85a9e871 --- /dev/null +++ b/frontend/src/shared/types/errorResponse.ts @@ -0,0 +1,4 @@ +export interface ErrorResponse { + code: number; + message: string; +} From 3745893fab8e052d2d98fed9a74829adedca54bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=84=ED=98=84?= <77152650+Creative-Lee@users.noreply.github.com> Date: Tue, 26 Dec 2023 17:10:00 +0900 Subject: [PATCH 2/3] =?UTF-8?q?Feat/#558=20=EC=8A=A4=EC=99=80=EC=9D=B4?= =?UTF-8?q?=ED=94=84,=20=EB=8C=93=EA=B8=80=20=EA=B8=B0=EB=8A=A5=EC=97=90?= =?UTF-8?q?=20React-Query=20=EC=A0=81=EC=9A=A9=20(#559)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * config: react-query, devtools 설치 * feat: 쿼리프로바이더, 데브 툴 적용 * refactor: 양방향 스와이프 로직 React Query 적용 1. useQuery, useInfiteQuery hook으로 기존 extraFetch hook 대체 2. entries api 응답값의 prev, next 사용하지 않게 되었음. * refactor: 코멘트 작성 로직 React Query 적용 1. useQuery, useMutation hook으로 기존 로직 대체 2. remote 함수 인자타입 변경 - mutateFn의 함수는 인자를 1개로 제한 3. onSuccess는 useMutation 과 mutate 함수 순서로 실행됨 * test: 댓글 mock 핸들러 수정 --- frontend/package-lock.json | 53 +++++++++++++++++++ frontend/package.json | 2 + .../comments/components/CommentForm.tsx | 34 +++++++----- .../comments/components/CommentList.tsx | 14 ++--- .../src/features/comments/queries/index.ts | 24 +++++++++ .../src/features/comments/remotes/comments.ts | 10 +++- .../songs/hooks/useExtraSongDetail.ts | 50 ++++++++--------- .../songs/hooks/useSongDetailEntries.ts | 12 ++--- frontend/src/features/songs/queries/index.ts | 50 +++++++++++++++++ frontend/src/index.tsx | 13 +++-- frontend/src/mocks/handlers/songsHandlers.ts | 18 +++++-- frontend/src/pages/SongDetailListPage.tsx | 49 +++++++++++------ 12 files changed, 245 insertions(+), 84 deletions(-) create mode 100644 frontend/src/features/comments/queries/index.ts create mode 100644 frontend/src/features/songs/queries/index.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a8eefaf1b..a6a61f1b2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "1.1.0", "license": "MIT", "dependencies": { + "@tanstack/react-query": "^5.14.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.14.1", @@ -29,6 +30,7 @@ "@storybook/react": "^7.0.27", "@storybook/react-webpack5": "^7.0.27", "@storybook/testing-library": "^0.0.14-next.2", + "@tanstack/react-query-devtools": "^5.14.1", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^14.0.0", "@testing-library/user-event": "^14.4.3", @@ -5869,6 +5871,57 @@ "node": ">=10" } }, + "node_modules/@tanstack/query-core": { + "version": "5.14.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.14.1.tgz", + "integrity": "sha512-TlZarySCVEiap4K7BCvrsYZnX7jBbEkR55YMrk8ELcRbuAx6ydL+qoxqUt8Fq8VMvQyGt52icn6T7eJL1Q35KQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.13.5", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.13.5.tgz", + "integrity": "sha512-effSYz9AWcZ6sNd+c8LCBYFIuDZApoCTXEpRlEYChBZpMz9QUUVMLToThwCyUY49+T5pANL3XxgZf3HV7hwJlg==", + "dev": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.14.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.14.1.tgz", + "integrity": "sha512-v7jhe/3jhChiR0XJbGHaG5WNPd/cURwzDGBCr4rzpUTeudPzxrtVRKsF1xJRLcJK3qH/0gIwTYHIPZ3gj+01Yw==", + "dependencies": { + "@tanstack/query-core": "5.14.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18.0.0" + } + }, + "node_modules/@tanstack/react-query-devtools": { + "version": "5.14.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.14.1.tgz", + "integrity": "sha512-8fuQs0AMQk8D66JUYqdYA33fOObevuWwm1atOnPbtV8PvIscaU0i/cNTqCl1Y10rgbR/QsqxQSJGBZ5TxxBrlA==", + "dev": true, + "dependencies": { + "@tanstack/query-devtools": "5.13.5" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.14.1", + "react": "^18.0.0" + } + }, "node_modules/@testing-library/dom": { "version": "8.20.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index dbbc95a82..ab5137b29 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ }, "license": "MIT", "dependencies": { + "@tanstack/react-query": "^5.14.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.14.1", @@ -33,6 +34,7 @@ "@storybook/react": "^7.0.27", "@storybook/react-webpack5": "^7.0.27", "@storybook/testing-library": "^0.0.14-next.2", + "@tanstack/react-query-devtools": "^5.14.1", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^14.0.0", "@testing-library/user-event": "^14.4.3", diff --git a/frontend/src/features/comments/components/CommentForm.tsx b/frontend/src/features/comments/components/CommentForm.tsx index 97d3c60b5..56eb9aba2 100644 --- a/frontend/src/features/comments/components/CommentForm.tsx +++ b/frontend/src/features/comments/components/CommentForm.tsx @@ -7,25 +7,24 @@ import LoginModal from '@/features/auth/components/LoginModal'; import Avatar from '@/shared/components/Avatar'; import useModal from '@/shared/components/Modal/hooks/useModal'; import useToastContext from '@/shared/components/Toast/hooks/useToastContext'; -import { useMutation } from '@/shared/hooks/useMutation'; -import { postComment } from '../remotes/comments'; +import { usePostCommentMutation } from '../queries'; interface CommentFormProps { - getComments: () => Promise; songId: number; partId: number; } -const CommentForm = ({ getComments, songId, partId }: CommentFormProps) => { +const CommentForm = ({ songId, partId }: CommentFormProps) => { const [newComment, setNewComment] = useState(''); const { isOpen, closeModal: closeLoginModal, openModal: openLoginModal } = useModal(); const { user } = useAuthContext(); const isLoggedIn = !!user; - const { mutateData: postNewComment } = useMutation(() => - postComment(songId, partId, newComment.trim()) - ); + const { + postNewComment, + mutations: { isPending: isPendingPostComment }, + } = usePostCommentMutation(); const { showToast } = useToastContext(); @@ -35,14 +34,18 @@ const CommentForm = ({ getComments, songId, partId }: CommentFormProps) => { currentTarget: { value }, }) => setNewComment(value); - const submitNewComment: React.FormEventHandler = async (event) => { + const submitNewComment: React.FormEventHandler = (event) => { event.preventDefault(); - await postNewComment(); - - showToast('댓글이 등록되었습니다.'); - resetNewComment(); - await getComments(); + postNewComment( + { songId, partId, content: newComment.trim() }, + { + onSuccess: () => { + showToast('댓글이 등록되었습니다.'); + resetNewComment(); + }, + } + ); }; return ( @@ -53,6 +56,7 @@ const CommentForm = ({ getComments, songId, partId }: CommentFormProps) => { color.disabledBackground}; + } `; const FlexEnd = styled.div` diff --git a/frontend/src/features/comments/components/CommentList.tsx b/frontend/src/features/comments/components/CommentList.tsx index 8ddb99001..1595853a8 100644 --- a/frontend/src/features/comments/components/CommentList.tsx +++ b/frontend/src/features/comments/components/CommentList.tsx @@ -1,12 +1,10 @@ -import { useEffect } from 'react'; import { styled } from 'styled-components'; import cancelIcon from '@/assets/icon/cancel.svg'; import BottomSheet from '@/shared/components/BottomSheet/BottomSheet'; import useModal from '@/shared/components/Modal/hooks/useModal'; import Spacing from '@/shared/components/Spacing'; import SRHeading from '@/shared/components/SRHeading'; -import useFetch from '@/shared/hooks/useFetch'; -import { getComments } from '../remotes/comments'; +import { useCommentsQuery } from '../queries'; import Comment from './Comment'; import CommentForm from './CommentForm'; @@ -17,13 +15,7 @@ interface CommentListProps { const CommentList = ({ songId, partId }: CommentListProps) => { const { isOpen, openModal, closeModal } = useModal(false); - const { data: comments, fetchData: refetchComments } = useFetch(() => - getComments(songId, partId) - ); - - useEffect(() => { - refetchComments(); - }, [partId]); + const { comments } = useCommentsQuery(songId, partId); if (!comments) { return null; @@ -66,7 +58,7 @@ const CommentList = ({ songId, partId }: CommentListProps) => { ))} - + ); diff --git a/frontend/src/features/comments/queries/index.ts b/frontend/src/features/comments/queries/index.ts new file mode 100644 index 000000000..1e52cb109 --- /dev/null +++ b/frontend/src/features/comments/queries/index.ts @@ -0,0 +1,24 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { getComments, postComment } from '../remotes/comments'; + +export const useCommentsQuery = (songId: number, partId: number) => { + const { data: comments, ...queries } = useQuery({ + queryKey: ['comments', songId, partId], + queryFn: () => getComments(songId, partId), + }); + + return { comments, queries }; +}; + +export const usePostCommentMutation = () => { + const client = useQueryClient(); + + const { mutate: postNewComment, ...mutations } = useMutation({ + mutationFn: postComment, + onSuccess: (_, { songId, partId }) => { + client.invalidateQueries({ queryKey: ['comments', songId, partId] }); + }, + }); + + return { postNewComment, mutations }; +}; diff --git a/frontend/src/features/comments/remotes/comments.ts b/frontend/src/features/comments/remotes/comments.ts index ed4f68e85..a47d575c4 100644 --- a/frontend/src/features/comments/remotes/comments.ts +++ b/frontend/src/features/comments/remotes/comments.ts @@ -1,7 +1,15 @@ import { client } from '@/shared/remotes/axios'; import type { Comment } from '../types/comment.type'; -export const postComment = async (songId: number, partId: number, content: string) => { +export const postComment = async ({ + songId, + partId, + content, +}: { + songId: number; + partId: number; + content: string; +}) => { await client.post(`/songs/${songId}/parts/${partId}/comments`, { content }); }; diff --git a/frontend/src/features/songs/hooks/useExtraSongDetail.ts b/frontend/src/features/songs/hooks/useExtraSongDetail.ts index 539fb93e4..cbabaf231 100644 --- a/frontend/src/features/songs/hooks/useExtraSongDetail.ts +++ b/frontend/src/features/songs/hooks/useExtraSongDetail.ts @@ -1,31 +1,33 @@ import { useCallback, useRef } from 'react'; -import useExtraFetch from '@/shared/hooks/useExtraFetch'; import useValidParams from '@/shared/hooks/useValidParams'; import createObserver from '@/shared/utils/createObserver'; -import { getExtraNextSongDetails, getExtraPrevSongDetails } from '../remotes/songs'; +import { + useExtraNextSongDetailsInfiniteQuery, + useExtraPrevSongDetailsInfiniteQuery, +} from '../queries'; import type { Genre } from '../types/Song.type'; const useExtraSongDetail = () => { - const { genre: genreParams } = useValidParams(); + const { id: songIdParams, genre: genreParams } = useValidParams(); - const { data: extraPrevSongDetails, fetchData: fetchExtraPrevSongDetails } = useExtraFetch( - getExtraPrevSongDetails, - 'prev' - ); + const { + extraPrevSongDetails, + fetchExtraPrevSongDetails, + infiniteQueries: { isLoading: isLoadingPrevSongDetails, hasPreviousPage }, + } = useExtraPrevSongDetailsInfiniteQuery(Number(songIdParams), genreParams as Genre); - const { data: extraNextSongDetails, fetchData: fetchExtraNextSongDetails } = useExtraFetch( - getExtraNextSongDetails, - 'next' - ); + const { + extraNextSongDetails, + fetchExtraNextSongDetails, + infiniteQueries: { isLoading: isLoadingNextSongDetails, hasNextPage }, + } = useExtraNextSongDetailsInfiniteQuery(Number(songIdParams), genreParams as Genre); const prevObserverRef = useRef(null); const nextObserverRef = useRef(null); const getExtraPrevSongDetailsOnObserve: React.RefCallback = useCallback((dom) => { if (dom !== null) { - prevObserverRef.current = createObserver(() => - fetchExtraPrevSongDetails(getFirstSongId(dom), genreParams as Genre) - ); + prevObserverRef.current = createObserver(() => fetchExtraPrevSongDetails()); prevObserverRef.current.observe(dom); return; @@ -36,9 +38,7 @@ const useExtraSongDetail = () => { const getExtraNextSongDetailsOnObserve: React.RefCallback = useCallback((dom) => { if (dom !== null) { - nextObserverRef.current = createObserver(() => - fetchExtraNextSongDetails(getLastSongId(dom), genreParams as Genre) - ); + nextObserverRef.current = createObserver(() => fetchExtraNextSongDetails()); nextObserverRef.current.observe(dom); return; @@ -47,21 +47,13 @@ const useExtraSongDetail = () => { nextObserverRef.current?.disconnect(); }, []); - const getFirstSongId = (dom: HTMLDivElement) => { - const firstSongId = dom.nextElementSibling?.getAttribute('data-song-id') as string; - - return Number(firstSongId); - }; - - const getLastSongId = (dom: HTMLDivElement) => { - const lastSongId = dom.previousElementSibling?.getAttribute('data-song-id') as string; - - return Number(lastSongId); - }; - return { extraPrevSongDetails, extraNextSongDetails, + isLoadingPrevSongDetails, + isLoadingNextSongDetails, + hasPreviousPage, + hasNextPage, getExtraPrevSongDetailsOnObserve, getExtraNextSongDetailsOnObserve, }; diff --git a/frontend/src/features/songs/hooks/useSongDetailEntries.ts b/frontend/src/features/songs/hooks/useSongDetailEntries.ts index 4667dd4ce..ca0cba30b 100644 --- a/frontend/src/features/songs/hooks/useSongDetailEntries.ts +++ b/frontend/src/features/songs/hooks/useSongDetailEntries.ts @@ -1,21 +1,21 @@ import { useCallback } from 'react'; -import useFetch from '@/shared/hooks/useFetch'; import useValidParams from '@/shared/hooks/useValidParams'; -import { getSongDetailEntries } from '../remotes/songs'; +import { useSongDetailEntriesQuery } from '../queries'; import type { Genre } from '../types/Song.type'; const useSongDetailEntries = () => { const { id: songIdParams, genre: genreParams } = useValidParams(); - const { data: songDetailEntries } = useFetch(() => - getSongDetailEntries(Number(songIdParams), genreParams as Genre) - ); + const { + songDetailEntries, + queries: { isLoading: isLoadingSongDetailEntries }, + } = useSongDetailEntriesQuery(Number(songIdParams), genreParams as Genre); const scrollIntoCurrentSong: React.RefCallback = useCallback((dom) => { if (dom !== null) dom.scrollIntoView({ behavior: 'instant', block: 'start' }); }, []); - return { songDetailEntries, scrollIntoCurrentSong }; + return { songDetailEntries, isLoadingSongDetailEntries, scrollIntoCurrentSong }; }; export default useSongDetailEntries; diff --git a/frontend/src/features/songs/queries/index.ts b/frontend/src/features/songs/queries/index.ts new file mode 100644 index 000000000..12454dc11 --- /dev/null +++ b/frontend/src/features/songs/queries/index.ts @@ -0,0 +1,50 @@ +import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; +import { + getExtraNextSongDetails, + getExtraPrevSongDetails, + getSongDetailEntries, +} from '../remotes/songs'; +import type { Genre } from '../types/Song.type'; + +export const useSongDetailEntriesQuery = (songId: number, genre: Genre) => { + const { data: songDetailEntries, ...queries } = useQuery({ + queryKey: ['songDetailEntries'], + queryFn: () => getSongDetailEntries(songId, genre), + staleTime: Infinity, + }); + + return { songDetailEntries, queries }; +}; + +export const useExtraPrevSongDetailsInfiniteQuery = (songId: number, genre: Genre) => { + const { + data: extraPrevSongDetails, + fetchPreviousPage: fetchExtraPrevSongDetails, + ...infiniteQueries + } = useInfiniteQuery({ + queryKey: ['extraPrevSongDetails'], + queryFn: ({ pageParam }) => getExtraPrevSongDetails(pageParam, genre), + getPreviousPageParam: (firstPage) => firstPage[0]?.id ?? null, + getNextPageParam: () => null, + initialPageParam: songId, + staleTime: Infinity, + }); + + return { extraPrevSongDetails, fetchExtraPrevSongDetails, infiniteQueries }; +}; + +export const useExtraNextSongDetailsInfiniteQuery = (songId: number, genre: Genre) => { + const { + data: extraNextSongDetails, + fetchNextPage: fetchExtraNextSongDetails, + ...infiniteQueries + } = useInfiniteQuery({ + queryKey: ['extraNextSongDetails'], + queryFn: ({ pageParam }) => getExtraNextSongDetails(pageParam, genre), + getNextPageParam: (lastPage) => lastPage.at(-1)?.id ?? null, + initialPageParam: songId, + staleTime: Infinity, + }); + + return { extraNextSongDetails, fetchExtraNextSongDetails, infiniteQueries }; +}; diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 67e866ffc..9bbea1b7e 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -1,3 +1,5 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import React from 'react'; import { createRoot } from 'react-dom/client'; import { RouterProvider } from 'react-router-dom'; @@ -9,6 +11,8 @@ import router from './router'; import ToastProvider from './shared/components/Toast/ToastProvider'; import theme from './shared/styles/theme'; +const queryClient = new QueryClient(); + async function main() { if (process.env.NODE_ENV === 'development') { const { worker } = await import('./mocks/browser'); @@ -30,9 +34,12 @@ async function main() { - - - + + + + + + diff --git a/frontend/src/mocks/handlers/songsHandlers.ts b/frontend/src/mocks/handlers/songsHandlers.ts index ca3d78e18..288b69656 100644 --- a/frontend/src/mocks/handlers/songsHandlers.ts +++ b/frontend/src/mocks/handlers/songsHandlers.ts @@ -9,13 +9,21 @@ import type { KillingPartPostRequest } from '@/shared/types/killingPart'; const { BASE_URL } = process.env; +const mockComments = [...comments]; + const songsHandlers = [ rest.get(`${BASE_URL}/songs/:songId/parts/:partId/comments`, (req, res, ctx) => { - return res(ctx.status(200), ctx.json(comments)); + return res(ctx.status(200), ctx.json(mockComments)); }), rest.post(`${BASE_URL}/songs/:songId/parts/:partId/comments`, async (req, res, ctx) => { - return res(ctx.status(201)); + mockComments.push({ + id: 123123124124, + content: '댓글 추가 목데이터 테스트입니다.', + createdAt: new Date().toISOString(), + writerNickname: '목데이터', + }); + return res(ctx.status(201), ctx.delay(1000)); }), rest.post(`${BASE_URL}/songs/:songId/parts`, async (req, res, ctx) => { @@ -49,7 +57,7 @@ const songsHandlers = [ rest.get(`${BASE_URL}/songs/high-liked/:songId`, (req, res, ctx) => { // const genre = req.url.searchParams.get('genre') - return res(ctx.status(200), ctx.json(songEntries)); + return res(ctx.status(200), ctx.json(songEntries), ctx.delay(1000)); }), rest.get(`${BASE_URL}/songs/high-liked/:songId/prev`, (req, res, ctx) => { @@ -59,7 +67,7 @@ const songsHandlers = [ const targetIdx = extraPrevSongDetails.findIndex((song) => song.id === Number(songId)); const sliced = extraPrevSongDetails.slice(0, targetIdx); - return res(ctx.status(200), ctx.json(sliced)); + return res(ctx.status(200), ctx.json(sliced), ctx.delay(1000)); }), rest.get(`${BASE_URL}/songs/high-liked/:songId/next`, (req, res, ctx) => { @@ -70,7 +78,7 @@ const songsHandlers = [ const targetIdx = extraNextSongDetails.findIndex((song) => song.id === Number(songId)); const sliced = extraNextSongDetails.slice(targetIdx); - return res(ctx.status(200), ctx.json(sliced)); + return res(ctx.status(200), ctx.json(sliced), ctx.delay(1000)); }), rest.get(`${BASE_URL}/songs/recent`, (req, res, ctx) => { diff --git a/frontend/src/pages/SongDetailListPage.tsx b/frontend/src/pages/SongDetailListPage.tsx index 176bc334d..9aae5dbfa 100644 --- a/frontend/src/pages/SongDetailListPage.tsx +++ b/frontend/src/pages/SongDetailListPage.tsx @@ -17,13 +17,20 @@ const SongDetailListPage = () => { const { extraPrevSongDetails, extraNextSongDetails, + isLoadingNextSongDetails, + isLoadingPrevSongDetails, + hasPreviousPage, + hasNextPage, getExtraPrevSongDetailsOnObserve, getExtraNextSongDetailsOnObserve, } = useExtraSongDetail(); - if (!songDetailEntries) return null; + // Suspense 적용시 워터폴 문제 해결 후 Suspense 적용 + // 적용 시 아래 분기문 사라짐. + if (!songDetailEntries || isLoadingNextSongDetails || isLoadingPrevSongDetails) return null; - const { prevSongs, currentSong, nextSongs } = songDetailEntries; + // 응답값의 prev, next 사용하지 않게 되었음. + const { currentSong } = songDetailEntries; const closeCoachMark = () => { setOnboarding(false); @@ -51,25 +58,35 @@ const SongDetailListPage = () => { )} - ); From b00731071ed63d0ac80e2bdfe3e922140a35d832 Mon Sep 17 00:00:00 2001 From: Eunsol Kim <61370551+Cyma-s@users.noreply.github.com> Date: Wed, 17 Jan 2024 11:58:05 +0900 Subject: [PATCH 3/3] =?UTF-8?q?Feat/#543=20=EB=A1=9C=EC=BB=AC=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=EC=97=90=20=EC=A2=8B=EC=95=84=EC=9A=94=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=EA=B0=80=20=EC=8B=A4=EC=8B=9C=EA=B0=84?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B0=98=EC=98=81=EB=90=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95=20(#549)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 로컬 인터셉터 추가 * refactor: TreeMap 으로 노래 캐싱 및 좋아요 실시간 반영 * refactor: 삽입 정렬로 정렬 속도 개선 * refactor: 로컬 실행 시 토큰이 아닌 Authorization의 memberId 를 사용하도록 변경 * config: 로컬 config 롤백 * fix: 삽입 정렬 로직 추가 * refactor: EntityManager 의존성 이동 및 메서드, 필드 이름 리팩터링 * refactor: 조건문 메서드 분리 * refactor: 접근 제어자 private 으로 변경 * refactor: KillingPart 가 AtomicInteger 를 필드로 갖도록 수정 * refactor: 사용하지 않는 dto 클래스 삭제 * refactor: 사용하지 않는 메서드 삭제 * refactor: 접근 제어자 private 으로 변경 * refactor: 중복 범위 검증 제거 * refactor: 분기문 제거 * feat: likeCount AtomicInteger 로 변경 및 좋아요 개수 데이터베이스와 동기화하는 로직 추가 * fix: setUp 메서드 실행 시점 변경 * refactor: synchronized 로 동시성 처리 * fix: cache 재생성 제거 --- .../shook/shook/auth/config/AuthConfig.java | 2 + .../shook/auth/config/LocalAuthConfig.java | 33 ++++ .../auth/ui/interceptor/LocalInterceptor.java | 28 +++ .../application/InMemorySongsScheduler.java | 26 ++- .../shook/song/application/SongService.java | 1 - .../killingpart/KillingPartLikeService.java | 6 +- .../shook/song/domain/InMemorySongs.java | 171 +++++++++++++----- .../shook/shook/song/domain/KillingParts.java | 5 +- .../song/domain/killingpart/KillingPart.java | 21 ++- .../domain/killingpart/KillingPartLikes.java | 6 +- .../killingpart/LikeCountConverter.java | 19 ++ .../repository/KillingPartRepository.java | 5 +- .../domain/repository/SongRepository.java | 5 +- .../src/main/resources/application-test.yml | 3 +- backend/src/main/resources/application.yml | 1 + backend/src/main/resources/shook-security | 2 +- .../InMemorySongsSchedulerTest.java | 36 ++++ .../song/application/SongServiceTest.java | 110 +++++------ .../KillingPartLikeConcurrencyTest.java | 74 +++++--- .../KillingPartLikeServiceTest.java | 84 +++++---- .../shook/song/domain/InMemorySongsTest.java | 26 +-- .../killingpart/KillingPartLikesTest.java | 4 +- .../repository/KillingPartRepositoryTest.java | 72 ++++---- .../song/ui/HighLikedSongControllerTest.java | 24 ++- .../shook/song/ui/MyPageControllerTest.java | 6 + .../shook/song/ui/SongControllerTest.java | 2 +- .../song/ui/SongSwipeControllerTest.java | 16 +- 27 files changed, 531 insertions(+), 257 deletions(-) create mode 100644 backend/src/main/java/shook/shook/auth/config/LocalAuthConfig.java create mode 100644 backend/src/main/java/shook/shook/auth/ui/interceptor/LocalInterceptor.java create mode 100644 backend/src/main/java/shook/shook/song/domain/killingpart/LikeCountConverter.java diff --git a/backend/src/main/java/shook/shook/auth/config/AuthConfig.java b/backend/src/main/java/shook/shook/auth/config/AuthConfig.java index 59f446a4e..fdd674e61 100644 --- a/backend/src/main/java/shook/shook/auth/config/AuthConfig.java +++ b/backend/src/main/java/shook/shook/auth/config/AuthConfig.java @@ -2,6 +2,7 @@ import java.util.List; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.HandlerInterceptor; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; @@ -12,6 +13,7 @@ import shook.shook.auth.ui.interceptor.PathMethod; import shook.shook.auth.ui.interceptor.TokenInterceptor; +@Profile("!local") @Configuration public class AuthConfig implements WebMvcConfigurer { diff --git a/backend/src/main/java/shook/shook/auth/config/LocalAuthConfig.java b/backend/src/main/java/shook/shook/auth/config/LocalAuthConfig.java new file mode 100644 index 000000000..03d433797 --- /dev/null +++ b/backend/src/main/java/shook/shook/auth/config/LocalAuthConfig.java @@ -0,0 +1,33 @@ +package shook.shook.auth.config; + +import java.util.List; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import shook.shook.auth.ui.argumentresolver.AuthArgumentResolver; +import shook.shook.auth.ui.interceptor.LocalInterceptor; + +@Profile("local") +@Configuration +public class LocalAuthConfig implements WebMvcConfigurer { + + private final AuthArgumentResolver authArgumentResolver; + private final LocalInterceptor localInterceptor; + + public LocalAuthConfig(final AuthArgumentResolver authArgumentResolver, final LocalInterceptor localInterceptor) { + this.authArgumentResolver = authArgumentResolver; + this.localInterceptor = localInterceptor; + } + + @Override + public void addInterceptors(final InterceptorRegistry registry) { + registry.addInterceptor(localInterceptor); + } + + @Override + public void addArgumentResolvers(final List resolvers) { + resolvers.add(authArgumentResolver); + } +} diff --git a/backend/src/main/java/shook/shook/auth/ui/interceptor/LocalInterceptor.java b/backend/src/main/java/shook/shook/auth/ui/interceptor/LocalInterceptor.java new file mode 100644 index 000000000..4f70aed5e --- /dev/null +++ b/backend/src/main/java/shook/shook/auth/ui/interceptor/LocalInterceptor.java @@ -0,0 +1,28 @@ +package shook.shook.auth.ui.interceptor; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; +import shook.shook.auth.ui.AuthContext; + +@Profile("local") +@Component +public class LocalInterceptor implements HandlerInterceptor { + + private final AuthContext authContext; + + public LocalInterceptor(final AuthContext authContext) { + this.authContext = authContext; + } + + @Override + public boolean preHandle(final HttpServletRequest request, final HttpServletResponse response, final Object handler) + throws Exception { + final long memberId = Long.parseLong(request.getHeader("Authorization")); + authContext.setAuthenticatedMember(memberId); + + return true; + } +} diff --git a/backend/src/main/java/shook/shook/song/application/InMemorySongsScheduler.java b/backend/src/main/java/shook/shook/song/application/InMemorySongsScheduler.java index f0f102efa..d0035607e 100644 --- a/backend/src/main/java/shook/shook/song/application/InMemorySongsScheduler.java +++ b/backend/src/main/java/shook/shook/song/application/InMemorySongsScheduler.java @@ -1,12 +1,16 @@ package shook.shook.song.application; import jakarta.annotation.PostConstruct; +import jakarta.persistence.EntityManager; +import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import shook.shook.song.domain.InMemorySongs; +import shook.shook.song.domain.Song; +import shook.shook.song.domain.killingpart.KillingPart; import shook.shook.song.domain.repository.SongRepository; @RequiredArgsConstructor @@ -17,6 +21,7 @@ public class InMemorySongsScheduler { private final SongRepository songRepository; private final InMemorySongs inMemorySongs; + private final EntityManager entityManager; @PostConstruct public void initialize() { @@ -26,6 +31,25 @@ public void initialize() { @Scheduled(cron = "${schedules.in-memory-song.cron}") public void recreateCachedSong() { log.info("InMemorySongsScheduler worked"); - inMemorySongs.recreate(songRepository.findAllWithKillingParts()); + final List songs = songRepository.findAllWithKillingPartsAndLikes(); + detachSongs(songs); + inMemorySongs.refreshSongs(songs); + } + + private void detachSongs(final List songs) { + songs.stream() + .peek(entityManager::detach) + .flatMap(song -> song.getKillingParts().stream()) + .forEach(entityManager::detach); + } + + @Transactional + @Scheduled(cron = "${schedules.in-memory-song.update-cron}") + public void updateCachedSong() { + log.info("InMemorySongsScheduler LikeCount update progressed"); + final List killingParts = inMemorySongs.getSongs().stream() + .flatMap(song -> song.getKillingParts().stream()) + .toList(); + killingParts.forEach(entityManager::merge); } } diff --git a/backend/src/main/java/shook/shook/song/application/SongService.java b/backend/src/main/java/shook/shook/song/application/SongService.java index 8f417a8a3..ba86c3b10 100644 --- a/backend/src/main/java/shook/shook/song/application/SongService.java +++ b/backend/src/main/java/shook/shook/song/application/SongService.java @@ -14,7 +14,6 @@ import shook.shook.member.domain.Member; import shook.shook.member.domain.repository.MemberRepository; import shook.shook.member.exception.MemberException; -import shook.shook.song.application.dto.RecentSongCarouselResponse; import shook.shook.member_part.domain.MemberPart; import shook.shook.member_part.domain.repository.MemberPartRepository; import shook.shook.song.application.dto.RecentSongCarouselResponse; diff --git a/backend/src/main/java/shook/shook/song/application/killingpart/KillingPartLikeService.java b/backend/src/main/java/shook/shook/song/application/killingpart/KillingPartLikeService.java index 73cac9d31..0146323d9 100644 --- a/backend/src/main/java/shook/shook/song/application/killingpart/KillingPartLikeService.java +++ b/backend/src/main/java/shook/shook/song/application/killingpart/KillingPartLikeService.java @@ -8,6 +8,7 @@ import shook.shook.member.domain.repository.MemberRepository; import shook.shook.member.exception.MemberException; import shook.shook.song.application.killingpart.dto.KillingPartLikeRequest; +import shook.shook.song.domain.InMemorySongs; import shook.shook.song.domain.killingpart.KillingPart; import shook.shook.song.domain.killingpart.KillingPartLike; import shook.shook.song.domain.killingpart.repository.KillingPartLikeRepository; @@ -22,6 +23,7 @@ public class KillingPartLikeService { private final KillingPartRepository killingPartRepository; private final MemberRepository memberRepository; private final KillingPartLikeRepository likeRepository; + private final InMemorySongs inMemorySongs; @Transactional public void updateLikeStatus( @@ -54,8 +56,8 @@ private void create(final KillingPart killingPart, final Member member) { final KillingPartLike likeOnKillingPart = likeRepository.findByKillingPartAndMember(killingPart, member) .orElseGet(() -> createNewLike(killingPart, member)); if (likeOnKillingPart.isDeleted()) { + inMemorySongs.like(killingPart, likeOnKillingPart); likeRepository.pressLike(likeOnKillingPart.getId()); - killingPartRepository.increaseLikeCount(killingPart.getId()); } } @@ -68,8 +70,8 @@ private KillingPartLike createNewLike(final KillingPart killingPart, final Membe private void delete(final KillingPart killingPart, final Member member) { killingPart.findLikeByMember(member) .ifPresent(likeOnKillingPart -> { + inMemorySongs.unlike(killingPart, likeOnKillingPart); likeRepository.cancelLike(likeOnKillingPart.getId()); - killingPartRepository.decreaseLikeCount(killingPart.getId()); }); } } diff --git a/backend/src/main/java/shook/shook/song/domain/InMemorySongs.java b/backend/src/main/java/shook/shook/song/domain/InMemorySongs.java index 6adecdff6..83d69779d 100644 --- a/backend/src/main/java/shook/shook/song/domain/InMemorySongs.java +++ b/backend/src/main/java/shook/shook/song/domain/InMemorySongs.java @@ -1,51 +1,55 @@ package shook.shook.song.domain; +import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; -import java.util.LinkedHashMap; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import shook.shook.song.domain.killingpart.KillingPart; +import shook.shook.song.domain.killingpart.KillingPartLike; import shook.shook.song.exception.SongException; +import shook.shook.song.exception.killingpart.KillingPartException; +@RequiredArgsConstructor @Repository public class InMemorySongs { - private Map songsSortedInLikeCountById = new LinkedHashMap<>(); + private static final Comparator COMPARATOR = + Comparator.comparing(Song::getTotalLikeCount, Comparator.reverseOrder()) + .thenComparing(Song::getId, Comparator.reverseOrder()); - public void recreate(final List songs) { - songsSortedInLikeCountById = getSortedSong(songs); - } - - private static Map getSortedSong(final List songs) { - songs.sort(Comparator.comparing( - Song::getTotalLikeCount, - Comparator.reverseOrder() - ).thenComparing(Song::getId, Comparator.reverseOrder())); + private Map songs = new HashMap<>(); + private List sortedSongIds = new ArrayList<>(); - return songs.stream() - .collect(Collectors.toMap( - Song::getId, - song -> song, - (prev, update) -> update, - LinkedHashMap::new - )); + public synchronized void refreshSongs(final List songs) { + this.songs = songs.stream() + .collect(Collectors.toMap(Song::getId, song -> song, (prev, update) -> update, HashMap::new)); + this.sortedSongIds = new ArrayList<>(this.songs.keySet().stream() + .sorted(Comparator.comparing(this.songs::get, COMPARATOR)) + .toList()); } public List getSongs() { - return songsSortedInLikeCountById.values().stream() + return sortedSongIds.stream() + .map(songs::get) .toList(); } public List getSongs(final int limit) { - final List songs = getSongs(); + final List topSongIds = sortedSongIds.subList(0, Math.min(limit, sortedSongIds.size())); - return songs.subList(0, Math.min(limit, songs.size())); + return topSongIds.stream() + .map(songs::get) + .toList(); } - public List getSortedSongsByGenre(final Genre genre) { - return songsSortedInLikeCountById.values().stream() + private List getSortedSongsByGenre(final Genre genre) { + return sortedSongIds.stream() + .map(songs::get) .filter(song -> song.getGenre() == genre) .toList(); } @@ -57,12 +61,10 @@ public List getSortedSongsByGenre(final Genre genre, final int limit) { } public Song getSongById(final Long id) { - if (songsSortedInLikeCountById.containsKey(id)) { - return songsSortedInLikeCountById.get(id); + if (songs.containsKey(id)) { + return songs.get(id); } - throw new SongException.SongNotExistException( - Map.of("song id", String.valueOf(id)) - ); + throw new SongException.SongNotExistException(Map.of("song id", String.valueOf(id))); } public List getPrevLikedSongByGenre(final Song currentSong, final Genre genre, final int prevSongCount) { @@ -81,31 +83,118 @@ public List getNextLikedSongByGenre(final Song currentSong, final Genre ge } return songsWithGenre.subList(Math.min(currentSongIndex + 1, songsWithGenre.size() - 1), - Math.min(songsWithGenre.size(), currentSongIndex + nextSongCount + 1)); + Math.min(songsWithGenre.size(), currentSongIndex + nextSongCount + 1)); } public List getPrevLikedSongs(final Song currentSong, final int prevSongCount) { - final List songIds = songsSortedInLikeCountById.keySet().stream() - .toList(); - final int currentSongIndex = songIds.indexOf(currentSong.getId()); + final int currentSongIndex = sortedSongIds.indexOf(currentSong.getId()); - return songIds.subList(Math.max(0, currentSongIndex - prevSongCount), currentSongIndex).stream() - .map(songsSortedInLikeCountById::get) + return sortedSongIds.subList(Math.max(0, currentSongIndex - prevSongCount), currentSongIndex).stream() + .map(songs::get) .toList(); } public List getNextLikedSongs(final Song currentSong, final int nextSongCount) { - final List songIds = songsSortedInLikeCountById.keySet().stream() - .toList(); - final int currentSongIndex = songIds.indexOf(currentSong.getId()); + final int currentSongIndex = sortedSongIds.indexOf(currentSong.getId()); - if (currentSongIndex == songIds.size() - 1) { + if (currentSongIndex == sortedSongIds.size() - 1) { return Collections.emptyList(); } - return songIds.subList(Math.min(currentSongIndex + 1, songIds.size() - 1), - Math.min(songIds.size(), currentSongIndex + nextSongCount + 1)).stream() - .map(songsSortedInLikeCountById::get) + return sortedSongIds.subList(Math.min(currentSongIndex + 1, sortedSongIds.size() - 1), + Math.min(sortedSongIds.size(), currentSongIndex + nextSongCount + 1)).stream() + .map(songs::get) .toList(); } + + public void like(final KillingPart killingPart, final KillingPartLike likeOnKillingPart) { + final Song song = songs.get(killingPart.getSong().getId()); + final KillingPart killingPartById = findKillingPart(killingPart, song); + final boolean updated = killingPartById.like(likeOnKillingPart); + if (updated) { + reorder(song); + } + } + + public void unlike(final KillingPart killingPart, final KillingPartLike unlikeOnKillingPart) { + final Song song = songs.get(killingPart.getSong().getId()); + final KillingPart killingPartById = findKillingPart(killingPart, song); + final boolean updated = killingPartById.unlike(unlikeOnKillingPart); + if (updated) { + reorder(song); + } + } + + private KillingPart findKillingPart(final KillingPart killingPart, final Song song) { + return song.getKillingParts().stream() + .filter(kp -> kp.equals(killingPart)) + .findAny() + .orElseThrow( + () -> new KillingPartException.PartNotExistException( + Map.of("killing part id", String.valueOf(killingPart.getId())))); + } + + private void reorder(final Song updatedSong) { + synchronized (sortedSongIds) { + int currentSongIndex = sortedSongIds.indexOf(updatedSong.getId()); + + if (currentSongIndex == -1) { + return; + } + + moveForward(updatedSong, currentSongIndex); + moveBackward(updatedSong, currentSongIndex); + } + } + + private void moveForward(final Song changedSong, final int songIndex) { + int currentSongIndex = songIndex; + + while (canSwapWithPreviousSong(changedSong, currentSongIndex)) { + swap(currentSongIndex, currentSongIndex - 1); + currentSongIndex--; + } + } + + private boolean canSwapWithPreviousSong(final Song changedSong, final int currentSongIndex) { + return currentSongIndex > 0 && currentSongIndex < sortedSongIds.size() && + shouldSwapWithPrevious(changedSong, + songs.get(sortedSongIds.get(currentSongIndex - 1))); + } + + private boolean shouldSwapWithPrevious(final Song song, final Song prevSong) { + final boolean hasSameTotalLikeCountAndLargerIdThanPrevSong = + song.getTotalLikeCount() == prevSong.getTotalLikeCount() && song.getId() > prevSong.getId(); + final boolean hasLargerTotalLikeCountThanPrevSong = song.getTotalLikeCount() > prevSong.getTotalLikeCount(); + + return hasLargerTotalLikeCountThanPrevSong || hasSameTotalLikeCountAndLargerIdThanPrevSong; + } + + private void swap(final int currentIndex, final int otherIndex) { + final Long prevIndex = sortedSongIds.get(currentIndex); + sortedSongIds.set(currentIndex, sortedSongIds.get(otherIndex)); + sortedSongIds.set(otherIndex, prevIndex); + } + + private void moveBackward(final Song changedSong, final int songIndex) { + int currentSongIndex = songIndex; + + while (canSwapWithNextSong(changedSong, currentSongIndex)) { + swap(currentSongIndex, currentSongIndex + 1); + currentSongIndex++; + } + } + + private boolean canSwapWithNextSong(final Song changedSong, final int currentSongIndex) { + return currentSongIndex < sortedSongIds.size() - 1 && currentSongIndex > 0 + && shouldSwapWithNext(changedSong, songs.get(sortedSongIds.get(currentSongIndex - 1))); + } + + private boolean shouldSwapWithNext(final Song song, final Song nextSong) { + final boolean hasSameTotalLikeCountAndSmallerIdThanNextSong = + song.getTotalLikeCount() == nextSong.getTotalLikeCount() && song.getId() < nextSong.getId(); + final boolean hasSmallerTotalLikeCountThanNextSong = song.getTotalLikeCount() < nextSong.getTotalLikeCount(); + + return hasSmallerTotalLikeCountThanNextSong || hasSameTotalLikeCountAndSmallerIdThanNextSong; + } } diff --git a/backend/src/main/java/shook/shook/song/domain/KillingParts.java b/backend/src/main/java/shook/shook/song/domain/KillingParts.java index 5300cdbd4..9957f7e14 100644 --- a/backend/src/main/java/shook/shook/song/domain/KillingParts.java +++ b/backend/src/main/java/shook/shook/song/domain/KillingParts.java @@ -58,12 +58,13 @@ public List getKillingParts() { public List getKillingPartsSortedByLikeCount() { return killingParts.stream() .sorted(Comparator.comparing(KillingPart::getLikeCount, Comparator.reverseOrder()) - .thenComparing(KillingPart::getStartSecond)) + .thenComparing(KillingPart::getStartSecond)) .toList(); } public int getKillingPartsTotalLikeCount() { return killingParts.stream() - .reduce(0, (sum, killingPart) -> sum + killingPart.getLikeCount(), Integer::sum); + .mapToInt(KillingPart::getLikeCount) + .reduce(0, Integer::sum); } } diff --git a/backend/src/main/java/shook/shook/song/domain/killingpart/KillingPart.java b/backend/src/main/java/shook/shook/song/domain/killingpart/KillingPart.java index 0114edf86..2ff3c3035 100644 --- a/backend/src/main/java/shook/shook/song/domain/killingpart/KillingPart.java +++ b/backend/src/main/java/shook/shook/song/domain/killingpart/KillingPart.java @@ -1,6 +1,7 @@ package shook.shook.song.domain.killingpart; import jakarta.persistence.Column; +import jakarta.persistence.Convert; import jakarta.persistence.Embedded; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -19,6 +20,7 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -60,8 +62,9 @@ public class KillingPart { @Embedded private final KillingPartLikes killingPartLikes = new KillingPartLikes(); + @Convert(converter = LikeCountConverter.class) @Column(nullable = false) - private int likeCount = 0; + private AtomicInteger likeCount; @Column(nullable = false, updatable = false) private LocalDateTime createdAt = LocalDateTime.now().truncatedTo(ChronoUnit.MICROS); @@ -82,7 +85,7 @@ private KillingPart( this.startSecond = startSecond; this.length = new PartLength(length); this.song = song; - this.likeCount = likeCount; + this.likeCount = new AtomicInteger(likeCount); } private KillingPart(final int startSecond, final int length) { @@ -114,12 +117,14 @@ public void addComment(final KillingPartComment comment) { comments.addComment(comment); } - public void like(final KillingPartLike likeToAdd) { + public boolean like(final KillingPartLike likeToAdd) { validateLikeUpdate(likeToAdd); final boolean isLikeCreated = killingPartLikes.addLike(likeToAdd); if (isLikeCreated) { - this.likeCount++; + likeCount.incrementAndGet(); + return true; } + return false; } private void validateLikeUpdate(final KillingPartLike like) { @@ -136,12 +141,14 @@ private void validateLikeUpdate(final KillingPartLike like) { } } - public void unlike(final KillingPartLike likeToDelete) { + public boolean unlike(final KillingPartLike likeToDelete) { validateLikeUpdate(likeToDelete); final boolean isLikeDeleted = killingPartLikes.deleteLike(likeToDelete); if (isLikeDeleted) { - this.likeCount--; + likeCount.decrementAndGet(); + return true; } + return false; } public Optional findLikeByMember(final Member member) { @@ -180,7 +187,7 @@ public int getLength() { } public int getLikeCount() { - return likeCount; + return likeCount.get(); } public void setSong(final Song song) { diff --git a/backend/src/main/java/shook/shook/song/domain/killingpart/KillingPartLikes.java b/backend/src/main/java/shook/shook/song/domain/killingpart/KillingPartLikes.java index 91df7f947..3aef76b68 100644 --- a/backend/src/main/java/shook/shook/song/domain/killingpart/KillingPartLikes.java +++ b/backend/src/main/java/shook/shook/song/domain/killingpart/KillingPartLikes.java @@ -2,9 +2,9 @@ import jakarta.persistence.Embeddable; import jakarta.persistence.OneToMany; -import java.util.ArrayList; -import java.util.List; +import java.util.HashSet; import java.util.Optional; +import java.util.Set; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -18,7 +18,7 @@ public class KillingPartLikes { @OneToMany(mappedBy = "killingPart") @Where(clause = "is_deleted = false") - private List likes = new ArrayList<>(); + private Set likes = new HashSet<>(); public boolean addLike(final KillingPartLike like) { if (like.isDeleted()) { diff --git a/backend/src/main/java/shook/shook/song/domain/killingpart/LikeCountConverter.java b/backend/src/main/java/shook/shook/song/domain/killingpart/LikeCountConverter.java new file mode 100644 index 000000000..303494a15 --- /dev/null +++ b/backend/src/main/java/shook/shook/song/domain/killingpart/LikeCountConverter.java @@ -0,0 +1,19 @@ +package shook.shook.song.domain.killingpart; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import java.util.concurrent.atomic.AtomicInteger; + +@Converter +public class LikeCountConverter implements AttributeConverter { + + @Override + public Integer convertToDatabaseColumn(final AtomicInteger attribute) { + return attribute.get(); + } + + @Override + public AtomicInteger convertToEntityAttribute(final Integer dbData) { + return new AtomicInteger(dbData); + } +} diff --git a/backend/src/main/java/shook/shook/song/domain/killingpart/repository/KillingPartRepository.java b/backend/src/main/java/shook/shook/song/domain/killingpart/repository/KillingPartRepository.java index 99679d467..17b8754d7 100644 --- a/backend/src/main/java/shook/shook/song/domain/killingpart/repository/KillingPartRepository.java +++ b/backend/src/main/java/shook/shook/song/domain/killingpart/repository/KillingPartRepository.java @@ -2,9 +2,6 @@ import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import shook.shook.song.domain.Song; import shook.shook.song.domain.killingpart.KillingPart; @@ -14,6 +11,7 @@ public interface KillingPartRepository extends JpaRepository List findAllBySong(final Song song); + /* AtomicInteger 사용하면서 예외 발생하여 주석 처리 @Query("update KillingPart kp set kp.likeCount = kp.likeCount + 1 where kp.id = :id") @Modifying(clearAutomatically = true, flushAutomatically = true) void increaseLikeCount(@Param("id") final Long killingPartLikeId); @@ -21,4 +19,5 @@ public interface KillingPartRepository extends JpaRepository @Query("update KillingPart kp set kp.likeCount = kp.likeCount - 1 where kp.id = :id") @Modifying(clearAutomatically = true, flushAutomatically = true) void decreaseLikeCount(@Param("id") final Long killingPartLikeId); + */ } diff --git a/backend/src/main/java/shook/shook/song/domain/repository/SongRepository.java b/backend/src/main/java/shook/shook/song/domain/repository/SongRepository.java index d9321bd8d..1fed695f8 100644 --- a/backend/src/main/java/shook/shook/song/domain/repository/SongRepository.java +++ b/backend/src/main/java/shook/shook/song/domain/repository/SongRepository.java @@ -22,8 +22,9 @@ public interface SongRepository extends JpaRepository { @Query("SELECT s AS song " + "FROM Song s " + "LEFT JOIN FETCH s.killingParts.killingParts kp " - + "GROUP BY s.id, kp.id") - List findAllWithKillingParts(); + + "LEFT JOIN FETCH kp.killingPartLikes.likes kpl " + + "GROUP BY s.id, kp.id, kpl.id") + List findAllWithKillingPartsAndLikes(); @Query("SELECT s FROM Song s " + "LEFT JOIN s.killingParts.killingParts kp " diff --git a/backend/src/main/resources/application-test.yml b/backend/src/main/resources/application-test.yml index fc22e8b16..efda546b7 100644 --- a/backend/src/main/resources/application-test.yml +++ b/backend/src/main/resources/application-test.yml @@ -50,4 +50,5 @@ schedules: in-memory-token: cron: "0/1 * * * * *" in-memory-song: - cron: "0 0/1 * * * *" # 1분 + cron: "0 0/5 * * * *" # 1분 + update-cron: "0 0/5 * * * *" # 1분 diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index c3334135c..eb64e781a 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -70,3 +70,4 @@ schedules: cron: "0 0 0/1 * * *" in-memory-song: cron: "0 0 0/1 * * *" #1시간 + update-cron: "0 0 0/1 * * *" #1시간 diff --git a/backend/src/main/resources/shook-security b/backend/src/main/resources/shook-security index be8515fd1..48e7440b2 160000 --- a/backend/src/main/resources/shook-security +++ b/backend/src/main/resources/shook-security @@ -1 +1 @@ -Subproject commit be8515fd110a4496fcce0c79c8fc1e312c49f6bc +Subproject commit 48e7440b247f24098ccad67de621e950c11df33c diff --git a/backend/src/test/java/shook/shook/song/application/InMemorySongsSchedulerTest.java b/backend/src/test/java/shook/shook/song/application/InMemorySongsSchedulerTest.java index cbe6ea259..56bad1a0e 100644 --- a/backend/src/test/java/shook/shook/song/application/InMemorySongsSchedulerTest.java +++ b/backend/src/test/java/shook/shook/song/application/InMemorySongsSchedulerTest.java @@ -8,7 +8,14 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.test.context.jdbc.Sql; +import shook.shook.member.domain.Member; +import shook.shook.member.domain.repository.MemberRepository; import shook.shook.song.domain.InMemorySongs; +import shook.shook.song.domain.Song; +import shook.shook.song.domain.killingpart.KillingPart; +import shook.shook.song.domain.killingpart.KillingPartLike; +import shook.shook.song.domain.killingpart.repository.KillingPartLikeRepository; +import shook.shook.song.domain.killingpart.repository.KillingPartRepository; @Sql(value = "classpath:/killingpart/initialize_killing_part_song.sql") @EnableScheduling @@ -21,6 +28,15 @@ class InMemorySongsSchedulerTest { @Autowired private InMemorySongsScheduler scheduler; + @Autowired + private KillingPartLikeRepository likeRepository; + + @Autowired + private KillingPartRepository killingPartRepository; + + @Autowired + private MemberRepository memberRepository; + @DisplayName("InMemorySongs 를 재생성한다.") @Test void recreateCachedSong() { @@ -31,4 +47,24 @@ void recreateCachedSong() { // then assertThat(inMemorySongs.getSongs()).hasSize(4); } + + @DisplayName("InMemorySongs 의 상태로 데이터베이스를 업데이트한다.") + @Test + void updateCachedSong() { + // given + scheduler.recreateCachedSong(); + final Song song = inMemorySongs.getSongById(1L); + final KillingPart killingPart = song.getKillingParts().get(0); + final Member member = memberRepository.save(new Member("email@email.com", "nickname")); + inMemorySongs.like(killingPart, likeRepository.save( + new KillingPartLike(killingPart, member) + )); + + // when + scheduler.updateCachedSong(); + + // then + killingPartRepository.findById(killingPart.getId()) + .ifPresent(updatedKillingPart -> assertThat(updatedKillingPart.getLikeCount()).isEqualTo(1)); + } } diff --git a/backend/src/test/java/shook/shook/song/application/SongServiceTest.java b/backend/src/test/java/shook/shook/song/application/SongServiceTest.java index 4cd3d8767..534b5f54a 100644 --- a/backend/src/test/java/shook/shook/song/application/SongServiceTest.java +++ b/backend/src/test/java/shook/shook/song/application/SongServiceTest.java @@ -18,8 +18,8 @@ import shook.shook.member_part.domain.MemberPart; import shook.shook.member_part.domain.repository.MemberPartRepository; import shook.shook.song.application.dto.KillingPartRegisterRequest; -import shook.shook.song.application.dto.RecentSongCarouselResponse; import shook.shook.song.application.dto.MemberPartResponse; +import shook.shook.song.application.dto.RecentSongCarouselResponse; import shook.shook.song.application.dto.SongResponse; import shook.shook.song.application.dto.SongSwipeResponse; import shook.shook.song.application.dto.SongWithKillingPartsRegisterRequest; @@ -56,12 +56,13 @@ class SongServiceTest extends UsingJpaTest { @Autowired private ArtistRepository artistRepository; - private final InMemorySongs inMemorySongs = new InMemorySongs(); + private InMemorySongs inMemorySongs; private SongService songService; @BeforeEach public void setUp() { + inMemorySongs = new InMemorySongs(); songService = new SongService( songRepository, killingPartRepository, @@ -117,15 +118,15 @@ void findById_exist_login_member() { //given final Member member = createAndSaveMember("email@naver.com", "email"); final Song song = registerNewSong("title"); + inMemorySongs.refreshSongs(List.of(song)); addLikeToEachKillingParts(song, member); - inMemorySongs.recreate(songRepository.findAllWithKillingParts()); addMemberPartToSong(10, 5, song, member); //when saveAndClearEntityManager(); final SongSwipeResponse response = songService.findSongByIdForFirstSwipe(song.getId(), - new MemberInfo(member.getId(), Authority.MEMBER)); + new MemberInfo(member.getId(), Authority.MEMBER)); //then assertAll( @@ -133,19 +134,19 @@ void findById_exist_login_member() { () -> assertThat(response.getNextSongs()).isEmpty(), () -> assertThat(response.getCurrentSong().getKillingParts().get(0)) .hasFieldOrPropertyWithValue("id", - song.getLikeCountSortedKillingParts().get(0).getId()) + song.getLikeCountSortedKillingParts().get(0).getId()) .hasFieldOrPropertyWithValue("rank", 1) .hasFieldOrPropertyWithValue("likeStatus", true), () -> assertThat(response.getCurrentSong().getKillingParts().get(1)) .hasFieldOrPropertyWithValue("id", - song.getLikeCountSortedKillingParts().get(1).getId()) + song.getLikeCountSortedKillingParts().get(1).getId()) .hasFieldOrPropertyWithValue("rank", 2) .hasFieldOrPropertyWithValue("likeStatus", true), () -> assertThat(response.getCurrentSong().getKillingParts().get(2)) .hasFieldOrPropertyWithValue("id", - song.getLikeCountSortedKillingParts().get(2).getId()) + song.getLikeCountSortedKillingParts().get(2).getId()) .hasFieldOrPropertyWithValue("rank", 3) .hasFieldOrPropertyWithValue("likeStatus", true), () -> assertThat(response.getCurrentSong().getMemberPart().getId()).isNotNull() @@ -162,13 +163,13 @@ private MemberPart addMemberPartToSong(final int startSecond, final int length, void findById_exist_not_login_member() { //given final Song song = registerNewSong("title"); - inMemorySongs.recreate(songRepository.findAllWithKillingParts()); + inMemorySongs.refreshSongs(List.of(song)); //when인 saveAndClearEntityManager(); final SongSwipeResponse response = songService.findSongByIdForFirstSwipe(song.getId(), - new MemberInfo(0L, Authority.ANONYMOUS)); + new MemberInfo(0L, Authority.ANONYMOUS)); //then assertAll( @@ -176,19 +177,19 @@ void findById_exist_not_login_member() { () -> assertThat(response.getNextSongs()).isEmpty(), () -> assertThat(response.getCurrentSong().getKillingParts().get(0)) .hasFieldOrPropertyWithValue("id", - song.getLikeCountSortedKillingParts().get(0).getId()) + song.getLikeCountSortedKillingParts().get(0).getId()) .hasFieldOrPropertyWithValue("rank", 1) .hasFieldOrPropertyWithValue("likeStatus", false), () -> assertThat(response.getCurrentSong().getKillingParts().get(1)) .hasFieldOrPropertyWithValue("id", - song.getLikeCountSortedKillingParts().get(1).getId()) + song.getLikeCountSortedKillingParts().get(1).getId()) .hasFieldOrPropertyWithValue("rank", 2) .hasFieldOrPropertyWithValue("likeStatus", false), () -> assertThat(response.getCurrentSong().getKillingParts().get(2)) .hasFieldOrPropertyWithValue("id", - song.getLikeCountSortedKillingParts().get(2).getId()) + song.getLikeCountSortedKillingParts().get(2).getId()) .hasFieldOrPropertyWithValue("rank", 3) .hasFieldOrPropertyWithValue("likeStatus", false), () -> assertThat(response.getCurrentSong().getMemberPart()).isNull() @@ -200,13 +201,14 @@ void findById_exist_not_login_member() { void findById_notExist() { //given final Member member = createAndSaveMember("email@naver.com", "email"); + inMemorySongs.refreshSongs(List.of()); //when //then assertThatThrownBy(() -> songService.findSongByIdForFirstSwipe( - 0L, - new MemberInfo(member.getId(), Authority.MEMBER) - ) + 0L, + new MemberInfo(member.getId(), Authority.MEMBER) + ) ).isInstanceOf(SongException.SongNotExistException.class); } @@ -227,8 +229,8 @@ void showHighLikedSongs() { addLikeToEachKillingParts(thirdSong, member2); addLikeToEachKillingParts(fourthSong, member1); + inMemorySongs.refreshSongs(songRepository.findAllWithKillingPartsAndLikes()); saveAndClearEntityManager(); - inMemorySongs.recreate(songRepository.findAllWithKillingParts()); // when final List result = songService.showHighLikedSongs(); @@ -304,7 +306,6 @@ void firstFindByMember() { // 4, 3, 5, 2, 1 addLikeToEachKillingParts(thirdSong, member); addLikeToEachKillingParts(fourthSong, member); - inMemorySongs.recreate(songRepository.findAllWithKillingParts()); // 1, 2, 3 노래에 memberPart 추가 addMemberPartToSong(10, 5, firstSong, member); @@ -313,11 +314,12 @@ void firstFindByMember() { addMemberPartToSong(10, 5, fourthSong, member); saveAndClearEntityManager(); + inMemorySongs.refreshSongs(songRepository.findAllWithKillingPartsAndLikes()); // when final SongSwipeResponse result = songService.findSongByIdForFirstSwipe(fifthSong.getId(), - new MemberInfo(member.getId(), Authority.MEMBER)); + new MemberInfo(member.getId(), Authority.MEMBER)); // then assertAll( @@ -325,20 +327,20 @@ void firstFindByMember() { () -> assertThat(result.getPrevSongs()).hasSize(2), () -> assertThat(result.getNextSongs()).hasSize(2), () -> assertThat(result.getPrevSongs().stream() - .map(SongResponse::getId) - .toList()).usingRecursiveComparison().isEqualTo(List.of(4L, 3L)), + .map(SongResponse::getId) + .toList()).usingRecursiveComparison().isEqualTo(List.of(4L, 3L)), () -> assertThat(result.getNextSongs().stream() - .map(SongResponse::getId) - .toList()).usingRecursiveComparison().isEqualTo(List.of(2L, 1L)), + .map(SongResponse::getId) + .toList()).usingRecursiveComparison().isEqualTo(List.of(2L, 1L)), () -> assertThat(result.getCurrentSong().getMemberPart()).isNull(), () -> assertThat(result.getPrevSongs().stream() - .map(songResponse -> songResponse.getMemberPart().getId()) - .toList()) + .map(songResponse -> songResponse.getMemberPart().getId()) + .toList()) .usingRecursiveComparison() .isEqualTo(List.of(4L, 3L)), () -> assertThat(result.getNextSongs().stream() - .map(songResponse -> songResponse.getMemberPart().getId()) - .toList()) + .map(songResponse -> songResponse.getMemberPart().getId()) + .toList()) .usingRecursiveComparison() .isEqualTo(List.of(2L, 1L)) ); @@ -351,19 +353,21 @@ void firstFindByAnonymous() { final Member member = createAndSaveMember("first@naver.com", "first"); final Long notExistSongId = Long.MAX_VALUE; + saveAndClearEntityManager(); + // when // then assertThatThrownBy( () -> songService.findSongByIdForFirstSwipe(notExistSongId, - new MemberInfo(member.getId(), Authority.MEMBER))) + new MemberInfo(member.getId(), Authority.MEMBER))) .isInstanceOf(SongException.SongNotExistException.class); assertThatThrownBy( () -> songService.findSongByIdForBeforeSwipe(notExistSongId, - new MemberInfo(member.getId(), Authority.MEMBER))) + new MemberInfo(member.getId(), Authority.MEMBER))) .isInstanceOf(SongException.SongNotExistException.class); assertThatThrownBy( () -> songService.findSongByIdForAfterSwipe(notExistSongId, - new MemberInfo(member.getId(), Authority.MEMBER))) + new MemberInfo(member.getId(), Authority.MEMBER))) .isInstanceOf(SongException.SongNotExistException.class); } @@ -385,8 +389,6 @@ void findSongByIdForBeforeSwipe() { addLikeToEachKillingParts(fourthSong, member2); addLikeToEachKillingParts(firstSong, member2); - inMemorySongs.recreate(songRepository.findAllWithKillingParts()); - addMemberPartToSong(10, 5, firstSong, member); addMemberPartToSong(10, 5, secondSong, member); addMemberPartToSong(10, 5, standardSong, member); @@ -395,20 +397,21 @@ void findSongByIdForBeforeSwipe() { // 정렬 순서: 2L, 4L, 1L, 5L, 3L saveAndClearEntityManager(); + inMemorySongs.refreshSongs(songRepository.findAllWithKillingPartsAndLikes()); // when final List beforeResponses = songService.findSongByIdForBeforeSwipe(standardSong.getId(), - new MemberInfo(member.getId(), Authority.MEMBER)); + new MemberInfo(member.getId(), Authority.MEMBER)); // then assertThat(beforeResponses.stream() - .map(SongResponse::getId) - .toList()).usingRecursiveComparison().isEqualTo(List.of(2L, 4L, 1L, 5L)); + .map(SongResponse::getId) + .toList()).usingRecursiveComparison().isEqualTo(List.of(2L, 4L, 1L, 5L)); assertThat(beforeResponses.stream() - .map(SongResponse::getMemberPart) - .map(MemberPartResponse::getId) - .toList()).usingRecursiveComparison().isEqualTo(List.of(2L, 4L, 1L, 5L)); + .map(SongResponse::getMemberPart) + .map(MemberPartResponse::getId) + .toList()).usingRecursiveComparison().isEqualTo(List.of(2L, 4L, 1L, 5L)); } @DisplayName("이후 노래를 1. 좋아요 순 내림차순, 2. id 내림차순으로 조회한다.") @@ -428,7 +431,6 @@ void findSongByIdForAfterSwipe() { addLikeToEachKillingParts(secondSong, member2); addLikeToEachKillingParts(standardSong, member2); addLikeToEachKillingParts(firstSong, member2); - inMemorySongs.recreate(songRepository.findAllWithKillingParts()); addMemberPartToSong(10, 5, firstSong, member); addMemberPartToSong(10, 5, secondSong, member); @@ -437,22 +439,22 @@ void findSongByIdForAfterSwipe() { addMemberPartToSong(10, 5, fifthSong, member); // 정렬 순서: 2L, 4L, 1L, 5L, 3L - saveAndClearEntityManager(); + inMemorySongs.refreshSongs(songRepository.findAllWithKillingPartsAndLikes()); // when final List afterResponses = songService.findSongByIdForAfterSwipe(standardSong.getId(), - new MemberInfo(member.getId(), Authority.MEMBER)); + new MemberInfo(member.getId(), Authority.MEMBER)); // then assertThat(afterResponses.stream() - .map(SongResponse::getId) - .toList()).usingRecursiveComparison().isEqualTo(List.of(1L, 5L, 3L)); + .map(SongResponse::getId) + .toList()).usingRecursiveComparison().isEqualTo(List.of(1L, 5L, 3L)); assertThat(afterResponses.stream() - .map(SongResponse::getMemberPart) - .map(MemberPartResponse::getId) - .toList()).usingRecursiveComparison().isEqualTo(List.of(1L, 5L, 3L)); + .map(SongResponse::getMemberPart) + .map(MemberPartResponse::getId) + .toList()).usingRecursiveComparison().isEqualTo(List.of(1L, 5L, 3L)); } } @@ -479,9 +481,9 @@ void findSongsByGenre() { addLikeToEachKillingParts(song1, member); addLikeToEachKillingParts(song1, secondMember); addLikeToEachKillingParts(song3, member); - inMemorySongs.recreate(songRepository.findAllWithKillingParts()); // 정렬 순서: 2L, 1L, 3L, 5L, 4L + inMemorySongs.refreshSongs(songRepository.findAllWithKillingPartsAndLikes()); saveAndClearEntityManager(); // when @@ -491,7 +493,7 @@ void findSongsByGenre() { assertAll( () -> assertThat(response).hasSize(5), () -> assertThat(response.stream() - .map(HighLikedSongResponse::getId).toList()) + .map(HighLikedSongResponse::getId).toList()) .containsExactly(2L, 1L, 3L, 5L, 4L) ); } @@ -504,13 +506,13 @@ void findSongById() { final Song song = registerNewSong("title"); final Member member = createAndSaveMember("email@email.com", "nickname"); addLikeToEachKillingParts(song, member); - inMemorySongs.recreate(songRepository.findAllWithKillingParts()); addMemberPartToSong(10, 5, song, member); saveAndClearEntityManager(); + inMemorySongs.refreshSongs(songRepository.findAllWithKillingPartsAndLikes()); // when final SongResponse response = songService.findSongById(song.getId(), - new MemberInfo(member.getId(), Authority.MEMBER)); + new MemberInfo(member.getId(), Authority.MEMBER)); // then assertAll( @@ -521,19 +523,19 @@ void findSongById() { () -> assertThat(response.getKillingParts()).hasSize(3), () -> assertThat(response.getKillingParts().get(0)) .hasFieldOrPropertyWithValue("id", - song.getLikeCountSortedKillingParts().get(0).getId()) + song.getLikeCountSortedKillingParts().get(0).getId()) .hasFieldOrPropertyWithValue("rank", 1) .hasFieldOrPropertyWithValue("likeStatus", true), () -> assertThat(response.getKillingParts().get(1)) .hasFieldOrPropertyWithValue("id", - song.getLikeCountSortedKillingParts().get(1).getId()) + song.getLikeCountSortedKillingParts().get(1).getId()) .hasFieldOrPropertyWithValue("rank", 2) .hasFieldOrPropertyWithValue("likeStatus", true), () -> assertThat(response.getKillingParts().get(2)) .hasFieldOrPropertyWithValue("id", - song.getLikeCountSortedKillingParts().get(2).getId()) + song.getLikeCountSortedKillingParts().get(2).getId()) .hasFieldOrPropertyWithValue("rank", 3) .hasFieldOrPropertyWithValue("likeStatus", true), () -> assertThat(response.getMemberPart().getId()).isNotNull() @@ -559,8 +561,8 @@ void findRecentRegisteredSongsForCarousel() { // then assertThat(songs.stream() - .map(RecentSongCarouselResponse::getId) - .toList()) + .map(RecentSongCarouselResponse::getId) + .toList()) .containsExactly(7L, 6L, 5L, 4L, 3L); } } diff --git a/backend/src/test/java/shook/shook/song/application/killingpart/KillingPartLikeConcurrencyTest.java b/backend/src/test/java/shook/shook/song/application/killingpart/KillingPartLikeConcurrencyTest.java index 2070e9e4a..5eef80e28 100644 --- a/backend/src/test/java/shook/shook/song/application/killingpart/KillingPartLikeConcurrencyTest.java +++ b/backend/src/test/java/shook/shook/song/application/killingpart/KillingPartLikeConcurrencyTest.java @@ -18,9 +18,12 @@ import shook.shook.member.domain.Member; import shook.shook.member.domain.repository.MemberRepository; import shook.shook.song.application.killingpart.dto.KillingPartLikeRequest; +import shook.shook.song.domain.InMemorySongs; +import shook.shook.song.domain.Song; import shook.shook.song.domain.killingpart.KillingPart; import shook.shook.song.domain.killingpart.repository.KillingPartLikeRepository; import shook.shook.song.domain.killingpart.repository.KillingPartRepository; +import shook.shook.song.domain.repository.SongRepository; @Sql("classpath:/killingpart/initialize_killing_part_song.sql") @SpringBootTest @@ -28,6 +31,7 @@ class KillingPartLikeConcurrencyTest { private static KillingPart SAVED_KILLING_PART; private static Member SAVED_MEMBER; + private static Song SAVED_SONG; @Autowired private KillingPartRepository killingPartRepository; @@ -41,14 +45,22 @@ class KillingPartLikeConcurrencyTest { @Autowired private PlatformTransactionManager transactionManager; + @Autowired + private InMemorySongs inMemorySongs; + + @Autowired + private SongRepository songRepository; + private KillingPartLikeService likeService; private TransactionTemplate transactionTemplate; @BeforeEach void setUp() { + SAVED_SONG = songRepository.findById(1L).get(); SAVED_KILLING_PART = killingPartRepository.findById(1L).get(); SAVED_MEMBER = memberRepository.findById(1L).get(); - likeService = new KillingPartLikeService(killingPartRepository, memberRepository, killingPartLikeRepository); + likeService = new KillingPartLikeService(killingPartRepository, memberRepository, killingPartLikeRepository, + inMemorySongs); transactionTemplate = new TransactionTemplate(transactionManager); transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); } @@ -59,6 +71,7 @@ void likeByMultiplePeople() throws InterruptedException { // given final Member first = SAVED_MEMBER; final Member second = memberRepository.save(new Member("second@gmail.com", "second")); + inMemorySongs.refreshSongs(songRepository.findAllWithKillingPartsAndLikes()); // when ExecutorService executorService = Executors.newFixedThreadPool(2); @@ -67,24 +80,28 @@ void likeByMultiplePeople() throws InterruptedException { final KillingPartLikeRequest request = new KillingPartLikeRequest(true); executorService.execute(() -> - transactionTemplate.execute((status -> { - likeService.updateLikeStatus(SAVED_KILLING_PART.getId(), first.getId(), request); - latch.countDown(); - return null; - })) + transactionTemplate.execute((status -> { + likeService.updateLikeStatus(SAVED_KILLING_PART.getId(), first.getId(), + request); + latch.countDown(); + return null; + })) ); executorService.execute(() -> - transactionTemplate.execute((status -> { - likeService.updateLikeStatus(SAVED_KILLING_PART.getId(), second.getId(), request); - latch.countDown(); - return null; - })) + transactionTemplate.execute((status -> { + likeService.updateLikeStatus(SAVED_KILLING_PART.getId(), second.getId(), + request); + latch.countDown(); + return null; + })) ); latch.await(); Thread.sleep(1000); // then - final KillingPart killingPart = killingPartRepository.findById(SAVED_KILLING_PART.getId()).get(); + final KillingPart killingPart = inMemorySongs.getSongById(SAVED_SONG.getId()).getKillingParts().stream() + .filter(kp -> kp.getId().equals(SAVED_KILLING_PART.getId())) + .findAny().get(); assertThat(killingPart.getLikeCount()).isEqualTo(2); } @@ -102,26 +119,23 @@ void likeByOnePersonMultipleTimes() throws InterruptedException { final KillingPartLikeRequest likeRequest = new KillingPartLikeRequest(true); final KillingPartLikeRequest unlikeRequest = new KillingPartLikeRequest(false); - executorService.execute(() -> - transactionTemplate.execute((status -> { - likeService.updateLikeStatus(SAVED_KILLING_PART.getId(), first.getId(), likeRequest); - latch.countDown(); - return null; - })) + executorService.execute(() -> transactionTemplate.execute((status -> { + likeService.updateLikeStatus(SAVED_KILLING_PART.getId(), first.getId(), likeRequest); + latch.countDown(); + return null; + })) ); - executorService.execute(() -> - transactionTemplate.execute((status -> { - likeService.updateLikeStatus(SAVED_KILLING_PART.getId(), first.getId(), unlikeRequest); - latch.countDown(); - return null; - })) + executorService.execute(() -> transactionTemplate.execute((status -> { + likeService.updateLikeStatus(SAVED_KILLING_PART.getId(), first.getId(), unlikeRequest); + latch.countDown(); + return null; + })) ); - executorService.execute(() -> - transactionTemplate.execute((status -> { - likeService.updateLikeStatus(SAVED_KILLING_PART.getId(), first.getId(), likeRequest); - latch.countDown(); - return null; - })) + executorService.execute(() -> transactionTemplate.execute((status -> { + likeService.updateLikeStatus(SAVED_KILLING_PART.getId(), first.getId(), likeRequest); + latch.countDown(); + return null; + })) ); latch.await(); diff --git a/backend/src/test/java/shook/shook/song/application/killingpart/KillingPartLikeServiceTest.java b/backend/src/test/java/shook/shook/song/application/killingpart/KillingPartLikeServiceTest.java index 3d79a5b14..f3fd844b8 100644 --- a/backend/src/test/java/shook/shook/song/application/killingpart/KillingPartLikeServiceTest.java +++ b/backend/src/test/java/shook/shook/song/application/killingpart/KillingPartLikeServiceTest.java @@ -14,10 +14,13 @@ import shook.shook.member.domain.repository.MemberRepository; import shook.shook.member.exception.MemberException; import shook.shook.song.application.killingpart.dto.KillingPartLikeRequest; +import shook.shook.song.domain.InMemorySongs; +import shook.shook.song.domain.Song; import shook.shook.song.domain.killingpart.KillingPart; import shook.shook.song.domain.killingpart.KillingPartLike; import shook.shook.song.domain.killingpart.repository.KillingPartLikeRepository; import shook.shook.song.domain.killingpart.repository.KillingPartRepository; +import shook.shook.song.domain.repository.SongRepository; import shook.shook.song.exception.killingpart.KillingPartException; import shook.shook.support.UsingJpaTest; @@ -28,6 +31,7 @@ class KillingPartLikeServiceTest extends UsingJpaTest { private static final long UNSAVED_KILLING_PART_ID = Long.MAX_VALUE; private static KillingPart SAVED_KILLING_PART; private static Member SAVED_MEMBER; + private static Song SAVED_SONG; @Autowired private KillingPartRepository killingPartRepository; @@ -38,14 +42,21 @@ class KillingPartLikeServiceTest extends UsingJpaTest { @Autowired private MemberRepository memberRepository; + @Autowired + private SongRepository songRepository; + private KillingPartLikeService likeService; + private InMemorySongs inMemorySongs; + @BeforeEach void setUp() { + SAVED_SONG = songRepository.findById(1L).get(); SAVED_KILLING_PART = killingPartRepository.findById(1L).get(); SAVED_MEMBER = memberRepository.findById(1L).get(); - likeService = new KillingPartLikeService(killingPartRepository, memberRepository, - killingPartLikeRepository); + inMemorySongs = new InMemorySongs(); + likeService = new KillingPartLikeService(killingPartRepository, memberRepository, killingPartLikeRepository, + inMemorySongs); } @DisplayName("킬링파트 좋아요를 누른다.") @@ -57,15 +68,18 @@ class Create { void create_newLike() { // given // when + inMemorySongs.refreshSongs(songRepository.findAllWithKillingPartsAndLikes()); likeService.updateLikeStatus(SAVED_KILLING_PART.getId(), SAVED_MEMBER.getId(), - new KillingPartLikeRequest(true)); + new KillingPartLikeRequest(true)); saveAndClearEntityManager(); // then final Optional savedLike = killingPartLikeRepository. findByKillingPartAndMember(SAVED_KILLING_PART, SAVED_MEMBER); - final Optional updatedKillingPart = killingPartRepository.findById( - SAVED_KILLING_PART.getId()); + final Optional updatedKillingPart = inMemorySongs.getSongById(SAVED_SONG.getId()) + .getKillingParts().stream() + .filter(killingPart -> killingPart.getId().equals(SAVED_KILLING_PART.getId())) + .findAny(); assertThat(savedLike).isPresent() .get() @@ -80,22 +94,25 @@ void create_newLike() { @Test void create_updateLike_exist() { // given + inMemorySongs.refreshSongs(songRepository.findAllWithKillingPartsAndLikes()); likeService.updateLikeStatus(SAVED_KILLING_PART.getId(), SAVED_MEMBER.getId(), - new KillingPartLikeRequest(true)); + new KillingPartLikeRequest(true)); likeService.updateLikeStatus(SAVED_KILLING_PART.getId(), SAVED_MEMBER.getId(), - new KillingPartLikeRequest(false)); + new KillingPartLikeRequest(false)); saveAndClearEntityManager(); // when likeService.updateLikeStatus(SAVED_KILLING_PART.getId(), SAVED_MEMBER.getId(), - new KillingPartLikeRequest(true)); + new KillingPartLikeRequest(true)); saveAndClearEntityManager(); // then final Optional savedLike = killingPartLikeRepository. findByKillingPartAndMember(SAVED_KILLING_PART, SAVED_MEMBER); - final Optional updatedKillingPart = killingPartRepository.findById( - SAVED_KILLING_PART.getId()); + final Optional updatedKillingPart = inMemorySongs.getSongById(SAVED_SONG.getId()) + .getKillingParts().stream() + .filter(killingPart -> killingPart.getId().equals(SAVED_KILLING_PART.getId())) + .findAny(); assertThat(savedLike).isPresent() .get() @@ -110,20 +127,23 @@ void create_updateLike_exist() { @Test void create_noAction() { // given + inMemorySongs.refreshSongs(songRepository.findAllWithKillingPartsAndLikes()); likeService.updateLikeStatus(SAVED_KILLING_PART.getId(), SAVED_MEMBER.getId(), - new KillingPartLikeRequest(true)); + new KillingPartLikeRequest(true)); saveAndClearEntityManager(); // when likeService.updateLikeStatus(SAVED_KILLING_PART.getId(), SAVED_MEMBER.getId(), - new KillingPartLikeRequest(true)); + new KillingPartLikeRequest(true)); saveAndClearEntityManager(); // then final Optional savedLike = killingPartLikeRepository. findByKillingPartAndMember(SAVED_KILLING_PART, SAVED_MEMBER); - final Optional updatedKillingPart = killingPartRepository.findById( - SAVED_KILLING_PART.getId()); + final Optional updatedKillingPart = inMemorySongs.getSongById(SAVED_SONG.getId()) + .getKillingParts().stream() + .filter(killingPart -> killingPart.getId().equals(SAVED_KILLING_PART.getId())) + .findAny(); assertThat(savedLike).isPresent() .get() @@ -141,7 +161,7 @@ void create_KillingPartException() { // when, then assertThatThrownBy( () -> likeService.updateLikeStatus(UNSAVED_KILLING_PART_ID, SAVED_MEMBER.getId(), - new KillingPartLikeRequest(true))) + new KillingPartLikeRequest(true))) .isInstanceOf(KillingPartException.PartNotExistException.class); } @@ -152,7 +172,7 @@ void create_memberNotExist() { // when, then assertThatThrownBy( () -> likeService.updateLikeStatus(SAVED_KILLING_PART.getId(), UNSAVED_MEMBER_ID, - new KillingPartLikeRequest(true))) + new KillingPartLikeRequest(true))) .isInstanceOf(MemberException.MemberNotExistException.class); } } @@ -167,7 +187,7 @@ void delete_noAction() { // given // when likeService.updateLikeStatus(SAVED_KILLING_PART.getId(), SAVED_MEMBER.getId(), - new KillingPartLikeRequest(false)); + new KillingPartLikeRequest(false)); saveAndClearEntityManager(); // then @@ -186,58 +206,54 @@ void delete_noAction() { @Test void delete_alreadyDeleted_noAction() { // given + inMemorySongs.refreshSongs(songRepository.findAllWithKillingPartsAndLikes()); likeService.updateLikeStatus(SAVED_KILLING_PART.getId(), SAVED_MEMBER.getId(), - new KillingPartLikeRequest(true)); + new KillingPartLikeRequest(true)); likeService.updateLikeStatus(SAVED_KILLING_PART.getId(), SAVED_MEMBER.getId(), - new KillingPartLikeRequest(false)); + new KillingPartLikeRequest(false)); saveAndClearEntityManager(); // when likeService.updateLikeStatus(SAVED_KILLING_PART.getId(), SAVED_MEMBER.getId(), - new KillingPartLikeRequest(false)); + new KillingPartLikeRequest(false)); saveAndClearEntityManager(); // then final Optional savedLike = killingPartLikeRepository. findByKillingPartAndMember(SAVED_KILLING_PART, SAVED_MEMBER); - final Optional updatedKillingPart = killingPartRepository.findById( - SAVED_KILLING_PART.getId()); + final Song savedSong = inMemorySongs.getSongById(SAVED_SONG.getId()); assertThat(savedLike).isPresent() .get() .hasFieldOrPropertyWithValue("isDeleted", true); - assertThat(updatedKillingPart).isPresent() - .get() - .hasFieldOrPropertyWithValue("likeCount", 0); + assertThat(savedSong.getTotalLikeCount()).isZero(); } @DisplayName("좋아요 데이터가 존재하는 경우, 상태가 변경된다.") @Test void create_noAction() { // given + inMemorySongs.refreshSongs(songRepository.findAllWithKillingPartsAndLikes()); likeService.updateLikeStatus(SAVED_KILLING_PART.getId(), SAVED_MEMBER.getId(), - new KillingPartLikeRequest(true)); + new KillingPartLikeRequest(true)); saveAndClearEntityManager(); // when likeService.updateLikeStatus(SAVED_KILLING_PART.getId(), SAVED_MEMBER.getId(), - new KillingPartLikeRequest(false)); + new KillingPartLikeRequest(false)); saveAndClearEntityManager(); // then final Optional savedLike = killingPartLikeRepository. findByKillingPartAndMember(SAVED_KILLING_PART, SAVED_MEMBER); - final Optional updatedKillingPart = killingPartRepository.findById( - SAVED_KILLING_PART.getId()); + final Song savedSong = inMemorySongs.getSongById(SAVED_SONG.getId()); assertThat(savedLike).isPresent() .get() .hasFieldOrPropertyWithValue("isDeleted", true); - assertThat(updatedKillingPart).isPresent() - .get() - .hasFieldOrPropertyWithValue("likeCount", 0); + assertThat(savedSong.getTotalLikeCount()).isZero(); } @DisplayName("존재하지 않는 킬링파트면 예외가 발생한다.") @@ -247,7 +263,7 @@ void create_KillingPartException() { // when, then assertThatThrownBy( () -> likeService.updateLikeStatus(UNSAVED_KILLING_PART_ID, SAVED_MEMBER.getId(), - new KillingPartLikeRequest(true))) + new KillingPartLikeRequest(true))) .isInstanceOf(KillingPartException.PartNotExistException.class); } @@ -258,7 +274,7 @@ void create_memberNotExist() { // when, then assertThatThrownBy( () -> likeService.updateLikeStatus(SAVED_KILLING_PART.getId(), UNSAVED_MEMBER_ID, - new KillingPartLikeRequest(false))) + new KillingPartLikeRequest(false))) .isInstanceOf(MemberException.MemberNotExistException.class); } } diff --git a/backend/src/test/java/shook/shook/song/domain/InMemorySongsTest.java b/backend/src/test/java/shook/shook/song/domain/InMemorySongsTest.java index 872023c82..5f6abd39f 100644 --- a/backend/src/test/java/shook/shook/song/domain/InMemorySongsTest.java +++ b/backend/src/test/java/shook/shook/song/domain/InMemorySongsTest.java @@ -39,12 +39,12 @@ void setUp() { @Test void recreate() { // given - final List songs = songRepository.findAllWithKillingParts(); + final List songs = songRepository.findAllWithKillingPartsAndLikes(); likeAllKillingPartsInSong(songs.get(0)); likeAllKillingPartsInSong(songs.get(1)); // when - inMemorySongs.recreate(songs); + inMemorySongs.refreshSongs(songs); // 정렬 순서: 2L, 1L, 4L // then @@ -67,14 +67,14 @@ private void likeAllKillingPartsInSong(final Song song) { @Test void getSongById() { // given - final List songs = songRepository.findAllWithKillingParts(); - inMemorySongs.recreate(songs); + inMemorySongs.refreshSongs(songRepository.findAllWithKillingPartsAndLikes()); // when + final List allSongs = inMemorySongs.getSongs(); final Song foundSong = inMemorySongs.getSongById(4L); // then - final Song expectedSong = songs.get(0); + final Song expectedSong = allSongs.get(0); assertThat(foundSong).isEqualTo(expectedSong); } @@ -82,14 +82,14 @@ void getSongById() { @Test void getPrevLikedSongs() { // given - final List songs = songRepository.findAllWithKillingParts(); + final List songs = songRepository.findAllWithKillingPartsAndLikes(); final Song firstSong = songs.get(0); final Song secondSong = songs.get(1); final Song thirdSong = songs.get(2); final Song fourthSong = songs.get(3); likeAllKillingPartsInSong(firstSong); likeAllKillingPartsInSong(secondSong); - inMemorySongs.recreate(songs); // second, first, fourth, third + inMemorySongs.refreshSongs(songs); // second, first, fourth, third // when final List prevLikedSongs = inMemorySongs.getPrevLikedSongs(thirdSong, 2); @@ -106,14 +106,14 @@ void getPrevLikedSongs() { @Test void getNextLikedSongs() { // given - final List songs = songRepository.findAllWithKillingParts(); + final List songs = songRepository.findAllWithKillingPartsAndLikes(); final Song firstSong = songs.get(0); final Song secondSong = songs.get(1); final Song thirdSong = songs.get(2); final Song fourthSong = songs.get(3); likeAllKillingPartsInSong(firstSong); likeAllKillingPartsInSong(secondSong); - inMemorySongs.recreate(songs); // second, first, fourth, third + inMemorySongs.refreshSongs(songs); // second, first, fourth, third // when final List nextLikedSongs = inMemorySongs.getNextLikedSongs(secondSong, 2); @@ -129,14 +129,14 @@ void getNextLikedSongs() { @DisplayName("특정 장르 노래에 대해 1. 좋아요 수가 더 적거나 2. 좋아요 수가 같은 경우 id가 더 작은 노래 목록을 조회한다.") @Test void getSortedSongsByGenre() { - final List songs = songRepository.findAllWithKillingParts(); + final List songs = songRepository.findAllWithKillingPartsAndLikes(); final Song firstSong = songs.get(0); final Song secondSong = songs.get(1); final Song thirdSong = songs.get(2); final Song fourthSong = songs.get(3); likeAllKillingPartsInSong(firstSong); likeAllKillingPartsInSong(secondSong); - inMemorySongs.recreate(songs); // first, fourth, third + inMemorySongs.refreshSongs(songs); // first, fourth, third // when final List prevLikedSongs = inMemorySongs.getPrevLikedSongByGenre(firstSong, Genre.DANCE, 2); @@ -151,14 +151,14 @@ void getSortedSongsByGenre() { @DisplayName("특정 장르 노래에 대해 1. 좋아요 수가 더 많거나 2. 좋아요 수가 같은 경우 id가 더 큰 노래 목록을 조회한다.") @Test void getPrevLikedSongByGenre() { - final List songs = songRepository.findAllWithKillingParts(); + final List songs = songRepository.findAllWithKillingPartsAndLikes(); final Song firstSong = songs.get(0); final Song secondSong = songs.get(1); final Song thirdSong = songs.get(2); final Song fourthSong = songs.get(3); likeAllKillingPartsInSong(firstSong); likeAllKillingPartsInSong(secondSong); - inMemorySongs.recreate(songs); // first, fourth, third + inMemorySongs.refreshSongs(songs); // first, fourth, third // when final List prevLikedSongs = inMemorySongs.getNextLikedSongByGenre(thirdSong, Genre.DANCE, 2); diff --git a/backend/src/test/java/shook/shook/song/domain/killingpart/KillingPartLikesTest.java b/backend/src/test/java/shook/shook/song/domain/killingpart/KillingPartLikesTest.java index b4e688271..ab405e0ab 100644 --- a/backend/src/test/java/shook/shook/song/domain/killingpart/KillingPartLikesTest.java +++ b/backend/src/test/java/shook/shook/song/domain/killingpart/KillingPartLikesTest.java @@ -2,7 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; -import java.util.List; +import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -135,7 +135,7 @@ void getLikes() { KILLING_PART.unlike(newLike); // when - final List likes = LIKES.getLikes(); + final Set likes = LIKES.getLikes(); // then assertThat(LIKES.getSize()).isEqualTo(1); diff --git a/backend/src/test/java/shook/shook/song/domain/killingpart/repository/KillingPartRepositoryTest.java b/backend/src/test/java/shook/shook/song/domain/killingpart/repository/KillingPartRepositoryTest.java index 19299528b..70e4db6f6 100644 --- a/backend/src/test/java/shook/shook/song/domain/killingpart/repository/KillingPartRepositoryTest.java +++ b/backend/src/test/java/shook/shook/song/domain/killingpart/repository/KillingPartRepositoryTest.java @@ -110,40 +110,40 @@ void findAllBySong() { ); } - @DisplayName("한 킬링파트에 UPDATE + 1로 좋아요 수를 증가시킨다.") - @Test - void increaseLikeCount() { - // given - killingPartRepository.saveAll(KILLING_PARTS.getKillingParts()); - final KillingPart killingPart = killingPartRepository.findById(FIRST_KILLING_PART.getId()).get(); - final int initialLikeCount = killingPart.getLikeCount(); - - // when - saveAndClearEntityManager(); - killingPartRepository.increaseLikeCount(killingPart.getId()); - - // then - final KillingPart foundKillingPart = killingPartRepository.findById(killingPart.getId()).get(); - - assertThat(foundKillingPart.getLikeCount()).isEqualTo(initialLikeCount + 1); - } - - @DisplayName("한 킬링파트에 UPDATE - 1로 좋아요 수를 감소시킨다.") - @Test - void decreaseLikeCount() { - // given - killingPartRepository.saveAll(KILLING_PARTS.getKillingParts()); - killingPartRepository.increaseLikeCount(FIRST_KILLING_PART.getId()); - final KillingPart killingPart = killingPartRepository.findById(FIRST_KILLING_PART.getId()).get(); - final int initialLikeCount = killingPart.getLikeCount(); - - // when - saveAndClearEntityManager(); - killingPartRepository.decreaseLikeCount(killingPart.getId()); - - // then - final KillingPart foundKillingPart = killingPartRepository.findById(killingPart.getId()).get(); - - assertThat(foundKillingPart.getLikeCount()).isEqualTo(initialLikeCount - 1); - } +// @DisplayName("한 킬링파트에 UPDATE + 1로 좋아요 수를 증가시킨다.") +// @Test +// void increaseLikeCount() { +// // given +// killingPartRepository.saveAll(KILLING_PARTS.getKillingParts()); +// final KillingPart killingPart = killingPartRepository.findById(FIRST_KILLING_PART.getId()).get(); +// final int initialLikeCount = killingPart.getLikeCount(); +// +// // when +// saveAndClearEntityManager(); +// killingPartRepository.increaseLikeCount(killingPart.getId()); +// +// // then +// final KillingPart foundKillingPart = killingPartRepository.findById(killingPart.getId()).get(); +// +// assertThat(foundKillingPart.getLikeCount()).isEqualTo(initialLikeCount + 1); +// } +// +// @DisplayName("한 킬링파트에 UPDATE - 1로 좋아요 수를 감소시킨다.") +// @Test +// void decreaseLikeCount() { +// // given +// killingPartRepository.saveAll(KILLING_PARTS.getKillingParts()); +// killingPartRepository.increaseLikeCount(FIRST_KILLING_PART.getId()); +// final KillingPart killingPart = killingPartRepository.findById(FIRST_KILLING_PART.getId()).get(); +// final int initialLikeCount = killingPart.getLikeCount(); +// +// // when +// saveAndClearEntityManager(); +// killingPartRepository.decreaseLikeCount(killingPart.getId()); +// +// // then +// final KillingPart foundKillingPart = killingPartRepository.findById(killingPart.getId()).get(); +// +// assertThat(foundKillingPart.getLikeCount()).isEqualTo(initialLikeCount - 1); +// } } diff --git a/backend/src/test/java/shook/shook/song/ui/HighLikedSongControllerTest.java b/backend/src/test/java/shook/shook/song/ui/HighLikedSongControllerTest.java index 653d29958..f9cedc93e 100644 --- a/backend/src/test/java/shook/shook/song/ui/HighLikedSongControllerTest.java +++ b/backend/src/test/java/shook/shook/song/ui/HighLikedSongControllerTest.java @@ -51,16 +51,15 @@ void setUp() { @Test void showHighLikedSongs() { //given + inMemorySongsScheduler.recreateCachedSong(); likeService.updateLikeStatus(FIRST_SONG_KILLING_PART_ID_1, MEMBER_ID, - new KillingPartLikeRequest(true)); + new KillingPartLikeRequest(true)); likeService.updateLikeStatus(FIRST_SONG_KILLING_PART_ID_2, MEMBER_ID, - new KillingPartLikeRequest(true)); + new KillingPartLikeRequest(true)); likeService.updateLikeStatus(SECOND_SONG_KILLING_PART_ID_1, MEMBER_ID, - new KillingPartLikeRequest(true)); + new KillingPartLikeRequest(true)); // 정렬 순서 1L 2L 4L 3L - inMemorySongsScheduler.recreateCachedSong(); - //when final List responses = RestAssured.given().log().all() .when().log().all() @@ -94,16 +93,15 @@ void showHighLikedSongs() { void showHighLikedSongsWithGenre() { // given final String genre = "DANCE"; + inMemorySongsScheduler.recreateCachedSong(); likeService.updateLikeStatus(FIRST_SONG_KILLING_PART_ID_1, MEMBER_ID, - new KillingPartLikeRequest(true)); + new KillingPartLikeRequest(true)); likeService.updateLikeStatus(FIRST_SONG_KILLING_PART_ID_2, MEMBER_ID, - new KillingPartLikeRequest(true)); + new KillingPartLikeRequest(true)); likeService.updateLikeStatus(SECOND_SONG_KILLING_PART_ID_1, MEMBER_ID, - new KillingPartLikeRequest(true)); + new KillingPartLikeRequest(true)); // 정렬 순서 1L 4L 3L - inMemorySongsScheduler.recreateCachedSong(); - // when final List responses = RestAssured.given().log().all() .queryParam("genre", genre) @@ -118,11 +116,11 @@ void showHighLikedSongsWithGenre() { assertAll( () -> assertThat(responses).hasSize(3), () -> assertThat(responses.stream() - .map(HighLikedSongResponse::getId) - .toList()) + .map(HighLikedSongResponse::getId) + .toList()) .containsExactly(1L, 4L, 3L), () -> assertThat(responses.stream() - .map(HighLikedSongResponse::getTotalLikeCount)) + .map(HighLikedSongResponse::getTotalLikeCount)) .containsExactly(2L, 0L, 0L) ); } diff --git a/backend/src/test/java/shook/shook/song/ui/MyPageControllerTest.java b/backend/src/test/java/shook/shook/song/ui/MyPageControllerTest.java index 9016cf812..73392533d 100644 --- a/backend/src/test/java/shook/shook/song/ui/MyPageControllerTest.java +++ b/backend/src/test/java/shook/shook/song/ui/MyPageControllerTest.java @@ -25,6 +25,7 @@ import shook.shook.song.application.dto.MyPartsResponse; import shook.shook.song.application.killingpart.KillingPartLikeService; import shook.shook.song.application.killingpart.dto.KillingPartLikeRequest; +import shook.shook.song.domain.InMemorySongs; import shook.shook.song.domain.Song; import shook.shook.song.domain.killingpart.KillingPart; import shook.shook.song.domain.killingpart.repository.KillingPartRepository; @@ -46,6 +47,9 @@ void setUp() { private static final long SAVED_MEMBER_ID = 1L; private static final String SAVED_MEMBER_NICKNAME = "nickname"; + @Autowired + private InMemorySongs inMemorySongs; + @Autowired private TokenProvider tokenProvider; @@ -73,6 +77,8 @@ class GetLikedKillingParts { @Test void likedKillingPartExistWithOneDeletedLikeExist() { //given + inMemorySongs.refreshSongs(songRepository.findAllWithKillingPartsAndLikes()); + final String accessToken = tokenProvider.createAccessToken(SAVED_MEMBER_ID, SAVED_MEMBER_NICKNAME); diff --git a/backend/src/test/java/shook/shook/song/ui/SongControllerTest.java b/backend/src/test/java/shook/shook/song/ui/SongControllerTest.java index 276805da1..bf77d0377 100644 --- a/backend/src/test/java/shook/shook/song/ui/SongControllerTest.java +++ b/backend/src/test/java/shook/shook/song/ui/SongControllerTest.java @@ -57,6 +57,7 @@ void findSongById() { final String accessToken = tokenProvider.createAccessToken(MEMBER_ID, MEMBER_NICKNAME); final Long songId = 1L; + inMemorySongsScheduler.recreateCachedSong(); likeService.updateLikeStatus(FIRST_SONG_KILLING_PART_ID_1, MEMBER_ID, new KillingPartLikeRequest(true)); likeService.updateLikeStatus(FIRST_SONG_KILLING_PART_ID_1, MEMBER_ID, @@ -65,7 +66,6 @@ void findSongById() { new KillingPartLikeRequest(true)); likeService.updateLikeStatus(FIRST_SONG_KILLING_PART_ID_3, MEMBER_ID, new KillingPartLikeRequest(true)); - inMemorySongsScheduler.recreateCachedSong(); memberPartService.register(songId, MEMBER_ID, new MemberPartRegisterRequest(0, 10)); diff --git a/backend/src/test/java/shook/shook/song/ui/SongSwipeControllerTest.java b/backend/src/test/java/shook/shook/song/ui/SongSwipeControllerTest.java index 494b106b7..fe25d891f 100644 --- a/backend/src/test/java/shook/shook/song/ui/SongSwipeControllerTest.java +++ b/backend/src/test/java/shook/shook/song/ui/SongSwipeControllerTest.java @@ -25,7 +25,6 @@ import shook.shook.song.application.killingpart.dto.HighLikedSongResponse; import shook.shook.song.application.killingpart.dto.KillingPartLikeRequest; -@SuppressWarnings("NonAsciiCharacters") @Sql("classpath:/killingpart/initialize_killing_part_song.sql") @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) class SongSwipeControllerTest { @@ -62,6 +61,7 @@ void showSongById() { final String accessToken = tokenProvider.createAccessToken(MEMBER_ID, "nickname"); final Long songId = 2L; + inMemorySongsScheduler.recreateCachedSong(); likeService.updateLikeStatus(FIRST_SONG_KILLING_PART_ID_1, MEMBER_ID, new KillingPartLikeRequest(true)); likeService.updateLikeStatus(FIRST_SONG_KILLING_PART_ID_2, MEMBER_ID, @@ -69,7 +69,6 @@ void showSongById() { likeService.updateLikeStatus(SECOND_SONG_KILLING_PART_ID_1, MEMBER_ID, new KillingPartLikeRequest(true)); - inMemorySongsScheduler.recreateCachedSong(); // 정렬 순서: 1L, 2L, 4L, 3L //when final SongSwipeResponse response = RestAssured.given().log().all() @@ -97,6 +96,8 @@ void showSongsBeforeSongWithId() { // given final Long songId = 2L; final String accessToken = tokenProvider.createAccessToken(MEMBER_ID, "nickname"); + + inMemorySongsScheduler.recreateCachedSong(); likeService.updateLikeStatus(FIRST_SONG_KILLING_PART_ID_1, MEMBER_ID, new KillingPartLikeRequest(true)); likeService.updateLikeStatus(FIRST_SONG_KILLING_PART_ID_2, MEMBER_ID, @@ -105,8 +106,6 @@ void showSongsBeforeSongWithId() { memberPartService.register(1L, MEMBER_ID, new MemberPartRegisterRequest(5, 5)); // 정렬 순서 1L, 4L, 3L, 2L - inMemorySongsScheduler.recreateCachedSong(); - //when final List response = RestAssured.given().log().all() .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) @@ -214,6 +213,7 @@ void findSongsByGenreForSwipe() { final String genre = "DANCE"; final String accessToken = tokenProvider.createAccessToken(MEMBER_ID, "nickname"); + inMemorySongsScheduler.recreateCachedSong(); likeService.updateLikeStatus(FIRST_SONG_KILLING_PART_ID_1, MEMBER_ID, new KillingPartLikeRequest(true)); likeService.updateLikeStatus(FIRST_SONG_KILLING_PART_ID_2, MEMBER_ID, @@ -224,8 +224,6 @@ void findSongsByGenreForSwipe() { memberPartService.register(1L, MEMBER_ID, new MemberPartRegisterRequest(5, 5)); // 정렬 순서 1L, 4L, 3L - inMemorySongsScheduler.recreateCachedSong(); - //when final SongSwipeResponse response = RestAssured.given().log().all() .queryParam("genre", genre) @@ -258,6 +256,7 @@ void showPrevSongsWithGenre() { final String genre = "DANCE"; final String accessToken = tokenProvider.createAccessToken(MEMBER_ID, "nickname"); + inMemorySongsScheduler.recreateCachedSong(); likeService.updateLikeStatus(FIRST_SONG_KILLING_PART_ID_1, MEMBER_ID, new KillingPartLikeRequest(true)); likeService.updateLikeStatus(FIRST_SONG_KILLING_PART_ID_2, MEMBER_ID, @@ -266,8 +265,6 @@ void showPrevSongsWithGenre() { memberPartService.register(1L, MEMBER_ID, new MemberPartRegisterRequest(5, 5)); // 정렬 순서 1L, 4L, 3L - inMemorySongsScheduler.recreateCachedSong(); - //when final List response = RestAssured.given().log().all() .queryParam("genre", genre) @@ -295,6 +292,7 @@ void showNextSongsWithGenre() { final String genre = "DANCE"; final String accessToken = tokenProvider.createAccessToken(MEMBER_ID, "nickname"); + inMemorySongsScheduler.recreateCachedSong(); likeService.updateLikeStatus(FIRST_SONG_KILLING_PART_ID_1, MEMBER_ID, new KillingPartLikeRequest(true)); likeService.updateLikeStatus(FIRST_SONG_KILLING_PART_ID_2, MEMBER_ID, @@ -305,8 +303,6 @@ void showNextSongsWithGenre() { memberPartService.register(4L, MEMBER_ID, new MemberPartRegisterRequest(5, 5)); // 정렬 순서 1L, 4L, 3L - inMemorySongsScheduler.recreateCachedSong(); - //when final List response = RestAssured.given().log().all() .queryParam("genre", genre)