C++ std::optional vs 포인터 | "null 처리" 안전하고 명확한 선택

C++ std::optional vs 포인터 | "null 처리" 안전하고 명확한 선택

이 글의 핵심

C++ std::optional vs 포인터에 대한 실전 가이드입니다.

들어가며: “null을 어떻게 표현해야 하나요?"

"포인터 대신 더 안전한 방법이 있나요?”

C++에서 “값이 없음”을 표현하는 방법은 여러 가지입니다. 포인터의 nullptrstd::optional이 대표적입니다.

비유로 말씀드리면, optional“오늘 도시락 있음/없음”을 도시락 통 자체에 스티커로 표시하는 것이고, 포인터“저기 탁자를 가리키는 손가락”입니다. 손가락은 다형성·비소유 참조에는 좋지만, 잘못 쓰면 댕글링이 납니다.

언제 std::optional을, 언제 포인터를 쓰나요?

관점std::optional포인터(또는 스마트 포인터)
성능보통 스택, 힙 없이 값 부재 표현간접 참조·캐시 미스 비용
사용성값 없음이 타입에 드러남nullptr 체크 관례에 의존
적용 시나리오반환값·로컬에서 “없을 수 있음”다형성, 비소유 참조, 배열·연속 메모리
// 포인터 방식
int* findValue(const std::vector<int>& vec, int target) {
    for (auto& val : vec) {
        if (val == target) {
            return &val;  // ❌ 댕글링 포인터 위험
        }
    }
    return nullptr;
}

// optional 방식 (C++17)
std::optional<int> findValue(const std::vector<int>& vec, int target) {
    for (auto val : vec) {
        if (val == target) {
            return val;  // ✅ 값 복사
        }
    }
    return std::nullopt;  // 값 없음
}

이 글에서 다루는 것:

  • std::optional vs 포인터 차이
  • 타입 안전성과 성능
  • 사용 시나리오
  • 실전 패턴

목차

  1. std::optional vs 포인터 차이
  2. 타입 안전성
  3. 성능 비교
  4. 사용 시나리오
  5. 정리

1. std::optional vs 포인터 차이

비교표

항목std::optionalT*
저장 위치스택힙 (일반적)
소유권소유참조
null 표현std::nulloptnullptr
값 접근value(), **, ->
타입 안전성✅ 높음❌ 낮음
메모리 할당없음있음 (동적 할당 시)
크기sizeof(T) + 1sizeof(void*)
C++ 버전C++17모든 버전

기본 사용법

// std::optional
std::optional<int> opt1;  // 값 없음
std::optional<int> opt2 = 42;  // 값 있음
std::optional<int> opt3 = std::nullopt;  // 값 없음

if (opt2) {
    std::cout << *opt2 << '\n';  // 42
}

// 포인터
int* ptr1 = nullptr;  // null
int value = 42;
int* ptr2 = &value;  // 값 가리킴

if (ptr2 != nullptr) {
    std::cout << *ptr2 << '\n';  // 42
}

2. 타입 안전성

optional: 타입 안전

// ✅ optional: 안전한 접근
std::optional<int> getValue(bool success) {
    if (success) {
        return 42;
    }
    return std::nullopt;
}

int main() {
    auto result = getValue(false);
    
    // ✅ has_value()로 확인
    if (result.has_value()) {
        std::cout << result.value() << '\n';
    }
    
    // ✅ value_or()로 기본값
    std::cout << result.value_or(0) << '\n';  // 0
    
    // ❌ value() 호출 시 예외
    try {
        std::cout << result.value() << '\n';
    } catch (const std::bad_optional_access& e) {
        std::cout << "값 없음: " << e.what() << '\n';
    }
}

포인터: 타입 불안전

// ❌ 포인터: 불안전한 접근
int* getValue(bool success) {
    if (success) {
        static int value = 42;
        return &value;
    }
    return nullptr;
}

int main() {
    int* result = getValue(false);
    
    // ❌ nullptr 체크 없이 접근 → 크래시
    // std::cout << *result << '\n';  // Segmentation fault
    
    // ✅ nullptr 체크 (수동)
    if (result != nullptr) {
        std::cout << *result << '\n';
    }
}

3. 성능 비교

메모리 레이아웃

// optional: 스택에 저장
struct Data {
    int x, y, z;
};

std::optional<Data> opt;
// 메모리: sizeof(Data) + 1 = 13바이트 (패딩 포함 16바이트)

// 포인터: 힙에 저장 (동적 할당 시)
Data* ptr = new Data{1, 2, 3};
// 메모리: 포인터 8바이트 + 힙 12바이트 = 20바이트

벤치마크

#include <benchmark/benchmark.h>

