출고 등록이라는 업무 처리가 출고·출고상세·재고 데이터를 각각 생성(C)·조회(R)·수정(U)·삭제(D) 중 무엇으로 다루는지 매트릭스로 정리한다.
업무 처리와 데이터가 서로 어떤 영향을 주고받는지 CRUD 매트릭스로 분석하는 것은 상관 관점(데이터와 프로세스의 상호작용)이다.
데이터 모델링은 데이터 관점·프로세스 관점·상관 관점의 세 관점으로 업무를 바라본다.
- 데이터 관점은 어떤 데이터가 있고 데이터끼리 어떤 관계인지를 본다.
- 프로세스 관점은 업무가 실제로 무엇을 하는지, 무엇을 해야 하는지를 본다.
- 저장 위치를 보는 물리 관점은 세 관점에 속하지 않는다.
엔터티는 업무에 필요한 정보를 담아야 하므로 반드시 속성을 가져야 하며, 속성 없이 개수만 세는 대상은 엔터티로 보지 않는다.
엔터티는 업무 필요성·유일한 식별자·두 개 이상의 인스턴스·속성·관계를 갖춰야 성립한다.
- 업무에서 관리할 필요가 없는 정보는 엔터티가 될 수 없다.
- 인스턴스마다 유일하게 구분할 식별자가 있어야 한다.
- 코드·통계성 엔터티를 빼면 다른 엔터티와 관계를 하나 이상 갖는다.
한 인스턴스는 속성마다 하나의 값만 가져야 하므로, 품목당 여러 값이 생기는 보관창고는 품목보관 같은 별도 엔터티로 분리한다.
분리된 엔터티는 품목번호와 창고코드를 함께 식별자로 가져 보관 건수만큼 인스턴스를 만든다.
- 한 칸에 여러 창고명을 이어 적으면 원자값 원칙을 어긴다.
- 번호 붙은 반복 속성은 창고가 늘 때마다 구조를 바꿔야 한다.
- 품목명에 창고명을 섞으면 한 속성이 두 의미를 갖게 된다.
IE 표기법에서 원(○)은 선택 참여(0), 까마귀발은 다수(N)를 뜻하므로 둘이 함께 있으면 0개 이상이다.
세로 막대(|)는 필수 참여를 뜻하며, 바커 표기법에서는 선택 참여를 점선으로 나타낸다.
- 막대와 까마귀발이 함께 있어야 1개 이상이다.
- 원과 막대 하나가 함께 있으면 0 또는 1개다.
- 막대 두 개가 겹치면 정확히 1개다.
식별 관계는 부모의 주식별자를 자식의 주식별자로 상속하므로 단계를 거칠수록 적재의 주식별자 속성 수가 늘어난다.
이 때문에 조인 조건과 SQL 이 길어지므로 독립적으로 관리할 하위 엔터티는 비식별 관계를 검토한다.
- 부모 없이 자식이 생길 수 있는 것은 비식별 관계의 선택 참여다.
- 상속된 주식별자는 동시에 외래키 역할을 하므로 사라지지 않는다.
- 상속된 키로 오히려 부모와 바로 조인할 수 있다.
반정규화는 조회 성능을 위해 중복·파생 데이터를 허용하는 것이어서 데이터 무결성을 지키기가 오히려 어려워진다.
절차상 조회 범위·빈도를 조사한 뒤 다른 방법(인덱스·뷰·클러스터링·응용 처리)을 먼저 검토하고, 그래도 안 될 때 파생 컬럼 같은 반정규화를 적용한다.
- 인덱스·뷰 같은 대안 검토는 반정규화 적용 전 단계다.
- 합계를 미리 담는 파생 컬럼 추가는 대표적인 컬럼 반정규화다.
- 중복 값은 원본이 바뀔 때 함께 갱신해야 정합성이 유지된다.
구역은 인조식별자 구역ID 를 쓰고 적재와 비식별 관계이므로, 적재에는 구역ID 만 외래키로 있고 창고코드는 상속되지 않는다.
따라서 적재→구역(구역ID)→창고(창고코드) 순으로 이어야 창고명을 얻을 수 있고, 품목은 창고명 집계에 쓰이지 않는다.
- 적재에 창고코드가 없어 창고와 바로 조인할 수 없다.
- 품목은 창고와 연결 경로가 없어 창고명을 얻지 못한다.
- 품목명이 필요 없으므로 품목 조인은 불필요하다.
재고 이동은 출발 창고 재고 차감과 도착 창고 재고 증가가 모두 반영되거나 모두 취소되어야 전체 재고가 맞으므로 하나의 트랜잭션으로 묶는다.
모델에서는 이동 엔터티와 두 재고 변경을 한 업무 단위로 보아 원자성(Atomicity)을 보장하도록 설계한다.
- 연락처 수정은 한 행만 바뀌어 다른 변경과 묶을 대상이 없다.
- 이름 오타 정정도 단일 행 변경이다.
- 집계 조회는 데이터를 바꾸지 않는다.
배송완료일시는 출고를 등록할 때는 아직 값이 없으므로 NULL 허용으로 두어야 한다.
주식별자인 출고번호, 업무상 반드시 있는 출고일자, 필수 참여 관계의 외래키인 거래처번호는 NOT NULL로 둔다.
- 주식별자는 NULL 을 가질 수 없다.
- 출고일자는 출고 등록 시 항상 정해진다.
- 반드시 거래처에 대해 등록되므로 거래처번호는 필수다.
[입고]
| 입고ID | 거래처번호 | 송장번호 | 수량 |
|---|---|---|---|
| 1 | C10 | S-771 | 40 |
| 2 | C20 | S-771 | 15 |
| 3 | C10 | S-771 | 40 |
| 4 | C10 | S-772 | 8 |
입고ID 같은 인조식별자는 매번 새 값이 붙어 업무상 같은 입고(C10·S-771)가 중복돼도 막지 못하므로, 본질 속성 조합인 거래처번호·송장번호에 UNIQUE 제약을 둔다.
송장번호는 거래처마다 따로 매기므로 C20 의 S-771 은 정상이고 입고ID 3 이 중복이다.
- 숫자형 범위를 넓혀도 중복 입력은 그대로 허용된다.
- 입고ID 인덱스는 이미 유일한 값이라 중복 송장을 거르지 못한다.
- NULL 을 허용하면 송장 없는 입고까지 늘어날 뿐이다.
테이블의 열(속성) 개수는 차수(Degree)이고, 행(튜플) 개수가 카디널리티(Cardinality)다.
예를 들어 5개 열에 8개 행이 있는 재고 테이블은 차수 5, 카디널리티 8 이다.
- 행은 튜플, 열은 속성(애트리뷰트)이라고도 부른다.
- 한 열이 가질 수 있는 값의 집합을 도메인이라 한다.
- 기본키는 NULL 없이 각 행을 유일하게 구분한다.
SELECT 창고코드, SUM(수량) AS 합계 FROM 출고 WHERE 출고일 >= DATE '2026-04-01' GROUP BY 창고코드 HAVING SUM(수량) >= 100 ORDER BY 합계 DESC;
논리적 처리 순서는 FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY이므로, WHERE 가 4월 이후 출고 행만 먼저 남긴 뒤 그룹이 만들어진다.
HAVING 은 만들어진 그룹 중 합계 100 이상만 남기고, ORDER BY 는 마지막에 처리되어 SELECT 별칭 합계로 정렬할 수 있다.
- HAVING 은 WHERE 와 GROUP BY 다음에 그룹 단위로 처리된다.
- ORDER BY 는 SELECT 다음, 가장 마지막에 처리된다.
- GROUP BY 가 먼저 묶고 HAVING 이 그 그룹을 거른다.
[재고월]
| 품목코드 | 기초 | 입고 | 출고 |
|---|---|---|---|
| P1 | 10 | 5 | 3 |
| P2 | 8 | (null) | 2 |
| P3 | (null) | 4 | 1 |
| P4 | 6 | 3 | (null) |
SELECT 품목코드,
기초 + 입고 - 출고 AS 기말1,
기초 + COALESCE(입고, 0) - COALESCE(출고, 0) AS 기말2
FROM 재고월
ORDER BY 품목코드;| 품목코드 | 기말1 | 기말2 |
|---|---|---|
| P1 | 12 | 12 |
| P2 | (null) | 6 |
| P3 | (null) | 3 |
| P4 | (null) | 9 |
| 품목코드 | 기말1 | 기말2 |
|---|---|---|
| P1 | 12 | 12 |
| P2 | (null) | 6 |
| P3 | 3 | 3 |
| P4 | (null) | 9 |
| 품목코드 | 기말1 | 기말2 |
|---|---|---|
| P1 | 12 | 12 |
| P2 | (null) | 6 |
| P3 | (null) | (null) |
| P4 | (null) | 9 |
| 품목코드 | 기말1 | 기말2 |
|---|---|---|
| P1 | 12 | 12 |
| P2 | 6 | 6 |
| P3 | (null) | (null) |
| P4 | 9 | 9 |
NULL 이 섞인 산술식은 결과가 NULL이 되므로 기말1 은 NULL 이 없는 P1 만 12 이고 나머지는 모두 NULL 이다.
기말2 는 입고·출고만 COALESCE로 0 으로 바꿔 P2=6, P4=9 가 되지만, 기초가 NULL 인 P3 은 여전히 NULL 이다.
- P3 의 기초는 COALESCE 대상이 아니어서 기말2 가 3 이 될 수 없다.
- 기말1 은 NULL 을 0 으로 바꾸지 않으므로 P2·P4 가 NULL 이다.
- P3 은 기초가 NULL 이라 두 식 모두 NULL 이다.
[출고]
| 출고번호 | 수량 |
|---|---|
| O1 | 120 |
| O2 | 50 |
| O3 | (null) |
| O4 | 30 |
| O5 | 100 |
SELECT 출고번호,
CASE WHEN 수량 >= 50 THEN '중량'
WHEN 수량 >= 100 THEN '대량'
WHEN 수량 < 50 THEN '소량'
END AS 구분
FROM 출고
ORDER BY 출고번호;| 출고번호 | 구분 |
|---|---|
| O1 | 대량 |
| O2 | 중량 |
| O3 | (null) |
| O4 | 소량 |
| O5 | 중량 |
| 출고번호 | 구분 |
|---|---|
| O1 | 대량 |
| O2 | 중량 |
| O3 | (null) |
| O4 | 소량 |
| O5 | 대량 |
| 출고번호 | 구분 |
|---|---|
| O1 | 중량 |
| O2 | 중량 |
| O3 | (null) |
| O4 | 소량 |
| O5 | 중량 |
| 출고번호 | 구분 |
|---|---|
| O1 | 중량 |
| O2 | 중량 |
| O3 | 소량 |
| O4 | 소량 |
| O5 | 중량 |
검색형 CASE 는 WHEN 을 위에서부터 평가해 처음 참이 되는 조건 하나만 적용하므로 120·100 도 첫 조건 수량 >= 50 에 걸려 중량이 된다.
수량이 NULL 인 O3 은 모든 비교가 UNKNOWN 이고 ELSE 가 없어 NULL 을 반환한다.
- 뒤쪽 WHEN 수량 >= 100 은 앞 조건에 가려 한 번도 적용되지 않는다.
- NULL 은 수량 < 50 도 참이 아니어서 소량이 되지 않는다.
- 100 을 넘는 120 도 첫 조건에서 끝나 대량이 될 수 없다.
[로케이션]
| 품목코드 | 위치코드 |
|---|---|
| P1 | A-03-12 |
| P2 | B1-07-02 |
| P3 | C-11-30 |
| P4 | DD-02-05 |
SELECT 품목코드, ( ㉠ ) AS 랙 FROM 로케이션 ORDER BY 품목코드;
[원하는 결과]
| 품목코드 | 랙 |
|---|---|
| P1 | 03 |
| P2 | 07 |
| P3 | 11 |
| P4 | 02 |
INSTR(위치코드, '-') 는 첫 번째 하이픈의 위치를 돌려주므로 여기에 1 을 더한 곳부터 2글자를 자르면 앞부분 길이가 달라도 03·07·11·02 가 나온다.
고정 위치로 자르는 식은 앞 토막이 B1·DD 처럼 두 글자인 코드에서 어긋난다.
- 시작 위치를 3 으로 고정하면 B1-07-02 에서 '-0' 이 나온다.
- 2~4번째 글자에서 하이픈만 지우면 B1 은 '1-0', DD 는 'D-0' 이 된다.
- 뒤에서 5글자를 자르면 '03-12' 처럼 뒤 토막까지 함께 나온다.
[운송]
| 운송번호 | 도착일 | 실중량 | 예상중량 |
|---|---|---|---|
| T1 | 2026-05-02 | 120 | 120 |
| T2 | (null) | (null) | 80 |
| T3 | 2026-05-03 | 95 | 100 |
| T4 | (null) | (null) | (null) |
SELECT 운송번호,
NVL2(도착일, '완료', '운송중') AS 상태,
COALESCE(실중량, 예상중량, 0) AS 중량,
NULLIF(실중량, 예상중량) AS 비교
FROM 운송
ORDER BY 운송번호;| 운송번호 | 상태 | 중량 | 비교 |
|---|---|---|---|
| T1 | 완료 | 120 | (null) |
| T2 | 운송중 | 80 | (null) |
| T3 | 완료 | 95 | 100 |
| T4 | 운송중 | 0 | (null) |
| 운송번호 | 상태 | 중량 | 비교 |
|---|---|---|---|
| T1 | 완료 | 120 | (null) |
| T2 | 운송중 | 0 | (null) |
| T3 | 완료 | 95 | 95 |
| T4 | 운송중 | 0 | (null) |
| 운송번호 | 상태 | 중량 | 비교 |
|---|---|---|---|
| T1 | 완료 | 120 | (null) |
| T2 | 운송중 | 80 | (null) |
| T3 | 완료 | 95 | 95 |
| T4 | 운송중 | 0 | (null) |
| 운송번호 | 상태 | 중량 | 비교 |
|---|---|---|---|
| T1 | 완료 | 120 | 120 |
| T2 | 운송중 | 80 | (null) |
| T3 | 완료 | 95 | (null) |
| T4 | 운송중 | 0 | (null) |
NULLIF(a, b) 는 두 값이 같으면 NULL, 다르면 첫 번째 인수를 돌려주므로 T1 은 NULL, T3 은 95 다.
NVL2 는 도착일이 있으면 완료, 없으면 운송중을 고르고, COALESCE 는 왼쪽부터 처음 NULL 이 아닌 값을 골라 T2=80, T4=0 이 된다.
- 값이 다를 때 두 번째 인수 100 을 돌려주지는 않는다.
- 같은 값이면 그 값이 아니라 NULL 이 되고, 다른 값이면 NULL 이 아니다.
- T2 는 예상중량 80 이 먼저 NULL 이 아니므로 0 까지 가지 않는다.
(창고코드, 품목코드) IN (('W1','P1'), ('W2','P2')) 같은 다중 열 IN 은 두 열 값의 쌍이 목록의 한 쌍과 모두 같은 행만 찾는다.
문자열은 앞 글자부터 사전순으로 비교하므로 'W30' 은 'W3' 뒤에, 'W10' 은 'W9' 앞에 놓인다.
- 'W30' 은 상한 'W3' 보다 커서 BETWEEN 범위 밖이다.
- 'W10' 은 두 번째 글자 1 이 9 보다 작아 조건을 만족하지 않는다.
- WHERE 는 SELECT 보다 먼저 처리되어 SELECT 별칭을 쓸 수 없다.
[입고]
| 입고번호 | 검수상태 | 수량 |
|---|---|---|
| I01 | 정상 | 30 |
| I02 | 불량 | 60 |
| I03 | (null) | 20 |
| I04 | 정상 | 70 |
| I05 | 보류 | 10 |
| I06 | (null) | 80 |
| I07 | 불량 | 40 |
SELECT 입고번호
FROM 입고
WHERE 검수상태 <> '불량'
OR 검수상태 = NULL
OR 수량 > 50
ORDER BY 입고번호;| 입고번호 |
|---|
| I01 |
| I04 |
| I05 |
| 입고번호 |
|---|
| I01 |
| I02 |
| I04 |
| I05 |
| 입고번호 |
|---|
| I01 |
| I02 |
| I03 |
| I04 |
| I05 |
| I06 |
| 입고번호 |
|---|
| I01 |
| I02 |
| I04 |
| I05 |
| I06 |
= NULL 은 어떤 행에서도 UNKNOWN이고 검수상태가 NULL 인 행은 <> 비교도 UNKNOWN 이어서, NULL 행은 수량 > 50 이 참일 때만 남는다.
그래서 I06(수량 80)은 남고 I03(수량 20)은 빠지며, 불량인 I02 는 수량 60 덕분에 남고 I07 은 빠져 5행이 된다.
- = NULL 이 참이 되지 않으므로 I03 은 결과에 없다.
- OR 에서는 UNKNOWN 이 있어도 다른 조건이 참이면 행이 남아 I06 이 포함된다.
- 세 번째 조건 수량 > 50 을 빼고 보면 I02·I06 이 빠진 결과가 된다.
[입고내역]
| 입고번호 | 창고코드 | 입고일 | 수량 |
|---|---|---|---|
| 1 | W1 | 2026-03-02 | 10 |
| 2 | W1 | 2026-03-15 | 20 |
| 3 | W1 | 2026-04-01 | 5 |
| 4 | W2 | 2026-03-09 | 7 |
| 5 | W2 | 2026-04-11 | 8 |
| 6 | W2 | 2026-04-20 | (null) |
| 7 | (null) | 2026-03-03 | 4 |
| 8 | (null) | 2026-03-28 | 6 |
| 9 | W3 | 2026-04-05 | 12 |
SELECT 창고코드, EXTRACT(MONTH FROM 입고일) AS 월, COUNT(수량) AS 건수 FROM 입고내역 GROUP BY 창고코드, EXTRACT(MONTH FROM 입고일) HAVING COUNT(수량) >= 2 ORDER BY 1, 2;
창고코드·월 조합으로 (W1,3)·(W1,4)·(W2,3)·(W2,4)·(NULL,3)·(W3,4) 의 6개 그룹이 생기고, COUNT(수량) 이 2 이상인 그룹은 (W1,3) 과 (NULL,3) 뿐이라 2행이다.
(W2,4) 는 행이 둘이지만 수량 NULL 은 세지 않아 건수 1 이고, 창고코드가 NULL 인 두 행은 하나의 그룹으로 묶인다.
- NULL 그룹을 버리면 1행으로 잘못 센다.
- COUNT(*) 처럼 NULL 행까지 세면 (W2,4) 가 들어가 3행이 된다.
- 건수 1 인 그룹은 HAVING 에서 모두 빠지므로 4행이 될 수 없다.
[재고]
| 품목코드 | 수량 |
|---|---|
| P1 | 10 |
| P2 | 20 |
| P3 | (null) |
| P4 | 30 |
| P5 | 0 |
| P6 | (null) |
㉠ SELECT AVG(수량) FROM 재고;
㉡ SELECT SUM(수량) / COUNT(수량) FROM 재고;
㉢ SELECT SUM(수량) / COUNT(*) FROM 재고;
㉣ SELECT SUM(수량) / COUNT(*)
FROM 재고
WHERE 수량 IS NOT NULL;COUNT(*) 는 NULL 행까지 세므로 ㉢은 60 을 6 으로 나눠 10이 되고, 나머지는 모두 NULL 을 뺀 4행 기준이라 15다.
AVG 는 NULL 을 분자·분모에서 모두 제외하지만 0 은 값이라 분모에 포함한다.
- ㉠은 NULL 을 뺀 10·20·30·0 의 평균으로 15 다.
- ㉡은 COUNT(수량) 이 NULL 을 세지 않아 60/4=15 다.
- ㉣은 WHERE 로 NULL 행을 먼저 지워 COUNT(*) 가 4 가 되어 15 다.
[출고]
| 출고번호 | 창고코드 | 출고일 | 수량 |
|---|---|---|---|
| 1 | W1 | 2026-03-01 | 40 |
| 2 | W2 | 2026-03-02 | (null) |
| 3 | W1 | 2026-03-05 | 15 |
| 4 | (null) | 2026-03-06 | 25 |
| 5 | W3 | 2026-02-27 | 90 |
| 6 | W2 | 2026-03-09 | 15 |
SELECT COUNT(*) AS 건수,
COUNT(DISTINCT 창고코드) AS 창고수,
MAX(수량) - MIN(수량) AS 범위,
SUM(수량) AS 합계
FROM 출고
WHERE 출고일 >= DATE '2026-03-01';| 건수 | 창고수 | 범위 | 합계 |
|---|---|---|---|
| 4 | 2 | 25 | 95 |
| 건수 | 창고수 | 범위 | 합계 |
|---|---|---|---|
| 5 | 2 | 25 | 95 |
| 건수 | 창고수 | 범위 | 합계 |
|---|---|---|---|
| 5 | 3 | 25 | 95 |
| 건수 | 창고수 | 범위 | 합계 |
|---|---|---|---|
| 5 | 2 | 75 | 185 |
GROUP BY 가 없으면 WHERE 를 통과한 3월 출고 5행이 하나의 그룹이 되어 결과는 1행이고, 건수 5·범위 40-15=25·합계 95 다.
COUNT(DISTINCT 창고코드) 는 NULL 을 빼고 W1·W2 만 세어 2 이며, 집계 없는 열은 이런 쿼리의 SELECT 절에 함께 쓸 수 없다.
- NULL 은 DISTINCT 집계에서도 값으로 세지 않아 창고수가 3 이 아니다.
- COUNT(*) 는 수량이 NULL 인 출고 2 도 세므로 4 가 아니다.
- 2월 출고 90 은 WHERE 에서 빠져 범위·합계에 들어가지 않는다.
[재고위치]
| 품목코드 | 보관구역 | 수량 |
|---|---|---|
| P1 | B | 30 |
| P2 | (null) | 10 |
| P3 | A | (null) |
| P4 | A | 50 |
| P5 | (null) | 40 |
| P6 | B | 20 |
SELECT 품목코드, 보관구역, 수량 FROM 재고위치 ORDER BY 보관구역 NULLS FIRST, 수량 DESC NULLS LAST;
| 품목코드 | 보관구역 | 수량 |
|---|---|---|
| P4 | A | 50 |
| P3 | A | (null) |
| P1 | B | 30 |
| P6 | B | 20 |
| P5 | (null) | 40 |
| P2 | (null) | 10 |
| 품목코드 | 보관구역 | 수량 |
|---|---|---|
| P2 | (null) | 10 |
| P5 | (null) | 40 |
| P4 | A | 50 |
| P3 | A | (null) |
| P6 | B | 20 |
| P1 | B | 30 |
| 품목코드 | 보관구역 | 수량 |
|---|---|---|
| P5 | (null) | 40 |
| P2 | (null) | 10 |
| P3 | A | (null) |
| P4 | A | 50 |
| P1 | B | 30 |
| P6 | B | 20 |
| 품목코드 | 보관구역 | 수량 |
|---|---|---|
| P5 | (null) | 40 |
| P2 | (null) | 10 |
| P4 | A | 50 |
| P3 | A | (null) |
| P1 | B | 30 |
| P6 | B | 20 |
첫 정렬 키 보관구역은 NULLS FIRST 라 NULL 구역(P5·P2)이 맨 앞에 오고, 같은 구역 안에서는 수량 내림차순이다.
Oracle 은 DESC 에서 NULL 을 먼저 두지만 NULLS LAST 를 지정해 A 구역의 P3(수량 NULL)이 P4 뒤로 간다.
- ASC 기본값(NULL 마지막)을 적용하면 NULL 구역이 끝에 오지만 NULLS FIRST 가 우선한다.
- DESC 기본 NULL 위치를 따르면 P3 이 P4 앞에 오지만 NULLS LAST 로 바뀌었다.
- 수량을 오름차순으로 보면 P2·P6 이 먼저 오지만 DESC 가 지정되었다.
[창고]
| 창고코드 | 창고명 | 상위창고코드 |
|---|---|---|
| W1 | 중앙센터 | (null) |
| W2 | 동부센터 | W1 |
| W3 | 서부센터 | W1 |
| W4 | 동부분소 | W2 |
SELECT A.창고명, B.창고명 AS 상위창고명
FROM 창고 A
LEFT OUTER JOIN 창고 B
ON A.상위창고코드 = B.창고코드;같은 창고 테이블을 별칭 A·B 로 두 번 쓰는 셀프 조인이며, LEFT OUTER JOIN 이라 상위창고코드가 NULL 인 중앙센터도 상위창고명 NULL 로 남는다.
창고코드가 기본키라 A 의 각 행은 B 의 한 행과만 이어지므로 결과는 4행이다.
- 같은 테이블을 두 번 쓰려면 별칭으로 구분해야 한다.
- B 쪽은 기본키로 한 행만 매칭되어 행 수가 늘지 않는다.
- B 는 같은 창고 테이블을 다른 이름으로 부른 것이다.
[운임등급]
| 등급 | 최소 | 최대 |
|---|---|---|
| S | 0 | 10 |
| M | 10 | 30 |
| L | 30 | 50 |
[거래처]
| 거래처번호 | 지역 |
|---|---|
| 1 | 서울 |
| 2 | 부산 |
| 3 | 대전 |
[출고]
| 출고번호 | 거래처번호 | 중량 |
|---|---|---|
| 1 | 1 | 5 |
| 2 | 1 | 10 |
| 3 | 2 | 30 |
| 4 | 4 | 20 |
| 5 | (null) | 8 |
| 6 | 3 | 50 |
| 7 | 2 | (null) |
| 8 | 3 | 29 |
SELECT O.출고번호, C.지역, G.등급 FROM 출고 O JOIN 거래처 C ON O.거래처번호 = C.거래처번호 JOIN 운임등급 G ON O.중량 >= G.최소 AND O.중량 < G.최대 ORDER BY O.출고번호;
거래처와의 EQUI 조인에서 없는 거래처 4 를 가진 출고와 거래처가 NULL 인 출고가 빠지고, 운임등급과의 NON-EQUI 조인에서 중량 NULL 인 출고와 어느 구간에도 속하지 않는 중량 50 출고가 빠져 4행이 남는다.
구간이 최소 이상·최대 미만이라 경계값 10·30 은 각각 M·L 한 등급에만 매칭된다.
- 경계값을 두 등급에 모두 붙이면 행 수가 늘어나지만 미만 조건이라 중복이 없다.
- 50 은 L 의 최대와 같아 미만 조건을 만족하지 못한다.
- 거래처가 없거나 NULL 인 출고는 INNER 조인에서 남지 않는다.
[창고]
| 창고코드 | 창고명 |
|---|---|
| W1 | 본관 |
| W2 | 별관 |
| W3 | 냉장 |
| W4 | 야적 |
[재고]
| 창고코드 | 품목코드 | 수량 |
|---|---|---|
| W1 | P1 | 10 |
| W1 | P2 | 5 |
| W3 | P1 | 7 |
| W3 | (null) | 3 |
| W5 | P3 | 4 |
SELECT W.창고코드, COUNT(S.품목코드) AS 품목수, SUM(S.수량) AS 수량합 FROM 창고 W, 재고 S WHERE W.창고코드 = S.창고코드(+) GROUP BY W.창고코드 ORDER BY W.창고코드;
| 창고코드 | 품목수 | 수량합 |
|---|---|---|
| W1 | 2 | 15 |
| W2 | 1 | (null) |
| W3 | 1 | 10 |
| W4 | 1 | (null) |
| 창고코드 | 품목수 | 수량합 |
|---|---|---|
| W1 | 2 | 15 |
| W2 | 0 | (null) |
| W3 | 1 | 10 |
| W4 | 0 | (null) |
| 창고코드 | 품목수 | 수량합 |
|---|---|---|
| W1 | 2 | 15 |
| W3 | 1 | 10 |
| 창고코드 | 품목수 | 수량합 |
|---|---|---|
| W1 | 2 | 15 |
| W2 | 0 | (null) |
| W3 | 2 | 10 |
| W4 | 0 | (null) |
(+) 는 짝이 없을 때 NULL 행을 채울 쪽에 붙으므로 이 쿼리는 창고를 기준으로 한 LEFT OUTER JOIN이며, 재고가 없는 W2·W4 도 NULL 과 결합해 남는다.
COUNT(S.품목코드) 는 NULL 을 세지 않아 W2·W4 는 0, 품목코드가 NULL 인 행이 있는 W3 은 1 이고, 창고에 없는 W5 재고는 결과에 나오지 않는다.
- INNER 조인처럼 W2·W4 를 버리면 2행이지만 기준 테이블 행은 보존된다.
- COUNT(*) 라면 W2·W4 가 1 이지만 열을 세므로 0 이다.
- W3 의 품목코드 NULL 행은 COUNT(열) 에서 빠져 2 가 아니다.
[창고]
| 창고코드 | 창고명 |
|---|---|
| W1 | 인천1 |
| W2 | 평택 |
| W3 | 이천 |
[재고]
| 품번 | 창고코드 | 수량 |
|---|---|---|
| P1 | W1 | 40 |
| P2 | W1 | 15 |
| P3 | W2 | 30 |
| P4 | W9 | 12 |
SELECT W.창고명, S.품번, S.수량
FROM 창고 W
( ㉠ ) 재고 S
ON W.창고코드 = S.창고코드
ORDER BY S.품번, W.창고명;| 창고명 | 품번 | 수량 |
|---|---|---|
| 인천1 | P1 | 40 |
| 인천1 | P2 | 15 |
| 평택 | P3 | 30 |
| (null) | P4 | 12 |
| 이천 | (null) | (null) |
결과에 창고 마스터에 없는 W9 재고(P4)와 재고가 없는 이천 창고가 함께 남아 있으므로 양쪽을 모두 보존하는 FULL OUTER JOIN 이어야 한다.
P4 는 짝이 없어 창고명이 NULL, 이천은 짝이 없어 품번·수량이 NULL 인 행이 되며, 정렬에서 품번 NULL 인 이천 행은 맨 뒤에 온다.
- INNER JOIN 은 짝이 있는 3행만 남겨 P4 와 이천이 모두 빠진다.
- LEFT OUTER JOIN 은 창고 기준이라 이천은 남지만 P4 행이 빠진다.
- RIGHT OUTER JOIN 은 재고 기준이라 P4 는 남지만 이천 행이 빠진다.
[창고]
| 창고코드 | 지역 |
|---|---|
| W1 | 서울 |
| W2 | 부산 |
| W3 | 서울 |
| W4 | 대구 |
[출고]
| 출고번호 | 창고코드 | 수량 |
|---|---|---|
| 1 | W1 | 30 |
| 2 | W1 | 20 |
| 3 | W2 | 50 |
| 4 | W3 | 10 |
SELECT W.창고코드, O.출고번호, O.수량
FROM 창고 W
LEFT OUTER JOIN 출고 O
ON W.창고코드 = O.창고코드
AND W.지역 = '서울'
ORDER BY W.창고코드, O.출고번호;| 창고코드 | 출고번호 | 수량 |
|---|---|---|
| W1 | 1 | 30 |
| W1 | 2 | 20 |
| W3 | 4 | 10 |
| W4 | (null) | (null) |
| 창고코드 | 출고번호 | 수량 |
|---|---|---|
| W1 | 1 | 30 |
| W1 | 2 | 20 |
| W2 | (null) | (null) |
| W3 | 4 | 10 |
| W4 | (null) | (null) |
| 창고코드 | 출고번호 | 수량 |
|---|---|---|
| W1 | 1 | 30 |
| W1 | 2 | 20 |
| W3 | 4 | 10 |
| 창고코드 | 출고번호 | 수량 |
|---|---|---|
| W1 | 1 | 30 |
| W1 | 2 | 20 |
| W2 | 3 | 50 |
| W3 | 4 | 10 |
| W4 | (null) | (null) |
ON 절의 W.지역 = '서울' 은 왼쪽 테이블을 걸러내는 조건이 아니라 조인 성립 조건이므로, 서울이 아닌 W2·W4 도 출고 쪽이 NULL 인 채 모두 남는다.
W1 은 두 건, W3 은 한 건과 결합하고 W2 는 출고번호 3 이 있어도 지역 조건이 거짓이라 짝 없는 행으로 처리되어 5행이 된다.
- 3행 결과는 지역 조건을 WHERE 절에 둔 경우이다.
- W2 에 출고번호 3 이 붙은 결과는 지역 조건을 무시한 경우이다.
- W2 가 빠진 결과는 짝 있는 출고가 있으면 NULL 행도 안 만든다고 본 경우이다.
[품목]
| 품번 | 품명 |
|---|---|
| P1 | 팔레트 |
| P2 | 랩필름 |
| P3 | 박스 |
| P4 | 테이프 |
[출고]
| 출고번호 | 품번 | 수량 |
|---|---|---|
| 1 | P1 | 10 |
| 2 | P3 | 5 |
| 3 | (null) | 7 |
| 4 | P1 | 3 |
SELECT P.품번, P.품명 FROM 품목 P WHERE ( ㉠ ) ORDER BY P.품번;
| 품번 | 품명 |
|---|---|
| P2 | 랩필름 |
| P4 | 테이프 |
상관 조건으로 같은 품번의 출고 행 존재 여부만 보는 NOT EXISTS 는 NULL 품번 행과 무관하게 출고 이력이 없는 P2·P4 를 돌려준다.
NOT IN 은 목록의 모든 값과 같지 않음을 AND 로 묶는데, NULL 과의 비교가 UNKNOWN 이라 어떤 품번도 참이 되지 못한다.
- NOT IN 은 서브쿼리에 NULL 이 있어 결과가 0행이다.
<> ALL은 NOT IN 과 같은 뜻이라 역시 0행이다.- 상관 조건 없는 COUNT 는 품목마다 전체 출고 품번 수 3을 돌려줘 0행이다.
< ALL 은 서브쿼리가 돌려준 모든 값에 대해 작아야 하므로 결과의 최솟값에 비해 작을 때만 참이다.
ALL 은 모든 값과의 비교가 참이어야 하고, ANY 는 하나라도 참이면 되므로 기준이 되는 값이 서로 반대로 정해진다.
- IN 은 결과 목록 중 하나와 같으면 참으로, = ANY 와 같은 뜻이다.
> ANY는 어느 한 값에 비해 크면 되므로 최솟값을 넘으면 참이다.- EXISTS 는 반환 값이 아니라 결과 행의 존재 여부만 판단한다.
[입고]
| 입고번호 | 창고 | 수량 |
|---|---|---|
| 1 | A | 50 |
| 2 | A | 80 |
| 3 | A | 50 |
| 4 | A | 30 |
| 5 | B | 40 |
| 6 | B | 40 |
| 7 | B | 20 |
| 8 | C | 60 |
SELECT A.창고, A.입고번호, A.수량
FROM 입고 A
WHERE 2 > (SELECT COUNT(*)
FROM 입고 B
WHERE B.창고 = A.창고
AND B.수량 > A.수량)
ORDER BY A.창고, A.수량 DESC, A.입고번호;| 창고 | 입고번호 | 수량 |
|---|---|---|
| A | 2 | 80 |
| A | 1 | 50 |
| B | 5 | 40 |
| B | 6 | 40 |
| C | 8 | 60 |
| 창고 | 입고번호 | 수량 |
|---|---|---|
| A | 2 | 80 |
| A | 1 | 50 |
| A | 3 | 50 |
| B | 5 | 40 |
| B | 6 | 40 |
| B | 7 | 20 |
| C | 8 | 60 |
| 창고 | 입고번호 | 수량 |
|---|---|---|
| A | 2 | 80 |
| A | 1 | 50 |
| A | 3 | 50 |
| B | 5 | 40 |
| B | 6 | 40 |
| C | 8 | 60 |
| 창고 | 입고번호 | 수량 |
|---|---|---|
| A | 2 | 80 |
| B | 5 | 40 |
| B | 6 | 40 |
| C | 8 | 60 |
바깥 행마다 같은 창고에서 자신의 수량을 넘는 행 수를 세는 연관 서브쿼리이므로, 그 수가 0 또는 1 인 행만 남아 동점을 포함한 상위 2위가 된다.
A 는 80(0건)·50 두 건(각 1건)이 남고 30(3건)은 빠지며, B 는 40 두 건(0건)이 남고 20(2건)은 빠져 C 의 60 까지 6행이다.
- 5행 결과는 창고별로 정확히 2행만 고른다고 본 경우로, A 의 동점 행이 빠졌다.
- B 의 20 이 포함된 결과는 비교를 2 이상으로 본 경우이다.
- 4행 결과는 1 > COUNT 처럼 1위만 남긴 경우이다.
차집합 연산자는 Oracle 에서 MINUS, SQL Server 와 표준 SQL 에서 EXCEPT 라는 이름을 쓰며 의미는 같다.
차집합은 앞 결과에서 뒤 결과에 있는 행을 빼므로 두 집합의 순서를 바꾸면 남는 행이 달라진다.
- A MINUS B 는 A 에만 있는 행, B MINUS A 는 B 에만 있는 행이라 일반적으로 다르다.
- 양쪽 공통 행만 남기는 연산은 INTERSECT 이다.
- INTERSECT 도 UNION 처럼 결과의 중복 행을 하나로 줄인다.
[입고]
| 입고번호 | 창고 | 품번 | 수량 |
|---|---|---|---|
| 1 | A | P1 | 30 |
| 2 | A | P1 | 30 |
| 3 | A | P2 | 50 |
| 4 | B | P3 | 40 |
| 5 | A | P3 | 20 |
[출고]
| 출고번호 | 창고 | 품번 | 수량 |
|---|---|---|---|
| 1 | A | P1 | 30 |
| 2 | A | P2 | 10 |
| 3 | B | P2 | 50 |
| 4 | A | P2 | 10 |
SELECT 품번, 수량, '입고' AS 구분 FROM 입고 WHERE 창고 = 'A' UNION SELECT 품번, 수량, '출고' FROM 출고 WHERE 창고 = 'A' ORDER BY 수량 DESC, 구분;
| 품번 | 수량 | 구분 |
|---|---|---|
| P2 | 50 | 입고 |
| P1 | 30 | 입고 |
| P1 | 30 | 출고 |
| P3 | 20 | 입고 |
| P2 | 10 | 출고 |
| 품번 | 수량 | 구분 |
|---|---|---|
| P2 | 50 | 입고 |
| P1 | 30 | 입고 |
| P1 | 30 | 입고 |
| P1 | 30 | 출고 |
| P3 | 20 | 입고 |
| P2 | 10 | 출고 |
| P2 | 10 | 출고 |
| 품번 | 수량 | 구분 |
|---|---|---|
| P2 | 50 | 입고 |
| P1 | 30 | 입고 |
| P3 | 20 | 입고 |
| P2 | 10 | 출고 |
| 품번 | 수량 | 구분 |
|---|---|---|
| P2 | 50 | 입고 |
| P1 | 30 | 출고 |
| P1 | 30 | 입고 |
| P3 | 20 | 입고 |
| P2 | 10 | 출고 |
UNION 은 상수 열 구분까지 포함한 행 전체가 같을 때만 중복으로 보아, 입고 안의 P1·30 두 건과 출고 안의 P2·10 두 건은 각각 한 행이 되고 입고·출고의 P1·30 은 둘 다 남는다.
맨 끝의 ORDER BY 는 결합된 전체에 적용되어 수량 내림차순, 같은 수량이면 구분 오름차순으로 정렬된다.
- 7행 결과는 중복을 남기는 UNION ALL 로 본 경우이다.
- P1·30 출고 행이 빠진 결과는 구분 열을 무시하고 중복을 제거한 경우이다.
- 30 수량에서 출고가 먼저 온 결과는 구분을 내림차순으로 정렬한 경우이다.
[재고]
| 창고 | 분류 | 수량 |
|---|---|---|
| A | 식품 | 10 |
| A | 식품 | 5 |
| A | 생활 | 20 |
| B | 식품 | 7 |
| B | 가전 | 3 |
SELECT 창고, 분류, SUM(수량) AS 합계 FROM 재고 GROUP BY 창고, ROLLUP(분류) ORDER BY 창고, 분류;
| 창고 | 분류 | 합계 |
|---|---|---|
| A | 생활 | 20 |
| A | 식품 | 15 |
| A | (null) | 35 |
| B | 가전 | 3 |
| B | 식품 | 7 |
| B | (null) | 10 |
| 창고 | 분류 | 합계 |
|---|---|---|
| A | 생활 | 20 |
| A | 식품 | 15 |
| B | 가전 | 3 |
| B | 식품 | 7 |
| 창고 | 분류 | 합계 |
|---|---|---|
| A | 생활 | 20 |
| A | 식품 | 15 |
| A | (null) | 35 |
| B | 가전 | 3 |
| B | 식품 | 7 |
| B | (null) | 10 |
| (null) | (null) | 45 |
| 창고 | 분류 | 합계 |
|---|---|---|
| A | 생활 | 20 |
| A | 식품 | 15 |
| B | 가전 | 3 |
| B | 식품 | 7 |
| (null) | 가전 | 3 |
| (null) | 생활 | 20 |
| (null) | 식품 | 22 |
ROLLUP 밖의 창고는 모든 집계 수준에 고정되고 ROLLUP 안의 분류만 생략되므로 (창고, 분류) 집계와 창고별 소계만 만들어진다.
따라서 A 35·B 10 소계 행은 생기지만 창고까지 묶는 전체 총계 행은 생기지 않아 6행이 된다.
- 45 총계가 있는 결과는 ROLLUP(창고, 분류) 의 결과이다.
- 4행 결과는 소계 없이 GROUP BY 창고, 분류 만 한 경우이다.
- 분류별 합계 행이 붙은 결과는 분류 기준 소계를 만드는 다른 그룹 구성이다.
[출고]
| 출고번호 | 지역 | 창고 | 수량 |
|---|---|---|---|
| 1 | 수도권 | W1 | 10 |
| 2 | 수도권 | W1 | 20 |
| 3 | 수도권 | W2 | 5 |
| 4 | 영남 | W3 | 8 |
| 5 | 영남 | W3 | 12 |
| 6 | 영남 | W4 | 4 |
SELECT 지역, 창고, SUM(수량) AS 합계 FROM 출고 GROUP BY ( ㉠ ) ORDER BY 지역, 창고;
| 지역 | 창고 | 합계 |
|---|---|---|
| 수도권 | W1 | 30 |
| 수도권 | W2 | 5 |
| 수도권 | (null) | 35 |
| 영남 | W3 | 20 |
| 영남 | W4 | 4 |
| 영남 | (null) | 24 |
| (null) | (null) | 59 |
결과에는 (지역, 창고) 상세, 창고가 NULL 인 지역별 소계, 둘 다 NULL 인 총계만 있으므로 첫 인수부터 계층으로 묶는 ROLLUP(지역, 창고) 이다.
상세 4행·소계 2행·총계 1행으로 7행이며, 정렬에서 NULL 이 뒤로 가 소계가 각 지역 끝, 총계가 맨 끝에 온다.
- ROLLUP(창고, 지역) 은 지역 없이 창고만 남은 창고별 소계를 만든다.
- CUBE 는 지역 소계에 더해 창고별 소계까지 만들어 행이 더 많다.
- GROUPING SETS(지역, 창고) 는 상세 행과 총계 없이 각 열의 소계만 만든다.
[출고]
| 출고번호 | 창고 | 분류 | 수량 |
|---|---|---|---|
| 1 | A | 식품 | 10 |
| 2 | A | 식품 | 4 |
| 3 | A | 생활 | 6 |
| 4 | B | 식품 | 3 |
| 5 | B | 가전 | 9 |
| 6 | B | (null) | 2 |
SELECT 창고, 분류, SUM(수량) AS 합계 FROM 출고 GROUP BY CUBE(창고, 분류);
CUBE(창고, 분류) 는 (창고, 분류)·창고·분류·전체의 4가지 그룹 수준을 만들고, 각 수준의 행 수는 데이터에 실제로 있는 값 조합 수로 정해진다.
상세 조합 5개(B 의 NULL 분류 포함), 창고 소계 2개, 분류 소계 4개(NULL 그룹 포함), 총계 1개를 더해 12행이다.
- 4는 그룹 수준의 개수(2의 2제곱)를 행 수로 착각한 값이다.
- 8은 분류 소계가 없는 ROLLUP(창고, 분류) 의 행 수이다.
- 10은 원래 분류가 NULL 인 행을 그룹에서 빼고 센 값이다.
[일별출고]
| 창고 | 출고일자 | 수량 |
|---|---|---|
| A | 2026-03-01 | 10 |
| A | 2026-03-02 | 20 |
| A | 2026-03-03 | 30 |
| A | 2026-03-04 | 40 |
| B | 2026-03-01 | 5 |
| B | 2026-03-02 | 15 |
SELECT 창고, 출고일자,
SUM(수량) OVER (PARTITION BY 창고
ORDER BY 출고일자
ROWS BETWEEN 1 PRECEDING
AND 1 FOLLOWING) AS 이동합
FROM 일별출고
ORDER BY 창고, 출고일자;| 창고 | 출고일자 | 이동합 |
|---|---|---|
| A | 2026-03-01 | 30 |
| A | 2026-03-02 | 60 |
| A | 2026-03-03 | 90 |
| A | 2026-03-04 | 70 |
| B | 2026-03-01 | 20 |
| B | 2026-03-02 | 20 |
| 창고 | 출고일자 | 이동합 |
|---|---|---|
| A | 2026-03-01 | (null) |
| A | 2026-03-02 | 60 |
| A | 2026-03-03 | 90 |
| A | 2026-03-04 | (null) |
| B | 2026-03-01 | (null) |
| B | 2026-03-02 | (null) |
| 창고 | 출고일자 | 이동합 |
|---|---|---|
| A | 2026-03-01 | 10 |
| A | 2026-03-02 | 30 |
| A | 2026-03-03 | 50 |
| A | 2026-03-04 | 70 |
| B | 2026-03-01 | 5 |
| B | 2026-03-02 | 20 |
| 창고 | 출고일자 | 이동합 |
|---|---|---|
| A | 2026-03-01 | 10 |
| A | 2026-03-02 | 30 |
| A | 2026-03-03 | 60 |
| A | 2026-03-04 | 100 |
| B | 2026-03-01 | 5 |
| B | 2026-03-02 | 20 |
ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING 은 파티션 안에서 앞 행·현재 행·뒤 행을 합하며, 앞이나 뒤 행이 없으면 있는 행만 합산한다.
A 의 첫날은 10+20=30, 둘째 날 60, 셋째 날 90, 마지막 날 30+40=70 이고 B 는 두 행뿐이라 두 날 모두 5+15=20 이다.
- 10·30·50·70 결과는 1 PRECEDING AND CURRENT ROW 프레임이다.
- 경계 행이 NULL 인 결과는 프레임이 모자라면 NULL 이 된다고 본 경우이다.
- 10·30·60·100 결과는 처음부터 현재 행까지의 누적합이다.
[출고]
| 출고번호 | 창고 | 수량 |
|---|---|---|
| 1 | A | 20 |
| 2 | A | 30 |
| 3 | A | 50 |
| 4 | B | 40 |
| 5 | B | (null) |
| 6 | B | 60 |
SELECT 창고, 출고번호, 수량,
RATIO_TO_REPORT(수량)
OVER (PARTITION BY 창고) AS 비율
FROM 출고
ORDER BY 창고, 출고번호;| 창고 | 출고번호 | 수량 | 비율 |
|---|---|---|---|
| A | 1 | 20 | 0.2 |
| A | 2 | 30 | 0.3 |
| A | 3 | 50 | 0.5 |
| B | 4 | 40 | 0.4 |
| B | 5 | (null) | (null) |
| B | 6 | 60 | 0.6 |
| 창고 | 출고번호 | 수량 | 비율 |
|---|---|---|---|
| A | 1 | 20 | 0.2 |
| A | 2 | 30 | 0.3 |
| A | 3 | 50 | 0.5 |
| B | 4 | 40 | 0.4 |
| B | 5 | (null) | 0 |
| B | 6 | 60 | 0.6 |
| 창고 | 출고번호 | 수량 | 비율 |
|---|---|---|---|
| A | 1 | 20 | 20 |
| A | 2 | 30 | 30 |
| A | 3 | 50 | 50 |
| B | 4 | 40 | 40 |
| B | 5 | (null) | (null) |
| B | 6 | 60 | 60 |
| 창고 | 출고번호 | 수량 | 비율 |
|---|---|---|---|
| A | 1 | 20 | 0.1 |
| A | 2 | 30 | 0.15 |
| A | 3 | 50 | 0.25 |
| B | 4 | 40 | 0.2 |
| B | 5 | (null) | (null) |
| B | 6 | 60 | 0.3 |
RATIO_TO_REPORT 는 값을 파티션 합계로 나눈 비율을 0~1 사이 소수로 돌려주며, 합계는 NULL 을 뺀 값이라 A·B 모두 100 이다.
그래서 A 는 0.2·0.3·0.5, B 는 0.4·0.6 이고 수량이 NULL 인 출고번호 5 는 나눌 값이 없어 NULL 로 남는다.
- 0.1 로 시작하는 결과는 PARTITION BY 없이 전체 합계 200 으로 나눈 경우이다.
- 출고번호 5 의 비율이 0 인 결과는 NULL 을 0 으로 바꿔 계산한다고 본 경우이다.
- 20·30·50 결과는 비율을 백분율로 돌려준다고 본 경우이다.
㉠ SELECT 창고, SUM(수량) OVER (PARTITION BY 창고)
FROM 출고;
㉡ SELECT 창고, SUM(수량)
FROM 출고
GROUP BY 창고;윈도우 함수는 집계 값을 각 행 옆에 붙일 뿐 행을 합치지 않으므로 ㉠은 원래 행 수 12행 그대로이다.
반면 GROUP BY 는 같은 창고 행을 하나로 묶어 그룹당 1행을 만들므로 ㉡은 창고 수와 같은 3행이다.
- 둘 다 3행이라 보면 윈도우 함수도 행을 묶는다고 오해한 것이다.
- 3, 12 는 두 구문의 동작을 서로 바꿔 본 것이다.
- 둘 다 12행이라 보면 GROUP BY 가 행을 줄이지 않는다고 본 것이다.
[배송실적]
| 기사 | 배송건수 |
|---|---|
| K1 | 12 |
| K2 | 9 |
| K3 | 9 |
| K4 | 7 |
| K5 | 7 |
| K6 | 5 |
SELECT 기사, 배송건수,
( ㉠ ) OVER (ORDER BY 배송건수 DESC) AS 순위
FROM 배송실적
ORDER BY 순위, 기사;| 기사 | 배송건수 | 순위 |
|---|---|---|
| K1 | 12 | 1 |
| K2 | 9 | 2 |
| K3 | 9 | 2 |
| K4 | 7 | 3 |
| K5 | 7 | 3 |
| K6 | 5 | 4 |
결과의 순위가 1·2·2·3·3·4 로 동점에 같은 순위를 주면서 다음 순위를 건너뛰지 않으므로 DENSE_RANK 이다.
9건 두 명이 공동 2위가 된 뒤 7건 두 명이 바로 3위를 받는 점이 RANK 와 갈리는 부분이다.
- RANK 는 공동 2위 다음을 4위로 건너뛰어 1·2·2·4·4·6 이 된다.
- ROW_NUMBER 는 동점에도 1부터 6까지 서로 다른 번호를 준다.
- NTILE(4) 는 6행을 네 묶음으로 나눠 1·1·2·2·3·4 가 된다.
[재고]
| 품번 | 수량 |
|---|---|
| P1 | 50 |
| P2 | 40 |
| P3 | 40 |
| P4 | 30 |
| P5 | 20 |
SELECT 품번, 수량 FROM 재고
Oracle 의 행 제한절(OFFSET·FETCH)은 SELECT 문의 맨 끝, 즉 ORDER BY 뒤에만 쓸 수 있어 FETCH 를 먼저 쓰고 ORDER BY 를 뒤에 두면 구문 오류가 난다.
FETCH 뒤에는 정확히 n행만 자르는 ONLY 나 n번째와 같은 값을 함께 내는 WITH TIES 중 하나를 붙인다.
- WITH TIES 는 세 번째 행과 수량이 같은 행까지 포함해 돌려준다.
- ORDER BY 에 열 번호를 쓰고 OFFSET 1 ROWS 로 첫 행을 건너뛰는 것도 정상 구문이다.
- ORDER BY 없이 FETCH 만 써도 실행되며 PERCENT 로 비율만큼 행을 자를 수 있다.
[출고]
| 출고번호 | 창고 | 수량 | 상태 |
|---|---|---|---|
| 1 | A | 50 | 완료 |
| 2 | A | 70 | 취소 |
| 3 | A | 40 | 완료 |
| 4 | A | 30 | 완료 |
| 5 | B | 60 | 완료 |
| 6 | B | 20 | 완료 |
| 7 | B | 45 | 취소 |
SELECT 창고, 출고번호, 수량, RN
FROM (SELECT 창고, 출고번호, 수량, 상태,
ROW_NUMBER() OVER (PARTITION BY 창고
ORDER BY 수량 DESC) AS RN
FROM 출고) T
WHERE RN <= 2
AND 상태 = '완료'
ORDER BY 창고, RN;| 창고 | 출고번호 | 수량 | RN |
|---|---|---|---|
| A | 1 | 50 | 2 |
| B | 5 | 60 | 1 |
| 창고 | 출고번호 | 수량 | RN |
|---|---|---|---|
| A | 1 | 50 | 1 |
| B | 5 | 60 | 1 |
| 창고 | 출고번호 | 수량 | RN |
|---|---|---|---|
| A | 2 | 70 | 1 |
| A | 1 | 50 | 2 |
| B | 5 | 60 | 1 |
| B | 7 | 45 | 2 |
| 창고 | 출고번호 | 수량 | RN |
|---|---|---|---|
| A | 1 | 50 | 1 |
| A | 3 | 40 | 2 |
| B | 5 | 60 | 1 |
| B | 6 | 20 | 2 |
ROW_NUMBER 는 인라인 뷰에서 취소 건까지 포함한 모든 행에 먼저 번호를 매기고, 바깥 WHERE 는 그 번호를 바꾸지 않고 행만 걸러낸다.
A 는 70(취소)이 RN 1, 50 이 RN 2 라 50 만 남고, B 는 60 이 RN 1, 45(취소)가 RN 2 라 60 만 남아 2행이다.
- 창고마다 완료 건 2행씩 나온 결과는 상태 조건을 인라인 뷰 안에 둔 경우이다.
- 취소 건이 포함된 결과는 상태 조건을 빼고 상위 2행을 고른 경우이다.
- A 의 RN 이 1 인 결과는 바깥 필터 뒤에 번호가 다시 매겨진다고 본 경우이다.
셀프 조인은 한 테이블을 직원 역할과 관리자 역할로 두 번 읽기 위해 서로 다른 별칭을 붙이고 E.관리자사번 = M.사번 처럼 연결한다.
관리자가 없는 최상위 직원까지 남기려면 LEFT OUTER JOIN 을 쓰고, 한 단계 더 올라가려면 같은 테이블을 한 번 더 조인한다.
- 셀프 조인은 INNER JOIN 으로도, OUTER JOIN 으로도 쓸 수 있다.
- 세 번째 별칭으로 한 번 더 조인하면 관리자의 관리자도 구할 수 있다.
- 같은 테이블을 두 번 쓰므로 별칭이 없으면 열이 모호해 오류가 난다.
[위치]
| 위치코드 | 상위코드 | 위치명 |
|---|---|---|
| C0 | (null) | 센터 |
| Z1 | C0 | A구역 |
| Z2 | C0 | B구역 |
| R1 | Z1 | 1번랙 |
| R2 | Z1 | 2번랙 |
| R3 | Z2 | 3번랙 |
| B1 | R1 | 1-1칸 |
| B2 | R2 | 2-1칸 |
SELECT 위치코드, LEVEL AS 깊이 FROM 위치 START WITH 위치코드 = 'Z1' CONNECT BY PRIOR 위치코드 = 상위코드 ORDER BY 깊이, 위치코드;
| 위치코드 | 깊이 |
|---|---|
| Z1 | 2 |
| R1 | 3 |
| R2 | 3 |
| B1 | 4 |
| B2 | 4 |
| 위치코드 | 깊이 |
|---|---|
| Z1 | 1 |
| C0 | 2 |
| 위치코드 | 깊이 |
|---|---|
| R1 | 1 |
| R2 | 1 |
| B1 | 2 |
| B2 | 2 |
| 위치코드 | 깊이 |
|---|---|
| Z1 | 1 |
| R1 | 2 |
| R2 | 2 |
| B1 | 3 |
| B2 | 3 |
PRIOR 위치코드 = 상위코드 는 앞 행의 코드를 상위코드로 가진 자식을 찾는 순방향 전개이고, START WITH 로 고른 Z1 이 LEVEL 1 이 된다.
Z1 아래 R1·R2 가 2, 그 아래 B1·B2 가 3 으로 붙고, 다른 구역의 R3 와 상위인 C0 은 결과에 들어오지 않는다.
- Z1 이 2로 시작하는 결과는 LEVEL 을 루트 C0 기준으로 센 경우이다.
- Z1·C0 결과는 위로 올라가는 역방향 전개로 본 경우이다.
- Z1 이 빠진 결과는 시작 행이 결과에 포함되지 않는다고 본 경우이다.
[분기입고]
| 품번 | Q1 | Q2 | Q3 |
|---|---|---|---|
| P1 | 10 | 0 | 20 |
| P2 | 5 | (null) | 15 |
| P3 | (null) | (null) | (null) |
㉠ SELECT 품번, 분기, 수량
FROM 분기입고
UNPIVOT (수량 FOR 분기 IN (Q1, Q2, Q3));
㉡ SELECT 품번, 분기, 수량
FROM 분기입고
UNPIVOT INCLUDE NULLS
(수량 FOR 분기 IN (Q1, Q2, Q3));UNPIVOT 은 기본값이 EXCLUDE NULLS 라 값이 NULL 인 칸은 행으로 만들지 않으므로 ㉠은 9칸 중 NULL 4칸을 뺀 5행이다.
㉡의 INCLUDE NULLS 는 NULL 칸도 행으로 만들어 3행×3열=9행이 되고, 모두 NULL 인 P3 도 세 행으로 나온다.
- 4, 9 는 P1 의 Q2 값 0 까지 NULL 처럼 제외한 경우이다.
- 6, 9 는 값이 모두 NULL 인 P3 를 한 행으로 남긴다고 본 경우이다.
- 9, 9 는 기본 동작이 NULL 을 포함한다고 본 경우이다.
[송장]
| 송장번호 |
|---|
| SE-12-0300 |
| BU7-4410 |
| GJ-905 |
| IC-X |
SELECT 송장번호,
REGEXP_SUBSTR(송장번호, '[0-9]+') AS 숫자1,
REGEXP_REPLACE(송장번호, '-[0-9]+', '') AS 정리
FROM 송장
ORDER BY 송장번호;| 송장번호 | 숫자1 | 정리 |
|---|---|---|
| BU7-4410 | 4410 | BU7 |
| GJ-905 | 905 | GJ |
| IC-X | (null) | IC-X |
| SE-12-0300 | 12 | SE |
| 송장번호 | 숫자1 | 정리 |
|---|---|---|
| BU7-4410 | 7 | BU7 |
| GJ-905 | 905 | GJ |
| IC-X | (null) | IC-X |
| SE-12-0300 | 12 | SE |
| 송장번호 | 숫자1 | 정리 |
|---|---|---|
| BU7-4410 | 7 | BU7 |
| GJ-905 | 905 | GJ |
| IC-X | (null) | (null) |
| SE-12-0300 | 12 | SE |
| 송장번호 | 숫자1 | 정리 |
|---|---|---|
| BU7-4410 | 7 | BU7 |
| GJ-905 | 905 | GJ |
| IC-X | (null) | IC-X |
| SE-12-0300 | 12 | SE-0300 |
REGEXP_SUBSTR 는 왼쪽부터 찾은 첫 번째 일치를 돌려주므로 BU7-4410 은 7, 숫자가 없는 IC-X 는 NULL 이다.
Oracle 의 REGEXP_REPLACE 는 발생 위치를 주지 않으면 모든 일치를 바꾸므로 SE-12-0300 의 -12 와 -0300 이 함께 지워지고, 일치가 없는 IC-X 는 원문 그대로 남는다.
- BU7-4410 이 4410 인 결과는 가장 긴 숫자를 고른다고 본 경우이다.
- SE-0300 결과는 첫 번째 일치만 바꾼다고 본 경우이다.
- IC-X 의 정리가 NULL 인 결과는 일치가 없으면 NULL 을 돌려준다고 본 경우이다.
㉠ DELETE FROM 입고; ㉡ TRUNCATE TABLE 입고; ㉢ DROP TABLE 입고;
세 구문 중 조건을 줄 수 있는 것은 DML 인 DELETE 뿐이라 WHERE 로 일부 행만 지울 수 있고, 커밋 전이면 ROLLBACK 으로 되돌릴 수 있다.
TRUNCATE 와 DROP 은 Oracle 에서 자동 커밋되는 DDL 이며, TRUNCATE 는 구조를 남기고 DROP 은 구조까지 없앤다.
- TRUNCATE 는 자동 커밋되므로 ROLLBACK 으로 행을 복구할 수 없다.
- DROP 은 테이블 정의까지 삭제해 빈 구조도 남지 않는다.
- DELETE 는 행만 지우고 확보된 저장 공간은 반환하지 않는다.
BEGIN TRANSACTION; INSERT INTO 출고 VALUES (1, 'P1', 5); BEGIN TRANSACTION; INSERT INTO 출고 VALUES (2, 'P2', 8); COMMIT; ROLLBACK; SELECT COUNT(*) FROM 출고;
SQL Server 에서 중첩 트랜잭션의 안쪽 COMMIT 은 실제 확정 없이 @@TRANCOUNT 를 1 줄일 뿐이고, 이어진 ROLLBACK 은 가장 바깥 트랜잭션 전체를 취소한다.
따라서 두 INSERT 가 모두 되돌려져 출고 테이블은 다시 비고 COUNT(*) 는 0 이다.
- 바깥 트랜잭션의 첫 INSERT 도 ROLLBACK 대상이라 남지 않는다.
- 안쪽 COMMIT 은 확정이 아니므로 둘째 INSERT 도 취소된다.
- 확정은 TRANCOUNT 가 0 이 되는 바깥 COMMIT 에서만 일어난다.
Oracle 의 가변 길이 문자형 VARCHAR2 는 최대 길이를 반드시 지정해야 하므로 MEMO VARCHAR2 처럼 길이를 빼면 오류가 난다.
숫자형 NUMBER 는 정밀도를 생략할 수 있고, 테이블명에는 문자로 시작하면 영문자·숫자·_·$·# 를 쓸 수 있다.
- WH_A 는 밑줄이 허용되고 NUMBER 는 정밀도 없이 써도 된다.
- TRUCK#1 은 # 와 숫자가 문자 뒤에 와 정상이고 NUMBER(3,1) 도 유효하다.
- INBOUND 의 DATE 타입은 길이를 지정하지 않는다.
CREATE TABLE 재고 (
품번 CHAR(3) PRIMARY KEY,
창고 CHAR(2) NOT NULL
REFERENCES 창고(창고코드),
수량 INT CHECK (수량 >= 0),
바코드 VARCHAR(13) UNIQUE
);
-- 창고 테이블의 창고코드: 'W1', 'W2'
-- 기존 행: ('P01', 'W1', 10, '880001')
㉠ INSERT INTO 재고 VALUES ('P02', 'W3', 5, '880002');
㉡ INSERT INTO 재고 VALUES ('P03', 'W2', 0, NULL);
㉢ INSERT INTO 재고 VALUES ('P05', 'W1', -1, '880003');
㉣ INSERT INTO 재고 VALUES ('P04', 'W1', 8, NULL);㉡과 ㉣은 새 품번, 존재하는 창고, CHECK (수량 >= 0) 을 만족하는 수량이고, 바코드가 NULL 이라 UNIQUE 제약에서 중복으로 보지 않아 둘 다 성공한다.
UNIQUE 는 NULL 끼리 같은 값으로 비교하지 않으므로 ㉡ 뒤에 NULL 바코드를 다시 넣는 ㉣도 허용된다.
- ㉠은 창고 W3 가 창고 테이블에 없어 FK 위반으로 거부된다.
- ㉢은 수량 -1 이 0 이상이라는 CHECK 조건을 어겨 거부된다.
- ㉡만 고르면 UNIQUE 열에 NULL 이 한 번만 들어간다고 본 것이다.
CREATE ROLE R_STOCK; GRANT SELECT ON 재고 TO R_STOCK; GRANT R_STOCK TO KIM; GRANT UPDATE ON 재고 TO R_STOCK;
ROLE 은 권한의 묶음이라 사용자에게 준 뒤에 ROLE 에 추가한 UPDATE 권한도 KIM 에게 그대로 반영되어 조회와 수정이 모두 가능하다.
ROLE 을 받은 사용자가 그 ROLE 을 다시 남에게 주려면 WITH ADMIN OPTION 으로 받아야 한다.
- ROLE 권한은 부여 시점에 고정되지 않아 나중에 추가한 UPDATE 도 적용된다.
- WITH ADMIN OPTION 없이 받았으므로 R_STOCK 을 다른 사용자에게 줄 수 없다.
- 테이블 삭제 권한은 부여되지 않았다.