Notice
Recent Posts
250x250
«   2026/02   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
관리 메뉴

일상 코딩

[C++ 스마트포인터 시리즈 3] 독점적 소유 unique_ptr와 실전 파이프라인 본문

C++

[C++ 스마트포인터 시리즈 3] 독점적 소유 unique_ptr와 실전 파이프라인

polarcompass 2026. 1. 1. 06:22
728x90

Step 1에서 '이동(Move)'이라는 개념이 이사짐을 싸는 행위였다면, Step 2: 독점적 소유 unique_ptr는 그 이사짐 박스에 단 한 명의 주인 이름만 적어두는 규칙입니다.

로봇 프레임워크나 고성능 서버(Drogon 등)에서 가장 많이 쓰이는 패턴은 "데이터를 생성한 놈이 처리할 놈에게 박스를 통째로 던져주고 손을 떼는 것"입니다. 이때 발생하는 암호 같은 코드들을 해독해 보겠습니다.


Step 2: 독점적 소유 unique_ptr와 실전 파이프라인

std::unique_ptr는 말 그대로 '유일한 소유권'을 의미합니다. 파이썬처럼 여러 변수가 하나의 객체를 가리키는 것을 원천 봉쇄합니다.

1. 객체의 생성: 왜 new가 아니라 make_unique인가?

C++ 예전 코드에서는 new를 썼지만, 최신 C++(C++14 이상)에서는 암호 같은 std::make_unique를 씁니다.

// 옛날 방식 (위험함: 생성하다 에러 나면 메모리 누수 발생 가능)
std::unique_ptr<RobotFrame> frame(new RobotFrame());

// 현대적 방식 (안전하고 빠름)
// 해석: "RobotFrame을 위한 메모리를 딱 맞게 할당하고, 그 주소를 가진 유일한 관리자를 즉시 만들어라"
auto frame = std::make_unique<RobotFrame>(101, 1.234); 

2. 함수 인자로 넘길 때의 3가지 패턴 (가장 헷갈리는 부분)

주니어 개발자들이 가장 많이 당황하는 "인자 전달" 암호를 해독해 드립니다.

패턴 A: 소유권을 완전히 넘길 때 (가장 권장됨)

함수가 끝난 뒤에도 데이터를 계속 써야 할 때(예: DB 저장, 큐에 삽입) 사용합니다.

// 암호문: void 가공(std::unique_ptr<T> ptr)
// 해석: "이 함수에 들어오는 순간, 원래 주인은 소유권을 잃는다. 이 함수가 끝나면 메모리도 삭제된다."
void moveToQueue(std::unique_ptr<RobotFrame> frame) {
    // 여기서 frame은 이 함수의 지역변수가 됨
    global_queue.push_back(std::move(frame)); // 다시 한번 밀어서 전역 큐로 보냄
}

int main() {
    auto frame = std::make_unique<RobotFrame>();

    // moveToQueue(frame); // 에러! (복사 금지)
    moveToQueue(std::move(frame)); // OK! "난 이제 몰라, 네가 가져가"

    // 이후에 frame을 쓰려고 하면 Segmentation Fault(Null 접근) 발생!
}

패턴 B: 잠깐 빌려줄 때 (Reference 사용)

함수가 실행되는 동안만 데이터를 읽거나 수정하고, 소유권은 내가 계속 갖고 싶을 때입니다.

// 암호문: void 수정(T& frame)
// 해석: "주소값만 알려줄 테니 가서 내용물만 고치고 와. 박스는 내 거야."
void updateSensorData(RobotFrame& frame) {
    frame.timestamp = 9.99; // 원본 수정 가능
}

int main() {
    auto frame = std::make_unique<RobotFrame>();
    updateSensorData(*frame); // *를 붙여서 '박스 안의 내용물'만 전달
    // 함수가 끝나도 frame은 여전히 살아있음
}

3. 실전 응용: 로봇 노드 간의 Zero-copy 데이터 전달

발표자가 강조한 "순수 C++ 프레임워크"의 핵심 로직입니다. 데이터를 복사하지 않고 unique_ptr를 이동시켜 성능을 극대화합니다.

#include <memory>
#include <queue>
#include <mutex>

class SensorNode {
    std::queue<std::unique_ptr<RobotFrame>> buffer;
    std::mutex mtx;

public:
    // 데이터를 받아서 버퍼에 쌓는 함수
    void onDataReceived(std::unique_ptr<RobotFrame> newFrame) {
        std::lock_guard<std::mutex> lock(mtx);

        // 데이터 복사 없이 '주소 관리권'만 큐로 밀어넣음 (Zero-copy)
        buffer.push(std::move(newFrame)); 

        // 이제 newFrame은 nullptr가 됨
    }

    // 데이터를 꺼내서 처리하는 함수
    void process() {
        std::lock_guard<std::mutex> lock(mtx);
        if (!buffer.empty()) {
            // 큐에서 꺼낼 때도 move를 써서 소유권을 꺼내옴
            std::unique_ptr<RobotFrame> work = std::move(buffer.front());
            buffer.pop();

            // 여기서 work를 가지고 연산... (가장 효율적인 방식)
            std::cout << "Processing frame: " << work->id << std::endl;
        }
    }
};

4. Step 2 요약 가이드 (주니어 필독)

상황 코드 작성법 소유권 상태
객체 생성 auto p = std::make_unique<T>(); 내가 유일한 주인
함수에게 소유권 양도 func(std::move(p)); 함수가 주인, 나는 빈털터리
함수에게 내용물만 빌려줌 func(*p); 나는 여전히 주인, 함수는 손님
함수에서 결과로 받기 return p; (move 안 써도 됨) 함수가 나에게 소유권을 선물함