// optional
static void BM_Optional(benchmark::State& state) {
    for (auto _ : state) {
        std::optional<int> opt = 42;
        int x = opt.value_or(0);
        benchmark::DoNotOptimize(x);
    }
}
BENCHMARK(BM_Optional);

// 포인터 (스택)
static void BM_PointerStack(benchmark::State& state) {
    for (auto _ : state) {
        int value = 42;
        int* ptr = &value;
        int x = (ptr != nullptr) ? *ptr : 0;
        benchmark::DoNotOptimize(x);
    }
}
BENCHMARK(BM_PointerStack);

// 포인터 (힙)
static void BM_PointerHeap(benchmark::State& state) {
    for (auto _ : state) {
        int* ptr = new int(42);
        int x = (ptr != nullptr) ? *ptr : 0;
        delete ptr;
        benchmark::DoNotOptimize(x);
    }
}
BENCHMARK(BM_PointerHeap);

결과 (GCC 13, -O3):

BM_Optional         1 ns
BM_PointerStack     1 ns
BM_PointerHeap    100 ns  (힙 할당 오버헤드)

4. 사용 시나리오

optional 사용: 반환값

// ✅ optional: 값이 없을 수 있는 반환값
std::optional<std::string> getEnv(const char* name) {
    const char* value = std::getenv(name);
    if (value != nullptr) {
        return std::string(value);
    }
    return std::nullopt;
}

int main() {
    auto path = getEnv("PATH");
    if (path) {
        std::cout << "PATH: " << *path << '\n';
    } else {
        std::cout << "PATH 없음\n";
    }
}

optional 사용: 선택적 매개변수

// ✅ optional: 선택적 매개변수
void connect(const std::string& host, 
             std::optional<int> port = std::nullopt) {
    int actualPort = port.value_or(80);  // 기본값 80
    std::cout << "연결: " << host << ":" << actualPort << '\n';
}

int main() {
    connect("example.com");        // 포트 80
    connect("example.com", 443);   // 포트 443
}

optional 사용: 초기화 지연

// ✅ optional: 초기화 지연
class Database {
    std::optional<Connection> conn_;
    
public:
    void connect(const std::string& host) {
        conn_ = Connection(host);  // 필요할 때 초기화
    }
    
    void query(const std::string& sql) {
        if (!conn_) {
            throw std::runtime_error("연결 안 됨");
        }
        conn_->execute(sql);
    }
};

포인터 사용: 다형성

// ✅ 포인터: 다형성
class Shape {
public:
    virtual void draw() = 0;
    virtual ~Shape() = default;
};

class Circle : public Shape {
public:
    void draw() override {
        std::cout << "Circle\n";
    }
};

void render(Shape* shape) {  // 포인터 필요
    if (shape != nullptr) {
        shape->draw();
    }
}

int main() {
    Circle circle;
    render(&circle);
}

포인터 사용: 큰 객체 참조

// ✅ 포인터: 큰 객체 참조
struct BigData {
    int data[1000000];
};

void process(const BigData* data) {  // 포인터로 전달 (복사 없음)
    if (data != nullptr) {
        // 처리
    }
}

int main() {
    BigData data;
    process(&data);
}

실전 예시

예시 1: 설정 파일 파싱

// ✅ optional: 설정값
class Config {
    std::map<std::string, std::string> values_;
    
public:
    std::optional<int> getInt(const std::string& key) const {
        auto it = values_.find(key);
        if (it != values_.end()) {
            try {
                return std::stoi(it->second);
            } catch (...) {
                return std::nullopt;
            }
        }
        return std::nullopt;
    }
    
    std::optional<std::string> getString(const std::string& key) const {
        auto it = values_.find(key);
        if (it != values_.end()) {
            return it->second;
        }
        return std::nullopt;
    }
};

int main() {
    Config config;
    
    auto port = config.getInt("port").value_or(8080);
    auto host = config.getString("host").value_or("localhost");
    
    std::cout << "서버: " << host << ":" << port << '\n';
}

예시 2: 검색 함수

// ✅ optional: 검색 결과
template <typename T>
std::optional<T> find(const std::vector<T>& vec, 
                      std::function<bool(const T&)> predicate) {
    for (const auto& item : vec) {
        if (predicate(item)) {
            return item;
        }
    }
    return std::nullopt;
}

int main() {
    std::vector<int> numbers = {1, 2, 3, 4, 5};
    
    auto result = find(numbers,  { return x > 3; });
    
    if (result) {
        std::cout << "찾음: " << *result << '\n';  // 4
    } else {
        std::cout << "못 찾음\n";
    }
}

예시 3: 캐시

