2019년 3월 13일 수요일

안드로이드 PIP 예제 - Picture In Picture

- PIP 지원
https://developer.android.com/guide/topics/ui/picture-in-picture?hl=ko
https://developer.android.com/about/versions/oreo/android-8.0?hl=ko#opip



1. 기본구성
- AndroidManifest.xml
<activity
android:name=".MainActivity"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"
android:resizeableActivity="true"
android:supportsPictureInPicture="true">


- 미디어 플레이어 코드가 구현된 상태에서 Actvity 추가사항들.
PictureInPictureParams.Builder pictureInPictureParamsBuilder = new PictureInPictureParams.Builder();
Rational aspectRatio = new Rational(mediaPlayer.getVideoWidth(), mediaPlayer.getVideoHeight());
PictureInPictureParams build = pictureInPictureParamsBuilder.setAspectRatio(aspectRatio).build();
enterPictureInPictureMode(build);

@Override
public void onPictureInPictureModeChanged(boolean isInPictureInPictureMode) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode);
Log.d(TAG, "onPictureInPictureModeChanged : " + isInPictureInPictureMode);
}


> PIP 동작 상태가 변경되면 onPictureInPictureModeChanged 메소드가 호출됩니다.
> enterPictureInPictureMode 메소드를 호출하면 PIP 모드로 동작합니다.


- Activity 전체코드
public class MainActivity extends AppCompatActivity {

    private static final String TAG = MainActivity.class.getSimpleName();
    private String mediaURL = "https://sample-videos.com/video123/mp4/720/big_buck_bunny_720p_20mb.mp4";
    private MediaPlayer mediaPlayer;
    private boolean minimize;

    private final Object pictureInPictureParamsBuilder;
    {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            pictureInPictureParamsBuilder = new PictureInPictureParams.Builder();
        } else {
            pictureInPictureParamsBuilder = null;
        }
    }

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        getSupportActionBar().hide();

        setContentView(R.layout.activity_main);


        SurfaceView surfaceView = findViewById(R.id.surfaceView);
        surfaceView.getHolder().addCallback(new SurfaceHolder.Callback() {
            @Override
            public void surfaceCreated(SurfaceHolder holder) {
                initMediaPlayer(holder);
            }

            @Override
            public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {

            }

            @Override
            public void surfaceDestroyed(SurfaceHolder holder) {

            }
        });

        View actionPIP = findViewById(R.id.actionPIP);
        actionPIP.setVisibility(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O ? View.VISIBLE : View.GONE);
        actionPIP.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                minimize();
            }
        });
    }

    private void initMediaPlayer(SurfaceHolder holder) {
        holder.getSurface();
        mediaPlayer = new MediaPlayer();
        mediaPlayer.setOnPreparedListener(new MediaPlayer.OnPreparedListener() {
            @Override
            public void onPrepared(MediaPlayer mp) {
                mp.start();
            }
        });
        mediaPlayer.setSurface(holder.getSurface());

        try {
            mediaPlayer.setDataSource(getBaseContext(), Uri.parse(mediaURL));
            mediaPlayer.prepareAsync();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    private void play() {
        if (mediaPlayer != null) {
            if (mediaPlayer.isPlaying() == false) {
                mediaPlayer.start();
            }
        }
    }

    @Override
    protected void onResume() {
        super.onResume();
        play();
    }

    @Override
    protected void onPause() {
        super.onPause();
        if (minimize == false)
            if (mediaPlayer != null) {
                mediaPlayer.pause();
            }
    }

    @Override
    protected void onDestroy() {
        if (mediaPlayer != null) {
            mediaPlayer.release();
        }
        super.onDestroy();
    }


    /**
     * PIP 모드
     */
    private void minimize() {
        if (mediaPlayer == null) {
            return;
        }
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            if (pictureInPictureParamsBuilder instanceof PictureInPictureParams.Builder) {
                Rational aspectRatio = new Rational(mediaPlayer.getVideoWidth(), mediaPlayer.getVideoHeight());
                PictureInPictureParams build = ((PictureInPictureParams.Builder) pictureInPictureParamsBuilder).setAspectRatio(aspectRatio).build();
                minimize = true;
                enterPictureInPictureMode(build);
            }
        }
    }


    @Override
    public void onPictureInPictureModeChanged(boolean isInPictureInPictureMode) {
        super.onPictureInPictureModeChanged(isInPictureInPictureMode);
        Log.d(TAG, "onPictureInPictureModeChanged : " + isInPictureInPictureMode);
        if (isInPictureInPictureMode) {
            play();
        } else {
            minimize = false;
        }
    }
}



