타입스크립트(TypeScript)로 에러 핸들링하기

기존에 try catch 문으로 에러를 처리하던 방식을 벗어나 타입스크립트로 에러를 처리해봤습니다. 에러 타입이 명확해지니 코드가 훨씬 명확해졌습니다.

1. 에러의 종류: 예상된 비즈니스 에러 vs. 예상치 못한 에러

에러 핸들링 전략을 수립하기 전에, 에러를 두 가지 주요 유형으로 구분하는 것이 매우 유용했습니다.

  • 예상된 비즈니스 에러 (Expected Business Errors): 시스템의 정상적인 운영 과정에서 발생할 수 있는 에러입니다. 예를 들어, 사용자 등록 시 이미 존재하는 사용자 이름을 입력하거나, 유효하지 않은 입력 값을 제출하는 경우 등이 있습니다. 이러한 에러는 사용자에게 명확한 피드백을 제공하고, 경우에 따라 사용자가 직접 문제를 해결할 수 있도록 안내해야 합니다.
  • 예상치 못한 에러 (Unexpected Errors): 시스템의 정상적인 작동 중에는 발생하지 않을 것으로 예상되는 에러입니다. 데이터베이스 연결 실패, 서버 내부 로직 오류, 외부 API 호출 실패 등이 여기에 해당합니다. 이러한 에러는 일반적으로 복구하기 어렵고, 개발자에게 알림을 보내고 로그를 기록하는 것이 주된 처리 방법이 됩니다.

어떤 에러가 예상된 에러이고 예상치 못한 에러인지는 애플리케이션의 도메인과 비즈니스 로직에 따라 달라질 수 있습니다.

2. 전통적인 try...catchthrow의 기본과 한계

JavaScript에서 에러를 발생시키고 처리하는 가장 기본적인 방법은 throwtry...catch 문입니다.

// 에러 발생시키기 (throw)
function getUser(id: string) {
  const user = findUserById(id)
  if (!user) {
    throw new Error(`User with id ${id} not found`) // 수동으로 에러 발생
  }
  return user
}

// 에러 잡기 (try...catch)
try {
  const user = getUser('123')
  console.log(user.name)
} catch (error) {
  console.error('An error occurred:', error)
}

unknown 타입 에러와 타입 좁히기

TypeScript에서 catch 블록의 error 변수는 기본적으로 unknown 타입입니다. 이는 JavaScript에서 문자열, 숫자, 객체 등 어떤 것이든 throw될 수 있기 때문입니다. unknown 타입의 에러를 다루기 위해서는 해당 에러의 타입을 명확히 좁혀야 합니다. instanceof 연산자를 사용하여 에러의 타입을 좁힐 수 있습니다.

try {
  // ...
} catch (error) {
  if (error instanceof Error) {
    // Error 인스턴스인지 확인하여 타입 좁히기
    console.error('Error message:', error.message)
  } else {
    console.error('An unknown error occurred:', error)
  }
}

throw의 한계

throwtry...catch는 간단하지만 몇 가지 한계가 있습니다.

  1. 모든 가능한 에러에 대한 지식 요구: 함수가 어떤 에러를 던질 수 있는지 파악하려면 해당 함수와 그 함수가 호출하는 모든 함수를 검사해야 합니다. 이는 문서화가 최신 상태로 유지되지 않으면 파악하기 어렵습니다.
  2. 제어 흐름의 불연속성: throw가 발생하면 코드의 제어 흐름이 예측하기 어렵게 “점프”합니다. 코드를 읽는 사람이 throw 문을 만났을 때 해당 에러가 어디서 처리될지 (또는 처리될지 여부) 파악하기 어렵습니다.

3. 객체 지향적 접근: 커스텀 에러 클래스 활용

