728x90

타입스크립트의 객체 지향

자바스크립트는 전통적인 객체 지향 프로그래밍 언어에서 기대할 수 있는 일부 기능을 지원하지 않는다.

이런 제약을 타입스크립트가 private과 같은 접근 제어자나 추상 클래스, 추상 메서드 같은 기능을 지원하면서 해결해준다.

 점진적 타이핑, 구조적 타이핑, 덕 타이핑이 결합한 언어로 이를 바탕으로  객체 지향의 폭을 넓혀준다.

  • 점진적 타이핑 : 프로그램 전체가 아닌 개발자가 명시한 일부분만 정적 타입 검사를 거치게 함
  • 덕 타이핑 : 객체의 변수와 메서드 지합이 객체의 타입을 결정하게 해줌
  • 구조적 타이핑 : 객체의 속성에 해당하는 특정 타입의 속성을 갖는지를 검사하여 타입 호완성을 결정함

 

객체 지향의 관점에서 타입스크립트가 프론트엔드에 주는 이점

  1. 타입스크립트는 prop을 인터페이스로 정의할 수 있다.
    객체 지향 패러다임에서는 객체 간의 협력 관계에 초점을 둔다. 컴포넌트 간의 협력 관게를 표현하는 것이 prop이다. 또한 객체 자체가 아니라 프레임워크에 의해 객체의 의존성이 주입되는 DI 패턴을 따르는데, 이러한 패턴을 더욱 명확하게 표현할 수 있는게 타입스크립트다.
  2. 타입스크립트 자체가 객체 지향적으로 다양한 측면을 표현하는 데 큰 장점을 가지고 있다.
    (점진적 타이핑, 구조적 타이핑, 덕 타이핑)

우아한형제들의 활용 방식

  • 온전히 레이아웃만 담당하는 컴포넌트 영역
  • 컴포넌트 영역 위에서 레이아웃과 비지니스 로직을 연결해주는 커스텀 훅 영역
  • 훅 영역 위에서 객체로서 상호 협력하는 모델 영역
  • 모델 영역 위에서 API를 해석하여 모델로 전달하는 API 레이어 영역

객체 지향의 패러다임에 매몰되기보다는 어떻게 하면 더 유기적인 협력관게를 만들어낼 수 있는지에 대한 고민을 하는 것이 중요하다.

728x90

이번 장에서는 실제 프로젝트 단위에서 사용할 내용들을 언급해준다.

앰비언트 타입 활용하기

앰비언트 타입 선언

타입스크립트의 타입을 .d.ts 확장자를 가진 파일에도 선언할 수 있는데, 이 경우 타입 선언만 할 수 있으며 값을 표현할 수는 없다.

값을 포함하는 일반적인 선언과  구별하기 위해 .d.ts 확장자를 가진 파일에서 하는 타입 선언을  앰비언트 타입 선언이라고 부른다. 또한 앰비언트 타입 선언으로 값을 정의할 수느 없지만 declare라는 키워드를 사용하여 어딘가에 자바스크립트 값이 존재한다는 사실을 알릴 수 있다.

 

대표적인 앰비언트 타입 사용 사례

declare module "*.png" {
	const src:string;
    export default src;
}

 

자바스크립트 라이브러리 사용할 때

 

앰비언트 타입 선언을 하면 자바스크립트 라이브러리 내부 함수와 변수의 타입을 선언하여 타입스크립트가 자동으로 .d.ts확장자를 가진 파일을 검색하여 타입 검사를 진행하고 문제 없이 컴파일되도록 한다. 

또한 VSCode와 같은 코드 편집기도 .d.ts 확장자를 가진 파일을 해석하여 코드를 작성할 때 유용한 타입 힌트를 제공한다.

ex : @types/react를  npm install -D 명령을 토해 설치하면 node_modules/@types@react 에 index.d.ts 와 global.d.ts가 설치된다.

 

 

vscode와 같은 에디터는 이러한 앰비언트 타입 선언 파일을 활용하여 코드 작성 시 유용한 타입 힌트를 제공한다.

 

즉 앰비언트 타입 선언은 타입스크립트에게 '자바스크립트 코드 안에는 이러한 정보들이 있어'라고 알려주는 도구'라고 이해하면 된다.

타입스크립트로 작성된 라이브러리

타입스크립트로 작성된 라이브러리도 컴파일을 줄이기 위해서 보통 자바스크립트 파일과 .d.ts파일로 배포된다.

이때 tsconfig.json 파일의 declaration을 true로 설정하면  타입스크립트 컴파일러는 자동으로 .d.ts파일을 생성한다.

 

자바스크립트 어딘가에 전역 변수가 정의되어 있음을 타입스크립트에 알릴 때

타입스크립트로 직접 구현하지 않았지만 시렞 자바스크립트 어딘가에 전역 변수가 정의되어 있는 상황을 타입스크립트에 알릴때 엠비언트 타입선언을 사용한다.

 

declare global {
  interface Window {
    deviceId: string | undefined;
    appVersion: string;
  }
}

위 예시 말고도 카카오 지도 등을 사용해보았으면 declare global을 사용해 보셨을 것이다.

 

앰비언트 타입 선언시 주의점

  1. 타입 스크립트로 만드는 라이브러리에는 불필요(자동 생성되므로)
  2. 전역으로 타입 정의하여 사용할 때 서로 다른 라이브러리에서 동일한 이름의 앰비언트 타입 선언을 한다면 충돌이 발생함
  3. 앰비언트 타입 선언을 .ts파일 내에 사용하면 앰비언트 타입의 의존성 관계가 보이지 않기 때문에 변경에 의한 영향 범위를 파악핳기 힘듬 ( 앰비언트 타입의 경우 명시적인 임포트나 익스포트 없이 코드 전역에서 사용 가능하기에 )

 

앰비언트 타입 활용하기

  1. 타입을 정의하여 임포트 없이 전역으로 공유가능하다. ( 유틸리티 타입에 사용할 경우 유용)
    //src/index/d.ts
    
    type Optional<T extends object, K extends keyof T = keyof T> = Omit<T, K> &
      Partial<Pick<T, K>>;​
  2. declare type 활용하기
    보편적으로 많이 사용하는 커스텀 유틸리티 타입을 declare type으로 선언하면 전역으로 사용 가능
    declare type Nullable<T> = T | null;​
  3. declare module 활용하기
    declare module "*.png" {
    	const src:string;
        export default src;
    }​
    로컬 이미지나 svg 같이 외부로 노출되어 있지 않은 파일을 모듈로 인식하여 사용하게 할 수도 있고, CSS-in-JS 라이브러리의 폰트, 크기 ,색상등이 있는 theme의 인터페이스 타입을 확장하여 theme타입이 자동으로 완성되도록 할 수 있다.
  4. declare namespace 활용하기
    .env 파일을 사용할 때 declare namespace를 활용하여 process.env로 설정값을 손쉽게 불러오고 환경변수의 자동완성기능을 쓸 수 있다.
    declare namespace NodeJS {
      interface ProcessEnv {
        readonly API_URL: string;
        readonly API_INTERNAL_URL: string;
      }
    }​
  5.  declare global 활용하기
    전역 변수를 선언할 떄 사용할 수 있다.

 

declare와 번들러의 시너지

declare를 통해서 단지 컴파일러에 해당 변수가 존재함을 알리기만 하면,  코드가 실행될 경우 실제 데이터가 없어서 기대하는 동작과 다를 수 있다. 이때 번들 시점에서 번들러를 통해서 해당 데이터를 주입하면 해당 문제를 해결할 수 있다.

ex) 롤업 번들러의 inject 모듈

 

스크립트와 설정 파일 활용하기

타입스크립트 프로젝트에서 스크립트와 tsconfig등을 잘 활용하면 개발 생산성을 높일 수 있는데 이와 관련된 팁을 알려준다.

스크립트 활용하기

실시간으로 타입을 검사하기

yarn tsc -noEmit -incremental -w

 

noEmit : 자바스크립트로된 출력 파일을 생성하지 않도록 설정

incremental : 증분 컴파일을 활성하여 컴파일 시간을 단축

w : 파일 변경 사항을 모니터링한다

 

타입 커버리지 확인

npx type-coverage -detail

 

타입스크립트로 마이그레이션 중인 프로젝트나 레거시 코드가 많은 프로젝트를 다룰 때 리팩토링에 도움이 되는 정량적인 지표를 얻을 수 있다.

 

설정 파일 활용하기

 

타입스크립트 컴파일 속도 높이기

incremental 속성을 t rue로 하면 증분 컴파일이 활성화되어, 변경된 부분만 컴파일하게 된다.

//tsconfig

{
	"compolierOptions": {
    	incremental: true
        //...
    }
}

 

에디터 활용하기

에디터에서 타입스크립트 서버 재시작하는법  : 종종 임포트나 자동 완성이 제대로 안될 때 사용  가능

