제조 데이터 · 설비 자동화

현장에서 얻은 데이터를
쓸 수 있는 정보로 정리합니다.

PLC, Python, SQL, Tableau와 예지보전을 실제 설비기술 업무의 흐름 안에서 기록합니다.

MSSQL 여러 열로 그룹화하기 — 다중 열 GROUP BY와 COUNT DISTINCT [MSSQL 23]

두 개 이상의 열로 그룹을 나눠 집계하는 방법입니다. 도시별 × 상태별처럼 기준이 둘일 때 GROUP BY에 무엇을 적어야 하는지, SELECT에 올릴 수 있는 열과 없는 열이 왜 갈리는지, 조인 때문에 건수가 부풀었을 때 COUNT(DISTINCT)로 바로잡는 방법까지 정리했습니다.

시리즈 안내 · 이 글은 MSSQL 23입니다.
SQL·MES 전체 글 보기 →

지난 초급 6편에서는 연산자 우선순위와 CASE 표현식으로 "조건을 정확히 적는 법"을 연습했습니다. 오늘은 입문 11편에서 배운 GROUP BY를 실전 수준으로 끌어올립니다. 두 개 이상의 열로 묶는 다중 그룹핑, 조건 두 개를 동시에 거는 HAVING 복합 조건, 그리고 조인 뒤에 건수가 슬그머니 부풀어 오르는 함정을 잡는 COUNT(DISTINCT) 까지 다룹니다. 오늘 실습은 전부 SELECT 조회뿐이라서, 포스트가 끝나도 BookMart 데이터는 원본 그대로입니다.

워밍업 — 한 열 GROUP BY와 처리 순서 복습

본격적으로 들어가기 전에 몸을 풀어 보겠습니다. 주문 상태별 건수를 세어 볼까요?

SELECT Status, COUNT(*) AS OrderCount
FROM Orders
GROUP BY Status
ORDER BY OrderCount DESC, Status;

StatusOrderCount

배송완료 6
배송중 2
주문접수 1
취소 1

주문 10건이 상태 단위로 4행으로 요약됐습니다. 여기서 눈여겨볼 부분은 ORDER BY입니다. OrderCount는 SELECT에서 만든 별칭인데 ORDER BY에서는 쓸 수 있습니다. 쿼리의 논리적 처리 순서가 FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY라서, ORDER BY 시점에는 별칭이 이미 만들어져 있기 때문입니다. 반대로 HAVING은 SELECT보다 먼저 처리되므로 별칭을 쓸 수 없다는 점, 오늘 뒷부분에서 다시 확인하겠습니다. 그리고 건수가 1로 같은 '주문접수'와 '취소'처럼 동점이 생길 수 있으니, 정렬 기준을 하나 더(, Status) 붙여서 순서를 못박는 습관도 함께 챙겨 두세요.

여러 열로 그룹화하기 — 다중 열 GROUP BY

SELECT 열A, 열B, COUNT(*) FROM 테이블명 GROUP BY 열A, 열B;

이제 오늘의 첫 번째 주인공입니다. "도시별 주문 건수"도, "상태별 주문 건수"도 아닌 "도시별로, 그 안에서 다시 상태별로" 건수를 보고 싶다면? GROUP BY에 열을 쉼표로 이어 적으면 됩니다. 손으로 결과를 맞춰 볼 수 있도록, 먼저 그룹핑 전의 원재료부터 확인해 두겠습니다. 주문에는 도시 정보가 없으니 Member 테이블과 조인합니다(입문 12편에서 배운 INNER JOIN입니다).

SELECT O.OrderID, M.Name, M.City, O.Status
FROM Orders AS O
    INNER JOIN Member AS M ON O.MemberID = M.MemberID
ORDER BY O.OrderID;

이 10행이 오늘의 원재료입니다. 이제 도시와 상태, 두 열로 묶어 보겠습니다.

SELECT M.City, O.Status, COUNT(*) AS OrderCount
FROM Orders AS O
    INNER JOIN Member AS M ON O.MemberID = M.MemberID
GROUP BY M.City, O.Status
ORDER BY M.City, O.Status;

10건의 주문이 (도시, 상태) 조합 단위로 6행이 됐습니다. 위의 원재료 화면과 맞춰 보세요. 서울 주문 6건은 배송완료 4·배송중 1·주문접수 1로, 부산 3건은 배송완료 2·취소 1로, 대전 1건은 배송중 1로 정확히 나뉩니다. GROUP BY A, B는 "A로 먼저 묶고 B로 또 묶는" 두 단계가 아니라, A와 B의 값 조합이 같은 행끼리 한 그룹이라고 이해하는 것이 정확합니다. 그래서 GROUP BY에 적는 열의 순서를 바꿔도 그룹 자체는 동일하고, 행의 표시 순서는 오직 ORDER BY가 결정합니다.

결과에서 두 가지 빈자리도 눈여겨보세요. 광주는 아예 등장하지 않고(광주의 윤지호 회원은 주문이 없어 INNER JOIN에서 빠졌습니다), '대전 × 배송완료' 같은 조합도 없습니다. 다중 그룹핑은 데이터에 존재하는 조합만 만들어 내고, 없는 조합을 0으로 채워 주지 않습니다. 빠진 도시까지 0으로 보고 싶다면 입문 12편에서 배운 LEFT JOIN을 떠올리시면 됩니다.

GROUP BY 오류 해결 — SELECT에 올 수 없는 열

다중 그룹핑에서 욕심이 하나 생깁니다. "도시·상태 옆에 회원 이름도 같이 보고 싶은데?" 그래서 SELECT에 M.Name을 슬쩍 추가해 보겠습니다.

SELECT M.City, O.Status, M.Name, COUNT(*) AS OrderCount
FROM Orders AS O
    INNER JOIN Member AS M ON O.MemberID = M.MemberID
GROUP BY M.City, O.Status;

실행하면 다음과 같은 취지의 오류가 나타납니다(문구는 SQL Server 버전과 언어 설정에 따라 조금 다를 수 있습니다).

메시지 8120, 수준 16 — 'Member.Name' 열이 집계 함수나 GROUP BY 절에 포함되어 있지 않으므로 SELECT 목록에서 사용할 수 없습니다.

이유는 입문 11편에서 본 그대로입니다. '서울 × 배송완료' 그룹 한 행 안에는 김민준·박지훈·정다은의 주문이 섞여 있으니, 한 행에 누구 이름을 적어야 할지 SQL Server가 정할 수 없기 때문입니다. SELECT에는 GROUP BY에 적은 열과 집계 함수만 — 이 규칙은 열이 몇 개가 되든 변하지 않습니다.

여기서 심화 포인트 하나. 오류를 없애겠다고 GROUP BY M.City, O.Status, M.Name처럼 기계적으로 열을 추가하면 오류는 사라지지만 결과의 의미가 바뀝니다. 그룹 기준이 (도시, 상태)에서 (도시, 상태, 회원)으로 잘게 쪼개져서, 방금 6행이던 결과가 9행이 됩니다. 서울의 배송완료 4건이 김민준 2·박지훈 1·정다은 1로 흩어지는 것이지요. 오류 메시지는 "GROUP BY에 넣어라"가 아니라 "이 열이 정말 그룹 기준인지 다시 생각해 보라"는 뜻으로 읽는 것이 안전합니다.

중복 없이 세기 — COUNT(DISTINCT)

SELECT COUNT(DISTINCT 열명) FROM 테이블명;

두 번째 주인공으로 넘어가겠습니다. COUNT 괄호 안에 DISTINCT를 넣으면 중복을 제거한 서로 다른 값의 개수를 셉니다. 간단한 예부터 볼까요?

SELECT COUNT(*) AS AllOrders,
       COUNT(DISTINCT MemberID) AS BuyerCount
FROM Orders;

AllOrdersBuyerCount

10 6

주문은 10건이지만, 주문한 회원은 중복을 빼면 6명입니다. "주문 건수"와 "구매 경험이 있는 회원 수"는 이렇게 한 줄로 구분됩니다.

이 도구가 진짜 빛나는 순간은 조인 후 집계입니다. 회원별 주문 건수와 총 주문 금액을 구해 보겠습니다. 금액은 OrderItem에 있으니 세 테이블을 조인해야 합니다. 일단 건수를 늘 하던 대로 COUNT(*)로 세어 보겠습니다.

SELECT M.MemberID, M.Name,
       COUNT(*) AS OrderCount,
       SUM(OI.Qty * OI.UnitPrice) AS TotalAmount
FROM Member AS M
    INNER JOIN Orders AS O ON M.MemberID = O.MemberID
    INNER JOIN OrderItem AS OI ON O.OrderID = OI.OrderID
GROUP BY M.MemberID, M.Name
ORDER BY TotalAmount DESC;

금액은 맞는데 건수가 이상합니다. 김민준 회원의 주문은 1001·1003·1008로 3건인데 4로 나옵니다. 범인은 조인입니다. 주문 1001에는 상품이 2종 담겨 있어서 OrderItem과 조인하는 순간 주문 하나가 2행으로 불어나고, COUNT(*)는 그 불어난 행을 그대로 셉니다. 지금 세어진 것은 주문 건수가 아니라 "주문 상품 행 수"인 셈이지요. 오류도 없이 그럴듯한 숫자가 나오니 보고서에 그대로 올라가기 딱 좋은, 조용하고 위험한 함정입니다.

고치는 방법이 바로 COUNT(DISTINCT)입니다. 불어난 행들 속에서 서로 다른 OrderID만 세면 됩니다.

SELECT M.MemberID, M.Name,
       COUNT(DISTINCT O.OrderID) AS OrderCount,
       SUM(OI.Qty * OI.UnitPrice) AS TotalAmount
FROM Member AS M
    INNER JOIN Orders AS O ON M.MemberID = O.MemberID
    INNER JOIN OrderItem AS OI ON O.OrderID = OI.OrderID
GROUP BY M.MemberID, M.Name
ORDER BY TotalAmount DESC;

이제 김민준 3건, 정다은 2건 — 실제 주문 건수와 정확히 일치합니다. 참고로 GROUP BY를 이름(Name)만으로 하지 않고 M.MemberID, M.Name 두 열로 한 것도 실전 습관입니다. 지금 데이터야 동명이인이 없지만, 이름만으로 묶으면 동명이인이 생기는 순간 두 사람이 한 그룹으로 합쳐지는 사고가 나기 때문입니다. 다중 그룹핑은 이렇게 "고유한 키 + 보여줄 이름"을 함께 묶는 용도로도 자주 씁니다.

HAVING 복합 조건 — 단골 회원만 추려내기

마지막으로 위 결과에 조건을 걸어 보겠습니다. 마케팅팀의 요청이 이렇게 왔다고 해 보지요. "주문을 2건 이상 했으면서, 총 주문 금액도 6만 원 이상인 회원" — 그룹 집계 결과에 대한 조건이 두 개이니, 둘 다 HAVING에 AND로 적으면 됩니다.

SELECT M.MemberID, M.Name,
       COUNT(DISTINCT O.OrderID) AS OrderCount,
       SUM(OI.Qty * OI.UnitPrice) AS TotalAmount
FROM Member AS M
    INNER JOIN Orders AS O ON M.MemberID = O.MemberID
    INNER JOIN OrderItem AS OI ON O.OrderID = OI.OrderID
GROUP BY M.MemberID, M.Name
HAVING COUNT(DISTINCT O.OrderID) >= 2
   AND SUM(OI.Qty * OI.UnitPrice) >= 60000
ORDER BY TotalAmount DESC;

여섯 명 중 두 명만 남았습니다. 직전 화면과 비교해 보면 두 조건이 각각 일하는 모습이 보입니다. 박지훈·강하늘·임세라 회원은 건수 조건(2건 이상)에서, 이서연 회원(2건, 50,500원)은 금액 조건(6만 원 이상)에서 걸러졌습니다. 정다은 회원이 2건에 111,000원으로 1위, 김민준 회원이 3건에 95,800원으로 2위입니다.

두 가지만 덧붙이겠습니다. 첫째, HAVING에는 HAVING OrderCount >= 2처럼 별칭을 쓸 수 없습니다. 워밍업에서 본 처리 순서 때문입니다(HAVING이 SELECT보다 먼저). 집계식을 그대로 한 번 더 적어야 합니다. 둘째, WHERE와 HAVING은 함께 쓸 수 있고 역할이 다릅니다. 예를 들어 취소된 주문을 빼고 집계하고 싶다면 WHERE O.Status <> N'취소'를 추가하면 되는데, 이러면 이서연 회원은 취소 주문 1006이 그룹핑 전에 제외되어 1건·33,000원으로 계산됩니다. 행을 거르는 조건은 WHERE, 그룹을 거르는 조건은 HAVING — 이 원칙은 심화에서도 그대로입니다.

오늘도 조회만 했으니 BookMart 데이터는 처음 상태 그대로입니다.

오늘의 정리

  • GROUP BY A, B는 두 열의 값 조합이 같은 행끼리 한 그룹으로 묶는 것 — 데이터에 없는 조합은 행으로 만들어지지 않습니다.
  • SELECT에는 GROUP BY에 적은 열과 집계 함수만 허용 — 오류가 났다고 GROUP BY에 열을 기계적으로 추가하면 그룹이 쪼개져 결과 의미가 바뀝니다(6행 → 9행).
  • 조인하면 행이 불어나므로 COUNT(*)는 건수를 부풀릴 수 있음 — 진짜 건수는 COUNT(DISTINCT 키열)로 세는 것이 안전합니다.
  • HAVING에는 AND/OR로 집계 조건을 여러 개 걸 수 있고, 별칭은 못 쓰므로 집계식을 그대로 적어야 합니다.
  • 집계 결과 정렬은 ORDER BY에서 별칭 사용 가능, 동점 대비 정렬 기준을 하나 더 두는 습관을 권장합니다.

공식 참고 자료

다음 편 예고

초급 8편에서는 서브쿼리 기본기 다지기 — WHERE절 서브쿼리를 다룹니다. "평균보다 비싼 책", "주문한 적 있는 회원"처럼 쿼리 결과를 다른 쿼리의 조건으로 쓰는 방법을, 안쪽 쿼리부터 바깥쪽으로 읽고 쓰는 순서와 함께 연습해 보겠습니다.