엑셀 다운로드 리팩토링 이야기

정수현·2023년 10월 28일
4
post-thumbnail

서론

소개

프론트에서 엑셀 파일을 생성하고 파일을 다운로드할 수 있는 유틸함수를 만들어두었다.

당장 사용하는데는 문제가 없었지만 복잡한 요구사항이 들어올 것을 대비하기 위해 리팩토링을 진행했다.

리팩토링 이유

  • 기존 코드의 문제점

  • 유틸함수를 생성해서 재사용하긴 했지만 유연하게 사용할 수 없다는 점이 가장 큰 이유이다.

  • 반복되는 코드가 많다.

    const makeTitle = (title: string) => {
      const titleWithStyle = [
        {
          v: title,
          t: 's',
          s: {
            font: {
              bold: true,
              sz: '16',
            },
            border: excelBorder,
            fill: {
              patternType: 'solid',
              fgColor: { rgb: 'ffc9daf8' },
            },
            alignment: {
              horizontal: 'center',
              vertical: 'center',
            },
          },
        },
      ];
      return titleWithStyle;
    };
    const makeThead = (headerArr: string[]) => {
      const headerWithStyle = headerArr.map((item: string[]) => {
        return {
          v: item,
          t: 's',
          s: {
            font: {
              bold: true,
              sz: '12',
            },
            border: excelBorder,
            fill: {
              patternType: 'solid',
              fgColor: { rgb: 'ffcbcbcb' },
            },
            alignment: {
              horizontal: 'center',
              vertical: 'center',
              wrapText: true,
            },
          },
        };
      });
      return headerWithStyle;
    };
    const makeTr = (rowArr: any) => {
      const row = rowArr.map((item: any) => {
        return {
          v: item,
          t: isNumber(item) ? 'n' : 's',
          s: {
            font: {
              sz: '11',
            },
            border: excelBorder,
            alignment: {
              horizontal: 'center',
              vertical: 'center',
              wrapText: true,
            },
          },
        };
      });
      return row;
    };
    • 스타일 속성이 조금씩 다르지만 비슷한 코드들이 반복되고 있다.
    • 유연하지 못하다는 것은 다음과 같은 사례들로 구체화할 수 있다.
      • 셀 병합같은 로직이나 시트를 여러 개 만들어야 할 때는 기존에 만들었던 유틸함수를 사용할 수 없어 그 때마다 새로운 함수를 만들어 주어야 했다.
      • 또한 셀의 스타일을 인자를 받아 동적으로 생성할 수 없다는 점도 유연하지 못함의 한 특징이다.
        • 위의 코드를 보면 Title, Thead, Tr의 style이 모두 고정되어있어 유동적으로 변경할 수 없다.

결국은 유틸 함수의 재사용성이 떨어진다는 것이었다.


본론

요구사항

  • 가독성
  • 유연성
  • 적합한 디자인 패턴 적용
  • 사용성
  • 기타

요구사항 구체화

  • 가독성

    • 코드를 읽는 사람이 주석없이 보았을 때에도 흐름을 따라 이해할 수 있어야 한다.
  • 유연성

    • 하나의 파일에 시트를 원하는 개수만큼 추가할 수 있어야 한다.
    • 컴포넌트 내에서 유틸 함수를 사용하여 엑셀 파일을 만드는 부분에서 동적으로 셀의 스타일을 커스텀할 수 있어야 한다.
    • 마찬가지로 유틸함수를 사용하는 부분에서 원하는 부분에 대해 동적으로 셀 병합이 가능해야 한다.
  • 적합한 디자인 패턴 적용

    • 디자인 패턴들에 대해 이해하고 상황에 알맞는 디자인 패턴을 선택하여 사용할 수 있어야 한다.
  • 사용성

    • 평소 주로 사용하는 라이브러리의 사용방식과 유사해야 한다.
    • 사용하는 부분에서 불필요한 코드를 줄인다.
  • 기타

    • 반복되는 코드를 없앤다. (cell object style 만 다른 부분)
    • cell object의 스타일을 넣는 부분에서 any 타입을 제거

과정

적합한 디자인 패턴 적용

