성능 개선 리팩토링 15s > 100ms -완-(feat. puppeteer 성능 및 클라이언트/서버 로직 분리)

최봉수·2023년 12월 15일
0
post-thumbnail

지난 포스팅에서 배포를 하고 나니 시그놀로지 환경에서 속도가 너무 느려져서 리팩토링을 진행하였다.
그 과정을 기록하려고 한다.

결과

리팩토링 전


속도 1.5배 한게 이 정도.
끔찍하다.

리팩토링 후


속도 보정 안했다.
뿌듯하다.

이제 차근차근 이 과정을 기록해보도록 하겠다.

리팩토링 과정

기존

1.5배 속도
데이터 패칭 ~ 이미지 렌더링까지 약 15s

1차

1.5배 속도

개선 점

  • 기존 : 데이터 스크랩 > 필터링/가공(base64 미포함) > json 저장(base64 추가) > 최신 json 가져옴 > 렌더링

  • 변경 : 데이터 스크랩 > 필터링/가공(base64 추가 이게 최신 json 가져온 거랑 동일함) > 렌더링 > json 저장

  • 이유 : 기존에 최신 데이터를 유지하기 위해서 최종 데이터를 json에 저장한 다음 해당 데이터를 다시 가져오는 식으로 로직을 구현했었다.
    그 결과 유저가 이미지를 보기 위해서 필요한 정보는 진작에 받아왔지만, 데이터가 완전 저장되기 전까지 유저는 이미지를 볼 수 없었다.
    그래서 base64가 포함된 최종 데이터를 기존 3단계(json저장)이 아닌 2단계(필터링/가공)로 빼서 2단계 완료 시 클라이언트에 바로 보내서 렌더링 해주고, 이후에 서버에서는 클라이언트에 보낸 최종 데이터를 저장하는 방식으로 변경했다.

  • 결과 : 최종 로직 완료 시간은 길어졌지만 유저는 getImageBase64가 완료되는 시점에 이미지를 볼 수 있다. 그러므로 약 10s가 걸렸기에 기존 15s > 10s까지 약 5s 정도의 개선이 있었다.

2차

1배 속도

개선 점

  • 기존 : 데이터 스크랩과 image-url을 base64로 변환하는 부분을 puppeteer 사용

  • 변경 : 데이터 스크랩과 image-url을 base64로 변환하는 부분을 fetch로 변경

  • 이유 : 1차에서 약 5s의 개선이 있었지만, 그래도 10s라는 끔찍한 성능이 나오고 있었다.
    그래서 nas와 puppeteer 성능 관련 키워드를 찾아보다가 puppeteer의 성능 이슈 개선 글 발견했다.
    해당 글은 puppeteer를 사용한 크롤링의 구조(브라우저 오픈 > url 이동 > 추출 로직 > 반환)가 너무 많다고 생각해서 심플하게 fetch를 통해 크롤링 단계를 획기적으로 줄였다. (데이터 요청 > 반환 > 가공)
    나는 여기서 기존에 사용하던 query 방식을 바꿨을 때 스택오버플로우나 블로그 솔루션들이 대부분 fetch 였던 것이 생각났다.
    아, 갑자기 내가 왜 Puppeteer를 쓰고있지? 생각해보니 이때 채택했던 puppeteer를 hashQuery를 변경했음에도 그대로 쓰고 있었었다.
    그래서 나도 puppeteer의 불필요한 과정을 줄이기로 했다.

  • 결과 : 기존 10s > 1~2s까지 압도적인 개선이 있었다.

3차

1배 속도

