본문 바로가기
Web Study/Next.js

Next.js로 SNS x.com 클론코딩하기 - 5

by 쿠리의일상 2024. 1. 25.

서버 컴포넌트와 서버 액션

전의 글에서 서버 액션은 unstable 이므로 사용 안한다고 했지만, 강의 도중에 14버전이 나오면서 stable 하게 되었다고 합니다. 그래서 서버 액션을 회원가입과 로그인에서 사용해보겠습니다.

우선 서버 액션을 사용하기 위해선 서버 컴포넌트인지 클라이언트 컴포넌트인지 사용법이 상이하기 때문에 먼저 서버 컴포넌트 상에서 서버 액션을 사용하는 경우입니다.

 

<div className={style.inputDiv}>
    <label className={style.inputLabel} htmlFor="id">
      아이디
    </label>
    <input
      id="id"
      className={style.input}
      type="text"
      placeholder=""
      value={id}
      onChange={onChangeId}
    />
</div>

이렇게 회원가입 컴포넌트 안에 input 필드가 존재하고 onChange는 당연하게도 클라이언트 컴포넌트 입니다. 서버 컴포넌트로 만들어주기 위해 과감하게 없애주고 상단의 form 태그 안에 action 속성을 추가해줍니다.

action 속성에 들어가는 함수가 바로 서버 액션 함수이며, submit 을 수행하는 서버 액션을 만들기 위해 아래처럼 정의합니다.

  const submit = async (formData: FormData): Promise<void> => {
    'use server';
    const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/users`, {
      method: 'post',
      body: formData,
      credentials: 'include',
    });
  };

비동기 처리를 해줘야 하므로 async 면서 FormData를 매개변수로 갖습니다. 함수 안에선 use server 를 사용해줘야 합니다. axios 를 사용해도 되지만 넥스트는 fetch 를 사용하는 것을 권장하고 있습니다.

FormData는 form 태그 안에 input 등에 지정해준 name 값을 키로 가진 value 값들이 담겨옵니다. 이 정보들을 body에 담아서 보내주면 됩니다. credentials 는 쿠키를 사용하기 위한 속성으로 추가해주셔야 쿠키 전달이 가능합니다.

그리고 use server 로 정의해준 내용은 서버에서만 볼 수 있고 브라우저에선 확인이 불가 하다고 합니다. 

 

redirect

import { redirect } from 'next/navigation';

일전에도 소개했다시피 redirect 는 페이지를 이동시켜주는 함수입니다만 회원가입 후 바로 홈으로 이동시켜주고자 할 때 등에 사용할 수 있습니다. 하지만 redirect 는 try/catch 문에선 사용할 수 없기 때문에 위의 코드 내에서 에러 검증을 위해 코드를 작성할 때 아래와 같이 번거롭지만 1번더 처리해줘야 합니다.

  const submit = async (formData: FormData): Promise<any> => {
    'use server';

    // FormData 검증
    if (!formData.get('id')) {
      return { message: 'no_id' };
    }

    if (!formData.get('name')) {
      return { message: 'no_name' };
    }

    if (!formData.get('password')) {
      return { message: 'no_password' };
    }

    if (!formData.get('image')) {
      return { message: 'no_image' };
    }

    // redirect 용 플래그
    let shouldRedirect = false;

    try {
      const response = await fetch(
        `${process.env.NEXT_PUBLIC_BASE_URL}/api/users`,
        {
          method: 'post',
          body: formData,
          credentials: 'include',
        }
      );

      if (response.status === 403) {
        return { message: 'user_exists' };
      }

      shouldRedirect = true; // 에러인 경우에는 redirect 가 실행되지 않게끔 처리
    } catch (err) {
      console.log(err);
    }

    if (shouldRedirect) {
      redirect('/home');
    }
  };

 

클라이언트 컴포넌트와 서버 액션

서버 액션은 서버 컴포넌트에서 기본적으로 실행이 되므로 클라이언트 컴포넌트로 만들어준 경우 정상 작동하지 않을 수 있습니다. 이 때는 간단히 별도의 함수를 담아놓은 폴더를 분리하여 import 해주면 됩니다. 위의 함수를 별도로 파일로 나누고 함수 안이 아닌 'use server' 를 최상단으로 빼줍니다.

 

usrFormState / useFormStatus

import { useFormState, useFormStatus } from 'react-dom';

react-dom 에 존재하는 훅입니다. 우선 useFormState 훅부터 설명하자면 form action의 결과에 따라 상태를 업데이트해줄 수 있는 훅입니다.

https://react.dev/reference/react-dom/hooks/useFormState

 

useFormState – React

The library for web and native user interfaces

react.dev

const [state, formAction] = useFormState( actionFn, initialState )
  • state : 현재 상태
  • formAction : form 에서 action 속성으로 전달하거나 button 에 전달할 수 있는 새로운 액션
  • actionFn : submit 했을 때 호출할 함수
  • initialState : 초기 상태값
const [state, formAction] = useFormState(onSubmit, { message: null });

<form action={formAction}>
	<p> {message} </p>
</form>

위와 같은 형식으로 기존에 action에 넣어주었던 서버 액션 함수를 useFormState() 훅에 직접 넣어주고, 초기 상태값을 설정해줍니다. 다음 form의 action에 useFormState의 formAction 반환값을 넣어주면 됩니다.

다만 useFormState 훅을 사용하게 되면 onSubmit 에 사용되는 함수의 매개변수를 수정해줘야합니다.

export default async function onSubmit(
      prevState: { message: string | null },
      formData: FormData
): Promise<any> { }

기존 formData만 매개변수로 받아줬으나, 이전 상태값을 의미하는 prevState 를 추가하여 받아와줍니다. 저의 경우엔 메세지를 받아오는 객체므로 저렇게 설정한 것이나 지정해준 상태값에 따라 달라질 수 있습니다.

 

useFormStatus 훅은 폼에서 받아온 데이터를 서버에서 처리하는 중인지 확인할 수 있는 훅입니다.

https://react.dev/reference/react-dom/hooks/useFormStatus

 

useFormStatus – React

The library for web and native user interfaces

react.dev

const {pending, data, method, action} = useFormStatus()
  • pending : true 면 form이 submission을 기다리고 있음을 의미
  • data : FormData 자체, 만약 form 태그나 submission이 없는 경우 null
  • method : HTTP 요청 메서드(get, post, ..)
  • action : form 태그에서 action 속성에 전달된 함수
function Submit() {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? "Submitting..." : "Submit"}
    </button>
  );
}

이런 식으로 사용하여 pending 중일 때 form 태그를 클릭하지 못하게 하는 등(로딩)의 처리가 가능합니다.

 

NextAuth.js

https://authjs.dev/

 

Auth.js

Authentication for the Web.

authjs.dev

로그인을 위해서 해당 라이브러리를 사용한다고 합니다. NextAuth.js 일 때 한번 사용해본 적이 있으나 버전 업이 되고 다른 프레임워크에서도 지원되면서 Auth.js로 이름을 바꿨다고 합니다. 네이버, 카카오톡, 구글 등의 간편 로그인을 쉽게 구현할 수 있어서 많이 사용됩니다. 저희는 Next.js 를 사용하니 당연히 NextAuth.js 를 사용할 것입니다.

https://authjs.dev/reference/nextjs

설치하기

npm install next-auth@beta @auth/core

 

그리고 app 폴더와 같은 위치에 auth.ts와 middleware.ts 파일을 만들어줍니다.

auth.ts

import NextAuth from 'next-auth';

export const {
  handlers: { GET, POST },
  auth,
  signIn,
} = NextAuth({ });
  • handlers : API 라우트
  • auth : 미들웨어를 묶어둘 파일
  • sighIn : 로그인 하기 위한 목적

 

middleware.ts

import { auth } from './auth';

export const config = {
  matcher: ['/compose/tweet', '/home', '/explore', '/messages', '/search'],
};

middleware.ts 파일에서 config.matcher 가 의미하는 것은 auth가 적용될 라우팅 주소를 의미합니다. 즉, 로그인이 된 이후로 접근할 수 있는 페이지를 설정해둔 것입니다. 이처럼 미들웨어의 기능은 app 라우터 이후로 생겨서 페이지 접근 권한을 다루기 쉬워졌다고 합니다.

 

API 라우트란?

해당 라우트에 작성한 코드들은 클라이언트 번들에 포함되지 않으며, app/api/ 폴더 내의 모든 파일들은 페이지 대신 API 엔드포인트로 처리되므로 서버리스로 개발 할 수 있다고 합니다.

app
  ㄴ api
   ㄴ auth
       	 ㄴ [...nextauth]
          	  ㄴ route.ts

현재 폴더 구조는 위와 같고, page.tsx 가 아닌, route.ts 파일을 만들어줍니다. 여기서 바로 상위 폴더인 [...nextauth] 의 경우 catch-all 라우트라고 합니다.

https://nextjs.org/docs/app/building-your-application/routing/dynamic-routes#catch-all-segments

 

Routing: Dynamic Routes | Next.js

Dynamic Routes can be used to programmatically generate route segments from dynamic data.

nextjs.org

알다시피 [ ] 대괄호는 동적 라우트를 위한 형태고, ... 은 어떤 주소든 다 들어갈 수 있는 형태로, 배열로 관리할 수 있다는 의미입니다.

즉, 위에 설정한 폴더 구조로 봤을 때 /api/auth/* 와 관련된 모든 API는 route.ts 파일이 담당하는 것입니다.

route.ts

export { GET, POST } from '@/auth';

해당 파일에 직접 관리하는 것보단 하나의 파일에 비슷한 기능끼리 묶어두는 것이 좋으므로 auth.ts 파일로 관리를 전가해줍니다. 

일단 nextauthjs 를 사용하기 위한 기본 설정은 여기까지인데 엄청 헷갈리네요^^...

 

위에서 틀만 잡아두었던 auth.ts 파일에 CredentialsProvider 를 설정해봅시다. Provider는 기본적으로 네이버, 카카오톡 등의 로그인을 제공해주는 틀이라고 보시면 됩니다.

Credentials는 기존 시스템이 존재하는채로 사용자를 인증할 때 사용하는 로그인 방법 중의 하나므로, 다른 방식의 로그인을 구현하고자 하면 providers 배열에 해당 방식의 로그인Provider 를 추가해줍니다.

import NextAuth from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';
import { NextResponse } from 'next/server';

export const {
  handlers: { GET, POST },
  auth,
  signIn,
} = NextAuth({
  providers: [
    CredentialsProvider({
      async authorize(credentials) {
        const authResponse = await fetch(`${process.env.AUTH_URL}/api/login`, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
          },
          body: JSON.stringify({
            id: credentials.username,
            password: credentials.password,
          }),
        });

        if (!authResponse.ok) {
          return null;
        }

        const user = await authResponse.json();
        console.log('user', user);
        return {
          email: user.id,
          name: user.nickname,
          image: user.image,
          ...user,
        };
      },
    }),
  ],
});

NextAuth() 안에 providers 배열 안 CredentialsProvider를 넣어주게 되면 로그인을 수행할 때 CredentialsProvider 안의 async 함수가 호출되게 됩니다. 이때 process.env.AUTH_URL 의 경우 실제 백엔드 서버 주소가 들어가야 하므로 .env에 넣어줘야합니다.

그리고 authrize() 함수의 매개변수인 credentials 에는 아이디(username), 패스워드(password) 정보가 약속되어 들어가므로 이 정보를 저희가 사용하기 좋게끔 변경해서 body에 담아줍니다.

마지막으로 authResponse 를 통해 정상적으로 로그인이 처리됐다면 json 으로 가져온 다음 해당 정보들을 return 해줍니다. 이 정보는 자주 사용되므로 형태를 잘 지정해줘야한다고 합니다.

 

여기까지 해주면 해당 페이지(/api/auth/signin)에 접속하면 기본 페이지를 확인할 수 있습니다.

이미 만들어놓은 페이지가 있으므로 해당 페이지를 사용한다고 정의하기 위해선 NextAuth에 pages를 지정해주면됩니다.

export const {
  handlers: { GET, POST },
  auth,
  signIn,
} = NextAuth({
/////////
  pages: {
    signIn: '/i/flow/login',
    newUser: 'i/flow/signup',
  },
/////////
  providers: [
    CredentialsProvider({
      async authorize(credentials) { }
    }),
  ],
});

 

auth 사용하기

이전에 만들어두었던 LoginModal.tsx 로 향합니다. 현재 로그인 모달은 클라이언트 컴포넌트므로 form 태그가 submit 되면 저희가 만들어준 auth.ts 가 아닌 기존 next/auth 가 지원하는 signIn 함수를 실행시켜주면 됩니다.

import { signIn } from 'next-auth/react';

//...

  const onSubmit: FormEventHandler<HTMLFormElement> = async (e) => {
    e.preventDefault();
    setMessage('');

    try {
      await signIn('credentials', {
        username: id,
        password: password,
        redirect: false,
      });
      router.replace('/home');
    } catch (err) {
      console.log(err);
    }
  };

signIn 함수의 경우, 첫번째 파라미터로 현재 인증 방식(저희의 경우 credentials)을 두번째 파라미터로는 input에서 받아온 정보를 담아줍니다. redirect의 경우 서버측에 적용되므로 false 처리 해줍니다. 그 후 로그인이 끝나면 replace()로 로그인한 채로 메인 화면으로 옮겨줍니다.

로그아웃 또한 이미 존재하는 next-auth/react 의 signOut 함수를 사용하여 구현해줄 수 있습니다.

  const onLogout = () => {
    signOut({ redirect: false }).then(() => {
      router.replace('/');
    });
  };

 

useSession()

import { useSession } from 'next-auth/react';

next-auth 에서 지원하는 useSession 훅으로 클라이언트 컴포넌트인 경우 아이디 세션 정보를 가져올 수 있습니다.

'use client';

//..

const {data} = useSession();

next-auth 에서 지원하는 data는 id, email, image, name 이므로 해당 정보값을 원래 구현해놓은대로 사용할 수 있게 변경해줄 필요가 있습니다.

다만 여기서 useSession 훅을 사용하려면 SessionProvider 로 감싸라는 에러가 아래처럼 발생할텐데,

이 에러를 해결하기 위해 파일을 생성해줍니다. 우선 Provider 는 보통 클라이언트 컴포넌트에 작성해줘야 하므로 MSW컴포넌트를 만들어준 것처럼 전체 파일을 감싸줄 수 있게끔 app 폴더 아래에 만들어줍니다.

AuthSession.tsx

'use client';
import { SessionProvider } from "next-auth/react";

type Props = ({
  children: React.ReactNode;
});

export default function AuthSession({ children }: Props) {
  return <SessionProvider>{children}</SessionProvider>;
}

SessionProvider 는 next-auth/react 에서 가져올 수 있으며, 위와 같이 ReactNode의 children으로 감싸주는 형태로 만들어줍니다. 그런다음 전체를 감싸주는 루트 레이아웃의 children에 감싸주도록 합니다.

 

export default function RootLayout({ children }: Props) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <MSWComponent />
        <AuthSession>{children}</AuthSession>
      </body>
    </html>
  );
}

어디에서든 useSession을 사용해줄 수 있게 되었습니다. useSession 으로 계속해서 로그인 여부를 판단해줘야 하므로 루트 레이아웃에 감싸주었습니다.

이제 사소한 설정을 마치고 로그인 또는 회원가입을 진행해보면 개발자 도구의 애플리케이션에 authjs 로 시작하는 쿠키가 생겼음을 알 수 있습니다. 이중에서 authjs.session-token 이 유저 정보를 가지고 있는 쿠키입니다.

 

CSRF 공격

Cross Site Request Forgery (사이트 간 요청 위조)

인증된 사용자의 브라우저에서 사이트가 갖는 신뢰를 악의적인 공격에 사용하는 공격 유형입니다. 사용자가 인증된 대상 사이트로 원하지 않는 HTTP 요청을 전송하도록 한다고 합니다. 위의 session-token(로그인 쿠키) 을 빼가는 CSRF가 존재하는데, next-auth에선 authjs.csrf-token이 방어해주므로 따로 처리할 필요가 없어집니다.

 

이를 통해 개발자 도구의 네트워크 탭을 확인해보면 로그인 관련 세션이 아래처럼 응답 받고 있음을 알 수 있습니다.

응답부분이 null 이면 로그인이 되지 않은 상태로 받아들일 수 있습니다.

 

현재 로그인이 되지 않은 상태에서 '/home' 주소가 정상적으로 작동하면 안됩니다만 정상작동하므로 auth.ts에 session 검사를 한 다음 실행되는 콜백 함수를 추가하도록 합니다.

  callbacks: {
    async authorized({ request, auth }) {
      if (!auth) {
        return NextResponse.redirect('http://localhost:3000/i/flow/login');
      }
      return true;
    },
  },

 

또는 middleware.ts 측에 지정해준 주소에서 작동할 수 있는 미들웨어를 추가해줄 수 있습니다.

export async function middleware() {
  const session = await auth();
  if (!session) {
    return NextResponse.redirect('http://localhost:3000/i/flow/login');
  }
}