가장 먼저 어떤 디자인 패턴을 적용할지를 생각해보았다.
얼마전에 스터디에서 여러 자바스크립트의 디자인 패턴에 대해 공부했기 때문에 적절한 패턴을 선택해서 적용하고 싶다는 생각을 했다.

그래서 공부했던 내용을 펼쳐놓고 기존 코드를 보면서 어떤 것이 적합한지 선택하려고 했다. 그리고 나는 패턴에 대해 아는 것과, 코드나 상황을 보고 어떤 패턴이 맞을지 선택하는 것은 다른 레벨의 문제이며 아직은 내가 어떤 것이 적절한지 판단할 수준이 아니라고 생각하게 되었다.

그래서 전체적인 로직에 대해 생각하기 보다 로직을 Divide and Conquer 해야겠다고 생각했다. ㅋㅋ

그래서 가장 작은 문제부터! 엑셀의 한 을 만드는 것부터 생각해보기로 했다. 라이브러리에 정의된 하나의 셀의 인터페이스는 다음과 같다.

/** Worksheet Cell Object */
export interface CellObject {
  /** The raw value of the cell.  Can be omitted if a formula is specified */
  v?: string | number | boolean | Date;

  /** Formatted text (if applicable) */
  w?: string;

  /**
   * The Excel Data Type of the cell.
   * b Boolean, n Number, e Error, s String, d Date, z Empty
   */
  t: 'b' | 'n' | 'e' | 's' | 'd' | 'z';

  /** Cell formula (if applicable) */
  f?: string;

  /** Range of enclosing array if formula is array formula (if applicable) */
  F?: string;

  /** Rich text encoding (if applicable) */
  r?: any;

  /** HTML rendering of the rich text (if applicable) */
  h?: string;

  /** Comments associated with the cell */
  c?: Comments;

  /** Number format string associated with the cell (if requested) */
  z?: string | number;

  /** Cell hyperlink object (.Target holds link, .tooltip is tooltip) */
  l?: {
    /** Target of the link (HREF) */
    Target: string;
    /** Plaintext tooltip to display when mouse is over cell */
    Tooltip?: string;
  };

  /** The style/theme of the cell (if applicable) */
  s?: any;
}

이 인터페이스를 보니 복잡한 객체들을 단계별로 생성할 수 있도록 하는 디자인 패턴인 빌더 패턴이 생각났다.

그래서 빌더 패턴을 사용하여 셀 객체를 만들기로 결정했다.

Cell Builder

class CellBuilder {
  private style: CellObjectType['s'];
  private value: CellObjectType['v'];
  private type: CellObjectType['t'];

  constructor(value: CellObjectType['v']) {
    this.value = value;
    this.type = this.setType(value).type;
    this.style = {
      alignment: {
        horizontal: 'center',
        vertical: 'center',
      },
      border: {
        top: { style: 'thin', color: { rgb: 'FF000000' } },
        bottom: { style: 'thin', color: { rgb: 'FF000000' } },
        left: { style: 'thin', color: { rgb: 'FF000000' } },
        right: { style: 'thin', color: { rgb: 'FF000000' } },
      },
      font: {
        color: { rgb: 'FF000000' },
        sz: '10',
      },
    };
  }
  private setType(value: CellObjectType['v']) {
    if (typeof value === 'boolean') {
      this.type = 'b';
    } else if (value instanceof Error) {
      this.type = 'e';
    } else if (typeof value === 'number') {
      this.type = 'n';
    } else if (value instanceof Date) {
      this.type = 'd';
    } else if (typeof value === 'string') {
      this.type = 's';
    }
    return this;
  }
  setAlignMentVertical(vertical: CellStyleType['alignment']['vertical']) {
    this.style.alignment.vertical = vertical;
    return this;
  }
  setAlignMentHorizontal(horizontal: CellStyleType['alignment']['horizontal']) {
    this.style.alignment.horizontal = horizontal;
    return this;
  }
  setAlignMentWrapText(
    wrapText: CellStyleType['alignment']['wrapText'] = true
  ) {
    this.style.alignment.wrapText = wrapText;
    return this;
  }
  setAlignMentTextRotation(
    textRotation: CellStyleType['alignment']['textRotation']
  ) {
    this.style.alignment.textRotation = textRotation;
    return this;
  }
  setBorder(border: CellStyleType['border'] | null) {
    this.style.border = border === null ? {} : border;
    return this;
  }
  setBackgroundColor(color: ColorType['rgb']) {
    this.style.fill = {
      patternType: 'solid',
      fgColor: { rgb: color },
    };
    return this;
  }
  setFontBold(bold: CellStyleType['font']['bold'] = true) {
    this.style.font.bold = bold;
    return this;
  }
  setFontColor(color: ColorType) {
    this.style.font.color = color;
    return this;
  }
  setFontItalic(italic: CellStyleType['font']['italic'] = true) {
    this.style.font.italic = italic;
    return this;
  }
  setFontStrike(strike: CellStyleType['font']['strike'] = true) {
    this.style.font.strike = strike;
    return this;
  }
  setFontSize(size: number) {
    this.style.font.sz = size.toString();
    return this;
  }
  setFontUnderline(underline: CellStyleType['font']['underline'] = true) {
    this.style.font.underline = underline;
    return this;
  }
  setNumberFormat(numFmt: CellStyleType['numFmt']) {
    this.style.numFmt = numFmt;
    return this;
  }
  build(): CellObjectType {
    return {
      v: this.value,
      t: this.type,
      s: this.style,
    };
  }
}

