카테고리 없음

RN 공부 | 8. Context API와 Ref | Do it! 리액트 네이티브 앱 프로그래밍

rushin 2025. 12. 15. 00:05

Context API: 이 데이터(상태)를 저 멀리 있는 컴포넌트까지 어떻게 효율적으로 전달할 것인가? 

Ref 시스템(forwardRef, useImperativeHandle): 리액트의 선언적 흐름을 깨고 특정 컴포넌트를 직접 제어해야 할 때는 어떻게 해야 하는가?

1. Context API: Prop Drilling 문제와 해결

리액트의 데이터 흐름은 기본적으로 단방향(Top-down)이다. 즉, 데이터는 항상 부모 컴포넌트에서 자식 컴포넌트로 props를 통해서만 전달된다. 앱의 규모가 작을 때는 이 방식이 직관적이고 데이터를 추적하기 쉽다는 장점.

하지만 컴포넌트 트리의 깊이(depth)가 깊어지고 구조가 복잡해지면 구조적 문제가 발생하는데, 이를 Prop Drilling(프롭 드릴링)이라 부른다.

1.1 Prop Drilling이란?

상위 컴포넌트가 가지고 있는 데이터를 트리의 맨 아래에 있는 하위 컴포넌트에 전달하기 위해, 중간에 있는 컴포넌트들이 그 데이터를 연쇄적으로 전달하는 패턴을 말한다. 마치 드릴로 땅을 파고 내려가는 것과 같다고 해서 붙여진 이름이다.

예를 들어, 최상위 App 컴포넌트에 있는 로그인한 사용자 정보(User Info)를 최하위 UserAvatar 컴포넌트에서 보여줘야 한다고 가정해보자.

[App] (데이터 보유: user)
  └─ [Layout] (데이터 필요 없음, 단순 전달)
       └─ [Header] (데이터 필요 없음, 단순 전달)
            └─ [UserMenu] (데이터 필요 없음, 단순 전달)
                 └─ [UserAvatar] (데이터 필요: user)

 

이 구조에서 Layout, Header, UserMenu는 오직 UserAvatar에게 user 정보를 전달하기 위해 props를 받아야 한다.

1.2 무엇이 문제인가?

이러한 패턴은 기능 구현에는 문제가 없지만, 프로젝트 유지보수 측면에서 심각한 비효율을 초래한다.

  1. 불필요한 데이터 접근: 중간 컴포넌트들은 자신과 전혀 상관없는 데이터를 가지고 있어야 한다. 이는 코드의 가독성을 해친다.
  2. 강한 결합도(Coupling): 중간 컴포넌트가 특정 데이터 구조에 의존하게 된다. 만약 전달해야 할 데이터의 이름이 user에서 profile로 바뀐다면, 경로상에 있는 모든 컴포넌트의 코드를 수정해야 한다.
  3. 리팩토링의 어려움: 컴포넌트의 위치를 옮기거나 구조를 변경할 때, 연결된 props 사슬을 끊고 다시 연결하는 작업이 매우 번거로워진다.

1.3 해결책: Context API

리액트는 이 문제를 해결하기 위해 Context API를 제공한다. Context는 데이터가 부모에서 자식으로 흐르는 것이 아니라, 컴포넌트 트리 전체에 데이터를 '방송(Broadcast)'하는 개념이다.

데이터가 필요한 컴포넌트(UserAvatar)는 중간 단계를 거치지 않고 Context(방송국)에 직접 접속하여 데이터를 받아올 수 있다. 이를 통해 중간 컴포넌트들의 불필요한 props 전달 과정을 제거하고, 깔끔하고 유지보수하기 쉬운 코드를 작성할 수 있다.

1.4 Context의 구성 요소

Context는 크게 세 가지 요소로 이루어진다.

  1. Context 객체: createContext로 생성된 파이프라인 그 자체.
  2. Provider: 파이프라인에 데이터를 흘려보내는 공급자.
  3. Consumer (useContext): 파이프라인에서 데이터를 받아 쓰는 소비자.

최신 리액트 네이티브 트렌드에서는 Consumer 컴포넌트를 직접 쓰기보다 useContext 훅을 사용하는 것이 표준.

