React Native 앱 개발과 내부 구조: 컴포넌트·네비게이션부터 Bridge·JSI·Yoga·스레드 모델까지

이 글의 핵심

React Native 프로젝트 생성부터 컴포넌트와 스타일, 화면 전환, 저장소와 API 호출, 로그인 화면 예제에 이어 JS와 네이티브가 Bridge·JSI로 통신하는 방식과 레이아웃 엔진을 정리합니다.

React Native는 React 컴포넌트로 iOS와 Android의 실제 네이티브 뷰를 그리는 크로스플랫폼 모바일 프레임워크입니다. 이 글은 프로젝트 생성, 기본 컴포넌트와 스타일, 화면 전환, 로컬 저장소, API 호출, 플랫폼별 코드를 예제로 다룬 뒤, JS와 네이티브가 Bridge와 JSI로 통신하는 방식, 네이티브 모듈, Yoga 레이아웃, 스레드 모델을 설명합니다.


React Native가 해결하려는 문제

iOS와 Android 앱을 각각 Swift와 Kotlin으로 만들면 같은 화면과 로직을 두 번 구현하고 두 팀이 따로 유지보수해야 합니다. React Native는 화면과 로직을 JavaScript(TypeScript)와 React로 한 번 작성하고, 렌더링은 각 플랫폼의 네이티브 뷰(UIView, Android View)로 처리합니다. WebView 안에 HTML을 띄우는 하이브리드 방식과 달리 실제 네이티브 컴포넌트가 화면에 그려지므로 스크롤이나 텍스트 입력 같은 기본 동작이 플랫폼의 것과 같습니다.

웹에서 React를 써 본 개발자라면 컴포넌트, props, state, 훅을 그대로 쓸 수 있다는 점이 큰 장점입니다. 개발 중에는 Fast Refresh로 코드 수정이 앱 상태를 유지한 채 바로 반영됩니다. 반면 카메라 세부 제어나 백그라운드 작업처럼 OS 기능에 깊이 들어가면 결국 네이티브 코드나 네이티브 모듈 라이브러리가 필요하고, 두 플랫폼의 빌드 도구(Xcode, Gradle)를 다룰 줄 알아야 한다는 점은 변하지 않습니다.


Expo와 React Native CLI 중 무엇으로 시작할까

React Native 공식 문서는 새 프로젝트를 Expo 같은 프레임워크로 시작하기를 권장합니다. Expo는 빌드, 업데이트, 권한 설정 같은 반복 작업을 표준화해 주고, 필요하면 npx expo prebuild로 네이티브 프로젝트를 생성해 직접 수정할 수도 있습니다.

Expo

npx create-expo-app@latest my-app
cd my-app
npx expo start

React Native Community CLI

npx @react-native-community/cli@latest init MyApp
cd MyApp
npm run android
# iOS는 먼저 CocoaPods 의존성을 설치해야 합니다
cd ios && pod install && cd ..
npm run ios

예전에 쓰던 npx react-native init은 0.75부터 지원이 중단되었으므로, 커뮤니티 CLI를 직접 호출하는 위 명령을 사용합니다. iOS 빌드는 macOS와 Xcode가 있어야 합니다.


View·Text·ScrollView·TextInput 기본 컴포넌트

import { View, Text, Image, ScrollView, TextInput, Button, Alert } from 'react-native';
export default function App() {
  return (
    <ScrollView>
      <View style={{ padding: 20 }}>
        <Text style={{ fontSize: 24, fontWeight: 'bold' }}>Hello React Native</Text>
        <Image source={{ uri: 'https://example.com/image.jpg' }} style={{ width: 200, height: 200 }} />
        <TextInput placeholder="Enter text" style={{ borderWidth: 1, padding: 10 }} />
        <Button title="Click me" onPress={() => Alert.alert('Clicked!')} />
      </View>
    </ScrollView>
  );
}

웹과 달리 모든 텍스트는 반드시 <Text> 안에 있어야 하며, <View> 안에 문자열을 직접 넣으면 오류가 납니다. 원격 이미지는 크기를 알 수 없으므로 width와 height를 지정해야 화면에 나타납니다.


