티스토리 뷰

왜 만들었나

이직을 준비하면서 가장 귀찮았던 게 채용 공고 확인이었다. 원티드, 잡코리아, 사람인... 매일 세 개 사이트를 돌아다니면서 새 공고가 올라왔는지 확인하고, 괜찮은 건 따로 메모하고. 하루 이틀이면 괜찮은데 몇 주 계속하다 보니 불필요한 시간이 소요되는것도 아깝고 이건 사람이 할 일이 아니라는 생각이 들었다.

그래서 자동화 시스템을 직접 만들었다.

Hire Monitor — 세 개 플랫폼의 채용 공고를 자동으로 긁어 와서, 하나의 대시보드에서 확인하고 스크랩할 수 있는 시스템

기술 스택

분류 기술
프레임워크 Next.js 15 (App Router)
언어 TypeScript (strict mode)
데이터베이스 Supabase (PostgreSQL)
스크래핑 Cheerio
자동화 GitHub Actions (매일 오전 9시 실행)
배포 Vercel

핵심 기능

1. 멀티 플랫폼 스크래핑

원티드, 잡코리아, 사람인 세 플랫폼에서 채용 공고를 수집한다. 각 플랫폼마다 데이터를 가져오는 방식이 달라서 스크래퍼를 플랫폼별로 분리하고, 하나의 통합 스크립트(`scrape.ts`)에서 세 스크래퍼를 순차적으로 실행하고 결과를 모아서 처리하도록 했다. 

hire-monitor/
└── src/
    └── features/
        └── job-posting/
            └── scraper/
                ├── jobkorea.ts
                ├── saramin.ts
                └── wanted.ts

다음 각각의 fetch...Jobs 함수를 임포트해서 순차적으로 호출하고, 그 결과를 ScrapeResult[]라는 통일된 형식으로 통합한다.

/ hire-monitor/scripts/scrape.ts

import { fetchJobKoreaJobs } from "../src/features/job-posting/scraper/jobkorea";
import { fetchSaraminJobs } from "../src/features/job-posting/scraper/saramin";
import { fetchWantedJobs } from "../src/features/job-posting/scraper/wanted";

// ...

