djm03178's profile image

djm03178

September 19, 2020 19:15

PS에서의 런타임 에러와 디버깅

debugging, , 알고리즘

서론

알고리즘 문제를 온라인 저지에서 풀다 보면 다양한 종류의 채점 결과 (verdict)를 받게 됩니다. 백준 온라인 저지의 경우 통상적으로 볼 수 있는 채점 결과만 ‘맞았습니다!!’, ‘틀렸습니다’, ‘시간 초과’, ‘메모리 초과’, ‘컴파일 에러’, ‘런타임 에러’, ‘출력 초과’, ‘출력 형식이 잘못되었습니다’ 등 8가지나 됩니다.1 그런데 애석하게도 이 중에 좋은 결과는 오로지 ‘맞았습니다!!’ 하나뿐이고, 나머지는 모두 안 좋은 결과입니다.

안 좋은 결과들의 의미는 대부분 명확합니다. ‘틀렸습니다’는 말 그대로 출력 내용이 정답이 아니었던 것이고, ‘시간 초과’는 말 그대로 문제에 적힌 시간 제한을 넘길 때까지 프로그램이 종료되지 않았다는 의미이고, ‘메모리 초과’ 역시 문제에 적힌 메모리 제한보다 많은 메모리를 사용했을 때 받게 됩니다. ‘컴파일 에러’는 대부분의 온라인 저지에서 어떤 에러 메시지를 받았는지 볼 수 있는 기능까지 제공해 줍니다.2 ‘출력 초과’나 ‘출력 형식이 잘못되었습니다’는 온라인 저지에 따라서는 그냥 ‘틀렸습니다’로 처리할 수도 있지만, 역시 출력 쪽에 문제가 있었다는 것을 인지하는 데에는 어려움이 없습니다.

그런데 ‘런타임 에러’는 굉장히 막막합니다. 뭔가 에러가 났다고는 하는데, 코드의 어느 부분에서 났는지, 무슨 종류의 에러가 난 것인지도 알아낼 길이 없습니다. 혹시 실행 시간(run time)이 너무 오래 걸려서 에러로 처리한 건 아닐까요?3 이 글에서는 런타임 에러에는 어떤 종류가 있고, 어떤 경우에 자주 발생할 수 있는지, 또 이 결과를 받았을 때 어떻게 대응해야 할지를 알아보겠습니다.

필자의 문제 풀이 주 언어인 C++을 기준으로 설명하나, 다른 언어들에 대해서도 대부분의 내용이 공통적으로 해당될 것입니다.

런타임 에러의 의미

런타임 에러를 한 문장으로 정의하면 프로그램이 실행 도중 비정상적으로 종료되는 것입니다. 비정상적인 종료는 왜 발생할까요? 그 이유는 간단합니다. 프로그램이 해서는 안 되는 행동을 했기 때문입니다. 프로그램의 모든 코드는 항상 자신에게 주어진 메모리 영역 내에서 읽기 및 쓰기를 수행해야 하며, 프로그래밍 언어 및 CPU 상에서 정의된 연산만을 수행해야 하고, 라이브러리를 올바른 사용법을 통해서 써야만 합니다. 또한 운영체제가 부여한 권한 내에서만 행동해야 합니다.

프로그램이 해서는 안 되는 행동을 하면 더 이상 올바르게 프로그램을 진행시킬 수 없기에 프로그램이 강제로 종료됩니다. C나 C++처럼 기계어로 번역되어 실행되는 프로그램을 강제로 종료시키는 주체는 일반적으로 운영체제인데, 이는 프로그램의 잘못된 행동을 CPU가 감지하면 제일 먼저 운영체제에게 인터럽트를 통해 알려주며, 이때 운영체제가 취하는 가장 기본적인 조치가 바로 프로그램을 즉시 종료시키는 것이기 때문입니다. Java나 Python 등의 인터프리어 언어는 대부분의 잘못된 행동을 인터프리터가 먼저 감지하고 에러를 발생시키지만, 이 역시 예외 처리를 하지 않으면 프로그램이 이어서 실행되는 것이 불가능하기에 강제 종료에 이르게 되고 런타임 에러로 간주됩니다.