CSS 대신 StyleSheet.create로 스타일 정의하기

import { View, Text, StyleSheet } from 'react-native';
export default function App() {
  return (
    <View style={styles.container}>
      <Text style={styles.title}>Hello</Text>
    </View>
  );
}
const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: '#f5f5f5',
  },
  title: {
    fontSize: 24,
    fontWeight: 'bold',
    color: '#333',
  },
});

스타일은 CSS 파일이 아니라 JavaScript 객체이고, 속성 이름은 camelCase이며 길이 단위는 밀도 독립 픽셀(dp) 숫자입니다. 스타일은 부모에서 자식으로 상속되지 않습니다(중첩된 <Text> 사이에서만 일부 텍스트 스타일이 상속됩니다). StyleSheet.create는 정의한 객체를 그대로 돌려주지만, 스타일을 컴포넌트 밖에 두어 렌더링마다 새 객체를 만들지 않게 하고 타입 검사를 받는 데 의미가 있습니다.


React Navigation으로 화면 전환하기

설치

npm install @react-navigation/native @react-navigation/native-stack
npm install react-native-screens react-native-safe-area-context

Expo 프로젝트라면 두 번째 줄 대신 npx expo install react-native-screens react-native-safe-area-context를 써야 SDK와 호환되는 버전이 설치됩니다.

Stack Navigator

// App.tsx
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import HomeScreen from './screens/Home';
import DetailsScreen from './screens/Details';

export type RootStackParamList = {
  Home: undefined;
  Details: { id: number };
};

const Stack = createNativeStackNavigator<RootStackParamList>();

export default function App() {
  return (
    <NavigationContainer>
      <Stack.Navigator>
        <Stack.Screen name="Home" component={HomeScreen} />
        <Stack.Screen name="Details" component={DetailsScreen} />
      </Stack.Navigator>
    </NavigationContainer>
  );
}
// screens/Home.tsx
import { View, Button } from 'react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import type { RootStackParamList } from '../App';

type Props = NativeStackScreenProps<RootStackParamList, 'Home'>;

export default function HomeScreen({ navigation }: Props) {
  return (
    <View>
      <Button
        title="Go to Details"
        onPress={() => navigation.navigate('Details', { id: 1 })}
      />
    </View>
  );
}

RootStackParamList로 화면별 파라미터 타입을 선언해 두면, navigate('Details')에 id를 빠뜨리거나 오타가 난 화면 이름을 넘길 때 컴파일 단계에서 잡힙니다. native-stack은 iOS의 UINavigationController와 Android의 Fragment 기반 화면 전환을 사용하므로 전환 애니메이션과 뒤로 가기 제스처가 플랫폼 기본 동작과 같습니다.


AsyncStorage로 로컬 데이터 저장하기

npm install @react-native-async-storage/async-storage
import AsyncStorage from '@react-native-async-storage/async-storage';

// 저장: 값은 문자열만 가능하므로 객체는 직렬화합니다
await AsyncStorage.setItem('user', JSON.stringify({ name: 'John' }));

// 읽기: 키가 없으면 null이 돌아옵니다
const raw = await AsyncStorage.getItem('user');
const userData = raw !== null ? JSON.parse(raw) : null;

// 삭제
await AsyncStorage.removeItem('user');

AsyncStorage는 암호화되지 않은 키-값 저장소입니다. 설정값이나 캐시에는 적합하지만, 액세스 토큰 같은 민감한 값은 iOS Keychain이나 Android Keystore를 쓰는 라이브러리(예: expo-secure-store, react-native-keychain)에 저장해야 합니다.


FlatList와 fetch로 사용자 목록 불러오기

import { useEffect, useState } from 'react';
import { View, Text, FlatList, ActivityIndicator } from 'react-native';

interface User {
  id: number;
  name: string;
}

