[안드로이드-그 한계를 넘어서] 9장. 데이터 저장 및 직렬화

데이터 저장 및 직렬화

이 장에서는 로컬 기기에서 데이터를 저장하고 표현하는 방법을 살펴보자. 데이터 저장은 모든 소프트웨어 어플에서 중요한 부분을 차지한다. 개발자들은 보통 데이터 저장을 얘기할 때 영속화라는 용어를 사용하고, 저장 상태에서 데이터를 표현하는 방법을 설명할 때는 직렬화라는 용어를 사용.

영속화가 없다면, 데이터는 메모리에 상태만을 보관하게 되고, 관련 프로그램에서 절출해야 한다. 예를 들어 데이터를 빠르게 읽어야 한다면 다소 느린 쓰기 작업을 선택해야 한다.

안드로이드에서 영속화 및 직렬화에 가장 많이 사용되는 두 가지 기법

  1. SQLite
  2. SharedPreference

와 더불어 네트워크나 두 기기 간 데이터를 전송할 수 있는 두 가지 대체 기법을 살펴보자.

안드로이드의 영속화 옵션

안드로이드 기기의 영속성 데이터를 저장할 때 표준 API에서는 환경설정 파일과 SQLite데이터 베이스라는 두 가지 구조적 데이터 저장 방식을 기본으로 제공한다.

환경설정 파일은 XML형식으로 저장되고,SharedPreference 클래서 관리한다. SQLite데이터베이스는 API가 좀 더 복잡하고, 주로 ContentProvider컴포넌트로 감싸서 사용한다.

SharedPreference는 설정, 옵션, 사용자 환경설정, 그 외 단순한 값을 저장하는데 사용한다. 환경설정 파일에서는 배열, 값 테이블, 바이너리 데이터를 저장하지 않는다. 대신 자바에서 리스트나 배열에 보관하는 데이터는 ContentProvider를 통해 SQLite데이터베이스에 담는다.

이미지, 동영상, 오디오 파일 같은 미디어에 주로 해당하는 바이너리 데이터는 SQLite데이터베이스나 환경설정 파일에 직접 저장하지 말아야 한다. 대신 이런 바이너리 데이터는 앱의 내부 저장소나 외부 저장소에 일반 파일로 저장하는 게 더 바람직하다.

환경설정 파일을 통한 데이터 저장

SharedPreference 객체를 지원하는 파일은 앱의 데이터 디렉터리에 저장된 일반 XML파일.

환경설정 파일에서는 단순 키/값 쌍만을 저장할 수 있으므로 이 파일의 구조는 매우 단순. 하지만 안드로이드 API에서는 타입 안전한 데이터를 읽고 쓸 수 있게 해주는 편리한 추상화도 제공한다.

SharedPreference 객체를 생성하는 가장 쉬운 방법은 PreferenceManager.getDefaultSharedPreference()메서드 사용하는 것. 이렇게 하면 어플리케이션의 기본 환경설정 객체가 반환된다.

이렇게해서 기본 환경설정 객체를 수정해서 관리함.

또는 애플리케이션에 여러 개의 환경 설정파일이 있다면 파일명을 마음대로 지정할 수 있는 Context.getSharedPreference()를 사용.

특정 액티비티와 관련한 환경설정 파일을 생성하고 싶다면 Activity.getPrefernce()메서드를 호출.

위에서 getDefaultSharedPreference() 필요한 경우는 크게 백업 에이전트를 구현할 때 파일명 중요. com.aaptl.code_preferences

SharedPreference를 사용해 환경설정 파일에 저장할 수 있는 값 타입에는 int, float, long, boolean, String, String 객체 Set이 있다. 키의 이름은 항상 유효한 String 이여야 함.

키 이름을 지정할 때는 여러 키를 그룹으로 구조화하기 위해 주로 점 표시법을 사용한다.

public interface Constants {
    public static final String NETWORK_PREFIX = "network.";
    public static final String UI_PREFIX = "ui.";
    public static final String NETWORK_RETRY_COUNT
            = NETWORK_PREFIX + "retryCount";
    public static final String NETWORK_CONNECTION_TIMEOUT
            = NETWORK_PREFIX + "connectionTimeout";
    public static final String NETWORK_WIFI_ONLY
            = NETWORK_PREFIX + "wifiOnly";
    public static final String UI_BACKGROUND_COLOR
            = UI_PREFIX + "backgroundColor";
    public static final String UI_FOREGROUND_COLOR
            = UI_PREFIX + "foregroundColor";
    public static final String UI_SORT_ORDER
            = UI_PREFIX + "sortOrder";
    public static final int SORT_ORDER_NAME = 10;
    public static final int SORT_ORDER_AGE = 20;
    public static final int SORT_ORDER_CITY = 30;
}

