블로그 개발기 14 (자동 발행 편)

원본 DB 실측 뒤 Git을 유지하고 수집 직후 집계를 발행하도록 바꿨다
calendar icon

2026년 10월 13일

조회 1

tag icon

지난 글에서는 작업 결과와 KB 대조 이력을 정리했다. 무엇을 했는지뿐 아니라 어떤 근거로 확인했는지를 남기면서 자료의 종류도 늘었다. 이 기록을 계속 모으고 블로그에 보여 주려면 저장과 갱신 흐름도 다시 볼 필요가 있었다.

처음에는 기기별 집계 원본을 DB에 모으는 방향으로 진행했다. 여러 기기의 같은 기록을 정확히 중복 제거하고, 충돌한 내용을 확인하고, 필요할 때 명시적으로 정정하는 구조까지 생각했다. 데이터를 한곳에 두면 이후 조회와 관리가 쉬워질 것 같았다.

그런데 용량을 실제로 재면서 판단이 바뀌었다. 현재 자료가 들어가는 크기와 다음 자료로 안전하게 교체할 수 있는 크기는 달랐다. 처음 확인한 약 185MB만 보고 무료 한도 안에 들어간다고 말할 수 없었다.

"지금 한 벌이 들어간다는 것과, 계속 갱신할 수 있다는 것은 같은 조건일까?"

결국 무료 한도를 유지하기로 했다. 기기별 원본과 정확한 중복 제거의 근거는 기존 Git·Python 계층에 남기고, DB에는 블로그가 읽는 집계 결과를 저장하는 쪽으로 정리했다. 동시에 하루 수집이 끝난 뒤 별도 발행 예약을 기다리던 흐름을 연결했다.

여기서 말하는 발행은 AI 활용 집계를 블로그에 반영하는 일이다. 블로그 글을 자동으로 공개하는 기능을 만든 것은 아니다. 이번 편은 운영에 넣은 최소 변경과, 그 과정에서 중단한 원본 DB 실험을 함께 정리한 기록이다.


원본을 DB에 모으려던 이유

합계가 같아도 같은 자료라고 할 수는 없었다

여러 기기의 자료를 합칠 때 기기별 총합을 더하면 중복이 생길 수 있었다. 반대로 비슷한 항목을 대충 같은 것으로 취급하면 실제로 다른 기록을 잃는다. 원본 DB 설계에서도 정확히 같은 항목을 식별하고, 각 항목이 집계에 기여하는 값을 보존하는 것을 기준으로 삼았다.

자료는 압축하고 안정적으로 나뉜 chunk로 전달하도록 했다. 전체 자료를 매번 보내기보다 내용이 바뀐 조각과 그 조각들을 묶는 정보를 보내는 방식이었다. 작은 수정 때문에 뒤의 조각이 전부 달라지지 않도록 분할의 안정성도 중요했다.

송신 측에서 확인한 활성 자료 전송은 chunk 131개와 commit 정보를 합쳐 4,230,857bytes였다. 가장 큰 요청은 67,157bytes였고, 내용 변경이 없을 때는 chunk 없이 20,431bytes의 commit 정보만 보냈다. 다만 이 값은 네트워크로 전달한 크기이며 DB의 저장량으로 환산하지 않았다.

전송이 작아졌다는 사실만으로 저장도 작다고 볼 수 없었다. DB에서는 인덱스와 메타데이터가 붙고, 조회를 위한 파생 자료가 생긴다. 더 중요한 것은 중복 제거한 집계와 기기별 원본 보존이 서로 다른 목적이라는 점이었다.

충돌을 발견했다고 원본을 고치지는 않기로 했다

같은 식별자에 다른 내용이 들어오면 하나를 임의로 고르지 않도록 했다. 각 변형의 원본 값과 digest, 실제 출처와 세대를 함께 확인해야 했다. 정상 수집이 진행됐다는 이유만으로 충돌을 자동 해결하거나 원본을 바꾸는 권한을 주고 싶지 않았다.