export default function UserList() {
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    let cancelled = false;
    fetch('https://api.example.com/users')
      .then((response) => {
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        return response.json() as Promise<User[]>;
      })
      .then((data) => {
        if (!cancelled) setUsers(data);
      })
      .catch((e) => {
        if (!cancelled) setError(String(e));
      })
      .finally(() => {
        if (!cancelled) setLoading(false);
      });
    return () => {
      cancelled = true;
    };
  }, []);

  if (loading) return <ActivityIndicator size="large" />;
  if (error) return <Text>불러오기 실패: {error}</Text>;

  return (
    <FlatList
      data={users}
      keyExtractor={(item) => item.id.toString()}
      renderItem={({ item }) => (
        <View style={{ padding: 16 }}>
          <Text>{item.name}</Text>
        </View>
      )}
    />
  );
}

fetch는 404나 500 응답에서도 reject하지 않으므로 response.ok를 직접 확인해야 합니다. 이 검사와 catch가 없으면 요청이 실패했을 때 로딩 스피너가 영원히 돌게 됩니다. cancelled 플래그는 응답이 오기 전에 화면을 떠났을 때 언마운트된 컴포넌트의 상태를 갱신하지 않도록 막습니다. FlatList는 화면 근처의 항목만 렌더링하므로, 긴 목록을 ScrollView와 map으로 그리는 것보다 메모리와 렌더링 비용이 훨씬 적습니다.


Platform.OS와 플랫폼별 파일로 iOS·Android 분기

import { Platform, StyleSheet } from 'react-native';

const styles = StyleSheet.create({
  container: {
    padding: Platform.OS === 'ios' ? 20 : 10,
    ...Platform.select({
      ios: { shadowOpacity: 0.2, shadowRadius: 4 },
      android: { elevation: 4 },
    }),
  },
});

작은 차이는 Platform.OS나 Platform.select로 분기하면 충분합니다. 위 예처럼 그림자는 iOS에서 shadow* 속성, Android에서 elevation으로 표현 방식이 달라 자주 분기 대상이 됩니다.

컴포넌트 구현 전체가 다를 때는 Button.ios.tsx와 Button.android.tsx처럼 플랫폼 확장자를 붙인 파일 두 개를 만들고 import Button from './Button'으로 가져오면, Metro 번들러가 빌드 대상 플랫폼에 맞는 파일만 골라 번들에 넣습니다. Platform.select에 require()를 넘기는 방식도 가능하지만 ES 모듈의 기본 내보내기는 require('./X').default로 꺼내야 해서 실수하기 쉽습니다.


예제: 로그인 화면

import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert } from 'react-native';
import { useState } from 'react';

export default function LoginScreen() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [submitting, setSubmitting] = useState(false);

  const handleLogin = async () => {
    if (!email.includes('@') || password.length === 0) {
      Alert.alert('Error', '이메일과 비밀번호를 확인하세요');
      return;
    }
    setSubmitting(true);
    try {
      const response = await fetch('https://api.example.com/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email, password }),
      });
      const data = await response.json().catch(() => ({}));
      if (response.ok) {
        Alert.alert('Success', 'Logged in successfully');
      } else {
        Alert.alert('Error', data.message ?? `HTTP ${response.status}`);
      }
    } catch (error) {
      Alert.alert('Error', 'Network error');
    } finally {
      setSubmitting(false);
    }
  };

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Login</Text>
      <TextInput
        style={styles.input}
        placeholder="Email"
        value={email}
        onChangeText={setEmail}
        keyboardType="email-address"
        autoCapitalize="none"
      />
      <TextInput
        style={styles.input}
        placeholder="Password"
        value={password}
        onChangeText={setPassword}
        secureTextEntry
      />
      <TouchableOpacity style={styles.button} onPress={handleLogin} disabled={submitting}>
        <Text style={styles.buttonText}>{submitting ? '...' : 'Login'}</Text>
      </TouchableOpacity>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    padding: 20,
    backgroundColor: '#fff',
  },
  title: {
    fontSize: 32,
    fontWeight: 'bold',
    marginBottom: 32,
    textAlign: 'center',
  },
  input: {
    borderWidth: 1,
    borderColor: '#ddd',
    padding: 12,
    marginBottom: 16,
    borderRadius: 8,
  },
  button: {
    backgroundColor: '#3498db',
    padding: 16,
    borderRadius: 8,
    alignItems: 'center',
  },
  buttonText: {
    color: 'white',
    fontSize: 16,
    fontWeight: 'bold',
  },
});

