프로젝트 회고/엑셀 다운로드, 업로드 공통 모듈 개발

2. 개발 - 레이아웃, 엑셀 다운로드

정현우12 2022. 8. 26. 23:34

레이아웃

엑셀 업로드나 다운로드 기능만 따로 사용하고 싶을 수 있기 때문에 enable{Uploader or Downlaoder}를 통해 한 가지 기능만 사용할 수 있게 했다.
업로드를 위해 전체 다운로드 요청을 보내는 함수, 데이터 type, 생성을 위한 함수 등을 prop으로 받아와서 사용했다.

export default function ExcelHandler({type, createParams, createParamsOrder, updateList, getFullListParams, restrictionMsg="", uploaderTitle=null, enableUploader = true, enableDownloader = true }) {
    return (
        <>
              {enableUploader && <ExcelUploadButton
                //...생략
            />}
            {enableDownloader && <ExcelDownloadButton
                // ...생략
            />}
        </>
    )
}

다운로드

전체 흐름

  1. 전체 데이터 조회
  2. 데이터를 엑셀에 넣고 싶은 속성만 골라서 파싱
  3. 엑셀 워크시트 생성
  4. 워크시트에 데이터 넣기
  5. 워크 북에 워크시트 넣고 워크북 다운로드

xlsx의 훌륭한 메소드들로 엑셀 파일에 데이터 넣기, 다운로드를 너무 쉽게 할 수 있었다.

const excelDownload = async () => {
          // 전체 데이터 조회
        const response = await getFullList[type](...getFullListParams)
        let fullList = await response.result.data.elements
        // 데이터 파싱
        fullList = dtosToJson(type, fullList)
        // 엑셀 워크 시트 생성
        const ws = XLSX.utils.aoa_to_sheet([
            [...sheetColumnNames[type], '등록일'],
        ])
        // 워크시트에 데이터 넣기
        fullList.forEach(item => {
            XLSX.utils.sheet_add_aoa(
                ws,
                [
                    [
                        ...dtoColumnNames[type].map(columnName => item[columnName]),
                        item["createdAt"]
                    ]
                ],
                {origin: -1}
            )    
        })
        // 워크시트 셀 크기 설정
        ws['!cols'] = (function(){
            let result = []
            for(let i = 0; i < sheetColumnNames[type].length+1; i++) result.push({ wpx: 150})
            return result
        })()

        // 워크북에 워크시트 넣기
        const wb = XLSX.utils.book_new()
        XLSX.utils.book_append_sheet(wb,ws,'목록')

          // 엑셀 파일 다운로드
        XLSX.writeFile(wb, `${type}_LIST_${getNowDateTime()}.xlsx` )
    }

데이터 파싱하기

하지만, 데이터들을 파싱하는 과정이 만만치 않았다.

데이터들을 파싱하는 함수의 초기 형태는 다음과 같았다.

export const dtosToJson = (type, dtos) => {
    let json = dtos
    switch (type) {
        case "NAMED_ENTITY":
            json.forEach(entity => {
                // 시스템 객체인 경우
                if (entity.key.substr(0,3) === "sys") {
                    entity.systemType = "SYSTEM"
                }
                // values 파싱
                let values = entity.values.map(e => e.value).join(",")
                entity.values = value

            })          
            break
        case "FAQ":
            json.forEach(entity => {
                entity.example = entity.sentences.map(sen => sen.text).join(", ")
                entity.return = entity.faqNode[0].bubbleList[0].texts[0].message
            })
            break
        default:
            break
           //..생략
    }
    return json    
}

이런 식으로 타입별로 파싱하는 함수 동작을 따로 만들었다. 이렇다보니 코드 양이 너무 길고 중복되고 뭔가 억지로 하나로 합쳐놓은듯한 모양이었다.

그래서 추후에 리팩토링을 했다.

export const dtoColumnNames = {
    NAMED_ENTITY: ["name" , "key", "dataType", "entityMin", "entityMax", "values.value", "systemType"],
      //..생략
}
// dtoColumnName에 해당하는 dto 값 구하기
const getDtoVal = (columnNameParsed=[], dto={}, idx = 0) => {
    console.log(dto,columnNameParsed[idx])
    if(idx === columnNameParsed.length) return dto
    else if(idx > columnNameParsed.length) return null

    const key = columnNameParsed[idx]
    if(Array.isArray(dto)) {
            // 배열 전체를 원하는 경우
            // ',' 로 join
            return dto.map(el => getDtoVal(columnNameParsed, el[key], idx+1)).join(',')
    } else if(typeof(dto) === "object") {
        if(key.includes("[")) {
            // val 배열의 특정 요소를 인덱스로 접근
            const idxStart =  key.indexOf("[")
            const idxEnd = key.indexOf("]")
            const targetIndex = parseInt(key.slice(idxStart+1, idxEnd))
            const targetName = key.slice(0,idxStart)
            return getDtoVal(columnNameParsed, dto[targetName][targetIndex], idx+1)
        } else return getDtoVal(columnNameParsed, dto[key], idx+1)
    } else {
        if(idx === columnNameParsed.length -1) return dto
        else return null
    }
}

export const dtosToJson = (type, dtos) => {
    let json = []
    for(let dto of dtos) {
        let object = {}
        for(let columnName of [...dtoColumnNames[type], "createdAt"]) {
            object[columnName] = getDtoVal(columnName.split("."), dto)
        }
        json.push(object)
    }
    return json    
}

원래는 속성이름을 'values.value'로 주지 않고 'value'로 주고 얘를 찾는 로직을 dtosToJson 에 따로 넣어줬다.
그것들을 통일해서 찾는 속성이름을 "a.b.c[0]" 이런식으로 줘서 getDtoVal 함수로 찾도록 했다.

getDtoVal은 재귀적으로 값을 찾는 함수다.

  1. .split(".")으로 key들을 배열에 담아놓는다.
  2. key를 순회하면서 값을 찾는다.
    1) 배열이면 다음 타겟에 대한 getDtoVal()을 재귀적으로 호출하고 그 리턴값들을 ","으로 join해서 최종 값을 가져온다.
    2) 객체이면 getDtoVal(obj[key]) 역시 재귀적으로 호출해 값을 찾는다.
    3) 1.에서 만든 배열의 마지막 원소이면서 string이나 number (나머지는 2가지 경우 뿐)이면 얘네를 리턴, 아니면 null을 리턴하게 했다.

사실 코드 양은 더 늘었다. 하지만 추후에 다른 데이터에 엑셀 다운로드 로직을 추가하면 할 수록 훨씬 편하고 간단하게 추가할 수 있고 상대적으로 코드 양도 훨씬 적게 된다.

배운 점

다운로드 로직을 만들면서 사실 다운로드 로직 자체는 진짜 쉬웠다. 하지만 여러 곳에서 공통으로 사용하는 모듈을 만들 때 고려해야할 것들이 엄청 많았다.
그래서 유지 보수를 많이 했는데, 하다보니 선언형 코딩의 장점을 알게 되었다. 동작을 쭉 기술한 함수들은 일일이 어디서 어떻게 값이 바뀌는지를 일일이 찍어보면서 디버깅을 해야해서 어렵다. 그러나 선언형으로 해놓으면 동작부가 의미별로 나눠져서 디버깅하기가 수월했다.