백엔드 개발 포트폴리오 | IT 채용공고 서비스

내일배움캠프 4기 웹 개발 과정 Node.js 트랙 수료생 최종 프로젝트 '잡핏'을 소개합니다.
Aug 16, 2023
백엔드 개발 포트폴리오 | IT 채용공고 서비스

👔 JOBFIT

여러 채용공고 사이트들의 IT 분야 채용공고를 한 곳에 모아 원하는 조건에 맞는 채용공고를 검색하고, 회원의 정보를 바탕으로 알고리즘을 통해 채용공고를 추천 받을 수 있는 서비스.
 
 

🛠️ 서비스 아키텍처

notion image
 

🔧 주요 기술 스택기술적 의사결정

NestJS

모듈의 의존성 주입을 기반으로 하는 프레임 워크로, 컨트롤러, 서비스 간의 의존성을 관리하고, 코드의 유지 보수테스트에 용이하다. 또한, TypeScript를 도입하여 개발 시 발생하는 오류들을 사전에 방지할 수 있다.

TypeORM

JavaScript 와 TypeScript를 모두 지원하며 타입 체크를 할 수 있다.
객체지향 프로그램과 관계형 데이터베이스를 매핑하여 추상화 계층을 제공하여 쿼리를 간결하고 명료하게 작성 할 수 있다.

Axios

비동기 HTTP 통신 라이브러리. API를 확인 할 수 있는 원티드와 프로그래머스 채용 공고의 데이터를 크롤링 하기 위해 사용.

Cheerio

HTML 과 XML 문서를 파싱하고 조작하기 위한 라이브러리. JQuery와 비슷한 방식으로 DOM을 조작 할 수 있으며, 문서를 쉽게 스크래핑 할 수 있다. Axios로 사람인 채용 공고에 접속하고 Cheerio 를 사용하여 스크래핑 하기 위해 사용.

Selenium

웹 브라우저를 제어하고 웹 어플리케이션을 자동화하는 데 사용되는 라이브러리. 브라우저를 직접 실행해서 크롤링을 하므로 자원을 많이 하고 Axios와 Cheerio 보다 느린 특징을 가지고 있다. 사람인 채용 공고의 상세 페이지는 페이지 렌더가 완료 된 후 데이터 스크래핑을 해야하므로 Selenium을 채택했다.

Redis

In-Memory 기반의 데이터 저장소로, 데이터를 디스크에 저장하지 않고 메모리에 저장 해 I/O 작업을 수행하지 않아 빠른 데이터 처리 속도를 제공한다.
우리 서비스는 많은 채용공고를 사용자에게 제공하는 것이 목적이므로 디스크 접근 횟수를 줄이고 조회 속도를 향상시키기 위해 사용한다.

AWS ElastiCache

Amazon Web Service 에서 제공하는 관리형 캐시 서비스. In-Memory 데이터 저장소를 제공하며, Redis 와 같은 인기있는 오픈 소스 캐시 엔진을 제공한다.

AWS RDS

Amazon Web Service 에서 제공하는 관리형 데이터베이스다.
MySQL, PostgreSQL과 같은 인기있는 관계형 데이터베이스 엔진을 제공한다.

AWS Elastic Beanstalk

Amazon Web Service 에서 제공하는 PaaS 이며, EC2 인스턴스를 생성하고 배포 자동화를 지원하며, 인스턴스 자동 Scaling 이 가능하고, 어플리케이션 및 인프라의 모니터링이 가능하다.
우리 서비스에는 AWS ElastiCache 와 RDS를 사용하므로 호환성을 높이고 같이 관리를 하기위해 선택하였다.

AWS CodePipeline

Amazon Web Service 에서 제공하는 지속적인 통합 배포 서비스 (CI/CD) 이며, 코드 변경사항을 감지하여 빌드, 테스트, 베포 프로세스를 자동화한다.
AWS Elastic Beanstalk 에 배포 자동화를 하기 위해 사용한다.
 

🔎 주요 기능

카카오 소셜 로그인

PassportStrategy를 이용한 카카오 인증 전략을 구현하여, 카카오 계정으로 회원가입 및 로그인을 할 수 있다.
notion image

기술 스택 선택

로그인 한 회원이 마이 페이지에 접속하여, 회원이 선호하는 기술 스택을 검색하고 추가 할 수 있다.
이 때 선택한 기술 스택들은 채용 공고 추천 알고리즘에 사용 된다.
notion image

