메모리, 포인터와 참조
프로그램 메모리 구조와 C 포인터, Java·Python 참조, 얕은 복사·깊은 복사를 학습한다.
1. 프로그램의 대표 메모리 영역
운영체제와 실행 형식에 따라 세부 구조는 달라질 수 있지만, 개념적으로 다음 영역을 구분한다.
높은 주소
┌──────────────────────────┐
│ 스택 │ 함수 호출 프레임·지역변수
│ ↓ │
├──────────────────────────┤
│ 사용 가능 공간 │
├──────────────────────────┤
│ ↑ │
│ 힙 │ 동적 할당 객체·메모리
├──────────────────────────┤
│ 데이터·BSS │ 전역·정적 변수
├──────────────────────────┤
│ 코드 영역 │ 실행 명령
└──────────────────────────┘
낮은 주소
- 스택: 함수 호출과 반환에 따라 자동 관리된다.
- 힙: 동적으로 할당하며 명시적 해제 또는 가비지 컬렉션으로 관리한다.
- 정적 영역: 프로그램 실행 기간 동안 유지되는 전역·정적 데이터를 저장한다.
- 코드 영역: 실행할 명령을 저장하며 일반적으로 직접 수정하지 않는다.
2. C 포인터의 기본
포인터는 메모리 주소를 저장하는 변수이다.
int x = 10;
int *p = &x;
printf("%d\n", *p); // 10
*p = 20; // x를 20으로 변경
p 변수 x 변수
┌─────────────┐ ┌──────────┐
│ 주소 0x1000 ├──────────►│ 값 10 │
└─────────────┘ └──────────┘
&x = 0x1000 *p = 10
&x: x의 주소*p: p가 가리키는 위치의 값int *p: int를 가리키는 포인터NULL: 유효한 객체를 가리키지 않음을 표현하는 포인터 값
*는 선언에서는 포인터형을 나타내고, 식에서는 역참조 연산자가 될 수 있다.
3. 포인터와 배열
배열 이름은 많은 식에서 첫 원소를 가리키는 포인터처럼 변환되지만 배열과 포인터 변수는 동일한 개념은 아니다.
int a[3] = {10, 20, 30};
int *p = a;
printf("%d\n", a[1]); // 20
printf("%d\n", *(p + 1)); // 20
포인터 연산은 바이트가 아니라 가리키는 자료형의 크기 단위로 이동한다.
p p+1 p+2
│ │ │
▼ ▼ ▼
[10] [20] [30]
배열 범위를 벗어난 위치를 역참조하면 정의되지 않은 동작이 될 수 있다.
4. 동적 메모리 관리
C에서는 malloc, calloc, realloc, free를 사용한다.
int *numbers = malloc(5 * sizeof *numbers);
if (numbers == NULL) {
return 1;
}
for (int i = 0; i < 5; i++) {
numbers[i] = i * 10;
}
free(numbers);
numbers = NULL;
대표 오류는 다음과 같다.
- 메모리 누수: 할당한 메모리를 더 이상 해제할 수 없는 상태
- 댕글링 포인터: 해제되었거나 수명이 끝난 객체를 계속 가리키는 포인터
- 이중 해제: 같은 메모리를 두 번 해제
- NULL 역참조: 유효한 객체가 없는 포인터를 사용
- 버퍼 범위 초과: 할당 범위를 넘어 읽거나 쓰기
malloc → 주소 보관 → 사용 → free → 포인터 사용 금지
└─ 필요하면 NULL 대입
5. 포인터의 const 조합
const int *p1; // 가리키는 값을 p1을 통해 변경하지 않음
int * const p2 = &x; // p2 자체가 다른 주소를 가리키지 않음
const int * const p3 = &x; // 둘 다 제한
선언을 오른쪽에서 왼쪽으로 읽으면 도움이 된다.
6. Java의 참조
Java의 클래스형 변수는 객체 자체가 아니라 객체를 가리키는 참조값을 저장한다.
Box a = new Box(10);
Box b = a;
b.value = 20;
System.out.println(a.value); // 20
a ─┐
├────► Box 객체 { value: 20 }
b ─┘
a와 b가 같은 객체를 가리키므로 한 참조를 통해 객체 상태를 바꾸면 다른 참조에서도 보인다. a == b는 기본적으로 두 참조가 같은 객체를 가리키는지 비교하고, 내용 동등성은 적절히 구현된 equals로 비교한다.
Java는 가비지 컬렉션을 사용하지만 파일·소켓·DB 연결 같은 외부 자원은 별도 정리가 필요할 수 있다. 이런 자원은 try-with-resources 같은 구조로 닫는다.
7. Python의 객체 참조
Python 변수는 객체에 붙는 이름으로 이해할 수 있다.
a = [1, 2]
b = a
b.append(3)
print(a) # [1, 2, 3]
이름 a ─┐
├────► 리스트 객체 [1, 2, 3]
이름 b ─┘
불변 객체인 정수·문자열·튜플은 내용 자체를 변경하지 않고 새 객체에 재바인딩된다.
x = 10
y = x
y += 1
print(x, y) # 10 11
8. 얕은 복사와 깊은 복사
중첩 객체를 복사할 때 내부 객체를 공유하는지 확인해야 한다.
원본 ─────► [ 내부 리스트 A ]
얕은 복사 ─► 새 바깥 리스트 ─┐
└──► 내부 리스트 A 공유
깊은 복사 ─► 새 바깥 리스트 ───► 새 내부 리스트 B
import copy
original = [[1], [2]]
shallow = copy.copy(original)
deep = copy.deepcopy(original)
shallow[0].append(9)
print(original) # [[1, 9], [2]]
print(deep) # [[1], [2]]
Java에서도 객체 복사는 단순 참조 대입, 필드 복사, 복사 생성자 등 방법에 따라 공유 범위가 달라진다.
9. 함수 호출과 주소 전달
void increment(int *p) {
if (p != NULL) {
(*p)++;
}
}
int main(void) {
int x = 5;
increment(&x);
// x는 6
return 0;
}
포인터를 함수에 전달하는 것도 포인터 값의 복사이다. 복사된 포인터가 같은 객체 주소를 담고 있어 원본 객체를 변경할 수 있는 것이다.
10. 배열의 포인터 변환 예외
배열 이름은 대부분의 식에서 첫 원소 포인터로 변환되지만 대표 예외가 있다.
int a[5];
sizeof a; // 배열 전체 크기
&a; // 배열 전체를 가리키는 포인터: int (*)[5]
함수 매개변수의 int a[]는 실제로 int *a로 조정되므로 함수 안의 sizeof a는 배열 전체 크기가 아니라 포인터 크기다.
int *p[4] : int 포인터 4개의 배열
int (*p)[4] : int 4개짜리 배열을 가리키는 포인터
괄호 위치가 자료형을 바꾼다.
11. one-past 포인터와 포인터 차이
배열의 마지막 원소 바로 다음 위치를 가리키는 포인터는 비교·반복 종료 표식으로 만들 수 있지만 역참조하면 안 된다.
int a[4] = {1,2,3,4};
for (int *p=a; p<a+4; ++p) {
printf("%d ", *p);
}
포인터 뺄셈과 순서 비교는 같은 배열 객체 안의 원소 또는 one-past 포인터 사이에서만 의미가 보장된다. 서로 무관한 배열의 포인터를 빼거나 순서 비교하지 않는다.
12. realloc의 안전한 사용
realloc은 같은 주소를 확장할 수도 있고 새 위치로 옮길 수도 있다. 실패하면 NULL을 반환하며 원래 블록은 그대로 유효하다.
int *temp = realloc(numbers, new_count * sizeof *numbers);
if (temp != NULL) {
numbers = temp;
} else {
/* numbers는 여전히 유효하므로 필요하면 해제 */
}
numbers = realloc(numbers, ...)처럼 즉시 덮어쓰면 실패 시 원래 주소를 잃어 누수가 발생할 수 있다. 성공해 블록이 이동했다면 이전 주소와 그 주소를 복사한 별칭은 더 이상 사용하면 안 된다.
13. 소유권·수명·별칭 검사표
누가 할당했는가?
누가 해제할 책임이 있는가?
현재 포인터가 유효한 객체 수명 안에 있는가?
같은 주소를 가진 다른 별칭이 있는가?
함수 종료 후 지역 객체 주소를 반환하지 않았는가?
free(p); p=NULL;은 p를 통한 재사용은 막지만, 같은 주소를 가진 다른 포인터까지 자동으로 NULL로 만들지는 않는다.
14. 이중 포인터로 호출자 포인터 변경
int allocate_one(int **out) {
if (out == NULL) return 0;
*out = malloc(sizeof **out);
if (*out == NULL) return 0;
**out = 42;
return 1;
}
C는 포인터값도 값으로 전달하므로 호출자의 포인터 변수 자체를 변경하려면 그 포인터의 주소, 즉 이중 포인터를 전달한다.
확인 문제
- 함수 밖의
int a[5]에 대해sizeof a는 무엇의 크기를 구하는가? int *p[4]와int (*p)[4]의 차이는 무엇인가?- 배열의 one-past 포인터를 역참조해도 되는가?
realloc실패 시 원래 메모리 블록은 어떻게 되는가?free(p); p=NULL;이 다른 별칭 포인터까지 안전하게 만드는가?