학습(공부)하는 블로그 :: 12. 포인터
 

 
반응형
블로그 이미지
학습하고 공부한 것을 보고 싶을때 다시 볼려고 요약해서 정리한 블로그입니다. 좋은 정보는 서로 공유합시다. 깨비형
« 2026/7 »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31

Archive»


Category»

Notice»

Recent Post»

Recent Comment»

Recent Trackback»

07-19 17:23

12. 포인터

학과 수업 노트/C언어 기초 배우기 | 2026. 7. 14. 11:36 | Posted by 깨비형
반응형

 

💡 1분 핵심 요약 (TL;DR)

  • 포인터 변수는 값이 아니라 메모리 주소를 저장하는 변수다 — int *ptr = &x;
  • ✅ 포인터를 쓰는 이유는 복사본이 아닌 원본을 직접 조작하기 위해서다
  • ✅ 선언할 때의 *(포인터 변수 만들기)와 사용할 때의 *(내용에 접근하기)는 역할이 다르다
  • ✅ 포인터에 +1을 하면 1바이트가 아니라 데이터 타입 크기만큼(int면 4바이트) 이동한다
  • ✅ 배열 이름은 포인터 상수다 — 포인터 변수와 달리 자체는 재대입할 수 없다

 

 

 

01 포인터, 왜 C언어에서만 유독 강조될까

파이썬은 알아서 해주는 걸 C언어는 직접 다룬다

 

포인터라는 개념은 다른 언어에도 존재하지만, 파이썬이나 자바 같은 언어는 메모리 주소 관리를 언어가 알아서 처리해줍니다. 반면 C언어는 개발자가 포인터를 직접 관리할 수 있다는 전제로 문법이 설계되어 있습니다. 이것이 C언어의 강점이자 주의사항입니다.

 

"프로그램 만드는 데 포인터가 꼭 필요한가?"라고 묻는다면, 기능 구현 자체에는 필수가 아닐 수 있습니다. 하지만 극한의 속도가 필요한 분야 — 게임 엔진, 우주 항공 소프트웨어 등 — 에서는 C/C++ 계열이 여전히 핵심이고, 실제로 스페이스X도 C++ 개발자를 채용해 비행 소프트웨어를 만들었습니다. 메모리를 직접 제어할 수 있다는 것이 그만큼 성능에서 강력하기 때문입니다.

 

 

 

 

02 포인터 변수 기본 구조와 문법 정리

헷갈리는 * 와 & , 표 하나로 정리하자

 

📖 포인터 변수 선언 기본 구조
타입 *변수이름 = 주소값; 형태입니다. 여기서 *는 곱하기가 아니라 "이 변수는 포인터"라는 표시(애스터리스크)입니다. 일반 변수에는 값을 넣지만, 포인터 변수에는 처음부터 주소를 넣습니다. 포인터(pointer)라는 이름 그대로, 어떤 데이터가 있는 곳을 손가락으로 가리키고 있는 변수입니다.

 

구분 일반 변수 포인터 변수
선언 int n = 10; int *ptr = &n;
저장된 것 메모리 내용(값) 메모리 주소
주소 확인 &n ptr
내용 접근 n *ptr
scanf 입력 scanf("%d", &n); scanf("%d", ptr);

 

⚠️ 두 개의 *는 역할이 다르다
선언할 때의 int *ptr에서 *는 "포인터 변수를 만든다"는 뜻이고, 사용할 때의 *ptr은 "이 주소로 한 번 들어가서 내용을 꺼낸다"는 뜻입니다. 같은 기호가 문맥에 따라 다르게 동작하기 때문에 초보 단계에서 가장 헷갈리는 지점입니다. 그래서 포인터 변수 이름은 ptr, p_x처럼 접두어를 붙여 한눈에 포인터임을 알 수 있게 짓는 것이 관례입니다.

 

 

 

 

03 실전 예제 — 포인터로 원본 값 바꾸기

포인터는 원본으로 가는 이정표다

 

변수 x를 만들고, x의 주소를 담는 포인터 변수 ptr을 만들어보겠습니다. 그리고 ptr을 통해 값을 바꾸면 원본 x가 실제로 바뀌는지 확인해봅니다.

 

포인터 변수 생성과 원본 조작

#include <stdio.h>

