Skip to Content
독학사독학사 2단계C프로그래밍20. 컴파일 오류·런타임 오류·논리 오류 분석 연습

이번 문서의 목표: 컴파일 오류·런타임 오류·논리 오류를 구분하고, 시험에 자주 나오는 오류 찾기형 코드를 한 줄씩 실행 추적하며 원인을 정확히 짚어낼 수 있다.

오류를 세 갈래로 나누는 이유

왜 필요한가

독학사 C프로그래밍 시험에는 “다음 코드의 문제점은 무엇인가”, “이 코드를 컴파일하면 어떤 일이 벌어지는가”를 묻는 오류 찾기형 문제가 꾸준히 나온다. 이런 문제를 풀려면 먼저 오류가 언제, 어떻게 드러나는지부터 구분해야 한다. 같은 실수라도 컴파일 시점에 걸리는지, 실행 중에야 터지는지, 아니면 프로그램은 멀쩡히 끝나지만 결과만 틀리는지에 따라 대처 방법과 시험 문항의 정답 근거가 완전히 달라지기 때문이다.

쉽게 말하면: 컴파일 오류는 “문장 자체가 틀려서” 번역기(컴파일러)가 아예 통과시켜주지 않는 것이고, 런타임 오류는 “문장은 맞는데 실행 중 사고가 나는 것”, 논리 오류는 “사고 없이 끝까지 실행은 되는데 답이 틀린 것”이다.

정의와 비교

컴파일 오류(compile error, compile-time error)는 소스 코드를 기계어로 번역하는 컴파일(compile) 단계에서 문법 규칙을 어겨 번역 자체가 중단되는 오류다. 6편에서 다룬 것처럼 C 소스는 전처리 → 컴파일 → 어셈블 → 링크 과정을 거쳐 실행 파일이 되는데, 컴파일 오류는 이 중 컴파일 단계를 통과하지 못해 실행 파일 자체가 만들어지지 않는다.

런타임 오류(runtime error, 실행 시간 오류)는 컴파일은 정상적으로 끝나 실행 파일이 만들어졌지만, 프로그램을 실제로 실행하는 도중에 운영체제나 하드웨어가 감지한 비정상 상황 때문에 프로그램이 강제로 종료되거나 예측 불가능한 동작을 보이는 오류다. 배열 경계를 벗어난 접근, 널(null) 포인터 역참조, 0으로 나누기 등이 대표적이다.

논리 오류(logic error)는 컴파일도 되고 실행도 끝까지 되지만, 프로그래머가 의도한 것과 다른 결과가 나오는 오류다. 컴파일러도 운영체제도 이를 “오류”로 감지하지 못하기 때문에 세 종류 중 가장 찾기 어렵다.

구분발생 시점컴파일러가 잡아주는가대표 사례
컴파일 오류컴파일 단계그렇다(빌드 실패)세미콜론 누락, 선언 안 된 변수 사용, 괄호 짝 불일치
런타임 오류실행 중아니다(실행은 시작됨)배열 범위 초과, 널 포인터 역참조, 0으로 나누기
논리 오류실행은 정상 종료아니다(결과만 틀림)조건식 부등호 반대, 반복 횟수 하나 어긋남(off-by-one)

자주 틀리는 점: “실행 파일이 만들어졌다”는 사실만으로 “오류가 없다”고 단정하는 것이 가장 흔한 함정이다. 실행 파일이 생성되었어도 런타임 오류나 논리 오류가 숨어 있을 수 있다. 반대로 “프로그램이 끝까지 실행되어 아무 메시지도 없이 종료됐다”는 것도 결과가 맞다는 뜻은 아니다 — 논리 오류는 원래 조용히 틀린 값을 낸다.

선언·타입 관련 오류

왜 필요한가

C는 정적 타입(static type) 언어이므로, 7편에서 배운 것처럼 모든 변수는 사용 전에 자료형을 선언해야 한다. 이 규칙을 어기면 대부분 컴파일 오류로 즉시 드러나지만, 형 변환(type conversion)이 자동으로 일어나는 경우에는 컴파일은 통과하면서도 값이 예상과 다르게 잘림(truncation)이 발생해 논리 오류로 이어지기도 한다.