3. 샘플코드
소스코드
APK

  


샘플 mp4 영상
https://sample-videos.com/video123/mp4/720/big_buck_bunny_720p_20mb.mp4

구글 샘플코드
https://github.com/googlesamples/android-PictureInPicture/#readme

FCM 클라이언트 예제

1. FCM 환경구성
- 파이어베이스 프로젝트 생성 및 어플리케이션 등록
https://console.firebase.google.com/

> keytool을 이용해서 어플리케이션을 파이어베이스 프로젝트와 연동(인증)
> keytool의 위치는 JAVA SDK의 bin 디렉토리에 있음.
https://developers.google.com/android/guides/client-auth

> google-services.json 파일은 [프로젝트]/app 디렉토리에 포함시킨다.



> 프로젝트 연동을 확인 하면 기본 설정은 완료.



2. 기본구성
- FCM gradle 구성
https://firebase.google.com/docs/cloud-messaging/android/client?hl=ko


- FirebaseMessagingService 상속
public class MyFirebaseMessagingService extends FirebaseMessagingService{}


- FCM 서비스 등록 - AndroidManifest.xml
<service android:name=".MyFirebaseMessagingService">
    <intent-filter>
        <action android:name="com.google.firebase.MESSAGING_EVENT" />
    </intent-filter>
</service>

> MyFirebaseMessagingService는 FirebaseMessagingService를 상속 한다.



2. 기본코드
- MyFirebaseMessagingService
public class MyFirebaseMessagingService extends FirebaseMessagingService {
    private static final String TAG = MyFirebaseMessagingService.class.getSimpleName();

    /*
     * Activity가 onResume 상태가 아닐 때는 Cloud Message에서 전송한 메시지는 수신되지 않는다.
     */
    @Override
    public void onMessageReceived(RemoteMessage remoteMessage) {
        super.onMessageReceived(remoteMessage);
        String from = remoteMessage.getFrom();
        String messageId = remoteMessage.getMessageId();
        String messageType = remoteMessage.getMessageType();

        Map<String, String> map = remoteMessage.getData();
        int size = map.size();
        Log.d(TAG, "RemoteMessage from : " + from);
        Log.d(TAG, "RemoteMessage id : " + messageId);
        Log.d(TAG, "RemoteMessage type : " + messageType);
        Log.d(TAG, "RemoteMessage size : " + size);
        if (size > 0) {
            JSONObject jsonObject = new JSONObject(map);
            Log.d(TAG, "RemoteMessage data : " + jsonObject.toString());
        }

        RemoteMessage.Notification notification = remoteMessage.getNotification();
        if (notification != null) {
            String title = notification.getTitle();
            String body = notification.getBody();
            Log.d(TAG, "RemoteMessage notification title : " + title);
            Log.d(TAG, "RemoteMessage notification body : " + body);
        }
    }

    @Override
    public void onNewToken(String s) {
        super.onNewToken(s);
        Log.d(TAG, "TOKEN : " + s);
    }
}

> 파이어베이스의 Cloud Message는 액티비티 상태에 따라 메시지 수신 결과가 달라진다.
> FCM 메시지의 data 영역만 사용할 때는 항상 MyFirebaseMessagingService 가 수신한다.



- MainActivity
public class MainActivity extends AppCompatActivity {

    private static final String TAG = MainActivity.class.getSimpleName();

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

