안녕하세요! 이번 글에서는 사용자가 직접 마주하는 프론트엔드 화면이 어떻게 똑똑하고 기민하게 반응하는지, 그 내부 구조를 깊이 있게 파헤쳐보려 합니다.
복잡해질 수 있는 데이터 필터링, 사용자 위치 정보, 즐겨찾기 상태 등을 어떻게 가볍고 체계적으로 관리했는지, 그리고 스켈레톤 UI, 에러 바운더리, 온보딩과 같은 디테일을 통해 어떻게 끊김 없는 사용자 경험(Seamless UX)을 만들어냈는지 그 비결을 상세히 공개합니다.
애플리케이션이 복잡해질수록 '상태(State)' 관리는 거대한 숙제가 됩니다. 사용자의 위치, 필터 조건, 가게 목록, 즐겨찾기, 로딩 및 에러 상태 등... 이 모든 데이터를 어떻게 체계적으로 관리하고 여러 컴포넌트 간에 효율적으로 공유할 수 있을까요?
저희는 여러 대안 속에서 Zustand를 선택했습니다. 그 이유는 명확합니다.
const { stores, loading } = useStoreStore();
처럼 React 훅처럼 자연스럽게 상태와 액션을 가져와 사용합니다.핵심 설계: 모든 것을 담는 중앙 스토어 (lib/store.ts
)
저희는 애플리케이션의 핵심적인 전역 상태를 lib/store.ts
파일 하나에 중앙화하여 관리합니다.
1. 스토어 상태와 액션 정의 (인터페이스)
먼저, 스토어에 저장될 모든 상태와 해당 상태를 변경할 액션 함수의 타입을 StoreState
인터페이스로 명확하게 정의합니다. 이는 TypeScript의 강력한 타입 추론을 활용하여 개발 과정에서의 실수를 줄이고 코드의 안정성을 높여줍니다.
// types/store.ts 등에서 타입을 가져옵니다.
import { Store, FormattedReview } from "@/types/store";
interface StoreState {
// 상태 (State)
stores: Store[];
loading: boolean;
error: string | null;
filters: { /* ... */ };
currentStore: Store | null;
// ... 기타 상태
// 액션 (Actions)
fetchStores: () => Promise<void>;
updateFilters: (newFilters: Partial<StoreState["filters"]>) => void;
// ... 기타 액션 함수들
}
2. 스토어 생성 (create
함수)
Zustand의 create
함수를 사용하여 스토어를 생성합니다. set
은 상태를 업데이트하는 함수이고, get
은 현재 상태를 가져오는 함수입니다.
// lib/store.ts
import { create } from "zustand";
// ...
export const useStoreStore = create<StoreState>((set, get) => ({
// 1. 초기 상태 값 정의
stores: [],
loading: false,
error: null,
filters: { /* ... */ },
// ...
// 2. 상태를 변경하는 액션 함수들 구현
fetchFilteredStores: async () => {
set({ loading: true, error: null }); // 로딩 시작
try {
const { filters } = get(); // get()으로 최신 필터 상태 조회
const data = await fetchFilteredStoresAPI(filters); // API 호출
set({ stores: data, loading: false }); // 성공 시 데이터 업데이트
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
set({ error: errMsg, loading: false }); // 실패 시 에러 상태 업데이트
}
},
updateFilters: (newFilters) => {
// 이전 상태(state)를 받아 새로운 상태를 계산
set((state) => ({ filters: { ...state.filters, ...newFilters } }));
// 필터 업데이트 후, get()으로 다른 액션을 호출하여 연쇄 작업 수행
get().fetchFilteredStores();
},
// ... 기타 액션 함수들
}));
이 구조의 가장 큰 장점은 상태와 그 상태를 변경하는 로직(액션)이 한곳에 모여 있다는 점입니다. 이는 코드의 응집도를 높이고, 상태 변경 로직을 추적하기 쉽게 만들어 유지보수성을 크게 향상시킵니다.
🤔 꼬리 질문: Zustand 스토어 내의 액션에서 다른 액션을 호출할 때
get()
을 사용하는 이유는 무엇일까요?set
함수만으로는 해결할 수 없는 어떤 상황이 있을까요?
컴포넌트가 비대해지는 가장 큰 원인은 상태 관리 로직과 비즈니스 로직이 UI 코드와 뒤섞이는 것입니다. 저희는 이를 방지하기 위해 관심사 분리(Separation of Concerns) 원칙에 따라 기능별로 로직을 분리하여 재사용 가능한 커스텀 훅으로 만들었습니다.
useGeolocation
: 모든 위치 요청의 중앙 관제탑navigator.geolocation
API는 콜백(callback) 기반으로 작동하여 async/await
와 함께 사용하기 번거롭습니다. 또한, 성공, 실패, 권한 거부 등 처리해야 할 분기점이 많아 여러 컴포넌트에서 동일한 로직이 반복될 가능성이 높습니다.hooks/use-geolocation.ts
훅 하나에 캡슐화했습니다.// hooks/use-geolocation.ts
export function useGeolocation() {
const [state, setState] = useState<GeolocationState>({ /* ... */ });
const { toast } = useToast();
// 1. 콜백 기반 API를 Promise 기반으로 변환하여 async/await와 함께 사용 용이
const getCurrentPosition = useCallback(async (): Promise<{ lat: number; lng: number }> => {
return new Promise((resolve, reject) => {
// 2. 로딩 상태와 사용자 피드백(Toast)을 훅 내부에 통합 관리
setState((prev) => ({ ...prev, isLoading: true }));
toast({ title: "위치 확인 중..." });
navigator.geolocation.getCurrentPosition(
(position) => {
// 3. 성공 시: 상태 업데이트, 스토리지 저장, 성공 토스트 알림
const coords = { lat: position.coords.latitude, lng: position.coords.longitude };
setState({ isLoading: false, coordinates: coords, error: null });
toast({ title: "현재 위치를 확인했습니다." });
resolve(coords);
},
(error) => {
// 4. 실패 시: 에러 유형에 맞는 메시지 생성 및 에러 상태 업데이트, 실패 토스트 알림
const errorMessage = getErrorMessage(error);
setState({ isLoading: false, error: errorMessage });
toast({ title: "위치 확인 실패", description: errorMessage, variant: "destructive" });
reject(error);
}
);
});
}, [toast]);
return { ...state, getCurrentPosition };
}
const { coordinates, isLoading, getCurrentPosition } = useGeolocation();
한 줄로 위치 정보 관련 기능에 접근할 수 있습니다. 로딩, 성공, 실패 시의 일관된 사용자 경험을 제공하며, 로직이 중앙화되어 유지보수가 용이해집니다.useFavorites
: API 연동과 옵티미스틱 UI(Optimistic UI)의 만남즐겨찾기 기능은 사용자 경험이 매우 중요합니다. 사용자가 하트 버튼을 눌렀을 때, 서버 응답을 기다렸다가 UI를 변경하면 느리다는 인상을 줄 수 있습니다.
hooks/use-favorites.ts
훅에서 옵티미스틱 UI(Optimistic UI) 패턴을 적용하여 즉각적인 피드백을 제공합니다.// hooks/use-favorites.ts
export function useFavorites() {
const [favoriteIds, setFavoriteIds] = useState<Set<number>>(new Set());
// ...
const addToFavorites = useCallback(async (storeId: number) => {
// 1. 낙관적 업데이트: API 요청 전에 UI 상태를 먼저 변경!
const prevFavorites = new Set(favoriteIds);
setFavoriteIds(prev => new Set(prev).add(storeId));
toast({ title: "즐겨찾기에 추가되었습니다." });
try {
// 2. 실제 API 요청
const response = await fetch('/api/favorites', { method: 'POST', /* ... */ });
if (!response.ok) throw new Error('API 요청 실패');
} catch (error) {
// 4. 실패 시: UI 상태를 원래대로 롤백하고 에러 메시지 표시
setFavoriteIds(prevFavorites);
toast({ title: '오류', description: '즐겨찾기 추가에 실패했습니다.', variant: 'destructive' });
}
}, [favoriteIds, toast]);
return { isFavorite: (id) => favoriteIds.has(id), addToFavorites, /* ... */ };
}
프론트엔드와 백엔드 사이의 원활한 데이터 통신은 애플리케이션의 안정성과 직결됩니다. 우리는 '데이터 파이프라인' 개념을 도입하여, 데이터가 프론트엔드 요청부터 백엔드를 거쳐 다시 프론트엔드로 돌아오는 전 과정을 체계적으로 관리합니다.
1단계: Zod를 이용한 API 게이트웨이 방어
모든 외부 요청은 잠재적인 위협입니다. 우리는 API 엔드포인트의 가장 앞단에서 Zod 라이브러리를 사용해 들어오는 모든 데이터의 유효성을 철저히 검사합니다.
// lib/validations.ts
import { z } from "zod";
// 가게 필터링 조건을 정의하는 Zod 스키마
export const storeFilterSchema = z.object({
latitude: z.coerce.number().min(-90).max(90).optional(),
longitude: z.coerce.number().min(-180).max(180).optional(),
// ...
});
// app/api/stores/filter/route.ts (API 라우트 핸들러)
import { storeFilterSchema } from "@/lib/validations";
export async function POST(request: Request) {
try {
const requestBody = await request.json();
// 1. Zod 스키마로 요청 데이터 파싱 및 검증
const validatedFilters = storeFilterSchema.parse(requestBody);
// 2. 검증 통과 후 안전하게 비즈니스 로직 수행
// ...
} catch (error) {
// 3. 유효성 검사 실패 시, 400 Bad Request 응답
return new Response(JSON.stringify({ error: "Invalid request" }), { status: 400 });
}
}
이 '스키마 우선(Schema-first)' 접근 방식은 잘못된 데이터로 인해 발생할 수 있는 수많은 버그를 사전에 예방하고 API의 안정성을 크게 높입니다.
2단계: Mapper 패턴으로 데이터 구조 변환하기
데이터베이스의 테이블 구조와 프론트엔드 컴포넌트가 필요로 하는 데이터의 형태는 종종 다릅니다. 이 간극을 메우기 위해 매퍼(Mapper) 패턴을 도입했습니다. lib/stores.ts
의 mapStoreFromDb
함수는 데이터베이스에서 가져온 원본 데이터를 프론트엔드에서 사용하기 편한 형태로 변환하는 역할을 전담합니다.
// types/store.ts - 타입 정의
interface StoreFromDb { /* ... */ naver_rating: number | null; }
interface Store { /* ... */ rating: { naver: number; /* ... */ }; }
// lib/stores.ts - 실제 변환 로직
export function mapStoreFromDb(store: StoreFromDb): Store {
return {
id: store.id,
name: store.name,
// ...
rating: { // 중첩 객체로 구조화
naver: store.naver_rating || 0,
// ...
},
};
}
이 매핑 계층을 둠으로써 얻는 가장 큰 이점은 프론트엔드와 백엔드의 디커플링(Decoupling)입니다. 만약 나중에 데이터베이스의 컬럼명이 변경되더라도, 프론트엔드의 수많은 컴포넌트를 수정할 필요 없이 이 매퍼 함수 하나만 수정하면 됩니다. 이는 시스템의 유지보수성을 극적으로 향상시킵니다.
훌륭한 사용자 경험은 단순히 화려한 UI가 아닌, 사용자의 입장에서 생각한 세심한 디테일들이 모여 완성됩니다.
새로운 사용자가 앱을 처음 실행했을 때, '무엇을 어떻게 해야 할지' 막막함을 느끼게 해서는 안 됩니다. 우리는 localStorage
를 활용하여 온보딩 완료 여부를 기록하고, 신규 사용자에게만 서비스의 핵심 기능을 단계별로 안내하는 온보딩 프로세스를 구현했습니다. 이 간단한 장치가 앱의 첫인상을 "친절하고 스마트하다"로 만드는 중요한 역할을 합니다.
데이터를 불러오는 시간은 사용자에게 가장 지루한 순간입니다. 우리는 단순한 로딩 스피너 대신, 스켈레톤 UI(Skeleton UI)를 적극적으로 활용하여 로딩 시간을 시각적으로 단축시키는 효과를 주었습니다. 실제 콘텐츠 레이아웃과 유사한 형태의 UI를 먼저 보여주면, 사용자는 "앱이 멈춘 것이 아니라, 곧 내용이 채워질 것"이라고 인지하게 됩니다. 이는 체감 성능(Perceived Performance)을 극적으로 향상시켜 사용자의 이탈을 막는 중요한 UX 기법입니다.
특정 컴포넌트에서 예기치 않은 에러가 발생했을 때, 전체 애플리케이션이 하얀 화면을 보여주며 멈춰버린다면 최악의 사용자 경험이 될 것입니다. Error Boundary는 이런 상황을 방지하는 안전망 역할을 합니다. 하위 컴포넌트 트리에서 발생하는 자바스크립트 에러를 포착하여, 미리 준비된 대체 UI(Fallback UI)를 보여줌으로써 전체 앱이 중단되는 것을 막고 사용자에게 상황을 안내합니다.
"즐겨찾기에 추가되었습니다", "리뷰가 성공적으로 등록되었습니다", "위치 정보를 가져오는 데 실패했습니다."
사용자의 행동에 대한 즉각적이고 명확한 피드백은 좋은 UX의 기본입니다. 우리는 토스트(Toast) 알림 시스템을 구축하여, 사용자에게 필요한 정보를 적시에, 방해되지 않는 방식으로 전달합니다. 이 간단한 피드백이 사용자의 불안감을 해소하고, 자신의 행동이 시스템에 올바르게 반영되었음을 명확히 인지시켜 줍니다.
훌륭한 사용자 경험은 단순히 화려한 UI에서 비롯되는 것이 아니라, 그 이면의 견고하고 체계적인 프론트엔드 아키텍처에서 시작됩니다.
이 프로젝트는 Zustand를 통한 가벼운 전역 상태 관리, 커스텀 훅을 활용한 로직의 재사용, 타입 안전성(Zod)과 데이터 매핑을 통한 안정적인 데이터 처리, 그리고 사용자 경험을 고려한 세심한 디테일 등 현대적인 프론트엔드 개발 패턴을 적극적으로 도입한 좋은 사례입니다.
이 글이 여러분의 다음 프로젝트에서 복잡한 상태와 비동기 로직을 어떻게 우아하게 풀어낼지에 대한 좋은 영감이 되기를 바랍니다.