const·noexcept·[[nodiscard]]로 C++ 인터페이스 의도 명확히 하기
const, noexcept, [[nodiscard]]는 컴파일러와 다음에 코드를 읽는 사람에게 “이 함수가 무엇을 하지 않는지, 무엇을 보장하는지”를 알려 줍니다. 세 가지 모두 런타임 비용이 거의 없거나 전혀 없으면서, 반환값을 무시하거나 읽기 전용이어야 할 객체를 수정하는 실수를 컴파일 단계에서 잡아 줍니다. 이 글은 인터페이스가 불명확할 때 생기는 문제부터 보고, 세 도구를 차례로 다룹니다.
인터페이스가 불명확할 때 생기는 문제
설정 객체를 process(Config& cfg)처럼 비const 참조로 넘기면, 호출자는 process가 설정을 읽기만 하는지 바꾸는지 시그니처만으로는 알 수 없습니다. 실제로 내부 어딘가에서 값을 바꾸는 함수를 호출하면, 그 변경은 호출자에게 그대로 전파됩니다. const Config&로 받고 내부에서 호출하는 멤버 함수를 모두 const로 선언해 두면 이런 수정 시도는 컴파일 오류가 됩니다.
커스텀 타입을 std::vector에 넣었을 때 재할당이 예상보다 느린 경우도 흔합니다. 이동 생성자에 noexcept가 없고 복사 생성자가 있으면, 표준 라이브러리는 재할당 중 예외 안전성을 지키기 위해 이동 대신 복사를 고릅니다.
bool init()이 실패 시 false를 반환하는데 호출자가 init();만 쓰고 결과를 확인하지 않으면, 초기화 실패가 숨겨진 채 프로그램이 계속 진행됩니다. [[nodiscard]] bool init()으로 선언하면 반환값을 무시할 때 컴파일러가 경고를 냅니다.
const correctness
읽기 전용임을 명시하기
const 멤버 함수는 “이 객체의 관찰 가능한 상태를 바꾸지 않는다”는 계약입니다. 컴파일러는 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
아래 코드에서 get에는 const가 붙어 있으므로 const Config&로 받은 cfg에서 호출할 수 있지만, set은 컴파일 오류가 납니다.
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:
char& operator[](size_t i) { return data_[i]; } // 수정 가능
const char& operator[](size_t i) const { return data_[i]; } // 읽기 전용
};
void use(const StringBuffer& buf) {
char c = buf[0]; // const operator[] 호출
// buf[0] = 'x'; // 컴파일 오류
}
mutable: 논리적 const와 물리적 const
mutable은 객체의 논리적 상태에는 영향이 없지만 구현상 바뀌어야 하는 멤버에 씁니다. 대표적인 예가 락, 캐시, 통계 카운터입니다.
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 함수 안에서 mutable 멤버를 수정한다면, 앞서 말한 “const 호출은 동시에 해도 안전하다”는 기대를 지키기 위해 그 수정도 락이나 원자 연산으로 보호해야 합니다.
const 참조 매개변수
수정하지 않을 큰 인자는 const&로 받는 것이 관례입니다. int, double, 포인터처럼 작은 타입은 값으로 받는 편이 오히려 간단하고 빠를 수 있습니다. 포인터는 const T*로 “가리키는 대상을 수정하지 않음”을 표현합니다.
void process(const std::string& name); // 읽기만 함: 복사 없음
void setName(std::string name); // 저장할 값: 값으로 받아 내부에서 std::move
void parse(const char* data, size_t len); // 가리키는 대상을 수정하지 않음
void setValue(int x); // 작은 타입은 값으로
함수가 인자를 멤버에 저장한다면 const&로 받아 복사하는 것보다 값으로 받아 std::move하는 편이 나을 때가 많습니다. 호출자가 임시 객체를 넘기면 복사 없이 이동만 일어나기 때문입니다.
const 참조 반환
내부 데이터를 참조로 돌려줄 때 호출자가 수정하지 못하게 하려면 const T&로 반환합니다.
class Container {
std::vector<int> data_;
public:
const std::vector<int>& getData() const { return data_; }
// 호출자는 getData().push_back(1) 같은 수정을 할 수 없음
};
const는 얕다: 포인터 멤버 주의
const 멤버 함수 안에서 this는 const 객체를 가리키지만, 멤버가 포인터나 std::unique_ptr이면 const가 되는 것은 포인터 자체뿐이고 가리키는 대상은 아닙니다. 즉 const 함수 안에서도 conn_->execute(...)처럼 대상 객체의 비const 멤버를 호출할 수 있습니다. 이런 멤버를 가진 클래스에서 const는 컴파일러가 강제하는 보장이 아니라 설계자가 지켜야 하는 약속이 됩니다.
const_cast
const_cast는 const를 제거합니다. 원래 const로 선언된 객체를 const_cast로 벗겨서 수정하면 미정의 동작입니다. 비const 객체를 const 참조로 받은 경우라면 수정 자체는 정의된 동작이지만, 호출자가 “수정하지 않는다”고 믿은 계약을 깨므로 마찬가지로 피해야 합니다. 실제로 필요한 경우는 const를 받지 않는 레거시 C API에 읽기만 하는 데이터를 넘길 때 정도입니다.
// 나쁜 예: 계약을 깨고 수정
void bad(const Config& cfg) {
const_cast<Config&>(cfg).set("x", "y");
}
// 수정이 필요하면 복사본을 만들거나, 애초에 비const 참조로 받도록 API를 바꿈
void good(const Config& cfg) {
Config local = cfg;
local.set("x", "y");
}
noexcept
예외를 던지지 않는다는 계약
noexcept는 “이 함수 밖으로 예외가 나가지 않는다”는 계약입니다. 컴파일러는 noexcept 함수 안에서 예외를 던지는 코드를 막지 않지만, 예외가 함수 밖으로 빠져나가려는 순간 std::terminate가 호출되어 프로그램이 종료됩니다. 호출자가 try/catch로 감싸도 잡을 수 없습니다. 그래서 메모리 할당처럼 실패할 수 있는 연산을 하는 함수에는 붙이지 않는 것이 원칙이고, 이동 생성자, 이동 대입, swap처럼 포인터만 바꾸면 되는 연산에 붙입니다.
flowchart LR
subgraph no_noexcept[이동 생성자가 noexcept 아님]
A1["vector 재할당"] --> A2{복사 가능한가}
A2 -->|예| A3[복사 선택]
A2 -->|아니오| A4[이동 선택]
end
subgraph with_noexcept[이동 생성자가 noexcept]
B1["vector 재할당"] --> B3[이동 선택]
end
noexcept와 std::vector 재할당
std::vector가 용량을 늘릴 때는 기존 요소를 새 버퍼로 옮깁니다. 이때 push_back은 강한 예외 보장(실패하면 원래 상태 유지)을 약속하는데, 이동 도중 예외가 나면 이미 옮긴 요소의 원본은 비어 있어서 되돌릴 수 없습니다. 그래서 표준 라이브러리는 std::move_if_noexcept와 같은 규칙으로, 이동 생성자가 noexcept이거나 타입이 복사 불가능할 때만 이동하고, 그 외에는 복사합니다. 복사가 불가능한 타입(move-only)은 noexcept가 없어도 이동하되, 그 경우 강한 보장은 포기됩니다.
class Buffer {
public:
explicit 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() { delete[] data_; } // 소멸자는 암시적으로 noexcept
private:
char* data_;
size_t size_;
};
static_assert(std::is_nothrow_move_constructible_v<Buffer>);
이동 생성자에서 std::exchange(other.data_, nullptr)로 원본의 포인터를 가져오고 원본을 nullptr로 비워 두면, 두 객체의 소멸자가 같은 메모리를 두 번 해제하지 않습니다. static_assert로 이동 생성자가 실제로 nothrow인지 컴파일 타임에 확인해 두면, 나중에 멤버를 추가하다가 noexcept가 깨지는 것을 바로 알 수 있습니다. (이 예시는 이동 연산만 보이기 위해 이동 대입과 복사 연산을 생략했습니다.)
소멸자는 기본적으로 noexcept
C++11부터 소멸자는 따로 적지 않아도 암시적으로 noexcept입니다(멤버나 기반 클래스의 소멸자가 noexcept(false)인 경우만 예외). 스택 되감기 중에 소멸자가 예외를 던지면 std::terminate가 호출되므로, 소멸자 안에서 실패할 수 있는 정리 작업은 예외를 내부에서 처리하고 밖으로 내보내지 않도록 설계합니다. ~T() noexcept라고 명시하는 것은 문서 효과만 있습니다.
swap
swap은 보통 내부 핸들이나 포인터만 바꾸므로 예외를 던지지 않습니다. noexcept swap은 copy-and-swap 관용구로 강한 예외 보장을 구현할 때의 전제이기도 합니다. 복사본을 만드는 단계에서만 예외가 날 수 있고, 마지막 교환은 실패하지 않아야 대입 연산 전체가 “성공하거나 아무 변화도 없거나”가 됩니다.
class Resource {
public:
void swap(Resource& other) noexcept {
std::swap(handle_, other.handle_);
}
private:
void* handle_ = nullptr;
};
void swap(Resource& a, Resource& b) noexcept { a.swap(b); }
조건부 noexcept: noexcept(expr)
템플릿에서는 “T의 이동이 noexcept이면 이 함수도 noexcept”처럼 조건부로 표현합니다. std::optional과 std::pair의 이동 생성자가 이런 방식으로 선언되어 있습니다.
template<typename T>
class Box {
T value_;
public:
Box(Box&& other) noexcept(std::is_nothrow_move_constructible_v<T>)
: value_(std::move(other.value_)) {}
};
noexcept(expr)에서 expr이 true이면 noexcept, false이면 예외를 던질 수 있는 함수가 됩니다. 반대로 noexcept(f())처럼 연산자로 쓰면 “이 식이 예외를 던지지 않는다고 선언되어 있는가”를 컴파일 타임에 bool로 돌려줍니다.
noexcept가 생성 코드를 직접 빠르게 만드는 효과는 크지 않습니다. 컴파일러가 예외 경로를 덜 만들 수 있는 정도이고, 실질적인 이득은 위처럼 표준 라이브러리가 이동을 고르게 하는 데서 나옵니다.
[[nodiscard]]
반환값 무시 방지
[[nodiscard]](C++17)가 붙은 함수의 반환값을 버리면 컴파일러가 경고를 냅니다. 에러 코드나 성공 여부를 반환하는 API에서 호출자가 결과를 확인하지 않고 넘어가는 실수를 막는 데 효과적입니다. 런타임 비용은 없습니다.
[[nodiscard]] bool init();
[[nodiscard]] std::unique_ptr<Resource> create();
void bad_usage() {
init(); // 경고: 반환값 무시
create(); // 경고: 만든 객체가 바로 파괴됨
}
void good_usage() {
if (!init()) return;
auto p = create();
}
create()의 반환값을 버렸을 때 무슨 일이 생기는지는 정확히 구분해야 합니다. 반환 타입이 std::unique_ptr이면 버려진 임시 객체가 그 문장 끝에서 소멸하면서 자원을 해제하므로 누수는 없습니다. 대신 자원을 만들자마자 버리는 셈이라 거의 확실히 의도와 다른 코드입니다. 누수가 생기는 것은 Resource* create()처럼 소유권 있는 원시 포인터를 반환할 때입니다. 표준이 operator new와 allocator::allocate()에 [[nodiscard]]를 붙인 이유가 이것입니다.
C++20부터는 [[nodiscard("이유")]]처럼 경고 메시지에 이유를 넣을 수 있고, 생성자에도 붙일 수 있습니다. void를 반환하는 함수에 붙이는 것은 의미가 없으며, 컴파일러에 따라 경고가 납니다.
타입에 [[nodiscard]] 붙이기
[[nodiscard]]는 C++17부터 클래스나 열거형 선언에도 붙일 수 있습니다. 그러면 그 타입을 값으로 반환하는 모든 함수에 일괄 적용되므로, 에러 코드 타입에 쓰면 효과적입니다. 속성의 위치는 enum class나 struct 키워드 뒤, 이름 앞입니다.
enum class [[nodiscard]] ErrorCode {
Ok,
NotFound,
PermissionDenied
};
ErrorCode openFile(const std::string& path); // 반환값 무시 시 경고
struct [[nodiscard]] Result {
int value;
bool success;
};
[[nodiscard]] enum class ErrorCode { ... };처럼 선언 맨 앞에 두면 타입에 적용되지 않고, 컴파일러가 속성을 무시한다는 경고를 냅니다.
붙일 만한 함수
에러 코드나 성공 여부를 반환하는 함수(bool init(), int connect()), 소유권 있는 자원을 반환하는 팩토리, 부수 효과 없이 결과만 계산하는 함수(double compute()), std::optional이나 std::expected를 반환하는 조회 함수가 대표적입니다. 반대로 operator=처럼 반환값을 대부분 버리는 것이 정상인 함수나, 체이닝용으로 *this를 돌려주는 함수에 붙이면 경고만 늘어납니다.
경고를 오류로 다루기
GCC와 Clang은 이 경고를 -Wunused-result 그룹으로 기본 출력하고, MSVC는 C4834(수준 1)로 출력합니다. 전체 경고를 오류로 만들지 않고 이 경고만 오류로 바꾸려면 다음처럼 설정합니다.
if(MSVC)
add_compile_options(/we4834)
else()
add_compile_options(-Werror=unused-result)
endif()
정말로 결과를 버려야 하는 곳에서는 (void)init();처럼 의도를 드러내 경고를 끕니다.
자주 만나는 컴파일 오류와 실수
const 객체에서 비const 멤버 호출
error: passing 'const Config' as 'this' argument discards qualifiers
const Config&로 받은 객체에서 const가 아닌 멤버 함수를 호출하면 GCC가 이 오류를 냅니다. 읽기 전용 함수라면 선언과 정의 양쪽에 const를 붙이는 것이 해결책입니다.
class Config {
public:
std::string get(const std::string& key) const; // const 추가
};
const 멤버 함수에서 멤버 수정
class Cache {
std::map<std::string, int> cache_;
public:
int get(const std::string& key) const {
return cache_[key]; // 오류: const map에는 operator[]가 없음
}
};
std::map::operator[]는 키가 없으면 원소를 삽입하므로 const 버전이 없습니다. 이 코드는 passing 'const std::map<...>' as 'this' argument discards qualifiers 오류가 납니다. 조회만 할 거라면 find나 at을 쓰고, 정말로 캐시를 채워야 한다면 cache_를 mutable로 선언하되 앞서 말한 대로 동시 호출에 대비해 락으로 보호합니다.
noexcept 없는 이동 생성자
class Buffer {
public:
Buffer(const Buffer&); // 복사 가능
Buffer(Buffer&& other) // noexcept 없음
: data_(other.data_), size_(other.size_) {
other.data_ = nullptr;
}
// ...
};
복사 생성자도 있는 타입이라 std::vector<Buffer>는 재할당 때마다 모든 요소를 복사합니다. 오류도 경고도 없이 느려지기만 하므로 찾기 어렵습니다. 이동 생성자에 noexcept를 붙이고 static_assert(std::is_nothrow_move_constructible_v<Buffer>)로 고정해 두면 됩니다.
noexcept를 잘못 붙인 함수
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;
}
문자열을 복사해 반환하는 함수는 메모리 할당에 실패하면 std::bad_alloc을 던질 수 있습니다. 여기에 noexcept를 붙이면 그 상황에서 호출자가 처리할 기회 없이 std::terminate로 종료됩니다. 할당이 있는 함수에 습관적으로 noexcept를 붙이지 않도록 주의해야 합니다.
Before/After: 의도가 드러나는 인터페이스
// Before
class Database {
Connection* conn;
public:
string query(string sql) {
return conn->execute(sql);
}
void close() { conn->disconnect(); }
};
이 인터페이스만으로는 query가 객체 상태를 바꾸는지, 실패를 어떻게 알리는지, 반환값을 무시해도 되는지, conn을 누가 소유하는지 알 수 없습니다. 인자도 값으로 받아 매번 복사합니다.
// After (C++23)
#include <expected>
#include <memory>
#include <string>
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 {
if (!conn_) return std::unexpected(DbError::NotConnected);
return conn_->execute(sql); // execute가 expected<QueryResult, DbError>를 반환한다고 가정
}
void close() noexcept {
if (conn_) conn_->disconnect(); // disconnect가 예외를 던지지 않는다고 가정
}
};
[[nodiscard]]와 std::expected는 호출자가 실패를 확인하도록 유도하고, std::unique_ptr은 소유권을 드러냅니다. query의 const는 “이 객체의 논리적 상태를 바꾸지 않는다”는 약속인데, 앞에서 본 대로 unique_ptr 멤버를 통한 호출은 막지 못하므로 컴파일러가 아니라 설계자가 지켜야 하는 약속입니다. query에는 noexcept를 붙이지 않았습니다. 쿼리 실행과 결과 생성에는 메모리 할당이 따르므로, 붙이면 할당 실패가 곧 프로그램 종료가 됩니다.
설정 관리자 예제
#include <optional>
#include <string>
#include <unordered_map>
class ConfigManager {
std::unordered_map<std::string, std::string> config_;
public:
[[nodiscard]] std::optional<std::string> get(const std::string& key) const {
auto it = config_.find(key);
if (it == config_.end()) return std::nullopt;
return it->second;
}
void set(const std::string& key, std::string value) {
config_[key] = std::move(value);
}
[[nodiscard]] bool has(const std::string& key) const {
return config_.contains(key); // C++20
}
void clear() noexcept {
config_.clear();
}
};
void processConfig(const ConfigManager& cfg) {
if (auto timeout = cfg.get("timeout")) {
// *timeout 사용
}
}
조회 함수는 const와 [[nodiscard]], 값을 저장하는 set은 값으로 받아 이동, 실패할 수 없는 clear만 noexcept입니다.
표준 라이브러리 선언에서 배우기
표준 라이브러리 선언은 세 도구를 어떻게 조합하는지 보여 주는 좋은 참고 자료입니다. std::vector::size()는 size_type size() const noexcept로, 조회이므로 const이고 실패하지 않으므로 noexcept입니다. 컨테이너의 empty()는 C++20부터 표준이 [[nodiscard]]를 요구하는데, v.empty();를 v.clear();의 뜻으로 잘못 쓰는 실수가 실제로 흔하기 때문입니다. 반면 std::make_unique나 std::optional::value()에는 표준 차원의 [[nodiscard]]가 없고, 구현체(libstdc++, MSVC STL)가 자체적으로 붙이는 경우가 있습니다.
기존 코드에 점진적으로 적용하기
기존 코드베이스에 한 번에 적용하기 어렵다면, 새로 작성하는 코드부터 세 도구를 쓰고, 버그를 고치거나 리팩터링할 때 해당 함수와 모듈에 추가하는 방식이 현실적입니다. const는 한 곳에 붙이면 그 함수가 호출하는 함수들도 const여야 해서 연쇄적으로 번지는 성질이 있으므로, 호출 그래프의 아래쪽(작은 유틸리티 함수)부터 붙여 올라가면 수월합니다.
// 리팩터링 전
std::string getConfig(std::string key);
// 리팩터링 후
[[nodiscard]] std::string getConfig(const std::string& key) const;
테스트에서도 활용할 수 있습니다. 테스트 대상에 const 객체를 넘기면 “이 경로는 객체를 수정하지 않는다”는 것이 컴파일 단계에서 확인되고, static_assert(std::is_nothrow_move_constructible_v<T>)나 static_assert(noexcept(std::declval<T&>().swap(std::declval<T&>())))로 noexcept 계약을 테스트 코드에 고정할 수 있습니다. 반환값 검사를 assert(init());처럼 쓰는 것은 피해야 합니다. NDEBUG 빌드에서는 assert 안의 식이 통째로 사라져 init() 자체가 호출되지 않습니다.
관련 글: RAII(#6-4), 예외 안전성(#8-2)