[React] Ground Admin (관리자 백오피스 페이지) 은 어떻게 구현했나?

ynifamily3·2021년 4월 2일
2

'Ground' 앱의 관리자 도구 (= 웹 페이지)를 만들어야 한다는 수요가 있어서 Ground Admin TF로 진행 중입니다.

일반 웹 사이트와는 다르게 관리자 페이지에는 여러 가지 특징이 있습니다.

첫째. 인가된 사용자만 접근가능합니다.

둘째. API 호출 (조회, 수정, 삭제 등)이 매우 많습니다. 거의 전부라고 보셔도 됩니다.

셋째. 다루어야 할 Form들이 많습니다. 거의 모든 기능이 Form을 다루고 있습니다.

→ API를 호출하고, 상태를 관리하고, Form을 추적하는 것은 매우 많은 반복 작업과 피로도가 수반됩니다.

반면에 소소한 구현상의 이점도 있습니다. 첫째로는 제한된 사용자만 사용하는 백오피스 성격의 앱이다보니까 브라우저 호환성을 크게 신경 쓰지 않아도 되고, 스타일링에 크게 신경쓰지 않아도 됩니다. (하지만 일관적이면 더 좋겠죠?) 그리고 모바일은 고려하지 않아도 됩니다. 이런 애플리케이션 속 기술적인 issue에 대해 어떻게 해결했는지 공유해드리고자 합니다.


첫째. 인가된 사용자만 접근가능하다는 특징이 있습니다.

'Ground'앱의 회원은 6단계로 구분되어 있습니다. (탈퇴/Guest/User/Saint/Owner/Admin) (표 보여줌)

이 중 관리자 페이지는 일단 Admin만 접근 가능하도록 구현하라는 요구사항이 있습니다. 로그인과 관련해서는 JWT 토큰으로 관리하고, 모든 API 트랜젝션은 이 accessToken과 refreshToken으로 관리됩니다.

