← DoniKit 웹 앱으로

DB 코드 스니펫 25종

PostgreSQL 실무 쿼리 모음입니다. 함수·프로시저·익명 블록(DO $$), 윈도우 함수, 재귀 CTE, DDL, 데이터 이관, 배열 컬럼, 다건 INSERT(ON CONFLICT)까지 — 그대로 실행해 보며 다듬을 수 있는 완성형 예제입니다.

웹 앱에서는 빈칸을 채워 완성된 코드를 바로 복사할 수 있습니다.

조회 SELECT — 조건 · 정렬 · 페이지네이션

DB 접근 · PostgreSQL

-- 기본 조회: 조건 · 정렬 · 페이지네이션
SELECT id, name, age
FROM users
WHERE age >= 20 AND name ILIKE '%kim%'
ORDER BY age DESC, name ASC
LIMIT 20 OFFSET 0;

💡 ILIKE는 대소문자 무시 LIKE(PostgreSQL 전용). 대용량 페이지네이션은 OFFSET보다 키셋(WHERE id > 마지막id)이 빠르다.

JOIN — INNER · LEFT

DB 접근 · PostgreSQL

-- INNER: 양쪽 다 있는 행만 · LEFT: 왼쪽 전부 + 매칭(없으면 NULL)
SELECT u.id, u.name, o.amount
FROM users u
INNER JOIN orders o ON o.user_id = u.id
LEFT JOIN coupons c ON c.user_id = u.id
WHERE o.amount > 0;

💡 LEFT JOIN 후 오른쪽 컬럼을 WHERE로 거르면 INNER처럼 동작한다. "매칭 없는 행"을 찾으려면 WHERE o.id IS NULL.

변경 INSERT · UPDATE · DELETE (+ RETURNING)

DB 접근 · PostgreSQL

-- 삽입: RETURNING으로 생성된 id를 바로 받는다
INSERT INTO users (name, age)
VALUES ('홍길동', 30)
RETURNING id;

-- 수정
UPDATE users
SET age = age + 1
WHERE id = 1;

-- 삭제
DELETE FROM users
WHERE id = 1;

💡 앱에서 값은 파라미터($1, $2)로 바인딩하고 문자열로 이어붙이지 말 것(SQL 인젝션). RETURNING은 PostgreSQL 강점.

집계 GROUP BY · HAVING

DB 접근 · PostgreSQL

-- 그룹별 집계: 도시별 인원 · 평균 나이
SELECT city, COUNT(*) AS cnt, AVG(age)::numeric(10, 1) AS avg_age
FROM users
GROUP BY city
HAVING COUNT(*) >= 5
ORDER BY cnt DESC;

💡 WHERE는 그룹 전, HAVING은 그룹 후 조건. SELECT의 비집계 컬럼은 전부 GROUP BY에 있어야 한다.

UPSERT — INSERT ... ON CONFLICT

DB 접근 · PostgreSQL

-- 있으면 수정, 없으면 삽입 (PostgreSQL UPSERT)
INSERT INTO users (id, name, age)
VALUES (1, '홍길동', 30)
ON CONFLICT (id) DO UPDATE
SET name = EXCLUDED.name,
    age  = EXCLUDED.age;

💡 EXCLUDED는 삽입하려던 값을 가리키는 의사 테이블. 충돌 시 무시는 DO NOTHING. 대상 컬럼(id)에 PRIMARY KEY/UNIQUE 제약이 있어야 한다.

다건 INSERT — 여러 행 한 번에 · ON CONFLICT DO NOTHING · INSERT SELECT

DB 접근 · PostgreSQL

-- 여러 행을 한 문장으로 (VALUES를 쉼표로 나열)
INSERT INTO users (id, name, age)
VALUES
  (1, '홍길동', 30),
  (2, '김철수', 28),
  (3, '이영희', 35);

-- 이미 있는 행은 조용히 건너뛰기 (에러 없이 그 행만 스킵)
INSERT INTO users (id, name, age)
VALUES
  (1, '홍길동', 30),
  (2, '김철수', 28)
ON CONFLICT DO NOTHING;

-- 충돌 대상을 지정 (유니크 제약이 여러 개일 때 그중 하나만 무시)
INSERT INTO users (id, name, age)
VALUES (1, '홍길동', 30)
ON CONFLICT (id) DO NOTHING;