매번 키에 접근할 때마다 키 이름을 하드코딩하는 방식보다는 앞의 예제처럼 환경설정 값에 접근하는 방식을 권장한다. 이렇게 하면 실수로 키 이름을 잘못 입력할 가능성을 아예 차단할 수 있고, 이로 인해 자주 생기는 버그를 막을 수 있다.

아래는 사용예!


 public class MainActivity extends Activity {

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


     @Override
     public boolean onCreateOptionsMenu(Menu menu) {
         // Inflate the menu; this adds items to the action bar if it is present.
         getMenuInflater().inflate(R.menu.main, menu);
         return true;
     }

     private void readUiPreferences() {
         SharedPreferences preferences
                 = PreferenceManager.getDefaultSharedPreferences(this);
         int defaultBackgroundColor = getResources().
                 getColor(R.color.default_background);
         int backgroundColor = preferences.getInt(
                 Constants.UI_BACKGROUND_COLOR,
                 defaultBackgroundColor);
         View view = findViewById(R.id.background_view);
         view.setBackgroundColor(backgroundColor);
     }


     public void doToggleWifiOnlyPreference(View view) {
         SharedPreferences preferences = PreferenceManager.
                 getDefaultSharedPreferences(this);
         boolean currentValue = preferences.
                 getBoolean(Constants.NETWORK_WIFI_ONLY, false);
         preferences.edit()
                 .putBoolean(Constants.NETWORK_WIFI_ONLY, !currentValue)
                 .apply();
     }


 }

환경 설정에서는 값을 변경하려면 먼저 Editor 인스턴스를 가져와야 한다. 이 인스턴스에서는 변경사항을 커밋할 수 있는 적절한 PUT메서드를 제공한다. 그리고 Commit말고 apply()를 사용하는 이유는 메인 스레드에서는 가능한 한 블로킹 작업은 삼가는 게 좋음.


     public void doToggleWifiOnlyPreference(View view) {
         SharedPreferences preferences = PreferenceManager.
                 getDefaultSharedPreferences(this);
         boolean currentValue = preferences.
                 getBoolean(Constants.NETWORK_WIFI_ONLY, false);
         preferences.edit()
                 .putBoolean(Constants.NETWORK_WIFI_ONLY, !currentValue)
                 .apply();
     }

여기서 보면 클릭리스터를 사용해 저장된 환경설정 값을 토글하는 법을 볼 수 있다. 이 경우 기존 Commit()메서드를 사용하면 메인 스레드가 블로킹될 수 있으므로 나쁜 사용자 경험을 초래할 수 있다.

모든 환경설정 파일은 같은 프로세스 내 동일 인스턴스를 갖고 있다. 따라서 서로 다른 두 컴포넌트(하지만 같은 애플리케이션)에서 같은 이름을 사용해 두 개의 SharedPreferences객체를 가져오더라도 이를 지원하는 인스턴스는 동일하므로, 두 객체 모두 변경사항이 바로 반영된다.

값이 업데이트 될 때 이를 알고 싶다면 apply()나 Commit()이 호출될 때 실행되는 콜백 리스너를 등록하면 된다. 주로 이런 콜백은 다음 예제에서 처럼 Activity에서 백그라운드 서비스의 동작에 영향을 미치는 환경설정 값을 변경할 때 사용한다.

 public class NetworkService extends IntentService
         implements SharedPreferences.OnSharedPreferenceChangeListener {
     public static final String TAG = "NetworkService";
     private boolean mWifiOnly;

     public NetworkService() {
         super(TAG);
     }

     @Override
     public void onCreate() {
         super.onCreate();
         SharedPreferences preferences = PreferenceManager
                 .getDefaultSharedPreferences(this);
         preferences.registerOnSharedPreferenceChangeListener(this);
         mWifiOnly = preferences.getBoolean(Constants.NETWORK_WIFI_ONLY,
                 false);
     }

     @Override
     protected void onHandleIntent(Intent intent) {
         ConnectivityManager connectivityManager
                 = (ConnectivityManager)
                 getSystemService(CONNECTIVITY_SERVICE);
         NetworkInfo networkInfo
                 = connectivityManager.getActiveNetworkInfo();
         int type = networkInfo.getType();
         if (mWifiOnly && type != ConnectivityManager.TYPE_WIFI) {
             Log.d(TAG, "We should only perform network I/O over WiFi.");
             return;
         }

         performNetworkOperation(intent);
     }

     @Override
     public void onSharedPreferenceChanged(SharedPreferences preferences,
                                           String key) {
         if (Constants.NETWORK_WIFI_ONLY.equals(key)) {
             mWifiOnly = preferences
                     .getBoolean(Constants.NETWORK_WIFI_ONLY, false);
             if(mWifiOnly) {
                 cancelNetworkOperationIfNecessary();
             }
         }
     }

     @Override
     public void onDestroy() {
         super.onDestroy();
         SharedPreferences preferences = PreferenceManager
                 .getDefaultSharedPreferences(this);
         preferences.unregisterOnSharedPreferenceChangeListener(this);
     }

     private void cancelNetworkOperationIfNecessary() {
         // TODO Cancel network operation if any active...
     }

     private void performNetworkOperation(Intent intent) {
         // TODO Perform actual network operation...
     }
 }