// src/contexts/AquariumContext.tsx
import React, { createContext, useContext, useState, ReactNode, useMemo } from 'react';

// 1. 관리할 상태의 타입 정의
interface AquariumState {
  fishCount: number;
  addFish: () => void;
}

// 2. Context 생성 (초기값은 보통 null이나 더미 값을 둠)
const AquariumContext = createContext<AquariumState | undefined>(undefined);

// 3. Provider 컴포넌트 구현
export const AquariumProvider = ({ children }: { children: ReactNode }) => {
  const [fishCount, setFishCount] = useState(0);

  const addFish = () => setFishCount(prev => prev + 1);

  // Tip: value 객체를 useMemo로 감싸면 불필요한 리렌더링을 방지할 수 있다.
  const value = useMemo(() => ({ fishCount, addFish }), [fishCount]);
  return (
 	<AquariumContext.Provider value={value}>
      {children}
    </AquariumContext.Provider>
  );
};

// 4. Custom Hook (Best Practice)
// useContext를 직접 쓰기보다, 에러 처리가 포함된 커스텀 훅을 만드는 것이 안전하다.
export const useAquarium = () => {
  const context = useContext(AquariumContext);
  if (!context) {
    throw new Error('useAquarium must be used within an AquariumProvider');
  }
  return context;
};

 

이렇게 useAquarium 훅을 만들어두면, 하위 컴포넌트 어디서든 const { addFish } = useAquarium(); 한 줄로 물고기를 추가할 수 있다.

2. 테마(Theme) 시스템 구축: 다크 모드 대응

최근 모바일 앱 트렌드에서 다크 모드(Dark Mode) 지원은 선택이 아닌 필수다. iOS 13, Android 10 이상부터 시스템 차원에서 다크 모드를 지원하며, 사용자는 눈의 피로를 줄이기 위해 이를 선호한다.

리액트 네이티브 앱이 시스템 테마 설정에 반응하려면 Context 기법이 필수적.

2.1 useColorScheme과 Appearance

리액트 네이티브는 useColorScheme이라는 훅을 제공한다. 이 훅은 현재 기기의 설정이 light인지 dark인지 알려준다.

import { useColorScheme } from 'react-native';

const ThemeContext = createContext<any>(null);

const ThemeProvider = ({ children }) => {
  const scheme = useColorScheme(); // 'light' | 'dark' | null
  const isDark = scheme === 'dark';
  
  const theme = {
    background: isDark ? '#001d3d' : '#e0f7fa', // 심해 vs 얕은 바다
    text: isDark ? '#ffffff' : '#000000',
  };

  return (
    <ThemeContext.Provider value={theme}>
      {children}
    </ThemeContext.Provider>
  );
};

2.2 Provider의 계층 구조 (Hierarchy)

Provider는 컴포넌트 트리 상단에 위치해야 한다. 만약 여러 개의 Context가 필요하다면, 서로 독립적인 기능이므로 순서는 상관없으나 보통 Theme이나 Auth 같은 전역적인 설정을 가장 바깥쪽에 둔다.

export default function App() {
  return (
    <ThemeProvider>        {/* 색상 정보 제공 */}
      <AquariumProvider>   {/* 어항 데이터 제공 */}
         <MainNavigator />
      </AquariumProvider>
    </ThemeProvider>
  );
}

3. Ref: 컴포넌트 인스턴스에 직접 접근하고 제어하기

리액트는 선언형(Declarative) 프로그래밍을 지향한다. 즉, UI의 변화를 직접적으로 명령하는 것이 아니라, 상태가 이럴 때 UI는 이런 모습이어야 한다고 선언하면 리액트가 알아서 렌더링한다.

하지만 현실 세계의 앱 개발에서는 명령형(Imperative) 접근이 필요할 때가 있다.

  • 특정 입력창(TextInput)에 포커스를 강제로 줄 때
  • 스크롤을 특정 위치로 강제 이동시킬 때
  • 애니메이션을 강제로 트리거할 때

이때 사용하는 것이 바로 ref 속성과 useRef 훅이다.

3.1 Ref의 본질

ref는 컴포넌트의 인스턴스(Native Component Instance)나 특정 값을 렌더링을 유발하지 않고 유지하고 싶을 때 사용하는 보관함이다.

