infra
Platform

모듈 맵

[Database] 실무에서 가장 많이 쓰는 날짜 및 문자열 함수 활용법

0 / 37 완료

펼치기
0 / 37 완료0%

데이터베이스 설계 & 튜닝 · 17 / 37

[Database] 실무에서 가장 많이 쓰는 날짜 및 문자열 함수 활용법

문자열 조작과 날짜 계산에 필요한 핵심 내장 함수들을 실무 예시로 익힙니다

🚨INCIDENT ALERT
HIGH

검색, 리포트, 배치 작업에서는 문자열과 날짜를 가공하는 쿼리가 자주 등장합니다. 함수 사용 위치를 잘못 잡으면 인덱스를 못 타거나 타임존 버그가 생깁니다. 문자열·날짜 함수를 정확히 쓰면 애플리케이션 코드를 줄이면서도 결과를 안정적으로 만들 수 있습니다.

이번 챕터에서 배울 것

문자열과 날짜 함수는 데이터 정제, 리포트 생성, 검색 기능 구현의 핵심 도구입니다. PostgreSQL 기준으로 설명하며 MySQL 차이점도 함께 다룹니다.

  • 1TRIM, UPPER/LOWER, SUBSTRING, REPLACE, CONCAT로 문자열을 가공할 수 있다
  • 2SPLIT_PART, POSITION, LEFT/RIGHT로 원하는 패턴을 추출할 수 있다
  • 3REGEXP_REPLACE, REGEXP_MATCH 정규식 함수로 복잡한 문자열을 처리할 수 있다
  • 4NOW, CURRENT_DATE, DATE_TRUNC, EXTRACT로 날짜/시간을 다룰 수 있다
  • 5AGE, INTERVAL 덧셈, DATEDIFF로 기간을 계산할 수 있다
  • 6TO_CHAR, TO_DATE로 날짜 포맷을 변환할 수 있다
  • 7AT TIME ZONE으로 타임존을 올바르게 처리할 수 있다
  • 8PostgreSQL과 MySQL의 함수 차이를 구분해 적용할 수 있다

문자열/날짜 함수 — 실무에서 가장 자주 쓰는 것들

데이터베이스에는 애플리케이션 코드 없이 SQL만으로 처리할 수 있는 강력한 내장 함수들이 있습니다. 문자열 정제, 날짜 계산, 기간 집계 등을 DB 레벨에서 처리하면 네트워크 전송과 애플리케이션 처리 비용을 줄일 수 있습니다.


💡개념

문자열 함수 실무 패턴 — 데이터 정제와 변환

외부 시스템에서 가져온 데이터에 공백이 섞여 있거나, 전화번호가 010-1234-567801012345678 두 형식으로 혼재합니다. 이걸 애플리케이션에서 정제하면 모든 곳에서 처리 로직이 중복됩니다. SQL 문자열 함수로 DB 쿼리 단계에서 정제하면 한 곳에서 관리할 수 있습니다.

문자열 함수 실무 패턴 — 데이터 정제와 변환확대

기본 문자열 함수

TRIM은 문자열의 앞뒤 공백을 제거합니다. LTRIM은 왼쪽만, RTRIM은 오른쪽만 제거하며, TRIM(BOTH '0' FROM ...) 형식으로 특정 문자를 제거할 수도 있습니다. INITCAP은 PostgreSQL 전용으로 각 단어 첫 글자를 대문자로 변환합니다.

SQL
SELECT
    TRIM('  안녕하세요  ')           AS trim_both,
    LTRIM('  안녕하세요  ')          AS trim_left,
    RTRIM('  안녕하세요  ')          AS trim_right,
    TRIM(BOTH '0' FROM '00042000')   AS trim_zeros;

SELECT
    UPPER('hello World')   AS upper_case,
    LOWER('Hello WORLD')   AS lower_case,
    INITCAP('hello world') AS title_case;

SELECT
    LENGTH('안녕하세요')        AS byte_length,
    CHAR_LENGTH('안녕하세요')   AS char_length,
    LENGTH('hello')             AS ascii_length;
