나는 새로운 앱을 만들 때 가장 먼저 네비게이션 구조부터 설계하는 편이다. 네비게이션은 가장 기본적인 Stack Navigation부터 Bottom Tabs Navigation, Drawer 등 여러가지 종류가 있는데, 구현하고자 하는 앱의 형태에 따라 여러개의 navigation을 중첩하여 구조를 설계해야한다. 결국 우리가 만드는 앱의 모든 스크린들은 이렇게 잡힌 네비게이션 구조 위에 그려지기 때문에, 앱 개발이 완료된 이후 네비게이션 구조를 크게 변경하려고 보면 네비게이팅이 안된다거나, 헤더가 이상해진다거나, 애니메이션이 이상하다든가 등 여러가지 사이드 이펙트가 발생할 수 있다.
나의 케이스를 떠올려보면 네비게이션을 셋팅하면서 정말 많은 문제를 만났고, RN을 다루면서 만나는 이슈의 1/3이상은 네비게이션 관련 이슈가 아닐까 하는 생각까지 했었다. 네비게이션은 아주 기본적인 파트인 동시에 상당히 까다로운 지점도 많이 있기 때문에 RN이 익숙하지 않은 분들에게 이글이 많은 도움이 되길 바라며 작성했다. 이 글에서는 다짐을 RN으로 새로 구축하면서 고민했던 부분과, 실제로 어떻게 구현했는지 등을 주제로 최대한 다양하면서도 구체적으로 적어보려고 한다. 다만 어디까지나 이 글의 목적은 중첩 네비게이션 구조 설계이므로 세부적인 스크린 옵션과 사용방법을 다 설명하지는 않을 것이며, 자세한 사용방법은 React Navigation 공식문서를 참조해주길 바란다.
root-stack
- main-tab
- shared-stack
- ...tabs screens
- sign-stack
- ...sign screens
- cart-stack
- ...cart screens
- search-stack
- ...search screens
- chat-stack
- ...chat screens
- map-stack
- ...map screens
- modal-presentation-stack
- ...moodal presentation screens
- ...common screens
다짐의 네비게이션은 대략 이런 구조로 짜여져 있다. 왜 네비게이션의 구조가 이런 형태를 띠게 되었는지, 어떤 기준이 있었고, 어떤 문제가 있었고, 어떤 고민이 있었는지를 하나씩 짚어보려고 한다.
로그인이 필요한 서비스들은 크게 '로그인을 해야만 앱내 컨텐츠에 접근할 수 있는 서비스'와 '로그인을 하지 않아도 대부분의 컨텐츠에 접근할 수 있는 서비스'로 나눌 수 있다. 전자에 해당하는 서비스는 대표적으로 카카오톡, 토스 등이 있다. 이런 서비스들은 사용자 개인에 특화된 서비스를 제공한다는 공통점이 있다. 반면 유튜브 지그재그 등의 서비스는 후자에 해당한다. 이들은 불특정 다수를 겨냥한 콘텐츠를 주력하고 삼고 있으며, 최대한 접근 허들을 낮춰 사용자를 후킹하는 것에 초점을 맞추기 때문에 구매 등 필수적인 상황에서만 로그인을 강제한다. 운동시설 플랫폼 다짐 또한 로그인 없이 앱 내 대부분의 콘텐츠를 즐길 수 있는 형태의 서비스다.
뜬금없이 로그인을 기준으로 서비스를 나눈 이유는 이 기준이 네비게이션 셋팅과 매우 밀접하기 때문이다. 만약 로그인을 해야만 앱내 콘텐츠에 접근이 가능하다면, 앱 코드 최상단에서 로그인 여부로 네비게이션을 조건부 렌더링 해주어야하기 때문이다.
const App = () =>{
const isLoggedIn = useLoggedIn() // 로그인 여부를 리턴해주는 커스텀 훅. 주로 전역상태의 유저정보 유무로 로그인을 판단하게 된다.
return <NavigationContainer>
{isLoggedIn ? <LoggedInStack/> : <LoggedOutStack/>}
</NavigationContainer>
}
여기서 LoggedInStack은 로그인했을 때 렌더링 되는 navigation stack, 즉 서비스의 메인 컨텐츠 스크린들을 담고 있으며, LoggedOutStack은 로그인, 회원가입, 아이디/비밀번호 찾기 등 계정 관련 스크린들을 포함하고 있다.
하지만 다짐의 경우 렌더링 되는 스크린을 로그인 여부로 판단하지 않기 때문에 단일 네비게이션만 존재한다.
const App = () =>{
return <NavigationContainer>
<RootStackNavigator/>
</NavigationContainer>
}
이번에는 Bottom Tabs가 있는 스크린인지 확인해봐야 한다. 여기서 바텀 탭이란, 손쉽게 앱내 주요 스크린간 전환할 수 있도록 앱 하단에 위치하고 있는 탭을 의미한다. 다짐을 예시로 들자면 앱 하단에 홈, 운동시설, 트레이너, 마이다짐 이렇게 4개의 탭이 위치하고 있다.
Bottom Tabs를 구현하기 위해서는 Bottom Tabs Navigation을 사용해야 한다. 기본적인 사용법은 다음과 같다.
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
const Tab = createBottomTabNavigator();
function MainTabNavigator() {
return (
<Tab.Navigator>
<Tab.Screen name="Home" component={HomeScreen} />
<Tab.Screen name="Gyms" component={GymsScreen} />
<Tab.Screen name="Trainers" component={TrainersScreen} />
<Tab.Screen name="MyDagym" component={MyDagymScreen} />
</Tab.Navigator>
);
}
그리고 이렇게 만들어진 BottomTabs는 그 자체로 하나의 Screen이 될 수 있기 때문에 RootStackNavigator 안쪽에 위치시켜주면 된다.
import {createNativeStackNavigator} from '@react-navigation/native-stack';
const Stack = createNativeStackNavigator<RootStackParamList>();
const RootStackNavigator = () =>{
return <Stack.Navigator>
<Stack.Screen
name="MainTabNavigator"
component={MainTabNavigator}
// stack navigator를 중첩할때마다 헤더가 하나씩 늘어나기 때문에 header hide처리
// (추후 bottomTab 하위에 shared stack이 추가되는 케이스 고려)
options={{headerShown: false}}
/>
</Stack.Navigator>
}
이렇게 Stack Navigator 안쪽에 Bottom Tabs Navigator가 위치하고 있는 기본적인 형태의 중첩 네비게이션(Nested Navigation)이 만들어졌다.
만약 Bottom Tabs에 정의된 Tab Screens(홈, 운동시설, 트레이너, 마이다짐) 이외의 다른 스크린에서 Bottom Tabs가 보여야 하는 케이스가 있다면, 아주 약간의 트릭이 필요하다. 다짐에서는 대표적으로 '설정'스크린이 그런 케이스에 해당했는데, 마이다짐의 설정 메뉴를 눌러 접근할 수 있는 '설정' 스크린은 하단 바텀 탭에 정의되어 있지는 않지만 바텀 탭이 사라지지 않고 그대로 노출되어야했다.
이를 구현하기 위해서 Bottom Tabs Navigation과 Tab Screen사이에 SharedStackNavigator라는 추가적인 Layer를 추가해줬다. SharedStack은 기본적으로 Stack Navigator이며, screenName을 prop으로 받아 메인 스크린과 그 외 공통 스크린(shared screen)을 렌더링해주는 역할을 한다.
export default function SharedStack({screenName}: {screenName: 'Home' | 'Gyms' | 'Trainers' | 'MyDagym'}) {
return (
<Stack.Navigator>
{screenName === 'Home' ? (
<Stack.Screen
name={'Home'}
component={HomeScreen}
/>
) : null}
{screenName === 'Gyms' ? (
<Stack.Screen
name={'Gyms'}
component={GymsScreen}
/>
) : null}
{screenName === 'Trainers' ? (
<Stack.Screen
name={'Trainers'}
component={TrainersScreen}
/>
) : null}
{screenName === 'MyDagym' ? (
<Stack.Screen
name={'MyDagym'}
component={MyDagymScreen}
/>
) : null}
<Stack.Screen name={'Settings'} component={SettingsScreen} />
</Stack.Navigator>
만약 Shared stack에 screenName prop으로 'Home'을 넘겨주면, 결과적으로 Shared Stack은 HomeScreen과 SettingsScreen 두 화면을 갖는 Stack Navigator로써 기능한다, 즉, Bottom Tab의 스크린 아이콘을 누르면, 해당 스크린으로 전환되는 것이 아니라, 또 하나의 Stack Navigator로 전환되는 것이기 때문에, Shared Stack 안에서 얼마든지 스크린간 네비게이팅이 가능해지는 것이다. 이렇게 SettingsScreen을 SharedStack 내부에 배치해주면 Bottom Tab에 노출되지는 않지만 구조상 Bottom Tabs Navigation 내부에 위치하고 있으므로 설정 화면에서 Bottom Tab이 노출될 수 있게 된다.
SharedStack을 BottomTabs 내부에 배치할 때는 다음과 같이 Tab.Screen의 children으로 넘겨서 배치해주면 된다.
const BottomTabs = () => {
return (
<Tab.Navigator>
<Tab.Screen name="HomeTab">
{()=> <SharedStack screenName={"Home"}/>}
</Tab.Screen>
<Tab.Screen name="GymsTab">
{()=> <SharedStack screenName={"Gyms"}/>}
</Tab.Screen>
<Tab.Screen name="TrainersTab">
{()=> <SharedStack screenName={"Trainers"}/>}
</Tab.Screen>
<Tab.Screen name="MyDagymTab">
{()=> <SharedStack screenName={"MyDagym"}/>}
</Tab.Screen>
</Tab.Navigator>
);
}
참고로 여기서 Tab.Screen의 name을 "Home"이 아니라 굳이 "HomeTab"이라고 네이밍 한것은 Shared Stack 안쪽에 위치하고 있는 실제 Home Screen의 name과 구분하기 위해서다.
여기서 말하는 modal 스크린이란 마치 모달처럼 기존 스크린을 덮듯이 하단에서 올라오는 형태의 스크린을 의미한다. navigator나 screen의 presentation option을 modal 또는 fullScreenModal로 설정하여 구현할 수 있다.
screen에서 option으로 제공해주는데 굳이 고려할 필요가 있냐고 묻는다면, ios에서는 modal screen을 말그대로 modal로 취급하고 있기 때문이다. modal은 무조건적으로 현재 스크린 레이어의 상단에 배치되며, 한 번에 한 개의 modal밖에 띄울 수 없다는 특징이 있다. 즉, android에서는 아무런 문제가 없지만, ios 환경에서는 의도하지 않은 버그가 발생할 수 있다.
이를테면, modal은 반드시 한개밖에 띄울 수 없기 때문에, React Native의 Modal component와 presentation modal로 설정한 스크린은 동시에 띄울 수 없다. 만약 모달을 최상단에 배치해두고 동적으로 컨텐츠를 바꿔가면서 띄우려고 설정했다면, modal screen에서는 모달이 보이지 않고 씹힐 것이다. modal screen에서 Modal을 띄우려면 modal screen 안쪽에 Modal component를 배치해주어 modal이 보여지는 레이어를 끌어올려줘야 한다. 뿐만아니라 modal의 버튼을 눌러 modal screen으로 navigate하는 상황을 상정해봤을 때, modal이 사라지는 애니메이션과 스크린이 올라오는 시점이 아주 조금이라도 겹치면 네비게이팅이 씹히게 된다. 나는 이 문제를 RN Modal component를 사용하지 않고 직접 Custom Modal을 만들어 해결했는데, 자세한 내용은 이후 모달 리펙토링 글에서 다루도록 하겠다.
위 케이스는 modal 관련 기능 구현과 관련된 이슈인 반면, 중첩 네비게이션 구조를 설계할 때 영향을 주는 이슈도 존재한다. 바로 modal screen을 띄운 상태에서 stack navigator의 기본 동작인 우측에서 화면이 넘어오는 card animation으로 화면을 이동했을 때 modal screen이 잔류하는 이슈다.
이 버그는 그 자체로도 거슬리지만, 로그인 스크린, 검색 스크린 등 하단에서 올라온 뒤 사용 플로우에 따라 card animation이 적용되어야 하는 스크린 구현에 큰 허들로 작용했다. 맨 처음 다짐의 navigator 구조도에서 Search Stack, Map Stack, Sign Stack 등 Stack Navigator들이 Root Stack 하위에 위치하고 있었던 이유가 바로 그때문이다. 이렇게 단일 screen이 아닌 stack navigator로 할당해 해당 레이어에서 화면전환을 주면 card animation을 적용할 수 있다.