-- 실제로 들어간 행만 돌려받기 — 건너뛴 행은 안 나온다
INSERT INTO users (id, name, age)
VALUES (1, '홍길동', 30), (2, '김철수', 28)
ON CONFLICT (id) DO NOTHING
RETURNING id;

-- 조회 결과를 그대로 적재 (INSERT ... SELECT)
INSERT INTO users (id, name, age)
SELECT id, name, age
FROM users_stage
ON CONFLICT (id) DO NOTHING;

💡 DO NOTHING은 충돌 행만 건너뛴다(문장 전체 롤백 아님) — 대상 컬럼을 생략하면 모든 유니크 제약·배타 제약 충돌을 무시한다. 한 문장 안에 같은 키가 두 번 들어있으면 DO NOTHING은 뒤엣것을 그냥 스킵하지만 DO UPDATE는 "같은 행을 두 번 못 고친다" 에러 → DISTINCT ON 등으로 미리 중복 제거. 행별 INSERT를 반복하는 것보다 이 다건 문장이 훨씬 빠르고, 수만 행 이상이면 COPY를 쓴다. Oracle엔 이 문법이 없다(MERGE, MySQL은 INSERT IGNORE / ON DUPLICATE KEY UPDATE).

배열 컬럼 값 추가 · 제거 (array_append · || · array_remove)

DB 접근 · PostgreSQL

-- tags text[] 처럼 배열 컬럼을 가진 테이블 기준
-- 뒤에 값 하나 붙이기
UPDATE posts
SET tags = array_append(tags, 'postgres')
WHERE id = 1;

-- || (연결 연산자) — 여러 개도 한 번에
UPDATE posts
SET tags = tags || ARRAY['sql', 'db']
WHERE id = 1;

-- 앞에 붙이기
UPDATE posts
SET tags = array_prepend('first', tags)
WHERE id = 1;

-- 값 제거 — 같은 값이 여러 개면 전부 사라진다
UPDATE posts
SET tags = array_remove(tags, 'postgres')
WHERE id = 1;

💡 array_append·||는 중복을 검사하지 않는다(중복 방지는 "배열 안전하게" 카드). 컬럼이 NULL이면 ||의 결과도 NULL — array_append(NULL, x)만 원소 1개 배열이 된다. 배열 인덱스는 0이 아니라 1부터.

배열 안전하게 — 중복 방지 · NULL · 여러 값 한 번에 빼기

DB 접근 · PostgreSQL

-- 1) 이미 있으면 그대로 두기 (중복 방지)
UPDATE posts
SET tags = array_append(tags, 'postgres')
WHERE id = 1
  AND NOT ('postgres' = ANY(tags));

-- 2) NULL 안전 — 컬럼이 NULL이어도 빈 배열에서 시작
UPDATE posts
SET tags = COALESCE(tags, '{}') || ARRAY['new']
WHERE id = 1;

-- 3) 여러 값 빼기 ① 중첩 (두세 개면 이게 간단)
UPDATE posts
SET tags = array_remove(array_remove(tags, 'a'), 'b')
WHERE id = 1;

-- 4) 여러 값 빼기 ② unnest + 재집계 (뺄 값이 많을 때 = 차집합)
UPDATE posts
SET tags = (
  SELECT COALESCE(array_agg(t), '{}')
  FROM unnest(tags) AS t
  WHERE t <> ALL(ARRAY['a', 'b', 'c'])
)
WHERE id = 1;

💡 컬럼이 NULL이면 1)의 = ANY(...)가 NULL이라 WHERE가 참이 안 된다 — COALESCE(col, '{}')로 감싸라. 타입이 모호하다는 에러가 나면 '{}'::text[]로 명시. 4)의 재집계는 원소 순서를 보장하지 않는다(순서가 중요하면 unnest ... WITH ORDINALITY로 정렬).

배열 조회 — 포함 조건(ANY · @> · &&) · 펼치기 · 인덱스

DB 접근 · PostgreSQL

-- 값 하나가 들어있는 행
SELECT id, tags
FROM posts
WHERE 'postgres' = ANY(tags);

-- @> 전부 포함 · && 하나라도 겹침
SELECT id FROM posts WHERE tags @> ARRAY['sql', 'db'];
SELECT id FROM posts WHERE tags && ARRAY['sql', 'db'];

