Skip to Content
독학사독학사 3단계프로그래밍언어론11. 배열·레코드·포인터·참조 등 자료형 설계

이번 문서의 목표: 배열·레코드·집합 같은 구조형이 메모리에 배치되는 방식을 주소 계산식으로 직접 구하고, 포인터와 참조의 의미·차이·위험(댕글링 포인터·메모리 누수)을 설명하며, 언어별로 이 개념들을 어떻게 다르게 설계했는지 비교할 수 있게 된다.

왜 자료형의 “설계”를 따로 배우는가

10편에서 자료형을 원시형·구조형·사용자 정의형으로 분류했습니다. 이번 편은 그중 구조형(배열·레코드·집합)과, 자료구조를 다루는 데 빠질 수 없는 포인터·참조를 언어 설계 관점에서 살펴봅니다. 자료구조 과목이 “이 자료구조로 무엇을 효율적으로 할 수 있는가”(탐색·정렬·삽입 성능)를 다룬다면, 프로그래밍언어론은 “언어가 이 자료형을 어떻게 메모리에 배치하고, 어떤 문법으로 접근을 허용하며, 그 설계가 어떤 위험을 낳는가” 를 다룹니다. 같은 배열이라도 언어에 따라 경계 검사를 하는지, 크기를 바꿀 수 있는지, 메모리 주소를 직접 노출하는지가 전혀 다르며, 이 차이가 바로 시험에서 묻는 지점입니다.

쉽게 말하면: 자료구조 과목이 “이 서랍장을 어떻게 쓰면 효율적인가”를 다룬다면, 이번 편은 “이 서랍장을 프로그래밍 언어가 애초에 어떻게 설계해 두었는가”를 다룹니다.

1. 배열(array) — 동질적 원소의 연속 배치

배열(array, 배열)은 같은 자료형의 원소 여러 개를 메모리에 연속으로 배치하고, 인덱스(index, 색인)로 각 원소에 접근하는 구조형입니다. 배열이 빠른 이유는 원소의 주소를 곱셈과 덧셈만으로 즉시 계산할 수 있기 때문입니다.

주소(A[i])=base+i×원소 크기\text{주소}(A[i]) = \text{base} + i \times \text{원소 크기}
  • base\text{base}: 배열의 시작 주소(첫 번째 원소 A[0]이 저장된 주소)
  • ii: 접근하려는 원소의 인덱스
  • 원소 크기\text{원소 크기}: 원소 하나가 차지하는 바이트 수(예: int가 4바이트라면 4)
int arr[5]; /* base 주소를 1000이라고 가정, int는 4바이트 */
인덱스 i계산식주소
01000 + 0×41000
11000 + 1×41004
31000 + 3×41012

결과 해석: arr[3]에 접근할 때 언어(정확히는 컴파일된 코드)는 배열을 처음부터 순서대로 세지 않고, 1000 + 3 × 4 = 1012라는 한 번의 계산으로 곧장 그 주소에 도달합니다. 이 상수 시간 접근(임의 접근, random access)이 배열의 핵심 장점입니다.

다차원 배열의 저장 순서 — 행 우선(row-major) 계산

2차원 이상의 배열은 실제 메모리에는 1차원으로 펼쳐 저장해야 합니다. C·Java 등 대부분의 언어는 행 우선(row-major order, 행 우선) 방식을 씁니다 — 같은 행의 원소를 먼저 죽 늘어놓고, 그다음 행으로 넘어가는 방식입니다.

주소(A[i][j])=base+(i×열의 개수+j)×원소 크기\text{주소}(A[i][j]) = \text{base} + (i \times \text{열의 개수} + j) \times \text{원소 크기}
int arr[3][4]; /* base 주소를 2000이라고 가정, int는 4바이트, 열의 개수는 4 */
원소계산식주소
arr[0][0]2000 + (0×4+0)×42000
arr[1][0]2000 + (1×4+0)×42016
arr[2][1]2000 + (2×4+1)×4 = 2000 + 9×42036

자주 틀리는 점: “2차원 배열은 행과 열이 각각 독립된 메모리 영역에 저장된다”고 오해하기 쉽지만, 실제로는 하나의 연속된 메모리 블록에 행 우선(또는 언어에 따라 열 우선, column-major — 예: Fortran)으로 한 줄로 펼쳐 저장됩니다. 그래서 arr[2][1]처럼 행과 열 인덱스가 섞인 원소도 위 공식 하나로 정확히 계산됩니다.

