블로그 개발기 5 (조용한 버그 편)

하루 동안 고친 열 몇 가지 - 빌드도 lint 도 통과하고 화면도 멀쩡했던 것들
calendar icon

2026년 9월 7일

조회 3

tag icon

하루 동안 커밋 18개를 쌓았다. 34개 파일에 2,219줄이 들어가고 151줄이 빠졌다.

그중 오류를 낸 것은 하나도 없었다. yarn lint 도 yarn tsc 도 yarn build 도 전부 통과하고 있었다. 화면도 멀쩡했다. 페이지는 뜨고, 숫자는 나오고, 버튼은 눌렸다.

그런데 숫자가 틀렸고, 목록에 없는 파일이 있었고, 붙어 있어야 할 것이 안 붙어 있었다.

"이건 버그가 아니라 그냥 좀 늦는 건데?"

이 문장을 하루에 네 번쯤 했다. 네 번 다 틀렸다. 그래서 그때마다 재봤고, 재보면 거의 항상 생각보다 나빴다. 이 글은 그 기록이다.


조회 한 번에 수정일이 나흘 밀렸다

관리 목록에 "수정일" 열을 붙이려던 참이었다. 컬럼은 이미 있었다. updatedAt 을 그대로 내려주면 끝나는 일로 보였다.

붙이기 전에 한 번 확인했다. 이 값이 정말 "고친 날" 인가?

포스트 조회수를 올리는 코드는 이렇게 생겼다.

await prisma.post.updateMany({
    where: { slug, status: "PUBLISHED" },
    data: { viewCount: { increment: 1 } },
});

@updatedAt 은 Prisma 가 update 계열에 자동으로 얹는 값이다. updateMany 에도 얹힌다. 즉 누가 글을 읽을 때마다 updated_at 이 지금으로 바뀐다.

되돌려지는 트랜잭션 안에서 조회수를 1 올려봤다.

조회수 +1 전   2026-09-03T02:03:21Z
조회수 +1 직후 2026-09-07T01:49:53Z

한 번 읽었더니 나흘이 밀렸다. 댓글 수 증감도 같은 모양이었다.

그러니까 그 열에 "수정일" 이라고 이름을 붙였으면 화면이 조용히 거짓말을 하는 상태가 된다. 지금은 트래픽이 없어서 그럴듯해 보였을 뿐이다. 방문자가 늘면 모든 글의 수정일이 오늘로 수렴한다.

셋 다 raw SQL 로 바꿨다.

await prisma.$executeRaw`
    UPDATE posts SET view_count = view_count + 1
     WHERE slug = ${slug} AND status = 'PUBLISHED'
`;

@updatedAt 은 클라이언트가 붙이는 값이라 raw SQL 에는 따라오지 않는다. 카운트가 어긋나면 안 되는 자리는 그대로라 댓글 쪽 트랜잭션은 유지했다.

쓰고 있는 썸네일이 "고아" 로 잡혔다

이미지 정리 스크립트가 하나 있다. 어떤 글도 참조하지 않는 Storage 이미지를 찾아 지운다. 돌려보니 이렇게 나왔다.

Storage 객체        8개
포스트가 참조 중    6개
유예 시간 내 제외   3개 (최근 24시간)
고아                0개

고아 0개. 넘어갈 뻔했다. 그런데 "유예 시간 내 제외 3개" 가 눈에 걸렸다. 24시간이 지나면 저 셋은 어떻게 판정될까?

유예를 0으로 두고 다시 돌렸다.

고아 3개
  2026-09/a69d87c4-....png   <- 발행된 글이 쓰고 있는 썸네일

참조를 모으는 함수가 contentMd 만 훑고 있었다. 썸네일 주소는 본문에 나타나지 않는다. 별도 컬럼에 있으니까.

옛 글들이 public/thumbnails 의 정적 파일을 쓰고 있어서 여태 안 터진 것이었다. 에디터로 썸네일을 올린 글이 처음 생기는 순간부터 해당된다. 하루만 늦었으면 발행된 글의 대표 이미지가 사라졌을 것이다.


