C++에 V8 임베딩하기: Isolate·Context, HandleScope, C++ 함수·객체를 JavaScript에 노출

왜 C++ 안에서 JavaScript를 돌리나

결제 금액 검증, 할인 규칙, 날짜 포맷 같은 로직이 Node.js 서버, 브라우저, C++ 네이티브 앱에 각각 따로 구현되어 있으면 규칙이 바뀔 때마다 세 곳을 고쳐야 하고, 한 곳이라도 빠지면 결과가 어긋납니다. C++ 앱에 JavaScript 엔진을 넣으면 같은 JS 코드를 세 환경에서 그대로 실행할 수 있습니다. V8은 Chrome과 Node.js가 쓰는 JavaScript 엔진이고, C++ 라이브러리로 임베딩할 수 있게 공개 API를 제공합니다.

flowchart TD
  subgraph wrong[중복 구현]
    W1[같은 규칙] --> W2[Node.js 서버 구현]
    W1 --> W3[브라우저 구현]
    W1 --> W4[C++ 구현]
    W2 -.->|각각 따로 수정| W5[불일치 위험]
  end
  subgraph right[V8 임베딩]
    R1[JS 로직 1곳] --> R2[Node·브라우저]
    R1 --> R3[C++ + V8]
  end

로직 공유 외에도 이유는 여러 가지입니다. 게임에서 UI 동작이나 퀘스트 조건을 스크립트로 빼 두면 C++를 다시 빌드하지 않고 수정할 수 있습니다. “주문 금액 5만 원 이상이면 무료 배송” 같은 비즈니스 규칙을 JS 함수로 정의하면 규칙 파일만 바꿔 다시 불러올 수 있습니다. 에디터나 도구에 플러그인 API를 JavaScript로 노출하면, 사용자가 C++ 빌드 환경 없이 확장 기능을 만들 수 있고 허용한 API만 보여 주는 식으로 범위를 제한할 수 있습니다.

비용도 분명합니다. V8은 빌드가 무겁고(소스가 크고 depot_tools와 GN을 써야 합니다), 바이너리 크기가 수십 MB 단위로 늘어나며, C++ API가 버전마다 바뀝니다. 스크립트 엔진이 필요하지만 웹과 코드를 공유할 필요가 없다면 Lua나 QuickJS처럼 작은 엔진이 더 나은 선택일 수 있습니다.

요구 환경: 최근 V8 헤더는 C++20을 요구합니다. V8은 depot_tools로 직접 빌드하거나 패키지 관리자가 제공하는 빌드를 씁니다.


Isolate·Context·HandleScope

Isolate는 V8 가상 머신 인스턴스 하나입니다. 자체 힙과 가비지 컬렉터를 가지며, 서로 다른 Isolate의 객체는 공유되지 않습니다. 한 Isolate는 여러 스레드에서 사용할 수 있지만 동시에는 한 스레드만 들어갈 수 있으므로, 여러 스레드가 번갈아 쓴다면 v8::Locker로 진입을 직렬화해야 합니다. 진짜 병렬 실행이 필요하면 스레드마다 Isolate를 따로 만듭니다.

Context는 JavaScript 실행 환경입니다. 전역 객체와 Array, Object 같은 빌트인이 Context마다 따로 있으므로, 같은 Isolate 안에서 Context를 여러 개 만들면 스크립트끼리 전역 변수를 공유하지 않게 격리할 수 있습니다.

HandleScope는 Local 핸들의 수명을 관리합니다. C++ 코드는 V8 힙의 객체를 직접 포인터로 들고 있지 않고 핸들을 통해 참조하는데, GC가 객체를 옮길 수 있기 때문입니다. HandleScope가 끝나면 그 안에서 만든 Local 핸들이 모두 해제되고, 다른 곳에서 참조하지 않는 객체는 GC가 회수할 수 있게 됩니다.

flowchart TB
  subgraph v8[V8]
    I[Isolate] --> C1[Context 1]
    I --> C2[Context 2]
    C1 --> S1[Script 실행]
    C2 --> S2[Script 실행]
  end
  A[C++ 애플리케이션] --> I

핸들 타입

