RN 프로젝트에서 화면 이동을 표현하기 위해 React Navigation 라이브러리를 선택하였습니다. 전체 기능의 몇 개의 카테고리로 나누어져있고 각각의 카테고리에서 화면을 전환하기 위해 서로 다른 종류의 중첩된 네비게이션이 필요했기 때문에 React Navigation이 적절하다고 생각했습니다. BottomTabNavigation으로 카테고리를 나누고 StackNavigation으로 카테고리 내에서 화면 이동을 표현하는 것이 생각했던 모습과 정확히 맞아 떨어졌기때문입니다.

 

그런데 사용을 하던 중 한 가지 불편한 점이 있었습니다. 몇몇 스크린에서는 해당 스크린으로 이동할 때 특정 데이터를 전달 받아야했습니다. React Navigation에서는 route와 param을 통해 그 기능을 쉽게 구현할 수 있도록 하였으나, 문제는 각각의 화면에서 필요로 하는 데이터를 찾으려 할 때마다 각 화면의 네비게이션 파일을 열어보아야 했습니다.

 

저는 TypeScript를 사용하여 받아야 하는 데이터를 다른 파일을 열지 않도록 할 수 있을 것이라 생각하고 공식 문서를 찾아 보았습니다. TypeScript의 지원이 잘 되어 있어 보이나 navigae()를 이용할 때 받을 수 있는 인자를 표시해주지 못하였습니다. 인자를 정확히 표시하려면 각 스크린에서 필요한 타입을 import하여 사용하는 구조였습니다. 저는 각 스크린 파일마다 필요한 타입을 찾아 가져오는 것이 귀찮았 가져온 뒤에도 제네릭을 이용해 추가적으로 타입을 정해주어야 하는 것이 귀찮다고 느껴졌습니다. 저는 하나의 타입으로 navigation의 모든 타입을 정확히 가져올 수 있도록 만들고 싶었습니다.

강력한 타입으로 무장하면 두려울게 없다?! 강력한 타입은 중무장한 갑옷을 입은 것 같습니다. 출처: Microsoft copilot으로 제작

 

타입 설정

기본적은 구조는 공식 문서와 비슷합니다. 다음 코드를 보시죠.

import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { RouteProp } from "@react-navigation/native";

import { HomeParamList } from "./Navigation/HomeNav";
import { RootParamList } from "./Navigation/AppNav";
import { BackyardParamList } from "./Navigation/BackyardNav";

type ScreenParamList = RootParamList & HomeParamList & BackyardParamList;

type Route<T extends keyof ScreenParamList> = RouteProp<ScreenParamList, T>;

declare global {
	type Navigation<T extends keyof ScreenParamList> = NativeStackNavigationProp<
		ScreenParamList,
		T
	>;
	interface RootScreenProp<T extends keyof ScreenParamList> {
		navigation: NativeStackNavigationProp<ScreenParamList, T>;
		route: Route<T>;
	}
}

RootParamList는 중첩된 네비게이션 중 최상위에 있는 네비게이션이 필요한 ParamList입니다. 다음과 같이 작성되어 있죠.

import { NavigationContainer } from "@react-navigation/native";
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";

import HomeNav, { HomeParamList } from "./HomeNav";
import BackyardNav, { BackyardParamList } from "./BackyardNav";

type NavigationParams<T extends Record<string, object | undefined>> = {
	screen: keyof T;
	params: T[keyof T];
};

export type RootParamList = {
	homeTab: NavigationParams<HomeParamList>;
	backyardTab: NavigationParams<BackyardParamList>;
};

const Tab = createBottomTabNavigator<RootParamList>();

const AppNav = () => {
	return (
		<NavigationContainer>
			<Tab.Navigator screenOptions={{ headerShown: false, lazy: false }}>
				<Tab.Screen name="homeTab" component={HomeNav} />
				<Tab.Screen name="backyardTab" component={BackyardNav} />
			</Tab.Navigator>
		</NavigationContainer>
	);
};

export default AppNav;

해당 타입의 키 값은 스크린의 이름입니다. 그리고 해당 스크린은 screen과 param를 값으로 가지죠. 이렇게 하면 앞의 Route타입을 통해 선택한 각 스크린에 대해 알맞은 screen과 params를 타입스크립트가 알 수 있습니다. Route타입은 제네릭으로 NativeStackParamList, 즉 모든 스크린의 화면 이름을 받습니다. 해당 제네릭은 RouteProp의 두 번째 제네릭 인자로 들어가서 React Navigation이 설정한 타입대로 화면 이름에 따른 params를 찾아 옵니다. RouteProp은 첫 번째 제네릭 인자로 설정된 NativeStackParamList에서 화면이름과 그에 따른 params를 찾아주죠.

 

그리고 params를 정확하게 찾기 위해 하위 내비게이션인 NativeStackNavigation은 다음과 같이 설정하여야합니다.

import { createNativeStackNavigator } from "@react-navigation/native-stack";

import { RoomScreen, HomeScreen } from "../../screens";

export type HomeParamList = {
	home: undefined;
	room: { stuff: string };
};

const Stack = createNativeStackNavigator<HomeParamList>();

const HomeNav = () => {
	return (
		<Stack.Navigator screenOptions={{ headerTitle: "집" }}>
			<Stack.Screen name="home" component={HomeScreen}></Stack.Screen>
			<Stack.Screen name="room" component={RoomScreen}></Stack.Screen>
		</Stack.Navigator>
	);
};

export default HomeNav;

 

 

다음으로는 Navigation라는 이름으로 지정된 타입입니다. NativeStackNavigationProp에 모든 스크린 타입 리스트를 받아와서 라이브러리가 미리 정해둔 대로 모든 화면에 대한 스크린 이름과 받아야할 Param들에 대한 타입을 만들어 주죠.

사용법

사용법은 다음과 같습니다.

import React, { useState } from "react";
import { Button, StyleSheet, Text, TextInput } from "react-native";
import { Example } from "../../components";

const HomeScreen: React.FC<RootScreenProp<"home">> = ({ navigation }) => {
	const [stuff, setStuff] = useState("");

	const handlePressRoom = () => {
    	// room은 같은 부모(homeTab)를 가지고 있으므로 이렇게 작성 가능합니다.
		navigation.navigate("room", { stuff });
	};

	const handlePressBackyard = () => {
		navigation.navigate("backyardTab", {
        	// backyardTab의 하위 스크린과 그에 따른 params를 전달합니다.
			screen: "backyard",
			params: { stuff },
		});
	};
	return // ...

 

 

각각 정해진 스크린의 하위 스크린과 그에 따른 params만 helper가 작동합니다.

 

만약 route를 사용한다면 다음과 같이 사용할 수 있습니다. 삼항조건 연산자를 사용한 이유는 빈 문자열이 들어오는 것이 가능하고 BottomTab의 네비게이션 바를 이용해서도 해당 스크린으로 이동 가능하기 때문입니다. 이런 경우 Backyard의 params는 "?"를 이용해 선택적으로 전달(optianl property)한다고 명시하는 것이 나을 수도 있으나 BottomTab의 네비게이션 바가 아닌 navigate를 통한 이동에서는 해당 params를 필수로 넣는 것을 바라므로 선택 인자가 아닌 필수 속성처럼 설정하였습니다.

import { Text } from "react-native";

const Backyard: React.FC<RootScreenProp<"backyard">> = ({ route }) => {
	return (
		<>
			{route.params?.stuff ? (
				<Text>뒷마당에 둔 물건: {route.params.stuff}</Text>
			) : (
				<Text>아무것도 들고 오지 않았습니다.</Text>
			)}
		</>
	);
};

export default Backyard;

 

useNavigation은 다음과 같이 사용할 수 있습니다.

cosnt navigation = useNavigation<Navigation<"이 컴포넌트를 사용하는 최상위 스크린 이름">>()
// 꼭 최상위 스크린이 아니어도 됩니다. 이동하려는 스크린과의 최소 공통 조상을 찾으셔도 상관없습니다.
	// 예시
	const navigation = useNavigation<Navigation<"homeTab">>();
    // ...

navigation 변수는 프로젝트가 가진 스크린과 그에 따른 param를 알고 있는 타입이 됩니다.

 

제가 작성한 코드는 대부분의 경우 정확한 타입을 지원할 것으로 생각됩니다. 이런 방식의 사용법에 대해 해당 라이브러리 개발자에게 질문을 했어고 문제없을 것이라는 답변을 받아 더욱 보완하여 작성한 코드입니다.

 

강력한 타입의 장점

더 강력한 타입을 사용하는 것을 “타입 강화(type strengthening)” 또는 "타입 정제(type refinement)"라고 합니다. 이러한 타입들은 코드의 안정성을 높이는 동시에 명시적인 타입으로 코드의 가독성 또한 높아집니다. 그리고 타입스크립트가 강력한 타입으로 인해 코드에 필요한 인자들을 helper를 통해 알려주어 자동완성, 코드의 정확도와 생산성이 상승하게 됩니다. 코드 수정의 귀찮음을 덜어주고 어떤 인자를 넘겨 주어야 할지 찾아보는 귀찮음도 없애주죠.

 

갑옷도 좋지만 자동으로 입혀주는 갑옷은 더 좋다! 더 튼튼할 가능성이 높습니다. 출처: Microsoft copilot으로 제작

 

물론 자주 사용하지 않은 함수나 클래스에 대한 타입 강화는 들이는 시간에 비해 생산성 향상이 미미할 수도 있습니다. 앞에 제시한 사진처럼 갑옷을 입으면 든든하겠지만 입는 과정은 귀찮은 것처럼 말이죠. 그러나 자주 사용할 수록 더 큰 생산성 향상을 불러오기 때문에 개발 시작 전 혹은 개발 중 충분히 고려해 볼만하다고 생각합니다. 그리고 무엇보다 재미있습니다. 사실 저의 경우처럼 라이브러리가 이미 타입스크립트를 잘 지원하는 경우 굳이 만드는 것은 시간 낭비일 가능성이 높습니다. 이번의 경우는 잘 만들어졌으나 시간만 낭비하고 제대로 만들지 못해 결과는 미비할 수 있거든요. 아무리도 해당 라이브러리를 만든 제작자가 코드에 대한 이해가 더 높으니 말이죠. 하지만 타입스크립트가 어색하다면 이런 경험으로 타입스크립트에 대한 이해를 높이는 것이 좋은 공부 방법이라고 생각합니다.

 

 

실제로 더 강력한 타입을 사용하고 싶다면 해당 함수 혹은 클래스의 사용 빈도가 높거나 오류를 접할 가능성이 높아 강력한 타입 적용시 얻는 이득이 큰 지를 고려해보아야 합니다.

 

중요!: 단점

해당 방식으로 타입을 다시 정의했을 때, navigation을 사용하는 컴포넌트 혹은 스크린의 현재 위치를 정확히 알고 있어야 합니다. 물론 중첩된 네비게이션이 아닌 경우는 크게 상관없겠지만, 만약 중첩된 네비게이션에서 부모다 다른 스크린으로 이동을 한다면 navigae혹은 replace 등 화면을 이동하는 메소드의 첫 번째 인자로 이동할 스크린의 부모 스크린을 넣어야 합니다. 그리고 두 번째 인자로 { screen: "이동할_스크린_이름" }을 지정해주어야 하죠. 다행히 두 번째 인자는 강력한 타입이 정확하게 반영됩니다.

 

반면 같은 부모의 스크린으로 이동할 때는 바로 해당 스크린의 이름을 첫 번째 인자로 넣어주어도 작동 됩니다. 제가 지정한 타입을 사용한다면 두 경우를 구분하지 못하기에 타입스크립트가 오류를 올바르게 표시하지 못하는 문제가 있습니다.

+ Recent posts