본문 바로가기
안드로이드 자바

[Java][Android] 화면 회전 onSaveInstanceState()로 데이터 지키기

by teamnova 2025. 5. 19.
728x90

안녕하세요, 안드로이드 앱을 만들다 보면 사용자가 화면을 가로로 돌리거나 세로로 돌릴 때, 입력했던 데이터나 현재 상태가 사라지는 경험을 해보신 적 있으신가요? 

 

오늘은 이 문제를 해결하는 가장 기본적인 방법 중 하나인 onSaveInstanceState()와 onRestoreInstanceState()에 대해 알아보겠습니다.

 

왜 사라질까요?

안드로이드에서 화면 방향이 변경되면, 현재 액티비티는 파괴(Destroy)된 후 다시 생성(Create)됩니다.

이 과정에서 액티비티 내에 저장되어 있던 멤버 변수들의 값은 초기화되어 버립니다.

 

대처하기

안드로이드는 이런 상황을 대비해 임시 데이터를 저장하고 복원할 수 있는 메커니즘을 제공합니다. 바로 onSaveInstanceState()와 onRestoreInstanceState() (또는 onCreate()의 Bundle 파라미터) 입니다.

 

1. 언제 호출되나요?
액티비티가 시스템에 의해 파괴될 가능성이 있을 때 호출됩니다. 예를 들어, 화면 회전 시, 메모리가 부족하여 백그라운드 액티비티가 종료될 때 등입니다. onPause() 또는 onStop() 이후, onDestroy() 이전에 호출됩니다.


2. 무엇을 하나요?
Bundle 객체 (outState)에 현재 액티비티의 상태를 key-value 형태로 저장합니다. 이 Bundle 객체는 나중에 액티비티가 재생성될 때 onCreate()나 onRestoreInstanceState()로 전달됩니다.

 

onSaveInstanceState() 왜 사용할까요?

예상치 못한 액티비티 재생성으로부터 사용자 경험 보호입니다.

1. 화면 회전 (Configuration Change): 가장 흔한 경우입니다. 사용자가 스마트폰을 가로 또는 세로로 돌릴 때, 기본적으로 액티비티는 파괴되고 다시 생성됩니다. 이때 사용자가 입력한 데이터나 현재 진행 상태가 초기화되면 매우 불편하겠죠. onSaveInstanceState()는 이러한 상황에서 데이터를 임시로 저장하여 부드러운 사용자 경험을 제공합니다.


2. 시스템에 의한 액티비티 종료 (Low Memory): 안드로이드 시스템은 메모리가 부족하다고 판단되면, 현재 사용 중이지 않은 백그라운드 액티비티를 강제로 종료시킬 수 있습니다. 사용자가 나중에 해당 앱으로 돌아왔을 때, 액티비티는 처음부터 다시 시작될 수 있습니다. 이때 onSaveInstanceState()에 저장된 데이터가 있다면, 사용자는 마치 아무 일 없었다는 듯이 이전 작업을 이어갈 수 있습니다.


3. 언어 변경 등 기타 구성 변경: 화면 회전 외에도 시스템 언어나 글꼴 크기 변경 등 다른 구성 변경(Configuration Change)이 발생했을 때도 액티비티가 재시작될 수 있습니다. 이때도 마찬가지로 데이터 유지가 중요합니다.

사용의 장점
1. 간단하고 직관적인 구현:
Bundle 객체에 key-value 형태로 데이터를 저장하고 복원하는 방식은 이해하고 구현하기 비교적 쉽습니다.
안드로이드 프레임워크에서 기본적으로 제공하는 메커니즘이므로 별도의 라이브러리 추가가 필요 없습니다.


2. 가벼운 데이터 처리에 적합:
소량의 원시 타입 데이터(int, String, boolean 등)나 Parcelable을 구현한 간단한 객체를 저장하고 복원하는 데 빠르고 효율적입니다. 복잡한 설정 없이 즉시 사용 가능합니다.


3. 안드로이드 생명주기 표준 준수:
액티비티의 정상적인 생명주기 흐름을 따르면서 상태를 관리할 수 있습니다.
android:configChanges와 같이 생명주기를 강제로 변경하는 방식보다 권장되는 접근법입니다.


4. 시스템 레벨에서 처리:
화면 회전뿐만 아니라, 메모리 부족으로 인한 시스템의 액티비티 재시작 상황에서도 데이터를 보호할 수 있습니다. 이는 ViewModel만으로는 완전히 커버하기 어려운 부분입니다. (ViewModel은 구성 변경에는 강하지만, 시스템에 의한 프로세스 종료 후에는 데이터가 소실될 수 있습니다. SavedStateHandle을 ViewModel과 함께 사용하면 이 문제를 해결할 수 있습니다.)


