중고나라 셀러 지원 센터의 다양한 부분을 개발하면서, 특히 interceptor를 활용해 인증/인가 기능을 구현한 배경과 마주했던 문제와 느낀 점을 전달하고자 이 글을 쓰게 되었다.
✨ 셀러 지원 센터에서의 인증/인가 구조
셀러 지원 센터에서 인증/인가 구조는 셀러와 관리자가 분리되었다. 두 사용자 그룹에 따라 각각 다른 접근 방식이 필요했다.
셀러 회원
셀러는 중고나라 웹사이트 회원가입 절차를 거쳐 셀러 지원 센터의 회원가입 절차를 진행할 수 있다. 그러나 회원 계정의 상태가 블랙이거나 이미 회원가입이 완료된 상태라면 회원가입이 제한되는 흐름이었다.
정상적으로 회원가입 화면에 진입했을 경우, 셀러는 아이디와 비밀번호를 입력하고 마케팅 수신 동의 항목을 거쳐 가입이 진행된다. 이 때, 중복되는 아이디는 사용 불가능하며 비밀번호를 8~16자의 영문과, 숫자, 특수문자를 조합하여야만 가입할 수 있다.
관리자 회원
관리자는 사내망 통합 인증 시스템을 통해 로그인을 진행해야 했다. 셀러와는 달리, 아이디와 비밀번호로 인증하는 방식이 아닌 joongna.com 도메인에서 공유되는 SSO 쿠키를 가지고 관리자용 로그인 API를 호출하여 토큰을 발급받는 것이다.
✨ interceptor로 인증/인가 구현
프로젝트는 Next.js를 사용하고 있었지만, 추후 SNS 로그인 방식이 도입될 가능성이 적었고, 셀러 지원 센터와 앱의 토큰을 번갈아 사용해야 하는 특성이 있었기에 ( 뒤에 설명 참고 ) next-auth 대신 직접 axios 라이브러리를 사용하여 인증/인가를 관리하기로 하였다.
우선, axios.create()로 Axios 인스턴스를 생성하여 여러 API 요청에 대해 공통으로 적용되도록 했다. 또한, 도메인별로 동일한 인증 로직을 유지할 수 있도록 interceptor 내부에서 코드를 구성하였다.
💡Request Interceptor
요청 전에 필요한 토큰을 헤더에 설정하거나 특정 상황에서 예외 처리하는 로직을 구현했다.
특정 상황에 대한 예를 들면, 셀러 화면에서 order API 호출 시 앱 토큰 발행, 토큰 자체가 없으면 로그아웃 처리 , 엑세스 재발급 요청 시 리프레시 토큰을 헤더에 적용하는 것이 있다.
📍 Order API 호출 시 거쳐야 하는 과정
앞서 언급한 특정 상황 중에서 일반적이지 않은 과정은 셀러 화면에서 Order API를 호출하는 상황이었다.
셀러 화면에서 Order API를 호출할 때는 카페 게시 이용권 결제나 구매 내역 조회 등의 결제 기능과 관련된 요청에서 이루어졌다. 해당 상황에서는 앱에서 발급받은 토큰을 이용해 호출하는 것이 필요했다. 다음은 Order API 호출 방식을 Diagram으로 표현한 것이다.
해당 로직을 request interceptor 내부에서 구현한 일부 코드이다.
// ✅ 앱 토큰 정보를 조회하여 헤더에 저장
const setJnTokenHeader = async (config: any) => {
try {
const { data: jnToken } = await fetchJnTokenClient();
const access_token = jnToken.data;
config.headers['Authorization'] = `Bearer ${access_token}`;
return config;
} catch (error) {
throw error;
}
};
instance.interceptors.request.use(
async function (config) {
const accessToken = Cookies.get(COOKIES.ACCESS_TOKEN);
const isAdmin = Cookies.get(COOKIES.IS_ADMIN) === 'true';
const checkUrl = config.url as string;
const isOrderAPI = checkUrl?.includes(ORDER API 경로)
if (isOrderAPI && accessToken && !isAdmin) {
return await setJnTokenHeader(config);
}
...
return config;
},
function (error) {
...
},
);
setJnTokenHeader 함수는 셀러 지원 센터 토큰을 통해 앱 토큰을 발급받아 요청의 Authorization 헤더에 Bearer ${access_token} 형식으로 설정하도록 작성하였다. 이후, request interceptor 내부에서는 isOrderAPI 변수를 통해 결제 관련 API 호출 여부를 확인하고, access token과 셀러 회원의 여부를 확인한 후 setJnTokenHeader 함수를 호출하여 토큰을 재설정하는 방식으로 처리하였다.
이를 통해 셀러 화면에서 Order API를 호출할 때 토큰을 재설정해야 하는 문제를 해결할 수 있게 되었다.
💡 Response Interceptor
다음으로 response interceptor 내부에서는 셀러와 관리자 요청 에러 및 토큰 에러를 처리하는 로직을 구현하였다. 주로 처리한 내용은 다음과 같다.
- 401 에러 발생으로 인한 새로운 액세스 토큰 발급 요청,
- 새로운 엑세스 토큰 요청에 대한 실패 시 로그아웃 처리
- 잘못된 형식으로 인한 403에러 처리
위 내용을 구현한 코드 일부이다.
// ✅ 토큰 갱신 처리 함수
const handleTokenRefresh = async ({isAdmin, config, refreshToken}:{isAdmin: boolean, config: any, refreshToken: string}) => {
try {
const { data } = isAdmin
? await fetchAdminTokenRefresh(refreshToken)
: await fetchSellerTokenRefresh();
...
config.headers['Authorization'] = `Bearer ${accessToken}`;
return axios(config);
} catch (error)
return Promise.reject(error);
}
};
// ✅ 로그아웃 처리 함수
const handleLogout = ({ isAdmin }: {isAdmin: boolean}) => {
resetCookie([COOKIES.ONE_DAY_CLOSE]);
window.location.href = isAdmin ? '/admin/logout' : '/seller/login';
};
instance.interceptors.response.use(
async function (response) {
...
},
async function (error) {
const { config, response } = error;
const refreshToken = Cookies.get(COOKIES.REFRESH_TOKEN);
const isAdmin = Cookies.get(COOKIES.IS_ADMIN) === 'true';
// ✅ 셀러 및 관리자 토큰 처리
if (response) {
const isTokenRefreshUrl = config.url.includes(isAdmin ? ADMIN_TOKEN_REFRESH_URL : SELLER_TOKEN_REFRESH_URL);
// ✅ 토큰 재발급 요청 실패 시 로그아웃 처리
if (isTokenRefreshUrl && response.status === 401) {
handleLogout({isAdmin});
return Promise.reject(error);
}
// ✅ 잘못된 토큰, 서명 문제 처리
if (response.status === 403 || !refreshToken) {
handleLogout({isAdmin});
return Promise.reject(error);
}
// ✅ 토큰 만료 시, 토큰 재발급 처리
if (response.status === 401 && !isTokenRefreshUrl && refreshToken) {
return handleTokenRefresh({isAdmin, config, refreshToken});
}
}
...
}
);
handleTokenRefresh 함수를 통해 셀러와 관리자 모두의 토큰 갱신을 처리하도록 구현했으며, handleLogout 함수를 통해 로그아웃 로직을 처리하도록 진행하였다. 이러한 로직을 response interceptor 내부에서 사용하여, 셀러와 관리자의 인증/인가 과정을 효율적으로 관리할 수 있게 되었다.
📌 Trouble Shooting
모든 구현 과정에는 늘 문제가 발생하기 마련이다. 내가 겪었던 핵심 문제는 2가지였다.
🚨 중복된 refresh token 발급 과정으로 인한 토큰 캐시 문제
셀러가 구매내역 화면에 진입할 때 여러 번의 order API 호출되는 현상이 있었다. 해당 과정에서 앱 토큰을 발급받을 때, 셀러 지원 센터의 access token이 만료되면 refresh token으로 새로운 access token을 발급받아 다시 앱 토큰을 발급해야 했다. 하지만 각각의 order API 호출에 대해 access token이 중복으로 발급되었다. 그 결과, 서버 내부적으로 한 번 토큰 캐싱이 어긋나는 현상이 발생하였다.
결국, 새로운 access token을 발급받아서 다시 order API를 호출해도 토큰 자체가 유효하지 않아 403 에러가 발생하여 로그아웃되는 현상이 발생했다.
💡 interceptor 내부에 방어 로직 구현
서버 측에서 원인을 분석하는 동안, 클라이언트에서 401 에러가 발생할 때 refresh token 을 중복으로 요청하지 않도록 interceptor 내부에 방어 로직을 구현하는 것이 필요하다고 판단했다.
let isTokenRefreshing = false;
const refreshSubscribers: any[] = [];
const onTokenRefreshed = (accessToken: string) => {
refreshSubscribers.map((callback) => callback(accessToken));
};
const addRefreshSubscriber = (callback: any) => {
refreshSubscribers.push(callback);
};
const addRetryOriginalRequest = (config: any) => {
return new Promise((resolve) => {
addRefreshSubscriber(async (accessToken: string) => {
config.headers.Authorization = 'Bearer ' + accessToken;
if (Order API 일 경우 ) {
// 앱토큰 발급받아서 헤더 토큰 재설정
resolve(axios(config));
}
resolve(axios(config));
});
});
};
const refreshAccessToken = async (config: any) => {
try {
const { data } = await 토큰재발급();
const access_token = data?.access_token;
const refresh_token = data?.refresh_token;
if (access_token && refresh_token) {
// 새로운 토큰을 쿠키에 저장
...
onTokenRefreshed(access_token);
return axios(config); // 요청 재시도
}
return config; // 토큰이 없으면 기본 config 반환
} catch (error) {
throw error;
} finally {
isTokenRefreshing = false; // 토큰 갱신 상태 초기화
}
};
const createAxiosInstance = (baseURL: string): AxiosInstance => {
const instance = axios.create({
baseURL,
});
instance.interceptors.request.use(
...
);
instance.interceptors.response.use(
async function (config) {
return config;
},
async function (error) {
const { config, response } = error;
...
// NOTE : 401에러, 엑세스 토큰 만료 셀러 토큰 재발급 요청
if (
config.url !== SELLER_TOKEN_REFRESH_URL &&
response.status === 401 &&
isRefreshToken
) {
if (!isTokenRefreshing) {
isTokenRefreshing = true;
return refreshAccessToken(config); // 토큰 갱신 함수 호출
} else {
return addRetryOriginalRequest(config); // 토큰 갱신 중 대기
}
}
...
},
);
return instance;
};
위 코드로 살펴볼 수 있듯이 response interceptor에서 access token 만료를 401 코드로 파악하게 되는 시점에 방어 로직을 구성하였고, 핵심 요소는 아래와 같다.
- isTokenRefreshing : 토큰 갱신이 진행 중인지 파악하기 위한 변수
- false : 토큰 갱신 진행 중 X
- true : 토큰 갱신 진행 중 O
- refreshSubscribers : 토근 갱신 중일 때, 발생한 다른 API 요청을 보류하고 토큰을 갱신하고 다시 요청을 보내도록 구현
- 토큰이 만료되고 갱신 중 일 때, 다른 API 요청들을 refreshSubscribers 배열에 콜백 함수로 저장
- 토큰이 성공적으로 갱신하면, onTokenRefreshed 함수를 호출
- refreshSubscribers 배열에 저장된 모든 요청을 재시도
이 2가지 변수와 함수를 통해 토큰 갱신 관리와 중복 요청 방지 그리고 대기 중인 요청 처리를 수행할 수 있게 되어 클라이언트 측에서의 안정성이 높아졌다.
🚨 셀러 / 관리자의 토큰 교차 문제
셀러 지원 센터에서는 관리자와 셀러가 서로 다른 도메인에서 인증을 진행하고 있었다. 관리자는 도메인에서 통합 인증 시스템의 쿠키를 공유할 수 있어야 로그인이 가능했기에 joongna.com 도메인, 셀러의 경우 joonggonara.co.kr 도메인을 사용하고 있었다.
사실 외부에서 사용하는 경우에는 joonggonara.co.kr 으로만 진입하는 상황이 대부분이었다. 문제는 사내망에서 관리자와 셀러 기능을 모두 사용해서 문의에 대응하거나 테스트 하는 상황이 지속적으로 진행된다는 것이었다. token 관리를 쿠키로 진행하고 있었던 상황 때문에, 사내망에서 관리자 도메인을 가지고 셀러 페이지에 진입하게 되면, 관리자 토큰을 통해 셀러 API를 호출하게 되면서 잘못 인증된 토큰이 헤더에 설정되는 문제가 발생하였다.
💡 middleware로 path 방어 로직 구현
셀러의 경우에는 /seller 하위 path를 사용하고 있었고, 관리자의 경우에는 /admin 하위 path를 사용하고 공통 페이지는 / 하위 path를 사용한다는 특징을 활용하여 middleware에서 path 방어로직을 구현하였다.
// 도메인에 따른 경로 차단 설정
function getBlockedPathForHost({host} :{ host: string}) {
return process.env.NEXT_PUBLIC_SELLER_WEB_SITE_DOMAIN.includes(host)
? '/admin'
: '/seller';
}
// 사용자 유형에 따른 리다이렉트 처리 함수
function handleRedirect({url , isAdmin } : {url: URL, isAdmin: boolean}) {
url.pathname = isAdmin ? '/admin' : '/seller';
url.search = '';
return NextResponse.redirect(url);
}
...
export default async function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
const url = req.nextUrl.clone();
const isAdminCookie = req.cookies.get(COOKIES.IS_ADMIN);
const isAdmin = isAdminCookie?.value === 'true';
const host = req.headers.get('host') as string;
const blockedForHost = getBlockedPathForHost({host});
if (blockedForHost && pathname.includes(blockedForHost)) {
return handleRedirect({url, isAdmin});
}
...
}
- blockedForHost
- 셀러 도메인을 가리키고 있다면 관리자로의 접근을 차단하기 위해 '/admin' 경로로 설정
- 관리자 도메인이라면 셀러로의 접근을 차단하기 위해 '/seller' 경로로 값을 설정
- pathname.includes(blockedForHost)
- 관리자 쿠키가 있는 경우 , /admin 경로로 redirect
- 관리자 쿠키가 없는 경우, /seller 경로로 redirect
결과적으로 host 정보를 기반으로 blockedForHost 변수를 통해 셀러와 관리자가 /admin과 /seller 경로에 접근하는 것을 방지하여 잘못된 토큰으로 인한 인증 문제를 해결하였다.
👩🏻💻 이를 통해 느낀 점
이번 문제를 해결하면서, 인증과 인가는 시스템 보안의 첫 번째 단계로서, 매우 중요한 역할을 한다는 것을 체감할 수 있었다. 특히, 인터셉터를 활용하여 복잡한 인증 흐름을 중앙에서 관리하는 것이 얼마나 효율적이고 편리한지 알 수 있었다.
관리자(admin)와 셀러(seller)처럼 구분된 사용자 그룹에 따라 접근을 제어하는 것이 보안상 필수적이라는 것을 배울 수 있었다. 이를 통해 서비스가 안정적으로 운영되고, 각 그룹의 사용자들이 적절한 권한을 가진 페이지에만 접근할 수 있도록 제어하는 것이 중요하다는 것을 알게 되었다.
여러 요청이 동시에 발생할 때 발생하는 동시성 문제는 이전에 경험하지 못했던 과제였지만, 이를 인터셉터 내부에서 제어하는 과정은 매우 흥미로운 기술적 도전이었다. 이러한 문제를 해결하면서 시스템의 안정성을 유지할 수 있었고, 이 과정에서 중복된 로직을 함수로 분리하고 재사용 가능한 방식으로 구현하는 것이 시스템 확장에 매우 유리하다는 것을 깨달았다.
결론적으로, 이번 작업을 통해 인증/인가와 관련된 문제를 해결할 때, 유연한 확장성을 위해 재사용 가능한 코드 구조를 유지하는 것이 매우 중요하다는 교훈을 얻을 수 있었습니다.
'WEB > React' 카테고리의 다른 글
개발자가 만들어가는 어드민 시스템의 UI/UX (0) | 2024.09.24 |
---|---|
[ React ] 비즈니스 로직 분리하기 (0) | 2023.12.09 |
액션/계산/데이터를 활용한 useMuation 기능 개선 (3) | 2023.12.07 |
React의 패키지 구조와 용어 정리 (0) | 2023.12.03 |
[ React ] useQuery 와 useMuataion를 활용한 기능개선 (0) | 2023.11.30 |