본문 바로가기
Database2026년 10월 14일17분 읽기

조회수 하나가 사이트맵을 망친다 — Prisma updatedAt과 lastmod 오염

YS
김영삼
조회 16
조회수 하나가 사이트맵을 망친다 — Prisma updatedAt과 lastmod 오염

글을 볼 때마다 ORM의 update로 조회수를 올리면, 자동으로 채워지는 updatedAt이 매번 "지금"으로 바뀌고 사이트맵 lastmod는 "마지막으로 누가 본 시각"이 된다. 해결은 카운터를 updatedAt을 건드리지 않는 별도 경로로 올리는 것이다.

구글이 영삼넷 글을 잘 색인하지 않아서 사이트 전체를 점검하다가 찾은 문제다. 글 수천 개가 전부 "방금 수정됨"으로 보이고 있었다. 범인은 조회수 한 줄이었다. 같은 점검에서 나온 중복 페이지 정리나 canonical 상속 문제는 다른 글에서 따로 다룬다.

3줄 요약
  • prisma.post.update({ data: { views: { increment: 1 } } })는 조회수만 올리는 것 같지만, @updatedAt 필드도 함께 현재 시각으로 바뀐다.
  • 그 결과 Post 6,149개, 용어사전 3,000개, 자료실 128개가 모두 작성일보다 수정일이 뒤였다. 사이트맵 lastmod와 JSON-LD dateModified가 같이 오염됐다.
  • 카운터는 raw SQL UPDATE ... SET "views" = "views" + 1로 올리는 공용 함수로 바꿨다. Prisma의 @updatedAt은 클라이언트가 채우는 값이라 raw SQL은 건드리지 않는다.

PART 01조회수 한 줄이 수정일을 바꾸고 있었다

영삼넷은 Next.js App Router, Prisma, PostgreSQL로 돌아간다. 글 상세 페이지는 렌더링할 때 조회수를 하나 올렸다. 코드는 아주 평범했다. 아마 Prisma로 조회수를 다루는 사람 대부분이 이렇게 쓸 것이다.

문제가 된 코드
// 기존 코드 — 글을 열 때마다 실행
await prisma.post.update({
  where: { id: post.id },
  data: { views: { increment: 1 } },
})

// schema.prisma
model Post {
  id        Int      @id @default(autoincrement())
  views     Int      @default(0)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  // ...
}

함정은 @updatedAt에 있다. Prisma는 이 속성이 붙은 필드를 update가 일어날 때마다 자동으로 현재 시각으로 채운다. 어떤 컬럼을 바꿨는지는 따지지 않는다. 조회수만 하나 올려도 행이 update된 것이니 updatedAt이 바뀐다. 즉 누가 글을 볼 때마다 그 글의 "수정일"이 지금으로 갱신되고 있었다.

좋아요나 다운로드 수도 같았다. 프로그램 페이지에서 다운로드 수를 올리는 코드도 같은 방식이라, 프로그램의 updatedAt도 "마지막으로 누가 내려받은 시각"이 되어 있었다. 숫자 하나 올리는 코드가 사이트 전체의 날짜 정보를 망가뜨린 셈이다.

삽질 기록 · 사이트맵이 전부 "오늘"이었다
점검 방법은 사이트맵에 있는 주소 9,709개를 전부 구글봇 User-Agent로 요청해서 상태 코드, canonical, robots meta, title을 모으는 스크립트였다. 그런데 사이트맵 자체를 열어 보니 lastmod가 이상했다. 몇 달 전에 쓴 글이 어제나 오늘 날짜로 찍혀 있었다. 처음엔 사이트맵 생성 코드를 의심했는데, 코드는 그냥 updatedAt을 넣고 있을 뿐이었다. 그래서 DB를 직접 열어 작성일과 수정일을 비교해 봤고, 거기서 범위가 드러났다. 수정한 기억이 없는 글까지 전부 수정일이 작성일보다 뒤였다.

PART 02얼마나 오염됐는지 SQL로 확인하기

감으로 "좀 이상하다"가 아니라 숫자로 확인하고 싶었다. 작성 직후 몇 분 안의 차이는 정상일 수 있으니 한 시간 여유를 두고, 그보다 수정일이 늦은 행을 셌다. PostgreSQL의 집계 필터를 쓰면 한 쿼리로 비율까지 나온다.

