[Flutter] 비동기 작업 취소, CancelableOperation

이상진·2025년 3월 12일
post-thumbnail

개요

서비스를 운영하는 도중, 닉네임 UX 개선이 필요하여 디자인 변경이 필요한 부분이 생겼다. 기존&변경 닉네임 UI 에 대한 Usecase는 다음과 같다.

▲ 그림 1. 닉네임 기획 변경 시안

- [기존] 닉네임 Usecase
  - 닉네임을 입력한다.
  - 입력한 닉네임을 '중복확인' 버튼을 클릭한다.
  - 닉네임을 요청보낸 뒤 서버로부터 사용가능(닉네임 중복 x) 혹은 사용불가(닉네임 중복 o)에 대한 응답을 받는다.
  
- [변경] 닉네임 Usecase
  - 닉네임을 입력한다.
  - 입력이 주어지면 1초 딜레이를 준 뒤 suffix 에 Loading Progress bar 가 돌아간다.
  - 추가적인 입력이 없는 경우 사용가능 혹은 사용불가에 대한 응답을 받아 해당 상태에 따른 UI 를 렌더링한다.
  - 1초 딜레이 동안 혹은 Loading Progress bar가 돌아가는 중 추가적인 입력이 주어지면 기존 입력에 대한 작업을 취소하고, 추가적인 입력에 대한 중복검사를 실시한다.
    (ex. 'ㅁㅁ' 입력 -> 1초 딜레이 -> Loading Progress bar('ㅁㅁ'에 대한 중복검사 수행) -> 'ㅁㅁㅁ' 입력 
    -> 'ㅁㅁ' 입력에 대한 중복검사 작업 취소 -> 1초 딜레이 -> Loading Progress bar('ㅁㅁㅁ'에 대한 중복검사 수행))  

기존 Usecase 와 변경 Usecase 의 가장 큰 차이점으로는 '중복확인' 버튼이 사라지면서, 입력이 주어졌을 때 '마지막 입력에 대해 자동으로 중복검사' 를 한다는 점이다. 기존 입력에 대한 중복검사를 취소하고 마지막 입력에 대한 중복검사만 한 뒤 상태에 따른 UI 렌더링까지를 목표로, 이를 구현하면서 겪었던 시행착오와 기능 구현하면서 얻게 된 지식인 CancelableOperation 에 대한 설명하고자 한다.

Keyword 및 중점 내용

  • Debounce
  • EventTransformer
  • CancelableOperation

Debounce 의 한계

우리는 상태 관리 라이브러리를 bloc 을 채택하여 사용하고 있다. bloc 에서 제공하는 수많은 기능들 중, debounceEventTransformer 가 있다. 간단하게 설명하자면,

EventTransformer 는 Bloc에서 이벤트가 처리되는 방식을 변환할 수 있도록 도와주는 함수
✔ 기본적으로 Bloc은 이벤트가 발생하면 순차적으로 처리하지만, eventTransformer를 사용하면 이벤트 흐름을 제어할 수 있음
✔ 여러 이벤트가 연속해서 발생할 때, 기존 이벤트를 취소하거나 지연시키거나, 특정 로직을 추가할 수 있음

보통 transformer 를 설정할 때 다음 4가지 옵션을 고려할 수 있는데,

  • debounce: 일정 시간 후 마지막 이벤트만 실행
EventTransformer<T> debounce<T>(Duration duration) {
  return (events, mapper) => 
      events.debounceTime(duration).switchMap(mapper);
}
  • restartable: 새 이벤트가 발생하면 이전 이벤트를 취소
on<CheckNickname>(
  _checkNickname,
  transformer: restartable(),
);
  • droppable: 새로운 이벤트가 들어와도 기존 이벤트가 끝날 때까지 실행하지 않음
on<CheckNickname>(
  _checkNickname,
  transformer: droppable(),
);
  • sequential: 이벤트를 순차적으로 실행 (Queue 방식)
on<CheckNickname>(
  _checkNickname,
  transformer: sequential(),
);

변경된 닉네임 Usecase 에서는 사용자의 입력이 짧은 시간 내에 많이 들어올 수 있기 때문에, 매번 들어올 때마다 중복검사를 수행하는 것이 아닌 마지막 입력에 대해서만 중복검사를 수행하면 되기 때문에, debounce 옵션을 채택했다. 다시 debounce 에 대해 좀 더 자세히 서술해보자면,

