[C++] OOP는 만능이 아니다. 데이터 중심 프로그래밍

객체 지향 프로그래밍은 만능이 아니다라는 말은 다소 논란의 여지가 있다. 그러나 OOP도 결국 하나의 프로그래밍 패러다임에 지나지 않고, 장점이 있는만큼 단점 역시 있기 마련이다. 그리고 OOP를 개선하기 위해, 혹은 새로운 프로그래밍 패러다임을 만들고자 다양한 패러다임이 등장하였다. C++는 기본적으로는 OOP 중심의 언어이지만, 람다 함수와 function 객체를 이용한 함수형 프로그래밍, 그리고 C언어 스타일의 절차 지향 프로그래밍 등 역시 지원한다. 이렇듯 시대의 변화에 맞추어 OOP 역시 다른 패러다임과 맞물려서 변형되어 사용되고 있고, 이제 단순히 OOP만을 지원하는 언어는 살아남기 힘들어졌다.

OOP는 기본적으로 프로그래밍 역사상 가장 성공한 프로그래밍 패러다임이고, 또한 가장 널리 쓰이는 프로그래밍 패러다임이라는 것은 부정할 수 없다. 하지만 그만큼 단점 역시 가지는데, 그 중 하나는 바로 성능에 관한 문제이다.

이러한 성능에 관한 문제를 '데이터 중심 프로그래밍(Data Oriented Programming, DOP)'을 통해서 해결할 수 있다.


데이터 중심 프로그래밍

데이터 중심 프로그래밍의 의미

C++를 쓰는 가장 큰 이유는 바로 성능과 OOP 두 마리 토끼를 같이 잡기 위해서이다. 때문에 성능이 중요한 곳이라면, 과감하게 OOP를 버릴 수 있어야 한다. 이런 곳에서는 DOP를 사용해보는 것을 고려할 수 있다.

데이터 중심 프로그래밍은 효율적이고 이해하기 쉬운 방식으로 데이터를 구성하고 조작하는 것을 강조하는 소프트웨어 설계 철학이다. 사용되는 데이터에 적합한 데이터 구조와 알고리즘을 사용하여 데이터를 추상화하는 것이 아니라, 데이터를 중심으로 시스템을 만드는 것을 포함하는 것을 의미한다. 데이터를 조작하는 데 필요한 처리 및 계산량을 최소화하여 고성능, 확장성 및 유지보수성을 달성하는 데 중점을 두는 프로그래밍 기법이다.

말로만 들으면 다소 어려운데, 쉽게 말해서 '추상화'가 중요하였던 OOP와 다르게 데이터 중심 프로그래밍에서는 데이터를 어떻게 다룰 지, 이를 어떻게 더 효율적으로 처리하고 계산량을 줄일 수 있을 것인지에 초점을 맞춘다.

데이터 중심 프로그래밍의 장점

DOP은 OOP와 비교해서 다음과 같은 이점이 있다.

  1. 성능: DOP는 OOP와 비교해서 더 높은 성능을 낼 수 있다. 데이터를 다루는데 필요한 프로세스와 컴퓨텅 단계를 줄이고, 더 캐시 프렌들리(Cache-Friendly)한 메모리 구조를 통해 효율을 극대화할 수 있다. DOP는 기본적으로 주어진 데이터에 대해 이를 더 효율적으로 처리하는데 중점을 두고 있지, 이를 어떻게 추상화할 지에 대해서는 관심이 없기 때문이다.
  2. 단순함: DOP는 추상화를 줄임으로써 코드를 단순화시킬 수 있다. 이를 통해서 코드를 더 이해하기 쉽게 만들고 유지 보수하기 좋게 만든다. 또한 추상화 과정에서 발생하는 복잡한 버그를 줄일 수 있다.
  3. 확장성: DOP는 OOP보다 확장성이 더 크다. 더 쉽게 거대한 양의 데이터를 다루고, 이를 병렬화하여 처리하는 것 역시 쉽게 만든다.
  4. 유연함: DOP는 OOP보다 더 유연하다. 복잡한 자료구조를 처리하고, 다양한 하드웨어와 플랫폼의 요구사항에 충족하는 최적화된 코드를 설계하는데 도움을 준다.

데이터 중심 프로그래밍은 언제 사용하는가