전송 전에 간단한 입력 검사를 하고, 요청 중에는 버튼을 비활성화해 중복 제출을 막습니다. 에러 응답 본문이 JSON이 아닐 수도 있으므로 response.json() 실패를 따로 처리합니다. secureTextEntry는 입력을 가릴 뿐 저장이나 전송을 보호하지 않으므로, 로그인 API는 반드시 HTTPS여야 하고 받은 토큰은 앞에서 말한 보안 저장소에 둡니다. 화면 아래쪽 입력란이 키보드에 가려지는 문제는 KeyboardAvoidingView로 감싸 해결합니다.


Bridge(구) vs JSI·New Architecture(신)

React Native를 깊이 이해하려면 JavaScript 런타임과 네이티브 레이어가 어떻게 만나는지를 알아야 합니다. 여기서 두 축은 레거시 Bridge와, JSI 위에 Fabric과 TurboModules를 올린 New Architecture입니다. New Architecture는 0.76부터 기본으로 켜졌고, 이후 버전에서는 레거시 아키텍처가 제거되는 중이므로 새 프로젝트는 New Architecture를 전제로 생각하면 됩니다.

Bridge(레거시) 모델

레거시 React Native에서 JS와 네이티브는 비동기로 배치 처리되는 메시지 채널인 Bridge로 통신했습니다. JS 스레드에서 생긴 UI 업데이트와 네이티브 모듈 호출은 포인터가 아니라 JSON으로 직렬화된 메시지로 포장되어 네이티브 쪽으로 넘어갔습니다. 두 런타임의 경계가 명확해 서로 다른 언어 세계를 안전하게 연결할 수 있다는 장점이 있었습니다.

단점은 작은 호출이 대량으로 쌓이면 직렬화와 큐잉 비용이 커지고, 네이티브 값을 동기적으로 즉시 읽어야 하는 경우(레이아웃 측정, 제스처에 따라 매 프레임 움직이는 애니메이션)를 구조적으로 처리할 수 없다는 점입니다. 모든 호출이 비동기이므로 한 프레임 안에 JS와 네이티브가 주고받기를 끝낼 수 없었습니다.

JSI(JavaScript Interface)와 New Architecture

JSI는 JavaScript 엔진(Hermes 등) 위에 놓인 얇은 C++ 인터페이스로, 네이티브 C++ 객체를 JS에 호스트 객체(Host Object)로 노출하고 JS가 그 메서드를 직렬화 없이 동기적으로 호출할 수 있게 합니다. New Architecture를 구성하는 요소는 다음과 같습니다.

  • Fabric: 새 렌더러입니다. 렌더링 트리를 C++로 공유하고, 필요하면 레이아웃 측정이나 업데이트를 동기적으로 처리할 수 있어 React 18의 동시성 기능과도 맞물립니다.
  • TurboModules: 재설계된 네이티브 모듈 시스템입니다. 앱 시작 시 모든 모듈을 초기화하던 방식 대신 처음 사용할 때 로딩하고, JSI로 호출됩니다.
  • Codegen: TypeScript나 Flow로 작성한 스펙에서 네이티브 인터페이스 코드를 생성해, JS와 네이티브 시그니처가 어긋나는 문제를 빌드 단계에서 막습니다.

성능 문제를 볼 때는 New Architecture가 켜져 있는지부터 확인하고, 병목이 JS 연산인지, 네이티브 I/O인지, 렌더링인지 나누어 측정해야 합니다. 같은 코드라도 아키텍처에 따라 프로파일이 달라집니다.

마이그레이션 시 깨지는 지점

레거시 아키텍처에서 잘 돌던 앱이 New Architecture에서 깨지는 흔한 원인은 아직 대응하지 않은 서드파티 네이티브 라이브러리, 바뀐 스레드와 타이밍에 의존하던 코드, Codegen 스펙과 실제 구현의 불일치입니다. 전환은 설정 플래그 하나로 끝나지 않으며, 의존 라이브러리의 호환 여부를 먼저 확인하고 단계적으로 검증해야 합니다.


네이티브 모듈 만들기: Bridge 모듈 vs Turbo Native Module

