Published on

한 컬럼에 두 의미가 섞여 있었다 — 속성 range를 FK 둘로 쪼갠 회고

Authors
  • avatar
    Name
    Hyo814
    Twitter

한 컬럼에 두 의미가 섞여 있었다 — 속성 range를 FK 둘로 쪼갠 회고

표준 메타데이터에서 속성(Property)의 range는 "이 속성의 값이 무엇인가"를 정의합니다. 그런데 이 값은 두 종류예요.

  • xsd:string, xsd:date 같은 데이터 유형
  • Message, DataFrame 같은 클래스 참조 (값이 다른 리소스인 경우)

RDF에서는 앞이 데이터 속성, 뒤가 객체 속성입니다. 애초에 성격이 다른데, 우리 모델에서는 한 CharField에 문자열로 같이 들어가 있었습니다.

# 변경 전
range = models.CharField(max_length=1024, null=True, blank=True)
# "xsd:string" 도 여기, "Message" 도 여기

이걸 FK 둘로 쪼개고 상호배타 제약을 건 작업입니다. 모델 하나 고치는 일처럼 보였는데, 로더·RDF 방출·화면 입력까지 줄줄이 딸려왔어요.


1. 문자열 range가 만든 문제

RDF 출력이 틀렸습니다. rdfs:range는 IRI를 가리켜야 하는데, 문자열 컬럼을 그대로 내보내니 리터럴로 방출되고 있었어요. xsd:string이라는 문자열이 나가는 것과 http://www.w3.org/2001/XMLSchema#string이라는 IRI가 나가는 건 다른 얘기입니다. 표준 파일을 산출물로 내놓는 시스템에서 이건 그냥 버그예요.

화면이 판단할 수 없었습니다. 속성 상세에서 range를 보여줄 때, 이게 클래스 참조면 그 클래스로 링크를 걸어야 하고 데이터 유형이면 그냥 텍스트여야 합니다. 문자열만 봐서는 구분이 안 되니 이름으로 추측하는 수밖에 없었어요.

참조 무결성이 없었습니다. Message라고 적힌 클래스가 이름이 바뀌거나 삭제돼도 문자열은 그대로 남습니다. 깨진 참조를 알아낼 방법이 RDF를 내보내 보는 것뿐이었어요.

그래프를 못 그렸습니다. 속성의 range를 따라 클래스 → 속성 → 다른 클래스 → ... 로 재귀 확장하는 게 관계도의 핵심인데, 문자열이면 매 홉마다 이름으로 다시 조회해야 합니다.

정리하면 한 컬럼이 두 개의 의미를 담고 있었고, 그래서 어느 쪽으로도 제대로 다룰 수 없었습니다.


2. 세 가지 안과 고른 이유

방법장점포기하는 것판단
① 문자열 유지 + 해석 규칙 함수스키마 무변경. xsd: 접두어면 데이터 유형으로 판정접두어 규칙에 의존. 새 데이터타입 어휘가 들어오면 규칙을 계속 늘려야 하고, 참조 무결성은 여전히 없음
② 단일 FK + 타입 구분 컬럼 (Generic FK)컬럼 하나로 양쪽 표현가리키는 대상이 서로 다른 테이블이라 content_type + object_id 구조가 됨. 조인이 안 되고 ORM 편의를 대부분 잃음
③ FK 두 개(range / metakeytype) + 상호배타 제약각 FK가 제 테이블을 정확히 가리킴. 조인·무결성·역참조 전부 정상둘 중 하나만 채워야 한다는 제약을 직접 걸어야 함✓ 채택

②를 진지하게 봤습니다. "range는 하나인데 컬럼이 둘이면 이상하지 않나" 싶었거든요. 접은 이유는 Generic FK의 대가가 이 상황에 비해 너무 컸기 때문입니다. 가리킬 테이블이 딱 둘로 고정돼 있고 앞으로 늘어날 일도 없는데, 임의 모델을 가리키는 범용 장치를 들일 이유가 없었어요. 조인이 안 되면 관계도 재귀 확장이 다시 어려워지고요.

③은 컬럼이 둘이라는 게 눈에 거슬리지만, 각 FK가 자기 테이블만 가리킨다는 명확함을 얻습니다. select_related("range")가 그냥 동작하고, 클래스 쪽에서 역참조도 됩니다.

# 변경 후
range = models.ForeignKey(          # 객체 속성 — 클래스 참조
    "MetaClass", null=True, blank=True,
    on_delete=models.SET_NULL, related_name="+", verbose_name="범위",
)
metakeytype = models.ForeignKey(    # 데이터타입 속성
    "MetaKeyType", null=True, blank=True, ...
)

on_delete=SET_NULL을 고른 것도 판단입니다. CASCADE면 클래스를 지울 때 그 클래스를 range로 쓰던 속성 용어까지 사라집니다. 속성은 표준 용어라 클래스보다 오래 살아야 해요. (실제로 데이터 유형 쪽에서 이걸 한 번 데였습니다 — 데이터 유형을 지웠더니 그걸 참조하던 속성 용어가 같이 지워졌거든요.)


