“상태 관리 라이브러리, 뭘 쓰지?”
프론트엔드 개발에서 가장 초기에, 그리고 가장 신중하게 내려야 하는 결정 중 하나가 상태 관리 전략이다. 잘못 고르면 프로젝트가 커질수록 복잡도가 기하급수적으로 늘어나고, 나중에 바꾸려면 대규모 리팩토링을 감수해야 한다.
썸블링을 기획하면서 가장 먼저 떠올린 것은 이 앱이 얼마나 복잡한 상태를 가져야 하는가였다. 단순한 CRUD 앱이라면 React의 useState와 서버 상태 라이브러리 하나면 충분하다. 그런데 데이팅 앱은 다르다.
데이팅 앱의 상태 관리 요구사항을 나열해 보면 이렇다:
- 실시간 채팅: WebSocket으로 수신한 메시지를 즉시 화면에 반영하고, 다른 탭에서 채팅방을 열었을 때도 동기화되어야 한다.
- 매칭 상태: 오늘의 매칭 카드 3장의 수락/거절 상태가 애니메이션과 함께 실시간으로 반영되어야 한다.
- 인벤토리와 카드 잔액: 슈퍼라이크를 보내거나 프로필 블러를 해제하면 잔량이 즉시 줄어야 한다. 서버 응답을 기다리면 UX가 끊긴다.
- 소울 테스트 진행 상태: 50문항짜리 테스트를 도중에 닫았다가 다시 열어도 이어서 풀 수 있어야 한다.
- 만남 신청 예약금: 카드 5장이 차감되는 트랜잭션에서 서버 실패 시 롤백이 필요하다.
- 인증 상태: JWT 토큰 만료, 자동 갱신, 로그아웃 시 모든 관련 상태 초기화가 연동되어야 한다.
이 요구사항들을 하나의 전역 스토어에 다 담으면 금방 스파게티 코드가 된다. 그렇다고 모든 것을 컴포넌트 로컬 상태로 관리하면 prop drilling 지옥이 기다리고 있다.
Redux vs Context API vs Zustand
선택지를 좁히기 전에 주요 상태 관리 솔루션들을 비교했다.
| 항목 | Redux Toolkit | Context API | Zustand |
|---|---|---|---|
| 보일러플레이트 | 많음 | 적음 | 최소 |
| 번들 크기 | ~47KB | 0KB (내장) | ~2KB |
| 러닝커브 | 높음 | 낮음 | 낮음 |
| Next.js App Router 호환 | 별도 설정 필요 | 기본 지원 | 기본 지원 |
| DevTools | Redux DevTools | 없음 | Zustand DevTools |
| 비동기 처리 | Thunk/Saga 필요 | 직접 구현 | 내장 |
| 선택적 구독 | 지원 | 미지원 (전체 리렌더) | 지원 |
| TypeScript 지원 | 우수 | 보통 | 우수 |
| persist 미들웨어 | redux-persist | 직접 구현 | zustand/middleware |
Redux는 강력하지만 액션, 리듀서, 셀렉터를 모두 작성해야 하는 의식(ritual)이 너무 많다. 기능 하나 추가할 때마다 파일을 여러 개 수정해야 한다. Context API는 번들 비용이 없고 직관적이지만, 상태가 바뀔 때마다 Provider를 구독하는 컴포넌트 전체가 리렌더링된다. 채팅 메시지처럼 자주 바뀌는 상태에 쓰기엔 성능 이슈가 크다.
Zustand를 선택한 핵심 이유 세 가지:
-
최소한의 보일러플레이트:
create()로 스토어를 만들고, 상태와 액션을 하나의 객체에 정의한다. 액션 타입 문자열도, 리듀서도 없다. -
Next.js App Router 친화적: 서버 컴포넌트와 클라이언트 컴포넌트를 혼용하는 App Router 구조에서 Zustand는 클라이언트 사이드 상태만 관리하도록 명확히 분리된다. Redux와 달리
Provider를 루트에 감쌀 필요도 없다. -
선택적 구독으로 성능 최적화: 셀렉터 함수를 사용하면 스토어의 특정 부분이 바뀔 때만 해당 컴포넌트가 리렌더링된다. 채팅 메시지가 100개 수신되어도 메시지 목록 외의 컴포넌트는 리렌더링되지 않는다.
8개 스토어 아키텍처
썸블링의 상태를 8개 도메인으로 분리했다. 각 스토어는 하나의 책임만 가진다.
flowchart TD
Auth[useAuthStore<br/>인증 · 사용자 정보] --> Chat[useChatStore<br/>채팅 · WebSocket]
Auth --> Match[useMatchStore<br/>일일 매칭]
Auth --> Purchase[usePurchaseStore<br/>구매 · 인벤토리]
Auth --> Sub[useSubscriptionStore<br/>구독]
Auth --> Invite[useInviteStore<br/>초대]
Auth --> Meeting[useMeetingStore<br/>만남 신청]
Auth --> Soul[useSoulTestStore<br/>소울 테스트]
Purchase --> Match
Purchase --> Meeting
Chat -.->|WebSocket 이벤트| Match
useAuthStore가 최상위에 위치한다. 로그인/로그아웃 이벤트는 다른 모든 스토어에 영향을 준다. 예를 들어 로그아웃하면 채팅 WebSocket 연결을 끊고, 캐시된 매칭 데이터를 초기화해야 한다.
usePurchaseStore는 useMatchStore와 useMeetingStore에 영향을 준다. 카드를 소모해서 추가 매칭을 요청하거나 만남 예약금을 납부하는 로직이 구매 스토어를 경유한다.
점선으로 표시한 Chat → Match 의존성은 양방향이 아니다. WebSocket으로 매칭 관련 이벤트(상대방이 수락했을 때 등)를 수신하면 채팅 스토어에서 매칭 스토어를 직접 업데이트하는 단방향 통신이다.
| 스토어 | 관리 상태 | 주요 액션 |
|---|---|---|
useAuthStore | 사용자 정보, 인증 여부 | fetchUser, logout |
useChatStore | 채팅방 목록, 메시지, WS 연결 | connect, sendMessage, markAsRead |
useMatchStore | 일일 매칭 목록, 성공 오버레이 | removeMatch, showSuccessOverlay |
usePurchaseStore | 상품 목록, 인벤토리, 카드 잔액 | purchaseProduct, sendSuperLike, unblurProfile |
useSubscriptionStore | 구독 등급, 플랜 목록 | subscribe, cancelSubscription |
useInviteStore | 초대 코드, 통계 | fetchMyCode, redeemCode |
useMeetingStore | 만남 신청 상태 (채팅방별) | requestMeeting, acceptMeeting, completeMeeting |
useSoulTestStore | 질문, 답변, 진행 인덱스 | setAnswer, nextQuestion, reset |
useAuthStore: 인증의 중심
인증 스토어는 가장 단순하지만 가장 중요한 스토어다. 전체 앱의 진입점이고, 다른 스토어들이 이 상태를 기준으로 동작한다.
// stores/useAuthStore.ts
import { create } from 'zustand';
import { api } from '@/lib/api';
import { removeTokens } from '@/lib/auth';
import type { User } from '@/types/auth';
interface AuthState {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
setUser: (user: User) => void;
fetchUser: () => Promise<void>;
logout: () => void;
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
isAuthenticated: false,
isLoading: false,
setUser: (user) => set({ user, isAuthenticated: true }),
fetchUser: async () => {
set({ isLoading: true });
try {
const res = await api.get<{ data: User }>('/api/v1/members/me');
set({ user: res.data.data, isAuthenticated: true });
} catch {
set({ user: null, isAuthenticated: false });
} finally {
set({ isLoading: false });
}
},
logout: () => {
removeTokens();
set({ user: null, isAuthenticated: false });
// 다른 스토어들도 초기화
useChatStore.getState().disconnect();
useMatchStore.getState().setDailyMatches([]);
useSoulTestStore.getState().reset();
},
}));
logout() 액션에서 다른 스토어의 getState()를 직접 호출하는 것이 핵심이다. Zustand의 스토어는 훅이지만, getState()를 통해 훅 컨텍스트 밖에서도 상태를 읽고 액션을 호출할 수 있다. 이 패턴 덕분에 로그아웃 시 WebSocket 연결 종료, 캐시 초기화, 토큰 삭제가 하나의 액션에서 조율된다.
fetchUser()는 앱 최초 로드 시 한 번, 그리고 토큰 갱신 이후 호출된다. 서버의 사용자 정보가 항상 로컬 상태와 동기화되도록 보장한다.
useChatStore: 실시간 상태의 핵심
채팅 스토어가 가장 복잡하다. WebSocket 연결 생명주기, 메시지 캐싱, 낙관적 업데이트, 타이핑 인디케이터를 모두 책임진다.
// stores/useChatStore.ts
import { create } from 'zustand';
import { connectStomp, sendMessage, isStompConnected } from '@/lib/websocket';
import { api } from '@/lib/api';
import type { ChatRoom, ChatMessage, CursorPage } from '@/types/chat';
interface ChatState {
rooms: ChatRoom[];
messages: Record<string, ChatMessage[]>; // roomUUID → 메시지 배열
activeRoomId: string | null;
totalUnread: number;
typingUsers: Record<string, boolean>; // roomUUID → 타이핑 여부
wsConnected: boolean;
connect: () => void;
disconnect: () => void;
fetchRooms: () => Promise<void>;
fetchMessages: (roomId: string, cursor?: number) => Promise<boolean>;
sendMessage: (roomId: string, content: string) => void;
markAsRead: (roomId: string) => Promise<void>;
setActiveRoom: (roomId: string | null) => void;
sendTyping: (roomId: string, typing: boolean) => void;
fetchTotalUnread: () => Promise<void>;
}
export const useChatStore = create<ChatState>((set, get) => ({
rooms: [],
messages: {},
activeRoomId: null,
totalUnread: 0,
typingUsers: {},
wsConnected: false,
connect: () => {
const token = getToken();
if (!token) return;
connectStomp(
token,
// 메시지 수신 핸들러
(message) => {
const roomId = message.roomUuid;
set((state) => ({
messages: {
...state.messages,
[roomId]: [
...(state.messages[roomId] ?? []).filter(
(m) => m.messageId !== message.messageId // 낙관적 메시지 교체
),
message,
],
},
rooms: state.rooms.map((r) =>
r.roomId === roomId
? { ...r, lastMessage: message.content, unreadCount: r.roomId === state.activeRoomId ? 0 : r.unreadCount + 1 }
: r
),
}));
},
// 타이핑 핸들러
({ roomUuid, typing }) => {
set((state) => ({
typingUsers: { ...state.typingUsers, [roomUuid]: typing },
}));
// 5초 후 자동 해제 (서버 TTL과 동기화)
if (typing) {
setTimeout(() => {
set((state) => ({
typingUsers: { ...state.typingUsers, [roomUuid]: false },
}));
}, 5000);
}
},
// 읽음 확인 핸들러
({ roomUuid }) => {
set((state) => ({
messages: {
...state.messages,
[roomUuid]: (state.messages[roomUuid] ?? []).map((m) => ({
...m,
status: 'READ' as const,
})),
},
}));
},
() => set({ wsConnected: true }),
() => set({ wsConnected: false }),
);
},
sendMessage: (roomId, content) => {
// 낙관적 업데이트: 서버 응답 전에 메시지를 즉시 표시
const tempId = Date.now();
const optimisticMessage: ChatMessage = {
messageId: tempId,
roomUuid: roomId,
senderId: useAuthStore.getState().user!.id,
senderNickname: useAuthStore.getState().user!.nickname,
content,
type: 'TEXT',
status: 'SENT',
createdAt: new Date().toISOString(),
};
set((state) => ({
messages: {
...state.messages,
[roomId]: [...(state.messages[roomId] ?? []), optimisticMessage],
},
}));
// WebSocket 전송 (실패 시 REST API 폴백)
const sent = sendMessage(roomId, content);
if (!sent) {
api.post(`/api/v1/chat/rooms/${roomId}/messages`, { content }).catch(() => {
// 전송 실패 시 낙관적 메시지 제거
set((state) => ({
messages: {
...state.messages,
[roomId]: (state.messages[roomId] ?? []).filter(
(m) => m.messageId !== tempId
),
},
}));
});
}
},
// ... 나머지 액션들
}));
메시지 전송 흐름을 시퀀스 다이어그램으로 보면:
sequenceDiagram
participant U as 사용자
participant S as useChatStore
participant WS as WebSocket (STOMP)
participant API as 서버
U->>S: sendMessage(roomId, "안녕하세요!")
S->>S: 낙관적 메시지 추가 (tempId: 1706123456789)
Note over S: 화면에 즉시 표시됨
S->>WS: STOMP publish /app/chat/{roomId}/send
WS->>API: 메시지 저장 요청
API-->>WS: 확인 이벤트 (실제 messageId: 445568)
WS-->>S: 메시지 수신 핸들러 호출
S->>S: tempId 메시지 → 실제 메시지로 교체
Note over S: messageId가 서버 발급 값으로 갱신됨
alt WebSocket 연결 실패
S->>API: POST /chat/rooms/{roomId}/messages (REST 폴백)
API-->>S: 응답
end
typingUsers 상태는 키가 채팅방 UUID, 값이 boolean이다. 서버의 Redis 타이핑 TTL(5초)과 동기화해서, 상대방이 입력을 멈추면 5초 후 자동으로 false로 바뀐다. setTimeout으로 간단하게 구현했지만, 연속으로 타이핑 이벤트가 오면 이전 타이머를 clearTimeout으로 정리해 줘야 한다. 실제 코드에서는 Map<string, NodeJS.Timeout>으로 타이머를 관리한다.
useSoulTestStore: localStorage 영속성
소울 테스트는 50문항이다. 답변하다가 실수로 탭을 닫거나 전화가 와서 앱을 나가면 처음부터 다시 해야 하는 UX는 사용자 이탈을 부른다. Zustand의 persist 미들웨어로 이 문제를 해결했다.
// stores/useSoulTestStore.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
interface SoulTestState {
currentQuestionIndex: number;
answers: Record<number, number>; // questionId → answerValue (1~5)
testId: number | null;
questions: SoulTestQuestion[];
isCompleted: boolean;
hydrated: boolean;
setTestId: (id: number) => void;
setQuestions: (questions: SoulTestQuestion[]) => void;
setAnswer: (questionId: number, value: number) => void;
nextQuestion: () => void;
prevQuestion: () => void;
reset: () => void;
hydrate: () => void;
hasProgress: () => boolean;
}
export const useSoulTestStore = create<SoulTestState>()(
persist(
(set, get) => ({
currentQuestionIndex: 0,
answers: {},
testId: null,
questions: [],
isCompleted: false,
hydrated: false,
setAnswer: (questionId, value) => {
set((state) => ({
answers: { ...state.answers, [questionId]: value },
}));
},
nextQuestion: () =>
set((state) => ({
currentQuestionIndex: Math.min(
state.currentQuestionIndex + 1,
state.questions.length - 1
),
})),
prevQuestion: () =>
set((state) => ({
currentQuestionIndex: Math.max(state.currentQuestionIndex - 1, 0),
})),
reset: () =>
set({
currentQuestionIndex: 0,
answers: {},
testId: null,
questions: [],
isCompleted: false,
}),
hydrate: () => set({ hydrated: true }),
// localStorage에 진행 중인 데이터가 있는지 확인
hasProgress: () => {
const state = get();
return state.testId !== null && Object.keys(state.answers).length > 0;
},
}),
{
name: 'aro-soul-test', // localStorage 키
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({ // 영속화할 필드만 선택
currentQuestionIndex: state.currentQuestionIndex,
answers: state.answers,
testId: state.testId,
questions: state.questions,
isCompleted: state.isCompleted,
}),
onRehydrateStorage: () => (state) => {
state?.hydrate(); // 복원 완료 신호
},
}
)
);
stateDiagram-v2
[*] --> 새로시작: 소울 테스트 진입
새로시작 --> 진행중: 첫 문항 응답
진행중 --> 진행중: 답변 저장 (localStorage)
진행중 --> 중단: 앱 종료 · 탭 닫기
중단 --> 이어하기: 재진입 시 hydration
이어하기 --> 진행중: "이어서 풀기" 선택
이어하기 --> 새로시작: "처음부터" 선택
진행중 --> 완료: 50문항 모두 응답
완료 --> 제출: 서버로 답변 전송
제출 --> 초기화: reset() · localStorage 클리어
초기화 --> [*]
SSR과 localStorage의 불일치 문제: Next.js App Router에서 persist 미들웨어를 사용하면 서버에서 렌더링할 때와 클라이언트에서 hydration할 때 상태가 달라 hydration mismatch가 발생할 수 있다. hydrated 플래그가 이를 해결한다.
// 소울 테스트 시작 페이지 컴포넌트
export default function SoulTestPage() {
const { hasProgress, hydrated } = useSoulTestStore();
// hydration 완료 전에는 아무것도 렌더링하지 않음
if (!hydrated) {
return <SoulTestSkeleton />;
}
return (
<div>
{hasProgress() ? (
<ResumePrompt /> // "이어서 풀기" 버튼
) : (
<StartPrompt /> // 새로 시작
)}
</div>
);
}
partialize 옵션으로 영속화할 필드를 명시했다. hydrated 같은 내부 플래그나 런타임에만 필요한 상태는 localStorage에 저장할 필요가 없다.
usePurchaseStore & useMeetingStore: 트랜잭션 상태
카드 잔액과 인벤토리는 UI 반응성이 중요하다. 슈퍼라이크 버튼을 누르고 서버 응답이 올 때까지 잔량이 그대로 표시되면 사용자는 버튼이 작동했는지 알 수 없다.
// stores/usePurchaseStore.ts
export const usePurchaseStore = create<PurchaseState>((set, get) => ({
cardBalance: 0,
inventory: [],
sendSuperLike: async (receiverId: number) => {
const previousBalance = get().cardBalance;
// 낙관적 차감: 서버 응답 전에 즉시 UI 업데이트
set((state) => ({
inventory: state.inventory.map((item) =>
item.productType === 'SUPER_LIKE_3'
? { ...item, quantity: item.quantity - 1 }
: item
),
}));
try {
const res = await api.post<{ data: SuperLikeResult }>(
'/api/v1/purchases/super-like',
{ receiverId }
);
return res.data.data;
} catch (error) {
// 실패 시 롤백
set((state) => ({
inventory: state.inventory.map((item) =>
item.productType === 'SUPER_LIKE_3'
? { ...item, quantity: item.quantity + 1 }
: item
),
}));
throw error;
}
},
getInventoryCount: (productType: string) => {
const item = get().inventory.find((i) => i.productType === productType);
return item?.quantity ?? 0;
},
}));
만남 신청은 더 복잡하다. 신청자와 수락자 모두 카드 5장이 차감되는 쌍방 예약금 시스템이다. 트랜잭션 실패 시 양쪽 모두 롤백해야 한다.
// stores/useMeetingStore.ts
export const useMeetingStore = create<MeetingState>((set, get) => ({
roomMeetings: {},
requestMeeting: async (data: MeetingRequestDto) => {
const purchaseStore = usePurchaseStore.getState();
const previousBalance = purchaseStore.cardBalance;
// 예약금 선차감 (낙관적 업데이트)
purchaseStore.setCardBalance(previousBalance - 5);
try {
const res = await api.post('/api/v1/meetings/request', data);
const meeting = res.data.data;
set((state) => ({
roomMeetings: {
...state.roomMeetings,
[data.chatRoomId]: meeting,
},
}));
} catch (error) {
// 서버 실패 시 카드 잔액 복원
purchaseStore.setCardBalance(previousBalance);
throw error;
}
},
}));
usePurchaseStore.getState()로 다른 스토어의 액션을 직접 호출한다. Zustand에서 스토어 간 통신의 기본 패턴이다. 의존성 주입 없이 필요한 스토어를 직접 참조할 수 있다.
스토어 간 통신 패턴
Zustand에서 스토어 간 통신은 세 가지 방식으로 구현했다.
1. 직접 getState() 호출: 단방향 의존성이 명확할 때 사용한다.
// useMeetingStore에서 usePurchaseStore 호출
const purchaseStore = usePurchaseStore.getState();
purchaseStore.fetchInventory();
2. 이벤트 기반 (custom event): 순환 의존성이 생길 것 같을 때 사용한다. 예를 들어 채팅 스토어와 매칭 스토어가 서로를 참조하면 순환 import가 된다.
// 매칭 수락 이벤트를 커스텀 이벤트로 발행
window.dispatchEvent(new CustomEvent('match:accepted', { detail: { matchId } }));
// 채팅 스토어에서 구독
useEffect(() => {
const handler = (e: CustomEvent) => {
fetchRooms(); // 채팅방 목록 갱신
};
window.addEventListener('match:accepted', handler);
return () => window.removeEventListener('match:accepted', handler);
}, []);
3. 컴포넌트에서 조율: 두 스토어가 직접 연결될 필요 없이, 상위 컴포넌트에서 순서를 조율한다.
// 매칭 수락 처리 컴포넌트
const handleAccept = async (matchId: number) => {
await api.post(`/api/v1/matches/${matchId}/accept`);
matchStore.removeMatch(matchId); // 매칭 카드 제거
chatStore.fetchRooms(); // 채팅방 목록 갱신
purchaseStore.fetchCardBalance(); // 카드 잔액 갱신
};
세 번째 패턴이 가장 단순하고 명확하다. 스토어들은 서로를 모르고, 컴포넌트가 오케스트레이터 역할을 한다.
성능 최적화
셀렉터 패턴으로 불필요한 리렌더링 방지
Zustand의 useStore(store, selector) 형태로 필요한 상태만 구독할 수 있다.
// 나쁜 예: 스토어 전체를 구독 → 어떤 상태가 바뀌어도 리렌더링
const chatStore = useChatStore();
// 좋은 예: 필요한 상태만 구독
const rooms = useChatStore((state) => state.rooms);
const totalUnread = useChatStore((state) => state.totalUnread);
객체나 배열을 반환하는 셀렉터는 shallow 비교를 함께 사용해야 한다.
import { shallow } from 'zustand/shallow';
// shallow로 배열 내 요소가 실제로 바뀌었을 때만 리렌더링
const { rooms, wsConnected } = useChatStore(
(state) => ({ rooms: state.rooms, wsConnected: state.wsConnected }),
shallow
);
파생 상태는 useMemo로
특정 채팅방의 읽지 않은 메시지 수 합산 같은 파생 상태는 스토어 밖에서 useMemo로 계산한다. 스토어에 넣으면 모든 메시지 변경마다 재계산이 트리거된다.
const rooms = useChatStore((state) => state.rooms);
const totalUnread = useMemo(
() => rooms.reduce((sum, room) => sum + room.unreadCount, 0),
[rooms]
);
React.memo와 조합
Zustand 셀렉터로 Props가 바뀌지 않았을 때 React.memo가 리렌더링을 막는다.
export const MatchCard = React.memo(
({ match, onAccept, onReject }: MatchCardProps) => {
// match 객체가 실제로 바뀌었을 때만 리렌더링
const soulScore = useMatchStore(
(state) => state.dailyMatches.find((m) => m.matchId === match.matchId)?.soulScore
);
return <Card soulScore={soulScore} {...match} />;
},
(prev, next) => prev.match.matchId === next.match.matchId
);
실전에서 배운 것들
스토어를 너무 잘게 쪼개지 말 것: 처음에 인증 스토어를 useTokenStore, useUserStore, useSessionStore로 세 개로 나눴다가 하나로 합쳤다. 논리적으로 연결된 상태들은 같은 스토어에 있어야 한다.
비동기 에러 처리는 컴포넌트에서: 스토어 액션에서 try/catch로 에러를 삼키면 컴포넌트가 실패를 알 수 없다. 스토어는 에러를 다시 throw하고, 컴포넌트에서 try/catch로 사용자에게 토스트를 보여준다.
// 스토어: 에러를 다시 던짐
sendSuperLike: async (receiverId) => {
try {
const result = await api.post('/api/v1/purchases/super-like', { receiverId });
return result.data.data;
} catch (error) {
rollbackInventory(); // 롤백
throw error; // 컴포넌트로 전달
}
},
// 컴포넌트: 에러를 받아서 UX 처리
const handleSuperLike = async () => {
try {
await sendSuperLike(partnerId);
showToast('슈퍼라이크를 보냈습니다!');
} catch {
showToast('슈퍼라이크 전송에 실패했습니다.', 'error');
}
};
WebSocket 이벤트는 스토어에서 직접 처리: 처음에 WebSocket 메시지를 컴포넌트에서 직접 수신하고 스토어를 업데이트하려 했다. 컴포넌트가 언마운트되면 구독이 해제되어 백그라운드 메시지를 놓쳤다. 스토어 액션 내부에서 WebSocket 핸들러를 등록하면 연결이 유지되는 한 메시지를 항상 수신할 수 있다.
마무리: 단순함이 확장성이다
Zustand를 선택하고 8개 스토어로 분리한 결과, 각 스토어는 개별적으로 보면 매우 단순하다. useAuthStore는 사용자 정보만 알고, useSoulTestStore는 테스트 진행 상태만 안다. 그런데 이 단순한 스토어들이 조합되어 복잡한 사용자 경험을 만들어낸다.
Redux를 사용했다면 지금쯤 수십 개의 액션 타입, 리듀서, 셀렉터 파일이 생겼을 것이다. Context API를 사용했다면 채팅 메시지 하나 수신될 때마다 앱 전체가 리렌더링되는 성능 이슈가 있었을 것이다. Zustand는 두 문제 모두 피하면서 코드베이스를 작고 이해하기 쉽게 유지해 준다.
썸블링 팀이 Zustand를 추천하는 이유를 하나로 압축하면: 라이브러리가 방해가 되지 않는다는 것이다. 기능을 추가할 때 상태 관리 방식 때문에 막히지 않는다. 새 스토어가 필요하면 create()로 만들고, 기존 스토어에 액션이 필요하면 함수 하나를 추가하면 된다.
복잡한 데이팅 앱을 만들면서 배운 것은, 좋은 상태 관리란 존재감이 없는 상태 관리라는 것이다. 상태 관리 코드를 생각하지 않고 기능에 집중할 수 있을 때 비로소 좋은 도구를 선택한 것이다.
Somebling Tech 블로그에서는 AI 기반 데이팅 앱을 만들면서 겪은 기술적인 도전과 해결책을 공유합니다. 다음 글에서는 STOMP WebSocket과 SockJS를 활용한 실시간 채팅 아키텍처를 상세히 다룹니다.
GitHub 계정으로 로그인하여 댓글을 남길 수 있습니다. 허위사실, 욕설, 사칭 등의 댓글은 통보 없이 삭제될 수 있습니다.