다형적 연관

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

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 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 조건으로 행을 필터링할 필요가 없어집니다.

요약하면, 별도의 테이블을 사용하면 외래 키를 효과적으로 사용할 수 있게 되며, 필요한 곳에만 인덱스를 생성할 수 있고, 공간을 절약하고, 데이터를 효율적으로 쿼리하며, 이러한 테이블을 쉽게 확장할 수 있게 됩니다(예: 별도의 디스크에 저장함으로써). 이로 인해 코드도 단순해지게 되며, 단일 모델이 다른 유형의 데이터를 처리하는 것과 같은 일을 담당하지 않게 됩니다.