Skip to content
Merged
31 changes: 31 additions & 0 deletions apps/web/src/app/(auth)/components/StartButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
'use client';

import { useRouter } from 'next/navigation';
import { Button } from '@/components/buttons/Button';
import { useAuth } from '@/hooks/useAuth';

interface StartButtonProps {
className?: string;
children: React.ReactNode;
}
export default function StartButton({
className,
children,
}: StartButtonProps) {
const router = useRouter();
const { isAuth, isLoading } = useAuth();

const handleStart = () => {
router.replace(isAuth ? '/main' : '/login');
};
return (
<Button
className={className}
onClick={handleStart}
disabled={isLoading}
size="lg"
>
{children}
</Button>
);
}
20 changes: 6 additions & 14 deletions apps/web/src/app/(auth)/signup/[type]/complete/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { useEffect, useState } from 'react';
import { Button } from '@/components/buttons/Button';
import CompleteImg from './components/CompleteImg';
import { useAuthStore } from '@/stores/authStore';
import { useAuth } from '@/hooks/useAuth';
import { useRouter } from 'next/navigation';
import {
useSaveNickname,
Expand All @@ -12,10 +12,11 @@ import {
import { useSignupFlow } from '@/hooks/useSignupFlow';
import { MotionSlotMachineText } from '@/components/MotionSlotMachineText';
import { SignupCompleteResponse } from '@/generated/api/models';
import { User } from '@/types/user.types';

export default function SignUpCompletePage() {
const login = useAuthStore((state) => state.login);
const router = useRouter();
const { login } = useAuth();
const { userType } = useSignupFlow();
const userTypeLabel =
userType === 'FOUNDER' ? '예비 창업자' : '주민';
Expand All @@ -30,14 +31,8 @@ export default function SignUpCompletePage() {
];

// 회원가입 완료 후 로그인 및 리다이렉트 처리
const handleSuccess = ({
user,
accessToken,
}: SignupCompleteResponse) => {
login({
user,
accessToken,
});
const handleSuccess = (user: User) => {
login(user);
console.log('회원가입 및 로그인 완료:', { nickname });
router.replace('/main');
};
Expand All @@ -48,10 +43,7 @@ export default function SignUpCompletePage() {
mutation: {
onSuccess: (data: SignupCompleteResponse) => {
if (data) {
handleSuccess({
user: data.user,
accessToken: data.accessToken,
});
handleSuccess(data.user);
} else {
console.error('닉네임 또는 토큰 정보가 없습니다.');
}
Expand Down
43 changes: 0 additions & 43 deletions apps/web/src/app/(auth)/signup/components/RedirectIfAuthed.tsx

This file was deleted.

5 changes: 1 addition & 4 deletions apps/web/src/app/(auth)/signup/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import RedirectIfAuthed from './components/RedirectIfAuthed';
/**
* 회원가입 레이아웃
*/
Expand All @@ -14,9 +13,7 @@ export default function SignUpLayout({
}: SignUpLayoutProps) {
return (
<div className="flex flex-col items-center h-full w-full">
<RedirectIfAuthed className="w-full flex-1 h-full">
{children}
</RedirectIfAuthed>
{children}
</div>
);
}
7 changes: 3 additions & 4 deletions apps/web/src/app/main/profile/page.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
'use client';

import { useRouter } from 'next/navigation';
import { Button } from '@/components/buttons/Button';
import { useAuthStore } from '@/stores/authStore';
import { useAuth } from '@/hooks/useAuth';
export default function ProfilePage() {
const router = useRouter();
const logout = useAuthStore((state) => state.logout);
const { logout } = useAuth();
const handleLogout = () => {
// 로그아웃 로직을 여기에 추가하세요.
console.log('로그아웃 버튼 클릭');
logout();
router.push('/');
Comment on lines 10 to 11
Copy link

Copilot AI Nov 28, 2025

Choose a reason for hiding this comment

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

logout() 호출 직후 router.push('/')를 실행하면 로그아웃 API 요청이 완료되기 전에 페이지 이동이 발생할 수 있습니다. 이는 비동기 작업이므로 완료를 기다려야 합니다.

문제점:

  • logout()mutate 함수로 비동기 작업을 시작만 함
  • 즉시 router.push('/')가 실행되어 페이지 이동
  • 로그아웃 API 요청이 취소되거나 완료되지 않을 수 있음

제안:

const handleLogout = () => {
  logout(undefined, {
    onSettled: () => {
      router.push('/');
    }
  });
};

또는 useLogout 훅의 onSuccess 콜백에서 리다이렉트를 처리하는 방법도 있습니다.

Suggested change
logout();
router.push('/');
logout(undefined, {
onSettled: () => {
router.push('/');
},
});

Copilot uses AI. Check for mistakes.
};
Expand Down
27 changes: 4 additions & 23 deletions apps/web/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,41 +1,22 @@
'use client';

import { useRouter } from 'next/navigation';
import { useAuth } from '@/hooks/useAuth';
import LogoImage from '@/assets/images/LogoImage';
import Button from '@/components/buttons/Button';
import StartButton from '@/app/(auth)/components/StartButton';

export default function HomePage() {
const router = useRouter();
const { isAuth, isLoading } = useAuth();

const handleStart = () => {
router.replace(isAuth ? '/main' : '/login');
};

return (
<div className="h-full w-full flex flex-col items-center justify-between pt-40">
<h1 className="text-2xl font-semibold animate-fadeIn text-neutral-900 dark:text-neutral-100">
소소에 오신 것을 환영합니다
</h1>
<div className="w-full h-full p-layout flex animate-fadeIn flex-col items-center justify-between space-y-4">
<div className="my-20 flex-1 flex items-center justify-center">
<div className="flex-1 flex items-center justify-center">
<LogoImage />
</div>

<div className="w-full space-y-4 flex flex-col items-center">
<div className="w-full gap-4 flex flex-col items-center">
<p className="text-neutral-600 animate-pulse dark:text-neutral-400 text-sm">
일상의 소소한 순간들을 기록해보세요
</p>

<Button
onClick={handleStart}
variant="filled"
className="w-full"
isLoading={isLoading}
>
소소 시작하기
</Button>
<StartButton className="w-full">소소 시작하기</StartButton>
</div>
</div>
</div>
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/buttons/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export const Button = React.forwardRef<
const isDisabled = disabled;

const classes = cn(
'relative inline-flex items-center justify-center rounded-lg font-medium select-none overflow-hidden',
'relative flex items-center justify-center rounded-lg font-medium select-none overflow-hidden',
Copy link

Copilot AI Nov 28, 2025

Choose a reason for hiding this comment

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

inline-flex에서 flex로 변경하면 버튼이 블록 레벨 요소처럼 동작하여 전체 너비를 차지하게 됩니다. 이는 의도하지 않은 레이아웃 변경을 초래할 수 있습니다.

영향:

  • 기존: 버튼이 콘텐츠 크기만큼만 차지 (inline-flex)
  • 변경 후: 버튼이 부모 요소의 전체 너비를 차지할 수 있음 (flex)

className="w-full"을 명시적으로 사용하는 경우가 아니라면 inline-flex를 유지하는 것이 안전합니다.

제안:

'relative inline-flex items-center justify-center rounded-lg font-medium select-none overflow-hidden',
Suggested change
'relative flex items-center justify-center rounded-lg font-medium select-none overflow-hidden',
'relative inline-flex items-center justify-center rounded-lg font-medium select-none overflow-hidden',

Copilot uses AI. Check for mistakes.
'transition-transform duration-150 ease-out',
// 기본 variant 스타일
variantMap[variant],
Expand All @@ -111,7 +111,7 @@ export const Button = React.forwardRef<
);

return (
<Pressable>
<Pressable className={cn('flex', className)}>
<button
ref={ref}
type="button"
Expand Down
45 changes: 17 additions & 28 deletions apps/web/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ const PUBLIC_ROUTES = ['/login', '/signup'];

const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL;

/**
* Set-Cookie 헤더를 현재 환경에 맞게 수정
* - Domain 속성 제거 (현재 도메인으로 자동 설정)
*/
function modifySetCookie(cookie: string): string {
return cookie.replace(/Domain=[^;]+;?\s*/gi, '');
Copy link

Copilot AI Nov 28, 2025

Choose a reason for hiding this comment

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

정규식이 Set-Cookie 헤더의 첫 번째 속성이 Domain인 경우를 올바르게 처리하지 못합니다.

문제 시나리오:

Domain=example.com; Path=/; Secure

이 경우 Domain=[^;]+;?\s*Domain=example.com; 를 제거하여 Path=/; Secure를 반환합니다. 하지만 첫 번째 속성이므로 세미콜론이 남아있으면 문제가 됩니다.

일반적으로 Set-Cookie 헤더는 name=value로 시작하고 속성들이 세미콜론으로 구분되므로, Domain은 첫 번째가 아닌 중간 속성일 가능성이 높습니다.

더 안전한 패턴:

function modifySetCookie(cookie: string): string {
  return cookie.replace(/;\s*Domain=[^;]+/gi, '');
}

이렇게 하면 세미콜론을 포함하여 제거하므로 다른 속성과의 구분이 명확해집니다.

Suggested change
return cookie.replace(/Domain=[^;]+;?\s*/gi, '');
// 중간/끝에 위치한 Domain 속성 제거
let result = cookie.replace(/;\s*Domain=[^;]+/gi, '');
// 맨 앞에 위치한 Domain 속성 제거
result = result.replace(/^Domain=[^;]+;?\s*/i, '');
return result;

Copilot uses AI. Check for mistakes.
}

/**
* 로그인 페이지로 리다이렉트 (returnUrl 포함)
*/
Expand Down Expand Up @@ -42,13 +50,6 @@ export async function middleware(request: NextRequest) {
return NextResponse.next();
}

// 모든 쿠키 확인
const allCookies = request.cookies.getAll();
console.log(
`[Middleware] 📦 전체 쿠키 개수: ${allCookies.length}`,
allCookies.map((c) => c.name),
);

// 쿠키에서 액세스 토큰과 리프레시 토큰 확인
const accessToken = request.cookies.get('accessToken')?.value;
const refreshToken = request.cookies.get('refreshToken')?.value;
Expand All @@ -62,19 +63,23 @@ export async function middleware(request: NextRequest) {
// 액세스 토큰이 없고 리프레시 토큰만 있는 경우 토큰 갱신 시도
if (!accessToken && refreshToken) {
try {
// 프록시를 통해 토큰 갱신 (쿠키 자동 포함)
// 백엔드 직접 호출하여 토큰 갱신
const refreshResponse = await fetch(
'http://localhost:3000/api/auth/refresh',
`${API_BASE_URL}/auth/refresh`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Cookie: `refreshToken=${refreshToken}`,
Copy link

Copilot AI Nov 28, 2025

Choose a reason for hiding this comment

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

쿠키를 수동으로 구성할 때 refreshToken 값에 대한 URL 인코딩이 누락되어 있습니다. 토큰 값에 특수 문자가 포함된 경우 파싱 오류가 발생할 수 있습니다.

제안:

Cookie: `refreshToken=${encodeURIComponent(refreshToken)}`,
Suggested change
Cookie: `refreshToken=${refreshToken}`,
Cookie: `refreshToken=${encodeURIComponent(refreshToken)}`,

Copilot uses AI. Check for mistakes.
},
},
);

if (refreshResponse.ok) {
console.log('[Middleware] 토큰 갱신 성공');
console.log(
'[Middleware] 토큰 갱신 성공 - 상태:',
refreshResponse.status,
);

// Set-Cookie 헤더를 클라이언트로 전달
const setCookieHeaders = refreshResponse.headers.getSetCookie
Expand All @@ -93,26 +98,10 @@ export async function middleware(request: NextRequest) {
});
}

// 토큰 갱신 성공 후 루트 경로면 리다이렉트
if (pathname === '/') {
const targetUrl = hasAuth ? '/main' : '/login';
console.log(
`[Middleware] 토큰 갱신 후 루트 접근 → ${targetUrl}로 리다이렉트`,
);
const redirectResponse = NextResponse.redirect(
new URL(targetUrl, request.url),
);
// Set-Cookie 헤더 유지
setCookieHeaders.forEach((cookie) => {
redirectResponse.headers.append('Set-Cookie', cookie);
});
return redirectResponse;
}

// 다른 경로는 계속 진행
const newResponse = NextResponse.next();
setCookieHeaders.forEach((cookie) => {
newResponse.headers.append('Set-Cookie', cookie);
const modifiedCookie = modifySetCookie(cookie);
newResponse.headers.append('Set-Cookie', modifiedCookie);
});
return newResponse;
} else {
Expand Down
11 changes: 9 additions & 2 deletions apps/web/src/providers/AuthHydrationProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,21 @@ export async function AuthHydrationProvider({
await queryClient.prefetchQuery({
queryKey: getGetCurrentUserQueryKey(),
queryFn: () => getServerCurrentUser(),
staleTime: 13 * 60 * 1000, // 13분
staleTime: (query) => {
if (
query.state.status === 'error' ||
query.state.data === null
) {
return 0;
}
return 13 * 60 * 1000; // 13분
},
Comment on lines +38 to +46
Copy link

Copilot AI Nov 28, 2025

Choose a reason for hiding this comment

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

새로 추가된 동적 staleTime 로직에 대한 주석이 없습니다. 이는 SSR Prefetch 401 엣지케이스를 해결하기 위한 중요한 변경사항이므로 코드에 설명을 추가하는 것이 좋습니다.

제안:

staleTime: (query) => {
  // SSR에서 401 에러가 발생한 경우 즉시 재검증하도록 설정
  // 클라이언트에서 즉시 다시 유저 정보를 조회할 수 있음
  if (
    query.state.status === 'error' ||
    query.state.data === null
  ) {
    return 0;
  }
  return 13 * 60 * 1000; // 13분
},

Copilot uses AI. Check for mistakes.
});
} else {
// 토큰이 없으면 비로그인 상태로 초기화
queryClient.setQueryData(getGetCurrentUserQueryKey(), null);
}
}
// HTTP 모드에서는 아무것도 하지 않음 (CSR이 알아서 처리)

return (
<HydrationBoundary state={dehydrate(queryClient)}>
Expand Down
59 changes: 0 additions & 59 deletions apps/web/src/stores/authStore.ts

This file was deleted.