ctrl(command) + shift +[

TypeScript : Restart TS server

 

타입스크립트 마이그레이션

1. 타입스크립트 마이그레이션의 필요성

보통은 마이그레이션보다 서비스가 변경하는 것에 따라 새로운 설계를 하는게 효율적일 수 있으므로 이를 확인해야 한다.

 

2.점진적인 마이그레이션

프로젝트 규모가 클 경우 한번에  변경하는 것은 불가능하므로, allowJS를 true, noImplicityAny 를 false로 설정하고 할 수 있으며, 이때 무작정 연기하기 보다는 우선순위를 정해 진행해야 한다.

 

3. 마이그레이션 진행하기

  1. 타입스크립트 개발 환경을 설정하고 빌드 파이프라인에 타입스크립트 컴파일러를 통합한다.
    이때 allowJS를 true, noImplicityAny를 우선 false로 설정해야 한다.
  2. 작성된 자바스크립트 파일을 타입스크립트 파일로 변환하다. 이 단계에서는 필요한 타입과 인터페이스를 하나씩 정의하며 함수 시그니처를 추가해나간다.
  3. 기존 자바스크립트 파일을 모두 변환이 완료되었다면, allowJS를 false로 변경하고, noImplicityAny를 true로 변경한 후 타입이 명시되지 않은 부분이 없는지 점검 한다.

 

 

..모노레포의 경우 타입스크립트 자체와는 연관성이 높지 않다 생각해서 생략했습니다.

728x90

CSS-in-JS가 무엇인지 모를때부터, 부트캠프를 통해 styled-components를 사용했었다. 배민에서는 어떻게 사용하는지도 궁금해서 개인적으로 흥미있는 장이었다!

 

 

CSS-in-JS

CSS-in-JS와 인라인 스타일의 차이점

 

CSS-in-JS는 CSS-in-CSS보다 더 강력한 추상화 수준을 제공한다. CS-in-JS를 활용하면 자바스크립트로 스타일을 선언적이고 유지보수 할 수 있게 표현할 수 있다.

흔히 인라인 스타일과 헤깔릴 수 있는데 두 방식의 차이점은 아래와 같다.

import React from 'react';
import styled from 'styled-components';

const textStyles = {
  color: 'white',
};
const CssInJs = () => {
  return (
    <div>
      <div style={textStyles}>예시2</div>
      <StyledComponentsEx>CSS-in-JS</StyledComponentsEx>
    </div>
  );
};

export default CssInJs;

const StyledComponentsEx = styled.div`
  color: white;
  background-color: black;
`;
<div style={{ color: 'white' }}>인라인2</div>

<style>
	.hash136s21 {
    	color:white;
    }
</style>
<div class='hash123s21'>CSS-in-JS</div>

 

인라인 스타일은 DOM노드에 속성으로 스타일을 추가한 반면에 CSS-in-JS는 DOM 상단에 style태그를 추가했다.

 

 

CSS-in-JS의 몇 가지 장점은 아래와 같다.

  1. 컴포넌트로 생각할 수 있다 : CSS-in-JS는 스타일을 컴포넌트 단위로 추상화하여 생각할 수 있게 해준다.
  2. 부모와 분리할 수 있다 : CSS에는 명시적으로 정의하지 않은 경우 부모 요소에서 자동으로 상속되는 속성이 있다.
  3. 스코프를 가진다 : CSS는 하나의 전역 네임스페이스를 가지기기 때문에 선택자 충돌을 피하기 어렵다. 이를 방지할 수 있음
  4. 자동으로 벤더 프리픽스가 붙는다 : 브라우저 호환성을 향상해줌
  5. 자바스크립트와 CSS사이에 상수와 함수를 쉽게 공유할 수 있다.

 

CSS-in-JS의 등장 배경

스타일링 라이브러리는 크게 두 가지로 나눌 수 있다.

CSS Preprocessor
sass/scss
less
stylus
CSS in JS
styled-components
emotion

 

웹 애플리케이션의 UI를 구성하는 데에도 CSS를사용하고 웹 개발에 컴포넌트/모듈 방식이 적용됨에 따라 CSS Modules를 시작으로 자바스크립트에서 CSS를 생성하는 방식이 도입되고 있다.

 

 크시르토퍼 쉬도가  CSS의 7가지 문제점을 제기했다 그리고 그에 대한 해결책으로 CSS-in-JS를 개념을 제시했다

  1. 글로벌 네임스페이스 : 모든 스타일이 전역 공간을 공유하므로 중복되지 않는 CSS 클래스 이름을 고민해야함
  2. 의존성 : CSS의 의존성과 자바스크립트의 의존성이 달라서 사용하지 않는 스타일이 포함되거나 꼭 필요한 스타일이 누락되는 문제가 생긴다.
  3. 불필요 코드 제거 : 기능 추가 수정 삭제 과정에서 불필요한 CSS를 삭제하기 힘들다.
  4. 최소화 : 클래스 이름을 최소화하기 힘들다.
  5. 상수 고융 : 자바스크립트와 상태 값을 공유할 수 없다. (현재는 CSS Variable이 도입됨)
  6. 비결정적 해결 : CSS 로드 순서에 따라 스타일 우선순위가 달라진다.
  7. 고립 : CSS의 외부 수정 관리하기 어렵다(캡슐화)

물론 CSS-in-JS를 적용하기 위해서는 별도의 라이브러리를 설치해야하고, 런타임에 스타일을 생성하기 위한 동작이 필요하기 때문에 CSS-in-CSS에 비해 성능적인 측면이 떨어질 수 있다.

 

하지만 동적인 대규모 웹 앱에서 컴포넌트 기반으로 개발할 떄 생산성을 높일 수 있다.

 

CSS-in-JS 사용하기

대부분의 CSS-in-JS은 사용방식이 유사한데, 템플릿 리터럴을 활ㅇ요해서 동적인 스타일을 정의하면 된다. 먼저 props의 타입을 정의하고, 이 props를 활용해서 동적인  스타일링을 구현한다.

또한 variant props의 유형에 따라 다른 스타일을 적용하고 싶다면 css함수를 사용하여 스타일을 정의하고 variant 값에 따라 맵 객체를 생성하여 사용할 수도 있다. (emotion 기준)

type ButtonRadius = 'xs' | 's' | 'm' | 'l';
export const buttonRadiusStyleMap:Record<ButtonRadius, SerializedStyles>={
  xs:css`
    border-radius:${radius.extra_small}
  `,
  // ...
}

 

 

그리고 유틸리티 함수를 활용하여 중복 타입 선언을 피할 수 있다.

interface BadCase {
  height?: string;
  isFull?: boolean;
}

const Component = styled.div<BadCase>`
  height: 100;
  margin: 0;
  ${({ isFull }) =>
    isFull &&
    css`
      margin: 0 -15px;
    `}
`;

const GoodCase = styled.div<Pick<Props, 'height' | 'isFull'>>``;

 

 

728x90

상태관리

상태란?

리액트 공식문서 : 렌더링 결과에 영향을 주는 정보를 담은 순수 자바스크립트 객체

 

시간이 지나면서 변할 수 있는 동적인 데이터이며, 값이 변경될 때마다 컴포넌트의 렌더링 결과물에 영향을 준다.

크게 지역 상태, 전역 상태, 서버 상태로 구분 할 수 있다.

상태의 종류

  • 지역 상태 : 컴포넌트 내부에서 사용되는 상태
  • 전역 상태 : 앱 전체에서 공유하는 상태
  • 서버 상태: 외부 서버에 저장해야 하는 상태

상태 관리시 유의 사항

상태는 애플리케이션의 복잡성을 증가시키고 동작을 예측하기 어렵게 만든다. 또한 상태가 업데이트될 때마다 리렌더링이 발생하기 때문에 유지 보수  및 성능 관점에서 상태의 개수를 최소화하는 것이 바람직한다.

이를 위해서 2가지를 고려해야 한다.

  • 시간이 지나도 변하지 않느다면 상태가 아니다.
  • 파생된 값은 상태가 아니다.

만약 동일 객체 참조를 유지하려는 목적이라면, 해당 값은 상태가 아니므로 useRef를 사용하는 것이 적합하다.

또한 SSOT(Single Source Of Truth)를 생각하여 어떠한 데이터도 단 하나의 출처에서 생성하고 수정해야 한다.

 

상태관리시 useState vs useReducer

useReducer의 경우 2가지 상황에서 권장된다.

  • 다수의 하위 필드를 포함하고 있는 복잡한 상태 로직을 다룰 때
  • 다음 상태가 이전 상태에 의존적일 때

 

  //before
  const [fold, setFold] = useState(true);
  const toggleFold = () => {
    setFold((prev) => !prev);
  };

  //after
  const [fold2, toggleFold2] = useReducer((v) => !v, true);

 

 전역 상태 관리와 상태 관리 라이브러리

상태는 사용하는 곳과 최대한 가까워야 하며 사용범위를 제한해야한다.

 

이때 전역 상태관리를 위해선 컨텍스트 API와 외부 상태관리 라이브러리를 쓸 수 있다.

 

Context API

깊은 레벨에 있는 컴포넌트 사이에 데이터를 전달하는 Prop Drilling 같은 문제를 해결하기 위한 도구로 사용된다.

컨텍스트 API를 활용하면 전역적으로 공유해야 하는 데이터를 컨텍스트로 제공하고 해당 컨텍스트를 구독한 컴포넌트에서만 데이터를 읽을 수 있다.

이때 유틸리티 함수를 만들어 간단하게 쓸 수 있다.

다만 컨텍스트 API를 사용할 경우 해당 컨텍스트를 구독하고 있는 모든 컴포넌트가 리렌더링 되기 때문에, 대규모 에플리케이션이나 성능이 중요한 애플리케이션에서는 권장되지 않는다.

import React, { useContext } from 'react';

type Consumer<C> = () => C;

export interface ContextInterface<S> {
  state: S;
}

export function createContext<S, C = ContextInterface<S>>(): readonly [
  React.FC<C>,
  Consumer<C>
] {
  const context = React.createContext<Nullable<C>>(null);

  const Provider: React.FC<C> = ({ children, ...otherProps }) => {
    return (
      <context.Provider value={otherProps as C}>{children}</context.Provider>
    );
  };

  const useCustomContext: Consumer<C> = () => {
    const _context = useContext(context);
    if (!_context) {
      throw new Error('Context not found');
    }

    return _context;
  };

  return [Provider, useCustomContext];
}

 

Mobx

  • 특징 및 장점
    1. 객체 지향 프로그래밍과 반응형 프로그래밍 패러다임의 영향을 받은 라이브러리
    2. 상태 변경 로직을 단순하게 작성할 수 있고 복잡한 업데이트 로직을 라이브러리에 위임할 수 있다.
  • 단점
    데이터가 언제, 어떻게 변하는지 추적하기 어려움

 

Redux

  • 특징 및 장점
    1. 함수형 프로그래밍의 영향을 받은 라이브러리
    2. 특정 UI 프레임워크에 종속되지 않아 독립적으로 상태 관리 라이브러리를 사용할 수 있음
    3. 상태 변경 추적에 최적화 되어 있음
  • 단점
    단순한 상태 설정에도 많은 보일러 플레이트가 필요함

 

Recoil

  • 특징 및 장점
    1. 상태 저장할 수 있는 Atom과 해당 상태를 변형할 수 있는 순수 함수 selector를 통해 상태를 관리함
    2.Redux에 비해 보일러플레이트가 적고 난이도가 쉬움
  • 단점
    아직 실험적 상태이기 때문에 충분한 검증이 이루어 지지 않음

Zustand

  • 특징 및 장점
    1. Flux  패턴을 사용하며 많은 보일러플레이트를 가지지 않는 훅 기반의 API모듈을 제공
    2.클로저를 활용하여 스토어 내부 상태를 관리함으로써 특정 라이브러리에 종속되지 않음
    3. 상태와 상태를 변경하는 액션을 정의하고 반환된 훅을 어느 컴포넌트에서나 임포트하여 원하는 대로 사용할 수 있음

배민팀에선 대체적으로 recoil을 선호하신다.

728x90

기본 훅

 

리액트 훅이 추가되면서 함수형 컴포넌트가 주가 되기전에는, 클래스 컴포넌트를 사용하였다. 하지만 이럴 경우 생명주기 함수에서만 상태 업데이트에 따른 로직을 실행시킬 수 있었고, 프로젝트 규모가 커질수록 스토어에 상태를 연결하거나 비슷한 로직을 가진 상태 업데이트 및 사이드 이펙트 처리가 불편해졌다. 또한 모든 상태를 하나의 함수 내에서 처리하다 보니 관심사가 뒤섞이게 되었고, 상태에 따른 테스트나 잘못 발생한 사이드 이펙트의 디버깅이 어려워졌다.

 

이번 장에서는 자주 사용하는 훅의 타입과 사용에 대해서 설명해준다.

리액트에서 hook을 import하고 vscode에서 ctrl+왼쪽클릭(윈도우)을 하면 타입을 확인할 수 있다.

1. useState

function useState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>];
    
