희곡 낭독 매칭 서비스에 포인트 기능을 넣었습니다. 배우가 리딩에 참여하거나 리뷰를 쓰면 포인트가 쌓이고, 작가가 배우에게 하트를 주면 작가도 포인트를 받습니다. 처음 요구는 여기에 하나가 더 붙어 있었습니다. 배우 랭킹을 "작가에게 받은 하트 70% + 포인트 30%"로 합산하라는 것이었습니다.

이 글은 그 기능을 설계하고, 서브에이전트로 나눠 구현하고, 운영에 배포하기까지의 기록입니다. 기능 자체보다는 세 가지 결정에 시간을 많이 썼습니다. 포인트를 어떻게 저장할 것인가, 랭킹 캐시를 언제 비울 것인가, 그리고 다 만든 뒤에 "포인트는 랭킹 근거가 아니다"라는 요구가 왔을 때 무엇을 바꾸고 무엇을 남길 것인가입니다.

전제: 리뷰는 한 번만 쓸 수 있고, 일정 상태는 관리자만 바꾼다

코드를 읽고 나서 설계가 단순해진 지점이 둘 있었습니다.

  • 리뷰(Review, CreatorReview)는 생성만 있고 수정·삭제 경로가 없습니다. DB 유니크 제약(일정 + 작성자)까지 있어서 한 사람이 한 일정에 리뷰를 두 번 쓸 수 없습니다. 포인트를 되돌리는 로직이 처음부터 필요 없다는 뜻입니다.
  • "리딩에 참여했다"는 별도 상태가 없고, 신청(Application) 행이 있고 일정이 리딩완료(END)면 참여입니다. END 전환은 관리자만 할 수 있고, 그 라우트에는 이미 알림 생성과 주석 정리를 같은 트랜잭션에서 처리하는 자리가 있었습니다. 포인트 지급 훅은 그 옆에 한 줄 들어가면 됩니다.

반대로 손을 대야 하는 곳도 보였습니다. 작가 리뷰 라우트는 서비스 없이 인라인이었고, zod도 역할 게이트도 없었으며, 하트를 줄 배우 목록을 신청자와 대조하지 않았습니다. 하트에 포인트가 붙는 순간 아무 사용자 id나 보내서 하트를 남길 수 있는 경로가 되므로, 이 라우트는 서비스로 추출하면서 검증을 붙이기로 했습니다.

설계 1: 원장과 카운터를 같은 트랜잭션에 두고, 멱등은 제약으로

포인트를 User.pointBalance 하나로 두면 내역도 감사도 소급도 불가능합니다. 반대로 원장만 두면 사이드바에 잔액을 띄울 때마다 SUM을 돌려야 합니다. 둘 다 두되 같은 트랜잭션에서만 갱신하는 것으로 정했습니다.

model PointTransaction {
  id         String           @id @default(cuid())
  userId     String
  reason     PointReason
  amount     Int              // 부호 있음. 적립 +, 차감 −
  dedupeKey  String           // "{reason-kebab}:{scheduleId}"
  scheduleId String?
  schedule   ReadingSchedule? @relation(..., onDelete: SetNull)
  metadata   Json?
  createdAt  DateTime         @default(now())

  @@unique([userId, dedupeKey])
  @@index([userId, createdAt])
}

핵심은 @@unique([userId, dedupeKey])입니다. 지급 서비스는 createMany({ skipDuplicates: true }) 한 번으로 원장에 쓰고, 그 결과 count가 요청 건수와 같을 때만 카운터를 increment합니다. 같은 사유를 두 번 주는 것이 코드가 아니라 제약으로 불가능해지므로, "리딩 1회 최대 150포인트" 같은 상한을 검사하는 코드가 필요 없습니다. 재진입이나 중복 호출로 일부가 건너뛰어진 드문 경우에만 원장 SUM으로 카운터를 다시 계산합니다.

const { count } = await tx.pointTransaction.createMany({ data, skipDuplicates: true });

