SQL 완전 가이드 — SELECT부터 JOIN·집계·트랜잭션·DDL까지

조회의 기본인 SELECT와 FROM에서 시작해 조건·정렬·조인·집계·서브쿼리를 거쳐 데이터 변경과 트랜잭션, 테이블 설계와 DDL까지 8개 단원을 한 편에 정리했습니다. 개념이 먼저 필요하면 데이터베이스 기초 완전 가이드를 먼저 읽으세요.

이 글의 순서

  1. SELECT와 FROM으로 값 꺼내기
  2. WHERE 조건과 연산자
  3. 정렬과 별칭
  4. JOIN으로 테이블 연결하기
  5. 집계와 그룹화
  6. 서브쿼리와 뷰
  7. 데이터 변경과 트랜잭션
  8. 테이블 설계와 DDL

SELECT와 FROM으로 값 꺼내기

여기서부터는 실제로 문장을 씁니다. SQL에서 가장 먼저, 그리고 가장 자주 쓰게 될 문장이 조회입니다. 이 글에서는 SELECTFROM이 하는 일, 값을 계산해서 꺼내는 법, 중복을 걷어내는 법, 그리고 데이터베이스가 문장을 받아 실행하기까지 무슨 일이 벌어지는지를 다룹니다.

SELECT와 FROM은 한 쌍입니다

조회 문장은 무엇을 볼지 적는 SELECT와 어디서 가져올지 적는 FROM으로 이루어집니다. 둘 중 하나라도 빠지면 문장이 성립하지 않습니다. 그리고 문장 끝에는 세미콜론을 찍어 여기서 끝났다고 알려 줍니다.

SELECT title, view_count
FROM   posts;

글 표에서 제목과 조회수 두 칸만 꺼내는 문장입니다. 결과는 요청한 칸만 남은 새 표 한 장이라고 생각하면 됩니다. 원래 표는 그대로 있고, 조회는 원본을 건드리지 않습니다.

전부 볼 때와 일부만 볼 때

모든 칸을 보고 싶으면 별표를 씁니다. 반대로 필요한 칸만 골라 적으면 그 칸만 나옵니다. 앞의 방식을 셀렉션, 뒤의 방식을 프로젝션이라고 부릅니다.

SELECT * FROM categories;          -- 모든 칸
SELECT category_name FROM categories;  -- 이름 칸만

적어 넣은 칸 이름의 순서가 곧 출력 순서입니다. 원본 표의 순서와 달라도 상관없습니다. 다만 실제 프로그램에서는 별표를 피하는 편이 낫습니다. 나중에 칸이 하나 추가되면 결과 모양이 조용히 바뀌어 버리기 때문입니다.

값을 계산해서 꺼내기

SELECT 자리에는 칸 이름뿐 아니라 계산식도 들어갈 수 있습니다. 저장된 값은 그대로 두고 화면에 나오는 값만 계산해 보여 주는 것입니다.

-- 조회수 1000회당 5000원으로 잡은 예상 수익
SELECT title,
       view_count,
       view_count / 1000 * 5000
FROM   posts;

사칙연산 기호를 그대로 쓰며 괄호로 순서를 지정할 수 있습니다. 여기서 주의할 점이 하나 있습니다. 계산에 NULL이 섞이면 결과 전체가 NULL이 됩니다. reward_rate처럼 비어 있을 수 있는 칸을 계산에 넣을 때는 빈 값을 대신 채워 주어야 합니다.

-- 표준 : COALESCE, 오라클 : NVL, MySQL : IFNULL
SELECT title, view_count * COALESCE(reward_rate, 0)
FROM   posts;

중복을 걷어내는 DISTINCT

어떤 회원들이 글을 썼는지만 알고 싶다면 회원번호가 반복해서 나올 필요가 없습니다. DISTINCT를 붙이면 같은 값이 여러 번 나올 때 한 번만 남습니다.

SELECT DISTINCT member_id
FROM   posts;

DISTINCT는 칸 하나가 아니라 적어 넣은 칸 전체의 조합을 기준으로 판단합니다. 두 칸을 적으면 그 두 값이 모두 같을 때만 중복으로 봅니다.

-- 회원번호와 분류번호의 조합 기준으로 중복 제거
SELECT DISTINCT member_id, category_id
FROM   posts;

중복 제거는 값을 전부 모아 비교해야 하므로 공짜가 아닙니다. 결과에 중복이 없을 것이 확실하다면 굳이 붙이지 않는 편이 빠릅니다.

문장이 실행되기까지

우리가 문장을 보내면 데이터베이스는 곧바로 값을 퍼 오지 않고 세 단계를 거칩니다. 먼저 적어 낸 칸이 정말 그 표에 있는지 확인하고, 다음으로 어떤 경로로 읽어 오는 것이 가장 빠른지 계획을 세우고, 그다음에야 실제로 읽습니다. 이 준비 과정을 파싱이라고 합니다.

파싱 결과는 잠시 보관해 두었다가, 똑같은 문장이 다시 들어오면 재사용합니다. 여기서 똑같다는 기준은 글자 단위로 완전히 같은가입니다. 대문자와 소문자가 다르면 다른 문장으로 취급해 계획을 새로 세웁니다. 그래서 팀에서는 표기를 통일해 두는데, 흔히 쓰는 관례는 이렇습니다.

  • 명령어는 대문자로 — SELECT, FROM, WHERE
  • 표와 칸 이름은 소문자로 — posts, view_count
  • 문자열 값은 작은따옴표로 — 'PRO'. 큰따옴표는 이름을 감쌀 때만 씁니다.

문장을 여러 줄로 나눠 적어도 됩니다. 줄바꿈과 공백은 실행에 영향을 주지 않으므로, 절마다 줄을 바꿔 왼쪽 끝을 맞춰 두면 훨씬 읽기 좋습니다. 이 강좌의 예제도 모두 그 방식으로 적었습니다. 다만 앞서 말한 재사용 규칙 때문에, 이미 잘 돌아가는 문장의 공백을 이유 없이 바꾸는 것은 득이 없습니다.