쉽게 말해, 이메일과 비밀번호를 넣어 서버에 로그인 요청을 보내면 서버는 access token과 refresh token을 클라이언트에게 보내줍니다. (사실 refreshToken은 http-only 쿠키로 보내면 좀 더 관리가 쉬워지고 보안 측면에서도 유리하지만, 서버 측에서 그런 쿠키 설정은 따로 하지 않은 것 같습니다.) 그래서 저희는 refreshToken, accessToken을 모두 localstorage에 넣고 관리합니다. (*출처: https://okayoon.tistory.com/entry/아티클-프로젝트-0010-프론트에서-안전하게-로그인-처리하기)

관리자 페이지는 여러 페이지로 구성되어 있고 이 페이지를 오가면서 그라운드 앱을 관리합니다. 그럴 때마다 수시로 access Token의 존재여부와 만료 여부를 감시하여 적절하지 않은 '상태'가 되면 적절히 refresh token으로 refresh시켜 주거나 그마저 불가능하다면 로그인 화면으로 사용자를 보내 주어야 합니다. 하지만 이러한 로직을 매번 페이지에 넣는다면 너무 verbose 하고 동일한 횡단 관심사가 여러 컴포넌트에 나타나게 됩니다. 그래서 저희는 이러한 인증을 high-order component로 처리하기로 했습니다.

어떤 페이지 컴포넌트 (예를 들어 adPanel)가 있다고 가정합니다. 이 페이지는 인가된 사용자만 접근가능하도록 하고 (accessToken이 유효해야 함), 자동으로 accessToken을 갱신하는 기능이 있었으면 좋을 것 같습니다. 그래서 다음과 같은 high order component를 만들었습니다.


const withRefreshToken = (WrappedComponent) => (props) => {
  const history = useHistory();
  const [auth, setAuth] = useAuth(); // 로컬스토리지에 저장된 accessToken과 refreshToken을 가져옵니다.
  const refresh = useRefreshToken(); // refreshToken을 갱신하는 async function을 반환합니다.
  const [refreshed, setRefreshed] = useState(false);

  useEffect(() => {
    if (!auth) { // 로그인이 되지 않았으면
      history.replace("/login"); // 로그인 페이지로 이동시킵니다.
    }
    refresh() // refreshToken을 통해 accessToken을 새로 발급받습니다.
			.then((data) => {
				// 새로 발급받은 accessToken을 setState 합니다.
        setAuth((auth) =>
          produce(auth, (draft) => { // 기존 상태를 변형한 새로운 객체를 만들어 던져줍니다.
              draft.accessToken = data.accessToken;
          });
        );
        setRefreshed(true);
      })
      .catch((error) => {
				/* 에러 처리 생략 */
			});
  }, []);
	// 성공적으로 accessToken을 발급받기 전까진 '토큰 갱신 중...'이라는 컴포넌트를 대신 보여줍니다.
  if (!refreshed) {
    return <div>토큰 갱신 중...</div>;
  } else {
		// 성공적으로 accessToken을 발급받는다면, 원래 보여주려던 컴포넌트를 보여줍니다.
    return <WrappedComponent {...props} />;
  }
};
export default withRefreshToken;

다음과 같이 페이지 컴포넌트에 해당하는 곳에 감싸서 사용할 수 있습니다.

function AdPage() {
  return (
      {/* 광고 관리 페이지 렌더링 */}
  );
}

export default withRefreshToken(AdPage); // 컴포넌트를 렌더링하기 전에 accessToken을 갱신한다!

둘째. API호출이 매우 많습니다. 거의 모든 API가 accessToken을 필요로 합니다. 그리고 일정한 호출 패턴이 있습니다. 이것을 좀 더 일관성 있게 작성하고 싶어서 커스텀 훅을 작성해 보았습니다.

API는 다음과 같은 특징이 있습니다.

  • 필요한 파라미터 (data - payload)가 있다. (url경로가 될 수도 있고 http method가 될 수도 있고, params가 될 수도 있고, body가 될 수도 있고 header가 될 수도 있다.)
    • accessToken은 꼭 필요하다.
  • 상태가 존재한다. (비동기다 보니까, 로딩 전, 로딩 중, 에러, 성공과 같은 상태를 가진다.)

일단 repo async function작성을 통해 API 호출을 캡슐화할 수 있습니다.

유어슈의 멤버를 불러오는 API가 다음 문서에 주어집니다.

여기서 알 수 있는 사실은

  • GET 메소드
  • URL: /v1/admin/users
  • Parameter (GET에서는 query입니다.)
    • min-banned-posts, page, size
  • (공통) Header에 AccessToken

이와 같은 repo function은 사용하는 API에 따라 얼마든지 많이 생성될 수 있으므로 함수 시그니쳐를 동일하게 유지하면 좋습니다. 그래서 다음 함수 시그니쳐를 사용하였습니다.

const getUsers = async (accessToken, payload) => {/* ... */}

accessToken과 payload를 받고 Promise를 리턴하도록 구성하였습니다. 여기서 payload는 해당 API를 사용하는 데 필요한 모든 의존 값들을 Object로 묶은 값입니다. 구체적으로는 다음과 같이 함수를 구현할 수 있습니다. (axios 라이브러리를 사용하였고, 기본 설정으로 baseURL을 이미 지정해 놓은 상태입니다.)

// 유저 정보 얻기
export const getUserInfo = async (accessToken, payload) => {
	// payload 의 유효성을 검사합니다. (payload를 검사하는 자체 로직을 넣을 수 있습니다.)
  if (!payload) throw new Error("Payload Error");
  const { userId } = payload;
  const req = await axios.get(`/v1/users/${userId}`, {
    headers: { ...defaultHeaders, accessToken },
    params: {},
  });
  return req.data;
};

이런 함수들을 만들어 놓고 컴포넌트에서 필요할 때마다 로드하면 될까요?

import { getUserInfo } from "../repos";

function UserPage() {

	// userInfo에 대한 상태
	const [userId, setUserId] = useState(0);
	const [isLoading, setIsLoading] = useState(false);
	const [isDone, setIsDone] = useState(false);
	const [hasError, setHasError] = useState(false);
	const [userInfo, setUserInfo] = useState({});
  const [auth, setAuth] = useAuth();
	// end of userInfo

	useEffect(() => {
		let detached = false; // 컴포넌트가 언마운트 될 때, 비동기 결과가 settled되어도 상태 변화를 일으키지 않도록 합니다.
		setIsLoading(true);
		setIsDone(false);
		getUserInfo(auth.accessToken, { userId })
		.then((data) => {
			if (detached) return;
			setIsDone(true);
			setUserInfo(data.result);
		})
		.catch((error) => {
			if (detached) return;
			setHasError(true);
		})
		.finally(() => {
			if (detached) return;
			setIsLoading(false);
		});
		return () => {
			// 컴포넌트가 언마운트 될 때, 비동기 결과가 settled되어도 상태 변화를 일으키지 않도록 합니다.
			detached = true;
		}
	}, [userId]);
	return (
	<div>
		{isLoading && <div>로딩 중...</div>}
		{hasError && <div>에러!</div>}
		{isDone && <div> {/* 유저 정보 렌더링 */} </div>}
	</div>
	);

}

위와 같이 컴포넌트를 렌더링해야 한다고 생각해 보세요. 괜찮아 보이나요?

사실 이건 API 호출이 2개 이상만 되어도 수많은 상태에 파묻혀야 합니다. 어질어질하죠.

useReducer로 상태를 묶어서 관리해도 괜찮을 것 같으나, 좀 더 코드 중복을 줄이고 세련된 방법을 찾아봅시다.

useApi라는 커스텀 훅을 구현하였습니다.

import { useCallback, useEffect, useRef, useState } from "react";
import { useAuth } from "./UseAuth";
import { ApiStatus } from "../entity/repo/ApiResponse"

function useApi(repoFunc) {
  const [auth] = useAuth(); // accessToken을 취득하기 위한 hook입니다.
  const _repoFunc = useRef(repoFunc); // repoFunc를 잡아둡니다.
  const _payload = useRef(undefined); // payload를 잡아둡니다.
  const [flag, setFlag] = useState(-1); // 똑같은 API를 여러 번 fetching할 수 있도록 하는 플래그입니다.
	// API 호출 상태 (IDLE, PENDING, SUCCESS, FAILURE)와 결과값을 가지고 있는 state입니다.
  const [statusData, setStatusData] = useState({
    status: ApiStatus.IDLE,
    data: null,
  });

  useEffect(() => {
		let detached = false; // 컴포넌트가 언마운트 될 때, 비동기 결과가 settled되어도 상태 변화를 일으키지 않도록 합니다.
		// 처음 Hook을 설정했을 때의 flag는 -1입니다. 처음에는 아무것도 하지 않습니다.
    if (flag === -1) {
      return;
    }
		// API 호출 전에 accessToken의 유효성을 검사합니다.
    if (auth === null || auth.accessToken.expireIn < +new Date()) {
      alert("액세스토큰 만료됨");
      setStatusData({ status: ApiStatus.FAILURE, data: null });
      return;
    }
		// API 호출을 하기 전 PENDING 상태로 만듭니다.
    setStatusData((statusData) => {
      return { status: ApiStatus.PENDING, data: statusData.data };
    });

		// 인자로 받은 repoFunc (아까 정의한 것과 같은 repo function을 호출합니다.)
    _repoFunc
      .current(auth.accessToken.token, _payload.current)
      .then((d) => {
				// API 호출이 성공적이라면
        if (!detached) {
          setStatusData({
            status: ApiStatus.SUCCESS,
            data: d,
          });
        }
      })
      .catch((error) => {
				// API 호출이 실패하였다면
        if (!detached) {
          setStatusData({
            status: ApiStatus.FAILURE,
            data: null,
          });
          console.log(error);
        }
      });

    return () => {
			// 컴포넌트가 언마운트 될 때, 비동기 결과가 settled되어도 상태 변화를 일으키지 않도록 합니다.
      detached = true;
    };
  }, [auth, flag, _payload, _repoFunc]);

	// API 함수를 call 할 수 있는 함수입니다.
  const call = useCallback((newPayload: Payload) => {
    _payload.current = newPayload;
    setFlag((r) => (r + 1) % 2); // flag를 바꾸어 useEffect가 실행되도록 유도합니다. 호출 횟수에 따라 (-1, 0, 1, 0, 1 ... 으로 진동합니다.)
  }, []);

  const status = statusData.status;
  const data = statusData.data;
  return { call, status, data };
}

export { useApi };

위와 같이 커스텀 훅을 작성하면 API 호출을 좀 더 편하게 할 수 있습니다.

다음과 같이 사용할 수 있습니다.

function TextAd() {
  const {
    status: textAdsStatus,
    data: textAds,
    call: invokeGetTextAds,
  } = useApi(getTextAds);

  useEffect(() => {
    invokeGetTextAds({page: 0, size: 100}); // payload를 넣어 API 함수 호출
  }, []);

  return (
    <ContentsBox>
      <Text style={{ fontWeight: "bold", lineHeight: 3 }}>
        텍스트 광고 목록 조회
      </Text>
      <FlatBox style={{ width: "100%", height: "auto" }}>
        {textAdsStatus === ApiStatus.PENDING && <Text>로딩 중...</Text>}
        {textAdsStatus === ApiStatus.FAILURE && (
          <Text>에러! (재시도 버튼)</Text>
        )}
        {textAdsStatus === ApiStatus.SUCCESS && (
          <ul
            style={{
              width: "100%",
              listStyle: "none",
              margin: 0,
              padding: 0,
            }}
          >
            {textAds &&
              textAds.advertisements.map((textAd) => (
                <AdView ad={textAd} key={`ad-${textAd.id}`} />
              ))}
          </ul>
        )}
      </FlatBox>
    </ContentsBox>
  );
}

export default React.memo(TextAd);

추가 사항: Child Component (Dot Notation) 을 사용하면 다음과 같이 사용할 수 있을 수도 있을 것입니다. (하지만 클래스형 컴포넌트를 사용해야겠죠!)

<Component>
	<Component.PENDING>{/* */}</Component.PENDING>
	<Component.FAILURE>{/* */}</Component.FAILURE>
	<Component.SUCCESS>{/* */}</COmponent.SUCCESS>
</Component>

셋째. 다루어야 할 Form이 많습니다. 거의 모든 기능이 Form을 사용합니다. 이 Form을 잘 관리할 수는 없을까요?

(이하 내용은 https://blog.roco.moe/React-forwardRef-useImperativeHandle-40b6f61c7bcf41a7b754017fc34189e2 의 내용입니다.)

Form을 다룰 때, 좀 더 편하게 관리 (부모 쪽에서 자식의 값을 한꺼번에 얻어온다거나..)할 수 있도록 한 고민이 듭니다.

회원가입 폼과 같이 여러 Form의 값을 다뤄야 할 상황이 생겼다고 가정합니다.

예를 들어, 이름, 이메일주소, 성별의 값을 받는다고 가정합니다.

첫 번째 방법 (부모 컴포넌트로 상태 끌어올리기)

바로 생각할 수 있는 방법입니다. 부모 컴포넌트에게 상태 관리를 맡길 수 있습니다.

// App.tsx
import { useState } from "react";
import JoinFormsUp from "./JoinFormsUp";

export type IGender = "M" | "F" | "N" | null;
type ISetState<T> = React.Dispatch<React.SetStateAction<T>>;
export interface IJoinFormsProps {
  name: string;
  setName: ISetState<string>;
  email: string;
  setEmail: ISetState<string>;
  gender: IGender;
  setGender: ISetState<IGender>;
}

function App() {
  const [name, setName] = useState("");
  const [email, setEmail] = useState("");
  const [gender, setGender] = useState<IGender>(null);

  return (
    <>
      <JoinFormsUp
        name={name}
        setName={setName}
        email={email}
        setEmail={setEmail}
        gender={gender}
        setGender={setGender}
      />
      <button
        onClick={() => {
          alert(`이름: ${name}\n이메일: ${email}\n성별: ${gender}`);
        }}
      >
        값 알아내기
      </button>
    </>
  );
}

export default App;
// JoinFormsUp.tsx
import React from "react";
import { IJoinFormsProps } from "./App";

const JoinForms: React.FC<IJoinFormsProps> = (props) => {
  const { name, setName, email, setEmail, gender, setGender } = props;

  return (
    <form onSubmit={(e) => e.preventDefault()}>
      <input
        type="text"
        placeholder={`이름`}
        value={name}
        onChange={() => setName(e.target.value)}
      />
      <input
        type="email"
        placeholder={`이메일`}
        value={email}
        onChange={() => setEmail(e.target.value)}
      />
      <input
        type="radio"
        name="gender"
        value={`M`}
        checked={gender === `M`}
        onChange={() => setGender(`M`)}
      />
      <input
        type="radio"
        name="gender"
        value={`F`}
        checked={gender === `F`}
        onChange={() => setGender(`F`)}
      />
      <input
        type="radio"
        name="gender"
        value={`N`}
        checked={gender === `N`}
        onChange={() => setGender(`N`)}
      />
    </form>
  );
};

export default JoinForms;

직관적인 방법이지만 뭔가 불편합니다.

  • 단지 이름, 이메일주소, 성별 세 가지 값이 필요한 것 뿐인데 너무 verbose합니다!
  • 만약 저 JoinFormsUp 을 재사용하고 싶다면 부모 상태까지 모두 복사해야 합니다.
    • Form이 수정된다면 더욱 많은 곳에서 코드 수정을 유발합니다.

좀 더 우아하게 해결할 수는 없을까요?

두 번째 방법 (ref를 전달받기)

이 방법은 부모 컴포넌트에서 각각의 input 에 대한 ref를 생성하고 부모 컴포넌트에서 값을 읽어올 수 있는 방법입니다.

prop의 이름이 ref 가 될 수 없으므로 다른 이름을 사용해야 합니다.

// App.tsx
import React, { useRef } from "react";
import JoinFormsUp from "./JoinFormsUp";

export type IGender = "M" | "F" | "N" | null;
export interface IJoinFormsProps {
  nameRef: React.RefObject<HTMLInputElement>;
  emailRef: React.RefObject<HTMLInputElement>;
  genderRef: React.RefObject<{ value: IGender }>;
}

function App() {
  const name = useRef<HTMLInputElement>(null);
  const email = useRef<HTMLInputElement>(null);
  const gender = useRef<{ value: IGender }>({ value: null });

  return (
    <>
      <JoinFormsUp nameRef={name} emailRef={email} genderRef={gender} />
      <button
        onClick={() => {
          alert(
            `이름: ${name.current?.value}\n이메일: ${email.current?.value}\n성별: ${gender.current?.value}`
          );
        }}
      >
        값 알아내기
      </button>
    </>
  );
}

export default App;
// JoinFormsUp.tsx
import React from "react";
import { IJoinFormsProps, IGender } from "./App";

const JoinForms: React.FC<IJoinFormsProps> = (props) => {
  const { nameRef, emailRef, genderRef } = props;
  const handleGenderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    if (genderRef.current) {
      genderRef.current.value = e.target.value as IGender;
    }
  };
  return (
    <form onSubmit={(e) => e.preventDefault()}>
      <input type="text" placeholder={`이름`} ref={nameRef} />
      <input type="email" placeholder={`이메일`} ref={emailRef} />
      <input
        type="radio"
        name="gender"
        value="M"
        onChange={handleGenderChange}
      />
      <input
        type="radio"
        name="gender"
        value="F"
        onChange={handleGenderChange}
      />
      <input
        type="radio"
        name="gender"
        value="N"
        onChange={handleGenderChange}
      />
    </form>
  );
};

export default JoinForms;
  • 위의 방식으로도 아주 잘 작동하지만 그래도 아쉬운 점이 있습니다.
    • 여전히 부모 컴포넌트는 자식 컴포넌트 하나하나에 verbose 하게 prop을 내려주고 있습니다.
    • ref를 좀 더 의미있고, 여러 가지 기능 (자식이 보내는 함수라든지..)을 처리하는 방법이 있으면 좋을 것 같습니다.

세 번째 방법 (forwardRef와 useImperativeHandle 사용하기)

forwardRef 는 노출시킬 ref를 하나 지정해 주는 기능입니다. (사용법은 잘 알고 있을 거라 믿습니다!)

하지만 여기선 노출하고 싶은 ref가 여러 개인데요?

걱정할 것 없습니다. ref의 매핑 기능을 하는 useImperativeHandle 이 있으니 말입니다. 원하는 값을 골라 노출시킬 수 있고, 자식에서 정의한 함수를 부모가 사용할 수도 있게 해 주고 get set 같은 기능도 마음껏 사용 가능하다. 한 마디로 원하는 메소드를 노출하는 기능이라 볼 수 있습니다.

일단 코드로 이해해 봅시다.

// App.tsx
import { useRef } from "react";
import JoinForms from "./JoinForms";

export type IGender = "M" | "F" | "N" | null;

interface IJoinFormsRef {
  readonly name: string;
  readonly email: string;
  readonly gender: IGender;
}

function App() {

  const ref = useRef<IJoinFormsRef>(null); // ref는 깔끔하게 하나이다. 이 ref는 자식 컴포넌트에게 많은 것을 행사할(?) 수 있다.

  return (
    <>
      <JoinForms ref={ref} />
      <button
        onClick={() => {
          alert(
            `이름: ${ref.current?.name}\n이메일: ${ref.current?.email}\n성별: ${ref.current?.gender}`
          );
        }}
      >
        값 알아내기
      </button>
    </>
  );
}

export default App;
// JoinForms.tsx
import React, { useImperativeHandle, useRef } from "react";
import { IGender } from "./App";
const JoinForms = React.forwardRef((props, ref) => {
  const nameInputRef = useRef<HTMLInputElement>(null);
  const emailInputRef = useRef<HTMLInputElement>(null);
  const genderRadio1Ref = useRef<HTMLInputElement>(null);
  const genderRadio2Ref = useRef<HTMLInputElement>(null);
  const genderRadio3Ref = useRef<HTMLInputElement>(null);

  useImperativeHandle(
    ref, // 내보낼 ref에 대해 다음 매핑을 수행한다.
    () => ({
      get name(): string {
        return nameInputRef.current ? nameInputRef.current.value : "";
      },
      get email(): string {
        return emailInputRef.current ? emailInputRef.current.value : "";
      },
      get gender(): IGender {
        if (genderRadio1Ref.current?.checked) return "M";
        if (genderRadio2Ref.current?.checked) return "F";
        if (genderRadio3Ref.current?.checked) return "N";
        return null;
      },
    }),
    [] // 제어형 컴포넌트 등에 state를 사용한다는 등 필요하면 deps도 받아들일 수 있습니다!!
  );

  return (
    <form onSubmit={(e) => e.preventDefault()}>
      <input type="text" ref={nameInputRef} placeholder={`이름`} />
      <input type="email" ref={emailInputRef} placeholder={`이메일`} />
      <input type="radio" ref={genderRadio1Ref} name="gender" />
      <input type="radio" ref={genderRadio2Ref} name="gender" />
      <input type="radio" ref={genderRadio3Ref} name="gender" />
    </form>
  );
});

export default JoinForms;

훨씬 코드가 깔끔해지고 재사용 가능성도 올라갑니다. 많은 form을 다루어야 할 때는 useImperativeHandleforwardRef 를 사용해 봅시다!

0개의 댓글