if (count === data.length) {
    // 정상 경로: 금액이 같은 사용자끼리 묶어 updateMany 1회
    for (const [amount, ids] of byAmount) {
        await tx.user.updateMany({
            where: { id: { in: ids } },
            data: { pointBalance: { increment: amount }, pointsEarned: { increment: amount } },
        });
    }
} else {
    // 재진입: 어떤 행이 건너뛰어졌는지 모르므로 원장에서 다시 계산
    await recalculateBalances(tx, userIds);
}

정상 경로의 추가 쿼리는 두 개입니다. 리딩완료 시 신청 배우 전원과 작가에게 50포인트씩 주는 경우에도 전원 금액이 같으니 updateMany 한 번으로 끝납니다. 이 서비스는 Vercel 서버리스에서 pg 풀 최대 2개, 트랜잭션 예산 10초로 돌아가므로 훅마다 쿼리 수를 세어 두는 것이 중요했습니다.

onDelete: SetNull은 의도적인 선택입니다. 리뷰와 하트는 일정이 삭제되면 cascade로 사라지지만, 포인트는 통화입니다. 작가가 지난 일정을 지웠다고 배우 잔액이 줄면 안 됩니다. 원장 행은 남고 scheduleId만 비워져서 내역 화면에 "삭제된 일정"으로 표시됩니다.

소급 적용은 마이그레이션 SQL에 넣었습니다. 이 저장소는 그동안 DDL만 있는 마이그레이션을 써 왔는데, 운영 배포 시 prisma migrate deploy가 빌드 단계에서 자동 실행되므로 백필도 거기에 두면 수동 작업이 없습니다. 아홉 개의 INSERT … SELECT … ON CONFLICT DO NOTHING과 마지막 UPDATE "User" 한 문장입니다. ON CONFLICT DO NOTHING은 앱 코드의 skipDuplicates와 같은 유니크 제약을 타므로 재실행해도 안전합니다.

한 가지 맞춰야 했던 것은 "평가 글 10자 이상" 판정입니다. 앱은 [...s.trim()].length >= 10으로 코드포인트를 세고(한글·이모지가 1자), SQL은 length(regexp_replace(x, '^\s+|\s+$', '', 'g')) >= 10 입니다. 앱과 백필이 다른 답을 내면 소급된 사용자와 새 사용자가 다른 기준으로 포인트를 받게 됩니다.

설계 2: 랭킹은 계산값이고, 캐시는 이벤트가 비운다

랭킹은 DB에 저장하지 않습니다. 하트 수와 점수는 원천 데이터에 있고, 순위는 순수 함수가 만듭니다. 산식은 최대값 정규화입니다.

score = 하트수 / 전체최대하트 × 70 + 점수 / 전체최대점수 × 30

하트는 수십 개 단위이고 포인트는 수백에서 수천 단위라, 그냥 7:3으로 가중하면 포인트가 랭킹을 지배합니다. 각 항을 그 항의 최대값으로 나눠 0~1로 만든 뒤 가중하면 단위 문제가 사라집니다. 대가는 "1등의 하트가 늘면 다른 사람 점수도 조금 내려간다"는 것인데, 어차피 전체를 매번 다시 계산하므로 자연스럽게 처리됩니다.

이 계산을 unstable_cache로 감쌌습니다. 처음에는 TTL 60초로 잡았다가 두 가지 질문을 받고 설계를 바꿨습니다.

"배우가 많아지면?" Vercel Data Cache는 항목당 2MB 상한이 있습니다. 랭킹 캐시에 이름과 프로필 이미지 URL을 함께 넣으면 행당 약 250바이트라 8천 명 근처에서 넘칩니다. id와 숫자만 넣으면 행당 약 60바이트, 3만 명까지 들어갑니다. 관리자 랭킹 페이지는 캐시에서 상위 50명 id만 꺼내 프로필을 따로 한 번 조회합니다. 2만 명을 넘기면 RANK() OVER 윈도우 함수로 User에 순위를 저장하는 방식으로 갈아타는 경로를 문서에 적어 두었습니다.

