Skip to content

친구_검색_화면_개선(QA_피드백_반영)

yujin45 edited this page Dec 2, 2024 · 4 revisions

✅ 진행한 기능

✅ 개선 사항

  1. 검색 화면 스크린 분리

    1️⃣ 키보드에 가려지는 문제 ⇒ UI 개선으로 해결하기

    • 검색 UI를 새로운 화면(Screen)으로 분리하여 관리.
    • 검색 화면 전환 시 ViewModel 상태와 데이터를 유지.
  2. 검색 조건에 따른 결과 불일치 문제 해결

    2️⃣ 검색 지우기 할 때 반영 안되는 문제

    • 검색어 입력/삭제 시 UI 상태와 Firestore 결과가 불일치하는 문제 해결.
  3. Firestore 쿼리 중복 호출 방지

    • 빠른 검색어 변경 시 발생하는 Firestore 쿼리 중복 호출 문제를 해결하여 최신 상태 유지.
  4. 검색 결과가 없을 경우 안내 메시지 추가

    • 검색 결과가 없을 때, 사용자에게 "검색 결과 없음" 메시지를 표시.

☑️ 짝팀QA 피드백 반영

☑️ 검색 조건에 따라 결과가 불일치하는 문제 해결 (e.g., yyu).

☑️ "a" 검색 시 여러 목록이 나타나며 검색바가 아래로 내려가는 문제 조정.

  • 여러 목록은 a로 시작되는 이메일을 반환하므로 유지.

☑️ 친구 검색 화면 네트워크 대응 추가.

☑️ 친구 요청이 없을 경우 "요청 없음" 안내 메시지 표시.

☑️ 친구 없을 때 친구 아직 없음 문구 표시하기

🎥동작 영상

QA.mp4

☑️ 친구 없을 때 친구 아직 없음 문구 표시하기

💡문제 해결 과정 기록

1️⃣ 키보드에 가려지는 문제 ⇒ UI 개선으로 해결하기

  • 검색 페이지는 보통 새로 열리는 경우가 많다는 팀원들의 조언으로 레퍼런스를 참고해본 결과 배민, 디스코드 등 검색 시 새로운 페이지가 열리는 경우가 많았다. 이를 참고하여 친구 검색 탭을 누르면 새 페이지로 이동해서 검색할 수 있도록 할 계획이다.

이전 문제 상황 영상

default.mp4

💡Navigation에 무엇을 넘겨주어야 하는가에 대한 고민 사항

지금 MyPage에서 FriendViewModel을 쓴다. 근데 검색할 때도 같은 uid로 뷰모델을 사용해야 한다.

  1. 원래 하던 대로 모든 함수와 상태를 전달하면 되는가?
    • 시도하다가 이건 아닌 것 같다는 느낌이 들었다. 스크린이 바뀌는데 거기에 호이스팅이라니 좀 이상하다.
  2. 네비게이션에서 뷰모델을 넘겨줘야 하나?
    • 뷰모델 넘겨주는 것으로 하다가 뭔가 복잡해졌다. 그리고 상태와 함수만 넘겨줘서 호이스팅 하던 것인데 이렇게 뷰모델을 넘기면 의미 없게 된다. 이건 아닌 것 같아서 그만두었다.
  3. CurrentUid만 넘겨주고 FriendViewModel을 초기화 하여 사용? 뷰모델을 또 만들어버리다니!
    • 일단 이렇게 진행했다. 이 과정에서도 험난한 네비게이션 과정이 있었다. 사실 지금 코드 이해 못했다. 짜맞춘 것이라 이해와 개선이 필요하다.
    • 아무튼 이 상태에서 로그로 해시값 출력 결과, MyPage에서 FriendViewModel이 소멸되지 않고 있는 상태에서 검색화면 전환 시 새롭게 생성되어 쓰인다. 홈화면으로 가면 뷰모델 클리어 되었는데 왜 안 없어질까? 홈화면으로 가는 것은 백스택에서 제거 되어서 그런 것인가? (답해줘요 고수들)
    • 친구 뷰모델을 공유하고 싶은데 어떻게 해야할지 모르겠다. 이전 xml에서는 activity에서 생성해서 관리했지만, compose는 어떻게 관리하는지 아직 모른다. 어떻게 하면 좋을까?
    • 일단 돌아는 가는 더럽고 완전히는 이해 못한 코드를 두고, 이 고민까지 해결할까?!했으나 지금 시각 5시. 우리 고수 팀원들에게 물어보면 답이 빠르게 나올 것 같으므로 이렇게 고민을 정리해둔다.

