책/Effective C++

[Effective C++] Cp 3. const 사용의 습관화

Sonji 2026. 4. 8. 16:14

1. 왜 const를 사용해야 하는가

const를 쓰는 이유는 간단하다. "바뀌면 안 되는 값을 못 바꾸게 만들기" 위해서다.

의도의 전달

const를 붙이면 "이 값은 안 바뀐다"는 약속을 코드에 직접 적는 셈이다. 그래서 나중에 코드를 읽는 사람(미래의 나 포함)도 의도를 바로 이해할 수 있다.

컴파일러를 통한 실수 방지

const 값은 바꾸려고 하면 컴파일 단계에서 바로 에러가 난다. 프로그램을 실행하기 전에 실수를 잡을 수 있다는 뜻이다.

const int maxSize = 100;
maxSize = 200; // 컴파일 에러 → 실수를 즉시 발견

핵심은 "내가 조심하자"가 아니라 "컴파일러가 막아주게 하자"다. const를 붙일 수 있으면 가능한 붙이는 편이 안전하다.


2. const의 적용 범위

const는 생각보다 여러 곳에 적용할 수 있다.

2-1. 변수

가장 기본적인 사용법이다. 처음 정한 값이 이후에 바뀌면 안 될 때 쓴다.

const int bufferSize = 1024;
const std::string name = "sonji-log/tistory";

2-2. 포인터

포인터에서는 const를 어디에 붙이느냐가 중요하다. "가리키는 값"을 고정할지, "포인터 자체"를 고정할지가 달라진다.

int value = 10;

const int* p1 = &value;     // 가리키는 대상이 const (대상 변경 불가)
int* const p2 = &value;     // 포인터 자체가 const (다른 곳을 가리킬 수 없음)
const int* const p3 = &value; // 둘 다 const

쉽게 보면 * 기준으로 읽으면 된다. 왼쪽 const는 값 보호, 오른쪽 const는 포인터 보호다.

2-3. 반복자(Iterator)

STL 반복자도 포인터와 비슷하게 생각하면 된다.

const std::vector<int>::iterator iter = vec.begin(); // iter는 다른 곳을 가리킬 수 없다 (T* const와 동일)
*iter = 10; // OK, 가리키는 값은 변경 가능

std::vector<int>::const_iterator cIter = vec.begin(); // 가리키는 값을 변경할 수 없다 (const T*와 동일)
*cIter = 10; // 컴파일 에러

2-4. 함수 매개변수

함수 안에서 인자를 바꾸지 않겠다는 뜻을 명확히 하고 싶을 때 사용한다. 특히 참조(&)로 받을 때 자주 쓴다.

// 원본을 변경하지 않겠다는 보장
void print(const std::string& text)
{
    std::cout << text << std::endl;
    // text = "modified"; // 컴파일 에러
}

const 참조를 쓰면 복사는 줄이고, 원본은 보호할 수 있다. 그래서 기본 선택으로 많이 쓴다.

2-5. 함수 반환값

반환값에 const를 붙이면, 반환된 임시 값에 잘못 대입하는 실수를 막을 수 있다.

class Rational { /* ... */ };

const Rational operator*(const Rational& lhs, const Rational& rhs);

예를 들어 아래 같은 실수를 컴파일 단계에서 걸러준다.

Rational a, b, c;

(a * b) = c;   // const 반환이므로 컴파일 에러
                // 의도는 비교(==)였을 가능성이 높다

3. const 멤버 함수

3-1. 왜 멤버 함수에 const를 붙이는가

멤버 함수 뒤에 const를 붙이면 "이 함수는 객체 상태를 바꾸지 않는다"는 약속이 된다. 이 약속 덕분에 실제로 얻는 이점이 두 가지 있다.

  1. const 객체에서도 호출할 수 있다.
  2. 함수 선언만 보고도 '상태 변경 여부'를 판단하기 쉽다.
class TextBlock
{
public:
    // const 멤버 함수 — const 객체에서도 호출 가능
    const char& operator[](std::size_t position) const
    {
        return text[position];
    }

    // non-const 멤버 함수 — non-const 객체에서만 호출 가능
    char& operator[](std::size_t position)
    {
        return text[position];
    }

private:
    std::string text;
};
void print(const TextBlock& ctb)
{
    std::cout << ctb[0]; // OK — const 버전 호출
    // ctb[0] = 'x';     // 컴파일 에러 — const char& 반환
}

TextBlock tb("Hello");
tb[0] = 'J';             // OK — non-const 버전 호출, char& 반환

이런 const 오버로딩이 없으면, print처럼 const 참조를 받는 코드에서 operator[]를 못 쓰게 된다.

3-2. bitwise constness vs logical constness

const를 보는 기준은 두 가지가 있고, 둘이 항상 같지는 않다.

bitwise constness (물리적 상수성)

컴파일러 기준의 상수성이다. 멤버 변수가 한 비트도 바뀌지 않으면 const라고 본다. 그런데 이 기준만으로는 놓치는 경우가 있다.

class CTextBlock
{
public:
    char& operator[](std::size_t position) const
    {
        return pText[position]; // 포인터 자체는 안 바뀌니 컴파일 통과
    }

private:
    char* pText;
};

const CTextBlock cctb("Hello");
char* pc = &cctb[0];
*pc = 'J';  // const 객체의 내용이 바뀌어 버린다!

