toggle menu

[NodeJS] try/catch는 모든 에러를 잡을 수 있을까?

2015. 8. 17. 11:30 Node.js

들어가며


node.js에서의 오류 처리에 대한 글을 공유받아 읽으며 테스트해보고 알게 된 점들을 정리해서 포스팅한다.

try/catch는 모든 에러를 잡을 수 있을까? 이제부터 하나하나 파헤쳐보자.




동기 코드(Synchronous code)


일반적으로 동기적인 코드의 경우, 발생한 에러는 아래와 같이 try/catch 로 핸들링 해줄 수 있다.


try {
	throw new Error('오류 핸들링 테스트');
}
catch (exception) {
	console.log(exception);
}


돌려보면, 정상적으로 오류를 catch에서 잡아서 exception 내용을 출력해주는 걸 볼 수 있다.


[Error: 오류 핸들링 테스트]





비동기 코드(Asynchronous code)


문제는 비동기적인 코드에서 나타난다. 비동기적인 코드가 포함되어 있을 경우, 위와 같이 try/catch로 감싸주면 정상적으로 에러를 핸들링할 수 있을까?

단순하게 생각할 때, 상위에서 감싸고 있는 try/catch 에서 내부에 포함된 비동기 코드의 오류도 처리해줄 것 같지만 실제로는 그렇지 않다.


try {
	setTimeout(function () {
		throw new Error('오류 핸들링 테스트');
	}, 2000);
}
catch (exception) {
	console.log(exception);
}


위의 코드를 돌려보면, 아래와 같이 오류를 핸들링하지 못하고 process 가 죽게 된다.


throw new Error('오류 핸들링 테스트');
	  ^
Error: 오류 핸들링 테스트
at null._onTimeout (/home/user/projects/errorhandling/test.js:13:15)
at Timer.listOnTimeout (timers.js:119:15)



이 문제를 해결하기 위한 가장 단순한 접근은, 비동기 코드 내부에서 다시 try/catch로 감싸서 에러를 핸들링하는 것이다.


try {
	setTimeout(function () {
		try {
			throw new Error('오류 핸들링 테스트');
		}
		catch (exception) {
			console.log(exception);
		}
	}, 2000);
}
catch (exception) {
	console.log(exception);
}


돌려보면, 정상적으로 오류를 catch에서 잡아서 exception 내용을 출력해주는 걸 볼 수 있다.


[Error: 오류 핸들링 테스트]





module 에서 발생한 에러에 대한 접근


그런데 만약 내가 컨트럴하지 않는 영역, 즉 다른 모듈에서 발생한 에러는 어떨까?

대부분의 경우 우리는 npm 에 등록된 모듈들을 소스레벨까지 확인한 후 사용하기보다는 버전과 누적된 사용자수 정도만 파악하고 사용할 때가 많다. 다른 많은 이들이 만든 모듈들을 사용해서 하나의 어플리케이션을 만드는 것이 흔한 node.js 의 특성을 생각할 때 내가 만들지 않은 모듈에서 오류가 발생하는 상황은 반드시 생기게 마련이다. 이런 경우에는 별다른 대응 방안이 없는걸까?


아래의 코드는 그러한 상황을 묘사하고 있다.


//모듈(say.js)
exports.hello = function () {
    setTimeout(function () {
        throw new Error('오류 핸들링 테스트');
    }, 2000);

    console.log('Hello! I\'m module!');
};


//메인(index.js)
var say = require('./say.js');

try {
	say.hello();
}
catch (exception) {
	console.log(exception);
}


위의 코드를 돌려보면, 콘솔은 출력하지만 결국 아래와 같이 오류를 핸들링하지 못하고 process 가 죽게 된다.


Hello! I'm module!

/home/user/projects/errorhandling/say.js:3
        throw new Error('오류 핸들링 테스트');
              ^
Error: 오류 핸들링 테스트
    at null._onTimeout (/home/user/projects/errorhandling/say.js:3:15)
    at Timer.listOnTimeout [as ontimeout] (timers.js:121:15)



다른 모듈에서 단지 에러를 throw 하기만 한다면 모듈을 사용하는 입장에서 try/catch로 처리할 수 없기 때문에 결국 프로세스가 죽을 수 밖에 없는걸까?


process 에는 예상치 못한 오류에 대한 적절한 처리를 위해 아래와 같이 uncaughtException 이벤트를 가지고 있다.


//모듈(say.js)
exports.hello = function () {
    setTimeout(function () {
        throw new Error('오류 핸들링 테스트');
    }, 2000);

    console.log('Hello! I\'m module!');
};


//메인(index.js) process.on('uncaughtException', function (err) { //예상치 못한 예외 처리 console.log('uncaughtException 발생 : ' + err); }); var say = require('./say.js'); try { say.hello(); } catch (exception) { console.log(exception); }


위의 코드를 돌려보면, 아래와 같이 적어도 아무런 대응없이 허무하게 프로세스가 종료되는 것만큼은 막아줄 수 있다.


Hello! I'm module!
uncaughtException 발생 : Error: 오류 핸들링 테스트