3. 상호배타 제약 — 그리고 "둘 다 비어 있음"을 허용한 이유

FK 둘 중 하나만 채워져야 합니다. clean()에 가드를 넣었어요.

def clean(self):
    super().clean()
    # 객체 속성(range=클래스 참조)과 데이터타입 속성(metakeytype=원시 타입)은 상호배타.
    # 둘 다 비어 있는 것(타입 미선언)은 허용 — 원본에 range 없던 주석·라벨 성격 속성.
    if self.range_id and self.metakeytype_id:
        raise ValidationError({
            "metakeytype": "range(객체 속성)와 metakeytype(데이터타입 속성)는 "
                           "동시에 설정할 수 없습니다."
        })

여기서 제일 오래 고민한 건 "둘 다 NULL"을 막을 것인가였습니다. 엄밀히 하면 모든 속성은 range가 있어야 맞아 보이거든요. 그런데 실제 표준 원본을 열어보니, range가 아예 없는 속성이 상당수 있었습니다. 주석이나 라벨 성격의 속성들이에요.

여기서 선택지가 갈렸습니다.

  • 엄격하게 막고 임의의 기본값을 채워 넣기 — 예를 들어 전부 xsd:string으로. 데이터는 깔끔해지지만 원본에 없는 정보를 우리가 만들어내는 게 됩니다.
  • 미선언을 허용하고 그대로 두기 — 데이터에 NULL이 남지만 원본과 일치합니다.

후자로 갔습니다. 이 시스템은 표준을 담는 시스템이지 표준을 정하는 시스템이 아니에요. 원본에 없는 걸 채워 넣기 시작하면, 나중에 우리가 만든 값과 표준이 정한 값을 구분할 수 없게 됩니다. 주석에 그 이유를 남겨둔 것도 다음 사람이 "이거 NULL인데 버그 아닌가" 하고 채우지 않게 하기 위해서였어요.


4. 데이터 마이그레이션 없이 재적재로 정정하기

기존 데이터를 새 구조로 옮겨야 했습니다. 보통은 RunPython으로 데이터 마이그레이션을 쓰는데, 이번엔 다르게 갔어요.

# 0016_metaclassproperty_range_class.py
# NOTE: MetaClassProperty.range 를 문자열 CharField 에서 MetaClass FK(컬럼 range_id)로
# 교체한다. DB 를 새로 마이그레이션하는 전제라 기존 데이터 이관(populate)은 하지 않고
# 필드를 제거 후 재생성한다. (파일명은 히스토리상 유지)

operations = [
    migrations.RemoveField(model_name="metaclassproperty", name="range"),
    migrations.AddField(model_name="metaclassproperty", name="range", field=models.ForeignKey(...)),
]

필드를 지우고 다시 만듭니다. 데이터를 옮기지 않아요. 대신 로더에 해석 규칙을 넣고 엑셀 원본에서 다시 적재했습니다.

def resolve_range(self, row):
    """range 토큰을 데이터타입 참조와 클래스 참조 중 하나로만 해석한다.

    MetaClassProperty는 range(FK)와 metakeytype(FK)이 상호배타라 둘을 동시에
    채우지 않는다.
    """
    token = row["range"]
    ...
    metakeytype = MetaKeyTypeManager.get_object_by_name(token)   # ① 데이터타입 우선
    if metakeytype:
        return None, metakeytype

    range_class = MetaClassManager.get_object_by_name(token)      # ② 클래스 해석
    if range_class is None:
        logger.warning("unresolved range=%r for %s:%s", token, row["namespace"], row["name"])
        self.stats["unresolved_range"] += 1                       # ③ 미해석은 세어둔다
    return range_class, None

이 방식이 가능했던 건 엑셀 워크북이 정본이기 때문입니다. DB는 그 정본의 적재 결과일 뿐이라, 해석 규칙이 바뀌면 다시 적재하는 게 데이터를 손으로 옮기는 것보다 정확해요. 데이터 마이그레이션 스크립트는 "지금 DB에 있는 값"을 기준으로 추측해야 하는데, 재적재는 원본을 다시 읽습니다.

효과가 숫자로 나왔습니다.

상태재적재 전재적재 후
range·metakeytype 둘 다 NULL581496
잘못 reify된 Literal11349
metakeytype 연결0149

과거에 rdfs:Literal을 클래스처럼 만들어버린(reify) 오적재가 113건 있었는데, 로더 라우팅만 고치니 절반 이하로 줄었습니다. 정정 스크립트를 한 줄도 안 썼어요.

미해석으로 남은 것들은 unresolved_range로 세어서 로그에 남겼습니다. 조용히 NULL로 밀어 넣으면 "원래 없는 것"과 "해석 실패한 것"이 구분이 안 되니까요.