정정은 진단을 확인한 뒤 명시적으로 승인하고, 원본 위에 별도의 overlay를 적용해 다시 집계하는 방향이었다. 승인을 기다리는 동안 원본이 바뀌면 오래된 승인으로 적용하지 않게 했다. 선택·교체·분리의 의미와 일반 수집의 권한도 구분했다.

처음 계약에서는 충돌 안에 같은 기기가 여러 번 나오지 않는다는 제약을 뒀다. 하지만 같은 기기에서도 서로 다른 주의 자료에 같은 식별자의 다른 변형이 있을 수 있었다. 기기 하나당 변형 하나로 축약하면 실제 자료를 표현할 수 없었다.

이 경계는 v2에서 보완했다. 서로 다른 변형에는 같은 기기 출처가 나타날 수 있지만, 한 변형 안에서 출처를 중복시키거나 실제 스냅샷과 맞지 않는 값을 넣는 것은 계속 거부했다. 기존 v1의 의미는 그대로 두고, 새 버전에서 허용하는 범위를 명시했다.

작업 안의 표시 정보와 별도 sidecar 표시 정보 사이의 관계 충돌도 확인했다. 서로 다른 종류의 자료를 가짜 동일 식별자로 합치지 않고 관계로 보존하는 v3 방향을 검토했다. 다만 이후 저장 설계를 바꾸면서 이 확장은 중단했고, 완성된 운영 기능으로 남기지 않았다.

이 실험에서 얻은 것은 서버 정정 기능의 완성이 아니었다. 무엇을 같은 자료로 볼지, 승인이 어느 원본에 묶여야 할지, 원본과 해석을 어떻게 분리할지에 대한 경계였다. 그 기준이 필요하다는 사실과 이번 운영 범위에 구현됐다는 사실은 구분해서 남겼다.


185MB 뒤에 남아 있던 용량

현재 자료 외에 이전 세대와 갱신 중 자료를 더했다

용량은 활성 자료를 바탕으로 기기 세 대가 1년 동안 수집하는 시나리오로 측정했다. 사용한 활성 자료는 UTC 기준 21일 분량이었고, 이를 반복 배치해 52개의 KST 주 범위에 맞췄다. 실제로 1년을 운영한 결과가 아니라 현재 자료의 형태를 늘린 로컬 실측이다.

기기 사이의 공통 항목 비율은 0%, 50%, 100%로 나눴다. 중복이 전혀 없을 때뿐 아니라 일부 또는 전부 겹칠 때 저장량이 얼마나 달라지는지도 확인하려고 했다. 같은 집계 결과가 나오는 것과 저장되는 원본의 양이 줄어드는 것은 별개의 문제였다.

저장량에는 테이블 데이터와 TOAST, 실제 인덱스, 기기 메타데이터와 파생·전역 인덱스 등의 구조를 포함했다. 여기에 현재 세대만 있을 때, 이전 세대를 함께 보존할 때, 새 세대를 받아들이는 staging까지 겹칠 때를 나눴다. 갱신하는 순간 필요한 여유를 빼면 평소에는 들어가도 다음 수집에서 막힐 수 있었다.

세 기기의 공통 항목 비율현재 세대현재 + 이전 세대staging을 포함한 갱신 구간
0%184.50MB369.82MB533.50MB
50%177.71MB341.33MB504.86MB
100%171.31MB334.86MB498.47MB

위 표의 MB는 1,000,000bytes 기준이며 표시 값은 반올림했다. 0%의 최종 갱신 구간은 533,495,808bytes였다. 0%에서는 증분과 메타데이터 변경 경로까지 거친 값이므로 현재 세대의 단순 두 배나 세 배로 계산한 표는 아니다.

완전 중복에서도 원본은 기기별로 보존하므로 저장량이 극적으로 줄지 않았다. 정확한 중복 제거는 집계의 중복 기여를 없애는 기능이지, 출처를 가진 모든 원본 사본을 없애는 정책은 아니었다. 이 차이를 용량 판단에서 놓치면 가장 좋은 조건의 수치도 잘못 해석하게 된다.