Debounce는 짧은 시간 동안 여러 번 발생하는 이벤트 중에서 마지막 이벤트만 실행하도록 하는 기술

✔ 사용 목적
• 사용자가 입력할 때마다 API 요청을 보내는 것을 방지
• 불필요한 이벤트 호출을 줄여 성능 최적화
• 사용자가 입력을 마친 후 일정 시간이 지나면 이벤트 실행

✔ 작동 방식
• 특정 시간이 지나기 전에 새로운 이벤트가 발생하면 이전 이벤트를 취소
• 가장 마지막 이벤트만 실행

처음에는 변경된 닉네임 Usecase를 구현하기 위해 입력이 여러 번 주어지는 경우 debounce를 활용하여 특정 시간 동안 마지막으로 호출된 이벤트만 처리하면 된다고 생각했다. 아래 그림에서는 기존에는 1~10까지 요청이 들어왔다면, debounce를 적용하면 300ms 간격을 두고 해당 시간 내 마지막 요청인 ‘2번’, ‘6번’, ‘10번’만 처리되는 예시를 볼 수 있다.

▲ 그림 2. debounce 작동 방식

이를 참고하여 “사용자의 입력은 보통 1~2초 이내로 끝난다” 는 전제 하에, debounce의 시간 간격을 적절히 조정하면 그 시간 동안 발생한 요청들 중 마지막 요청만 처리할 수 있을 것이라 예상했다. 따라서 debounce 의 시간을 2초 정도 여유롭게 걸어놓았을 때 2초동안 마지막 이벤트만 처리되게끔 구현했다.

  • debounce 를 적용한 코드
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:rxdart/rxdart.dart';
import 'package:value_up/core/di/locator.dart';
import 'package:value_up/features/profile/domain/usecase/check_nickname.dart';

part 'profile_nickname_event.dart';
part 'profile_nickname_state.dart';

class ProfileNicknameBloc
    extends Bloc<ProfileNicknameEvent, ProfileNicknameState> {
  final CheckNicknameUseCase checkNicknameUseCase =
      serviceLocator<CheckNicknameUseCase>();

  ProfileNicknameBloc() : super(ProfileNicknameInitial()) {
    on<Initialize>(_initialize);
    on<CheckNickname>(_checkNickname,
        transformer: debounce(const Duration(seconds: 2)));
  }

  Future<void> _initialize(
      Initialize event, Emitter<ProfileNicknameState> emit) async {
    emit(ProfileNicknameInitial());
  }

  Future<void> _checkNickname(
      CheckNickname event, Emitter<ProfileNicknameState> emit) async {
    print('${event.nickname} 검사 시작 ${DateTime.now()}');
    emit(ProfileNicknameLoading());
    await Future.delayed(Duration(seconds: 1));
    final result = await checkNicknameUseCase.execute(event.nickname);
    result.when(
      success: (data) {
        emit(data ? IsUnique(event.nickname) : IsNotUnique());
        print('${event.nickname} 검사 종료 ${DateTime.now()}');
      },
      failure: (error) {
        return;
      },
    );
  }

  EventTransformer<T> debounce<T>(Duration duration) {
    return (events, mapper) => events.debounceTime(duration).switchMap(mapper);
  }
}
  • 로그 출력
flutter: ㅇㅇ 검사 시작 2025-03-11 09:52:02.948724
flutter: ㅇㅇ 검사 종료 2025-03-11 09:52:04.032297
flutter: ㅇㅇㅇ 검사 시작 2025-03-11 09:52:05.818071
flutter: ㅇㅇㅇ 검사 종료 2025-03-11 09:52:06.865051
flutter: ㅇㅇㅇㅇ 검사 시작 2025-03-11 09:52:10.088473
flutter: ㅇㅇㅇㅇ 검사 종료 2025-03-11 09:52:11.176691
flutter: ㅇㅇㅇㅇㅇ 검사 시작 2025-03-11 09:52:12.769056
flutter: ㅇㅇㅇㅇㅇ 검사 종료 2025-03-11 09:52:13.812183

▲ 그림 3. Debounce 적용 결과

