사용자 정의 예외(Custom Exception)는 프로그램에서 발생하는 예외 상황을 더 구체적으로 처리하고, 사용자의 의도에 맞는 예외 처리를 구현하기 위해 사용된다. Dart에서는 예외 처리를 위해 표준 라이브러리에서 제공하는 예외 클래스가 있지만, 프로그램의 복잡성에 따라 표준 예외 클래스로는 처리하기 어려운 상황이 발생할 수 있다. 이때, 사용자 정의 예외를 통해 보다 명확하고 의미 있는 예외를 정의할 수 있다.

예외 클래스 생성

Dart에서 사용자 정의 예외는 일반 클래스를 상속받거나 Exception 클래스를 상속받아 구현할 수 있다. Exception 클래스는 기본적으로 Dart에서 제공하는 예외 클래스이다. 사용자 정의 예외를 만들기 위해서는 이 클래스를 상속받아야 하며, 생성자(constructor)를 통해 예외에 대한 메시지나 추가적인 데이터를 전달할 수 있다.

예를 들어, 특정 값이 범위를 벗어났을 때 발생하는 예외를 처리하는 클래스를 만들어 보자.

class OutOfRangeException implements Exception {
  final String message;

  OutOfRangeException(this.message);

  @override
  String toString() => 'OutOfRangeException: $message';
}

이 예제에서 OutOfRangeException 클래스는 Exception 인터페이스를 상속받고, 생성자를 통해 예외 메시지를 받는다. toString() 메소드를 재정의하여 예외 발생 시 출력할 메시지를 명시적으로 지정한다. 이처럼 예외를 구체화하여, 발생한 오류가 어떤 이유로 발생했는지 명확히 나타낼 수 있다.

사용자 정의 예외 던지기

정의된 예외를 발생시키기 위해서는 throw 키워드를 사용한다. 이는 일반적인 예외 처리와 동일하게 try-catch 블록 안에서 사용될 수 있다. 다음은 위에서 정의한 OutOfRangeException을 발생시키는 예제이다.

void checkValue(int value) {
  if (value < 0 || value > 100) {
    throw OutOfRangeException('Value must be between 0 and 100.');
  } else {
    print('Value is in range.');
  }
}

이 함수는 전달된 값이 0과 100 사이에 있지 않으면 OutOfRangeException을 발생시키고, 그렇지 않으면 정상적인 값을 출력한다.

사용자 정의 예외 처리

사용자 정의 예외를 처리하는 방법은 Dart에서 기본 제공하는 예외 처리와 동일하다. 예외 발생 시 이를 잡아내는 catch 블록을 작성하여, 필요한 경우 사용자 정의 예외에 맞는 처리를 수행할 수 있다.

void main() {
  try {
    checkValue(150);
  } catch (e) {
    print(e);
  }
}

위 코드에서는 checkValue(150)을 호출하여 OutOfRangeException이 발생하고, catch 블록에서 해당 예외를 잡아 출력하는 방식으로 동작한다.

예외에 데이터 추가

사용자 정의 예외는 예외 상황에 맞는 추가적인 데이터를 포함할 수 있다. 예를 들어, 어떤 함수가 잘못된 값을 입력받았을 때, 입력된 값이나 그 외의 정보를 예외 객체에 포함시켜 사용할 수 있다.

다음은 입력된 값과 함께 범위 정보를 포함하는 예외 클래스의 예이다.

class OutOfRangeException implements Exception {
  final int input;
  final int min;
  final int max;
  final String message;

  OutOfRangeException(this.input, this.min, this.max, this.message);

  @override
  String toString() => 
      'OutOfRangeException: $message (Input:$input, Range: $min -$max)';
}

이 클래스는 입력된 값(input)과 허용되는 범위(min, max), 그리고 예외 메시지(message)를 포함한다. 이를 통해 예외가 발생한 상황을 더 구체적으로 설명할 수 있다.

