C++에서 예외를 어떻게 처리하는지에 대해서 알아보도록 한다.
C++에서도 예외를 지원한다. 다만 C++에서는 예외의 중요성이 다른 언어(ex. JAVA)에 비해서 좀 떨어진다. JAVA나 C#에 당연히 있는 예외가 C++에는 없는 경우도 있다. 그래서 이번 포스팅에서는 올바른 사용법 위주로만 알아본다. 예외를 남용하는 것은 지양해야 한다.
예외가 발생하는 상황
첫 번째 예외 발생 상황은 범위 이탈이다.
다음과 같이 범위를 넘어서서 참조를 하는 상황일 경우 에러가 발생하고 try/catch를 통해서 예외 처리를 해줄 수 있다. try/catch가 없는 경우 Visual Studio는 핸들링이 되지 않은 exception이 있는 경우 breakpoint를 걸어주는 기능이 있다.
참고로 아래와 같은 코드는 예외라고 부르지 않는다. 예외는 기본적으로 예측할 수 없고, 컨트롤 할 수 없는 상황을 말하는 것이다. 위의 코드는 충분히 컨트롤 할 수 있고 예측할 수 있다.
두 번째 예외 발생 사례는 0으로 나눈 경우이다.
0으로 숫자를 나누면 어떤 결과가 발생할까? 당연히 에러가 발생한다. C++에서도 exception이 뜨기는 한다. 하지만 정의되지 않은 상태라는 의미로 나타는 것 뿐이다. 이 예외는 C++의 예외가 아니라 운영체제의 예외이다.
그리고 이 역시 충분히 예측 가능한 부분이다. 나누는 값 (여기서는 num2)이 0이 아닌 경우만 나누어 주는 식으로 조건문을 추가하면 된다. 이 역시 예외로 볼 수 없다.
세 번째 예외는 NULL 개체가 발생하는 경우이다.
이 경우도 예외가 발생한다. 하지만 C++의 예외가 아니다. 메모리가 0이기에 발생하는 운영체제 상의 에러이다. 이 역시 myCat == NULL인 경우 조건문으로 처리해 주면 해결할 수 있다.
앞으로도 OS에서 발생하는 예외와 C++에서 처리하는 예외는 구분하는 것이 바람직하다.
따라서 대부분의 예외는 불필요하다. 하지만 생성자의 경우는 이론적으로 필요하다.
만약 위의 코드에서 mSlots가 NULL인 경우는 어떻게 해야 할까? 이 경우 예외 처리를 해 주어야 한다. 자체적인 예외를 만드는 코드는 아래와 같이 만들 수 있다.
그러나 생성자에서 쓰는 예외도 문제가 없는 것은 아니다. 우선 예외처리 기능이 꺼 있는게 많은 C++ 컴파일러의 기본 옵션이다. 물론 기능을 켜 놓으면 되지만 그렇게 되면 느리다. 성능이 중요한 C++에서 치명적이다. 애초에 예외 처리가 많이 필요한 분야라면 C++ 가 적합한 프로그래밍 언어가 아닌 것이다.
그리고 만약에 메모리 부족 때문에 예외가 발생하면 어떻게 해야 할까? 프로그램을 종료하게 되면 그건 크래시와 다를 바가 없다. 생성자 호출을 재시도 할 수도 없다. 메모리가 부족하기 때문이다. 이런저런 이유로 예외가 많이 발생할 것 같은 프로그램에서는 C++가 좋은 선택지가 아닌 것임을 알 수 있다.
예외 처리의 루머와 진실
예외 처리에 대한 몇 가지 소문(?)들이 있는데 이 부분도 가볍게 짚고 넘어가도록 하자.
- 예외 처리가 가독성이 더 좋다.
- 예외 처리가 유지보수가 더 쉽다.
- 예외 처리를 하면 프로그램이 더 탄탄하다.
첫 번째 소문 부터 짚고 넘어가면 결론은 별 차이 없다. 예외 처리가 아닌 다른 부분에서 차이가 있을 수 있지만 같은 기능으로 비교해 보면 크게 차이가 없음을 알 수 있다. 이 부분은 성향 차이이므로 취향껏 선택하는 편이 좋을 것 같다.
두 번째 소문, 세 번째 소문도 뒷받침할 증거나 데이터가 없다. 오히려 운영체제(윈도우, 리눅스 등)는 대부분 C와 같은 언어로 짜여져 있고 예외처리를 많이 쓰지 않는다. 증거가 없다고 틀린 것은 아니나 지난 20여년 동안 프로그래머들이 예외를 써본 결과 대부분의 프로그래머들은 예외를 제대로 처리하지 못한다는 것을 깨달았다.
다음 코드를 한 번 살펴보자. Java로 작성한 코드이다.
이 코드는 예외 안정성이 보장되지 않았다. 예외 안정성이란 프로그램이 예외 처리 후에 정상적으로 계속 돌아가는 성질을 의미한다. 이 코드에 따르면 커피숍은 커피(아이템)가 없을 때 예외를 던지는데 커피숍이 포인트를 고객으로부터 받고 아이템을 주어야 하는데 예외를 던져서 아이템을 주지 않았다. 그리고 다음 손님을 받는다. 이것은 분명히 잘못된 프로세스이다.
위의 코드를 조금 바꾸어 본 코드는 아래와 같다. 코드를 조금 안전하게 고쳐보았다.
이번에는 아이템이 있을 때만 고객 포인트를 까기 때문에 아까처럼 고객의 포인트를 받고 아이템을 주지 않는 일은 일어나지 않을 것이다. 다만 이렇게 모든 예외를 처리하기에는 프로그램이 커지면 너무 복잡하다.
수 많은 언어에서는 어떤 함수가 무슨 예외를 던지는지 알기가 힘들다. 왜냐하면 함수 헤더에 그 함수에서 던지는 예외를 표기하지 않기 때문이다. 예외를 어디서 던지는지 찾기 위해 거치는 함수를 다 뒤져보아야 하는 번거로움이 있다. 여기에 대한 예외 중 대표적인 언어가 Java이다. Java의 경우는 함수 시그니처 옆에 그 함수에서 나오는 예외 목록을 적어주기 때문이다.
자바도 요즘은 아래와 같이 catch 블록에서 한 번에 예외를 처리하는 식으로 흐름이 바뀌고 있다.
웹에서는 코드(200, 404 등)로 에러 처리를 한다. C++에서도 Struct, Class 등을 통해서 에러 처리를 할 수 있다.
그러면 이번에는 적절한 예외 처리 전략에 대해서 알아보도록 하자.
- 유효성 검사/예외는 오직 경계에서만 할 것
- 밖에서 오는 데이터를 제어할 수 없기 때문
- ex. 외부에서 들어오는 웹 요청, 파일 I/O, 외부 라이브러리
- 일단 시스템에 들어온 데이터는 다 올바르다고 간주할 것
- assert를 사용하여 개발 중 문제를 잡아내고 고칠 것
- 내부 로직에 집중하는 것이 중요하다. 그렇기 때문에 경계 상황을 꼼꼼하게 체크할 것!
- 예외 상황이 발생할 때는 NULL을 능동적으로 사용할 것
- 기본적으로 함수가 NULL을 반환하거나 받는 일은 없어야 함
- 코딩 표준 : 만약 NULL을 반환하거나 받는다면 함수의 이름을 잘 지어야 함. (ex. startWithOrNull)
사람의 생명이 달린 프로젝트(ex. 우주선, 의료기기, 자동차 등)는 정말 목숨 걸고 테스트를 해야 한다. 나는 그에 비해서 정말 다행이지 싶다. 엔지니어로서의 사명감을 좀 더 가지고 개발에 임해야 겠다.
예외 안전성 <Effective C++> 항목 29
배경 이미지를 깔고 나오는 GUI 메뉴를 구현하기 위한 클래스를 하나 만든다고 가정하자.
이 함수는 예외 안정성을 지키지 않은 함수이다.
예외 안정성을 가진 함수는 예외가 발생할 때 이렇게 동작해야 한다.
- 자원이 새도록 만들지 않는다 : 위의 예제는 자원이 샌다. new Image(imgSrc) 표현식에서 예외를 던지면 unlock 함수가 실행되지 않게 되어 뮤텍스가 계속 잡힌 상태로 남아있기 때문이다.
- 자료구조가 더럽혀 지는 것을 허용하지 않는다 : new Image(imgSrc) 표현식에서 예외를 던지면 bgImage가 가리키는 객체는 이미 삭제가 되었다. 그리고 새 이미지가 깔리지도 않았는데 ImageChanges 변수는 증가가 되었다.
따라서 위의 코드는 다음과 같이 바꿔서 예외 안정성을 지켜준다. 이 때 뮤텍스를 적절한 시점에 해제하는 Lock 클래스를 사용한다. 이렇게 하면 자원 누출 문제는 어느정도 깔끔하게 해결을 할 수 있을 것으로 보인다.
자료구조 오염을 해결하기 위해서는 또 다른 관점에서 보아야 한다. 예외 안전성을 갖춘 함수는 아래의 세 가지 보장(guarantee) 중 하나를 제공한다.
- 기본적인 보장 (basic guarantee)
- 함수 동작 중에 예외가 발생하면, 실행 중인 프로그램에 관련된 모든 것들을 유효한 상태로 유지하겠다는 보장
- 어떤 객체나 자료구조도 더럽혀지지 않으며, 모든 객체의 상태는 내부적으로 일관성 유지
- 프로그램의 상태는 어떤지 정확하게 예측이 안 될 수도 있음
- 강력한 보장 (strong guarantee)
- 함수 동작 중에 예외가 발생하면, 프로그램의 상태를 절대로 변경하지 않겠다는 보장
- 이런 함수를 호출하는 것은 원자적(atomic)인 동작으로 볼 수 있음
- 호출이 성공하면 마무리까지 완벽, 실패하면 함수 호출이 없었던 것처럼 되돌아감
- 예외불가 보장 (nothrow guarantee)
- 예외를 절대로 던지지 않겠다는 보장. 약속한 동작은 언제나 끝까지 완수하는 함수
- 기본 제공 타입(ex. int, 포인터 등)에 대한 모든 연산은 예외를 던지지 않음
예외 안전성을 갖춘 함수는 위의 세 가지 보장 중 하나를 반드시 제공해야 한다. 만약 위의 세 가지 보장 중 하나를 골라야 한다면 아무래도 실용성이 있는 강력한 보장이 괜찮아 보일 것이다. 예외 불가 보장을 선택할 수 있으면 해야하지만, 예외를 던지는 함수를 호출하지 않고 C++의 C 부분으로부터 빠져 나오는 것은 쉽지 않다.
changeBackground 함수로 돌아가서 살펴보면 강력한 보장을 거의 제공해 볼 수 있다. 먼저 첫 번째로 PrettyMenu의 bgImage 데이터 멤버의 타입을 기본제공 포인터 타입인 Image*에서 자원관리 전담용 포인터로 바꾼다. 두 번째로 changeBackground 함수 내의 문장을 재배치해서 배경 이미지가 진짜 바뀌기 전에는 imageChanges를 증가시키지 않도록 만든다. 어떤 동작이 일어났는지를 나타내는 객체를 프로그램 내에서 쓰는 경우 해당 동작이 실제로 일어날 때 까지 그 객체의 상태를 바꾸지 않는 편이 좋다.
위에 설명한 대로 바뀐 코드는 다음과 같다.
여기까지 하면 거의 다 완성이 되었다. 사실 뒤에 더 살펴보아야 할 케이스가 있지만... 이번 포스팅은 여기서 마무리를 하고 다음번에 뒷부분 예외 처리는 좀 더 자세하게 설명해 보도록 하겠다. ㅎㅎ
참고자료
- <C++ 언매니지드 프로그래밍> 포큐 아카데미
- <Effective C++> 스콧 마이어스 저
'Prog. Langs & Tools > C++' 카테고리의 다른 글
[C++] Ch11. STL - 맵(Map) (0) | 2021.04.14 |
---|---|
[C++] Ch10. STL - 벡터(Vector) (0) | 2021.03.31 |
[C++] Ch08. 인라인 함수, static 키워드 (0) | 2020.11.30 |
[C++] Ch07. 캐스팅(Casting) (0) | 2020.11.16 |
[C++] Ch06. OOP 3 (0) | 2020.10.19 |