6 minute read

Node.js repo의 ./src 디렉토리에 가 보면, 시스템 콜 사용 같은 저레벨 작업이나 퍼포먼스 향상을 위해 C++로 작성된 코드가 보일 것이다. 그리고 이 코드들을 까 보면 v8 네임스페이스의 뭔가로 가득 차 있어 C++만 잘 한다고 코드를 이해하기가 쉽지 않다. 이 글에서는 V8이 무엇인지, Node.js와 어떤 관계가 있는지, 그리고 Node.js의 C++ 코드를 이해하는데 도움이 되는 V8의 핵심 개념들을 살펴보겠다.

V8이란 무엇인가?

V8은 구글에서 개발한 C++ 기반의 오픈소스 JavaScript 엔진이다. JavaScript 엔진은 JavaScript 코드를 해석하고 실행하는 프로그램이다. V8은 웹 브라우저인 크롬의 핵심 엔진으로 사용되며, Node.js도 V8을 사용하여 JavaScript 코드를 실행한다.

JavaScript 런타임 환경의 일종이라고 하는 Node.js도 자바스크립트를 실행하는 프로그램인데, 런타임 환경엔진의 차이는 구체적으로 다음과 같다. JavaScript 엔진은 일종의 인터프리터 또는 컴파일러에 해당된다. ECMAScript 표준에 따라 JavaScript 코드를 해석하고 실행하는 역할을 하지만, 파일 I/O나 네트워크 요청 같은 시스템 레벨의 기능은 제공하지 않는다. 런타임 환경은 이러한 엔진을 포함하며, 바닐라 JavaScript 외에도 브라우저의 DOM API나 Node.js의 fs 모듈 같은 기능을 제공하여 더 넓은 용도로 사용할 수 있게 한다.

V8의 다른 중요한 기능은, C++ 코드와 JavaScript 코드 간의 상호작용을 가능하게 하는 바인딩이다. 이를 통해 C++로 작성된 코드에서 JavaScript 함수를 호출하거나, JavaScript 코드에서 C++ 함수를 호출할 수 있다. Node.js의 C++ 코드는 이러한 바인딩을 통해 V8의 기능을 활용하여 JavaScript 코드와 상호작용한다.

이제 V8의 핵심 개념을 살펴보고, 이를 통해 JavaScript 코드에서 불러올 수 있는 C++ 함수가 어떻게 작성되는지 알아보자.

V8의 핵심 개념

Isolate

v8::Isolate 클래스는 V8에서 작동되는 하나의 JavaScript 실행 환경을 나타낸다. 각 Isolate는 자신만의 메모리 공간을 가지며, 서로 다른 Isolate 간에는 데이터를 공유할 수 없다. 이는 JavaScript 코드가 실행되는 환경을 격리시켜, 서로 다른 Isolate 간에는 데이터 누출이나 충돌이 발생하지 않도록 한다.

Node.js에서는 각 요청이나 연결마다 새로운 Isolate를 생성하여 격리를 유지한다. 이를 통해 하나의 요청이 다른 요청에 영향을 미치지 않도록 보장한다.

Context

v8::Context 클래스는 JavaScript 코드가 실행되는 환경을 나타낸다. ContextIsolate 내에 생성되며, 여러 개의 Context를 생성하여 각각의 JavaScript 코드를 실행할 수 있다. Context는 JavaScript 코드가 실행되는 환경을 나타내므로, Context를 통해 JavaScript 코드에서 사용할 수 있는 전역 객체나 함수 등을 정의할 수 있다.

V8 JavaScript Value

V8은 JavaScript의 값들을 C++에서 사용할 수 있는 형태로 표현하기 위해 v8::Value 클래스를 사용한다. v8::Value 클래스는 JavaScript의 모든 자료형에 대응되며, 특정한 자료형을 나타내고 싶다면 서브클래스인 v8::String, v8::Number, v8::Object 등을 사용한다. Value의 실제 값을 C++의 타입으로 변환하기 위해서는 Value 객체를 적절한 서브클래스로 캐스팅한 후, .Value() 메소드를 사용한다.

Value handle

JavaScript value는 v8::Value 객체로 표현되며, 이 객체를 다루기 위해 handle이라는 일종의 포인터를 사용한다. v8::Value 객체는 Local, Global, 두 가지 타입의 handle을 가질 수 있다.

Local handle

v8::Local<T> handle은 v8::Value 객체를 가리키는 임시 포인터이다. 특정 스코프 내에서만 유효하며, 스코프를 벗어나면 자동으로 해제된다. 주로 함수 내의 지역 변수로 사용된다.