주소 선택

다음 주소 API (우편번호 서비스) 를 통해 회원의 주소를 등록한다. 반환 된 정확한 주소를 카카오 로컬 API에 다시 입력하여, 시/도, 시/군/구, 위도와 경도를 반환 받아 데이터베이스에 업데이트한다.
회원의 주소는 채용 공고 추천 알고리즘에 사용 된다.
notion image

채용 공고 찜 하기 / 찜 취소하기

채용공고 목록, 상세 페이지에서 채용공고 이미지 우측 상단의 별⭐ 버튼을 누르면 찜 하고 취소를 할 수 있다.
회원이 찜 한 채용 공고는 추천 알고리즘에 사용 된다.
notion image

채용 공고 검색

  • Header 검색창 이용
    • 입력한 단어를 회사 이름, 키워드 기술 스택, 주소, 공고 제목, 공고 내용 에 매칭 시켜 검색
      notion image
  • 필터 이용
    • 주소, 키워드, 기술 스택을 선택하여 검색
      notion image

채용 공고 상세 페이지 확인

채용 공고 목록에서 제목을 누르면 상세 페이지로 접속 할 수 있다.
notion image
 

채용 공고 크롤링

  • Axios, Cheerio, Selenium 라이브러리를 사용하여 원티드 / 프로그래머스 / 사람인 채용 공고 리스트를 크롤링
  • NestJS 의 Task Scheduling 기능인 Cron 데코레이터를 이용하여 프로그래머스 채용 공고는 매일 새벽 3시, 원티드 채용 공고는 매일 새벽 4시, 사람인 채용 공고는 매일 새벽 5시에 크롤링을 하도록 설정

공고 추천 알고리즘

  • SQL 쿼리문을 이용해서 추천 점수를 계산
  • 계산 방식:
      1. 공고에 기재된 주소와 내 주소의 거리는 두 주소의 경도와 위도를 Haversine 공식을 사용해 계산
      1. 유저가 등록한 기술스택 중에 공고가 몇 개 일치 되는지 확인
          • 예: 유저가 NestJS, Javascript, Typescript를 등록했는데 공고의 기술스택은 NestJS, Javascript, Java일 때 2개 일치
      1. 유저가 찜한 공고의 키워드 중에 다른 공고의 키워드는 몇 개 일치 되는지 확인
          • 예: 유저가 찜한 공고는 1번과 2번이다. 1번 공고의 키워드는 신입과 IT이고 2번 공고의 키워드는 신입과 QA이다. 유저가 찜한 공고의 키워드들은 신입, IT와 QA이다. 다른 공고 4번의 키워드는 신입, 계약직, 고졸일 시 1개의 키워드만 일치한다.
      1. 추천 요소를 정규화하기 위해서 최대값과 최소값을 구한다
      1. 추천 점수를 계산하기 위해 모든 요소를 Min-Max 정규화를 한다.
    • 주소만 정규화 함수의 결과에서 1을 뺀다. 주소가 가까울 수록 점수가 높아야되기 때문이다.
      1. 각 요소에 비중을 줘서 추천 점수를 계산한다.
      위 함수의 변수:
    • stackMatches: 유저의 기술스택이랑 일치하는 수
    • distance: 유저의 거리와 공고에 기재된 주소의 거리
    • keywordMatches: 유저의 찜한 공고의 키워드랑 일치하는 수
    • salary: 공고에 기재된 연봉
    • avgSalary: 공고를 올린 회사의 평균 연봉
 

📌 트러블 슈팅

Axios, Cheerio VS Selenium

  • 사람인 채용 공고 사이트를 크롤링 시, Axios 와 Cheerio를 사용하여 채용 공고 리스트를 스크랩 하고 상세 페이지를 다시 Axios로 로드 하는 과정에서, 빈 값이 출력하는 문제 발생.
  • 속도는 느리지만 확실히 스크랩하기 위해 Selenium 으로 변경.

  • 원티드 채용 공고 사이트를 크롤링 시, 무한 스크롤로 구현되어 있어 Selenium 을 사용 하기에 너무 느림.
  • 원티드 채용 공고 API 문서를 찾아 Axios 만 이용하여 속도 개선.

