객체를 안전하게 만든다는 것
지난 장에서 우리는 클래스가 데이터와 함수를 한곳에 묶는 도구라는 것을 배웠습니다. User 같은 클래스를 만들고, 멤버 변수와 멤버 함수를 통해 객체를 표현했습니다. 그런데 클래스를 만들었다고 해서 곧바로 객체지향적인 코드가 되는 것은 아닙니다.
예를 들어 은행 계좌를 다음처럼 만들었다고 해 봅시다.
#include <iostream>
#include <string>
struct BankAccount {
std::string owner;
int balance;
};
int main() {
BankAccount account;
account.owner = "Kim";
account.balance = 10000;
account.balance = -500000;
std::cout << account.owner << "의 잔액: "
<< account.balance << "원n";
}문법적으로는 아무 문제가 없습니다. 컴파일도 잘 됩니다. 하지만 프로그램의 의미는 망가졌습니다. 일반적인 은행 계좌라면 잔액이 음수가 되어서는 안 됩니다. 적어도 우리가 만들려는 간단한 계좌 모델에서는 그렇습니다.
이 코드의 문제는 balance가 public으로 열려 있다는 데에만 있지 않습니다. 더 정확히 말하면, 객체의 상태가 아무 경로로나 바뀔 수 있다는 것이 문제입니다. 계좌 잔액은 입금하거나 출금할 때만 바뀌어야 합니다. 그런데 위 코드에서는 프로그램 어디에서든 다음과 같은 일이 가능합니다.
account.balance = -1;
account.balance = 999999999;
account.balance = account.balance * 100;은행 직원도 아닌데 금고 문을 열고 돈을 직접 집어넣거나 빼는 셈입니다. 객체가 자기 자신을 지킬 방법이 없습니다.
캡슐화는 바로 이 문제에서 출발합니다. 캡슐화는 단순히 변수를 private으로 숨기는 기술이 아닙니다. 캡슐화는 객체가 망가지지 않도록 상태 변경 경로를 통제하는 설계 원칙입니다.

