[[nodiscard]]로 반환값 무시 막기: 에러 코드, RAII 타입, 사유 메시지, Clang-Tidy 검증

들어가며: 반환값을 확인하지 않아서 생기는 버그

C++에서는 함수의 반환값을 받지 않아도 아무 문제 없이 컴파일됩니다. 대부분은 의도한 동작이지만, 반환값이 곧 결과인 함수에서는 이것이 조용한 버그가 됩니다. bool init()이 설정 로드나 DB 연결에 실패해 false를 돌려줘도 호출자가 init();만 쓰면 프로그램은 실패를 모른 채 진행하다 엉뚱한 곳에서 크래시합니다. int connect(const char* host)가 음수 에러 코드를 돌려줘도 마찬가지이고, std::optional<User> findUser(int id)의 결과를 받지 않으면 찾았는지 확인할 기회 자체가 사라집니다.

[[nodiscard]]는 이런 함수나 타입에 “반환값을 버리면 경고하라”는 표시를 붙여, 사람의 주의력 대신 컴파일러가 실수를 잡게 합니다. 함수 이름에 Check를 붙이거나 문서에 적어 두는 방식은 강제력이 없지만, 경고는 빌드 로그에 남고 CI에서 오류로 올릴 수도 있습니다. 이 글에서는 반환값을 버려서 생기는 대표적인 버그, 함수·타입·생성자에 붙이는 문법, 경고가 기대대로 나오지 않는 경우, 그리고 CI 연동을 다룹니다.


반환값을 버려서 생기는 버그

초기화 실패를 놓침

bool init() {
    if (!loadConfig()) return false;
    if (!connectDB()) return false;
    return true;
}
void main_loop() {
    init();  // 실패해도 그대로 진행
    run();   // DB가 연결되지 않은 상태에서 실행
}

[[nodiscard]]를 붙이면 init();처럼 결과를 버리는 호출에서 컴파일러가 경고합니다.

[[nodiscard]] bool init();
void main_loop() {
    if (!init()) {
        log_error("초기화 실패");
        return;
    }
    run();
}

에러 코드를 확인하지 않음

#include <fcntl.h>
#include <cerrno>
// 성공 시 파일 디스크립터(0 이상), 실패 시 -errno
[[nodiscard]] int openFile(const char* path) {
    int fd = ::open(path, O_RDONLY);
    return fd >= 0 ? fd : -errno;
}
void process() {
    int fd = openFile("data.txt");
    if (fd < 0) {
        log_error("파일 열기 실패: %d", fd);
        return;
    }
    // fd 사용 후 close(fd)
}

결과를 버리면 파일 디스크립터를 닫을 방법도 사라지므로, 이 함수는 에러 확인과 리소스 회수 두 가지 이유로 [[nodiscard]]가 어울립니다.

리소스·optional 반환값을 버림

[[nodiscard]] std::unique_ptr<Connection> createConnection();
[[nodiscard]] std::optional<User> findUser(int id);

void setup() {
    createConnection();   // 경고: 만든 연결이 문장 끝에서 바로 소멸
    findUser(42);         // 경고: 찾은 결과를 확인할 방법이 없음
}

unique_ptr을 버려도 메모리가 새지는 않습니다. 임시 객체가 그 자리에서 소멸하며 리소스를 해제하기 때문입니다. 문제는 “만들고 바로 버리는” 호출이 거의 항상 실수라는 점입니다. 반면 new로 만든 원시 포인터를 반환하는 함수라면 반환값을 버리는 순간 실제로 누수가 생기므로 더 강하게 필요합니다.

std::async의 future를 버림

void run_async() {
    std::async(std::launch::async, [] {
        throw std::runtime_error("비동기 오류");
    });
    // 반환된 future 임시 객체가 이 문장 끝에서 소멸
}

std::async가 돌려준 future는 공유 상태의 마지막 참조라서, 그 소멸자는 작업이 끝날 때까지 블로킹합니다. 그래서 future를 버리면 비동기로 실행한다는 의도와 달리 이 줄에서 작업이 끝날 때까지 기다리게 되고, 작업이 던진 예외는 future에 저장된 채 함께 버려집니다. C++20부터 표준 라이브러리의 std::async에는 [[nodiscard]]가 붙어 있어 이 실수를 경고하고, 직접 만든 비동기 함수도 같은 이유로 붙이는 것이 좋습니다.

