자바스크립트 에러와 스택 트레이스 심화


원문 : https://lucasfcosta.com/2017/02/17/JavaScript-Errors-and-Stack-Traces.html

이번 시간에는 에러와 스택 트레이스를 조작하는 방법에 대해 이야기하려 한다.

때때로 사람들은 이런 세부 사항에 대해 신경쓰지 않지만, 테스트나 에러와 관련된 어떤 라이브러리를 작성하려고 할 때 이 지식들은 굉장히 유용할 것이다. 예로 이번 주에 Chai는 어설션이 실패했을 때 사용자들이 많은 정보를 얻기 위해 스택 트레이스 조작 방식을 개선한 훌륭한 풀 리퀘스트를 받았다.

스택 트레이스를 조작하면 불필요한 데이터를 정리하고 중요한 것에 집중할 수 있다. 또한 정확하게 에러가 무엇인지, 그리고 그 속성들을 이해할 때 당신은 훨씬 더 자신감을 가지게 될 것이다.

이 블로그 포스트의 도입부는 명확해 보이지만 스택 트레이스를 조작하기 시작하면 꽤 복잡해질 수 있다. 그래서 해당 섹션으로 이동하기 전에 이전 내용을 잘 이해했는지 확인하라.

호출 스택 작동 방법

에러에 대해서 이야기하기 전에 호출 스택(Call Stack)이 어떻게 동작하는지 이해해야 한다. 굉장히 단순하지만 꼭 알아야 할 기초이다. 이미 알고 있다면 이번 섹션은 넘어가도 좋다.

함수 호출이 있을 때마다 함수는 스택의 맨 위에 쌓이게 된다. 호출이 끝나면 함수는 스택의 맨 위에서 제거된다.

이러한 데이터 구조에서 흥미로운 점은 스택에 들어간 마지막 아이템이 나올 때 첫 번째로 나온다는 것이다. 이것을 LIFO(Last In, First Out) 속성이라고 한다.

예를 들어 함수 x안에서 함수 y가 호출될 때 x, y 순서로 스택을 갖게 된다.

다음 코드를 보고 이야기해 보자.

function c() {
    console.log('c');
}

function b() {
    console.log('b');
    c();
}

function a() {
    console.log('a');
    b();
}

a();

위 예제에서 함수 a를 실행하면 스택의 맨 위에 추가될 것이다. 그리고 함수 a 안에서 b를 호출하면 스택 맨 위에 b가 추가된다. 함수 b 안에서 c를 호출할 때도 같은 일이 일어난다.

함수 c를 실행하면 스택은 a, b, c를 순서대로 포함하고 있을 것이다.

함수 c 실행이 끝나면 스택의 맨 위에서 제거되고 제어 흐름은 b로 돌아간다. 함수 b 실행이 끝나면 역시 스택에서 제거되고 제어권은 a로 돌아온다. 마지막으로 함수 a 실행이 끝나면 스택에서 제거된다.

이 동작을 보다 잘 설명하기 위해, console.trace()를 사용해 콘솔로 현재 스택 트레이스를 출력할 것이다. 일반적으로 스택 트레이스는 위에서 아래로 읽어야 한다.

function c() {
    console.log('c');
    console.trace();
}

function b() {
    console.log('b');
    c();
}

function a() {
    console.log('a');
    b();
}

a();

노드 레플 서버로 이 코드를 실행하면 다음 결과가 나타난다.

Trace
    at c (repl:3:9)
    at b (repl:3:1)
    at a (repl:3:1)
    at repl:1:1 // <-- For now feel free to ignore anything below this point, these are Node's internals
    at realRunInThisContextScript (vm.js:22:35)
    at sigintHandlersWrap (vm.js:98:12)
    at ContextifyScript.Script.runInThisContext (vm.js:24:12)
    at REPLServer.defaultEval (repl.js:313:29)
    at bound (domain.js:280:14)
    at REPLServer.runBound [as eval] (domain.js:293:12)

여기에서 알 수 있듯이 함수 c 내부에서 출력된 스택에서 a, b 그리고 c를 얻을 수 있다.

함수 c 실행이 끝난 후에 b 안에서 스택 트레이스를 출력하면, 스택의 맨 위에 이미 c가 제거되고 ab만 있을 것이다.

function c() {
    console.log('c');
}

function b() {
    console.log('b');
    c();
    console.trace();
}

function a() {
    console.log('a');
    b();
}

a();

보다시피 스택은 이미 함수 실행이 끝난 c를 갖지 않는다.