OUTPUT
실행 완료 또는 조회 결과가 표시됩니다.
🔍실행 후 확인할 것
  • 먼저 TRIM 결과의 LENGTH를 확인합니다. TRIM 전후 LENGTH 차이가 0이면 공백이 없었던 것입니다 — 차이가 있다면 입력 데이터에 앞뒤 공백이 있었다는 의미이고, DB 저장 전 정제 로직이 필요합니다.
  • DATE_TRUNC 결과의 타임존을 확인합니다. TIMESTAMPTZ 컬럼을 DATE_TRUNC('month', ts)로 처리할 때 서버 timezone이 UTC이면 한국 기준 자정(00:00 KST = 전날 15:00 UTC) 경계가 밀립니다 — 8시간 이상 차이가 나는 데이터가 의심스러우면 AT TIME ZONE 'Asia/Seoul'을 추가하세요.
  • WHERE 절에 함수를 씌운 컬럼이 있다면 EXPLAIN으로 확인합니다. LOWER(email) = 'user@example.com' 패턴은 인덱스를 타지 못합니다(Seq Scan) — 함수 기반 인덱스(CREATE INDEX ON users (LOWER(email)))가 없으면 수십만 건 이상에서 성능 문제가 생깁니다.

문자열 자르기와 치환

SUBSTRING은 시작 위치와 길이로 부분 문자열을 추출합니다. SPLIT_PART는 PostgreSQL 전용으로 구분자 기준 N번째 토큰을 반환하며, 이메일 도메인 추출이나 날짜 파싱에 유용합니다.

SQL
SELECT
    SUBSTRING('Hello, World!' FROM 1 FOR 5)    AS first_5,
    SUBSTRING('Hello, World!' FROM 8)          AS from_8,
    SUBSTR('Hello, World!', 8, 5)              AS world;

SELECT
    REPLACE('010-1234-5678', '-', '')          AS no_dash,
    REPLACE('홍길동 선생님', ' 선생님', '')     AS name_only;

SELECT
    SPLIT_PART('hong@gmail.com', '@', 1)  AS username,
    SPLIT_PART('hong@gmail.com', '@', 2)  AS domain,
    SPLIT_PART('2024-03-15', '-', 1)      AS year,
    SPLIT_PART('2024-03-15', '-', 2)      AS month;

SELECT
    LEFT('010-1234-5678', 3)    AS area_code,
    RIGHT('010-1234-5678', 4)   AS last_4;

SELECT
    POSITION('@' IN 'user@example.com')     AS at_position,
    STRPOS('user@example.com', '@')         AS at_strpos;

문자열 연결과 포맷팅

CONCAT은 NULL을 빈 문자열로 처리하지만, || 연산자는 NULL이 하나라도 있으면 전체 결과가 NULL이 됩니다. CONCAT_WS는 NULL 항목을 건너뛰면서 구분자로 이어 붙일 때 유용합니다.

SQL
SELECT
    '성: ' || '홍' || ' 이름: ' || '길동'          AS 연결1,
    CONCAT('홍', '길동')                            AS 연결2,
    CONCAT_WS(', ', '서울', '강남구', NULL, '역삼동') AS 연결3;

SELECT
    LPAD('42', 8, '0')        AS zero_padded,
    RPAD('hello', 10, '.')    AS right_padded;

실무 데이터 정제

이메일에서 도메인을 추출하거나, 전화번호의 하이픈과 공백을 제거하고, 정규식으로 유효하지 않은 이메일을 찾는 쿼리는 데이터 정제 작업에서 반복적으로 사용됩니다. REGEXP_REPLACE의 네 번째 인자 'g'는 전체 치환(global)을 의미합니다.

SQL
SELECT
    email,
    LOWER(SPLIT_PART(email, '@', 2)) AS domain
FROM users
WHERE email IS NOT NULL;
SQL
UPDATE users
SET phone = REGEXP_REPLACE(phone, '[^0-9]', '', 'g')
WHERE phone IS NOT NULL;
SQL
SELECT
    full_name,
    SPLIT_PART(full_name, ' ', 1) AS first_name,
    SPLIT_PART(full_name, ' ', 2) AS last_name
FROM users_import;
SQL
SELECT *
FROM users
WHERE email !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$';

WHERE email LIKE '%@gmail.com'처럼 앞에 % 와일드카드를 붙이면 PostgreSQL은 인덱스를 사용하지 못하고 전체 테이블 스캔(Seq Scan)을 수행합니다. 수백만 건 테이블에서 쿼리가 수초 이상 걸리는 원인이 됩니다.

