별은 어디에 붙여야 할까? C 프로그래머들의 조용한 종교 전쟁
별을 어디에 붙일지를 두고 벌이는 사소한 스타일 논쟁처럼 보인다. 하지만 const와 typedef가 끼어들면 선언의 의미가 조용히 뒤집히고, 그 대가는 세 번째 릴리스 후보에서 치르게 된다.

새 버전의 세 번째 릴리스 후보가 나올 즈음, 그 선언을 의심하는 사람은 아무도 없었다.
코드 리뷰도, 컴파일도, 테스트도, 앞선 두 번의 릴리스 후보도 무사히 넘긴 줄이었다. 너무 짧아서 눈에 걸릴 틈도 없이 스쳐 지나가는 한 줄이었다.
const INTPTR p = &value;
어떤 개발자는 이 줄을 “상수 정수를 가리키는 포인터”로 읽었다. 컴파일러는 “값을 바꿀 수 있는 정수를 가리키는 상수 포인터”로 읽었다.
둘 다 그럴듯하다. 하지만 맞는 해석은 하나뿐이었다. (당연히 컴파일러가 옳다. 하지만 컴파일러와 개발자는 해석에 관한 대화를 나누지 않는다. 개발자의 오해를 바로잡을 기회가 없는 것이다.)
선언 자체는 흠잡을 데가 없었으니 컴파일은 됐다. 두 해석이 갈리는 분기는 한 번도 실행되지 않았으니 테스트도 통과했다. 세 번째 릴리스 후보에서 마침내 그 분기가 실행됐고, 한 줄 속에 숨어 있던 착각이 드러났다.
문제의 시작은 아무도 눈여겨보지 않은 별 * 하나였다.
이야기는 몇 년 전으로 거슬러 올라간다.
점심을 배불리 먹고 졸음과 싸우던 오후, 종이컵에 탄 달달한 맥심 커피를 홀짝이며 오래된 C 코드를 훑고 있었다. 화면 곳곳에 낯익은 별 * 들이 박혀 있었다. 같은 별인데 자리마다 뜻이 달랐다. 수식 안에서는 곱셈 기호였고, 포인터 이름 앞에서는 그 주소에 있는 값을 꺼내는 역참조 연산자였으며, 선언문 안에서는 그 둘과 전혀 다른 뜻, ’이 변수는 포인터다’라는 표시였다. 이 글의 골칫거리는 바로 이 세 번째 경우다.
가비지 컬렉터가 메모리를 알아서 치워주고 참조도 언어가 안전하게 관리해주는 환경에서 개발을 시작한 사람에게는, C 포인터가 안전장치라는 게 없던 시절의 유물처럼 보일지도 모른다. 포인터에는 객체의 주소가 담긴다. 포인터를 역참조하면 그 주소에 있는 값을 읽거나 바꿀 수 있다.
포인터를 역참조할 때 그 주소가 잘못됐거나, 이미 해제된 곳이거나, 그저 프로그래머가 생각한 주소가 아니라면 프로그램은 Segmentation Fault를 내며 죽는다. 그나마 이건 양반이다. 고약한 버그는 죽지도 않고 어딘가를 조용히 망가뜨린다.
C는 프로그래머에게 강력한 열쇠를 쥐여주고, 그 열쇠로 어느 문을 여는지는 거의 참견하지 않는다. 그리고 C 프로그래머들은 이 열쇠를 어떻게 적을지를 두고 길고도 끈질긴 종교 전쟁을 벌여왔다.
발단은 이렇게까지 다툴 일인가 싶을 정도로 쩨쩨한 질문이다.
그 질문은 바로 “별 * 은 어디에 붙여야 할까?“이다.
int* p;
int *p;
타입파는 별을 int 쪽에 붙인다. p는 정수가 아니라 정수 포인터이니, int* 전체가 하나의 타입으로 보여야 한다는 것이다. 왼쪽에서 오른쪽으로 자연스럽게 읽힌다는 장점도 있다.
변수파는 별을 p 쪽에 붙인다. 그 유명한 K&R에서 사용되는 스타일이고, C의 선언자(declarator) 문법과도 맞아떨어진다. *p라는 식의 타입이 int이니 int *p라고 쓰는 게 맞다는 논리다.
어느 쪽으로 쓰든 컴파일러가 만드는 타입은 같다. 컴파일러는 공백에 관심이 없다. 그러니 이 논쟁은 컴파일러가 아니라 나중에 그 코드를 읽을 사람을 위한 것이다. 그렇다고 프로그래머들이 핏대를 덜 세웠느냐 하면, 전혀 그렇지 않다.
C로 자료구조와 알고리즘 책을 쓰면서 나도 두 진영 사이에서 갈팡질팡했다. 보기엔 타입파 쪽이 깔끔해서 마음이 갔다. 그러다 이런 코드를 치곤 했다.
int* p, q;
포인터 두 개를 선언한 것처럼 보인다. 하지만 아니다.
이 선언은 기본 타입 int로 시작하고, 그 뒤로 선언자 두 개가 따로따로 붙는다. *p는 p를 int 포인터로 만든다. q에는 별이 없으니 그냥 int다.
별 * 을 타입에 붙여준 건 공백이 아니었다. 내 눈이었다.
그럴 때마다 타이핑을 멈추고 뒷목을 잡았다. 잠깐은 컴파일러를 탓했다. 하지만 컴파일러는 잘못한 게 없었다. 선언을 적힌 그대로 해석했을 뿐, 글자 배치까지 타입 체계의 일부로 쳐주지는 않았다.
둘 다 포인터로 만들고 싶었다면 선언에 그렇게 적었어야 했다.
int *p, *q;
아직 커피를 못 마신 다음 사람을 배려한다면 이쪽이 더 낫다.
int *p;
int *q;
몇 년 뒤, 이직한 회사에서 레거시 코드를 리뷰하다가 제3의 스타일을 만났다.
int * p;
별 양옆에 공백이 하나씩 있었다. 타입에도 변수에도 붙지 않은 모양새였다.
별것 아닌 한 줄 앞에서 한참을 머뭇거렸다. 두 진영이 맺은 휴전 협정일까? 원래 작성자가 끝까지 편을 들지 않은 걸까? 아니면 모두 논쟁에 지쳐 별을 중립 지대에 내버려둔 걸까?
컴파일러는 늘 그렇듯 이런 파벌 싸움에는 관심이 없었다. 컴파일러에겐 int* p도 int *p도 int * p도 모두 같은 선언이다.
“코드가 제품이 아니다. 시스템이 제품이다.”
리뷰하다가 이 말이 떠올랐다. 공백을 어디에 두든 돌아가는 시스템은 달라지지 않는다. 하지만 다음 사람이 선언을 얼마나 빨리 알아보느냐는 달라진다. 그러니 이 논쟁이 아주 쓸데없지는 않았다. 다만 판정을 엉뚱한 쪽에 맡기고 있었다. 이 논쟁의 심판은 컴파일러가 아니라 다음에 그 코드를 읽을 사람이다.
급기야 어떤 코드베이스는 별을 아예 숨겨서 논쟁을 끝내려 했다.
#define INTPTR int *
처음엔 한결 깔끔해보인다.
INTPTR p;
하지만 전처리기는 타입을 만들지 않는다. 토큰을 바꿔 끼울 뿐이다. 변수가 하나만 더 붙어도 예전 문제가 고스란히 돌아온다.
INTPTR p, q;
매크로가 풀리면 컴파일러 눈에는 이렇게 보인다.
int *p, q;
p는 포인터, q는 정수다.
별은 선언에서 사라졌지만 별의 규칙은 그대로 남았다. 별을 숨겨서 달라진 건, 그 규칙이 어디에 적용되는지 프로그래머가 볼 수 없게 됐다는 것 하나뿐이다.
typedef는 다르게 동작한다.
typedef int *INTPTR;
INTPTR p, q;
이번에는 INTPTR이 포인터 타입의 이름이다. p와 q 둘 다 int 포인터가 된다. 여기까지만 보면 논쟁은 끝난 것 같다. 포인터는 깔끔한 이름을 가진 온전한 타입이 됐고, 별은 더 이상 편을 고를 필요가 없다.
그런데 여기에 const가 끼어든다.
먼저 매크로 버전이다.
int value = 0;
#define INTPTR int *
const INTPTR p = &value;
전처리기가 풀어놓으면 이렇게 된다.
const int *p = &value;
p는 다른 곳을 가리키게 할 수 있지만, *p에 값을 대입할 수는 없다. 그 정수는 p를 통해서는 고칠 수 없다.
같은 선언을 typedef로 써보자.
int value = 0;
typedef int *INTPTR;
const INTPTR p = &value;
이번엔 INTPTR이 이미 “int 포인터”라는 온전한 타입의 이름이다. 그러니 const는 포인터 자체에 붙는다. 풀어 쓰면 이 선언과 같다.
int * const p = &value;
이제 p는 다른 곳을 가리키게 할 수 없지만, *p로 정수를 고치는 건 여전히 된다.
화면 속 선언은 거의 그대로인데 뜻은 정반대가 됐다.
매크로 버전에서 const는 포인터가 가리키는 정수를 보호했다. typedef 버전에서는 포인터 자체를 보호하고 정수는 바꿀 수 있게 남겨두었다. 차이를 만든 건 공백이 아니라, INTPTR이 그저 바꿔 끼울 토큰 덩어리냐 온전한 포인터 타입의 이름이냐였다.
세 번째 릴리스 후보에 숨어 있던 것이 바로 이 차이였다.
우리 팀이 넣은 수정 하나에 상수 포인터가 있었다. 그런데 그 선언을 누군가는 ’상수 데이터를 가리키는 포인터’로 읽었다. 바꿀 수 있는 데이터를 가리키는 상수 포인터도 멀쩡한 C라서 컴파일은 됐다. 그 코드 경로를 테스트가 거치지 않아서 실수도 드러나지 않았다. 세 번째 릴리스 후보에 와서야 시스템이 그 경로를 지나갔고, 컴파일러는 처음부터 우리가 읽은 것과 다른 문장을 따르고 있었다는 사실이 그제야 드러났다.
누구도 부주의하지 않았다. 작성자도 리뷰어도 컴파일러도 INTPTR을 저마다 다르게 이해한 채 같은 줄을 읽었을 뿐이다. 그리고 실행 파일에 반영된 건 컴파일러의 이해뿐이었다.
그 일을 겪은 뒤로 스타 * 워즈에서 이기는 데는 흥미를 잃었다.
내 휴전 조건은 일부러 단순하게 잡았다. 변수는 한 줄에 하나씩 선언한다. 토큰 덩어리를 타입처럼 보이게 하는 매크로는 쓰지 않는다. 포인터 typedef는 별을 숨길 만한 가치가 있는 추상화일 때만 쓰고, 그 별칭을 믿기 전에 const가 어디에 붙는지부터 확인한다.
차이가 중요한 곳에서는 두 타입을 그냥 풀어서 쓴다.
const int *data; /* const int를 가리키는 포인터 */
int * const fixed = &value; /* int를 가리키는 const 포인터 */
수십 년이 지났지만 어느 진영이 이겼는지는 여전히 모른다. 컴파일러는 별이 int 옆에 있든 p 옆에 있든 상관하지 않았다. 컴파일러가 따진 건 선언자와 타입 별칭, 그리고 const가 정확히 무엇에 붙어 있느냐였다.
이 종교 전쟁은 애초에 별을 어디에 붙이느냐의 싸움이 아니었다. 1년 뒤 이 코드로 돌아온 프로그래머가 어떤 문장을 떠올리느냐의 싸움이었다. “이 변수는 포인터 타입이다”인가, “이 변수를 역참조하면 정수가 나온다”인가.
별은 타입 옆에 붙어도 되고 변수 옆에 붙어도 된다. 어느 쪽이든 컴파일러는 같은 타입을 만든다. 중요한 건 릴리스 후보 세 개를 날리기 전에, 다음 사람이 이 별이 무엇에 붙어 있는지, const가 무엇을 못 바꾸게 막는지 알아볼 수 있느냐다.