본문 바로가기
개발기록

job-hunter 회고 #1 개발 초기

by 떤떤 2024. 1. 15.

정말 오랜만에 개인 프로젝트를 만들어봤다. 이직을 위해 채용 공고를 둘러보던 중 새로 올라온 공고를 알려주는 프로그램을 하나 만들어야겠다고 생각했다. 그리고 요즘 디스코드를 자주 켜놓기 때문에 디스코드 알림으로 볼 수 있도록 디스코드 봇으로 만들기로 결정했다.

 

나의 초기 계획

  • 디스코드에서 원하는 직군, 지역 구독/구독해제
  • 채용 공고 사이트 여러 곳에서 관련 데이터 크롤링 후 DB에 저장
  • 새로 올라온 채용 공고 디스코드로 알림
  • 마감된 채용 공고 디스코드로 알림
  • 각 포지션 별로 회사에서 어떤 기술 스택을 원하는지 데이터 분석해서 통계해서 디스코드로 알림

내가 처음 기획한 내용은 위에 항목대로 여러가지 기능을 추가하고 싶었지만 우선 나만의 MVP 기능 먼저 동작하는 거 확인하고 추후 추가하기로 했다. CI/CD 파이프라인 구축도 생각했었으니까 일단 EC2에 배포가 잘 되는지 확인하고 싶었다.

 

MVP 기능

결국 나 자신과 타협해서 아래와 같은 기능 먼저 구현하기로 했다.

  • 디스코드 nodejs 채용 공고 알림 구독/구독해제
  • 원티드에서 nodejs 채용 공고 크롤링 후 DB에 저장
  • 새로 올라온 채용 공고 디스코드 봇으로 알림
  • 마감된 채용 공고 마감 처리

어떤 언어와 툴로 만들까?

보통 파이썬으로 크롤링을 주로 하지만 Node.js로 해보고 싶었기 때문에 Node.js로 결정했다.

크롤링 관련해서 자주 사용하는 라이브러리를 찾아보니까 cheerio와 puppeteer가 나왔다. 투탑인 것 같다.

그리고 빠르게 만들기 위해서 typescript말고 javascript로 결정. (타입 선언하는게 귀찮아서 그런건 절대 아니다)

DB는 오랜만에 공부할 겸  MySQL로 결정했다.

 

개발 중 변동사항

패키지 cherrio -> puppeteer

처음에 cheerio를 사용해서 원티드 데이터를 가져오려는데 계속 null만 가져와졌다.

알고보니까 원티드는 SPA(Single Page Application)라서 정적 크롤링 패키지인 cheerio 패키지로는 가져올 수 없었다.

그래서 SPA도 크롤링할 수 있는 puppeteer 패키지를 사용하기로 했다.

 

javascript -> typescript

최대한 빠르게 만들고 리팩토링 해야지라는 생각으로 javascript로 짰었는데 typescript로 개발하다가 javascript로 개발하려니까...타입 관련해서 너무 화가 나서 중간에 typescript로 바꿨다.

 

문제 해결 사례

자동 스크롤

원티드 페이지는 인피니티 스크롤로 되어있고 정렬을 최신순으로 했지만 올라온 날짜와 시간에 대한 정보는 얻을 수 없었고 position_id는 뒤죽박죽이라 마감된 채용 공고를 확인하기 위해서는 목록 끝까지 확인을 해야했었다. 목록을 끝까지 확인하기 위해서 puppeteer의 page.evaluate() 메서드를 사용해서 페이지의 스크롤을 조작하는 코드를 작성했다. puppeteer의 page.evaluate() 메서드는 웹 페이지의 컨텍스트에서 javascript 코드를 실행해서 원하는 데이터를 가져오거나 조작할 수 있다.

 

그런데 여기서 문제가 발생했다. 다음 데이터를 가져오기 위해 스크롤을 내리도록 했는데 제대로 로딩이 되지 않은 상태에서 바로 스크롤을 내려버리니까 끝까지 스크롤 됐다고 인식이 되어버려서 해당 로직은 종료됐다. 스크롤 전에 puppeteer의 page.waitForSelector() 메서드를 사용하면 괜찮을줄 알았는데 스크롤 할 때마다 적용을 해줘야 했나보다. 그래서 스크롤 할 때마다 할 수 있도록 추가해줬는데 timeout 에러가 발생하거나 무한 대기 상태가 되어버렸다.

 

열심히 삽질한 결과 해결은 했다.

아래에 문제 원인과 해결 방법을 정리했다.

 

[원인]

SPA 환경에서는 페이지의 일부분이 동적으로 로딩되기 때문에 스크롤을 내린다고 해서 모든 데이터가 즉시 로드되지 않는다. 로딩되기 전에 스크롤을 내려봐도 document.body.scrollHeight는 변경되지 않는다.

 

[해결 방법]

