๐Ÿ” React Native์—์„œ Axios + JWT ํ† ํฐ ์ž๋™ ๊ฐฑ์‹  ๋ฐ ์š”์ฒญ ์žฌ์‹œ๋„ ์ฒ˜๋ฆฌํ•˜๊ธฐ

๋ฑ€๊ธฐยท2025๋…„ 7์›” 29์ผ
0

Nurihaus

๋ชฉ๋ก ๋ณด๊ธฐ
15/18

React Native ์•ฑ์—์„œ JWT ์ธ์ฆ์„ ์‚ฌ์šฉํ•˜๋Š” ์ค‘, accessToken์ด ๋งŒ๋ฃŒ๋˜์–ด 401 ์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ•˜๋Š” ๋ฌธ์ œ๊ฐ€ ์žˆ์—ˆ๊ณ ,
์ด๋กœ ์ธํ•ด ํŠน์ • ์š”์ฒญ์ด ์ฒ˜๋ฆฌ๋˜์ง€ ์•Š๊ฑฐ๋‚˜ ์•ฑ ๋™์ž‘์ด ๋ฉˆ์ถ”๋Š” ์‚ฌ๋ก€๊ฐ€ ์ž์ฃผ ๋ฐœ๊ฒฌ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

์ฒ˜์Œ์—๋Š” ํ† ํฐ ๊ฐฑ์‹ ์ด ์ •์ƒ์ ์œผ๋กœ ์ž‘๋™ํ•œ๋‹ค๊ณ  ํŒ๋‹จํ–ˆ์ง€๋งŒ, ์‹ค์ œ ์„œ๋น„์Šค์—์„œ๋Š” ๋™์‹œ ์š”์ฒญ ์ฒ˜๋ฆฌ, ์žฌ์‹œ๋„ ๋ˆ„๋ฝ, ๋ฌดํ•œ ๋ฃจํ”„ ๋“ฑ ๋‹ค์–‘ํ•œ ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ๋‹ค๋Š” ๊ฒƒ์„ ํ™•์ธํ–ˆ์Šต๋‹ˆ๋‹ค.
์ด๋ฅผ ํ•ด๊ฒฐํ•˜๊ธฐ ์œ„ํ•ด, Axios ์ธํ„ฐ์…‰ํ„ฐ๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ํ† ํฐ์„ ๊ฐฑ์‹ ํ•˜๊ณ , ์‹คํŒจํ•œ ์š”์ฒญ์„ ์ž๋™์œผ๋กœ ์žฌ์‹œ๋„ํ•˜๋Š” ๊ตฌ์กฐ๋ฅผ ์„ค๊ณ„ํ–ˆ์Šต๋‹ˆ๋‹ค.


๐ŸŽฏ ๊ตฌํ˜„ ๋ชฉํ‘œ

  1. accessToken์ด ๋งŒ๋ฃŒ๋˜์—ˆ์„ ๋•Œ refreshToken์„ ํ†ตํ•ด ์ƒˆ๋กœ์šด ํ† ํฐ์„ ๋ฐ›์•„์˜ด
  2. ํ•ด๋‹น ์‹œ์ ์— ์‹คํŒจํ•œ ์š”์ฒญ์€ ํ์— ๋ณด๊ด€ํ•˜๊ณ , ์ƒˆ ํ† ํฐ์œผ๋กœ ์žฌ์‹คํ–‰
  3. ๊ฐฑ์‹  ์ค‘์—๋Š” ์ถ”๊ฐ€ ๊ฐฑ์‹  ์š”์ฒญ ์—†์ด ํ์—๋งŒ ์Œ“๋„๋ก ์ œ์–ด
  4. ๊ฐฑ์‹  ์‹คํŒจ ์‹œ์—๋Š” ์•ˆ์ „ํ•˜๊ฒŒ ๋กœ๊ทธ์•„์›ƒ ์ฒ˜๋ฆฌ

1. Axios ์ธ์Šคํ„ด์Šค ๋ฐ ์ธํ„ฐ์…‰ํ„ฐ ๊ตฌ์„ฑ

// apiClient.ts
import axios from 'axios';

export const apiClient = axios.create({ baseURL: BASE_URL });

apiClient.interceptors.request.use(
  (config) => config,
  (error) => Promise.reject(error)
);

apiClient.interceptors.response.use(
  (response) => response,
  (error) => handleJwtError(error, apiClient)
);

2. 401 ์—๋Ÿฌ ๊ฐ์ง€ ๋ฐ ๊ฐฑ์‹  ์ง„์ž…

// handleJwtError.ts
export const handleJwtError = (error: unknown, client: AxiosInstance) => {
  if (!(error instanceof AxiosError)) return;

  const { config, response } = error;
  const requestUrl = config?.url;

  if (response?.status === 401) {
    // refreshToken ์ž์ฒด ์š”์ฒญ์ธ ๊ฒฝ์šฐ ๋ฌดํ•œ ๋ฃจํ”„ ๋ฐฉ์ง€
    if (requestUrl?.includes('/auth/token/refresh')) {
      return Promise.reject(error);
    }

    return attemptTokenRefreshAndRetry(config, client);
  }

  return Promise.reject(error);
};

3. ์š”์ฒญ ์žฌ์‹œ๋„๋ฅผ ์œ„ํ•œ ํ ๊ตฌ์กฐ ์ •์˜