"60초면 60초마다 도는 건가, 요청마다 도는 건가?" 둘 다 아닙니다. unstable_cache는 요청이 방아쇠입니다. 요청이 없으면 아무 일도 없고, 60초 안의 모든 요청은 저장값을 받고, 60초가 지난 뒤 첫 요청이 옛 값을 받으면서 백그라운드로 갱신합니다. 그런데 이 기능에서 랭킹이 바뀌는 사건은 한정돼 있습니다. 리딩완료, 배우 리뷰, 작가 리뷰, 그리고 하트가 cascade로 사라지는 탈퇴·작품 삭제·일정 삭제. 그래서 TTL 대신 이벤트 무효화로 바꾸고, 놓친 경로에 대비해 1시간 안전망 TTL만 남겼습니다. 평소에는 요청이 아무리 많아도 재계산이 0회입니다.

함정: revalidateTag(tag, "max")는 캐시를 비우지 않는다

여기까지 만들고 최종 리뷰를 돌렸더니 Important 하나가 나왔습니다. 무효화 헬퍼가 이렇게 돼 있었습니다.

revalidateTag("actor-ranking", "max");

Next.js 16에서 revalidateTag는 두 번째 인자가 필수이고, "max"가 문서에 자주 등장하는 값입니다. 리뷰어가 설치된 Next 16.2.6 소스를 따라간 결과는 이렇습니다.

  1. 문자열 프로파일을 주면 cacheLifeProfiles["max"]에서 expire를 꺼내 핸들러에 넘깁니다.
  2. max 프로파일의 expire1년입니다.
  3. 파일시스템 캐시 핸들러는 stale = now, expired = now + 1년으로 기록합니다.
  4. 다음 읽기에서 만료 검사는 false(1년 뒤니까), stale 검사는 true. 항목은 값과 함께 반환됩니다.
  5. unstable_cache는 요청 시점 렌더링에서 stale 값을 받으면 그 값을 즉시 돌려주고 백그라운드로 재검증합니다.

즉 리뷰를 등록한 직후 router.refresh()가 그리는 사이드바는 지급 전 잔액입니다. 새 값은 그 다음 내비게이션에서야 보입니다. 데이터가 틀리는 건 아니고 한 요청 늦는 것이라 Critical은 아니지만, 이 기능의 체감 포인트가 정확히 그 순간이라 스펙이 명시한 "커밋 후 즉시 반영" 계약을 못 지키는 셈입니다.

같은 소스의 주석이 결정적이었습니다. "프로파일이 없고 max도 아니면 durations가 undefined가 되어 캐시 핸들러에서 즉시 만료를 트리거한다." 프로파일을 주면 즉시 만료가 아닙니다. 대안 셋을 검토했습니다.

후보 판정
updateTag(tag) 라우트 핸들러에서 호출하면 던집니다. 지급 훅은 전부 라우트 핸들러입니다.
revalidateTag(tag) (1인자) 동작은 맞지만 매 호출 deprecation 경고, 타입상 두 번째 인자가 필수라 TS 에러.
revalidateTag(tag, { expire: 0 }) CacheLifeConfig = { expire?: number }가 공개 타입. expired = now가 되어 다음 읽기가 캐시 미스.

세 번째로 바꾸고, 태그 이름과 인자 형태를 고정하는 두 줄짜리 단위 테스트를 같이 넣었습니다. 이 버그가 살던 자리에 테스트가 없었던 것이 근본 원인이었습니다.

개정: "포인트는 통화, 랭킹은 활동"

구현이 끝나고 검증까지 통과한 뒤에 요구가 하나 바뀌었습니다. 랭킹의 30% 항을 포인트에서 떼어내 별도의 활동 점수로 만들라는 것이었습니다. 이유는 둘입니다. 포인트는 앞으로 활동과 무관하게 지급되거나 차감될 수 있고, 랭킹에는 활동 외 요소가 들어올 수 있습니다. 둘을 묶어 두면 어느 한쪽을 바꿀 때마다 다른 쪽이 흔들립니다.

사실 이 방향의 징후는 개정 전에도 있었습니다. "나중에 포인트를 쓰면 순위가 떨어지나요?"라는 질문에 잔액(pointBalance)과 누적 적립(pointsEarned)을 분리하고 랭킹은 누적을 쓰도록 이미 바꾼 상태였습니다. 그 임시방편을 제대로 된 분리로 바꾸는 셈이었습니다.

