B2B 서비스의 복잡한 인증/인가 플로우 및 레거시 코드 개선하기

들어가며

B2B 서비스의 인증(Authentication) 및 인가(Authorization) 시스템은 B2C에 비해 복잡한 구조를 갖습니다. 여러 기업 관리자 계정, 권한 계층, 그룹 단위의 접근 제어 등 고려해야 할 요소가 많기 때문입니다. 잘 설계된 인증/인가 시스템은 사용자 경험을 해치지 않으면서도 데이터 보안과 접근 제어를 효과적으로 관리할 수 있게 해줍니다.

이번 글에서는 제가 담당 중인 기업 관리자용 서비스의 인증/인가 시스템을 개선하며 겪었던 기술적 고민과 문제 해결 과정, 그리고 얻은 인사이트를 공유하고자 합니다. 단순한 코드 개선을 넘어 전체 시스템 아키텍처와 사용자 경험까지 고려한 여정을 담았습니다.

이 글에서는 다음과 같은 문제들을 어떻게 개선했는지 기록해보려 합니다.

  • 페이지별 접근 제어 로직의 복잡성
  • 토큰 상태 관리의 분산과 불필요한 파생 상태
  • API 요청 시 헤더의 토큰 세팅 방식
  • 인증/인가 실패(401, 403)에 대한 에러 처리
  • 다중 탭 환경에서의 인증/인가 상태 관리

개선 배경

해당 서비스는 본래 기업 관리자들을 위한 플랫폼이었지만, 그동안은 대부분의 운영을 내부 운영팀이 관리를 대행하고 있었습니다. 이로 인해 인증/인가와 관련된 문제가 실제 사용자에게 큰 이슈로 확산되지 않았고, 레거시 코드도 유지된 채 방치되어 있었습니다.

그러나 최근, 서비스 본래의 목적에 맞게 기업 관리자들이 직접 기능을 활용할 수 있도록 많은 기능이 추가되었고, 이에 따라 인증/인가 플로우의 안정성과 신뢰성을 확보하는 것이 중요한 과제가 되었습니다.

Partner 서비스만의 인증 플로우 특징: 이중 토큰 시스템

먼저 서비스의 인증 플로우를 이해하는 것이 앞으로의 설명에 도움이 될 것 같습니다. Partner 서비스는 조직 내 여러 멤버 그룹 단위의 인가 처리가 필요한 구조를 가지고 있습니다. 사용자가 로그인하면 최근 접속한 멤버 그룹이 자동으로 선택되며, 선택된 멤버 그룹에 따라 접근 가능한 데이터가 달라집니다.

🔑 인증 플로우

1. 로그인 (이메일/비밀번호) → Access Token 발급
2. 멤버 목록 조회 → 멤버 그룹 정보 획득
3. 멤버 선택 → 해당 멤버 ID로 Member Token 발급
4. 두 토큰 모두 유효할 때 최종 인증 완료

🔑 토큰 정리

  • Access Token: 로그인 여부 판단 (사용자 인증)
  • Member Token: 선택한 멤버 그룹의 권한을 나타냄 (권한 인가)

이러한 이중 토큰 구조는 사용자가 속한 여러 조직이나 역할 사이를 쉽게 전환하여 효율적으로 관리할 수 있게 해주지만, 그만큼 구현 복잡도도 증가합니다.

기존 시스템의 문제점과 리팩토링 방향

1. 접근 제어방식

인증/인가 시스템을 리팩토링하려는 초기 단계에서 가장 먼저 마주한 레이어는 접근 제어 로직이었습니다. 처음에는 이 부분만 고치면 되겠다고 생각했지만, 실제로는 토큰의 상태 관리 방식까지 손을 대야 했습니다. 그 이유는, 접근 제어 로직이 서비스의 복잡한 비즈니스 로직과 강하게 결합되어 있었기 때문입니다. 인증/인가 상태 판단을 위한 여러 상태들이 산발적으로 존재했고, 그 로직을 단일 파일에서 모두 처리하고 있었기에 유지보수가 점점 어려워졌습니다.

AS-IS 🚨 단일 HOC에 모든 인증/인가 로직이 몰려 있음

