Skip to main content
Summary

관계형 데이터베이스는 데이터를 표로만 보지 않는다

관계형 데이터베이스를 처음 배울 때 가장 흔한 설명은 "데이터를 테이블에 저장한다"는 말이다. 틀린 말은 아니지만 충분하지 않다. 관계형 데이터베이스의 핵심은 데이터를 정해진 구조와 관계에 따라 저장하고, 그 관계를 기준으로 일관성 있게 조회·변경한다는 데 있다.

테이블은 같은 종류의 데이터를 담는 구조다. users 테이블은 사용자, orders 테이블은 주문, payments 테이블은 결제 데이터를 담는다. 테이블의 열은 속성이다. 예를 들어 사용자의 이메일, 주문의 상태, 결제 금액이 열이다. 행은 실제 데이터 한 건이다. 사용자 한 명, 주문 한 건, 결제 한 건이 각각 행이 된다.

여기서 중요한 것은 각 행을 어떻게 구분하느냐다. 기본 키는 테이블 안에서 한 행을 유일하게 식별한다. 보통 id 같은 값을 사용한다.

CREATE TABLE users (
  id BIGINT PRIMARY KEY,
  email VARCHAR(255) NOT NULL UNIQUE,
  name VARCHAR(100) NOT NULL
);

id는 사용자를 식별하는 기준이고, email은 중복되면 안 되는 업무 속성이다. 이메일도 사용자를 구분할 수 있어 보이지만, 사용자가 이메일을 바꿀 수 있다면 기본 키로는 위험하다. 다른 테이블에서 사용자를 참조할 때 기준값이 흔들리기 때문이다.

외래 키는 다른 테이블의 기본 키를 참조하는 열이다. 주문은 특정 사용자에게 속하므로 orders.user_id는 users.id를 참조할 수 있다. 예제에서는 주문 금액도 함께 저장한다고 가정해 보자.

CREATE TABLE orders (
  id BIGINT PRIMARY KEY,
  user_id BIGINT NOT NULL,
  status VARCHAR(30) NOT NULL,
  total_amount DECIMAL(12, 2) NOT NULL,
  ordered_at TIMESTAMP NOT NULL,
  FOREIGN KEY (user_id) REFERENCES users(id)
);

외래 키는 단순한 연결 정보가 아니다. 존재하지 않는 사용자에게 주문이 생기는 일을 막는 안전장치다. 애플리케이션 코드에서 "먼저 사용자를 조회한 뒤 주문을 저장하면 되지 않나?"라고 생각할 수 있지만, 여러 서버와 배치 작업, 운영자 도구가 같은 데이터베이스를 건드리는 환경에서는 데이터베이스 수준의 제약 조건이 마지막 방어선이 된다.

관계형 데이터베이스와 SQL의 핵심AI 생성 이미지

SELECT는 적는 순서와 처리 순서가 다르다

SQL은 사람이 읽기 좋은 순서로 작성한다. 하지만 데이터베이스가 논리적으로 처리하는 순서는 다르다. 이 차이를 이해해야 복잡한 조회에서 실수를 줄일 수 있다.

예를 들어 사용자별 완료 주문 수와 총 주문 금액을 조회한다고 하자.

SELECT
  u.id,
  u.email,
  COUNT(o.id) AS order_count,
  SUM(o.total_amount) AS total_amount
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE o.status = 'COMPLETED'
GROUP BY u.id, u.email
HAVING COUNT(o.id) >= 3
ORDER BY total_amount DESC;

작성 순서는 SELECT → FROM → WHERE → GROUP BY → HAVING → ORDER BY처럼 보인다. 하지만 SQL의 의미를 이해할 때는 대략 다음 순서로 생각하는 편이 좋다.

  1. FROM과 JOIN으로 조회할 데이터 집합을 만든다.

  2. WHERE로 행을 먼저 걸러낸다.

  3. GROUP BY로 남은 행을 그룹으로 묶는다.

  4. HAVING으로 그룹을 걸러낸다.

  5. SELECT로 출력할 값을 계산한다.

  6. ORDER BY로 결과를 정렬한다.