결과를 보면 자료형도 짐작할 수 있습니다. 대체로 숫자는 오른쪽, 문자와 날짜는 왼쪽으로 정렬되어 나옵니다. 이제 원하는 줄만 골라내는 방법을 원하는 행만 남기기에서 이어 보겠습니다.

핵심 정리

  • SELECT + FROM — 무엇을 어디서 가져올지 짝으로 적고 세미콜론으로 닫습니다.
  • 별표 대신 칸 이름 — 표 구조가 바뀌어도 결과가 흔들리지 않습니다.
  • 계산식 — 조회할 때 계산할 수 있지만 NULL이 섞이면 결과가 NULL이 됩니다.
  • DISTINCT — 적어 넣은 칸 조합 전체를 기준으로 중복을 없앱니다.
  • 파싱 — 검증·계획·실행 순서로 처리되며, 글자가 같아야 계획을 재사용합니다.

WHERE 조건과 연산자

표에 글이 수천 개 쌓이면 전부 꺼내 보는 일은 의미가 없습니다. 필요한 것은 조건에 맞는 몇 줄뿐입니다. 이 글에서는 조건을 적는 자리인 WHERE와, 그 안에서 쓸 수 있는 연산자들을 하나씩 살펴봅니다.

WHERE는 FROM 다음에 옵니다

조건은 어디서 가져올지 정한 다음에 붙입니다. 순서를 바꿔 적으면 문법 오류가 납니다.

SELECT title, view_count
FROM   posts
WHERE  view_count >= 1000;

조회수가 1000 이상인 글만 남깁니다. 조건이 참인 줄만 결과에 들어오고, 나머지는 아예 없는 것처럼 취급됩니다.

비교할 값의 종류에 따라 적는 방법이 다릅니다. 숫자는 그대로 적고, 문자와 날짜는 작은따옴표로 감쌉니다. 문자는 대소문자를 구분하므로 'PRO''pro'는 다른 값입니다.

WHERE grade = 'PRO'                -- 문자
WHERE published_on >= DATE '2026-01-01'  -- 날짜

조건을 여러 개 묶기

AND는 둘 다 참일 때, OR은 둘 중 하나만 참이어도 통과시킵니다. NOT은 조건 앞에 붙여 결과를 뒤집습니다.

SELECT title, view_count, category_id
FROM   posts
WHERE  category_id = 3
  AND  view_count > 500;

둘을 섞어 쓸 때는 괄호를 꼭 넣는 편이 안전합니다. ANDOR보다 먼저 묶이기 때문에, 괄호 없이 적으면 의도와 다르게 해석되는 일이 흔합니다.

-- 분류가 3 또는 4이면서 조회수가 500 초과
WHERE (category_id = 3 OR category_id = 4)
  AND view_count > 500;

범위와 목록 — BETWEEN, IN

어떤 구간에 드는 값을 찾을 때는 BETWEEN을 씁니다. 양 끝값을 포함하므로 아래 두 문장은 같은 결과를 냅니다.

WHERE view_count BETWEEN 500 AND 1500;
WHERE view_count >= 500 AND view_count <= 1500;

정해진 몇 개 값 중 하나인지 볼 때는 IN이 편합니다. OR을 늘어놓는 것보다 짧고 읽기 좋습니다. 앞에 NOT을 붙이면 그 값들만 빼고 가져옵니다.

WHERE category_id IN (2, 3, 5);
WHERE grade NOT IN ('BASIC');

문자열 패턴 찾기 — LIKE

제목에 특정 단어가 들어간 글을 찾는 것처럼 일부만 일치하는 경우에는 LIKE를 씁니다. 두 가지 기호를 조합해 씁니다. 퍼센트 기호는 글자 수에 상관없는 아무 문자열을, 밑줄은 정확히 한 글자를 뜻합니다.

패턴 찾는 것
'파이썬%' 파이썬으로 시작하는 제목
'%정리' 정리로 끝나는 제목
'%카드뉴스%' 카드뉴스가 어디든 들어간 제목
'___편' 세 글자 뒤에 편이 오는 제목
SELECT title
FROM   posts
WHERE  title LIKE '%카드뉴스%';

여러 조건을 동시에 만족시켜야 한다면 LIKEAND로 이어 붙이면 됩니다. 다만 앞머리에 퍼센트를 붙인 패턴은 색인을 활용하지 못해 느립니다. 글이 많아지면 검색 전용 기능을 따로 쓰는 편이 낫습니다.

NULL은 등호로 비교할 수 없습니다

값이 비어 있는 줄을 찾겠다고 reward_rate = NULL이라고 적으면 아무것도 나오지 않습니다. NULL은 값이 아니라 값을 모른다는 상태이므로, 모르는 것과 모르는 것을 같다고 판정할 수 없기 때문입니다.

-- 제휴 수익률이 설정되지 않은 글
SELECT title
FROM   posts
WHERE  reward_rate IS NULL;

-- 설정된 글
WHERE  reward_rate IS NOT NULL;

부정형에서 NOT의 위치가 다른 연산자와 다르다는 점도 눈여겨봐야 합니다. NOT IN이나 NOT BETWEENNOT이 앞에 오지만, NULL 비교에서는 IS NOT NULL처럼 가운데에 들어갑니다. 조건까지 익혔으니 이제 결과를 보기 좋게 다듬는 정렬과 별칭으로 넘어가겠습니다.

핵심 정리

  • WHERE의 자리 — FROM 다음에 오며, 조건이 참인 줄만 남깁니다.
  • AND와 OR — AND가 먼저 묶이므로 섞어 쓸 때는 괄호를 넣습니다.
  • BETWEEN과 IN — 구간과 목록을 짧게 표현하고, NOT을 앞에 붙여 뒤집습니다.
  • LIKE — 퍼센트는 임의 길이, 밑줄은 한 글자를 뜻합니다.
  • NULL 비교 — 등호가 아니라 IS NULL, IS NOT NULL을 씁니다.

