MySQL 문법을 처음 훑을 때 LIKE, IN, BETWEEN, JOIN, GROUP BY, EXISTS, ANY를 차례로 정리했다. 그때는 예제를 옮겨 적는 데 집중하다 보니 비슷해 보이는 문법이 실제로 무엇을 비교하고, 결과 행을 어떻게 바꾸는지는 충분히 구분하지 못했다.
이번에는 문법 목록 대신 행을 거르는 조건, 두 집합을 연결하는 방법, 여러 행을 한 그룹으로 요약하는 방법, 서브쿼리 결과와 비교하는 방법으로 다시 묶었다. 예시는 MySQL 8.4 문서를 기준으로 했다.
LIKE: 문자열 패턴으로 행을 거른다
LIKE는 문자열이 패턴과 일치하는지 검사한다. 가장 많이 쓰는 wildcard는 두 개다.
| wildcard | 의미 | 예시 |
|---|---|---|
% |
문자가 없거나 하나 이상인 임의의 문자열 | 'kim%' |
_ |
정확히 한 문자 | 'A_1' |
SELECT customer_id, customer_name
FROM customers
WHERE customer_name LIKE '김%';
김, 김민수, 김 개발자는 모두 '김%'에 맞을 수 있다. 반면 '김_'은 김 뒤에 문자가 정확히 하나 있어야 한다.
대소문자 구분 여부는 LIKE 자체만 보고 단정할 수 없다. 비교에 쓰이는 character set과 collation에 따라 결과가 달라진다. 검색 결과가 예상과 다르면 column의 collation도 함께 확인해야 한다.
사용자가 입력한 %와 _를 문자 그대로 찾고 싶다면 escape 처리가 필요하다.
SELECT product_name
FROM products
WHERE product_name LIKE '%=%%' ESCAPE '=';
위 query는 이름에 % 문자가 들어간 상품을 찾는다. application에서 검색어를 조립할 때는 문자열을 직접 이어 붙이지 말고 prepared statement의 parameter를 사용해야 한다.
IN과 BETWEEN: 값 목록과 구간을 표현한다
여러 값 가운데 하나와 같은지 확인할 때는 IN이 읽기 쉽다.
SELECT order_id, status
FROM orders
WHERE status IN ('PAID', 'SHIPPED');
이는 다음 조건과 같은 의도다.
WHERE status = 'PAID' OR status = 'SHIPPED'
BETWEEN은 양 끝값을 모두 포함한다.
SELECT order_id, total_amount
FROM orders
WHERE total_amount BETWEEN 10000 AND 50000;
즉 10000 <= total_amount AND total_amount <= 50000과 같은 범위다. 날짜·시간 column에서는 끝값 포함이 오히려 실수를 만들기도 한다. 하루 전체를 찾는다면 마지막 시각을 임의로 적기보다 반열린 구간이 안전하다.
WHERE created_at >= '2026-08-01'
AND created_at < '2026-08-02'
alias: 결과 이름과 table 참조를 짧게 만든다
column alias는 결과의 label을 바꾸고, table alias는 긴 이름을 줄인다.
SELECT
o.order_id,
CONCAT_WS(' ', c.last_name, c.first_name) AS customer_name
FROM orders AS o
JOIN customers AS c ON c.customer_id = o.customer_id;
CONCAT_WS(separator, ...)는 첫 argument를 separator로 사용한다. 뒤의 argument 가운데 NULL은 건너뛰지만, 빈 문자열까지 제거하는 것은 아니다.
alias에 공백이나 예약어가 필요하다면 MySQL identifier quote인 backtick을 사용할 수 있다.
SELECT total_amount AS `주문 금액`
FROM orders;
single quote는 기본적으로 문자열 literal을 뜻한다. 일부 alias 위치에서 허용되더라도 다른 clause에서 같은 표기가 문자열로 해석될 수 있으므로, identifier에는 backtick을 쓰거나 공백 없는 이름을 고르는 편이 혼동이 적다.
JOIN: 어떤 행을 결과에 남길지 정한다
JOIN은 두 table 사이의 관련 행을 결합한다. 핵심은 그림 모양보다 일치하지 않는 행을 어느 쪽에서 남길지다.
| 종류 | 결과에 남는 행 |
|---|---|
INNER JOIN |
join 조건을 만족하는 양쪽 행 |
LEFT JOIN |
왼쪽의 모든 행과 일치하는 오른쪽 행 |
RIGHT JOIN |
오른쪽의 모든 행과 일치하는 왼쪽 행 |
CROSS JOIN |
양쪽 행의 Cartesian product |
SELECT c.customer_id, c.customer_name, o.order_id
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.customer_id;
주문이 없는 고객도 남고, 그 고객의 o.order_id는 NULL이 된다.
outer join에서는 오른쪽 table의 filter를 ON에 둘지 WHERE에 둘지가 결과를 바꾼다.
-- 주문이 없거나, 8월의 주문이 있는 고객을 모두 유지
LEFT JOIN orders AS o
ON o.customer_id = c.customer_id
AND o.created_at >= '2026-08-01'
AND o.created_at < '2026-09-01'
같은 조건을 WHERE o.created_at ...에 두면 NULL인 행이 제거되어 사실상 inner join처럼 보일 수 있다.
CROSS JOIN은 모든 조합이 필요할 때 쓴다. 예를 들어 100개 상품과 12개월을 결합하면 1,200행이 된다. WHERE 조건을 붙인 cross join이 특정 inner join과 같은 결과를 낼 수는 있지만, 두 문법의 의미가 언제나 같다고 외우는 것은 위험하다.
self join은 별도의 join 종류라기보다 같은 table을 서로 다른 alias로 두 번 참조하는 방식이다.
SELECT employee.name, manager.name AS manager_name
FROM employees AS employee
LEFT JOIN employees AS manager
ON manager.employee_id = employee.manager_id;
UNION: 두 SELECT의 결과를 세로로 합친다
JOIN이 column을 옆으로 연결한다면 UNION은 query 결과를 아래로 이어 붙인다.
SELECT email FROM customers
UNION
SELECT email FROM newsletter_subscribers;
각 SELECT는 같은 수의 column을 반환해야 하고 대응 column의 type도 호환돼야 한다.
UNION은 중복 행을 제거한다.UNION ALL은 중복을 유지한다.
중복 제거가 필요하지 않다면 의도를 정확히 드러내는 UNION ALL을 먼저 검토한다. 최종 정렬은 전체 compound query 뒤의 ORDER BY로 지정한다.
GROUP BY와 HAVING: 행을 묶은 뒤 조건을 건다
GROUP BY는 같은 key를 가진 행을 한 그룹으로 만들고 집계 함수와 함께 사용한다.
SELECT
customer_id,
COUNT(*) AS order_count,
SUM(total_amount) AS total_amount
FROM orders
WHERE status = 'PAID'
GROUP BY customer_id
HAVING SUM(total_amount) >= 100000;
실행 의도를 순서로 읽으면 다음과 같다.
WHERE가 집계 전 행을 거른다.GROUP BY가customer_id별로 묶는다.- 집계 함수가 각 그룹을 계산한다.
HAVING이 집계된 그룹을 거른다.
GROUP BY에 없는 일반 column을 임의로 SELECT하면 그룹 안의 어느 값을 보여 줄지 모호하다. MySQL의 ONLY_FULL_GROUP_BY가 활성화된 환경에서는 functional dependency가 인정되지 않는 이런 query를 거부한다. mode를 끄기보다 grouping key와 집계 의도를 명확히 쓰는 편이 낫다.
EXISTS: 서브쿼리 결과 행이 하나라도 있는가
EXISTS는 서브쿼리가 실제 값을 무엇으로 반환하는지가 아니라 행을 하나 이상 반환하는지를 검사한다.
SELECT c.customer_id, c.customer_name
FROM customers AS c
WHERE EXISTS (
SELECT 1
FROM orders AS o
WHERE o.customer_id = c.customer_id
AND o.status = 'PAID'
);
위 query는 결제 완료 주문이 하나라도 있는 고객을 찾는다. 서브쿼리의 SELECT 1에서 1은 관례일 뿐이다. EXISTS는 select list의 값이 아니라 행의 존재만 본다.
NOT EXISTS는 관련 행이 하나도 없는 대상을 찾을 때 유용하다. NOT IN은 서브쿼리에 NULL이 섞이면 three-valued logic 때문에 예상하지 못한 결과가 날 수 있어, 반관계 의도에는 NOT EXISTS가 더 분명한 경우가 많다.
ANY와 SOME: 서브쿼리 결과 중 하나라도 조건을 만족하는가
ANY는 comparison operator와 subquery를 함께 쓴다.
SELECT product_id, product_name, price
FROM products
WHERE price > ANY (
SELECT price
FROM products
WHERE category_id = 10
);
category 10의 가격 가운데 하나보다라도 비싸면 참이다. SOME은 ANY의 동의어다.
IN (subquery)는 = ANY (subquery)와 같은 의미로 볼 수 있다.
WHERE customer_id IN (SELECT customer_id FROM vip_customers)
-- 같은 비교 의미
WHERE customer_id = ANY (SELECT customer_id FROM vip_customers)
하지만 ANY를 단순한 값 목록의 별칭처럼 쓰는 것은 아니다. 비교 대상은 subquery여야 하며, 빈 결과와 NULL이 섞인 결과에서는 SQL의 UNKNOWN까지 고려해야 한다. > ANY를 단순히 최솟값 비교로 바꾸려면 빈 집합과 NULL 처리도 같은지 확인해야 한다.
ALL, CASE, NULL 비교처럼 이어지는 표현식은 MySQL query expression 정리에 따로 정리했다.
어떤 문법을 고를지 빠르게 구분하기
| 질문 | 먼저 떠올릴 문법 |
|---|---|
| 문자열 pattern과 일치하는가 | LIKE |
| 정해진 값 목록에 포함되는가 | IN |
| 양 끝을 포함한 범위인가 | BETWEEN |
| 관련된 두 table의 column이 함께 필요한가 | JOIN |
| 같은 구조의 결과를 세로로 합치는가 | UNION ALL 또는 UNION |
| key별 집계가 필요한가 | GROUP BY |
| 집계 결과에 조건을 거는가 | HAVING |
| 관련 행의 존재 여부만 필요한가 | EXISTS |
| 서브쿼리 값 중 하나와 비교하는가 | ANY |
문법을 외울 때는 keyword보다 결과 행이 어떻게 달라지는지를 먼저 그려 보는 것이 도움이 됐다. 특히 outer join의 ON과 WHERE, UNION과 UNION ALL, IN과 EXISTS는 비슷해 보이지만 의도와 NULL 처리, 실행 계획이 다를 수 있다. 실제 table과 index에서는 EXPLAIN으로 optimizer가 선택한 계획도 확인해야 한다.
처음 정리한 기본 조회 흐름은 MySQL 학습 1일차에서, 이어지는 표현식은 MySQL 학습 3일차에서 볼 수 있다.
참고 자료
'배움과 성장 > 백엔드·데이터' 카테고리의 다른 글
| Spring MVC HTTP 요청·응답 학습노트: Query·Form·JSON과 Servlet API (0) | 2022.05.10 |
|---|---|
| MySQL 8.4 쿼리 문법: ALL·INSERT SELECT·CASE·NULL 처리 (0) | 2022.05.10 |
| MySQL SELECT 기초 학습노트: WHERE·NULL·LIKE·ORDER BY·LIMIT (0) | 2022.05.05 |
| Spring Container 학습노트: ApplicationContext·Bean 조회·BeanDefinition (0) | 2022.04.22 |
| Spring 핵심 원리 학습노트: IoC·DI·컨테이너와 SOLID (0) | 2022.04.21 |
댓글