프로그램이 자의적으로 강제 종료를 하는 경우도 있는데, 대표적인 것이 라이브러리에서 발생시키는 예외입니다. 라이브러리를 잘못된 방법으로 사용한 경우 많은 프로그래밍 언어에서는 예외를 던지는데4, 이를 처리해주는 핸들러가 없는 경우 프로그램의 종료까지 이어지며 이 역시 런타임 에러로 간주됩니다.

프로그램이 끝까지 정상적으로 실행되지 않았기에, 대부분의 온라인 저지에서는 출력 결과에 대한 채점 자체를 하지 않습니다. 설령 그 시점까지 출력한 내용이 있고 그것이 정답일지라도, 처음부터 끝까지 문제 없이 실행된 것이 아닌 이상 올바른 프로그램을 제출했다고 볼 수 없기 때문입니다.

런타임 에러의 디버깅

런타임 에러를 디버깅하는 제일 쉽고 기본적인 방법은 디버거를 사용하는 것입니다. C/C++ 컴파일러들이 제공하는 디버깅 모드는 일반적으로 사소한 배열 범위 초과 오류, 특히 쓰기 오류를 거의 항상 감지할 수 있도록 하는 기법을 코드에 추가해 주며,5 그 외에도 최적화된 릴리즈 모드에서는 쉽게 확인되지 않는 대부분의 잘못된 동작, 이를테면 라이브러리의 잘못된 사용 등을 미리 감지하여 프로그래머에게 어느 부분에서 어떤 잘못이 발생했는지를 구체적으로 알려줍니다.

또한 디버거는 함수 호출 스택을 기억하여 프로그램의 실행 흐름을 눈으로 볼 수 있게 해주고, 브레이크 포인트를 걸어 특정 지점에서 실행을 일시 정지하고 해당 시점에서의 변수나 배열의 값들을 즉석에서 검색하는 기능 등도 제공하므로, 이런 것들을 적극적으로 활용하면 빠르고 효율적으로 디버깅을 할 수 있습니다.

중요한 것은 런타임 에러가 발생할 만한 입력을 찾아내는 것인데, 이 작업은 일반적인 반례를 찾는 것과 마찬가지로 쉽지 않은 작업이 될 수도 있습니다. 운이 좋다면 릴리즈 모드로는 별 문제가 없이 넘어가졌던 예제 입력만으로도 디버거를 통해 실제로는 문제가 있었음을 찾아낼 수도 있고, 그렇지 않다면 직접 다양한 예시를 만들어 가며 문제가 발생하는 상황을 만들어야 할 것입니다.

반례 찾기가 일반적으로 그렇듯이 런타임 에러 역시 극단적인 입력에서 발생할 확률이 높기 때문에, 최소 / 최대 입력, 최솟값 / 최댓값, 극단적으로 값이 요동치는(?) 입력, 또는 전부 같은 값 등등 최대한 자신의 코드를 공격하고자 하는 마음가짐으로 반례를 만들어 보는 것이 효과적입니다.

런타임 에러의 주요 원인들

프로그래머의 입장에서, 런타임 에러를 발생시키는 ‘잘못된 코드’를 작성하게 되는 주요 패턴은 다음과 같습니다.

잘못된 배열 인덱스 참조

가장 흔한 실수이며, 런타임 에러의 직접적인 원인으로서도 가장 빈번한 요소입니다. 말 그대로 배열의 크기를 넘어서는 인덱스를 통해 배열을 접근했을 때 발생합니다. 이것은 보다 일반화하면 잘못된 메모리 주소를 참조하는 경우에 해당하고, 아래 소개하는 많은 경우에도 직접적인 에러의 원인이 이에 해당합니다.

대표적으로 다음과 같은 코드들이 잘못된 배열 인덱스 참조로 런타임 에러를 발생시킬 수 있습니다.

int arr[100];

