# 운영 적용 런북: REQ-047 Step2 — broker_credentials PK 전환 (daemon_id, broker) - Batch: BAT-021 - Discovery: dcy-010 - Date: 2026-06-28 - 결재 게이트: 사장님 결재 필수 (비가역 PK 변경 — Step 4) - 작업 예상 시간: 약 20~30분 (데이터량에 따라 상이) --- ## 전제 조건 - **prod는 REL-002(freeze `bd34450`, alembic head `20260605_0025`)** 상태다. BAT-020·BAT-021 코드는 아직 미배포다. - **본 절차 시작 시(0단계)** 체크아웃을 origin/main(`9d98d47`)으로 갱신하고 `pip install -e .` 를 실행해 마이그레이션 파일·backfill CLI·신규 코드를 디스크에 올린다. **단 이 시점에는 서비스를 재시작하지 않는다.** 서비스 재시작(신규 코드 활성화)은 Step2 완료 이후(6단계)에 수행한다. - 마이그레이션 전 DB 백업이 완료돼 있어야 한다. - 이 런북의 모든 alembic 명령은 `PORTFOLIOHUB_DB_ENABLED=true` 및 DB 접속 환경변수가 설정된 셸에서 실행한다. --- ## 배포 순서 개요 ``` 0단계: 체크아웃 갱신(origin/main 9d98d47, pip install -e ., 서비스 재시작 X) → DB 백업 → 프리체크(alembic head·NULL 확인·락 확인) → Step1 upgrade(20260628_0026) → Step1 검증 → backfill CLI(pfhub credential backfill-daemon-id) → NULL 0건 확인(게이트) → Step2 upgrade(20260628_0027, 사장님 결재 후) → Step2 검증 → 서비스 재시작(코드는 0단계에서 이미 디스크에 있음 → Step2 후 재시작으로 신규 코드 활성화) → 배포 후 동작 확인 ``` --- ## 순서 위반 실패 모드 (반드시 읽을 것) | 위반 | 증상 | | --- | --- | | **체크아웃 미갱신 상태로 `alembic upgrade` 시도** | revision `20260628_0026` 파일이 디스크에 없으므로 alembic이 head를 못 찾거나 `Can't locate revision` 오류로 실패 | | **서비스 재시작을 Step2보다 먼저 수행** | `upsert_broker_credential`의 `ON CONFLICT (daemon_id, broker)` 절이 구 PK `(user_id, broker)`와 불일치 → 인증정보 등록·회전 시 `psycopg.errors.UndefinedColumn` 또는 `ProgrammingError` 발생 | | **Step2를 backfill 없이 수행** | `alembic upgrade 20260628_0027` 내부 사전 검증 로직이 `daemon_id IS NULL` 행을 감지하고 `RuntimeError`로 자동 거부 (DB 변경 없음) | | **Step1 없이 Step2 수행** | Step2 revision(`down_revision = 20260628_0026`)이 Step1 적용 여부를 검사하므로 alembic 체인 오류로 실패 | | **백업 없이 Step2 수행** | 비가역 PK 변경 후 이슈 발생 시 복원 불가 — 반드시 백업 후 진행 | --- ## 0단계: 체크아웃 갱신 (서비스 재시작 없음) prod 서버의 portfolio-hub 체크아웃을 origin/main(`9d98d47`)으로 갱신한다. 이 작업은 **실행 중 프로세스를 바꾸지 않는다** — 파이썬 프로세스는 재시작 전까지 메모리의 구 코드를 그대로 실행한다. 갱신 목적은 마이그레이션 파일·backfill CLI·신규 앱코드를 디스크에 올려 이후 alembic 명령과 CLI가 실행 가능하게 하는 것이다. ```bash cd /path/to/portfolio-hub # origin/main 최신 상태로 체크아웃 갱신 git fetch origin git checkout 9d98d47 # 또는: git reset --hard origin/main # 의존성 및 패키지 재설치 (마이그레이션 파일·CLI entrypoint 포함) pip install -e . # 마이그레이션 파일 존재 확인 ls app/db/migrations/versions/20260628_0026_*.py ls app/db/migrations/versions/20260628_0027_*.py # 기대: 두 파일 모두 존재 # backfill CLI 확인 pfhub credential backfill-daemon-id --help # 기대: usage 출력 (에러 없음) ``` > **서비스 재시작 금지**: 이 단계에서는 서비스(컨테이너·프로세스)를 재시작하지 않는다. 구 프로세스는 Step1·backfill 동안 구 PK `(user_id, broker)` 스키마와 호환된 상태로 정상 동작한다. 서비스 재시작은 Step2 완료 이후(6단계)에만 수행한다. --- ## 1단계: DB 백업 운영 DB에 마이그레이션을 적용하기 전 전체 스냅샷을 백업한다. ```bash pg_dump -h -U -d -F c \ -f backup_pre_bat021_$(date +%Y%m%d%H%M%S).dump ``` 백업 완료 후 파일 크기와 종료 코드를 확인한다: ```bash ls -lh backup_pre_bat021_*.dump # 기대: 파일이 존재하고 크기가 0이 아닌 값 echo $? # 기대: 0 ``` > **중단 기준**: 백업 실패(파일 미생성, 크기 0, exit code != 0) 시 이후 단계 진행 금지. 백업 성공 확인 후 진행. --- ## 2단계: 사전 프리체크 (마이그레이션 시작 전) ### 2-1: 현재 alembic head/stamp 확인 ```bash export TIMESCALEDB_HOST= export TIMESCALEDB_PORT= export TIMESCALEDB_NAME= export TIMESCALEDB_USER= export TIMESCALEDB_PASSWORD= export PORTFOLIOHUB_DB_ENABLED=true alembic current ``` **기대값**: `20260605_0025 (head)` — REL-002 마이그레이션(`20260605_0025 ledger_consolidation`)까지 적용된 상태 > **주의**: alembic 상태가 없거나(`None`) 다른 revision인 경우 팀장에 보고 후 중단. `alembic stamp 20260605_0025` 조치가 필요한지 여부를 팀장이 판단한다. ### 2-2: broker_credentials 현재 스키마 확인 ```sql -- 현재 컬럼 목록 확인 SELECT column_name, data_type, is_nullable FROM information_schema.columns WHERE table_name = 'broker_credentials' ORDER BY ordinal_position; -- 기대: daemon_id 컬럼이 없는 상태 (Step1 미적용) 또는 nullable인 상태 (Step1 적용 후) -- 현재 PK 확인 SELECT constraint_name, constraint_type FROM information_schema.table_constraints WHERE table_name = 'broker_credentials' AND constraint_type = 'PRIMARY KEY'; -- 기대: broker_credentials_pkey PRIMARY KEY ``` ### 2-3: 기존 데이터 행 수 및 daemon_id NULL 현황 확인 ```sql -- 전체 행 수 (백업·복원 검증용으로 기록해 둘 것) SELECT COUNT(*) AS total_rows FROM broker_credentials; -- NULL 현황 (Step1 이전에는 daemon_id 컬럼 자체가 없으므로 실패 정상) -- Step1 적용 후 실행: SELECT COUNT(*) AS null_daemon_id_rows FROM broker_credentials WHERE daemon_id IS NULL; ``` ### 2-4: 활성 connection 및 락 확인 ```sql -- 현재 broker_credentials 테이블을 잡고 있는 락 확인 SELECT pid, usename, application_name, state, wait_event_type, wait_event, query FROM pg_stat_activity WHERE query ILIKE '%broker_credentials%' AND state != 'idle'; -- 기대: 결과 없음 (락 없음) -- 전체 활성 connection 수 확인 SELECT COUNT(*) FROM pg_stat_activity WHERE state = 'active'; ``` > **중단 기준**: broker_credentials를 잡고 있는 활성 쿼리/락이 있으면 대기 또는 ops 윈도우 재조정. 서비스 배포 중단 확인 후 진행 권장. --- ## 3단계: Step1 alembic upgrade (daemon_id NULL 컬럼 추가) ```bash alembic upgrade 20260628_0026 ``` 예상 출력: ``` INFO [alembic.runtime.migration] Running upgrade 20260605_0025 -> 20260628_0026, broker_credentials: daemon_id TEXT NULL 컬럼 추가 (REQ-047 Step1) ``` ### Step1 검증 ```sql -- daemon_id TEXT NULL 컬럼이 존재해야 함 SELECT column_name, data_type, is_nullable FROM information_schema.columns WHERE table_name = 'broker_credentials' AND column_name = 'daemon_id'; -- 기대: daemon_id | text | YES (nullable) -- 기존 PK (user_id, broker) 유지 확인 SELECT pg_get_constraintdef(oid) FROM pg_constraint WHERE conname = 'broker_credentials_pkey'; -- 기대: PRIMARY KEY (user_id, broker) -- 기존 데이터 daemon_id=NULL 보존 확인 SELECT COUNT(*) AS total_rows FROM broker_credentials; -- 기대: 2단계에서 확인한 total_rows와 동일 ``` ### Step1 롤백 절차 Step1에서 문제 발생 시: ```bash alembic downgrade 20260605_0025 ``` 예상 출력: ``` INFO [alembic.runtime.migration] Running downgrade 20260628_0026 -> 20260605_0025, ... ``` 롤백 검증: ```sql SELECT column_name FROM information_schema.columns WHERE table_name = 'broker_credentials' AND column_name = 'daemon_id'; -- 기대: 결과 없음 (컬럼 제거됨) ``` > **중단 기준**: Step1 upgrade 실패(exit code != 0, 오류 메시지 출력) 시 즉시 downgrade 후 팀장에 보고. --- ## 4단계: backfill CLI — daemon_id 매핑 Step1 적용 후 모든 기존 broker_credentials 행에 daemon_id를 설정한다. ### 4-1: 대상 사용자/daemon 확인 현재 user-scoped 인증정보(daemon_id IS NULL)가 있는 사용자와 그 데몬을 사전 파악한다: ```sql SELECT bc.user_id, u.username, bc.broker, bc.account_ref FROM broker_credentials bc LEFT JOIN users u ON bc.user_id = u.id WHERE bc.daemon_id IS NULL; ``` 각 (username, daemon_slug) 쌍을 확인한다. username은 포트폴리오 시스템에 등록된 사용자명, daemon_slug는 해당 사용자의 데몬 식별자(pfhub daemon list로 확인 가능). ### 4-2: --dry-run으로 선 확인 ```bash # 각 사용자별로 실행 (daemon_id는 해당 user의 daemon slug 또는 UUID) pfhub credential backfill-daemon-id \ --user \ --daemon-id \ --dry-run ``` 예상 출력: ``` [dry-run] 대상 N건: user_id= broker=kis → daemon_id= {'status': 'dry_run', 'would_update': N} ``` ### 4-3: 실제 백필 실행 ```bash pfhub credential backfill-daemon-id \ --user \ --daemon-id \ --force ``` 여러 사용자가 있으면 각 사용자별로 반복 실행한다. ### 4-4: NULL 0건 게이트 — Step2 진행 전 필수 확인 ```sql -- 이 쿼리가 반드시 0을 반환해야 Step2를 진행할 수 있다 SELECT COUNT(*) FROM broker_credentials WHERE daemon_id IS NULL; -- 기대: 0 ``` **경고: NULL이 1건이라도 있으면 Step2 upgrade가 RuntimeError로 거부된다. 반드시 0건 확인 후 진행.** > **중단 기준**: NULL 잔존 시 해당 행의 user와 daemon 매핑이 확인되지 않은 것이므로 팀장/사장님에게 보고 후 처리 방침 확인. 임의로 daemon을 지정하지 않는다. --- ## 5단계: Step2 alembic upgrade (PK 전환) — 사장님 결재 필수 > **CRITICAL**: 이 단계는 비가역 PK 변경이다. **사장님 결재 후에만 실행한다.** 결재 없이 실행 금지. Step2 이전 최종 체크리스트: - [ ] DB 백업 완료 (1단계) - [ ] Step1 적용·검증 완료 (3단계) - [ ] NULL 0건 확인 완료 (4단계) - [ ] 사장님 결재 획득 ```bash alembic upgrade 20260628_0027 ``` 예상 출력: ``` INFO [alembic.runtime.migration] Running upgrade 20260628_0026 -> 20260628_0027, broker_credentials: PK (user_id,broker) → (daemon_id,broker) 전환 (REQ-047 Step2) ``` ### Step2 검증 — 완료 확인 (필수) ```sql -- PK가 (daemon_id, broker)로 변경됐는지 확인 SELECT pg_get_constraintdef(oid) FROM pg_constraint WHERE conname = 'broker_credentials_pkey'; -- 기대: PRIMARY KEY (daemon_id, broker) -- daemon_id NOT NULL 확인 SELECT column_name, is_nullable FROM information_schema.columns WHERE table_name = 'broker_credentials' AND column_name = 'daemon_id'; -- 기대: daemon_id | NO (not nullable) -- user_id 컬럼 유지 확인 (감사/이력 목적, REQ-047 AC-4) SELECT column_name FROM information_schema.columns WHERE table_name = 'broker_credentials' AND column_name = 'user_id'; -- 기대: user_id 존재 -- 데이터 손실 없음 확인 SELECT COUNT(*) AS total_rows FROM broker_credentials; -- 기대: 2단계 total_rows와 동일 -- alembic head 갱신 확인 -- (alembic 명령으로 확인) ``` ```bash alembic current # 기대: 20260628_0027 (head) ``` ### Step2 롤백 절차 (조건부 가역 — 주의) Step2 downgrade는 같은 user의 여러 daemon이 동일 broker를 사용하는 경우 RuntimeError로 자동 거부된다. ```bash # Step2 → Step1 상태로 복원 시도 alembic downgrade 20260628_0026 ``` **경고**: 같은 user의 여러 daemon이 동일 broker를 사용하는 경우 downgrade가 다음과 같이 거부된다: ``` RuntimeError: downgrade 불가: N개 (user_id, broker) 조합이 중복됩니다. 같은 user의 여러 daemon이 동일 broker를 사용하고 있어 (user_id, broker) PK로 복원 시 데이터 손실이 발생합니다. 수동으로 데이터를 정리한 후 downgrade하세요. ``` 이 경우 백업에서 복원해야 한다(아래 DB 복원 참조). ### DB 복원 (최후 수단) ```bash pg_restore -h -U -d -c \ backup_pre_bat021_.dump ``` > **비가역 지점 강조**: Step2 PK 전환 완료 후에는 구 스키마(`PK=(user_id, broker)`)로의 롤백이 데이터 정리 없이 불가능할 수 있다. **백업 없이 Step2를 실행하면 복원이 불가능하다.** Step2 완료 후 추가 백업도 권장한다. --- ## 6단계: 서비스 재시작 (Step2 완료 이후) Step2 alembic upgrade 완료 후 서비스를 재시작한다. **코드·마이그레이션 파일은 0단계에서 이미 디스크에 올라 있다.** 재시작은 메모리의 구 프로세스를 내리고 디스크의 신규 코드(`9d98d47`)를 활성화하는 것이 목적이다. 신규 코드에 포함된 변경(재시작 후 활성화됨): - `app/repositories/postgres.py`: - `ensure_schema()` broker_credentials: daemon_id NOT NULL, PK=(daemon_id, broker) 정합 - `_row_to_broker_credential()`: Step2 컬럼 순서 (daemon_id 선두) - `upsert_broker_credential()`: ON CONFLICT (daemon_id, broker), fallback 제거 - `list_broker_credentials()`: fallback 제거, Step2 컬럼 순서 ```bash # portfolio-hub 서비스 재시작 (origin/main 9d98d47 — 코드는 0단계에서 이미 디스크에 있음) # 재시작 방법은 운영 절차에 따름 (예: 컨테이너 재시작, systemd restart 등) ``` --- ## 7단계: 배포 후 동작 확인 ```bash # 헬스체크 curl -s http:///health | python3 -m json.tool # 기대: {"status": "ok", "db_enabled": true, ...} # pfhub CLI로 인증정보 조회 테스트 (Step2 이후 스키마 정합) pfhub credential list --user --daemon-id # 기대: 인증정보 1건, daemon_id가 설정된 상태 ``` --- ## go/no-go 게이트 | 게이트 | 조건 | 판단 | | --- | --- | --- | | 체크아웃 갱신 | `20260628_0026`·`0027` 파일 존재, `pfhub credential backfill-daemon-id --help` 정상 | go / **NO-GO → 0단계 재수행** | | DB 백업 | 파일 생성 + 크기 > 0 + exit 0 | go / **NO-GO → 중단** | | alembic current | `20260605_0025 (head)` (Step1 이전 기준) | go / **NO-GO → 팀장 보고** | | 활성 락 없음 | broker_credentials 대상 활성 쿼리 없음 | go / **NO-GO → 대기 또는 재조정** | | Step1 검증 | daemon_id TEXT NULL, PK=(user_id,broker) 유지 | go / **NO-GO → Step1 downgrade 후 팀장 보고** | | NULL 0건 | `SELECT COUNT(*) ... WHERE daemon_id IS NULL` = 0 | go / **NO-GO → backfill 재수행 또는 팀장 보고** | | 사장님 결재 | 명시적 결재 확인 | go / **NO-GO → Step2 실행 금지** | | Step2 검증 | PK=(daemon_id,broker), NOT NULL, user_id 존재, 행 수 일치 | go / **NO-GO → 롤백 또는 DB 복원** | | 재시작 후 헬스 | GET /health → 200, db_enabled=true | go / **NO-GO → 구 커밋으로 체크아웃 후 재시작, 팀장 보고** | ### 중단 기준 요약 이 중 하나라도 발생하면 즉시 해당 단계를 중단하고 팀장에게 보고한다: 1. 체크아웃 갱신 실패 (마이그레이션 파일 부재, CLI 오류) 2. DB 백업 실패 3. alembic current가 기대값과 다름 4. broker_credentials 활성 락 존재 (ops 윈도우 재조정) 5. Step1 upgrade 실패 6. NULL 잔존 (backfill 미완료) 7. 사장님 결재 미획득 상태에서 Step2 실행 시도 8. Step2 upgrade 실패 9. Step2 검증 쿼리 기대값 불일치 10. 재시작 후 헬스체크 실패 --- ## 주의사항 요약 - prod는 REL-002(`bd34450`) 상태로 시작한다. **0단계 체크아웃 갱신(origin/main `9d98d47`)을 먼저 수행하지 않으면 마이그레이션 파일·backfill CLI가 디스크에 없어 이후 단계를 실행할 수 없다.** - Step2 이전에 서비스를 재시작하면 `ON CONFLICT (daemon_id, broker)` 절이 구 스키마와 맞지 않아 인증정보 upsert 오류 발생. - 전체 순서: **0단계(체크아웃 갱신, 재시작 X) → DB 백업 → Step1 → backfill → NULL 0건 확인 → Step2(결재) → 서비스 재시작** 순서를 엄수한다. - `ensure_schema()`는 `IF NOT EXISTS`를 사용하므로 Step2 적용 후 실행 시 테이블이 이미 존재해 스킵됨 (신규 인스턴스 초기화 시에만 적용됨). - `CREDENTIAL_ENCRYPTION_KEY`가 운영 환경에 설정돼 있지 않으면 인증정보 암·복호화가 동작하지 않는다. 재시작 전 반드시 확인.