최근 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도 확인해보았으나, 여기서는 문제가 없었다. 🫤
이 문제를 해결해보자.
문제 해결 전, 먼저 원인을 정확하게 파악해보자. 당시 크게 두 가지 경우를 고려했다.
조사해본 결과, 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
변경 이벤트 리스너를 등록한다.문제가 하나 남아있었다. expo-dev-client
를 사용 중이긴 하지만, android/ios 디렉토리는 git으로 관리하고 있지 않았고, EAS build/update를 사용 중이기 때문에 MainActivity.kt
를 수정하고 싶지 않았다.
고민하던 중, 한 가지 아이디어가 떠올랐다. 원래 React Native에서 스플래시 스크린을 커스텀하려면 네이티브 코드를 직접 수정해야 한다. 그러나 Expo에서는 플러그인을 통해 쉽게 작업할 수 있다.
플러그인으로 네이티브 코드 수정이 가능할 것 같았고, 결과적으로 Expo 공식 문서를 참고하며 플러그인을 구현해 해결할 수 있었다.
직접 플러그인을 구현하고 싶지 않으시다면, Java와 Kotlin을 모두 지원하는 아래 패키지를 설치해 사용하시는 걸 추천드립니다!
코틀린 코드를 수정하기 위한 요구 사항을 정리하면 다음과 같다.
MainActivity.kt
에 접근해서import
만 추가하고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-node
를 devDependency
로 추가해주자.
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);
이제 뼈대 준비는 끝났다. 플러그인 구현을 진행해보자.
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으로 만들었는데, 되던게 안 되서 많이 당황했다. 😭
아직 공식 문서에 반영되지 않은 내용이라 헷갈리기 쉬운 것 같다..!
플러그인에서 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;
정규식이 다소 복잡한데, 한 번 정리해보자.
^
하는 줄/m
을 찾는다. ()
으로 설정한다.$1
위치에서 한 줄 띄고\n
, 위에서 계산한 누락 import 문자열을 주입한다.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 문자열 주입이제 이 플러그인이 정상적으로 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
에 추가되어야 하는 코드만 적당한 위치에 주입되는 것을 확인할 수 있다!
이제 Android, IOS 각각 빌드해 커스텀 플러그인이 정상적으로 적용되는지 확인해보자.
플러그인 적용 전
IOS에서는 문제가 없지만, Android에서는 키보드가 화면을 가리는 것을 확인할 수 있다.
플러그인 적용 후
플러그인을 적용하면 Android에서도 키보드가 화면을 가리지 않는 것을 확인할 수 있다! 😊
Android SDK 35에서 edge-to-edge가 도입되며 발생한 이슈를 대응했던 방법에 대해 정리해보았다.
네이티브 코드를 수정해야 함과 동시에 EAS build/update를 계속 사용할 수 있어야 했고, Expo plugin을 통해 깔끔하게 해결할 수 있었다.
Expo의 강점 중 하나로 javascript 레벨에서 네이티브 코드를 수정할 수 있는 점을 뽑고 싶다.
글 읽어주셔서 감사합니다.
Expo SDK 53/Android SDK 35로 업데이트한 뒤 안드로이드에서만 키보드가 화면을 가리는 문제가 발생하신다면, 이 포스트가 도움이 되었기를 바랍니다.
이 글에 대한 가독성, 오탈자/오개념, 코드 오타 등 다양한 지적을 환영합니다!