쉽게 말하면: 선언·타입 오류는 “이 상자에 원래 담을 수 없는 걸 억지로 넣거나, 상자를 아예 준비하지 않고 쓰려는” 실수다.

코드로 확인하기 — 선언 누락

#include <stdio.h> int main(void) { total = 10 + 20; printf("%d\n", total); return 0; }

이 코드는 total이라는 변수를 어떤 자료형으로도 선언하지 않은 채 바로 대입하고 있다. 컴파일러는 total이라는 이름을 어디서도 찾을 수 없으므로 “선언되지 않은 식별자(undeclared identifier)“라는 컴파일 오류를 내고 번역을 중단한다. 실행 파일 자체가 생성되지 않으므로, 이 코드는 애초에 실행되지 않는다.

고친 코드

#include <stdio.h> int main(void) { int total = 10 + 20; printf("%d\n", total); return 0; }
30

코드로 확인하기 — 암묵적 형 변환으로 인한 값 손실

#include <stdio.h> int main(void) { int score = 1000000; short small_score = score; printf("%d\n", small_score); return 0; }

이 코드는 컴파일이 통과된다. int보다 표현 범위가 좁은 short(대개 16비트, 범위는 -32768부터 32767까지)에 그보다 큰 값을 대입하는 것은 문법적으로 허용되는 암묵적 형 변환(implicit conversion)이기 때문이다. 하지만 결과는 프로그래머의 의도와 다르다.

단계내용
1score에 1000000 저장score = 1000000
2short는 16비트라 표현 가능한 값의 개수가 65536가지뿐65536으로 나눈 나머지 개념으로 잘림
31000000을 65536으로 나눈 나머지 계산1000000mod65536=169601000000 \bmod 65536 = 16960
416960은 short의 양수 최댓값(32767)보다 작으므로 그대로 양수로 해석small_score = 16960
1000000mod65536=169601000000 \bmod 65536 = 16960
  • mod\bmod: 나머지 연산(modulo), 앞의 수를 뒤의 수로 나눈 나머지를 구한다
  • 6553665536: short(16비트)로 표현 가능한 값의 가짓수, 2162^{16}
16960

이처럼 큰 자료형의 값을 작은 자료형에 대입하면 상위 비트가 잘려나가는 현상을 오버플로에 의한 값 손실이라고 부른다. 컴파일러는 이를 문법 오류로 보지 않으므로(경고만 낼 수 있다), 시험에서는 “이 코드는 컴파일 오류가 난다”는 보기가 오답으로 자주 등장한다 — 정답은 “컴파일은 되지만 의도와 다른 값이 출력된다”는 논리 오류 쪽이다.

자주 틀리는 점: 형 변환으로 값이 잘리는 상황을 컴파일 오류라고 착각하는 경우가 많다. C 컴파일러는 암묵적 형 변환을 문법적으로 허용하므로, 값 손실은 런타임에 계산된 결과가 틀리는 논리적 문제이지 컴파일이 막히는 문제가 아니다.

포인터·배열 경계 오류

왜 필요한가

5편과 12편에서 배운 것처럼, C는 배열의 경계를 실행 중에 검사해주지 않는다. 배열 arr[5]가 있을 때 arr[5]arr[-1]에 접근해도 컴파일러도 실행 환경도 이를 막지 않는다. 이 특성이 C를 빠르게 만들지만, 동시에 시험에서 가장 즐겨 묻는 런타임 오류의 원천이기도 하다.

쉽게 말하면: 배열은 정해진 칸 수만큼만 준비된 사물함인데, C는 “몇 번 칸까지 있는지” 스스로 확인해주지 않는다. 없는 칸 번호를 부르면 남의 사물함(다른 변수의 메모리)을 건드리게 된다.

코드로 확인하기 — 배열 경계 초과

#include <stdio.h> int main(void) { int arr[5] = {10, 20, 30, 40, 50}; int guard = 999; for (int i = 0; i <= 5; i++) { printf("arr[%d] = %d\n", i, arr[i]); } printf("guard = %d\n", guard); return 0; }

이 코드의 반복문 조건은 i <= 5다. arr는 인덱스 0부터 4까지만 유효한데, 조건이 i < 5가 아니라 i <= 5로 되어 있어 i가 5일 때도 반복문 몸체가 한 번 더 실행된다. 이런 실수를 경계값 하나가 어긋난 오류(off-by-one error)라고 부른다.