다음 상황에서, DOP는 훌륭한 OOP의 대체제가 될 수 있다.

  1. 고성능 시스템: DOP는 특히 성능이 매우 중요한 분야에서 사용될 수 있다. 게임, 시뮬레이션, 수치 해석 모델 등에서 DOP가 유용하게 사용될 수 있다.
  2. 거대한 데이터 셋을 다룰 때: 많은 양의 데이터를 다뤄야 할 경우 DOP를 사용하는 것을 고려하라.
  3. 실시간 시스템: 레이턴시를 극단적으로 줄여야 하는 실시간 시스템에서 DOP를 사용할 수 있다. 실시간 스트리밍이나, 밀리초, 마이크로초 수준의 빠른 연산을 요구하는 주식 거래 시스템이 그 예시이다.
  4. 임베디드 시스템: 특히 메모리와 컴퓨팅 파워가 제한되어 있는 임베디드 시스템에서는 OOP보다 DOP를 사용하는 것도 고려해야 한다.
  5. 데이터 중심의 시스템: 만약 구현하고자 하는 시스템이 주로 데이터를 중심으로 하는 시스템이라면 DOP를 고려해보자. 특히 데이터베이스 시스템, 혹은 데이터 분석 툴, 혹은 컴퓨터 비전 분야에서와 같이 거대한 데이터를 훑고 지나가는 경우 DOP의 사용을 꼭 고려해야 한다.

물론 위 상황에서도 그 규모가 작거나, 혹은 성능보다 빠른 개발을 위한 가독서과 유지 보수성이 더 중요한 분야라면, 혹은 유저 인터페이스나 비즈니스 로직과 같이 객체 사이의 관계들이 더 중요한 경우라면 DOP보다는 OOP를 사용하는 게 더 우선시돼야 한다. 그러나 OOP를 사용하는 것을 우선시했음에도 성능 상의 중요한 이점을 얻어야 하는 경우라면, OOP와 DOP를 혼용해서 사용하거나 아예 DOP를 사용하는 것도 고려하자.

데이터 중심 프로그래밍의 예시

우리가 만약 주식 거래 시스템을 만든다고 해보자. 주식 거래 시장은 순간적인 판단과 순발력이 매우 중요한 곳이고, 그렇기에 밀리초, 마이크로초 수준의 성능 최적화가 필요한 시스템이다. 그런데 이런 시스템에서 OOP를 사용해서 다음과 같이 프로그램을 짰다고 가정해보자.

#include <vector>
#include <algorithm>

class Stock
{
public:
    Stock(int id, double price)
        : id(id), price(price)
    {}

    auto get_id() const { return id; }
    auto get_price() const { return price; }

private:
    int id;
    double price;
};

class StockManager
{
public:
    StockManager() = default;

    void add_stock(const Stock& stock) { stocks.emplace_back(stock); }

    Stock get_stock_with_highest_price() const
    {
        return *std::max_element(stocks.begin(), stocks.end(),
                                 [](const Stock& a, const Stock& b) {
                                     return a.get_price() < b.get_price();
                                 });
    }

private:
    std::vector<Stock> stocks;
};

int main()
{
    StockManager manager;
    // populate manager with stock data

    Stock highest_price_stock = manager.get_stock_with_highest_price();

    // output the stock with the highest price
    std::cout << "Stock ID: " << highest_price_stock.get_id()
              << " Price: " << highest_price_stock.get_price() << std::endl;

    return 0;
}

