C++ 클린 코드 기초: const, noexcept, ]로 인터페이스 의도 명확히 하기

C++ 클린 코드 기초: const, noexcept, ]로 인터페이스 의도 명확히 하기

이 글의 핵심

C++ 클린 코드 기초: const, noexcept, ]로 인터페이스 의도 명확히 하기에 대한 실전 가이드입니다.

들어가며: 의도를 코드에 녹이기

”이 함수는 부작용이 있나요? 예외를 던지나요?”

31번 시리즈까지 배운 문법과 패턴을 어떻게 배치하고 연결할 것인가가 38번의 핵심입니다. 그 첫걸음은 인터페이스에 의도를 분명히 적는 것입니다.
const, noexcept, [[nodiscard]]는 컴파일러와 다음에 코드를 읽는 사람에게 “이 함수/변수가 무엇을 하지 않는지, 무엇을 보장하는지”를 알려 줍니다. 실수로 반환값을 무시하거나, 예외 안전성을 깨는 코드를 컴파일 단계에서 줄일 수 있습니다.

이 글에서 다루는 것:

  • const correctness(const를 일관되게 써서 “수정하지 않음”을 표현하는 코딩 스타일): 읽기 전용·수정 금지 표현으로 버그와 오용 방지
  • noexcept: 예외를 던지지 않음을 계약으로 명시 — 이동·최적화·RAII와의 관계
  • [[nodiscard]]: 반환값 무시 경고로 API 오용 방지

문제 시나리오: 인터페이스가 불명확할 때 겪는 일

시나리오 1: const 없이 넘긴 Config가 어디선가 수정됨

문제: process(const Config& cfg)로 넘겼는데, 내부에서 cfg를 수정하는 함수를 호출해 버그가 발생했습니다. const가 없으면 “읽기만 한다”는 보장이 없어, 호출자는 안전하게 참조로 넘기기 어렵습니다.

해결: const Config&로 받고, 내부에서 호출하는 모든 함수도 const 멤버로 선언하면 컴파일러가 수정 시도를 막아 줍니다.

시나리오 2: vector::resize 시 복사만 선택되어 느려짐

문제: 커스텀 타입을 std::vector에 넣었는데, resizepush_back 시 이동 대신 복사가 선택되어 성능이 떨어졌습니다. 이동 생성자에 noexcept가 없으면 표준 라이브러리가 “예외가 날 수 있으니” 복사를 선호하기 때문입니다.

해결: 이동 생성자·이동 대입·소멸자에 noexcept를 붙이면 std::vector가 이동을 적극적으로 사용합니다.

시나리오 3: init() 반환값을 무시해 초기화 실패를 놓침

문제: bool init()이 실패 시 false를 반환하는데, 호출자가 init();만 쓰고 결과를 확인하지 않아 프로덕션에서 초기화 실패가 숨겨졌습니다.

해결: [[nodiscard]] bool init()으로 선언하면 반환값을 무시할 때 컴파일러가 경고(또는 오류)를 냅니다.

시나리오 4: create()의 unique_ptr을 무시해 리소스 누수

문제: std::unique_ptr<Resource> create()의 반환값을 무시하면 할당된 리소스가 해제되지 않아 메모리 누수가 발생합니다.

해결: [[nodiscard]] std::unique_ptr<Resource> create()로 선언하면 create(); 단독 호출 시 경고가 나옵니다.

개념을 잡는 비유

optional값이 비어 있을 수도 있는 상자, string_view·span원본 문자열·배열의 별명 카드처럼 소유하지 않고 범위만 가리킵니다. RAII·unique_ptr자동문처럼 스코프를 나가면 자원을 닫습니다.


목차

  1. const correctness
  2. noexcept
  3. [[nodiscard]]
  4. 자주 발생하는 에러와 해결법
  5. 프로덕션 패턴
  6. 완전한 클린 코드 예제
  7. 성능 고려사항
  8. 정리

1. const correctness