타입설명수명
Local<T>현재 HandleScope에 속한 핸들HandleScope가 끝날 때까지
Persistent<T>HandleScope와 무관한 핸들Reset()을 호출할 때까지
Global<T>이동만 가능하고 소멸자에서 자동으로 Reset()하는 Persistent객체(멤버 변수 등)의 수명과 같음

Local은 HandleScope가 끝나면 무효가 되므로, 함수에서 Local을 반환하려면 EscapableHandleScope::Escape()로 바깥 스코프에 옮겨야 합니다. 함수 호출을 넘어 오래 보관할 핸들(Context, 콜백 함수 등)은 Global로 들고 있습니다.


V8 초기화부터 스크립트 실행까지

// v8_basic.cpp — V8 초기화·스크립트 실행
#include <v8.h>
#include <libplatform/libplatform.h>
#include <string>
#include <iostream>
#include <memory>

class V8Engine {
public:
    explicit V8Engine(const char* argv0) {
        v8::V8::InitializeICUDefaultLocation(argv0);
        v8::V8::InitializeExternalStartupData(argv0);
        platform_ = v8::platform::NewDefaultPlatform();
        v8::V8::InitializePlatform(platform_.get());
        v8::V8::Initialize();
        allocator_.reset(v8::ArrayBuffer::Allocator::NewDefaultAllocator());
        v8::Isolate::CreateParams params;
        params.array_buffer_allocator = allocator_.get();
        isolate_ = v8::Isolate::New(params);
    }
    ~V8Engine() {
        isolate_->Dispose();          // Isolate 먼저
        v8::V8::Dispose();
        v8::V8::DisposePlatform();    // 구버전 이름: ShutdownPlatform
        // allocator_는 Isolate가 사라진 뒤 멤버 소멸 순서에 따라 해제됨
    }
    std::string runScript(const std::string& js_code) {
        v8::Isolate::Scope isolate_scope(isolate_);
        v8::HandleScope handle_scope(isolate_);
        v8::Local<v8::Context> context = v8::Context::New(isolate_);
        v8::Context::Scope context_scope(context);
        v8::Local<v8::String> source;
        if (!v8::String::NewFromUtf8(isolate_, js_code.c_str()).ToLocal(&source)) {
            return "[Invalid source]";
        }
        v8::Local<v8::Script> script;
        if (!v8::Script::Compile(context, source).ToLocal(&script)) {
            return "[Compile Error]";
        }
        v8::Local<v8::Value> result;
        if (!script->Run(context).ToLocal(&result)) {
            return "[Runtime Error]";
        }
        v8::String::Utf8Value utf8(isolate_, result);
        return std::string(*utf8, utf8.length());
    }
private:
    std::unique_ptr<v8::Platform> platform_;
    std::unique_ptr<v8::ArrayBuffer::Allocator> allocator_;
    v8::Isolate* isolate_ = nullptr;
};

int main(int argc, char* argv[]) {
    V8Engine engine(argv[0]);
    std::cout << engine.runScript("'Hello, ' + 'V8!'") << "\n";  // Hello, V8!
    return 0;
}

초기화는 ICU 데이터와 스냅샷 위치 지정, Platform 생성과 등록, V8::Initialize(), Isolate 생성 순서이고, 해제는 그 역순입니다. InitializeICUDefaultLocation과 InitializeExternalStartupData는 실행 파일 경로(argv[0])를 기준으로 icudtl.dat나 스냅샷 파일을 찾으므로 빈 문자열 대신 실행 파일 경로를 넘깁니다(V8을 데이터 내장 방식으로 빌드했다면 파일은 필요 없습니다).

ArrayBuffer 할당자는 Isolate가 사용하는 동안 살아 있어야 하므로 멤버로 들고 있다가 Isolate를 Dispose()한 뒤에 해제합니다. 해제된 Isolate에서 isolate_->GetArrayBufferAllocator()를 호출해 할당자를 지우는 코드가 인터넷에 꽤 돌아다니는데, 이미 해제된 객체를 사용하는 것이라 미정의 동작입니다.