기존에는 withAuthorization이라는 HOC 하나에서 인증/인가 로직을 모두 처리하고 있었습니다.

withAuthorization.tsx
export const withAuthorization = <Props = unknown,>(Component: ComponentType<Props>) => {
  const WrappedComponent = (props: Props) => {
    const { accessToken, memberToken, isAuthorized, isTokenExpired, getMember, logout } = useAuth();
    
    useEffect(() => {
      if (!accessToken || !memberToken) {
        // 토큰이 하나라도 없는 경우 signin으로 이동
        logout();
        const redirectPathname = location.pathname;
        navigate('/auth/signin' + (redirectPathname ? `?${REDIRECT_URL_PARAM}=${redirectPathname}` : ''));
        return;
      }
 
      getMember();
    }, [accessToken, memberToken]);
 
    useEffect(() => {
      if (isTokenExpired) {
        // 토큰이 존재하나 만료된 경우
        logout();
        const redirectPathname = location.pathname;
        navigate('/auth/signin' + (redirectPathname ? `?${REDIRECT_URL_PARAM}=${redirectPathname}` : ''));
        return;
      }
    }, [isTokenExpired]);
 
    return <>{!isAuthorized ? <div>need login</div> : <Component {...props} />}</>;
  };
  return WrappedComponent;
};

이 접근 방식에는 다음과 같은 문제점이 있었습니다.

  • 역할 구분 없는 단일 HOC - AccessToken(인증)과 MemberToken(인가)에 대한 처리가 단일 컴포넌트에 혼재되어 있어 인증/인가 상태에 따라 페이지 접근 정책을 추가하기 힘든 구조였습니다.
  • 모호한 상태 처리 - accessToken, memberTokenisAuthorized, isTokenExpired 같은 파생 상태가 함께 사용되어 비즈니스 로직이 명확하게 구분되지 않았습니다.
  • 반대 케이스 처리 불가 - 인증된 사용자를 특정 페이지(예: 로그인 페이지)에서 차단하는 반대 케이스를 명확히 처리할 방법이 없었습니다.

TO-BE ✅ 역할별로 분리된 접근 제어 HOC 구성

개선 방향은 인증/인가 상태를 명확히 구분하여, 역할에 따라 접근을 제어하는 HOC를 분리하는 것이었습니다. 이렇게 하면 인증 로직이 명확해지고, 페이지별 접근 허용 조건을 코드 레벨에서 직관적으로 표현할 수 있으며, 페이지별 인증/인가 정책 확장에 유연해질 수 있습니다.

✅ 접근 허용용 HOC

  • withAuthenticatedOnly : 로그인 사용자만 접근 가능
  • withAuthorizedOnly : 멤버토큰이 존재하는 사용자만 접근 가능
  • withAuthedOnly : 로그인 + 멤버토큰 모두 갖춘 사용자만 접근 가능
withAuthedOnly.tsx
// 로그인 사용자만 접근 가능
const withAuthenticatedOnly = <Props = unknown,>(Component: ComponentType<Props>) => {
  const WrappedComponent = (props: Props) => {
    const { signOut } = useSignOut();
    const [isAuthenticatedPageRenderable, setIsAuthenticatedPageRenderable] = useState(false);
 
    useEffect(() => {
      const checkAccessToken = () => {
        const isAuthenticated = getAccessToken();
        if (isAuthenticated) {
          setIsAuthenticatedPageRenderable(true);
        } else {
          setIsAuthenticatedPageRenderable(false);
          signOut({ goToSignInPage: true, redirectUrl: true });
        }
      };
      // ...
    }, []);
    return isAuthenticatedPageRenderable ? <Component {...props} /> : <>Loading...</>;
  };
  return WrappedComponent;
};
 
// 멤버토큰이 존재하는 사용자만 접근 가능
const withAuthorizedOnly = <Props = unknown,>(Component: ComponentType<Props>) => {
  const WrappedComponent = (props: Props) => {
    const { signOut } = useSignOut();
    const [isAuthorizedPageRenderable, setIsAuthorizedPageRenderable] = useState(false);
    
    useEffect(() => {
      const checkMemberToken = () => {
        const hasMemberToken = getMemberToken();
        if (hasMemberToken) {
          setIsAuthorizedPageRenderable(true);
        } else {
          if (getAccessToken()) {
            setMemberTokenMutate();
          } else {
            setIsAuthorizedPageRenderable(false);
            signOut({ goToSignInPage: true, redirectUrl: true });
          }
        }
      };
      // ...
    }, []);
    return isAuthorizedPageRenderable ? <Component {...props} /> : <>Loading...</>;
  };
  return WrappedComponent;
};
 
