상태 관리 & API 연동




“화면에 데이터를 채우고, 서버와 통신하는 방법을 알아봅니다.”
이 글에서는 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편 빌드 & 앱스토어 배포



댓글 남기기