5. 뷰(View) 상태 자동 저장/복원과의 연동:
EditText의 텍스트, ScrollView의 스크롤 위치 등 id가 있는 특정 뷰들은 onSaveInstanceState()에서 super.onSaveInstanceState()를 호출하면 시스템이 자동으로 상태를 저장하고 복원해 줍니다. 여기에 추가적인 커스텀 데이터를 덧붙여 저장할 수 있습니다.

 

사용의 단점 및 한계
1. 대용량 데이터 저장 부적합:
Bundle에 저장되는 데이터는 직렬화/역직렬화 과정을 거치며, 이는 메인 스레드에서 처리될 수 있습니다. 따라서 이미지, 비트맵, 긴 목록, 복잡한 객체 등 대용량 데이터를 저장하면 성능 저하(ANR 발생 가능성)를 유발할 수 있습니다.
저장할 수 있는 데이터 크기에 제한이 있습니다. (약 1MB 내외, 시스템마다 다를 수 있음)


2. 영구적인 데이터 저장용이 아님:
onSaveInstanceState()는 임시적인 상태 저장을 위한 것입니다. 앱이 완전히 종료되거나(사용자가 직접 종료, 시스템 최적화 등) 시스템이 재부팅되면 Bundle에 저장된 데이터는 사라집니다.
사용자 설정, 데이터베이스 내용 등 영구적으로 보존해야 하는 데이터는 SharedPreferences, Room 데이터베이스, 파일 저장 등을 사용해야 합니다.


3. 비동기 데이터 처리의 어려움:
네트워크 요청 결과나 백그라운드 스레드에서 처리 중인 데이터를 직접 저장하기에는 적합하지 않습니다. 이러한 데이터는 ViewModel과 LiveData 등을 통해 관리하는 것이 더 효율적입니다.


4. 보일러플레이트 코드 발생 가능성:
저장하고 복원해야 할 데이터가 많아질수록 putInt(), getString(), getInt(), getString() 등의 코드가 반복적으로 나타나 코드가 길어지고 관리가 번거로워질 수 있습니다.


5. ViewModel과의 역할 구분 필요:
현대 안드로이드 개발에서는 화면 구성 변경 시 UI 관련 데이터를 유지하는 데 ViewModel이 더 권장됩니다. ViewModel은 생명주기를 인지하고 메모리 누수 없이 데이터를 관리할 수 있기 때문입니다. onSaveInstanceState()는 ViewModel을 보완하는 역할, 특히 시스템에 의한 프로세스 종료 시 임시 상태를 저장하는 용도로 이해하는 것이 좋습니다.

 

예제코드

import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.TextView;
import android.widget.Toast;

import androidx.annotation.NonNull; // onSaveInstanceState를 위해 추가
import androidx.appcompat.app.AppCompatActivity;

public class MainActivity extends AppCompatActivity {
    private static final String TAG = "Lifecycle"; // Log 태그 일관성
    private static final String KEY_COUNTER = "counter_key";
    private static final String KEY_USER_INPUT_DISPLAY = "user_input_display_key"; // showTextView에 표시될 텍스트
    private static final String KEY_EDIT_TEXT_CONTENT = "edit_text_content_key";   // EditText의 현재 내용
    TextView textViewCounter, showTextView;
    Button buttonIncrement, buttonTextView;
    EditText editTextVal;
    String userInputToDisplay; // showTextView에 표시될 텍스트를 저장할 변수
    int counter = 0;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        Log.d(TAG, "onCreate() 호출됨"); // Log 태그 통일

        textViewCounter = findViewById(R.id.textViewCounter);
        buttonIncrement = findViewById(R.id.buttonIncrement);
        buttonTextView = findViewById(R.id.buttonTextView);
        showTextView = findViewById(R.id.showTextView);
        editTextVal = findViewById(R.id.editTextUserInput); // XML ID 확인 필요

        if (savedInstanceState != null) {
            Log.d(TAG, "onCreate() - savedInstanceState에서 데이터 복원 시도");
            Toast.makeText(getApplicationContext(), "savedInstanceState에서 데이터 복원 시도", Toast.LENGTH_SHORT).show(); // 디버깅용 Toast
            counter = savedInstanceState.getInt(KEY_COUNTER, 0);
            userInputToDisplay = savedInstanceState.getString(KEY_USER_INPUT_DISPLAY, "초기화가 진행되어 내용이 없습니다.");
            editTextVal.setText(savedInstanceState.getString(KEY_EDIT_TEXT_CONTENT, "")); // EditText 내용 복원
        } else {
            Log.d(TAG, "onCreate() - savedInstanceState가 null, 초기값 사용");
            Toast.makeText(getApplicationContext(), "savedInstanceState가 null, 초기값 사용", Toast.LENGTH_SHORT).show(); // 디버깅용 Toast
            userInputToDisplay = "초기화가 진행되어 내용이 없습니다."; // 초기값 설정
        }

        textViewCounter.setText("카운터: " + counter);
        showTextView.setText(userInputToDisplay);