읽기 전용임을 명시하기

  • const 멤버 함수는 “이 객체의 논리적 상태를 바꾸지 않는다”는 계약입니다. 컴파일러는 const 객체에서 비const 멤버 호출을 막고, 스레드 안전성을 논할 때도 “읽기만 한다”는 힌트가 됩니다.
  • const 참조/포인터 매개변수는 “이 함수는 인자를 수정하지 않는다”를 선언합니다. 호출자는 복사 비용 없이 넘기면서도 값이 바뀌지 않음을 신뢰할 수 있습니다.
flowchart TD
    subgraph const_usage["const 사용처"]
        A[멤버 함수 const] --> A1["객체 상태 변경 없음"]
        B[매개변수 const&] --> B1["인자 수정 없음"]
        C[반환 const] --> C1["수정 불가 반환"]
    end
    A1 --> D[컴파일러가 보장]
    B1 --> D
    C1 --> D

getconst가 붙어 있으면 “이 멤버 함수는 객체 상태를 바꾸지 않는다”는 계약이 되어, const Config&로 받은 cfg에서 get만 호출할 수 있고 set은 컴파일 에러가 납니다. 호출자는 processConfig를 넘길 때 “값이 바뀌지 않는다”고 신뢰할 수 있어, 복사 없이 참조로 넘기기 쉬워집니다.

class Config {
public:
    std::string get(const std::string& key) const;  // 상태 변경 없음
    void set(const std::string& key, std::string value);  // 상태 변경
};

void process(const Config& cfg) {
    auto v = cfg.get("timeout");  // OK
    // cfg.set("x", "y");         // 컴파일 에러: const 객체에서 비const 호출 불가
}

const 멤버 함수의 오버로딩

같은 이름의 함수를 const/비const로 오버로딩하면, const 객체에서는 const 버전이, 비const 객체에서는 비const 버전이 호출됩니다. operator[]처럼 “읽기”와 “쓰기”를 구분할 때 유용합니다.

class StringBuffer {
    std::string data_;
public:
    // 비const: 수정 가능한 참조 반환
    char& operator { return data_[i]; }
    // const: 읽기 전용, const 객체에서 호출
    const char& operator const { return data_[i]; }
};

void use(const StringBuffer& buf) {
    char c = buf[0];   // const operator[] 호출
    // buf[0] = 'x';   // 컴파일 에러: const 참조로 수정 불가
}

mutable: 논리적 const vs 물리적 const

mutable은 “논리적으로는 const이지만 물리적으로는 쓸 수 있는” 멤버에만 제한적으로 사용합니다. 캐시, 락, 통계 카운터처럼 “객체의 논리적 상태”에는 영향이 없지만 내부 구현상 변경이 필요한 경우에 씁니다.

class CachedLookup {
    std::map<std::string, int> cache_;
    mutable std::mutex mutex_;  // 락은 "논리적 상태"가 아님
public:
    int get(const std::string& key) const {
        std::lock_guard<std::mutex> lock(mutex_);  // mutable이므로 const에서 수정 가능
        auto it = cache_.find(key);
        return it != cache_.end() ? it->second : -1;
    }
};

주의: mutable을 남용하면 “const라고 믿었는데 내부가 바뀌는” 혼란을 줄 수 있으므로, 캐시·락·로깅 등 명확한 용도에만 사용하세요.

const 매개변수: 복사 방지 + 수정 금지

값으로 받으면 복사 비용이 들고, 수정하지 않을 인자는 const&로 받는 것이 관례입니다. 포인터도 const T*로 “가리키는 대상을 수정하지 않음”을 표현할 수 있습니다.

// ❌ 나쁜 예: 불필요한 복사
void process(std::string name) {
    // name 복사 발생
}

// ✅ 좋은 예: 참조로 받고 수정하지 않음
void process(const std::string& name) {
    // 복사 없음, name 수정 불가
}

// 포인터: const T* = "가리키는 대상 수정 안 함"
void parse(const char* data, size_t len);

const 반환

포인터나 참조를 반환할 때, 호출자가 수정하지 못하게 하려면 const T& 또는 const T*로 반환합니다.

class Container {
    std::vector<int> data_;
public:
    const std::vector<int>& getData() const { return data_; }
    // 호출자가 getData().push_back(1) 같은 수정을 할 수 없음
};

2. noexcept

