C++는 C 언어를 기반으로 객체지향, 제네릭 프로그래밍, 함수형 프로그래밍 등의 기능을 추가한 범용 프로그래밍 언어다. 1980년대 초 Bjarne Stroustrup이 개발하기 시작했으며, 현재는 ISO 표준에 따라 계속 발전하고 있다.
C++의 특징을 하나로 정리하면 높은 수준의 추상화와 낮은 수준의 메모리 제어를 동시에 제공하는 언어라는 점이다.
클래스와 템플릿을 이용해 복잡한 프로그램을 추상화할 수 있으면서도 포인터, 메모리 주소, 객체의 수명, 메모리 배치 같은 부분까지 직접 다룰 수 있다.
그래서 게임 엔진, 그래픽스, 운영체제, 임베디드, 데이터베이스, 고성능 서버처럼 성능과 자원 제어가 중요한 영역에서 오랫동안 사용되고 있다.
C++의 기본 구조
C++ 프로그램은 크게 보면 다음과 같은 요소들로 구성된다.
#include <iostream>
int main()
{
int value = 10;
std::cout << value << '\n';
return 0;
}
main()은 일반적인 실행 프로그램의 시작점이다.
변수를 선언하고 함수를 호출하며 객체를 생성하는 등의 작업이 여기서부터 시작된다.
C++ 코드는 소스 코드 그대로 실행되는 것이 아니라 컴파일러를 거친다.
C++ Source -> Preprocessing -> Compilation -> Object Code -> Linking -> Executable
컴파일 단계에서 C++ 코드는 기계가 실행할 수 있는 형태로 변환되고, 링커가 여러 오브젝트 파일과 라이브러리를 연결해 최종 실행 파일을 만든다.
이 구조를 이해하면 C++에서 헤더 파일, 라이브러리, 링커 에러가 왜 존재하는지도 자연스럽게 연결된다.
변수와 타입
C++은 정적 타입 언어다.
int health = 100;
float speed = 3.5f;
double distance = 100.0;
bool alive = true;
char grade = 'A';
변수의 타입이 결정되면 해당 타입에 맞는 메모리 공간과 연산 방식이 결정된다.
대표적인 기본 타입은 다음과 같다.
bool
char
short
int
long
long long
float
double
정수형은 크기와 표현 범위가 서로 다르고, 부동소수점 타입은 소수값을 표현한다.
다만 C++에서 타입의 실제 크기는 플랫폼과 컴파일러에 따라 달라질 수 있다. 그래서 정확한 크기가 중요한 코드에서는 <cstdint>의 std::int32_t, std::uint64_t 같은 고정 폭 정수 타입을 사용하는 경우가 많다.
포인터
C++을 이해하려면 포인터를 피하기 어렵다.
int value = 10;
int* ptr = &value;
여기서 value는 실제 데이터를 가지고 있고 &value는 그 데이터가 저장된 메모리 주소다.
ptr은 그 주소를 저장한다.
value <- ptr
포인터를 역참조하면 해당 주소에 있는 데이터를 접근할 수 있다.
*ptr = 20;
이 코드는 ptr이 가리키는 value의 값을 변경한다.
포인터가 강력한 이유는 단순히 주소를 저장하기 때문이 아니다. 객체의 메모리를 직접 참조하거나, 배열을 다루거나, 동적 메모리를 사용하거나, 특정 메모리 영역과 인터페이스를 연결할 수 있기 때문이다.
반대로 잘못 사용하면 잘못된 메모리 접근, Use-After-Free, Dangling Pointer 같은 문제가 발생한다.
C++에서 포인터를 사용할 때는 그 포인터가 무엇을 가리키고 있는지와 그 객체의 수명이 언제 끝나는지를 항상 같이 봐야 한다.
참조
참조는 기존 객체에 다른 이름을 붙이는 방식에 가깝다.
int value = 10;
int& ref = value;
ref = 20;
ref를 수정하면 value가 수정된다.
함수에서 특히 많이 사용한다.
void SetHealth(int& health)
{
health = 100;
}
객체를 복사하지 않고 기존 객체를 함수에 전달할 수 있다.
읽기만 필요한 경우에는 const 참조가 자주 사용된다.
void PrintName(const std::string& name)
{
std::cout << name;
}
큰 객체를 복사하지 않으면서 수정도 막을 수 있다.
값 전달 -> Object 복사 -> 함수
참조 전달 -> Object 참조 -> 함수
현대 C++에서는 함수 매개변수를 설계할 때 값, 참조, const 참조, 포인터 중 어떤 것이 의미에 맞는지를 구분하는 것이 중요하다.
함수
함수는 특정 작업을 하나의 단위로 분리한다.
int Add(int a, int b)
{
return a + b;
}
C++에서는 함수 오버로딩도 사용할 수 있다.
int Add(int a, int b);
double Add(double a, double b);
이름은 같지만 매개변수 타입이 다르기 때문에 서로 다른 함수로 취급된다.
함수는 단순히 코드를 재사용하기 위한 기능만은 아니다.
게임이나 서버처럼 코드 규모가 커지면 하나의 함수가 너무 많은 일을 담당하지 않도록 책임을 분리하는 것이 중요하다.
const
const는 C++ 코드를 이해할 때 자주 등장한다.
const int maxHealth = 100;
한번 초기화된 이후 값을 변경할 수 없다.
포인터에서는 const의 위치에 따라 의미가 달라진다.
const int* ptr;
int* const ptr;
첫 번째는 포인터를 통해 값을 변경할 수 없다는 의미이고, 두 번째는 포인터 자체를 다른 주소로 변경할 수 없다는 의미다.
const int* ptr = &value;
// *ptr = 20; // 불가능
int* const ptr = &value;
// ptr = &other; // 불가능
const는 단순한 문법이 아니라 변경 가능성을 코드에 명시하는 방법이라고 보는 편이 좋다.
클래스와 객체
C++의 객체지향 기능은 클래스에서 시작한다.
class Player
{
public:
void Attack();
private:
int health = 100;
};
클래스는 데이터와 해당 데이터를 처리하는 함수를 하나의 타입으로 묶는다.
Player player;
player.Attack();
이렇게 생성된 player가 객체다.
접근 지정자를 사용하면 외부에서 접근할 수 있는 인터페이스와 내부 구현을 구분할 수 있다.
Player -> public -> Attack()
Player -> private -> health
이런 구조를 이용하면 객체 내부 상태를 아무 코드에서나 직접 변경하지 못하게 만들 수 있다.
게임에서 Player, Monster, Weapon, Inventory 같은 개념을 코드로 표현할 때 흔히 사용한다.
생성자와 소멸자
객체가 생성될 때 실행되는 함수가 생성자다.
class Player
{
public:
Player(int hp)
: health(hp)
{
}
private:
int health;
};
Player player(100);처럼 객체를 생성하면 생성자가 호출된다.
반대로 객체의 수명이 끝날 때 소멸자가 호출된다.
~Player()
{
}
C++에서는 객체의 수명과 리소스 관리가 매우 밀접하게 연결되어 있다.
파일, 락, 메모리, 소켓 같은 리소스를 객체의 생성과 소멸에 맞춰 관리하는 패턴을 RAII(Resource Acquisition Is Initialization)라고 한다.
이 개념이 현대 C++의 자원 관리에서 상당히 중요하다.
스택과 힙
C++ 코드를 작성하다 보면 스택과 힙을 자주 구분하게 된다.
void Test()
{
Player player;
}
이처럼 지역 변수로 생성한 객체는 일반적으로 자동 저장 기간을 가진다. 함수의 스코프가 끝나면 객체의 수명도 끝난다.
반면 동적 메모리를 사용할 수도 있다.
Player* player = new Player();
delete player;
다만 현대 C++에서는 이런 식으로 직접 new와 delete를 호출하는 코드를 최대한 줄이는 방향으로 작성한다.
대신 스마트 포인터를 사용한다.
std::unique_ptr<Player> player =
std::make_unique<Player>();
unique_ptr은 객체의 소유권을 하나의 포인터가 갖도록 만들고, 포인터의 수명이 끝날 때 객체도 정리한다.
공유 소유권이 필요하면 shared_ptr을 사용할 수 있다.
std::shared_ptr<Player> player =
std::make_shared<Player>();
다만 shared_ptr은 편하다는 이유만으로 무조건 사용하는 것이 좋지는 않다. 참조 카운팅 비용이 존재하고 소유권 구조가 복잡해질 수 있다.
가능하면 객체의 소유권을 명확하게 설계하고, 그에 맞는 타입을 선택하는 편이 좋다.
상속과 다형성
C++에서는 클래스를 상속할 수 있다.
class Animal
{
public:
virtual void Speak();
};
class Dog : public Animal
{
public:
void Speak() override;
};
virtual을 사용하면 부모 클래스의 인터페이스를 통해 자식 클래스의 구현을 호출할 수 있다.
Animal* animal = new Dog();
animal->Speak();
이런 구조가 런타임 다형성이다.
다만 상속이 항상 좋은 설계는 아니다.
실제 코드에서는 상속보다 조합을 사용하는 것이 더 적합한 경우도 많다.
특히 게임 개발에서는 기능을 여러 컴포넌트로 분리하고 조합하는 구조가 자주 사용된다.
템플릿
C++의 제네릭 프로그래밍은 템플릿을 통해 구현된다.
template <typename T>
T Add(T a, T b)
{
return a + b;
}
그러면 int, float, double 등 여러 타입에 같은 함수를 사용할 수 있다.
Add(10, 20);
Add(1.5f, 2.5f);
STL 역시 템플릿을 기반으로 만들어져 있다.
std::vector<int> numbers;
std::vector<std::string> names;
같은 vector라는 자료구조를 사용하지만 저장하는 타입은 다르게 만들 수 있다.
템플릿은 단순한 코드 재사용을 넘어 C++의 타입 시스템과 결합되어 상당히 강력한 추상화를 만들 수 있다.
다만 템플릿 코드는 복잡해지기 시작하면 컴파일 에러와 타입 추론 과정이 상당히 어려워진다. 필요한 수준에서 사용하는 것이 좋다.
STL
C++ 표준 라이브러리에는 자료구조와 알고리즘을 비롯해 다양한 기능이 제공된다.
대표적으로 다음과 같은 것들이 있다.
vector
array
list
deque
map
set
unordered_map
unordered_set
string
게임 개발에서 특히 많이 사용하는 것은 vector, unordered_map, string 정도다.
std::vector<int> scores;
scores.push_back(100);
scores.push_back(200);
동적 배열이 필요할 때 직접 배열 크기를 관리하기보다 vector를 사용하는 것이 일반적이다.
알고리즘도 표준 라이브러리에 제공된다.
std::sort(scores.begin(), scores.end());
자료구조와 알고리즘을 직접 구현하는 것보다 검증된 표준 기능을 사용하는 것이 유지보수 측면에서 유리한 경우가 많다.
람다
C++에서는 함수를 변수처럼 다루는 람다 표현식을 사용할 수 있다.
auto add = [](int a, int b)
{
return a + b;
};
STL 알고리즘과 함께 사용하면 유용하다.
std::sort(scores.begin(), scores.end(),
[](int a, int b)
{
return a > b;
});
이 코드는 scores를 내림차순으로 정렬한다.
람다는 콜백이나 이벤트 처리, 알고리즘의 조건 정의 등에서 자주 사용한다.
예외 처리
C++에는 예외 처리 기능도 있다.
try
{
// 작업
}
catch (const std::exception& e)
{
// 예외 처리
}
throw를 사용해 예외를 발생시킬 수 있다.
다만 게임이나 고성능 서버처럼 프레임 단위 성능과 오류 처리 정책이 중요한 시스템에서는 예외를 어디까지 사용할지 별도로 결정하는 경우가 많다.
모든 오류를 예외로 처리하는 것이 정답은 아니다.
오류 코드, std::optional, std::expected 같은 방식으로 실패를 명시적으로 전달하는 설계도 있다.
move semantics
C++11부터 추가된 이동 의미론은 현대 C++을 이해할 때 빼놓기 어렵다.
객체를 복사하는 대신 내부 리소스를 다른 객체로 이동할 수 있다.
std::string a = "Hello";
std::string b = std::move(a);
std::move 자체가 데이터를 이동시키는 것은 아니다. 객체를 이동 가능한 상태로 취급하도록 캐스팅하는 역할을 한다.
실제 이동은 해당 타입의 이동 생성자나 이동 대입 연산자가 수행한다.
큰 객체나 동적 메모리를 가진 객체를 불필요하게 복사하지 않도록 하는 데 사용된다.
이 때문에 C++에서는 복사와 이동, 소유권이라는 개념이 서로 연결되어 있다.
lvalue와 rvalue
move semantics를 이해하려면 lvalue와 rvalue 개념도 필요하다.
int value = 10;
value처럼 이름을 가지고 지속적으로 접근할 수 있는 객체는 lvalue에 해당하고, 10 같은 임시 값은 rvalue로 볼 수 있다.
C++11 이후에는 이를 더 세분화해서 value category를 정의한다.
lvalue
xvalue
prvalue
glvalue
rvalue
일반적인 개발에서는 이 모든 분류를 처음부터 깊게 파고들 필요는 없다.
다만 다음 문법을 이해하려면 필요하다.
T(T&& other);
여기서 T&&는 rvalue reference다.
이것이 이동 생성자와 move semantics의 기반이 된다.
메모리 관리
C++에서 메모리 관리는 언어의 특징과 직접 연결된다.
직접 메모리를 할당할 수도 있다.
int* data = new int[100];
delete[] data;
하지만 이 방식은 개발자가 해제 시점을 직접 관리해야 한다.
실수하면 메모리 누수가 발생한다.
더 큰 문제는 단순한 메모리 누수만이 아니다.
Dangling Pointer
Use-After-Free
Double Free
Buffer Overflow
Memory Leak
이런 문제는 프로그램이 비정상 종료되거나, 잘못된 데이터를 읽거나, 보안 취약점으로 이어질 수 있다.
현대 C++에서는 가능한 한 객체의 수명을 코드 구조에 포함시키는 방향으로 작성한다.
{
std::unique_ptr<Player> player =
std::make_unique<Player>();
// 사용
}
// 여기서 자동 정리
RAII와 스마트 포인터를 활용하면 많은 메모리 관리 문제를 구조적으로 줄일 수 있다.
헤더와 소스 파일
C++ 프로젝트는 보통 선언과 구현을 분리한다.
// Player.h
class Player
{
public:
void Attack();
};
// Player.cpp
#include "Player.h"
void Player::Attack()
{
}
헤더에는 다른 코드에서 알아야 하는 인터페이스를 선언하고, 소스 파일에는 구현을 작성하는 식이다.
물론 템플릿처럼 구현까지 헤더에 있어야 하는 경우도 있다.
헤더는 여러 파일에서 include될 수 있기 때문에 중복 정의를 막기 위한 헤더 가드나 #pragma once도 사용한다.
전처리기
C++에는 컴파일 전에 코드를 처리하는 전처리 단계가 있다.
#include <iostream>
#define MAX_PLAYER 100
#include는 지정된 헤더 내용을 현재 소스에 포함시키는 역할을 한다.
#define을 이용한 매크로도 사용할 수 있지만, 현대 C++에서는 단순한 상수나 타입 정의에 매크로를 사용하는 것을 피하는 편이다.
constexpr int MaxPlayer = 100;
이런 방식이 타입 시스템과 디버깅 측면에서 더 명확하다.
전처리기는 조건부 컴파일에도 사용된다.
#ifdef DEBUG
// 디버그 코드
#endif
플랫폼별 코드나 빌드 설정에 따라 코드를 분리할 때 사용한다.
C++에서 중요한 설계 관점
C++은 문법을 많이 아는 것만으로 잘 사용할 수 있는 언어는 아니다.
같은 기능도 여러 방식으로 작성할 수 있다.
Player player;
Player* player;
std::unique_ptr<Player> player;
std::shared_ptr<Player> player;
문법적으로 모두 가능한 상황이 있을 수 있지만 의미는 다르다.
객체를 누가 소유하는지, 언제 생성되고 언제 파괴되는지, 복사가 필요한지, 이동이 가능한지, 외부에서 수정할 수 있어야 하는지에 따라 적절한 표현이 달라진다.
그래서 C++ 코드를 볼 때는 단순히 “이 문법이 무엇인가”만 보는 것보다 객체의 수명과 소유권이 어떻게 흘러가는가를 보는 편이 좋다.
C++과 성능
C++이 고성능 언어로 많이 사용되는 이유는 단순히 컴파일 언어이기 때문만은 아니다.
메모리 배치와 객체 수명, 데이터 구조, 메모리 접근 패턴 등을 개발자가 상당히 세밀하게 제어할 수 있다.
예를 들어 같은 데이터를 처리하더라도
Cache-friendly한 연속 메모리 접근
과
여기저기 흩어진 메모리 접근
은 실제 CPU 성능에서 차이가 발생할 수 있다.
그래서 C++에서는 자료구조를 선택할 때 단순히 기능만 보는 것이 아니라 메모리 배치와 접근 패턴까지 고려하게 된다.
게임 엔진이나 렌더링 시스템에서 데이터 지향 설계가 중요하게 다뤄지는 이유도 이런 부분과 연결된다.
C++의 동시성
C++ 표준 라이브러리에는 스레드 관련 기능도 포함되어 있다.
std::thread
std::mutex
std::lock_guard
std::atomic
std::condition_variable
예를 들어 여러 스레드가 공유 데이터를 수정한다면 동기화가 필요하다.
std::mutex mutex;
{
std::lock_guard<std::mutex> lock(mutex);
// 공유 데이터 접근
}
lock_guard는 스코프를 벗어날 때 자동으로 mutex를 해제한다.
이 역시 RAII의 활용이다.
멀티스레드 프로그래밍에서는 단순히 스레드를 만드는 것보다 데이터의 소유권과 공유 범위를 어떻게 설계할지가 더 중요하다.
C++의 컴파일과 빌드
규모가 커지면 C++에서는 빌드 시스템도 중요해진다.
일반적으로 다음과 같은 요소들이 함께 사용된다.
C++ Source -> Compiler -> Object File -> Linker -> Executable
프로젝트가 커지면 소스 파일과 라이브러리가 많아지기 때문에 빌드 설정을 직접 관리하기 어려워진다.
그래서 CMake 같은 빌드 시스템을 사용한다.
CMake -> Build Configuration -> Compiler / Linker -> Executable
C++ 개발에서는 언어 자체뿐만 아니라 컴파일러, 링커, 빌드 시스템, 디버거까지 하나의 개발 환경으로 봐야 한다.
실무에서 C++을 사용할 때
C++을 사용하는 영역은 상당히 넓다.
게임에서는 엔진과 렌더링, 물리, 네트워크, 툴 등에 사용되고 운영체제나 브라우저, 데이터베이스, 임베디드 시스템에서도 사용된다.
분야가 달라도 공통적으로 따라오는 문제가 있다.
메모리
객체 수명
소유권
성능
동시성
ABI
빌드
디버깅
특히 규모가 커질수록 언어 문법보다 이러한 시스템적인 문제가 더 중요해진다.
C++은 개발자가 시스템 내부에 깊게 들어갈 수 있는 만큼 그에 따른 책임도 함께 가져가는 언어라고 생각한다.
정리
C++을 공부하면서 처음에는 문법 자체가 어렵게 느껴질 수 있다.
포인터가 있고, 참조가 있고, 클래스가 있고, 템플릿이 있고, 상속이 있고, 스마트 포인터가 있고, move semantics까지 들어간다.
그런데 각각을 따로 외우기 시작하면 오히려 복잡해진다.
C++의 구조를 큰 틀에서 보면 다음과 같이 정리할 수 있다.
기본 타입 -> 변수 / 함수 -> 포인터 / 참조 -> 클래스 / 객체 -> 생성자 / 소멸자 -> 객체 수명 / 소유권 -> RAII / 스마트 포인터 -> 템플릿 / STL -> move semantics -> 동시성 / 성능 / 시스템 프로그래밍
결국 C++에서 계속 따라오는 문제는 데이터가 어디에 존재하고, 누가 그것을 소유하며, 언제 생성되고 언제 사라지는가에 가깝다.
여기에 컴파일러와 CPU, 메모리 구조까지 연결되면서 C++의 특성이 만들어진다.
그래서 C++은 문법만 공부해서 끝나는 언어라기보다 프로그램이 실제 컴퓨터에서 어떻게 동작하는지를 같이 이해하면서 사용할수록 구조가 보이는 언어라고 생각한다.