2. 레코드(record)와 구조체 — 이질적 필드의 묶음

레코드(record, 레코드. C·Java에서는 구조체struct·클래스class로 구현)는 배열과 달리 서로 다른 자료형의 필드 여러 개를 하나의 이름 아래 묶는 구조형입니다.

구분배열(array)레코드(record)
원소의 자료형모두 같음(동질적, homogeneous)필드마다 다를 수 있음(이질적, heterogeneous)
접근 방법정수 인덱스(arr[i])필드 이름(person.age)
메모리 배치원소 크기 × 개수만큼 연속 배치, 인덱스로 곱셈 계산필드들을 선언 순서대로 이어 붙여 배치, 필드 이름은 컴파일 시 오프셋(base로부터의 거리)으로 변환됨
struct 사람 { char 이름[10]; /* 오프셋 0부터 10바이트 */ int 나이; /* 오프셋 10부터 4바이트 (구현에 따라 정렬을 위한 여백이 추가될 수 있음) */ };

결과 해석: person.나이라는 필드 접근은 실행 시점에 이름으로 찾는 것이 아니라, 컴파일 시점에 이미 “구조체 시작 주소 + 10바이트”라는 고정된 오프셋 계산으로 바뀝니다. 즉 레코드의 필드 접근도 배열의 인덱스 접근과 마찬가지로 컴파일 시 정해진 상수 오프셋을 이용한 상수 시간 접근이라는 점에서 원리가 같습니다. 다만 배열은 오프셋이 i × 원소 크기라는 계산식으로 나오는 반면, 레코드는 필드마다 크기가 달라 오프셋이 각 필드별로 미리 고정된 값이라는 차이가 있습니다.

3. 집합(set) — 비트로 표현하는 구조형

집합(set, 집합)은 원소의 존재 여부만 의미가 있고 중복과 순서는 의미가 없는 구조형입니다. 언어 차원에서 집합을 지원하는 경우(대표적으로 Pascal), 원소가 될 수 있는 값의 범위가 작다면 비트 벡터(bit vector)로 구현해 매우 효율적으로 처리합니다 — 원소 하나마다 비트 하나를 할당해, 그 비트가 1이면 “포함”, 0이면 “불포함”을 나타냅니다.

전체 원소 후보: {0, 1, 2, 3, 4, 5, 6, 7} 집합 {1, 3, 6}을 비트 벡터로 표현하면: 0 1 0 1 0 0 1 0 (인덱스) 7 6 5 4 3 2 1 0

결과 해석: 원소가 포함되어 있는지 확인하는 연산(3이 집합에 있는가)은 3번 비트를 확인하는 단 한 번의 비트 연산으로 끝나고, 합집합·교집합도 비트 벡터 사이의 OR·AND 연산 한 번으로 계산됩니다. C·Java에는 언어 차원의 집합 자료형이 없지만(라이브러리로 제공), 이 비트 벡터 아이디어는 훗날 그래프 알고리즘 등에서도 자주 재사용됩니다.

4. 포인터(pointer) — 주소를 담는 변수

포인터(pointer, 포인터)는 다른 변수가 저장된 메모리 주소 값 자체를 담는 변수입니다. C의 포인터가 대표적입니다.

int x = 10; int *p = &x; /* p는 x의 주소를 담는다 */ *p = 20; /* p가 가리키는 곳(x의 자리)에 20을 쓴다 */ printf("%d\n", x);
20

포인터는 다음과 같은 성질을 가집니다.

  • 재대입 가능: p에 다른 변수의 주소를 다시 대입해 가리키는 대상을 바꿀 수 있습니다.
  • 산술 연산 가능: p + 1처럼 주소에 정수를 더해 다음 원소를 가리킬 수 있습니다(배열 순회에 자주 쓰임).
  • 널(null)이 될 수 있음: 아무것도 가리키지 않는 상태(NULL)를 표현할 수 있습니다.
  • 역참조 필요: *p처럼 명시적으로 역참조 연산을 해야 가리키는 값에 접근합니다.

5. 참조(reference) — 값의 별명

참조(reference, 참조)는 포인터처럼 다른 저장 공간을 가리키지만, 더 제한된 규칙을 갖는 경우가 많습니다. 대표적으로 C++의 참조는 다음과 같은 제약이 있습니다.

