C++ nullptr vs NULL: NULL이 정수라서 생기는 오버로드·템플릿 버그
이 글의 핵심
C++11 이전 코드에서 NULL과 0을 섞어 쓰던 습관은 오버로딩이나 템플릿을 만나면 컴파일은 되지만 의도와 다른 함수를 부르는 버그로 이어집니다. nullptr는 어떤 포인터 타입으로도 변환되지만 정수로는 변환되지 않는 전용 타입이라 이런 모호성이 없습니다. 연결 리스트와 옵셔널 포인터 예제, NULL·0·nullptr 비교표, modernize-use-nullptr로 일괄 변환할 때 확인할 점을 정리했습니다.
들어가며
C++11 nullptr은 타입 안전한 널 포인터 리터럴입니다. 기존의 NULL이나 0과 달리 std::nullptr_t 타입을 가지며, 함수 오버로딩과 템플릿에서 명확한 의미를 제공합니다.
nullptr와 std::nullptr_t
C++03 이전에는 널 포인터를 표현할 방법이 매크로 NULL(대개 정수 0으로 정의됨)이나 정수 리터럴 0을 직접 쓰는 것뿐이었습니다. 문제는 이 값들이 진짜 정수인지 포인터인지 컴파일러 입장에서 구분할 방법이 없다는 것인데, C++11의 nullptr은 std::nullptr_t라는 전용 타입을 가진 리터럴로 이 모호함을 근본적으로 해결합니다.
NULL·0·nullptr가 대입될 때의 타입
아래 예제에서 ptr1(NULL), ptr2(0), ptr3(nullptr) 모두 결과적으로는 같은 널 포인터 값을 가리키지만, 대입되는 값의 실제 타입은 서로 다릅니다. NULL과 0은 정수 타입이 포인터 타입으로 암묵적 변환되는 것이고, nullptr은 애초에 모든 포인터 타입으로 자연스럽게 변환되도록 설계된 std::nullptr_t 타입의 값이라는 근본적인 차이가 있습니다. 이 차이가 왜 중요한지는 바로 다음 절의 함수 오버로딩 문제에서 명확하게 드러납니다.
#include <iostream>
int main() {
// C++03 이전
int* ptr1 = NULL; // 0 또는 ((void*)0)
int* ptr2 = 0; // 정수 0
// C++11 이후
int* ptr3 = nullptr; // std::nullptr_t
// 모두 널 포인터지만 타입이 다름
std::cout << "ptr1: " << ptr1 << std::endl; // 0
std::cout << "ptr2: " << ptr2 << std::endl; // 0
std::cout << "ptr3: " << ptr3 << std::endl; // 0
return 0;
}
nullptr_t로 오버로드 구분하기
nullptr의 타입인 std::nullptr_t는 <cstddef>에 정의된 독립적인 타입이라, 함수 오버로드 집합에서 int를 받는 버전과 명확하게 구분됩니다. 아래 예제에서 func(nullptr)은 정확히 func(std::nullptr_t) 오버로드를 호출하는 반면, func(NULL)을 시도하면 NULL이 정수 0으로 정의되어 있어 func(std::nullptr_t)와 func(int) 둘 다 후보가 될 수 있는 모호성 때문에 컴파일 에러가 발생합니다. 이처럼 nullptr_t라는 독립된 타입의 존재 자체가 코드에서 “포인터”와 “정수”의 의도를 컴파일러 수준에서 명확히 구분해주는 역할을 합니다.
#include <iostream>
#include <cstddef>
void func(std::nullptr_t) {
std::cout << "nullptr_t" << std::endl;
}
void func(int) {
std::cout << "int" << std::endl;
}
int main() {
func(nullptr); // "nullptr_t"
// func(NULL); // 컴파일 에러 (모호함)
// func(0); // "int"
return 0;
}
NULL이 정수라서 생기는 버그
NULL이 정수 0으로 정의되어 있다는 사실은 단순한 스타일 문제가 아니라, 실제로 잘못된 오버로드가 호출되거나 타입 추론이 의도와 다르게 동작하는 실질적인 버그로 이어질 수 있습니다. 함수 오버로딩, 템플릿 타입 추론, auto 타입 추론이라는 세 가지 상황에서 이 문제가 어떻게 드러나는지 살펴보겠습니다.
process(NULL)이 int 오버로드를 호출함
process가 int를 받는 버전과 int*를 받는 버전으로 오버로드되어 있을 때, 널 포인터를 의도하고 process(NULL)을 호출해도 실제로는 process(int)가 선택됩니다. 이는 NULL이 매크로 전개 과정에서 정수 0으로 치환되어 컴파일러가 정수 오버로드를 정확한 매치로 판단하기 때문이며, 개발자의 의도(포인터 전달)와 실제 동작(정수 전달)이 어긋나는 조용한 버그를 만들어냅니다. process(nullptr)을 사용하면 std::nullptr_t가 오직 포인터 타입으로만 변환 가능하므로 항상 올바른 포인터 오버로드가 선택됩니다.
#include <iostream>
void process(int value) {
std::cout << "정수: " << value << std::endl;
}
void process(int* ptr) {
std::cout << "포인터" << std::endl;
}
int main() {
process(0); // "정수: 0"
process(NULL); // "정수: 0" (의도하지 않음!)
process(nullptr); // "포인터" (올바름)
return 0;
}
문제: NULL은 정수 0으로 정의되어 process(int)가 호출됩니다.
템플릿에 NULL을 넘기면 T가 int로 추론됨
템플릿 함수에서는 문제가 더 은밀해집니다. func(NULL)을 호출하면 컴파일러는 NULL의 실제 타입인 int(또는 플랫폼에 따라 long)로 T를 추론하기 때문에, 겉보기엔 널 포인터를 넘긴 것 같아도 템플릿 내부에서는 정수로 취급되어 포인터 관련 연산을 시도하면 컴파일 에러가 나거나 예상과 다른 동작을 하게 됩니다. func(nullptr)을 사용하면 T가 정확히 std::nullptr_t로 추론되어, 제네릭 코드 내부에서도 “이 값은 널 포인터다”라는 정보가 타입을 통해 명확하게 전달됩니다.
#include <iostream>
template<typename T>
void func(T value) {
std::cout << "T의 타입: " << typeid(T).name() << std::endl;
}
int main() {
func(0); // T = int
func(NULL); // T = int (또는 long)
func(nullptr); // T = std::nullptr_t
return 0;
}
문제: NULL은 정수로 추론되어 포인터가 아닙니다.
auto p = NULL;은 포인터가 아니다
auto로 변수를 선언할 때도 같은 문제가 반복됩니다. auto p1 = NULL;은 p1을 정수 타입으로 추론하기 때문에, 나중에 p1을 포인터가 필요한 자리에 사용하려 하면 컴파일러 경고나 에러를 마주치게 됩니다. 반면 auto p2 = nullptr;는 p2를 std::nullptr_t로 정확히 추론해, 이후 int* ptr2 = p2;처럼 실제 포인터 타입으로 자연스럽게 대입할 수 있습니다. 널 포인터 값을 auto로 받아야 하는 상황이라면, NULL이나 0이 아니라 반드시 nullptr을 사용해야 의도한 타입을 얻을 수 있습니다.
#include <iostream>
int main() {
auto p1 = NULL; // int (또는 long)
auto p2 = nullptr; // std::nullptr_t
// ❌ p1은 정수
// int* ptr1 = p1; // 경고 또는 에러
// ✅ p2는 포인터
int* ptr2 = p2; // OK
std::cout << "p1 타입: " << typeid(p1).name() << std::endl;
std::cout << "p2 타입: " << typeid(p2).name() << std::endl;
return 0;
}
초기화·널 체크·반환값에 nullptr 쓰기
NULL의 문제점을 이해했다면, 이제 실제 코드에서 어떻게 일관되게 적용할지가 중요합니다. 포인터를 초기화하는 방법부터, 유효성을 검사하는 관용구, 함수에서 실패를 나타내는 반환값까지 세 가지 상황을 살펴보겠습니다.
원시·스마트·함수 포인터 초기화
nullptr은 원시 포인터뿐 아니라 스마트 포인터(std::unique_ptr, std::shared_ptr)와 함수 포인터에도 똑같이 사용할 수 있는 범용 널 값입니다. 아래 예제처럼 어떤 포인터 타입을 선언하든 초기값이 필요 없다는 것을 표현할 때는 항상 nullptr을 쓰는 것이 일관된 스타일이며, 이렇게 습관을 들이면 코드 전체에서 “이 포인터는 아직 아무것도 가리키지 않는다”는 의미가 명확하게 전달됩니다.
#include <iostream>
int main() {
// ✅ nullptr 사용
int* ptr1 = nullptr;
char* ptr2 = nullptr;
double* ptr3 = nullptr;
// ✅ 스마트 포인터
std::unique_ptr<int> uptr = nullptr;
std::shared_ptr<int> sptr = nullptr;
// ✅ 함수 포인터
void (*funcPtr)() = nullptr;
return 0;
}
널 체크의 세 가지 스타일
포인터가 유효한지 확인하는 방법은 크게 세 가지가 있는데, 셋 다 동일하게 동작하지만 상황에 따라 선호되는 스타일이 다릅니다. ptr == nullptr은 비교 대상이 명확히 드러나 처음 코드를 읽는 사람에게 가장 직관적이고, !ptr은 C 스타일 관용구를 계승한 간결한 표현으로 숙련된 개발자들 사이에서 널리 쓰이며, if (ptr)는 포인터가 유효할 때의 로직을 자연스럽게 표현합니다. 세 방식 모두 nullptr과의 비교이므로 앞서 본 정수 비교의 모호함 문제와 무관하게 항상 명확하게 동작합니다.
#include <iostream>
void process(int* ptr) {
// ✅ nullptr 비교 (명확함)
if (ptr == nullptr) {
std::cout << "널 포인터" << std::endl;
return;
}
// ✅ 간단한 체크 (관용적)
if (!ptr) {
std::cout << "널 포인터" << std::endl;
return;
}
// ✅ 역으로 체크
if (ptr) {
std::cout << "유효한 포인터: " << *ptr << std::endl;
}
}
int main() {
int value = 42;
int* ptr = &value;
process(ptr);
process(nullptr);
return 0;
}
”결과 없음”을 반환할 때
함수가 포인터를 반환하는 시그니처인데 특정 조건에서 “결과 없음”을 나타내야 할 때, return 0;은 문법적으로는 동작하지만 코드를 읽는 사람에게 “이게 정말 널 포인터를 의도한 것인지, 아니면 실수로 정수를 반환한 것인지” 혼란을 줄 수 있습니다. return nullptr;은 반환 타입이 포인터라는 맥락과 정확히 일치하는 값을 명시적으로 드러내, 코드 리뷰나 유지보수 과정에서 의도를 훨씬 분명하게 전달합니다.
#include <iostream>
// ❌ 0 반환 (명확하지 않음)
int* findValue1(int target) {
// ...
return 0; // 가능하지만 의도가 불명확
}
// ✅ nullptr 반환 (명확함)
int* findValue2(int target) {
// ...
return nullptr; // 명확하게 널 포인터 반환
}
int main() {
int* result = findValue2(42);
if (result == nullptr) {
std::cout << "찾지 못함" << std::endl;
}
return 0;
}
다만 “결과 없음”을 nullptr로 표현할지는 API 설계의 문제이기도 합니다. findWidget(id)처럼 컨테이너가 소유한 객체를 빌려 주는 조회라면 const Widget*를 반환하고 “없으면 nullptr”을 문서화하는 방식이 여전히 자연스럽습니다. 포인터 자체가 “소유하지 않음”이라는 의미를 함께 전달하기 때문입니다. 반대로 값 자체를 새로 만들어 돌려주는 함수라면, 결과를 힙에 올려 포인터로 넘기느니 std::optional<T>로 반환하는 편이 할당도 없고 호출자가 검사를 빼먹기도 어렵습니다. 실패 이유까지 전달해야 한다면 C++23 std::expected<T, E>가 맞습니다. 선택적인 입력 인자에도 같은 논리가 적용됩니다. void connect(const Options* opts = nullptr)처럼 기본값을 nullptr로 두면 “옵션 없음”을 표현할 수 있지만, 호출자가 임시 객체의 주소를 넘기는 실수를 막으려면 std::optional<Options>나 오버로드가 더 안전한 경우가 많습니다.
// 소유하지 않는 조회: 포인터 + nullptr 규약이 자연스러움
const Widget* findWidget(int id) const;
// 새 값을 만드는 함수: optional이 할당 없이 "없음"을 표현
std::optional<Config> parseConfig(std::string_view text);
연결 리스트와 저장소 조회 예제
nullptr이 실제 자료구조와 애플리케이션 코드에서 어떻게 자연스럽게 녹아드는지, 연결 리스트와 저장소 조회라는 두 가지 대표적인 예제로 확인해 보겠습니다.
연결 리스트의 끝 표시
연결 리스트는 nullptr이 가장 본질적으로 사용되는 자료구조입니다. 아래 Node의 생성자는 next(nullptr)로 “다음 노드가 아직 없다”는 상태를 명확히 초기화하고, LinkedList의 소멸자는 head != nullptr을 반복 조건으로 삼아 리스트 끝에 도달할 때까지 각 노드를 순서대로 해제합니다. print() 함수가 순회를 마친 뒤 “nullptr”이라는 문자열을 출력해 리스트의 끝을 시각적으로 보여주는 부분도, 널 포인터가 “여기서 리스트가 끝난다”는 신호로 얼마나 자연스럽게 쓰이는지를 잘 나타냅니다.
#include <iostream>
#include <memory>
struct Node {
int data;
Node* next;
Node(int d) : data(d), next(nullptr) {}
};
class LinkedList {
Node* head;
public:
LinkedList() : head(nullptr) {}
~LinkedList() {
while (head != nullptr) {
Node* temp = head;
head = head->next;
delete temp;
}
}
void push(int data) {
Node* newNode = new Node(data);
newNode->next = head;
head = newNode;
}
void print() const {
Node* current = head;
while (current != nullptr) {
std::cout << current->data << " -> ";
current = current->next;
}
std::cout << "nullptr" << std::endl;
}
};
int main() {
LinkedList list;
list.push(3);
list.push(2);
list.push(1);
list.print(); // 1 -> 2 -> 3 -> nullptr
return 0;
}
찾지 못하면 nullptr을 반환하는 findById
“결과가 있을 수도 없을 수도 있다”는 상황을 표현하는 데도 nullptr은 흔히 쓰이는 관용구입니다. 아래 UserRepository::findById는 주어진 ID에 해당하는 사용자를 찾으면 그 주소를 반환하고, 찾지 못하면 nullptr을 반환해 호출부가 if (user != nullptr)로 자연스럽게 성공·실패를 분기하도록 합니다. C++17 이후라면 이런 상황에 std::optional<User*>나 아예 std::optional<User>를 쓰는 것이 의미를 더 명확히 드러낼 수도 있지만, 기존 코드베이스나 성능이 민감한 경로에서는 여전히 nullptr 반환 패턴이 널리 쓰입니다.
#include <iostream>
#include <string>
class User {
public:
std::string name;
int age;
User(const std::string& n, int a) : name(n), age(a) {}
};
class UserRepository {
public:
// 찾지 못하면 nullptr 반환
User* findById(int id) {
if (id == 1) {
static User user("홍길동", 25);
return &user;
}
return nullptr;
}
};
int main() {
UserRepository repo;
User* user = repo.findById(1);
if (user != nullptr) {
std::cout << "찾음: " << user->name << std::endl;
} else {
std::cout << "찾지 못함" << std::endl;
}
User* notFound = repo.findById(999);
if (notFound == nullptr) {
std::cout << "사용자 없음" << std::endl;
}
return 0;
}
nullptr·NULL·0 비교표
지금까지 살펴본 차이들을 하나의 표로 압축하면, 왜 세 가지 표기법 중 nullptr만이 타입 안전성을 보장하는지가 한눈에 드러납니다.
| 특징 | nullptr | NULL | 0 |
|---|---|---|---|
| 타입 | std::nullptr_t | int (또는 long) | int |
| 함수 오버로딩 | 포인터 버전 호출 | 정수 버전 호출 | 정수 버전 호출 |
| 템플릿 추론 | std::nullptr_t | int | int |
| 타입 안전성 | ✓ | ✗ | ✗ |
| C++ 버전 | C++11+ | 모든 버전 | 모든 버전 |
| 권장 사용 | ✓ | ✗ | ✗ |
기존 코드의 NULL을 nullptr로 옮기기
오래된 코드베이스에 NULL이 잔뜩 남아있다고 해서 당장 문제가 생기는 것은 아니지만, C++11 이상을 사용하는 프로젝트라면 점진적으로 nullptr로 옮겨가는 것이 함수 오버로딩·템플릿 관련 잠재적 버그를 예방하는 데 도움이 됩니다.
C++03 스타일 코드
C++03 스타일 코드에서는 포인터 초기화, 널 체크, 기본 인자 모두에 NULL이 쓰였습니다. 아래 세 가지 패턴은 지금도 컴파일은 되지만, 앞서 살펴본 오버로딩·템플릿 추론 문제의 잠재적 원인이 그대로 남아 있는 코드입니다.
// C++03 스타일
int* ptr = NULL;
if (ptr == NULL) {
// ...
}
void func(Widget* w = NULL) {
// ...
}
nullptr로 바꾼 코드
같은 세 가지 상황을 nullptr로 바꾸면 코드의 겉모습은 거의 그대로이면서도 타입 안전성만 확보할 수 있습니다. 이처럼 NULL을 nullptr로 바꾸는 작업은 대부분 기계적인 텍스트 치환에 가까워서, 대규모 코드베이스에서도 비교적 안전하고 부담 없이 진행할 수 있는 현대화 작업입니다.
// C++11 스타일
int* ptr = nullptr;
if (ptr == nullptr) {
// ...
}
// 또는 간결하게
if (!ptr) {
// ...
}
void func(Widget* w = nullptr) {
// ...
}
Clang-Tidy modernize-use-nullptr로 일괄 변환
수백, 수천 개의 NULL이 흩어져 있는 대규모 코드베이스를 손으로 일일이 바꾸는 것은 비현실적입니다. Clang-Tidy는 modernize-use-nullptr이라는 규칙을 통해 코드 전체를 스캔해 NULL과 널을 의미하는 0을 찾아 자동으로 nullptr로 치환해주는데, 아래처럼 -fix 옵션을 붙이면 검사에 그치지 않고 실제 파일까지 수정해줘 마이그레이션 작업 시간을 크게 단축할 수 있습니다.
# Clang-Tidy로 자동 변환
clang-tidy -checks='-*,modernize-use-nullptr' -fix program.cpp
nullptr 정리
핵심 요약
- nullptr: C++11 타입 안전 널 포인터
- NULL: 정수 0, 레거시
- 타입:
std::nullptr_tvsint - 오버로딩: nullptr은 포인터 버전 호출
- 템플릿: nullptr은 타입 추론 명확
- 성능: 차이 없음 (컴파일 타임)
상황별 nullptr와 NULL
| 상황 | nullptr | NULL |
|---|---|---|
| 함수 오버로딩 | 포인터 버전 호출 | 정수 버전 호출 (문제) |
| 템플릿 타입 추론 | std::nullptr_t | int (문제) |
| auto 추론 | std::nullptr_t | int (문제) |
| 가독성 | 명확 (포인터) | 모호 (정수?) |
| 타입 안전성 | 높음 | 낮음 |
옮길 때 알아 둘 점
마이그레이션:
- 기존 코드의
NULL을nullptr로 교체 - Clang-Tidy 자동 변환 도구 활용
- 컴파일러 경고 활성화 (
-Wzero-as-null-pointer-constant)
주의사항:
- 소멸자에서 포인터를
nullptr로 설정할 필요 없음 (객체 소멸됨) delete nullptr은 안전 (아무 일도 안 함)- 스마트 포인터도
nullptr로 초기화
이어서 볼 글
같이 보면 좋은 글
- C++20 std::jthread: 자동 join과 stop_token으로 스레드 협조적 중단하기
- C++ 관찰 포인터: 소유하지 않는 참조를 원시 포인터·weak_ptr로 표현하는 기준
- C++ 반환 방식 고르기: 값·레퍼런스·포인터 반환, RVO를 막는 return std::move
자주 묻는 질문 (FAQ)
Q. clang-tidy의 modernize-use-nullptr로 일괄 변환할 때 주의할 점은?
A. 이 검사는 포인터 문맥에 쓰인 0과 NULL만 nullptr로 바꾸기 때문에, NULL을 정수나 문자 대신 잘못 쓰던 코드는 변환 후 컴파일 에러로 드러날 수 있습니다. 이런 에러는 원래 숨어 있던 버그이므로 되돌리기보다 해당 코드를 올바른 값으로 고치는 것이 맞습니다. 또 C 컴파일러로도 빌드되는 공용 헤더는 nullptr을 지원하지 않는 환경이 있을 수 있으니 변환 대상에서 제외하고, 변환 후에는 오버로드 선택이 달라진 호출이 없는지 테스트로 확인해야 합니다.