✨ 구현 목표
사용자가 로그인하면 서버에서 accessToken(15분), refreshToken(장기) 발급
클라이언트는 accessToken은 Redux에, refreshToken은 AsyncStorage에 저장
accessToken 만료 시 → refreshToken으로 새 accessToken 자동 갱신
전역 상태로 로그인 여부 판단 → 로그인 여부에 따라 UI 분기
1. 서버 – 로그인 시 JWT 토큰 발급
📁 /src/controllers/auth.ts
export const signIn: RequestHandler = async (req, res) => {
const { email, password } = req.body;
const user = await UserModel.findOne({ email });
if (!user || !(await user.comparePassword(password)))
return sendErrorRes(res, "Email/Password mismatch!", 403);
const payload = { id: user._id };
const accessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: "15m" });
const refreshToken = jwt.sign(payload, JWT_SECRET);
// 리프레시 토큰을 DB에 저장
if (!user.tokens) {
user.tokens = [refreshToken];
}
else {
user.tokens.push(refreshToken);
}
await user.save();
res.json({
profile: {
id: user._id,
email: user.email,
name: user.name,
verified: user.verified,
avatar: user.avatar?.url || user.avatar,
},
tokens: {
refresh: refreshToken,
access: accessToken,
},
});
};
🔑 핵심 포인트
로그인 성공 시 두 개의 토큰 발급
- accessToken: 15분 유효
- refreshToken: DB에 저장됨 (무제한 유효)
응답은 { profile, tokens: { access, refresh } } 형태
2. 📦 클라이언트 – 로그인 요청 및 상태 저장
📁 useAuth.tsx
const signIn = async (userInfo: UserInfo) => {
dispatch(updateAuthState({ profile: null, pending: true }));
await new Promise((r) => setTimeout(r, 1500)); // UX용 로딩 지연
const res = await runAxiosAsync<SignInRes>(
client.post("/auth/sign-in", userInfo)
);
if (res) {
await AsyncStorage.setItem("access-token", res.tokens.access);
await AsyncStorage.setItem("refresh-token", res.tokens.refresh);
dispatch(updateAuthState({
profile: {
...res.profile,
accessToken: res.tokens.access, // ✅ Redux의 profile 내부에 accessToken 포함
},
pending: false,
}));
return true;
} else {
dispatch(updateAuthState({ profile: null, pending: false }));
return false;
}
};
✅ 저장 위치 요약
정보 | 저장 위치 |
accessToken | Redux 상태 (profile.accessToken) |
refreshToken | AsyncStorage (refresh-token) |
3️⃣ Redux 구조 – 로그인 상태 관리
📁 store/auth.ts
export type Profile = {
id: string;
email: string;
name: string;
verified: boolean;
avatar?: string;
accessToken: string; // ✅ accessToken은 profile 내부에 저장됨
};
interface AuthState {
profile: null | Profile;
pending: boolean;
}
const authSlice = createSlice({
name: "auth",
initialState: { profile: null, pending: false },
reducers: {
updateAuthState(state, { payload }: PayloadAction<AuthState>) {
state.profile = payload.profile;
state.pending = payload.pending;
},
},
});
✅ 이유
accessToken을 profile 안에 포함시켜서,
다음처럼 편하게 접근 가능:
const token = useSelector(getAuthState).profile?.accessToken;
4️⃣ Axios 요청 시 토큰 자동 삽입 및 재발급
📁 useClient.ts
authClient.interceptors.request.use((config) => {
if (!config.headers) config.headers = {};
config.headers.Authorization = "Bearer " + token;
return config;
});
📍 만료 시: createAuthRefreshInterceptor에 등록된 갱신 로직
const refreshAuthLogic = async (failedRequest) => {
const refreshToken = await asyncStorage.get(Keys.REFRESH_TOKEN);
const res = await runAxiosAsync<TokenResponse>(
axios.post(`${baseURL}/auth/refresh-token`, { refreshToken })
);
if (res?.tokens) {
failedRequest.response.config.headers.Authorization = "Bearer " + res.tokens.access;
await asyncStorage.save(Keys.AUTH_TOKEN, res.tokens.access);
await asyncStorage.save(Keys.REFRESH_TOKEN, res.tokens.refresh);
dispatch(updateAuthState({
profile: {
...authState.profile!,
accessToken: res.tokens.access,
},
pending: false,
}));
return Promise.resolve();
}
};
✅ 흐름 요약
accessToken이 만료된 요청 실패 시 → 위 로직 자동 실행
refreshToken으로 새 accessToken 발급 후
실패한 요청을 다시 재시도
5️⃣ 서버 – refreshToken으로 accessToken 재발급
📁 src/controllers/auth.ts
export const grantAccessToken: RequestHandler = async (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) return sendErrorRes(res, "Unauthorized request!", 403);
const payload = jwt.verify(refreshToken, JWT_SECRET) as { id: string };
const user = await UserModel.findOne({ _id: payload.id, tokens: refreshToken });
if (!user) {
await UserModel.findByIdAndUpdate(payload.id, { tokens: [] });
return sendErrorRes(res, "Unauthorized request!", 401);
}
const newAccessToken = jwt.sign({ id: user._id }, JWT_SECRET, { expiresIn: "15m" });
const newRefreshToken = jwt.sign({ id: user._id }, JWT_SECRET);
user.tokens = [...user.tokens.filter((t) => t !== refreshToken), newRefreshToken];
await user.save();
res.json({
tokens: {
refresh: newRefreshToken,
access: newAccessToken,
},
});
};
이 로직이 useClient.ts에서 자동 실행됨
6️⃣ UI 라우팅: 로그인 여부 분기
const { profile } = useSelector(getAuthState);
return profile?.accessToken ? <MainApp /> : <LoginScreen />;
Redux 상태에 accessToken이 있으면 로그인된 상태로 간주
❓ accessToken이 만료되었을 때 refreshToken으로 자동 갱신되는 전체 흐름
1. 기본 개념
accessToken | API 요청 시 사용하는 인증 토큰 (유효기간 짧음, 15분) |
refreshToken | accessToken이 만료되었을 때 새로운 accessToken을 받기 위한 장기 토큰 |
목적 | 사용자에게 로그아웃되지 않은 UX 제공 (자동 로그인 유지) |
2. 문제 상황
accessToken은 15분만 유효 → 그 이후 API 요청 시 401 Unauthorized 발생
이를 감지하고 Axios 인터셉터에서 자동으로 refreshToken으로 새 accessToken을 요청
새 accessToken을 Redux 상태에 갱신, 요청 재시도
3. 흐름
A. accessToken을 사용하는 Axios 요청 시도
authClient.interceptors.request.use((config) => {
config.headers.Authorization = "Bearer " + token;
return config;
});
모든 API 요청 시 이 인터셉터가 accessToken을 자동으로 붙여 줌.
B. accessToken 만료 → 요청 실패 (401 에러 발생)
Axios가 요청을 보내다가 401 오류를 받으면,
아래 로직을 실행하게 됨: createAuthRefreshInterceptor(...)
createAuthRefreshInterceptor(authClient, refreshAuthLogic);
C. refreshAuthLogic 실행
const refreshAuthLogic = async (failedRequest) => {
const refreshToken = await asyncStorage.get(Keys.REFRESH_TOKEN);
const res = await runAxiosAsync<TokenResponse>(
axios.post(`${baseURL}/auth/refresh-token`, { refreshToken })
);
accessToken이 만료됐을 때 자동으로 실행되는 로직
AsyncStorage에서 저장해 둔 refreshToken을 꺼내서 서버에 /auth/refresh-token 요청
D. 서버에서 refreshToken 검증 → 새로운 토큰 발급
export const grantAccessToken: RequestHandler = async (req, res) => {
const { refreshToken } = req.body;
const payload = jwt.verify(refreshToken, JWT_SECRET); // 유효한 토큰인지 확인
const user = await UserModel.findOne({
_id: payload.id,
tokens: refreshToken,
});
const newAccessToken = jwt.sign({ id: user._id }, JWT_SECRET, { expiresIn: "15m" });
const newRefreshToken = jwt.sign({ id: user._id }, JWT_SECRET);
// DB 갱신 및 응답
user.tokens = [...user.tokens.filter((t) => t !== refreshToken), newRefreshToken];
await user.save();
res.json({ tokens: { access: newAccessToken, refresh: newRefreshToken } });
};
기존 refreshToken이 DB에 등록된 것과 일치해야 새 토큰 발급
accessToken + refreshToken 새로 생성 후 클라이언트에 전달
E. 클라이언트에서 새 accessToken 저장 + 실패한 요청 재시도
if (res?.tokens) {
failedRequest.response.config.headers.Authorization = "Bearer " + res.tokens.access;
// 새 토큰 저장
await asyncStorage.save(Keys.AUTH_TOKEN, res.tokens.access);
await asyncStorage.save(Keys.REFRESH_TOKEN, res.tokens.refresh);
// Redux 상태 갱신
dispatch(updateAuthState({
profile: {
...authState.profile!,
accessToken: res.tokens.access,
},
pending: false,
}));
return Promise.resolve(); // ✅ 요청 재시도
}
새로 받은 accessToken은:
- AsyncStorage에도 저장
- Redux 상태에도 갱신 (profile.accessToken)
실패했던 원래 요청을 다시 수행함 (재시도)
✅ 최종 흐름
[클라이언트]
↓
Axios 요청 (accessToken 포함)
↓
accessToken 만료 → 401 오류
↓
refreshAuthLogic 실행
↓
refreshToken으로 /auth/refresh-token 요청
↓
[서버]
refreshToken 검증
→ accessToken, refreshToken 새로 발급
→ 응답 반환
[클라이언트]
새 accessToken → Redux + AsyncStorage 갱신
실패했던 요청 → 재시도
❓ accessToken을 전역 Redux 상태에 저장하는 위치
전역 상태 저장 위치: auth.ts (Redux Slice)
1. Profile 타입 정의에서 accessToken 포함
export type Profile = {
id: string;
email: string;
name: string;
verified: boolean;
avatar?: string;
accessToken: string; // ✅ 여기!
};
2. 상태 구조에 profile: Profile | null 포함
interface AuthState {
profile: null | Profile;
pending: boolean;
}
3. 액션 함수 updateAuthState를 통해 accessToken 포함한 profile 저장
updateAuthState(authState, { payload }: PayloadAction<AuthState>) {
authState.pending = payload.pending;
authState.profile = payload.profile; // ✅ accessToken도 함께 들어 있음
}
4. 실제 저장 예시 (로그인 로직에서)
dispatch(updateAuthState({
pending: false,
profile: {
id: data.profile.id,
email: data.profile.email,
name: data.profile.name,
verified: data.profile.verified,
avatar: data.profile.avatar,
accessToken: data.token, // ✅ 여기가 실제 저장 위치
}
}));
즉, accessToken은 profile 객체의 필드로 포함되어 있고,
updateAuthState() 액션으로 전역 Redux 상태에 저장된다
이후 useSelector(getAuthState)로 어디서든 접근 가능 하다
❓ 서버에서는 token과 profile을 분리해서 응답하는데?
{
"token": "eyJhbGciOiJIUzI1...",
"profile": {
"id": "...",
"name": "...",
"email": "...",
"verified": true,
"avatar": "..."
}
}
그런데 클라이언트 Redux 상태 구조에서는 이 둘을 profile에 합쳐서 저장하고 있다
profile: {
id: "...",
name: "...",
email: "...",
verified: true,
avatar: "...",
accessToken: "eyJhbGciOiJIUzI1..." // ← 여기 주입
}
✅ 그럼 이건 어디서 합쳐지나?
정답: 클라이언트 로그인 처리 로직 (useAuth.ts, 혹은 signIn 함수 내부)
dispatch(updateAuthState({
pending: false,
profile: {
...data.profile, // 서버에서 온 profile만 복사
accessToken: data.token // 여기에 token을 직접 추가해서 "profile처럼" 저장
}
}));
즉, "accessToken"은 서버 응답의 token을 클라이언트에서 임의로 profile 안에 넣은 것
🔁 왜 이렇게 했을까?
Redux 상태 구조를 단순화하려는 목적
- auth.profile에 로그인한 사용자의 모든 정보 (id, name, token)를 한 번에 접근하려고
- 예를 들어 아래처럼 간단하게 쓸 수 있음:
const token = useSelector(getAuthState).profile?.accessToken;