정렬과 별칭

릴레이션에서 줄의 순서는 의미가 없다고 했습니다. 그래서 원하는 차례로 보고 싶다면 조회할 때 직접 지정해야 합니다. 이 글에서는 결과를 정렬하는 ORDER BY, 칸에 다른 이름을 붙이는 별칭, 그리고 상위 몇 개만 뽑아내는 방법을 다룹니다.

ORDER BY는 문장의 맨 끝

정렬 지시는 조회 문장에서 가장 마지막에 붙습니다. 기준으로 삼을 칸 이름을 적고, 오름차순이면 ASC, 내림차순이면 DESC를 붙입니다. ASC는 기본값이라 생략해도 됩니다.

SELECT title, view_count
FROM   posts
ORDER  BY view_count DESC;

많이 읽힌 글부터 나옵니다. 실무에서는 오름차순일 때도 ASC를 적어 두는 사람이 많습니다. 생략하면 나중에 읽는 사람이 의도인지 실수인지 구분하기 어렵기 때문입니다.

기준을 여러 개 두기

쉼표로 이어 붙이면 앞의 기준이 같을 때 뒤의 기준으로 다시 정렬합니다. 기준마다 방향을 따로 정할 수 있습니다.

-- 분류 번호는 작은 순서로, 같은 분류 안에서는 조회수 많은 순서로
SELECT category_id, title, view_count
FROM   posts
ORDER  BY category_id ASC, view_count DESC;

순서를 적는 대신 SELECT에 나열한 번호로 지정할 수도 있습니다. ORDER BY 3 DESC는 세 번째 칸을 기준으로 삼으라는 뜻입니다. 짧기는 하지만 나중에 칸을 하나 끼워 넣으면 엉뚱한 기준이 되므로, 계산식이 길어 이름을 적기 곤란할 때만 쓰는 편이 안전합니다.

NULL은 어디에 놓이는가

비어 있는 값이 섞여 있으면 그 줄을 어느 쪽 끝에 둘지가 문제가 됩니다. 여기서 제품 차이가 나옵니다. 오라클은 오름차순에서 NULL을 맨 뒤로 보내고, MySQL은 맨 앞에 둡니다.

-- 표준 : 위치를 직접 지정
SELECT title, reward_rate
FROM   posts
ORDER  BY reward_rate DESC NULLS LAST;

NULLS LASTNULLS FIRST는 오라클과 PostgreSQL에서 쓸 수 있습니다. MySQL에서는 지원하지 않으므로 정렬 기준을 하나 더 만들어 흉내 냅니다. 순위표처럼 사람이 직접 보는 화면이라면 이 부분을 명시해 두는 편이 좋습니다.

별칭 — 칸에 읽기 좋은 이름 붙이기

계산식을 그대로 꺼내면 결과의 머리글이 view_count/1000*5000처럼 지저분하게 나옵니다. AS를 붙여 다른 이름을 줄 수 있습니다.

SELECT title,
       view_count / 1000 * 5000 AS est_revenue,
       published_on             AS 발행일
FROM   posts
ORDER  BY est_revenue DESC;

AS는 생략해도 되지만 적어 두는 편이 눈에 잘 띕니다. 이름에 공백이나 한글, 대소문자 구분이 필요하면 큰따옴표로 감쌉니다. 문자열 값에 쓰는 작은따옴표와 헷갈리지 않도록 주의해야 합니다.

WHERE에서는 별칭을 쓸 수 없습니다

위 예제에서 ORDER BYest_revenue를 알아들었습니다. 그런데 같은 이름을 WHERE에 적으면 오류가 납니다. 이유는 처리 순서에 있습니다.

순서 하는 일
1 FROM 어느 표에서 읽을지 정합니다
2 WHERE 조건에 맞는 줄만 남깁니다
3 SELECT 칸을 고르고 별칭을 붙입니다
4 ORDER BY 남은 결과를 정렬합니다

별칭은 3번에서 생기는데 WHERE는 2번이라 아직 그 이름을 모릅니다. 반면 ORDER BY는 4번이라 이미 알고 있습니다. 이 순서는 집계와 그룹화 편에서 HAVING이 왜 필요한지 설명할 때 다시 쓰이므로 기억해 두면 좋습니다.

상위 몇 개만 뽑기

가장 많이 읽힌 글 다섯 개처럼 앞쪽 일부만 필요할 때가 많습니다. 이 기능은 제품마다 문법이 가장 크게 갈리는 부분입니다.

-- MySQL, PostgreSQL
SELECT title FROM posts ORDER BY view_count DESC LIMIT 5;

-- 표준 SQL, 오라클 12c 이상
SELECT title FROM posts ORDER BY view_count DESC
FETCH FIRST 5 ROWS ONLY;

정렬을 먼저 하고 자르는 것이 핵심입니다. 자르고 나서 정렬하면 전체에서 상위 다섯 개가 아니라 아무 다섯 줄을 정렬한 결과가 나옵니다.

핵심 정리

  • ORDER BY — 문장 맨 끝에 붙으며 기본값은 오름차순입니다.
  • 복수 기준 — 쉼표로 잇고 기준마다 방향을 따로 정합니다.
  • NULL 위치 — 제품마다 다르므로 필요하면 직접 지정합니다.
  • 별칭 — ORDER BY에서는 쓸 수 있지만 WHERE에서는 쓸 수 없습니다.
  • 상위 N개 — LIMIT 또는 FETCH FIRST를 쓰되 정렬을 먼저 해야 합니다.

JOIN으로 테이블 연결하기

정규화를 거치면 데이터가 여러 표로 흩어집니다. 글 목록에는 회원번호만 있고 닉네임은 회원 표에 있습니다. 이 둘을 한 화면에 같이 보여 주려면 표를 이어 붙여야 하는데, 그 작업이 조인입니다. 이 글에서는 조인의 종류와 각각을 언제 쓰는지 정리합니다.

조인이 없으면 어떻게 되는가

