August 07, 2025
기존에 try catch 문으로 에러를 처리하던 방식을 벗어나 타입스크립트로 에러를 처리해봤습니다. 에러 타입이 명확해지니 코드가 훨씬 명확해졌습니다.
에러 핸들링 전략을 수립하기 전에, 에러를 두 가지 주요 유형으로 구분하는 것이 매우 유용했습니다.
어떤 에러가 예상된 에러이고 예상치 못한 에러인지는 애플리케이션의 도메인과 비즈니스 로직에 따라 달라질 수 있습니다.
try...catch
와 throw
의 기본과 한계JavaScript에서 에러를 발생시키고 처리하는 가장 기본적인 방법은 throw
와 try...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
의 한계throw
와 try...catch
는 간단하지만 몇 가지 한계가 있습니다.
throw
가 발생하면 코드의 제어 흐름이 예측하기 어렵게 “점프”합니다. 코드를 읽는 사람이 throw
문을 만났을 때 해당 에러가 어디서 처리될지 (또는 처리될지 여부) 파악하기 어렵습니다.unknown
타입 에러 문제를 해결하고 에러를 더 구조적으로 관리하기 위해 커스텀 에러 클래스를 정의하는 디자인 패턴이 유용합니다.
커스텀 에러 타입 생성: 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) // 상속 체인 유지
}
}
커스텀 에러 던지기: 새로운 커스텀 에러를 인스턴스화할 때, 정의된 유니온 타입 내에서 name
값을 선택할 수 있습니다.
function createProject(userId: string, projectName: string) {
if (getUserProjectCount(userId) >= MAX_PROJECT_LIMIT) {
throw new ProjectError(
'PROJECT_LIMIT_REACHED',
'프로젝트 생성 한도에 도달했습니다.'
)
}
// ... 프로젝트 생성 로직
}
커스텀 에러 잡기: 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)
}
}
재사용 가능한 에러 베이스 만들기: 여러 도메인에서 커스텀 에러 클래스를 사용해야 할 경우, 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> {}
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 절에서는 여전히 예상치 못한 에러를 처리할 수 있어서 컨텍스트를 분리해서 생각할 수 있었습니다.