본문 바로가기
글 목록

개인정보 개발기: 방침의 '30일 보관'을 매일 지키는 자동 삭제 만들기

개인정보처리방침에 쓴 30일 보관을 실제로 지키려고, 보관 일수를 한 파일에 두고 매일 도는 삭제 작업과 로그·백업 정리까지 맞춘 과정을 정리했어요.

IntentCode··6분 읽기

3줄 요약

  • 개인정보처리방침에 쓴 "30일 보관"을 실제로 지키는 매일 삭제 작업을 만들었어요
  • 보관 일수는 한 파일에만 두고, 삭제 코드와 방침 문장이 같은 숫자를 읽게 했어요
  • 로그·백업처럼 놓치기 쉬운 곳까지 방침과 맞추는 게 진짜 숙제였어요

방침을 쓰기 전에 코드부터 봤어요

인텐트코드 홈페이지에는 회사와 제품을 안내하는 AI 챗봇과 블로그 뉴스레터가 있어요. 둘 다 개인정보가 남을 수 있는 곳이에요. 챗봇은 대화 내용을 데이터베이스에 저장하고, 뉴스레터는 구독자의 이메일 주소를 저장해요.

그런데 얼마 전까지 사이트 아래쪽의 "개인정보처리방침"은 누를 수 없는 "준비 중" 글자였어요. 방침을 새로 쓰면서 정한 원칙은 하나였어요. 코드가 실제로 하는 일만 적는다는 거예요. 그래서 문장을 쓰기 전에 어떤 데이터를 어디에, 얼마나 오래 두는지부터 코드에서 하나씩 확인했어요.

확인해 보니 보관 기간이 문제였어요. 대화 기록을 지우는 코드가 아예 없었거든요. 방침에 "30일 뒤 파기"라고 쓰는 순간, 그 문장은 매일 지켜야 하는 약속이 돼요. 개인정보 보호법 제21조도 보유 기간이 지나 필요 없어진 개인정보는 지체 없이 파기하라고 정하고 있어요.

보관 일수는 한 파일에만 둬요

가장 먼저 한 일은 숫자를 한곳에 모으는 거였어요. 방침 문장에 "30일"을 직접 쓰고 삭제 코드에도 30을 따로 쓰면, 언젠가 한쪽만 바뀌는 날이 와요. 그러면 방침과 실제 동작이 조용히 어긋나요.

ts
// lib/retention-policy.ts
// 자동 파기(lib/db/retention.ts)와 개인정보처리방침(lib/privacy-content.ts) 공용
export const CHAT_RETENTION_DAYS = 30;
export const NEWSLETTER_RETENTION_DAYS = 30;

방침 본문은 이 상수를 가져와 문장을 만들어요. 예를 들어 "마지막 대화로부터 30일이 지나면 자동으로 파기합니다"의 30은 CHAT_RETENTION_DAYS에서 와요. 한국어·영어·일본어 방침이 모두 같은 상수를 써서, 숫자를 바꾸면 세 언어가 함께 바뀌어요.

참고로 방침의 한국어 본문만은 사이트의 다른 글과 달리 합니다체로 썼어요. 법적 문서라서 그렇게 정했고, 한국어본을 기준으로 삼고 영어·일본어본은 참고용 번역이라고 밝혀 두었어요.

무엇을 언제 지울지 SQL로 정해요

기준은 데이터마다 조금씩 달라요. 챗봇 대화는 마지막 메시지로부터 30일이 지나면 지워요. 뉴스레터는 구독 중에는 보관하고, 수신거부한 지 30일이 지나면 지워요. 구독 확인 메일을 누르지 않은 신청은 신청 후 30일이 지나면 지워요.

ts
// lib/db/retention.ts (요약)
await query(
  `DELETE FROM conversations
    WHERE last_active_at < now() - make_interval(days => $1)
    RETURNING id`,
  [CHAT_RETENTION_DAYS],
);
await query(
  `DELETE FROM newsletter_subscribers
    WHERE (status = 'unsubscribed' AND unsubscribed_at < now() - make_interval(days => $1))
       OR (status = 'pending' AND created_at < now() - make_interval(days => $1))
    RETURNING id`,
  [NEWSLETTER_RETENTION_DAYS],
);