arr[10000] = 1; // 1
arr[100] = 2; // 2
arr[-1] = 3; // 3

arr 배열은 크기가 100이기 때문에 0번부터 99번 인덱스까지만 존재합니다.

1번의 경우 10000이라는 인덱스를 참조하고 있으니 배열의 범위를 벗어난 것이 쉽게 눈에 띕니다. 이렇게 배열의 크기를 많이 넘어서는 배열 접근은 배열을 선언할 때부터 크기를 잘못 생각한 경우에 자주 발생합니다. 다시 한 번 각 배열의 크기가 얼마가 되어야 맞는지 생각하다 보면 비교적 어렵지 않게 찾아낼 수 있는 케이스이기도 합니다.

각별히 조심해야 하는 케이스는 2번과 3번 케이스입니다. 2번 케이스의 경우 배열의 크기가 100이기 때문에 100번 인덱스에 접근하려는 실수는 누구나 쉽게 할 수 있지만, 100번에 접근하는 것이나 10000번에 접근하는 것이나 원칙적으로는 똑같은 undefined behavior이므로 단 한 칸의 인덱스라도 잘못 참조한다면 무슨 일이 일어날지 모르게 되는 것 역시 마찬가지입니다. 3번 케이스와 더불어 배열의 특정 인덱스에서 좌우를 살피다가 발생하는 경우가 많습니다. 예를 들면 BFS 탐색 중 다음 좌표 (ny, nx)를 방문했는지 확인하기 위해 visited[ny][nx]ny, nx범위보다 먼저 검사하는 경우에 쉽게 발생합니다.

이런 실수를 예방하기 위한 방법으로 다음과 같은 것들이 있습니다.

  1. 배열의 크기를 항상 넉넉하게 잡읍시다. 2배나 10배처럼 너무 크게 할 필요는 없고, 3칸 ~ 5칸 정도의 여유분이면 일반적으로 충분합니다. 예를 들어 입력이 10만 개 주어지는 문제라면 딱 int arr[100000];으로 쓰기보다는 int arr[100005];로 선언하는 것이 보다 안전합니다. 1, 2차원 배열에서 5칸의 여유분을 두는 것은 메모리 사용량이나 속도에 거의 아무런 영향이 없고, 그로 인해 가끔이라도 발생하는 실수를 방지할 수 있다면 훨씬 이득일 것입니다.
  2. 배열에 접근하기 위해 사용하는 인덱스가 배열의 범위를 벗어날 가능성이 있다면 범위를 먼저 검사해야 하는 것을 항상 기억합시다. 대부분의 고수준 언어에서는 short-circuit evaluation을 사용하고 있기 때문에 && 연산자로 연결된 여러 조건문들의 순서만 알맞게 바꾸어주어도 문제를 방지할 수 있습니다. if (!visited[ny][nx] && 0 <= ny && ny < r && 0 <= nx && nx < c)로 쓰면 visited[ny][nx]를 먼저 접근하기 때문에 문제가 될 수 있지만, if (0 <= ny && ny < r && 0 <= nx && nx < c && !visited[ny][nx])로 쓰면 문제가 없게 됩니다.
  3. 배열을 선언할 때 그 크기가 충분한지 100% 확신이 들게 합시다. 예를 들어 정점이 V개이고 간선이 E개인 그래프 문제에서 간선 정보를 저장하기 위해 E개의 간선들을 (s, e)의 형태로 크기 E의 배열에 저장할 수도 있고, 인접 리스트에 저장하기 위해 크기 Vvector의 배열의 s번째 벡터에 e를 추가할 수도 있습니다. 그런데 만약 전자의 경우를 사용하고 있는데 후자의 경우라고 착각하고 크기를 V로 잡게 되면 이러한 문제가 발생할 수 있습니다. 배열은 선언하는 시점에서 그 용도를 확실히 해야 하고, 중간에 의미가 변하는 경우가 있다면 그때마다 선언 방식에 하자가 없는지 꼼꼼한 체크가 필요합니다.

