Published on

표준데이터를 고정 컬럼에서 인스턴스 기반 모델로 갈아끼운 회고

Authors
  • avatar
    Name
    Hyo814
    Twitter

표준데이터를 고정 컬럼에서 인스턴스 기반 모델로 갈아끼운 회고

표준데이터 관리 시스템의 중심 모델을 갈아끼웠습니다. 화면 몇 개 고치는 작업이 아니라, 데이터를 담는 방식 자체를 바꾸는 일이었어요. 팀 두 명이 나눠 진행했고, 저는 공개 화면·관리 화면 전환과 URL 규칙, 데이터 맵 정합을 맡았습니다.

결론부터 적으면 이렇습니다.

[변경 전]  Dataset (고정 컬럼 20여 개)
[변경 후]  MetaClass ─┬─ MetaClassInstance ─┬─ MetaClassInstanceMetadata (리터럴 값)
           (스키마)          (인스턴스)      └─ MetaClassInstanceLink     (객체 참조)
                       └─ MetaClassMetadata (클래스별 속성 구성)

이 글은 왜 이렇게 갔는지, 무엇을 포기했는지에 대한 기록입니다.


1. 문제 — 표준이 개정되면 스키마가 따라 바뀐다

기존 표준데이터는 Dataset 모델의 고정 컬럼에 담겼습니다.

class Dataset(models.Model):
    name = models.CharField(max_length=128)
    title = models.CharField(max_length=256)
    url = models.CharField(max_length=256, null=True, blank=True)
    notes = models.CharField(max_length=256, null=True, blank=True)
    author = models.CharField(max_length=128, null=True, blank=True)
    author_email = models.EmailField(...)
    maintainer = models.CharField(max_length=128, null=True, blank=True)
    maintainer_email = models.EmailField(...)
    type = models.CharField(max_length=32, choices=TYPE_DATASET, default="DAT")
    state = models.CharField(max_length=32, choices=TYPE_STATES, default="ACT")
    ...

평범한 Django 모델이고, 그 자체로 나쁘지 않습니다. 문제는 이 시스템이 다루는 게 "데이터"가 아니라 "표준 메타데이터" 라는 데 있었어요.

  • 표준 메타데이터의 항목 구성은 표준 문서가 정합니다. 우리가 정하는 게 아니에요.
  • 표준은 개정됩니다. 항목이 늘고, 이름이 바뀌고, 필수 여부가 바뀝니다.
  • 게다가 클래스마다 항목 구성이 다릅니다. 메시지, 데이터프레임, 데이터엘리먼트가 각각 다른 속성 집합을 가져요.

고정 컬럼 구조에서는 이게 전부 스키마 변경입니다. 속성 하나 추가에 마이그레이션 한 장, 폼 수정, 템플릿 수정, 시리얼라이저 수정이 따라붙어요. 실제로 클래스별로 안 맞는 항목이 생기면서 notes 같은 범용 컬럼에 이것저것 밀어넣는 상황이 생기고 있었습니다.

한 줄로 줄이면 도메인이 "런타임에 정의되는 스키마"를 요구하는데, 우리는 컴파일 타임 스키마로 대응하고 있었어요.


2. 세 가지 안과 고른 이유

방법장점포기하는 것판단
① 고정 컬럼 유지 + 개정 때마다 마이그레이션쿼리 단순, 타입 안전, ORM 그대로표준 개정마다 배포가 필요. 클래스별 상이한 구성을 표현할 방법이 없음
JSONField 하나에 속성 전부 담기스키마 자유, 마이그레이션 불필요속성이 1급 시민이 아님. "이 속성을 쓰는 클래스 목록", "이 값으로 검색" 같은 질의가 전부 JSON 순회. RDF 변환도 매핑 규칙을 따로 들고 있어야 함
③ 클래스·속성·인스턴스·값을 각각 테이블로 (EAV 계열)표준 개정이 데이터 변경이 됨. 속성 자체를 질의·연결 가능쿼리 복잡도, 타입 안전성 상실, 조인 증가✓ 채택

②를 오래 붙들었습니다. 구현이 제일 빠르고, Postgres JSONB면 인덱스도 걸 수 있으니까요. 접은 결정적인 이유는 속성 자체가 조회 대상이라는 점이었습니다.

이 시스템은 "표준데이터를 보여주는" 시스템이면서 동시에 "표준 용어 사전을 보여주는" 시스템이에요. 화면에 이런 게 있습니다. 클래스 목록, 클래스가 가진 구성 속성, 그 속성의 정의와 국문 레이블, 그 속성을 쓰는 다른 클래스, 속성 간 상하위 관계. JSONField로 가면 속성은 그냥 키 문자열이 되고, 이 화면들을 만들 방법이 없어집니다.