Cell Builder로 객체 생성

const sheet1 = new SheetBuilder('시트 1').appendCustomRow(
  // 스타일 커스텀
  data.map((item) =>
    new CellBuilder(item)
      .setFontSize(20)
      .setFontItalic()
      .setBackgroundColor('FF0000')
      .build()
  )
);

가독성

  • [BEFORE] 기존코드는 step을 따라가기 위해 주석이 필요했었다.

    const exportExcel = ({
      SHEET_NAME,
      THEAD,
      TBODY,
      TITLE,
      FILE_NAME,
    }: Props) => {
      // STEP 1: 새로운 workbook을 만든다
      const WB = XLSX.utils.book_new();
    
      const WB_FILE_NAME = FILE_NAME || TITLE || SHEET_NAME || '제목없음';
      const WS_TITLE = TITLE || SHEET_NAME || FILE_NAME || '제목없음';
      const WS_SHEET_NAME = SHEET_NAME || TITLE || FILE_NAME || '제목없음';
    
      // STEP 2: 새로운 worksheet를 만든다
      const WS = XLSX.utils.aoa_to_sheet([
        makeTitle(WS_TITLE),
        makeThead(THEAD),
        ...makeTbody(TBODY),
      ]);
    
      // STEP 2-1: worksheet의 첫 번째 행에 제목을 작성한다. (셀 병합)
      WS['!merges'] = [
        XLSX.utils.decode_range(
          `${EXCEL_COLUMN[0]}1:${EXCEL_COLUMN[THEAD.length - 1]}1`
        ),
      ];
      // STEP 3: worksheet를 workbook에 추가한다.
      XLSX.utils.book_append_sheet(WB, WS, WS_SHEET_NAME);
    
      // STEP 4: .xlsx file로 export
      XLSX.writeFile(WB, WB_FILE_NAME + '.xlsx');
    };
  • [AFTER] 주석없이도 직관적으로 동작을 파악할 수 있게 되었다.

    excel.append(sheet1).append(sheet2).download();

유연성

하나의 엑셀 파일에 원하는 만큼 시트를 추가할 수 있고, 셀 병합이 가능하다.

const excel = new FileBuilder('파일명.xlsx');

const sheet1 = new SheetBuilder('시트 1')
  .appendThead(['접수번호', '이름', '이메일', '연락처', '부서'])
  .appendCustomRow(
    // 스타일 커스텀
    data.map((item) =>
      new CellBuilder(item).setFontSize(20).setFontItalic().build()
    )
  )
  .mergeCell([0, 48], [10, 48]); // 셀 병합 [x0, y0], [x1, y1]

const sheet2 = new SheetBuilder('시트 2')
  .appendThead(['접수번호', '이름', '이메일', '연락처', '부서'])
  .appendTbody(data.map((item) => item));

excel.append(sheet1).append(sheet2).download(); // 원하는 만큼 시트 추가

사용성

