Skip to content

Conversation

@DreamPaste
Copy link
Member

📋 개요 (Summary)

Body 기반 토큰 인증에서 HttpOnly Cookie 기반 인증으로 완전히 마이그레이션했습니다.

핵심 개선사항

  • XSS 공격 방어: HttpOnly Cookie로 토큰을 JavaScript에서 접근 불가능하게 보호
  • 자동 토큰 관리: 브라우저가 자동으로 쿠키를 전송하여 토큰 관리 간소화
  • SSR 인증 지원: 서버 사이드에서 인증 상태 확인 및 페이지 보호
  • HTTPS/HTTP 개발 모드: 선택적 프록시로 빠른 개발과 완전한 기능 테스트 모두 지원
  • 큐 기반 토큰 갱신: 동시 401 에러 발생 시 한 번만 재발급 요청
  • 전역 에러 핸들링: 네트워크, 서버, 인증 에러를 일관되게 처리

🔧 주요 변경사항

1. 큐 기반 토큰 갱신 (Queue-based Token Refresh)

파일: apps/web/src/lib/api-client.ts

핵심 근거:

  • 여러 API 요청이 동시에 실패하면 모두 401을 받음
  • 각 요청이 개별적으로 /auth/refresh를 호출하면 중복 요청 발생
  • 큐 시스템으로 첫 번째 요청만 refresh를 처리하고, 나머지는 대기

구현:

let isRefreshing = false;
let failedQueue: Array<{
  resolve: (value: unknown) => void;
  reject: (reason?: unknown) => void;
}> = [];

AXIOS_INSTANCE.interceptors.response.use(
  (response) => response,
  async (error: AxiosError) => {
    const originalRequest = error.config as AxiosRequestConfig & { _retry?: boolean };

    // 401 에러 && 재시도 안 한 요청 && refresh 엔드포인트가 아닐 때
    if (error.response?.status === 401 && !originalRequest._retry && !originalRequest.url?.includes('/auth/refresh')) {
      if (isRefreshing) {
        // 이미 갱신 중이면 큐에 추가
        return new Promise((resolve, reject) => {
          failedQueue.push({ resolve, reject });
        }).then(() => AXIOS_INSTANCE(originalRequest));
      }

      originalRequest._retry = true;
      isRefreshing = true;

      try {
        await postAuthRefresh();
        processQueue(null);
        return AXIOS_INSTANCE(originalRequest);
      } catch (refreshError) {
        processQueue(refreshError);
        // 로그아웃 처리
      } finally {
        isRefreshing = false;
      }
    }

    return Promise.reject(error);
  }
);

2. 전역 에러 핸들링 (Global Error Handling)

파일: apps/web/src/providers/queryProvider.tsx

핵심 근거:

  • 모든 컴포넌트에서 개별적으로 에러 처리하면 코드 중복 및 일관성 문제
  • React Query의 Cache 레벨에서 에러를 중앙 집중식으로 처리

구현:

queryCache: new QueryCache({
  onError: (error, query) => {
    const apiError = ApiError.wrap(error);

    // 초기 로드 실패는 각 컴포넌트에서 처리
    if (query.state.data !== undefined) {
      // 401 에러는 api-client에서 처리
      if (apiError.isAuthError()) return;

      // 네트워크 에러
      if (apiError.isNetworkError()) {
        toast('네트워크 연결을 확인해주세요.', 'error');
        return;
      }

      // 서버 에러 (5xx)
      if (apiError.isServerError()) {
        toast('서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', 'error');
        return;
      }
    }
  },
}),

3. 미들웨어 인증 (Middleware Authentication)

파일: apps/web/src/middleware.ts

핵심 근거:

  • 클라이언트에서 인증 체크하면 페이지가 먼저 렌더링된 후 리다이렉트 (깜빡임 발생)
  • 미들웨어에서 서버 사이드에 요청이 도달하기 전에 인증 체크
  • 보호된 페이지를 빠르고 안전하게 방어

흐름:

  1. 사용자가 /main 접근
  2. 미들웨어가 accessToken 쿠키 확인
  3. 없으면 /login으로 리다이렉트
  4. 있으면 /api/users/me 호출하여 토큰 유효성 검증
  5. 401이면 /api/auth/refresh 호출하여 토큰 갱신 시도
  6. 갱신 성공 시 새 쿠키 설정 후 페이지 진입 허용
  7. 갱신 실패 시 쿠키 삭제 후 /login으로 리다이렉트
export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  // 프록시 비활성화 시 미들웨어 인증 스킵
  const proxyEnabled = process.env.NEXT_PUBLIC_ENABLE_PROXY !== 'false';
  if (!proxyEnabled) {
    return NextResponse.next();
  }

  // /main, /profile 등 보호 필요한 경로
  if (PROTECTED_PATHS.some((path) => pathname.startsWith(path))) {
    const accessToken = request.cookies.get('accessToken')?.value;

    if (!accessToken) {
      return NextResponse.redirect(new URL('/login', request.url));
    }

    // 토큰 유효성 검증
    const verifyResponse = await fetch('http://localhost:3000/api/users/me', {
      headers: { Cookie: `accessToken=${accessToken}` },
    });

    if (verifyResponse.status === 401) {
      // 토큰 만료 시 갱신 시도
      const refreshResponse = await fetch('http://localhost:3000/api/auth/refresh', {
        method: 'POST',
        headers: { Cookie: request.cookies.toString() },
      });

      if (refreshResponse.ok) {
        // 갱신 성공 시 새 쿠키 설정
        const response = NextResponse.next();
        const setCookieHeader = refreshResponse.headers.get('set-cookie');
        if (setCookieHeader) {
          response.headers.set('set-cookie', setCookieHeader);
        }
        return response;
      } else {
        // 갱신 실패 시 로그인 페이지로
        const response = NextResponse.redirect(new URL('/login', request.url));
        response.cookies.delete('accessToken');
        response.cookies.delete('refreshToken');
        return response;
      }
    }

    if (!verifyResponse.ok) {
      return NextResponse.redirect(new URL('/login', request.url));
    }
  }

  return NextResponse.next();
}