ToLocalChecked()는 결과가 비어 있으면 프로세스를 중단시킵니다. 사용자 입력처럼 실패할 수 있는 값에는 ToLocal(&var)로 성공 여부를 확인합니다. 위 runScript는 호출할 때마다 새 Context를 만드는데, Context 생성은 빌트인 객체를 모두 준비하는 작업이라 가볍지 않으므로 반복 실행한다면 Context를 Global로 보관해 재사용합니다(뒤의 게임 엔진 예제 참고).

TryCatch로 예외 메시지 얻기

v8::TryCatch try_catch(isolate_);
v8::Local<v8::Value> result;
if (!script->Run(context).ToLocal(&result)) {
    if (try_catch.HasCaught() && !try_catch.Message().IsEmpty()) {
        v8::String::Utf8Value msg_str(isolate_, try_catch.Message()->Get());
        std::cerr << "JS Error: " << *msg_str << "\n";
    }
    return "[Runtime Error]";
}

TryCatch가 없으면 스크립트가 던진 예외는 메시지 없이 실패로만 돌아옵니다. TryCatch를 스택에 만들어 두면 그 범위에서 발생한 예외를 잡아 메시지와 스택 트레이스를 꺼낼 수 있습니다.


C++ 함수를 JavaScript에 노출

JavaScript에서 print("hello")처럼 C++ 함수를 부르려면 콜백을 FunctionTemplate으로 감싸 전역 객체 템플릿에 등록한 뒤, 그 템플릿으로 Context를 만듭니다.

void PrintCallback(const v8::FunctionCallbackInfo<v8::Value>& args) {
    v8::Isolate* isolate = args.GetIsolate();
    for (int i = 0; i < args.Length(); i++) {
        v8::String::Utf8Value str(isolate, args[i]);
        std::cout << (i > 0 ? " " : "") << *str;
    }
    std::cout << "\n";
}

v8::Local<v8::ObjectTemplate> global = v8::ObjectTemplate::New(isolate);
global->Set(isolate, "print", v8::FunctionTemplate::New(isolate, PrintCallback));
v8::Local<v8::Context> context = v8::Context::New(isolate, nullptr, global);
print("Hello", "from", "JavaScript!");
// 출력: Hello from JavaScript!

인자 검사와 반환값

void AddCallback(const v8::FunctionCallbackInfo<v8::Value>& args) {
    v8::Isolate* isolate = args.GetIsolate();
    if (args.Length() < 2 || !args[0]->IsNumber() || !args[1]->IsNumber()) {
        isolate->ThrowException(v8::Exception::TypeError(
            v8::String::NewFromUtf8Literal(isolate, "Need two numbers")));
        return;
    }
    double a = args[0].As<v8::Number>()->Value();
    double b = args[1].As<v8::Number>()->Value();
    args.GetReturnValue().Set(v8::Number::New(isolate, a + b));
}

JavaScript는 인자 개수나 타입을 강제하지 않으므로, 콜백에서 직접 확인하지 않으면 add("a") 같은 호출이 C++ 쪽에서 엉뚱한 값으로 처리됩니다. 문자열을 그대로 던지는 대신 v8::Exception::TypeError로 감싸면 JS 쪽에서 e instanceof TypeError와 e.stack을 쓸 수 있습니다.

콜백에 C++ 상태 전달하기 (External)

struct AppState { int counter = 0; };

void IncrementCallback(const v8::FunctionCallbackInfo<v8::Value>& args) {
    auto* state = static_cast<AppState*>(args.Data().As<v8::External>()->Value());
    state->counter++;
    args.GetReturnValue().Set(v8::Integer::New(args.GetIsolate(), state->counter));
}

AppState state;  // Context를 쓰는 동안 살아 있어야 함
global->Set(isolate, "increment",
    v8::FunctionTemplate::New(isolate, IncrementCallback,
        v8::External::New(isolate, &state)));

FunctionTemplate::New의 세 번째 인자로 넘긴 값은 콜백에서 args.Data()로 돌아옵니다. External은 C++ 포인터를 JS 값으로 감싸기만 할 뿐 수명을 관리하지 않으므로, 가리키는 객체가 먼저 사라지면 다음 호출에서 댕글링 포인터가 됩니다.


C++ 객체를 JavaScript 객체로 감싸기