-- 배열을 행으로 펼쳐 집계 (태그별 개수)
SELECT t AS tag, COUNT(*) AS cnt
FROM posts, unnest(tags) AS t
GROUP BY t
ORDER BY cnt DESC;

-- 길이 · 원소 접근 (인덱스는 1부터)
SELECT array_length(tags, 1) AS len, tags[1] AS first_item
FROM posts
WHERE id = 1;

-- 포함 검색이 잦으면 GIN 인덱스
CREATE INDEX idx_posts_tags ON posts USING GIN (tags);

💡 GIN 인덱스를 타는 건 @>·<@·&&이고 = ANY(...)는 못 탄다 — 대용량 포함 검색은 @>로. FROM에 콤마로 붙인 unnest는 배열이 비었거나 NULL인 행을 통째로 떨어뜨린다(남기려면 LEFT JOIN LATERAL unnest(col) AS t ON true).

함수 CREATE FUNCTION — PL/pgSQL 기본 골격

DB 접근 · PostgreSQL (PL/pgSQL)

-- 값 하나를 돌려주는 함수 (변수 선언 · IF 분기 · RETURN)
CREATE OR REPLACE FUNCTION fn_user_name(p_id bigint)
RETURNS text
LANGUAGE plpgsql
AS $$
DECLARE
  v_name text;
BEGIN
  SELECT name INTO v_name FROM users WHERE id = p_id;

  IF v_name IS NULL THEN
    RETURN '(없음)';
  END IF;
  RETURN v_name;
END;
$$;

-- 호출: 함수는 SELECT로 부른다
SELECT fn_user_name(1);

💡 $$ ... $$는 본문을 감싸는 달러 인용 — 안에서 따옴표를 이스케이프 없이 쓴다. 함수 안에선 COMMIT 불가(트랜잭션 제어가 필요하면 프로시저).

함수 RETURNS TABLE — 조회 결과셋 반환

DB 접근 · PostgreSQL (PL/pgSQL)

-- 여러 행(결과셋)을 돌려주는 함수 — 호출부에서 테이블처럼 쓴다
CREATE OR REPLACE FUNCTION fn_adult_users(p_min_age int)
RETURNS TABLE (id bigint, name text, age int)
LANGUAGE plpgsql
AS $$
BEGIN
  RETURN QUERY
  SELECT u.id, u.name, u.age
  FROM users u
  WHERE u.age >= p_min_age
  ORDER BY u.age DESC;
END;
$$;

-- 호출: FROM 자리에 넣는다
SELECT * FROM fn_adult_users(20);

💡 RETURN QUERY가 SELECT 결과를 그대로 반환한다. RETURNS TABLE의 컬럼명(id 등)이 테이블 컬럼과 겹치면 모호성 오류 — 본문에서 u.id처럼 별칭 필수.

프로시저 CREATE PROCEDURE — CALL · 트랜잭션

DB 접근 · PostgreSQL 11+

-- 프로시저: 반환값 없는 작업 묶음 · 본문에서 COMMIT 가능
CREATE OR REPLACE PROCEDURE sp_withdraw(p_id bigint, p_amount numeric)
LANGUAGE plpgsql
AS $$
BEGIN
  UPDATE accounts
  SET balance = balance - p_amount
  WHERE id = p_id;

  INSERT INTO account_logs (account_id, amount, logged_at)
  VALUES (p_id, p_amount, now());

  COMMIT; -- 함수는 불가, 프로시저만 가능
END;
$$;

-- 호출: SELECT가 아니라 CALL
CALL sp_withdraw(1, 500);

💡 PROCEDURE·CALL은 PostgreSQL 11+ — 구버전(폐쇄망 레거시)이면 RETURNS void 함수로 대체. 본문 COMMIT은 자동커밋 상태의 CALL에서만 되고, BEGIN 트랜잭션 안에서 부르면 오류.

익명 블록 DO $$ … END $$ — 만들지 않고 프로시저처럼 실행

DB 접근 · PostgreSQL 9.0+

-- 익명 블록: 함수·프로시저를 만들지 않고 PL/pgSQL을 그 자리에서 실행한다.
-- 일회성 백필·마이그레이션·운영 점검처럼 "한 번 돌리고 버릴" 로직에 쓴다.
DO $$
DECLARE
  v_count integer;
