오류를 우아하게 처리하기
오류를 우아하게 처리하기
오류를 처리해서 앱의 견고함을 높여 봐요. 예외(exception), 오류(error), try/catch, throw, rethrow에 대해 배워 봐요.
본문
이 장에서는 오류를 우아하게 처리해서 애플리케이션을 더 견고하게 만들어 봐요. 예외, try/catch 블록, 그리고 구조화된 방식으로 오류를 관리하기 위한 사용자 정의 예외를 만드는 방법까지 함께 살펴볼게요.
- 오류와 예외를 구분해요.
try/catch로 실패를 처리해요.- 사용자 정의 예외를 만들고 던져 봐요.
사전 준비 (Prerequisites)
이 장을 시작하기 전에 다음을 확인해 주세요:
- 5장을 마치고
dartpedia프로젝트가 있는, 동작하는 Dart 개발 환경을 갖췄는지 확인해요. - 함수와 클래스 같은 기본 프로그래밍 개념을 이해하고 있는지 확인해요.
오류와 예외 (Errors versus exceptions)
Dart는 두 가지 주요 실패 유형을 구분해요: 오류(error) 와 예외(exception).
| 개념 | Exception |
Error |
|---|---|---|
| 의미 | 코드가 복구할 수 있는 예상 가능한 런타임 실패 | 코드의 프로그래밍 버그나 결함 |
| 흔한 예 | FormatException, HttpException, SocketException |
RangeError, TypeError, StateError |
| 전형적 원인 | 잘못된 사용자 입력, 네트워크 실패, 파일 없음 | 인덱스가 하나 벗어남(off-by-one), null에 메서드 호출 |
| 처리 방법 | 잡아서 우아하게 처리(예: try/catch) |
잡지 말고 코드의 버그를 고치기 |
예외 (Exceptions)
예외는 미리 예상해서 복구할 수 있는 상황을 나타내요. 예를 들어 사용자가 인식할 수 없는 명령줄 인자를 입력했을 때, 애플리케이션이 원시 스택 트레이스(raw stack trace)로 크래시하면 안 돼요. 대신 코드가 예외를 잡아서 친절한 오류 메시지를 보여 주고, 올바른 사용법 안내로 사용자를 유도해야 해요.
Dart에서 사용자 정의 예외는 보통 Exception 클래스를 구현하거나 FormatException 같은 기존 예외 타입을 확장해요.
오류 (Errors)
오류는 런타임에 처리하기보다 개발 중에 고쳐야 하는 로직의 버그를 나타내요. 예를 들어 리스트의 범위를 벗어난 요소에 접근하면 RangeError가 발생해요. RangeError를 잡아서 숨기려고 하면 버그를 감추고 애플리케이션을 예측 불가능한 상태로 만들 수 있어요. 대신 오류가 전파되도록 두어 스택 트레이스를 확인하고 근본적인 실수를 고치는 게 좋아요.
Dart에서 버그를 나타내는 클래스는 Error를 확장하거나 구현해요.
작업 (Tasks)
다음 작업들은 이런 원칙을 command_runner 패키지에 적용해요. 잘못된 사용자 인자를 위한 사용자 정의 예외 클래스를 정의하고, try/catch 블록으로 실패를 가로채며, 예상치 못한 오류는 전파되도록 두면서 CommandRunner가 예외를 우아하게 처리하도록 구성해요.
작업 1: 사용자 정의 ArgumentException 만들기 (Create a custom ArgumentException)
먼저 명령줄 인자와 관련된 오류를 나타내는 ArgumentException이라는 사용자 정의 예외 클래스를 정의해 봐요.
-
command_runner/lib/src/exceptions.dart파일을 만들어요. 이 파일에는ArgumentException클래스 정의가 들어가요. -
FormatException을extends하는ArgumentException이라는 클래스를 정의해 주세요:
class ArgumentException extends FormatException {
/// The command that was parsed before discovering the error.
///
/// This will be empty if the error was on the root parser.
final String? command;
/// The name of the argument that was being parsed when the error was
/// discovered.
final String? argumentName;
ArgumentException(
super.message, [
this.command,
this.argumentName,
super.source,
super.offset,
]);
@override
String toString() {
return 'ArgumentException: $message';
}
}
이 클래스는 Exception을 구현하는 Dart 내장 클래스인 FormatException을 확장해요. 잘못된 명령줄 인자는 호출자가 미리 예상해서 우아하게 처리할 수 있는 예상 가능한 조건이므로, ArgumentException은 Error가 아니라 예외로 설계했어요. 오류와 관련된 command와 argument 이름을 저장하는 추가 속성도 담고 있어요. 이는 예외를 처리할 때 더 많은 컨텍스트를 제공해 줘요.
command: 예외가 발생했을 때 처리 중이던 명령.argumentName: 예외를 일으킨 인자의 이름.
작업 2: CommandRunner에 오류 처리 구현하기 (Implement error handling in CommandRunner)
다음으로 CommandRunner 클래스를 갱신해 잠재적인 오류를 우아하게 처리해 봐요. 여기에는 오류 처리 콜백을 추가하고, try/catch로 예외를 관리하며, 사용자가 잘못된 입력을 주면 새로운 ArgumentException을 던지는 작업이 포함돼요.
- 필요한 import를 추가해요.
command_runner/lib/src/command_runner_base.dart에서 dart:async(FutureOr를 쓰기 위해)와 새 exceptions.dart 파일에 대한 import를 추가해 주세요:
import 'dart:async'; // Add this line
import 'dart:collection';
import 'dart:io';
import 'arguments.dart';
import 'exceptions.dart'; // Add this line
CommandRunner에onError콜백을 추가해요.
CommandRunner를 수정해 생성자에서 선택적 onError 함수를 받아들이게 해 봐요. 이렇게 하면 여러분 패키지의 사용자가 자신만의 오류 처리 로직을 정의할 수 있어요.
class CommandRunner {
// Add a constructor that accepts the optional callback.
CommandRunner({this.onError});
final Map<String, Command> _commands = <String, Command>{};
UnmodifiableSetView<Command> get commands =>
UnmodifiableSetView<Command>(<Command>{..._commands.values});
// Define the onError property.
FutureOr<void> Function(Object)? onError;
// The rest of the class implementation...
}
이 변경은 nullable onError 속성을 도입해요. FutureOr<void> Function(Object)? 타입은 Object 하나를 받아 Future 또는 아무것도 반환하지 않으면서 null일 수도 있는 함수를 뜻해요.
- run 메서드를
try/catch로 갱신해요.
run 메서드 안의 로직을 try/catch 블록으로 감싸요. 예외가 발생하면 이 블록이 잡아서 onError 콜백에 전달하거나, 콜백이 없으면 다시 던져요(rethrow). rethrow는 원래의 오류와 스택 트레이스를 보존해요.
Future<void> run(List<String> input) async {
// [Step 6 update] try/catch added
try {
final ArgResults results = parse(input);
if (results.command != null) {
Object? output = await results.command!.run(results);
print(output.toString());
}
} on Exception catch (exception) {
if (onError != null) {
onError!(exception);
} else {
rethrow;
}
}
}
on Exception catch (exception)의 사용을 주목해 주세요. Dart에서 그냥 catch (e)는 Error 인스턴스를 포함해 던져진 모든 객체를 가로챕니다. on Exception을 지정하면 여러분의 코드가 복구 가능한 예외만 잡고, 버그는 잡히지 않고 전파되도록 두어 고칠 수 있어요.
parse메서드에 검증을 추가해요.
마지막으로 command_runner_base.dart의 기존 parse 메서드를 다음 갱신 버전으로 교체해 봐요. 이 버전은 잘못된 사용자 입력을 감지할 때마다 사용자 정의 ArgumentException을 던지는 검사들을 포함해요.
// [Step 6 update] This method is replaced entirely.
ArgResults parse(List<String> input) {
ArgResults results = ArgResults();
if (input.isEmpty) return results;
// Throw an exception if the command is not recognized.
if (_commands.containsKey(input.first)) {
results.command = _commands[input.first];
input = input.sublist(1);
} else {
throw ArgumentException(
'The first word of input must be a command.',
null,
input.first,
);
}
// Throw an exception if multiple commands are provided.
if (results.command != null &&
input.isNotEmpty &&
_commands.containsKey(input.first)) {
throw ArgumentException(
'Input can only contain one command. Got ${input.first} and ${results.command!.name}',
null,
input.first,
);
}
// Section: Handle options, including flags.
Map<Option, Object?> inputOptions = {};
int i = 0;
while (i < input.length) {
if (input[i].startsWith('-')) {
var base = _removeDash(input[i]);
// Throw an exception if an option is not recognized for the given command.
var option = results.command!.options.firstWhere(
(option) => option.name == base || option.abbr == base,
orElse: () {
throw ArgumentException(
'Unknown option ${input[i]}',
results.command!.name,
input[i],
);
},
);
if (option.type == OptionType.flag) {
inputOptions[option] = true;
i++;
continue;
}
if (option.type == OptionType.option) {
// Throw an exception if an option requires an argument but none is given.
if (i + 1 >= input.length) {
throw ArgumentException(
'Option ${option.name} requires an argument',
results.command!.name,
option.name,
);
}
if (input[i + 1].startsWith('-')) {
throw ArgumentException(
'Option ${option.name} requires an argument, but got another option ${input[i + 1]}',
results.command!.name,
option.name,
);
}
var arg = input[i + 1];
inputOptions[option] = arg;
i++;
}
} else {
// Throw an exception if more than one positional argument is provided.
if (results.commandArg != null && results.commandArg!.isNotEmpty) {
throw ArgumentException(
'Commands can only have up to one argument.',
results.command!.name,
input[i],
);
}
results.commandArg = input[i];
}
i++;
}
results.options = inputOptions;
return results;
}
String _removeDash(String input) {
if (input.startsWith('--')) {
return input.substring(2);
}
if (input.startsWith('-')) {
return input.substring(1);
}
return input;
}
이 갱신된 parse 메서드는 이제 잘못된 입력에 적극적으로 대비해요. 특히 새 throw 문들이 여러 흔한 오류 상황을 처리해요:
- 알 수 없는 명령 (Unknown commands): 첫 번째
if/else블록이 첫 번째 인자가 유효한 명령인지 보장해요. - 여러 명령 (Multiple commands): 사용자가 한 번에 두 개 이상의 명령을 실행하려 하지 않았는지 확인해요.
- 알 수 없는 옵션 (Unknown options):
firstWhere안의orElse파라미터가 사용자가 그 명령에 정의되지 않은 플래그나 옵션(예:--foo)을 제공하면 예외를 던져요. - 옵션 값 누락 (Missing option values): 옵션(예:
--output) 뒤에 값이 따라오는지, 다른 옵션이나 입력의 끝이 아닌지 보장해요. - 인자가 너무 많음 (Too many arguments): 명령이 위치 인자(positional argument)를 하나만 가질 수 있다는 규칙을 강제해요.
작업 3: cli.dart를 새 오류 처리로 갱신하기 (Update cli.dart to use the new error handling)
cli/bin/cli.dart를 수정해 CommandRunner의 새 오류 처리를 사용해 봐요.
-
cli/bin/cli.dart파일을 열어 주세요. -
CommandRunner에onError함수를 넘기도록main함수를 갱신해 주세요:
import 'package:command_runner/command_runner.dart';
const version = '0.0.1';
void main(List<String> arguments) {
// [Step 6 update] Add onError method
var commandRunner = CommandRunner(
onError: (Object error) {
if (error is Error) {
throw error;
}
if (error is Exception) {
print(error);
}
},
)..addCommand(HelpCommand());
commandRunner.run(arguments);
}
이 코드는 CommandRunner에 onError 콜백을 전달해요. 콜백은 어떤 Error든 다시 던져서 애플리케이션이 크래시하게 하고, Exception은 콘솔에 출력해서 사용자가 오류 메시지를 볼 수 있게 해 줘요.
작업 4: command_runner 라이브러리 export 갱신하기 (Update command_runner library exports)
ArgumentException을 command_runner 라이브러리에서 사용할 수 있게 만들어 봐요.
command_runner/lib/command_runner.dart를 열고 다음 export를 추가해 주세요:
/// Support for doing something awesome.
///
/// More dartdocs go here.
library;
export 'src/arguments.dart';
export 'src/command_runner_base.dart';
export 'src/help_command.dart';
export 'src/exceptions.dart'; // Add this line
// TODO: Export any libraries intended for clients of this package.
Dart 패키지에서 lib/src/ 안의 파일은 비공개 구현 세부사항이에요. lib/command_runner.dart에서 src/exceptions.dart를 export하면 ArgumentException이 패키지 공개 API의 일부로 노출되어 호출자(예: cli.dart)가 import해서 사용할 수 있어요.
작업 5: 새 오류 처리 테스트하기 (Test the new error handling)
잘못된 인자로 애플리케이션을 실행해 새 오류 처리를 테스트해 봐요.
-
터미널을 열고
cli디렉터리로 이동해 주세요. -
dart run bin/cli.dart invalid_command명령을 실행해 봐요.
다음 출력이 보여야 해요:
ArgumentException: The first word of input must be a command.
이것은 ArgumentException이 제대로 던져지고 잡히고 있다는 걸 확인해 줘요.
복습 (Review)
배운 내용 (What you accomplished)
이번 강의에서 만들고 배운 내용을 정리해 드릴게요.
- 오류와 예외 구분하기:
Error의 하위 타입은 프로그래밍 버그를 나타내며 잡아서는 안 되고,Exception의 하위 타입은 코드가 우아하게 처리할 수 있는 복구 가능한 실패를 나타낸다는 걸 배웠어요. - try-catch 블록으로 실패 처리하기: 위험한 코드를
try-catch블록으로 감싸 예외를 가로챘어요. 특정 타입만 처리하기 위해on ExceptionType catch (e)를 사용하고, 스택 트레이스를 보존하면서 예외를 다시 전파하기 위해rethrow도 사용했어요. - 사용자 정의 예외 만들고 던지기:
FormatException을 확장하는ArgumentException클래스를 만들어 컨텍스트가 풍부한 오류 정보를 제공했어요. 그리고throw로 검증 실패를 더 나은 디버깅과 사용자 피드백을 위한 의미 있는 메시지와 함께 알렸어요.
퀴즈 (Quiz)
이해도를 확인해 봐요 (Check your understanding)
Q1. 사용자 입력을 파싱하는 함수를 작성하고 있어요. 입력이 잘못됐다면 호출 코드에 신호를 보내는 가장 좋은 방법은 무엇인가요?
- 무엇이 잘못됐는지 설명하는 예외를 던져요. — 맞아요! 예외를 던지면 잘못된 코드 경로가 즉시 멈추고, 명확한 오류 메시지를 주며, 호출자가 오류를 명시적으로 처리하도록 강제해요.
null을 반환하고 호출자가 그것을 확인하게 해요. — 아니에요.null을 반환하면 호출자가 확인하는 것을 기억해야 해요. 잊어버리면 버그가 조용히 발생할 수 있어요. 문제를 알리는 더 명시적인 방법이 있어요.- 오류 메시지를 출력하고 실행을 계속해요. — 아니에요. 출력한다고 잘못된 데이터가 쓰이지 않는 건 아니에요. 함수는 여전히 뭔가를 반환해야 해서, 이후 단계에서 버그를 일으킬 수 있어요.
- 다른 코드가 확인할 수 있는 전역 오류 플래그를 설정해요. — 아니에요. 전역 상태는 오류가 나기 쉽고 잊어버리기 쉬워요. 이 접근은 호출자가 오류를 처리하도록 강제하지 않아요.
Q2. catch 블록에서 throw와 rethrow의 차이는 무엇인가요?
throw는 항상 새 스택 트레이스를 만드는 반면,rethrow는 원래 스택 트레이스를 보존해요. — 맞아요! 예외를 로그로 남기거나 부분적으로 처리하면서도 디버깅을 위해 원래 스택 트레이스를 그대로 전파하고 싶을 때rethrow를 사용해요.throw는 새 예외를 만들고,rethrow는 현 예외를 그냥 출력해요. — 아니에요. 둘 다 아무것도 출력하지 않아요.rethrow는 예외를 전파하지, 그냥 보여 주는 게 아니에요.throw는 catch 블록 안에서,rethrow는 밖에서만 작동해요. — 아니에요. 오히려 반대에 가까워요.rethrow는 catch 블록 안에서만 작동하고,throw는 어디서든 작동해요.throw는 오류용이고rethrow는 예외용이에요. — 아니에요. 둘 다 어떤 던질 수 있는 객체든 다뤄요. 차이는 던져지는 객체의 타입에 관한 게 아니에요.
Q3. 이 코드를 생각해 봐요: try { riskyOperation(); } on FormatException catch (e) { print(e); }. riskyOperation()이 HttpException을 던지면 어떻게 되나요?
HttpException은 이 try/catch에 잡히지 않고 위로 전파돼요. — 맞아요!on절은 예외 타입으로 필터링해요.HttpException은FormatException이 아니므로 이 catch 블록을 완전히 건너뛰게 돼요.HttpException이 잡혀서 출력돼요. — 아니에요.on FormatException절은FormatException과 그 하위 타입만 잡아요.HttpException은 다른 타입이에요.on을catch와 함께 쓸 수 없어서 프로그램이 크래시해요. — 아니에요.on Type catch (e)는 잡을 예외를 필터링하는 유효한 문법이에요. 문제는 문법이 아니라 타입 불일치예요.- 예외는 조용히 무시되고 실행이 계속돼요. — 아니에요. Dart에서 예외는 결코 조용히 무시되지 않아요. catch 블록이 일치하지 않더라도 예외가 그냥 사라지지는 않아요.
Q4. Dart에서 Error와 Exception의 주된 차이는 무엇인가요?
Error는 잡아서는 안 되는 프로그래밍 버그를,Exception은 코드가 복구할 수 있는 예상 가능한 조건을 나타내요. — 맞아요!Error의 하위 타입(예:RangeError,TypeError)은 고쳐야 할 코드의 버그를 나타내요.Exception의 하위 타입(예:FormatException,HttpException)은 코드가 미리 예상해서 우아하게 처리할 수 있는 조건을 나타내요.Error는 Dart 런타임이 던지고,Exception은 사용자 코드만 던질 수 있어요. — 아니에요. Dart 런타임과 사용자 코드 모두 예외와 오류를 던질 수 있어요.Exception은try/catch블록이 필요하지만,Error는 잡히지 않으면 무시돼요. — 아니에요. 잡히지 않은 오류와 잡히지 않은 예외 모두 실행을 종료해요. 둘 다 조용히 무시되지 않아요.Error와Exception은 Dart에서 동일하며 서로 바꿔 쓸 수 있어요. — 아니에요. Dart는 설계상 이 둘을 구분해요. 오류는 버그를, 예외는 복구 가능한 실패를 나타내요.