클래스와 객체의 개념
현실의 상태와 행동을 클래스와 객체로 모델링하는 원리를 이해하고 캡슐화 중심의 객체 지향 사고를 익힙니다.
지금까지는 데이터는 변수에, 그 데이터를 다루는 코드는 함수에 따로 두었습니다. 자동차의 모델, 색상, 속도를 변수 세 개로 두고 가속하는 함수를 따로 만들면, 프로그램이 커질수록 어떤 함수가 어떤 변수를 바꾸는지 따라가기 어려워집니다. 객체 지향 프로그래밍(object-oriented programming)은 함께 다루어야 하는 데이터와 함수를 하나로 묶은 객체를 프로그램의 단위로 삼습니다.
이 장에서는 C++의 객체 지향 기초를 다룹니다. 이 글은 객체를 만드는 틀인 클래스(class)와, 클래스로 만든 실체인 객체(object)의 관계, 그리고 객체의 데이터를 보호하는 캡슐화(encapsulation)를 소개합니다. 클래스를 정의하는 자세한 문법은 다음 글부터 차례로 봅니다.
C++ · class · object tree
클래스는 공통 타입을 정의하고, 객체는 각자의 상태를 저장한다
class Car는 멤버의 이름·형식·동작을 정의하는 타입 계약입니다. 객체를 만들면 비정적 데이터 멤버의 저장 공간은 객체마다 따로 생기고, 멤버 함수 호출은 그 대상 객체의 상태에 작용합니다.
Sonata · White · speed 30
myCar의 비정적 데이터 멤버를 위한 독립 저장 공간입니다.
K5 · Black · speed 70
yourCar의 비정적 데이터 멤버를 위한 별도 저장 공간입니다.
비정적 데이터 멤버는 객체마다 저장된다
myCar.speed를 바꿔도 yourCar.speed는 바뀌지 않습니다. 어느 인스턴스를 대상으로 호출했는지가 상태 변경의 기준입니다.
함수 코드와 공유 static 상태는 객체별 복사본이 아니다
멤버 함수의 정의는 공통 동작이며 호출할 때 대상 객체를 사용합니다. 정의된 non-thread_local static 데이터 멤버는 모든 객체가 공유하는 한 사본입니다. 일반 non-inline 멤버는 클래스 밖 정의가 필요하고, C++17 inline static은 클래스 안 선언이 정의를 겸합니다. thread_local static은 스레드별 한 사본입니다.
클래스 정의는 객체의 모양과 사용할 수 있는 동작을 정합니다. 실제 인스턴스마다 생기는 것은 비정적 데이터 멤버의 저장 공간이며, 동일한 함수 인터페이스를 통해 각 객체의 상태를 독립적으로 다룹니다.
상태와 동작
자동차는 모델, 색상, 현재 속도 같은 상태를 가지고, 가속하거나 멈추는 동작을 합니다. 객체 지향에서는 이런 상태를 데이터로, 동작을 그 데이터를 바꾸는 함수로 표현하고, 둘을 한 단위로 묶습니다. 이렇게 묶으면 속도를 바꾸는 방법이 가속과 감속 함수로 정해지므로, 속도가 음수가 되는 것 같은 잘못된 상태를 막을 곳도 한 군데로 모입니다.
객체 지향의 특징으로는 흔히 네 가지를 듭니다. 데이터와 함수를 묶고 내부를 감추는 캡슐화, 필요한 기능만 드러내는 추상화(abstraction), 기존 클래스를 물려받아 새 클래스를 만드는 상속(inheritance), 같은 호출이 객체에 따라 다르게 동작하는 다형성(polymorphism)입니다. 이 장에서는 캡슐화를 중심으로 다루고, 상속과 다형성은 10장에서 봅니다.
클래스
클래스는 객체가 가질 데이터와 함수를 정의한 타입입니다. 클래스 안의 변수를 멤버 변수(member variable) 또는 데이터 멤버라고 하고, 함수를 멤버 함수(member function)라고 합니다.
#include <iostream>
#include <string>
class Car {
public:
std::string model;
std::string color;
int speed = 0;
void accelerate(int amount) {
speed += amount;
}
void brake(int amount) {
speed -= amount;
if (speed < 0) {
speed = 0;
}
}
void displayInfo() {
std::cout << "model " << model << ", color " << color
<< ", speed " << speed << '\n';
}
};클래스는 class 키워드와 이름으로 시작하고, 중괄호 안에 멤버를 적은 뒤 세미콜론으로 끝납니다. public:은 그 아래의 멤버를 클래스 밖에서도 쓸 수 있게 하는 접근 지정자(access specifier)이며, 접근 지정자에서 자세히 다룹니다. 멤버 함수 안에서는 speed처럼 같은 클래스의 멤버 변수를 이름만으로 쓸 수 있습니다.
클래스를 정의한 것만으로는 model이나 speed를 담을 공간이 생기지 않습니다. 클래스는 int처럼 하나의 타입이며, 그 타입으로 객체를 만들어야 멤버 변수의 공간이 생깁니다.
객체
객체는 클래스를 타입으로 해서 만든 실체이며, 클래스의 인스턴스(instance)라고도 합니다. 객체를 만들면 그 객체만의 멤버 변수 공간이 생기고, 멤버에는 점 연산자 .로 접근합니다.
int main() {
Car myCar;
myCar.model = "Sonata";
myCar.color = "White";
myCar.displayInfo();
myCar.accelerate(50);
myCar.displayInfo();
myCar.brake(20);
myCar.displayInfo();
Car yourCar;
yourCar.model = "K5";
yourCar.color = "Black";
yourCar.accelerate(70);
yourCar.displayInfo();
myCar.displayInfo();
}앞의 Car 정의 아래에 이 main을 이어 붙여 실행하면 다음과 같이 출력됩니다. 앞의 세 줄은 myCar 하나의 속도가 바뀌어 가는 과정이고, 넷째 줄은 새로 만든 yourCar, 마지막 줄은 다시 myCar의 상태입니다.
model Sonata, color White, speed 0
model Sonata, color White, speed 50
model Sonata, color White, speed 30
model K5, color Black, speed 70
model Sonata, color White, speed 30myCar와 yourCar는 같은 Car 클래스로 만들었지만, 위 그림처럼 멤버 변수를 각자 따로 가집니다. yourCar.accelerate(70)은 yourCar의 speed만 바꾸므로, 마지막 줄에서 myCar의 속도는 여전히 30입니다. 멤버 함수의 코드는 객체마다 복사되지 않고 하나뿐이며, 호출할 때 점 앞의 객체를 대상으로 동작합니다.
speed는 클래스 안에서 = 0으로 기본값을 주었으므로 객체를 만들면 0에서 시작합니다. 객체도 다른 변수처럼 지역 객체는 블록이 끝나면 사라지고, 실행 중에 만들어야 한다면 동적 메모리 할당에서 본 std::make_unique<Car>()로 만들어 std::unique_ptr에 소유를 맡깁니다.
캡슐화
Car의 brake는 속도가 음수가 되지 않게 막지만, speed가 public이므로 myCar.speed = -10;처럼 밖에서 직접 대입하면 이 검사를 건너뜁니다. 캡슐화는 데이터를 private으로 감추고, 데이터를 바꾸는 길을 검사가 들어 있는 public 멤버 함수로만 열어 두는 것입니다. 이렇게 내부를 감추는 것을 정보 은닉(information hiding)이라고도 합니다.
#include <iostream>
#include <string>
class Student {
private:
std::string name;
int age = 0;
public:
Student(std::string n, int a) : name(n) {
setAge(a);
}
std::string getName() { return name; }
int getAge() { return age; }
void setAge(int newAge) {
if (newAge >= 0 && newAge <= 120) {
age = newAge;
} else {
std::cerr << "invalid age: " << newAge << '\n';
}
}
};
int main() {
Student s1("Kim", 20);
std::cout << s1.getName() << ' ' << s1.getAge() << '\n';
// s1.age = -5; // 컴파일 오류: age는 private
s1.setAge(-5);
std::cout << s1.getAge() << '\n';
s1.setAge(22);
std::cout << s1.getAge() << '\n';
}Kim 20
20
22setAge(-5)는 범위 검사에서 거부되어 표준 오류에 invalid age: -5를 출력하고, 나이는 20 그대로입니다. Student(std::string n, int a) : name(n)처럼 클래스 이름과 같은 함수는 객체를 만들 때 실행되는 생성자(constructor)이며, 생성자와 소멸자에서 자세히 다룹니다. 이 생성자도 setAge를 거쳐 나이를 정하므로, 처음부터 잘못된 나이로 객체를 만들 수 없습니다.
공개 API를 통한 private 상태 변경
public setAge의 범위 검사 성공 때만 age가 갱신되고 거절 때 이전 값이 유지된다. private 직접 쓰기 오류는 런타임 분기로 포함하지 않는다.
C++ · encapsulation · checked flowchart
외부 요청은 공개 API와 검증을 통과한 뒤에만 내부 상태를 바꾼다
일반 외부 코드는 private age를 직접 쓸 수 없습니다. 이 예제에서는 공개된 setAge()가 값의 범위를 검사하고, 유효할 때만 객체 내부 상태를 변경합니다.
-
s.setAge(newAge)호출일반 외부 코드는 공개된 멤버 함수를 통해 변경을 요청합니다.
-
0 <= newAge && newAge <= 120검사범위 검사는 접근 지정자가 아니라
setAge()의 실행 로직입니다.
private age 변경
클래스 멤버 함수는 자신의 객체에 속한 비공개 상태에 접근하여 age = newAge를 수행합니다.
이전 값 유지
오류를 알리고 대입하지 않으므로 객체 상태는 바뀌지 않습니다.
s.age = -5는 컴파일되지 않는다
일반 외부 코드의 직접 쓰기는 private 접근 제어에 의해 차단됩니다.
private는 접근 가능한 코드를 제한한다
클래스의 멤버와 허용된 friend는 접근할 수 있지만, 일반 외부 호출자는 비공개 멤버를 직접 읽거나 쓸 수 없습니다.
유효성 검사는 공개 함수의 실행 규칙이다
private만으로 값의 범위가 보장되지는 않습니다. setAge()가 검사와 변경을 한 경로에 모아 불변식을 지킵니다.
캡슐화는 비공개 데이터를 숨기는 데서 끝나지 않습니다. 외부가 사용할 공개 약속을 작게 만들고, 그 경로에서 검증한 뒤에만 상태를 바꾸어 객체의 일관성을 유지합니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 원래 가로 그림의 6개 노드와 5개 관계 간선을 보존한다. 일반 외부 코드의 직접 age 대입은 컴파일 오류 경로이며 공개 setAge 호출은 범위 검사로 진행한다. 예 분기는 age 저장/아니요 분기는 이전 age 유지로 이어진다. 직접 쓰기를 실행 중 조건으로 검사한다는 뜻이 아니다. 범위는 이 Student 예제의 정책이다. |
| 노드·연결선 라벨 | 아니요 · 대입하지 않음 |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 입력 검사 public setAge가 요청값을 범위와 대조한다. |
|
|
| 조건부 대입 허용 범위 검사 결과에 따라 갱신 여부를 결정한다. |
|
|
공개 API를 통한 private 상태 변경
public setAge의 범위 검사 성공 때만 age가 갱신되고 거절 때 이전 값이 유지된다. private 직접 쓰기 오류는 런타임 분기로 포함하지 않는다.
C++ · encapsulation · checked flowchart
외부 요청은 공개 API와 검증을 통과한 뒤에만 내부 상태를 바꾼다
일반 외부 코드는 private age를 직접 쓸 수 없습니다. 이 예제에서는 공개된 setAge()가 값의 범위를 검사하고, 유효할 때만 객체 내부 상태를 변경합니다.
-
s.setAge(newAge)호출일반 외부 코드는 공개된 멤버 함수를 통해 변경을 요청합니다.
-
0 <= newAge && newAge <= 120검사범위 검사는 접근 지정자가 아니라
setAge()의 실행 로직입니다.
private age 변경
클래스 멤버 함수는 자신의 객체에 속한 비공개 상태에 접근하여 age = newAge를 수행합니다.
이전 값 유지
오류를 알리고 대입하지 않으므로 객체 상태는 바뀌지 않습니다.
s.age = -5는 컴파일되지 않는다
일반 외부 코드의 직접 쓰기는 private 접근 제어에 의해 차단됩니다.
private는 접근 가능한 코드를 제한한다
클래스의 멤버와 허용된 friend는 접근할 수 있지만, 일반 외부 호출자는 비공개 멤버를 직접 읽거나 쓸 수 없습니다.
유효성 검사는 공개 함수의 실행 규칙이다
private만으로 값의 범위가 보장되지는 않습니다. setAge()가 검사와 변경을 한 경로에 모아 불변식을 지킵니다.
캡슐화는 비공개 데이터를 숨기는 데서 끝나지 않습니다. 외부가 사용할 공개 약속을 작게 만들고, 그 경로에서 검증한 뒤에만 상태를 바꾸어 객체의 일관성을 유지합니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 원래 가로 그림의 6개 노드와 5개 관계 간선을 보존한다. 일반 외부 코드의 직접 age 대입은 컴파일 오류 경로이며 공개 setAge 호출은 범위 검사로 진행한다. 예 분기는 age 저장/아니요 분기는 이전 age 유지로 이어진다. 직접 쓰기를 실행 중 조건으로 검사한다는 뜻이 아니다. 범위는 이 Student 예제의 정책이다. |
| 노드·연결선 라벨 | 아니요 · 대입하지 않음 |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 입력 검사 public setAge가 요청값을 범위와 대조한다. |
|
|
| 조건부 대입 허용 범위 검사 결과에 따라 갱신 여부를 결정한다. |
|
|
위 그림처럼 밖의 코드가 age를 직접 바꾸려는 시도는 실행하기 전에 컴파일 오류로 막히고, setAge를 통한 요청만 실행 중에 범위 검사를 거칩니다. private은 직접 접근을 막아 줄 뿐 값의 범위를 검사하지는 않으므로, 검사는 공개한 함수 안에 따로 적어야 합니다. 0부터 120까지라는 범위도 이 예제가 정한 규칙입니다.
이처럼 값을 읽는 함수를 게터(getter), 바꾸는 함수를 세터(setter)라고 부르기도 합니다. 하지만 모든 멤버에 게터와 세터를 기계적으로 붙이면 public 변수와 다를 바가 없으므로, 객체가 밖에 무엇을 약속할지부터 정합니다. Car라면 속도를 아무 값으로나 정하는 세터보다, 지금처럼 accelerate와 brake만 공개하는 편이 객체의 규칙을 지키기 쉽습니다.
정리
- 클래스는 데이터인 멤버 변수와 동작인 멤버 함수를 묶은 타입이며, 정의만으로는 멤버 변수의 공간이 생기지 않습니다.
- 객체는 클래스로 만든 실체이며, 객체마다 자기만의 멤버 변수를 가지고 멤버에는
.로 접근합니다. - 멤버 함수의 코드는 하나이고, 호출할 때 점 앞의 객체를 대상으로 동작합니다.
- 캡슐화는 데이터를
private으로 감추고, 검사가 들어 있는public멤버 함수로만 바꾸게 하는 것입니다. private은 직접 접근을 컴파일할 때 막을 뿐이므로, 값의 범위 검사는 공개 함수 안에 따로 둡니다.
연습 문제
1. 두 객체의 속도
car_objects.cpp의 끝에 myCar.brake(50); yourCar.accelerate(5);를 추가하면 두 객체의 속도는 각각 얼마인가요?
해설 보기
myCar는 30에서 50을 빼 -20이 되지만, brake가 음수를 0으로 고치므로 0입니다. yourCar는 70에 5를 더해 75입니다. 같은 클래스의 함수라도 점 앞의 객체에만 작용하므로, 두 객체의 속도는 서로 영향을 주지 않습니다.
2. 생성자에서 거부된 나이
Student s2("Lee", -5);를 만든 뒤 s2.setAge(121);과 s2.setAge(120);을 차례로 호출하면, 각 시점의 s2의 나이는 얼마인가요?
해설 보기
age는 기본값 0으로 시작하고, 생성자의 setAge(-5)가 거부되므로 만든 직후는 0입니다. setAge(121)도 범위 밖이어서 0을 유지하고, setAge(120)은 허용되어 120이 됩니다. s1은 별도의 객체이므로 이 호출들과 상관이 없습니다.
3. 공개된 멤버 변수
car_objects.cpp에서 myCar.speed = -10;은 컴파일되나요? brake의 검사를 언제나 지키게 하려면 Car를 어떻게 고치면 되나요?
해설 보기
speed가 public이므로 컴파일되고, 속도가 -10이 됩니다. brake의 검사는 brake를 거칠 때만 적용되기 때문입니다. speed를 private으로 옮기고 읽기용 getSpeed()만 공개하면 속도를 바꾸는 길이 accelerate와 brake로 한정됩니다. 다만 지금의 accelerate는 accelerate(-50)처럼 음수를 받으면 속도를 음수로 만들 수 있으므로, amount가 음수이면 무시하는 검사를 accelerate에도 넣어야 속도가 음수가 될 수 없습니다.
다음 글 클래스 정의와 객체 생성에서는 클래스를 헤더와 소스 파일로 나누어 정의하는 방법과, 객체를 만들고 초기화하는 여러 방법을 다룹니다.