[RN] Android SDK 35 업데이트 이후 키보드가 화면을 가리는 문제 해결 (Expo plugin 활용)

NARARIA03·2025년 7월 14일
0
post-thumbnail

개요


최근 Google Play Console에서 8월 31일까지 SDK를 35로 올려야 한다는 안내를 받았다.

// app.config.ts

export default ({ config }: ConfigContext): ExpoConfig => ({
  ...
  plugins: [
    ...
      "expo-build-properties",
      {
        android: {
          compileSdkVersion: 35,
          targetSdkVersion: 34,
          buildToolsVersion: "35.0.0",
          kotlinVersion: "1.9.25",
          extraMavenRepos: ["https://devrepo.kakao.com/nexus/content/groups/public/"],
        },
      },
    ],
});

app.config.ts를 살펴보니 targetSdkVersion은 34지만, 컴파일은 35를 기준으로 하고 있는 것을 확인했고, 문제가 생기지 않기를 기도하면서 targetSdkVersion을 35로 올렸다.

Android SDK를 35로 올린 뒤 테스트를 해보았는데, 위 사진처럼 키보드가 화면을 가리는 현상이 발생했다.. IOS도 확인해보았으나, 여기서는 문제가 없었다. 🫤

이 문제를 해결해보자.

문제 해결


원인 파악


문제 해결 전, 먼저 원인을 정확하게 파악해보자. 당시 크게 두 가지 경우를 고려했다.

  1. KeyboardAvoidingView가 Android SDK 35를 지원하지 않는다.
  2. Android SDK 35로 올라가면서 어떤 정책이 변경되었다.

조사해본 결과, 2번이 정답이었다. RN Android SDK 35 KeyboardAvoidingView 라는 키워드로 검색해보면 Github issue가 하나 나오는데, 여기서 논의가 오가고 있었다.

이슈의 원인은 Android SDK 35부터 앱이 기본적으로 edge-to-edge 레이아웃을 사용하도록 변경되면서 android:windowSoftInputMode="adjustResize" 만으로는 키보드가 차지하는 영역을 고려한 앱 UI 배치가 불가능해졌기 때문이었다.

edge-to-edge 레이아웃?

기존 안드로이드에서는 앱 UI가 상태바, 네비게이션 바, 키보드 등 시스템 요소를 제외한 영역에만 그려졌지만, edge-to-edge 레이아웃은 앱 UI가 전체 화면을 채우고 시스템 요소가 그 위에 오버레이되는 방식이다.

앱 UI 위에 시스템 요소가 오버레이 되다보니 공식 문서에서도 프로그래매틱하게 키보드 동작을 처리하라고 안내하고 있다.

사내 안드로이드 개발자분께서 SDK를 35로 올린 뒤, UI를 테스트하고 계셔서 왜 필요한 작업인지 궁금했는데.. 직접 경험하게 되었다! 😔

해결 방법은?


댓글와 같이 MainActivity.kt (또는 MainActivity.java)를 수정해주면 해결할 수 있다.

코틀린 코드를 GPT와 함께 분석해 보았는데, JS와 로직 흐름이 비슷해서 어렵지 않게 이해할 수 있었다.

  • 루트 뷰를 가져와서 WindowInsets 변경 이벤트 리스너를 등록한다.
  • 이벤트 핸들러에서는 키보드(ime) 영역의 inset 값을 받아 루트 뷰의 패딩에 적용해서 UI가 가려지지 않도록 수동 처리해준다.

코틀린 코드 수정? 🫤 Expo plugin! 😊


문제가 하나 남아있었다. expo-dev-client를 사용 중이긴 하지만, android/ios 디렉토리는 git으로 관리하고 있지 않았고, EAS build/update를 사용 중이기 때문에 MainActivity.kt를 수정하고 싶지 않았다.

고민하던 중, 한 가지 아이디어가 떠올랐다. 원래 React Native에서 스플래시 스크린을 커스텀하려면 네이티브 코드를 직접 수정해야 한다. 그러나 Expo에서는 플러그인을 통해 쉽게 작업할 수 있다.