int main() {
    int x = 10;
    int *ptr = &x; // x의 주소를 저장하는 포인터 변수

    printf("x의 값: %d\n", x);
    printf("x의 주소: %p\n", &x);
    printf("ptr에 저장된 값: %p\n", ptr);

    printf("x 수정: ");
    scanf("%d", &x); // 일반 변수는 &로 주소를 넘긴다
    printf("x의 값: %d\n", x);

    printf("x 수정: ");
    scanf("%d", ptr); // ptr은 이미 주소니까 그대로 넘긴다
    printf("x의 값: %d\n", x);

    return 0;
}

 

▶ 실행 결과 (주소값은 실행할 때마다 다르다)

x의 값: 10
x의 주소: 0x7ffee4a3c0
ptr에 저장된 값: 0x7ffee4a3c0
x 수정: 8
x의 값: 8
x 수정: 5
x의 값: 5

 

⚠️ 자주 하는 실수 — 주소를 %d로 출력하기
실습하다 보면 printf("%d", &x)처럼 주소를 %d로 출력하는 실수를 하기 쉽습니다. %d는 int 전용 서식이라, 64비트 환경에서는 주소가 잘리거나 엉뚱한 값이 나올 수 있는 정의되지 않은 동작입니다. 주소를 출력할 때는 반드시 %p를 써야 합니다.

 

x의 주소와 ptr에 저장된 값이 완전히 동일합니다. 그리고 scanf(&x)로 수정하든, scanf(ptr)로 수정하든 둘 다 같은 메모리 위치를 가리키고 있어서 결과는 동일합니다. 포인터는 원본으로 가는 이정표 같은 존재라서, 이정표를 따라가 수정하면 결국 원본이 수정되는 것입니다. 이것이 포인터를 쓰는 핵심 이유입니다 — 복사본이 아니라 원본을 제어하기 위해서.

 

 

 

04 포인터 연산 — +1이 4바이트씩 움직이는 이유

포인터와 배열이 만나는 지점

 

배열의 각 원소는 메모리에 연속으로 배치되어 있습니다. 포인터에 +1을 하면 다음 원소로 이동하는데, 이때 1바이트가 아니라 데이터 타입의 크기만큼 이동합니다. 배열과 포인터를 함께 놓고 확인해보겠습니다.

 

인덱스 접근 vs 포인터 연산 접근

#include <stdio.h>

int main() {
    int arr[5] = {1, 3, 5, 7, 9};
    int *ptr_arr = &arr[0]; // 첫 번째 요소의 주소

    // CASE 1. 인덱스로 값 출력
    for (int i = 0; i < 5; i++) {
        printf("%d ", arr[i]);
    }
    printf("\n");

    // CASE 2. 각 요소의 주소 출력
    for (int i = 0; i < 5; i++) {
        printf("%p\n", &arr[i]);
    }

    // CASE 3. 포인터 연산으로 값 출력
    for (int i = 0; i < 5; i++) {
        printf("%d ", *(ptr_arr + i));
    }
    printf("\n");

    return 0;
}

 

▶ 실행 결과 (주소값은 환경마다 다르다)

1 3 5 7 9
0x7ffee4a3c0
0x7ffee4a3c4
0x7ffee4a3c8
0x7ffee4a3cc
0x7ffee4a3d0
1 3 5 7 9

 

✓ +1인데 왜 주소는 4씩 늘어날까
주소를 보면 c0 → c4 → c8 → cc → d0으로 정확히 4바이트씩 커집니다. 포인터를 int형으로 선언했기 때문에, +1을 하면 int 하나의 크기(4바이트)만큼 이동하는 겁니다. char형 포인터라면 1바이트씩, double형 포인터라면 8바이트씩 이동합니다. 사실상 포인터 연산의 +i가 인덱스 [i]와 같은 역할을 하는 셈입니다.

 

 

그렇다면 배열 인덱스로 수정한 값과 포인터 연산으로 수정한 값이 정말 같은 메모리를 가리키는지 직접 확인해보겠습니다.

 

인덱스 수정과 포인터 연산 수정은 같은 자리를 가리킨다

#include <stdio.h>

