본문 바로가기

안드로이드 앱개발

안드로이드에서 검색기능 및 검색어 자동완성 기능 구현하기 (Debounce & Throttle

반응형

안녕하세요. 오늘은 안드로이드에서 kotlin을 이용해 안드로이드에서 검색기능 및 검색어 자동완성 기능 구현하는 방법에 대해 알아보겠습니다.

 

검색어 자동완성 기능을 구현하기 위해서는 우선적으로 Debounce의 개념을 이해해야 합니다.

 

1. Debounce란 

Debounce는 마지막 이벤트가 발생한 후 일정 시간 동안 추가적인 이벤트가 발생하지 않을 때, 마지막 이벤트를 처리합니다. 다시 말해, 연속적으로 발생하는 이벤트 중 마지막 이벤트만을 실행합니다. 이를 통해 너무 자주 발생하는 이벤트를 하나로 묶어 처리할 수 있습니다.

 

그 예시로 사용자가 검색 입력창에 빠르게 여러 문자를 입력할 때, 입력이 끝난 후 일정 시간 동안 추가 입력이 없으면 마지막 입력에 대해 검색 요청을 보냅니다.

 

이러한 Debounce의 특징을 이용하여 사용자가 검색창에 text를 입력하는 상황에서,

입력이 끝난 후 특정 시간종안 추가 입력이 없으면 그 시점에 이벤트(검색어 자동 완성)가 발생합니다.

 

만약, 사용자가 여러번 입력하면, 입력이 끝날 때 까지 이벤트가 발생하지 않다가 마지막에 한 번만 발생합니다.

 

이러한 원리를 이용하여 검색어 자동완성 기능을 구현 할 수 있게 됩니다.

 

2. 검색기능 및 검색어 자동완성기능 구현

검색기능 및 검색어 자동완성 기능을 본격적으로 구현해보겠습니다.

 

우선 UI 코드입니다.

@Composable
fun TabFriend2(
    friendViewModel: FriendViewModel,
    navController: NavHostController,
    modifier: Modifier = Modifier
) {
    val context = LocalContext.current
    var searchQuery2 by remember { mutableStateOf("") }
    val searchResults2 by friendViewModel.searchResults2.collectAsState()

    val users by friendViewModel.users.collectAsState()

    var showDetailUser by remember { mutableStateOf(false) }
    var selectedUserEmail by remember { mutableStateOf<String?>(null) }


    LaunchedEffect(Unit) {
        friendViewModel.readFriendRequest(context)
    }

    //friendViewModel.readFriendRequest(context)

    if(showDetailUser && selectedUserEmail != null){
        UserInfoScreen(
            navController = navController,
            userEmail = selectedUserEmail,
            friendViewModel = friendViewModel
        )
    } else {
        Column(
            modifier = Modifier.fillMaxWidth()
        ) {
            Text(text = "유저 검색")
            OutlinedTextField(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(8.dp),
                value = searchQuery2,
                singleLine = true,
                onValueChange = {
                    searchQuery2 = it
                    friendViewModel.onSearchQueryChange2(searchQuery2)
                },
                label = { Text("Search User") }
            )

            val itemsToShow = if (searchQuery2.isNotEmpty()) searchResults2 else users

            fun onUserClick(userEmail: String){
                navController.navigate("friend/info/$userEmail")
                Log.d("home screen", "search test route = ${userEmail}")
            }

            //search user result
            LazyColumn(contentPadding = PaddingValues(16.dp, 8.dp)) {
                items(
                    items = searchResults2,
                    //items = itemsToShow,
                    itemContent = { user ->
                        UserListItem(user = user, navController = navController) {
                            onUserClick(user.userEmail)
                        }
                    }
                )

            }

            Text(text = "친구 요청 목록")
            Divider(color = Color.Black, thickness = 1.dp)

            RecyclerViewFriendRequestContent(viewModel = friendViewModel, navController = navController, context = context){ userEmail ->
                selectedUserEmail = userEmail
                showDetailUser = true
            }
        }
    }
}

 

위 코드에서 OutlinedTextField에 입력된 값은  by remember mutableStateOf 형식으로 선언된 searchQuery2가  value 형식으로저장됩니다.

 

이후에는 viewModel의 onSearchQueryChange2 method에 searchQuery2를 parameter로 전달하게 됩니다.

 

private val searchQuery2 = MutableStateFlow("")

fun onSearchQueryChange2(keyword: String){
        searchQuery2.value = keyword
}

 

 

이에 대하여 viewModel의 onSearchQueryChange2 method에서는 해당 parameter를 viewModel에 정의한 searchQuery2변수에 저장해줍니다.

 

init {        
        viewModelScope.launch {            
            searchQuery2
                .debounce(300)
                .collectLatest { query ->
                    if(query.isNotEmpty()) {
                        searchUser(query)
                    } else {
                        _searchResults2.value = emptyList()
                    }
                }
        }

 

 

이후에는 viewModel의 init에서 searchQuery2에 대하여 debounce를 이용하여 마지막 입력 후 300ms 까지 추가적인 입력이 없다면, viewModel에 정의된 searchUser method를 이용해 입력받은 값을 parameter로 Spring에 해당 검색어와 일치하는 결과를 요청하게 됩니다.

 

fun searchUser(keyword: String){
        viewModelScope.launch(Dispatchers.IO) {
            val userEmail = userRepository.getMostRecentUserName()
            val response = SecretDiaryObject.getRetrofitSDService.searchUser(keyword, userEmail!!)
            if(response.isSuccessful){
                response.body()?.let {
                    _searchResults2.value = it
                }
            }else{
                val errorBody = response.errorBody()?.string()
                val statusCode = response.code()
                Log.d("Search_read", "Failed: StatusCode = $statusCode, Error = $errorBody")
            }
        }
    }

 

searchUser의 경우 room을 이용하여 현재 로그인한 유저의 이메일을 보내,

검색 결과에 본인은 포함되지 않게 하기위한 parameter로 사용하게 됩니다.

 

검색 결과에 대해서는 _searchResult2.value를 이용해 해당 변수에 결과를 저장하게 됩니다.

 

@GET("search/{keyword}/{userEmail}")
    suspend fun searchUser(@Path("keyword") keyword: String,  @Path("userEmail") userEmail: String): Response<List<RUserModel>>

 

이제는 viewModel에 정의한 searchUser method가 호출하는 retrofit2 API method입니다.

 

해당 method의 경우 @Path를 이용하여 Spring server에게 검색어와 본인 이메일을 건내주고 있습니다.

 

이제부터는 android에서 보낸 요청을 Spring에서 받아올 차례입니다. 

 

@GetMapping("search/{keyword}/{userEmail}")
    public List<RUserRequestDTO> searchUser2(@PathVariable("keyword") String keyword, @PathVariable("userEmail") String userEmail){
        return userService.getSearchUser2(keyword, userEmail);
    }

 

spring의 controller에서는 위와같은 방법으로 해당 요청을 받아옵니다.

 

이후에는

@Override
    public List<RUserRequestDTO> getSearchUser2(String keyword, String userEmail){
        List<User> entities = userRepository.findByEmailContainingAndEmailNot(keyword, userEmail);
        List<RUserRequestDTO> dtos = new ArrayList<>();

        for(User entity : entities){
            RUserRequestDTO userRequestDTO = new RUserRequestDTO();

            userRequestDTO.setUserId(entity.getId());
            userRequestDTO.setUserEmail(entity.getEmail());
            userRequestDTO.setUserNickName(entity.getName());
            userRequestDTO.setUserText(entity.getText());
            userRequestDTO.setUserImgPath(entity.getUserImg());
            dtos.add(userRequestDTO);
        }
        return dtos;
    }

 

repository에 JPA 문법을 이용하여 userEmail에 해당하는 유저를 제외하고, keyword를 포함하고 있는 유저만 가져오는 method를 repository에 정의하여 구현하면 됩니다.

 

 

이렇게  안드로이드에서 검색기능 및 검색어 자동완성 기능 구현하는 방법은 모두 설명하였습니다.

 

하지만 Debounce라는 개념과 같이 소개되는 Throttle에 대해서도 이번 게시물에서 알아보겠습니다.

 

 

2. Throttle이란 

Throttle은 특정 시간 간격 내에서 한 번만 이벤트를 처리하도록 제한합니다. 일정한 시간 간격을 두고 이벤트가 발생하게 하여, 일정 주기로 이벤트가 실행됩니다. 즉, 이벤트가 너무 자주 발생하는 것을 방지하고, 일정 시간 간격으로만 실행됩니다.

 

그 예시로 사용자가 페이지를 스크롤할 때, 스크롤 이벤트가 너무 자주 발생하는 것을 방지하고, 일정 간격으로 스크롤 이벤트를 처리하여 부하를 줄입니다.

 

아래는 Kotlin Coroutine을 이용한 Throttle 구현 방식예제입니다. 

import kotlinx.coroutines.flow.flow

fun <T> Flow<T>.throttleFirst(windowDuration: Long): Flow<T> = flow {
    var lastEmissionTime = 0L
    collect { value ->
        val currentTime = System.currentTimeMillis()
        if (currentTime - lastEmissionTime >= windowDuration) {
            emit(value)
            lastEmissionTime = currentTime
        }
    }
}

 

이 throttleFirst 함수는 Kotlin의 Flow에 대해 확장함수로 정의된 것으로, 일정한 시간 동안 발생한 이벤트 중 첫 번째 이벤트만 처리하고, 그 이후의 이벤트는 windowDuration에 설정한 시간까지무시하는 동작을 구현합니다.

이후에는, 다시 이벤트를 발생시키고 windowDuration에 설정한 시간까지 무시하는 동작을 반복하는 함수가 됩니다.

 

 

이렇게 kotlin을 이용해 안드로이드에서 검색기능 및 검색어 자동완성 기능 구현하는 방법에 대해서는 마무리하도록 하겠습니다.

 

다음번에는 security 관련 주제로 돌아오겠습니다.

 

반응형