FrontEnd/TypeScript

[TypeScript] 에러 처리

개발자 김비숑 2023. 6. 5. 14:59
반응형
  • 타입스크립트는 런타임에 발생할 수 있는 예외를 컴파일 타임에 잡기 위해 최선을 다한다. 
  • 그럼에도 런타임에 발생하는 네트워크 장애, 사용자 입력 파싱 에러, 스택 오버플로, 메모리 부족 에러와 같은 에러를 모두 막을 수는 없다. 
  • 타입스크립트는 타입 시스템을 이용해 이러한 런타임 에러를 표현하고 처리하는 패턴을 제공한다. 
    1.  null 반환
    2. 예외 던지기
    3. 예외 반환
    4. Option 타입
  • 이 중 어떤 에러 처리 기법을 사용할지는 프로그래머의 이도와 응용 프로그램의 종류에 따라 달라진다. 따라서 각각의 장단점을 잘 비교해 선택해야 한다. 

1. null 반환

  • (장점) 에러를 처리하는 가장 간단한 방법이다. 
    • 예를 들어, 사용자가 생일을 입력하는 프로그램이 있을 때 유효한 내용을 입력하면 Date가 반환되고, 아니면 null이 반환되도록 구현할 수 있다. 
  • (단점) 문제가 생긴 원인을 알 수 없고, 모든 연산에서 null을 확인해야 하므로 연산을 연결할 때 복잡해진다. 
// 1. null 반환하기 
function ask() {
  return prompt('When is your birthday?')
}

// 사용자의 입력을 받아 Date로 변환해서 반환 
function parse(birthday: string | null):Date | null {
  if (!birthday) return null
  let date = new Date(birthday)
  if (!isValid(date)) {
    return null
  }
  return date
}

function isValid(date: Date) {
  return Object.prototype.toString.call(date) === '[object Date]'
    && !Number.isNaN(date.getTime())
}

let date = parse(ask())
// date가 null인 경우 
if (date) {
  console.info(date.toISOString())
} else {
  console.error('Error parsing date for some reason')
}

2. 예외 던지기

  • 문제가 발생하면 null 반환 대신 예외를 던지는 것이 좋다. 
  • 각 상황에 해당하는 에러를 발생시키고, try-catch문을 통해 이를 처리한다.
  • (장점) 커스텀 에러를 이용하면 어떤 문제가 생겼는지와 문제가 생긴 원인을 알 수 있다.
// 2. 예외 던지기 
function parse2(birthday: string): Date { 
  let date = new Date(birthday) 
  if (!isValid(date)) {
    throw new RangeError('Enter a date in the form YYYY/MM/DD')
  }
  return date 
}

try {
  let date = parse2(ask())
  console.info(date.toISOString())
} catch(e) {
  if (e instanceof RangeError) {
    console.error(e.message)
  } else {
    throw e
  }
}

// 커스텀 에러 타입 
class InvalidDateFormatError extends RangeError {}
class DateIsInTheFutureError extends RangeError {}

function parse(birthday: string): Date {
  let date = new Date(birthday) 
  if (!isValid(date)) {
    throw new InvalidDateFormatError('Enter a date in the form YYYY/MM/DD')
  }

  if (date.getTime() > Date.now()) {
    throw new DateIsInTheFutureError('Are you a timelord?')
  }  
  return date
} 

// 사용 
try {
  let date = parse(ask())
  console.info(date.toISOString())
} catch (e) {
  if (e instanceof InvalidDateFormatError) {
    console.error(e.message)
  } else if (e instanceof DateIsInTheFutureError) {
    console.info(e.message)
  } else {
    throw e
  }
}

3. 예외 반환

  • 유니온 타입을 이용해 throws 문을 비슷하게 흉내낼 수 있다. throws문은 메서드가 어떤 종류의 런타임 예외를 발생시킬 수 있는지 알려주는 문이다. 
  • 각 상황에 해당하는 에러를 반환하고, 반환값을 기준으로 에러를 처리한다.  
  • (장점) 코드를 사용하는 개발자에게 성공과 에러 상황을 모두 처리하도록 알려줄 수 있다. 
  • 예를 들어 parse 함수는 birthday라는 string 타입의 매개변수를 받아
    1. 파싱에 성공할 경우 Date를 
    2. 유효하지 않은 Date인 경우 InvalidDateFormateError를 
    3. 현재 날짜보다 이후인 날짜를 입력한 경우 DateIsInTheFutureError를 반환한다. 

       그리고, 함수 사용자는 이 반환값을 기준으로 각각의 에러를 모두 처리하거나 다시 던져야 한다. 