개선 점

  • 기존 : 브라우저에 접속할 때마다 클라이언트에서 서버에 업데이트 및 여러 로직 요청을 보내고 그 결과를 받아서 렌더링 해준다.

  • 변경 : 업데이트와 필터링/가공 및 저장 로직을 전부 서버에 옮기고 스케줄링을 통해 특정 시간마다 서버에서 알아서 업데이트 필요 항목 체크 후 필요 항목의 데이터를 업데이트해서 저장해둔다. 클라이언트는 저장되어있는 최신 데이터만 받아와서 렌더링 해준다.

  • 이유 : 2차 리팩토링을 통해 속도는 만족스러웠지만, 현재는 브라우저 접속 시 클라이언트에서 서버에 request를 보내고 있는데, 사내 NAS에 띄운 웹 서버라 점심 시간 쯤 임직원 1~20명 가량이 한번에 접속하게 됐을 경우 어떻게 될까 생각이 들었다.
    아마도 저럴 경우 별도의 처리를 해두지 않았기에 서버에서 db 역할을 해주는 json 데이터가 꼬일 수도 있고, 단 시간에 과도한 요청(스크랩)은 인스타그램에 Block 당할 가능성이 있다고 판단하였다.
    그래서 데이터 스크랩 ~ 저장까지의 모든 로직을 서버에 옮기고 node-cron을 사용해서 업데이트를 해야하는 시간 마다 서버에서 업데이트 필요 항목을 체크 후 필요 항목의 데이터를 업데이트 하고 저장한다.
    그러면 유저가 10명이던지 100명이던지 브라우저 접속 시 클라이언트는 서버에 저장되어있는 데이터만 요청해 받아서와서 렌더링 해주도록 변경했다.

  • 결과 : 기존 1~2s > 100ms까지 개선이 있었다.

그러면 서버에서 너무 오래 걸리는 거 아니냐? 싶겠지만 아래와 같다.
서버 로그이다. 로직 다 도는데 약 1~2s 정도 걸린다.
이미 최신 상태라 체크에서 걸렸을 때이다.

클라이언트, 서버 모두 빠르고 쾌적해졌다!

최종 코드

처음 그려봐서 이렇게 표현하면 되려나..?

클라이언트

const SERVER = 'http://{HOST}:{PORT}';
const GET_LUNCH_DATA_URL = `${SERVER}/getLunchData`;

const RESTAURANT_DATA = [
	{ name: '식당1', ig_name: 'restaurant1', ig_owner_id: '111111' },
	{ name: '식당2', ig_name: 'restaurant2', ig_owner_id: '222222' },
	{ name: '식당3', ig_name: 'restaurant3', ig_owner_id: '333333' },
];

// 초기화 및 렌더링
async function initializeMenuAndRender() {
	createRestaurantElement();
	startLoading();

	try {
		const lunchData = await getLunchData();
		renderMenuImages(lunchData);
	} catch (error) {
		handleClientError(error, 'initializeMenuAndRender Function');
	} finally {
		endLoading();
	}
}

// 서버에서 데이터 가져오기
async function getLunchData() {
	try {
		const response = await fetch(GET_LUNCH_DATA_URL);

		if (!response.ok) {
			throw new Error(`서버에서 데이터를 가져오는 중 오류가 발생했습니다. (HTTP ${response.status})`);
		}

		return await response.json();
	} catch (error) {
		handleClientError(error, 'getLunchData Function');
	}
}

// 이미지 렌더링
function renderMenuImages(data) {
	Object.entries(data).forEach(([key, value]) => {
		const elem = document.querySelector(`.restaurant[data-restaurant="${key}"] .menu-image`);
		const img = document.createElement('img');
		img.src = value.base64;
		elem.append(img);
	});
}

// 레스토랑 리스트 생성
function createRestaurantElement() {
	RESTAURANT_DATA.forEach(({ name, ig_name }) => {
		const restaurantList = document.querySelector('.restaurant-list');
		const restaurant = document.createElement('div');
		restaurant.classList.add('restaurant');
		restaurant.dataset.restaurant = ig_name;

		const nameBox = document.createElement('div');
		nameBox.classList.add('name-box');

		const restaurantName = document.createElement('a');
		restaurantName.classList.add('name');
		restaurantName.innerText = name;
		restaurantName.href = `https://www.instagram.com/${ig_name}/`;
		restaurantName.target = '_blank';

		const image = document.createElement('div');
		image.classList.add('menu-image');

		nameBox.append(restaurantName);
		restaurant.append(nameBox, image);
		restaurantList.append(restaurant);
	});
}


function startLoading() {
	const imageBox = document.querySelectorAll('.restaurant .menu-image');
	imageBox.forEach((imageBox) => imageBox.classList.add('loading'));
}

function endLoading() {
	const imageBox = document.querySelectorAll('.restaurant .menu-image');
	imageBox.forEach((imageBox) => imageBox.classList.remove('loading'));
}

function handleClientError(error, from) {
	console.error(`Error from Client ${from}: ${error.message}`);
}

initializeMenuAndRender();

