[C언어 50강] 29강. 이중 포인터: 포인터의 포인터, 문자열 배열(char**)
C언어에서 이중 포인터(T **)는 처음 보면 문법 장벽처럼 느껴지지만, 실제로는 아주 단순한 구조입니다. “값을 가리키는 포인터를 또 가리키는 포인터”일 뿐입니다. 문제는 이 개념이 문자열 배열, 함수에서 포인터를 바꾸는 패턴, 동적 2차원 구조와 한꺼번에 등장한다는 점입니다. 오늘은 문법을 외우기보다, 왜 포인터를 한 번 더 감싸야 하는지를 메모리 관점에서 이해해보겠습니다.
핵심 개념
int *p는 int 값을 가리키고,int **pp는 “int를 가리키는 포인터 p”를 가리킨다.- 함수가 호출자 쪽 포인터 자체를 변경하려면
T **형태(out-parameter)가 필요하다. char **argv는 “문자열(=char 배열의 시작 주소)들의 배열”을 다루는 표준 패턴이다.T **를 무조건 2차원 배열로 이해하면 안 된다. 연속 메모리 2차원 배열(T a[R][C])과는 메모리 레이아웃이 다를 수 있다.
개념 먼저 이해하기
이중 포인터를 이해하는 가장 좋은 방법은 “누구의 값을 바꾸고 싶은가?”를 질문하는 것입니다. 예를 들어 함수 안에서 정수값을 바꾸고 싶으면 int *를 전달합니다. 그 이유는 함수가 원본 변수의 주소를 받아서 역참조로 수정하기 위해서죠. 그런데 함수 안에서 포인터 변수 자체를 다른 주소로 바꾸고 싶다면 한 단계 더 들어가야 합니다. 즉 포인터 변수의 주소가 필요하고, 그 타입이 T **가 됩니다. 이게 이중 포인터의 본질입니다. 어려운 트릭이 아니라, 값 수정 패턴이 한 단계 확장된 것뿐입니다.
메모리 그림으로 보면 더 명확합니다. int x = 10; int *p = &x; int **pp = &p;일 때, p는 x의 주소를 담는 변수입니다. pp는 p의 주소를 담는 변수입니다. 따라서 *pp는 p이고, **pp는 x의 값입니다. 여기서 자주 생기는 실수는 *pp = 20 같은 코드를 쓰는 것인데, *pp는 int가 아니라 int*이므로 정수 20을 대입하면 타입이 깨집니다. 값(10, 20)을 바꾸고 싶으면 **pp = 20이어야 합니다. 반대로 p가 다른 int를 가리키게 바꾸려면 *pp = &y처럼 “포인터를 대입”해야 맞습니다.
문자열 배열에서 char **가 많이 나오는 이유도 같은 원리입니다. C에서 문자열은 보통 char *(정확히는 문자 배열의 시작 주소)로 다룹니다. 그러면 문자열 여러 개를 담는 배열은 char *names[]가 됩니다. 배열이 함수 인자로 decay 되면 char **로 전달됩니다. 즉 char **는 “문자열 하나”가 아니라 “문자열들의 목록”을 순회할 때 자연스럽게 등장하는 타입입니다. argv를 떠올리면 쉽습니다. argv[i]는 i번째 문자열(=char*), argv[i][j]는 그 문자열의 j번째 문자(char)입니다.
다만 여기서 아주 중요한 경계가 있습니다. 많은 입문자가 int **를 보면 “2차원 배열이구나”라고 단정합니다. 하지만 int matrix[3][4]의 타입은 함수 인자에서 int (*)[4]로 취급되어야 하며, 이것은 int **와 호환되지 않습니다. 이유는 메모리 배치가 다르기 때문입니다. int matrix[3][4]는 12개의 int가 한 덩어리로 연속 배치됩니다. 반면 int **rows 구조는 각 행을 별도 할당하면 행들이 서로 떨어진 주소에 있을 수 있습니다. 즉 둘 다 “행/열처럼 보이는 접근”은 가능해도 내부 계약이 다르므로, 함수 시그니처를 정확히 설계해야 합니다.
실무에서 이중 포인터가 빛나는 지점은 생성/해제 API입니다. 예를 들어 int make_buffer(char **out, size_t n) 같은 함수는 내부에서 malloc한 주소를 호출자에게 돌려줘야 합니다. C는 인자를 값으로 전달하므로 char *out으로 받으면 함수 내부에서 out을 바꿔도 호출자 변수는 그대로입니다. 그래서 char **out을 받고 *out = malloc(...)처럼 기록해야 호출자 포인터가 실제로 바뀝니다. 이 패턴을 이해하면 이후 동적 메모리, 연결 리스트 헤드 변경, 파서 상태 반환 같은 주제가 훨씬 쉬워집니다.
정리하면, 이중 포인터는 “문법이 어려운 특별 기능”이 아니라 “수정 대상이 포인터 변수인 상황”을 표현하는 정직한 타입입니다. *의 개수를 억지로 외우기보다, 데이터 흐름을 따라가면서 ‘지금 내가 바꾸는 것이 값인지, 포인터인지, 포인터를 담은 포인터인지’를 구분하면 헷갈림이 크게 줄어듭니다.
기본 사용
예제 1) 이중 포인터의 최소 동작 확인
#include <stdio.h>
int main(void) {
int x = 10;
int y = 99;
int *p = &x;
int **pp = &p;
printf("초기: x=%d, *p=%d, **pp=%d\n", x, *p, **pp);
**pp = 20; // x 값 변경
*pp = &y; // p가 가리키는 대상 변경 (x -> y)
**pp = 77; // 이제 y 값 변경
printf("변경 후: x=%d, y=%d, *p=%d, **pp=%d\n", x, y, *p, **pp);
return 0;
}
설명:
**pp는 최종 데이터(int 값)에 접근한다.*pp는 포인터 변수p자체를 의미한다.- 따라서
*pp = &y는 “포인터 재지정”,**pp = 77은 “최종 값 수정”이다.
예제 2) 함수에서 포인터를 생성해 돌려주기
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int make_message(char **out) {
if (out == NULL) return 0;
const char *src = "Hello, double pointer";
size_t len = strlen(src);
char *buf = (char *)malloc(len + 1);
if (buf == NULL) return 0;
memcpy(buf, src, len + 1);
*out = buf; // 호출자 포인터를 실제로 변경
return 1;
}
int main(void) {
char *msg = NULL;
if (!make_message(&msg)) {
fprintf(stderr, "메시지 생성 실패\n");
return 1;
}
printf("msg=%s\n", msg);
free(msg);
return 0;
}
설명:
make_message가char *out을 받았다면, 함수 내부 대입은 복사본만 바꾼다.char **out을 받아*out에 주소를 써야 호출자msg가 갱신된다.- 할당 책임(누가 free 하는지)을 인터페이스 문서에 반드시 명시해야 한다.
예제 3) 문자열 배열(char **) 순회와 argv 감각
#include <stdio.h>
void print_words(char **words) {
if (words == NULL) return;
for (int i = 0; words[i] != NULL; ++i) {
printf("%d: %s\n", i, words[i]);
}
}
int main(void) {
char *langs[] = {"C", "Python", "Rust", NULL};
print_words(langs); // 배열 decay -> char **
return 0;
}
설명:
words[i]는 문자열 시작 주소(char *)다.- 종료 표식으로
NULL센티넬을 두면 길이 인자 없이도 순회 가능하다. - 같은 패턴이
int main(int argc, char **argv)에서 그대로 사용된다.
자주 하는 실수
실수 1) int **를 모든 2차원 배열에 그대로 적용
- 원인: “별이 두 개니까 2차원”으로 단순화해서 이해함.
- 해결: 연속 2차원 배열은
int (*a)[COLS]계열 시그니처를 사용하고, 행 포인터 배열 구조와 구분한다.
실수 2) *pp와 **pp의 의미를 섞어서 사용
- 원인: 역참조 단계별 대상(포인터 vs 값)을 구분하지 않음.
- 해결: 디버깅 시 타입을 말로 읽어본다.
*pp는int *,**pp는int라는 식으로 확인한다.
실수 3) 동적 할당 후 소유권/해제 책임을 문서화하지 않음
- 원인: “일단 동작”에만 집중하고 계약을 남기지 않음.
- 해결: 함수 주석에 “성공 시 *out에 할당 주소 저장, 호출자가 free”를 명시하고, 실패 시
*out을 건드리지 않는 규칙을 유지한다.
실무 패턴
- out-parameter 함수는 기본 골격을 통일한다: 입력 검증 → 임시 포인터 할당/구성 → 성공 시
*out = tmp커밋. - 실패 경로에서는 호출자 상태를 망치지 않도록
*out을 함부로 덮어쓰지 않는다. char **목록은 길이(count) 기반인지NULL센티넬 기반인지 하나만 선택해 팀 규칙으로 고정한다.const를 적극 적용한다. 읽기 전용 문자열 목록이라면const char *const *items같은 형태로 의도를 명확히 한다.- API 문서에 메모리 소유권(allocate/free 주체), 에러 코드, 부분 실패 동작을 반드시 적는다.
오늘의 결론
한 줄 요약: 이중 포인터는 “포인터 자체를 바꿔야 하는 상황”을 표현하는 타입이며, 문자열 배열·생성 함수·메모리 소유권 설계에서 핵심 역할을 한다.
연습문제
int create_int(int **out, int value)함수를 작성하세요. 내부에서 int 하나를 동적 할당하고 value를 저장한 뒤 호출자에게 전달하세요. 실패 시 메모리 누수가 없도록 처리하세요.void free_words(char **words, size_t n)함수를 작성하세요. 각words[i]를 free 한 뒤 마지막에words도 free 하는 패턴을 구현해 보세요.print_env_like(char **items)함수를 만들어KEY=VALUE목록을 출력하세요.NULL센티넬 종료 방식과count인자 방식 두 버전을 모두 구현해 비교하세요.
이전 강의 정답
지난 28강(포인터 산술, const 포인터) 연습문제 예시 정답 요약:
find_first(const int *arr, size_t n, int target, const int **out_ptr)
- 정답 포인트:
- 입력 검증:
if (!arr || !out_ptr) return 0; - 순회 중
arr[i] == target를 찾으면*out_ptr = &arr[i]; return 1; - 못 찾으면
*out_ptr = NULL; return 0;
- 입력 검증:
clamp_all(int *arr, size_t n, int min_v, int max_v)
- 정답 포인트:
- 포인터 순회:
for (int *p = arr, *end = arr + n; p < end; ++p) *p < min_v면*p = min_v,*p > max_v면*p = max_v
- 포인터 순회:
const int *vsint * const데모
- 정답 포인트:
const int *p는*p수정 금지,p재지정 가능int * const p는p재지정 금지,*p수정 가능
실습 환경/재현 정보
- 컴파일러: Apple clang 17.x 또는 GCC 13+
- 컴파일 옵션:
-std=c11 -Wall -Wextra -pedantic -O2 - 실행 환경: macOS (arm64) / Linux (x86_64)
- 재현 체크:
clang -std=c11 -Wall -Wextra -pedantic -O2 lesson29.c -o lesson29- 예제 1에서
*pp와**pp변경 결과를 출력으로 확인 - 예제 2에서 free 주석을 일부러 제거해 누수 점검 도구(ASan/Valgrind)로 확인
- 예제 3에서 종료
NULL을 빼보며 왜 센티넬 계약이 중요한지 리뷰