오늘의 시간표

시간 카테고리 할 일 상세
15:00-22:00 OneStep SZ-215: 프론트 자잘한 듯 자잘하지 않은 버그 이슈
23:00-00:00 SOMA 발표 대본 작성

 

 오늘 배운 것

axios 인스턴스에 대해서는 어제 axios interceptor를 사용해서 401 AxiosError가 발생했을 경우 액세스토큰 갱신 API를 호출하고, 해당 리프레시 토큰으로 액세스토큰을 갱신시키는 로직을 추가해 두었었다. 

 

그런데 오늘 다시 로그를 보니 여전히 401 에러(어제는 400 에러도 중간에 섞여 있었는데 오늘은 401 에러만 나온다)가 나왔다. 

알고보니 리프레시 토큰이 만료된 경우는 액세스 토큰을 갱신하려고 해도 갱신할 수 없었다. 혹시나 해서 API 서버의 리프레시 토큰의 만료일자를 찾아보니 1일로 되어있었다. 그래서 어제는 400 에러가 나왔는데 오늘은 401 에러가 나왔을 수 있겠다. 

 

그렇다면 만약 액세스토큰을 갱신하려고 시도했을 때도 401 AxiosError가 난다면, 이를 리프레시 토큰 만료 에러로 간주하고 강제로 로그인 페이지로 리디렉션 시켜야 하겠다. 우리 프로젝트에서는 expo router를 사용하고 있어서, 다음 코드를 추가해 주었다. 

router.replace('(tabs)');

 

그런데도 여전히 에러에 의해 무한루프가 발생했다. 

알고보니 로그인 페이지로 리다이렉션 하면서 axios의 interceptor 로직을 return문 등으로 빠져나온 것도 아니기 때문에, 해당 401 에러에서 또 다시 renew API로 요청을 보내면서 위 과정이 반복되는 것이었다. 이걸 막으려면 단순히 로그인 페이지로 리다이렉션 시키는 것뿐만 아니라 추가적인 처리가 필요하겠다. 

 

만약 refresh API를 요청하다 401 오류가 난 경우, 액세스토큰과 리프레시 토큰을 지운 다음 로그인 페이지로 리다이렉션 시켰다. 

if (originalRequest.url === API_PATH.renew) {
  await AsyncStorage.removeItem('accessToken');
  await AsyncStorage.removeItem('refreshToken');
  router.replace('(tabs)');
  return Promise.reject(error);
}

 

그런데 여전히 반복적인 오류가 난다. 이번에는 로그인 페이지에서 혹시 액세스나 리프레시 토큰의 유무에 관계없이 투두 리스트 뷰로 접근을 시도하는 것은 아닌지 확인해 보았다. 

 

로그인 페이지에서 컴포넌트의 리렌더링마다 시행되는 로직들, 즉 useEffect 훅 안에 정의된 함수들은 뭐가 있는지를 보았다. 'handleToken'과 'handleLocalToken' 이라는 함수들이 useEffect 안에 정의되어 있었다. 공식문서에서 useEffect의 정의를 찾아보니 컴포넌트를 외부 요소들과 동기화하고 싶을 때 사용하는 훅이라고 했다. 

 

내가 그런 심오한 작업을 위해 useEffect를 사용하고 있나? 라는 의문이 들어서 handleLocalToken과 handleToken의 로직들을 보았다. handleLocalToken의 경우는 비동기 저장소 AsyncStorage에서 유저 정보 및 토큰들의 값을 가져오고 있었으므로 일종의 외부 시스템과 컴포넌트를 동기화시키는 작업이었다. 

const handleLocalToken = async () => {
  const token = await getAccessTokenFromLocal();
  const user = await getUserInfoFromLocal();
  api.verifyToken(token);
  setAccessToken(token);
  setUserId(user.userId);
  router.replace('(tabs)');
};

 

또한 handleToken의 경우도 외부 API를 호출하여 유저의 정보를 받아오고, 그 정보를 AsyncStorage에 저장하면서 외부 시스템과 통신하고 있었다. 

const handleToken = useCallback(async () => {
  if (response?.type === 'success') {
    const token = response.authentication?.idToken;
    if (token) {
      await getToken({ token });
      const user = await api.getUserInfo();

      await AsyncStorage.setItem('userId', user.id.toString());
      await AsyncStorage.setItem('userName', user.username);
      setUserId(user.id);
      router.replace('(tabs)');
    }
  }
}, [response, setUserId, getToken, api]);

 

