다형성 연관

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

Rails는 이른바 “다형성 연관”을 정의할 수 있게 해줍니다. 이는 일반적으로 두 개의 열을 테이블에 추가하는 방식으로 작동합니다: 대상 유형 열 및 대상 ID. 예를 들어, 이 문서를 작성할 당시 우리는 다음 열이 있는 members에 대해 이러한 설정을 가지고 있습니다:

  • source_type: 사용할 모델을 정의하는 문자열로, Project 또는 Namespace일 수 있습니다.

  • source_id: source_type을 기반으로 검색할 행의 ID입니다. 예를 들어, source_typeProject일 때 source_id에는 프로젝트 ID가 포함됩니다.

이러한 설정은 유용해 보일 수 있지만, 여러 단점이 있어 이를 피해야 합니다.

공간 낭비

이 설정은 사용할 모델을 결정하기 위해 문자열 값에 의존하므로 많은 공간을 낭비합니다. 예를 들어, ProjectNamespace의 경우 최대 크기는 9바이트이며, PostgreSQL을 사용할 때 문자열마다 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 조건을 사용하여 행을 필터링할 필요가 없습니다.

요약하자면: 별도의 테이블을 사용하면 외래 키를 효율적으로 사용할 수 있고, 필요할 때만 인덱스를 생성하며, 공간을 절약하고 데이터를 더 효율적으로 쿼리할 수 있으며, 이러한 테이블을 더 쉽게 확장할 수 있습니다(예: 서로 다른 디스크에 저장하기). 이의 부수적인 효과로, 코드는 또한 더 쉬워질 수 있으며, 단일 모델이 서로 다른 유형의 데이터를 처리하는 책임을 지지 않게 됩니다.