처음으로 해본 '제대로 된' 프로젝트 KOJ ②

KOJ의 컴포넌트 빌딩. 실제 코드를 중심으로

Featured image

1월 : 기초부터 탄탄히

이제 실제로 KOJ를 어떻게 개발했는가에 대한 이야기를 해봅시다. 어떤 순서로 프로젝트를 구성하고, 블록들을 쌓아 올려갔는지를 말이죠.

1월달은 전체적인 구조를 잡는 작업들이 참 많았습니다. 우선 피그마로 작성한 UI를 보면서 필요한 컴포넌트들의 구조를 빠르게 잡았습니다. 관련 커밋

Routing 설계

remix-flat-routes를 활용하였습니다. 페이지들은 크게, _noAuth(인증 미필요), _protected(인증 필요), admin(관리자 전용) 이라는 세가지 요소로 구분할 수 있었습니다. 최상위 분류의 _layout.tsx 파일에는 각각 적절한 권한을 가지고 있는지 검사하고, 해당 권한이 없다면 쫒아내는 간단한 로직을 구현하였습니다. 아래는 admin만 접근 할 수 있는 페이지를 보호하기 위한 _layout.tsx의 코드입니다.

import { Outlet, useNavigate } from "@remix-run/react";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { getUserInfo } from "~/API/user";
import Header from "~/components/Header";
import { useAuth } from "~/contexts/AuthContext";

const ProctedRoute = () => {
  const auth = useAuth();
  const navigate = useNavigate();

  const [contextIsLoading, setContextIsLoading] = useState(
    auth.token === "" && auth.userId === ""
  );

  const [isNotAdmin, setIsNotAdmin] = useState(false);

  useEffect(() => {
    const token = sessionStorage.getItem("authToken");
    const userId = sessionStorage.getItem("userId");
    if (!token || !userId) {
      setIsNotAdmin(true);
      return;
    }
    getUserInfo(userId, token)
      .then(({ data }) => {
        console.log(token, userId);
        if (data.is_admin === false) {
          setIsNotAdmin(true);
          return;
        }
      })
      .catch(() => {
        setIsNotAdmin(true);
      });
  }, []);

  useEffect(() => {
    if (
      !sessionStorage.getItem("authToken") ||
      sessionStorage.getItem("authToken")!.length < 10 ||
      isNotAdmin
    ) {
      toast.error("관리자만 접속할 수 있는 페이지 입니다");
      navigate("/");
    }
  }, [isNotAdmin]);

  useEffect(() => {
    if (contextIsLoading)
      setContextIsLoading(auth.token === "" && auth.userId === "");
  });

  return contextIsLoading ? null : (
    <>
      <Header />
      <Outlet />
    </>
  );
};

export default ProctedRoute;

AuthContext에서 정보를 읽어와서, 관리자가 아니라면 쫒아내는 간단명료한 로직입니다.

전역 데이터 관리를 위한 context

이전에도 언급했듯이, 본 프로젝트 에서는 auth 정보나, 현재 접근중인 강의에 대한 정보 등 많은 컴포넌트에서 구독하고 있는 정보들이 있습니다. 이를 관리하는것 용도로 react context를 사용하였고, redux-persistent와 같은 라이브러리들처럼 sessionstorage에 정보를 저장해, 새로고침 등의 상황에서도 일관성있게 전역 상태를 보존할 수 있도록 하였습니다.

import React, {
  createContext,
  useContext,
  ReactNode,
  useReducer,
  useEffect,
} from "react";
import { getUserInfo } from "~/API/user";

type AuthType = {
  userId: string;
  token: string;
  role: "student" | "professor";
  isAdmin: boolean;
  isRoleFetching: boolean;
  userName: string;
};

const AuthContext = createContext<AuthType | undefined>(undefined);

type AuthActionType =
  | {
      type: "UPDATE_DATA";
      payload: {
        userId: string;
        token: string;
        role?: "student" | "professor";
      };
    }
  | { type: "DELETE_DATA" }
  | {
      type: "__internal_fetch_complete";
      payload: {
        role: "student" | "professor";
        isAdmin: boolean;
        userName: string;
      };
    };

const AuthDispatchContext = createContext<
  React.Dispatch<AuthActionType> | undefined
>(undefined);

const AuthReducer = (state: AuthType, action: AuthActionType): AuthType => {
  switch (action.type) {
    case "UPDATE_DATA":
      return {
        ...state,
        ...action.payload,
        isRoleFetching: true,
      };
    case "DELETE_DATA":
      sessionStorage.clear();
      return {
        userId: "",
        token: "",
        role: "student",
        userName: "",
        isRoleFetching: false,
        isAdmin: false,
      };
    case "__internal_fetch_complete":
      return { ...state, ...action.payload, isRoleFetching: false };
    default:
      throw new Error(
        `Unhandled action type: ${(action as AuthActionType).type}`
      );
  }
};

type AuthProviderProps = {
  children: ReactNode;
};

export const AuthProvider = ({ children }: AuthProviderProps) => {
  const [state, dispatch] = useReducer(AuthReducer, {
    token: "",
    userId: "",
    role: "student",
    isRoleFetching: true,
    isAdmin: false,
    userName: "",
  });

  useEffect(() => {
    if (
      state.token.length === 0 &&
      sessionStorage.getItem("authToken") !== null &&
      sessionStorage.getItem("userId") !== null &&
      sessionStorage.getItem("role") !== null &&
      sessionStorage.getItem("authToken")!.length !== 0 &&
      sessionStorage.getItem("userId")!.length !== 0 &&
      sessionStorage.getItem("role")!.length !== 0
    ) {
      dispatch({
        type: "UPDATE_DATA",
        payload: {
          token: sessionStorage.getItem("authToken")!,
          userId: sessionStorage.getItem("userId")!,
          role: sessionStorage.getItem("role") as "student" | "professor",
        },
      });
    }
    if (state.token && state.userId && state.role) {
      sessionStorage.setItem("authToken", state.token);
      sessionStorage.setItem("userId", state.userId);
      sessionStorage.setItem("role", state.role);
    }
  }, [state.token, state.userId]);

  useEffect(() => {
    async function fetchRole() {
      const response = await getUserInfo(state.userId, state.token);
      dispatch({
        type: "__internal_fetch_complete",
        payload: {
          role: response.data.role as "student" | "professor",
          isAdmin: response.data.is_admin,
          userName: response.data.name,
        },
      });
    }
    if (state.token) fetchRole();
  }, [state.token]);

  return (
    <AuthDispatchContext.Provider value={dispatch}>
      <AuthContext.Provider value={state}>{children}</AuthContext.Provider>
    </AuthDispatchContext.Provider>
  );
};

export const useAuth = () => {
  const context = useContext(AuthContext);
  if (context === undefined) {
    throw new Error("useAuth must be used within an AuthProvider");
  }
  return context;
};

export const useAuthDispatch = () => {
  const context = useContext(AuthDispatchContext);
  if (context === undefined) {
    throw new Error("useAuthDispatch must be used within an AuthProvider");
  }
  return context;
};

Data를 제공하는 context와 dispatch를 담당하는 context를 분리함으로서, dispatch만 하는 컴포넌트가 dispatch시 재렌더링 되는 불필요한 상황을 예방하고자 하였습니다. 그리고 useEffect hook을 사용함으로서, 적절한 정보 갱신과 백업/복원 또한 가능하도록 구성하였습니다.

컴포넌트 설계

많은 서비스들은 공통적으로 쓰이는 컴포넌트들이 있습니다. 물론 모든것이 일반화 가능하다는 이야기는 하지 않겠습니다만, 그래도 어느정도는 일반화 해둔다면 유용한 부분들이 많습니다. 모든 컴포넌트의 코드를 이 지면에 싣는것은 그렇게 재밌는 일이 아니므로, 어떤 컴포넌트들을 만들었는지, 그리고 도전적이었던 일은 어떤것 이었는지를 중점으로 이야기 해보고자 합니다.

Aside

koj-aside

실습 풀이 화면에서 왼쪽을 차지하고 있는 aside입니다. 하위 항목을 가질수도, 접고 펴기가 가능할수도, 그리고 수정/삭제 등 관리 기능을 가져야 할 수도 있는점을 감안하여, 각각 종류에 맞는 Element들을 만들어서 named export를 하게 하였습니다.

CodeBlock

koj-codeblock

