ㅇ.. 엑셀이요..?

GeeU·2021년 12월 22일
1

회사에서 6개월 간 구르며 한 일을 크게 두 가지로 나눠보자면, 이상형 월드컵 과 엑셀 -> PDF 변환 이다.

시간 순서상으론 이상형 월드컵이 먼저이지만 대부분을 이상형 월드컵에 시간을 사용했으니 이상형 월드컵을 시작하기 전에 엑셀 -> PDF 부터 기록해 머리 속에서 지워두고 이상형 월드컵 기록을 하려한다.

서비스 설명

x개의 주문 정보가 담겨있는 엑셀 파일을 올리면, 10cm * 15cm 크기로 출력할 수 있는 x장의 pdf 모음으로 변환해달라 였다. 정확히는 그런게 가능한 엑셀 매크로를 아느냐 그러길래 잘 모르기도 하고 이상형 월드컵 쪽이 잠시 한적하기도 했으니 만들어 드리겠습니다 하고 시작했다.

엑셀의 형식은 이렇게 생겼었다.

구현

우선은 엑셀 파일을 다루기 위해 관련 라이브러리를 리서칭 해본 결과 두 가지 라이브러리가 나왔다.

  1. SheetJS
  2. ExcelJS

결론적으론 SheetJS 를 사용했다. 이유는 엑셀 속 텍스트만 따올 수 있으면 됐기 때문이다.
구현을 위해 떠올린 방식은 이렇다.

  1. PDF 틀을 직접 html 로 그려둔다.
  2. 엑셀을 업로드하면 행의 길이만큼 html 을 그려 미리보기를 제공한다.
  3. Download 버튼을 누르면 html2canvas 와 jsPDF 를 이용해 PDF 파일을 생성한다.

굳이 스타일 기능을 사용할 필요도 없으니 무료버전 SheetJS 로 충분했다.
그리고 SheetJS 가 2번을 하기에 적합하여 선택하게 된 것 같다.
코드로 비교하자면

ExcelJS

async loadExcelFile(filePath) {
	const sheetData = [] 
 
	const workbook = new ExcelJS.Workbook()
	await workbook.xlsx.readFile(filePath)
	const worksheet = workbook.worksheets[0]
    
	const options = { includeEmpty: true }
 
	await worksheet.eachRow(options, (row, rowNum) => {
		sheetData[rowNum] = []
		row.eachCell(options, (cell, cellNum) => {
			sheetData[rowNum][cellNum] = { value:cell.value, style:cell.style }
		})
	})
 
	console.log(sheetData)
}

SheetJS

const readExcel = (event) => {
  const file = event.target.files[0];
  const reader = new FileReader();

  reader.readAsBinaryString(file);

  reader.onload = (e) => {
    const fileData = e.target.result;
    const workbook = XLSX.read(fileData, { type: "binary" });
    const targetSheet = workbook.Sheets[SHEET_NAME];
    
    // 각 행의 정보가 JSON의 배열로 변환됨.
    const rows = XLSX.utils.sheet_to_json(targetSheet);
    
    // rows
//  [
//    {
//      "LWB": 98369151579,
//      "Order Number": 12818,
//      "Consignee Name": "customer name 1",
//      "Block": "customer address Block 1",
//      "Street": "customer address Street 1",
//      "House": "customer address House 1",
//      "City": "customer address City 1",
//      "Consignee Tel No": 29105810,
//      "Actual Weight": 0.92,
//      "PCS": 3,
//      "COD Amount": 6
//    }
//    { ... }, { ... }
//  ]
  };
};

ExcelJS 예제 코드는 여기서 참고했다.
어떤 식으로 나오는지 기억이... 아무튼 귀찮았던 기억은 있다 ㅋㅋ... 기록의 중요성을 다시금 느끼게되는 순간

SheetJS 로 돌아와서 저런식으로 받아온 rows 에 반복을 돌려 각각 html을 그리는 component 에 넘겨주고 그린 html 들을 나열해 보여준다.

App.jsx