int main() {
    int array[3] = {1, 10, 100};
    int *pArray = &array[0];

    array[2] = 999; // 인덱스로 수정
    printf("%d\n", array[2]);

    *(pArray + 2) = 888; // 포인터 연산으로 수정
    printf("%d\n", array[2]); // 배열 쪽에서 확인
    printf("%d\n", *(pArray + 2)); // 포인터 쪽에서 확인

    return 0;
}

 

▶ 실행 결과

999
888
888

 

array[2]로 수정한 뒤 999가 나왔고, 이어서 *(pArray + 2)로 수정했더니 array[2]도 888로, 포인터로 읽은 값도 888로 똑같이 바뀝니다. 둘이 애초에 같은 메모리 위치를 가리키고 있었기 때문입니다.

 

📖 포인터 변수도 자기만의 주소가 있다
헷갈리기 쉬운 세 가지를 구분해봅시다. p_arr는 배열을 가리키는 포인터 변수의 값(=배열의 주소)이고, *p_arr은 그 주소에 있는 내용(값)이며, &p_arr은 포인터 변수 p_arr 자기 자신이 저장된 위치입니다. 포인터 변수도 결국 메모리 어딘가에 저장된 변수이기 때문에, 그 변수 자체의 주소도 존재합니다. p_arr과 &p_arr은 완전히 다른 값이 나온다는 걸 기억해두면 좋습니다.

 

 

 

05 배열과 포인터의 관계 — 포인터 상수

배열 이름은 포인터처럼 동작하지만, 포인터 변수는 아니다

 

배열 이름은 첫 번째 요소의 주소로 자동 변환되는데, 이를 포인터 붕괴(pointer decay)라고 부릅니다. 그래서 배열 이름을 포인터처럼 연산하는 것은 표준 문법이고 실전에서도 흔히 쓰입니다. 다만 배열 이름은 포인터 변수가 아니라 포인터 상수라는 중요한 차이가 있습니다. 아래 표로 정리해보겠습니다.

 

코드 동작 여부
int *ptr = &arr[0]; ✅ 권장 — 의도가 명확하다
ptr[2] = 55; (포인터에 인덱스 연산) ✅ 권장
*(ptr + 2) = 55; (포인터 연산) ✅ 권장
int *ptr = arr; (배열 이름으로 초기화) ✅ 표준 문법 — 포인터 붕괴(decay)로 정상 동작
*(arr + 2) = 55; (배열 이름으로 포인터 연산) ✅ 표준 문법 — 실전에서도 흔히 쓰인다
arr = &x; (배열 이름 자체를 재대입) ❌ 컴파일 에러 — 배열 이름은 상수라 재대입 불가

 

📖 배열 이름은 "포인터 상수(constant)"다
배열 이름은 첫 번째 원소의 주소를 가진 포인터 상수입니다. 자유롭게 다른 주소를 담을 수 있는 포인터 변수와 달리, 배열 이름 자체는 다른 주소로 재대입할 수 없습니다(예: arr = &x; 는 컴파일 에러). int *ptr = arr;이나 *(arr + 2) = 55;처럼 배열 이름을 포인터처럼 연산하는 것 자체는 표준 문법이라 문제없이 동작합니다. 다만 주소를 자유롭게 옮겨 담거나 재대입해야 하는 상황이라면, 애초에 포인터 변수를 따로 만들어 쓰는 편이 의도가 더 명확하고 안전합니다.

 

위 표의 내용을 하나의 코드로 직접 실습해보면 다음과 같습니다.

 

✅ 직접 실습해본 코드

#include <stdio.h>

int main() {
    int score[] = {3, 4, 5, 6, 7};

    // int *ptr_score = score; // 되긴 하지만 권장하지 않는다
    int *ptr_score = &score[0]; // 권장함

    // 포인터 변수를 통한 수정 — 전부 권장
    *ptr_score = 99;
    ptr_score[1] = 89;
    *(ptr_score + 2) = 79;

    // 배열 이름으로 직접 포인터 연산 — 되긴 하나 권장하지 않음
    *(score + 3) = 66;
    *score = 55;

    for (int i = 0; i < 5; i++) {
        printf("%d ", score[i]);
    }
    printf("\n");

    return 0;
}

 

▶ 실행 결과

55 89 79 66 7

 

score[0]=99로 바꿨다가 마지막 줄 *score=55로 다시 덮어써서 최종적으로 55가 되었고, 나머지도 각각 89, 79, 66으로 바뀐 것을 확인할 수 있습니다. 권장 방식이든 비권장 방식이든 결과는 똑같이 동작합니다 — 차이는 어디까지나 가독성과 코드의 의도 전달에 있습니다.

 

 

 