이외에도 배열의 인덱스를 1부터 시작하는 습관을 들이는 것도 하나의 방법이지만, 이는 취향에 가깝고 문제에 따라서 시작 지점을 달리하는 것이 편할 수도 있습니다. 다만, 0부터 시작했을 때 음수 인덱스를 더욱 조심해야 한다는 것은 염두에 두어야 합니다.6

다양한 예시들을 입력해 보다가 디버거가 잘못된 메모리 접근을 감지하여 알려주면 이러한 부분들을 다시 한 번 체크해 보고, 침범 시점에서의 인덱스 변수의 값이 얼마였는지를 역추적하다 보면 잘못 생각했던 부분이 어디였는지 찾아낼 수 있습니다. 처음으로 인덱스로 쓰일 변수의 값이 의도와 달라지는 시점을 찾아냈다면, 그 근처에 의도와는 다르게 동작한 식이 존재할 가능성이 높습니다.

인터프리터 언어들에서는 별도의 디버거가 없어도 인터프리터가 반드시 인덱스를 먼저 검사해주기 때문에 해당 위치를 항상 알아낼 수 있습니다. 어떤 언어든 런타임 에러가 발생했다면 발생한 위치를 먼저 찾고, 변수의 값이 의도대로 변하지 않게 된 최초의 지점을 역추적해나가는 것이 기본적인 방법일 것입니다.

문자열의 끝에는 널 문자가 반드시 들어간다

이 문단은 윗 문단의 하위 분류에 해당합니다. C/C++에서 문자열을 배울 때 반드시 배우는 부분이지만 항상 잊어버리기 쉬운 부분으로, 문자열은 항상 그 길이보다 1 더 큰 배열 공간을 차지한다는 것입니다.

이것은 윗 문단에서 배열의 크기를 넉넉하게 잡는 것이 좋은 이유 중에 하나입니다. 문자열의 길이가 100 이하라고 주어졌고 이것을 문자열로서 입력을 받는다면 그 끝에 널 문자가 붙으므로 실제로는 101칸의 배열이 필요해집니다. 그런데 간혹 101칸으로도 모자라는 경우가 있는데, 일부 입력 함수의 경우 각 줄의 끝에 있는 개행 문자까지 입력받는 경우도 있기 때문입니다. 이런 경우에는 102칸의 배열이 필요하게 되며, 배열 인덱스를 1부터 시작하는 코드를 사용한다면 무려 103칸의 배열이 필요하게 될 수도 있습니다.

0으로 나누기

0으로 나누는 것은 수학에서도 정의되지 않은 연산이며, 컴퓨터에서도 일반적으로 아예 수행할 수 없는 연산으로 판단하고 런타임 에러를 발생시킵니다. 단, 이는 정수 나눗셈에만 국한되며, 실수 나눗셈으로 0으로 나눈 경우에는 NaN이 만들어집니다.

일반적으로 0으로 나누는 일은 그리 흔하게 발생하지는 않습니다. 이런 오류가 발생하는 것은 보통 변수의 값을 구하는 수식이 잘못된 경우입니다. 문제를 제대로 푸는 과정에서 0으로 나누는 경우를 예외 처리해야 하는 경우도 간혹 있지만, 보통은 그런 경우보다는 변수의 값을 구하는 식 자체가 의도한 것과 다르게 동작했거나, 또는 잘못된 변수를 사용하여 식을 구성했을 가능성이 높습니다.

라이브러리의 잘못된 사용

많은 고수준 프로그래밍 언어들에는 예외 (exception) 기능이 있습니다. 이는 무언가 잘못된 것을 감지했을 때 런타임 에러를 발생시키기 전에 해당 문제를 해결할 수 있는 기회를 부여하기 위해 함수, 또는 블록을 실행시킨 주체에게 알림을 보내는 것입니다. 만일 그 주체가 해당 예외를 처리하는 코드를 가지고 있다면 런타임 에러 없이 계속 실행을 시킬 수도 있지만, 아무도 그 예외를 처리하지 않는다면 예외가 그대로 끝까지 올라가 결국 강제 종료, 즉, 런타임 에러에 이르게 됩니다.

