발행일

같은 표준데이터를 두 화면이 198건과 134건으로 세고 있었다 — 집계 모집단 일원화

같은 표준데이터를 두 화면이 198건과 134건으로 세고 있었다

홈 화면에는 "데이터 현황" 타일이 있습니다. 표준데이터가 몇 건인지 큰 숫자로 보여주고, 누르면 /std-data/dataset/ 목록 화면으로 갑니다.

운영에서 이렇게 보였습니다.

  • 타일: 198건
  • 눌러서 도착한 목록: 134건

64건이 증발합니다. 사용자 입장에서는 둘 중 하나가 거짓말인데, 어느 쪽이 거짓말인지 화면만 봐서는 알 수가 없어요.

닷새 전에 이미 이 결정을 했었다

더 곤란한 건, 이게 새로 생긴 문제가 아니라는 점이었습니다. 8월 1일에 같은 성격의 어긋남(66 대 69)을 한 번 잡았고, 그때 매니저 함수에 주석까지 박아뒀습니다.

@staticmethod
def get_std_data_queryset(public_only=True):
    """표준데이터로 세는 인스턴스 쿼리셋 — 홈 목록·건수의 공통 모집단.

    ⚠️ 컨테이너 클래스를 걷어내지 않는다. 홈 '데이터 현황' 타일이 목록 화면
    (/std-data/dataset/)으로 링크하는데, 여기서 카탈로그를 빼면 66 을 누르고 69 를
    보게 된다(2026-08-01 결정). 고르는 쪽(참조 표준데이터 선택기)만
    get_reference_queryset 으로 컨테이너를 계속 뺀다 — 세는 것과 고르는 것은 다르다.
    """

원칙은 맞습니다. "세는 것"과 "고르는 것"은 다른 모집단이다. 참조 대상으로 고를 수 있는 것(카탈로그는 그릇이라 제외)과 표준데이터로 셀 것은 같을 이유가 없죠.

문제는 그 다음이었습니다. 8월 3일에 목록 화면에 분류 칩(기초 자원·데이터 자원·시스템/서비스·부가 정보·미분류)을 붙이면서, 목록 뷰가 매니저에 분류 조건을 하나 더 넘기기 시작했습니다. 매니저가 주는 모집단은 그대로인데, 목록 뷰가 거기서 한 번 더 좁힌 겁니다.

주석은 "매니저를 같이 쓴다"까지만 지키고 있었고, "목록 뷰가 그 뒤에 무엇을 더 하는가"는 아무도 안 보고 있었어요.

어느 쪽에 맞출 것인가

여기서 방향을 정해야 했습니다. 8월 1일 결정을 그대로 유지하면 목록에서 분류 조건을 빼야 하고, 뒤집으면 타일이 좁아집니다.

방법장점포기하는 것판단
① 목록에서 분류 조건을 뺀다 (8/1 결정 유지)매니저 한 곳만 정본으로 남음. 코드 변경 최소분류 칩이 무의미해짐 — 칩 없는 분류(미분류·기초)의 데이터가 "전체"에는 있는데 어느 칩에도 안 잡히는 상태가 됨기각
② 타일을 목록 범위로 좁힌다 (역전)누른 수 = 도착해서 보는 수. 칩 합계 = 전체홈 숫자가 198 → 134로 줄어 "데이터가 줄었다"고 보임채택
③ 타일 링크를 ?filter= 없는 별도 화면으로양쪽 다 안 건드림화면이 하나 더 생김. 같은 목록을 두 벌 유지기각
④ 타일에 "전체 198건 중 분류된 134건" 병기정직함홈 타일에 도메인 설명을 얹게 됨. 사용자는 왜 두 숫자인지 모름기각

②를 골랐습니다. 근거는 숫자가 아니라 의미였어요.

목록이 미분류(0)·기초(1)를 빼는 데는 이유가 있습니다. 카테고리·카탈로그·기관은 표준데이터가 참조하는 어휘지 표준데이터 자체가 아닙니다. 그러니까 목록이 맞고, 타일이 틀렸던 겁니다. 8월 1일에는 "타일이 목록으로 링크하니까"라는 연결 관계만 보고 방향을 정했는데, 이번에는 "무엇을 표준데이터라고 부르는가"라는 정의를 보고 정했습니다.

한 번 뒤집은 결정이라 주석에 날짜와 실측값을 같이 남겼습니다.