        Task<InstanceIdResult> id = FirebaseInstanceId.getInstance().getInstanceId();
        id.addOnCompleteListener(new OnCompleteListener<InstanceIdResult>() {
            @Override
            public void onComplete(@NonNull Task<InstanceIdResult> task) {
                // 토큰 확인
                if (task.isSuccessful()) {
                    InstanceIdResult result = task.getResult();
                    String id = result.getId();
                    String token = result.getToken();

                    Log.d(TAG, "Token id : " + id);
                    Log.d(TAG, "Token : " + token);

                    TextView tokenView = findViewById(R.id.token);
                    tokenView.setText(task.getResult().getToken());
                } else {
                    Log.d(TAG, "Token Exception : " + task.getException().toString());
                }
            }
        });
    }
}



- AndroidManifest.xml 코드
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="app.example.fcmapp">

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">

        <meta-data
            android:name="com.google.firebase.messaging.default_notification_channel_id"
            android:value="@string/default_notification_channel_id" />

        <activity android:name=".MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

        <service android:name=".MyFirebaseMessagingService">
            <intent-filter>
                <action android:name="com.google.firebase.MESSAGING_EVENT" />
            </intent-filter>
        </service>
    </application>
</manifest>



- Cloud Message 전송 테스트
> 백그라운드 또는 알림센터에서 메시지가 수신된다.



3. 샘플코드
소스코드
APK



2019년 3월 12일 화요일

FCM 전송 예제

- GCM은 사용하지 말것.
https://developers.google.com/cloud-messaging/

- FCM 레퍼런스 :
https://firebase.google.com/docs/cloud-messaging/migrate-v1?hl=ko

- 예제소스는 HTTP v1 샘플소스

- 메시지 포멧은 JSON 이므로 JSON 라이브러리를 사용을 권장.



1. 메시지 전송
- 기본코드
String urlStr = "https://fcm.googleapis.com/fcm/send";
String authorization = "key=[클라우드 메시지 서버 키]";

String token = "[클라이언트 FCM 토큰]";
String data = "\"data\": {\r\n" + " \"en\": \"abc\",\r\n" + " \"ko\": \"가나다\"\r\n" + " }";
String notification = "\"notification\" : {\r\n"
        + " \"body\" : \"This is an FCM notification message!\",\r\n"
        + " \"title\" : \"FCM Message\"\r\n" + " }";

URL url = null;
HttpURLConnection connection = null;
BufferedOutputStream bos = null;
BufferedReader reader = null;

try {
    url = new URL(urlStr);
    connection = urlStr.startsWith("https://") ? (HttpsURLConnection) url.openConnection()
            : (HttpURLConnection) url.openConnection();
    connection.setRequestProperty("Content-Type", "application/json; charset=utf-8");
    connection.setRequestProperty("cache-control", "no-cache");
    connection.setRequestProperty("Authorization", authorization);

    connection.setDoOutput(true);
    connection.setDoInput(true);

    connection.connect();

    bos = new BufferedOutputStream(connection.getOutputStream());

    String message = "{\"to\" : \"" + token + "\"," + data + "," + notification + "}";
    bos.write(message.getBytes("UTF-8"));

    bos.flush();
    bos.close();

    int responseCode = connection.getResponseCode();
    String responseMessage = connection.getResponseMessage();
    StringBuffer buffer = null;
    if (responseCode == HttpURLConnection.HTTP_OK) {

        buffer = new StringBuffer();
        reader = new BufferedReader(new InputStreamReader(connection.getInputStream(), "UTF-8"));
        String temp = null;
        while ((temp = reader.readLine()) != null) {
            buffer.append(temp);
        }
        reader.close();
    }
    connection.disconnect();
    System.out.println(String.format("Response : %d, %s", responseCode, responseMessage));
    System.out.println("Response DATA : ");
    System.out.println(buffer == null ? "NULL " : buffer.toString());
} catch (IOException e) {
    e.printStackTrace();
}


> POST 전송 방식
> data만 사용하면 항상 백그라운드 서비스가 메시지를 수신
> notification을 적용할 경우 Activity가 Foreground 상태일 때와 Background 상태일 때 응답 방식이 변경됨.
> 한글깨짐 문제가 될 수 있으므로 UTF-8 인코딩을 적용.

안드로이드 프로젝트 gitignore 리스트