JS에서 직접 쓸 수 없는 OS API가 필요하면 네이티브 모듈을 작성해야 합니다.

레거시: NativeModules 기반 모듈

예전 방식은 Android에서 @ReactMethod, iOS에서 RCT_EXPORT_METHOD로 메서드를 노출하고 JS에서 NativeModules.MyModule로 접근하는 것이었습니다. 이해하기는 쉽지만, 메서드 이름과 인자 타입을 JS와 두 플랫폼 코드에서 각각 손으로 맞춰야 해서 어긋나기 쉽습니다. 어긋나도 런타임에 호출해 보기 전까지 알 수 없습니다.

신: Turbo Native Module + Codegen

New Architecture에서는 TypeScript로 작성한 스펙 파일이 인터페이스의 기준이 되고, Codegen이 이 스펙에서 네이티브 쪽 인터페이스를 생성합니다. 메서드 시그니처가 한 곳에 고정되고, 네이티브 구현이 스펙과 맞지 않으면 빌드가 실패하며, 모듈은 처음 사용할 때 초기화됩니다.

아래는 스펙 파일의 최소 형태입니다. Codegen 설정(package.json의 codegenConfig)과 파일 배치는 공식 문서의 최신 템플릿을 따르는 것이 안전합니다. 스펙 파일 이름은 Native로 시작해야 Codegen이 인식합니다.

// specs/NativeSampleModule.ts
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';

export interface Spec extends TurboModule {
  readonly getConstants: () => { appVersion: string };
  multiply(a: number, b: number): number;
}

export default TurboModuleRegistry.getEnforcing<Spec>('NativeSampleModule');

getEnforcing은 모듈이 등록되어 있지 않으면 즉시 예외를 던지고, get은 null을 돌려줍니다. 네이티브 구현은 Codegen이 생성한 인터페이스를 기준으로 시그니처를 맞추면 됩니다.

어느 방식이든 지켜야 할 원칙이 있습니다. UIKit이나 Android 뷰처럼 메인 스레드에서만 안전한 API는 메인 스레드로 넘겨 호출하고, 무거운 작업은 백그라운드 큐에서 처리합니다. 네이티브 예외는 삼키지 말고 JS에서 처리할 수 있는 Promise reject나 오류 코드로 바꿔 전달합니다. 특히 위 예의 multiply처럼 동기 메서드는 JS 스레드를 그대로 붙잡으므로, 오래 걸리는 작업은 반드시 Promise를 반환하는 비동기 메서드로 설계해야 합니다.


Yoga와 Flexbox 레이아웃 모델

React Native는 레이아웃을 iOS Auto Layout이나 Android 레이아웃 시스템에 맡기지 않고, 두 플랫폼에서 같은 결과를 내는 Flexbox 엔진인 Yoga로 계산합니다. Yoga는 C++ 라이브러리이며, width, height, flex, justifyContent, alignItems, position, padding 같은 스타일을 받아 각 뷰의 위치와 크기를 정합니다.

웹 CSS와 이름은 같아도 CSS의 부분집합이고 기본값이 다릅니다. flexDirection의 기본값은 웹의 row가 아니라 column이고, alignContent 기본값은 flex-start, flexShrink 기본값은 0입니다. 또 flex: 1 같은 숫자 하나짜리 flex는 웹의 축약 문법과 의미가 다릅니다(React Native에서 양수 flex는 남는 공간을 비율대로 나눠 갖는다는 뜻입니다). 웹에서 복사한 스타일이 다르게 보이면 이런 기본값 차이부터 확인합니다.

실무에서 자주 겪는 함정은 다음과 같습니다. 부모에 높이가 정해지지 않으면 자식의 flex: 1은 나눌 공간이 없어 높이가 0이 됩니다. position: 'absolute'로 띄운 요소는 세이프 에어리어나 키보드와 겹치기 쉽습니다. 폰트 메트릭이 플랫폼마다 달라 같은 fontSize라도 텍스트 높이가 조금씩 다르므로, 두 플랫폼에서 픽셀 단위로 똑같이 맞추려 하면 비용이 큽니다. 복잡한 화면은 스타일을 쓰기 전에 레이아웃 트리를 먼저 그리고 View 경계를 나누면 결과를 예측하기 쉬워집니다.


