카카오 로그인을 처음 구현할 때는 로그인 버튼을 누르고, 전달받은 인가 코드를 백엔드에 보내 토큰을 저장하면 끝날 것이라고 생각했습니다.
하지만 실제로 구현해야 했던 범위는 훨씬 넓었습니다.
401을 반환해도 재발급 요청이 중복되지 않아야 했습니다.결국 카카오 로그인은 하나의 API를 연결하는 작업이 아니라, OAuth 흐름과 앱의 인증 상태를 연결하는 작은 인증 시스템을 만드는 작업에 가까웠습니다.
이 글에서는 제가 작성했던 카카오 로그인 및 인증 세션 API 연동 PR을 기반으로, 현재 구조까지 발전시키며 고민한 내용을 정리했습니다.
이번 구현에서 가장 중요했던 판단은 모든 상태를 React의 useState에 넣지 않는 것이었습니다.
로그인 과정에는 이름은 모두 “상태”지만 수명과 소비자가 전혀 다른 값들이 존재했습니다.
| 상태 | 관리 방법 | 선택한 이유 |
|---|---|---|
| 인증 판정 상태 | useState | 변경 시 화면과 라우팅이 다시 계산되어야 했습니다. |
userId, 온보딩 완료 여부 | useState | 여러 컴포넌트가 구독해야 하는 세션 정보였습니다. |
| 로그인 버튼 이동 중 여부 | 로컬 useState | 버튼 UI에서만 필요한 일시적인 상태였습니다. |
| Callback 실패 여부 | 로컬 useState | 로딩 화면과 실패 화면을 전환하기 위한 상태였습니다. |
| Callback 처리 시작 여부 | useRef | 화면 렌더링과 무관한 일회성 실행 플래그였습니다. |
| 초기 세션 복원 시작 여부 | useRef | 세션 재발급 API의 중복 호출을 방지해야 했습니다. |
| Access Token | 모듈 메모리 | API 클라이언트가 React 외부에서 즉시 읽어야 했습니다. |
| 진행 중인 재발급 요청 | 공유 Promise | 여러 401 요청을 하나의 재발급으로 합쳐야 했습니다. |
OAuth state | sessionStorage | 외부 페이지 왕복 동안 유지되고 탭별로 격리되어야 했습니다. |
따라서 저장 위치는 단순히 사용되는 곳이 전역인지만으로 결정하지 않았습니다.
다음 네 가지 질문을 기준으로 상태를 분리했습니다.
이 기준을 먼저 세우자 useState, useRef, Context, 모듈 변수, Promise, sessionStorage의 역할이 자연스럽게 나뉘었습니다.
인증 관련 코드는 다음과 같이 역할을 분리했습니다.
src/
├─ app/
│ ├─ auth/kakao/callback/page.tsx
│ ├─ (private)/layout.tsx
│ └─ providers.tsx
│
├─ domains/auth/
│ ├─ api/
│ │ ├─ query.ts
│ │ └─ type.ts
│ ├─ model/
│ │ └─ auth.ts
│ └─ features/
│ ├─ auth-session/
│ │ ├─ auth-session-provider.tsx
│ │ └─ auth-entry-guard.tsx
│ └─ kakao-login/
│ ├─ kakao-login-button.tsx
│ ├─ kakao-callback.tsx
│ └─ kakao-oauth.ts
│
└─ shared/api/
├─ api-client.ts
└─ auth-token.ts
각 계층에는 다음과 같은 책임을 부여했습니다.
app은 Route, layout, Provider를 조립했습니다.domains/auth는 카카오 로그인, 인증 세션, 온보딩 분기와 같은 제품의 인증 흐름을 관리했습니다.shared/api는 모든 도메인의 HTTP 요청에 공통으로 적용되는 토큰 첨부와 재발급 처리를 담당했습니다.특히 shared/api가 domains/auth를 직접 import하지 않도록 구성했습니다.
API 클라이언트는 재발급이 필요하다는 사실만 알고, 실제 세션 전환은 AuthSessionProvider가 담당하도록 했습니다. Provider가 재발급 함수를 API 계층에 주입하는 방식으로 app → domains → shared 의존 방향을 유지했습니다.
전체 OAuth 흐름은 다음과 같습니다.