예외를 던지지 않음을 계약으로

  • noexcept는 “이 함수는 예외를 던지지 않는다”는 계약입니다. std::move 등 이동 연산에 noexcept를 붙이면 표준 컨테이너가 이동을 더 적극적으로 사용해 성능이 나아질 수 있습니다.
  • noexcept(false) 또는 생략은 “예외를 던질 수 있다”는 뜻입니다. 소멸자·이동 연산은 기본적으로 noexcept를 고려하는 것이 좋습니다.
flowchart LR
    subgraph no_noexcept["noexcept 없음"]
        A1["vector resize"] --> A2{이동 생성자}
        A2 -->|예외 가능| A3[복사 선택]
    end
    subgraph with_noexcept["noexcept 있음"]
        B1["vector resize"] --> B2{이동 생성자}
        B2 -->|예외 없음| B3[이동 선택]
    end

이동 생성자에서 std::exchange(other.data_, nullptr)로 원본의 포인터를 가져오고 원본은 nullptr로 비워 두면, 소멸자가 delete[]를 한 번만 호출하게 됩니다. noexcept를 붙여 두면 vector::resize 등에서 복사 대신 이동이 선택되어 성능에 유리합니다.

class Buffer {
public:
    Buffer(size_t size) : data_(new char[size]), size_(size) {}
    
    Buffer(Buffer&& other) noexcept
        : data_(std::exchange(other.data_, nullptr)),
          size_(std::exchange(other.size_, 0)) {}
    
    ~Buffer() noexcept { delete[] data_; }
    
private:
    char* data_;
    size_t size_;
};

왜 소멸자에 noexcept? 스택 언와인딩 중에 예외가 발생하면 std::terminate가 호출됩니다. 소멸자에서 예외를 던지지 않도록 설계하고, noexcept로 명시하면 “이 소멸자는 안전하다”는 계약이 됩니다.

swap과 기본 연산

swap은 보통 예외를 던지지 않습니다. 이동 가능한 타입끼리 포인터만 바꾸면 되기 때문입니다. noexcept를 붙이면 std::sort 등에서 더 효율적인 구현이 선택될 수 있습니다.

class Resource {
public:
    void swap(Resource& other) noexcept {
        std::swap(handle_, other.handle_);
    }
private:
    void* handle_;
};

조건부 noexcept: noexcept(expr)

템플릿에서 “T의 이동이 noexcept이면 이 함수도 noexcept”처럼 조건부로 표현할 수 있습니다. std::vector의 이동 생성자도 내부적으로 이런 패턴을 사용합니다.

template<typename T>
class Optional {
    T value_;
    bool has_value_;
public:
    Optional(Optional&& other) noexcept(std::is_nothrow_move_constructible_v<T>)
        : value_(std::move(other.value_))
        , has_value_(other.has_value_) {
        other.has_value_ = false;
    }
};

noexcept(expr)에서 exprtrue이면 noexcept, false이면 예외를 던질 수 있습니다. std::is_nothrow_move_constructible_v<T>로 T의 이동 생성자가 noexcept인지 확인합니다.

noexcept와 std::vector의 동작

std::vector는 재할당 시 요소를 새 버퍼로 옮길 때, 이동 생성자가 noexcept이면 이동을, 아니면 복사를 사용합니다. 복사는 예외 발생 시 원본을 유지할 수 있지만, 이동은 “이미 옮겼는데 예외가 나면” 복구가 어렵기 때문입니다.

// T의 이동 생성자가 noexcept이면 → vector는 이동 사용 (빠름)
// T의 이동 생성자가 noexcept가 아니면 → vector는 복사 사용 (안전하지만 느림)
std::vector<Buffer> vec;
vec.push_back(Buffer(1024));  // Buffer 이동 생성자 noexcept → 이동 선택

3. [[nodiscard]]

반환값 무시 방지

  • [[nodiscard]]가 붙은 함수의 반환값을 무시하면 컴파일러가 경고(또는 오류)를 냅니다. “에러 코드”나 “새로 할당된 리소스”를 반환하는 API에서 호출자가 결과를 확인하지 않고 넘어가는 실수를 막을 수 있습니다.
flowchart TD
    A[""nodiscard"] 함수 호출"] --> B{반환값 사용?}
    B -->|예| C[OK]
    B -->|아니오| D[컴파일 경고/오류]
    D --> E[버그 조기 발견]