선택지는 카운터 하나만 추가하는 것과 원장을 하나 더 두는 것이었습니다. 원장을 복사하기로 했습니다. User.activityScore 컬럼만 두고 포인트 원장의 멱등성에 기대면, 포인트 없는 활동(예를 들어 프로필 작성)이 생기는 순간 중복 방지가 사라집니다. 원장을 복사하는 비용은 사건당 행 하나입니다.

model ActivityLog {
  id         String       @id @default(cuid())
  userId     String
  type       ActivityType
  score      Int
  dedupeKey  String
  scheduleId String?
  // ... PointTransaction 과 같은 골격
  @@unique([userId, dedupeKey])
}

activity.service.tspoint.service.ts와 코드가 거의 같지만 서로 import하지 않습니다. 공유하는 것은 "10자 이상" 판정 함수 하나뿐입니다. 그건 텍스트 규칙이지 포인트 규칙이 아니기 때문입니다. 점수표는 오늘 포인트 금액과 같은 값으로 시작하고, 두 표가 같다는 테스트에는 "표가 갈라지는 날 이 테스트를 지운다"는 주석을 달았습니다. 분리의 목적이 바로 그것이니까요.

세 지급 훅에서는 포인트 지급 바로 다음 줄에 활동 기록 호출이 들어갑니다. 같은 tx, 같은 입력입니다. 훅당 추가 쿼리는 2개에서 4개가 됐습니다. 랭킹 순수 함수와 캐시는 필드 이름만 points에서 activityScore로 바뀌었고, 캐시 무효화 경로는 그대로입니다. 활동 점수가 바뀌는 모든 사건이 포인트도 바꾸는 사건이기 때문입니다.

두 번째 마이그레이션은 첫 번째 백필을 테이블 이름만 바꿔 복사한 것입니다. 배포 전 로컬에서 두 원장의 종류별 건수와 합계가 정확히 같은지, 그리고 한쪽에만 있는 dedupeKey가 없는지를 SQL로 대조했습니다.

배포와 마무리

운영 배포는 마이그레이션 2건이 빌드 단계에서 순서대로 실행되는 형태였습니다. 두 마이그레이션 모두 마지막에 WHERE 없는 UPDATE "User"로 전 행을 갱신하므로 User 테이블 락이 두 번 걸립니다. 트래픽이 적은 시간에 배포했고, 배포 직후 세 가지를 확인했습니다.

확인 결과
포인트 원장 합 = 잔액, 양수 합 = 누적 적립 불일치 0명
활동 원장 합 = 활동 점수 불일치 0명
두 원장의 dedupeKey 대조 한쪽에만 있는 기록 0건

원장은 2,139행씩, 186명에게 소급됐습니다.

배포 후에 두 가지를 더 정리했습니다. 하나는 리뷰 생성 서비스가 프로젝트 규칙과 달리 prisma.$transaction(기본 대기 2초)을 직접 쓰고 있던 것을 서버리스용 래퍼로 교체한 것입니다. 포인트·활동 쿼리가 그 트랜잭션에 4개 더 들어갔으니 미룰 이유가 없었습니다. 다른 하나는 상태 머신입니다. 관리자 화면은 리딩확정 상태에서만 "리딩완료" 버튼을 보여주는데, 상태 머신은 종료 상태만 빼고 어디서든 END를 허용하고 있었습니다. 포인트가 END 시점에 지급되는 지금은 API 층에서도 CONFIRMED에서만 END로 갈 수 있게 좁혔습니다.

돌아보면

  • 멱등을 코드가 아니라 제약으로 만들면 상한 검사, 회수 로직, 재실행 걱정이 한꺼번에 사라집니다. @@unique 하나와 skipDuplicates, ON CONFLICT DO NOTHING이 앱과 백필에서 같은 제약을 탑니다.
  • 캐시 API의 두 번째 인자는 문서가 아니라 소스로 확인해야 합니다. "max"가 "가장 강한 무효화"처럼 읽히지만 실제로는 "1년 뒤 만료"였습니다.
  • 원장을 복사하는 비용은 생각보다 작고, 나중에 떼어내는 비용은 생각보다 큽니다. 포인트와 활동을 처음부터 분리했다면 개정에 든 반나절이 없었을 것입니다. 다만 그 요구는 만들어 보기 전에는 나오지 않았을 가능성도 큽니다.