C++ 인스턴스를 JS 객체로 노출할 때는 객체 템플릿에 Internal Field 슬롯을 만들고 거기에 C++ 포인터를 저장합니다. JS 코드는 Internal Field를 볼 수 없으므로, 프로퍼티 접근자 콜백에서 포인터를 꺼내 실제 값을 읽고 씁니다.

class Point {
public:
    Point(int x, int y) : x_(x), y_(y) {}
    int x() const { return x_; }
    int y() const { return y_; }
    void setX(int x) { x_ = x; }
    void setY(int y) { y_ = y; }
private:
    int x_, y_;
};

Point* Unwrap(v8::Local<v8::Object> self) {
    return static_cast<Point*>(self->GetInternalField(0).As<v8::External>()->Value());
}

void GetPointX(v8::Local<v8::Name>, const v8::PropertyCallbackInfo<v8::Value>& info) {
    info.GetReturnValue().Set(Unwrap(info.Holder())->x());
}
void SetPointX(v8::Local<v8::Name>, v8::Local<v8::Value> value,
               const v8::PropertyCallbackInfo<void>& info) {
    int32_t v;
    if (value->Int32Value(info.GetIsolate()->GetCurrentContext()).To(&v))
        Unwrap(info.Holder())->setX(v);
}
// GetPointY / SetPointY도 같은 형태

v8::Local<v8::ObjectTemplate> point_templ = v8::ObjectTemplate::New(isolate);
point_templ->SetInternalFieldCount(1);
point_templ->SetNativeDataProperty(
    v8::String::NewFromUtf8Literal(isolate, "x"), GetPointX, SetPointX);
point_templ->SetNativeDataProperty(
    v8::String::NewFromUtf8Literal(isolate, "y"), GetPointY, SetPointY);

예전 예제에서 흔히 보이는 ObjectTemplate::SetAccessor는 최근 V8에서 deprecated되었고, 같은 시그니처의 콜백을 받는 SetNativeDataProperty(또는 getter/setter를 JS 함수로 만드는 SetAccessorProperty)로 바뀌었습니다. PropertyCallbackInfo::Holder()도 최신 버전에서는 HolderV2()로 대체되는 중이므로, 사용하는 V8 버전의 헤더에서 deprecated 경고를 확인하세요. Int32Value(...).FromJust()는 변환이 예외를 던지면(예: valueOf가 throw하는 객체) 프로세스를 중단시키므로 To(&v)로 확인합니다.

v8::Local<v8::Object> wrapPoint(v8::Isolate* isolate,
                                v8::Local<v8::ObjectTemplate> templ, Point* p) {
    v8::Local<v8::Object> obj =
        templ->NewInstance(isolate->GetCurrentContext()).ToLocalChecked();
    obj->SetInternalField(0, v8::External::New(isolate, p));
    return obj;
}
// C++에서 wrapPoint 결과를 전역 point로 등록했다고 가정
print(point.x, point.y);  // 10 20
point.x = 100;
print(point.x);           // 100

Internal Field 인덱스는 0부터 SetInternalFieldCount로 정한 개수 미만까지만 유효합니다. SetInternalFieldCount(1) 뒤에 GetInternalField(1)을 읽거나, 템플릿 없이 만든 일반 객체에서 Internal Field를 읽으면 디버그 빌드에서는 체크 실패로, 릴리스 빌드에서는 메모리 손상으로 이어집니다.

프로토타입에 메서드 추가

void PointToString(const v8::FunctionCallbackInfo<v8::Value>& args) {
    Point* p = Unwrap(args.This());
    std::string str = "Point(" + std::to_string(p->x()) + ", " + std::to_string(p->y()) + ")";
    args.GetReturnValue().Set(
        v8::String::NewFromUtf8(args.GetIsolate(), str.c_str()).ToLocalChecked());
}

v8::Local<v8::FunctionTemplate> point_ctor = v8::FunctionTemplate::New(isolate);
point_ctor->InstanceTemplate()->SetInternalFieldCount(1);
point_ctor->PrototypeTemplate()->Set(
    isolate, "toString", v8::FunctionTemplate::New(isolate, PointToString));