type Dispatch<A> = (value: A) => void;
type SetStateAction<S> = S | ((prevState: S) => S);

 

앞선 장에서도 언급했지만  useState는 튜플을 반환한다.

첫 번째 요소는 제네릭으로 지정한 S타입이며, 두 번째 요소는 상태를 업데이트할 수 있는 Dispatch 타입의 함수이다.

Dispatch함수의 제네릭으로 지정한 SetStateAction에는 useState로 관리할 상태 타입인 S또는 이전 상태 값을 받아 새로운 상태를 반환하는 함수가 있다.       (prevState:S)=>S 

이처럼 useState를 동기적으로 처리하기 위해 사용한다

useState가 동기적으로 처리된다는 말이 의아할 수 있는데, useState는 동기적으로 처리되나, 해당 값의 반영은 렌더링 사이클에 의해 비동기적으로 작동한다.

 

 

2. 의존성 배열을 사용하는 훅 (useEffect,useLayoutEffect)

공식문서에서는 아래와 같이 설명한다

The Effect Hook lets you perform side effects in function components:

두 함수 모두 함수형 컴퍼넌트에서 렌더링 이후에 부가 작용(side effect)를 실행하기 위해 실행되는데 차이점은

useEffect는 기존 생명주기 함수와 다르게, 레이아웃 배치와 화면 렌더링이 모두 완료된 후에 실행된다.
이와 달리 useLayoutEffect는 컴포넌트가 그려지기 전에 콜백 함수를 싱행하기 때문에 첫 번째 렌더링 때 빈 이름이 뜨는 경우를 방지할 수 있다.

 

function useEffect(effect: EffectCallback, deps?: DependencyList): void;
function useLayoutEffect(effect: EffectCallback, deps?: DependencyList): void;

type EffectCallback = () => void | Destructor;
type Destructor = () => void | { [UNDEFINED_VOID_ONLY]: never };
type DependencyList = readonly unknown[];

 

첫 번쨰 인자인 effect의 타입인 EffectCallback은 Destructorㄹ르 반환하거나 아무것도 반환하지 않는 함수이다.

Promise 타입은 반환하지 않으므로 useEffect의 콜백함수에는 비동기 함수가 들어갈 수 없다. useEffect내에서 비동기 함수를 호출시 경쟁 상태를 불러 일으킬 수 있기 때문이다.

 

 

두 번째 인자인 deps는 옵셔널하게 제공되며 effect가 수행되기 위한 조건을 나열한다. 이때 deps의 원소로 기본 자료형이 아닌 객체나 배열을 넣을 때는 얕은 비교를 하기 때문에 주의해야한다(참조값이 변경되면 콜백함수가 실행됨)

 

useEffect의 콜백함수는 컴포넌트가 마운트 및 리랜더링 될 때 실행되고, 클린업함수(Destructor)의 경우에는 리랜더링 될 때(콜백함수 이전에) 및 언마운트 될 때 실행된다.

 

3. useMemo와 useCallback

useMemo와 useCallback 모두 이전에 생성된 값 또는 함수를 기억하며, 동일한 값과 함수를 반복해서 생성하지 않도록 해주는 훅이다. 어떤 값을 계산하는 데 오랜 시간이 걸릴 때나 렌더링이 자주 발생하는 form에서 useMemo나 useCallback을 유용하게 사용할 수 있다. 다만 과도하게 메모이제이션할 경우 컴포넌트 성능 향상이 보장되지 않을 수 있다.

 

function useMemo<T>(factory: () => T, deps: DependencyList): T;
function useCallback<T extends Function>(callback: T, deps: DependencyList): T;

type DependencyList = readonly unknown[];

두 훅 모두 제네릭을 지원하기 위해 T타입을 선언해준다.

 

4.  useRef

react에서도 특정 컴포넌트의 위치로 스크롤 하는 등 DOM을 직접 선택해야 하는 경우가 발생할 수 있다. 이때 리액트의 useRef를 사용한다.

ref의 특징으로는

  • useRef로 관리되는 변수는 값이 바뀌어도 컴포넌트의 리렌더링이 발생되지 않는다.
  • 리액트 컴포넌트의 상태는 상태 변경 함수를 호출하고 렌더링된 이후에 업데이트된 사태를 조회할 수 있다.

useRef의 경우에는 세 종류의 타입 정의를 가지고 있으며, useRef에 넣어주느 인자 타입에 따라 반환되는 값이 다르다.

//
function useRef<T>(initialValue: T): MutableRefObject<T>;
//
function useRef<T>(initialValue: T | null): RefObject<T>;
//
function useRef<T = undefined>(): MutableRefObject<T | undefined>;

interface MutableRefObject<T> {
    current: T;
}

interface RefObject<T> {
    readonly current: T | null;
}

 

MutableRefObject의 current는 값을 변경할 수 있다. 만약 null을 허용하기 위해 useRef의 제네릭에 HTMLInputElement |null 타입을 넣어주었다면, 해당 useRef는 첫 번째 타입 정의를 따른다. 이때 MutableObject의 current는 변경할 수 있는 값이 되어 ref.current의 값이 바뀌는 사이드 이펙트가 발생할 수 있다.

 

반면 RefObject의 current는 readonly로 값을 변경할 수 없다.

즉, 초기값이 null이더라도 mutable로 사용하려면 제네릭에 null을 넣으면 된다.

  const ref = useRef<HTMLInputElement>(null);
  const mutableRef = useRef<HTMLInputElement | null>(null);

 