// 로그인 + 멤버토큰 모두 갖춘 사용자만 접근 가능
const withAuthedOnly = <Props = unknown,>(Component: ComponentType<Props>) => {
  const WrappedComponent = withAuthenticatedOnly(withAuthorizedOnly(Component));
  return WrappedComponent;
};

🚫 접근 차단용 HOC

반대로, 로그인한 사용자가 다시 로그인 페이지나 회원가입 페이지에 접근하는 것을 차단할 필요도 있습니다.

  • withUnAuthenticatedOnly : 로그인한 사용자는 접근 불가 (예: 로그인/회원가입)
  • withUnAuthorizedOnly : 멤버토큰이 있는 사용자는 접근 불가
  • withUnAuthedOnly : 완전히 비인증 상태 사용자만 접근 가능
withUnAuthedOnly.tsx
// 로그인한 사용자는 접근 불가
export const withUnAuthenticatedOnly = <Props = unknown,>(Component: ComponentType<Props>) => {
  const WrappedComponent = (props: Props) => {
    const navigateLandingPage = useNavigateLandingPage();
    const { signOut } = useSignOut();
    const [isUnAuthenticatedPageRenderable, setIsUnAuthenticatedPageRenderable] = useState(false);
 
    useEffect(() => {
      const checkAccessToken = () => {
        const hasAccessToken = getAccessToken();
        if (hasAccessToken) {
          setIsUnAuthenticatedPageRenderable(false);
          navigateLandingPage();
        } else {
          setIsUnAuthenticatedPageRenderable(true);
          if (getMemberToken()) {
            signOut({ goToSignInPage: false });
          }
        }
      };
      // ...
    }, []);
    return isUnAuthenticatedPageRenderable ? <Component {...props} /> : <>Loading...</>;
  };
  return WrappedComponent;
};
 
// 멤버토큰이 있는 사용자는 접근 불가
export const withUnAuthorizedOnly = <Props = unknown,>(Component: ComponentType<Props>) => {
  // ... similar implementation
};
 
// 완전히 비인증 상태 사용자만 접근 가능
const withUnAuthedOnly = <Props = unknown,>(Component: ComponentType<Props>) => {
  const WrappedComponent = withUnAuthenticatedOnly(withUnAuthorizedOnly(Component));
  return WrappedComponent;
};

실제 사용할 때는 다음과 같이 사용합니다.

partner/src/views/DefaultLayout.tsx, partner/src/views/AuthLayout.tsx
// 인증된 사용자만 접근 가능한 레이아웃
export default withAuthedOnly(DefaultLayout);
 
// 비인증 사용자만 접근 가능한 레이아웃
export default withUnAuthedOnly(AuthLayout);

정리하자면, authedOnly, unAuthedOnly와 같은 역할별 HOC로 접근 제어를 명확히 구분함으로써, 비즈니스 로직에 맞는 인증 흐름을 코드로 자연스럽게 표현할 수 있게 되었습니다.

물론 인증/인가 조건이 단순한 서비스라면 이처럼 HOC를 세분화하지 않고도 충분히 관리할 수 있습니다. 하지만 해당 서비스처럼 멤버 권한이 세분화되어 있고, 비즈니스 상태가 복잡하게 얽혀 있는 경우라면, HOC 분리는 유지보수성과 가독성 모두에 큰 도움이 될 수 있습니다.

2. 토큰 상태 관리

기존 로그인 플로우는 다음과 같은 순서로 이루어져 있었습니다.

1. 로그인 API 호출
2. accessToken 수신
3. Redux/로컬스토리지에에 accessToken 토큰 저장
4. API 클라이언트 헤더에 accessToken 토큰 설정
5. accessToken 토큰으로 사용자의 멤버 정보 조회
6. 선택된 멤버의 memberToken 호출
7. Redux에 memberToken 토큰 저장
8. 성공 시 Redux의 setIsAuthorized 상태 변경
9. 메인 페이지로 이동, 실패 시 로그아웃