Trace
    at b (repl:4:9)
    at a (repl:3:1)
    at repl:1:1  // <-- For now feel free to ignore anything below this point, these are Node's internals
    at realRunInThisContextScript (vm.js:22:35)
    at sigintHandlersWrap (vm.js:98:12)
    at ContextifyScript.Script.runInThisContext (vm.js:24:12)
    at REPLServer.defaultEval (repl.js:313:29)
    at bound (domain.js:280:14)
    at REPLServer.runBound [as eval] (domain.js:293:12)
    at REPLServer.onLine (repl.js:513:10)

요약하면 호출된 함수들은 스택의 맨 위에 들어가게 된다. 그리고 실행이 끝나면 스택에서 빠지게 된다. 이처럼 간단하다.

오류 객체와 오류 핸들링

오류가 발생하면 Error 객체가 발생한다. Error 객체는 확장하거나 자체 오류를 작성하려는 사용자를 위한 원형(prototype)으로 사용할 수도 있다.

Error.prototype 객체는 다음 프로퍼티를 가진다.

  • constructor - 인스턴스의 프로토타입을 담당하는 생성자 함수
  • message - 오류 메시지
  • name - 오류 이름

이것들은 표준 프로퍼티이며, 환경에 따라서 특수한 프로퍼티를 가지고 있다. 노드, 파이어폭스, 크롬, 엣지, 익스플로러 10+, 오페라, 사파리 6+와 같은 환경에서 stack 프로퍼티를 가진다. stack 프로퍼티는 오류의 스택 트레이스를 포함한다. 오류의 스택 트레이스는 자체 생성자 함수가 될 때까지 모든 스택 프레임들을 포함한다.

Error 객체의 특수한 프로퍼티에 대해 더 알고 싶다면 MDN 아티클을 읽어보길 추천한다.

오류를 발생시키려면 throw 키워드를 사용해야 한다. 발생된 오류를 잡아내기 위해서 catch 블록 전에 try 블록으로 오류를 발생하는 코드를 감싸야 한다. catch는 발생한 오류를 인자로 사용한다.

또한 자바와 자바스크립트에서는 try 블록이 오류를 발생하는지 여부에 관계 없이 try/catch 블록 뒤에 finally 블록을 가질 수 있다. 오류가 발생한 다음을 정리하기 위해 finally를 사용하는 것이 좋다.

지금까지는 사람들이 대부분 알고 있는 내용이므로 좀 더 자세히 살펴보자.

try 블록 다음에 catch가 없어도 되지만, finally는 꼭 따라야 한다. try문은 3가지 형태로 사용할 수 있다.

  • try...catch
  • try...finally
  • try...catch...finally

다음과 같이 try문은 내부에서 다른 try문을 중첩해서 사용할 수 있다.

try {
    try {
        throw new Error('Nested error.'); // The error thrown here will be caught by its own `catch` clause
    } catch (nestedErr) {
        console.log('Nested catch'); // This runs
    }
} catch (err) {
    console.log('This will not run.');
}

또한 catchfinally 블록에서도 try문을 중첩해서 사용할 수 있다.

try {
    throw new Error('First error');
} catch (err) {
    console.log('First catch running');
    try {
        throw new Error('Second error');
    } catch (nestedErr) {
        console.log('Second catch running.');
    }
}
try {
    console.log('The try block is running...');
} finally {
    try {
        throw new Error('Error inside finally.');
    } catch (err) {
        console.log('Caught an error inside the finally block.');
    }
}

Error 객체가 아닌 값도 발생시킬 수 있는데 알아두는 것이 중요하다. 굉장히 관대하게 보일수도 있지만 실제로는 그렇지 않다. 특히 다른 사람의 코드를 처리해야 하는 라이브러리(예: Chai) 개발자들에게 좋지 않다. 표준이 없기 때문에 사용자로부터 무엇을 기대할지 알 수 없기 때문이다. Error 객체 대신 단순히 문자열이나 숫자를 발생시킬 수 있기 때문에 발생된 Error 객체를 신뢰할 수 없다. 또한 스택 트레이스와 의미있는 메타 데이터를 처리해야 하는 경우 어려워진다.

다음 코드가 있다고 가정해 보자.

function runWithoutThrowing(func) {
    try {
        func();
    } catch (e) {
        console.log('There was an error, but I will not throw it.');
        console.log('The error\'s message was: ' + e.message)
    }
}