"값을 어떻게 저장할까"가 아니라 "속성을 1급 개체로 다룰 수 있나"가 기준이었어요. 그 기준에서 ②는 탈락이었습니다.

③의 대가는 알고 갔습니다. EAV는 악명이 있고, 그 악명은 대체로 정당해요. 다만 이 도메인은 EAV가 잘 맞는 드문 경우였습니다. 표준 메타데이터 자체가 이미 "주어-술어-목적어" 구조(RDF) 로 정의돼 있고, 시스템의 최종 산출물 중 하나가 RDF 파일이거든요. 저장 구조를 도메인 구조에 맞춘 셈입니다.


3. 설계 — 값과 참조를 다른 테이블로 나눈 이유

인스턴스가 가지는 값은 두 종류입니다.

  • "KR-2026-001", "2026-07-27" 같은 리터럴 값
  • "이 메시지가 참조하는 데이터프레임" 같은 다른 인스턴스 참조

한 테이블에 value 컬럼 하나로 담고 참조는 id를 문자열로 넣을 수도 있었습니다. 그렇게 안 했어요.

class MetaClassInstanceMetadata(models.Model):     # 리터럴 값
    metaclass_instance = models.ForeignKey(MetaClassInstance, related_name="metadata_values", ...)
    metaclass_metadata = models.ForeignKey(MetaClassMetadata, related_name="instance_values", ...)
    value = models.CharField(max_length=512, null=True, blank=True)


class MetaClassInstanceLink(models.Model):         # 객체 참조
    """객체 속성(range=클래스) 슬롯의 값 = 다른 인스턴스 참조.

    리터럴 값(MetaClassInstanceMetadata.value)과 달리 인스턴스↔인스턴스를 FK로 잇는다.
    슬롯당 target 다수 허용(다중값)이라 RDF 트리플(subject 술어 object)과 정합하고,
    Message→dataFrame→DataFrame…처럼 재귀 그래프를 구성한다(간접 순환은 표시 단계에서 가드).
    """
    source_instance = models.ForeignKey(MetaClassInstance, related_name="object_links", ...)
    metaclass_metadata = models.ForeignKey(MetaClassMetadata, related_name="instance_object_links", ...)
    target_instance = models.ForeignKey(MetaClassInstance, related_name="referenced_by", ...)
    seq = models.PositiveIntegerField(default=0, verbose_name="표시 순서")

나눈 이유가 세 개 있습니다.

첫째, 참조 무결성. 문자열에 담긴 id는 대상이 삭제돼도 남습니다. FK면 DB가 막아줘요. 참조가 깨진 표준데이터는 RDF로 내보내는 순간 드러나는데, 그때 발견하면 늦습니다.

둘째, 그래프 탐색. 이 참조들이 그대로 관계도 화면이 됩니다. Message → dataFrame → DataFrame → dataElement → ... 로 재귀적으로 따라가야 하는데, FK여야 select_related/prefetch_related로 감당이 돼요. 문자열 id면 매 홉마다 수동 조회입니다.

셋째, RDF와 1:1. 리터럴 값은 RDF에서 데이터 속성이고, 인스턴스 참조는 객체 속성입니다. 애초에 다른 종류예요. 저장 구조에서 이미 갈라놓으면 변환 코드가 분기 없이 단순해집니다.

seq 필드는 다중값 슬롯의 표시 순서용입니다. 슬롯 하나에 여러 target이 붙을 수 있어야 RDF 트리플과 맞는데, 그러면 순서가 사라져요. 화면에서 순서가 매번 바뀌면 사용자는 데이터가 바뀐 줄 압니다.

생애 이력 필드에서 배운 것

인스턴스에 created/modified/issued를 넣으면서, 처음엔 셋 다 auto_now_add/auto_now로 두려고 했습니다. 그러다 멈췄어요.

created = models.DateTimeField(auto_now_add=True, verbose_name="등록 일시")
# modified 는 auto_now 를 쓰지 않는다 — 최초 생성 시엔 비워 두고(수정 이력 없음),
# 수정 저장에서만 채운다(dcterms:modified 의미: 변경이 일어난 시점).
modified = models.DateTimeField(null=True, blank=True, verbose_name="수정 일시")
# issued(dcterms:issued, 발행 시점)는 auto 로 두지 않는다 — 발행이라는 도메인 이벤트라
# 채우는 주체가 정한다.
issued = models.DateTimeField(null=True, blank=True, verbose_name="발행 일시")