@staticmethod
def _public_instance_queryset(public_only=True):
    """공개 인스턴스 원본 — 아래 두 모집단이 갈라져 나가는 지점."""
    ...

@staticmethod
def get_std_data_queryset(public_only=True):
    """표준데이터로 세는 인스턴스 쿼리셋 — 홈 목록·건수의 공통 모집단.

    ⚠️ 목록 화면(StdDataInstanceListView)과 **같은 분류 범위**여야 한다. 홈 '데이터
    현황' 타일이 /std-data/dataset/ 으로 링크하므로, 범위가 어긋나면 198 을 누르고
    134 를 보게 된다(2026-08-06 실측). 목록이 칩 없는 분류(0 미분류·1 기초)를 빼는
    이유가 여기에도 그대로 적용된다 — 카테고리·카탈로그·기관은 표준데이터가 참조하는
    어휘지 표준데이터가 아니다.
    """
    return MetaClassInstanceManager._public_instance_queryset(public_only).filter(
        metaclass__class_category__in=CLASS_CATEGORY_FILTERABLE
    )

그리고 참조 선택기는 갈라져 나가는 지점을 바꿨습니다. 전에는 get_reference_queryset()get_std_data_queryset() 위에 얹혀 있었는데, 그러면 이번 분류 조건이 선택기에도 딸려 갑니다. 원본(_public_instance_queryset)에서 직접 갈라지게 했어요.

@staticmethod
def get_reference_queryset(public_only=True):
    """'참조 표준데이터' 선택 후보 — 그릇이 아니라 발행 단위만 고를 수 있어야 한다.

    집계(get_std_data_queryset)의 분류 조건을 타지 않고 원본에서 갈라지는 것은
    의도된 차이다 — 세는 것과 고르는 것은 다르고, 분류가 아직 안 붙은 클래스도
    참조 대상으로는 고를 수 있어야 한다.
    """
    return MetaClassInstanceManager.filter_std_data(
        MetaClassInstanceManager._public_instance_queryset(public_only)
    )

함수를 상속처럼 겹쳐 쌓으면, 위쪽에 조건을 하나 넣을 때 아래쪽 호출처가 전부 딸려 옵니다. 갈라질 지점을 명시적으로 하나 만들어두는 편이 나중에 조건을 붙일 때 안전했습니다.

진짜 문제는 테스트였다

여기까지는 15줄짜리 수정입니다. 정작 오래 본 건 "이 정합을 지킨다"고 이름 붙어 있던 테스트 2건이 왜 초록이었나였어요.

테스트 A — 기대값을 상수로 박아둠

def test_index_count_matches_public_list_screen(self):
    response = self.client.get(reverse("common:index"))

    # 타일이 /std-data/dataset/ 로 링크하므로 컨테이너(카탈로그)까지 같이 센다
    self.assertEqual(response.context["active_dataset_count"], 2)

이름은 matches_public_list_screen인데, 목록 화면을 조회하지 않습니다. 타일 숫자가 2인지만 봅니다. 목록 뷰가 조건을 하나 더 붙여 1건이 되어도 이 테스트는 계속 통과해요. 이름이 주장하는 것과 코드가 검사하는 것이 달랐습니다.

실제로 두 화면을 부딪히게 고쳤습니다.

def test_index_count_matches_public_list_screen(self):
    """타일이 /std-data/dataset/ 로 링크하므로, 누른 수와 도착해서 보는 수가 같아야 한다.

    기대값을 박아두지 않고 목록 화면을 실제로 조회해 비교한다 — 상수로 두면 목록 뷰가
    모집단을 좁혀도 이 테스트는 통과한다(2026-08-06 198 대 134 회귀가 그렇게 샜다).
    """
    index = self.client.get(reverse("common:index"))
    listing = self.client.get(reverse("std-data:std_data_list"))

    self.assertEqual(listing.status_code, 200)
    self.assertEqual(
        index.context["active_dataset_count"],
        listing.context["class_category_total"],
    )

테스트 B — 매니저끼리만 비교함

다른 한 건은 MetaClassInstanceManager 단위에서 두 함수의 결과를 비교하고 있었습니다. 매니저 두 개는 애초에 같았습니다. 어긋난 건 목록 뷰가 매니저 결과에 인자를 하나 더 얹는 지점이었고, 매니저만 보는 테스트는 그 자리를 볼 수 없었어요.