가장 작은 결과도 무료 안전 판정은 아니었다

당시 운영 기준인 500MB와 비교하면 0%와 50%는 갱신 구간에서 이미 한도를 넘었다. 100%는 수치상 아래였지만 남은 여유가 약 1.53MB뿐이었다. 기존 블로그 DB를 제외한 값이어서 안전하게 들어간다는 결론을 내릴 수 없었다.

측정하지 못한 증가분도 있었다. 작업·sidecar·활동 메타데이터는 활성 자료의 상태를 유지한 것이며, 이 항목들이 1년 내내 늘어나는 상황까지 재현한 것은 아니다. 측정에 사용한 활성 자료에는 실제 review 기록도 없었다.

장기 감사 이력, 영수증의 누적, WAL과 임시 작업 공간, 동시 실행의 여유까지 모두 포함한 운영 상한도 아니었다. 미측정 항목을 0으로 가정하면 표는 단순해지지만 운영 판단은 위험해진다. 그래서 초기 약 185MB라는 값은 최종 무료 안전 판정에서 제외했다.

시간과 메모리도 같은 조건을 붙여 기록했다. 로컬 PGlite에서 chunk 저장·commit 이후 projection을 재계산한 시간은 중복 0%·50%·100% 순으로 약 84초·82초·55초였다. 그 앞의 chunk 저장과 commit 단계는 별도로 약 106초·109초·80초였으므로 재계산 시간만 초기 구성 전체 시간으로 볼 수는 없다. 0%에서 한 chunk 증분 뒤의 projection은 약 9.5초, 메타데이터 변경 뒤의 projection은 약 172초로, 작은 종류의 변경이라고 항상 싸게 끝나는 것은 아니었다.

RSS 관측에는 Node와 PGlite의 WASM 실행, 송신 경로가 함께 들어갔다. 이를 그대로 운영 서버리스 함수 한 개의 메모리 요구량으로 옮기지 않았다. 50%와 100%의 증분·메타데이터 변경 시간을 따로 반복 측정하지 않은 부분도 미측정으로 남겼다.

"한도를 넘는 이유를 알았는데, 더 측정하면 지금의 선택이 달라질까?"

여기서 대량 검사를 계속 늘리지 않기로 했다. 무료 한도를 유지한다는 조건이 정해졌고, 그 조건에서 원본 DB를 안전하게 운영할 근거는 부족했다. 자동으로 유료 전환하거나 보존 기간을 줄이거나 과거 자료를 삭제하지 않고 저장 방향을 바꿨다.


Git은 남기고 발행 사이의 대기를 없앴다

원본과 집계가 맡는 일을 다시 나눴다

기존 private 저장소의 usage-data 브랜치는 기기별 자료를 모으고 있었다. 정확한 중복 제거도 Python 계층에서 수행할 수 있었다. 이 경로를 유지하고, 블로그 DB에는 기존 화면과 캐시가 읽는 집계 결과만 넣기로 했다.

내가 원했던 것은 하루 수집이 끝난 뒤 또 다른 예약 시각을 기다리지 않는 흐름이었다. 이를 위해 원본 저장소까지 바꿀 필요는 없었다. 수집과 동기화가 성공한 바로 뒤에 기존 main 발행 workflow를 명시적으로 호출하는 것이 필요한 변경이었다.

기존 하루 수집 예약
  → 로컬 자료 수집
  → usage-data 동기화 성공과 실제 revision 확인
  → main의 발행 workflow 호출
  → Python의 정확한 중복 제거와 집계
  → 기존 aggregate API에서 집계 적용과 캐시 갱신
  → ACK를 바탕으로 영수증 생성·검증

기존 오전 9시 수집 타이머는 유지했다. 수집 자체를 더 자주 실행하거나 매번 운영 송신을 새로 추가한 것은 아니다. 정기 수집 뒤의 발행 연결을 바꾸고, 필요한 재시도를 그 흐름 안에서 관리하도록 했다.

