Hyunjin · Java 백엔드 개발자

Spring/JSP/jQuery 기반 서비스 개발 경력. 최근에는 AI 코딩 도구를 붙여 설계·문서·검증을 직접 챙기는 방식으로 사이드 프로젝트 두 개를 만들고 운영 중입니다.

프로젝트

한지갑 — 함께 쓰는 공동 가계부

2026-09 ~ · 개인 · iOS / Android / PC 웹
FlutterPostgres 16RLSGoTruePostgRESTRealtimeSQLiteCaddydocker compose

부부·가족이 한 가계부를 같이 쓰는 앱. Supabase 클라우드 대신 오픈소스 부품 4개를 직접 띄우고, 접근 제어를 Postgres RLS 한 곳으로 모았다. 혼자 쓸 때는 기기 SQLite, 함께 쓸 때만 서버로 옮긴다.

테이블 17 · RLS 정책 42 · RPC 10 · 단위 테스트 94 · RLS 시나리오 35(기대 거부 11)

realestate-pipeline / 실봄

2026-09 ~ · 개인 · 웹 / iOS / Android
Python 3.12FastAPIPostGISpgvectorNeo4jReact 19Capacitor네이버 지도OCI

국토부 실거래가·K-apt·건축물대장·뉴스 RSS를 한 파이프라인으로 수집해 지도에 보여주고, 같은 데이터로 뉴스 GraphRAG 질의응답을 붙였다. 원본 보관 → 정규화·격리 → 파티션 단위 원자적 공개.

Python 21,000줄 · ORM 모델 36 · 마이그레이션 27 · 데이터셋 YAML 28 · API 라우트 86 · pytest 275

한지갑 — 함께 쓰는 공동 가계부

역할
기획·백엔드(DB 스키마, RLS, RPC, 인프라)·앱 전부
기간
2026-09-17 착수, 9-19 기준 로드맵 M0~M7 코드 완료
운영
hanjigap.hyunjins.co.kr (PC 웹) · api.hanjigap.hyunjins.co.kr (API 게이트웨이)
규모
서버 테이블 17 · RLS 정책 42(테이블 39 + 스토리지 3) · RPC 10 · 마이그레이션 4 · Dart 파일 112 · 단위 테스트 94 · RLS 시나리오 35

왜 만들었나

부부 공유 가계부 앱 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());
테이블selectinsertupdatedelete
ledgers멤버본인 생성ownerowner
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)뿐이다.

트랜잭션이 필요한 쓰기는 security definer RPC

RPC이유
create_ledger가계부 + owner 멤버 + 기본 카테고리 17개 + 결제수단 시드를 한 트랜잭션으로
create_invite / join_ledger클라이언트가 초대 테이블을 읽지 못해도 코드만으로 참여. 열거 방지, for update로 사용 횟수 경합 방지
leave_ledger / transfer_ownershipowner는 양도 후에만 나감, 마지막 멤버면 가계부 삭제
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);

셀프호스팅 Supabase — Kong 대신 nginx 한 장

직접 해결한 것내용
롤·스키마stock Postgres에 없는 anon/authenticated/service_role/authenticator 등 롤과 auth.uid()·publication·PostgREST 스키마 캐시 갱신 트리거를 initdb SQL로 보강
마이그레이션 순서GoTrue·storage-api가 자기 스키마를 만든 뒤(healthy) → post-auth SQL → 앱 마이그레이션. 이력은 public._migrations
Realtimewal_level=logical + wal2json 이미지. 테넌트 id가 Host 헤더 첫 라벨이라 컨테이너 이름 고정
CORSGoTrue·storage-api가 preflight를 제대로 못 받아 nginx가 직접 응답(Kong이 하던 일)
컬럼 권한 회귀migrate.shgrant all이 컬럼 단위 grant를 풀었다. RLS 시나리오 T35가 잡아 post_grants.sql로 다시 좁힘

인증: GoTrue + 네이버 브리지 + 패스키