BEGIN
  SELECT count(*) INTO v_count
  FROM orders
  WHERE status = 'PENDING';

  IF v_count = 0 THEN
    RAISE NOTICE '대상 없음 — 건너뜀';
    RETURN; -- 블록 종료 (반환값은 없다)
  END IF;

  UPDATE orders
  SET status = 'DONE', updated_at = now()
  WHERE status = 'PENDING';

  RAISE NOTICE '% 건 처리 완료', v_count;

EXCEPTION WHEN OTHERS THEN
  RAISE NOTICE '실패: %', SQLERRM;
  RAISE; -- 다시 던져야 롤백된다. 삼키면 그대로 커밋됨
END $$;

💡 이름이 없어 CALL·재사용 불가, 파라미터·반환값도 없다(값이 필요하면 함수). 본문에 $$가 들어가면 $tag$ … $tag$로 구분자를 바꾼다. RAISE NOTICE는 결과셋이 아니라 클라이언트 로그로 나가고(psql·DBeaver 출력창), EXCEPTION에서 RAISE를 빼면 오류를 삼켜 그대로 커밋된다.

윈도우 함수 — ROW_NUMBER · 그룹별 최신 1건

DB 접근 · PostgreSQL

-- 유저별 주문 중 "가장 최근 1건"만 (조회 화면 단골)
SELECT *
FROM (
  SELECT o.*,
         ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn
  FROM orders o
) t
WHERE t.rn = 1;

-- 순위 · 누계
SELECT name, score,
       RANK() OVER (ORDER BY score DESC)     AS ranking,
       SUM(score) OVER (ORDER BY score DESC) AS running_total
FROM users;

💡 윈도우 함수는 WHERE에 바로 못 쓴다(실행 순서상 나중) — 서브쿼리/CTE로 감싸고 rn = 1로 거른다. 동점: ROW_NUMBER는 무조건 1개씩, RANK는 공동 순위.

WITH (CTE) — 서브쿼리에 이름 붙여 단계별 가공

DB 접근 · PostgreSQL

-- 1단계: 월별 매출 집계 → 2단계: 전월 대비 증감
WITH monthly AS (
  SELECT date_trunc('month', created_at) AS ym, SUM(amount) AS total
  FROM orders
  GROUP BY 1
),
compared AS (
  SELECT ym, total,
         LAG(total) OVER (ORDER BY ym) AS prev_total
  FROM monthly
)
SELECT ym, total, total - prev_total AS diff
FROM compared
ORDER BY ym;

💡 WITH는 서브쿼리에 이름을 붙여 위→아래로 읽게 만든다. 같은 CTE를 여러 번 참조할 수 있고, WITH RECURSIVE로 조직도·트리 조회도 가능(아래 예).

WITH RECURSIVE — 조직도·계층(트리) 펼치기

DB 접근 · PostgreSQL

-- 기준 사원 아래 모든 부하를 계층으로 (자기참조 테이블 employees: id, name, manager_id)
WITH RECURSIVE subordinates AS (
  -- 앵커(시작 행): 기준 사원 한 명
  SELECT id, name, manager_id, 1 AS depth
  FROM employees
  WHERE id = 1

  UNION ALL

  -- 재귀: 직전 결과(s)의 부하를 계속 이어붙인다
  SELECT e.id, e.name, e.manager_id, s.depth + 1
  FROM employees e
  JOIN subordinates s ON e.manager_id = s.id
)
SELECT repeat('    ', depth - 1) || name AS tree, depth
FROM subordinates
ORDER BY depth, name;

💡 UNION ALL 위가 시작 행, 아래가 재귀. 종료 조건이 없어 순환 데이터면 무한 루프 — UNION(중복 제거)으로 바꾸거나 depth 상한으로 사이클을 막는다. 상위 조상 찾기는 JOIN 조건만 뒤집으면 된다.

데이터 변경 CTE — DELETE … RETURNING → INSERT (한 문장 이관)

DB 접근 · PostgreSQL

-- 1년 지난 주문을 아카이브로 "옮기기": 삭제하며 뽑은 행을 그대로 적재 (원자적)
WITH moved AS (
  DELETE FROM orders
  WHERE created_at < now() - interval '1 year'
  RETURNING *
)
INSERT INTO orders_archive
SELECT * FROM moved;

