PostgreSQLpg_cronSupabase마이그레이션Next.js

기능을 지우지 말고 끄는 법 — pg_cron 잡을 되돌릴 수 있게 비활성화하기

2026년 7월 26일6 분 읽기

"좋은 기능"이 지금은 독이 될 때

노가리(익명 커뮤니티 사이드 프로젝트)에는 처음부터 넣어둔 자동 청소 기능이 하나 있었다. 30일 동안 댓글이 하나도 안 달린 방은 자동으로 문을 닫는다는 규칙이다. 빈 방이 계속 쌓이면 목록이 지저분해지고, 들어가봤자 아무 얘기도 없는 방이 검색에 걸리면 사용자 경험이 나빠지니까. 커뮤니티가 어느 정도 굴러가는 상황이라면 분명 필요한 기능이다.

그런데 지금은 상황이 다르다. 아직 테스터 몇 명만 들락거리는 초기 단계라, 방에 댓글이 잘 안 달린다. 이 상태에서 "30일 무활동이면 종료"를 그대로 두면 어떻게 될까? 애써 만들어둔 방들이 하나둘 조용히 사라진다. 사용자를 모으려고 콘텐츠(방)를 쌓아두는 시기인데, 그 콘텐츠를 시스템이 알아서 치워버리는 역설이 생기는 거다.

그래서 결정했다. 이 기능을 테스터 기간 동안만 꺼두자. 사용자가 늘어서 방에 활동이 붙기 시작하면 다시 켜면 된다.

여기서 중요한 건 방식이다. "안 쓰니까 지워"가 아니라 **"나중에 되돌릴 수 있게 꺼둬"**여야 한다. 이 글은 pg_cron 잡을 안전하게 비활성화하면서, 나중의 내가(혹은 다른 누군가가) 헤매지 않고 되살릴 수 있게 흔적을 남긴 과정을 정리한 것이다.

사전 배경: 이 기능이 어떻게 만들어져 있었나

노가리는 Supabase(PostgreSQL)를 쓰고, 주기적인 정리 작업은 pg_cron 확장으로 돌린다. 애플리케이션 서버가 아니라 DB 안에서 스케줄이 도는 방식이다. (pg_cron을 고른 이유는 노가리 개발기에 적어 두었다.) 처음 크론을 정의한 마이그레이션(0011)에는 이런 잡들이 있었다.

-- 72시간 내 동의수 미달 제안 만료
select cron.schedule('expire-pending-proposals', '*/10 * * * *', $$
  update topics set status = 'EXPIRED'
  where status = 'PENDING' and expires_at < now();
$$);

-- 대나무숲(24시간 시한부) 방 폭파
select cron.schedule('explode-bamboo-rooms', '0 * * * *', $$
  update topics set status = 'EXPIRED'
  where room_mode = 'BAMBOO_24H' and status = 'ACTIVE' and expires_at < now();
$$);

-- 무활동 정식 방 아카이브 (처음엔 48시간)
select cron.schedule('archive-inactive-rooms', '0 * * * *', $$
  update topics set status = 'EXPIRED'
  where status = 'ACTIVE'
    and coalesce(last_comment_at, activated_at) < now() - interval '48 hours';
$$);

그 뒤 0018 마이그레이션에서 무활동 기준을 48시간에서 30일로 늘리고, 시드 데이터(처음부터 제공되는 정치인 방 등, is_seed = true)는 대상에서 뺐다. cron.schedule을 같은 잡 이름으로 다시 호출하면 기존 스케줄을 덮어쓴다는 걸 이용한 갱신이었다.

-- 0018: 48시간 → 30일로 완화 + 시드 방 영구 유지
select cron.schedule('archive-inactive-rooms', '0 * * * *', $$
  update topics set status = 'EXPIRED'
  where status = 'ACTIVE'
    and is_seed = false
    and coalesce(last_comment_at, activated_at) < now() - interval '30 days';
$$);

이번에 꺼야 할 대상은 이 중 archive-inactive-rooms 하나뿐이다. 나머지 두 개(제안 만료, 대나무숲 폭파)는 무활동과는 전혀 다른 성격의 시한부 규칙이라 그대로 둬야 한다. 대나무숲은 "24시간 뒤 폭파"가 컨셉 자체이고, 제안 만료는 "72시간 내 동의 못 받으면 무산"이니 사용자가 적든 많든 계속 필요하다.

