1. 들어가며
Vizend Dock은 사용자의 인증 정보와 현재 작업 공간 정보를 관리하는 프론트엔드 공통 모듈입니다. 로그인 후 발급되는 access token과 refresh token뿐만 아니라 pavilion, cineroom, stage, actor와 같은 업무 컨텍스트를 보관하고, API 요청 시 필요한 인증 헤더와 Dock 컨텍스트를 interceptor를 통해 전달합니다. 따라서 Dock의 상태가 어긋나면 단순히 화면 한 곳이 잘못 보이는 수준에서 끝나지 않습니다. 로그인 여부, 권한 판단, 화면 이동, API 요청 컨텍스트가 함께 영향을 받습니다.
기존 Dock은 웹 애플리케이션을 전제로 작성되어 있었습니다. window, localStorage, sessionStorage, BroadcastChannel과 같은 브라우저 API를 여러 atom, hook, interceptor가 직접 사용했습니다. 웹에서만 실행할 때는 동작했지만 모바일 애플리케이션에 Dock을 적용하려고 하자 이 전제가 즉시 문제가 되었습니다. React Native 환경에는 브라우저의 window.localStorage와 window.sessionStorage가 존재하지 않기 때문입니다.
웹에서도 이전 사용자의 Dock 상태가 남거나, storage 값은 바뀌었는데 hook이 즉시 갱신되지 않아 새로고침 후에야 반영되는 문제가 있었습니다. 인증 atom, Dock atom, 실제 storage가 각각 상태를 가지고 있어 변경 경로에 따라 일부만 갱신될 수 있었기 때문입니다.
모바일 적용 일정상 장기간 재설계할 수는 없었습니다. 먼저 저장소를 주입할 수 있도록 웹 의존성을 끊고, 이후 상태의 기준점을 storage 하나로 통합한 뒤 Observer 패턴으로 React hook을 동기화했습니다. 이 글에서는 해당 리팩터링의 배경과 실제 적용 과정을 설명합니다.
2. 기존 구조에서 발생한 문제
2.1 브라우저 저장소에 대한 강한 결합
리팩터링 전 인증 상태는 atom을 선언하는 순간 브라우저 저장소를 직접 읽었습니다. Dock 상태도 모듈 로딩 시 window.sessionStorage에서 값을 읽어 atom의 초깃값으로 만들었습니다.
export const accessTokenAtom = atom(
sessionStorage.getItem(accessTokenKey) ?? '',
);
const load = () => {
const session =
window?.sessionStorage.getItem(dockSessionKey) ?? '{}';
const context =
window?.sessionStorage.getItem(dockContextKey) ?? '{}';
return {
session: JSON.parse(session),
context: JSON.parse(context),
};
};
이 코드는 저장 기술과 상태관리 코드가 분리되어 있지 않습니다. 브라우저에서는 Storage 구현이 이미 주어지지만 React Native에서는 사용할 저장소를 애플리케이션이 선택해야 합니다. 모바일 저장소를 추가하려면 atom, hook, interceptor에 흩어진 직접 참조를 모두 찾아 수정해야 했습니다. 테스트에서 메모리 저장소를 넣는 것도 쉽지 않았습니다.
저장 위치에 대한 정책도 여러 곳에 퍼져 있었습니다. access token은 session storage, refresh token과 remembered 값은 local storage에 두는 규칙이 atom과 interceptor에 각각 구현되어 있었습니다.
2.2 atom과 storage가 각각 상태를 보유하는 이중화
기존 구조에서는 Jotai atom이 React의 반응형 상태를 담당하고 storage가 영속 상태를 담당했습니다. 문제는 모든 변경이 반드시 atom을 통과하지 않았다는 점입니다. hook에서는 atom을 갱신하지만 interceptor에서는 요청 처리 중 storage를 직접 수정할 수 있었습니다. 반대로 atom의 값은 바뀌었지만 storage 반영 시점이나 초기화 여부가 다를 수도 있었습니다.
브라우저의 storage 이벤트만으로 이 문제를 해결하기도 어려웠습니다. 같은 문서에서 실행한 storage 변경은 해당 문서의 storage 이벤트를 발생시키지 않으며, 모바일 저장소에는 브라우저 이벤트 자체가 없습니다. 결국 “값을 저장하는 코드”와 “React 컴포넌트를 다시 그리는 코드” 사이에 명시적인 연결이 필요했습니다.
-
로그인 또는 토큰 갱신 후 storage 값은 변경되었지만 hook이 이전 값을 유지했습니다.
-
로그아웃 시 인증 토큰만 삭제되고 Dock context나 logbook의 일부가 남아 다음 사용자에게 이전 상태처럼 보일 수 있었습니다.
-
atom 초기화 순서와 interceptor 초기화 순서가 달라 첫 진입에서 상태를 정상적으로 읽지 못하는 경우가 있었습니다.
-
hook이 직접 읽는 값과 interceptor가 직접 읽는 값의 출처가 달라 문제 재현과 디버깅이 어려웠습니다.
2.3 설정 책임의 파편화
모바일에서는 storage 외에도 웹 전용 동작을 대체해야 했습니다. 예를 들어 window.alert와 window.location을 직접 호출할 수 없으므로 알림과 화면 이동 함수를 모바일 애플리케이션에서 주입해야 합니다. 하지만 초기 구조에서는 인증 atom 초기화, Dock atom 초기화, auth interceptor 저장소, context interceptor 저장소, logbook interceptor 저장소가 각각 별도의 설정 함수를 가졌습니다.
설정이 여러 진입점으로 나뉘면 “hook은 새 storage를 보지만 interceptor는 기본 storage를 보는” 불일치가 생길 수 있습니다. 따라서 저장소 구현뿐 아니라 초기화 경계도 하나로 모아야 했습니다.
3. 1차 대응: 저장소 주입으로 모바일 실행 경로 확보
우선 React Native에서 Dock 코드를 실행할 수 있도록 웹과 비웹 환경을 구분하고 외부 storage를 주입하는 경로를 추가했습니다. 웹에서는 기존 기본 storage를 사용하고, 모바일에서는 KeyValueStorage 구현을 애플리케이션이 전달하도록 했습니다.
공통 저장소 계약은 get, set, remove, clear의 단순한 key-value 연산으로 정의했습니다.
export interface KeyValueStorage {
get: (key: string) => string | null;
set: (key: string, value: string) => void;
remove: (key: string) => void;
clear: () => void;
}
Dock은 이 인터페이스만 의존하므로 구체적인 저장 기술을 알 필요가 없습니다. 웹에서는 브라우저 storage를 감싼 구현을 사용하고, 모바일에서는 같은 계약을 만족하는 어댑터를 전달합니다. 별도 session storage가 없는 환경에서는 하나의 storage가 두 역할을 담당하도록 구성했습니다.
초기 적용 과정에서는 전달받은 storage 객체와 실제 저장·조회 결과를 로그로 확인했습니다. 모바일 빌드에서는 모듈 로드 시점과 storage 주입 시점이 달랐기 때문에 초기화 순서를 검증하는 작업이 중요했습니다.
또한 웹 전용 알림과 이동을 대체하기 위해 showAlert, navigateTo handler를 주입받도록 했습니다. 저장소뿐 아니라 플랫폼에 종속적인 부수 효과도 외부로 밀어낸 것입니다.
이 1차 대응으로 모바일 실행 경로는 확보했지만 atom과 storage가 여전히 동시에 상태를 관리했고, 인증과 Dock의 초기화 함수도 분리되어 있었습니다. 저장소를 주입할 수 있게 만드는 것만으로는 상태 동기화 문제가 해결되지 않았습니다.
4. 최종 방향: storage를 단일 기준점으로 전환
1차 대응 후에는 저장소를 atom 뒤에 숨기는 방식보다 storage 자체를 상태의 기준점으로 만드는 방향으로 전환했습니다. 핵심 원칙은 네 가지였습니다.
첫째, 인증과 Dock의 영속 상태는 중앙 storage manager를 통해서만 읽고 씁니다.
둘째, React hook은 값을 별도로 소유하지 않고 manager에서 읽은 값을 화면에 반영합니다.
셋째, manager에서 값이 변경되면 해당 key를 구독한 hook에 즉시 알립니다.
넷째, storage 및 interceptor 초기화는 하나의 진입점에서 수행합니다.
기존의 연쇄 atom 구조를 제거하고 CentralStorageManager를 도입했습니다. Dock 전체에서 Jotai를 없앤 것은 아닙니다. 인증 토큰과 Dock session/context처럼 영속성이 필요한 상태만 storage manager로 이동하고, heartbeat와 개발 모드처럼 메모리에서만 필요한 상태에는 기존 방식을 유지했습니다.
목적은 모든 상태를 한 기술로 통일하는 것이 아니라, 영속 상태의 원본이 atom과 storage 두 곳에 존재하는 문제를 없애는 것이었습니다.
5. Storage 추상화 구현
5.1 중앙 관리자와 저장 위치 정책
CentralStorageManager는 local/session storage 구현을 한 번 주입받고, 공통 get, set, remove 연산을 제공합니다. 문자열 이외의 값은 JSON으로 직렬화하고, 조회 시에는 JSON 파싱을 우선 시도합니다.
export class CentralStorageManager {
private localStorage: KeyValueStorage | null = null;
private sessionStorage: KeyValueStorage | null = null;
private listeners = new Map<string, Set<(value: any) => void>>();
initialize(
localStorage: KeyValueStorage,
sessionStorage: KeyValueStorage,
) {
this.localStorage = localStorage;
this.sessionStorage = sessionStorage;
}
set<T>(
key: string,
value: T,
storageType: StorageType = 'session',
): void {
const storage = this.getStorage(storageType);
if (!storage) return;
const serialized =
typeof value === 'string' ? value : JSON.stringify(value);
storage.set(key, serialized);
this.notifyListeners(key, value);
}
remove(
key: string,
storageType: StorageType = 'session',
): void {
const storage = this.getStorage(storageType);
if (!storage) return;
storage.remove(key);
this.notifyListeners(key, undefined);
}
}
저장 위치 정책은 auth와 dock이라는 도메인 API 안에 모았습니다. 호출자는 access token이 어느 storage에 저장되는지 매번 판단하지 않고 storageManager.auth.setAccessToken()을 호출합니다. refresh token은 local, access token은 session, Dock session과 context는 session이라는 정책이 한 파일에 드러납니다.
auth = {
getAccessToken: () =>
this.get(STORAGE_KEYS.ACCESS_TOKEN, '', 'session'),
setAccessToken: (token: string) =>
this.set(STORAGE_KEYS.ACCESS_TOKEN, token, 'session'),
getRefreshToken: () =>
this.get(STORAGE_KEYS.REFRESH_TOKEN, '', 'local'),
setRefreshToken: (token: string) =>
this.set(STORAGE_KEYS.REFRESH_TOKEN, token, 'local'),
clearTokens: () => {
this.remove(STORAGE_KEYS.ACCESS_TOKEN, 'session');
this.remove(STORAGE_KEYS.REFRESH_TOKEN, 'local');
this.remove(STORAGE_KEYS.REMEMBERED, 'local');
},
};
hook, interceptor, 로그아웃 처리, 토큰 갱신 처리에서 모두 같은 API를 사용하므로 저장 위치가 달라져도 중앙 관리자만 수정하면 됩니다. 테스트에서는 메모리 기반 KeyValueStorage를 넣어 브라우저 없이 상태 흐름을 구성할 수도 있습니다.
5.2 Dock 상태를 부분 갱신하는 API
기존 dockAtom은 session과 context 전체를 조합한 큰 객체를 전달받았습니다. 일부 필드만 바꾸려 해도 현재 객체를 가져와 복사하고 다시 atom과 storage에 저장해야 했습니다. 리팩터링 후에는 manager가 현재 값을 조회한 뒤 부분 갱신하는 함수를 제공했습니다.
updateContext: (updates: Partial<DockContext>): void => {
const currentContext = this.dock.getContext();
const updatedContext = { ...currentContext, ...updates };
this.set(
STORAGE_KEYS.DOCK_CONTEXT,
updatedContext,
'session',
);
},
updateSessionField: <T extends keyof SessionDockRdo>(
field: T,
value: SessionDockRdo[T],
): void => {
const currentSession = this.dock.getSession();
const updatedSession = {
...currentSession,
[field]: value,
};
this.set(
STORAGE_KEYS.DOCK_SESSION,
updatedSession,
'session',
);
},
이후 useDock은 큰 atom 객체 전체를 교체하는 대신 변경 의도가 드러나는 API를 사용하게 되었고, 모든 쓰기가 동일한 알림 경로를 거치게 되었습니다.
6. Observer 패턴으로 hook과 storage 동기화
storage를 단일 기준점으로 만들면 다음 문제가 생깁니다. storage는 React 상태가 아니므로 값이 바뀌어도 컴포넌트가 자동으로 다시 렌더링되지 않습니다. 이를 해결하기 위해 manager에 key 단위 구독 기능을 추가했습니다.
subscribe(
key: string,
listener: (value: any) => void,
): () => void {
if (!this.listeners.has(key)) {
this.listeners.set(key, new Set());
}
this.listeners.get(key)!.add(listener);
return () => {
const keyListeners = this.listeners.get(key);
keyListeners?.delete(listener);
if (keyListeners?.size === 0) {
this.listeners.delete(key);
}
};
}
private notifyListeners(key: string, value: any): void {
this.listeners.get(key)?.forEach((listener) => {
listener(value);
});
}
manager가 Subject 역할을 하고 hook이 Observer 역할을 합니다. set 또는 remove가 실행되면 해당 key의 listener에 알리고, listener는 getter를 다시 실행해 authenticated 같은 파생 값도 함께 계산합니다. 이 구독 로직은 공통 hook인 useStorageValues로 분리했습니다.
export const useStorageValues = <
T extends Record<string, any>
>(
gettersMap: { [K in keyof T]: () => T[K] },
subscriptionKeys: string[],
): T => {
const gettersMapRef = useRef(gettersMap);
gettersMapRef.current = gettersMap;
const [values, setValues] = useState<T>(() => {
const initialValues = {} as T;
for (const [key, getter] of Object.entries(gettersMap)) {
initialValues[key as keyof T] = getter();
}
return initialValues;
});
const updateValues = useCallback(() => {
const newValues = {} as T;
for (
const [key, getter]
of Object.entries(gettersMapRef.current)
) {
newValues[key as keyof T] = getter();
}
setValues(newValues);
}, []);
useEffect(() => {
if (!storageManager.isInitialized()) return;
const unsubscribers = subscriptionKeys.map((key) =>
storageManager.subscribe(key, updateValues),
);
updateValues();
return () =>
unsubscribers.forEach((unsubscribe) => unsubscribe());
}, [updateValues]);
return values;
};
getterMap은 ref로 유지해 listener 함수가 렌더링할 때마다 바뀌지 않도록 했습니다. useAuthValues는 인증 관련 key를 구독하고, useDockValues는 Dock session과 context를 구독합니다. 공통 hook으로 추출하면서 hook마다 존재하던 강제 갱신 counter와 구독·해제 중복 코드도 제거했습니다.
7. 초기화 경계 통합
storage manager가 동작하려면 각 플랫폼이 올바른 구현을 주입해야 합니다. 이를 위해 storage 초기화와 interceptor 구성을 initStorageSystem으로 통합했습니다.
웹에서는 별도 설정이 없으면 기존 local/session storage adapter를 사용합니다. 비웹 환경에서는 storage와 showAlert, navigateTo handler를 필수로 받고, sessionStorage가 따로 없으면 local storage 구현을 함께 사용합니다. 그다음 storage manager를 먼저 초기화하고 auth, context, log 등의 interceptor를 구성합니다.
if (isWeb) {
finalLocalStorage =
localStorage || getLocalKeyValueStorage();
finalSessionStorage =
sessionStorage || getSessionKeyValueStorage();
} else {
setNotWebHandlers({
showAlert: notWebHandlers!.showAlert,
navigateTo: notWebHandlers!.navigateTo,
});
finalLocalStorage = localStorage!;
finalSessionStorage = sessionStorage || localStorage!;
}
storageManager.initialize(
finalLocalStorage,
finalSessionStorage,
);
configureInterceptors([
authInterceptor,
contextInterceptor,
logbookInterceptor,
...interceptors,
]);
초기화 함수에는 중복 호출을 방지하는 검사도 추가했습니다. 애플리케이션 진입점에서 한 번 설정하면 hook과 interceptor가 같은 storage manager를 사용합니다. 웹의 기존 호출 방식도 유지해 기존 애플리케이션의 변경 범위를 줄였습니다.
8. 적용 결과
이번 작업의 가장 큰 결과는 웹 기능을 모바일에서도 재사용할 수 있게 된 것과, 상태 변경의 흐름을 하나의 경로로 설명할 수 있게 된 것입니다.
첫째, 저장소 구현이 Dock 외부로 분리되었습니다. 웹은 기본 adapter를 사용하고, 모바일은 KeyValueStorage 계약을 만족하는 구현을 주입합니다.
둘째, 인증 토큰과 Dock session/context의 기준 상태가 storage manager로 통합되었습니다. hook과 interceptor가 서로 다른 상태 원본을 보는 문제가 줄었고, 값 변경 후 Observer 알림을 통해 화면이 즉시 갱신됩니다.
셋째, 저장소 정책과 초기화 책임이 중앙화되었습니다. access/refresh token의 저장 위치, 웹·비웹 초기화 순서, 로그아웃 시 정리 범위를 한정된 파일에서 확인할 수 있습니다. 로그아웃과 세션 만료 시에는 token뿐 아니라 Dock session, context, log도 함께 삭제하도록 정리해 이전 사용자의 상태가 남는 문제를 방지했습니다.
넷째, 연쇄 atom을 관리하던 파일을 제거하고 storageManager와 useStorageSubscription으로 역할을 재구성하면서 중복 코드가 줄었습니다.
다섯째, useCrossTabTokenRefresh, useLogbook, auth/context/log interceptor가 같은 manager API를 사용하게 되어 후속 수정 지점이 명확해졌습니다.
정량적인 성능 수치를 별도로 측정한 작업은 아닙니다. 다만 새로고침에 의존하던 상태 갱신 경로와 사용자 전환 시 잔존 상태의 원인을 제거하고, 모바일에서 브라우저 storage 없이 초기화할 수 있는 구조를 확보했습니다.
9. 배운 점
이번 작업을 통해 가장 크게 배운 점은 상태관리 라이브러리 자체보다 상태의 소유권이 중요하다는 것입니다. atom을 많이 사용해서 복잡한 것이 아니라, atom과 storage와 interceptor가 모두 상태를 소유하고 서로 다른 방식으로 수정할 수 있었기 때문에 복잡했습니다. 어떤 기술을 사용하더라도 원본 상태가 둘 이상이면 동기화 코드는 계속 늘어납니다.
두 번째는 플랫폼 추상화의 범위를 적절히 잡아야 한다는 점입니다. 처음에는 localStorage를 모바일 storage로 바꾸는 문제로 보였지만 알림, 이동, 초기화 순서, token refresh, logbook에도 플랫폼 전제가 퍼져 있었습니다. 저장소 인터페이스뿐 아니라 초기화 경계와 부수 효과도 함께 분리해야 했습니다.
세 번째는 Observer 패턴이 실무적인 연결 장치가 될 수 있다는 점입니다. 상태를 기록하는 주체가 직접 변경을 알리도록 하자 웹과 모바일에서 같은 반응형 갱신 방식을 사용할 수 있었고, hook별 강제 렌더링 코드도 공통화할 수 있었습니다.
네 번째는 급한 1차 수정과 구조 개선을 구분하는 것이 현실적인 전략이라는 점입니다. 먼저 실행 가능한 경로를 확보하고 실제 실패 지점을 확인한 뒤, atom 연쇄 구조 제거, 중앙 manager 도입, 구독 hook 분리, 초기화 통합을 단계적으로 진행했습니다.
10. 마치며
Vizend Dock의 모바일 적용은 단순히 브라우저 API를 조건문으로 감싸는 작업으로 끝나지 않았습니다. 기존에 잠재되어 있던 상태 이중화와 초기화 파편화 문제가 플랫폼 확장 과정에서 더 분명하게 드러났습니다.
저장소를 KeyValueStorage로 추상화하고, 중앙 manager를 영속 상태의 단일 기준점으로 삼고, key 기반 Observer 패턴으로 hook을 연결하면서 상태 흐름을 단순화할 수 있었습니다. 여기에 초기화 진입점과 로그아웃 정리 범위를 통합해 웹의 기존 사용 방식을 유지하면서 모바일 저장소를 주입할 수 있는 구조를 만들었습니다.
이번 경험은 프론트엔드 상태관리 리팩터링에서 중요한 질문이 “어떤 라이브러리를 선택할 것인가”에만 있지 않다는 점을 보여주었습니다. 더 먼저 확인해야 할 것은 상태의 원본이 어디인지, 누가 변경할 수 있는지, 변경 사실이 소비자에게 어떻게 전달되는지입니다. 이 세 가지를 명확히 정의했을 때 라이브러리와 플랫폼이 달라져도 확장 가능한 구조를 만들 수 있습니다.
IAN