const App = () => {
  const [invoices, setInvoices] = useAtom(invoicesAtom);
  const [, setInvoiceIds] = useAtom(invoiceIdsAtom);

  const readExcel = (event) => {
    const file = event.target.files[0];
    const reader = new FileReader();

    reader.readAsBinaryString(file);

    reader.onload = (e) => {
      const fileData = e.target.result;
      const workbook = read(fileData, { type: "binary" });
      const targetSheet = workbook.Sheets[TARGET_SHEET_NAMES.KUWAIT];
      const rows = sheet_to_json(targetSheet);

      let tempInvoices = [];
      let tempInvoiceIds = [];

      rows.forEach(row => {
        const lwb = row[LWB];
        const orderNumber = row[ORDER_NUMBER];
        const pcs = row[PCS];
        const name = row[NAME];
        const block = row[BLOCK];
        const street = row[STREET];
        const house = row[HOUSE];
        const city = row[CITY];
        const phone = row[PHONE];
        const productDescription = row[PRODUCT_DESCRIPTION];

        const barcodeCanvas = document.createElement('canvas');
        JsBarcode(barcodeCanvas, lwb, { format: "CODE128" });
        const barcodeUrl = barcodeCanvas.toDataURL('image/png');
        
        // pcs 는 한 행에 몇 장의 PDF 가 필요한지 의미한다.
        for (let pc = 1; pc <= pcs; pc++) {
          const id = `${lwb}_${orderNumber}_${pc}_${pcs}_${name}_${block}_${street}_${house}_${city}_${phone}_${productDescription}`;

          const invoice = (
            <KuwaitInvoiceForm
              key={uuid()}
              lwb={lwb}
              orderNumber={orderNumber}
              pc={pc}
              pcs={pcs}
              barcodeUrl={barcodeUrl}
              name={name}
              block={block}
              street={street}
              house={house}
              city={city}
              phone={phone}
              productDescription={productDescription}
              id={id}
            />
          );

          tempInvoices.push(invoice);
          tempInvoiceIds.push(id);
        };
      });

      setInvoices(tempInvoices);
      setInvoiceIds(tempInvoiceIds);
    };
  };

  return (
    <>
      <input type="file" onChange={readExcel} />
      <CreatePDFButton />
      {invoices}
    </>
  );
}

디자인 없이 일단 기능구현만 했다.
useAtom 은 jotai 라는 상태 관리 라이브러리에서 사용하는 놈인데 jotai 에 관해선 추후 다른 글에서 설명할 예정이니 일단 useState 의 전역 버전이라고 생각하고 넘어가면 된다.

이건 간단하게 짚고 넘어갈 해프닝인데, reader.onload 에 대해 제대로 이해를 못하고 있었다. 파일을 다 읽는데 성공하면 실행하는건 알겠는데 뭔가 코드 동작 순서가 이상하다..? 얘는 비동기적으로 실행되는건가...? 싶다가,
아, 파일을 읽는 동안 코드를 onload 에서 멈췄다가 지금 작성하고 있는 함수를 실행하는게 아니라 지금 코드로 작성하는 onload 는 그저 함수를 등록하는 과정인 것뿐이구나! 라는걸 깨달았다... 이런 멍청한... ㅋㅋㅋㅋ

invoices 는 알겠는데 invoiceIds 는 어디에 쓰이는가? 하면 CreatePDFButton 에서 사용된다.

CreatePDFButton.jsx

const CreatePDFButton = () => {
  const [invoiceIds] = useAtom(invoiceIdsAtom);

  // 요기!
  const createPDF = async () => {
    let invoicePngs = [];
    await Promise.all(invoiceIds.map(async (invoiceId, i) => {
      const invoiceHtml = document.getElementById(invoiceId);
      const invoiceCanvas = await html2canvas(invoiceHtml);
      const invoicePng = invoiceCanvas.toDataURL('image/png');

      invoicePngs[i] = invoicePng;
    }));

    const doc = new jsPDF(
    	KUWAIT_INVOICE_ORIENTATION,
        KUWAIT_INVOICE_UNIT,
        [KUWAIT_INVOICE_WIDTH, KUWAIT_INVOICE_HEIGHT],
        true
    );
    
    invoicePngs.forEach((invoicePng, i) => {
      doc.addImage(
      	invoicePng, "PNG",
        0, 0, KUWAIT_INVOICE_WIDTH, KUWAIT_INVOICE_HEIGHT,
        '', 'FAST'
      );

      if (i < invoicePngs.length - 1) {
      	doc.addPage([KUWAIT_INVOICE_WIDTH, KUWAIT_INVOICE_HEIGHT], "p");
      };
    });

    doc.save("KuwaitInvoice.pdf");
  };

  return (
    <button onClick={() => createPDF()}>PDF 다운로드</button>
  );
};