이 필드들은 RDF의 dcterms:created / dcterms:modified / dcterms:issued로 나갑니다. 표준이 정의한 의미가 이미 있어요. dcterms:modified는 "마지막 저장 시각"이 아니라 "변경이 일어난 시점"이고, 생성만 된 리소스는 modified가 없는 게 맞습니다. auto_now를 걸면 생성 직후에도 modified가 채워져서, 우리 편의를 위해 표준 의미를 왜곡하게 돼요.

issued는 더 명확합니다. 발행은 도메인 이벤트지 저장 부작용이 아닙니다. 지금은 심사 없는 즉시 등록이라 폼이 생성 시점에 같이 채우지만, 신청→심사→발행 워크플로가 붙으면 그 전이에서 채우게 됩니다. 그때까지는 NULL(미발행)이고요.

프레임워크가 주는 편의 기능이 도메인 의미와 어긋날 때가 있다는 걸, 이 세 줄에서 제일 선명하게 배웠습니다.


4. 전환 전략 — 두 모델을 한동안 같이 살려두기

설계보다 어려웠던 건 이미 돌아가는 시스템을 어떻게 옮기느냐였습니다. 표준데이터·카탈로그·분류체계 화면이 이미 서비스 중이었거든요.

전략문제
빅뱅 전환 (한 번에 갈아끼우기)전환 중 화면이 반쪽. 되돌릴 방법이 사실상 없음
신규만 새 모델, 기존은 레거시 유지같은 목록에 두 종류가 섞임. 화면·다운로드·RDF가 전부 분기
미러링 적재 + 병행 운영데이터가 두 벌. 동기화 필요

세 번째로 갔습니다. 레거시 카탈로그·분류체계를 인스턴스로 미러링해서 적재하고, 두 모델이 같은 데이터를 동시에 들고 있게 했어요.

load_catalog_instance    # 레거시 카탈로그 → 인스턴스로 미러링
load_scheme_instance     # 레거시 분류체계 → 인스턴스로 미러링
load_metaclass_instance  # 인스턴스 적재
dump_metaclass_instance  # 백업(되돌릴 수 있게)

데이터가 두 벌이 되는 건 명백한 비용입니다. 그래도 이걸 고른 이유는 언제든 되돌릴 수 있기 때문이었어요. 새 화면이 이상하면 링크만 레거시로 되돌리면 되고, 데이터는 그대로 남아 있습니다. 백업 커맨드를 같이 만든 것도 같은 맥락이고요.

URL 규칙 — 인스턴스는 instance/, 레거시는 원래 경로

병행 운영에서 제일 헷갈렸던 게 주소였습니다. 같은 "카탈로그 상세"가 두 개 존재하니까요.

규칙을 이렇게 못박았습니다.

  • 레거시는 원래 경로를 그대로 유지 — 기존 링크·북마크·외부 참조가 안 깨짐
  • 인스턴스 기반은 instance/ 아래로 — 새 경로

새 것에 좋은 자리를 주고 레거시를 밀어내는 방법도 있었는데, 반대로 갔습니다. 전환기에는 기존 주소가 살아 있는 게 훨씬 중요하다고 봤어요. 나중에 레거시를 걷어낼 때 instance/ 접두어를 떼면 되고, 그 시점엔 리다이렉트 한 겹만 남기면 됩니다.


5. 전환하면서 드러난 것들

새 모델로 화면을 옮기니, 레거시 시절에 가려져 있던 문제들이 같이 나왔습니다.

죽은 컬럼을 화면이 그대로 보여주고 있었다. 클래스 속성 목록 표가 레거시 FK(metaclass, metakeytype)를 참조하고 있었는데, 이 값이 966건 전량 null이었어요. 그래서 표에 no such element: None['name']이 그대로 렌더되고 있었습니다. 실제로 채워진 건 label_ko(964/966), definition_ko(962/966)였고요. 표를 용어명·국문 레이블·정의 3열로 재편하면서, 같은 원인의 화면 세 개를 한 번에 정리했습니다. 하나만 고치고 넘어갔으면 나머지 둘은 그대로 남았을 거예요.

정본과 파생 캐시가 어긋났다. 인스턴스가 정본이고 기존 DatasetMetadata가 파생 캐시인데, 링크 시점에 한 번 복사하는 구조라 인스턴스를 고쳐도 목록·다운로드는 옛 값을 보여줬습니다. 이건 분량이 커서 별도 글로 씁니다.

