부캠/TypeScript&Next

[NEXT] STANDARD플러스주차 복습 #5

FS29 2024. 12. 24. 23:42
1. Streaming : 서버가 클라이언트로 데이터를 한 번에 전달하지 않고, 준비된 부분부터 점진적으로 전송하는 방식. UX와 SEO를 동시에 개선할 수 있다.
2. Suspense : React에서 비동기 작업의 상태를 관리하는 컴포넌트. 로딩 중에는 fallback 속성을 통해 대체 UI를 보여줄 수 있다.
3. Trigger : 데이터베이스에서 특정 이벤트(예: INSERT, UPDATE)가 발생했을 때 자동으로 실행되는 SQL 함수. (js 의 이벤트리스너와 유사하게 동작함)

 

Loading UI

https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming

 

Routing: Loading UI and Streaming | Next.js

Built on top of Suspense, Loading UI allows you to create a fallback for specific route segments, and automatically stream content as it becomes ready.

nextjs.org

loading.tsx 가 layout.tsx 및 그 하위 경로의 page.tsx가 렌더링되는 동안 로딩상태를 표시합니다.

import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import QueryProvider from "@/components/QueryProvider";
import { Suspense } from "react";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "포켓몬 도감",
  description: "이거슨 포켓몬 도감",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <header className="text-2xl text-center mt-4 mb-4">포켓몬 도감</header>
        <QueryProvider>
	      {/* 바로 이 Suspense fallback 부분을 Next.js는 loading.tsx로 인식합니다. */}
          <Suspense fallback={<div>loading.tsx 컴포넌트</div>}>
            {children}
          </Suspense>
        </QueryProvider>
      </body>
    </html>
  );
}

 

  • <Suspense>: 비동기 작업을 처리할 때 사용할 수 있는 컴포넌트로, 로딩 상태를 관리합니다.
  • fallback: 비동기 작업이 완료되기 전까지 보여줄 컴포넌트를 정의합니다.

https://react.dev/reference/react/Suspense

 

<Suspense> – React

The library for web and native user interfaces

react.dev

 

 

 

  • Next.js 에러처리 패턴
    • 클라이언트 컴포넌트에서의 에러처리
// useQuery, useInfiniteQuery 에 대한 에러 처리
function makeQueryClient() {
  return new QueryClient({
    queryCache: new QueryCache({
      onError: (error, query) => {
        alert(`Error Occured in ${query.queryKey}: ${error.message}`);
      },
    }),
    defaultOptions: {
      queries: {
        staleTime: 60 * 1000,
        retry: 0,
      },
    },
  });
}
// useMutation 의 경우 onError 콜백 사용
import { useMutation } from "@tanstack/react-query";

function MyComponent() {
  const mutation = useMutation(
    {
	    mutationFn: async (data) => {
	      const res = await fetch("/api/action", {
	        method: "POST",
	        body: JSON.stringify(data),
	        headers: { "Content-Type": "application/json" },
	      });
	      if (!res.ok) {
	        const errorData = await res.json();
	        throw new Error(errorData.message || "서버 오류");
	      }
	      return res.json();
	    },
      onError: (error, variables, context) => {
        console.error("Mutation 실패:", error);
        alert(`요청 실패: ${error.message}`);
      },
    }
  );

  return (
    <button onClick={() => mutation.mutate({ key: "value" })}>
      서버 액션 실행
    </button>
  );
}

서버 컴포넌트에서의 에러처리

전역에러처리로 global-error.tsx 을 app/ 에 만들어두면 네트워크 오류와 같은 전역적 에러 발생했을 때 에러를 받고 화면에 나타내줄 수 있습니다. 각 page 별 서버컴포넌트는 에러는 error.tsx 를 각 page.tsx 와 같은 레벨로 만들면 에러를 받아 처리가능 합니다.
// src/app/global-error.tsx
"use client";

export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <html>
      <body className="min-h-screen bg-gradient-to-b from-gray-50 to-gray-100 flex items-center justify-center p-4">
        <div className="max-w-md w-full bg-white rounded-lg shadow-lg p-8 text-center">
          <div className="mb-6">
            <svg
              className="w-16 h-16 text-red-500 mx-auto mb-4"
              fill="none"
              stroke="currentColor"
              viewBox="0 0 24 24"
              xmlns="http://www.w3.org/2000/svg"
            >
              <path
                strokeLinecap="round"
                strokeLinejoin="round"
                strokeWidth="2"
                d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
              />
            </svg>
            <h2 className="text-2xl font-bold text-gray-800 mb-2">
              문제가 발생했습니다!
            </h2>
            <p className="text-gray-600 mb-6">{error.message}</p>
          </div>
          <button
            onClick={() => reset()}
            className="bg-blue-500 hover:bg-blue-600 text-white font-semibold py-2 px-6 rounded-md transition duration-200 ease-in-out transform hover:scale-105 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50"
          >
            다시 시도하기
          </button>
        </div>
      </body>
    </html>
  );
}

 