서버

const express = require('express');
const cors = require('cors');
const app = express();
const { readFile, writeFile } = require('fs').promises;
const path = require('path');
const fetch = require('node-fetch');
const cron = require('node-cron');

const PORT = 8080;
const ALLOWED_DOMAIN = 'http://${HOST}';
const LUNCH_MENU_DATA_FILE_PATH = path.join(__dirname, '/lunchData.json');

const RESTAURANT_DATA = [
	{ name: '식당1', ig_name: 'restaurant1', ig_owner_id: '111111', filter: restaurant1Filter },
	{ name: '식당2', ig_name: 'restaurant2', ig_owner_id: '222222', filter: restaurant2Filter },
	{ name: '식당3', ig_name: 'restaurant3', ig_owner_id: '333333', filter: restaurant3Filter },
];

// Start Server
app.use(
	cors({
		origin: ALLOWED_DOMAIN,
		optionsSuccessStatus: 200,
	})
);
app.use(express.json());
app.listen(PORT, () => console.log(`running server port: '${ALLOWED_DOMAIN}:${PORT}'`));

// 최신 데이터 반환
app.get('/getLunchData', async (req, res) => {
	try {
		const parsedJson = await getParsedLunchJson();
		res.json(parsedJson);
	} catch (error) {
		handleServerError(error, 'JSON 데이터를 가져오는 중 오류가 발생했습니다.');
	}
});

/**
 * 1. cron을 통해 스케줄링
 * 2. 실행마다 업데이트가 필요한 데이터만 체크 (json 파일 데이터의 날짜와 현재 날짜로 1차 체크)
 * 3. 피드 데이터 가져오기
 * 4. 필터링 및 데이터 구조 수정 (json 파일 데이터의 id와 가져온 데이터의 id로 2차 체크)
 * 5. json 파일에 데이터 저장
 */

cron.schedule(
	// *1 *2 *3 *4 *5 *6
	// 1: seconds(optional), 2: minutes, 3: hours, 4: dayOfMonth, 5: month, 6: dayOfWeek
	// 월~금 11시 30분 ~ 11시 59분까지 5분마다 6회 실행 (11:30, 11:35, 11:40, 11:45, 11:50, 11:55)
	'30-59/5 11 * * 1-5',
	() => {
		updateDataAndSave();
	},
	{
		timezone: 'Asia/Seoul',
	}
);

// 데이터 업데이트 및 저장 함수
async function updateDataAndSave() {
	try {
		// 데이터 체크
		const { needsUpdateData, isNeedsUpdating } = await checkUpdateData(RESTAURANT_DATA);

		if (isNeedsUpdating) {
			// 데이터 엽데이트
			const updatePromises = needsUpdateData.map(({ ig_name, ig_owner_id }) => getUpdateData(ig_name, ig_owner_id));
			const updatedData = await Promise.all(updatePromises);

			// 데이터 가공
			const restructurePromises = updatedData.map(({ ig_name, data }) => restructureData(ig_name, data, getFilterFunc(ig_name)));
			const restructuredData = await Promise.all(restructurePromises);

			// 데이터 저장
			await saveDataInJson(restructuredData);
		} else {
			console.log('모든 데이터가 최신 상태입니다.');
		}
	} catch (error) {
		handleServerError(error, '스케줄링 실행 중 오류가 발생했습니다.');
	}
}

// 데이터 업데이트가 필요한지 체크 후 필요한 데이터만 반환
async function checkUpdateData(data) {
	try {
		const parsedJson = await getParsedLunchJson();
		const needsUpdateData = data.filter(({ ig_name }) => parsedJson[ig_name].lastUpdate !== getCurrentDate());
		const isNeedsUpdating = needsUpdateData.length > 0;
		return { needsUpdateData, isNeedsUpdating };
	} catch (error) {
		handleServerError(error, '업데이트가 필요한 데이터를 확인하는 중 오류가 발생했습니다.');
	}
}

// 최신 피드 데이터 가져오기
async function getUpdateData(ig_name, ig_owner_id) {
	try {
		const request = await fetch(`https://www.instagram.com/graphql/query/?query_hash=e769aa130647d2354c40ea6a439bfc08&variables={"id":"${ig_owner_id}", "first":12 }`);
		const response = await request.json();

		if (response.status !== 'ok') {
			throw new Error(response.message);
		}

		return { ig_name, ...response };
	} catch (error) {
		handleServerError(error, '피드 데이터를 가져오는 중 오류가 발생했습니다.');
	}
}