크롤링 시도 중, 403 ( 접근 권한 없음 ) 에러

  • 원티드 채용 공고 사이트를 한 번 크롤링 할 때 마다 5분만에 1만5천건의 GET 요청을 하다 보니, IP 가 차단되는 문제가 발생.
  • HTTP Header 가짜 유저 에이전트와 프록시 서버를 사용 했지만 접속하는 IP는 동일하여 여전히 크롤링 불가능.
  • VPN 으로 IP 우회 시, 사이트 접속 가능.
  • 원티드 채용 공고 사이트의 크롤링을 트래픽이 적은 새벽으로 Cron을 설정하였고, 하루에 한번 하도록 설정.

크롤링 시, 중복된 채용 공고 데이터에 대한 처리

  • 한 회사가 여러 채용 공고 사이트에 같은 공고를 올렸을 경우, 크롤링 시 제공되는 데이터의 종류와 양식이 다른 문제가 발생.
  • 중복된 채용 공고의 경우, 마지막으로 크롤링 한 사이트의 데이터로 무분별하게 업데이트 되는 문제가 발생하여 데이터의 손실이 일어남.
  • 채용 공고의 제목과 회사의 고유번호를 Unique Index로 설정하여 중복체크를 진행.
  • 중복인 경우, orUpdate() 메서드를 사용하여 특정 컬럼에 대한 업데이트 동작이 이루어 지도록 해결.

삭제된 채용 공고에 대한 동기화

  • 중복된 채용 공고 데이터는 orUpdate() 메서드를 사용하여, 이미 DB에 존재하는 공고가 더 이상 크롤링 되지 않는다면 업데이트 날짜가 갱신되지 않음. 하지만, 중복된 채용공고가 크롤링 되어도 특정 칼럼의 값이 바뀌지 않는다면, 업데이트 날짜가 갱신되지 않는 문제 발생.
  • 이미 DB에 공고가 존재하는지 findOne() 메서드로 확인하는 작업을 거치도록 변경. 존재한다면, 특정 컬럼 값이 변화가 없더라도 무조건 업데이트를 진행.
  • 업데이트 날짜가 갱신되지 않는다면, 삭제된 채용 공고로 판단.

찜 하기 기능에서 Redis 를 이용한 캐싱 전략

찜 기능에서 캐싱을 적용하기로 선택한 이유
  • 채용 공고를 찜 해두고 볼 수 있는 기능은 중요한 기능 중 하나.
  • 추천 알고리즘의 중요한 데이터로 작용.
  • 많은 사용자들이 채용공고를 찜 할 수 있고 자주 사용되는 기능 일 것이라 예상.
  • DB의 접근 횟수를 줄이고 속도를 빠르게 해보자는 생각.
프론트엔드 에서의 기존 코드와의 차이
변경 전 코드
function postLike(userId, jobpostId) { $.ajax({ type: 'POST', url: '/api/jobpost/like', async: false, data: { userId: userId, jobpostId: jobpostId }, success: function (response) { if (response['message'] === 'success') { let starTag = $(`#${jobpostId}`) if (starTag.css('color') === 'rgb(255, 234, 0)') { starTag.css('color', 'rgba(69, 87, 117, 0.8)') starTag.removeClass('liked') } else { starTag.css('color', '#FFEA00') starTag.addClass('liked') } } }, }) }
변경 후 코드
// 찜 하기 function createJobpostLike(jobpostId) { const userId = Number('<%- user?.userId %>') axios .post(`/api/jobpost/${jobpostId}/like`, { userId }) .then((res) => { alert(res.data.message) location.reload() }) .catch((err) => { alert(err.response.data.message) location.reload() }) } // 찜 삭제 function deleteJobpostLike(jobpostId) { const userId = Number('<%- user?.userId %>') axios .delete(`/api/jobpost/${jobpostId}/like`, { data: { userId } }) .then((res) => { alert(res.data.message) location.reload() }) .catch((err) => { alert(err.response.data.message) location.reload() }) }
하나의 함수가 하나의 기능을 처리하도록 했기 때문에, 처리 속도에 영향을 미칠 것이라 예상.
찜 하기 로직
변경 전 코드
async postLike(userId: number, jobpostId: number) { let likes = await this.cacheService.getLikedjobpost(jobpostId, userId) if (likes == 0) { await this.cacheService.setLikedjobpost(jobpostId, userId) setTimeout(() => { this.jobpostRepository.insertLike(userId, jobpostId) }, 1000) } else { setTimeout(() => { this.jobpostRepository.deleteLike(userId, jobpostId) }, 1000) } return 'success' }
변경 후 코드
// 찜 하기 async createJobpostLike(userId: number, jobpostId: number) { try { await this.cacheService.setLikedjobpost(jobpostId, userId) return { message: '찜 목록에 추가했습니다.' } } catch (err) { console.log(err) return { message: '찜 목록 추가에 실패했습니다.' } } }
  • 기존의 코드는 캐시에 이미 존재하는지를 확인하고 존재하면 DB에서 제거, 존재하지 않는다면 캐시데이터를 생성한 후 DB에 저장. → 한번 호출 당 평균 100ms 정도의 시간
  • 기존의 방식은 1초의 지연 시간 후 DB에 반영하는 작업이므로 캐싱의 의미가 없다고 판단.
  • 변경 후, 찜 하기 기능은 캐싱만 하는 역할로 수정 → 평균 48ms 의 시간.
  • 누적된 데이터를 Cron scheduling 을 이용하여 매 5초마다 DB에 반영하는 함수를 구현.
  • Set 형태로 되어있는 캐싱된 데이터를 쿼리에 맞게 문자열로 변환 후, insert 쿼리를 한 번의 호출로 여러 데이터를 한 번에 저장 하게 설계
  • 5초간 누적 시킨 데이터를 5초마다 DB에 1번 접근하여 저장하도록 개선됨.