이 구조에서는 Redux, localStorage, API 클라이언트가 서로 다른 책임을 가지며 인증 상태를 관리하고 있었습니다. 결과적으로 인증 상태가 분산되고, 동기화 문제가 발생할 수 있는 구조였습니다.

AS-IS 🚨 토큰 상태 관리의 분산 및 불필요한 파생상태

기존 시스템에서는 다음과 같이 토큰 상태가 여러 곳에 분산되어 있었습니다.

// Redux store에서의 토큰 관리
const initialState = {
  accessToken: localStorage.getItem('ACCESS_TOKEN'),
  memberToken: localStorage.getItem('MEMBER_TOKEN'),
  isAuthorized: false,  // 파생 상태
  isTokenExpired: false // 파생 상태
};
 
// API 클라이언트에서의 토큰 관리
class CrudClient {
  static setAccessToken(token: string) {
    this.clientConfig.headers.authorization = `bearer ${token}`;
  }
 
  static setMemberToken(token: string){
    this.clientConfig.headers.'member-token' = token;
  }
}
 
// 로그인 처리 시
function* signin(action) {
  try {
    // 1. 로그인 API 호출
    const response = yield call(api.auth.post, action.payload);
    
    // 2. 토큰 수신 및 저장
    const { access_token } = response.data;
    // Redux store에 토큰 저장
    yield put(actions.setAccessToken(access_token));
    // 로컬스토리지에 토큰 저장
    localStorage.setItem('ACCESS_TOKEN', access_token);
    // api client header에 토큰 세팅
    CrudClient.setAccessToken(access_token);
    
    // 3. 멤버 정보 조회
    const memberResponse = yield call(api.member.get);
    yield put(actions.setMember(memberResponse.data));
    
    // 4. 인증 상태 설정
    yield put(actions.setIsAuthorized(true));
  } catch (error) {
    yield put(actions.logout());
  }
}
 
// 로그아웃 처리 시
function logout() {
  // 모든 상태 초기화
  dispatch(actions.logout());
  localStorage.removeItem('ACCESS_TOKEN');
  localStorage.removeItem('MEMBER_TOKEN');
  CrudClient.setAccessToken('');
}

이러한 구조에서는 다음과 같은 문제점이 있었습니다.

  • 토큰 상태가 Redux store, localStorage, API 클라이언트에 분산되어 관리되어 상태 변경 시 여러 곳을 동시에 수정해야 합니다.
  • isAuthorized, isTokenExpired 등의 파생 상태를 명시적으로 관리해야합니다.
  • 상태 불일치 시 예측하기 어려운 버그가 발생할 가능성이 높습니다.

TO-BE ✅ 토큰 상태 관리 일원화

파생상태와 전역으로 관리하고 있던 토큰 상태들을 정리하며, 브라우저 저장소(localStorage / sessionStorage) 를 통해 토큰 및 상태 관리를 일원화 시켰습니다. 토큰의 유무효 판단은 API 응답을 기반으로 직접 판단하여 관리 포인트를 줄이고 동기화 이슈를 해소하고자 하였습니다.

1.토큰 관리 유틸리티 (utils/auth.ts)
// Access Token은 localStorage에 저장
export const getAccessToken = () => {
  return getLocalStorageItem(config.ACCESS_TOKEN);
};
 
export const setAccessToken = (accessToken: string) => {
  setLocalStorageItem(config.ACCESS_TOKEN, accessToken);
};
 
// Member Token은 sessionStorage에 저장
export const getMemberToken = () => {
  return getSessionStorageItem(config.MEMBER_TOKEN);
};
 
export const setMemberToken = (memberToken: string) => {
  setSessionStorageItem(config.MEMBER_TOKEN, memberToken);
};
 
// 토큰 제거
export const removeTokens = () => {
  removeLocalStorageItem(config.ACCESS_TOKEN);
  removeSessionStorageItem(config.MEMBER_TOKEN);
};
 
