[Typescript] 서버에서 주는 데이터를 안전하게 받는법에 대한 고민 (런타임 타입 검증)

하영·2023년 3월 22일
2

TypeScript

목록 보기
2/2
post-thumbnail

0. 들어가며

Front-end 개발에 있어서 API 통신은 거의 필수 요소라고 생각합니다. 초반엔 경험하지 못하더라도 어느정도 프로젝트를 진행하다보면 백엔드와 통신하는 경우는 무조건 발생하죠.

자바스크립트로 프론트엔드 개발을 하면서, 런타임 오류를 정말 많이 맞이했었는데요, 그 이유는 대부분 타입 불일치나 변수명 불일치, 각 end간의 규격 불일치, 즉 응답 객체를 완전히 믿지 못하거나 응답값이 불안정 한 경우 발생하였습니다.

이런 문제를 Typescript로 어느정도 해결할 수 있었습니다. 하지만, 타입스크립트는 컴파일 단계에서 JS로 변환되므로 런타임 환경에서 보다 더 정확한 타입 검증을 위해서는 몇 가지 조치를 더 해주어야 했습니다.


1. 에러 경우의 수를 생각해보자.

우선, 서버에서는 json 형태로 응답 데이터를 전송합니다. 현재 진행중인 프로젝트를 기반하여 임시 응답 값을 만들어보았습니다. 모든 프로퍼티에는 null 또는 undefined가 올 수 있습니다.

data:{
  quizset_id:'16sdc851a',
  set_title:'새로운 퀴즈 세트',
  quiz_list:[{quiz_id:'11aa538', quiz_title:'1번 퀴즈 제목',},{...}]
  solver_cnt:0
}

에러의 경우를 생각해보겠습니다.

  1. quiz_list가 undefined, null 일 경우 배열 내장함수 호출이 불가하며, 호출하려 하면 에러를 뿜습니다. 또한 빈 배열일 경우 배열 요소인 객체 프로퍼티에 접근할 수 없습니다.
  2. 최상단 객체의 프로퍼티의 타입이 일치하지 않으면 에러가 발생합니다. 예를 들어 string 타입이 와야하는 프로퍼티에 number 타입 값이 오게되면 string 내장함수를 호출 하려 하면 에러를 뿜습니다.
  3. 필수 프로퍼티가 없거나 undefined 라면 에러가 발생합니다.
  4. ...

위와 같은 에러사항을 두고, 해결 방법에는 무엇이 있는지 고민해보았습니다.


2. 안전하게 받아와보자 !

ES6 문법을 이용해 값을 안전하게 받아오기

자바스크립트에는 옵셔널체이닝 ?.||,&&, ??, typeof 와 같이 값을 검증할 수 있는 문법이 있습니다. 이를 활용해서 어느정도 값 검증을 할 수 있습니다.

  • 옵셔널 체이닝이란 프로퍼티가 없는 중첩 객체를 에러 없이 안전하게 접근할 수 있도록 하는 연산자 입니다.

에러 1번은 다음과 같이 임시 방편으로 해결 할 수 있습니다.

  • quizList : data?.quiz_list
    • 배열 하위 객체에 접근할 때 quizList가 falsy한 값 인지 검사한다.
    • if(!!quizList) quizList.map ....
    • 배열 하위 객체 프로퍼티의 존재 여부를 검사한다.
    • quiz?.quiz_id

에러 2번은 다음과 같이 임시 방편으로 해결 할 수 있습니다.

  • if(typeof(data.quizset_id) ==='string') ...
  • quizSetId:quizset_id.toString();

하지만 이런 방식은 매번 백엔드 통신 규약이 바뀌거나 에러가 날 때 마다 수정 해주어야 하는데, 통일성도 없으며 유지보수가 용이하지도 않습니다.


타입 가드(Type Guard) 사용하기

이번에 채용 과제를 진행하면서, 타입을 꼼꼼히 검증해야 하는 경우가 있었습니다. 평소에 깊게 생각해보지 않았었는데, 서버의 응답값을 런타임 오류를 최소화 하면서 받을 수 있는 방법이 무엇이 있을지 생각 해볼 수 있는 계기가 되었습니다.