https://nextjs.org/docs/14/app/api-reference/file-conventions/error

 

File Conventions: error.js | Next.js

API reference for the error.js special file.

nextjs.org

 

  • SSR 페이지 Streaming
    • Streaming 이란?
    • Streaming은 서버가 클라이언트(브라우저)에게 웹 페이지의 내용을 한 번에 전부 보내는 대신, 부분적으로 점진적으로 보내는 방식입니다. 이는 페이지가 로딩되는 동안 일부 콘텐츠가 준비되면 그 부분부터 먼저 사용자에게 보여주기 시작하는 것을 의미합니다.

 

page.tsx 내에서 Suspense로 Streaming 적용 시 SEO 와 UX 두마리 토끼를 잡을 수 있습니다.

 

// page.tsx (SSR 페이지)
export default async function SSRPage() {
	await fetch("서버URL"); // 이 부분은 loading.tsx 에서 처리
	return (
		<>
			<Suspense fallback={<div>로딩중...</div>}>
				<SSRComponent1 />
			</Suspense>
			<Suspense fallback={<div>로딩중...</div>}>
				<SSRComponent2 />
			</Suspense>
		</>
	)
}

Streaming 을 사용한 SSR 페이지의 html response(doc)를 확인해 봅시다.

(FAQ) Streaming 해도 SEO에 문제없을까요?

 

https://vercel.com/guides/does-streaming-affect-seo

 

Does streaming affect SEO and can streamed content be indexed?

Streamed content does not affect SEO and will still be indexed by Google. Learn more in this guide.

vercel.com

 

  • Next.js middleware
  • 미들웨어(Middleware)는 특정 경로에 대한 요청이 서버에서 처리되기 전에 중간에 어떤 작업을 수행하고 싶을 때 사용합니다.

대표적인 용도로 인가 처리(Authorization) 이 있습니다.

  • 알아두면 좋은 NextResponse 의 4가지 메소드 의미
// 요청을 계속 처리. 단순히 사용자 요청을 그대로 진행시킵니다.
return NextResponse.next();

// about 경로로 redirect 시킵니다.
return NextResponse.redirect(new URL('/about', request.url)); 

// faq 경로로 rewrite 합니다. (url path는 그대로인데 실행되는 페이지 컴포넌트가 다릅니다.)
return NextResponse.rewrite(new URL("/faq", request.url));

// 요청으로 가기전에 json 데이터를 응답합니다.
return NextResponse.json({ data: { hello: "world" } });

권한에 따른 redirect 로직

 

import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function middleware(request: NextRequest) {
  if (request.nextUrl.pathname.startsWith("/profile")) {
    const token = request.cookies.get("token")?.value;
    if (!token) {
	    // token 이 없는 상태로 프로필페이지에 접근 시도 시 홈 화면으로 리다이렉트 시킵니다.
      return NextResponse.redirect(new URL("/", request.url));
    }
    return NextResponse.next();
  }
}

export const config = {
  matcher: [
    "/((?!api|_next/static|_next/image|favicon.ico).*)",
  ],
};

권한에 따른 rewrites 로직

import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

// This function can be marked `async` if using `await` inside
export function middleware(request: NextRequest) {
  const membershipLevel = request.cookies.get('membershipLevel')?.value || 'guest';
  if (request.nextUrl.pathname.startsWith("/posts")) {
	  if (membershipLevel === "admin") {
			// 회원등급이 admin 인 경우 관리자 전용 페이지를 볼 수 있게 인가처리할 수도 있습니다.
		  return NextResponse.rewrite(new URL("/admin/posts", request.url));
	  }
    return NextResponse.next();
  }
}

export const config = {
  matcher: [
    "/((?!_next/static|_next/image|favicon.ico).*)",
  ],
};

 

권한없는 api 요청 시 빠른 응답

import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function middleware(request: NextRequest) {
  if (request.nextUrl.pathname.startsWith("/api/profile")) {
    const token = request.cookies.get("token")?.value;
    if (!token) {
      return NextResponse.json({ message: "token 이 없습니다." });
    }
  }
  return NextResponse.next();
}

export const config = {
  matcher: [
    "/((?!_next/static|_next/image|favicon.ico).*)",
  ],
};

 

middleware.ts 파일은 src/ 에 위치해야 합니다. app/ 안으로 넣으면 동작하지 않습니다.

https://nextjs.org/docs/14/app/building-your-application/routing/middleware

 

Routing: Middleware | Next.js

Learn how to use Middleware to run code before a request is completed.

nextjs.org

 

 

utils/supabase/middleware.ts 설정 일부 수정

import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'