그리고 쓰다보니 내가 위에서 router.replace()의 값으로 잘못된 주소를 할당했다는 것을 알았다. 나는 로그인 화면으로 돌아가고 싶었는데, 해당 파일의 이름은 'index.jsx'였기 때문에 router.replace()의 값으로 공백 문자열을 넘겨줘야 했다. 이 부분을 수정하고 다시 앱을 실행해 보았다. 

 

그랬더니 이번에는 투두 뷰에서만 무한루프가 일어났던 것과 달리 로그인 뷰와 투두 뷰를 오가면서 어떠한 로직이 무한루프로 실행되고 있었다. 로그인 페이지에서 계속해서 투두 뷰로 이동하기 위해 토큰을 체크하는 로직을 호출하는 부분이 의심이 갔다. 그러지 않고서야 가만히 로그인 페이지에 유저가 머물러 있어야 하기 때문이다. 

 

앞서 언급한 로그인 페이지에서 useEffect() 안에 정의된 두 함수는 handleLocalToken, handleToken 이었다. 찾아보니 두 함수 모두 API를 호출하고 있었다. 다만 handleToken은 if문을 통해서 구글로그인 버튼을 눌러서 로그인의 결과로 구글에서 발급하는 idToken 값이 있어야만 추가 로직을 처리하고 있었기 때문에, 구글로그인 버튼을 누르지 않는다면 API를 호출하지 않는다. 

 

그렇다면 handleLocalToken 로직을 봐야 하겠다. 여기서는 현재 가진 액세스토큰이 유효한지를 인증 API를 호출함으로써 확인하고 있었다. 이 부분이 문제라고 판단했다. 왜냐하면 로컬의 AsyncStorage에서 토큰과 유저 정보를 가져오는 것 까지는 괜찮은데, 최소한 해당 정보가 있어야 그 정보를 가지고 API를 호출하는 것이 맞기 때문이다. 그런데 현재 로직에서는 그런 정보가 있는지 확인하는 절차 없이 바로 토큰이 유효한지 인증하는 API를 호출하고 있었다. 

 

우선 현재 로그인 뷰<->투두 뷰 사이에서 무한루프를 도는 상황에서, handleLocalToken 안에 있는 token과 user의 값을 찍어보았다. 

 

현재는 리프레시 토큰이 만료되어서 액세스토큰과 리프레시 토큰의 값은 지워진 상태이다. 그런데 원칙상 유저가 현재 로그인되어 있지 않으니 AsyncStorage에는 유저의 값도 없어야 하는 것이 맞다. 그런데 유저의 값은 저장되어 있었다. 

 

리프레시 토큰 만료 시, AsyncStorage에 저장되어 있는 토큰 값뿐만 아니라 유저의 값도 지워주는 코드를 추가해주었다. 이제 리프레시토큰이 만료되어서 로그인이 실패한 경우 모든 값이 null로 잘 나온다. 

 

이제 적어도 AsyncStorage에 관련 토큰이나 유저 값이 있을 때만 verify API로 토큰이 유효한지 조회하도록 로직을 조금 수정해보았다. (기존에는 if문과 try/catch문이 없었다) 

if (token && user.userId) {
  try {
    api.verifyToken(token);
    setAccessToken(token);
    setUserId(user.userId);
    router.replace('(tabs)');
  } catch (e) {
    Sentry.captureException(e);
  }
}

 

그리고 AsyncStorage.setItem()으로 토큰값을 바꿔줄 때는 해당 컴포넌트에서 사용 중인 싱글톤 Api 객체의 토큰값도 바꿔 주었다. 그러기 위해서 setter 메소드를 Api 클래스에 추가해주었다. 

setAccessToken(newAccessToken) {
  this.accessToken = newAccessToken;
}

setRefreshToken(newRefreshToken) {
  this.refreshToken = newRefreshToken;
}

 

이렇게 했더니 앞서 언급한 '로그인 뷰와 투두 뷰를 계속 오가면서 무한루프가 일어나는 문제'는 해결되었다. 그런데 이제는 구글로그인이 성공적으로 실행되어도 투두 뷰로 이동하지 않는 문제가 발생했다. 

 