### 사용자 옵션 및 설정UI

많은 애플리케이션에서 애플리케이션의 옵션 및 설정을 변경할 수 있는 별도의 UI를 제공한다. 안드로이드에서는 이런 UI를 쉽게 만들 수 있게 끔 PreferenceActivity 및 PreferenceFramgent라는 기본 Activity및 Fragment를 제공한다.

먼저 xml리소스 디렉터리에 PreferenceScreen 구문을 따르는 XML파일을 생성한다. 이 구문은 사용자가 변경할 수 있는 모든 환경설정 및 각 환경설정과의 상호작용 방식을 지정하는 간단한 XML구조다. 여기서 텍스트 문자열 입력에 사용할 수 있는 간단한 텍스트 필드, 체크박스, 리스트옵션 등을 제공한다.

KakaoTalk_Photo_2017-02-24-13-08-10_74

 public class SettingsFragment extends PreferenceFragment {
     @Override
     public void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
         PreferenceManager.setDefaultValues(getActivity(),
                 R.xml.preference_screen, false);
         addPreferencesFromResource(R.xml.preference_screen);
     }
 }

고성능 콘텐트프로바이더

SQLite데이터베이스에 데이터를 저장할 때는 내부 용도로만 데이터를 저장하더라도 항상 ContentProvider를 구현할 것을 권장한다. 이 방식을 권장하는 이유는 안드로이드에서 ContentProvider를 기반으로 동작하는 여러 유틸리티 및 UI관련 클래스를 제공하고 있고, 이를 활용할 경우 작업이 휠씬 쉬어지기 때문이다.

데이터베이스 테이블을 생성할 때는 테이블의 주 목적을 고려해야합니다. 주로 읽기 및 UI표시에 사용할지, 읽기 작업보다 쓰기 작업이 많고, 백그라운드에서 주로 작업에 수행할지 고민해야 한다. 사용용도에 따라 읽기 성능이 빠른 쓰기 성능보다 더 중요할 수 도 있고 그 반대가 될수 도 있다.

#### 데이터베이스 생성 및 업데이트

안드로이드에서 SQLite데이터베이스를 항상 ContentProvider 컴포넌트로 감쌀 것을 권장한다. 이렇게 하면 모든 데이터베이스 호출을 한곳에서 관리할 수 있고, 데이터베이스 연동에 여러 편의 유틸리티를 활용할 수 있다.