실제 데이터베이스 내부에서는 옵티마이저가 인덱스와 통계 정보를 보고 더 효율적인 실행 계획을 선택할 수 있다. 따라서 위 순서는 물리적으로 항상 그대로 실행된다는 뜻이라기보다, SQL이 어떤 의미로 해석되는지 이해하기 위한 논리적 순서에 가깝다.

이 순서를 모르면 WHERE COUNT(o.id) >= 3처럼 잘못된 쿼리를 쓰기 쉽다. WHERE는 아직 그룹이 만들어지기 전의 행을 필터링한다. 반면 COUNT 같은 집계 결과는 GROUP BY 이후에 생긴다. 그래서 집계 조건은 HAVING에 둔다.

WHERE와 HAVING의 차이는 성능에도 영향을 준다. 주문 상태가 완료인 행만 필요하다면 WHERE o.status = 'COMPLETED'로 먼저 줄이는 편이 낫다. 모든 주문을 그룹으로 묶은 뒤에 집계 조건을 따지면 처리할 데이터가 불필요하게 커질 수 있다.

JOIN은 관계를 결과 행으로 펼치는 작업이다

관계형 데이터베이스에서 데이터는 보통 여러 테이블로 나뉜다. 사용자 정보는 users, 주문 정보는 orders, 결제 정보는 payments에 둔다. JOIN은 이 나뉜 데이터를 필요한 모양으로 다시 합치는 작업이다.

가령 다음과 같은 관계를 생각해 보자.

  • 사용자 1명은 주문을 여러 개 가질 수 있다.

  • 주문 1건은 결제를 가질 수도 있고, 아직 결제가 없을 수도 있다.

  • 결제는 특정 주문에 속한다.

INNER JOIN: 양쪽에 모두 있는 데이터만 본다

SELECT u.email, o.id AS order_id
FROM users u
JOIN orders o ON o.user_id = u.id;

INNER JOIN은 양쪽 조건이 맞는 행만 반환한다. 주문이 없는 사용자는 결과에 나오지 않는다. "주문한 사용자 목록"처럼 관계가 실제로 존재하는 데이터만 필요할 때 적합하다.

결제까지 붙이면 의미가 더 좁아진다.

SELECT u.email, o.id AS order_id, p.paid_at
FROM users u
JOIN orders o ON o.user_id = u.id
JOIN payments p ON p.order_id = o.id;

이 쿼리는 사용자, 주문, 결제가 모두 연결된 데이터만 보여준다. 아직 결제되지 않은 주문은 빠진다. 결제 완료 주문 리포트라면 맞지만, 미결제 주문 관리 화면이라면 잘못된 선택이다.

LEFT JOIN: 왼쪽 데이터는 유지하고, 없으면 NULL로 채운다

SELECT u.email, o.id AS order_id
FROM users u
LEFT JOIN orders o ON o.user_id = u.id;

LEFT JOIN은 왼쪽 테이블의 행을 유지한다. 사용자가 주문을 하지 않았더라도 사용자 행은 결과에 남고, 주문 관련 열은 NULL이 된다. "전체 사용자와 각 사용자의 주문 여부"를 보고 싶을 때 필요하다.

결제가 없는 주문을 찾을 때도 자주 사용한다.

SELECT o.id AS order_id
FROM orders o
LEFT JOIN payments p ON p.order_id = o.id
WHERE p.id IS NULL;

이 쿼리는 주문은 있지만 결제 행이 없는 데이터를 찾는다. 운영에서 미결제 주문, 누락된 정산 데이터, 연결이 끊긴 참조를 찾을 때 자주 쓰는 패턴이다.

다만 LEFT JOIN을 쓸 때 가장 흔한 실수가 있다.

SELECT u.email, o.id AS order_id
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE o.status = 'COMPLETED';

이 쿼리는 사실상 INNER JOIN처럼 동작한다. 주문이 없는 사용자는 o.status가 NULL이고, WHERE o.status = 'COMPLETED' 조건을 통과하지 못하기 때문이다. 왼쪽 데이터를 유지하면서 완료 주문만 붙이고 싶다면 조건을 ON 절에 두는 편이 의도에 가깝다.

SELECT u.email, o.id AS completed_order_id
FROM users u
LEFT JOIN orders o
  ON o.user_id = u.id
 AND o.status = 'COMPLETED';

