참고 글: https://www.haydenbleasel.com/blog/on-javascript-errors
🧐 서론
에러처리 방식. 과연 뭐가 더 좋을까?
👀 본론
Try / Catch
기존 전통적인 에러처리방식은 try ... catch 내부에 에러를 throw해서 처리.
try {
const data = await fetchData(); // might throw if fetch fails
renderData(data);
} catch (err) {
console.error("Something went wrong:", err);
showErrorToUser("Oops, couldn't load data!");
}
Bubbles
JS에서 오류는 try/catch 블록에 의해 포착되거나 버블링되어 전역 범위에 도달함.
function C() {
throw new Error("Something went wrong");
}
function B() {
C();
}
function A() {
B();
}
A();
여기서 C 함수에서 처리되지 않은 오류는 A 함수까지 버블링된다. 이 시점에서 JS는 처리되지 않은 예외를 발생시키면서 추가실행을 중단하거나 콘솔에 경고를 기록함.
→ 이 메커니즘 덕분에 오류를 중앙에서 포착해 내부 함수를 깔끔하게 유지할 수 있지만, 예상치 못한 지점에서 발생한 오류를 처리하지 못하면 의도한 것보다 멀리 오류가 전파될 수 있음.
ParseError
JS에선 Error로 모든 타입을 throw할 수 있다. catch (err)에선 문자열, 객체, undefined까지 대비를 해야 하는 상황.
export const parseError = (error: unknown) => {
if (typeof error === 'string') {
return error;
}
if (error instanceof Error) {
return error.message;
}
return 'An error occurred';
};
위 코드에선 오류는 unknown으로 받아서 문자열을 반환함. (any는 사실상 타입검사를 비활성화해서 as로 입력함). 어떠한 타입으로 error가 들어와도 사람이 읽을 수 있는 메시지 형태로 제공.
import { toast } from 'sonner';
import { parseError } from './parse';
export const handleError = (title: string, error: unknown) => {
const description = parseError(error);
toast.error(title, { description });
};
// test.ts
import { handleError } from '@/lib/error/handle';
try {
throw new Error('Something went wrong');
} catch (err) {
handleError('Something went wrong', err);
}
// ...
fetch('https://api.example.com/data')
.then((response) => response.json())
.then(console.log)
.catch(handleError);
더 간단하게 처리된 로직. 로깅을 잊어버릴 걱정 없고, JSON.stringify 객체를 복잡하게 만들 필요도 없고, 실수로 [Object object]를 사용자에게 표시할 필요도 없음.
🗣️ Try/Catch 만 사용하세요
try/catch 만으로도 충분히 더 복잡한 추상화가 필요없다고 생각함. AxiosError, SupabaseError 같은 프록시 오류 유형 처리까지 확장됨. 이 같은 확장 오류 유형이 제공하는 특정 상태 코드나 오류 속성을 확인할 수 있다.
또한 Sentry 같은 로깅 서비스를 이용해 에러를 포착해서 중앙으로 보내기 쉬운 구조. 일부 개발자들은 대부분의 함수에서 오류를 포착하지 않고 상위 수준 오류 경계(ex: 상위 React 컴포넌트나 Express 오류처리 미들웨어)를 사용하는 것을 선호.
단점은 매번 promise에서 발생한 오류를 catch 하는 것.
Neverthrow
인기있는 제안 중 한 라이브러리 neverthrow는 오류를 발생시키는 대신 Ok(성공값 포함) 또는 Err(오류 값 포함)를 반환함. railway oriented programming 또는 Either/Result 패턴이라고 불림.
예를 들어 오류 발생 시 함수는 T 대신 Result<T, E>를 반환한다.
const result = getUserById(id);
if (result.isErr()) {
// handle result.error
} else {
// use result.value
}
위 같은 접근방식을 지지하는 사람들은 명시적이고 타입이 안전하다는 점을 선호함.
이 같은 라이브러리는 결과를 래핑하고 언래핑해야 하는데, try/catch의 암묵적인 흐름에 비해 소규모 프로젝트에선 과할 수 있음.
Effect
Effect는 함수가 오류를 발생시키지 않는 대신 Effect<E, A> 타입을 반환. 이 타입은 ‘E’ 오류로 실패하거나 ‘A’ 값으로 성공할 수 있는 작업을 의미하며 라이브러리 런타임이 이를 바탕으로 오류를 처리함.
Effect는 복잡한 작업(자동 재시도, 타임아웃 있는 URL 가져오기 등) 시, 발생 가능한 모든 오류(타임아웃, HTTP 오류 등)를 타입으로 처리. 새로운 관용구와 코드 구조화 방식 도입 때문에 라이브러리보단 프레임워크에 더 가까움. 시간만 투자한다면 코드베이스를 더 쉽고 안전하게 관리할 수 있다는 점이 있지만 과도한 기술이라는 평이 있음.
| 구분 | Try/Catch | Neverthrow | Effect |
|---|---|---|---|
| 핵심 개념 | 표준 JS 오류 처리. throw/catch로 예외 흐름 제어 | Result<Ok | Err>로 Railway Oriented Programming 패턴 적용 | Effect<E, A> 타입으로 오류/성공 분리, 런타임이 처리 |
| 장점 | 간단하고 직관적, 프록시 오류 타입 처리 가능, 로깅 연계 용이 | 타입 안전성 보장, 오류 흐름이 명시적, 선언적 | 타입 안전성 극대화, 복잡한 작업(재시도/타임아웃 등) 효과적 관리 |
| 단점 | Promise 체인에서 매번 catch 필요, 암묵적 흐름 | 소규모 프로젝트에선 과할 수 있음, 래핑/언래핑 번거로움 | 학습 곡선 높음, 새로운 관용구 필요, 무거운 기술로 평가됨 |
| 사용 적합 | 간단한 프로젝트 / 기존 JS/TS 코드와 자연스러운 통합 | 중/대규모 프로젝트에서 명시적 오류 처리 필요할 때 | 복잡한 비즈니스 로직/재시도/타임아웃 등 장애 허용성이 중요한 서비스 |
| 예시 | try { await f() } catch(e) {} | const result = fn(); if (result.isErr()) { ... } | Effect.try(...) 또는 pipe(Effect, ...) 패턴 |
예시
✅ 1) Try/Catch
import axios from 'axios';
async function getUserById(id: string) {
try {
const response = await axios.get(`/api/users/${id}`);
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
console.error('Axios Error:', error.response?.status, error.message);
} else {
console.error('Unknown Error:', error);
}
// 예: Sentry에 로깅
// Sentry.captureException(error);
throw error; // 상위로 전달
}
}
async function main() {
try {
const user = await getUserById('123');
console.log(user);
} catch (error) {
console.error('Failed to get user:', error);
}
}
📌 표준 오류 흐름. AxiosError 같은 프록시 타입 처리. 중앙 로깅(Sentry 등)과 잘 어울림.
✅ 2) Neverthrow
import { Result, ok, err } from 'neverthrow';
import axios from 'axios';
type User = { id: string; name: string };
async function getUserById(id: string): Promise<Result<User, Error>> {
try {
const response = await axios.get(`/api/users/${id}`);
return ok(response.data);
} catch (error) {
return err(new Error('Failed to fetch user'));
}
}
async function main() {
const result = await getUserById('123');
if (result.isErr()) {
console.error('Error:', result.error.message);
return;
}
console.log('User:', result.value);
}
📌 Result<Ok | Err> 타입으로 오류가 명시적. isErr()로 분기. throw 없이 Railway Oriented 흐름.
✅ 3) Effect
import { Effect, pipe } from 'effect';
import axios from 'axios';
type User = { id: string; name: string };
const getUserById = (id: string): Effect.Effect<Error, User> =>
Effect.tryPromise({
try: () => axios.get(`/api/users/${id}`).then((res) => res.data),
catch: (e) => new Error('Failed to fetch user'),
});
const program = pipe(
getUserById('123'),
Effect.match({
onSuccess: (user) => {
console.log('User:', user);
},
onFailure: (error) => {
console.error('Error:', error.message);
},
}),
);
Effect.runPromise(program);
📌 Effect<E, A>로 실패/성공 분리. pipe로 선언형 조합 가능. 재시도/타임아웃 같은 고급 제어도 가능.
👇 결론
- 기본부터 시작:
try/catch를 올바른 위치에서 사용하고 있는지 확인. 단순히 에러를 로그에 남기기만 해도 아무것도 안 하는 것보다는 낫다. - 프로젝트에 맞는 규칙 정하기: “
catch블록에서는 항상handleError를 사용한다”, “문자열을 그대로 throw하지 않고 반드시Error객체만 throw한다”, “서비스 레이어에서는 반드시 이Result타입을 사용한다” 같은 일관성을 고려한 간단한 규칙이라도 정하기. - 라이브러리도 고려해볼 것:
neverthrow나Effect같은 것도 살펴보기.