💡 왜 이렇게까지 하나요? (Python/Go와의 차이점)

파이썬에서는 list.append(obj)를 하면 내부적으로 레퍼런스 카운트가 올라가며 메모리가 유지됩니다. 하지만 C++ unique_ptr"이 데이터는 오직 한 곳에서만 관리된다"는 것을 보장하므로, 위와 같이 move를 통해 명확히 소유권을 주고받아야 합니다. 덕분에 가비지 컬렉터 없이도 메모리가 누수되지 않고 즉시(Deterministic) 해제됩니다.


질문자님의 의문은 C++ 포인터를 처음 접할 때 가장 많이 혼동하는 '포인터 변수 자체''포인터가 가리키는 실제 데이터' 사이의 간극 때문입니다.

결론부터 말씀드리면, *frame을 전달하는 것은 "내용물 전체를 복사해서 주는 것"이 아니라, "상자에 든 내용물의 주소(참조)를 알려주는 것"입니다. 파이썬이나 Go의 관점에서 이 암호를 풀어드릴게요.


Step 2-1: 포인터 역참조(*)와 참조(&)의 미스터리 해독

1. 타입의 일치 (Type Matching)

C++은 타입에 매우 엄격합니다. unique_ptr<RobotFrame>RobotFrame은 완전히 다른 타입입니다.

  • frame: "RobotFrame이 들어있는 상자의 주소(관리자)" 그 자체입니다. (타입: std::unique_ptr<RobotFrame>)
  • *frame: "상자 안에 들어있는 실제 로봇 프레임 데이터"입니다. (타입: RobotFrame)

패턴 B의 함수 선언을 다시 보겠습니다.

void updateSensorData(RobotFrame& frame) { ... }

이 함수는 "나는 스마트 포인터 상자 같은 건 모르겠고, 실제 RobotFrame 데이터의 주소(참조)를 줘!"라고 요구하고 있습니다.

따라서 호출할 때 updateSensorData(frame)이라고 하면 "상자(포인터)를 줄게"가 되어 타입이 맞지 않습니다. updateSensorData(*frame)이라고 해야 "상자 안의 내용물(데이터)의 주소를 알려줄게"가 되어 컴파일이 성공합니다.


2. 왜 *를 붙여도 "내용물 전체 복사"가 일어나지 않나요?

이 부분이 가장 오해하기 쉬운 지점입니다.

  • 일반 변수일 때: void func(RobotFrame frame) (인자에 &가 없음)
  • 이때 func(*frame)을 하면 내용물 전체가 복사됩니다. (이사 비용 발생!)
  • 참조(&) 인자일 때: void func(RobotFrame& frame) (인자에 &가 있음)
  • 이때 func(*frame)을 하면, C++ 컴파일러는 "아, 내용물의 메모리 주소만 연결해주면 되는구나"라고 이해합니다.
  • 즉, 전달되는 것은 여전히 8바이트짜리 메모리 주소값뿐입니다.

유튜브 비유로 재해석:

  • frame: 유튜브 영상 관리자 계정 ID (포인터)
  • *frame: 유튜브 영상 그 자체 (데이터)
  • updateSensorData(RobotFrame& frame): "영상 내용(데이터)을 보고 수정할 권한을 줘"라는 요청
  • 결과: *frame을 인자로 넣는 행위는 "이 영상(데이터)의 수정 권한을 줄게"라는 뜻이지, "영상 파일 전체를 다운로드해서 보내줄게"가 아닙니다.

3. 코드 예시로 보는 "암호 해독"

void updateByReference(RobotFrame& data) { // 인자에 &가 있음!
    data.id = 500; // 원본의 주소를 알고 있으므로 직접 수정
}

int main() {
    auto frame = std::make_unique<RobotFrame>(); 
    frame->id = 100;

    // [오해] *를 붙였으니 데이터 덩어리가 복사되어 날아간다? (X)
    // [진실] 함수 인자가 & 타입이므로, "내용물의 주소"만 안전하게 전달된다. (O)
    updateByReference(*frame); 

    std::cout << frame->id; // 출력: 500 (성공적으로 수정됨)
}

4. 왜 포인터(frame)를 직접 안 넘기고 굳이 *frame으로 넘기나요?

시니어들이 이렇게 코드를 짜는 이유는 "안전성""범용성" 때문입니다.

  1. Null 방지: RobotFrame& 인자는 문법적으로 null이 들어올 수 없습니다. 함수 내부에서 if (frame != nullptr) 같은 체크를 안 해도 되니 코드가 깔끔해집니다.
  2. 스마트 포인터 독립성: updateSensorData 함수는 이 데이터가 unique_ptr에 담겨 있는지, shared_ptr에 담겨 있는지, 아니면 그냥 일반 변수인지 알 필요가 없습니다. 그냥 "데이터"만 처리하면 되니까요.

Step 2-1 요약

호출 코드 함수 인자 타입 실제 전달되는 것 비고
func(frame) unique_ptr<T> 소유권 이전 (Move) 메인의 frame은 빈털터리가 됨
func(*frame) T& (Reference) 내용물의 주소 복사 없음. 가장 안전하고 빠름
func(frame.get()) T* (Raw Pointer) 내용물의 주소 null일 가능성이 있을 때 사용

이제 왜 *를 붙여서 넘기는지, 그리고 그것이 대용량 데이터를 복사하는 '멍청한 짓'이 아니라 '영리하게 주소만 알려주는 행위'라는 것이 이해되셨나요?

이 개념이 잡히셨다면, 이제 여러 명이 동시에 영상 링크를 나눠 갖는 Step 3: shared_ptr로 넘어가 보겠습니다.

728x90