이렇게 쓰면 주문이 없는 사용자도 결과에 남는다. 다만 완료 주문이 없는 사용자의 주문 관련 열은 NULL이 된다. 즉, LEFT JOIN에서는 조건을 ON에 두는지 WHERE에 두는지에 따라 결과의 의미가 달라질 수 있다.

RIGHT JOIN, FULL OUTER JOIN, CROSS JOIN은 언제 보나

RIGHT JOIN은 오른쪽 테이블을 유지한다. 대부분의 경우 테이블 순서를 바꿔 LEFT JOIN으로 표현할 수 있어 실무에서는 상대적으로 덜 쓰인다.

FULL OUTER JOIN은 양쪽 중 어느 한쪽에만 있는 데이터도 모두 보여준다. 데이터 이관 후 원본과 대상의 차이를 비교하거나, 두 시스템 간 누락 데이터를 찾을 때 유용하다. 다만 모든 DBMS가 같은 방식으로 지원하지는 않는다.

CROSS JOIN은 모든 조합을 만든다. 사용자 1,000명과 쿠폰 100개를 CROSS JOIN하면 100,000행이 생긴다. 의도한 조합 생성에는 쓸 수 있지만, 실수로 발생하면 성능 문제로 이어지기 쉽다.

INSERT, UPDATE, DELETE는 제약 조건 안에서 실행된다

데이터 조회가 SELECT라면, 데이터 변경은 INSERT, UPDATE, DELETE가 맡는다. 이 세 문장은 단순히 값을 넣고 바꾸고 지우는 명령처럼 보이지만, 실제로는 제약 조건과 트랜잭션의 영향을 받는다.

INSERT는 새 행을 추가한다.

INSERT INTO orders (id, user_id, status, total_amount, ordered_at)
VALUES (1001, 10, 'CREATED', 30000.00, CURRENT_TIMESTAMP);

이때 user_id = 10인 사용자가 users 테이블에 없다면 외래 키 제약 조건 때문에 실패할 수 있다. status나 total_amount가 NOT NULL인데 값을 넣지 않아도 실패한다. 이미 같은 기본 키가 있어도 실패한다. 실패는 불편한 일이 아니라 잘못된 데이터가 들어오는 것을 막는 정상적인 동작이다.

UPDATE는 기존 행을 변경한다.

UPDATE orders
SET status = 'CANCELED'
WHERE id = 1001;

실무에서 UPDATE의 핵심은 WHERE다. 조건이 빠지면 모든 행이 바뀐다. 운영 데이터베이스에서 가장 위험한 실수 중 하나다. 중요한 변경은 먼저 같은 조건으로 SELECT를 실행해 대상 행을 확인하는 습관이 좋다.

DELETE도 마찬가지다.

DELETE FROM orders
WHERE id = 1001;

삭제 대상 행이 다른 테이블에서 참조되고 있다면 외래 키 제약 조건 때문에 실패할 수 있다. 예를 들어 결제 행이 orders.id를 참조하고 있는데 주문을 먼저 삭제하면 결제 데이터가 고아 데이터가 된다. 데이터베이스는 이런 삭제를 막을 수 있다.

변경 작업은 보통 트랜잭션 안에서 실행된다. 트랜잭션은 여러 변경을 하나의 작업 단위로 묶는다.

BEGIN;

INSERT INTO orders (id, user_id, status, total_amount, ordered_at)
VALUES (1002, 10, 'CREATED', 30000.00, CURRENT_TIMESTAMP);

INSERT INTO payments (id, order_id, amount, paid_at)
VALUES (5001, 1002, 30000.00, CURRENT_TIMESTAMP);

COMMIT;

두 번째 INSERT가 실패했는데 첫 번째 주문만 남으면 시스템 상태가 어색해진다. 결제 생성까지 성공해야 주문을 확정하는 업무라면 두 작업은 함께 성공하거나 함께 실패해야 한다. 이런 판단이 트랜잭션 설계의 출발점이다.

NULL은 값이 아니라 "모름"에 가깝다

SQL에서 NULL은 빈 문자열이나 0이 아니다. 값이 없거나 아직 알 수 없다는 표시다. 이 차이를 가볍게 보면 버그가 생긴다.