표 두 개를 조건 없이 나열하면 모든 줄이 모든 줄과 짝지어집니다. 글이 100개, 회원이 30명이면 3000줄이 나옵니다. 이 결과를 카티션 곱이라고 하는데, 대개는 조인 조건을 빠뜨렸다는 신호입니다.

-- 조건이 없어 100 x 30 = 3000줄이 나옵니다
SELECT p.title, m.nickname
FROM   posts p, members m;

여기서 pm은 표에 붙인 짧은 별명입니다. 조인에서는 어느 표의 칸인지 매번 밝혀야 하므로 별명을 붙여 두면 문장이 훨씬 짧아집니다.

일치하는 것끼리 잇기 — INNER JOIN

가장 많이 쓰는 형태입니다. 두 표에서 값이 서로 맞는 줄만 남깁니다. 표준 문법은 JOIN ... ON입니다.

SELECT p.title, m.nickname, p.view_count
FROM   posts p
JOIN   members m ON p.member_id = m.member_id
ORDER  BY p.view_count DESC;

같은 일을 WHERE로도 할 수 있습니다. FROM posts p, members m WHERE p.member_id = m.member_id처럼 씁니다. 오래된 코드에서 자주 보이는 방식인데, 조인 조건과 걸러내는 조건이 한자리에 섞여 읽기 어렵습니다. 새로 쓸 때는 ON 쪽을 권합니다.

표를 셋 이상 이을 때도 같은 방식으로 계속 붙여 나가면 됩니다.

SELECT c.category_name, p.title, m.nickname
FROM   posts p
JOIN   members m    ON p.member_id   = m.member_id
JOIN   categories c ON p.category_id = c.category_id;

짝이 없는 줄도 살리기 — OUTER JOIN

안쪽 조인에는 함정이 하나 있습니다. 분류를 아직 정하지 않아 category_id가 비어 있는 글은 결과에서 통째로 사라집니다. 짝이 없으니 이을 상대가 없기 때문입니다.

이럴 때 한쪽 표의 줄을 전부 남기고 싶다면 바깥 조인을 씁니다. 왼쪽 표를 다 남기면 LEFT JOIN입니다. 짝이 없는 자리는 NULL로 채워집니다.

SELECT p.title, c.category_name
FROM   posts p
LEFT   JOIN categories c ON p.category_id = c.category_id;
-- 분류가 없는 글도 나오고, category_name 자리는 NULL

방향을 뒤집으면 RIGHT JOIN이고, 양쪽 모두 남기면 FULL OUTER JOIN입니다. 오라클에는 등호 옆에 더하기 기호를 붙이는 고유 표기도 있지만, 표준 문법이 훨씬 읽기 쉬우므로 굳이 쓸 이유가 없습니다.

자기 자신과 잇기 — SELF JOIN

members에는 나를 추천한 회원을 가리키는 invited_by가 있었습니다. 추천인의 닉네임을 함께 보려면 같은 표를 두 번 불러 서로 다른 별명을 주면 됩니다.

SELECT me.nickname       AS 회원,
       inviter.nickname  AS 추천인
FROM   members me
LEFT   JOIN members inviter ON me.invited_by = inviter.member_id;

여기서 바깥 조인을 쓴 이유가 있습니다. 추천인 없이 직접 가입한 회원은 invited_by가 비어 있는데, 안쪽 조인을 쓰면 그 회원들이 명단에서 사라져 버립니다. 조직도나 댓글의 답글처럼 같은 표 안에서 위아래 관계가 생기는 구조는 모두 이 방식으로 다룹니다.

조인할 때 자주 하는 실수

실수는 대체로 세 가지 중 하나입니다.

  • 조건을 빠뜨림 — 결과 줄 수가 비정상적으로 많으면 카티션 곱을 의심합니다.
  • 줄 수가 불어남 — 한쪽에 짝이 여럿이면 그만큼 줄이 늘어납니다. 글 하나에 댓글이 다섯이면 그 글은 다섯 번 나옵니다. 개수만 필요하다면 조인 대신 집계를 쓰는 편이 맞습니다.
  • 바깥 조인에 조건을 잘못 붙임 — 남기려던 줄에 걸리는 조건을 WHERE에 적으면 그 줄이 다시 걸러집니다. 조인 대상에 거는 조건은 ON에 적어야 합니다.

핵심 정리

  • 카티션 곱 — 조인 조건을 빠뜨리면 모든 조합이 나옵니다.
  • INNER JOIN — 양쪽에 짝이 있는 줄만 남기며 가장 많이 씁니다.
  • OUTER JOIN — 한쪽을 통째로 남기고 빈자리는 NULL로 채웁니다.
  • SELF JOIN — 같은 표를 두 번 불러 다른 별명을 주면 됩니다.
  • 별명 — 어느 표의 칸인지 밝혀야 하므로 짧은 별명을 붙여 씁니다.

집계와 그룹화

지금까지는 저장된 줄을 그대로 꺼냈습니다. 그런데 실제로 궁금한 것은 개별 줄이 아니라 요약인 경우가 많습니다. 분류별 글이 몇 개인지, 회원별 조회수 합계가 얼마인지 같은 것들입니다. 이 글에서는 여러 줄을 하나로 접어 요약값을 만드는 집계 함수와 GROUP BY를 다룹니다.

여러 줄을 한 줄로 접는 함수

집계 함수는 여러 줄의 값을 받아 하나의 결과를 돌려줍니다. 자주 쓰는 것은 다섯 가지입니다.

함수 하는 일
COUNT 줄의 개수를 셉니다
SUM 값을 모두 더합니다
AVG 평균을 냅니다
MAX 가장 큰 값을 찾습니다
MIN 가장 작은 값을 찾습니다
SELECT COUNT(*)        AS 글수,
       SUM(view_count) AS 총조회수,
       AVG(view_count) AS 평균조회수,
       MAX(view_count) AS 최고조회수
FROM   posts;

표 전체를 하나로 접었기 때문에 결과는 한 줄만 나옵니다.

