C++로 Headless CMS 개발하기
학교에서 <Computer Programming> 과목을 수강하며 C++을 이용한 자유 과제를 수행하게 되었는데, 과제 스펙이 스펙이랄 것도 없이 정말 자유도 100%의 과제였다. 정말 충격을 금치 않을 수 없었는데 교수님께서는 간단한 계산기를 만들든, 게임 엔진(?)을 만들든 상관이 없으니 자유롭게 만들어오라고 하셨다. 코딩 괴수와 같은 우리 과 학우들이 도대체 무슨 짓을 해올지 모르겠다는 생각에 가슴을 졸이고 있었는데, 설상가상으로 수업 시간에 가르쳐주시는 .NET 프레임워크 API를 이용해 Desktop application을 개발하는 프로젝트를 할 것을 추천해 주셨지만 당장 이용할 수 있는 Windows 환경이 없어 .NET 개발 환경을 구축할 수가 없었다.
그래도 해본 것을 하는게 마음 편하겠다는 생각이 들어 웹 애플리케이션 쪽을 알아보던 중 다행히 C++ 진영에도 Spring이나 Django 같은 framework이 있다는 사실을 알게 되어 대충 웹 서버 비슷한 것을 만들기로 결정했다. 그냥 웹서버를 만드는 것은 재미가 없으니 Strapi같은 최근에 흥미롭게 본 Headless CMS를 개발해보려 한다.
그리고 명색이 C++ 프로젝트인데 network feature만 보는 것도 조금 모양이 빠지는 것 같아서 CPU-bound task와 같이 C++로 할 법한 기능을 (억지로 끼워 넣어서) 구현해볼 것이다.
Github: https://github.com/AtlasYang/sonicms
Headless CMS란?
CMS는 Content Management System의 약자로, Server-side에 각종 정보, 미디어를 저장하고 이를 Front-end 까지 제공하는 총체적인 시스템을 의미한다. 유저는 이 시스템을 이용하여 편리하게 커뮤니티, 플랫폼, 전자상거래 웹 앱을 구축할 수 있으며, 개발 유지 보수에 있어 굉장한 이점을 가져간다.
Headless CMS란 CMS에서 Front-end, 즉 웹 UI로 대표되는 Client-side가 사라진 형태를 말한다. Client application 개발자는 Headless CMS를 이용하여 Server-side에 대한 개발 소요 없이 UI를 개발하고, 데이터 저장, 전송, 추가 등의 관리를 Headless CMS에 맡김으로써 client 개발에 집중할 수 있다.
매우 당연하게도 CMS는 물론 Headless CMS 프레임워크는 웹 개발의 두 강자인 Node.js(Javascript)와 Ruby 기반의 프레임워크가 시장 대부분을 장악하고 있다. 이는 최근의 개발 양상이 desktop에서 web으로, local machine 에서 cloud로 전환되고 있기 때문이라고 생각하는데, Client web application 개발의 업계 표준에 가까워지고 있다시피 한 React의 등장으로JS의 영향력은 더 커지게 되었으며, 이에 편승하여 Node.js 기반의 웹 서버 프레임워크인 Express.js, Nest.js 등이 점점 점유율을 높여가고 있는 상황이다.
JS의 장점이라 함은 Front, Back-end에 동일한 언어를 사용할 수 있다는 점, 그리고 낮은 언어 장벽으로 인한 많은 유입, 그리고 그에 따른 압도적인 크기의 web application 분야 커뮤니티, 라이브러리라고 할 수 있다. 단점을 따지자면 스크립트 언어이자 동적 타입 언어이기 때문에 Runtime performance가 타 언어에 비해 느릴 수 있다는 것인데, 최근 하드웨어 성능이 빠르게 증가하여 이를 느끼기 어려워졌으며, 아주 특수한 task(잦은 file system access, 이미지/영상processing 등)가 아닌 이상 대부분의 웹 서버의 bottleneck은 Database I/O, 네트워크 대역폭 등에 있기 대문에 IO-bound task일 가능성이 농후하고, 이에 따라 Node.js가 그리 단점이 부각되지 않는 것이다.
그럼에도 불구하고, 나는 이번 프로젝트로 C++를 이용한 Headless CMS 프레임워크를 구축해보고자 한다. 이유는 다음과 같다.
1. C++의 Runtime performance는 현존 언어 중 가장 빠르다고 알려져 있다. 어떤 언어를 이용하여도 C, C++보다는 빨라질 수 없기 때문에 이를 Backend에 꼭 이용해보고 싶었다.
2. Headless CMS라는 특성상 개발자가 코드를 직접 수정할 일이 거의 없으며, C++ 의 코드 작성 난이도가 상대적으로 높다는 단점이 어느 정도 희석된다.
3. CPU-bound task에 특장점이 있기 때문에, 영상, 이미지 등 미디어 데이터를 다루거나, 기타 CPU를 많이 이용하게 되는task에서 두각을 드러낼 가능성이 있으며, CMS 분야에서도 응용할 분야가 있다고 예상된다.
초기 개발 컨셉은 C++을 이용한 매우 빠른 속도를 가진 Headless CMS이다. 대규모 요청을 처리하는데에 Node.js가 부족할 수 있는 부분을 C++을 이용하여 보완해보고, 실제 runtime performance를 비교해보고자 한다. 또한 개발 역량에 따라 C++의 장점을 살릴 수 있는 추가적인 기능을 도입하고자 한다.
이유가 장황해 보이긴 하지만 사실 학교 과제가 없었다면 절대로 C++로 웹 서버를 만드는 멍청한 행동은 하지 않았을 것이다.
프로젝트 구성
C++을 이용한 제대로 된 프로젝트는 처음이다 보니 전체적인 틀을 잡는 것 부터 고역이었다. 지금까지 Python의 PIP, Java의 maven, Rust의 cargo, JS의 npm 등 의존성 관리, 빌드 툴 등을 한번에 제공하는 언어들을 주로 이용하다 보니 이러한 툴이 완벽하게 정형화 되어있지는 않은 C++ 진영에서는 vcpkg, conan 등 다양한 툴들이 존재했고, 그에 따라 프로젝트를 관리하는 방식도 상이했다.
CMake와 ninja를 이용하여 개발을 진행하였고, 필요한 라이브러리는 직접 코드를 가져와 빌드를 하는 방식을 이용했다. 또한 Docker와 Docker compose를 이용한 개발 및 배포 환경을 제공한다.
소개
이름은 유튜브를 보다가 <소닉>이라는 캐릭터가 썸네일에 보여서 그냥 빠르다는 의미를 전달하는 느낌으로 sonic에 CMS를 합쳐서 SoniCMS로 하기로 하였다. (PostgreSQL 처럼) 나름 로고도 만들었는데 그냥 개발하기는 싫고 과제는 해야겠다는 생각에 괜히 피그마를 만지작대다보니 이상한 짓만 하는 것 같다. Supersonic(초음속) 이런 단어를 떠올리니 총알이 연상되어서 총알이 공기를 뚫고 나아가는 모습을 형상화하였다.

