콘텐츠로 이동

1인 서비스에 개인정보보호법 입히기 — On Pocket News 개편기

On Pocket News는 정부·지자체가 쏟아내는 복지·정책 소식 중에서 "나한테 해당하는 것"만 골라 보여주는 개인화 뉴스 서비스입니다. 사는 곳, 나이대, 아이 유무, 처한 상황(구직, 한부모, 장애 등)을 입력하면 그에 맞는 지원금과 정책을 추천합니다.

추천이 정확하려면 개인정보를 받아야 합니다. 개인정보를 받는 순간 서비스는 법의 적용을 받습니다. 이번 개편은 혼자 운영하는 작은 서비스가 그 무게를 감당할 수 있게 만드는 작업이었습니다. 그와 함께 클라우드 비용을 최소로 줄이는 구조로 바꿨습니다.


1. 개인정보보호법, 실제로 무엇을 해야 하나

막연히 "법에 맞게"라고 하면 끝이 없습니다. 그래서 서비스가 실제로 받는 정보를 기준으로 조항을 하나씩 짚었습니다.

조항 요구 사항 적용한 것
제22조의2 만 14세 미만은 법정대리인 동의 필요 가입 시 만 14세 이상 확인 필수, 서버에서도 검증
제23조 건강 등 민감정보는 별도 동의 '내 상황'에서 장애·임신 등을 고르면 별도 동의 칸이 나타나고, 동의하지 않으면 저장 거부
제29조 안전성 확보 조치 이름·전화번호 AES-256-GCM 암호화 저장
제30조 처리방침 공개 항목·목적·보유기간·위탁(Google)·파기·권리행사 방법 전면 현행화
제35·36조 열람·정정 권리 내 정보 화면에서 직접 조회·수정
파기 목적 달성 시 지체 없이 파기 회원 탈퇴 = 즉시 삭제 (추천 이력까지)

회원가입 동의 항목 — 실제 서비스 화면(테스트 계정)

'장애인'을 고르면 민감정보 별도 동의 칸이 나타납니다. 만 14세 확인이 없으면 가입 버튼이 눌리지 않습니다.

암호화는 "나중에 켜도 되게"

이미 가입한 회원의 데이터는 평문으로 저장돼 있습니다. 그래서 암호화 계층을 저장소 앞에 데코레이터로 씌웠습니다.

  • 저장할 때 enc:v1: 접두어와 암호문으로 바꿉니다
  • 읽을 때 접두어가 없으면 기존 평문으로 보고 그대로 반환합니다
  • 서버가 시작할 때 평문인 회원을 찾아 자동으로 암호화해 다시 저장합니다 (마이그레이션 스크립트 불필요)

키는 Secret Manager에 두었습니다. 이 키를 잃으면 데이터를 영영 복구할 수 없으므로 운영 문서 맨 위에 경고를 적었습니다.

이메일 인증

6자리 코드는 10분 동안 유효하고, 확인이 끝나면 30분짜리 1회용 가입 토큰을 발급합니다. 같은 이메일로는 한 시간에 5번까지만 요청할 수 있고, 5번 틀리면 코드가 폐기됩니다. 메일 계정이 아직 없는 환경에서는 인증이 자동으로 꺼집니다. 그래서 메일 설정이 늦어져도 가입이 막히지 않습니다.

내 정보 화면 — 실제 서비스 화면(테스트 계정)


2. "강원 고성군" 사는 사람도 추천받게

예전에는 지역 코드가 구 단위 위주라 군 단위 회원은 지역 매칭이 사실상 안 됐습니다. 게다가 '중구'는 서울·부산·대구·인천·대전·울산에 다 있습니다. 서울 중구 회원에게 울산 중구 소식이 갈 수 있었습니다.

전국 시·군·구 코드표를 단일 출처로 새로 만들었습니다.

  • 2026년 행정구역 기준 (인천 제물포구·영종구·검단구 개편 반영)
  • 같은 이름의 구는 시/도와 함께 비교해 구분
  • 경기 광주시 ≠ 광주광역시
  • "부산 강서구"를 "서구"로 잘못 읽지 않도록 가장 긴 이름부터 매칭

가상 회원(페르소나)으로 추천 적합도를 오프라인 측정했습니다. 초기 14%에서 어휘 통일과 코드표 작업을 거쳐 62%까지 올렸습니다.

회원가입 — 강원 고성군 선택, 실제 서비스 화면(테스트 계정)


3. 같은 기사가 세 번 뜨지 않게