원본 DB partition이나 서버 정정 UI를 계속 확장하지도 않았다. 기존 ingest·캐시·공개 화면·관리 화면은 재사용하고, 원본 DB와 v3 실험 코드는 비활성 상태로 보존했다. 실험을 멈추는 것과 남아 있는 변경을 삭제하는 일은 별도 판단이었다.

usage-data의 push만 트리거로 붙이지 않았다

처음에는 데이터 브랜치의 push를 workflow 시작 조건에 추가하면 될 것처럼 보일 수 있었다. 하지만 실제로 어떤 ref의 workflow와 코드를 실행하는지 확인해야 했다. 데이터를 보관하는 브랜치와 실행할 코드가 있는 main의 역할을 섞지 않기로 했다.

송신 측은 동기화가 성공한 실제 commit SHA를 요청 revision으로 기록하고, main의 workflow를 명시적으로 호출한다. workflow는 데이터 브랜치의 현재 끝을 읽되 요청 revision이 그 안에 포함되는지 조상 관계를 확인한다. 요청한 과거 상태로 무조건 되돌려 읽는 방식은 아니었다.

이 선택은 여러 기기가 가까운 시점에 동기화하는 상황과도 연결됐다. 먼저 보낸 요청이 실행될 무렵에는 데이터 브랜치에 더 최신 자료가 들어와 있을 수 있다. 요청한 자료를 포함하는 최신 상태인지 확인하고, 실제 적용한 revision을 영수증에 남기면 두 상태를 구분할 수 있다.

workflow에는 main에서 실행된 요청인지 확인하는 guard를 두고, exporter는 검토한 전체 commit SHA로 고정했다. 입력값도 실행할 셸 코드처럼 끼워 넣지 않도록 전달 경로를 맞췄다. 같은 이름의 workflow가 성공했다는 사실만으로 원하는 코드를 실행했다고 간주하지 않았다.

exporter에서는 연도별 결과를 만들기 전에 전체 identity를 검증했다. 연도 필터를 먼저 적용하면 다른 연도에 걸친 동일 식별자의 문제를 놓칠 수 있기 때문이다. 화면에서 조회하는 기간과 원본의 일관성을 확인하는 범위는 다르게 잡았다.

호출 성공 응답이 없어도 요청을 잃지 않게 했다

자동 발행은 네트워크 호출 하나로 끝나지 않았다. 요청이 전달됐는데 응답만 유실될 수 있고, 호출에 실패한 뒤 프로세스가 종료될 수도 있다. 메모리에만 재시도 상태를 두면 다음 수집 때 무엇이 진행 중이었는지 알 수 없었다.

그래서 호출하기 전에 요청과 시도 정보를 영속 저장하도록 했다. 실제 revision과 요청 식별자를 남기고, 저장을 마친 다음 dispatch를 보낸다. 재시작하더라도 기다리던 요청을 다시 확인할 수 있게 한 것이다.

응답을 받지 못했다고 즉시 같은 발행을 또 보내지는 않았다. 해당 요청에 대응하는 실행이 이미 생겼는지 먼저 찾고, 확인 결과에 따라 재시도한다. 새로운 시도도 따로 기록해 어떤 호출에서 어떤 실행이 생겼는지 이어서 볼 수 있도록 했다.

실행 결과는 저장소·workflow·event·main ref·요청 식별자를 함께 대조했다. 성공한 실행에서 받은 artifact도 정해진 영수증 형식으로 확인했다. 다른 실행의 성공 결과나 비슷한 이름의 파일을 이번 요청의 완료로 처리하지 않게 했다.

경계확인한 것
동기화 → 요청실제 동기화 revision
요청 → 실행main workflow와 요청 식별자
실행 → 영수증해당 실행의 artifact와 엄격한 형식
영수증 → 데이터적용 순번·revision·필요한 조상 관계
실패 → 다음 시도디스크에 남은 요청과 시도 상태