// 데이터 구조 수정
async function restructureData(ig_name, data, filterFunc) {
	try {
		const { url, id } = filterFunc(data);
		const base64 = await imageUrlToBase64(url);
		return { ig_name, url, id, base64, lastUpdate: getCurrentDate() };
	} catch (error) {
		handleServerError(error, '데이터를 가공하는 중 오류가 발생했습니다.');
	}
}

// 데이터 json에 저장
async function saveDataInJson(data) {
	try {
		const parsedJson = await getParsedLunchJson();

		for (const { ig_name, url, id, base64, lastUpdate } of data) {
			const isNotSameId = parsedJson[ig_name][id] !== String(id);
			if (isNotSameId) {
				parsedJson[ig_name].id = id;
				parsedJson[ig_name].url = url;
				parsedJson[ig_name].lastUpdate = lastUpdate;
				parsedJson[ig_name].base64 = base64;
			}
		}

		await writeFile(LUNCH_MENU_DATA_FILE_PATH, JSON.stringify(parsedJson, null, 4), 'utf8');
		console.log(`${getCurrentDate()} JSON 업데이트 완료.`);
	} catch (error) {
		handleServerError(error, 'JSON 데이터를 저장하는 중 오류가 발생했습니다.');
	}
}

// 이미지 url base64로 변환
async function imageUrlToBase64(url) {
	try {
		const response = await fetch(url);
		const blob = await response.arrayBuffer();
		const contentType = response.headers.get('content-type');
		const base64 = Buffer.from(blob).toString('base64');
		const imageBase64 = `data:${contentType};base64,${base64}`;
		return imageBase64;
	} catch (error) {
		throw new Error('이미지 URL을 Base64로 변환하는 중 오류가 발생했습니다.');
	}
}

async function getParsedLunchJson() {
	const json = await readFile(LUNCH_MENU_DATA_FILE_PATH, 'utf8');
	return JSON.parse(json);
}

function getFilterFunc(name) {
	return RESTAURANT_DATA.find(({ ig_name }) => ig_name === name).filter;
}

function getCurrentDate() {
	const date = new Date();
	const day = String(date.getDate()).padStart(2, '0');
	const month = String(date.getMonth() + 1).padStart(2, '0');
	const year = String(date.getFullYear()).slice(-2);
	return `${year}${month}${day}`;
}

function restaurant1Filter(data) {
	// filtering...
	return { url: imgUrl, id: postID };
}

function restaurant2Filter(data) {
	// filtering...
	return { url: imgUrl, id: postID };
}

function restaurant3Filter(data) {
	// filtering...
	return { url: imgUrl, id: postID };
}

function handleServerError(error, message) {
	console.error(`Error from Server: ${error.message}, ${message}`);
}

마무리

별 거 아닌 이유로 시작한 프로젝트지만, 이게 이렇게 길어질 줄 몰랐다.
내가 아닌 개발팀 그 누구라도 유지보수를 할 수 있게 주석과 네이밍을 다시 정돈해봐야겠다.
그래야 내가 이 회사에 없어도 누군가가 관리를하며 계속 이 기능을 사람들이 사용할 수 있을테니 말이다.

무지성으로 시작한 프로젝트지만, 정말 많은 것을 배웠다.
node로 간단하지만 서버를 혼자 짜서 프로젝트를 완료한 것도 처음이고, NAS, Docker 등 처음인 것 투성이였다.
첫 포스팅에서 말했던 것처럼 모르면 공부하면 되겠지란 생각이였는데 좀 오래 걸렸지만 정말 되긴 했다 ㅋㅋ

아무도 안 볼 수도 있는 프로젝트 시리즈지만 나에겐 나름 뜻 깊다.
내가 스스로 생각해서 시작한 프로젝트이고,
처음 다뤄보는 것들이 많았지만 완성했고,
끝내주는 성능 개선도 있었고,
사람들이 좋아한다.

처음엔 내가 귀찮아서 만들기로 했던거지만,
내가 코드로 사람들의 불편함을 해결해줬던 적이 있었나?
처음이다.

profile
돈이 좋아

0개의 댓글