2020.10.12

흔한 C 프로그래밍 오류 4가지, '그리고 5가지 대처법'

Serdar Yegulalp | InfoWorld
단순히 속도와 기계 수준의 위력만으로 비교한다면 C에 필적할 프로그래밍 언어는 별로 없다. 이는 50년 전에도 사실이었고 지금도 역시 사실이다. 그러나 프로그래머들이 C 언어의 위력을 ‘풋건(footgun)’이라는 말로 묘사하는 데에는 그만한 이유가 있다. 주의하지 않는다면 C는 자신은 물론 다른 사람에게도 피해를 줄 수 있다. C언어에서 가장 흔한 4가지 오류와 이를 예방하는 5가지 조치를 소개한다. 


NeONBRAND (CC0)

흔한 C 언어 오류: 멜록 함수로 할당된((malloc-ed) 메모리를 해제하지 않는 것(또는 1회 이상 해제하는 것)
이는 C 언어에서 중대한 오류 가운데 하나이고, 주로 메모리 관리와 연관된다. 할당 메모리는 (멜록 함수를 이용) C에서 자동으로 해제되지 않는다. 메모리가 더 이상 사용되지 않을 때 이를 해제하는 것은 프로그래머의 몫이다. 반복된 메모리 요청을 해제하지 않는다면 메모리 누출(leak)로 이어진다. 이미 해제된 메모리 영역을 사용한다면 프로그램이 충돌하거나, 더 심한 경우, 파편화될 것이고, 이 메커니즘을 악용한 공격에 취약해진다. 

메모리 누출은 메모리가 해제되어야 하지만 해제되지 않은 경우를 설명하는 것뿐임을 유의할 필요가 있다. 프로그램이 메모리가 실제로 필요하고 작업에서 사용되기 때문에 메모리를 계속 할당한다면 메모리 이용은 비효율적일 것이지만, 엄격히 말해 이는 누수가 아니다. 

흔한 C 언어 오류: 경계 밖의 배열을 읽는 것(Reading an array out of bounds) 
C 언어에서 흔하고 위험한 또 다른 오류가 있다. 배열의 끝을 넘어선 읽기는 가비지 데이터(garbage data)를 반환할 수 있다. 배열의 영역을 지난 쓰기는 프로그램 상태를 훼손하거나 이를 완전히 붕괴시키거나, 최악의 경우, 악성코드의 공격 매개체가 된다.

그렇다면 배열의 경계를 확인하는 부담이 왜 프로그래머에게 남겨졌는가? 공식적인 C 규격을 보면 경계를 벗어나 배열을 읽거나 쓰는 것은 ‘정의되지 않은 행동(undefined behavior)’이다. 이는 무슨 일이 일어날 것인지에 관한 언급이 규격에 없다는 뜻이다. 컴파일러는 이의를 제기하지조차 않는다.

C는 프로그래머 본인의 위험 부담 하에서 오랫동안 프로그래머에게 권한을 주어왔고, 경계 밖의 읽기나 쓰기는 컴파일러에 의해 포착되지 않는다. 이를 방어하는 컴파일러 옵션을 명시적으로 활성화하지 않았다면 말이다. 게다가 런타임 시 컴파일러가 막을 수 없는 방식으로 배열의 경계를 넘어서는 것도 가능하다.

흔한 C 언어 오류: 멜록의 결과를 검사하지 않는 것(Not checking the results of malloc)
멜록(malloc)과 콜록(calloc)은 (0으로 초기화된 메모리) 시스템으로부터 힙-할당된 메모리(heap-allocated memory)를 획득하는 C라이브러리 함수이다. 이들이 메모리를 할당할 수 없다면 이들은 오류를 발생시킨다. 컴퓨터 메모리가 비교적 작았던 시절 멜록 호출이 성공하지 못할 확률이 상당했다.

오늘날의 컴퓨터는 기가바이트 RAM을 할당할 수 있지만, 멜록이 실패할 가능성은 여전히 상존한다. 메모리에 대한 압박이 심하거나 메모리를 큰 용량으로 할당할 때 특히 그렇다. 예컨대 OS로부터 대용량 메모리 블록을 먼저 슬랩 할당한 후, 자체적으로 이를 분리해 이용하는 C 프로그램의 경우이다. 

최초의 할당이 용량이 너무 커서 실패한다면 거절을 포착해 할당 크기를 줄일 수 있고, 프로그램의 메모리 이용을 적절히 조정할 수 있다. 그러나 메모리 할당이 포착되지 않은 채 실패한다면 전체 프로그램이 정지될 수 있다.

흔한 C 언어 오류: 범용 메모리 포인터로 ‘void*’를 이용 
메모리를 지정하기 위해 ‘void*’을 이용하는 것은 오래된 좋지 않은 습관이다. 메모리 포인터는 언제나 ‘char*’, ‘unsigned char*’, ‘uintptr_t*’여야 한다. 현대의 C 컴파일러 스위트라면 ‘stdint.h’의 일부로서 ‘uintptr_t’를 제공할 것이다.

이들 가운데 하나로 명명될 때 포인터는 정의되지 않은 객체 유형이 아니라 일반적으로 메모리 위치를 가리킨다. 포인터 산술을 수행하는 경우 이는 2배로 중요하다. ‘uintptr_t*’ 등이라면 크기 요소와 사용 방식이 명백하지만, ‘void*’는 그렇지 않다.

흔한 C 언어 오류를 피하는 5가지 방법 
C 언어에서 메모리, 어레이(배열), 포인터로 작업할 때 이러한 너무 흔한 오류를 어떻게 피할 것인가? 아래의 5가지 방법을 기억해두는 것이 좋다.

◆ 메모리 소유권이 명확히 유지될 수 있도록 C 프로그램을 구조화하라 
C 앱을 이제 막 시작하고 있다면 메모리가 할당되고 해제되는 방식을 프로그램의 구조 원리라고 생각하는 것이 가치가 있다. 일정한 메모리 할당이 어디서 어떤 상황에서 해제되는 지 불명확하다면 문제가 발생할 수밖에 없다. 메모리 소유권을 최대한 명확히 하는 추가적 노력이 필요하다. 이는 스스로에게 그리고 미래의 개발자에게 유익할 것이다.

이는 러스트(Rust) 같은 언어의 배경이 되는 원리이다. 러스트는 메모리가 어떻게 할당되고 이전되는가를 명확히 표시하지 않는다면 적절히 컴파일 되는 프로그램을 코딩하는 것 자체가 불가능하다. C에는 이러한 제약이 없지만, 이 원리를 가능할 때마다 지침으로 수용하는 것이 현명하다. 

◆ 메모리 문제를 방어하는 C 컴파일러 옵션을 이용하라 
앞서 서술한 문제 가운데 많은 수는 엄격한 컴파일러 옵션으로 약화시킬 수 있다. 예를 들어 최신 gcc 에디션에서는 ‘AddressSanitizer(ASAN)’ 같은 툴을 컴파일 옵션으로 제공하여 일반적인 메모리 관리 오류를 검사한다. 

그러나 이들 툴이 모든 것을 잡아내는 것은 아님을 유의해야 한다. 이들은 보호 난간 같은 것이지, 정상 궤도를 이탈했을 때 조정을 하는 것이 아니다. 또한 이들 툴 가운데 일부는 ASAN처럼 컴파일 및 런타임에 지장을 주고, 따라서 릴리즈 빌드에서는 피해야 한다. 

◆ Cppcheck나 Valgrind를 이용해 C 코드에 메모리 누출이 없는지 분석하라 
컴파일러 자체가 불완전하다면 다른 툴이 공백을 메우기 위해 개입한다. 런타임 시 프로그램 거동을 분석할 때가 특히 그렇다. Cppcheck는 C 소스 코드에 대한 정적 분석을 실행하고 메모리 관리의 흔한 오류와, 무엇보다, 정의되지 않은 거동을 조사한다. 

Valgrind는 툴 캐시를 제공하여 C 프로그램 실행 시 메모리 및 스레드 오류를 검출한다. 이는 컴파일-시간 분석을 이용하는 것보다 훨씬 더 강력하다. 프로그램이 실행 중일 때 프로그램 거동에 대한 정보를 추출할 수 있기 때문이다. 단점은 프로그램이 정상 속도에 훨씬 못 미치는 속도로 실행된다는 점이다. 그러나 테스팅 시에는 문제가 없다. 

이들 툴은 완벽하지 않고, 모든 것을 잡아내지 못한다. 다만 C 언어에서 메모리 오류에 대한 전체적인 방어 전략의 일부로 기능한다. 

◆ 가비지 컬렉터에 의해 C 메모리 관리를 자동화하라 
메모리 오류는 C 언어 문제의 두드러진 근원이기 때문에 한가지 쉬운 해법이 있다. C에서 메모리를 수동으로 관리하지 않는 것이다. 가비지 컬렉터(garbage collector)를 활용하라. 

이는 C에서 가능하다. ‘Boehm-Demers-Weiser 가비지 컬렉터’ 등을 이용해 자동 메모리 관리를 C 프로그램에 추가할 수 있다. 일부 프로그램의 경우 보임 컬렉터(Boehm collector)를 이용한다면 심지어 속도를 높일 수도 있다. 심지어 누출 감지 메커니즘으로 사용될 수도 있다. 

보임 가비지 컬렉터의 가장 큰 단점은 디폴트 멜록을 사용하는 메모리를 검사하거나 해제할 수 없다는 점이다. 보임은 자체 할당 함수를 이용하고, 이용자가 명시적으로 할당한 메모리에서만 작용한다. 

◆ 다른 언어로 충분하다면 C 언어를 사용하지 말라 
일부 사람들이 C언어를 사용하는 이유는 이를 진심으로 즐기고 또 성과가 나타나기 때문이다. 그러나 전체적으로 보았을 때, 필요할 때에만, 그리고 C 언어가 진실로 이상적인 선택인 상황에서만(얼마 되지 않기는 하지만), C를 사용하는 것이 가장 좋다. 

실행 속도가 주로 I/O나 디스크 액세스에 의해 제약을 받는 프로젝트의 경우, 이를 C 언어로 작성하는 것은 속도를 유의미하게 높이지 않을 것이다. 게다가 아마 오류가 나기가 더 쉽고 관리하기가 더 까다롭기만 할 것이다. 이런 프로그램은 고우 또는 파이썬으로 충분히 작성될 수 있을 것이다. 

또 하나의 방법은 앱의 속도 집약적 부분에서만 C를 사용하는 것이다. 그리고 다른 부분에서는 더 느리지만 더 안정적인 다른 언어를 사용하는 것이 좋다. 다시 한번, 파이썬은 C 라이브러리나 커스텀 C 코드를 래핑하는 데 사용될 수 있다. 이는 명령줄 옵션 처리 같은 보편적인 컴포넌트를 위한 좋은 선택이다. ciokr@idg.co.kr



2020.10.12

흔한 C 프로그래밍 오류 4가지, '그리고 5가지 대처법'

Serdar Yegulalp | InfoWorld
단순히 속도와 기계 수준의 위력만으로 비교한다면 C에 필적할 프로그래밍 언어는 별로 없다. 이는 50년 전에도 사실이었고 지금도 역시 사실이다. 그러나 프로그래머들이 C 언어의 위력을 ‘풋건(footgun)’이라는 말로 묘사하는 데에는 그만한 이유가 있다. 주의하지 않는다면 C는 자신은 물론 다른 사람에게도 피해를 줄 수 있다. C언어에서 가장 흔한 4가지 오류와 이를 예방하는 5가지 조치를 소개한다. 


NeONBRAND (CC0)

흔한 C 언어 오류: 멜록 함수로 할당된((malloc-ed) 메모리를 해제하지 않는 것(또는 1회 이상 해제하는 것)
이는 C 언어에서 중대한 오류 가운데 하나이고, 주로 메모리 관리와 연관된다. 할당 메모리는 (멜록 함수를 이용) C에서 자동으로 해제되지 않는다. 메모리가 더 이상 사용되지 않을 때 이를 해제하는 것은 프로그래머의 몫이다. 반복된 메모리 요청을 해제하지 않는다면 메모리 누출(leak)로 이어진다. 이미 해제된 메모리 영역을 사용한다면 프로그램이 충돌하거나, 더 심한 경우, 파편화될 것이고, 이 메커니즘을 악용한 공격에 취약해진다. 

메모리 누출은 메모리가 해제되어야 하지만 해제되지 않은 경우를 설명하는 것뿐임을 유의할 필요가 있다. 프로그램이 메모리가 실제로 필요하고 작업에서 사용되기 때문에 메모리를 계속 할당한다면 메모리 이용은 비효율적일 것이지만, 엄격히 말해 이는 누수가 아니다. 

흔한 C 언어 오류: 경계 밖의 배열을 읽는 것(Reading an array out of bounds) 
C 언어에서 흔하고 위험한 또 다른 오류가 있다. 배열의 끝을 넘어선 읽기는 가비지 데이터(garbage data)를 반환할 수 있다. 배열의 영역을 지난 쓰기는 프로그램 상태를 훼손하거나 이를 완전히 붕괴시키거나, 최악의 경우, 악성코드의 공격 매개체가 된다.

그렇다면 배열의 경계를 확인하는 부담이 왜 프로그래머에게 남겨졌는가? 공식적인 C 규격을 보면 경계를 벗어나 배열을 읽거나 쓰는 것은 ‘정의되지 않은 행동(undefined behavior)’이다. 이는 무슨 일이 일어날 것인지에 관한 언급이 규격에 없다는 뜻이다. 컴파일러는 이의를 제기하지조차 않는다.

C는 프로그래머 본인의 위험 부담 하에서 오랫동안 프로그래머에게 권한을 주어왔고, 경계 밖의 읽기나 쓰기는 컴파일러에 의해 포착되지 않는다. 이를 방어하는 컴파일러 옵션을 명시적으로 활성화하지 않았다면 말이다. 게다가 런타임 시 컴파일러가 막을 수 없는 방식으로 배열의 경계를 넘어서는 것도 가능하다.

흔한 C 언어 오류: 멜록의 결과를 검사하지 않는 것(Not checking the results of malloc)
멜록(malloc)과 콜록(calloc)은 (0으로 초기화된 메모리) 시스템으로부터 힙-할당된 메모리(heap-allocated memory)를 획득하는 C라이브러리 함수이다. 이들이 메모리를 할당할 수 없다면 이들은 오류를 발생시킨다. 컴퓨터 메모리가 비교적 작았던 시절 멜록 호출이 성공하지 못할 확률이 상당했다.

오늘날의 컴퓨터는 기가바이트 RAM을 할당할 수 있지만, 멜록이 실패할 가능성은 여전히 상존한다. 메모리에 대한 압박이 심하거나 메모리를 큰 용량으로 할당할 때 특히 그렇다. 예컨대 OS로부터 대용량 메모리 블록을 먼저 슬랩 할당한 후, 자체적으로 이를 분리해 이용하는 C 프로그램의 경우이다. 

최초의 할당이 용량이 너무 커서 실패한다면 거절을 포착해 할당 크기를 줄일 수 있고, 프로그램의 메모리 이용을 적절히 조정할 수 있다. 그러나 메모리 할당이 포착되지 않은 채 실패한다면 전체 프로그램이 정지될 수 있다.

흔한 C 언어 오류: 범용 메모리 포인터로 ‘void*’를 이용 
메모리를 지정하기 위해 ‘void*’을 이용하는 것은 오래된 좋지 않은 습관이다. 메모리 포인터는 언제나 ‘char*’, ‘unsigned char*’, ‘uintptr_t*’여야 한다. 현대의 C 컴파일러 스위트라면 ‘stdint.h’의 일부로서 ‘uintptr_t’를 제공할 것이다.

이들 가운데 하나로 명명될 때 포인터는 정의되지 않은 객체 유형이 아니라 일반적으로 메모리 위치를 가리킨다. 포인터 산술을 수행하는 경우 이는 2배로 중요하다. ‘uintptr_t*’ 등이라면 크기 요소와 사용 방식이 명백하지만, ‘void*’는 그렇지 않다.

흔한 C 언어 오류를 피하는 5가지 방법 
C 언어에서 메모리, 어레이(배열), 포인터로 작업할 때 이러한 너무 흔한 오류를 어떻게 피할 것인가? 아래의 5가지 방법을 기억해두는 것이 좋다.

◆ 메모리 소유권이 명확히 유지될 수 있도록 C 프로그램을 구조화하라 
C 앱을 이제 막 시작하고 있다면 메모리가 할당되고 해제되는 방식을 프로그램의 구조 원리라고 생각하는 것이 가치가 있다. 일정한 메모리 할당이 어디서 어떤 상황에서 해제되는 지 불명확하다면 문제가 발생할 수밖에 없다. 메모리 소유권을 최대한 명확히 하는 추가적 노력이 필요하다. 이는 스스로에게 그리고 미래의 개발자에게 유익할 것이다.

이는 러스트(Rust) 같은 언어의 배경이 되는 원리이다. 러스트는 메모리가 어떻게 할당되고 이전되는가를 명확히 표시하지 않는다면 적절히 컴파일 되는 프로그램을 코딩하는 것 자체가 불가능하다. C에는 이러한 제약이 없지만, 이 원리를 가능할 때마다 지침으로 수용하는 것이 현명하다. 

◆ 메모리 문제를 방어하는 C 컴파일러 옵션을 이용하라 
앞서 서술한 문제 가운데 많은 수는 엄격한 컴파일러 옵션으로 약화시킬 수 있다. 예를 들어 최신 gcc 에디션에서는 ‘AddressSanitizer(ASAN)’ 같은 툴을 컴파일 옵션으로 제공하여 일반적인 메모리 관리 오류를 검사한다. 

그러나 이들 툴이 모든 것을 잡아내는 것은 아님을 유의해야 한다. 이들은 보호 난간 같은 것이지, 정상 궤도를 이탈했을 때 조정을 하는 것이 아니다. 또한 이들 툴 가운데 일부는 ASAN처럼 컴파일 및 런타임에 지장을 주고, 따라서 릴리즈 빌드에서는 피해야 한다. 

◆ Cppcheck나 Valgrind를 이용해 C 코드에 메모리 누출이 없는지 분석하라 
컴파일러 자체가 불완전하다면 다른 툴이 공백을 메우기 위해 개입한다. 런타임 시 프로그램 거동을 분석할 때가 특히 그렇다. Cppcheck는 C 소스 코드에 대한 정적 분석을 실행하고 메모리 관리의 흔한 오류와, 무엇보다, 정의되지 않은 거동을 조사한다. 

Valgrind는 툴 캐시를 제공하여 C 프로그램 실행 시 메모리 및 스레드 오류를 검출한다. 이는 컴파일-시간 분석을 이용하는 것보다 훨씬 더 강력하다. 프로그램이 실행 중일 때 프로그램 거동에 대한 정보를 추출할 수 있기 때문이다. 단점은 프로그램이 정상 속도에 훨씬 못 미치는 속도로 실행된다는 점이다. 그러나 테스팅 시에는 문제가 없다. 

이들 툴은 완벽하지 않고, 모든 것을 잡아내지 못한다. 다만 C 언어에서 메모리 오류에 대한 전체적인 방어 전략의 일부로 기능한다. 

◆ 가비지 컬렉터에 의해 C 메모리 관리를 자동화하라 
메모리 오류는 C 언어 문제의 두드러진 근원이기 때문에 한가지 쉬운 해법이 있다. C에서 메모리를 수동으로 관리하지 않는 것이다. 가비지 컬렉터(garbage collector)를 활용하라. 

이는 C에서 가능하다. ‘Boehm-Demers-Weiser 가비지 컬렉터’ 등을 이용해 자동 메모리 관리를 C 프로그램에 추가할 수 있다. 일부 프로그램의 경우 보임 컬렉터(Boehm collector)를 이용한다면 심지어 속도를 높일 수도 있다. 심지어 누출 감지 메커니즘으로 사용될 수도 있다. 

보임 가비지 컬렉터의 가장 큰 단점은 디폴트 멜록을 사용하는 메모리를 검사하거나 해제할 수 없다는 점이다. 보임은 자체 할당 함수를 이용하고, 이용자가 명시적으로 할당한 메모리에서만 작용한다. 

◆ 다른 언어로 충분하다면 C 언어를 사용하지 말라 
일부 사람들이 C언어를 사용하는 이유는 이를 진심으로 즐기고 또 성과가 나타나기 때문이다. 그러나 전체적으로 보았을 때, 필요할 때에만, 그리고 C 언어가 진실로 이상적인 선택인 상황에서만(얼마 되지 않기는 하지만), C를 사용하는 것이 가장 좋다. 

실행 속도가 주로 I/O나 디스크 액세스에 의해 제약을 받는 프로젝트의 경우, 이를 C 언어로 작성하는 것은 속도를 유의미하게 높이지 않을 것이다. 게다가 아마 오류가 나기가 더 쉽고 관리하기가 더 까다롭기만 할 것이다. 이런 프로그램은 고우 또는 파이썬으로 충분히 작성될 수 있을 것이다. 

또 하나의 방법은 앱의 속도 집약적 부분에서만 C를 사용하는 것이다. 그리고 다른 부분에서는 더 느리지만 더 안정적인 다른 언어를 사용하는 것이 좋다. 다시 한번, 파이썬은 C 라이브러리나 커스텀 C 코드를 래핑하는 데 사용될 수 있다. 이는 명령줄 옵션 처리 같은 보편적인 컴포넌트를 위한 좋은 선택이다. ciokr@idg.co.kr

X