HLOG 리펙터링
안녕하세요? 오랜만에 블로그 포스팅을 하는 것 같습니다.
몇 개월 동안 정말 열심히 달려오느라 블로그 관리할 시간이 없었어요.
거두절미하고 이번 글의 주제는 HLOG 리펙터링이에요.
기존의 레거시 코드를 전부 효율적이고 읽기 편한 코드로 변환하는 작업을 하려고 해요.
또한, 기술 스택도 기존에 사용해보지 않았던 SWR(Data-fetching Library)를 사용해서 작업을 해보려고 합니다. 그리고 Next.js를 벗어나 React를 이용해서 개발을 진행하려고 합니다.
그리고 기존에 미약했던 반응형 디자인 시스템도 새로 구축해보려고 합니다.
이번 리펙터링의 최대 목표는 선언적으로 코딩하기입니다.
트러블 슈팅
- React-Router-Dom사용 시, 직접 주소 입력을 할 때 Cannot GET이 뜨는 경우.
해당 에러가 발생하는 이유를 이해하려면 Client-Side Rendering의 개념을 이해하여야 하는데, 처음에 리액트로 구현된 웹사이트에 들어가게 되면 최소한의 HTML을 받게 되고, Script를 로드하면서 리액트를 다운로드하게 됩니다. 여기서 포인트는 브라우저에서 HTML을 받을 때 바로 React가 실행되는 게 아니라는 건데요. 리액트를 브라우저에서 다운로드하여 사용하는 것입니다. 리액트 로드가 완료되면 구현되었던 리액트 코드를 DOM에 대입하게 되는 겁니다.
이제 원인을 알았습니다. 다른 Route에 접근했을 때에 React-Router-Dom은 해당 URL을 GET 할 준비가 되지 않았다는 것이죠.
저는 해당 에러를 Webpack의 historyApiFallback을 설정해줘서 해결했습니다. 그럼 historyApiFallback이 무슨 기능을 수행하는지 알아야겠죠.
이 옵션을 true로 설정해주면 기본적으로 설정해놓은 URL의 경로가 아닌 다른 경로로 접근했을 때에 원래는! 404 에러를 뱉어내는 게 정상이지만 이 옵션을 설정함으로써 404 에러 대신에 index.html을 렌더링 하게 됩니다. 한번 생각해봅시다. index.html을 렌더링 한다는 게 어떤 의미일까요? 루트 경로가 아닌 다른 경로에 들어가더라도 정상적으로 리액트를 다운을 받는다는 의미입니다.
이제 Cannot GET이라는 것을 받지 않습니다.
기능 구현
- React Editor 만들기 (Draft.js)
우선 Draft.js를 설치합니다.
npm install draft-js react react-dom
# or alternately
yarn add draft-js react react-dom
타입스크립트를 사용하고 있는 입장에서는 타입 모듈도 받아주도록 합니다.
yarn add -D @types/draft-js
이제 에디터를 사용하려는 곳에서 다음과 같이 불러보겠습니다. (타입스크립트 기준으로 설명하겠습니다.)
import { Editor, EditorState, DraftEditorCommand, RichUtils } from 'draft-js';
import 'draft-js/dist/Draft.css';
그런 다음 에디터의 로컬 상태를 만들고
const [editorState, setEditorState] = useState(
() => EditorState.createEmpty(),
);
return <Editor
editorState={editorState}
onChange={setEditorState}
/>;
다음과 같이 작성합니다.
이제 해당 작업들이 무슨 역할을 하는지 알아보도록 합시다.
EditorState.createEmpty()의 역할은
- 현재 텍스트 내용 상태
- 현재 선택 상태
- 내용물을 완벽하게 장식한 표현
- 스택 실행 취소/다시 실행
- 가장 최근에 변경된 내용 유형
의 기본적인 정보들이 담겨있는 레코드입니다. 레코드는 일반 JS객체와 유사하지만 특정 문자열 허용된 문자열 키의 특정 집합을 시행하며 기본값을 갖습니다. (정보 : https://web.archive.org/web/20150623131347/http://facebook.github.io:80/immutable-js/docs/#/Record)
이제 각 상태와 Setter를 Editor 컴포넌트에 바인딩해주고 화면을 보면 다음과 같습니다.
아무것도 안 보이지만 ContentEditable 한 div객체가 하나 있습니다.
확인을 위해서 Border처리를 해보도록 하겠습니다.
이제 잘 보입니다.
Bold처리, Italic처리 모두 잘 적용되는 모습입니다.
리액트에서 외부 클릭 감지되는 메뉴 리스트 구현하기
그냥 토글 되는 메뉴 리스트 구현하는 것은 매우 쉬운 작업이지만 외부 클릭 시 자동으로 닫히게 되는 메뉴 리스트를 어떤 식으로 구현하면 될까요? 이것을 구현할 때 많은 방식을 생각하게 됩니다.
제가 처음에 생각한 방식은 모달 방식이었습니다. 메뉴 리스트가 렌더링 될 때 background공간을 화면 전체에 퍼지게 만들어서 그 background 공간이 클릭되면 메뉴가 닫히게 되는 방식이었습니다. 물론 이 방법으로도 구현할 수 있겠지만 저는 조금 더 괜찮은 방법이 없을까 생각했습니다.
외부 자료를 찾아본 결과, Ref를 사용해서 DOM API에서 제공되는 기능으로 이를 좀 더 쉽게 구현할 수 있었습니다.
기본적으로 HTMLElement에서는 contains라는 것을 제공하는데요.
https://developer.mozilla.org/ko/docs/Web/API/Node/contains
위 문서를 읽어보면
주어진 인자가 node의 자손인지, 아닌지에 대한 Boolean 값을 리턴합니다.
즉, document 전체에 클릭 이벤트를 걸어서 contains로 클릭된 타깃이 Menu의 자손인지 확인하는 방법입니다.
부분 코드로 예시를 한번 살펴보도록 하겠습니다.
const [userMenuToggleState, toggleUserMenu] = useToggle();
const headerMenuRef = useRef<HTMLDivElement | null>(null);
먼저 토글 되는 메뉴의 상태를 선언해줍니다. useToggle은 제가 만든 커스텀 훅입니다.
그다음 DOM에 접근하기 위해 headerMenuRef를 선언해줍니다.
이제 해당되는 컴포넌트에 ref를 연결해줍니다.
<Element ref={headerMenuRef} onClick={toggleUserMenu}>
...
</Element>
여기서 ref의 적절한 위치는 어디일까요? 기능을 구현하는데 이 부분에서 생각이 많아졌는데요.
저는 Menu를 렌더링 하는 컴포넌트와 헤더 컴포넌트를 분리해놨기 때문에 적절한 위치를 찾는데 고민이 되었습니다.
하지만 쉽게 생각해보면 그 둘을 묶는 컨테이너에 Ref를 주는 게 맞습니다. 그게 무슨 말이냐면
저 프로필 사진을 눌려야 메뉴 리스트가 렌더링 되는 것인데, 이때 메뉴 리스트에 ref를 준다면 프로필 사진을 클릭했을 때에 열리지 않겠죠.
또한 프로필 사진에 ref를 준다면 메뉴 리스트를 눌렀을 때 닫히게 됩니다. 이는 저희가 의도한 바가 아니기 때문에 이 둘을 묶는 컨테이너에 ref를 주는 게 정상적으로 동작할 것입니다. (컴포넌트를 둘로 분리했을 경우에 해당됩니다.)
<Container ref={headerMenuRef} onClick={toggleUserMenu}>
<DefaultProfile/>
<MenuList
items={[
{
title: '내 게시글',
link: '/my-post'
},
{
title: '새 글 작성',
link: '/write'
},
{
title: '설정',
link: '/settings'
},
{
title: '로그아웃',
link: '/logout'
}
]}
visible={userMenuToggleState}
/>
</Container>
이제 외부 클릭을 감지하는 로직을 작성해보면 다음과 같습니다.
useEffect(() => {
function handleOutsideClick(event: MouseEvent) {
if(headerMenuRef.current && !headerMenuRef.current.contains((event.target as Node))) {
// Close User Menu
console.log("Outside");
userMenuToggleState && toggleUserMenu();
}
}
document.addEventListener('mousedown', handleOutsideClick);
return () => document.removeEventListener('mousedown', handleOutsideClick);
}, [userMenuToggleState, toggleUserMenu]);
해당 로직은 헤더 컴포넌트에서 useEffect로 Header컴포넌트가 렌더링 되었을 때 실행되는 콜백인데요.
document에 이벤트를 걸어서 클릭된 타깃이 헤더 프로필과 메뉴 리스트를 묶어놓은 컨테이너의 자식이 아닌 경우에는 Outside가 콘솔에 찍힌 다음 toggleUserMenu를 하게 됩니다. userMenuToggleState && toggleUserMenu()를 하게 되면 메뉴가 열려있을 때 닫는다는 의미죠.
그리고 메뉴 리스트는 어떤 방식으로 구현하면 될까요? 저는 간단하게 두 가지 props를 받게 설계했습니다.
렌더링 할 정보를 받는 items, 열림/닫힘 상태를 받는 visible
소스코드를 보면 다음과 같습니다.
import React from 'react';
import { Link } from 'react-router-dom';
import S from './StyledMenuList';
type MenuItemType = {
title: string;
link: string;
}
type MenuList = {
items: MenuItemType[];
visible: boolean;
}
const MenuList: React.FunctionComponent<MenuList> = ({
items,
visible
}) => {
const handleMenuClick = (event: React.MouseEvent) => {
event.stopPropagation();
// 아이템을 눌렀을 때 동작할 콜백
}
const itemsRender = items.map((item, idx) => (
<S.MenuItem
key={idx}
onClick={handleMenuClick}
>
<Link to={item.link}>
{item.title}
</Link>
</S.MenuItem>
));
if(!visible) return null;
return (
<S.MenuContainer>
{itemsRender}
</S.MenuContainer>
);
}
export default MenuList;
여기서 handleMenuClick이라는 함수에 event.stopPropagation();을 걸어놨는데요.
그 이유는 이벤트는 전파되기 때문입니다. 즉, 이전에 걸어놨던 handleOutsideClick이 실행되기 때문에 이 이벤트가 더 이상 전파되지 않도록 막아줘야 합니다. 이렇게 코드를 작성해보았습니다. 더 좋은 방식이 있다면 언제든 코멘트 남겨주세요. 피드백은 언제나 환영입니다!
리펙터링
Currying 기법으로 리액트 Event Handler 정리
Currying 기법으로 Event Handler 코드 중복 제거하는 법을 알아봅시다.
우선 커링의 개념을 짚고 넘어가야 할 것 같은데요, 간단히 말해서 인자를 여러 개 받는 함수를 하나씩만 받는 함수의 체인으로 만드는 방법입니다.
굳이 여러 개 받는 걸 나누면 더 난잡해지고 코드가 더러워질 것으로 예상되는데요, 하지만 이는 함수형 프로그래밍에 있어서 굉장히 많이 쓰이는 패턴이고 커링을 이용해서 코드가 정말 깔끔해지는 경우가 많습니다.
예시를 한번 들어보겠습니다.
const a = (b,c) => console.log(b,c);
다음과 같은 함수를 커링 기법으로 분리를 해보면
const a = (b) => (c) => console.log(b,c);
이렇게 분리가 됩니다.
이렇게 나누게 되면 다음 코드처럼 사용할 수 있는데요,
const ca = a('Hello');
ca("World");
ca("JunHyeok");
실행 결과는 다음과 같습니다.
아래 코드는 기본적인 리액트 이벤트 핸들링 기법입니다.
// 원본코드
const handleClick = (event, name) => {
setName(name);
}
<div onClick={(event) => handleClick(event, "홍준혁")}/>
이 기법을 리액트 이벤트 핸들러에 사용하면 원본 코드를 다음과 같이 해결할 수 있습니다.
// 커링 변환 코드
const handleClick = (name) => (event) => setName(name);
<div onClick={handleClick("홍준혁")}/>
이렇게 핸들러가 짧아졌습니다. 뭐가 달라졌을까요?
이벤트를 전달하는 부분이 함수 실행 단계에서 없어졌습니다. 어떻게 이런 기술이 가능했던 걸까요?
아래의 코드를 봅시다.
const a = (b) => c(b);
위 코드를 짧게 요약하면 어떻게 변할까요?
다음 코드를 보면 위 이벤트 객체를 전달하지 않아도 되는지 이해가 될 텐데요. 바로 다음 코드와 같게 동작합니다.
const a = (b) => c(b);
// 위 코드는 다음으로 요약할 수 있다.
const a = c;
처음 코드를 봤을 때는 이해가 잘 되지 않습니다. 어째서 저렇게 변할 수 있는지 코드를 하나하나 살펴봅시다.
먼저 (b) => c(b)는 함수입니다. b라는 인자를 전달받아 c함수가 b를 받고 실행하는 함수입니다.
네, c함수는 b라는 인자를 받고 실행하는 함수입니다. 그럼 c와 (b) => c(b)는 결국 같은 행위를 하는 꼴이 아닐까요?
좀 더 이해를 돕기 위해 실제로 돌아가는 코드를 작성해봅시다.
const add1 = (n) => n + 1;
const fn1 = (b) => add1(b);
const fn2 = add1;
fn1(3); // 4
fn2(3); // 4
이벤트 핸들러를 줄이기 위해서 여기까지 왔습니다. 이제 해당 기법을 사용해서 event객체를 명시하지 않고도 실행 가능한 핸들러를 만들 수 있습니다.
실제로 이벤트 핸들러를 정말 많이 전달해줘야 하는 Editor구현에서 코드를 줄일 수 있었습니다.
중복되는 외부 감지 기능을 훅으로 분리하기.
위 기능 구현에서 만들었던 메뉴 리스트는 몇 가지 문제점이 있습니다. 또 다른 외부 감지 컴포넌트를 만들었을 때에 똑같은 코드를 작성해야 하는 중복성 관련 문제입니다. 코드의 가독성 또한 좋지 않고요.
그래서 이를 Hook으로 분리하려고 합니다. 중복되는 코드를 분리하면 다음과 같이 코드를 작성할 수 있을 것 같습니다.
import React, { Ref, useEffect, useRef } from 'react';
function useOutsideRef(closeCallback: Function) {
const ref = useRef(null);
useEffect(() => {
function handleOutsideClick(event: MouseEvent) {
if(ref.current && !ref.current.contains((event.target as Node))) closeCallback();
}
document.addEventListener('mousedown', handleOutsideClick);
return () => document.removeEventListener('mousedown', handleOutsideClick);
}, [closeCallback]);
return ref;
}
export default useOutsideRef;
간단하게 이 훅은 ref를 리턴하는 훅입니다. 하지만 외부 감지를 곁들인 ref죠.
useOutsideRef를 호출할 때 외부 클릭이 되었을 때
행할 콜백만 넣어준다면 기존에 구현한 기능과 동일하게 동작할 것입니다.
트러블슈팅
- 리액트에서 Font-Awesome 설정하기.
리액트에서 Font-Awesome을 설정해주려면 몇 가지 설정을 해주어야 합니다.
가장 먼저 SVG Core를 설치합니다. (아이콘 동작을 위한 모든 유틸리티가 포함된 코어 패키지입니다.)
yarn add @fortawesome/fontawesome-svg-core
그다음 사용할 아이콘을 설치하면 됩니다. 무료와 프로 플랜이 있는데, 저는 무료 플랜으로 이용할 예정입니다.
yarn add @fortawesome/free-solid-svg-icons
yarn add @fortawesome/free-regular-svg-icons
이제 리액트에서 컴포넌트 형태로 사용할 수 있도록 react-fontawesome을 설치합니다.
yarn add @fortawesome/react-fontawesome@latest
이제 사용할 준비가 다 되었습니다.
아이콘 불러올 때는 동작 가져오기 기능이 있습니다.
개별 아이콘을 따로 불러올 필요 없이 동적으로 불러오게 되는 기능입니다. 하지만 이 기능을 사용하려면 Bebel설정을 해주어야 합니다.
yarn add babel-plugin-macros
설치를 한 다음에 babel.config.js이나. babelrc에 다음과 같이 설정해줍니다.
저는. babelrc를 사용하므로 아래와 같이 작성했습니다.
{
...,
"plugins": ["macros"]
}
이제 실행을 하면 에러가 발생합니다. 추가적으로 설정해주어야 하는 게 존재합니다.
babel-plugin-macros.config.js 이란 파일을 만듭니다. 그 파일에 어떤 라이선스를 사용할 것인지에 대한 명시가 필요합니다.
module.exports = {
'fontawesome-svg-core': {
'license': 'free'
}
}
이제 리액트에서 사용할 준비가 완벽하게 끝났습니다. 사용을 해보도록 하겠습니다.
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { solid, regular, brands } from '@fortawesome/fontawesome-svg-core/import.macro'
다음과 같이 불러와 줍니다.
FontAwesomeIcon 컴포넌트는 실제로 아이콘을 렌더링 하는 컴포넌트이고요, solid, regular, brands 등은 가져올 스타일들을 의미합니다. Font-Awesome에서는 다음과 같이 스타일 카테고리가 나눠져 있어요.
트러블슈팅
- 로그인 상태관리
"유저 상태"는 Client State인지 Server State인지 많은 고민이 있었던 것 같아요. 그래서 내린 결론은, "로그인의 유무"는 Client State, "유저의 정보"는 Server State라는 결론을 내렸습니다.
그러면 로그인의 유무는 Redux로 전역적으로 관리를 하고, 유저의 정보는 React Query로 사용하면 되겠죠.
import { AnyAction } from 'redux';
export const LOG_IN = 'LOG_IN' as const;
export const LOG_OUT = 'LOG_OUT' as const;
interface AuthStateType {
isAuthenticated: boolean;
}
const initialState: AuthStateType = {
isAuthenticated: false,
};
function authReducer(state = initialState, action: AnyAction): AuthStateType {
switch (action.type) {
case LOG_IN:
return {
isAuthenticated: true,
};
case LOG_OUT:
return {
isAuthenticated: false,
};
default:
return { ...state };
}
}
export default authReducer;
위 코드는 로그인 상태를 관리하는 리덕스 모듈입니다.
import { useQuery, UseQueryResult } from 'react-query';
import { UserType } from '@/@types/user';
import useInterceptedAxios from '../useInterceptedAxios';
import useLocalStorage from '@/utils/useLocalStorage';
import useAuth from '../useAuth';
interface QueryResult {
user: UserType;
}
export default function useMyInfo(): UseQueryResult<QueryResult> {
const customAxios = useInterceptedAxios();
const {
storedValue: hlogToken,
} = useLocalStorage('hlog_access_token', '');
const getMyInfo = () => customAxios.get('/user/me');
const { loginDispatch } = useAuth();
return useQuery(
['my_info'],
() => getMyInfo(),
{
enabled: !!hlogToken,
onSuccess: loginDispatch,
},
);
}
위 코드는 내 정보를 Fetching하는 쿼리입니다.
여기서 중요하게 봐야할 점은, enabled입니다. 토큰의 여부에 따라 쿼리를 날릴지 말지 결정하는 모습입니다.
만약 토큰이 없는데 쿼리를 보내게 된다면 그 쿼리는 무조건 unauthorized를 응답하겠죠. 그리고 쿼리가 성공했을 경우에는 위에서 만들었던 유저 로그인 정보 상태를 리덕스 Hook을 사용하여 변경시켜줍니다.
로그아웃을 예제로 들어보면 다음과 같습니다.
import useLocalStorage from '@/utils/useLocalStorage';
import useInterceptedAxios from './useInterceptedAxios';
import useAuth from './useAuth';
const useLogout = () => {
const {
remove: removeToken,
} = useLocalStorage('hlog_access_token', '');
const customAxios = useInterceptedAxios();
const { logoutDispatch } = useAuth();
const logout = async () => {
await customAxios.post('/auth/logout');
logoutDispatch();
removeToken();
};
return logout;
};
export default useLogout;
트러블슈팅
- Axios Intercept 중복되는 문제
해당 문제는 제 코드에 있었습니다.
import customAxios from '@/utils/customAxios';
import useLocalStorage from '@/utils/useLocalStorage';
import useRefreshToken from './useRefreshToken';
const useInterceptedAxios = () => {
const { storedValue: hlogToken } = useLocalStorage('hlog_access_token', '');
const refreshToken = useRefreshToken();
customAxios.interceptors.request.use((config) => {
// 모든 Request Header에 Access토큰을 넣어주는 역할
if (!config.headers.authorization) {
config.headers.authorization = hlogToken;
}
return config;
}, (error) => error);
customAxios.interceptors.response.use(
(response) => response,
async (error) => {
// 토큰이 만료되었을 때 새로운 토큰을 발급하는 역할
const prevRequest = error?.config;
if (error?.response?.status === 403 && !prevRequest?.sent) {
prevRequest.sent = true;
const data = await refreshToken();
prevRequest.headers.authorization = data.accessToken;
return customAxios(prevRequest);
}
return customAxios;
},
);
return customAxios;
};
export default useInterceptedAxios;
이렇게 작성을 했었어요. 그래서 사용하는 측에서 다음과 같이 작성을 하면 모든 요청에 intercept가 중첩되게 되는 문제가 발생했습니다.
중첩되게 되면 (심지어 React-Query라서 중첩이 더 심하게 됩니다.) 매 요청/응답 시 중첩된 인터셉터가 100번 1000번 실행되게 됩니다.
처음에는 이를 인지하지 못하고 있었는데, 점점 사용 시간이 늘어날수록 체감이 되어서 이를 수정하기로 결정했습니다.
위 작업을 Hook로 할 필요성을 크게 느끼지 못해서 customAxios.ts내부 코드에서 intercept하는 작업을 한번만 수행한다면
중첩되는 문제가 해소될 것 같아서 코드를 수정해보도록 하겠습니다.
customAxios를 내보내는 코드에서 다음과 같이 작성을 합니다.
import axios, { AxiosRequestConfig } from 'axios';
const AxiosConfigure: AxiosRequestConfig = {
baseURL: process.env.API_SERVER_URL,
timeout: 1000,
withCredentials: true,
};
const customAxios = axios.create(AxiosConfigure);
const hlogToken = localStorage.getItem('hlog_access_token');
customAxios.interceptors.request.use((config) => {
// 모든 Request Header에 Access토큰을 넣어주는 역할
if (!config.headers.authorization && hlogToken) {
config.headers.authorization = JSON.parse(hlogToken);
}
return config;
}, (error) => error);
customAxios.interceptors.response.use(
(response) => response,
async (error) => {
// 토큰이 만료되었을 때 새로운 토큰을 발급하는 역할
const prevRequest = error?.config;
if (error?.response?.status === 403 && !prevRequest?.sent) {
prevRequest.sent = true;
const refreshToken = async () => {
const response = await customAxios.post('/auth/refresh');
const { accessToken } = response.data.payload;
return accessToken;
};
const accessToken = await refreshToken();
prevRequest.headers.authorization = accessToken;
return customAxios(prevRequest);
}
return customAxios;
},
);
export default customAxios;
위 코드와 다른점은, request.use, response.use는 딱 한번만 실행된다는 점이 다릅니다.
이제 메모리 누수가 발생할 요인이 없고 정상적으로 애플리케이션도 동작하는 모습입니다.
이제 custom axios 인터셉터는 더이상 Hooks로 관리하는 주체가 아니라서 그것을 분리하는 작업을 수행해야 합니다.
원래는 고친 방식처럼 이렇게 작성하는게 보편적인 방법이긴 하지만 Hook로 따로 분리해서 인터셉터를 관리해서 생겼던 문제였습니다.
아직 저 코드도 보완해야 할 부분이 많습니다. 그 부분은 추후에 수정해 보겠습니다.
해당 글은 계속 업데이트될 거예요. 아래의 Todo들은 앞으로 제가 해쳐나가야 할 과제고, 과제를 해결하면서 직면했던 상황(에러 등)을 꼼꼼하게 담아두려고 합니다.