타입 가드란, 타입스크립트 환경에서 여러 타입을 인자로 받는 함수 내부에서 조건문으로 타입을 구분하여 타입의 경우를 좁혀 나가는 것을 의미합니다. 타입스크립트 내에서 타입을 보다 꼼꼼히 검증할 수 있죠. 특히 리터럴 타입의 경우, 여러 텍스트가 올 수 있기 때문에 경우를 잘 나누어 주어야 합니다.

type ChoiceType = 'text' | 'img'; // 타입 선언

/* text 또는 img 를 입력 받음*/
const selectChoice = (type:ChoiceType) =>{
  if(type === 'text') ... /* text의 경우 */
  else ... /* img의 경우 */
}

사실, 타입 가드란 말을 처음 들어서 그렇지 기존부터 사용하고 있었던 방식이었습니다. 가독성을 높여 경우를 구분한다면 보다 더 깔끔한 코드와 타입 검증이 가능하지 않을까 싶습니다.

원시타입을 이용해서는, 다음과 같이 코드를 작성해도 됩니다! (과제에서 사용했던 방식) 아주 중요한 API 호출에서는 마이크로 단위의 검증이 필요하기 때문에 보다 더 꼼꼼히 코드를 작성해주어야 합니다. 아래 방식은 무엇이 에러인지 확인할 수 있도록 모든 경우를 나눈 사례 입니다.

  • 서버에서 주는 응답값은 any 가 아니라 아직은 타입을 모르는 unknown에 가깝기 때문에 unknown으로 지정해주었습니다.
  const productListTypeGuard = (productList: unknown): void => {
    if (!Array.isArray(productList)) throw new Error('productList가 배열이 아닙니다.');

    productList.forEach((product: unknown, idx: number) => {
      if (!product || typeof product !== 'object') throw new Error(`productList[${idx}]가 없거나 객체가 아닙니다.`);

      if (!('id' in product) || typeof product.id !== 'number')
        throw new Error(`productList[${idx}].id가 없거나 number 타입이 아닙니다.`);
      if (!('name' in product) || typeof product.name !== 'string')
        throw new Error(`productList[${idx}].name이 없거나 string 타입이 아닙니다.`);
      if (!('imageUrls' in product) || !Array.isArray(product.imageUrls))
        throw new Error(`productList[${idx}].imageUrls 없거나 배열이 아닙니다.`);
      /* url의 개수에 따라 추가 검증 여부를 정할 수 있을 것 같습니다.*/
      if (!('price' in product) || typeof product.price !== 'number')
        throw new Error(`productList[${idx}].price가 없거나 number 타입이 아닙니다.`);
      if (!('stock' in product)) throw new Error(`productList[${idx}].stock이 없습니다.`);

      stockTypeGuard(product.stock);
    });
  };

타입스크립트 타입 선언 후 대입

가장 많이 사용하는 방법입니다. 타입스크립트는 객체의 타입도 정의할 수 있죠 !

interface Quiz{
  quizId:string;
  quizTitle:string;
}
interface QuizSet{
  quizSetId:string,
  setTitle:string,
  quizList:Quiz[],
  solverCnt:number
}

위와 같이 객체 타입을 선언한 후 , fetch 해온 데이터를 parse 하는 단계에서 타입을 맞춰주고, 에러 상황을 고려하면 됩니다.


const parseQuizSet = (data:unknown) =>{
  const {quizset_id,set_title,quiz_list,solver_cnt} = data as QuizSet;
  const _quizSet = {
   quizSetId:data?.quizset_id,
   setTitle:data?.set_title,
   quizList:data?.quiz_list,
   solverCnt:data?.solver_cnt,
  }
  setQuizSet(_quizSet); 
}

옵셔널체이닝을 이용해 존재하지 않는 값은 undefined가 뜰것이며 타입에 맞지 않는 값은 프로퍼티에 접근하기 전에는 에러가 발생하지 않습니다.