init()이 실패 시 false를 반환하는데 호출자가 무시하면 초기화 실패를 놓칠 수 있고, create()unique_ptr을 무시하면 리소스 누수가 납니다.

[[nodiscard]] bool init();
[[nodiscard]] std::unique_ptr<Resource> create();

void bad_usage() {
    init();           // 경고: 반환값 무시
    auto p = create(); // OK
    create();          // 경고: 반환값(스마트 포인터) 무시
}

void good_usage() {
    if (!init()) return;
    auto p = create();
}

enum class / 구조체에 [[nodiscard]] (C++20)

C++20부터 enum class구조체[[nodiscard]]를 붙이면, 해당 타입을 반환하는 모든 함수에 일괄 적용할 수 있습니다. 에러 코드 타입에 쓰면 효과적입니다.

[[nodiscard]] enum class ErrorCode {
    Ok,
    NotFound,
    PermissionDenied
};

ErrorCode openFile(const std::string& path);  // 반환값 무시 시 경고

// 구조체에도 적용 가능
struct [[nodiscard]] Result {
    int value;
    bool success;
};

[[nodiscard]]를 붙이면 좋은 함수

함수 유형예시이유
에러 코드 반환bool init(), int connect()실패 시 처리 누락 방지
리소스 반환unique_ptr<T> create()누수 방지
계산 결과double compute()결과 사용 누락 방지
optional/expectedoptional<T> find()값 없음 처리 누락 방지

4. 자주 발생하는 에러와 해결법

에러 1: const 객체에서 비const 멤버 호출

증상: error: passing 'const X' as 'this' argument discards qualifiers

원인: const X&로 받은 객체에서 const가 아닌 멤버 함수를 호출했습니다.

// ❌ 잘못된 코드
class Config {
public:
    std::string get(const std::string& key);  // const 누락
};

void use(const Config& cfg) {
    cfg.get("x");  // 컴파일 에러
}

해결:

// ✅ 올바른 코드
class Config {
public:
    std::string get(const std::string& key) const;  // const 추가
};

에러 2: const 멤버에서 멤버 수정 시도

증상: error: assignment of member 'X::cache_' in read-only object

원인: const 멤버 함수 안에서 멤버 변수를 수정하려 했습니다. 캐시처럼 “논리적 const”에 해당하면 mutable을 사용합니다.

// ❌ 잘못된 코드
class Cache {
    std::map<std::string, int> cache_;
public:
    int get(const std::string& key) const {
        cache_[key] = 42;  // 에러: const 멤버에서 수정
        return cache_[key];
    }
};

해결:

// ✅ 올바른 코드: 캐시는 논리적으로 const에 해당
class Cache {
    mutable std::map<std::string, int> cache_;
public:
    int get(const std::string& key) const {
        return cache_[key];  // 캐시 갱신이 필요하면 mutable 사용
    }
};

에러 3: vector가 이동 대신 복사 사용 — 성능 저하

증상: std::vector에 커스텀 타입을 넣을 때 resize/push_back이 느립니다.

원인: 이동 생성자에 noexcept가 없어 표준 라이브러리가 복사를 선택했습니다.

// ❌ 잘못된 코드
class Buffer {
public:
    Buffer(Buffer&& other)  // noexcept 없음
        : data_(other.data_), size_(other.size_) {
        other.data_ = nullptr;
    }
};

해결:

// ✅ 올바른 코드
class Buffer {
public:
    Buffer(Buffer&& other) noexcept
        : data_(std::exchange(other.data_, nullptr)),
          size_(std::exchange(other.size_, 0)) {}
};

에러 4: 반환값 무시로 초기화 실패 놓침

증상: init()이 실패해도 프로그램이 계속 진행되어 나중에 크래시합니다.

원인: bool init()의 반환값을 확인하지 않았습니다.

// ❌ 잘못된 코드
bool init() {
    return connect_to_db();
}

void main_loop() {
    init();  // 실패해도 무시됨
    run();
}

해결:

// ✅ 올바른 코드
[[nodiscard]] bool init() {
    return connect_to_db();
}

