다대다 관계
다대다(Many-to-Many) 관계는 한 테이블의 여러 레코드가 다른 테이블의 여러 레코드와 연결될 수 있는 관계입니다.
실생활 예시
- 학생 ↔ 과목: 한 학생이 여러 과목을 수강하고, 한 과목을 여러 학생이 수강
- 사용자 ↔ 역할: 한 사용자가 여러 역할을 가지고, 한 역할을 여러 사용자가 가짐
- 상품 ↔ 카테고리: 한 상품이 여러 카테고리에 속하고, 한 카테고리에 여러 상품이 속함
- 게시글 ↔ 태그: 한 게시글에 여러 태그가 달리고, 한 태그가 여러 게시글에 달림
다대다 관계의 테이블 설계
다대다 관계는 중간 테이블(Junction Table/Bridge Table)을 통해 구현합니다.
기본 구조 예시: 학생-과목 관계
-- 1. 학생 테이블 (Students)
CREATE TABLE Students (
student_id INT IDENTITY(1,1) PRIMARY KEY,
student_name NVARCHAR(50) NOT NULL,
student_number VARCHAR(20) UNIQUE NOT NULL,
email VARCHAR(100),
phone VARCHAR(20),
created_at DATETIME2 DEFAULT GETDATE()
);
-- 2. 과목 테이블 (Subjects)
CREATE TABLE Subjects (
subject_id INT IDENTITY(1,1) PRIMARY KEY,
subject_name NVARCHAR(100) NOT NULL,
subject_code VARCHAR(10) UNIQUE NOT NULL,
credits INT NOT NULL,
department NVARCHAR(50),
created_at DATETIME2 DEFAULT GETDATE()
);
-- 3. 중간 테이블 (Student_Subject_Enrollment)
CREATE TABLE Student_Subject_Enrollment (
enrollment_id INT IDENTITY(1,1) PRIMARY KEY,
student_id INT NOT NULL,
subject_id INT NOT NULL,
enrollment_date DATETIME2 DEFAULT GETDATE(),
grade CHAR(2), -- 성적 (A+, A, B+ 등)
status VARCHAR(20) DEFAULT 'ENROLLED', -- ENROLLED, COMPLETED, DROPPED
-- 외래 키 제약 조건
CONSTRAINT FK_Enrollment_Student
FOREIGN KEY (student_id) REFERENCES Students(student_id)
ON DELETE CASCADE,
CONSTRAINT FK_Enrollment_Subject
FOREIGN KEY (subject_id) REFERENCES Subjects(subject_id)
ON DELETE CASCADE,
-- 복합 고유 키 (한 학생이 같은 과목을 중복 수강 방지)
CONSTRAINT UQ_Student_Subject
UNIQUE (student_id, subject_id)
);
테스트 데이터 삽입
-- 학생 데이터
INSERT INTO Students (student_name, student_number, email, phone) VALUES
(N'김철수', '2024001', 'kim@university.ac.kr', '010-1111-2222'),
(N'이영희', '2024002', 'lee@university.ac.kr', '010-3333-4444'),
(N'박민수', '2024003', 'park@university.ac.kr', '010-5555-6666'),
(N'최은정', '2024004', 'choi@university.ac.kr', '010-7777-8888'),
(N'정우진', '2024005', 'jung@university.ac.kr', '010-9999-0000');
-- 과목 데이터
INSERT INTO Subjects (subject_name, subject_code, credits, department) VALUES
(N'데이터베이스', 'CS301', 3, N'컴퓨터공학과'),
(N'자료구조', 'CS201', 3, N'컴퓨터공학과'),
(N'운영체제', 'CS302', 3, N'컴퓨터공학과'),
(N'선형대수학', 'MATH201', 3, N'수학과'),
(N'통계학개론', 'STAT101', 2, N'통계학과'),
(N'영어회화', 'ENG101', 2, N'영어영문학과');
-- 수강신청 데이터 (다대다 관계)
INSERT INTO Student_Subject_Enrollment (student_id, subject_id, grade, status) VALUES
-- 김철수 (student_id: 1)
(1, 1, 'A+', 'COMPLETED'), -- 데이터베이스
(1, 2, 'A', 'COMPLETED'), -- 자료구조
(1, 4, 'B+', 'COMPLETED'), -- 선형대수학
-- 이영희 (student_id: 2)
(2, 1, 'A', 'COMPLETED'), -- 데이터베이스
(2, 3, 'B+', 'COMPLETED'), -- 운영체제
(2, 5, 'A+', 'ENROLLED'), -- 통계학개론
-- 박민수 (student_id: 3)
(3, 2, 'B', 'COMPLETED'), -- 자료구조
(3, 3, 'A', 'ENROLLED'), -- 운영체제
(3, 6, 'A+', 'COMPLETED'), -- 영어회화
-- 최은정 (student_id: 4)
(4, 1, NULL, 'ENROLLED'), -- 데이터베이스 (진행중)
(4, 4, NULL, 'ENROLLED'), -- 선형대수학 (진행중)
(4, 5, NULL, 'ENROLLED'), -- 통계학개론 (진행중)
-- 정우진 (student_id: 5)
(5, 3, 'B+', 'COMPLETED'), -- 운영체제
(5, 6, 'A', 'COMPLETED'); -- 영어회화
다대다 관계 조회 방법 이해하기
1. 기본 조회 원리 이해
다대다 관계에서 데이터를 조회할 때는 반드시 3개의 테이블을 연결해야 합니다.
왜 3개 테이블이 필요한가?
- 첫 번째 테이블: 조회하고 싶은 주 데이터 (예: 학생 정보)
- 중간 테이블: 관계 정보를 담고 있는 연결 테이블 (예: 수강신청 정보)
- 세 번째 테이블: 연관된 데이터 (예: 과목 정보)
JOIN의 순서와 의미:
-- 1단계: 학생 테이블과 중간테이블 연결
-- "어떤 학생이 어떤 수강신청을 했는지" 알아내기
FROM Students s
JOIN Student_Subject_Enrollment e ON s.student_id = e.student_id
-- 2단계: 중간테이블과 과목 테이블 연결
-- "그 수강신청이 어떤 과목인지" 알아내기
JOIN Subjects sub ON e.subject_id = sub.subject_id
전체 조회 쿼리:
-- 모든 수강신청 정보를 한번에 보기
SELECT
s.student_name, -- 학생 정보
sub.subject_name, -- 과목 정보
e.grade, -- 관계 정보 (중간테이블)
e.enrollment_date -- 관계 정보 (중간테이블)
FROM Students s
JOIN Student_Subject_Enrollment e ON s.student_id = e.student_id
JOIN Subjects sub ON e.subject_id = sub.subject_id
ORDER BY s.student_name;
2. 특정 학생의 과목 조회하기
생각의 과정:
- "김철수가 듣는 과목들을 보고 싶다"
- 김철수 → 중간테이블에서 김철수의 수강신청 찾기 → 그 수강신청들이 어떤 과목인지 찾기
단계별 접근:
-- 1단계: 김철수 찾기
SELECT * FROM Students WHERE student_name = N'김철수';
-- 결과: student_id = 1
-- 2단계: 김철수의 수강신청들 찾기
SELECT * FROM Student_Subject_Enrollment WHERE student_id = 1;
-- 결과: subject_id = 1, 2, 4 (데이터베이스, 자료구조, 선형대수학)
-- 3단계: 그 과목들의 정보 가져오기
SELECT * FROM Subjects WHERE subject_id IN (1, 2, 4);
-- 위 3단계를 한 번에 처리하는 JOIN 쿼리:
SELECT
s.student_name AS '학생명',
sub.subject_name AS '과목명',
sub.credits AS '학점수',
e.grade AS '성적',
CASE
WHEN e.grade IS NOT NULL THEN '수강완료'
ELSE '수강중'
END AS '상태'
FROM Students s
JOIN Student_Subject_Enrollment e ON s.student_id = e.student_id -- 학생의 수강신청 찾기
JOIN Subjects sub ON e.subject_id = sub.subject_id -- 수강신청의 과목 찾기
WHERE s.student_name = N'김철수' -- 김철수만 필터링
ORDER BY e.enrollment_date;
쿼리 결과 해석:
- 김철수는 총 3개 과목을 수강
- 데이터베이스(A+), 자료구조(A), 선형대수학(B+) 모두 완료
- 총 9학점을 이수했음
3. 반대 방향 조회: 특정 과목을 듣는 학생들
생각의 과정:
- "데이터베이스 과목을 듣는 학생들을 보고 싶다"
- 데이터베이스 과목 → 중간테이블에서 이 과목의 수강신청들 찾기 → 그 수강신청을 한 학생들 찾기
-- 데이터베이스 과목을 수강하는 학생들 조회
SELECT
sub.subject_name AS '과목명',
s.student_name AS '학생명',
s.student_number AS '학번',
e.grade AS '성적',
e.status AS '상태',
e.enrollment_date AS '수강신청일'
FROM Subjects sub
JOIN Student_Subject_Enrollment e ON sub.subject_id = e.subject_id -- 과목의 수강신청들 찾기
JOIN Students s ON e.student_id = s.student_id -- 수강신청한 학생들 찾기
WHERE sub.subject_name = N'데이터베이스' -- 데이터베이스 과목만 필터링
ORDER BY e.enrollment_date;
쿼리 결과 분석:
- 데이터베이스 과목을 수강한 학생: 김철수, 이영희, 최은정
- 김철수(A+), 이영희(A)는 완료, 최은정은 아직 수강중
- 인기 있는 전공과목임을 알 수 있음
4. 집계를 통한 통계 조회
목적: 각 학생이 몇 개의 과목을 듣는지, 평균 성적은 어떻게 되는지 알고 싶다.
접근 방법:
- 먼저 모든 학생-과목 관계를 JOIN으로 가져오기
- 학생별로 GROUP BY 하기
- COUNT, AVG 등 집계함수로 통계 계산하기
-- 각 학생의 수강 통계
SELECT
s.student_name AS '학생명',
COUNT(*) AS '총수강과목수',
COUNT(CASE WHEN e.grade IS NOT NULL THEN 1 END) AS '완료과목수',
SUM(sub.credits) AS '총학점수',
STRING_AGG(sub.subject_name, ', ') AS '수강과목목록',
AVG(CASE
WHEN e.grade = 'A+' THEN 4.5
WHEN e.grade = 'A' THEN 4.0
WHEN e.grade = 'B+' THEN 3.5
WHEN e.grade = 'B' THEN 3.0
WHEN e.grade = 'C+' THEN 2.5
ELSE NULL -- 아직 성적이 없는 경우 평균 계산에서 제외
END) AS '평점'
FROM Students s
LEFT JOIN Student_Subject_Enrollment e ON s.student_id = e.student_id
LEFT JOIN Subjects sub ON e.subject_id = sub.subject_id
GROUP BY s.student_id, s.student_name -- 학생별로 그룹화
ORDER BY '평점' DESC;
LEFT JOIN을 사용하는 이유:
- 아직 아무 과목도 수강하지 않은 학생도 결과에 포함시키기 위해
- INNER JOIN을 쓰면 수강신청을 안 한 학생은 결과에서 제외됨
GROUP BY 이해하기:
- GROUP BY s.student_id, s.student_name: 학생별로 데이터를 묶어서 처리
- 각 학생마다 한 줄의 결과가 나옴
- COUNT, SUM, AVG 등은 각 그룹(학생) 내에서 계산됨
5. 복잡한 조건을 가진 조회
A. 조건을 만족하는 학생 찾기
요구사항: "3학점 이상 과목을 2개 이상 수강한 학생들을 찾고 싶다"
해결 과정:
- 먼저 모든 학생-과목 관계를 JOIN으로 가져오기
- 3학점 이상 과목만 WHERE로 필터링
- 학생별로 GROUP BY 하기
- HAVING으로 2개 이상인 학생만 선택
SELECT
s.student_name AS '학생명',
COUNT(*) AS '고학점과목수',
SUM(sub.credits) AS '총학점',
STRING_AGG(sub.subject_name, ', ') AS '수강과목'
FROM Students s
JOIN Student_Subject_Enrollment e ON s.student_id = e.student_id
JOIN Subjects sub ON e.subject_id = sub.subject_id
WHERE sub.credits >= 3 -- 1단계: 3학점 이상만 필터링
GROUP BY s.student_id, s.student_name -- 2단계: 학생별로 그룹화
HAVING COUNT(*) >= 2 -- 3단계: 2개 이상인 학생만 선택
ORDER BY '총학점' DESC;
WHERE vs HAVING 차이점:
- WHERE: 개별 행을 필터링 (GROUP BY 전에 실행)
- HAVING: 그룹화된 결과를 필터링 (GROUP BY 후에 실행)
B. 없는 것 찾기 (NOT EXISTS)
요구사항: "아직 수강하지 않은 과목이 있는 학생들을 찾고, 어떤 과목을 안 들었는지 보고 싶다"
해결 과정:
- 모든 학생 x 모든 과목의 조합을 만들기 (CROSS JOIN)
- 실제로 수강한 것들은 제외하기 (NOT EXISTS)
SELECT
s.student_name AS '학생명',
sub.subject_name AS '미수강과목',
sub.credits AS '학점수',
sub.department AS '개설학과'
FROM Students s
CROSS JOIN Subjects sub -- 모든 학생 x 모든 과목 조합
WHERE NOT EXISTS ( -- 실제로 수강한 것은 제외
SELECT 1
FROM Student_Subject_Enrollment e
WHERE e.student_id = s.student_id
AND e.subject_id = sub.subject_id
)
ORDER BY s.student_name, sub.subject_name;
CROSS JOIN의 의미:
- 학생 5명 x 과목 6개 = 총 30개의 조합이 만들어짐
- NOT EXISTS로 실제 수강한 조합들을 빼면, 수강하지 않은 조합들만 남음
6. 다대다 관계 쿼리의 일반적인 패턴들
A. "A의 모든 B 찾기" 패턴
-- 패턴: 특정 A에 연결된 모든 B들
SELECT B테이블.*, 중간테이블.추가정보
FROM A테이블
JOIN 중간테이블 ON A테이블.id = 중간테이블.a_id
JOIN B테이블 ON 중간테이블.b_id = B테이블.id
WHERE A테이블.조건 = '특정값';
B. "B를 가진 모든 A 찾기" 패턴
-- 패턴: 특정 B에 연결된 모든 A들
SELECT A테이블.*, 중간테이블.추가정보
FROM B테이블
JOIN 중간테이블 ON B테이블.id = 중간테이블.b_id
JOIN A테이블 ON 중간테이블.a_id = A테이블.id
WHERE B테이블.조건 = '특정값';
C. "A별 B 개수 세기" 패턴
-- 패턴: 각 A가 몇 개의 B를 가지는지
SELECT
A테이블.name,
COUNT(B테이블.id) AS b_count,
STRING_AGG(B테이블.name, ', ') AS b_list
FROM A테이블
LEFT JOIN 중간테이블 ON A테이블.id = 중간테이블.a_id
LEFT JOIN B테이블 ON 중간테이블.b_id = B테이블.id
GROUP BY A테이블.id, A테이블.name;
D. "조건을 만족하는 A 찾기" 패턴
-- 패턴: 특정 조건의 B를 N개 이상 가진 A들
SELECT A테이블.name, COUNT(*) as matching_count
FROM A테이블
JOIN 중간테이블 ON A테이블.id = 중간테이블.a_id
JOIN B테이블 ON 중간테이블.b_id = B테이블.id
WHERE B테이블.조건 = '원하는값'
GROUP BY A테이블.id, A테이블.name
HAVING COUNT(*) >= N;
E. "공통된 B를 가진 A들 찾기" 패턴
-- 패턴: 같은 B를 가진 A들끼리 묶기
SELECT
a1.name AS first_a,
a2.name AS second_a,
COUNT(*) AS common_b_count,
STRING_AGG(b.name, ', ') AS common_bs
FROM A테이블 a1
JOIN 중간테이블 m1 ON a1.id = m1.a_id
JOIN B테이블 b ON m1.b_id = b.id
JOIN 중간테이블 m2 ON b.id = m2.b_id
JOIN A테이블 a2 ON m2.a_id = a2.id
WHERE a1.id < a2.id -- 중복 방지
GROUP BY a1.id, a1.name, a2.id, a2.name
HAVING COUNT(*) >= 1; -- 최소 1개 이상 공통점
1. 가중치가 있는 다대다 관계
-- 사용자-역할 관계 (권한 레벨 포함)
CREATE TABLE Users (
user_id INT IDENTITY(1,1) PRIMARY KEY,
username NVARCHAR(50) NOT NULL,
email VARCHAR(100) NOT NULL
);
CREATE TABLE Roles (
role_id INT IDENTITY(1,1) PRIMARY KEY,
role_name NVARCHAR(50) NOT NULL,
description NVARCHAR(200)
);
CREATE TABLE User_Role_Assignment (
assignment_id INT IDENTITY(1,1) PRIMARY KEY,
user_id INT NOT NULL,
role_id INT NOT NULL,
permission_level INT DEFAULT 1, -- 1: 읽기, 2: 읽기+쓰기, 3: 모든권한
assigned_date DATETIME2 DEFAULT GETDATE(),
assigned_by INT, -- 누가 권한을 부여했는지
expiry_date DATETIME2, -- 권한 만료일
is_active BIT DEFAULT 1,
FOREIGN KEY (user_id) REFERENCES Users(user_id),
FOREIGN KEY (role_id) REFERENCES Roles(role_id),
FOREIGN KEY (assigned_by) REFERENCES Users(user_id)
);
2. 계층적 다대다 관계
-- 상품-카테고리 관계 (우선순위 포함)
CREATE TABLE Products (
product_id INT IDENTITY(1,1) PRIMARY KEY,
product_name NVARCHAR(100) NOT NULL,
price DECIMAL(10,2)
);
CREATE TABLE Categories (
category_id INT IDENTITY(1,1) PRIMARY KEY,
category_name NVARCHAR(50) NOT NULL,
parent_category_id INT, -- 상위 카테고리
FOREIGN KEY (parent_category_id) REFERENCES Categories(category_id)
);
CREATE TABLE Product_Category_Mapping (
mapping_id INT IDENTITY(1,1) PRIMARY KEY,
product_id INT NOT NULL,
category_id INT NOT NULL,
is_primary BIT DEFAULT 0, -- 주 카테고리 여부
sort_order INT DEFAULT 0, -- 카테고리 내 정렬 순서
created_at DATETIME2 DEFAULT GETDATE(),
FOREIGN KEY (product_id) REFERENCES Products(product_id),
FOREIGN KEY (category_id) REFERENCES Categories(category_id),
UNIQUE (product_id, category_id)
);
성능 최적화 방법
1. 인덱스 최적화
-- 중간 테이블에 적절한 인덱스 생성
-- 1. 외래 키별 인덱스
CREATE INDEX IX_Enrollment_Student ON Student_Subject_Enrollment(student_id);
CREATE INDEX IX_Enrollment_Subject ON Student_Subject_Enrollment(subject_id);
-- 2. 복합 인덱스 (자주 함께 조회되는 컬럼들)
CREATE INDEX IX_Enrollment_Student_Status ON Student_Subject_Enrollment(student_id, status);
CREATE INDEX IX_Enrollment_Subject_Grade ON Student_Subject_Enrollment(subject_id, grade)
WHERE grade IS NOT NULL;
-- 3. 커버링 인덱스 (SELECT에 필요한 모든 컬럼 포함)
CREATE INDEX IX_Enrollment_Covering
ON Student_Subject_Enrollment(student_id, subject_id)
INCLUDE (enrollment_date, grade, status);
2. 효율적인 쿼리 작성
-- ❌ 비효율적인 쿼리 (N+1 문제)
-- 각 학생마다 별도 쿼리 실행
SELECT student_name FROM Students;
-- 각 학생에 대해 별도로
SELECT subject_name FROM Subjects s
JOIN Student_Subject_Enrollment e ON s.subject_id = e.subject_id
WHERE e.student_id = ?;
-- ✅ 효율적인 쿼리 (한 번의 JOIN으로 해결)
SELECT
s.student_name,
STRING_AGG(sub.subject_name, ', ') AS subjects
FROM Students s
LEFT JOIN Student_Subject_Enrollment e ON s.student_id = e.student_id
LEFT JOIN Subjects sub ON e.subject_id = sub.subject_id
GROUP BY s.student_id, s.student_name;
3. 페이징 처리
-- 대용량 데이터 페이징
DECLARE @PageSize INT = 20;
DECLARE @PageNumber INT = 1;
SELECT
s.student_name,
sub.subject_name,
e.grade,
e.status
FROM Students s
JOIN Student_Subject_Enrollment e ON s.student_id = e.student_id
JOIN Subjects sub ON e.subject_id = sub.subject_id
ORDER BY s.student_name, sub.subject_name
OFFSET (@PageNumber - 1) * @PageSize ROWS
FETCH NEXT @PageSize ROWS ONLY;
실무 활용 예제
1. 동적 권한 관리 시스템
-- 사용자의 모든 권한 조회 (상속된 권한 포함)
WITH UserPermissions AS (
-- 직접 할당된 권한
SELECT
u.user_id,
u.username,
r.role_name,
ura.permission_level,
'DIRECT' AS assignment_type
FROM Users u
JOIN User_Role_Assignment ura ON u.user_id = ura.user_id
JOIN Roles r ON ura.role_id = r.role_id
WHERE ura.is_active = 1
AND (ura.expiry_date IS NULL OR ura.expiry_date > GETDATE())
UNION ALL
-- 그룹을 통해 상속된 권한 (복잡한 계층 구조가 있다면)
SELECT
u.user_id,
u.username,
r.role_name,
ura.permission_level,
'INHERITED' AS assignment_type
FROM Users u
JOIN User_Group_Membership ugm ON u.user_id = ugm.user_id
JOIN Group_Role_Assignment gra ON ugm.group_id = gra.group_id
JOIN User_Role_Assignment ura ON gra.role_id = ura.role_id
JOIN Roles r ON ura.role_id = r.role_id
WHERE ura.is_active = 1
)
SELECT
username,
STRING_AGG(role_name + '(' + assignment_type + ')', ', ') AS roles,
MAX(permission_level) AS max_permission_level
FROM UserPermissions
WHERE user_id = 1
GROUP BY user_id, username;
2. 추천 시스템 기반 쿼리
-- 비슷한 수강 패턴을 가진 학생들 찾기
WITH StudentSubjects AS (
SELECT
student_id,
STRING_AGG(CAST(subject_id AS VARCHAR), ',') AS subject_pattern
FROM Student_Subject_Enrollment
WHERE status = 'COMPLETED'
GROUP BY student_id
),
SimilarStudents AS (
SELECT
s1.student_id AS student1,
s2.student_id AS student2,
s1.subject_pattern,
s2.subject_pattern
FROM StudentSubjects s1
JOIN StudentSubjects s2 ON s1.student_id < s2.student_id
WHERE s1.subject_pattern = s2.subject_pattern
)
SELECT
st1.student_name AS student1_name,
st2.student_name AS student2_name,
ss.subject_pattern
FROM SimilarStudents ss
JOIN Students st1 ON ss.student1 = st1.student_id
JOIN Students st2 ON ss.student2 = st2.student_id;
주의사항 및 베스트 프랙티스
1. 데이터 무결성 보장
-- ✅ 적절한 제약 조건 설정
ALTER TABLE Student_Subject_Enrollment
ADD CONSTRAINT CK_Grade_Valid
CHECK (grade IN ('A+', 'A', 'B+', 'B', 'C+', 'C', 'D+', 'D', 'F') OR grade IS NULL);
ALTER TABLE Student_Subject_Enrollment
ADD CONSTRAINT CK_Status_Valid
CHECK (status IN ('ENROLLED', 'COMPLETED', 'DROPPED', 'WITHDRAWN'));
-- ✅ 트리거를 이용한 비즈니스 로직 적용
CREATE TRIGGER TR_Enrollment_BusinessLogic
ON Student_Subject_Enrollment
AFTER INSERT, UPDATE
AS
BEGIN
-- 한 학생이 같은 학기에 너무 많은 과목을 수강하는 것 방지
IF EXISTS (
SELECT student_id
FROM inserted i
JOIN Student_Subject_Enrollment e ON i.student_id = e.student_id
JOIN Subjects s ON e.subject_id = s.subject_id
WHERE e.status = 'ENROLLED'
GROUP BY student_id
HAVING SUM(s.credits) > 21 -- 최대 21학점
)
BEGIN
RAISERROR('학생이 수강할 수 있는 최대 학점을 초과했습니다.', 16, 1);
ROLLBACK;
END
END;
2. 삭제 처리
-- ✅ 소프트 삭제 패턴
ALTER TABLE Student_Subject_Enrollment
ADD is_deleted BIT DEFAULT 0,
deleted_at DATETIME2 NULL,
deleted_by INT NULL;
-- 삭제 시 실제로는 플래그만 변경
UPDATE Student_Subject_Enrollment
SET is_deleted = 1,
deleted_at = GETDATE(),
deleted_by = @current_user_id
WHERE enrollment_id = @target_id;
-- 조회 시 삭제된 레코드 제외
SELECT s.student_name, sub.subject_name, e.grade
FROM Students s
JOIN Student_Subject_Enrollment e ON s.student_id = e.student_id
JOIN Subjects sub ON e.subject_id = sub.subject_id
WHERE e.is_deleted = 0; -- 삭제되지 않은 것만 조회
마무리
다대다 관계는 복잡해 보이지만, 중간 테이블을 올바르게 설계하고 적절한 JOIN을 사용하면 효과적으로 관리할 수 있습니다.
핵심 포인트 요약
- 중간 테이블 필수: 다대다 관계는 반드시 중간 테이블로 구현
- 적절한 제약 조건: 외래 키와 고유 키 제약으로 데이터 무결성 보장
- 인덱스 최적화: 자주 조회되는 컬럼 조합에 인덱스 생성
- 비즈니스 로직 반영: 중간 테이블에 추가 속성으로 복잡한 요구사항 처리
- 성능 고려: 대용량 데이터에서는 적절한 페이징과 쿼리 최적화 필요
이 가이드를 참고하여 다대다 관계를 효과적으로 설계하고 활용해보세요!