// ✅ optional: 캐시 조회
class Cache {
    std::map<std::string, std::string> data_;
    
public:
    std::optional<std::string> get(const std::string& key) const {
        auto it = data_.find(key);
        if (it != data_.end()) {
            return it->second;
        }
        return std::nullopt;
    }
    
    void set(const std::string& key, const std::string& value) {
        data_[key] = value;
    }
};

int main() {
    Cache cache;
    cache.set("user:1", "Alice");
    
    auto user = cache.get("user:1");
    if (user) {
        std::cout << "사용자: " << *user << '\n';
    }
    
    auto missing = cache.get("user:2");
    std::cout << "사용자: " << missing.value_or("없음") << '\n';
}

정리

optional vs 포인터 선택

상황사용
값이 없을 수 있는 반환값optional
선택적 매개변수optional
초기화 지연optional
다형성포인터
큰 객체 참조포인터
소유권 공유shared_ptr
배열포인터

핵심 규칙

  1. 값 타입 → optional
  2. 참조 타입 → 포인터
  3. 타입 안전성 우선 → optional
  4. 다형성 필요 → 포인터

체크리스트

  • 값이 없을 수 있는가?
  • 소유권이 필요한가?
  • 다형성이 필요한가?
  • 타입 안전성이 중요한가?

같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

  • C++ std::optional 기초 | 완벽 가이드
  • C++ 포인터 기초 | Pointer 가이드
  • C++ shared_ptr vs unique_ptr | 스마트 포인터
  • C++ null 처리 | nullptr 가이드

이 글에서 다루는 키워드 (관련 검색어)

std::optional, optional vs 포인터, null 처리, C++17, 타입 안전성 등으로 검색하시면 이 글이 도움이 됩니다.

실전 팁

실무에서 바로 적용할 수 있는 팁입니다.

디버깅 팁

  • optional은 value() 호출 시 예외를 던집니다
  • has_value()로 값 존재 여부를 확인하세요
  • value_or()로 기본값을 제공하세요

성능 팁

  • optional은 스택에 저장되어 빠릅니다
  • 큰 객체는 optional보다 포인터가 효율적입니다
  • optional은 힙 할당이 없어 캐시 효율이 좋습니다

코드 리뷰 팁

  • nullptr 반환을 optional로 바꾸세요
  • 포인터 nullptr 체크를 optional로 대체하세요
  • 다형성이 필요 없으면 optional을 사용하세요

자주 하는 실수

실수 1: optional 참조

// ❌ 실수: optional<T&>는 없음
std::optional<int&> opt;  // 컴파일 에러

// ✅ 포인터 사용
int* ptr = nullptr;

// ✅ 또는 reference_wrapper
std::optional<std::reference_wrapper<int>> opt;

실수 2: value() 예외 처리 누락

// ❌ 실수: 예외 처리 없음
std::optional<int> opt;
int x = opt.value();  // ❌ 예외 던짐!

// ✅ has_value() 체크
if (opt.has_value()) {
    int x = opt.value();
}

// ✅ value_or() 사용
int x = opt.value_or(0);

실수 3: 불필요한 optional

// ❌ 실수: 항상 값이 있는데 optional 사용
std::optional<int> getId() {
    return 42;  // 항상 값 반환
}

// ✅ 일반 타입 사용
int getId() {
    return 42;
}

실무 트러블슈팅

문제: optional 체인

증상:

std::optional<User> getUser(int id);
std::optional<Address> getAddress(const User& user);

// 중첩 체크 필요
auto user = getUser(123);
if (user) {
    auto address = getAddress(*user);
    if (address) {
        std::cout << address->street << '\n';
    }
}

해결: and_then() 사용 (C++23)

getUser(123)
    .and_then( { return getAddress(user); })
    .and_then( {
        std::cout << addr.street << '\n';
        return std::optional<void>{};
    });

문제: optional 성능

증상: 큰 객체를 optional로 반환 시 복사 오버헤드

해결:

// ❌ 큰 객체 복사
std::optional<BigData> getData() {
    BigData data;  // 1MB
    // ...
    return data;  // 복사
}

// ✅ unique_ptr 사용
std::unique_ptr<BigData> getData() {
    auto data = std::make_unique<BigData>();
    // ...
    return data;  // 이동
}

// ✅ 또는 out 파라미터
bool getData(BigData& out) {
    // ...
    return true;  // 성공 여부
}

성능 비교 상세

메모리 레이아웃

struct Data {
    int x, y, z;  // 12바이트
};

// optional
std::optional<Data> opt;
// 메모리: 12 + 1 + 3(패딩) = 16바이트

// 포인터
Data* ptr = nullptr;
// 메모리: 8바이트 (포인터)
// + 힙 할당 시 12바이트 (Data)

캐시 효율성

// ✅ optional: 캐시 친화적
std::vector<std::optional<int>> vec(1000);
// 연속 메모리 (캐시 효율 높음)

