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

KOJ의 FE 외부 의존성. 왜 사용했고, 어떻게 사용했는가

Featured image

들어가며

KOJ 이야기도 벌써 3편이네요. 기획과 컴포넌트 작성에 대한 대략적인 이야기를 했다면, 이번 시간에는 “바퀴의 재발명”을 피하기 위한 라이브러리 채용에 대해서 이야기 해볼까 합니다.

“When there are good libraries, you should be using it.” - Ralph Johnson

이 프로젝트에 쓰인 monaco 텍스트 에디터의 개발진으로도 알려지고, 유명 저서 ‘디자인 패턴’의 저자 랄프 존슨선생님께서 하신 말씀입니다. 물론, 해당 인터뷰는 프론트엔드 개발에 관한 이야기는 아니지만, 좋은 라이브러리가 있으면 써야한다는 이야기를 저명성이 있는 분의 어록을 통해서 인용하고 싶었습니다.

이번 포스트는 실제 코드를 작성하는데에 직접적인 도움이 될 수 있는 주제인만큼 분류를 frontend로 잡아봤습니다. 단순한 라이브러리 소개가 아닌, 해당 라이브러리를 어떻게 통합했는지에 대한 이야기도 할거거든요…

사용한 외부 의존성

우선 리스트부터 공유하겠습니다. 이전 아티클에서 다루었던 친구들은 제하고 말이죠…

관련이 있는 것들을 묶어서 최대한 짜임새 있게 설명해 보겠습니다.

@monaco-editor/react

monaco 에디터는 이전에도 설명했듯이, vscode에서 쓰이는 에디터이기 때문에, 웹 서비스에서 코드를 바로 작성하더라도, 편리한 코드 편집 이라는 사용자 경험 향상에 도움이 됩니다. 단순 textarea와는 다르게, 신택스 하이라이팅을 지원 할 뿐만 아니라, vscode 단축키라고 구글 검색하면 나오는 이러한 글들을 보면 상당히 많은 기능들을 지원합니다. ctrl(cmd) + d로 같은 단어를 선택한다거나, 하는 그러한 기능들은 정말로 생산성 향상에 도움이 되죠.

그런데 이 monaco 에디터를 원본 그대로 리액트 프로젝트에 통합하는것은 꽤나 번거로운 일입니다. vite나 webpack 같은 번들러를 위한 설정도 해줘야 하고… 이런것을 누가 대신 해주면 참 좋겠다 하는 생각을 충분히 할 수 있다고 생각합니다. 그런 번거로운 설정을 대신 해놓은 라이브러리라서 채용하게 되었습니다.

용례

프로그래밍 OJ 이니만큼, 코드를 보여주는 화면은 반드시 존재하게 됩니다. 그러한 컴포넌트에 공통적으로 적용할 수 있게, CodeBlock컴포넌트로 예쁘게 말아놨습니다. 저 기능들 말고는 딱히 쓸 일도 없어서, 쓸 기능만 남겨두고 나머지는 숨겨놓는것도 나쁘지 않더라고요. 이 프로젝트 내에서만 쓰이는 라이브러리니까, 범용성을 조금 희생해서 직관성을 확보 하였습니다.

파일 다루는 라이브러리

KOJ에는 코드를 직접 타이핑 해서 제출 할 수도 있지만, 파일을 제출하는 유즈케이스 역시 존재합니다. 그리고 사용자의 관리의 용이성을 위해서 파일을 다루는 유즈케이스 또한 있죠. 크게 두 분류로 나뉩니다.

코드를 다루기 위해서

코드는 기본적으로 텍스트 파일입니다. 텍스트 파일은 문자를 저장하고, 문자를 저장하기 위한 규칙을 우리는 “인코딩” 이라고 부르죠.

여기까지는 문제 될 것이 없습니다. 문제는 이 프로젝트의 주 사용처인 경북대학교는 1학년 전공 수업인 프로그래밍 기초 수업을 진행 할 때, C언어를 가르치고, 그 때 사용하는 IDE가 Visual Studio 라는 것입니다…

이 물건 자체가 큰 문제는 아니지만, 어째서인지 인코딩을 전세계 표준인 utf-8로 하지 않고, 구시대의 유물인 EUC-KR을 확장한 cp949로 한다는 것입니다… (VSCODE는 utf-8이 기본인데… 어째서…) KOJ의 서버 환경은 텍스트를 utf-8로 해석하기 때문에, 이것을 가만히 두면 한글이 포함된 코드가 와장창 해버리는 일이 발생합니다.

아래 사진은 vs에서 작성된 c 코드 입니다. vs에서 작성된 한글이 포함된 코드

하지만 이를 OJ에 그대로 제출하면 한글이 깨져서 정상적인 결과를 받아 볼 수 없습니다. OJ에서 깨져보이는 코드

백엔드 개발을 하고 있는 친구의 맥북에서도 깨져 보이는군요… (It doesn’t work on my machine) 맥에서 깨진것이 확인된 코드

그러기 위해서 문자 인코딩을 감지해서, 이게 utf-8이 아니면(적어도 경북대 수업 내에서는) cp949인코딩일 테니까, 바꿔줍시다… 짧으니 코드 전문을 첨부합니다.

import { Buffer } from "buffer";
import iconv from "iconv-lite";
import isUtf8 from "isutf8";

export async function cp949ToUTF8(file: File): Promise<File> {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();

    reader.onload = async () => {
      const result = reader.result as ArrayBuffer;
      const buf = Buffer.from(result);
      if (isUtf8(buf)) {
        resolve(file);
      }
      const decoded = iconv.decode(buf, "cp949");
      resolve(new File([decoded], file.name, { type: file.type }));
    };

    reader.onerror = () => {
      reject(reader.error);
    };
    reader.readAsArrayBuffer(file);
  });
}

보면 Buffer, iconv-lite, isutf8 라이브러리가 쓰이고 있습니다. iconv-lite는 입력으로 Node의 buffer를 받는데, 이것의 인터페이스가 브라우저 사이드 js에서 이진 데이터를 처리할 때 사용하는 ArrayBuffer와 일치하지 않습니다… 이를 맞춰주기 위해서 buffer라이브러리를 사용하면, iconv-lite와 isutf8 라이브러리를 문제없이 사용할 수 있습니다.

기타 파일을 다루기 위해서

코드를 처리하는 것도 중요하지만, 사용자 정보 입력이나, 내 제출물 일괄 다운로드 등, 기타 파일을 다루는 용례 또한 있습니다. xlsx 확장자로 대표되는 스프레드 시트 파일을 파싱하는데에는 기존 연구실 프로젝트에서는 xlsx를 사용하였으나, 더이상 유지보수 관리가 되고 있지 않고, 보안 이슈가 있다고 하여 ExcelJS를 사용하였습니다. 용례는 xlsx.ts에서 확인할 수 있습니다.

파일을 읽는것이 끝이라면 여기서 끝이지만, 파일을 정리하거나 생성해서 사용자에게 다운로드 할 수 있는 인터페이스를 제공할 용례 또한 있습니다. 이를 위해서, file-saver, jszip을 사용하였습니다. 여기에서 약간의 이슈사항이 있었는데, file-save는 esmodule이 아니라서 named import 가 되지 않았기 때문에 아래와 같이 사용했어야 했다는 점 정도인것 같습니다.

import pkg from "file-saver";
const { saveAs } = pkg;

react-hot-toast

서비스를 이용하면서 알림창을 보는 일은 상당히 잦은 일입니다. 성공적으로 완료되었다거나, 처리중이라거나, 아니면 특정한 오류가 있어서 실패했다거나 하는 사용자에게 정보를 고지하기 위한 용도로 많이 사용되죠. 사용자의 UI 상호작용을 블록하는 alert와는 달리, 정보만 알려주고 몇초 뒤에 사라지는 toast를 도입하는것은 꽤나 좋은 선택이라고 생각합니다. 그리고 아까 말했던 랄프 존슨 선생님의 말대로, 좋은 라이브러리가 있으면 가져다 쓰면 참 좋습니다.

react-hot-toast 라는 라이브러리는 참 많은 것을 지원해줍니다. 성공/에러/promise 핸들링… 그리고 보이는것도 꽤나 나쁘지 않게 생겼지요.

용례(API 핸들링)

react-hot-toast의 단락에 적었지만, 이것은 react-hot-toast의 용례 뿐만 아니라, API 호출과 에러 핸들링의 추상화 라는 내용도 담고 있어서 여기 적어도 될련지 모르겠네요… 그래도 react-hot-toast의 promise 핸들링 덕분에 사용할 수 있었으니 여기에 적겠습니다.

API 호출은 성공 여부가 불확실한 비동기 작업입니다. 그렇기 때문에 실패하면 사용자에게 알려 줄 필요가 있겠지요… 처음에 저는 API 호출 래퍼에서 직접 toast를 호출했습니다.

export async function setSemester(
  semester: number,
  token: string
): Promise<EmptyResponse> {
  const response = await fetch(`${API_SERVER_URL}/semester`, {
    method: "PUT",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${token}`,
    },
    body: JSON.stringify({ semester }),
  });

  switch (response.status) {
    case 400:
      toast.error("JWT토큰이 없거나 입력값 검증 실패");
      break;
    case 401:
      handle401();
      break;
    case 403:
      toast.error("관리자만 접근할 수 있는 API 입니다");
      break;
  }
  if (response.status === 204) {
    return {};
  }
  return await response.json();
}

그런데 이 방식은, 뭔가 책임 분리가 제대로 되어 있지 않다고 느꼈습니다. 분명히 API 핸들링을 해야할 유틸 함수에서 렌더링..? 그래서 아래와 같이 에러 처리를 수정하였습니다.

import { API_SERVER_URL } from "~/util/constant";
import { handle401 } from "~/util";
import { EmptyResponse } from "~/types/APIResponse";
import { BadRequestError, ForbiddenError } from "~/util/errors";

export async function setSemester(
  semester: number,
  token: string
): Promise<EmptyResponse> {
  const response = await fetch(`${API_SERVER_URL}/semester`, {
    method: "PUT",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${token}`,
    },
    body: JSON.stringify({ semester }),
  });

  switch (response.status) {
    case 400:
      throw new BadRequestError("JWT토큰이 없거나 입력값 검증 실패");
      break;
    case 401:
      handle401();
      break;
    case 403:
      throw new ForbiddenError("관리자만 접근할 수 있는 API 입니다");
      break;
  }
  if (response.status === 204) {
    return {};
  }
  return await response.json();
}

그리고 각각 에러의 정의는 아래와 같습니다.

class HttpError extends Error {
  statusCode: number;
  responseMessage: string;

  constructor(
    message: string,
    statusCode: number,
    responseMessage: string = ""
  ) {
    super(message);
    this.statusCode = statusCode;
    this.responseMessage = responseMessage || message;
    this.name = this.constructor.name;
  }
}

export class BadRequestError extends HttpError {
  constructor(responseMessage: string = "") {
    super("Bad Request", 400, responseMessage);
  }
}

export class UnauthorizedError extends HttpError {
  constructor(responseMessage: string = "") {
    super("Unauthorized", 401, responseMessage);
  }
}

export class ForbiddenError extends HttpError {
  constructor(responseMessage: string = "") {
    super("Forbidden", 403, responseMessage);
  }
}

export class NotFoundError extends HttpError {
  constructor(responseMessage: string = "") {
    super("Not Found", 404, responseMessage);
  }
}

export class ConflictError extends HttpError {
  constructor(responseMessage: string = "") {
    super("Conflict", 409, responseMessage);
  }
}

export class RequestTooLongError extends HttpError {
  constructor(responseMessage: string = "") {
    super("Request Too Long", 413, responseMessage);
  }
}

export class InternalServerError extends HttpError {
  constructor(responseMessage: string = "") {
    super("Internal Server Error", 500, responseMessage);
  }
}

그리고 이렇게 발생한 에러를 react-hot-toast를 통해서 받아오죠.

await toast.promise(changePassword(userId, token, old_password, new_password), {
  loading: "변경중...",
  success: "성공적으로 변경되었습니다!",
  error: (err) => `Error: ${err.message} - ${err.responseMessage}`,
});

이렇게 하면, 화면에 보이는 뷰는 온전히 뷰를 렌더링 하는 컴포넌트에서만 관리 하고, API 래퍼는 뷰에 간섭하지 않는 좋은 구조가 되서 코드가 더욱 직관적이 되었습니다!

prism.js

이건 codehole 이라는 용례 때문에 사용했던 라이브러리 입니다. syntax highlighting을 유지하면서 코드의 빈칸을 예쁘게 보여줘야 하는데, syntax highlighting을 해주는 라이브러리가 필요했던 것이죠. 그리고 syntax highlight의 결과로 className이 적용된 DOM을 가공해서 빈칸을 예쁘게 뚫었습니다.

용례

말했듯이 codehole 하나를 위해서 차용된 라이브러리 입니다. 아마 코드에 빈칸 뚫는 라이브러리는 없을거여서, 제가 직접 파싱하고 온갖 난리를 겪었던것 같습니다.

codehole.ts 그리고 CodeBlank/index.tsx가 사실상 유일한 용례입니다… 이것들에 관해서는 나중에 진짜 제대로 각잡고 글을 써봐야 겠네요…

마치며

“진짜 제대로 한 프로젝트” KOJ의 FE 개발을 하면서 사용한 라이브러리와 어떻게 통합했는지에 대한 이야기를 해보았습니다. 다음 글에는 CI/CD 등의 개발 환경 설정에 대해서 이야기를 해볼까 합니다. 끝까지 읽어주셔서 감사합니다.