- 프로젝트 복사와 SVN 업로드 시에 불필요한 부분을 덜어내야 관리하기 편리합니다.


1. gitignore 목록
- 프로젝트 루트 디렉토리의 .gitignore 파일은 아래와 같이 설정
*.iml
.gradle/
.idea
/*/build/
/local.properties
/captures
.externalNativeBuild
.DS_Store
Thumbs.db

> app 디렉토리의 .gitignore 파일이 아닌 루트 디렉토리의 .gitignore 파일 입니다.
> 루트 디렉토리의 .gitignore이 없을 경우 새로 생성 하거나 안드로이드 스튜디오에서 직접 gitignore 항목을 지정합니다.




2. 프로젝트 압축 시 용량 줄이기
- 프로젝트 루트 디렉토리





























.gradle 디렉토리 제외
.idea 디렉토리 제외
local.properties 파일 제외
.iml 확장자 제외




- 어플리케이션 디렉토리






























build 디렉토리 제외
.iml 확장자 제외

콜백 인터페이스 - ResultReceiver

- ResultReceiver를 더 자세히 이해하기 위해서는 안드로이드 IPC 자료를 찾아볼 것.
ResultReceiver 클래스는 Parcelable 인터페이스를 구현하므로 Parcelable 객체를 전달 받을 수 있는 모든 곳에서 콜백을 처리할 수 있다.


1. ResultReceiver 기본코드
- ResultReceiver 콜백
ResultReceiver resultReceiver = new ResultReceiver(textView.getHandler()) {
    @Override
    protected void onReceiveResult(int resultCode, Bundle resultData) {
        super.onReceiveResult(resultCode, resultData);
        // 콜백
        if (resultCode == RESULT_OK && resultData != null) {
            String text = resultData.getString("message");
            textView.setText(text);
        }
    }
};



ResultReceiver 콜백 파라미터
BlankFragment fragment = new BlankFragment();
Bundle args = new Bundle();
args.putParcelable(ResultReceiver.class.getSimpleName(), resultReceiver);
fragment.setArguments(args);

또는

Intent intent = new Intent(getBaseContext(), SubActivity.class);
intent.putExtra(ResultReceiver.class.getSimpleName(), resultReceiver);
startActivity(intent);


ResultReceiver가 Parcelable를 구현하므로 Parcelable를 수신할 수 있는 모든 곳에서 콜백을 동작시킬 수 있다.
> 주로 Activity와 Service 간 통신에 사용된다.



- Bundle 전송
ResultReceiver resultReceiver = getArguments().getParcelable(ResultReceiver.class.getSimpleName());
Bundle bundle = new Bundle();
bundle.putString("message", "Hello, World!");
resultReceiver.send(Activity.RESULT_OK, bundle);


> sned를 통해 Bundle를 전송하면 onReceiveResult 메소드에서 수신한다.




2. 샘플코드
소스코드
APK


안드로이드 관리자 권한 예제 - DevicePolicyManager

- 관리자 권한 취득
- 카메라 사용 제한하기
- 앱 삭제 제한하기(어플리케이션 관리 화면에서는 삭제 가능)


1. 관리자 권한
- xml 정의 - device_admin_receiver.xml
<device-admin xmlns:android="http://schemas.android.com/apk/res/android">
    <uses-policies>
        <disable-camera />
    </uses-policies>
</device-admin>

> res 디렉토리의 xml 디렉토리에 xml 문서 생성
> 카메라 제어를 위해 <disable-camera /> 적용
> 레퍼런스 :
https://developer.android.com/guide/topics/admin/device-admin
> 안드로이드 9 버전 이후 부터 변경 사항이 있습니다.
https://developers.google.com/android/work/device-admin-deprecation


- DeviceAdminReceiver  상속
public class AppDeviceAdminReceiver extends DeviceAdminReceiver {
    private static final String TAG = AppDeviceAdminReceiver.class.getSimpleName();

    @Override
    public void onEnabled(Context context, Intent intent) {
        super.onEnabled(context, intent);
        Log.d(TAG, "Admin onEnabled");
    }

    @Override
    public void onDisabled(Context context, Intent intent) {
        super.onDisabled(context, intent);
        Log.d(TAG, "Admin onDisabled");
    }
}


- 리시버 등록 - AndroidManifest.xml
<receiver
android:name=".AppDeviceAdminReceiver"
android:description="@string/device_admin_description"
android:label="@string/app_name"
android:permission="android.permission.BIND_DEVICE_ADMIN">
<meta-data
android:name="android.app.device_admin"
android:resource="@xml/device_admin_receiver" />
<intent-filter>
<action android:name="android.app.action.DEVICE_ADMIN_ENABLED" />
</intent-filter>
</receiver>


- 관리자 권한 요청
ComponentName componentName = new ComponentName(this, AppDeviceAdminReceiver.class);
Intent intent = new Intent(DevicePolicyManager.ACTION_ADD_DEVICE_ADMIN);
intent.putExtra(DevicePolicyManager.EXTRA_DEVICE_ADMIN, componentName);
startActivityForResult(intent, DEVICE_ADMIN_ADD_RESULT_ENABLE);


> 권한 확인
final boolean adminActive = devicePolicyManager.isAdminActive(componentName);

> 카메라 제어 확인
boolean cameraDisabled = devicePolicyManager.getCameraDisabled(componentName);




2. 샘플코드
소스코드
APK

 



2019년 3월 8일 금요일

Room 아키텍처 예제

- 아키텍처 구조


- 구글 코드랩 예제 :
https://codelabs.developers.google.com/codelabs/android-room-with-a-view/index.html?index=..%2F..index#0




1. 기본 구성
- dependencies 구성
def room_version = "2.1.0-alpha04"
implementation "androidx.room:room-runtime:$room_version"
annotationProcessor "androidx.room:room-compiler:$room_version"

def lifecycle_version = "2.0.0"
// ViewModel and LiveData
implementation "androidx.lifecycle:lifecycle-extensions:$lifecycle_version"

> 라이프사이클
https://developer.android.com/jetpack/androidx/releases/lifecycle?hl=ko#declaring_dependencies
> Room
https://developer.android.com/jetpack/androidx/releases/room?hl=ko



- Entity
@Entity(tableName = "user_table")
public class User {

    @PrimaryKey(autoGenerate = true)
    @ColumnInfo(name = "id")
    private int id;

    @NonNull
    @ColumnInfo(name = "userName")
    private String userName;

    @NonNull
    @ColumnInfo(name = "age")
    private int age;

}

> autoGenerate를 적용하면 insert 과정에서 id 값이 자동으로 증가한다.



- Dao
@Dao
public interface UserDao {
    @Insert
    long insert(User user);

    @Update
    int update(User user);

    @Query("DELETE FROM user_table")
    int deleteAll();

    @Query("DELETE FROM user_table WHERE id = :id")
    int deleteUser(int id);

    @Query("SELECT * from user_table ORDER BY userName ASC")
    LiveData<List<User>> getAllUsers();

}

> LiveData를 사용하지 않을 경우
    @Query("SELECT * from user_table ORDER BY userName ASC")
    List<User> getAllUsers();



- Database
@Database(entities = {User.class}, version = 1)
public abstract class UserRoomDatabase extends RoomDatabase {
    public abstract UserDao userDao();

    private static volatile UserRoomDatabase INSTANCE;

    static UserRoomDatabase getDatabase(final Context context) {
        if (INSTANCE == null) {
            synchronized (UserRoomDatabase.class) {
                if (INSTANCE == null) {
                    INSTANCE = Room.databaseBuilder(context.getApplicationContext(), UserRoomDatabase.class, "user_database").build();
                }
            }
        }
        return INSTANCE;
    }
}


- 설명
androidx.room:room-compiler가 generatedJava 디렉토리의 소스코드를 자동 생성한다.

SQLiteOpenHelper를 사용했을 때 보다 더 생산성이 좋다.
링크 : 






2. 안드로이드 앱 아키텍처 적용



Room 아키텍처는 안드로이드 앱 아키텍처의 DB 연동 구성요소이다.


- Repository
class UserRepository {
    private static final String TAG = UserRepository.class.getSimpleName();

    private final UserDao userDao;
    private final LiveData<List<User>> allUsers;

    UserRepository(Application application) {
        UserRoomDatabase db = UserRoomDatabase.getDatabase(application);
        userDao = db.userDao();
        allUsers = userDao.getAllUsers();
    }

    public void insert(User user) {
        new AsyncTask<User, Void, Long>() {
            @Override
            protected Long doInBackground(User... users) {
                if (userDao == null)
                    return -1L;
                return userDao.insert(users[0]);
            }

            @Override
            protected void onPostExecute(Long aLong) {
                super.onPostExecute(aLong);
                Log.d(TAG, "insert : " + aLong);
            }
        }.execute(user);
    }

    public void update(User user) {
        new AsyncTask<User, Void, Integer>() {
            @Override
            protected Integer doInBackground(User... users) {
                if (userDao == null)
                    return -1;
                return userDao.update(users[0]);
            }

            @Override
            protected void onPostExecute(Integer integer) {
                super.onPostExecute(integer);
                Log.d(TAG, "update : " + integer);
            }
        }.execute(user);
    }

    public void deleteAll() {
        new AsyncTask<Void, Void, Integer>() {
            @Override
            protected Integer doInBackground(Void... voids) {
                if (userDao == null)
                    return -1;
                return userDao.deleteAll();
            }

            @Override
            protected void onPostExecute(Integer integer) {
                super.onPostExecute(integer);
                Log.d(TAG, "deleteAll : " + integer);
            }
        }.execute();
    }

    public void deleteUser(int id) {
        new AsyncTask<Integer, Void, Integer>() {
            @Override
            protected Integer doInBackground(Integer... integers) {
                if (userDao == null)
                    return -1;
                return userDao.deleteUser(integers[0]);
            }

            @Override
            protected void onPostExecute(Integer integer) {
                super.onPostExecute(integer);
                Log.d(TAG, "deleteUser : " + integer);
            }
        }.execute(id);
    }

    public LiveData<List<User>> getAllUsers() {
        return allUsers;
    }
}

> 메인쓰레드에서 DB 연동을 사용하지 않아야 한다.
> LiveData를 사용하지 않는 곳은 AsyncTask를 적용한다.



- AndroidViewModel 
public class UserViewModel extends AndroidViewModel {
    private static final String TAG = UserViewModel.class.getSimpleName();

    private final UserRepository repository;
    private final LiveData<List<User>> allUsers;

    public UserViewModel(Application application) {
        super(application);
        repository = new UserRepository(application);
        allUsers = repository.getAllUsers();
    }

    public void insert(User user) {
        repository.insert(user);
    }

    public void update(User user) {
        repository.update(user);
    }

    public void deleteAll() {
        repository.deleteAll();
    }

    public void deleteUser(int id) {
        repository.deleteUser(id);
    }

    public LiveData<List<User>> getAllUsers() {
        return allUsers;
    }
}

> AndroidViewModel를 상속하고 대부분의 메소드들은 델리게이트로 구성한다.



- ViewModel 구현
userViewModel = ViewModelProviders.of(this).get(UserViewModel.class);
userViewModel.getAllUsers().observe(this, new Observer<List<User>>() {
@Override
public void onChanged(List<User> users) {

}
});

> 라이프사이클을 이용해서 ViewModel을 통해 DB 연동을 한다.
> 라이프 사이클 :
https://developer.android.com/jetpack/androidx/releases/lifecycle?hl=ko#declaring_dependencies



3. 샘플코드
소스코드
APK




- Room의 구성을 더 강력하게 만드는 구성은 Room + ViewModel + Paging 조합이다.
추가적으로 Data Binding을 함께 구성하기도 하지만 반드시 필요한 경우만 사용해하 한다.

- Room을 사용하는 주요 목적은 안드로이드 앱 아키텍처 구조 적용과 SQLiteOpenHelper의 불편함을 줄이기 위함이다.

- 안드로이드와 SQLite의 관계를 이해하고 Room 아키텍처를 사용하도록 하자.