라이브러리의 일부 함수들은 특정 조건이 감지되었을 때 이러한 예외를 던지게 되어있습니다. 하지만 퍼포먼스상의 이유로 예외를 던지지 않는 경우도 많으며, 많은 경우 디버깅 모드에서만 조건을 검사하여 예외를 던지게 합니다. 그래서 디버깅 모드를 사용한다면 라이브러리를 잘못 사용한 경우에 바로 정확한 위치와 원인을 찾아낼 수 있을 가능성이 높습니다.

대표적으로 std::vectoroperator[]는 표준상 배열의 인덱스 검사를 하지 않지만, 디버깅 모드로 컴파일할 경우 그 검사를 수행하여 예외를 발생시키는 코드가 추가될 수 있습니다. 특히 vector의 경우 메모리 할당 기법 때문에7 이 검사를 인위적으로 수행하지 않을 경우 인덱스가 1, 2 정도 벗어나는 것으로는 런타임 에러를 발생시키기가 매우 어렵기도 합니다.

하지만 이렇게 런타임 에러의 위치를 찾아내기 전에 먼저 해야 할 가장 중요한 것은 라이브러리의 사용법을 정확하게 숙지하는 것입니다. 표준 라이브러리의 사용법은 각 언어의 documentation에 상세하게 설명되어 있습니다. C/C++의 경우 https://en.cppreference.com/w/, Java의 경우 https://docs.oracle.com/javase/8/docs/api/index.html, Python의 경우 https://docs.python.org/3/ 등 모든 라이브러리 클래스 / 모듈 / 함수들에 대한 세부 명세가 있으니, 라이브러리를 사용할 때는 항상 그 모든 내용을 확실하게 지켜서 사용하고 있는지를 스스로 점검해야 합니다.

매우 자주 발견되는 잘못된 라이브러리 사용 예시를 몇 가지 나열하면 다음과 같습니다.

  • atoi 함수에 char형 변수의 주소값을 넘기기: char c = '3'; int x = atoi(&c);는 틀린 사용법입니다. char 변수의 주소값을 const char * 형의 인자로 넘길 수 있는 것은 맞지만, atoi 함수는 단순히 그 자료형의 인자를 받으면 되는 것이 아니라 넘겨받은 인자가 문자열 일 것을 요구합니다. 즉, 위에서 언급한 것과 같이 해당 주소에서 시작해서 어딘가에서 널 문자로 끝나는 지점이 있어야 하는 것이고, char 변수 하나의 주소를 넘기는 것은 잘못된 사용 방법입니다. 참고로, 숫자를 가진 char형을 해당하는 정수값으로 바꾸기 위해서는 c - '0'을 하면 됩니다.
  • compare 함수의 잘못된 사용: 정렬과 관련된 함수들 (sort, priority_queue 등)에서는 원소의 대소 비교를 위한 compare 함수를 정의해줄 수 있습니다. 그런데 이 함수는 반드시 지켜줘야 할 조건들이 여럿 있는데, 가장 중요한 것은 이 함수가 비교하는 원칙이 ‘strict weak ordering’을 만족해야 한다는 것입니다. 구체적으로는 몇 가지 조건이 있지만 많이 실수하는 부분은 compare(a, b) == true 이면서 compare(b, a) == true인 경우는 없어야 한다는 것인데, compare 함수를 return a <= b;와 같이 구현하면 ab가 같은 우선순위를 가지는 경우에 어느 순서로 비교해도 항상 true가 반환되는 문제가 발생하게 됩니다. 따라서 return a < b;와 같이 둘이 같은 경우 compare 함수가 둘 다 false를 반환하도록 만들어주어야 합니다.
  • Invalidate된 iterator의 사용: Iterator가 있는 클래스의 경우 메서드의 사용에 따라 iterator가 invalidate, 즉, 무효화되는 경우가 있습니다. 대표적인 것으로 vector의 모든 iterator는 해당 벡터에 push_back이 이루어질 때마다 invalidate될 가능성이 있습니다.8 또한 erase를 한 경우에도 해당 지점 이후의 모든 iterator가 invalidate됩니다. 무효화된 iterator는 원칙적으로 사용할 수 없기 때문에 각별히 사용에 주의해야 하며, 컨테이너에 따라 iterator가 무효화되는 연산들이 각기 다르기 때문에 레퍼런스를 잘 확인해야 합니다.