메서드는 인스턴스마다 붙이지 않고 FunctionTemplate의 프로토타입 템플릿에 한 번 등록합니다. 다만 Point.prototype.toString.call({})처럼 Internal Field가 없는 객체로 호출될 수 있으므로, 공개 API라면 args.This()->InternalFieldCount()를 확인하거나 FunctionTemplate::New에 Signature를 지정해 수신 객체 타입을 제한해야 합니다.


게임 엔진 API와 C++에서 JS 콜백 호출

엔티티 생성, 위치 설정, 점수 추가를 JS에 노출하고, C++에서 충돌이 일어나면 스크립트에 정의된 on_collision을 호출하는 예제입니다.

// game_js_engine.h
#include <v8.h>
#include <libplatform/libplatform.h>
#include <string>
#include <unordered_map>
#include <memory>
#include <iostream>

struct Entity { int id; float x, y; };

class EntityManager {
    int next_id_ = 0;
    std::unordered_map<int, Entity> entities_;
public:
    int createEntity() {
        int id = next_id_++;
        entities_[id] = Entity{id, 0, 0};
        return id;
    }
    void setPosition(int id, float x, float y) {
        auto it = entities_.find(id);
        if (it != entities_.end()) { it->second.x = x; it->second.y = y; }
    }
};

class GameJSEngine {
    std::unique_ptr<v8::Platform> platform_;
    std::unique_ptr<v8::ArrayBuffer::Allocator> allocator_;
    v8::Isolate* isolate_ = nullptr;
    v8::Global<v8::Context> context_;   // runScript·fireCollision이 공유
    EntityManager entities_;
    int score_ = 0;

    static GameJSEngine* Self(const v8::FunctionCallbackInfo<v8::Value>& args) {
        return static_cast<GameJSEngine*>(args.Data().As<v8::External>()->Value());
    }
    static void JsCreateEntity(const v8::FunctionCallbackInfo<v8::Value>& args) {
        args.GetReturnValue().Set(Self(args)->entities_.createEntity());
    }
    static void JsSetPosition(const v8::FunctionCallbackInfo<v8::Value>& args) {
        if (args.Length() < 3) return;
        auto ctx = args.GetIsolate()->GetCurrentContext();
        int32_t id; double x, y;
        if (!args[0]->Int32Value(ctx).To(&id) ||
            !args[1]->NumberValue(ctx).To(&x) ||
            !args[2]->NumberValue(ctx).To(&y)) return;   // 예외는 JS로 전파됨
        Self(args)->entities_.setPosition(id, static_cast<float>(x), static_cast<float>(y));
    }
    static void JsAddScore(const v8::FunctionCallbackInfo<v8::Value>& args) {
        int32_t delta;
        if (args.Length() < 1 ||
            !args[0]->Int32Value(args.GetIsolate()->GetCurrentContext()).To(&delta)) return;
        Self(args)->score_ += delta;
    }
    static void JsPrint(const v8::FunctionCallbackInfo<v8::Value>& args) {
        for (int i = 0; i < args.Length(); i++) {
            v8::String::Utf8Value str(args.GetIsolate(), args[i]);
            std::cout << (i > 0 ? " " : "") << *str;
        }
        std::cout << "\n";
    }

    // 호출자가 HandleScope를 들고 있어야 함
    v8::Local<v8::Context> getOrCreateContext() {
        if (!context_.IsEmpty()) return context_.Get(isolate_);
        v8::Local<v8::External> self = v8::External::New(isolate_, this);
        v8::Local<v8::ObjectTemplate> global = v8::ObjectTemplate::New(isolate_);
        global->Set(isolate_, "print", v8::FunctionTemplate::New(isolate_, JsPrint));
        global->Set(isolate_, "create_entity",
                    v8::FunctionTemplate::New(isolate_, JsCreateEntity, self));
        global->Set(isolate_, "set_position",
                    v8::FunctionTemplate::New(isolate_, JsSetPosition, self));
        global->Set(isolate_, "add_score",
                    v8::FunctionTemplate::New(isolate_, JsAddScore, self));
        v8::Local<v8::Context> ctx = v8::Context::New(isolate_, nullptr, global);
        context_.Reset(isolate_, ctx);
        return ctx;
    }

public:
    explicit GameJSEngine(const char* argv0) {
        v8::V8::InitializeICUDefaultLocation(argv0);
        v8::V8::InitializeExternalStartupData(argv0);
        platform_ = v8::platform::NewDefaultPlatform();
        v8::V8::InitializePlatform(platform_.get());
        v8::V8::Initialize();
        allocator_.reset(v8::ArrayBuffer::Allocator::NewDefaultAllocator());
        v8::Isolate::CreateParams params;
        params.array_buffer_allocator = allocator_.get();
        isolate_ = v8::Isolate::New(params);
    }
    ~GameJSEngine() {
        context_.Reset();         // Isolate보다 먼저 핸들 해제
        isolate_->Dispose();
        v8::V8::Dispose();
        v8::V8::DisposePlatform();
    }

