[SQL] JOIN 의 종류와 특징 그리고 작성방법
JOIN
JOIN 개요
조인은 서로 다른 테이블의 컬럼을 결합하여 결과 집합을 생성하는 기능. 데이터 필터링보다는 프로젝션(Projection)을 생성하는 데 주로 사용.
Projection (투영): 보고자 하는 속성만 추출 하여 부분 집합을 생성.
JOIN은 암묵적 또는 명시적으로 사용된다.
이 글에서는 JOIN 중 CROSS JOIN, INNER JOIN, OUTER JOIN, NATURAL JOIN, LATERAL JOIN 에 대해서 정리한다.
CROSS JOIN
개요
CROSS JOIN은 두 테이블 간의 카테시안 곱(Cartesian Product)을 생성하여, 모든 가능한 조합의 결과를 반환한다.
카테시안 곱: 가능한 모든 조합을 생성함.
특징
- 가장 기본적인 JOIN 유형.
- 일반적으로 자주 사용되지 않음.
- 암묵적이거나 의도치 않게 발생하는 경우가 있으므로 작동 방식을 이해하는 것이 중요.
작성 방식
다음과 같은 테이블을 JOIN 한다고 해보자.
erDiagram RANKS { string name int rank_value } SUITS { string name string symbol }
SQL 92 문법 (권장)
CROSS JOIN 키워드를 명시적으로 사용. 다른 JOIN 유형처럼 작성하여 가독성이 높음.
SELECT
ranks.name as rank,
suits.symbol as suit
FROM
ranks
CROSS JOIN
suits
ORDER BY
ranks.rank_value DESC,
suits.name ASC
Theta 스타일 구문 (SQL 86/89 방식)
FROM 절에서 여러 테이블을 나열하여 CROSS JOIN 수행. CROSS JOIN 키워드를 명시하지 않아도 동일한 결과 생성.
SELECT
ranks.name as rank,
suits.symbol as suit
FROM
ranks, suits
ORDER BY
ranks.rank_value DESC,
suits.name ASC
INNER JOIN
개요
두 테이블 간의 연관된 레코드만 반환하며, 필터링 역할을 한다. 카테시안 곱에서 시작해 특정 조건을 만족하는 레코드만 선택.
특징
- ON 절을 통해 부모 테이블과 자식 테이블의 관계를 정의.
- 보통 외래키와 함께 사용됨.
- CROSS JOIN과는 다르게 조인을 구성하는 방식을 정의해야함.
데이터 설정
다음과 같은 테이블을 JOIN 한다고 가정한다.
erDiagram direction LR POST { int id string title } POST_COMMENT { int id string review int post_id } POST ||--o{ POST_COMMENT : "post_id"
데이터는 다음과 같이 주어졌다고 하자.
POST 테이블
id | title |
---|---|
1 | Java |
2 | Hibernate |
3 | JPA |
POST_COMMENT 테이블
id | review | post_id |
---|---|---|
1 | Good | 1 |
2 | Excellent | 1 |
3 | Awesome | 2 |
작성 방식
기본 방식
Projection 을 수행하면서 동시에 댓글이 없는 게시물을 필터링 함.
SELECT
p.id AS post_id,
p.title AS post_title,
pc.review AS review
FROM post p
INNER JOIN post_comment pc ON pc.post_id = p.id
ORDER BY pc.id
theta 스타일 작성
SELECT
p.id AS post_id,
p.title AS post_title,
pc.review AS review
FROM post p, post_comment pc
WHERE pc.post_id = p.id
ORDER BY pc.id
옵티마이저를 통해 동일하게 동작하겠지만, 가독성이 떨어짐.
USING 절을 사용하여 작성
부모와 자식 테이블의 컬럼 이름이 동일한 경우 사용 가능. (잘 사용하지는 않는 방식)
SELECT
post_id,
title,
review
FROM post
INNER JOIN post_comment USING(post_id)
ORDER BY post_id, post_comment_id
ON과는 약간의 차이가 있음, SELECT *
를 사용하는 경우 결과가 다르게 나올 수 있음. (중복 컬럼이 1번만 나오게 됨.)
조회 결과
post_id | post_title | review |
---|---|---|
1 | Java | Good |
1 | Java | Excellent |
2 | Hibernate | Awesome |
Post id 3 JPA 는 comment 가 없어 조회되지 않았다. 부모와 연결된 자식 record 가 없을 경우, 조회가 되지 않는 것에 주의해야한다. 이럴 경우 left join 을 사용해야 한다.
NATURAL JOIN
두 테이블 간 동일한 이름을 가진 열을 기준으로 조인 조건을 암시적으로 생성.
특징 및 문제점
- 동일한 이름을 가진 모든 열이 조건에 포함 → 하나라도 일치하지 않을 경우 조회되지 않음.
- 테이블 스키마가 변경되었을 때, 암묵적인 조인 조건으로 인해 예상과 다르게 동작할 수 있음.
- join 대상에
NULL
이 들어가 있으면 사용 불가NULL
은 알 수 없는 상태라는 의미.NULL
값은NULL = NULL
로 비교되지 않으므로 매칭 실패.
작성 방법
다음과 같은 테이블을 JOIN 한다고 가정한다.
erDiagram direction LR POST { int id string title string locale } LOCALIZATION { string locale string country string language } POST ||--o{ LOCALIZATION : "locale"
SELECT
p.id AS post_id,
p.title AS post_title,
l.language AS post_language
FROM post p
NATURAL JOIN localization l
ORDER BY p.id
on
이나 using
을 사용하지 않아도 된다. locale 컬럼을 통해 join이 진행된다.
LEFT JOIN
개요
FROM 절의 왼쪽 테이블의 모든 데이터를 반환하며, 오른쪽 테이블의 조건과 일치하는 데이터는 컬럼으로 추가한다.
왼쪽 테이블은
LEFT JOIN
키워드 왼쪽에 명시된 테이블을 의미한다. 만약LEFT JOIN
이 한 번 진행된다면, 왼쪽 테이블은 FROM 절에 사용된 테이블이라고 이해하면 쉽다. 만약LEFT JOIN
이 여러번 진행된다면, 이전까지의 JOIN 결과가 왼쪽 테이블이 된다.
조건이 일치하지 않으면 결과 집합에서 해당 컬럼에는 NULL 값이 할당된다.
INNER JOIN과 달리, 연관된 데이터가 없는 경우에도 왼쪽 테이블의 모든 레코드가 포함된다.
작성 방법
데이터는 INNER JOIN 데이터 설정 과 동일하게 작성했다고 가정한다.
다음과 같이 작성한다.
SELECT
p.id AS post_id,
p.title AS post_title,
pc.review AS review
FROM post p
LEFT JOIN post_comment pc ON pc.post_id = p.id
ORDER BY p.id, pc.id
이 코드의 결과는 아래 코드의 결과와 동일하다.
SELECT
post_id,
post_title,
review
FROM (
SELECT
p.id AS post_id,
p.title AS post_title,
pc.review AS review,
pc.id AS pc_id
FROM post p
INNER JOIN post_comment pc ON pc.post_id = p.id
UNION ALL
SELECT
id AS post_id,
title AS post_title,
NULL AS review,
NULL AS pc_id
FROM post
WHERE id NOT IN (SELECT post_id FROM post_comment)
)
ORDER BY post_id, pc_id
결과
inner join 에서는 보지 못했던 JPA 를 확인할 수 있다. review 에는 null 이 들어간다.
post_id | post_title | review |
---|---|---|
1 | Java | Good |
1 | Java | Excellent |
2 | Hibernate | Awesome |
3 | JPA |
의도하지 않은 카테시안 곱 발생 주의
여러 테이블을 조인할 때, 관계가 명확하지 않으면 의도하지 않은 카테시안 곱이 발생할 수 있다. (CROSS JOIN을 사용하지 않았지만.)
다음과 같은 예시를 살펴보자.
erDiagram direction LR POST { int id string title } POST_COMMENT { int id string review int post_id } TAG { int id string name } POST_TAG { int post_id int tag_id } POST ||--o{ POST_COMMENT : "post_id" POST ||--o{ POST_TAG : "post_id" TAG ||--o{ POST_TAG : "tag_id"
여기서 테이블 간의 관계는 다음과 같다.
POST
와POST_COMMENT
의 사이는 일대다 관계이다.POST
와POST_TAG
의 사이는 일대다 관계이다.
이 상태에서 아래 쿼리를 실행하면 어떻게 될까?
SELECT
p.id AS post_id, p.title AS post_title,
pc.id AS comment_id, pc.review AS comment_review,
t.id AS tag_id, t.name AS tag_name
FROM post p
LEFT JOIN post_comment pc ON p.id = pc.post_id
LEFT JOIN post_tag pt ON p.id = pt.post_id
LEFT JOIN tag t ON pt.tag_id = t.id
ORDER BY pc.id, t.id
아래와 같이 post와 post_comment 만 LEFT JOIN 했을 때에는 문제가 없다.
FROM post p
LEFT JOIN post_comment pc ON p.id = pc.post_id
--LEFT JOIN post_tag pt ON p.id = pt.post_id
마찬가지로 post와 post_tag 만 LEFT JOIN 했을 때에도 문제가 없다.
FROM post p
-- LEFT JOIN post_comment pc ON p.id = pc.post_id
LEFT JOIN post_tag pt ON p.id = pt.post_id
하지만 문제는 둘을 같이 진행한다는 것이다. post_comment
는 post_tag
와 직접적인 연관이 없다. 서로 연관이 없기 때문에 둘을 동시에 LEFT JOIN
하면 카테시안 곱이 발생될 수 있다. 이러한 상황을 주의해야 한다.
RIGHT JOIN
RIGHT JOIN
은 오른쪽 테이블의 모든 데이터를 반환한다. 직관적이지 않아 잘 사용하지는 않는다.
아래와 같이 작성하면 동일한 결과를 얻을 수 있다.
SELECT
p.id AS post_id,
p.title AS post_title,
pc.review AS review
FROM post_comment pc
RIGHT JOIN post p ON pc.post_id = p.id
ORDER BY p.id, pc.id
FULL JOIN
FULL JOIN은 LEFT JOIN과 RIGHT JOIN의 조합으로, 두 테이블의 일치하지 않는 데이터까지 모두 포함한다.
데이터 설정
erDiagram direction LR POST { Long id String title Long category_id } CATEGORY { Long id String name } POST ||--o| CATEGORY : "category"
데이터는 다음과 같이 주어졌다고 하자.
POST 테이블
id | title | category_id |
---|---|---|
1 | 8 Java Stream Tips | 1 |
2 | 10 Hibernate Tips | 2 |
3 | 3 years of blogging |
CATEGORY 테이블
id | name |
---|---|
1 | Java |
2 | Hibernate |
3 | JPA |
작성방법
다음과 같이 사용할 수 있다.
SELECT
p.id AS post_id,
p.title AS post_title,
c.name AS category_name
FROM post p
FULL JOIN category c ON c.id = p.category_id
ORDER BY p.id, c.id
이 SQL의 결과는 아래 SQL의 결과와 동일하다.
SELECT post_id, post_title, category_name
FROM (
SELECT
p.id AS post_id, c.id AS category_id,
p.title AS post_title, c.name AS category_name
FROM post p
LEFT JOIN category c ON c.id = p.category_id
UNION
SELECT
p.id AS post_id, c.id AS category_id,
p.title AS post_title, c.name AS category_name
FROM post p
RIGHT JOIN category c ON c.id = p.category_id
) fj
ORDER BY post_id, category_id
MySQL, MariaDB 와 같이 FULL JOIN을 지원하지 않는 DB 에서는 위의 방법을 사용할 수 있다.
조회 결과
post_id | post_title | category_name |
---|---|---|
1 | 8 Java Stream Tips | Java |
2 | 10 Hibernate Tips | Hibernate |
3 | 3 years of blogging | |
JPA |
FULL JOIN과 카테시안 곱의 차이
FULL JOIN은 필터링된 데이터만 반환하며, 중복 없이 세 가지 유형의 데이터를 가져온다:
- 일치하는 레코드
- 왼쪽 테이블의 일치하지 않는 레코드
- 오른쪽 테이블의 일치하지 않는 레코드
반면, 카테시안 곱은 모든 가능한 조합을 반환하여 결과 레코드 수가 두 테이블의 레코드 수를 곱한 값이 된다. 예를들어 위 데이터 설정으로 카테시안 곱을 하면 3 * 3
으로 9개의 row 가 나오게 된다.
FULL JOIN은 어디에 사용할 수 있을까?
두 테이블 간 연관되지 않은 모든 레코드를 조회할 때 유용. (Disjunctive Union)
예를 들어, Post와 Tag 테이블에서 다음 두 데이터를 추출할 수 있음
- 태그가 없는 게시물
- 게시물과 연관되지 않은 태그
SELECT
p.title AS post_title,
t.name AS tag_name
FROM post p
FULL JOIN post_tag pt ON pt.post_id = p.id
FULL JOIN tag t ON pt.tag_id = t.id
WHERE
p.id IS NULL OR
t.id IS NULL
ORDER BY
p.id, t.id
LEFT JOIN 과 RIGHT JOIN을 따로 써서 추출할수도 있지만, FULL JOIN을 사용하면 한 번에 가능하다.
LATERAL JOIN
LATERAL JOIN
은 서브쿼리와 유사하게 작동하지만, 데이터를 처리하고 이를 다시 활용할 수 있도록 해주는 SQL 기능이다.
PostgreSQL, MySQL, Oracle, SQL Server 에서 지원하며, DB에 따라 문법에는 차이가 있다.
사용 사례 1 - 블로그 n 주년 기념일 계산
LATERAL JOIN
을 사용하면 동일한 계산을 여러 번 반복하지 않고 재사용할 수 있다.
예를 들어 블로그 ID, 나이(연도), 다음 기념일, 기념일까지 남은 일수를 계산해본다고 하면 다음과 같이 쿼리를 작성해볼 수 있다.
SELECT
b.id as blog_id,
extract(YEAR FROM age(now(), b.created_on)) AS age_in_years,
date(
created_on + (
extract(YEAR FROM age(now(), b.created_on)) + 1
) * interval '1 year'
) AS next_anniversary,
date(
created_on + (
extract(YEAR FROM age(now(), b.created_on)) + 1
) * interval '1 year'
) - date(now()) AS days_to_next_anniversary
FROM blog b
ORDER BY blog_id
위 SQL 을 보면
extract(YEAR FROM age(now(), b.created_on))
부분이 반복된다는 것을 볼 수 있다.
이러한 상황에서 LATERAL JOIN
을 적용하면 다음과 같이 개선할 수 있다.
SELECT
b.id as blog_id,
age_in_years,
date(created_on + (age_in_years + 1) * interval '1 year') AS next_anniversary,
date(created_on + (age_in_years + 1) * interval '1 year') - date(now()) AS days_to_next_anniversary
FROM blog b
CROSS JOIN LATERAL (
SELECT
cast(
extract(YEAR FROM age(now(), b.created_on)) AS int
) AS age_in_years
) AS t
ORDER BY blog_id
사용 사례 2 - JSON 데이터 처리
JSON 배열을 관계형 테이블처럼 변환:
SELECT
r.*
FROM
book
CROSS APPLY
OPENJSON (
JSON_QUERY(
properties, '$.reviews'
)
)
WITH (
reviewer VARCHAR(200) '$.reviewer',
review VARCHAR(4000) '$.review',
review_date DATETIME '$.date',
rating INT '$.rating'
) AS r
WHERE
isbn = '978-9730228236'
주의 할 점 (종합)
- 가능하면
select *
을 사용하는 것보다 직접 컬럼을 명시하는 방식으로 작성하는 것이 좋음- 데이터베이스가 제공하는 순서로 결과가 반환됨 → 의도와 다른 결과가 나올 수 있음.
null = null
은 성립하지 않음.null
은 알 수 없는 상태라는 의미. 따라서 SQL에서는IS NULL
과IS NOT NULL
키워드를 제공함.
마무리
인프런에서 열린 JPA (ORM) 개발자를 위한 고성능 SQL (High-Performance SQL) 강의를 듣기 시작하였다. 꾸준히 듣고 내용을 정리해서 올려보고자 한다.
이번 글에서는 데이터베이스의 핵심적인 기능 중 하나인 JOIN 에 대해 깊이 학습해보았다. 그동안 JOIN에 대해 어렴풋하게 알고 있긴 했지만, 종종 인터넷을 찾아보며 확인해야 했다. 이번 학습을 통해 JOIN 의 종류와 그 동작 방식을 명확히 이해하게 되었다.