4. useAuth() 훅 마이그레이션

파일: apps/web/src/hooks/useAuth.ts

변경 내용:

  • Body에서 토큰을 관리하던 방식 제거
  • React Query로 서버에서 유저 정보를 가져오는 방식으로 변경
  • 쿠키는 브라우저가 자동으로 관리
export function useAuth() {
  const { data: user, isLoading, error } = useGetCurrentUser({
    query: {
      retry: (failureCount, error: AxiosError) => {
        // 401/403은 재시도하지 않음 (인증 실패 확정)
        if (error.response?.status === 401 || error.response?.status === 403) {
          return false;
        }
        return failureCount < 2;
      },
      retryDelay: 500,
    },
  });

  return {
    isAuth: !!user,
    isLoading,
    user,
  };
}

5. SSR Prefetch (Server-Side Data Fetching)

파일: apps/web/src/providers/AuthHydrationProvider.tsx

핵심 근거:

  • 클라이언트에서 인증 정보를 가져오면 초기 렌더링 시 로딩 상태 표시
  • 서버에서 미리 인증 정보를 가져와서 HydrationBoundary로 전달하면 즉시 렌더링 가능
  • SSR로 초기 페이지 로드 속도 향상 및 UX 개선

흐름:

  1. 서버 컴포넌트에서 cookies()accessToken 확인
  2. 토큰이 있으면 /api/users/me 호출하여 유저 정보 prefetch
  3. QueryClient에 데이터를 미리 채움
  4. dehydrate()로 직렬화하여 클라이언트에 전달
  5. 클라이언트에서 HydrationBoundary가 캐시 복원
  6. useAuth()가 즉시 캐시된 데이터 반환 (로딩 없음)
export async function AuthHydrationProvider({ children }) {
  const proxyEnabled = process.env.NEXT_PUBLIC_ENABLE_PROXY !== 'false';
  const queryClient = new QueryClient();

  // HTTPS 모드에서만 SSR Prefetch 실행
  if (proxyEnabled) {
    const cookieStore = cookies();
    const accessToken = cookieStore.get('accessToken');

    if (accessToken) {
      await queryClient.prefetchQuery({
        queryKey: getGetCurrentUserQueryKey(),
        queryFn: () => getServerCurrentUser(),
        staleTime: 13 * 60 * 1000, // 13분
      });
    } else {
      // 토큰이 없으면 비로그인 상태로 초기화
      queryClient.setQueryData(getGetCurrentUserQueryKey(), null);
    }
  }
  // HTTP 모드에서는 아무것도 하지 않음 (CSR이 알아서 처리)

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      {children}
    </HydrationBoundary>
  );
}

6. HTTPS 로컬 개발 서버

파일: apps/web/server.mjs, apps/web/package.json

핵심 근거:

  • HttpOnly Cookie는 브라우저 보안 정책으로 Secure 플래그가 필요함
  • Secure 쿠키는 HTTPS 환경에서만 전송됨
  • 프로덕션과 동일한 환경에서 테스트하려면 로컬에서도 HTTPS 필요

구현:

// server.mjs
import https from 'https';
import fs from 'fs';
import next from 'next';

const dev = process.env.NODE_ENV !== 'production';
const app = next({ dev });
const handle = app.getRequestHandler();

const httpsOptions = {
  key: fs.readFileSync('./certs/localhost-key.pem'),
  cert: fs.readFileSync('./certs/localhost.pem'),
};

app.prepare().then(() => {
  https.createServer(httpsOptions, (req, res) => {
    handle(req, res);
  }).listen(3000, () => {
    console.log('🚀 HTTPS Server ready on https://localhost:3000');
  });
});

인증서 생성 (mkcert):

brew install mkcert
mkcert -install
mkdir -p apps/web/certs
cd apps/web/certs
mkcert localhost

7. Next.js Rewrites 프록시

파일: apps/web/next.config.js