해결: 도메인 기준 검색이 자주 필요하다면 SPLIT_PART(email, '@', 2)로 도메인을 별도 컬럼에 저장하고 인덱스를 겁니다. 또는 REVERSE(email) LIKE REVERSE('%@gmail.com')처럼 뒤집어서 앞 와일드카드를 제거하는 트릭을 쓸 수 있습니다. 전문 검색이 필요하다면 pg_trgm 확장의 GIN 인덱스를 활용합니다.

💡개념

날짜/시간 함수 — 기간 계산과 날짜 집계

가입일 기준으로 30일이 지난 사용자를 조회해야 합니다. 월별 신규 가입자를 집계해야 합니다. 두 날짜 사이의 일수를 계산해야 합니다. 날짜 함수가 PostgreSQL과 MySQL에서 다르게 생겼는데 어느 걸 써야 할지 모릅니다. 날짜 함수의 기본 패턴을 익혀두면 이런 요구사항을 쿼리 한 줄로 해결할 수 있습니다.

PostgreSQL vs MySQL 날짜 함수 차이

PostgreSQL과 MySQL은 날짜 함수 문법이 다릅니다. 팀이 두 DB를 혼용하거나 마이그레이션할 때 이 차이를 반드시 확인해야 합니다.

기능PostgreSQLMySQL
현재 날짜/시간NOW(), CURRENT_TIMESTAMPNOW(), SYSDATE()
날짜 자르기DATE_TRUNC('month', ts)DATE_FORMAT(ts, '%Y-%m-01')
부분 추출EXTRACT(YEAR FROM ts)YEAR(ts), MONTH(ts)
날짜 차이(일)ts1 - ts2 (정수 반환)DATEDIFF(ts1, ts2)
날짜 덧셈ts + INTERVAL '7 days'ts + INTERVAL 7 DAY
포맷 변환TO_CHAR(ts, 'YYYY-MM-DD')DATE_FORMAT(ts, '%Y-%m-%d')
경과 기간AGE(ts1, ts2)TIMESTAMPDIFF(MONTH, ts2, ts1)
타임존 변환ts AT TIME ZONE 'Asia/Seoul'CONVERT_TZ(ts, '+00:00', '+09:00')

현재 날짜/시간 함수

NOW()는 트랜잭션이 시작된 시각을 반환합니다. 트랜잭션 내에서 여러 번 호출해도 동일한 값을 반환합니다. 트랜잭션 내 실제 호출 시각이 필요하다면 CLOCK_TIMESTAMP()를 사용합니다.

SQL
SELECT
    NOW()             AS now_with_tz,
    CURRENT_TIMESTAMP AS current_ts,
    CURRENT_DATE      AS today,
    CURRENT_TIME      AS now_time,
    CLOCK_TIMESTAMP() AS real_now;

DATE_TRUNC — 시간 단위로 자르기

DATE_TRUNC는 타임스탬프를 지정한 단위의 시작점으로 내림합니다. 월별, 주별, 일별 집계 쿼리에서 GROUP BY와 함께 가장 많이 사용됩니다.

SQL
SELECT
    DATE_TRUNC('year',   NOW()) AS this_year_start,
    DATE_TRUNC('month',  NOW()) AS this_month_start,
    DATE_TRUNC('week',   NOW()) AS this_week_start,
    DATE_TRUNC('day',    NOW()) AS today_start,
    DATE_TRUNC('hour',   NOW()) AS this_hour_start,
    DATE_TRUNC('minute', NOW()) AS this_minute_start;
SQL
SELECT
    DATE_TRUNC('month', created_at) AS month,
    COUNT(*) AS orders,
    SUM(amount) AS revenue
FROM orders
WHERE status = 'completed'
GROUP BY DATE_TRUNC('month', created_at)
ORDER BY 1;

DATE_TRUNC로 흩어진 타임스탬프를 월 버킷으로 묶어 집계하는 과정 — 3·4·5월에 흩어진 created_at 원본 행이 DATE_TRUNC('month')로 각 월 1일 00:00 버킷에 모이고 GROUP BY로 월별 주문 수·매출이 집계된다. 단 WHERE 절에서 컬럼에 함수를 씌우면 인덱스가 무력화되므로 필터는 범위 조건으로 작성해야 한다확대

1DATE_TRUNC로 월별 매출 리포트를 직접 집계