06 함수와 포인터 — swap 예제 다시 보기

함수 강의에서 미뤄뒀던 그 코드, 이제 완전히 이해된다

 

함수 강의에서 "포인터를 배운 뒤 다시 보라"고 미뤄뒀던 swap 예제를 이제 완전히 해석할 수 있습니다. 일반 변수로 값을 넘기면 복사본(값)만 전달되어 함수 안에서 아무리 바꿔도 원본은 그대로였습니다. 포인터 변수로 주소를 넘기면, 함수가 그 주소를 따라가 원본을 직접 수정하게 됩니다.

 

❌ 값만 전달 — swap1은 실패한다

#include <stdio.h>

void swap1(int n1, int n2) {
    int temp = n1;
    n1 = n2;
    n2 = temp;
}

int main() {
    int n1 = 5, n2 = 3;
    printf("원본 값 - n1: %d, n2: %d\n", n1, n2);

    swap1(n1, n2); // 값(복사본)만 전달한다

    printf("함수 실행 후 - n1: %d, n2: %d\n", n1, n2);

    return 0;
}

 

▶ 실행 결과 (원본은 그대로다)

원본 값 - n1: 5, n2: 3
함수 실행 후 - n1: 5, n2: 3

 

함수 안에서는 n1, n2가 서로 바뀌었지만, 그건 복사본끼리의 교환일 뿐입니다. main의 n1, n2는 전혀 건드리지 못했습니다. 이제 주소를 전달하는 swap2로 바꿔보겠습니다.

 

✅ 주소를 전달해서 원본을 바꾸는 swap

#include <stdio.h>

void swap2(int *n1, int *n2) {
    int temp = *n1;
    *n1 = *n2;
    *n2 = temp;
}

int main() {
    int n1 = 5, n2 = 3;
    printf("원본 값 - n1: %d, n2: %d\n", n1, n2);

    swap2(&n1, &n2); // 주소를 전달한다

    printf("함수 실행 후 - n1: %d, n2: %d\n", n1, n2);

    return 0;
}

 

▶ 실행 결과

원본 값 - n1: 5, n2: 3
함수 실행 후 - n1: 3, n2: 5

 

 

✓ 포인터 매개변수, 언제 쓰고 언제 안 쓸까
무조건 포인터를 써야 하는 게 아닙니다. 원본을 수정해야 할 때만 포인터 매개변수를 씁니다. 삼각형 넓이 함수(triangle)처럼 값을 받아 계산 결과만 돌려주면 되는 경우엔 원본을 건드릴 필요가 없으니 일반 변수 매개변수로 충분합니다. "이 함수가 원본 조작이 필요한가?"를 기준으로 판단하면 됩니다.

 

 

 

07 scanf와 포인터 변수

&를 붙였던 진짜 이유가 여기서 밝혀진다

 

scanf는 입력받은 값을 저장할 메모리 주소를 요구하는 함수였습니다. 그래서 일반 변수는 &를 붙여 주소를 만들어 넘겼던 거죠. 포인터 변수는 이미 주소를 담고 있으니 & 없이 변수명 그대로 넘기면 됩니다.

 

일반 변수 vs 포인터 변수로 scanf 입력받기

#include <stdio.h>

int main() {
    int n1 = 0;
    int *ptr_n1 = &n1;

    // CASE 1. 일반 변수 — &로 주소를 만들어 넘긴다
    printf("n1 입력: ");
    scanf("%d", &n1);

    // CASE 2. 포인터 변수 — 이미 주소라서 그대로 넘긴다
    printf("ptr_n1로 입력: ");
    scanf("%d", ptr_n1);

    printf("n1의 값: %d\n", n1);
    printf("*ptr_n1의 값: %d\n", *ptr_n1);

    return 0;
}

 

▶ 실행 결과

n1 입력: 1
ptr_n1로 입력: 5
n1의 값: 5
*ptr_n1의 값: 5

 

두 방법 모두 같은 주소에 값을 저장하기 때문에, 포인터로 입력해도 원본 n1이 함께 바뀝니다. 문자열 입력 때 배열 이름에 &를 안 붙였던 것, 일반 변수에는 &를 붙였던 것 — 모두 "scanf에는 주소를 넘긴다"는 하나의 원리였다는 게 이제 하나로 연결됩니다.

 

 

 