// ❌ 포인터: 캐시 미스
std::vector<int*> vec(1000);
// 포인터들은 연속이지만, 실제 데이터는 흩어짐

베스트 프랙티스

1. 함수 반환 가이드

// ✅ optional: 값이 없을 수 있음
std::optional<User> findUser(int id);

// ✅ 포인터: 외부 객체 참조
User* getCurrentUser();

// ✅ 예외: 반드시 있어야 함
User& getUser(int id);  // 없으면 예외

2. 함수 파라미터 가이드

// ✅ optional: 선택적 파라미터
void connect(const std::string& host,
             std::optional<int> port = std::nullopt);

// ✅ 포인터: nullable 파라미터
void process(const Data* data);  // nullptr 가능

// ✅ 참조: 반드시 필요
void process(const Data& data);  // nullptr 불가

3. 멤버 변수 가이드

class Widget {
    // ✅ optional: 지연 초기화
    std::optional<Connection> conn_;
    
    // ✅ 포인터: 다형성
    std::unique_ptr<Renderer> renderer_;
    
    // ✅ 값: 항상 존재
    std::string name_;
};

실무 시나리오

시나리오 1: 데이터베이스 조회

// ✅ 실무 예시: DB 조회
class UserRepository {
public:
    std::optional<User> findById(int id) {
        auto result = db_.query("SELECT * FROM users WHERE id = ?", id);
        if (result.empty()) {
            return std::nullopt;
        }
        return User::fromRow(result[0]);
    }
    
    std::vector<User> findAll() {
        // 항상 벡터 반환 (빈 벡터 가능)
        auto results = db_.query("SELECT * FROM users");
        std::vector<User> users;
        for (auto& row : results) {
            users.push_back(User::fromRow(row));
        }
        return users;
    }
};

// 사용
auto user = repo.findById(123);
if (user) {
    std::cout << "사용자: " << user->name << '\n';
} else {
    std::cout << "사용자 없음\n";
}

시나리오 2: HTTP 응답

// ✅ 실무 예시: HTTP 헤더
class HttpResponse {
    std::map<std::string, std::string> headers_;
    
public:
    std::optional<std::string> getHeader(const std::string& name) const {
        auto it = headers_.find(name);
        if (it != headers_.end()) {
            return it->second;
        }
        return std::nullopt;
    }
    
    std::string getHeaderOr(const std::string& name, 
                           const std::string& defaultValue) const {
        return getHeader(name).value_or(defaultValue);
    }
};

// 사용
HttpResponse response;
auto contentType = response.getHeader("Content-Type");
if (contentType) {
    std::cout << "타입: " << *contentType << '\n';
}

auto encoding = response.getHeaderOr("Content-Encoding", "identity");

시나리오 3: 설정 파일 파싱

// ✅ 실무 예시: 설정 파싱
class ConfigParser {
public:
    std::optional<int> getInt(const std::string& key) {
        auto value = getValue(key);
        if (!value) {
            return std::nullopt;
        }
        
        try {
            return std::stoi(*value);
        } catch (...) {
            return std::nullopt;
        }
    }
    
    std::optional<bool> getBool(const std::string& key) {
        auto value = getValue(key);
        if (!value) {
            return std::nullopt;
        }
        
        if (*value == "true" || *value == "1") {
            return true;
        } else if (*value == "false" || *value == "0") {
            return false;
        }
        return std::nullopt;
    }
    
private:
    std::optional<std::string> getValue(const std::string& key);
};

// 사용
ConfigParser config;
int port = config.getInt("server.port").value_or(8080);
bool debug = config.getBool("debug").value_or(false);

C++23 개선사항

monadic operations

// C++23: and_then, or_else, transform
std::optional<int> getValue();

auto result = getValue()
    .and_then( { return std::optional<int>(x * 2); })
    .or_else( { return std::optional<int>(0); })
    .transform( { return x + 1; });

마치며

std::optional값이 없을 수 있는 상황타입 안전하게 표현합니다.

핵심 원칙:

  1. 값 타입 → optional
  2. 참조 타입 → 포인터
  3. 타입 안전성 우선

실무 팁:

  • DB 조회, HTTP 응답, 설정 파싱에 활용
  • value_or()로 기본값 제공
  • 큰 객체는 포인터 고려

nullptr 대신 std::optional을 사용해 더 안전한 코드를 작성하세요.

다음 단계: optional을 이해했다면, C++ std::variant 가이드에서 더 깊이 배워보세요.


관련 글

  • C++ std::variant vs union |
  • C++ std::any vs void* |
  • C++ optional·variant·any |
  • C++ new vs malloc |
  • C++ string vs string_view |