[[nodiscard]] std::future<int> computeAsync(int x) {
    return std::async(std::launch::async, [x] { return heavyComputation(x); });
}
void run() {
    auto fut = computeAsync(42);
    int result = fut.get();   // 결과와 예외를 여기서 받음
}

검증 결과를 버림

[[nodiscard]] std::array<uint8_t, 32> computeSHA256(const void* data, size_t len);

void verifyIntegrity(const std::vector<uint8_t>& payload,
                     const std::array<uint8_t, 32>& expected) {
    auto hash = computeSHA256(payload.data(), payload.size());
    if (hash != expected) throw std::runtime_error("무결성 검증 실패");
}

해시나 서명 검증 함수는 결과를 쓰지 않으면 호출 자체가 아무 의미가 없고, 검증을 한 줄 알았는데 실제로는 비교하지 않는 보안 버그로 이어집니다. 부수 효과가 없는 계산 함수는 결과를 버리는 호출이 언제나 실수이므로 [[nodiscard]]의 가장 안전한 적용 대상입니다.

락 가드를 임시 객체로 만듦

std::mutex g_mtx;
class ScopedLock {
    std::mutex& mtx_;
public:
    explicit ScopedLock(std::mutex& m) : mtx_(m) { mtx_.lock(); }
    ~ScopedLock() { mtx_.unlock(); }
};
void criticalSection() {
    ScopedLock{g_mtx};   // 임시 객체: 이 문장 끝에서 바로 unlock
    doCriticalWork();    // 락 없이 실행 → 데이터 레이스
}

이름 없는 임시 객체는 그 문장이 끝나는 순간 소멸하므로 락이 곧바로 풀립니다. 여기서 중괄호를 쓴 데는 이유가 있습니다. ScopedLock(g_mtx);처럼 괄호로 쓰면 C++ 문법상 임시 객체가 아니라 g_mtx라는 이름의 ScopedLock 변수 선언으로 해석되어, 기본 생성자가 없다는 컴파일 오류(또는 지역 변수로 전역 이름을 가리는 더 이상한 동작)가 납니다. 실제 코드에서 이 실수는 std::lock_guard<std::mutex>{m};나 std::unique_lock<std::mutex>{m}; 형태로 자주 나타납니다.

C++20부터는 클래스에 [[nodiscard]]를 붙이면 이런 임시 객체 생성에도 경고가 나옵니다.

class [[nodiscard]] ScopedLock {
    std::mutex& mtx_;
public:
    explicit ScopedLock(std::mutex& m) : mtx_(m) { mtx_.lock(); }
    ~ScopedLock() { mtx_.unlock(); }
    ScopedLock(const ScopedLock&) = delete;
    ScopedLock& operator=(const ScopedLock&) = delete;
};
void criticalSection() {
    ScopedLock lock(g_mtx);   // 이름 있는 변수: 스코프 끝까지 락 유지
    doCriticalWork();
}

파일 핸들, 소켓 래퍼, 시작 시 BEGIN하고 소멸 시 COMMIT/ROLLBACK하는 트랜잭션 가드처럼 객체가 살아 있는 동안에만 의미가 있는 타입은 모두 같은 이유로 [[nodiscard]] 후보입니다. 반대로 파일 핸들 래퍼를 임시 객체로 만들면 파일이 열리자마자 닫히므로, 열린 채로 남는 것이 아니라 열어 둔 효과가 사라지는 쪽의 버그가 됩니다.


[[nodiscard]]의 의미와 문법

[[nodiscard]]는 C++17에서 도입된 속성입니다. 표준은 “nodiscard 호출”의 결과가 버려지는 값 표현식(discarded-value expression)에서 쓰이면 컴파일러가 경고하도록 권장합니다. 버려지는 값 표현식은 init();처럼 식 하나가 문장 전체인 경우가 대표적이고, 쉼표 연산자의 왼쪽 피연산자도 해당합니다. 반대로 변수 초기화, 조건식, return, 다른 함수의 인자, 연산자의 피연산자로 쓰이면 사용된 것으로 봅니다. 경고는 컴파일 시점의 진단일 뿐이라 생성되는 코드나 런타임 비용에는 영향이 없습니다.