깊이를 안다고 가정한 자리

같은 실수를 하루에 세 번 밟았다.

첫 번째. 버킷 목록을 훑는 함수가 한 겹만 내려갔다. 업로드 경로가 YYYY-MM/파일명 이라 그걸로 충분했고, 주석에도 그렇게 적혀 있었다. 이미지 경로를 thumbnails/<slug>/파일 로 바꾸는 순간 새 파일이 통째로 안 보이게 된다. 그러면 정리 스크립트는 "고아 0개" 라고 답하면서 아무것도 하지 않는다.

두 번째. 이미지 선택 화면을 만들면서 정적 파일도 목록에 넣었는데, public/thumbnails 만 훑었다. 마이그레이션으로 넘어온 옛 본문 이미지는 public/images 아래에 있다. 그래서 "본문" 태그가 45개 중 5개만 잡히고 있었다.

세 번째. 그걸 고치면서 "images 아래는 글마다 폴더 한 겹" 으로 보고 두 단계를 훑었다. find 로 센 57개와 목록의 47개가 안 맞았다.

public/images/docker-gcp/01/...
public/images/docker-gcp/02/...
public/images/docker-gcp/04/...

docker-gcp 만 그 안에 한 겹이 더 있었다. 10장이 조용히 빠졌다.

세 번 다 "이 정도 깊이면 되겠지" 였고, 세 번 다 예외가 있었다. 결국 전부 재귀로 바꿨다.

"깊이를 아는 코드는 언젠가 틀린다. 모른다고 쓰는 편이 짧기도 하다."

로컬에서만 멀쩡한 경로

재귀로 바꾸고 나니 새 문제가 생겼다. Windows 의 readdirSync 는 구분자를 역슬래시로 준다.

/images/docker-gcp\01\image.png

그대로 주소에 쓰면 배포본에서 깨진다. 그런데 로컬에서는 크롬이 알아서 고쳐 보여준다. 눈으로는 절대 못 잡는다. 배포하고 나서야, 그것도 그 글을 열어봐야 알 수 있다.

sep 으로 잘라 / 로 잇는 한 줄이면 되는데, 그 한 줄이 필요하다는 걸 아는 게 전부였다.


재보기 전에는 모른다

에디터의 본문과 미리보기가 따로 스크롤되게 만들고, 한쪽을 굴리면 다른 쪽도 같은 곳을 보게 이었다. 처음에는 "굴릴 수 있는 거리를 비율로 맞추기" 로 갔다. 서른 줄이면 된다.

눈으로 보니 잘 맞았다. 제목이 나란히 섰다.

그런데 얼마나 맞는 걸까? 원문에서 제목 줄의 픽셀 위치를 재고, 미리보기의 제목 위치와 비교했다.

평균 244px, 최대 594px  (칸 높이 679px)

최악의 경우 거의 한 화면이 밀린다. 눈으로 본 자리가 마침 잘 맞는 구간이었을 뿐이다.

그래서 제목을 기준점으로 삼고 구간마다 따로 비율을 맞추는 방식으로 올렸다. 원문 쪽 제목의 픽셀 위치는 같은 글꼴·같은 폭의 보이지 않는 거울을 그려서 잰다. textarea 는 줄의 위치를 알려주지 않고, "줄 번호 × 줄 높이" 로 대신할 수도 없다. 줄바꿈이 있고, 한글은 Consolas 에 없어 대체 글꼴로 그려지므로 글자 수로 폭을 셀 수 없다.

측정 도구가 틀려서 결과가 뒤집혔다

새 방식을 검증하려고, 빈 줄로 갈리는 덩어리를 양쪽에서 세어 순서대로 짝지었다. 그리고 이런 결과를 받았다.

옛 방식새 방식
blog-development-2중앙값 211px중앙값 1,483px

새 방식이 일곱 배 나빴다. 잠깐 그대로 믿을 뻔했다.

그런데 그 글에서 덩어리 개수가 63과 53으로 안 맞았다. 중첩 목록 하나가 여러 원문 블록을 <ul> 하나로 삼킨 것이다. 개수가 다른데 순서로 짝지었으니 비교 자체가 무의미했다.

