C++ 속성(Attributes): [[nodiscard]]·[[deprecated]]·[[likely]]·[[fallthrough]] 실전 사용법
이 글의 핵심
속성은 코드 동작을 바꾸지 않고 컴파일러에 의도를 전달하는 수단이라, 잘 쓰면 리뷰에서 놓칠 실수를 빌드 단계에서 잡아 줍니다. 반면 likely/unlikely를 근거 없이 붙이거나 fallthrough를 잘못된 위치에 두면 효과가 없거나 오히려 경고가 늘어납니다. 컴파일러별 지원 차이와 속성을 조합하는 방법도 함께 다룹니다.
Attributes란?
Attributes (속성) 는 C++11에서 도입된 기능으로, 컴파일러에게 추가 정보를 제공하는 표준화된 방법입니다. 코드 품질 향상, 최적화 힌트, API 문서화 등에 사용됩니다.
왜 필요한가?:
- 코드 품질: 컴파일러 경고로 버그 방지
- 최적화: 컴파일러에게 최적화 힌트 제공
- 문서화: 의도를 명확히 표현
- 표준화: 컴파일러별 확장 대신 표준 사용
// ❌ 컴파일러별 확장: 비표준
#ifdef __GNUC__
__attribute__((warn_unused_result))
#endif
int compute();
// ✅ 표준 속성: 모든 컴파일러
[[nodiscard]] int compute();
속성의 가장 중요한 성질은 프로그램의 의미를 바꾸지 않는다는 것입니다. [[nodiscard]]를 지워도 코드는 똑같이 동작하고, 달라지는 것은 컴파일러가 내는 경고와 최적화 판단뿐입니다(단, [[noreturn]]이나 C++20 [[no_unique_address]]처럼 계약을 어기면 정의되지 않은 동작이 되거나 객체 레이아웃이 바뀌는 속성도 있습니다). 또 표준은 모르는 속성은 무시하라고 정하고 있어서, [[gnu::always_inline]]을 MSVC에서 컴파일하면 에러 대신 “알 수 없는 속성” 경고만 나옵니다. 이 규칙 덕분에 #ifdef 없이도 여러 컴파일러에서 빌드되는 코드를 쓸 수 있지만, 속성 이름에 오타를 내도([[nodiscrad]]) 경고 하나로 조용히 무시된다는 뜻이기도 합니다.
[[nodiscard]]
반환값을 무시하면 경고를 발생시킵니다. 에러 코드나 중요한 리소스를 반환하는 함수에 사용합니다.
// 반환값 무시 시 경고
[[nodiscard]] int compute() {
return 42;
}
int main() {
compute(); // 경고: 반환값 무시
int result = compute(); // OK
}
사용 시나리오:
- 에러 코드:
[[nodiscard]] ErrorCode save() - 리소스 핸들:
[[nodiscard]] FileHandle open() - 중요한 계산:
[[nodiscard]] int calculate()
// 에러 코드
[[nodiscard]] bool saveFile(const std::string& path) {
// 저장 로직
return true;
}
// ❌ 에러 무시
saveFile("data.txt"); // 경고
// ✅ 에러 확인
if (!saveFile("data.txt")) {
std::cerr << "저장 실패\n";
}
[[nodiscard]]가 가장 효과적인 곳은 반환값을 무시하면 버그가 되는 함수입니다. 실패를 bool이나 에러 코드로 알리는 함수, 새로 할당한 자원을 돌려주는 함수, 그리고 이름이 부수 효과처럼 보이지만 실제로는 값을 돌려주는 함수가 대표적입니다. 마지막 경우의 유명한 예가 std::vector::empty()입니다. “비운다”로 오해해 v.empty();만 호출하고 벡터가 비워졌다고 믿는 실수가 흔했기 때문에, C++20 표준 라이브러리는 empty()에 [[nodiscard]]를 붙였습니다. 새 컴파일러에서 기존 코드에 이 경고가 나온다면 대부분 실제 버그입니다.
반대로 모든 함수에 붙이면 경고가 너무 많아져 팀이 경고를 무시하는 습관이 생깁니다. std::map::insert처럼 반환값이 있지만 대부분 무시해도 되는 함수에는 붙이지 않는 것이 좋습니다. C++20부터는 [[nodiscard("저장 실패를 확인하세요")]]처럼 이유를 적을 수 있어, 경고 메시지만 보고도 왜 반환값이 중요한지 알 수 있습니다.
[[deprecated]]
// 사용 중단 경고
[[deprecated("use newFunc instead")]]
void oldFunc() {
cout << "구식 함수" << endl;
}
void newFunc() {
cout << "새 함수" << endl;
}
int main() {
oldFunc(); // 경고: deprecated
newFunc(); // OK
}
[[deprecated]]는 API를 한 번에 지우지 않고 단계적으로 바꿀 수 있게 해 줍니다. 새 함수를 추가하고 옛 함수에 deprecated를 붙여 한두 릴리스 동안 경고를 내보낸 뒤, 사용처가 사라지면 삭제하는 순서입니다. 메시지에는 무엇으로 바꿔야 하는지를 적어야 경고를 본 사람이 바로 고칠 수 있습니다.
실무에서 부딪히는 문제는 -Werror입니다. 경고를 에러로 처리하는 빌드에서는 라이브러리가 함수를 deprecated로 바꾸는 순간 사용자의 빌드가 깨집니다. 라이브러리를 업그레이드했더니 갑자기 빌드가 안 된다는 상황의 흔한 원인이라, -Werror를 쓰는 프로젝트는 -Wno-error=deprecated-declarations로 deprecated 경고만 에러에서 제외해 두는 경우가 많습니다. 또 deprecated 함수를 구현하는 라이브러리 자신의 코드(테스트 등)에서도 경고가 나므로, 그 부분에서는 #pragma GCC diagnostic ignored "-Wdeprecated-declarations"나 MSVC의 #pragma warning(disable: 4996)으로 국소적으로 끕니다.
[[maybe_unused]]
void func([[maybe_unused]] int debug) {
#ifdef DEBUG
cout << "디버그: " << debug << endl;
#endif
// debug 미사용 시 경고 없음
}
int main() {
[[maybe_unused]] int x = 10;
// x 미사용 시 경고 없음
}
[[maybe_unused]]는 빌드 설정에 따라 쓰이기도 하고 안 쓰이기도 하는 변수에 붙입니다. 가장 흔한 경우는 assert에서만 쓰는 변수입니다. auto ok = init(); assert(ok);는 NDEBUG로 빌드하면 assert가 사라져 ok가 사용되지 않는다는 경고가 나오는데, [[maybe_unused]] auto ok = init();로 쓰면 디버그·릴리스 모두 조용합니다. 예전에 쓰던 (void)x; 캐스트와 효과는 같지만, 선언에 붙어 있어 의도가 더 분명합니다.
[[likely]] / [[unlikely]] (C++20)
int process(int x) {
if (x > 0) [[likely]] {
// 대부분 이 경로
return x * 2;
} else [[unlikely]] {
// 드문 경로
return 0;
}
}
[[likely]]는 CPU의 분기 예측기에 직접 힌트를 주는 것이 아닙니다. 현대 x86·ARM CPU는 소프트웨어 힌트를 거의 쓰지 않고 실행 이력을 보고 스스로 예측합니다. 속성이 바꾸는 것은 컴파일러의 코드 배치입니다. 자주 실행되는 경로를 분기 바로 다음에 이어 붙여 명령어 캐시를 효율적으로 쓰고, 드문 경로는 함수 끝이나 별도 영역으로 밀어냅니다. 인라인 판단이나 레지스터 할당도 자주 실행되는 쪽에 유리하게 바뀔 수 있습니다.
그래서 효과는 대개 작고, 예측이 틀리면 오히려 손해입니다. 이미 프로파일 기반 최적화(PGO, GCC의 -fprofile-use, Clang의 -fprofile-instr-use)를 쓰고 있다면 실제 실행 데이터가 더 정확하므로 수동 힌트는 거의 의미가 없습니다. 에러 처리 분기처럼 “드물다”는 것이 설계상 분명한 곳에 [[unlikely]]를 붙이는 정도가 안전한 사용법입니다. C++20 이전에는 GCC·Clang의 __builtin_expect(cond, 1)로 같은 효과를 냈고, 리눅스 커널의 likely()/unlikely() 매크로가 이것을 감싼 것입니다.
[[fallthrough]]
void process(int x) {
switch (x) {
case 1:
cout << "1" << endl;
[[fallthrough]]; // 의도적 fall-through
case 2:
cout << "1 또는 2" << endl;
break;
case 3:
cout << "3" << endl;
// [[fallthrough]]; // 여기 두면 컴파일 에러: 뒤에 이어질 case 레이블이 없음
}
}
switch에서 break를 빠뜨리는 것은 C 시절부터 가장 흔한 버그 중 하나라, GCC·Clang은 -Wimplicit-fallthrough(GCC는 -Wextra에 포함)로 fall-through를 경고합니다. 문제는 의도적인 fall-through도 경고가 난다는 것이고, [[fallthrough]];는 “여기는 일부러 이어지게 했다”고 표시해 이 경고를 끄는 수단입니다. 예전에는 // fall through 같은 주석으로 표시했는데, 컴파일러가 인식하는 주석 형식이 제각각이라 표준 속성이 만들어졌습니다.
[[fallthrough]]는 다음 case 레이블 바로 앞의 문장이어야 합니다. 마지막 case처럼 뒤에 이어질 레이블이 없는 곳에 두면 경고가 아니라 컴파일 에러가 납니다. 또 case 사이에 문장이 하나도 없는 경우(case 1: case 2: ...)는 원래 경고 대상이 아니므로 속성이 필요 없습니다.
[[noreturn]]
[[noreturn]] void fatal(const string& msg) {
cerr << "치명적 에러: " << msg << endl;
exit(1);
}
int main() {
if (error) {
fatal("에러 발생");
// 여기 도달 안함
}
}
[[noreturn]]은 컴파일러에게 “이 함수는 절대 호출자에게 돌아오지 않는다”고 약속합니다. 컴파일러는 이 정보로 호출 이후 코드를 도달 불가능한 것으로 보고 제거하거나, 값을 반환해야 하는 함수에서 fatal() 호출 뒤에 return이 없어도 “반환값 없음” 경고를 내지 않습니다. 약속은 개발자가 지켜야 합니다. [[noreturn]] 함수가 실제로 반환하면 정의되지 않은 동작이며, 예를 들어 로깅만 하고 조건에 따라 반환하도록 나중에 수정했다면 호출자 쪽 코드가 이상하게 동작할 수 있습니다. 반환 대신 예외를 던지는 것은 허용됩니다. std::exit, std::abort, std::terminate는 표준 라이브러리에서 이미 [[noreturn]]으로 선언되어 있습니다.
실전 예시
예시 1: 에러 처리
class [[nodiscard]] Result {
private:
bool success;
string message;
public:
Result(bool s, string m) : success(s), message(m) {}
bool isOk() const { return success; }
string getMessage() const { return message; }
};
Result saveFile(const string& filename) {
// 파일 저장 로직
return Result(true, "저장 성공");
}
int main() {
saveFile("test.txt"); // 경고: 반환값 무시
auto result = saveFile("test.txt"); // OK
if (!result.isOk()) {
cout << result.getMessage() << endl;
}
}
[[nodiscard]]를 타입에 붙이면 그 타입을 값으로 반환하는 모든 함수가 자동으로 nodiscard가 됩니다. 함수마다 붙이는 것을 잊을 걱정이 없고, 새로 추가되는 함수에도 적용되므로 Result나 에러 코드 enum처럼 “절대 무시하면 안 되는” 타입에 가장 효과적입니다. 다만 참조나 포인터로 반환하는 경우(Result& getLast())에는 적용되지 않습니다.
이 방식이 막을 수 있는 것은 반환값을 통째로 버리는 경우뿐이라는 점도 알아 두어야 합니다. auto result = saveFile(...)로 받아 놓고 isOk()를 확인하지 않는 코드는 경고가 나지 않습니다. 실패 확인을 강제하고 싶다면 소멸자에서 확인 여부를 검사하는 디버그용 단언을 넣거나, 값에 접근하려면 반드시 성공 여부를 거치게 하는 std::expected(C++23) 같은 타입을 쓰는 편이 더 강력합니다.
예시 2: API 버전 관리
class API {
public:
[[deprecated("use processV2 instead")]]
void processV1(int data) {
cout << "V1: " << data << endl;
}
void processV2(int data, bool flag = false) {
cout << "V2: " << data << ", " << flag << endl;
}
};
int main() {
API api;
api.processV1(10); // 경고
api.processV2(10); // OK
}
예시 3: 최적화 힌트
#include <random>
int main() {
random_device rd;
mt19937 gen(rd());
uniform_int_distribution<> dis(1, 100);
int x = dis(gen);
if (x > 50) [[likely]] {
// 50% 확률 (likely)
cout << "큰 수" << endl;
} else [[unlikely]] {
// 50% 확률 (unlikely는 부적절)
cout << "작은 수" << endl;
}
// 올바른 사용
if (x > 95) [[unlikely]] {
// 5% 확률
cout << "매우 큰 수" << endl;
}
}
50% 분기에 [[likely]]를 붙이는 것은 틀린 정보는 아니지만 쓸모가 없습니다. 어느 쪽이 더 자주 실행되는지에 대한 정보가 없으니 컴파일러가 배치를 바꿀 근거도 없습니다. 이런 분기는 CPU 분기 예측기 입장에서도 예측이 불가능해 매번 절반 가까이 틀리는데, 그 비용은 속성으로 줄일 수 없습니다. 진짜로 성능이 문제라면 분기 자체를 없애는(branchless) 방향으로 코드를 바꿔야 합니다.
예시 4: 스위치 fall-through
enum Command {
CMD_INIT,
CMD_START,
CMD_STOP,
CMD_CLEANUP
};
void execute(Command cmd) {
switch (cmd) {
case CMD_INIT:
cout << "초기화" << endl;
[[fallthrough]];
case CMD_START:
cout << "시작" << endl;
break;
case CMD_STOP:
cout << "중지" << endl;
[[fallthrough]];
case CMD_CLEANUP:
cout << "정리" << endl;
break;
}
}
int main() {
execute(CMD_INIT);
// 초기화
// 시작
}
이 예제는 “초기화한 뒤 시작도 한다”, “중지한 뒤 정리도 한다”는 순서 관계를 fall-through로 표현했습니다. 이렇게 단계가 누적되는 상태 전이에서는 fall-through가 코드 중복을 줄여 주지만, 나중에 누군가 CMD_START와 CMD_INIT 사이에 새 case를 끼워 넣으면 초기화 뒤에 엉뚱한 명령이 실행됩니다. [[fallthrough]]는 적어도 “이 연결은 의도된 것”이라는 표시를 남기므로, 리뷰어가 case 순서를 바꿀 때 한 번 더 확인하게 만듭니다. 연결 관계가 복잡해지면 init(); start();처럼 함수 호출로 명시하는 편이 안전합니다.
컴파일러별 지원
// GCC/Clang
[[gnu::always_inline]]
inline void fastFunc() {}
// MSVC
[[msvc::forceinline]]
void fastFunc() {}
// 크로스 플랫폼
#ifdef __GNUC__
[[gnu::always_inline]]
#elif _MSC_VER
[[msvc::forceinline]]
#endif
inline void fastFunc() {}
벤더 속성은 gnu::, clang::, msvc:: 같은 네임스페이스로 구분됩니다. 앞에서 말했듯 모르는 속성은 무시되므로 #ifdef 없이 둘 다 적어도 빌드는 되지만, 각 컴파일러가 상대의 속성에 “알 수 없는 속성” 경고를 내므로 경고를 깨끗하게 유지하려면 매크로로 감싸는 편이 낫습니다. 특정 속성을 지원하는지는 C++20의 __has_cpp_attribute(nodiscard)로 전처리 단계에서 확인할 수 있고, 값이 표준에 추가된 연월(예: 201603L)로 나와 버전별 기능 차이도 판단할 수 있습니다. 참고로 Clang은 gnu:: 속성 대부분을 이해하므로, __GNUC__ 분기는 GCC와 Clang 모두에 적용됩니다.
자주 발생하는 문제
문제 1: nodiscard 무시
[[nodiscard]] int compute() {
return 42;
}
int main() {
(void)compute(); // 명시적 무시 (경고 없음)
compute(); // 경고
}
문제 2: likely/unlikely 오용
// ❌ 잘못된 힌트
if (x == 0) [[likely]] { // 실제로는 드문 경우
// ...
}
// ✅ 올바른 힌트
if (x != 0) [[likely]] { // 실제로 자주 발생
// ...
}
문제 3: fallthrough 위치
switch (x) {
case 1:
cout << "1" << endl;
[[fallthrough]]; // ✅ case 1의 마지막 문장, 다음 case 레이블 바로 앞
case 2:
cout << "2" << endl;
break;
}
// ❌ [[fallthrough]]; 다음에 다른 문장이 오거나, 마지막 case에 두면 컴파일 에러
들여쓰기는 상관없고, 중요한 것은 [[fallthrough]];가 현재 case에서 마지막으로 실행되는 문장이어야 한다는 점입니다. 그 뒤에 cout 같은 다른 문장이 있으면 “fallthrough 속성이 switch 레이블 바로 앞에 있지 않다”는 에러가 납니다. if 블록 안처럼 조건에 따라 fall-through하는 경우에도, 속성 뒤에서 곧바로 다음 case로 흐를 수 있는 위치여야 합니다.
속성 조합
[[nodiscard, deprecated("use newFunc")]]
int oldFunc() {
return 42;
}
[[nodiscard]] [[deprecated]]
int oldFunc2() {
return 42;
}
실무 패턴
패턴 1: RAII 리소스
class [[nodiscard]] FileHandle {
FILE* file_;
public:
FileHandle(const char* path) : file_(fopen(path, "r")) {
if (!file_) {
throw std::runtime_error("파일 열기 실패");
}
}
~FileHandle() {
if (file_) {
fclose(file_);
}
}
// 복사 금지
FileHandle(const FileHandle&) = delete;
FileHandle& operator=(const FileHandle&) = delete;
};
// 사용
// FileHandle("data.txt"); // 경고: 즉시 소멸
auto file = FileHandle("data.txt"); // OK
RAII 타입에서 [[nodiscard]]가 막는 실수는 이름 없는 임시 객체입니다. FileHandle("data.txt");는 파일을 열고 문장이 끝나는 즉시 닫아 버립니다. 이 실수가 가장 위험한 곳은 락입니다. std::lock_guard<std::mutex>(mtx);처럼 변수 이름을 빠뜨리면 컴파일은 되지만 락이 즉시 풀려, 임계 영역이 전혀 보호되지 않는데도 테스트에서는 대부분 정상으로 보입니다. C++20부터 생성자 호출로 만든 nodiscard 타입의 임시 객체도 경고 대상이 되었으므로, 락이나 스코프 가드 같은 타입에는 클래스 수준 [[nodiscard]]를 붙여 두는 것이 좋습니다.
패턴 2: API 마이그레이션
class Database {
public:
// 구버전
[[deprecated("use executeQuery instead")]]
void query(const std::string& sql) {
// 구식 구현
}
// 신버전
[[nodiscard]] Result executeQuery(const std::string& sql) {
// 새 구현
return Result{};
}
};
// 사용
Database db;
db.query("SELECT * FROM users"); // 경고: deprecated
auto result = db.executeQuery("SELECT * FROM users"); // OK
패턴 3: 성능 최적화
int processData(int* data, size_t size) {
int sum = 0;
for (size_t i = 0; i < size; ++i) {
if (data[i] > 0) [[likely]] {
// 대부분 양수
sum += data[i];
} else [[unlikely]] {
// 드물게 음수
sum -= data[i];
}
}
return sum;
}
이 패턴은 오히려 역효과가 날 수 있는 대표적인 경우입니다. 이 루프의 분기는 sum += |data[i]|와 같아서, 컴파일러가 속성이 없으면 분기 없는 조건부 이동(cmov)이나 SIMD 명령으로 바꿔 루프 전체를 벡터화할 수 있습니다. 여기에 [[likely]]를 붙이면 컴파일러가 “분기를 유지하고 한쪽을 빠른 경로로 배치하라”는 뜻으로 받아들여, 이런 변환이 막히거나 달라질 수 있습니다. 제가 성능 힌트를 다룰 때 지키는 원칙도 “측정 전에는 붙이지 않는다”입니다. 속성을 붙이기 전후로 벤치마크를 돌리고, 가능하면 생성된 어셈블리(Compiler Explorer 등)를 확인한 뒤에만 남기는 것이 안전합니다.
FAQ
Q1: 속성은 언제 사용하나요?
A:
- 코드 품질: 컴파일러 경고로 버그 방지
- 최적화: 컴파일러에게 힌트 제공
- 문서화: 의도를 명확히 표현
- API 관리: deprecated, nodiscard
[[nodiscard]] bool save(); // 반환값 확인 필수
[[deprecated]] void oldFunc(); // 사용 중단
Q2: nodiscard는 언제 사용하나요?
A:
- 에러 코드: 반환값 확인 필수
- 리소스 핸들: RAII 객체
- 중요한 계산: 결과 무시하면 안 됨
[[nodiscard]] ErrorCode connect();
[[nodiscard]] FileHandle open();
[[nodiscard]] int calculate();
Q3: likely/unlikely의 효과는?
A: 컴파일러의 코드 배치를 바꿔 자주 실행되는 경로를 빠르게 만드는 힌트입니다. 효과는 코드와 CPU에 따라 없음부터 몇 퍼센트 수준까지 다양하고, 힌트가 실제 분포와 반대면 손해를 봅니다. 반드시 벤치마크로 확인한 뒤 남기십시오.
if (x > 0) [[likely]] {
// 자주 실행되는 경로
}
Q4: 모든 컴파일러가 지원하나요?
A: 표준 속성은 C++11부터 지원합니다. 일부는 C++17/20에 추가되었습니다.
- C++11:
[[noreturn]],[[carries_dependency]] - C++14:
[[deprecated]] - C++17:
[[fallthrough]],[[nodiscard]],[[maybe_unused]] - C++20:
[[likely]],[[unlikely]],[[no_unique_address]],[[nodiscard("이유")]] - C++23:
[[assume(표현식)]]
Q5: 속성을 무시하면 어떻게 되나요?
A: 컴파일러가 무시합니다. 에러는 아니며, 경고만 발생할 수 있습니다.
[[nodiscard]] int compute();
compute(); // 경고 (무시 가능)
Q6: 여러 속성을 조합할 수 있나요?
A: 가능합니다.
[[nodiscard, deprecated("use newFunc")]]
int oldFunc() {
return 42;
}
// 또는 분리
[[nodiscard]] [[deprecated]]
int oldFunc2() {
return 42;
}
Q7: 사용자 정의 속성은?
A: 컴파일러별 확장으로 가능하지만, 표준은 아닙니다.
// GCC/Clang
[[gnu::always_inline]] void fastFunc();
// MSVC
[[msvc::forceinline]] void fastFunc();
Q8: 속성 학습 리소스는?
A:
- cppreference.com - Attributes
- “C++17: The Complete Guide” by Nicolai Josuttis
- 컴파일러 문서 (GCC, Clang, MSVC)
관련 글: nodiscard, deprecated, noreturn.
Attributes는 컴파일러에게 추가 정보를 제공하는 C++11 표준화된 방법입니다.
같이 보면 좋은 글
- noexcept로 C++ 이동 연산 최적화하기: 예외 계약 설계 패턴
- C++ decltype: auto와 다른 타입 추론 규칙, decltype(auto), 괄호 하나로 바뀌는 결과
- C++11 random 라이브러리: rand() % n이 편향되는 이유와 mt19937·분포 사용법
- C++ 시리즈 전체 보기