SoniCMS는 Docker 및 Docker compose 환경에서 동작하는 것을 전제로 개발되어 Cloud 환경에서의 배포에 적합하며, C++ 기반의 고성능 데이터베이스인 ScyllaDB를 이용하여 병목을 최소화하고자 하였다. ScyllaDB는 Apache Cassandra 기반의 NoSQL DBMS로, 빠른 쿼리 속도와 강력한 확장성을 가지고 있다. 대용량 미디어 저장을 위해S3 Bucket Storage와 호환되는 Minio Object Store를 이용하였으며, 약간의 설정으로 Amazon S3와의 연동이 가능하도록 하였다. 종합적으로, SoniCMS는 매우 Cloud-friendly한 CMS라고 볼 수 있다. SoniCMS의 핵심은 단연 C++ 기반의 Core CMS API 부분이라 할 수 있다. Oat++를 이용한 REST API로부터 시작하여 DB 연결에 Scylla C++ SDK, storage 연결에 Minio C++ SDK를 외부 라이브러리로 연동하며, 이를 이용한 최적화된 쿼리, 파일 처리 코드 작성으로 언어적으로 발생할 수 있는 병목을 최소화하고자 한다.
…
라고 보고서에 작성해서 낼 것이다. 클라우드 환경에 적합한지, 실제로 JS 기반 웹 서버보다 performance가 나은 지는 알고 싶지 않다.(아마 느리지는 않을 것이다) 쓰고 보니 웃기기는 한데, 누구는 터미널에서 가위바위보 게임 만들어서 낼 텐데 그냥 마음 편하게 살기로 했다.
그래도 C++에서 하면 좋을, C++이라서 가능한 feature를 그래도 도입해보고자 하여 이번 프로젝트에서는 ONNX Runtime이라는 onnx 기반 ai model을 빠르게 실행할 수 있는 런타임을 통해 텍스트 유사도 기반 서치 기능을 내장하는 방식을 도입하고자 한다. 다른 CMS라면 ai 관련 feature를 위해 플러그인을 도입한다든지의 노력이 들어가겠지만, 기본 기능으로 내장시키면 그런 수고가 덜어지기 때문에 가치가 있다고 생각한다.
Architecture