스레드 모델: JS, UI, 백그라운드

React Native의 성능 문제는 알고리즘보다 작업이 잘못된 스레드에 몰려서 생기는 경우가 많습니다.

JavaScript 스레드

React 렌더링, 비즈니스 로직, 대부분의 이벤트 핸들러가 JS 스레드에서 실행됩니다. 이 스레드를 오래 점유하면 터치 응답과 JS 기반 애니메이션이 프레임을 놓칩니다. 렌더링 자체는 가벼운데 화면이 끊긴다면 동기 루프, 과도한 console.log, 큰 JSON 파싱, 메모이제이션 누락으로 인한 대규모 리렌더를 의심합니다. 애니메이션은 Animated의 useNativeDriver: true나 Reanimated처럼 UI 스레드에서 돌게 하면 JS 스레드가 바빠도 끊기지 않습니다.

UI 스레드(메인 스레드)

iOS와 Android의 실제 뷰 갱신과 시스템 콜백이 실행되는 스레드입니다. 네이티브 모듈이 여기서 무거운 작업을 하면 터치, 애니메이션, 화면 전환이 함께 멈춥니다.

레이아웃 계산

레거시 아키텍처에서는 Yoga 레이아웃을 별도의 shadow 스레드에서 계산했습니다. Fabric에서는 렌더링 트리가 C++로 공유되어 레이아웃을 백그라운드 스레드에서도, 필요하면 UI 스레드에서 동기적으로도 계산할 수 있습니다. 직접 네이티브 코드를 작성할 때는 그 코드가 어느 스레드에서 호출되고 어느 스레드에서만 안전한지를 먼저 확인해야 합니다.

Hermes

Hermes는 React Native를 위해 만든 JS 엔진으로, 0.70부터 기본 엔진입니다. 빌드 시점에 JS를 바이트코드로 미리 컴파일하므로 앱 시작 시 파싱 비용이 줄고 메모리 사용량도 적은 편입니다. 다만 JIT가 없어 순수 연산 성능이 항상 더 빠른 것은 아니며, Intl이나 일부 최신 문법 지원은 버전마다 다르므로 실제 기기에서 프로파일링해 확인합니다.


서비스 운영 단계에서 챙겨야 할 것들

서버 데이터는 TanStack Query 같은 라이브러리로 캐시, 재시도, 만료를 관리하는 편이 좋습니다. 앞의 useEffect + fetch 예제처럼 직접 구현하면 경쟁 조건, 언마운트 후 갱신, 앱이 백그라운드에서 돌아왔을 때의 재요청을 매번 손으로 처리해야 합니다.

FlatList는 가상화를 기본으로 하지만 설정에 따라 성능 차이가 큽니다. 안정적인 keyExtractor, 항목 높이가 고정이면 getItemLayout, React.memo로 감싼 항목 컴포넌트, 적절한 windowSize가 기본입니다. 서버에서 수천 개 항목을 한 번에 받지 말고 onEndReached로 페이지 단위로 불러옵니다.

딥링크와 유니버설 링크는 플랫폼 설정(Associated Domains, intent filter)과 앱 라우팅이 분리되어 있어 자주 깨집니다. 앱이 꺼진 상태, 켜진 상태, 백그라운드에서 돌아온 상태를 각각 테스트해야 합니다. 토큰 갱신은 화면마다 처리하지 말고 API 클라이언트의 한 지점에서 처리하는 편이 디버깅하기 쉽습니다.

JS 오류는 에러 바운더리로 잡을 수 있지만 네이티브 크래시는 잡지 못하므로, Sentry 같은 크래시 리포팅 도구로 두 가지를 모두 수집합니다. Expo Updates 같은 OTA 업데이트는 JS 번들과 에셋만 교체할 수 있으므로, 네이티브 코드가 바뀐 변경은 스토어 배포로 별도 관리해야 합니다.

민감한 화면은 앱 전환기 썸네일에 내용이 노출되지 않도록 가리고, 로그에 토큰이나 개인정보를 남기지 않습니다.


같이 보면 좋은 글