저희팀 (NDD)는 면접연습 서비스를 개발하기전, 기술 선정을 하는 시간을 가지게 되었습니다.
기술 선정에 있어서 다음과 같은 질문이 주어졌습니다.
- next를 사용해야하는가?
- 어떤 styled in JS 를 사용할것인지
- 상태관리 라이브러리는 어떤것?
저희는 아래와 같은 논의를 진행하게 되었습니다.
Next를 사용하면 다양한 편의적인 기능들을 사용할 수 있는 아주 강력한 프레임워크입니다.
이번 팀 블로그도 SSG를 이용해서 Next기반으로 만들었기도 했습니다.
Next을 사용하며 호감이 드는 프레임워크 인데, 여러가지 편리한 기능들이 존재하기 때문입니다.
→ next13 버전의 app Dir 를 사용하지 않는다면, page router 처리에서 소소하게 감동받을 수 있다.
특별한 규칙을 적용하지 않더라도(react-router 등등) 정말 직관적인 page 처리가 가능하며, 바로 코드 스플릿팅이 이루어져 성능을 향상시키고 사용자 경험을 개선하는데 큰 도움이 된다고 생각합니다. (페이지 별로 필요한 자바스크립트 코드만 로드하는것을 프레임워크에서 지원)
→ 일반적으로 웹페이지의 성능을 끌어올리는 여러 방법중에 이미지 최적화는 아주 필수적인 기능을 제공합니다. Next를 개발한 vercel도 이 또한 유념해서 기능을 개발한것이 눈에 띄는 데요! 대표적으로 Lazy-Loading과 이미지 사이즈 최적화 등등을 통해서 이미지를 통해서 발생하는 여러 문제를 아주 쉽게 해결할 수 있습니다.
카카오 엔터테이먼트에서도 Next/Image를 사용해 아주 간단하게 이미지 최적화를 적용한것이 눈에 띕니다.
→ Next는 동적 데이터를 바탕으로 서버측에서 HTML 을 생성하고 클라이언트에 전송하는 방식인 SSR을 지원합니다. 이는 아주 강력한 기능이며, SEO에 극히 유리합니다. (SEO는 "Search Engine Optimization"의 약자로, 웹사이트나 웹페이지를 검색 엔진에서 더 잘 찾을 수 있게 최적화하는 과정입니다. 이는 웹사이트의 가시성을 높이고, 검색 엔진 결과 페이지(SERP)에서의 랭킹을 향상시켜, 더 많은 방문자와 트래픽을 유도하는 목적을 가집니다.)
위에 작성한 기능들 뿐아니라 여러 장점들이 다수 포함되어 있습니다. (/public 경로를 우선해 이미지 src 관리가 편리 등등)
하지만 우리는 Next를 사용하지 않기로 결정했는데, 아래와 같은 이유로 결정했습니다.
→ 사실 가장 큰 이유라고 할 수 있다. 편리한 기능이 모여진 하나의 프레임워크를 사용한다면, 더 좋은 성능의 서비스를 빠른 시일 동안 만들어 낼 수 있을지도 모른다. 하지만 그 과정에서 우리에게 남는것이 서비스만 있기를 바라지는 않는다. 우린 이번 프로젝트를 진행하며 학습을 통해 지식적으로 성취를 이뤄야 한다고 생각했다. 프레임워크에서 지원하는 이미지 최적화를 직접 만들어보고, 코드 스플릿팅을 해보며 좀 더 깊이 있게 원리를 이해해 보고 싶다. 사실 정도의 문제이긴 합니다. react조차도 사용하지 않아야만 한다고 생각할 수 도 있으니 말입니다. 그래도 우린 react를 사용하며 더 깊게 FE의 여러 분야에 대해서 학습을 진행해 보고 싶습니다.
→ Next13이 업데이트 되며 정말 다양한 기능이 업데이트 되었습니다. 이전까지는 client-component기반이던 Next가 default를 server-component로 변경했다는 점이 가장 큰 전환점이라고 할 수 있습니다. 하지만 여기서 다소 치명적인 문제가 발생했는데, emotion을 비롯한 여러 style in JS 라이브러리 들이 호환되지 않는 다는 점입니다.(물론 그런 흐름에 따라 Next 또한 atomic css 기반인 tailwind를 적극적으로 지원하고 있습니다. ) 이를 사용하기 위해선 다소 난해한 셋팅을 진행한 후 파일의 최 상단에 “use client”를 기입하며 다소 어글리한(지극히 주관적입니다) 코드가 작성되어야만 합니다. 물론 app router를 사용해서 layout를 재활용하는 부분까지 포함되어 있다보니, 이쯤 되면 FE 공부라기보단 Next13의 학습에 치중되는 듯하여 방향성이 잘못되었다는 생각이 들었습니다. (물론 app router를 설정하지 않는다면 이전까지의 next를 사용할 수 있습니다. 하지만 이는 오히려 사장되어 가는 프레임워크의 끝에 메달려 있는 듯한 기분이 들었습니다. Next 13의 app router가 stable 까지 되었으니, 더더욱 이전 버전에 대한 학습은 아쉽다는 생각이 들었습니다. )
→ SSR의 최대 장점이라고 할 수 있는 초기 로딩 속도와 Seo 에 유리하다는 점 모두 아직 우리 서비스에 대해서 불필요하다고 생각했습니다. 저희의 서비스는 Desktop 환경이 에서 제공되기 때문에 아직 초기 로딩 속도에 대해서 모바일에 비해 여유롭게 대처가 가능할것이라 예상할 수 있었습니다. seo 또한 ssr이 아닌 csr 상태에서 최선을 다해서 seo를 준비하는 경험을 가지고 싶기 때문입니다. 이 편이 우리 FE 팀이 성장하는데 큰 도움이 되리라 생각했습니다. 더불어 SSR이기 때문에 동적으로 서버를 운영해야 하는 부담을 동시에 가지고 있습니다.
결국 이와 같은 이유로 우리는 Next를 사용하지 않기로 결정했습니다!
사실 많은 선택지가 있지는 않았습니다. 오히려 다들 명확했습니다.
우리 팀원들 모두 tailwind와 같은 atomic css 에 익숙하지는 않았기 때문에 tailwind css는 가장먼저 우선순위에서 제거되었습니다
(사용할거면 차라리 Next13의 app router랑 함께 사용했지…!)
팀원들 모두 styled-components를 사용해본 경험이 있었습니다.
모두들 styled-components에 익숙했으며, emotion을 사용하기에 큰 어려움이 없을것 이라는 생각이 들었습니다.
무엇보다 제가 emotion을 사용해보고 싶었습니다.
제가 사용해봤던 css-props에 대한 경험이 좋았기 때문입니다.
어느 정도는 inline css 스러운 작성이었으나, 보다 직관적이었으며 더 이상 styled-component의 이름을 정하는 것에 고통받지 않아도 된다는 점이 매력적이었습니다.
갈등 하나 없이 우리는 Emotion을 선택하게 되었습니다.
사실 결정하기 가장 어려운 부분이었습니다.
저는 여러가지 상태관리 라이브러리를 사용해본적이 없었으며, 유일하게 사용해본 상태관리 라이브러리는 Mobx였습니다.
Mobx에 대한 경험은 정말 좋았습니다. Mobx의 중심이 되는 철학은
"Anything that can be derived from the application state, should be derived. Automatically."
즉, "애플리케이션 상태에서 파생될 수 있는 모든 것은 자동으로 파생되어야 한다"입니다. 이는 개발자가 상태 관리에 대해 신경 쓰지 않고도 UI가 최신 상태를 반영하도록 합니다. MobX는 또한 효율적인 업데이트를 위해 최소한의 재 계산만 수행하여 성능을 최적화 하는 기능을 지원합니다.
하지만 다른 상태관리 라이브러리에 대한 경험은 전혀 없었기 때문에, 팀원분들을 믿고 팀원분들의 recoil을 사용해보자는 의견을 따르게 되었습니다.
recoil을 새롭게 도입하기 전에 제가 mobx를 사용하던 코드를 확인해 보며, 그 개념을 다시금 익혀보려했습니다.
아래는 제가 지금까지 사용했던 mobx 를 사용할 때의 코드입니다.
전체적인 Mobx 느낌을 기억해보려 다시 꺼내봤습니다!
// MobX의 `observable`과 `toJS` 함수를 임포트합니다.
import { observable, toJS } from "mobx";
// ToastProps와 ToastState 타입을 임포트합니다.
import type { ToastProps, ToastState } from "./type";
// `toastStore` 상태 저장소를 생성합니다. 이 상태는 반응형으로 관찰 가능한 객체입니다.
export const toastStore = observable({
// `state` 객체를 초기화합니다. 여기에는 `toasts` 배열과 `deviceType` 문자열이 포함됩니다.
state: {
toasts: [],
deviceType: "desktop",
} as ToastState,
// 새로운 토스트 메시지를 추가하는 메서드입니다.
addToast(content: string, type: "success" | "warning" | "info" | "error") {
// 디바이스 타입에 따라 최대 토스트 메시지 길이를 설정합니다.
const maxLength = this.state.deviceType === "desktop" ? 5 : 0;
// 최대 길이를 초과하면 가장 오래된 토스트 메시지를 제거합니다.
if (this.state.toasts.length > maxLength) this.state.toasts.shift();
// 랜덤한 ID를 생성합니다.
const randIndex = Math.floor(Math.random() * 1000);
// 새로운 토스트 메시지를 배열에 추가합니다.
this.state.toasts.push({
content: content,
id: randIndex,
visible: false,
type: type,
});
// 토스트 메시지가 보이도록 설정하는 타이머를 설정합니다.
setTimeout(() => {
toastStore.visibleToast(randIndex);
}, 1);
// 토스트 메시지를 숨기는 타이머를 설정합니다.
setTimeout(() => {
toastStore.unVisibleToast(randIndex);
}, 4000);
// 토스트 메시지를 삭제하는 타이머를 설정합니다.
setTimeout(() => {
toastStore.deleteToast(randIndex);
}, 5000);
},
// 디바이스 타입을 설정하는 메서드입니다.
setDiviceType(deviceType: "desktop" | "webview" | "mobile") {
this.state = {
...this.state,
deviceType,
};
},
// ID에 해당하는 토스트 메시지를 보이도록 상태를 변경하는 메서드입니다.
visibleToast(id: number) {
const toasts = toJS(this.state.toasts);
const toastIndex = toasts.findIndex((toast) => {
if (toast?.id === id) {
return true;
}
});
this.state.toasts[toastIndex].visible = true;
this.state = {
...this.state,
toasts: [...this.state.toasts],
};
},
// ID에 해당하는 토스트 메시지를 숨기도록 상태를 변경하는 메서드입니다.
unVisibleToast(id: number) {
const toasts = toJS(this.state.toasts);
const toastIndex = toasts.findIndex((toast) => {
if (toast?.id === id) {
return true;
}
});
if (toastIndex !== -1) this.state.toasts[toastIndex].visible = false;
},
// ID에 해당하는 토스트 메시지를 삭제하는 메서드입니다.
deleteToast(id: number) {
const toastIndex = this.state.toasts.findIndex((toast) => {
if (toast?.id === id) {
return true;
}
});
if (toastIndex !== -1) this.state.toasts.splice(toastIndex, 1);
},
// 계산된 값을 제공하는 getter입니다. 현재 토스트 배열을 반환합니다.
//* computed value
get toasts(): ToastProps[] {
return toJS(this.state.toasts);
},
});
해당 코드는 MobX를 사용하여 애플리케이션에서 토스트 알림들을 관리하는 Store입니다. observable 객체는 자동으로 상태가 변경될 때마다 UI가 업데이트되도록 합니다.
// 사용방법
// 특정 비지니스 코드에서 사용하는 방식
const { userStore, toastStore } = useStore();
try {
// 로그인을 수행하는 로직
} catch (error) {
await logger.error(apiService.get.name, error);
toastStore.addToast("이메일 또는 비밀번호를 확인해주세요", "error"); // UI를 업데이트 합니다
router.push("/login");
}
// Toast.tsx
import React from "react";
import { observer } from "mobx-react";
import useStore from "@hooks/useStore";
import ToastPresenter from "./Toast.presenter";
import type { ToastComponentProps } from "./Toast.type";
const Toast = ({ isWebview }: ToastComponentProps) => {
const { toastStore } = useStore();
const deleteToast = (id: number) => {
toastStore.unVisibleToast(id);
setTimeout(() => {
toastStore.deleteToast(id);
}, 400);
};
return (
<ToastPresenter
toastList={toastStore.toasts} // toastStore의 배열을 확인하여 UI를 업데이트 합니다.
deleteToast={deleteToast}
isWebview={isWebview}
/>
);
};
export default observer(Toast);
다음과 같이 Mobx를 통해서 Store를 업데이트 하고, Toast 컴포넌트는 toastStore가 업데이트 됨을 확인하며 UI를 업데이트합니다.
위와 같이 Mobx는 Toast의 상태를 지속적으로 observable이라는 관찰 가능한 상태를 통해서 컴포넌트 업데이트를 통제하는것이 저에겐 익숙한 개념이엇습니다. 또한 직관적으로 상태변경을 일으키기 위해 action을 사용하는것 또한 마음에 들었습니다.
위에서도 적혀있는 store.ts에서 확인할 수 있듯, state를 추상화하여 관리하는것 또한 객체지향적으로 추상화가 잘 되어 있는듯 했습니다.
Recoil
은 Facebook
에서 만든 React
용 상태 관리 라이브러리입니다. React의 Context API와 유사한 기능을 제공하지만, React 애플리케이션에서 상태 관리를 더 효율적이고 유연하게 만들기 위해 설계되었습니다. 여기에는 다음과 같은 몇 가지 주요 특징이 있습니다.
React 컴포넌트
는 이 Atom을 구독
할 수 있습니다. Atom 값이 변경되면 해당 Atom을 구독하는 모든 컴포넌트가 리렌더링
됩니다.derived state
)의 일부를 나타내는 순수 함수입니다. Atom
이나 다른 Selector
의 상태를 입력으로 받아 새로운 데이터를 계산합니다. Selector
는 캐시를 사용하여 효율적인 상태 파생을 가능하게 합니다.Recoil
은 비동기 쿼리를 포함한 상태 관리를 쉽게 할 수 있게 해줍니다. 이를 통해 데이터 페칭, 비동기 계산 등을 손쉽게 관리할 수 있습니다.useState
훅과 유사하게, Recoil은 함수형 업데이트를 지원하여 이전 상태를 기반으로 새 상태를 생성할 수 있습니다.Recoil
은 개발자 도구를 제공하여 상태 변화를 시각적으로 추적하고 디버깅하는 것을 도와줍니다.Recoil
의 주요 목표 중 하나는React
의 훅 API와 잘 어우러지도록 하는 것입니다. 이는 컴포넌트 기반 아키텍처에 자연스럽게 녹아들어 상태 관리를 더욱 직관적으로 만듭니다. 또한, Atom
과 Selector
의 개념(Mobx에서의 state 선언방식과 유사)은 상태를 더욱 모듈화하고 관리하기 쉽게 만들어, 크고 복잡한 애플리케이션에서 특히 유용합니다.
앞으로 진행하게 될 recoil에 대해서도 깊이 있는 학습을 진행해 mobx와 비교하며 전역상태관리 방식에 대해 학습이 필요할 듯 합니다.
아래는 간단하게 확인해본 recoil 코드이며 mobx와의 간단한 비교를 포함합니다.
// 상태관리 state 정의
// recoil -> 토스트 상태를 저장하는 atom을 정의합니다.
export const toastState = atom({
key: 'toastState', // 고유한 key
default: {
toasts: [],
deviceType: 'desktop',
}, // 기본 상태
});
// mobx -> `state` 객체를 초기화합니다. 여기에는 `toasts` 배열과 `deviceType` 문자열이 포함됩니다.
state: {
toasts: [],
deviceType: "desktop",
} as ToastState,
// 상태관리 메서드 관리
// recoil -> selector는 set 속성을 통해 상태를 변경할 수 있습니다.
set: ({ set, get }, action) => {
const { toasts, deviceType } = get(toastState);
if (action.type === 'addToast') {
const maxLength = deviceType === 'desktop' ? 5 : 0;
if (toasts.length > maxLength) toasts.shift();
const randIndex = Math.floor(Math.random() * 1000);
toasts.push({
content: action.content,
id: randIndex,
visible: false,
type: action.toastType,
});
// 비동기 액션을 처리하기 위해 setTimeout을 사용합니다.
setTimeout(() => setVisibleToast(set, randIndex), 1);
setTimeout(() => setUnVisibleToast(set, randIndex), 4000);
setTimeout(() => deleteToast(set, randIndex), 5000);
}
set(toastState, { toasts, deviceType });
},
// mobx -> 매서드 형식으로 addToast 함수를 정의합니다.
addToast(content: string, type: "success" | "warning" | "info" | "error") {
// 디바이스 타입에 따라 최대 토스트 메시지 길이를 설정합니다.
const maxLength = this.state.deviceType === "desktop" ? 5 : 0;
// 최대 길이를 초과하면 가장 오래된 토스트 메시지를 제거합니다.
if (this.state.toasts.length > maxLength) this.state.toasts.shift();
// 랜덤한 ID를 생성합니다.
const randIndex = Math.floor(Math.random() * 1000);
// 새로운 토스트 메시지를 배열에 추가합니다.
this.state.toasts.push({
content: content,
id: randIndex,
visible: false,
type: type,
});
// 토스트 메시지가 보이도록 설정하는 타이머를 설정합니다.
setTimeout(() => {
toastStore.visibleToast(randIndex);
}, 1);
// 토스트 메시지를 숨기는 타이머를 설정합니다.
setTimeout(() => {
toastStore.unVisibleToast(randIndex);
}, 4000);
// 토스트 메시지를 삭제하는 타이머를 설정합니다.
setTimeout(() => {
toastStore.deleteToast(randIndex);
}, 5000);
},
사실 “왜 recoil을 사용해야만 했어” 라는 질문에는 아직 완벽한 대답을 하지 못할것 같습니다.
저는 지금까지 mobx에 익숙한 코드를 작성해왔고, recoil 코드를 위와 같이 작성해보았지만, 아직 mobx 사고에서 벗어나지 못한듯 합니다.
앞으로 코드를 작성해가며 recoil을 통한 관리 방식의 철학을 잘 이해하고 정리하며 새롭게 학습하는것 또한 의미 있다는 생각이 들었습니다.