여기서 얻은 규칙:

두 화면의 숫자가 같아야 한다면, 테스트도 두 화면을 조회해서 비교해야 한다. 공통 함수를 두 번 부르는 건 "같은 함수를 쓴다"는 것만 증명하지, "화면에 같은 수가 뜬다"는 것은 증명하지 않는다.

픽스처도 손봐야 했습니다. 기존 픽스처는 class_category를 아예 안 넣고 클래스를 만들고 있었는데(전부 0 = 미분류), 그러면 분류 조건이 들어간 순간 전부 빠져 테스트가 무의미해집니다. 운영 실측을 따라 분류를 명시했습니다.

# 카탈로그는 '기초' — 목록 화면에 칩이 없어 홈 모집단에서도 빠진다.
catalog_class = MetaClass.objects.create(
    name="Catalog",
    namespace=cls.dcat,
    public=True,
    class_category=CLASS_CATEGORY_FOUNDATIONAL,
)

덤 — 카드 목록 N+1

같은 목록 화면에서 카드 렌더링이 느려 쿼리를 세어봤더니, 카드 한 장마다 1~2개씩 질의가 붙고 있었습니다. metadata_values는 프리페치하는데, 값을 읽는 쪽이 값마다 속성 이름을 본다는 걸 프리페치가 모르고 있었어요.

.prefetch_related(
    Prefetch(
        "metadata_values",
-       queryset=MetaClassInstanceMetadata.objects.order_by(
-           "metaclass_metadata_id"
-       ),
+       # 슬롯 값을 읽는 쪽(`_slot_value`)이 값마다 속성 이름을 보므로 슬롯·속성을
+       # 함께 당긴다 — 안 당기면 카드 한 장에 값 개수만큼 질의가 붙는다.
+       queryset=MetaClassInstanceMetadata.objects.select_related(
+           "metaclass_metadata__metaclassproperty"
+       ).order_by("metaclass_metadata_id"),
        to_attr="all_values",
    ),

prefetch_related 안의 querysetselect_related를 얹는 형태입니다. 프리페치는 "이 관계를 미리 당긴다"까지만 하고, 그렇게 당긴 행이 다시 다른 테이블을 보는 것은 막아주지 않아요. N+1 정리 글에서 다뤘던 것과 같은 계열인데, 한 겹 안쪽이라 눈에 덜 띄었습니다.

표준데이터 목록 39 → 19 쿼리 (dev 실측).

검증

  • common·std_data 정합 테스트 통과. 상수 단언을 제거한 뒤 목록 뷰의 분류 인자를 일부러 되돌려 레드가 뜨는 것을 확인하고 되돌렸습니다(테스트가 실제로 회귀를 잡는지 본 것).
  • 홈 타일 = 목록 class_category_total 일치, 운영 기준 134건.
  • 홈 "자주 찾는"·"최근 등록" 목록도 같은 함수를 쓰므로 함께 좁아짐 — 의도된 파급이고, 픽스처에서 카탈로그 인스턴스가 목록에서 빠지는 것으로 못박았습니다.
  • 참조 표준데이터 선택기는 분류 조건 미적용 유지 확인.
  • 표준데이터 목록 쿼리 39 → 19.

남은 것

  • 홈 숫자가 갑자기 줄어 보이는 문제는 안 풀었습니다. 사용자에게는 "데이터가 64건 사라진 것"으로 보일 수 있는데, 릴리스 노트로만 알렸어요. 타일에 기준을 병기하는 안(④)은 홈에 도메인 설명을 얹게 되어 미뤘습니다.
  • 분류가 안 붙은 클래스(미분류 0)는 여전히 있습니다. 목록의 "미분류" 칩으로는 볼 수 있지만 홈 집계에는 안 잡혀요. 근본은 적재 시점에 분류를 강제하는 것인데, 엑셀 원본에 분류 열이 없어 지금은 못 합니다.
  • class_category_total을 뷰 컨텍스트로 노출해 테스트가 읽게 한 건 편법에 가깝습니다. 화면이 안 쓰는 값을 테스트 때문에 컨텍스트에 두고 있어요. 목록 총계 API가 생기면 그쪽으로 옮길 자리입니다.

관련 글: 표준데이터를 고정 컬럼에서 인스턴스 기반 모델로 · 없는 관계를 prefetch하고 있었다