오염 탐지 쿼리
-- 작성 후 1시간 넘게 지나서 "수정"된 것으로 기록된 행 비율
SELECT
  type,
  count(*)                                                            AS total,
  count(*) FILTER (WHERE "updatedAt" > "createdAt" + interval '1 hour') AS touched,
  round(100.0 * count(*) FILTER (WHERE "updatedAt" > "createdAt" + interval '1 hour')
        / count(*), 1)                                                AS pct
FROM "Post"
GROUP BY type
ORDER BY total DESC;

결과는 생각보다 심했다. 노트, 뉴스, 커뮤니티를 담는 Post 6,149개가 모두, 용어사전 3,000개가 모두, 자료실 128개가 모두 수정일이 작성일보다 뒤였다. 합치면 9,277개 중 사실상 전부다. 이 정도면 "일부가 오염됐다"가 아니라 updatedAt 컬럼이 아무 의미도 없어졌다고 보는 게 맞다.

9,277개
수정일이 작성일보다 뒤인 행(사실상 전부)
6,149개
Post — 노트·뉴스·커뮤니티 전부
3,000개
용어사전 전부
128개
자료실 전부

피해는 사이트맵에서 끝나지 않았다. 글 페이지의 JSON-LD에 들어가는 dateModified도 updatedAt을 그대로 쓰고 있었다. 검색엔진에게 "이 글은 방금 고쳐졌다"고 매번 말하고 있었던 것이다. 실제로는 아무것도 바뀌지 않았는데 말이다.

하나 더 있었다. 사이트맵의 일부 정적 페이지와 놀이터 주소는 lastmod에 new Date()를 넣고 있었다. 요청할 때마다 그 순간의 시각이 찍히니, 이 주소들은 사이트맵을 열 때마다 "방금 바뀜"이었다. 조회수 문제와 원인은 다르지만 결과는 똑같다.

PART 03구글은 일관되게 정확한 lastmod만 믿는다

이게 왜 문제냐면, 구글은 lastmod를 무조건 따르지 않는다. 구글 검색 센터 문서와 공식 블로그는 lastmod가 일관되게 정확할 때 이 값을 크롤링 판단에 쓴다는 취지로 설명한다. 반대로 말하면, 매번 오늘로 찍히는 lastmod는 신뢰할 수 없는 값으로 분류될 가능성이 크다. 그러면 정말로 글을 고쳤을 때도 그 신호가 통하지 않는다.

참고로 사이트맵의 changefreq와 priority는 구글이 무시한다고 밝혀 왔다. 날짜 관련해서 구글이 보는 건 사실상 lastmod 하나다. 그 하나를 망가뜨린 것이다.

영삼
내 생각
검색엔진이 한 번 신뢰를 거두면 되찾는 데 오래 걸린다고 본다. lastmod가 거짓말을 한다는 걸 학습한 뒤라면, 고친 다음에도 그 값을 다시 믿어 주기까지 시간이 필요할 것이다. 그래서 이런 문제는 "나중에 손보자"가 아니라 보이는 즉시 고쳐야 한다. 거짓 신호를 보내는 기간이 길수록 손해가 쌓인다.
dateModified도 같은 원칙이다. schema.org의 dateModified는 콘텐츠가 실제로 바뀐 날을 뜻한다. 조회수나 좋아요처럼 독자 행동으로 바뀌는 값과 엮이면 안 된다. 본문이나 제목처럼 사람이 읽는 내용이 바뀌었을 때만 갱신되어야 한다.

PART 04카운터를 raw SQL로 올리는 공용 함수

고치는 방법은 여러 가지가 있지만, 나는 가장 작은 변경으로 바로 효과를 보는 쪽을 골랐다. 카운터 증가를 raw SQL로 바꾸는 것이다. Prisma의 @updatedAt은 데이터베이스 트리거가 아니라 Prisma 클라이언트가 쿼리를 만들 때 채워 넣는 값이다. 그래서 $queryRaw로 직접 보낸 UPDATE에는 끼어들지 않는다.

다만 raw SQL은 조심해야 한다. 테이블과 컬럼 이름은 파라미터로 바인딩할 수 없어서 문자열로 넣어야 하는데, 여기에 외부 입력이 섞이면 바로 인젝션이다. 그래서 테이블·컬럼 이름은 타입으로 제한된 상수 목록에서만 받고, id와 증가량은 파라미터로 바인딩했다.

공용 카운터 함수 bumpCounter
import { Prisma } from '@prisma/client'
import { prisma } from '@/lib/prisma'