이외에도 흔히 보이는 라이브러리의 잘못된 사용 예시는 매우 많으며, 기회가 된다면 이들만을 모아서 글을 써보겠습니다.

반환형이 있는 함수에서 반환하지 않을 때

또 한 가지 자주 하게 되는 실수는 반환형이 void가 아닌 함수에서 반환을 하지 않는 것입니다. 반환형이 void가 아니라면 반드시 어떠한 반환값이 있어야 하는데, 반환을 하지 않을 가능성이 있더라도 컴파일러가 반드시 그것을 잡아준다는 보장이 없기 때문에 스스로 조심해야 합니다.

단순히 마지막에 넣어야 할 return문을 하나 잊어버렸던 것이라면 쉽게 찾아 고칠 수 있지만, 문제는 함수 도중 분기를 통해 반환을 하는 경우입니다. 이 경우 모든 경우에 대해 반환이 일어날 것이라고 생각하여 무심코 지나치게 될 수도 있습니다. 예시 코드는 다음과 같습니다.

int f(int x)
{
  if (x > 0)
    return 1;
  else if (x < 0)
    return -1;
}

이 코드는 x를 0을 기준으로 크면 1을, 작으면 -1을 반환하려고 하고 있으나, 한 가지 빠뜨린 케이스가 있습니다. 바로 x가 0인 경우입니다.

이러한 실수를 예방하려면 우선 함수의 원형을 만들 때부터 그 함수가 어떤 값을 반환할 것인지를 명확히 설계하고, 모든 분기에서 그 의미에 맞는 반환값을 반환하도록 신경 쓰면서 코드를 작성해야 합니다. 또한 함수의 마지막 문장이 return문이라면 그 함수는 모든 경우에 값을 반환하게 되므로, 반환형이 있는 함수를 작성할 때 처음부터 맨 마지막에 return문을 하나 작성해두고 시작하는 것도 방법입니다.

main 함수 / 프로그램의 잘못된 반환

main 함수는 반드시 0을 반환해야 합니다. main 함수, 즉, 프로그램의 반환값은 대다수의 온라인 저지들이 런타임 에러 여부를 판단하는 근거로 사용됩니다. 프로그램이 정상적으로 종료되면 0을, 그 외의 경우에는 0이 아닌 값을 반환하도록 하는 것은 프로그래밍계의 암묵적인 규칙이며, 이는 문제 풀이에서도 마찬가지입니다.

main 함수가 반환하는 값은 곧 프로그램이 프로그램을 실행한 주체에게 돌려주는 값이기도 합니다, 따라서 프로그램이 올바르게 종료되었다면 main 함수가 반드시 0을 반환하게 해야 하며9, main 함수의 반환 대신 exit 함수를 통해 종료할 때에도 반드시 인자로 0을 넘겨주어야 정상적으로 종료했음을 나타내게 됩니다.

또한 main 함수의 반환형은 int여야 합니다. main 함수가 void를 반환하는 것은 표준이 아닐 뿐더러, 실제로 이런 코드에 대한 컴파일을 지원해주는 GCC에서도 void main()을 했을 때 반환되는 값은 0이라는 보장이 없습니다. 반드시 int main()으로 작성해 주고, 프로그램이 항상 모든 경로에서 0의 exit code를 가지도록 코드를 작성해야 합니다.

