접근 지정자
public·private·protected가 외부 접근과 상속 경계를 제한하는 방식을 익혀 클래스의 상태와 구현을 캡슐화합니다.
앞의 두 글에서 public:과 private:을 클래스 안에 적었습니다. 이렇게 멤버를 어디에서 이름으로 쓸 수 있는지를 정하는 키워드를 접근 지정자(access specifier)라고 하며, C++에는 public, private, protected의 세 가지가 있습니다. 접근 지정자는 캡슐화를 코드로 표현하는 수단입니다.
접근 검사는 컴파일할 때 적용되는 언어 규칙입니다. 규칙을 어긴 코드는 컴파일되지 않을 뿐, 실행 중인 프로그램의 메모리를 숨기거나 공격을 막는 보안 장치는 아닙니다. 이 글은 세 지정자가 각각 누구에게 멤버를 열어 주는지와, class와 struct의 기본 접근이 어떻게 다른지를 봅니다.
C++ · access control · semantic table
접근 위치와 지정자를 함께 봐야 허용 범위가 보인다
public은 외부 계약, protected는 상속 확장 지점, private은 내부 구현입니다. 접근 검사는 소스를 컴파일할 때 적용되는 언어 규칙이며 실행 중 보안 경계는 아닙니다.
| 지정자 | 선언 클래스 문맥 | 파생 클래스 문맥 | 그 밖의 외부 코드 | 주요 설계 역할 |
|---|---|---|---|---|
public |
허용 · 선언 클래스도 자신의 공개 멤버를 사용합니다. | 허용 · 접근 제어 관점에서 그대로 사용할 수 있습니다. | 허용 · 외부 사용자가 의존하는 공개 API가 됩니다. | 안정적으로 제공할 공개 계약과 검증 진입점입니다. |
protected |
허용 · 선언 클래스의 멤버와 명시된 friend가 사용합니다. |
조건부 허용 · 비정적 멤버는 현재 파생 타입 또는 더 파생된 타입의 객체를 통해 접근해야 합니다. 임의의 Base& 객체를 통한 접근은 허용되지 않습니다. |
차단 · 일반 객체 사용자의 공개 API가 아닙니다. | 파생 타입과 결합되는 의도적인 상속 확장 지점입니다. |
private |
허용 · 선언 클래스의 멤버와 그 클래스가 명시한 friend가 사용합니다. |
차단 · 파생됐다는 이유만으로는 접근할 수 없습니다. 기반 클래스가 별도로 허가한 friend 권한은 예외입니다. |
차단 · 외부에서 직접 이름 붙일 수 없습니다. | 표현 세부, 보조 동작, 외부가 직접 바꾸지 않을 상태입니다. |
외부에 약속한 계약
선언 클래스, 파생 클래스, 외부 코드에서 접근 제어상 허용됩니다. 공개 메서드는 검증을 모으는 진입점이 되어 불변식을 지키는 데 도움을 주지만, 구현의 정확성까지 보장하지는 않습니다.
상속 계층의 확장 지점
선언 클래스의 멤버와 friend, 파생 클래스의 멤버와 friend에게 허용되고 외부에서는 차단됩니다. 파생 문맥의 비정적 멤버는 현재 파생 타입 또는 더 파생된 타입의 객체를 통해 접근해야 하며, 임의의 Base& 객체는 사용할 수 없습니다.
선언 클래스의 내부 구현
선언 클래스의 멤버와 그 클래스가 명시한 friend만 접근합니다. 파생 클래스와 외부 코드는 기본적으로 차단되며, 기반 클래스가 별도로 부여한 friend 권한만 예외입니다.
접근 제어 위반은 컴파일 시 해당 구성을 ill-formed로 만듭니다. 메모리 보호나 권한 인증을 대신하지 않으며, 공개 진입점도 올바르게 구현될 때에만 불변식 유지에 도움이 됩니다.
위 그림은 멤버를 쓰는 코드가 어디에 있는지에 따라 세 지정자가 허용하는 범위를 비교한 것입니다. 접근 지정자는 그 아래에 오는 멤버 선언에 적용되며, 다음 지정자가 나오거나 클래스 정의가 끝날 때까지 이어집니다.
public
public 멤버는 클래스 밖의 어떤 코드에서도 이름으로 쓸 수 있습니다. 클래스를 쓰는 사람이 의존해도 되는 멤버, 즉 클래스의 공개 인터페이스(interface)를 public으로 둡니다.
#include <iostream>
#include <string>
class User {
public:
std::string username;
std::string email;
void displayUserInfo() {
std::cout << "user " << username << ", email " << email << '\n';
}
};
int main() {
User user1;
user1.username = "Alice";
user1.email = "[email protected]";
user1.displayUserInfo();
}user Alice, email [email protected]이 예제는 접근 문법을 보여 주려고 멤버 변수를 공개했습니다. 하지만 공개한 멤버 변수는 밖의 코드가 직접 의존하므로, 나중에 표현을 바꾸려면 그 변수를 쓰는 코드를 모두 고쳐야 합니다. 값 사이에 지켜야 할 규칙이 있는 클래스라면 데이터는 감추고, 의미 있는 동작을 공개합니다.
private
private 멤버는 그 클래스의 멤버 함수와, 클래스가 허락한 friend에서만 쓸 수 있습니다. friend는 프렌드 함수와 프렌드 클래스에서 다룹니다. 클래스 밖의 코드는 물론, 그 클래스를 상속받은 클래스도 직접 쓸 수 없습니다.
#include <iostream>
#include <string>
class BankAccount {
private:
std::string accountNumber;
double balance = 0;
public:
BankAccount(std::string number, double initial) : accountNumber(number) {
if (initial >= 0) {
balance = initial;
}
}
void deposit(double amount) {
if (amount > 0) {
balance += amount;
std::cout << "deposit " << amount << ", balance " << balance << '\n';
} else {
std::cout << "invalid deposit\n";
}
}
void withdraw(double amount) {
if (amount > 0 && amount <= balance) {
balance -= amount;
std::cout << "withdraw " << amount << ", balance " << balance << '\n';
} else {
std::cout << "withdraw failed\n";
}
}
double getBalance() const { return balance; }
};
int main() {
BankAccount account("123-456-7890", 1000.0);
// account.balance = -500; // 컴파일 오류: balance는 private
std::cout << "initial " << account.getBalance() << '\n';
account.deposit(500.0);
account.withdraw(200.0);
account.withdraw(2000.0);
std::cout << "final " << account.getBalance() << '\n';
}initial 1000
deposit 500, balance 1500
withdraw 200, balance 1300
withdraw failed
final 1300balance는 private이므로 밖에서 바꾸는 길은 deposit과 withdraw뿐이고, 두 함수가 금액을 검사합니다. 잔액보다 큰 2000의 출금은 거부되어 최종 잔액은 1300입니다. 이처럼 공개 함수가 상태를 바꾸는 유일한 길이면, 잔액이 음수가 되지 않는다는 규칙을 그 함수 안에서만 지키면 됩니다.
다만 private이 해 주는 것은 직접 접근을 막는 것까지입니다. 검사를 빠뜨린 공개 함수가 있다면 그 함수를 통해 잘못된 상태가 될 수 있으므로, 규칙은 공개 함수들이 모두 지켜야 합니다. 또 이 예제는 금액을 double로 다루지만, 실제 금융 프로그램은 실수의 오차 때문에 정수나 전용 타입으로 금액을 표현합니다.
더 깊이: 접근 검사는 이름을 고른 뒤에 합니다
접근 지정자는 이름이 보이는지를 바꾸지 않습니다. private 멤버도 이름 찾기와 오버로드 해석에 그대로 참여하고, 접근 검사는 무엇을 쓸지 정해진 뒤 마지막 단계에서 이루어집니다. 그래서 클래스 밖의 호출에 private 오버로드가 가장 잘 맞으면, 컴파일러는 다른 public 오버로드로 물러서지 않고 접근할 수 없다는 오류를 냅니다. cppreference의 접근 지정자 설명에 따르면 이 규칙의 의도는 private을 public으로 바꾸어도 프로그램의 동작이 달라지지 않게 하는 것입니다.
같은 이유로 접근 지정은 가상 함수의 재정의를 막지 않습니다. 기반 클래스의 private 가상 함수도 파생 클래스가 재정의할 수 있으므로, 기반 클래스는 공개한 비가상 함수 안에서 private 가상 함수를 부르고 파생 클래스는 그 가상 함수만 재정의하게 하는 설계를 할 수 있습니다. 이때 바깥에서 쓸 수 있는 것은 기반 클래스가 공개한 함수뿐이어서, 호출 전후에 해야 할 검사를 기반 클래스 한 곳에 모을 수 있습니다.
또 private은 객체가 아니라 클래스 단위의 규칙입니다. BankAccount의 멤버 함수는 매개변수로 받은 다른 BankAccount 객체의 balance도 직접 읽을 수 있으며, 복사 생성자가 원본 객체의 private 멤버를 읽을 수 있는 것도 이 때문입니다.
protected
protected 멤버는 선언한 클래스와 friend, 그리고 그 클래스를 상속받은 파생 클래스(derived class)에서 쓸 수 있고, 그 밖의 코드에서는 쓸 수 없습니다. 밖에 공개할 것은 아니지만 파생 클래스가 쓰도록 열어 두는 멤버에 씁니다. 상속은 10장에서 자세히 다루므로, 여기서는 접근 범위만 봅니다.
#include <iostream>
#include <string>
class Animal {
protected:
std::string species;
int age;
public:
Animal(std::string s, int a) : species(s), age(a) {}
};
class Dog : public Animal {
private:
std::string name;
public:
Dog(const std::string& dogName, int dogAge)
: Animal("dog", dogAge), name(dogName) {}
void copyAgeFrom(const Dog& other) {
age = other.age; // 허용: other는 Dog
}
void compareWith(const Animal& base) {
// age = base.age; // 컴파일 오류: Animal 객체를 통한 접근
(void)base;
}
void display() const {
std::cout << name << " / " << species << " / " << age << '\n';
}
};
int main() {
Dog myDog("Max", 3);
Dog olderDog("Buddy", 5);
myDog.copyAgeFrom(olderDog);
myDog.display();
// myDog.age = 4; // 컴파일 오류: age는 protected
}Max / dog / 5class Dog : public Animal은 Dog가 Animal을 상속받는다는 뜻이며, Dog의 멤버 함수는 물려받은 species와 age를 쓸 수 있습니다. 그래서 display가 species를 출력하고, copyAgeFrom이 olderDog의 나이 5를 가져옵니다. main은 파생 클래스가 아니므로 myDog.age를 쓸 수 없습니다.
protected에는 미묘한 제한이 하나 있습니다. 파생 클래스 안에서도 자기 타입이나 그보다 더 파생된 타입의 객체를 통해서만 protected 멤버를 쓸 수 있고, compareWith의 base처럼 아무 Animal 객체를 통해서는 쓸 수 없습니다. 그 Animal이 Dog가 아닌 다른 파생 클래스의 객체일 수도 있기 때문입니다. compareWith의 (void)base;는 base를 void로 바꾸어 버리기만 하는 식이지만, 이 줄이 있으면 base가 쓰인 것이 되어 -Wall -Wextra로 빌드할 때 켜지는 -Wunused-parameter 경고가 나지 않습니다.
class와 struct의 기본 접근
접근 지정자를 적지 않은 멤버는 class에서는 private, struct에서는 public이 됩니다. 이 기본값은 첫 번째 접근 지정자가 나오기 전의 멤버에만 적용되며, 지정자를 적은 뒤의 뜻은 두 키워드가 같습니다.
C++ · class key · semantic table
멤버 기본 접근은 class가 private, struct가 public이다
차이는 첫 접근 지정자 이전의 시작값입니다. 명시한 public:, protected:, private:의 의미와 적용 범위는 두 클래스 키에서 같습니다.
| 비교 항목 | class |
struct |
|---|---|---|
| 첫 지정자 이전 | private가 기본입니다. |
public이 기본입니다. |
| 명시한 지정자 | public:, protected:, private:를 모두 같은 의미로 사용할 수 있습니다. |
public:, protected:, private:를 모두 같은 의미로 사용할 수 있습니다. |
| 적용 범위 | 지정자 뒤부터 다음 지정자 또는 클래스 정의 끝까지 적용됩니다. | 지정자 뒤부터 다음 지정자 또는 클래스 정의 끝까지 적용됩니다. |
| 흔한 관례 | 불변식을 관리하는 추상화에 자주 쓰지만 언어가 강제하지 않습니다. | 공개 데이터 묶음에 자주 쓰지만 언어가 강제하지 않습니다. |
첫 멤버는 기본적으로 private
첫 지정자 이전만 닫힌 상태로 시작합니다. 이후에는 명시한 public:, protected:, private:가 다음 지정자 또는 정의 끝까지 적용됩니다. 불변식을 관리하는 추상화에 자주 쓰지만 관례일 뿐입니다.
첫 멤버는 기본적으로 public
첫 지정자 이전만 열린 상태로 시작합니다. 명시한 세 접근 지정자의 의미와 적용 범위는 class와 같습니다. 공개 데이터 묶음에 자주 쓰지만 관례일 뿐입니다.
이 표는 멤버 기본 접근을 비교합니다. 기반 클래스 접근 지정자를 생략한 상속 목록에서는 파생 클래스를 class로 정의하면 private, struct로 정의하면 public이 기본이지만 이는 별도 규칙입니다.
class MyClass {
int hidden; // private
public:
int visible;
};
struct MyStruct {
int visible; // public
private:
int hidden;
};
int main() {
MyClass a;
MyStruct b;
a.visible = 1;
b.visible = 2;
// a.hidden = 3; // 컴파일 오류
// b.hidden = 4; // 컴파일 오류
}그림처럼 class와 struct의 차이는 이 기본값뿐이고, 둘 다 멤버 함수, 생성자, 상속을 똑같이 쓸 수 있습니다. 관례적으로는 규칙을 지키는 추상화에 class를, 단순히 공개 데이터를 묶을 때 struct를 쓰지만 언어가 강제하는 것은 아닙니다. 상속할 때 접근을 생략한 경우의 기본값도 class는 private, struct는 public이며, 이것은 멤버의 기본 접근과는 별도의 규칙입니다.
정리
public멤버는 어디서나,private멤버는 그 클래스와friend에서만 쓸 수 있습니다.protected멤버는 선언한 클래스,friend, 파생 클래스에서 쓸 수 있으며, 파생 클래스에서는 자기 타입의 객체를 통해서만 씁니다.- 접근 검사는 컴파일할 때의 규칙이며, 실행 중의 보안 장치가 아닙니다.
private은 직접 접근을 막을 뿐이므로, 상태의 규칙은 상태를 바꾸는 공개 함수들이 모두 지켜야 합니다.- 지정자를 적지 않은 멤버는
class에서private,struct에서public이며, 그 밖의 차이는 없습니다.
연습 문제
1. 실행 중 거부와 컴파일 거부
bank_account.cpp의 끝에 account.withdraw(1300.0); account.deposit(-10.0);을 추가하면 각 호출 뒤의 잔액은 얼마인가요? 주석으로 막은 account.balance = -500;을 풀면 잔액 -500이 출력되나요?
해설 보기
1300 출금은 금액이 양수이고 잔액과 같으므로 허용되어 잔액이 0이 되고, -10 입금은 검사에서 거부되어 0을 유지합니다. account.balance = -500;은 private 멤버에 대한 접근이어서 컴파일 오류가 나므로, 새 실행 파일이 만들어지지 않아 -500이 출력될 일도 없습니다. 실행 중에 금액을 검사해 거부하는 것과, 코드가 컴파일되지 않는 것은 서로 다른 단계의 거부입니다.
2. protected 멤버에 접근하는 객체
Dog에 void copyAgeFromAnimal(const Animal& other) { age = other.age; }를 추가하면 컴파일되나요? 매개변수를 const Dog&로 바꾸면 어떤가요?
해설 보기
const Animal&이면 컴파일 오류입니다. Dog 안에서도 protected 멤버는 Dog나 그보다 더 파생된 타입의 객체를 통해서만 쓸 수 있는데, Animal 객체는 Dog가 아닐 수 있기 때문입니다. const Dog&로 바꾸면 copyAgeFrom과 같은 형태가 되어 컴파일됩니다.
3. struct의 접근
struct Point { int x; private: int y; };일 때 Point p; p.x = 1; p.y = 2;는 어떻게 되나요?
해설 보기
x는 첫 지정자보다 앞에 있어 struct의 기본값인 public이므로 p.x = 1은 올바릅니다. y는 private: 뒤에 있으므로 p.y = 2는 컴파일 오류입니다. struct도 지정자를 적은 뒤에는 class와 똑같이 동작합니다.
다음 글 생성자와 소멸자에서는 객체를 만들 때 실행되는 생성자로 멤버를 초기화하고, 객체가 사라질 때 실행되는 소멸자로 자원을 정리하는 방법을 다룹니다.