POOOLING FOREST
C++의 '공유 상태' 악몽, 불변성(Immutability)으로 끊어내기 - C++ 멀티스레드 환경의 공유 상태 문제를 immer 라이브러리와 불변성(Immutability)을 통해 해
Engineering & Tech

C++의 '공유 상태' 악몽, 불변성(Immutability)으로 끊어내기

C++ 멀티스레드 환경의 공유 상태 문제를 immer 라이브러리와 불변성(Immutability)을 통해 해결한 경험을 공유합니다. 락 경합 없이 안전하고 우아하게 상태를 관리하는 방법을 소개합니다.

송찬영

CTO

안녕하세요. 풀링포레스트 CTO 송찬영입니다.

솔직히 고백하자면, 저는 한동안 '동시성(Concurrency)'이라는 단어만 들어도 식은땀이 흘렀습니다. 얼마 전, 서비스의 핵심 엔진을 고도화하면서 겪었던 아찔한 경험 때문입니다. 수많은 스레드가 하나의 거대한 상태(State) 객체를 동시에 참조하고 수정해야 하는 상황이었죠. 처음에는 교과서적인 방법으로 접근했습니다. std::mutex를 걸고, lock을 쪼개고, 세밀하게 제어하려 노력했죠.

하지만 트래픽이 몰리자 시스템은 비명을 질렀습니다. 락 경합(Lock Contention)으로 인한 성능 저하는 물론이고, 아주 미세한 타이밍 이슈로 인해 데이터 정합성이 깨지는 'Race Condition'이 발생했습니다. 새벽 3시에 터진 알람을 보고 모니터 앞에 앉아, 도대체 어디서 상태가 꼬였는지 디버거를 노려보던 그때의 막막함은 다시 겪고 싶지 않습니다.

C++ 개발자라면 누구나 한 번쯤 겪는 딜레마일 겁니다. "안전하게 짜려면 느려지고, 빠르게 짜려면 위험하다."

우리는 이 문제를 해결하기 위해 근본적인 질문을 던져야 했습니다. "왜 우리는 데이터를 수정(Mutation)하려고만 할까?" 이 질문에 대한 답을 찾는 과정에서 도입하게 된 것이 바로 immer라는 라이브러리입니다. 오늘은 우리가 왜 이 C++ 라이브러리에 주목했는지, 그리고 이것이 어떻게 엔지니어링의 패러다임을 바꿨는지 이야기해보려 합니다.

락(Lock) 없이 상태를 공유하는 방법

보통 C++에서 std::vectorstd::map을 쓸 때, 우리는 데이터가 제자리에서 변경되는 것에 익숙합니다. 성능을 위해 포인터나 레퍼런스로 원본을 공유하죠. 문제는 이 공유된 원본을 누군가 건드리는 순간 지옥문이 열린다는 것입니다.

그렇다고 매번 데이터를 통째로 복사(Deep Copy)해서 넘기자니, 메모리와 CPU 비용이 감당이 안 됩니다. 이때 등장하는 개념이 영속적 데이터 구조(Persistent Data Structures)입니다. immer는 이 개념을 C++에서 우아하게 구현해낸 라이브러리입니다.

원리는 간단하지만 강력합니다. 데이터를 수정하면 원본을 건드리는 대신, 수정된 부분만 새로 만들고 나머지는 원본과 공유하는 것입니다. 마치 Git이 커밋을 관리하는 방식과 비슷합니다. 이를 '구조적 공유(Structural Sharing)'라고 부르죠.

// 예시: 기존 벡터는 그대로 두고, 새로운 요소가 추가된 새 벡터를 반환합니다.
const auto v0 = immer::vector<int>{};
const auto v1 = v0.push_back(13);

assert(v0.size() == 0); // v0는 여전히 비어있음
assert(v1.size() == 1); // v1은 13을 가지고 있음

이 코드를 처음 봤을 때, 팀원 중 한 명은 "이거 매번 복사하면 엄청 느린 거 아니에요?"라고 반문했습니다. 하지만 immer는 내부적으로 고도로 튜닝된 트리 구조(Relaxed Radix Balanced Trees 등)를 사용해, 복사 비용을 거의 '0'에 수렴하게 만듭니다.

값 의미론(Value Semantics)이 가져온 평화

immer를 도입하고 나서 우리 팀의 코드에는 놀라운 변화가 생겼습니다. 가장 큰 변화는 '방어적 코딩'이 사라졌다는 점입니다.

예전에는 어떤 함수에 벡터를 넘길 때 const &로 넘기면서도, 혹시 다른 스레드에서 const_cast로 장난치지 않을까, 혹은 내가 읽는 도중에 누가 지우지 않을까 전전긍긍했습니다. 하지만 immer의 데이터 구조는 불변(Immutable)입니다. 한번 만들어진 데이터는 절대 변하지 않습니다.

덕분에 우리는 멀티스레드 환경에서 락(Lock)을 걷어낼 수 있었습니다. 읽기 작업은 아무런 동기화 없이 수행하면 됩니다. 데이터가 변하지 않으니까요. 쓰기 작업이 필요하면 새로운 버전을 만들어서 원자적(Atomic)으로 포인터만 교체하면 끝입니다.

이것이 바로 값 의미론(Value Semantics)의 힘입니다. 숫자 1이 영원히 1이듯, 우리가 만든 데이터 객체도 영원히 그 값을 유지합니다. 상태 변화를 추적하기 위해 React나 Redux 같은 패턴을 C++ 백엔드 로직에 적용할 수 있게 된 것이죠. 실제로 immer 제작자는 Redux 스타일의 상태 관리를 C++에서 가능하게 하는 'Lager'라는 라이브러리도 함께 제안하고 있습니다.

은탄환은 없습니다, 하지만...

물론 immer가 모든 상황의 정답은 아닙니다. 극도로 짧은 지연 시간(Low Latency)이 요구되는 HFT(고빈도 매매) 시스템이나, 임베디드 환경처럼 바이트 단위의 메모리 최적화가 필요한 곳에서는 std::vector의 날 것 그대로의 성능이 여전히 필요할 수 있습니다. immer는 내부적으로 트리 구조를 사용하기 때문에 포인터 추적 비용이 발생하니까요.

하지만 우리가 운영하는 대부분의 비즈니스 로직, 특히 복잡한 상태를 다루는 서버 애플리케이션에서는 '디버깅할 수 없는 고성능'보다 '예측 가능한 적정 성능'이 훨씬 중요합니다.

C++ 생태계는 그동안 "성능을 위해 안전을 희생하라"는 암묵적인 압박을 받아왔습니다. 하지만 모던 C++과 immer 같은 라이브러리의 등장은, 이제 우리도 안전하고 우아한 아키텍처를 선택할 수 있다는 것을 보여줍니다.

지금 여러분의 프로젝트에서 std::mutex 때문에 골머리를 앓고 있거나, 스파게티처럼 꼬인 상태 관리로 고통받고 있다면, 잠시 멈추고 생각해보세요. "이 데이터를 정말 수정해야 할까? 아니면 새로운 버전으로 대체하면 될까?"

이 작은 사고의 전환이, 여러분의 칼퇴근을 보장해 줄지도 모릅니다. 기술은 결국 사람을 위해 존재하는 것이니까요.

지금 읽으신 내용, 귀사에 적용해보고 싶으신가요?

상황과 목표를 알려주시면 가능한 옵션과 현실적인 도입 경로를 제안해드립니다.