블로그 개발기 - 같은 코드인데 왜 두 배포의 글 개수가 다를까
어느 날, 숫자가 안 맞았다
내 블로그는 두 군데에서 돌아간다.
- doowiki.dev — 진짜 본진(RHEL 서버)
- vercel.app — 본진이 죽었을 때 대신 받아주는 백업 서버(Vercel fallback)
평소엔 신경 쓸 일이 없는데, 어느 날 두 화면 아래쪽 문구가 서로 다른 걸 발견했다.
doowiki.dev -> 223개의 공개 문서 / 비공개 1432개
doo-blog.vercel.app -> 203개의 공개 문서 / 비공개 1452개
공개 문서가 20개나 차이 난다.
같은 글을, 같은 코드로 보여주는데 왜일까
머릿속에 떠오른 용의자는 넷이었다.
- DB가 다른가? (두 곳이 보는 데이터베이스가 다른가)
- 공개 정책 설정이 다른가? (어떤 폴더를 숨길지 정하는 설정)
- 캐시 때문인가? (한쪽이 옛날 걸 들고 있나)
- 배포가 덜 됐나? (Vercel이 옛날 글을 서빙 중인가)
단서 0: 합계가 똑같다
본격적으로 파기 전에, 숫자를 가만히 들여다봤다.
doowiki : 공개 223 + 비공개 1432 = 1655
vercel : 공개 203 + 비공개 1452 = 1655
합계가 1655로 똑같다.
이게 생각보다 큰 힌트였다. 만약 "Vercel이 글을 덜 가져왔다"면 합계(전체 문서 수)부터 달라야 한다. 그런데 전체는 같고, 공개/비공개 분류만 20개가 엇갈린다.
즉 문제는 *"문서가 빠졌다"*가 아니라 *"같은 문서를 한쪽은 공개, 한쪽은 비공개로 본다"*에 가깝다.
이 한 줄 덕분에 "배포 누락" 가설의 우선순위가 확 내려갔다.
공개/비공개는 무엇으로 정해질까
코드를 열어 "이 글이 공개냐 비공개냐"를 정하는 로직을 찾았다. 딱 세 가지로 결정되고 있었다.
1) 폴더 규칙 (blog.config.json: 어떤 폴더는 통째로 비공개)
2) 글 머리말 (frontmatter에 published:false / draft:true 면 비공개)
3) DB 개별 설정 (visibilityOverride: 글 하나를 손으로 숨김/공개)
최종 공개여부 = DB설정 ?? (폴더규칙 또는 머리말로 정해진 기본값)합계(파일 수)가 같으니 (1) 파일 집합과 (2) 머리말은 두 곳이 동일할 가능성이 높았다. 그럼 남는 용의자는 config 아니면 DB.
용의자 1·2를 지우다: config와 DB
config(폴더 규칙)부터
본진 서버의 실제 설정 파일과 코드 저장소의 설정 파일을 나란히 찍어 비교했다.
라이브 서버 /var/lib/doo-blog/blog.config.json
저장소 apps/web/config/blog.config.json
-> rootVisibility, 폴더 규칙 9개 ... 한 글자도 안 틀리고 동일여기서 한 번 가슴이 철렁했다.
왜냐하면 관리자 화면에서 공개 정책을 바꾸면 그 변경은 서버 파일에만 저장되고, 저장소에는 커밋되지 않는 구조였기 때문이다.
"아, 이거 둘이 어긋나 있겠구나" 싶었는데…
막상 비교하니 똑같았다. 용의자 1, 무죄.
그다음 DB
Vercel 백업은 본진 DB를 그대로 쓰는 게 아니라, 민감한 테이블을 비운 공개용 스냅샷을 2시간마다 GitHub에 올려서 받아 쓴다.
그 스냅샷을 실제로 내려받아 본진 DB와 visibilityOverride(개별 숨김/공개) 분포를 비교했다.
본진 DB : 설정없음 1647 / 비공개 5 / 공개 1
Vercel 스냅샷 : 설정없음 1647 / 비공개 5 / 공개 1 ← 완전히 동일개별 설정은 다 합쳐도 6개뿐. 20개 차이를 만들 수가 없다. 게다가 스냅샷과 본진이 똑같다. 용의자 2, 무죄.
이쯤 되니 좀 황당했다. DB도 같고, 설정도 같고, 파일 수도 같은데…
대체 뭐가 20개를 갈라놓는 거지?
결정적 장면: 폴더별로 쪼개서 보다
다행히 나는 이 문제를 쫓느라 미리 진단용 엔드포인트(/api/health?verbose=1)를 만들어 뒀었다.
이게 폴더별로 "디스크에 파일 몇 개 있고, 그중 몇 개를 실제로 인덱싱했는지"를 보여준다.
두 사이트에 똑같이 찔러봤다.
디스크 파일수 본진 인덱싱 Vercel 인덱싱
PARA 1630 1630 989 <- 641개 증발
Zettelkasten 19 19 0 <- 19개 통째로 증발
99_TODO 2 2 1찾았다.
- 디스크에 있는 파일 수(맨 왼쪽)는 두 곳이 똑같다 -> 파일은 다 거기 있다.
- 그런데 Vercel은 그중 약 660개를 "인덱싱"하지 못하고 있었다. 특히 Zettelkasten 폴더는 19개가 통째로 사라졌다.
그리고 사라진 19개(전부 공개 글) + PARA 한 개 = 딱 20개. 우리가 찾던 그 20개다.
그럼 이 "증발한" 글들은 어디로 갔을까? 코드를 보니 한 줄이 모든 걸 설명했다.
privateCount = 전체파일수 - 공개수 // <- 함정비공개 수를 **"전체에서 공개를 뺀 값"**으로 계산하고 있었다. 그래서 인덱싱에 실패해 사라진 660개가 조용히 "비공개"로 둔갑한 것이다. 그래서 겉보기엔 그냥 "공개가 20개 적네?" 정도로만 보였지, 사실은 660개가 실패하고 있었는데 아무도 몰랐다.
범인 검거: "순차로 읽으면 되는데, 한꺼번에 읽으면 안 된다"
이제 질문은 하나. 왜 Vercel에서만 파일 인덱싱이 실패할까?
여기서 또 하나의 진단 엔드포인트가 빛을 발했다. Zettelkasten 폴더만 따로 읽어보는 /api/debug/zettel을 Vercel에 찔러봤더니…
okCount: 19 <- 19개 전부 정상으로 읽힘어? 똑같은 Vercel 환경에서, 똑같은 19개 파일을, 이 엔드포인트는 멀쩡하게 다 읽는다.
그럼 파일이 안 읽히는 게 아니다. 파일은 분명히 거기 있고, 읽을 수도 있다.
두 코드의 차이가 뭘까 하고 나란히 놓고 봤다.
// 메인 인덱싱 (실패하는 쪽): 1655개를 "한꺼번에" 동시에 읽음
const posts = await Promise.all(
files.map(async (file) => { /* 파일 읽기 */ })
);
// 진단 엔드포인트 (성공하는 쪽): 하나씩 "순서대로" 읽음
for (const file of files) { /* 파일 읽기 */ }Promise.all은 1655개 파일을 동시에 와르르 열어젖힌다. 내 본진 RHEL 서버는 한 번에 열 수 있는 파일 수 한도(file descriptor)가 넉넉해서 아무 문제가 없다. 그런데 Vercel 서버리스 람다는 이 한도가 훨씬 빡빡하다.
그래서 1655개를 동시에 열려다가 한도를 넘긴 순간부터 "파일을 더 못 열겠다(EMFILE)"는 에러가 터지고, 그게 딱 660개쯤에서 시작된 것이다. 반면 하나씩 순서대로 읽는 진단 엔드포인트는 한 번에 한 개만 여니까 한도를 넘길 일이 없어서 다 성공했던 거고.
비유하자면 이렇다.
좁은 회전문(Vercel)에 1655명이 동시에 밀고 들어가려다 문이 끼어버렸다.
본진(RHEL)은 문이 넓어서 다 통과했다.
한 명씩 차례로 들어가니(진단 엔드포인트) 좁은 문이어도 다 통과했다.
그리고 결정적으로, 그 끼어버린 사람들(실패한 파일들)을 코드가 catch {}로 아무 말 없이 삼켜버려서, 우리는 한참을 몰랐다.
고치기: 세 군데를 손봤다
원인을 알고 나니 수정은 깔끔했다.
**1. 동시에 읽는 파일 수를 제한한다 **
Promise.all로 무한정 동시에 읽지 말고, 한 번에 최대 32개씩만 읽도록 작은 헬퍼를 만들었다. 회전문을 넓힌 게 아니라, 줄을 32명씩 끊어서 들여보낸 셈이다.
// 동시 실행을 limit 개로 묶는 헬퍼 (순서는 그대로 보존)
await mapWithConcurrency(files, 32, async (file) => { /* 파일 읽기 */ });2. 에러를 조용히 삼키지 않는다
실패한 파일이 있으면 어떤 파일이 왜 실패했는지 로그로 남기게 했다. 다음에 같은 일이 생기면 이번처럼 660개가 몰래 사라지는 대신, 로그에 바로 찍힌다.
3. 비공개 수를 "진짜 비공개 수"로 센다
전체 - 공개라는 위험한 뺄셈을 버리고, 실제로 잘 읽힌 비공개 글의 수를 직접 셌다. 이제 인덱싱이 실패하면 공개 + 비공개가 전체와 안 맞아서 숫자가 안 맞는 것 자체로 이상 신호가 보인다.
마지막으로, 핵심인 동시성 헬퍼에는 단위 테스트를 붙였다(순서 보존, 동시 실행 수가 한도를 절대 안 넘는지 등).
결과
배포하고 다시 두 사이트를 나란히 찍어봤다.
전체 / 공개 / 비공개
doowiki.dev : 1655 / 223 / 1432
doo-blog.vercel.app : 1655 / 223 / 1432 <- 완전히 일치
PARA : 본진 1630 / Vercel 1630 (이전 989)
Zettelkasten : 본진 19 / Vercel 19 (이전 0)220 vs 203의 간극이 사라지고, 두 배포가 똑같은 숫자를 말하게 됐다.
돌아보며 — 이 삽질에서 건진 것들
- 숫자부터 봐라. "합계가 같다"는 한 줄이 용의자의 절반을 날려줬다.
catch {}로 에러를 삼키지 마라. 660개가 실패하는데도 겉으론 "20개 차이"로만 보였다. 조용한 실패가 제일 무섭다.전체 - 공개 = 비공개같은 뺄셈 계산을 조심하라. 실패를 다른 정상 상태로 둔갑시켜 버린다. 합이 맞는지로 검증할 수 있게 각각 세는 게 안전하다.Promise.all은 공짜가 아니다. 내 노트북·내 서버에선 멀쩡하던 코드가 서버리스의 빡빡한 자원 한도에선 무너진다. 대량 I/O는 동시성에 상한을 둬야 한다.- 진단용 엔드포인트를 미리 심어둔 과거의 내게 감사한다. "폴더별로 몇 개 인덱싱됐나", "이 폴더만 따로 읽으면 되나" — 이 두 개가 없었으면 훨씬 헤맸을 거다.
같은 코드가 환경에 따라 다르게 동작할 때, 답은 보통 "코드가 가정한 환경"과 "실제 환경"의 틈새에 있다. 이번엔 그 틈이 "파일을 한 번에 몇 개나 열 수 있는가" 였다.
댓글
첫 번째 댓글을 남겨보세요.