이 문서는 의문을 가지고 있던 지점을 AI 에이전트와 대화하며 정리한 메모입니다. Nested Loop Join을 중심으로 드라이빙 테이블과 드리븐 테이블이 왜 중요하고, 실행 계획에서 어떻게 읽는지 정리합니다.
목차#
- 핵심 개념 정리
- JOIN의 내부 동작 원리
- 왜 드라이빙 테이블 선택이 중요한가?
- 좋은 드라이빙/드리븐 테이블 선택 기준
- 인덱스와의 관계
- JOIN 알고리즘별 드라이빙/드리븐 역할 차이
- 옵티마이저가 드라이빙 테이블을 결정하는 방법
- 강제로 드라이빙 테이블 지정하는 방법
- 실무 체크리스트
- 한 줄 요약
1. 핵심 개념 정리#
JOIN을 실행할 때 옵티마이저는 두 테이블 중 어느 쪽을 먼저 읽을지 결정한다.
| 구분 | 다른 이름 | 설명 |
|---|---|---|
| 드라이빙 테이블 | Driving Table / Outer Table | 먼저 읽히는 테이블. JOIN의 기준점이 됨 |
| 드리븐 테이블 | Driven Table / Inner Table | 드라이빙 테이블의 각 행마다 탐색당하는 테이블 |
이 선택은 단순한 순서의 문제가 아니라, 쿼리 전체 성능을 크게 좌우하는 핵심 요소다.
다만 이 용어는 특히 JOIN 순서 나 Nested Loop Join(NLJ) 을 설명할 때 가장 직관적이다.
Hash Join이나 Merge Join에서는 outer/inner, build/probe 같은 표현이 더 정확할 때도 있다.
2. JOIN의 내부 동작 원리#
2-1. Nested Loop Join (NLJ) - 드라이빙/드리븐 개념을 이해하기 가장 쉬운 모델#
드라이빙/드리븐 개념은 NLJ에서 가장 선명하게 드러난다.
// 드라이빙 = A, 드리븐 = B라고 가정
for each row in A (드라이빙): // A를 한 번 순회
for each matching row in B (드리븐): // B를 A의 row 수만큼 반복 탐색
output(A.row + B.row)이 구조를 보면 핵심 비대칭이 명확해진다.
- 드라이빙 테이블은 한 번 읽힌다
- 드리븐 테이블은 드라이빙 결과 row 수만큼 반복 탐색된다
따라서 NLJ에서는 드리븐 테이블의 탐색 비용이 전체 JOIN 성능을 지배하기 쉽다.
2-2. 탐색 횟수로 보는 비용#
드라이빙 결과 = N건
드리븐 탐색 비용 = 1회당 C
전체 비용 = N × C- N을 줄이려면 -> 드라이빙 테이블에 필터 효과가 큰 WHERE 조건
- C를 줄이려면 -> 드리븐 테이블 JOIN 컬럼에 인덱스
3. 왜 드라이빙 테이블 선택이 중요한가?#
예시 시나리오#
SELECT *
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.status = 'PENDING';테이블 현황 가정:
| 테이블 | 전체 건수 | 조건 필터 후 건수 |
|---|---|---|
| orders | 1,000,000 | 500건 (status = 'PENDING') |
| customers | 100,000 | 조건 없음 |
케이스 1: orders가 드라이빙 (좋은 경우)
1. orders에서 status = 'PENDING' 필터 -> 500건 추출
2. 500건의 customer_id로 customers 테이블 인덱스 탐색 -> 500회
총 탐색 횟수 = 500회케이스 2: customers가 드라이빙 (나쁜 경우)
1. customers 전체 읽기 -> 100,000건
2. 100,000건 각각으로 orders 테이블 탐색 -> 100,000회
총 탐색 횟수 = 100,000회같은 논리의 쿼리여도 조인 순서에 따라 비용이 크게 벌어진다.
4. 좋은 드라이빙/드리븐 테이블 선택 기준#
4-1. 드라이빙 테이블로 적합한 것#
✅ WHERE 조건 적용 후 결과 건수가 적은 테이블
✅ 필터 효과가 큰 조건을 가진 테이블
✅ 조인 전에 row 수를 많이 줄일 수 있는 테이블필터 효과 계산 예시#
orders 테이블: 1,000,000건
status = 'PENDING' 조건 후 결과: 500건
조건 일치 비율 = 500 / 1,000,000 = 0.0005 (0.05%)
-> 남는 row 비율이 매우 낮으므로 필터 효과가 큼문헌에 따라 selectivity 용어를 “남는 비율"로 정의하기도 하고, 실무에서는 반대로 “필터가 잘 먹는다"는 뜻으로 느슨하게 쓰기도 한다.
실전에서는 용어보다 조건 적용 후 몇 건이 남는가 를 보는 편이 안전하다.
4-2. 드리븐 테이블로 적합한 것#
✅ JOIN 컬럼(ON 절 컬럼)에 인덱스가 걸려 있는 테이블
✅ 단건 또는 소수건 lookup 역할을 하는 테이블
✅ 테이블이 커도 JOIN 컬럼 인덱스로 빠르게 찾을 수 있는 테이블특히 NLJ에서는 드리븐 테이블의 JOIN 컬럼 인덱스가 사실상 성능의 핵심이다.
황금 법칙: 드라이빙은 “작게”, 드리븐은 “인덱스 있게”
5. 인덱스와의 관계 - 가장 중요한 부분#
5-1. 이상적인 인덱스 구성 예시#
-- 테이블 구조
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
customer_id BIGINT NOT NULL,
status VARCHAR(20) NOT NULL,
created_at DATETIME NOT NULL
);
CREATE TABLE customers (
id BIGINT PRIMARY KEY,
name VARCHAR(100),
email VARCHAR(200)
);
-- 인덱스 추가
CREATE INDEX idx_orders_status ON orders(status); -- WHERE 조건용
CREATE INDEX idx_orders_customer_id ON orders(customer_id); -- JOIN/FK 컬럼용5-2. 이상적인 실행 흐름#
SELECT *
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.status = 'PENDING';Step 1. orders에서 idx_orders_status 사용
-> status = 'PENDING' 에 해당하는 row만 빠르게 추출 (드라이빙)
Step 2. 추출된 N건의 customer_id 각각으로
customers의 PK 인덱스 탐색 (드리븐)
-> 매 반복마다 index seek
결과: 전체 비용 ≈ N × lookup_cost5-3. 드리븐 테이블에 인덱스가 없는 경우#
-- 최악에 가까운 시나리오
SELECT * FROM small_ref_table s
JOIN huge_table h ON h.non_indexed_col = s.id;-> small_ref_table 결과가 N건이라면
-> huge_table을 N번 Full Table Scan
-> 전체 비용 = N × M (M = huge_table 전체 건수)
-> N=1,000, M=5,000,000이면 row 비교 수가 폭증결론: NLJ에서 드리븐 테이블의 JOIN 컬럼에 인덱스가 없으면, 전체 JOIN 비용이 급격히 나빠질 수 있다.
5-4. FK 컬럼 인덱스 주의사항#
FK 컬럼 인덱스는 DBMS마다 자동 생성 여부가 다르다.
- MySQL InnoDB: 참조하는 쪽(referencing table)에 필요한 인덱스가 없으면 자동으로 생성될 수 있다
- PostgreSQL: FK 선언만으로 referencing columns 인덱스를 자동 생성하지 않는다
- SQL Server: FK 선언만으로 corresponding index를 자동 생성하지 않는다
따라서 실무에서는 다음 원칙이 안전하다.
FK가 있으니 인덱스도 있겠지 -> 위험한 가정
실제 인덱스 정의 + EXPLAIN 결과를 확인 -> 안전한 접근-- 필요 시 명시적으로 생성
CREATE INDEX idx_orders_customer_id ON orders(customer_id);6. JOIN 알고리즘별 드라이빙/드리븐 역할 차이#
6-1. Nested Loop Join (NLJ)#
드라이빙/드리븐 개념이 가장 직접적으로 적용되는 알고리즘이다.
특징:
- 드라이빙 테이블 한 번 순회
- 드리븐 테이블을 드라이빙 row 수만큼 반복 탐색
최적 조건:
- 드라이빙: 소량 결과, 필터 효과 큼
- 드리븐: JOIN 컬럼에 인덱스가 매우 중요
적합 상황: OLTP, 비교적 작은 결과 집합, 인덱스 잘 갖춰진 환경-- NLJ 동작 의사코드
for each row r1 in driving_table:
lookup driven_table where join_col = r1.join_col
if found:
emit(r1, matched_row)6-2. Hash Join (MySQL 8.4, PostgreSQL, SQL Server 등)#
인덱스가 약한 상황에서도 대용량 JOIN을 처리할 수 있는 알고리즘이다.
동작 방식:
Phase 1 (Build): 보통 더 작은 입력을 해시 테이블로 적재
Phase 2 (Probe): 다른 입력을 스캔하며 해시 테이블과 매칭
특징:
- 드리븐 인덱스가 없어도 동작 가능
- 메모리 사용량이 큼
- 메모리를 초과하면 디스크 spill이 발생할 수 있음
적합 상황: 대용량 데이터, equi-join 중심, 인덱스가 약한 환경PostgreSQL 문서는 Hash Join에서 한쪽 입력이 Hash 노드로 해시 테이블이 되고, 다른 쪽 입력이 이를 probe한다고 설명한다.
MySQL 8.4 문서도 EXPLAIN FORMAT=TREE 에 Hash 와 Inner hash join 노드가 표시된다고 설명한다.
-- MySQL 8.4에서 Hash Join 동작 확인
EXPLAIN FORMAT=TREE
SELECT * FROM orders o JOIN customers c ON o.customer_id = c.id;
-- 출력에 "Hash" 또는 "Inner hash join" 이 보이면 Hash Join 사용 중-- MySQL: hash join 메모리와 관련된 대표 설정
SET join_buffer_size = 256 * 1024 * 1024; -- 256MBMySQL 8.4 문서 기준으로 hash join 메모리 사용량은 join_buffer_size 와 관련이 있고, 부족하면 디스크 파일을 사용할 수 있다.
6-3. Sort Merge Join (PostgreSQL, Oracle, SQL Server)#
양쪽 입력이 JOIN 컬럼 기준으로 정렬되어 있을 때 유리한 알고리즘이다.
동작 방식:
1. 양쪽 입력을 JOIN 컬럼 기준으로 정렬
2. 정렬된 두 목록을 순차 스캔하며 병합
특징:
- 양쪽 입력이 정렬돼 있어야 함
- 정렬 비용이 들 수 있음
- 범위 조건이나 정렬된 입력 활용에 유리할 수 있음
- 드라이빙/드리븐 개념이 NLJ보다 덜 중요함PostgreSQL 공식 문서도 merge join은 입력 데이터가 join key 기준으로 정렬되어 있어야 한다고 설명한다.
6-4. 알고리즘 선택 요약#
| 알고리즘 | 인덱스 필요성 | 메모리 사용 | 적합 환경 |
|---|---|---|---|
| Nested Loop Join | 드리븐 인덱스가 매우 중요 | 낮음 | OLTP, 소량 결과 |
| Hash Join | 인덱스 의존도 낮음 | 높음 | 대량 데이터, equi-join |
| Sort Merge Join | 정렬된 입력이 중요 | 중간 | 대량 데이터, 정렬 활용 |
7. 옵티마이저가 드라이빙 테이블을 결정하는 방법#
옵티마이저는 통계 정보(테이블 건수, 인덱스 분포, 컬럼 분포 등)를 기반으로 비용을 추정해 조인 순서를 결정한다.
7-1. 실행계획(EXPLAIN) 읽는 법#
-- MySQL
EXPLAIN SELECT *
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.status = 'PENDING';+----+-------------+-------+--------+-------------------------+-------------------+---------+-------------------+------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows |
+----+-------------+-------+--------+-------------------------+-------------------+---------+-------------------+------+
| 1 | SIMPLE | o | ref | idx_orders_status | idx_orders_status | 22 | const | 500 |
| 1 | SIMPLE | c | eq_ref | PRIMARY | PRIMARY | 8 | mydb.o.customer_id| 1 |
+----+-------------+-------+--------+-------------------------+-------------------+---------+-------------------+------+MySQL 공식 문서는 EXPLAIN 이 테이블이 어떤 순서로 조인되는지 보여 준다고 설명한다.
따라서 tabular EXPLAIN 에서는 보통 위에서 아래 순서가 조인 순서를 반영한다.
다만 다음 예외는 기억해야 한다.
const또는system테이블은 최적화 단계에서 먼저 읽혀 계획 맨 앞에 나타날 수 있다- Hash Join 같은 경우는
FORMAT=TREE로 보는 편이 실제 build/probe 구조를 더 잘 보여 준다
7-2. type 컬럼 - MySQL에서 보는 주요 접근 방식#
| type 값 | 의미 |
|---|---|
system | 테이블에 행이 1건뿐인 경우 |
const | PK/Unique = 상수값, 1건 접근 |
eq_ref | PK/Unique를 이용한 조인 lookup |
ref | 일반 인덱스 lookup |
range | 인덱스 범위 탐색 |
index | 인덱스 전체 스캔 |
ALL | 테이블 전체 스캔 |
대체로 위에서 아래로 갈수록 비용이 커지는 경향이 있다.
드리븐 테이블의
type이ALL이면 인덱스 추가나 조인 순서 재검토를 먼저 의심하는 편이 좋다.
7-3. 추가로 봐야 할 컬럼들#
| 컬럼 | 의미 |
|---|---|
rows | 옵티마이저가 예측한 탐색 건수 |
filtered | WHERE 조건으로 걸러질 비율 추정치 |
Extra | Using index, Using filesort, Using join buffer (hash join) 등 부가 정보 |
7-4. 통계 정보 갱신#
옵티마이저 판단이 이상할 때는 통계가 오래됐을 가능성도 확인해야 한다.
-- MySQL
ANALYZE TABLE orders;
ANALYZE TABLE customers;
-- PostgreSQL
ANALYZE orders;
ANALYZE customers;
-- Oracle
EXEC DBMS_STATS.GATHER_TABLE_STATS('schema_name', 'orders');8. 강제로 드라이빙 테이블 지정하는 방법#
옵티마이저가 잘못된 판단을 내릴 때는 힌트나 planner 설정으로 영향을 줄 수 있다.
다만 힌트는 어디까지나 예외적 수단 이고, 먼저 인덱스와 통계부터 바로잡는 편이 안전하다.
8-1. MySQL#
-- STRAIGHT_JOIN: FROM 절 순서를 고정
SELECT STRAIGHT_JOIN *
FROM orders o
JOIN customers c ON o.customer_id = c.id;
-- JOIN_ORDER: 더 세밀한 조인 순서 지정
SELECT /*+ JOIN_ORDER(o, c) */ *
FROM orders o
JOIN customers c ON o.customer_id = c.id;MySQL 8.4 공식 문서 기준으로:
STRAIGHT_JOIN은 FROM 절의 순서를 따르게 한다JOIN_FIXED_ORDER는STRAIGHT_JOIN과 같은 효과다JOIN_ORDER는 특정 테이블들의 조인 순서를 지정한다
Hash Join 제어도 주의가 필요하다.
-- MySQL 8.4에서는 HASH_JOIN/NO_HASH_JOIN 대신 BNL/NO_BNL 사용
SELECT /*+ NO_BNL(c) */ *
FROM orders o
JOIN customers c ON o.customer_id = c.id;MySQL 8.4 문서는 HASH_JOIN / NO_HASH_JOIN 힌트가 효과가 없고, hash join 제어에는 BNL / NO_BNL 을 쓰라고 설명한다.
8-2. PostgreSQL#
-- 조인 재정렬을 강하게 줄이기
SET join_collapse_limit = 1;
-- 특정 알고리즘 비선호
SET enable_hashjoin = off;
SET enable_mergejoin = off;PostgreSQL 공식 문서 기준으로:
join_collapse_limit은 planner가 JOIN 순서를 얼마나 재배치할지에 영향을 준다enable_hashjoin,enable_mergejoin,enable_nestloop는 해당 조인 방식을 비활성화라기보다 비선호 하게 만든다- 특히 nested loop는 다른 대안이 없으면 완전히 배제되지는 않는다
8-3. Oracle#
-- LEADING 힌트: 조인 순서 시작점 지정
SELECT /*+ LEADING(o) */ *
FROM orders o
JOIN customers c ON o.customer_id = c.id;
-- USE_NL: 지정한 테이블을 inner table로 nested loop 사용
SELECT /*+ LEADING(o) USE_NL(c) */ *
FROM orders o
JOIN customers c ON o.customer_id = c.id;Oracle 공식 문서는:
LEADING을 조인 순서를 잡는 multitable hint로USE_NL을 지정한 테이블을 inner table로 nested loops join 하도록 지시하는 hint로 설명한다
8-4. SQL Server#
SELECT *
FROM orders o
INNER LOOP JOIN customers c ON o.customer_id = c.id
OPTION (FORCE ORDER);SQL Server 공식 문서는:
LOOP,HASH,MERGE를 join hint로FORCE ORDER를 query hint로 제공한다- 하나의 join hint를 주면 쿼리 전체 조인 순서에도 영향이 생길 수 있다고 설명한다
⚠️ 힌트 사용 시 주의사항
데이터 분포, 통계, 인덱스가 바뀌면 힌트가 오히려 성능을 악화시킬 수 있다.
가능하면 인덱스 설계와 통계 정보 갱신으로 먼저 해결하자.
9. 실무 체크리스트#
9-1. 쿼리 작성 시#
✅ WHERE 조건으로 결과가 많이 줄어드는 테이블을 드라이빙 후보로 본다
✅ 드리븐 테이블 JOIN 컬럼(ON 절)에 인덱스가 있는지 확인한다
✅ FK 컬럼은 "자동 인덱스가 있을 것"이라고 가정하지 말고 실제 인덱스를 확인한다
✅ 3개 이상 JOIN 시 EXPLAIN으로 실행 순서를 확인한다
✅ SELECT * 를 피하고 필요한 컬럼만 선택해 커버링 인덱스 가능성을 높인다9-2. 성능 문제 의심 시 진단 절차#
Step 1. EXPLAIN 실행
-> JOIN 순서와 access type 확인
-> 드리븐 쪽이 ALL인지 먼저 본다
Step 2. rows / filtered 확인
-> 드라이빙 후보의 결과 건수가 비정상적으로 큰지 점검
Step 3. 통계 정보 갱신
-> ANALYZE 또는 DBMS_STATS 후 실행계획 재확인
Step 4. 인덱스 추가
-> 드리븐 JOIN 컬럼 인덱스
-> 필요 시 드라이빙 필터 컬럼 인덱스
Step 5. 그래도 느리면
-> DBMS별 힌트 또는 planner 설정으로 예외적으로 보정9-3. 자주 하는 실수 & 올바른 해결책#
실수 1: 드리븐 테이블 JOIN 컬럼에 인덱스 없음#
-- 나쁜 예
SELECT * FROM small_table s
JOIN huge_table h ON h.non_indexed_col = s.id;
-- 해결
CREATE INDEX idx_huge_non_indexed ON huge_table(non_indexed_col);실수 2: 대형 테이블이 드라이빙으로 선택됨#
-- 나쁜 예
SELECT * FROM customers c
JOIN orders o ON o.customer_id = c.id
WHERE o.status = 'PENDING';
-- 확인 후 조인 순서 보정
SELECT STRAIGHT_JOIN *
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.status = 'PENDING';다만 이런 경우도 힌트 전에 인덱스와 통계부터 확인 하는 것이 우선이다.
실수 3: FK라서 인덱스가 자동으로 있을 거라고 가정#
ALTER TABLE orders
ADD CONSTRAINT fk_orders_customer
FOREIGN KEY (customer_id) REFERENCES customers(id);이 선언만으로 모든 DBMS가 자동 인덱스를 만드는 것은 아니다.
- PostgreSQL, SQL Server는 referencing columns 인덱스를 자동 생성하지 않는다
- MySQL InnoDB는 필요한 인덱스를 자동 생성할 수 있지만, 실제로 어떤 인덱스가 있는지는 확인해야 한다
-- 실제 스키마 확인 후 필요 시 명시적으로 생성
CREATE INDEX idx_orders_customer_id ON orders(customer_id);실수 4: 3개 이상 테이블 JOIN 시 순서 방치#
SELECT *
FROM orders o
JOIN customers c ON o.customer_id = c.id
JOIN products p ON o.product_id = p.id
JOIN categories cat ON p.category_id = cat.id
WHERE o.created_at BETWEEN '2024-01-01' AND '2024-03-31';이런 쿼리는 반드시 실행계획을 보고:
- 어떤 테이블이 먼저 읽히는지
- 중간 단계에서 row 수가 얼마나 줄어드는지
- 어느 지점에서 Full Scan이나 Hash/Merge가 붙는지
를 확인해야 한다.
9-4. 인덱스 설계 가이드#
-- 드라이빙 후보용: WHERE 조건 컬럼
CREATE INDEX idx_orders_status_created ON orders(status, created_at);
-- 드리븐 후보용: JOIN 컬럼
CREATE INDEX idx_orders_customer_id ON orders(customer_id);
-- 커버링 인덱스 예시
CREATE INDEX idx_orders_covering ON orders(customer_id, status, created_at);MySQL의 경우 EXPLAIN 의 Extra 에 Using index 가 보이면 커버링 인덱스 활용 여부를 판단하는 데 도움이 된다.
10. 한 줄 요약#
특히 NLJ에서는, 드라이빙 테이블은 “필터 후 결과가 적은 것"으로, 드리븐 테이블은 “JOIN 컬럼에 인덱스가 있는 것"으로 잡혀야 JOIN이 빨라진다.
작은 드라이빙 × 인덱스 있는 드리븐 = 빠른 JOIN
큰 드라이빙 × 인덱스 없는 드리븐 = 느린 JOIN참고 - DB별 빠른 레퍼런스#
| 목적 | MySQL | PostgreSQL | Oracle |
|---|---|---|---|
| 실행계획 확인 | EXPLAIN, EXPLAIN ANALYZE, FORMAT=TREE | EXPLAIN, EXPLAIN ANALYZE | EXPLAIN PLAN FOR |
| 통계 갱신 | ANALYZE TABLE t | ANALYZE t | DBMS_STATS.GATHER_TABLE_STATS |
| 조인 순서 영향 주기 | STRAIGHT_JOIN, JOIN_ORDER, JOIN_FIXED_ORDER | join_collapse_limit | LEADING |
| Hash Join 제어 | BNL, NO_BNL | enable_hashjoin | USE_HASH |
참고 자료#
- MySQL 8.4 Reference Manual,
SELECTStatement (STRAIGHT_JOIN)
https://dev.mysql.com/doc/refman/8.4/en/select.html - MySQL 8.4 Reference Manual,
EXPLAINStatement
https://dev.mysql.com/doc/refman/8.4/en/explain.html - MySQL 8.4 Reference Manual, Hash Join Optimization
https://dev.mysql.com/doc/refman/8.4/en/hash-joins.html - MySQL 8.4 Reference Manual, Optimizer Hints
https://dev.mysql.com/doc/refman/8.4/en/optimizer-hints.html - MySQL 8.4 Reference Manual, FOREIGN KEY Constraints
https://dev.mysql.com/doc/refman/8.4/en/create-table-foreign-keys.html - PostgreSQL 18 Documentation, Using EXPLAIN
https://www.postgresql.org/docs/18/using-explain.html - PostgreSQL 18 Documentation, Query Planning
https://www.postgresql.org/docs/18/runtime-config-query.html - PostgreSQL 18 Documentation, Constraints
https://www.postgresql.org/docs/18/ddl-constraints.html - PostgreSQL 18 Documentation, CREATE TABLE
https://www.postgresql.org/docs/18/sql-createtable.html - Oracle Database, Influencing the Optimizer
https://docs.oracle.com/en/database/oracle/oracle-database/21/tgsql/influencing-the-optimizer.html - Oracle Database, DBMS_STATS
https://docs.oracle.com/en/database/oracle/oracle-database/19/arpls/DBMS_STATS.html - SQL Server, Join hints (Transact-SQL)
https://learn.microsoft.com/en-us/sql/t-sql/queries/hints-transact-sql-join?view=sql-server-ver16 - SQL Server, Query hints (Transact-SQL)
https://learn.microsoft.com/en-us/sql/t-sql/queries/hints-transact-sql-query?view=azuresqldb-current - SQL Server, Primary and foreign key constraints
https://learn.microsoft.com/en-us/sql/relational-databases/tables/primary-and-foreign-key-constraints?view=sql-server-ver16
