포스트

[이제와서 시작하는 Next.js 마스터하기 #8] Proxy.ts로 네트워크 제어하기

[이제와서 시작하는 Next.js 마스터하기 #8] Proxy.ts로 네트워크 제어하기

“모든 요청의 문지기!” - Proxy.ts로 인증, 리다이렉트, 헤더 수정을 한 곳에서!

🎯 이 글에서 배울 내용

  • Proxy.ts가 무엇인지
  • 인증 체크하기
  • 리다이렉트와 리라이트
  • 국제화(i18n) 구현

예상 소요 시간: 40분


🚪 Proxy.ts란?

Next.js 16의 새 기능으로 middleware.ts를 대체합니다.

역할: 모든 요청이 페이지에 도달하기 전에 거치는 “문지기”

1
2
3
4
5
사용자 요청 → Proxy.ts → 페이지
              ↑
         인증 체크
         리다이렉트
         헤더 수정

🔐 기본 인증 체크

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// proxy.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  // 쿠키에서 토큰 확인
  const token = request.cookies.get('auth_token');

  // 보호된 경로
  if (request.nextUrl.pathname.startsWith('/dashboard')) {
    if (!token) {
      // 로그인 페이지로 리다이렉트
      return NextResponse.redirect(new URL('/login', request.url));
    }
  }

  return NextResponse.next();
}

export const config = {
  matcher: ['/dashboard/:path*', '/profile/:path*']
};

🌐 국제화 (i18n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// proxy.ts
import { NextResponse } from 'next/server';

const locales = ['en', 'ko', 'ja'];
const defaultLocale = 'en';

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  // 이미 로케일이 있으면 통과
  const pathnameHasLocale = locales.some(
    (locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
  );

  if (pathnameHasLocale) {
    return NextResponse.next();
  }

  // Accept-Language 헤더에서 선호 언어 가져오기
  const locale = request.headers
    .get('accept-language')
    ?.split(',')[0]
    ?.split('-')[0] || defaultLocale;

  // 적절한 로케일로 리다이렉트
  request.nextUrl.pathname = `/${locale}${pathname}`;
  return NextResponse.redirect(request.nextUrl);
}

🔄 리다이렉트 vs 리라이트

Redirect (URL 변경됨)

1
2
3
4
5
6
7
export function middleware(request: NextRequest) {
  if (request.nextUrl.pathname === '/old-blog') {
    return NextResponse.redirect(new URL('/blog', request.url));
  }

  return NextResponse.next();
}

Rewrite (URL 유지)

1
2
3
4
5
6
7
8
export function middleware(request: NextRequest) {
  // /blog를 /posts로 내부적으로 처리 (URL은 /blog 유지)
  if (request.nextUrl.pathname.startsWith('/blog')) {
    return NextResponse.rewrite(new URL('/posts', request.url));
  }

  return NextResponse.next();
}

📊 헤더 수정

1
2
3
4
5
6
7
8
9
10
11
12
13
export function middleware(request: NextRequest) {
  const response = NextResponse.next();

  // 보안 헤더 추가
  response.headers.set('X-Frame-Options', 'DENY');
  response.headers.set('X-Content-Type-Options', 'nosniff');
  response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');

  // 커스텀 헤더
  response.headers.set('X-Custom-Header', 'my-value');

  return response;
}

🎯 실전 예제: 완전한 인증 시스템

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// proxy.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { verifyAuth } from '@/lib/auth';

export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  // 공개 경로
  const publicPaths = ['/', '/login', '/signup', '/api/auth'];
  const isPublicPath = publicPaths.some(path => pathname.startsWith(path));

  if (isPublicPath) {
    return NextResponse.next();
  }

  // 인증 확인
  const token = request.cookies.get('token')?.value;

  if (!token) {
    const url = new URL('/login', request.url);
    url.searchParams.set('from', pathname);
    return NextResponse.redirect(url);
  }

  try {
    // 토큰 검증
    const user = await verifyAuth(token);

    // 관리자 전용 경로
    if (pathname.startsWith('/admin') && user.role !== 'admin') {
      return NextResponse.redirect(new URL('/unauthorized', request.url));
    }

    // 사용자 정보를 헤더에 추가
    const response = NextResponse.next();
    response.headers.set('x-user-id', user.id);
    response.headers.set('x-user-role', user.role);

    return response;
  } catch (error) {
    // 유효하지 않은 토큰
    return NextResponse.redirect(new URL('/login', request.url));
  }
}

export const config = {
  matcher: [
    /*
     * Match all request paths except:
     * - _next/static (static files)
     * - _next/image (image optimization files)
     * - favicon.ico (favicon file)
     * - public folder
     */
    '/((?!_next/static|_next/image|favicon.ico|public).*)',
  ],
};

verifyAuth 함수 구현

위 예제에서 사용한 verifyAuth 함수를 직접 만들어보겠습니다!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// lib/auth.ts
import { jwtVerify, SignJWT } from 'jose';

// 환경 변수에서 시크릿 키 가져오기
const secret = new TextEncoder().encode(
  process.env.JWT_SECRET || 'your-secret-key-min-32-characters-long'
);

// 사용자 타입 정의
export interface User {
  id: string;
  email: string;
  role: 'user' | 'admin';
}

// JWT 토큰 생성
export async function createToken(user: User): Promise<string> {
  const token = await new SignJWT({
    userId: user.id,
    email: user.email,
    role: user.role
  })
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('7d') // 7일 후 만료
    .sign(secret);

  return token;
}

// JWT 토큰 검증
export async function verifyAuth(token: string): Promise<User> {
  try {
    const verified = await jwtVerify(token, secret);
    const payload = verified.payload;

    return {
      id: payload.userId as string,
      email: payload.email as string,
      role: payload.role as 'user' | 'admin'
    };
  } catch (error) {
    throw new Error('Invalid token');
  }
}

// 쿠키에서 사용자 정보 가져오기
export async function getCurrentUser(request: Request): Promise<User | null> {
  const cookieHeader = request.headers.get('cookie');
  if (!cookieHeader) return null;

  // 쿠키에서 토큰 추출
  const tokenMatch = cookieHeader.match(/token=([^;]+)/);
  if (!tokenMatch) return null;

  const token = tokenMatch[1];

  try {
    return await verifyAuth(token);
  } catch {
    return null;
  }
}

패키지 설치:

1
npm install jose

코드 설명 (초보자용):

  1. JWT (JSON Web Token):
    • 사용자 정보를 암호화해서 저장하는 토큰
    • 서버가 발급하고, 클라이언트가 쿠키에 저장
    • 매 요청마다 서버가 검증
  2. createToken(): 로그인 성공 시 토큰 생성
    1
    2
    3
    4
    5
    6
    
    const token = await createToken({
      id: '123',
      email: 'user@example.com',
      role: 'user'
    });
    // 쿠키에 저장
    
  3. verifyAuth(): 토큰이 유효한지 확인
    • 유효하면: 사용자 정보 반환
    • 무효하면: 에러 던짐 → 로그인 페이지로 리다이렉트
  4. 환경 변수 설정 (.env.local):
    1
    
    JWT_SECRET=my-super-secret-key-at-least-32-characters-long-for-security
    

실전 사용 예시:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// app/api/auth/login/route.ts
import { createToken } from '@/lib/auth';
import { cookies } from 'next/headers';

export async function POST(request: Request) {
  const { email, password } = await request.json();

  // 사용자 인증 (실제로는 데이터베이스에서 확인)
  // 예: const user = await prisma.user.findUnique({ where: { email } });

  // 간단한 시뮬레이션
  if (email === 'test@example.com' && password === 'password123') {
    const user = {
      id: '1',
      email: 'test@example.com',
      role: 'user' as const
    };

    // 토큰 생성
    const token = await createToken(user);

    // 쿠키에 저장
    const cookieStore = await cookies();
    cookieStore.set('token', token, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax',
      maxAge: 60 * 60 * 24 * 7 // 7일
    });

    return Response.json({ success: true, user });
  }

  return Response.json(
    { error: 'Invalid credentials' },
    { status: 401 }
  );
}

💡 보안 팁:

  • JWT_SECRET은 최소 32자 이상
  • ✅ 프로덕션에서는 secure: true 사용
  • httpOnly: true로 JavaScript에서 접근 차단
  • ✅ 토큰 만료 시간 설정

🔍 자주 묻는 질문 (FAQ)

Q1: proxy.ts vs middleware.ts?

Proxy.ts (Next.js 16 신기능):

  • 더 명확한 네트워크 경계
  • Node.js 런타임
  • 더 강력한 기능

Middleware.ts (기존):

  • Edge 런타임
  • 제한적이지만 빠름

권장: 새 프로젝트는 proxy.ts 사용!


🎯 오늘 배운 내용 정리

  1. Proxy.ts
    • 모든 요청 제어
    • 인증, 리다이렉트, 헤더 수정
  2. 인증
    • 쿠키 기반 인증
    • 역할 기반 접근 제어
  3. 국제화
    • 자동 언어 감지
    • URL 기반 로케일

📚 시리즈 네비게이션


“Proxy.ts로 보안과 UX를 동시에!” 🔐

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.