[[nodiscard]] 함수의 반환값을 버렸을 때 g++ 경고

그림: 실제 실행 결과 — 반환값을 버린 두 줄에만 경고가 나고, 사유 문자열을 적으면 그 문장이 경고에 그대로 붙는다.

nodiscard의 의미와 문법 다이어그램(주요 항목: 변수·조건·인자·return, 식 문장으로 버려짐, (void) 캐스트, nodiscard 함수 호출, 결과가 쓰이나?, 경고 없음, 컴파일 경고)

붙일 수 있는 위치

대상표준문법
함수C++17[[nodiscard]] bool init();
클래스·구조체C++17struct [[nodiscard]] Result { ... };
열거형C++17enum class [[nodiscard]] Status { ... };
사유 메시지C++20[[nodiscard("이유")]]
생성자C++20[[nodiscard]] Guard(std::mutex&);
람다C++23[] [[nodiscard]] () { return 42; }

타입에 붙일 때는 위치가 중요합니다. 속성은 struct, class, enum class 키워드 뒤, 이름 앞에 둡니다. [[nodiscard]] enum class Status { ... };처럼 선언 맨 앞에 두면 속성이 타입이 아니라 (존재하지 않는) 선언된 변수에 붙은 것으로 해석되어, GCC는 “attribute ignored” 경고를 내고 아무 효과도 없습니다.

타입에 붙이면 그 타입을 값으로 반환하는 모든 함수가 nodiscard 호출이 됩니다. 에러 코드 열거형이나 Result 구조체처럼 여러 함수가 공유하는 반환 타입이라면 함수마다 붙이는 것보다 타입에 한 번 붙이는 편이 빠뜨릴 일이 없습니다. 단, 참조나 포인터로 반환하는 함수에는 적용되지 않습니다.

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

ErrorCode openFile(const std::string& path, FILE*& out) {
    out = std::fopen(path.c_str(), "r");
    return out ? ErrorCode::Ok : ErrorCode::IoError;
}
ErrorCode& lastError();   // 참조 반환: 타입의 nodiscard가 적용되지 않음

void process() {
    FILE* f = nullptr;
    if (openFile("data.txt", f) != ErrorCode::Ok) return;   // OK: 결과를 사용
    openFile("other.txt", f);                               // 경고
    lastError();                                            // 경고 없음
}
struct [[nodiscard]] ParseResult {
    int value;
    bool ok;
};
ParseResult parseInteger(const std::string& s);   // 함수에 따로 붙이지 않아도 경고 대상

C++23의 std::expected는 표준에서 [[nodiscard]] 클래스로 규정되어 있지 않으므로, std::expected<T, E>를 반환하는 함수에는 함수 쪽에 직접 붙여야 합니다. 직접 만든 Result 타입이라면 위처럼 타입에 붙이면 됩니다.

사유 메시지 (C++20)

C++20부터 [[nodiscard("문자열")]]로 경고에 표시할 이유를 지정할 수 있습니다.

[[nodiscard("반환된 핸들을 저장하지 않으면 리소스가 즉시 해제됩니다")]]
std::unique_ptr<Resource> createResource();

[[nodiscard("초기화 실패를 확인하지 않으면 이후 호출이 미정의 상태에서 실행됩니다")]]
bool init();

void bad() {
    createResource();   // 경고에 위 메시지가 함께 출력됨
    init();
}

메시지는 무시했을 때 무슨 일이 생기는지, 또는 호출자가 무엇을 해야 하는지를 짧게 쓰는 것이 좋습니다. 경고 한 줄만 보고도 고칠 방향을 알 수 있어야 합니다.

생성자에 붙이기 (C++20)

클래스 전체가 아니라 특정 생성자에만 붙일 수도 있습니다. 같은 타입이라도 어떤 생성 방법만 임시 객체로 쓰면 안 될 때 유용합니다.

class ResourceGuard {
public:
    [[nodiscard]] explicit ResourceGuard(Resource& r);   // 획득하는 생성자만 nodiscard
    ResourceGuard(ResourceGuard&&) noexcept;
};

람다 (C++23)

C++23부터 람다의 operator()에 속성을 붙이는 문법이 생겼습니다.