대화 하나에는 메시지 여러 개와 답변 평가가 딸려 있어요. 이 표들은 처음부터 외래 키(foreign key, 다른 표의 행을 가리키는 열)에 ON DELETE CASCADE를 달아 두었어요. PostgreSQL에서 이 설정은 가리키는 행이 지워지면 그 행을 가리키는 행도 함께 지워요. 그래서 대화만 지우면 메시지와 평가가 남는 일이 없어요.

make_interval(days => $1)는 일수를 매개변수로 받아 기간 값을 만드는 PostgreSQL 함수예요. 문자열을 이어 붙이지 않고 숫자를 그대로 넘길 수 있어서 편해요. 응답에는 지운 건수만 담고, 지운 내용은 어디에도 남기지 않아요.

매일 한 번, 예약 작업이 불러요

삭제 코드는 관리자용 API 하나로 감쌌어요. 공유 비밀값이 없으면 401을 돌려주고, 여러 번 불러도 기간이 지난 행만 지우니 결과가 같아요. 이렇게 다시 실행해도 안전한 성질을 멱등성(idempotency)이라고 해요.

이 API는 GitHub Actions의 예약 작업(schedule)이 매일 불러요. 이미 쓰고 있던 예약 게시 작업과 같은 방식이라 새로 익힐 게 적었어요.

yaml
on:
  schedule:
    - cron: '30 18 * * *' # 매일 03:30 KST
  workflow_dispatch: {}   # 필요하면 손으로 바로 실행

GitHub Actions의 cron은 기본으로 UTC 기준이에요. 그래서 한국 시각 새벽 3시 30분은 UTC 18시 30분으로 적어요. 실제로 돌려 보니 예약 시각보다 서너 시간 늦게 시작하는 날이 많았어요. GitHub 문서도 사용량이 몰리면 예약 실행이 늦어질 수 있다고 안내해요. 하루 한 번 지우는 작업이라 몇 시간 늦는 건 괜찮지만, 정확한 시각이 중요한 작업이었다면 다른 방법을 골랐을 거예요.

첫 실행은 조금 특별했어요. 그동안 쌓인 30일 넘은 대화와 구독 기록이 한 번에 지워지거든요. 그래서 병합 전에 버리는 용도의 PostgreSQL 컨테이너에 같은 스키마를 만들고, 31일 된 데이터는 지워지고 최근 데이터는 남는지 확인했어요.

로그와 백업도 방침에 맞췄어요

데이터베이스만 지운다고 끝나지 않아요. 서버 로그에 대화 원문이 찍히고 있다면, 표를 아무리 잘 지워도 같은 내용이 로그에 남아요. 살펴보니 짧은 키워드 질문을 보정할 때 사용자 입력 원문을 로그에 남기는 줄이 있었어요. 이제는 "보정을 적용했다"는 사실만 남기고, 구독자 이메일도 로그에 쓰지 않아요.

백업은 반대로 지울 수 없는 곳이에요. 데이터베이스는 매일 백업하고, 백업 파일은 30일 뒤 자동으로 지워져요. 그러니 오늘 파기한 대화도 최대 30일 동안은 백업본에 남을 수 있어요. 이 사실을 숨기지 않고 방침에 그대로 적었어요.

아직 남은 숙제

방침과 코드를 맞추고 나니 남은 일도 또렷해졌어요. 챗봇에 보낸 질문은 답변을 만들려고 국외 AI API로 전송돼요. 방침에는 이 국외 이전과 거부하는 방법(챗봇을 쓰지 않으면 됨)을 적었지만, 질문 안의 개인정보를 미리 가리는 처리는 아직 넣지 않았어요. 그래서 챗봇 입력창 아래에 개인정보를 입력하지 말아 달라는 짧은 안내를 달았어요.

이번 작업에서 배운 건 단순해요. 개인정보처리방침은 법무 문서이기 전에 시스템이 매일 지켜야 하는 명세예요. 숫자는 한곳에 두고, 지우는 일은 사람이 아니라 예약 작업에 맡기고, 지울 수 없는 곳은 솔직하게 적는 게 가장 오래 지켜지는 방법이었어요.

참고 자료

새 글을 메일로 받아 보세요

제품을 만들며 배운 것을 주 2회 정리해요. 궁금한 건 도트에게 물어보세요.