기타 직/간접적인 원인들

  • 초기화하지 않은 변수의 사용: 초기화하지 않은 변수를 사용하는 것은 undefined behavior입니다. 보통 C/C++을 배울 때 이런 경우 ‘쓰레기값’이 들어있다고 배우지만, 실제로는 그마저도 보장이 없습니다. 값을 실제로 대입하여 유효한 상태로 만드는 문장이 없다면, 컴파일러는 이 변수를 아예 없는 것 취급하고 최적화를 수행해버릴 수도 있습니다. 결국, 프로그램이 전혀 예측하지 못한 흐름으로 흘러갈 수 있게 되며 많은 경우 런타임 에러를 유발하는 문장을 실행하게 됩니다.
  • delete된 객체의 사용 / 다시 delete:어떤 객체를 delete 하기 위해서는 그 객체가 반드시 new로 할당된 객체여야 합니다. 또한 한 번 delete한 객체에는 다시 접근해서는 안 되며, 이미 delete된 주소를 다시 delete 하는 것도 안 됩니다.
  • 정수 오버플로: 부호 있는 정수 (signed)에서 오버플로가 발생하는 것은 기본적으로 undefined behavior이며, 이 자체로 런타임 에러를 발생시키는 일은 거의 없지만 프로그램의 동작 자체를 예측하기 매우 어렵게 만들기도 합니다. 또한 부호 없는 정수(unsigned)에서의 오버플로는 undefined behaivor는 아니지만, 역시 프로그램의 흐름을 예상한 것과 전혀 다르게 만들 여지가 있기 때문에 의도하지 않았다면 반드시 피해야 합니다. 대표적인 예시로 vector<int> v;에 대해 for (int i = 0; i < v.size() - 1; i++)를 수행하는 것이 있습니다. 이는 v.size()가 부호 없는 정수를 반환하기 때문에 크기가 0인 경우 UINT_MAX가 반환되어, 루프를 엄청나게 많이 돌게 될 뿐 아니라 v[i]를 사용하는 모든 경우가 위험 요소가 됩니다.

런타임 에러를 “이용한” 디버깅

그렇다면 런타임 에러는 무조건 나쁜 것이고, 없을수록 디버깅하기 편한 것일까요? 그렇지는 않습니다. 개인적으로는 오히려 ‘런타임 에러’는 ‘틀렸습니다’보다 훨씬 디버깅에 용이하고, 살펴보아야 할 실수의 유형도 한정적이어서 디버깅하기가 쉽다고 생각합니다. 런타임 에러가 발생할 만한 문장이 있는데도 우연히 그것이 직접적인 에러를 유발하지 않고 간접적으로 출력하는 답에만 영향을 미쳐서 ‘틀렸습니다’를 받는다거나, 프로그램의 실행 흐름을 무한 루프로 바꾸어버려 ‘시간 초과’를 받는다면 그것들만큼 디버깅하기 어려운 것이 없을 것입니다.

런타임 에러는 실제로 디버깅을 하기 위해 유용하게 사용될 수 있으며, 라이브러리에서 에러가 날 만한 가능성이 조금이라도 있는 상황을 감지하고 예외를 던져주는 것 역시 디버깅을 더욱 용이하게 할 수 있게 해주기 위함이지, 프로그램을 더 취약하게 만들어 조금만 어긋나도 꺼뜨리기 위함이 아닙니다.

문제를 푸는 입장에서도 이를 직접적으로 활용할 수 있습니다. 대표적인 것이 assert입니다. assert는 어떤 조건이 참인지를 확실하게 한다는 의미로, 그 조건이 어긋난 경우 바로 메시지와 함께 런타임 에러를 발생시켜 줍니다. 프로그램이 올바르게 동작하고 있는지를 프로그래머가 코드 곳곳에 체크하면서, 어딘가에서 의도와 다르게 동작하는 곳이 발견되면 바로 그 위치를 알아챌 수 있도록 작업을 해두는 것입니다.

또는 보다 간접적으로, 위에서 언급한 대로 main 함수의 반환값이나 exit 함수의 인자로 0이 아닌 값을 반환하여 런타임 에러를 발생시키는 방법도 있습니다. 코드를 온라인 저지에 제출했는데 해당 문장이 없을 때는 ‘틀렸습니다’가 나왔고, 추가했더니 ‘런타임 에러’가 나왔다면 그 조건이 생각한 것과 다르게 어긋나는 경우가 있을 수 있다는 것을 알아낼 수 있습니다.