// 인증/인가 상태 체크
export const getIsAuthenticated = () => {
  return !!getAccessToken();
};
 
export const getIsAuthorized = () => {
  return !!getAccessToken() && !!getMemberToken();
};

실제로는 클라이언트 저장소에 저장된 토큰이 항상 유효하다고 보장할 수 없기 때문에, 위와 같은 유틸 함수들은 점차 사용 빈도가 줄어들고 있습니다. 이제는 대부분의 인증 여부 판단을 API 호출 결과 기반으로 처리합니다. 다만, API 호출 전 클라이언트에서 인증/인가 상태에 따른 조건 분기가 필요한 경우가 있어, 해당 유틸은 일부 남겨두고 사용 중입니다.

2. 로그인 처리 (useSignIn.ts)
const useSignIn = () => {
  // ...
  const { mutateAsync: setMemberTokenMutate } = useMutation({
    mutationFn: getMemberToken,
    onSuccess: ({ memberToken }) => {
      setMemberToken(memberToken);
    },
  });
 
  const { mutateAsync: signInMutate } = useMutation({
    mutationFn: getAccessToken,
    onSuccess: async ({ accessToken }) => {
      setAccessToken(accessToken);
      await setMemberTokenMutate();
      // 랜딩페이지로 이동
    },
    onError: (error) => {
      // 에러 처리
    }
  });
 
  return { signIn: signInMutate };
};
3. 로그아웃 처리 (useSignOut.ts)
export const useSignOut = () => {
  // ...
  const signOut = () => {
    removeTokens();
    queryClient.removeQueries();
    navigate('/auth/signin');
  };
 
  return { signOut };
};

이제 로그인/로그아웃 처리 시 브라우저 스토리지의 토큰 값만 관리하면 됩니다.😀

또한 토큰 관리 유틸리티를 단일 파일로 통합하여 관리하고 있어, 추후 토큰 저장소나 유효성 검사 로직이 변경되더라도 해당 파일만 수정하면 됩니다.

3. Header의 Token 세팅 방식

API 요청 시 인증 토큰을 헤더에 포함시키는 방식은 인증/인가 시스템의 핵심입니다. 폐쇄적인 B2B 서비스의 특성상, 대부분의 API 요청에는 인증 토큰이 필수로 포함되어야 하며, 서버는 해당 토큰을 검증하여 요청에 대한 적절한 응답을 반환합니다.

기존 시스템에서는 이 토큰 설정을 정적인 객체로 관리했으며, 외부에서 해당 객체를 직접 수정하거나, 갱신된 clientConfig를 API 요청 시마다 주입하는 구조였습니다.

AS-IS 🚨 정적 세팅

기존에는 클라이언트 인스턴스를 생성할 때, 인증 토큰을 포함한 헤더 값을 초기 설정(config)에 하드코딩하거나 Redux 액션을 통해 수동으로 동기화했습니다.

const clientConfig = {
  headers: {
    authorization: '',
    'member-token': '',
    // ...
  },
};
static setAccessToken(accessToken?: string) {
  CrudClient.clientConfig.headers.authorization = accessToken
    ? `bearer ${accessToken}`
    : `bearer ${getLocalStorageItem(config.ACCESS_TOKEN)}`;
}
reducers: {
  setAccessToken(state, action) {
    setLocalStorageItem(config.ACCESS_TOKEN, action.payload);
    CrudClient.setAccessToken(action.payload); // 동기화
    state.accessToken = action.payload;
  },
}

로그인 시에는 다음과 같이 토큰을 설정했습니다.

authSlice.ts
const slice = createSlice({
  name: 'auth',
  initialState,
  reducers: {
    setAccessToken(state, action) {
      setLocalStorageItem(config.ACCESS_TOKEN, action.payload);
      CrudClient.setAccessToken(action.payload);
      state.accessToken = action.payload;
    },
    //...
  }
})

이러한 client config header 세팅 방식에는 다음과 같은 문제점이 있었습니다.

  • 클라이언트 설정 시점에 토큰이 고정되기 때문에, 이후 토큰이 갱신되더라도 동기화되지 않을 수 있습니다.
  • Redux → localStorage → API 클라이언트로 이어지는 상태 전달 체인이 존재해 구조가 복잡해지고, 버그 발생 가능성도 높아집니다.
  • 브라우저 저장소의 토큰이 변경되더라도 이를 클라이언트가 감지할 방법이 없습니다.