    bool runScript(const std::string& js_code) {
        v8::Isolate::Scope isolate_scope(isolate_);
        v8::HandleScope handle_scope(isolate_);
        v8::Local<v8::Context> context = getOrCreateContext();
        v8::Context::Scope context_scope(context);
        v8::TryCatch try_catch(isolate_);
        v8::Local<v8::String> source;
        v8::Local<v8::Script> script;
        v8::Local<v8::Value> result;
        if (v8::String::NewFromUtf8(isolate_, js_code.c_str()).ToLocal(&source) &&
            v8::Script::Compile(context, source).ToLocal(&script) &&
            script->Run(context).ToLocal(&result)) {
            return true;
        }
        if (!try_catch.Message().IsEmpty()) {
            v8::String::Utf8Value msg(isolate_, try_catch.Message()->Get());
            std::cerr << "JS Error: " << *msg << "\n";
        }
        return false;
    }

    // 스크립트에 on_collision이 정의되어 있으면 호출
    void fireCollision(int a, int b) {
        v8::Isolate::Scope isolate_scope(isolate_);
        v8::HandleScope handle_scope(isolate_);
        v8::Local<v8::Context> context = getOrCreateContext();
        v8::Context::Scope context_scope(context);
        v8::TryCatch try_catch(isolate_);
        v8::Local<v8::Value> fn_val;
        if (!context->Global()
                 ->Get(context, v8::String::NewFromUtf8Literal(isolate_, "on_collision"))
                 .ToLocal(&fn_val) || !fn_val->IsFunction()) {
            return;
        }
        v8::Local<v8::Value> argv[2] = {v8::Integer::New(isolate_, a),
                                        v8::Integer::New(isolate_, b)};
        v8::Local<v8::Value> ret;
        if (!fn_val.As<v8::Function>()->Call(context, context->Global(), 2, argv).ToLocal(&ret)) {
            std::cerr << "on_collision threw\n";
        }
    }

    int getScore() const { return score_; }
};

Context를 Global로 보관해 두었기 때문에 runScript에서 정의한 on_collision 함수가 같은 전역 객체에 남아 있고, fireCollision이 그것을 찾아 호출할 수 있습니다. 소멸자에서 context_.Reset()을 Isolate 해제보다 먼저 호출하는 이유는, Isolate가 사라진 뒤 Global의 소멸자가 실행되면 이미 해제된 힙에 접근하기 때문입니다.

getOrCreateContext에는 HandleScope를 두지 않았습니다. 함수 안에서 HandleScope를 만들고 그 안에서 생성한 Local Context를 그대로 반환하면, 스코프가 끝나는 순간 반환값이 무효가 됩니다. 호출자의 HandleScope를 그대로 쓰거나, 함수 안에 둬야 한다면 EscapableHandleScope로 Escape()해야 합니다.

// main.cpp
#include "game_js_engine.h"

int main(int argc, char* argv[]) {
    GameJSEngine engine(argv[0]);
    if (!engine.runScript(R"(
        const id = create_entity();
        print("Entity ID:", id);
        set_position(id, 50, 75);
        add_score(100);
        function on_collision(a, b) {
            print("Collision:", a, b);
            add_score(10);
        }
    )")) {
        return 1;
    }
    engine.fireCollision(1, 2);   // Collision: 1 2
    std::cout << "Final score: " << engine.getScore() << "\n";   // 110
    return 0;
}