핵심 근거:

  • 브라우저는 다른 도메인의 쿠키를 읽을 수 없음 (Same-Origin Policy)
  • 프론트엔드: localhost:3000
  • 백엔드: soso.dreampaste.com
  • Next.js Rewrites로 /api/* 경로를 백엔드로 프록시하여 도메인 통일
  • 브라우저 입장에서는 모두 localhost:3000에서 온 것처럼 보임

동작 방식:

[브라우저] → /api/auth/login → [Next.js 프록시] → https://soso.dreampaste.com/auth/login
[브라우저] ← Set-Cookie: accessToken ← [Next.js 프록시] ← Response

구현:

// next.config.js
async rewrites() {
  const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'https://soso.dreampaste.com';

  return [
    {
      source: '/api/auth/:path*',
      destination: `${apiBaseUrl}/auth/:path*`,
    },
    {
      source: '/api/users/me',
      destination: `${apiBaseUrl}/users/me`,
    },
  ];
}
// api-client.ts - 쿠키 필요 경로만 프록시 사용
const COOKIE_REQUIRED_PATHS = ['/auth/login', '/auth/refresh', '/users/me'];

AXIOS_INSTANCE.interceptors.request.use((config) => {
  const url = config.url || '';
  const needsCookie = COOKIE_REQUIRED_PATHS.some((path) => url.includes(path));

  if (needsCookie) {
    config.baseURL = '';
    config.url = `/api${url}`;
  }

  return config;
});

8. 선택적 프록시 (Conditional Proxy)

파일:

  • apps/web/next.config.js
  • apps/web/src/lib/api-client.ts
  • apps/web/src/lib/server-api-client.ts
  • apps/web/src/middleware.ts
  • apps/web/src/providers/AuthHydrationProvider.tsx

핵심 근거:

  • HTTPS는 완전한 기능을 제공하지만 느림 (인증서 핸드셰이크)
  • HTTP는 빠르지만 Secure Cookie가 작동하지 않음
  • 환경 변수로 개발 상황에 맞게 선택 가능

구현:

# HTTP 모드 (빠른 개발)
npm run dev
# → NEXT_PUBLIC_ENABLE_PROXY=false
# → 프록시 비활성화, 미들웨어 스킵, SSR Prefetch 스킵

# HTTPS 모드 (전체 기능)
npm run dev:https
# → NEXT_PUBLIC_ENABLE_PROXY=true
# → 프록시 활성화, 미들웨어 인증, SSR Prefetch

각 파일에서 체크:

const proxyEnabled = process.env.NEXT_PUBLIC_ENABLE_PROXY !== 'false';

if (!proxyEnabled) {
  // HTTP 모드: 프록시/미들웨어/SSR 스킵
  return;
}

// HTTPS 모드: 전체 기능 실행

📖 인증 시나리오 (Authentication Scenarios)

시나리오 1: 로그인 (Login)

HTTPS 모드 (프록시 활성화)

  1. 사용자가 /login 페이지에서 카카오 로그인 버튼 클릭
  2. useKakaoAuth() 훅이 카카오 OAuth 인가 코드 받음
  3. POST /api/auth/login 호출 (프록시 사용)
    localhost:3000/api/auth/login → soso.dreampaste.com/auth/login
    
  4. 백엔드가 Set-Cookie: accessToken; refreshToken; HttpOnly; Secure; SameSite=Lax 응답
  5. 브라우저가 쿠키 자동 저장 (JavaScript 접근 불가)
  6. router.replace('/main') 실행
  7. /main 접근 시 미들웨어가 쿠키 확인 → 통과
  8. AuthHydrationProvider가 서버에서 유저 정보 prefetch → 클라이언트로 전달
  9. useAuth()가 캐시된 데이터 즉시 반환 (isAuth: true)

HTTP 모드 (프록시 비활성화)

  1. 사용자가 /login 페이지에서 카카오 로그인 버튼 클릭
  2. useKakaoAuth() 훅이 카카오 OAuth 인가 코드 받음
  3. POST https://soso.dreampaste.com/auth/login 호출 (직접 호출)
  4. 백엔드가 Set-Cookie: accessToken; refreshToken; HttpOnly; SameSite=Lax 응답
    • ⚠️ Secure 플래그가 없어서 HTTP에서도 작동
  5. 브라우저가 쿠키 자동 저장
  6. router.replace('/main') 실행
  7. 미들웨어 스킵 (프록시 비활성화)
  8. AuthHydrationProvider 스킵 (서버에서 prefetch 안 함)
  9. 클라이언트에서 useAuth()/users/me 호출 → 쿠키 전송
  10. 유저 정보 받아서 isAuth: true

시나리오 2: 페이지 새로고침 (Page Refresh)

HTTPS 모드

  1. 사용자가 /main에서 새로고침 (F5 또는 주소창 입력)
  2. 미들웨어 실행:
    • accessToken 쿠키 확인
    • /api/users/me 호출하여 토큰 유효성 검증
    • 유효하면 페이지 진입 허용
  3. AuthHydrationProvider 실행 (서버 컴포넌트):
    • accessToken 쿠키 확인
    • /api/users/me 호출하여 유저 정보 prefetch
    • QueryClient에 데이터 미리 채움
  4. 클라이언트 Hydration:
    • HydrationBoundary가 캐시 복원
    • useAuth()가 캐시된 데이터 즉시 반환
    • 로딩 없이 인증 상태 유지

HTTP 모드

  1. 사용자가 /main에서 새로고침
  2. 미들웨어 스킵 (프록시 비활성화)
  3. AuthHydrationProvider 스킵 (캐시에 아무것도 설정하지 않음)
  4. 클라이언트 렌더링:
    • useAuth()가 캐시 확인 → 빈 캐시
    • GET https://soso.dreampaste.com/users/me 호출
    • 브라우저가 자동으로 쿠키 전송
    • 유저 정보 받아서 isAuth: true

시나리오 3: 토큰 만료 - 미들웨어 (Token Expiry in Middleware)

HTTPS 모드

  1. 사용자가 /main 접근 (액세스 토큰 만료됨)
  2. 미들웨어 실행:
    • accessToken 쿠키 확인 → 존재함
    • /api/users/me 호출 → 401 Unauthorized
  3. 토큰 갱신 시도:
    • POST /api/auth/refresh 호출 (refreshToken 쿠키 자동 전송)
    • 백엔드가 새로운 accessToken 발급
    • Set-Cookie: accessToken 응답
  4. 미들웨어가 새 쿠키 설정:
    const response = NextResponse.next();
    const setCookieHeader = refreshResponse.headers.get('set-cookie');
    response.headers.set('set-cookie', setCookieHeader);
    return response;
  5. 페이지 진입 허용
  6. AuthHydrationProvider가 새 토큰으로 유저 정보 prefetch
  7. 클라이언트에서 인증 상태 유지

시나리오 4: 토큰 만료 - SSR Prefetch (Token Expiry in SSR)

HTTPS 모드

  1. 미들웨어는 통과했지만 AuthHydrationProvider 실행 시점에 토큰 만료
  2. getServerCurrentUser() 호출 → 401 Unauthorized
  3. 예외 처리:
    if (response.status === 401 || response.status === 403) {
      return null;
    }
  4. QueryClient에 null 설정
  5. 클라이언트 Hydration 시 isAuth: false
  6. useAuth()가 자동으로 /users/me 재호출 (staleTime 만료)
  7. api-client의 401 인터셉터가 토큰 갱신 처리
  8. 갱신 후 재요청 성공 → isAuth: true

시나리오 5: 토큰 만료 - 클라이언트 요청 (Token Expiry in Client)

공통 (HTTPS/HTTP 모두)

  1. 사용자가 /main에서 API 호출 (예: 게시글 목록)
  2. 여러 API가 동시에 호출됨:
    useGetPosts();
    useGetComments();
    useGetProfile();
  3. 모든 요청이 401 반환 (액세스 토큰 만료)
  4. 큐 시스템 작동:
    • 첫 번째 401: isRefreshing = truePOST /auth/refresh 호출
    • 나머지 401: failedQueue에 추가 (대기)
  5. 토큰 갱신 성공:
    • processQueue(null) 실행 → 대기 중인 요청들 재개
    • 각 요청이 새 토큰으로 재시도 → 성공
  6. 사용자는 인증 상태 유지

🚨 HTTP 모드 문제와 해결 (HTTP Mode Issues and Solutions)

문제 1: 쿠키 도메인 격리 (Cookie Domain Isolation)

문제:

  • 프론트엔드: localhost:3000
  • 백엔드: soso.dreampaste.com
  • 브라우저는 soso.dreampaste.com에서 설정된 쿠키를 localhost:3000에서 읽을 수 없음

해결책: Next.js Rewrites 프록시

  • /api/* 경로를 백엔드로 프록시
  • 브라우저 입장에서는 모두 localhost:3000에서 온 것처럼 보임

문제 2: Secure Cookie 제약 (Secure Cookie Restrictions)

문제:

  • Secure 플래그가 있는 쿠키는 HTTPS에서만 전송됨
  • 로컬 개발 환경은 기본적으로 HTTP
  • HTTPS 설정은 인증서 관리 필요 및 성능 저하

해결책: 선택적 프록시

  • HTTP 모드: 백엔드가 Secure 플래그 없이 쿠키 전송 (개발용)
  • HTTPS 모드: mkcert로 로컬 HTTPS 서버 구축 (프로덕션 동일 환경)

문제 3: 캐시 초기화 문제 (Cache Initialization Problem)

문제:

  • HTTP 모드에서 로그인 후 주소창으로 페이지 이동 시 인증 상태 초기화
  • AuthHydrationProvider가 서버에서 쿠키를 읽을 수 없어서 null 설정
  • React Query가 캐시에 null이 있으면 API 호출하지 않음 (staleTime 때문)

해결책: HTTP 모드에서는 캐시 설정 스킵

if (proxyEnabled) {
  // HTTPS 모드: 서버에서 prefetch
  if (accessToken) {
    await queryClient.prefetchQuery(...);
  } else {
    queryClient.setQueryData(['getCurrentUser'], null);
  }
}
// HTTP 모드: 아무것도 하지 않음 (클라이언트가 처음부터 fetch)

🔄 환경 비교 (Environment Comparison)

기능 HTTP 모드 (npm run dev) HTTPS 모드 (npm run dev:https)
프록시 ❌ 비활성화 (직접 호출) ✅ 활성화 (/api/* 프록시)
미들웨어 인증 ❌ 스킵 ✅ 서버 사이드 인증 체크
SSR Prefetch ❌ 스킵 ✅ 서버에서 유저 정보 미리 가져옴
쿠키 전송 ⚠️ Secure 플래그 없이 전송 Secure 플래그로 안전하게 전송
인증 처리 🔵 CSR만 (클라이언트에서 처리) 🟢 SSR + CSR (서버 + 클라이언트)
속도 🚀 빠름 🐢 느림 (HTTPS 핸드셰이크)
프로덕션 일치 ❌ 다름 ✅ 동일
사용 시점 빠른 개발 및 디버깅 완전한 기능 테스트 및 프로덕션 준비

📦 기타 개선사항 (Additional Improvements)

1. Next.js Image 경고 수정

파일: apps/web/src/assets/images/LogoImage.tsx

문제:

Image with src has either width or height modified, but not the other.

해결:

<Image
  src={'/icons/Logo.svg'}
  alt="SOSO 로고"
  width={200}
  height={150}
  priority
  style={{ width: 'auto', height: 'auto' }}  // 추가
  className={twMerge('max-w-[200px]', className)}
/>

2. ESLint 설정 개선

파일: apps/web/.eslintrc.cjs

server.mjs 파일을 ignorePatterns에 추가하여 파싱 에러 방지

3. 환경 변수 문서화

파일: apps/web/.env.example

모든 환경 변수에 대한 설명 추가 및 사용 예시 제공


추가 노트

📝 SSL 인증서 설치 가이드

로컬 HTTPS 서버를 실행하려면 신뢰할 수 있는 SSL 인증서가 필요합니다. 브라우저가 신뢰하지 않는 자체 서명 인증서를 사용하면 보안 경고가 발생하고 쿠키가 제대로 작동하지 않을 수 있습니다.

mkcert란?

  • 로컬 개발용 신뢰할 수 있는 SSL 인증서를 쉽게 생성해주는 도구
  • 시스템의 루트 인증 기관(Root CA)에 자동으로 등록되어 브라우저가 신뢰함
  • 복잡한 OpenSSL 명령어 없이 간단하게 사용 가능

설치 및 설정 단계:

macOS

# 1. mkcert 설치
brew install mkcert

# 2. 로컬 CA(Certificate Authority) 설치
mkcert -install
# 출력: The local CA is now installed in the system trust store! ⚡️

# 3. 프로젝트에 인증서 디렉토리 생성
mkdir -p apps/web/certs
cd apps/web/certs

# 4. localhost용 인증서 생성
mkcert localhost
# 생성 파일:
#   - localhost.pem (인증서)
#   - localhost-key.pem (개인키)

Windows

방법 1: Chocolatey 사용 (권장)

Chocolatey가 설치되어 있지 않다면 먼저 설치:

# PowerShell을 관리자 권한으로 실행 후
Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))

mkcert 설치:

# PowerShell (관리자 권한)
choco install mkcert

# CA 설치
mkcert -install

# 프로젝트 디렉토리로 이동
cd apps\web
mkdir certs
cd certs

# 인증서 생성
mkcert localhost

방법 2: Scoop 사용

# PowerShell
scoop bucket add extras
scoop install mkcert

# CA 설치
mkcert -install

# 인증서 생성
cd apps\web\certs
mkcert localhost

중요 사항:

  • ⚠️ certs/ 디렉토리는 .gitignore에 추가해야 합니다 (개인키 유출 방지)
  • ⚠️ localhost-key.pem은 절대 공개 저장소에 커밋하면 안 됩니다
  • ✅ 팀원 각자가 로컬에서 mkcert를 설치하고 인증서를 생성해야 합니다

문제 해결:

  1. "mkcert: command not found" 에러

    # Windows
    choco install mkcert
    
    # Linux
    sudo apt install mkcert  # Ubuntu/Debian
    # 또는
    sudo pacman -S mkcert    # Arch Linux
  2. 브라우저가 여전히 "안전하지 않음" 경고 표시

    • mkcert -install을 다시 실행
    • 브라우저를 완전히 종료 후 재시작
    • 인증서 파일 경로가 server.mjs와 일치하는지 확인
  3. "ENOENT: no such file or directory" 에러

    # 인증서 파일 위치 확인
    ls -la apps/web/certs/
    
    # 파일명이 다른 경우 server.mjs에서 경로 수정

대안: 자체 서명 인증서 (권장하지 않음)

mkcert를 사용할 수 없는 환경이라면 OpenSSL로 자체 서명 인증서를 생성할 수 있지만, 브라우저 경고를 수동으로 무시해야 합니다:

openssl req -x509 -out localhost.pem -keyout localhost-key.pem \
  -newkey rsa:2048 -nodes -sha256 \
  -subj '/CN=localhost' -extensions EXT -config <( \
   printf "[dn]\nCN=localhost\n[req]\ndistinguished_name = dn\n[EXT]\nsubjectAltName=DNS:localhost\nkeyUsage=digitalSignature\nextendedKeyUsage=serverAuth")

이 방법은 브라우저가 인증서를 신뢰하지 않아 매번 경고를 무시해야 하므로 mkcert 사용을 강력히 권장합니다.

@linear
Copy link

linear bot commented Nov 26, 2025

@DreamPaste DreamPaste added Feat 💡 새로운 기능을 구현하고 추가합니다! Refactor 🫧 기존 내용을 개선하거나 최적화합니다! 휘건 labels Nov 26, 2025
@github-actions
Copy link

@github-actions
Copy link

github-actions bot commented Nov 26, 2025

📦 번들 분석 결과

📊 번들 크기 요약

항목
📦 전체 번들 크기 3.7M
📄 JavaScript 크기 1.5M
🗂️ JavaScript 파일 수 58개

🔍 주요 청크 파일 (크기순)

fe98bb7c-75056deddb8826d9.js - 169K
framework-69e0f7d37422957b.js - 137K
main-ef574bfa9cb78477.js - 130K
7147-4b792b1c613a0a9e.js - 122K
1762-0e7232d83dcb3887.js - 121K
polyfills-42372ed130431b0a.js - 110K
3920-f5a845f8074e2bd5.js - 85K
6086-65069d4634f32053.js - 76K
page-3026af1cfd56a766.js - 31K
2906-230d0473dd8a9533.js - 28K

🤖 자동 생성된 번들 분석 리포트

@github-actions
Copy link

github-actions bot commented Nov 26, 2025

⚡ Lighthouse 성능 분석 결과

📊 전체 평균 점수

지표 점수
🚀 Performance 73점
♿ Accessibility 86점
✅ Best Practices 100점
🔍 SEO 100점

📈 측정 현황

  • 측정 성공: 15/16 페이지
  • 상태: success

📄 페이지별 상세 분석

🏠 커뮤니티 페이지: /main/community

지표 점수
🚀 Performance 67점
♿ Accessibility 78점
✅ Best Practices 100점
🔍 SEO 100점

📊 상세 분석 보기

👥 창업자 페이지: /main/founder

지표 점수
🚀 Performance 75점
♿ Accessibility 87점
✅ Best Practices 100점
🔍 SEO 100점

📊 상세 분석 보기

🏡 홈 페이지: /main/home

지표 점수
🚀 Performance 74점
♿ Accessibility 91점
✅ Best Practices 100점
🔍 SEO 100점

📊 상세 분석 보기

🗺️ 지도 페이지: /main/maps

지표 점수
🚀 Performance 75점
♿ Accessibility 87점
✅ Best Practices 100점
🔍 SEO 100점

📊 상세 분석 보기

👤 프로필 페이지: /main/profile

지표 점수
🚀 Performance 75점
♿ Accessibility 88점
✅ Best Practices 100점
🔍 SEO 100점

📊 상세 분석 보기

🔗 전체 상세 분석 결과

📊 전체 상세 Lighthouse 분석 결과 보기

📄 측정된 페이지

  • /main/community
  • /main/founder
  • /main/home
  • /main/maps
  • /main/profile

모든 페이지에서 성능 측정이 완료되었습니다.


🤖 자동 생성된 Lighthouse 성능 리포트

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

이 PR은 Body 기반 토큰 인증에서 HttpOnly Cookie 기반 인증으로 완전히 마이그레이션하는 대규모 아키텍처 개선 작업입니다. XSS 공격 방어, SSR 인증 지원, 큐 기반 토큰 갱신 등 보안과 사용자 경험을 크게 향상시키는 변경사항들이 포함되어 있습니다.

주요 개선사항:

  • 보안 강화: HttpOnly 쿠키로 XSS 공격 차단, JavaScript에서 토큰 접근 불가
  • SSR 인증: 서버 사이드에서 인증 상태 확인 및 페이지 보호, 초기 로드 속도 향상
  • 큐 기반 토큰 갱신: 동시 401 에러 발생 시 중복 요청 방지, 한 번만 재발급
  • 전역 에러 핸들링: React Query Cache 레벨에서 네트워크/서버/인증 에러 일관성 있게 처리
  • 선택적 프록시: HTTP(빠른 개발) / HTTPS(완전한 기능) 모드 지원

중요한 개선이 필요한 부분:

  • 토큰 갱신 로직의 동시성 제어에 일부 버그 존재 (critical)
  • 서버 컴포넌트에서 쿠키 전달 누락 (moderate)
  • 프로덕션 환경에서 과도한 로깅으로 인한 성능 저하 우려 (moderate)

Reviewed changes

Copilot reviewed 18 out of 23 changed files in this pull request and generated 34 comments.

Show a summary per file
File Description
apps/web/src/lib/api-client.ts 큐 기반 토큰 갱신 로직 추가, 프록시 경로 인터셉터 구현 (일부 버그 존재)
apps/web/src/lib/api-error.ts ApiError 클래스 분리하여 재사용성 향상
apps/web/src/lib/server-api-client.ts 서버 컴포넌트 전용 API 클라이언트 추가 (쿠키 전달 개선 필요)
apps/web/src/middleware.ts 인증 미들웨어 추가로 SSR 페이지 보호 구현 (쿠키 전달 및 하드코딩 개선 필요)
apps/web/src/providers/AuthHydrationProvider.tsx SSR Prefetch로 초기 로드 최적화
apps/web/src/providers/queryProvider.tsx 전역 에러 핸들링 추가 (useToast 클로저 문제 존재)
apps/web/src/hooks/useAuth.ts Body 기반에서 쿠키 기반 인증으로 리팩토링, SSR 최적화
apps/web/src/hooks/useLogout.ts 로그아웃 훅 신규 추가, 캐시 초기화 처리
apps/web/src/hooks/useKakaoAuth.ts 쿠키 기반 인증으로 마이그레이션, authStore 의존성 제거
apps/web/src/app/layout.tsx AuthProvider를 AuthHydrationProvider로 교체, viewport 분리
apps/web/src/app/page.tsx useAuthStore에서 useAuth로 마이그레이션
apps/web/server.mjs HTTPS 로컬 개발 서버 추가 (인증서 경로 유연성 필요)
apps/web/next.config.js 인증 엔드포인트 프록시 설정 추가
apps/web/package.json dev:https 스크립트 추가
apps/web/.eslintrc.cjs server.mjs ignorePatterns 추가
apps/web/.eslintignore ESLint ignore 규칙 문서화
apps/web/src/generated/* API 문서 업데이트, 중복 export 제거 필요
apps/web/src/providers/AuthProvider.tsx 기존 AuthProvider 제거 (AuthHydrationProvider로 대체)
apps/web/src/stores/userStore.ts 빈 파일 (삭제 필요)
apps/web/src/utils/query.ts api-error 모듈로 import 경로 변경

throw error;
}
},
staleTime: 13 * 60 * 1000, // 13분
Copy link

Copilot AI Nov 26, 2025

Choose a reason for hiding this comment

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

staleTime이 13분으로 하드코딩되어 있습니다. 일관성을 위해 상수로 정의하는 것이 좋습니다.

해결 방법:

// 파일 상단에 상수 정의
const USER_CACHE_STALE_TIME = 13 * 60 * 1000; // 13분

// 사용
const query = useQuery({
  queryKey: getGetCurrentUserQueryKey(),
  queryFn: async () => { /* ... */ },
  staleTime: USER_CACHE_STALE_TIME,
  throwOnError: false,
});