void main_loop() {
    if (!init()) {
        log_error("DB 연결 실패");
        return;
    }
    run();
}

에러 5: unique_ptr 반환값 무시 — 리소스 누수

증상: 메모리 사용량이 계속 증가합니다.

원인: create()이 반환하는 unique_ptr을 무시했습니다.

// ❌ 잘못된 코드
std::unique_ptr<Connection> create() {
    return std::make_unique<Connection>();
}

void setup() {
    create();  // 반환된 포인터가 버려짐 → 누수
}

해결:

// ✅ 올바른 코드
[[nodiscard]] std::unique_ptr<Connection> create() {
    return std::make_unique<Connection>();
}

void setup() {
    auto conn = create();  // 경고 없음, 올바른 사용
}

5. 프로덕션 패턴

패턴 1: RAII 클래스의 표준 형태

리소스를 관리하는 클래스는 소멸자·이동 연산에 noexcept를 붙이고, 복사가 의미 있으면 복사 생성/대입을, 아니면 삭제합니다.

class FileHandle {
public:
    explicit FileHandle(const char* path);
    ~FileHandle() noexcept;
    
    FileHandle(FileHandle&& other) noexcept;
    FileHandle& operator=(FileHandle&& other) noexcept;
    
    FileHandle(const FileHandle&) = delete;
    FileHandle& operator=(const FileHandle&) = delete;
    
    [[nodiscard]] bool isValid() const noexcept;
    [[nodiscard]] size_t read(void* buf, size_t len) const;
};

패턴 2: 팩토리 함수는 [[nodiscard]]

객체를 생성해 반환하는 함수는 반환값 무시 시 리소스 누수나 미사용 객체가 되므로 [[nodiscard]]를 붙입니다.

[[nodiscard]] std::unique_ptr<Database> createDatabase(const Config& cfg);
[[nodiscard]] std::expected<Session, Error> openSession(const std::string& id);

패턴 3: 읽기 전용 API는 const 일관

조회·검색·계산만 하는 API는 멤버 함수에 const를 붙이고, 매개변수는 const&로 받습니다.

class UserRepository {
public:
    [[nodiscard]] std::optional<User> findById(int id) const;
    [[nodiscard]] std::vector<User> search(const std::string& query) const;
    [[nodiscard]] size_t count() const noexcept;
};

패턴 4: swap은 noexcept

표준 관례에 따라 swap은 예외를 던지지 않습니다. noexcept로 명시하면 제네릭 알고리즘에서 이점을 얻습니다.

void swap(MyType& a, MyType& b) noexcept {
    a.swap(b);
}

6. 완전한 클린 코드 예제

Before: 의도가 불명확한 코드

class Database {
    Connection* conn;
public:
    string query(string sql) {
        return conn->execute(sql);
    }
    void close() { conn->disconnect(); }
};

문제점:

  • query가 상태를 바꾸는지 불명확
  • 예외를 던지는지 불명확
  • 반환값을 무시해도 되는지 불명확
  • string 값 전달로 불필요한 복사
  • raw pointer로 소유권 불명확

After: 의도가 명확한 코드

#include <memory>
#include <string>
#include <expected>

enum class DbError { NotConnected, QueryFailed, Timeout };

class Database {
    std::unique_ptr<Connection> conn_;
public:
    [[nodiscard]] std::expected<QueryResult, DbError>
    query(const std::string& sql) const noexcept {
        if (!conn_) return std::unexpected(DbError::NotConnected);
        return conn_->execute(sql);
    }
    
    void close() noexcept {
        if (conn_) conn_->disconnect();
    }
};

개선 사항:

  • const: query가 DB 상태를 바꾸지 않음을 명시
  • noexcept: 예외를 던지지 않음을 보장
  • [[nodiscard]]: 쿼리 결과를 무시하면 안 됨
  • const std::string&: 문자열 복사 방지
  • std::expected: 에러를 명시적으로 처리
  • std::unique_ptr: 소유권 명확

통합 예제: 설정 관리자

#include <string>
#include <unordered_map>
#include <optional>

class ConfigManager {
    std::unordered_map<std::string, std::string> config_;
    
public:
    [[nodiscard]] std::optional<std::string>
    get(const std::string& key) const noexcept {
        auto it = config_.find(key);
        return it != config_.end()
            ? std::optional<std::string>(it->second)
            : std::nullopt;
    }
    