성능 최적화인 줄 알았던 코드가 장애였다. 카탈로그 매니저에 prefetch_related("metadata")가 들어 있었는데, Dataset에는 metadata라는 관계가 아예 없었습니다. QuerySet은 지연 평가라 선언은 통과하고 순회 시점에 AttributeError가 나서, 카탈로그 다운로드 4종이 전부 죽어 있었어요. 자세한 건 별도 글로 정리했습니다.


6. 검증

전환은 되돌리기 어려운 작업이라, 화면 단위로 렌더와 데이터를 같이 확인했습니다.

항목결과
공개 화면 (표준데이터·카탈로그·분류체계)인스턴스 기반으로 렌더 200, 목록·상세 항목 일치
관리 화면클래스 데이터 목록 정상, 편집·삭제 경로 유지
미러링 적재레거시 카탈로그·분류체계 → 인스턴스 적재 완주
URL 규칙레거시 경로 200 유지, 인스턴스는 instance/ 아래 200
관계도인스턴스 링크를 따라 재귀 탐색, 순환 가드 동작
데이터 맵공개 화면과 같은 실데이터·트리 규칙으로 일치
다운로드RDF 방출 시 리터럴/객체 속성 분기 정상
회귀관리 화면 테스트 기대값 정합 후 스위트 통과

7. 남은 빚

전환은 끝났지만 대가는 남아 있습니다. 숨기지 않고 적어둡니다.

  • valueCharField(512)입니다. 리터럴 값이 전부 문자열이라 타입 안전성이 없어요. 날짜·숫자 속성도 문자열로 들어갑니다. 값 유형(range) 정보는 속성 쪽에 있으니 검증은 가능한데, 지금은 clean() 레벨이라 벌크 적재 경로에서는 안 걸립니다. 길이 상한도 언젠가 걸릴 겁니다 — 같은 실수를 정의 필드에서 이미 한 번 했거든요.
  • 쿼리가 확실히 복잡해졌습니다. 인스턴스 하나의 값을 다 모으려면 두 테이블을 각각 조회해야 하고, 관계도는 재귀입니다. 지금 데이터 규모에서는 감당되지만, 매니저 레이어로 걷어내지 않으면 뷰마다 조회 코드가 복제될 거예요. 실제로 데이터 맵 설명 조회를 매니저로 옮기는 작업을 이미 한 번 했습니다.
  • 간접 순환은 표시 단계에서만 막고 있습니다. A → B → A 같은 참조를 모델이 막지 않아요. 지금은 그래프 렌더링에서 가드하는데, 저장 시점에 막는 게 맞습니다.
  • 데이터가 두 벌인 상태가 계속 유지되면 안 됩니다. 병행 운영은 전환기 전략이지 최종 상태가 아니에요. 레거시를 걷어내는 시점을 정해야 합니다.

8. 회고

  • 도메인이 런타임 스키마를 요구하면, 컴파일 타임 스키마로 버티는 데는 한계가 있습니다. 표준이 개정될 때마다 배포가 필요한 구조는 언젠가 병목이 돼요.
  • EAV는 기본적으로 나쁜 선택이지만, 도메인이 이미 트리플 구조면 얘기가 다릅니다. 저장 구조를 도메인 구조에 맞춘 거라, "우리가 복잡하게 만든 것"이 아니라 "원래 복잡한 걸 그대로 담은 것"이 됐어요.
  • 선택 기준을 "저장 방식"이 아니라 "무엇을 1급 개체로 다뤄야 하나"로 잡은 게 결정적이었습니다. 그 기준이 없었으면 JSONField로 갔을 거고, 반년쯤 뒤에 속성 관련 화면을 만들다가 막혔을 겁니다.
  • 프레임워크의 편의 기능이 도메인 의미를 왜곡할 수 있습니다. auto_now 한 줄이 dcterms:modified의 의미를 바꿉니다. 표준을 다루는 시스템에서는 이런 게 그냥 편의 문제가 아니에요.
  • 큰 전환일수록 되돌릴 수 있는 경로를 먼저 만들어두는 게 좋았습니다. 미러링 적재와 백업 커맨드, 그리고 레거시 URL 보존이 그 역할을 했어요. 덕분에 화면을 하나씩 옮기면서도 "잘못되면 되돌린다"는 여유가 있었습니다.

모델을 바꾸는 것보다, 두 모델을 한동안 동시에 살려두는 게 실제 일의 대부분이었습니다. 설계는 하루면 그리는데 전환은 몇 주가 걸리더라고요. 다음에 이런 작업을 또 만나면, 설계 시간보다 전환 시간을 넉넉히 잡는 것부터 시작할 생각입니다.