// 허용된 테이블과 카운터 컬럼만 — 외부 입력은 절대 여기로 오지 않는다
const COUNTERS = {
  Post: ['views', 'likes'],
  Program: ['downloads', 'likes'],
} as const
type Table = keyof typeof COUNTERS

export async function bumpCounter<T extends Table>(
  table: T, id: number, cols: (typeof COUNTERS)[T][number][], by = 1,
) {
  const allowed: readonly string[] = COUNTERS[table]
  if (!cols.length || cols.some((c) => !allowed.includes(c))) throw new Error('bad counter column')
  const q = (s: string) => Prisma.raw('"' + s + '"')
  const sets = Prisma.join(cols.map((c) => Prisma.sql`${q(c)} = ${q(c)} + ${by}`))
  const ret = Prisma.join(cols.map(q))
  // @updatedAt은 Prisma 클라이언트가 채우는 값이라 raw UPDATE는 건드리지 않는다
  return prisma.$queryRaw`UPDATE ${q(table)} SET ${sets} WHERE id = ${id} RETURNING ${ret}`
}

// 사용: 결과적으로 UPDATE "Post" SET "views" = "views" + $1 WHERE id = $2 RETURNING "views"
await bumpCounter('Post', post.id, ['views'])

바꾼 뒤에는 직접 확인했다. 글을 한 번 열었더니 views가 374에서 375로 올라갔고, updatedAt은 그대로였다. 원하던 동작이다. 이제 조회수, 좋아요, 다운로드 수를 올리는 곳은 전부 이 함수 하나를 거친다. 나중에 카운터 저장 방식을 바꾸고 싶어져도 이 함수만 고치면 된다.

정적 페이지의 new Date()는 더 간단하게 처리했다. 실제로 그 페이지를 손본 날을 상수로 적어 두고, 언제 바꿨는지 모르는 페이지는 lastmod를 아예 빼 버렸다. 틀린 날짜를 넣는 것보다 없는 편이 낫다. lastmod가 없으면 구글은 다른 신호로 판단할 뿐이고, 그것 때문에 불이익을 받는다는 이야기는 들어 본 적이 없다.

PART 05raw SQL 말고 다른 설계는 없을까

솔직히 raw SQL 카운터는 응급처치에 가깝다. 근본적으로는 카운터와 콘텐츠를 다르게 다루는 설계가 맞다. 내가 고민했던 대안들을 정리하면 이렇다.

조회수 카운터 설계 대안 비교
방식장점단점
raw SQL로 카운터만 증가변경 범위가 가장 작다. 바로 적용 가능같은 행을 계속 갱신하니 쓰기 경합과 행 버전이 쌓인다. 실수로 Prisma update를 쓰면 재발
별도 contentUpdatedAt 컬럼의미가 분명하다. 본문·제목 저장할 때만 갱신저장 경로마다 직접 넣어야 한다. 빠뜨리면 날짜가 안 바뀜
조회수를 별도 테이블로 분리콘텐츠 행이 아예 안 바뀐다. 가장 깔끔한 정규화목록에서 조인이나 추가 조회가 필요하다
Redis 등에 누적 후 집계 배치쓰기 부하가 거의 없다. 트래픽이 많을 때 유리구성 요소가 늘고, 배치 주기만큼 숫자가 늦게 반영된다

나는 지금 규모에서는 raw SQL 카운터로 충분하다고 판단했다. 대신 장기적으로는 카운터를 별도 테이블로 빼는 게 원래 맞다고 생각한다. 글 행은 글의 내용을 담는 곳이고, 독자가 얼마나 봤는지는 다른 성격의 데이터다. 같은 행에 두니까 이런 사고가 난 것이다.

영삼
내 생각
"자동으로 채워지는 컬럼"은 의미를 먼저 정의하고 써야 한다. updatedAt이 행이 바뀐 시각인지 콘텐츠가 바뀐 시각인지 정하지 않고 쓰면, 어느 날 그 값을 사이트맵이나 구조화 데이터 같은 바깥 세계에 내보내는 순간 문제가 된다. 나는 앞으로 외부에 내보내는 날짜는 이름부터 다르게 만들 생각이다. 편리함 때문에 의미가 다른 두 값을 한 컬럼에 섞는 건 반대다.

PART 06이미 오염된 날짜 되돌리기

코드를 고친다고 이미 망가진 값이 돌아오지는 않는다. 9,277개의 updatedAt은 여전히 "누가 마지막으로 본 시각"이다. 다행히 이 사이트에서 글 내용을 실제로 고친 기록은 거의 없다. 그래서 updatedAt을 createdAt으로 되돌리는 일괄 작업을 준비했다. 실제 내용 수정이 드문 상황에서는 이쪽이 진실에 훨씬 가깝다.

