Query
쿼리
데이터베이스에서 정보를 요청하거나 조작하는 명령입니다. SQL의 SELECT, INSERT, UPDATE, DELETE부터 NoSQL의 find(), aggregate()까지, 데이터를 다루는 모든 명령문을 통칭합니다.
쿼리
데이터베이스에서 정보를 요청하거나 조작하는 명령입니다. SQL의 SELECT, INSERT, UPDATE, DELETE부터 NoSQL의 find(), aggregate()까지, 데이터를 다루는 모든 명령문을 통칭합니다.
쿼리(Query)는 데이터베이스에 "질문"을 던지는 것입니다. "서울에 사는 20대 사용자를 모두 찾아줘"라는 요청을 SQL이나 NoSQL 문법으로 표현한 것이 쿼리입니다. 데이터 조회뿐 아니라 삽입, 수정, 삭제까지 모든 데이터 조작 명령을 포함합니다.
SELECT 쿼리는 가장 자주 사용되는 조회 명령입니다. FROM으로 테이블을 지정하고, WHERE로 조건을 걸고, ORDER BY로 정렬하며, LIMIT로 개수를 제한합니다. JOIN을 통해 여러 테이블을 연결하고, GROUP BY와 집계 함수(COUNT, SUM, AVG)로 데이터를 요약할 수 있습니다.
서브쿼리(Subquery)는 쿼리 안에 포함된 또 다른 쿼리입니다. "평균 급여보다 높은 직원 조회"처럼 다른 쿼리 결과를 조건으로 사용할 때 활용합니다. 스칼라 서브쿼리(단일 값), 인라인 뷰(FROM절), EXISTS/IN 서브쿼리(WHERE절) 등 다양한 형태가 있습니다.
쿼리 실행 순서는 SQL 문법 순서와 다릅니다. FROM -> WHERE -> GROUP BY -> HAVING -> SELECT -> ORDER BY -> LIMIT 순으로 처리됩니다. 이 순서를 이해해야 올바른 쿼리를 작성하고 성능을 최적화할 수 있습니다.
쿼리 최적화는 성능의 핵심입니다. EXPLAIN으로 실행 계획을 분석하고, 인덱스를 활용하며, N+1 문제를 방지하고, 불필요한 컬럼 조회를 피해야 합니다. Slow Query Log를 모니터링하여 병목 쿼리를 찾아 개선하는 것이 중요합니다.
-- ============================================
-- 1. 기본 SELECT 쿼리
-- ============================================
-- 전체 조회
SELECT * FROM users;
-- 특정 컬럼만 조회 (권장)
SELECT id, name, email FROM users;
-- 조건 필터링
SELECT * FROM users
WHERE status = 'active'
AND created_at >= '2024-01-01';
-- 정렬과 페이지네이션
SELECT * FROM orders
ORDER BY created_at DESC
LIMIT 20 OFFSET 0;
-- ============================================
-- 2. JOIN - 테이블 연결
-- ============================================
-- INNER JOIN: 양쪽 모두 일치하는 데이터
SELECT u.name, o.total_amount, o.created_at
FROM users u
INNER JOIN orders o ON u.id = o.user_id
WHERE o.status = 'completed';
-- LEFT JOIN: 왼쪽 테이블 기준 (주문 없는 사용자도 포함)
SELECT u.name, COUNT(o.id) as order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.name;
-- 다중 JOIN
SELECT u.name, p.name as product_name, oi.quantity
FROM users u
JOIN orders o ON u.id = o.user_id
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id
WHERE o.id = 12345;
-- ============================================
-- 3. 집계 쿼리 (GROUP BY)
-- ============================================
-- 카테고리별 상품 수
SELECT category, COUNT(*) as product_count
FROM products
GROUP BY category
ORDER BY product_count DESC;
-- 월별 매출 통계
SELECT
DATE_TRUNC('month', created_at) as month,
COUNT(*) as order_count,
SUM(total_amount) as revenue,
AVG(total_amount) as avg_order_value
FROM orders
WHERE status = 'completed'
GROUP BY DATE_TRUNC('month', created_at)
HAVING SUM(total_amount) > 1000000
ORDER BY month DESC;
-- ============================================
-- 4. 서브쿼리 (Subquery)
-- ============================================
-- 스칼라 서브쿼리: 평균보다 높은 가격의 상품
SELECT name, price
FROM products
WHERE price > (SELECT AVG(price) FROM products);
-- IN 서브쿼리: 주문한 적 있는 사용자
SELECT * FROM users
WHERE id IN (
SELECT DISTINCT user_id FROM orders
WHERE created_at >= '2024-01-01'
);
-- EXISTS: 리뷰가 있는 상품
SELECT p.name FROM products p
WHERE EXISTS (
SELECT 1 FROM reviews r WHERE r.product_id = p.id
);
-- 인라인 뷰 (FROM절 서브쿼리)
SELECT category, avg_price
FROM (
SELECT category, AVG(price) as avg_price
FROM products
GROUP BY category
) as category_stats
WHERE avg_price > 50000;
-- ============================================
-- 5. 윈도우 함수 (고급)
-- ============================================
-- 순위 매기기
SELECT
name,
category,
price,
RANK() OVER (PARTITION BY category ORDER BY price DESC) as price_rank
FROM products;
-- 누적 합계
SELECT
created_at::date as date,
total_amount,
SUM(total_amount) OVER (ORDER BY created_at) as cumulative_revenue
FROM orders;
-- 이전 행 비교 (전일 대비)
SELECT
date,
revenue,
revenue - LAG(revenue) OVER (ORDER BY date) as diff_from_yesterday
FROM daily_sales;
-- ============================================
-- 6. 데이터 조작 쿼리 (DML)
-- ============================================
-- INSERT
INSERT INTO users (name, email, status)
VALUES ('홍길동', 'hong@example.com', 'active');
-- UPDATE
UPDATE users
SET status = 'inactive', updated_at = NOW()
WHERE last_login_at < NOW() - INTERVAL '6 months';
-- DELETE (주의!)
DELETE FROM sessions
WHERE expires_at < NOW();
-- UPSERT (PostgreSQL)
INSERT INTO user_settings (user_id, theme, language)
VALUES (1, 'dark', 'ko')
ON CONFLICT (user_id)
DO UPDATE SET theme = EXCLUDED.theme, language = EXCLUDED.language;
# 쿼리 예제 - Python + SQLAlchemy
from sqlalchemy import create_engine, text, select, func, and_, or_
from sqlalchemy.orm import Session, declarative_base, relationship
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Numeric
from datetime import datetime, timedelta
Base = declarative_base()
# 모델 정의
class User(Base):
__tablename__ = 'users'
id = Column(Integer, primary_key=True)
name = Column(String(100))
email = Column(String(255))
status = Column(String(20))
created_at = Column(DateTime, default=datetime.utcnow)
orders = relationship("Order", back_populates="user")
class Order(Base):
__tablename__ = 'orders'
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey('users.id'))
total_amount = Column(Numeric(10, 2))
status = Column(String(20))
created_at = Column(DateTime)
user = relationship("User", back_populates="orders")
class QueryExamples:
def __init__(self, engine):
self.engine = engine
def basic_select(self):
"""기본 SELECT 쿼리"""
with Session(self.engine) as session:
# ORM 스타일
users = session.query(User).filter(
User.status == 'active'
).order_by(User.created_at.desc()).limit(10).all()
# 2.0 스타일 (권장)
stmt = select(User).where(
User.status == 'active'
).order_by(User.created_at.desc()).limit(10)
users = session.scalars(stmt).all()
return users
def complex_filter(self):
"""복잡한 조건 필터"""
with Session(self.engine) as session:
one_month_ago = datetime.utcnow() - timedelta(days=30)
# AND, OR 조건
stmt = select(User).where(
and_(
User.status == 'active',
or_(
User.email.like('%@gmail.com'),
User.created_at >= one_month_ago
)
)
)
return session.scalars(stmt).all()
def join_query(self):
"""JOIN 쿼리"""
with Session(self.engine) as session:
# 사용자와 주문 조인
stmt = (
select(User.name, Order.total_amount, Order.created_at)
.join(Order, User.id == Order.user_id)
.where(Order.status == 'completed')
.order_by(Order.created_at.desc())
)
results = session.execute(stmt).all()
# Eager Loading으로 N+1 방지
from sqlalchemy.orm import selectinload
stmt = select(User).options(
selectinload(User.orders)
).where(User.status == 'active')
users_with_orders = session.scalars(stmt).all()
return results
def aggregate_query(self):
"""집계 쿼리"""
with Session(self.engine) as session:
# 월별 매출 통계
stmt = (
select(
func.date_trunc('month', Order.created_at).label('month'),
func.count(Order.id).label('order_count'),
func.sum(Order.total_amount).label('revenue'),
func.avg(Order.total_amount).label('avg_order')
)
.where(Order.status == 'completed')
.group_by(func.date_trunc('month', Order.created_at))
.having(func.sum(Order.total_amount) > 1000000)
.order_by(text('month DESC'))
)
return session.execute(stmt).all()
def subquery_example(self):
"""서브쿼리 예제"""
with Session(self.engine) as session:
# 주문한 적 있는 사용자
order_subquery = (
select(Order.user_id)
.where(Order.created_at >= datetime(2024, 1, 1))
.distinct()
.subquery()
)
stmt = select(User).where(
User.id.in_(select(order_subquery.c.user_id))
)
return session.scalars(stmt).all()
def raw_sql(self):
"""Raw SQL 쿼리 (복잡한 경우)"""
with Session(self.engine) as session:
result = session.execute(text("""
SELECT u.name,
COUNT(o.id) as order_count,
SUM(o.total_amount) as total_spent
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.status = :status
GROUP BY u.id, u.name
HAVING COUNT(o.id) > :min_orders
ORDER BY total_spent DESC
"""), {"status": "active", "min_orders": 5})
return [dict(row._mapping) for row in result]
def pagination(self, page: int = 1, per_page: int = 20):
"""페이지네이션"""
with Session(self.engine) as session:
offset = (page - 1) * per_page
# 전체 개수
count_stmt = select(func.count(User.id)).where(User.status == 'active')
total = session.scalar(count_stmt)
# 페이지 데이터
stmt = (
select(User)
.where(User.status == 'active')
.order_by(User.id)
.offset(offset)
.limit(per_page)
)
users = session.scalars(stmt).all()
return {
"data": users,
"total": total,
"page": page,
"per_page": per_page,
"total_pages": (total + per_page - 1) // per_page
}
# 사용 예시
if __name__ == "__main__":
engine = create_engine("postgresql://localhost/mydb")
examples = QueryExamples(engine)
# 기본 조회
users = examples.basic_select()
# 집계 쿼리
stats = examples.aggregate_query()
print(stats)
// 쿼리 예제 - Node.js + Prisma
import { PrismaClient, Prisma } from '@prisma/client';
const prisma = new PrismaClient({
log: ['query'], // 실행되는 SQL 확인
});
class QueryExamples {
/**
* 기본 SELECT 쿼리
*/
async basicSelect() {
// 전체 조회
const users = await prisma.user.findMany({
where: { status: 'active' },
orderBy: { createdAt: 'desc' },
take: 10,
});
// 특정 컬럼만 조회
const emails = await prisma.user.findMany({
select: { id: true, email: true },
where: { status: 'active' },
});
return users;
}
/**
* 복잡한 조건 필터
*/
async complexFilter() {
const oneMonthAgo = new Date();
oneMonthAgo.setDate(oneMonthAgo.getDate() - 30);
// AND, OR 조건
const users = await prisma.user.findMany({
where: {
AND: [
{ status: 'active' },
{
OR: [
{ email: { contains: '@gmail.com' } },
{ createdAt: { gte: oneMonthAgo } },
],
},
],
},
});
// NOT 조건
const notDeleted = await prisma.user.findMany({
where: {
NOT: { status: 'deleted' },
},
});
return users;
}
/**
* JOIN (관계 포함)
*/
async joinQuery() {
// 사용자와 주문 함께 조회
const usersWithOrders = await prisma.user.findMany({
where: { status: 'active' },
include: {
orders: {
where: { status: 'completed' },
orderBy: { createdAt: 'desc' },
take: 5,
},
},
});
// 중첩 관계 (주문 -> 주문상품 -> 상품)
const ordersWithDetails = await prisma.order.findMany({
include: {
user: { select: { name: true, email: true } },
orderItems: {
include: {
product: { select: { name: true, price: true } },
},
},
},
});
return usersWithOrders;
}
/**
* 집계 쿼리
*/
async aggregateQuery() {
// 기본 집계
const stats = await prisma.order.aggregate({
_count: { id: true },
_sum: { totalAmount: true },
_avg: { totalAmount: true },
_min: { totalAmount: true },
_max: { totalAmount: true },
where: { status: 'completed' },
});
// GROUP BY
const categoryStats = await prisma.product.groupBy({
by: ['category'],
_count: { id: true },
_avg: { price: true },
orderBy: { _count: { id: 'desc' } },
});
// HAVING
const popularCategories = await prisma.product.groupBy({
by: ['category'],
_count: { id: true },
having: {
id: { _count: { gt: 10 } },
},
});
return { stats, categoryStats };
}
/**
* Raw SQL 쿼리 (복잡한 경우)
*/
async rawQuery() {
// 타입 안전 Raw SQL
const result = await prisma.$queryRaw<
{ name: string; order_count: bigint; total_spent: number }[]
>`
SELECT u.name,
COUNT(o.id) as order_count,
COALESCE(SUM(o.total_amount), 0) as total_spent
FROM "User" u
LEFT JOIN "Order" o ON u.id = o.user_id
WHERE u.status = 'active'
GROUP BY u.id, u.name
HAVING COUNT(o.id) > 5
ORDER BY total_spent DESC
`;
// 동적 쿼리
const status = 'active';
const dynamicResult = await prisma.$queryRaw`
SELECT * FROM "User" WHERE status = ${status}
`;
return result;
}
/**
* 페이지네이션
*/
async pagination(page = 1, perPage = 20) {
const skip = (page - 1) * perPage;
// 트랜잭션으로 데이터와 카운트 동시 조회
const [users, total] = await prisma.$transaction([
prisma.user.findMany({
where: { status: 'active' },
orderBy: { id: 'asc' },
skip,
take: perPage,
}),
prisma.user.count({
where: { status: 'active' },
}),
]);
return {
data: users,
meta: {
total,
page,
perPage,
totalPages: Math.ceil(total / perPage),
},
};
}
/**
* 커서 기반 페이지네이션 (대용량 최적화)
*/
async cursorPagination(cursor?: number, limit = 20) {
const users = await prisma.user.findMany({
take: limit + 1, // 다음 페이지 존재 여부 확인
...(cursor && {
cursor: { id: cursor },
skip: 1, // 커서 자체는 제외
}),
orderBy: { id: 'asc' },
});
const hasNextPage = users.length > limit;
const data = hasNextPage ? users.slice(0, -1) : users;
const nextCursor = hasNextPage ? data[data.length - 1]?.id : null;
return { data, nextCursor, hasNextPage };
}
/**
* 트랜잭션 쿼리
*/
async transactionQuery(fromUserId: number, toUserId: number, amount: number) {
return prisma.$transaction(async (tx) => {
// 1. 출금
const sender = await tx.wallet.update({
where: { userId: fromUserId },
data: { balance: { decrement: amount } },
});
// 잔액 체크
if (sender.balance < 0) {
throw new Error('잔액 부족');
}
// 2. 입금
await tx.wallet.update({
where: { userId: toUserId },
data: { balance: { increment: amount } },
});
// 3. 거래 기록
return tx.transaction.create({
data: { fromUserId, toUserId, amount, type: 'transfer' },
});
});
}
}
// 사용 예시
async function main() {
const examples = new QueryExamples();
// 기본 조회
const users = await examples.basicSelect();
// 집계
const stats = await examples.aggregateQuery();
console.log(stats);
// 커서 페이지네이션
let cursor: number | undefined;
do {
const { data, nextCursor } = await examples.cursorPagination(cursor);
console.log(`Fetched ${data.length} users`);
cursor = nextCursor ?? undefined;
} while (cursor);
await prisma.$disconnect();
}
main();
"이 API가 느린 이유가 N+1 쿼리 때문이에요. 사용자 목록 조회할 때 각 사용자의 주문을 별도 쿼리로 가져오고 있거든요. JOIN이나 Eager Loading으로 한 번에 가져오면 쿼리 수를 100개에서 1개로 줄일 수 있습니다."
"SELECT * 대신 필요한 컬럼만 명시해주세요. 특히 이 테이블에 BLOB 컬럼이 있어서 불필요하게 큰 데이터를 전송하게 됩니다. select: { id, name, email } 이렇게 바꾸면 응답 크기가 90% 줄어들 거예요."
"Slow Query Log를 분석해보니 이 집계 쿼리가 5초 이상 걸리네요. 매번 전체 테이블을 스캔하고 있어요. 날짜 범위 조건을 추가하고 적절한 인덱스를 만들면 0.1초로 줄일 수 있습니다."
사용자 입력을 쿼리에 직접 연결하지 마세요. 반드시 파라미터 바인딩(Prepared Statement)을 사용하세요. "WHERE id = " + userId 대신 "WHERE id = $1"로 작성합니다.
루프 안에서 쿼리를 실행하면 성능이 급격히 저하됩니다. JOIN, Eager Loading, 또는 IN 절로 한 번에 조회하세요.
OFFSET이 커지면 성능이 급격히 저하됩니다(OFFSET 10000은 10000개를 읽고 버림). 대용량 데이터는 커서 기반 페이지네이션을 사용하세요.
EXPLAIN으로 실행 계획 분석, 필요한 컬럼만 SELECT, 인덱스 활용, 적절한 LIMIT 설정, Slow Query 모니터링, 복잡한 쿼리는 뷰나 함수로 캡슐화.