예외 발생과 처리 예시

위에서 정의한 OutOfRangeException 클래스를 활용하여, 범위를 벗어난 값에 대한 예외 발생과 그 예외를 처리하는 예시를 작성할 수 있다.

void checkValue(int value, int min, int max) {
  if (value < min || value > max) {
    throw OutOfRangeException(value, min, max, 'Value is out of range.');
  } else {
    print('Value is within range.');
  }
}

void main() {
  try {
    checkValue(150, 0, 100);
  } catch (e) {
    print(e);
  }
}

이 예제에서 checkValue 함수는 전달된 값이 지정된 범위(min, max) 내에 있지 않으면 OutOfRangeException을 발생시킨다. main 함수에서는 try-catch 블록을 사용하여 예외를 처리하고, 발생한 예외에 포함된 정보를 출력한다. 결과적으로, 예외 메시지와 함께 입력된 값과 허용되는 범위가 출력된다.

중첩된 예외 처리

프로그램이 더 복잡해지면 여러 예외 상황이 중첩되어 발생할 수 있다. 이때 하나의 catch 블록에서 여러 유형의 예외를 처리하거나, 각 예외마다 다른 처리를 해야 할 수 있다. Dart에서는 여러 catch 블록을 사용하여 특정 예외를 구분하고 처리할 수 있다.

다음은 사용자 정의 예외와 함께 다른 일반적인 예외를 처리하는 예제이다.

void main() {
  try {
    checkValue(-10, 0, 100);
    int result = 100 ~/ 0;  // ZeroDivisionError 발생
  } on OutOfRangeException catch (e) {
    print('Caught an OutOfRangeException: $e');
  } on IntegerDivisionByZeroException catch (e) {
    print('Caught a division by zero: $e');
  } catch (e) {
    print('Caught an unknown exception: $e');
  }
}

이 코드는 먼저 checkValue 함수에서 발생할 수 있는 OutOfRangeException을 처리하고, 그 후 정수 나누기에서 발생할 수 있는 IntegerDivisionByZeroException을 처리한다. 마지막 catch 블록에서는 모든 예외를 처리하는 기본 예외 처리기가 동작한다.

이 방식으로, 프로그램에서 발생할 수 있는 다양한 예외 상황을 효과적으로 처리할 수 있다.

재사용 가능한 예외 클래스

사용자 정의 예외 클래스는 여러 모듈에서 재사용될 수 있다. 예를 들어, 특정 값의 범위를 체크하는 코드를 여러 곳에서 사용해야 한다면, 해당 예외 클래스를 한 번 정의한 후 여러 곳에서 예외를 발생시키는 방식으로 코드의 재사용성을 높일 수 있다.

다음 예시에서 동일한 OutOfRangeException을 다른 함수에서도 재사용할 수 있다.

void checkTemperature(int temp) {
  if (temp < -50 || temp > 50) {
    throw OutOfRangeException(temp, -50, 50, 'Temperature is out of range.');
  } else {
    print('Temperature is normal.');
  }
}

void checkSpeed(int speed) {
  if (speed < 0 || speed > 200) {
    throw OutOfRangeException(speed, 0, 200, 'Speed is out of range.');
  } else {
    print('Speed is normal.');
  }
}

이처럼 하나의 사용자 정의 예외 클래스를 여러 상황에 맞춰 재사용할 수 있다. 각 함수에서는 적절한 범위와 메시지를 설정하여 예외를 발생시키고, 같은 catch 블록에서 이들을 처리할 수 있다.

사용자 정의 예외의 장점

사용자 정의 예외를 사용하면 프로그램에서 발생하는 예외 상황을 더 구체적이고 직관적으로 처리할 수 있는 여러 가지 장점이 있다. 이러한 장점을 통해 예외 처리의 가독성을 높이고, 디버깅을 더 용이하게 만들 수 있다.

1. 명확한 예외 분류