크론 잡하는 일이번에
expire-pending-proposals72시간 내 동의 미달 제안 만료유지
explode-bamboo-rooms대나무숲 24시간 뒤 폭파유지
archive-inactive-rooms30일 무활동 방 종료비활성화
cleanup-rate-limit-events오래된 rate limit 로그 정리유지

Step 1: 잡을 삭제가 아니라 unschedule로 빼기

pg_cron에서 잡을 멈추는 함수는 cron.unschedule이다. 잡 이름이나 잡 ID를 넘기면 스케줄에서 제거된다.

select cron.unschedule('archive-inactive-rooms');

그런데 이걸 마이그레이션에 그냥 넣으면 위험하다. 마이그레이션은 여러 환경(로컬, 스테이징, 운영)에서 돌 수 있고, 이미 잡이 없는 상태에서 unschedule을 호출하면 에러가 나면서 마이그레이션 전체가 실패한다. 예를 들어 로컬 DB에는 크론 잡을 안 만들어뒀다면, 이 한 줄 때문에 마이그레이션이 멈춰버린다.

그래서 "잡이 있을 때만 unschedule"하도록 방어 코드를 넣었다. pg_cron은 등록된 잡을 cron.job 테이블에서 조회할 수 있으니, 존재 여부를 먼저 확인한다.

-- 잡이 없을 때 cron.unschedule이 에러를 던지므로 존재할 때만 호출한다.
do $$
begin
  if exists (select 1 from cron.job where jobname = 'archive-inactive-rooms') then
    perform cron.unschedule('archive-inactive-rooms');
  end if;
end $$;

do $$ ... $$ 는 익명 PL/pgSQL 블록이다. 마이그레이션처럼 순수 SQL만 쓰는 파일 안에서 if 조건 분기 같은 절차적 로직이 필요할 때 유용하다. select로 값을 버리고 함수만 실행할 때는 perform을 쓴다는 것도 PL/pgSQL의 문법 포인트다(select cron.unschedule(...)를 블록 안에 그대로 쓰면 "결과를 어디에 담을지" 에러가 난다).

이렇게 하면 잡이 있는 운영 DB에서는 정상적으로 제거되고, 잡이 없는 로컬에서는 조용히 넘어간다. 어느 환경에서 돌려도 안전하다.

Step 2: "왜 껐는지"와 "어떻게 되살리는지"를 코드에 남기기

기능을 끄는 것보다 더 중요한 게 이 부분이었다. 3개월 뒤에 이 마이그레이션 파일을 다시 열었을 때, "이거 왜 껐더라? 다시 켜려면 뭘 해야 하지?"를 헤매지 않으려면 의도와 복원 절차를 파일 안에 적어둬야 한다.

마이그레이션 파일 맨 위에 이렇게 남겼다.

-- 무활동 방 자동 종료 기능 비활성화.
-- 초기에는 사용자 유입이 적어 30일 무활동으로 방을 닫으면 오히려 콘텐츠가
-- 줄어든다. 사용자가 늘기 전까지는 방을 그대로 남겨두기로 해서 크론 잡을 뺀다.
--
-- 대나무숲 폭파(explode-bamboo-rooms)와 제안 만료(expire-pending-proposals)는
-- 그대로 유지한다 — 이 둘은 무활동과 무관한 시한부 규칙이라 계속 필요하다.
--
-- 재활성화 방법: 0018_relax_inactivity_archive.sql의 cron.schedule 구문을
-- 다시 실행하고, src/app/about/page.tsx의 안내 문구 주석을 되살린다.

핵심은 세 가지를 명시한 거다.

  1. 왜 껐는가 — "사용자 유입이 적어 콘텐츠가 줄어든다"는 판단 근거. 나중에 상황이 바뀌면 이 전제가 깨졌는지 판단할 기준이 된다.
  2. 무엇은 안 건드렸는가 — 대나무숲·제안 만료는 그대로라는 명시. "혹시 이것도 같이 꺼진 거 아냐?"라는 의심을 미리 차단한다.
  3. 어떻게 되살리는가 — 정확히 어느 파일의 어느 구문을 다시 실행하면 되는지. 복원 지점을 파일명까지 콕 집어뒀다.

Step 3: 사용자에게 보이는 안내 문구도 함께 숨기기

기능만 끄고 끝이 아니었다. 서비스 소개(/about) 페이지에 이 기능을 설명하는 문구가 있었다.