프론트엔드는 카카오에서 전달받은 인가 코드를 백엔드에 전달했습니다. 실제 카카오 토큰 교환과 서비스 Refresh Token 발급은 백엔드가 담당하도록 구성했습니다.
따라서 카카오 Client Secret이나 JWT 서명 키와 같은 비밀값은 프론트엔드에 두지 않았습니다.
stateOAuth의 state는 React state와는 다른 개념입니다.
로그인을 시작할 때 예측하기 어려운 값을 생성해 브라우저에 저장하고, 카카오가 Callback으로 돌려준 값과 비교했습니다. 이를 통해 Callback이 현재 탭에서 시작한 로그인 요청과 연결된 것인지 검증했습니다.
const KAKAO_AUTHORIZE_URL = 'https://kauth.kakao.com/oauth/authorize';
const KAKAO_OAUTH_STATE_KEY = 'kakao-oauth-state';
export const createKakaoAuthorizeUrl = () => {
const clientId = getRequiredEnvironmentVariable(
process.env.NEXT_PUBLIC_KAKAO_REST_API_KEY,
'NEXT_PUBLIC_KAKAO_REST_API_KEY',
);
const redirectUri = getRequiredEnvironmentVariable(
process.env.NEXT_PUBLIC_KAKAO_REDIRECT_URI,
'NEXT_PUBLIC_KAKAO_REDIRECT_URI',
);
const state = crypto.randomUUID();
sessionStorage.setItem(KAKAO_OAUTH_STATE_KEY, state);
const authorizeUrl = new URL(KAKAO_AUTHORIZE_URL);
authorizeUrl.searchParams.set('client_id', clientId);
authorizeUrl.searchParams.set('redirect_uri', redirectUri);
authorizeUrl.searchParams.set('response_type', 'code');
authorizeUrl.searchParams.set('state', state);
return authorizeUrl.toString();
};
sessionStorage를 선택한 이유는 다음과 같습니다.
Callback에서 검증한 값은 즉시 제거했습니다.
export const validateKakaoOAuthState = (state: string | null) => {
const savedState = sessionStorage.getItem(KAKAO_OAUTH_STATE_KEY);
sessionStorage.removeItem(KAKAO_OAUTH_STATE_KEY);
return Boolean(state && savedState && state === savedState);
};
저장된 값을 읽은 후 바로 제거했기 때문에 사실상 한 번만 소비할 수 있도록 만들었습니다.
다만 OAuth state가 모든 보안 문제를 해결하는 것은 아닙니다. 이는 로그인 요청과 Callback을 결속해 로그인 CSRF와 원치 않는 Callback을 방어하기 위한 장치이며, PKCE나 XSS 방어를 대체하지 않습니다.
isRedirectingOAuth state와 별개로 로그인 버튼에는 로컬 UI 상태가 필요했습니다.
export const KakaoLoginButton = () => {
const router = useRouter();
const { status, onboardingCompleted } = useAuthSession();
const [isRedirecting, setIsRedirecting] = useState(false);
useEffect(() => {
if (status === 'authenticated') {
router.replace(onboardingCompleted ? ROUTES.HOME : ROUTES.ONBOARDING);
}
}, [onboardingCompleted, router, status]);
const handleLoginClick = () => {
setIsRedirecting(true);
try {
window.location.assign(createKakaoAuthorizeUrl());
} catch (error) {
setIsRedirecting(false);
Sentry.captureException(error);
}
};
const isDisabled = isRedirecting || status !== 'unauthenticated';
return (
<Button disabled={isDisabled} onClick={handleLoginClick}>
카카오로 시작하기
</Button>
);
};
버튼은 다음 상황에서 비활성화했습니다.
window.location.assign()을 실행해도 실제 페이지가 전환되기 전까지 짧은 시간이 존재합니다. 이때 사용자가 버튼을 여러 번 누르면 OAuth 요청이 중복될 수 있으므로, 클릭 즉시 isRedirecting을 true로 변경했습니다.
URL 생성에 실패하면 다시 false로 복구하고 Sentry에 오류를 기록했습니다.
isRedirecting은 인증 상태가 아니라 해당 버튼에서만 필요한 일시적인 UI 상태입니다. 따라서 전역 Context가 아닌 로컬 useState로 관리했습니다.
Callback에는 code, state, error가 URL query로 전달됩니다.
인가 코드는 일회성이므로 같은 코드를 백엔드에 두 번 전달해서는 안 됩니다. 하지만 개발 환경의 React Strict Mode나 Effect 의존성 변화로 인해 Effect가 다시 평가될 수 있습니다.
따라서 Callback 처리 시작 여부를 useRef로 기록했습니다.
export const KakaoCallback = () => {
const router = useRouter();
const searchParams = useSearchParams();
const { authenticateWithKakao } = useAuthSession();
const hasStartedRef = useRef(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
useEffect(() => {
if (hasStartedRef.current) {
return;
}
hasStartedRef.current = true;
const code = searchParams.get('code');
const state = searchParams.get('state');
const oauthError = searchParams.get('error');
window.history.replaceState(
window.history.state,
'',
window.location.pathname,
);
if (oauthError || !code) {
queueMicrotask(() => {
setErrorMessage('카카오 로그인이 취소되었거나 올바르지 않습니다.');
});
return;
}
if (!validateKakaoOAuthState(state)) {
queueMicrotask(() => {
setErrorMessage(
'로그인 요청을 확인할 수 없습니다. 다시 시도해 주세요.',
);
});
return;
}
authenticateWithKakao(code)
.then(({ onboardingCompleted }) => {
router.replace(onboardingCompleted ? ROUTES.HOME : ROUTES.ONBOARDING);
})
.catch((error: unknown) => {
setErrorMessage(
error instanceof Error
? error.message
: '카카오 로그인에 실패했습니다. 다시 시도해 주세요.',
);
});
}, [authenticateWithKakao, router, searchParams]);
// 로딩 또는 실패 UI를 반환합니다.
};
hasStartedRef를 useState로 만들지 않은 이유는 이 값이 화면에 표시될 필요가 없기 때문입니다.
useState를 사용하면 값 변경으로 불필요한 렌더링이 발생합니다. 반면 useRef는 렌더링 사이에 값을 유지하면서도 다시 렌더링하지 않기 때문에 일회성 부수 효과 실행 여부를 기록하기에 적합했습니다.
Callback 진입 직후 다음 코드를 실행했습니다.
window.history.replaceState(window.history.state, '', window.location.pathname);
이를 통해 다음과 같은 URL을
/auth/kakao/callback?code=...&state=...
다음과 같이 변경했습니다.
/auth/kakao/callback
인가 코드와 OAuth state가 주소창이나 브라우저 방문 기록에 오래 남는 시간을 줄였습니다.
다만 이 처리는 Client Effect가 실행된 이후에 이루어집니다. 최초 HTTP 요청이나 그 이전 단계에서 URL이 한 번도 노출되지 않는다는 의미는 아닙니다.
Callback 컴포넌트는 useSearchParams()를 사용하므로 Client Component여야 했습니다. 대신 route의 page.tsx는 Server Component로 유지하고, 브라우저 API가 필요한 부분만 하위 Client Component로 분리했습니다.
export default function KakaoCallbackPage() {
return (
<Suspense
fallback={
<main>
<div role="status" aria-live="polite">
<h1>로그인 중</h1>
<p>카카오 계정을 확인하고 있어요.</p>
</div>
</main>
}
>
<KakaoCallback />
</Suspense>
);
}
Suspense 경계는 실제로 useSearchParams를 사용하는 Client Component보다 한 단계 위에 배치했습니다. 이를 통해 페이지 전체를 Client Component로 전환하지 않고도 Callback의 로딩 상태를 제공했습니다.
로그인과 토큰 재발급 응답 타입은 직접 중복 선언하지 않고 OpenAPI로 생성한 타입을 사용했습니다.
export type KakaoLoginParams = operations['kakaoLogin']['parameters']['query'];
export type KakaoLoginResponse =
components['schemas']['BaseResponseLoginResponse'];
하지만 TypeScript 타입은 컴파일 시점에만 존재합니다. 실제 서버 응답이 명세와 다르더라도 런타임에서는 자동으로 막아주지 않습니다.
또한 생성된 OpenAPI 타입에서는 응답 필드가 선택적 속성으로 표현될 수 있습니다. 따라서 API 경계에서 필요한 필드를 다시 검증한 뒤, 앱 내부에서 사용할 필수 모델로 변환했습니다.
export interface AuthSession {
userId: number;
accessToken: string;
onboardingCompleted: boolean;
}
const requestLoginSession = async (
request: Promise<KakaoLoginResponse | ReissueResponse>,
): Promise<AuthSession> => {
try {
const response = await request;
if (
!response.success ||
typeof response.data?.userId !== 'number' ||
!response.data?.accessToken ||
typeof response.data.onboardingCompleted !== 'boolean'
) {
throw new Error(response.message || '로그인 처리에 실패했습니다.');
}
return {
userId: response.data.userId,
accessToken: response.data.accessToken,
onboardingCompleted: response.data.onboardingCompleted,
};
} catch (error) {
if (!isKyError(error)) {
throw error;
}
throw new Error(getAuthErrorMessage(error));
}
};
이 경계를 통과한 이후에는 앱 내부에서 accessToken이나 onboardingCompleted가 존재하는지 매번 검사하지 않아도 됩니다.
즉, 다음 두 가지 역할을 분리했습니다.
네트워크 오류도 이 계층에서 사용자용 메시지로 정규화했습니다.
5xx 오류4xx 메시지이를 통해 Callback 컴포넌트가 HTTP 라이브러리의 오류 구조를 직접 알지 않아도 되도록 만들었습니다.
토큰 저장 전략은 다음과 같이 나누었습니다.
| 토큰 | 저장 위치 | 역할 |
|---|---|---|
| Access Token | JavaScript 모듈 메모리 | 일반 API 요청의 Authorization 헤더에 사용했습니다. |
| Refresh Token | 백엔드가 발급하는 HttpOnly Cookie | Access Token 재발급과 새로고침 후 세션 복원에 사용했습니다. |
Access Token을 localStorage나 sessionStorage에 저장하지 않은 이유는 브라우저의 영속 저장소에 토큰이 장기간 남는 범위를 줄이기 위해서였습니다.
let accessToken: string | null = null;
export const getAccessToken = () => accessToken;
export const setAccessToken = (token: string | null) => {
accessToken = token;
};
메모리 저장 방식은 새로고침하면 Access Token이 사라진다는 특징이 있습니다. 이 동작은 의도한 동작입니다.
앱을 다시 시작하면 브라우저가 Refresh Token Cookie를 재발급 API에 전달하고, 응답으로 받은 새 Access Token을 다시 메모리에 저장합니다.
페이지 새로고침
→ 메모리 Access Token 소멸
→ POST /api/v1/auth/reissue
→ Refresh Token Cookie 자동 전송
→ 새로운 Access Token 수신
→ 인증 세션 복원
API 클라이언트에는 Cookie 전송을 위해 다음 설정을 적용했습니다.
export const apiClient = ky.create({
prefix: API_BASE_URL,
credentials: 'include',
});
물론 메모리 저장이 XSS로부터 Access Token을 완전히 보호한다는 의미는 아닙니다. 악성 스크립트가 현재 페이지에서 실행되고 있다면 메모리 값이나 인증된 API 요청에 접근할 수 있습니다.
이 전략은 영속 저장소에 토큰이 남는 시간을 줄이는 선택입니다. XSS는 CSP, 안전한 DOM 처리, 입력 검증 등으로 별도로 방어해야 합니다.
또한 Refresh Token Cookie의 HttpOnly, Secure, SameSite 정책과 CORS 설정은 백엔드와 함께 확인해야 합니다.
처음에는 인증 상태를 true와 false만으로 표현하는 방법도 고려했습니다.
하지만 앱이 처음 실행된 순간에는 사용자가 로그인했는지 아직 알 수 없습니다. 메모리에는 Access Token이 없지만, 유효한 Refresh Token Cookie가 존재할 수 있기 때문입니다.
따라서 인증 상태를 세 가지로 구분했습니다.
export type AuthStatusTypes =
| 'initializing'
| 'authenticated'
| 'unauthenticated';
각 상태의 의미는 다음과 같습니다.
initializing은 Refresh Token Cookie를 이용해 기존 세션을 확인하는 상태입니다.authenticated는 인증 세션 복원 또는 카카오 로그인이 완료된 상태입니다.unauthenticated는 재발급에 실패했거나 로그인하지 않은 상태입니다.상태 전이는 다음과 같습니다.
| 현재 상태 | 조건 | 다음 상태 |
|---|---|---|
| 앱 최초 실행 | 초기 세션 확인 시작 | initializing |
initializing | 초기 재발급 성공 | authenticated |
initializing | 재발급 실패 | unauthenticated |
initializing | Callback 경로의 초기 복원 생략 | unauthenticated |
unauthenticated | 카카오 코드 교환 성공 | authenticated |
unauthenticated | 카카오 코드 교환 실패 | unauthenticated |
authenticated | 401 이후 재발급 성공 | authenticated |
authenticated | 401 이후 재발급 실패 | unauthenticated |
만약 isAuthenticated: boolean만 사용한다면 초기값을 false로 둘 수밖에 없습니다. 그러면 기존 로그인 사용자도 재발급 응답이 도착하기 전에 미인증 사용자로 판단되어 랜딩 페이지로 이동할 수 있습니다.
initializing은 이러한 조기 리다이렉트를 막는 데 필요한 실제 비즈니스 상태였습니다.
전역 인증 상태는 Context Provider에서 관리했습니다.
const [status, setStatus] = useState<AuthStatusTypes>('initializing');
const [userId, setUserId] = useState<number | null>(null);
const [onboardingCompleted, setOnboardingCompleted] = useState<boolean | null>(
null,
);
onboardingCompleted는 boolean | null로 선언했습니다.
null은 인증 세션이 아직 확정되지 않았거나 제거되어 온보딩 여부를 알 수 없는 상태입니다.false는 세션은 확인했지만 온보딩이 완료되지 않은 상태입니다.true는 온보딩까지 완료된 상태입니다.인증 성공과 실패 시 함께 변경되어야 하는 값은 별도의 함수에 모았습니다.
const setAuthenticatedSession = useCallback((session: AuthSession) => {
setAccessToken(session.accessToken);
setUserId(session.userId);
setOnboardingCompleted(session.onboardingCompleted);
setStatus('authenticated');
return session.accessToken;
}, []);
const clearSession = useCallback(() => {
setAccessToken(null);
setUserId(null);
setOnboardingCompleted(null);
setStatus('unauthenticated');
}, []);
카카오 로그인과 토큰 재발급은 서로 다른 API를 호출하지만, 성공 이후의 세션 전환은 동일합니다.
const refreshSession = useCallback(async () => {
try {
const session = await reissueAccessToken();
return setAuthenticatedSession(session);
} catch (error) {
clearSession();
throw error;
}
}, [clearSession, setAuthenticatedSession]);
const authenticateWithKakao = useCallback(
async (code: string) => {
try {
const session = await loginWithKakao({ code });
setAuthenticatedSession(session);
return session;
} catch (error) {
clearSession();
throw error;
}
},
[clearSession, setAuthenticatedSession],
);
실패 시 오류를 다시 던진 이유는 다음 두 책임을 분리하기 위해서였습니다.
현재는 관리하는 세션 상태가 많지 않아 여러 useState와 중앙 전환 함수로 충분했습니다. 하지만 refreshing, loggingOut, expired, error와 같은 상태가 추가된다면 불가능한 상태 조합을 막기 위해 useReducer나 명시적인 상태 머신을 검토할 수 있습니다.
Provider는 앱이 처음 실행되면 재발급 API를 호출했습니다.
const pathname = usePathname();
const shouldSkipSessionBootstrap = pathname === ROUTES.AUTH.KAKAO_CALLBACK;
const hasBootstrappedRef = useRef(false);
useEffect(() => {
queueMicrotask(() => {
if (hasBootstrappedRef.current) {
return;
}
hasBootstrappedRef.current = true;
if (shouldSkipSessionBootstrap) {
clearSession();
return;
}
refreshSession().catch(() => undefined);
});
}, [clearSession, refreshSession, shouldSkipSessionBootstrap]);
hasBootstrappedRef는 앱 초기 재발급 요청이 중복 실행되는 것을 방지했습니다.
queueMicrotask는 초기 세션 복원을 Effect의 동기 실행 구간 밖으로 미루는 역할을 했습니다. 중복 실행 방지의 직접적인 역할은 queueMicrotask가 아니라 hasBootstrappedRef가 담당했습니다.
카카오 OAuth는 외부 페이지를 거치는 전체 페이지 전환입니다. Callback으로 돌아오면 앱과 Provider가 다시 마운트됩니다.
이때 일반 페이지와 똑같이 초기 재발급을 실행하면 다음 두 요청이 경쟁할 수 있습니다.
기존 Refresh Token을 사용한 세션 복원
vs
새로운 카카오 인가 코드를 사용한 로그인
두 요청이 동시에 실행되면 기존 세션이 잠시 활성화될 수 있습니다. 또한 응답 순서에 따라 새 카카오 로그인 결과가 기존 세션 정보로 다시 덮이거나, 로그인 성공 직후 세션이 초기화될 수 있습니다.
따라서 Callback 경로에서는 초기 재발급을 생략하고, 새 카카오 로그인 결과를 인증 상태의 유일한 기준으로 사용했습니다.
API 클라이언트는 React Context를 직접 사용할 수 없습니다. 반대로 공통 API 계층이 domains/auth를 import하면 의존 방향도 뒤집힙니다.
이를 해결하기 위해 API 계층에는 재발급 함수의 저장 공간만 두고, Provider가 자신의 refreshSession을 주입하도록 구성했습니다.
let refreshHandler: (() => Promise<string>) | null = null;
export const setAccessTokenRefreshHandler = (
handler: (() => Promise<string>) | null,
) => {
refreshHandler = handler;
if (!handler) {
refreshPromise = null;
}
};
useEffect(() => {
setAccessTokenRefreshHandler(refreshSession);
return () => {
setAccessTokenRefreshHandler(null);
};
}, [refreshSession]);
이 구조에서 각 계층은 다음 책임만 알고 있습니다.
AuthSessionProvider는 재발급 결과를 앱의 인증 상태로 반영합니다.이는 작은 형태의 의존성 역전이라고 볼 수 있습니다.
401 요청을 하나의 재발급으로 통합홈 화면처럼 여러 API 요청이 동시에 실행되는 상황에서 Access Token이 만료되면 모든 요청이 거의 동시에 401을 반환할 수 있습니다.
각 요청이 개별적으로 재발급 API를 호출하면 다음 문제가 발생할 수 있습니다.
이를 해결하기 위해 현재 진행 중인 재발급 작업을 Promise로 공유했습니다.
let refreshPromise: Promise<string> | null = null;
export const refreshAccessToken = async () => {
if (!refreshHandler) {
throw new Error('Access token refresh handler is not registered');
}
if (!refreshPromise) {
const currentRefreshPromise = refreshHandler().finally(() => {
if (refreshPromise === currentRefreshPromise) {
refreshPromise = null;
}
});
refreshPromise = currentRefreshPromise;
}
return refreshPromise;
};
첫 번째 요청만 실제 재발급을 시작하고, 이후 요청은 같은 refreshPromise를 기다리도록 만들었습니다.
API 요청 A ─┐
API 요청 B ─┼─ 401 → 하나의 refreshPromise → 재발급 1회
API 요청 C ─┘
finally에서 단순히 refreshPromise = null로 변경하지 않고 Promise identity를 비교한 것도 중요했습니다.
Provider가 교체되면서 이전 Promise가 정리되고 새로운 재발급 Promise가 만들어졌다고 가정했습니다. 이때 이전 Promise의 finally가 늦게 실행되면 새 Promise까지 null로 만들어버릴 수 있습니다.
따라서 자신이 정리하려는 Promise가 현재 저장된 Promise와 동일한 경우에만 제거했습니다.
이 single-flight 처리는 같은 JavaScript 실행 환경, 즉 하나의 탭 안에서 동작합니다. 여러 탭 사이의 재발급 경쟁은 공유되지 않으므로 필요한 경우 백엔드의 Refresh Token 정책도 함께 고려해야 합니다.
각 API 함수에서 다음과 같은 코드를 반복하고 싶지 않았습니다.
try {
return await request();
} catch (error) {
if (error.status === 401) {
await reissue();
return request();
}
}
따라서 공통 API 클라이언트의 ky Hook으로 이동했습니다.
export const apiClient = ky.create({
prefix: API_BASE_URL,
credentials: 'include',
timeout: 10000,
retry: {
limit: 1,
shouldRetry: () => false,
},
hooks: {
beforeRequest: [
({ request, options }) => {
if (options.context.skipAuth) {
return;
}
const accessToken = getAccessToken();
if (accessToken) {
request.headers.set('Authorization', `Bearer ${accessToken}`);
}
},
],
afterResponse: [
({ response, options, retryCount }) => {
if (
response.status === 401 &&
!options.context.skipAuthRefresh &&
retryCount === 0
) {
return ky.retry({ delay: 0 });
}
},
],
beforeRetry: [
async ({ request, options }) => {
if (options.context.skipAuthRefresh) {
return ky.stop;
}
const accessToken = await refreshAccessToken();
request.headers.set('Authorization', `Bearer ${accessToken}`);
},
],
},
});
각 Hook의 역할은 다음과 같습니다.
beforeRequest는 메모리에 Access Token이 있으면 Bearer Token을 추가했습니다.afterResponse는 최초 요청이 401이면 인증 재시도를 시작했습니다.beforeRetry는 공유 재발급 요청을 기다린 뒤 새로운 토큰을 원 요청에 반영했습니다.일반적인 네트워크 자동 재시도는 비활성화했습니다.
retry: {
limit: 1,
shouldRetry: () => false,
}
인증 실패에 대해서만 명시적으로 한 번 재시도했습니다. 재시도한 요청도 다시 401이라면 그대로 오류를 반환해 무한 반복을 막았습니다.
로그인과 재발급 요청에는 다음 context를 전달했습니다.
const AUTH_REQUEST_CONTEXT = {
skipAuth: true,
skipAuthRefresh: true,
};
ky의 context는 서버로 전송되는 값이 아니라 클라이언트 내부에서 Hook의 동작을 제어하기 위한 메타데이터입니다.
skipAuth는 기존 Access Token을 로그인 및 재발급 요청에 첨부하지 않도록 했습니다.skipAuthRefresh는 재발급 요청의 401이 다시 재발급을 호출하는 재귀를 막았습니다.재발급 API가 자기 자신을 재발급하려는 구조가 되면 무한 루프가 발생할 수 있기 때문에 반드시 분리해야 했습니다.
전체 흐름은 다음과 같습니다.

PR 초기에는 홈 페이지에서 개별적으로 인증 가드를 사용했습니다. 하지만 보호해야 하는 페이지가 늘어나면 모든 페이지에 가드를 빠짐없이 적용해야 했습니다.
현재는 Next.js App Router의 Route Group을 사용하고 있습니다.
// src/app/(private)/layout.tsx
export default function PrivateLayout({ children }: PrivateLayoutProps) {
return <AuthEntryGuard>{children}</AuthEntryGuard>;
}
(private)는 URL에 포함되지 않으면서 내부 route 구조만 그룹화합니다.
가드는 세 가지 인증 상태를 각각 다르게 렌더링합니다.
export const AuthEntryGuard = ({ children }: AuthEntryGuardProps) => {
const router = useRouter();
const { status } = useAuthSession();
useEffect(() => {
if (status === 'unauthenticated') {
router.replace(ROUTES.LANDING);
}
}, [router, status]);
if (status === 'initializing') {
return <AsyncLoadingState />;
}
if (status === 'unauthenticated') {
return null;
}
return <>{children}</>;
};
initializing이면 공통 로딩 화면을 보여줍니다.unauthenticated이면 보호된 화면을 렌더링하지 않고 랜딩 페이지로 이동합니다.authenticated이면 자식 페이지를 렌더링합니다.초기 구현에서는 initializing 상태에도 null을 반환해 빈 화면이 보였습니다. 인증 판단 자체는 맞았지만 사용자 입장에서는 앱이 멈춘 것처럼 느낄 수 있었습니다.
현재는 AsyncLoadingState를 표시해 “아직 인증 여부를 확인하는 중”이라는 상태를 UI에도 반영했습니다.
다만 이 가드는 Client-side render guard입니다. 사용자가 URL을 입력하는 행위나 route 리소스 요청 자체를 차단하지는 않습니다.
보호된 데이터를 지키는 최종 보안 경계는 반드시 백엔드 API의 인증 검사여야 합니다. 서버 단계에서 페이지 접근 자체를 차단해야 한다면 BFF, 서버 세션, Middleware 등 별도의 인증 전략이 필요합니다.
현재 앱에서는 인증 상태를 페이지 접근에만 사용하지 않습니다. 채팅을 위한 STOMP 연결도 인증 완료 이후에만 활성화했습니다.
const AuthenticatedStompProvider = ({ children }: ProvidersProps) => {
const { status } = useAuthSession();
return (
<StompProvider enabled={status === 'authenticated'}>
{children}
</StompProvider>
);
};
이를 통해 세션 복원 전이나 미인증 상태에서 불필요한 WebSocket 연결이 만들어지는 것을 막았습니다.
인증 상태를 단순히 “로그인 버튼을 보여줄 것인가”에만 사용하지 않고, 앱의 네트워크 생명주기를 제어하는 기준으로 활용했습니다.
인증 요청이 실패하면 Provider는 반드시 기존 세션을 제거했습니다.
catch (error) {
clearSession();
throw error;
}
이후 Callback 컴포넌트는 전달받은 오류를 바탕으로 실패 화면을 보여줍니다.
if (errorMessage) {
return (
<main>
<EmptyState
title="로그인에 실패했어요"
description="잠시 후 다시 시도하거나 로그인 정보를 확인해주세요"
/>
<Button onClick={() => router.replace(ROUTES.AUTH.LOGIN)}>
로그인 화면으로 돌아가기
</Button>
</main>
);
}
세션 정리와 사용자 안내를 한 계층에서 모두 담당하지 않은 이유는 책임을 나누기 위해서였습니다.
로그인 진행 화면에는 다음 접근성 속성도 적용했습니다.
<div role="status" aria-live="polite">
<h1>로그인 중</h1>
<p>카카오 계정을 확인하고 있어요.</p>
</div>
시각적으로 로딩 중임을 보여주는 것뿐 아니라 보조 기술에도 상태 변화를 전달하기 위한 선택이었습니다.
인증 기능은 성공 경로보다 실패와 경쟁 조건에서 문제가 발생하기 쉽습니다. 따라서 완료 기준도 다음 시나리오를 중심으로 구성했습니다.
code와 state로 로그인할 수 있어야 합니다.state와 Callback의 state가 다르면 API를 호출하지 않아야 합니다.code가 없거나 사용자가 동의를 취소한 경우 실패 화면을 보여주어야 합니다.state가 Callback URL에서 제거되어야 합니다.unauthenticated로 전환되어야 합니다.401 이후 재발급하고 원 요청을 한 번 재시도해야 합니다.401이어도 재발급 API는 한 번만 호출되어야 합니다.401이면 무한 반복하지 않고 오류를 반환해야 합니다.initializing 상태에서는 로딩 화면이 보여야 합니다.현재 구조는 로그인, 초기 세션 복원, 401 자동 복구까지 담당하지만 완성된 인증 시스템의 모든 기능을 포함하지는 않습니다.
명시적인 로그아웃 API와 Refresh Token Cookie 제거, 메모리 토큰 초기화, Query Cache 정리 흐름이 추가로 필요합니다.
로그아웃에서는 단순히 setAccessToken(null)만 호출해서는 안 됩니다. 서버 Cookie와 클라이언트 캐시까지 함께 정리해야 합니다.
현재는 다음 두 시점에 재발급합니다.
401을 반환한 시점Access Token 만료 시간을 읽어 만료 직전에 재발급하는 방식은 사용하지 않았습니다. 현재 규모에서는 반응형 재발급이 단순하지만, 첫 401으로 인한 지연이 중요해진다면 선제 재발급을 검토할 수 있습니다.
현재 AuthEntryGuard는 보호 화면 렌더링과 클라이언트 리다이렉트를 제어합니다. 주소 입력 자체나 route bundle 요청을 차단하지는 않습니다.
서버에서 페이지 접근 자체를 판단해야 하는 요구가 생기면 인증 구조도 서버 세션을 인식할 수 있는 형태로 확장해야 합니다.
로그인 URL 생성 오류는 Sentry에 기록하지만 Callback API 실패에 대한 세부 Context는 추가로 보완할 수 있습니다.
예를 들어 다음 정보를 민감 정보 없이 기록할 수 있습니다.
인가 코드와 Access Token 같은 값은 절대 로그에 남기면 안 됩니다.
state 정리현재 Callback에 error가 있거나 code가 없는 경우에는 state 검증 전에 종료됩니다. 다음 로그인 요청에서 새 값으로 덮어쓰기 때문에 영향은 제한적이지만, 모든 Callback 종료 경로에서 저장값을 소비하도록 개선하면 생명주기가 더 명확해집니다.
현재는 세 가지 상태와 중앙 전환 함수로 충분합니다. 하지만 다음 상태가 추가되면 useReducer 또는 상태 머신이 더 적합할 수 있습니다.
판단 기준은 상태의 개수 자체가 아니라, 불가능한 상태 조합과 전이 규칙을 코드로 강제해야 하는지 여부입니다.
이번 작업을 통해 OAuth 로그인은 단순히 로그인 API를 호출하고 토큰을 저장하는 기능이 아니라는 점을 알게 되었습니다.
로그인에는 여러 종류의 상태가 존재했습니다.
state가 있었습니다.중요한 것은 모든 상태를 하나의 전역 저장소에 넣는 것이 아니었습니다. 각 상태의 수명과 소비자, 렌더링 필요성, 동시성 특성에 맞는 저장 위치를 선택하는 것이 중요했습니다.
또한 401을 만나면 재발급이라는 단순한 요구에도 다음과 같은 설계가 필요했습니다.
401은 single-flight 방식으로 처리했습니다.처음에는 카카오 로그인 API 연동으로 시작했습니다.
하지만 결과적으로 OAuth 요청 검증, 메모리 기반 토큰 관리, 세션 복원, 동시성 제어, 공통 route guard까지 포함하는 인증 기반을 만드는 작업이 되며 많은 것들을 배울 수 있었습니다.