- 발행일
dev의 OID 번호를 운영과 같게 심었다 — 순번을 다시 매기는 것과 원본을 옮기는 것은 다르다
dev의 OID 번호를 운영과 같게 심었다
9월 3일에 dev 데이터베이스를 운영 전량으로 덮었습니다. 운영을 정본으로 잡고 인스턴스 7,083건을 그대로 맞춘 거예요. 표준데이터 검색 목록이 1,485건(메시지 103·데이터 프레임 694·데이터 엘리먼트 688)으로 이름·설명까지 운영과 같아졌습니다.
그러자 OID 검색 화면이 뼈대 9건으로 무너졌습니다. 1,513건이 9건이 됐어요. 뿌리에서 버킷까지 가지 9개만 남고 잎이 하나도 없는 트리.
미러가 만들지 않는 것
원인은 미러 명령의 범위였습니다. mirror_remote_instances는 인스턴스의 kadif:objectIdentifier 슬롯에 든 문자열만 운영 값 그대로 복사하고, ObjectIdentifier 노드는 만들지 않아요. 인스턴스마다 "내 OID는 2.16.410.…이다"라는 글자는 있는데, 트리에 그 자리가 없는 상태입니다.
채울 명령은 이미 있었습니다. assign_instance_oids — 인스턴스를 돌면서 OID가 없는 것에 새로 부여하는 백필이에요. 돌리면 잎이 생깁니다. 그런데 번호가 다릅니다.
| 방법 | 잎이 생기나 | 번호 | 판단 |
|---|---|---|---|
① assign_instance_oids 백필 | 생김 | 형제 최대값+1로 새로 매김. 스탬프(슬롯 문자열)를 안 읽음 | 기각 — 1,107건 불일치 |
② rebuild_instance_oids 전면 재발급 | 생김 | 마찬가지로 새 순번 | 기각 |
| ③ 운영 REST 트리를 받아 (버킷, 이름) → 번호로 심기 | 생김 | 운영 번호 그대로 | 채택 |
①을 먼저 돌려 봤습니다. 잎 1,489건 중 1,107건이 운영과 다른 번호를 받았어요. 순번을 새로 매기니 당연한 결과인데, 정렬 차이까지 겹쳤습니다. dev는 SQLite라 바이트 정렬이고 운영은 PostgreSQL이라 대소문자를 무시해요. DE_AbnormalLux가 운영에서는 1번인데 dev에서는 다른 자리에 섭니다.
번호가 다르면 QA가 의미가 없습니다. 운영에서 "OID 1.2.3.45를 열면 이 인스턴스"인 것이 dev에서는 다른 인스턴스를 가리키니, 주소를 박아 둔 스펙도 화면 캡처도 운영과 안 맞아요.
운영 트리를 받아 그 자리에 심는다
mirror_remote_oids를 따로 만들었습니다. 모듈 docstring에 "왜 따로 있나"를 먼저 적었어요.
"""운영 OID 번호를 dev 에 그대로 심는다 — DataDictionary 세 버킷 아래 잎 전량.
왜 따로 있나 — `mirror_remote_instances` 는 인스턴스의 `kadif:objectIdentifier` 문자열만 운영
값 그대로 복사하고 `ObjectIdentifier` 노드는 만들지 않는다. 그 상태에서 `assign_instance_oids`
로 채우면 스탬프를 읽지 않고 형제 최대값+1 로 **번호를 새로 매긴다**(SQLite 바이트 정렬이라
운영 PostgreSQL 의 대소문자 무시 정렬과도 어긋나 2026-09-03 실측 1,107건 불일치). 운영과 같은
번호가 필요하면 운영 트리의 (버킷, 이름) → number 를 그 자리에 세워야 한다.
운영의 OID REST 트리를 받아서 (버킷, 이름) → 번호 표를 만들고, dev 인스턴스를 같은 열쇠로 찾아 그 번호로 노드를 세웁니다. 규칙 넷.
버킷은 이름이 아니라 arc로 맞춥니다. 운영 노드 이름 표기가 개발과 달랐던 적이 있어요 — 운영이 Dataelement였던 시절이 있었습니다. arc 3이면 같은 버킷이에요. 테스트 이름이 그대로 test_bucket_is_matched_by_arc_not_name입니다.
같은 이름이 여럿이면 dev pk 오름차순 ↔ 운영 번호 오름차순으로 짝짓습니다. dev는 운영 pk를 보존하지 않아 이름 말고는 열쇠가 없고, 운영 자체가 쌍둥이 84종을 그렇게 매겨 뒀어요.
운영에 없는 dev 시드는 그 버킷의 운영 최대 번호 뒤에 붙입니다. 4건이었습니다.
세 버킷 아래는 전부 걷어내고 다시 세웁니다. 운영 번호와 dev 번호가 한 버킷에 섞이면 (부모, 번호)에 DB 제약이 없어 같은 번호가 두 노드에 붙을 수 있어요.
걷어내는 자리에 treebeard 함정이 하나 있었습니다.
ObjectIdentifier.objects.filter(pk__in=[leaf.pk for leaf in leaves]).delete()
# treebeard 가 부모의 numchild 를 되돌려 놓지 않으면 다음 add_child 가 첫 자식
# path 를 잘못 계산한다. 자식을 통째로 지운 뒤라 0 이 정답이다.
ObjectIdentifier.objects.filter(pk=bucket.pk).update(numchild=0)
그리고 심는 자리에도.
def _plant(self, bucket_pk, instance, number, user):
# ⚠️ 삽입 직전에 부모를 다시 읽는다 — node_order_by=["name"] 정렬 삽입이 형제 path 를
# 다시 쓰므로 낡은 객체로 add_child 하면 path 가 충돌한다.
parent = ObjectIdentifier.objects.get(pk=bucket_pk)
정렬 삽입 트리라 자식을 하나 넣을 때마다 형제의 path가 다시 쓰입니다. 부모 객체를 한 번 읽어 두고 반복문에서 계속 쓰면 두 번째부터 path가 충돌해요. 연결과 슬롯 스탬프는 add_instance가 함께 합니다 — 화면에서 저장할 때 타는 sync_instance_oid와 같은 경로예요.
--dry-run은 예외로 빠져나가는 방식입니다. DryRunFinished를 던져야 transaction.atomic()이 되돌립니다. 세 버킷을 통째로 걷어내는 명령이라 먼저 보고 돌리게 했어요.
재현 순서에 한 단계를 더했다
빈 DB에서 dev를 다시 채우는 순서가 문서에 있었는데, 이번에 4단계가 됐습니다.
./scripts/load.sh dev [d]
./scripts/mapping.sh dev # 하위 클래스 117건 — 운영 링크 검증에 필요
python manage.py mirror_remote_terms --base-url http://<운영>
python manage.py mirror_remote_instances --base-url http://<운영>
python manage.py mirror_remote_oids --base-url http://<운영> # OID 잎 — 아래 참조
저장소 안내 문서의 OID 절에도 한 줄을 넣었습니다. 백필·재발급 둘 다 새 순번을 매기니 운영을 미러한 dev에서는 쓰지 말고 이 명령을 쓰라고요. 문서만 고치면 다음 사람이 assign_instance_oids를 돌리는 걸 못 막습니다만, 적어도 "왜 안 되는지"는 남겼습니다.
e2e 쪽에는 가드를 넣었습니다. 잎이 없는 dev에서는 OID 목록 스펙이 12 ≠ 3으로 죽었어요 — 잎 목록 자리에 형제 버킷 3개가 그려지거든요. 12를 3으로 낮추면 계약이 사라지니, 잎이 없으면 이유를 밝히고 건너뛰게 했습니다.
async function skipWithoutLeaves(page) {
const heading = await page.locator("#oidNodeListPanel").innerText();
test.skip(
heading.includes("DataDictionary"),
"부여 OID 미적재(운영 잎 미복원) — 잎 목록 자리에 형제 버킷이 뜬다",
);
}
같은 배치의 다리 하나는 하루 뒤 걷었다
같은 날 카탈로그 적재기도 손봤습니다. 카탈로그 탭이 0건이라 축이 죽어 있었는데, 카탈로그 ─dct:conformsTo→ 표준 ←kadif:definedBy─ 메시지로 표준을 다리 삼아 메시지 링크를 심었어요. dev에 12건. 멱등이라 재적재해도 중복이 없고, 값 영역 위반은 full_clean()이 잡아 조용히 심지 않습니다.
하루 뒤 9월 4일에 그 단계와 테스트, dev 링크 12건을 전부 지웠습니다.
> 🔄 **2026-09-04 되돌렸다.** 운영은 카탈로그에 담긴 표준데이터가 0건이고(국-1 은 준수 표준
> `KS R 1600-2` 와 `dcat:Dataset` 7건뿐, 메시지 링크 없음) dev 도 그 상태를 그대로 보여야
> 한다는 결정.
이 글의 주제와 같은 이유입니다. dev는 운영을 흉내내야 QA가 의미 있다. OID 번호를 운영과 같게 맞춘 것과, 운영에 없는 링크를 dev에 만들지 않기로 한 것은 같은 원칙이에요. 다만 전날의 나는 "축이 죽어 있으니 살리자"에 있었고 다음 날의 결정은 "운영이 0건이면 dev도 0건"이었습니다. 카탈로그 탭 첫 화면은 첫 뿌리가 걸린 채 0건이고, 그 탭의 카드 스펙은 0건이면 skip입니다.
검증
mirror_remote_oids실측 — 잎 1,489 = 운영 일치 1,485 + dev 시드 4, 불일치 0. 돌리기 전assign_instance_oids판은 1,107건 불일치.- 단위 테스트 3건: 순번 번호를 운영 번호로 갈아치우는 것, 버킷을 이름이 아니라 arc로 맞추는 것,
--dry-run이 아무것도 안 쓰는 것. - OID e2e 두 스펙에
skipWithoutLeaves가드. 잎을 심은 뒤로는 통과 경로를 dev에서 볼 수 있는데, 이 커밋 시점에는 아직 안 돌렸습니다. - 카탈로그 적재기 다리는 테스트와 함께 하루 뒤 삭제.
std-data-axis-tabs스펙은 0건이면 skip.
남은 것 · 한계
mirror_remote_instances와mirror_remote_terms는 원본.py가 유실돼 바이트코드만 남아 있습니다. 커밋 이력이 없어요. 지금은 그 위에_fetch만 덮은 껍데기로 돌리는데, 껍데기 없이 원본을 그대로 돌리면 표시용 라벨이 이름이 돼 3,061건이 새 인스턴스로 생깁니다. 이번 OID 명령은.py로 남겼지만, 앞 두 단계가 이 상태인 채로 4단계 순서를 문서화한 셈이에요.- 동명 인스턴스 짝짓기가 "pk 순 ↔ 번호 순"이라는 가정 위에 있습니다. 운영이 그렇게 매겼다는 걸 확인했지만, 운영에서 쌍둥이 하나를 지우고 다시 만들면 이 짝이 어긋나요.
- (부모, 번호) 유일 제약이 DB에 없습니다. 명령이 버킷을 통째로 걷어내는 이유가 그건데, 제약을 거는 쪽이 근본 수리예요. 안 했습니다.
- 운영 전량으로 덮으면서 되돌린 것이 있습니다. 운영이 dev보다 뒤진 자리 — 한국어로 다듬어 둔 메시지 설명 20여 건이 영어 원문으로,
ISO 14817-1:2015가ISO 14817-1로. 이건 이 명령의 문제가 아니라 "운영이 정본"이라는 결정의 비용이고, 문서에 표로 남겼습니다. - 카탈로그 다리를 걷은 뒤 카탈로그 축은 다시 미결입니다. 관리 화면에서 소속이 걸리기 시작하면 skip이 저절로 풀리는 구조라, 누가 언제 거는지는 정해지지 않았어요.
관련 글: pk를 박아둔 e2e 스펙 11개가 한꺼번에 빨개졌다 · Django management command로 시드 데이터 다루기 · OID 트리를 낮에 고정하고 밤에 되돌렸다