집계 함수는 NULL을 세지 않습니다

여기에 초보자가 가장 자주 걸리는 함정이 있습니다. 집계 함수는 값이 비어 있는 줄을 계산에서 빼 버립니다. 글이 100개인데 reward_rate가 채워진 글이 20개뿐이라면, 평균은 100이 아니라 20으로 나눈 값이 됩니다.

SELECT AVG(reward_rate)             AS 값이있는글만평균,
       AVG(COALESCE(reward_rate,0)) AS 전체글평균,
       COUNT(*)                     AS 전체줄수,
       COUNT(reward_rate)           AS 값이있는줄수
FROM   posts;

COUNT(*)는 줄 자체를 세므로 NULL이든 아니든 모두 포함합니다. 반면 칸 이름을 적은 COUNT(reward_rate)는 값이 있는 줄만 셉니다. 둘의 차이를 이용하면 비어 있는 줄이 몇 개인지 바로 알 수 있습니다.

어느 쪽이 맞는 평균인지는 상황에 따라 다릅니다. 제휴를 건 글만의 평균을 보고 싶으면 첫 번째가 맞고, 전체 글 기준 평균이라면 빈 값을 0으로 채운 두 번째가 맞습니다. 무엇을 세고 있는지 스스로 분명히 해 두어야 합니다.

GROUP BY — 묶어서 따로따로 세기

전체가 아니라 분류별로 나눠 세고 싶다면 기준이 될 칸을 GROUP BY에 적습니다. 같은 값을 가진 줄끼리 묶이고, 묶음마다 결과가 한 줄씩 나옵니다.

SELECT category_id,
       COUNT(*)        AS 글수,
       SUM(view_count) AS 총조회수
FROM   posts
GROUP  BY category_id
ORDER  BY 총조회수 DESC;

여기에 반드시 지켜야 할 규칙이 하나 있습니다. SELECT에 적은 칸 가운데 집계 함수로 감싸지 않은 것은 전부 GROUP BY에도 적어야 합니다. 묶음 하나에 제목이 여러 개인데 그중 무엇을 보여 줄지 정할 수 없기 때문입니다.

-- 오류 : title 은 묶음마다 여러 개라 하나로 정할 수 없습니다
SELECT category_id, title, COUNT(*)
FROM   posts
GROUP  BY category_id;

기준을 둘 이상 적으면 그 조합마다 묶입니다. 분류와 회원을 함께 적으면 어느 분류에 어느 회원이 몇 개를 썼는지 나옵니다.

묶음에 조건을 걸 때는 HAVING

글이 다섯 개를 넘는 분류만 보고 싶다고 해 보겠습니다. WHERECOUNT(*) > 5라고 적으면 오류가 납니다. 정렬과 별칭 편에서 본 처리 순서를 떠올리면 이유가 분명합니다. WHERE는 묶기 전에 실행되므로 그 시점에는 개수라는 것이 아직 존재하지 않습니다.

묶은 뒤에 거는 조건은 HAVING에 적습니다.

SELECT category_id, COUNT(*) AS 글수
FROM   posts
WHERE  published_on >= DATE '2026-01-01'   -- 묶기 전 : 줄을 고름
GROUP  BY category_id
HAVING COUNT(*) > 5                        -- 묶은 뒤 : 묶음을 고름
ORDER  BY 글수 DESC;

두 절은 경쟁 관계가 아니라 역할이 다릅니다. 위 문장은 올해 발행된 글만 대상으로 삼고, 그중 다섯 개를 넘는 분류만 남깁니다. 가능하면 WHERE에서 미리 줄여 두는 편이 성능에도 유리합니다.

전체 처리 순서 정리

이제 조회 문장의 절이 거의 다 나왔습니다. 우리가 적는 순서와 실제 처리 순서는 다릅니다.

적는 순서 처리 순서
SELECT 5
FROM / JOIN 1
WHERE 2
GROUP BY 3
HAVING 4
ORDER BY 6

이 표 하나면 별칭을 어디서 쓸 수 있는지, HAVING이 왜 필요한지가 전부 설명됩니다. 다음은 문장 안에 또 다른 문장을 넣는 서브쿼리와 뷰입니다.

핵심 정리

  • 집계 함수 — COUNT·SUM·AVG·MAX·MIN이 여러 줄을 한 줄로 접습니다.
  • NULL 제외 — COUNT(*)는 줄을 세고, COUNT(칸)은 값이 있는 줄만 셉니다.
  • GROUP BY — 집계로 감싸지 않은 칸은 반드시 여기에도 적어야 합니다.
  • HAVING — 묶은 뒤의 조건, WHERE는 묶기 전의 조건입니다.
  • 처리 순서 — FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY

서브쿼리와 뷰

평균보다 조회수가 높은 글을 찾으려면 먼저 평균을 알아야 합니다. 문장을 두 번 실행해 눈으로 옮겨 적을 수도 있지만, 평균은 매일 달라지므로 그 방법은 오래가지 못합니다. 답은 문장 안에 문장을 넣는 것입니다. 이 글에서는 서브쿼리와, 그것을 이름 붙여 재사용하는 뷰를 다룹니다.

문장 안의 문장

서브쿼리는 괄호로 감싼 조회 문장이며, 바깥 문장이 쓸 값을 대신 만들어 줍니다. 안쪽이 먼저 실행되어 값이 정해지고, 바깥은 그 값을 받아 마치 처음부터 적혀 있던 것처럼 씁니다.

SELECT title, view_count
FROM   posts
WHERE  view_count > (SELECT AVG(view_count) FROM posts);

안쪽이 평균 820을 돌려주면 바깥은 view_count > 820인 글을 찾습니다. 평균이 달라져도 문장은 고칠 필요가 없습니다.

한 값이냐 여러 값이냐

서브쿼리를 쓸 때 가장 중요한 것은 안쪽이 몇 줄을 돌려주는가입니다. 여기가 어긋나면 바로 오류가 납니다.

