포인터와 메모리 관리
C언어를 공부할 때 가장 어렵게 느껴지는 개념 중 하나가 포인터이다.
포인터는 메모리 주소를 저장하는 변수이다.
일반 변수는 값을 저장한다.
int a = 10;
위 코드에서 a는 정수 10을 저장하는 변수이다.
그런데 컴퓨터 메모리 안에서 이 값은 어딘가의 주소에 저장된다.
포인터는 바로 그 주소를 저장할 수 있는 변수이다.
메모리 주소
프로그램이 실행되면 변수는 메모리 어딘가에 저장된다.
int a = 10;
이때 a라는 변수는 값 10을 가지고 있고, 동시에 메모리 주소도 가진다.
변수의 주소를 확인할 때는 & 연산자를 사용한다.
#include <stdio.h>
int main() {
int a = 10;
printf("%d\n", a);
printf("%p\n", &a);
return 0;
}
출력은 실행 환경마다 다르지만 대략 다음처럼 나온다.
10
0x7ffee3b4a8ac
10은 변수 a에 저장된 값이다.
0x7ffee3b4a8ac 같은 값은 변수 a가 저장된 메모리 주소이다.
주소는 실행할 때마다 달라질 수 있다.
포인터 변수
포인터 변수는 메모리 주소를 저장하는 변수이다.
정수형 변수의 주소를 저장하려면 int * 타입의 포인터를 사용한다.
int a = 10;
int *p = &a;
여기서 p는 a의 주소를 저장한다.
a에는 10이 저장되어 있다.
p에는 a의 주소가 저장되어 있다.
코드로 확인해보면 다음과 같다.
#include <stdio.h>
int main() {
int a = 10;
int *p = &a;
printf("a의 값: %d\n", a);
printf("a의 주소: %p\n", &a);
printf("p에 저장된 값: %p\n", p);
return 0;
}
&a와 p는 같은 값을 출력한다.
p가 a의 주소를 저장하고 있기 때문이다.
& 연산자와 * 연산자
포인터에서 중요한 연산자는 두 가지이다.
&
변수의 주소를 구한다.
*
포인터가 가리키는 주소에 있는 값을 가져온다.
예를 들어 다음 코드를 보자.
int a = 10;
int *p = &a;
&a는 a의 주소를 의미한다.
p는 그 주소를 저장한다.
*p는 p가 가리키는 주소에 있는 값을 의미한다.
#include <stdio.h>
int main() {
int a = 10;
int *p = &a;
printf("%d\n", a);
printf("%d\n", *p);
return 0;
}
출력은 다음과 같다.
10
10
p는 a의 주소를 가지고 있고, *p는 그 주소에 저장된 값인 10을 가져온다.
포인터를 이용한 값 변경
포인터를 사용하면 주소를 통해 변수의 값을 바꿀 수 있다.
#include <stdio.h>
int main() {
int a = 10;
int *p = &a;
*p = 20;
printf("%d\n", a);
return 0;
}
출력은 다음과 같다.
20
*p = 20은 p가 가리키는 곳의 값을 20으로 바꾸라는 뜻이다.
p가 a를 가리키고 있으므로, 결국 a의 값이 20으로 바뀐다.
p는 a의 주소를 가지고 있다.
*p는 a의 값을 의미한다.
*p = 20은 a의 값을 20으로 바꾸는 것과 같다.
포인터가 필요한 이유
포인터는 단순히 주소를 저장하기 위한 개념처럼 보이지만, C언어에서는 매우 중요하다.
대표적인 이유는 다음과 같다.
함수 안에서 원본 값을 변경할 수 있다.
배열과 문자열을 효율적으로 다룰 수 있다.
동적 메모리 할당을 사용할 수 있다.
자료구조를 구현할 수 있다.
C언어에서는 함수에 값을 전달하면 기본적으로 복사본이 전달된다.
#include <stdio.h>
void change(int x) {
x = 20;
}
int main() {
int a = 10;
change(a);
printf("%d\n", a);
return 0;
}
출력은 다음과 같다.
10
change(a)를 호출했지만 a의 값은 바뀌지 않는다.
함수 안의 x는 a의 복사본이기 때문이다.
원본 값을 바꾸려면 주소를 전달해야 한다.
#include <stdio.h>
void change(int *p) {
*p = 20;
}
int main() {
int a = 10;
change(&a);
printf("%d\n", a);
return 0;
}
출력은 다음과 같다.
20
이번에는 a의 주소를 함수에 전달했다.
함수 안에서 *p = 20을 수행하면, 그 주소에 있는 원본 값이 바뀐다.
배열과 포인터
C언어에서 배열과 포인터는 밀접한 관계가 있다.
배열의 이름은 배열의 첫 번째 원소 주소처럼 동작한다.
int arr[3] = {10, 20, 30};
여기서 arr은 배열의 첫 번째 원소인 arr[0]의 주소처럼 사용할 수 있다.
#include <stdio.h>
int main() {
int arr[3] = {10, 20, 30};
printf("%p\n", arr);
printf("%p\n", &arr[0]);
return 0;
}
두 값은 같은 주소를 출력한다.
배열의 원소는 포인터 연산으로도 접근할 수 있다.
#include <stdio.h>
int main() {
int arr[3] = {10, 20, 30};
printf("%d\n", arr[0]);
printf("%d\n", *(arr + 0));
printf("%d\n", arr[1]);
printf("%d\n", *(arr + 1));
return 0;
}
출력은 다음과 같다.
10
10
20
20
arr[1]과 *(arr + 1)은 같은 의미로 볼 수 있다.
배열 인덱싱은 내부적으로 포인터 연산과 연결되어 있다.
포인터 연산
포인터에 숫자를 더하면 주소가 그 숫자만큼 단순히 증가하는 것이 아니다.
포인터가 가리키는 타입의 크기만큼 이동한다.
int arr[3] = {10, 20, 30};
int *p = arr;
p + 1은 주소가 1바이트 증가하는 것이 아니라, int 하나의 크기만큼 이동한다.
보통 int가 4바이트라면 p + 1은 4바이트 뒤를 가리킨다.
p : arr[0]을 가리킴
p + 1 : arr[1]을 가리킴
p + 2 : arr[2]를 가리킴
코드로 보면 다음과 같다.
#include <stdio.h>
int main() {
int arr[3] = {10, 20, 30};
int *p = arr;
printf("%d\n", *p);
printf("%d\n", *(p + 1));
printf("%d\n", *(p + 2));
return 0;
}
출력은 다음과 같다.
10
20
30
메모리 영역
C 프로그램이 실행되면 메모리는 여러 영역으로 나뉘어 사용된다.
대표적으로 다음 영역이 있다.
코드 영역
데이터 영역
스택 영역
힙 영역
코드 영역에는 실행할 기계어 명령어가 저장된다.
데이터 영역에는 전역 변수와 정적 변수가 저장된다.
스택 영역에는 함수 호출 시 만들어지는 지역 변수, 매개변수 등이 저장된다.
힙 영역에는 프로그래머가 직접 할당하고 해제하는 동적 메모리가 저장된다.
스택 메모리
스택 영역은 함수가 호출될 때 자동으로 사용되는 메모리 영역이다.
지역 변수와 매개변수는 보통 스택에 저장된다.
void func() {
int a = 10;
}
func()가 호출되면 a가 만들어진다.
func()가 끝나면 a는 자동으로 사라진다.
함수 호출
지역 변수 생성
함수 종료
지역 변수 제거
스택 메모리는 자동으로 관리되기 때문에 편리하다.
하지만 함수가 끝난 뒤에도 계속 사용해야 하는 데이터라면 스택만으로는 부족할 수 있다.
힙 메모리
힙 영역은 프로그래머가 직접 할당하고 해제하는 메모리 영역이다.
C언어에서는 malloc()으로 메모리를 할당하고, free()로 해제한다.
동적 메모리 할당은 실행 중에 필요한 만큼 메모리를 확보할 때 사용한다.
예를 들어 사용자가 몇 개의 데이터를 입력할지 실행 전에는 모른다고 하자.
이럴 때 필요한 크기만큼 메모리를 동적으로 할당할 수 있다.
malloc
malloc()은 힙 영역에 메모리를 할당하는 함수이다.
사용하려면 <stdlib.h>를 포함해야 한다.
#include <stdio.h>
#include <stdlib.h>
int main() {
int *p = (int *)malloc(sizeof(int));
*p = 10;
printf("%d\n", *p);
free(p);
return 0;
}
malloc(sizeof(int))는 int 하나를 저장할 수 있는 크기의 메모리를 힙에 할당한다.
malloc()은 할당된 메모리의 시작 주소를 반환한다.
이 주소를 포인터 p가 저장한다.
malloc
힙 메모리 할당
p
할당된 메모리 주소 저장
*p
할당된 메모리에 저장된 값
free
free()는 동적으로 할당한 메모리를 해제하는 함수이다.
C언어에서는 malloc()으로 할당한 메모리를 자동으로 해제해주지 않는다.
사용이 끝나면 반드시 free()로 해제해야 한다.
int *p = (int *)malloc(sizeof(int));
free(p);
메모리를 해제하지 않으면 메모리 누수가 발생할 수 있다.
메모리 누수
메모리 누수(memory leak)는 더 이상 사용하지 않는 메모리를 해제하지 않아 계속 점유하는 문제이다.
예를 들어 다음 코드를 보자.
#include <stdlib.h>
int main() {
int *p = (int *)malloc(sizeof(int));
*p = 10;
return 0;
}
malloc()으로 메모리를 할당했지만 free()를 호출하지 않았다.
프로그램이 짧게 끝나면 큰 문제가 없어 보일 수 있다.
하지만 장시간 실행되는 서버 프로그램이라면 이런 메모리 누수가 계속 쌓여 문제가 될 수 있다.
메모리 할당
사용 완료
해제하지 않음
사용하지 않는 메모리가 계속 남음
동적 할당을 했다면 해제까지 책임져야 한다.
댕글링 포인터
댕글링 포인터(dangling pointer)는 이미 해제된 메모리를 여전히 가리키고 있는 포인터이다.
#include <stdio.h>
#include <stdlib.h>
int main() {
int *p = (int *)malloc(sizeof(int));
*p = 10;
free(p);
printf("%d\n", *p);
return 0;
}
free(p)를 호출한 뒤에도 p는 여전히 이전 주소를 가지고 있다.
하지만 그 메모리는 이미 해제되었다.
해제된 메모리에 접근하면 예측할 수 없는 문제가 생길 수 있다.
그래서 free() 후에는 포인터를 NULL로 바꾸는 습관이 좋다.
free(p);
p = NULL;
NULL은 아무것도 가리키지 않는 포인터 값을 의미한다.
NULL 포인터
NULL 포인터는 유효한 메모리 주소를 가리키지 않는 포인터이다.
포인터를 선언만 하고 아직 어떤 주소도 가리키지 않는다면 NULL로 초기화하는 것이 좋다.
int *p = NULL;
이렇게 하면 포인터가 현재 아무것도 가리키지 않는다는 것을 명확히 표현할 수 있다.
포인터를 사용하기 전에 NULL인지 확인할 수도 있다.
if (p != NULL) {
printf("%d\n", *p);
}
아무 주소나 잘못 접근하는 것보다 안전하다.
포인터 사용 시 주의할 점
포인터는 강력하지만 위험할 수 있다.
잘못된 주소에 접근하면 프로그램이 비정상 종료되거나, 예측하기 어려운 버그가 생길 수 있다.
주의할 점은 다음과 같다.
초기화하지 않은 포인터를 사용하지 않는다.
NULL 포인터를 역참조하지 않는다.
free한 메모리에 다시 접근하지 않는다.
malloc 후에는 free를 호출한다.
배열 범위를 벗어난 주소에 접근하지 않는다.
예를 들어 초기화하지 않은 포인터는 어떤 주소를 가리키는지 알 수 없다.
int *p;
*p = 10;
위 코드는 위험하다.
p가 어떤 주소를 가리키는지 정해져 있지 않기 때문이다.
배열 범위를 벗어난 접근도 위험하다.
int arr[3] = {1, 2, 3};
printf("%d\n", arr[5]);
arr[5]는 배열 범위를 벗어난 접근이다.
C언어는 이런 실수를 자동으로 막아주지 않는 경우가 많다.
포인터와 메모리 관리가 중요한 이유
C언어는 메모리를 직접 다룰 수 있는 언어이다.
이 점은 장점이면서 단점이다.
메모리를 직접 제어할 수 있으므로 성능이 중요한 프로그램을 만들 수 있다.
운영체제, 임베디드 시스템, 드라이버, 게임 엔진 같은 저수준 프로그램에서 C언어가 많이 사용되는 이유이기도 하다.
하지만 메모리를 직접 다루는 만큼 실수도 직접 책임져야 한다.
장점
메모리를 세밀하게 제어할 수 있다.
성능 최적화에 유리하다.
저수준 시스템 프로그래밍에 적합하다.
단점
메모리 누수 위험이 있다.
잘못된 포인터 접근이 위험하다.
개발자가 직접 관리해야 할 것이 많다.
정리
포인터는 메모리 주소를 저장하는 변수이다.
일반 변수는 값을 저장하고, 포인터 변수는 주소를 저장한다.
& 연산자는 변수의 주소를 구할 때 사용한다.
* 연산자는 포인터가 가리키는 주소의 값을 가져오거나 변경할 때 사용한다.
포인터를 사용하면 함수 안에서 원본 값을 변경할 수 있고, 배열과 문자열을 효율적으로 다룰 수 있으며, 동적 메모리 할당과 자료구조 구현이 가능해진다.
C 프로그램의 메모리는 코드 영역, 데이터 영역, 스택 영역, 힙 영역 등으로 나뉜다.
스택 영역에는 지역 변수와 매개변수가 저장되며, 함수가 끝나면 자동으로 정리된다.
힙 영역은 프로그래머가 직접 할당하고 해제하는 메모리 영역이다.
malloc()은 힙 메모리를 할당하는 함수이고, free()는 할당한 메모리를 해제하는 함수이다.
동적으로 할당한 메모리를 해제하지 않으면 메모리 누수가 발생할 수 있다.
이미 해제된 메모리를 계속 가리키는 포인터는 댕글링 포인터라고 한다.
포인터를 안전하게 사용하려면 초기화하지 않은 포인터를 사용하지 않고, free() 후에는 포인터를 NULL로 바꾸며, 배열 범위를 벗어난 접근을 피해야 한다.
짧게 정리하면 다음과 같다.
포인터
메모리 주소를 저장하는 변수
& 연산자
변수의 주소를 구함
* 연산자
포인터가 가리키는 값을 사용함
malloc
힙 메모리 할당
free
할당한 힙 메모리 해제
메모리 누수
사용하지 않는 메모리를 해제하지 않아 계속 남는 문제
댕글링 포인터
이미 해제된 메모리를 가리키는 포인터
'STUDY' 카테고리의 다른 글
| [프로그래밍 기초] 함수형 프로그래밍 정리: map, filter, reduce 이해하기 (0) | 2026.05.29 |
|---|---|
| [자료구조] 스택, 큐, 링크드 리스트 구현 개념 정리 (0) | 2026.05.29 |
| [프로그래밍 기초] 정적 타이핑, 동적 타이핑, 강타입, 약타입 정리 (0) | 2026.05.29 |
| [컴퓨터 기초] 컴파일러와 인터프리터 차이 정리 (0) | 2026.05.29 |
| [객체지향] 객체지향 4대 특성 정리: 캡슐화, 상속, 다형성, 추상화 (0) | 2026.05.29 |