최대 재시도 횟수(3회 정도)를 설정해서 스크롤하도록 하고 evaluate() 안에서 dom을 조작하여 스크롤 끝까지 내리는 로직을 setInterval()로 감싸서 3000ms 마다 실행하도록 했다. 어차피 매 시간마다 업데이트하도록 되어있기 때문에 데이터를 완전히 다 가져올 수 있는 걸 목표로 했다. 추후 속도 개선은 필요할 것 같다.

    // 비동기 이슈 때문에 최대 3회까지 재실행
    async autoScroll(page: Page, height: number): Promise<void> {
        let retryCount = 0;

        while (retryCount < this.limitRetryCount) {
            try {
                await this.scroll(page, height);

                return;
            } catch (err) {
                if (err instanceof Error) {
                    if (err.message.startsWith('scroll failed')) {
                        retryCount++;
                    }
                } else {
                    throw err;
                }
            }

            await delay(2000);
        }

        if (retryCount === this.limitRetryCount) {
            throw new Error('Maximum count exceeded');
        }
    }
    async scroll(page: Page, height: number): Promise<void> {
        await page.evaluate(async (height) => {
            await new Promise(async (resolve, reject) => {
                let totalHeight = 0;

                window.scrollTo(0, height);
                new Promise((resolve) => setTimeout(resolve, 1000));

                const timer = setInterval(async () => {
                    const scrollHeight = document.body.scrollHeight;
                    if (scrollHeight < height) {
                        reject(new Error('scroll failed'));
                    }

                    console.log(
                        `check - height: ${scrollHeight}, total: ${totalHeight}`
                    );

                    window.scrollBy(0, scrollHeight);

                    if (totalHeight === scrollHeight) {
                        clearInterval(timer);
                        resolve('success');
                    }

                    totalHeight = scrollHeight;
                }, 3000); // scrollBy보다 먼저 실행돼서 3000으로 수정함
            });
        }, height);
    }
  • 제일 처음 window.scrollTo(x, y)를 사용해서 초기 height 값을 설정해주고 현재 scorllHeight를 받아와서 window.scrollBy(0, scrollHeight) 메소드를 실행
  • 파라미터로 넘긴 초기 height 값보다 document.body.scrollHeight 값이 작으면 페이지가 제대로 로딩되지 않은 것이기 때문에 reject
  • 페이지가 제대로 로딩 됐다면 스크롤 내린 만큼 scrollHeight가 더해져야 하기 때문에 resolve() 되지 않고 끝까지 스크롤을 내리기
  • 스크롤 내리면서 저장한 totalHeight와 현재 document.body.scrollHeight가 같다면 interval 종료 후 resolve

참고

https://stackoverflow.com/questions/51529332/puppeteer-scroll-down-until-you-cant-anymore

 

Puppeteer - scroll down until you can't anymore

I am in a situation where new content is created when I scroll down. The new content has a specific class name. How can I keep scrolling down until all the elements have loaded? In other words, I w...

stackoverflow.com

 

업데이트 된 원티드 화면

[원인]

원티드 화면이 바뀜 -> HTML 코드가 변경됨

크롤링 할 때 가져오는 항목들이 사라지거나 구조 또는 class name이 바뀌뀌어서 기존에 가져오던 데이터들이 안 가져와지고 에러 발생함

[해결 방법(=임시방편)]

필요한 정보를 포함한 selector를 다시 찾고 구조를 파악한 다음 코드를 수정했다. 이건 정말 눈물나는 작업이다.

 

매번 원티드 사이트가 바뀔 때마다 수정해야 하는 걸까싶고, 앞으로 다른 사이트도 추가 할 건데 변경사항 추적 모니터링에 대해서도 생각해봐야할 것 같다. 방법 찾아보고 정리해서 글 올릴 것!

 

puppeteer 사용 시 console.log 안 찍히는 문제

자동 스크롤 함수가 실행 될 때 생각보다 좀 걸리기 때문에 확인할 수 있는 로그를 남겨야 했다.

그런데 page.evaluate() 메서드 안에서 콘솔로그가 제대로 안 찍히는 문제가 발생했다.

 

[원인]

page.evaluate() 메서드는 브라우저 내부 환경에서 실행되기 때문에 서버와는 독립적인 환경에서 작동하므로 서버 측 로그에는 영향을 주지 않음

[해결 방법]

page의 'console' 이벤트로 브라우저에서 찍히는 콘솔로그를 가져올 수 있다. 브라우저에서 다양한 로그가 찍히기 때문에 내가 원하는 로그만 가져올 수 있도록 로그 메시지 앞에 'check'를 붙여서 서버 로그로 찍을 수 있도록 했다.

    async setPage(browser: Browser, url: string): Promise<Page> {
        const page = await browser.newPage();

        // user agent 설정 해줘야 403 안 뜸
        await page.setUserAgent(new userAgent().random().toString());
        await page.goto(url);

        await delay(2500);

        page.on('console', (msg) => {
            const msgText = msg.text();
            if (msgText.startsWith('check')) {
                console.log('received msg:', msgText);
            }
        });

        return page;
    }

 

아직 몇 가지 기능 밖에 실행되지 않지만 프로그램은 잘 돌아간다!

아래 이미지들은 디스코드 서버에 봇을 추가하고 캡처한 것이다.

MVP 기능만 추가 돼서 커맨드는 필요한 것만 추가해놨다.

채용공고가 너무 긴 경우 보기 힘들어서 해당 공고 상세내용 링크를 걸고 글자수에 제한을 뒀다.

 

다음 글은 도커와 배포 자동화에 대한 내용을 다뤄보겠다.