위 코드에서는 포인터 변수 자체는 그대로라서 컴파일러가 통과시킨다. 하지만 실제 문자열 내용은 바뀌었으니, 사람 입장에서는 "변경됨"이다.

logical constness (논리적 상수성)

프로그래머 기준의 상수성이다. "사용자 눈에 보이는 결과가 안 바뀌면 const로 보자"는 생각이다. 예를 들어 캐시 계산처럼 내부 최적화는 외부 동작이 같다면 const 함수 안에서 해도 괜찮다고 본다.

3-3. mutable 키워드

문제는 컴파일러가 bitwise 기준으로 검사한다는 점이다. 이때 mutable이 도움이 된다. mutable 멤버는 const 멤버 함수 안에서도 수정할 수 있다.

class CTextBlock
{
public:
    std::size_t length() const
    {
        if (!lengthIsValid)
        {
            textLength = std::strlen(pText); // mutable이므로 변경 가능
            lengthIsValid = true;            // mutable이므로 변경 가능
        }
        return textLength;
    }

private:
    char* pText;
    mutable std::size_t textLength;  // const 멤버 함수에서도 수정 가능
    mutable bool lengthIsValid;      // const 멤버 함수에서도 수정 가능
};

length()는 바깥에서 볼 때 동작이 변하지 않는다. 내부 캐시만 갱신하는 것이므로 const로 두는 게 자연스럽고, mutable이 그걸 가능하게 한다.


4. const / non-const 중복 코드 제거

4-1. 문제: 코드 중복

const 오버로딩을 하면 같은 코드가 두 번 들어가는 일이 많다. 경계 검사나 로그 같은 코드가 복붙되면 수정할 때 실수하기 쉽다.

class TextBlock
{
public:
    const char& operator[](std::size_t position) const
    {
        // 경계 검사
        // 접근 로그 기록
        // 자료 무결성 검증
        return text[position];
    }

    char& operator[](std::size_t position)
    {
        // 경계 검사          ← 중복
        // 접근 로그 기록      ← 중복
        // 자료 무결성 검증    ← 중복
        return text[position];
    }

private:
    std::string text;
};

4-2. 해결: non-const가 const를 호출한다

해결 방법은 간단하다. non-const 버전에서 const 버전을 호출하고, 마지막에 const만 벗겨서 반환한다.

class TextBlock
{
public:
    const char& operator[](std::size_t position) const
    {
        // 경계 검사, 로깅, 검증 등 모든 로직을 여기에 작성
        return text[position];
    }

    char& operator[](std::size_t position)
    {
        // 1. *this에 const를 붙여서 const 버전을 호출
        // 2. 반환된 const char&에서 const를 제거
        return const_cast<char&>(
            static_cast<const TextBlock&>(*this)[position]
        );
    }

private:
    std::string text;
};

캐스트가 두 번 보여서 처음엔 낯설 수 있다. 그래도 핵심 로직을 한 곳에만 두게 되어 유지보수가 쉬워진다.

  • static_cast<const TextBlock&>(*this) — *this에 const를 붙여서 const 버전이 호출되도록 한다.
  • const_cast<char&>(...) — const 버전이 반환한 const char&에서 const를 벗겨낸다.

4-3. 반대 방향은 안 되는 이유

반대로 const 버전에서 non-const 버전을 호출하면 위험하다. const 함수는 "안 바꾼다"가 약속인데, non-const 함수는 바꿀 수 있기 때문이다.
반면 non-const에서 const를 호출하는 건 안전하다. 원래 변경 권한이 있는 쪽에서 읽기 전용 버전을 재사용하는 형태이기 때문이다.

4-4. 그럼 const_cast를 직접 쓰면 안 될까?

const_cast 자체가 나쁜 것은 아니다. 다만 잘못 쓰면 const 약속을 깨기 쉬워서 기본 전략으로는 권장되지 않는다.

특히 아래처럼 const 함수 안에서 const_cast로 non-const 함수를 부르는 방식은 위험하다.

class TextBlock
{
public:
    const char& operator[](std::size_t position) const
    {
        return const_cast<TextBlock&>(*this)[position]; // 권장하지 않음
    }

    char& operator[](std::size_t position)
    {
        return text[position];
    }

private:
    std::string text;
};

이 코드는 "const 객체"로 들어온 경우에도 non-const 경로를 탈 수 있어 문제가 생긴다.
즉, const 객체를 실제로 수정하면 정의되지 않은 동작(Undefined Behaviour) 이 될 수 있다.

정리하면 다음처럼 기억하면 된다.

  • 중복 제거의 기본 패턴은 non-const -> const 호출
  • const_cast는 그 과정에서 반환 타입 맞출 때 최소한으로만 사용
  • const 객체를 수정할 가능성이 생기는 방향(const -> non-const)은 피하기

5. 코멘트

이론으로는 참 많이 봤는데, 실제로 코드에서 잘 쓰느냐? 하면 모르겠다.
실제로 지금 개발 패러다임처럼 대부분 코드를 AI에게 맡기는 형국에선 const가 아예 없어서 QA 레벨에 많은 논리적 버그가 생기거나 const가 너무 많아서 개발자가 알아보기 힘든 문제가 같이 발생하는 것 같다.
개인적으로 AI의 코드를 리뷰할 때 const 를 사용한 방어처리가 많이 비어서 해당 로직을 추가해달라고 자주 요청했다. 결과적으로 내 서비스를 많이 deploy 해본 것은 아니라.. 얼마나 효용성이 있었는지는 검증되지 않았다.