로그를 찍어보니, 구글로그인이 성공적으로 진행되고 나서 handleToken 함수 내부에서 유저 정보를 불러오는 API를 호출할 때 액세스 토큰값이 최신값으로 반영되지 않아서 401 AxiosError가 나는 것 같았다. 그래서 다시 로그인 페이지로 돌아오는 것으로 보였다. 

 

왜 토큰값이 최신값으로 반영되지 않을까 알아보기 위해서, handleToken 함수 내부에서 유저 정보를 불러오는 API 호출 전에 실행되는 'getToken' 함수를 보았다. 이 함수에서는 구글로그인으로 받아온 idToken의 값을 서버의 특정 API를 호출하여 API 서버와 통신할 때 사용할 수 있는 JWT 토큰으로 바꾸는 작업을 했다.

 

이 API의 실행 로직 관련해서 로그를 찍어 보았더니 400 AxiosError가 나오고 있었다. 즉 여기서 400 에러가 리턴되어서 AsyncStorage에 저장된 액세스토큰의 값이 최신값으로 반영되지 않았고, 그래서 이후에 유저 정보를 받아오는 API에서 401 에러가 호출되는 것이었다. 

 

무엇이 잘못되었을까? api 호출 로직의 내부를 보았다. 서버에서는 '400 Bad Request'라는 응답만 내려줄 뿐, 무엇 때문에 400 에러가 났는지는 알 수 없었다. (어서 빨리 Fargate를 EC2로 바꿔야 하는 이유 중 하나이기도 하다...) 아무튼 그래서 임시방편으로 develop 브랜치 상태인 로컬 서버를 실행시킨 다음 해당 로컬 서버로 API 요청을 보내보았다. 

 

그랬더니 구글로그인 API에서 401 Unauthorized 에러가 나고 있었다. 이해할 수 없는 에러였다. 왜냐하면 구글로그인 API는 토큰값과 디바이스 토큰값만 제대로 보낸다면 클라이언트에서 액세스토큰 없이도 접근 가능해야 하는 API였기 때문이다. 개발서버에서도 똑같은 패턴의 에러가 나고 있었다. 즉 400 에러는 구글로그인 API가 아니라 토큰 갱신(refresh) API에서 토큰값이 null로 되어있기 때문에 나는 에러로 보였다. 

 

가능한 원인들은 여러 가지였다. 예를 들면 해당 구글로그인 API에서는 파이썬에서 사용 가능한 google.oauth 라이브러리를 사용해서 앞서 프론트에서 구글로그인을 통해 받은 idToken을 인증한다. 이때 401 에러가 났을 가능성도 있다. 이 경우에는 프론트에서 사용하는 GOOGLE_CLIENT_ID의 값과 백엔드에서 사용하는 GOOGLE_CLIENT_ID의 값이 같은지 확인해야 되겠다. 

 

그런데 이 원인 때문은 아니었다. API가 호출되는 시점에 로그를 찍고 있었는데, 그 로그조차 호출되지 않으니 아예 요청이 거부가 되는 것이었다. 미들웨어 단에서 무언가가 요청을 거부한다고 추측했다. 내가 현재 아는 요청을 거부할만한 미들웨어는 DRF의 미들웨어가 있었으니, settings.py에 DRF 설정을 추가해서 기본 permission을 AllowAny로 변경해주었다. 

# settings.py
REST_FRAMEWORK = {
    # 기존 내용
    'DEFAULT_PERMISSION_CLASSES': [
        'rest_framework.permissions.AllowAny',
    ],
}

 

여전히 API 호출 시점에 로그가 찍히지 않아서 이 문제도 아니었다. 해당 코드는 원래대로 돌려놓았다.

즉 DRF 기본 미들웨어가 아닌 다른 미들웨어에서 요청을 막고 있을 가능성도 있다. 현재 사용 중인 미들웨어는 다음과 같았다. 

# settings.py
MIDDLEWARE = [
    "corsheaders.middleware.CorsMiddleware",
    "django.middleware.security.SecurityMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
    "allauth.account.middleware.AccountMiddleware",
    "djangorestframework_camel_case.middleware.CamelCaseMiddleWare",
]

 

또한 DRF에서 사용하는 기본 인증 클래스(default authenticationn classes)의 값으로 설정해 준 authentication backend에서도 미들웨어로 요청을 가로챌 수 있기에, 이 부분도 고려해야 하겠다. 