월별 리포트는 실무에서 가장 자주 쓰는 집계입니다. 위 쿼리를 실행해 created_at이 월의 1일 00:00으로 내림되어 같은 달 주문이 한 줄로 묶이는지 확인합니다.

SQL
SELECT
    DATE_TRUNC('month', created_at) AS month,
    COUNT(*)    AS orders,
    SUM(amount) AS revenue
FROM orders
WHERE status = 'completed'
GROUP BY 1
ORDER BY 1;
OUTPUT
        month        | orders | revenue
---------------------+--------+----------
 2026-03-01 00:00:00 |    412 |  8240000
 2026-04-01 00:00:00 |    537 | 10930000
 2026-05-01 00:00:00 |    621 | 12880000
SELECT DATE_TRUNC('month', created_at) AS month, COUNT(*) AS orders, SUM(amount) AS revenue FROM orders WHERE status = 'completed' GROUP BY 1 ORDER BY 1;
🔍실행 후 확인할 것
  • month 열의 값이 모두 '월-01 00:00:00'으로 떨어지는지 본다 — 일/시가 0이 아니면 DATE_TRUNC가 아니라 원본 created_at이 그대로 GROUP BY된 것(월 그룹핑 실패)
  • 행 수가 '데이터가 존재하는 개월 수'와 같은지 확인한다. 같은 달이 두 줄로 나뉘면 타임존 차이로 월 경계가 어긋난 것 — created_at을 TIMESTAMPTZ로 두고 AT TIME ZONE을 검토
  • orders 합이 WHERE status='completed' 적용 전 전체 건수보다 작아야 정상(필터가 먹은 것). 같으면 WHERE가 무시된 것
  • revenue가 NULL로 나오면 amount에 NULL이 섞인 것 — SUM은 NULL을 건너뛰므로 COALESCE(amount,0) 여부를 판단한다

EXTRACT — 날짜/시간 부분 추출

EXTRACT는 타임스탬프에서 특정 필드(연, 월, 일, 시, 요일 등)를 숫자로 추출합니다. DOW는 요일(0=일요일, 6=토요일), EPOCH는 Unix timestamp(초)를 반환합니다.

SQL
SELECT
    EXTRACT(YEAR  FROM NOW()) AS year,
    EXTRACT(MONTH FROM NOW()) AS month,
    EXTRACT(DAY   FROM NOW()) AS day,
    EXTRACT(HOUR  FROM NOW()) AS hour,
    EXTRACT(DOW   FROM NOW()) AS day_of_week,
    EXTRACT(DOY   FROM NOW()) AS day_of_year,
    EXTRACT(EPOCH FROM NOW()) AS unix_timestamp;
SQL
SELECT
    EXTRACT(DOW FROM created_at) AS day_of_week,
    TO_CHAR(created_at, 'Day')   AS day_name,
    COUNT(*)                     AS order_count
FROM orders
GROUP BY 1, 2
ORDER BY 1;

날짜 계산과 INTERVAL

PostgreSQL에서 날짜 연산은 INTERVAL 타입을 사용합니다. AGE(ts1, ts2)는 두 타임스탬프 사이의 경과 기간을 interval 형식으로 반환하며, AGE(NOW())처럼 인자 하나만 전달하면 현재 시각 기준으로 계산합니다.

SQL
SELECT
    NOW() + INTERVAL '7 days'          AS next_week,
    NOW() - INTERVAL '30 days'         AS last_month,
    NOW() + INTERVAL '1 year 3 months' AS next_period,
    CURRENT_DATE + 7                   AS also_next_week;
SQL
SELECT
    AGE('2024-03-15', '2000-06-15')  AS age_interval,
    AGE(NOW(), created_at)           AS account_age
FROM users WHERE id = 1;
SQL
SELECT
    EXTRACT(DAY FROM AGE('2024-03-15', '2024-01-01')) AS days_diff,
    ('2024-03-15'::DATE - '2024-01-01'::DATE)         AS date_subtraction;
SQL
SELECT id, name, created_at
FROM users
WHERE created_at >= NOW() - INTERVAL '30 days'
ORDER BY created_at DESC;
SQL
SELECT id, name, last_login
FROM users
WHERE last_login < NOW() - INTERVAL '90 days'
   OR last_login IS NULL;

TO_CHAR — 날짜 포맷 변환

TO_CHAR는 숫자와 타임스탬프를 사람이 읽기 좋은 형식의 문자열로 변환합니다. 리포트 출력이나 레이블 생성에 사용합니다.

SQL
SELECT
    TO_CHAR(NOW(), 'YYYY-MM-DD')             AS iso_date,
    TO_CHAR(NOW(), 'YYYY년 MM월 DD일')       AS korean_date,
    TO_CHAR(NOW(), 'HH24:MI:SS')             AS time_24h,
    TO_CHAR(NOW(), 'YYYY-MM-DD HH24:MI:SS')  AS full_datetime,
    TO_CHAR(1234567.89, '999,999,999.99')    AS number_format;
SQL
SELECT
    TO_CHAR(created_at, 'YYYY-MM')                  AS month_label,
    COUNT(*)                                         AS order_count,
    TO_CHAR(SUM(amount), 'FM999,999,999')            AS revenue_formatted
FROM orders
GROUP BY TO_CHAR(created_at, 'YYYY-MM')
ORDER BY 1;

타임존 처리 — TIMESTAMPTZ 사용 권장

TIMESTAMP 타입은 타임존 정보 없이 입력값을 그대로 저장합니다. TIMESTAMPTZ는 UTC로 저장하고 조회 시 세션 타임존으로 변환합니다. 운영 서비스에서는 항상 TIMESTAMPTZ를 사용해야 타임존 문제를 피할 수 있습니다.

SQL
SHOW timezone;
SELECT current_setting('TimeZone');
SQL
SELECT
    NOW()                                  AS utc_now,
    NOW() AT TIME ZONE 'Asia/Seoul'        AS seoul_time,
    NOW() AT TIME ZONE 'America/New_York'  AS ny_time;
SQL
CREATE TABLE events (
    id       BIGSERIAL PRIMARY KEY,
    title    VARCHAR(200) NOT NULL,
    event_at TIMESTAMPTZ NOT NULL,
    created_at TIMESTAMPTZ DEFAULT NOW()
);
SQL
INSERT INTO events (title, event_at)
VALUES ('서울 컨퍼런스', '2024-06-15 09:00:00 Asia/Seoul');
SQL
SELECT event_at FROM events;

SELECT event_at AT TIME ZONE 'Asia/Seoul' FROM events;

서버가 UTC로 설정된 환경에서 WHERE created_at::date = '2024-03-15'로 한국 시간 기준 3월 15일 데이터를 조회했는데, 한국 시간 자정(00:00~08:59 KST)에 생성된 데이터가 누락되었습니다. UTC 기준으로 이 시간대는 3월 14일이기 때문입니다.

해결: 날짜 비교 시 항상 타임존을 명시합니다. WHERE created_at AT TIME ZONE 'Asia/Seoul' >= '2024-03-15 00:00:00' AND created_at AT TIME ZONE 'Asia/Seoul' < '2024-03-16 00:00:00'처럼 작성하거나, DATE_TRUNC와 타임존 변환을 함께 사용합니다. 컬럼 타입은 항상 TIMESTAMPTZ를 사용하고 애플리케이션 서버의 타임존도 UTC로 통일하는 것이 장기적으로 가장 안전합니다.

💼
실무 맥락날짜 함수로 월별 리포트와 사용자 가입 코호트 분석 쿼리 작성
현업 패턴

월별 매출 리포트는 DATE_TRUNC('month', created_at)으로 월 단위로 집계하고, 신규 가입자 코호트 분석은 가입월과 활동월을 각각 DATE_TRUNC로 잘라서 조인합니다. 예를 들어 "2024년 1월에 가입한 사용자가 각 월에 얼마나 재방문했는가"를 분석하는 코호트 리텐션 쿼리는 DATE_TRUNC('month', signup_at) AS cohort_monthDATE_TRUNC('month', last_active_at) AS activity_month를 GROUP BY로 집계하는 구조입니다. EXTRACT(EPOCH FROM AGE(activity_month, cohort_month)) / 2592000으로 코호트 이후 몇 달이 지났는지 계산해 피벗 테이블을 만들 수 있습니다.

💡개념

함수가 값을 바꾸는 순간, 인덱스가 죽는 순간 — WHERE에 함수를 씌우면 벌어지는 4단계

지금까지 TRIM·LOWER·DATE_TRUNC·EXTRACT로 값을 변환해 봤습니다. 그런데 똑같은 함수를 WHERE 절에서 컬럼에 씌우면, 인덱스가 조용히 무력화되어 같은 쿼리가 수십 배 느려집니다. 결과는 맞는데 느리기 때문에 원인을 찾기 어렵습니다. 왜 그런지는, 함수가 컬럼 값을 건드리는 순간부터 옵티마이저가 인덱스를 포기하기까지의 4단계를 따라가면 분명해집니다.

TEXT
WHERE LOWER(email) = 'a@x.com'      (email 컬럼에 B-Tree 인덱스 있음)
   │
   ① 입력 값 — 컬럼 email 의 원본 문자열('A@X.com', 'A@X.COM' …)
   │
   ② 함수 적용 — LOWER()가 값을 변환(대소문자·포맷·형변환·부분추출)
   │     인덱스에 저장된 키는 '원본값'인데, 비교 대상은 '변환된 값'
   │
   ③ 옵티마이저 판단 — 인덱스 키(email)와 LOWER(email)이 서로 다름
   │     원본으로 정렬된 인덱스로는 변환값을 찾아갈 수 없다 → 인덱스 포기
   │
   ④ 실행 — 전체 행을 읽어 행마다 LOWER()를 적용한 뒤 비교(Seq Scan)
   ▼     수십만~수백만 행에서 급격히 느려짐
  결과 (정답이지만 느림)

대조:  WHERE email = 'a@x.com'  →  ②③ 없이 인덱스로 바로 탐색(Index Scan)

각 단계에서 무슨 일이 일어나고, 인덱스에 무슨 일이 생기나:

단계하는 일인덱스에 생기는 일
① 입력 값컬럼의 원본값이 조건에 들어옴아직 문제 없음 — 인덱스는 이 원본값으로 정렬돼 있음
② 함수 적용값 변환 — LOWER/UPPER(대소문자), DATE(col)·DATE_TRUNC(날짜 절단), AT TIME ZONE(타임존), ::타입·암묵 형변환, SUBSTRING(추출)비교 대상이 더는 '원본값'이 아니게 됨
③ 옵티마이저 판단인덱스 키(원본)와 함수 결과가 불일치함을 확인인덱스로 탐색 불가 → Seq Scan 선택(인덱스 무력화)
④ 실행전체 행을 읽어 각 행에 함수 적용 후 필터I/O·CPU가 행 수에 비례해 폭증

인덱스가 죽는 곳은 ③이고, 원인은 언제나 ② — 컬럼에 함수·형변환이 씌워지는 순간입니다. 진단은 EXPLAIN으로 합니다: 인덱스가 있는 컬럼을 필터했는데 Seq Scan이 찍히면, 그 컬럼에 함수나 형변환이 씌워졌는지 의심합니다. 실무에서 가장 잦은 세 형태는 WHERE DATE(created_at) = '2024-03-15'(날짜 함수), WHERE LOWER(email) = …(대소문자 정규화), WHERE phone = 01012345678(문자열 컬럼에 숫자 리터럴 → 암묵 형변환)입니다. 해결은 방향을 뒤집는 것입니다 — 함수를 상수 쪽으로 옮겨 범위 조건으로 바꾸거나(created_at >= '2024-03-15' AND created_at < '2024-03-16'), 리터럴 타입을 컬럼에 맞추거나(phone = '01012345678'), 꼭 함수가 필요하면 표현식 인덱스(CREATE INDEX ON users (LOWER(email)))를 만들어 '변환된 값'을 인덱스로 저장합니다. 타임존·포맷 불일치로 생기는 느린 쿼리도 결국 이 "컬럼에 함수를 씌운" 형태로 나타납니다.

심화 — 리포트가 거짓말하는 법: 없는 '월'은 표시되지 않는다

💡개념

심화: DATE_TRUNC 집계의 빈 구간 함정과 gap filling

앞에서 GROUP BY DATE_TRUNC('month', created_at)로 월별 리포트를 만들었습니다. 이 쿼리는 대부분 잘 동작하지만, 한 가지 조용한 함정이 있습니다 — 데이터가 없는 달은 결과에 아예 나타나지 않습니다. 그리고 이 '없음'이 리포트를 미묘하게 거짓말하게 만듭니다.

GROUP BY는 '매칭되는 행이 하나라도 있는' 그룹에 대해서만 결과 행을 만듭니다. 완료 주문이 0건인 달은 그 버킷에 속한 행이 없으니 그룹 자체가 생기지 않아, 3월과 5월만 있고 4월은 통째로 빠진 결과가 나옵니다. 여기서 두 가지 왜곡이 생깁니다.

  • 그래프가 나쁜 달을 감춘다: 선 그래프는 3월 점과 5월 점을 바로 이어, 매출이 0이던 4월을 없던 일처럼 매끄러운 상승선으로 그립니다. 추세가 실제보다 좋아 보입니다.
  • 평균이 부풀려진다: '월 평균 매출'을 결과 행으로 AVG 하면, 실제 기간 수(예: 6개월)가 아니라 '결과에 나온 달 수'(예: 5개월)로 나눠 평균이 커집니다. '없음(missing)'과 '0'은 리포트에서 전혀 다른 값입니다.
  • 알림이 침묵한다: "이번 달 주문 수"를 조회했는데 0건이면, 0이 아니라 행 없음이 반환돼 "값이 없다"와 "0이다"를 구분 못 하는 코드가 오작동합니다.

해결은 모든 버킷을 먼저 만들어 두고(scaffold) 집계를 붙이는 것입니다. generate_series로 기간의 모든 달을 생성한 뒤, 실제 집계를 LEFT JOIN하고 COALESCE로 없는 달을 0으로 채웁니다.

SQL
SELECT g.month,
       COALESCE(o.orders, 0)  AS orders,
       COALESCE(o.revenue, 0) AS revenue
FROM generate_series(
       date_trunc('month', DATE '2026-01-01'),
       date_trunc('month', DATE '2026-06-01'),
       interval '1 month'
     ) AS g(month)
LEFT JOIN (
  SELECT date_trunc('month', created_at) AS month,
         count(*)    AS orders,
         sum(amount) AS revenue
  FROM orders
  WHERE status = 'completed'
  GROUP BY 1
) o ON o.month = g.month
ORDER BY g.month;

덧붙여, 집계함수의 NULL 처리도 함께 알아 둡니다 — SUM/AVG/COUNT(col)은 NULL을 건너뜁니다. 그래서 amount에 NULL이 섞이면 SUM은 무시하지만 AVG(amount)는 NULL이 아닌 행 수로만 나눠 값이 달라집니다. '행 없음 vs 0 vs NULL' 세 가지를 리포트마다 COALESCE로 명시적으로 정해 두는 것이 집계 정확성의 핵심입니다.

상황: 월별 리포트 쿼리는 문제없이 돌고 값도 맞아 보입니다. 그런데 마케팅 중단으로 매출이 0이던 한 달이 표에서 아예 사라졌고, 그래프는 그 앞뒤 달을 바로 이어 부드러운 우상향으로 보입니다. 경영진에 보고한 '월 평균 매출'도 재무팀 수치보다 높게 나옵니다.

원인: GROUP BY DATE_TRUNC('month', created_at)는 그 달에 완료 주문이 하나라도 있어야 행을 만듭니다. 0건인 달은 행 자체가 없어(0도 NULL도 아님) 타임라인에 보이지 않는 구멍이 생깁니다. 선 그래프는 그 구멍을 그냥 이어 붙여 나쁜 달을 감추고, 평균은 '결과에 나온 달 수'로만 나눠 부풀려집니다.

진단: 결과에 나온 달 수와 기간 내 실제 달 수를 비교합니다.

SQL
-- 2026년 상반기(6개월)인데 몇 개 달이 나오나
SELECT count(*) AS returned_months
FROM (
  SELECT date_trunc('month', created_at) AS m
  FROM orders
  WHERE status = 'completed'
    AND created_at >= '2026-01-01'
    AND created_at <  '2026-07-01'
  GROUP BY 1
) x;

6이 나와야 하는데 5가 나오면 한 달이 통째로 빠진 것입니다. 알고 있는 '매출 0인 달'이 결과에 있는지 직접 확인해도 됩니다.

해결: generate_series로 기간의 모든 달을 만들어 두고 집계를 LEFT JOIN + COALESCE로 붙입니다(위 심화의 쿼리). 그러면 0인 달이 revenue = 0 행으로 나타나 그래프에 제대로 찍히고, 평균도 실제 기간 수(6)로 나뉘어 정확해집니다. 지표마다 '없는 구간을 0으로 볼지 제외할지'를 먼저 정하고, 그 결정을 COALESCE로 쿼리에 명시하세요 — 리포트의 신뢰도는 "있는 데이터"만큼이나 "없는 데이터를 어떻게 표현하는가"에 달려 있습니다.


명령어·구문 빠른 참조

이 모듈에서 다룬 문자열·날짜 함수를 실전 예시와 함께 모았습니다(PostgreSQL 기준).

구문/명령용도
TRIM / LTRIM / RTRIM앞뒤(또는 지정 문자) 제거TRIM(' hi '), TRIM(BOTH '0' FROM '00420')
UPPER / LOWER / INITCAP대소문자 정규화LOWER(email) (대소문자 무시 비교·유니크)
SUBSTRING / SUBSTR부분 문자열 추출SUBSTRING('Hello' FROM 1 FOR 3)
SPLIT_PART구분자 기준 N번째 토큰SPLIT_PART(email, '@', 2) (도메인 추출)
REPLACE문자열 치환REPLACE(phone, '-', '')
CONCAT / CONCAT_WS / ||문자열 연결(NULL 처리 주의)CONCAT_WS(', ', 시, 구, 동) (NULL 건너뜀)
REGEXP_REPLACE정규식 치환('g'=전체)REGEXP_REPLACE(phone, '[^0-9]', '', 'g')
NOW / CURRENT_DATE현재 시각/날짜WHERE created_at >= NOW() - INTERVAL '30 days'
DATE_TRUNC시간 단위 내림(월·일 집계)GROUP BY DATE_TRUNC('month', created_at)
EXTRACT연·월·요일·epoch 추출EXTRACT(EPOCH FROM (expires_at - NOW()))
AGE / INTERVAL경과 기간·날짜 연산AGE(NOW(), created_at), ts + INTERVAL '7 days'
TO_CHAR날짜·숫자 포맷 문자열TO_CHAR(created_at, 'YYYY-MM')
… AT TIME ZONE타임존 변환created_at AT TIME ZONE 'Asia/Seoul'
generate_series + COALESCE빈 구간 채우기(gap filling)generate_series(…, interval '1 month') LEFT JOIN … COALESCE(revenue, 0)

관련 모듈로 더 깊이:

다음 모듈에서는 뷰(View)와 Stored Procedure의 운영 관점 장단점과 언제 사용할지 판단 기준을 다룹니다.

지식 확인

퀴즈 — 8문제

Q1

사용자가 회원가입 폼에 이메일을 ' user@example.com '처럼 앞뒤 공백이 포함된 채로 입력했습니다. DB 저장 전에 SQL 레벨에서 공백을 제거해야 합니다. 가장 적절한 방법은?

Q2

DATE_TRUNC('month', '2024-03-15 14:30:00')의 결과는?

Q3

쿠폰 만료 시간이 TIMESTAMPTZ 컬럼에 저장돼 있습니다. 현재 시간과 만료 시간의 차이를 초(second) 단위 정수로 구해 '남은 시간: X초'를 표시하려 합니다. 올바른 PostgreSQL 쿼리는?

Q4

API 응답에서 사용자 이름과 이메일을 'Hong Gildong <hong@example.com>' 형식으로 합쳐야 합니다. 일부 사용자의 name 컬럼이 NULL입니다. NULL이 있어도 이메일만큼은 올바르게 포함되어야 할 때 안전한 방법은?

Q5

AGE('2024-01-01', '2000-06-15')가 반환하는 것은?

Q6

사용자가 이메일을 'User@Example.com'과 'user@example.com'으로 대소문자만 다르게 가입해 중복 계정이 생겼다. SQL 함수로 이를 막거나 일관되게 다루는 방법은?

Q7

[심화] GROUP BY DATE_TRUNC('month', created_at)로 월별 매출을 집계할 때, 완료 주문이 하나도 없던 달은 결과에서 어떻게 되나?

Q8

[심화] 월별 매출 리포트에서 주문 없던 달이 빠지자 그래프가 매끄러운 상승선이 되고 '월 평균 매출'이 실제보다 높게 나온다. 원인과 올바른 해결은?

0 / 8 답변

🧪 실습으로 확인하기

PostgreSQL 설치 및 기본 설정

초급

Linux 서버에 PostgreSQL을 설치하고, 데이터베이스와 사용자를 생성한 뒤 외부 접속이 가능하도록 설정한다.

50📋 5단계💻 직접 환경
실습 시작하기 →

이것도 배워보세요