프론트에서 엑셀 파일을 생성하고 파일을 다운로드할 수 있는 유틸함수를 만들어두었다.
당장 사용하는데는 문제가 없었지만 복잡한 요구사항이 들어올 것을 대비하기 위해 리팩토링을 진행했다.
기존 코드의 문제점
유틸함수를 생성해서 재사용하긴 했지만 유연하게 사용할 수 없다는 점이 가장 큰 이유이다.
반복되는 코드가 많다.
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;
};
결국은 유틸 함수의 재사용성이 떨어진다는 것이었다.
가독성
유연성
적합한 디자인 패턴 적용
사용성
기타
가장 먼저 어떤 디자인 패턴을 적용할지를 생각해보았다.
얼마전에 스터디에서 여러 자바스크립트의 디자인 패턴에 대해 공부했기 때문에 적절한 패턴을 선택해서 적용하고 싶다는 생각을 했다.
그래서 공부했던 내용을 펼쳐놓고 기존 코드를 보면서 어떤 것이 적합한지 선택하려고 했다. 그리고 나는 패턴에 대해 아는 것과, 코드나 상황을 보고 어떤 패턴이 맞을지 선택하는 것은 다른 레벨의 문제이며 아직은 내가 어떤 것이 적절한지 판단할 수준이 아니라고 생각하게 되었다.
그래서 전체적인 로직에 대해 생각하기 보다 로직을 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;
};
타입을 명시해줌으로써 에디터에서 자동완성을 사용할 수 있게 되었다!
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 };