Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/web/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,16 @@ const nextConfig = {
source: '/api/users/me',
destination: `${apiBaseUrl}/users/me`,
},
// 자유 게시판
{
source: '/api/community/freeboard/:path*',
destination: `${apiBaseUrl}/community/freeboard/:path*`,
},
// 투표 게시판
{
source: '/api/community/votesboard/:path*',
destination: `${apiBaseUrl}/community/votesboard/:path*`,
},
Comment on lines +44 to +53
Copy link
Member

Choose a reason for hiding this comment

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

{
        source: '/api/community/:path*',
        destination: `${apiBaseUrl}/community/:path*`,
      },

위처럼 한번에 해도 좋을거같아요!

];
},
//추후 제거 필요
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export default function CommentInput({
const targetRef = useRef<HTMLTextAreaElement>(null);
const queryClient = useQueryClient();
const toast = useToast();
const { guard } = useAuthGuard();
const { requireAuth } = useAuthGuard();

const { mutate, isPending } = useMutation({
mutationFn: (content: string) =>
Expand Down Expand Up @@ -67,11 +67,10 @@ export default function CommentInput({
setValue(next.length > limit ? next.slice(0, limit) : next);
};

const handleSubmit = () =>
guard(() => {
if (!value.trim() || isPending) return;
mutate(value.trim());
});
const handleSubmit = requireAuth(() => {
if (!value.trim() || isPending) return;
mutate(value.trim());
});

const handleKeyDown = (
e: React.KeyboardEvent<HTMLTextAreaElement>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export default function LikeButtonComment({
}: LikeButtonCommentProps) {
const queryClient = useQueryClient();
const toast = useToast();
const { guard } = useAuthGuard();
const { requireAuth } = useAuthGuard();

// UI 전용 상태(부모 props와 동기화됨)
const [liked, setLiked] = useState(!!initialLiked);
Expand Down Expand Up @@ -95,17 +95,16 @@ export default function LikeButtonComment({
});

// 클릭 시: 가드 통과 후, 중복 요청 방지 & 뮤테이션 트리거
const handleToggleLike = () =>
guard(() => {
if (toggleLike.isPending) return;
toggleLike.mutate({ freeboardId: postId, commentId });
});
const handleToggleLike = () => {
if (toggleLike.isPending) return;
toggleLike.mutate({ freeboardId: postId, commentId });
};

return (
<button
type="button"
aria-pressed={liked}
onClick={handleToggleLike}
onClick={requireAuth(handleToggleLike)}
className="flex items-center gap-1.5"
disabled={toggleLike.isPending}
aria-label={liked ? '좋아요 취소' : '좋아요'}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export default function LikeButtonPost({
}: LikeButtonPostProps) {
const queryClient = useQueryClient();
const toast = useToast();
const { guard } = useAuthGuard();
const { requireAuth } = useAuthGuard();

// UI 전용 상태(부모 props와 동기화됨)
const [liked, setLiked] = useState(initialLiked);
Expand Down Expand Up @@ -115,18 +115,17 @@ export default function LikeButtonPost({
},
});

// 클릭 시: 가드 통과 후, 중복 요청 방지 & 뮤테이션 트리거
const handleToggleLike = () =>
guard(() => {
if (toggleLike.isPending) return;
toggleLike.mutate({ freeboardId: postId });
});
// 중복 요청 방지 & 뮤테이션 트리거
const handleToggleLike = () => {
if (toggleLike.isPending) return;
toggleLike.mutate({ freeboardId: postId });
};

return (
<button
type="button"
aria-pressed={liked}
onClick={handleToggleLike}
onClick={requireAuth(handleToggleLike)}
className="flex items-center gap-1.5"
disabled={toggleLike.isPending}
aria-label={liked ? '좋아요 취소' : '좋아요'}
Expand Down
88 changes: 88 additions & 0 deletions apps/web/src/components/LoginRedirectOverlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
'use client';

import { useRouter, usePathname } from 'next/navigation';
import Modal from './Modal';

interface LoginRedirectOverlayProps {
// useOverlay.open()에서 내려주는 close 함수
// - result: 호출 쪽에 resolve로 넘겨줄 값
// - options: fadeOut 여부, 애니메이션 duration(ms)
close: (
result: boolean,
options?: {
fadeOut?: boolean;
duration?: number;
},
) => void;
}

/**
* 비로그인 상태에서 행동 시 띄우는 "로그인 리다이렉트" 오버레이
*
* - 전체 화면을 fixed로 덮고 가운데에 Modal을 띄운다
* - 배경을 클릭했을 때는 클릭이 통과되도록 pointer-events 설정
* - 버튼 클릭 시에만 실제 클릭 이벤트가 동작하도록 분리
*/
export function LoginRedirectOverlay({
close,
}: LoginRedirectOverlayProps) {
const router = useRouter();
const pathname = usePathname();

// 취소: 로그인 페이지로 이동하지 않고 모달만 닫기
const handleCancel = () => {
close(false, { fadeOut: true, duration: 300 });
};

// 확인: 로그인 페이지로 이동 + 모달 닫기
// - 현재 경로(pathname)를 returnTo로 넘겨서 로그인 후 되돌아올 수 있게 함
const handleConfirm = () => {
const returnTo = encodeURIComponent(pathname || '/');
router.push(`/login?returnTo=${returnTo}`);
close(true, { fadeOut: true, duration: 300 });
};

return (
// 전체 화면을 덮는 오버레이 레이어
<div className="fixed inset-0 flex items-center justify-center pointer-events-none">
{/* 실제 인터랙션이 일어나는 영역만 pointer-events 활성화 */}
<Modal
isOpen
onClose={handleCancel}
className="relative pointer-events-auto"
>
{/* 모달 내부 레이아웃: 수직 정렬 + 여백 */}
<div className="flex flex-col gap-4 w-full h-full">
<h2 className="text-lg font-semibold text-center">
로그인이 필요합니다
</h2>

<p className="text-sm text-center text-gray-500">
로그인이 필요한 기능이에요.
<br />
로그인하러 이동할까요?
</p>

{/* 버튼 영역: 가운데 정렬, 버튼 간 간격 */}
<div className="mt-4 flex justify-center gap-2">
<button
type="button"
className="px-3 py-1.5 text-sm rounded-lg border border-gray-300"
onClick={handleCancel}
>
취소
</button>

<button
type="button"
className="px-3 py-1.5 text-sm rounded-lg bg-primary text-white"
onClick={handleConfirm}
>
로그인하러 가기
</button>
</div>
</div>
</Modal>
</div>
);
}
87 changes: 45 additions & 42 deletions apps/web/src/hooks/useAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import {
} from '@/generated/api/endpoints/users/users';
import { ApiError } from '@/lib/api-error';
import type { UserResponse } from '@/generated/api/models';
import { useToast } from './ui/useToast';
import { useLogout } from './useLogout';
import { useOverlay } from './ui/useOverlay';
import React from 'react';
import { LoginRedirectOverlay } from '@/components/LoginRedirectOverlay';

/**
* 현재 로그인한 사용자 정보를 조회하는 Hook
Expand Down Expand Up @@ -83,60 +85,61 @@ export function useAuthRestore() {
}

/**
* 인증 가드 Hook (기존 코드 호환성 유지)
*
* ## 반환값
* - `authed`: 인증 여부 (boolean)
* - `guard(fn)`: 인증된 경우에만 fn 실행, 아니면 토스트 표시
* - `ensureAuthed()`: 인증 여부 체크, false면 토스트 표시
*
* ## 레거시 호환
* 기존 코드에서 사용하던 guard/ensureAuthed 패턴을 유지합니다.
* 내부적으로는 새로운 useAuth()를 사용하여 SSR 최적화를 활용합니다.
* 행동 단위 인증 가드 Hook (HOF 패턴)
*
* - 인증된 상태: 넘겨준 액션(fn)을 그대로 실행
* - 비인증 상태: 로그인 리다이렉트 오버레이를 띄우고, 액션은 실행하지 않음
*/
export function useAuthGuard(
options: { onUnauthed?: () => void } = {},
) {
export function useAuthGuard() {
// 현재 로그인 여부
const { isAuth } = useAuth();
const toast = useToast();

// 비로그인 기본 처리: 토스트 표시 (기존 동작 유지)
// 옵션으로 커스텀 동작 지정 가능 (예: 리다이렉트)
const onUnauthed =
options.onUnauthed ??
(() => toast('로그인이 필요합니다.', 'error'));
// 전역 오버레이 제어 훅
const { open } = useOverlay();

/**
* 인증된 경우에만 함수 실행
* @param fn - 실행할 함수
* 내부 헬퍼: "지금 로그인 되어 있는지" 확인하는 함수
*
* - 로그인 X:
* - LoginRedirectOverlay 오버레이를 띄움
* - false 반환
* - 로그인 O:
* - true 반환
*/
const guard = (fn: () => void | Promise<void>) => {
const ensureAuthed = async () => {
if (!isAuth) {
onUnauthed();
return;
}
return fn();
};

/**
* 인증 여부 확인 (조기 리턴 패턴에 사용)
* @returns 인증 여부
*/
const ensureAuthed = () => {
if (!isAuth) {
onUnauthed();
// 오버레이 스택에 로그인 리다이렉트 모달 추가
await open<boolean>(
// renderer: close 함수를 받아서 오버레이 컴포넌트를 렌더링
({ close }) =>
React.createElement(LoginRedirectOverlay, { close }),
{
blockScroll: true,
closeOnBackdrop: true,
},
);
return false;
}
return true;
};

/**
* 고차함수(HOF) 패턴
*
* - 인자: fn: 실제로 실행하고 싶은 액션(함수)
* - 반환: 로그인 체크가 래핑된 새 함수
**/
const requireAuth =
// Args: 원래 함수가 받을 인자 타입들
<Args extends unknown[]>(
fn: (...args: Args) => void | Promise<void>,
) =>
async (...args: Args) => {
if (!(await ensureAuthed())) return;
return fn(...args);
};

Comment on lines +125 to +140
Copy link
Member

Choose a reason for hiding this comment

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

HOF 패턴이네요 고생하셨어요

return {
/** 인증 여부 */
authed: isAuth,
/** 인증된 경우에만 fn 실행 */
guard,
/** 인증 여부 확인 후 false면 onUnauthed 실행 */
ensureAuthed,
requireAuth,
};
}
13 changes: 12 additions & 1 deletion apps/web/src/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import { refreshToken } from '@/generated/api/endpoints/auth/auth';
import { ApiError } from './api-error';

// 쿠키가 필요한 경로 (프록시 사용)
const COOKIE_REQUIRED_PATHS = ['/auth/', '/users/me'];
const COOKIE_REQUIRED_PATHS = [
'/auth/',
'/users/me',
'/community/freeboard/',
'/community/votesboard/',
];

export const AXIOS_INSTANCE = Axios.create({
baseURL:
Expand All @@ -26,6 +31,12 @@ export const AXIOS_INSTANCE = Axios.create({
AXIOS_INSTANCE.interceptors.request.use((config) => {
const proxyEnabled =
process.env.NEXT_PUBLIC_ENABLE_PROXY !== 'false';
const isBrowser = typeof window !== 'undefined';

// SSR에서는 절대 URL 그대로 사용 (상대 경로로 바꾸면 Invalid URL 발생)
if (!isBrowser) {
return config;
}

// 프록시 비활성화 시 직접 백엔드 호출
if (!proxyEnabled) {
Expand Down