한 줄만 돌려주면 등호나 부등호 같은 보통 비교 연산자를 쓸 수 있습니다. 여러 줄을 돌려주면 IN이나 ANY, ALL을 써야 합니다.

-- 여러 줄 : IN 으로 받습니다
SELECT title
FROM   posts
WHERE  member_id IN (SELECT member_id
                     FROM   members
                     WHERE  grade = 'PRO');

여기에 등호를 쓰면 값이 하나로 좁혀지지 않아 실패합니다. 반대로 한 줄만 나오는데 IN을 써도 동작은 합니다. 확신이 없으면 IN이 안전합니다.

ANYALL은 여러 값 중 어디에 걸리는지를 정합니다. 목록이 300과 800이라고 하면 > ANY는 가장 작은 300보다 크면 통과이고, > ALL은 가장 큰 800보다 커야 통과입니다.

-- PRO 회원이 쓴 어떤 글보다도 조회수가 높은 글
SELECT title, view_count
FROM   posts
WHERE  view_count > ALL (SELECT p.view_count
                         FROM   posts p
                         JOIN   members m ON p.member_id = m.member_id
                         WHERE  m.grade = 'PRO');

어디에 넣을 수 있나

서브쿼리는 WHERE에만 들어가는 것이 아닙니다. 세 자리에 넣을 수 있습니다.

  • WHEREHAVING — 비교할 기준값을 만들 때
  • FROM — 임시 표를 만들 때. 인라인 뷰라고 부릅니다
  • SELECT — 줄마다 값을 하나씩 계산해 붙일 때
-- 인라인 뷰 : 분류별 최고 조회수를 임시 표로 만들어 다시 조인
SELECT p.title, p.view_count, top.max_view
FROM   posts p
JOIN   (SELECT category_id, MAX(view_count) AS max_view
        FROM   posts
        GROUP  BY category_id) top
  ON   p.category_id = top.category_id
WHERE  p.view_count = top.max_view;

분류마다 가장 많이 읽힌 글을 뽑는 문장입니다. 집계 결과를 다시 원래 표와 맞춰 봐야 하는 이런 요구는 인라인 뷰 없이는 풀기 어렵습니다. 인라인 뷰에는 반드시 별명을 붙여야 한다는 점도 기억해 두어야 합니다.

뷰 — 서브쿼리에 이름 붙여 두기

같은 서브쿼리를 여기저기서 쓰게 되면 이름을 붙여 저장해 둘 수 있습니다. 이것이 뷰입니다. 뷰는 값을 복사해 두는 것이 아니라 문장을 저장해 두는 것이며, 조회할 때마다 원본에서 최신 값을 읽어 옵니다.

CREATE VIEW popular_posts AS
SELECT p.post_id, p.title, m.nickname, p.view_count
FROM   posts p
JOIN   members m ON p.member_id = m.member_id
WHERE  p.view_count >= 1000;

-- 이후에는 표처럼 씁니다
SELECT * FROM popular_posts ORDER BY view_count DESC;

뷰를 쓰면 좋은 점이 두 가지 있습니다. 긴 조인을 매번 다시 적지 않아도 되고, 보여 주고 싶지 않은 칸을 빼 둘 수 있습니다. 회원 표에서 이메일을 뺀 뷰만 열어 주면 이메일은 노출되지 않습니다.

서브쿼리와 조인, 어느 쪽을 쓸까

같은 결과를 조인으로도 서브쿼리로도 낼 수 있는 경우가 많습니다. 판단 기준은 단순합니다.

결과에 다른 표의 칸을 보여 줘야 하면 조인을 씁니다. 조건을 거는 데에만 쓰고 그 표의 내용은 보여 줄 필요가 없다면 서브쿼리가 읽기 좋습니다. 예를 들어 PRO 회원이 쓴 글의 제목만 필요하다면 서브쿼리로 충분하고, 제목 옆에 닉네임까지 보여 줘야 한다면 조인이 맞습니다.

또 하나, 조인은 짝이 여럿일 때 줄 수가 불어나지만 서브쿼리는 그렇지 않습니다. 중복 없이 걸러내기만 하려는데 조인을 쓰면 같은 글이 여러 번 나올 수 있으니 주의해야 합니다.

핵심 정리

  • 서브쿼리 — 괄호로 감싼 안쪽 문장이 먼저 실행되어 값을 만들어 줍니다.
  • 줄 수 — 한 줄이면 비교 연산자, 여러 줄이면 IN·ANY·ALL을 씁니다.
  • 인라인 뷰 — FROM에 넣어 임시 표로 쓰며 별명이 필요합니다.
  • — 문장을 저장하는 것이라 조회할 때마다 최신 값을 읽습니다.
  • 선택 기준 — 다른 표의 값을 보여 줘야 하면 조인, 조건에만 쓰면 서브쿼리입니다.

데이터 변경과 트랜잭션

지금까지는 읽기만 했습니다. 읽기는 아무리 잘못해도 데이터가 상하지 않지만, 쓰기는 다릅니다. 한 줄 잘못 실행하면 표 전체가 같은 값으로 덮여 버립니다. 이 글에서는 데이터를 넣고 고치고 지우는 세 문장과, 그 작업을 안전하게 묶어 주는 트랜잭션을 다룹니다.

넣기 — INSERT

새 줄을 추가할 때는 어떤 칸에 무엇을 넣을지 짝지어 적습니다. 칸 목록과 값 목록의 개수가 같아야 하고, 순서도 맞아야 합니다.

INSERT INTO categories (category_id, category_name, sort_order)
VALUES (7, '데이터베이스', 40);

칸 목록을 생략하고 값만 적을 수도 있지만 권하지 않습니다. 표에 칸이 하나 추가되는 순간 그 문장이 전부 깨지기 때문입니다. 값이 문자나 날짜라면 작은따옴표로 감싸고, 숫자는 그대로 적습니다. 적지 않은 칸에는 기본값이 들어가고, 기본값도 없으면 NULL이 됩니다.