💡 WITH 안에 INSERT/UPDATE/DELETE + RETURNING을 넣어 그 결과를 다음 단계에서 쓰는 건 PostgreSQL 특유. 하위문은 모두 같은 스냅샷에서 한 번씩만 실행돼 실행 순서에 기대면 안 된다. 삭제→적재를 한 문장으로 묶어 중간 실패 시 전부 취소된다(orders_archive는 같은 컬럼 구성이어야 함).

트랜잭션 — BEGIN · COMMIT · ROLLBACK · SAVEPOINT

DB 접근 · PostgreSQL

-- 묶음 작업: 전부 성공 아니면 전부 취소
BEGIN;

UPDATE accounts SET balance = balance - 500 WHERE id = 1;
UPDATE accounts SET balance = balance + 500 WHERE id = 2;

-- 중간 저장점 — 일부만 되돌리기
SAVEPOINT before_log;
INSERT INTO transfer_logs (from_id, to_id, amount) VALUES (1, 2, 500);
ROLLBACK TO SAVEPOINT before_log; -- 저장점 이후만 취소

COMMIT; -- 확정 (전체 취소는 COMMIT 대신 ROLLBACK;)

💡 앱에서는 보통 프레임워크(@Transactional 등)가 관리한다 — 이 구문은 DB 콘솔·스크립트용. 에러 난 트랜잭션은 ROLLBACK 전까지 이후 명령이 전부 거부된다(25P02). GUI 메뉴얼 커밋 모드는 도구가 BEGIN을 대신 거는 것 — 스크립트의 BEGIN/COMMIT과 섞으면 스크립트 COMMIT이 실행되는 순간(도구 버튼과 무관하게) 확정된다. psql이면 스크립트에 BEGIN/COMMIT 포함, 메뉴얼 커밋 모드면 스크립트에서 빼고 도구 버튼 사용.

데이터 이관 골격 — TEMP TABLE 매핑 + 트랜잭션 (dry-run → 본 이관)

DB 접근 · PostgreSQL

-- 일회성 이관: 매핑을 TEMP TABLE로 한 번 구체화 → dry-run·본 이관 전 단계가 같은 기준 공유
-- (CTE WITH는 문장 하나에만 유효 — 여러 문장이 공유하려면 TEMP TABLE)
BEGIN;

-- ON COMMIT DROP: COMMIT/ROLLBACK 때 자동 삭제 → 재실행해도 already exists 없음.
-- 반드시 BEGIN 뒤(트랜잭션 안)에서 생성 — 밖에서 만들면 문장이 끝나는 즉시 사라진다.
CREATE TEMP TABLE mig_map ON COMMIT DROP AS
SELECT s.id                             AS legacy_store_id,
       replace(s.store_key, 'old-', '') AS member_region_code, -- 실소속 코드 (하위 뎁스 포함)
       hq.id                            AS target_store_id     -- 통합될 본부 매장
FROM stores s
JOIN region_codes rc ON rc.code = replace(s.store_key, 'old-', '')
JOIN stores hq       ON hq.region_code = rc.root_code -- 행이 실존하는 레벨(root)로 — parent는 매장 행이 없다
WHERE s.store_key LIKE 'old-%';

-- 1) dry-run SELECT로 건수·샘플 검토 → 문제 있으면 ROLLBACK; 후 중단
SELECT count(*) FROM mig_map;
-- 2) 본 이관 문장 순서대로 실행 — 문장마다 "UPDATE n" 건수가 예상과 맞는지 확인
-- 3) 검증 SELECT (이관 결과 샘플 확인)

COMMIT; -- 이상하면 대신 ROLLBACK;

💡 TEMP TABLE은 세션 전용 — 다른 세션엔 안 보이고 커넥션이 끊기면 자동 삭제. legacy 키를 신 값으로 rename부터 하면 기존 행과 UNIQUE 충돌 — 조인에서 replace()로 매핑하면 충돌 자체가 없다. 트랜잭션이 열린 동안 대상 행에 잠금이 걸리니 열어놓고 오래 고민하지 말 것. 파일 실행은 psql -v ON_ERROR_STOP=1 -f 파일.sql — 에러 시 즉시 중단돼 COMMIT 미도달 = 자동 롤백.

이관 DML — UPDATE … FROM · DELETE … USING (매핑 조인)

DB 접근 · PostgreSQL

-- 매핑 조인 UPDATE: 매핑되는 행만 갱신 — 스칼라 서브쿼리의 "조용한 NULL" 원천 차단
UPDATE orders o
SET store_id = m.target_store_id
FROM mig_map m
WHERE o.store_id = m.legacy_store_id;

