NextAuth에서 OAuth 로그인 흐름을 직접 제어하기

2025-04-06

OAuth 기반 소셜 로그인을 구현할 때, 대부분은 인증 과정을 간편하게 처리해주는 라이브러리나 공식 Provider를 활용합니다. Next.js에서 인증을 처리하기 위해 널리 사용되는 라이브러리인 NextAuth 역시 다양한 소셜 Provider를 지원하고 있어 빠르게 로그인 기능을 도입할 수 있다는 장점이 있습니다.

하지만 실제 프로젝트에서 카카오와 같은 일부 플랫폼의 특성과, 이미 구축된 백엔드 중심의 인증 구조를 함께 고려해야 할 경우, 공식 Provider의 내부 동작이 오히려 발목을 잡는 상황이 발생했습니다. 특히 인가 코드가 일회용이라는 점이 문제였고, 이는 NextAuth가 인가코드를 즉시 사용하는 구조와 충돌했습니다.

이 글에서는 이러한 문제를 어떻게 해결했는지, 그리고 왜 Credentials Provider를 활용하여 소셜 로그인을 직접 구현하게 되었는지에 대한 배경과 실제 구현 방법을 정리했습니다. 같은 고민을 하고 있는 분들께 도움이 되었으면 합니다.

배경 및 프로젝트 구조

현재 프로젝트의 OAuth 인증 구조는 다음과 같이 구현해야합니다.

  1. 사용자가 소셜 플랫폼(예: 카카오, 구글 등)에 인증 요청을 보냅니다.
  2. 소셜 플랫폼은 인증 후 인가 코드(authorization code)를 프론트엔드로 전달합니다.
  3. 프론트엔드는 이 인가 코드를 백엔드에 전달합니다.
  4. 백엔드는 인가 코드를 바탕으로 일련의 처리를 수행합니다. (예: 사용자 등록, 내부 토큰 발급 등)
  5. 이후 사용자 정보와 함께 프로젝트 전용 인증 토큰을 프론트엔드에 응답합니다.

문제의 발생

프론트엔드는 인가 코드만 전달하고, 민감한 인증 및 사용자 등록과 같은 처리는 백엔드에서 전담하는 구조입니다. 하지만 NextAuth의 공식 Provider(KakaoProvider)는 이 구조와 맞지 않는 문제가 있었습니다. 해당 Provider는 인가 코드를 즉시 사용하여 토큰 및 소셜 플랫폼의 사용자 정보를 가져오는 로직이 내장되어 있습니다.

카카오의 경우 인가 코드는 단 한 번만 사용할 수 있는 일회성 코드이기 때문에, NextAuth 내부에서 이를 먼저 사용해 버리고 백엔드에 전달하면 백엔드에서 이 인가코드로 토큰 발행 및 유저정보 조회등의 작업을 못하게 되어 인증 흐름이 깨지게 됩니다.

이로 인해 저희팀 내에서는 공식 Provider를 사용하지 않고, 프론트엔드에서 받은 인가 코드를 백엔드로 전달하여 인증을 완료한 뒤, 프로젝트 전용 액세스토큰과 사용자 정보를 받아오는 흐름을 별도 구현해야한다는 의견이 나왔습니다..

카카오 로그인 과정

고민과 아이디어

부트캠프내 강사님에게 문의를 통해 nextauth의 social provider를 사용하는 방법은 없을까하여 다른 팀원이 문의도 했지만, nextauth로는 구현이 안될것 같다는 의견을 받기도 했습니다. 그래서 팀내에 nextauth를 걷어내고, 직접 로그인을 구현하자는 의견도 있었습니다. 하지만 저는 팀원들께 일정 시간을 요청드리고 본 문제를 처음부터 다시 해결해보려고 시도했습니다.

  • nextauth는 session 관리를 도와주기위한 목적으로 있는 라이브러리이다. 우리가 직접 그 세션을 만드는 과정은 제공되는 기능들로 충분히 커스텀이 가능할것이다.
  • Oauth 구현을 너무 복잡하게 생각하여, 모든 과정을 미리 제공된 provider에게 위임하려고 한게 아닐까?
  • 이메일과 비밀번호를 사용시 연결하는 credentials provider의 credentials의 뜻은 '자격','증명'인데, 사용자가 소셜플랫폼에 로그인후 받게 되는 인가코드(token)를 이 증명에 사용하면 안되는걸까?
  • 그렇게되면 일반 이메일과 비밀번호를 이용하여 로그인할때 유저의 정보를 조회하는것과 동일 프로세스를 거칠수 있지 않을까?