async function scrapeAll(): Promise<ScrapeResult[]> {
  // ...
  const jobs = await fetchWantedJobs({ keyword, limit: 100 });
  // ...
  const jobs = await fetchSaraminJobs({ keywords: keyword, count: 100 });
  // ...
  const jobs = await fetchJobKoreaJobs({ keyword, maxPages: 5 });
  // ...

원티드는 공개 API가 있어서 비교적 수월했다. /api/v4/jobs 엔드포인트에 키워드, 경력, 지역 등의 파라미터를 넘기면 JSON으로 깔끔하게 응답이 온다. 

사람인도 오픈 API를 제공하고 있어서 API 키만 발급받으면 된다. XML 기반 응답이라 파싱이 조금 다르지만 큰 어려움은 없었다. 문제는 잡코리아파싱하는거였다.

2. 잡코리아 RSC 스트리밍 파서

잡코리아는 일반적인 REST API가 아니라 Next.js의 RSC(React Server Components) 스트리밍 형식으로 데이터를 내려준다. 브라우저 개발자 도구에서 네트워크 탭을 보면 self.__next_f.push([...]) 형태의 스트리밍 청크가 날아오는 걸 볼 수 있다.

이걸 파싱하기 위해 커스텀 파서를 만들었다:

// hire-monitor/src/features/job-posting/scraper/jobkorea.ts

/** RSC streaming payload에서 검색 결과 추출 */
function extractSearchResults(html: string): RawJobKoreaItem[] {
  // 1. self.__next_f.push(...) 형태의 데이터 덩어리를 정규식으로 찾습니다.
  const regex = /self\.__next_f\.push\(\[[\d,]*"(.+?)"\]\)/gs
  let match
  while ((match = regex.exec(html)) !== null) {
    // 2. 이스케이프된 문자열을 원래대로 복원합니다.
    const chunk = match[1]
    const unescaped = chunk
      .replace(/\\"/g, '"')
      .replace(/\\n/g, '\n')
      // ...

    // 3. 채용 공고 목록이 담긴 'content' 배열의 시작점을 찾습니다.
    const pattern = /"pageSize":20,"pageNumber":\d+,"totalElements
  1. 스트리밍 청크에서 self.__next_f.push(...) 패턴을 추출
  2. 이스케이프된 JSON 문자열을 언이스케이프
  3. 중첩된 따옴표와 이스케이프를 정확하게 처리하는 상태 머신으로 JSON 경계를 찾음

처음에는 정규 표현식으로 JSON 블록을 추출하려 했다. 그런데 RSC 페이로드 안에는 JSON 안에 JSON이 들어 있고, 그 안에 이스케이프된 따옴표가 중첩되어 있다. {\"key\": \"{\\\"nested\\\": true}\"} 같은 구조에서 정규식은 어디가 진짜 JSON의 끝인지 판단할 수 없다. 실제로 정규식 버전을 먼저 만들었는데, 10건 중 3~4건에서 JSON 파싱 에러가 났다. 이스케이프가 3중으로 중첩된 케이스에서 닫는 괄호 위치를 잘못 잡는 거였다.

결국 findJsonEnd()라는 상태 머신 기반 함수를 직접 구현했다. 문자 하나씩 순회하면서 현재 문맥이 문자열 안인지 밖인지, 이스케이프 상태인지를 추적하는 방식이다. 정규식 3줄로 될 것 같았던 작업이 상태 머신 50줄짜리 함수가 됐다.

이 파서 하나 때문에 잡코리아 스크래퍼 개발에 전체의 절반 이상의 시간을 썼다. 하지만 한번 안정화되고 나니 이후로는 깨진 적이 없다. "간단해 보이는 파싱이 왜 어려운가"를 온몸으로 체험한 기능이었다.

3. GitHub Actions 자동화

GitHub Actions로 매일 오전 9시(KST)에 스크래핑이 자동 실행하도록 구성했다.

name: 일일 채용 공고 수집

on:
  schedule:
    # 매일 한국시간 오전 9시 (UTC 0시)
    - cron: '0 0 * * *'
  workflow_dispatch:

permissions:
  contents: read
  issues: write

jobs:
  scrape:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - run: npm ci

      - name: 채용 공고 수집 및 알림
        run: npx tsx scripts/scrape.ts
        env:
          NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
          NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }}
          SARAMIN_API_KEY: ${{ secrets.SARAMIN_API_KEY }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GITHUB_REPOSITORY: ${{ github.repository }}
          GOOGLE_SHEETS_WEBHOOK_URL: ${{ secrets.GOOGLE_SHEETS_WEBHOOK_URL }}

실행 흐름은 이렇다:

  1. 여러 키워드로 세 플랫폼을 동시에 스크래핑
  2. URL 기준으로 중복 제거
  3. DB에 이미 있는 공고 필터링
  4. 개발 직군이 아닌 공고 필터링
  5. 회사 규모(대기업 > 중견기업 > 중소기업)와 마감일 기준 정렬
  6. Supabase에 저장
  7. GitHub Issue로 새 공고 알림 생성
  8. Google Sheets 웹훅으로 동기화

아침에 일어나면 GitHub 알림으로 오늘 새로 올라온 채용 공고를 확인할 수 있다. 더 이상 세 사이트를 직접 돌아다닐 필요가 없어졌다.

4. 대시보드

대시보드에서는 수집된 모든 공고를 한눈에 볼 수 있다.

  • 플랫폼별 필터: 원티드/잡코리아/사람인 토글
  • 검색: 키워드 검색
  • 상세 필터: 경력(신입, 1년+, 3년+...), 지역, 기술 분야(프론트엔드, 백엔드, 풀스택...)
  • 스크랩: 관심 있는 공고를 별표로 저장
  • 스크랩북: 저장한 공고만 모아보기

Server Actions를 사용해서 API 라우트 없이 서버 사이드에서 직접 데이터를 처리한다. 스크랩 토글 같은 인터랙션은 낙관적 업데이트(Optimistic Update)를 적용해서 즉각적인 피드백을 제공한다.

아키텍처

프로젝트 구조는 기능 기반(Feature-Based Architecture)으로 설계했다.

src/
├── app/                          # Next.js App Router (진입점)
│   ├── page.tsx                  # SSR 홈페이지
│   ├── _components/Dashboard.tsx # 대시보드 UI
│   └── actions.ts                # Server Actions
│
├── features/job-posting/         # 채용 공고 도메인
│   ├── types/                    # 타입 정의
│   ├── api/                      # Supabase CRUD
│   ├── scraper/                  # 플랫폼별 스크래퍼
│   │   ├── wanted.ts
│   │   ├── jobkorea.ts
│   │   └── saramin.ts
│   └── constants/                # 필터 옵션
│
└── shared/                       # 공통 유틸리티
    ├── api/                      # HTTP 클라이언트, Supabase 인스턴스
    └── types/                    # 공통 타입

의존성 흐름은 app/ → features/ → shared/ 한 방향으로만 흐른다. 각 레이어가 하위 레이어에만 의존하기 때문에 나중에 플랫폼을 추가하거나 기능을 확장할 때 영향 범위가 명확하다.

데이터베이스 설계

Supabase(PostgreSQL)에 job_postings 테이블 하나로 심플하게 구성했다.

필드 타입 설명
id UUID 자동 생성 PK
title Text 채용 포지션명
company Text 회사명
platform Text 플랫폼 (wanted / jobkorea / saramin)
url Text 원본 URL (유니크 제약조건)
location Text 근무지
deadline Timestamp 마감일
tags Text[] 경력, 산업, 기술스택 태그
starred Boolean 스크랩 여부
created_at Timestamp 수집 시각

URL에 유니크 제약조건을 걸고 upsert를 사용해서 중복 공고가 저장되지 않도록 했다.

처음에는 Supabase 대시보드에 직접 들어가서 브라우저에서 테이블을 만들고 컬럼을 추가하는 방식으로 작업했다. 문제는 AI(Claude Code)로 개발하면서 테이블 만들달라고 명령하면 결국 내가 직접 브라우저를 열어서 수동으로 해야 한다는 점이었다. 개발 흐름이 끊기는 게 불편해서, Supabase MCP(Model Context Protocol) 서버를 연결했다.

 

Claude Code에 DB 접근 권한을 주니까 브라우저에 직접 접근하지 않고도 테이블 생성, 스키마 변경, 마이그레이션까지 대화하면서 자동으로 처리할 수 있게 됐다.

AI와 함께 개발할 때는 이런 도구 연동이 생산성에 큰 차이를 만든다.

 

AI와 함께 설계하고 구현한 과정

이 프로젝트는 처음부터 Claude Code와 함께 만들었다. 단순히 "코드 짜줘"가 아니라, 아키텍처 설계부터 기술 선택, 구현, 리팩터링까지 전 과정을 AI와 대화하면서 진행했다.

1단계: 로드맵 설계

처음에 Claude에게 프로젝트 목적과 요구사항을 설명하고, 개발 로드맵을 함께 잡았다. 데이터 수집 파이프라인, DB 선택, UI 구현, 자동화까지 4단계 로드맵이 나왔고, 각 단계마다 기술 선택지와 장단점을 비교했다.

DB 선택에서는 SQLite + Prisma, Supabase, JSON 파일 세 가지 옵션을 비교했다. SQLite는 가볍고 별도 서버가 필요 없다는 장점이 있었지만, GitHub Actions에서 매일 자동으로 스크래핑 결과를 저장해야 하는 요구사항을 고려하면 원격 접근이 가능한 Supabase가 더 적합하다고 판단했다. 무료 티어로도 충분하고, 나중에 대시보드에서 실시간 데이터를 바로 조회할 수 있다는 점도 맞았다.

2단계: 멀티 세션으로 기능별 개발

처음에는 하나의 세션에서 전부 진행하려 했다. 그런데 스크래퍼 3개, DB 설계, UI, 자동화까지 한 세션에 쌓다 보니 문제가 생겼다. 예를 들어 GitHub Actions 자동화를 만들다가 "Supabase 테이블 구조가 어떻게 되어 있었지?"라고 물으면, 한참 전에 결정한 스키마를 부정확하게 기억하거나 아예 다른 컬럼명을 사용하는 식이었다. AI의 컨텍스트 윈도우에는 한계가 있어서, 대화가 길어질수록 앞부분의 맥락이 흐려진다.

그래서 전체 프로세스를 관리하는 메인 세션을 하나 두고, 각 기능별 개발은 개별 세션에서 진행하는 구조로 바꿨다. 원티드 스크래퍼, 잡코리아 RSC 파서, DB 설계, GitHub Actions 자동화 등을 각각 독립된 세션에서 만들고, 메인 세션에서 전체 흐름을 잡고 다음 방향을 결정했다.

체감 차이가 컸다. 한 세션에서 모든 걸 하던 때는 중간중간 "아까 그거 다시 확인해봐"라는 수정이 잦았는데, 세션을 분리한 뒤에는 각 기능의 완성도가 눈에 띄게 올라갔다. 문제가 생겼을 때도 해당 세션만 다시 시작하면 되니까 디버깅이 훨씬 수월했다. AI를 잘 쓰는 건 프롬프트를 잘 쓰는 것만이 아니라, 작업 단위를 어떻게 나누느냐의 문제이기도 하다.

3단계: 아키텍처 리팩터링

초기 구현은 page.tsx를 통째로 'use client'로 만든 CSR 구조였다. 동작은 했지만, 페이지에 접속하면 1~2초간 빈 하얀 화면이 먼저 뜨고 그 후에야 공고 목록이 나타났다. 채용 공고를 확인하려고 매일 여는 페이지인데, 매번 빈 화면을 보면서 기다리는 게 생각보다 거슬렸다.

AI(Claude)가 처음 만들어준 구조가 이 CSR 방식이었다. 동작하니까 넘어갈 수도 있었는데, "Next.js를 쓰면서 SSR을 안 쓴다고?" 하는 의문이 들었다. 이건 AI가 잡아주지 않은 부분이었고, 아키텍처 방향은 결국 사람이 판단해야 한다는 걸 느꼈다.

Server Component + Server Actions 구조로 리팩터링한 뒤에는 페이지를 열자마자 공고 목록이 바로 보였다. 체감 차이가 확실했다. API Route 3개를 삭제하고 Server Actions 3개로 대체하면서 코드도 간결해졌지만, 진짜 의미 있는 변화는 코드량이 아니라 매일 쓰는 페이지의 첫인상이 달라진 것이었다.

4단계: 자동화 파이프라인

대시보드를 고도화하는 것과 자동화 파이프라인을 만드는 것 중 뭘 먼저 할지 고민했는데, Claude와 논의한 결과 "대시보드 UI 개선보다 자동화가 실제 가치 전달이 더 빠르다" 는 결론이 나왔다. 이 판단이 정확했다. 매일 아침 GitHub 알림으로 새 공고를 받아보는 경험은 대시보드를 예쁘게 만드는 것보다 훨씬 실용적이었다.

이런 식으로 AI와 대화하면서 의사결정을 문서화하고, 각 단계에서 다음 방향을 함께 정해나갔다. 의사결정 문서만 5개가 쌓였고, 이 문서들이 프로젝트의 "왜 이렇게 했는지"를 기록하는 역할을 했다.

기술적으로 고려했던 점

회사 규모 기반 정렬

자동 알림에서 공고를 보여줄 때, 단순히 최신순이 아니라 회사 규모를 기준으로 정렬한다.

Big Tech → Enterprise → Mid-size → 마감일 임박순

이직을 준비하는 입장에서 대기업 공고를 먼저 확인하고 싶은 건 자연스러운 욕구다. 이런 작은 정렬 로직 하나가 실제 사용 경험에 큰 차이를 만들었다.

Server Actions 활용

Next.js 15의 Server Actions를 적극 활용했다. 별도의 API 라우트를 만들지 않고도 서버 사이드에서 직접 Supabase를 호출할 수 있어서 코드가 훨씬 간결해졌다. API 키 같은 민감한 정보도 자연스럽게 서버에만 남는다.

GitHub Issue에서 Google Sheets로

처음에는 별도의 알림 서비스를 구축하는 대신 GitHub Issue를 알림 채널로 활용했다. GitHub Actions에서 자동으로 Issue를 생성하면 이메일 알림이 오고, 모바일에서도 바로 확인할 수 있어서 추가 인프라 비용 0원으로 괜찮은 방법이었다.

그런데 며칠 써보니 한계가 보였다. 매일 새 공고가 10~20건씩 들어오는데, Issue는 일자별로 하나씩 쌓이는 구조라서 일주일만 지나도 Issue가 7개다. "지난주에 봤던 그 공고 뭐였지?" 하면 Issue 7개를 하나씩 열어서 텍스트를 훑어봐야 했다. 플랫폼별로 묶어서 보거나, 마감일 순으로 정렬하거나, 지원 현황을 메모하는 것도 불가능했다. 알림 용도로는 충분하지만 데이터를 관리하기에는 부족했다.

그래서 Google Sheets 웹훅 연동을 추가했다. 스크래핑 결과를 구조화된 JSON으로 웹훅에 보내면, 스프레드시트에 자동으로 행이 추가된다. 이제 회사명, 포지션, 마감일, 플랫폼이 컬럼별로 정리되니까 필터링이나 정렬이 자유롭고, 지원 현황 컬럼을 추가해서 트랙킹하기도 편해졌다. GitHub Issue는 "오늘 새 공고가 몇 건 왔다"는 알림용으로 유지하고, 실제 관리는 Google Sheets에서 하는 이중 구조가 됐다.

운영 비용

이 프로젝트의 운영 비용은 월 0원이다.

  • Supabase: Free tier (500MB DB, 무제한 API 호출)
  • Vercel: Free tier (Hobby plan)
  • GitHub Actions: Public repo 무료 (Private도 월 2,000분 무료)

개인 프로젝트 수준에서는 완전히 무료로 운영할 수 있다.

회고

이 프로젝트는 이틀 만에 완성했다. 처음에는 "그냥 크롤러 하나 만들면 되겠지"라고 가볍게 시작했는데, 실제로 만들다 보니 생각보다 고려할 게 많았다.

  • 플랫폼마다 다른 데이터 형식과 API 구조
  • 잡코리아의 RSC 스트리밍 파싱이라는 예상치 못한 난관
  • 중복 제거, 필터링, 정렬 등 데이터 가공 로직
  • 자동화 파이프라인의 에러 핸들링

하지만 이런 문제들을 하나씩 해결해 나가는 과정이 재미있었고, 무엇보다 내가 실제로 매일 쓰는 도구를 만들었다는 점에서 만족스럽다.

숫자로 말하면, 만들기 전에는 매일 아침 30분 정도를 세 사이트 돌아다니면서 공고 확인하는 데 썼다. 지금은 아침에 GitHub 알림 한 번 확인하고, 괜찮은 공고는 Google Sheets에서 바로 체크한다. 5분이면 끝난다. 하루 25분, 한 달이면 12시간 넘게 절약되는 셈이다.

시간 절약보다 더 큰 건 놓치는 공고가 없어졌다는 점이다. 수동으로 확인할 때는 바쁜 날 빼먹기도 하고, 검색 키워드에 안 걸리는 공고는 아예 못 봤다. 자동화 이후에는 세 플랫폼의 모든 새 공고가 매일 빠짐없이 수집되니까, 실제로 "이걸 직접 찾았으면 절대 못 봤겠다" 싶은 공고를 잡아낸 적이 몇 번 있었다.

이틀 만에 만든 도구가 몇 달째 매일 돌아가고 있다. 결국 좋은 사이드 프로젝트란 거창한 기술을 쓰는 게 아니라, 자기 문제를 자기 손으로 해결하는 것이 아닐까.