플러그인으로 네이티브 코드 수정이 가능할 것 같았고, 결과적으로 Expo 공식 문서를 참고하며 플러그인을 구현해 해결할 수 있었다.

직접 플러그인을 구현하고 싶지 않으시다면, Java와 Kotlin을 모두 지원하는 아래 패키지를 설치해 사용하시는 걸 추천드립니다!

Expo Android Keyboard Fix

플러그인 요구 사항 정리


코틀린 코드를 수정하기 위한 요구 사항을 정리하면 다음과 같다.

  1. MainActivity.kt에 접근해서
  2. 추가로 필요한 import만 추가하고
  3. MainActivity 클래스의 다른 부분은 유지하면서 루트 뷰를 가져와 리스너를 등록하는 코드를 추가해야 한다.

초기 MainActivity.kt 구조

package com.example.blog
import expo.modules.splashscreen.SplashScreenManager

// 나머지 임포트문들...

class MainActivity : ReactActivity() {
  override fun onCreate(savedInstanceState: Bundle?) {
    // @generated begin expo-splashscreen - expo prebuild (DO NOT MODIFY)
    SplashScreenManager.registerOnActivity(this)
    // @generated end expo-splashscreen
    super.onCreate(null)
  }
  // 추가적인 메서드들... (여기서는 수정 X)
}

추가되어야 하는 import문

import android.os.Build
import android.view.View
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat

onCreate 함수에 추가해야하는 코드

if (Build.VERSION.SDK_INT >= 35) {
  val rootView = findViewById<View>(android.R.id.content)
  ViewCompat.setOnApplyWindowInsetsListener(rootView) { _, insets ->
    val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime())
    rootView.setPadding(
      imeInsets.left,
      imeInsets.top,
      imeInsets.right,
      imeInsets.bottom
    )
    insets
  }
}

플러그인 뼈대 구성


플러그인 뼈대 구성은 Expo 공식 문서를 보며 진행했다.

먼저 프로젝트 루트에 plugins 폴더를 만들고, android-keyboard-fix.ts와 같은 플러그인 코드를 생성해주자.

// plugins/android-keyboard-fix.ts

import { type ConfigPlugin } from "@expo/config-plugins";

const withAndroidKeyboardFix = ((config) => {
  ...
}) satisfies ConfigPlugin;

export default withAndroidKeyboardFix;

다음으로 Expo 공식 문서에 나와있는 대로 ts-nodedevDependency로 추가해주자.

pnpm add -D ts-node

그리고 app.config.ts에 위에서 만든 플러그인을 불러오자.
ts-node/register import 문도 함께 추가해주자! (ts 실행을 위해 필요)

// app.config.ts

import type { ConfigContext, ExpoConfig } from "expo/config";
import 'ts-node/register'; // 추가!

export default ({ config }: ConfigContext) =>
  ({
    ...config,
    name: "blog",
    slug: "blog",
    plugins: [
      ["./plugins/android-keyboard-fix.ts"], // 추가!
      "expo-router",
      [
        "expo-splash-screen",
        {
          image: "./assets/images/splash-icon.png",
          imageWidth: 200,
          resizeMode: "contain",
          backgroundColor: "#ffffff",
        },
      ],
    ],
  } satisfies ExpoConfig);

이제 뼈대 준비는 끝났다. 플러그인 구현을 진행해보자.

(참고) SDK 53 Cannot use import statement outside a module Error 해결


Expo SDK 53부터는 app.config.ts에서 ts-node 사용 시 아래 에러가 발생하며 prebuild에 실패한다.

SyntaxError: Cannot use import statement outside a module 
/...plugins/android-keyboard-fix.ts:1
import { withMainActivity } from "@expo/config-plugins";
^^^^^^

SyntaxError: Cannot use import statement outside a module

Github Issue에서 해결 방법을 찾았다. ts-node 대신 tsx 라는 라이브러리로 교체하면 해결할 수 있다고 한다. (TypeScript Execute의 약자라고 한다 😊)

