expo에서 키보드가 두 번 올라온 이유

setTimeout 하나로 시작해서 중첩 Stack 네이티브 타이밍까지 파고든 과정
2026년 4월 16일

화면 진입시 키보드가 두 번 올라오는 버그

이번에 아이두에 메모 기능을 추가하게 되었다. 기능 추가와 함께 메모 작성 페이지에 진입하면 바로 작성하는 ux를 제공하기 위해 autoFocus를 적용했다.

그런데 화면 진입 시 키보드가 두 번 올라오는 버그가 생겼다 🥹

오류 영상

문제 해결을 위한 첫 번째 시도: setTimeout

페이지 진입 시 fade_from_bottom 애니메이션(200ms)이 실행된다. 키보드는 이 애니메이션이 끝난 뒤에 올라와야 하기 때문에 이 타이밍을 맞추는 것이 중요했다.

그래서 직접 타이밍을 제어하기 위해 autoFocus 대신 setTimeout을 사용했다.

해당 코드로 키보드가 1번 올라오게 잘 동작했지만, 생각하지 못한 허점이 있었다.

  1. 저사양 기기에서의 프레임 드랍

animationDuration: 200ms로 설정했으니까 보통은 300ms면 애니메이션이 끝나 있지만, 저사양 기기에서 프레임 드랍이 나면 200ms로 설정해도 실제 렌더링이 300ms 넘게 걸릴 수 있다. 그렇게 되면 애니메이션이 끝나기 전에 focus()가 호출되어 키보드가 다시 두 번 올라온다.

  1. 매직 넘버

300이라는 숫자는 나중에 코드를 다시 볼 때 "왜 하필 300이지?"라는 의문을 남긴다.


두 번째 시도: transitionEnd

팀원이 transitionEnd 이벤트를 사용하는 아이디어를 주어 이 방법으로 수정을 했다. navigation.addListener('transitionEnd', callback)은 화면 전환 애니메이션이 끝난 시점에 콜백을 실행한다.

if (!e.data.closing) 조건으로 push로 진입할 때만 focus()를 호출하고, 뒤로가기 시에는 무시한다.

전환 흐름은 다음과 같다.

  1. 화면 push/pop 시작
  2. fade_from_bottom 애니메이션 실행
  3. 애니메이션 완료
  4. transitionEnd 이벤트 발화 → focus() 호출

첫 진입에서는 키보드가 한 번만 올라왔다. 그런데 두 번째 진입부터 다시 두 번 올라오는 버그가 생겼다.


문제의 원인: 중첩 Stack

코드 자체의 문제가 아니었다. 중첩 Stack 구조에서 useNavigation()이 반환하는 네비게이터의 범위가 문제였다.

파일구조는 위와 같이 두 Stack으로 구성되어 있다. create.tsx에서 useNavigation()을 호출하면 가장 가까운 네비게이터인 Stack B의 navigation이 반환된다. 하지만 실제로 fade_from_bottom 애니메이션을 실행하는 것은 Stack A이다.

Stack A와 Stack B의 transitionEnd 발화 시점을 로그로 확인했다.

transitionEnd 발화 순서 로그
첫 진입에서는 Stack A → Stack B 순서지만, 재진입부터는 Stack B → Stack A 순서로 바뀐다

첫 진입에서는 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-stackNativeStackNavigationEventMap에 정의된 이벤트다.

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 애니메이션이 엄청나게 중요한 부분이 아니였어서 이를 없애면 빨리 해결할 수 있는 버그긴했다. 그렇지만 기획 & 디자인이 이렇게 해달라고 요청이 왔을 때를 가정해서 꼭 이렇게 구현해야한다고 생각하고 문제를 해결하는 중이였어서 최대한 애니메이션이 살아있는 상태에서 해결을 하고 싶었다.

알맞은 해결방법인 지는 확신할 수 없지만 해결하면서 많은 공부가 된 것 같다!