1
백업부터
대상 테이블의 id, createdAt, updatedAt을 따로 떠 둔다. 판단이 틀렸으면 그대로 되돌릴 수 있게.
2
예외 목록 확인
실제로 내용을 고친 게 확실한 글이 있다면 그 글은 제외하거나 고친 날을 따로 넣는다.
3
일괄 UPDATE
UPDATE ... SET "updatedAt" = "createdAt"를 raw SQL로 실행한다. 컬럼끼리 대입하는 일괄 작업이라 raw SQL이 자연스럽고, @updatedAt이 끼어들 틈도 없다.
4
탐지 쿼리 재실행
위의 FILTER 쿼리로 수정일이 뒤인 행이 사라졌는지 확인하고, 사이트맵을 다시 열어 lastmod를 본다.

이 글을 쓰는 시점에 이 일괄 작업은 실행 승인을 기다리고 있다. 수천 개 행의 날짜를 한 번에 바꾸는 일이라 서두르지 않기로 했다. 코드는 이미 고쳤으니 더 이상 오염이 늘어나지는 않는다. 급한 불은 끈 상태다.

복구 스크립트에서도 같은 함정. 복구 작업 중에 updatedAt을 명시하지 않은 Prisma update가 하나라도 섞이면 그 행에는 또 지금 시각이 들어간다. 복구 스크립트는 raw SQL 한 문장으로 끝내고, 실행 후 몇 행을 직접 조회해 확인하는 게 마음 편하다.
조회수 사고에서 얻은 교훈
  1. ORM의 자동 updatedAt은 어떤 컬럼이 바뀌었는지 따지지 않는다. 카운터 증가도 "수정"이 된다.
  2. 카운터는 updatedAt을 건드리지 않는 경로(raw SQL, 별도 테이블, 배치)로 올린다.
  3. 테이블·컬럼 이름은 상수 화이트리스트로만, 값은 파라미터 바인딩으로 넘긴다.
  4. 사이트맵 lastmod와 dateModified는 실제 콘텐츠 변경일만 쓰고, 모르면 생략한다.
  5. 이미 오염된 값은 백업 후 raw SQL로 일괄 복구하고 탐지 쿼리로 다시 확인한다.

자주 묻는 질문

Prisma에서 조회수를 올리면 updatedAt이 바뀌나요?

바뀐다. @updatedAt이 붙은 필드는 Prisma 클라이언트로 update를 실행할 때마다 현재 시각으로 자동 갱신된다. 조회수만 increment해도 마찬가지다. 이를 피하려면 $queryRaw나 $executeRaw로 직접 UPDATE를 보내야 한다.

사이트맵 lastmod가 매번 오늘 날짜면 어떻게 되나요?

구글은 lastmod가 일관되게 정확할 때 그 값을 쓴다는 입장이라, 매번 바뀌는 값은 무시될 가능성이 높다. 그러면 실제로 글을 고쳤을 때도 재크롤링 신호로 쓰이지 않는다. 정확한 날짜를 모르면 lastmod를 빼는 편이 낫다.

사이트맵의 changefreq와 priority는 효과가 있나요?

구글은 changefreq와 priority를 사용하지 않는다고 밝혀 왔다. 넣어도 해는 없지만 구글 검색 기준으로는 의미가 없다. 날짜 신호로는 정확한 lastmod 하나에 집중하는 게 낫다.

$queryRaw로 테이블 이름을 넘겨도 안전한가요?

테이블과 컬럼 이름은 파라미터로 바인딩할 수 없어서 Prisma.raw로 문자열을 넣어야 한다. 그래서 외부 입력을 절대 넣지 말고, 코드 안에 고정된 허용 목록에서만 고르게 해야 한다. id 같은 값은 태그드 템플릿의 파라미터로 넘기면 바인딩된다.

dateModified에는 어떤 날짜를 넣어야 하나요?

본문이나 제목처럼 독자가 보는 내용이 실제로 바뀐 날짜를 넣는다. 조회수, 좋아요, 댓글 수 같은 값의 변화는 콘텐츠 수정이 아니다. 별도의 contentUpdatedAt 같은 컬럼을 두면 의미를 분명히 유지할 수 있다.

참고 자료

댓글 0

아직 댓글이 없습니다.
Ctrl+Enter로 등록