[Project] 아이모잡학도구_1.7.2_업데이트
본 앱은 구글 플레이스토어에 배포된 어플인 게임 ‘아이모’ 의 팬메이드 어플입니다. 1.7.2 업데이트를 수행하면서 공부한 내용을 정리하여 기록하고자 합니다.
Database
이전 1.6.2 버전의 데이터베이스 구성도는 다음과 같았습니다.
문제점?
- 기본적으로 db 내의 릴레이션들이 서로 관계를 갖지 않는 상태인 문제가 있었습니다.
- id라는 값으로 pk가 설정되있지만, 사실 몬스터에서는 몬스터이름 그리고 아이템에서는 아이템이름이 유일한 값을 가지므로 이들로 pk를 구성했어도 됬습니다. 불필요한 속성을 제거해야 합니다.
- 관계설정이 미흡했습니다. ‘표현하다’ 라기 보다는 몬스터가 아이템을 ‘드랍한다’ 가 적절한 표현입니다. 네이밍이 부실하다 보니 그에따라 관계설정이 미흡하여 몬스터<->맵 의 관계에서는 1:N 보다는 N:M이 적절했지만 그렇게 설정하지 않았던것 같습니다. 이에 대해 적절한 네이밍과 그에따라 정확한 관계설정이 필요했습니다.
개선
상단부는 E-R Diagram이고, 하단부는 Schema입니다.
모든 릴레이션이 관계를 가져서 서로 연결되도록 구성했습니다. 따라서 각 개체와 관계를 가지는 형태로 정확한 개체-관계 모델이 설정되었습니다.
관계의 네이밍을 적절하게 변경했습니다. 도감<-> 내용물의 형태가 아닌, ‘도감에 아이템들이 등록된다’ 의 N:M 관계설정으로 새로운 릴레이션을 구성하도록 변경했습니다.
다른 관계들도 마찬가지로 정확한 네이밍을 정의하였더니 그에맞게 속성값들이 결정되었습니다.
또한, 모든 개체들의 PK를 가지고있는 속성값들중에 유일성을 만족하는 값으로 변경했습니다.
무조건 유일성을 만족한다고 PK가 되는가? 에대한 질문의 답은 전혀 아닙니다.
해당 개체들의 PK는 본래 저러한 형태를 가졌어야만 했습니다.
이름이 없거나 중복되는 몬스터는 존재하지 않습니다. 몬스터 개체는 몬스터이름이 유일하며, 절때 Not Nullable 해야합니다.
이는 PK의 특징인 Unique 와 NotNull 을 만족함을 의미합니다.
즉, 굳이 Unique와 NotNull을 만족하는 속성값이 존재하는데 새로운 id라는 속성을 구태여 만들어줄 이유가 없었습니다.
다른 개체들도 마찬가지로 유일성과 NotNull이면서 개체를 식별하기에 만족하는 것들을 PK로 변경하였습니다.
가장 의아한 부분은 ‘Timer’ 개체 였습니다.
타이머는 사용자가 특정 몬스터를 선택하여 일정시간 뒤에 알람이 울리게 등록해두는 기능입니다. 해당기능을 본래에는 SharedPreference 로 구현하려 했었으나 해당 개체의 속성값들이 많고 변경의 여지가 많았으며, 등록하는 몬스터가 많으면 많아질수록 관리하기가 어려웠습니다.
따라서 데이터베이스에 두고 관리하려했고, 기존 버전1의 DB에서 관계를 가지지않는 형태를 초래했었습니다.
해당부분은 ‘몬스터를 선택하여’ 일정시간 뒤에 울리는 알람 이므로 몬스터이름이 필요했으므로 몬스터 개체의 이름을 FK로 받아 ‘등록하다’ 로 관계설정 하도록 변경하였습니다.
그리고 상태값 속성을 없애고 해당값이 존재하면 Timer 릴레이션에 데이터가 존재할것이고, 없으면 데이터가 존재하지 않도록 DML로 데이터를 관리하도록 변경했습니다.
이제 DB가 어느정도 깔끔하게 정리된것 같습니다. DB가 바뀌게되니 필요한 데이터를 요청하는 DML도 변경되어야하고 그에따라 Data Layer가 변경되어야 합니다.
이전버전에서는 MVVM이 재대로 정의되지 않아 해당 DB구조 변경의 사이드이펙트가 Data Layer에 국한되지 않고 Presenter Layer로 전파되어 변경될 부분이 많았습니다.
UI
기존에는 명령형 UI 방식으로 구현되었었는데, 다른 프로젝트에서 공부했던 내용을 바탕으로 알람설정 화면 파트는 Jetpack Compose로 구현하도록 변경하였습니다.
그외에 아이템도감과 몬스터도감은 기존의 명령형 UI 방식을 이어서 리펙토링 하였습니다.
부분적으로 반반으로 나눠서 구현한 이유는 아직 두가지 방법 모두 할수있어야 한다고 생각하고, Compose를 현업에서 도입한다고 생각했을때 부분적으로 도입하거나 신규프로젝트 형태로 도입할거라고 생각해서 그러한 경우를 대비하여 기존프로젝트에 부분적으로 적용하는 패턴으로 공부해 보았습니다.
기존의 코드는 MVC 패턴에 가깝게 구현되었었고 이를 MVVM 패턴에 맞게 리펙토링 하였습니다.
Data layer에는 DataStore 와 Room, Domain Layer에는 repository, Presenter Layer에는 ViewModel과 View를 각각 위치 시켜 단방향 의존관계를 두고 ViewModel에 View의 Event 로직들을 분리하여 작성하여 View와 ViewModel의 의존성을 줄였습니다. 또한, Data Layer의 변경에 따른 side effect가 Presenter Layer 까지 미치지 않게 되었습니다.
홈화면
기존의 UX가 너무 안좋다고 생각해서 홈화면에서 이미지를 클릭해서 화면을 전환하는 부분을 Bottom Navigation으로 변환했습니다. ( 좌: 이전, 우: 이후 )
상단바와 하단바는 activity에서 관리하도록 하였고, fragment에서는 이를 보일것인지 안보이게 할것인지 결정하도록 하였습니다. 상단바 메뉴의 경우 Fragment에서 MenuProvider 를 이용하여 inflate하도록 구현하였습니다.
도감 화면
아이템도감과 몬스터도감의 경우 기존의 명령형 UI 방식을 그대로 사용하되 LiveData를 코루틴의 Flow로 리펙토링 하였습니다. Coroutine의경우 순수 kotlin 코드로써 data layer와 domain layer에서 안드로이드 클래스의 의존성을 없애는 효과를 얻어 단위 테스트하기 좋은 코드가 되었습니다.
또한 bindingAdapter에서는 여러곳에서 자주 사용될만한 로직만을 구현하여 재사용하였고, 그외에 한번만 사용되는 로직의 경우에는 각 viewModel에 구현하여 사용하였습니다.
화면이 잘리는 이슈
도감쪽의 RecyclerView에서 화면의 가로넓이가 잘리는 이슈가 있었는데, 이를 해결하기 위해 constraint의 percent_width 속성을 이용하거나 직접 width를 명시적으로 할당하는 방법을 이용하는 등 해결해보려 했지만 근본적으로 기기비율에 따라 width가 꽉차지 못하는 형태를 해결하지 못했었습니다.
따라서 RecyclerView의 근본적인 생성 방식에서 ViewHolder가 itemView를 inflate할때 직접 activity로 부터 기기의 width를 가져와서 할당하도록 구현하여 해결하였습니다.
알람 화면
알람화면은 이전의 코드들이 너무 불필요하게 작성된점이 많았고, 이전프로젝트에서 공부한 Compose를 적용시키기 위해 모두 제거하여 리펙토링 하였습니다.
Compose의 경우 디자인단에서 작성된 컴포넌트들을 각 composable function으로 재사용하는 형태로 화면을 구성시켜 가독성과 재사용성이 높아 생산성이 좋다는 장점이 있습니다.
하지만 각 컴포넌트를 정의할때 단위를 얼마만큼 설정하는가에 대한 고민이 있었습니다.
이전 프로젝트에서 컴포넌트를 정의하고 재사용할때 특정 부분에서 추가적으로 필요한 값이 발생하여 이를 컴포넌트의 파라미터에 추가했을때 그에 따라 이미 작성된 코드들을 수정해야하는 side effect가 발생해서 문제가 있었습니다.
즉, 컴포넌트의 단위가 너무 크면 재사용할때 경우에 따라 필요한 추가적인 코드들이 작성될 여지가 적어 side effect는 줄어들지만 재사용의 목적 자체가 의미 없어질수 있습니다. 이와 달리 컴포넌트의 단위가 너무 작으면 side effect가 발생할 여지가 커집니다.
따라서 기본적인 디자인에 따라 컴포넌트를 정의하되, 변경의 여지가 큰 modifier를 파라미터로 받는 것이 중요했고 추가적으로 크기나 색상을 파라미터로 받아 적절한 제사용 가능하면서 side effect가 없을 단위의 컴포넌트를 만드려고 노력했습니다.
기존의 화면의 경우 상단바의 메뉴들의 직관성이 부족하여 어떤 아이콘이 어떤 화면인지를 들어가봐야 알수있었고, 화면내에 버튼들이 너무 많고 필요한 기능을 한번에 수행할 수 없는등 깔끔하지 못한 UX를 제공하고 있었습니다.
firebase realtime database를 이용한 서버기능은 사용량이 저조하여 제거하고 딱 필요한 기능을 ‘현재시간 항상보기 활성화’ 와 ‘타이머 설정’ 으로 두가지로 분류하여 구성시켰고, 화면내에는 적절하게 직관성과 편리성을 증가시키는 방향으로 화면을 구성하였습니다.
상단바의 아이콘을 클릭하여 화면이 전환되는 부분은 Compose Navigation을 이용하여 구현하였습니다.
알람 로직들은 단순화 시켜 viewModel에 작성하였는데, 알람이 울리는 동작을 Broadcast Receiver 또는 Service 중에 어느 곳에서 구현해야 할지 고민이 있었습니다.
Broadcast Receiver는 subscriber&publisher 패턴으로 동작하는데 보통 다른앱과의 상호작용을 목적으로 사용됩니다. 이는 생명주기가 따로 존재하지 않기 때문에 이벤트형태로 onReceive()가 순차적으로 코드를 수행시키고 종료됩니다.
Service의 경우 백그라운드 작업을 수행할때 눈에보이는 것이면 Foreground Service로, 그렇지 않으면 Service로 수행시키는데 생명주기가 따로 존재하는 특징이 있고, LifecycleService를 이용하면 생명주기에 따라 coroutine을 동작시키고 제거할수도 있습니다.
알람의 동작같은 경우에 데이터베이스를 갱신연산 해야만 하고, Notification을 만들어주고 해당 notification의 action을 통해 알람을 재설정하는등 기능을 여러가지 수행해주어 복잡하기 때문에 Service로 구현하려 했지만 문제점 이 있었습니다.
화면이 꺼져있을때 알람이 울리지 않았습니다.
알람의 생성 과정은 PendingIntent을 이용하여 특정시점에 AlarmManager(구현체는 Service)가 동작하여 적용된 intent를 수행하는 형태로 동작합니다.
물론, 백그라운드 작업의 경우 Job Schedular나 이를 대체하기 위해 고안된 workManager 를 이용하는 것이 권장되고 있는데 이는 지연 혹은 즉시(상호작용 중 일때) 알람에서 사용됩니다.
본 앱의 경우 알람이 오차없이 정시에 동작해야 하기 때문에 반드시 AlarmManager를 사용하여야만 합니다.
이때의 문제는 화면이 꺼져있을때 안드로이드의 배터리 정책에 따라 화면이 꺼진 상태에서 수십분이 지나면 Doze Mode 에 진입하여 cpu time이 멈추면서 알람이 정시에 발생하지 않는 문제가 있습니다. 이는 AlarmManager의 setAlarmClock() 을 이용하여 배터리의 소모는 크겠지만 정시 알람을 보장할 수 있습니다.
자 이런데도 알람이 울리지 않았습니다. 이유는 Service로 구현했기 때문입니다.
Service의 경우 백그라운드 작업이고, 이는 위에서 알았듯이 Doze Mode에서 배터리 정책에 따라 작업이 지연되거나 제한되기 때문에 동작하지 않습니다.
하지만 Broadcast Receiver의 경우 다른 앱과의 상호작용을 위해 시스템에서 Global Message를 수신하여 onReceive()가 동작하는 형태이기 때문에 제한되지 않는 것으로 보입니다. Doze Mode 와 전력 관리 제한사항 를 참고하였습니다.
배터리 소모는 크겠지만, 이제 정확한 알람이 발생하여 사용자에게 혼란을 주지 않게됬습니다.
추가 개선 방향
이후 업데이트에서는 Localization을 통해 필리핀과 미국 스토어에 등록해보고자 합니다. 또한, 아이템 도감의 경우 사용자가 갱신연산을 수행하여 기능을 확대해보고자 합니다.
또 향후 다른프로젝트에서 배우는 내용이 있다면, 내것으로 만들기 위해 제프로젝트에 맞게 응용하여 적용시키면서 공부를 지속적으로 해볼 것입니다.
댓글남기기