이번 프로젝트에는 emotion을 사용했다. 그동안 styled-component, tailwind, scss 등을 사용해봤는데 emotion을 사용해본적은 없어서 경험해보고 싶었기도 하고 emotion이 styled-component보다 빠르기도 해서 선택하게 되었다.
emotion-css-mode의 렌더링 속도가 4위를 차지했다. 물론 inline style 방식만 월등히 빠르지만 그 이외 것도 styled-component보다 앞섰다. 퍼포먼스 면에서도 아주 미세하지만 emotion이 앞선다.
렌더링 속도 표에서 놀라웠던건, react with inline styles의 속도가 아주 빠르다는 것이었다. react를 처음 배웠을때 inline style을 사용하면 렌더링될때마다 style 객체를 다시 계산하여 성능 이슈가 생기기 때문에 inline style을 지양하라고 배웠다. 그런데 속도 비교를 보니, 리렌더 속도나 mount time 모두 아주 빨랐다. 이제 생각해보니 styled 함수를 호출하고 객체를 생성하고 import, export 하는 시간보다는 단순 객체를 계산하는 시간이 더 빠를 것 같다는 생각이 들었다.
stitches 발견!
style 라이브러리를 찾다가 stitches를 알게 되었다. 사용방법은 Emotion이나 styled-component와 크게 다르지 않은데 속도가 비교가 안될만큼 빨랐다. 다음 프로젝트때 한 번 사용해보면서 알아보는 것도 좋을 것 같다.
출처:
개발 동아리 활동을 하면서 react query를 한 번 사용해 본 적이 있다. 깊게 알아보고 사용한건 아니었고, 요즘 react query를 많이 사용한다, 캐싱을 알아서 해주기 때문에 편리하다 등의 장점으로 가볍게 사용해봤다.
1월 프리온보딩 수업을 듣고 내가 얼마나 가볍게 알고 사용하는지, 또 사실 react query를 써본게 아니라는 걸 알게 되었다.
프론트엔드 개발을 할 때 항상 고민되는 것 중 하나가 state이다. 어떻게 해야 상태관리를 잘할 수 있는가를 늘 고민하고 상태 관리를 하기 위해 상태 관리 매니저를 많이 사용한다. 그 중 가장 유명한 상태 관리 매니저는 redux
이다. 리덕스를 통해 복잡한 상태를 하나의 스토어로 관리할 수 있다.
그런데, 서버로부터 받은 데이터를 리덕스 스토어에 저장하는 것이 리덕스에서 추구하는 진실한 정보의 원천(Single source of truth)
에 부합하는가를 고민해볼 필요가 있다. 기존에 api 요청을 통해 서버에서 데이터를 가져오면 진실한 정보의 원천인 redux store에 넣어 두고, 그 데이터를 업데이트 하며 사용했다. 그러나 store는 진짜 진실한 정보의 원천일까?
그럴 수도 있고 그렇지 않을 수 있다. 하지만 대부분의 경우 서버 데이터가 서버를 떠나 store에 저장되는 이상 store는 원본이 아니라 서버 데이터의 복사본일 뿐이다. 우리는 서버로부터 받아온 데이터의 최신 여부를 따지는 매커니즘인 HTTP 캐시를 알고 있다. 이 개념을 비동기 호출하는 여러 데이터들에서도 적용해볼 수 있다.
react query, swr 등의 캐시 패러다임의 라이브러리가 stale-while-revalidate
를 채택하고 있다.
왜 react query가 필요한지 정리하면 다음과 같다.
query key는 문자열, 배열 모두 가능했으나 React query V4 이후 query key는 배열만 가능하다. 문자열이 되더라도 배열로 쓰는 것이 휴먼 에러도 줄이고 유지보수면에서도 좋으니 배열을 사용하자.
배열로 query key를 생성할때 가장 일반적인 것부터 점차 가장 구체적인 것으로 나열한다.
['todos', 'list', { filters: 'all' }]
['todos', 'list', { filters: 'done' }]
['todos', 'detail', 1]
['todos', 'detail', 2]
이 구조를 사용하면 하나의 특정 목록을 무효화하거나 todos와 관련있는 모든 query key를 무효화할 수 있다. 그런데, 이렇게 일일이 배열을 직접 넣어주는 방법도 마찬가지로 유지보수를 어렵게 만든다. 그렇기 때문에 기능 하나당 하나의 query key factory를 만들어 쓰는 것이 좋다.
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,
}
todoKeys라는 하나의 객체 안에 각각의 key가 등록되어 있기 때문에 여전히 독립적으로 접근이 가능하다.
query key를 어디에 위치해야 할지 고민이 많았다. 처음엔 hooks/queries 안에 넣으려다가 여러 가지 커스텀 훅 사이에 query 파일이 들어있으면 다른 사람이 봤을 때 더 찾기 어려울 것 같은 느낌이 들어 고민 끝에 consts/query.ts 파일을 만들어 그 안에 TODOS_KEYS 객체를 생성했다.
📦hooks
┣ 📂queries
┃ ┣ 📜index.ts
┃ ┣ 📜...todo 관련 커스텀훅
┣ 📜index.ts
┣ 📜...일반적인 커스텀훅
그런데, Kent C.Dodds
의 코로케이션을 통한 유지 관리 가능성을 읽고나니 consts/query.ts에 생성한게 좋은 생각은 아니었던 것 같다. 일단 기능에 따라 query key 파일의 구분 없이 하나의 query.ts라고 명시한 것은 유지보수적으로 좋지 않다. 또, consts는 전역적으로 재사용성이 있는 변수를 담아야 하는데 query key는 query custom hook에서만 사용되기 때문에 디렉토리 위치도 맞지 않았다. Tkdodo님의 블로그에서 feature 디렉토리에 각각의 queries.ts 파일을 만들어 관리하는 것을 추천했으나, 지금 내 디렉토리 구조가 다음처럼 분산되어 있어 그 방법은 적용하기 힘들었다.
<Tkdodo 추천 방법>
- src
- features
- Profile
- index.tsx
- queries.ts
- Todos
- index.tsx
- queries.ts
<현재 내 디렉토리 구조>
📦src
┣ 📂components
┣ 📂consts
┣ 📂hooks
┣ 📂pages
┣ 📂...생략
결국 고민 끝에 hooks/queries 하위에 todos라는 디렉토리를 만들고 그 안에 todo query key 파일과 다른 todo 관련 커스텀 훅을 넣는 것이 제일 낫다고 결론내렸다.
useMutation을 사용하면 데이터가 변경된다. 이때 따로 refetch하는 함수를 호출할 필요가 없이, 호출할 대상이 stale하다고 알려주면 된다.(ex. 캐시에게 모진 말 하기 : 너 상했어..)
쿼리의 상태는 쿼리 키를 중심으로 관리되는데, 이 쿼리 키는 Mutation에서 사용할 수 있다.
function useUpdateTodo() {
return useMutation({
mutationFn: updateTodo,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: TODO_KEYS.list() });
},
});
}
mutation 이후에 어떤 쿼리 키가 상했는지 알려주어야 한다.
invalidateQueries로 상했다고(stale) 알려주는 것과 그냥 refetch를 호출하는 것이 뭐가 다른지 궁금해져서 찾아봤다.
invalidation은 더 "똑똑한" refetching이다. refetch는 쿼리에 대한 observer가 없더라도 항상 refetch를 실행할 것이다. 반면에, invalidation은 그냥 다음 번에 observer가 마운트 될 때 refetch할 수 있게 상했다고만 표시하는 것이다. 먄약 퀴리에 active된 observer가 있다면 이 둘의 차이는 없다.
optimistic update란 직역하면 낙관적 업데이트이다. 보통 프론트에서 서버로 POST 요청을 하고 서버에서 내려준 response를 프론트가 받아 UI를 업데이트 한다. 반면 optimistic update는 이와 다르게 접근한다. 프론트가 보낸 요청을 "성공했다치고" 서버에서 응답을 받기 전에 UI를 미리 업데이트 한다.
사람들은 눈을 깜빡이는 것처럼 반응하는 인터페이스를 선호한다. 과거에는 버튼이 클릭됐을때 비활성화 하여 이를 충족시켰으나, 요소를 비활성화 하는 것은 사용자에게 통제할 수 없는 수동적 기다림을 의미하므로 optimistic UI는 비활성화 상태를 통째로 건너뛰어 기다림 대신에 낙관적인 결과로 소통한다.
처음엔 응답을 받기 전에 UI를 미리 업데이트 하는게 오히려 유저에게 혼란을 줄 것이라고 생각했다. 하지만, API가 안정적이고 예측가능한 수준이며, 프론트엔드가 UI에 적절한 액션을 취하는 한, 유저가 처음에 취한 행동에 대한 응답으로 오류가 뜰 확률은 상당히 낮다고 한다. "Denys Mishunov"에 의하면 오류 상황은 1-3%에 머무른다고 한다. 97-99%의 성공 응답을 확신할 수 있다면, optimistic updates가 유저에게 혼란을 줄 확률은 적을 것이다.
optimistic updates는 즉각적인 사용자 피드백이 필요한 작은 mutations에서 매우 효과적이다.
const usePostTodo = () => {
const queryClient = useQueryClient();
return useMutation(createTodo, {
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: TODO_KEYS.lists() });
const previousTodos = queryClient.getQueryData(TODO_KEYS.lists());
await queryClient.setQueryData<Array<CreateTodoRequest>>(
TODO_KEYS.lists(),
(oldTodo) => oldTodo && [...oldTodo, newTodo]
);
return previousTodos;
},
onError: (error, newTodo, previoustTodos) => {
queryClient.setQueryData(TODO_KEYS.lists(), previoustTodos);
},
onSettled: () => {
queryClient.invalidateQueries(TODO_KEYS.lists());
},
});
};
react query 공식 홈페이지를 보고 todo app에서 새 todo를 등록할때 optimistic updates를 적용했다. 적용후 에러 상황을 만들어 롤백되는 것을 보니 새로운 todo가 등록됐다 바로 사라졌다. optimistic updates를 쓰면 더 나은 사용자 경험을 제공한다 했는데, 이 경우는 전혀 그런 것 같지 않았다. optimistic updates는 언제 써야 적절한 것인가를 고민해보게 되었다.
optimistic updates는 다음과 같은 경우에 사용해야 한다.
1. 성공 또는 실패 응답 이상을 기대할 수 없는 이분법적인 요소들에 적용한다.(좋아요
, 별표
또는 저장된 검색에서
)
2. optimistic updates를 적용하는 곳은 인터페이스의 다른 부분에 연결되어서는 안된다.
: 여러 테이블에 데이터를 함께 저장하는 경우, 하나라도 실패하면 전체 API가 실패로 처리되기 때문에 실패 확률이 높다.
3. API 응답 시간은 매우 빨라야 한다.
4. API 성공률은 100%에 가까워야 한다.
때때로 사용자는 지연을 예상한다
만약, 글을 비공개에서 공개로 전환하는 작업이나 결제 또는 예약을 하는 경우를 생각해보자. 단순한 버튼을 누르는 것일지라도 사용자는 중요한 작업이라고 생각하기 때문에 진행이 지연될 것을 예상한다. 글을 비공개 하는 즉시 작업이 이루어지거나 결제 버튼을 누르는 즉시 결제가 완료되는 즉각적인 피드백은 오히려 사용자를 불신하게 만든다. 이러한 작업들에는 optimistic updates 사용을 지양하자.
출처:
어떤 변수의 타입이 string이 아닌 특정한 문자열로 타입을 좁히려고 하니, const 상수를 새로 만들고 그 상수로 객체를 참초하여 value를 사용해야 했다. 그때 객체의 key 혹은 value에 대한 타입을 알아야 했다.
타입스크립트의 keyof
와 typeof
를 사용하여 객체에 대한 타입을 알아낼 수 있었다.
typeof : 객체 데이터를 객체 타입으로 변환해주는 연산자
변수로 선언한 객체는 당연히 타입으로 사용할 수 없다. 만약 선언한 객체에 들어있는 key, value 구조를 그대로 가져와 독립된 타입으로 사용하고 싶다면 객체 이름 앞에 typeof
키워드를 명시해주면 된다.
const person1 = {
name: 'jaemin',
age: 28,
job: 'unemployed',
};
// person1 객체를 타입으로 변환
type Person = typeof person1;
const person2: Person = {
name: 'calmdown man',
age: 40,
job: 'youtuber',
};
함수 또는 클래스도 typeof
연산자를 사용하여 타입으로 변환할 수 있다.
keyof : 객체 타입에서 key들만 뽑아 유니온 타입으로 만들어주는 연산자
type Person {
name: string;
age: number;
married: boolean;
}
type PersonKey = keyof Person;
// type PersonKey = name | age | married
const name: PersonKey = 'name';
const age: PersonKey = 'age';
const married: PersonKey = 'married';
만약 특정 객체의 key들만 뽑아오고 싶다면 아래와 같이 사용할 수 있다.
const person = {
name: 'jaemin',
age: 28,
married: false,
};
type Person = keyof typeof person;
// type Person = "name" | "age" | "married"
const name: Person = 'name';
const age: Person = 'age';
다음은 객체에서 value 값들만 뽑아 유니온 타입으로 만들어주는 방법이다.
const person = {
name: 'jaemin',
age: 28,
married: false,
};
type PersonValue = typeof person[keyof typeof person];
// type PersonValue = string | number | boolean
person 객체 value들의 type이 아니라 리터럴 값을 가지고 싶다면 아래와 같이 사용할 수 있다.
const person = {
name: 'jaemin',
age: 28,
married: false,
} as const;
type PersonValue = typeof person[keyof typeof person];
// type PersonValue = false | "jaemin" | 28
객체의 value의 type을 변환할때 매번 typeof obj[keyof typeof obj]
이렇게 적는건 불편하기도 하고 가독성이 떨어지기도 한다. 재사용할 수 있게 utils 파일에 넣으려고 하다가 typescript에서 제공하는 d.ts 파일에 대해 알게 되었다.
ambient module: import export 없이 전역에서 가져다 쓸 수 있는 기능
모든 요청에 포함되는 공통된 config 설정이 있기 때문에 공통 config가 들어가있는 axios instance를 만들어 사용하는게 좋다고 생각했다.
const axiosInstance = axios.create({
baseURL: import.meta.env.VITE_BASE_URL,
timeout: 10000,
headers: { 'Content-Type': 'application/json' },
});
axios의 timeout 값이 필요한 이유는 서버의 네트워크에 문제가 생겨 서버 통신이 지연될때 timeout 값으로 설정한 시간이 지나면 서버와의 연결을 즉시 중단한다. timeout 값을 5000(5초)로 설정했다면 5초가 지나도 서버로부터 정상적인 응답을 받지 못하는 경우 네트워크가 중단됩니다.
만약 timeout을 설정하지 않았다면 클라이언트는 계속 서버에 연결을 유지하고 응답을 기다리게 된다. 이때 방문자가 많을 경우, 즉 서버에 요청이 많은 상태라면 과부하 등의 이유로 서버가 다운되는 등 더 큰 문제가 발생할 수 있다. 따라서 적절한 timeout 시간을 설정하여 에러 메시지를 보여주는 등의 설정이 필요하다. timeout 값이 너무 짧으면 방문자가 접속하지 못하는 문제가 있을 수 있다. 대부분 5초에서 10초 사이의 값을 사용하지만 서버 환경 및 네트워크 상황을 고려하여 설정할 필요가 있다.
여기까지 하고 난 후 axios.create가 무엇을 반환하는지 궁금해졌다. return 값을 찍어봐도 함수가 찍혀 자세히 알기가 어려워 axios 코드를 까봤다.
local storage에 token이 있다면 headers에 포함하여 보내야 했다. 이것도 매 요청마다 확인하는 로직이 반복할테니 interceptos.request에 token을 검사하는 로직을 넣어 반복을 줄이고 관심사를 분리하기로 했다.
axiosInstance.interceptors.request.use(
(config) => {
const token = getLocalStorage(ACCESS_TOKEN);
if (config.headers) (config.headers as AxiosHeaders).set('Authorization', token);
return config;
},
(error) => Promise.reject(error)
);
서버에서 응답으로 내려주는 데이터가 다음과 같아서 렌더링에 필요한 데이터를 사용하려면 response.data.data로 접근해야 했다.
{
config: ...,
data: { data: Array(7) },
headers: ...,
request: ...,
status: ...,
...
}
react query를 도입하기 전에는 대부분의 요청의 then절에서 response.data.data를 적어주었으나, react query를 사용하고 커스텀 훅에서 .data.data로 접근하려니 가독성이 떨어진다고 생각해, interceptors.response에서 데이터를 가공하기로 했다.
src/api/base.ts
const createApiMethod =
(axiosInstance: AxiosInstance, method: Method) =>
(config: AxiosRequestConfig): Promise => {
axiosInstance.interceptors.response.use((response) => {
return response.data;
});
return axiosInstance({
...config,
method,
});
};
const api = {
get: createApiMethod(axiosInstance, HTTP_METHODS.GET),
post: createApiMethod(axiosInstance, HTTP_METHODS.POST),
patch: createApiMethod(axiosInstance, HTTP_METHODS.PATCH),
put: createApiMethod(axiosInstance, HTTP_METHODS.PUT),
delete: createApiMethod(axiosInstance, HTTP_METHODS.DELETE),
};
createApiMethod라는 함수 내부에서 interceptors.response 로직을 넣어주었고 각 메서드마다 createApiMethod를 호출하는 구조로 작성했다. 내가 예상한건 axios instance에 interceptors.response가 한 번만 등록되는 것이었는데 실제로 그렇게 동작하지 않았다. 처음엔 잘 동작하다가 그 이후로 response에 response.data 들어오거나 undefined가 돌어왔다.
axios를 뜯어보니 responseInterceptorChain 배열에 interceptor가 push되고 promise chaining으로 연산됐다. 지금 코드를 보면 get, post, patch, ..등을 호출될때마다 interceptors.response.use를 호출하고 chain 배열에 push 된다. 첫 번째 파라미터인 (response) => response.data는 fulfilled 될때 호출되고 있었기 때문에 response에 undefined가 들어오는 오류가 있었던 것이다.
// pseudo code
chain = [
// ...생략,
onResponseFulfilled1, onResponseRejected1,
onResponseFulfilled2, onResponseRejected2
];
promise = Promise.resolve(config)
// ... 생략
.then(onResponseFulfilled1, onResponseRejected1)
.then(onResponseFulfilled2, onResponseRejected2)
그렇다면 이제 호출될때마다 interceptor가 등록되는게 아니라 처음 인스턴스가 생성될때 한 번만 등록되도록 해야 했다.
src/apis/base.ts
const axiosInstance = axios.create({
baseURL: import.meta.env.VITE_BASE_URL,
timeout: 10000,
headers: { 'Content-Type': 'application/json' },
});
axiosInstance.interceptors.request.use(
(config) => {
const token = getLocalStorage(ACCESS_TOKEN);
if (config.headers) (config.headers as AxiosHeaders).set('Authorization', token);
return config;
},
(error) => Promise.reject(error)
);
axiosInstance.interceptors.response.use(
<T>(response: AxiosResponse<T>) => {
console.log(response);
return response.data;
},
(error) => Promise.reject(error)
);
// ... 이외 다른 함수 생략
base 파일 내부에 instance create 함수와 interceptor들을 선언했다. 이렇게 하면 문제를 해결할 수 있지만 전역에 드러나는게 신경쓰여 즉시실행함수로 감싸주었다.
const axiosInstanceWithInterceptors = (() => {
const axiosInstance = axios.create({
baseURL: import.meta.env.VITE_BASE_URL,
timeout: 10000,
headers: { 'Content-Type': 'application/json' },
});
axiosInstance.interceptors.request.use(
(config) => {
const token = getLocalStorage(ACCESS_TOKEN);
if (config.headers) (config.headers as AxiosHeaders).set('Authorization', token);
return config;
},
(error) => Promise.reject(error)
);
axiosInstance.interceptors.response.use(
<T>(response: AxiosResponse<T>) => {
console.log(response);
return response.data;
},
(error) => Promise.reject(error)
);
return axiosInstance;
})();
typescript 숙련도도 높지 않고 axios custom instance를 직접 만들어 사용한 적도 없다보니 요청들의 request, response 타입을 어디서 잡아줘야 할지 막막했다. response들의 type이 unknown
으로 잡히다보니 response.data로 참조할 수 없었다.
then절에서 response 타입을 일일이 명시해주는 방법이 먼저 떠올랐지만 내가 봐도 가독성이 떨어졌고 기존 axios instance 처럼 타입을 명시해줬으면 했다.
axios.get<requestType, responseType>(url)
고생 끝에 알아낸 방법...
내가 만들어낸 요청 메서드들은 createApiMethod
를 호출하여 만들어진다. 이 함수 내부에서 제네릭을 사용하면 메서들르 호출할때 전달한 타입이 request, response에 전달될 수 있었다.
src/apis/base.ts
const createApiMethod =
(axiosInstance: AxiosInstance, method: Method) =>
// <T, K> 제네릭으로 전달 (T: request type, K: response type)
<T, K>(config: AxiosRequestConfig<T>): Promise<K> => {
return axiosInstance({
...config,
method,
});
};
const api = {
get: createApiMethod(instance, HTTP_METHODS.GET),
post: createApiMethod(instance, HTTP_METHODS.POST),
patch: createApiMethod(instance, HTTP_METHODS.PATCH),
put: createApiMethod(instance, HTTP_METHODS.PUT),
delete: createApiMethod(instance, HTTP_METHODS.DELETE),
};
제네릭으로 전달할 수 있다는걸 몰라 며칠 고민했다. 제네릭 공부 좀 할걸...
개발을 배우고 수도 없이 만들어봤던 todo였지만 이번 todo는 만들면서 다양한 고민을 해보게 되었다. 그동안 혼자 하는 프로젝트들은 큰 고민없이 내 맘대로 짰었는데 이번에는 혼자 하는 프로젝트지만 네이밍, 구조, 가독성에 대해 고민해가며 만들었다. 물론 혼자 하다보니 고민하는 시간이 무한정 길어지기도 해서 다음 프로젝트 때는 기한에 맞춰 완성하는 것을 연습해볼 필요가 있다고 느꼈다.
이번에 가장 인상 깊었던 경험은 처음으로 라이브러리 코드도 열어보았다는 것이다. 많은 선배님들이 라이브러리 까보면서 공부해라 라고 하셨을땐 코드를 보기만 해도 어렵고 이해도 안가서 나같은 짜바리는 아직 그럴 단계가 못된다고 생각하고 넘겼다. 그런데, 막연히 axios create나 interceptors가 어떻게 돌아가는지 궁금해서 코드를 열어보니 생각보다 어렵지 않았고 내 코드에서 어떤 결과물이 반환되는지 예상할 수 있게 되었다. 내가 사용한 방법들이 문제 해결에 있어 최선의 방법은 아니겠지만 이런 경험을 해봤다는 것 자체로 만족스러웠다.