포인터, 배열, 문자열
이번 주차에는 C언어의 핵심이자 가장 까다로운 부분 중 하나인 포인터(Pointer), 그리고 이를 기반으로 동작하는 배열(Array)와 문자열(String)에 대해 깊이 있게 학습합니다. 메모리를 직접 다루는 C언어의 강력함을 이해하고, 동시에 발생할 수 있는 여러 문제점과 그 해결 방법도 배웁니다.
메모리(memory) 와 어드레스 (주소/address)

메모리란 저장공간입니다. 각 저장 공간마다 고유한 주소가 있으며, 각 주소마다 1byte씩 값을 저장할 수 있습니다.

위 사진은 Visual Studio의 메모리 뷰로 메모리를 확인한 모습입니다. 숫자는 모두 16진수로 표시됩니다. 좌측 값은 주소를 의미합니다.
- 0x10FD95F7E4 주소에는 0x01이라는 값이 있습니다.
- 0x10FD95F7E5 주소에는 0x00이라는 값이 있습니다.
- 0x10FD95F7E6 주소에는 0x00이라는 값이 있습니다.
- 0x10FD95F7E7 주소에는 0x00이라는 값이 있습니다.
- 0x10FD95F7E8 주소에는 0xCC이라는 값이 있습니다.
변수
최적화가 적용되지 않았다고 가정하면, 모든 변수는 타입의 크기만큼 메모리 공간을 가지며, 타입에 따라 해당 공간의 값을 다루고 해석합니다.
- int 타입 변수: 4byte 메모리 공간을 가지고, int 타입으로 다룹니다.
- long 타입 변수: 4byte 메모리 공간을 가지고, long 타입으로 다룹니다.
- long long 타입 변수: 8byte 메모리 공간을 가지고, long long 타입으로 다룹니다.
- double 타입 변수: 8byte 메모리 공간을 가지고, double 타입으로 다룹니다.
- float 타입 변수: 4byte 메모리 공간을 가지고, float 타입으로 다룹니다.