표준 예외 클래스는 일반적인 예외 상황을 처리하기 위한 것이며, 예외가 발생했을 때 특정 상황을 명확히 구분하기 어렵다. 그러나 사용자 정의 예외를 사용하면 프로그램에서 발생하는 다양한 예외 상황을 구체적으로 분류할 수 있다. 예를 들어, OutOfRangeException과 같은 클래스는 값의 범위가 벗어난 상황을 구체적으로 표현하므로, 예외 처리 코드에서 해당 상황을 더 쉽게 파악할 수 있다.

2. 추가적인 정보 제공

사용자 정의 예외는 예외 발생 시 그 상황에 대한 추가적인 정보를 함께 전달할 수 있다. 예를 들어, 입력된 값, 허용되는 범위, 발생한 오류 메시지 등 다양한 데이터를 포함할 수 있다. 이렇게 추가적인 정보를 제공함으로써 예외 발생 원인을 더 쉽게 파악할 수 있다.

class OutOfRangeException implements Exception {
  final int input;
  final int min;
  final int max;
  final String message;

  OutOfRangeException(this.input, this.min, this.max, this.message);

  @override
  String toString() => 
      'OutOfRangeException: $message (Input:$input, Range: $min -$max)';
}

위 예제에서는 예외 발생 시 입력된 값과 허용되는 범위를 함께 출력하여, 디버깅 과정에서 문제를 쉽게 찾을 수 있도록 도와준다.

3. 특정 기능에 맞는 예외 처리

프로그램의 특정 기능에서만 발생할 수 있는 특수한 예외 상황을 처리할 때 사용자 정의 예외가 유용하다. 예를 들어, 파일 입출력, 네트워크 통신, 데이터베이스 연결 등의 모듈에서 발생할 수 있는 다양한 예외를 각 모듈에 맞게 구체화하여 처리할 수 있다.

사용자 정의 예외와 상속

Dart에서 예외 클래스는 일반적인 클래스이기 때문에 상속을 통해 더 복잡하고 다양한 예외를 정의할 수 있다. 상속을 사용하여 여러 예외를 그룹화하거나, 공통의 속성을 가지는 예외들을 정의할 수 있다.

다음은 상속을 사용하여 더 구체적인 예외를 정의하는 예시이다.

class ApplicationException implements Exception {
  final String message;

  ApplicationException(this.message);

  @override
  String toString() => 'ApplicationException: $message';
}

class FileNotFoundException extends ApplicationException {
  FileNotFoundException(String message) : super(message);
}

class NetworkException extends ApplicationException {
  NetworkException(String message) : super(message);
}

위 코드에서 ApplicationException 클래스는 모든 사용자 정의 예외의 부모 클래스 역할을 하며, FileNotFoundExceptionNetworkException은 각각 파일 입출력과 네트워크 관련 예외를 나타낸다. 상속을 통해 공통적인 속성(message)을 재사용하면서, 각 예외에 특화된 처리를 할 수 있다.

이와 같은 구조를 사용하면, 프로그램의 예외 처리 구조가 더욱 체계적이고 일관성 있게 유지될 수 있다.

사용자 정의 예외와 다형성

상속을 통해 정의한 예외 클래스는 다형성을 통해 더욱 유연하게 사용할 수 있다. 예외 처리 코드에서 부모 클래스 타입으로 예외를 처리하면, 여러 자식 클래스의 예외를 동일한 방식으로 처리하거나, 특정 자식 클래스에만 맞는 처리를 할 수 있다.

다음 예제는 ApplicationException을 사용하여 다형성을 구현한 예시이다.

void handleException(ApplicationException e) {
  print(e);
}

void main() {
  try {
    throw FileNotFoundException('File not found.');
  } catch (e) {
    if (e is ApplicationException) {
      handleException(e);
    }
  }

  try {
    throw NetworkException('Network is down.');
  } catch (e) {
    if (e is ApplicationException) {
      handleException(e);
    }
  }
}

