
Flutter에서 콜백 지옥(Callback Hell) 탈출하기
Flutter 개발을 하다 보면 콜백 함수가 중첩되면서 코드가 깊어지는 현상을 자주 마주치게 된다. 이른바 ‘콜백 지옥(Callback Hell)’이라 불리는 이 현상에 대해 알아보고, 이를 개선하는 방법을 살펴보자.
문제가 되는 코드 예시
다음은 실제 프로젝트에서 흔히 볼 수 있는 콜백 지옥의 예시다:
void handleUserAction() {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('사용자 정보 입력'),
content: TextField(
onChanged: (value) {
setState(() {
if (value.isNotEmpty) {
api.validateInput(value, (isValid) {
if (isValid) {
api.processData(value, (result) {
if (result.success) {
api.saveData(result.data, (success) {
if (success) {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
content: Text('저장 완료!'),
);
},
);
}
});
}
});
}
});
}
});
},
),
);
},
);
}
이 코드의 문제점:
- 가독성이 매우 떨어짐
- 에러 처리가 어려움
- 코드 유지보수가 힘듦
- 디버깅이 복잡함
- 비동기 처리 흐름 파악이 어려움
해결 방법 1: async/await 활용
가장 간단한 해결 방법은 async/await를 활용하는 것이다:
Future<void> handleUserAction() async {
final value = await _showInputDialog();
if (value == null || value.isEmpty) return;
final isValid = await api.validateInput(value);
if (!isValid) {
_showErrorDialog('유효하지 않은 입력입니다.');
return;
}
final result = await api.processData(value);
if (!result.success) {
_showErrorDialog('처리 중 오류가 발생했습니다.');
return;
}
final success = await api.saveData(result.data);
if (success) {
_showSuccessDialog('저장이 완료되었습니다!');
}
}
Future<String?> _showInputDialog() async {
return showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: const Text('사용자 정보 입력'),
content: TextField(
controller: _textController,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('취소'),
),
TextButton(
onPressed: () => Navigator.pop(context, _textController.text),
child: const Text('확인'),
),
],
),
);
}
해결 방법 2: 함수 분리
복잡한 로직을 작은 함수들로 분리하여 관리할 수 있다:
class UserDataManager {
Future<bool> processUserInput(String value) async {
if (!await _validateInput(value)) {
return false;
}
final processedData = await _processData(value);
if (processedData == null) {
return false;
}
return await _saveData(processedData);
}
Future<bool> _validateInput(String value) async {
try {
return await api.validateInput(value);
} catch (e) {
_handleError('유효성 검사 실패', e);
return false;
}
}
Future<ProcessedData?> _processData(String value) async {
try {
final result = await api.processData(value);
if (!result.success) {
_handleError('데이터 처리 실패', null);
return null;
}
return result.data;
} catch (e) {
_handleError('데이터 처리 중 오류', e);
return null;
}
}
Future<bool> _saveData(ProcessedData data) async {
try {
return await api.saveData(data);
} catch (e) {
_handleError('저장 실패', e);
return false;
}
}
}
해결 방법 3: Stream 활용
연속된 데이터 처리가 필요한 경우 Stream을 활용할 수 있다:
Stream<ProcessingState> processUserData(String input) async* {
yield ProcessingState.validating;
final isValid = await api.validateInput(input);
if (!isValid) {
yield ProcessingState.invalidInput;
return;
}
yield ProcessingState.processing;
final result = await api.processData(input);
if (!result.success) {
yield ProcessingState.processingError;
return;
}
yield ProcessingState.saving;
final success = await api.saveData(result.data);
yield success
? ProcessingState.completed
: ProcessingState.savingError;
}
// 사용 예시
StreamBuilder<ProcessingState>(
stream: processUserData(inputValue),
builder: (context, snapshot) {
final state = snapshot.data;
switch (state) {
case ProcessingState.validating:
return const Text('유효성 검사 중...');
case ProcessingState.processing:
return const Text('처리 중...');
case ProcessingState.saving:
return const Text('저장 중...');
case ProcessingState.completed:
return const Text('완료!');
case ProcessingState.invalidInput:
return const Text('유효하지 않은 입력입니다.');
case ProcessingState.processingError:
return const Text('처리 중 오류가 발생했습니다.');
case ProcessingState.savingError:
return const Text('저장 중 오류가 발생했습니다.');
default:
return const SizedBox.shrink();
}
},
)
정리
콜백 지옥을 피하는 핵심 원칙:
- async/await 적극 활용
- 함수를 작은 단위로 분리
- 적절한 에러 처리 추가
- 가능한 경우 Stream 활용
- 명확한 상태 관리
이러한 방법들을 활용하면 코드의 가독성과 유지보수성을 크게 향상시킬 수 있다.
참고 자료