메모리에서 arr의 5개 칸 바로 뒤에 guard가 배치되어 있다고 가정하면(실제 배치는 컴파일러·환경마다 다를 수 있다), arr[5]에 접근하는 순간 arr의 몫이 아닌 guard의 메모리를 읽게 된다.

i조건 i <= 5접근 위치결과
0arr[0]10 출력
1arr[1]20 출력
2arr[2]30 출력
3arr[3]40 출력
4arr[4]50 출력
5참(문제!)arr[5]배열 밖, 우연히 옆 메모리 값을 읽음
6거짓-반복 종료
arr[0] = 10 arr[1] = 20 arr[2] = 30 arr[3] = 40 arr[4] = 50 arr[5] = 999 guard = 999

이 실행 결과는 컴파일러와 메모리 배치에 따라 달라질 수 있는 미정의 동작(undefined behavior)이다. 위 출력은 guardarr 바로 뒤에 배치된 한 가지 경우를 보여준 것일 뿐, 실제 시험 문제에서는 “정확히 어떤 값이 나온다”고 단정하기보다 “배열 경계를 벗어나 어떤 값이 나올지 예측할 수 없다”는 점 자체가 정답 근거가 되는 경우가 많다.

자주 틀리는 점: “배열 인덱스를 벗어나면 컴파일 오류가 난다”는 보기는 항상 틀렸다. C는 배열 경계를 컴파일 시점은 물론 실행 시점에도 검사하지 않으므로, 경계 초과는 런타임에 벌어지는 미정의 동작이지 컴파일 오류가 아니다.

코드로 확인하기 — 널 포인터 역참조

#include <stdio.h> int main(void) { int *p = NULL; printf("%d\n", *p); return 0; }

p는 아무것도 가리키지 않는다는 뜻의 널 포인터(null pointer, 값이 0인 포인터)로 초기화되어 있다. 그런데 *p로 역참조(dereference, 포인터가 가리키는 곳의 값을 읽는 연산)를 시도하면, 운영체제가 “주소 0번지는 접근이 허용되지 않는 영역”이라고 판단해 프로그램을 강제로 종료시킨다(대표적으로 세그멘테이션 오류, segmentation fault). 이 역시 컴파일은 문제없이 통과되고, 실행 중에야 비정상 종료되는 런타임 오류다.

동적 메모리 오류: 메모리 누수와 잘못된 free

왜 필요한가

17편에서 배운 malloc/free는 힙(heap) 영역의 메모리를 프로그래머가 직접 관리하게 해준다. 그런데 “직접 관리한다”는 것은 “관리 실수도 프로그래머 책임”이라는 뜻이다. 자동으로 회수해주는 지역 변수와 달리, 힙 메모리는 free를 호출하지 않으면 프로그램이 끝날 때까지(또는 운영체제가 회수할 때까지) 계속 점유된 채로 남는다.

쉽게 말하면: malloc으로 빌린 메모리는 도서관에서 빌린 책과 같다. 다 읽고 free로 반납하지 않으면 그 책은 계속 “대출 중”으로 남아 다른 사람이 못 빌린다. 반대로 이미 반납한 책을 또 반납하려 하거나, 반납한 책을 계속 읽으려 하면 사고가 난다.

코드로 확인하기 — 메모리 누수

#include <stdio.h> #include <stdlib.h> void make_leak(void) { int *buffer = (int *)malloc(sizeof(int) * 100); if (buffer == NULL) { return; } buffer[0] = 42; printf("%d\n", buffer[0]); /* free(buffer)를 호출하지 않고 함수가 끝난다 */ } int main(void) { make_leak(); return 0; }

make_leak 함수 안에서 malloc으로 힙에 int 100개짜리 공간을 확보한 뒤, 그 주소를 가리키는 지역 변수 buffer에 저장했다. 함수가 끝나면 지역 변수 buffer는 스택(stack)에서 사라지지만, buffer가 가리키던 힙 메모리 자체는 사라지지 않는다. free(buffer)를 호출하지 않았으므로, 그 100개의 int 공간은 아무도 가리키지 못하는 채로 계속 점유된 상태로 남는다. 이를 메모리 누수(memory leak)라고 한다.

