지난 포스팅에서 배포를 하고 나니 시그놀로지 환경에서 속도가 너무 느려져서 리팩토링을 진행하였다.
그 과정을 기록하려고 한다.
속도 1.5배 한게 이 정도.
끔찍하다.
속도 보정 안했다.
뿌듯하다.
이제 차근차근 이 과정을 기록해보도록 하겠다.
1.5배 속도
데이터 패칭 ~ 이미지 렌더링까지 약 15s
1.5배 속도
기존 : 데이터 스크랩 > 필터링/가공(base64 미포함) > json 저장(base64 추가) > 최신 json 가져옴 > 렌더링
변경 : 데이터 스크랩 > 필터링/가공(base64 추가 이게 최신 json 가져온 거랑 동일함) > 렌더링 > json 저장
이유 : 기존에 최신 데이터를 유지하기 위해서 최종 데이터를 json에 저장한 다음 해당 데이터를 다시 가져오는 식으로 로직을 구현했었다.
그 결과 유저가 이미지를 보기 위해서 필요한 정보는 진작에 받아왔지만, 데이터가 완전 저장되기 전까지 유저는 이미지를 볼 수 없었다.
그래서 base64가 포함된 최종 데이터를 기존 3단계(json저장)이 아닌 2단계(필터링/가공)로 빼서 2단계 완료 시 클라이언트에 바로 보내서 렌더링 해주고, 이후에 서버에서는 클라이언트에 보낸 최종 데이터를 저장하는 방식으로 변경했다.
결과 : 최종 로직 완료 시간은 길어졌지만 유저는 getImageBase64
가 완료되는 시점에 이미지를 볼 수 있다. 그러므로 약 10s가 걸렸기에 기존 15s > 10s까지 약 5s 정도의 개선이 있었다.
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까지 압도적인 개선이 있었다.
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 등 처음인 것 투성이였다.
첫 포스팅에서 말했던 것처럼 모르면 공부하면 되겠지란 생각이였는데 좀 오래 걸렸지만 정말 되긴 했다 ㅋㅋ
아무도 안 볼 수도 있는 프로젝트 시리즈지만 나에겐 나름 뜻 깊다.
내가 스스로 생각해서 시작한 프로젝트이고,
처음 다뤄보는 것들이 많았지만 완성했고,
끝내주는 성능 개선도 있었고,
사람들이 좋아한다.
처음엔 내가 귀찮아서 만들기로 했던거지만,
내가 코드로 사람들의 불편함을 해결해줬던 적이 있었나?
처음이다.