- b 변수는 4byte만큼 메모리 공간을 가지며, 시작 주소는 0x00000053134FF6C4입니다.
- b의 값은 10입니다. (0x0a)
자료형 / 타입
타입(type)이란 변수나 값이 메모리에서 어떻게 저장되고, 어떤 방식으로 해석되는지를 결정하는 정보입니다. C언어에서 타입은 변수의 크기(바이트 수), 저장 가능한 값의 범위, 연산 방식, 그리고 메모리 해석 방법을 정의합니다.
- 예를 들어, int는 정수를 저장하는 타입으로 4byte 공간을 차지하며, double은 실수를 저장하는 타입으로 8byte 공간을 차지합니다. char는 1byte로 문자를 저장합니다.
- 포인터 타입(int *, double * 등)은 해당 타입의 변수 주소를 저장하며, 포인터의 크기는 시스템(32bit/64bit)에 따라 다르지만, 8byte(64bit 기준)입니다.
- 타입의 크기는 컴파일러와 운영체제에 따라 달라질 수 있으므로, sizeof 연산자를 사용해 실제 크기를 확인할 수 있습니다.
printf("int: %zu\n", sizeof(int)); printf("double: %zu\n", sizeof(double)); printf("int *: %zu\n", sizeof(int*)); - typedef를 사용하면 타입에 별칭을 붙일 수 있고, 구조체(struct)는 여러 타입을 묶어 새로운 타입을 만들 수 있습니다.
typedef unsigned int uint; struct Point { int x; int y; }; - 타입 앞에 붙는 키워드(const, volatile, static 등)는 변수의 성질을 바꿉니다. 예를 들어, const int는 값이 변경될 수 없는 정수형입니다.
즉, 타입은 변수의 의미와 동작을 결정하는 핵심적인 정보이며, 값 자체뿐 아니라 주소, 크기, 해석 방식까지 모두 포함하는 개념입니다.
1. 포인터 (Pointers)
포인터는 메모리의 주소를 값으로 갖는 변수입니다. C언어가 하드웨어를 직접 제어하고, 메모리를 효율적으로 사용할 수 있게 해주는 핵심적인 도구입니다.
포인터 산술
포인터는 덧셈/뺄셈 연산이 가능합니다. 예를 들어, int *p가 int형 배열을 가리킬 때 p+1은 다음 원소의 주소를 의미합니다.
int arr[3] = { 1, 2, 3};
int *p = arr;
printf("%d", *(p+1)); // 2 출력
void 포인터
void*는 모든 타입의 주소를 저장할 수 있습니다. 하지만 역참조하려면 반드시 타입 캐스팅이 필요합니다.
void *vp;
int n = 5;
vp = &n;
printf("%d", *(int*)vp); // 반드시 타입 캐스팅 필요
다중 포인터
포인터를 가리키는 포인터도 만들 수 있습니다. 예: int **pp
int a = 10;
int *p = &a;
int **pp = &p;
printf("%d", **pp); // 10 출력
가. 포인터란 무엇인가?
변수를 선언하면, 해당 변수의 값을 저장하기 위한 메모리 공간이 할당됩니다. 포인터는 바로 그 메모리 공간의 시작 주소를 저장합니다.
&(주소 연산자): 변수 앞에 붙여서 해당 변수의 메모리 주소를 가져옵니다.*(역참조 연산자): 포인터 변수 앞에 붙여서, 그 포인터가 가리키는 주소에 있는 실제 값에 접근합니다.
int num = 10;
int *ptr; // int형 변수를 가리키는 포인터 ptr 선언
ptr = # // ptr에 num 변수의 주소를 저장
printf("num의 값: %d", num);
printf("num의 메모리 주소: %p", &num);
printf("ptr에 저장된 값(num의 주소): %p", ptr);
printf("ptr이 가리키는 곳의 실제 값: %d", *ptr);
*ptr = 20; // ptr이 가리키는 곳의 값을 20으로 변경
printf("값 변경 후 num의 값: %d", num); // num의 값이 20으로 바뀜
포인터 타입:
int *ptr,char *ptr처럼 포인터 선언 시*앞에 붙는 타입은, 포인터가 가리키는 메모리 주소에서 어떤 타입으로 데이터를 해석할 것인지를 컴파일러에게 알려주는 역할을 합니다. 예를 들어,int *는 해당 주소부터 4바이트(시스템에 따라 다름)를 정수형으로 읽으라는 의미입니다.
나. NULL
포인터가 아무것도 가리키지 않는 상태를 나타내기 위해, 주소값이 0인 경우를 특별히 NULL 포인터라고 합니다.
#include <stdio.h>
// C 표준에서는 NULL을 (void*)0 으로 정의하는 경우가 많습니다.
#define NULL ((void *)0)
int main() {
int *ptr = NULL; // ptr을 NULL로 초기화
if (ptr != NULL) {
// 이 코드는 실행되지 않음
printf("ptr이 가리키는 값: %d", *ptr);
}
// NULL 포인터 역참조 시도 (매우 위험!)
*ptr = 100; // CRASH! 운영체제가 접근 권한이 없는 메모리(0번지)에 접근을 막아 오류 발생
return 0;
}
중요: 초기화되지 않은 포인터(Wild Pointer)는 쓰레기 값을 가져 예측 불가능한 동작을 유발하므로, 포인터는 선언과 동시에 특정 변수의 주소로 초기화하거나, 가리킬 대상이 없다면 반드시 NULL로 초기화하는 습관이 매우 중요합니다.
2. 배열 (Arrays)
배열은 동일한 자료형의 원소들이 연속된 메모리 공간에 순서대로 저장된 집합입니다.
배열과 포인터의 관계
배열 이름은 배열의 첫 번째 원소의 주소와 같습니다. 배열을 함수 인자로 넘길 때는 포인터로 전달됩니다.
void printArr(int *arr, int size) {
for(int i=0;i<size;i++) printf("%d ", arr[i]);
}
int nums[3] = { 1, 2, 3};
printArr(nums, 3); // 배열 이름은 포인터로 전달됨
다차원 배열
2차원 배열은 행과 열로 구성되며, 메모리에는 행이 먼저 저장됩니다.
int mat[2][3] = { {1 ,2 , 3} , { 4, 5, 6}};
printf("%d", mat[1][2]); // 6 출력
int numbers[5]; // int형 원소 5개를 저장할 수 있는 배열 선언
numbers[0] = 10;
numbers[1] = 20;
// ...
numbers[4] = 50;
// 배열 이름은 배열의 첫 번째 원소의 주소와 같습니다.
printf("배열의 시작 주소: %p", numbers);
printf("첫 번째 원소의 주소: %p", &numbers[0]);
// 포인터를 이용한 배열 원소 접근
int *ptr = numbers;
printf("포인터로 접근한 첫 번째 원소: %d", *ptr); // numbers[0]
printf("포인터로 접근한 두 번째 원소: %d", *(ptr + 1)); // numbers[1]
C99 가변 길이 배열(VLA): C89 표준에서는 배열의 크기를 반드시 상수로 지정해야 했지만, C99 표준부터는 변수를 사용해 배열 크기를 동적으로 지정할 수 있게 되었습니다. 단, 컴파일러나 환경에 따라 지원하지 않을 수 있으니 주의해야 합니다.
int size = 10; int dynamic_array[size]; // C99 이상에서 가능
3. C언어의 문자열 (Strings)
C언어에는 별도의 문자열 타입이 없습니다. 대신 문자(char)들의 배열을 이용해 문자열을 표현하며, 문자열의 끝은 항상 NULL 문자(\0)로 표시됩니다.
문자열 입력
문자열을 입력받을 때는 scanf, gets, fgets 등을 사용할 수 있습니다. gets는 보안상 위험하므로 fgets를 권장합니다.
char buf[100];
fgets(buf, sizeof(buf), stdin); // 줄바꿈까지 입력, 안전
문자열 처리 함수 (추가)
strncpy, strncat, strchr, strstr 등 다양한 함수가 있습니다.
char s[20] = "hello";
strncat(s, " world", 6); // 최대 6글자만 추가
char *p = strchr(s, 'w'); // 'w' 위치 포인터 반환
char str1[] = "hello"; // 컴파일러가 크기(6)를 계산하고, 끝에 '\0'을 자동으로 추가
char str2[6] = {'h', 'e', 'l', 'l', 'o', '\0'}; // 수동으로 NULL 문자 포함
char str3[100] = { 0, }; // 모든 원소를 0(NULL)으로 초기화. 안전한 초기화 방법
printf("str1: %s", str1);
printf("str2: %s", str2);
버퍼 오버런 (Buffer Overrun)
할당된 배열의 크기를 넘어선 공간에 데이터를 쓰려고 할 때 발생하는 문제입니다. C언어는 배열의 경계를 자동으로 검사해주지 않기 때문에, 프로그래머의 부주의로 인해 쉽게 발생할 수 있으며 이는 심각한 보안 취약점으로 이어질 수 있습니다.
char name[5]; // 5개의 문자 + NULL 문자('\0') => 최대 4글자 저장 가능
// "world"는 5글자 + NULL 문자('\0')가 필요하므로 총 6바이트가 필요
// name 배열의 크기를 넘어서 데이터를 쓰게 됨 (Buffer Overrun)
strcpy(name, "world");
printf("name: %s", name); // 의도치 않은 결과나 프로그램 비정상 종료 발생 가능
4. 표준 문자열 함수
<string.h> 헤더 파일에 선언된 여러 함수들을 사용하면 문자열을 편리하게 다룰 수 있습니다.
| 함수 | 설명 | 직접 구현 예시 |
|---|---|---|
strlen |
문자열의 길이(NULL 문자를 제외한 문자의 수)를 반환 | size_t strlen_impl(const char *s) { const char *p = s; while (*p) ++p; return p - s; } |
strcpy |
문자열을 복사 | char* strcpy_impl(char *dest, const char *src) { char *ret = dest; while ((*dest++ = *src++)); return ret; } |
strcmp |
두 문자열을 사전 순으로 비교 (같으면 0, 앞>뒤면 양수, 앞<뒤면 음수) | int strcmp_impl(const char *s1, const char *s2) { while (*s1 && (*s1 == *s2)) { s1++; s2++; } return *(unsigned char*)s1 - *(unsigned char*)s2; } |
strcat |
한 문자열 뒤에 다른 문자열을 이어 붙임 | char* strcat_impl(char *dest, const char *src) { char *ret = dest; while (*dest) dest++; while ((*dest++ = *src++)); return ret; } |
5. 코드 품질과 디버깅
- 실수는 당연하다: 코드를 작성할 때 실수는 필연적입니다. 중요한 것은 실수를 최대한 빨리 발견하고 수정하는 것입니다.
- 컴파일러는 첫 번째 방어선: 컴파일러는 문법 오류나 타입 불일치 등 많은 실수를 가장 먼저, 그리고 가장 빠르게 알려주는 훌륭한 도구입니다. 컴파일러 경고(Warning)를 무시하지 말고, 그 의미를 파악하여 코드를 수정하는 습관이 중요합니다.
- 엄격한 문법의 중요성:
Rust와 같은 최신 언어들이 엄격한 문법과 컴파일 규칙을 적용하는 이유는, 컴파일 단계에서부터 잠재적인 오류를 최대한 걸러내어 런타임 오류의 가능성을 줄이기 위함입니다. “컴파일만 통과해도 일단 돌아는 간다”는 신뢰를 줍니다. - 오류 처리: 함수가 실패할 수 있는 모든 경우(파일 열기 실패, 메모리 할당 실패 등)를 고려하고, 오류 발생 시 어떻게 대처할지(에러 메시지 출력, 프로그램 종료 등) 미리 계획해야 합니다.
6. 연습 문제
아래 백준 온라인 저지 문제들을 풀면서 포인터, 배열, 문자열에 대한 이해도를 높여보세요.
문자열 관련 문제
- 2743번: 단어 길이 재기
- 11654번: 아스키 코드
- 11720번: 숫자의 합
- 1152번: 단어의 개수
- 1259번: 팰린드롬수
- 11328번: Strfry
- 12871번: 무한 문자열
- 1296번: 팀 이름 정하기