💡 형준님께서 관련 PR 링크를 주셨다.

https://github.com/boostcampwm-2024/and04-Nature-Album/pull/180/commits/301472830a7a8ecf48d1a3467de64d27181a7ff2

  • 위에서 진행한 것처럼 하려고 했는데, 어제 새벽 Navigation 코드를 보며 진행한 것과 동일했다. 그런데 자꾸 안돼서 왜 안되지 하고 있었는데, 아니 이럴 수가!
  • getNavBackStackEntry()가 이름만 보고 이전 BackStackEntry()를 가져오는 것으로 알고 있었는데, 아무리 생각해도 이상해서 들어가보니 저렇게 설정되어 있었다! getNavBackStackEntry()인데 이제 SavePhoto.route만 가져오는 것이다.

image

  • 그래서 해당 부분을 통합하여 어느 BackStackEntry로 가는지 명확하게 해주었다.

image

  • 위와 같은 형식으로 이전 BackStackEntry에서 viewmodel을 받아온 결과 아래와 같이 hashcode도 동일하게 유지되는 것을 확인했다.

image

  • 이제 currentUid를 넘기는 것도 삭제하고 viewmdoel만 backstackEntry에서 가져오도록 수정했다.

2️⃣ 검색 지우기 할 때 반영 안되는 문제

  • abc를 입력하고 지우면 그 쿼리에 대한 결과가 반영되지 않는 문제가 있었다.
default.mp4
  • 원래는 repository에서 전체 user 가져오게 하지 않기 위해서 filter를 걸어서 빈 query는 쓰지 않았다. 근데 이제 빈 query 처리해주기 위해 아래의 filter 부분을 삭제했다.
private fun startSearchQueryListener() {
        viewModelScope.launch {
            _searchQuery
                .debounce(debouncePeriod)
                **.filter { query -> query.isNotBlank() }**
                .distinctUntilChanged()
                .collect { query ->
                    _currentUid.value?.let { currentUid ->
                        fetchFilteredUsersAsFlow(currentUid, query)
                    }
                }
        }
    }
  • 삭제 후 user가 전체 반환되어 아래의 repository함수를 살펴봄. 빈 쿼리일 때의 처리가 필요했다.