int x = 10; int &r = x; /* r은 x의 "별명(alias)"이 된다 — 주소가 아니라 x 자체를 가리키는 또 다른 이름 */ r = 20; /* r에 값을 쓰면 곧바로 x가 바뀐다. 역참조 연산자가 필요 없다 */
20
구분포인터(pointer, C 기준)참조(reference, C++ 기준)
선언 이후 재대입가능(다른 주소를 다시 담을 수 있음)불가능(처음 초기화한 대상에 영구히 묶임)
산술 연산가능(p + 1 등)불가능
널 값가능(NULL을 담을 수 있음)원칙적으로 불가능(항상 유효한 대상을 참조해야 함)
값 접근 문법역참조 연산자 필요(*p)필요 없음(별명처럼 그냥 r로 바로 접근)
비유”다른 상자의 위치가 적힌 쪽지”(쪽지 자체를 바꿔 다른 상자를 가리킬 수 있음)“한 상자에 붙인 두 번째 이름표”(이름표를 다른 상자로 옮길 수 없음)

자주 틀리는 점 — Java의 “참조”는 C++의 참조와 다르다: Java에서 객체를 다루는 변수를 “참조(reference)“라고 부르지만, 이는 C++의 참조와 성질이 다릅니다. Java의 객체 참조는 재대입이 가능하고 null이 될 수 있다는 점에서 오히려 포인터에 가깝습니다(단, 명시적 역참조 연산자가 없고 산술 연산도 할 수 없다는 점은 다릅니다). 시험에서 “Java의 참조는 C++의 참조와 완전히 같은 개념이다”라는 보기가 나오면 이는 오답입니다 — 이름은 같아도 재대입 가능 여부·널 허용 여부가 다른, 언어마다 다르게 설계된 개념입니다. 매개변수로 넘길 때 값을 복사하는지 저장 공간을 공유하는지에 대한 정확한 전달 방식 비교는 14편에서 다룹니다.

6. 포인터·참조가 낳는 위험

포인터·참조처럼 메모리를 직접 다루는 능력은 강력한 만큼, 잘못 쓰면 다음과 같은 문제를 일으킵니다.

댕글링 포인터(dangling pointer)

이미 해제되어 더 이상 유효하지 않은 메모리를 여전히 가리키고 있는 포인터입니다.

int *p = malloc(sizeof(int)); /* 힙에 정수 하나만큼 공간을 명시적으로 할당 (10편의 명시적 힙 동적 저장 클래스) */ *p = 42; free(p); /* 이 공간을 반납한다 — 이제 이 주소는 더 이상 내 것이 아니다 */ printf("%d\n", *p); /* 위험! p는 이미 해제된 공간을 여전히 가리키는 댕글링 포인터가 되었다 */
정의되지 않은 동작(우연히 42가 나올 수도, 전혀 다른 값이나 충돌이 날 수도 있다)

결과 해석: free(p)는 그 메모리 공간을 “이제 다른 용도로 써도 된다”고 반납하는 것이지, p가 그 주소를 가리키고 있다는 사실 자체를 지워주지 않습니다. 그래서 p는 여전히 예전 주소를 담고 있지만, 그 주소의 내용은 언제 다른 데이터로 덮어써질지 알 수 없습니다. 자주 틀리는 점:free를 호출하면 포인터가 자동으로 NULL이 된다”는 착각입니다. 안전하게 쓰려면 free(p); 직후 p = NULL;을 직접 대입해 이후 실수로 역참조하더라도 즉시 알아챌 수 있게 해야 합니다.

메모리 누수(memory leak)

명시적으로 할당한 메모리를 더 이상 가리키는 포인터가 하나도 없는데도 해제하지 않아, 프로그램이 끝날 때까지 그 공간을 계속 차지하는 문제입니다.

void 함수(void) { int *p = malloc(sizeof(int) * 100); *p = 1; /* free(p)를 호출하지 않고 함수가 끝난다 */ } /* p는 지역 변수(스택 동적)라 이 시점에 소멸하지만, p가 가리키던 힙의 100개짜리 공간은 반납되지 않은 채 그대로 남는다 */

