홍수성찬 2024. 5. 14. 16:40

2024년 5월 11일 프론트엔드 챌린지 강의 요약

 

타입스크립트

타입스크립트 비율은 높아짐을 넘어 이제는 기본이 되었다.

우리는 왜 타입스크립트를 사용해야 할까?

 

안전

자바스크립트는 동적 타입 언어! 즉, 런타임에 실제 코드 값이 평가 될 시점에서야 변수가 들어올 값과 타입을 알 수 있다.

function multiply(num1, num2) {
    let result = num1 * num2;
    return result;
}

 

위 코드에서 런타임에 어떤 값이 들어올 지 알 수 없다. 변수 이름만으로 추측해야할 뿐이다.

 

반면, 정적 타입 언어들은 컴파일 타임에 변수 타입 결정! 코드를 실행해보지 않아도 변수 값이 무슨 타입인지 빌드 타임에 미리 확인이 가능하다. 이는 개발자가 변수 타입이 무엇인지 코드 레벨에서 결정하도록 강제한다.

 

편리

익숙해진다면, 자바스크립트보다 훨씬 더 개발 속도가 빨라질 것이다.

 

  • 자동완성
    • 타입스크립트는 자바스크립트의 수퍼셋이며, IDE 타입 힌트를 줄 수 있는 도구
    • 직접 모든 코드를 작성하지 않고, IDE가 추론해준 선택지를 골라 사용 가능
    • 정적 분석의 원리를 이용한 도구 - ESLint, Prettier
  • 문서화
    • 타입 정보만을 모아놓은 라이브러리 - '@types' 패키지
    • 각 함수나 태그가 어떤 타입으로 구성되어 있는지 확인 가능

 

타입스크립트 고급 개념 - 제네릭 / 타입가드&타입추론

제네릭(Generic)

함수는 재사용 가능한 코드 블럭! 인자가 있다면 인자가 들어오는 가짓수에 따라 함수는 다양한 일을 한다.

// 인자의 타입을 'string'만 주거나
const log = (message: string) => {
    console.log(message)
    return message
}