function funcThatThrowsError() {
    throw new TypeError('I am a TypeError.');
}

runWithoutThrowing(funcThatThrowsError);

이 코드는 사용자가 runWithoutThrowing 함수에 Error 객체를 던지는 함수를 전달할 때 유용하다. 하지만 만약에 문자열 값을 던지면 문제가 생길 수 있다.

function runWithoutThrowing(func) {
    try {
        func();
    } catch (e) {
        console.log('There was an error, but I will not throw it.');
        console.log('The error\'s message was: ' + e.message)
    }
}

function funcThatThrowsString() {
    throw 'I am a String.';
}

runWithoutThrowing(funcThatThrowsString);

두 번째 console.log에서 오류 메시지(e.message)를 undefined로 보여줄 것이다. 이것이 그다지 중요하지 않게 보일 수도 있다. 하지만 Error 객체에 특정 프로퍼티가 존재하는지 또는 이 프로퍼티를 다른 방식(예: Chai의 throws 어설션)으로 다루어야 하는지 필요한 경우, 이것이 잘 작동하는지 확인하기 위한 더 많은 작업이 필요하게 된다.

또한 Error 객체가 아닌 값을 발생하면 stack 프로퍼티와 같은 다른 중요한 데이터에 접근할 수 없다.

Error는 다른 객체로도 사용될 수 있다. 반드시 던져질 필요는 없다. 따라서 아래 fs.readdir 함수와 같이 콜백 함수의 첫 번째 인자로 여러 번 사용된다.

fs.readdir('/example/i-do-not-exist', function callback(err, dirs) {
    if (err instanceof Error) {
        // `readdir` will throw an error because that directory does not exist
        // We will now be able to use the error object passed by it in our callback function
        console.log('Error Message: ' + err.message);
        console.log('See? We can use Errors without using try statements.');
    } else {
        console.log(dirs);
    }
});

Error 객체는 프라미스를 거부할 때(reject 함수 실행) 사용할 수도 있다. 이것은 프라미스 리젝션을 다룰 때 편리하다.

new Promise(function(resolve, reject) {
    reject(new Error('The promise was rejected.'));
}).then(function() {
    console.log('I am an error.');
}).catch(function(err) {
    if (err instanceof Error) {
        console.log('The promise was rejected with an error.');
        console.log('Error Message: ' + err.message);
    }
});

스택 트레이스 조작

이번에는 모두가 기다린 스택 트레이스 조작 방법을 다루려고 한다.

이 장은 노드JS와 같은 Error.captureStackTrace를 지원하는 환경 전용이다.

Error.captureStackTrace 함수는 첫 번째 인자로 object와 두 번째 인자로 function을 선택적으로 넘긴다. 스택 트레이스를 캡처하는 것은 현재의 스택 트레이스를 캡처하고 대상 객체 안에 stack 프로퍼티를 만들어 저장한다. 만약에 두 번째 인자가 있으면 인자로 전달된 함수는 호출 스택의 끝점으로 간주되어, 스택 트레이스는 콜백 함수가 호출되기 전에 발생한 호출만 표시할 것이다.

좀 더 명확하게 하기 위해 몇 가지 예를 보자. 먼저 현재 스택 트레이스를 캡처하여 일반 객체(myObj)에 저장한다.

const myObj = {};

function c() {
}

function b() {
    // Here we will store the current stack trace into myObj
    Error.captureStackTrace(myObj);
    c();
}

function a() {
    b();
}

// First we will call these functions
a();

// Now let's see what is the stack trace stored into myObj.stack
console.log(myObj.stack);

// This will print the following stack to the console:
//    at b (repl:3:7) <-- Since it was called inside B, the B call is the last entry in the stack
//    at a (repl:2:1)
//    at repl:1:1 <-- Node internals below this line
//    at realRunInThisContextScript (vm.js:22:35)
//    at sigintHandlersWrap (vm.js:98:12)
//    at ContextifyScript.Script.runInThisContext (vm.js:24:12)
//    at REPLServer.defaultEval (repl.js:313:29)
//    at bound (domain.js:280:14)
//    at REPLServer.runBound [as eval] (domain.js:293:12)
//    at REPLServer.onLine (repl.js:513:10)

먼저 스택에 최초로 푸시 된 a를 호출한 다음 a 내부에서 함수 b를 호출하여 a의 맨 위에 푸시했다. 그리고 b 안에서 현재 스택 트레이스를 캡처하여 myObj에 저장한다. 이것이 콘솔로 출력한 스택에서 함수 ab만 얻는 이유이다.

