[TYPESCRIPT] 에러 처리 설계 전략: 에러를 던지지 않고 관리하는 TypeScript 실무 기준

 

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 하면 코드는 더 읽기 쉬워지고, 운영은 더 예측 가능해집니다. 결국 안정적인 서비스는 에러를 숨기지 않고, 다룰 수 있는 형태로 만드는 데서 시작합니다.