타입스크립트에서 ref 객체는 current 속성을 가진 RefObject<T> 타입을 가진다.

// TextInput에 접근하기 위한 Ref 생성
const inputRef = useRef<TextInput>(null);

// 사용: 어항에 물고기 이름을 짓기 위해 키보드를 올릴 때
const focusInput = () => {
  inputRef.current?.focus(); // ?. 연산자로 안전하게 접근
};

4. 키보드 핸들링

모바일 개발에서는 키보드 핸들링이 요구

4.1 Keyboard.dismiss()

화면의 빈 곳을 터치했을 때 키보드를 내리고 싶다면 TouchableWithoutFeedback과 Keyboard 모듈을 조합한다.

import { Keyboard, TouchableWithoutFeedback } from 'react-native';

<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
  <View style={{ flex: 1 }}>{/* 내용 */}</View>
</TouchableWithoutFeedback>

4.2 KeyboardAwareScrollView

하지만 단순히 키보드를 내리는 것보다, 키보드가 올라오면 입력창도 같이 올라가는 동작이 훨씬 자연스럽다. KeyboardAvoidingView라는 코어 컴포넌트가 있지만, 플랫폼별(iOS/Android) 동작 차이로 react-native-keyboard-aware-scroll-view 라이브러리를 사실상 표준처럼 사용한다.

이 라이브러리는 현재 포커스된 TextInput을 감지하여 자동으로 스크롤 위치를 조정해준다.

5. useImperativeHandle과 forwardRef

ref는 기본적으로 View, Text, TextInput 같은 네이티브 코어 컴포넌트에만 사용할 수 있다. 커스텀 컴포넌트에는 ref를 사용할 수 없지만, forwardRef와 useImperativeHandle을 통해 구현할 수 있다.

5.1 forwardRef: Ref 전달

forwardRef는 부모로부터 받은 ref를 자식(보통 코어 컴포넌트)에게 전달(Forwarding)해주는 역할.

5.2 useImperativeHandle

단순히 ref를 전달만 하는 게 아니라, 부모가 내 컴포넌트의 특정 기능만 실행할 수 있도록 제한적인 API를 만들고 싶을 때 사용한다.

import React, { forwardRef, useImperativeHandle, useState } from 'react';
import { View, Text } from 'react-native';

// 1. 부모가 사용할 수 있는 메서드(Handle)의 타입 정의
export interface FishHandle {
  swim: () => void;
  feed: () => void;
}

interface FishProps {
  name: string;
}

// 2. forwardRef로 감싸기
const Fish = forwardRef<FishHandle, FishProps>((props, ref) => {
  const [position, setPosition] = useState({ x: 0, y: 0 });

  // 3. 부모에게 노출할 메서드 정의
  useImperativeHandle(ref, () => ({
    swim: () => {
      console.log(`${props.name}가 헤엄칩니다!`);
      setPosition({ x: Math.random() * 100, y: Math.random() * 100 });
    },
    feed: () => {
      console.log(`${props.name}가 먹이를 먹습니다.`);
    }
  }));

  return (
    <View style={{ position: 'absolute', left: position.x, top: position.y }}>
      <Text>🐟 {props.name}</Text>
    </View>
  );
});

export default Fish;

 

사용하는 쪽 (부모 컴포넌트):

import React, { useRef } from 'react';
import { View, Button } from 'react-native';

const Aquarium = () => {
  // FishHandle 타입의 ref 생성
  const nemoRef = useRef<FishHandle>(null);

  const handleTouch = () => {
    // 이제 부모는 자식의 내부 구현을 몰라도 swim 명령을 내릴 수 있다.
    nemoRef.current?.swim();
  };

  return (
    <View>
      <Fish ref={nemoRef} name="니모" />
      <Button title="헤엄쳐!" onPress={handleTouch} />
    </View>
  );
};

활용 이유

캡슐화(Encapsulation): 자식 컴포넌트는 자신의 복잡한 애니메이션 로직이나 상태를 숨기고, 부모에게는 깔끔한 메서드들만 제공한다. 유지보수 용이.

 

리액트를 사용하기 위해서는 선언적 흐름(Context, Props)과 명령형 제어(Ref)를 모두 적절히 사용해야 한다.