멋쟁이 사자처럼에서 진행하는 안드로이드 앱 스쿨에서 멘토링을 받고 배운 지식들을 정리하는 글 입니다.
👩💻 오늘의 할 말
개인 프로젝트를 진행하던 중에 startActvity( )를 통한 액티비티 전환 시 전환하려는 액티비티가 두 개 생성되는 문제가 발생하여 멘토링을 요청하게 되었습니다.
매장 정보를 입력하는 RegistStoreInfoFragment 의 서버에 데이터를 저장하는 과정을 주석처리하고 실행을 해본결과 문제없이 동작했습니다. 그래서 이 부분이 문제의 원인임을 확인하였습니다.
서버에 데이터를 전송하는 과정은 다음과 같습니다.
문제의 원인을 생각했을 때 데이터를 저장 하는 작업이 끝나기 전에 액티비티 전환을 시도해 문제가 발생했다고 생각했습니다. 그래서 Network Inspection을 통해 데이터 전송에 약 138ms가 소요되는 것을 확인했습니다.
그래서 다음과 같이 Coroutine delay( ) 함수를 통해 지연을 주었지만 똑같은 현상이 발생했습니다.
class StoreInfoRegistrationFragment : Fragment(), View.OnClickListener {
private var _binding: FragmentStoreInfoRegistrationBinding ?= null
private val binding: FragmentStoreInfoRegistrationBinding get() = _binding!!
private val viewModel: StoreManagementViewModel by viewModels()
private val args: StoreInfoRegistrationFragmentArgs by navArgs()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentStoreInfoRegistrationBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
initView()
initActivityProcess()
}
(생략...)
override fun onClick(v: View?) {
when(v?.id){
R.id.submit_button -> {
(생략...)
else {
// 코루틴을 사용하여 백그라운드 작업 수행
CoroutineScope(Dispatchers.IO).launch{
// TranslateGeo와 같은 시간이 많이 소요되는 작업 수행
val location = TranslateGeo(addr)
val lat = location.latitude
val lng = location.longitude
if(lat == 0.0 || lng == 0.0){
withContext(Dispatchers.Main){
requestPlzInputText("올바른 주소를 입력해 주세요", addrLayout)
}
}else{
val uri: Uri? = imgUrl
if (uri != null){
CoroutineScope(Dispatchers.IO).launch {
viewModel.registrationStore(requireContext(), uri, storeName, ceoName,
crn, phone, addr, lat.toString(), lng.toString(), kind)
delay(200)
}
val intent = Intent(activity, MyStoreActivity::class.java)
intent.putExtra("FLAG", "REGISTER")
//intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
startActivity(intent)
// 부모 액티비티 종료
activity?.finish()
}
}
}
}
}
}
📖 이에 대해 멘토링에선 다음과 같은 답변을 얻을 수 있었습니다.
1️⃣ 위의 코드에선 이미 Coroutine IO Dispatcher를 사용해 코루틴 블럭을 생성했습니다. 그러므로 코루틴 내부에 또 다른 동일한 코루틴을 만드는 것은 불필요한 작업입니다.
2️⃣ 서버와의 통신은 다양한 네트워크 상황에 따라 시간이 더 오래 걸릴 수 도 있습니다. Network Inspector의 값은 다양한 사용자의 상황에 따라 변경되는 값입니다. 그러므로 200ms의 delay를 주었더라도 서버에 데이터가 모두 저장되지 않고 액티비티가 실행될 수 있습니다. 그러므로 서버에 데이터 전송이 종료가 완료된 후 액티비티를 전환하는 로직이 필요합니다.
멘토링을 통해 아래와 같은 로직을 배울 수 있었습니다.
class StoreManagementViewModel: ViewModel() {
private val networkRepository = NetworkRepository()
// 서버에 데이터 전송이 완료 되었음을 알리는 flag
private val _flag = MutableLiveData<Unit>()
val flag: LiveData<Unit> get() = _flag
fun registrationStore(context: Context, imgUri: Uri, storeName:String, ceoName: String, crn: String,
phone:String, addr: String, lat:String, lng:String, kind:String) = viewModelScope.launch(Dispatchers.IO){
// 내부 코드 순차 실행
Log.d("MyCompanyRegisterNumber", "RegistStoreViewModel / setStoreInfo : $crn")
MyDataStore().setCrn(crn)
// 이미지 절대경로 반환 후 파일 객체 생성
val file = File(absolutelyPath(imgUri, context))
// 파일 객체를 RequestBody Type으로 변환
val requestFile = file.asRequestBody("image/*".toMediaTypeOrNull())
// MultipartBody.Part Type으로 변환
val imgBody = MultipartBody.Part.createFormData("storeMainImage", file.name, requestFile)
// 매장 정보를 RequestBody Type으로 변환 후
// Retrofit으로 전송할 MyStoreInfoRequestModel 객체 생성
val myStoreInfoRequestModel = MyStoreInfoRequestModel(
imgBody,
createRequestBody(storeName),
createRequestBody(ceoName),
createRequestBody(crn),
createRequestBody(phone),
createRequestBody(addr),
createRequestBody(lat),
createRequestBody(lng),
createRequestBody(kind)
)
networkRepository.registrationStore(myStoreInfoRequestModel)
// flag가 변경 = 네트워크 통신이 종료되었다
_flag.postValue(Unit)
}
- viewModelScope.launch는 비동기로 내부 코드가 순차적으로 실행됩니다.
- 이를 활용해 먼저, LiveData 또는 stateFlow를 사용해 서버와의 통신이 완료 되었음을 알려줄 수 있는 Activity에서 관찰 가능한 멤버를 생성해 이 서버에 데이터 전송이 완료되면 flag의 값을 변경합니다.
class StoreInfoRegistrationFragment : Fragment(), View.OnClickListener {
private var _binding: FragmentStoreInfoRegistrationBinding ?= null
private val binding: FragmentStoreInfoRegistrationBinding get() = _binding!!
private val viewModel: StoreManagementViewModel by viewModels()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentStoreInfoRegistrationBinding.inflate(inflater, container, false)
initAdapter()
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
initView()
initActivityProcess()
viewModel.flag.observe(viewLifecycleOwner) {
val intent = Intent(activity, MyStoreActivity::class.java)
intent.putExtra("FLAG", "REGISTER")
//intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
startActivity(intent)
// 부모 액티비티 종료
activity?.finish()
}
}
- Fragment에선 ViewModel의 flag를 지속적으로 관찰하다 flag의 값이 변경되면 즉, 서버에 데이터 전송이 완료되면 액티비티를 전환합니다.
- 이 부분은 액티비티가 두 번 생성되는 문제와는 직접적인 연관은 없지만 서버에 안정적으로 데이터를 저장하기 위해 정말 중요한 배움이었습니다 👍
🚨 가장 크리티컬한 문제가 되는 부분은 아래 코드였습니다 🚨
위 코드에는 치명적인 오류가 있었습니다.
- CoroutineScope(Dispatchers.IO).launch 블럭은 IO 스레드가 crn이 변경되는 값을 지속해서 구독하게 되는데 이 때 생성된 스레드는 자신이 계속해서 해야할 작업이 있다면 사라지지 않습니다.
- 즉, 스레드가 액티비티나 프래그먼트가 종료되어도 계속 살아있을 수 있습니다.
- 그래서 매장을 등록했을 때 MyStoreActivity가 한번, crn의 값이 생성되어 MyStoreActivity가 두번 생성된 것이었습니다.
이를 해결하기 위해 IO 쓰레드를 생성하는 것이 아닌 생명주기를 따르는 코루틴 스코프를 생성하여 Fragment의 생명주기에 따라 Fragment 제거시 스레드를 종료 하도록 만들어 주었습니다.
📕 이외의 질문들
1️⃣ 현재 모든 매장 정보를 모델 클래스 리스트로 받고 있는데 Room에서 Flow로 반환 받는 것 처럼 이걸 개선한 더 좋은 방법이 있을까요 ?
👉 Flow는 특정 데이터의 변화를 감지하기 위한 상황에서 사용합니다. Room에서는 자신의 로컬 데이터의 변화를 감지하기 위해 사용하는데 로컬 데이터베이스의 경우 통신 상태나 트래픽에 상관 없이 데이터를 구독할 수 있어 Flow를 사용할 수 있습니다.
하지만 Flow는 비동기 데이터 스트림으로 리모트 의 경우 데이터의 변화를 알기 위해선 리모트 서버를 지속적으로 네트워크에 요청을 보내야 관찰해 하는데, 이는 네트워크 통신 상태나 서버 트래픽에 큰 영향을 주고 Flow는 이에 적합한 라이브러리가 아닙니다.
2️⃣ 하나의 액티비티에서 다수의 viewModel을 사용해도 되나요?
👉 액티비티와 뷰모델은 1:N 관계로 하나의 액티비티는 여러개의 뷰모델을 가질 수 있습니다. 단, viewModel에서 context를 전달받는 Memory Leak을 발생시키므로 금지해야 합니다. 예를 들어, XActivity와 YActivity가 ZViewModel를 사용한다고 가정했을 때, 다른 액티비티의 context가 남아있어 Memory Leak을 발생시킵니다.
예외로, Applicaiton의 Context는 ViewModel의 생명 주기보다 오래 살아있어 전달해줄 수 있고 application context 를 사용할 수 있게 만든 viewModel Class로 AndroidViewModel()가 있습니다.
📖 정리
- 다양한 네트워크 상황에 대비해 앱의 안정성을 위해 서버와 앱간의 데이터 송수신을 확인하는 로직을 구현해야 합니다.
- 스레드는 해야할 작업이 남아있다면 액티비티나 프래그먼트가 종료되도 살아있을 수 있습니다.
- 무분별한 Coroutine Dispatchers 사용은 예측하기 힘든 에러를 발생시킵니다. 액티비티나 프래그먼트에서는 생명주기를 따르는 코루틴 스코프를 사용해야 합니다.
- 액티비티와 뷰모델은 1:N 관계로 하나의 액티비티는 여러개의 뷰모델을 가질 수 있고 viewModel에 context를 전달하는 것은 Memory Leak을 발생시키므로 금지합니다.
🙇♂️ 후기
정말 많은것을 배우고 성장할 수 있는 시간이었습니다. 코루틴 사용에 나름 자신감이 생겨 여기저기 난사하고 다녔는데 크게 한번 혼나고 많이 배웠네용 😢 안드로이드 스튜디오에서 브레이크 포인트를 찍고 디버그 하는 것도 처음 해봤는데 이젠 여기저기 막 로그찍고 다니지 않아도 될 거 같습니다. 오늘 배운 내용을 토대로 프로젝트에 다른 코드도 한번 리팩토링을 해봐야겠습니다.
'TEKHIT ANDROID SCHOOL' 카테고리의 다른 글
[TEKHIT] 오늘의 멘토링 (1) | 2024.02.14 |
---|---|
[TEKHIT] 오늘의 멘토링 (0) | 2024.02.13 |
[TECKHIT] Material3 Text fields (0) | 2024.01.22 |
[TECKHIT] Material3 Button (1) | 2024.01.21 |
[TEKHIT] KOTLIN 범위 지정 함수와 배열 (0) | 2024.01.12 |