수신부가 실제 적용 상태를 답하게 했다

HTTP 성공만으로 갱신을 판단하지 않았다

기존 aggregate 수신 API에는 적용 여부뿐 아니라 실제 DB 상태를 함께 돌려주는 ACK를 추가했다. 이미 더 최신 자료가 들어와 있으면 요청 자체는 정상이어도 이번 자료를 덮어쓰지 않아야 한다. 단순히 HTTP 200을 받았다는 이유로 요청한 내용이 반영됐다고 판단할 수 없었다.

응답에는 실제 순번과 source revision을 담았다. 새 자료가 적용됐는지, 같은 자료가 이미 적용돼 있었는지, 더 최신 자료가 앞서 들어왔는지를 송신 측에서 구분할 수 있게 했다. 같은 순번인데 revision이 다르면 정상 재시도로 취급하지 않도록 했다.

핵심은 그 값을 언제 읽느냐였다. 저장을 마친 뒤 별도 조회로 순번과 revision을 가져오면 그 사이에 다른 요청이 반영될 수 있다. 적용 판단과 ACK의 실제 상태를 같은 잠금·트랜잭션 안에서 확인하도록 했다.

이미 더 최신 상태인 경우에는 일부 연도만 보고 판단하지 않고 전체 집계 행의 실제 순번을 기준으로 응답했다. 여러 연도를 갱신하다 실패하는 경우의 원자성도 확인했다. 요청을 늦게 처리했다는 이유로 최신 자료를 과거 값으로 되돌리지 않는 것이 중요했다.

비활성 DB 기능이 기존 조회를 깨지 않게 했다

원본 DB 실험은 중단했지만 코드 작업 폴더에는 새 스키마와 미완료 기능이 남아 있었다. Prisma 모델에 필드가 추가되면 기존 조회가 별도 의도 없이 그 컬럼까지 요구할 수 있었다. 운영 DB에 raw migration을 적용하지 않은 상태에서도 기존 집계 경로가 동작해야 했다.

운영에 필요한 집계 필드만 명시적으로 선택하도록 의존성을 분리했다. ACK를 배포하기 위해 원본 DB migration까지 따라가는 구조를 막은 것이다. 기존 컬럼만 있는 임시 DB에서 실제 Prisma와 HTTP 경로로 확인했다.

관리 화면의 변경과 수신부 변경은 커밋도 나눴다. 이미 검토한 화면 변경은 별도로 반영하고, 수신부에는 ACK와 기존 DB 의존성 분리에 필요한 파일만 담았다. 미완료 원본 DB와 v3, 다른 작업의 변경을 운영 반영에 섞지 않았다.

배포 환경을 맞추는 과정에서 남긴 주의점도 있었다. 이 저장소는 Yarn 4를 쓰는데, Vercel에서 Corepack 설정이 빠지면 Yarn 1로 설치하며 lockfile과 resolutions를 예상대로 처리하지 못했다. 로컬 설치와 빌드가 통과해도 원격 설치 도구가 같다는 보장은 없어서 해당 설정을 유지하도록 기록했다.

이 문제를 계기로 검증 범위도 더 분명히 했다. 수신부의 DB·API 검사는 블로그 쪽에서, Python 호환성은 송신 쪽에서 맡고 실제 fixture와 코드 digest를 근거로 결과를 공유했다. 같은 통합 검사를 양쪽에서 다시 실행하는 대신 바뀐 경계를 확인했다.


결과

최종 운영 반영은 수신 ACK 지원 배포를 먼저 확인한 뒤 진행했다. 검토한 exporter SHA와 main workflow를 연결하고, 로컬 publisher를 opt-in으로 활성화했다. 실제 동기화 revision을 대상으로 한 첫 실행에서 Actions 성공과 strict receipt 검증까지 확인했다.

