Next.js와 TypeScript 프로젝트를 운영하다 보면, “에러는 어디서 던지고, 어디서 잡아야 하는가”라는 질문을 계속 마주하게 됩니다. 처음에는 try/catch로 충분해 보이지만, 기능이 늘어나고 팀원이 늘수록 에러 처리 방식은 금방 일관성을 잃습니다.
실무에서 자주 겪는 상황은 다음과 같습니다.
- 같은 에러인데 화면마다 메시지가 다르다
- 어떤 에러는 throw 되고, 어떤 에러는 값으로 반환된다
- 운영 로그에는 에러가 있는데, 사용자는 이유를 알 수 없다
- 사소한 실패가 전파되어 화면 전체가 깨진다
이 글에서는 “에러를 던지지 말자”가 아니라, 던져야 할 에러와 관리해야 할 에러를 구분하는 기준을 정리합니다.
- 에러를 제어 흐름으로 다루는 이유
- TypeScript에서 에러 타입을 설계하는 방식
- 사용자 메시지와 운영 로그를 분리하는 기준
- Next.js 환경에서 에러 경계를 어디까지 둘 것인지
개념/배경 설명
에러는 크게 두 종류로 나눌 수 있습니다.
- 예상 가능한 실패(비즈니스/정책/검증 실패)
- 예상 불가능한 실패(버그, 인프라 장애)
문제는 이 두 가지를 같은 방식으로 처리할 때 발생합니다. 예상 가능한 실패까지 throw 해버리면, 에러는 제어 흐름이 아니라 “폭탄”이 됩니다.
실무에서는 다음 원칙이 도움이 됩니다.
- 예상 가능한 실패는 값으로 반환한다
- 정상 복구가 불가능한 상황만 throw 한다
이렇게 구분하면, 에러는 더 이상 화면을 깨는 요소가 아니라 의사결정에 쓰이는 신호가 됩니다.
핵심 설계 1: Result 타입으로 에러를 제어 흐름에 포함시킨다
가장 기본적인 패턴은 성공/실패를 명시적으로 표현하는 Result 타입입니다.
// src/shared/types/result.ts
export type Result<T, E> =
| { ok: true; data: T }
| { ok: false; error: E };
이 타입의 핵심은 “실패를 무시할 수 없게 만드는 것”입니다. 호출부에서는 반드시 분기 처리를 하게 됩니다.
const result = await updateProfile(input);
if (!result.ok) {
return showError(result.error);
}
render(result.data);
실무 포인트 정리
- 예상 가능한 실패는 throw 하지 않는다
- Result 타입은 실패 처리를 강제한다
- UI에서의 예외 누락을 구조적으로 줄일 수 있다
핵심 설계 2: 에러 타입을 문자열이 아니라 “의미”로 정의한다
에러 메시지를 문자열로만 다루면, 시간이 지날수록 분기 처리가 불가능해집니다.
export type AuthError =
| { type: 'UNAUTHORIZED' }
| { type: 'FORBIDDEN' }
| { type: 'SESSION_EXPIRED' }
| { type: 'VALIDATION_ERROR'; field: string };
이렇게 정의하면, UI와 서버 모두 “의미”를 기준으로 대응할 수 있습니다.
if (!result.ok) {
switch (result.error.type) {
case 'SESSION_EXPIRED':
redirectToLogin();
return;
case 'VALIDATION_ERROR':
highlightField(result.error.field);
return;
}
}
실무 포인트 정리
- 에러 메시지는 나중에 붙인다
- 분기 기준은 항상 타입이다
- 국제화(i18n)에도 유리하다
핵심 설계 3: 사용자 메시지와 운영 로그를 분리한다
운영에서 가장 위험한 패턴은 “사용자에게 보여줄 메시지 = 로그 메시지”인 경우입니다.
실무 기준은 다음과 같습니다.
- 사용자 메시지: 짧고, 행동을 안내
- 운영 로그: 원인, 맥락, 식별자 포함
function toUserMessage(error: AuthError): string {
switch (error.type) {
case 'SESSION_EXPIRED':
return '보안을 위해 다시 로그인해 주세요.';
case 'FORBIDDEN':
return '권한이 없습니다.';
default:
return '요청을 처리할 수 없습니다.';
}
}
반대로 운영 로그에는 다음 정보가 필요합니다.
- 에러 타입
- userId, sessionId
- 요청 경로, 입력 요약
- stack trace(throw 된 경우)
핵심 설계 4: Next.js에서 throw 해야 하는 경계
Next.js에서는 모든 에러를 Result로 처리할 수는 없습니다. 다음 경우에는 throw가 더 적절합니다.
- 절대 복구 불가능한 서버 상태
- 프로그래밍 오류(null 접근, invariant 깨짐)
- 에러 페이지로 즉시 전환해야 하는 경우
이런 경우는 명확히 실패시키고, Error Boundary 또는 error.tsx에 맡기는 것이 맞습니다.
if (!config) {
throw new Error('Critical config missing');
}
실무 포인트 정리
- throw는 “시스템 이상”에만 사용한다
- 비즈니스 실패는 값으로 반환한다
- Error Boundary는 최후의 안전망이다
운영/실무에서 자주 겪는 문제
- 모든 실패를 throw 해서 화면이 자주 깨지는 문제
- 에러 문자열 비교로 분기하다가 조건이 누락되는 문제
- 로그에는 정보가 많은데, 사용자는 이유를 모르는 문제
- 팀원마다 다른 에러 처리 스타일로 코드가 섞이는 문제
실무 권장 체크리스트
- 예상 가능한 실패를 Result 타입으로 처리하고 있는가
- 에러 타입이 문자열이 아닌 의미 기반으로 정의되어 있는가
- 사용자 메시지와 운영 로그가 분리되어 있는가
- throw 해야 할 경계가 명확히 정해져 있는가
- 에러 처리 규칙이 팀 내에서 공유되어 있는가
에러 처리는 “예외를 잘 잡는 기술”이 아니라 실패를 어떻게 다룰 것인지에 대한 설계입니다.
예상 가능한 실패를 제어 흐름으로 끌어들이고, 정말 위험한 상황만 throw 하면 코드는 더 읽기 쉬워지고, 운영은 더 예측 가능해집니다. 결국 안정적인 서비스는 에러를 숨기지 않고, 다룰 수 있는 형태로 만드는 데서 시작합니다.
