서비스를 운영하는 도중, 닉네임 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 에 대한 설명하고자 한다.
우리는 상태 관리 라이브러리를 bloc 을 채택하여 사용하고 있다. bloc 에서 제공하는 수많은 기능들 중, debounce 와 EventTransformer 가 있다. 간단하게 설명하자면,
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은 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;
}
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;
}
}
}
[Debounce & EventTransformer]
[CancelableOperation]