// parse 함수에서 발생할 수 있는 예외를 나열
function parse(
  birthday: string
): Date | InvalidDateFormatError | DateIsInTheFutureError {
  let date = new Date(birthday);
  if (!isValid(date)) {
    return new InvalidDateFormatError("Enter a date in the form YYYY/MM/DD");
  }

  if (date.getTime() > Date.now()) {
    return new DateIsInTheFutureError("Are you a timelord?");
  }

  return date;
}

// 해당 함수 사용자는 세 가지 상황(파싱 성공, InvalidDateFormatError, DateIsInTheFutureError)을 모두 처리해야 하고.
// 아니면 컴파일 타임에 TypeError가 발생한다.
let result = parse(ask())

if (result instanceof InvalidDateFormatError) {
  console.error(result.message)
} else if (result instanceof DateIsInTheFutureError) {
  console.info(result.message)
} else {
  console.info(result.toISOString())
  
  
// 또는 아래와 같이 한 번에 처리할 수도 있다.
let result = parse4(ask()); // 날짜 또는 에러 반환

if (result instanceof Error) {
  console.error(result.message);
} else {
  console.info(result.toISOString());
}

4. Option 타입 

  • 에러가 발생할 수 있는 계산에 여러 연산을 연쇄적으로 수행할 수 있도록 하는 특수 목적 데이터 타입을 활용한다. 
    • 가장 많이 사용하는 타입은 Try, Option, Either 타입이 있다. 이는 자바스크립트가 기본으로 제공하지 않으므로 npm에서 찾아 설치하거나 직접 구현해야 한다. 
  • 어떤 특정 값을 반환하는 대신 값을 포함하거나 포함하지 않을 수 있는 컨테이너를 반환하는 것이 Option의 특징이다.
  • 값이 없더라도 연쇄적으로 연산을 수행할 수 있도록 한다. 값을 포함할 수 있는 자료구조로 이를 구현할 수 있다. 
  • (장점)
    • 타입 안전성을 제공한다.
    • 타입 시스템을 통해 해당 연산이 실패할 수 있음을 사용자에게 알려줄 수 있다. 
  • (단점) 
    • None으로 실패를 표현하기 때문에 무엇이 왜 실패했는지 자세히 알 수 없다.
    • Option을 사용하지 않는 다른 코드와는 호환되지 않는다. 
  • 예를 들어, string 타입의 birthday를 인수로 받는 parseFn을 살펴보자. date가 유효하지 않은 경우에도 parseFn이 []를 반환하기 때문에 이후에 map, forEach 메서드를 호출해도 에러가 나지 않는다.
// 4. Option 타입 사용
function parseFn(birthday: string): Date[] {
  let date = new Date(birthday);
  // date가 유효하지 않은 경우에도 []를 리턴해 연산 시 에러가 나지 않도록 함. 
  if (!isValid(date)) {
    return [];
  }
  return [date];
}

let dateResult = parseFn(ask());

dateResult
  .map((date) => date.toISOString())
  .forEach((formattedDate) => console.info(formattedDate));
  • prompt에 사용자가 입력 했을 때 실패할 수 있다고 가정하자. 사용자가 생일 입력을 취소하면 null을 반환할 것인데, 이때 또 다른 Opiton을 이용해 이 상황을 처리할 수 있다.
  • 아래의 경우 Date[][]로 map을 연산해 에러가 나는데, 이는 평탄화(flatten)을 통해 해결할 수 있다. 
// prompt가 실패하는 경우 처리 
function ask() {
  let result = prompt('When is your birthday?')
  if (result === null) {
    return []
  }
  return [result]
}

ask()
  .map(parse)
  .map((date) => date.toISOString()) // 속성 'toISOString'은 'Date[]' 타입에 존재하지 않음
  .forEach((date) => console.info(date));

 

 

 


 

 

  • 이제 배열 대신 Option 컨테이너를 사용해 이를 구현해보자. 조금 복잡할 수 있지만 천천히 따라가볼 예정이다. 
  • 컨테이너는 1. 대상 값을 이용해 연산을 수행하는 방법과 2. 그 결과를 얻어내는 방법을 보여준다. 컨테이너를 구현하고 나면 아래와 같이 사용할 수 있게 된다. 지금은 null이나 undefined일 수 있는 값에 처리를 해 연쇄적으로 연산을 수행할 수 있다는 정도로 이해하면 된다. 
ask()
  .flatMap(parse)
  .flatMap((date) => new Some(date.toISOString()))
  .flatMap(date => new Some('Date is', date))
  .getOrElse('Error parsing date for some reason')
  • 먼저 Option 타입을 정의한다. 
    • Option은 Some<T>와 None이 구현하게 될 인터페이스이다. 이 두 클래스는 모두 Option의 한 형태가 된다.  Some<T>는 T라는 값을 포함하는 Option(성공한 경우)이고, None은 값이 없는 Option(실패한 경우)을 가리킨다. 
    • Option은 타입이면서 함수이다. 타입 관점에서는 Some과 None의 슈퍼타입을 뜻한다. 함수 관점에서는 Opiton 타입의 새 값을 만드는 기능을 뜻한다. 
// Option과 하위 타입 간략하게 정의해보기
// Option<T>는 Some<T>와 None이 공유하는 인터페이스 
interface Option<T> {} 

// 연산에 성공해 값이 만들어진 상황.
class Some<T> implements Option<T> {
  constructor(private value: T) {}
} 

// 연산이 실패한 상황. 값을 담고 있지 않다. 
class None implements Option<never> {}
<Option으로 할 수 있는 연산 정의하기>
* flatMap : Option에 연산을 연쇄적으로 수행
* getOrElse :  Option에서 값을 가져옴 
  • Option 인터페이스에 이 연산을 정의해보자. 이를 위해 Some<T>와 None에서 구체적인 코드를 구현해야 한다. 
// Option 데이터 컨테이너 타입 사용하기
interface Option<T> {
  // Option에 연산을 연쇄적으로 수행
  flatMap<U>(f: (value: T) => Option<U>): Option<U>
  // Option에서 값 읽기
  getOrElse(value: T): T
}

class Some<T> implements Option<T> {
  constructor(private value:T) {}

  flatMap<U>(f: (value: T) => Option<U>): Option<U> {
    return f(this.value) // T를 인수로 f 호출한 반환값
  }
  getOrElse(): T {
    return this.value // Option 안의 값을 반환
  }
}

class None implements Option<never> {
  flatMap<U>(): Option<U> {
    return this // None 반환
  }

  getOrElse<U>(value: U): U {
    return value // 매개변수 값 그대로 반환
  }

}
  • 위 코드는 기본적인 구현이므로 좀 더 flatMap의 반환값을 명시적으로 나타낼 수 있다.
  • None의 매핑 결과는 항상 None이고, Some<T>의 결과는 f 호출 결과에 따라 값이 있다면 Some<T>, 없다면 None이 된다. 이를 바탕으로 flatMap을 더 구체적인 타입을 제공하도록 시그니처를 오버로드 할 수 있다. 
// flatMap이 더 구체적인 타입을 가지도록 시그니처 오버로드
interface Option<T> {
  flatMap<U>(f: (value: T) => None): None;
  flatMap<U>(f: (value: T) => Option<U>): Option<U>;
  getOrElse(value: T): T;
}

class Some<T> implements Option<T> {
  constructor(private value: T) {}

  flatMap<U>(f: (value: T) => None): None;
  flatMap<U>(f: (value: T) => Some<U>): Some<U>;
  flatMap<U>(f: (value: T) => Option<U>): Option<U> {
    return f(this.value);
  }
  getOrElse(): T {
    return this.value;
  }
}

class None implements Option<never> {
  flatMap(): None {
    return this;
  }

  getOrElse<U>(value: U): U {
    return value;
  }
}
반응형