위와 같은 생각을 해보면서, nextauth의 공식문서를 다시 한번 꼼꼼하게 살펴보았습니다. 그러던중 credentials provider에서 2FA, OTP등 이메일과 비밀번호가 아닌 사용 예제를 보게 되었습니다. 이것을 보고 우리가 받아서 백엔드에 전달해줘야하는 인가코드를 credentials로 이용하고, 이메일과 비밀번호로 로그인하는 케이스처럼 백엔드에서 사용자정보와 토큰을 가져와서 jwt콜백에 넘겨주는 이 과정을 그대로 사용할 수 있지 않을까? 라는 아이디어가 떠올라서, 이 과정을 작성하게 되었습니다.

여기서 중요한 점은, Credentials Provider의 authorize 콜백 내부에서 백엔드로 인가 코드를 전달하고 사용자 정보를 요청하는 방식으로 구성하면, NextAuth가 자체적으로 생성하는 임의의 access token이 아닌, 백엔드에서 발급한 프로젝트 전용 인증 토큰을 세션에 포함시켜 관리할 수 있다는 점입니다.

이를 통해 인증 이후에도 백엔드 API 요청 시 사용할 수 있는 실제 유효한 인증 토큰을 세션 내에서 유지할 수 있으며, 세션 기반 인증 구조와도 자연스럽게 통합할 수 있습니다.

Credentials Provider 구성

NextAuth의 CredentialsProvider는 이메일/비밀번호 기반 로그인을 위한 용도로 자주 사용되지만, 실제로는 인증 정보를 자유롭게 받아 처리할 수 있어, OAuth 인가 코드 기반 로그인에도 유연하게 활용할 수 있었습니다.

이 방식은 Google 등 다른 OAuth 플랫폼에도 동일한 구조로 확장할 수 있었으며, 모든 로그인 방식을 CredentialsProvider 하나로 통일하여 제어 가능하다는 장점이 있었습니다.

아래는 소셜 로그인 흐름을 직접 구현한 예시입니다:

  • 사용자가 카카오 로그인을 한후 돌아오게 되는 Redirect URL에서, 주소에 함께 파라미터로 붙어온 code를 signIn 함수에 지정한 Provider id('kakao')와 함께 credentials로 전달합니다.
  • NextAuth의 설정 파일에서 셋팅해둔 id가 Kakao인 Credentials Provider가 이를 처리합니다.
  • authorize 함수 내에서 프로젝트의 백엔드 API를 호출하여 사용자 정보와 엑세스토큰을 받습니다.
  • 이 정보를 리턴하여 jwt 콜백과 session콜백에서 활용합니다.
// 카카오 로그인 리다이렉션 URL로 접근한 페이지입니다.
// 사용자가 카카오 로그인후 해당 페이지로 리다이렉션을 해올때 같이 붙여온 code를
// credentials로 넘겨 해당 provider id에 구성된 authorize callback을 실행합니다.

'use client';

...

export default function KakaoCallback() {
  const searchParams = useSearchParams();
  const code = searchParams.get('code');

  useEffect(() => {
    (async function () {
      const res = await signIn('kakao', {
        code,
        redirect: false,
      });

      if (res?.ok) {
        redirect('/');
      } else {
        redirect('/login?error=kakao login error');
      }
    })();
  }, [code]);

  return <p>카카오 로그인 중입니다...</p>;
}
// signIn('kakao', {code})를 통해 전달된 code를
// authorize 콜백 내부에서 백앤드와 통신후 사용자 정보와 엑세스토큰을 받습니다.

