- 발행일
pk를 박아둔 e2e 스펙 11개가 한꺼번에 빨개졌다 — 재적재에도 안 밀리는 것으로 갈아타기
pk를 박아둔 e2e 스펙 11개가 한꺼번에 빨개졌다
용어(메타클래스·클래스 속성)는 엑셀 원본에서 적재합니다. load.sh를 돌리면 테이블을 비우고 다시 넣어요. 그러면 pk가 밀립니다.
실제로 dcat:Catalog가 35 → 5로 옮겨갔고, 그 번호를 URL에 박아둔 Playwright 스펙 11개가 한꺼번에 빨개졌습니다.
await page.goto("/std-data/meta-class/35/"); // ← dcat:Catalog 라고 믿고 있던 자리
한꺼번에 빨개지니 처음엔 "무언가 크게 깨졌다"고 봤습니다. 스펙 하나씩 열어보고 나서야 깨진 건 앱이 아니라 스펙이 잡고 있던 손잡이였다는 게 보였어요.
무엇이 밀리고 무엇이 안 밀리는가
여기서 전부 URI 조회로 바꾸는 건 과했습니다. 밀리는 건 엑셀에서 다시 적재하는 용어 쪽뿐이고, 레거시 표준데이터(Dataset·Catalog·Scheme·Concept 인스턴스)는 시드로 고정이라 안 밀려요.
| 방법 | 장점 | 포기하는 것 | 판단 |
|---|---|---|---|
| ① 전부 pk 유지 + 밀릴 때마다 손으로 갱신 | 지금 코드 그대로 | 재적재가 월 단위로 있음. 매번 11개를 손으로 고침. 무엇이 정답 pk인지도 매번 조회해야 함 | 기각 |
| ② e2e 전용 고정 시드 DB 분리 | pk가 절대 안 밀림 | 시드와 dev DB가 갈라져 "dev에서는 되는데 e2e에서만" / 그 반대가 생김. 지금 이 e2e는 dev DB를 그대로 보는 게 값어치의 절반임 | 기각 |
| ③ 전부 URI 조회로 전환 | 밀림에 완전 면역 | 스펙마다 REST 왕복 2회씩 추가. 안 밀리는 것까지 조회해 느려지고, "왜 조회하지?"를 읽는 사람이 다시 확인해야 함 | 기각 |
| ④ 밀리는 것만 URI 조회, 안 밀리는 것은 pk 유지 | 필요한 곳만 비용 지불. 코드가 "이건 밀린다"를 표현함 | 어느 쪽인지 판단이 필요함 — 새 스펙 쓰는 사람이 잘못 고를 수 있음 | 채택 |
④로 가면서, 판단 근거를 헬퍼 맨 위에 적었습니다. 이게 없으면 다음 사람은 "어떤 건 pk, 어떤 건 URI"인 게 그냥 일관성 없음으로 보입니다.
// 메타클래스·클래스 속성의 pk 는 `load.sh` 재적재 때마다 밀린다(실제로 dcat:Catalog 가
// 35 → 5 로 옮겨가며 스펙 여러 개가 한꺼번에 빨개진 이력). 재적재에도 불변인 URI 로
// 공개 REST 목록에서 실행 시점에 찾아 쓴다.
//
// 레거시 표준데이터(Dataset·Catalog·Scheme·Concept)의 pk 는 시드 고정이라 여기 없다 —
// 밀리는 것은 엑셀에서 다시 적재하는 용어 쪽뿐이다.
import { expect } from "@playwright/test";
export async function classIdByUri(request, uri) {
const res = await request.get("/std-data/rest-api/meta-class/");
const found = (await res.json()).find((item) => item.data.uri === uri);
expect(found, `${uri} 클래스가 목록에 없음`).toBeTruthy();
return found.id;
}
export async function propertyIdByUri(request, uri) {
const res = await request.get("/std-data/rest-api/meta-class-property/");
const found = (await res.json()).data.find((item) => item.uri === uri);
expect(found, `${uri} 속성이 목록에 없음`).toBeTruthy();
return found.id;
}
expect(found, "...")로 메시지를 붙인 건, 못 찾았을 때 undefined.id로 죽으면 "왜 없는지"가 스택트레이스에 안 남기 때문입니다. 조회 실패와 시나리오 실패를 구별할 수 있어야 했어요.
적재량에 매달린 단언 세 곳
pk만 문제가 아니었습니다. 데이터가 몇 건이냐에 매달린 단언도 재적재마다 흔들렸어요.
| 단언 | 문제 | 바꾼 것 |
|---|---|---|
관계도 노드 수 === 42 | 클래스가 늘면 깨짐. 정작 검증하려던 건 "관계도가 그려지는가" | 전체 클래스 수 상한으로 |
통합검색 키워드 member | 그 단어가 든 데이터가 재적재로 사라짐 | 더 안정적인 data로 |
| 분류 칩 → 렌더된 카드 개수 세기 | 페이지네이션·min-height 변경에 같이 깨짐 | 서버가 내려주는 #list-total-count |
세 개 다 같은 성격입니다. 화면에 우연히 그렇게 보이는 것을 단언하고 있었어요. 지금 값이 맞다는 걸 확인하는 순간에는 그게 제일 정확해 보이는데, 그게 함정이었습니다.
정렬 토글 스펙에서는 반대 문제가 나왔습니다. dcat:Catalog의 필수 속성이 seq 맨 앞에 하나뿐이라, 정렬을 하든 안 하든 결과가 같았습니다. 초록인데 아무것도 검증하지 않는 스펙이었어요. 스펙이 실행 중에 dct:modified를 필수로 올렸다가 원복하도록 고쳤습니다.
원복이 같이 죽어 dev DB가 어긋나 있었다
그 "올렸다 원복" 코드에 진짜 문제가 있었습니다.
// ⚠️ page 가 아니라 request 픽스처를 받는다 — 시나리오가 타임아웃으로 죽어 페이지가
// 닫혀도 finally 의 원복이 살아남아야 dev DB 가 더럽혀지지 않는다(실제로 page.request 로
// 쓰다가 원복이 함께 죽어 DataFrame 필수 등급이 며칠간 어긋나 있던 이력).
export async function setRequiredProperties(request, classId, propertyIds) {
const { cookies } = await request.storageState();
const csrf = cookies.find((c) => c.name === "csrftoken");
expect(csrf, "csrftoken 쿠키가 있어야 PATCH 가 통과한다").toBeTruthy();
const response = await request.patch(
`/std-data/rest-api/meta-class/${classId}/`,
{
headers: { "X-CSRFToken": csrf.value },
data: { required_properties: propertyIds },
},
);
expect(response.ok(), `구성 속성 등급 변경 실패 (${response.status()})`).toBeTruthy();
}
원래는 page.request를 썼습니다. try/finally로 원복도 감싸뒀고요. 그런데 시나리오가 타임아웃으로 죽으면 페이지가 닫히고, page.request도 같이 죽습니다. finally는 도는데 그 안의 요청이 나가질 못해요.
그래서 DataFrame의 필수 등급이 며칠간 어긋난 채로 있었습니다. dev에서 이상한 화면을 보고 "데이터가 왜 이러지" 했던 게 이거였어요. 테스트가 자기가 바꾼 걸 못 되돌리고 있었던 겁니다.
request 픽스처는 페이지와 수명이 분리돼 있어 살아남습니다. try/finally를 쓴다고 정리가 보장되는 게 아니라, finally 안에서 쓰는 도구가 그때까지 살아 있어야 한다는 걸 이 일로 배웠습니다.
죽은 스펙 두 개는 지웠다
같은 정리에서 스펙 2건(edit-panel-enter-save, request-dataset-flow)을 고치지 않고 삭제했습니다.
두 화면(편집 패널 Enter 저장, 표준데이터 신청 흐름)은 살아 있습니다. 스펙만 오래 빨간 채였고, 고치려니 화면 구조가 그새 바뀌어 사실상 새로 쓰는 작업이었어요.
삭제하면서 두 가지를 같이 했습니다.
- 문서에 삭제 표시 — 두 스펙을 "검증 근거"로 인용하던 서술이 세 군데 있었습니다. 그대로 두면 근거 없는 주장이 남습니다.
guide-capture주석의 참조 제거.
빨간 스펙을 지우는 건 커버리지를 줄이는 일이라 마음이 편치 않은데, 오래 빨간 스펙은 이미 커버리지가 아닙니다. 아무도 안 보고, 다른 스펙이 빨개져도 묻힙니다. "커버리지 빈 상태"라고 명시적으로 적어두는 편이 정직했어요.
같은 맥락으로, PNG 다운로드 버튼을 뺀 커밋을 못 따라간 테스트도 정리했습니다. 기대 목록에서 빼는 김에 "PNG URL이 없다"를 단언으로 못박았습니다. 라우트와 다운로드 뷰는 살아 있어서, 나중에 누가 버튼을 되살려도 이 단언이 먼저 걸립니다.
QA 범위를 문장이 아니라 diff에서 뽑기
이 정리를 하면서 규칙 하나를 문서로 올렸습니다. 배경은 이렇습니다.
- "표준데이터 검색 화면만 봐줘"라고 했는데 전체 스윕을 돌려 시간을 버리는 경우
- 반대로 정말 그 화면만 보고, 바뀐 매크로를 include하는 다른 화면 4개를 놓치는 경우
둘 다 있었습니다. 그래서 범위 = 사용자가 말한 것 + 바뀐 코드가 닿는 것으로 규칙을 적었어요.
**⚠️ Scope = named scope + everything that consumes the changed code.**
A named scope ("○○ 화면 QA해줘") rules out the *unrelated* sweep — it does not rule out
the screens your change actually reaches. Derive the test set from the diff, not from
the sentence:
1. Start from what the user named.
2. `grep` the callers/importers/includers of every symbol and template you changed
(manager method, serializer, macro, JS module, URL name).
3. Run that set. State the widening and its evidence *before* running —
"`ConceptManager.get_list_queryset()` 은 카탈로그·검색 2개 화면도 쓰므로 같이 돌림".
핵심은 3번입니다. 넓힌 이유를 grep 근거와 함께 돌리기 전에 말하게 했어요. 그래야 "왜 이것까지 돌리냐"에 답이 되고, 반대로 근거를 못 대면 그건 안 돌려도 되는 겁니다.
필터 없는 전체 실행은 "전체"·"다"·"모두"라고 말했을 때로 한정했습니다. 범위가 애매하면 되묻고요.
검증
npx playwright test63건 전량 통과. 그 전에는 11건이 pk 때문에, 3건이 적재량 때문에 빨간 상태였습니다.- ESLint 신규 0건.
- dev DB 원복 확인 —
DataFrame필수 60·93,Catalog필수 93, range 조인 0. 테스트가 남긴 게 없는지 숫자로 봤습니다. 이걸 안 봤으면 위의page.request문제를 또 놓쳤을 거예요. - 신규 회귀 잠금 8건(신규 스펙 3 + 기존 파일 추가 5) — 공개 속성 상세 클래스 참조 배지·값 앞 순서, 용어 관리 클래스 값 영역 참조 왕복(조인이 시드에 없어 직접 생성 후 원복), 인스턴스 관리 네임스페이스 토글, 분류 칩 0건 회색·목록 min-height.
남은 것 · 한계
- URI 조회는 REST 왕복을 추가합니다. 스펙마다 목록 전체를 받아
find로 훑어요. 지금 규모(클래스 수백)에서는 체감이 없지만, 정석은?uri=로 서버가 찾게 하는 겁니다. 공개 API에 필터를 추가하는 일이라 미뤘습니다. - 어느 쪽이 밀리는지는 여전히 사람이 판단합니다. 헬퍼 주석에 적어뒀을 뿐, 새 스펙에서 용어 pk를 박아도 아무도 안 막아요. 린트로 잡을 수 있는 형태(URL 안의 숫자 리터럴 금지)가 있는지 안 찾아봤습니다.
- 삭제한 스펙 2건은 커버리지 구멍인 채입니다. 화면은 살아 있는데 흐름 검증이 없어요. 다시 쓸 계획을 잡아두지 않았습니다.
- e2e가 dev DB를 그대로 봅니다. 격리를 포기하고 얻은 게 "실제 데이터로 검증"인데, 그 대가가 이번 원복 사고였습니다. 이 트레이드오프는 아직 유효하다고 보지만, 원복이 필요한 스펙이 늘면 다시 계산해야 합니다.
관련 글: Playwright로 메뉴 136종 QA 스윕 · 엑셀 2단 병합 헤더 워크북 리더와 4패스 로더