가장 대표적인 실수는 비교다.

SELECT *
FROM users
WHERE deleted_at = NULL;

이 쿼리는 삭제되지 않은 사용자를 찾으려는 의도일 수 있지만, 제대로 동작하지 않는다. NULL은 =로 비교하지 않는다. IS NULL을 써야 한다.

SELECT *
FROM users
WHERE deleted_at IS NULL;

SQL의 조건 판단에는 TRUE, FALSE뿐 아니라 UNKNOWN이 있다. deleted_at = NULL 같은 비교는 참도 거짓도 아닌 알 수 없음이 되고, WHERE는 참인 행만 통과시키므로 결과에서 제외된다.

집계에서도 차이가 있다.

SELECT
  COUNT(*) AS row_count,
  COUNT(paid_at) AS paid_count
FROM payments;

COUNT(*)는 행 수를 센다. 반면 COUNT(paid_at)은 paid_at이 NULL이 아닌 행만 센다. 결제 시각이 아직 없는 행이 있다면 두 값은 달라진다. 평균을 구하는 AVG, 합계를 구하는 SUM도 보통 NULL을 제외하고 계산한다. 그래서 "평균 배송일"을 구할 때 배송 완료되지 않은 주문이 제외되는 것이 맞는지, 아니면 별도로 집계해야 하는지 업무 의미를 먼저 확인해야 한다.

NULL은 NOT IN에서도 자주 문제를 만든다. 앞의 orders.user_id처럼 NOT NULL 제약이 확실한 열이라면 위험이 줄어들지만, 실제 서비스에는 아직 담당자가 배정되지 않은 작업의 assignee_id처럼 NULL이 허용되는 열도 많다. 이런 열을 대상으로 NOT IN을 쓰면 결과가 예상과 달라질 수 있다.

SELECT *
FROM users
WHERE id NOT IN (SELECT assignee_id FROM tasks);

만약 tasks.assignee_id에 NULL이 섞여 있으면 비교 대상 중 알 수 없는 값이 포함된다. 이때 NOT IN 전체 판단이 흔들려 의도한 결과가 나오지 않을 수 있다. 이런 경우에는 NULL을 명시적으로 제외하거나, NOT EXISTS로 의도를 더 분명하게 표현하는 편이 안전하다.

SELECT *
FROM users u
WHERE NOT EXISTS (
  SELECT 1
  FROM tasks t
  WHERE t.assignee_id = u.id
);

실무 버그는 대개 작은 오해에서 시작된다. "값이 없으면 그냥 비교하면 되겠지", "주문이 없으면 0으로 나오겠지", "LEFT JOIN이니까 사용자는 다 나오겠지" 같은 추측이 실제 SQL의 규칙과 어긋날 때 문제가 된다.

ORM을 써도 SQL을 알아야 하는 이유

많은 애플리케이션은 ORM을 사용한다. ORM은 객체와 테이블 사이의 변환을 도와주고, 반복적인 SQL 작성을 줄여준다. 하지만 ORM이 SQL의 의미까지 대신 판단해 주지는 않는다.

예를 들어 사용자 목록 화면에서 주문 수를 함께 보여주려 할 때 ORM 코드 한 줄이 내부적으로는 사용자 수만큼 주문 조회를 반복할 수 있다. 반대로 JOIN과 GROUP BY를 적절히 사용하면 한 번의 쿼리로 가져올 수 있다. 어떤 방식이 맞는지는 데이터 양, 화면 요구사항, 인덱스, 캐시 여부에 따라 달라진다. 이 판단은 결국 SQL을 이해해야 가능하다.

관계형 데이터베이스와 SQL의 핵심은 문법 암기가 아니다. 테이블이 업무 개념을 어떻게 나누어 담는지, 키와 제약 조건이 정합성을 어떻게 지키는지, JOIN과 집계가 결과의 의미를 어떻게 바꾸는지 이해하는 것이다. 이 기반이 있어야 다음 단계인 스키마 설계, 인덱스, 트랜잭션, 동시성 문제를 제대로 다룰 수 있다.

책 목록

개발자가 알아야 할 데이터베이스 지식

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기