들어가며
C++을 공부하다 보면 변수와 함수, 클래스, 포인터와 같은 문법과 기능을 익히는 데 집중하게 된다.하지만 문법을 사용할 수 있게 되는 것과 그 문법이 왜 만들어졌는지를 이해하는 것은 별개의 문제이다. 문법을 단순히 암기하는 데 그치지 않고, 각각의 개념이 어떤 필요에 의해 등장했는지 살펴보면 C++을 더 나아가 프로그래밍에 대해 더욱 깊이 이해할 수 있다.하나의 언어가 만들어지고 발전하는 과정에는 해결해야 할 문제와 그에 따른 설계 의도가 담겨 있다. C++의 문법과 객체지향 프로그래밍 역시 이러한 흐름 속에서 등장한 개념이다.이 글에서는 C++의 문법을 바로 살펴보기보다, 언어와 기능들이 어떤 배경에서 등장했는지부터 차근차근 알아나아가고자 한다.
먼저 생각해볼 질문
본문을 읽기 전에 다음 질문에 대해 먼저 스스로 고민해보자.
- C++은 왜 등장했을까?
- 객체지향 프로그래밍은 왜 필요해졌을까?
- 객체지향 프로그래밍에는 어떤 특징이 필요할까?
C++의 등장
언어가 만들어진 배경과 역사를 살펴보면, 해당 언어가 어떤 문제를 해결하기 위해 설계되었는지 이해할 수 있다.
C++ 역시 처음부터 완성된 형태로 등장한 언어가 아니다. 기존 언어가 가진 장점을 활용하면서도, 당시 개발 과정에서 느꼈던 한계를 보완하는 방향으로 발전했다.
C++ 이전의 두 흐름
C++의 등장 배경을 이해하기 위해서는 먼저 C와 Simula를 살펴볼 필요가 있다.
C는 효율적인 실행 속도와 하드웨어에 가까운 제어를 제공했으며, 운영체제와 같은 시스템 소프트웨어를 개발하는 데 적합했다. 또한 다른 소프트웨어와 연동하기 쉽고 다양한 환경에서 사용할 수 있다는 장점이 있었다.
하지만 C는 프로그램의 상태와 동작을 명확한 단위로 묶어 표현하는 기능이 제한적이었다. 프로그램의 규모가 커질수록 데이터와 함수 사이의 관계가 복잡해지고, 코드의 변경 범위와 의존성을 관리하기 어려워질 수 있었다.
Simula는 C와 다른 방향의 장점을 가지고 있었다. 강한 타입 시스템과 객체지향적인 설계 방식을 통해 프로그램을 구조적으로 표현할 수 있었다. 관련된 데이터와 동작을 객체라는 단위로 묶고, 객체 사이의 관계를 통해 프로그램을 설계할 수 있다는 점이 특징이었다.
그러나 Simula는 시스템 소프트웨어를 개발하는 데 필요한 실행 효율과 다른 소프트웨어와의 연동 측면에서 한계가 있었다.
결국 두 언어는 서로 다른 장점을 가지고 있었다.
- C는 효율적인 실행과 시스템 수준의 제어에 강점이 있었다.
- Simula는 복잡한 프로그램을 구조적으로 설계하는 데 강점이 있었다.
C++은 이러한 두 가지 방향의 장점을 결합하려는 시도에서 출발했다.
C With Classes
Bjarne Stroustrup은 1979년 Bell Labs에서 C를 기반으로 새로운 기능을 추가하기 시작했다. 처음부터 C++이라는 이름을 사용한 것은 아니며, 초기에는 C with Classes라는 이름으로 불렸다.
C with Classes에는 클래스와 생성자 같은 기능이 추가되었다. 이를 통해 C가 제공하던 효율성과 시스템 프로그래밍 능력을 유지하면서도, 데이터를 관련된 동작과 함께 관리할 수 있는 구조를 표현할 수 있게 되었다.
초기의 C with Classes는 별도의 독립적인 언어라기보다, 새로운 문법을 C 코드로 변환한 뒤 기존 C 컴파일러를 통해 처리하는 방식으로 동작했다. 이러한 방식은 기존 C 개발 환경과 소프트웨어를 활용할 수 있다는 장점을 제공했다.
이후 가상 함수, 함수 오버로딩, 연산자 오버로딩, 상속과 같은 기능이 추가되면서 언어의 표현력이 점차 확장되었다. C with Classes는 이러한 발전을 거쳐 C++이라는 이름으로 발전하게 되었다.
객체지향 프로그래밍의 대두
앞서 살펴본 것처럼 C++은 C의 효율성과 시스템 프로그래밍 능력을 유지하면서, 복잡한 프로그램을 더 체계적으로 표현하기 위한 방향으로 발전했다. 그렇다면 프로그램을 체계적으로 표현한다는 것은 구체적으로 무엇을 의미할까?
절차 중심으로 프로그램을 구성하는 방식
초기의 프로그래밍은 프로그램이 수행해야 할 작업을 순서대로 표현하는 방식에 가까웠다. 필요한 데이터를 정의하고, 그 데이터를 처리하는 함수를 작성한 뒤, 함수가 실행되는 순서를 구성하는 방식이다.
이러한 방식은 프로그램의 규모가 작을 때는 단순하고 이해하기 쉽다. 기능이 많지 않고 데이터의 종류도 제한적이라면, 어떤 함수가 어떤 데이터를 사용하는지 직접 확인하면서 프로그램을 관리할 수 있다.
그러나 프로그램의 규모가 커지면 상황이 달라진다.
하나의 데이터가 여러 함수에서 사용되고, 하나의 함수가 여러 데이터에 의존하게 되면서 데이터와 함수 사이의 관계가 복잡해진다. 특정 데이터를 변경했을 때 어떤 함수가 영향을 받는지 확인해야 하고, 새로운 기능을 추가할 때 기존 코드의 여러 부분을 함께 수정해야 할 수도 있다.
결국 프로그램이 처리해야 하는 기능이 많아질수록 다음과 같은 문제가 발생할 수 있다.
- 데이터와 데이터를 처리하는 함수가 서로 떨어져 있어 관계를 파악하기 어렵다.
- 하나의 데이터가 여러 위치에서 직접 변경될 수 있다.
- 기능을 수정할 때 영향을 받는 코드의 범위를 예측하기 어렵다.
- 여러 개발자가 프로그램의 각 부분을 나누어 작업하기 어렵다.
이러한 문제는 단순히 함수의 개수를 줄인다고 해결되지 않는다. 프로그램을 어떤 단위로 나누고, 각 단위가 어떤 책임을 가져야 하는지에 대한 새로운 관점이 필요해졌다.
객체를 중심으로 프로그램을 바라보기
객체지향 프로그래밍은 프로그램을 단순히 실행되어야 할 작업의 순서로만 바라보지 않는다. 대신 프로그램을 서로 상호작용하는 객체들의 집합으로 구성한다.
객체는 자신이 관리하는 상태와 그 상태를 처리하는 동작을 함께 가진다. 예를 들어 게임의 플레이어를 하나의 객체로 표현한다면, 플레이어의 위치와 체력은 상태가 되고 이동이나 피해 처리는 동작이 된다.
이때 다른 객체가 플레이어의 상태를 직접 변경하도록 두기보다, 플레이어 객체가 제공하는 동작을 통해 상태가 변경되도록 설계할 수 있다. 외부 객체는 플레이어의 상태가 내부적으로 어떻게 관리되는지 알 필요 없이, 필요한 기능을 요청하면 된다.
나중에 이런 개념이 발전하면 인터페이스, 델리게이트와 같은 기능이 된다.
이처럼 객체지향 프로그래밍은 관련된 상태와 동작을 하나의 단위로 묶고, 객체가 자신의 상태와 책임을 관리하도록 한다. 전체 프로그램은 각자의 역할을 가진 객체들이 서로 상호작용하는 구조로 표현된다.
다만 객체지향 프로그래밍이 프로그램을 설계하는 유일한 방법은 아니다. 객체를 중심으로 프로그램을 구성하는 방식 외에도, 데이터의 배치와 처리 흐름을 중심으로 설계하는 Data-Oriented Design이나 함수를 조합해 프로그램을 구성하는 Functional Programming과 같은 관점도 존재한다.
객체지향 프로그래밍의 핵심
객체지향 프로그래밍의 핵심은 모든 것을 클래스로 만드는 데 있지 않다. 중요한 것은 프로그램을 역할과 책임을 가진 단위로 나누고, 각 객체가 자신이 맡은 상태와 동작을 관리하도록 설계하는 것이다.
설계에서 반복적으로 발생하는 문제와 해결 방법을 정리한 Design Pattern이란 개념도 존재한다.
객체는 다른 객체의 내부 구현을 직접 다루기보다, 외부에 공개된 인터페이스를 통해 필요한 동작을 요청한다. 객체 사이의 상호작용은 이러한 요청과 응답을 통해 이루어진다.
이를 통해 다음과 같은 설계가 가능해진다.
- 객체가 자신의 상태를 직접 관리한다.
- 객체가 담당해야 할 책임을 명확하게 나눌 수 있다.
- 객체의 내부 구현을 변경해도 외부 코드에 미치는 영향을 줄일 수 있다.
- 같은 인터페이스를 사용하는 객체를 서로 다른 방식으로 구현할 수 있다.
- 여러 객체의 협력을 통해 복잡한 프로그램을 구성할 수 있다.
이러한 설계 원리를 설명하기 위해 객체지향 프로그래밍에서는 캡슐화, 추상화, 상속, 다형성을 대표적인 핵심 개념으로 자리잡게 된다.
객체지향 프로그래밍의 4가지 특성
객체지향 프로그래밍을 설명할 때는 일반적으로 캡슐화, 추상화, 상속, 다형성을 네 가지 핵심 특성으로 다룬다.
이 네 가지 특성은 서로 완전히 독립된 개념이 아니다. 객체의 상태와 동작을 하나로 묶고, 필요한 부분만 외부에 공개하며, 객체 사이의 관계와 동작을 효율적으로 표현하기 위해 서로 연결되어 사용된다.
캡슐화
캡슐화는 객체의 상태와 그 상태를 처리하는 동작을 하나의 단위로 묶는 것이다.
객체의 내부 상태를 외부에 직접 노출하지 않고, 객체가 제공하는 기능을 통해서만 상태를 변경하도록 설계하면 객체 스스로 자신의 상태를 관리할 수 있다.
예를 들어 플레이어의 체력을 외부에서 직접 수정하도록 두기보다, TakeDamage()와 같은 동작을 통해서만 변경하도록 만들 수 있다. 이렇게 하면 체력이 음수가 되지 않도록 하거나, 피해를 받았을 때 추가적인 처리를 수행하는 등의 규칙을 객체 내부에서 관리할 수 있다.
캡슐화는 단순히 멤버 변수를private으로 선언하는 것이 아니라, 데이터의 변경 권한과 책임을 객체 내부에 두는 설계 방식이다.
캡슐화를 통해 객체의 내부 구현을 외부로부터 감추고, 객체가 제공하는 인터페이스만을 통해 상호작용하도록 만들 수 있다.
추상화
추상화는 복잡한 내부 구현을 모두 드러내지 않고, 사용하는 데 필요한 핵심적인 기능만 외부에 제공하는 것이다.
자동차를 운전할 때 운전자는 엔진 내부에서 연료가 연소되는 과정이나 변속기가 동작하는 원리를 알 필요가 없다. 운전자는 핸들, 가속 페달, 브레이크와 같은 필요한 인터페이스를 사용해 자동차를 조작한다.
소프트웨어에서도 마찬가지로 객체의 내부 동작을 모두 알지 않아도, 객체가 제공하는 기능과 사용 방법만 알면 해당 객체를 사용할 수 있도록 설계할 수 있다.
추상화는 복잡한 것을 없애는 것이 아니라, 사용자에게 필요한 복잡도만 남기는 것이다.
추상화는 클래스나 함수, 인터페이스와 같은 다양한 형태로 구현할 수 있다.
다만 추상화와 추상 클래스는 같은 개념이 아니다. 추상 클래스는 추상화를 구현하는 여러 방법 중 하나이다.
상속
상속은 기존 클래스의 특성과 동작을 새로운 클래스가 물려받아 확장할 수 있도록 하는 기능이다.
기반 클래스는 여러 객체가 공통적으로 가져야 하는 상태와 동작을 정의하고, 파생 클래스는 이를 물려받아 자신에게 필요한 기능을 추가하거나 기존 동작을 재정의할 수 있다.
예를 들어 Character라는 기반 클래스가 이동과 공격 기능을 제공한다면, Player와 Enemy는 이를 상속받아 각자의 방식으로 기능을 확장할 수 있다.
상속은 공통 기능을 재사용할 수 있게 해주지만, 두 클래스 사이의 결합도가 높아진다는 단점도 가진다. 따라서 단순히 코드를 재사용하기 위한 목적만으로 상속을 사용하기보다, 실제로 두 타입 사이에 명확한 관계가 있는지 고려해야 한다.
상속은 코드를 재사용하기 위한 기능이면서, 동시에 클래스 사이의 관계를 표현하는 기능이다.
일반적으로 상속 관계는 “is-a” 관계로 설명한다.Player는Character이고,Enemy도Character라는 관계가 이에 해당한다.
다형성
다형성은 같은 인터페이스를 사용하더라도 실제 객체의 타입에 따라 서로 다른 동작을 수행할 수 있는 성질이다.
예를 들어 Character 타입으로 플레이어와 적을 다룬다고 하더라도, Attack()을 호출했을 때 플레이어와 적이 각자의 방식으로 공격하도록 구현할 수 있다.
이때 사용하는 쪽에서는 구체적인 객체의 종류를 일일이 확인하지 않아도 된다. 공통된 인터페이스를 통해 기능을 요청하면 실제 객체에 맞는 동작이 수행된다.
다형성의 핵심은 객체의 구체적인 타입보다 객체가 제공하는 인터페이스를 기준으로 코드를 작성하는 것이다.
C++에서는 함수 오버로딩이나 템플릿을 이용한 컴파일 타임 다형성과, 가상 함수를 이용한 런타임 다형성을 구현할 수 있다.
특히 런타임 다형성은 기반 클래스의 포인터나 참조를 통해 파생 클래스의 함수를 호출할 수 있도록 하며, 이후 가상 함수와 추상 클래스에 대한 질문으로 이어진다.
더 나아가기
여기서부터는 앞에서 배운 개념을 다른 관점으로 확장해본다. 아래 질문에는 하나로 정해진 정답이나 명확한 오답이 없다. 각자가 어떤 관점에서 바라보고, 어떤 근거로 판단하는지에 따라 서로 다른 답이 나올 수 있다.
중요한 것은 정답을 맞히는 것이 아니라, 지금까지 살펴본 개념을 바탕으로 자신의 생각을 논리적으로 정리하고 확장해보는 것이다.
Rust는 C++의 어떤 단점을 보완하기 위해 등장했을까?
나만의 대답
C++은 높은 성능과 메모리에 대한 직접적인 제어를 제공하지만, 그만큼 메모리와 자원의 생명주기를 개발자가 직접 관리해야 한다.
이 과정에서 해제된 메모리를 다시 사용하는 문제, 메모리를 중복해서 해제하는 문제, 유효하지 않은 포인터를 사용하는 문제, 여러 스레드가 같은 데이터에 동시에 접근하면서 발생하는 데이터 경쟁과 같은 오류가 발생할 수 있다.
스마트 포인터나 코딩 규칙을 통해 이러한 문제를 줄일 수는 있지만, C++에서는 여전히 개발자가 소유권과 접근을 직접 관리해야 한다.
일부 개발자는 이러한 메모리 관리 책임을 개발자에게 맡기는 방식을 C++의 큰 부담으로 지적하기도 한다.
Rust는 소유권과 빌림 검사 규칙을 통해 이러한 문제를 컴파일 단계에서 검사한다. 따라서 C++의 성능과 시스템 수준의 제어 능력을 유지하면서도, 메모리 안전성과 동시성 안전성을 언어 차원에서 강화하려 했다고 생각한다.
본인이 생각하기에 객체지향 프로그래밍에서 가장 중요한 개념은 무엇인가?
나만의 대답
내가 생각하는 객체지향 프로그래밍에서 가장 중요한 개념은 다형성이다.
다형성을 사용하면 구체적인 객체의 타입을 직접 확인하지 않고도 공통된 인터페이스를 통해 여러 객체를 동일한 방식으로 다룰 수 있다. 사용하는 쪽은 객체의 구체적인 구현에 의존하지 않고, 객체가 제공하는 기능에만 의존하게 된다.
이를 통해 새로운 객체나 구현을 추가할 때 기존 코드를 크게 수정하지 않아도 되고, 프로그램의 확장성과 유연성을 높일 수 있다. 또한 실행 중인 객체의 구체적인 타입보다 객체가 제공하는 인터페이스를 기준으로 코드를 작성할 수 있기 때문에 객체 사이의 결합도도 낮출 수 있다.
다형성은 하나의 개념으로 끝나지 않고 여러 C++ 개념으로 연결된다. 컴파일 타임에 호출 대상을 결정하는 정적 다형성은 함수 오버로딩이나 템플릿과 연결되고, 실행 중 실제 객체의 타입에 따라 동작이 결정되는 동적 다형성은 함수 오버라이딩과 가상 함수로 구현된다.
여기서 더 나아가면 순수 가상 함수, 추상 클래스, 인터페이스, 가상 함수 테이블과 동적 디스패치와 같은 개념으로 이어진다. 결국 다형성에 대한 하나의 질문이 컴파일 타임과 런타임의 차이, virtual의 동작 원리, 추상 클래스의 역할까지 확장될 수 있다.
게임 엔진은 왜 상속만 사용하지 않고 컴포넌트 구조를 함께 사용할까?
나만의 대답
상속은 두 클래스 사이의 is-a 관계를 표현한다. 예를 들어 플레이어는 캐릭터이고, 캐릭터는 액터라는 관계가 이에 해당한다.
반면 컴포넌트 구조는 객체가 어떤 기능을 가지고 있는지를 표현하는 has-a 관계에 가깝다. 플레이어가 이동 컴포넌트와 체력 컴포넌트, 렌더링 컴포넌트를 가진다고 표현하는 방식이다.
게임 객체는 여러 기능을 조합해 만들어지는 경우가 많기 때문에, 모든 기능을 상속으로만 표현하면 클래스 계층이 복잡해지고 기능 조합에 따른 클래스가 계속 늘어날 수 있다.
컴포넌트 구조를 사용하면 필요한 기능을 객체에 조합할 수 있고, 각각의 기능을 독립적으로 수정하거나 재사용하기도 쉽다.
이러한 컴포넌트 구조를 더 발전시킨 형태로 ECS(Entity Component System)를 생각해볼 수 있다. ECS에서는 엔티티를 여러 컴포넌트의 조합으로 표현하고, 시스템이 특정 컴포넌트를 가진 엔티티들을 찾아 동일한 로직을 처리한다.
상속 중심의 구조가 객체 사이의 관계와 행동을 클래스 계층으로 표현한다면, ECS는 데이터를 기능 단위로 분리하고 같은 데이터를 가진 대상을 묶어 처리하는 데 초점을 둔다. 따라서 많은 엔티티를 반복적으로 처리해야 하는 게임에서는 데이터 배치와 처리 효율을 높이는 방향으로 활용할 수 있다.
따라서 게임 엔진에서는 명확한 타입 관계를 표현할 때는 상속을 사용하고, 여러 기능을 조합해야 할 때는 has-a 관계를 기반으로 한 컴포넌트 구조를 사용한다. 더 많은 객체를 효율적으로 처리해야 하는 경우에는 ECS와 같은 데이터 중심 설계까지 고려할 수 있다.