12. 객체지향 프로그래밍 입문: 생성자, 캡슐화, 상속, 다형성
12. 객체지향 프로그래밍 입문: 생성자, 캡슐화, 상속, 다형성

이제 클래스가 "상자"를 넘어 "설계도"가 된다
지난 장에서 우리는 class가 데이터를 묶고, 함수를 함께 넣고, 바깥에서 함부로 건드리지 못하게 막아 주는 도구라는 점을 배웠다. 거기까지가 클래스의 문을 여는 단계였다면, 이번 장은 그 문 안으로 한 걸음 더 들어가는 단계다.
이제부터 클래스는 단순히 값을 담는 그릇이 아니다. 객체가 태어날 때 자동으로 준비를 해 주고, 사라질 때 뒷정리를 하고, 공통 기능을 물려주고, 같은 명령에도 각자 다르게 반응하게 만들 수 있다. 이 지점부터 C++는 "문법이 많은 언어"가 아니라 "큰 프로그램을 설계하는 언어"처럼 보이기 시작한다.
객체지향 프로그래밍은 프로그램을 기능 조각으로만 보지 않고, 상태와 책임을 가진 객체들의 협력으로 바라보는 방식이다. 작은 예제에서는 이 차이가 작아 보일 수 있다. 하지만 코드가 커질수록 이 생각법은 엄청난 힘을 발휘한다. 누가 데이터를 가지고 있는지, 누가 그 데이터를 바꿀 수 있는지, 새 기능을 넣을 때 어디를 고쳐야 하는지가 훨씬 분명해지기 때문이다.1
생성자: 객체가 태어나는 순간 자동으로 호출되는 함수
생성자(constructor)는 객체가 만들어질 때 자동으로 실행되는 특별한 멤버 함수다. 이름은 클래스 이름과 같고, 반환형이 없다. 생성자의 가장 중요한 역할은 객체를 처음부터 쓸 수 있는 상태로 만들어 주는 것이다.1
예를 들어 게임 캐릭터를 하나 만든다고 생각해 보자. 이름도 없고 체력도 이상한 값인 캐릭터가 나타나면 게임이 시작되기도 전에 불안하다. 생성자는 이런 불안을 줄여 준다.
#include <iostream>
#include <string>
class Player {
private:
std::string name;
int hp;
public:
Player(const std::string& name, int hp)
: name(name), hp(hp < 0 ? 0 : hp) {
std::cout << this->name << " 생성 완료n";
}
void printStatus() const {
std::cout << name << "의 체력: " << hp << 'n';
}
};
int main() {
Player p("Luna", 100);
p.printStatus();
}여기서 눈에 띄는 부분이 : name(name), hp(...)이다. 이것을 초기화 리스트(initialization list) 라고 한다. 초보자 눈에는 조금 낯설지만, 뜻은 단순하다. "객체의 멤버를 만들면서 바로 초기화한다"는 뜻이다. 특히 const 멤버, 참조 멤버, 그리고 부모 클래스의 생성자를 호출할 때 초기화 리스트가 중요하다.5
생성자는 여러 형태로 만들 수 있다. 인자가 없는 기본 생성자도 만들 수 있고, 값을 받아 초기화하는 생성자도 만들 수 있다. 다만 한 가지는 기억해 두자. 사용자 정의 생성자를 하나라도 만들면, 컴파일러가 기본 생성자를 자동으로 만들어 주지 않는 경우가 많다. 그래서 MyClass obj;처럼 빈 생성도 필요하다면 직접 준비해야 한다.5
생성자는 결국 객체에게 이렇게 말해 주는 셈이다.
"너는 이제 태어났고, 최소한 이상한 상태는 아니야."
이 한마디가 프로그램 안정성에 꽤 큰 차이를 만든다.
소멸자: 객체가 떠날 때 정리하는 함수
객체가 태어날 때 생성자가 있다면, 객체가 사라질 때는 소멸자(destructor)가 있다. 소멸자는 클래스 이름 앞에 물결표 ~를 붙여 선언하며, 객체가 범위를 벗어나거나 delete될 때 자동으로 호출된다.1
#include <iostream>
class LogMessage {
public:
LogMessage() {
std::cout << "로그 시작n";
}
~LogMessage() {
std::cout << "로그 종료n";
}
};
int main() {
LogMessage msg;
std::cout << "main 내부 작업 중n";
}이 코드에서는 msg가 main()의 끝에서 사라질 때 소멸자가 자동으로 호출된다. 우리가 직접 msg.~LogMessage(); 같은 것을 보통 부르지 않는 이유가 여기에 있다. C++가 객체의 생명 주기를 관리해 주기 때문이다.
소멸자는 특히 정리 작업에 중요하다. 예를 들어 파일을 닫거나, 동적으로 할당한 메모리를 반납하거나, 어떤 자원을 사용 종료 상태로 되돌릴 때 쓰인다.1 다음 장들에서 더 본격적으로 배우겠지만, C++에서는 이런 자동 정리를 매우 중요하게 여긴다. 이 철학이 나중에 스마트 포인터와 RAII로 이어진다.
또 하나 기억할 점이 있다. 객체가 여러 겹으로 만들어졌다면 생성은 부모에서 자식 순서, 소멸은 자식에서 부모 순서로 일어난다.1 먼저 태어난 쪽이 나중에 나간다고 생각하면 이해하기 쉽다.
캡슐화: 데이터를 숨기고, 올바른 사용법만 보여 주기
객체지향에서 초보자가 가장 먼저 체감하는 강력한 장점은 캡슐화(encapsulation)다. 캡슐화는 관련 있는 데이터와 함수를 클래스로 묶고, 내부 구현은 감추고, 바깥에는 필요한 사용법만 보여 주는 방식이다.2
쉽게 말해, 자동차를 운전할 때 엔진 내부의 나사 하나하나를 직접 돌릴 필요는 없다. 운전자는 핸들, 브레이크, 가속 페달만 알면 된다. 클래스도 비슷하다. 내부 상태는 보호하고, 바깥에는 필요한 조작만 공개한다.
이때 쓰는 것이 접근 지정자다.
public: 바깥에서 사용할 수 있는 부분private: 클래스 내부에서만 다룰 수 있는 부분protected: 클래스 내부와 상속받은 자식 클래스에서 사용할 수 있는 부분
C++의 class는 멤버를 따로 표시하지 않으면 기본이 private다.2
#include <iostream>
class BankAccount {
private:
int balance;
public:
BankAccount(int initialBalance)
: balance(initialBalance < 0 ? 0 : initialBalance) {
}
void deposit(int amount) {
if (amount > 0) {
balance += amount;
}
}
bool withdraw(int amount) {
if (amount <= 0 || amount > balance) {
return false;
}
balance -= amount;
return true;
}
int getBalance() const {
return balance;
}
};이 클래스의 핵심은 balance를 직접 공개하지 않았다는 점이다. 바깥에서는 deposit()과 withdraw()를 통해서만 잔액을 바꿀 수 있다. 그 덕분에 음수 입금, 잔액 초과 출금 같은 이상한 상태를 막을 수 있다.
이런 규칙을 클래스 불변식(class invariant) 이라고 생각해도 좋다. 즉, "이 객체는 항상 이런 상태를 지켜야 한다"라는 약속이다. 예를 들어 은행 계좌라면 잔액이 함부로 깨지면 안 되고, 캐릭터라면 체력이 말도 안 되는 값으로 바뀌면 안 된다. 캡슐화는 바로 그 약속을 지키게 도와준다.
여기서 초보자가 자주 하는 오해가 하나 있다. 캡슐화는 "무조건 getter, setter를 다 만들자"가 아니다. 중요한 것은 의미 있는 인터페이스를 만드는 것이다. 계좌라면 setBalance(99999999)보다 deposit(1000)과 withdraw(500)가 더 자연스럽다. 클래스는 데이터를 보여 주는 것보다, 어떻게 다뤄야 하는지를 안내하는 것이 더 중요하다.
상속: 공통점을 물려받고, 차이점은 덧붙이기
상속(inheritance)은 기존 클래스의 특징을 새로운 클래스가 이어받는 기능이다. 공통 부분을 부모 클래스에 두고, 더 구체적인 부분을 자식 클래스에 두면 중복을 줄이고 구조를 정리할 수 있다.
예를 들어 개와 고양이는 다르지만, 둘 다 이름이 있고 먹을 수 있다는 공통점이 있다. 이런 공통점은 Animal에 두고, 각자만의 행동은 Dog, Cat에 둘 수 있다.
#include <iostream>
#include <string>
class Animal {
protected:
std::string name;
public:
Animal(const std::string& name) : name(name) {
}
void eat() const {
std::cout << name << "가 먹습니다.n";
}
};
class Dog : public Animal {
public:
Dog(const std::string& name) : Animal(name) {
}
void bark() const {
std::cout << name << ": 멍멍!n";
}
};
int main() {
Dog dog("Bori");
dog.eat();
dog.bark();
}여기서 Dog : public Animal은 Dog가 Animal을 공개 상속한다는 뜻이다. 쉽게 말해 "개는 동물이다"라는 관계다. 이 관계를 흔히 is-a 관계라고 부른다.
상속을 볼 때 중요한 포인트는 세 가지다.
자식 클래스는 부모 클래스의 공개 인터페이스를 이어받는다.
부모의
private멤버는 자식이 직접 만질 수 없다.부모의
protected멤버는 자식이 사용할 수 있지만, 바깥에서는 여전히 직접 접근할 수 없다.2
또 하나 눈여겨볼 점은 Dog 생성자 안의 : Animal(name)이다. 자식 객체를 만들 때는 부모 부분도 먼저 만들어져야 하므로, 부모 생성자를 호출해야 한다. 실제 생성 순서도 부모 → 자식, 소멸 순서는 자식 → 부모다.1
상속은 편리하지만, 아무 관계에나 붙이면 오히려 혼란을 만든다. "재사용되니까 상속하자"보다는, "정말 이 클래스가 저 클래스의 한 종류인가?"를 먼저 생각하는 습관이 중요하다.
다형성: 같은 호출인데, 실제 행동은 다르게
객체지향이 재미있어지는 순간은 다형성(polymorphism)을 만났을 때다. 다형성은 하나의 공통 인터페이스로 여러 다른 타입을 다루는 능력이다.
말만 들으면 어렵지만, 예를 들어 보자. 동물 보고서를 출력하는 함수가 있다고 하자.
void report(const Animal& animal) {
std::cout << animal.getName() << "의 소리: " << animal.speak() << 'n';
}이 함수는 개든 고양이든 앵무새든, Animal처럼 다루기만 하면 된다. 그런데 같은 speak() 호출이어도 실제로는 각 동물의 소리가 나와야 한다. 바로 이때 가상 함수(virtual function) 가 필요하다.
#include <iostream>
#include <string>
class Animal {
protected:
std::string name;
public:
Animal(const std::string& name) : name(name) {
}
const std::string& getName() const {
return name;
}
virtual std::string speak() const {
return "???";
}
virtual ~Animal() = default;
};
class Dog : public Animal {
public:
Dog(const std::string& name) : Animal(name) {
}
std::string speak() const override {
return "멍멍";
}
};
class Cat : public Animal {
public:
Cat(const std::string& name) : Animal(name) {
}
std::string speak() const override {
return "야옹";
}
};
void report(const Animal& animal) {
std::cout << animal.getName() << "의 소리: " << animal.speak() << 'n';
}
int main() {
Dog dog("Bori");
Cat cat("Nabi");
report(dog);
report(cat);
}여기서 핵심은 Animal::speak() 앞의 virtual과, 자식 클래스의 override다. virtual은 "이 함수는 자식 클래스가 자기 방식으로 다시 정의할 수 있고, 호출은 실제 객체 타입에 맞춰 결정하라"는 신호다. 이렇게 하면 Animal&나 Animal*로 다루더라도 실제 객체가 Dog면 Dog::speak()가, Cat이면 Cat::speak()가 호출된다.3
이것이 바로 런타임 다형성(runtime polymorphism) 이다. 컴파일할 때가 아니라 실행 중에 "너의 진짜 정체가 뭐니?"를 보고 함수를 고른다고 생각하면 이해하기 쉽다.3
여기서 override는 꼭 붙이는 습관을 들이는 것이 좋다. 이 키워드는 단순한 장식이 아니다. 부모의 가상 함수를 정말 정확히 재정의했는지 컴파일러가 검사해 준다. 예를 들어 const를 빼먹거나 매개변수 타입을 잘못 적었을 때, override가 있으면 조용히 엉뚱한 새 함수를 만드는 대신 바로 에러를 내 준다.3
가상 함수가 아니면 무슨 일이 생길까
초보자가 다형성을 배울 때 가장 많이 헷갈리는 부분은 이것이다. "분명 자식 클래스에서 같은 이름의 함수를 만들었는데 왜 부모 버전이 불리지?"
그 이유는 이름이 같다고 자동으로 다형성이 되는 것이 아니기 때문이다. 부모 함수가 virtual이 아니면, 부모 타입의 포인터나 참조로 호출할 때 부모 버전이 선택된다. 즉, 함수 선택이 컴파일 시점에 끝나 버린다.34
다형성이 살아나는 조건은 비교적 분명하다.
부모 클래스에
virtual함수가 있어야 한다.호출이 부모 타입의 포인터나 참조를 통해 일어나야 한다.
실제 객체가 자식 객체여야 한다.3
반대로 객체를 값으로 복사해 버리면, 자식 부분이 잘려 나가는 객체 슬라이싱(object slicing) 이 일어나 다형성이 깨질 수 있다. 초보 단계에서는 일단 "다형성을 쓰고 싶으면 포인터나 참조를 통해 다뤄야 한다"라고 기억해 두면 충분하다.3
가상 소멸자: 다형성 세계의 안전벨트
부모 클래스 포인터로 자식 객체를 가리키는 코드는 객체지향에서 아주 흔하다.
Animal* pet = new Dog("Bori");
delete pet;이 코드가 안전하려면 부모 클래스의 소멸자가 virtual이어야 한다. 그렇지 않으면 delete pet;에서 부모 소멸자만 호출되고, 자식 소멸자가 실행되지 않아 정리 작업이 빠질 수 있다. 이 문제는 메모리 누수나 자원 누수로 이어질 수 있다.4
그래서 다형적으로 사용할 부모 클래스라면 소멸자를 가상으로 두는 습관이 매우 중요하다.4
virtual ~Animal() = default;이 한 줄은 겉보기엔 조용하지만, 실제로는 매우 든든한 안전벨트다.
순수 가상 함수와 추상 클래스: "반드시 구현해"를 표현하는 법
때로는 부모 클래스가 기본 동작을 줄 수 없을 때가 있다. 예를 들어 Shape라는 부모 클래스가 있다고 하자. 원, 사각형, 삼각형은 모두 도형이지만, area()를 부모 쪽에서 하나로 정해 버리기는 어렵다. 그럴 때는 순수 가상 함수(pure virtual function) 를 사용한다.4
class Shape {
public:
virtual double area() const = 0;
virtual ~Shape() = default;
};= 0이 붙은 가상 함수는 "이 함수는 자식 클래스가 반드시 구현해야 한다"는 뜻이다. 이런 함수를 하나라도 가진 클래스는 추상 클래스(abstract class) 가 되며, 직접 객체를 만들 수 없다.4
class Circle : public Shape {
private:
double radius;
public:
Circle(double radius) : radius(radius) {
}
double area() const override {
return 3.141592 * radius * radius;
}
};이 구조의 장점은 분명하다. Shape는 "도형이라면 넓이를 구할 수 있어야 한다"는 규칙을 정하고, 실제 계산법은 각 도형이 맡는다. 부모는 약속을 만들고, 자식은 구현을 채운다.
이쯤 되면 객체지향이 왜 설계 도구인지 감이 온다. 클래스는 단순히 코드를 묶는 단위가 아니라, 역할과 책임을 선언하는 단위이기도 하다.
생성자와 다형성에서 꼭 알아둘 주의점
조금 까다롭지만 중요한 사실이 하나 있다. 생성자와 소멸자 안에서 호출되는 가상 함수는, 우리가 기대하는 다형성처럼 동작하지 않는다. 생성 중인 객체는 아직 완전히 자식 객체가 아니고, 소멸 중인 객체는 이미 자식 부분이 정리되고 있기 때문이다.1
즉, 부모 생성자 안에서 가상 함수를 불렀다고 해서 자식 버전이 호출된다고 기대하면 안 된다. 이 규칙은 처음엔 의외로 느껴지지만, 덜 만들어진 객체를 건드리지 않게 하기 위한 안전장치라고 생각하면 된다.1
초보 단계에서는 이렇게 기억하면 충분하다.
생성자와 소멸자에서는 가상 함수에 복잡한 기대를 하지 말자.
객체지향이 큰 프로그램에서 왜 빛나는가
지금까지 배운 내용을 한 줄로 줄이면 이렇다.
생성자는 객체를 처음부터 정상 상태로 만든다.
캡슐화는 객체의 내부를 보호한다.
상속은 공통점을 재사용하게 해 준다.
다형성은 같은 인터페이스로 다양한 객체를 다루게 해 준다.
이 네 가지가 합쳐지면 프로그램 구조가 달라진다. 예를 들어 결제 시스템에서 CardPayment, BankTransfer, KakaoPay를 모두 PaymentMethod라는 공통 부모로 다루게 만들 수 있다. 게임에서는 Warrior, Mage, Archer를 모두 Character로 묶어 파티를 관리할 수 있다. 그래픽 프로그램에서는 Circle, Rectangle, Triangle을 모두 Shape로 다뤄 그리기 함수를 하나로 유지할 수 있다.
이때 가장 큰 장점은 새로운 타입을 추가해도 기존 코드를 덜 건드리게 된다는 것이다. 이것이 큰 프로그램에서 정말 중요하다. 이미 잘 돌아가는 코드를 자꾸 뜯어고치는 것은 늘 위험하다. 객체지향은 새 기능을 "끼워 넣기 좋은 구조"를 만드는 데 큰 도움을 준다.34
초보자가 자주 하는 실수
객체지향 문법은 화려해 보이지만, 처음에는 몇 가지 실수를 반복하기 쉽다.
생성자를 만들었는데 기본 생성이 안 되는 이유를 모른다. 사용자 정의 생성자를 만들면 기본 생성자가 자동으로 생기지 않을 수 있다.5
private멤버를 바깥에서 직접 바꾸려 한다. 막히는 것이 정상이다. 클래스의 규칙을 통하게 해야 한다.2자식 클래스에서 같은 이름의 함수를 만들고도
virtual을 빼먹는다. 그러면 다형성이 아니라 그냥 "이름이 같은 다른 함수"처럼 동작할 수 있다.3override를 쓰지 않아 시그니처가 어긋난 것을 놓친다. 특히const하나 차이로도 재정의가 실패할 수 있다.3부모 포인터로
delete하면서 부모 소멸자를virtual로 만들지 않는다. 이 실수는 조용히 지나가서 더 무섭다.4생성자나 소멸자 안에서 가상 함수가 자식 버전으로 호출될 거라 기대한다. 보통 그렇지 않다.1
이 실수들은 대부분 "문법을 몰라서"라기보다, 객체의 생명 주기와 역할 분담을 아직 몸으로 익히지 못해서 생긴다. 그러니 틀려도 괜찮다. 대신 왜 그런 동작이 나왔는지 한 번씩 꼭 따져 보자. 그 과정에서 객체지향 감각이 붙는다.
정리
이번 장은 C++가 단순히 빠른 언어가 아니라, 큰 프로그램을 조직하는 언어라는 사실을 보여 주는 첫 관문이다.
생성자는 객체의 출발점을 책임진다. 소멸자는 마무리를 책임진다. 캡슐화는 내부 상태를 보호하고, 상속은 공통점을 묶어 주며, 다형성은 같은 인터페이스로 서로 다른 객체를 유연하게 다루게 만든다.
처음에는 이 개념들이 각각 따로 노는 것처럼 보일 수 있다. 하지만 실제로는 전부 연결되어 있다. 객체를 안전하게 만들고, 올바르게 사용하게 하고, 공통점을 재사용하고, 확장 가능한 구조를 만드는 하나의 흐름이다.
이제 C++의 클래스는 더 이상 "변수와 함수가 함께 있는 문법"이 아니다. 객체는 스스로 상태를 지키고, 부모에게서 공통 기능을 이어받고, 같은 메시지에 자신만의 방식으로 반응하는 존재가 된다.
이 감각을 익히면, C++ 코드는 점점 "줄을 많이 쓰는 코드"가 아니라 "역할이 분명한 코드"로 바뀌기 시작한다.
참고
1IBM constructors and destructors overview
2GeeksforGeeks Encapsulation in C++
3LearnCpp Virtual functions and polymorphism
4Standard C++ FAQ: virtual functions
5CircuitLabs C++ Object Lifecycle: Constructors & Destructors
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.