[BE] xlsx 파일을 json으로 변환해서 S3에 저장하기

홍종훈·2024년 1월 17일
0

BE

목록 보기
9/9
post-thumbnail

기존엔 데이터 디자이너님께서 엑셀파일에서 매크로를 통해 매 시트마다 json파일을 뽑던 작업을

xlsx파일만 올리면 서버에서 json으로 변환 후 S3버킷에 업로드 하는 기능을 추가하게 됐다.

1. 먼저 Multer를 통해 엑셀 데이터를 서버에서 받는다.

@Post('upload')
    @ApiOperation({ summary: 'Upload Game Data Json File' })
    @UseInterceptors(FilesInterceptor('files'))
    async upload(
        @Body('serverType') serverType: string,
        @Body('dataVersion') dataVersion: string,
        @Body('clientKeyVersion') clientKeyVersion: string,
        @Body('clientKey') dataDoor: string,
        @Body('clientSecret') dataHive: string,
        @UploadedFiles(new ParseFilePipe())
        files: Express.Multer.File[]
    ) {
        await this.dataService.dataSave(
            serverType,
            dataVersion,
            clientKeyVersion,
            clientKey,
            clientSecret,
            files
        );
    }

2. Data서비스 로직에서 S3서비스의 uploadFile메서드를 호출한다.

 async dataSave(
        serverType: string,
        dataVersion: string,
        clientKeyVersion: string,
        clientKey: string,
        clientSecret: string,
        files: Express.Multer.File[]
    ) {
        // AWS S3 에 저장
        const awsFileKey = `${serverType}/${dataVersion}`;
        const updatedFiles = await this.s3Service.uploadFile(serverType, dataVersion, files);

3. S3서비스에서 서버타입, 데이터 버전, xlsx파일을 전송받아 json형식으로 변환한다.

이때 수정된 파일만 S3에 업로드 후 Slack 웹훅을 통해 알려야 하므로 수정된 파일을 return 받고 data service에 return한다.

async uploadFile(serverType: string, dataVersion: string, files: Express.Multer.File[]) {
        let updatedFiles = [];

        try {
            for (const file of files) {
                const fileUpdatedPaths = await this.convertExcelToJsonAndSave(
                    `${this.uploadFolderPath}/${file.filename}`,
                    dataVersion
                );

                updatedFiles = updatedFiles.concat(fileUpdatedPaths);

                await this.uploadConvertedFiles(serverType, dataVersion, updatedFiles);
                await this.uploadConvertedFiles(serverType, dataVersion, updatedFiles);
            }

            this.invalidateCache(serverType, dataVersion);
            this.logger.log(`Successfully uploaded to S3`);

            return updatedFiles;
        } catch (err) {
            this.logger.error('Error', err);
        }
    }

4. 엑셀파일을 json으로 변환한다.

4-1. 맨 뒤에 #이 붙어있는 시트는 제외한다.

4-2. 각 시트의 row 수를 구한다. 기존에 사용하던 json과 같은 형식으로 파일을 만들어야 하므로 엑셀의 매크로인 VBA코드를 참고했다.

4-3. 엑셀파일의 5번째 줄엔 server, client, all 이 적혀있다. server는 서버에만 필요한 json파일, client는 클라이언트에만 필요한 json파일, all은 둘 다에게 필요한 json파일이다.

4-4. 엑셀파일의 6번째 줄엔 각 value의 타입이 적혀있다. 타입에 맞추어 json파일을 생성한다. 이때 주의해야 할 점은 int이면서 null일 경우 json은 null로, string이면서 null일 경우 json은 '' 빈 문자열로 변환해주어야 한다.

4-5. 생성된 파일들을 돌면서 기존의 데이터와 다른지 확인하기 위해 JSON.stringify를 사용했다. 컴퓨터가 생성하는 파일은 항상 같은 형식이므로 조금이라도 데이터가 바뀐다면 fileChanged가 true를 반환할 것이다.

4-6. 파일이 변했다면 updatedFile에 넣고 처음에 생성했던 serverData배열과 clientData배열에 있는 값들로 json파일을 만든다.

async convertExcelToJsonAndSave(filePath, dataVersion) {
        const workbook = xlsx.readFile(filePath);
        const updatedFiles = [];

        workbook.SheetNames.forEach(sheetName => {
            if (sheetName.endsWith('#')) {
                return;
            }

            const worksheet = workbook.Sheets[sheetName];
            const rawData: any = xlsx.utils.sheet_to_json(worksheet, { header: 1 });

            const headers = rawData[6];
            const types = rawData[5];
            const fileTypes = rawData[4];
            const startLine = 7;

            let actualRowCount = 0;
            for (let i = startLine; i < rawData.length; i++) {
                if (rawData[i].some(cell => cell !== null && cell !== undefined)) {
                    actualRowCount++;
                } else {
                    break;
                }
            }

            const serverData = [],
                clientData = [];
            rawData.slice(startLine, startLine + actualRowCount).forEach(row => {
                const serverRow = {},
                    clientRow = {};
                const indexColumnExists = headers.includes('index');
                headers.forEach((header, index) => {
                    if (types[index] !== 'ignore') {
                        let value = row[index];
                        if (value !== undefined) {
                            if (types[index] === 'int') {
                                value = parseInt(value);
                            } else if (types[index] === 'string') {
                                value = String(value);
                            }
                        } else {
                            value = types[index] === 'string' ? '' : null;
                        }
                        if (fileTypes[index] === 'server' || fileTypes[index] === 'all') {
                            serverRow[header] = value;
                        }
                        if (fileTypes[index] === 'client' || fileTypes[index] === 'all') {
                            clientRow[header] = value;
                        }
                    }
                });

                if (!indexColumnExists || (indexColumnExists && serverRow['index'] != null)) {
                    if (Object.keys(serverRow).length > 0) serverData.push(serverRow);
                }
                if (!indexColumnExists || (indexColumnExists && clientRow['index'] != null)) {
                    if (Object.keys(clientRow).length > 0) clientData.push(clientRow);
                }
            });

            const createJsonFile = (type, data, sheetName) => {
                const dirPath = path.join(this.uploadFolderPath, `${type}/${dataVersion}`);
                if (!fs.existsSync(dirPath)) {
                    fs.mkdirSync(dirPath, { recursive: true });
                }

                const jsonFilePath = path.join(dirPath, `${sheetName}_${type}.json`);
                const jsonData = JSON.stringify({ items: data }, null, 2);
                let fileChanged = false;

                try {
                    if (fs.existsSync(jsonFilePath) && fs.lstatSync(jsonFilePath).isFile()) {
                        const existingData = fs.readFileSync(jsonFilePath, 'utf8');
                        if (jsonData !== existingData) {
                            fs.writeFileSync(jsonFilePath, jsonData);
                            fileChanged = true;
                        }
                    } else {
                        fs.writeFileSync(jsonFilePath, jsonData);
                        fileChanged = true;
                    }
                } catch (err) {
                    console.error('Error reading or writing file:', err);
                }

                if (fileChanged) {
                    this.logger.log(`File updated: ${jsonFilePath}`);
                    updatedFiles.push(jsonFilePath);
                }
            };

            if (serverData.length > 0) {
                createJsonFile('Server', serverData, sheetName);
            }
            if (clientData.length > 0) {
                createJsonFile('Client', clientData, sheetName);
            }
        });
        return updatedFiles;
    }

5. 수정된 파일들의 json을 생성했다면 S3에 업로드한다.

_Server.json은 server버킷에, _Client.json은 client 버킷에 버전폴더 아래에 저장한다.

 async uploadConvertedFiles(
        serverType: string,
        dataVersion: string,
        updatedFiles: string[]
    ): Promise<void> {
        for (const filePath of updatedFiles) {
            if (fs.lstatSync(filePath).isFile()) {
                const filename = path.basename(filePath);
                let bucket = this.s3ClientBucket;

                if (filename.endsWith('_Server.json')) {
                    bucket = this.s3ServerBucket;
                } else if (!filename.endsWith('_Client.json')) {
                    continue;
                }

                const Key = `${serverType}/${dataVersion}/${filename}`;
                const stream = fs.createReadStream(filePath);
                await this.s3.send(new PutObjectCommand({ Bucket: bucket, Key, Body: stream }));
                this.logger.log(`[ ${bucket} ] Successfully uploaded ${filename}`);
            }
        }
    }

6. S3 버킷에저장이 완료되고 나면 CDN에 있는 캐시를 무효화 해준다.

현재 개발중인 게임의 경우 글로벌 서비스이기 때문에 하나의 리전에서 데이터를 가져가려면 시간이 오래걸린다. 따라서, AWS의 Cloud Front를 사용하여 사용자에게 가까운 엣지 로케이션에서 데이터를 제공받을 수 있게 했다.

캐싱된 데이터는 기본적으로 24시간동안 기존 버킷의 객체를 유지하기 때문에 데이터가 바뀌었다면 캐시를 무효화하여 각 엣지 로케이션에서 새로운 데이터를 갖고있게 해야한다.

 async invalidateCache(serverType: string, dataVersion: string): Promise<void> {
        const paths = [`/${serverType}/${dataVersion}/*`];
        const command = new CreateInvalidationCommand({
            DistributionId: this.cloudFrontDistributionId,
            InvalidationBatch: {
                Paths: {
                    Quantity: paths.length,
                    Items: paths
                },
                CallerReference: new Date().getTime().toString()
            }
        });

        try {
            await this.cloudFront.send(command);
            this.logger.log(`Cache invalidated`);
        } catch (error) {
            this.logger.error('Error invalidating cache', error);
            throw error;
        }
    }

7. 데이터를 업로드 하고나면 Slack 웹훅으로 채널에 메시지를 전송한다.

 const updatedClientFiles = updatedFiles
            .filter(f => f.endsWith('_Client.json'))
            .map(f => path.basename(f))
            .join(', ');
        const updatedServerFiles = updatedFiles
            .filter(f => f.endsWith('_Server.json'))
            .map(f => path.basename(f))
            .join(', ');

        const message = `
    - Server Type : ${serverType.toUpperCase()}
    - Data Version : ${dataVersion}
    - Client Modified Files: ${updatedClientFiles}
    - Server Modified Files: ${updatedServerFiles}`;
        console.log(message);
        if (serverType !== 'local') {
            const url = this.configService.get('SLACK_WEBHOOK_URL');
            await SlackService.send(url, 'Game Data Upload Complete!', message);
        }

8. 로컬에선 열심히 확인했지만, 항상 다른 환경에서 다른사람이 서비스를 이용하면 문제가 생기기 마련이다.

데이터 디자이너님께 테스트를 부탁드렸다.


정상적으로 수정된 파일을 감지하고 CDN에서 캐시까지 무효화 한 로그를 확인할 수 있었다.
여기까진 확인했지만 혹시나 클라이언트에서 제대로 작동하지 않는 부분이 있을 수 있어 클라이언트 팀장님께도 업데이트 한 내용을 말씀드렸다.

클라이언트에도 새로 올라간 데이터를 사용하는데 문제가 없다고 하셨고, 이후 QA 테스트까지 잘 진행했다. 이번에도 자그마한 자동화 기능을 만들었다.

그동안 했던 기능들과는 달리 출시가 얼마 남지 않은 게임이기 때문에 생성되는 json파일이 기존의 파일과 한글자라도 다르면 모든 코드가 동작하지 않을 수 있어 사소한 로직까지 모두 신경써야 했다.

쉽진 않았지만 데이터 디자이너님께서 파일 업로드 하는 부분이 엄청 간편해졌다며 좋아하셨다. 누군가에게 도움이 되는 일은 항상 보람차다. 🥳

profile
Search Engineer

0개의 댓글