박종훈 기술블로그

[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 테이블

idtitle
1Java
2Hibernate
3JPA

POST_COMMENT 테이블

idreviewpost_id
1Good1
2Excellent1
3Awesome2

작성 방식

기본 방식

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_idpost_titlereview
1JavaGood
1JavaExcellent
2HibernateAwesome

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_idpost_titlereview
1JavaGood
1JavaExcellent
2HibernateAwesome
3JPA 

의도하지 않은 카테시안 곱 발생 주의

여러 테이블을 조인할 때, 관계가 명확하지 않으면 의도하지 않은 카테시안 곱이 발생할 수 있다. (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"

여기서 테이블 간의 관계는 다음과 같다.

  • POSTPOST_COMMENT 의 사이는 일대다 관계이다.
  • POSTPOST_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_commentpost_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 테이블

idtitlecategory_id
18 Java Stream Tips1
210 Hibernate Tips2
33 years of blogging 

CATEGORY 테이블

idname
1Java
2Hibernate
3JPA

작성방법

다음과 같이 사용할 수 있다.

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_idpost_titlecategory_name
18 Java Stream TipsJava
210 Hibernate TipsHibernate
33 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 NULLIS NOT NULL 키워드를 제공함.

마무리

인프런에서 열린 JPA (ORM) 개발자를 위한 고성능 SQL (High-Performance SQL) 강의를 듣기 시작하였다. 꾸준히 듣고 내용을 정리해서 올려보고자 한다.

이번 글에서는 데이터베이스의 핵심적인 기능 중 하나인 JOIN 에 대해 깊이 학습해보았다. 그동안 JOIN에 대해 어렴풋하게 알고 있긴 했지만, 종종 인터넷을 찾아보며 확인해야 했다. 이번 학습을 통해 JOIN 의 종류와 그 동작 방식을 명확히 이해하게 되었다.