Copilot uses AI. Check for mistakes.
Comment on lines +24 to +28
onError: (error) => {
console.error('[useLogout] 로그아웃 API 실패:', error);
queryClient.clear();
queryClient.setQueryData(getGetCurrentUserQueryKey(), null);
},
Copy link

Copilot AI Nov 26, 2025

Choose a reason for hiding this comment

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

로그아웃 실패 시에도 클라이언트 상태를 초기화하는 것은 좋지만, 사용자에게 알림을 표시하지 않아 문제를 인지하지 못할 수 있습니다. 최소한 개발 환경에서는 명확한 에러 메시지를 표시하는 것이 좋습니다.

제안:

onError: (error) => {
  console.error('[useLogout] 로그아웃 API 실패:', error);
  
  // 개발 환경에서는 toast 표시
  if (process.env.NODE_ENV === 'development') {
    // toast 사용 필요
  }
  
  // 클라이언트 상태는 여전히 초기화
  queryClient.clear();
  queryClient.setQueryData(getGetCurrentUserQueryKey(), null);
},

Copilot uses AI. Check for mistakes.
Comment on lines +45 to +50
// 모든 쿠키 확인
const allCookies = request.cookies.getAll();
console.log(
`[Middleware] 📦 전체 쿠키 개수: ${allCookies.length}`,
allCookies.map((c) => c.name),
);
Copy link