RDF 방출도 같이 고쳤습니다. 데이터타입 속성의 rdfs:range가 이제 정규 XSD IRI로 나갑니다.


5. 화면 — 값 유형 토글

입력 UI도 바꿔야 했습니다. 문자열 한 칸이던 게 "무엇을 고를 것인가"부터 정해야 하는 구조가 됐으니까요.

방법문제
입력 두 칸을 나란히 노출둘 다 채우는 실수가 구조적으로 가능. XOR을 사용자가 지켜야 함
값 유형 토글 + 활성 쪽만 렌더동시 전송이 구조적으로 불가능

후자로 갔습니다. 데이터 유형/클래스 참조 토글을 두고, 활성화된 쪽 입력만 렌더합니다. 비활성 필드는 저장 시 빈 값을 명시적으로 보내고요.

여기에 솔직히 적어둘 게 있습니다. 이 화면의 저장 경로가 full_clean()을 호출하지 않습니다. 그래서 3절의 clean() XOR 가드가 이 경로에서는 안 걸리고, 프런트가 사실상 유일한 가드인 상태예요. 활성 쪽만 렌더하는 구조를 고른 것도 그래서였습니다. 사용자가 실수할 수 있는 UI를 만들어놓고 서버가 안 막으면, 그건 시간문제니까요.

이건 임시방편이고, 아래 7절에 후속 과제로 남겨뒀습니다.


6. 검증

항목결과
재적재both-NULL 581→496, reify 113→49, metakeytype 0→149
미해석 추적unresolved_range 카운트 + 경고 로그로 남김
XOR 가드둘 다 채운 상태로 full_clean() → ValidationError
RDF 방출데이터타입 속성의 rdfs:range가 정규 XSD IRI로 출력
관계도range FK를 따라 클래스 재귀 확장 동작
화면값 유형 토글 전환 시 반대 값 초기화, 읽기 모드 배지(클래스 참조/데이터 유형/미지정) 표시 일치
회귀 테스트test_load_property·test_rdf_factory 갱신 후 통과

7. 남은 것

  • XOR이 DB 제약이 아닙니다. clean()full_clean()을 타는 경로에서만 동작하고, 벌크 적재나 시리얼라이저 저장에서는 안 걸려요. models.CheckConstraint로 DB 레벨에 거는 게 맞습니다. 정의 필드 길이 문제로 이미 한 번 배운 교훈인데(로더는 폼 검증을 안 탄다), 같은 자리에 또 서 있습니다.
  • metakeytype을 네임스페이스 스코프 어휘로 다루기 시작했지만 아직 정리가 덜 됐습니다. name이 전역 유일이 아니라 네임스페이스 안에서만 유일해서, 이름으로 조회하는 코드가 남아 있으면 언젠가 충돌합니다.
  • 미해석 range 496건은 여전히 NULL입니다. 원본에 없는 것과 해석 실패가 섞여 있어서, 원본 대조로 한 번 걸러내야 합니다.

8. 회고

  • 한 컬럼에 두 의미가 들어가 있으면, 그 컬럼을 쓰는 모든 코드가 추측을 합니다. RDF 방출, 화면 렌더, 그래프 확장이 각자 다른 방식으로 "이게 클래스인가 타입인가"를 판정하고 있었어요. 쪼개고 나니 그 판정 코드가 전부 사라졌습니다.
  • Generic FK는 "가리킬 대상이 열려 있을 때" 쓰는 도구입니다. 둘로 고정돼 있으면 컬럼 두 개가 더 정직해요. 컬럼이 늘어나는 게 항상 나쁜 신호는 아니었습니다.
  • 원본이 따로 있는 데이터는, 옮기지 말고 다시 읽는 게 정확합니다. 데이터 마이그레이션 스크립트는 현재 DB 값을 추측하지만 재적재는 원본을 봅니다. 로더 라우팅 한 군데 고쳐서 오적재 113건이 49건이 된 게 그 증거예요.
  • "둘 다 비어 있음"을 허용할지가 제일 오래 걸린 판단이었습니다. 데이터를 깔끔하게 만들려고 없는 값을 채우면, 표준을 담는 시스템이 표준을 만드는 시스템이 됩니다. 주석에 이유를 남긴 게 그 판단의 실체예요.
  • 앱 레벨 제약은 우회 경로가 있으면 제약이 아닙니다. 지금 XOR은 프런트가 지키고 있는데, 그건 지켜지고 있다는 뜻이 아니라 아직 안 깨졌다는 뜻입니다.

모델을 쪼개는 작업이었지만, 실제로 배운 건 "의미가 다르면 칸을 나눠라" 하나였습니다. 그리고 그 칸이 지켜지게 하려면 제약을 어디에 걸어야 하는지도요 — 후자는 아직 숙제로 남아 있습니다.