-- 이동시키면 UNIQUE(store_id, account_id) 충돌인 행은 먼저 제거
DELETE FROM store_staff st
USING mig_map m
WHERE st.store_id = m.legacy_store_id
  AND EXISTS (SELECT 1 FROM store_staff x
              WHERE x.store_id = m.target_store_id AND x.account_id = st.account_id);

-- 소속 이관: 본부로 이동 + 실소속 코드 기록
UPDATE store_staff st
SET store_id    = m.target_store_id,
    region_code = m.member_region_code,
    updated_at  = CURRENT_TIMESTAMP
FROM mig_map m
WHERE st.store_id = m.legacy_store_id;

-- 원본은 hard DELETE 대신 소프트 삭제 — 이력 테이블 FK가 참조 중이면 삭제가 실패하거나 이력을 잃는다
UPDATE stores s
SET status = 'CLOSED', deleted_at = CURRENT_TIMESTAMP
WHERE s.id IN (SELECT legacy_store_id FROM mig_map);

💡 SET col = (SELECT …) 스칼라 서브쿼리는 못 찾으면 NULL을 그대로 심는다(NULL 허용 컬럼이면 조용히 깨짐) — FROM 조인 방식이 안전. 한 UPDATE 안의 SET 표현식은 전부 갱신 전(old) 행 값 기준. "SET x = y; WHERE …" 세미콜론 오타는 WHERE 없는 전 행 UPDATE가 된다 — 실행 전 문장 단위로 검증하고 UPDATE n 건수를 dry-run 예상치와 대조(다르면 COMMIT 전에 ROLLBACK).

이관 사전 점검 — 매핑 누락 · UNIQUE 충돌 · FK 참조 조회

DB 접근 · PostgreSQL

-- (a) 매핑 안 되는 행: 기준 테이블에 없는 코드 → 개별 판단 대상
SELECT s.id, s.store_key, s.name
FROM stores s
WHERE s.store_key LIKE 'old-%'
  AND NOT EXISTS (SELECT 1 FROM region_codes rc
                  WHERE rc.code = replace(s.store_key, 'old-', ''));

-- (b) 통합 후 UNIQUE 충돌 예정: 같은 대상으로 2건 이상 모이는 행
SELECT m.target_store_id, st.account_id, count(*)
FROM store_staff st
JOIN mig_map m ON m.legacy_store_id = st.store_id
GROUP BY 1, 2
HAVING count(*) > 1;

-- (c) 테이블의 PK·UNIQUE·FK 제약 정의 조회 (rename·통합 전 충돌 축 확인)
SELECT conname, pg_get_constraintdef(oid)
FROM pg_constraint
WHERE conrelid = 'stores'::regclass;

-- (d) 삭제 전 — 이 테이블을 참조하는 FK 전수 조회
SELECT conrelid::regclass AS referencing, pg_get_constraintdef(oid)
FROM pg_constraint
WHERE confrelid = 'stores'::regclass;

💡 (b)에 결과가 있으면 어느 쪽을 남길지 수동으로 정한 뒤 진행 — 자동 규칙으로 임의 선택하지 않는다. 지워야 한다면 자식(참조) → 부모(피참조) 순 — 부모부터 지우면 FK 위반. "상위로 올린다"가 parent(한 단계)인지 root(최상위)인지도 명확히 — 행이 없는 레벨을 축으로 잡으면 NULL/NOT NULL 위반.

테이블 생성 CREATE TABLE — 제약 · 기본값 · 인덱스

DB 접근 · PostgreSQL

CREATE TABLE orders (
  id         bigserial PRIMARY KEY,
  user_id    bigint      NOT NULL REFERENCES users (id),
  status     varchar(20) NOT NULL DEFAULT 'READY',
  amount     numeric(12, 2) NOT NULL CHECK (amount >= 0),
  memo       text,
  created_at timestamptz NOT NULL DEFAULT now(),
  UNIQUE (user_id, created_at)
);

-- 자주 검색하는 컬럼은 인덱스
CREATE INDEX idx_orders_user ON orders (user_id);

-- 컬럼 추가
ALTER TABLE orders ADD COLUMN cancel_yn char(1) DEFAULT 'N';

