상황
우리 회사에서는 네이버 블로그를 운영 중이다. 이 블로그에는 월 4-5회의 블로그 글이 업로드된다. 그리고 이 블로그 글을 기반으로 홈페이지 내 PR 페이지의 뉴스룸에 업로드가 된다. 이 뉴스룸에는 블로그 글의 썸네일 이미지와 내용 기반 3줄 요약이 포함된다. 이 뉴스룸의 글 업로드는 우리 팀에서 담당을 하고 있었다.
그동안은 글이 올라올 때마다 홍보팀이 요청을 주면, 담당자가 네이버 블로그에 직접 들어가 제목과 본문, 썸네일을 복사해오고, AI로 3줄 요약을 만들고, 기사 데이터 파일에 손으로 추가해서 PR을 올리는 식이었다. 그리고 테섭(테스트 서버)에서 홍보팀 확인을 받은 뒤 상용 배포까지 해야 끝이 났다. 한 건 한 건은 큰일이 아니었지만, 글이 올라올 때마다 사람이 손을 대야 하고, 글 하나 올리자고 프론트 배포를 타야 했다. 담당자가 바뀌면 이 과정을 처음부터 다시 익혀야 하는 것도 마음에 걸렸다.
이 과정을 자동화해보면 어떨까? 하는 아이디어에서 시작된 작업을 소개하고자 한다.
어떻게 자동화할까
먼저 어떤 방식으로 자동화할 수 있을지 나열해봤다.
- GitHub Actions cron : 스케줄로 실행해서 자동 커밋, 자동 PR까지. 완전 자동이지만 AI 키를 Secrets에 등록해야 하고 Actions에 PR 생성 권한이 필요하다.
- Claude Code 스케줄 에이전트 : 클라우드에서 cron으로 스킬을 실행한다. 역시 완전 자동이지만 개인 Claude 계정에 묶여서, 그 계정이 빠지면 자동화가 멈춘다.
- Claude Code Skill :
/pr-news같은 스킬로 대화형 실행. AI 키를 따로 발급받지 않아도 되지만 여전히 사람이 기억해야 한다. - 로컬 CLI 스크립트 :
npm run news:update를 월 1회 실행. 가장 단순하지만 이것도 사람이 기억해야 한다.
유지보수 비용은 넷 다 사실상 차이가 없었다. 기사 한 건 요약에 34천 토큰, 월 45건이면 API를 써도 월 $0.1이 안 된다. 실질적인 비용은 돈이 아니라 담당자 종속에서 나온다. Claude 스케줄이나 Skill은 개인 계정에 묶여 있어 담당자가 빠지면 재설정이 필요하지만, GitHub Actions는 레포에 코드가 남는다. 2번과 3번은 비결정적이라는 문제도 있다. 정해진 스크립트가 도는 게 아니라 AI가 매번 코드 수정을 새로 하는 방식이라, 같은 입력에도 결과가 달라질 수 있다. 잘 돌았는지 매주, 매월 사람이 들여다봐야 한다는 뜻이다. 그래서 1번으로 가기로 했다.
그런데 GitHub Actions로 가더라도 한 가지가 마음에 걸렸다. 기사 데이터가 코드 안에 상수 배열로 박혀 있고, 썸네일도 레포의 public/images/news/ 아래에 162장이 쌓여 있었다. 이 구조에서는 자동화가 아무리 잘 돌아도 결국 PR을 만들고, main에 머지하고, 프론트 배포를 타야 기사가 보인다. 기사는 계속 쌓이는 콘텐츠인데, 그때마다 레포가 커진다는 점도 우려했다.
그래서 이번 작업과 함께 데이터를 레포 밖으로 뺄 것을 먼저 제안했다. 기사 목록은 형식화되어있기 때문에, JSON파일로 만들어 S3에 업로드하고, 썸네일도 같은 버킷에 두고, 페이지는 그걸 fetch해서 그리는 구조다. 이렇게 하면 기사 반영에 프론트 배포가 필요 없다. 대신 버킷, CloudFront, IAM, Secrets 같은 인프라 신설이 이번 작업 범위로 들어오게 된다. 복잡도는 올라가지만 한 번 만들어두면 다음 콘텐츠 자동화에도 그대로 쓸 수 있다고 판단했다. 덤으로 1번의 단점 하나도 같이 사라졌다. 커밋할 파일이 레포에 없으니 Actions에 PR 생성 권한을 줄 필요가 없어진 것이다. 남은 건 AI 키를 Secrets에 등록하는 정도였다.
여기에 팀 논의를 거치며 사람의 확인 단계가 더해졌다. 자동으로 상용에 바로 올라가는 건 중간 점검 과정이 없어지는 것이니, 테섭에 먼저 올리고 슬랙으로 확인을 받은 뒤 버튼을 눌러야 상용에 반영되도록 순서를 강제하기로 했다.
신규 판정 기준도 하나 정했다. 처음에는 ‘마지막 실행 날짜 이후 글’로 잡으려 했는데, 확인해보니 같은 날 후속 게시물이 올라온 사례가 있어서 날짜 대신 게시물 ID로 판정하기로 했다. 이미 news.json에 있는 id와 대조하면 되니 별도 상태 파일도 필요 없다.
실행 주기는 홍보팀에 문의를 했다. 네이버 RSS는 webhook이 없어서 폴링만 가능한데, 기존처럼 글이 올라올 때마다 반영할지 주기를 정해 묶어서 갈지는 콘텐츠 승인 주기와 맞물려 있기 때문이다. 결국 실행 주기는 매주 월요일 오전 10시로 정해졌다.
자동화 흐름
워크플로우는 탐지와 발행, 두 개다. 그 사이에 사람이 한 번 끼어든다.
① 탐지 워크플로우 - 매주 월요일 10시 cron
- RSS 페칭 → 뉴스룸 카테고리만 필터
- S3
news.json의 id 집합과 대조 → 신규만 남김. 없으면 조용히 종료 - 원문 페이지에서 본문, RSS에서 썸네일 수집
- AI(Claude)로 3문장 요약 → 문장 수와 본문에 없는 수치가 섞였는지 검증
news.json정적 검증 (id 중복, 최신순 정렬, 필수 필드)- 테섭 S3 업로드
- 슬랙 확인 요청 : 기사 카드 + 3줄 요약 + [승인하고 상용 반영] 버튼, 홍보팀 담당자 멘션. 검증에 실패한 건이 있으면 실패 사유와 함께 프론트팀을 태깅
② 확인 - 홍보팀이 테섭에서 내용을 보고 버튼 클릭
- 홈페이지 SSR 서버의 API 라우트가 클릭을 수신
- 슬랙 서명 검증, 승인자 화이트리스트 확인
- GitHub API로 발행 워크플로우 dispatch
- 스레드에 승인자 기록, 원본 메시지의 버튼 제거
③ 발행 워크플로우 - dispatch로 실행
- 테섭 산출물 내려받기 → 재검증
- 상용 S3 업로드
- 확인 요청 스레드에 결과 댓글
문제가 생기면 S3 버킷 버저닝으로 이전 news.json을 복원한다. 배포를 되돌리는 게 아니라 파일을 되돌리는 거라 훨씬 빠르다는 장점이 있다. 복원은 콘솔이나 CLI에서 이전 버전을 다시 최신으로 올리는 수동 작업이다. 다행히 아직 되돌릴 일은 없었다.
발행 순서에도 비슷한 장치가 있다. 이미지를 먼저 올리고 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로 발급된 임시 자격증명이 코드에 닿지 않는다.
요약 검증은 두 가지만 본다. 문장 수와 수치다. 문장이 세 개가 아니면 실패고, 수치는 요약에서 숫자를 전부 뽑아 본문의 숫자와 대조해서 본문에 없는 숫자가 나오면 실패다. 보도자료는 수치가 핵심이라, 여기서 환각이 나면 요약을 통째로 반려하는 게 맞다고 봤다. 대신 같은 수치가 다른 표기로 나오는 경우가 있다. 본문의 '2023.10.11'을 요약이 '2023년 10월 11일'로 쓰는 식이다. 그래서 천 단위 쉼표를 지우고 숫자 단위로 쪼갠 뒤 앞자리 0을 없애서, 표기가 달라도 같은 토큰으로 비교되게 했다. 문장 길이는 검증하지 않는다. 모델이 글자 수를 정확히 맞추지 못해서, 내용과 무관한 반려만 늘어나기 때문이다.
프롬프트에서도 신경 쓴 게 하나 있다. 본문은 외부 블로그에서 그대로 가져오는, 신뢰할 수 없는 입력이다. 그래서 요약 지시문은 system에, 본문은 user에 분리해서 본문 안에 지시문처럼 보이는 문장이 섞여 있어도 규칙을 덮어쓰지 못하게 했다. 검증에 실패한 요약은 실패 사유를 그대로 슬랙 알림에 싣는다.
인프라 의사결정
버킷을 새로 판 이유
처음에는 기존 버킷을 재사용하려 했다. 그런데 살펴본 버킷들이 전부 목적이 달랐다. 앱 정적 리소스 버킷, 모듈 페더레이션 배포 전용 버킷, 다른 서비스 버킷. 홈페이지 프로젝트가 원래 쓰던 버킷은 없었다.
그중 약관을 관리하는 버킷이 성격은 가장 가까웠다. 사람이 갱신하는 정적 콘텐츠를 용도별 프리픽스로 나눠 쓰고 있어서 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번 방법으로 진행을 했다.
Role은 아무 워크플로우나 받을 수 있으면 의미가 없다. 신뢰 정책은 이 레포의 main 브랜치에서 실행된 워크플로우만 Role을 받을 수 있게 좁혔다. 다른 레포는 물론이고, 같은 레포라도 PR 브랜치에서는 자격증명이 나오지 않는다. 버킷에 쓰는 코드는 main에 머지된 것만 돌 수 있는 셈이다.
CloudFront 캐싱은 썸네일에만
CloudFront는 새로 만들지 않고 기존 배포에 /pr-news/* behavior를 추가했다. 도메인이 이미 연결되어 있으니 그대로 쓰는 게 나았다.
그런데 news.json까지 CloudFront 캐싱을 태울지는 고민했다. 결론은 캐싱은 썸네일만이다. 썸네일은 여러 사용자의 브라우저가 직접 호출하니 엣지 캐싱의 이득이 그대로 있다. 반면 news.json의 소비자는 SSR 서버 한 곳이고, 서버가 5분 캐시를 이미 들고 있다. 여기에 CloudFront 캐시까지 얹으면 발행할 때마다 무효화를 호출하거나 짧은 TTL을 유지해야 하는 관리 지점만 늘어난다. 그래서 news.json도 경로는 같은 CloudFront를 타지만 캐싱은 하지 않는다. 버킷이 OAC로만 열려 있으니 CloudFront를 거치면 SSR 서버가 AWS 자격증명 없이 HTTPS GET 한 번으로 읽을 수 있다는 덤도 있다. 비용은 무시할 수준이었다. news.json은 127KB 정도고, 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로 한다. 여기에 승인자 화이트리스트를 더해 목록에 없는 사용자의 클릭은 거부한다.
중복 클릭은 두 겹으로 막는다. 승인이 처리되면 원본 메시지에서 버튼 블록을 제거하고 그 자리에 승인자와 '승인 완료'를 남긴다. 버튼이 사라지니 다시 누를 방법이 없다. 혹시 그 틈에 발행이 두 번 걸리더라도, 탐지와 발행 워크플로우가 같은 concurrency 그룹으로 묶여 있어 동시에 돌지는 않는다. 하나 더, 슬랙은 버튼 클릭에 3초 안의 응답을 기대한다. 그래서 라우트는 요청을 받으면 즉시 200을 돌려주고, 발행 dispatch와 메시지 수정은 뒤에서 처리한다. 순서는 발행을 먼저 걸고 슬랙에 표시한다. 반대로 하면 워크플로우가 실행되지 않았는데도 승인 완료로 보일 수 있기 때문이다.
코드에서 걸렸던 것들
-
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은 이번 자동화만을 위한 게 아니다. 다음에 다른 콘텐츠를 자동화할 때 그대로 재사용할 수 있게 되었다.
맺으며
이번 작업에서 가장 인상 깊었던 부분은 ‘설계’였다. 적지 않은 코드를 혼자 작성하다 보니 거시적인 관점, 설계적인 면에서 놓친 부분들이 많았다. 팀원들과 대면 리뷰를 하며 내 자동화 흐름을 소개하며 설계 면에서 피드백을 받을 수 있었는데, 더 넓은 시야에서 설계를 바라볼 수 있다는 점에서 그 시간이 너무 좋았다. 혼자서 설계할 때는 내가 처음에 만든 프레임이라는 세계에 갇혀서 더 넓은 시야, 다른 시각에서 바라보기 힘들었는데, 다른 시각에서 피드백을 준 덕분에 더 넓게 바라볼 수 있게 되었다. 앞으로도 큰 규모의 코드를 바꾸거나, 혹은 주기적으로 팀원들과 대면 리뷰를 진행하는 것이 내 사고를 넓히는데에 도움이 될 것이라 생각했다.
그리고 두 번째로 인상 깊었던건 이 자동화가 실제로 동작할 때였다. 지난 주 구현을 마치고, 어제 예비군을 다녀왔는데 슬랙에서 자동화가 동작을 한 것이다. 나는 지금 노트북이 없는데, 내가 구현한 코드가 동작을 한 것이다. 절로 웃음이 나왔다. 홍보팀과 커뮤니케이션하는 비용도 줄어들고, 수작업으로 하는 시간도 줄어든 것이다.
무엇보다도 내가 가장 큰 가치로 둔 것은 ‘길’이었다. 이번 자동화를 구현하면서 나는 ‘자동화를 하기 위한 길’을 개척하는 과정이라 생각했다. 나에게는 물론 처음 해보는 것들이고, 팀 내에도 선례가 많지 않았다. 그래서 내가 처음 개척해야하는 것들이 적지 않았다. GitHub Actions에서 AWS에 접근하기 위해서는 어떤 권한 정책/설정이 필요하고, 슬랙 앱을 생성하기 위해서는 어디에 확인을 받아야하고, 이러한 것들 하나하나에 대한 설계를 누구에게 컨펌을 받아야하는지 등 말이다. 나는 자동화에 정말로 관심이 많은데, 앞으로 새로운 자동화를 도입하는 것에 대한 허들이 낮아진다는 점에서 이번 작업은 단순히 ‘기사 요약 자동화’ 그 이상의 가치가 있다고 생각한다. 그래서 더더욱이 잘 해내고 싶었고, 결국 차질 없이 잘 해내서 다행이었다.