pnpm add -D tsx

그리고 app.config.ts에서 ts-node/register 대신 tsx/cjs를 임포트하자.

// app.config.ts

import type { ConfigContext, ExpoConfig } from "expo/config";
import "tsx/cjs"; // 추가!

export default ({ config }: ConfigContext) => ({ 
  ... 
} satisfies ExpoConfig);

나는 기존 프로젝트(Expo SDK 52)에서 먼저 구현한 뒤 블로그 포스팅을 위해 새 프로젝트를 SDK 53으로 만들었는데, 되던게 안 되서 많이 당황했다. 😭

아직 공식 문서에 반영되지 않은 내용이라 헷갈리기 쉬운 것 같다..!

import문 주입 구현


플러그인에서 MainActivity.kt 코드를 가져와 import문 4개를 추가해야 한다.

MainActivity를 가져오는 방법은 아래와 같다. string으로 코드를 가져올 수 있다!

// plugins/android-keyboard-fix.ts

import { type ConfigPlugin, withMainActivity } from "@expo/config-plugins";

const withAndroidKeyboardFix = ((config) => {
  return withMainActivity(config, (config) => {
    let mainActivity: string = config.modResults.contents;
    ...
  });
}) satisfies ConfigPlugin;

export default withAndroidKeyboardFix;

코드 문자열에서 import문만 필터링한 뒤, 추가해야 하는 import문이 포함되어 있지 않다면 추가하는 식으로 구현하면 된다.

// plugins/android-keyboard-fix.ts

import { type ConfigPlugin, withMainActivity } from "@expo/config-plugins";

const addImports = [
  "import android.os.Build",
  "import android.view.View",
  "import androidx.core.view.ViewCompat",
  "import androidx.core.view.WindowInsetsCompat",
];

const withAndroidKeyboardFix = ((config) => {
  return withMainActivity(config, (config) => {
    let mainActivity: string = config.modResults.contents;

    // 기존 import문을 추출해 Set으로 저장
    const prevImports = new Set(
      mainActivity
        .split("\n")
        .map((line) => line.trim())
        .filter((trimmedLine) => trimmedLine.startsWith("import"))
    );

    // 추가해야 할 import중 기존에 없는 항목만 필터링
    const missingImports = addImports.filter((line) => !prevImports.has(line));

    // 삽입할 import문을 줄바꿈으로 연결
    const missingImportsText = missingImports.join("\n");

    // 정규식을 활용해 누락 import문 주입
    mainActivity = mainActivity.replace(/^(package .+)$/m, `$1\n${missingImportsText}`);
  });
}) satisfies ConfigPlugin;

export default withAndroidKeyboardFix;

정규식이 다소 복잡한데, 한 번 정리해보자.

  • 기존 코드에서 package로 시작^하는 줄/m을 찾는다.
    • 가장 위에 package가 선언되므로 안전한 자리라고 볼 수 있다.
  • 찾은 줄을 캡처 그룹()으로 설정한다.
  • 첫 번째 캡처 그룹$1 위치에서 한 줄 띄고\n, 위에서 계산한 누락 import 문자열을 주입한다.

onCreate 내부 코드 주입 구현


import문을 주입한 방법과 유사하게 구현해볼 예정이다.
이번에는 onCreate 함수 내부의 super.onCreate() 호출부 아래에 코드를 주입해보자.

super.onCreate()는 Android Activity 생명주기 내에서 딱 한 번만 실행되는 함수로, MainActivity.kt 내에서 여러 번 호출될 가능성이 없으므로 타겟으로 지정했다. Android docs

import { type ConfigPlugin, withMainActivity } from "@expo/config-plugins";

const addImports = [ ... ];

const addCode = `
    if (Build.VERSION.SDK_INT >= 35) {
      val rootView = findViewById<View>(android.R.id.content)
      ViewCompat.setOnApplyWindowInsetsListener(rootView) { _, insets ->
        val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime())
        rootView.setPadding(
          imeInsets.left,
          imeInsets.top,
          imeInsets.right,
          imeInsets.bottom
        )
        insets
      }
    }`;

