C++ const 정확성: const 멤버 함수·포인터·참조와 mutable을 쓸 자리
이 글의 핵심
const를 뒤늦게 붙이기 시작하면 호출하는 함수들까지 줄줄이 수정해야 하는 전파 문제가 생깁니다. const 오버로딩으로 읽기 전용 접근을 제공하는 방법, const_cast가 정의되지 않은 동작으로 이어지는 경우, const와 스레드 안전성의 관계를 짚어 처음부터 인터페이스를 올바르게 설계하는 기준을 제공합니다.
const 변수
const는 “변경 불가”를 표현해 코드 리뷰에서도 권장됩니다. mutable과 함께 쓰면 캐시·락 등 예외적으로 수정 가능한 멤버를 둘 수 있으며, 함수 기초·스마트 포인터와 조합해 읽기 전용 인터페이스를 만들 수 있습니다.
const int x = 10;
// x = 20; // 에러: const 변수는 수정 불가
const int y; // 에러: const는 선언 시 초기화 필수
const 정확성(const correctness)은 “바뀌지 않는 것에는 모두 const를 붙인다”는 규칙을 타입 시스템으로 강제하는 것입니다. 변수 하나에 const를 붙이는 것은 쉽지만, 진짜 효과는 함수 시그니처와 멤버 함수에서 나옵니다. void print(const Document& doc)라는 선언 하나로 호출자는 “이 함수가 내 문서를 바꾸지 않는다”는 보장을 받고, 함수 작성자가 실수로 수정 코드를 넣으면 컴파일러가 막습니다. 주석으로 “수정하지 않음”이라고 적는 것과 달리 코드가 바뀌어도 약속이 깨지지 않습니다.
const는 전파된다는 성질이 있습니다. const 참조로 받은 객체에서는 const 멤버 함수만 호출할 수 있으므로, 어떤 클래스의 getter에 const가 빠져 있으면 그 클래스를 const 참조로 받는 모든 함수가 getter를 부를 수 없게 됩니다. 기존 코드에 나중에 const를 도입하기 어려운 이유가 이것이고, 처음 설계할 때부터 붙이는 것이 가장 싸게 먹힙니다.
const 함수
class Point {
private:
int x, y;
public:
Point(int x, int y) : x(x), y(y) {}
// const 멤버 함수 (객체 상태 변경 안함)
int getX() const { return x; }
int getY() const { return y; }
// non-const 멤버 함수
void setX(int newX) { x = newX; }
};
int main() {
const Point p(10, 20);
cout << p.getX() << endl; // OK
// p.setX(30); // 에러: const 객체는 non-const 함수 호출 불가
}
멤버 함수 뒤의 const는 숨겨진 this 포인터의 타입을 Point*에서 const Point*로 바꿉니다. 그래서 const 멤버 함수 안에서는 모든 멤버가 const로 취급되어 수정할 수 없고, 다른 non-const 멤버 함수도 호출할 수 없습니다. 주석 처리된 p.setX(30)의 실제 에러 메시지는 GCC 기준 passing 'const Point' as 'this' argument discards qualifiers인데, 처음 보면 무슨 말인지 알기 어렵지만 “const 객체의 this를 non-const 함수에 넘기면 const가 떨어진다”는 뜻입니다.
한 가지 주의할 점은 const 멤버 함수가 보장하는 것이 비트 단위의 불변, 즉 멤버 변수 자체를 바꾸지 않는 것뿐이라는 점입니다. 멤버가 포인터라면 포인터 값은 바꿀 수 없지만 포인터가 가리키는 데이터는 바꿀 수 있습니다. int* data_; 멤버를 가진 클래스의 const 함수에서 data_[0] = 5;는 컴파일됩니다. 사용자 입장에서 보이는 “논리적 상태”가 바뀌지 않도록 하는 것은 여전히 설계자의 책임이며, 표준 라이브러리의 std::unique_ptr이나 std::vector처럼 이 구분을 제대로 해 둔 타입을 멤버로 쓰면 이런 구멍이 줄어듭니다.
const 포인터
const 포인터 패턴
| 선언 | 포인터 수정 | 값 수정 | 읽는 법 |
|---|---|---|---|
const int* ptr | ✅ | ❌ | “포인터 to const int” |
int* const ptr | ❌ | ✅ | “const 포인터 to int” |
const int* const ptr | ❌ | ❌ | “const 포인터 to const int” |
int const* ptr | ✅ | ❌ | const int*와 동일 |
int x = 10;
int y = 20;
// 1. 포인터가 가리키는 값이 const
const int* ptr1 = &x;
// *ptr1 = 20; // 에러
ptr1 = &y; // OK
// 2. 포인터 자체가 const
int* const ptr2 = &x;
*ptr2 = 20; // OK
// ptr2 = &y; // 에러
// 3. 둘 다 const
const int* const ptr3 = &x;
// *ptr3 = 20; // 에러
// ptr3 = &y; // 에러
외우는 법: const는 자기 왼쪽에 있는 것을 고정하고, 왼쪽에 아무것도 없으면 오른쪽을 고정합니다. 선언을 오른쪽에서 왼쪽으로 읽으면 헷갈리지 않습니다. int* const ptr은 “ptr은 const인 포인터, int를 가리키는”, const int* ptr(= int const* ptr)은 “ptr은 포인터, const int를 가리키는”으로 읽힙니다. 이 규칙 때문에 일부 스타일 가이드는 const를 항상 오른쪽에 쓰는 “east const”(int const*)를 선호합니다.
실무에서 가장 자주 쓰는 형태는 const T*(가리키는 대상을 읽기만 함)입니다. T* const는 포인터가 다른 곳을 가리키지 못하게 하는 것이라, 지역 변수나 멤버에 가끔 쓰는 정도입니다. 함수 매개변수에 void f(int* const p)처럼 쓰는 것은 호출자에게 아무 의미가 없습니다. 포인터가 값으로 복사되어 전달되므로 함수 안에서 p를 바꾸든 말든 호출자와 무관하기 때문이며, 선언부에서는 이 const가 함수 타입에서 무시됩니다. 같은 이유로 void f(const int x)의 const도 호출자에게는 의미가 없고, 정의부에서 실수로 x를 바꾸지 않게 하는 구현상의 선택일 뿐입니다.
const 포인터 시각화
graph TD
A[const int* ptr] --> B[ptr 변경 가능]
A --> C[*ptr 변경 불가]
D[int* const ptr] --> E[ptr 변경 불가]
D --> F[*ptr 변경 가능]
G[const int* const ptr] --> H[ptr 변경 불가]
G --> I[*ptr 변경 불가]
const 참조
void process(const vector<int>& v) {
// v를 읽기만 함 (복사 없음)
for (int x : v) {
cout << x << " ";
}
// v.push_back(10); // 에러
}
int main() {
vector<int> data = {1, 2, 3};
process(data); // 복사 없이 전달
}
const T& 매개변수는 “복사 없이 읽기만”을 표현하는 C++의 기본 도구입니다. 또 하나의 장점은 임시 객체를 받을 수 있다는 점입니다. process({1, 2, 3})이나 process(makeVector())처럼 임시값을 넘기면 non-const 참조(vector<int>&)는 컴파일 에러가 나지만, const 참조는 임시 객체를 받아 함수가 끝날 때까지 수명을 유지해 줍니다.
그렇다고 모든 매개변수를 const 참조로 받는 것이 정답은 아닙니다. int, double, 포인터처럼 레지스터 크기의 작은 타입은 값으로 받는 편이 오히려 빠릅니다. 참조는 내부적으로 주소를 넘기는 것이라 함수 안에서 값을 읽을 때마다 메모리를 거쳐야 하고, 컴파일러는 다른 포인터가 같은 메모리를 바꿀 가능성(aliasing)을 고려해야 하기 때문입니다. 또 함수 안에서 어차피 복사본을 만들어 저장할 문자열이라면 std::string 값으로 받아 std::move하는 편이 호출자가 임시 객체를 넘길 때 복사 한 번을 줄입니다. 읽기 전용 문자열 매개변수라면 C++17의 std::string_view가 const std::string&보다 유연합니다.
mutable
mutable 사용 시나리오
| 시나리오 | 예시 | 이유 |
|---|---|---|
| 캐싱 | 계산 결과 저장 | 논리적으로 const, 물리적으로 변경 |
| 통계 | 접근 횟수 카운트 | 관찰만 하는 동작 |
| 동기화 | mutable mutex | 락은 논리적 상태 아님 |
| 지연 초기화 | 첫 접근 시 초기화 | 읽기 동작이지만 내부 변경 |
class Cache {
private:
mutable int accessCount; // const 함수에서도 수정 가능
int value;
public:
Cache(int v) : value(v), accessCount(0) {}
int getValue() const {
accessCount++; // OK: mutable
return value;
}
int getAccessCount() const {
return accessCount;
}
};
int main() {
const Cache cache(42);
cout << cache.getValue() << endl; // accessCount 증가
cout << cache.getAccessCount() << endl; // 1
}
mutable은 “이 멤버는 객체의 논리적 상태가 아니다”라는 선언입니다. 접근 횟수, 계산 결과 캐시, 뮤텍스처럼 외부에서 관찰되는 값에 영향을 주지 않는 내부 부속품에만 써야 하고, 사용자에게 보이는 값에 mutable을 붙이면 const가 주는 보장 자체가 거짓말이 됩니다.
이 예제에는 멀티스레드 환경에서 문제가 되는 부분이 있습니다. C++11 이후 표준 라이브러리는 “const 멤버 함수는 여러 스레드에서 동시에 호출해도 안전하다”는 관례를 따릅니다. std::vector::size()를 여러 스레드가 동시에 불러도 되는 이유이며, 사용자 타입도 표준 컨테이너에 넣거나 표준 알고리즘에 쓰일 때 같은 가정을 받습니다. 그런데 getValue()는 const인데도 accessCount++로 멤버를 수정하므로, 두 스레드가 동시에 호출하면 데이터 경쟁이 생깁니다. mutable 멤버를 const 함수에서 수정한다면 std::atomic<int>를 쓰거나 아래 예시 3처럼 mutable std::mutex로 보호해야 합니다. 제가 코드 리뷰에서 mutable을 보면 가장 먼저 확인하는 것도 “이 객체가 여러 스레드에서 공유되는가”입니다.
또 이 클래스는 멤버를 accessCount, value 순서로 선언했는데 생성자 초기화 목록은 value, accessCount 순서입니다. 멤버는 항상 선언 순서대로 초기화되므로 동작은 같지만 -Wreorder 경고가 나며, 한 멤버의 초기화가 다른 멤버에 의존하는 경우에는 실제 버그가 됩니다.
실전 예시
예시 1: const 정확성
class String {
private:
char* data;
size_t length;
public:
String(const char* str) {
length = strlen(str);
data = new char[length + 1];
strcpy(data, str);
}
~String() {
delete[] data;
}
// const 버전
const char* c_str() const {
return data;
}
// non-const 버전
char* c_str() {
return data;
}
size_t size() const {
return length;
}
};
void print(const String& s) {
cout << s.c_str() << endl; // const 버전 호출
}
const 오버로딩은 객체가 const냐 아니냐에 따라 다른 함수가 호출되는 기법입니다. print 안의 s는 const 참조이므로 const char* c_str() const가 선택되고, 호출자는 반환된 포인터로 문자열을 수정할 수 없습니다. 반면 non-const String에서는 char* c_str()이 선택되어 수정이 가능합니다. 표준 컨테이너의 begin()과 operator[]가 모두 이런 쌍으로 제공됩니다.
다만 이 String 클래스는 const 설명에 집중하느라 중요한 것을 빠뜨렸습니다. 복사 생성자와 복사 대입 연산자를 정의하지 않았으므로 String b = a;를 하면 두 객체가 같은 data를 가리키고, 둘 다 소멸될 때 delete[]가 두 번 호출되어 크래시가 납니다(규칙 3). 또 non-const c_str()로 내부 버퍼 포인터를 내주면 호출자가 length와 맞지 않는 내용을 써 넣거나 버퍼 끝을 넘어 쓸 수 있어, 클래스가 스스로 지켜야 할 불변 조건이 외부에 노출됩니다. std::string이 C++17 전까지 data()를 const 버전만 제공했던 것도 같은 이유입니다.
예시 2: const와 반복자
#include <vector>
using namespace std;
void process(const vector<int>& v) {
// const_iterator 사용
for (vector<int>::const_iterator it = v.begin();
it != v.end(); ++it) {
cout << *it << " ";
// *it = 10; // 에러
}
// 또는 auto 사용
for (auto it = v.cbegin(); it != v.cend(); ++it) {
cout << *it << " ";
}
}
const_iterator와 const iterator는 다릅니다. vector<int>::const_iterator는 가리키는 요소를 바꿀 수 없는 반복자이고, const vector<int>::iterator는 반복자 자체를 움직일 수 없는(++it 불가) 반복자입니다. 앞의 const 포인터 표에서 const int*와 int* const의 차이와 정확히 같습니다. v가 const 참조이므로 v.begin()도 이미 const_iterator를 반환하지만, cbegin()을 쓰면 non-const 컨테이너에서도 읽기 전용 반복자를 명시적으로 얻을 수 있어 auto와 함께 쓸 때 의도가 분명해집니다. 범위 기반 for라면 for (const auto& x : v)가 가장 간단합니다.
예시 3: const와 스레드 안전성
#include <mutex>
class ThreadSafeCounter {
private:
mutable mutex mtx; // const 함수에서도 lock 가능
int count;
public:
ThreadSafeCounter() : count(0) {}
void increment() {
lock_guard<mutex> lock(mtx);
count++;
}
int getCount() const {
lock_guard<mutex> lock(mtx); // OK: mutable
return count;
}
};
std::mutex::lock()은 non-const 함수이므로, 뮤텍스가 mutable이 아니면 const 함수인 getCount()에서 잠글 수 없어 passing 'const std::mutex' as 'this' argument discards qualifiers 에러가 납니다. 뮤텍스는 보호하는 데이터에 대한 접근을 조율하는 도구일 뿐 객체의 논리적 상태가 아니므로 mutable이 정확히 맞는 자리입니다. 이 패턴은 “const 함수는 스레드 안전하다”는 관례를 실제로 지키는 방법이기도 합니다. 읽기가 쓰기보다 훨씬 많다면 mutable std::shared_mutex와 std::shared_lock으로 여러 스레드가 동시에 읽을 수 있게 할 수 있습니다.
mutable 멤버가 있으면 클래스의 복사가 까다로워진다는 점도 기억해 둡니다. std::mutex는 복사도 이동도 불가능하므로, 이 멤버를 가진 ThreadSafeCounter는 기본 복사 생성자가 삭제됩니다. 복사가 필요하다면 원본의 락을 잡고 count만 복사하는 복사 생성자를 직접 작성해야 합니다.
자주 발생하는 문제
문제 1: const 함수에서 멤버 수정
// ❌ 에러
class Bad {
private:
int x;
public:
void func() const {
x = 10; // 에러!
}
};
// ✅ mutable 사용
class Good {
private:
mutable int x;
public:
void func() const {
x = 10; // OK
}
};
문제 2: const 오버로딩
class Container {
private:
int data[10]{}; // const Container를 만들려면 초기화 필요
public:
// const 버전
const int& operator[](size_t index) const {
return data[index];
}
// non-const 버전
int& operator[](size_t index) {
return data[index];
}
};
int main() {
Container c;
c[0] = 10; // non-const 버전
const Container cc;
int x = cc[0]; // const 버전
// cc[0] = 10; // 에러
}
const 버전과 non-const 버전의 본문이 같다면 코드가 중복됩니다. 간단한 경우는 그대로 두어도 되지만, 범위 검사나 로깅이 붙은 긴 함수라면 한쪽을 수정하고 다른 쪽을 빠뜨리는 실수가 생깁니다. 전통적인 해결책은 non-const 버전이 const 버전을 호출하고 결과의 const를 벗기는 것입니다: return const_cast<int&>(std::as_const(*this)[index]);. 여기서 const_cast가 안전한 이유는 호출한 객체 자체가 원래 non-const임이 확실하기 때문입니다. 반대 방향, 즉 const 버전이 non-const 버전을 부르는 것은 const 객체를 수정할 수 있는 경로를 여는 것이라 안 됩니다. C++23에서는 “deducing this”(template<class Self> auto&& operator[](this Self&& self, size_t i))로 두 버전을 하나의 함수로 쓸 수 있습니다.
const Container cc;가 컴파일되려면 조건이 있습니다. const 객체는 반드시 초기화되어야 하는데, 사용자 제공 기본 생성자도 없고 멤버 data에 초기값도 없으면 초기화되지 않은 const 객체가 되므로 default initialization of an object of const type 'const Container' without a user-provided default constructor(Clang) 같은 에러가 납니다. 위 코드에서 int data[10]{};처럼 멤버에 초기값을 준 것이 그 때문입니다.
문제 3: const_cast
void legacyFunction(char* str) {
// 레거시 함수 (const 없음)
}
void modernFunction(const char* str) {
// const_cast로 const 제거 (위험!)
legacyFunction(const_cast<char*>(str));
}
// ✅ 더 나은 방법: 래퍼 함수
void safeWrapper(const char* str) {
char* temp = new char[strlen(str) + 1];
strcpy(temp, str);
legacyFunction(temp);
delete[] temp;
}
const_cast 자체는 정의되지 않은 동작이 아닙니다. UB가 되는 것은 원래 const로 정의된 객체를 수정하는 것입니다. modernFunction("hello")처럼 문자열 리터럴을 넘겼는데 legacyFunction이 내부에서 문자열을 수정하면, 리터럴은 읽기 전용 메모리에 있으므로 대부분의 플랫폼에서 세그멘테이션 폴트가 납니다. 반면 legacyFunction이 선언만 char*일 뿐 실제로는 읽기만 한다는 것이 확실하다면(오래된 C 라이브러리에 흔한 경우) const_cast는 합법이고 복사보다 효율적입니다. 핵심은 “상대 함수가 정말 수정하지 않는가”를 문서나 소스로 확인했는지입니다.
래퍼 함수의 방향은 맞지만 구현은 개선할 여지가 있습니다. legacyFunction이 예외를 던지면 delete[]가 실행되지 않아 메모리가 샙니다. std::string copy(str); legacyFunction(copy.data());(C++17부터 data()가 non-const char*를 반환)나 std::vector<char>를 쓰면 수동 할당과 해제가 사라지고 예외에도 안전합니다.
const 정확성 체크리스트
함수 매개변수
// ❌ 불필요한 복사
void process(vector<int> v) { }
// ✅ const 참조
void process(const vector<int>& v) { }
멤버 함수
class MyClass {
public:
// ❌ const 누락
int getValue() { return value; }
// ✅ const 추가
int getValue() const { return value; }
};
반환 타입
class String {
public:
// ❌ 내부 데이터 노출
char* getData() { return data; }
// ✅ const 반환
const char* getData() const { return data; }
};
컴파일러 최적화
// const는 컴파일러 최적화에 도움
void compute(const int* data, int size) {
// 컴파일러가 data가 변하지 않음을 알고 최적화
for (int i = 0; i < size; i++) {
// ...
}
}
이 부분은 흔히 퍼져 있는 오해라 바로잡아 둡니다. 포인터나 참조 매개변수의 const는 대부분 최적화에 도움이 되지 않습니다. const int* data는 “이 함수가 data를 통해 수정하지 않는다”는 뜻일 뿐, “데이터가 변하지 않는다”는 뜻이 아닙니다. 같은 메모리를 가리키는 다른 non-const 포인터가 있을 수 있고(aliasing), 함수 안에서 호출한 다른 함수가 그 메모리를 바꿀 수도 있으며, const_cast로 const를 벗기는 것도 원래 객체가 non-const라면 합법입니다. 그래서 컴파일러는 data[i]를 루프마다 다시 읽어야 하는 경우가 많고, const가 있든 없든 생성되는 코드는 같습니다.
const가 실제로 최적화에 기여하는 경우는 객체 자체가 const로 정의되었을 때입니다. const int table[] = {...};처럼 정의된 객체는 절대 바뀌지 않으므로 컴파일러가 값을 상수로 접어 넣거나 읽기 전용 영역에 배치할 수 있습니다. 컴파일 시점 계산까지 보장받고 싶다면 constexpr을 씁니다. 결국 const의 가치는 성능이 아니라 실수를 컴파일 단계에서 잡는 것과 인터페이스의 의도를 드러내는 것에 있습니다.
FAQ
Q1: const를 왜 사용하나요?
A:
- 의도 명확화 (이 값은 변하지 않음)
- 버그 방지 (실수로 수정 불가)
- 컴파일러 최적화
- 인터페이스 안전성
Q2: const를 어디에 붙여야 하나요?
A:
- 변하지 않는 변수
- 객체를 수정하지 않는 멤버 함수
- 복사를 피하려는 함수 매개변수
Q3: mutable은 언제 사용하나요?
A:
- 캐싱
- 로깅
- 참조 카운팅
- 뮤텍스
Q4: const_cast는 안전한가요?
A: 위험합니다. 원래 const인 객체를 수정하면 정의되지 않은 동작입니다. 레거시 코드 통합 시에만 사용하세요.
Q5: const와 성능?
A: const 자체는 런타임 오버헤드가 없습니다. 다만 포인터·참조 매개변수의 const는 aliasing 가능성 때문에 최적화에 거의 기여하지 못하고, const로 정의된 객체나 constexpr일 때 컴파일러가 값을 상수로 다룰 수 있습니다.
Q6: const 정확성을 어떻게 시작하나요?
A:
- 새 코드에서 const 습관화
- 멤버 함수에 const 추가
- 함수 매개변수를 const 참조로
- 점진적으로 개선
관련 글: mutable, 스마트 포인터, 함수 기초, 코드 리뷰 체크리스트.
같이 보면 좋은 글
- C++ mutable: const 멤버 함수에서 캐시·카운터·mutex를 수정하는 경우와 남용 주의
- C++ 스마트 포인터 | 3일 동안 찾지 못한 순환 참조 버그 해결법
- C++ 함수 처음 만들기
- C++ 코드 리뷰 | ‘체크리스트’ 20가지 (실무 필수)
- const·noexcept·[[nodiscard]]로 C++ 인터페이스 의도 명확히 하기
- C++ const 에러
- C++ constexpr 함수와 변수 | 컴파일 타임에 계산하기 [#26-1]