# settings.py
REST_FRAMEWORK = {
    # 다른 내용
    "DEFAULT_AUTHENTICATION_CLASSES": (
        "rest_framework_simplejwt.authentication.JWTAuthentication",
    ),
}

 

현재 미들웨어 중 고려해볼 만한 것들은 다음과 같겠다. 

1. allauth 라이브러리의 AccountMiddleware

2. django에서 기본으로 제공하는 AuthenticationMiddleware

3. django에서 기본으로 제공하는 SessionMiddleware

4. 기본 인증 클래스로 정의된 JwtAuthentication(Middleware)

 

이 중에서 1번은 사실상 allauth 라이브러리가 설치는 되어있지만 코드에서 직접 사용하지 않았고, 2번과 3번은 장고에서 기본으로 제공하는 미들웨어라서 처음부터 정의되어 있었는데 어느 시점까지는 잘 동작했다. 즉 갑자기 동작을 바꿀 가능성은 거의 없다. 따라서 이 중에서 4번에 문제가 있겠다고 생각했다. 

 

우선은 1번 라이브러리의 dependency를 제거하기 위해서 middleware 변수에서 해당 부분을 빼고 실행해 보았다. 그랬더니 에러가 났다. allauth 라이브러리를 사용하는 상황에서 AccountMiddleware를 빼면 안 되나보다. 

 

사실 원칙상 사용하지 않는 라이브러리는 uninstall하고 dependency에서 지우는 게 맞다. 그런데 이걸 지우다가 무슨 에러가 날지도 모르기에, 일단은 덮어두기로 했다. 아니면 사실 allauth는 장고 소셜로그인에서 가장 많이 사용되는 라이브러리이니 simplejwt 라이브러리 대신 allauth를 사용해서 JWT 소셜로그인을 사용해도 되긴 하다(사실 이게 젤 바람직한 방법이다). 

 

그러나 지금은 일단은 '정상적으로 동작하는 배포'가 급해서, 이 문제를 지라 백로그에 일단 기록해둔 다음 중간평가 끝나고 처리해두기로 했다. 

그래서 이 부분은 그대로 두고, 다른 문제 후보인 JwtAuthenticationMiddleware의 동작 코드를 보면서 로그를 좀 찍어보았다. 다행히 해당 미들웨어의 authenticate 메소드까지는 호출이 되고 있었다. 구글로그인 API를 호출할 때에는 Bearer 토큰값이 null인 것이 당연한 거라서, 해당 부분에는 문제가 없다. 그런데 사실 그 밑에 추가로 로그를 찍어 놓았었는데, 그 부분까지는 도달하지 못한 것으로 보였다. 

 

혹시나 해당 미들웨어(메소드)에서 none을 리턴해서 401 에러가 날 가능성도 생각해야 했다. 그러나 구글로그인 API는 토큰을 넣을 수가 없는 API이기에, 특정 API에 대해서 해당 미들웨어를 우회하는 방법을 찾아보았다. 알고보니 APIView의 필드값으로 authentication_classes의 값을 빈 배열로 설정하면, 해당 뷰에 대해서는 인증 미들웨어를 우회할 수 있었다. 

그랬더니 구글로그인을 무사히 통과하여 투두 뷰 화면이 나왔다!

 

궁금한 점

1. axios 인스턴스를 create할 때 timeout 설정이 있다. 이게 정확히 뭘 의미하는건지 궁금하다. 

2. expo router과 react의 navigation의 차이가 궁금하다. 

3. router의 원리도 새삼스럽지만 갑자기 궁금해졌다. 

4. 왜 안드로이드에서는 자바를 사용해서 빌드를(컴파일인지 빌드인지 모르겠다... 암튼) 진행할까? 소스 코드가 자바로 되어 있다는 건 아는데 왜 자바 말고 다른 언어로는 아예 실행할 수 없는 것인지도 궁금하다. 

5. LocalStorage와 AsyncStorage의 차이가 무엇일까?

6. useCallback의 원리가 잘 이해되지 않는다. 함수를 캐싱한다는 것이 무슨 의미일까? 그리고 그게 앱의 성능에 유의미한 영향을 줄 정도로 중요한가? 라는 의문이 든다. 

7. permission_classes와 authentication_classes의 의미 차이가 궁금하다. 인증과 인가의 차이일까?

 

+ Recent posts