자식 컴포넌트에 ref를 전달할 떄는  forwardRef를 사용해야 한다.

ref라는 속성의 이름은 리액트에서 'DOM 요소 접근' 이라는 특수한 목적으로 사용되기 때문에 props를 넘겨주는 방식으로 전달할 수 없다.(ref가 아닌 inputRef등 다른 이름 사용할 경우 forwardref 사용하지 않아도 된다.) 

interface Props {
  name: string;
}
const TestInput = forwardRef<HTMLInputElement, Props>((props, ref) => {
  return (
    <div>
      <label>{props.name}</label>
      <input ref={ref} />
    </div>
  );
});

//Type
function forwardRef<T, P = {}>(
    render: ForwardRefRenderFunction<T, P>,
): ForwardRefExoticComponent<PropsWithoutRef<P> & RefAttributes<T>>;

interface ForwardRefRenderFunction<T, P = {}> {
    (props: P, ref: ForwardedRef<T>): ReactNode;
 	displayName?: string | undefined;
    defaultProps?: never | undefined;
    propTypes?: never | undefined;
}

type ForwardedRef<T> = ((instance: T | null) => void) | MutableRefObject<T | null> | null;

 

forwardRef에 인자로 넘겨주는 콜백함수의 타입인 ForwardRefRendFunction은 2개의 타입 매개변수 T와 P를 받는다. P는 일반적인 리액트 컴퍼넌트의 props의 타입, T는 ref로 전달하려는 요소의 타입을 나타낸다.

ref의 타입이 T를 래핑한 형태인 ForwardedRef<T>인데 여기서는 MutableRefObject<T|null>을 통해서 앞선 ref와 다르게 RefObject가 올 수 없다. 따라서 부모 컴포넌트에서 ref를 어떻게 선언했는지와 관계없이 자식 컴포넌트가 해당 ref를 수용할 수 있다.

 

또한 useImperativeHandle 훅은 ForwardRefRendFunction과 함꼐 쓸 경우, 부모 컴포넌트에서 ref를 통해 자식 컴포넌트에서 정의한 커스터마이징된 메서드를 호출할 수 있게 된다. 즉 자식 컴포넌터는 내부 상태나 로직을 관리하면서 부모컴포넌트와의 결합도를 낮출 수 있다.

const JobCreateForm : React.ForwardRefRenderFunction<CreateFormHandle, CreateFormProps>= (props,ref)=>{
  useImperativeHandle(ref,()=>({
    submit:()=>{
      //
    }
  }))
}

 

커스텀 훅

리액트에서 기본적으로 제공하는 훅들에 더해, 사용자 정의 훅을 생성하여 컴포넌트 로직을 함수로 뽑아내 재사용할 수 있다.

가장 기본적인 훅으로는 onChange함수를 input값과 함께 반환하는 훅이다. 

 

import { useState } from 'react';

const useInput = (initialValue) => {
  const [value, setValue] = useState(initialValue);
  const onChange = (e) => {
    setValue(e.target.value);
  };

  return { value, onChange };
};

이를 타입스크립트에 적용하고, useCallback으로 최적화하면 아래와 같다.

import { ChangeEvent, useCallback, useState } from 'react';

export const useInput = (initialValue: string) => {
  const [value, setValue] = useState(initialValue);
  const onChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
    setValue(e.target.value);
  }, []);

  return { value, onChange };
};
728x90

타입스크립트로 리액트 컴포넌트 만들기

 

1. 컴포넌트 속성 타입 명시하기

일반적인 JSX로 작성된 컴포넌트를 봤을 때, 각 속성에 어떤 타입의 값을 전달해야 할 지 명확하기 알기 힘들다.

이를 JSX로 작성된 Select 컴포넌트를 통해서 개선해보자

const Select = ({ onChange, options, selectedOption }) => {
  const handleChange = (e) => {
    const selected = Object.entries(options).find(
      ([_, value]) => value === e.target.value
    )?.[0];
    onChange?.(selected);
  };

  return (
    <select
      onChange={handleChange}
      value={selectedOption && options[selectedOption]}
    >
      {Object.entries(options).map(([key, value]) => (
        <option key={key} value={value}>
          {value}
        </option>
      ))}
    </select>
  );
};

 

 

1-1) JSDocs로 해결하기

컴포넌트의 속성 타입을 명시하기 위해서 JSDocs를 사용할 수 있다. 이를 통해 컴포넌트에 대한 설명과 각 속성이 어떤 역할을 하는지 간단하게 알려줄 수 있다.

/**
*Select 컴포넌트
*@param {Object}props - Select 컴포넌트가 넘겨주는 속성
*@param {Object}props.options  - { [key:string]:string} 형식으로 이루어진 객체
*@param {string | undefined} props.selectedOption - 현재 선택된 option의 key 값 (optional)
*** 등등

*/

 

 

1-2)  props 인터페이스 적용하기

JSDocs를 사용해도, options가 어떤 형식의 객체를 나타내는지나, onChange의 매개변수 및 반환 값에 대한 구체적인 정보를 알기는 쉽지 않다. 하지만 타입스크립트를 사용하면 좀 더 정교하고 구체적인 타입을 지정할 수 있다.

type Option = Record<string, string>;
interface SelectProps {
  options: Option;
  selectedOption?: string;
  onChange?: (selected?: string) => void;
}

const Select = ({
  onChange,
  options,
  selectedOption,
}: SelectProps): JSX.Element => {
  const handleChange = (e) => {
    const selected = Object.entries(options).find(
      ([_, value]) => value === e.target.value
    )?.[0];
    onChange?.(selected);
  };

  return (
    <select
      onChange={handleChange}
      value={selectedOption && options[selectedOption]}
    >
      {Object.entries(options).map(([key, value]) => (
        <option key={key} value={value}>
          {value}
        </option>
      ))}
    </select>
  );
};

 

2. 리액트 이벤트

리액트에서는 가상 DOM을 다루면서 이벤트도 별도로 관리한다. 따라서 리액트 이벤트는 브라우저의 고유한 이벤트와 완전히 동일하게 동작하지는 않는다. (ex : 리액트 이벤트 핸들러는 이벤트 버블링 단계에서 호출됨)

또한 리액트는 브라우저 이벤트를 함성한 합성 이벤트(SyntheticEvent)를 제공한다.

 

앞선 코드에서 handleChange 함수의 타입이 명시되지 않았다. 이를 다뤄보려고 한다.

React.ChangeEventHandler<HTMLSelectElement> 타입을 적용하였다.

const Select = ({
  onChange,
  options,
  selectedOption,
}: SelectProps): JSX.Element => {
  const handleChange: React.ChangeEventHandler<HTMLSelectElement> = (e) => {
    const selected = Object.entries(options).find(
      ([_, value]) => value === e.target.value
    )?.[0];
    onChange?.(selected);
  };

  return (
    <select
      onChange={handleChange}
      value={selectedOption && options[selectedOption]}
    >
      {Object.entries(options).map(([key, value]) => (
        <option key={key} value={value}>
          {value}
        </option>
      ))}
    </select>
  );
};

 

3. 훅에 타입 추가하기

훅에서는 제네릭을 사용하여 타입을 추가할 수 있다.

useState를 예시로 들면 아래 state는 string과 undefined(초기 값이 없는 경우)가 가능하다.

만약 타입을 지정하지 않는다면 undefined만 오게 된다. 이때 훅에서 타입을 세세하게 잡아서 사이드 이펙트를 방지할 수 있다.

  const [state, setState] = useState<string | undefined>();

 

4.  제네릭 컴포넌트 만들기

select의 옵션의 경우 일반적인 Record<string,string> 으로 타입을 지정할 경우, 올바르지 않은 옵션을 받아도 에러가 발생하지 않는다. 하지만 사용하는 입장에서 불편하게 되는데, 이럴 때 제네릭을 사용한 컴포넌트로 제한된 키와 벨류만 받을 수 있도록 할 수 있다.

interface GenericSelectProps<OptionType extends Record<string, string>> {
  options: OptionType;
  selectedOption?: keyof OptionType;
  onChange?: (selected?: keyof OptionType) => void;
}

const GenericSelect = <OptionType extends Record<string, string>>({
  options,
  selectedOption,
  onChange,
}: GenericSelectProps<OptionType>) => {
  //
};

 

5.  HTMLAttribuites, ReactProps 적용하기

className, id 와 같은 리액트 컴포넌트의 기본 props를 리액트에서 제공하는 타입을 사용하면 더 정확한 타입을 설정할 수 있다.

 

6.  styled-component에 타입 적용하기

컴포넌트에 CSS파일 대신 자바스크립트 안에 직접 스타일을 정의하는 방식을 css-in-js라고 한다. 

 그 중 대표 라이브러리인 styled-component에서 typescript를 적용하면 아래와 같다.

type Theme = typeof theme;
type FontSize = keyof Theme['fontSize'];
type Color = keyof Theme['color'];

interface SelectStyleProps {
  color: Color;
  fontSize: FontSize;
}

const StyledSelect = styled.select<SelectStyleProps>`
  color: ${({ color }) => theme.color[color]};
`;

 

7.  공변성과 반공변성

