0. 들어가며…
예외 처리를 하지 않은 서비스는 사용자에게 흰 화면을 제공하거나 예상치 못한 동작을 유발시킨다.
그래서 외부 API나 네트워크 요청 시에는 에러 처리가 필수적이다. 그럼 가능한 모든 함수에서 에러 처리를 하는 게 좋을까?
물론 에러 처리를 하면 예외 케이스를 커버할 수는 있지만, 무분별한 에러 핸들링은 오히려 성능에 악영향을 미칠 수 있다.
이번 시간에는 V8 엔진 기준으로 throw의 동작 원리와 비용을 분석하고, 언제 예외 처리를 사용해야 하는지 살펴보았다.
1. throw는 어떻게 동작할까?
1) throw 동작원리 살펴보기
“1. throw는 어떻게 동작할까”는 JavaScript 엔진(V8) 공식 문서를 토대로 작성하였다.
throw문이 실행되면 에러 메시지, 이름, 스택 트레이스 정보 등을 수집한다.- 여기서 스택 트레이스(Stack Trace)란 예외가 발생한 지점까지의 함수 호출 경로를 나열한 정보이다:
Error: 예외 발생
at throwCatch (app.js:10:15)
at main (app.js:20:5)
at Object.<anonymous> (index.js:5:1)
...
- 구체적인 동작 과정은 다음 함수를 토대로 살펴보자!
function throwCatch() {
try {
throw new Error("예외 발생");
} catch (e) {
// 예외 처리
}
}
1단계: Error 객체 생성과 스택 트레이스 수집
1-1. 메모리 할당
new Error("예외 발생")이 실행되면 힙(heap) 메모리에 Error 객체를 위한 공간이 할당된다.
{
message: "예외 발생",
name: "Error",
stack: "..." // 스택 트레이스 정보
}
2-1. 스택 정보 수집
- Error 객체가 생성되는 시점의 콜 스택을 탐색하여
throwCatch함수와 이를 호출한 모든 상위 함수들의 정보를 기록한다. - 이때, 수집하는 콜 스택 정보는
Error.stackTraceLimit(기본값: 10개)만큼 수집한다. - 만약 스택 트레이스 개수를 변경하고 싶다면
Error.stackTraceLimit을 수정하면 된다.
// 스택 트레이스 제한 설정 예시
Error.stackTraceLimit = 5; // 5개로 제한
Error.stackTraceLimit = Infinity; // 무제한 (성능 주의)
- 이렇게 수집한 콜 스택 정보는 성능 최적화를 위해,
error.stack접근하기 전까지 문자열로 포맷팅하지 않는다. (Lazy Formatting)
2단계: throw 실행과 제어 흐름 중단
- Error 객체가 생성된 후
throw문이 실행되면, 현재 실행 중인 코드가 그 즉시 중단된다. - 그리고 throw 문 이후의 코드는 실행되지 않는다.
3단계: catch 블록 검색 및 실행
예외가 발생하면 JavaScript 엔진은 다음 순서로 예외를 처리한다:
- 가장 가까운 catch 블록 찾기: 현재 try 블록에 연결된 catch 블록이 있는지 확인
- catch 블록으로 이동: catch 블록을 찾으면 실행 흐름을 해당 블록으로 바꾸고, Error 객체를 catch의 매개변수(e)에 전달
- 상위로 올라가며 검색: 만약 catch 블록이 없다면, 함수 호출 스택을 한 단계씩 올라가며 다른 catch 블록을 찾음
- 예외 처리 코드 실행: catch 블록 안의 예외 처리 코드를 실행
우리는 v8 엔진을 기준으로 throw가 어떻게 작동하는지 확인했다.
그럼 이제 이 throw와 성능과의 관계를 살펴보면서 왜 throw를 무작정 사용하면 안되는지 살펴보자
2) throw와 성능과의 상관관계는?
(1) try...catch 블록 자체는 성능에 거의 영향을 주지 않는다.
예외가 발생하지 않는 try...catch 블록은 거의 성능 비용이 없다. 예외가 없으면 다음과 같은 과정들이 모두 생략되기 때문이다:
- 에러 객체 생성
- 스택 트레이스 수집
- 제어 흐름 중단
- catch 블록 검색
(2) throw 실행 시 발생하는 성능 비용은?
반면 실제로 throw가 실행되면 다음 3단계의 작업들이 연쇄적으로 발생한다:
1단계: Error 객체 생성 → 2단계: 제어 흐름 중단 → 3단계: catch 블록 검색
각 단계별로 발생하는 구체적인 성능 비용을 살펴보면:
- 📍 1단계: Error 객체 생성과 스택 트레이스 수집
- 스택 트레이스 수집은 콜 스택의 깊이에 비례하여 처리 비용이 증가하는 연산이다.
- 함수 호출이 깊어질수록 더 많은 정보를 수집해야 하므로 성능 부담이 커진다.
- 📍 2단계: 제어 흐름 중단
- 엔진이 최적화해 둔 일반적인 실행 경로(happy path)에서 벗어나 추가 오버헤드가 발생한다.
- 정상적인 코드 흐름을 강제로 중단시키는 과정에서 성능 비용이 든다.
- 📍 3단계: catch 블록 검색 및 실행
- 호출 스택을 역추적하여 예외 핸들러를 찾는 과정에서 성능 비용이 발생한다.
(3) 성능 벤치마크 실험
- 실제 성능 차이를 확인하기 위해 다음 코드로 벤치마크를 진행했다:
function noTryCatchLoop() {
for (let i = 0; i < 1e6; i++) {
// 아무 작업도 하지 않음
}
}
function tryCatchNoThrowLoop() {
for (let i = 0; i < 1e6; i++) {
try {
// 예외가 발생하지 않음
} catch (e) {
// 실행되지 않음
}
}
}
function throwCatchLoop() {
for (let i = 0; i < 1e6; i++) {
try {
throw new Error("예외 발생");
} catch (e) {
// 예외 처리
}
}
}
// 성능 측정
console.time("noTryCatch");
noTryCatchLoop();
console.timeEnd("noTryCatch");
console.time("tryCatchNoThrow");
tryCatchNoThrowLoop();
console.timeEnd("tryCatchNoThrow");
console.time("throwCatch");
throwCatchLoop();
console.timeEnd("throwCatch");
- 측정 결과는 다음과 같았다.
| 함수 | 측정 결과 |
|---|---|
| noTryCatch | 1.355ms |
| tryCatchNoThrow | 0.999ms |
| throwCatch | 2.039s |
try...catch블록 자체는 예외가 발생하지 않으면 성능에 거의 영향 없었다.- 그에 비해
throw new Error()실행은 예외 객체 생성, 스택 정보 캡처, 스택 언와인딩 등으로 인한 상당한 성능 비용 발생했다. - 결국, 예외 처리는 안정적인 서비스를 위해 필수적이지만, 무분별한 사용은 성능 저하를 초래할 수 있다.
그럼 어떤 상황에서 예외 처리를 해야 안정성과 성능을 모두 확보할 수 있을까?
2. throw를 사용해야 할 때/아닐 때
- 앞서 설명했듯이
throw는 비용이 크지만 특정 상황에서는 반드시 필요하며 유용하다. - 핵심은 예측 불가능하거나 애플리케이션의 정상적인 흐름을 방해하는 "진짜 예외적인" 상황에 사용하는 것이다.
- throw를 사용해야할 때/아닐 때를 간단히 살펴보자.
1) throw를 사용해야 하는 상황
(1) 외부 API 및 네트워크 요청 처리
- 외부 서비스와 통신할 때는 네트워크 장애, 서버 오류 등 예측할 수 없는 문제들이 발생할 수 있다.
async function fetchUserData(userId) {
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
const errorMessage = await response.text().catch(() => '응답 읽기 실패');
throw new Error(
`사용자 데이터 조회 실패: ${response.status} (${response.statusText}). ${errorMessage}`
);
}
return await response.json();
} catch (error) {
if (error instanceof TypeError) {
console.error('네트워크 연결 오류:', error.message);
} else if (error instanceof SyntaxError) {
console.error('응답 데이터 파싱 오류:', error.message);
} else {
console.error('사용자 데이터 조회 오류:', error.message);
}
// 적절한 폴백 처리
return null;
}
}
(2) 파일 시스템 및 데이터베이스 작업
- I/O 작업은 파일 접근 권한, 디스크 공간 부족 등 다양한 문제가 발생할 수 있다.
import { promises as fs } from 'fs';
async function loadConfiguration(configPath) {
try {
const configText = await fs.readFile(configPath, 'utf8');
const config = JSON.parse(configText);
// 설정 파일 검증
if (!config.appName || !config.version) {
throw new Error('필수 설정 정보가 누락되었습니다');
}
return config;
} catch (error) {
switch (error.code) {
case 'ENOENT':
console.error(`설정 파일을 찾을 수 없습니다: ${configPath}`);
break;
case 'EACCES':
console.error(`설정 파일 접근 권한이 없습니다: ${configPath}`);
break;
default:
if (error instanceof SyntaxError) {
console.error('설정 파일 JSON 형식 오류:', error.message);
} else {
console.error('설정 파일 로드 실패:', error.message);
}
}
// 기본 설정으로 폴백
return { appName: 'MyApp', version: '1.0.0' };
}
}
(3) 서드파티 라이브러리 안전 장치
- 외부 라이브러리는 예상치 못한 방식으로 실패할 수 있으므로 방어적 프로그래밍이 필요하다.
import externalLib from 'some-third-party-library';
function processWithExternalLib(data) {
try {
return externalLib.process(data);
} catch (error) {
console.warn('외부 라이브러리 처리 실패, 안전 모드로 전환:', error.message);
// 내장 로직으로 폴백
return processDataSafely(data);
}
}
function processDataSafely(data) {
// 안전한 내장 로직 구현
return data ? data.toString().toUpperCase() : '';
}
(4) 데이터 파싱과 검증
- 외부 데이터를 파싱할 때는 형식 오류나 데이터 무결성 문제가 발생할 수 있다.
class DataValidationError extends Error {
constructor(field, value) {
super(`데이터 검증 실패 - ${field}: ${value}`);
this.name = 'DataValidationError';
this.field = field;
this.value = value;
}
}
function parseAndValidateUser(jsonString) {
try {
const userData = JSON.parse(jsonString);
// 필수 필드 검증
const requiredFields = ['id', 'email', 'name'];
for (const field of requiredFields) {
if (!userData[field]) {
throw new DataValidationError(field, userData[field]);
}
}
// 이메일 형식 검증
if (!/\S+@\S+\.\S+/.test(userData.email)) {
throw new DataValidationError('email', userData.email);
}
return userData;
} catch (error) {
if (error instanceof SyntaxError) {
console.error('잘못된 JSON 형식:', error.message);
} else if (error instanceof DataValidationError) {
console.error('데이터 검증 오류:', error.message);
} else {
console.error('사용자 데이터 파싱 오류:', error.message);
}
return null;
}
}
2) throw을 사용하지 않아도 되는 상황
(1) 사용자 입력 검증
사용자 입력은 예측 가능한 범위 내에서 검증할 수 있으므로, 조건문을 사용하는 것이 더 효율적이다.
// ❌ 비효율적 - 예외 처리 남용
function validateAgeWithException(age) {
try {
if (typeof age !== 'number') throw new Error('숫자가 아님');
if (age < 0) throw new Error('음수');
if (age > 150) throw new Error('너무 큼');
return { valid: true };
} catch (error) {
return { valid: false, message: error.message };
}
}
// ✅ 효율적 - 조건부 검증
function validateAge(age) {
if (typeof age !== 'number') {
return { valid: false, message: '나이는 숫자여야 합니다' };
}
if (age < 0) {
return { valid: false, message: '나이는 0 이상이어야 합니다' };
}
if (age > 150) {
return { valid: false, message: '유효하지 않은 나이입니다' };
}
return { valid: true };
}
// 사용법
const ageValidation = validateAge(25);
if (!ageValidation.valid) {
console.log(ageValidation.message);
return;
}
(2) 안전한 객체 속성 접근
- 옵셔널 체이닝을 사용하면 예외 없이 중첩된 속성에 접근할 수 있다.
// ❌ 비효율적
function getUserNameWithException(user) {
try {
return user.profile.personal.name;
} catch {
return '이름 없음';
}
}
// ✅ 효율적
function getUserName(user) {
return user?.profile?.personal?.name ?? '이름 없음';
}
// 더 상세한 정보가 필요한 경우
function getUserNameDetailed(user) {
if (!user) return { name: '이름 없음', reason: '사용자 정보 없음' };
if (!user.profile) return { name: '이름 없음', reason: '프로필 없음' };
if (!user.profile.personal) return { name: '이름 없음', reason: '개인정보 없음' };
return { name: user.profile.personal.name || '이름 없음', reason: null };
}
(3) 반복문 처리
- 반복문에서는 사전 검사를 통해 예외 발생을 최소화해야 한다.
// ❌ 성능 저하 - 반복문 내 예외 처리
function processItemsInefficient(items) {
const results = [];
for (const item of items) {
try {
if (!item.active) throw new Error('비활성 아이템');
if (!item.data) throw new Error('데이터 없음');
results.push(transformItem(item));
} catch (error) {
// 에러 정보 손실
continue;
}
}
return results;
}
// ✅ 성능 최적화 - 사전 검사
function processItemsEfficient(items) {
const results = [];
const errors = [];
for (const item of items) {
// 예측 가능한 조건들을 사전에 검사
if (!item) {
errors.push({ item: null, reason: 'null 아이템' });
continue;
}
if (!item.active) {
errors.push({ itemId: item.id, reason: '비활성 아이템' });
continue;
}
if (!item.data) {
errors.push({ itemId: item.id, reason: '데이터 누락' });
continue;
}
// 정말 예외가 발생할 수 있는 부분만 try-catch
try {
const transformed = transformItem(item);
results.push(transformed);
} catch (error) {
errors.push({
itemId: item.id,
reason: '변환 실패',
error: error.message
});
}
}
return { results, errors };
}
(4) 타입 변환
- JavaScript의 내장 함수들은 예외를 던지지 않는 경우가 많으므로, 불필요한 try-catch는 피해야 한다.
// ❌ 불필요한 예외 처리
function parseNumberWithException(str) {
try {
const num = Number(str);
if (isNaN(num)) throw new Error('숫자 변환 실패');
return num;
} catch {
return null;
}
}
// ✅ 직접적인 검사
function parseNumber(str) {
const num = Number(str);
return isNaN(num) ? null : num;
}
3. 마치며…
- JavaScript에서 예외 처리는 안정적인 서비스를 위한 필수 요소이지만, 그 비용을 이해하고 사용해야 한다.
- 핵심은 "진짜 예외적인 상황"에만
throw를 사용하는 것이다. - 외부 API 호출, 파일 시스템 접근, 서드파티 라이브러리 사용처럼 우리가 제어할 수 없는 영역에서는 예외 처리가 필수적이다.
- 반면 사용자 입력 검증이나 타입 체크처럼 예측 가능한 상황에서는 조건문을 사용하는 것이 훨씬 효율적이다.
- 벤치마크 결과에서 확인했듯이,
throw의 성능 비용은 생각보다 크다. 하지만 이것이 예외 처리를 피해야 한다는 의미는 아니다. - 오히려 적재적소에 사용하여 사용자 경험을 보호하면서도 성능을 최적화하는 것이 중요하다.
- 만약 예외 처리를 해야 한다면, 다음 체크리스트를 통해 판단해봐도 좋을 듯 하다.
- 이 상황이 정말 "예외적"인가? - 정상적인 프로그램 흐름에서 예측할 수 없는 상황인지 확인
- 조건문으로 해결 가능한가? - 단순한 if문으로 처리할 수 있다면 예외 처리 대신 조건문 사용
- 반복문 안에서 발생하는가? - 반복문 내부라면 사전 검증을 통해 예외 발생 최소화
- 적절한 폴백 전략이 있는가? - 예외 발생 시 사용자에게 의미 있는 대안 제공 가능 여부 확인
- 성능에 미치는 영향을 고려했는가? - 호출 빈도가 높은 함수라면 더욱 신중하게 판단
반응형
'개발 기술 > 사소하지만 놓치기 쉬운 개발 지식' 카테고리의 다른 글
| [JS] Array.map() vs Iterator Helper API: 어떤 방식이 더 빠를까? (2) | 2025.07.21 |
|---|---|
| [CSS] scrollIntoView를 사용하면 바운싱되는 이유(with. position의 차이) (0) | 2025.06.22 |
| [React] onBlur/onFocus가 버블링되는 이유와 PrimeReact 메뉴 버그 해결기 (0) | 2025.05.11 |
| [JS] Delete vs Backspace: 키 이벤트 처리 시 주의사항 (with. OTP 입력기) (2) | 2025.03.29 |
| CSS interpolate-size 기반 아코디언, JS보다 빠를까? 실측 성능 테스트 (4) | 2025.03.15 |
댓글