원문 : 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
가 제거되고 a
와 b
만 있을 것이다.
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.');
}
또한 catch
와 finally
블록에서도 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
에 저장한다.
이것이 콘솔로 출력한 스택에서 함수 a
와 b
만 얻는 이유이다.
이번에는 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
에 해당)를 전달한다.
아래 코드를 보기 전에 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 내부에서 어떤 일이 일어나는지 리뷰해보고 스택에서 불필요한 프레임을 제거해보자.
addChainableMethod
메서드를 세팅할 수 있다.ssfi
를 생성 중인 어설션에 넘겨 보존할 수 있다.이 내용을 이해하기 위해서 @meeber의 코멘트를 읽어보길 추천한다.