다른 표에서 읽어 온 결과를 그대로 넣을 수도 있습니다. 이때는 VALUES 대신 조회 문장을 붙입니다.

INSERT INTO posts_archive (post_id, title, view_count)
SELECT post_id, title, view_count
FROM   posts
WHERE  published_on < DATE '2025-01-01';

고치기 — UPDATE

UPDATE는 줄이 아니라 칸을 기준으로 움직입니다. SET에 적은 칸의 값을 바꾸고, 어느 줄을 바꿀지는 WHERE로 정합니다.

UPDATE posts
SET    view_count = view_count + 1
WHERE  post_id = 102;

여기서 가장 중요한 것은 WHERE를 빠뜨리지 않는 일입니다. 조건 없이 실행하면 표의 모든 줄이 바뀝니다.

-- 위험 : 모든 글의 조회수가 0이 됩니다
UPDATE posts SET view_count = 0;

습관 하나를 권합니다. 고치기 전에 같은 조건으로 SELECT를 먼저 돌려 몇 줄이 걸리는지 확인하는 것입니다. 예상과 다르면 조건이 잘못된 것입니다.

지우기 — DELETE와 그 친척들

DELETEWHERE로 대상을 정합니다. 조건을 빼면 모든 줄이 지워지는 것은 마찬가지입니다.

DELETE FROM comments
WHERE  created_on < DATE '2024-01-01';

비슷해 보이지만 성격이 전혀 다른 명령이 둘 더 있습니다. 셋의 차이를 정확히 알아 두어야 합니다.

명령 지우는 것 조건 지정 되돌리기
DELETE 고른 줄 가능 가능
TRUNCATE 모든 줄, 표는 남음 불가 불가
DROP 표 자체 불가 불가

줄이 아주 많을 때 TRUNCATE가 훨씬 빠른 이유는 한 줄씩 지우지 않고 저장 공간을 통째로 비우기 때문입니다. 대신 되돌릴 수 없습니다.

트랜잭션 — 전부 되거나 전부 안 되거나

글을 지우면서 그 글의 댓글도 같이 지운다고 해 보겠습니다. 두 문장 사이에서 오류가 나면 글은 사라졌는데 댓글만 남는 이상한 상태가 됩니다. 트랜잭션은 여러 문장을 한 덩어리로 묶어, 전부 반영하거나 전부 취소하게 만듭니다.

-- 여기서부터 한 덩어리
DELETE FROM comments WHERE post_id = 102;
DELETE FROM posts    WHERE post_id = 102;

COMMIT;     -- 확정. 이제 되돌릴 수 없습니다
-- ROLLBACK;  -- 취소. 덩어리 시작 시점으로 되돌립니다

COMMIT 전까지는 다른 사람에게 변경 내용이 보이지 않습니다. 각자 자기 작업 전의 값을 그대로 보므로 화면이 중간에 흔들리지 않습니다.

주의할 점은 자동으로 확정되는 경우가 있다는 것입니다. 표를 만들거나 바꾸는 명령, 권한을 주거나 거두는 명령은 실행되는 순간 앞의 작업까지 함께 확정합니다. 반대로 접속이 비정상적으로 끊기면 확정되지 않은 작업은 자동으로 취소됩니다.

참고로 MySQL 기본 설정과 대부분의 도구는 문장 하나마다 자동 확정을 켜 둡니다. 여러 문장을 묶으려면 START TRANSACTION으로 시작을 명시해야 합니다.

잠금과 교착 상태

누군가 어떤 줄을 고치는 중이면 데이터베이스는 그 줄에 잠금을 걸어 둡니다. 같은 줄을 고치려는 다른 요청은 잠금이 풀릴 때까지 기다립니다. 덕분에 두 사람의 수정이 뒤엉키지 않습니다.

문제는 서로 상대가 쥔 것을 기다릴 때입니다. A가 글을 잠근 채 댓글을 기다리고, B가 댓글을 잠근 채 글을 기다리면 둘 다 영원히 멈춥니다. 이를 교착 상태라고 합니다. 데이터베이스가 이를 감지해 한쪽을 강제로 취소하지만, 애초에 줄어들게 만드는 것이 낫습니다.

가장 효과적인 예방책은 트랜잭션을 짧게 유지하는 것입니다. 확정을 미룬 채 다른 일을 하지 말고, 여러 표를 건드릴 때는 팀에서 접근 순서를 하나로 정해 두면 교착이 크게 줄어듭니다.

핵심 정리

  • INSERT — 칸 목록을 생략하지 말고 개수와 순서를 맞춥니다.
  • UPDATE와 DELETE — WHERE를 빠뜨리면 전체가 바뀌거나 지워집니다.
  • DELETE·TRUNCATE·DROP — 지우는 범위와 되돌리기 가능 여부가 다릅니다.
  • 트랜잭션 — COMMIT으로 확정, ROLLBACK으로 취소하며 중간 상태를 남기지 않습니다.
  • 교착 상태 — 트랜잭션을 짧게 유지하고 접근 순서를 통일해 예방합니다.

테이블 설계와 DDL

마지막 편입니다. 지금까지는 이미 만들어진 표를 다뤘지만, 실제 프로젝트는 표를 설계하는 일에서 시작합니다. 이 글에서는 표를 만들고 고치는 명령, 조회를 빠르게 해 주는 인덱스, 그리고 나중에 후회하지 않을 설계를 위한 점검 목록을 정리합니다.

표 만들기 — CREATE TABLE

칸 이름과 자료형을 나열하고, 필요한 제약조건을 함께 겁니다. 이 강좌에서 계속 써 온 posts를 실제로 만들어 보겠습니다.

CREATE TABLE posts (
  post_id      INT           PRIMARY KEY,
  title        VARCHAR(200)  NOT NULL,
  member_id    INT           NOT NULL,
  category_id  INT,
  view_count   INT           DEFAULT 0 NOT NULL,
  reward_rate  DECIMAL(4,2),
  published_on DATE,
  CONSTRAINT posts_member_fk
    FOREIGN KEY (member_id)   REFERENCES members(member_id),
  CONSTRAINT posts_category_fk
    FOREIGN KEY (category_id) REFERENCES categories(category_id)
);

