한지갑 — 함께 쓰는 공동 가계부
부부·가족이 한 가계부를 같이 쓰는 앱. Supabase 클라우드 대신 오픈소스 부품 4개를 직접 띄우고, 접근 제어를 Postgres RLS 한 곳으로 모았다. 혼자 쓸 때는 기기 SQLite, 함께 쓸 때만 서버로 옮긴다.
테이블 17 · RLS 정책 42 · RPC 10 · 단위 테스트 94 · RLS 시나리오 35(기대 거부 11)
Spring/JSP/jQuery 기반 서비스 개발 경력. 최근에는 AI 코딩 도구를 붙여 설계·문서·검증을 직접 챙기는 방식으로 사이드 프로젝트 두 개를 만들고 운영 중입니다.
부부·가족이 한 가계부를 같이 쓰는 앱. Supabase 클라우드 대신 오픈소스 부품 4개를 직접 띄우고, 접근 제어를 Postgres RLS 한 곳으로 모았다. 혼자 쓸 때는 기기 SQLite, 함께 쓸 때만 서버로 옮긴다.
테이블 17 · RLS 정책 42 · RPC 10 · 단위 테스트 94 · RLS 시나리오 35(기대 거부 11)
국토부 실거래가·K-apt·건축물대장·뉴스 RSS를 한 파이프라인으로 수집해 지도에 보여주고, 같은 데이터로 뉴스 GraphRAG 질의응답을 붙였다. 원본 보관 → 정규화·격리 → 파티션 단위 원자적 공개.
Python 21,000줄 · ORM 모델 36 · 마이그레이션 27 · 데이터셋 YAML 28 · API 라우트 86 · pytest 275
api.hanjigap.hyunjins.co.kr (API 게이트웨이)부부 공유 가계부 앱 BuBoo를 실제로 쓰면서 불편했던 점이 출발점이다.
| BuBoo에서 불편했던 점 | 한지갑에서 바꾼 것 |
|---|---|
| iOS·Android 한쪽만 지원 | Flutter로 iOS·Android·PC 웹 동시 지원 |
| 처음부터 계정을 만들어야 함 | 계정 없이 혼자 시작 → 필요할 때 "함께 쓰기"로 전환 |
| 2인 제한 | N명 초대, 가계부마다 다른 닉네임 |
| 카드 승인 문자를 손으로 옮겨 적음 | Android 알림 리스너·문자 붙여넣기·카드사 엑셀·이메일 전달로 자동 입력 |
| 정산이 없음 | 공동/개인 지출 구분 → 월말 "누가 누구에게 얼마" 자동 계산 |
| ID | 기능 | 서버에서 하는 일 |
|---|---|---|
| ACC-08 / LDG-06 | 무계정 시작, 혼자=기기 SQLite / 함께=서버 | 기기 가계부를 id 그대로 서버로 옮기는 마이그레이션, 서버 가계부와 합치기 |
| MEM-01~03 | 초대 코드(6자리, 72시간)·링크·참여 | create_invite·join_ledger RPC. 코드만으로 참여, 초대 테이블은 owner만 조회 |
| TXN-03 / STA-04 | 공동·개인 지출 구분과 정산 | member_month_summary RPC. 탈퇴 멤버 지출도 정산에 남음 |
| TXN-06 | 반복 거래 | materialize_recurring — 규칙 작성자 귀속, 다중 기기 중복 방지 |
| TXN-10 / TXN-13 | 할부·환불 | 환불은 양수 금액 + refund_of 참조, 집계는 signed_amount()로 상계 |
| AUT-*, SET-06 | 알림·문자·엑셀·이메일 자동 입력 | 원문은 서버로 보내지 않음. source_hash 부분 unique로 채널 간 중복 제거 |
| SYN-01 | 실시간 동기화 | Realtime(wal2json). DELETE 이벤트에 ledger_id가 실리도록 replica identity full |
| ACC-02 / ACC-09 | 소셜 로그인·패스키 | GoTrue 미지원인 네이버와 WebAuthn은 별도 authbridge 컨테이너 |
[Flutter 앱: iOS / Android / PC 웹]
│ 혼자 모드: 기기 SQLite ──(함께 쓰기 전환)──▶ 서버로 이전
│ 함께 모드: HTTPS / WebSocket
▼
Caddy (80/443, Let's Encrypt 자동 발급)
▼
gateway (nginx 1장, Kong 대체)
├─ /auth/v1 → GoTrue (인증·세션·소셜 로그인)
├─ /auth/naver → authbridge (네이버 OAuth → GoTrue admin API → magiclink)
├─ /auth/passkey → authbridge (WebAuthn 등록·검증 → magiclink)
├─ /rest/v1 → PostgREST (테이블·RPC, RLS 적용)
├─ /realtime/v1 → Realtime (WebSocket, wal2json 논리 복제)
└─ /storage/v1 → storage-api (영수증 사진)
▼
Postgres 16 (+wal2json) ── 마이그레이션 4개 · RLS · security definer RPC · 트리거
├─ mailpoller (IMAP → inbound_messages)
├─ OCI Email Delivery (SMTP) → 가입 확인·비밀번호 재설정 메일
└─ backup.sh (pg_dump -Fc + 스토리지 tgz, 14세대, 오프사이트 rsync, heartbeat)
감시: Uptime Kuma(반대편 서버) · Beszel(자원) · Dozzle(로그) · 텔레그램 알림
운영 서버는 OCI A1(2 OCPU / 12GB) 1대. Supabase 클라우드 대신 부품만 띄운 이유는 이미 Postgres를 운영 중이었고, 앱 코드·마이그레이션·RLS가 Supabase 그대로라 나중에 pg_dump/pg_restore로 옮길 수 있기 때문이다.
ledger_members 한 곳으로모든 테이블은 RLS ON이고, 판단은 "내 user_id가 그 가계부의 ledger_members에 있는가"로만 한다. 멤버 행 하나를 지우면 그 사람의 모든 접근이 끊긴다.
create function is_member(p_ledger uuid) returns boolean
language sql stable security definer set search_path = public as $$
select exists (select 1 from ledger_members where ledger_id = p_ledger and user_id = auth.uid()) $$;
create policy transactions_insert on transactions for insert
with check (can_edit(ledger_id) and created_by = auth.uid());
| 테이블 | select | insert | update | delete |
|---|---|---|---|---|
| ledgers | 멤버 | 본인 생성 | owner | owner |
| ledger_members | 멤버 | 정책 없음 | 본인 nickname만 / role은 owner만 | 본인 나가기 / owner 내보내기 |
| categories, budgets, recurring_rules … | 멤버 | editor+ | editor+ | editor+ |
| transactions | 멤버 | editor+ & 작성자=본인 | 작성자 or owner | 작성자 or owner |
| storage.objects | 멤버 | editor+ | — | editor+ |
ledger_members에 insert 정책을 일부러 두지 않았다. 있으면 누구나 임의 ledger_id로 자기를 owner로 등록할 수 있다. 멤버가 생기는 경로는 security definer RPC 두 개(create_ledger, join_ledger)뿐이다.| RPC | 이유 |
|---|---|
create_ledger | 가계부 + owner 멤버 + 기본 카테고리 17개 + 결제수단 시드를 한 트랜잭션으로 |
create_invite / join_ledger | 클라이언트가 초대 테이블을 읽지 못해도 코드만으로 참여. 열거 방지, for update로 사용 횟수 경합 방지 |
leave_ledger / transfer_ownership | owner는 양도 후에만 나감, 마지막 멤버면 가계부 삭제 |
materialize_recurring(ledger, p_today) | on conflict do nothing으로 여러 기기가 동시에 불러도 중복 없음. 서버 current_date는 UTC라 클라이언트 로컬 날짜를 파라미터로 |
delete_account | 혼자 가계부 삭제, 공동 가계부는 owner 자동 양도 후 auth.users 삭제 |
| 집계 3종 | security invoker라 RLS가 그대로 적용 |
환불은 양수 금액 + refund_of 참조로 두고 집계에서만 부호를 바꾼다. 같은 규칙이 SQL 함수 signed_amount(t), Dart signedAmount, 기기 SQLite signedAmountSql 세 곳에 있다. 트리거 check_refund가 같은 가계부의 지출만 환불 대상으로 허용하고 공동/개인·카테고리를 원거래에서 상속시킨다.
create unique index on transactions(ledger_id, source_hash) where source_hash is not null;
create unique index transactions_recurring_occurrence_key on transactions(recurring_rule_id, occurred_on);
| 직접 해결한 것 | 내용 |
|---|---|
| 롤·스키마 | stock Postgres에 없는 anon/authenticated/service_role/authenticator 등 롤과 auth.uid()·publication·PostgREST 스키마 캐시 갱신 트리거를 initdb SQL로 보강 |
| 마이그레이션 순서 | GoTrue·storage-api가 자기 스키마를 만든 뒤(healthy) → post-auth SQL → 앱 마이그레이션. 이력은 public._migrations |
| Realtime | wal_level=logical + wal2json 이미지. 테넌트 id가 Host 헤더 첫 라벨이라 컨테이너 이름 고정 |
| CORS | GoTrue·storage-api가 preflight를 제대로 못 받아 nginx가 직접 응답(Kong이 하던 일) |
| 컬럼 권한 회귀 | migrate.sh의 grant all이 컬럼 단위 grant를 풀었다. RLS 시나리오 T35가 잡아 post_grants.sql로 다시 좁힘 |
authbridge(Python, 525줄)가 네이버 OAuth → 이메일 조회 → GoTrue admin API로 사용자 생성 → magiclink로 세션 발급. state는 HMAC 서명, redirect는 허용 목록만.app_metadata.passkeys(서비스 롤만 수정)에 보관, 검증 성공 시 magiclink hashed_token → 앱 verifyOTP. 챌린지 5분·1회용. soft-webauthn 가짜 인증기로 테스트 16건.SupabaseClient 대신 Db/Query 인터페이스(PostgREST 부분집합)를 받는다. SupabaseDb와 LocalDb(sqflite) 두 구현, 같은 쿼리 코드.uuid.v4()라 서버로 옮길 때 id를 그대로 쓴다. 의존 순서로 500행씩 복사, 실패하면 서버 가계부를 지워 되돌린다.source_hash 거래는 건너뛰고(환불은 서버 거래 재참조), 실패하면 이번에 넣은 행만 되돌린다.pg_dump -Fc + 스토리지 tgz, 14세대, 다른 계정·다른 서버로 오프사이트 rsync, 성공 시 heartbeat(25시간 무응답이면 알림).sb-<호스트 첫 라벨>-auth-token)가 바뀌어 모든 기기가 로그아웃됐다. 옛 키 → 새 키 세션 이전, 동의 게이트 수정, 회귀 테스트 4건, 옛 도메인 병행 서빙. 배포 체크리스트에 "기존 기기 로그인 유지 확인" 추가..env는 웹 빌드에서 공개되므로 anon 키·플래그만. service_role 키·메일 비밀번호는 서버 전용.| 종류 | 내용 | 규모 |
|---|---|---|
| 단위 | 파서, 회계월, 반복 규칙, 할부, 엑셀 가져오기, 해시, 딥링크, 내비게이션, 패스키, 기기 저장소, 마이그레이션 | 12파일 94건 |
| 통합 | 무계정 기기 가계부 전 흐름(서버 없이) / 회원가입 → 서버 이전 → 초대 / 합치기 | 5시나리오 |
| RLS 시나리오 | 세 사용자로 참여·역할 상승·탈퇴·양도·환불·잔액·cascade를 psql로 실행, 기대 거부 11건과 비교 | T1~T35 |
| authbridge | 패스키 등록·로그인·오류 경로 | pytest 16건 |
| 수동 QA | Android 릴리스 APK 전 화면 전수 진입 | 23건 발견·수정 |
| 영역 | 선택 |
|---|---|
| 앱 | Flutter / Dart 3.12, flutter_riverpod 3, go_router 17.5, sqflite |
| 인증·API·실시간·파일 | GoTrue v2.196 + authbridge(py_webauthn), PostgREST v14.17, Realtime v2.134(wal2json), storage-api v1.74 |
| DB | Postgres 16 — RLS 42정책, 트리거 8, RPC 10 |
| 인프라 | OCI A1, docker compose, nginx + Caddy(Let's Encrypt), OCI Email Delivery, Uptime Kuma·Beszel·Dozzle |
/, 관리 UI /admin/, API /api/)호갱노노 같은 실거래가 지도 서비스를 공공데이터만으로 어디까지 만들 수 있는지 확인하고 싶었다. 국토부 실거래가 API는 누구나 받을 수 있지만 거래 고유 ID가 없고, 값은 전부 문자열이며, 해제 건은 별도 행으로 오고, 단지 키가 다른 공공 API와 다른 체계다. 그래서 "원본 보관 → 정규화·격리 → 검증 → 파티션 단위 원자적 공개"를 지키는 파이프라인을 먼저 세우고 그 위에 지도 앱과 뉴스 RAG를 올렸다.
| 영역 | 기능 |
|---|---|
| 수집 | 실거래가 8개 데이터셋(아파트·연립다세대·단독다가구·오피스텔 × 매매·전월세), K-apt, 건축물대장, 공시가격, 법정동 경계, 청약홈, 학교, KOSIS·행안부 통계, 뉴스 RSS 5개 피드 |
| 지도 앱 | 단지 핀 → 읍면동 → 시군구 3단 줌, 거래종류·유형·평형·가격·세대수·전세가율·갭·수익률 필터, 지도 모드(시세·거래량·변동·신고가·하락), 단지 상세(차트·이력·평당가 비교·거래세·보유세·지역 인사이트·관련 뉴스) |
| 관리 UI | 파티션 현황, 수집 범위, 데이터셋 YAML 편집·검증·미리보기·버전, 수집 플로우 DAG(React Flow), 단지 매칭 검수·병합, 뉴스 그래프, 감사 로그 |
| 질의응답 | /rag/query(벡터 + 그래프), /rag/agent(plan→act→answer, 도구 3개). 출처 번호, 근거 부족 시 "모른다" |
| 모바일 앱 '실봄' | Capacitor + 자체 네이버 지도 플러그인(Swift·Kotlin). 투명 WebView 뒤에 네이티브 지도, 터치 라우팅 |
| 운영 | 내장 스케줄러/Dagster, rp doctor, 백업·복원·raw 재생성, 메모리 감시 |
[외부 소스] 국토부 실거래가(XML) · K-apt(JSON) · 건축물대장 · VWorld · 청약홈 · KOSIS/행안부 · 뉴스 RSS
│ collectors/ (커넥터: fetch(partition, cursor) → page, tenacity 재시도, 키 마스킹)
▼
raw_pages (원문 불변 보관, 본문 해시) ← ingestion_runs (파티션·설정 버전·코드 버전·건수)
│ core/ (datasets/*.yaml 템플릿 상속 → 변환 함수 레지스트리 → 실패는 rejected_records 격리)
│ enrich/ (지오코딩 + 캐시, K-apt 매칭, 공급면적, 임베딩, LLM 엔티티 추출)
▼
serving_generations (파티션 단위 원자적 전환. 실패한 실행은 기존 세대를 건드리지 않음)
├─ deals(deals_current 뷰) · property_master · property_summary(단지×거래종류×면적대)
├─ news_chunks(pgvector HNSW) · entities · relations · mentions ──▶ Neo4j 미러
└─ region_stats · public_prices · building_registers …
▼
FastAPI ── 공개 조회 API · /admin/* (X-Admin-Key, audit_log) · /rag/* · 내장 스케줄러 · 메모리 감시
▼
nginx (SPA + /api 프록시) ◀── 리버스 프록시(TLS) ◀── 브라우저 / 실봄 앱
수집 단위는 (dataset_id, lawd_cd, deal_ymd) = 시군구 × 계약월. 같은 파티션의 동시 실행은 pg_try_advisory_xact_lock으로 막고, 페이지 누락·건수 불일치가 있으면 공개를 보류하고 기존 세대를 유지한다. 서빙 테이블은 언제든 raw에서 다시 만든다(rp replay). 변환 실패는 조용히 보정하지 않고 사유와 함께 격리한다.
| 선택지 | 판단 |
|---|---|
| 속성 해시로 upsert | 금액 정정 시 해시가 바뀌어 중복이 남고, 같은 속성의 서로 다른 거래(실측: 전월세 138그룹)가 합쳐진다 → 기각 |
| 파티션 스냅샷 교체 | 파티션 전체를 새 세대로 바꾸고 이전 세대는 보존. 동일 행도 개수를 보존 → 채택 |
사라진 행은 source_missing에 기록하되 "해제"로 해석하지 않는다. 상태를 cancelled / source_missing / superseded로 나눈다. 재수집은 최근 3개월 매 실행 + 과거 월 순환.
commit()한 뒤 부른다.FOR UPDATE SKIP LOCKED로 잡고 claimed로 바꿔 커밋한 뒤 처리한다.rollback()한다.with_for_update로 잠근다.idle_in_transaction_session_timeout=3min, 정적 검사(with session_scope() 안 외부 호출 탐지), 런타임 검사(가짜 클라이언트로 in_transaction() 측정). 새 수집기는 둘 다 통과해야 한다.운영 중 uvicorn이 16.5GB까지 자라 스왑 없는 호스트 전체가 멈췄다. tracemalloc으로 잡은 원인은 VWorld API가 끝을 넘는 pageNo를 오류 없이 마지막 페이지로 되돌려 주는데 len(rows) < 1000으로만 끝을 판정해 같은 페이지를 무한히 이어 붙인 것. totalCount·응답 pageNo·상한으로 끝을 판정하도록 고치고 회귀 테스트를 추가했다. 함께 발견한 httpx.Client 미종료는 정적 검사로 강제. 방어는 두 겹: 컨테이너 mem_limit와 30초 주기 RSS 감시(memwatch.py).
# molit-base / molit-trade-base (발췌)
fields:
price: { type: money_won, from: dealAmount, transform: [strip_comma, x10000] }
deal_date: { type: date, from: [dealYear, dealMonth, dealDay], transform: [ymd_concat] }
cancelled: { type: tri_state, from: cdealType, transform: [tri_state] } # true / false / unknown
커넥터·변환 함수·처리부만 코드로 등록하고 조합은 YAML이 한다. 실행 시 해석된 설정을 config_version으로 고정하고, 관리 UI에서 breaking 변경(타입 변경·삭제)은 acknowledge_breaking 없이는 409로 거부한다.
property_summary 읽기 모델을 쓴다. 세대 공개 시 영향 단지만 재계산. 지도 집계 = 상세 목록 합을 테스트로 검증.OFFSET 0 울타리로 2.9초.complete())로만 호출. 용도별 3트랙(추출은 로컬 Ollama, 답변은 Gemini 무료 티어, 임베딩 로컬). 에이전트는 LangGraph 없이 plan→act→answer, 도구는 읽기 전용, 평가 스크립트로 벡터/그래프/에이전트 비교.deploy_oci.sh: git archive HEAD → 서버에서 api·web 이미지 재빌드 → /api/health 확인. 컨테이너 시작 시 alembic upgrade head.pg_dump -Fc 14세대 + 체크섬, 다른 DB에 먼저 복원해 확인하는 절차. rp doctor로 구멍·격리·멈춘 잡·오래된 트랜잭션 점검.| 종류 | 내용 |
|---|---|
| 단위·fixture | 변환·상속·정규화, 실제 응답 fixture 전량 정규화 E2E(8개 데이터셋 3,611건) |
| DB 테스트 | 세대 전환·이전 세대 보존·격리 시 유지, 설정 스냅샷, 관리 API 401, 집계=상세 합. fixture는 시군구 99999로 격리 |
| 정적 검사 | 트랜잭션 안 외부 호출, httpx.Client 미종료, 잡 프리셋 누락 |
| 수동 QA | 운영 API 143케이스 + 브라우저 8시나리오 → 21건 조치(1건은 배포 후 재확인 미완) |
합계 pytest 275건(45파일), ruff + pnpm lint/build.
| 영역 | 선택 |
|---|---|
| 언어·패키징 | Python 3.12 (uv workspace 6패키지), TypeScript (pnpm workspace 3앱) |
| DB | Postgres 16 + PostGIS 3 + pgvector, SQLAlchemy + alembic 27개, Neo4j 5.26(미러) |
| API·오케스트레이션 | FastAPI(86 라우트), APScheduler 내장 스케줄러 + 플로우 DAG, Dagster(선택), advisory lock |
| LLM | Gemini·로컬 Ollama·anthropic, 단일 어댑터, embeddinggemma |
| 프론트·모바일 | React 19, Vite, 네이버 지도 SDK, React Flow, Capacitor 8 + 자체 플러그인(Swift·Kotlin) |
| 배포 | docker compose(OCI), nginx, mem_limit + 메모리 감시 |
Claude Code를 주 도구로 썼고 설계 검토에는 다른 도구(Codex CLI 등)를 리뷰어로 붙였다. 실제로 효과가 있었던 방식과 한계를 그대로 적는다.
on delete set null + 회귀 시나리오). 오판("Dart 'key': ?value는 문법 오류")은 근거를 적고 기각했다..order() 기본 내림차순 같은 함정은 저장소의 CLAUDE.md에 한 줄씩 남기고 정적/런타임 테스트로 강제했다. 다음 세션의 도구가 같은 실수를 하면 테스트가 먼저 잡는다.