[TYPESCRIPT] 에러 타입을 나누기 시작하면 코드에서 달라지는 것들

프로젝트가 어느 정도 커지면, 에러를 문자열이나 단일 Error로 처리하는 방식이 점점 불편해집니다. 로그를 봐도 원인을 바로 알기 어렵고, 호출부에서는 에러 내용을 파싱해서 분기해야 하는 상황이 늘어납니다.

 

이 시점에서 자연스럽게 에러 타입을 나누기 시작합니다. 도메인 에러, 인프라 에러, 인증 에러처럼 이름을 붙이고, 각각을 타입이나 클래스로 표현합니다.

 

처음에는 코드가 훨씬 정돈된 것처럼 보입니다. 하지만 일정 시점이 지나면, 에러 타입을 나눈 대가가 서서히 드러나기 시작합니다.

 

에러를 나누기 전의 상태

초기에는 보통 이런 코드로 시작합니다.

 


async function findUser(id: string) {
  const user = await repo.find(id);
  if (!user) {
    throw new Error('USER_NOT_FOUND');
  }
  return user;
}

 

에러 메시지는 거칠지만, 흐름은 단순합니다. 문제가 생기면 예외를 던지고, 상위에서 한 번에 처리합니다.

 

에러 타입을 만들기 시작한 시점

에러를 조금 더 명확하게 다루고 싶어지면, 다음과 같은 코드가 등장합니다.

 


class UserNotFoundError extends Error {
  constructor(userId: string) {
    super(`user not found: ${userId}`);
  }
}

 


async function findUser(id: string) {
  const user = await repo.find(id);
  if (!user) {
    throw new UserNotFoundError(id);
  }
  return user;
}

 

이제 에러의 의도가 코드에 드러납니다. catch 구문에서도 타입으로 분기할 수 있습니다.

 

에러 타입이 늘어나면서 생기는 변화

에러 타입을 하나 만들기 시작하면, 비슷한 에러도 하나씩 늘어납니다.

  • UserNotFoundError
  • InvalidPasswordError
  • PermissionDeniedError
  • TokenExpiredError

 

이 자체가 문제는 아닙니다. 문제는 이 에러들이 어디까지 전파되는지입니다.

 

에러가 레이어를 넘어갈 때

서비스 레이어에서 정의한 에러가 컨트롤러까지 그대로 올라오는 구조는 처음에는 자연스럽게 느껴집니다.

 


try {
  await userService.findUser(id);
} catch (e) {
  if (e instanceof UserNotFoundError) {
    throw new NotFoundException();
  }
  throw e;
}

 

하지만 에러 타입이 늘어날수록, 이 분기 로직도 같이 늘어납니다. 컨트롤러는 점점 도메인 에러를 많이 알게 됩니다.

 

이쯤 되면, 레이어 간 경계가 조금씩 흐려집니다.

 

instanceof에 기대기 시작하면

에러 타입 분기는 보통 instanceof로 처리합니다. 로컬 환경에서는 잘 동작합니다.

 

하지만 에러가 다음 경계를 넘는 순간, 상황이 달라집니다.

  • 에러가 JSON으로 직렬화된 경우
  • 다른 프로세스나 워커에서 전달된 경우
  • 서드파티 라이브러리에서 던진 에러

 

이 경우 instanceof는 더 이상 기대한 대로 동작하지 않습니다. 결국 분기 로직이 빠지거나, 모두 알 수 없는 에러로 처리됩니다.

 

에러 타입이 많아질수록 생기는 또 다른 문제

에러 타입이 많아질수록, 에러를 던지는 쪽도 점점 부담이 커집니다.

 

이 상황에서 자주 보게 되는 코드가 있습니다.

 


throw new UserNotFoundError(id);
throw new PermissionDeniedError(userId);
throw new InvalidStateError(state);

 

에러를 던질 때마다 “이건 어떤 타입이어야 하지?”를 고민하게 됩니다. 이 고민은 결국 생산성에 영향을 줍니다.

 

에러를 값으로 다루기 시작하는 흐름

이런 문제를 겪다 보면, 일부 에러를 예외가 아니라 값으로 돌리는 선택을 하게 됩니다.

 


type Result<T, E> =
  | { ok: true; value: T }
  | { ok: false; error: E };

 

모든 에러를 이렇게 바꾸지는 않습니다. 다만 “예상 가능한 실패” 정도는 이 구조로 흡수되는 경우가 많습니다.

 

그 결과,

  • 에러 타입 수는 줄어들고
  • 분기 위치는 명확해지고
  • 예외는 정말 예외적인 상황에만 남게 됩니다

 

운영에서 드러나는 차이

에러 타입을 무작정 늘린 코드와, 경계를 기준으로 정리한 코드의 차이는 운영에서 더 분명해집니다.

 

로그를 봤을 때

  • 이 에러가 어디서 왔는지
  • 어떤 요청에서 반복되는지
  • 사용자 입력 때문인지 시스템 문제인지

 

이런 정보가 정리되어 있는지 여부가 장애 대응 속도에 직접적인 영향을 줍니다.

 


 

에러 타입을 나누는 일은 코드를 정리하는 시작점이 될 수 있습니다. 하지만 그 자체가 목표가 되면, 오히려 코드와 흐름을 복잡하게 만들기도 합니다.

 

어디까지 타입으로 표현할지, 어디부터 값이나 로그로 남길지는 프로젝트 규모와 팀 구성에 따라 달라집니다. 에러 타입을 늘리는 방향보다, 에러가 어떻게 흘러가고 어디서 처리되는지를 먼저 보는 쪽이 오래 유지되는 경우가 많았습니다.