캡슐화는 숨기는 것이 아니라 지키는 것이다
초보자가 캡슐화를 배울 때 가장 흔히 듣는 설명은 "멤버 변수를 private으로 만들고, 멤버 함수를 public으로 만든다"입니다. 이 설명은 완전히 틀리지는 않았지만, 핵심을 놓치기 쉽습니다.
캡슐화의 목적은 감추기 자체가 아닙니다. 감추는 이유가 중요합니다. 객체 내부의 데이터가 아무렇게나 바뀌면 객체는 자기 규칙을 지킬 수 없습니다. 그래서 데이터를 숨기고, 대신 안전한 함수만 외부에 공개합니다.
은행 계좌를 예로 들면 외부에서 직접 balance를 바꾸는 것은 위험합니다. 하지만 입금과 출금은 허용해야 합니다. 그러면 다음처럼 생각할 수 있습니다.
class BankAccount {
private:
std::string owner_;
int balance_ = 0;
public:
void deposit(int amount) {
if (amount > 0) {
balance_ += amount;
}
}
bool withdraw(int amount) {
if (amount <= 0) {
return false;
}
if (amount > balance_) {
return false;
}
balance_ -= amount;
return true;
}
int getBalance() const {
return balance_;
}
};여기서 balance_는 private입니다. 외부 코드는 balance_에 직접 접근할 수 없습니다. 대신 deposit, withdraw, getBalance라는 통로를 통해서만 계좌와 상호작용합니다.
중요한 점은 이 통로들이 아무 함수나 아니라는 것입니다. deposit은 0보다 큰 금액만 입금합니다. withdraw는 0보다 큰 금액이면서 현재 잔액 이하일 때만 출금합니다. 즉, 계좌 잔액이 음수가 되지 않도록 클래스가 스스로 지킵니다.
이것이 캡슐화의 감각입니다. 데이터는 숨기고, 규칙은 함수 안에 넣습니다.
불변식: 객체가 반드시 지켜야 하는 약속
객체를 안전하게 설계하려면 "이 객체는 어떤 상태를 절대 허용하지 않을 것인가?"를 먼저 생각해야 합니다. 객체가 살아 있는 동안 항상 참이어야 하는 조건을 불변식이라고 합니다.
불변식이라는 말은 조금 딱딱하지만 뜻은 단순합니다. "무슨 일이 있어도 지켜야 하는 규칙"입니다.
은행 계좌의 불변식은 다음처럼 정할 수 있습니다.
잔액은 음수가 될 수 없다.
입금액은 0보다 커야 한다.
출금액은 0보다 커야 한다.
출금 후에도 잔액은 음수가 되면 안 된다.
물론 실제 은행 시스템에서는 훨씬 더 많은 규칙이 필요합니다. 계좌 번호 형식, 거래 한도, 동결 계좌 여부, 통화 단위, 수수료, 이체 기록 같은 요소도 고려해야 합니다. 하지만 지금은 입문 단계이므로 가장 중요한 규칙 하나에 집중하겠습니다. 잔액은 음수가 되면 안 됩니다.
불변식은 클래스 안에서 보장되어야 합니다. 외부 코드가 "조심해서 써 주겠지"라고 기대하면 안 됩니다.
다음 코드를 봅시다.
BankAccount account;
account.deposit(10000);
account.withdraw(3000);
std::cout << account.getBalance() << "원n";이 코드를 사용하는 사람은 계좌 내부가 어떻게 생겼는지 몰라도 됩니다. balance_라는 변수를 쓰는지, 나중에 long long으로 바꾸는지, 거래 내역을 따로 저장하는지 알 필요가 없습니다. 사용자는 그저 "입금은 deposit, 출금은 withdraw, 잔액 조회는 getBalance"라는 약속만 알면 됩니다.
이 약속이 바로 public 인터페이스입니다.
public 인터페이스는 객체의 사용 설명서다
클래스에서 public으로 공개한 멤버 함수들은 객체 바깥의 코드가 사용할 수 있는 공식 통로입니다. 그래서 public 인터페이스는 단순한 함수 목록이 아닙니다. 그 객체의 사용 설명서이자 약속입니다.
예를 들어 다음과 같은 인터페이스가 있다고 합시다.
class BankAccount {
public:
void deposit(int amount);
bool withdraw(int amount);
int getBalance() const;
};이 짧은 코드만 보아도 사용자는 많은 것을 짐작할 수 있습니다. deposit은 돈을 넣는 함수입니다. withdraw는 돈을 빼는 함수이고, 성공 여부를 bool로 알려 줍니다. getBalance는 현재 잔액을 조회하는 함수입니다. 함수 이름만 보아도 계좌를 어떻게 써야 하는지 방향이 보입니다.
좋은 인터페이스는 사용자가 실수하기 어렵게 만듭니다. 반대로 나쁜 인터페이스는 사용자가 실수하도록 초대합니다.
다음과 같은 인터페이스를 생각해 봅시다.
class BankAccount {
public:
void setBalance(int amount);
int getBalance() const;
};겉으로 보면 balance_를 private으로 숨기고 setBalance를 제공했으니 캡슐화한 것처럼 보입니다. 하지만 정말 그럴까요?
setBalance는 잔액을 직접 바꾸는 함수입니다. 외부 코드가 다음처럼 호출할 수 있습니다.
account.setBalance(-10000);물론 setBalance 안에서 음수를 막을 수도 있습니다. 하지만 더 큰 문제가 남아 있습니다. 은행 계좌에서 잔액은 보통 "설정"하는 값이 아닙니다. 입금과 출금의 결과로 바뀌는 값입니다. setBalance라는 함수는 계좌의 의미를 흐립니다. 사용자는 "잔액은 마음대로 바꿔도 되는구나"라고 오해할 수 있습니다.
캡슐화는 단지 private 변수에 접근하는 함수를 만드는 일이 아닙니다. 객체가 제공해야 할 행동을 설계하는 일입니다.
getter와 setter를 남용하지 않기
많은 입문자는 클래스를 만들 때 습관적으로 모든 멤버 변수에 대해 getter와 setter를 만듭니다.
class User {
private:
std::string name_;
int age_ = 0;
public:
std::string getName() const {
return name_;
}
void setName(std::string name) {
name_ = name;
}
int getAge() const {
return age_;
}
void setAge(int age) {
age_ = age;
}
};이런 코드가 항상 나쁜 것은 아닙니다. 이름을 바꾸는 기능이 필요한 프로그램도 있고, 나이를 수정해야 하는 프로그램도 있습니다. 하지만 아무 생각 없이 모든 변수에 getter와 setter를 붙이면, 클래스는 사실상 공개 변수 묶음과 크게 다르지 않게 됩니다.
특히 setter는 조심해야 합니다. setter는 외부에서 객체 상태를 바꾸는 문입니다. 문이 많을수록 객체가 망가질 가능성도 커집니다.
예를 들어 age가 음수가 되면 안 된다면 setAge는 최소한 검사를 해야 합니다.
void setAge(int age) {
if (age >= 0) {
age_ = age;
}
}하지만 여기서 한 걸음 더 나아가 질문해야 합니다. "정말 나이를 마음대로 설정하는 기능이 필요한가?"
게임 캐릭터라면 setHp보다 takeDamage와 heal이 더 자연스러울 수 있습니다. 은행 계좌라면 setBalance보다 deposit과 withdraw가 더 자연스럽습니다. 자동차라면 setSpeed보다 accelerate와 brake가 더 자연스러울 수 있습니다.
객체를 설계할 때는 내부 데이터 이름을 그대로 드러내는 함수보다, 객체가 실제로 수행하는 행동을 함수로 만드는 편이 좋습니다.
class Character {
private:
int hp_ = 100;
public:
void takeDamage(int damage) {
if (damage <= 0) {
return;
}
hp_ -= damage;
if (hp_ < 0) {
hp_ = 0;
}
}
void heal(int amount) {
if (amount <= 0) {
return;
}
hp_ += amount;
if (hp_ > 100) {
hp_ = 100;
}
}
int getHp() const {
return hp_;
}
};이 코드는 hp_를 직접 설정하지 않습니다. 대신 피해를 받거나 회복하는 행동을 제공합니다. 덕분에 hp_는 0 이상 100 이하라는 규칙을 유지할 수 있습니다.
이것이 getter와 setter를 대하는 기본 태도입니다. 조회가 필요하면 getter를 만들 수 있습니다. 변경이 필요하면 setter부터 만들지 말고, 그 변경이 객체의 어떤 행동인지 먼저 생각해야 합니다.
const 멤버 함수: 조회와 변경을 구분하기
앞에서 getBalance 뒤에 const가 붙어 있는 것을 보았습니다.
int getBalance() const {
return balance_;
}멤버 함수 뒤의 const는 "이 함수는 객체의 상태를 바꾸지 않는다"는 뜻입니다. 즉, getBalance는 잔액을 읽기만 하고 변경하지 않습니다.
이 구분은 캡슐화에서 꽤 중요합니다. 객체의 함수를 볼 때 "이 함수는 상태를 바꾸는가?"를 알 수 있으면 코드를 이해하기 쉬워집니다.
class BankAccount {
private:
int balance_ = 0;
public:
void deposit(int amount) {
if (amount > 0) {
balance_ += amount;
}
}
int getBalance() const {
return balance_;
}
};deposit은 객체 상태를 바꿉니다. 그래서 const를 붙이면 안 됩니다. 반면 getBalance는 객체 상태를 바꾸지 않습니다. 그래서 const를 붙이는 것이 좋습니다.
나중에 코드를 읽는 사람은 함수 선언만 보고도 어느 정도 안심할 수 있습니다. getBalance를 호출해도 계좌 잔액이 몰래 바뀌지는 않겠구나, 하고 말입니다.
물론 const가 모든 설계 문제를 해결해 주지는 않습니다. 하지만 "조회 함수와 변경 함수를 구분한다"는 습관은 안전한 클래스를 만드는 첫걸음입니다.
은행 계좌 클래스 만들기
이제 본격적으로 은행 계좌 클래스를 만들어 보겠습니다. 목표는 단순합니다. 계좌 소유자 이름과 잔액을 가지고, 입금, 출금, 조회 기능을 제공합니다. 그리고 잔액이 음수가 되지 않도록 클래스 내부에서 보장합니다.
먼저 완성된 코드를 보겠습니다.
#include <iostream>
#include <string>
class BankAccount {
private:
std::string owner_;
int balance_ = 0;
public:
BankAccount(std::string owner, int initialBalance) {
owner_ = owner;
if (initialBalance >= 0) {
balance_ = initialBalance;
} else {
balance_ = 0;
}
}
void deposit(int amount) {
if (amount <= 0) {
std::cout << "입금액은 0보다 커야 합니다.n";
return;
}
balance_ += amount;
std::cout << amount << "원이 입금되었습니다.n";
}
bool withdraw(int amount) {
if (amount <= 0) {
std::cout << "출금액은 0보다 커야 합니다.n";
return false;
}
if (amount > balance_) {
std::cout << "잔액이 부족합니다.n";
return false;
}
balance_ -= amount;
std::cout << amount << "원이 출금되었습니다.n";
return true;
}
int getBalance() const {
return balance_;
}
std::string getOwner() const {
return owner_;
}
void printSummary() const {
std::cout << owner_ << "님의 현재 잔액: "
<< balance_ << "원n";
}
};
int main() {
BankAccount account("Kim", 10000);
account.printSummary();
account.deposit(5000);
account.withdraw(3000);
account.withdraw(20000);
account.deposit(-1000);
account.printSummary();
std::cout << "잔액 조회: "
<< account.getBalance() << "원n";
}실행하면 대략 다음과 같은 흐름이 나옵니다.
Kim님의 현재 잔액: 10000원
5000원이 입금되었습니다.
3000원이 출금되었습니다.
잔액이 부족합니다.
입금액은 0보다 커야 합니다.
Kim님의 현재 잔액: 12000원
잔액 조회: 12000원이 클래스에서 가장 중요한 부분은 balance_가 private이라는 사실이 아닙니다. 진짜 중요한 부분은 balance_를 바꾸는 모든 경로가 deposit과 withdraw로 제한되어 있다는 사실입니다.
외부 코드는 다음처럼 할 수 없습니다.
account.balance_ = -10000; // 컴파일 오류잔액을 바꾸려면 반드시 deposit 또는 withdraw를 호출해야 합니다. 그리고 이 함수들은 잘못된 금액을 거부합니다. 따라서 객체는 자기 불변식을 지킬 수 있습니다.
생성자에서도 불변식을 지켜야 한다
객체는 만들어지는 순간부터 올바른 상태여야 합니다. 아무리 deposit과 withdraw를 잘 만들어도, 생성자에서 잘못된 상태를 허용하면 시작부터 문제가 생깁니다.
다음 코드를 생각해 봅시다.
BankAccount account("Lee", -5000);초기 잔액으로 음수가 들어왔습니다. 이때 어떻게 해야 할까요? 이번 예제에서는 음수 초기 잔액을 0으로 보정했습니다.
BankAccount(std::string owner, int initialBalance) {
owner_ = owner;
if (initialBalance >= 0) {
balance_ = initialBalance;
} else {
balance_ = 0;
}
}실무에서는 잘못된 초기값이 들어왔을 때 예외를 던지거나, 객체 생성을 실패로 처리하거나, 오류 결과를 반환하는 별도 생성 함수를 둘 수도 있습니다. 예외 처리는 뒤에서 따로 배울 것입니다. 지금은 핵심만 기억하면 됩니다.
객체의 불변식은 생성자에서 시작되고, 모든 public 멤버 함수가 끝난 뒤에도 유지되어야 합니다.
이 문장은 아주 중요합니다. 좋은 클래스는 "처음에는 정상인데 쓰다 보면 망가지는 객체"가 아니라, "처음부터 정상이고 어떤 public 함수를 호출해도 규칙을 지키는 객체"를 목표로 합니다.
private 함수로 내부 규칙 정리하기
클래스가 조금 커지면 같은 검사를 여러 함수에서 반복하게 됩니다. 예를 들어 입금과 출금 모두 "금액은 0보다 커야 한다"는 검사를 합니다.
이런 경우 내부에서만 사용하는 도우미 함수를 만들 수 있습니다.
#include <iostream>
#include <string>
class BankAccount {
private:
std::string owner_;
int balance_ = 0;
bool isPositiveAmount(int amount) const {
return amount > 0;
}
public:
BankAccount(std::string owner, int initialBalance) {
owner_ = owner;
if (initialBalance >= 0) {
balance_ = initialBalance;
}
}
void deposit(int amount) {
if (!isPositiveAmount(amount)) {
std::cout << "금액은 0보다 커야 합니다.n";
return;
}
balance_ += amount;
}
bool withdraw(int amount) {
if (!isPositiveAmount(amount)) {
std::cout << "금액은 0보다 커야 합니다.n";
return false;
}
if (amount > balance_) {
std::cout << "잔액이 부족합니다.n";
return false;
}
balance_ -= amount;
return true;
}
void printSummary() const {
std::cout << owner_ << "님의 현재 잔액: "
<< balance_ << "원n";
}
};isPositiveAmount는 private입니다. 이 함수는 클래스 내부 규칙을 정리하기 위한 도우미일 뿐, 사용자에게 공개할 필요가 없습니다. 사용자는 deposit과 withdraw만 알면 됩니다.
이처럼 private에는 데이터만 둘 수 있는 것이 아닙니다. 내부 구현을 돕는 함수도 둘 수 있습니다. 외부에 보여 줄 필요가 없는 것은 숨기고, 외부에 필요한 약속만 public으로 남기는 것이 좋습니다.
인터페이스를 작게 유지하기
클래스의 public 함수가 많을수록 사용자는 더 많은 것을 알아야 합니다. 동시에 클래스 작성자는 더 많은 약속을 지켜야 합니다.
한번 공개한 public 함수는 쉽게 바꾸기 어렵습니다. 이미 그 함수를 사용하는 코드가 많아질 수 있기 때문입니다. 함수 이름을 바꾸거나 매개변수를 바꾸면, 그 함수를 호출하던 모든 코드가 영향을 받습니다.
반면 private 영역은 비교적 자유롭게 바꿀 수 있습니다. 외부 코드가 직접 의존하지 않기 때문입니다. 예를 들어 지금은 잔액을 int로 저장하지만, 나중에 더 큰 금액을 다루기 위해 long long으로 바꿀 수 있습니다.
class BankAccount {
private:
std::string owner_;
long long balance_ = 0;
public:
void deposit(int amount);
bool withdraw(int amount);
long long getBalance() const;
};물론 반환 타입까지 바꾸면 외부 코드에 영향이 있을 수 있습니다. 하지만 내부 계산 방식, 검증 방식, 출력 방식 등은 외부 인터페이스를 유지한 채 바꿀 수 있습니다. 이것이 캡슐화가 주는 큰 장점입니다.
캡슐화가 잘 된 클래스는 내부를 고쳐도 사용자 코드가 덜 흔들립니다. 반대로 내부 데이터가 여기저기 공개되어 있으면, 작은 변경도 프로그램 전체를 흔들 수 있습니다.
객체에게 일을 시켜라
캡슐화를 잘하려면 사고방식이 조금 바뀌어야 합니다. 객체의 데이터를 꺼내서 바깥에서 처리하려고만 하면 캡슐화가 약해집니다. 가능하면 객체에게 직접 일을 시키는 편이 좋습니다.
예를 들어 다음 코드를 봅시다.
if (account.getBalance() >= 3000) {
account.withdraw(3000);
}나쁘지는 않습니다. 하지만 출금 가능 여부를 바깥에서 먼저 판단하고 있습니다. 만약 여러 곳에서 이런 코드를 반복하면, 출금 규칙이 바깥으로 퍼집니다. 나중에 "하루 출금 한도" 같은 규칙이 추가되면 여러 곳을 고쳐야 할 수도 있습니다.
더 나은 방식은 계좌 객체가 스스로 판단하게 하는 것입니다.
if (account.withdraw(3000)) {
std::cout << "출금 성공n";
} else {
std::cout << "출금 실패n";
}이제 출금 규칙은 withdraw 안에 모입니다. 바깥 코드는 성공했는지 실패했는지만 알면 됩니다. 이것이 객체에게 일을 시킨다는 감각입니다.
캡슐화가 약한 코드는 데이터를 꺼내서 바깥에서 판단합니다. 캡슐화가 강한 코드는 객체에게 요청하고, 객체가 자기 규칙에 따라 행동합니다.
출력 책임은 어디에 둘까?
앞의 BankAccount 예제에서는 deposit과 withdraw 안에서 바로 std::cout으로 메시지를 출력했습니다. 입문 예제로는 이해하기 쉽습니다. 하지만 조금 더 진지한 프로그램을 만든다면 고민할 지점이 있습니다.
은행 계좌 클래스가 꼭 화면 출력까지 책임져야 할까요?
콘솔 프로그램에서는 괜찮아 보입니다. 하지만 나중에 같은 계좌 클래스를 웹 서버, 모바일 앱, 게임 UI에서 사용한다면 어떨까요? std::cout으로 출력하는 코드는 재사용하기 어려울 수 있습니다.
그래서 실제 설계에서는 다음처럼 계좌 클래스는 성공 여부만 알려 주고, 출력은 바깥에서 처리하게 만들기도 합니다.
class BankAccount {
private:
std::string owner_;
int balance_ = 0;
public:
BankAccount(std::string owner, int initialBalance) {
owner_ = owner;
if (initialBalance >= 0) {
balance_ = initialBalance;
}
}
bool deposit(int amount) {
if (amount <= 0) {
return false;
}
balance_ += amount;
return true;
}
bool withdraw(int amount) {
if (amount <= 0 || amount > balance_) {
return false;
}
balance_ -= amount;
return true;
}
int getBalance() const {
return balance_;
}
std::string getOwner() const {
return owner_;
}
};사용하는 쪽에서는 이렇게 출력할 수 있습니다.
int main() {
BankAccount account("Park", 10000);
if (account.deposit(5000)) {
std::cout << "입금 성공n";
} else {
std::cout << "입금 실패n";
}
if (account.withdraw(20000)) {
std::cout << "출금 성공n";
} else {
std::cout << "출금 실패n";
}
std::cout << account.getOwner() << "님의 잔액: "
<< account.getBalance() << "원n";
}어느 쪽이 항상 정답이라고 말하기는 어렵습니다. 학습용 작은 프로그램에서는 클래스 안에서 출력해도 충분합니다. 하지만 재사용 가능한 클래스를 만들고 싶다면, 핵심 로직과 출력 코드를 분리하는 편이 좋습니다.
여기서도 캡슐화의 핵심은 같습니다. 계좌의 상태를 바꾸는 규칙은 계좌 클래스 안에 있어야 합니다. 출력 형식을 어디에 둘지는 프로그램의 목적에 따라 결정할 수 있습니다.
캡슐화가 깨지는 흔한 순간
캡슐화는 처음 클래스를 만들 때만 신경 쓰는 것이 아닙니다. 코드를 고치다 보면 어느새 캡슐화가 무너질 때가 있습니다.
가장 흔한 경우는 "일단 필요하니까 public으로 열자"입니다.
class BankAccount {
public:
std::string owner_;
int balance_;
};당장은 편합니다. 테스트하기도 쉽고, 값을 넣고 빼기도 쉽습니다. 하지만 프로그램이 커질수록 누가 언제 balance_를 바꾸는지 추적하기 어려워집니다. 음수 잔액이 생겼을 때 범인을 찾으려면 전체 코드를 뒤져야 할 수도 있습니다.
또 다른 경우는 setter를 너무 많이 만드는 것입니다.
void setBalance(int balance) {
balance_ = balance;
}이 함수는 balance_를 private으로 둔 의미를 약하게 만듭니다. 이름만 함수일 뿐, 사실상 외부에서 잔액을 마음대로 바꾸게 합니다.
마지막으로, 내부 데이터를 수정 가능한 형태로 그대로 넘겨주는 것도 조심해야 합니다. 예를 들어 나중에 std::vector 같은 컨테이너를 멤버로 둘 때, 그것을 외부에서 마음대로 수정할 수 있는 참조로 반환하면 내부 규칙이 깨질 수 있습니다. std::vector는 뒤에서 더 자세히 다루겠지만, 지금은 "내부 데이터를 그대로 밖에 내주면 위험할 수 있다" 정도만 기억하면 됩니다.
캡슐화는 귀찮게 문을 잠그는 일이 아닙니다. 중요한 방의 열쇠를 관리하는 일입니다.
좋은 클래스는 사용자를 올바른 길로 이끈다
좋은 클래스는 사용자에게 많은 설명을 요구하지 않습니다. 함수 이름과 구조만 보아도 자연스럽게 올바른 사용법으로 이끕니다.
은행 계좌에 deposit과 withdraw가 있으면 사용자는 입금과 출금을 떠올립니다. setBalance가 있으면 잔액을 직접 설정해도 된다고 생각합니다. 게임 캐릭터에 takeDamage와 heal이 있으면 전투 상황이 떠오릅니다. setHp만 있으면 체력을 아무 값으로 바꾸는 코드가 생기기 쉽습니다.
클래스 설계는 작은 언어를 만드는 일과 비슷합니다. public 함수 이름 하나하나가 그 객체를 사용하는 문장이 됩니다.
account.deposit(10000);
account.withdraw(3000);
character.takeDamage(20);
character.heal(10);이 문장들은 읽기 쉽습니다. 무엇을 하는지 분명합니다. 그리고 각 객체는 자기 내부 규칙을 지킬 기회를 가집니다.
반면 다음 문장들은 조심해서 읽어야 합니다.
account.setBalance(account.getBalance() - 3000);
character.setHp(character.getHp() - 20);이 코드에서는 규칙이 바깥으로 새어 나왔습니다. 출금 가능한지, 체력이 0 아래로 내려가지는 않는지, 최대 체력을 넘지는 않는지 외부 코드가 직접 챙겨야 합니다. 이런 코드가 여러 곳에 퍼지면 버그가 숨어들기 쉬워집니다.
객체지향 프로그래밍에서 객체는 단순한 데이터 상자가 아닙니다. 자기 규칙을 알고, 자기 상태를 책임지는 작은 주체입니다.
마무리
캡슐화는 C++ 클래스 설계의 중심에 있는 개념입니다. 하지만 단순히 private과 public을 외우는 것으로는 충분하지 않습니다. 중요한 것은 객체가 어떤 상태를 가져야 하고, 어떤 상태는 절대 허용하면 안 되는지 생각하는 것입니다.
이번 장에서 만든 은행 계좌 클래스의 핵심은 잔액을 숨긴 것이 아닙니다. 잔액이 바뀌는 경로를 입금과 출금으로 제한한 것입니다. 그리고 그 함수들 안에서 잘못된 금액과 잔액 부족을 검사했습니다. 덕분에 계좌 객체는 잔액이 음수가 되지 않는다는 불변식을 스스로 지킬 수 있었습니다.
좋은 캡슐화는 코드를 답답하게 만드는 장벽이 아닙니다. 오히려 프로그램이 커졌을 때 우리를 지켜 주는 안전장치입니다. 어디서든 마음대로 바꿀 수 있는 데이터는 처음에는 편하지만, 나중에는 추적하기 어려운 버그가 됩니다. 반대로 잘 설계된 public 인터페이스는 사용자를 올바른 길로 안내하고, private 구현은 자유롭게 개선할 수 있게 해 줍니다.
다음 장에서는 생성자와 소멸자를 더 깊이 살펴봅니다. 객체가 태어나는 순간 어떤 일이 일어나는지, 객체가 사라질 때 어떤 정리가 필요한지 배우면 캡슐화의 의미가 한층 더 선명해집니다. 객체는 만들어질 때부터 올바른 상태여야 하고, 살아 있는 동안 자기 규칙을 지켜야 하며, 사라질 때도 자신이 맡은 일을 깔끔하게 마무리해야 합니다.
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.