unknown 객체에 접근하기 위해 타입 단언을 사용한 것이 뭔가 께름칙 합니다. 이럴 때 위에서 언급한 타입 가드로 타입을 좁히면 unknown 객체에 타입 단언 없이 접근할 수 있습니다. 2 depth 이상 객체도 추가 검증 함수를 만들어 꼼꼼히 검증하면 에러 없이 안전하게 서버 응답값을 받아올 수 있습니다.. 서버 응답값 뿐만 아니라 unknown 인 객체 및 데이터를 받아올 때 이 방법을 사용하면 용이할 것 같습니다.

하지만, 백엔드와의 API 규격이 명확하지 않으면 또 예상치 못한 런타임 에러가 나올 수 있고, 코드가 길어져 복잡할 수 있습니다.


서버의 응답값 검증을 객체로 위임

채용 과제에서 받은 피드백은 다음과 같았습니다.

타입가드를 각 객체로 전환하여 생성자에서 해당 객체의 필수값을 체크하면 더 좋았을 것 같습니다. 값 검증을 객체로 위임할 수 있고 객체를 사용하는 측에서는 객체가 생성되면(런타임) 해당 객체를 이용하는것에 신뢰성을 가질 수 있습니다. 또한 유지보수 용이성이 더 향상됩니다.

위의 피드백을 받고 다시 생각해보았습니다. 객체로 위임한다는 뜻이 무엇일까? 자바스크립트에서는 {} 만으로도 쉽게 객체를 생성할 수 있지만, OOP 에서는 객체 class를 선언하고 new 로 생성해주어야 했습니다. 이 때 class 안에 멤버변수, 생성자, 메소드를 포함할 수 있죠.

클래스를 선언하여 멤버 변수의 타입 관리와 생성자에서 검증(타입가드)를 시도하며, 모든 검증을 마친 객체는 성공적으로 생성되게 합니다. 객체를 생성할 때 서버의 응답값을 파라미터로 넣어주면 객체 생성 및 검증이 1번에 이루어지니 코드가 1줄로 개선됩니다.

하지만, 클래스로 타입을 선언하고 생성자를 만드는 것이기 때문에 프론트엔드 개발을 FP로 하고 있다면 class가 섞이는 것이 코드 가독성을 더 저해시킨다고 생각하였습니다.


그래서 어떻게?

결국은 런타임 환경 타입체크를 한다는 것은 정말 어려운 일인 것 같습니다. 프론트엔드 개발자들이 모인 오픈채팅방에서 의견을 나누어 보았습니다.

  1. tRPC 와 같은 라이브러리를 사용해 강력한 타입 검증을 한다.
  2. 프론트엔드와 백엔드 사이에서 사용하는 타입을 mono repo로 관리한다
    -> 프론트엔드와 백엔드가 사용하는 타입이 같기 때문에 걱정 없다.
  3. graphQL 도입을 고려한다.

좋은 의견이고, 꼭 알아 보면 좋을 견해였습니다! 하지만, 아직까지는 공부할 것이 많아 추후로 미루고 결론을 내렸습니다.

  1. 프론트엔드는 백엔드와 정한 API 규격에 맞게 타입을 정의한다.
  2. 예상 가능한 타입에 대해 예외처리를 진행한다. 객체 리터럴 방식을 이용해 타입 단언을 해준 뒤 간단한 검증을 수행한다. (ex. null, "", [] ).
  3. 만약 전혀 예상하지 못한 값이 도착할 경우는 hotfix 한다.

타입스크립트로 빌드 타임 검증을 하는 것 만으로도 코드가 단단해지는 경험을 하였습니다 :)


3. 마치며

항상 서버 응답값을 가져오는 것에서 런타임 오류가 발생했었는데, 깊은 고민을 통해 검증 방법을 공부하게 되어 뿌듯합니다 .. 하지만, 과제 피드백 내용을 정확하게 이해 한 것인지 아직은 의문이 있으며 프로젝트 코드에 적용해보지 못해 아쉬움이 남습니다. 다양한 예제 코드를 짜보면서 더 공부 할 필요가 있는 것 같습니다!

공부하며 작성한 글이기 때문에 부족한 부분이나 궁금한 점이 있으시다면 댓글 자유롭게 남겨주세요 😊 감사합니다! 코드는 계속 개선되고 있습니다!

profile
maker를 넘어 solver를 지향합니다.

0개의 댓글