위 코드는 Stock 이란 클래스가 있고, StockManager란 클래스가 이 Stock이란 클래스를 관리한다. StockManager 클래스에는 가장 높은 가격의 stock을 찾는 get_stock_with_highest_price() 메소드가 있다. 이 메소드는 stocks란 벡터에서 Stock들을 선형 탐색해서 가격이 가장 높은 stock을 반환할 것이다. OOP 관점에서 보면 유지보수하기 쉽고 읽기도 좋은 코드여서, 문제가 딱히 보이지 않는 코드이지만, DOP 관점에서 보면 위 코드는 다음과 같은 문제점이 있다.

  1. 캐시 메모리적 관점에서 문제점: 데이터를 접근하는데 있어서, 캐시 메모리적 관점에서 보면 위 코드는 심각하게 비효율적이다. 위 코드 상에서 stocks 벡터를 그림 상에서 나타내보면 다음과 같이 될 것이다.stocks 벡터의 메모리 구조를 그림 상으로 나타내면 위와 같이 나타낼 수 있다.

    실제 모델은 다를 수 있겠지만, 일단int iddouble price만 표현하였다.
    저기서 패딩 영역이 무엇이냐 물을 수도 있는데, 컴파일러는 최적화 과정 중에서 메모리 블록을 최대 크기의 타입(여기서는 8byte의 double형)의 배수 크기로 공간을 할당한다. 그래서 int+double형은 12byte가 아니라 중간에 패딩 영역이 들어가서 16byte로 표현된다. 이에 대한 설명은 추후 다른 글에서 다루겠다. 그리고 그림은 링크드 리스트처럼 화살표로 연결되어 있지만 실제로는 연속된 메모리 블록이다.
    우리가 원하는 값은 오직 stock들의 price값이다. 이 값을 읽기 위해서, CPU는 16byte의 메모리 블럭을 읽어야 한다. 그리고 다음 stock의 price를 읽으려면, 역시나 중간에 있는 int id 값을 건너 뛰고 다음 price를 읽어야 한다. 그림 상에서는 3개의 블럭밖에 표현되지 않았지만, 만약 우리가 찾아야 하는 price값이 10만 개 정도 있다고 하면 어떨까? 이때는 CPU 캐시 메모리의 한계로, 레지스터는 끊임없이 다음 메모리 청크를 요청해야 한다. 계속해서 캐시 메모리 상에서 효율 저하가 일어나고 있다.
  2. 간접 참조: 포인터와 참조(reference)를 이용해서 메모리에 접근하는 것은 간접 참조를 늘리고, 메모리에 직접 접근하는 것보다 성능을 더 떨어뜨린다.
  3. 추상화 오버헤드: 데이터를 클래스와 멤버 메소드로 추상하는 것 자체가 오버헤드를 발생시키고, 더 많은 처리와 연산을 요구한다. 1번 그림 역시도 메모리 구조상에서 오버헤드가 발생한 예이다.

이 경우 DOP적 관점에서 생각하면 다음과 같이 쓸 수도 있다.

#include <vector>
#include <algorithm>
#include <iostream>
#include <numeric>

int main()
{
    std::vector<int> stock_ids;
    std::vector<double> prices;
    // populate stock_ids and prices vectors with stock data

    auto max_iter = std::max_element(prices.begin(), prices.end());
    int max_id = stock_ids[std::distance(prices.begin(), max_iter)];
    double max_price = *max_iter;

    // output the stock with the highest price
    std::cout << "Stock ID: " << max_id << " Price: " << max_price << std::endl;

    return 0;
}

이렇게 되면, std::vector<double> prices에는 오직 prices 정보들이 메모리 안에 꽉 채워서 들어가기 때문에, 캐시 메모리 관점에서 친화적이고 성능을 더 높일 수 있다.

데이터 중심 프로그래밍의 단점

DOP는 만능인가? 당연히 아니다. 애초에 데이터 중심 프로그래밍이 OOP보다 우월했다면 이미 IT 산업은 OOP를 버리고 DOP를 선택했을 것이다. 그러나 DOP가 선택되지 않는 이유에는 다음과 같은 이유들이 있다.

  1. 캡슐화의 부재: OOP의 가장 강력한 장점 중 하나는 캡슐화이다. 이 캡슐화가 DOP에서는 쉽지 않기 때문에, 데이터의 변경과 같은 상황에서 이를 빠르게 대응하기 어렵다.
  2. 복잡한 관계를 다루기 힘들다: 상속, 다형성(polymorphism) 등과 같은 복잡한 관계 구조를 다루기 힘들다.
  3. 복잡성: 특히 데이터 간의 복잡한 관계를 다룰 때, DOP는 OOP보다 오히려 더 복잡하고 읽기 어려운 코드를 만들어 낼 수 있다. 바로 위 코드에서도, max_id를 찾기 위해서 우리는 std::distance함수를 사용하였다.
  4. 디버깅의 어려움: 3번의 이유와 연관되어서, 특정한 상황에서 억지로 DOP를 적용했다간 디버깅 과정에서 어려움을 겪을 수 있다.
  5. 추상화의 부재: OOP의 또다른 가장 강력한 장점 중 하나인 추상화를 DOP에서는 적용하기 어렵다. 그렇기 때문에 재사용 가능한 코드, 혹은 코드의 모듈화를 어렵게 만든다.

결론

따라서 우리는 무조건 DOP가 좋다, OOP가 좋다라고 생각하고 어느 하나만을 고집하기보다, 특정한 상황에 맞추어 둘 중 하나를 선택하거나 심지어 둘을 혼합하는 것도 고려해야 한다. C++는 성능과 객체 지향 두 마리 토끼를 둘다 잡을 수 있는 매우 강력한 언어이다. 데이터 중심 프로그래밍이 무엇인지 알고, 필요한 경우 OOP를 과감하게 버리고 DOP를 선택하는 용기도 필요하다.