Copilot AI Nov 26, 2025

Choose a reason for hiding this comment

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

모든 요청마다 전체 쿠키를 로깅하면 프로덕션 환경에서 성능 저하 및 로그 과부하가 발생할 수 있습니다.

해결 방법:

if (process.env.NODE_ENV === 'development') {
  const allCookies = request.cookies.getAll();
  console.log(
    `[Middleware] 📦 전체 쿠키 개수: ${allCookies.length}`,
    allCookies.map((c) => c.name),
  );
}
Suggested change
// 모든 쿠키 확인
const allCookies = request.cookies.getAll();
console.log(
`[Middleware] 📦 전체 쿠키 개수: ${allCookies.length}`,
allCookies.map((c) => c.name),
);
// 모든 쿠키 확인 (개발 환경에서만 로깅)
const allCookies = request.cookies.getAll();
if (process.env.NODE_ENV === 'development') {
console.log(
`[Middleware] 📦 전체 쿠키 개수: ${allCookies.length}`,
allCookies.map((c) => c.name),
);
}

Copilot uses AI. Check for mistakes.
}

export function QueryProvider({ children }: QueryProviderProps) {
const toast = useToast();
Copy link

Copilot AI Nov 26, 2025

Choose a reason for hiding this comment

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

useToast 훅을 컴포넌트 함수 본문 내에서 호출하고 있지만, useState의 initializer 함수 내부에서 사용되는 QueryClient 생성 시점에 참조하려고 하면 클로저 문제가 발생할 수 있습니다. useState의 initializer는 첫 렌더링에서만 실행되므로 toast 함수의 최신 참조를 보장할 수 없습니다.

해결 방법:

export function QueryProvider({ children }: QueryProviderProps) {
  const toastStore = useToastStore();
  
  const [queryClient] = useState(() => {
    return new QueryClient({
      queryCache: new QueryCache({
        onError: (error, query) => {
          // toastStore를 클로저로 캡처하거나 useToastStore.getState() 사용
          const toast = (msg: string, type: ToastType) => {
            useToastStore.getState().addToast({
              id: Date.now(),
              message: msg,
              type,
            });
          };
          // ...
        },
      }),
    });
  });
}

Copilot uses AI. Check for mistakes.
Comment on lines +152 to +154
console.log('[Middleware] 인증 필요:', pathname);
return redirectToLogin(request, pathname);
}
Copy link

