"컴파일은 됐는데 실행하다 뻑난다"의 의미

    부업(?)처럼 C를 공부하다 보면 "이건 컴파일할 때 정해지고~" "이건 컴파일러는 모르고 실행할 때 정해지고~" 같은 말들을 많이 듣게 된다. 또 계속 '컴파일 에러'와 '런타임 에러'에 대해서도 많이 듣게 된다. 이 개념들에 대해서 어렴풋하게는 이해하고 있는데, 좀 제대로 알아둬야 언어들이 동작하는 원리를 알 것 같다는 생각이 들어서 정리하는 글.

    컴파일 타임(Compile time)과 런타임(Runtime)

    기본적으로 컴파일 타임은 말 그대로 컴파일을 하는 시간이다. 컴파일러가 소스 코드를 기계어(or 어셈블리어)로 변환하는 때를 의미한다. 이 때 컴파일러는 소스코드를 한줄한줄 읽으면서 정해진 syntax에 따라 해당 코드를 기계어로 변환한다. 이 때 에러가 나는 것이 '컴파일 에러'다. 컴파일러가 이 소스코드를 해석할 수가 없다는 것이다. 즉, 소스코드가 컴파일러가 읽을 수 있는 규칙에서 어긋나 있다는 것이고, 대부분은 문법 오류다.

     

    그렇게 개발자가 수 없는 컴파일 오류를 고치고 고치면 컴파일이 무사히 끝난다. 그 후에야 우리는 프로그램을 실행할 수 있게 된다. 기본적으로 컴파일이 무사히 되었다는 것은, 컴파일러가 소스코드를 문법적으로 다 이해해서, 이걸 기계가 알아들을 수 있는 명령어(기계어)의 집합으로 다 변환해놨다는 얘기다. 그렇게 변환된 명령대로 쫘라락 실행하는 시간이 런타임이다. 런타임 에러는, 이렇게 컴파일이 다 된 걸 실행하는 도중에 에러가 났다는 것이다.

     

    다시 말해서, "컴파일러가 컴퓨터가 알아들을 수 있는 형태로 명령을 쫙 변환해주긴 했는데, 그 명령대로 하다 보니까 말이 안 돼!"라는 것이다. 컴파일러가 "~ 하면서 ~을 해" 라는 형태로 소스코드를 기계어로 만들었다고 쳐 보자. 예를 들어서 "밥 먹으면서 음악 들어"라는 명령어는 문법에도 맞고 실행하는 것도 가능하다. 근데 "밥 먹으면서 밥 먹지 마"는 일단 문법상으로는 틀린 부분이 없다. 그래서 컴파일러가 문제 없이 컴파일을 한 것이다. 근데 막상 이 명령을 실행하려고 하니까, 밥을 먹으면서 동시에 밥을 안 먹을 수는 없다. 이럴 때 생기는 게 런타임 에러다. 컴파일이 된 코드를 실행하다 보니까 "명령이 형식에는 맞는데, 해 보려니까 말이 안 돼"라고 이해하면 된다.

     

    대표적으로 예시로 드는 런타임 에러가 0으로 나누거나, null 포인터 값을 읽어와서 뭔가 작업하거나 하는 것이다. "10을 5로 나눠"와 "10을 0으로 나눠" 모두 "A를 B로 나눠"라는 형식에는 맞아서 컴파일러가 열심히 컴파일을 했지만, 후자는 실행할 수가 없다(일부 언어에서는 실행이 되기도 한다). 어떤 메모리 주소의 값에다가 뭔가 작업을 하려고 하는데 그 메모리 주소가 없다(null이다)면 실행하다가 뻗는 것이다.

     

     

    C에서 함수의 배열 매개변수 크기를 sizeof()로 구할 수 없는 이유

    자, 이렇게 컴파일 타임과 런타임을 이해하게 되면, 어떤 것이 컴파일할 때 결정되는지, 어떤 게 실행 시에 결정되는지 알 수 있다. 가장 대표적인 게 함수다. 함수가 컴파일될 때, 함수는 어떤 매개변수로 어떤 인자가 들어오는지 정확히 알 수 있을까? 당연히 없다. 함수 선언/정의부에서 명시된 것은 매개변수의 개수와 자료형일 뿐이다. 결국 누가 그 함수를 호출해서 정확히 어떤 인자를 넣어줄 지는, 실행해 봐야 안다는 것이다. 즉, 컴파일 된 어셈블리어(기계어)에는 그냥 1)함수의 시작 주소와, 2)인자로 들어온 값들을 가지고 어떻게어떻게 해라 하는 명령만 있을 뿐이다.

     

    자, C의 sizeof()는 함수같이 생겼지만 함수가 아니라 연산자다. 그래서 컴파일 시에 평가된다. 즉, 컴파일 시에 sizeof에 들어온 피연산자의 크기를 내놓아야 한다는 얘기다.

     

    int main(void)
    {
        size_t int_size = sizeof(int);
        
        char arr[10];
        size_t arr_size = sizeof(arr);
    }

     

    위 코드에서 int_size를 출력해 보면 int 자료형의 사이즈인 4가 나온다. arr_size는? char * 10개의 사이즈인 10이 나온다. 컴파일 시에 이미 arr이 char 10개짜리 배열이라는 걸 알 수 있기 때문이다. 근데 아래와 같은 경우는 어떨까?

    size_t get_arr_size(char char_arr[])
    {
        return sizeof(char_arr);
    }
    
    int main(void)
    {
        char arr[10];
        size_t arr_size = get_arr_size(arr);
    }

    여기서 arr_size를 넣어 보면 4가 나온다. 왜냐하면, 컴파일할 때 get_arr_size()에 char 몇 개 짜리 배열이 들어올지 알 방법이 전혀 없기 때문이다! 함수의 매개변수로 배열을 넣으면 배열 주소가 들어올 뿐이다. 한 함수를 여러 곳에서 다양한 매개변수를 넣어서 호출할 수 있고, 그래서 실제 char_arr[]의 크기가 얼마인지는 런타임 시에 함수를 호출하면서 그때그때 결정되는 것이다. 이걸 컴파일할 때 알 수가 없기 때문에 기본적으로 함수에 들어오는 매개변수는 스택 메모리에 주소만 가지고 들어오고, 컴파일러는 이에 맞게 char 배열의 포인터 크기인 4바이트를 반환할 수밖에 없는 것이다. 돌려보기 전에는 뭐가 들어올지 모르니까!

     


     

    이런 과정을 통해서 그냥 '함수 매개변수로 들어온 배열에 sizeof()를 하면 포인터 크기인 4가 반환된다' 같은 암기가 아니라, 왜 배열 매개변수는 기본적으로 주소를 넘겨주는 방식으로 작동하는지도 이해하게 되고, 그래서 sizeof()라는 연산자가 이렇게 작동할 수밖에 없는 이유도 이해할 수 있게 된다. 이렇게 컴파일 타임과 런타임의 이해는, 기본적으로 컴퓨터가 프로그램을 돌리는 방식에 대한 이해이기 때문에 여러 언어들의 작동 방식을 깊게 이해하는 데 도움이 된다.


    참고자료

    - Compile time vs Runtime

    - Can evaluation of functions happen during compile time?

    - POCU C 언매니지드 프로그래밍

    댓글