위에서부터 순서대로 보자.
우선 html2canvas를 살짝 뜯어보면 인자로 HTMLElement 를 받는다는 것을 알 수 있다.

declare const html2canvas: (element: HTMLElement, options?: Partial<Options>) => Promise<HTMLCanvasElement>;

음... 근데 현재 렌더링에 사용되고 있는 invoices 는 엄밀히 따지자면 화면에 보이고 있는 HTML 과 다른 놈일 것이다. 화면에 보이고 있는 놈은 HTMLElement 가 맞지만, state 인 invoices 는 ReactDOM.render() 로 렌더링 되기 전인 React Element 의 배열일테니 html2canvas 의 인자로 넘겨줄 수 없다.

그래서 각각에 id를 부여하고 getElementById 로 HTMLElement 를 받아와 html2canvas 에 넘겨주는 식으로 해결했다.
사실 이 방법 이 외에 invoices 를 이용하여 해결해보려 했으나 React Element 를 HTMLElement 로 변환 하는 방법을 ReactDOM.render()에 넘겨주는 것 외에는 떠오르지 않아 이렇게 해결했다. (혹시 다른 방법 있다면 댓글 부탁드립니다)

html2canvas 에 관한 다른 얘기도 있는데 그건 이상형 월드컵에서 기록하도록 하겠다. Promise.all 얘기도 따로 나름 Promise 를 끄적여보는 글을 쓸 때 얘기하겠다.

jsPDF 얘기를 하자면 이 곳 을 많이 참고 했다.
jsPDF 의 인자 중 마지막 인자는 compressPdf 인데 기본은 false 고 true를 넘기면 생성된 PDF 를 압축해준다. 처음엔 없었다가 너무 느리고 용량이 크다는 소리를 듣고 추가해줬다. 느린건 그대로였으나 용량은 압축이 되었다.
속도와 관련된 건 밑의 addImage 였다. 마지막 인자는 'NONE', 'FAST', 'MEDIUM', 'SLOW' 만이 들어갈 수 있고, 위에서 compressPdf 에 true 를 안 주면 NONE(하지만 여기서 FAST 를 주면 상관 없긴하다), 주어지면 기본 값으론 SLOW 가 주어지는 걸 실험을 통해 확인했다. (딱히 defalut 가 적혀져 있지 않았기에...)
NONE 일땐 느리기도 하지만 용량이 어마어마해진다. SLOW, MEDIUM, FAST 는 사실상 용량 차이는 별로 없지만 속도가 확실히 차이가 나기에 FAST 를 주어 속도까지 해결했다.

마무리

좋았던 점

뭔가 두려웠던 엑셀 과 PDF 를 간단하게 나마 다뤄본 게 의외로 재밌었다. 다음에 비슷한 걸 맡으면 더 맘 편히 할 수 있지 않을까 생각해본다.
무엇보다 다른 팀에서 수작업으로 진행하고 있어서 주문량이 많은 날은 엄청난 생산성 저하가 일어났었는데 팀의 생산성 향상에 기여한 느낌이 들어 기분이 좋았다.

아쉬웠던 점 & 그 외

좀 더 깔끔한 방법이 있지 않았을까..? 싶기도 하고, ExcelJS 도 좀 더 다뤄보고 싶었다.
그리고 사실 무엇보다 지금 정리중인 이 글이 너무 지저분한거 같고, 설명이 조잡한 것 같다.

앞으로 더 써보면서 나아지길 비는 수 밖에... ㅋㅋㅋㅋ

profile
개발자라 불러도 될진 모르겠지만 아무튼 응애 개발자

0개의 댓글