단순한 HTML textarea가 아니라, IDE에서 작업하는듯한 편안한 UX를 제공하고 싶었습니다. 많은 프로그래머들에게 익숙할 VSCODE의 텍스트 에디터 기능을 그대로 뽑아놓은 MS에서 배포한 monaco 라이브러리를 사용하여 Syntax Highlight와 보다 편리한 코드 편집 기능을 제공하기 위해 노력하였습니다.

CodeBlank

koj-codeblank

타 OJ와는 구별되는 기능이라고 감히 말할 수 있을것 같습니다. 코드에 빈칸을 설정하면서, Syntax Highlight까지 하기 위해서 참 많은 노력을 기울였습니다. CodeBlank에 관한 이야기로 포스트 하나를 쓸 수 있을 만큼 너무나도 많은 이야기가 담겨 있습니다. 렌더링을 위해서 어떤 코드가 들어있는지 궁금하신 분을 위해서 링크를 우선 하나 남겨놓는 것으로 이 지면은 줄이겠습니다… 향후에 KOJ 이야기 특별편을 적게되는 날이 온다면, 그 때 이 코드에 관해서 많은 이야기를 할 수 있지 싶네요…

koj-codeblank

많은 웹 서비스에서 볼 수 있는 헤더입니다. 권한별로 사용할 수 있는 메뉴들을 각기 다르게 렌더링 할 수 있습니다.

Input

koj-inputs

HTML input에 서비스의 일관된 룩앤필을 위해서 CSS를 얹인 친구들입니다. HTML input 뿐 아니라, searchInput이나 dropdown등 조금 더 specific한 광의의 입력 컴포넌트들도 들어 있습니다. 시스템 대화상자를 이용하거나 drag and drop을 이용해서 파일을 입력 할 수 있는 커스텀 파일 입력 컴포넌트도 만들었지요.

koj-modal

HTML의 <dialog>에 요구사항 명세에 맞는 것들을 추가해서 만든 모달입니다. 제목, 설명, 우측 상단의 종료 버튼 이라는 정해진 틀을 이용해서 다양한 모달이 많이 쓰이는 KOJ라는 서비스를 빠르게 개발 할 수 있게 해준 고마운 코드였습니다.

Table

koj-table koj-table

표라는 것은 웹 FE 개발을 하면서 언제나 머리를 아프게 하는것 같습니다. 보는 사람 입장에서는 편하지만, 만드는 사람 입장에서는 정말 신경쓸 것이 많습니다. 구글 검색만 해봐도 수많은 표 라이브러리들이 존재하는것을 보면 말이지요…

표준 HTML 태그에도 <table>이라는 물건이 존재하지만, <th><td>같은 요소들이 table-cell이나 table-row와 같은 자기들만의 CSS display 프로퍼티를 가지기 때문에, CSS flex 레이아웃을 기반으로 작업해왔던 저에게는 힘든 부분이 많았습니다. 그리고 위의 스크린샷에서 보듯 다양한 테이블 용례도 있었고요…

제가 선택한 해결책은 CSS grid 레이아웃 이었습니다. table 친구들과 달리 CSS flex 레이아웃과도 싸우지 않아서 레이아웃이 와장창 깨지는 일도 없었고요…

TreeView

koj-treeview

학기-강의-실습-문제-TC 라는 계층구조를 다루는 서비스인 만큼, 이러한 데이터들을 직관적으로 보여주기 위해서는 계층형 보기 컴포넌트가 필요했습니다. 위의 스크린샷은 교수가 사용할 수 있는 기능 중, 본인의 이전 강의의 실습을 불러오는 기능에서 실습을 선택할 수 있는 TreeView 입니다.

common

비즈니스 로직은 공유하지 않지만, 룩앤필은 동일한 친구들을 위해서 CSS 모듈을 모았습니다. form-module.css

마치며

이 글에서는 컴포넌트 설계를 어떻게 하고, 컴포넌트 폴더가 어떻게 구성되어 있는지에 대해 다루었습니다. 이것만으로도 충분히 많은 내용들을 다루었지만, 놀랍게도 논의할 주제들은 아직도 남아있습니다. 다음 글에서는 API 에러 핸들링을 어떻게 했는지, 그리고 에러가 발생하면 어떻게 사용자들에게 직관적으로 알렸는지에 대한 그런 이야기들을 하면 좋을 것 같네요. 끝까지 읽어주셔서 감사합니다.