지난 포스팅에서 크리티컬한 이슈가 발생했다.
지금은 정상적으로 나오지만 정확한 원인을 파악하지 못한 지금 다시 시도를 하기엔 리스크가 크다고 생각한다.
원인을 찾기위해 수 많은 구글링과 GPT를 잡고 늘어지다보니 기존에는 몰랐던 크롤링과 스크랩의 차이에 대해서 알게 되었고, 그에 따라 방향을 바꾸기로 하였다.
다들 나보다 많이 알고 있을테니 간단하게만 적고 넘어가야겠다.
대략 이런 차이가 있다.
내가 이걸 진행하던 목적은 크롤링에 맞지 않았다.
해시태그 크롤링처럼 불특정한 폭 넓은 데이터가 필요하지도 않았고 인덱싱도 필요하지 않았다.
딱 2~3개의 프로필에 첫번째 게시물 정보만 필요하기에 스크래핑이 더 적합하다고 판단하여 방향을 바꾸기로 결정했다.
방향을 스크래핑으로 정하고 구글링과 GPT에게 물어본 결과 웹 스크래핑 라이브러리는 크게 Cheerio, Puppeteer, Playwright등이 있었다.
그 중에 Cheerio는 Axios와 함께 사용해야했고, Playwright는 크로스브라우징 테스트와 스크래핑이 동시에 가능하며 사용법도 Puppeteer와 크게 다르지 않았다.
하지만 나는 마이그레이션 하면서 딱히 fetch를 하지 않을거기에 Axios도 필요 없었고 크로스브라우징 테스트도 필요 없었다.
그래서 사용법도 간단하며, 크롬 개발자 센터에도 소개되어 있는 Puppeteer로 선택하였다.
이것저것 계속 수정하면서 코드가 많이 변했다.
매번 자잘한 로직 수정과 네이밍, 함수분리 등을 기록 하기 쉽지 않더라.
그래서 우선은 통으로 코드를 올리고 아래 '로직의 변화'부분에서 뭐가 어떻게 변했는지 대략적으로 기록해둘 생각이다.
// 피드 데이터 가져오기
app.get('/getFeedData', async (req, res) => {
try {
const scrapeData = await getScrapeData(`https://www.instagram.com/${req.headers.ig_name}/?__a=1&__d=dis`);
if (scrapeData.status === 'fail') {
throw new Error(scrapeData.message);
}
res.json(scrapeData);
} catch (error) {
handleServerError(res, error, '피드 데이터를 가져오는 중 오류가 발생했습니다.');
}
});
// 데이터 정적 json 파일에 저장
app.post('/setMenuData', async (req, res) => {
try {
const { ig_name, url, id, lastUpdate } = req.body;
const data = await readFile(LUNCH_MENU_DATA, 'utf8');
const jsonData = JSON.parse(data);
const isNotSameId = jsonData[ig_name].id !== String(id);
const isNotSameLastUpdate = jsonData[ig_name].lastUpdate !== lastUpdate;
// id 혹은 lastUpdate가 다를 경우에만 json 파일 내용 수정
if (isNotSameId || isNotSameLastUpdate) {
// id가 다를 경우에만 lastUpdate 수정
jsonData[ig_name].lastUpdate = isNotSameId ? lastUpdate : jsonData[ig_name].lastUpdate;
jsonData[ig_name].id = id;
jsonData[ig_name].url = url;
jsonData[ig_name].base64 = await getImageBase64(url);
await writeFile(LUNCH_MENU_DATA, JSON.stringify(jsonData, null, 4), 'utf8');
console.log('JSON 파일 내용이 성공적으로 수정되었습니다.');
} else {
console.log('JSON 파일은 이미 최신 상태입니다.');
}
res.sendStatus(200);
} catch (error) {
handleServerError(res, error, 'JSON 데이터를 수정하는 중 오류가 발생했습니다.');
}
});
// 이미지 base64 데이터 가져오기 (import 캐싱 문제로 인해 최신 json 파일을 직접 읽어옴)
app.get('/getLatestLunchData', async (req, res) => {
try {
const data = await readFile(LUNCH_MENU_DATA, 'utf8');
const jsonData = JSON.parse(data);
res.json(jsonData);
} catch (error) {
handleServerError(res, error, '최신 JSON 데이터를 가져오는 중 오류가 발생했습니다.');
}
});
async function getScrapeData(url) {
const { browser, page } = await getPuppeteerPage(url);
try {
// 페이지 내에서 JavaScript 실행 후 json 데이터 가져오기
return await page.evaluate(() => JSON.parse(document.querySelector('pre').textContent));
} catch (error) {
throw new Error('데이터 스크랩 중 오류가 발생했습니다.');
} finally {
// 브라우저 닫기
await browser.close();
}
}
async function getImageBase64(url) {
const { browser, page } = await getPuppeteerPage(url);
try {
const imgElement = await page.$('img');
return await imgElement.screenshot({ encoding: 'base64' });
} catch (error) {
throw new Error('이미지 base64 데이터를 가져오는 중 오류가 발생했습니다.');
} finally {
await browser.close();
}
}
async function getPuppeteerPage(url) {
try {
// Headless 브라우저를 시작
const browser = await puppeteer.launch({ headless: 'new' });
// 시크릿 브라우저 컨텍스트 생성
const context = await browser.createIncognitoBrowserContext();
// 새로운 페이지 열기
const page = await context.newPage();
// 페이지에 이동
await page.goto(url);
return { browser, page };
} catch (error) {
throw new Error('Puppeteer 실행 중 오류가 발생했습니다.');
}
}
post 부분 body 유효성 검사는 따로 해두진 않았지만, 혹시 모르니 간단하게 처리는 해둘 예정이다.
puppeteer를 이용해 Headless 브라우저를 띄운 후 url로 이동후 해당 브라우저와 페이지의 컨텍스트를 반환해준다.
그리고 evaluate()이라는 method를 통해서 headless 브라우저 내에서 스크립트를 실행해 json text가 담긴 태그의 내용을 파싱해서 가져와 클라이언트에 응답을 넘겨준다.
// index.js
async function init() {
// 마지막 업데이트 날짜 체크
const lastUpdates = Object.values(lunchData).map((value) => value.lastUpdate);
const isNotLatest = lastUpdates.some((value) => value !== getCurrentDate());
try {
/// 최신이 아닐 경우 실행
if (isNotLatest && isUpdateTime()) {
// json 파일 수정 때문에 all로 처리하면 데이터 엉킴
await updateMenu('wyfood_nutri', wyFoodFilter);
await updateMenu('onnuri_r', onnuriFilter);
await updateMenu('babplus_f1', babPlusFilter);
updateMenuImages();
} else {
renderMenuImages(lunchData);
}
} catch (error) {
handleClientError(error, 'init Function');
}
}
async function updateMenu(igName, filterFunc) {
try {
// getFeedData
const getFeedData = await useFetch(INSTAGRAM_DATA_URL, 'GET', { ig_name: igName });
const { url, id } = filterFunc(getFeedData);
// saveMenuData
await useFetch(SET_MENU_DATA_URL, 'POST', { ig_name: igName, url, id, lastUpdate: getCurrentDate() });
} catch (error) {
handleClientError(error, 'updateMenu Function');
}
}
async function updateMenuImages() {
try {
const getLatestLunchData = await useFetch(GET_LATEST_LUNCH_DATA_URL, 'GET');
renderMenuImages(getLatestLunchData);
} catch (error) {
handleClientError(error, 'updateMenuImages 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 = `data:image/jpeg;base64,${value.base64}`;
elem.append(img);
});
}
async function useFetch(url, method, requestData) {
try {
const getOptions = {
method: 'GET',
headers: requestData,
};
const postOptions = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestData),
};
const request = fetch(url, method === 'GET' ? getOptions : postOptions);
const response = await request;
if (!response.ok) {
const { errorMessage } = await response.json();
throw new Error(errorMessage);
}
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
return await response.json();
} else {
return await response.text();
}
} catch (error) {
handleClientError(error, 'useFetch Function');
}
}
여기가 로직이 꽤나 변해서 뭘 어떻게 바꿨는지 설명이 힘들다..
그래도 기록을 위해 간략하게라도 써두자면
메일 특정 시간에만 업데이트를 할 것이기에 데이터가 최신인지 체크하는 로직을 추가했고
updateMenu(), useFetch()와 같은 함수를 새로 만들어서 중복 코드를 줄이고 분리했다.
test1Filter같은 함수는 각 식당마다 게시물의 순서와 가져와야 할 사진의 위치가 달라서 필터링 로직을 함수로 만들어 뺐다.
크롤링 > 스크래핑으로 목적지을 바꿨다.
- Puppeteer를 사용했다.
- 더 이상 인스타그램 API에 요청을 보내지 않는다.
매번 스크랩을 해오지 않도록 fs module을 사용해서 local json 파일에 스크랩한 데이터와 날짜를 저장한다.
- 정해진 시간에 업데이트가 끝나고 나면 그 다음 업데이트 전까지는 저장해둔 static data를 빠르게 보여주도록 한다. (renderMenuImages)
import(module) 캐싱 이슈로 업데이트 후 바로 최신 이미지 데이터를 렌더링 하지 못하고 한번 더 새로고침을 해야 반영되던 이슈를 서버에서 업데이트가 끝난 최신 json을 읽어오는 방식으로 처리
- 단 업데이트 시에만 서버에 요청을 하고 그 이후엔 업데이트 되어있는 local file을 읽어옴
- 서버에 요청하는 부분도 현재 가져오는 데이터가 로컬 데이터기에 속도가 잘 나와서 임시처리 상태 (더 좋은 방법 찾아봐야함 굳이 서버에 요청을 한번 더 보내는게 불필요 해보임)
아직은 에러 처리와 함수, 로직 등이 어수선한 느낌이있다.
더 개선은 해야겠지만 우선 오늘 업데이트 시간, 데이터 저장/변환, 이미지 렌더링 등의 테스트는 문제가 없었다.
저번주부터 계속 삽질하면서 수정하던 문제라 변화를 자세하게 기록해두지 않고, 벨로그 포스팅 임시저장으로 키워드로만 메모해놨기에 어디가 어떻게 왜 달라졌는지 자세하게 기록하지 못했다.
마지막 이슈는 현재 url의 경우 너무 잦은 요청이 발생하면 한동안 응답이 블락된다는 문제인데 이 문제는 하루에 특정 시간에 && 최신 데이터가 아닐 경우에만 스크랩 하도록 해놨으니 며칠간 지켜보면 될 문제이다.
그리고 이제 node 서버를 배포해야하는데 지금 사용 중엔 aws에 ec2 인스턴스를 추가하는 것은 꽤나 비효율적인 거 같다. 점심 메뉴 좀 보자고 인스턴스를 추가해서 돈을 내자니..
그래서 현재 내부 시그놀로지 나스에 서버를 띄울까하는데 내 나스 DSM 계정은 제어판 접근 권한이 없어서 팀장님한테 먼저 ftp가 활성화 되어있는지, 내 계정에 접근권한이 있는지 등 확인해보고 진행해야할 거 같다.
인스타그램 계정 3개에서 매일 메뉴판 사진 한장씩 가져오는게 뭐 이리 어려운지
내가 이걸 왜 하고 있는지 의문이 든다.
그래도 요즘 일이 많이 널널해서 근무 시간에 남는 시간이 좀 많다.
우리 회사는 그 시간에 개인 프로젝트나 평소 부족했던 부분을 공부하며 시간을 보내도 된다.
오히려 장려한달까 그 부분에서 재미도 있고, 하면서 꽤나 배운 것도 있으니 됐다.
그리고 이 회사에 다니면서 내가 회사 시스템에 뭐라도 하나 남기고 가면 얼마나 보람찰까