0328 - 0403
0328
React 훅
등장 배경
컴포넌트에 보다 많은 로직을 작성해 래퍼 지옥 문제를 해결하려고 하면 컴포넌트가 커지고, 리팩토링 하기 어려워진다.
반면, 컴포넌트를 더 작은 조각으로 나눠 재사용하려고 하면 컴포넌트 트리보다 훨씬 많은 중첩이 생겨 래퍼 지옥이 다시 나타난다.
레거시 설계
- 리팩토링, 테스트가 어려운 규모가 큰 컴포넌트
- 컴포넌트 간 중복된 상태, 라이프 사이클
- Render Props, HOC와 같은 복잡한 패턴
- React 컴포넌트 간에 로직을 재사용하기 위해 사용된 Render Props, HOC 패턴은 코드 추적을 어렵게 한다.
- 컨텍스트 공급/수요자, 고차 컴포넌트 등을 포함한 수많은 추상 레이어로 복잡하게 엮여진 래퍼 지옥을 보게 된다.
-> 문제의 원인을 리액트 팀은 클래스 컴포넌트에서 로직 재사용을 위해 사용되었던 복잡한 방식보다 간단한 방법을 제공하지 못했기 때문이라고 분석했다.
- 클래스 컴포넌트를 사용할 때 JS의 this가 어떻게 동작하는지 이해해야 하지만, 여타 언어와 다르게 작동함에 따라 사용자의 혼란을 부추김
- 이벤트 핸들러를 등록하기 위한 다양한 방식을 이해해야 했고, 클래스 필드 구문의 도움이 없을 경우 코드는 매우 장황해졌다.
- 클래스는 번들 과정에서 파일의 크기를 증가시켜 성능 문제를 가져온다.
React 훅의 장점
- 클래스를 사용하지 않아도 함수 컴포넌트를 중심으로 앱 개발이 가능
- 훅을 사용해 컴포넌트에서 상태 및 로직을 추출한 후, 다른 컴포넌트에서 재사용 할 수 있다.
- 서로 관련 있는 코드들을 한군데에 모아 작성할 수 있다.
React 훅의 특징
캡슐화
- 훅은 완전하게 캡슐화 처리되므로, 현재 실행 중인 컴포넌트에서 훅을 호출할 때에도 격리된 로컬 상태를 유지한다.
- 훅은 상태를 공유하는 방법이 아니라, 상태 저장 로직을 공유하는 방법이다. 결과적으로 리액트의 단방향 데이터 흐름을 깨트리지 않는다.
클린 트리
- 훅 간에 데이터를 전달하는 기능은 Hooks를 애니메이션, 구독, 폼 관리 등을 처리하는데 매우 적합하다.
- Hooks는 컴포넌트 트리를 어지럽게 만드는 거짓 된 레이어를 추가하지 않는다. 컴포넌트에 연결된 메모리 셀의 단순한 리스트와 유사하다.
상태 유지 위치
- 리액트가 Hooks에 대한 상태를 유지하는 위치는 클래스 컴포넌트 상태를 유지하는 방법과 똑같이 유지한다.
- 리액트에는 컴포넌트를 어떻게 정의하든 관계없이 모든 상태에 대한 내부 업데이트 대기열이 있다.
보다 작은 번들 크기
- Hooks는 클래스를 사용할 때 보다 번들된 파일의 크기를 줄여 다소 성능 향상을 기대할 수 있다.
Hooks의 사용 규칙
Hooks는 조금 특별할 뿐인 일반 JS 함수로, 다음 2가지 규칙을 준수해야 한다.
- React 함수 컴포넌트, 다른 커스텀 Hook 함수 안에서만 사용 가능하다. 일반 함수, 또는 클래스 컴포넌트 안에서 호출하면 안 된다.
- Hook은 반복문, 조건문 또는 중첩 된 함수 안에서 사용할 수 없다. 컴포넌트, 커스텀 훅 함수 최상위에만 사용
- 이 규칙을 따르면 컴포넌트가 렌더링 될 때마다 항상 동일한 순서로 Hook이 호출되는 것을 보장한다.
- 이러한 점은 useState와 useEffect가 여러 번 호출되는 중에도 Hook의 상태를 올바르게 유지할 수 있도록 해준다.
useState
함수 컴포넌트에서 상태를 관리할 때는 React.useState() 훅을 사용한다.
상태 값, 업데이트 함수
useState()훅은 상태와 상태 업데이트를 담당하는 함수를 반환한다.- 훅에 최초 전달된 값이 컴포넌트의 초기 상태 값이 되며, 함수 컴포넌트가 다시 렌더링 될 때는 항상 이전의 업데이트 된 상태 값이 최신 상태 값이 된다.
const [stateValue, stateUpdater] = useState(initialState);
지연된 초기화
- initialState 인자는 함수 컴포넌트 초기 렌더링 시에만 사용되는 state 초깃값을 설정한다.
- 이후 다시 렌더링 될 때는 이 값이 무시된다.
- 만약 state 초깃값을 계산하는데 많은 시간이 필요한 경우는 초기 렌더링 시에만 실행될 함수를 설정해 지연된 초기화 처리가 가능하다.
const [stateValue, setUpdater] = useState(() => {
const initialState = localStorage.getItem("item");
return JSON.parse(initialState);
});
객체 타입 상태 관리
- useState() 훅에서 클래스의 state, setState()와 동일하게 작동되도록 하려면 합성된 객체를 반환해야 한다.
const [state, updateState] = useState({
key1: false,
key2: true,
});
updateState({
...state,
key2: true,
});
- useState()는 상태를 합성하는 것이 아니라 대체한다.
- 클래스 컴포넌트의 setState 메서드와 다르게, 이전 상태를 새로운 상태가 대체한다.
useEffect
함수 컴포넌트 본문은 class 컴포넌트의 render 메서드에 해당되므로 사이드 이펙트 코드를 포함할 수 없지만 useEffect() 훅에 전달 된 함수 내부에서는 사이드 이펙트를 작성할 수 있다.
함수 컴포넌트에서 발생 가능한 부수 효과는 React.useEffect 훅으로 관리한다.
이펙트 함수
- 사이드 이펙트(비동기 통신 요청/응답, DOM 조작, 구독/취소 등)는 클래스 컴포넌트의 render 메서드에서는 다룰 수 없다.
- 이를 다룰 수 있는 라이프 사이클 메서드 내에서만 다뤄야 한다.
- 클래스에서는 side effect를 componentDidMount와 componentDidUpdate에 두는 데, 두 개의 생명주기 메서드에 같은 코드가 중복되는 것에 주의해야 한다. 이는 컴포넌트가 같은 사이드 이펙트를 수행하기 바라기 때문에 발생한다.
- useEffect 훅은 클래스 컴포넌트의 사이드 이펙트 관리 라이프 사이클 메서드 기능을 모두 처리한다.
- useEffect를 컴포넌트 내부에 둠으로써 effect를 통해 state 변수 또는 prop에 접근 가능하다. 함수 범위 안에 두고 클로저를 이용해서 특별한 API 없이도 값을 얻을 수 있다.
- useEffect는 렌더링 이후에 발생한다. 즉, effect가 수행되는 시점에 이미 DOM이 업데이트 되었음을 보장한다.
- useEffect에 전달된 함수는 모든 렌더링에서 다르다. 각각의 effect는 특정한 렌더링에 속한다.
useEffect(() => {
// DOM 마운트(렌더링) 이후 콜백
});
이펙트 조건 처리
- 특정 조건에 따라 이펙트 함수를 실행해야 할 경우, useEffect() 훅의 2번째 인자로 종속성 배열을 설정한다.
useEffect(() => {
// componentDidMount
}, []);
- 종속성 배열에 관리할 상태가 추가되면, 해당 상태가 변경될 때에만 이펙트 함수가 실행된다. (조건 처리)
클린업
- 이펙트 함수를 통해 설정된 사이드 이펙트(구독)는 컴포넌트가 UI에서 제거될 때 정리되어야 한다.
- 이를 수행하려면 이펙트 함수에서 클린업 함수를 반환하고 그 함수 내부에서 정리를 실행한다.
- effect가 함수를 반환하면 리액트는 그 함수를 정리가 필요한 때에 실행시킨다.
useEffect(() => {
// 구독
return () => {
// 정리 함수
// 구독 취소
};
}, []);
- 클린업 함수는 메모리 누수 방지를 위해 UI에서 컴포넌트를 제거하기 직전 수행된다.
- 이는 클래스 컴포넌트의 componentWillUnmount와 유사하게 동작한다고 볼 수 있지만, 실상은 다음 이펙트 함수가 실행될 때마다 클린업 함수가 먼저 실행되어 정리한다.
effect를 이용하는 팁
관심사를 구분하기 위해서는 Multiple Effect를 사용한다
- 훅이 탄생한 동기가 된 문제 중의 하나가 생명주기 클래스 메서드가 관련 없는 로직들을 모아놓고, 관련이 있는 로직들은 여러 개의 메서드에 나누어 놓는 경우가 자주 있다는 것이다.
- State Hook을 여러 번 사용할 수 있는 것처럼 effect 또한 여러 번 사용할 수 있다. Effect를 이용하여 서로 관련이 없는 로직들을 갈라놓을 수 있다.
- 훅을 이용하면 생명주기 메서드에 따라서가 아니라 코드가 무엇을 하는지에 따라 나눌 수 있다. 리액트는 컴포넌트에 사용된 모든 effect를 지정된 순서에 맞춰 적용한다.
effect가 업데이트 시마다 실행되는 이유
- 컴포넌트가 화면에 표시되어 있는 동안 prop이 변한다면 무슨 일이 일어날까?
- 버그가 발생하고, 마운트 해제가 일어날 동안에 잘못 호출되어 메모리 누수나 충돌이 발생할 수 있다.
- 클래스 컴포넌트에서는 이를 해결하기 위해 componentDidUpdate를 사용하는데, 리액트 애플리케이션의 흔한 버그 중의 하나가 이를 제대로 다루지 않아서 발생한다.
- useEffect는 기본적으로 업데이트를 다루기 때문에 더는 업데이트를 위한 특별한 코드가 필요 없다. 다음의 effect를 적용하기 전에 이전의 effect를 정리하여 더욱 사용하기 간편하다.
Effect를 건너뛰어 성능 최적화하기
- 모든 렌더링 이후에 effect를 정리하거나 적용하는 것이 때때로 성능 저하를 발생시키는 경우도 있다.
- 클래스 컴포넌트의 경우에는 componentDidUpdate에서 prevProps나 prevState와의 비교를 통해 문제를 해결할 수 있다.
- useEffect의 두 번째 인수로 배열을 넘기면 내부 값이 변경되지 않는다면 리액트로 하여금 effect를 건너뛰도록 할 수 있다.
useEffect(() => {
document.title = `You clicked ${count} times`;
}, [count]); // count가 바뀔 때만 effect를 재실행합니다.
- 리렌더링 이후에 종속성 배열 내의 모든 값이 변하지 않았다면 effect를 건너뛰게 된다.
- 이 최적화 방법을 사용한다면 배열이 컴포넌트 범위 내에서 바뀌는 값들과 effect에 의해 사용되는 값들을 모두 포함하는 것을 기억해야 한다.
- 그렇지 않으면 현재 값이 아닌 이전의 렌더링 때의 값을 참고하게 된다.
- effect를 실행하고 이를 정리하는 과정을 딱 한 번씩만 실행하고 싶다면, 빈 배열을 두 번째 인수로 넘기면 된다.
- 이렇게 함으로써 리액트로 하여금 effect가 props나 state 그 어떤 값에도 의존하지 않으며 따라서 재실행되어야 할 필요가 없음을 알게 하는 것이다.
0329
custom hook
커스텀 훅은 기술적으로 React 만의 고유 기능이 아니라, 이름이 use로 시작하는 일반적인 JavaScript 함수이다.
이를 통해 복잡한 로직을 컴포넌트에서 분리하여 다른 컴포넌트에서 손쉽게 재사용할 수 있다.
예를 들어, 뷰포트 정보를 여러 컴포넌트에서 표시해야 한다고 가정해보자
이런 경우 특정 컴포넌트 내부에서만 사용되도록 로직을 구성하기 보다는, 다른 컴포넌트에서도 재사용 할 수 있도록 로직을 구성해야 한다.
import { useState, useEffect } from "react";
export const useViewport = () => {
const { innerWidth, innerHeight } = window;
const [width, setWidth] = useState(innerWidth);
const [height, setHeight] = useState(innerHeight);
useEffect(() => {
const handleResize = () => {
setWidth(window.innerWidth);
setHeight(window.innerHeight);
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
return { width, height };
};
- 커스텀 훅은 useStatel, useEffect와 같은 기본 Hooks를 활용한다.
- 이처럼 여러 컴포넌트에서 재사용 가능한 로직을 손쉽게 분리, 재사용할 수 있어 커스텀 훅을 사용하는 것은 React의 고유 API를 사용하는 것처럼 일반적이다.
데이터 패치 Hook
데이터 요청/응답과 같이 자주 사용되는 사이드 이펙트 로직 또한 커스텀 훅을 사용해 재사용할 수 있다.
import { useState, useEffect } from "react";
import axios from "axios";
function useFetchData(apiEndpoint) {
const [state, setState] = useState({
loading: true,
data: null,
error: null,
});
useEffect(() => {
async function getData() {
try {
const { data } = await axios.get(apiEndpoint);
setState({ ...state, data });
} catch (error) {
setState({ ...state, error });
} finally {
setState({ ...state, loading: false });
}
}
getData();
}, [apiEndpoint]);
return state;
}
- 이는 커스텀 훅을 사용하지 않은 코드와 동일한 코드로, 정확히 같은 방식으로 작동한다.
- 바뀐 것은 오로지 공통의 코드를 뽑아내 새로운 함수로 만든 것뿐이다.
- 커스텀 훅은 리액트의 특별한 기능이라기보다 기본적으로 디자인을 따르는 관습이다.
- 커스텀 훅을 작성할 때 이름은 use로 시작해야 하는데, 이를 따르지 않으면 특정한 함수가 그 안에서 Hook을 호출하는지를 알 수 없기 때문에 Hook 규칙의 위반 여부를 자동으로 체크할 수 없다.
- 같은 훅을 사용하더라도 두 개의 컴포넌트는 state를 공유하지 않는다. 커스텀 훅은 재사용하는 메커니즘이지만 사용할 때마다 그 안의 state와 effect는 완전히 독립적이다.
- 너무 이른 단계에서 로직을 뽑아내려고 하기 보다는, 커스텀 훅이 복잡한 로직을 단순한 인터페이스 속에 숨길 수 있도록 하거나 복잡하게 뒤엉킨 컴포넌트를 풀어내도록 돕는 경우를 찾아내서 사용하자.
0330
타입 선언과 관련된 세 가지 버전 이해하기
타입스크립트는 의존성 관리를 오히려 더 복잡하게 만든다.
타입스크립트를 사용하면 다음 세 가지 사항을 추가로 고려해야 하기 때문이다.
- 라이브러리 버전
- 타입 선언의 버전
- 타입스크립트 버전
세 가지 버전 중 하나라도 맞지 않으면, 의존성과 상관없어 보이는 곳에서 엉뚱한 오류가 발생할 수 있다.
타입스크립트 라이브러리 관리의 메커니즘을 이해하게 된다면 프로젝트 내에서 작성한 타입 선언을 외부에 공개해야 하는 시점이 되었을 때, 버전과 관련해서 제대로 된 결정을 내릴 수 있다.
라이브러리와 타입 정보의 버전이 별도로 관리되는 방식의 문제점
라이브러리를 업데이트했지만 실수로 타입 선언은 업데이트 하지 않는 경우
라이브러리 업데이트와 관련된 새로운 기능을 사용하려 할 때마다 타입 오류가 발생하게 된다.
하위 호환성이 깨지는 변경이 있었다면, 코드가 타입 체커를 통과하더라도 런타임에 오류가 발생할 수 있다.
일반적인 해결책은 타입 선언도 업데이트하여 라이브러리와 버전을 맞추는 것
타입 선언의 버전이 아직 준비되지 않은 경우 보강 기법을 활용하여 사용하려는 타입 정보를 프로젝트 자체에 추가하거나, 타입 선언의 업데이트를 직접 작성하고 공개하여 커뮤니티에 기여하는 방법이 있다.
라이브러리보다 타입 선언이 최신인 경우
이런 경우 타입 정보 없이 라이브러리를 사용해 오다가 타입 선언을 설치하려고 할 때 뒤늦게 발생
타입 체커는 최신 API를 기준으로 코드를 검사하게 되지만 런타임에 실제로 쓰이는 것은 과거 버전
해결책은 라이브러리와 타입 선언의 버전이 맞도록 라이브러리 버전을 올리거나 타입 선언 버전을 내리는 것
프로젝트에서 사용하는 TS 버전보다 라이브러리에서 필요로 하는 TS 버전이 최신인 경우
현재 프로젝트보다 라이브러리에게 필요한 TS 버전이 높은 상황이라면, @types 선언 자체에서 타입 오류가 발생하게 된다.
이 오류를 해결하려면 프로젝트 TS 버전을 올리거나, 라이브러리 타입 선언의 버전을 원래대로 내리거나, declare module 선언으로 라이브러리의 타입 정보를 없애 버리면 된다.
라이브러리와 타입 선언의 버전을 일치시키는 것이 최선이겠지만, 상황에 따라 해당 버전의 타입 정보가 없을 수도 있다.
@types 의존성 중복
@types/foo와 @types/bar에 의존하는 경우, 만약 @types/bar가 현재 프로젝트와 호환되지 않는 버전의 @types/foo에 의존한다면 npm은 중첩된 폴더에 별도로 해당 버전을 설치하여 문제를 해결하려고 한다.
런타임에 사용되는 모듈이라면 괜찮을 수 있지만, 전역 네임스페이스에 있는 타입 선언 모듈이라면 대부분 문제가 발생한다.
전역 네임스페이스에 타입 선어니 존재하면 중복된 선언, 또는 선언이 병합될 수 없다는 오류가 발생한다.
해결책은 둘 중 하나를 업데이트해서 서로 버전이 호환되게 하는 것이다.
타입 선언을 포함(번들링)하는 경우 문제점
자체적 타입 선언은 보통 package.json의 “types” 필드에서 .d.ts 파일을 가리키도록 되어 있다.
번들된 타입 선언에 보강 기법으로 해결할 수 없는 오류가 있는 경우 or 공개 시점에는 잘 동작했지만 TS 버전이 올라가면서 오류가 발생하는 경우
@types을 별도로 사용하는 경우라면 라이브러리 자체의 버전에 맞추어 선택할 수 있지만, 번들된 타입에서는 버전 선택이 불가능하다.
단 하나의 잘못된 타입 선언으로 인해 TS 버전을 올리지 못하는 불상사가 생길 수 있는 것이다.
프로젝트 내의 타입 선언이 다른 라이브러리의 타입 선언에 의존하는 경우
JS 사용자 입장에서 @types를 설치할 이유가 없기 때문에 다른 라이브러리의 타입 선언을 설치할 이유가 없다.
해결책은 아이템 51장에서..
프로젝트의 과거 버전에 있는 타입 선언에 문제가 있는 경우
과거 버전으로 돌아가서 패치 업데이트를 해야 한다.
DefinitelyTyped는 동일 라이브러리의 여러 버전의 타입 선언을 동시에 유지보수할 수 있는 메커니즘을 가지고 있다.
타입 선언의 패치 업데이트를 자주 하기 어려움
DefinitelyTyped는 커뮤니티에서 관리되기 때문에 패치 작업량을 감당할 수 있지만, 개별 프로젝트에서는 비슷한 처리 시간을 보장하기 어렵다.
결론
TS에서 의존성 관리는 쉽지 않지만, 잘 관리하면 그에 따른 보상이 함께 존재한다.
잘 작성된 타입 선언은 라이브러리를 올바르게 사용하는 방법을 배우는 데 도움이 되며 생산성 역시 크게 향상시킬 수 있다.
의존성 관리에 문제가 생긴다면 처음 언급했던 세 가지 버전을 기억해야 한다.
타입스크립트로 작성된 라이브러리라면 타입 선언을 자체적으로 포함하고, 자바스크립트로 작성된 라이브러리라면 타입 선언을 DefinitelyTyped에 공개하는 것이 좋다.
공개 API에 등장하는 모든 타입을 익스포트하기
라이브러리 제작자는 프로젝트 초기에 타입 익스포트부터 작성해야 한다.
함수의 선언에 이미 타입 정보가 있다면 제대로 익스포트되고 있는 것이며, 타입 정보가 없다면 타입을 명시적으로 작성해야 한다.
만약 어떤 타입을 숨기고 싶어서 익스포트하지 않았다고 하더라도, 익스포트된 함수 시그니처에 등장하는 타입들은 추출해 낼 수 있다.
추출하는 한 가지 방법은 Parameters와 ReturnType 제너릭 타입을 사용하는 것이다.
그렇기 때문에 공개 메서드에 등장한 어떤 형태의 타입이든 익스포트 하는 것이 좋다.
어차피 라이브러리 사용자가 추출할 수 있으므로, 익스포트하기 쉽게 만드는 것이 좋다.
API 주석에 TSDoc 사용하기
사용자를 위한 문서라면 JSDoc 스타일의 주석으로 만드는 것이 좋다.
대부분의 편집기는 함수가 호출되는 곳에서 함수에 붙어 있는 JSDoc 스타일의 주석을 툴팁으로 표시해 주기 때문이다.
TS 언어 서비스가 JSDoc 스타일을 지원하기 때문에 적극적으로 활용하는 것이 좋다. 만약 공개 API에 주석을 붙인다면 JSDoc 형태로 작성해야 한다.
타입스크리븥 관점에서는 TSDoc이라고 부르기도 한다.
/** 인사말을 생성합니다. 결과는 보기 좋게 꾸며집니다.
* @param name 인사할 사람의 이름
* @param title 그 사람의 칭호
* @returns 사람이 보기 좋은 형태의 인사말
*/
function greet(name: string, title: string) {
return `Hello ${title} ${name}`;
}
타입 정의에 TSDoc을 사용할 수도 있다.
/** 특정 시간과 장소에서 수행된 측정 */
type Measurement = {
/** 어디에서 측정되었나? */
position: Vector3D;
/** 언제 측정되었나? epoch에서부터 초 단위로 */
time: number;
/** 측정된 운동량 */
momentum: Vector3D;
};
- 각 필드에 마우스를 올려 보면 필드별로 설명을 볼 수 있다.
- TSDoc 주석은 마크다운 형식으로 꾸며지므로 굵은 글씨, 기울임 글씨 등을 사용할 수 있다.
- 주석은 간단히 요점만 언급한다.
- 타입스크립트에서는 타입 정보가 코드에 있기 때문에 타입 정보를 명시하면 안 된다.
0331
콜백에서 this에 대한 타입 제공하기
let이나 const로 선언된 변수가 렉시컬 스코프인 반면, this는 다이나믹 스코프이다. 다이나믹 스코프의 값은 정의된 방식이 아니라 호출된 방식에 따라 달라진다.
this 바인딩은 종종 콜백 함수에서 쓰인다.
class ResetButton {
onClick() {
alert(`Reset ${this}`);
}
}
그러나 onClick을 호출하면, this 바인딩 문제로 인해 경고가 뜬다.
일반적인 해결책은 생성자에서 메서드에 this를 바인딩시키는 것이다.
class ResetButton {
constructor() {
this.onClick = this.onClick.bind(this);
}
onClick() {
alert(`Reset ${this}`);
}
}
- 생성자에서 위와 같이 바인딩하면, onClick 속성에 this가 바인딩되어 해당 인스턴스에 생성된다.
- 프로퍼티 탐색 순서에서 onClick 인스턴스 속성은 onClick 프로토타입 속성보다 앞에 놓이므로, this.onClick은 바인딩된 함수를 참조하게 된다.
- 더 간단한 방법은 onClick을 화살표 함수로 바꾸는 것이다.
타입스크립트 역시 this 바인딩을 그대로 모델링하게 된다. 만약 작성 중인 라이브러리에 this를 사용하는 콜백 함수가 있다면, this 바인딩 문제를 고려해야 한다.
이 문제는 콜백 함수의 매개변수에 this를 추가하고, 콜백 함수를 call로 호출해서 해결할 수 있다.
function addKeyListener(
el: HTMLElement,
fn: (this: HTMLElement, e: KeyboardEvent) => void
) {
el.addEventListener("keydown", (e) => {
fn.call(el, e);
});
}
- 콜백 함수의 첫 번째 매개변수에 있는 this는 특별하게 처리된다.
- call을 제거하고 사용하면 타입 에러가 발생하고, this 바인딩도 체크해준다.
- 그리고 라이브러리 사용자의 콜백 함수에서 this를 참조할 수 있고 완전한 타입 안정성도 얻을 수 있다.
- 만약 라이브러리 사용자가 콜백을 화살표 함수로 작성하고 this를 참조하려고 하면 타입스크립트가 문제를 잡아낸다.
- 이처럼 콜백 함수에서 this 값을 사용해야 한다면 this는 API의 일부가 되는 것이기 때문에 반드시 선언 타입에 포함해야 한다.
오버로딩 타입보다는 조건부 타입을 사용하기
string 또는 number 타입의 매개변수가 들어올 수 있는 double 함수에 타입 정보를 추가해보자
이때 함수 오버로딩 개념을 사용했다.
function double(x: number | string): number | string;
function double(x: any) {
return x + x;
}
- double에 number 타입을 매개변수를 넣으면 number 타입을 반환해야 하지만 선언문에는 number 타입을 매개변수로 넣고 string 타입을 반환하는 경우도 포함되어 있다.
- 제너릭을 사용하면 이러한 동작을 모델링 할 수 있지만, 타입이 너무 과하게 구체적이다. string 타입을 매개변수로 넘기면 string 타입이 반환되어야 한다.
또 다른 방법은 여러 가지 타입 선언으로 분리하는 것이다.
타입스크립트에서 함수의 구현체는 하나지만, 타입 선언은 몇 개든지 만들 수 있다.
function double(x: number): number;
function double(x: string): string;
function double(x: any) {
return x + x;
}
- 이를 활용하여 double의 타입을 개선할 수 있지만, 아직도 버그는 남아 있다.
- string이나 number 타입의 값으로는 잘 동작하지만, 유니온 타입 관련해서 문제가 발생한다.
- 세 번째 오버로딩을 추가하여 문제를 해결할 수도 있지만, 가장 좋은 해결책은 조건부 타입을 사용하는 것이다.
function double<T extends number | string>(
x: T
): T extends string ? string : number;
function double(x: any) {
return x + x;
}
- 이 코드는 제너릭을 사용했던 방법과 유사하지만, 반환 타입이 더 정교하다.
- 조건부 타입은 삼항 연산자처럼 사용하면 된다.
- T가 string의 부분집합이면, 반환 타입이 string이고 그 외의 경우는 반환 타입이 number이다.
결론
- 유니온에 조건부 타입을 적용하면, 조건부 타입의 유니온으로 분리되기 때문에 동작한다.
- 오버로딩 타입이 작성하기는 쉽지만, 조건부 타입은 개별 타입의 유니온으로 일반화하기 때문에 타입이 더 정확해진다.
- 오버로딩 타입을 작성하는 경우, 조건부 타입을 사용해서 개선할 수 있을지 검토해 보는 것이 좋다.
0401
의존성 분리를 위해 미러 타입 사용하기
CSV 파일을 파싱하는 라이브러리를 작성한다고 가정해보자.
parseAPI는 매개변수로 string이나 NodeJS 사용자를 위해 매개변수에 Buffer 타입을 허용한다.
Buffer의 타입 정의는 NodeJS 타입 선언을 설치해서 얻을 수 있다.
이때 타입 선언이 @types/node에 의존하기 때문에 @types/node는 devDependencies로 포함해야 한다.
그러나 @types/node를 devDependencies로 포함하면 다음 두 그룹의 라이브러리 사용자들에게 문제가 생긴다.
- @types와 무관한 JS 개발자
- NodeJS와 무관한 TS 웹 개발자
Buffer는 NodeJS와 TS를 동시에 사용하는 개발자만 관련된다.
각자가 필요한 모듈만 사용할 수 있도록 구조적 타이핑을 적용할 수 있다.
@types/node에 있는 Buffer 선언을 사용하지 않고, 필요한 메서드와 속성만별도로 작성할 수 있다.
만약 작성 중인 라이브러리가 의존하는 라이브러리의 구현과 무관하게 타입에만 의존한다면, 필요한 선언부만 추출하여 작성 중인 라이브러리에 넣는 것(미러링)을 고려해 보는 것도 좋다.
그러나 프로젝트 의존성이 다양해지고 필수 의존성이 추가됨에 따라 타입 선언의 대부분을 추출해야 한다면, 차라리 명시적으로 @types 의존성을 추가하는 게 낫다.
테스팅 타입의 함정에 주의하기
프로젝트를 공개하려면 테스트 코드를 작성하는 것은 필수이며, 타입 선언도 테스트를 거쳐야 한다.
그러나 타입 선언을 테스트하기는 매우 어렵다. 궁극적으로는 dtslint 또는 타입 시스템 외부에서 타입을 검사하는 유사한 도구를 사용하는 것이 더 안전하고 간단하다.
타입 선언 파일을 테스팅할 때는 단순히 함수를 실행만 하는 방식을 일반적으로 적용하게 되는데, 그 이유는 라이브러리 구현체의 기존 테스트 코드를 복사하면 간단히 만들 수 있기 때문이다.
함수를 실행만 하는 테스트 코드가 의미 없는 것은 아니지만, 실제로 반환 타입을 체크하는 것이 훨씬 좋은 테스트 코드이다.
반환 값을 특정 타입의 변수에 할당하여 간단히 반환 타입을 체크할 수 있는 방법
const lengths: number[] = map(["john", "paul"], (name) => name.length);
- 이 코드는 일반적으로 불필요한 타입 선언에 해당한다.
- 그러나 테스트 코드 관점에서는 중요한 역할을 한다.
number[]타입 선언은 map 함수의 반환 타입이number[]임을 보장한다. - 실제로 DefinitelyTyped를 살펴보면, 테스팅을 위해 정확히 동일한 방식을 사용한 수많은 타입 선언을 볼 수 있다.
그러나 테스팅을 위해 할당을 사용하는 방법에는 두 가지 근본적인 문제가 있다.
- 불필요한 변수를 만들어야 한다.
반환값을 할당하는 변수는 샘플 코드처럼 쓰일 수도 있지만, 일부 린팅 규칙(미사용 변수 경고)을 비활성해야 한다.
일반적인 해결책은 변수를 도입하는 대신 헬퍼 함수를 정의하는 것이다.
function assertType<T>(x: T) {}
assertType<number[]>(map(["john", "paul"], (name) => name.length));
- 두 타입이 동일한지 체크하는 것이 아니라 할당 가능성을 체크하고 있다. 객체의 타입을 체크하는 경우 문제를 발견하게 된다.
const beatles = ["john", "paul", "george", "ringo"];
assertType<{ name: string }[]>(
map(beatles, (name) => ({
name,
inYellowSubmarine: name === "ringo",
}))
);
반환된 배열은 할당 가능하지만, inYellowSubmarine 속성에 대한 부분이 체크되지 않았다. 상황에 따라 타입이 정확한지 체크할 수도 있고, 할당이 가능한지 체크할 수도 있다.
assertType에 함수를 넣어 보면, 이상한 결과가 나타난다.
const add = (a: number, b: number) => a + b;
assertType<(a: number, b: number) => number>(add);
const double = (x: number) => 2 * x;
assertType<(a: number, b: number) => number>(double);
- double 함수 체크가 성공하는 이유는, TS의 함수는 매개변수가 더 적은 함수 타입를 할당 가능하기 때문이다.
- 이러한 사례는 콜백 함수에서 흔히 볼 수 있는데, 예를 들어 map 함수의 콜백은 3 가지 매개변수를 받지만 이를 모두 사용하는 경우는 매우 드물다.
- 만약 매개변수의 개수가 맞지 않는 경우를 타입 체크에서 허용하지 않으면, 매우 많은 곳에서 오류가 발생할 것이다.
그렇다면 어떻게 assertType을 제대로 사용할 수 있을까?
Parameters와 ReturnType 제너릭 타입을 이용해 함수의 매개변수 타입과 반환 타입만 분리하여 테스트할 수 있다.
const p: Parameters<typeof double> = null!;
assertType<[number, number]>(p);
// '[x: number]' 형식의 인수는 '[number, number]' 형식의 매개 변수에 할당될 수 없습니다.
const r: ReturnType<typeof double> = null!;
assertType<number>(r);
this가 등장하는 콜백 함수의 경우 또 다른 문제가 있다. map은 콜백 함수에서 this의 값을 사용할 때가 있으며 TS는 이러한 동작을 모델링할 수 있으므로, 타입 선언에 반영해야 하며 테스트도 해야 한다.
assertType<number[]>(
map(beatles, function (name, i, array) {
// '(name: any, i: any, array: any) => any' 형식의 인수는 '(u: string) => any' 형식의 매개 변수에 할당될 수 없습니다.
assertType<string>(name);
assertType<number>(i);
assertType<string[]>(array);
assertType<string[]>(this);
// 'this'에는 형식 주석이 없으므로 암시적으로 'any' 형식이 포함됩니다.
return name.length;
})
);
- 콜백 함수 내부에서 몇 가지 문제가 발생한다. 이 콜백 함수는 화살표 함수가 아니기 때문에 this의 타입을 테스트할 수 있다.
- 다음 코드 선언을 사용하면 타입 체크를 통과한다.
declare function map<U, V>(
array: U[],
fn: (this: U[], u: U, i: number, array: U[]) => V
): V[];
다음 모듈 선언은 까다로운 테스트를 통과할 수 있는 완전한 타입 선언 파일이지만, 결과적으로 좋지 않은 설계가 된다.
declare module "overbar";
- 이 선언은 전체 모듈에 any 타입을 할당한다.
- 모든 테스트를 통과하겠지만, 모든 타입 안전성을 포기하게 된다.
- 더 나쁜 점은, 해당 모듈에 속하는 모든 함수의 호출마다 any 타입을 반환하기 때문에 코드 전반에 걸쳐 타입 안정성을 무너뜨리게 된다.
- 타입 시스템 내에서 암시적 any 타입을 발견해내는 것은 매우 어렵다. 그렇기에 타입 체커와 독립적으로 동작하는 도구를 사용해서 타입 선언을 테스트하는 방법이 권장된다.
- DefinitelyTyped의 타입 선언을 위한 도구는 dtslint이다. 이는 특별한 형태의 주석을 통해 동작한다.
map(
beatles,
function (
name, // $ExpectType string
i, // $ExpectType number
array // $ExpectType string[]
) {
this; // $ExpectType string[]
return name.length;
}
); // $ExpectType number[]
- dtslint는 할당 가능성을 체크하는 대신 각 심벌의 타입을 추출하여 글자 자체가 같은지 비교한다.
- 이 비교 과정은 편집기에서 타입 선언을 눈으로 보고 확인하는 것과 같은데, dtslint는 이 과정을 자동화한다.
- 글자 자체가 같은지 비교하는 방식에는 단점이 있는데,
number | string과string | number는 같은 타입이지만 글자 자체로 보면 다르기 때문에 다른 타입으로 인식된다.