-
Notifications
You must be signed in to change notification settings - Fork 1
Description
안드로이드 개발 시작부터 굽기전까지 작업해오면서 해왔던 생각들을 정리했습니다. 향후 리펙토링을 할 때 도움이 되면 좋겠네요.
Refactoring
1. 디렉토리 구조
개인적으로 처음 안드로이드 개발할 때 디렉토리가 정리 안된채로 개발하고 계셔서 좀 충격받았던 기억이... 그 당시 저도 안드로이드 입문을 막 시작해서 MVVM 아키텍쳐에 대한 이해도가 부족했었고, 그 당시에 activity 별로 디렉토리를 나눠서 아직까지도 그 구조를 사용하고 있네요.
그 이후에 개발하면서 다른 자료나 Repository를 찾아보면서 알게 되었는데 MVVM 아키텍쳐를 기반으로 디렉토리 구조를 나누는게 일반적인 방식인 것 같습니다.
흔히 data layer
, domain layer
, presentation layer
로 나누어서 관리하는데 간단히 요약하자면 아래와 같습니다.
data layer
: 외부 데이터 소스(API, DB 등)와 앱 사이의 연결을 담당하는 layerdomain layer
: 앱의 비즈니스 로직에 맞게 구현된 layer로, 외부 기술(ex. API) 등에 의존하지 않는 것이 핵심 (API가 바뀌어도 data layer만 바뀌고 domain layer는 영향을 받지 않게 설계해야함)presentation layer
: UI를 그리고 사용자의 입력을 처리하는 layer
와플의 다른 안드로이드 프로젝트도 거의 다 이렇게 나누는 것 같습니다. 아래는 제가 재구성해본 디렉토리 구조입니다! 참고하시면 좋을 것 같아요.
com.example.memowithtags
├── data // domain layer에서 필요한 데이터를 서버 혹은 local에서 불러오는 layer
│ ├── remote // 향후 local에 데이터 저장을 할 경우 remote/local 구분 필요
│ │ ├── model // API 형식에 의존하는 model (ex. MemoDto.kt)
│ │ ├── api // API interface
│ │ ├── request/response // API DTO
│ └── repositoryImpl // Repository 구현체
│
├── domain // 앱의 비즈니스 로직에 맞게 구현된 layer (말 그대로 domain의 역할을 함)
│ ├── model // API 형식에 의존하지 않는 model (ex. Memo.kt, enums, results 등)
│ └── repository // Repository Interface
│
├── ui // activity 단위로 분리
│ ├── mainMemo
│ │ ├── fragment
│ │ ├── viewmodel
│ │ └── adapter
│ └──settings
│ ...
│
├── di // Hilt 의존성 주입 모듈
└── App.kt
2. 비동기 처리 & 에러 핸들링
제가 이미 #42 에서 언급했던 내용이기도 한데 현재 retrofit의 enqueue를 통해 서버 요청을 처리하는 과정에서 동기로 처리하다보니까 요청을 연속적으로 처리해야되는 경우에 callback 안에 callback이 이어지는 구조라서 좀 코드를 이해하기 힘들다는 단점이 있는 관계로 suspend 함수를 통해서 비동기로 바꾸는게 좋을 것 같습니다. (특히 main memo 쪽 로직이 복잡해서 향후 기능이 많이 추가될 경우에 현재 구조로는 개발하기 힘들 수도...)
아래는 기존 방식이랑 비동기 처리 방식을 비교해놓았습니다!
기존 방식
SignupViewModel.kt
fun sendEmail(email: String) {
repository.sendEmail(SendEmailRequest(email),
onSuccess = {
...
},
onError = {
...
}
)
}
AuthRepository.kt
fun sendEmail(
request: SendEmailRequest,
onSuccess: () -> Unit,
onError: () -> Unit
) {
authApi.sendEmail(request).enqueue(object : Callback<ResponseBody> {
override fun onResponse(call: Call<ResponseBody>, response: Response<ResponseBody>) {
val result = if (response.isSuccessful) {
SendEmailResult.Success
onSuccess()
} else {
SendEmailResult.Error(
code = response.code(),
message = response.errorBody()?.string() ?: "Unknown error"
)
}
}
override fun onFailure(call: Call<ResponseBody>, t: Throwable) {
val result = SendEmailResult.Exception(t)
onError()
}
})
}
비동기 처리
SignupViewModel.kt
fun sendEmail(email: String) {
viewModelScope.launch {
val result = repository.sendEmail(SendEmailRequest(email))
if (result is SendEmailResult.Success) {
[email protected] = email
}
_emailSentEvent.emit(result)
}
}
AuthRepository.kt
suspend fun sendEmail(request: SendEmailRequest): SendEmailResult {
return try {
val response = authApi.sendEmail(request)
if (response.isSuccessful) {
SendEmailResult.Success
} else {
SendEmailResult.Error(
code = response.code(),
message = response.errorBody()?.string() ?: "Unknown error"
)
}
} catch (e: Exception) {
SendEmailResult.Exception(e)
}
}
보시면 비동기 처기가 callback이 없기도 하고, 코드가 더 깔끔하긴합니다. 참고로 #42 에서 도입한 Result를 사용하면 에러 핸들링까지 쉽게 처리가 가능해요!
3. View Model 책임 분산
후반가서 Main Memo 쪽 작업을 하면서 MemoViewModel에서 Memo에 대한 원본 데이터를 관리하다 보니 모든 로직 (검색, 추천, 페이지네이션 등)이 하나의 ViewModel에서 이루어져서 되게 복잡해진다는 느낌을 받았습니다.
개인적으로 Memo에 대한 원본 데이터를 MemoRepository에서 저장하고, 이를 View Model에서 접근하는 방식으로 바꿔서 MemoViewModel의 역할을 축소하고, 이러면 검색, 페이지네이션 등의 로직 또한 개별의 View Model에서 관리가 가능할 것 같아서 유지보수 하기에 더 좋을 것 같습니다.
또한 더 이상 데이터가 activity안에 한정되어 있지 않아 앱 시작할 때 데이터 불러오는 등의 과정이 delay가 느껴지지 않도록 자연스럽게 이루어져서 사용자 경험도 좋아질 것 같습니다. (이미 #44 main memo에서 리사이클러뷰 맨 밑에서 시작하게 하기 위해서 repository에 데이터 저장하도록 수정하신 것 같네요. memo 말고도 tag도 이런식으로 관리하면 좋을 것 같아요)
4. Adapter
제가 Adapter 관련 애니메이션을 작업하면서 느낀 점들인데 Adapter가 기본적으로 view를 재활용해서 사용하다보니 binding을 할 때 상태에 따른 설정 (메모가 펼쳐져 있는지의 유무, highlight 유무 등)을 제대로 하지 않으면 메모의 높이가 이상해지는 등의 문제가 많이 발생했었고, 이 때문에 상당히 힘들었습니다.
제가 따로 말은 안했는데 #40 을 작업하면서 사실 생체인증 사용해서 메모를 잠구는 기능을 구현할려고 시도했었는데 위의 문제로 인해 스트레스를 받아서 접었던 경험이 있습니다... 메모가 잠금되어 있는 상태에서는 메모를 꾹 눌렀을 때 팝업창이 뜨면 안되는데 이게 binding할 때 view를 재활용해서 eventlistener가 미리 설정되어 있어서 그런지 막기가 되게 힘들었습니다.
현재 memo adapter의 bind() 함수 안에 데이터 binding, view 설정, eventListener가 모두 섞여 있는 상태인데 이걸 좀 분리시키는 작업이 필요할 것 같습니다. bindData
, setupViewState
, setupListeners
이런 식으로 멤버 함수로 나눠두고, 메모 조건에 따라서 실행시키는 구조로 바꾸는게 정신적으로 이로울 것 같네요. 참고로 뷰 상태 초기화를 되게 철저히해야 이런 문제가 잘 발생하지 않습니다. 화이팅!
Bugs
작업하면서 찾은 버그들을 정리해놓으면 나중에 고치기 좋을 것 같아서 남겨놓습니다. 고쳐주실거라 믿어요! ㅎ...
- 메모 생성 및 메모 편집화면에서 태그 생성 EditTextView에서 커서가 안나타남
- 메모 간편 수정 -> 메모 편집 화면 이동 후 다시 메인 메모 페이지로 돌아오면 메모 생성 란에 텍스트가 남아있음
- 페이지네이션 시 로딩 때문에 위로 스크롤이 일시적이 막힌 경우, 위로 계속 스크롤하면 다음 페이지로 못넘어감 (반드시 조금 내렸다가 올려야함)
- 큰 문제가 아닐 순 있어도 '로딩하고 있다'라는 정보를 사용자가 인식할 수 있는 수단이 없어서 사용자 경험에 부정적일 수 있음.
- 다만 그냥 페이지 로딩할 때 로딩한다는 아이콘? 같은 걸 띄워서 헷갈리지 않게 하면 될듯함
- 태그 수정 화면에서 키보드가 올라오는 경우 밑의 '완료' 버튼도 같이 올라옴 (고칠려고 했으나 생각보다 까다로운 문제여서 놔둠)
- 키보드가 올라올 때 layout이 가려지면 자동으로 layout이 조정되도록 하는데, 이걸 개별 xml에서 변경하기는 어렵고 activity 단위로 변경이 가능해서 수정하면 main memo 쪽이 고장나서 건드리지 않았음.
- 태그 수정 화면에서 color palette에 inner shadow를 추가해야되는데 xml에서 지원하지 않는 기능이라 구현이 어려움. 찾아봤을 땐 Jetpack Compose로는 그래도 괜찮게 구현이 가능한데 xml로는 좋은 퀄리티의 inner shadow가 가능한지 모르겠음.
- 메모 간편 수정에서 메모 recommend에서 recommend 된 메모 중 하나를 삭제하고 위아래 버튼 누르면 메모가 존재하지 않다고 Toast가 뜸. 어느 정도 의도된 동작인지는 모르겠으나 메모 생성 혹은 삭제 시에 recommend를 리셋시키고 다시 시작시키는 방식이 좋지 않을까 생각됨. ( + 간편 수정 시 메모 생성이랑 구분되도록 버튼 추가 필요)