이 예제에서, FileNotFoundExceptionNetworkException은 모두 ApplicationException을 상속받기 때문에, catch 블록에서 ApplicationException 타입으로 예외를 처리할 수 있다. handleException 함수는 부모 클래스 타입을 인수로 받아, 여러 예외를 동일한 방식으로 처리할 수 있다.

장점

사용자 정의 예외의 실제 사용 사례

사용자 정의 예외는 다양한 실제 상황에서 유용하게 사용된다. 특히, 데이터 처리나 입출력, 네트워크 통신과 같은 모듈에서 발생할 수 있는 특수한 예외를 처리하는 데 효과적이다. 예를 들어, 웹 애플리케이션에서 서버와 통신할 때 네트워크 오류가 발생할 수 있는데, 이때 사용자 정의 예외를 사용하여 보다 명확한 메시지와 함께 문제를 처리할 수 있다.

예시: 파일 처리 예외

파일을 열거나 읽는 과정에서 파일이 존재하지 않거나 권한이 없을 때 발생하는 예외를 사용자 정의 예외로 처리할 수 있다.

class FileReadException implements Exception {
  final String fileName;
  final String message;

  FileReadException(this.fileName, this.message);

  @override
  String toString() => 'FileReadException: $message (File:$fileName)';
}

void readFile(String fileName) {
  if (!File(fileName).existsSync()) {
    throw FileReadException(fileName, 'File not found');
  }
  // 파일 읽기 로직
}

void main() {
  try {
    readFile('data.txt');
  } catch (e) {
    print(e);
  }
}

이 코드에서 FileReadException은 파일을 읽는 도중 발생할 수 있는 문제를 구체적으로 정의하고, 파일 이름과 함께 예외 메시지를 전달한다. readFile 함수에서 파일이 존재하지 않으면 FileReadException을 던지고, catch 블록에서 예외를 처리하여 문제를 출력한다.

예시: 네트워크 예외

네트워크 통신 도중 발생할 수 있는 예외를 사용자 정의 예외로 처리할 수 있다. 예를 들어, 서버와의 연결이 끊어지거나 응답이 없을 때 발생하는 문제를 다룰 수 있다.

class NetworkException implements Exception {
  final String url;
  final String message;

  NetworkException(this.url, this.message);

  @override
  String toString() => 'NetworkException: $message (URL:$url)';
}

void fetchData(String url) {
  if (url.isEmpty) {
    throw NetworkException(url, 'Invalid URL');
  }
  // 네트워크 요청 처리 로직
}

void main() {
  try {
    fetchData('');
  } catch (e) {
    print(e);
  }
}

NetworkException은 네트워크 관련 예외를 처리하기 위한 클래스이다. fetchData 함수에서 잘못된 URL을 전달하면 NetworkException이 발생하고, catch 블록에서 해당 예외를 처리하여 문제를 알린다.

사용자 정의 예외와 로깅

실제 애플리케이션에서는 예외 발생 시 로그를 남겨 디버깅 및 문제 분석에 사용될 수 있다. 사용자 정의 예외를 사용하면 예외가 발생한 구체적인 상황과 관련된 정보를 로그에 기록할 수 있다. Dart에서는 print를 통해 로그를 출력하거나, 파일에 기록하는 방식을 사용할 수 있다.

다음은 사용자 정의 예외와 로깅을 결합한 예시이다.

class DatabaseException implements Exception {
  final String operation;
  final String message;

  DatabaseException(this.operation, this.message);

  @override
  String toString() => 'DatabaseException: $message (Operation:$operation)';
}

void performDatabaseOperation(String operation) {
  // 데이터베이스 작업 시 오류가 발생하는 가정
  if (operation == 'insert') {
    throw DatabaseException(operation, 'Failed to insert data');
  }
}