Copilot AI Nov 26, 2025

Choose a reason for hiding this comment

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

프로덕션 환경에서도 로깅하면 성능 저하가 발생합니다.

해결 방법:

if (PROTECTED_ROUTES.some((route) => pathname.startsWith(route))) {
  if (!hasAuth) {
    if (process.env.NODE_ENV === 'development') {
      console.log('[Middleware] 인증 필요:', pathname);
    }
    return redirectToLogin(request, pathname);
  }
}

Copilot uses AI. Check for mistakes.
Comment on lines +122 to +129
isRefreshing = false;
return AXIOS_INSTANCE(originalRequest);
} catch (refreshError) {
console.error('[Auth] ❌ 토큰 갱신 실패:', refreshError);
// 대기 중인 모든 요청 실패 처리
processQueue(refreshError);
isRefreshing = false;
return Promise.reject(refreshError);
Copy link

Copilot AI Nov 26, 2025

Choose a reason for hiding this comment

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

토큰 갱신 중 에러 발생 시 isRefreshing 플래그가 false로 리셋되지 않는 경우가 있습니다. processQueue 호출 후에도 에러가 발생하면 isRefreshingtrue 상태로 남아 이후 모든 갱신 시도가 큐에 추가되어 무한 대기 상태에 빠질 수 있습니다.

해결 방법:

try {
  await refreshToken();
  processQueue();
  return AXIOS_INSTANCE(originalRequest);
} catch (refreshError) {
  console.error('[Auth] ❌ 토큰 갱신 실패:', refreshError);
  processQueue(refreshError);
  return Promise.reject(refreshError);
} finally {
  isRefreshing = false; // finally 블록으로 이동
}
Suggested change
isRefreshing = false;
return AXIOS_INSTANCE(originalRequest);
} catch (refreshError) {
console.error('[Auth] ❌ 토큰 갱신 실패:', refreshError);
// 대기 중인 모든 요청 실패 처리
processQueue(refreshError);
isRefreshing = false;
return Promise.reject(refreshError);
return AXIOS_INSTANCE(originalRequest);
} catch (refreshError) {
console.error('[Auth] ❌ 토큰 갱신 실패:', refreshError);
// 대기 중인 모든 요청 실패 처리
processQueue(refreshError);
return Promise.reject(refreshError);
} finally {
isRefreshing = false;

Copilot uses AI. Check for mistakes.
Comment on lines +31 to +34
console.log(
'[ServerAPI] accessToken:',
accessToken ? '있음' : '없음',
);
Copy link

Copilot AI Nov 26, 2025

Choose a reason for hiding this comment

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

프로덕션 환경에서 로깅이 계속 실행되면 성능 저하 및 로그 과부하가 발생할 수 있습니다.

해결 방법:

if (process.env.NODE_ENV === 'development') {
  console.log(
    '[ServerAPI] accessToken:',
    accessToken ? '있음' : '없음',
  );
}

Copilot uses AI. Check for mistakes.
originalRequest && // 원래 요청이 존재
!originalRequest._retry // 무한 루프 방지
) {
//이미 갱신 중이면 큐에 추가
Copy link

Copilot AI Nov 26, 2025

Choose a reason for hiding this comment

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

주석 앞에 공백이 누락되었습니다.

Suggested change
//이미 갱신 중이면 큐에 추가
// 이미 갱신 중이면 큐에 추가

Copilot uses AI. Check for mistakes.
Comment on lines +49 to +52
console.log(`[API Client] 🔄 프록시 사용: ${url} → /api${url}`);
} else {
console.log(`[API Client] 📡 직접 호출: ${url}`);
}
Copy link