CredentialsProvider({
  id: 'kakao', // client api의 singIn('providerId')에서 사용되는 유니크한 값으로 사용
  name: 'Kakao',
  credentials: {
    code: { label: 'code', type: 'text' },
  },
  async authorize(credentials) {
    if (!credentials?.code) return null;

    // 백엔드에 code를 전달하여, 사용자정보와 엑세스토큰을 받아옵니다.
    const { user, token } = await kakaoSignIn(credentials.code);

    return {
      id: user.id,
      email: user.email,
      nickname: user.nickname,
      image: user.image,
      token,
    };
  },
}),

// 이메일과 비밀번호로 로그인 인증처리와 구조가 동일합니다.
CredentialsProvider({
  id: 'credentials',
  name: 'Credentials',
  credentials: {
    email: { label: '이메일', type: 'email' },
    password: { label: '비밀번호', type: 'password' },
  },
  async authorize(credentials) {
    if (!credentials) return null;

    const { user, token } = await signIn({
      email: credentials.email,
      password: credentials.password,
    });

    return {
      id: user.id,
      email: user.email,
      nickname: user.nickname,
      image: user.image,
      token,
    };
  },
}),

왜 클라이언트에서 인가 코드를 전달했는가?

OAuth 인증 흐름에서 인가 코드를 서버 측 route handler에서 처리할 수도 있습니다. 그렇다면 왜 이 프로젝트에서는 클라이언트 페이지에서 직접 인가 코드를 받아 `signIn` 함수로 전달하는 방식을 선택했을까요?

가장 큰 이유는 NextAuth v4의 제한점 때문입니다.
현재 프로젝트는 NextAuth v4를 사용하고 있으며, 서버 측에서 직접 로그인 처리를 위한 API를 공식적으로 제공하지 않으며, 이를 직접 호출하려면 CSRF token 발급, 인증 정보 전달 등의 복잡한 절차를 수동으로 구성해야 합니다.

즉, 서버 라우트를 통해 로그인 처리를 구성하려면 NextAuth 내부 로직을 일부 재현해야 하며, 이는 비효율적이고 유지보수가 어렵습니다. 따라서 공식적으로 제공되는 클라이언트용 `signIn` 함수를 사용하는 것이 더 안정적인 방법이라고 판단하였습니다.

또한 클라이언트에서 인증 처리를 수행하면 로그인 진행 상태를 UI로 보여줄 수 있는 장점도 있습니다. 예를 들어, 카카오 로그인 중이라는 메시지나 로딩 스피너 등을 통해 사용자 경험을 개선할 수 있었습니다.

로그인 과정

수정된 인증 흐름도

수정된 인증 흐름도

정리

NextAuth의 공식 Provider는 간편하게 OAuth 로그인을 구현할 수 있다는 장점이 있지만, 내부 인증 흐름이 고정되어 있어 커스터마이징이 어려운 단점도 존재합니다. 특히 카카오처럼 인가 코드가 1회용으로 제한되는 플랫폼의 경우, 백엔드에서 인증 및 사용자 관리를 전담하는 구조와 충돌이 발생할 수 있습니다.

이번 프로젝트에서는 이러한 문제를 해결하고, 기존의 백엔드 중심 인증 구조와의 호환성을 유지하기 위해 Credentials Provider를 커스터마이징하여 OAuth 로그인 흐름을 직접 구현하였습니다. 이를 통해 프로젝트에 적합한 인증 흐름을 구성할 수 있었을 뿐만 아니라, OAuth 인증 플로우 전반에 대해 다시 한번 깊이 있게 이해하고 정리할 수 있는 좋은 기회가 되었습니다.