첫 운영 영수증은 applied였고, 적용 순번은 6이었다. 요청한 revision과 실제 적용한 revision이 같았으며, 남은 pending 요청은 0이었다. 이 결과는 특정한 한 번의 운영 경로가 끝까지 이어졌다는 근거이며 장기 운영 전체를 검증했다는 뜻은 아니다.

항목최종 상태
기기별 자료와 정확한 중복 제거 근거기존 Git·Python 계층 유지
블로그 DB기존 집계 결과 저장
하루 수집 예약기존 오전 9시 타이머 유지
수집 후 발행동기화 성공 직후 main workflow 호출
발행 실패 처리영속 요청·시도와 실행·영수증 확인
서버 ACK실제 적용 순번과 revision을 원자적으로 반환
원본 DB·서버 정정 v3운영 활성화 없이 중단 상태 보존
Git 기반 원본 정정과 승인·감사 연결이번 최소 범위 밖의 미완료 후속

검증은 마지막 변경의 영향 경로를 기준으로 했다. 송신 측의 요청·재시도 검사 22개, exporter 검사 12개, 기존 동기화에서 선택한 4개를 합쳐 고유 38개가 통과했다. 수신부의 실제 ACK fixture는 Python에서도 정상 적용·재적용·최신 상태 우선과 오류 거부를 대조했다.

용량 실측이나 이미 통과한 정정 검사를 운영 전환 때문에 다시 반복하지 않았다. v1·v2의 통과 결과는 실험 이력으로 보존했고, 미완성 v3는 통과한 기능에 포함하지 않았다. 무료 안전 판단도 초기 185MB 대신 갱신 여유와 미측정 범위를 포함한 결론으로 바꿨다.

남은 한계는 분명하다. 모든 기기의 장기 자동 실행, 모든 장애 조합, Git에 보관하는 원본의 명시적 정정과 감사 연결까지 끝낸 것은 아니다. 서버 관리 권한이 Git 원본의 수정 승인으로 자동 전환되는 것도 아니며, 새 보존 정책이나 삭제 작업도 수행하지 않았다.


느낀점

  • 용량에는 교체하는 순간이 포함돼야 했다. 현재 한 벌의 크기만 보면 무료 한도에 들어갔지만, 이전 세대와 staging을 더하니 결론이 달라졌다.
  • 중복 제거와 원본 보존은 다른 비용이었다. 집계에서 한 번만 세는 자료라도 기기별 출처와 원본을 남기면 저장 비용은 계속 존재했다.
  • 자동화의 끝은 호출이 아니라 확인이었다. 동기화 revision부터 실행과 수신 영수증까지 연결해야 원하는 자료가 실제 반영됐는지 알 수 있었다.
  • 중단한 구현도 정확히 기록해야 했다. 검증한 계약과 미완성 코드를 보존하되 운영 기능처럼 설명하지 않아야 다음 작업의 출발점이 분명해졌다.
  • 검사는 반복 횟수보다 근거의 연결이 중요했다. 같은 코드와 fixture의 통과 결과를 재사용하고 변경 경계만 확인하면서도 송수신의 계약을 맞출 수 있었다.

이번에는 기능을 더 넓히는 대신 저장 방향을 되돌아봤다. DB 원본 설계를 계속 밀어붙이는 것보다 기존 Git 경로를 살리고 필요한 발행 연결만 고치는 편이 내가 정한 운영 조건에 맞았다. 무료 한도를 유지한다는 조건을 마지막에 맞추는 것이 아니라 설계의 기준으로 다시 놓은 셈이다.

세 편에 걸쳐 화면에서 시작해 기록과 발행까지 정리했다. 아직 남은 정정 작업과 장기 운영 확인은 이후의 실제 결과가 쌓였을 때 이어서 적으려고 한다. 긴 개발기를 끝까지 읽어줘서 감사하다.

좋아요 —
hamburger icon

무링의 개발 블로그

FDE를 향하는 개발자


댓글

GitHub 계정으로 로그인하면 댓글을 남길 수 있습니다.

로그인

연관된 글