void main() {
  try {
    performDatabaseOperation('insert');
  } catch (e) {
    print('Error occurred: $e');
    logError(e);
  }
}

void logError(Exception e) {
  // 여기서는 간단히 콘솔에 기록하지만, 실제로는 파일이나 외부 시스템에 로그 기록 가능
  print('Logging error: $e');
}

이 예제에서 DatabaseException 클래스는 데이터베이스 작업 중 발생한 예외를 나타내며, performDatabaseOperation 함수는 데이터베이스 작업을 수행하다가 오류가 발생했을 때 예외를 던진다. main 함수에서는 try-catch 블록을 사용해 예외를 처리하고, 발생한 예외를 로그에 기록한다.

실제 프로젝트에서는 logError 함수를 확장하여 파일에 기록하거나, 원격 서버에 예외 데이터를 보내는 등의 방식으로 사용할 수 있다. 이를 통해 프로그램에서 발생하는 예외를 추적하고 분석하는 데 도움을 줄 수 있다.

사용자 정의 예외와 다단계 예외 처리

경우에 따라 하나의 작업에서 여러 단계의 예외 처리가 필요할 수 있다. 예를 들어, 데이터베이스 연결, 네트워크 요청, 파일 읽기 등 다양한 작업이 한 함수 내에서 연속적으로 이루어질 때 각각의 작업에서 발생할 수 있는 예외를 따로따로 처리하거나, 상위 예외로 감싸서 처리하는 방식으로 구현할 수 있다.

다음은 여러 단계의 예외를 처리하는 예시이다.

void performOperations() {
  try {
    performDatabaseOperation('insert');
    fetchData('invalid_url');
  } catch (e) {
    print('Error in performOperations: $e');
    rethrow;  // 예외를 다시 상위로 전달
  }
}

void main() {
  try {
    performOperations();
  } catch (e) {
    print('Caught at top level: $e');
  }
}

이 예제에서는 performOperations 함수 내에서 두 가지 작업(performDatabaseOperationfetchData)을 수행하고, 각각의 작업에서 예외가 발생할 경우 이를 처리한 후 예외를 다시 상위로 전달한다(rethrow). 최종적으로 상위 catch 블록에서 모든 예외를 처리할 수 있다.

이처럼, 다단계 예외 처리를 통해 예외 발생 시 세부적인 처리를 한 후에도 필요할 경우 상위 계층으로 예외를 전달할 수 있다.

사용자 정의 예외 클래스의 확장

복잡한 시스템에서는 다양한 예외를 관리하기 위해 사용자 정의 예외 클래스를 확장할 수 있다. 예를 들어, 예외를 세부적으로 분류하여 여러 예외 클래스를 정의하고, 상속을 통해 공통적인 예외 처리 로직을 재사용할 수 있다.

다음은 예외를 세분화하여 다양한 상황을 처리하는 예시이다.

class InvalidUserInputException implements Exception {
  final String message;

  InvalidUserInputException(this.message);

  @override
  String toString() => 'InvalidUserInputException: $message';
}

class InvalidAgeException extends InvalidUserInputException {
  InvalidAgeException(String message) : super(message);
}

class InvalidEmailException extends InvalidUserInputException {
  InvalidEmailException(String message) : super(message);
}

void validateUserInput(int age, String email) {
  if (age < 0 || age > 120) {
    throw InvalidAgeException('Age must be between 0 and 120.');
  }
  if (!email.contains('@')) {
    throw InvalidEmailException('Email is invalid.');
  }
}

void main() {
  try {
    validateUserInput(150, 'invalid_email');
  } catch (e) {
    print(e);
  }
}

이 예제에서는 InvalidUserInputException을 상속받아 InvalidAgeExceptionInvalidEmailException을 정의하고, 각각 나이와 이메일에 대한 유효성 검사를 수행한다. 이를 통해 입력 값에 따라 더 구체적인 예외 메시지를 던지고 처리할 수 있다.