순서가 아니라 내용으로 짝짓게 고치고 다시 쟀다.

글옛 방식새 방식
blog-development-4167px16px
blog-development-21,625px141px
docker-gcp-01 (이미지 8장)126px73px

결과가 반대로 뒤집혔다. 옛 방식은 이미지가 많은 글에서 1,625px, 그러니까 칸 두 개 반쯤 밀리고 있었다.

숫자를 못 믿겠다고 생각한 이유는 단순했다 — 개수가 안 맞는데 짝을 지었기 때문이다. 재는 쪽이 틀리면 재지 않느니만 못하다.


화면은 맞는데 화면이 틀렸다

태그를 고치고 발행했는데 반영이 안 된다는 이야기를 들었다. 확인해보니 이랬다.

DB고친 대로 저장돼 있음
배포본이 내보내는 HTML고친 대로 나옴
브라우저 화면옛 태그

두 번 재현해봤는데 둘 다 정상이었다. 그러다 "새로고침하니까 맞게 나왔다" 는 말에서 걸렸다.

Next 의 App Router 는 브라우저에도 라우터 캐시를 따로 둔다. 관리 목록에서 글 제목 링크를 미리 받아둔 적이 있으면, 그 낡은 payload 가 최대 5분 동안 쓰인다. 서버에서 revalidatePath 를 불러도 그건 서버 캐시만 지운다.

이 캐시를 자동으로 비워주는 것은 Server Action 뿐이다. 라우트 핸들러를 fetch 로 부르는 자리에서는 직접 해야 한다.

router.refresh();   // 라우터 캐시 비우기
router.push("/" + slug);

push 뒤가 아니라 앞이다. 뒤에 두면 낡은 화면이 한 번 그려진 다음 바뀌어 눈에 띈다.

원래 코드에는 "여기서 router.refresh() 를 부르지 않는다. 목적지는 이미 서버에서 무효화돼 있어 새로 받아온다" 는 주석이 달려 있었다. 내가 쓴 주석이고, 서버 캐시만 보고 내린 판단이었다.

카드의 조회수도 같은 종류였다

포스트 상세 페이지의 조회수는 실시간이다. 페이지는 캐시하고 숫자만 따로 받아오게 해뒀다. 그런데 목록 카드는 서버가 그려준 값을 그대로 쓰고 있었다.

같은 시각에 재보면 이렇다.

캐시된 목록 DAL   2
실제 DB           5

카드를 볼 때마다 캐시를 깰 수도 없다. 방문 한 번에 목록 전체가 다시 만들어지면 캐시를 두는 이유가 사라진다. 그래서 상세 페이지와 같은 방식을 카드에도 적용했다. 다만 카드는 여러 장이라 slug 마다 부르면 홈에서만 스무 번이 넘으므로, 발행 포스트 전체를 한 번에 받아 나눠 쓴다. 카드가 모두 같은 쿼리 키를 쓰므로 화면당 요청은 하나다.

카드 6장에 요청 1건인 것을 확인하고 넘어갔다.


규칙집은 내가 밟을 때 쓸모가 있다

저장소에 규칙을 적어둔 문서가 있다. 실제로 한 번씩 깨뜨려 본 것만 적는 곳이다. 거기에 이런 줄이 있다.

셸에서 grep | head -1 은 매치가 없어도 exit 0 이다. 이걸로 "없음" 을 판정하면 거짓 결과가 나온다.

이 저장소는 public 이라 푸시 전에 비밀값 패턴을 훑는다. 18개 커밋을 한 번에 올리는 참이었고, 이렇게 썼다.

git diff origin/main..main | grep -E 'sb_secret_|...' | head -20 || echo "걸린 것 없음"

아무것도 출력되지 않았다. 그리고 "걸린 것 없음" 도 출력되지 않았다. head 가 exit 0 이라 || 가 실행되지 않은 것이다. 아무 신호도 없는 상태를 "안전" 으로 읽을 뻔했다.

grep -c 로 패턴마다 개수를 찍게 바꿨다.