이번에는 Error.captureStackTrace 함수에 두 번째 인자로 함수를 전달하고 어떤 일이 발생하는지 살펴보자.

const myObj = {};

function d() {
    // Here we will store the current stack trace into myObj
    // This time we will hide all the frames after `b` and `b` itself
    Error.captureStackTrace(myObj, b);
}

function c() {
    d();
}

function b() {
    c();
}

function a() {
    b();
}

// First we will call these functions
a();

// Now let's see what is the stack trace stored into myObj.stack
console.log(myObj.stack);

// This will print the following stack to the console:
//    at a (repl:2:1) <-- As you can see here we only get frames before `b` was called
//    at repl:1:1 <-- Node internals below this line
//    at realRunInThisContextScript (vm.js:22:35)
//    at sigintHandlersWrap (vm.js:98:12)
//    at ContextifyScript.Script.runInThisContext (vm.js:24:12)
//    at REPLServer.defaultEval (repl.js:313:29)
//    at bound (domain.js:280:14)
//    at REPLServer.runBound [as eval] (domain.js:293:12)
//    at REPLServer.onLine (repl.js:513:10)
//    at emitOne (events.js:101:20)

Error.captureStackTraceFunction에 함수 b를 전달하면 b와 그 위의 모든 프레임들을 숨긴다. 이것이 스택 트레이스에 a만 가지는 이유이다.

아마도 당신은 "왜 이것이 유용한가?"라고 물을 것이다. 이는 사용자와 관련되지 않은 내부 구현 내용을 숨기려고 할 때 유용하다. 예를 들어 Chai는 검사 및 어설션을 구현하는 방식에 대해 사용자에게 관련 없는 세부 정보가 표시되지 않도록 이것을 사용한다.

실제 스택 트레이스 조작

마지막 섹션에서 언급했듯이 Chai는 스택 조작 방법을 사용해 사용자와 관련된 스택 트레이스를 사용자와 관련성 있게 만든다. 어떻게 하는지 여기 방법이 있다.

먼저 어설션이 실패했을 때 던져지는 AssertionError 생성자를 보자.

// `ssfi` stands for "start stack function". It is the reference to the
// starting point for removing irrelevant frames from the stack trace
function AssertionError (message, _props, ssf) {
  var extend = exclude('name', 'message', 'stack', 'constructor', 'toJSON')
    , props = extend(_props || {});

  // Default values
  this.message = message || 'Unspecified AssertionError';
  this.showDiff = false;

  // Copy from properties
  for (var key in props) {
    this[key] = props[key];
  }

  // Here is what is relevant for us:
  // If a start stack function was provided we capture the current stack trace and pass
  // it to the `captureStackTrace` function so we can remove frames that come after it
  ssf = ssf || arguments.callee;
  if (ssf && Error.captureStackTrace) {
    Error.captureStackTrace(this, ssf);
  } else {
    // If no start stack function was provided we just use the original stack property
    try {
      throw new Error();
    } catch(e) {
      this.stack = e.stack;
    }
  }
}

위에서 볼 수 있듯이 스택 트레이스를 캡처하고 빌드하고 있는 AssertionError의 인스턴스를 저장하기 위해 Error.captureStackTrace를 사용한다. 스택 트레이스에서 Chai의 내부 구현 내용을 보여주고 스택을 지저분하게 만드는 관계 없는 프레임을 제거하기 위해 시작 스택 함수(Start Stack Function, ssf에 해당)를 전달한다.

이제 @meeber멋진 PR를 살펴보자.

아래 코드를 보기 전에 addChainableMethod가 하는 일이 무엇인지 알려주려고 한다. 이 함수는 전달된 연결 가능한 메서드를 어설션에 추가하고, 어설션을 감싸는 메서드를 사용해 어설션 자체에 표시를 한다. 이것은 ssfi(Start Stack Function Indicator)라는 이름으로 저장된다. 기본적으로 현재 어설션이 스택의 마지막 프레임이 될 것이므로 스택에서 더 이상 Chai의 내부 메서드를 보여주지 않는다. 전체 코드는 양도 많고 까다롭기 때문에 추가하지 않지만 읽어보길 원한다면 링크를 참조하라.

아래 코드에 lengthOf 어설션에 대한 로직이 있다. lengthOf는 객체가 어떤 length 값을 가졌는지 검사한다. 우리는 사용자들이 expect(['foo', 'bar']).to.have.lengthOf(2)와 같이 사용하기를 기대한다.