마치며

다양한 런타임 에러의 경우들을 살펴보았지만, 모든 경우의 공통점은 프로그램이 의도되지 않은 방향으로 동작하게 되는 경우들이라는 것입니다. 프로그램의 실행 흐름이 가능한 모든 입력 범위에 대해서 항상 수행이 가능한 문장들로만 이루어진다면 런타임 에러가 발생할 일은 없습니다.

따라서 해당 언어의 문법, 그리고 라이브러리의 사용법을 정확하게 숙지하고 사용하는 것이 중요하겠으며, 코드 설계 시 모든 요소에 대한 의미를 명확히 하며, 문제의 모든 가능한 입력에 대해 코드가 올바르게 동작할 수 있는지 확인하는 것이 런타임 에러를 잡는 기본적인 방법이라고 할 수 있겠습니다.

  1. 여기에 문제 유형에 따라서는 전체에서 몇 개를 맞았는지를 볼 수 있는 경우도 있고, 점수를 받는 경우도 있습니다. 또한 더 이상 지원되지 않는 언어로 제출한 코드가 재채점된 경우 채점 불가 결과를 받을 수도 있습니다. 

  2. 이는 같은 언어라고 하더라도 사용하는 컴파일러나 설치된 라이브러리 등에 따라 컴파일 가능 여부가 다를 수 있기 때문입니다. 

  3. 사실 이것은 아닙니다. 실행 시간이 너무 긴 경우는 ‘시간 초과’이고, 어떤 온라인 저지에서도 그를 런타임 에러로 처리하지는 않습니다. 

  4. 경우에 따라서는 잘못된 방법임을 라이브러리가 감지할 수 없거나 퍼포먼스상의 이유로 감지하는 코드를 넣지 않아 CPU나 인터프리터에게 잡혀 바로 런타임 에러로 이어질 수도 있습니다. 

  5. 보통 각 변수의 주변에 미리 정해진 값들을 넣어놓고 이 값이 변하는 일이 있지 않았는지 검사하는 방식으로 이루어집니다. 

  6. 매우 자주 사용되는 케이스로 prefix sum 테크닉이 있는데, 이 테크닉은 $[s, e]$ 구간의 합을 구하기 위해 ‘처음부터 $e$번째 인덱스까지의 합’에서 ‘처음부터 $s-1$번째 인덱스까지의 합’을 빼는 방법을 사용합니다. 만일 $s$가 $0$이 될 수 있다면 $s-1$은 $-1$이 되기 때문에 배열 범위를 침범하게 되므로, 이를 예외 처리해야 하는 번거로움이 발생합니다. 인덱스를 1부터 시작한다면 이럴 일이 없습니다. 

  7. 대체로 원소가 예약 공간 (capacity)을 가득 채우고 넘어서려 할 때마다 2배씩 늘리는 기법을 사용하며, 이 늘어난 공간은 벡터의 사용법상으로는 직접 접근해서는 안 되는 영역이지만 프로그램 자체에게는 사용이 허용되었기 때문에 런타임 에러가 발생하지 않습니다. 하지만 이렇게 늘어나지 않은 상태에서 size() 이상의 인덱스에 접근할 경우 런타임 에러가 발생할 수도 있습니다. 

  8. 정확히는 size()capacity()보다 커질 때는 모든 iterator가 무효화되고, 그 외에는 end() iterator만 무효화되지만, 사실상 이 두 경우를 구분해서 구현할 경우가 없기 때문에 항상 모든 iterator가 무효화된다고 가정하고 코드를 작성하는 것이 좋습니다. 

  9. return 0;은 없어도 됩니다. 오로지 main 함수의 경우에만 함수의 마지막에 return문이 없는 경우 0을 반환하는 것으로 간주되기 때문입니다.