    void set(const std::string& key, std::string value) {
        config_[key] = std::move(value);
    }
    
    [[nodiscard]] bool has(const std::string& key) const noexcept {
        return config_.find(key) != config_.end();
    }
    
    void clear() noexcept {
        config_.clear();
    }
};

void processConfig(const ConfigManager& cfg) {
    if (auto timeout = cfg.get("timeout")) {
        // *timeout 사용
    }
}

7. 성능 고려사항

const 참조 vs 값 전달

작은 타입(int, double, 포인터)은 값 전달이 참조보다 빠를 수 있습니다. 큰 타입(std::string, std::vector)은 const&로 받는 것이 일반적으로 유리합니다.

// 작은 타입: 값 전달 OK
void setValue(int x);
void setFlag(bool f);

// 큰 타입: const& 권장
void process(const std::string& s);
void process(const std::vector<int>& v);

noexcept와 인라인

noexcept 자체가 직접적인 성능 이득을 주지는 않지만, 컴파일러가 “예외 경로를 만들 필요 없다”고 판단해 인라인·코드 생성에 도움이 될 수 있습니다. 더 중요한 것은 std::vector 등 표준 라이브러리가 이동을 선택하게 하는 효과입니다.

[[nodiscard]]와 성능

[[nodiscard]]는 컴파일 타임 속성이라 런타임 오버헤드가 없습니다. 반환값 무시로 인한 버그(초기화 실패, 누수)를 막아 간접적으로 성능과 안정성에 기여합니다.

const·noexcept·nodiscard 적용 우선순위

우선순위적용 대상이유
1소멸자 noexcept예외 안전성의 기본
2이동 연산 noexceptvector 등 컨테이너 성능
3에러 코드/리소스 반환 [[nodiscard]]버그·누수 방지
4읽기 전용 멤버 함수 const인터페이스 명확화
5매개변수 const&복사 방지

8. 정리

도구역할
const읽기 전용·수정 금지 — 의도 명확화, 오용 방지
noexcept예외 없음 계약 — 이동·RAII·최적화에 유리
[[nodiscard]]반환값 무시 경고 — API 오용·버그 조기 발견

38번의 시작으로, const / noexcept / [[nodiscard]]를 습관화하면 “어떻게 배치하고 연결할지”의 기반인 명확한 인터페이스를 갖출 수 있습니다.

실무 코드 리뷰에서 자주 지적되는 사항

const 누락: std::string getName() 대신 std::string getName() const로 써야 const 객체에서도 호출 가능. 읽기 전용 함수는 항상 const를 붙이는 습관이 중요합니다.

noexcept 누락: 이동 생성자·소멸자에 noexcept가 없으면 std::vector 재할당 시 복사가 선택되어 성능이 떨어집니다. 특히 RAII 클래스는 소멸자에 noexcept를 명시하는 것이 좋습니다.

반환값 무시: bool init() 같은 함수의 반환값을 무시하면 초기화 실패를 놓칠 수 있습니다. [[nodiscard]]를 붙이면 컴파일 단계에서 경고가 나와 버그를 조기에 발견할 수 있습니다.

실전 체크리스트: 코드 리뷰 전 자가 점검

const 점검:

  • 멤버 함수가 상태를 바꾸지 않으면 const 붙였는가?
  • 매개변수를 수정하지 않으면 const&로 받았는가?
  • 반환 타입이 수정 불가능하면 const 반환인가?

noexcept 점검:

  • 소멸자에 noexcept가 있는가?
  • 이동 생성자/대입에 noexcept가 있는가?
  • swap, 기본 연산에 noexcept를 고려했는가?

nodiscard 점검:

  • 에러 코드를 반환하는 함수에 [[nodiscard]]가 있는가?
  • 리소스를 반환하는 함수(스마트 포인터 등)에 [[nodiscard]]가 있는가?
  • 계산 결과를 반환하는 순수 함수에 [[nodiscard]]를 고려했는가?

컴파일러 경고로 [[nodiscard]] 위반 잡기

