B2B 서비스의 인증(Authentication) 및 인가(Authorization) 시스템은 B2C에 비해 복잡한 구조를 갖습니다. 여러 기업 관리자 계정, 권한 계층, 그룹 단위의 접근 제어 등 고려해야 할 요소가 많기 때문입니다. 잘 설계된 인증/인가 시스템은 사용자 경험을 해치지 않으면서도 데이터 보안과 접근 제어를 효과적으로 관리할 수 있게 해줍니다.
이번 글에서는 제가 담당 중인 기업 관리자용 서비스의 인증/인가 시스템을 개선하며 겪었던 기술적 고민과 문제 해결 과정, 그리고 얻은 인사이트를 공유하고자 합니다. 단순한 코드 개선을 넘어 전체 시스템 아키텍처와 사용자 경험까지 고려한 여정을 담았습니다.
이 글에서는 다음과 같은 문제들을 어떻게 개선했는지 기록해보려 합니다.
해당 서비스는 본래 기업 관리자들을 위한 플랫폼이었지만, 그동안은 대부분의 운영을 내부 운영팀이 관리를 대행하고 있었습니다. 이로 인해 인증/인가와 관련된 문제가 실제 사용자에게 큰 이슈로 확산되지 않았고, 레거시 코드도 유지된 채 방치되어 있었습니다.
그러나 최근, 서비스 본래의 목적에 맞게 기업 관리자들이 직접 기능을 활용할 수 있도록 많은 기능이 추가되었고, 이에 따라 인증/인가 플로우의 안정성과 신뢰성을 확보하는 것이 중요한 과제가 되었습니다.
먼저 서비스의 인증 플로우를 이해하는 것이 앞으로의 설명에 도움이 될 것 같습니다. Partner 서비스는 조직 내 여러 멤버 그룹 단위의 인가 처리가 필요한 구조를 가지고 있습니다. 사용자가 로그인하면 최근 접속한 멤버 그룹이 자동으로 선택되며, 선택된 멤버 그룹에 따라 접근 가능한 데이터가 달라집니다.
🔑 인증 플로우
1. 로그인 (이메일/비밀번호) → Access Token 발급
2. 멤버 목록 조회 → 멤버 그룹 정보 획득
3. 멤버 선택 → 해당 멤버 ID로 Member Token 발급
4. 두 토큰 모두 유효할 때 최종 인증 완료
🔑 토큰 정리
이러한 이중 토큰 구조는 사용자가 속한 여러 조직이나 역할 사이를 쉽게 전환하여 효율적으로 관리할 수 있게 해주지만, 그만큼 구현 복잡도도 증가합니다.
인증/인가 시스템을 리팩토링하려는 초기 단계에서 가장 먼저 마주한 레이어는 접근 제어 로직이었습니다. 처음에는 이 부분만 고치면 되겠다고 생각했지만, 실제로는 토큰의 상태 관리 방식까지 손을 대야 했습니다. 그 이유는, 접근 제어 로직이 서비스의 복잡한 비즈니스 로직과 강하게 결합되어 있었기 때문입니다. 인증/인가 상태 판단을 위한 여러 상태들이 산발적으로 존재했고, 그 로직을 단일 파일에서 모두 처리하고 있었기에 유지보수가 점점 어려워졌습니다.
기존에는 withAuthorization이라는 HOC 하나에서 인증/인가 로직을 모두 처리하고 있었습니다.
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를 분리하는 것이었습니다. 이렇게 하면 인증 로직이 명확해지고, 페이지별 접근 허용 조건을 코드 레벨에서 직관적으로 표현할 수 있으며, 페이지별 인증/인가 정책 확장에 유연해질 수 있습니다.
✅ 접근 허용용 HOC
// 로그인 사용자만 접근 가능
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
반대로, 로그인한 사용자가 다시 로그인 페이지나 회원가입 페이지에 접근하는 것을 차단할 필요도 있습니다.
// 로그인한 사용자는 접근 불가
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;
};
실제 사용할 때는 다음과 같이 사용합니다.
// 인증된 사용자만 접근 가능한 레이아웃
export default withAuthedOnly(DefaultLayout);
// 비인증 사용자만 접근 가능한 레이아웃
export default withUnAuthedOnly(AuthLayout);
정리하자면, authedOnly, unAuthedOnly와 같은 역할별 HOC로 접근 제어를 명확히 구분함으로써, 비즈니스 로직에 맞는 인증 흐름을 코드로 자연스럽게 표현할 수 있게 되었습니다.
물론 인증/인가 조건이 단순한 서비스라면 이처럼 HOC를 세분화하지 않고도 충분히 관리할 수 있습니다. 하지만 해당 서비스처럼 멤버 권한이 세분화되어 있고, 비즈니스 상태가 복잡하게 얽혀 있는 경우라면, HOC 분리는 유지보수성과 가독성 모두에 큰 도움이 될 수 있습니다.
기존 로그인 플로우는 다음과 같은 순서로 이루어져 있었습니다.
1. 로그인 API 호출
2. accessToken 수신
3. Redux/로컬스토리지에에 accessToken 토큰 저장
4. API 클라이언트 헤더에 accessToken 토큰 설정
5. accessToken 토큰으로 사용자의 멤버 정보 조회
6. 선택된 멤버의 memberToken 호출
7. Redux에 memberToken 토큰 저장
8. 성공 시 Redux의 setIsAuthorized 상태 변경
9. 메인 페이지로 이동, 실패 시 로그아웃
이 구조에서는 Redux, localStorage, API 클라이언트가 서로 다른 책임을 가지며 인증 상태를 관리하고 있었습니다. 결과적으로 인증 상태가 분산되고, 동기화 문제가 발생할 수 있는 구조였습니다.
기존 시스템에서는 다음과 같이 토큰 상태가 여러 곳에 분산되어 있었습니다.
// 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('');
}
이러한 구조에서는 다음과 같은 문제점이 있었습니다.
파생상태와 전역으로 관리하고 있던 토큰 상태들을 정리하며, 브라우저 저장소(localStorage / sessionStorage) 를 통해 토큰 및 상태 관리를 일원화 시켰습니다. 토큰의 유무효 판단은 API 응답을 기반으로 직접 판단하여 관리 포인트를 줄이고 동기화 이슈를 해소하고자 하였습니다.
// 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 호출 전 클라이언트에서 인증/인가 상태에 따른 조건 분기가 필요한 경우가 있어, 해당 유틸은 일부 남겨두고 사용 중입니다.
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 };
};
export const useSignOut = () => {
// ...
const signOut = () => {
removeTokens();
queryClient.removeQueries();
navigate('/auth/signin');
};
return { signOut };
};
이제 로그인/로그아웃 처리 시 브라우저 스토리지의 토큰 값만 관리하면 됩니다.😀
또한 토큰 관리 유틸리티를 단일 파일로 통합하여 관리하고 있어, 추후 토큰 저장소나 유효성 검사 로직이 변경되더라도 해당 파일만 수정하면 됩니다.
API 요청 시 인증 토큰을 헤더에 포함시키는 방식은 인증/인가 시스템의 핵심입니다. 폐쇄적인 B2B 서비스의 특성상, 대부분의 API 요청에는 인증 토큰이 필수로 포함되어야 하며, 서버는 해당 토큰을 검증하여 요청에 대한 적절한 응답을 반환합니다.
기존 시스템에서는 이 토큰 설정을 정적인 객체로 관리했으며, 외부에서 해당 객체를 직접 수정하거나, 갱신된 clientConfig를 API 요청 시마다 주입하는 구조였습니다.
기존에는 클라이언트 인스턴스를 생성할 때, 인증 토큰을 포함한 헤더 값을 초기 설정(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;
},
}
로그인 시에는 다음과 같이 토큰을 설정했습니다.
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 세팅 방식에는 다음과 같은 문제점이 있었습니다.
리팩토링 이후에는 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,
});
}
이 방식의 주요 장점은 다음과 같습니다
API 요청 중 401(Unauthorized) 또는 403(Forbidden) 에러가 발생하는 경우, 이는 사용자의 인증 상태가 유효하지 않거나 해당 리소스에 대한 접근 권한이 없음을 의미합니다. 이런 상황에서는 적절한 에러 처리를 통해 사용자를 로그인 페이지로 리다이렉트하거나, 권한이 없는 접근을 차단해야 합니다.
앞서 토큰 저장소를 브라우저 저장소로 일원화하면서, Redux에 대한 의존성이 제거되었습니다. 이에 따라 기존에 Redux-Saga를 통해 분산적으로 관리되던 로그인 에러 처리를, 보다 일관되고 독립적인 방식으로 개선할 필요가 있었습니다.
기존에는 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 요청을 하나의 상위 레이어에서 통합적으로 감싸 인증 실패를 처리하도록 구조를 개선했습니다.
개선 방향
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;
};
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;
}
};
export class CrudClient {
private static clientConfig: ClientConfig;
// ...
get<T>(params: any) {
return sendRequestWithErrorHandler(() =>
httpClient.sendGetRequest<T>(/*...*/, {
...getClientConfig(),
params,
})
);
}
// put, post, delete 요청 메소드도 동일
}
이렇게 통합 API 에러 핸들링 레이어를 도입한 결과, 다음과 같은 이점을 얻을 수 있었습니다.
기존 구현에서는 다중 탭 환경을 전혀 고려하지 않았습니다. 사용자가 하나의 탭에서 로그인한 이후, 다른 탭으로 접근할 경우에도 인증 상태가 공유되지 않아 다시 로그인해야 하는 불편함이 있었습니다. 또한 멤버 그룹 상태도 탭마다 강제로 공유되어, 여러 멤버 계정을 병렬로 확인하고 싶은 요구를 충족시킬 수 없었습니다.
이러한 문제를 해결하기 위해, 인증/인가 정보를 다음과 같이 구분하여 관리하도록 리팩토링했습니다.
Access Token: localStorage → 브라우저 탭 간 공유 가능하도록 설정
Member Token (MT): sessionStorage → 탭마다 독립적인 인가 상태 관리
// 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);
};
// 인증 상태 체크
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;
};
이벤트 리스너를 활용함으로써 사용자가 멤버를 변경하거나 로그아웃한 다른 탭의 변경 사항이 현재 탭에서도 반영되어, 인증 상태의 일관성을 유지할 수 있게합니다.
const getClientConfig = () => ({
headers: {
authorization: getAccessToken() ? `bearer ${getAccessToken()}` : '',
'x-bpo-member-token': getMemberToken() ?? '',
'content-type': 'application/json',
},
});
인증/인가 레거시 코드를 하나씩 정리해 나가면서, 전체 플로우 역시 보다 명확하고 일관성 있게 개선할 수 있었습니다.
이번 작업을 통해 인증이 필요한 페이지와 필요하지 않은 페이지에 대한 접근 흐름이 명확히 구분되었고, 각 단계별 처리 로직도 보다 견고하게 다듬어졌습니다. 이전에는 흐름이 복잡하고 예외 케이스에 대한 관리가 어려웠던 반면, 이제는 인증 상태에 따른 행동을 일관되게 관리할 수 있는 구조로 발전시킬 수 있었습니다.
이번 개선 작업은 단순한 코드 리팩토링을 넘어, 인증/인가 정책과 플로우를 함께 고민하며 서비스의 안정성과 확장성을 높이는 데 집중한 시간이었습니다. 작업을 진행하면서 많은 뿌듯함을 느끼기도 했지만, 동시에 아쉬움도 적지 않았습니다.
처음에는 인증 시스템 개선에만 집중할 계획이었지만, 연관된 기술 부채들이 하나둘 드러나면서 예상보다 작업 범위가 커지고 일정도 길어졌습니다. 이 과정을 통해, 리팩토링을 시작하기 전에 연관된 의존성과 레거시 코드를 명확히 파악하고 점진적인 개선 계획을 세우는 것이 얼마나 중요한지 깊이 깨달았습니다.
또한 다양한 인증/인가 시나리오에 대한 테스트가 충분하지 않다는 점도 절감했습니다. 수동 테스트에 많은 리소스와 시간이 소모되었고, 플로우의 중요도를 고려할 때 앞으로는 유닛 테스트와 통합 테스트를 기반으로 한 자동화 체계를 적극적으로 강화할 필요가 있음을 느꼈습니다.
아직 개선해야 할 부분은 많습니다. 에러 핸들링은 보다 구체적으로 다듬어야 하고, 리프레시 토큰 사용 및 브라우저 저장소를 활용한 토큰 관리 방식에 대해서도 추가적인 논의와 정비가 필요합니다.
이번 경험을 통해 인증/인가 시스템이 가지는 복잡성과 중요성을 다시 한 번 체감할 수 있었으며, 앞으로도 지속적으로 개선과 테스트를 이어가며 더 안정적이고 유연한 구조를 만들어갈 방향을 모색하고자 합니다.