08 한 걸음 더 — 포인터 배열

주소들만 모아놓은 배열, 리모컨 같은 존재

 

마지막으로 포인터 배열을 소개합니다. 값이 아니라 주소들만 모아서 저장하는 배열입니다. 당장 쓸 일은 없을 수 있지만, 여기저기 흩어져 있는 데이터들을 한곳에서 관리하는 리모컨(내비게이션) 같은 역할을 할 수 있어서, 규모가 있는 프로그램이나 동적 배열을 다룰 때 유용해집니다.

 

 

 

✅ 직접 실습해본 코드 — for문으로 채우는 포인터 배열

#include <stdio.h>

int main() {
    int arr[3] = {10, 20, 30};
    int *pArr[3]; // 포인터들만 저장할 배열 생성

    // 각 요소의 주소를 for문으로 채운다
    for (int i = 0; i < 3; i++) {
        pArr[i] = &arr[i];
    }

    for (int i = 0; i < 3; i++) {
        printf("pArr[%d] = %p, *pArr[%d] = %d\n", i, (void*)pArr[i], i, *pArr[i]);
    }

    return 0;
}

 

▶ 실행 결과 (주소값은 환경마다 다르다)

pArr[0] = 0x7ffee4a3c0, *pArr[0] = 10
pArr[1] = 0x7ffee4a3c4, *pArr[1] = 20
pArr[2] = 0x7ffee4a3c8, *pArr[2] = 30

 

✓ 초기화 목록보다 for문이 유리한 이유
원소가 3개라면 {&arr[0], &arr[1], &arr[2]}처럼 나열해도 되지만, 원소가 100개, 1000개라면 일일이 나열할 수 없습니다. for문으로 채우는 방식은 배열 크기가 커져도 코드를 그대로 재사용할 수 있어 훨씬 실전적입니다.

 

📖 %p에는 왜 (void*)로 형변환할까
printf의 %p는 표준상 void* 타입을 받도록 정의되어 있습니다. int* 값을 형변환 없이 그대로 넘겨도 대부분의 컴파일러는 무리 없이 동작하지만, (void*)로 명시적으로 형변환해주는 것이 C 표준을 정확히 지키는 방식입니다.

 

 

 

📌 핵심 정리

· 포인터 변수 : 메모리 주소를 저장하는 전용 변수 — int *ptr = &x;
· 사용 이유 : 복사본이 아닌 원본을 접근·수정하기 위해서
· * 두 가지 역할 : 선언할 때는 "포인터 만들기", 사용할 때는 "주소로 들어가 내용 꺼내기"
· 포인터 연산 : +1은 1바이트가 아니라 데이터 타입 크기만큼 이동한다 (int면 4바이트)
· 배열과의 관계 : 배열 이름은 포인터 붕괴로 첫 요소의 주소처럼 동작하는 포인터 상수다 — 재대입은 불가능하다
· 함수 매개변수 : 원본 수정이 필요하면 포인터로, 계산 결과만 돌려주면 일반 변수로
· scanf의 원리 : scanf는 주소를 요구한다 — 일반 변수는 &를, 포인터·배열은 그대로 넘긴다
· 함수(10강) → 배열(11강) → 포인터(12강)를 한 번 더 순서대로 복습하면 세 개념이 하나로 연결된다

 

※ 이 글은 아래 동영상으로 학습한 내용을 요약한 글입니다.

 

출처 동영상: https://youtu.be/6yE4eYZ6_50?si=9LTkZ0rNvxumCc-U

 

 

태그 : C언어 포인터 개념포인터 변수 사용법포인터 배열 관계포인터 연산C언어 주소값 출력swap 함수 포인터scanf 주소 전달포인터 배열 만들기C언어 메모리 주소C언어 코딩 기초 강의

반응형

'학과 수업 노트 > C언어 기초 배우기' 카테고리의 다른 글

14. 구조체와 파일 입출력  (0) 2026.07.19
13. 문자열과 string.h  (0) 2026.07.18
11. 배열  (0) 2026.07.12
10. 함수  (0) 2026.07.05
9. Switch Case문  (0) 2026.07.03
: