MariaDB 강좌 / LEFT JOIN으로 연결되지 않은 행도 조회하기
LEFT JOIN은 왼쪽 테이블의 행을 유지하면서 오른쪽의 일치 행을 연결하고, 일치가 없으면 오른쪽 열을 NULL로 반환합니다. 조건을 ON에 둘지 WHERE에 둘지에 따라 주문 없는 고객이 사라질 수 있으므로 유지하려는 기준 행을 먼저 정해야 합니다.
남겨야 하는 기준 테이블을 왼쪽에 두기
가입한 모든 고객과 주문 유무를 함께 보려면 고객을 왼쪽에 둡니다. 결과가 없는 주문 데이터를 만들거나 실제 주문을 NULL로 저장하는 것이 아니라 조회 결과에서 연결되지 않은 오른쪽 부분만 NULL이 됩니다. 고객 한 명의 여러 주문이 있으면 그 고객은 여러 결과 행으로 나타납니다. 일치 조합만 필요한 경우는 INNER JOIN으로 서로 연결된 데이터 조회하기와 비교하고, 특정 주문이 없는 고객만 고르는 목적은 서브쿼리·EXISTS·NOT EXISTS로 조건 표현하기의 NOT EXISTS로도 표현할 수 있습니다.
| 관심 대상 | 연결 기준 | 주의 |
|---|---|---|
| 전체 고객과 주문 | customers LEFT JOIN orders | 주문 없는 고객 유지 |
| 전체 주문과 고객 | orders LEFT JOIN customers | 유지하는 대상이 다름 |
| paid 주문이 없는 고객 | ON에 paid 조건 후 NULL 검사 | 취소 주문만 있는 고객도 포함 |
| 모든 상태의 주문이 없는 고객 | 상태 제한 없는 연결 후 NULL 검사 | 완전한 미주문 고객 |
주문 없는 고객을 포함한 데이터 준비하기
학습용 MariaDB 서버의 shopcourse에 course_user로 접속합니다. 명령줄 클라이언트와 UTF-8 SQL 파일 실행 방법대로 코드를 UTF-8 파일에 저장하고 SOURCE C:/mariadb-lab/lesson.sql;처럼 실제 파일 경로로 실행할 수 있습니다. SQL 문은 운영체제 터미널이 아닌 MariaDB 연결 안에서 실행합니다.
먼저 SELECT VERSION(), DATABASE();와 SHOW TABLES;로 대상 서버·DB·이름 중복을 확인합니다. 아래 c27_customers, c27_products, c27_orders, c27_items는 이 글의 독립된 연습용 이름입니다. 이미 존재하면 정의와 행을 확인하고, 보관할 자료가 없는 이 글의 실습 테이블임이 확실할 때만 마지막 절의 정리 순서로 지운 뒤 다시 만듭니다. 기존 강좌의 동명 없는 테이블이나 운영 데이터는 삭제하지 않습니다.
USE shopcourse; CREATE TABLE c27_customers ( customer_id INT NOT NULL PRIMARY KEY, customer_name VARCHAR(50) NOT NULL, city VARCHAR(30) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; CREATE TABLE c27_products ( product_id INT NOT NULL PRIMARY KEY, product_name VARCHAR(100) NOT NULL, category VARCHAR(30) NOT NULL, price DECIMAL(10,2) NOT NULL CHECK (price >= 0), stock INT NOT NULL CHECK (stock >= 0) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; CREATE TABLE c27_orders ( order_id INT NOT NULL PRIMARY KEY, customer_id INT NOT NULL, ordered_at DATETIME NOT NULL, status VARCHAR(20) NOT NULL, shipping_fee DECIMAL(10,2) NOT NULL DEFAULT 1000, CONSTRAINT fk_c27_order_customer FOREIGN KEY (customer_id) REFERENCES c27_customers(customer_id) ON DELETE RESTRICT ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; CREATE TABLE c27_items ( order_id INT NOT NULL, product_id INT NOT NULL, quantity INT NOT NULL CHECK (quantity > 0), unit_price DECIMAL(10,2) NOT NULL CHECK (unit_price >= 0), PRIMARY KEY (order_id, product_id), CONSTRAINT fk_c27_item_order FOREIGN KEY (order_id) REFERENCES c27_orders(order_id) ON DELETE RESTRICT, CONSTRAINT fk_c27_item_product FOREIGN KEY (product_id) REFERENCES c27_products(product_id) ON DELETE RESTRICT ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; INSERT INTO c27_customers VALUES (1,'김민수','서울'),(2,'이서연','부산'), (3,'박지우','서울'),(4,'최하늘','대전'); INSERT INTO c27_products VALUES (1,'노트','문구',3500,20),(2,'볼펜','문구',1500,50), (3,'머그컵','생활',8000,12),(4,'스탠드','생활',25000,5); INSERT INTO c27_orders (order_id,customer_id,ordered_at,status,shipping_fee) VALUES (101,1,'2026-01-10 10:00:00','paid',1000), (102,1,'2026-01-20 11:00:00','paid',1000), (103,2,'2026-02-05 12:00:00','paid',1000), (104,3,'2026-02-10 13:00:00','canceled',1000); INSERT INTO c27_items VALUES (101,1,2,3500),(101,2,3,1500),(102,3,1,8000), (103,1,1,3500),(103,3,2,8000),(104,2,1,1500);
최하늘 고객 4번은 주문이 전혀 없고, 박지우 고객 3번은 취소 주문만 있습니다. 이 둘을 구분할 수 있도록 상태가 섞인 예제를 사용합니다. 쿼리 결과를 보기 전에 어떤 고객을 미구매자로 정의할지부터 정하세요.
오른쪽 NULL과 기준 행 유지 확인하기
SELECT c.customer_id,c.customer_name,o.order_id,o.status FROM c27_customers AS c LEFT JOIN c27_orders AS o ON o.customer_id=c.customer_id ORDER BY c.customer_id,o.order_id;
김민수 두 행, 이서연 한 행, 박지우 한 행, 최하늘 한 행으로 총 다섯 행입니다. 최하늘의 주문 열만 NULL이며 고객 번호와 이름은 유지됩니다. 결과를 고객 네 명으로만 예상했다면 한 고객의 주문 여러 건이 각각 출력된다는 점을 확인합니다.
ON의 상태 조건과 WHERE의 차이 비교하기
SELECT c.customer_id,COUNT(o.order_id) AS paid_orders FROM c27_customers AS c LEFT JOIN c27_orders AS o ON o.customer_id=c.customer_id AND o.status='paid' GROUP BY c.customer_id ORDER BY c.customer_id;
| customer_id | paid_orders |
|---|---|
| 1 | 2 |
| 2 | 1 |
| 3 | 0 |
| 4 | 0 |
paid 조건을 ON에 두었으므로 일치하는 paid 주문이 없는 고객도 남습니다. 같은 조건을 WHERE o.status='paid'로 옮기면 오른쪽 값이 NULL인 행은 조건을 통과하지 못하고, 취소 주문만 있는 고객도 제외됩니다. 고객 3·4를 0으로 보여 주려면 필터 위치를 유지해야 합니다. LEFT JOIN의 연결 구문을 함께 확인하세요.
같은 paid 조건을 WHERE로 옮긴 결과도 직접 비교할 수 있습니다. 아래 쿼리는 고객 1·2만 반환하므로 앞 표의 고객 3·4와 0건 결과를 보존하지 않습니다.
SELECT c.customer_id,COUNT(o.order_id) AS paid_orders FROM c27_customers c LEFT JOIN c27_orders o ON o.customer_id=c.customer_id WHERE o.status='paid' GROUP BY c.customer_id ORDER BY c.customer_id;
필터 위치만 바꾸고 고객별 결과 행과 건수를 대조한 뒤, 전체 고객을 남길 목적이라면 앞의 ON 조건으로 되돌립니다.
COUNT(*) 대신 실제 오른쪽 키를 세기
주문 없는 고객에게도 LEFT JOIN 결과 행은 하나 존재하므로 COUNT(*)를 쓰면 1로 셀 수 있습니다. 오른쪽의 NOT NULL 기본키인 o.order_id를 세면 일치한 주문만 계산되어 0이 됩니다. 항상 존재하는 고객 키를 세는 COUNT(c.customer_id)도 이 목적에는 맞지 않습니다.
SELECT c.customer_id FROM c27_customers AS c LEFT JOIN c27_orders AS o ON o.customer_id=c.customer_id WHERE o.order_id IS NULL ORDER BY c.customer_id;
상태 조건 없이 모든 주문을 연결하고 o.order_id IS NULL로 찾으면 최하늘인 4번만 반환합니다. 같은 검사에서 ON에 paid 조건을 넣으면 3·4번이 반환됩니다. 오른쪽에서 원래 NULL을 허용하는 메모 열로 미일치 여부를 검사하면 실제 주문의 빈 메모와 혼동하므로 기본키를 사용합니다.
기준 행·필터 위치·집계 수를 점검하기
잘못된 결과를 DISTINCT로 숨기기 전에 기준 행을 유지하려는 JOIN인지와 상태 조건의 위치를 확인합니다. 오른쪽 열을 WHERE에서 비교하면 NULL을 제외하는지 살펴보고, 결과 한 행이 고객인지 주문인지 명확히 합니다. 설명한 결과가 나오지 않으면 고객·주문의 초기 키와 상태부터 조회하세요.
반복하기 전 현재 DB와 실제 정의·행을 확인하고 필요한 자료를 백업합니다. 보관할 데이터가 없는 이 글의 전용 실습 객체만 아래 순서로 정리합니다. 외래키가 있으면 자식부터 지웁니다. DROP TABLE은 구조와 데이터를 모두 없애며 일반적인 ROLLBACK으로 복원되지 않으므로 취소 가능한 DML 실습과 분리합니다.
DROP TABLE shopcourse.c27_items; DROP TABLE shopcourse.c27_orders; DROP TABLE shopcourse.c27_products; DROP TABLE shopcourse.c27_customers;