Copilot AI Nov 26, 2025

Choose a reason for hiding this comment

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

프로덕션 환경에서 모든 API 요청에 대해 로깅하면 성능 저하가 발생할 수 있습니다.

해결 방법:

if (process.env.NODE_ENV === 'development') {
  if (needsCookie) {
    console.log(`[API Client] 🔄 프록시 사용: ${url} → /api${url}`);
  } else {
    console.log(`[API Client] 📡 직접 호출: ${url}`);
  }
}

Copilot uses AI. Check for mistakes.
Comment on lines +32 to +34
console.log(
'[Middleware] 🚫 프록시 비활성화 - 미들웨어 인증 스킵 (CSR 전용 모드)',
);
Copy link

Copilot AI Nov 26, 2025

Choose a reason for hiding this comment

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

프로덕션 환경에서도 모든 요청마다 로깅하면 성능 저하가 발생합니다.

해결 방법:

if (!proxyEnabled) {
  if (process.env.NODE_ENV === 'development') {
    console.log(
      '[Middleware] 🚫 프록시 비활성화 - 미들웨어 인증 스킵 (CSR 전용 모드)',
    );
  }
  return NextResponse.next();
}
Suggested change
console.log(
'[Middleware] 🚫 프록시 비활성화 - 미들웨어 인증 스킵 (CSR 전용 모드)',
);
if (process.env.NODE_ENV === 'development') {
console.log(
'[Middleware] 🚫 프록시 비활성화 - 미들웨어 인증 스킵 (CSR 전용 모드)',
);
}

Copilot uses AI. Check for mistakes.
@github-actions
Copy link

@DreamPaste DreamPaste merged commit 441ee60 into dev Nov 27, 2025
4 checks passed
@DreamPaste DreamPaste deleted the feat/SOS-40-token-auth branch November 27, 2025 12:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Feat 💡 새로운 기능을 구현하고 추가합니다! Refactor 🫧 기존 내용을 개선하거나 최적화합니다! 휘건

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants