[AssetManager] 리액트에서 에셋 상수관리를 간편하게!
취미로 하는 개발

[AssetManager] 리액트에서 에셋 상수관리를 간편하게!

몸이 근질근질 거리는 여름.

남는 시간에 미리미리 생산성을 올려줄 수 있는 툴을 만들 생각으로 옆자리 동료분께 여쭤봤다.

 

"혹시 지금 프로젝트에서 제일 반복, 단순 노동인 부분이 어디일까요?"

"음... 이미지 에셋 import 후 상수 관리하는 부분이요!"

Ah....

나 역시 기존 프로젝트에서 이미지 에셋을 관리하는데에 적잖은 노력을 들였던 부분이 생각났다.

 

좋아. 오늘은 이 반복노동을 줄일 수 있는 툴을 만드는데 시간을 보내보자고 마음을 먹었다.

 

먼저 목표를 정했다.

  1. import -> 네이밍 -> export의 번거로운 과정 없이 에셋 폴더 기준 자동 상수 정의가 되게 하자.
  2. 에셋의 이름은 파일명 및 폴더명의 조합으로 만들어준다. 폴더구조와 파일명에 신경쓰면 그게 곧 해당 에셋의 이름이 된다!
    (ex. assets/commmon/svgs/down.svg --> COMMON_SVGS_DOWN)
  3. 에셋의 변화에 따라 자동으로 최신화되고, 개발자가 첫 세팅 이후에는 딱히 신경을 안 써도 되게끔 하자.
    관리 소요를 줄이려고 만드는 건데 오히려 관리 포인트가 늘어나면 안 되니깐.

이름은 Asset Manager로 정하고 폴더를 만들어 줬다.

먼저 나에게는 특정 폴더에 있는 파일들의 목록을 읽는 기능이 필요했다. 에셋 감지를 위해서.

 

찾아보니 노드에 있는 fs(파일시스템) 모듈에 readdir 메서드가 있었다.

바로 사용해보자.

const fs = require("fs");
const path = require("path");

const pathName = path.resolve(process.cwd(), "src/assets/images/");

fs.readdir(pathName,(err,list)=>{
  console.log(list)
})

오... 순조롭다. 파일명을 가져왔으니 전체 작업의 1/3은 해낸 셈이다.

이제 스코프를 하나 올려서 assets 폴더 자체를 읽어보자.

 

내가 구상하는 프로그램은 여러 depth로 구성된 폴더구조에서도 무리없이 작동해야 하기 때문에, 파일 목록에서 폴더를 읽은 경우에는 해당 폴더 안에 접근해서 다시 한번 파일 목록을 읽는 기능을 재귀적으로 구현해야 한다.

 

폴더인지 아닌지 확인하는 방법은 여러가지가 있지만, 나는 쉽게 쉽게 확장자 여부를 확인하고 확장자가 없으면 폴더로 간주하기로 했다.

그리고 mac의 경우에는 앞이 .으로 시작하는 숨김 폴더가 있기 때문에 해당 케이스도 제외해야 한다.

 

그런데 재귀적으로 모든 파일을 탐색하고, 그 결과를 하나의 결과물로 리턴하기 위해서는 현재의 readdir 메서드의 콜백으로는 뭔가 여의치 않았다.

 

찾아보니 util.promisify를 통해 readdir 메소드를 promise 형태로 사용할 수 있었다.

const fs = require("fs");
const util = require("util");
const path = require("path");

const readdir = util.promisify(fs.readdir);
const pathName = path.resolve(process.cwd(), "src/assets/images/");

readdir(pathName).then((list)=>{
  console.log(list)
})

프로미스 형태로 변환된 readdir 메소드. 

 

앞서 말한 요구조건들을 반영해서, 파일 목록을 가져오는 함수를 만들어보자.

async function getFileList(pathname, prefix) {
  // pathname 경로에 위치한 파일 목록을 읽어온다.
  // depth가 깊어지면 앞에 prefix가 계속 붙는다.
  const fileNames = await readdir(
    prefix ? `${pathname}/${prefix.join("/")}` : pathname
  );
  
  return Promise.all(
  
    fileNames.map((name) => {
      if (name.indexOf(".") === -1) {
        // 찾은 파일이 폴더인 경우 재귀호출을 통해 해당 폴더를 탐색한다. prefix에 폴더명 추가.
        return getFileList(pathname, [...prefix, name]);
      }
      
      if (name.indexOf(".") !== 0) {
        // 찾은 파일이 확장자가 있는 경우(폴더가 아닌 경우)
        let CONSTANTS_NAME = name.toUpperCase().split(".")[0];
        // 확장자를 떼자.
        let filePath = name;
        if (prefix.length > 0) {
          // prefix가 있다면 _ 로 구분해서 앞에 붙여준다.
          CONSTANTS_NAME = `${prefix
            .join("_")
            .toUpperCase()}_${CONSTANTS_NAME}`;
            
          // prefix가 있다면 경로에 더해준다.
          filePath = `${prefix.join("/")}/${name}`;
        }
        // name, filePath 값을 반환한다.
        return new Promise(function (resolve) {
          resolve({ name: CONSTANTS_NAME, filePath });
        });
      }
      
      // 숨김파일 / 폴더인 경우에는 null을 반환한다.
      return new Promise(function (resolve) {
        resolve(null);
      });
    })
  );
}

 

실행을 해보면 다음과 같은 결과물이 나온다.

 

몇 가지 수정을 해줘야 하는 부분이 보이는데,

우선 파일명에 언더바를 제외한 특수문자들을 제거해줘야 한다.

실제 호출시에 변수명으로 사용할 수 없는 문자열이기 때문.

