1. 개요
이번 항목은 캡슐화의 중요성에 대해서 다시 한번 생각해보는 항목입니다.
기본적으로 데이터 멤버를 public으로 선언하면 안되는 이유와 그것이 protected에도 똑같이 적용된다는 점에 대해서 서술합니다.
2. public으로 선언하면 안되는 이유
2-1. 문법적 일관성
https://yeoul0714.tistory.com/59
참고
[Effective C++] 항목 18: 인터페이스 설계는 제대로 쓰기엔 쉽게, 엉터리로 쓰기엔 어렵게 하자
1. 개요 우리는 C++로 개발을 하면서 정말 수많은 인터페이스를 설계하게 됩니다. 그리고 우리가 개발한 인터페이스가 올바르게 쓰여지길 바랍니다. 만약 올바르게 쓰여지지 않았다면 최소한의
yeoul0714.tistory.com
만약 데이터가 public이 아니라면 접근 방법은 오로지 멤버 함수뿐입니다.
이러한 규칙으로 일관적으로 코딩을 한다면 데이터에 접근할때 고민을 할 필요 없이 함수를 호출하게 될 것입니다.
2-2. 정교한 데이터 접근성 제어
만약 데이터 멤버가 public이라면 사용자는 이 멤버에 대해서 읽기, 쓰기의 모든 접근권한을 가지게 됩니다.
만약에 읽고 쓰는 함수가 있다면 읽기, 쓰기 권한을 마음대로 설정하는 것이 가능합니다.
읽기전용 데이터, 쓰기전용 데이터등 아주 정교하게 접근성을 제어하는 것이 가능해집니다.
아래의 클래스가 바로 정교한 데이터 접근성 제어의 예시를 보여주는 클래스입니다.
외부에 노출시켜서는 안되는 데이터들이 있기 때문에 이러한 방식은 효과적입니다.
class AccessLevels {
public:
AccessLevels() : noAccess(0), readOnly(0), readWrite(0), writeOnly(0) {}
int getReadOnly() const { return readOnly; }
void setReadWrite(int value) { readWrite = value; }
int getReadWrite() const { return readWrite; }
void setWriteOnly(int value) { writeOnly = value; }
private:
int noAccess; // 접근 불가
int readOnly; // 읽기만 가능
int readWrite; // 읽기와 쓰기 모두 가능
int writeOnly; // 쓰기만 가능
};
2-3. 캡슐화 (encapsulation)
함수를 통해서만 데이터에 접근하도록 한다면 데이터 멤버를 계산식으로 대체하는 것도 가능하고,
사용자는 이 클래스를 넘보지 못합니다.
넘보지 못한다는 의미는 아래와 같습니다.
- 접근 제한: 데이터를 private으로 숨기고 함수 인터페이스만 제공함으로써, 사용자는 클래스 설계자가 의도한 방식으로만 클래스를 사용할 수 있습니다.
- 내부 보호: 사용자는 클래스의 내부 구현에 직접 간섭하거나 "넘보는" 것이 불가능합니다. 즉, 의도하지 않은 방식으로 데이터를 수정하거나 읽는 것을 방지합니다.
- 안정적인 인터페이스: 클래스 사용자는 public 인터페이스만 알면 되므로, 내부 구현이 변경되어도 사용자 코드는 영향을 받지 않습니다.
이것보다 좀더 구체적인 예시를 들어보도록 하겠습니다.
class SpeedDataCollection
{
public:
void addValue(int speed); // 새로운 데이터 추가
double averageSoFar() const; // 평균 속도 반환
};
이 클래스는 자동차의 속도를 모니터링 하는 클래스입니다.
여기서 평균속도를 반환하는 averageSoFar함수를 어떻게 구현할지에 대해 생각해보겠습니다.
1. 지금까지 수집한 속도 데이터의 평균을 담는 변수를 만들고 그것을 반환한다.
이 경우엔 SpeedDataCollection클래스의 크기가 커집니다.
왜냐하면 평균값을 저장을 위한 공간, 데이터 수, 총합등 들어가야 할 데이터들이 있기 때문입니다.
그리고 단순히 계산된 값을 반환하기 때문에 매우 빠릅니다.
2. 함수가 호출될때마다 평균값을 계산해서 반환한다.
자체적인 속도는 1번보다 느려지지만 데이터 공간을 덜 차지합니다.
둘중 어느방법이 절대적으로 좋다고 말하기는 어렵습니다.
데이터 공간이 부족하고 평균이 자주 필요하지 않는 상황이라면 2번이 유리할 것이고
평균값이 자주 사용되고 속도가 중요하며 메모리가 충분하다면 1번이 유리할 것입니다.
결국 핵심은 캡슐화를 한다는 것입니다.
내부 구현을 원하는 대로 바꿀 수 있습니다.
2-4. 구현상의 융통성 증가
2-4.1 데이터 멤버를 읽고 쓸 때 다른 객체에 알림 메시지 보내기
class DatabaseField {
private:
std::string value;
public:
std::string getValue() const
{
return value;
}
void setValue(const std::string& newValue)
{
value = newValue;
// 다른 시스템에 알림 보내기
NotificationSystem::notifyFieldChanged(this);
}
};
이러한 예시처럼 Set, Get함수에서 이벤트를 호출하는 것이 가능합니다.
2-4.2. 불변속성, 사전/사후조건 검증
- 사전조건(precondition): 함수가 실행되기 전에 반드시 충족되어야 하는 조건
- deposit: 입금액은 양수여야 함
- withdraw: 출금액은 양수여야 함, 잔액이 출금액보다 크거나 같아야 함
- 사후조건(postcondition): 함수가 실행된 후에 보장되어야 하는 조건
- withdraw: 출금 작업 후에도 계좌는 유효한 상태를 유지해야 함
- 불변속성(invariant): 클래스의 모든 상태에서 항상 유지되어야 하는 조건
- 계좌 잔액은 항상 0 이상이어야 함
class Account {
private:
double balance;
public:
Account() : balance(0) {}
double getBalance() const { return balance; }
void deposit(double amount) {
// 사전조건(precondition) 검증
if (amount <= 0) {
throw std::invalid_argument("Deposit amount must be positive");
}
balance += amount;
// 불변속성(invariant) 검증
assert(balance >= 0); // 잔액은 항상 0 이상이어야 함
}
void withdraw(double amount) {
// 사전조건(precondition) 검증
if (amount <= 0) {
throw std::invalid_argument("Withdrawal amount must be positive");
}
// 사전조건(precondition) 검증
if (balance < amount) {
throw std::runtime_error("Insufficient funds");
}
balance -= amount;
// 사후조건(postcondition) 검증
// 출금 후에도 불변속성이 유지되는지 확인
assert(balance >= 0);
}
};
이렇듯 데이터의 상태가 유효한지 아닌지를 자유롭게 설정하고 확인하는 것이 가능해집니다.
2-4.3 스레딩 환경 동기화
class ThreadSafeCounter {
private:
int count;
std::mutex mutex;
public:
ThreadSafeCounter() : count(0) {}
int getCount() const {
std::lock_guard<std::mutex> lock(mutex);
return count;
}
void increment() {
std::lock_guard<std::mutex> lock(mutex);
count++;
}
void decrement() {
std::lock_guard<std::mutex> lock(mutex);
count--;
}
};
이러한 방식으로 캡슐화를 한뒤 함수로 접근하게 되면 매번 lock을 거는 코드를 써줄필요 없이 간단하게 상호배제가 가능해집니다.
C#의 프로퍼티와 상당히 유사한 부분입니다.
2-4.4. 현재의 구현을 나중에 바꾸기로 결정할 권한을 예약
이말의 의미는 데이터를 캡슐화 하여서 함수를 통해 데이터에 접근하도록 구현하면 후에 수정하기에 매우 편해진다는 의미입니다.
아래가 아주 간단한 예시입니다.
getArea라는 함수는 각각 미리 계산한것 return, 호출시에 계산하는 방법 이렇게 바뀌게 되었지만
사용자의 입장에서는 변환에 상관없이 getArea함수만 호출하면 됩니다.
// 버전 1: 계산 결과 저장
class Circle {
private:
double radius;
double area; // 미리 계산해서 저장
public:
Circle(double r) : radius(r) {
area = 3.14159 * radius * radius;
}
double getArea() const { return area; }
};
// 버전 2: 필요할 때 계산
class Circle {
private:
double radius;
// area 멤버 제거
public:
Circle(double r) : radius(r) {}
// 동일한 인터페이스, 다른 구현
double getArea() const {
return 3.14159 * radius * radius;
}
};
결론
public으로 선언된 것은 캡슐화되지 않았다는 의미이고, 위의 내용을 바탕으로 생각하면 바꿀 수 없다는 말을 의미하기도 합니다.
자주 쓰이는 클래스일수록 캡슐화를 철저하게 해주어야 합니다.
캡슐화는 개선할 수 있는 기능의 혜택을 가지고 있으니까 말이죠.
이러한 혜택은 자주 쓰이는 클래스가 많이 받아야 유리합니다.
3. protected도 안전하지 않다.
항목 23을 보면 어떤 것이 바뀔 때 깨질 가능성을 가진 코드가 늘어난다면 캡슐화 정도는 작아지게 된다는 설명이 있습니다.
만약 public 데이터 멤버가 있는데 이것을 제거하게 된다면 이 것을 사용하고 있던 사용자 코드들은 큰 문제를 맞이하게 됩니다.
protected역시 비슷합니다. 만약 없어지게 된다면 이 멤버를 사용중이던 파생 클래스 전부 고장나게 됩니다.
사실상 public이나 protected나 캡슐화 되지 않는 것은 똑같습니다.
public이나 protected로 선언한 데이터를 사용자가 직접 사용하기 시작하면 후에 이 데이터들을 수정하기가 매우 어려워집니다.
많은 양의 코드가 다시 쓰여져야 할 것입니다.
캡슐화 관점에서 쓸모 있는 접근은 private와 private가 아닌 나머지 이렇게 둘뿐입니다.
4. 결론
이것만은 잊지말자
- 데이터 멤버는 private 멤버로 선언합시다. 이를 통해 클래스 제작자는 문법적으로 일관성 있는 데이터 접근 통로를 제공할 수 있고, 필요에 따라서는 세밀한 접근 제어도 가능하며, 클래스의 불변속성을 강화할 수 있을 뿐 아니라, 내부 구현의 융통성도 발휘할 수 있습니다.
- protected는 public보다 더 많이 '보호'받고 있는 것이 절대로 아닙니다.
'Effective C++ > Chapter 4: 설계 및 선언' 카테고리의 다른 글
[Effective C++] 항목 21: 함수에서 객체를 반환해야 할 경우에 참조자를 반환하려고 들지 말자 (0) | 2025.05.29 |
---|---|
[Effective C++] 항목 20: '값에 의한 전달'보다는 '상수객체 참조자에 의한 전달' 방식을 택하는 편이 대개 낫다 (0) | 2025.05.29 |
[Effective C++] 항목 19: 클래스 설계는 타입 설계와 똑같이 취급하자 (0) | 2025.05.18 |
[Effective C++] 항목 18: 인터페이스 설계는 제대로 쓰기엔 쉽게, 엉터리로 쓰기엔 어렵게 하자 (0) | 2025.05.17 |