Intro
안녕하세요.
오늘은 Next.js에서 next-auth 없이 애플 로그인을 구현한 과정을 정리하려고 합니다.
소셜 로그인을 구현한다면 대부분 카카오톡, 구글, 네이버를 많이 떠올리실 것 같은데요.
이번 저희 팀의 서비스는 앱이어야 접근성이 높아지는 '기록' 서비스이기 때문에 앱 출시를 해야 했습니다. (ㅠㅠ)
iOS에서 설치할 수 있는 앱, 즉 앱스토어에 출시하기 위해서는 반드시 애플 로그인이 구현되어야 합니다.
그렇기 때문에 이번에 처음으로 애플 로그인을 구현하게 되었어요.
다른 소셜 로그인들은 수월하게 진행한 반면 애플 로그인은 매우 챌린징한 요소들이 있었습니다.
1. 부족하고 오래된 레퍼런스
애플 로그인을 웹에서, Next.js로 구현한 레퍼런스가 거의 없었습니다.
있어도 최소 6개월 ~ 몇 년 전 글이다 보니 참고하기가 어려웠어요.
2. 공식 문서의 진입 장벽
관련한 공식 문서가 있지만 모두 영어로 되어 있어서 친절하진 않았던 것 같아요.
아무래도 카카오톡과 같은 국내 서비스의 공식 문서에 길들여져 있었기 때문인 듯
3. localhost에서 테스트 할 수 없음
이전에는 가능했다고 하는데 최근에는 애플의 정책이 변함에 따라 http에서 테스트를 할 수 없게 되었습니다.
그 말은 프론트가 로컬에서 테스트를 할 수 없다는 뜻이고, 매번 배포를 해야만 확인할 수 있는 것입니다.
게다가 저도 Next.js라는 프레임워크를 이번에 처음 사용하였기 때문에
어디까지가 가능한 일이고 어디까지가 불가능한 것인지 백엔드의 질문에 쉽게 답할 수 없었어요.
시간은 없는데 백엔드의 말은 이해가 안되고, 검색을 하면 다 next-auth만 나오니 너무 후회가 되고 심리적 압박이 상당했습니다 🥲
나 때문에 앱 출시가 늦어지면 어떡하지 하는 생각에 패닉이 왔는데,
백엔드 분이 제 코드를 디벨롭 해주시면서 구현을 하게 되었습니다.
개발 순서
개발자 계정으로 등록을 하는 부분은 생략하겠습니다.
해당 부분은 계정을 갖고 있는 백엔드 분이 진행해주셔서 자세한 스콥은 공식 문서를 확인하는 게 좋을 것 같아요.
이번에 저희가 구현한 방법은 공식 문서에 오피셜로 기재되어 있는 것이 아니라,
어떤 부분을 보고 백엔드 분이 따로 생각하신 방법이라 정석이 아닌 점 참고해주세요!
등록이 다 되어 있다고 가정하고 우리 스위미에서의 프로세스를 정리해보겠습니다.
1. 애플 로그인 버튼 클릭
- 정해진 UI 규격이 있으므로 앱 출시 예정이라면 준수해야 함
2. appleid와 관련된 url로 이동
https://appleid.apple.com/auth/authorize?client_id=${process.env.NEXT_PUBLIC_APPLE_CLIENT_ID}&redirect_uri=${process.env.NEXT_PUBLIC_APPLE_REDIRECT_URI}&response_type=code id_token&scope=name email&response_mode=form_post
3. 해당 사이트에서 이메일, 비밀번호를 입력해 로그인 절차 진행
4. Apple 서버에서 설정한 redirectURI(서버 컴포넌트)로 POST 요청을 보냄
- payload의 formdata 형식으로 들어옴
- code, id_token, user(첫 로그인 시에만 제공됨 - 추후에는 제공 안됨)이 담겨 있음
5. 위 데이터를 우리 서비스의 백엔드 서버로 POST 요청을 보냄
- 우리의 경우 /login/apple로 위 내용을 body에 담아 줌
6. 요청과 body를 확인한 우리 서비스의 백엔드 서버는 미리 약속한 회원 정보와 관련된 데이터를 response로 제공함
7. 현재 서버 컴포넌트에 있으므로 이제 redirect할 클라이언트 컴포넌트로 이동
- url에 회원 정보와 관련된 데이터를 담아 이동함
8. redirect한 클라이언트 컴포넌트에 도착하면 url에 있는 회원 정보와 관련된 데이터를 추출해 로그인을 진행함
3번까지는 작성한 그대로이기 때문에 4번부터 코드를 보도록 하겠습니다!
구현하기
만약 우리가 Apple 서버로부터 user 정보를 받지 않는다면 일반적으로 구현하는 redirect 방식으로 소셜 로그인을 구현할 수 있지만,
user 정보를 받아야 하기 때문에 redirectURI로 POST 요청을 받아야 합니다.
이 때 redirectURI는 https://www.swimie.life/api/apple/oauth 처럼 api/~로 구성되어 있습니다.
즉 route handler를 통해 POST 요청을 받고 이를 서비스 백엔드 서버로 보내주어야 합니다.
먼저 전체 코드는 아래와 같습니다.
import { NextRequest, NextResponse } from 'next/server';
import { parse } from 'querystring';
import { setAuthCookies } from '@/apis/server-cookie';
import { LoginResponse } from '@/types/authType';
export async function POST(request: NextRequest): Promise<NextResponse> {
try {
const body = await request.text();
const formData = parse(body);
const code = formData['code'];
const idToken = formData['id_token'];
const userData = formData['user'];
if (!code || !idToken) {
return NextResponse.json(
{ error: 'code 또는 id_token이 누락되었습니다.' },
{ status: 400 },
);
}
const bodyData = {
code: code.toString(),
idToken: idToken.toString(),
user: userData ? JSON.parse(userData.toString()) : undefined,
};
const res = await fetch(
`${process.env.NEXT_PUBLIC_SERVER_URL}/login/apple`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(bodyData),
},
);
if (!res.ok) {
return NextResponse.json(
{ error: '서버 요청 실패' },
{ status: res.status },
);
}
const data = (await res.json()) as LoginResponse;
const { userId, nickname, profileImageUrl, isSignUpComplete } = data.data;
setAuthCookies(data.data);
const loginUrl = new URL('/apple/test', request.url);
loginUrl.searchParams.set('userId', userId.toString());
loginUrl.searchParams.set('nickname', encodeURIComponent(nickname));
loginUrl.searchParams.set(
'profileImageUrl',
encodeURIComponent(profileImageUrl),
);
loginUrl.searchParams.set('isSignUpComplete', isSignUpComplete.toString());
return NextResponse.redirect(loginUrl, 302);
} catch (error) {
console.error('Error handling POST request:', error);
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
}
}
1) NextRequest 객체의 본문 데이터를 text()와 parse()를 사용해 파싱합니다.
현재 들어온 데이터는 formData 형식이기 때문에 문자열로 읽어오고, 이를 parse를 사용해 객체로 바꿔줍니다.
{
code: '1234',
idToken: 'abcd',
user: {
email: 'john.doe@example.com',
firstName: 'John',
lastName: 'Doe'
}
}
이런 형태가 되었다고 가정합니다.
이 때 user는 처음 가입할 때만 제공되기 때문에 있을 수도 있고 없을 수도 있습니다.
하지만 code와 idToken은 반드시 있어야 합니다.
2) 우리 서비스의 백엔드 서버에 전달하기
code나 idToken이 없는 경우는 에러로 간주합니다.
그렇지 않은 경우에는 값들은 문자열로, user는 parse를 사용해 객체로 바꾸어 전달합니다.
이 때 await으로 fetch를 하는데, 먼저 request에서 필요한 데이터를 받은 후에 진행되어야 하기 때문에 그렇습니다.
3) 응답을 받고 이동하기
const data = (await res.json()) as LoginResponse;
const { userId, nickname, profileImageUrl, isSignUpComplete } = data.data;
setAuthCookies(data.data);
const loginUrl = new URL('/apple/test', request.url);
loginUrl.searchParams.set('userId', userId.toString());
loginUrl.searchParams.set('nickname', encodeURIComponent(nickname));
loginUrl.searchParams.set(
'profileImageUrl',
encodeURIComponent(profileImageUrl),
);
loginUrl.searchParams.set('isSignUpComplete', isSignUpComplete.toString());
return NextResponse.redirect(loginUrl, 302);
구조 분해 할당으로 필요한 값들만 끄집어 냈습니다.
data.data에 토큰들이 있어서 해당 부분은 setAuthCookies에서 쿠키 세팅을 해줍니다.
그리고 현재는 어떤 페이지가 아니라 route.ts에 있기 때문에 UI를 그릴 수 있도록 클라이언트 컴포넌트로 이동합니다.
이를 위해 /apple/test로 이동을 하되 카카오톡 로그인을 할 때처럼 잠깐 경유하는 그런 역할을 하도록 합니다.
따라서 searchParams에 userId, nickname, profileImageUrl, isSignUpComplete을 넣어줍니다.
이때 이 값들은 문자열이어야 하기 때문에 nickname이나 profileImageUrl는 %20 같은 공백으로 인한, 또는 유저가 입력하여 특수 문자가 발생할 수 있으므로 인코딩 해서 보내줍니다.
원래 백엔드 분이 data를 통째로 url에 올리셨는데 이렇게 하면 토큰들까지 다 노출되어 버려서
토큰은 따로 저장하고 민감하지 않은 나머지 정보들만 url에 params로 올려줬습니다.
이제 이동을 합니다.
이때 302를 적어주지 않으면 307, 308을 거쳐 405 에러(허용되지 않은 메서드)가 발생합니다.
이유는 redirect를 하기 위함입니다.
302는 클라이언트가 요청한 리소스가 임시로 다른 URL로 이동됨을 의미합니다.
이를 통해 브라우저가 새로운 URL로 자동으로 리디렉션 됩니다.
307(Temporary Redirect): 클라이언트가 HTTP 메서드를 요청 본문과 그대로 유지한 채 전달
308(Permanent Redirect): 영구적인 리디렉션이 발생함
307, 308에서는 POST가 그대로 유지되지만 이제는 GET으로 변경이 필요합니다. (중복 데이터 전송 및 문제 방지)
이 부분이 해결되지 않아 런칭 데이 때는 애플 로그인을 숨겨야 했네요... 🥲
LoginResponse는 이렇게 생겼습니다.
export interface LoginResponse {
status: number;
code: string;
message: string;
data: {
userId: number;
nickname: string;
accessToken: string;
refreshToken: string;
profileImageUrl: string;
isSignUpComplete: boolean;
};
}
4) 이동 후 로그인 완료하기
그렇다면 /apple/test/userId=... 이런 식으로 url이 바뀐 채 /apple/test/index.tsx에 오게 됩니다.
마저 로그인 처리를 완료하고 메인으로 보내주겠습니다.
const Page = () => {
const router = useRouter();
const searchParams = useSearchParams();
const setAuth = useSetAtom(AuthInfoAtom);
useEffect(() => {
const userId = searchParams.get('userId');
const nickname = searchParams.get('nickname');
const profileImageUrl = searchParams.get('profileImageUrl');
const isSignUpComplete = searchParams.get('isSignUpComplete');
if (userId && nickname && profileImageUrl && isSignUpComplete !== null) {
setAuth({
isLogined: true,
nickname: decodeURIComponent(nickname),
userId: Number(userId),
});
if (isSignUpComplete === 'true') {
router.push('/');
} else {
router.push('/join/nickname');
}
}
}, [router, searchParams, setAuth]);
return (
<>
<LoginLoading />
<LoginScreen isAnimate={false} />
</>
);
};
url에 있는 회원 정보와 관련된 값을 추출하고 전역 상태 관리에 넣어줍니다.
이때 nickname은 다시 디코드를 해주고, userId는 Number로 형 변환을 합니다.
아까 url에 넣기 위해 문자열로 바꾸었기 때문입니다.
isSignUpComplete는 불리언 값인데, true/false가 아니라 이 필드 자체가 존재하는지 null로 확인합니다.
만약 null이 아니고 (불리언 값 중 하나) 나머지 필드들이 다 있다면 로그인이 완료됩니다.
isSignUpComplete이 true라면 이미 회원가입을 완료했기 때문에 메인으로 넘어가고,
그렇지 않다면 이제 막 가입한 상태이기 때문에 회원가입 페이지로 넘어갑니다.
이때도 isSignUpComplete는 문자열로 들어왔기 때문에 문자열로 비교해주어야 합니다.
이렇게 구현하는 방법이 어디에도 명시되어 있지 않아 난항이었지만 하고 나니 조금은 이해가 됩니다.
이 방법 외에도 다르게 구현하는 방법이 있다면 알고 싶네요 🍎
'🛠️ 프로젝트 > ➕ 디프만 15기' 카테고리의 다른 글
[디프만 15기] 공통 컴포넌트 리팩토링과 UI 테스트 (feat. pandaCSS, storybook) (0) | 2024.08.20 |
---|---|
[디프만 15기] Next로 소셜 로그인 구현하기 (feat: cookie, route handler, middleware) (0) | 2024.08.05 |
[디프만 15기] pandaCSS 맛보기 (0) | 2024.07.30 |
[디프만 15기] 서류 지원 및 면접 후기 (+ 최종 합격) (0) | 2024.05.26 |