저장 분리: 혼자 = 기기 SQLite, 함께 = 서버

운영: 백업·감시·도메인 이전

검증 체계

종류내용규모
단위파서, 회계월, 반복 규칙, 할부, 엑셀 가져오기, 해시, 딥링크, 내비게이션, 패스키, 기기 저장소, 마이그레이션12파일 94건
통합무계정 기기 가계부 전 흐름(서버 없이) / 회원가입 → 서버 이전 → 초대 / 합치기5시나리오
RLS 시나리오세 사용자로 참여·역할 상승·탈퇴·양도·환불·잔액·cascade를 psql로 실행, 기대 거부 11건과 비교T1~T35
authbridge패스키 등록·로그인·오류 경로pytest 16건
수동 QAAndroid 릴리스 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
DBPostgres 16 — RLS 42정책, 트리거 8, RPC 10
인프라OCI A1, docker compose, nginx + Caddy(Let's Encrypt), OCI Email Delivery, Uptime Kuma·Beszel·Dozzle

realestate-pipeline / 실봄 — 실거래가 수집 파이프라인과 지도

역할
설계·수집 파이프라인·API·관리 UI·지도 앱·모바일 앱·배포 전부
기간
2026-09-11 착수, 9-19 기준 로드맵 0~5단계 대부분 완료, 커밋 184개
운영
OCI 서버, realestate.hyunjinssss.duckdns.org (지도 앱 /, 관리 UI /admin/, API /api/)
규모
Python 75파일 21,000줄 · TS/TSX 65파일 6,100줄 · ORM 모델 36 · alembic 마이그레이션 27 · 데이터셋 YAML 28 · API 라우트 86 · pytest 275건

왜 만들었나

호갱노노 같은 실거래가 지도 서비스를 공공데이터만으로 어디까지 만들 수 있는지 확인하고 싶었다. 국토부 실거래가 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개월 매 실행 + 과거 월 순환.

트랜잭션 경계 규칙 — 사고 5건 뒤에 코드로 강제

  1. 외부 호출(HTTP·LLM·임베딩·Neo4j)은 열린 트랜잭션 안에서 하지 않는다. 읽고 commit()한 뒤 부른다.
  2. 시군구·단지·기사 단위 반복 작업은 단위마다 커밋한다.
  3. 여러 프로세스가 집는 큐는 FOR UPDATE SKIP LOCKED로 잡고 claimed로 바꿔 커밋한 뒤 처리한다.
  4. 실패 기록을 별도 연결로 쓰기 전에 현재 세션을 rollback()한다.
  5. 통째로 다시 쓰는 JSON 행은 with_for_update로 잠근다.
강제 장치: 모든 연결에 idle_in_transaction_session_timeout=3min, 정적 검사(with session_scope() 안 외부 호출 탐지), 런타임 검사(가짜 클라이언트로 in_transaction() 측정). 새 수집기는 둘 다 통과해야 한다.

외부 API 페이징 사고와 메모리 감시

운영 중 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로 거부한다.

조회 API 설계와 성능

뉴스 RAG → GraphRAG → 에이전트

배포와 운영

검증 체계

종류내용
단위·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앱)
DBPostgres 16 + PostGIS 3 + pgvector, SQLAlchemy + alembic 27개, Neo4j 5.26(미러)
API·오케스트레이션FastAPI(86 라우트), APScheduler 내장 스케줄러 + 플로우 DAG, Dagster(선택), advisory lock
LLMGemini·로컬 Ollama·anthropic, 단일 어댑터, embeddinggemma
프론트·모바일React 19, Vite, 네이버 지도 SDK, React Flow, Capacitor 8 + 자체 플러그인(Swift·Kotlin)
배포docker compose(OCI), nginx, mem_limit + 메모리 감시

AI 코딩 도구를 어떻게 썼나

Claude Code를 주 도구로 썼고 설계 검토에는 다른 도구(Codex CLI 등)를 리뷰어로 붙였다. 실제로 효과가 있었던 방식과 한계를 그대로 적는다.