ACN
dAppOffice
문서 목차 (5 / 16)

05. 데이터베이스 (PostgreSQL)

PostgreSQL 16. 접속 정보는 DATABASE_URL(libpq URI, 예: postgresql://acn:***@127.0.0.1:5432/acn). 애플리케이션은 커넥션 풀(psycopg 3, 최대 20)을 쓰고, 스키마는 기동 시 db.pySCHEMAoffice/schema.pyOFFICE_SCHEMACREATE TABLE IF NOT EXISTS로 적용한다.

2026-09-08 이전까지는 SQLite(backend/data/alice.db) 였다. 이전 절차와 남은 SQLite 파일의 취급은 아래 SQLite에서 이전 참고.

트랜잭션 모델

db.get_conn()이 세 가지 모드를 준다. SQLite 시절의 보장을 그대로 옮긴 것이다.

호출PostgreSQL대체한 SQLite 동작
get_conn(write=True)pg_advisory_xact_lock 획득BEGIN IMMEDIATE
get_conn(snapshot=True)REPEATABLE READdeferred BEGIN(WAL 스냅샷)
get_conn()오토커밋 읽기동일

advisory lock이 왜 필요한가. PostgreSQL은 MVCC라 writer끼리 서로 막지 않는다. 그런데 Office 서비스 계층은 전부 "읽고-검사하고-쓰는" 코드이고(잔액, 노드 재고, 상태 전이), SQLite에서는 단일 writer 락이 그 사이를 지켜주고 있었다. 그대로 옮기면 두 요청이 같은 잔액 검사를 동시에 통과해 이중 출금이 가능해진다. 트랜잭션 범위 advisory lock으로 "한 번에 한 writer"를 복원했고, 덕분에 정산 로직은 한 줄도 바뀌지 않았다. 상태 전이의 조건부 UPDATE와 부분 유니크 인덱스는 그 위에 겹쳐진 두 번째·세 번째 방어선으로 그대로 남아 있다.

읽기는 락과 무관하다. 조직도 조회(snapshot=True)는 writer가 락을 쥐고 있어도 즉시 응답한다.

테이블

transfers — ACN Transfer 이벤트

컬럼타입설명
tx_hashTEXT트랜잭션 해시(소문자 0x)
log_indexBIGINT영수증 내 로그 인덱스. 한 tx에 Transfer가 여러 개일 수 있어 PK에 포함
block_numberBIGINT블록 번호
from_addressTEXT보낸 주소(소문자)
to_addressTEXT받은 주소(소문자)
amount_weiTEXT수량(wei). uint256은 64bit 정수를 넘을 수 있어 TEXT
block_timeBIGINT블록 타임스탬프(unix). 없으면 NULL
sourceTEXTindexer 또는 client(POST /api/transfers로 기록)
created_atBIGINT저장 시각(unix), 기본값 EXTRACT(EPOCH FROM now())::bigint
  • PK: (tx_hash, log_index)
  • 인덱스: from_address, to_address, block_number DESC
  • upsert 규칙: 충돌 시 block_number 갱신, block_time은 NULL이 아닌 값 우선 유지.

indexer_state — 인덱서 체크포인트 겸 운영 키·값

컬럼타입설명
keyTEXT PKlast_indexed_block, office_swap_rate_micro, office_airdrop_backfill
valueTEXT

Office office_* 테이블은 16-office.md에 있다.

설계 메모

  • 주소는 전부 소문자로 정규화해 저장·비교한다(EIP-55 체크섬은 표시용).
  • 금액 계산은 DB가 아니라 애플리케이션(파이썬 int, 프론트 bigint)에서 한다. DB에는 정수로만 저장한다(USD는 센트, ACN·USDT는 milli).
  • 집계는 ::bigint로 캐스팅한다. PostgreSQL의 SUM(bigint)numeric을 돌려주고 psycopg가 이를 Decimal로 넘기는데, 그대로 두면 원장 금액이 JSON에 숫자가 아닌 문자열로 나간다. 장부 집계 8곳은 전부 COALESCE(SUM(...),0)::bigint 형태다. 새 집계를 추가할 때도 같은 규칙을 지킨다.
  • id는 BIGINT GENERATED BY DEFAULT AS IDENTITY다. ALWAYS가 아닌 이유는 이전 도구가 원래 id를 그대로 넣어야 하기 때문이다(추천 보상·에어드랍 배분·조직이동 감사기록이 같은 회원/노드를 계속 가리켜야 한다).
  • 애플리케이션 SQL은 SQLite 문법(? 자리표시자)을 그대로 쓴다. db.PgConnection이 문자열 리터럴·주석 밖의 ?%s로 치환한다. 방언 차가 실제로 있는 곳만 손댔다: INSERT OR IGNOREON CONFLICT DO NOTHING, COLLATE NOCASELOWER(...), IS ?IS NOT DISTINCT FROM ?::bigint, lastrowidRETURNING id.
  • 마이그레이션 도구 없이 시작한다. 스키마 변경이 잦아지면 Alembic 도입을 검토한다.

백업

pg_dump가 매일 03:17 UTC(12:17 KST)에 돈다(/etc/cron.d/acn-backup).

항목
스크립트/usr/local/bin/acn-backup
저장 위치/var/lib/acn/backup/acn-<UTC타임스탬프>.dump
형식pg_dump -Fc --compress=9 (선택 복원 가능)
보관14일 (ACN_BACKUP_KEEP_DAYS)
로그/var/log/acn-backup.log (월 단위 rotate, 12개월)
권한디렉터리·파일 모두 root 전용 0600

권한이 root 전용인 이유는 덤프에 비밀번호 해시와 TOTP 비밀키가 들어 있기 때문이다. 이 파일을 읽을 수 있으면 회원의 2단계 인증을 재현할 수 있다. 외부로 복사할 때도 같은 취급을 해야 한다.

스크립트는 덤프를 .tmp로 쓴 뒤 원자적으로 옮기고(중단된 실행이 백업처럼 보이지 않도록), pg_restore --list로 목차를 읽어 최소한의 무결성을 확인한 다음 오래된 덤프를 지운다.

복원 검증

복원해 본 적 없는 백업은 백업이 아니다. acn-restore-check가 최신 덤프를 임시 DB에 복원해 스키마·행 수를 운영 DB와 대조하고 임시 DB를 지운다. 운영 DB에는 쓰지 않는다.

acn-restore-check                    # 최신 덤프
acn-restore-check /path/to/x.dump    # 특정 덤프

실제 복원

systemctl stop acn-api
sudo -u postgres dropdb acn && sudo -u postgres createdb -O acn acn
sudo -u postgres pg_restore --dbname=acn --no-owner --role=acn < /var/lib/acn/backup/acn-<타임스탬프>.dump
systemctl start acn-api

덤프가 root 전용이라 postgres 사용자가 직접 열 수 없다. 위처럼 root가 읽어 stdin으로 넘긴다.

SQLite에서 이전

backend/tools/migrate_sqlite_to_pg.py가 옛 alice.db를 PostgreSQL로 옮긴다.

cd backend
.venv/bin/python tools/migrate_sqlite_to_pg.py --dry-run          # 무엇이 옮겨지는지만 출력
.venv/bin/python tools/migrate_sqlite_to_pg.py --sqlite /var/lib/acn/alice.db

원장은 오프체인이라 어디서도 재구축할 수 없으므로 도구는 관대하지 않고 엄격하게 만들었다.

  • 대상 스키마는 앱 자신(app.db.init_db)이 만든다. API가 기대하는 모양과 데이터가 들어가는 모양이 어긋날 수 없다.
  • 소스에 대상에 없는 컬럼이 있으면 중단한다. TOTP·USDT 이전 시절의 SQLite 파일을 필드가 빠진 채로 조용히 밀어넣지 않는다. 그런 파일은 구버전을 한 번 띄워 자체 마이그레이션을 돌린 뒤 옮긴다.
  • 행 id를 그대로 보존하고, 끝나면 각 identity 시퀀스를 최대 id 뒤로 밀어 둔다.
  • office_users.referrer_id는 자기 참조 FK라 회원을 추천인 없이 먼저 넣고 2패스로 연결한다. 트리의 id 순서와 무관하게 동작한다.
  • 전체가 한 트랜잭션이고, 테이블별 행 수를 대조해 불일치하면 실패한다.
  • 비어 있지 않은 대상은 --force 없이는 건드리지 않는다.

이전 검증은 구버전(SQLite)과 신버전(PostgreSQL)에서 전 회원의 장부·노드·추천·에어드랍·조직도를 JSON으로 덤프해 비교하는 방식으로 했다. 결과는 바이트 단위로 동일했다.

/var/lib/acn/alice.db는 참고용으로 남아 있고 애플리케이션은 열지 않는다. 이전이 확정되면 지워도 된다(백업 디렉터리에 사본이 있다).