C++ 클래스와 객체 입문: 설계도 비유로 이해하는 생성자·접근 제어·소멸자
이 글의 핵심
학생 성적 관리, 게임 캐릭터, 도서 관리 예제로 클래스를 직접 설계해 보면서 문법을 익힙니다. 초기화하지 않은 멤버 변수, 포인터 멤버를 복사할 때 생기는 얕은 복사 문제, const 멤버 함수 누락처럼 컴파일은 되지만 나중에 문제를 일으키는 부분도 함께 살펴봅니다.
C++ 객체지향의 출발점인 클래스와 객체를 붕어빵 틀과 붕어빵 비유로 시작해, 첫 클래스 작성, 생성자, public/private 접근 제어, 소멸자 순서로 살펴봅니다. 입문자가 자주 하는 실수와 실전 예시, 자주 부딪히는 컴파일·런타임 문제도 함께 정리합니다.
클래스를 설계도로 이해하기
비유: 클래스는 “붕어빵 틀”, 객체는 “붕어빵”입니다.
클래스 = 설계도 (한 번만 정의)
객체 = 실제 제품 (여러 개 만들 수 있음)
비유를 조금 더 정확하게 풀면, 클래스 정의 자체는 메모리를 차지하지 않습니다. 컴파일러에게 “이런 모양의 데이터와 이런 동작을 묶어서 쓰겠다”고 알려 주는 타입 선언일 뿐이고, Person p1;처럼 객체를 선언하는 순간 비로소 멤버 변수만큼의 메모리가 잡힙니다. 객체마다 멤버 변수는 각자 따로 갖지만, 멤버 함수의 기계어 코드는 모든 객체가 하나를 공유합니다. 멤버 함수가 “지금 어느 객체의 name을 읽어야 하는지” 아는 것은 호출할 때 숨은 인자로 그 객체의 주소(this)가 넘어가기 때문입니다. 이 구조를 알아 두면 sizeof(Person)에 함수 개수가 영향을 주지 않는 이유, 그리고 뒤에서 배우는 const 멤버 함수가 “this가 가리키는 객체를 바꾸지 않겠다”는 약속이라는 점이 자연스럽게 이해됩니다.
첫 번째 클래스 만들기
기본 구조
#include <iostream>
#include <string>
using namespace std;
// 클래스 정의
class Person {
public:
// 멤버 변수 (속성)
string name;
int age;
// 멤버 함수 (동작)
void introduce() {
cout << "안녕하세요, " << name << "입니다. "
<< age << "살입니다." << endl;
}
};
int main() {
// 객체 생성
Person p1;
p1.name = "홍길동";
p1.age = 25;
p1.introduce(); // 안녕하세요, 홍길동입니다. 25살입니다.
// 여러 객체 생성 가능
Person p2;
p2.name = "김철수";
p2.age = 30;
p2.introduce();
return 0;
}
이 예제에서 눈여겨볼 점은 세 가지입니다. 첫째, p1.name처럼 점(.) 연산자로 멤버에 접근합니다. 포인터로 객체를 다룰 때는 ptr->name을 씁니다. 둘째, p1과 p2는 같은 클래스로 만들었지만 서로의 name과 age에 영향을 주지 않는 독립된 객체입니다. 셋째, 모든 멤버를 public으로 열어 두었기 때문에 누구나 p1.age = -5;처럼 말이 안 되는 값을 넣을 수 있습니다. 이 마지막 문제가 뒤에서 생성자와 접근 제어를 배우는 이유입니다. 또한 Person p1; 직후 name은 빈 문자열이지만 age는 초기화되지 않은 쓰레기 값이라는 점도 기억해 두세요. std::string 같은 클래스 타입 멤버는 자기 기본 생성자가 호출되지만, int 같은 기본 타입은 아무도 초기화해 주지 않습니다.
생성자 (Constructor)
기본 생성자
class Person {
public:
string name;
int age;
// 생성자: 객체 생성 시 자동 호출
Person() {
name = "이름없음";
age = 0;
cout << "Person 객체 생성됨" << endl;
}
void introduce() {
cout << name << ", " << age << "살" << endl;
}
};
int main() {
Person p; // "Person 객체 생성됨" 출력
p.introduce(); // 이름없음, 0살
}
생성자는 반환 타입이 없고 이름이 클래스와 똑같은 특수한 멤버 함수입니다. 객체가 만들어지는 시점에 컴파일러가 자동으로 호출하므로, “객체를 쓰기 전에 반드시 해야 하는 준비”를 여기에 모아 두면 초기화를 깜빡하는 실수를 구조적으로 막을 수 있습니다. 생성자를 하나도 작성하지 않으면 컴파일러가 아무 일도 하지 않는 기본 생성자를 대신 만들어 주는데, 이때 int 멤버는 여전히 초기화되지 않는다는 점이 초보자에게 가장 헷갈리는 부분입니다.
매개변수가 있는 생성자
class Person {
public:
string name;
int age;
// 매개변수 생성자
Person(string n, int a) {
name = n;
age = a;
}
void introduce() {
cout << name << ", " << age << "살" << endl;
}
};
int main() {
Person p1("홍길동", 25); // 생성과 동시에 초기화
p1.introduce(); // 홍길동, 25살
}
매개변수 생성자를 하나라도 직접 정의하면 컴파일러는 더 이상 기본 생성자를 자동으로 만들어 주지 않습니다. 그래서 위 클래스로 Person p;라고 쓰면 GCC에서는 error: no matching function for call to 'Person::Person()' 같은 에러가 납니다. 인자 없는 생성도 허용하고 싶다면 Person() = default;를 함께 선언하거나 Person(string n = "이름없음", int a = 0)처럼 기본 인자를 주면 됩니다.
또 한 가지, 위 코드처럼 생성자 본문에서 name = n;으로 대입하는 방식은 엄밀히 말하면 “초기화”가 아닙니다. 본문에 들어오기 전에 name은 이미 빈 문자열로 만들어져 있고, 본문에서 한 번 더 값을 덮어쓰는 것입니다. Person(string n, int a) : name(n), age(a) {}처럼 멤버 초기화 리스트를 쓰면 처음부터 원하는 값으로 만들어지고, const 멤버나 참조 멤버처럼 대입이 불가능한 멤버도 초기화할 수 있습니다. 뒤쪽 예제들이 모두 초기화 리스트를 쓰는 이유가 이것입니다. 초기화 리스트에 적은 순서와 무관하게 멤버는 클래스에 선언된 순서대로 초기화된다는 점도 주의하세요. 한 멤버를 다른 멤버 값으로 초기화할 때 선언 순서가 뒤바뀌어 있으면 아직 초기화되지 않은 값을 읽게 되며, GCC/Clang은 -Wall에서 -Wreorder 경고로 알려 줍니다.
접근 제어 (Access Control)
class BankAccount {
private:
// private: 클래스 내부에서만 접근 가능
int balance;
public:
// public: 외부에서 접근 가능
BankAccount() {
balance = 0;
}
void deposit(int amount) {
if (amount > 0) {
balance += amount;
cout << amount << "원 입금됨" << endl;
}
}
void withdraw(int amount) {
if (amount > 0 && balance >= amount) {
balance -= amount;
cout << amount << "원 출금됨" << endl;
} else {
cout << "잔액 부족" << endl;
}
}
int getBalance() {
return balance;
}
};
int main() {
BankAccount account;
// account.balance = 1000000; // ❌ 컴파일 에러! (private)
account.deposit(10000); // ✅ public 함수로 접근
account.withdraw(3000);
cout << "잔액: " << account.getBalance() << "원" << endl;
}
balance를 private으로 숨긴 이유는 “잔액은 음수가 될 수 없다” 같은 규칙(불변 조건)을 클래스가 스스로 지키게 하기 위해서입니다. 외부 코드가 balance를 직접 바꿀 수 있다면 이 규칙을 지키는 책임이 코드 곳곳으로 흩어지고, 한 군데만 실수해도 잘못된 상태가 만들어집니다. 반대로 모든 변경이 deposit과 withdraw를 거치게 하면 검증 로직이 한 곳에만 있으면 되고, 나중에 거래 로그를 남기거나 동시성 보호를 추가할 때도 이 두 함수만 고치면 됩니다.
처음 클래스를 짤 때 흔히 하는 실수는 모든 멤버에 getter/setter를 기계적으로 만들어 두는 것입니다. setBalance(int)까지 열어 버리면 private으로 숨긴 의미가 거의 사라집니다. 접근 제어의 핵심은 “외부에 어떤 동작을 허용할지”를 고르는 것이지 필드를 함수로 한 번 감싸는 것이 아닙니다. 참고로 class는 접근 지정자를 쓰지 않으면 기본이 private이고 struct는 public이라는 점만 다릅니다. getBalance()처럼 값을 읽기만 하는 함수에는 const를 붙이는 것이 좋은데, 그 이유는 아래 “문제 3”에서 다룹니다.
소멸자 (Destructor)
class Resource {
private:
int* data;
public:
// 생성자
Resource(int size) {
data = new int[size];
cout << "리소스 할당됨" << endl;
}
// 소멸자: 객체 소멸 시 자동 호출
~Resource() {
delete[] data;
cout << "리소스 해제됨" << endl;
}
};
int main() {
Resource r(100); // "리소스 할당됨"
// ... 사용 ...
} // 여기서 자동으로 "리소스 해제됨"
소멸자는 이름 앞에 ~가 붙고 매개변수와 반환 타입이 없으며, 클래스당 하나만 가질 수 있습니다. 스택에 만든 객체는 선언된 블록의 }에 도달할 때는 물론, 함수 중간에 return하거나 예외가 던져져서 빠져나갈 때도 빠짐없이 소멸자가 호출됩니다. 같은 블록에 객체가 여러 개 있으면 생성의 역순으로 소멸합니다. 이렇게 “생성자에서 자원을 얻고 소멸자에서 반납한다”는 패턴을 RAII라고 부르며, 파일 핸들·뮤텍스 잠금·네트워크 연결처럼 반드시 정리해야 하는 자원을 다루는 C++의 기본 전략입니다. new로 힙에 만든 객체는 예외로, delete를 호출해야만 소멸자가 실행됩니다.
다만 이 Resource 클래스를 그대로 복사하면(Resource r2 = r;) 두 객체가 같은 data를 가리키다가 둘 다 delete[]를 호출해 프로그램이 비정상 종료될 수 있습니다. 소멸자를 직접 작성해야 하는 클래스라면 복사 동작도 함께 정해야 한다는 규칙(Rule of Three)이 여기서 나오며, 아래 “문제 2”에서 자세히 봅니다.
자주 하는 실수
실수 1: 생성자 이름 오타
// ❌ 잘못된 코드
class Person {
public:
void person() { // 소문자 p - 일반 함수!
// ...
}
};
// ✅ 올바른 코드
class Person {
public:
Person() { // 클래스 이름과 동일
// ...
}
};
실수 2: private 멤버 직접 접근
class Person {
private:
int age;
public:
Person(int a) : age(a) {}
};
int main() {
Person p(25);
// cout << p.age; // ❌ 컴파일 에러!
}
실수 3: 세미콜론 누락
// ❌ 잘못된 코드
class Person {
// ...
} // 세미콜론 없음!
// ✅ 올바른 코드
class Person {
// ...
}; // 세미콜론 필수!
세미콜론 누락은 에러 메시지가 엉뚱한 곳을 가리켜서 더 헷갈립니다. 클래스 정의 뒤의 ;는 문법상 “이 타입의 변수를 함께 선언할 수 있는 자리”를 닫는 역할이라, 빠지면 컴파일러가 다음 줄의 코드를 그 선언의 일부로 해석하려 합니다. 최신 GCC는 error: expected ';' after class definition으로 친절하게 알려 주지만, 헤더 파일 끝의 클래스에서 빠진 경우에는 그 헤더를 include한 다른 파일의 다음 줄에서 알 수 없는 타입 에러가 나기도 합니다. include 직후 줄에서 이상한 에러가 나면 헤더 마지막 클래스의 };부터 확인해 보세요.
실전 예시
예시 1: 학생 성적 관리 시스템
#include <iostream>
#include <string>
#include <vector>
using namespace std;
class Student {
private:
string name;
int id;
vector<int> scores;
public:
// 생성자
Student(string n, int i) : name(n), id(i) {}
// 점수 추가
void addScore(int score) {
if (score >= 0 && score <= 100) {
scores.push_back(score);
}
}
// 평균 계산
double getAverage() const {
if (scores.empty()) return 0.0;
int sum = 0;
for (int score : scores) {
sum += score;
}
return (double)sum / scores.size();
}
// 정보 출력
void print() const {
cout << "학생: " << name << " (ID: " << id << ")" << endl;
cout << "평균 점수: " << getAverage() << endl;
}
};
int main() {
Student s1("홍길동", 2024001);
s1.addScore(85);
s1.addScore(90);
s1.addScore(88);
s1.print();
// 출력: 학생: 홍길동 (ID: 2024001)
// 평균 점수: 87.67
return 0;
}
설명: 학생 정보와 성적을 관리하는 클래스입니다. private 멤버로 데이터를 보호하며, public 메서드로 안전하게 접근합니다. addScore가 0~100 범위를 검사하기 때문에 scores에는 유효한 점수만 들어가고, getAverage는 그 전제를 믿고 계산할 수 있습니다. getAverage와 print에 const를 붙였으므로 const Student&로 전달받은 함수 안에서도 호출할 수 있습니다.
이 짧은 함수에도 함정이 두 가지 숨어 있습니다. (double)sum / scores.size()에서 형변환을 빼면 정수 나눗셈이 되어 87.67이 아니라 87이 나오고, scores.empty() 검사가 없으면 0으로 나누게 됩니다. 또 범위를 벗어난 점수를 조용히 무시한다는 점은 아쉽습니다. 실제 프로그램이라면 bool을 반환하거나 예외를 던져 호출한 쪽이 실패를 알 수 있게 하는 편이 낫습니다.
예시 2: 게임 캐릭터 시스템
#include <algorithm> // std::max
#include <iostream>
#include <string>
using namespace std;
class Character {
private:
string name;
int hp;
int maxHp;
int attack;
int defense;
public:
// 생성자
Character(string n, int h, int a, int d)
: name(n), hp(h), maxHp(h), attack(a), defense(d) {}
// 공격
void attackTarget(Character& target) {
int damage = max(1, attack - target.defense);
target.takeDamage(damage);
cout << name << "이(가) " << target.name
<< "에게 " << damage << " 데미지!" << endl;
}
// 데미지 받기
void takeDamage(int damage) {
hp -= damage;
if (hp < 0) hp = 0;
}
// 체력 회복
void heal(int amount) {
hp += amount;
if (hp > maxHp) hp = maxHp;
cout << name << " 체력 회복: " << hp << "/" << maxHp << endl;
}
// 생존 여부
bool isAlive() const {
return hp > 0;
}
// 상태 출력
void printStatus() const {
cout << name << " - HP: " << hp << "/" << maxHp
<< ", ATK: " << attack << ", DEF: " << defense << endl;
}
};
int main() {
Character hero("용사", 100, 20, 10);
Character monster("고블린", 50, 15, 5);
hero.printStatus();
monster.printStatus();
// 전투
hero.attackTarget(monster);
monster.attackTarget(hero);
hero.printStatus();
monster.printStatus();
return 0;
}
설명: 게임 캐릭터의 속성과 행동을 클래스로 구현했습니다. 캡슐화를 통해 HP가 음수가 되거나 maxHp를 초과하지 않도록 보호합니다.
attackTarget 안에서 target.defense와 target.name처럼 다른 객체의 private 멤버에 바로 접근하는 부분이 처음에는 이상해 보일 수 있습니다. C++의 접근 제어는 객체 단위가 아니라 클래스 단위로 동작하기 때문에, 같은 Character 클래스의 멤버 함수라면 다른 Character 객체의 private 멤버도 읽을 수 있습니다. 그래서 자기 자신을 friend로 선언할 필요가 없습니다(이 예제의 이전 버전에는 friend class Character;가 들어 있었는데 아무 효과가 없는 코드라 제거했습니다). friend는 다른 클래스나 함수에게 private 접근을 허락할 때만 의미가 있습니다. std::max는 <algorithm> 헤더에 있으므로 include도 추가했습니다. 어떤 표준 라이브러리 구현에서는 <iostream>이 간접적으로 포함해 줘서 없어도 컴파일되지만, 다른 환경에서는 'max' was not declared in this scope 에러가 날 수 있습니다.
max(1, attack - target.defense)로 최소 데미지를 1로 보장한 것도 설계 결정입니다. 방어력이 공격력보다 높으면 음수 데미지가 나와 오히려 상대 체력이 회복되는 버그가 생기는데, 이런 계산 규칙을 클래스 안에 두면 전투 로직을 호출하는 쪽에서 매번 신경 쓸 필요가 없습니다.
예시 3: 도서 관리 시스템
#include <iostream>
#include <string>
#include <vector>
using namespace std;
class Book {
private:
string title;
string author;
string isbn;
bool isAvailable;
public:
// 생성자
Book(string t, string a, string i)
: title(t), author(a), isbn(i), isAvailable(true) {}
// Getter
string getTitle() const { return title; }
string getAuthor() const { return author; }
string getISBN() const { return isbn; }
bool available() const { return isAvailable; }
// 대출
bool borrow() {
if (isAvailable) {
isAvailable = false;
cout << "'" << title << "' 대출 완료" << endl;
return true;
}
cout << "'" << title << "'은(는) 대출 중입니다" << endl;
return false;
}
// 반납
void returnBook() {
isAvailable = true;
cout << "'" << title << "' 반납 완료" << endl;
}
// 정보 출력
void print() const {
cout << "제목: " << title << endl;
cout << "저자: " << author << endl;
cout << "ISBN: " << isbn << endl;
cout << "상태: " << (isAvailable ? "대출 가능" : "대출 중") << endl;
}
};
class Library {
private:
vector<Book> books;
public:
// 책 추가
void addBook(const Book& book) {
books.push_back(book);
cout << "'" << book.getTitle() << "' 추가됨" << endl;
}
// 책 검색
Book* findBook(const string& title) {
for (auto& book : books) {
if (book.getTitle() == title) {
return &book;
}
}
return nullptr;
}
// 전체 책 목록
void listBooks() const {
cout << "\n=== 도서 목록 ===" << endl;
for (const auto& book : books) {
cout << book.getTitle() << " - "
<< (book.available() ? "대출 가능" : "대출 중") << endl;
}
}
};
int main() {
Library lib;
// 책 추가
lib.addBook(Book("C++ 프로그래밍", "홍길동", "978-1234567890"));
lib.addBook(Book("자료구조", "김철수", "978-0987654321"));
// 책 목록
lib.listBooks();
// 책 대출
Book* book = lib.findBook("C++ 프로그래밍");
if (book) {
book->borrow();
}
// 책 목록 (대출 후)
lib.listBooks();
return 0;
}
설명: 실제 도서관 시스템을 모델링한 예제입니다. Book 클래스와 Library 클래스가 협력하여 도서 관리 기능을 제공합니다. Book은 한 권의 상태(대출 여부)만 책임지고, Library는 여러 권을 보관·검색하는 책임만 집니다. 책임을 이렇게 나누면 대출 규칙이 바뀌어도 Book만, 검색 방식이 바뀌어도 Library만 고치면 됩니다.
이 예제에는 실제로 자주 밟는 함정이 하나 숨어 있습니다. findBook이 반환하는 Book*는 vector 내부 원소의 주소인데, 그 포인터를 들고 있는 동안 addBook으로 책을 더 추가하면 vector가 더 큰 메모리로 재할당되면서 기존 주소가 무효가 됩니다. 이후 book->borrow()를 호출하면 정의되지 않은 동작이라 운 좋으면 크래시, 운 나쁘면 조용히 엉뚱한 메모리를 바꿉니다. 저도 이런 구조를 처음 짤 때 “책이 몇 권일 때는 잘 되다가 어느 순간부터 이상해지는” 현상을 겪기 쉬웠는데, 원인이 재할당 시점에 달려 있어서 재현이 잘 안 된다는 점이 가장 곤란했습니다. 반환한 포인터는 바로 쓰고 버리거나, 인덱스나 ISBN 같은 키를 돌려주는 방식이 더 안전합니다.
자주 발생하는 문제
문제 1: 초기화되지 않은 멤버 변수
증상: 객체 생성 후 멤버 변수에 쓰레기 값이 들어있음
원인: 생성자에서 초기화하지 않음
해결법:
// ❌ 잘못된 코드
class Person {
public:
int age; // 초기화 안 됨
Person() {
// age 초기화 안함!
}
};
int main() {
Person p;
cout << p.age; // 쓰레기 값 출력
}
// ✅ 올바른 코드 (방법 1: 생성자에서 초기화)
class Person {
public:
int age;
Person() {
age = 0; // 초기화
}
};
// ✅ 올바른 코드 (방법 2: 멤버 초기화 리스트)
class Person {
public:
int age;
Person() : age(0) {} // 선언과 동시에 초기화
};
// ✅ 올바른 코드 (방법 3: 클래스 내 초기화 C++11)
class Person {
public:
int age = 0; // 기본값 설정
Person() {}
};
세 방법 중 무엇을 쓸지는 상황에 따라 다릅니다. int 하나만 놓고 보면 성능 차이는 사실상 없지만, std::string이나 vector 같은 클래스 타입 멤버는 본문에서 대입하면 “기본 생성 후 대입”으로 두 번 일하게 되므로 초기화 리스트가 낫습니다. 요즘 권장되는 방식은 방법 3처럼 선언부에 기본값을 적어 두고, 생성자마다 달라지는 값만 초기화 리스트에서 덮어쓰는 것입니다. 이렇게 하면 생성자를 여러 개 추가해도 어느 하나에서 초기화를 빠뜨릴 위험이 줄어듭니다. 초기화되지 않은 변수를 읽는 것은 단순히 쓰레기 값이 나오는 수준이 아니라 정의되지 않은 동작이므로, 디버그 빌드에서는 멀쩡하다가 최적화 빌드에서만 결과가 달라지는 일도 생깁니다. -Wall -Wextra 경고, Clang의 -fsanitize=memory, Valgrind가 이런 문제를 잡는 데 도움이 됩니다.
문제 2: 얕은 복사 vs 깊은 복사
증상: 객체 복사 후 원본을 수정하면 복사본도 변경됨
원인: 포인터 멤버 변수의 얕은 복사
해결법: 복사 생성자와 대입 연산자 직접 구현
// ❌ 문제가 있는 코드
class Array {
private:
int* data;
int size;
public:
Array(int s) : size(s) {
data = new int[size];
}
~Array() {
delete[] data;
}
// 기본 복사 생성자 사용 (얕은 복사)
};
int main() {
Array arr1(10);
Array arr2 = arr1; // 얕은 복사: 같은 메모리 가리킴
// arr1, arr2 소멸 시 이중 delete 발생!
}
// ✅ 올바른 코드 (깊은 복사)
class Array {
private:
int* data;
int size;
public:
Array(int s) : size(s) {
data = new int[size];
}
// 복사 생성자 (깊은 복사)
Array(const Array& other) : size(other.size) {
data = new int[size];
for (int i = 0; i < size; i++) {
data[i] = other.data[i];
}
}
// 대입 연산자 (깊은 복사)
Array& operator=(const Array& other) {
if (this != &other) {
delete[] data;
size = other.size;
data = new int[size];
for (int i = 0; i < size; i++) {
data[i] = other.data[i];
}
}
return *this;
}
~Array() {
delete[] data;
}
};
얕은 복사 문제는 “복사본도 같이 바뀐다”보다 프로그램 종료 시점의 크래시로 먼저 드러나는 경우가 많습니다. arr2가 먼저 소멸하며 메모리를 해제하고, 이어서 arr1이 같은 주소를 다시 delete[]하면 최근 glibc 환경에서는 free(): double free detected in tcache 2 같은 메시지와 함께 중단됩니다. 소멸자, 복사 생성자, 복사 대입 연산자 중 하나를 직접 작성해야 한다면 나머지 둘도 필요할 가능성이 높다는 것이 Rule of Three이고, C++11 이후에는 이동 생성자·이동 대입까지 포함해 Rule of Five라고 부릅니다.
위 대입 연산자는 동작하지만 delete[] data를 먼저 한 뒤 new를 하기 때문에, new가 std::bad_alloc을 던지면 객체가 이미 해제된 포인터를 들고 있는 상태로 남습니다. 새 메모리를 먼저 할당해 복사한 다음 기존 것을 해제하거나, copy-and-swap 기법을 쓰면 이 문제를 피할 수 있습니다. 하지만 입문 단계에서 가장 좋은 해결책은 애초에 원시 포인터를 멤버로 두지 않는 것입니다. int* data 대신 std::vector<int> data를 쓰면 복사·이동·해제를 vector가 모두 올바르게 처리하므로 다섯 함수를 하나도 작성하지 않아도 됩니다(Rule of Zero).
문제 3: const 멤버 함수 누락
증상: const 객체에서 멤버 함수 호출 시 컴파일 에러
원인: 멤버 함수가 객체를 수정하지 않는데도 const를 붙이지 않음
해결법: 객체를 수정하지 않는 함수에는 const 붙이기
// ❌ 문제가 있는 코드
// 타입 정의
class Point {
private:
int x, y;
public:
Point(int x, int y) : x(x), y(y) {}
int getX() { return x; } // const 없음
int getY() { return y; } // const 없음
};
void print(const Point& p) {
// cout << p.getX(); // 컴파일 에러!
// const 객체는 const 함수만 호출 가능
}
// ✅ 올바른 코드
class Point {
private:
int x, y;
public:
Point(int x, int y) : x(x), y(y) {}
int getX() const { return x; } // const 추가
int getY() const { return y; } // const 추가
void setX(int newX) { x = newX; } // 수정하므로 const 없음
};
void print(const Point& p) {
cout << p.getX() << ", " << p.getY(); // OK!
}
이 문제는 클래스를 직접 쓸 때보다 다른 사람이 쓸 때 드러납니다. 복사를 피하려고 함수 매개변수를 const Point&로 받는 것은 매우 흔한 관례인데, 그 순간 const가 없는 멤버 함수는 전부 호출할 수 없게 됩니다. GCC의 에러 메시지는 passing 'const Point' as 'this' argument discards qualifiers처럼 나와서 처음 보면 무슨 뜻인지 알기 어렵습니다. “const 객체의 this를 const가 아닌 함수에 넘기려 했다”는 뜻입니다. 나중에 한꺼번에 const를 붙이려고 하면 그 함수가 호출하는 다른 함수까지 연쇄적으로 고쳐야 하므로, 처음부터 상태를 바꾸지 않는 멤버 함수에는 습관적으로 const를 붙이는 편이 훨씬 수월합니다.
생성자 매개변수와 복사 비용
이 글의 예제들은 이해하기 쉽게 Person(string n, int a)처럼 문자열을 값으로 받았습니다. 이 경우 호출할 때 인자가 n으로 한 번 복사되고, name(n)에서 또 한 번 복사됩니다. 짧은 이름이라면 문제가 되지 않지만, 큰 문자열이나 vector를 받는 생성자라면 name(std::move(n))처럼 n의 내용을 멤버로 옮겨 복사 한 번을 줄일 수 있습니다(<utility> 헤더). 값으로 받고 move하는 방식은 호출하는 쪽이 임시 객체를 넘기든 기존 변수를 넘기든 생성자 하나로 대응할 수 있다는 장점이 있습니다. 반대로 일반 멤버 함수가 문자열을 읽기만 한다면 const string&로 받는 것이 가장 단순합니다.
Library::addBook(const Book& book)처럼 객체를 참조로 받는 것도 같은 이유입니다. 값으로 받으면 호출할 때마다 Book 전체(문자열 세 개)가 복사됩니다. 다만 이런 최적화는 코드가 올바르게 동작한 다음의 문제입니다. 입문 단계에서는 “읽기만 하면 const&, 저장해 둘 거면 값으로 받고 move”라는 두 가지 규칙만 기억해도 대부분의 경우에 충분합니다.
같이 보면 좋은 글
- C++ 함수 처음 만들기
- C++ 상속과 다형성
- C++ 디자인 패턴 종합 가이드 | Singleton·Factory·Observer·Strategy·PIMPL [2026 실전]
- C++ 시리즈 전체 보기
- C++ static 멤버 변수와 함수: 초기화 순서, 스레드 안전성, C++17 inline static
- C++ friend: private 접근을 열어 줄 때, operator<<와 대칭 연산자, getter와 비교