unknown 타입 에러 문제를 해결하고 에러를 더 구조적으로 관리하기 위해 커스텀 에러 클래스를 정의하는 디자인 패턴이 유용합니다.

  1. 커스텀 에러 타입 생성: Error 객체를 확장하는 클래스를 정의합니다. 이 클래스는 스택 트레이스를 포함할 수 있게 해줍니다. 에러 이름에 대한 유니온 타입을 유지하여 타입 안전성과 자동 완성을 제공할 수 있습니다.

    // errors.ts
    type ProjectErrorName = 'PROJECT_LIMIT_REACHED' | 'PROJECT_NOT_FOUND'
    
    export class ProjectError extends Error {
      constructor(public name: ProjectErrorName, message?: string) {
        super(message)
        Object.setPrototypeOf(this, ProjectError.prototype) // 상속 체인 유지
      }
    }
  2. 커스텀 에러 던지기: 새로운 커스텀 에러를 인스턴스화할 때, 정의된 유니온 타입 내에서 name 값을 선택할 수 있습니다.

    function createProject(userId: string, projectName: string) {
      if (getUserProjectCount(userId) >= MAX_PROJECT_LIMIT) {
        throw new ProjectError(
          'PROJECT_LIMIT_REACHED',
          '프로젝트 생성 한도에 도달했습니다.'
        )
      }
      // ... 프로젝트 생성 로직
    }
  3. 커스텀 에러 잡기: catch 블록에서 instanceof를 사용하여 커스텀 에러 타입을 좁힐 수 있습니다. 에러가 좁혀지면 error.name을 통해 특정 에러에 대한 로직을 수행할 수 있습니다.

    try {
      createProject('user123', 'My New Project')
    } catch (error) {
      if (error instanceof ProjectError) {
        if (error.name === 'PROJECT_LIMIT_REACHED') {
          console.error('사용자에게 메시지 표시:', error.message)
        } else if (error.name === 'PROJECT_NOT_FOUND') {
          console.error('프로젝트를 찾을 수 없습니다.')
        }
      } else if (error instanceof Error) {
        console.error('일반 에러:', error.message)
      } else {
        console.error('알 수 없는 에러:', error)
      }
    }
  4. 재사용 가능한 에러 베이스 만들기: 여러 도메인에서 커스텀 에러 클래스를 사용해야 할 경우, DRY(Don’t Repeat Yourself) 원칙을 위해 제네릭을 사용하는 ErrorBase 클래스를 만들 수 있습니다.

    // base-error.ts
    export class ErrorBase<T extends string> extends Error {
      constructor(public name: T, message?: string) {
        super(message)
        Object.setPrototypeOf(this, new.target.prototype)
      }
    }
    
    // project-errors.ts
    type ProjectErrorName = 'PROJECT_LIMIT_REACHED' | 'PROJECT_NOT_FOUND'
    export class ProjectError extends ErrorBase<ProjectErrorName> {}

4. 함수형 접근: Result 타입 패턴

throw 방식의 한계를 극복하기 위해 함수형 프로그래밍에서 영감을 받은 Result 타입을 사용하는 방법이 있습니다. 이 접근 방식은 함수가 에러를 발생시킬 수 있다는 사실을 반환 타입에 명시적으로 인코딩합니다.

Result<T, E> 타입의 개념

Result 타입은 성공적인 값(T) 또는 에러 값(E) 중 하나를 포함할 수 있는 공용체(discriminated union)입니다.

type Result<T, E> =
  | { result: 'success'; value: T }
  | { result: 'error'; error: E }

function createUser(newUser: NewUser): Result<User, Error> {
  if (Math.random() > 0.5) {
    return { result: 'success', value: newUser }
  } else {
    return { result: 'error', error: new Error('Username already taken') }
  }
}

// 호출 코드
const userResult = createUser({ username: 'hunter2' })

if (userResult.result === 'error') {
  console.error(`Failed to create user: ${userResult.error.message}`)
} else {
  console.log(`User created: ${userResult.value.username}`)
}

이 방식은 함수가 반환할 수 있는 모든 에러를 명시적으로 선언하므로, 호출하는 쪽에서 어떤 에러를 처리해야 하는지 쉽게 알 수 있습니다. 또한, 제어 흐름이 순차적으로 진행되어 코드 가독성이 향상됩니다.

함수형 접근을 하니 예상된 비즈니스 에러를 핸들링하기 수월해졌고 catch 절에서는 여전히 예상치 못한 에러를 처리할 수 있어서 컨텍스트를 분리해서 생각할 수 있었습니다.


Written by@Donghoon Song
사람들의 꿈을 이어주는 코멘토에서 일하고 있습니다.

InstagramGitHubTwitterLinkedIn