        buttonIncrement.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                counter++;
                textViewCounter.setText("카운터: " + counter);
            }
        });

        buttonTextView.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                String currentEditTextInput = editTextVal.getText().toString().trim();

                if (currentEditTextInput.isEmpty()) {
                    Toast.makeText(getApplicationContext(), "내용을 입력하세요!", Toast.LENGTH_SHORT).show();
                    // userInputToDisplay = "내용을 입력하세요!"; // 또는 현재 showTextView 내용을 유지
                    showTextView.setText("내용을 입력하세요!"); // 직접 설정
                } else {
                    userInputToDisplay = currentEditTextInput; // showTextView에 표시될 내용 업데이트
                    showTextView.setText(userInputToDisplay);
                }
            }
        });
    }

    // 화면 회전 등 구성 변경 시 데이터 저장 오버라이드 메서드
    @Override
    protected void onSaveInstanceState(@NonNull Bundle outState) {
        super.onSaveInstanceState(outState); // 항상 부모 메소드 호출
        Log.d(TAG, "onSaveInstanceState: 화면 변경 시작!");
        Log.d(TAG, "onSaveInstanceState() 호출됨 - 데이터 저장");
        Toast.makeText(getApplicationContext(), "화면이 변경되어 데이터 저장", Toast.LENGTH_SHORT).show(); // 디버깅용 Toast

        outState.putInt(KEY_COUNTER, counter);
        // showTextView에 현재 표시된 텍스트를 저장하거나, userInputToDisplay 변수를 저장
        outState.putString(KEY_USER_INPUT_DISPLAY, showTextView.getText().toString());
        // EditText의 현재 내용을 저장
        outState.putString(KEY_EDIT_TEXT_CONTENT, editTextVal.getText().toString());
    }


    // 생명주기 로그 추가 (이전과 동일)
    @Override
    protected void onStart() {
        super.onStart();
        Log.d(TAG, "onStart() 호출됨"); // Log 태그 통일
    }

    @Override
    protected void onResume() {
        super.onResume();
        Log.d(TAG, "onResume() 호출됨"); // Log 태그 통일
    }

    @Override
    protected void onPause() {
        super.onPause();
        Log.d(TAG, "onPause() 호출됨"); // Log 태그 통일
    }

    @Override
    protected void onStop() {
        super.onStop();
        Log.d(TAG, "onStop() 호출됨"); // Log 태그 통일
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        Log.d(TAG, "onDestroy() 호출됨"); // Log 태그 통일
    }
}

 

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:padding="16dp"
    tools:context=".MainActivity">

    <EditText
        android:id="@+id/editTextUserInput"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="32dp"
        android:hint="텍스트를 입력하세요"
        android:inputType="text"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.0"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/showTextView" />

    <TextView
        android:id="@+id/textViewCounter"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="80dp"
        android:text="카운터: 0"
        android:textSize="24sp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/buttonTextView" />

    <TextView
        android:id="@+id/showTextView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="32dp"
        android:text="입력한 내용이 보여집니다."
        android:textSize="24sp"
        app:layout_constraintEnd_toEndOf="@+id/editTextUserInput"
        app:layout_constraintStart_toStartOf="@+id/editTextUserInput"
        app:layout_constraintTop_toTopOf="parent" />

    <Button
        android:id="@+id/buttonIncrement"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="증가"
        app:layout_constraintEnd_toEndOf="@+id/textViewCounter"
        app:layout_constraintStart_toStartOf="@+id/textViewCounter"
        app:layout_constraintTop_toBottomOf="@+id/textViewCounter" />

    <Button
        android:id="@+id/buttonTextView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="텍스트입력"
        app:layout_constraintEnd_toEndOf="@+id/editTextUserInput"
        app:layout_constraintStart_toStartOf="@+id/editTextUserInput"
        app:layout_constraintTop_toBottomOf="@+id/editTextUserInput" />

    <!-- 수직 중앙 정렬을 위한 가이드라인 (선택적) -->
    <!-- 위젯들을 이 가이드라인에 묶거나, 체인(chain)을 사용할 수도 있습니다. -->
    <!-- 이 예제에서는 간단하게 위젯 간의 상대적 위치로 배치합니다. -->
    <!-- 좀 더 중앙에 가깝게 배치하고 싶다면, vertical chain을 활용할 수 있습니다. -->
    <!-- 예시 (세 위젯을 수직으로 묶고 중앙 정렬):
    <androidx.constraintlayout.widget.Guideline
        android:id="@+id/guideline_center_vertical"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        app:layout_constraintGuide_percent="0.5" />

    app:layout_constraintTop_toTopOf="parent"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintVertical_chainStyle="packed" (EditText에 추가)
    (EditText, TextViewCounter, ButtonIncrement 순서대로 chain 연결)
    -->

</androidx.constraintlayout.widget.ConstraintLayout>

 

시연영상 (onSaveInstanceState 미 사용)

 

시연영상 (onSaveInstanceState 사용)