
솔직히 고백하자면, 저도 주니어 시절에는 '포인터(Pointer)가 무조건 빠르다'는 맹신을 가지고 있었습니다. 데이터를 복사하는 비용을 아껴야 한다는 강박 때문에 int 같은 작은 변수조차 습관적으로 주소값으로 넘기곤 했죠. 하지만 최근 C와 C++ 확장에서 클로저(Closure) 구현체의 성능을 분석한 벤치마크 결과를 보면서, 10년 넘게 쌓아온 제 직관이 때로는 틀릴 수도 있다는 사실을 다시금 깨달았습니다. 오늘은 C언어 환경에서 함수에 상태(State)를 전달하는 여러 방법론과 그 성능 차이, 특히 우리가 놓치기 쉬운 '간접 참조의 비용'에 대해 이야기해보려 합니다.
우리가 흔히 C에서 클로저와 유사한 동작, 즉 함수에 특정 컨텍스트나 데이터를 함께 전달해야 할 때 사용하는 방법은 크게 두 가지로 나뉩니다. 하나는 구조체에 데이터를 담아 함수 인자로 넘기는 '일반 함수(Normal Functions)' 방식이고, 다른 하나는 static이나 thread_local 변수를 활용해 함수 시그니처를 유지하면서 상태를 공유하는 방식입니다. 이번 벤치마크에서는 이들을 비교했는데, 가장 흥미로운 지점은 바로 '데이터를 어떻게 참조하느냐'에서 발생했습니다.
많은 개발자가 참고하는 로제타 코드(Rosetta Code) 스타일의 구현을 봅시다. 이 방식은 재귀 호출이나 상태 유지를 위해 구조체 안에 int* k와 같이 포인터를 담아 값을 참조합니다. 저를 포함한 많은 분이 "값을 복사하지 않고 원본을 가리키니 효율적이겠지"라고 생각할 겁니다. 하지만 벤치마크 결과는 충격적이었습니다. 포인터를 사용한 이 방식의 성능이 너무나도 떨어져서, 로그 스케일(Log Scale)로 그래프를 그리지 않으면 다른 지표들이 보이지 않을 정도였으니까요.
반면, 단순히 int k를 구조체에 값으로 포함해 매번 복사해서 넘기는 '일반 함수' 방식은 훨씬 뛰어난 성능을 보여주었습니다. 원인은 바로 '간접 로드(Indirect Load)'에 있습니다. 컴파일러 입장에서 생각해보면 이해가 쉽습니다. 구조체 포인터를 따라가서, 다시 그 안의 k 포인터를 따라가 실제 값을 가져오는 과정은 CPU 파이프라인에 부담을 줄 뿐만 아니라, 컴파일러가 수행할 수 있는 강력한 최적화 기법들을 방해합니다. 메모리의 특정 위치를 제자리에서 수정(in-place modification)하는 것이 빠를 것이라는 우리의 막연한 기대와 달리, 현대의 CPU와 컴파일러는 명시적인 값 전달과 지역적인 데이터 처리를 훨씬 더 효율적으로 다룹니다.
물론 qsort 같은 레거시 API와의 호환성을 위해 함수 시그니처를 void* 없이 유지해야 한다면 static이나 thread_local 변수를 사용하는 것이 대안이 될 수 있습니다. Normal Functions (Static) 방식은 스레드 안전성(Thread-safety)을 포기하는 대신 빠른 속도를 제공하고, thread_local은 안전성을 보장하면서도 준수한 성능을 냅니다. 하지만 이들 역시 결국은 데이터를 어떻게 접근하느냐의 문제로 귀결됩니다.
결국 "포인터는 빠르고 복사는 느리다"라는 명제는 현대 컴퓨팅 환경에서 더 이상 절대적인 진리가 아닙니다. 구조체를 통한 간접 참조가 늘어날수록, 그리고 그 깊이가 깊어질수록 우리는 컴파일러가 코드를 최적화할 기회를 스스로 박탈하고 있는 셈입니다. 람다(Lambda)나 클로저를 직접 구현하거나 FFI(Foreign Function Interface)를 다룰 때, 불필요한 포인터 사용을 줄이고 값(Value) 그 자체를 다루는 것이 성능상 훨씬 유리할 수 있습니다.
이번 분석을 통해 저 역시 코드를 작성할 때 '기계적인 효율성'보다는 '컴파일러가 이해하기 쉬운 구조'를 먼저 고민해야겠다는 다짐을 했습니다. 여러분도 관습적으로 사용하던 포인터나 구조체 설계가 혹시 성능의 병목이 되고 있지는 않은지, 한 번쯤 의심해보고 측정해보는 시간을 가져보시길 권합니다. 엔지니어링에 영원한 정답은 없으니까요.