공공기관 게시판은 같은 글이 여러 경로로 들어옵니다. 목록의 페이지 번호나 검색어가 URL에 붙어 다른 글처럼 보이고, 같은 보도자료를 여러 기관이 그대로 게시합니다.

중복 판정을 4단계로 강화했습니다.

  1. URL 정규화: pageIndex, searchKrwd 같은 목록 상태 파라미터 제거
  2. 같은 사이트, 같은 제목: 공백·기호를 무시하고 비교
  3. 다른 사이트, 같은 본문: 제목·출처 줄을 뺀 본문의 해시로 비교
  4. 삭제한 글은 다시 오지 않게: 감수에서 지운 글은 '묘비(tombstone)'를 남겨, 서버를 재시작해도 다시 수집되지 않음

4. 클라우드 비용: "클라우드는 서비스만"

1인 서비스에서 가장 무서운 건 조용히 쌓이는 클라우드 요금입니다. 원칙을 하나로 정했습니다.

수집은 내 PC가, 클라우드는 서비스만.

  • 외부 사이트·공공 API를 호출하던 서버 쪽 수집 작업을 로컬 에이전트(Python)로 이관했습니다
  • 서버는 "어떤 사이트를 수집할 차례인지"와 "이 URL을 처음 보는지"만 대답합니다. 처음 보는 글만 상세 API를 호출하니 하루 호출 한도도 아낄 수 있습니다
  • 운영 서버는 설정 하나(APP_COLLECTION_SERVER_SIDE=false)로 외부 호출을 하지 않습니다
  • 배포 이미지는 최근 3회분만 남기고 자동 삭제하고, 웹 호스팅 이력도 3개만 유지합니다
  • Cloud Run은 최소 인스턴스 0, 최대 1개로 둡니다

5. AI와 같이 일하는 방식

이번 작업은 처음부터 끝까지 Claude Code와 함께 했습니다. 그 과정에서 지킨 규칙이 몇 가지 있습니다.

  • 서버는 LLM을 자동으로 호출하지 않습니다. 기사 감수와 사이트 분석은 사람과 AI가 한 세션에서 한 건씩 처리합니다. 예전에 스크립트로 384건을 일괄 처리했다가 쓰레기 글이 사용자에게 그대로 노출된 사고가 있었습니다. 이번에도 감수 대기 254건을 한 건씩 비웠습니다.
  • 배포는 오직 git push와 CI로. 로컬에서 직접 배포했다가 개발용 API 주소가 운영에 올라간 사고 이후로 굳힌 원칙입니다.
  • 모든 기능은 테스트와 증적까지가 완료입니다. 단위 테스트를 통과하면 끝이 아니라, 실제 화면을 브라우저로 자동 조작해 캡처하는 데까지 했습니다.

증적을 만들다 버그를 찾았다

화면 증적을 찍다가 문제가 하나 보였습니다. 가입할 때 알림 수신 동의를 체크하지 않았는데 '마감 알림'이 켜져 있던 것입니다. 단위 테스트는 모두 통과한 상태였습니다. 기존 테스트가 오히려 "기본값은 켜짐"을 정답으로 검증하고 있었기 때문입니다.

처리방침에는 "알림 수신에 동의한 회원에 한함"이라고 써 놓고 코드는 반대로 동작하고 있었습니다. 가입 시 동의 값을 따르도록 고쳤고, 이미 기본값으로 켜진 기존 회원에게도 메일이 가지 않게 발송 조건을 이중으로 걸었습니다.

테스트는 코드가 의도대로 동작하는지 확인합니다. 그 의도가 맞는지는 실제 화면을 봐야 알 수 있었습니다.


숫자로 보는 이번 개편

항목 결과
API 테스트 (Kotlin) 158건 통과
클라이언트 테스트 (React) 50건 통과, 타입 오류 0
수집 에이전트 테스트 (Python) 23건 통과
추천 적합도 (페르소나) 14% → 62%
감수 대기 254건 → 0건
보관 배포 이미지 최근 3회분

API 테스트 실행 결과 — 실제 터미널 캡처


다음 할 일

  • ~~커스텀 도메인 pocket.wonslab.dev 연결~~ → 2026-10-19 완료: pocket.wonslab.dev
  • 구청·시청·군청 게시판을 한 곳씩 다시 분석해 수집 재개
  • 법인·단체 대상 사업(개인이 신청할 수 없는 것)은 수집 단계에서 걸러내기

작은 서비스라도 개인정보를 받는다면 책임은 같습니다. 이번 개편은 그 책임을 혼자서도 유지할 수 있는 구조로 만드는 작업이었습니다.