C++ nullptr vs NULL | '널 포인터' 가이드
이 글의 핵심
C++ nullptr vs NULL: "널 포인터" 가이드. nullptr 기본·NULL의 문제점.
들어가며
C++11 nullptr은 타입 안전한 널 포인터 리터럴입니다. 기존의 NULL이나 0과 달리 std::nullptr_t 타입을 가지며, 함수 오버로딩과 템플릿에서 명확한 의미를 제공합니다.
1. nullptr 기본
C++03 이전에는 널 포인터를 표현할 방법이 매크로 NULL(대개 정수 0으로 정의됨)이나 정수 리터럴 0을 직접 쓰는 것뿐이었습니다. 문제는 이 값들이 진짜 정수인지 포인터인지 컴파일러 입장에서 구분할 방법이 없다는 것인데, C++11의 nullptr은 std::nullptr_t라는 전용 타입을 가진 리터럴로 이 모호함을 근본적으로 해결합니다.
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;
}
2. NULL의 문제점
NULL이 정수 0으로 정의되어 있다는 사실은 단순한 스타일 문제가 아니라, 실제로 잘못된 오버로드가 호출되거나 타입 추론이 의도와 다르게 동작하는 실질적인 버그로 이어질 수 있습니다. 함수 오버로딩, 템플릿 타입 추론, auto 타입 추론이라는 세 가지 상황에서 이 문제가 어떻게 드러나는지 살펴보겠습니다.
문제 1: 함수 오버로딩
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)가 호출됩니다.
문제 2: 템플릿 타입 추론
템플릿 함수에서는 문제가 더 은밀해집니다. 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은 정수로 추론되어 포인터가 아닙니다.
문제 3: auto 타입 추론
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;
}
3. nullptr 사용법
nullptr의 문제점을 이해했다면, 이제 실제 코드에서 어떻게 일관되게 적용할지가 중요합니다. 포인터를 초기화하는 방법부터, 유효성을 검사하는 관용구, 함수에서 실패를 나타내는 반환값까지 세 가지 상황을 살펴보겠습니다.
포인터 초기화
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;
}
4. 실전 예제
nullptr이 실제 자료구조와 애플리케이션 코드에서 어떻게 자연스럽게 녹아드는지, 연결 리스트와 저장소 조회라는 두 가지 대표적인 예제로 확인해 보겠습니다.
예제 1: 연결 리스트
연결 리스트는 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;
}
예제 2: 옵셔널 포인터
“결과가 있을 수도 없을 수도 있다”는 상황을 표현하는 데도 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;
}
5. nullptr vs NULL vs 0 비교
지금까지 절별로 살펴본 차이들을 하나의 표로 압축하면, 왜 세 가지 표기법 중 nullptr만이 타입 안전성을 보장하는지가 한눈에 드러납니다.
| 특징 | nullptr | NULL | 0 |
|---|---|---|---|
| 타입 | std::nullptr_t | int (또는 long) | int |
| 함수 오버로딩 | 포인터 버전 호출 | 정수 버전 호출 | 정수 버전 호출 |
| 템플릿 추론 | std::nullptr_t | int | int |
| 타입 안전성 | ✓ | ✗ | ✗ |
| C++ 버전 | C++11+ | 모든 버전 | 모든 버전 |
| 권장 사용 | ✓ | ✗ | ✗ |
6. 마이그레이션 가이드
오래된 코드베이스에 NULL이 잔뜩 남아있다고 해서 당장 문제가 생기는 것은 아니지만, C++11 이상을 사용하는 프로젝트라면 점진적으로 nullptr로 옮겨가는 것이 함수 오버로딩·템플릿 관련 잠재적 버그를 예방하는 데 도움이 됩니다.
기존 코드
C++03 스타일 코드에서는 포인터 초기화, 널 체크, 기본 인자 모두에 NULL이 쓰였습니다. 아래 세 가지 패턴은 지금도 컴파일은 되지만, 앞서 살펴본 오버로딩·템플릿 추론 문제의 잠재적 원인이 그대로 남아 있는 코드입니다.
// C++03 스타일
int* ptr = NULL;
if (ptr == NULL) {
// ...
}
void func(Widget* w = NULL) {
// ...
}
C++11 스타일
같은 세 가지 상황을 nullptr로 바꾸면 코드의 겉모습은 거의 그대로이면서도 타입 안전성만 확보할 수 있습니다. 이처럼 NULL을 nullptr로 바꾸는 작업은 대부분 기계적인 텍스트 치환에 가까워서, 대규모 코드베이스에서도 비교적 안전하고 부담 없이 진행할 수 있는 현대화 작업입니다.
// C++11 스타일
int* ptr = nullptr;
if (ptr == nullptr) {
// ...
}
// 또는 간결하게
if (!ptr) {
// ...
}
void func(Widget* w = nullptr) {
// ...
}
자동 변환 (Clang-Tidy)
수백, 수천 개의 NULL이 흩어져 있는 대규모 코드베이스를 손으로 일일이 바꾸는 것은 비현실적입니다. Clang-Tidy는 modernize-use-nullptr이라는 규칙을 통해 코드 전체를 스캔해 NULL과 널을 의미하는 0을 찾아 자동으로 nullptr로 치환해주는데, 아래처럼 -fix 옵션을 붙이면 검사에 그치지 않고 실제 파일까지 수정해줘 마이그레이션 작업 시간을 크게 단축할 수 있습니다.
# Clang-Tidy로 자동 변환
clang-tidy -checks='-*,modernize-use-nullptr' -fix program.cpp
정리
핵심 요약
- nullptr: C++11 타입 안전 널 포인터
- NULL: 정수 0, 레거시
- 타입:
std::nullptr_tvsint - 오버로딩: nullptr은 포인터 버전 호출
- 템플릿: nullptr은 타입 추론 명확
- 성능: 차이 없음 (컴파일 타임)
nullptr vs NULL
| 상황 | nullptr | NULL |
|---|---|---|
| 함수 오버로딩 | 포인터 버전 호출 | 정수 버전 호출 (문제) |
| 템플릿 타입 추론 | std::nullptr_t | int (문제) |
| auto 추론 | std::nullptr_t | int (문제) |
| 가독성 | 명확 (포인터) | 모호 (정수?) |
| 타입 안전성 | 높음 | 낮음 |
실전 팁
사용 원칙:
- C++11 이후에는 항상
nullptr사용 NULL과0은 레거시 코드에서만- 포인터 비교는
ptr == nullptr또는!ptr
마이그레이션:
- 기존 코드의
NULL을nullptr로 교체 - Clang-Tidy 자동 변환 도구 활용
- 컴파일러 경고 활성화 (
-Wzero-as-null-pointer-constant)
주의사항:
- 소멸자에서 포인터를
nullptr로 설정할 필요 없음 (객체 소멸됨) delete nullptr은 안전 (아무 일도 안 함)- 스마트 포인터도
nullptr로 초기화
다음 단계
관련 글
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. Everything about C++ nullptr vs NULL : from basic concepts to practical applications.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
이 글에서 다루는 키워드 (관련 검색어)
C++, nullptr, NULL, pointer, C++11 등으로 검색하시면 이 글이 도움이 됩니다.