Web Project/market (client)

로그인 인증 흐름 전체 구조: JWT + Redux + Refresh 토큰 갱신 (React Native + Node.js)

hmmmmmmmmmmmm 2025. 5. 25. 18:36

✨ 구현 목표

사용자가 로그인하면 서버에서 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;