집계 함수와 GROUP BY
건수의 NULL 차이와 GROUP BY의 행 단위를 이해해 회원별 게시글 통계를 정확히 만듭니다.
게시글 행 100개를 그대로 읽어 합계를 애플리케이션에서 계산할 수도 있지만, 데이터베이스는 집합을 그룹으로 묶고 각 그룹을 한 행으로 축약할 수 있습니다.
문제는 어떤 행 단위에서 어떤 값을 세는지 명확하지 않으면 그럴듯한 오답도 쉽게 나온다는 점입니다.
posts에서 전체 게시글 수, 게시 시각이 있는 수, 총 조회수, 평균·최대·최소를 계산하고 회원별로 그룹화합니다.
원본 행의 제목을 그룹 결과에 섞는 오류를 통해 ONLY_FULL_GROUP_BY가 왜 보호 장치인지 확인합니다.
집계 예제 데이터
회원별 건수와 합계를 고정하기 위해 일곱 행을 전용 테이블에 다시 만듭니다.
DROP TABLE IF EXISTS ch4_aggregate_post_demo;
CREATE TABLE ch4_aggregate_post_demo (
id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
author_id BIGINT UNSIGNED NOT NULL,
title VARCHAR(80) NOT NULL,
status VARCHAR(16) NOT NULL,
view_count BIGINT UNSIGNED NOT NULL,
created_at DATETIME(6) NOT NULL,
published_at DATETIME(6) NULL
) ENGINE = InnoDB;
INSERT INTO ch4_aggregate_post_demo
(id, author_id, title, status, view_count, created_at, published_at)
VALUES
(1, 1, 'SELECT 기초', 'PUBLISHED', 30, '2026-07-10 09:00:00', '2026-07-10 09:30:00'),
(2, 1, 'WHERE 조건', 'PUBLISHED', 50, '2026-07-11 09:00:00', '2026-07-11 10:00:00'),
(3, 1, 'GROUP BY', 'PUBLISHED', 55, '2026-07-12 12:00:00', '2026-07-12 13:00:00'),
(4, 1, '집계 초안', 'DRAFT', 60, '2026-07-13 15:00:00', NULL),
(5, 2, 'JOIN 기초', 'PUBLISHED', 40, '2026-07-11 10:00:00', '2026-07-11 10:30:00'),
(6, 2, 'NULL 정리', 'PUBLISHED', 45, '2026-07-12 11:00:00', '2026-07-12 11:30:00'),
(7, 2, '통계 초안', 'DRAFT', 25, '2026-07-13 13:00:00', NULL);모호한 비집계 열
회원별 합계를 만들면서 title도 함께 SELECT합니다.
한 회원의 여러 게시글 중 어느 제목을 보여 줘야 하는지 규칙이 없으므로 그룹 결과의 행 단위와 맞지 않습니다.
SELECT
author_id,
title,
SUM(view_count) AS total_views
FROM ch4_aggregate_post_demo
GROUP BY author_id;MySQL 8.4의 기본 ONLY_FULL_GROUP_BY 모드에서는 모호한 제목 선택을 오류로 거절합니다.
ERROR 1055 (42000): Expression #2 of SELECT list is not in GROUP BY clause
and contains nonaggregated column 'board_lab.ch4_aggregate_post_demo.title'GROUP BY 이후 결과 한 행은 회원 그룹 전체를 대표합니다.
SUM은 여러 값을 하나로 줄이지만 제목은 그대로 여러 개 남습니다.
SQL 모드를 끄면 임의 제목이 보일 수 있으나 의미가 정해지는 것은 아닙니다.
대표 제목이 필요하면 최신, 최장, 사전순 첫 값 같은 선택 규칙을 별도 쿼리로 표현해야 합니다.
집계와 그룹의 단위
COUNT(*)는 그룹의 행 수를 세고 COUNT(column)은 NULL이 아닌 값 수를 셉니다.
SUM과 AVG는 NULL을 제외하며 값이 하나도 없을 때 결과가 NULL일 수 있습니다.
MIN과 MAX는 숫자뿐 아니라 날짜의 최초·최근 범위에도 쓸 수 있습니다.
GROUP BY 컬럼 조합이 결과 한 행의 식별자가 되므로 SELECT의 모든 비집계 표현식은 그 조합으로 결정되어야 합니다.
기본 집계 함수와 GROUP BY는 표준 SQL입니다.
MySQL에서 SELECT 별칭을 ORDER BY나 HAVING에 사용하는 편의는 지원 범위가 넓지만, 이식성을 우선하면 원래 표현식이나 명확한 외부 단계를 사용합니다.
ONLY_FULL_GROUP_BY를 끄는 방식은 모호한 모델을 숨기므로 교재 기준에서 사용하지 않습니다.
회원별 게시글 통계
회원 ID를 그룹 키로 두고 모든 나머지 결과를 집계합니다.
게시된 행 수는 published_at의 비NULL 수로, 전체 행 수와 차이를 비교합니다.
SELECT
author_id,
COUNT(*) AS post_count,
COUNT(published_at) AS published_count,
SUM(view_count) AS total_views,
ROUND(AVG(view_count), 1) AS average_views,
MIN(created_at) AS first_created_at,
MAX(created_at) AS last_created_at
FROM ch4_aggregate_post_demo
GROUP BY author_id
ORDER BY total_views DESC, author_id;회원마다 정확히 한 행이 나오고 전체 게시글과 게시된 글의 차이도 보입니다.
개선 실행 결과author_id | posts | published | total_views | average_views | first_created_at | last_created_at
1 | 4 | 3 | 195 | 48.8 | 2026-07-10 09:00:00 | 2026-07-13 15:00:00
2 | 3 | 2 | 110 | 36.7 | 2026-07-11 10:00:00 | 2026-07-13 13:00:00미게시 글의 조회수까지 포함할지에 따라 평균 의미가 달라집니다.
게시된 글의 평균 조회수만 필요하면 WHERE에서 게시된 행만 남기거나 조건부 집계를 사용해야 합니다.
통계 이름에 대상 집합을 드러내지 않으면 같은 AVG라도 팀마다 다른 의미로 사용됩니다.
전체 합과 그룹 합 비교
그룹 통계를 만든 뒤 전체 원본 합과 그룹별 합계의 합을 비교하면 누락된 필터나 중복 조인을 찾을 기준선이 생깁니다.
SELECT
(SELECT SUM(view_count) FROM ch4_aggregate_post_demo) AS raw_total,
SUM(group_total) AS regrouped_total
FROM (
SELECT author_id, SUM(view_count) AS group_total
FROM ch4_aggregate_post_demo
GROUP BY author_id
) AS author_totals;두 값이 같아야 합니다.
여기서는 다음 장의 서브쿼리 문법을 미리 사용한 진단 도구로만 제시합니다.
조인을 추가한 집계에서 값이 커지면 일대다 행 증가를 먼저 의심합니다.
평균의 평균은 각 그룹 행 수가 다르면 전체 평균과 같지 않습니다.
회원별 평균을 다시 단순 AVG하면 게시글이 한 개인 회원과 백 개인 회원이 같은 가중치를 가집니다.
전체 평균이 필요하면 전체 SUM/건수 또는 가중 평균을 사용합니다.
통계 질문의 모집단과 가중치를 먼저 적어야 함수 선택이 맞아집니다.
집계 테스트에는 게시글이 없는 회원, 게시글은 있지만 게시 시각이 모두 NULL인 회원, 조회수 0인 글과 조회수가 매우 큰 글을 포함합니다.
LEFT JOIN 뒤 COUNT(*)는 부모 행 하나도 세기 때문에 자식 수가 0인데 1로 나오는 함정이 있습니다.
그때는 COUNT(child.id)를 사용합니다.
GROUP BY에 날짜를 넣을 때 서버 시간대와 사용자 날짜 구간의 끝이 다르면 일별 통계가 어긋날 수 있습니다.
어떤 시간대를 기준으로 그룹을 만드는지 데이터 규칙에 포함합니다.
대시보드 집계는 원본이 바뀌는 동안 각 카드가 서로 다른 시점을 볼 수 있습니다.
여러 수치를 한 쿼리에서 만들지, 같은 트랜잭션 스냅샷으로 읽을지, 약간의 차이를 허용할지 정합니다.
결과가 자주 요청되고 원본이 크면 사전 집계 테이블을 검토하지만 16장에서 멱등 재계산과 지연 허용 범위를 함께 설계합니다.
지금은 원본 쿼리를 정확성 기준선으로 남깁니다.
SUM의 결과 타입과 최대값도 확인해야 합니다.
작은 정수 컬럼이라도 많은 행을 합치면 원본 타입 범위를 넘습니다.
AVG는 NULL을 제외하므로 ‘값 없음’을 0으로 간주해야 하는 업무라면 명시적으로 변환하되 의미 왜곡을 검토합니다.
DISTINCT를 집계 안에 넣으면 고유 값 수를 세지만 중복이 왜 생겼는지 먼저 확인합니다.
통계 숫자는 함수보다 입력 집합 정의에서 더 자주 틀립니다.
이번 장의 회원별 집계는 뒤에서 만드는 사전 통계의 검증 기준선이 됩니다.
따라서 쿼리와 함께 샘플 입력, 예상 행 수, 총합 일치 검사 결과를 보존합니다.
새 상태가 추가되거나 게시글 삭제 정책이 바뀌면 모집단 정의도 수정되어야 하므로 단순히 SQL 파일만 복사하지 않습니다.
일별·태그별처럼 그룹 축을 늘릴 때는 결과 한 행의 후보 키도 함께 늘어납니다.
author_id, post_date가 한 행을 식별한다면 그 조합을 데이터 규칙과 이후 집계 테이블의 유일성 제약에 반영합니다.
집계 학습은 함수 암기보다 결과 행의 정체성을 설계하는 첫 연습입니다.
평균과 비율을 저장할 때는 분자·분모도 함께 보존해야 재집계할 수 있습니다.
평균값만 합치면 전체 평균을 재현할 수 없고 반올림 오차도 누적됩니다.
결과 캐시에 total_views와 post_count를 함께 두면 필요할 때 가중 평균을 다시 계산할 수 있습니다.
통계 행의 생성 시각과 원본 범위도 기록해 사용자가 최신성 수준을 판단하게 합니다.
집계 결과를 API나 CSV로 제공할 때는 빈 그룹이 아예 없는 것인지 합계가 0인 것인지 구분합니다.
재집계 작업은 같은 격리와 기준 시각을 사용해 원본이 계산 도중 바뀌어도 분자와 분모가 서로 다른 시점을 보지 않게 합니다.
월별 통계라면 사용자 시간대에서 경계를 계산한 뒤 UTC 반열린 범위로 조회하고, 원본 삭제·정정 이벤트가 과거 월을 다시 계산해야 하는지도 정책에 포함합니다.
이 기준이 없으면 정확한 GROUP BY 문법도 시간이 지나며 서로 다른 숫자를 만듭니다.
집계 쿼리 작성 순서
| 판단 축 | 확인할 질문 |
|---|---|
| 모집단 | 어떤 WHERE 조건의 행을 통계에 포함하는가? |
| 그룹 | 결과 한 행이 회원·날짜·주제 중 무엇을 뜻하는가? |
| 측정 | 행 수·비NULL 수·고유 수 중 무엇을 세는가? |
| NULL | 값 없음이 제외·0·별도 상태 중 무엇인가? |
| 일치 검사 | 원본 합과 그룹 합을 비교할 수 있는가? |
GROUP BY를 쓰기 전에 결과 표의 기본 키를 말로 적습니다.
‘회원별 한 행’이라면 author_id가 그룹 키이고, 다른 열은 집계되거나 그 키에 함수적으로 종속되어야 합니다.
연습 문제
게시된 글만 대상으로 작성 날짜별 게시글 수, 고유 작성자 수, 총 조회수, 평균 조회수를 계산하세요.
날짜는 DATE(created_at)을 사용하고 최근 날짜부터 정렬합니다.
원본 합과 날짜별 합의 합도 비교하세요.
해설과 예시 답안
게시 상태 필터는 GROUP BY 전에 적용합니다.
COUNT(DISTINCT author_id)는 날짜별 고유 작성자 수를 셉니다.
SELECT
DATE(created_at) AS post_date,
COUNT(*) AS post_count,
COUNT(DISTINCT author_id) AS author_count,
SUM(view_count) AS total_views,
ROUND(AVG(view_count), 1) AS average_views
FROM ch4_aggregate_post_demo
WHERE status = 'PUBLISHED'
GROUP BY DATE(created_at)
ORDER BY post_date DESC;같은 날짜에 같은 회원의 게시글을 두 개 넣어 post_count는 2 늘고 author_count는 1만 늘어나는지 확인합니다.
핵심 정리
- 집계는 여러 입력 행을 그룹당 한 결과 행으로 바꿉니다.
- 건수(*)와 건수(열)은 NULL 처리 때문에 다른 질문에 답합니다.
- 비집계 컬럼은 그룹 키로 한 값이 결정되어야 합니다.
- 원본 합과 그룹 합을 일치 검사해 누락과 행 증가를 검증합니다.
다음 문서에서는 원본 행 필터 WHERE와 그룹 결과 필터 HAVING의 실행 위치를 구분합니다.