export async function updateSession(request: NextRequest) {
  let supabaseResponse = NextResponse.next({
    request,
  })

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return request.cookies.getAll()
        },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value, options }) => request.cookies.set(name, value))
          supabaseResponse = NextResponse.next({
            request,
          })
          cookiesToSet.forEach(({ name, value, options }) =>
            supabaseResponse.cookies.set(name, value, options)
          )
        },
      },
    }
  )

  // IMPORTANT: Avoid writing any logic between createServerClient and
  // supabase.auth.getUser(). A simple mistake could make it very hard to debug
  // issues with users being randomly logged out.

  const {
    data: { user },
  } = await supabase.auth.getUser()

  if (
    !user &&
    !request.nextUrl.pathname.startsWith('/login')
  ) {
    // no user, potentially respond by redirecting the user to the login page
    const url = request.nextUrl.clone()
    url.pathname = '/login'
    return NextResponse.redirect(url)
  }
  
  // 이 부분 수정함. 현재 로그인 상태이면서 경로가 /login 인 경우 홈화면으로 리다이렉트.
  if (user && request.nextUrl.pathname.startsWith("/login")) {
    return NextResponse.redirect(request.nextUrl.origin);
  }

  // IMPORTANT: You *must* return the supabaseResponse object as it is. If you're
  // creating a new response object with NextResponse.next() make sure to:
  // 1. Pass the request in it, like so:
  //    const myNewResponse = NextResponse.next({ request })
  // 2. Copy over the cookies, like so:
  //    myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll())
  // 3. Change the myNewResponse object to fit your needs, but avoid changing
  //    the cookies!
  // 4. Finally:
  //    return myNewResponse
  // If this is not done, you may be causing the browser and server to go out
  // of sync and terminate the user's session prematurely!

  return supabaseResponse
}

 

  • supabase general client 설정
  • 서버 컴포넌트, 클라이언트 컴포넌트 구분없이 어디서든 편안하게 사용할 수 있는 client 설정
const supabase = typeof window === undefined ? createServerClient() : createBrowserClient();
route handler 에서의 supabase client 는 반드시 매번 새롭게 정의 해줘야 합니다.
route handler 는 서버리스 환경에서 동작하여 각 요청이 독립적인 상태를 가지는 특징을 가지고 있습니다.
import { createClient } from "@/utils/supabase/server";

export async function POST(request: Request) {
  const supabase = createClient();
  ...
}

 

  • Supabase SignUp Trigger (회원가입 시 자동으로 users 테이블에 회원등록 연동)
    • 회원가입 요청 api
let { data, error } = await supabase.auth.signUp({
    email: "본인이메일",
    password: "본인비밀번호",
    options: {
      data: {
        nickname: "본인닉네임",
      },
    },
  });
  • Trigger 생성 및 DB 함수 생성 SQL 명령어 템플릿
  • 아래 명령어를 SQL Editor 에 붙여넣고 실행하세요.
-- 새로운 트리거 함수 생성
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
  INSERT INTO public.users (id, email, nickname)
  VALUES (NEW.id, NEW.email, NEW.raw_user_meta_data->>'nickname');
  RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

-- 새로운 트리거 생성
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW
EXECUTE FUNCTION public.handle_new_user();
  • 트리거 삭제 SQL 명령어
  • 실수로 트리거를 잘못 만들었을 때는 삭제하고 다시 만들어야 합니다
DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;

handle_new_user 라는 DB Function은 CREATE OR REPLACE FUNCTION 로 생성되었기 때문에 뭔가 실수가 있었더라도 삭제하지 않고 수정 후 다시 실행하면 됩니다.

 

  • Supabase with Typescript
  • Supabase api 에 관한 타입생성을 간단하게 할 수 있는 설정을 제공하고 있습니다.

https://supabase.com/docs/guides/api/rest/generating-types

 

Generating TypeScript Types | Supabase Docs

How to generate types for your API and Supabase libraries.

supabase.com

 

package.json 의 scripts 에 타입생성 명령 셋업

# package.json
"scripts": {
    "genTypes": "supabase gen types typescript --project-id $PROJECT_REF --schema public > src/types/supabase.ts"
 },

 

 

  • useState 상태 타입 지정
  • 만약 todos 테이블에서 관리하는 todo 데이터 타입을 가져오고 싶을 때
import { Database, Tables, Enums } from "./database.types.ts";

// Before 😕
let todo: Database['public']['Tables']['todos']['Row'] = // ...

// After 😍
let todo: Tables<'todos'>

// useState 예시
const [todo, setTodo] = useState<Tables<'todos'> | null>(null);
  • Supabase Login by Server Client
  • Server Client로 로그인 성공 시 쿠키에 세션 정보가 저장됩니다. (Browser Client로 로그인 시 localStorage에 저장되었었죠?)