일반적인 타입은 공변성을 가지고 있어서 좁은 타입에서 넓은 타입으로 할당이 가능하다.

interface User {
  id: string;
}

interface Member extends User {
  nickname: string;
}

let users: Array<User> = [];
let members: Array<Member> = [];

users = members;
members = users//User[]' 형식은 'Member[]' 형식에 할당할 수 없습니다. 'nickname' 속성이 'User' 형식에 없지만 'Member' 형식에서 필수입니다.

 

하지만 제네릭 타입은 반공변성을 지닌다.  즉 T<B>가 T<A>의 서브타입이 되어 좁은 타입T<A>의 함수를 넓은 타입T<B>의 함수에 적용할 수 없다.

type PrintUserInfo<U extends User> = (user: U) => void;
let printUser: PrintUserInfo<User> = (user) => console.log(user.id);
let printMember: PrintUserInfo<Member> = (user) =>
  console.log(user.id, user.nickname);

printMember = printUser;

printUser = printMember; //intUserInfo<Member>' 형식은 'PrintUserInfo<User>' 형식에 할당할 수 없습니다. 'nickname' 속성이 'User' 형식에 없지만 'Member' 형식에서 필수입니다.

 

 

마찬가지로

interface Props<T extends string> {
  onChangeA?: (selected: T) => void;
  onChangeB?(selected: T): void;
}

 

A와 같이 함수 타입을 화살표 표기법으로 작성한다면 반공변성을 띠게 된다.

B와 같이 함수 타입을 지정하면 공변성과 반공변성을 가지는 이변성을 띠게 된다.

안전한 타입 가드를 위해서는 특수한 경우를 제외하고는 일반적으로 반공변적인 함수 타입을 설정하는 것이 권장된다.

 

https://inpa.tistory.com/entry/TS-%F0%9F%93%98-%ED%83%80%EC%9E%85%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8-%EA%B3%B5%EB%B3%80%EC%84%B1-%EB%B0%98%EA%B3%B5%EB%B3%80%EC%84%B1-%F0%9F%92%A1-%ED%95%B5%EC%8B%AC-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0#%EA%B3%B5%EB%B3%80%EC%84%B1covariance

 

📘 타입스크립트 공변성 & 반공변성 완벽 이해

타입의 공변성과 반공변성 타입스크립트는 자바스크립트에 타입을 추가해준 라이브러리 이지만, 타입을 다루는 언어이기도 하다. 그래서 어느새 타입 자체를 코딩하고 있는 자신을 발견하기도

inpa.tistory.com

를 참고해도 좋을 거 같다.

728x90

리액트 컴포넌트의 타입

리액트로 타입스크립트로 작성할 때 @types/react 패키지에 정의된 내장 타입을 사용한다. 이중에서 헤깔릴 수 있는 타입도 존재하고 그에 대한 유의점을 알려준다.

 

1. 클래스 컴포넌트 타입

작년부터 리액트를 시작해서, 클래스 컴포넌트를 사용해본적이 없다. 하지만 아래의 예시를 통해 클래스 컴포넌트 타입에서 타입을 쓰는 것을 확인할 수 있다.

import { ComponentLifecycle } from 'react';

interface Component<P = {}, S = {}, SS = any>
  extends ComponentLifecycle<P, S, SS> {}

class Component<P, S> {}

class PureComponent<P = {}, S = {}, SS = any> extends Component<P, S, SS> {}
interface WelcomProps {
  name: string;
}

class Welcome extends React.Component<WelcomProps> {
  // 생략
}

P와 S는 각각 props와 state이다.

 

2. 함수 컴포넌트 타입

//함수 선언을 사용
function Welcome(props: WelcomeProps): JSX.Element {
  return <div></div>;
}

// 함수 표현식을  사용한 방식 3가지
const Welcome2: React.FC<WelcomeProps> = ({ name }) => {
  return <div></div>;
};

const Welcome3: React.VFC<WelcomeProps> = ({ name }) => {
  return <div></div>;
};

const Welcome4 = ({ name }: WelcomeProps): JSX.Element => {
  return <div></div>;
};

 

FC는 FunctionComponent 의 약자이고, VFC는 children이 없는 FC이다. 18v에서는 VFC가 사라지고, FC의 기본 옵션이 children 있음에서 없음으로 변경되었다.

 

3. Children props 타입 지정

가장 보편적인 children의 타입은 ReactNode | undefined가 된다.
ReactNode 타입은 ReactElement 외에도 boolean, number 등 여러 타입을 포함하고 있는 타입이다. 

세분화하고 싶으면 아래와 같이 사용가능하다.

type SpecificProps = {
  children: '천생연분' | '더 귀한 분' | '귀한 분' | '고마운 분';
};

type StringProps = {
  children: string;
};

type ReactElementProps = {
  children: ReactElement;
};

 

4. render 메서드와 함수 컴포넌트의 반환 타입-React.ReactElement, JSX.Element, React.ReactNode

 

React.ReactElement 와 JSX.Element , React.ReactNode 타입은 헷깔리기 쉽다.

3가지 모두 리액트의 요소를 나타내는 타입인데, 차이점이 존재한다.

JSX.Element  <  React.ReactElement  < React.ReactNode   ( 포함관계)

 


JSX.Element

declare global {
  namespace JSX {
    interface Element extends React.ReactElement<any, any> {}
  }
}

 

JSX.Element 타입은 위의 코드를 보면 알 수 있듯이 ReactElement를 확장하고 있는 타입이며, 글로벌 네임스페이스에 정의되어 있어 외부 라이브러리에서 컴포넌트 타입을 재정의 할 수 있는 유연성을 제공한다.

JSX.Element는 ReactElement의 특정 타입으로 props와 타입 필드를 any 로 가지는 타입이다.

리액트 엘리먼트를 prop으로 전달받아 render props 패턴으로 컴포넌트를 구현할 때 유용하다.

 

interface Props {
  icon: JSX.Element;
}

const Item = ({ icon }: Props) => {
  const iconSize = icon.props.size;
  return <li>{icon}</li>;
};

const App = () => {
  return <Item icon={<Icon size={14} />} />;
};

 

React.ReactElement 

React. ReactElement  는 리액트 컴포넌트를 객체 형태로 저장하기 위한 포맷이다.

interface ReactElement<
  P = any,
  T extends string | JSXElementConstructor<any> =
    | string
    | JSXElementConstructor<any>
> {
  type: T;
  props: P;
  key: Key | null;
}


ReactElement 타입은 JSX의 createElement 메서드 호출로 생성된 리액트 엘리먼트를 나타내는 타입이다.
 ReactElement의 제네릭으로 컴포넌트의 props를 지정해 줄 수 있다.

 

React.ReactNode

type ReactText = string | number;
type ReactChild = ReactElement | ReactText;
type ReactFragment = {} | Iterable<ReactNode>;

type ReactNode =
  | ReactChild
  | ReactFragment
  | ReactPortal
  | boolean
  | null
  | undefined;

 

ReactNode는 리액트의 render함수가 반환할 수 있는 모든 형태를 담고 있다.

따라서 prop으로 리액트 컴포넌트가 다양한 형태를 가질 수 있게 하고 싶을 때 유용하게 사용된다.

5. 리액트에서 기본 HTML 요소 타입 활용하기

HTML 태그의 속성 타입을 활용하는 대표적인 2가지 방법은 DetailedHTMLProps 와 ComponentWithoutRef가 있다.

차이점은 ref의 포함 유무이다.

DetailedHTMLProps

type NativeButtonProps = React.DetailedHTMLProps<
  React.ButtonHTMLAttributes<HTMLButtonElement>,
  HTMLButtonElement
>;

type ButtonProps = {
  onClick?: NativeButtonProps['onClick'];
};

 

ComponentWithoutRef

 

type NativeButtonType2 = React.ComponentPropsWithoutRef<'button'>;
type ButtonProps2 = {
  onClick?: NativeButtonType2['onClick'];
};

 

최근에는 함수 컴포넌트를 많이 쓴다. 이때 함수 컴포넌트의 props로 DetailedHTMLProps와 같이 ref를  포함하는 타입을 사용하게 되면, 실제로는 동작하지 않는 ref를 받도록 타입이 지정되어 예기치 않은 에러가 발생할 수 있다

 

따라서 HTML 속성을 확장하는 props를 설계할 때는 ComponentPRopsWithoutRef 타입을 사용하여 ref가 실제로 forwardRef와 함께 사용할 때만 props로 전달되도록 타입을 정의하는 것이 안전하다.

728x90

이 장에서는 타입스크립트에 직결되는 느낌보다는, 배민팀이 API 사용시 에러 핸들링과 모킹을 어떻게 했는지에 대한 내용입니다. 즉, 배민팀에 코드에 관심이 있다면 교재를 구매해서 보는 것이 효율적일 것이라 생각하고,, 어떠한 방법들을 사용했는지 정도만 저는 기록하였습니다.

 

API 에러 핸들링

비동기 API 호출을 하다 보면 다양한 에러가 발생할 수 있다. 이를 타입스크립트에서 구체적이고 명시적으로 핸들링하는 방법을 알아보자