auto getValue = [] [[nodiscard]] () { return 42; };
void f() {
    getValue();          // 경고
    int x = getValue();  // OK
}

[]() [[nodiscard]] { ... }처럼 매개변수 목록 뒤에 두면 함수 타입에 붙은 속성으로 해석되어 nodiscard로 동작하지 않습니다. GCC와 Clang은 C++23 이전 모드에서도 앞의 문법을 확장으로 받아 주지만 경고를 냅니다.

의도적으로 무시하기

정말로 결과가 필요 없을 때는 (void) 캐스트로 의도를 밝힙니다.

[[nodiscard]] bool connect(const std::string& host);

void test_connection_failure() {
    // 이 테스트는 반환값이 아니라 lastError()를 검증함
    (void)connect("invalid.example.com");
    assert(lastError() == ErrorCode::ConnectionFailed);
}

std::ignore = connect(...);도 같은 효과가 있습니다. std::ignore는 원래 std::tie에서 자리를 비우는 용도로만 규정되어 있었지만 어떤 값이든 대입받을 수 있어 실제로는 C++11부터 이렇게 써 왔고, C++26에서는 P2968로 이 용법이 명세상으로도 보장됩니다. 어느 쪽이든 왜 무시하는지 주석을 남기는 것이 좋습니다.

GCC의 확장 속성 __attribute__((warn_unused_result))는 (void) 캐스트로도 경고가 꺼지지 않는다는 점이 표준 [[nodiscard]]와 다릅니다. 레거시 코드에서 이 확장을 쓰고 있다면 std::ignore나 실제로 변수에 받는 방식이 필요합니다.


경고가 기대대로 나오지 않을 때

경고가 아예 안 나옴

GCC, Clang, MSVC 모두 [[nodiscard]] 위반 경고는 기본으로 켜져 있습니다(GCC·Clang은 -Wunused-result, MSVC는 레벨 1 경고 C4834). 그런데도 경고가 없다면 다음을 확인합니다.

  • 속성이 호출하는 쪽에서 보이는 선언에 붙어 있는지 확인합니다. 헤더 선언에는 없고 .cpp의 정의에만 붙어 있으면 다른 번역 단위의 호출자는 알 수 없습니다.
  • 타입에 붙였다면 위치가 enum class [[nodiscard]] X처럼 키워드 뒤인지 확인합니다.
  • 빌드 플래그에 -Wno-unused-result가 섞여 있거나, 헤더가 시스템 헤더로 포함되어 경고가 억제되는지 확인합니다.
  • 함수 포인터나 std::function을 통해 호출하면 속성 정보가 없어 경고가 나오지 않습니다.

오버라이드에서 빠짐

속성은 상속되지 않습니다. 기반 클래스의 가상 함수에만 [[nodiscard]]가 있으면, 파생 클래스 타입으로 직접 호출할 때는 경고가 나오지 않습니다. 오버라이드에도 똑같이 붙입니다.

struct Base {
    [[nodiscard]] virtual bool validate() const { return true; }
};
struct Derived : Base {
    [[nodiscard]] bool validate() const override { return check(); }
};

표준 라이브러리 함수에서 경고가 남

C++20부터 표준은 empty(), std::async, operator new, allocate 같은 함수에 [[nodiscard]]를 붙였고, libc++와 MSVC STL은 그 밖의 많은 함수(std::make_unique 등)에도 확장으로 붙입니다. vec.empty();에서 경고가 난다면 대개 vec.clear();를 의도한 버그이므로 경고를 끄지 말고 코드를 고칩니다.

반환 타입이 복잡할 때의 위치

함수에 붙이는 속성은 선언 맨 앞에 둡니다. 반환 타입 뒤에 두면 속성이 타입에 붙은 것으로 해석되어, 컴파일러에 따라 오류가 나거나 무시됩니다.

[[nodiscard]] std::optional<std::unique_ptr<Resource>> tryCreate();   // 올바른 위치
std::optional<std::unique_ptr<Resource>> [[nodiscard]] tryCreate();   // 타입에 붙음: 의도와 다름

C++17 이전 표준과 함께 빌드