42

프로그램이 짧게 한 번 실행되고 끝난다면 메모리 누수는 눈에 띄지 않을 수 있다. 하지만 make_leak 같은 함수가 서버 프로그램처럼 반복해서 호출된다면, 호출할 때마다 힙 메모리가 조금씩 계속 늘어나 결국 시스템 메모리를 고갈시킬 수 있다.

코드로 확인하기 — 이중 해제와 댕글링 포인터

#include <stdio.h> #include <stdlib.h> int main(void) { int *p = (int *)malloc(sizeof(int)); *p = 100; printf("%d\n", *p); free(p); printf("%d\n", *p); /* 이미 해제된 메모리를 다시 읽음 */ free(p); /* 이미 해제된 메모리를 또 해제함 */ return 0; }

free(p)가 호출되는 순간 p가 가리키던 힙 메모리는 운영체제(정확히는 메모리 할당 라이브러리)에 반납된다. 하지만 p라는 포인터 변수 자체의 값(주소)은 지워지지 않고 그대로 남아 있다. 이렇게 가리키는 대상은 이미 해제되었는데 포인터 변수만 예전 주소를 그대로 들고 있는 상태댕글링 포인터(dangling pointer, 매달린 포인터)라고 부른다.

시점p의 값p가 가리키는 메모리 상태*p 접근 결과
malloc 직후주소 A사용 중(내 것)정상 접근, 100
free(p) 직후여전히 주소 A(댕글링)반납됨(내 것 아님)미정의 동작 — 우연히 100이 남아있을 수도, 다른 값일 수도 있음
두 번째 free(p)여전히 주소 A이미 반납된 것을 또 반납 시도이중 해제(double free), 대부분 프로그램 비정상 종료

댕글링 포인터를 통해 *p를 읽거나 쓰는 것, 그리고 같은 포인터에 free를 두 번 호출하는 이중 해제(double free)는 모두 미정의 동작이며, 컴파일 시점에는 전혀 검출되지 않는 대표적인 런타임 오류다.

자주 틀리는 점: “free를 호출하면 포인터 변수도 자동으로 NULL이 된다”고 착각하는 경우가 많다. free(p)p가 가리키던 메모리를 반납할 뿐, p 자체의 값은 그대로 남는다(댕글링 포인터가 된다). 실수를 막으려면 free(p) 직후 p = NULL;을 직접 대입하는 습관이 필요하다.

제어문·함수 호출 논리 오류

왜 필요한가

컴파일도 되고 크래시(crash, 비정상 종료)도 없이 끝까지 실행되지만 결과가 틀린 경우가 실전 시험에서는 가장 흔하다. 9편의 제어문과 10편의 함수 호출 규칙을 정확히 알아야 이런 논리 오류를 잡아낼 수 있다.

쉽게 말하면: 논리 오류는 “문법 시험은 통과했지만 산수를 잘못한” 상태다. 컴퓨터는 시키는 대로 정확히 실행했을 뿐이고, 잘못된 건 애초의 지시(코드) 자체다.

코드로 확인하기 — 반복 횟수 하나 어긋남

#include <stdio.h> int main(void) { int sum = 0; for (int i = 1; i < 10; i++) { sum += i; } printf("%d\n", sum); return 0; }

이 코드는 “1부터 10까지의 합”을 구하려는 의도로 보이지만, 조건이 i < 10이라서 i가 10이 되는 순간 반복문을 빠져나간다. 즉 실제로 더해지는 값은 1부터 9까지다.

i조건 i < 10sum(반복 전)sum(i 더한 후)
101
213
336
4610
51015
61521
72128
82836
93645
10거짓-반복 종료
45
1+2+3++9=9×102=451+2+3+\cdots+9 = \frac{9 \times 10}{2} = 45
  • 9×102\frac{9 \times 10}{2}: 1부터 9까지 등차수열 합 공식 n(n+1)2\frac{n(n+1)}{2}n=9n=9를 대입한 값

1부터 10까지의 합(정답 55)을 원했다면 조건을 i <= 10으로 고쳐야 한다. 이처럼 반복 경계를 하나 잘못 잡는 실수를 off-by-one 오류라고 하며, 배열 인덱싱뿐 아니라 합계·개수 세기 문제에서도 시험에 자주 등장한다.