찜 하기 기능 캐싱 전략으로 인한 프론트엔드 싱크 문제

  • 찜 하기를 하고 데이터가 캐싱되어있고 DB에 반영되지 않았을 때, 화면에서는 찜 하기가 되지 않은 것으로 보이는 문제 발생.
  • 반영이 된 것 처럼 나타내기 위해, Client-Side Caching 처리.
  • 로그인 한 유저가 어떤 채용 공고를 찜 했는지 가져오는 동작에서 캐시를 한번 체크 하고, DB에 반영되어있는 리스트와 합쳐서 클라이언트에게 전달하도록 수정.

조회 기능에서 Redis 를 이용한 캐싱 전략

조회 기능에서 캐싱을 적용하기로 선택한 이유
  • 조회수는 찜과 더불어 추천 알고리즘의 중요한 데이터로 작용.
  • 많은 사용자들이 채용공고를 조회하고 반드시 사용되는 기능일 것이라 예상
  • DB의 접근 횟수를 줄이고 속도를 빠르게 해보자는 생각
조회 기능 로직
Redis
async getViewCount(jobpostId: number) { return await this.redisClient.hget('views', jobpostId.toString()) } async remViewjobpost() { return await this.redisClient.del('views') } async setViewCount(jobpostId: number, userId: number) { let pipe = this.redisClient.pipeline() if (!userId) { //비회원 조회 userId = 0 } let count = await this.redisClient.getbit(jobpostId.toString(), userId) if (count != 1) { pipe.setbit(jobpostId.toString(), userId, 1).expire( jobpostId.toString(), 5 ) pipe.exec() await this.addCountOne(jobpostId) } } async addCountOne(jobpostId: number) { let count = Number(await this.getViewCount(jobpostId)) if (count > 0) { await this.redisClient.hincrby('views', jobpostId.toString(), 1) } else { await this.redisClient.hset('views', jobpostId.toString(), 1) } }
  • 조회수 기능을 위해 bitmap(공고id, 유저id, 0 or 1)과 hash(공고마다 전체 조회수)를 redis에 구현
  • 공고 페이지를 들어갈 경우 setViewCount로 해당 유저(비회원은 0)를 조회한 공고 bitmap 데이터가 없어야 5초 만료 시간을 가진 bitmap을 생성 후 hash에 해당 공고의 조회수 증가
변경 전 조회 로직
  • Redis의 조회 데이터를 DB에 그대로 insert한다.
  • 이 방법은 Redis의 조회수와 DB의 조회수가 일치하기 때문에 Redis 데이터를 지울 수가 없었다.
조회 업데이트
@Cron('*/5 * * * * *') async jobpostViewInsert() { const viewJobposts = Object.entries( await this.cacheService.getAllViews() ) if (viewJobposts.length === 0) return const values = viewJobposts .map(([jobpostId, viewCount]) => `${jobpostId}, ${viewCount}`) .join('/') await this.jobpostRepository.updateView(values) await this.cacheService.remViewjobpost() console.log('조회수 redis 제거완료') }
async updateView(views: string) { const viewCounts = views.split('/').map((view: string) => { const [id, count] = view.split(', ') return { jobpostId: parseInt(id), views: parseInt(count) } }) for (const { jobpostId, views } of viewCounts) { await this.jobpostRepository.increment( { jobpostId }, 'views', views ) }
  • 누적된 데이터를 Cron scheduling 을 이용하여 매 5초마다 DB에 반영하는 함수를 구현.
  • 전체 조회수가 담긴 hash 데이터를 가져와 typrorm의 increment를 통해 기존 DB의 조회수를 합친다.

배포된 사이트에서 스크레이핑 함수 실행 문제

문제 프로그래머스 스크레이퍼는 ~5분, 원티드 스크레이퍼는 8~55분, 사람인 스크레이퍼는 몇 시간을 걸쳐야 실행이 완료된다. AWS Elastic Beanstalk으로 배포된 EC2 Linux 인스턴스에서는 기본적인 load balancer의 idle timeout 설정과 nginx 설정이 60초 안에 요청이 끝나지 않으면 요청을 종료시키게 되어있다. 스크레이퍼 함수를 끝까지 완료시키기 위해 load balancer의 idle timeout은 성공적으로 수정되었지만 nginx의 timeout 설정 수정이 적용이 안 되어서 스크레이퍼가 실행이 안 되었다.
해결
Cron job으로 실행되는 함수는 다른 EC2 Windows 인스턴스로 배포해서 Elastic Beanstalk에 배포된 사이트와 같은 AWS RDS DB에 스크레이핑 데이터를 저장했다. EC2 Windows 인스턴스에서는 load balancing이 적용될 필요가 없어서 다른 설정을 수정 필요가 없었다.

메인 페이지 최신 공고, 곧 마감 공고 캐싱으로 로드 속도 개선

  • 메인 페이지의 캐싱이 필요한 이유
    • 현재 우리 서비스에 저장된 채용 공고 데이터가 약 24000개
    • 메인 페이지는 가장 많이 접근하는 페이지이고, 메인 페이지의 인기 공고, 최신 공고, 곧 마감하는 공고의 12개 데이터를 매번 전체 채용공고를 돌면서 가져오고 있음.
    • 데이터의 양이 늘어날 수록, 로딩 시간이 느려짐.
  • 캐싱 전
    • 약 8000건의 최신 공고 및 곧 마감하는 공고 처리 시간.
      • 캐싱 전 최신 공고
        캐싱 전 최신 공고
        캐싱 전 곧 마감하는 공고
        캐싱 전 곧 마감하는 공고
  • 메인 페이지의 최신 공고, 곧 마감하는 공고를 캐싱 대상으로 정한 이유
    • 인기 공고는 채용 공고의 찜 수 와 조회수의 영향을 받아 자주 바뀌는 데이터.
    • 최신 공고와 곧 마감하는 공고는 크롤링 시점에 영향을 받아 최소 하루 정도의 기간에는 변하지 않는 데이터.
    • 모든 채용공고의 크롤링 끝나는 시점으로 부터 만료시간을 1일으로 설정.
  • 캐싱 후
    • 약 8000건의 최신 공고 및 곧 마감하는 공고 처리 시간.
      • 캐싱 후 최신 공고
        캐싱 후 최신 공고
        캐싱 후 곧 마감하는 공고
        캐싱 후 곧 마감하는 공고
  • 캐싱으로 DB 접근을 줄이고, 로드 속도를 높여 데이터의 양이 많아 질 수록 효과가 기대됨.
 

💉 사용자 피드백 개선

채용공고 데이터 로딩 스피너 적용

  • 채용공고 검색 후 로드하는 시간이 걸리다 보니 기능이 작동하지 않는 것 처럼 보이는 문제
  • Bootstrap 의 Spinner 기능을 사용하여 적용
    • notion image

일부 공고 불러오는 속도 개선하기 위해 캐싱 적용

DB에 공고량이 많아질수록 공고 불러오는 속도가 점점 느려졌다.
이러한 문제를 해결하기 위해 메인페이지에 자주 안 바뀌는 ‘최신순’과 ‘마감순’으로 정렬된 공고를 캐싱처리했다.
 
캐싱을 적용해서 개선한 수치는 트러블슈팅 참고:
 
 
Share article
Subscribe to our newsletter

내일배움캠프 블로그