1. 타입가드

Axios에서는 Axios에 대해 isAxiosError라는 타입가드를 제공한다. 이 타입가드를 가공해서 , 서버 에러임을 명확하게 표시하고 서버에서 내려주는 에러 응답 객체에 대해서도 구체적으로 정의함으로써 에러 객체가 어떤 속성을 가졌는지 파악할 수 있다.

 

// 공통에러에 대한 정의
import axios, { AxiosError } from 'axios';

interface ErrorResponse {
  status: string;
  serverDateTime: string;
  errorCode: string;
  errorMessage: string;
}

function isServerError(error: unknown): error is AxiosError<ErrorResponse> {
  return axios.isAxiosError(error);
}

2. 에러 서브 클래싱하기

서브 클래싱이란 기존 클래스를 확장하여 새로운 클래스를 만드는 과정이다.

단순한 서버 에러말고 인증 정보 에러, 네트워크 에러등 다양한 에러를 명시적으로 표시하기 위해 서브클래싱을 사용할 수 있다. 서브클래싱을  활용하면 에러가 발생했을 때 코스당에서 어떤 에러인지를 바로 확인할 수 있고 에러 인스턴스에 따라 처리 방식을 다르게 구현할 수 있다.

 

class OrderHttpError extends Error {
  private readonly privateResponse: AxiosResponse<ErrorResponse> | undefined;

  constructor(message?: string, response?: AxiosResponse<ErrorResponse>) {
    super(message);
    this.name = 'OrderHttpError';
    this.privateResponse = response;
  }

  get response(): AxiosResponse<ErrorResponse> | undefined {
    return this.privateResponse;
  }
}

class NetworkError extends Error {
  constructor(message = '') {
    super(message);
    this.name = 'NetworkError';
  }
}

class UnauthorizedError extends Error {
  constructor(message: string, response?: AxiosResponse<ErrorResponse>) {
    super(message);
    this.name = 'UnauthorizedError';
  }
}

 

 

 

const httpErrorHandler = (
  error: AxiosError<ErrorResponse> | Error
): Promise<Error> => {
  let promiseError: Promise<Error>;

  if (axios.isAxiosError(error)) {
    if (Object.is(error.code, 'ECONNABORTED')) {
      promiseError = Promise.reject(new TimeoutError());
    } else if (Object.is(error.code, 'Network Error')) {
      promiseError = Promise.reject(new NetworError());
    } else {
      const { response } = error as AxiosError<ErrorResponse>;

      switch (response?.status) {
        case HttpStatusCode.Unauthorized:
          promiseError = Promise.reject(
            new UnauthorizedError(response?.data.errorMessage, response)
          );
          break;
        default:
          promiseError = Promise.reject(
            new OrderHttpError(response?.data.errorMessage, response)
          );
      }
    }
  } else {
    promiseError = Promise.reject(error);
  }
  return promiseError;
};

이후 error instance of OrderHttpError와 같이 작성된 타입 가드문을 통해 코드상에서 에러핸들링에 대한 부분을 한눈에 볼 수 있게 만들 수 있다.

const onUnauthorizedError = (message: string, callback?: () => void) => {
  console.error(`Unauthorized Error: ${message}`);
  if (callback) {
    callback();
  }
};
const onActionError = (
  error: unknown,
  params?: Omit<AlertPopup, 'type' | 'message'>
) => {
  if (error instanceof UnauthorizedError) {
    onUnauthorizedError(error.message);
  } else if (error instanceof NetworkError) {
    // ...
    alert('내트워크 연결이 이상합니다.');
  }
};

 

3. Axios 인터셉터를 활용한 에러처리

const httpErrorHanlder = (
  error: AxiosError<ErrorResponse> | Error
): Promise<Error> => {
  (error) => {
    if (error.response && error.response.status === 401) {
      window.location.href = `${backofficeAuthHost}`;
    }
    return Promise.reject(error);
  };
};

orderApiRequester.interceptors.response.use(
  (response: AxiosResponse) => response,
  httpErrorHandler
);

응답 시에 인터셉터를 통해 처리 가능하다.

 

4. 에러 바운더리를 활용한 에러처리

에러 바운더리는 리액트 컴포넌트 트리에서 에러가 발생할 때 공통으로 에러를 처리하는 리액트 컴퍼넌트이다. 에러 바운더리는 에러가 발생한 컴퍼넌트 대신에 에러 처리를 하거나 예상치 못한 에러를 공통 처리할 떄 사용할 수 있다.

 

5. 상태 관리 라이브러리에서 에러 처리

6. react-query 를 활용한 에러 처리

요청에 대한 상태를 반환해 주기 때문에 요청 상태를 확인하기 쉽다.

 

7. 그 밖의 에러처리

커스텀 에러를 만들어서 처리할 수도 있다.

 

API 모킹

서버 API가 완성되기 전에 가짜 모듈을 활용하는 것을 모킹이라고 한다.

모킹의 사용 예시로는

  1. JSON 파일 불러오기
    간단한  경우 사용하는 방법. 
  2. NextApiHandler 활용하기
    Next.js에 존재함. 응답 처리 로직도 추가 가능
  3. API 요청 핸들러에 분기 추가하기
    분기처리를 통해서 필요할 떄에만 실제 요청을 보낼 수 있다. 이 방법은 개발 이후에도 쓸 수 있으니, 모든 api요청에 if 분기문을 추가해야하므로 번거로울 수 있다.

    const mockFetchBrands = (): Promise<FetchBrandsResponse> =>
      new Promise((resolve) => {
        setTimeout(() => {
          resolve({
            status: 'SUCCESS',
            message: null,
            data: [
              {
                id: 1,
                label: '배민스토어',
              },
              {
                id: 2,
                label: '비마트',
              },
            ],
          });
        }, 500);
      });
    
    const fetchBrands = () =>{
        if(useMock){
            return mockFetchBrands();
        }
        return requester.get("/brands")
    }
  4. axios-mock-adapter로 모킹하기
    서비스에 분기문이 추가되는 것을 바라지 않는다면 ,axios-mock-adapter 라이브러리를 사용하면 된다. 해당 라이브러리는 Axios 요청을 가로채서 요청에 대한 응답 값을 대신 반환한다.
    에러 및 HTTP 메서드에 대한 목업도 작성 가능하다.
  5. 목업 사용 여부 제어하기
    플래그를 사용하여 목업으로 개발할 떄와 개발하지 않을 때를 구분할 수 있다.

const useMock = Object.is(REACT_APP_MOCK, 'true');
const mockFn = ({status=200,time=100,use=true}:MockResult) => use &&
    mock.onGet(/\/order\/list/).reply(()=>
    new Promise((resolve)=>
    setTimeout(()=>{
        resolve([
            status,
            status ===200? fetchOrderListSuccessResponse : undefined,
        ]);
    },time)
    )

    if(useMock){
        mockFn({status:200,time:100,use:true})
    }
)

위 처럼 플래그에 따라 mockFN을 제어할 수 있는데, 매개변수를 넘겨 특정 mock함수만 동작 여부를 선택할 수 있다.
이후 스크립트 실행 시 구분 짓고자 한다면 package.json에 관련 스크립트를 추가해줄 수도 있다.

728x90

이번장의 내용은 배민 팀에서 비동기 호출 중에서 API 요청 및 응답 행위를 어떻게 처리했는지를 다룬다. 

각각의 문제점을 어떻게 개선 및 보완하려했고 시도했던 방법들을 언급해준다.

API요청

1. fetch함수 대신 Axios를 도입하게 된 이유

 

내장함수인 fetch를 통해 기본적으로 구현했는데, 가장 안 좋은 모델은 특정 컴퍼넌트에서 일일이 API 요청을 복사해가면 하는 것이다. 같이 요청에 대해서도 똑같은 URI를 복붙해서 썼고 이는 백엔드 URI 변경 및 추가적인 요청 정책이 추가될 떄마다 번거러움이 발생했다.

 

이를 해결하려면 우선적으로 서비스 레이어로 분리할 필요가 있다.

컴퍼넌트와 요청을 관리하는 fetch함수를 따로 분리하는 것이다. 하지만 직접 타임 아웃, 커스텀 헤더 추가 등등 다양한 정책을 구현하는 것은 번거롭기 때문에 Axios라이브러리를 사용한다.

 

2. Axios 활용

1) 일반적인 활용

const defaultConfig = {
  baseURL: 'https://api.example.com',
  timeout: 5000,
  headers: {
    'Content-Type': 'application/json',
  },
};
const apiRequester: AxiosInstance = axios.create(defaultConfig);

const orderApiRequester: AxiosInstance = axios.create({
  ...defaultConfig,
  baseURL: 'https://api.baemin.or/',
});

const orderCartApiRequester: AxiosInstance = axios.create({
  ...defaultConfig,
  baseURL: 'https://api.baemin.order/',
});

const setRequestDefaultHeader = (requestConfig) => {
  const config = { ...requestConfig };

  config.headers = {
    ...config.headers,
    'Content-Type': 'application/json;charset=utf-8',
    user: '유저토큰',
  };

  return config;
};

apiRequester.interceptors.request.use(setRequestDefaultHeader);

 

 