이 절에서는 우선순위, 상태, 담당자를 다양하게 지정할 수 있는 할 일 콘텐트프로바이더를 구현한다. 먼저 실제 데이터베이스를 생성하는 법부터 살펴보자.

 public class TaskProvider extends ContentProvider {
     public static final String AUTHORITY = "com.aptl.code.provider";
     public static final int ALL_TASKS = 10;
     public static final int SINGLE_TASK = 20;
     public static final String TASK_TABLE = "task";
     public static final String[] ALL_COLUMNS =
             new String[]{TaskColumns._ID, TaskColumns.NAME,
                     TaskColumns.CREATED, TaskColumns.PRIORITY,
                     TaskColumns.STATUS, TaskColumns.OWNER};
     public static final String DATABASE_NAME = "TaskProvider";
     public static final int DATABASE_VERSION = 2;
     public static final String TAG = "TaskProvider";
     public static final String CREATE_SQL = "CREATE TABLE "
             + TASK_TABLE + " ("
             + TaskColumns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT, "
             + TaskColumns.NAME + " TEXT NOT NULL, "
             + TaskColumns.CREATED + " INTEGER DEFAULT NOW, "
             + TaskColumns.PRIORITY + " INTEGER DEFAULT 0, "
             + TaskColumns.STATUS + " INTEGER DEFAULT 0, "
             + TaskColumns.OWNER + " TEXT, "
             + TaskColumns.DATA + " TEXT);";
     public static final String CREATED_INDEX_SQL = "CREATE INDEX "
             + TaskColumns.CREATED + "_idx ON " + TASK_TABLE + " ("
             + TaskColumns.CREATED + " ASC);";
     public static final String OWNER_INDEX_SQL = "CREATE INDEX "
             + TaskColumns.OWNER + "_idx ON " + TASK_TABLE + " ("
             + TaskColumns.CREATED + " ASC);";
     private static final String FILE_PREFIX = "com.aptl_";
     private static final String FILE_SUFFIX = ".png";
     public static UriMatcher sUriMatcher
             = new UriMatcher(UriMatcher.NO_MATCH);
     public MyDatabaseHelper mOpenHelper;

     static {
         sUriMatcher.addURI(AUTHORITY, "task", ALL_TASKS);
         sUriMatcher.addURI(AUTHORITY, "task/#", SINGLE_TASK);
     }

     public static Bitmap readBitmapFromProvider(int taskId, ContentResolver resolver)
             throws FileNotFoundException {
         Uri uri = Uri.parse("content://" + TaskProvider.AUTHORITY
                 + "/" + TaskProvider.TASK_TABLE + "/" + taskId);
         return BitmapFactory.decodeStream(resolver.openInputStream(uri));
     }

     public static String[] fixSelectionArgs(String[] selectionArgs,
                                             String taskId) {
         if (selectionArgs == null) {
             selectionArgs = new String[]{taskId};
         } else {
             String[] newSelectionArg =
                     new String[selectionArgs.length + 1];
             newSelectionArg[0] = taskId;
             System.arraycopy(selectionArgs, 0,
                     newSelectionArg, 1, selectionArgs.length);
         }
         return selectionArgs;
     }

     public static String fixSelectionString(String selection) {
         selection = selection == null ? TaskColumns._ID + " = ?" :
                 TaskColumns._ID + " = ? AND (" + selection + ")";
         return selection;
     }

     @Override
     public boolean onCreate() {
         mOpenHelper = new MyDatabaseHelper(getContext());
         return true;
     }

     [생략]

     public interface TaskColumns extends BaseColumns {
         public static final String NAME = "name";
         public static final String CREATED = "created";
         public static final String PRIORITY = "priority";
         public static final String STATUS = "status";
         public static final String OWNER = "owner";
         public static final String DATA = "_data";
     }

     private class MyDatabaseHelper extends SQLiteOpenHelper {

         public MyDatabaseHelper(Context context) {
             super(context, DATABASE_NAME, null, DATABASE_VERSION);
         }

         @Override
         public void onCreate(SQLiteDatabase database) {
             Log.d(TAG, "Create SQL : " + CREATE_SQL);
             database.execSQL(CREATE_SQL);
             database.execSQL(CREATED_INDEX_SQL);
         }

         @Override
         public void onUpgrade(SQLiteDatabase db,
                               int oldVersion, int newVersion) {
             if (oldVersion < 2) {
                 db.execSQL("ALTER TABLE " + TASK_TABLE
                         + " ADD COLUMN " + TaskColumns.OWNER + " TEXT");
                 db.execSQL("ALTER TABLE " + TASK_TABLE + " ADD COLUMN " + TaskColumns.DATA + "TEXT");
                 db.execSQL(OWNER_INDEX_SQL);
             }
         }
     }

## 조회 메서드 구현

데이터베이스를 조회(주로 ContentResolver.query()를 사용해)하면 ContentProvider.query()가 호출된다. 콘텐트프로바이더 구현체에서는 들어오는 Uri를 정확히 해석해 어떤 메서드를 실행해야 하는지 판단하고, 전달받은 파라미터가 모두 안전한지 검사해야 한다.

데이터베이스 트랜잭션

[ 생략 ]

영속화를 위한 데이터 직렬화

ContentProvider를 활용할 떄 애플리케이션에서 ContentValues 및 Cursor를 활용해 영속성 데이터를 처리할 수는 있지만 인터넷으로 데이터를 전송하거나 다른 기기와 데이터를 공부하는 것은 전혀 다른 문제다. 이 문제를 해결하려면 데이터를 받는 쪽에서 작업할 수 있고, 네트워크를 통해 전송하기에 적합한 형식으로 데이터를 변환해야한다. 이 기법을 직렬화라고 한다.

직렬화는 메모리에 있는 객체를 가져와 나중에 메모리로 동일하게 읽을 수 있게끔(이를 역직렬화)파일(또는 다른 출력형식)에 쓰는 작업을 말한다. 안드로이드에서는 Parcelable인터페이스를 통해 내부 직렬화 API를 제공하지만, 이 인터페이스는 파일 저장이나 네트워크 전송에 적합하게끔 설계되지 않았다.

이 절에서는 복잡한 데이터를 영속화하기에 적합한 두 형식인 JSON 및 구글 프로토콜 버퍼를 살펴보쟈.

자바스크립트 객체 표기법

JSON은 자바스크립트 객체 표기법의 약자이며, 자바스크립트 표준에 속한다. 공식 안드로이드 API에 JSON 데이터를 읽고 쓸 수 있는 지원 기능을 기본으로 제공.

  public JSONArray readTasksFromInputStream(InputStream stream) {
      InputStreamReader reader = new InputStreamReader(stream);
      JsonReader jsonReader = new JsonReader(reader);
      JSONArray jsonArray = new JSONArray();
      try {
          jsonReader.beginArray();
          while (jsonReader.hasNext()) {
              JSONObject jsonObject
                      = readSingleTask(jsonReader);
              jsonArray.put(jsonObject);
          }
          jsonReader.endArray();
      } catch (IOException e) {
          // Ignore for brevity
      } catch (JSONException e) {
          // Ignore for brevity
      }

      return jsonArray;
  }

  private JSONObject readSingleTask(JsonReader jsonReader)
          throws IOException, JSONException {
      JSONObject jsonObject = new JSONObject();
      jsonReader.beginObject();
      JsonToken token;
      do {
          String name = jsonReader.nextName();
          if ("name".equals(name)) {
              jsonObject.put("name", jsonReader.nextString());
          } else if ("created".equals(name)) {
              jsonObject.put("created", jsonReader.nextLong());
          } else if ("owner".equals(name)) {
              jsonObject.put("owner", jsonReader.nextString());
          } else if ("priority".equals(name)) {
              jsonObject.put("priority", jsonReader.nextInt());
          } else if ("status".equals(name)) {
              jsonObject.put("status", jsonReader.nextInt());
          }

          token = jsonReader.peek();
      } while (token != null && !token.equals(JsonToken.END_OBJECT));
      jsonReader.endObject();
      return jsonObject;
  }

여기서 InputStream의 전체 내용을 String으로 읽은 다음 이를 JSONArray의 생성자로 넘겨줄 수도 있지만, 앞의 방식이 좀 더 메모리를 적게 소모하고 더 속도가 빠르다.

반대로 JsonWriter클래스를 활용하면 JSON데이터를 OutputStream에 효과적으로 쓸 수 있다.

public void writeJsonToStream(JSONArray array, OutputStream stream)
        throws JSONException, IOException {
    OutputStreamWriter writer = new OutputStreamWriter(stream);
    JsonWriter jsonWriter = new JsonWriter(writer);

    int arrayLength = array.length();
    jsonWriter.beginArray();
    for(int i = 0; i < arrayLength; i++) {
        JSONObject object = array.getJSONObject(i);
        jsonWriter.beginObject();
        jsonWriter.name("name").
                value(object.getString("name"));
        jsonWriter.name("created").
                value(object.getLong("created"));
        jsonWriter.name("priority").
                value(object.getInt("priority"));
        jsonWriter.name("status").
        value(object.getInt("status"));
        jsonWriter.name("owner").
                value(object.getString("owner"));
        jsonWriter.endObject();
    }
    jsonWriter.endArray();
    jsonWriter.close();
}

Gson을 활용한 고급 JSON 처리 이 라이브러리는 일단 다른 서드파드라이브러리 처럼 gradle에 의존성 추가하고 사용하면 된다.

이 라이브러리를 활용하면 일반 자바 객체(POJO)를 JSON으로 변환하거나 그 역으로 변환할 수 있다. 이 때 할 일은 게터/세터를 사용해 데이터를 일반 자바 객체로 정의하고, 프로젝트에 Gson라이브러리를 포함시키는 것뿐이다.

http://m.blog.naver.com/writer0713/220700687650

GSON 활용법 중요.