[[nodiscard]] 위반을 오류로 처리하려면 컴파일러 옵션을 설정합니다.

# GCC/Clang: -Werror와 함께 사용
g++ -std=c++17 -Wunused-result -Werror ...

# MSVC: /W4 또는 /WX
cl /std:c++17 /W4 /WX ...

CMake에서는 다음과 같이 설정할 수 있습니다.

if(MSVC)
    add_compile_options(/W4 /WX)
else()
    add_compile_options(-Wunused-result -Werror)
endif()

레거시 코드에 점진적으로 적용하기

기존 코드베이스에 한 번에 적용하기 어렵다면 다음 순서를 권장합니다.

  1. 새로 작성하는 코드부터 const·noexcept·[[nodiscard]]를 습관화
  2. 버그 수정 시 해당 함수에만 추가
  3. 리팩터링 시 관련 모듈 단위로 적용
  4. 코드 리뷰에서 누락된 경우 지적하고 수정 요청
// 리팩터링 전
std::string getConfig(std::string key);

// 리팩터링 후: const&와 const, [[nodiscard]] 추가
[[nodiscard]] std::string getConfig(const std::string& key) const;

실전 예제: 클린 코드 Before/After

Before (의도가 불명확):

class Database {
    Connection* conn;
public:
    string query(string sql) {
        return conn->execute(sql);
    }
    void close() { conn->disconnect(); }
};

After (의도가 명확):

class Database {
    std::unique_ptr<Connection> conn_;
public:
    [[nodiscard]] std::expected<QueryResult, DbError> 
    query(const std::string& sql) const noexcept {
        if (!conn_) return std::unexpected(DbError::NotConnected);
        return conn_->execute(sql);
    }
    
    void close() noexcept { 
        if (conn_) conn_->disconnect(); 
    }
};

개선 사항:

  • const: query가 DB 상태를 바꾸지 않음을 명시
  • noexcept: 예외를 던지지 않음을 보장
  • [[nodiscard]]: 쿼리 결과를 무시하면 안 됨
  • const&: 문자열 복사 방지
  • expected: 에러를 명시적으로 처리

표준 라이브러리에서의 활용

표준 라이브러리 자체가 const·noexcept·nodiscard를 광범위하게 사용합니다. 참고할 만한 예시는 다음과 같습니다.

  • std::vector::size()size_t size() const noexcept — 크기 조회는 const이며 예외를 던지지 않음
  • std::optional::value()[[nodiscard]] T& value() (C++20) — 값이 없을 때 예외를 던지므로 반환값 무시 방지
  • std::make_unique[[nodiscard]] unique_ptr<T> make_unique(...) — 스마트 포인터 반환값 무시 시 누수 방지

자신의 API를 설계할 때 표준 라이브러리의 선언을 참고하면 일관된 스타일을 유지할 수 있습니다.

시나리오별 적용 가이드

시나리오constnoexcept[[nodiscard]]
getter/settergetter에 constoptional 반환 시 고려
팩토리 함수내부에서 예외 안 던지면필수
RAII 클래스소멸자·이동에 필수
에러 코드 반환필수
swap필수
연산자 오버로드읽기만 하면 const기본 연산은 noexcept

단위 테스트에서의 활용

const·noexcept·[[nodiscard]]는 테스트 작성에도 도움이 됩니다.

  • const: 테스트에서 mock 객체를 const로 넘겨 “수정하지 않는” 경로만 검증할 수 있습니다.
  • noexcept: static_assert(noexcept(std::declval<T>().swap(std::declval<T>().)))처럼 컴파일 타임에 검증할 수 있습니다.
  • [[nodiscard]]: 테스트에서 assert(init()) 대신 init()만 쓰는 실수를 방지합니다.
// 테스트에서 const 활용
void test_process_readonly() {
    const Config cfg = createTestConfig();
    process(cfg);  // const Config&에서 process 호출됨 — 수정 불가 검증
}

const_cast는 언제 쓸까?

const_cast는 const를 제거하는 위험한 연산입니다. 레거시 APIconst를 받지 않는데 우리가 const 객체만 가지고 있을 때 등, 드문 경우에만 사용합니다. 새로 작성하는 코드에서는 const_cast 없이 설계하는 것이 좋습니다.

