본문 바로가기

접근 지정자

public·private·protected가 외부 접근과 상속 경계를 제한하는 방식을 익혀 클래스의 상태와 구현을 캡슐화합니다.

앞의 두 글에서 public:과 private:을 클래스 안에 적었습니다. 이렇게 멤버를 어디에서 이름으로 쓸 수 있는지를 정하는 키워드를 접근 지정자(access specifier)라고 하며, C++에는 public, private, protected의 세 가지가 있습니다. 접근 지정자는 캡슐화를 코드로 표현하는 수단입니다.

접근 검사는 컴파일할 때 적용되는 언어 규칙입니다. 규칙을 어긴 코드는 컴파일되지 않을 뿐, 실행 중인 프로그램의 메모리를 숨기거나 공격을 막는 보안 장치는 아닙니다. 이 글은 세 지정자가 각각 누구에게 멤버를 열어 주는지와, class와 struct의 기본 접근이 어떻게 다른지를 봅니다.

public, protected, private 멤버를 선언 클래스, 파생 클래스, 외부 코드에서 이름 붙일 수 있는지와 각 지정자의 설계 역할을 비교하는 표

C++ · access control · semantic table

접근 위치와 지정자를 함께 봐야 허용 범위가 보인다

public은 외부 계약, protected는 상속 확장 지점, private은 내부 구현입니다. 접근 검사는 소스를 컴파일할 때 적용되는 언어 규칙이며 실행 중 보안 경계는 아닙니다.

접근 지정자 × 이름을 사용하는 코드 위치와 설계 역할
지정자 선언 클래스 문맥 파생 클래스 문맥 그 밖의 외부 코드 주요 설계 역할
public 허용 · 선언 클래스도 자신의 공개 멤버를 사용합니다. 허용 · 접근 제어 관점에서 그대로 사용할 수 있습니다. 허용 · 외부 사용자가 의존하는 공개 API가 됩니다. 안정적으로 제공할 공개 계약과 검증 진입점입니다.
protected 허용 · 선언 클래스의 멤버와 명시된 friend가 사용합니다. 조건부 허용 · 비정적 멤버는 현재 파생 타입 또는 더 파생된 타입의 객체를 통해 접근해야 합니다. 임의의 Base& 객체를 통한 접근은 허용되지 않습니다. 차단 · 일반 객체 사용자의 공개 API가 아닙니다. 파생 타입과 결합되는 의도적인 상속 확장 지점입니다.
private 허용 · 선언 클래스의 멤버와 그 클래스가 명시한 friend가 사용합니다. 차단 · 파생됐다는 이유만으로는 접근할 수 없습니다. 기반 클래스가 별도로 허가한 friend 권한은 예외입니다. 차단 · 외부에서 직접 이름 붙일 수 없습니다. 표현 세부, 보조 동작, 외부가 직접 바꾸지 않을 상태입니다.
PUBLIC

외부에 약속한 계약

선언 클래스, 파생 클래스, 외부 코드에서 접근 제어상 허용됩니다. 공개 메서드는 검증을 모으는 진입점이 되어 불변식을 지키는 데 도움을 주지만, 구현의 정확성까지 보장하지는 않습니다.

PROTECTED

상속 계층의 확장 지점

선언 클래스의 멤버와 friend, 파생 클래스의 멤버와 friend에게 허용되고 외부에서는 차단됩니다. 파생 문맥의 비정적 멤버는 현재 파생 타입 또는 더 파생된 타입의 객체를 통해 접근해야 하며, 임의의 Base& 객체는 사용할 수 없습니다.

PRIVATE

선언 클래스의 내부 구현

선언 클래스의 멤버와 그 클래스가 명시한 friend만 접근합니다. 파생 클래스와 외부 코드는 기본적으로 차단되며, 기반 클래스가 별도로 부여한 friend 권한만 예외입니다.

접근 제어 위반은 컴파일 시 해당 구성을 ill-formed로 만듭니다. 메모리 보호나 권한 인증을 대신하지 않으며, 공개 진입점도 올바르게 구현될 때에만 불변식 유지에 도움이 됩니다.

위 그림은 멤버를 쓰는 코드가 어디에 있는지에 따라 세 지정자가 허용하는 범위를 비교한 것입니다. 접근 지정자는 그 아래에 오는 멤버 선언에 적용되며, 다음 지정자가 나오거나 클래스 정의가 끝날 때까지 이어집니다.

public

public 멤버는 클래스 밖의 어떤 코드에서도 이름으로 쓸 수 있습니다. 클래스를 쓰는 사람이 의존해도 되는 멤버, 즉 클래스의 공개 인터페이스(interface)를 public으로 둡니다.

public_member.cpp
#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는 프렌드 함수와 프렌드 클래스에서 다룹니다. 클래스 밖의 코드는 물론, 그 클래스를 상속받은 클래스도 직접 쓸 수 없습니다.

bank_account.cpp
#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 1300

balance는 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장에서 자세히 다루므로, 여기서는 접근 범위만 봅니다.

protected_member.cpp
#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 / 5

class 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이 됩니다. 이 기본값은 첫 번째 접근 지정자가 나오기 전의 멤버에만 적용되며, 지정자를 적은 뒤의 뜻은 두 키워드가 같습니다.

class와 struct에서 첫 접근 지정자 이전 멤버의 기본 접근과 명시적인 public, protected, private 지정자의 동일한 동작을 비교하는 표

C++ · class key · semantic table

멤버 기본 접근은 class가 private, struct가 public이다

차이는 첫 접근 지정자 이전의 시작값입니다. 명시한 public:, protected:, private:의 의미와 적용 범위는 두 클래스 키에서 같습니다.

멤버 선언에서 class와 struct의 기본 접근 비교
비교 항목 class struct
첫 지정자 이전 private가 기본입니다. public이 기본입니다.
명시한 지정자 public:, protected:, private:를 모두 같은 의미로 사용할 수 있습니다. public:, protected:, private:를 모두 같은 의미로 사용할 수 있습니다.
적용 범위 지정자 뒤부터 다음 지정자 또는 클래스 정의 끝까지 적용됩니다. 지정자 뒤부터 다음 지정자 또는 클래스 정의 끝까지 적용됩니다.
흔한 관례 불변식을 관리하는 추상화에 자주 쓰지만 언어가 강제하지 않습니다. 공개 데이터 묶음에 자주 쓰지만 언어가 강제하지 않습니다.
CLASS

첫 멤버는 기본적으로 private

첫 지정자 이전만 닫힌 상태로 시작합니다. 이후에는 명시한 public:, protected:, private:가 다음 지정자 또는 정의 끝까지 적용됩니다. 불변식을 관리하는 추상화에 자주 쓰지만 관례일 뿐입니다.

STRUCT

첫 멤버는 기본적으로 public

첫 지정자 이전만 열린 상태로 시작합니다. 명시한 세 접근 지정자의 의미와 적용 범위는 class와 같습니다. 공개 데이터 묶음에 자주 쓰지만 관례일 뿐입니다.

이 표는 멤버 기본 접근을 비교합니다. 기반 클래스 접근 지정자를 생략한 상속 목록에서는 파생 클래스를 class로 정의하면 private, struct로 정의하면 public이 기본이지만 이는 별도 규칙입니다.

default_access.cpp
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와 똑같이 동작합니다.

다음 글 생성자와 소멸자에서는 객체를 만들 때 실행되는 생성자로 멤버를 초기화하고, 객체가 사라질 때 실행되는 소멸자로 자원을 정리하는 방법을 다룹니다.