레이블로 필터링
GitLab v19.4요약
GitLab에는 이슈, 머지 리퀘스트, 에픽에 지정할 수 있는 레이블이 있습니다. 이 객체들을 여러 레이블로 필터링하려면(예: '~Plan 레이블과 ~backend 레이블이 모두 붙은 열린 이슈 전체') GROUP BY 절이 포함된 쿼리를 생성합니다.
개요#
GitLab에는 이슈, 머지 리퀘스트, 에픽에 지정할 수 있는 레이블이
있습니다. 이러한 객체의 레이블은 다형성 label_links 테이블을 통한
다대다 관계입니다.
이 객체들을 여러 레이블로 필터링하려면(예: '~Plan 레이블과 ~backend 레이블이
모두 붙은 열린 이슈 전체') GROUP BY 절이 포함된 쿼리를
생성합니다. 단순한 형태는 다음과 같습니다.
SELECT
issues.*
FROM
issues
INNER JOIN label_links ON label_links.target_id = issues.id
AND label_links.target_type = 'Issue'
INNER JOIN labels ON labels.id = label_links.label_id
WHERE
issues.project_id = 13083
AND (issues.state IN ('opened'))
AND labels.title IN ('Plan',
'backend')
GROUP BY
issues.id
HAVING (COUNT(DISTINCT labels.title) = 2)
ORDER BY
issues.updated_at DESC,
issues.id DESC
LIMIT 20 OFFSET 0
구체적으로는 다음과 같습니다.
GROUP BY issues.id는 결과를 이슈 단위로 묶습니다.HAVING (COUNT(DISTINCT labels.title) = 2)는 일치한 모든 이슈가 두 레이블을 모두 가지도록 보장합니다.
이 방식은 이상적인 형태보다 복잡합니다. 그만큼 쿼리 구성에서 오류가 발생하기 쉽습니다(예: 이슈 #15557).
시도 A: WHERE EXISTS#
시도 A1: WHERE EXISTS를 사용한 다중 서브쿼리#
이슈 #37137과
연결된 머지 리퀘스트에서는
GROUP BY를 여러 개의 WHERE EXISTS로 대체해 보았습니다. 위 예시에 적용하면
다음과 같습니다.
WHERE (EXISTS (
SELECT
TRUE
FROM
label_links
INNER JOIN labels ON labels.id = label_links.label_id
WHERE
labels.title = 'Plan'
AND target_type = 'Issue'
AND target_id = issues.id))
AND (EXISTS (
SELECT
TRUE
FROM
label_links
INNER JOIN labels ON labels.id = label_links.label_id
WHERE
labels.title = 'backend'
AND target_type = 'Issue'
AND target_id = issues.id))
이 방식은 스키마 변경 없이 동작했고 가독성도 어느 정도 좋아졌지만, 쿼리 성능은 개선되지 않았습니다.
시도 A2: WHERE EXISTS 절에서 레이블 ID 사용#
머지 리퀘스트 #34503에서는 A1과 비슷한 방식을 따랐습니다. 다만 이번에는
필터에 사용된 레이블의 ID를 별도 쿼리로 가져와 EXISTS 절의 JOIN을 피하고
label_links.label_id로 직접 필터링했습니다. 이 쿼리를 빠르게 하기 위해 label_links에 target_id, label_id, target_type 칼럼을 사용하는 새 인덱스도 추가했습니다.
하나의 루트 네임스페이스 안에 제목이 같은 레이블이 여러 개 있을 수 있어 레이블 ID를 찾는 일은 간단하지 않았습니다. 이는
레이블 ID를 제목별로 묶은 다음 그 ID 배열을 EXISTS 절에 사용하는 방식으로 해결했습니다.
그 결과 성능이 크게 개선되었습니다. 다만 이 최적화는 프로젝트나 그룹 컨텍스트가 없는 대시보드 페이지에는 적용할 수 없었습니다. 여기서 레이블 ID를 찾으려면 사용자가 접근할 수 있는 모든 프로젝트와 그룹을 검색해야 하므로 간단히 처리할 수 없었습니다.
시도 B: 배열 칼럼을 사용한 역정규화#
이슈 #49651에서
쿼리를 위해 label_links 테이블을 역정규화하는 방안을
레이블 ID와 제목이라는 두 가지 선택지로
논의했습니다.
두 가지 모두 issues, merge_requests, epics의 배열 칼럼으로 생각할 수
있습니다. issues.label_ids는 레이블 ID의 배열 칼럼이 되고,
issues.label_titles는 레이블 제목의 배열이 됩니다.
이러한 배열 칼럼에는 매칭 성능을 높이기 위해 GIN 인덱스를 함께 사용할 수 있습니다.
시도 B1: 각 객체에 레이블 ID 저장#
이 방식은 제목을 저장하는 방식보다 확실한 장점이 있습니다.
- 레이블이 삭제되거나 프로젝트가 이동하지 않는 한, 역정규화된 칼럼을 일괄 갱신할 필요가 없습니다.
- 제목보다 저장 공간을 적게 사용합니다.
다만 GitLab의 애플리케이션 설계 때문에 이 방식은 쉽지 않습니다. 레이블 ID만으로 쉽게
조회할 수 있었다면 이 문서 앞부분의 최초 쿼리에 INNER JOIN labels가
필요하지 않았을 것입니다. GitLab은 사용자가 프로젝트를 넘어, 나아가 그룹을 넘어
레이블 제목으로 필터링할 수 있게 하므로 ~Plan 레이블 필터에는
서로 다른 ID를 가진 레이블이 여러 개 포함될 수 있습니다.
사용자가 서로 다른 ID를 알아야 하는 상황은 원하지 않으며, 이는 다음 데이터 집합이 주어졌을 때를 뜻합니다.
| 프로젝트 | ~Plan 레이블 ID | ~backend 레이블 ID |
|---|---|---|
| A | 11 | 12 |
| B | 21 | 22 |
| C | 31 | 32 |
다음과 같은 쿼리가 필요합니다.
WHERE
label_ids @> ARRAY[11, 12]
OR label_ids @> ARRAY[21, 22]
OR label_ids @> ARRAY[31, 32]
경우에 따라 같은 객체에 적용될 수 있는, ID가 서로 다른 ~backend 레이블이 둘 있을 수도 있다는 점까지 고려하면 더 복잡해지며, 조합의 수는 더욱 늘어납니다.
시도 B2: 각 객체에 레이블 제목 저장#
객체를 갱신하는 관점에서는 이 방식이 가장 나쁜 선택지입니다. 다음 경우에 객체를 일괄 갱신해야 합니다.
- 객체가 한 프로젝트에서 다른 프로젝트로 이동할 때.
- 프로젝트가 한 그룹에서 다른 그룹으로 이동할 때.
- 레이블 이름이 바뀔 때.
- 레이블이 삭제될 때.
저장 공간도 훨씬 많이 사용합니다. 다만 쿼리는 단순합니다.
WHERE
label_titles @> ARRAY['Plan', 'backend']
그리고 이슈 #49651의 테스트에서 이 방식이 빠를 수 있다는 결과를 얻었습니다.
다만 현재로서는 단점이 장점보다 큽니다.
결론#
역정규화가 필요 없으면서 쿼리 성능을 크게 개선하는 A2 방법을 찾았습니다. 이 방법을
모든 경우에 적용할 수는 없었지만, 나머지 경우에는 A1 방법을 적용해 모든 상황에서
GROUP BY와 HAVING 절을 제거할 수 있었습니다.
그 결과 쿼리가 단순해졌고 가장 흔한 경우의 성능이 개선되었습니다.