CMake 빌드

cmake_minimum_required(VERSION 3.16)
project(GameJSDemo LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# depot_tools로 체크아웃한 V8 소스와 monolith 빌드 출력 경로
set(V8_SRC "${CMAKE_CURRENT_SOURCE_DIR}/v8")
set(V8_OUT "${V8_SRC}/out.gn/x64.release.sample/obj")

add_executable(game_js_demo main.cpp)
target_include_directories(game_js_demo PRIVATE ${V8_SRC}/include)
target_link_directories(game_js_demo PRIVATE ${V8_OUT})
target_link_libraries(game_js_demo PRIVATE v8_monolith v8_libbase v8_libplatform pthread dl)
# V8을 빌드할 때의 설정과 반드시 같아야 함
target_compile_definitions(game_js_demo PRIVATE V8_COMPRESS_POINTERS V8_ENABLE_SANDBOX)
target_compile_options(game_js_demo PRIVATE -fno-rtti)

링크 라이브러리 목록과 매크로는 v8.dev의 임베딩 가이드에 있는 hello-world 빌드 명령을 따른 것입니다. V8_COMPRESS_POINTERS, V8_ENABLE_SANDBOX 같은 매크로가 V8 빌드 설정(args.gn)과 다르면 헤더가 가정하는 객체 레이아웃이 실제 라이브러리와 어긋납니다. 최근 V8은 Isolate를 만들 때 이를 검사해 “Embedder-vs-V8 build configuration mismatch” 같은 메시지와 함께 중단하지만, 이 메시지를 처음 보면 원인을 찾기 어렵습니다. V8을 -fno-rtti로 빌드했다면 임베더도 같은 옵션을 써야 링크 에러(typeinfo 관련 undefined symbol)가 나지 않습니다.


자주 만나는 오류

Isolate를 Dispose()한 뒤 그 Isolate로 스크립트를 실행하거나 핸들을 만들면 바로 크래시합니다. Global 멤버가 남아 있는 상태에서 Isolate를 먼저 해제하는 것도 같은 문제입니다. 해제는 항상 핸들, Isolate, V8, Platform 순서입니다.

함수 안의 HandleScope에서 만든 Local을 반환하면 스코프가 끝날 때 핸들이 무효가 되어 댕글링이 됩니다.

// 잘못된 예: 반환값이 무효화된 핸들
v8::Local<v8::String> getString() {
    v8::HandleScope scope(isolate_);
    return v8::String::NewFromUtf8Literal(isolate_, "hello");
}

// 올바른 예
v8::Local<v8::String> getString() {
    v8::EscapableHandleScope scope(isolate_);
    v8::Local<v8::String> s = v8::String::NewFromUtf8Literal(isolate_, "hello");
    return scope.Escape(s);
}

오래된 예제의 v8::Script::Compile(source), script->Run()처럼 Context를 받지 않는 오버로드는 이미 제거되었습니다. 현재 API는 모든 컴파일·실행 함수가 Context를 인자로 받고, 그 전에 Context::Scope로 Context에 진입해 있어야 합니다.

ICU를 외부 데이터 파일 방식으로 빌드했다면 icudtl.dat이 InitializeICUDefaultLocation에 넘긴 경로 기준으로 실행 파일 옆에 있어야 합니다. 없으면 Intl이나 toLocaleString처럼 ICU를 쓰는 기능이 실패하거나 초기화 단계에서 중단됩니다. 배포 패키지에 이 파일을 넣는 것을 빠뜨리기 쉽습니다.

String::NewFromUtf8(...).ToLocalChecked()는 문자열이 V8의 최대 길이를 넘는 경우처럼 생성이 실패하면 프로세스를 중단시킵니다. 외부에서 들어오는 데이터에는 ToLocal(&s)로 실패를 처리합니다.


운영할 때 필요한 것들

객체 소유권

Internal Field나 External에 넣은 C++ 포인터는 V8이 소유하지 않습니다. C++가 소유하고 JS 객체보다 오래 살게 하거나(위 게임 엔진 예제처럼 엔진이 엔티티를 소유), JS 객체가 GC될 때 C++ 객체도 지우려면 Global::SetWeak()으로 약한 콜백을 등록합니다. 약한 콜백은 GC가 언제 실행될지에 달려 있어 호출 시점이 보장되지 않고 프로세스 종료 시에는 아예 호출되지 않을 수 있으므로, 파일 핸들처럼 확실히 닫아야 하는 자원은 명시적인 close() 메서드도 함께 제공하는 편이 안전합니다.

컴파일 재사용

같은 스크립트를 반복 실행한다면 한 번 컴파일한 Script를 계속 Run()합니다. Script는 컴파일한 Context에 묶여 있으므로, 여러 Context에서 같은 코드를 쓰려면 ScriptCompiler::CompileUnboundScript로 만든 UnboundScript를 보관하고 Context마다 BindToCurrentContext()합니다. 프로세스 재시작 간에도 컴파일 비용을 줄이고 싶다면 ScriptCompiler::CreateCodeCache로 코드 캐시를 저장해 다음 실행 때 넘길 수 있습니다.

실행 범위 제한

V8 자체에는 require, fetch, 파일 접근 같은 API가 없습니다. 이런 것은 Node.js나 브라우저가 추가한 것이므로, 임베더가 등록하지 않으면 스크립트는 처음부터 쓸 수 없습니다. 반면 eval과 Function 생성자는 JavaScript 언어의 일부라 모든 Context에 기본으로 있고, 전역에 등록하지 않는다고 사라지지 않습니다. 문자열로부터 코드를 만드는 것을 막으려면 context->AllowCodeGenerationFromStrings(false)를 호출합니다. 신뢰할 수 없는 스크립트를 실행한다면 V8 안에서 막는 것만으로는 부족하고, 별도 프로세스와 OS 수준 샌드박스까지 고려해야 합니다.

실행 시간 제한

while (true) {} 같은 스크립트는 Run()이 영원히 돌아오지 않게 만듭니다. 감시 스레드에서 일정 시간이 지나면 isolate->TerminateExecution()을 호출하면, 실행 중인 스크립트가 잡을 수 없는 종료 예외로 중단되고 Run()이 빈 결과를 반환합니다. 이후 같은 Isolate를 계속 쓰려면 isolate->CancelTerminateExecution()으로 상태를 되돌려야 합니다.

메모리 제한

힙 크기는 Isolate를 만들 때 CreateParams의 constraints로 정합니다. 오래된 예제의 v8::SetResourceConstraints는 더 이상 없습니다.

v8::Isolate::CreateParams params;
params.array_buffer_allocator = allocator_.get();
params.constraints.ConfigureDefaultsFromHeapSize(
    0, 64 * 1024 * 1024);   // 초기 크기, 최대 힙 크기(바이트)
isolate_ = v8::Isolate::New(params);

한도에 도달하면 V8은 기본적으로 out-of-memory로 프로세스를 중단합니다. 스크립트 하나 때문에 앱 전체가 죽는 것을 피하려면 isolate->AddNearHeapLimitCallback()으로 한도 근처에서 호출되는 콜백을 등록해, 실행을 종료시키고 한도를 잠시 늘려 주는 처리를 해야 합니다.

에러 로깅

void logV8Error(v8::Isolate* isolate, v8::TryCatch& try_catch) {
    v8::Local<v8::Context> ctx = isolate->GetCurrentContext();
    if (!try_catch.Message().IsEmpty()) {
        v8::String::Utf8Value msg(isolate, try_catch.Message()->Get());
        std::cerr << "V8: " << *msg << "\n";
    }
    v8::Local<v8::Value> stack;
    if (try_catch.StackTrace(ctx).ToLocal(&stack)) {   // Error 객체가 아니면 비어 있음
        v8::String::Utf8Value stack_str(isolate, stack);
        std::cerr << "Stack: " << *stack_str << "\n";
    }
}

throw "문자열"처럼 Error 객체가 아닌 값을 던지면 스택 트레이스가 없어 StackTrace()가 빈 값을 반환합니다. 여기에 ToLocalChecked()를 쓰면 에러를 기록하려던 코드가 프로세스를 중단시키므로 반드시 ToLocal로 확인합니다.


참고 자료


같이 보면 좋은 글