axios인스턴스를 도입하면서 uri의 중복을 막고 유지보수성을 향상 시킬 수 있다.

그리고 interceptor 도입해서 header 및 각종 config , 에러를 서비스레이어에서 처리할 수 있게 된다.

 

2) 빌더 패턴 사용

 

위의 방법과 다르게 요청 옵션 따라 다른 인터셉터를 만들기 위해 빌더 패턴을 추가하여 APIBuilder 같은 클래스 형태로 구성할 수도 있다.

 

class API {
  readonly method: HTTPMethod;
  readonly url: string;
  baseURL?: string;
  headers?: HTTPHeaders;
  data?: unknown;
  timeout?: number;
  withCredentials?: boolean;

  constructor(method: HTTPMethod, url: string) {
    this.method = method;
    this.url = url;
  }

  call<T>(): AxiosPromise<T> {
    const http = axios.create();

    if (this.withCredentials) {
      http.interceptors.response.use(
        (response) => response,
        (error) => {
          if (error.response && error.response.status === 401) {
            //에러처리
          }
          return Promise.reject(error);
        }
      );
    }
    return http.request({ ...this });
  }
}

기본 API 클래스로 실제 호출 부분을 구성하고, 위와 같은 API를 호출하기 위한 래퍼를 빌더 패턴으로 만든다.

 

class APIBuilder {
  private _instance: API;

  constructor(method: HTTPMethod, url: string, data?: unknown) {
    this._instance = new API(method, url);
    this._instance.baseURL = 'hostURL';
    this._instance.data = data;
    this._instance.headers = {
      'Content-Type': 'application/json;charset=utf-8',
    };
    this._instance.timeout = 5000;
    this._instance.withCredentials = false;
  }

  static get = (url: string) => new APIBuilder('GET', url);
  static put = (url: string, data: unknown) => new APIBuilder('PUT', url, data);
  static post = (url: string, data: unknown) =>
    new APIBuilder('POST', url, data);
  static delete = (url: string) => new APIBuilder('DELETE', url);

  baseURL(value: string): APIBuilder {
    this._instance.baseURL = value;
    return this;
  }

  headers(value: HTTPHeaders): APIBuilder {
    this._instance.headers = value;
    return this;
  }

  //...
}

 

APIBuilder 클래스는 보일러플레이트 코드가 많다는 단점을 갖고 있다.

하지만 옵션이 다양한 경우에 인터셉터를 설정값에 따라 적용하고, 필요 없는 인터셉터를 선택적으로 사용할 수 있다는 장점이 있다.

 

3.  API응답 타입 지정하기

같은 서버에서 오는 응답의 형태는 대체로 통일되어 있어서 하나의 Response 타입으로 묶일 수 있다.

interface Response<T> {
  data: T;
  status: string;
  serverDateTime: string;
  errorCode?: string; //FAIL, ERROR
  errorMessage?: string;
}

const fetchCart = (): AxiosPromise<Response<FetchCartResponse>> =>
  apiRequester.get<Response<FetchCartResponse>>'cart';

다만 Response 타입을 apiRequester 내에서 처리할 때, UPDATE나 CREATE같이 응답이 없을 수 있는 API처리가 까다로워진다. 따라서 Response 타입은 apiRequester가 모르게 관리되어야 한다.

 

API요청 및 응답 값 중에서는 하나의 API 서버에서 다른 API 서버로 넘겨주기만 하는 값도 존재할 수 있다. 해당 값에 어떤 응답이 들어있느지 알 수 없거나 값의 형식이 달라지더라도 로직에 영향을 주지 않느 경우에는 unknown 타입으로 사용하여 알 수 없는 값임을 표현한다.

interface response {
  data: {
    // cartItems: CartItem[];
    forPass: unknown;
  };
}

type ForPass = {
  type: 'A' | 'B' | 'C';
};

const isTargetValue = () => (data.forPass as ForPass).type === 'A';

그리고 만약 forPass안에 프론트 로직에서 사용해야하는 값이 있다면, 여전히 알 수 없으므로 unknown을 유지하고, 넘겨주는 값의 타입은 언제든지 변경될 수 있으므로 forPass 내의 값을 사용하지 않아야한다. 하지만 이미 설계된 프로덕트에서 쓰는 값이라면 프론트 로직에서 써야 하는 값에 대해서만 타입을 선언한 다음 사용하는게 좋다.

4.  View Model 사용해서 API응답

1) 일반적인 케이스

interface ListResponse {
  items: ListItem[];
}

const fetchList = async (filter?: ListFetchFilter): Promise<ListResponse> => {
  const { data } = await apiRequester
    .params({ ...filter })
    .get('/apis/get-list-summaries')
    .call<Response<ListResponse>>();

  return { data };
};

하지만 위와 같이 사용하면 API 응답의 items 인자를 좀 더 정확한 개념으로 나타내기 위해 jobItems등으로 수정하면 해당 컴포넌트도  수정해야한다. 이렇게 수정해야 할 컴포넌트가 API 1개 뿐만 아니라, 사용는 기존 컴포넌트도 수정해야 한다

(초기 프로젝트에서 자주 나옴)

 

2) 뷰 모델을 도입

interface JobListItemResponse {
  name: string;
}

interface JobListResponse {
  jobItems: JobListItemResponse[];
}

class JobList {
  readonly totalItemCount: number;
  readonly items: JobListItemResponse[];

  constructor({ jobItems }: JobListResponse) {
    this.totalItemCount = jobItems.length;
    this.items = jobItems;
  }
}

const fetchJobList = async (
  filter?: ListFetchFilter
): Promise<JobListResponse> => {
  const { data } = await apiRequester
    .params({ ...filter })
    .get('/apis/get-list-summaries')
    .call<Response<JobListResponse>>();

  return new JobList(data);
};

뷰 모델을 만들면 API 응답이 바뀌어도 UI가 꺠지지 않게 개발할 수 있다. 또한 API 응답에는 없는 totalItemCount 같은 도메인 개념을 넣을 때 백엔드나 UI에서 로직을 추가하여 처리할 필요 없이 간편하게 새로운 필드를 뷰 모델에 추가할 수 있다.

 

하지만 뷰모델에서도 '추상화 레이어 추가는 결국 코드를 복잡하게 만들며 레이어 관리하고 개발하는데 비용이 든다'는 단점이 있다. 앞의 코드에서 JobListItemResponse 타입은 서버에서 지정한 응답 형식이기 때문에 이를 UI에서 사용하려면  더 많은 타입을 선언해야 한다. 앞 코드의 totalItemCount 같이 API 응답에는 없는 새로운 필드를 만들어서 사용할 때, 서버가 내려준 응답과 클라이언트가 실제 사용하는 도메인이 다르면 서버와 클라이언트 간의 의사소통 문제도 생길 수 있다.

 

따라서 API 응답이 바뀌었을 떄는 클라이언트 코드를 수정하는 데 들어가는 비용을 줄이면서도 도메인의  일관성을 지킬 수 있는 절충안을 찾아야 한다.

 

ex) 꼭 필요한 곳에만 뷰ㄷ모델 부분적으로 만들어서 사용하기, 백엔드와 클라이언트 개발자가 충분히 소통해 API 응답 변화 최대한 줄이기, 뷰 모델에 필드를 추가하는 대신 getter 등의 함수를 축하여 실제 어떤 값이 뷰 모델에 추가한 값인지 알기 쉽게 하기 등등

 

5.  Superstruct를 사용해서 A런타임에서 응답 타입 검증하기

Superstruct 라이브러리는 2가지의 핵심 역할을 언급한다.

  • 인터페이스 정의와 자바스크립트 데이터의 유효성 검사를 쉽게 하기
  • 런타임에서의 데이터 유효성 검사를 통해 개발자와 사용자에게 자세한 런타임 에러를 보여주기

 

import { assert, object, number, string, array } from 'superstruct'
//https://docs.superstructjs.org/

const Article = object({
  id: number(),
  title: string(),
  tags: array(string()),
  author: object({
    id: number(),
  }),
})

const data = {
  id: 34,
  title: 'Hello World',
  tags: ['news', 'features'],
  author: {
    id: 1,
  },
}

assert(data, Article)
// This will throw an error when the data is invalid.
// If you'd rather not throw, you can use `is()` or `validate()`.
is(data,Article);
validate(Data,Article);

Article이라는 변수는 Superstruct의 object() 모듈의 반환 결과다. (id는 숫자, title은 문자열 등등의 속성을 가진 객체)

data는 정보를 다음 객체다.

 

assert,is,validate 모듈은 유효성 검사를 도와주는 모듈들이다.

공통점은 데이터 정보를 담은 data 변수와 데이터 명세를 가진 스키마인 Article을 인자로 받아 데이터가 스키마와 부합하는지 검사하는 것이다.

차이점은

  • assert : 유효하지 않을 경우 에러를 던진다.
  • is : 유혀성 감사 결과에 따라 true 또는 false를 반환한다.
  • validate : [error,data] 형식의 튜플을 반환한다. 유효하지 않을 떄는 에러 값이 반환되고 유효한 경우에는 첫 번째 요소로 undefined, 두 번쨰 요소로 data value가 반환된다.