그렇다면, 컨트럴할 수있는 영역(내가 코딩하는 영역)은 최대한 try/catch로 감싸고, 모듈에서 발생하는 오류는 프로세스의 uncaughtException 을 잡아서 대응없이 죽는 것만 막는 것이 최선일까?


node.js 문서의 uncaughtException 항목을 살펴보면, uncaughtException 을 사용하는 것은 조악한 예외처리 메커니즘이기 때문에 사용을 자제하도록 권장하고 있다. 특히 발생한 에러에 대해 복구한 뒤 다시 다음 단계로 진행하는 용도로 uncaughtException를 사용하지 말고, 발생했다면 application을 재시작하도록 가이드하고 있는데, uncaughtException 발생했다면 application 이 규정되지 않은 상태에 있다는 것 뿐 아니라 node.js 자체도 어떤 상태에 있는지 확신하기 어렵기 때문이다.


process의 uncaughtException 이벤트를 통해 예외 처리를 하는 것의 한계와 위험성은 felixge 가 제기한 issue에 잘 나타나 있다.


이를 대신해 node.js 에서 권장하고 있는 도메인(domain)을 통해 컨텍스트를 유지하면서 uncaughtException 을 다룰 수 있는 방법을 살펴보자.




도메인(Domain)


node.js 문서에서 도메인에 대해 설명하고 있는 내용을 인용하면 아래와 같다.


도메인은 하나의 그룹으로 여러 가지 다른 IO 작업을 다루는 방법을 제공한다. 도메인에 등록된 이벤트 이미터나 콜백 등에서 error 이벤트가 발생하거나 오류를 던졌을 때 process.on('uncaughtException')에서 오류의 컨텍스트를 잃어버리거나 오류 코드로 프로그램이 즉시 종료되는 대신 도메인 객체가 이를 인지할 수 있다.