// ❌ 나쁜 예: const를 무시하고 수정
void bad(const Config& cfg) {
    const_cast<Config&>(cfg).set("x", "y");  // 위험! 원본 수정
}

// ✅ 좋은 예: API를 const로 수정하거나, 비const 복사본 사용
void good(const Config& cfg) {
    Config mutable_copy = cfg;
    mutable_copy.set("x", "y");  // 복사본만 수정
}

다음 단계로 나아가기

이 글을 마스터했다면:

  • RAII 심화: 리소스 관리 패턴
  • 예외 안전성: 강한 보장, 기본 보장
  • API 설계: 오용하기 어려운 인터페이스

관련 글: RAII(#6-4), 예외 안전성(#8-2)


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

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

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

C++ const correctness, noexcept 사용법, nodiscard 예제, C++ 클린 코드, C++ 인터페이스 설계, C++ 베스트 프랙티스, const 멤버 함수, 예외 안전성, C++ 코드 품질 등으로 검색하시면 이 글이 도움이 됩니다.

자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. 새 클래스나 API를 설계할 때, 그리고 코드 리뷰 시 const·noexcept·[[nodiscard]]를 점검하면 됩니다. 레거시 코드는 점진적으로 적용하는 것이 좋습니다. 먼저 새로 작성하는 코드부터 습관화하고, 리팩터링 시 기존 함수에 추가하는 방식으로 진행하세요.

Q. const를 붙이면 성능에 영향이 있나요?

A. const 자체는 런타임 오버헤드가 없습니다. 다만 const&로 받으면 복사를 피할 수 있어 큰 객체 전달 시 성능에 유리합니다. 컴파일러가 const를 활용해 최적화할 여지도 생깁니다.

Q. 모든 함수에 noexcept를 붙여야 하나요?

A. 아니요. 예외를 던질 수 있는 함수에 noexcept를 붙이면, 예외 발생 시 std::terminate가 호출됩니다. 소멸자, 이동 연산, swap처럼 “예외를 던지지 않도록 설계된” 함수에만 붙이는 것이 안전합니다.

Q. [[nodiscard]]를 void 반환 함수에 붙일 수 있나요?

A. void를 반환하는 함수에는 의미가 없습니다. [[nodiscard]]는 “반환값을 사용해야 하는” 함수에만 적용됩니다. void 함수는 반환값이 없으므로 무시할 것이 없습니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.

한 줄 요약: const·noexcept·nodiscard로 인터페이스 의도를 드러내면 버그가 줄어듭니다. 다음으로 다형성·variant(#38-2)를 읽어보면 좋습니다.

참고 자료

오늘 배운 것 한눈에

  • const: 읽기 전용 멤버 함수·매개변수·반환에 사용 → 의도 명확화, 오용 방지
  • noexcept: 소멸자·이동 연산·swap에 사용 → vector 등에서 이동 선택, 성능·안정성 향상
  • [[nodiscard]]: 에러 코드·리소스·계산 결과 반환 함수에 사용 → 반환값 무시로 인한 버그·누수 방지

세 가지를 함께 습관화하면 “이 함수는 무엇을 하지 않는지, 무엇을 보장하는지”가 코드만 봐도 드러나, 리뷰와 유지보수가 쉬워집니다.

다음 글에서는 다형성 설계와 std::variant 활용을 다룹니다.

다음 글: [C++ 아키텍처 #38-2] 현대적 다형성 설계: 상속(Inheritance) 대신 합성(Composition)과 std::variant 활용

이전 글: [C++23 프리뷰 #37-1] 남들보다 먼저 써보는 C++23 핵심 기능


관련 글

  • C++ std::optional·std::variant 완벽 가이드 | nullptr 대신 타입 안전하게
  • C++ 현대적 다형성 설계: 상속 대신 합성·variant
  • C++ std::string_view·std::span 완벽 가이드 | 제로카피 뷰·댕글링 방지
  • C++ 인터페이스 설계와 PIMPL: 컴파일 의존성을 끊고 바이너리 호환성(ABI) 유지하기 [#38-3]
  • C++ 남들보다 먼저 써보는 C++23 핵심 기능 [#37-1]