import { Infer, number, object, string, assert } from 'superstruct';

const User = object({
  id: number(),
  email: string(),
  name: string(),
});

// type User = Infer<typeof User>;

type User = {
  id: number;
  email: string;
  name: string;
};

function isUser(user: User) {
  assert(user, User);
  console.log('적절한 유저입니다.');
}

 

적절한 값이 들어온다면 "적절한 유저입니다"가 출력되고 아닌 경우(오염된 경우)에는 런타임 에러가 발생한다.

 

이를 활용하여 아래와 같이 사용할 수 있다. (타입이 다를 경우 에러를 던져서 런타임 유효성 검사를 할 수 있다.)

import {assert} from "superstruct";

functoin isListItem(listItems : ListItem[]){
	listItems,forEach((listItem) => aseert(listItem, ListItem));
}

 

API 상태 관리하기

실제 API 요청시에는 성공 유무에 따른 상태 관리가 되어야 하므로 상태 관리 라이브러리의 액션이나 훅과 같이 재정의된 형태를 사용해야 한다.

 

1. 상태 관리 라이브러리에서 호출

상태 관리 라이브러리의 비동기 함수들은 서비스 코드를 사용해서 비동기 상태를 변화시킬 수 있는 함수를 제공한다. 컴포넌트느 이러한 함수를 사용하여 상태를 구독하며, 상태가 변경될 때 컴포넌트를 다시 렌더링하는 방식으로 동작한다.

 

redux에서는 미들웨어를 통해 비동기 상태를 관리한다. 그 결과 보일러플레이트가 많다.

Mobx에서는 위의 불편함을 개선하기 위해 비동기 콜백함수를 분리하여 액션을 만들거나 runInAction 과 같은 메서드를 사용하여 상태 변경을 처리한다. 또한 async/await 나 flow 같은 비동기 상태 관리를 위한 기능도 있다.

 

모든 상태 관리 라이브러리에서 비동기 처리 함수를 호출하기 위해 액션이 추가될 때마다 관련 스토어나 상태가 늘어난다. 이로 인한 가장 큰 문제는 전역 상태 관리자가 모든 비동기 상태에 접근하고 변경할 수 있다는 것이다.

 

2. 훅으로 호출

react-query나 useSwr 같은 훅을 사용한 방법은 훨씬 간단하다. 이러한 훅은 캐시를 사용하여 비동기 함수를 호출하며, 상태 관리 라이브러리에서 발생했던 읟도치 않은 상태 변경을 방지하는 데 도움이 된다.

 

 

 

728x90

고수준 언어 : 사람이 이해하기 쉬운 언어

저수준 언어:  컴퓨터가 이해하기 쉬운 언어

 

자바스크립트는 대표적인 고수준언어로 컴파일러에 의해 기계어 코드로 변환되어 실행이 가능한 프로그램이 된다.

 

컴파일타임 :  소스코드가 컴파일 과정을 거쳐 컴퓨터가 인식할 수 있는 기계어로 변환되는 과정

런타임 : 소스코드가 컴파일이 완료되면 프로그램이 메모리에 적재되어 실행되는 과정

 

타입스크립트의 컴파일

타입스크립트는 컴파일 시 또 다른 수준의 고수준 언어인 자바스크립트로 바뀐다. 

이를 트랜스파일이라고 하는데, 이를 바탕으로 컴파일을 크게 2가지로도 구분한다.

 

좁은 의미의 컴파일: 고수준 언어를 저수준 언어로 바꾸는 과정

트랜스파일 : 고수준언어를 또 다른 고수준 언어로 변환하는 과정(소스 대 소스 컴파일러)
                      ex) 바벨, Emscripten

넓은 의미의 컴파일 :  위의 두 경우를 합친 것

 

타입스크립트 컴파일러 컴파일 과정

tsc 단계 , 런타임 단계

  1. 타입스크립트 소스코드를 타입스크립트 AST(Abstract Syntax Tree : 소스코드를 해석하는 과정에서 생긴
    데이터 구조)로 만든다.
  2. 타입 검사기가 AST를 확인하여 타입을 확인한다.
  3. 타입스크립트 AST를 자바스크립트 소스로 변환한다.
  4. 자바스크립트 소스코드를 자바스크립트 AST로 만든다.
  5. AST가 바이트 코드로 변환된다.
  6. 런타임에서 바이트 코드가 평가되어 프로그램이 실행된다.

이때 타입스크립트 소스코드 타입은 1~2단계에만 적용되고, 3단계부터는 타입을 확인하지 않는다. 

따라서 개발자가 작성한 타입 정보는 최종적으로 만들어지는 프로그램에는 아무런 영향을 주지 않는다.

 

하지만 순서상 컴파일타임에 타입을 검사하며 에러가 발생하면 프로그램이 실행되지 않기에 정적 타입 검사기라고 부른다.

 

타입스크립트 컴파일러의 동작

  1. 코드 검사기로서의 타입스크립트 컴파일러
    타입스크립트 컴파일러는 tsc binder를 사용하여 타입 검사를 하며, 컴파일타임에 타입 오류를 발견한다.
    const developer = {
      worker() {
        console.log('working...');
      },
    };
    
    developer.worker();
    // developer.sleep();//'{ worker(): void; }' 형식에 'sleep' 속성이 없습니다.ts(2339)​


  2. 코드 변환기로서의 타입스크립트 컴파일러
    타입스크립트 컴파일러는 타입을 검사한 다음에  타입스크립트 코드를 각자의 런타임 환경에서 동작할 수 있도록 구버전의 자바스크립트로 트랜스파일한다.
    >> 자바스크립트로 컴파일되어야 브라우저는 코드를 이해하고 실행할 수 있음.


타입스크립트 컴파일러의 구조

타입스크립트는 컴파일러라는 tsc 명령어로 실행되고,  이는 tsconfig.json에 명시된 컴파일 옵션을 기반으로 컴파일을 수행한다. 먼저 전체적인 컴파일 과정을 관리하는 프로그램 객체가 생성된다.  이 프로그램 객체는 컴파일할 타입스크립트 소스 파일과 소스 파일 내에서 임포트된 파일을 불러오는데, 가장 최초로 불러온 파일을 기준으로 컴파일 과정이 시작된다.

 

타입스크립트 컴파일러의 실행 과정

 

  1. 스캐너
    타입스크립트 소스 파일을 어휘적으로 분석하여 토큰을 생성하는 역할을 한다.
    const woowa = "bros";​​​
     const : ConstKeyword
       : (띄어쓰기 3개) WhitespaceTrivia
    woowa : Identifier
    = : EqualsToken
    "bros" : StringLiteral
    ;  : SemicolonToken

  2. 파서
    스캐너가 소스 파일을 토큰으로 나눠주면 파서는 그 토큰 정보를 이용하여 AST를 생성한다.
    AST는 컴파일러가 동작하는 데 핵심 기반이 되는 자료 구조로 , 소스코드의 구조를 트리 형태로 표현한다.
    각각의 노드는 코드상의 위치, 구문 종류, 코드 내용 같은 정보를 담고 있다.
    AST 최상위 노드는 타입스크립트 소스 파일이며, 최하위 노드 파일의 끝 지점이다.
    파서는 생성된 토큰 목록을 활용하여 구문적 분석을 한다.

  3. 바인더
    바인더의 주요  역할은 체커 단계에서 타입 검사를 할 수 있도록 기반을 마려함. 이를 위해 심볼을 생성한다.
    심볼은 이전 단계의 AST에서 선언된 타입의 노드 정보를 저장한다.
    export interface Symbol {
    	flags : SymbolFlags;
    	excapedName : string;
        declarations?: Declaration[];
    }​
     심볼의 인터페이스 일부는 위와 같으면 flags 필드는 AST에서 선업된 타이브이 노드 정보를 저장하는 식별자이다.
    심볼 인터페이스의 declarations 필드는 AST 노드의 배열 형태를 보인다.
    결과적으로 바인더는 심볼을 생성하고  해당 심볼과 그에 대응하는 AST 노드를 연결하는 역할을 수행한다.
  4. 체커
    체커는 파서가 생성한 AST와 바인더가 생성한 심볼을 활용하여 타입 검사를 수행한다.
    이 단계에서 체커의 소스 크기가 다른 것에 비해 큰 것을 통해 전체 컴파일 과정에서 타입 검사가 차지하는 비중이 크다는 것을 짐작할 수 있다.
    체커의 타입 검사는 다음 컴파일 단계인 이미터에서 실행된다.
  5. 이미터
    이미터는 타입스크립트 소스 파일을 변환하는 역할을 한다. 즉, 타입스크립트 소스를 자바스크립트파일(js)과 타입 선언 파일 (d.ts)로 생성한다.
    이미터는 타입스크립트 소스 파일을 변환하는 과정에서 개발자가 설정한 타입스크립트 설정 파일을 읽어오고, 체커를 통해 코드에 대한 타입 검증 정보를 가져온다.
    그리고 emitter.ts 소스 파일 내부의 emitFiles() 함수를 사용하여 타입스크립트 소스 변환을 진행한다.

 

 

 

 

+ Recent posts