function assertLength (n, msg) {
    if (msg) flag(this, 'message', msg);
    var obj = flag(this, 'object')
        , ssfi = flag(this, 'ssfi');

    // Pay close attention to this line
    new Assertion(obj, msg, ssfi, true).to.have.property('length');
    var len = obj.length;

    // This line is also relevant
    this.assert(
            len == n
        , 'expected #{this} to have a length of #{exp} but got #{act}'
        , 'expected #{this} to not have a length of #{act}'
        , n
        , len
    );
}

Assertion.addChainableMethod('lengthOf', assertLength, assertLengthChain);

위에 코드에서 우리와 관련된 행을 강조했다. this.assert를 호출해 보자.

다음 코드는 this.assert 메서드를 구현한 코드이다.

Assertion.prototype.assert = function (expr, msg, negateMsg, expected, _actual, showDiff) {
    var ok = util.test(this, arguments);
    if (false !== showDiff) showDiff = true;
    if (undefined === expected && undefined === _actual) showDiff = false;
    if (true !== config.showDiff) showDiff = false;

    if (!ok) {
        msg = util.getMessage(this, arguments);
        var actual = util.getActual(this, arguments);

        // This is the relevant line for us
        throw new AssertionError(msg, {
                actual: actual
            , expected: expected
            , showDiff: showDiff
        }, (config.includeStack) ? this.assert : flag(this, 'ssfi'));
    }
};

기본적으로, assert 메서드는 어설션이 boolean 표현식을 통과했는지 아닌지 여부를 확인한다. 통과하지 못한다면 AssertionError 생성자를 인스턴스화 해야 한다. 새로운 AssertionError를 인스턴스화 할 때 스택 트레이스 함수(ssfi)를 넘기는 것에 주목하라. 만약 설정 플래그 값인 includeStack 값이 true를 반환한다면, this.assert 자체를 스택에 전달하여 전체 스택 트레이스를 표시한다. 이것은 실제로 스택의 마지막 프레임에 해당된다. 그러나 includeStack 설정 플래그 값이 false를 반한하면 스택 트레이스의 내부 구현 내용을 숨겨야 한다. 그래서 ssfi 플래그에 저장된 값을 사용한다.

지금부터 우리와 관련된 다른 줄에 대해서 이야기해 보자.

new Assertion(obj, msg, ssfi, true).to.have.property('length');

이 코드에서 중첩 어설션을 생성할 때 ssfi 플래그에서 가져온 내용을 넘긴다. 즉, 새 어설션이 만들어지면 이 함수를 스택 트레이스에서 불필요한 프레임을 제거하기 위한 시작점으로 사용한다. 다음은 Assertion 생성자이다.

function Assertion (obj, msg, ssfi, lockSsfi) {
    // This is the line that matters to us
    flag(this, 'ssfi', ssfi || Assertion);
    flag(this, 'lockSsfi', lockSsfi);
    flag(this, 'object', obj);
    flag(this, 'message', msg);

    return util.proxify(this);
}

addChainableMethod에 대해 말한 것을 기억할 수 있을 것이다. 이 메서드는 ssfi 플래그 값을 자체 래퍼 메서드로 설정한다. 스택 트레이스에서 가장 낮은 내부 프레임으로써 위에 있는 모든 프레임을 제거할 수 있다.

객체가 length 프로퍼티를 가지는지 검사하는 중첩 어설션에 ssfi를 던지면 프레임을 다시 설정하지 않아 시작점 표시기로 사용하고, 이전 addChainableMethod을 스택에 표시할 수 있다.

이것은 조금 복잡해 보일수도 있다. Chai 내부에서 어떤 일이 일어나는지 리뷰해보고 스택에서 불필요한 프레임을 제거해보자.

  1. 어설션을 실행시킬 때 스택에서 다음 프레임을 제거하기 위한 참조로써 addChainableMethod 메서드를 세팅할 수 있다.
  2. 어설션이 실행되고 만약에 실패하면 저장하고 있는 참조 이후의 모든 내부 프레임을 제거한다.
  3. 만약에 중첩 어설션이 있다면, 스택의 다음 프레임을 제거하기 위한 참조 지점으로 래퍼 메서드를 계속 사용해야 한다. 그래서 현재 ssfi를 생성 중인 어설션에 넘겨 보존할 수 있다.

이 내용을 이해하기 위해서 @meeber의 코멘트를 읽어보길 추천한다.

류선임2017.03.06
Back to list