External Client는 Headless CMS의 REST API를 이용할 외부 Front end를 의미한다.
Web Application
React를 이용하여 최소한의 코드로 구현한다(길어지면 실제 과제인 C++ 코드가 상대적으로 적어보일 것이기 때문이다)
Main Server
Oat++를 이용한 HTTP 서버이다. CMS의 핵심적인 api를 REST API로 노출하며, ONNX Model runtime을 내장하고 있다.
Main Database
Highly-scalable NoSQL인 ScyllaDB를 이용한다. Apache Cassandra와 호환되는 high-performance dbms라고 홍보하는데, 실제로 빠르기도 했고 Discord에서도 사용한다는 글을 보고 이용하기로 하였다. 또한 C++로 구현된 DB이기도 하다.(Apache Cassandra는 Java 기반)
Vector Database
Rust로 구현되어있는 VectorDB인 Qdrant를 골랐다. 여타 VectorDB에 비해 상대적으로 lightweight이고, REST API로 깔끔하게 제어가 가능해 이번 프로젝트에 적합하다고 판단하였다.
Main Storage
CMS는 미디어, 대용량 파일을 관리하는데에 자주 이용되기 때문에 Object store를 도입하였다. Local drive에 저장하는 방식에 비해 Cloud 호환성 및 접근성도 높일 수 있다.
Develpment
1. overview
src/main.cpp
void run() {
DatabaseComponent databaseComponent;
StorageComponent storageComponent;
VectorDBComponent vectorDBComponent;
LLMEngineComponent llmEngineComponent;
auto router = oatpp::web::server::HttpRouter::createShared();
auto objectMapper = oatpp::parser::json::mapping::ObjectMapper::createShared();
auto collectionController = make_shared<CollectionController>(objectMapper);
auto entryController = make_shared<EntryController>(objectMapper);
router->addController(collectionController);
router->addController(entryController);
auto connectionHandler = oatpp::web::server::HttpConnectionHandler::createShared(router);
uint16_t port = std::getenv("CMS_API_PORT") ? std::stoi(std::getenv("CMS_API_PORT")) : 3003;
auto connectionProvider = oatpp::network::tcp::server::ConnectionProvider::createShared({"0.0.0.0", port, oatpp::network::Address::IP_4});
oatpp::network::Server server(connectionProvider, connectionHandler);
std::cout << "Server running on port " << port << std::endl;
server.run();
}
int main() {
oatpp::base::Environment::init();
run();
oatpp::base::Environment::destroy();
return 0;
}
Oatpp에서 제공하는 “Component” 를 이용하여 서버 전반의 global state를 정의하고 이용한다. 자원을 초기에 할당하고 계속 이용할 DB Client, Model runtime 등이 이에 해당한다. TCP binding, Router 등 다른 웹 서버 framwork과 유사하지만 하나 특이한 점은 C++은 JS, Go 처럼 내장된 json parser가 없기 때문에 json을 자주 이용하는 웹 서버 특성상 oatpp에서 제공하는 ObjectMapper instance를 이용해야 한다. 굳이 여러 번 선언할 필요가 없기 때문에 controller에 inject하여 이용한다.
2. Component
src/database/scylla_db_session.hpp
#ifndef scylla_db_session_hpp
#define scylla_db_session_hpp
#include <cassandra.h>
namespace Database {
namespace ScyllaDBSession {
/**
* Create a Cassandra cluster object
*
* @param mode 0: Production(Default), 1: Development
* @return CassCluster*
*/
CassCluster* create_cluster(int mode = 0);
/**
* Create a Cassandra session object
*
* @param cluster CassCluster*
* @param mode 0: Production(Default), 1: Development
* @return CassSession*
*/
CassSession* create_db_session(CassCluster* cluster, int mode = 0);
/**
* Close the Cassandra session and cluster
* @param session CassSession*
* @param cluster CassCluster*
*/
void close_db_session(CassSession* session, CassCluster* cluster);
}
}
#endif // scylla_db_session_hpp
src/database/database_component.hpp
#ifndef database_component_hpp
#define database_component_hpp
#include <cassandra.h>
#include "oatpp/core/macro/component.hpp"
#include "database/scylla_db_session.hpp"
class DatabaseComponent {
public:
OATPP_CREATE_COMPONENT(std::shared_ptr<CassSession>, scylla_session)([] {
CassCluster* cluster = Database::ScyllaDBSession::create_cluster();
CassSession* session = Database::ScyllaDBSession::create_db_session(cluster);
return std::shared_ptr<CassSession>(session, cass_session_free);
}());
};
#endif // database_component_hpp
이런 식으로 제공되는 Component 매크로를 이용하여 DB client를 선언하고 이용한다.
3. JSON Validation
JSON parsing 라이브러리는 범용적인 것이 있었고, Valid한 json인지 판정하는 함수는 있었지만 특정 스키마에 맞는 JSON인지 validation하는 함수는 딱히 찾지 못해 간단하게 만들어 사용하였다.
src/utils/strings.cpp
#include <vector>
#include <string>
#include <sstream>
#include <nlohmann/json.hpp>
using namespace std;
using json = nlohmann::json;
namespace Utils { namespace Strings {
vector<string> split(string input, char delimiter) {
vector<string> answer;
stringstream ss(input);
string temp;
while (getline(ss, temp, delimiter)) {
answer.push_back(temp);
}
return answer;
}
bool validate_json(const json& schema, const json& data);
bool validate_array(const json& schema, const json& data_array) {
if (!schema.contains("items")) {
return false;
}
for (const auto& item : data_array) {
if (!validate_json(schema["items"], item)) {
return false;
}
}
return true;
}
bool validate_json(const json& schema, const json& data) {
if (!schema.contains("type")) {
return false;
}
if (schema["type"] == "object") {
if (!schema.contains("properties") || !schema["properties"].is_object()) {
return false;
}
for (const auto& item : schema["properties"].items()) {
const std::string& key = item.key();
const json& property_schema = item.value();
if (!data.contains(key)) {
return false;
}
const std::string& type = property_schema["type"];
if (type == "string" && !data[key].is_string()) {
return false;
} else if (type == "integer" && !data[key].is_number_integer()) {
return false;
} else if (type == "number" && !data[key].is_number()) {
return false;
} else if (type == "boolean" && !data[key].is_boolean()) {
return false;
} else if (type == "object") {
if (!data[key].is_object()) {
return false;
}
if (!validate_json(property_schema, data[key])) {
return false;
}
} else if (type == "array") {
if (!data[key].is_array()) {
return false;
}
if (!validate_array(property_schema, data[key])) {
return false;
}
}
}
} else if (schema["type"] == "array") {
if (!data.is_array()) {
return false;
}
return validate_array(schema, data);
}
return true;
}
bool validate_json_from_str(std::string schema_str, std::string data_str) {
json schema = json::parse(schema_str);
json data = json::parse(data_str);
return validate_json(schema, data);
}}}
4. ONNX Runtime
src/llm_engine/onnx.cpp
#include <iostream>
#include <vector>
#include <string>
#include <sstream>
#include "onnxruntime_cxx_api.h"
#include "tokenizer.hpp"
#include "onnx.hpp"
namespace LLMEngine { namespace OnnxRuntime {
OnnxModel::OnnxModel() {
Ort::Env env(ORT_LOGGING_LEVEL_WARNING, "test");
Ort::SessionOptions session_options;
const char* model_path = std::getenv("ONNX_MODEL_PATH") ? std::getenv("ONNX_MODEL_PATH") : "../assets/model.onnx";
onnx_session = std::make_shared<Ort::Session>(env, model_path, session_options);
std::cout << "Onnx Model loaded" << std::endl;
}
OnnxModel::~OnnxModel() {
onnx_session.reset();
}
std::vector<float> OnnxModel::get_sentence_embedding(const char* sentence) {
const char* json_path = std::getenv("TOKENIZER_PATH") ? std::getenv("TOKENIZER_PATH") : "../assets/tokenizer.json";
LLMEngine::Tokenizer tokenizer(json_path);
char* encoded = tokenizer.encode_sentence(sentence);
std::vector<int64_t> input_ids = LLMEngine::OnnxRuntime::Utils::parse_encoded_to_vector(encoded);
std::vector<int64_t> attention_mask(input_ids.size(), 1);
Ort::MemoryInfo memory_info = Ort::MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeDefault);
int max_token_length = 128;
std::vector<int64_t> input_shape = {1, max_token_length};
std::vector<int64_t> attention_shape = {1, max_token_length};
Ort::Value input_ids_tensor = Ort::Value::CreateTensor<int64_t>(memory_info, input_ids.data(), input_ids.size(), input_shape.data(), input_shape.size());
Ort::Value attention_mask_tensor = Ort::Value::CreateTensor<int64_t>(memory_info, attention_mask.data(), attention_mask.size(), attention_shape.data(), attention_shape.size());
const char* input_names[] = {"input_ids", "attention_mask"};
const char* output_names[] = {"sentence_embedding", "token_embeddings"};
auto output_tensors = onnx_session->Run(Ort::RunOptions(), input_names, &input_ids_tensor, 2, output_names, 1);
float* output = output_tensors.front().GetTensorMutableData<float>();
size_t output_size = output_tensors.front().GetTensorTypeAndShapeInfo().GetElementCount();
std::vector<float> output_vector(output, output + output_size);
tokenizer.free_string(encoded);
return output_vector;
}
}}
ONNX Runtime을 구현하며 순간 아찔했던 점은 지금까지 ai 관련 개발은 대부분 Python으로 했기 때문에 HuggingFace Tokenizer와 같은 것을 C++에서는 사용하지 못할 수도 있다는 것을 생각하지 못했던 것이다. 정말 다행히도 HF Tokenizer 라이브러리는 Rust로 개발되어 있었고, Rust는 FFI(Foreign Function Interface)를 잘 지원하는 언어이기 때문에 간단히해당 오픈소스 코드에 몇 줄을 추가하여 C++ binding을 만들어 이용하였다
(https://github.com/huggingface/tokenizers) tokenizers/src/lib.rs에 다음 코드 추가
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
use tokenizer::{EncodeInput, Tokenizer};
#[no_mangle]
pub extern "C" fn create_tokenizer(json_path: *const c_char) -> *mut Tokenizer {
let c_str = unsafe { CStr::from_ptr(json_path) };
let json_path = c_str.to_str().unwrap();
let tokenizer = Tokenizer::from_file(json_path).unwrap();
Box::into_raw(Box::new(tokenizer))
}
#[no_mangle]
pub extern "C" fn encode_sentence(
tokenizer: *mut Tokenizer,
sentence: *const c_char,
) -> *mut c_char {
let tokenizer = unsafe { &mut *tokenizer };
let c_str = unsafe { CStr::from_ptr(sentence) };
let sentence = c_str.to_str().unwrap();
let encoding = tokenizer
.encode(EncodeInput::Single(sentence.into()), true)
.unwrap();
let ids = encoding.get_ids().to_vec();
let encoded_str = format!("{:?}", ids);
CString::new(encoded_str).unwrap().into_raw()
}
#[no_mangle]
pub extern "C" fn free_tokenizer(tokenizer: *mut Tokenizer) {
if !tokenizer.is_null() {
unsafe {
Box::from_raw(tokenizer);
}
}
}
#[no_mangle]
pub extern "C" fn free_string(s: *mut c_char) {
if !s.is_null() {
unsafe {
CString::from_raw(s);
}
}
}
unwrap을 쓴게 불편하긴 하지만 과제에서 크게 신경 쓸 부분은 아닌 것 같아 넘어갔다.
src/llm_engine/tokenizer.cpp
#include <iostream>
#include <cstring>
#include "tokenizer.hpp"
extern "C" {
void* create_tokenizer(const char* json_path);
char* encode_sentence(void* tokenizer, const char* sentence);
void free_tokenizer(void* tokenizer);
void free_string(char* str);
}
namespace LLMEngine {
Tokenizer::Tokenizer(const char* json_path) {
tokenizer = create_tokenizer(json_path);
}
Tokenizer::~Tokenizer() {
free_tokenizer(tokenizer);
}
char* Tokenizer::encode_sentence(const char* sentence) {
return ::encode_sentence(tokenizer, sentence);
}
void Tokenizer::free_string(char* str) {
::free_string(str);
}
}
Demo

유저가 정한 컬렉션 타입 (articles)의 모든 entry를 불러오는api를 호출한 결과를 띄우는 창을 보여주고 있다

문장 임베딩 기반 유사도를 계산하여 가장 유사한 n개의 entry를 반환하는 search api의 response를 보여주는 사진이다.

실제로 제시한AI 관련 컨텐츠와 가장 유사한 내용을 가진 entry 들을 유사도 순으로 제공해주는 것을 확인할 수 있었다.
느낀점
이번 프로젝트를 수행하면서 딱히 C++ 실력이 는 것 같지는 않다. 얻을 수 있었던 것은 C++ 컴파일러와 씨름하다보니 JS가 다시 재밌어지기 시작했다는 것이다. 좋은 성과인 것 같다.
우리나라는 자바공화국이기 때문에 다음 과제인 Java 자유 프로젝트를 열과 성의를 다해 해봐야겠다.

Enjoy Reading This Article?
Here are some more articles you might like to read next: