이미지 로딩 중...

비즈니스 지표 이해 및 데이터베이스 설계 완벽 가이드 - 슬라이드 1/11
A

AI Generated

2025. 11. 15. · 6 Views

비즈니스 지표 이해 및 데이터베이스 설계 완벽 가이드

비즈니스 성과를 측정하는 핵심 지표부터 데이터베이스 설계까지, 실무에서 바로 활용할 수 있는 데이터 분석의 모든 것을 배워봅니다. Python과 Polars를 활용한 효율적인 데이터 처리 방법을 함께 알아봅니다.


목차

  1. KPI와 비즈니스 지표의 이해
  2. 전환율과 퍼널 분석
  3. 코호트 분석과 리텐션
  4. 데이터베이스 정규화와 테이블 설계
  5. 팩트 테이블과 디멘전 테이블 (스타 스키마)
  6. 인덱스 전략과 쿼리 최적화
  7. 트랜잭션과 ACID 속성
  8. 윈도우 함수를 활용한 고급 분석
  9. 데이터 품질 검증과 이상치 탐지
  10. ETL 파이프라인 설계와 자동화

1. KPI와 비즈니스 지표의 이해

시작하며

여러분이 데이터 분석 업무를 시작했을 때, 상사나 팀장이 "이번 달 KPI 달성률은 어떻게 되나요?"라고 물어본 적 있나요? 그때 어떤 숫자를 봐야 할지 막막했던 경험, 저도 있습니다.

많은 신입 개발자와 분석가들이 데이터는 수집하지만, 정작 비즈니스 의사결정에 필요한 핵심 지표가 무엇인지 모르는 경우가 많습니다. 매출액만 보거나, 방문자 수만 체크하다가 정작 중요한 수익성이나 고객 만족도를 놓치게 됩니다.

바로 이럴 때 필요한 것이 KPI(Key Performance Indicator)와 비즈니스 지표에 대한 정확한 이해입니다. 올바른 지표를 설정하고 측정하면, 데이터가 비즈니스 성장의 나침반이 되어줍니다.

개요

간단히 말해서, KPI는 비즈니스 목표 달성 여부를 객관적으로 측정할 수 있는 핵심 성과 지표입니다. KPI가 필요한 이유는 명확합니다.

감으로 일하는 것과 데이터 기반으로 의사결정하는 것은 천지 차이입니다. 예를 들어, 이커머스 사이트를 운영한다면 단순히 "매출이 늘었다"가 아니라 "신규 고객 획득 비용(CAC)이 20% 줄었고, 고객 생애 가치(LTV)가 30% 증가했다"처럼 구체적으로 성과를 측정해야 합니다.

기존에는 엑셀로 수동으로 집계하고 수식을 만들었다면, 이제는 Python과 Polars를 활용해 자동화된 지표 대시보드를 만들 수 있습니다. 핵심 비즈니스 지표는 크게 세 가지로 나뉩니다: 1) 성장 지표(매출, 사용자 수), 2) 효율성 지표(전환율, ROI), 3) 고객 지표(이탈률, NPS).

이러한 지표들을 조합해서 보면 비즈니스의 전체 그림이 보이기 시작합니다.

코드 예제

import polars as pl
from datetime import datetime, timedelta

# 매출 데이터 샘플 생성
sales_data = pl.DataFrame({
    "date": [datetime.now() - timedelta(days=i) for i in range(30)],
    "revenue": [15000 + i*500 for i in range(30)],
    "orders": [150 + i*5 for i in range(30)],
    "new_customers": [30 + i*2 for i in range(30)]
})

# 핵심 KPI 계산
kpi_metrics = sales_data.select([
    pl.col("revenue").sum().alias("total_revenue"),
    pl.col("orders").sum().alias("total_orders"),
    (pl.col("revenue").sum() / pl.col("orders").sum()).alias("avg_order_value"),
    pl.col("new_customers").sum().alias("total_new_customers"),
    (pl.col("revenue").sum() / pl.col("new_customers").sum()).alias("revenue_per_customer")
])

print(kpi_metrics)

설명

이것이 하는 일: 위 코드는 30일간의 매출 데이터를 기반으로 가장 중요한 5가지 KPI를 자동으로 계산합니다. 첫 번째로, Polars DataFrame을 생성하여 날짜별 매출액, 주문 수, 신규 고객 수를 구조화된 형태로 저장합니다.

Polars를 사용하는 이유는 Pandas보다 훨씬 빠른 성능을 제공하기 때문입니다. 특히 대용량 데이터를 다룰 때 그 차이가 극명하게 드러납니다.

그 다음으로, select 메서드를 사용해 여러 KPI를 한 번에 계산합니다. total_revenue는 전체 매출의 합계이고, avg_order_value는 주문당 평균 금액을 나타냅니다.

이 지표들은 각각 비즈니스의 다른 측면을 보여줍니다. revenue_per_customer 계산이 특히 중요한데, 이는 고객 한 명당 얼마나 많은 수익을 창출하는지 보여줍니다.

이 값이 낮다면 마케팅 전략을 재검토해야 할 신호이고, 높다면 고객 유지 전략이 잘 작동하고 있다는 의미입니다. 여러분이 이 코드를 사용하면 매일 아침 자동으로 전날의 KPI 리포트를 받아볼 수 있습니다.

엑셀로 수동 계산할 때 1시간 걸리던 작업이 1초 만에 완료되며, 실수 가능성도 0%로 줄어듭니다.

실전 팁

💡 KPI는 5-7개 이내로 유지하세요. 너무 많은 지표를 추적하면 정작 중요한 것을 놓칩니다. AARRR 프레임워크(획득-활성화-유지-수익-추천)를 기준으로 각 단계별 1-2개씩 선정하는 것이 효과적입니다.

💡 절대값보다 변화율을 주목하세요. "매출 1억"보다 "전월 대비 20% 성장"이 더 의미 있는 정보입니다. Polars의 pct_change() 함수를 활용하면 쉽게 계산할 수 있습니다.

💡 지표에는 반드시 목표값을 설정하세요. "전환율 2.5%"만 보는 것이 아니라 "목표 3.0% 대비 83% 달성"처럼 맥락을 함께 제공해야 실행 가능한 인사이트가 됩니다.

💡 Leading Indicator와 Lagging Indicator를 구분하세요. 매출은 후행 지표이고, 웹사이트 방문자 수나 이메일 오픈율은 선행 지표입니다. 선행 지표를 먼저 개선해야 후행 지표가 따라옵니다.

💡 세그먼트별로 KPI를 나눠서 보세요. 전체 평균만 보면 문제가 숨겨집니다. 연령대별, 지역별, 유입 채널별로 나눠보면 어디에 집중해야 할지 명확해집니다.


2. 전환율과 퍼널 분석

시작하며

여러분의 웹사이트에 하루 1,000명이 방문하는데 실제 구매는 10건뿐이라면, 어디서 고객을 잃고 있는지 궁금하지 않으신가요? 많은 트래픽을 확보했는데도 매출이 안 나온다면, 그건 퍼널 어딘가에 구멍이 뚫린 것입니다.

이 문제를 해결하려면 단순히 "전환율이 낮다"는 사실만 아는 것이 아니라, 정확히 어느 단계에서 얼마나 많은 고객이 이탈하는지 알아야 합니다. 방문 → 상품 조회 → 장바구니 → 결제 각 단계마다 전환율을 측정하지 않으면 개선 지점을 찾을 수 없습니다.

바로 이럴 때 필요한 것이 퍼널 분석입니다. 각 단계별 전환율을 측정하고 병목 구간을 찾아내면, 마케팅 예산을 어디에 투입해야 할지 명확해집니다.

개요

간단히 말해서, 전환율은 특정 행동을 완료한 사용자의 비율이고, 퍼널 분석은 여러 단계로 이루어진 프로세스에서 각 단계별 전환율을 추적하는 방법입니다. 퍼널 분석이 필요한 이유는 사용자 여정의 병목 지점을 정확히 파악할 수 있기 때문입니다.

예를 들어, 회원가입 퍼널에서 "이메일 입력 → 비밀번호 설정 → 프로필 작성 → 완료" 각 단계의 이탈률을 보면, 어느 단계가 너무 복잡한지 알 수 있습니다. 기존에는 Google Analytics에서 제공하는 퍼널 리포트만 봤다면, 이제는 직접 데이터베이스에서 로그를 가져와 커스텀 퍼널을 만들 수 있습니다.

핵심 개념은 코호트 분석과 결합하는 것입니다. 같은 시기에 유입된 사용자 그룹별로 전환율을 비교하면, 마케팅 캠페인의 효과나 제품 개선의 영향을 정확히 측정할 수 있습니다.

또한 평균 전환율뿐 아니라 중앙값과 분포도 함께 봐야 극단값에 속지 않습니다.

코드 예제

import polars as pl

# 사용자 퍼널 데이터
funnel_data = pl.DataFrame({
    "user_id": range(1, 1001),
    "visited": [True] * 1000,
    "viewed_product": [True if i < 600 else False for i in range(1000)],
    "added_to_cart": [True if i < 200 else False for i in range(1000)],
    "completed_purchase": [True if i < 50 else False for i in range(1000)]
})

# 각 단계별 전환율 계산
total_users = funnel_data.height
conversions = {
    "방문": funnel_data.filter(pl.col("visited")).height,
    "상품조회": funnel_data.filter(pl.col("viewed_product")).height,
    "장바구니": funnel_data.filter(pl.col("added_to_cart")).height,
    "구매완료": funnel_data.filter(pl.col("completed_purchase")).height
}

# 단계별 전환율과 이탈률 계산
for i, (step, count) in enumerate(conversions.items()):
    conversion_rate = (count / total_users) * 100
    if i > 0:
        prev_count = list(conversions.values())[i-1]
        step_conversion = (count / prev_count) * 100
        print(f"{step}: {count}명 ({conversion_rate:.1f}% 전체 전환율, {step_conversion:.1f}% 단계 전환율)")
    else:
        print(f"{step}: {count}명 ({conversion_rate:.1f}%)")

설명

이것이 하는 일: 위 코드는 1,000명의 사용자가 방문부터 구매까지 4단계 퍼널을 거치는 과정에서 각 단계의 전환율과 이탈률을 계산합니다. 첫 번째로, 사용자별로 각 단계의 완료 여부를 Boolean 값으로 저장합니다.

실무에서는 이 데이터가 이벤트 로그 테이블에서 추출되는데, 예를 들어 "page_view", "product_click", "add_to_cart", "purchase_complete" 같은 이벤트들을 조인하여 만듭니다. 그 다음으로, filter 메서드를 사용해 각 단계를 완료한 사용자 수를 집계합니다.

Polars의 height 속성은 Pandas의 len()과 같은 역할을 하지만 훨씬 빠르게 동작합니다. 이렇게 계산한 숫자로 전체 전환율과 단계별 전환율 두 가지를 모두 구합니다.

전체 전환율은 "전체 방문자 중 몇 %가 구매했는가"를 보여주고, 단계별 전환율은 "이전 단계를 완료한 사람 중 몇 %가 다음 단계로 넘어갔는가"를 보여줍니다. 예를 들어, 장바구니에 담은 200명 중 50명만 구매했다면 단계 전환율은 25%입니다.

여러분이 이 코드를 실행하면 "상품조회에서 장바구니로 넘어가는 전환율이 33%밖에 안 된다"는 구체적인 문제를 발견할 수 있습니다. 이는 상품 상세 페이지에 문제가 있다는 신호이며, A/B 테스트로 개선할 수 있는 명확한 타깃이 됩니다.

실무에서는 이 분석을 일별, 주별로 반복 실행하여 트렌드를 추적합니다. 특정 날짜에 전환율이 급락했다면 그날 배포한 코드나 진행한 마케팅 캠페인에 문제가 있을 가능성이 높습니다.

실전 팁

💡 퍼널은 너무 많은 단계로 나누지 마세요. 3-5단계가 적당하며, 그 이상은 분석이 복잡해지고 인사이트가 흐려집니다. 핵심적인 의사결정 포인트만 포함하세요.

💡 시간 윈도우를 설정하세요. "방문 후 24시간 이내 구매"처럼 기한을 정해야 합니다. 일주일 뒤에 구매한 것까지 포함하면 데이터가 왜곡됩니다. Polars의 date_range와 filter를 조합하면 쉽게 구현할 수 있습니다.

💡 세그먼트별로 퍼널을 비교하세요. 모바일 vs 데스크톱, 유료광고 vs 자연유입처럼 나눠서 보면 어느 채널이 더 효율적인지 명확해집니다. group_by를 활용하면 됩니다.

💡 역퍼널도 분석하세요. 구매한 고객이 그 이전에 어떤 경로를 거쳤는지 역추적하면, 성공 패턴을 발견할 수 있습니다. 이를 바탕으로 다른 사용자에게도 같은 경로를 유도할 수 있습니다.

💡 전환율만 보지 말고 절대 숫자도 함께 보세요. 전환율 50%라도 모수가 10명이면 의미가 없습니다. 통계적 유의성을 확보하려면 최소 100명 이상의 샘플이 필요합니다.


3. 코호트 분석과 리텐션

시작하며

여러분이 운영하는 앱의 다운로드 수는 계속 늘어나는데, 활성 사용자 수가 정체되어 있다면 무엇이 문제일까요? 신규 유입은 많지만 기존 사용자가 계속 떠나고 있다는 신호입니다.

많은 스타트업이 신규 사용자 획득에만 집중하다가, 정작 리텐션(재방문율)이 낮아서 "구멍 뚫린 양동이에 물 붓기"를 하고 있습니다. 매달 1,000명을 데려와도 900명이 떠난다면, 성장이 아니라 제자리걸음입니다.

바로 이럴 때 필요한 것이 코호트 분석입니다. 같은 시기에 가입한 사용자 그룹을 추적하면, 제품 개선이 실제로 리텐션을 높이는지 객관적으로 확인할 수 있습니다.

개요

간단히 말해서, 코호트 분석은 특정 시점에 공통된 경험을 한 사용자 그룹을 시간에 따라 추적하여 행동 패턴을 분석하는 방법입니다. 코호트 분석이 필요한 이유는 제품 변화의 영향을 정확히 측정하기 위해서입니다.

예를 들어, 3월에 온보딩 프로세스를 개선했다면, 3월 가입자 코호트의 리텐션이 2월 코호트보다 높은지 비교해야 합니다. 전체 평균만 보면 이런 변화가 묻혀버립니다.

기존에는 엑셀 피벗 테이블로 수동으로 코호트를 만들었다면, 이제는 Python으로 자동화하여 매일 업데이트되는 코호트 리포트를 만들 수 있습니다. 코호트의 핵심 지표는 N일차 리텐션입니다.

Day 1, Day 7, Day 30 리텐션을 보면 사용자가 언제 가장 많이 이탈하는지 알 수 있습니다. 보통 Day 1 리텐션이 40% 미만이면 온보딩에 문제가 있고, Day 30이 낮으면 장기적 가치 제공에 실패한 것입니다.

코드 예제

import polars as pl
from datetime import datetime, timedelta

# 사용자 활동 로그 샘플
activity_log = pl.DataFrame({
    "user_id": [1,1,1,2,2,3,3,3,3,4],
    "signup_date": [datetime(2024,1,1)] * 3 + [datetime(2024,1,1)] * 2 + [datetime(2024,1,8)] * 4 + [datetime(2024,1,8)],
    "activity_date": [
        datetime(2024,1,1), datetime(2024,1,2), datetime(2024,1,8),
        datetime(2024,1,1), datetime(2024,1,3),
        datetime(2024,1,8), datetime(2024,1,9), datetime(2024,1,15), datetime(2024,1,22),
        datetime(2024,1,8)
    ]
})

# 코호트별 리텐션 계산
cohort_analysis = activity_log.with_columns([
    pl.col("signup_date").dt.strftime("%Y-%m").alias("cohort"),
    ((pl.col("activity_date") - pl.col("signup_date")).dt.total_days() // 7).alias("week_number")
]).group_by(["cohort", "week_number"]).agg([
    pl.col("user_id").n_unique().alias("active_users")
])

# 코호트 크기 계산
cohort_sizes = activity_log.group_by("signup_date").agg([
    pl.col("user_id").n_unique().alias("cohort_size")
]).with_columns([
    pl.col("signup_date").dt.strftime("%Y-%m").alias("cohort")
])

print("코호트별 주차 리텐션:")
print(cohort_analysis.sort(["cohort", "week_number"]))

설명

이것이 하는 일: 위 코드는 사용자의 가입일과 활동일을 기반으로 코호트를 생성하고, 각 코호트가 몇 주차에 얼마나 활성화되어 있는지 계산합니다. 첫 번째로, with_columns를 사용해 두 개의 파생 컬럼을 생성합니다.

cohort는 가입 월을 나타내고, week_number는 가입 후 몇 주차인지를 계산합니다. total_days()로 일수 차이를 구한 뒤 7로 나누면 주차가 나옵니다.

이렇게 하면 "2024-01 코호트의 Week 0, Week 1, Week 2" 식으로 데이터가 구조화됩니다. 그 다음으로, group_by로 코호트와 주차별로 그룹화하여 활성 사용자 수를 집계합니다.

n_unique()는 중복을 제거한 고유 사용자 수를 세는데, 한 사용자가 같은 주에 여러 번 활동해도 1명으로 카운트됩니다. 이것이 MAU(Monthly Active Users) 계산의 핵심입니다.

cohort_sizes를 별도로 계산하는 이유는 리텐션 비율을 구하기 위해서입니다. Week 2에 10명이 활성화되어 있어도, 전체 코호트 크기가 100명이면 리텐션은 10%이고, 20명이면 50%입니다.

이 두 숫자를 조인하면 정확한 리텐션율을 얻을 수 있습니다. 여러분이 이 코드를 실행하면 "1월 가입 코호트는 4주 후 30% 리텐션, 2월 코호트는 40% 리텐션"처럼 구체적인 비교가 가능합니다.

만약 2월에 신기능을 추가했다면, 그 효과가 리텐션 10%p 증가로 나타난 것입니다. 실무에서는 이 데이터를 히트맵으로 시각화합니다.

가로축은 주차, 세로축은 코호트, 셀 색깔은 리텐션율로 표시하면 패턴이 한눈에 보입니다. Seaborn의 heatmap이나 Plotly를 사용하면 쉽게 만들 수 있습니다.

코호트 분석의 진짜 힘은 인과관계 파악입니다. "전체 리텐션이 올랐다"는 상관관계일 뿐이지만, "3월 코호트부터 리텐션이 올랐고, 3월에 푸시 알림 기능을 추가했다"면 인과관계를 추론할 수 있습니다.

실전 팁

💡 코호트는 가입일 기준이 가장 일반적이지만, 첫 구매일, 첫 결제일 등 다른 기준도 시도해보세요. 상황에 따라 더 의미 있는 인사이트를 얻을 수 있습니다.

💡 Day 1, Day 7, Day 30 리텐션을 벤치마크하세요. 업계별로 다르지만, 모바일 앱은 보통 Day 1이 40%, Day 7이 20%, Day 30이 10% 정도입니다. 이보다 낮으면 개선이 시급합니다.

💡 리텐션 커브의 형태를 주목하세요. 급격히 떨어지다가 평평해지면 정상이지만, 계속 급락하면 핵심 가치를 전달하지 못하고 있다는 뜻입니다. "스마일 커브"(초반 떨어졌다가 다시 오름)가 이상적입니다.

💡 네거티브 코호트도 분석하세요. 이탈한 사용자들의 공통점을 찾으면 이탈 징후를 조기에 감지할 수 있습니다. 예를 들어 "가입 후 3일 내 친구 추가 안 한 사용자는 90% 이탈"같은 패턴을 발견할 수 있습니다.

💡 Polars의 lazy() 모드를 활용하세요. 대용량 로그 데이터를 다룰 때 lazy evaluation을 사용하면 쿼리 최적화가 자동으로 이루어져 10배 이상 빠릅니다. 마지막에 collect()만 호출하면 됩니다.


4. 데이터베이스 정규화와 테이블 설계

시작하며

여러분이 데이터베이스를 설계할 때, 모든 정보를 하나의 거대한 테이블에 때려 넣고 싶은 유혹을 느낀 적 있나요? 처음에는 간단해 보이지만, 나중에 고객 주소를 변경하려는데 10개 주문 레코드를 모두 수정해야 하는 악몽을 겪게 됩니다.

이런 문제는 데이터 중복과 일관성 문제로 이어집니다. 같은 고객 정보가 여러 곳에 저장되면, 한 곳만 업데이트하고 다른 곳은 놓칠 수 있습니다.

그러면 "이 고객의 진짜 주소가 뭐지?"라는 질문에 답할 수 없게 됩니다. 바로 이럴 때 필요한 것이 데이터베이스 정규화입니다.

데이터를 논리적으로 분리하고 관계를 정의하면, 데이터 무결성을 유지하면서도 유연한 쿼리가 가능합니다.

개요

간단히 말해서, 정규화는 데이터 중복을 최소화하고 무결성을 보장하기 위해 테이블을 논리적으로 분리하는 프로세스입니다. 정규화가 필요한 이유는 데이터 이상 현상(anomaly)을 방지하기 위해서입니다.

예를 들어, 주문 테이블에 고객 정보를 함께 저장하면 삽입 이상(주문 없이 고객 추가 불가), 갱신 이상(주소 변경 시 여러 레코드 수정), 삭제 이상(주문 삭제 시 고객 정보도 삭제)이 발생합니다. 기존에는 엑셀처럼 모든 걸 하나의 시트에 저장했다면, 이제는 고객, 주문, 상품을 별도 테이블로 분리하고 외래키로 연결합니다.

정규화는 1NF부터 5NF까지 있지만, 실무에서는 주로 3NF까지만 적용합니다. 1NF는 원자값(더 이상 쪼갤 수 없는 값), 2NF는 부분 함수 종속 제거, 3NF는 이행 함수 종속 제거입니다.

너무 과도한 정규화는 조인이 많아져 성능 저하를 일으킬 수 있으므로, 때로는 의도적으로 비정규화를 선택하기도 합니다.

코드 예제

-- 비정규화된 잘못된 예 (Anti-pattern)
-- CREATE TABLE orders_bad (
--     order_id INT PRIMARY KEY,
--     customer_name VARCHAR(100),
--     customer_email VARCHAR(100),
--     customer_address TEXT,
--     product_names TEXT, -- "상품A, 상품B" 처럼 저장
--     total_price DECIMAL(10,2)
-- );

-- 정규화된 올바른 설계 (3NF)
CREATE TABLE customers (
    customer_id SERIAL PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    email VARCHAR(100) UNIQUE NOT NULL,
    address TEXT
);

CREATE TABLE products (
    product_id SERIAL PRIMARY KEY,
    name VARCHAR(200) NOT NULL,
    price DECIMAL(10,2) NOT NULL
);

CREATE TABLE orders (
    order_id SERIAL PRIMARY KEY,
    customer_id INT NOT NULL REFERENCES customers(customer_id),
    order_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    total_amount DECIMAL(10,2) NOT NULL
);

CREATE TABLE order_items (
    order_item_id SERIAL PRIMARY KEY,
    order_id INT NOT NULL REFERENCES orders(order_id),
    product_id INT NOT NULL REFERENCES products(product_id),
    quantity INT NOT NULL CHECK (quantity > 0),
    unit_price DECIMAL(10,2) NOT NULL
);

-- 인덱스 추가로 조인 성능 최적화
CREATE INDEX idx_orders_customer ON orders(customer_id);
CREATE INDEX idx_order_items_order ON order_items(order_id);

설명

이것이 하는 일: 위 SQL은 이커머스의 핵심 데이터를 4개 테이블로 정규화하여 설계하고, 성능을 위한 인덱스까지 추가합니다. 첫 번째로, customers 테이블은 고객 정보만 담당합니다.

email을 UNIQUE로 설정하여 중복 가입을 방지하고, customer_id라는 대리키를 사용합니다. 자연키(이메일)를 기본키로 쓸 수도 있지만, 나중에 이메일 변경이 필요할 때 모든 관련 테이블을 업데이트해야 하므로 대리키가 더 안전합니다.

그 다음으로, orders와 order_items를 분리한 것이 핵심입니다. 하나의 주문에 여러 상품이 있을 수 있으므로(다대다 관계), 중간 테이블인 order_items가 필요합니다.

이렇게 하면 "주문 12345의 3번째 상품"처럼 개별 품목을 관리할 수 있습니다. REFERENCES 키워드는 외래키 제약조건을 설정합니다.

order_id가 실제 존재하지 않는 주문을 참조하려고 하면 데이터베이스가 에러를 발생시킵니다. 이것이 참조 무결성(referential integrity)이며, 데이터의 신뢰성을 자동으로 보장해줍니다.

CHECK 제약조건으로 비즈니스 룰도 강제할 수 있습니다. quantity > 0 조건은 음수 수량을 방지하며, 이런 검증을 애플리케이션 코드가 아닌 데이터베이스 레벨에서 하면 어떤 경로로 데이터가 들어와도 안전합니다.

마지막 인덱스 설정이 성능의 핵심입니다. "고객 123의 모든 주문 조회" 같은 쿼리가 자주 실행되므로, customer_id에 인덱스를 만들면 풀 테이블 스캔 없이 빠르게 찾을 수 있습니다.

인덱스 없으면 100만 건 테이블에서 1초 걸릴 쿼리가 인덱스 있으면 10ms로 단축됩니다. 여러분이 이 설계를 사용하면 "고객 주소 변경"은 customers 테이블 1개 레코드만 업데이트하면 되고, 모든 과거 주문이 자동으로 최신 정보를 참조합니다.

만약 주문 당시의 배송지를 별도로 저장하고 싶다면, orders 테이블에 shipping_address 컬럼을 추가하는 의도적 비정규화를 선택할 수도 있습니다.

실전 팁

💡 무조건 정규화가 정답은 아닙니다. 읽기가 99%인 분석 시스템에서는 의도적으로 비정규화하여 조인을 줄이는 것이 더 효율적입니다. OLTP는 정규화, OLAP은 비정규화가 일반적입니다.

💡 복합키보다는 단일 대리키를 사용하세요. (customer_id, order_id)처럼 두 개를 조합한 기본키는 코드가 복잡해지고 인덱스도 비효율적입니다. 대신 order_item_id라는 단일 키를 만드는 것이 깔끔합니다.

💡 삭제는 물리적 삭제 대신 논리적 삭제를 고려하세요. deleted_at 컬럼을 추가하고 NULL이면 활성, 값이 있으면 삭제된 것으로 처리하면 데이터 복구와 감사 추적이 가능합니다.

💡 JSON 컬럼을 남용하지 마세요. PostgreSQL의 JSONB는 편리하지만, 구조화된 데이터를 JSON에 넣으면 정규화의 이점을 모두 잃습니다. 진짜 비정형 데이터(메타데이터, 설정값)에만 사용하세요.

💡 마이그레이션 전략을 세우세요. 스키마 변경 시 다운타임 없이 배포하려면, 1) 새 컬럼 추가 (NULL 허용), 2) 애플리케이션 배포, 3) 데이터 마이그레이션, 4) NOT NULL 제약조건 추가 순서로 진행해야 합니다.


5. 팩트 테이블과 디멘전 테이블 (스타 스키마)

시작하며

여러분이 데이터 분석가에게 "지난 분기 서울 지역 30대 여성의 모바일 구매 금액"을 물어봤는데 쿼리 실행에 30초가 걸린다면, 데이터베이스 설계에 문제가 있는 것입니다. 이런 문제는 OLTP(트랜잭션 처리)용 정규화 스키마를 그대로 분석에 사용할 때 발생합니다.

10개 테이블을 조인하는 복잡한 쿼리는 실시간 분석에 적합하지 않으며, 데이터 분석가들은 SQL 작성에 너무 많은 시간을 소비하게 됩니다. 바로 이럴 때 필요한 것이 스타 스키마(Star Schema)입니다.

중앙의 팩트 테이블과 주변의 디멘전 테이블로 구성하면, 복잡한 분석 쿼리가 간단하고 빠르게 실행됩니다.

개요

간단히 말해서, 스타 스키마는 중앙의 팩트 테이블(측정값)을 여러 디멘전 테이블(분석 차원)이 둘러싸는 데이터 웨어하우스 설계 패턴입니다. 스타 스키마가 필요한 이유는 분석 쿼리의 성능과 단순성을 극대화하기 위해서입니다.

예를 들어, "제품별, 지역별, 월별 매출 분석"이 필요할 때, 팩트 테이블에는 매출액과 수량 같은 측정값만 있고, 제품/지역/시간 정보는 각각 별도 디멘전 테이블에서 조인하여 가져옵니다. 기존 정규화 스키마에서는 customers → orders → order_items → products 같은 체인 조인이 필요했다면, 스타 스키마에서는 fact_sales를 중심으로 모든 디멘전을 직접 조인할 수 있습니다.

핵심 개념은 팩트와 디멘전의 구분입니다. 팩트는 "측정 가능한 숫자"(매출액, 수량, 클릭 수), 디멘전은 "분석 기준"(누가, 언제, 어디서, 무엇을)입니다.

팩트 테이블은 좁고 길게(많은 로우, 적은 컬럼), 디멘전 테이블은 넓고 짧게(적은 로우, 많은 컬럼) 설계됩니다.

코드 예제

-- 팩트 테이블: 매출 트랜잭션의 측정값
CREATE TABLE fact_sales (
    sale_id SERIAL PRIMARY KEY,
    date_key INT NOT NULL,  -- dim_date 참조
    customer_key INT NOT NULL,  -- dim_customer 참조
    product_key INT NOT NULL,  -- dim_product 참조
    store_key INT NOT NULL,  -- dim_store 참조
    quantity INT NOT NULL,
    unit_price DECIMAL(10,2) NOT NULL,
    total_amount DECIMAL(10,2) NOT NULL,
    discount_amount DECIMAL(10,2) DEFAULT 0
);

-- 디멘전 테이블: 시간 차원 (미리 생성된 날짜 정보)
CREATE TABLE dim_date (
    date_key INT PRIMARY KEY,  -- 20240315 형식
    full_date DATE NOT NULL,
    year INT NOT NULL,
    quarter INT NOT NULL,
    month INT NOT NULL,
    week_of_year INT NOT NULL,
    day_name VARCHAR(10) NOT NULL,
    is_weekend BOOLEAN NOT NULL
);

-- 디멘전 테이블: 고객 차원
CREATE TABLE dim_customer (
    customer_key SERIAL PRIMARY KEY,
    customer_id VARCHAR(50) NOT NULL,  -- 원천 시스템의 ID
    name VARCHAR(100),
    age_group VARCHAR(20),  -- "20-29", "30-39" 등
    gender VARCHAR(10),
    city VARCHAR(50),
    join_date DATE
);

-- 디멘전 테이블: 상품 차원
CREATE TABLE dim_product (
    product_key SERIAL PRIMARY KEY,
    product_id VARCHAR(50) NOT NULL,
    name VARCHAR(200),
    category VARCHAR(100),
    subcategory VARCHAR(100),
    brand VARCHAR(100),
    unit_cost DECIMAL(10,2)
);

-- 분석 쿼리 예시: 20241분기 카테고리별 매출
-- SELECT
--     p.category,
--     SUM(f.total_amount) as revenue
-- FROM fact_sales f
-- JOIN dim_date d ON f.date_key = d.date_key
-- JOIN dim_product p ON f.product_key = p.product_key
-- WHERE d.year = 2024 AND d.quarter = 1
-- GROUP BY p.category;

설명

이것이 하는 일: 위 SQL은 매출 데이터를 분석하기 쉬운 스타 스키마 구조로 설계하며, 시간/고객/상품 차원에서 자유롭게 분석할 수 있게 합니다. 첫 번째로, fact_sales가 중심입니다.

이 테이블에는 실제 측정값(quantity, total_amount)과 디멘전 테이블을 가리키는 외래키만 있습니다. customer_key는 숫자 대리키를 사용하는데, 이는 조인 성능을 위해서입니다.

문자열 customer_id로 조인하는 것보다 INT 조인이 훨씬 빠릅니다. 그 다음으로, dim_date가 특별한 이유는 미리 계산된 시간 속성들을 담고 있기 때문입니다.

"2024년 1분기", "주말만", "월요일만" 같은 조건을 매번 계산할 필요 없이 바로 필터링할 수 있습니다. date_key를 20240315처럼 정수로 만들면 저장 공간도 절약되고 비교도 빠릅니다.

dim_customer에서 age_group처럼 계산된 값을 미리 저장하는 것도 중요합니다. 생년월일에서 매번 나이를 계산하는 대신, "20-29" 같은 그룹을 미리 만들어두면 GROUP BY가 훨씬 간단해집니다.

이것이 비정규화의 전략적 사용입니다. 쿼리 예시를 보면, 3개 테이블만 조인하면 "2024년 1분기 카테고리별 매출"이 나옵니다.

정규화 스키마였다면 orders, order_items, customers, products, 시간 계산 서브쿼리 등 훨씬 복잡했을 것입니다. 스타 스키마는 쿼리 복잡도를 O(n)에서 O(1)로 만듭니다.

여러분이 이 구조를 사용하면 BI 도구(Tableau, Power BI)를 연결했을 때 드래그앤드롭만으로 복잡한 분석이 가능합니다. "제품 카테고리를 행에, 월을 열에, 매출을 값에" 놓으면 피벗 테이블이 자동으로 만들어지며, 쿼리도 최적화되어 있어서 빠릅니다.

실무에서는 ETL 프로세스로 OLTP 데이터베이스에서 주기적으로 데이터를 추출하여 이 스타 스키마로 변환합니다. 예를 들어, 매일 밤 12시에 그날의 주문 데이터를 fact_sales에 삽입하고, 새로운 고객이 있으면 dim_customer에 추가하는 식입니다.

실전 팁

💡 디멘전은 SCD(Slowly Changing Dimension) 전략을 고려하세요. Type 1은 덮어쓰기(최신 값만 유지), Type 2는 이력 관리(유효 기간 추가), Type 3은 이전 값 컬럼 추가입니다. 고객 주소 변경을 추적하려면 Type 2를 사용하세요.

💡 팩트 테이블에는 계산 가능한 값을 중복 저장하세요. total_amount를 quantity * unit_price로 계산할 수 있지만, 미리 저장하면 SUM() 집계가 훨씬 빠릅니다. 저장 공간보다 쿼리 속도가 우선입니다.

💡 컬럼 기반 저장소를 고려하세요. PostgreSQL의 cstore_fdw나 ClickHouse 같은 컬럼 DB는 "특정 컬럼만 읽는" 분석 쿼리에서 100배 이상 빠릅니다. 10억 건 데이터도 실시간 집계가 가능합니다.

💡 파티셔닝으로 관리를 쉽게 하세요. fact_sales를 월별로 파티션하면, 오래된 데이터는 아카이브하고 최근 데이터만 활성 유지할 수 있습니다. WHERE d.date_key >= 20240101 같은 쿼리가 파티션 프루닝으로 10배 빠릅니다.

💡 집계 테이블(Aggregate Table)을 추가하세요. 일별 합계가 자주 필요하면 fact_sales_daily를 미리 만들어두는 것이 효율적입니다. 원본은 유지하되, 자주 쓰는 집계는 사전 계산하는 것이 데이터 웨어하우스의 핵심 패턴입니다.


6. 인덱스 전략과 쿼리 최적화

시작하며

여러분이 "WHERE email = 'user@example.com'" 같은 간단한 쿼리가 5초나 걸린다면, 100만 건 테이블을 처음부터 끝까지 다 읽고 있다는 뜻입니다. 책에서 특정 단어를 찾을 때 색인 없이 1페이지부터 전부 읽는 것과 같습니다.

이런 문제는 데이터가 적을 때는 눈에 띄지 않지만, 서비스가 성장하면서 치명적인 병목이 됩니다. 사용자 1만 명일 때 100ms였던 쿼리가 100만 명이 되면 10초로 느려지며, 결국 서비스 전체가 느려집니다.

바로 이럴 때 필요한 것이 올바른 인덱스 전략입니다. 어떤 컬럼에 인덱스를 만들지, 복합 인덱스는 어떻게 설계할지 이해하면 쿼리 성능을 1000배 개선할 수 있습니다.

개요

간단히 말해서, 인덱스는 데이터베이스가 빠르게 검색할 수 있도록 만든 정렬된 데이터 구조이며, 보통 B-Tree나 Hash 형태로 구현됩니다. 인덱스가 필요한 이유는 풀 테이블 스캔(전체 읽기)을 피하기 위해서입니다.

예를 들어, 100만 건 테이블에서 WHERE user_id = 12345를 찾을 때, 인덱스 없으면 100만 건을 다 읽지만, 인덱스 있으면 B-Tree를 타고 log₂(1,000,000) ≈ 20번 비교로 찾을 수 있습니다. 기존에는 "느린 쿼리가 있으면 무조건 인덱스 추가"라고 생각했다면, 이제는 인덱스의 비용(쓰기 성능 저하, 저장 공간 증가)도 고려해야 합니다.

인덱스의 핵심은 선택도(Selectivity)입니다. 성별(남/여 2가지)처럼 선택도가 낮은 컬럼은 인덱스 효과가 적고, 이메일(거의 고유)처럼 선택도가 높은 컬럼은 인덱스 효과가 큽니다.

또한 복합 인덱스의 컬럼 순서가 매우 중요하며, (A, B) 인덱스는 A만 검색할 때도 쓰이지만, B만 검색할 때는 안 쓰입니다.

코드 예제

import polars as pl

# PostgreSQL에서 실행할 인덱스 생성 예시 (Python 주석으로 설명)

sql_queries = """
-- 1. 단일 컬럼 인덱스: 자주 WHERE 조건에 사용되는 컬럼
CREATE INDEX idx_users_email ON users(email);

-- 2. 복합 인덱스: 두 개 이상 컬럼을 함께 검색
-- (status, created_at) 순서가 중요! 반대로 하면 효과 없음
CREATE INDEX idx_orders_status_date ON orders(status, created_at);

-- 3. 부분 인덱스: 특정 조건만 인덱싱 (저장 공간 절약)
CREATE INDEX idx_orders_active ON orders(created_at)
WHERE status = 'active';

-- 4. 표현식 인덱스: 함수 결과에 대한 인덱스
CREATE INDEX idx_users_email_lower ON users(LOWER(email));

-- 5. 커버링 인덱스: 인덱스만으로 쿼리 완성 (테이블 접근 불필요)
CREATE INDEX idx_orders_covering ON orders(customer_id, created_at)
INCLUDE (total_amount);

-- 쿼리 실행 계획 확인 (인덱스 사용 여부 체크)
-- EXPLAIN ANALYZE SELECT * FROM orders WHERE status = 'active' AND created_at > '2024-01-01';
"""

# Python에서 쿼리 성능 측정
import time
# 실제 사용 예시:
# start = time.time()
# df = pl.read_database(query, connection)
# print(f"쿼리 실행 시간: {time.time() - start:.3f}초")

print("인덱스 전략 SQL 쿼리:")
print(sql_queries)

설명

이것이 하는 일: 위 SQL은 5가지 인덱스 유형을 보여주며, 각각 다른 쿼리 패턴에 최적화되어 있습니다. 첫 번째로, 단일 컬럼 인덱스는 가장 기본입니다.

users 테이블에 email 인덱스를 만들면, 로그인 쿼리(WHERE email = ?)가 순식간에 실행됩니다. B-Tree 구조로 이메일을 알파벳 순으로 정렬하여 저장하므로, 이진 탐색으로 빠르게 찾습니다.

그 다음으로, 복합 인덱스의 컬럼 순서가 핵심입니다. (status, created_at) 인덱스는 status로 먼저 그룹화한 뒤 각 그룹 내에서 created_at으로 정렬합니다.

따라서 "WHERE status = 'active' AND created_at > ?" 쿼리에 최적이지만, "WHERE created_at > ?" 만 있는 쿼리에는 효과가 적습니다. 부분 인덱스는 저장 공간을 크게 절약합니다.

전체 주문의 10%만 active 상태라면, 전체 인덱스 대신 WHERE status = 'active' 조건을 추가한 부분 인덱스를 만들면 크기가 1/10로 줄고, 유지보수도 빨라집니다. 표현식 인덱스는 함수를 사용하는 쿼리에 필수입니다.

LOWER(email)로 대소문자 구분 없이 검색한다면, email 컬럼 자체가 아니라 LOWER(email) 결과에 인덱스를 만들어야 합니다. 그렇지 않으면 매번 전체 테이블을 읽어서 함수를 적용합니다.

커버링 인덱스는 최고 성능 최적화입니다. 인덱스에 total_amount를 INCLUDE하면, "SELECT customer_id, total_amount FROM orders WHERE ..." 쿼리가 인덱스만 읽고 끝납니다.

실제 테이블 페이지를 안 읽으므로 I/O가 90% 줄어듭니다. 여러분이 EXPLAIN ANALYZE를 실행하면 쿼리 플래너가 어떤 인덱스를 선택했는지, 얼마나 많은 행을 스캔했는지 볼 수 있습니다.

"Seq Scan"(풀 스캔)이 보이면 인덱스가 안 쓰인 것이고, "Index Scan" 또는 "Index Only Scan"이 보이면 성공입니다.

실전 팁

💡 인덱스는 많을수록 좋지 않습니다. INSERT/UPDATE/DELETE 시 모든 인덱스를 갱신해야 하므로 쓰기 성능이 저하됩니다. 실제 쿼리 패턴을 분석하여 꼭 필요한 5-10개만 유지하세요.

💡 복합 인덱스 순서는 "선택도 높은 것 → 낮은 것" 또는 "= 조건 → 범위 조건" 순서입니다. (status, date) 보다 (date, status)가 나은 경우가 많습니다. 실제 데이터로 테스트하세요.

💡 pg_stat_statements로 느린 쿼리를 찾으세요. PostgreSQL에서 이 확장을 활성화하면 모든 쿼리의 실행 시간과 빈도를 추적합니다. "자주 실행되면서 느린 쿼리"를 먼저 최적화하는 것이 효과적입니다.

💡 인덱스 유지보수를 잊지 마세요. VACUUM과 REINDEX로 인덱스를 주기적으로 정리하지 않으면 bloat(부풀어오름)가 발생하여 성능이 점점 느려집니다. 특히 DELETE가 많은 테이블은 월 1회 REINDEX를 권장합니다.

💡 쿼리 캐시와 Connection Pool도 함께 고려하세요. 아무리 인덱스를 최적화해도 매번 DB 연결을 새로 만들면 느립니다. pgBouncer 같은 풀링 도구와 Redis 캐시를 조합하면 전체 응답 시간을 1/10로 줄일 수 있습니다.


7. 트랜잭션과 ACID 속성

시작하며

여러분이 은행 계좌에서 친구에게 10만원을 송금하는데, 내 계좌에서는 돈이 빠져나갔는데 친구 계좌에는 입금이 안 됐다면 어떻게 될까요? 이런 일이 실제로 일어난다면 금융 시스템 전체가 붕괴할 것입니다.

이 문제는 여러 데이터베이스 작업이 하나의 논리적 단위로 묶이지 않았을 때 발생합니다. "출금"과 "입금" 사이에 서버가 죽거나 네트워크가 끊기면, 데이터 일관성이 깨집니다.

바로 이럴 때 필요한 것이 트랜잭션입니다. 여러 SQL 문을 하나의 원자적 단위로 묶어서, 전부 성공하거나 전부 실패하도록 보장합니다.

개요

간단히 말해서, 트랜잭션은 데이터베이스 작업들을 논리적 하나로 묶은 것이며, ACID(Atomicity, Consistency, Isolation, Durability) 속성을 보장합니다. 트랜잭션이 필요한 이유는 데이터 무결성을 보장하기 위해서입니다.

예를 들어, 온라인 쇼핑몰에서 주문 생성 → 재고 감소 → 포인트 차감이 모두 성공하거나, 하나라도 실패하면 전부 취소되어야 합니다. 재고만 줄고 주문이 안 생기면 큰 문제입니다.

기존에는 애플리케이션 코드에서 try-catch로 수동 롤백을 구현했다면, 이제는 데이터베이스의 BEGIN, COMMIT, ROLLBACK으로 자동화할 수 있습니다. ACID의 네 가지 속성을 이해하는 것이 핵심입니다.

Atomicity(원자성)는 전부 성공 또는 전부 실패, Consistency(일관성)는 제약조건 위배 방지, Isolation(격리성)은 동시 트랜잭션 간 간섭 방지, Durability(지속성)은 커밋 후 영구 저장입니다. 특히 Isolation Level(READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, SERIALIZABLE)에 따라 성능과 정합성의 트레이드오프가 달라집니다.

코드 예제

import psycopg2
from contextlib import contextmanager

@contextmanager
def get_db_transaction(connection):
    """트랜잭션 컨텍스트 매니저"""
    try:
        yield connection
        connection.commit()  # 성공 시 커밋
    except Exception as e:
        connection.rollback()  # 실패 시 롤백
        raise e

# 계좌 이체 트랜잭션 예시
def transfer_money(conn, from_account, to_account, amount):
    with get_db_transaction(conn) as txn:
        cursor = txn.cursor()

        # 1. 출금 계좌 잔액 확인 (FOR UPDATE로 락 획득)
        cursor.execute("""
            SELECT balance FROM accounts
            WHERE account_id = %s FOR UPDATE
        """, (from_account,))
        balance = cursor.fetchone()[0]

        if balance < amount:
            raise ValueError("잔액 부족")

        # 2. 출금
        cursor.execute("""
            UPDATE accounts SET balance = balance - %s
            WHERE account_id = %s
        """, (amount, from_account))

        # 3. 입금
        cursor.execute("""
            UPDATE accounts SET balance = balance + %s
            WHERE account_id = %s
        """, (amount, to_account))

        # 4. 거래 내역 기록
        cursor.execute("""
            INSERT INTO transactions (from_acc, to_acc, amount, timestamp)
            VALUES (%s, %s, %s, NOW())
        """, (from_account, to_account, amount))

        cursor.close()
    # with 블록 종료 시 자동 commit 또는 rollback

# 사용 예시:
# conn = psycopg2.connect("dbname=mydb user=myuser")
# transfer_money(conn, from_account=123, to_account=456, amount=100000)

설명

이것이 하는 일: 위 코드는 계좌 이체라는 복잡한 비즈니스 로직을 안전한 트랜잭션으로 감싸서, 부분 성공으로 인한 데이터 불일치를 방지합니다. 첫 번째로, 컨텍스트 매니저를 사용하여 트랜잭션을 자동 관리합니다.

with 블록 안에서 예외가 발생하면 자동으로 rollback()이 호출되고, 정상 종료되면 commit()이 호출됩니다. 이렇게 하면 개발자가 실수로 commit을 빼먹거나 rollback을 안 하는 일이 없습니다.

그 다음으로, FOR UPDATE 절이 매우 중요합니다. 이는 해당 행에 배타적 락(exclusive lock)을 걸어서, 다른 트랜잭션이 동시에 같은 계좌를 수정하지 못하게 막습니다.

만약 락 없이 잔액을 읽고 업데이트하면, 두 트랜잭션이 동시에 "잔액 10만원"을 읽어서 각각 5만원씩 출금하면 실제로는 10만원이 빠져야 하는데 5만원만 빠지는 Lost Update 문제가 발생합니다. 네 단계의 SQL이 모두 하나의 원자적 단위로 실행됩니다.

3단계까지 성공했는데 4단계에서 에러가 나면, 1-3단계도 모두 취소됩니다. 마치 타임머신처럼 트랜잭션 시작 전 상태로 돌아갑니다.

여러분이 이 코드를 실행하면 "잔액 부족" 에러가 발생해도 데이터베이스는 아무 변화가 없습니다. try 블록에서 ValueError가 raise되면, except에서 rollback()이 호출되어 모든 변경사항이 취소되기 때문입니다.

실무에서는 데드락(Deadlock)도 고려해야 합니다. 트랜잭션 A가 계좌1 락 → 계좌2 락을 기다리고, 트랜잭션 B가 계좌2 락 → 계좌1 락을 기다리면 영원히 멈춥니다.

이를 방지하려면 항상 같은 순서로 락을 획득하거나(예: account_id 오름차순), 데이터베이스의 데드락 감지 기능에 의존하여 하나를 자동 롤백시켜야 합니다. 격리 수준(Isolation Level)도 중요합니다.

PostgreSQL 기본값은 READ COMMITTED인데, 이는 커밋된 데이터만 읽습니다. 더 강한 보장이 필요하면 SERIALIZABLE을 사용하지만, 동시성이 크게 떨어집니다.

대부분의 경우 READ COMMITTED + FOR UPDATE 조합이면 충분합니다.

실전 팁

💡 트랜잭션은 가능한 짧게 유지하세요. 긴 트랜잭션은 락을 오래 잡고 있어서 다른 요청을 블로킹합니다. 외부 API 호출이나 파일 I/O를 트랜잭션 안에 넣지 마세요.

💡 읽기 전용 작업에는 명시적으로 SET TRANSACTION READ ONLY를 선언하세요. 데이터베이스가 최적화를 더 잘 할 수 있고, 실수로 데이터를 변경하는 것도 방지합니다.

💡 Optimistic Locking도 고려하세요. FOR UPDATE(비관적 락) 대신 version 컬럼을 사용하여 "UPDATE ... WHERE version = 5" 같이 하면, 락 없이도 동시성 제어가 가능합니다. 충돌이 드문 경우 성능이 훨씬 좋습니다.

💡 분산 트랜잭션은 피하세요. 여러 데이터베이스에 걸친 2PC(Two-Phase Commit)는 복잡하고 느립니다. 대신 Saga 패턴이나 이벤트 소싱으로 최종 일관성(Eventual Consistency)을 추구하는 것이 현대적 접근입니다.

💡 트랜잭션 로그를 모니터링하세요. 커밋되지 않은 오래된 트랜잭션이 있으면 VACUUM이 작동하지 않아 데이터베이스가 비대해집니다. pg_stat_activity로 2분 이상 실행 중인 트랜잭션을 찾아서 죽이는 자동화 스크립트를 만드는 것이 좋습니다.


8. 윈도우 함수를 활용한 고급 분석

시작하며

여러분이 "각 카테고리별 상위 3개 상품"을 찾으려고 할 때, GROUP BY로는 한계에 부딪힌 경험이 있나요? GROUP BY는 집계는 잘하지만, 그룹 내 순위나 누적 합계 같은 복잡한 분석은 어렵습니다.

이 문제를 해결하려고 서브쿼리를 중첩하거나 애플리케이션 코드에서 후처리를 하면, 쿼리가 복잡해지고 성능도 나빠집니다. "전체 평균보다 높은 매출을 기록한 날"을 찾으려면 평균을 먼저 계산한 뒤 다시 조인해야 합니다.

바로 이럴 때 필요한 것이 윈도우 함수(Window Function)입니다. ROW_NUMBER, RANK, LAG, SUM OVER 같은 함수로 복잡한 분석을 한 줄로 해결할 수 있습니다.

개요

간단히 말해서, 윈도우 함수는 그룹 내에서 각 행의 순위, 누적값, 이전/다음 값 등을 계산하면서도 원래 행을 유지하는 SQL 함수입니다. 윈도우 함수가 필요한 이유는 GROUP BY의 한계를 넘어서기 위해서입니다.

예를 들어, "부서별 급여 순위"를 구할 때 GROUP BY로는 각 사람의 순위를 매길 수 없고 집계값만 나옵니다. 하지만 RANK() OVER (PARTITION BY department ORDER BY salary DESC)를 사용하면 각 직원의 부서 내 순위가 나옵니다.

기존에는 서브쿼리로 "SELECT *, (SELECT AVG(price) FROM products) as avg_price"처럼 했다면, 이제는 AVG(price) OVER()로 한 번에 계산할 수 있습니다. 윈도우 함수의 핵심은 PARTITION BY와 ORDER BY입니다.

PARTITION BY는 그룹을 나누고(GROUP BY와 유사), ORDER BY는 그룹 내 정렬 순서를 정합니다. ROWS BETWEEN으로 윈도우 범위를 지정하면 "최근 7일 이동 평균" 같은 계산도 가능합니다.

코드 예제

-- Polars에서 윈도우 함수 사용 예시
import polars as pl

# 샘플 데이터: 카테고리별 제품 판매량
sales_data = pl.DataFrame({
    "category": ["전자제품", "전자제품", "전자제품", "의류", "의류", "의류", "식품", "식품"],
    "product": ["노트북", "태블릿", "마우스", "청바지", "티셔츠", "재킷", "사과", "바나나"],
    "revenue": [5000000, 3000000, 100000, 80000, 50000, 200000, 30000, 25000]
})

# 1. 카테고리별 매출 순위
ranked = sales_data.with_columns([
    pl.col("revenue").rank(method="ordinal", descending=True)
      .over("category").alias("category_rank")
])

# 2. 카테고리별 매출 비중 (개별 매출 / 카테고리 총 매출)
with_pct = ranked.with_columns([
    (pl.col("revenue") / pl.col("revenue").sum().over("category") * 100)
      .round(2).alias("revenue_pct")
])

# 3. 상위 2개 제품만 필터링
top_products = with_pct.filter(pl.col("category_rank") <= 2)

print("카테고리별 상위 2개 제품:")
print(top_products.sort(["category", "category_rank"]))

# 4. 누적 합계 (Running Total)
cumulative = sales_data.sort("revenue", descending=True).with_columns([
    pl.col("revenue").cum_sum().alias("cumulative_revenue")
])

print("\n매출 누적 합계:")
print(cumulative)

설명

이것이 하는 일: 위 코드는 카테고리별 제품 매출 데이터에서 순위, 비중, 누적 합계를 윈도우 함수로 계산합니다. 첫 번째로, rank() 함수에 over("category")를 추가하면 전체 순위가 아니라 카테고리별 순위가 계산됩니다.

"전자제품" 그룹 안에서 1, 2, 3위, "의류" 그룹 안에서 별도로 1, 2, 3위가 매겨집니다. method="ordinal"은 동점이 없도록 보장하며, descending=True는 높은 값부터 1위입니다.

그 다음으로, 매출 비중을 계산할 때 over("category")를 다시 사용합니다. pl.col("revenue").sum()은 보통 전체 합계를 구하지만, over("category")가 붙으면 각 행이 속한 카테고리의 합계를 반환합니다.

따라서 "노트북" 행에서는 전자제품 총 매출(810만원)로 나눠지고, "청바지" 행에서는 의류 총 매출(33만원)로 나눠집니다. category_rank <= 2 필터링은 윈도우 함수의 진가를 보여줍니다.

GROUP BY로는 "각 그룹의 상위 2개"를 직접 필터링할 수 없지만, 윈도우 함수로 순위를 매긴 뒤 필터링하면 간단합니다. 이것이 "Top-N per Group" 패턴입니다.

cum_sum()은 누적 합계를 계산하는데, 매출 순으로 정렬한 뒤 실행하면 "상위 3개 제품이 전체 매출의 80%를 차지한다" 같은 파레토 분석이 가능합니다. 이를 cumulative_revenue / total_revenue로 나누면 누적 비중이 나오며, 80%를 넘는 지점까지가 핵심 제품입니다.

여러분이 이 코드를 실행하면 "전자제품 카테고리에서 노트북이 1위(61.7%), 태블릿이 2위(37.0%)"처럼 구체적인 분석이 나옵니다. SQL에서 같은 작업을 하려면 여러 서브쿼리를 조인해야 하지만, Polars는 체이닝으로 깔끔하게 처리합니다.

실무에서 자주 쓰는 패턴은 LAG/LEAD입니다. 전월 대비 증감률을 구할 때 LAG(revenue, 1) OVER (ORDER BY month)로 이전 달 매출을 가져온 뒤 (revenue - prev_revenue) / prev_revenue로 계산하면 한 쿼리로 끝납니다.

실전 팁

💡 RANK, DENSE_RANK, ROW_NUMBER의 차이를 이해하세요. 동점 처리가 다릅니다. RANK는 1,2,2,4 (3 건너뜀), DENSE_RANK는 1,2,2,3, ROW_NUMBER는 1,2,3,4 (무조건 고유). 상황에 맞게 선택하세요.

💡 윈도우 프레임(ROWS/RANGE BETWEEN)을 활용하세요. "최근 7일 이동 평균"은 AVG(value) OVER (ORDER BY date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)로 구현합니다. 시계열 분석에 필수입니다.

💡 윈도우 함수는 WHERE 절에 못 씁니다. WHERE category_rank = 1은 에러입니다. 대신 서브쿼리나 WITH 절로 먼저 계산한 뒤 바깥에서 필터링하세요. 또는 Polars의 with_columns + filter 패턴을 쓰세요.

💡 성능을 위해 인덱스를 확인하세요. PARTITION BY와 ORDER BY에 사용되는 컬럼에 인덱스가 없으면 전체 정렬이 발생하여 느립니다. (category, revenue DESC) 복합 인덱스를 만들어두세요.

💡 Polars의 lazy mode를 활용하세요. 여러 윈도우 연산을 체이닝할 때 .lazy()로 시작하고 마지막에 .collect()하면 쿼리 최적화가 자동으로 이루어져 중간 결과를 재사용합니다.


9. 데이터 품질 검증과 이상치 탐지

시작하며

여러분이 데이터 분석 리포트를 만들었는데, 발표 직전에 "매출이 -100만원"이라는 이상한 값을 발견한 경험이 있나요? 쓰레기 데이터로 분석하면 쓰레기 결과가 나옵니다(Garbage In, Garbage Out).

이런 문제는 데이터 수집 과정의 버그, 사용자 입력 오류, 시스템 장애 등 다양한 원인으로 발생합니다. 이상치를 방치하면 평균값이 왜곡되고, 머신러닝 모델의 정확도도 크게 떨어집니다.

바로 이럴 때 필요한 것이 체계적인 데이터 품질 검증입니다. NULL 체크, 범위 검증, 통계적 이상치 탐지를 자동화하면 신뢰할 수 있는 분석이 가능합니다.

개요

간단히 말해서, 데이터 품질 검증은 데이터가 정확성, 완전성, 일관성, 유효성 기준을 만족하는지 자동으로 확인하는 프로세스입니다. 데이터 품질 검증이 필요한 이유는 잘못된 데이터 기반 의사결정을 방지하기 위해서입니다.

예를 들어, 나이가 200살, 이메일이 "asdf", 주문 금액이 음수 같은 명백히 잘못된 데이터를 걸러내지 않으면, 고객 세그먼트 분석이나 매출 예측이 엉터리가 됩니다. 기존에는 데이터를 보고 난 뒤 문제를 발견했다면, 이제는 데이터 파이프라인에 자동 검증 단계를 추가하여 실시간으로 품질을 모니터링할 수 있습니다.

핵심 검증 항목은 5가지입니다: 1) 완전성(NULL 비율), 2) 고유성(중복 체크), 3) 범위(min/max 제약), 4) 형식(이메일, 전화번호 패턴), 5) 일관성(외래키 무결성). 이상치 탐지는 IQR(사분위수 범위)이나 Z-score를 사용하여 통계적으로 비정상인 값을 찾습니다.

코드 예제

import polars as pl
import numpy as np

# 샘플 데이터 (일부러 이상치 포함)
raw_data = pl.DataFrame({
    "user_id": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
    "age": [25, 30, 200, 28, None, 35, 40, -5, 29, 32],  # 이상치: 200, -5, NULL
    "revenue": [50000, 60000, 55000, 9999999, 58000, 62000, 51000, 59000, 57000, 61000],  # 이상치: 9999999
    "email": ["a@b.com", "valid@email.com", "asdf", "c@d.com", "e@f.com", None, "g@h.com", "i@j.com", "k@l.com", "m@n.com"]
})

# 1. 완전성 검사: NULL 비율 계산
completeness = raw_data.select([
    (pl.col(col).is_null().sum() / raw_data.height * 100).alias(f"{col}_null_pct")
    for col in raw_data.columns
])
print("NULL 비율:")
print(completeness)

# 2. 범위 검증: 나이는 0-120 사이여야 함
valid_age = raw_data.filter(
    (pl.col("age").is_not_null()) &
    (pl.col("age") >= 0) &
    (pl.col("age") <= 120)
)

# 3. IQR 방식 이상치 탐지 (매출 데이터)
revenue_stats = raw_data.select([
    pl.col("revenue").quantile(0.25).alias("Q1"),
    pl.col("revenue").quantile(0.75).alias("Q3")
])
Q1, Q3 = revenue_stats[0, "Q1"], revenue_stats[0, "Q3"]
IQR = Q3 - Q1
lower_bound = Q1 - 1.5 * IQR
upper_bound = Q3 + 1.5 * IQR

cleaned_data = raw_data.filter(
    (pl.col("revenue") >= lower_bound) &
    (pl.col("revenue") <= upper_bound)
)

print(f"\n이상치 범위: {lower_bound:.0f} ~ {upper_bound:.0f}")
print(f"원본 데이터: {raw_data.height}건 → 정제 데이터: {cleaned_data.height}건")

# 4. 이메일 형식 검증 (간단한 정규식)
email_valid = raw_data.filter(
    pl.col("email").str.contains(r"^[^@]+@[^@]+\.[^@]+$")
)
print(f"\n유효한 이메일: {email_valid.height}건")

설명

이것이 하는 일: 위 코드는 실제 데이터에 섞여 있는 여러 유형의 오류와 이상치를 단계별로 검증하고 제거합니다. 첫 번째로, NULL 비율을 모든 컬럼에 대해 계산합니다.

is_null().sum()으로 NULL 개수를 세고, 전체 행 수로 나눠 퍼센트로 표현합니다. 만약 age의 NULL이 30%를 넘으면, 그 컬럼은 분석에 사용하기 어렵다는 신호이며, 데이터 수집 프로세스를 점검해야 합니다.

그 다음으로, 비즈니스 룰 기반 검증을 합니다. 나이는 상식적으로 0-120 사이여야 하므로, 이 범위를 벗어난 200이나 -5는 명백한 오류입니다.

filter로 이런 값들을 제외하면, 평균 나이 같은 통계가 왜곡되지 않습니다. IQR(InterQuartile Range) 방식은 통계적 이상치 탐지의 표준입니다.

25% 지점(Q1)과 75% 지점(Q3) 사이가 정상 범위이고, Q1 - 1.5IQR ~ Q3 + 1.5IQR를 벗어나면 이상치로 간주합니다. 9,999,999원 매출은 명백히 이 범위를 벗어나므로 제거됩니다.

이메일 정규식 검증은 형식 오류를 잡아냅니다. "asdf"처럼 @와 도메인이 없는 값은 유효하지 않습니다.

실무에서는 더 엄격한 RFC 5322 정규식을 사용하거나, 실제로 이메일 인증을 보내서 확인하기도 합니다. 여러분이 이 코드를 실행하면 "원본 10건 → 정제 후 7건"처럼 얼마나 많은 데이터가 걸러졌는지 알 수 있습니다.

만약 50% 이상이 걸러진다면, 데이터 수집 파이프라인에 심각한 문제가 있다는 뜻이므로 근본 원인을 찾아야 합니다. 실무 팁으로, 데이터 품질 리포트를 매일 자동 생성하여 슬랙에 알림을 보내는 것이 좋습니다.

"어제 NULL 비율 5% → 오늘 30%"처럼 급격한 변화가 있으면, 데이터 소스에 장애가 발생했을 가능성이 높습니다.

실전 팁

💡 이상치를 무조건 제거하지 마세요. 때로는 진짜 중요한 신호일 수 있습니다. VIP 고객의 1억 구매는 이상치지만 제거하면 안 됩니다. 도메인 지식으로 "유효한 극값"과 "오류"를 구분하세요.

💡 Great Expectations 같은 전문 라이브러리를 사용하세요. Polars의 기본 기능만으로는 한계가 있습니다. Great Expectations는 100가지 이상의 검증 규칙을 제공하고, 자동 리포트도 생성해줍니다.

💡 데이터 리니지(Lineage)를 추적하세요. 어떤 데이터가 어디서 왔는지, 어떤 변환을 거쳤는지 기록하면 문제 발생 시 빠르게 원인을 찾을 수 있습니다. dbt나 Apache Airflow를 사용하면 자동화할 수 있습니다.

💡 Z-score 방식도 배워두세요. (value - mean) / std를 계산하여 절댓값이 3을 넘으면 이상치로 간주합니다. IQR보다 정규분포 가정이 필요하지만, 더 민감하게 이상치를 잡을 수 있습니다.

💡 데이터 프로파일링을 습관화하세요. 새 데이터셋을 받으면 바로 분석하기보다, 먼저 describe(), value_counts(), null_count()로 탐색하세요. 10분 투자로 나중에 몇 시간을 아낄 수 있습니다.


10. ETL 파이프라인 설계와 자동화

시작하며

여러분이 매일 아침 10시에 어제의 매출 리포트를 만든다고 상상해보세요. 수동으로 데이터를 다운로드하고, 정제하고, 집계하면 1시간이 걸립니다.

이걸 365일 반복한다면 얼마나 비효율적일까요? 이런 반복 작업은 자동화하지 않으면 시간 낭비일 뿐 아니라, 사람이 매번 실행하므로 실수가 발생할 가능성도 높습니다.

한 단계를 빠뜨리거나, 잘못된 파일을 선택하는 휴먼 에러가 데이터 품질을 해칩니다. 바로 이럴 때 필요한 것이 ETL(Extract, Transform, Load) 파이프라인입니다.

데이터를 추출하고, 변환하고, 저장하는 전 과정을 코드로 자동화하면 신뢰성과 효율성이 극대화됩니다.

개요

간단히 말해서, ETL은 여러 소스에서 데이터를 추출(Extract)하고, 비즈니스 요구에 맞게 변환(Transform)한 뒤, 목적지에 적재(Load)하는 자동화된 데이터 파이프라인입니다. ETL이 필요한 이유는 원천 시스템의 데이터가 분석에 바로 사용하기 어렵기 때문입니다.

예를 들어, MySQL 운영 DB의 정규화된 테이블들을 매번 조인하는 것은 느리고 복잡하므로, ETL로 분석용 데이터 웨어하우스(스타 스키마)에 미리 가공해서 적재합니다. 기존에는 수동으로 CSV를 다운로드하고 엑셀로 가공했다면, 이제는 Python 스크립트로 전체 프로세스를 자동화하고 크론잡이나 Airflow로 스케줄링할 수 있습니다.

ETL의 핵심은 멱등성(Idempotency)과 증분 처리입니다. 같은 파이프라인을 여러 번 실행해도 결과가 같아야 하고(멱등성), 매번 전체 데이터를 처리하지 않고 변경된 부분만 처리해야(증분 처리) 효율적입니다.

또한 실패 시 재시도, 로깅, 알림 같은 운영 요소도 중요합니다.

코드 예제

import polars as pl
from datetime import datetime, timedelta
import psycopg2
from psycopg2.extras import execute_batch

class SalesETL:
    def __init__(self, db_conn):
        self.conn = db_conn

    def extract(self, start_date, end_date):
        """1. Extract: 운영 DB에서 데이터 추출"""
        query = f"""
        SELECT
            o.order_id, o.customer_id, o.order_date, o.total_amount,
            c.name, c.age, c.city,
            oi.product_id, oi.quantity, oi.unit_price,
            p.category, p.brand
        FROM orders o
        JOIN customers c ON o.customer_id = c.customer_id
        JOIN order_items oi ON o.order_id = oi.order_id
        JOIN products p ON oi.product_id = p.product_id
        WHERE o.order_date BETWEEN '{start_date}' AND '{end_date}'
        """
        # Polars는 PostgreSQL을 직접 읽을 수 있음
        df = pl.read_database(query, self.conn)
        print(f"추출 완료: {df.height}건")
        return df

    def transform(self, df):
        """2. Transform: 데이터 변환 및 집계"""
        transformed = df.with_columns([
            # 날짜 키 생성 (20240315 형식)
            pl.col("order_date").dt.strftime("%Y%m%d").cast(pl.Int32).alias("date_key"),
            # 나이 그룹 생성
            pl.when(pl.col("age") < 30).then("20대")
              .when(pl.col("age") < 40).then("30대")
              .otherwise("40대+").alias("age_group"),
            # 총 금액 계산
            (pl.col("quantity") * pl.col("unit_price")).alias("line_total")
        ])

        # 팩트 테이블용 집계
        fact_sales = transformed.group_by([
            "date_key", "customer_id", "product_id"
        ]).agg([
            pl.col("quantity").sum().alias("total_quantity"),
            pl.col("line_total").sum().alias("total_amount")
        ])

        print(f"변환 완료: {fact_sales.height}건")
        return fact_sales

    def load(self, df, table_name):
        """3. Load: 데이터 웨어하우스에 적재"""
        cursor = self.conn.cursor()

        # UPSERT 로직 (충돌 시 업데이트)
        insert_query = f"""
        INSERT INTO {table_name} (date_key, customer_id, product_id, total_quantity, total_amount)
        VALUES (%s, %s, %s, %s, %s)
        ON CONFLICT (date_key, customer_id, product_id)
        DO UPDATE SET
            total_quantity = EXCLUDED.total_quantity,
            total_amount = EXCLUDED.total_amount
        """

        # Polars DataFrame을 튜플 리스트로 변환
        data = [tuple(row) for row in df.iter_rows()]
        execute_batch(cursor, insert_query, data)
        self.conn.commit()
        print(f"적재 완료: {len(data)}건")

    def run(self, start_date, end_date):
        """전체 ETL 실행"""
        try:
            raw_data = self.extract(start_date, end_date)
            transformed_data = self.transform(raw_data)
            self.load(transformed_data, "fact_sales")
            print(f"ETL 성공: {start_date} ~ {end_date}")
        except Exception as e:
            print(f"ETL 실패: {e}")
            raise

# 사용 예시 (매일 전날 데이터 처리)
# conn = psycopg2.connect("dbname=mydb user=myuser")
# etl = SalesETL(conn)
# yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
# etl.run(yesterday, yesterday)

설명

이것이 하는 일: 위 코드는 운영 DB에서 주문 데이터를 읽어서 분석용 팩트 테이블로 변환하고 적재하는 전체 ETL 프로세스를 구현합니다. 첫 번째로, extract 단계에서 여러 테이블을 조인하여 필요한 데이터를 한 번에 가져옵니다.

WHERE 조건으로 특정 날짜 범위만 추출하여 증분 처리를 구현합니다. 전체 데이터를 매번 읽으면 시간이 오래 걸리므로, "어제 날짜만" 또는 "최근 1주일만" 같은 필터링이 필수입니다.

그 다음으로, transform 단계에서 비즈니스 로직을 적용합니다. date_key를 정수로 만들고, age_group을 계산하고, line_total을 집계합니다.

with_columns와 group_by를 조합하여 복잡한 변환도 깔끔하게 표현할 수 있습니다. 이 단계에서 데이터 품질 검증도 함께 수행하는 것이 좋습니다.

load 단계의 ON CONFLICT 절이 멱등성의 핵심입니다. 같은 (date_key, customer_id, product_id) 조합이 이미 존재하면 덮어쓰고, 없으면 새로 삽입합니다.

이렇게 하면 파이프라인을 여러 번 실행해도 중복 데이터가 생기지 않으며, 실패 후 재시도도 안전합니다. execute_batch는 성능 최적화입니다.

1,000건을 개별 INSERT로 실행하면 1,000번의 왕복(round-trip)이 발생하지만, 배치로 묶으면 1번에 처리됩니다. 10배 이상 빠르며, 대용량 데이터 처리에 필수입니다.

여러분이 이 클래스를 크론잡으로 등록하면 매일 자동으로 실행됩니다. 예를 들어, 리눅스 crontab에 "0 1 * * * python etl.py"를 추가하면 매일 새벽 1시에 전날 데이터를 처리합니다.

사람이 개입하지 않아도 데이터 웨어하우스가 최신 상태로 유지됩니다. 실무에서는 Airflow나 Prefect 같은 워크플로우 오케스트레이션 도구를 사용합니다.

여러 ETL 작업의 의존성을 관리하고(A가 끝나야 B 시작), 실패 시 재시도, 알림 발송, 모니터링 대시보드 같은 기능을 제공합니다.

실전 팁

💡 Full Load보다 Incremental Load를 선호하세요. 매번 전체 데이터를 다시 처리하는 것은 비효율적입니다. updated_at 컬럼으로 마지막 실행 이후 변경된 것만 처리하면 100배 빠릅니다.

💡 멱등성을 반드시 보장하세요. DELETE → INSERT보다 UPSERT를 사용하고, 타임스탬프를 체크하여 중복 실행을 방지하세요. 같은 파이프라인을 5번 실행해도 결과가 같아야 안전합니다.

💡 실패한 레코드만 재처리하세요. 1,000건 중 10건이 실패하면, 전체를 다시 돌리지 말고 실패한 10건만 다시 시도하는 로직을 만드세요. error_log 테이블에 실패 건을 기록하고, 별도 스크립트로 처리합니다.

💡 데이터 백업과 롤백 전략을 세우세요. ETL 전에 스냅샷을 만들거나, 변경 전 데이터를 audit 테이블에 저장하면 문제 발생 시 복구할 수 있습니다. 특히 DELETE나 UPDATE가 많은 파이프라인은 필수입니다.

💡 모니터링과 알림을 설정하세요. 파이프라인 실행 시간, 처리 건수, 에러율을 추적하고, 정상 범위를 벗어나면 슬랙/이메일 알림을 보내세요. "어제 ETL이 실패했는데 모르고 있다가 일주일 뒤 발견"은 최악입니다.


#Python#Polars#데이터분석#비즈니스지표#데이터베이스설계#데이터분석,Python,Polars

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.