자료형 선택이 생각보다 중요합니다. 필요 이상으로 넉넉하게 잡으면 저장 공간과 조회 속도를 함께 잃습니다. 자주 쓰는 것만 추리면 다음과 같습니다.

자료형 쓰는 곳
INT 번호, 개수처럼 소수점이 없는 숫자
DECIMAL(p,s) 금액, 비율처럼 오차가 있으면 안 되는 숫자
VARCHAR(n) 길이가 들쭉날쭉한 문자열. 대부분의 문자 칸
CHAR(n) 길이가 항상 같은 코드 값
DATE, TIMESTAMP 날짜, 그리고 시각까지 필요한 값

금액에 실수형을 쓰면 안 되는 이유가 여기 있습니다. 실수형은 근사값이라 더하다 보면 1원씩 어긋납니다. 돈과 비율은 DECIMAL을 씁니다.

표 고치기 — ALTER TABLE

운영을 시작한 뒤에도 칸을 더하거나 자료형을 바꿀 일이 생깁니다.

ALTER TABLE posts ADD summary VARCHAR(300);          -- 칸 추가
ALTER TABLE posts MODIFY title VARCHAR(300);         -- 자료형 변경
ALTER TABLE posts DROP COLUMN summary;               -- 칸 삭제
ALTER TABLE posts
  ADD CONSTRAINT posts_view_ck CHECK (view_count >= 0);  -- 제약조건 추가

새로 추가한 칸은 기존 줄에서 모두 NULL로 채워집니다. 그래서 NOT NULL 칸을 추가하려면 기본값을 함께 지정해야 합니다.

크기를 줄이는 변경은 조심해야 합니다. 늘리는 것은 언제든 되지만, 줄이려면 이미 들어 있는 값이 새 크기에 들어맞아야 합니다. 참고로 칸 이름을 바꾸는 문법은 제품마다 갈리므로 ALTER TABLE ... RENAME COLUMN이 되는지 미리 확인해야 합니다.

인덱스 — 찾는 속도를 바꾸는 장치

인덱스는 책 뒤의 찾아보기와 같습니다. 없으면 처음부터 끝까지 훑어야 하지만, 있으면 해당 위치로 바로 건너뜁니다.

CREATE INDEX posts_published_idx ON posts (published_on);
CREATE INDEX posts_cat_view_idx  ON posts (category_id, view_count);

공짜는 아닙니다. 인덱스도 저장 공간을 쓰고, 값이 바뀔 때마다 함께 갱신해야 합니다. 그래서 넣고 고치는 일이 잦은 표에 인덱스를 남발하면 오히려 느려집니다.

판단 기준은 이렇습니다. 조회 조건이나 정렬 기준으로 자주 쓰이는 칸에는 두는 것이 좋고, 전체에서 걸러지는 비율이 낮을수록, 즉 조건에 걸리는 줄이 전체의 아주 일부일수록 효과가 큽니다. 반대로 값의 종류가 몇 개뿐인 칸에는 효과가 거의 없습니다. 기본키에는 자동으로 만들어지므로 따로 만들 필요가 없습니다.

좋은 설계를 위한 점검 목록

설계 단계에서 짚어 두면 나중에 고생이 크게 줄어드는 항목들입니다.

  • 이름을 뜻이 보이게 짓습니다. flag1 같은 이름은 반년 뒤의 자신도 알아보지 못합니다.
  • 예약어를 이름으로 쓰지 않습니다. order, group 같은 이름은 쓸 때마다 따옴표로 감싸야 합니다.
  • 기본키는 짧고 바뀌지 않는 값으로 잡습니다.
  • 비어도 되는 칸인지 먼저 따집니다. 기본은 NOT NULL로 두고, 비어도 되는 이유가 분명할 때만 허용합니다.
  • 표 사이의 연결은 외래키로 명시합니다. 프로그램에만 맡기면 언젠가 짝 없는 줄이 생깁니다.
  • 날짜와 시각은 전용 자료형으로 저장합니다. 문자열로 넣으면 비교와 정렬에서 곧바로 문제가 생깁니다.
  • 중복은 정규화로 덜어냅니다. 조회 속도 때문에 남겨야 한다면 그 이유를 기록해 둡니다.
  • 백업과 복구를 먼저 준비합니다. 복구를 실제로 해 본 적 없는 백업은 아직 백업이 아닙니다.

다음은 무엇을 하면 좋을까

12편을 따라왔다면 조회와 조작, 설계의 뼈대는 갖춘 셈입니다. 남은 것은 손에 익히는 일뿐입니다. 가장 빠른 방법은 자신이 만들고 싶은 서비스의 표를 직접 그려 보는 것입니다. 회원과 글, 주문과 상품 같은 것부터 시작해 외래키를 걸고, 실제로 값을 넣어 보며 조회해 보면 강좌를 읽을 때 넘어갔던 부분이 눈에 들어옵니다.

여기서 배운 SQL은 프로그램 안에서 쓰게 됩니다. JAVA 기초 강좌JavaScript 기초 강좌를 함께 보면 화면에서 데이터베이스까지 한 줄로 이어지는 흐름이 그려질 것입니다.

핵심 정리

  • CREATE TABLE — 자료형을 넉넉하게 잡지 말고, 금액과 비율은 DECIMAL을 씁니다.
  • ALTER TABLE — 크기는 늘리기 쉽고 줄이기 어렵습니다.
  • 인덱스 — 조회는 빨라지지만 변경은 느려지므로 자주 찾는 칸에만 둡니다.
  • 이름 규칙 — 뜻이 보이게 짓고 예약어는 피합니다.
  • 백업 — 복구를 실제로 해 본 백업만 백업입니다.

“SQL 완전 가이드 — SELECT부터 JOIN·집계·트랜잭션·DDL까지”에 대한 1개의 생각

댓글 남기기