PostgreSQL 집계 쿼리
개별 레코드가 아니라 "오늘 총 매출이 얼마인가", "디바이스별 판매 건수가 몇 건인가" 같은 요약 데이터가 필요할 때 집계 쿼리를 사용한다. ORM이 많은 부분을 추상화하지만, 생성되는 SQL을 이해하면 성능 문제를 진단하고 복잡한 쿼리를 작성할 수 있다.
집계 함수
COUNT
행의 개수를 센다.
SELECT COUNT(*) FROM chiki.shot WHERE session_id = 'abc-123';
MikroORM에서는 em.count()로 사용한다.
const shotCount = await this.em.count(Shot, {
session: { sessionId },
});
COUNT(*)는 조건에 맞는 모든 행을 센다. Entity 전체를 로드하지 않으므로 단순 집계에 효율적이다. 수백만 건의 테이블에서 find로 전체를 가져와서 .length를 확인하는 것과 count를 쓰는 것은 성능 차이가 크다.
SUM
숫자 컬럼의 합계를 구한다.
SELECT SUM(amount) FROM chiki.sales
WHERE device_id = 'abc-123'
AND sold_at >= '2026-02-06'
AND sold_at < '2026-02-07';
MikroORM에서는 Entity를 조회한 후 코드에서 계산하거나, QueryBuilder를 사용한다.
// 방법 1: Entity 조회 후 코드에서 합산
const sales = await this.em.find(Sale, {
device: { deviceId },
soldAt: { $gte: targetDate, $lt: nextDate },
});
const totalRevenue = sales.reduce(
(sum, sale) => sum + Number(sale.amount), 0
);
// 방법 2: QueryBuilder로 DB에서 직접 합산
const result = await this.em.createQueryBuilder(Sale, 's')
.select('SUM(s.amount) as total')
.where({ device: { deviceId } })
.andWhere({ soldAt: { $gte: targetDate, $lt: nextDate } })
.execute('get');
방법 1은 모든 Sale Entity를 메모리에 로드한 후 계산한다. 데이터가 수백 건 이하면 문제없지만, 수만 건이면 메모리와 네트워크에 부담이 된다. 방법 2는 DB에서 합계를 계산해서 숫자 하나만 반환하므로 효율적이다.
AVG
평균값을 구한다.
SELECT AVG(processing_time) FROM chiki.ai_model
WHERE status = 'available';
MIN / MAX
최소값, 최대값을 구한다.
SELECT MIN(sold_at), MAX(sold_at) FROM chiki.sales
WHERE store_id = 'abc-123';
WHERE 절 조건
비교 연산자
-- 수량이 2 이상인 판매
SELECT * FROM chiki.sales WHERE quantity >= 2;
-- 금액이 5000원 미만인 판매
SELECT * FROM chiki.sales WHERE amount < 5000;
MikroORM의 필터 연산자와 SQL의 대응 관계다.
| MikroORM | SQL | 의미 |
|---|---|---|
{ $eq: value } | = value | 같다 |
{ $ne: value } | != value | 다르다 |
{ $gt: value } | > value | 크다 |
{ $gte: value } | >= value | 크거나 같다 |
{ $lt: value } | < value | 작다 |
{ $lte: value } | <= value | 작거나 같다 |
IN / NOT IN
여러 값 중 하나에 해당하는지 확인한다.
SELECT * FROM chiki.session
WHERE status NOT IN ('completed', 'cancelled', 'expired');
const sessions = await this.em.find(Session, {
status: { $nin: [SessionStatus.COMPLETED, SessionStatus.CANCELLED, SessionStatus.EXPIRED] },
});
날짜 범위 필터링
날짜 범위를 "이상/미만" 조합으로 표현한다.
-- 2026년 2월 6일 하루의 판매 데이터
SELECT * FROM chiki.sales
WHERE sold_at >= '2026-02-06 00:00:00'
AND sold_at < '2026-02-07 00:00:00';
const targetDate = new Date('2026-02-06');
const nextDate = new Date('2026-02-06');
nextDate.setDate(nextDate.getDate() + 1);
const sales = await this.em.find(Sale, {
soldAt: { $gte: targetDate, $lt: nextDate },
});
>=와 <를 조합하는 이유: BETWEEN이나 <=를 쓰면 경계값 처리가 복잡해진다. sold_at <= '2026-02-06 23:59:59'는 23:59:59.500 같은 밀리초 단위 데이터를 놓칠 수 있다. < nextDate는 이런 문제가 없다.
GROUP BY
동일한 값을 가진 행들을 그룹으로 묶어서 집계한다.
-- 매장별 총 매출
SELECT store_id, SUM(amount) as total_revenue, COUNT(*) as sale_count
FROM chiki.sales
GROUP BY store_id;
결과:
| store_id | total_revenue | sale_count |
|----------|---------------|------------|
| abc-123 | 150000.00 | 75 |
| def-456 | 230000.00 | 120 |
GROUP BY는 집계 함수와 함께 사용한다. SELECT에 집계 함수가 아닌 컬럼을 넣으려면 반드시 GROUP BY에도 포함해야 한다.
-- 월별 매출 요약
SELECT
DATE_TRUNC('month', sold_at) as month,
SUM(amount) as total_revenue,
COUNT(*) as sale_count
FROM chiki.sales
WHERE store_id = 'abc-123'
GROUP BY DATE_TRUNC('month', sold_at)
ORDER BY month DESC;
DATE_TRUNC('month', sold_at)은 날짜를 월 단위로 잘라낸다. 2026-02-06 15:30:00은 2026-02-01 00:00:00이 된다. 같은 월의 데이터가 하나의 그룹으로 묶인다.
JOIN
여러 테이블의 데이터를 연결해서 조회한다.
-- 판매 데이터에 매장명, 컨셉명 포함
SELECT s.*, st.name as store_name, c.name as concept_name
FROM chiki.sales s
JOIN chiki.store st ON s.store_id = st.store_id
LEFT JOIN chiki.concept c ON s.concept_id = c.concept_id
WHERE s.device_id = 'abc-123'
ORDER BY s.sold_at DESC;
JOIN(INNER JOIN)은 양쪽 테이블에 모두 데이터가 있는 경우만 결과에 포함한다. LEFT JOIN은 왼쪽 테이블의 데이터는 항상 포함하고, 오른쪽 테이블에 매칭되는 데이터가 없으면 null로 채운다.
Sale에서 concept_id가 nullable이므로 LEFT JOIN을 사용한다. 컨셉 없이 판매된 건도 결과에 포함해야 하기 때문이다.
MikroORM에서 populate 옵션이 JOIN을 대신한다.
const sale = await this.em.findOne(
Sale,
{ saleId },
{ populate: ['concept', 'store', 'device'] },
);
MikroORM은 populate를 별도의 SELECT 쿼리로 실행하거나 JOIN으로 실행할 수 있다. 기본 전략은 별도 쿼리다. { strategy: LoadStrategy.JOINED }를 지정하면 SQL JOIN을 사용한다.
인덱스
인덱스는 특정 컬럼의 값을 정렬된 구조로 별도 관리해서 조회 속도를 높인다. 책의 색인(index)과 같은 원리다.
-- session_id로 Shot을 자주 조회하므로 인덱스 추가
CREATE INDEX idx_shot_session_id ON chiki.shot (session_id);
인덱스가 없으면 PostgreSQL이 테이블의 모든 행을 순차적으로 읽어야 한다(Sequential Scan). 인덱스가 있으면 원하는 행의 위치를 바로 찾을 수 있다(Index Scan).
인덱스를 추가해야 하는 경우:
WHERE절에 자주 사용되는 컬럼 (외래 키, 상태 필드)ORDER BY에 자주 사용되는 컬럼JOIN조건에 사용되는 컬럼
인덱스를 추가하지 말아야 하는 경우:
- 행 수가 매우 적은 테이블 (전체 스캔이 더 빠르다)
- INSERT/UPDATE가 매우 빈번한 컬럼 (인덱스 유지 비용이 크다)
- 값의 종류가 극히 적은 컬럼 (boolean 같은 컬럼은 인덱스 효과가 낮다)
MikroORM에서 @Index() 데코레이터를 사용하면 Migration 생성 시 자동으로 인덱스 SQL이 포함된다.
정리
- 단순 개수/합계는
em.count()나SUM()으로 DB에서 처리하고, Entity 전체를 로드해서 코드에서 계산하는 방식은 데이터가 적을 때만 사용한다. GROUP BY는 집계 함수와 항상 함께 쓰며,SELECT에 넣은 일반 컬럼은 반드시GROUP BY에도 포함해야 한다.- 인덱스는 WHERE/ORDER BY/JOIN에 자주 쓰이는 컬럼에 추가하되, 행 수가 적거나 INSERT가 빈번한 컬럼은 오히려 비용이 커질 수 있다.