코드로 확인하기 — 값 전달 함수에서 원본이 안 바뀌는 착각

#include <stdio.h> void increase(int n) { n = n + 1; } int main(void) { int x = 5; increase(x); printf("%d\n", x); return 0; }

10편과 13편에서 다룬 것처럼, C의 함수 인자 전달은 기본적으로 값에 의한 전달(call by value)이다. increase(x)를 호출하면 x의 값 5가 복사되어 매개변수 n에 전달될 뿐, nx와는 별개의 메모리 공간(스택 프레임 안의 지역 변수)이다. increase 함수 안에서 n을 아무리 바꿔도 mainx에는 영향을 주지 않는다.

호출 단계main의 xincrease의 n
호출 전5(존재하지 않음)
호출 직후(값 복사)55
n = n + 1; 실행 후5(변화 없음)6
함수 종료(n 소멸)5(스택에서 제거됨)
5

x를 실제로 바꾸고 싶다면, 13편에서 배운 것처럼 x주소를 넘겨 포인터 매개변수로 받아야 한다.

void increase(int *n) { *n = *n + 1; } /* 호출부: increase(&x); */

자주 틀리는 점: “함수에 변수를 넘기면 함수 안에서 값을 바꿀 수 있다”는 전제 자체가 C에서는 기본적으로 틀렸다. 포인터(주소)를 명시적으로 넘기지 않는 한, C의 인자 전달은 항상 값 복사이며 원본은 절대 바뀌지 않는다.

핵심 정리

  • 컴파일 오류는 번역 단계에서 문법을 어겨 실행 파일 자체가 만들어지지 않는 오류이고, 런타임 오류는 실행 중 비정상 상황으로 강제 종료되는 오류이며, 논리 오류는 끝까지 실행되지만 결과가 틀리는 오류다.
  • 큰 자료형의 값을 작은 자료형에 대입하는 것은 컴파일 오류가 아니라, 값이 잘려 예상과 다른 결과를 내는 논리적 문제다.
  • 배열 경계 초과와 널 포인터 역참조는 컴파일 시점에 전혀 검출되지 않는 대표적인 런타임 오류(미정의 동작)다.
  • 메모리 누수는 malloc한 메모리를 free하지 않아 계속 점유되는 문제이고, 댕글링 포인터·이중 해제는 이미 free된 메모리를 다시 접근하거나 다시 해제하는 문제다.
  • 반복 경계를 하나 잘못 잡는 off-by-one 오류와, 값에 의한 전달(call by value)을 오해해 원본이 바뀔 것으로 착각하는 것은 시험에 반복 출제되는 대표 논리 오류다.

마무리 복습

문제 14지선다
컴파일 오류·런타임 오류·논리 오류에 대한 설명으로 옳지 않은 것은?

본문 앞서 나온 int score = 1000000;short small_score = score;에 대입하고 small_score를 출력하는 코드를 다시 떠올려보자.

문제 24지선다
int형 1000000을 short형 변수에 대입한 뒤 그 값을 출력하는 코드를 컴파일·실행했을 때 결과로 가장 적절한 것은?
문제 34지선다
malloc으로 확보한 힙 메모리를 free하지 않고 함수가 종료되었을 때 일어나는 현상은?
문제 44지선다
free(p) 호출 직후 p에 대한 설명으로 가장 적절한 것은?

본문 앞서 나온, sum을 0으로 시작해 i가 1부터 9까지(반복 조건이 10 미만인 동안) 더해가는 for문 코드를 다시 떠올려보자.

문제 54지선다
sum을 0으로 시작해 i가 1부터 9까지(반복 조건이 10 미만인 동안) 1씩 늘려가며 sum에 더하는 코드를 실행했을 때 출력되는 값은?

본문 앞서 나온, increase 함수가 매개변수 n을 1 증가시키고 main에서 x를 5로 두고 increase(x)를 호출하는 코드를 다시 떠올려보자.

문제 64지선다
increase(int n) 함수가 n을 1 증가시키기만 하고, main에서 x를 5로 두고 increase(x)를 호출한 뒤 x를 출력하는 코드를 실행하면 x 값으로 옳은 것은?

참고 자료

Last updated on