TO-BE ✅ 요청 시점의 동적 세팅 방식

리팩토링 이후에는 API 요청 시점마다 브라우저 저장소에서 최신 토큰을 읽어와 헤더를 구성합니다. 이로써 상태 불일치 문제를 근본적으로 해결하고, Redux에 대한 의존성도 제거할 수 있었습니다.

const getClientConfig = () => ({
  headers: {
    authorization: getAccessToken() ? `bearer ${getAccessToken()}` : '',
    'member-token': getMemberToken() ?? '',
    'content-type': 'application/json',
  },
});
 
get<T>(params: any) {
  return httpClient.sendGetRequest<T>(/*...*/, {
    ...getClientConfig(),
    params,
  });
}
 

이 방식의 주요 장점은 다음과 같습니다

  • 항상 최신 토큰을 사용하므로, 토큰 갱신 후에도 별도의 동기화 작업이 필요 없습니다.
  • Redux → localStorage → 클라이언트 설정으로 이어지는 토큰 전달 체인도 제거되어 구조가 단순해졌습니다.
  • 여러 클라이언트 인스턴스를 사용할 때도 일관된 방식으로 헤더를 설정할 수 있습니다.
  • 코드의 가독성과 예측 가능성이 향상되었습니다.

4. API 에러(401, 403) 처리

API 요청 중 401(Unauthorized) 또는 403(Forbidden) 에러가 발생하는 경우, 이는 사용자의 인증 상태가 유효하지 않거나 해당 리소스에 대한 접근 권한이 없음을 의미합니다. 이런 상황에서는 적절한 에러 처리를 통해 사용자를 로그인 페이지로 리다이렉트하거나, 권한이 없는 접근을 차단해야 합니다.

앞서 토큰 저장소를 브라우저 저장소로 일원화하면서, Redux에 대한 의존성이 제거되었습니다. 이에 따라 기존에 Redux-Saga를 통해 분산적으로 관리되던 로그인 에러 처리를, 보다 일관되고 독립적인 방식으로 개선할 필요가 있었습니다.

AS-IS 🚨 서버 상태 관리 라이브러리에 의존한 에러 분산과 일관성 부족

기존에는 API 호출 방식과 사용하는 라이브러리에 따라 인증/인가 에러를 서로 다르게 처리하고 있었습니다. • 로그인 API 호출은 Redux-Saga를 통해 에러를 관리했습니다. • 최근 추가된 API들은 TanStack Query를 통해 호출되어 이 또한 라이브러리에 의존해 에러를 처리했습니다. • 일부 API는 상태 관리 라이브러리를 사용하지 않고 직접 호출되었고, 별도의 에러 핸들러를 사용했습니다.

또한 다음과 같이 다양한 방식으로 비슷한 에러를 처리하고 있었습니다.