위에서 한 가지 간과했던 사실은 'debounce 시간 이 후에 들어온 이벤트에 대한 처리' 이다. 위의 영상처럼 'ㅇㅇ' 를 입력한 뒤 debounce 시간인 2초가 지나 loading progressBar가 돌아갈 때 입력이 주어진다면, 이미 _checkNickname 이 수행되었고 이 때 추가적인 이벤트인 'ㅇㅇㅇ' 이 들어왔을 때 기존의 _checkNickname 을 수행했던 작업이 emit 을 한 순간 UI 렌더링이 되었고, 병렬적으로 'ㅇㅇㅇ' 에 대한 중복검사를 수행하여 이에 따른 state도 emit 되어 UI 렌더링이 된 것이다. 따라서 위의 로그를 보면 debounce 시간 이 후 들어온 이벤트들에 대해서 모두 작업을 수행한 것을 확인할 수 있다.

▲ 그림 4. Debounce flow

따라서 debounce 의 한계는 '진행 중인 요청을 강제로 취소할 수 없다' 는 점이고, 결국 debounce 설정한 시간 내 이벤트 요청을 줄이는 데 유용하다. 또한 EventTransformer 부분에 설정했던 switchMap 을 활용하면 최신 이벤트만 실행할 순 있지만, 이미 실행된 요청은 취소하지 못한다는 점에 결국 해당 구현 방법은 기각되었다.


CancelableOperation

CancelableOperation은 Dart의 async 패키지에서 제공하는 기능으로, 실행 중인 비동기 작업을 강제로 취소할 수 있도록 도와주는 도구이다. 핵심 기능으로는 다음과 같다.

✔ 진행 중인 비동기 작업을 강제로 취소 가능
✔ 취소된 작업은 더 이상 실행되지 않음
✔ 새로운 요청이 들어오면 기존 요청을 즉시 중단하고 최신 요청만 유지
✔ 사용자가 빠르게 입력할 경우, 마지막 입력만 처리하도록 최적화 가능

  • 새로운 요청이 들어오면 기존 요청을 중단하고 최신 요청만 실행하는 핸들러
Future<void> _handleCheckNicknameRequest(
      CheckNickname event, Emitter<ProfileNicknameState> emit) async {
    // 이전 요청이 있으면 취소
    _activeOperation?.cancel();
    _cancelCompleter?.complete(); // 이전 요청이 있을 경우 강제 종료

    emit(ProfileNicknameLoading());

    // 새로운 Completer 생성
    _cancelCompleter = Completer<void>();

    // CancelableOperation으로 새로운 작업 실행
    _activeOperation = CancelableOperation.fromFuture(
      _executeNicknameValidation(event, emit, _cancelCompleter!),
      onCancel: () {
        // 검사 요청 취소됨
        print("닉네임 검사 요청 취소됨: ${event.nickname}");
      },
    );

    await _activeOperation?.value;
  }
  • cancelCompleter.isCompleted 를 이용하여 작업 취소
Future<void> _executeNicknameValidation(
      CheckNickname event,
      Emitter<ProfileNicknameState> emit,
      Completer<void> cancelCompleter) async {
    if (event.nickname.isEmpty) {
      emit(ProfileNicknameInitial());
      return;
    }

    try {
      print("닉네임 검사 시작: ${event.nickname}");

      await Future.any([
        Future.delayed(Duration(seconds: 1)), // 1초 딜레이 후 API 호출
        cancelCompleter.future, // 취소 요청이 들어오면 즉시 종료
      ]);

      if (cancelCompleter.isCompleted) {
        print("닉네임 검사 중단됨 (딜레이 중): ${event.nickname}");
        return;
      }

      final result = await checkNicknameUseCase.execute(event.nickname);

      if (cancelCompleter.isCompleted) {
        print("닉네임 검사 중단됨 (응답 후): ${event.nickname}");
        return;
      }

      result.when(
        success: (data) {
          emit(data ? IsUnique(event.nickname) : IsNotUnique());
          print("닉네임 검사 완료: ${event.nickname}");
        },
        failure: (error) {
          return;
        },
      );
    } catch (e) {
      return;
    }
  }
  • 로그 출력