유저가 제안한 일반 방도 30일 동안 아무 댓글이 없으면 자동으로 문을 닫습니다. (정치인 등 처음부터 제공되는 노가리방은 무활동 여부와 상관없이 계속 남아있어요)

기능을 껐는데 이 안내가 그대로 있으면, 사용자에게 거짓 설명을 하는 셈이다. 그렇다고 문구를 완전히 지워버리면, 나중에 기능을 되살릴 때 이 설명을 처음부터 다시 써야 한다.

그래서 JSX 주석으로 감싸서 화면에는 안 보이되 코드에는 남기는 방식을 택했다.

{/* 무활동 자동 종료(30일) 안내 — 사용자 유입 전까지는 방을 그대로
    남겨두기로 해서 기능을 껐다(0030 마이그레이션). 재활성화 시 이 문구도
    함께 노출할 것:
    "유저가 제안한 일반 방도 30일 동안 아무 댓글이 없으면 자동으로 문을
    닫습니다. (정치인 등 처음부터 제공되는 노가리방은 무활동 여부와
    상관없이 계속 남아있어요)" */}

React/JSX에서 {/* ... */} 는 렌더링되지 않는 주석이라 화면에는 전혀 안 나온다. 여기에도 마이그레이션 번호(0030)를 적어둬서, 크론 쪽 변경과 UI 쪽 변경이 한 세트라는 걸 연결해뒀다. 문구를 되살릴 때는 이 주석 블록을 다시 실제 텍스트로 풀어주기만 하면 된다.

Step 4: 적용하고 확인하기

마이그레이션을 원격 DB에 밀어넣는다.

supabase db push
# Applying migration 0030_disable_inactivity_archive.sql...
# Finished supabase db push.

do 블록이 에러 없이 완료됐다는 건, 잡이 있었으면 제거됐고 없었으면 조용히 넘어갔다는 뜻이다. 앞으로 이 마이그레이션이 로컬·스테이징에서 다시 돌아도 깨지지 않는다.

여기서 하나 짚을 점. pg_cron 잡을 unschedule해도 이미 만들어진 방들의 상태는 그대로다. 이 잡은 "조건에 맞는 방을 EXPIRED로 바꾸는" 작업만 멈추는 거지, 과거에 이미 종료된 방을 되살리진 않는다. 다행히 이번엔 아직 무활동으로 닫힌 방이 없어서 추가 복구 작업은 필요 없었다. 만약 이미 닫힌 방이 있었다면, 그것들을 다시 ACTIVE로 돌릴지는 별도로 판단해야 했을 거다.

되돌릴 수 있게 끄는 패턴 정리

이번 작업을 관통하는 원칙은 **"삭제가 아니라 비활성화, 그리고 복원 경로를 남기기"**였다. 정리하면 이렇다.

대상한 일되돌리는 법
pg_cron 잡do 블록으로 안전하게 unschedule0018의 cron.schedule 재실행
소개 페이지 문구JSX 주석으로 감싸 숨김주석 블록을 텍스트로 복원
판단 근거·절차마이그레이션·컴포넌트 주석에 기록(기록 자체가 복원 가이드)

이런 식으로 처리해두면 좋은 점이 몇 가지 있다.

  1. Git 이력이 깔끔하다. 기능을 통째로 지웠다가 나중에 되살리면, diff에 큰 덩어리가 사라졌다 다시 나타나서 히스토리가 지저분해진다. 껐다 켜는 방식은 변경 범위가 작다.
  2. 의사결정이 기록된다. "왜 이때 껐는지"가 코드 주석과 커밋 메시지에 남아서, 나중에 "이거 켜도 되나?"를 판단할 근거가 된다.
  3. 복원이 실수 없이 된다. 되살릴 구문과 파일을 정확히 지목해뒀으니, 미래의 나는 고민 없이 그대로 따라 하면 된다.

기능을 만드는 것만큼이나, 때가 아닌 기능을 잠시 접어두는 것도 설계라는 걸 다시 느낀 작업이었다. 특히 사이드 프로젝트는 상황이 자주 바뀌니까, "지금은 아니지만 나중엔 필요할" 기능을 깔끔하게 껐다 켜는 습관이 생각보다 자주 쓰인다. 지울까 말까 고민될 땐, 지우지 말고 되돌릴 수 있게 꺼두고 이유를 적어두자.

PM

backtodev

40대 PM, 다시 개발자로 돌아갑니다. 실패하고 배우며 성장하는 기록.