let isRefreshing = false;

const pendingRequests: Array<{
  resolve: (token: string) => void;
  reject: (error: unknown) => void;
}> = [];

const flushQueue = (error: any, newToken: string | null = null) => {
  pendingRequests.forEach(({ resolve, reject }) => {
    if (error) reject(error);
    else if (newToken) resolve(newToken);
  });
  pendingRequests.length = 0;
};

4. ํ† ํฐ ๊ฐฑ์‹  + ์‹คํŒจ ์š”์ฒญ ์žฌ์‹คํ–‰ ์ฒ˜๋ฆฌ

// attemptTokenRefreshAndRetry.ts
export const attemptTokenRefreshAndRetry = async (originalConfig: any, client: AxiosInstance) => {
  if (!originalConfig) return Promise.reject(new Error('Missing config'));

  if (isRefreshing) {
    return new Promise((resolve, reject) => {
      pendingRequests.push({ resolve, reject });
    }).then((token) => {
      originalConfig.headers.Authorization = `Bearer ${token}`;
      return client(originalConfig);
    });
  }

  isRefreshing = true;

  try:
    const refreshToken = await getRefreshTokenFromStorage();
    if (!refreshToken) throw new Error('No refresh token found');

    const { accessToken, refreshToken: newRefreshToken } = await requestNewToken(refreshToken);

    await saveTokensToStorage(accessToken, newRefreshToken);
    client.defaults.headers.common.Authorization = `Bearer ${accessToken}`;

    flushQueue(null, accessToken);

    originalConfig.headers.Authorization = `Bearer ${accessToken}`;
    return client(originalConfig);
  } catch (err) {
    flushQueue(err, null);
    await clearTokenStorage();
    handleLogout();
    return Promise.reject(err);
  } finally {
    isRefreshing = false;
  }
};

๐Ÿ” ์ „์ฒด ํ๋ฆ„ ์š”์•ฝ

  1. 401 Unauthorized ์‘๋‹ต์ด ์˜ค๋ฉด attemptTokenRefreshAndRetry๋กœ ์ง„์ž…
  2. ์ฒซ ๋ฒˆ์งธ ์š”์ฒญ๋งŒ refresh API๋ฅผ ํ˜ธ์ถœ
  3. ๋™์‹œ์— ์‹คํŒจํ•œ ์š”์ฒญ๋“ค์€ ํ์— ๋ณด๊ด€
  4. ์ƒˆ accessToken ๋ฐœ๊ธ‰ ํ›„, ํ์— ์žˆ๋Š” ์š”์ฒญ๋“ค์„ ์ˆœ์ฐจ์ ์œผ๋กœ ์žฌ์‹œ๋„
  5. ๊ฐฑ์‹  ์‹คํŒจ ์‹œ ๋กœ๊ทธ์•„์›ƒ ๋ฐ ํ† ํฐ ์‚ญ์ œ

โœ… ํšจ๊ณผ

  • ์ค‘๋ณต ๊ฐฑ์‹  ์š”์ฒญ ์ฐจ๋‹จ: ๊ฐฑ์‹  ์ค‘์—๋Š” ํ์—๋งŒ ์Œ“์ด๊ณ  ์ค‘๋ณต ํ˜ธ์ถœ ์—†์Œ
  • ์š”์ฒญ ์ž๋™ ๋ณต๊ตฌ: ์‹คํŒจํ•œ ์š”์ฒญ๋„ ์‚ฌ์šฉ์ž ์ธ์ง€ ์—†์ด ๋ณต๊ตฌ๋จ
  • UX ๊ฐœ์„ : ๋กœ๊ทธ์ธ ๋งŒ๋ฃŒ๋ฅผ ์ฒด๊ฐํ•˜์ง€ ์•Š๋„๋ก ์ฒ˜๋ฆฌ
  • ๋ณด์•ˆ ์ฒ˜๋ฆฌ: ๊ฐฑ์‹  ์‹คํŒจ ์‹œ ์ฆ‰์‹œ ๋กœ๊ทธ์•„์›ƒ ๋ฐ ์ดˆ๊ธฐํ™”

๐Ÿ“Œ ์ฐธ๊ณ  ํ•จ์ˆ˜ (ํ•ต์‹ฌ ๋กœ์ง ์™ธ๋ถ€ ์ถ”์ƒํ™” ์˜ˆ์‹œ)

const getRefreshTokenFromStorage = () => SecureStore.getItemAsync('refresh_token');
const saveTokensToStorage = (access: string, refresh: string) => {
  return Promise.all([
    SecureStore.setItemAsync('access_token', access),
    SecureStore.setItemAsync('refresh_token', refresh),
  ]);
};
const clearTokenStorage = () => {
  return Promise.all([
    SecureStore.deleteItemAsync('access_token'),
    SecureStore.deleteItemAsync('refresh_token'),
  ]);
};
const handleLogout = () => {
  store.dispatch(SignOut());
  reset({ routes: [{ name: 'Auth', params: { screen: 'Landing' } }] });
};

> ๋ฐ€๋ฆฐ๊ฑธ ๋ชฐ์•„์“ฐ์ง€ ์•Š๋„๋ก ํ•˜์ž...!

profile
FE ์ž…๋‹ˆ๋‹ค.

0๊ฐœ์˜ ๋Œ“๊ธ€