C 언어는 단순한 프로그래밍 도구가 아니라, 특정 문제 해결을 위해 탄생한 철학적 산물이다. C의 역사와 특징을 통해 왜 이 언어가 반세기 동안 컴퓨팅 세계의 근간을 이루어왔는지 탐구한다.
C 언어의 역사는 1972년, 미국 AT&T 벨 연구소(Bell Laboratories)의 데니스 리치(Dennis Ritchie)와 켄 톰슨(Ken Thompson)에 의해 시작되었다.1 그 기원은 지극히 실용적인 필요에서 비롯되었다. 당시 벨 연구소의 핵심 프로젝트는 UNIX 운영체제 개발이었는데, 초기의 UNIX는 어셈블리어로 작성되었다. 어셈블리어는 하드웨어의 성능을 최대한 활용할 수 있었지만, 특정 기계에 완전히 종속된다는 치명적인 단점을 안고 있었다. 이는 다른 종류의 컴퓨터로 UNIX를 이식(porting)하는 작업을 거의 불가능하게 만들었다.
이러한 이식성의 문제를 해결하기 위해, 하드웨어를 직접 제어할 수 있는 저수준 언어의 효율성과 여러 시스템에서 동작할 수 있는 고수준 언어의 이식성을 동시에 갖춘 새로운 언어의 필요성이 대두되었다.1 C 언어는 바로 이 간극을 메우기 위해 탄생한 결과물이다. C 언어의 이름은 그것이 B 언어를 계승하여 개발되었기 때문에 붙여졌으며, B 언어는 BCPL(Basic Combined Programming Language)에서, BCPL은 CPL(Combined Programming Language)에서, 그리고 CPL은 ALGOL 60에서 파생된 계보를 따른다.2
C의 탄생 배경은 이 언어의 근본적인 설계 철학을 결정지었다. C는 추상적인 이론이나 학문적 탐구에서 출발한 것이 아니라, “다양한 컴퓨터에서 효율적으로 동작하는 운영체제를 만들고 싶다”는 명확하고 실용적인 목표를 달성하기 위한 도구로서 설계되었다. 이 실용주의 철학은 C가 오늘날까지도 운영체제 커널, 임베디드 시스템 등 시스템 프로그래밍의 핵심 언어로 사용되는 근본적인 이유가 된다. 1973년, struct 자료형이 추가되면서 C 언어는 유닉스 커널의 대부분을 다시 작성할 수 있을 만큼 강력해졌고, 이로써 유닉스는 고급 프로그래밍 언어로 작성된 최초의 운영체제 중 하나가 되었다.
C 언어가 UNIX와 함께 널리 보급되면서, 다양한 기관과 회사에서 자신들의 시스템에 맞는 C 컴파일러를 개발하기 시작했다. 이는 자연스럽게 여러 버전의 C 언어가 난립하는 결과를 낳았고, 코드의 호환성 문제가 중요한 과제로 떠올랐다.1 이러한 혼란 속에서 사실상의 비공식 표준 역할을 한 것은 1978년 브라이언 커니핸(Brian Kernighan)과 데니스 리치가 출간한 저서 “The C Programming Language”였다. 이 책은 저자들의 이름을 따 ‘K&R C’라 불리며 오랫동안 C 언어의 규격과 같은 역할을 수행했다.
언어의 안정성과 이식성을 공식적으로 보장하기 위한 노력은 1983년 미국 국립 표준 연구소(ANSI)가 C 언어 표준 위원회를 결성하면서 본격화되었다. 이 위원회의 작업은 1989년 ANSI 표준 X3.159-1989, 통칭 ‘ANSI C’ 또는 ‘C89’의 제정으로 결실을 보았다. 이 표준은 이듬해 국제 표준화 기구(ISO)에 의해 ISO/IEC 9899:1990, 즉 ‘C90’으로 채택되면서 C를 개인의 프로젝트에서 국제 산업 표준으로 격상시켰다.1
C의 표준화 과정은 ‘안정성’과 ‘진화’ 사이의 신중한 균형을 추구하는 특징을 보여준다. C++가 객체 지향과 같은 새로운 패러다임을 적극적으로 도입하며 빠르게 변화한 반면, C 표준은 비교적 보수적인 태도를 유지했다.1 이는 C가 이미 수많은 운영체제 커널과 임베디드 시스템의 기반으로 깊숙이 자리 잡고 있었기 때문이다. 급진적인 변화는 기존 시스템과의 하위 호환성을 깨뜨릴 위험이 컸다.
따라서 이후의 개정 표준들, 예를 들어 1999년의 ‘C99’, 2011년의 ‘C11’, 그리고 2018년의 ‘C17’ 등은 언어의 근본 철학을 유지하는 선에서 국제 문자 세트 지원 강화, 새로운 자료형 추가, 편의 기능 도입 등 점진적인 개선에 초점을 맞추었다.3 이처럼 C의 표준화는 신뢰성이 중요한 시스템 프로그래밍 언어로서의 정체성을 지키기 위해 하위 호환성과 안정성을 최우선으로 고려하는 전략을 채택해왔다.
C 언어의 핵심 철학은 한 문장으로 요약될 수 있다: “프로그래머는 자신이 무엇을 하는지 알고 있다.” 이 철학은 C의 문법과 기능 전반에 깊이 스며들어 있다. C는 언어 차원에서 프로그래머의 실수를 막기 위한 복잡한 안전장치를 제공하기보다는, 프로그래머에게 최대한의 자유와 제어권을 부여하여 시스템의 성능을 극한까지 끌어올릴 수 있도록 설계되었다.1
이러한 철학은 다음과 같은 특징으로 구체화된다.
그러나 C가 부여하는 ‘자유’는 ‘책임’을 동반하는 양날의 검이다. 포인터를 잘못 사용하면 메모리 누수(memory leak)나 허가되지 않은 메모리 영역을 침범하는 등의 심각한 오류를 유발하여 프로그램 전체를 불안정하게 만들 수 있다.4 C에는 자동 메모리 관리 기능인 가비지 컬렉터(Garbage Collector)가 없으므로, 프로그래머가 할당한 메모리는 반드시 직접 해제해야 한다.8 또한, 배열의 경계를 자동으로 검사해주지 않기 때문에 버퍼 오버플로우(buffer overflow)와 같은 보안 취약점에 노출되기 쉽다.
결론적으로, C의 강력함은 프로그래머의 역량에 정비례한다. C를 마스터한다는 것은 단순히 문법을 아는 것을 넘어, 컴퓨터의 메모리 구조를 깊이 이해하고 자신이 작성한 코드의 모든 동작에 대해 완전한 책임을 지는 능력을 갖추는 것을 의미한다.
C 언어의 설계 철학은 다음과 같은 핵심적인 특징들로 발현된다.
C 언어의 활용 분야는 공통적으로 ‘하드웨어와 가깝고’, ‘최고 수준의 성능이 요구되는’ 영역에 집중되어 있다. 이는 C의 설계 철학이 반세기가 지난 현대 컴퓨팅 환경에서도 여전히 유효함을 증명한다.
C는 최종 사용자에게 직접 노출되는 화려한 애플리케이션보다는, 보이지 않는 곳에서 현대 기술 문명을 지탱하는 기반 기술로서의 성격이 강하다. 우리가 일상적으로 사용하는 스마트폰, 웹 브라우저, 클라우드 서비스 등 모든 기술의 가장 낮은 계층에는 C 언어로 작성된 견고한 기반이 자리 잡고 있다. 따라서 C를 학습하는 것은 단순히 프로그래밍 기술 하나를 익히는 것을 넘어, 컴퓨터 시스템이 근본적으로 어떻게 동작하는지를 이해하는 열쇠를 얻는 과정이며, 이는 다른 고수준 언어를 배울 때에도 깊이 있는 이해의 바탕이 된다.2
C 코드를 실제 실행 가능한 프로그램으로 만드는 데 필요한 도구와 그 설정 방법을 다룬다. 컴파일 과정에 대한 이론적 이해를 바탕으로, Windows, macOS, Linux 각 운영체제에 맞는 최적의 개발 환경을 구축하는 구체적인 절차를 제시한다.
C는 컴파일 언어(Compiled Language)이다. 이는 프로그래머가 작성한 소스 코드(.c 파일)가 컴퓨터가 직접 이해할 수 있는 기계어 코드(실행 파일)로 번역되는 과정을 거쳐야 함을 의미한다. 이 번역 과정을 ‘컴파일’이라 하며, 이 역할을 수행하는 프로그램을 ‘컴파일러(Compiler)’라고 부른다.
C 프로그램의 빌드(build) 과정은 크게 네 단계로 나눌 수 있다.16
#include, #define과 같이 #으로 시작하는 전처리기 지시문을 먼저 처리한다. 예를 들어, #include <stdio.h>는 stdio.h 헤더 파일의 내용을 소스 코드에 그대로 삽입하고, #define PI 3.14는 코드 내의 모든 PI를 3.14로 치환한다. 이 단계의 결과로 확장된 소스 코드 파일(보통 .i 확장자)이 생성된다..s 확장자)이 생성된다..o 또는 .obj 확장자)은 아직 완전한 실행 파일이 아니다. printf와 같은 라이브러리 함수의 코드가 연결되지 않은 상태다.이 복잡한 과정을 이해하는 것은 단순히 ‘빌드’ 버튼을 누르는 것 이상의 의미를 가진다. 개발 과정에서 마주치는 ‘undefined reference’와 같은 링크 에러나 헤더 파일 관련 문제의 원인을 정확히 진단하고 해결하기 위한 필수적인 기초 지식이다.
C 코드를 컴파일하기 위한 여러 컴파일러가 존재하며, 대표적인 것은 다음과 같다.
어떤 컴파일러를 선택하든 C 표준을 준수한다면 대부분의 코드는 동일하게 동작한다. 이 자습서에서는 가장 범용적이고 무료로 사용할 수 있는 GCC와 Clang을 중심으로 설명한다. Windows 환경에서는 GCC를 Windows에서 사용할 수 있도록 이식한 MinGW-w64를 사용한다.23
현대의 C 개발 환경은 크게 두 가지 접근 방식으로 나뉜다. 첫 번째는 Visual Studio나 Xcode와 같이 컴파일러, 디버거, 코드 에디터 등 모든 기능이 통합된 IDE(Integrated Development Environment)를 사용하는 방식이다. 이 방식은 초기 설정이 간편하고 강력한 디버깅 기능을 제공하여 초보자에게 유리하다.25
두 번째는 Visual Studio Code(VS Code)와 같은 경량 코드 에디터와 터미널(명령줄 인터페이스)을 조합하는 방식이다. 에디터 자체는 단순한 텍스트 편집 기능만 제공하지만, C/C++ 확장 프로그램과 외부 컴파일러(GCC, Clang)를 연동하여 자신에게 맞는 유연하고 가벼운 개발 환경을 구축할 수 있다. 이 방식은 여러 운영체제에서 일관된 개발 경험을 제공한다는 장점이 있다.23
이 자습서에서는 개발자의 선호와 프로젝트 성격에 따라 선택할 수 있도록 두 가지 방식을 모두 안내한다.
이 방식은 가볍고 유연하며, 다른 운영체제에서의 개발 방식과 유사한 경험을 제공한다.
Visual Studio Code 설치: 공식 웹사이트(https://code.visualstudio.com/)에서 설치 파일을 다운로드하여 설치한다.28
MinGW-w64 설치:
MSYS2 프로젝트(https://www.msys2.org/)를 통해 최신 GCC를 설치하는 것이 가장 안정적이다. MSYS2 설치 후, MSYS2 터미널에서 다음 명령을 실행한다.
pacman -Syu
pacman -S --needed base-devel mingw-w64-x86_64-toolchain
또는 SourceForge에서 MinGW-w64 빌드를 직접 다운로드할 수 있다.23
x86_64-posix-seh 스레드 모델을 선택하는 것이 일반적이다. 다운로드 후 원하는 위치(예: C:\mingw64)에 압축을 푼다.
환경 변수 설정: 컴파일러를 명령줄 어디에서든 실행할 수 있도록 경로를 설정해야 한다.
시스템 환경 변수 편집을 열고 환경 변수 버튼을 클릭한다.시스템 변수 목록에서 Path를 선택하고 편집을 누른다.새로 만들기를 클릭하고 MinGW-w64의 bin 폴더 경로(예: C:\msys64\mingw64\bin 또는 C:\mingw64\bin)를 추가한다.23설치 확인: 명령 프롬프트(cmd)를 새로 열고 gcc --version을 입력한다. GCC 버전 정보가 출력되면 성공적으로 설치된 것이다.23
VS Code 확장 프로그램 설치: VS Code를 실행하고 확장(Extensions) 탭에서 C/C++ Extension Pack을 검색하여 설치한다. 이 패키지는 코드 자동 완성, 디버깅 등 필수 기능을 제공한다.23
프로젝트 설정: 작업할 폴더를 열고, 첫 C 파일(.c)을 생성한다. VS Code는 컴파일 및 디버깅을 위해 .vscode 폴더 내에 tasks.json(빌드 작업 정의)과 launch.json(디버거 설정) 파일 생성을 안내한다. 이 파일들을 통해 GCC 컴파일러를 사용하여 코드를 빌드하고 디버깅하도록 설정할 수 있다.23
Microsoft가 제공하는 강력한 무료 IDE로, Windows 환경에 최적화되어 있다.
추가 > 새 항목을 선택한다..c로 지정한다. 확장자가 .cpp이면 C++ 컴파일러가 동작하여 일부 C 코드가 다르게 해석될 수 있다.31Ctrl+F5 키를 누르거나 메뉴에서 디버그 > 디버그하지 않고 시작을 선택하여 프로그램을 컴파일하고 실행한다.31macOS는 UNIX 기반이므로 C 개발 환경이 잘 갖추어져 있다.
Command Line Tools 설치: macOS에는 C 컴파일러인 Clang이 기본적으로 포함되어 있다. 터미널을 열고 다음 명령을 입력하여 개발 도구를 설치한다.34
xcode-select --install
Visual Studio Code 설치: Homebrew 패키지 관리자를 사용하는 것이 편리하다. 터미널에서 다음 명령으로 Homebrew를 설치하고, 이어서 VS Code를 설치한다.27
# Homebrew 설치 (공식 사이트에서 최신 명령어 확인)
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# VS Code 설치
brew install --cask visual-studio-code
VS Code 확장 프로그램 설치: Windows와 마찬가지로 C/C++ Extension Pack을 설치한다. Code Runner 확장을 함께 설치하면 단축키로 간단한 코드 실행이 가능해 편리하다.27
프로젝트 설정: Windows 환경과 유사하게, 작업 폴더 내에 .vscode 디렉터리를 생성하고 tasks.json과 launch.json 파일을 설정하여 Clang 컴파일러와 LLDB 디버거를 사용하도록 구성한다.34
Apple이 공식적으로 제공하는 macOS 및 iOS 개발용 IDE다.
main.c 파일이 자동으로 만들어진다. 코드를 작성한 후, Xcode 창 상단의 실행(▶) 버튼을 클릭하면 컴파일과 실행이 동시에 이루어지고 결과는 하단 콘솔 창에 출력된다.26Linux는 C 개발에 가장 이상적인 환경 중 하나로, 대부분의 배포판에 GCC가 기본적으로 포함되어 있거나 쉽게 설치할 수 있다.
가장 전통적이고 강력한 방식이다.
빌드 도구 설치: 터미널을 열고 다음 명령을 실행하여 GCC, make 등 개발에 필요한 필수 패키지들을 한 번에 설치한다.19
sudo apt update
sudo apt install build-essential
설치 확인: gcc --version 명령으로 GCC가 올바르게 설치되었는지 확인한다.
코드 작성: vim, nano, gedit 등 원하는 텍스트 에디터를 사용하여 소스 파일(예: hello.c)을 작성한다.19
컴파일: 터미널에서 다음 명령을 사용하여 컴파일한다.39
gcc hello.c -o hello
-o hello 옵션은 출력될 실행 파일의 이름을 hello로 지정하는 것이다. 이 옵션을 생략하면 기본적으로 a.out이라는 이름의 파일이 생성된다.실행: 생성된 실행 파일을 다음 명령으로 실행한다.39
./hello
Linux에서도 VS Code를 사용하여 그래픽 기반의 편리한 개발 환경을 구축할 수 있다.
빌드 도구 설치: 방법 1과 동일하게 build-essential 패키지를 먼저 설치한다.
Visual Studio Code 설치: Ubuntu Software Center를 이용하거나, 터미널에서 snap을 통해 설치할 수 있다.40
sudo snap install code --classic
VS Code 확장 프로그램 및 설정: Windows나 macOS에서와 동일하게 C/C++ Extension Pack을 설치하고, .vscode 폴더의 설정 파일들을 통해 GCC 컴파일러와 GDB 디버거를 연동한다.38
이 파트에서는 C 프로그램을 구성하는 가장 기본적인 요소인 변수, 데이터 타입, 연산자, 입출력 함수를 학습한다. 컴퓨터가 데이터를 어떻게 표현하고 처리하는지에 대한 근본적인 이해를 목표로 한다.
모든 프로그래밍 언어 학습의 첫 관문은 화면에 “Hello, World!”를 출력하는 프로그램을 작성하는 것이다. 이 간단한 코드는 C 프로그램의 기본 구조를 담고 있다.
#include <stdio.h>
int main(void)
{
printf("Hello, World!\n");
return 0;
}
이 코드의 각 구성 요소를 분석해 보자.
#include <stdio.h>: 전처리기 지시문(preprocessor directive)이다. 컴파일이 시작되기 전에 전처리기가 이 줄을 먼저 처리한다. <stdio.h>는 표준 입출력(Standard Input/Output) 함수의 선언이 담긴 헤더 파일(header file)이다. 이 파일을 포함시켜야 printf와 같은 함수를 사용할 수 있다.4int main(void): main 함수의 시작을 알린다. C 프로그램은 항상 main 함수에서 실행을 시작하며, 프로그램의 진입점(entry point) 역할을 한다. int는 이 함수가 종료될 때 정수(integer) 값을 반환(return)함을 의미하고, (void)는 함수가 아무런 인자(argument)도 받지 않음을 의미한다.4{... }: 중괄호는 함수의 시작과 끝을 나타내는 코드 블록(code block)이다.printf("Hello, World!\n");: printf 함수를 호출하여 괄호 안의 문자열을 화면에 출력한다. \n은 이스케이프 시퀀스(escape sequence)로, 줄바꿈(new line)을 의미한다.43return 0;: main 함수의 실행을 종료하고 운영체제에 값을 반환한다. 관례적으로 0은 프로그램이 성공적으로 종료되었음을 의미한다.4;: 세미콜론은 C에서 하나의 문장(statement)이 끝났음을 알리는 중요한 문법 요소다.프로그램은 데이터를 다루기 위해 존재한다. C에서는 데이터를 변수와 상수의 형태로 다룬다.
변수는 프로그램이 실행되는 동안 값이 변할 수 있는 데이터를 저장하기 위한 메모리 공간에 붙여진 이름이다.45 변수를 사용하기 위해서는 반드시 먼저 선언(declaration)해야 한다. 선언은 컴파일러에게 어떤 종류의 데이터를 저장할 것인지(자료형), 그리고 그 공간을 무엇이라 부를 것인지(변수명)를 알려주는 과정이다.47
int age; // 정수(int)를 저장할 수 있는 'age'라는 이름의 변수 선언
age = 25; // 변수 age에 값 25를 할당(assignment)
double height = 175.8; // 실수(double) 변수 height를 선언과 동시에 175.8로 초기화(initialization)
변수 이름을 짓는 데에는 몇 가지 규칙이 있다.47
_) 문자만 사용할 수 있다.int, for, while 등 C 언어에서 이미 사용 중인 키워드(예약어)는 변수명으로 사용할 수 없다.상수는 프로그램 실행 중에 값이 변하지 않는 고정된 데이터다.45 상수에는 두 가지 종류가 있다.
리터럴 상수 (Literal Constant): 100, 3.14, 'A', "Hello"와 같이 코드에 직접 쓰인 값 자체를 의미한다.49
기호 상수 (Symbolic Constant): 값에 의미 있는 이름을 붙여 사용의 편의성과 코드의 가독성 및 유지보수성을 높인 상수다. C에서는 두 가지 방법으로 정의할 수 있다.
const 키워드 사용: 변수를 선언할 때 앞에 const를 붙이면 그 변수는 값을 변경할 수 없는 상수가 된다. 이는 변수를 읽기 전용으로 만드는 방식이다.49
const double PI = 3.141592;
// PI = 3.14; // 컴파일 오류 발생
#define 전처리기 사용: #define 지시문은 컴파일 이전에 특정 이름을 지정된 값으로 모두 치환한다. 이는 단순 텍스트 치환 방식이다.49
#define TAX_RATE 0.1
데이터 타입(자료형)은 변수가 저장할 데이터의 종류와 그에 따라 할당될 메모리의 크기를 결정하는 핵심적인 개념이다.48 C가 다양한 데이터 타입을 제공하는 이유는 메모리 공간을 낭비 없이 효율적으로 사용하기 위함이다.51 예를 들어, 0부터 100까지의 값만 필요한데 8바이트 크기의 자료형을 쓰는 것은 낭비다.
C의 데이터 타입 시스템은 추상적인 개념이 아니라, 실제 컴퓨터 하드웨어의 아키텍처를 직접적으로 반영한다. char, short, int, long 등 다양한 크기의 정수형이 존재하는 이유는 CPU가 데이터를 처리하는 기본 단위인 워드(word) 크기에 맞춰 가장 효율적인 연산을 수행하기 위함이다.52 특히 int의 크기는 표준에 고정되어 있지 않고, 해당 시스템에서 가장 효율적으로 처리할 수 있는 크기로 정의되는 경우가 많다.
sizeof 연산자를 사용하면 특정 자료형이나 변수가 차지하는 메모리 크기를 바이트 단위로 확인할 수 있다.54
| 분류 | 자료형 (Keyword) | 크기 (Bytes) | 값의 범위 | 서식 지정자 |
|---|---|---|---|---|
| 문자형 | char |
1 | -128 ~ 127 | %c |
unsigned char |
1 | 0 ~ 255 | %c |
|
| 정수형 | short |
2 | -32,768 ~ 32,767 | %hd |
unsigned short |
2 | 0 ~ 65,535 | %hu |
|
int |
4 | -2,147,483,648 ~ 2,147,483,647 | %d |
|
unsigned int |
4 | 0 ~ 4,294,967,295 | %u |
|
long |
4 | -2,147,483,648 ~ 2,147,483,647 | %ld |
|
unsigned long |
4 | 0 ~ 4,294,967,295 | %lu |
|
long long |
8 | 약 $-9.2 \times 10^{18}$ ~ $9.2 \times 10^{18}$ |
%lld |
|
unsigned long long |
8 | 0 ~ 약 $1.8 \times 10^{19}$ |
%llu |
|
| 실수형 | float |
4 | 약 $\pm 3.4 \times 10^{\pm 38}$ |
%f |
double |
8 | 약 $\pm 1.7 \times 10^{\pm 308}$ |
%lf (scanf), %f (printf) |
|
long double |
8 이상 | double 이상 |
%Lf |
참고: 위 표의 크기와 범위는 일반적인 32/64비트 시스템 기준이며, 컴파일러나 시스템 환경에 따라 다를 수 있다.50
C는 데이터를 가공하고 처리하기 위한 풍부하고 강력한 연산자들을 제공한다.55
+ (덧셈), - (뺄셈), * (곱셈), / (나눗셈), % (나머지)= (단순 대입), +=, -=, *=, /=, %= (복합 대입)++ (1 증가), -- (1 감소). 변수 앞에 붙으면 전위(prefix), 뒤에 붙으면 후위(postfix) 연산이 된다.== (같다), != (다르다), > (크다), < (작다), >= (크거나 같다), <= (작거나 같다). 결과는 참(1) 또는 거짓(0)이다.&& (논리 AND), || (논리 OR), ! (논리 NOT)& (비트 AND), | (비트 OR), ^ (비트 XOR), ~ (비트 NOT), << (왼쪽 시프트), >> (오른쪽 시프트)sizeof (크기 계산), (type) (형 변환), ? : (조건 연산자, 삼항 연산자)하나의 수식에 여러 연산자가 함께 사용될 때, 계산 순서는 우선순위(precedence)와 결합 방향(associativity)에 따라 결정된다. 우선순위가 높은 연산자가 먼저 실행되며, 우선순위가 같을 경우 결합 방향(왼쪽에서 오른쪽으로 또는 그 반대)에 따라 실행 순서가 정해진다. 복잡한 수식에서는 괄호 ()를 사용하여 실행 순서를 명확히 지정하는 것이 바람직하다.57
| 우선순위 | 연산자 | 설명 | 결합 방향 |
|---|---|---|---|
| 1 (높음) | () `` . -> x++ x-- |
함수 호출, 배열 첨자, 멤버 접근 | –» |
| 2 | ++x --x ! ~ (type) * & sizeof |
전위 증감, 논리/비트 NOT, 형 변환, 포인터 참조 | ← |
| 3 | * / % |
곱셈, 나눗셈, 나머지 | –» |
| 4 | + - |
덧셈, 뺄셈 | –» |
| 5 | << >> |
비트 시프트 | –» |
| 6 | < <= > >= |
관계 연산 | –» |
| 7 | == != |
등가 연산 | –» |
| 8 | & |
비트 AND | –» |
| 9 | ^ |
비트 XOR | –» |
| 10 | $\rvert$ |
비트 OR | –» |
| 11 | && |
논리 AND | –» |
| 12 | $\rvert\rvert$ |
논리 OR | –» |
| 13 | ? : |
조건(삼항) 연산자 | ← |
| 14 | = += -= *= /= %=… |
대입 연산자 | ← |
| 15 (낮음) | , |
쉼표 연산자 | –» |
출처: 55
프로그램은 사용자와 상호작용하기 위해 데이터를 입력받고 결과를 출력해야 한다. C에서는 <stdio.h> 헤더 파일에 정의된 표준 입출력 함수를 통해 이 기능을 수행한다.
printf 함수는 서식 문자열(format string)을 사용하여 다양한 종류의 데이터를 정해진 형식에 맞춰 화면(표준 출력)에 출력한다.44
int age = 25;
double pi = 3.141592;
printf("나이: %d, 원주율: %f\n", age, pi);
서식 문자열 안의 %d, %f와 같은 서식 지정자(format specifier)는 뒤따라오는 인자(age, pi)의 값을 어떤 형태로 출력할지 지정하는 역할을 한다. 서식 지정자는 출력할 데이터의 타입과 일치해야 한다.61
printf는 출력 형식을 세밀하게 제어하는 다양한 옵션을 제공한다.63
%10d: 최소 10칸의 출력 폭을 확보하고 오른쪽 정렬하여 출력한다.%-10d: 최소 10칸의 출력 폭을 확보하고 왼쪽 정렬하여 출력한다.%.2f: 실수를 소수점 이하 둘째 자리까지 출력한다.%05d: 5칸의 폭을 확보하고, 남는 공간을 0으로 채운다.scanf 함수는 키보드(표준 입력)로부터 데이터를 입력받아 변수에 저장하는 역할을 한다. printf와 유사한 서식 지정자를 사용한다.65
int age;
printf("나이를 입력하세요: ");
scanf("%d", &age);
scanf를 사용할 때 가장 중요한 규칙은 값을 저장할 변수 앞에 반드시 주소 연산자 &를 붙여야 한다는 것이다.44
scanf는 값을 저장하기 위해 변수의 값이 아닌 변수의 메모리 주소를 필요로 하기 때문이다. (단, 문자열을 배열에 저장할 때는 예외적으로 &를 붙이지 않는다.)
scanf의 동작 원리를 이해하려면 ‘입력 버퍼’의 개념을 알아야 한다. 사용자가 키보드로 입력한 데이터는 프로그램에 직접 전달되지 않고, 운영체제가 관리하는 임시 공간인 입력 버퍼에 먼저 저장된다. scanf는 이 버퍼에서 형식에 맞는 데이터를 읽어간다. 이때, scanf는 기본적으로 공백 문자(스페이스, 탭, 개행)를 데이터의 구분자로 취급한다.68 이로 인해 개행 문자(\n)가 버퍼에 남아 다음 입력 함수에 영향을 주는 등 예기치 않은 동작이 발생할 수 있다. 이는 C 입문자들이 흔히 겪는 문제로, 입력 처리 시 세심한 주의가 필요하다.
| 서식 지정자 | 대응 자료형 | 설명 |
|---|---|---|
%d, %i |
int |
부호 있는 10진 정수 |
%u |
unsigned int |
부호 없는 10진 정수 |
%o |
unsigned int |
부호 없는 8진 정수 |
%x, %X |
unsigned int |
부호 없는 16진 정수 (소문자/대문자) |
%c |
char |
단일 문자 |
%s |
char* (문자열) |
문자열 (널 문자 \0까지) |
%f |
float, double |
10진 표기법 실수 (printf) |
%lf |
double |
10진 표기법 실수 (scanf) |
%e, %E |
float, double |
지수 표기법 실수 (소문자/대문자) |
%p |
void* (포인터) |
포인터의 메모리 주소 (16진수) |
%% |
해당 없음 | % 문자 자체를 출력 |
출처: 61
이 파트에서는 프로그램의 실행 순서를 제어하는 방법을 배운다. 조건에 따라 다른 코드를 실행하거나, 특정 코드를 반복적으로 실행하는 제어문을 통해 논리적인 프로그램을 작성하는 능력을 기른다.
조건문은 특정 조건의 참(true) 또는 거짓(false) 여부에 따라 프로그램의 실행 흐름을 분기시키는 구문이다. C에서 참은 0이 아닌 모든 값으로, 거짓은 0으로 표현된다.
if 문은 가장 기본적인 조건문으로, 주어진 조건식이 참일 경우에만 특정 코드 블록을 실행한다.70
if (score >= 90)
{
printf("A 학점입니다.\n");
}
else를 사용하면 if의 조건식이 거짓일 때 실행될 코드를 지정할 수 있다. else if를 추가하면 여러 조건을 연쇄적으로 검사하는 것이 가능하다.71
if (score >= 90)
{
printf("A 학점입니다.\n");
}
else if (score >= 80)
{
printf("B 학점입니다.\n");
}
else
{
printf("C 학점 이하입니다.\n");
}
if-else if-else 구조는 위에서 아래로 순차적으로 조건을 검사하며, 하나의 조건이라도 참이 되어 해당 블록이 실행되면 나머지 else if나 else 부분은 검사하지 않고 전체 구조를 빠져나온다.72
switch 문은 하나의 정수형 변수나 수식의 값이 여러 개의 특정 상수 값과 일치하는 경우를 처리하는 데 특화된 다중 분기문이다.71
switch (level)
{
case 1:
printf("초급 사용자\n");
break;
case 2:
printf("중급 사용자\n");
break;
case 3:
printf("고급 사용자\n");
break;
default:
printf("알 수 없는 레벨\n");
break;
}
switch의 괄호 안에는 정수형으로 평가될 수 있는 변수나 식이 온다.case 뒤에는 반드시 정수형 상수(리터럴 또는 const 상수)나 문자 상수가 와야 하며, 변수는 올 수 없다.break 문은 해당 case의 실행을 마치고 switch 블록 전체를 빠져나가도록 하는 중요한 역할을 한다. 만약 break가 없으면, 일치하는 case부터 시작하여 break를 만나거나 switch 블록이 끝날 때까지 아래의 모든 case 문이 연달아 실행된다. 이를 fall-through라고 한다.73default는 if문의 else와 같이, 어떤 case와도 일치하지 않을 때 실행되는 선택적 레이블이다.72if-else와 switch는 선택의 문제이며, 그 결정은 주로 ‘조건의 형태’와 ‘성능’이라는 두 가지 측면에서 이루어진다.
조건의 형태: if문은 score >= 90과 같이 범위를 비교하거나, 여러 변수를 조합한 복잡한 논리 표현이 가능하다.71 반면,
switch문은 변수의 값이 1, 2, 'A'와 같은 특정 상수 값과 ‘정확히 일치’하는지만 검사할 수 있다. 따라서 조건이 범위나 복잡한 논리라면 if를, 여러 개의 특정 값과 비교하는 경우라면 switch를 사용하는 것이 더 자연스럽고 가독성이 높다.71
성능: if-else if 체인은 조건문을 위에서부터 하나씩 순차적으로 평가한다. 만약 case가 10개이고 마지막 case에 해당하는 조건이라면, 앞의 9개 조건을 모두 비교한 후에야 실행된다. 최악의 경우, 조건의 개수에 비례하여 성능이 저하될 수 있다 ($O(N)$).74 반면,
switch문은 컴파일러에 의해 점프 테이블(jump table)이라는 내부 구조로 최적화되는 경우가 많다. 점프 테이블은 배열처럼 동작하여, switch 변수의 값을 인덱스로 사용하여 실행할 코드로 한 번에 ‘점프’한다. 이 경우, case의 개수와 상관없이 거의 일정한 시간($O(1)$)에 해당 코드를 찾을 수 있어 if-else보다 훨씬 효율적이다.74
case의 개수가 3~4개를 넘어간다면 switch가 성능상 이점을 보이기 시작한다.75
결론적으로, 여러 개의 특정 값과 비교해야 하는 상황에서는 switch문이 가독성, 유지보수성, 성능 모든 면에서 더 나은 선택이다.
반복문은 특정 코드 블록을 정해진 횟수만큼 또는 특정 조건이 만족되는 동안 반복적으로 실행하는 구문이다.
for 문은 반복 횟수가 명확하게 정해져 있을 때 가장 일반적으로 사용된다.76 반복을 제어하는 데 필요한 세 가지 핵심 요소인 초기식, 조건식, 증감식을 괄호 안에 한 줄로 명시하여 구조가 명확하고 가독성이 높다.78
for (int i = 0; i < 5; i++)
{
printf("반복 %d\n", i);
}
for 문의 실행 순서는 다음과 같다.
int i = 0): 루프에 진입하기 전 단 한 번만 실행된다. 주로 반복 제어 변수를 선언하고 초기화한다.i < 5): 매 반복이 시작되기 전에 평가된다. 이 식이 참이면 루프 본문을 실행하고, 거짓이면 루프를 종료한다.{...}): 조건식이 참일 경우 실행된다.i++): 루프 본문이 실행된 후 매번 실행된다. 주로 제어 변수의 값을 변경하여 언젠가 조건식이 거짓이 되도록 만든다.while 문은 반복 횟수가 정해져 있지 않고, 특정 조건이 만족되는 동안 계속해서 반복해야 할 때 유용하다.77 조건식을 루프의 시작 부분에서 검사한다.
int count = 0;
while (count < 5)
{
printf("count: %d\n", count);
count++;
}
만약 count의 초기값이 5 이상이었다면, while의 조건식은 처음부터 거짓이므로 루프 본문은 한 번도 실행되지 않는다.
do-while 문은 while 문과 매우 유사하지만, 조건식을 루프의 끝에서 검사한다는 결정적인 차이가 있다. 이 때문에 do-while 루프의 본문은 조건식의 참/거짓 여부와 관계없이 최소 한 번은 반드시 실행된다.76
int selection;
do
{
printf("1. 시작 2. 종료\n");
printf("선택: ");
scanf("%d", &selection);
} while (selection!= 1 && selection!= 2);
위 예제처럼 사용자에게 메뉴를 보여주고 입력을 받는 경우, 최소 한 번은 메뉴를 출력해야 하므로 do-while 문이 매우 적합하다.
for: 반복 횟수가 사전에 명확하게 정해진 경우 (예: 1부터 100까지의 합, 배열의 모든 요소 순회).77while: 반복 횟수를 예측할 수 없고, 특정 조건이 만족되는 동안 계속 반복해야 하는 경우 (예: 파일의 끝에 도달할 때까지 읽기, 사용자가 특정 값을 입력할 때까지 반복).76do-while: 코드를 최소 한 번은 실행해야 하는 경우 (예: 사용자 입력 검증, 메뉴 표시).76분기 제어문은 반복문의 정상적인 흐름을 벗어나 다른 곳으로 실행을 이동시키는 역할을 한다.
break: 현재 실행 중인 가장 안쪽의 반복문(for, while, do-while)이나 switch 문을 즉시 중단하고 빠져나온다.81continue: 반복문의 현재 실행 단계를 중단하고, 루프의 나머지 부분을 건너뛴 뒤 즉시 다음 반복을 시작한다. for 문에서는 증감식으로 이동하고, while이나 do-while 문에서는 조건식 검사 부분으로 이동한다.82goto: 코드 내에 정의된 특정 레이블(label)로 프로그램의 실행 흐름을 무조건적으로 이동시킨다.82goto 문은 프로그램의 논리적 흐름을 예측하기 어렵게 만드는 주범으로, 소위 ‘스파게티 코드’를 유발할 수 있다.84 구조적 프로그래밍의 원칙을 해치므로 사용을 극도로 지양해야 한다. 대부분의 경우 break, continue, return 또는 함수 분리를 통해 goto 없이 더 나은 구조의 코드를 작성할 수 있다. 예외적으로 깊게 중첩된 루프를 한 번에 탈출하는 등 매우 제한적인 상황에서만 고려될 수 있으나, 이 역시 좋은 설계로 보기 어렵다.
이 파트에서는 코드를 재사용 가능하고 관리하기 쉬운 단위로 만드는 ‘함수’에 대해 배운다. 함수의 선언과 정의, 다양한 값 전달 방식, 그리고 강력한 프로그래밍 기법인 재귀 호출을 탐구한다.
함수(function)는 특정 작업을 수행하기 위해 설계된 독립적인 코드 블록이다. 잘 설계된 함수는 코드의 재사용성을 높이고, 프로그램을 논리적인 단위로 분해하여 전체적인 구조를 이해하기 쉽게 만든다.86 C 프로그램은 이러한 함수들의 집합으로 구성된다.
함수는 선언(declaration)과 정의(definition)라는 두 가지 요소로 구성된다.87
함수 정의 (Function Definition): 함수의 실제 몸체(body)를 구현하는 부분이다. 함수가 어떤 작업을 수행할지를 구체적인 코드로 작성한다. 하나의 함수는 프로그램 전체에서 단 한 번만 정의될 수 있다.87
// 두 정수의 합을 반환하는 add 함수의 정의
int add(int a, int b) // 반환형, 함수명, 매개변수 목록
{
int result = a + b;
return result; // 결과값 반환
}
함수 선언 (Function Declaration): 함수 원형(prototype)이라고도 불리며, 컴파일러에게 “이러한 이름, 반환 타입, 매개변수 타입을 가진 함수가 프로그램 어딘가에 존재한다”고 미리 알려주는 역할을 한다. 선언은 문장이므로 반드시 세미콜론(;)으로 끝나야 한다.89
int add(int a, int b); // add 함수의 선언 (원형)
함수 선언이 필요한 이유는 C 컴파일러의 동작 방식과 관련이 있다. C 컴파일러는 기본적으로 소스 코드를 위에서 아래로 한 번만 훑으며 번역한다. 만약 main 함수에서 add 함수를 호출하는데, add 함수의 정의가 main 함수보다 아래쪽에 위치한다면 컴파일러는 add라는 이름의 함수를 처음 마주했을 때 그 정체를 알지 못해 오류를 발생시킨다.88
이때, 소스 코드 상단에 함수 선언을 미리 해두면 컴파일러는 “아, add라는 함수는 정수 두 개를 인자로 받아 정수를 반환하는 함수구나”라고 인지하고 넘어갈 수 있다. 실제 함수의 몸체는 링킹(linking) 단계에서 찾아 연결된다. 따라서 함수 선언은 함수의 정의 위치에 구애받지 않고 자유롭게 함수를 호출할 수 있도록 해주는 필수적인 요소다. 일반적으로 함수 선언은 헤더 파일(.h)에 모아두고, 함수 정의는 소스 파일(.c)에 작성하여 관리한다.87
함수를 호출할 때 인자(argument)를 매개변수(parameter)에 전달하는 방식은 크게 두 가지로 나뉜다.
C 언어의 기본적인 함수 호출 방식이다. 함수를 호출할 때, 인자로 전달되는 변수의 값(value)이 복사되어 함수의 매개변수에 전달된다.91
#include <stdio.h>
void increment(int num) // num은 value의 복사본
{
num = num + 1;
printf("함수 안: %d\n", num); // 출력: 11
}
int main(void)
{
int value = 10;
increment(value);
printf("함수 밖: %d\n", value); // 출력: 10
return 0;
}
위 예제에서 increment 함수가 호출될 때 main 함수의 value 변수 값인 10이 increment 함수의 매개변수 num에 복사된다. 함수 안에서 num의 값을 11로 변경하더라도, 이는 복사본을 변경한 것이므로 main 함수의 원본 변수 value에는 아무런 영향을 미치지 않는다.92
때로는 함수 안에서 함수 밖의 원본 변수 값을 직접 변경해야 할 필요가 있다. C 언어는 ‘참조’라는 개념을 직접 지원하지는 않지만, 포인터를 사용하여 동일한 효과를 구현할 수 있다. 이는 변수의 값을 복사해서 넘기는 대신, 변수가 저장된 메모리 주소(address)를 전달하는 방식이다.91
#include <stdio.h>
// 두 변수의 값을 교환하는 함수
void swap(int* a, int* b) // 주소를 받기 위해 매개변수를 포인터로 선언
{
int temp = *a; // a가 가리키는 주소의 값
*a = *b; // a가 가리키는 주소에 b가 가리키는 값을 저장
*b = temp;
}
int main(void)
{
int x = 10, y = 20;
printf("바꾸기 전: x=%d, y=%d\n", x, y);
swap(&x, &y); // 변수의 주소를 인자로 전달
printf("바꾼 후: x=%d, y=%d\n", x, y);
return 0;
}
swap 함수는 매개변수로 정수 포인터 int*를 받는다. main 함수에서는 swap을 호출할 때 변수 x와 y의 주소를 주소 연산자 &를 이용해 전달한다. swap 함수 내에서는 간접 참조 연산자 *를 사용하여 전달받은 주소를 통해 main 함수의 원본 변수 x와 y에 직접 접근하여 값을 변경할 수 있다. 이 기법은 C 프로그래밍에서 매우 중요하고 빈번하게 사용된다.92
재귀 함수는 함수 내부에서 자기 자신을 다시 호출하는 방식으로 동작하는 함수다.96 복잡한 문제를 더 작고 동일한 구조의 문제로 나누어 해결하는 데 효과적이다. 모든 재귀 함수는 반드시 두 가지 핵심 요소를 갖추어야 한다.
팩토리얼(Factorial) 계산은 재귀 함수의 대표적인 예다. 팩토리얼은 수학적으로 $n! = n \times (n-1)!$로 정의되며, $0! = 1$이라는 종료 조건을 가진다. 이 정의는 재귀 함수의 구조와 완벽하게 일치한다.
#include <stdio.h>
long long factorial(int n)
{
// 종료 조건 (Base Case)
if (n == 0)
{
return 1;
}
// 재귀 호출 (Recursive Step)
else
{
return n * factorial(n - 1);
}
}
int main(void)
{
printf("5! = %lld\n", factorial(5)); // 출력: 120
return 0;
}
factorial(5)가 호출되면, 내부적으로 5 * factorial(4), 4 * factorial(3),…, 1 * factorial(0) 순으로 연쇄적인 호출이 일어난다. factorial(0)이 종료 조건에 도달하여 1을 반환하면, 호출의 역순으로 값이 계산되어 최종 결과인 120이 반환된다.98
재귀는 문제의 정의를 코드로 간결하고 우아하게 표현할 수 있게 해주지만, 함수 호출에 따르는 부하(overhead)가 있어 단순 반복문보다 성능이 저하될 수 있다. 특히 피보나치 수열처럼 하나의 문제를 해결하기 위해 자기 자신을 여러 번 호출하는 경우, 동일한 계산이 불필요하게 반복되어 성능이 급격히 나빠질 수 있다.99 따라서 재귀는 문제 해결을 위한 강력한 사고의 도구로 활용하되, 실제 구현 시에는 성능을 고려하여 반복문으로의 변환을 고려하거나 메모이제이션(memoization) 같은 최적화 기법을 함께 사용하는 것이 현명하다.
이 파트에서는 C 언어를 가장 C 언어답게 만드는 핵심 기능인 포인터를 심도 있게 다룬다. 메모리 주소의 개념부터 시작하여 포인터 연산, 배열과의 밀접한 관계, 그리고 함수 포인터와 같은 고급 활용법까지 정복한다.
포인터(Pointer)는 변수다. 하지만 일반 변수처럼 데이터 값 자체를 저장하는 것이 아니라, 다른 변수나 데이터가 저장된 메모리의 주소(address)를 값으로 저장하는 특별한 변수다.53 컴퓨터의 메모리(RAM)는 데이터를 저장하는 수많은 바이트(byte) 공간들이 일렬로 늘어선 것과 같으며, 각 바이트 공간에는 고유한 번호, 즉 주소가 부여되어 있다. 포인터는 바로 이 주소 값을 다루기 위한 도구다.
포인터의 핵심 연산자는 두 가지다.
&): 앰퍼샌드(ampersand)라고 읽는다. 변수 이름 앞에 붙여서 해당 변수가 할당된 메모리의 시작 주소 값을 반환한다.53\*): 애스터리스크(asterisk)라고 읽는다. 포인터 변수 이름 앞에 붙여서, 그 포인터가 가리키고 있는 메모리 주소에 저장된 값에 접근(읽거나 쓰기)하게 해준다. 이를 역참조(dereferencing)라고도 한다.53포인터는 다음과 같이 선언하고 사용한다.
#include <stdio.h>
int main(void)
{
int num = 100; // 정수형 변수 num 선언 및 100으로 초기화
int* ptr; // 정수형 변수를 가리킬 포인터 변수 ptr 선언
ptr = # // ptr에 num 변수의 주소를 저장. 이제 ptr은 num을 가리킨다.
printf("num의 값: %d\n", num); // 출력: 100
printf("num의 주소: %p\n", &num); // num의 메모리 주소 출력
printf("ptr에 저장된 값: %p\n", ptr); // ptr이 저장하고 있는 값(num의 주소) 출력
printf("ptr이 가리키는 값: %d\n", *ptr); // ptr이 가리키는 주소에 있는 값(num의 값) 출력
*ptr = 200; // ptr이 가리키는 곳의 값을 200으로 변경
printf("값 변경 후 num의 값: %d\n", num); // 출력: 200
return 0;
}
포인터를 선언할 때 자료형* 포인터이름; 형식에서 자료형은 포인터 변수 자체의 타입이 아니라, 그 포인터가 가리킬 대상의 자료형을 의미한다.100 모든 포인터 변수는 시스템 아키텍처에 따라 동일한 크기(예: 64비트 시스템에서 8바이트)를 가지지만, 가리키는 대상의 자료형을 명시하는 것은 매우 중요하다.
포인터에 타입 정보가 필요한 이유는 크게 두 가지다. 첫째, 메모리 해석의 기준을 제공한다. 컴파일러는 int* 포인터를 역참조할 때 해당 주소부터 sizeof(int)(예: 4바이트)만큼을 하나의 정수로 읽어오고, char* 포인터를 역참조할 때는 sizeof(char)(1바이트)만큼을 문자로 읽어온다. 타입 정보가 없다면 메모리를 몇 바이트 단위로 어떻게 해석해야 할지 알 수 없다.102
둘째, 주소 연산의 기준을 제공한다. int* 타입의 포인터에 1을 더하면 주소 값은 sizeof(int)만큼 증가한다. 이는 포인터가 배열처럼 연속된 메모리 공간을 올바르게 탐색할 수 있도록 하는 핵심 원리다.
포인터 변수에는 제한된 산술 연산이 가능하다. 이는 메모리 상에서 데이터를 순차적으로 접근하기 위해 설계되었다.
++ 또는 -- 연산을 적용할 수 있다. ptr++는 ptr이 가리키는 주소를 sizeof(*ptr)만큼 증가시킨다. 즉, int* 포인터라면 4바이트, double* 포인터라면 8바이트만큼 다음 메모리 주소로 이동한다.103ptr + n은 ptr의 주소 값에 n * sizeof(*ptr)을 더한 새로운 주소 값을 의미한다. 이를 통해 배열의 n번째 다음 요소에 직접 접근할 수 있다.105포인터끼리 더하거나, 곱하거나, 나누는 연산은 논리적으로 의미가 없으므로 허용되지 않는다.103
C 언어에서 포인터와 배열은 매우 밀접한 관계를 맺고 있으며, 문법적으로 거의 동일하게 사용될 수 있다.
“배열의 이름은 포인터다”라는 말은 흔히 사용되지만, 정확히는 ‘배열의 이름은 그 배열의 첫 번째 요소의 주소를 가리키는 포인터 상수(constant pointer)’이다.107
int arr = {10, 20, 30, 40, 50};
int* ptr = arr; // 배열의 이름 arr은 &arr과 같으므로 포인터에 대입 가능
문법적 동등성: arr[i]와 *(arr + i)는 C 언어 문법상 완전히 동일한 표현이다. 마찬가지로 ptr[i]와 *(ptr + i)도 동일하다. 이 때문에 포인터 변수를 배열 이름처럼, 배열 이름을 포인터처럼 사용할 수 있다.109
printf("%d\n", arr); // 출력: 30
printf("%d\n", *(arr + 2)); // 출력: 30
printf("%d\n", ptr); // 출력: 30
printf("%d\n", *(ptr + 2)); // 출력: 30
결정적 차이점:
arr은 상수이므로, 가리키는 주소를 변경할 수 없다. arr++나 arr = 다른주소와 같은 코드는 컴파일 오류를 발생시킨다. 반면, ptr은 변수이므로 다른 주소를 가리키도록 변경하는 것이 가능하다 (ptr++ 등).108sizeof 연산자: sizeof(arr)는 배열 전체의 크기(이 예제에서는 4 * 5 = 20바이트)를 반환한다. 반면, sizeof(ptr)는 포인터 변수 자체의 크기(64비트 시스템에서는 8바이트)를 반환한다.109이러한 미묘하지만 중요한 차이점을 이해하는 것은 포인터와 배열을 정확하게 다루는 데 필수적이다.
함수 포인터(Function Pointer)는 변수나 배열이 아닌, 함수의 시작 주소를 저장하는 특별한 포인터다.111 프로그램이 컴파일되면 함수 코드 역시 메모리의 특정 영역(코드 세그먼트)에 위치하게 되며, 함수 이름은 그 코드의 시작 주소를 나타내는 포인터 상수처럼 사용될 수 있다.112
함수 포인터의 선언 문법은 다소 복잡하다.
반환_타입 (*포인터_이름)(매개변수_타입_목록);
예를 들어, int를 반환하고 두 개의 int를 매개변수로 받는 함수를 가리킬 수 있는 함수 포인터 fp는 다음과 같이 선언한다.
int (*fp)(int, int);
여기서 (*fp)의 괄호는 매우 중요하다. 만약 int *fp(int, int);라고 쓰면, 이는 두 개의 int를 인자로 받아 int*를 반환하는 fp라는 이름의 ‘함수’를 선언하는 것이 되어 의미가 완전히 달라진다.113
#include <stdio.h>
int add(int a, int b) { return a + b; }
int subtract(int a, int b) { return a - b; }
int main(void)
{
int (*fp)(int, int); // 함수 포인터 선언
fp = add; // fp가 add 함수의 주소를 가리킴
printf("결과: %d\n", fp(10, 5)); // 출력: 15
fp = subtract; // fp가 subtract 함수의 주소를 가리킴
printf("결과: %d\n", fp(10, 5)); // 출력: 5
return 0;
}
함수 포인터는 다음과 같은 고급 프로그래밍 기법을 가능하게 한다.
함수 포인터는 C 언어의 유연성과 확장성을 극대화하는 강력한 도구로, 잘 활용하면 매우 정교하고 효율적인 프로그램을 작성할 수 있다.
이 파트에서는 기본 데이터 타입을 조합하여 더 복잡하고 의미 있는 데이터를 표현하는 방법을 배운다. 다차원 배열, 문자열, 그리고 C의 강력한 사용자 정의 자료형인 구조체, 공용체, 열거형을 다룬다.
다차원 배열은 배열의 배열이다. 가장 흔하게 사용되는 2차원 배열은 행(row)과 열(column)으로 구성된 테이블이나 행렬과 같은 데이터를 표현하는 데 적합하다.115
2차원 배열은 자료형 배열이름[행의_개수][열의_개수]; 형태로 선언한다.
int matrix; // 3행 4열의 정수형 2차원 배열
초기화는 중첩된 중괄호를 사용하여 각 행의 데이터를 지정하는 것이 일반적이다.
int matrix = {
{1, 2, 3, 4},
{5, 6, 7, 8},
{9, 10, 11, 12}
};
초기화 값을 1차원 배열처럼 나열할 수도 있지만, 가독성이 떨어진다.115 초기화 값이 부족할 경우 나머지 요소들은 모두 0으로 자동 초기화된다.117
논리적으로는 2차원 테이블 구조이지만, 실제 컴퓨터 메모리에는 1차원 공간에 연속적으로 저장된다. matrix 배열의 요소들은 메모리에 matrix, matrix,…, matrix, matrix,… 순서로 빈틈없이 나열된다.119 이러한 메모리 구조 때문에, 배열을 선언하거나 함수의 매개변수로 전달할 때 첫 번째 차원(행)의 크기는 생략할 수 있어도, 두 번째 차원(열)부터의 크기는 반드시 명시해야 한다. 컴파일러가
matrix과 같이 다음 행의 시작 주소를 계산하려면 한 행이 몇 개의 요소로 이루어져 있는지(열의 크기)를 알아야 하기 때문이다.115
C 언어에는 별도의 문자열 타입이 존재하지 않는다. 대신, char 타입의 배열을 사용하여 문자열을 다룬다. C 스타일 문자열의 가장 중요한 특징은 문자열의 끝을 표시하기 위해 항상 널(NULL) 문자 \0가 마지막에 추가된다는 점이다.47 예를 들어, "hello"라는 문자열은 메모리에 'h', 'e', 'l', 'l', 'o', '\0' 순서로 총 6바이트를 차지한다.
char str1 = "Hello"; // 컴파일러가 크기를 6으로 자동 계산
char str2 = "World"; // 크기를 10으로 지정. 나머지 공간은 널 문자로 채워짐
문자열을 다루는 작업은 매우 빈번하므로, C 표준 라이브러리는 <string.h> 헤더 파일을 통해 다양한 문자열 처리 함수를 제공한다.121
strlen(str): 문자열 str의 길이(널 문자 제외)를 반환한다.121strcpy(dest, src): 문자열 src를 dest로 복사한다. dest 배열은 src 문자열과 널 문자를 담을 만큼 충분히 커야 한다.123strcat(dest, src): 문자열 dest의 끝에 src 문자열을 이어 붙인다.123strcmp(str1, str2): 두 문자열을 사전 순서로 비교한다. str1이 앞서면 음수, 같으면 0, 뒤에 오면 양수를 반환한다.123strchr(str, ch): 문자열 str에서 문자 ch가 처음 나타나는 위치의 포인터를 반환한다. 없으면 NULL을 반환한다.125strstr(haystack, needle): 문자열 haystack에서 문자열 needle이 처음 나타나는 위치의 포인터를 반환한다.125C는 기본 자료형들을 조합하여 프로그래머가 직접 새로운 자료형을 만들 수 있는 강력한 기능을 제공한다.
구조체(Structure)는 이름, 학번, 학점처럼 서로 다른 타입이지만 논리적으로 연관된 데이터들을 하나의 단위로 묶어 새로운 자료형을 정의하는 기능이다.126
// '학생'이라는 새로운 자료형을 정의
struct Student {
char name;
int studentId;
double gpa;
};
int main(void) {
// Student 타입의 변수 s1 선언
struct Student s1;
// 멤버 접근 연산자(.)를 사용하여 멤버에 값 할당
strcpy(s1.name, "홍길동");
s1.studentId = 20240001;
s1.gpa = 4.3;
printf("이름: %s, 학번: %d, 학점: %.2f\n", s1.name, s1.studentId, s1.gpa);
// 선언과 동시에 초기화
struct Student s2 = {"이순신", 20240002, 4.5};
return 0;
}
구조체 변수를 선언할 때마다 struct Student라고 쓰는 것이 번거로울 수 있다. typedef 키워드를 사용하면 구조체에 별칭(alias)을 부여하여 더 간결하게 사용할 수 있다.128
typedef struct {
char name;
int studentId;
double gpa;
} Student; // 이제 'Student'가 새로운 자료형 이름처럼 사용된다.
Student s1; // 'struct' 키워드 없이 선언 가능
구조체 변수를 가리키는 포인터를 선언하고 사용할 수 있다. 구조체 포인터를 통해 멤버에 접근할 때는 점(.) 연산자 대신 화살표 연산자(->)를 사용한다.130
ptr->member는 (*ptr).member와 완전히 동일한 표현이지만 훨씬 간결하다.132
Student s1 = {"홍길동", 20240001, 4.3};
Student* p_s1 = &s1; // 구조체 포인터 p_s1이 s1을 가리킴
printf("이름: %s\n", p_s1->name); // 화살표 연산자로 멤버 접근
p_s1->gpa = 4.4; // 포인터를 통해 원본 구조체의 멤버 값 변경
구조체를 함수에 인자로 전달할 때, 구조체 자체를 값으로 넘기면 구조체 전체가 복사되어 비효율적이다. 특히 구조체의 크기가 클 경우 성능 저하가 발생할 수 있다. 반면, 구조체의 주소(포인터)를 전달하면 주소 값만 복사되므로 매우 효율적이다. 또한, 함수 내에서 원본 구조체의 값을 변경해야 할 경우 반드시 포인터를 사용해야 한다. 이러한 이유로 C 프로그래밍에서는 함수에 구조체를 전달할 때 포인터를 사용하는 것이 일반적인 관례다.130
공용체(Union)는 구조체와 선언 방식이 유사하지만, 모든 멤버가 동일한 메모리 공간을 공유한다는 결정적인 차이가 있다.134 공용체의 전체 크기는 멤버 중에서 가장 큰 자료형의 크기로 결정된다.136
union Data {
int i;
float f;
char str;
};
위 Data 공용체의 크기는 가장 큰 멤버인 str의 크기인 20바이트가 된다. i, f, str 멤버는 모두 이 20바이트의 메모리 공간을 공유한다. 따라서 한 번에 하나의 멤버만 의미 있는 값을 가질 수 있다. i에 값을 저장한 후 f에 값을 저장하면, i의 값은 의미를 잃게 된다.134 공용체는 서로 다른 종류의 데이터를 같은 메모리 영역에서 다루어야 할 때, 예를 들어 통신 프로토콜의 패킷을 해석하거나 메모리를 절약해야 하는 특수한 상황에서 사용된다.137
열거형(Enumeration)은 RED, GREEN, BLUE와 같이 서로 연관된 정수형 상수에 의미 있는 이름을 부여하여 코드의 가독성을 높이는 기능이다.138
enum Weekday { SUN, MON, TUE, WED, THU, FRI, SAT };
// 별도로 값을 지정하지 않으면 SUN=0, MON=1, TUE=2,... 순으로 자동 할당
열거형으로 정의된 이름들은 정수 상수로 취급된다. 특정 값부터 시작하도록 초기화할 수도 있으며, 중간에 값을 지정하면 그 다음 멤버는 지정된 값에서 1씩 증가한다.140
enum MachineState { STOPPED, RUNNING = 5, PAUSED }; // STOPPED=0, RUNNING=5, PAUSED=6
열거형은 #define을 사용하여 상수를 정의하는 것보다 타입 검사 측면에서 더 안전하며, 특히 switch 문과 함께 사용될 때 코드의 의도를 명확하게 드러내어 유지보수를 용이하게 한다.142
이 파트에서는 프로그램이 실행되는 동안 필요한 메모리를 동적으로 할당하고 해제하는 방법과, 데이터를 영구적으로 저장하기 위해 파일 시스템과 상호작용하는 방법을 다룬다. C의 로우 레벨 특성이 가장 잘 드러나는 영역이다.
프로그램이 실행될 때 필요한 메모리의 크기를 미리 알 수 없는 경우가 많다. 예를 들어, 사용자로부터 몇 개의 데이터를 입력받을지 모르는 상황에서 그 데이터를 저장할 배열의 크기를 미리 정할 수는 없다. 이럴 때 필요한 것이 동적 메모리 할당이다.
C에서 메모리 할당 방식은 크게 두 가지로 나뉜다.
스택과 힙의 가장 큰 차이는 ‘관리 주체’와 ‘생명 주기’에 있다. 스택은 컴파일러와 운영체제가 자동으로 관리하며 생명 주기가 함수 호출에 묶여 있지만, 힙은 프로그래머가 수동으로 관리하며 생명 주기를 직접 제어할 수 있다. 따라서 크기를 예측할 수 없거나 함수 호출을 넘어서까지 데이터가 유지되어야 할 경우 힙을 사용한 동적 할당이 필수적이다.
C 표준 라이브러리(<stdlib.h>)는 힙 메모리 관리를 위한 네 가지 주요 함수를 제공한다.
malloc(size_t size): size 바이트만큼의 메모리 공간을 힙에서 할당하고, 할당된 공간의 시작 주소를 void* 타입의 포인터로 반환한다. 할당에 실패하면 NULL을 반환한다. 할당된 메모리 공간은 초기화되지 않은 임의의 값(쓰레기 값)을 담고 있다.148
void*는 범용 포인터이므로, 실제 사용할 포인터 타입으로 반드시 형 변환(casting)을 해주어야 한다.
int* arr = (int*)malloc(sizeof(int) * 10); // 정수 10개를 저장할 공간 할당
if (arr == NULL) { /* 메모리 할당 실패 처리 */ }
calloc(size_t num, size_t size): num개의 요소를 저장할 메모리 공간을 할당하며, 각 요소의 크기는 size 바이트다. malloc과 가장 큰 차이점은 할당된 메모리 공간의 모든 비트를 0으로 초기화한다는 것이다. 배열을 할당하고 즉시 0으로 초기화할 필요가 있을 때 유용하다.150
realloc(void\* ptr, size_t new_size): malloc이나 calloc으로 이미 할당된 메모리 블록(ptr)의 크기를 new_size로 변경한다. 기존에 저장된 데이터는 가능한 한 보존된다. 크기를 늘리거나 줄일 수 있다.150
free(void\* ptr): 동적으로 할당했던 메모리 공간(ptr)을 힙으로 되돌려주어 다른 프로그램이 사용할 수 있도록 해제한다. malloc, calloc, realloc으로 할당된 모든 메모리는 사용이 끝난 후 반드시 free를 통해 해제해야 메모리 누수를 막을 수 있다.149
프로그램이 종료되면 변수에 저장된 데이터는 모두 사라진다. 데이터를 영구적으로 보존하기 위해서는 하드 디스크나 SSD와 같은 저장 장치에 파일 형태로 저장해야 한다. C는 파일로부터 데이터를 읽고 쓰는 파일 입출력 기능을 제공한다.
파일은 저장 방식에 따라 크게 두 종류로 나뉜다.
123을 문자 '1', '2', '3'의 조합으로 저장하는 것처럼, 모든 데이터를 사람이 읽을 수 있는 문자(주로 ASCII 코드)로 변환하여 저장하는 파일이다. 메모장으로 열었을 때 내용을 알아볼 수 있다. .c, .txt, .html 파일 등이 해당된다.153.exe, .jpg, .mp3 파일 등이 해당된다.153fopen 함수에서 파일 열기 모드를 t(text)로 지정하면 텍스트 모드, b(binary)로 지정하면 이진 모드로 동작한다. 모드를 명시하지 않으면 기본적으로 텍스트 모드로 열린다.156
파일에 접근하기 위해서는 먼저 스트림(stream)을 생성해야 한다. 스트림은 프로그램과 파일 사이의 데이터 통로 역할을 한다. fopen 함수는 파일을 열고 이 스트림을 생성하여, 파일을 제어하는 데 필요한 정보를 담고 있는 FILE 구조체의 포인터, 즉 파일 포인터(FILE*)를 반환한다.157
파일 작업이 모두 끝나면, 반드시 fclose 함수를 호출하여 스트림을 닫아야 한다. fclose는 버퍼에 남아있던 데이터를 파일에 완전히 기록하고, 파일과 연결된 시스템 자원을 해제하는 중요한 역할을 한다.156
#include <stdio.h>
int main(void)
{
FILE* fp = NULL; // 파일 포인터 선언 및 NULL로 초기화
fp = fopen("data.txt", "w"); // "data.txt" 파일을 쓰기("w") 모드로 연다.
if (fp == NULL)
{
printf("파일 열기 실패!\n");
return 1;
}
//... 파일 작업 수행...
fclose(fp); // 파일 닫기
return 0;
}
| 모드 | 설명 | 파일이 없을 경우 | 파일이 있을 경우 |
|---|---|---|---|
"r" |
읽기 전용으로 연다. | 오류 (NULL 반환) |
파일의 처음부터 읽는다. |
"w" |
쓰기 전용으로 연다. | 새로 생성한다. | 기존 내용을 모두 지우고 새로 쓴다. |
"a" |
추가 모드로 연다. | 새로 생성한다. | 파일의 끝에 내용을 추가한다. |
"r+" |
읽기/쓰기 |
| white-world.tistory.com, 8월 15, 2025에 액세스, [https://white-world.tistory.com/165#:~:text=%EA%B8%B0%EB%A1%9D%2FC%20Language-,%5BC%EC%96%B8%EC%96%B4%5D%20call%20by%20value%2C%20call%20by%20reference%20%7C,%ED%98%B8%EC%B6%9C%2C%20%EC%B0%B8%EC%A1%B0%EC%97%90%20%EC%9D%98%ED%95%9C%20%ED%98%B8%EC%B6%9C&text=%EA%B0%92%EC%97%90%20%EC%9D%98%ED%95%9C%20%ED%98%B8%EC%B6%9C%EC%9D%80,%EB%A7%A4%EA%B0%9C%EB%B3%80%EC%88%98%EB%A1%9C%20%EB%B0%9B%EB%8A%94%20%EA%B2%83%EC%9D%B4%EB%8B%A4.](https://white-world.tistory.com/165#:~:text=기록%2FC Language-,[C언어] call by value%2C call by reference | ,호출%2C 참조에 의한 호출&text=값에 의한 호출은,매개변수로 받는 것이다.) |
| [C언어] call by value, call by reference | 값에 의한 호출, 참조에 의한 호출 - 준성 스페이스, 8월 15, 2025에 액세스, https://white-world.tistory.com/165 |
| [C] 구조체(struct). 구조체의 개념과 배열 사용 예시 | by EunJin | Medium, 8월 15, 2025에 액세스, https://jin0904.medium.com/c-%EA%B5%AC%EC%A1%B0%EC%B2%B4-struct-a82bae699581 |