스팟잇서비스는 팝업스토어의 정보제공과 예약을 도와주는 서비스입니다. 유저기능을 구상하면서 사용자가 팝업현장에서 웨이팅하는 상황을 고려하여 복잡한 로컬 회원가입보다 간편한 카카오로그인방식을 선택하였습니다. 이번 글에서는 프론트엔드에서 카카오 로그인 방식을 구현한 방법과 트러블슈팅경험을 설명해보겠습니다.
(카카오 개발자 페이지에서 앱 등록과 권한 설정은 이미 되어 있다는 가정하에 글을 작성하였습니다.)
우리는 새로운 웹사이트나 앱을 사용할 때, 구글,네이버,카카오와 같은 소셜계정으로 간편하에 회원가입 및 로그인을 할 수 있습니다. 이 떄 사용되는 프로토콜이 바로 OAuth입니다. 이 방법은 소셜사이트에서 나의 정보중 일부를 제3의 서비스와 공유해서 가능한 것 입니다. 이렇듯 OAuth는 서비스들 사이에서 안전하게 고객의 데이터를 주고 받기 위한 배경을 가지고 탄생했습니다.
OAuth는 인터넷 사용자들이 비밀번호를 제공하지 않고 다른 웹사이트 상의 자신들의 정보에 대해 웹사이트나 애플리케이션의 접근 권한을 부여할 수 있는 공통적인 수단으로서 사용되는, 접근 위임을 위한 개방형 표준이다.
즉, 제 3자의 클라이언트에게 보호된 리소스를 제한적으로 접하게 해주는 프레임워크이다.
참고로 2010년에 OAuth 1.0에 대한 공식 표준안이 발표되었는데, 여기에는 문제점이 있었습니다. 대표적으로 복잡한 구현, 모바일 어플리케이션에서의 지원 부족, 만료되지 않은 AccessToken으로 인한 보안취약점들이 존재했습니다. 이런 Oauth 1.0의 문제점을 보완하여 2012년에 Oauth 2.0이 발표되었습니다.
Oauth의 구성요소를 먼저 살펴볼게요.
Resource owner (리소스 소유자) : 우리 서비스의 사용자
Client(클라이언트)
→ 사용자의 정보에 접근하려는 제3의 서비스(=우리 서비스)
→ 이름이 클라이언트인 이유는 우리 서비스 서버가 Resource Server
에게 필요한 자원을 요청하고 응답하는 관계이기 때문이에요.
Authorization Server(인증서버)
사용자
는 이 서버로 id, pw를 넘겨서 Authorization Code
를 발급받아요.Client(우리서버)
: 이 서버로 Authorization Code
를 넘겨서 Token
을 발급받아요.Resource Server(리소스서버)
Client(우리서버)
는 Token
을 이 서버로 넘겨 개인정보(보호된 리소스)
를 응답받아요.(1) 리소스 소유자가 클라이언트에게 인증을 허가해요
(2) 리소스 소유자의 동의가 확인되면, 인증 서버는 클라이언트에게 AccessToken과 RefreshToken을 발급해요.
Client(우리서버)
는 Authorization Server로 부터 access token(비교적 짧은 만료기간을 가짐) 과 refresh token(비교적 긴 만료기간을 가짐)을 함께 부여 받아요.카카오 개발자도구의 문서에는 OAuth 2.0 프레임워크로 카카오로그인 과정이 친절하게 안내되어있습니다. 위 내용이 조금 어려웠라도 문서를 차분히 읽어내려가면 충분히 이해할 수 있어요.
해당 과정을 이해했다면 이제 구현에 들어가면 됩니다. 이 과정에서 Authorization Endpoint(인가요청주소)와 Redirect URI를 다루게 됩니다.
Authorization endpoint(인가 요청 주소)
인가 요청 주소는 보통 프론트가 호출하며 사용자를 카카오 로그인 화면으로 보내기 위해 카카오가 제공하는 URL 입니다. 보통 아래 처럼 생겼습니다.
https://kauth.kakao.com/oauth/authorize?client_id=...&redirect_uri=...
Redirect URI
인가 요청 주소를 살펴보면 쿼리로 redirect_uri
가 있는 것을 알 수 있습니다. Redirect URI
는 카카오 로그인후 사용자를 돌려보낼 프론트 or 서버주소가 되며,사용자가 id와 password를 입력해서 받은 Authcode(인가코드)
가 함께 동봉됩니다.
백엔드 개발자와 함께 의논하여 Redirect URI를 어느쪽으로 할지 정하면 됩니다. Redirect URI
에 프론트주소를 넣게되면 프론트가 인가코드를 받아서 백엔드서버를 다시 호출하는 구조이고, 서버 주소를 넣으면 서버가 바로 인가코드를 받아서 처리하면 됩니다.
https://myapp.com/oauth/callback
https://api.myapp.com/auth/kakao/callback
저희 팀에서는 인가코드가 노출되는 것을 막기위해 서버 측으로 redirect uri를 설정해주도록 하겠습니다. 개념은 복잡했지만 프론트에서는 사용자가 로그인 버튼을 누르면 인가요청주소를 열어주기만 하면, 모든 과정은 서버에서 처리된다음 우리 서비스의 토큰을 쿠키에 실어 다시 프론트페이지로 리디렉션하도록 구성했어요.
카카오 로그인 과정에서 고민한 부분은 보호된 경로(로그인된 사용자만 볼수 있음)로 접근시 로그인 후 다시 되돌아가도록 하는 것이였어요.스팟잇 서비스의 "현장 대기"기능에서는 QR 코드를 통해 바로 웨이팅폼 페이지로 접근할 수 있는데, 이때 로그인 되지 않은 사용자의 경우 로그인 후 다시 웨이팅 페이지로 돌아와야 합니다. 때문에 useKakaoLoginUrl
훅에서 로그인 후 되돌아갈 페이지정보를 서버에게 state
에 담아 넘겨주도록 Authorization endpoint
를 만드는 방식으로 구현했어요.
카카오 로그인 버튼을 누르면 Authorization endpoint
를 생성하여 로그인 창을 엽니다.
export default function Login() {
const kakaoLoginUrl = useKakaoLoginUrl({
clientId: process.env.NEXT_PUBLIC_CLIENT_ID!,
redirectUri: process.env.NEXT_PUBLIC_REDIRECT_URL!,
});
const handleLogin = () => {
if (kakaoLoginUrl) {
window.location.href = kakaoLoginUrl;
}
};
return (
<div>
/** ... */
<KakaoLoginButton onClick={handleLogin} disabled={!kakaoLoginUrl} />
</div>
);
}
useKakaoLoginUrl 훅에서는 현재 url의 쿼리스트링에서 redirect_path값을 가져와 Authorization endpoint
의 state값에 담아줍니다. 이렇게 하면 서버측에서 로그인을 완료한후 다시 redirect_path값으로 사용자를 되돌려보내줄 수 있어요.
export function useKakaoLoginUrl({
clientId,
redirectUri,
}: {
clientId: string;
redirectUri: string;
}) {
const [url, setUrl] = useState('');
useEffect(() => {
if (typeof window === 'undefined') return;
const params = new URLSearchParams(window.location.search);
const redirectPath = params.get('redirect_path');
const redirectPathAfterLogin = redirectPath
? `/kakao?redirect_path=${redirectPath}`
: '/kakao';
const kakaoUrl =
`https://kauth.kakao.com/oauth/authorize` +
`?client_id=${clientId}` +
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
`&response_type=code` +
`&state=${encodeURIComponent(redirectPathAfterLogin)}`;
setUrl(kakaoUrl);
}, [clientId, redirectUri]);
return url;
}
서버에서는 사용자를 로그인 처리한 뒤, 성공했다면 /kakao
로, 실패했다면 실패 코드를 query에 넣어 /login/fail?reason=CODE
로 리다이렉트 해줍니다. 실패하는 케이스는 백엔드 개발자와 의논하여 실패Code에 따라 메세지를 매핑하여 로그인이 처리된 상황을 안내할 수 있도록 했어요.
// 카카오 로그인 실패시 보여줄 페이지
export default async function LoginFailPage({
searchParams,
}: {
searchParams: Promise<{ reason: KakaoLoginFailType }>;
}) {
const { reason } = await searchParams;
const DEFAULT_FAIL_MESSAGE = '알 수 없는 오류가 발생했어요.';
const message = KAKAO_LOGIN_FAIL_REASON_MAP[reason] ?? DEFAULT_FAIL_MESSAGE;
return (
<di>
{/* 제목 및 메시지 */}
<h1 >로그인 실패</h1>
<p>{message}</p>
/**
자세한 ui 구현 내용은 생략
*/
</div>
</div>
);
}
성공할 경우 거쳐가는 /kakao?redirect_path={path}
페이지에서는 (1) 유저 정보를 받아오고 (2) 클라이언트의 전역 상태를 업데이트 하는 과정을 진행합니다. 마지막으로 모든 과정이 마무리되면 로그인전 접근하려던 페이지로 라우팅시켜줍니다.
// 로그인 성공시 페이지
export default function Kakao() {
const {
data: user,
isError,
isSuccess,
} = useQuery({
queryKey: ['auth', 'user'],
queryFn: () => getUserApi(),
retry: false,
staleTime: 5 * 60 * 1000, // 5분
gcTime: 30 * 60 * 1000, // 30분
refetchOnWindowFocus: false,
select: data => (data ? { email: data.email, nickname: data.name } : null),
throwOnError: false,
});
const router = useRouter();
const searchParams = useSearchParams();
const redirectTo = searchParams.get('redirect_path') || '/';
const setUser = useUserStore(state => state.setUser);
const clearUser = useUserStore(state => state.clearUser);
// ❗ useEffect로 분리: isError 처리
useEffect(() => {
if (isError) {
clearUser();
}
}, [isError, clearUser]);
// ❗ useEffect로 분리: 성공 시 사용자 설정 + 라우터 이동
useEffect(() => {
if (user && isSuccess) {
setUser({
email: user.email,
nickname: user.nickname,
role: 'user',
});
router.replace(redirectTo);
}
}, [user, isSuccess, setUser, router]);
return (
<div>
{/* 자세한 ui 스타일링은 생략 */}
<p >로그인 중이이에요</p>
</div>
);
}