// 1. HttpClientErrorHandler
case 401:
  yield put(this.#authActions.logout());
  window.location.href = '/auth/signin';
  return;
 
// 2. useApiErrorHandler
case 401:
  dispatch(slices.actions.auth.logout());
  window.location.href = '/auth/signin';
  return;
 
// 3. errorHandler
case 401:
  customHandler?.();
  break;

이처럼 인증 에러 처리 로직이 여러 곳에 흩어져 있으면서 다음과 같은 문제가 있었습니다.

  • 동일한 에러 상황에서도 각자 다른 방식으로 처리되었습니다.
  • 에러 처리 로직 변경 시 여러 파일을 수정해야 했습니다.
  • 새로운 에러 처리 로직 추가 시 어떤 핸들러를 사용해야 할지 결정하기 어려웠습니다.
  • 몇 에러 핸들러는 상태 관리 라이브러리에 강하게 의존하여, 외부 모듈이나 독립적인 API 호출에서는 동일한 인증 처리 로직을 재사용할 수 없어 유연성이 떨어졌습니다.

TO-BE ✅ 통합 에러 핸들링 레이어 구성

리팩토링 이후에는 모든 API 요청을 하나의 상위 레이어에서 통합적으로 감싸 인증 실패를 처리하도록 구조를 개선했습니다.

개선 방향

  • 모든 API요청을 공통 함수로 래핑하여 에러 코드에 따라 일관된 에러 처리를 적용합니다.
  • 요청 전에 토큰이 없을 경우, 서버로의 요청 자체를 차단하여 불필요한 트래픽을 방지합니다.
  • 글로벌 interceptor를 사용할 수 없는 구조적 제약을 우회하기 위해, 각 요청을 래퍼 함수로 감싸 interceptor와 유사한 흐름을 구현했습니다.
1. 공통 에러 핸들러
const apiErrorHandler = (error: any) => {
  const signOut = () => {
    removeTokens();
    if (window.location.pathname !== '/auth/signin') {
      window.location.href = '/auth/signin';
    }
    queryClient.clear();
  };
 
  if (isHttpClientError(error)) {
    const status = error.response.status;
    switch (status) {
      case 401:
      case 403:
        signOut();
        return;
      case 404:
        // Not Found 처리
        return;
      default:
        throw error;
    }
  }
  throw error;
};
2. API 요청 래퍼
const sendRequestWithErrorHandler = async <T>(request: () => Promise<HttpResponse<T>>) => {
  try {
    return await request();
  } catch (error) {
    if (error instanceof Error) {
      apiErrorHandler(error);
    } else {
      logger.error('unknown error', {}, error);
    }
    throw error;
  }
};
3. API 호출 예시
export class CrudClient {
  private static clientConfig: ClientConfig;
  // ...
  get<T>(params: any) {
    return sendRequestWithErrorHandler(() =>
      httpClient.sendGetRequest<T>(/*...*/, {
        ...getClientConfig(),
        params,
      })
    );
  }
  // put, post, delete 요청 메소드도 동일
}

이렇게 통합 API 에러 핸들링 레이어를 도입한 결과, 다음과 같은 이점을 얻을 수 있었습니다.

  • 모든 API 요청에 일관된 에러 처리 로직이 적용됩니다.
  • 인증 실패 발생 시, 로그아웃 및 로그인 페이지로의 리다이렉트가 확실히 보장됩니다.
  • 중복된 에러 핸들러가 제거되어 코드 유지보수가 쉬워졌습니다.
  • 상태 관리 라이브러리에 종속되지 않는 독립적인 에러 처리가 가능해졌습니다.

5. 탭 간 인증 상태 공유, 인가 상태는 개별 관리

AS-IS 🚨 고려되지 않은 다중 탭 정책

기존 구현에서는 다중 탭 환경을 전혀 고려하지 않았습니다. 사용자가 하나의 탭에서 로그인한 이후, 다른 탭으로 접근할 경우에도 인증 상태가 공유되지 않아 다시 로그인해야 하는 불편함이 있었습니다. 또한 멤버 그룹 상태도 탭마다 강제로 공유되어, 여러 멤버 계정을 병렬로 확인하고 싶은 요구를 충족시킬 수 없었습니다.

TO-BE ✅ 인증은 공유, 인가는 독립적으로

이러한 문제를 해결하기 위해, 인증/인가 정보를 다음과 같이 구분하여 관리하도록 리팩토링했습니다.

  • Access Token: localStorage → 브라우저 탭 간 공유 가능하도록 설정

  • Member Token (MT): sessionStorage → 탭마다 독립적인 인가 상태 관리

1. 토큰 저장 및 관리 (/utils/auth.ts)
// Access Token은 localStorage에 저장 (탭 간 공유)
export const getAccessToken = () => {
  return getLocalStorageItem(config.ACCESS_TOKEN);
};
 
export const setAccessToken = (accessToken: string) => {
  setLocalStorageItem(config.ACCESS_TOKEN, accessToken);
};
 
// Member Token은 sessionStorage에 저장 (탭별 독립)
export const getMemberToken = () => {
  return getSessionStorageItem(config.MEMBER_TOKEN);
};
 
export const setMemberToken = (memberToken: string) => {
  setSessionStorageItem(config.MEMBER_TOKEN, memberToken);
};
2. 인증/인가 상태 체크 (withAuthedOnly.tsx, withUnAuthedOnly.tsx)
// 인증 상태 체크
const withAuthenticatedOnly = <Props = unknown,>(Component: ComponentType<Props>) => {
  return (props: Props) => {
   // ...
    useEffect(() => {
      const checkAccessToken = () => {
       // ...
      };
      checkAccessToken();
      window.addEventListener('storage', checkAccessToken);
      return () => {
        window.removeEventListener('storage', checkAccessToken);
      };
    }, []);
 
    return isAuthenticatedPageRenderable ? <Component {...(props as unknown as any)} /> : <>Loading...</>;
  };
 
  return WrappedComponent;
};
 
// 인가 상태 체크
const withAuthenticatedOnly = <Props = unknown,>(Component: ComponentType<Props>) => {
  return (props: Props) => {
   // ...
   useEffect(() => {
      const checkMemberToken = () => {
        // ...
      };
 
      checkMemberToken();
      window.addEventListener('storage', checkMemberToken);
      return () => {
        window.removeEventListener('storage', checkMemberToken);
      };
    }, []);
 
    return isAuthenticatedPageRenderable ? <Component {...(props as unknown as any)} /> : <>Loading...</>;
  };
 
  return WrappedComponent;
};

이벤트 리스너를 활용함으로써 사용자가 멤버를 변경하거나 로그아웃한 다른 탭의 변경 사항이 현재 탭에서도 반영되어, 인증 상태의 일관성을 유지할 수 있게합니다.

3. API 요청 시 토큰 사용 (/api/index.ts)
const getClientConfig = () => ({
  headers: {
    authorization: getAccessToken() ? `bearer ${getAccessToken()}` : '',
    'x-bpo-member-token': getMemberToken() ?? '',
    'content-type': 'application/json',
  },
});

개선된 전체 플로우

인증/인가 레거시 코드를 하나씩 정리해 나가면서, 전체 플로우 역시 보다 명확하고 일관성 있게 개선할 수 있었습니다.

auth-flow

이번 작업을 통해 인증이 필요한 페이지와 필요하지 않은 페이지에 대한 접근 흐름이 명확히 구분되었고, 각 단계별 처리 로직도 보다 견고하게 다듬어졌습니다. 이전에는 흐름이 복잡하고 예외 케이스에 대한 관리가 어려웠던 반면, 이제는 인증 상태에 따른 행동을 일관되게 관리할 수 있는 구조로 발전시킬 수 있었습니다.


개선을 통해 얻은 인사이트

이번 개선 작업은 단순한 코드 리팩토링을 넘어, 인증/인가 정책과 플로우를 함께 고민하며 서비스의 안정성과 확장성을 높이는 데 집중한 시간이었습니다. 작업을 진행하면서 많은 뿌듯함을 느끼기도 했지만, 동시에 아쉬움도 적지 않았습니다.

처음에는 인증 시스템 개선에만 집중할 계획이었지만, 연관된 기술 부채들이 하나둘 드러나면서 예상보다 작업 범위가 커지고 일정도 길어졌습니다. 이 과정을 통해, 리팩토링을 시작하기 전에 연관된 의존성과 레거시 코드를 명확히 파악하고 점진적인 개선 계획을 세우는 것이 얼마나 중요한지 깊이 깨달았습니다.

또한 다양한 인증/인가 시나리오에 대한 테스트가 충분하지 않다는 점도 절감했습니다. 수동 테스트에 많은 리소스와 시간이 소모되었고, 플로우의 중요도를 고려할 때 앞으로는 유닛 테스트와 통합 테스트를 기반으로 한 자동화 체계를 적극적으로 강화할 필요가 있음을 느꼈습니다.

아직 개선해야 할 부분은 많습니다. 에러 핸들링은 보다 구체적으로 다듬어야 하고, 리프레시 토큰 사용 및 브라우저 저장소를 활용한 토큰 관리 방식에 대해서도 추가적인 논의와 정비가 필요합니다.

이번 경험을 통해 인증/인가 시스템이 가지는 복잡성과 중요성을 다시 한 번 체감할 수 있었으며, 앞으로도 지속적으로 개선과 테스트를 이어가며 더 안정적이고 유연한 구조를 만들어갈 방향을 모색하고자 합니다.