“화면에 데이터를 채우고, 서버와 통신하는 방법을 알아봅니다.”
이 글에서는 Expo 앱에서 상태를 관리하고 외부 API와 연동하는 방법을 코드와 함께 살펴봅니다.
상태 관리란?
앱에서 변하는 데이터를 관리하는 것을 상태 관리라고 합니다.
사용자가 버튼을 누름
↓
상태(state)가 변경됨
↓
화면이 다시 렌더링됨
React Native도 React와 동일한 상태 관리 방식을 사용합니다.
1단계 — useState (로컬 상태)
단일 컴포넌트 안에서 관리하는 가장 기본적인 상태입니다.
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { useState } from 'react';
export default function CounterScreen() {
const [count, setCount] = useState(0);
const [isLoading, setIsLoading] = useState(false);
return (
<View style={styles.container}>
<Text style={styles.count}>{count}</Text>
<TouchableOpacity
style={styles.button}
onPress={() => setCount(count + 1)}
>
<Text style={styles.buttonText}>+1 증가</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, styles.resetButton]}
onPress={() => setCount(0)}
>
<Text style={styles.buttonText}>초기화</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 12 },
count: { fontSize: 64, fontWeight: 'bold' },
button: {
backgroundColor: '#007AFF',
paddingHorizontal: 24,
paddingVertical: 12,
borderRadius: 8,
},
resetButton: { backgroundColor: '#FF3B30' },
buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
});
2단계 — useContext (전역 상태)
여러 화면에서 공유해야 하는 상태는 Context API를 사용합니다.
로그인 정보, 테마, 언어 설정 등에 적합합니다.
// context/AuthContext.tsx
import { createContext, useContext, useState, ReactNode } from 'react';
type User = { id: string; name: string; email: string } | null;
type AuthContextType = {
user: User;
login: (user: User) => void;
logout: () => void;
};
const AuthContext = createContext<AuthContextType | null>(null);
// Provider: 앱 전체를 감싸는 컴포넌트
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User>(null);
const login = (userData: User) => setUser(userData);
const logout = () => setUser(null);
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
);
}
// Hook: 어느 화면에서나 사용
export function useAuth() {
const context = useContext(AuthContext);
if (!context) throw new Error('AuthProvider 안에서 사용해주세요');
return context;
}
// app/_layout.tsx
import { AuthProvider } from '@/context/AuthContext';
import { Stack } from 'expo-router';
export default function RootLayout() {
return (
<AuthProvider>
<Stack />
</AuthProvider>
);
}
// app/(tabs)/profile.tsx
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { useAuth } from '@/context/AuthContext';
export default function ProfileScreen() {
const { user, logout } = useAuth();
return (
<View style={styles.container}>
{user ? (
<>
<Text style={styles.name}>안녕하세요, {user.name}님!</Text>
<TouchableOpacity style={styles.button} onPress={logout}>
<Text style={styles.buttonText}>로그아웃</Text>
</TouchableOpacity>
</>
) : (
<Text>로그인이 필요합니다.</Text>
)}
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 16, alignItems: 'center', justifyContent: 'center' },
name: { fontSize: 20, fontWeight: 'bold', marginBottom: 16 },
button: { backgroundColor: '#FF3B30', padding: 12, borderRadius: 8 },
buttonText: { color: '#fff', fontWeight: '600' },
});
3단계 — Zustand (외부 상태 라이브러리)
Context보다 간결하고 성능이 좋은 외부 상태 관리 라이브러리입니다.
복잡한 상태가 많아지면 Zustand 사용을 권장합니다.
npx expo install zustand
// store/useCartStore.ts
import { create } from 'zustand';
type CartItem = { id: string; name: string; price: number; quantity: number };
type CartStore = {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
totalPrice: () => number;
};
export const useCartStore = create<CartStore>((set, get) => ({
items: [],
addItem: (item) =>
set((state) => {
const existing = state.items.find((i) => i.id === item.id);
if (existing) {
return {
items: state.items.map((i) =>
i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
),
};
}
return { items: [...state.items, { ...item, quantity: 1 }] };
}),
removeItem: (id) =>
set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
totalPrice: () =>
get().items.reduce((sum, item) => sum + item.price * item.quantity, 0),
}));
// app/(tabs)/cart.tsx
import { View, Text, TouchableOpacity, FlatList, StyleSheet } from 'react-native';
import { useCartStore } from '@/store/useCartStore';
export default function CartScreen() {
const { items, removeItem, totalPrice } = useCartStore();
return (
<View style={styles.container}>
<FlatList
data={items}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<View style={styles.item}>
<View>
<Text style={styles.itemName}>{item.name}</Text>
<Text style={styles.itemPrice}>
{item.price.toLocaleString()}원 × {item.quantity}
</Text>
</View>
<TouchableOpacity onPress={() => removeItem(item.id)}>
<Text style={styles.removeText}>삭제</Text>
</TouchableOpacity>
</View>
)}
/>
<View style={styles.total}>
<Text style={styles.totalText}>
총 {totalPrice().toLocaleString()}원
</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
item: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 16,
borderBottomWidth: 1,
borderBottomColor: '#eee',
},
itemName: { fontSize: 16, fontWeight: '600' },
itemPrice: { fontSize: 14, color: '#666', marginTop: 4 },
removeText: { color: '#FF3B30', fontSize: 14 },
total: { padding: 16, backgroundColor: '#f5f5f5' },
totalText: { fontSize: 18, fontWeight: 'bold', textAlign: 'right' },
});
API 연동 — fetch
가장 기본적인 API 호출 방법입니다.
import { View, Text, FlatList, ActivityIndicator, StyleSheet } from 'react-native';
import { useState, useEffect } from 'react';
type Post = { id: number; title: string; body: string };
export default function PostListScreen() {
const [posts, setPosts] = useState<Post[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchPosts();
}, []);
const fetchPosts = async () => {
try {
setIsLoading(true);
const response = await fetch('https://jsonplaceholder.typicode.com/posts');
if (!response.ok) throw new Error('서버 오류가 발생했습니다');
const data = await response.json();
setPosts(data.slice(0, 20));
} catch (err) {
setError('데이터를 불러오지 못했습니다.');
} finally {
setIsLoading(false);
}
};
if (isLoading) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#007AFF" />
</View>
);
}
if (error) {
return (
<View style={styles.center}>
<Text style={styles.errorText}>{error}</Text>
</View>
);
}
return (
<FlatList
data={posts}
keyExtractor={(item) => String(item.id)}
renderItem={({ item }) => (
<View style={styles.item}>
<Text style={styles.title}>{item.title}</Text>
<Text style={styles.body} numberOfLines={2}>{item.body}</Text>
</View>
)}
/>
);
}
const styles = StyleSheet.create({
center: { flex: 1, alignItems: 'center', justifyContent: 'center' },
errorText: { color: '#FF3B30', fontSize: 16 },
item: { padding: 16, borderBottomWidth: 1, borderBottomColor: '#eee' },
title: { fontSize: 16, fontWeight: '600', marginBottom: 4 },
body: { fontSize: 14, color: '#666', lineHeight: 20 },
});
API 연동 — React Query (권장)
실무에서 가장 많이 쓰이는 데이터 패칭 라이브러리입니다.
로딩 상태, 에러 처리, 캐싱, 재시도를 자동으로 처리합니다.
npx expo install @tanstack/react-query
// app/_layout.tsx에 Provider 추가
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Stack } from 'expo-router';
const queryClient = new QueryClient();
export default function RootLayout() {
return (
<QueryClientProvider client={queryClient}>
<Stack />
</QueryClientProvider>
);
}
// app/(tabs)/posts.tsx
import { View, Text, FlatList, ActivityIndicator, StyleSheet } from 'react-native';
import { useQuery } from '@tanstack/react-query';
type Post = { id: number; title: string; body: string };
const fetchPosts = async (): Promise<Post[]> => {
const res = await fetch('https://jsonplaceholder.typicode.com/posts');
if (!res.ok) throw new Error('서버 오류');
return res.json();
};
export default function PostsScreen() {
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
});
if (isLoading) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#007AFF" />
</View>
);
}
if (isError) {
return (
<View style={styles.center}>
<Text style={styles.errorText}>오류가 발생했습니다.</Text>
<TouchableOpacity onPress={() => refetch()}>
<Text style={styles.retryText}>다시 시도</Text>
</TouchableOpacity>
</View>
);
}
return (
<FlatList
data={data?.slice(0, 20)}
keyExtractor={(item) => String(item.id)}
renderItem={({ item }) => (
<View style={styles.item}>
<Text style={styles.title}>{item.title}</Text>
<Text style={styles.body} numberOfLines={2}>{item.body}</Text>
</View>
)}
onRefresh={refetch}
refreshing={isLoading}
/>
);
}
const styles = StyleSheet.create({
center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 12 },
errorText: { color: '#FF3B30', fontSize: 16 },
retryText: { color: '#007AFF', fontSize: 16 },
item: { padding: 16, borderBottomWidth: 1, borderBottomColor: '#eee' },
title: { fontSize: 16, fontWeight: '600', marginBottom: 4 },
body: { fontSize: 14, color: '#666', lineHeight: 20 },
});
POST 요청 — 데이터 전송
import { View, TextInput, TouchableOpacity, Text, StyleSheet } from 'react-native';
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
type NewPost = { title: string; body: string };
const createPost = async (post: NewPost) => {
const res = await fetch('https://jsonplaceholder.typicode.com/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(post),
});
if (!res.ok) throw new Error('전송 실패');
return res.json();
};
export default function CreatePostScreen() {
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const mutation = useMutation({
mutationFn: createPost,
onSuccess: (data) => {
alert(`게시글이 등록되었습니다! ID: ${data.id}`);
setTitle('');
setBody('');
},
onError: () => alert('오류가 발생했습니다.'),
});
return (
<View style={styles.container}>
<TextInput
style={styles.input}
placeholder="제목"
value={title}
onChangeText={setTitle}
/>
<TextInput
style={[styles.input, styles.multiline]}
placeholder="내용"
value={body}
onChangeText={setBody}
multiline
numberOfLines={4}
/>
<TouchableOpacity
style={[styles.button, mutation.isPending && styles.buttonDisabled]}
onPress={() => mutation.mutate({ title, body })}
disabled={mutation.isPending}
>
<Text style={styles.buttonText}>
{mutation.isPending ? '등록 중...' : '게시글 등록'}
</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 16, gap: 12 },
input: {
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
padding: 12,
fontSize: 16,
backgroundColor: '#fff',
},
multiline: { height: 120, textAlignVertical: 'top' },
button: {
backgroundColor: '#007AFF',
padding: 14,
borderRadius: 8,
alignItems: 'center',
},
buttonDisabled: { backgroundColor: '#aaa' },
buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
});
상태 관리 방식 선택 가이드
단순한 컴포넌트 내부 상태
→ useState
로그인 정보, 테마 등 앱 전체 공유
→ useContext
복잡한 비즈니스 로직, 여러 화면 공유
→ Zustand
서버 데이터 패칭, 캐싱
→ React Query (TanStack Query)
정리
| 방식 | 용도 | 복잡도 |
|---|---|---|
useState | 컴포넌트 로컬 상태 | 낮음 |
useContext | 앱 전역 상태 (소규모) | 중간 |
Zustand | 앱 전역 상태 (중대규모) | 중간 |
fetch | 단순 API 호출 | 낮음 |
React Query | 서버 데이터 패칭 (실무 권장) | 중간 |
다음 편에서는 카메라, 위치, 푸시 알림 등 디바이스 고유 기능을 활용하는 방법을 살펴보겠습니다.
시리즈 목차
- 1편 Expo 개발 환경 세팅 & 첫 앱 실행
- 2편 화면 구성 & 핵심 컴포넌트
- 3편 화면 이동 구현 (Expo Router)
- 4편 상태 관리 & API 연동 ← 현재 글
- 5편 디바이스 기능 활용 (카메라, 위치, 알림)
- 6편 Firebase 연동
- 7편 빌드 & 앱스토어 배포