expo에서 키보드가 두 번 올라온 이유
setTimeout 하나로 시작해서 중첩 Stack 네이티브 타이밍까지 파고든 과정화면 진입시 키보드가 두 번 올라오는 버그
이번에 아이두에 메모 기능을 추가하게 되었다. 기능 추가와 함께 메모 작성 페이지에 진입하면 바로 작성하는 ux를 제공하기 위해 autoFocus를 적용했다.
그런데 화면 진입 시 키보드가 두 번 올라오는 버그가 생겼다 🥹

문제 해결을 위한 첫 번째 시도: setTimeout
페이지 진입 시 fade_from_bottom 애니메이션(200ms)이 실행된다. 키보드는 이 애니메이션이 끝난 뒤에 올라와야 하기 때문에 이 타이밍을 맞추는 것이 중요했다.
그래서 직접 타이밍을 제어하기 위해 autoFocus 대신 setTimeout을 사용했다.
해당 코드로 키보드가 1번 올라오게 잘 동작했지만, 생각하지 못한 허점이 있었다.
- 저사양 기기에서의 프레임 드랍
animationDuration: 200ms로 설정했으니까 보통은 300ms면 애니메이션이 끝나 있지만, 저사양 기기에서 프레임 드랍이 나면 200ms로 설정해도 실제 렌더링이 300ms 넘게 걸릴 수 있다. 그렇게 되면 애니메이션이 끝나기 전에 focus()가 호출되어 키보드가 다시 두 번 올라온다.
- 매직 넘버
300이라는 숫자는 나중에 코드를 다시 볼 때 "왜 하필 300이지?"라는 의문을 남긴다.
두 번째 시도: transitionEnd
팀원이 transitionEnd 이벤트를 사용하는 아이디어를 주어 이 방법으로 수정을 했다. navigation.addListener('transitionEnd', callback)은 화면 전환 애니메이션이 끝난 시점에 콜백을 실행한다.
if (!e.data.closing) 조건으로 push로 진입할 때만 focus()를 호출하고, 뒤로가기 시에는 무시한다.
전환 흐름은 다음과 같다.
- 화면 push/pop 시작
fade_from_bottom애니메이션 실행- 애니메이션 완료
transitionEnd이벤트 발화 →focus()호출
첫 진입에서는 키보드가 한 번만 올라왔다. 그런데 두 번째 진입부터 다시 두 번 올라오는 버그가 생겼다.
문제의 원인: 중첩 Stack
코드 자체의 문제가 아니었다. 중첩 Stack 구조에서 useNavigation()이 반환하는 네비게이터의 범위가 문제였다.
파일구조는 위와 같이 두 Stack으로 구성되어 있다. create.tsx에서 useNavigation()을 호출하면 가장 가까운 네비게이터인 Stack B의 navigation이 반환된다. 하지만 실제로 fade_from_bottom 애니메이션을 실행하는 것은 Stack A이다.
Stack A와 Stack B의 transitionEnd 발화 시점을 로그로 확인했다.

첫 진입에서는 Stack A → Stack B 순서로 발화되지만, 재진입부터는 Stack B → Stack A 순서로 바뀐다.
발화 순서가 보장되지 않는 이유는 expo-router가 내부적으로 @react-navigation/native-stack을 사용하기 때문이다. Stack Navigator의 애니메이션은 JS 스레드가 아닌 네이티브(iOS UIKit / Android Fragment)에서 처리된다. 중첩 Stack 구조에서 각 Stack은 독립적인 네이티브 컨트롤러를 갖기 때문에, transitionEnd 발화 순서가 보장되지 않는다.
Stack B의 transitionEnd에서 focus()를 호출하면, 재진입 시 Stack A의 애니메이션이 아직 끝나지 않은 시점에 키보드가 올라온다. 그래서 결국 키보드가 2번 올라오는 현상이 발생한 것이다.
세 번째 시도: Stack B에서 애니메이션 처리
Stack B에서 애니메이션을 처리하고, Stack A에서는 애니메이션 없이 전환하는 방식을 시도했다.
그런데 사실 원래부터 fade_from_bottom은 Stack A의 애니메이션이었다. Stack B의 create.tsx는 push로 진입하는 화면이 아니라, Stack B가 마운트될 때 초기 화면으로 렌더링된다. (*react-native-screens는 이 경우를 push 전환으로 인식하지 않아 처음부터 애니메이션을 스킵하고 있었다.
- iOS:
firstTimePush이면animated: NO - Android:
topScreenWrapper가 null이면stackAnimation = NONE
Stack B는 어차피 스킵 상태이므로, Stack A의 애니메이션까지 없애면 결국 애니메이션이 아예 사라진다.
InteractionManager.runAfterInteractions도 시도해봤다. InteractionManager는 JS 측 인터랙션만 추적하는데, Stack의 네이티브 애니메이션은 등록되지 않아 타이밍이 맞지 않았다.
마지막 해결: getParent()로 Stack A의 transitionEnd 구독
Stack A의 애니메이션이 끝난 시점에 키보드를 올리면 된다. navigation.getParent()로 Stack A의 navigation을 가져왔다.
키보드가 두 번 올라오는 문제는 해결됐다. 그런데 타입 에러가 발생했다.
타입 에러 해결
transitionEnd는 @react-navigation/native-stack의 NativeStackNavigationEventMap에 정의된 이벤트다.
expo-router의 useNavigation()은 Stack, Tab, Drawer 등 모든 네비게이터에서 사용할 수 있어야 하므로, 특정 네비게이터의 EventMap을 포함하지 않는다.
EventMap이 비어 있어 TypeScript는 addListener('transitionEnd')를 인식하지 못하고 에러를 낸다.
getParent<T>()의 제네릭에 필요한 shape만 직접 명시해 해결했다.
NativeStackNavigationEventMap을 import하거나 as any, @ts-expect-error를 사용하지 않고, react-navigation이 의도한 제네릭 방식으로 타입을 좁혔다.
(+ NativeStackNavigationEventMap을 직접 import하는 방법도 있지만, @react-navigation/native-stack은 expo-router의 간접 의존성이라 버전 관리가 되지 않아 권장하지 않는다고 한다.)
마치며
물론, 메모 생성에서 fade_from_bottom 애니메이션이 엄청나게 중요한 부분이 아니였어서 이를 없애면 빨리 해결할 수 있는 버그긴했다. 그렇지만 기획 & 디자인이 이렇게 해달라고 요청이 왔을 때를 가정해서 꼭 이렇게 구현해야한다고 생각하고 문제를 해결하는 중이였어서 최대한 애니메이션이 살아있는 상태에서 해결을 하고 싶었다.
알맞은 해결방법인 지는 확신할 수 없지만 해결하면서 많은 공부가 된 것 같다!