C++ · 심층 가이드
C++ 객체지향 완전 정리
클래스 설계 20개 예제로 생성자 초기화 리스트와 가상 소멸자, 슬라이싱을 피하는 다형성, 값 객체 비교, 의존성 주입까지 C++식 객체 모델을 정리합니다.
C++의 객체는 기본적으로 값입니다. 참조로만 객체를 다루는 언어에서 오면 이 지점에서 모델이 어긋납니다. 대입하면 복사되고, 함수에 넘기면 또 복사되며, 스코프를 벗어나면 소멸자가 결정적으로 호출됩니다. 이 결정적 소멸이 곧 RAII의 기반이고, 그래서 C++에서는 자원 정리를 언어 기능이 아니라 클래스 설계로 해결합니다. 상속 계층을 그리기 전에 소멸자와 복사·이동 동작을 먼저 정하는 것이 순서입니다.
생성자와 초기화에서는 대입이 아니라 멤버 초기화 리스트가 기본이라는 점을, 캡슐화와 불변 데이터 객체에서는 const 멤버와 접근자 설계를 봅니다. 인터페이스 또는 trait는 순수 가상 함수로 표현되는데, 이것이 다형성과 의존성 주입으로 그대로 이어집니다. 인터페이스를 참조나 스마트 포인터로 받아 두면 저장소 객체와 서비스 객체가 테스트에서 교체 가능해집니다. 이 네 항목은 붙여서 읽는 편이 이해가 빠릅니다.
두 가지 함정이 반복됩니다. 첫째, 기반 클래스 포인터로 파생 객체를 delete할 때 소멸자가 가상이 아니면 정의되지 않은 동작이 됩니다. 다형적으로 쓸 기반 클래스에는 가상 소멸자를, 그렇지 않다면 상속 자체를 막는 편이 안전합니다. 둘째는 슬라이싱입니다. 파생 객체를 기반 타입 값으로 받으면 파생 부분이 조용히 잘려 나가고 컴파일러는 아무 말도 하지 않습니다. 다형성이 필요한 자리에서는 값이 아니라 참조나 포인터로 받아야 합니다.
01기본 클래스 또는 구조체
User는 name과 level만 가진 집합체이고, User user{"Ada", 1}는 선언 순서대로 두 멤버를 채운 뒤 이름을 출력합니다. 설정값이나 좌표처럼 동작 없이 값만 묶을 때 이 형태를 씁니다. 사용자 정의 생성자를 하나라도 붙이면 집합체 자격을 잃어 이 중괄호 초기화는 더 이상 컴파일되지 않습니다.
C++ code
// 기본 클래스 또는 구조체
#include <iostream>
#include <string>
struct User { std::string name; int level; };
int main() {
User user{"Ada", 1};
std::cout << user.name << "\n";
}C++20의 지정 초기화자를 쓰면 중괄호 안에 .name과 .level처럼 필드 이름을 직접 적을 수 있어 순서를 헷갈릴 일이 줄어듭니다.
중괄호 초기화에서 값을 하나만 적으면 나머지 멤버는 값 초기화되어 level이 0이 되므로, 빠뜨린 필드가 조용히 기본값으로 채워집니다.
02생성자와 초기화
생성자를 private으로 감추고 정적 함수 local()만 열어 두어, Connection은 오직 정해진 경로로만 만들어집니다. 초기화 리스트의 std::move는 인자 문자열을 복사 대신 옮깁니다. explicit이 붙어 있어 문자열이 저절로 Connection으로 변환되는 일도 막힙니다.
C++ code
// 생성자와 초기화
#include <iostream>
#include <string>
class Connection { public: static Connection local() { return Connection("local"); } const std::string& mode() const { return mode_; } private: explicit Connection(std::string mode) : mode_(std::move(mode)) {} std::string mode_; };
int main() {
std::cout << Connection::local().mode() << "\n";
}초기화 리스트에 적은 순서와 무관하게 멤버는 선언 순서대로 초기화되므로, 두 순서를 맞춰 두어야 경고 없이 읽힙니다.
std::move로 옮긴 인자를 그 뒤에 다시 읽으면 내용이 무엇인지 보장되지 않으므로, 이동은 마지막 사용 지점에서만 해야 합니다.
03인스턴스 메서드
출력에 쓰인 것은 메서드가 아니라 user.name이라는 공개 멤버 직접 접근입니다. 데이터 전달이 목적인 작은 타입은 이렇게 두는 편이 읽기 쉽습니다. struct와 class의 실질적 차이는 기본 접근 지정자가 public이냐 private이냐뿐입니다.
C++ code
// 인스턴스 메서드
#include <iostream>
#include <string>
struct User { std::string name; int level; };
int main() {
User user{"Ada", 3};
std::cout << user.name << "\n";
}읽기 전용 멤버 함수에 const를 붙여 두면 const User&로 받은 객체에서도 부를 수 있어, 호출 가능한 자리가 넓어집니다.
struct라고 해서 상속이 막히는 것은 아니며, 기본 상속 접근이 public이라는 점만 class와 다릅니다.
04정적 메서드
Connection::local()은 객체 없이 클래스 이름만으로 호출되어 새 인스턴스를 돌려주는 이름 붙은 생성 함수 역할을 합니다. 생성 방식이 여러 가지일 때 각각에 의미 있는 이름을 줄 수 있습니다. 정적 멤버 함수에는 this가 없어 mode_ 같은 비정적 멤버를 직접 건드릴 수 없습니다.
C++ code
// 정적 메서드
#include <iostream>
#include <string>
class Connection { public: static Connection local() { return Connection("local"); } const std::string& mode() const { return mode_; } private: explicit Connection(std::string mode) : mode_(std::move(mode)) {} std::string mode_; };
int main() {
std::cout << Connection::local().mode() << "\n";
}정적 팩터리를 쓰면 인자 개수가 같아 오버로드로 구분할 수 없는 생성 방식에도 서로 다른 이름을 줄 수 있습니다.
정적 멤버 함수 안에서 비정적 멤버 이름을 그대로 적으면 this가 없어 컴파일 오류가 나므로, 대상 객체를 인자로 받아야 합니다.
05상속 또는 확장
여기에는 기반 클래스도 파생 클래스도 없고, 평범한 User 하나를 만들어 출력할 뿐입니다. 계층을 도입하기 전에 데이터 모양을 먼저 굳히는 순서로 보면 됩니다. 상속을 얹는 순간 기반 포인터로 delete하려면 가상 소멸자가 필요하고, 파생 객체를 기반 타입 값에 대입하면 추가 멤버가 잘려 나갑니다.
C++ code
// 상속 또는 확장
#include <iostream>
#include <string>
struct User { std::string name; int level; };
int main() {
User user{"Ada", 5};
std::cout << user.name << "\n";
}기반 클래스를 만들 때는 소멸자를 virtual로 두거나, 다형적으로 쓰지 않을 것이라면 final을 붙여 상속 자체를 막는 편이 안전합니다.
파생 객체를 기반 타입 값 매개변수로 넘기면 추가 멤버가 잘려 나가고 가상 함수도 기반 쪽이 불리므로, 다형성이 필요하면 참조로 받아야 합니다.
06인터페이스 또는 trait
코드의 Connection에는 순수 가상 함수가 없으므로 인터페이스가 아니라 구현이 하나로 고정된 구체 타입입니다. 구현이 하나뿐일 때는 이 상태로 두는 편이 간단합니다. C++에는 interface 키워드가 없어서, 필요해지면 virtual ... = 0과 가상 소멸자를 갖춘 추상 클래스로 같은 역할을 만듭니다.
C++ code
// 인터페이스 또는 trait
#include <iostream>
#include <string>
class Connection { public: static Connection local() { return Connection("local"); } const std::string& mode() const { return mode_; } private: explicit Connection(std::string mode) : mode_(std::move(mode)) {} std::string mode_; };
int main() {
std::cout << Connection::local().mode() << "\n";
}순수 가상 함수만 가진 클래스라도 소멸자에는 정의가 필요하며, virtual ~Base() = default; 한 줄이 그 역할을 합니다.
가상 소멸자 없이 기반 포인터로 delete하면 파생 클래스의 소멸자가 불리지 않아 자원이 새고, 이 자체가 정의되지 않은 동작입니다.
07캡슐화
struct User의 멤버는 전부 공개라 바깥에서 level을 아무 값으로나 덮어쓸 수 있습니다. 불변 조건을 지켜야 하는 필드가 생기면 그때 private과 접근자로 옮깁니다. 접근 지정자는 컴파일러가 확인하는 규칙일 뿐이라 메모리 배치를 바꾸거나 포인터 조작을 막아 주지는 않습니다.
C++ code
// 캡슐화
#include <iostream>
#include <string>
struct User { std::string name; int level; };
int main() {
User user{"Ada", 7};
std::cout << user.name << "\n";
}불변 조건이 있다면 생성자에서 한 번 검증하고 그 뒤로는 값을 바꾸지 못하게 두는 편이, 설정자마다 검사를 흩어 두는 것보다 확실합니다.
필드를 private으로 옮기면서 모든 멤버에 기계적으로 설정자를 만들면 공개해 둔 것과 다를 바가 없어져 캡슐화의 이점이 사라집니다.
08합성으로 기능 묶기
Connection은 std::string mode_를 값으로 품고 있으니, 상속이 아니라 멤버 보유로 기능을 조립한 형태입니다. 다른 타입의 능력을 빌리고 싶을 때 가장 먼저 시도할 방식입니다. 멤버가 스스로 자원을 정리하므로 소멸자와 복사·이동 연산을 직접 쓸 필요가 없습니다.
C++ code
// 합성으로 기능 묶기
#include <iostream>
#include <string>
class Connection { public: static Connection local() { return Connection("local"); } const std::string& mode() const { return mode_; } private: explicit Connection(std::string mode) : mode_(std::move(mode)) {} std::string mode_; };
int main() {
std::cout << Connection::local().mode() << "\n";
}멤버가 모두 스스로 정리되는 타입이면 소멸자와 복사·이동을 하나도 선언하지 않는 편이 좋고, 이것이 이른바 0의 규칙입니다.
소멸자를 직접 선언하면 이동 생성자와 이동 대입이 자동으로 만들어지지 않아, 이동이 조용히 복사로 되돌아가며 성능이 떨어집니다.
09팩터리 메서드
이 예제가 하는 일은 중괄호 초기화로 User를 즉석에서 만드는 것뿐이고, 별도의 생성 함수는 등장하지 않습니다. 생성 규칙이 늘어날 때 이 자리를 정적 함수로 감싸게 됩니다. C++17부터 return User{...}; 같은 순수 우변값 반환은 복사·이동을 아예 만들지 않도록 표준이 요구합니다.
C++ code
// 팩터리 메서드
#include <iostream>
#include <string>
struct User { std::string name; int level; };
int main() {
User user{"Ada", 9};
std::cout << user.name << "\n";
}이름 있는 지역 변수를 반환할 때도 컴파일러는 대개 복사를 생략하지만, 이는 의무가 아니므로 return std::move(local)을 적으면 오히려 최적화를 막습니다.
팩터리가 참조를 돌려주도록 만들면 함수 안에서 만든 객체를 가리키게 되어, 호출한 쪽이 이미 사라진 메모리를 읽습니다.
10열거형 상태 표현
상태를 나타내는 mode_가 문자열로 저장되어 있어 값은 "local"이라는 텍스트로 표현됩니다. 모드 종류가 늘고 비교가 잦아지면 이 자리를 바꿔야 합니다. enum class로 바꾸면 오타가 컴파일 단계에서 걸리고, 문자열 비교와 힙 할당도 사라집니다.
C++ code
// 열거형 상태 표현
#include <iostream>
#include <string>
class Connection { public: static Connection local() { return Connection("local"); } const std::string& mode() const { return mode_; } private: explicit Connection(std::string mode) : mode_(std::move(mode)) {} std::string mode_; };
int main() {
std::cout << Connection::local().mode() << "\n";
}enum class는 기반 타입을 지정할 수 있어, 크기를 못 박거나 다른 형식으로 저장할 때 표현 범위를 예측할 수 있습니다.
범위 없는 enum은 정수로 자동 변환되어 서로 다른 열거형끼리 비교해도 통과하므로, 잘못된 상수를 넘겨도 컴파일러가 잡지 못합니다.
11불변 데이터 객체
이름과 레벨이 담긴 user는 이름만 읽고 끝나지만, 멤버 자체는 const가 아니라 언제든 수정 가능합니다. 읽기 전용으로 다루고 싶다면 참조를 const로 받는 쪽이 현실적입니다. 멤버에 const를 직접 붙이면 대입 연산자가 삭제되어 std::vector 안에서 정렬하거나 교체할 수 없게 됩니다.
C++ code
// 불변 데이터 객체
#include <iostream>
#include <string>
struct User { std::string name; int level; };
int main() {
User user{"Ada", 11};
std::cout << user.name << "\n";
}값을 바꾸지 않는 지역 객체는 const로 선언해 두면, 나중에 실수로 수정하는 코드가 들어올 때 컴파일 단계에서 막힙니다.
const 멤버를 가진 타입은 이동 대입까지 막히므로, 정렬이나 재배치가 필요한 컨테이너에 담으면 컴파일 오류를 만나게 됩니다.
12빌더 패턴
단계별 설정을 쌓는 대신, 완성된 객체를 한 번에 돌려주는 local() 하나로 생성이 끝납니다. 선택 항목이 두세 개뿐이라면 이 편이 코드가 짧습니다. 빌더는 설정 항목이 많고 조합이 자유로울 때 값어치가 생기며, 그 전까지는 이름 붙인 정적 함수가 더 저렴합니다.
C++ code
// 빌더 패턴
#include <iostream>
#include <string>
class Connection { public: static Connection local() { return Connection("local"); } const std::string& mode() const { return mode_; } private: explicit Connection(std::string mode) : mode_(std::move(mode)) {} std::string mode_; };
int main() {
std::cout << Connection::local().mode() << "\n";
}빌더의 각 설정 함수가 자기 참조를 돌려주도록 하면 호출을 이어 쓸 수 있고, 마지막 단계에서 필수 항목을 한 번에 검사할 수 있습니다.
빌더가 필수 항목을 검사하지 않으면 절반만 채워진 객체가 만들어져, 생성자로 강제하던 안전장치를 그대로 잃게 됩니다.
13다형성
가상 함수도 기반 포인터도 없으므로 user.name 호출은 컴파일 시점에 대상이 확정됩니다. 타입이 하나로 정해진 코드에서는 이 정적 결합이 가장 빠릅니다. 가상 함수를 도입하면 각 객체에 vtable 포인터가 하나 추가되고 호출이 간접 분기로 바뀝니다.
C++ code
// 다형성
#include <iostream>
#include <string>
struct User { std::string name; int level; };
int main() {
User user{"Ada", 13};
std::cout << user.name << "\n";
}파생 클래스에서 재정의할 때 override를 붙이면 시그니처가 어긋났을 때 컴파일 오류로 잡히고, 더 이상의 재정의는 final로 막습니다.
생성자나 소멸자 안에서 가상 함수를 부르면 아직 완성되지 않은 파생 부분 대신 현재 단계의 구현이 불려, 기대와 다른 동작이 나옵니다.
14의존성 주입
local()은 "local"이라는 모드를 함수 안에서 스스로 정하므로, 외부에서 다른 설정을 밀어 넣을 통로가 없습니다. 테스트에서 다른 모드를 쓰고 싶다면 이 구조를 바꿔야 합니다. 생성자가 private이라 주입을 허용하려면 공개 생성자를 추가하거나 인자를 받는 팩터리를 하나 더 두어야 합니다.
C++ code
// 의존성 주입
#include <iostream>
#include <string>
class Connection { public: static Connection local() { return Connection("local"); } const std::string& mode() const { return mode_; } private: explicit Connection(std::string mode) : mode_(std::move(mode)) {} std::string mode_; };
int main() {
std::cout << Connection::local().mode() << "\n";
}테스트에서만 필요한 통로라면 인자를 받는 팩터리를 하나 더 두는 편이, 생성자를 통째로 공개해 불변 조건을 무너뜨리는 것보다 낫습니다.
의존 대상을 원시 포인터로 주입하면 수명 책임이 흐려지므로, 소유하지 않는다면 참조로 받아 널이 될 수 없음을 타입으로 드러내는 편이 낫습니다.
15값 객체 비교
User에는 비교 연산자가 정의되어 있지 않아, 두 개를 만들어 ==로 견주면 컴파일 오류가 납니다. 같은 값이면 같은 것으로 취급해야 하는 타입에는 비교가 필요합니다. C++20에서는 bool operator==(const User&) const = default; 한 줄로 멤버별 비교를 생성할 수 있습니다.
C++ code
// 값 객체 비교
#include <iostream>
#include <string>
struct User { std::string name; int level; };
int main() {
User user{"Ada", 15};
std::cout << user.name << "\n";
}C++20의 삼방 비교를 = default로 선언하면 멤버를 선언 순서대로 비교하는 순서 연산자까지 한 번에 만들어집니다.
부동소수 멤버가 있는 타입에 기본 비교를 그대로 쓰면 오차 때문에 같아야 할 값이 다르게 판정되고, NaN이 들어가면 어떤 비교도 참이 되지 않습니다.
16저장소 객체
Connection이 보관하는 것은 모드 문자열 하나이고, mode()는 그 내부를 const 참조로 그대로 내줍니다. 큰 문자열을 복사 없이 읽게 할 때 쓰는 방식입니다. 다만 반환된 참조는 원본 객체가 살아 있는 동안만 유효해서, 임시 Connection::local()의 결과를 참조 변수에 담아 두면 곧바로 무효가 됩니다.
C++ code
// 저장소 객체
#include <iostream>
#include <string>
class Connection { public: static Connection local() { return Connection("local"); } const std::string& mode() const { return mode_; } private: explicit Connection(std::string mode) : mode_(std::move(mode)) {} std::string mode_; };
int main() {
std::cout << Connection::local().mode() << "\n";
}반환된 const 참조를 auto로 받으면 참조가 벗겨져 복사가 생기므로, 복사를 피하려면 const auto&로 받아야 합니다.
임시 객체의 멤버를 가리키는 참조는 그 줄이 끝나면 무효가 되므로, 팩터리 호출 결과의 내부 참조를 변수에 담아 두면 곧바로 매달린 참조가 됩니다.
17서비스 객체
화면에 이름을 찍는 로직은 main에 있고 User는 데이터만 들고 있으니, 동작과 데이터가 분리된 배치입니다. 흐름 제어가 복잡해질 때 그 부분을 따로 묶게 됩니다. 보관할 상태가 없는 동작이라면 클래스보다 네임스페이스 안의 자유 함수가 더 단순합니다.
C++ code
// 서비스 객체
#include <iostream>
#include <string>
struct User { std::string name; int level; };
int main() {
User user{"Ada", 17};
std::cout << user.name << "\n";
}상태가 없는 동작은 정적 멤버보다 네임스페이스 안의 자유 함수로 두면, 헤더 의존이 줄고 테스트에서 부르기도 쉬워집니다.
정적 저장 기간 객체에 상태를 두면 번역 단위 사이의 초기화 순서가 정해지지 않아, 다른 전역이 먼저 그것을 쓰면 초기화되기 전 값을 읽습니다.
18DTO 매핑
내부 필드 mode_를 mode()라는 읽기 창구로 노출해, 바깥은 저장 형태를 몰라도 값을 가져갈 수 있습니다. 계층 사이에서 값을 옮길 때 이런 얇은 통로를 씁니다. 변환 과정에서 std::move로 옮긴 원본 문자열은 파괴해도 안전하지만 내용이 무엇인지는 보장되지 않으므로 다시 읽으면 안 됩니다.
C++ code
// DTO 매핑
#include <iostream>
#include <string>
class Connection { public: static Connection local() { return Connection("local"); } const std::string& mode() const { return mode_; } private: explicit Connection(std::string mode) : mode_(std::move(mode)) {} std::string mode_; };
int main() {
std::cout << Connection::local().mode() << "\n";
}계층 사이를 오가는 타입은 필드만 가진 단순한 구조로 두어야, 양쪽 도메인의 규칙이 그 타입에 함께 스며들지 않습니다.
접근자가 내부 컨테이너의 참조를 그대로 내주면 바깥에서 내용을 고칠 수 있으므로, 불변을 지키려면 복사본이나 읽기 전용 뷰로 돌려주어야 합니다.
19변경 불가능 업데이트
user는 만들어진 뒤 값이 바뀌지 않고 그대로 읽히기만 합니다. 수정 대신 새 값을 만들어 갈아 끼우는 방식은 공유 상태가 얽힌 코드에서 추적을 쉽게 해 줍니다. 다만 std::string 멤버가 있으면 새로 만들 때마다 힙 할당이 따라오므로, 정수만 든 구조체와 같은 비용으로 생각하면 안 됩니다.
C++ code
// 변경 불가능 업데이트
#include <iostream>
#include <string>
struct User { std::string name; int level; };
int main() {
User user{"Ada", 19};
std::cout << user.name << "\n";
}일부 필드만 바꾼 사본을 만들 때는 복사한 뒤 그 필드만 대입하면 되고, 짧은 문자열이라면 구현에 따라 힙을 쓰지 않을 수도 있습니다.
매번 새 객체를 만들어 교체하는 방식은 큰 구조에서 복사 비용이 그대로 드러나므로, 변경이 잦은 자리에는 맞지 않습니다.
20확장 스타일 동작
Connection에 새 멤버를 추가하지 않고도, 이 타입을 인자로 받는 함수를 밖에 두면 기능을 붙일 수 있습니다. 헤더를 고칠 수 없는 타입에 동작을 얹을 때 쓰는 방법입니다. C++에는 확장 메서드 문법이 없어 자유 함수와 인자 의존 탐색으로 대신하며, 그런 함수는 private 멤버에 접근하지 못합니다.
C++ code
// 확장 스타일 동작
#include <iostream>
#include <string>
class Connection { public: static Connection local() { return Connection("local"); } const std::string& mode() const { return mode_; } private: explicit Connection(std::string mode) : mode_(std::move(mode)) {} std::string mode_; };
int main() {
std::cout << Connection::local().mode() << "\n";
}자유 함수를 타입과 같은 네임스페이스에 두면 인자 의존 탐색으로 찾아지므로, 사용자는 네임스페이스를 적지 않고도 호출할 수 있습니다.
friend를 남발해 자유 함수에 내부 접근을 열어 주면 캡슐화가 무너지므로, 공개 인터페이스만으로 구현할 수 있는지 먼저 따져야 합니다.
정리하며
- 다형적으로 사용할 기반 클래스에는 가상 소멸자를 두고, 아니라면 상속을 막습니다
- 파생 객체를 기반 타입 값으로 받으면 슬라이싱이 일어나므로 참조나 포인터를 씁니다
- 멤버는 생성자 본문 대입이 아니라 초기화 리스트로 채우고 const 멤버를 적극 활용합니다
- 순수 가상 인터페이스를 주입 받게 설계하면 저장소와 서비스를 테스트에서 교체할 수 있습니다
더 깊이 들어가고 싶다면 C++ 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.