Intro
이번 서비스에서 내가 맡은 부분은 소셜 로그인이다.
백엔드는 spring security로, 프론트에서는 next 14로 해당 기능을 개발한다.
개발 단계에 들어가기 앞서 서버와 미스컴으로 next-auth라는 조금 쉬운 길을 가지 못했고,
React로만 개발을 해서 next에 대해 제대로 이해하지 못했던 시행착오부터
그렇다면 next에서는 어떻게 해야 하는지의 과정을 정리하려고 한다.
React에서는 됐다고요
기존에 React로 로그인 기능을 구현할 때는 서드파티 라이브러리인 react-cookie와 axios interceptor를 사용했다.
로그인 시 서버로부터 응답을 받으면 accessToken은 쿠키에, refreshToken은 로컬 스토리지에 setCookie()로 저장하고,
axios header의 authorization에 getCookie()로 accessToken을 가져와 넣어주었다.
import { Cookies } from 'react-cookie';
const cookies = new Cookies();
export const setCookie = (name: string, value?: string, options?: object) => {
return cookies.set(name, value, { ...options });
};
export const getCookie = (name: string): string => {
return cookies.get(name) as string;
};
export const removeCookie = (name: string) => {
return cookies.remove(name);
};
아래는 로그아웃, refreshToken으로 accessToken을 재발급 받아 다시 요청하는 코드까지 작성된 부분이다.
import axios, { AxiosError, AxiosRequestConfig } from 'axios';
import { setCookie } from './cookie';
import { LoginResponse } from './type';
export const axiosInstance = axios.create({
baseURL: process.env.NEXT_PUBLIC_SERVER_URL,
withCredentials: true,
});
// 로그아웃
export const logout = () => {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
window.location.href = '/';
};
// accessToken 만료 시 refreshToken 전달
export const postRefreshToken = async () => {
const refreshToken = localStorage.getItem('refreshToken');
const response = await axiosInstance.post<LoginResponse>(
'/api/login/access',
{},
{
headers: {
Authorization: `Bearer ${refreshToken}`,
},
},
);
return response;
};
// 요청 인터셉터
axiosInstance.interceptors.request.use(
(config) => {
const accessToken = localStorage.getItem('accessToken');
if (accessToken) {
config.headers['Authorization'] = `Bearer ${accessToken}`;
}
return config;
},
(error: AxiosError) => {
return Promise.reject(error);
},
);
// 응답 인터셉터
axiosInstance.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as AxiosRequestConfig & {
_retry?: boolean;
};
// 만료된 accessToken
if (error && error.code === 'AUTH_3') {
originalRequest._retry = true;
try {
const response = await postRefreshToken();
if (response.status === 200) {
const newAccessToken = response.data.data.accessToken;
const newRefreshToken = response.data.data.refreshToken;
localStorage.setItem('accessToken', newAccessToken);
setCookie('refreshToken', newRefreshToken);
if (originalRequest.headers) {
originalRequest.headers = {
Authorization: `Bearer ${newAccessToken}`,
};
}
return axiosInstance(originalRequest);
}
} catch (e) {
return Promise.reject(e);
}
}
// 유효하지 않거나 일치하지 않거나 만료된 refreshToken
if (
error &&
(error.code === 'AUTH_2' ||
error.code === 'AUTH_5' ||
error.code === 'AUTH_4')
) {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
// 사용자에게 알림
alert('인증 세션이 만료되었습니다. 다시 로그인해 주세요.');
logout();
}
return Promise.reject(error);
},
);
우선 이 방식으로 로그인을 구현한 것은 잘못된 방식이었다.
첫 번째, next의 서버 사이드 컴포넌트는 로컬 스토리지, 세션 스토리지와 같은 웹 스토리지에 접근할 수 없다.
웹 스토리지는 브라우저 API로 클라이언트 측에서만 사용할 수 있기 때문이다.
만약 로그인 및 인가를 위해 axiosInstance를 사용하면 매번 로컬 스토리지에서 쿠키를 가져와야 하는데,
이는 'use client'를 선언한 클라이언트 사이드 컴포넌트에서만 사용할 수 있고, 서버 사이드 컴포넌트에서는 사용할 수 없게 된다.
RSC를 사용하지 못하게 되면 Next가 아니라 그냥 React가 되어버리기 때문에 Next를 사용하는 의의가 사라진다.
두 번째, 이 부분은 잘 이해하지 못했지만... axios보다 fetch를 지향하자!
- fetch
- 브라우저 내장 API로 설치할 필요도, 별도 종속성이 없고 번들 크기도 증가시키지 않는다.
- next에서는 fetch를 확장해서 사용해 서버에 요청할 때 캐싱, 재검증 등을 설계할 수 있도록 하고 있다. (next 지원)
- 클라이언트 사이드, 서버 사이드 어느 쪽에서든 사용할 수 있다.
일단 사용하는 프레임워크에서 내장되어 강력하게 지원하고 있는 api를 사용하는 게 더 맞다는 생각이 들기도 하거니와,
axios를 사용하면 클라이언트 사이드랑 서버 사이드에서 코드를 다르게 짜야 하는...? 뭔가 복잡해보이는 게 있었다.
그래서 결론은 웹 스토리지를 사용하는 게 아니라 쿠키를 사용하되,
쿠키를 무조건 서버 쪽에서 세팅하고, 서버 쪽에서만 사용할 수 있도록 수정해야 했다.
그리고 이 기능을 구현할 때는 fetch를 사용하는 것으로 대충 정리할 수 있었다.
여기서 또 난관이... 쿠키를 어떻게 세팅하지?
그리고 이러면 인가에서 interceptor를 구현하지?
일단 route handler를 이용해보자
소셜 로그인을 구현하는 로직은 사실 비교적 간단하다.
1. 예를 들어 카카오톡 로그인 버튼을 클릭하면 카카오 공식 문서에 있는대로 새 url을 띄워준다.
이 때 redirect uri는 클라이언트에서 접근할 수 있어야 한다. (8080 안됨)
const kakaoLogin = () => {
window.location.href = `https://kauth.kakao.com/oauth/authorize?client_id=${process.env.NEXT_PUBLIC_KAKAO_CLIENT_ID}&redirect_uri=${process.env.NEXT_PUBLIC_KAKAO_REDIRECT_URI}&response_type=code`;
};
2. 정상적으로 진행되었다면 url에 code=2313123 같은 값이 들어온다.
이 code 값을 useSearchParams로 떼어내고 백엔드에 보내준다.
여기서도 이슈가 있다.
2번 스텝을 밟을 때, useEffect로 컴포넌트가 마운트 되자마자 code값을 떼어내 서버에게 전달해야 한다.
전달해 서버로부터 토큰을 받으면 해당 값을 서버 쪽 쿠키에 저장해야 하는데,
useEffect를 사용하려면 클라이언트 사이드 컴포넌트여야 한다.
쿠키 저장은 클라이언트 사이드에서 못하는데, useEffect를 써야만 한다...?
아 어쩌란 말이냐... 했는데 이럴 때 route handler를 사용하면 되는 것이었다.
route handler를 사용하면 웹 요청 및 응답 API를 사용해 지정된 '경로'에 대한 사용자 지정 요청 핸들러를 만들 수 있다.
우리는 app router를 쓰고 있기 때문에 app/api 폴더를 만들어 기존 파일 경로와 똑같이 작성해주면 된다.
좀 더 간단하게 설명하면, useEffect까지는 클라이언트 사이드 컴포넌트에서 사용하되,
백엔드에 요청을 보내고 쿠키를 받는 로직은 서버 사이드 컴포넌트에서 진행하려고 한다.
이때 서버 사이드 컴포넌트가 route handler가 되는 것이다.
경로는 이렇게 지정해주면 된다.
나는 app/google/oauth/index.tsx에서 code를 받고,
이것을 app/api/google/oauth/route.ts로 전달한다.
const response = await fetch(`/api/google/oauth?code=${GOOGLE_CODE}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ code: GOOGLE_CODE }),
});
웹 서버로 보내는 것이기 때문에 fetch 요청에는 저렇게 작성해주면 된다.
이제 route.ts를 작성해보자.
import { error } from 'console';
import { NextRequest, NextResponse } from 'next/server';
import { setAuthCookies } from '@/apis/server-cookie';
import { LoginResponse } from '@/types/authType';
export async function POST(request: NextRequest): Promise<NextResponse> {
const { searchParams } = new URL(request.url);
const code = searchParams.get('code');
if (!code) {
return NextResponse.json(
{ error: '코드가 누락되었습니다.' },
{ status: 400 },
);
}
const res = await fetch(
`${process.env.NEXT_PUBLIC_SERVER_URL}/login/google`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Origin: `${process.env.NEXT_PUBLIC_LOGIN_URL}`,
},
body: JSON.stringify({ code }),
},
);
if (!res.ok) {
console.error('Error fetching data:', error);
return NextResponse.json(
{ error: '토큰을 확인해주세요.' },
{ status: res.status },
);
}
const data = (await res.json()) as LoginResponse;
// 쿠키 설정
setAuthCookies(data.data);
return NextResponse.json({ data }, { status: res.status });
}
NextRequest는 타입으로 지금 요청한(아까 그 클라이언트 사이드에서 요청한 것)것을 request로 받고 있다.
이제 원래 클라이언트 사이드에서 백엔드로 요청하려던 것을 이곳에서 요청해주면 된다.
response를 받았다면 이것을 쿠키값으로 넣어주면 끝이다.
그래서 쿠키 세팅 그거 어떻게 하는 건데
마지막... 은 아니지만 이 단계의 마지막 관문은 서버 쪽 쿠키 세팅이었다. (아직도 용어 구분이 헷갈린다.)
나는 이때까지 위에서 언급한대로 react-cookie의 setCookie라는 함수로 쿠키를 세팅했었다.
그러나 이것은 클라이언트에서 사용하는 쿠키...
서버 사이드에서 쿠키를 읽고 설정할 수 있도록 하려면
next에서 지원하는 next/headers의 cookies()를 사용하면 된다.
import { cookies } from 'next/headers';
export interface TokenData {
accessToken: string;
refreshToken: string;
}
// 쿠키 세팅
export function setAuthCookies(tokenData: TokenData): void {
const cookieStore = cookies();
// 엑세스 토큰
cookieStore.set('accessToken', tokenData.accessToken, {
maxAge: 3600, // 1시간
httpOnly: true,
secure: true,
});
// 리프레시 토큰
cookieStore.set('refreshToken', tokenData.refreshToken, {
maxAge: 7 * 24 * 3600, // 7일
httpOnly: true,
secure: true,
});
}
// 쿠키 삭제
export function clearAuthCookies(): void {
const cookieStore = cookies();
cookieStore.delete('accessToken');
cookieStore.delete('refreshToken');
}
위의 route.ts에서 받은 response를 setAuthCookies에 넣어준 게 이 로직이었다.
이렇게 하면 서버 쪽으로 쿠키 세팅이 완료된다.
httpOnly를 걸어놓았기 때문에 클라이언트 쪽에서는 쿠키에 접근할 수 없다.
나는 cookies로 서버에서 쿠키 세팅/관리하고 getCookie로 클라이언트에서 쿠키를 가져올 수 있다고 생각했는데,
httpOnly라서 undefined가 뜬다.
하지만 요청하는 웹 서버에는 쿠키가 있기 때문에 요청 자체는 정상적으로 되고 있었다.
이걸 모르고 이상한 질문을 남기기도 함... ^^
요약하자면 클라이언트 사이드 컴포넌트에서는 웹 서버로 요청을 보내거나 use client를 사용해야 하는 hook을 사용하고,
쿠키와 같이 서버 사이드에서만 사용할 수 있는 것은 route handler를 통해 일종의 경유를 해서 사용하면 된다!
인가를 위해 요청 시 쿠키 전달하기
api 요청을 할 때마다 header의 Authorization에 accessToken을 전달해야 한다.
axios를 사용할 때는 create로 instance화 해서 사용했는데 fetch로도 비슷하게 만들었다.
/**
* fetchData로 요청 시 코드 중복 및 휴먼 에러를 최소화 합니다.
* @param endpoint 요청할 엔드포인트를 전달합니다.
* @param method 요청의 method를 지정합니다.
* @param body method에 따라 body가 필요할 경우 지정합니다.
* @returns 요청에 대한 응답을 받습니다.
*/
import { cookies } from 'next/headers';
export async function fetchData<T>(
endpoint: string,
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH',
body?: object,
): Promise<T> {
const BASE_URL = process.env.NEXT_PUBLIC_SERVER_URL;
const url = `${BASE_URL}${endpoint}`;
const accessToken = cookies().get('accessToken')?.value;
const headers: HeadersInit = {
'Content-Type': 'application/json',
};
if (accessToken) {
headers['Authorization'] = `${accessToken}`;
}
const response = await fetch(url, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
console.error('요청을 다시 확인해주세요.', response.statusText);
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json() as Promise<T>;
}
이런 식으로 endpoint, method, body를 인자로 받는 fetchData 함수를 만들었다.
BASE_URL은 우리 백엔드 서버고, 여기에 endpoint를 붙여 url을 작성한다.
다음 headers는 HeadersInit 타입을 갖게 한다. 안 그러면 타입 에러가 난다.
Element implicitly has an 'any' type because expression of type '"Authorization"' can't be used to index type '{ 'Content-Type': string; }'. Property 'Authorization' does not exist on type '{ 'Content-Type': string; }'
'Authorization' 키를 사용하려고 하는데 해당 키가 정의된 객체 타입에 존재하지 않아 발생하는 에러다.
따라서 HeadersInit이라는 타입을 지정해주어야 한다.
accessToken을 헤더에 넣어주고 요청을 날린다.
토큰의 유효성 확인과 재발급 구현하기
이제 요청을 보낼 때 토큰을 잘 넣어주는데, 이게 유효한지, 만료되진 않았는지, 재발급은 어떻게 하는지를 구현해보겠다.
[TO DO]
1. accessToken이 만료되면 refreshToken을 전달해 새 accessToken을 발급 받고 이전 요청을 재시도 해야 한다.
2. refreshToken까지 만료되면 새로 로그인을 하도록 응답을 줘야 한다.
이전에는 이것을 axios interceptor로 구현했는데, fetch로는 어떻게 해야 할지 모르겠는 것이다...
직접 구현하면 된다고 하는데 일단 내 빡머리로는 이해하지 못했고 (ㅠㅠ) 시간이 너무 없어서 fetch로 interceptor 구현은 뒤로 미뤘다.
대신 middleware를 사용하기로 했다.
1. accessToken은 있는데 refreshToken이 없다면 로그인 페이지로 리다이렉션
2. refreshToken은 있는데 accessToken이 없다면 TO DO의 1번대로 진행한다.
middleware는 또 뭔데
미들웨어를 사용하면 요청이 완료되기 전에 코드를 실행할 수 있다.
그러면 요청에 따라 요청 또는 응답 헤더를 다시 작성, 리다이렉션, 수정, 직접 응답 등으로 응답을 수정할 수 있다.
미들웨어는 캐시된 콘텐츠와 경로가 일치하기 전에 실행되기 때문에 경로 매칭을 참조해야 한다.
거의 interceptor랑 비슷하다!
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { NewTokenData } from './apis/refresh-token';
export async function middleware(request: NextRequest) {
let accessToken = request.cookies.get('accessToken')?.value;
const refreshToken = request.cookies.get('refreshToken')?.value;
const loginPageRegex = /^\/login$/;
const isLoginPage = loginPageRegex.test(request.nextUrl.pathname);
// NOTE: token 없으므로 login page로 리다이렉트
if (!refreshToken) {
return NextResponse.redirect(new URL('/login', request.url));
}
// NOTE: accessToken 재발급
if (refreshToken && !accessToken) {
const responseData = await fetch(
`${process.env.NEXT_PUBLIC_SERVER_URL}/login/refresh`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: refreshToken,
},
},
);
const data = (await responseData.json()) as NewTokenData;
accessToken = `Bearer ${data?.data?.accessToken}`;
const response = NextResponse.next();
response.cookies.set('accessToken', accessToken, {
maxAge: 3600,
httpOnly: true,
secure: true,
});
response.headers.set('Authorization', accessToken);
// NOTE: 로그인 페이지일 경우, '/'경로로 리다이렉트
if (isLoginPage) {
return NextResponse.redirect(new URL('/', request.url));
}
// NOTE: 로그인 페이지가 아닌 경우, 토큰 발급만 진행
return response;
}
}
export const config = {
matcher: [
'/((?!api|_next/static|_next/image|favicon.ico|fonts|images|login|kakao/oauth|google/oauth).*)',
],
};
1. accessToken은 있는데 refreshToken이 없다면 로그인 페이지로 리다이렉션
2. refreshToken은 있는데 accessToken이 없다면 TO DO의 1번대로 진행한다.
위에 작성한 그대로다.
refreshToken은 swagger에 적힌 api 엔드포인트로 POST 요청을 보낼 때 사용하고,
새로 토큰을 받아오면 이것을 쿠키와 헤더에 작성해준다.
만약 /login에 있다면 / 경로로 이동 시키고, 그렇지 않다면 토큰만 발급하도록 한다.
이 때 경로 매칭은 저 경로들이 아닐 때, 즉 저것들 이외의 모든 경로에서 미들웨어가 동작하게 하는 것이다.
예를 들어 우리가 기록을 하는 페이지가 /record라면, 해당 페이지에서도 토큰이 유효한지 확인해야 한다.
이렇게 /recode/:path*, /memory/:path* 등으로 적어도 되지만 페이지가 많거나 추가된다면 좀 귀찮아진다.
우리는 기능이 몇 개 없어서 처음엔 하드코딩 했는데 팀원의 도움으로 정규식을 사용하니 더욱 간단해졌다.
이렇게 next로 소셜 로그인을 하는 과정을 정리해 보았다.
next에 대해 그래도 기본적인 건 이해하고 있다고 생각했는데, 생각보다 너무 어려웠다.
React와 원리 자체가 다르다는 것을 머리로만(머리로도 알긴 한건가?;) 알고 있어서 실수도 많았고...
그래도 정리를 하면서 어느 정도 이해한 것 같다.
아마도...
'🛠️ 프로젝트 > ➕ 디프만 15기' 카테고리의 다른 글
[디프만 15기] next-auth 없이 웹에서 애플 로그인 구현하기 (0) | 2024.09.10 |
---|---|
[디프만 15기] 공통 컴포넌트 리팩토링과 UI 테스트 (feat. pandaCSS, storybook) (0) | 2024.08.20 |
[디프만 15기] pandaCSS 맛보기 (0) | 2024.07.30 |
[디프만 15기] 서류 지원 및 면접 후기 (+ 최종 합격) (0) | 2024.05.26 |