const withAndroidKeyboardFix: ConfigPlugin = (config) => {
  return withMainActivity(config, (config) => {
    let mainActivity: string = config.modResults.contents;
    ...
    // 정규식을 활용해 supre.onCreate(...) 아래에 코드 주입
    mainActivity = mainActivity.replace(/(super\.onCreate\(.*\))/, `$1\n${addCode}`);

    // 기존 config의 MainActivity를 덮어씀
    config.modResults.contents = mainActivity;

    // 수정된 config 반환
    return config;
  });
};

export default withAndroidKeyboardFix;

이번에도 정규식에 대해서 정리해보자.

  • super\.onCreate\(: "super.onCreate(" 문자열 전체가 일치하는 부분을 찾음
  • .*: 아무거나 0개 이상 들어와도 괜찮음
  • \): ")" 문자
  • (...): 위에서 찾은 super.onCreate(..) 전체를 캡처 그룹으로 설정
  • $1\n${addCode}: 1번째 캡처 그룹에서 한 줄 띄고 addCode 문자열 주입

prebuild 테스트


이제 이 플러그인이 정상적으로 MainActivity.kt를 수정해내는지 확인해보자.

pnpm dlx expo prebuild --clean
package com.example.blog
import expo.modules.splashscreen.SplashScreenManager
import android.view.View // 추가
import androidx.core.view.ViewCompat // 추가
import androidx.core.view.WindowInsetsCompat // 추가

import android.os.Build // 기존에 이미 존재
import android.os.Bundle

// ... 나머지 import 문들

class MainActivity : ReactActivity() {
  override fun onCreate(savedInstanceState: Bundle?) {
    // @generated begin expo-splashscreen - expo prebuild (DO NOT MODIFY) sync-f3ff59a738c56c9a6119210cb55f0b613eb8b6af
    SplashScreenManager.registerOnActivity(this)
    // @generated end expo-splashscreen
    super.onCreate(null)

	// 추가
    if (Build.VERSION.SDK_INT >= 35) {
      val rootView = findViewById<View>(android.R.id.content)
      ViewCompat.setOnApplyWindowInsetsListener(rootView) { _, insets ->
        val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime())
        rootView.setPadding(
          imeInsets.left,
          imeInsets.top,
          imeInsets.right,
          imeInsets.bottom
        )
        insets
      }
    }
  }
  // ... 나머지 코드들
}

MainActivity.kt에 추가되어야 하는 코드만 적당한 위치에 주입되는 것을 확인할 수 있다!

build 테스트


이제 Android, IOS 각각 빌드해 커스텀 플러그인이 정상적으로 적용되는지 확인해보자.

플러그인 적용 전

IOS에서는 문제가 없지만, Android에서는 키보드가 화면을 가리는 것을 확인할 수 있다.

플러그인 적용 후

플러그인을 적용하면 Android에서도 키보드가 화면을 가리지 않는 것을 확인할 수 있다! 😊

정리


Android SDK 35에서 edge-to-edge가 도입되며 발생한 이슈를 대응했던 방법에 대해 정리해보았다.

네이티브 코드를 수정해야 함과 동시에 EAS build/update를 계속 사용할 수 있어야 했고, Expo plugin을 통해 깔끔하게 해결할 수 있었다.

Expo의 강점 중 하나로 javascript 레벨에서 네이티브 코드를 수정할 수 있는 점을 뽑고 싶다.

글 읽어주셔서 감사합니다.

Expo SDK 53/Android SDK 35로 업데이트한 뒤 안드로이드에서만 키보드가 화면을 가리는 문제가 발생하신다면, 이 포스트가 도움이 되었기를 바랍니다.

이 글에 대한 가독성, 오탈자/오개념, 코드 오타 등 다양한 지적을 환영합니다!

profile
신입 프론트엔드 개발자입니다. React와 RN 생태계를 좋아합니다.

0개의 댓글