다형성 연관

개요: 항상 다형성 연관 대신 별도의 테이블을 사용하세요.

Rails를 사용하면 “다형성 연관”이라고 불리는 것을 정의하는 것이 가능해집니다. 이는 보통 테이블에 두 개의 열을 추가하는 방식으로 작동합니다: 대상 유형 열과 대상 ID 열입니다. 예를 들어, 현재 우리는 members에 대해 다음과 같은 열을 가진 이러한 설정을 가지고 있습니다.

  • source_type: 사용할 모델을 정의하는 문자열로, Project 또는 Namespace가 될 수 있습니다.
  • source_id: source_type을 기반으로 검색할 행의 ID입니다. 예를 들어, source_typeProject인 경우 source_id에는 프로젝트 ID가 포함됩니다.

이러한 설정은 유용해 보일 수 있지만, 많은 단점이 함께 오기 때문에 가능하면 이를 피해야 합니다.

공간 낭비

이 설정은 모델을 결정하기 위해 문자열 값을 사용하므로 많은 공간을 낭비합니다. 예를 들어, PostgreSQL을 사용할 때 ProjectNamespace에 대한 최대 크기는 9바이트이며, 각 문자열에 대해 추가 1바이트가 필요합니다. 이는 각 행 당 10바이트에 불과할지라도, 이러한 설정을 사용하는 테이블과 행이 충분하다면 많은 양의 디스크 공간과 메모리(인덱스에 대해)을 낭비할 수 있습니다.

인덱스

우리의 연관이 두 개의 열로 분리되기 때문에, 이는 쿼리를 효율적으로 수행하기 위해 복합 인덱스가 필요할 수 있습니다. 복합 인덱스는 전혀 잘못된 것은 아니지만, 이러한 인덱스의 열 순서는 최적의 성능을 보장하기 위해 중요할 수 있습니다.

일관성

다형성 연관의 정말 큰 문제는 외래 키를 사용하여 데이터 일관성을 데이터베이스 수준에서 강제할 수 없다는 것입니다. 데이터 일관성을 데이터베이스 수준에서 강제하려면 다형성 연관을 지원하기 위해 자체 외래 키 로직을 작성해야 합니다.

데이터베이스 수준에서의 일관성 강제는 건강한 환경을 유지하는 데 절대적으로 중요하며, 이는 다형성 연관을 피해야 하는 또 다른 이유입니다.

쿼리 오버헤드

다형성 연관을 사용할 때는 항상 두 열을 사용하여 필터링해야 합니다. 예를 들어, 다음과 같은 쿼리를 작성해야 할 수 있습니다.

SELECT *
FROM members
WHERE source_type = 'Project'
AND source_id = 13083;

여기서 PostgreSQL은 두 열이 모두 인덱싱되어 있다면 쿼리를 상당히 효율적으로 수행할 수 있습니다. 그러나 쿼리가 더 복잡해지면 이러한 인덱스를 효과적으로 사용할 수 없을 수 있습니다.

혼합 책임

함수와 클래스와 마찬가지로 테이블은 단일 책임을 가져야 합니다: 지정된 열 집합으로 데이터를 저장하는 것입니다. 다형성 연관을 사용할 때는 동일한 테이블에 다른 유형의 데이터(가능한 경우 다른 열 집합)를 저장합니다.

해결책

다행히도 이러한 문제에 대한 해결책이 있습니다: 동일한 테이블에 저장할 것들에 대해 별도의 테이블을 사용하세요. 별도의 테이블을 사용하면 데이터베이스가 일관성을 보장하고 데이터를 효율적으로 쿼리할 수 있는 모든 것을 활용할 수 있으며, 추가 응용 프로그램 로직이 필요하지 않습니다.

예를 들어, 승인된 멤버와 보류 중인 멤버, 프로젝트 및 그룹 모두를 저장하는members테이블이 있다고 가정해 봅시다. 보류 중인 상태는 requested_at열이 설정되어 있는지 여부에 따라 결정됩니다. 스키마적으로 이러한 설정은 특정 행에만 특정 열이 설정되는 경우가 있어 공간을 낭비할 수 있습니다. 또한, 특정 인덱스가 특정 행에만 설정될 수도 있어 공간을 낭비합니다. 마지막으로, 이러한 테이블을 쿼리하는 것은 이상적이지 않습니다. 예를 들어:

SELECT *
FROM members
WHERE requested_at IS NULL
AND source_type = 'GroupMember'
AND source_id = 4

대신 이러한 테이블을 별도로 분리해야 합니다. 예를 들어, 이 경우 4개의 테이블이 생길 수 있습니다:

  • project_members
  • group_members
  • pending_project_members
  • pending_group_members

이렇게 하면 데이터 조회가 간단해집니다. 예를 들어, 그룹의 멤버를 얻으려면 다음을 실행합니다:

SELECT *
FROM group_members
WHERE group_id = 4

그룹의 보류 중인 멤버를 얻으려면 다음을 실행합니다:

SELECT *
FROM pending_group_members
WHERE group_id = 4

두 결과를 얻고 싶다면 UNION을 사용할 수 있지만, 결과 세트가 첫 번째 쿼리의 열을 사용하기 때문에 SELECT해야 하는 열을 명시적으로 지정해야 합니다. 예를 들어:

SELECT id, 'Group' AS target_type, group_id AS target_id
FROM group_members

UNION ALL

SELECT id, 'Project' AS target_type, project_id AS target_id
FROM project_members

위의 예는 아마도 약간 어리석은 것일 수 있지만, 데이터를 병합해서 동일한 페이지에 표시하는 데 제한이 없음을 보여줍니다. 열을 명시적으로 선택하는 것은 데이터를 얻기 위해 데이터베이스가 수행해야 하는 작업을 줄일 수 있으므로 쿼리 속도를 높일 수도 있습니다 (사용하지 않는 열을 선택하는 대신 모든 열을 선택하는 것보다).

우리의 스키마도 더욱 간단해집니다. 이제 source_type열을 저장하고 인덱싱할 필요가 없으며, 외래 키를 쉽게 정의할 수 있으며, IS NULL 조건을 사용하여 행을 필터링할 필요가 없습니다.

요약하면: 별도의 테이블을 사용하면 외래 키를 효과적으로 활용할 수 있으며 필요한 곳에만 인덱스를 정의하고, 공간을 절약하고, 데이터를 효율적으로 쿼리하며, 이러한 테이블을 쉽게 확장할 수 있습니다(예를 들어, 별도의 디스크에 저장함으로써). 이로 인해 코드도 더 간단해질 수 있으며, 단일 모델이 다양한 유형의 데이터를 처리하는 데 책임을 지지 않아도 되는 좋은 부수 효과가 있습니다.