표준은 모르는 속성을 무시하도록 규정하므로, C++14 모드에서 [[nodiscard]]를 써도 보통 오류가 아니라 “unknown attribute” 경고가 나옵니다. 여러 표준을 지원하는 라이브러리라면 매크로로 감쌉니다. __cplusplus 값으로 분기하면 MSVC는 /Zc:__cplusplus 없이는 항상 199711L을 보고하므로, __has_cpp_attribute로 검사하는 편이 정확합니다.

#if defined(__has_cpp_attribute)
#  if __has_cpp_attribute(nodiscard)
#    define MYLIB_NODISCARD [[nodiscard]]
#  endif
#endif
#ifndef MYLIB_NODISCARD
#  define MYLIB_NODISCARD
#endif

MYLIB_NODISCARD bool init();

어디에 붙이고 어디에 붙이지 않나

붙일 대상은 반환값을 무시하면 거의 확실히 버그인 함수입니다. 성공·실패를 값으로 알리는 함수(bool, 에러 코드, std::expected), 리소스나 소유권을 넘겨주는 함수(스마트 포인터, 핸들), 찾았는지 여부를 담은 std::optional, 결과를 꺼내야 하는 future, 그리고 부수 효과 없이 값을 계산하는 함수가 여기에 속합니다. 마지막 범주는 UserRepository::count() const나 search() 같은 조회 함수도 포함합니다.

반대로 반환값이 부차적인 함수에는 붙이지 않습니다. 출력한 바이트 수를 돌려주는 printf류, 체이닝을 위해 *this를 돌려주는 operator<<나 빌더 메서드, 삽입 위치를 돌려주지만 대부분 무시되는 insert 같은 함수가 그렇습니다. 이런 함수에 붙이면 정상적인 호출마다 (void)가 필요해지고, 경고가 너무 많아지면 팀이 경고 자체를 무시하게 됩니다. void 함수에는 버릴 반환값이 없으므로 붙여도 의미가 없습니다.

flowchart TD
    A[함수/타입 검토] --> B{반환값이 있나?}
    B -->|void| C[적용 안 함]
    B -->|예| D{무시하면 거의 항상 버그인가?}
    D -->|에러 코드·상태| E[적용]
    D -->|리소스·RAII·future| E
    D -->|optional/expected·순수 계산| E
    D -->|부수 효과가 주목적·체이닝| F[적용 안 함]

레거시 코드에 도입할 때는 새로 작성하는 공개 API와 에러 반환 타입부터 붙이고, 기존 함수는 리팩터링하면서 하나씩 추가하는 편이 현실적입니다. 공유 에러 타입 하나에 [[nodiscard]]를 붙이면 그 타입을 쓰는 모든 함수에서 그동안 숨어 있던 무시된 호출이 한꺼번에 드러나므로, 경고 목록을 먼저 확인한 뒤 오류로 올리는 순서가 안전합니다.


CI와 정적 분석 연동

경고를 오류로

컴파일러경고오류로 승격
GCC-Wunused-result (기본 활성)-Werror=unused-result
Clang-Wunused-result (기본 활성)-Werror=unused-result
MSVCC4834 (레벨 1, 기본 활성)/we4834
if(MSVC)
  target_compile_options(myapp PRIVATE /we4834)
else()
  target_compile_options(myapp PRIVATE -Werror=unused-result)
endif()

-Werror 전체가 아니라 이 경고만 오류로 올리면, 다른 경고 때문에 빌드가 깨지는 일 없이 nodiscard 위반만 머지 전에 막을 수 있습니다.

Clang-Tidy

# .clang-tidy
Checks: 'bugprone-unused-return-value,modernize-use-nodiscard'
WarningsAsErrors: 'bugprone-unused-return-value'

modernize-use-nodiscard는 인자가 없거나 값·const 참조만 받는 const 멤버 함수처럼 반환값을 버릴 이유가 없어 보이는 함수에 [[nodiscard]]를 붙이자고 제안하고, --fix로 자동 삽입도 합니다. bugprone-unused-return-value는 [[nodiscard]]가 없는 함수 중 반환값을 버리면 안 되는 목록(std::remove, std::unique, std::async 등, 설정으로 추가 가능)을 검사합니다. std::remove는 원소를 실제로 지우지 않고 새 끝 위치를 돌려주므로, 결과를 erase에 넘기지 않으면 컨테이너 크기가 그대로인 고전적인 버그를 이 검사가 잡아 줍니다.


참고 자료