sb_secret_                     0건
service_role                   0건
postgres\.[a-z]{16,}:          0건

0이라고 적혀 있는 것과 아무것도 안 나오는 것은 다르다. 내가 적어둔 규칙에 내가 걸렸다.

사흘 된 서버를 보고 있었다

작업 중에 "고친 게 하나도 안 보인다" 는 상황이 있었다. 파일은 분명히 바뀌어 있었다.

.next/BUILD_ID 만들어진 시각   2026-09-04 15:00
서버 프로세스 시작 시각         2026-09-04 15:00:36
내가 고친 파일들                2026-09-07 10:54 ~ 11:17

next start 는 미리 컴파일된 결과를 내보내는 명령이라 소스를 아예 읽지 않는다. 사흘 전 빌드를 계속 서빙하고 있었다.

덤으로 하나 더 알았다. next dev 에서 페이지가 낡은 값을 보여주길래 .next/cache 를 지웠는데도 그대로였다. 캐시가 메모리에도 있어서 서버를 내려야 지워진다.


결과

전후
관리 목록의 "수정일"마지막으로 읽힌 날실제로 고친 날
이미지 정리쓰는 썸네일을 고아로 판정참조 5 → 6, 오판 없음
이미지 목록본문 이미지 45개 중 5개65개 전부 (find 결과와 일치)
에디터 스크롤 연동없음어긋남 중앙값 16px
카드 조회수최대 1시간 낡음실시간, 화면당 요청 1건
발행 후 이동새로고침해야 반영바로 반영

Storage 경로도 정리했다. 2026-09/<uuid>.png 한 곳에 쌓이던 것을 글과 쓰임으로 나눴다.

thumbnails/<slug>/thumbnail-<hash>.png
posts/<slug>/image-1-<hash>.png

이름 뒤의 8자리는 유일성 때문이 아니다. 그건 폴더와 번호가 맡는다. 업로드에 1년 캐시가 걸려 있어서 같은 주소에 다른 그림이 올라가면 이미 본 사람에게는 옛 그림이 최대 1년 동안 보인다. 주소를 절대 재사용하지 않으려고 붙였다. 그래도 겹치면 이름을 다시 만들어 세 번까지 시도한다. "드물다" 와 "불가능하다" 는 다르다.

느낀점

  • 오류를 내지 않는 버그가 제일 오래 산다. 오늘 고친 것 중 스택 트레이스를 남긴 것은 하나도 없었다. 빌드도 lint 도 전부 통과했고, 그래서 몇 주씩 살아 있었다. 화면이 뜬다는 것은 아무것도 보장하지 않는다.
  • 깊이를 안다고 가정하지 않는다. 하루에 세 번 밟았다. "여기는 한 겹" 이라는 판단은 그때는 맞았고 나중에 틀렸다. 재귀로 쓰면 처음부터 안 틀린다.
  • 재는 쪽이 틀리면 재지 않느니만 못하다. 개수가 안 맞는데 순서로 짝지었더니 결과가 정확히 반대로 나왔다. 숫자를 믿기 전에 그 숫자를 만든 방법부터 의심해야 한다.
  • "아무 일도 안 일어났다" 를 성공으로 읽지 않는다. grep | head 가 조용한 것과 0건이라고 적혀 있는 것은 다르다. 확인은 값을 봐야 확인이다.
  • 내 주석도 근거가 아니다. 라우터 캐시를 안 비운 이유가 주석으로 또렷하게 적혀 있었고, 그 판단이 틀렸다. 자신 있게 적어둔 문장일수록 다시 안 보게 된다.

돌아보면 오늘 한 일의 절반은 고치는 것이었고 절반은 재는 것이었다. 그런데 고칠 것을 찾아준 건 대부분 재는 쪽이었다. 눈으로 보고 "잘 되네" 하고 넘어갔으면 여섯 개쯤은 그대로 남았을 것이다.

여기까지 읽어주었다면 정말 감사드린다.

좋아요 —
hamburger icon

무링의 개발 블로그

FDE를 향하는 개발자


댓글

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

로그인

연관된 글