Front-end 개발에 있어서 API 통신은 거의 필수 요소라고 생각합니다. 초반엔 경험하지 못하더라도 어느정도 프로젝트를 진행하다보면 백엔드와 통신하는 경우는 무조건 발생하죠.
자바스크립트로 프론트엔드 개발을 하면서, 런타임 오류를 정말 많이 맞이했었는데요, 그 이유는 대부분 타입 불일치나 변수명 불일치, 각 end간의 규격 불일치, 즉 응답 객체를 완전히 믿지 못하거나 응답값이 불안정 한 경우 발생하였습니다.
이런 문제를 Typescript로 어느정도 해결할 수 있었습니다. 하지만, 타입스크립트는 컴파일 단계에서 JS로 변환되므로 런타임 환경에서 보다 더 정확한 타입 검증을 위해서는 몇 가지 조치를 더 해주어야 했습니다.
우선, 서버에서는 json 형태로 응답 데이터를 전송합니다. 현재 진행중인 프로젝트를 기반하여 임시 응답 값을 만들어보았습니다. 모든 프로퍼티에는 null
또는 undefined
가 올 수 있습니다.
data:{
quizset_id:'16sdc851a',
set_title:'새로운 퀴즈 세트',
quiz_list:[{quiz_id:'11aa538', quiz_title:'1번 퀴즈 제목',},{...}]
solver_cnt:0
}
에러의 경우를 생각해보겠습니다.
quiz_list
가 undefined, null 일 경우 배열 내장함수 호출이 불가하며, 호출하려 하면 에러를 뿜습니다. 또한 빈 배열일 경우 배열 요소인 객체 프로퍼티에 접근할 수 없습니다.string
타입이 와야하는 프로퍼티에 number
타입 값이 오게되면 string
내장함수를 호출 하려 하면 에러를 뿜습니다.위와 같은 에러사항을 두고, 해결 방법에는 무엇이 있는지 고민해보았습니다.
자바스크립트에는 옵셔널체이닝 ?.
과 ||
,&&
, ??
, typeof
와 같이 값을 검증할 수 있는 문법이 있습니다. 이를 활용해서 어느정도 값 검증을 할 수 있습니다.
- 옵셔널 체이닝이란 프로퍼티가 없는 중첩 객체를 에러 없이 안전하게 접근할 수 있도록 하는 연산자 입니다.
에러 1번은 다음과 같이 임시 방편으로 해결 할 수 있습니다.
quizList : data?.quiz_list
if(!!quizList) quizList.map ....
quiz?.quiz_id
에러 2번은 다음과 같이 임시 방편으로 해결 할 수 있습니다.
if(typeof(data.quizset_id) ==='string') ...
quizSetId:quizset_id.toString();
하지만 이런 방식은 매번 백엔드 통신 규약이 바뀌거나 에러가 날 때 마다 수정 해주어야 하는데, 통일성도 없으며 유지보수가 용이하지도 않습니다.
이번에 채용 과제를 진행하면서, 타입을 꼼꼼히 검증해야 하는 경우가 있었습니다. 평소에 깊게 생각해보지 않았었는데, 서버의 응답값을 런타임 오류를 최소화 하면서 받을 수 있는 방법이 무엇이 있을지 생각 해볼 수 있는 계기가 되었습니다.
타입 가드란, 타입스크립트 환경에서 여러 타입을 인자로 받는 함수 내부에서 조건문으로 타입을 구분하여 타입의 경우를 좁혀 나가는 것을 의미합니다. 타입스크립트 내에서 타입을 보다 꼼꼼히 검증할 수 있죠. 특히 리터럴 타입의 경우, 여러 텍스트가 올 수 있기 때문에 경우를 잘 나누어 주어야 합니다.
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
가 섞이는 것이 코드 가독성을 더 저해시킨다고 생각하였습니다.
결국은 런타임 환경 타입체크를 한다는 것은 정말 어려운 일인 것 같습니다. 프론트엔드 개발자들이 모인 오픈채팅방에서 의견을 나누어 보았습니다.
tRPC
와 같은 라이브러리를 사용해 강력한 타입 검증을 한다.mono repo
로 관리한다좋은 의견이고, 꼭 알아 보면 좋을 견해였습니다! 하지만, 아직까지는 공부할 것이 많아 추후로 미루고 결론을 내렸습니다.
- 프론트엔드는 백엔드와 정한 API 규격에 맞게 타입을 정의한다.
- 예상 가능한 타입에 대해 예외처리를 진행한다. 객체 리터럴 방식을 이용해 타입 단언을 해준 뒤 간단한 검증을 수행한다. (ex. null, "", [] ).
- 만약 전혀 예상하지 못한 값이 도착할 경우는 hotfix 한다.
타입스크립트로 빌드 타임 검증을 하는 것 만으로도 코드가 단단해지는 경험을 하였습니다 :)
항상 서버 응답값을 가져오는 것에서 런타임 오류가 발생했었는데, 깊은 고민을 통해 검증 방법을 공부하게 되어 뿌듯합니다 .. 하지만, 과제 피드백 내용을 정확하게 이해 한 것인지 아직은 의문이 있으며 프로젝트 코드에 적용해보지 못해 아쉬움이 남습니다. 다양한 예제 코드를 짜보면서 더 공부 할 필요가 있는 것 같습니다!
공부하며 작성한 글이기 때문에 부족한 부분이나 궁금한 점이 있으시다면 댓글 자유롭게 남겨주세요 😊 감사합니다! 코드는 계속 개선되고 있습니다!