override fun searchUsersAsFlow(
        uid: String,
        query: String
    ): Flow<Map<String, FirestoreUserWithStatus>> =
        callbackFlow {
            val listener = fireStore.collection(USER)
                .whereGreaterThanOrEqualTo(EMAIL, query)
                .whereLessThanOrEqualTo(EMAIL, query + QUERY_SUFFIX)
                .addSnapshotListener { snapshot, e ->
                    if (e != null) {
                        Log.e("searchUsersAsFlow", "Listen failed: ${e.message}")
                        trySend(emptyMap())
                        return@addSnapshotListener
                    }

                    val userMap = mutableMapOf<String, FirestoreUserWithStatus>()

                    snapshot?.documents?.forEach { userDoc ->
  • 그래서 아래와 같이 변경하여 빈 query가 오면 빈 값 바로 반환하도록 했다.
 override fun searchUsersAsFlow(
        uid: String,
        query: String
    ): Flow<Map<String, FirestoreUserWithStatus>> =
        callbackFlow {
            if (query.isBlank()) {
                trySend(emptyMap())
                close()
                return@callbackFlow
            }

3️⃣ debounce로 인해 쿼리 되지 않는 문제

  • 위처럼 빈 query가 오면 빈 값이 오도록 했으나, 아래와 같이 빠르게 지우다 보면 갑자기 다 지우고서 값이 와서 화면에 그려지는 경우가 생겼다.

image

  • 지우기를 빠르게 할 때 searchResults가 갑자기 3으로 변하는 현상은 Firestore 데이터 갱신 타이밍과 ViewModel 상태 업데이트 순서 때문이었다.

원인 분석

  1. Firestore의 비동기 작업 중복 호출
    • 사용자가 빠르게 입력을 지우면, debounce가 이전 작업을 취소하고 새로 호출한다.
    • Firestore 쿼리는 비동기로 실행되며, 결과가 늦게 도착하면 이전 쿼리 결과가 UI에 반영된다.
    • 예를 들어:
      1. "abc" → Firestore 쿼리 실행 중.
      2. 빈 쿼리 → searchResults를 빈 상태로 설정.
      3. 이전 "abc" 쿼리 결과가 나중에 도착 → searchResults가 다시 갱신.
  2. 결과 데이터의 중복 처리
    • Firestore 쿼리가 동시에 여러 번 실행되면, 이전 쿼리의 결과가 늦게 도착하면서 상태가 다시 갱신된다.
    • 이전 쿼리 결과와 새로운 쿼리 결과가 서로 영향을 미친다.
  3. debounce와 ViewModel 상태 관리 문제
    • debounce로 인해 쿼리가 연속적으로 취소되고 재시작되면서 상태 갱신이 일관되지 않을 수 있게 된 것이다.
    • 빈 쿼리에서 emptyMap()으로 초기화되었지만, 비동기로 Firestore에서 이전 쿼리의 결과가 반환되면서 다시 상태가 업데이트되어서 저런 결과가 나온 것이다.

해결 방안: Firestore 쿼리 타이밍 관리

Firestore 쿼리를 최신 상태로 유지하고 이전 쿼리의 결과가 나중에 반영되지 않도록 Job을 사용하여 이전 쿼리를 취소한다.

FriendViewModel 수정 전

        private fun fetchFilteredUsersAsFlow(currentUid: String, query: String) {
        viewModelScope.launch {
            fireBaseRepository.searchUsersAsFlow(currentUid, query).collectLatest { results ->
                _searchResults.value = results
            }
        }
    }

FriendViewModel 수정 후

private var currentSearchJob: Job? = null

private fun fetchFilteredUsersAsFlow(currentUid: String, query: String) {
    currentSearchJob?.cancel() // 이전 작업 취소
    currentSearchJob = viewModelScope.launch {
        if (query.isBlank()) {
            _searchResults.value = emptyMap()
        } else {
            fireBaseRepository.searchUsersAsFlow(currentUid, query).collectLatest { results ->
                _searchResults.value = results
            }
        }
    }
}
  • 제대로 지워지는 것에 대해서 이전 쿼리 결과가 취소되어 빈 값이 왔다.

image

  • 저렇게 두면 repository에 빈 쿼리가 갈 일이 없어지므로 아래의 코드는 삭제된다. 그렇지만 viewmodel에서 빈 쿼리인 것을 체크한다면 viewmodel의 역할이 가중되므로 이는 query를 search하는 곳에서 처리하는 것이 맞다고 판단했다. 그래서 아래를 그대로 유지하고 viewmodel에서 빈 쿼리여도 넘겨서 searchUsersAsFlow에서 처리하도록 했다.
 override fun searchUsersAsFlow(
        uid: String,
        query: String
    ): Flow<Map<String, FirestoreUserWithStatus>> =
        callbackFlow {
            if (query.isBlank()) {
                trySend(emptyMap())
                close()
                return@callbackFlow
            }

❓ 아래의 상황에서 어떤 문제가 발생할까요?

  • Firestore 쿼리를 최신 상태로 유지하고 이전 쿼리의 결과가 나중에 반영되지 않도록 Job을 사용하여 이전 쿼리를 취소한다.
  • 이렇게 해서 빠르게 글자를 지웠을 때 이전에 요청해둔 job이 검색 결과 리스트에 반영되지 않아서 원하는 화면 결과가 나올 수 있었다.
  • 그러나 내부적으로 문제가 발생한다. 과연 무슨 문제일까? 코드를 보고 유추해보자.
 // FriendViewModel.kt
 private var currentSearchJob: Job? = null

    private fun fetchFilteredUsersAsFlow(currentUid: String, query: String) {
        currentSearchJob?.cancel()
        currentSearchJob = viewModelScope.launch {
            fireBaseRepository.searchUsersAsFlow(currentUid, query).collectLatest { results ->
                _searchResults.value = results
            }
        }
    }
// FireBaseRepository.kt
// 검색했을 경우
    override fun searchUsersAsFlow(
        uid: String,
        query: String
    ): Flow<Map<String, FirestoreUserWithStatus>> =
        callbackFlow {
            if (query.isBlank()) {
                trySend(emptyMap())
                close()
                return@callbackFlow
            }

            val listener = fireStore.collection(USER)
                .whereGreaterThanOrEqualTo(EMAIL, query)
                .whereLessThanOrEqualTo(EMAIL, query + QUERY_SUFFIX)
                .addSnapshotListener { snapshot, e ->
                    if (e != null) {
                        Log.e("searchUsersAsFlow", "Listen failed: ${e.message}")
                        trySend(emptyMap())
                        return@addSnapshotListener
                    }

                    val userMap = mutableMapOf<String, FirestoreUserWithStatus>()

                    snapshot?.documents?.forEach { userDoc ->
                        launch {
                            if (userDoc.id == uid) return@launch

                            val user = userDoc.toObject(FirestoreUser::class.java) ?: return@launch
                            var friendStatus = FriendStatus.NORMAL

                            try {
                                val (friendRequestDoc, friendDoc) = listOf(
                                    fireStore.collection(USER)
                                        .document(userDoc.id)
                                        .collection(FRIEND_REQUESTS)
                                        .document(uid)
                                        .get(),
                                    fireStore.collection(USER)
                                        .document(uid)
                                        .collection(FRIENDS)
                                        .document(userDoc.id)
                                        .get()
                                ).map { taskResult -> taskResult.await() }

                                // 친구 상태 결정
                                friendStatus = when {
                                    friendDoc.exists() -> FriendStatus.FRIEND
                                    friendRequestDoc.exists() -> {
                                        val request =
                                            friendRequestDoc.toObject(FirebaseFriendRequest::class.java)
                                        // 상대방(equest?.status) 기준 => 현재 uid 에게 보냈는지, 받았는지 확인
                                        if (request?.status == FriendStatus.RECEIVED) {
                                            FriendStatus.SENT // 현재 uid 기준 [상대방 RECEIVED : 나  SENT]
                                        } else {
                                            FriendStatus.RECEIVED // 현재 uid 기준 [상대방 SENT : 나  RECEIVED]
                                        }
                                    }

                                    else -> FriendStatus.NORMAL
                                }
                            } catch (ex: Exception) {
                                Log.e("searchUsersAsFlow", "Error: ${ex.message}")
                            }

                            userMap[userDoc.id] = FirestoreUserWithStatus(
                                user = user,
                                status = friendStatus
                            )

                            trySend(userMap).isSuccess
                        }
                    }
                }

            awaitClose { listener.remove() }
        }
  • 바로 다음과 같은 문제이다.

image

  • 아래와 같이 isActive로 확인도 해봤다
if (!isActive) return@launch

문제 정의

  1. isActive의 동작 범위:
    • isActive현재 코루틴 컨텍스트의 활성 상태를 나타낸다.
    • 상위 Job(예: viewModelScope.launch에서 생성된 currentSearchJob)이 취소되면, 하위 코루틴도 취소되며 isActivefalse가 된다.
    • 하지만, isActive를 통해 확인하는 것은 현재 실행 중인 코루틴 컨텍스트에 한정되며, 상위 Job의 취소 여부를 직접 확인하지는 않는다.
  2. 현재 문제:
    • viewModelScope에서 시작된 상위 Job이 취소되면, searchUsersAsFlow 내부의 하위 launch들도 함께 취소된다.
    • 이 과정에서 CancellationException이 발생하며, 이를 명시적으로 처리하지 않으면 예외가 전파되거나 리소스가 제대로 정리되지 않을 가능성이 있다.

image

해결 진행

  • Job 추적 리스트 관리:

    • jobList를 생성하여 각 launch에서 생성된 Job을 추가로 관리한다.
    • 이는 취소된 Job을 정리하거나 awaitClose에서 모든 Job을 한 번에 처리하기 위함이다.
  • 개별 Job 취소:

    • try-catch 블록에서 발생하는 CancellationException을 명시적으로 처리한다.

    • 예외 발생 시 [email protected]()을 호출하여, 해당 Job을 안전하게 취소하고 리소스를 정리한다.

      image

  • awaitClose에서 전체 Job 정리:

    • Flow가 닫힐 때(awaitClose), Listener를 제거하고 jobList의 모든 Job을 취소하여 리소스를 정리한다.
  • 로그를 활용한 디버깅:

    • Job의 생성, 취소, 정리 상태를 로그로 출력하여 실행 상태를 추적하고 문제를 디버깅한다.

해결 완료

  • Job 생성 및 추가:

    • snapshot?.documents?.forEach에서 각 문서를 처리할 때, launch를 사용하여 새로운 Job을 생성한다.
    • 생성된 Job을 jobList에 추가한다.
  • 취소 상태 확인:

    • try-catch 블록을 사용하여 CancellationException과 기타 예외를 처리한다.
    • CancellationException 발생 시 [email protected]()을 호출하여 Job을 명시적으로 취소한다.
  • awaitClose에서 리소스 정리:

    • Listener를 제거하고, jobList의 모든 Job을 순회하며 cancel()을 호출한다.
    • jobList.clear()를 호출하여 리스트를 초기화한다.
  • 코드

     // 검색했을 경우
        override fun searchUsersAsFlow(
            uid: String,
            query: String
        ): Flow<Map<String, FirestoreUserWithStatus>> =
            callbackFlow {
                if (query.isBlank()) {
                    trySend(emptyMap())
                    close()
                    return@callbackFlow
                }
                val jobList = mutableListOf<Job>()
                val listener = fireStore.collection(USER)
                    .whereGreaterThanOrEqualTo(EMAIL, query)
                    .whereLessThanOrEqualTo(EMAIL, query + QUERY_SUFFIX)
                    .addSnapshotListener { snapshot, e ->
                        if (e != null) {
                            Log.e("searchUsersAsFlow", "Listen failed: ${e.message}")
                            trySend(emptyMap())
                            return@addSnapshotListener
                        }
    
                        val userMap = mutableMapOf<String, FirestoreUserWithStatus>()
    
                        snapshot?.documents?.forEach { userDoc ->
                            val job = launch {
                                Log.d("Flow", "Coroutine Context: $coroutineContext $isActive")
                                if (userDoc.id == uid) return@launch
    
                                val user = userDoc.toObject(FirestoreUser::class.java) ?: return@launch
                                var friendStatus = FriendStatus.NORMAL
    
                                try {
                                    Log.d("Flow", "searchUsersAsFlow Coroutine Context: $coroutineContext $isActive")
                                    val (friendRequestDoc, friendDoc) = listOf(
                                        fireStore.collection(USER)
                                            .document(userDoc.id)
                                            .collection(FRIEND_REQUESTS)
                                            .document(uid)
                                            .get(),
                                        fireStore.collection(USER)
                                            .document(uid)
                                            .collection(FRIENDS)
                                            .document(userDoc.id)
                                            .get()
                                    ).map { taskResult -> taskResult.await() }
    
                                    // 친구 상태 결정
                                    friendStatus = when {
                                        friendDoc.exists() -> FriendStatus.FRIEND
                                        friendRequestDoc.exists() -> {
                                            val request =
                                                friendRequestDoc.toObject(FirebaseFriendRequest::class.java)
                                            // 상대방(equest?.status) 기준 => 현재 uid 에게 보냈는지, 받았는지 확인
                                            if (request?.status == FriendStatus.RECEIVED) {
                                                FriendStatus.SENT // 현재 uid 기준 [상대방 RECEIVED : 나  SENT]
                                            } else {
                                                FriendStatus.RECEIVED // 현재 uid 기준 [상대방 SENT : 나  RECEIVED]
                                            }
                                        }
    
                                        else -> FriendStatus.NORMAL
                                    }
    
                                    userMap[userDoc.id] = FirestoreUserWithStatus(
                                        user = user,
                                        status = friendStatus
                                    )
    
                                    trySend(userMap).isSuccess
                                } catch (ex: CancellationException) {
                                    Log.e("searchUsersAsFlow", "Coroutine was cancelled: ${ex.message} - ${this.coroutineContext}")
                                    this@launch.cancel()
                                } catch (ex: Exception) {
                                    Log.e("searchUsersAsFlow", "Error: ${ex.message}")
                                    this@launch.cancel()
                                } finally {
                                    Log.d("searchUsersAsFlow", "Cleaning up job for userDoc ${userDoc.id}")
                                }
    
                            }
                            jobList.add(job)
                            Log.d("searchUsersAsFlow", "jobList.add(job) ${jobList.toString()}")
                        }
                    }
    
                awaitClose {
                    listener.remove()
                    jobList.forEach { job: Job -> job.cancel() }
                    jobList.clear()
                }
            }

☑️ 짝팀 QA 피드백 반영

  • 목록은 상위에 적힌 체크리스트 참고

📌 다음 작업 예정

  1. 스크롤 시 UI 개선 작업.
  2. 친구 요청 알림 딥링크 추가.

Job 관리에 대한 느낀 점

이번 작업을 통해 Firestore와 연동한 비동기 작업에서 Job 관리를 명시적으로 하는 것의 중요성을 다시 한번 실감했다. 특히, 친구 검색 기능을 구현하면서 빠르게 입력을 변경하거나 검색 쿼리를 삭제했을 때 이전 Job이 취소되지 않아 UI에 잘못된 결과가 반영되는 문제가 발생했다. 이 문제를 해결하기 위해 Job을 명시적으로 관리하는 방식을 도입했다.

문제 상황

친구 검색에서 검색어를 빠르게 입력하거나 삭제할 때, 이전에 실행된 Firestore 쿼리 결과가 늦게 도착하면서 이미 취소된 Job의 결과가 UI에 반영되었다. 예를 들어, "abc"를 입력했다가 빠르게 "a"로 수정하면:

  1. "abc" 쿼리가 Firestore에 전달되고 실행됨.
  2. 사용자가 빠르게 "a"로 수정하면 이전 Job이 취소되지 않아 "abc" 결과가 늦게 도착.
  3. "a"의 결과 대신 "abc" 결과가 UI에 반영되는 문제 발생.

해결 방법

  1. Job을 명시적으로 관리: currentSearchJob 변수를 추가해 현재 실행 중인 검색 작업을 추적하고, 새 작업이 시작되기 전에 기존 작업을 cancel()로 취소하도록 구현했다.

    private var currentSearchJob: Job? = null
    
    private fun fetchFilteredUsersAsFlow(currentUid: String, query: String) {
        currentSearchJob?.cancel() // 이전 작업 취소
        currentSearchJob = viewModelScope.launch {
            if (query.isBlank()) {
                _searchResults.value = emptyMap()
            } else {
                fireBaseRepository.searchUsersAsFlow(currentUid, query).collectLatest { results ->
                    _searchResults.value = results
                }
            }
        }
    

    이를 통해 새 쿼리 요청 시 이전 Job이 반드시 취소되어 더 이상 결과가 UI에 반영되지 않도록 했다.

  2. callbackFlow 내부 작업의 추적과 정리: Firestore 쿼리 결과를 처리하기 위해 launch를 사용해 여러 코루틴 작업을 실행했으며, 각 JobjobList에 저장해 추적했다. awaitClose에서 Listener를 제거하고 jobList의 모든 Job을 취소해 리소스를 정리했다.

    val jobList = mutableListOf<Job>()
    
    snapshot?.documents?.forEach { userDoc ->
        val job = launch {
            // Firestore 작업 수행
            val user = userDoc.toObject(FirestoreUser::class.java) ?: return@launch
            val friendStatus = fetchFriendStatus(uid, userDoc.id) // 친구 상태 확인
            userMap[userDoc.id] = FirestoreUserWithStatus(user, friendStatus)
            trySend(userMap).isSuccess
        }
        jobList.add(job)
    }
    
    awaitClose {
        listener.remove()
        jobList.forEach { it.cancel() }
        jobList.clear()
    }
    

적용 결과

위 개선 작업을 통해 이전 Job이 취소되지 않아 UI에 잘못된 결과가 반영되는 문제를 해결할 수 있었다. 예를 들어, "abc" → "a"로 검색어를 빠르게 변경했을 때, 이제는 반드시 최신 쿼리 결과만 UI에 반영되도록 했다. 또한, Firestore 작업이 중복 호출되지 않아 리소스 낭비도 줄일 수 있었다.

느낀 점

이 작업을 통해 UI의 정확성을 유지하고, 중복 호출로 인한 리소스 낭비를 방지하며, 비동기 작업을 안정적으로 관리하는 방법을 배웠다. 특히 Job 추적과 명시적 취소의 중요성을 실무적으로 경험하는 계기가 되었으며, 향후 테스트와 코드 추상화를 통해 더 효율적이고 견고한 구조를 설계해야겠다고 느꼈다.

참고 링크

https://developer.android.com/kotlin/flow?hl=ko

Clone this wiki locally