셀에 대한 스타일을 지정하는 Cell Builder에서 스타일을 주고 싶은 메소드만 호출하여 셀 객체를 생성하고 있는데, boolean 타입의 매개변수의 기본값을 true로 설정하여 불필요한 코드 작성을 줄일 수 있도록 함

// ...
setFontBold(bold: CellStyleType['font']['bold'] = true) {
  this.style.font.bold = bold;
  return this;
}
setFontItalic(italic: CellStyleType['font']['italic'] = true) {
  this.style.font.italic = italic;
  return this;
}
setFontStrike(strike: CellStyleType['font']['strike'] = true) {
  this.style.font.strike = strike;
  return this;
}
// ...
const sheet1 = new SheetBuilder('시트 1')
  .appendCustomRow(
    // 스타일 커스텀
    data.map((item) =>
      new CellBuilder(item)
-        .setFontBold(true)
-        .setFontItalic(true)
-        .setFontStrike(true)
+        .setFontBold()
+        .setFontItalic()
+        .setFontStrike()
        .build()
    )
  )

기타

위에서 보았던 라이브러리에서 정의된 CellObject의 style 부분인 s는 any 타입으로 정의되어 있다. 이를 정확한 타입을 지정해주어 자동완성을 통해 안정적으로 스타일을 지정할 수 있도록 했다.

라이브러리 인터페이스

/** Worksheet Cell Object */
export interface CellObject {
  /** The raw value of the cell.  Can be omitted if a formula is specified */
  v?: string | number | boolean | Date;

  // ....
  t: 'b' | 'n' | 'e' | 's' | 'd' | 'z';

  /** Cell formula (if applicable) */
  f?: string;

  // ...

  /** The style/theme of the cell (if applicable) */
  s?: any; // ✅ any 타입이다
}

s 타입 지정

import type { CellObject } from 'xlsx-js-style';

interface CellObjectType extends CellObject {
  v: CellObject['v'];
  t: CellObject['t'];
  s: CellStyleType;
}

type CellStyleType = {
  alignment: {
    vertical?: 'center' | 'top' | 'bottom'; // 수직 정렬
    horizontal?: 'center' | 'left' | 'right'; // 수평 정렬
    wrapText?: boolean; // - 줄바꿈
    textRotation?: number; // - 텍스트 회전 0 to 180, or 255 --180 is rotated down 180 degrees, 255 is special, aligned vertically
  };
  border: {
    top?: { style: BorderStyleType; color: ColorType };
    bottom?: { style: BorderStyleType; color: ColorType };
    left?: { style: BorderStyleType; color: ColorType };
    right?: { style: BorderStyleType; color: ColorType };
    diagonal?: {
      style: BorderStyleType;
      color: ColorType;
      diagonalUp: boolean;
      diagonalDown: boolean;
    };
  };
  fill?: {
    patternType?: 'none' | 'solid';
    fgColor?: ColorType; // foreground color 전경색
    bgColor?: ColorType; // background color 배경색
  };
  font: {
    bold?: boolean;
    color?: ColorType;
    italic?: boolean;
    name?: string;
    strike?: boolean; // 취소선
    outline?: boolean;
    sz?: string;
    underline?: boolean;
    vertAlign?: 'subscript' | 'superscript';
  };
  numFmt?: string;
};

type BorderStyleType =
  | 'dashDotDot'
  | 'dashDot'
  | 'dashed'
  | 'dotted'
  | 'hair'
  | 'mediumDashDotDot'
  | 'mediumDashDot'
  | 'mediumDashed'
  | 'medium'
  | 'slantDashDot'
  | 'thick'
  | 'thin';

type ColorType = {
  rgb?: string;
  theme?: number;
  tint?: number;
};

타입을 명시해줌으로써 에디터에서 자동완성을 사용할 수 있게 되었다!

결론

3줄 요약

  • 디자인 패턴을 적용해보았다.
  • 복잡한 요구사항에 유연하게 대응할 수 있게 되었다.
  • 뿌듯하다.

전체 코드

index.ts

import XLSX from 'xlsx-js-style';

import type { CellObjectType, CellStyleType, ColorType } from './types';
import type { SheetAOAOpts } from 'xlsx-js-style';

class CellBuilder {
  private style: CellObjectType['s'];
  private value: CellObjectType['v'];
  private type: CellObjectType['t'];

