PARA/03_Resources/R001_개발_레퍼런스(참고문서)/트러블슈팅/블로그 개발기 - 같은 코드인데 왜 두 배포의 글 개수가 다를까.md

블로그 개발기 - 같은 코드인데 왜 두 배포의 글 개수가 다를까

어느 날, 숫자가 안 맞았다

내 블로그는 두 군데에서 돌아간다.

  • doowiki.dev — 진짜 본진(RHEL 서버)
  • vercel.app — 본진이 죽었을 때 대신 받아주는 백업 서버(Vercel fallback)

평소엔 신경 쓸 일이 없는데, 어느 날 두 화면 아래쪽 문구가 서로 다른 걸 발견했다.

doowiki.dev        ->  223개의 공개 문서 / 비공개 1432개
doo-blog.vercel.app ->  203개의 공개 문서 / 비공개 1452개

공개 문서가 20개나 차이 난다.
같은 글을, 같은 코드로 보여주는데 왜일까

머릿속에 떠오른 용의자는 넷이었다.

  1. DB가 다른가? (두 곳이 보는 데이터베이스가 다른가)
  2. 공개 정책 설정이 다른가? (어떤 폴더를 숨길지 정하는 설정)
  3. 캐시 때문인가? (한쪽이 옛날 걸 들고 있나)
  4. 배포가 덜 됐나? (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는 동시성에 상한을 둬야 한다.
  • 진단용 엔드포인트를 미리 심어둔 과거의 내게 감사한다. "폴더별로 몇 개 인덱싱됐나", "이 폴더만 따로 읽으면 되나" — 이 두 개가 없었으면 훨씬 헤맸을 거다.

같은 코드가 환경에 따라 다르게 동작할 때, 답은 보통 "코드가 가정한 환경"과 "실제 환경"의 틈새에 있다. 이번엔 그 틈이 "파일을 한 번에 몇 개나 열 수 있는가" 였다.

댓글

첫 번째 댓글을 남겨보세요.