데이터베이스: SQL로 데이터 다루기 - SELECT부터 서브쿼리까지
2026.08.15. DB
- 데이터베이스
- SQL
- JOIN
- 서브쿼리
지난 글에서 테이블을 어떻게 나누고 관계를 맺을지(정규화)를 정리했다면, 이번엔 그렇게 설계한 테이블을 실제로 조회하고 조작하는 SQL을 정리해볼게요.
예제는 이전 글에서 썼던 student, course, enrollment 스키마를 그대로 이어서 쓸게요.
student
| student_id | name | grade |
|---|---|---|
| 1001 | 김철수 | 2 |
| 1002 | 이영희 | 3 |
| 1003 | 박민수 | 1 |
| 1004 | 최지훈 | 2 |
course
| course_id | course_name | professor |
|---|---|---|
| C01 | 알고리즘 | 박교수 |
| C02 | DB개론 | 최교수 |
| C03 | 자료구조 | 박교수 |
enrollment
| student_id | course_id |
|---|---|
| 1001 | C01 |
| 1001 | C02 |
| 1002 | C01 |
| 1004 | C03 |
박민수(1003)는 아직 아무 강좌도 신청하지 않은 상태로 남겨뒀어요. JOIN 파트에서 이 학생이 왜 중요한 예시가 되는지 보여드릴게요.
SELECT, WHERE — 조회와 필터링
SELECT — 조회할 컬럼 지정
SELECT student_id, name FROM student;
SELECT * FROM student; -- 전체 컬럼 (성능·가독성 때문에 실무에선 지양)
WHERE — 행 필터링
조건에 맞는 행만 걸러내요. (뒤에서 볼 GROUP BY보다 먼저 적용돼요.)
SELECT * FROM student WHERE grade >= 2;
SELECT * FROM student WHERE name LIKE '김%'; -- 패턴 매칭
SELECT * FROM student WHERE grade IN (2, 3); -- 여러 값 중 하나
SELECT * FROM student WHERE grade BETWEEN 1 AND 3; -- 범위
WHERE grade >= 2의 결과는 이렇게 나와요.
| student_id | name | grade |
|---|---|---|
| 1001 | 김철수 | 2 |
| 1002 | 이영희 | 3 |
| 1004 | 최지훈 | 2 |
JOIN — 여러 테이블 결합
FK로 연결된 여러 테이블을 합쳐서 조회할 때 사용해요. 종류별로 결과가 어떻게 달라지는지 표로 먼저 볼게요.
| 종류 | 결과 |
|---|---|
| INNER JOIN | 양쪽 테이블에 모두 매칭되는 행만 |
| LEFT JOIN | 왼쪽 테이블은 전부 + 오른쪽은 있으면 값, 없으면 NULL |
| RIGHT JOIN | 오른쪽 테이블은 전부 + 왼쪽은 있으면 값, 없으면 NULL |
| FULL OUTER JOIN | 양쪽 테이블 전부, 매칭 안 되는 쪽은 NULL |
MySQL은 FULL OUTER JOIN 문법을 직접 지원하지 않아서, LEFT JOIN과 RIGHT JOIN 결과를 UNION으로 합쳐서 흉내 내는 경우가 많아요.
🤔 왜 MySQL 은 FULL OUTER JOIN 을 직접 지원하지 않나요?
- 공식적으로 명시된 이유는 없지만,
LEFT JOIN과RIGHT JOIN결과를UNION으로 합치면 FULL OUTER JOIN과 동일한 결과를 만들 수 있기 때문이라는 설명이 일반적이에요. 이미 우회할 방법이 있으니 문법을 따로 추가할 우선순위가 낮았던 거죠. - 반대로 PostgreSQL, SQL Server, Oracle은 표준 SQL을 더 폭넓게 구현하고 있어서
FULL OUTER JOIN을 그대로 지원해요.
🤔 PostgreSQL 과 MySQL 의 차이점은 또 무엇이 있나요? 그리고 어떨 때 사용해야 하나요?
- 몇 가지만 추려보면 이렇습니다!
- 표준 SQL 준수도: PostgreSQL이
FULL OUTER JOIN, 윈도우 함수, CTE(WITH) 등 표준 SQL 기능을 더 폭넓게, 더 먼저 지원해온 편이에요. - 데이터 타입: PostgreSQL은
JSONB, 배열,ENUM등 확장 타입이 풍부하고, 그 안에서도 인덱싱까지 지원해요. - 동시성 제어: 둘 다 MVCC 기반이지만 구현 방식이 달라서, 쓰기가 많은 워크로드에서의 동작 특성이 조금씩 달라요.
- 생태계: MySQL은 웹 서비스(특히 WordPress 같은 CMS)와 호스팅 환경에서 오래 자리 잡아 레퍼런스와 운영 노하우가 많아요.
- 언제 쓰나: 복잡한 쿼리, 데이터 정합성, 고급 타입이 중요하면 PostgreSQL, 단순한 CRUD 위주 서비스를 빠르게 만들고 넓은 생태계 지원을 받고 싶으면 MySQL 쪽으로 기우는 경우가 많아요. 다만 최근엔 두 DB의 기능 격차가 많이 좁혀져서, 팀의 기존 경험이나 클라우드 매니지드 서비스 지원 여부로 고르는 경우도 흔해요.
- 표준 SQL 준수도: PostgreSQL이
INNER JOIN 예시
SELECT s.name, e.course_id
FROM student s
INNER JOIN enrollment e ON s.student_id = e.student_id;
| name | course_id |
|---|---|
| 김철수 | C01 |
| 김철수 | C02 |
| 이영희 | C01 |
| 최지훈 | C03 |
수강 기록이 없는 박민수는 결과에서 아예 빠져요. 이게 INNER JOIN의 특징이에요.
LEFT JOIN 예시
SELECT s.name, e.course_id
FROM student s
LEFT JOIN enrollment e ON s.student_id = e.student_id;
| name | course_id |
|---|---|
| 김철수 | C01 |
| 김철수 | C02 |
| 이영희 | C01 |
| 박민수 | NULL |
| 최지훈 | C03 |
이번엔 박민수도 남지만, 매칭되는 enrollment 행이 없어서 course_id가 NULL로 채워져요.
ON과 WHERE, 조건을 어디에 넣느냐의 차이
INNER JOIN과 “JOIN 후 WHERE로 조건 거는 것”은 결과가 같을 때가 많지만, LEFT JOIN에서는 조건을 ON에 넣느냐 WHERE에 넣느냐에 따라 결과가 완전히 달라져요.
조건을 ON에 넣으면 — 왼쪽 행은 그대로 다 유지돼요.
SELECT s.name, e.course_id
FROM student s
LEFT JOIN enrollment e
ON s.student_id = e.student_id AND e.course_id = 'C01';
| name | course_id |
|---|---|
| 김철수 | C01 |
| 이영희 | C01 |
| 박민수 | NULL |
| 최지훈 | NULL |
같은 조건을 WHERE로 옮기면 — NULL인 행까지 걸러지면서 사실상 INNER JOIN처럼 동작해요.
SELECT s.name, e.course_id
FROM student s
LEFT JOIN enrollment e ON s.student_id = e.student_id
WHERE e.course_id = 'C01';
| name | course_id |
|---|---|
| 김철수 | C01 |
| 이영희 | C01 |
박민수, 최지훈이 사라진 이유는 e.course_id가 NULL이라 WHERE e.course_id = 'C01' 조건을 통과하지 못했기 때문이에요. “왼쪽 테이블은 무조건 다 보고 싶다”면 조건을 꼭 ON에 넣어야 해요.
GROUP BY, HAVING — 그룹별 집계
같은 값을 가진 행들을 하나의 그룹으로 묶어서, 그룹 단위로 집계 함수를 적용해요.
SELECT grade, COUNT(*) AS student_count
FROM student
GROUP BY grade;
| grade | student_count |
|---|---|
| 1 | 1 |
| 2 | 2 |
| 3 | 1 |
GROUP BY 이후에 조건을 걸고 싶으면 WHERE가 아니라 HAVING을 써요. (WHERE는 그룹화 전 개별 행 필터, HAVING은 그룹화 후 집계 결과 필터예요.)
SELECT grade, COUNT(*) AS student_count
FROM student
GROUP BY grade
HAVING COUNT(*) >= 2;
| grade | student_count |
|---|---|
| 2 | 2 |
인원이 1명뿐인 1학년, 3학년 그룹은 조건을 만족하지 못해 결과에서 빠졌어요.
실행 순서 (작성 순서와 다름 — 면접에서 자주 물어봐요)
FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT
WHERE가 SELECT보다 먼저 실행되기 때문에, SELECT에서 만든 별칭(alias)을 WHERE 조건에 쓸 수 없어요. (MySQL은 예외적으로 GROUP BY, HAVING, ORDER BY에서는 별칭 사용을 허용해요.)
INSERT, UPDATE, DELETE — 데이터 변경
INSERT — 새 행 추가
INSERT INTO student (student_id, name, grade)
VALUES (1005, '한소희', 3);
-- 여러 행 한 번에
INSERT INTO student (student_id, name, grade)
VALUES (1006, '정우성', 1), (1007, '송지효', 2);
UPDATE — 기존 행 수정
UPDATE student
SET grade = 2
WHERE student_id = 1003;
⚠️ WHERE 절을 빼먹으면 테이블 전체 행이 수정돼요. 실무 사고의 단골 원인이라, 항상 먼저 SELECT ... WHERE ...로 대상 행을 확인한 뒤 UPDATE를 실행하는 습관이 안전해요.
DELETE — 행 삭제
DELETE FROM student WHERE student_id = 1003;
마찬가지로 WHERE 없으면 전체 삭제예요.
참고 — DELETE vs TRUNCATE vs DROP
| 명령 | 대상 | 롤백 가능 여부 | 속도 |
|---|---|---|---|
| DELETE | 행(row) | 트랜잭션 안에서 가능 | 느림 (행 단위로 로그를 남김) |
| TRUNCATE | 테이블 데이터 전체 | DB/설정에 따라 다름 (대부분 제한적) | 빠름 (테이블을 통째로 초기화) |
| DROP | 테이블 자체(구조 + 데이터) | 불가 | 가장 빠름 |
집계 함수(Aggregate Functions)
여러 행의 값을 하나의 값으로 요약해요. 보통 GROUP BY와 함께 써요.
| 함수 | 설명 |
|---|---|
| COUNT() | 행 개수 |
| SUM() | 합계 |
| AVG() | 평균 |
| MAX() / MIN() | 최댓값 / 최솟값 |
SELECT AVG(grade) FROM student; -- 전체 평균
SELECT grade, COUNT(*) FROM student GROUP BY grade; -- 학년별 인원수
주의: COUNT(*)는 NULL을 포함한 모든 행을 세지만, COUNT(컬럼명)은 해당 컬럼이 NULL이 아닌 행만 세요.
서브쿼리(Subquery)
쿼리 안에 들어있는 또 다른 쿼리예요. 위치에 따라 역할이 달라져요.
1) WHERE절 서브쿼리 — 조건에 사용
SELECT name FROM student
WHERE student_id IN (
SELECT student_id FROM enrollment WHERE course_id = 'C01'
);
→ “C01 강좌를 듣는 학생 이름 조회” → 결과: 김철수, 이영희
2) FROM절 서브쿼리 (인라인 뷰) — 임시 테이블처럼 사용
SELECT grade_summary.grade, grade_summary.cnt
FROM (
SELECT grade, COUNT(*) AS cnt FROM student GROUP BY grade
) AS grade_summary
WHERE grade_summary.cnt > 1;
→ 앞서 GROUP BY로 만든 학년별 인원수 결과를 다시 테이블처럼 감싸서, 2명 이상인 학년만 걸러요. 결과: grade = 2, cnt = 2
3) SELECT절 서브쿼리 (스칼라 서브쿼리) — 값 하나를 컬럼처럼 사용
SELECT name,
(SELECT COUNT(*) FROM enrollment e WHERE e.student_id = s.student_id) AS course_count
FROM student s;
| name | course_count |
|---|---|
| 김철수 | 2 |
| 이영희 | 1 |
| 박민수 | 0 |
| 최지훈 | 1 |
이렇게 바깥 쿼리의 값(s.student_id)을 안쪽 쿼리가 참조하는 걸 상관 서브쿼리(Correlated Subquery)라고 해요. 바깥 행마다 서브쿼리가 다시 실행되기 때문에 일반 서브쿼리보다 느릴 수 있어요 — 실무에서는 JOIN으로 바꿔 최적화하는 경우가 많아요. 위 예제도 JOIN + GROUP BY로 바꾸면 이렇게 쓸 수 있어요.
SELECT s.name, COUNT(e.course_id) AS course_count
FROM student s
LEFT JOIN enrollment e ON s.student_id = e.student_id
GROUP BY s.student_id, s.name;
서브쿼리 vs JOIN은 같은 결과를 낼 수 있는 경우가 많지만, 실행계획과 성능이 달라질 수 있어서 실무·면접에서 자주 비교되는 주제예요.
🤔 그렇다면 같은 결과이지만 성능에 차이를 줄 수 있는 쿼리문 예시는?
-
바로 위 예제(상관 서브쿼리 vs JOIN + GROUP BY)가 대표적이에요. 학생이 4명이면 상관 서브쿼리는 바깥 행마다 서브쿼리를 4번 실행하지만, JOIN + GROUP BY는 한 번의 조인·그룹화로 끝나요. 데이터가 많아질수록 상관 서브쿼리 쪽이 훨씬 느려질 수 있어요.
또 다른 대표 예시는
NOT INvsNOT EXISTS예요.-- 수강 기록이 없는 학생 (NOT IN) SELECT name FROM student WHERE student_id NOT IN (SELECT student_id FROM enrollment); -- 같은 결과, NOT EXISTS SELECT name FROM student s WHERE NOT EXISTS ( SELECT 1 FROM enrollment e WHERE e.student_id = s.student_id );두 쿼리 모두 결과는 박민수 하나로 같아요. 하지만
NOT IN은 서브쿼리 결과에NULL이 단 하나라도 섞여 있으면 전체 결과가 예상과 달리 텅 비어버리는 함정이 있어요 (3값 논리 때문에NULL과의 비교는 전부 알 수 없음으로 처리되거든요).NOT EXISTS는 이런 함정이 없고 옵티마이저가 최적화하기도 더 쉬워서, 실무에서는NOT EXISTS를 권장하는 경우가 많아요.
정리하며
SELECT/WHERE로 원하는 행을 골라내고, JOIN으로 여러 테이블을 합치고, GROUP BY/HAVING으로 그룹 단위 집계를 뽑아내는 흐름을 정리했어요. 여기에 INSERT/UPDATE/DELETE로 데이터를 바꾸고, 서브쿼리로 쿼리 안에 쿼리를 넣어 더 복잡한 조건을 표현할 수 있다는 것까지 봤어요.
특히 LEFT JOIN에서 조건을 ON에 넣을지 WHERE에 넣을지, 서브쿼리를 JOIN으로 바꿀지 말지는 결과와 성능에 직접 영향을 주니 실무에서 자주 마주치는 포인트예요.