  constructor(value: CellObjectType['v']) {
    this.value = value;
    this.type = this.setType(value).type;
    this.style = {
      alignment: {
        horizontal: 'center',
        vertical: 'center',
      },
      border: {
        top: { style: 'thin', color: { rgb: 'FF000000' } },
        bottom: { style: 'thin', color: { rgb: 'FF000000' } },
        left: { style: 'thin', color: { rgb: 'FF000000' } },
        right: { style: 'thin', color: { rgb: 'FF000000' } },
      },
      font: {
        color: { rgb: 'FF000000' },
        sz: '10',
      },
    };
  }
  private setType(value: CellObjectType['v']) {
    if (typeof value === 'boolean') {
      this.type = 'b';
    } else if (value instanceof Error) {
      this.type = 'e';
    } else if (typeof value === 'number') {
      this.type = 'n';
    } else if (value instanceof Date) {
      this.type = 'd';
    } else if (typeof value === 'string') {
      this.type = 's';
    }
    return this;
  }
  setAlignMentVertical(vertical: CellStyleType['alignment']['vertical']) {
    this.style.alignment.vertical = vertical;
    return this;
  }
  setAlignMentHorizontal(horizontal: CellStyleType['alignment']['horizontal']) {
    this.style.alignment.horizontal = horizontal;
    return this;
  }
  setAlignMentWrapText(
    wrapText: CellStyleType['alignment']['wrapText'] = true
  ) {
    this.style.alignment.wrapText = wrapText;
    return this;
  }
  setAlignMentTextRotation(
    textRotation: CellStyleType['alignment']['textRotation']
  ) {
    this.style.alignment.textRotation = textRotation;
    return this;
  }
  setBorder(border: CellStyleType['border'] | null) {
    this.style.border = border === null ? {} : border;
    return this;
  }
  setBackgroundColor(color: ColorType['rgb']) {
    this.style.fill = {
      patternType: 'solid',
      fgColor: { rgb: color },
    };
    return this;
  }
  setFontBold(bold: CellStyleType['font']['bold'] = true) {
    this.style.font.bold = bold;
    return this;
  }
  setFontColor(color: ColorType) {
    this.style.font.color = color;
    return this;
  }
  setFontItalic(italic: CellStyleType['font']['italic'] = true) {
    this.style.font.italic = italic;
    return this;
  }
  setFontStrike(strike: CellStyleType['font']['strike'] = true) {
    this.style.font.strike = strike;
    return this;
  }
  setFontSize(size: number) {
    this.style.font.sz = size.toString();
    return this;
  }
  setFontUnderline(underline: CellStyleType['font']['underline'] = true) {
    this.style.font.underline = underline;
    return this;
  }
  setNumberFormat(numFmt: CellStyleType['numFmt']) {
    this.style.numFmt = numFmt;
    return this;
  }
  build(): CellObjectType {
    return {
      v: this.value,
      t: this.type,
      s: this.style,
    };
  }
}
class SheetBuilder {
  private worksheet: XLSX.WorkSheet;
  private sheetName: string;

  constructor(sheetName: string) {
    this.worksheet = XLSX.utils.aoa_to_sheet([]);
    this.sheetName = sheetName;
  }
  appendThead(
    theadArr: string[],
    option: SheetAOAOpts = { origin: this.worksheet['A1'] ? -1 : 'A1' }
  ) {
    const thead = theadArr.map((item) =>
      new CellBuilder(item)
        .setFontBold()
        .setFontSize(12)
        .setBackgroundColor('ffcbcbcb')
        .setAlignMentWrapText()
        .build()
    );
    XLSX.utils.sheet_add_aoa(this.worksheet, [thead], option);
    return this;
  }
  appendRow(
    tRowArr: CellObjectType['v'][],
    option: SheetAOAOpts = { origin: this.worksheet['A1'] ? -1 : 'A1' }
  ) {
    const tRow = tRowArr.map((item) =>
      new CellBuilder(item).setAlignMentWrapText().build()
    );
    XLSX.utils.sheet_add_aoa(this.worksheet, [tRow], option);
    return this;
  }
  appendTbody(
    tbodyArr: CellObjectType['v'][][],
    option: SheetAOAOpts = { origin: this.worksheet['A1'] ? -1 : 'A1' }
  ) {
    const tbody = tbodyArr.map((item) => {
      return item.map((value: any) => {
        return new CellBuilder(value).setAlignMentWrapText().build();
      });
    });
    XLSX.utils.sheet_add_aoa(this.worksheet, [...tbody], option);
    return this;
  }