flutter: 닉네임 검사 시작: ㅇ
flutter: 닉네임 검사 요청 취소됨: ㅇ
flutter: 닉네임 검사 시작: ㅇㅇ
flutter: 닉네임 검사 중단됨 (딜레이 중): ㅇ
flutter: 닉네임 검사 완료: ㅇㅇ
flutter: 닉네임 검사 시작: ㅇㅇㅇ
flutter: 닉네임 검사 요청 취소됨: ㅇㅇㅇ
flutter: 닉네임 검사 시작: ㅇㅇㅇㄷ
flutter: 닉네임 검사 중단됨 (딜레이 중): ㅇㅇㅇ
flutter: 닉네임 검사 완료: ㅇㅇㅇㄷ
flutter: 닉네임 검사 시작: ㅇㅇㅇㄷㄷ
flutter: 닉네임 검사 요청 취소됨: ㅇㅇㅇㄷㄷ
flutter: 닉네임 검사 시작: ㅇㅇㅇㄷㄷㄷ
flutter: 닉네임 검사 중단됨 (딜레이 중): ㅇㅇㅇㄷㄷ
flutter: 닉네임 검사 요청 취소됨: ㅇㅇㅇㄷㄷㄷ
flutter: 닉네임 검사 시작: ㅇㅇㅇㄷㄷㄷㄷ
flutter: 닉네임 검사 중단됨 (딜레이 중): ㅇㅇㅇㄷㄷㄷ
flutter: 닉네임 검사 완료: ㅇㅇㅇㄷㄷㄷㄷ

▲ 그림 5. CancelableOperation 적용 결과

  • 전체 코드
import 'dart:async';

import 'package:async/async.dart';
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:value_up/core/di/locator.dart';
import 'package:value_up/features/profile/domain/usecase/check_nickname.dart';

part 'profile_nickname_event.dart';
part 'profile_nickname_state.dart';

class ProfileNicknameBloc
    extends Bloc<ProfileNicknameEvent, ProfileNicknameState> {
  final CheckNicknameUseCase checkNicknameUseCase =
      serviceLocator<CheckNicknameUseCase>();

  CancelableOperation<void>? _activeOperation;
  Completer<void>? _cancelCompleter;

  ProfileNicknameBloc() : super(ProfileNicknameInitial()) {
    on<Initialize>(_initialize);
    on<CheckNickname>(_handleCheckNicknameRequest);
  }

  /// Initialize 발생 시 초기화
  Future<void> _initialize(
      Initialize event, Emitter<ProfileNicknameState> emit) async {
    emit(ProfileNicknameInitial());
  }

  /// 실행 중인 CheckNickname이 있으면 취소 후 새로운 요청 처리하는 핸들러
  Future<void> _handleCheckNicknameRequest(
      CheckNickname event, Emitter<ProfileNicknameState> emit) async {
    // 이전 요청이 있으면 취소
    _activeOperation?.cancel();
    _cancelCompleter?.complete(); // 이전 요청이 있을 경우 강제 종료

    emit(ProfileNicknameLoading());

    // 새로운 Completer 생성
    _cancelCompleter = Completer<void>();

    // CancelableOperation으로 새로운 작업 실행
    _activeOperation = CancelableOperation.fromFuture(
      _executeNicknameValidation(event, emit, _cancelCompleter!),
      onCancel: () {
        // 검사 요청 취소됨
      },
    );

    await _activeOperation?.value;
  }

  /// 닉네임 검사 API 호출
  Future<void> _executeNicknameValidation(
      CheckNickname event,
      Emitter<ProfileNicknameState> emit,
      Completer<void> cancelCompleter) async {
    if (event.nickname.isEmpty) {
      emit(ProfileNicknameInitial());
      return;
    }

    try {
      await Future.any([
        Future.delayed(Duration(seconds: 1)), // 1초 딜레이 후 API 호출
        cancelCompleter.future, // 취소 요청이 들어오면 즉시 종료
      ]);

      if (cancelCompleter.isCompleted) return;

      final result = await checkNicknameUseCase.execute(event.nickname);

      if (cancelCompleter.isCompleted) return;

      result.when(
        success: (data) {
          emit(data ? IsUnique(event.nickname) : IsNotUnique());
        },
        failure: (error) {
          return;
        },
      );
    } catch (e) {
      return;
    }
  }
}

Reference

[Debounce & EventTransformer]

[CancelableOperation]

profile
모바일 개발에 관하여 이것, 저것 다 합니다.

0개의 댓글