그리고 배열 구조로 return 해주고 있어 사용이 어렵다.

평탄화된 object 형태로 바꿔줘야 사용이 쉽겠지.

 

그리고 이렇게 return 된 값을 json 파일로 만들어서 로컬에 저장하고, 타입 추론은 그 json파일을 통해서 해야겠다는 생각이 들었다.

에셋의 상수명과 경로를 json 파일로 만들면 나중에 명세를 보기도 편할 것 같고...

 

async function updateFileInfoJson(basePath) {
  if (basePath) {
  
    const fileInfoObj = {
      assetsBasePath: basePath,
      files: {},
    };
    
    const pathName = path.resolve(process.cwd(), basePath);
    
    try {
      const fileList = await getFileList(pathName, []);
      
      fileList
        .flat(Infinity)
        .filter(Boolean)
        .map(({ name, filePath }) => {
          fileInfoObj.files[name] = filePath;
        });
        
      const savePath = path.resolve(__dirname, "fileInfo.json");
      
      fs.writeFileSync(savePath, JSON.stringify(fileInfoObj));
      
      console.log("UPDATED ASSETFILE\n", fileInfoObj);
    } catch (e) {
      console.error(e);
    }
  }
}

fileInfoObj라는 객체 안에 위치한 files에 아까 받아온 파일 배열을 key - value 형태로 넣는다.

작업을 위한 전처리로 배열을 평탄화하는 flat, 숨김 파일/폴더 제거를 위한 filter(Boolean)이 사용됐다.

 

이제 동일 경로에 fileInfo.json 파일이 생성되었다.

{
  "assetsBasePath": "src/assets",
  "files": {
    "IMAGES_ICON_24_CLEAR_BLACK_3X": "images/icon-24-clear-black@3x.png",
    "IMAGES_JUMBOTRON": "images/jumbotron.png",
    "IMAGES_LOGIN_IMG": "images/login_img.png",
    "IMAGES_LOGO": "images/logo.png",
    "IMAGES_SELECTCHECK": "images/selectCheck.png",
    "IMAGES_SELECTORARROWDOWN": "images/selectorArrowDown.png",
    "SVGS_CARPET_LOGO": "svgs/carpet_logo.svg",
    "SVGS_CARPET_LOGO2": "svgs/carpet_logo2.svg",
    "SVGS_CARPET_LOGO_WHITE": "svgs/carpet_logo_white.svg",
    "SVGS_DESCRIPTION_ARROW": "svgs/description-arrow.svg",
    "SVGS_EXCEL_BTN": "svgs/excel-btn.svg",
    "SVGS_EXCEL": "svgs/excel.svg",
    "SVGS_FILTER_RESET": "svgs/filter-reset.svg"
  }
}

아... 깔끔하다.

 

이제 우리한테 필요한 에셋의 상수명, 에셋을 import 할 경로까지 다 마련이 되었다.

 

fs.watch를 사용해 에셋 폴더를 감시하고, 변경사항이 생기면 그때그때 업데이트를 해주자. 그리고 에셋 경로도 인자로 받을 수 있게 처리한다.

...

const assetDir = process.argv.slice(2)[0];

async function init(dir) {
  try {
    await updateFileInfoJson(dir);
    fs.watch(
      assetDir,
      {
        recursive: true,
      },
      (eventType, fileName) => {
        console.log("--- AssetManager -> Detect Asset File Changes ---");
        console.info("fileName:", fileName);
        updateFileInfoJson(dir);
      }
    );
  } catch (e) {
    console.error(e);
  }
}

init(assetDir);

 

정말로 사용하는 일만 남았다!

 

asset 사용을 위한 파일을 만들어준다.

import fileInfo from "./fileInfo.json";

type Asset<S> = {
  [key in keyof S]: string;
};

function getResource() {
  const fileMap: Partial<Asset<typeof fileInfo.files>> = {};
  const { assetsBasePath, files } = fileInfo;
  const basePath = assetsBasePath.replace("src/", "");
  Object.keys(files).map((fileName: string) => {
    // @ts-ignore
    fileMap[fileName] = require(`../${basePath}/${files[fileName]}`);
  });
  return fileMap;
}

export const assets = getResource();

fileInfo.json을 읽어 파일의 키값을 뽑아내고, require를 통해 import해온다.

이제 사용시에는 assets을 참조하여 assets.LOGO 등의 방식으로 사용하면 간. 편. 하. 다.

 

실 사용 사례를 보자.

크... 늘 느끼지만 개발하면서 가장 짜릿한 순간은 나와 주변 동료들의 불편함을 해결하는 솔루션을 내 손으로 만들어서 도움을 주는 순간인 것 같다.

 

CTO님에게 보여드렸더니, 훌륭하다는 코멘트와 함께 내가 놓친 부분도 포인트로 짚어주셔서 감사한 피드백을 받았다.

 

부족한 실력으로 만든 툴이지만, 혹시 한 번쯤 사용하고 싶다는 생각이 드신다면 아래의 링크를 참조하면 된다.

 

피드백이나 조언은 언제든 무제한 환영!

 

https://github.com/JongHakSeo-slogup/AssetManager

 

GitHub - JongHakSeo-slogup/AssetManager: Asset Management Tool

Asset Management Tool. Contribute to JongHakSeo-slogup/AssetManager development by creating an account on GitHub.

github.com

 

2022.05.03 기준 위 프로젝트는 아래 패키지로 관리되고 있습니다.

 

 

eima

Easy to Import Multiple Assets. Latest version: 0.2.12, last published: 5 months ago. Start using eima in your project by running `npm i eima`. There are no other projects in the npm registry using eima.

www.npmjs.com