💡 PK는 bigserial(자동 증가)이 무난. REFERENCES(FK)는 부모 테이블이 먼저 있어야 한다. 인덱스는 조회를 빠르게 하는 대신 INSERT/UPDATE를 느리게 한다 — 검색 조건 컬럼에만.

날짜·시간 — now · interval · date_trunc

날짜·시간 · PostgreSQL

-- 현재 시각 · 기간 연산 · 구간 절단
SELECT
  now()                          AS current_ts,
  now() - interval '7 days'      AS week_ago,
  date_trunc('month', now())     AS month_start,
  age(now(), '2000-01-01'::date) AS elapsed;

💡 interval은 '7 days'·'1 month'처럼 문자열. date_trunc는 월·일 단위 버킷 집계에 유용. 타임존이 필요하면 timestamptz.

날짜 비교 — 기준 형태로 완성해서 비교 ('yyyymmdd' · 'yyyymmddhh24miss' · timestamp · date)

날짜·시간 · PostgreSQL

-- 쓰는 법: 비교할 기준 형태를 하나 고르고, 그 영역의 변환으로 완성해서 비교한다.
-- 예시 값: created_at = timestamp 컬럼(안의 값: 2026-07-30 14:30:25),
--          '20260730' = 자바·화면에서 넘어온 문자열 파라미터

-- ══ 1. 'yyyymmdd' 문자열로 완성해서 비교 — 완성형: '20260730' ══════
SELECT * FROM orders
WHERE  to_char(created_at, 'YYYYMMDD') = '20260730';  -- timestamp(2026-07-30 14:30:25) → '20260730'

-- varchar 'yyyymmdd' 컬럼 ↔ timestamp 컬럼 조인도 같은 방식
SELECT * FROM legacy_t l
JOIN   modern_t m ON l.ymd = to_char(m.created_at, 'YYYYMMDD');

-- ══ 2. 'yyyymmddhh24miss' 문자열로 완성해서 비교 — 완성형: '20260730143025' ══
SELECT * FROM orders
WHERE  to_char(created_at, 'YYYYMMDDHH24MISS')       -- timestamp → '20260730143025'
       >= '20260730' || '000000';                      -- '20260730' → '20260730000000' (자정 붙이기)

-- ══ 3. timestamp로 완성해서 비교 — 완성형: 2026-07-30 14:30:25 ══
-- 문자열 쪽을 타입으로 바꾼다 — 컬럼이 그대로라 인덱스를 탄다 (대량 테이블 권장)
SELECT * FROM orders
WHERE  created_at >= to_timestamp('20260730143025', 'YYYYMMDDHH24MISS'); -- '20260730143025' → 2026-07-30 14:30:25

-- 하루 범위 조건 (BETWEEN 대신 미만 비교)
SELECT * FROM orders
WHERE  created_at >= to_date('20260730', 'YYYYMMDD')                -- '20260730' → 2026-07-30 00:00:00
AND    created_at <  to_date('20260730', 'YYYYMMDD') + interval '1 day';

-- ══ 4. date로 완성해서 비교 — 완성형: 2026-07-30 (일 단위) ══
SELECT * FROM orders
WHERE  created_at::date = to_date('20260730', 'YYYYMMDD'); -- 2026-07-30 14:30:25 → 2026-07-30 (시각 버림)

💡 기준 고르는 법 — 1·2·4번은 컬럼을 감싸거나 캐스팅해 그 컬럼 인덱스를 못 탈 수 있다(소량 조회·조인 키 맞추기엔 충분). 대량 테이블 WHERE는 3번(문자열 쪽을 변환 — 컬럼 유지). 포맷 대소문자가 Java와 다르다 — 분은 MI(mm 아님)·24시간제는 HH24. Oracle도 to_char/to_date 문법 동일.

문자열 — 연결 · 부분 · 치환 · 분리

문자열 · PostgreSQL

-- 연결 · 부분추출 · 치환 · 분리 · 패턴
SELECT
  'a' || '-' || 'b'               AS concat,
  substring('hello' FROM 1 FOR 3) AS sub,
  replace('a,b,c', ',', ';')      AS replaced,
  split_part('a,b,c', ',', 2)     AS second,
  'HELLO' ILIKE 'hel%'            AS matched;

💡 연결은 || 연산자(CONCAT 함수도 가능). split_part 인덱스는 1부터. ILIKE는 대소문자 무시 매칭.