Local handle이 생성되기 이전에, v8::HandleScopev8::EscapableHandleScope 객체가 스택에 존재해야 한다. Local handle은 이 스코프에 추가되며, 스코프와 함께 삭제된다. EscapableHandleScopeHandleScope와 달리 Local handle을 반환할 수 있으며, 이를 통해 handle을 다른 스코프로 넘길 수 있다.

단, binding 함수 내에서 Local handle을 사용할 때는 이미 함수 밖에 HandleScope가 존재하므로, 다른 스코프를 생성할 필요가 없다.

간단한 JavaScript 코드와 그에 대응되는 C++ 코드를 살펴보자.

function getFoo(obj) {
  return obj.foo;
}
v8::Local<v8::Value> GetFoo(v8::Local<v8::Context> context,
                            v8::Local<v8::Object> obj) {
  v8::Isolate* isolate = context->GetIsolate();
  v8::EscapableHandleScope handle_scope(isolate);

  // The 'foo_string' handle cannot be returned from this function because
  // it is not “escaped” with `.Escape()`.
  v8::Local<v8::String> foo_string =
      v8::String::NewFromUtf8(isolate, "foo").ToLocalChecked();

  v8::Local<v8::Value> return_value;
  if (obj->Get(context, foo_string).ToLocal(&return_value)) {
    return handle_scope.Escape(return_value);
  } else {
    // There was a JS exception! Handle it somehow.
    return v8::Local<v8::Value>();
  }
}

Global handle

v8::Global<T> handle은 JavaScript 엔진이 종료되지 않는 한 계속 유지될 수 있는 포인터이다. 전역 변수나 모듈 변수 같이 계속 사용되는 값들에 사용된다.

Global handle에는 strong과 weak 두 가지 타입이 있다. strong handle은 JavaScript 엔진이 종료되지 않는 한 계속 유지되는 반면, weak handle은 다른 값에 의해 참조되지 않으면 자동으로 해제된다.

Exception handling

V8은 C++의 exception을 JavaScript에 직접 bind할 수 없다. 그 대신 Maybe 타입을 사용하거나 TryCatch 객체를 사용하여 JavaScript 코드에서 발생한 예외를 처리할 수 있다.

Maybe, MaybeLocal 타입

V8의 Maybe<T>MaybeLocal<T> 타입은 반환값이 있을 수도 있고 없을 수도 있는 함수의 반환값을 나타낸다. std::optional과 비슷한 것이라고 보면 된다. Maybe<T>의 값이 비어 있는 경우 (.IsNothing()true인 경우)는 예외가 발생한 것으로 간주할 수 있다. 함수의 반환 타입이 Maybe<T>인 경우, 함수 내부에서 서브클래스인 Just<T>Nothing<T>을 반환하여 값을 반환하거나 예외를 처리할 수 있다.

MaybeLocal<T>Local handle을 반환하는 함수의 반환 타입으로 사용된다. MaybeLocal<T>Maybe<T>와 비슷하게 동작하지만, Local handle을 반환한다는 점이 다르고 메소드 이름에도 차이가 있다.

Maybe MaybeLocal
maybe.IsNothing() maybe_local.IsEmpty()
maybe.IsJust() !maybe_local.IsEmpty()
maybe.To(&value) maybe_local.ToLocal(&local)
maybe.ToChecked() maybe_local.ToLocalChecked()
maybe.FromJust() maybe_local.ToLocalChecked()
maybe.Check()
v8::Nothing<T>() v8::MaybeLocal<T>()
v8::Just<T>(value) v8::MaybeLocal<T>(value)

TryCatch 객체

Funtion binding

이제 binding funciton을 쓰기 위한 정말 기초적인 것들은 살펴봤다. 이제 실제로 JavaScript 코드에서 불러올 수 있는 C++ 함수를 작성해보자.

JavaScript에 노출되는 C++ 함수는 다음과 같은 형태를 가진다. 아래 함수는 node_util.cc 파일에 정의된 함수로, ArrayBufferView 객체가 주어졌을 때 해당 객체가 버퍼를 가지고 있는지 확인하는 함수이다.

void ArrayBufferViewHasBuffer(const FunctionCallbackInfo<Value>& args) {
  CHECK(args[0]->IsArrayBufferView());
  args.GetReturnValue().Set(args[0].As<ArrayBufferView>()->HasBuffer());
}

(네임스페이스는 using v8;로 설정되어 있다고 가정한다.)

args는 JavaScript 함수에 전달된 전체 인자를 담고 있고, args[i]는 i번째 (0-based) 인자를 나타낸다. args.GetReturnValue()는 함수의 반환값을 설정하는데 사용되며, Set() 메소드를 통해 반환값을 설정한다.