결과 해석: 지역 변수 p 자체는 함수가 끝나면 스택에서 사라지지만(09편의 스택 동적 저장 클래스), p가 가리키던 힙 메모리 공간은 저절로 사라지지 않습니다. 이 함수를 반복 호출하면 아무도 가리키지 않는 힙 공간이 계속 쌓여 결국 메모리 부족으로 프로그램이 멈출 수 있습니다. Java·Python처럼 가비지 컬렉터(garbage collector)가 있는 언어는 “아무도 참조하지 않는 객체”를 자동으로 찾아 회수해 이 문제를 크게 줄여주지만, C·C++처럼 명시적 힙 동적 저장 클래스를 쓰는 언어는 프로그래머가 직접 free·delete를 호출해야 합니다.

7. 언어별 설계 비교

언어포인터 지원참조 지원메모리 해제 방식
C명시적 포인터(*, &, 산술 연산 모두 가능)없음프로그래머가 malloc/free로 직접 관리
C++C의 포인터를 그대로 지원재대입 불가능한 참조(&) 별도 지원프로그래머가 new/delete로 직접 관리(단, 스마트 포인터로 자동화 가능)
Java없음(포인터 연산 자체를 언어에서 제거)재대입 가능한 객체 참조(포인터에 가까운 성질)가비지 컬렉터가 자동 회수
Python없음모든 이름이 객체를 가리키는 참조로 동작가비지 컬렉터(참조 카운트 + 순환 참조 수집)가 자동 회수

결과 해석: C는 메모리를 직접 통제할 수 있는 대신 댕글링 포인터·메모리 누수 같은 위험을 프로그래머가 전적으로 책임져야 합니다. Java·Python은 포인터 산술 자체를 언어에서 없애고 가비지 컬렉터를 도입해 이런 위험 상당수를 줄였지만, 그 대가로 메모리 배치를 세밀하게 통제할 수 없고 가비지 컬렉션이 도는 시점을 예측하기 어렵다는 트레이드오프가 있습니다. 이는 07편에서 다룬 “효율성 vs 신뢰성” 설계 trade-off의 또 다른 사례입니다.

핵심 정리

  • 배열은 동질적 원소를 연속 배치해 base + i × 원소 크기 계산으로 상수 시간에 접근하며, 다차원 배열은 보통 행 우선으로 1차원처럼 펼쳐 저장된다.
  • 레코드는 이질적 필드를 묶으며, 필드 접근은 컴파일 시 정해진 고정 오프셋을 이용한다는 점에서 배열과 원리가 같다.
  • 집합은 원소 후보가 적을 때 비트 벡터로 표현해 포함 여부·합집합·교집합을 비트 연산 한 번으로 처리할 수 있다.
  • 포인터는 재대입·산술 연산·널이 모두 가능한 주소 변수이고, 참조는 대개 재대입 불가능한 “별명”으로 더 제한적이다. Java의 참조는 이름은 같아도 C++ 참조와 성질이 다르다(재대입·null 가능).
  • 댕글링 포인터(해제된 공간을 계속 가리킴)와 메모리 누수(아무도 가리키지 않는데 해제 안 됨)는 포인터를 직접 다루는 언어에서 프로그래머가 책임져야 하는 대표적 위험이다.
  • C는 프로그래머가 메모리를 직접 관리하고, Java·Python은 가비지 컬렉터로 자동화한다 — 통제력과 안전성 사이의 trade-off다.

마무리 복습

문제 14지선다
int arr[10]의 시작 주소(base)가 2000이고 int가 4바이트일 때, arr[5]의 주소는?
문제 24지선다
int arr[4][6] (행 우선 저장, int 4바이트, base=1000)에서 arr[1][2]의 주소는? (주소(A[i][j]) = base + (i × 열의 개수 + j) × 원소 크기)
문제 34지선다
배열(array)과 레코드(record)의 차이로 옳은 것은?
문제 44지선다
C의 포인터와 C++의 참조(reference)의 차이로 옳지 않은 것은?
문제 54지선다
free(p); 를 호출해 힙 공간을 해제한 직후 *p로 그 값을 다시 읽으려 하는 상황을 가리키는 용어는?
문제 64지선다
함수 안에서 malloc으로 할당한 메모리를 가리키는 지역 포인터 변수가, free를 한 번도 호출하지 않은 채로 함수가 끝나 사라졌다. 이 상황이 일으키는 문제는?
문제 74지선다
Java의 객체 참조(reference)와 C++의 참조(reference)에 대한 설명으로 옳은 것은?

참고 자료

Last updated on