(http://nodejs.sideeffect.kr/docs/v0.10.35/api/domain.html)


앞서 살펴본 uncaughtException 이벤트를 통해서 에러를 다루었던 것을 도메인을 활용해서 다루는 예제로 바꾸어보면 아래와 같다.


//모듈(say.js)
exports.hello = function () {
    setTimeout(function () {
        throw new Error('오류 핸들링 테스트');
    }, 2000);

    console.log('Hello! I\'m module!');
};


//메인(index.js)
var domain = require('domain').create();
var say = require('./say.js');

//도메인 내에서 발생한 에러는 여기에서 핸들링한다.
domain.on('error', function (err) {
	console.log('에러 발생 : ' + err);
});

//run 메서드를 통해 domain context 내에서 로직이 실행되게 할 수 있다.
domain.run(function () {
	try {
		say.hello();
	}
	catch (exception) {
		console.log(exception);
	}
});


위의 코드를 돌려보면, uncaughtException 이벤트를 통해 처리한 것과 같이 오류가 발생한 것을 적절하게 대응 후에 종료시키는 것을 볼 수 있다.


Hello! I'm module!
uncaughtException 발생 : Error: 오류 핸들링 테스트



도메인을 생성한 뒤, 도메인 내부에서 로직을 실행하고 도메인 내부에서 발생한 오류는 모두 domain context 내에서 다루어지는 것이다.


하지만 도메인을 이렇게 사용하는 것은 바람직하지 않다. process 에서 uncaughtException 이벤트를 잡는 것과 크게 다르지 않기 때문이다. 클러스터와 조합해서 domain context와 프로그램을 워커 프로세스로 분리하면 훨씬 더 안전하게 오류를 다룰 수 있다.



//모듈(say.js)
exports.hello = function () {
    setTimeout(function () {
        throw new Error('오류 핸들링 테스트');
    }, 2000);

    console.log('Hello! I\'m module!');
};


//메인(index.js) var cluster = require('cluster'); //마스터 if (cluster.isMaster) { //production 에서는 이보다 많은 갯수의 워커를 생성할 것이다. cluster.fork(); //워커가 죽은 경우, cluster.on('disconnect', function (worker) { //죽은 워커 정보 표시 console.error('[worker disconnected] ' + worker.id); //다시 생성한다. cluster.fork(); console.log('[new worker forked!]'); }); } //워커 else { var domain = require('domain').create(); var say = require('./say.js'); domain.on('error', function (err) { //에러가 발생하면 워커를 죽이기 전에 적절한 종료 프로세스를 수행한다. try { //3초 이내에 종료되었는지 확인한다 var killtimer = setTimeout(function() { process.exit(1); }, 3000); //하지만 setTimeout을 현재 process와 독립적으로 동작하도록 레퍼런스 제거 killtimer.unref(); //워커가 죽은 것을 마스터에게 알린다. cluster.worker.disconnect(); //적절한 처리 console.log('에러 발생 : ' + err); } catch (exception) { //여기서 할 수 있는 일은 많지 않다. console.error('Error!!!', exception.stack); } }); domain.run(function () { try { say.hello(); } catch (exception) { console.log(exception); } }); }


위의 코드를 실행해보면, 마스터에서 워커를 생성하고 워커 내부에서는 도메인 내에서 로직을 실행함으로써 워커에서 치명적인 오류가 발생해도 적절하게 대응한 후에 워커를 종료시키는 것을 볼 수 있다. 즉, 문제가 생긴 워커가 적절한 프로세스에 따라 종료되면 마스터는 새로운 워커를 생성해서 오류를 복구하는 구조인 것이다.


Hello! I'm module!
에러 발생 : Error: 오류 핸들링 테스트
[worker disconnected] 1
[new worker forked!]
Hello! I'm module!
에러 발생 : Error: 오류 핸들링 테스트
[worker disconnected] 2
[new worker forked!]
Hello! I'm module!
....





암묵적 바인딩과 명시적 바인딩(Implicit Binding and Explicit Binding)


도메인을 사용하면, 도메인 컨텍스트 내에서 새로 생성되는 모든 EventEmitter 객체(스트림 객체, 요청, 응답 등)는 암묵적으로 활성화된 도메인에 바인딩된다. 따라서 도메인 안에서 콜백의 콜백에서 에러가 나더라도 도메인에서 해당 에러를 컨트럴할 수 있다. 게다가 node.js 에서 제공하는 fs.open 등의 콜백을 받는 메서드들은 자동으로 활성화된 도메인에 바인딩되기 때문에 이런 메서드에서 예외가 발생하면 도메인에서 error 를 처리할 수 있다.


하지만 종종 이미 도메인에 바인딩된 EventEmitter 를 바인딩하지 않거나 다른 도메인에 바인딩해야하는 경우도 생긴다. 이럴 때에는 명시적으로 바인딩을 해주거나 제거해주어야 한다.


아래의 예는 node.js 문서에서 제공하고 있는 예이다. 아래의 예와 같이, HTTP 서버에서 사용 중인 도메인이 있지만 요청마다 다른 도메인을 사용하길 원할 수 있다.


//서버에 대한 최상위 도메인을 생성
var serverDomain = domain.create();

serverDomain.run(function() {

	//http서버는 serverDomain에 암묵적으로 바인딩된다.
	http.createServer(function(req, res) {

		//req와 res도 serverDomain의 범위내에서 생성되어 암묵적으로 serverDomain에 바인딩되지만,
		//요청마다 다른 도메인을 사용하길 원한다면 먼저 새로운 도메인을 생성한후,
		var requestDomain = domain.create();

		//아래와 같이 새로운 도메인에 명시적으로 바인딩해주면 된다.
		requestDomain.add(req);
		requestDomain.add(res);

		//요청마다 발생한 오류는 requestDomain 컨텍스트에서 처리된다.
		requestDomain.on('error', function(er) {
			console.error('Error', er, req.url);

			try {
				res.writeHead(500);
				res.end('Error occurred, sorry.');
			}

			catch (er) {
				console.error('Error sending 500', er, req.url);
			}
		});
	}).listen(1337);
});


위에서 명시적 바인딩을 위해 domain.add 를 사용한 것처럼 반대로 암묵적 바인딩을 제거할 때는 domain.remove 를 통해 제거해줄 수 있다.


도메인에는 바인딩을 위한 몇 가지 메서드가 더 존재하는데, 전달한 콜백함수를 감싸고 있는 랩퍼 함수를 반환하는 domain.bind 와, domain.bind 와 거의 유사하지만 오류를 잡기 위해 함수의 첫 아규먼트로 보낸 Error객체를 가로채는 domain.intercept 가 있다.






마치며


지금까지 try/catch는 모든 에러를 잡을 수 있을까 라는 질문으로 시작해서 동기 처리에는 try/catch로 오류를 잡을 수 있지만, 비동기 처리에서는 비동기 로직마다 try/catch를 걸어야 한다는 것과, process 의 uncaughtException 이벤트의 사용은 신중해야한다는 것과, context 내에서 발생하는 모든 에러들을 편리하게 처리 가능하게 해주는 domain까지, 비동기 코드에서 오류 처리 방법에 대해 살펴보았다.


현재로서는 domain 을 사용해서 unhandled exception 을 다루는 것이 가장 나은 방법으로 보인다. 하지만 너무 domain에 의지하지 않는 것이 좋을 것 같다. domain으로 감싸서 그 내부에서 에러를 다루는 것은 사용자 입장에서는 편리하지만 domain 때문에 node core가 너무 복잡해지는 문제가 있어서 현재 domain API 는 IO.js에서는 soft deprecated 상태이고 node.js 에서도 곧 deprecated 될 것으로 보이기 때문이다. 이에 관련된 논의는 node.js issue 를 참고하면 된다. 아마도 IO.js 에서는 domain API를 대체할만한 것을 준비하고 있는 것으로 보이는데 현재까지 알려진 내용은 많지 않다. domain을 대체하는 API가 공개된다면 관련된 내용을 정리해서 포스팅하도록 하겠다.


이 글이 node.js application 의 예외처리에 대해 조금이라도 도움이 되었기를 기대한다.



참조

https://nodejs.org/api/domain.html

http://nodeqa.com/nodejs_ref/1

http://nodeqa.com/nodejs_ref/19

http://nodeqa.com/nodejs_ref/106

https://gist.github.com/hueniverse/5167027

Node.js 관련 포스팅 더보기