// 인자의 타입을 'string' 또는 'number'로 받고 싶을 때 (유니온 타입)
const log = (message: string | number) => {
    const logNumber = (message: string | number) => {
    console.log(message)
    return message
}

What about { data: { id: "foo", email: "bar" }, error: null } ??

 

그러나, 미리 알 수 없는 형태의 타입이 들어온다면 미리 준비하기 어렵다. 이 때, 필요한 것이 '제네릭(Generic)'

 

'제네릭(Generic)'은 간단하게 '인자로 타입을 받는 함수'

 

// 예시 1.
const res = log("hi");

// 예시 2.
const [state, setState] = useState("hi") // 타입은 'string'

// 예시 3.
const [foo, bar] = useState<number>() // 타입은 'number | undefined'

 

'useState'는 InitialState의 인자를 받으며. 이는 타입이 'S'이거나 '() => S'이다. 그리고, '[S, Dispatch<SetStateAction<S>>]'를 반환한다. 여기서 'S'<S> 자리에 주어진 값으로 추론되거나, 초기값 InitialState 값에 의해 정해진다.

 

타입 좁히기 (타입가드&타입추론)

타입스크립트는 개발자가 언어 서버에 넘겨준 힌트(단서)를 조합해 연쇄적으로 변수와 함수들의 타입을 추측!

 

만약, 함수의 인자 하나가 2개 이상의 타입을 가질 수 있고, 타입에 따라 다른 처리를 해주어야 한다면?

// hello가 배열인지 아닌지에 따라 다르게 처리
const sayHello = (hello: string | string[]) => {
    if (Array.isArray(hello)) {
        hello.forEach((h) => console.log(h)) // (parameter) hello: string[]
        return;
    }
    return console.log(hello) // (parameter) hello: string
}

// 자바스크립트에서는 문제가 없으나, 타입스크립트에서는 error의 타입을 unknown으로 추론하여 에러 발생
// throw 문이 던질 수 있는 것은 Error 객체만이 아니기 때문
const getUsers = () => {
    try {
    	throw new Error("can you read?")
    } catch (error) {
    	console.log(error.message)
    }
}

// 에러 객체에 타입을 확신하게 된다면 문제 해결
const getUsers = () => {
    try {
    	...
    	throw new Error("can you read?")
    } catch (error) {
        if (error instanceof Error) {
            console.log(error.message)
        }
	}
}

 

// 배열을 필터링하여 특정 데이터만으로 구성된 새로운 배열 생성하기

// 타입스크립트가 정한 규칙에 맞지 않아 아래의 코드는 실패!
const arr = ["a", "b", "c", "d", 1, 2, 3, 4]
const onlyNumber = arr.filter(character => typeof value === "number")
console.log(onlyNumber) // number[] ???

// is(Type Predicate) 사용으로 해결 가능
const arr = ["a", "b", "c", "d", 1, 2, 3, 4]
const onlyNumber = arr.filter((character): character is number => isNumber(character))
const isNumber = (value: any): value is number => {
	return typeof value === "number"
}
const onlyNumber2 = arr.filter(isNumber)
console.log(onlyNumber)

 

그렇다면, 아래의 코드는 어떨까?

배열 값은 임의의 값이 있다고 가정해보자.

const res = arr.reduce((acc, cur) => {
	return [...acc, cur.id + cur.name]
}, []) // (parameter) acc: never[]

 

  1. reduce(callback)
  2. reduce(callback, initValue)
  3. reduce<T>(callback, initValue)

 

위 코드는, 세 개 중 어떤 것에 해당될까? 2번 케이스에 해당된다. InitialValue에 들어온 값이 T로 추론되었다고 볼 수 있다.

인자로 넘겨준 빈 배열은 'never[]'

 

  • never 타입: 공집합을 표현. 어떤 값도 타입으로 가질 수 없으며, 'never[] 타입'은 내부에 어떤 값도 가지고 있지 않은 빈 배열을 의미
// 위 코드 수정된 해결 코드
const res = arr.reduce<string[]>((acc, cur) => {
	return [...acc, cur.id + cur.name]
}, [])

 

그러나, `const arr = []`를 직접 보면 'any[]'로 추론될 수 있다. 이 내용은 아래에서 확인해볼 수 있다.

 

inconsistent inference of empty array with --strict flag · Issue #29795 · microsoft/TypeScript

TypeScript Version: 3.4.0-dev.201xxxxx Search Terms: Code const foo = []; // any[] class Foo { bar = []; // never[] } Expected behavior: const foo = [] is type never[] Actual behavior: const foo = ...

github.com

 

그리고, 객체 프로퍼티 유무에 따라 분기를 태우는 방법은 'in'을 사용하면 된다.

interface Friend {
	name: string;
}

interface BestFriend extends Friend {
	phoneNumber: `${number}-${number}-${number}`
}

const getFriendType = (friend: Friend | BestFriend) => {
    if ("phoneNumber" in friend) {
    	return `${friend.name}도 알고 ${friend.phoneNumber}도 알고 있어요!`
    }
	return `${friend.name}만 알고 있어요!` // no phoneNumber
}

const john = getFriendType({
    name: "hi",
    phoneNumber: "010-1234-1234"
})

Tanstack-Query

Tanstack-Query에 들어가기 전, 'Redux'에 대해 간략하게 알아야 한다.

 

Redux

전역 상태 관리의 대표적인 라이브러리로 자바스크립트 앱을 위한 예측 가능한 상태 컨테이너

자바스크립트 앱이 고도화되고, 그에 따라 복잡해지는 데이터를 통제하기 위해 고안된 Flux 패턴 기반 구현체

 

이는, React와 관련이 없는, 종속되지 않은 라이브러리이다. React에서 사용할 수 있도록 Wrapping 해주는 기능은 'React-Redux'!

 

Redux는 상태 변화를 관리하기 위해 상태 변화를 일으키는 시점과 형태에 제약을 둔다. 데이터 변경은 반드시 발행된 'Action'에 의해 순수 함수인 'Reducer' 내에서 일어나야 하며, 단방향으로만(View - Action + Dispatcher - Middleware - Reducer - Store - View) 일어나야 한다.

 

  • dispatcher: Action 객체를 전달해주는 배달부 역할

 

왜 단방향이어야 하는가?

표를 검표하는 상황을 예시로 들어보자.

표를 검표하는데 뒷문으로 몰래 들어오거나, 그물 위로 넘나드는 관객이 발생할 경우에 표를 제대로 검표할 수 없다. 즉, 제대로 추적하기가 어렵다. 이를 방지하기 위해서 단방향이어야 한다.

 

전체적인 구조

  1. View에서 유저가 일으키는 행동에 맞게 Action 객체 생성
  2. 업데이트 의도를 입력으로 넣어주기 위해, 그것이 순수한 데이터 형태이기 위해 Action 객체 사용
  3. Actioin은 Dispatcher를 통해 Reducer로 전달
  4. Action의 타입에 따라 Reducer 내에 미리 정해져 있던 로직이 Store 변경
  5. 변경된 Store의 내용이 View로 반영되는 패턴

 

Tip! 순수 함수
동일한 인자가 들어오면 항상 같은 값을 리턴하는 함수
외부의 상태에 영향(Side Effect, 부수 효과)을 주지 않는 함수

 

Thinking in Redux

  • 진실은 하나의 소스로부터(Single source of truth)
    • 애플리케이션의 모든 상태는 하나의 저장소 안에 하나의 자바스크립트 객체 트리로 저장
  • 상태는 읽기 전용(State is read-only)
    • `dispatch()`에 Action 객체를 담아 호출하는 방법으로만 상태 수정
    • Action 객체는 순수한 자바스크립트 객체 활용 (직접 생성한 이벤트 객체라고 생각하기)
    • {
          type: "ADD_TODO",
          payload: { title: "do study", content: "react" }
      }

 

단순하게 상태값 수정 목적이라면 상탯값을 직접 수정하는 방법이 빠르지만, 이전 상탯값과 이후 상탯값을 비교해서 변경 여부를 파악할 때는 불변 객체가 훨씬 유리하다.

 

  • 변화는 순수 함수로 작성(Changes are made with pure functions)
    • `reducer`는 변화 이전 'state'와 'Action' 객체를 입력으로 받아 새로운 'state' 생성하는 순수 함수 리턴 값은 'store'
    • (이전 상태, action) => 새로운 상태
    • 'fetch' 등 비동기 로직, 'new Date()', 'Math.random()'의 입력이라도 다른 응답을 반환할 수 있는 값이나, 부수 효과가 존재하는 함수의 경우 redux와 함께 사용 불가 → 'middleware' 자리에서 처리

 

Redux 코드 컨셉

const createStoreFromScratch = (reducer) => {
    let state;
    let listeners = [];
    // 클로저 내부 변수에 저장된 상태를 그대로 리턴

    const getState = () => state;
    
    // 향후 dispatch에 반응할 listener를 등록
    // listener를 등록 해제할 수 있도록 unsubscribe 함수를 리턴
    const subscribe = (listener) => {
        listeners.push(listener);
        return () => {
        	listeners = listeners.filter((l) => l !== listener);
        };
    };
    
    // reducer로 새로 생성한 state로 기존 state를 교체하고 listener를 호출
    const dispatch = (action) => {
        state = reducer(state, action);
        listeners.forEach((listener) => listener());
    };
    
    // 빈 dispatch를 실행해서 reducer에서 받은 initialState를 적용
    dispatch({});
    
    // 외부에서 클로저 내부 변수에 접근할 수 있도록 함수를 리턴
    return { getState, subscribe, dispatch }; // store
};

 

Redux in React

  • 전역 상태 관리
    • 하나의 관심사에 대해 여러 컴포넌트에 걸쳐 일어나는 변경 사항 반영(컴포넌트 간 통신)
  • Props Drilling 회피
    • React는 state, props가 변화할 때 해당 컴포넌트 리렌더!
    • props를 통해 여러 번 전달되는 데이터가 전달되는 경우 실제 변화가 적용되어야 하는 컴포넌트 뿐만 아니라, 전달 경로에 있는 컴포넌트들도 함께 리렌더!

 

'react-redux' 사용으로 해소 가능하며, 이 라이브러리에서 'useSelector' Hook은 내부적으로 Context API를 사용하여, 전역 상태와 Consumer 컴포넌트가 1:1 관계를 맺고 통신 가능하도록!

 

import { Provider } from 'react-redux'
import store from './store'
import App from './App'

const root = ReactDOM.createRoot(document.getElementById('root'))

root.render(
    <Provider store={store}>
    	<App />
    </Provider>
)

export function Counter() {
    const count = useSelector(selectCount)
    const dispatch = useDispatch()
    // ...
}

 

Tanstack-Query

Redux를 통해 자바스크립트 내부의 복잡한 상태를 단일 'Store'에 저장하여 안전하게 관리할 수 있게 됐고, 비동기 처리는 'redux-thunk'와 'redux-saga'의 미들웨어 함수들로 관리가 가능해졌다!

 

단, 문제가 발생!

 

서버에서 가져온 데이터를 redux store에 저장하는 행위가 redux에서 추구하는 '진실한 정보의 원천(Single source of truth)'와 부합하는가에 대한 문제를 포함하여, 가져오는 데이터는 복사본일 뿐, 최신 데이터라고 보기 어렵다.

 

HTTP 통신에서 '캐시(Cache)' 개념과 비슷하다. 갱신되지 않았다는 전제하에, 동일한 데이터를 서버에서 반복적으로 받아올 필요가 없어 가능하면 브라우저 캐시나 CDN 등 저장공간을 활용해 응답 속도를 최적화하는 것처럼!

 

Tanstack-Query, SWR 등 캐시 패러다임의 라이브러리들이 채택하고 있는 전략이 'stale-while-revalidate'!

 

stale-while-revalidate

  1. 캐시된 응답이 오래될 수 있다고 가정하는 부분
  2. 캐시된 응답을 재검증하는 프로세스
예시. 1초부터 59초까지 소비시간
Cache-Control: max-age=1, stale-while-revalidate=59

 

  • Cache-Control Header의 max-age
    • 만료되지 않았다면, Do Nothing
  • 만료 시, stale-while-revaildate
    • stale-while-revalidate 값을 넘지 않았다면, 일단 캐싱된 값 반환, 동시에 향후 사용을 위해 데이터 요청하여 최신화
    • 넘었다면, 데이터 새로 요청하여 최신화

 

API Call

클라이언트에서 가지고 있는 정보와 서버의 정보가 동일한가? Active

아니면, 다른가? Stale

 

위 중심으로 상태를 캐시 관점에서 재정의가 가능하다.

 

  • Fetching: 서버에서 데이터를 가져오는 동안 갖게 되는 상태
  • Fresh: 서버 / 클라이언트의 정보가 동일하다는 것을 보장. 다만, 서버로부터 가져온 데이터가 최신인지 보장 어려움
    • Tanstack-Query에서는 active에서 stale 상태로 넘기는 옵션인 'staleTime'의 기본 값을 0(즉시)로 설정
  • Stale: 서버 / 클라이언트의 정보가 동일함을 보장할 수 없는(신선하지 않은) 상태. 서버로부터 새로운 값을 업데이트 받지 않았거나, 클라이언트에서 입력 받은 값을 서버에 전송하지 않은 경우 'stale'!
    • Tanstack-Query는 값을 업데이트하기 위해 새 요청
  • Inactive: 해당 쿼리가 Tanstack-Query 가비지 컬렉터에 의해 제거될 예정임을 전달. 사용되지 않는 쿼리(unmount)는 'inactive' 처리
  • Deleted: 삭제된 쿼리

 

사용 예제 코드

function App() {
    const [postId, setPostId] = React.useState(-1);
    
    return (
        <QueryClientProvider client={queryClient}>
            {postId > -1 ? (
            	<Post postId={postId} setPostId={setPostId} />
            ) : (
            	<Posts setPostId={setPostId} />
            )}
            <ReactQueryDevtools initialIsOpen />
        </QueryClientProvider>
    );
}

import { useQuery } from "@tanstack/react-query";
import axios from "axios";

const getPostById = async (postId) => {
    const { data } = await axios.get(
    	`https://jsonplaceholder.typicode.com/posts/${postId}`
    );
    
    return data;
};
    
export default function usePost(postId) {
	return useQuery({
        queryKey: ["post", postId],
        queryFn: () => getPostById(postId)
    );
}

function Posts({ setPostId }) {
    const queryClient = useQueryClient();
    const { status, data, error, isFetching } = usePosts();

	// ...
}

 

구현은 Redux와 유사하게 useQuery가 실행될 때, 각 observer를 등록하는 개념(옵저버 패턴)!

 

쿼리 키(Query Key) 개념(Key-Value Store)

캐시는 보통 'key-value' 쌍으로 구성되고, 찾고자 하는 'key' 값이 캐시에 등록되어 있지 않다면, 'k-v pair'를 캐시에 등록하고, 이미 등록되어 있다면 'key'에 해당하는 'value'를 리턴하는 구조

 

캐시 패러다임 기반으로 'Prefetching', 'Pagination', 'Polling', 'Window Refocus Refetching', 'Offline Query' 등 다양한 기능들을 간편하게 구현 가능하다.

 

또한, 프론트엔드 서버 측에서 호출한 비동기 호추르이 결과를 캐시에 담아놓고, 클라이언트 측 캐시로 넘겨 곧바로 렌더링하도록 하는 방식의 구현도 가능하다.

 

Tanstack-Query가 바꿔놓은 것

'Tanstack-Query'는 편하다. 왜 편할까?

 

다른 라이브러리와 비슷하게, 클라이언트는 마치 브라우저 캐시처럼 앱 내 API 호출에 대한 별도의 저장소를 가지고, 해당 데이터들의 호출 정보와 위 5가지 상태 여부 체크 방식으로 비동기 호출을 관리할 수 있다. 이 정보들을 '서버 상태(Server State)'라고 부른다.

 

특히, 차별화된 장점은 이 과정을 자동화하면서 'DX(Developer Experience)'를 극대화했다는 점!

서버 상태를 관리하는 레이어 전체를 추상화시켜 개발자가 관리하는 앱 내 상태에서 서버 상태를 제외한 'UI 상태'에만 집중하여 개발!

 

React는 화면을 업데이트하기 위해 어떤 DOM언제, 어떻게 수정할 것인지 묻지 않으며, 무엇을 렌더할 것인지만 알려주면 그냥 알아서 렌더한다. Tanstack-Query도 마찬가지!

 

언제, 어떻게 호출할 것인지 묻지 않으며, 무엇을 호출할 것인지만 정의한다. 'useQuery'만 잘 사용하면 알아서 호출하고, 낡은 데이터를 버리고, 뒤에서 새로 받아 채우는 선언적인 방식으로 클라이언트 측 상태와 서버 상태를 알아서 동기화한다.

 

이와 관련된 질문을 받는다면, "useQuery 사용하기 편했다."보다 "선언적으로 서버 상태를 다룰 수 있었다."가 좋은 답변에 가깝다. 특정 개념에 대해 답하기 전 질문의 입구가 정문인지 생각해보자.

 

더 쉽게 생각해보자면, "A는 B다."의 대답이 좋다. 정의를 먼저 내린 후에 그에 대한 추가 설명이나 비유, 사용 경험 등을 이어서 답변하는 게 좋다.

 

예를 들면, 아래는 위의 내용을 기반으로 정의한 것처럼 말이다.

'RSC(React Server Components)'에 대해 서버에서만 실행되는 컴포넌트이며, RSC Payload를 기반으로 서버와 클라이언트를 통합(번들러, 라우터)하는 렌더링 메커니즘이다

 

OpenAPI 코드 제네레이터를 통한 타입 생성 효과

API 연동을 해본 적이 있는가? 

Fetch 함수로 백엔드 API 호출을 통해 받아온 데이터를 렌더링을 한다면 아래처럼 작성할 것이다.

 

function registerUser(userData) {
    return fetch('https://example.com/api/register', {
        method: 'POST',
        headers: {
        'Content-Type': 'application/json'
        },
        body: JSON.stringify(userData)
    })
    .then(response => {
        if (!response.ok) {
        	throw new Error('Network response was not ok.');
        }
    	return response.json(); // 응답을 JSON으로 파싱하여 다음 then 블록으로 전달
	})
}

// 토큰을 활용한 코드
function fetchUserInfo(userId, authToken) {
    return fetch(`https://example.com/api/users/${userId}`, {
        headers: {
        	'Authorization': `Bearer ${authToken}` // 토큰을 포함한 인증 헤더
        }
    })
}

 

굉장히 간단해 보이지만, 처음 할 때는 많은 오류를 경험하게 되는 코드이다. 예를 들어, 아래와 같은 이유로 말이다.

 

  1. header 에 Content-Type 을 잘못 포함 (서버 구현에 따라 생략 가능)
  2. body 에 넘기는 값을 직렬화 하지 않거나, 스펙에 맞지 않는 객체를 전송 (ex. nickname → username)
  3. method 에 따른 요청을 잘못 구현 (ex. GET 요청에 body를 넣음)
  4. 인증이 필요한 요청에 Authorization 관련 값을 넣지 않거나 잘못 포함 (ex. Bearer, 헤더 이름 오타 등)

 

이 이유는, 통신이 근본적으로 인터페이스에 기반한 규약에 의해 이뤄지기 때문이다.

요청 받는 입장에서 '처리 가능한 요청 형태' 기반으로 로직을 작성해야 한다. 그러나, 요청을 보내는 입장에서는 어떻게 요청을 보내야 하는지 잘 모르기 때문에 이러한 문제가 발생하는 것이다.

 

소통을 통해 프론트엔드 개발자와 백엔드 개발자가 규약을 정해서 잘 처리하도록 해야 하지만, '소통 강화'를 하더라도 문제가 발생하는 경우가 종종 발생한다.

 

문서화

'문서화'라는 방법이 있지만, 이는 생각보다 쉽지 않다. 해야 할 것이 많기 때문이다.

API 개발 외 문서도 찾아 업데이트를 해야 하기 때문에 쉽게 낙후되기도 한다. 낙후된 문서화로 인해 오히려 에러가 발생할 수 있다.

 

그렇다면, 어떻게 해야 할까? 방법이 없을까?

 

Swagger(스웨거)

'Swagger(스웨거)'는 API 배포와 함께 문서 또한 함께 최신 스펙에 맞게 업데이트되는 도구이다. 직접 API 호출 로직을 작성하지 않아도 문서에서 요청을 날릴 수도 있다.

 

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.annotations.ApiOperation;

import io.swagger.annotations.ApiParam;

@RestController
public class UserController {

    @GetMapping("/users/{userId}")
    @ApiOperation(value = "사용자 정보 조회", notes = "특정 사용자의 상세 정보를 조회합니다.
    public User getUserById(@ApiParam(value = "사용자 ID", required = true) @PathVariable String use
    // 사용자 정보 조회 로직
    	return userService.getUserById(userId);
    }
}

 

이를 기반으로, API 스펙을 커뮤니케이션 하는 것이 쉬워질 것이다. 그러나, 이 도구가 모든 문제를 해결해주지 않는다.

빠르게 수시로 변하는 스펙의 변경으로 인해 프론트엔드 입장에서는 변경에 대한 변경을 적용하고 다시 기능을 개발하는 문제가 생기는 경우가 발생한다.

 

이 문제는 프론트엔드에서 '타입스크립트'를 사용함으로써 해결이 가능하다. 이 과정에서 대부분 타입 에러가 서버로부터 넘어온 데이터와 관련이 있음을 알게 되며, '처리 가능한 요청 형태'를 프론트엔드가 알 수 없다는 근본적인 이유가 이것이다.

 

interface UserData {
    id: number;
    name: string;
    email: string;
}

async function fetchUserData(userId: number): Promise<UserData> {
    try {
        const response = await fetch(`https://api.example.com/users/${userId}`);
        if (!response.ok) {
        	throw new Error('Network response was not ok');
        }
        const userData: UserData = await response.json();
        console.log(userData.name); // 타입스크립트는 userData의 형식을 알고 있음
        return userData;
    } catch (error) {
        console.error('Failed to fetch user data:', error);
        throw error;
    }
}

 

타입 설정

보통, 프론트엔드를 개발하면서 타입을 설정할 때 어떻게 작성하게 될까?

 

// 잘못 통합된 사용자 타입
interface User {
    id: number;
    name: string;
    email?: string; // RegularUser는 이메일이 필수, AdminUser는 이메일이 옵셔널
    role: string;
    accessLevel?: number; // AdminUser에만 존재하는 프로퍼티
}

 

위의 타입 설정은 잘못된 작성이다. 왜냐하면, 너무나 많은 것을 통합하고 있기 때문이다. 각 기능마다 필요없는 타입이 있을텐데, 이를 필터링하기 위해서는 조건문을 여러 번 사용하여 처리해야 하는 문제가 발생한다.

// 사용자 데이터를 처리하는 함수
function displayUserInfo(user: User) {
    if (user.role === 'admin') {
    // 어드민을 걸러냈지만 여전히 accessLevel은 옵셔널로 추론
        if (user.accessLevel === undefined) {
        	console.error("Admin must have an access level defined.");
        }
    // Admin 특화 로직
    } else {
        if (user.email === undefined) {
            console.error("Regular user must have an email defined.");
        }
    // Regular 특화 로직
    }
}

 

그리고, 만약에 타입이 늘어나게 되면 하나의 인터페이스 속에 수십 개의 타입을 선언하여 문제만 일으키게 될 것이다. 

 

위 코드보다 더 나은 코드를 작성한다면, 어떻게 작성하면 될까?

// 옳게 된 타입 - BasicUser를 거치지 않아도 괜찮습니다
interface RegularUser {
	id: number;
    name: string;
    role: string;
    email: string;
    lastLogin: Date;
}

interface AdminUser {
    id: number;
    name: string;
    role: string;
    email?: string;
    accessLevel: number;
    department: string;
}

function displayRegularUserInfo(user: RegularUser) {
	console.log(`Name: ${user.name}, Email: ${user.email.toUpperCase()}, Last Log
}

function displayAdminUserInfo(user: AdminUser) {
    console.log(`Name: ${user.name}, Department: ${user.department}`);
    if (user.email) {
    	console.log(`Email: ${user.email.toUpperCase()}`);
    }
    // 어드민 타입에서는 accessLevel이 확실히 존재하므로 옵셔널로 추론되지 않음
    console.log(`Access Level: ${user.accessLevel}`);
}

 

그런데, 다시 생각해보면 위 처럼 타입을 설정해주는 게 정말 정답일까?

값이 계속 추가된다면 그 양을 감당할 수 있을까?

또한, 온갖 파생 타입을 만들어버리게 되지 않을까?

 

// 파생 타입
interface User {
    id: number;
    name: string;
    email?: string;
    role: string;
    accessLevel?: number;
    // 새로 추가된 필드
    department?: string; // AdminUser에만 관련 있는 부서 정보
    lastLogin?: Date; // RegularUser에만 관련 있는 마지막 로그인 시간
}

 

이렇게, 타입스크립트를 적용해서 서버에서 오는 데이터에 대한 타입을 설정하게 되면, "서버에 이미 타입이 있는데, 굳이 직접 다 옮겨 적어야 하는가?"라는 의문이 들게 될 것이다.

 

Driven Development

스웨거를 도입했을 때처럼 프론트엔드와 백엔드 개발자들의 협업을 위해서 생각해볼 필요가 있는 도구가 'Graphql-Codegen'이다.

 

'GraphQL'은 스키마를 굉장히 적극적으로 사용하여 코드 제너레이터라는 물건을 쓰기 좋다. 타입이 적힌 'GraphQL 스키마 문서''Resolver(API 호출 함수)'를 합해 그에 기반한 타입스크립트 타입 뿐만 아니라 Fetcher 함수까지 생성해준다.

 

잘 사용한다면, 서버에서 한 번 타입 정의하여 프론트엔드에서는 모든 타입과 Fetcher가 무료로 생기고, 이를 사용하기만 하면 될 것이라는 생각이 들게 된다. API 응답 중 무엇이 변경되었다는 공지도 없어질 것이다.

 

그렇지만, 이를 위해서 GraphQL로 넘어가야 하는지 생각하고 고민하게 될 것이다.

이 때, 'RESTful API'에도 이와 비슷하고 스웨거 기반으로 만들어진 'OpenAPI Specification(OAS)'를 발견하게 될 것이다.

 

💡 OpenAPI Specification(OAS)

소스 코드, 문서에 엑세스하거나 네트워크 트래픽 검사를 통해 인간과 컴퓨터 모두 서비스 기능을 검색하고 이해할 수 있도록 하는 HTTP API에 대한 언어 독립적인 표준 인터페이스를 정의
최소한의 구현 로직으로 원격 서비스를 이해하고 상호 작용

 

💡 OpenAPI

API를 표시하기 위한 문서 생성 도구, 다양한 프로그래밍 언어로 서버와 클라이언트를 생성하기 위한 코드 생성 도구, 테스트 도구 및 기타 여러 사용 사례에서 사용

 

 

스웨거를 기존에 잘 사용하고 있었다면, 빌드 과정에서 'OAS 스펙 기반의 YAML 파일'을 생성할 수 있다. 이 파일을 기반으로 '스웨거 문서 페이지'가 만들어진다.

 

// YAML 파일 예시
openapi: '3.0.0'
info:
    version: 1.0.0
    title: Swagger Petstore
    license:
    	name: MIT
servers:
	- url: http://petstore.swagger.io/v1
paths:
    /pets:
        get:
            summary: List all pets
            operationId: listPets
            tags:
            	- pets
            parameters:
            	- name: limit
                in: query
                description: How many items to return at one time (max 100)
                required: false
                schema:
                	type: string
						# 이하 생략

 

// 생성된 타입 예시
interface Pet {
    id: number;
    name: string;
    age?: number; // age는 optional, exclusive minimum 0, exclusive maximum 30
    tag?: string | null; // nullable이므로 string 또는 null 가능
}

interface Pets {
	pets: Pet[];
}

interface Error {
    code: number;
    message: string;
}

 

표준 스펙을 기반으로 문서 생성이나 타입 생성이나 그저 서로 다른 방식의 활용임을 알아야 한다.

 

백엔드 개발자는 프론트엔드 개발자가 언제든지 YAML 파일을 가져갈 수 있도록 업로드하고, 배포할 때마다 해당 파일이 최신화되도록 자동화를 한다.

 

프론트엔드 개발자는 언제든지 YAML 파일을 다운로드하여 타입을 생성하도록 하는 스크립트를 작성한다. 여러 구현체에서 타입 뿐만 아니라 Axios, Tanstack-Query 까지 모두 섞어 API 호출 함수까지 만들어주는 기능(Orval)을 사용하기도 한다.

 

이러한 과정을 통해 프론트엔드에서 모든 것을 스크립트 하나로 자동화할 수 있으며, 백엔드에 변경이 일어나도 스크립트만 건드리면 변경이 완료된 코드 베이스 위에서 작업할 수 있게 된다.

 

이 모든 것을 통해서 지금까지 '정보 비대칭', 'SSOT(Single Source Of Truth)'의 문제임을 알게 된다.

그렇지만, 무조건 도입해야 하는지에 대해서는 진지하게 고민해봐야 한다. 프로젝트의 성격, 팀원, 기한 등 모든 것을 고려해 결정해야 함을 잊지 말자.

 

Thinking in Query

많은 렌더링을 고려하고 작성하기

React-Query를 처음 사용할 때, Network 탭에 너무 많은 요청이 일어나 당황스러울 수 있다. 하지만, 이는 서버 상태와 클라이언트 상태가 동기화되는 과정이라고 받아들여야 한다.

 

Stale한 쿼리를 서버와 동기화 시키기 위해 새로운 요청이 일어나는 경우

  • refetchOnMount : useQuery를 포함한 컴포넌트가 Mount 될 때마다 revalidate
  • refetchOnWindowFocus : 브라우저가 포커스를 얻을 때마다 revalidate
  • refetchOnReconnect : 네트워크가 끊어졌다가 다시 연결될 때마다 revalidate
  • 개발자가 직접 Mutation 등의 작업 이후 queryClient.invalidateQueries 를 사용해 수동으로 쿼리를 무효화 하여
    revalidate

 

쿼리 키 배열을 의존성 배열로 생각하기

기본적으로 비동기 호출을 명령형에 가까운 방식으로 처리한다.

이제는, "이 비동기 요청은 `state`가 `x`인 경우의 결과물이다." 식의 선언형에 가까운 형태로 호출할 수 있게 된다. 만약, `state`의 값이 변경된다면 쿼리 키 배열의 변화를 감지하여 자동으로 새로운 요청을 보낸다.

 

const [state, setState] = useState()
const [data, setData] = useState()

useEffect(() => {
    const res = fetchTodos(state)
    setData(res)
}, [state])

export const useTodosQuery = (state: State) =>
	useQuery(['todos', state], () => fetchTodos(state))

 

만약, 컨셉을 이해한다면 아래와 같이 'refresh'가 실행될 상황이나, 함수 인자로 특정 요청에 필요한 `id`를 넘기지 않을 것이다.

// 잘못된 예시

const { data, refetch } = useQuery(['item'], () => fetchItem({ id: 1 }))
<button onClick={() => {
	// 🚨 작동하지 않음
	refetch({ id: 2 })
}})>Show Item 2</button>

 

이보다, 데이터 요청이 `state(id)`의 의존하도록 수정하는 것이 옳다.

const [id, setId] = useState(1)
const { data } = useQuery(['item', id], () => fetchItem({ id }))

<button onClick={() => {
    // ✅ refreshing없이 set
    setId(2)
}})>Show Item 2</button>

// Tip
useEffect(() => {
    id !== undefined &&
    mutateAsync(id).then((response) => setDetail(response.data));
}, [location]);

 

QueryCache를 전역 상태로도 생각하기

Tanstack-Query는 서버 상태를 다룬다. 따라서, 일종의 'State Manager'!

다만, 쿼리-키만 가지고 있다면 어떤 위치에 있는 컴포넌트에 상관없이 캐시에 존재하는 응답 값에 접근하여 값을 읽어올 수도, 업데이트 할 수도 있다. → 전역 상태?

 

export const useTodosQuery = (state: State) =>
    useQuery({
        queryKey: ['todos', state],
        queryFn: () => fetchTodos(state),
        initialData: () => {
            const allTodos = queryClient.getQueryData<Todos>(['todos', 'all'])
            const filteredData =
            allTodos?.filter((todo) => todo.state === state) ?? []
            return filteredData.length > 0 ? filteredData : undefined
        }
    })

 

Server State와 Client State를 분리하기

Server 상태와 Client 상태는 서로 가지고 있는 특성이 다르기 때문에 가급적으로 분리되어야 한다. 다만, 가끔 이 두 상태가 섞이려는 상황이 발생한다.

 

그러나, 그런 경우라도 Server 상태의 복사본을 만들게 되면 회피한 두 개의 문제와 다시 마주치게 된다.

const useRandomValue = () => {
    const [draft, setDraft] = React.useState(undefined);
    const { data, ...queryInfo } = useQuery({
        queryKey: ["random"],
        queryFn: async () => {
            await sleep(1000);
                return Promise.resolve(String(Math.random()));
            },
        enabled: !draft
    });

	return {
        value: draft ?? data,
        setDraft,
        queryInfo
    };
};

 

`Form State`도 마찬가지로, Form의 기본 값이 필요한 경우에도 Server 상태가 존재할 경우, 해당 값을 우선하여 사용하도록 할 수 있다.

function PersonDetail({ id }) {
    const { data } = useQuery(['person', id], () => fetchPerson(id))
    const { control, handleSubmit } = useForm()
    const { mutate } = useMutation({ queryFn: updatePerson })
    if (data) {
        return (
            <form onSubmit={handleSubmit(mutate)}>
                <div>
                    <label htmlFor="firstName">First Name</label>
                    <Controller
                        name="firstName"
                        control={control}
                        render={({ field }) => (
                        // ✅ derive state from field value (client state)
                        // and data (server state)
                        <input {...field} value={field.value ?? data.firstName} />
                        )}
                    />
                </div>
                <div>
                    <label htmlFor="lastName">Last Name</label>
                    <Controller
                        name="lastName"
                        control={control}
                        render={({ field }) => (
                        <input {...field} value={field.value ?? data.lastName} />
                        )}
                    />
                </div>
                <input type="submit" />
            </form>
        )
    }

	return 'loading...'
}

 

서버 통신의 결과가 나오기 전에 미리 UI를 반영하는 '낙관적 업데이트(Optimistic Update)'는 되도록 멀리하자.

// 서버 상태를 그대로 사용해야 하지만
// 클라이언트 상태를 다시 만드는 상황
const filterTodoDetail = (id?: string) => {
    const todoDetail = queryData?.data.filter((todo: any) => todo.id === id)[0];
    
    if (todoDetail) {
    	setTodoDetail(todoDetail);
    } else {
    	setTodoDetail(initTodoDetailValue);
    }
};

 

직접 호출보다, 호출할 대상이 Stale하다고 알리기

쿼리 상태는 쿼리-키 중심으로 관리된다. 배열, 객체 형태가 혼합된 형태의 쿼리-키를 사용해야 하며, 이 값들은 내부적으로 직렬화(Serialize) 된 다음 캐시-키로 활용된다.

 

['todos', 'list', { filters: 'all' }]
['todos', 'list', { filters: 'done' }]
['todos', 'detail', 1]
['todos', 'detail', 2]

// 캐시-키를 활용한 쿼리 컨셉은 특히, Mutation을 할 때 유용하게 사용
function useUpdateTitle() {
    return useMutation(updateTitle, {
    onSuccess: (newTodo) => {
        queryClient.setQueryData(['todos', 'detail', newTodo.id], newTodo)
        // ✅ just invalidate all the lists
        queryClient.invalidateQueries(['todos', 'list'])
        },
    })
}

const todoKeys = {
    all: ['todos'] as const,
    lists: () => [...todoKeys.all, 'list'] as const,
    list: (filters: string) => [...todoKeys.lists(), { filters }] as const,
    details: () => [...todoKeys.all, 'detail'] as const,
    detail: (id: number) => [...todoKeys.details(), id] as const,
}