C++의 예외 처리는 근본적으로 잘못됐다. 커널 개발의 경우에 특히 그렇다.
The whole C++ exception handling thing is fundamentally broken. It’s especially broken for kernels.
— 리누스 토발즈Linus Torvalds (2004)
프로그래밍에선 예외 처리 또한 중요한 작업입니다. 그렇게 해서 (개발자를 포함한) 사용자에게 무엇이 잘못됐는지 알려주는 것이 좋기 때문입니다.
예를 들어, 다음과 같은 수도코드로 나머지를 구하는 함수를 만든다고 해봅시다.
나머지 (, )
예외 처리 // 예외: 으로 나눌 수 없음
리턴 을 로 나눈 나머지
그러면 0으로 나누는 경우는 정상적으로 실행할 수 없는 예외가 됩니다. 따라서 함수를 진행하는 대신, 이런 오류가 발생했다는 사실을 사용자에게 전달할 필요가 생깁니다.
예외를 처리하는 방법은 각 프로그래밍 언어가 선택한 접근법이나 철학에 따라 다양하겠지만, 여기서는 대부분의 언어에서 자주 나타나는 세 가지 방법을 살펴보려고 합니다. 앞으로의 내용을 통해 이런 방법이 나타난 이유와 그 차이점을 알게 될 것입니다.
예시를 위해 다양한 언어를 이용하지만 그 언어의 문법을 소개하기 위한 것은 아니므로 예시 코드는 가벼운 마음으로 읽어보시기 바랍니다.
첫 번째: 오류 값 리턴하기
예외를 처리하는 가장 간단한 방법은 오류 자체를 값으로서 리턴하는 것입니다.
나머지 (, ) // 을 로 나눈 나머지를 리턴
리턴 오류 // (구체적으로 어떤 값?)
리턴 을 로 나눈 나머지
이 수도코드가 구체적인 프로그래밍 언어에서 어떻게 나타나는지 살펴보겠습니다.
빈 값 리턴하기
많은 프로그래밍 언어에서는 오류를 의미하는 대표적인 값으로서 빈 값이 존재합니다. 물론 이것이 좋은 디자인인지는 논란의 여지가 있지만, 단순히 소개하는 차원에서 정리해보겠습니다.
이 값에는 여러 이름이 있는데, 자바스크립트JavaScript에서는 null
값으로 제공합니다.
이를 이용하면 나머지 함수는 다음과 같이 구현됩니다.
function mod(n, d) {
if (d === 0) {
return null;
}
return n % d;
}
보기에는 아주 간단한 방법입니다. 그런데 이 방법은 다음과 같은 문제점이 있습니다.
- 오류가 두 종류 이상이라면, 하나의 오류 값으로는 충분하지 않다.
- 오류가 생긴 이유를 제공하지 않는다.
위와 같이 간단한 경우에는 빈 값으로도 충분하겠지만, 상황이 약간만 복잡해지더라도 금방 이러한 문제점이 나타납니다.
예를 들어, 자바스크립트는 동적으로dynamically 타이핑되는 언어인 만큼, 다음과 같이 올바른 파라미터 타입을 보장할 필요가 있다고 해봅시다.
function mod(n, d) {
if (typeof n !== "number" || typeof d !== "number") {
return null;
}
if (d === 0) {
return null;
}
return n % d;
}
여기서 예외 처리를 위쪽에 몰아서 하는 이유는, 성공과 예외 케이스를 분리하기 위함입니다. 성공 케이스와 예외 처리가 섞이게 되면 코드가 읽기에 복잡해지므로, 이것을 잘 분리하는 것이 예외 처리에 있어서 또다른 중요한 관심사입니다.
하지만 위와 같은 함수를 호출하는 쪽에서는 오류가 생긴 이유를 알 수 없습니다. 어느 경우에나 똑같이 빈 값을 받기 때문입니다.
오류 코드 리턴하기
빈 값 하나 대신, 오류 값을 여러 개 만들면 문제점이 해결되지 않을까요? 이번에는 빈 값 대신 음수를 리턴해봅시다.
if (typeof n !== "number" || typeof d !== "number") {
return null;
return -1;
}
if (d === 0) {
return null;
return -2;
}
여기서는 -1
이 잘못된 타입을, -2
가 0으로 나누기를 의미하는 걸로 약속한 것입니다.
나머지는 음수가 될 일이 없다고 가정했다는 점을 참고하세요.
이런 방법은 C 언어와 같은 언어에서도 사용됩니다.
매뉴얼에 따르면, C 라이브러리의 open()
함수는 파일을 여는데 실패할 때 -1
을 리턴합니다.
(여기서도 정상적인 리턴 값은 0
이상입니다.)
int fd = open(file_path, O_RDONLY);
if (fd == -1 && errno == ENOENT) {
fprintf(stderr, "Error: file not found.\n");
}
이 라이브러리에서 구체적인 오류 값은 errno
전역 변수에 기록합니다.
그리고 ENOENT
는 파일이 없음을 의미하며, 정수 값을 그대로 쓰는 대신 변수명으로서 그 의미를 나타낸 것입니다.
셸에서도 이와 같은 방법을 사용하는데, 셸에서는 0은 성공을, 그 외의 값은 오류를 의미합니다.
이것은 C 언어와 같은 언어에서 main()
함수가 0
을 정상적인 종료로서 리턴하는 이유이기도 합니다.
따라서 종료 코드exit code라고도 부릅니다.
예를 들어, 다음과 같이 grep
프로그램은 문자열을 찾지 못하면 1
을 종료 코드로 리턴합니다.
$ echo foo | grep bar
$ echo $?
1
여기서 $?
변수는 종료 코드를 가집니다.
따라서 다음 조건문은 아무 것도 실행하지 않습니다.
$ if echo foo | grep bar; then echo baz; fi
한번 bar
를 foo
로 바꿔서 실행해보세요.
성공 값과 오류 값을 동시에 리턴하기
앞서 성공 값과 오류 값을 리턴할 수 있었던 것은 마침 그 타입이 정수로 같았기 때문입니다. 그런데 타입이 달라야 한다면 어떨까요?
예를 들어, 매뉴얼에 따르면 C 언어 라이브러리의 stat()
함수는 다음과 같이 파일의 정보를 리턴합니다.
struct stat sb;
if (stat(file_path, &sb) == -1) {
perror("stat");
exit(EXIT_FAILURE);
}
이처럼 오류는 -1
을 리턴함으로서 전달하며, 성공 시에는 참조로서 전달한 sb
변수에 성공 값을 담습니다.
즉 참조에 의한 호출call by reference을 통해 사실상 두 개의 값을 리턴하는 것입니다.
한편 프로그래밍 언어 자체에서 문법으로서 지원한다면, 성공 값과 오류 값을 동시에 리턴하는 것이 간단한 방법입니다. 이런 방법은 Go 언어에서 주로 사용합니다. 다음은 패키지 문서의 예시입니다.
dat, err := os.ReadFile(filePath)
if err != nil {
log.Fatal(err)
}
여기서 nil
은 Go 언어에서 제공하는 빈 값입니다.
자바스크립트 또한 비동기적인 예외 처리에 이런 방법을 사용합니다. 다음은 패키지 문서의 예시입니다.
readFile(filePath, (err, data) => {
if (err) throw err;
console.log(data);
});
이 함수는 파일 읽기를 시도하고, 결과를 클로저에 인자로 전달합니다.
이를 통해 호출한 쪽에서 성공 값 data
와 오류 값 err
을 받게 됩니다.
예시: 자바스크립트 나누기 함수
한번 위에서 만든 나머지 함수의 예외 처리를 호출한 쪽에서 해봅시다. 그러면 다음과 같이, 약속한 오류 코드에 따라 그 원인을 알려줄 수 있습니다.
const result = mod(n, d);
if (result === -1) {
console.error("Error: not a number.");
process.exit(1);
}
if (result === -2) {
console.error("Error: cannot divide by 0.");
process.exit(1);
}
console.log(`Result: ${result}`);
다음과 같이 노드Node 런타임을 이용해 mod.js
파일로 실행한다고 해봅시다.
$ node mod.js 3 0
여기서 3
, 0
과 같은 입력은 커맨드라인 인자로 받을 수 있습니다.
const n = process.argv[2];
const d = process.argv[3];
const result = mod(n, d);
이것을 실행하면 오류 메시지가 나타납니다.
$ node mod.js 3 0
Error: not a number.
왜냐하면 커맨드라인 인자가 기본적으로 문자열이기 때문입니다. 따라서 숫자로 변환해봅시다.
const n = process.argv[2];
const d = process.argv[3];
const n = Number(process.argv[2]);
const d = Number(process.argv[3]);
그리고 다시 실행해봅시다.
$ node mod.js 3 0
Error: cannot divide by 0.
성공적으로 오류 메시지가 나타납니다. 다른 인자로도 실행해보세요.
두 번째: 오류 던지기
오류를 값으로 리턴하는 것은 간단한 방식이지만, 만약 중첩된 함수에서 예외가 발생한다면 어떨까요? 예를 들어, 다음과 같은 함수를 생각해봅시다.
배수 (, ) // 이 의 배수인지 여부를 리턴
나머지(, )
만약 이 이면
리턴 참
리턴 거짓
이 함수는 앞서 만든 나머지 함수를 이용해서 배수인지 판단합니다.
그런데 나머지 함수의 오류 값까지 고려한다면, 배수 함수에서도 예외를 처리해야 합니다. 물론 그렇게 할 수도 있겠지만, 이런 방법은 다음과 같은 문제가 있습니다.
- 바깥 함수는 자기가 발생시키지 않는 오류도 알아야 합니다.
- 만약 중첩이 여러 단계라면, 모든 함수에서 오류를 다시 리턴해야 합니다.
여기서 오류를 바깥으로 더 멀리 ‘던지는’ 방법이 있다고 해봅시다.
나누기 (, ) // 을 로 나눈 값을 리턴
던짐 오류
리턴
그러면 배수 함수를 건너 뛴 더 바깥쪽에서 오류 값을 처리할 수 있게 됩니다.
즉 이 던지기 방식은 잘 제어된 점프 혹은 goto
문과 같습니다.
이것이 구체적인 프로그래밍 언어에서 어떻게 나타나는지 살펴보겠습니다.
오류 클래스 던지기
C++와 같은 객체 지향 언어에서는 던지기에 throw
키워드를 제공하며 오류 클래스를 주로 활용합니다.
다음은 레퍼런스의 예시 코드입니다.
int foo() {
throw std::runtime_error("error");
}
그리고 오류를 ‘잡는’ 방법으로 catch
키워드를 제공합니다.
try {
foo();
} catch (const exception& e) {
cout << e.what() << '\n';
}
위와 같이 각 클래스는 자연스럽게 오류의 원인을 의미하게 되며, what()
과 같은 메소드로 세부적인 이유를 전달하기도 합니다.
이런 문법은 자바스크립트나 자바Java, 파이썬 같은 언어에서도 나타납니다.
그리고 try
, catch
구문은 성공 케이스와 예외 케이스를 자연스럽게 나누기도 합니다.
즉 코드를 읽는 사람 입장에서는 try
구문만 보고 성공 로직을 파악하게 됩니다.
하지만 이 방법은 갑자기 다른 곳으로 점프하는 것과 같기 때문에, 실행 흐름을 파악하는 데 단점이 될 수도 있습니다. 그래서 보통은 스택 트레이스stack trace라는 이름으로, 예외가 발생해서 그것이 처리될 때까지의 함수 호출을 제공하기도 합니다.
예를 들어, 다음 파이썬 코드와 같이 중첩된 함수에서 예외를 던진다고 해봅시다.
def foo():
raise Exception("error")
def bar():
foo()
bar()
이것을 어디에서도 잡지 않으면 기본적으로 프로그램은 종료되며 스택 트레이스를 보여줍니다.
$ python throw.py
Traceback (most recent call last):
File "/home/user/throw.py", line 7, in <module>
bar()
File "/home/user/throw.py", line 5, in bar
foo()
File "/home/user/throw.py", line 2, in foo
raise Exception("error")
Exception: error
이를 통해 예외가 발생한 시점을 파악할 수 있습니다.
예시: 자바스크립트 배수 함수
자바스크립트 또한 throw
키워드를 제공합니다.
이를 통해 나머지 함수가 예외를 던지도록 만들어봅시다.
if (typeof n !== "number" || typeof d !== "number") {
return -1;
throw TypeError("not a number.");
}
if (d === 0) {
return -2;
throw Error("cannot divide by 0.");
}
배수 함수는 간략하게 구현할 수 있습니다.
function isMultiple(n, m) {
return mod(n, m) === 0;
}
여기서 mod()
함수에서 던지는 오류를 처리할 필요가 없습니다.
마지막으로, 배수 함수를 호출하는 쪽에서 오류를 잡아 처리합니다.
try {
if (isMultiple(n, m)) {
console.log(`${n} is a multiple of ${m}`);
} else {
console.log(`${n} is not a multiple of ${m}`);
}
} catch (e) {
console.error(`Error: ${e.message}`);
process.exit(1);
}
이 소스 코드를 multiple.js
로 만들고 셸에서 다음과 같이 실행해봅시다.
$ node multiple.js 3 0
Error: cannot divide by 0.
이처럼 다른 방법으로 이전과 똑같이 예외를 처리할 수 있습니다.
세 번째: 실패할 수 있는 타입 리턴하기
오류 던지기는 어디선가 잡지 않으면 프로그램이 종료되는, 좋지 않은 일이 발생합니다.
대신 값을 리턴하되, 오류일 수도 있다는 문맥을 타입에 추가하면 어떨까요?
이 타입에는 Maybe
같은 이름도 있지만, 러스트Rust에서는 Option
으로 제공합니다.
fn modulo(n: usize, d: usize) -> Option<usize> {
if d == 0 {
None
} else {
Some(n % d)
}
}
러스트 문서에 따르면, Option
타입은 그저 None
또는 Some
값을 가지는 열거형, 즉 이뉴머레이션enumeration일 뿐입니다.
따라서 둘 중 하나만을 반드시 갖게 됩니다.
이 함수는 예외 발생 시 빈 값을 리턴한다는 점에서 첫 번째 방법과 비슷합니다.
하지만 정수 타입에 대해 빈 값이라는 점이 다릅니다.
즉 앞서 null
은 모든 타입에 대해 일반적으로 썼던 빈 값인 것입니다.
이 방법은 던지기와 비교하면 다음과 같은 차이점이 있습니다.
- 오류가 타입에 포함되었기 때문에, 예외 처리를 실수로 놓친다면 컴파일러가 타입을 체크할 때 잡아냅니다.
- 오류를 던지면 잡을 때까지의 모든 중첩된 함수에서 진행이 중단되지만, 값으로 리턴하면 그렇지 않습니다.
여기서 두 번째는 다음과 같은 상황에 중요한 특징입니다. 예를 들어, 앞서 자바스크립트로 만든 나머지 함수를 봅시다.
const list = [0, 1, 2, 3].map(n => mod(3, n));
이 코드는 0
부터 3
까지의 숫자로 나누는데, mod()
함수는 0
으로 나눌 때 오류를 던집니다.
따라서 map()
함수의 진행이 중단되므로 아무 결과를 얻을 수 없습니다.
반면, 방금 러스트로 만들었던 함수를 이용해봅시다.
let list: Vec<Option<isize>> = (0..4).map(|n| modulo(3, n)).collect();
이 경우에는 예외가 발생하면 빈 값을 리턴하고 계속 진행합니다.
실제로 프린트해보면 어떨까요?
println!("{:?}", list); // [None, Some(0), Some(1), Some(0)]
이와 같이 나머지 성공 케이스에 대해서 값을 구합니다.
성공 케이스만 표현하기
한편 이 방법 또한, 첫 번째 방법과 똑같은 문제점으로서, 중첩된 모든 함수에서 예외 처리가 필요합니다. 하지만 프로그래밍 언어의 도움을 받아 마치 그렇지 않은 것처럼 코드를 작성할 수 있습니다.
예를 들어, 러스트로 배수 함수를 만들어봅시다.
fn is_multiple(n: usize, d: usize) -> Option<bool> {
let m = modulo(n, d);
if m.is_none() {
return None;
}
let m = m.unwrap();
return Some(m == 0);
}
메소드 이름 자체가 무엇을 하는지 알려주기 때문에 별 다른 설명은 필요 없을 것입니다.
그런데 러스트에서 이런 스타일은 다소 장황한 편입니다. 사실 같은 내용을 패턴 매칭을 통해 간략하게 표현할 수도 있습니다.
fn is_multiple(n: usize, d: usize) -> Option<bool> {
let Some(m) = modulo(n, d) else {
return None;
};
return Some(m == 0);
}
러스트의 문법을 설명하려는 것은 아니므로, m
이 modulo()
함수의 성공 값이 된다는 것만 언급하고 넘어가겠습니다.
하지만 러스트에서는 더 간결하게 쓸 수도 있습니다.
fn is_multiple(n: usize, d: usize) -> Option<bool> {
Some(modulo(n, d)? == 0)
}
여기서 ?
연산자는 오류 값이 나타나는 경우 진행을 중단하고 그 값을 그대로 리턴하며, 러스트에서는 트라이 연산자try-operator라고 부릅니다.
어차피 None
값을 만났을 때 그대로 리턴할 것이므로, ?
를 이용해 그 과정을 숨기는 것입니다.
이 방법을 통해 마치 성공 케이스만 작성하듯이 예외를 처리할 수 있게 됩니다. 이것을 중첩된 함수에 사용하면, 마치 던지기로 오류를 계속 바깥에 전달하는 것과 같습니다.
예시: 러스트 배수 함수
위에서 만든 배수 함수를 이용해, 온전한 러스트 프로그램을 만들어봅시다.
fn main() -> () {
let args: Vec<String> = std::env::args().collect();
let n: usize = args[1].parse().unwrap();
let m: usize = args[2].parse().unwrap();
let Some(result) = is_multiple(n, m) else {
eprintln!("Cannot divide by 0.");
std::process::exit(1);
};
println!("Result: {}", result);
}
이것은 앞서 자바스크립트의 경우와 똑같은 일을 하는 소스 코드입니다.
즉 커맨드라인 인자를 n
, m
변수로 읽고, 배수 함수를 호출할 때 예외를 처리합니다.
실제로 실행해보면 똑같은 결과를 얻을 수 있습니다.
$ rustc multiple.rs
$ ./multiple 3 0
Cannot divide by 0.
다른 인자 값으로도 실행해보세요.
실패 원인 제공하기
만약 예외가 여러 종류라면, 첫 번째 방법의 경우와 똑같이, 하나의 오류 값으로는 구분할 수 없다는 단점이 있습니다.
그 해결 방법으로, 빈 값 대신 여러 개의 값을 가질 수 있는 타입을 이용합시다.
여기에는 Either
같은 이름도 있지만, 러스트에서는 Result
로 제공합니다.
이 타입 또한 이뉴머레이션일 뿐이라는 사실을 참고하세요.
fn modulo(n: usize, d: usize) -> Result<usize, &'static str> {
if d == 0 {
Err("Cannot divide by 0.")
} else if n > 100 || d > 100 {
Err("Too large.")
} else {
Ok(n % d)
}
}
조금 인위적이기는 하지만, 숫자가 너무 큰 경우를 예외로 생각해보겠습니다. 여기서는 성공 값으로 정수를, 오류 값으로 원인을 나타내는 문자열을 가지도록 만든 것입니다.
이번에도 배수 함수는 ?
연산자를 이용해 간략하게 만들 수 있습니다.
fn is_multiple(n: usize, d: usize) -> Result<bool, &'static str> {
Ok(modulo(n, d)? == 0)
}
마지막으로 main()
함수에서는 이전과 마찬가지로 인자를 받은 뒤, 다음과 같이 패턴 매칭으로 성공 값과 오류 값을 처리합니다.
match is_multiple(n, m) {
Ok(result) => println!("Result: {}", result),
Err(err) => {
eprintln!("Error: {}", err);
std::process::exit(1);
}
}
실행 결과는 다음과 같습니다.
$ rustc multiple2.rs
$ ./multiple2 3 0
Error: Cannot divide by 0.
$ ./multiple2 101 1
Error: Too large.
이렇게 여러 종류의 예외가 처리됩니다.
마치며
앞서 예외 처리 방법 세 가지를 따로 살펴봤지만, 하나의 프로그래밍 언어가 꼭 한 가지를 선택하는 것은 아닙니다. 예를 들어, C++와 자바를 비롯한 언어에서는 던지기와 옵션 타입이 공존하기도 합니다. 따라서 상황에 따라 적절한 예외 처리 방법을 사용하는 것이 중요할 것입니다.
예시에서 구현한 코드는 지스트Gist에서 확인할 수 있습니다.
레퍼런스
다음은 참고한 예시 코드입니다.
- open(2) (man7.org)
- stat(2) (man7.org)
- Throwing exceptions (cppreference.com)
os.ReadFile
(Go Packages)fs.readFile
(Node.js Documentation)
러스트의 타입에 관한 문서입니다.