  appendCustomRow(
    row: CellObjectType[],
    option: SheetAOAOpts = { origin: this.worksheet['A1'] ? -1 : 'A1' }
  ) {
    XLSX.utils.sheet_add_aoa(this.worksheet, [row], option);
    return this;
  }
  mergeCell(start: number[], end: number[]) {
    const EXCEL_COLUMN: string[] = Array(26)
      .fill(0)
      .map((_, index) => String.fromCharCode(index + 65))
      .concat(
        Array(26)
          .fill(0)
          .map((_, index) => 'A' + String.fromCharCode(index + 65))
      );

    this.worksheet['!merges'] = [
      XLSX.utils.decode_range(
        `${EXCEL_COLUMN[start[0]]}${start[1]}:${EXCEL_COLUMN[end[0]]}${end[1]}`
      ),
    ];
    return this;
  }
  getWorkSheet(): XLSX.WorkSheet {
    return this.worksheet;
  }
  getSheetName(): string {
    return this.sheetName;
  }
}
class FileBuilder {
  private workbook: XLSX.WorkBook;
  private fileName: string;

  constructor(fileName: string) {
    this.workbook = XLSX.utils.book_new();
    this.fileName = fileName;
  }

  addSheet(worksheet: XLSX.WorkSheet) {
    XLSX.utils.book_append_sheet(
      this.workbook,
      worksheet.getWorkSheet(),
      worksheet.getSheetName()
    );
    return this;
  }
  download() {
    if (this.workbook.SheetNames.length === 0) {
      throw new Error('Sheet is empty');
    }
    XLSX.writeFile(this.workbook, `${this.fileName}.xlsx`);
  }
}

export { SheetBuilder, CellBuilder, FileBuilder };

types.ts

import type { CellObject } from 'xlsx-js-style';

interface CellObjectType extends CellObject {
  v: CellObject['v'];
  t: CellObject['t'];
  s: CellStyleType;
}

type CellStyleType = {
  alignment: {
    vertical?: 'center' | 'top' | 'bottom'; // 수직 정렬
    horizontal?: 'center' | 'left' | 'right'; // 수평 정렬
    wrapText?: boolean; // - 줄바꿈
    textRotation?: number; // - 텍스트 회전 0 to 180, or 255 --180 is rotated down 180 degrees, 255 is special, aligned vertically
  };
  border: {
    top?: { style: BorderStyleType; color: ColorType };
    bottom?: { style: BorderStyleType; color: ColorType };
    left?: { style: BorderStyleType; color: ColorType };
    right?: { style: BorderStyleType; color: ColorType };
    diagonal?: {
      style: BorderStyleType;
      color: ColorType;
      diagonalUp: boolean;
      diagonalDown: boolean;
    };
  };
  fill?: {
    patternType?: 'none' | 'solid';
    fgColor?: ColorType; // foreground color 전경색
    bgColor?: ColorType; // background color 배경색
  };
  font: {
    bold?: boolean;
    color?: ColorType;
    italic?: boolean;
    name?: string;
    strike?: boolean; // 취소선
    outline?: boolean;
    sz?: string;
    underline?: boolean;
    vertAlign?: 'subscript' | 'superscript';
  };
  numFmt?: string;
};

type BorderStyleType =
  | 'dashDotDot'
  | 'dashDot'
  | 'dashed'
  | 'dotted'
  | 'hair'
  | 'mediumDashDotDot'
  | 'mediumDashDot'
  | 'mediumDashed'
  | 'medium'
  | 'slantDashDot'
  | 'thick'
  | 'thin';

type ColorType = {
  rgb?: string;
  theme?: number;
  tint?: number;
};

export type { CellObjectType, CellStyleType, ColorType };

더 고민해볼 점

  • table tag로 넘겼을 때 다운로드
  • 반대로 sheet_to_html ()

0개의 댓글