C언어 (5)
1. Null 포인터
포인터를 선언만 하고 초기화하지 않으면 쓰레기값 이 들어있다. 이 상태에서 역참조(*p)를 하면 엉뚱한 메모리에 접근하게 되어 프로그램이 터지거나, 더 위험하게는 조용히 잘못된 데이터를 건드릴 수 있다.
이런 상황을 방지하기 위해 "아직 아무것도 가리키지 않는다"는 의미로 NULL 을 대입한다.
int *p = NULL;
NULL 은 보통 0 또는 (void *)0으로 정의되어 있다. 유효한 메모리 주소가 아니므로, NULL 포인터를 역참조하면 프로그램이 확실하게 크래시한다. 쓰레기값으로 인한 알 수 없는 동작보다는 차라리 확실하게 터지는 것이 디버깅하기 훨씬 쉽다.
포인터를 사용하기 전에 NULL 인지 확인하는 것은 C 프로그래밍에서 기본적인 안전 습관이다.
int *p = NULL;
if (p != NULL) {
printf("%d\n", *p); // 안전하게 접근
} else {
printf("포인터가 초기화되지 않았습니다.\n");
}
메모리를 해제한 뒤에도 포인터에 NULL 을 대입하는 것이 좋다. free 한 뒤에도 포인터 변수에는 이전 주소값이 남아있는데, 이런 포인터를 댕글링 포인터(dangling pointer) 라고 한다. 이미 해제된 메모리를 다시 접근하면 큰 문제가 생길 수 있으므로, free 직후 NULL 을 넣어주면 실수를 방지할 수 있다.
int *p = malloc(sizeof(int));
*p = 42;
free(p);
p = NULL; // 댕글링 포인터 방지
2. 구조체와 공용체
2-1. 구조체란?
구조체는 서로 다른 타입의 데이터를 하나로 묶는 사용자 정의 자료형 이다.
배열은 같은 타입만 묶을 수 있다. 하지만 현실의 데이터는 그렇지 않다. 학생 한 명의 정보를 표현하려면 이름(문자열), 나이(정수), 평균 점수(실수)가 필요한데, 이걸 각각 별도의 변수로 관리하면 코드가 지저분해진다. 구조체는 이 문제를 해결한다.
struct Student {
char name[20];
int age;
double gpa;
};
JavaScript의 객체({ name: "홍길동", age: 20, gpa: 3.8 })와 비슷한 개념이다. 다만 C의 구조체는 타입을 미리 정의해야 하고, 메서드를 가질 수 없다는 차이가 있다. C++에서 구조체에 함수를 넣을 수 있게 되고, 그것이 곧 클래스로 발전한다.
2-2. 구조체 사용
구조체 변수
구조체를 정의한 뒤 변수를 선언하고 사용한다.
struct Student {
char name[20];
int age;
double gpa;
};
int main(void) {
struct Student s1; // 구조체 변수 선언
strcpy(s1.name, "홍길동"); // 문자열은 strcpy로 대입
s1.age = 20;
s1.gpa = 3.8;
printf("이름: %s\n", s1.name);
printf("나이: %d\n", s1.age);
printf("학점: %.1f\n", s1.gpa);
return 0;
}
구조체 멤버에 접근할 때는 점(.) 연산자 를 사용한다. s1.age는 "s1이라는 구조체 안의 age 멤버"라는 뜻이다.
선언과 동시에 초기화할 수도 있다.
struct Student s1 = {"홍길동", 20, 3.8};
매번 struct Student라고 쓰는 것이 번거로우면 typedef로 별칭을 만들 수 있다.
typedef struct {
char name[20];
int age;
double gpa;
} Student;
Student s1 = {"홍길동", 20, 3.8}; // struct 키워드 생략 가능
2-3. 구조체의 배열과 포인터
구조체도 배열로 만들 수 있다. 학생 여러 명의 데이터를 관리할 때 유용하다.
Student students[3] = {
{"홍길동", 20, 3.8},
{"김철수", 21, 3.2},
{"이영희", 19, 4.0}
};
for (int i = 0; i < 3; i++) {
printf("%s: %.1f\n", students[i].name, students[i].gpa);
}
구조체 포인터도 사용할 수 있다. 구조체 포인터로 멤버에 접근할 때는 점(.) 대신 화살표(->) 연산자 를 사용한다.
Student s1 = {"홍길동", 20, 3.8};
Student *p = &s1;
printf("이름: %s\n", p->name); // 화살표 연산자
printf("나이: %d\n", p->age);
p->age는 (*p).age와 같은 의미다. 포인터를 역참조한 뒤 점으로 멤버에 접근하는 것을 화살표 연산자로 줄여 쓴 것이다.
구조체가 크면 함수에 넘길 때 통째로 복사하는 것은 비효율적이다. 포인터로 넘기면 주소 하나만 전달하므로 훨씬 효율적이다.
void printStudent(const Student *s) {
printf("이름: %s, 나이: %d\n", s->name, s->age);
}
int main(void) {
Student s1 = {"홍길동", 20, 3.8};
printStudent(&s1);
return 0;
}
매개변수에 const를 붙이면 함수 안에서 구조체를 수정할 수 없게 된다. 읽기만 할 때는 const를 붙이는 것이 안전하다.
2-4. 공용체
공용체(union)는 구조체와 비슷하게 생겼지만, 핵심적인 차이가 있다. 구조체는 모든 멤버가 각자의 메모리 공간 을 가진다. 공용체는 모든 멤버가 같은 메모리 공간을 공유 한다.
union Data {
int i;
float f;
char c;
};
이 공용체의 크기는 가장 큰 멤버(int 또는 float, 4바이트)의 크기와 같다. int, float, char 세 멤버가 전부 같은 4바이트를 나눠 쓴다.
union Data d;
d.i = 42;
printf("%d\n", d.i); // 42
d.f = 3.14f;
printf("%f\n", d.f); // 3.140000
printf("%d\n", d.i); // 엉뚱한 값 (f를 쓰면서 i가 덮어써짐)
한 멤버에 값을 넣으면 다른 멤버의 값은 의미 없어진다. 한 번에 하나의 멤버만 유효하다. 메모리를 절약해야 하거나, 같은 데이터를 다른 타입으로 해석하고 싶을 때 사용한다. 실무에서는 프로토콜 파싱이나 하드웨어 레지스터 접근에서 가끔 쓰인다.
2-5. 열거형 (enum)
열거형은 관련된 상수들에 이름을 붙이는 자료형 이다. 매직 넘버(의미를 알 수 없는 숫자) 대신 의미 있는 이름을 사용해서 코드의 가독성을 높인다.
enum Day {
MON, // 0
TUE, // 1
WED, // 2
THU, // 3
FRI, // 4
SAT, // 5
SUN // 6
};
별도로 값을 지정하지 않으면 0부터 순서대로 정수가 부여된다. 값을 직접 지정할 수도 있다.
enum StatusCode {
OK = 200,
NOT_FOUND = 404,
SERVER_ERROR = 500
};
enum Day today = WED;
if (today == SAT || today == SUN) {
printf("주말\n");
} else {
printf("평일\n");
}
#define으로 상수를 정의하는 것과 비슷하지만, enum 은 관련된 값들을 하나의 타입으로 묶어준다는 점에서 더 체계적이다. 디버거에서도 숫자 대신 이름으로 보여주므로 디버깅이 편해진다.
3. 동적 메모리 할당과 메모리 표준함수
3-1. 메모리의 구조
C 프로그램이 실행되면 OS로부터 메모리를 할당받는데, 이 메모리는 크게 4개 영역으로 나뉜다.
코드 영역(Code/Text Segment) 에는 컴파일된 기계어 코드가 올라간다. 작성한 함수들의 실행 코드가 여기에 저장되며, 읽기 전용이다.
데이터 영역(Data Segment) 에는 전역변수와 정적변수(static)가 저장된다. 프로그램 시작 시 할당되어 프로그램이 종료될 때까지 유지된다. 초기화된 변수와 초기화되지 않은 변수(BSS 영역)로 다시 나뉘기도 한다.
스택 영역(Stack) 에는 지역변수와 함수 호출 정보(매개변수, 반환 주소 등)가 저장된다. 함수가 호출되면 공간이 확보되고, 함수가 끝나면 자동으로 해제된다. 높은 주소에서 낮은 주소 방향으로 자란다.
힙 영역(Heap) 은 프로그래머가 직접 할당하고 해제하는 공간이다. malloc, calloc 등으로 할당하고 free로 해제한다. 낮은 주소에서 높은 주소 방향으로 자란다. 스택과 힙이 서로를 향해 자라는 구조다.
높은 주소
┌────────────────┐
│ 스택 (Stack) │ ← 지역변수, 함수 호출 정보 (위에서 아래로 성장)
│ ↓ │
│ │
│ ↑ │
│ 힙 (Heap) │ ← 동적 할당 (아래에서 위로 성장)
├────────────────┤
│ 데이터 (Data) │ ← 전역변수, static 변수
├────────────────┤
│ 코드 (Code) │ ← 실행 코드 (읽기 전용)
└────────────────┘
낮은 주소
3-2. 동적 메모리 할당
배열을 선언할 때 크기를 상수로 지정해야 한다고 했다. 그런데 실제 프로그램에서는 필요한 메모리 크기를 런타임에야 알 수 있는 경우가 많다. 사용자가 몇 개의 데이터를 입력할지, 파일에 데이터가 몇 줄인지는 프로그램이 실행되어야 알 수 있기 때문이다.
이때 힙 메모리 에 원하는 크기만큼 공간을 확보하는 것이 동적 메모리 할당이다.
타입캐스팅(Type Casting)
동적 메모리 할당 함수들을 이해하려면 타입캐스팅을 알아야 한다. 타입캐스팅은 하나의 자료형을 다른 자료형으로 변환하는 것이다.
암시적 캐스팅 은 컴파일러가 자동으로 수행한다.
int를double에 대입하면 자동으로 변환된다. 명시적 캐스팅 은 프로그래머가(타입)을 직접 붙여서 강제로 변환하는 것이다.int a = 10; double b = a; // 암시적 캐스팅 (int → double) int c = (int)3.14; // 명시적 캐스팅 (double → int, 소수점 버림)
malloc은void *를 반환하는데, 이것을 원하는 포인터 타입으로 캐스팅해서 사용한다.void *는 "어떤 타입인지 정해지지 않은 포인터"라는 뜻으로, 어떤 포인터 타입으로든 변환할 수 있다.
malloc
malloc(memory allocation)은 지정한 바이트 수만큼 힙에 메모리를 할당하고, 그 시작 주소를 반환한다.
#include <stdlib.h>
int *p = (int *)malloc(sizeof(int) * 5); // int 5개 크기 할당
malloc(sizeof(int) * 5) 는 20바이트를 힙에 확보한다. 반환 타입이 void * 이므로 (int *)로 캐스팅해서 int 포인터에 저장한다. C에서는 void * 가 다른 포인터 타입으로 자동 변환되므로 캐스팅을 생략해도 되지만, C++에서는 반드시 캐스팅해야 한다. 명시적으로 쓰는 습관을 들이는 것이 좋다.
할당한 메모리는 배열처럼 사용할 수 있다.
int *p = (int *)malloc(sizeof(int) * 5);
if (p == NULL) {
printf("메모리 할당 실패\n");
return 1;
}
for (int i = 0; i < 5; i++) {
p[i] = (i + 1) * 10;
}
for (int i = 0; i < 5; i++) {
printf("%d ", p[i]); // 10 20 30 40 50
}
free(p);
p = NULL;
malloc 은 할당에 실패하면 NULL 을 반환한다. 메모리가 부족하면 실패할 수 있으므로, 반드시 NULL 체크를 해야 한다.
malloc 으로 할당한 메모리는 초기화되지 않는다. 쓰레기값이 들어있다.
free
free는 malloc으로 할당한 메모리를 OS에 반환한다.
int *p = (int *)malloc(sizeof(int) * 10);
// ... 사용 ...
free(p); // 메모리 해제
p = NULL; // 댕글링 포인터 방지
free 는 반드시 malloc, calloc, realloc으로 할당한 메모리에만 사용해야 한다. 스택에 있는 지역변수의 주소를 free 하면 정의되지 않은 동작이 발생한다. 같은 메모리를 두 번 free 하는 것(double free)도 위험하다.
JavaScript에서는 가비지 컬렉터가 알아서 메모리를 해제해주지만, C에서는 프로그래머가 직접 해야 한다. 이것이 C에서 메모리 관리가 어렵다고 하는 핵심 이유다.
calloc
calloc(contiguous allocation)은 malloc과 비슷하지만 두 가지 차이가 있다. 매개변수를 "요소 개수"와 "요소 크기"로 나눠서 받고, 할당한 메모리를 0으로 초기화 해준다.
int *p = (int *)calloc(5, sizeof(int)); // int 5개, 전부 0으로 초기화
malloc(sizeof(int) * 5) 와 같은 크기를 할당하지만, calloc 은 모든 바이트를 0으로 채워준다. 배열을 0으로 시작해야 하는 경우에 편리하다.
realloc
realloc(reallocation)은 이미 할당한 메모리의 크기를 변경 한다. 배열 크기가 부족해졌을 때 늘리거나, 너무 클 때 줄일 수 있다.
int *p = (int *)malloc(sizeof(int) * 3);
p[0] = 10;
p[1] = 20;
p[2] = 30;
// 크기를 5로 늘림
p = (int *)realloc(p, sizeof(int) * 5);
p[3] = 40;
p[4] = 50;
realloc 은 가능하면 기존 위치에서 크기를 늘린다. 공간이 부족하면 새로운 위치에 메모리를 할당하고, 기존 데이터를 복사한 뒤, 이전 메모리를 해제한다. 기존 데이터는 보존된다.
다만 realloc 도 실패하면 NULL 을 반환한다. 이때 원래 포인터에 바로 대입하면 기존 메모리 주소를 잃어버려 메모리 누수가 발생한다. 안전하게 사용하려면 임시 포인터를 쓰는 것이 좋다.
int *temp = (int *)realloc(p, sizeof(int) * 10);
if (temp == NULL) {
printf("재할당 실패\n");
free(p); // 기존 메모리는 수동으로 해제
return 1;
}
p = temp;
메모리 누수
메모리 누수(memory leak)는 malloc으로 할당한 메모리를 free 하지 않아서 반환되지 않은 메모리가 쌓이는 현상이다.
void leak(void) {
int *p = (int *)malloc(sizeof(int) * 100);
// free(p)를 하지 않고 함수 종료
} // p는 지역변수이므로 사라지지만, 힙 메모리는 그대로 남음
p는 스택에 있는 지역변수이므로 함수가 끝나면 사라진다. 하지만 p가 가리키던 힙 메모리는 free 하지 않았으므로 그대로 남아있다. 이 메모리에 접근할 방법도 없고, 해제할 방법도 없다. 이것이 메모리 누수다.
짧은 프로그램에서는 큰 문제가 되지 않을 수 있다(프로그램 종료 시 OS가 전부 회수하므로). 하지만 서버처럼 오래 실행되는 프로그램에서는 메모리가 점점 쌓여서 결국 시스템을 다운시킬 수 있다.
원칙은 간단하다 — malloc 한 만큼 free 한다.
3-3. 메모리 표준함수
<string.h>에 포함된 메모리 조작 함수들이다. 문자열뿐 아니라 모든 메모리 영역에 사용할 수 있다.
memset
메모리 블록을 특정 값으로 채운다. 배열을 0으로 초기화하거나 특정 값으로 채울 때 사용한다.
#include <string.h>
int arr[5];
memset(arr, 0, sizeof(arr)); // 전부 0으로 채움
memset 은 바이트 단위 로 값을 채운다. 두 번째 인자가 각 바이트에 들어갈 값이다. 0으로 채우는 것은 문제없지만, int 배열을 1로 채우겠다고 memset(arr, 1, sizeof(arr))를 하면 각 바이트가 0x01이 되어 의도한 정수 1이 아니라 0x01010101(약 1677만)이 들어간다. 0이 아닌 값으로 int 배열을 초기화하려면 반복문을 사용해야 한다.
memcpy
한 메모리 블록의 내용을 다른 곳으로 복사 한다.
int src[5] = {1, 2, 3, 4, 5};
int dest[5];
memcpy(dest, src, sizeof(src));
// dest는 이제 {1, 2, 3, 4, 5}
첫 번째 인자가 목적지, 두 번째가 출발지, 세 번째가 복사할 바이트 수다. strcpy는 문자열만 복사할 수 있지만, memcpy는 어떤 타입의 메모리든 복사할 수 있다.
단, 출발지와 목적지 메모리가 겹치면 안 된다. 겹치는 경우에는 memmove를 사용해야 한다.
memcmp
두 메모리 블록을 바이트 단위로 비교 한다.
int a[3] = {1, 2, 3};
int b[3] = {1, 2, 3};
int c[3] = {1, 2, 4};
printf("%d\n", memcmp(a, b, sizeof(a))); // 0 (같음)
printf("%d\n", memcmp(a, c, sizeof(a))); // 음수 (a < c)
반환값이 0이면 같고, 양수면 첫 번째가 크고, 음수면 두 번째가 크다. strcmp가 문자열을 비교하는 것처럼, memcmp는 임의의 메모리를 비교한다. 구조체 두 개를 통째로 비교할 때도 사용할 수 있다.
memmove
memcpy와 같은 기능이지만, 출발지와 목적지 메모리가 겹쳐도 안전하게 동작 한다.
int arr[5] = {1, 2, 3, 4, 5};
// arr[0~2]의 내용을 arr[1~3]으로 이동 (겹침 발생)
memmove(&arr[1], &arr[0], sizeof(int) * 3);
// arr는 이제 {1, 1, 2, 3, 5}
memcpy 는 앞에서부터 순서대로 복사하기 때문에, 겹치는 영역에서는 아직 복사하지 않은 데이터를 덮어쓸 수 있다. memmove 는 내부적으로 겹침을 감지하고, 필요하면 뒤에서부터 복사하는 등의 방법으로 안전하게 처리한다.
성능은 memcpy가 약간 더 빠를 수 있다. 메모리가 겹치지 않는 것이 확실하면 memcpy를, 겹칠 가능성이 있으면 memmove를 사용한다.
마무리
이번 글에서는 C언어의 중급 개념들을 다뤘다. NULL 포인터로 안전한 포인터 사용의 기초를 다지고, 구조체로 복잡한 데이터를 하나로 묶는 방법을 배웠다. 동적 메모리 할당은 C 프로그래밍에서 가장 강력하면서도 가장 위험한 기능이다. malloc으로 할당하고 free로 해제하는 것, 그리고 그 사이에서 NULL 체크와 메모리 누수에 주의하는 것이 핵심이다. 메모리 표준함수들은 이런 메모리를 효율적으로 조작하는 도구다.