워크스페이스 생성 기능을 리팩토링하면서, 생각보다 여러 디테일이 필요하다는 걸 알게 되었다.
단순히 입력값을 넣어서 서버에 요청만 하면 끝날 줄 알았는데… 실제 안정적인 서비스로 동작하려면 페이지 이동, 새로고침, 중복 요청 방지까지 챙겨야 했다. 이번 글에서는 그 과정을 정리해보려고 한다.
처음에는 그래도 단순했다. 간략하게 말하자면 사용자가 워크스페이스 이름을 입력하고, 체크 버튼을 누르면 유효성 검사를 실시하게 된다. 이 검사를 통과하면 서버에 요청을 보낸다. 제대로 된 서버 요청에 성공하면 입력창은 잠기고(readOnly), 사용자가 이용 가능한 워크스페이스 url이 화면에 보이게 된다.
여기까지만 보면 그냥 로컬 상태(useState)로도 충분해 보였다. 굳이 전역으로 관리하지 않아도 되는 것들이니까!
문제는 바로 여기서 시작됐다.
사용자가 체크 버튼을 눌러 url까지 확인 했는데, 만약 페이지를 이동했다가 다시 돌아오면? 또는 단순히 새로고침을 하면? 👉🏻 입력값이 싹 다 날아가는 것이었다....ㅠㅠ
이건 서비스 정책상 치명적이다. 🥹 우리 서비스에서는 계정당 워크스페이스를 하나만 만들 수 있기 때문!
만약 이미 워크스페이스 생성까지 했는데 값이 날아가면, 사용자가 다시 입력 → 체크 버튼 → 생성하기 버튼을 누르다가 에러가 나는 상황이 발생한다. 서버 입장에서는 이 사용자가 이미 워크스페이스를 만든 이력이 있기 때문이다.
즉, 단순히 컴포넌트 로컬 상태로 관리하면 안 되고, 전역 + 영속적 관리가 필요했다!
그래서 꺼내든 무기는 zustand + persist 조합! 🤩
stores/onboardingWorkspace.ts라는 파일을 만들어, 이름/URL/잠금 여부를 전역 상태로 관리하고자 했다.
export type WSState = {
workspaceName: string;
workspaceUrl: string;
isLocked: boolean;
setWorkspaceName: (v: string) => void;
setWorkspaceUrl: (v: string) => void;
setIsLocked: (v: boolean) => void;
};
export const useOnboardingWS = create<WSState>()(
persist(
(set) => ({
workspaceName: '',
workspaceUrl: '',
isLocked: false,
setWorkspaceName: (v) => set({ workspaceName: v }),
setWorkspaceUrl: (v) => set({ workspaceUrl: v }),
setIsLocked: (v) => set({ isLocked: v }),
}),
{
name: 'onboarding-workspace',
storage: createJSONStorage(() => localStorage),
partialize: (s) => ({
workspaceName: s.workspaceName,
workspaceUrl: s.workspaceUrl,
isLocked: s.isLocked,
}),
}
)
);
여기서 중요한 포인트는 partialize이다.
사용자가 입력한 워크스페이스명, 체크 후 얻은 url, 그리고 잠금 여부까지 로컬스토리지에 키로 저장한다.
잠금 여부까지 저장한 이유는, 체크 통과 후에도 사용자가 입력을 이어가면 불필요하게 로컬스토리지 값이 덮어써지는 문제를 막기 위함이다!
실제 입력창에는 이렇게 적용했다 :
// 코드 일부 생략
<input
type="text"
placeholder="워크스페이스 이름"
value={workspaceName}
onChange={(e) => {
if (isLocked || !!workspaceUrl) return; // 잠금 또는 URL 있으면 무시
setWorkspaceName(e.target.value);
}}
readOnly={isLocked || !!workspaceUrl}
/>
isLocked = true👉 이렇게 하면 잠금 상태에서도 혹시 모를 값 변경 시도가 깔끔히 차단된다!

여기서 끝났으면 좋았겠지만… 😅 한 번 더 생각해봐야 할 문제가 있었다.
사용자가 이미 워크스페이스를 생성한 상태인데, 뒤로가기를 눌러 다시 이 화면에 돌아와 '워크스페이스 생성하기' 버튼을 누른다면? 👉🏻 이 역시도 에러가 난다.
이걸 막지 않으면, 사용자는 한 번이라도 새로고침을 하거나 페이지 이동을 하면 에러 때문에 더 이상 앞으로 이동하지 못하는 상황이 생겨버린다.
그래서 stores/onboardingStatus.ts라는 전역 스토어를 하나 더 만들었다.
이번에는 생성 이력 자체를 persist로 관리한다.
export const useOnboardingStatus = create<OnboardingStatusState>()(
persist(
(set) => ({
workspaceCreated: false,
createdWorkspaceId: undefined,
setWorkspaceCreated: (v, id) => set({ workspaceCreated: v, createdWorkspaceId: id }),
resetWorkspaceCreated: () => set({ workspaceCreated: false, createdWorkspaceId: undefined }),
}),
{
name: 'onboarding-status',
storage: createJSONStorage(() => localStorage),
partialize: (s) => ({
workspaceCreated: s.workspaceCreated,
createdWorkspaceId: s.createdWorkspaceId,
}),
}
)
);
이제 workspaceCreated가 true라면, 다시 버튼을 눌러도 API 호출 없이 바로 다음 단계로 이동시킨다.
const { workspaceName, workspaceUrl, isLocked, setWorkspaceName, setWorkspaceUrl, setIsLocked } = useOnboardingWS();
const { workspaceCreated, setWorkspaceCreated } = useOnboardingStatus();
// 코드 일부 생략..
const handleButtonClick = async () => {
if (workspaceCreated) {
navigate('/onboarding/invite');
return;
}
try {
await mutateAsync({ workspaceName });
setWorkspaceCreated(true);
navigate('/onboarding/invite');
} catch (error) {
console.error('에러 발생:', error);
}
};

zustand + persist 조합 덕분에 전역 상태를 안정적으로 관리할 수 있었다. 또한 partialize를 사용해서 간편하게 로컬스토리지에 접근할 수 있다는 것도 굉장한 장점인 것 같다.
온보딩 플로우가 훨씬 견고해졌다! 새로고침을 하든, 페이지를 이동했다가 돌아오든, 혹은 이미 워크스페이스를 만든 계정이든... 어떤 경우에도 일관된 동작을 보장할 수 있게 되었다 ㅎㅎ♪(´▽`)
zustand는 Redux Toolkit에 비해 코드가 훨씬 간결하고 가볍다. 정말...!! UMC 8기 Web 워크북으로 공부할 때마다 느끼던 장점이었는데, 실제 프로젝트에 적용하면서 그 차이를 다시 한번 확실히 체감할 수 있었다.