From 65fc22a3dce073bab2676dc09337ba1be3940a4c Mon Sep 17 00:00:00 2001
From: sooloin
Date: Sun, 14 Jun 2026 01:14:39 +0900
Subject: [PATCH 1/3] =?UTF-8?q?:sparkles:=20Feat:=20=EC=BB=A4=EB=AE=A4?=
=?UTF-8?q?=EB=8B=88=ED=8B=B0=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EB=B0=98?=
=?UTF-8?q?=EC=9D=91=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 게시글 좋아요/싫어요 반응 API 연동
- 게시글 상세에서 반응 추가, 변경, 취소 기능 구현
- 반응 후 상세/목록 UI가 즉시 반영되도록 낙관적 업데이트 적용
- 커뮤니티 목록에서는 좋아요 수만 노출되도록 유지
- 404, 409 등 반응 관련 예외 상황 메시지 처리
`#56`
---
src/features/community/api/reactions.ts | 21 ++++
src/features/community/index.ts | 8 ++
src/features/community/lib/constants.ts | 13 +-
src/features/community/lib/reactions.ts | 68 +++++++++++
src/features/community/model/types.ts | 20 +++
.../community/model/useBoardReaction.ts | 114 ++++++++++++++++++
.../community/ui/BoardDetailContent.tsx | 85 ++++++++++++-
src/pages/community/BoardDetailPage.tsx | 28 +++++
8 files changed, 351 insertions(+), 6 deletions(-)
create mode 100644 src/features/community/api/reactions.ts
create mode 100644 src/features/community/lib/reactions.ts
create mode 100644 src/features/community/model/useBoardReaction.ts
diff --git a/src/features/community/api/reactions.ts b/src/features/community/api/reactions.ts
new file mode 100644
index 0000000..2a436b4
--- /dev/null
+++ b/src/features/community/api/reactions.ts
@@ -0,0 +1,21 @@
+import { apiClient } from '@/shared/api/axios';
+
+import type { BoardReactionResponse, CreateBoardReactionRequest, UpdateBoardReactionRequest } from '../model/types';
+
+export async function createBoardReaction(payload: CreateBoardReactionRequest): Promise {
+ const response = await apiClient.post('/reactions/board', payload);
+ return response.data;
+}
+
+export async function updateBoardReaction(
+ boardId: number,
+ payload: UpdateBoardReactionRequest,
+): Promise {
+ const response = await apiClient.patch(`/reactions/board/${boardId}`, payload);
+ return response.data;
+}
+
+export async function deleteBoardReaction(boardId: number): Promise {
+ const response = await apiClient.delete(`/reactions/board/${boardId}`);
+ return response.data;
+}
diff --git a/src/features/community/index.ts b/src/features/community/index.ts
index 13d126a..4f4916a 100644
--- a/src/features/community/index.ts
+++ b/src/features/community/index.ts
@@ -9,16 +9,19 @@ export {
updateBoard,
} from './api/boards';
export { createComment, deleteComment, getCommentList, getMyCommentList, updateComment } from './api/comments';
+export { deleteBoardReaction, createBoardReaction, updateBoardReaction } from './api/reactions';
export {
BOARD_DETAIL_STATUS_MESSAGES,
BOARD_DRAFT_STATUS_MESSAGES,
BOARD_LIST_STATUS_MESSAGES,
BOARD_MUTATION_STATUS_MESSAGES,
+ BOARD_REACTION_STATUS_MESSAGES,
COMMENT_LIST_STATUS_MESSAGES,
COMMENT_MUTATION_STATUS_MESSAGES,
COMMUNITY_DRAFT_SESSION_KEY,
MY_ACTIVITY_STATUS_MESSAGES,
} from './lib/constants';
+export { getBoardReactionType, normalizeReactionType } from './lib/reactions';
export {
clearStoredBoardDraftSessionKey,
getStoredBoardDraftSessionKey,
@@ -28,6 +31,7 @@ export { INITIAL_BOARD_EDITOR_FORM_STATE, validateBoardEditorForm } from './lib/
export { useBoardDetail } from './model/useBoardDetail';
export { useBoardEditorForm } from './model/useBoardEditorForm';
export { useBoardList } from './model/useBoardList';
+export { useBoardReaction } from './model/useBoardReaction';
export { useCommentList } from './model/useCommentList';
export { useCreateBoard } from './model/useCreateBoard';
export { useCreateComment } from './model/useCreateComment';
@@ -52,22 +56,26 @@ export type {
BoardListItem,
BoardListResponse,
BoardPayload,
+ BoardReactionResponse,
CommentListResponse,
CommentPageInfo,
CreateCommentRequest,
CreateCommentResponse,
CreateBoardRequest,
CreateBoardResponse,
+ CreateBoardReactionRequest,
DeleteCommentResponse,
DeleteBoardResponse,
MyBoardListResponse,
MyCommentListItem,
MyCommentListResponse,
+ ReactionType,
TempSavedBoardResponse,
TempSaveBoardRequest,
TempSaveBoardResponse,
UpdateCommentRequest,
UpdateCommentResponse,
+ UpdateBoardReactionRequest,
UpdateBoardRequest,
UpdateBoardResponse,
} from './model/types';
diff --git a/src/features/community/lib/constants.ts b/src/features/community/lib/constants.ts
index 30a58ac..c3c6360 100644
--- a/src/features/community/lib/constants.ts
+++ b/src/features/community/lib/constants.ts
@@ -1,7 +1,7 @@
export const COMMUNITY_DRAFT_SESSION_KEY = 'community-board-draft-session-key';
export const BOARD_LIST_STATUS_MESSAGES: Partial> = {
- 400: '잘못된 요청이에요.',
+ 400: '게시글 목록 요청이 올바르지 않아요.',
401: '로그인이 필요해요. 다시 로그인해주세요.',
500: '서버 오류가 발생했어요. 잠시 후 다시 시도해주세요.',
};
@@ -13,8 +13,17 @@ export const BOARD_MUTATION_STATUS_MESSAGES: Partial> = {
500: '서버 오류가 발생했어요. 잠시 후 다시 시도해주세요.',
};
+export const BOARD_REACTION_STATUS_MESSAGES: Partial> = {
+ 400: '반응 요청이 올바르지 않아요. 다시 시도해주세요.',
+ 401: '로그인이 필요한 기능이에요. 다시 로그인해주세요.',
+ 403: '이 게시물에 반응할 권한이 없어요.',
+ 404: '게시물을 찾을 수 없거나 남긴 반응이 없어요.',
+ 409: '이미 같은 반응을 남긴 게시물이에요.',
+ 500: '서버 오류가 발생했어요. 잠시 후 다시 시도해주세요.',
+};
+
export const BOARD_DETAIL_STATUS_MESSAGES: Partial> = {
- 400: '잘못된 게시글 요청이에요.',
+ 400: '게시글 요청이 올바르지 않아요.',
401: '로그인이 필요해요. 다시 로그인해주세요.',
403: '게시글을 조회할 권한이 없어요.',
404: '게시글을 찾을 수 없어요.',
diff --git a/src/features/community/lib/reactions.ts b/src/features/community/lib/reactions.ts
new file mode 100644
index 0000000..cd3cfca
--- /dev/null
+++ b/src/features/community/lib/reactions.ts
@@ -0,0 +1,68 @@
+import type { BoardDetailResponse, BoardListItem, ReactionType } from '../model/types';
+
+const REACTION_TYPES: ReactionType[] = ['LIKE', 'DISLIKE'];
+
+function clampCount(value: number) {
+ return Math.max(0, value);
+}
+
+export function normalizeReactionType(value: unknown): ReactionType | null {
+ if (typeof value !== 'string') {
+ return null;
+ }
+
+ const normalized = value.toUpperCase();
+ return REACTION_TYPES.includes(normalized as ReactionType) ? (normalized as ReactionType) : null;
+}
+
+export function getBoardReactionType(board: Partial | null | undefined): ReactionType | null {
+ if (!board) {
+ return null;
+ }
+
+ return (
+ normalizeReactionType(board.reactionType) ??
+ normalizeReactionType(board.myReactionType) ??
+ normalizeReactionType(board.currentUserReactionType) ??
+ normalizeReactionType(board.userReactionType)
+ );
+}
+
+export function getReactionCountDelta(previousReaction: ReactionType | null, nextReaction: ReactionType | null) {
+ const likeDelta = (nextReaction === 'LIKE' ? 1 : 0) - (previousReaction === 'LIKE' ? 1 : 0);
+ const dislikeDelta = (nextReaction === 'DISLIKE' ? 1 : 0) - (previousReaction === 'DISLIKE' ? 1 : 0);
+
+ return { likeDelta, dislikeDelta };
+}
+
+export function applyReactionToBoardDetail(
+ board: BoardDetailResponse,
+ previousReaction: ReactionType | null,
+ nextReaction: ReactionType | null,
+): BoardDetailResponse {
+ const { likeDelta, dislikeDelta } = getReactionCountDelta(previousReaction, nextReaction);
+
+ return {
+ ...board,
+ likeCount: clampCount((board.likeCount ?? 0) + likeDelta),
+ dislikeCount: clampCount((board.dislikeCount ?? 0) + dislikeDelta),
+ reactionType: nextReaction,
+ myReactionType: nextReaction,
+ currentUserReactionType: nextReaction,
+ userReactionType: nextReaction,
+ };
+}
+
+export function applyReactionToBoardListItem(
+ board: BoardListItem,
+ previousReaction: ReactionType | null,
+ nextReaction: ReactionType | null,
+): BoardListItem {
+ const { likeDelta, dislikeDelta } = getReactionCountDelta(previousReaction, nextReaction);
+
+ return {
+ ...board,
+ likeCount: clampCount(board.likeCount + likeDelta),
+ dislikeCount: clampCount(board.dislikeCount + dislikeDelta),
+ };
+}
diff --git a/src/features/community/model/types.ts b/src/features/community/model/types.ts
index 8359481..de605b0 100644
--- a/src/features/community/model/types.ts
+++ b/src/features/community/model/types.ts
@@ -12,6 +12,8 @@ export interface BoardListItem {
modifiedAt: string;
}
+export type ReactionType = 'LIKE' | 'DISLIKE';
+
export interface BoardListResponse {
message: string;
boards: BoardListItem[];
@@ -60,10 +62,28 @@ export interface BoardDetailResponse {
profileUrl: string | null;
nickname: string;
likeCount?: number;
+ dislikeCount?: number;
commentCount?: number;
viewCount: number;
boardCreatedAt: string;
modifiedAt: string;
+ reactionType?: ReactionType | null;
+ myReactionType?: ReactionType | null;
+ currentUserReactionType?: ReactionType | null;
+ userReactionType?: ReactionType | null;
+}
+
+export interface CreateBoardReactionRequest {
+ boardId: number;
+ reactionType: ReactionType;
+}
+
+export interface UpdateBoardReactionRequest {
+ reactionType: ReactionType;
+}
+
+export interface BoardReactionResponse {
+ message: string;
}
export type UpdateBoardRequest = BoardPayload;
diff --git a/src/features/community/model/useBoardReaction.ts b/src/features/community/model/useBoardReaction.ts
new file mode 100644
index 0000000..8f0da64
--- /dev/null
+++ b/src/features/community/model/useBoardReaction.ts
@@ -0,0 +1,114 @@
+import { useMutation, useQueryClient, type InfiniteData } from '@tanstack/react-query';
+
+import { createBoardReaction, deleteBoardReaction, updateBoardReaction } from '../api/reactions';
+import { applyReactionToBoardDetail, applyReactionToBoardListItem } from '../lib/reactions';
+import type { BoardDetailResponse, BoardListResponse, MyBoardListResponse, ReactionType } from './types';
+import { queryKeys } from '@/shared/lib/react-query/queryKey';
+
+interface BoardReactionVariables {
+ boardId: number;
+ nextReactionType: ReactionType;
+ currentReactionType: ReactionType | null;
+}
+
+interface BoardReactionContext {
+ previousDetail?: BoardDetailResponse;
+ previousListInfinite?: InfiniteData;
+ previousMineQueries: Array;
+}
+
+function updateBoardListResponse(
+ data: BoardListResponse,
+ boardId: number,
+ previousReaction: ReactionType | null,
+ nextReaction: ReactionType | null,
+) {
+ return {
+ ...data,
+ boards: data.boards.map((board) =>
+ board.boardId === boardId ? applyReactionToBoardListItem(board, previousReaction, nextReaction) : board,
+ ),
+ };
+}
+
+export function useBoardReaction() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({ boardId, nextReactionType, currentReactionType }: BoardReactionVariables) => {
+ if (currentReactionType === null) {
+ return createBoardReaction({ boardId, reactionType: nextReactionType });
+ }
+
+ if (currentReactionType === nextReactionType) {
+ return deleteBoardReaction(boardId);
+ }
+
+ return updateBoardReaction(boardId, { reactionType: nextReactionType });
+ },
+ onMutate: async ({ boardId, nextReactionType, currentReactionType }): Promise => {
+ const optimisticReactionType = currentReactionType === nextReactionType ? null : nextReactionType;
+
+ await Promise.all([
+ queryClient.cancelQueries({ queryKey: queryKeys.boards.detail(boardId) }),
+ queryClient.cancelQueries({ queryKey: queryKeys.boards.listInfinite() }),
+ queryClient.cancelQueries({ queryKey: ['boards', 'mine'] }),
+ ]);
+
+ const previousDetail = queryClient.getQueryData(queryKeys.boards.detail(boardId));
+ const previousListInfinite = queryClient.getQueryData>(
+ queryKeys.boards.listInfinite(),
+ );
+ const previousMineQueries = queryClient.getQueriesData({ queryKey: ['boards', 'mine'] });
+
+ queryClient.setQueryData(queryKeys.boards.detail(boardId), (current) =>
+ current ? applyReactionToBoardDetail(current, currentReactionType, optimisticReactionType) : current,
+ );
+
+ queryClient.setQueryData>(queryKeys.boards.listInfinite(), (current) =>
+ current
+ ? {
+ ...current,
+ pages: current.pages.map((page) =>
+ updateBoardListResponse(page, boardId, currentReactionType, optimisticReactionType),
+ ),
+ }
+ : current,
+ );
+
+ queryClient.setQueriesData({ queryKey: ['boards', 'mine'] }, (current) =>
+ current ? updateBoardListResponse(current, boardId, currentReactionType, optimisticReactionType) : current,
+ );
+
+ return {
+ previousDetail,
+ previousListInfinite,
+ previousMineQueries,
+ };
+ },
+ onError: (_error, variables, context) => {
+ if (!context) {
+ return;
+ }
+
+ if (context.previousDetail) {
+ queryClient.setQueryData(queryKeys.boards.detail(variables.boardId), context.previousDetail);
+ }
+
+ if (context.previousListInfinite) {
+ queryClient.setQueryData(queryKeys.boards.listInfinite(), context.previousListInfinite);
+ }
+
+ context.previousMineQueries.forEach(([queryKey, data]) => {
+ queryClient.setQueryData(queryKey, data);
+ });
+ },
+ onSettled: async (_data, _error, variables) => {
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: queryKeys.boards.detail(variables.boardId) }),
+ queryClient.invalidateQueries({ queryKey: queryKeys.boards.listInfinite() }),
+ queryClient.invalidateQueries({ queryKey: ['boards', 'mine'] }),
+ ]);
+ },
+ });
+}
diff --git a/src/features/community/ui/BoardDetailContent.tsx b/src/features/community/ui/BoardDetailContent.tsx
index 3d7ff6c..52f2184 100644
--- a/src/features/community/ui/BoardDetailContent.tsx
+++ b/src/features/community/ui/BoardDetailContent.tsx
@@ -1,7 +1,7 @@
import { useLayoutEffect, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
-import type { BoardComment, BoardDetailResponse, CommentPageInfo } from '../model/types';
+import type { BoardComment, BoardDetailResponse, CommentPageInfo, ReactionType } from '../model/types';
interface BoardDetailContentProps {
board: BoardDetailResponse;
@@ -14,7 +14,11 @@ interface BoardDetailContentProps {
isCreatingComment: boolean;
isUpdatingComment: boolean;
isDeletingComment: boolean;
+ currentReactionType: ReactionType | null;
+ isReacting: boolean;
+ reactionErrorMessage?: string;
onDelete: () => void;
+ onReact: (reactionType: ReactionType) => Promise;
onRetryComments: () => void;
onCreateComment: (payload: { commentContent: string; parentCommentId?: number | null }) => Promise;
onUpdateComment: (payload: { commentId: number; commentContent: string }) => Promise;
@@ -145,11 +149,15 @@ export function BoardDetailContent({
isUpdatingComment,
isDeletingComment,
onDelete,
+ onReact,
onRetryComments,
onCreateComment,
onUpdateComment,
onDeleteComment,
onChangeCommentPage,
+ currentReactionType,
+ isReacting,
+ reactionErrorMessage,
}: BoardDetailContentProps) {
const [draftComment, setDraftComment] = useState('');
const [replyTargetId, setReplyTargetId] = useState(null);
@@ -162,6 +170,7 @@ export function BoardDetailContent({
const [deleteError, setDeleteError] = useState<{ commentId: number; message: string } | null>(null);
const likeCount = board.likeCount ?? 0;
+ const dislikeCount = board.dislikeCount ?? 0;
const commentCount = board.commentCount ?? pageInfo?.totalElements ?? comments.length;
const authorName = board.nickname.trim() || DETAIL_COPY.authorFallback;
const imageUrls = board.imageFileUrls.filter((imageUrl) => imageUrl.trim().length > 0);
@@ -318,10 +327,32 @@ export function BoardDetailContent({
{board.boardContent}
-
-
-
+
+
+ void onReact('LIKE')}
+ />
+ void onReact('DISLIKE')}
+ />
+
+
+
+
+
+
+ {reactionErrorMessage ?
{reactionErrorMessage}
: null}
@@ -573,6 +604,40 @@ function SocialStat({ kind, value }: { kind: 'like' | 'comment'; value: number }
);
}
+interface ReactionButtonProps {
+ reactionType: ReactionType;
+ label: string;
+ count: number;
+ active: boolean;
+ disabled: boolean;
+ onClick: () => void;
+}
+
+function ReactionButton({ reactionType, label, count, active, disabled, onClick }: ReactionButtonProps) {
+ const activeClass =
+ reactionType === 'LIKE'
+ ? 'border-[#ef3c32]/30 bg-[#ef3c32]/8 text-[#d93025]'
+ : 'border-[#4b5563]/30 bg-neutral-100 text-neutral-700';
+ const idleClass = 'border-neutral-200 bg-white text-neutral-500 hover:border-neutral-300 hover:text-neutral-700';
+
+ return (
+
+ );
+}
+
interface CommentRowProps {
comment: BoardComment;
currentUserId?: string | null;
@@ -792,6 +857,18 @@ function ThumbsUpIcon({ className }: { className?: string }) {
);
}
+function ThumbsDownIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
function CommentIcon({ className }: { className?: string }) {
return (