상황
우리 회사에서는 네이버 블로그를 운영 중이다. 이 블로그에는 월 4-5회의 블로그 글이 업로드된다. 그리고 이 블로그 글을 기반으로 홈페이지 내 PR 페이지의 뉴스룸에 업로드가 된다. 이 뉴스룸에는 블로그 글의 썸네일 이미지와 내용 기반 3줄 요약이 포함된다. 이 뉴스룸의 글 업로드는 우리 팀에서 담당을 하고 있었다.
그동안은 글이 올라올 때마다 IPR팀이 요청을 주면, 담당자가 네이버 블로그에 직접 들어가 제목과 본문, 썸네일을 복사해오고, AI로 3줄 요약을 만들고, 기사 데이터 파일에 손으로 추가해서 PR을 올리는 식이었다. 그리고 테섭에서 IPR팀 확인을 받은 뒤 상용 배포까지 해야 끝이 났다. 한 건 한 건은 큰일이 아니었지만, 글이 올라올 때마다 사람이 손을 대야 하고, 글 하나 올리자고 프론트 배포를 타야 했다. 담당자가 바뀌면 이 과정을 처음부터 다시 익혀야 하는 것도 마음에 걸렸다.
이 과정을 자동화해보면 어떨까? 하는 아이디어에서 시작된 자동화에 대해서 소개하고자 한다.
어떻게 자동화할까
먼저 어떤 방식으로 자동화할 수 있을지 나열해봤다.
- GitHub Actions cron : 스케줄로 실행해서 자동 커밋, 자동 PR까지. 완전 자동이지만 AI 키를 Secrets에 등록해야 하고 Actions에 PR 생성 권한이 필요하다.
- Claude Code 스케줄 에이전트 : 클라우드에서 cron으로 스킬을 실행한다. 역시 완전 자동이지만 개인 Claude 계정에 묶여서, 그 계정이 빠지면 자동화가 멈춘다.
- Claude Code Skill :
/pr-news같은 스킬로 대화형 실행. AI 키를 따로 발급받지 않아도 되지만 여전히 사람이 기억해야 한다. - 로컬 CLI 스크립트 :
npm run news:update를 월 1회 실행. 가장 단순하지만 이것도 사람이 기억해야 한다.
유지보수 비용은 넷 다 사실상 차이가 없었다. 기사 한 건 요약에 3~4천 토큰, 월 3건 정도면 API를 써도 월 $0.1이 안 된다. 실질적인 비용은 돈이 아니라 담당자 종속에서 나온다. Claude 스케줄이나 Skill은 개인 계정에 묶여 있어 담당자가 빠지면 재설정이 필요하지만, GitHub Actions는 레포에 코드가 남는다. 그래서 1번으로 가기로 했다.
그런데 GitHub Actions로 가더라도 한 가지가 마음에 걸렸다. 기사 데이터가 코드 안에 상수 배열로 박혀 있고, 썸네일도 레포의 public/images/news/ 아래에 162장이 쌓여 있었다. 이 구조에서는 자동화가 아무리 잘 돌아도 결국 PR을 만들고, main에 머지하고, 프론트 배포를 타야 기사가 보인다. 기사는 계속 쌓이는 콘텐츠인데 그때마다 레포가 커지는 것도 이상했다.
그래서 이번 작업과 함께 데이터를 레포 밖으로 뺄 것을 먼저 제안했다. 기사 목록을 news.json으로 만들어 S3에 올리고, 썸네일도 같은 버킷에 두고, 페이지는 그걸 fetch해서 그리는 구조다. 이렇게 하면 기사 반영에 프론트 배포가 필요 없다. 대신 버킷, CloudFront, IAM, Secrets 같은 인프라 신설이 이번 작업 범위로 들어오게 된다. 복잡도는 올라가지만 한 번 만들어두면 다음 콘텐츠 자동화에도 그대로 쓸 수 있다고 판단했다.
여기에 팀 논의를 거치며 사람의 확인 단계가 더해졌다. 자동으로 상용에 바로 올라가는 건 중간 점검 과정이 없어지는 것이니, 테섭에 먼저 올리고 슬랙으로 확인을 받은 뒤 버튼을 눌러야 상용에 반영되도록 순서를 강제하기로 했다.
신규 판정 기준도 하나 정했다. 처음에는 ‘마지막 실행 날짜 이후 글’로 잡으려 했는데, 확인해보니 같은 날 후속 게시물이 올라온 사례가 있어서 날짜 대신 게시물 ID로 판정하기로 했다. 이미 news.json에 있는 id와 대조하면 되니 별도 상태 파일도 필요 없다.
실행 주기는 IPR팀에 문의를 했다. 네이버 RSS는 webhook이 없어서 폴링만 가능한데, 기존처럼 글이 올라올 때마다 반영할지 주기를 정해 묶어서 갈지는 콘텐츠 승인 주기와 맞물려 있기 때문이다. 결국 실행 주기는 매주 월요일 오전 10시로 정해졌다.
자동화 흐름
워크플로우는 탐지와 발행, 두 개다. 그 사이에 사람이 한 번 끼어든다.
① 탐지 워크플로우 - 매주 월요일 10시 cron
- RSS 페칭 → 뉴스룸 카테고리만 필터
- S3
news.json의 id 집합과 대조 → 신규만 남김. 없으면 조용히 종료 - 원문 페이지에서 본문, RSS에서 썸네일 수집
- AI(Claude)로 3문장 요약 → 문장 수와 본문에 없는 수치가 섞였는지 검증
news.json정적 검증 (id 중복, 최신순 정렬, 필수 필드)- 테섭 S3 업로드
- 슬랙 확인 요청 : 기사 카드 + 3줄 요약 + [승인하고 상용 반영] 버튼, IPR 담당자 멘션. 검증에 실패한 건이 있으면 실패 사유와 함께 프론트팀을 태깅
② 확인 - IPR팀이 테섭에서 내용을 보고 버튼 클릭
- 홈페이지 SSR 서버의 API 라우트가 클릭을 수신
- 슬랙 서명 검증, 승인자 화이트리스트 확인
- GitHub API로 발행 워크플로우 dispatch
- 스레드에 승인자 기록, 원본 메시지의 버튼 제거
③ 발행 워크플로우 - dispatch로 실행
- 테섭 산출물 내려받기 → 재검증
- 상용 S3 업로드
- 확인 요청 스레드에 결과 댓글
문제가 생기면 S3 버킷 버저닝으로 이전 news.json을 복원한다. 배포를 되돌리는 게 아니라 파일을 되돌리는 거라 훨씬 빠르다.
탐지 워크플로우의 뼈대는 이렇다.
on:
schedule:
- cron: '0 1 * * 1' # 매주 월요일 10:00 KST
workflow_dispatch:
concurrency:
group: pr-news
cancel-in-progress: false
permissions:
id-token: write # OIDC로 AWS 자격증명을 받기 위해
contents: read
jobs:
detect:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- name: 신규 기사 수집 및 요약
run: npm run news:detect
- name: AWS 자격증명 설정
uses: aws-actions/configure-aws-credentials@v4
- name: 테스트 서버 S3 업로드
run: aws s3 cp ..
- name: 슬랙 확인 요청
run: npx tsx .github/scripts/pr-news/notify.ts
- name: 실행 실패 알림
if: failure()
run: npx tsx .github/scripts/pr-news/notify.ts
S3 업로드를 TypeScript가 아니라 Actions 스텝으로 뺀 이유가 있다. 러너에 aws CLI가 이미 있어서 AWS SDK 의존성이 안 늘어나고, OIDC로 발급된 임시 자격증명이 코드에 닿지 않는다.
인프라 의사결정
버킷을 새로 판 이유
처음에는 기존 버킷을 재사용하려 했다. 그런데 살펴본 버킷들이 전부 목적이 달랐다. 앱 정적 리소스 버킷, 모듈 페더레이션 배포 전용 버킷, 다른 서비스 버킷. 홈페이지 프로젝트가 원래 쓰던 버킷은 없었다.
그중 약관을 관리하는 버킷이 성격은 가장 가까웠다. 사람이 갱신하는 정적 콘텐츠를 용도별 프리픽스로 나눠 쓰고 있어서 pr-news/를 하나 더 얹는 것 자체는 어색하지 않았다. 그런데 세 가지가 걸렸다. test/prod가 나뉘어 있지 않아 프리픽스로 나누면 테섭 데이터가 상용 도메인으로 노출된다. 자동화용 IAM 키가 개인정보 처리방침이 있는 버킷에 쓰기 권한을 갖게 된다. 그리고 캐시 무효화 방식이 CloudFront가 아니라 별도 CDN이라 인증 토큰이 하나 더 필요했다.
그래서 홈페이지 콘텐츠용 버킷을 test/prod로 새로 만들었다. 이름을 ‘PR 뉴스’가 아니라 ‘콘텐츠’로 넓게 잡은 건 다음 콘텐츠 자동화에서 재사용하기 위해서다. 버저닝을 켜고, 퍼블릭 접근은 막고, CloudFront OAC로만 읽게 했다.
키 없이 OIDC로
GitHub Actions가 S3에 올리려면 자격증명이 필요하다. 방법은 네 가지 정도가 있었다.
- OIDC + Role : Role 생성 1회. 장기 키가 없고, OIDC Provider가 계정에 이미 있어 작업이 절반으로 준다.
- IAM 사용자 + 액세스 키 : 사용자 생성, 키 발급, Secrets 등록. 장기 키가 남아서 이후 보안팀 감사와 교체 대상이 된다.
- CodeBuild에서 업로드 : 키는 필요 없지만 자동화가 Actions에 있어서 Actions → CodeBuild 트리거를 하나 더 만들어야 한다.
- EB 인스턴스 역할 : 키는 필요 없지만 업로드 주체가 SSR 서버가 되어야 해서 지금 구조와 맞지 않는다.
결국 가장 보안에 안전하고 단순한 1번 방법으로 진행을 했다. 다만 IAM Role의 신뢰 정책을 편집하려면 어드민 권한이 필요하기 때문에 이 부분은 파트장님이 만들어주셨다.
CloudFront는 썸네일에만
CloudFront는 새로 만들지 않고 기존 배포에 /pr-news/* behavior를 추가했다. 도메인이 이미 연결되어 있으니 그대로 쓰는 게 나았다.
그런데 news.json까지 CloudFront를 태울지는 고민했다. 결론은 썸네일만이다. 썸네일은 브라우저가 직접 호출하니 외부에서 접근 가능한 URL이 필요하고, CloudFront 없이 하려면 버킷을 퍼블릭으로 열고 S3 주소를 그대로 노출해야 한다. 반면 news.json의 소비자는 SSR 서버 한 곳이다. 여러 사용자에게 엣지에서 가깝게 전달하는 게 CDN의 목적인데, 소비자가 하나면 같은 리전 S3를 직접 읽는 것보다 경로만 길어진다. 발행할 때마다 무효화를 호출하거나 짧은 TTL을 유지해야 하는 관리 지점도 늘어난다. 비용은 무시할 수준이었다. news.json은 127KB 정도고, 버킷을 SSR 서버와 같은 리전에 두면 전송이 무료이기 때문이다. GET 요청도 서버 캐시 5분 기준 월 $0.01이 안 된다.
슬랙 버튼은 우리 서버로
슬랙에서 버튼을 눌렀을 때 누가 GitHub API를 호출해서 발행 워크플로우를 실행할 것인가. 두 가지가 있었다.
- Workflow Builder는 슬랙이 직접 GitHub을 호출한다. 노코드라 편하지만 조직에 설치된 Slack GitHub App에 이 레포 접근 권한을 열어줘야 하고, 그건 레포 Owner만 할 수 있었다.
- API 라우트는 홈페이지 SSR 서버가 GitHub에 요청한다. Astro 서버가 상시 떠 있으니 라우트 하나만 추가하면 된다. 슬랙 앱에 Interactivity를 켜고 Request URL로 이 라우트를 등록하면, 버튼 클릭이 우리 서버로 온다. 레포 권한 설정이 필요 없고, 누가 눌렀는지 검증할 수 있고, 중복 클릭도 막을 수 있다.
레포 권한 추가 설정이 필요 없으며, 누가 눌렀는지 검증할 수 있다는 장점이 있기에 API 라우트 방식이 더 적절하다고 판단했다. 또한, 이 방식은 공개 엔드포인트이기 때문에 방어는 필수적이었다. 슬랙은 요청마다 v0:{timestamp}:{body}를 Signing Secret으로 HMAC-SHA256 해싱한 값을 헤더에 실어 보낸다. 우리도 같은 재료로 계산해서 대조했다.
const requestAgeSeconds = Math.abs(Date.now() / 1000 - Number(timestamp))
if (!Number.isFinite(requestAgeSeconds) || requestAgeSeconds > 60 * 5) {
throw new SlackRequestError('요청 유효 시간 초과')
}
const expectedSignature = `v0=${crypto
.createHmac('sha256', signingSecret)
.update(`v0:${timestamp}:${rawBody}`)
.digest('hex')}`
5분이 지난 요청은 거부해서 재전송 공격을 막고, 비교는 crypto.timingSafeEqual로 한다. 여기에 승인자 화이트리스트를 더해 목록에 없는 사용자의 클릭은 거부할 수도 있다.
코드에서 걸렸던 것들
- Astro의 origin 체크
슬랙은 버튼 클릭을 application/x-www-form-urlencoded로, Origin 헤더 없이 보낸다. Astro는 POST 계열에 form Content-Type이면 Origin이 사이트와 다를 때 403을 돌려주는데, 슬랙 요청은 여기에 걸린다. security.checkOrigin은 boolean이라 경로 예외가 없고, 내부에서 origin 체크 미들웨어가 사용자 미들웨어 앞에 끼워지기 때문에 우리 미들웨어로 특정 경로만 통과시킬 수도 없다. 결국 이 옵션(security.checkOrigin)을 끄고, 대신 서명 검증에 기대기로 했다.
- 빌드타임 env와 런타임 env
처음엔 CodeBuild 환경 변수에 넣으면 될 거라 생각했지만, process.env로 읽는 값은 거기에 닿지 않는다. import.meta.env는 Vite 문법이라 빌드 시점에 문자열로 치환되고, process.env는 서버가 뜰 때 실제 프로세스 환경을 조회한다. CodeBuild env는 빌드 컨테이너 안에서만 살고 아티팩트에 .env가 담기지 않으니 런타임엔 없는 값이 된다. 결국, 토큰 교체 시 재빌드 없이 갱신할 수 있게 EB 환경 속성으로 옮기고 process.env로 통일했다.
정리하면 이렇다. 빌드 때 값이 정해져야 하고 클라이언트 번들에도 들어가는 값(퍼블릭 오리진 같은 것)은 import.meta.env, 서버가 뜬 뒤에 읽으면 되는 비밀값은 process.env다. 이번 라우트는 토큰과 시크릿만 다루니 후자로 통일하는 게 적절했다. 여기에 한 걸음 더 나아가, 값이 빠졌을 때 undefined인 채로 조용히 흘러가지 않도록, 스크립트 시작 시점에 필요한 환경 변수를 한 번에 확인해서 없으면 바로 에러를 던지게 했다. 실패했을 때 어떤 값이 빠져서 실패했는지 로그에서 바로 보이게 하기 위해서였다.
효과
기사 반영에 프론트 배포가 사라졌다. 코드 변경 없이 S3에 파일이 올라가면 끝이고, 되돌리는 것도 이전 버전 복원이다. 사람이 하는 일은 슬랙에서 내용을 확인하고 버튼을 한 번 누르는 것뿐이다. 담당자가 기억할 필요도, 인수인계할 절차도 없다. 코드와 워크플로우가 레포에 남아 있으니 누가 빠져도 자동화는 계속 돈다.
그리고 홈페이지 콘텐츠용 버킷과 CloudFront 경로, OIDC Role은 이번 자동화만을 위한 게 아니다. 다음에 다른 콘텐츠를 자동화할 때 그대로 재사용할 수 있게 되었다.
맺으며
이번 작업에서 가장 크게 남은 건 설계였다. 작지 않은 코드를 혼자 수정하다 보니 거시적인 관점, 설계적인 면에서 놓친 점들이 있었다. 대면 리뷰를 하며 내 자동화 흐름을 소개하고, 설계 면에서 피드백을 받을 수 있었는데, 지적받은 것들 대부분이 ‘설계를 안 하고 작업해서 생긴 문제’였다. 스크립트 위치, 폴더 구조, 예외를 던질지 결과 객체를 돌려줄지 같은 것들을 리뷰 이후에 다시 잡았다. 대면 리뷰는 너무 좋았다. 혼자서는 보이지 않던 것들이 그 시간에 보였다.
돌아보면 순서를 바꿨어야 할 것들이 있다. 흐름도는 요청받기 전에 처음부터 그려서 공유할걸 그랬다는 생각이 든다. 그림 한 장이 있고 없고의 차이가 크다고 느껴졌다. 그림이 없을 때는 ‘말로 하나씩 절차적으로 설명해야하는 형태’였다면, 그림으로 공유하는 순간 ‘이 그림으로 조금 더 명료하게 설명할 수’ 있게 되었다. PR도 조각부터가 아니라 전체 그림이 보이는 워크플로우부터 올렸으면 리뷰어가 덜 헤맸을 것이라는 아쉬움이 남는다. 마지막으로, 한 번은 AI에게 IAM 테스트를 요청했더니 main에 그냥 푸시해버린 일이 있었다. 그 뒤로 main 직접 푸시를 막는 hook을 걸어두었다.
마지막으로, 작업을 마치고 IPR팀이 감사하다고 했을 때의 뿌듯함, 매주 월요일마다 실행될 때의 짜릿함은 나를 춤추게 만든다. 다음엔 더 크고 더 복잡한 병목을 없애는 자동화를 만들어보고 싶다. 자동화는, 언제해도 참 즐겁다.