이 함수를 JavaScript에서 사용하려면 다음과 같이 SetMethod, SetMethodNoSideEffect 등의 함수를 이용해 Node.js의 target 객체에 함수를 등록해야 한다.

void Initialize(Local<Object> target,
                Local<Value> unused,
                Local<Context> context,
                void* priv) {
  Environment* env = Environment::GetCurrent(context);

  SetMethod(context, target, "getaddrinfo", GetAddrInfo);
  // ...
}

NODE_MODULE_CONTEXT_AWARE(NODE_GYP_MODULE_NAME, Initialize);

이제 기존에 존재하는 모듈에 SumNumbers라는 함수를 추가해보자. 이 함수는 배열을 받아 배열의 모든 요소를 더한 값을 반환하며, 배열의 요소가 숫자가 아닌 경우 예외를 발생시킨다.

// This could also return a v8::MaybeLocal<v8::Number>, for example.
v8::Maybe<double> SumNumbers(v8::Local<v8::Context> context,
                             v8::Local<v8::Array> array_of_integers) {
  v8::Isolate* isolate = context->GetIsolate();
  v8::HandleScope handle_scope(isolate);

  double sum = 0;

  for (uint32_t i = 0; i < array_of_integers->Length(); i++) {
    v8::Local<v8::Value> entry;
    if (!array_of_integers->Get(context, i).ToLocal(&entry)) {
      // Oops, we might have hit a getter that throws an exception!
      // It's better to not continue return an empty (“nothing”) Maybe.
      return v8::Nothing<double>();
    }

    if (!entry->IsNumber()) {
      // Let's just skip any non-numbers. It would also be reasonable to throw
      // an exception here, e.g. using the error system in src/node_errors.h,
      // and then to return an empty Maybe again.
      continue;
    }

    // This cast is valid, because we've made sure it's really a number.
    v8::Local<v8::Number> entry_as_number = entry.As<v8::Number>();

    sum += entry_as_number->Value();
  }

  return v8::Just(sum);
}

// Function that is exposed to JS:
void SumNumbers(const v8::FunctionCallbackInfo<v8::Value>& args) {
  // This will crash if the first argument is not an array. Let's assume we
  // have performed type checking in a JavaScript wrapper function.
  CHECK(args[0]->IsArray());

  double sum;
  if (!SumNumbers(args.GetIsolate()->GetCurrentContext(),
                  args[0].As<v8::Array>()).To(&sum)) {
    // Exception was thrown
    // Nothing to do, we can just return directly to JavaScript.
    return;
  }

  args.GetReturnValue().Set(sum);
}

첫 번째 함수는 배열의 합을 구하는 실제 로직이 담겨 있는 함수로, 다른 함수에서의 예외 처리를 위해 Maybe 타입을 반환한다. 두 번째 함수는 JavaScript와 실제로 binding되는 wrapper 함수로, Maybe 타입을 처리하여 JavaScript에 반환한다.

두 번째 함수의 if문 내 조건식이 !SumNumbers(...).To(&sum)인 이유는, SumNumbers 함수가 Maybe 타입을 반환하기 때문이다. To 메소드는 Maybe 타입을 받아서 그 안에 있는 값을 sum에 넣어주는 역할을 한다. 만약 SumNumbers 함수가 Nothing을 반환한다면, To 메소드는 false를 반환하고 sum에는 아무 값도 들어가지 않는다.

이제 이 함수를 Node.js에서 사용할 수 있도록 target 객체에 등록하면 된다.

void Initialize(Local<Object> target,
                Local<Value> unused,
                Local<Context> context,
                void* priv) {
  Environment* env = Environment::GetCurrent(context);

  /*
  / Other functions...
  */

  SetMethod(context, target, "sumNumbers", SumNumbers);
}

마지막으로, js 파일에서 이 함수를 사용할 수 있도록 sumNumbers 함수를 등록하면 된다.

const { sumNumbers } = internalBinding("MODULE_NAME");

주의할 점으로, C++ 코드의 주석에도 있지만 SumNumbers 함수로 들어오는 인자가 Array 타입이 아닌 경우에 대한 예외 처리가 없다. 이는 JavaScript 코드에서 미리 타입 체크를 하고 넘겨주는 것이 좋다.

어쩌다 보니 Node.js의 Node.js C++ codebase 공식 문서를 번역해서 libuv 내용 날린 다음에 압축해 놓은 것 같은 글이 됐는데 글이 꽤 길어졌다. 그 와중에 메모리 관리, bootstrap 과정에서의 C++ binding 로딩 등 빠진 내용도 좀 있다. 나중에 시간나면 더 살펴봐야겠다.

  • 이 글의 코드 대부분은 Node.js C++ codebase 공식 문서에서 가져왔다.

Leave a comment