논리적 모델링은 개념 모델을 업무 규칙에 맞게 구체화하며 정규화로 속성과 관계를 정제하는 단계이다.
특정 DBMS 와 무관하게 키·속성·관계를 확정하므로 재사용성이 가장 높은 산출물이 나온다.
- 테이블스페이스·인덱스 정의는 물리적 모델링에서 한다.
- 핵심 엔터티로 전사 관점을 잡는 일은 개념적 모델링이다.
- DBMS별 데이터 타입·용량 결정도 물리적 모델링이다.
운영사는 자사 1건만 저장되어 인스턴스가 두 개 이상이라는 엔터티 성립 조건을 충족하지 못한다.
게다가 다른 엔터티와의 관계도 없어 업무 데이터로서 쓰임이 없으므로 설정값처럼 따로 관리하는 편이 낫다.
- 수강신청은 회원번호·강좌코드 복합 식별자로 유일하게 구분되면 충분하다.
- 강좌는 일반 속성이 두 개라도 속성을 가진다는 조건을 만족한다.
- 회원은 수강신청과 관계를 맺고 있어 관계 조건을 충족한다.
속성은 엔터티를 설명하는 더 이상 쪼갤 수 없는 단위이며, 한 인스턴스는 각 속성에 대해 하나의 값만 가진다.
값이 여러 개라면 별도 엔터티로 분리하거나 속성을 나누어 원자성을 지켜야 한다.
- 여러 값을 한 속성에 묶으면 제1정규형을 위반한다.
- 속성은 반드시 어떤 엔터티에 소속되어야 의미를 가진다.
- 하나의 속성은 하나의 엔터티에만 소속된다.
회원과 도서는 M:N 관계이므로 논리 모델에서는 대출이라는 교차 엔터티를 두어 회원–대출, 도서–대출의 1:M 관계 둘로 해소한다.
대출에는 회원번호·도서번호가 FK 로 상속되고 대출일·반납일 같은 관계 고유 속성이 함께 놓인다.
- 도서에 회원번호 하나만 두면 여러 회원의 대출 이력을 담지 못한다.
- 회원에 도서번호를 반복해 두면 제1정규형을 어긴다.
- 두 엔터티를 통합하면 회원·도서 정보가 중복된다.
최소성은 유일성을 만족하는 데 필요한 최소한의 속성으로만 주식별자를 구성해야 한다는 뜻이므로, 속성을 여유 있게 더 넣는 것은 반대 방향이다.
예를 들어 환자번호만으로 유일하다면 환자번호+환자명은 최소성을 어긴 식별자가 된다.
- 유일성은 주식별자로 모든 인스턴스를 유일하게 구분하는 특성이다.
- 불변성은 한번 지정된 값이 자주 변하지 않아야 한다는 특성이다.
- 존재성은 주식별자에 반드시 값이 들어가 NULL 이 없다는 특성이다.
BCNF 는 함수 종속의 모든 결정자가 후보키여야 한다는 조건으로, 제3정규형보다 강한 정규형이다.
후보키가 아닌 속성이 다른 속성을 결정하면 3NF 를 만족해도 BCNF 위반이므로 그 결정자를 기준으로 테이블을 분해한다.
- 모든 속성 값이 원자값인 것은 제1정규형 조건이다.
- 기본키에 대한 부분 종속 제거는 제2정규형 조건이다.
- 키가 아닌 속성 사이의 이행 종속 제거는 제3정규형 조건이다.
수강신청은 개설의 주식별자 (학과코드, 과목번호, 학기)를 FK 로 상속받으므로 개설을 거치지 않고 과목·학과와 바로 조인하면 충분하다.
과목명은 과목에만 있어 (학과코드, 과목번호)로 과목과, 학과명은 학과코드로 학과와 조인한다.
- 개설은 담당교수가 필요할 때만 조인하면 되므로 최소 조합이 아니다.
- 학과만 조인하면 과목명을 얻을 수 없다.
- 학생은 신청자 정보일 뿐 과목의 소속 학과와는 경로가 다르다.
예약과 결제가 서로 필수 참여라면 어느 한쪽만 저장된 상태는 업무 규칙 위반이므로 두 인스턴스를 하나의 트랜잭션으로 함께 입력·커밋해야 한다.
한쪽만 필수인 관계는 부모를 먼저 입력해 두고 자식을 나중에 별도 트랜잭션으로 넣어도 정합성이 깨지지 않는다.
- 예약이 없는 회원은 단독으로 입력할 수 있다.
- 객실은 예약과 별개로 먼저 등록해 둘 수 있다.
- 리뷰는 예약이 이미 있으면 나중에 따로 입력해도 된다.
재진예약일은 다음 방문이 정해지지 않은 진료에서는 값이 없을 수 있으므로 NULL 허용이 업무 규칙에 맞다.
반대로 식별자·필수 관계의 FK·업무상 반드시 생기는 값은 NOT NULL로 두어야 데이터 누락을 막는다.
- 진료번호는 주식별자라 존재성에 따라 NULL 이 될 수 없다.
- 진료일시는 진료가 생기는 순간 반드시 정해지는 값이다.
- 필수 관계의 FK 인 환자번호가 NULL 이면 관계가 깨진다.
수강신청
| 신청ID | 학번 | 강좌코드 | 신청일 |
|---|---|---|---|
| 1 | 2026001 | C10 | 2026-03-02 |
| 2 | 2026002 | C10 | 2026-03-02 |
| 3 | 2026001 | C20 | 2026-03-03 |
| 4 | 2026001 | C10 | 2026-03-04 |
인조식별자만 기본키로 두면 같은 학생의 같은 강좌 신청이 신청ID 만 달리해 중복 입력되며, 실제로 2026001·C10 신청이 두 행 저장되었다.
본질식별자인 (학번, 강좌코드)에 유니크 제약을 걸면 인조식별자를 유지하면서도 업무상 중복을 막을 수 있다.
- 신청일은 여러 신청이 같은 날 생기므로 유일성이 없다.
- 신청ID 는 기본키라 이미 NOT NULL 이며 중복을 막지 못한다.
- 학번 단독 유니크는 한 학생의 여러 강좌 신청까지 막는다.
관계형 모델에서 테이블의 행은 순서 개념이 없는 집합이므로 저장된 순서대로 조회된다는 보장이 없고, 순서가 필요하면 ORDER BY를 써야 한다.
열은 하나의 속성으로 같은 도메인의 값을 갖고, 행은 하나의 인스턴스를 나타내며 기본키로 구분된다.
- 같은 열의 값이 같은 도메인을 갖는 것은 테이블의 기본 성질이다.
- 기본키는 각 행을 유일하게 식별하는 열 집합이다.
- 행 하나는 엔터티의 인스턴스 하나에 대응한다.
SELECT 회원번호, COUNT(*) AS 대출건수 FROM 대출 WHERE 반납일 IS NULL GROUP BY 회원번호 HAVING COUNT(*) >= 2 ORDER BY 대출건수 DESC;
SELECT 문은 FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY 순으로 처리되므로 네 번째는 HAVING 절이다.
그래서 SELECT 절의 별칭 대출건수는 HAVING 에서 쓸 수 없고 마지막에 처리되는 ORDER BY 에서만 쓸 수 있다.
- WHERE 절은 FROM 다음 두 번째로 처리된다.
- GROUP BY 절은 세 번째로 처리된다.
- SELECT 절은 HAVING 다음 다섯 번째로 처리된다.
진료비
| 진료번호 | 기본료 | 검사료 |
|---|---|---|
| 1 | 10000 | 5000 |
| 2 | 8000 | (null) |
| 3 | (null) | 3000 |
| 4 | 12000 | 2000 |
| 5 | 9000 | (null) |
SELECT SUM(기본료 + 검사료) AS A,
SUM(기본료) + SUM(검사료) AS B,
COUNT(검사료) AS C
FROM 진료비;| A | B | C |
|---|---|---|
| 49000 | 49000 | 3 |
| A | B | C |
|---|---|---|
| 29000 | 29000 | 3 |
| A | B | C |
|---|---|---|
| 29000 | 49000 | 5 |
| A | B | C |
|---|---|---|
| 29000 | 49000 | 3 |
행 단위 덧셈은 한쪽이 NULL 이면 결과가 NULL이 되어 SUM 에서 빠지므로 A 는 두 값이 모두 있는 행의 합 15000+14000=29000이다.
B 는 열마다 NULL 을 제외하고 먼저 합한 39000+10000=49000이고, COUNT(열)은 NULL 이 아닌 3건만 센다.
- A 를 49000으로 본 것은 NULL 을 0처럼 더한 오해다.
- B 를 29000으로 본 것은 열별 집계가 NULL 을 먼저 제외한다는 점을 놓쳤다.
- C 를 5로 본 것은 COUNT(*)와 COUNT(열)을 혼동한 것이다.
예약
| 예약번호 | 인원 | 확정인원 | 메모 |
|---|---|---|---|
| 1 | 4 | 4 | 창가 |
| 2 | 3 | (null) | (null) |
| 3 | (null) | (null) | (null) |
| 4 | 5 | 2 | 유아 |
SELECT 예약번호,
COALESCE(확정인원, 인원, 0) AS P,
NULLIF(인원, 확정인원) AS Q,
CASE WHEN 메모 IS NULL THEN 'N' ELSE 'Y' END AS R
FROM 예약
ORDER BY 예약번호;| 예약번호 | P | Q | R |
|---|---|---|---|
| 1 | 4 | (null) | Y |
| 2 | 3 | 3 | N |
| 3 | 0 | (null) | N |
| 4 | 2 | 5 | Y |
| 예약번호 | P | Q | R |
|---|---|---|---|
| 1 | 4 | (null) | Y |
| 2 | 3 | (null) | N |
| 3 | 0 | (null) | N |
| 4 | 2 | 5 | Y |
| 예약번호 | P | Q | R |
|---|---|---|---|
| 1 | 4 | (null) | Y |
| 2 | 3 | 3 | N |
| 3 | 0 | (null) | N |
| 4 | 5 | 5 | Y |
| 예약번호 | P | Q | R |
|---|---|---|---|
| 1 | 4 | 4 | Y |
| 2 | 3 | 3 | N |
| 3 | 0 | (null) | N |
| 4 | 2 | 5 | Y |
COALESCE는 왼쪽부터 처음 만나는 NULL 아닌 값을 돌려주어 예약번호 4 행은 확정인원 2, 두 값이 모두 NULL 인 예약번호 3 행은 0이 된다.
NULLIF(a, b)는 두 값이 같을 때만 NULL 이고 다르면 a 를 돌려주므로 4와 4가 같은 첫 행만 NULL, 확정인원이 NULL 인 둘째 행은 3이다.
- 첫 행의 Q 를 4로 본 것은 같을 때 NULL 이 된다는 규칙을 뒤집은 것이다.
- 둘째 행의 Q 를 NULL 로 본 것은 NULL 과의 비교를 같음으로 오해한 것이다.
- 마지막 행의 P 를 5로 본 것은 COALESCE 인자 순서를 놓친 것이다.
대출
| 대출번호 | 회원번호 | 대출일 |
|---|---|---|
| 1 | 11 | 2026-03-02 |
| 2 | 12 | 2026-03-20 |
| 3 | 11 | 2026-04-25 |
SELECT 대출번호,
( ㉠ ) AS 반납예정일
FROM 대출
ORDER BY 대출번호;| 대출번호 | 반납예정일 |
|---|---|
| 1 | 2026-03-16 |
| 2 | 2026-04-03 |
| 3 | 2026-05-09 |
Oracle 에서 DATE + 숫자는 그 일수만큼 더한 날짜이므로 대출일 + 14 로 14일 뒤를 구한 다음 TO_CHAR로 'YYYY-MM-DD' 문자열을 만든다.
3월 20일은 4월 3일, 4월 25일은 5월 9일처럼 월이 바뀌는 경우도 날짜 연산이 자동으로 처리한다.
- ADD_MONTHS 는 일이 아니라 개월 수를 더하며 결과도 문자열이 아니다.
- 월 뒤에 '-14'를 붙이면 대출일과 무관하게 모든 행이 14일이 된다.
- DATE 를 TO_NUMBER 로 바로 바꿀 수 없어 형변환 오류가 난다.
수강
| 학번 | 강좌코드 | 등급 |
|---|---|---|
| 2026101 | K1 | A |
| 2026102 | K1 | B |
| 2026103 | K1 | (null) |
| 2026104 | K1 | C |
| 2026105 | K1 | B |
SELECT 학번,
DECODE(등급, 'A', '우수', 'B', '보통', '미흡') AS 평가
FROM 수강
ORDER BY 학번;| 학번 | 평가 |
|---|---|
| 2026101 | 우수 |
| 2026102 | 보통 |
| 2026103 | 미흡 |
| 2026104 | 미흡 |
| 2026105 | 보통 |
| 학번 | 평가 |
|---|---|
| 2026101 | 우수 |
| 2026102 | 보통 |
| 2026103 | (null) |
| 2026104 | (null) |
| 2026105 | 보통 |
| 학번 | 평가 |
|---|---|
| 2026101 | 우수 |
| 2026102 | 보통 |
| 2026103 | 미흡 |
| 2026104 | (null) |
| 2026105 | 보통 |
| 학번 | 평가 |
|---|---|
| 2026101 | 우수 |
| 2026102 | 보통 |
| 2026103 | (null) |
| 2026104 | 미흡 |
| 2026105 | 보통 |
DECODE는 등급을 검색값과 차례로 비교해 일치하는 결과를 돌려주고, 어느 것과도 일치하지 않으면 마지막 기본값 '미흡'을 돌려준다.
등급이 C 인 행과 NULL 인 행은 'A'·'B' 어느 쪽과도 일치하지 않으므로 둘 다 '미흡'이 된다.
- NULL 행만 NULL 로 둔 것은 기본값이 NULL 에도 적용된다는 점을 놓쳤다.
- C 행만 NULL 로 둔 것은 기본값을 NULL 전용으로 오해했다.
- 두 행 모두 NULL 로 둔 것은 기본값 인자를 무시했다.
NOT BETWEEN a AND b는 BETWEEN 의 부정이므로 a 미만이거나 b 초과인 행만 찾고, 양 끝값 a·b 는 결과에서 빠진다.
와일드카드 % 와 _ 는 LIKE 에서만 패턴으로 해석되며, 그 문자 자체를 찾을 때는 ESCAPE 로 지정한 문자를 앞에 붙인다.
- LIKE '%10\%%' ESCAPE '\' 처럼 쓰면 % 기호 자체를 검색한다.
- IS NOT NULL 은 NULL 이 아닌, 값이 들어 있는 행을 고른다.
- = 연산자는 패턴 비교가 아니어서 % 를 글자 그대로 비교한다.
[회원]
| 회원번호 | 등급 | 포인트 |
|---|---|---|
| 1 | VIP | 300 |
| 2 | 일반 | 450 |
| 3 | (null) | 200 |
| 4 | 일반 | (null) |
| 5 | (null) | 800 |
| 6 | VIP | 100 |
| 7 | 일반 | 500 |
| 8 | (null) | (null) |
SELECT 회원번호, 등급 FROM 회원 WHERE COALESCE(등급, '일반') <> 'VIP' AND 포인트 < 500 ORDER BY 회원번호;
| 회원번호 | 등급 |
|---|---|
| 2 | 일반 |
| 3 | (null) |
| 회원번호 | 등급 |
|---|---|
| 2 | 일반 |
| 3 | 일반 |
| 회원번호 | 등급 |
|---|---|
| 2 | 일반 |
| 3 | (null) |
| 4 | 일반 |
| 8 | (null) |
| 회원번호 | 등급 |
|---|---|
| 2 | 일반 |
등급이 NULL 인 행은 COALESCE 로 '일반'이 되어 첫 조건이 참이지만, 포인트가 NULL 이면 UNKNOWN 이라 AND 결과가 참이 되지 않아 회원 2·3 만 남는다.
회원 4·8 은 포인트 비교가 UNKNOWN 이라 빠지고 회원 5·7 은 포인트가 500 이상이며, SELECT 의 등급은 바꾸지 않았으므로 회원 3 의 등급은 NULL 그대로 출력된다.
- 회원 3 의 등급을 '일반'으로 보인 표는 WHERE 의 COALESCE 가 출력값까지 바꾼다고 본 것이다.
- 회원 2 만 남긴 표는 등급 NULL 행을 COALESCE 없이 비교한 결과다.
- 회원 4·8 까지 넣은 표는 NULL 과의 비교를 참으로 계산한 것이다.
대출
| 대출번호 | 회원번호 | 연체일 | 반납일 |
|---|---|---|---|
| 1 | 11 | 0 | 2026-03-10 |
| 2 | 12 | 35 | 2026-03-20 |
| 3 | 13 | 10 | (null) |
| 4 | 11 | (null) | (null) |
| 5 | 14 | 25 | (null) |
SELECT COUNT(*) AS CNT, SUM(연체일) AS S, MAX(연체일) AS M FROM 대출 WHERE 반납일 IS NULL AND 연체일 > 30;
| CNT | S | M |
|---|---|---|
| 0 | 0 | 0 |
| CNT | S | M |
|---|---|---|
| 1 | 35 | 35 |
| CNT | S | M |
|---|---|---|
| 0 | (null) | (null) |
| CNT | S | M |
|---|
GROUP BY 가 없는 집계 쿼리는 조건을 만족하는 행이 없어도 항상 한 행을 돌려주며, 이때 COUNT(*)는 0, SUM·MAX 는 NULL이다.
반납일이 NULL 인 대출은 연체일이 10, NULL, 25뿐이라 30 초과가 없고, 연체일 35인 대출은 이미 반납되어 WHERE 에서 제외된다.
- 결과 행이 없다고 본 것은 GROUP BY 가 있을 때의 동작과 혼동한 것이다.
- SUM·MAX 를 0으로 본 것은 빈 집합의 집계가 NULL 이라는 점을 놓쳤다.
- 연체일 35인 행을 센 것은 반납일 조건을 빠뜨린 것이다.
㉠ SELECT 강좌코드, AVG(점수) FROM 수강 GROUP BY 강좌코드 HAVING AVG(점수) >= 80; ㉡ SELECT 강좌코드, COUNT(*) FROM 수강 WHERE COUNT(*) >= 2 GROUP BY 강좌코드; ㉢ SELECT 강좌코드 FROM 수강 WHERE 점수 >= 60 GROUP BY 강좌코드 HAVING COUNT(*) >= 2; ㉣ SELECT 학번, MAX(점수) FROM 수강 GROUP BY 강좌코드;
WHERE 절은 그룹이 만들어지기 전에 행 단위로 처리되므로 집계 함수를 WHERE 에 쓸 수 없어 ㉡은 오류이며, 집계 조건은 HAVING 에 써야 한다.
㉣은 GROUP BY 에 없는 학번을 집계 없이 SELECT 해 그룹당 한 값을 정할 수 없으므로 오류가 난다.
- ㉠은 집계 조건을 HAVING 에 둔 올바른 구문이다.
- ㉢은 행 조건은 WHERE, 그룹 조건은 HAVING 에 둔 정상 구문이다.
- ㉡만 고르면 GROUP BY 열 규칙을 어긴 ㉣을 놓친다.
예약
| 예약번호 | 지점 | 객실유형 | 예약일 |
|---|---|---|---|
| 1 | 강남 | 디럭스 | 2026-03-02 |
| 2 | 강남 | 디럭스 | 2026-03-05 |
| 3 | 강남 | (null) | 2026-03-07 |
| 4 | 강남 | (null) | 2026-03-09 |
| 5 | 홍대 | 스탠다드 | 2026-02-27 |
| 6 | 홍대 | 스탠다드 | 2026-03-03 |
| 7 | 홍대 | 디럭스 | 2026-03-04 |
| 8 | (null) | 디럭스 | 2026-03-06 |
| 9 | 홍대 | (null) | 2026-02-20 |
SELECT 지점, 객실유형, COUNT(*) AS 건수 FROM 예약 WHERE 예약일 >= DATE '2026-03-01' GROUP BY 지점, 객실유형 ORDER BY 지점, 객실유형;
WHERE 로 3월 1일 이전 예약 두 건을 먼저 제외한 뒤 (지점, 객실유형) 조합으로 묶으며, GROUP BY 는 NULL 도 하나의 그룹으로 만든다.
남는 그룹은 (강남, 디럭스), (강남, NULL), (홍대, 스탠다드), (홍대, 디럭스), (NULL, 디럭스)로 5개이다.
- 4개는 NULL 그룹 하나를 빠뜨린 계산이다.
- 3개는 NULL 이 들어간 그룹을 모두 제외한 계산이다.
- 6개는 WHERE 를 무시해 (홍대, NULL) 그룹까지 센 계산이다.
Oracle 은 NULL 을 가장 큰 값처럼 다루어 오름차순이면 맨 뒤, 내림차순이면 맨 앞에 둔다.
SQL Server 는 반대로 NULL 을 가장 작은 값으로 보아 오름차순에서 맨 앞에 오며, Oracle 은 NULLS FIRST/LAST로 위치를 직접 정할 수 있다.
- SQL Server 는 NULL 을 최솟값으로 취급한다.
- NULLS FIRST 는 오름차순·내림차순 모두에 쓸 수 있다.
- Oracle 내림차순에서는 NULL 이 맨 앞에 온다.
등가 조인은 두 테이블의 조인 열을 = 연산자로 비교해 값이 같은 행끼리만 결합하는 방식이다.
NULL 은 어떤 값과도 같지 않으므로 조인 키가 NULL 인 행은 짝을 찾지 못해 결과에서 빠진다.
- 조인 키가 NULL 인 행을 남기려면 OUTER JOIN 을 써야 한다.
- 값만 비교할 수 있으면 PK·FK 관계가 없어도 조인할 수 있다.
- BETWEEN 처럼 범위로 비교하는 것은 비등가 조인이다.
회원
| 회원번호 | 이름 |
|---|---|
| 1 | 한지수 |
| 2 | 오민재 |
| 3 | 서가은 |
대출
| 대출번호 | 회원번호 | 도서번호 |
|---|---|---|
| 101 | 1 | B1 |
| 102 | 1 | B2 |
| 103 | 2 | B3 |
| 104 | 3 | B1 |
| 105 | 9 | B4 |
도서
| 도서번호 | 제목 | 분류 |
|---|---|---|
| B1 | SQL 입문 | IT |
| B2 | 봄날 | 소설 |
| B3 | 파이썬 기초 | IT |
| B4 | 고려사 | 역사 |
SELECT M.이름, B.제목 FROM 회원 M, 대출 L, 도서 B WHERE M.회원번호 = L.회원번호 AND B.분류 = 'IT' ORDER BY M.이름, B.제목;
세 테이블 조인에는 조인 조건이 최소 2개(N−1) 필요한데 대출–도서 조건이 빠져 도서와는 카티션 곱이 생긴다.
회원과 짝이 맞는 대출은 회원번호 9 를 뺀 4건이고, 이 4건이 IT 도서 2권과 모두 짝지어 4 × 2 = 8행이 된다.
- 3행은 대출–도서 조건까지 있을 때의 정상 조인 결과이다.
- 10행은 회원이 없는 대출까지 IT 도서 2권과 곱한 값이다.
- 16행은 분류 조건을 무시하고 도서 4권과 곱한 값이다.
강좌
| 강좌코드 | 강좌명 |
|---|---|
| K1 | SQL기초 |
| K2 | 모델링 |
| K3 | 튜닝 |
| K4 | 파이썬 |
수강
| 학번 | 강좌코드 | 상태 |
|---|---|---|
| S1 | K1 | 승인 |
| S2 | K1 | 대기 |
| S3 | K1 | 승인 |
| S1 | K2 | 대기 |
| S4 | K3 | 승인 |
| S2 | K3 | 취소 |
SELECT C.강좌코드, COUNT(E.학번) AS 인원
FROM 강좌 C
LEFT OUTER JOIN 수강 E
ON C.강좌코드 = E.강좌코드
AND E.상태 = '승인'
GROUP BY C.강좌코드
ORDER BY C.강좌코드;| 강좌코드 | 인원 |
|---|---|
| K1 | 2 |
| K2 | 0 |
| K3 | 1 |
| K4 | 0 |
| 강좌코드 | 인원 |
|---|---|
| K1 | 3 |
| K2 | 1 |
| K3 | 2 |
| K4 | 0 |
| 강좌코드 | 인원 |
|---|---|
| K1 | 2 |
| K2 | 1 |
| K3 | 1 |
| K4 | 1 |
| 강좌코드 | 인원 |
|---|---|
| K1 | 2 |
| K3 | 1 |
LEFT OUTER JOIN 에서 ON 절에 둔 상태 조건은 조인할 수강 행만 거를 뿐 왼쪽 강좌 행은 모두 남기므로 K2·K4 도 결과에 나타난다.
K2·K4 는 짝이 없어 수강 열이 NULL 이 되고, COUNT(E.학번)은 NULL 을 세지 않아 인원이 0이다.
- K1·K3 만 남은 표는 상태 조건을 WHERE 에 둔 결과이다.
- K2·K4 가 1인 표는 COUNT(*)로 NULL 행까지 센 결과이다.
- K1 이 3인 표는 승인 조건 없이 모든 수강을 센 결과이다.
[강의]
| 강의코드 | 강의명 | 강의실 |
|---|---|---|
| C1 | 자료구조 | A101 |
| C2 | 운영체제 | B201 |
| C3 | 네트워크 | A102 |
[시험]
| 강의코드 | 강의실 | 시험일 |
|---|---|---|
| C1 | A101 | 2026-06-15 |
| C2 | B301 | 2026-06-16 |
| C3 | A102 | 2026-06-17 |
| C4 | A101 | 2026-06-18 |
SELECT 강의코드, 강의명, 시험일 FROM 강의 ( ㉠ ) ORDER BY 강의코드;
[결과]
| 강의코드 | 강의명 | 시험일 |
|---|---|---|
| C1 | 자료구조 | 2026-06-15 |
| C2 | 운영체제 | 2026-06-16 |
| C3 | 네트워크 | 2026-06-17 |
결과에 C2(시험 강의실 B301)까지 시험일과 함께 나오려면 강의코드만 조인 조건으로 써야 하므로 USING (강의코드)가 맞다.
NATURAL JOIN은 이름이 같은 강의코드·강의실 두 열을 모두 같게 비교해 강의실이 다른 C2를 버린다. USING 에 쓴 열은 하나로 합쳐져 접두어 없이 조회한다.
- NATURAL JOIN 시험: C2가 빠져 2행이 된다.
- 강의실로 ON 조인: 강의코드가 양쪽 열로 남아 모호하다는 오류가 난다.
- CROSS JOIN 시험: 조인 조건이 없고 강의코드도 모호해 오류가 난다.
[도서]
| 도서번호 | 제목 |
|---|---|
| B1 | 데이터베이스 입문 |
| B2 | SQL 활용 |
| B3 | 모델링 기초 |
| B4 | 통계 개론 |
[대출]
| 대출번호 | 도서번호 | 대출일 |
|---|---|---|
| 1 | B1 | 2026-02-20 |
| 2 | B1 | 2026-03-05 |
| 3 | B2 | 2026-02-10 |
| 4 | B3 | 2026-03-02 |
| 5 | B3 | 2026-03-15 |
| 6 | B3 | 2026-03-20 |
SELECT B.도서번호, COUNT(L.대출번호) AS 대출건수 FROM 도서 B, 대출 L WHERE B.도서번호 = L.도서번호(+) AND L.대출일(+) >= DATE '2026-03-01' GROUP BY B.도서번호 ORDER BY B.도서번호;
| 도서번호 | 대출건수 |
|---|---|
| B1 | 1 |
| B2 | 1 |
| B3 | 3 |
| B4 | 1 |
| 도서번호 | 대출건수 |
|---|---|
| B1 | 1 |
| B2 | 0 |
| B3 | 3 |
| B4 | 0 |
| 도서번호 | 대출건수 |
|---|---|
| B1 | 1 |
| B3 | 3 |
| 도서번호 | 대출건수 |
|---|---|
| B1 | 2 |
| B2 | 1 |
| B3 | 3 |
| B4 | 0 |
대출일 조건에도 (+)가 붙어 있어 이 조건은 ANSI 의 LEFT OUTER JOIN … ON 안 조건과 같고, 도서 4권이 모두 남는다.
3월 이후 대출만 짝지어져 B1은 1건, B3은 3건이고 B2·B4는 NULL 확장 행이라 COUNT(열)이 0을 센다.
- B1·B3 두 행만 있는 표는 대출일 조건의 (+)를 빼 WHERE 필터가 된 경우다.
- B2·B4가 1인 표는 NULL 확장 행까지 세는 COUNT(*)의 결과다.
- B1이 2인 표는 날짜 조건을 무시하고 모든 대출을 센 경우다.
[진료]
| 진료번호 | 의사번호 | 환자번호 | 진료비 |
|---|---|---|---|
| 1 | D1 | P1 | 30000 |
| 2 | D1 | P2 | 50000 |
| 3 | D1 | P3 | 40000 |
| 4 | D2 | P1 | 20000 |
| 5 | D2 | P4 | 26000 |
| 6 | D3 | P2 | 70000 |
| 7 | D3 | P5 | 90000 |
SELECT A.진료번호, A.의사번호, A.진료비
FROM 진료 A
WHERE A.진료비 > (SELECT AVG(B.진료비)
FROM 진료 B
WHERE ( ㉠ ))
ORDER BY A.진료번호;[결과]
| 진료번호 | 의사번호 | 진료비 |
|---|---|---|
| 2 | D1 | 50000 |
| 5 | D2 | 26000 |
| 7 | D3 | 90000 |
바깥 행의 의사번호를 안쪽에서 참조해야 행마다 같은 의사의 평균이 계산되므로 B.의사번호 = A.의사번호가 맞다.
연관 서브쿼리는 메인쿼리 행마다 다시 실행되어 D1 평균 40000, D2 23000, D3 80000과 비교하고 2·5·7번만 남는다.
- 환자번호 상관: 환자별 평균과 비교해 1·6번이 나온다.
- 진료번호 상관: 자기 자신의 평균과 같아 0행이다.
- A.의사번호 IS NOT NULL: 전체 평균(약 46571)과 비교해 2·6·7번이 나온다.
스칼라 서브쿼리는 SELECT 절 등에서 값 하나처럼 쓰이므로 한 행 한 열만 돌려줘야 하고, 여러 행이면 오류가 난다.
인라인 뷰는 FROM 절에 두어 테이블처럼 쓰는 서브쿼리이고, 뷰는 SQL 정의만 저장하는 가상 테이블이다.
- 인라인 뷰는 WHERE 절이 아니라 FROM 절에 쓰는 서브쿼리다.
- 뷰는 정의(SQL)만 저장하는 가상 테이블이라 데이터를 복제하지 않는다.
- 연관 서브쿼리는 메인쿼리 행마다 반복 실행된다.
[직원]
| 직원번호 | 이름 | 지점코드 |
|---|---|---|
| 101 | 김하늘 | S1 |
| 102 | 이도윤 | S1 |
| 103 | 박서연 | S2 |
| 104 | 최민준 | S2 |
| 105 | 정예린 | S3 |
[지점]
| 지점코드 | 지점명 | 관리자번호 |
|---|---|---|
| S1 | 강남 | 101 |
| S2 | 판교 | 103 |
| S3 | 분당 | (null) |
㉠ SELECT 이름 FROM 직원
WHERE 직원번호 NOT IN (SELECT 관리자번호 FROM 지점);
㉡ SELECT 이름 FROM 직원 E
WHERE NOT EXISTS (SELECT 1 FROM 지점 J
WHERE J.관리자번호 = E.직원번호);㉠은 목록에 NULL이 섞여 직원번호 <> NULL이 UNKNOWN이 되므로 어떤 행도 참이 되지 않아 0행이다.
㉡의 NOT EXISTS는 짝이 되는 행의 존재만 따지고 NULL 관리자번호는 어느 직원과도 같지 않아 102·104·105번 3행이 나온다.
- 둘 다 3행이라 보면 NOT IN의 NULL 비교를 놓친 것이다.
- ㉠ 3행·㉡ 0행은 두 연산의 NULL 처리를 뒤바꾼 것이다.
- 둘 다 0행이라 보면 NOT EXISTS도 NULL에 막힌다고 오해한 것이다.
[상반기수강]
| 과목 |
|---|
| DB |
| DB |
| 통계 |
| 파이썬 |
| (null) |
[하반기수강]
| 과목 |
|---|
| 통계 |
| (null) |
| 자바 |
집합 연산자는 중복을 비교할 때 NULL끼리 같은 값으로 보므로 INTERSECT 결과는 통계와 NULL, 2행이다.
UNION·INTERSECT·EXCEPT(MINUS)는 중복을 제거하고 UNION ALL만 모든 행을 그대로 이어 붙인다.
- UNION은 DB·통계·파이썬·NULL·자바로 5행이다.
- EXCEPT는 DB 중복이 제거돼 DB·파이썬 2행이다.
- UNION ALL은 NULL 행도 포함해 5+3=8행이다.
[정회원]
| 회원번호 | 이름 | 등급 |
|---|---|---|
| 1 | 한지우 | A |
| 2 | 서준호 | B |
| 3 | 윤채원 | A |
[준회원]
| 회원번호 | 이름 | 등급 |
|---|---|---|
| 11 | 서준호 | B |
| 12 | 강다은 | C |
| 13 | 윤채원 | B |
SELECT 이름 AS 성명, 등급 FROM 정회원 UNION SELECT 이름, 등급 FROM 준회원 ORDER BY 성명, 등급;
| 성명 | 등급 |
|---|---|
| 강다은 | C |
| 서준호 | B |
| 서준호 | B |
| 윤채원 | A |
| 윤채원 | B |
| 한지우 | A |
| 성명 | 등급 |
|---|---|
| 강다은 | C |
| 서준호 | B |
| 윤채원 | A |
| 윤채원 | B |
| 한지우 | A |
| 성명 | 등급 |
|---|---|
| 한지우 | A |
| 서준호 | B |
| 윤채원 | A |
| 강다은 | C |
| 윤채원 | B |
| 성명 | 등급 |
|---|---|
| 강다은 | C |
| 서준호 | B |
| 윤채원 | A |
| 한지우 | A |
UNION은 이름과 등급이 모두 같은 행만 하나로 합치므로 서준호 B는 한 번, 등급이 다른 윤채원은 두 행으로 남아 5행이다.
결과 열 이름은 첫 SELECT의 별칭 성명을 따르고, 맨 끝 ORDER BY는 합쳐진 전체 결과를 정렬한다.
- 윤채원이 한 행인 표는 이름만으로 중복을 판단한 경우다.
- 정렬되지 않은 표는 ORDER BY가 마지막 SELECT에만 적용된다고 본 것이다.
- 서준호 B가 두 번인 표는 UNION ALL의 결과다.
[주문]
| 주문번호 | 채널 | 결제수단 | 금액 |
|---|---|---|---|
| 1 | 앱 | 카드 | 100 |
| 2 | 앱 | 현금 | 40 |
| 3 | 웹 | 카드 | 70 |
| 4 | 웹 | 카드 | 30 |
| 5 | 매장 | 현금 | 50 |
| 6 | 매장 | 카드 | (null) |
SELECT 채널, 결제수단, SUM(금액) AS 합계 FROM 주문 GROUP BY CUBE(채널, 결제수단) ORDER BY 채널, 결제수단;
CUBE(채널, 결제수단)는 채널별 소계뿐 아니라 채널을 NULL 로 둔 결제수단별 소계도 만들므로, 카드 소계는 100+70+30=200 이다.
매장의 카드 주문은 금액이 NULL 이라 SUM 에서 제외되며, 채널과 결제수단이 모두 NULL 인 총계 행은 290 이다.
- 140 은 결제수단이 NULL 인 앱 채널 소계와 혼동한 값이다.
- 290 은 두 열이 모두 NULL 인 총계 행의 값이다.
- 100 은 앱 채널의 카드 상세 행 값이다.
[학생]
| 학번 | 학과 | 학년 |
|---|---|---|
| 1 | 경영 | 1 |
| 2 | 경영 | 2 |
| 3 | 컴공 | 1 |
| 4 | 컴공 | 1 |
| 5 | 컴공 | 3 |
| 6 | 통계 | 2 |
SELECT 학과, 학년, GROUPING(학과) AS G, COUNT(*) AS 인원 FROM 학생 GROUP BY ( ㉠ ) ORDER BY 3, 1, 2;
[결과]
| 학과 | 학년 | G | 인원 |
|---|---|---|---|
| 경영 | (null) | 0 | 2 |
| 컴공 | (null) | 0 | 3 |
| 통계 | (null) | 0 | 1 |
| (null) | 1 | 1 | 3 |
| (null) | 2 | 1 | 2 |
| (null) | 3 | 1 | 1 |
| (null) | (null) | 1 | 6 |
결과에 학과별 소계, 학년별 소계, 총계만 있고 학과·학년 상세 행이 없으므로 GROUPING SETS (학과, 학년, ())가 맞다.
GROUPING(학과)는 학과가 집계로 생긴 NULL일 때 1이어서 학년 소계와 총계 행에서 1, 학과 소계 행에서 0이다.
- ROLLUP은 학과·학년 상세 행이 생기고 학년별 소계가 없다.
- CUBE는 원하는 소계 외에 상세 행까지 만든다.
- 일반 GROUP BY 학과, 학년은 상세 행만 만들고 소계가 없다.
[진료실적]
| 월 | 진료과 | 건수 |
|---|---|---|
| 3 | 내과 | 10 |
| 3 | 외과 | 20 |
| 4 | 내과 | 30 |
SELECT 월, 진료과, SUM(건수) AS 합계 FROM 진료실적 GROUP BY ROLLUP(월, 진료과) ORDER BY 월, 진료과;
| 월 | 진료과 | 합계 |
|---|---|---|
| 3 | 내과 | 10 |
| 3 | 외과 | 20 |
| 3 | (null) | 30 |
| 4 | 내과 | 30 |
| 4 | (null) | 30 |
| 월 | 진료과 | 합계 |
|---|---|---|
| 3 | 내과 | 10 |
| 3 | 외과 | 20 |
| 3 | (null) | 30 |
| 4 | 내과 | 30 |
| 4 | (null) | 30 |
| (null) | 내과 | 40 |
| (null) | 외과 | 20 |
| (null) | (null) | 60 |
| 월 | 진료과 | 합계 |
|---|---|---|
| 3 | 내과 | 10 |
| 3 | 외과 | 20 |
| 4 | 내과 | 30 |
| (null) | 내과 | 40 |
| (null) | 외과 | 20 |
| (null) | (null) | 60 |
| 월 | 진료과 | 합계 |
|---|---|---|
| 3 | 내과 | 10 |
| 3 | 외과 | 20 |
| 3 | (null) | 30 |
| 4 | 내과 | 30 |
| 4 | (null) | 30 |
| (null) | (null) | 60 |
ROLLUP(월, 진료과)는 (월, 진료과)·(월)·() 세 단계만 만들어 월별 소계 2행과 총계 1행이 상세 3행에 더해진 6행이다.
인수 순서가 계층이 되므로 앞 열인 월 기준 소계만 생기고 진료과별 소계는 나오지 않는다.
- 진료과 소계가 있고 월 소계가 없는 표는 인수 순서를 뒤집은 ROLLUP(진료과, 월)의 결과다.
- 총계가 없는 표는 마지막 () 단계를 빠뜨린 것이다.
- 양쪽 소계가 다 있는 8행 표는 CUBE의 결과다.
[배송]
| 기사 | 배송일 | 건수 |
|---|---|---|
| K1 | 2026-04-01 | 5 |
| K1 | 2026-04-02 | 3 |
| K1 | 2026-04-03 | 4 |
| K2 | 2026-04-01 | 6 |
| K2 | 2026-04-02 | 2 |
SELECT 기사, 배송일, 건수,
SUM(건수) OVER (PARTITION BY 기사
ORDER BY 배송일) AS 누적
FROM 배송
ORDER BY 기사, 배송일;| 기사 | 배송일 | 건수 | 누적 |
|---|---|---|---|
| K1 | 2026-04-01 | 5 | 5 |
| K1 | 2026-04-02 | 3 | 8 |
| K1 | 2026-04-03 | 4 | 12 |
| K2 | 2026-04-01 | 6 | 18 |
| K2 | 2026-04-02 | 2 | 20 |
| 기사 | 배송일 | 건수 | 누적 |
|---|---|---|---|
| K1 | 2026-04-01 | 5 | 12 |
| K1 | 2026-04-02 | 3 | 12 |
| K1 | 2026-04-03 | 4 | 12 |
| K2 | 2026-04-01 | 6 | 8 |
| K2 | 2026-04-02 | 2 | 8 |
| 기사 | 배송일 | 건수 | 누적 |
|---|---|---|---|
| K1 | 2026-04-01 | 5 | 5 |
| K1 | 2026-04-02 | 3 | 8 |
| K1 | 2026-04-03 | 4 | 12 |
| K2 | 2026-04-01 | 6 | 6 |
| K2 | 2026-04-02 | 2 | 8 |
| 기사 | 배송일 | 건수 | 누적 |
|---|---|---|---|
| K1 | 2026-04-01 | 5 | 12 |
| K1 | 2026-04-02 | 3 | 7 |
| K1 | 2026-04-03 | 4 | 4 |
| K2 | 2026-04-01 | 6 | 8 |
| K2 | 2026-04-02 | 2 | 2 |
PARTITION BY 기사로 기사마다 따로, ORDER BY 배송일 순으로 처음부터 현재 행까지 더하는 누적 합이라 K1은 5·8·12, K2는 6·8이다.
ORDER BY가 있으면 기본 프레임이 파티션 처음부터 현재 행까지여서 파티션이 바뀌는 K2 첫 행에서 다시 시작한다.
- 18·20으로 이어지는 표는 PARTITION BY를 무시하고 전체를 누적한 것이다.
- 12·8이 반복되는 표는 ORDER BY 없는 파티션 전체 합이다.
- 12·7·4로 줄어드는 표는 배송일 역순으로 누적한 것이다.
[예약]
| 예약번호 | 객실 | 예약일 | 금액 |
|---|---|---|---|
| 1 | R1 | 2026-05-01 | 120 |
| 2 | R1 | 2026-05-03 | 150 |
| 3 | R1 | 2026-05-07 | 130 |
| 4 | R2 | 2026-05-02 | 200 |
| 5 | R2 | 2026-05-06 | 180 |
SELECT 예약번호, 객실, 금액, ( ㉠ ) AS 마지막금액 FROM 예약 ORDER BY 예약번호;
[결과]
| 예약번호 | 객실 | 금액 | 마지막금액 |
|---|---|---|---|
| 1 | R1 | 120 | 130 |
| 2 | R1 | 150 | 130 |
| 3 | R1 | 130 | 130 |
| 4 | R2 | 200 | 180 |
| 5 | R2 | 180 | 180 |
[후보]
(가) LEAD(금액) OVER (PARTITION BY 객실 ORDER BY 예약일)
(나) LAST_VALUE(금액) OVER (PARTITION BY 객실 ORDER BY 예약일)
(다) LAST_VALUE(금액) OVER (PARTITION BY 객실 ORDER BY 예약일
ROWS BETWEEN CURRENT ROW AND 1 FOLLOWING)
(라) FIRST_VALUE(금액) OVER (PARTITION BY 객실 ORDER BY 예약일 DESC)예약일 내림차순 파티션의 첫 값이 곧 가장 늦은 예약 금액이므로 FIRST_VALUE … ORDER BY 예약일 DESC가 R1 모든 행에 130, R2 모든 행에 180을 붙인다.
ORDER BY만 둔 윈도우의 기본 프레임은 현재 행까지라서 LAST_VALUE를 그대로 쓰면 자기 금액이 나온다.
- LEAD는 다음 예약 금액을 돌려주고 마지막 행은 NULL이다.
- 기본 프레임의 LAST_VALUE는 현재 행 금액과 같다.
- 1 FOLLOWING까지인 프레임은 다음 행 금액이라 예약번호 1·4가 150·180이 된다.
[강의평가]
| 강의 | 평점 |
|---|---|
| SQL기초 | 95 |
| 데이터분석 | 88 |
| 모델링 | 88 |
| 파이썬 | 80 |
| 통계 | 72 |
SELECT 강의, 평점,
NTILE(2) OVER (ORDER BY 평점 DESC, 강의) AS 조,
PERCENT_RANK() OVER (ORDER BY 평점 DESC) AS 백분위
FROM 강의평가
ORDER BY 평점 DESC, 강의;| 강의 | 평점 | 조 | 백분위 |
|---|---|---|---|
| SQL기초 | 95 | 1 | 0 |
| 데이터분석 | 88 | 1 | 0.25 |
| 모델링 | 88 | 1 | 0.25 |
| 파이썬 | 80 | 2 | 0.75 |
| 통계 | 72 | 2 | 1 |
| 강의 | 평점 | 조 | 백분위 |
|---|---|---|---|
| SQL기초 | 95 | 1 | 0 |
| 데이터분석 | 88 | 1 | 0.25 |
| 모델링 | 88 | 1 | 0.25 |
| 파이썬 | 80 | 2 | 0.5 |
| 통계 | 72 | 2 | 0.75 |
| 강의 | 평점 | 조 | 백분위 |
|---|---|---|---|
| SQL기초 | 95 | 1 | 0 |
| 데이터분석 | 88 | 1 | 0.25 |
| 모델링 | 88 | 2 | 0.25 |
| 파이썬 | 80 | 2 | 0.75 |
| 통계 | 72 | 2 | 1 |
| 강의 | 평점 | 조 | 백분위 |
|---|---|---|---|
| SQL기초 | 95 | 1 | 0.2 |
| 데이터분석 | 88 | 1 | 0.6 |
| 모델링 | 88 | 1 | 0.6 |
| 파이썬 | 80 | 2 | 0.8 |
| 통계 | 72 | 2 | 1 |
5행을 NTILE(2)로 나누면 나머지 1행이 앞 조에 붙어 3행·2행이 되고, PERCENT_RANK는 (RANK−1)/(5−1)이라 0·0.25·0.25·0.75·1이다.
평점 88 동점 두 강의는 RANK가 모두 2이고 다음 파이썬은 RANK 4라서 0.75가 된다.
- 조가 1·1·2·2·2인 표는 나머지를 뒤 조에 준 것이다.
- 0.5·0.75로 끝나는 표는 DENSE_RANK처럼 순위를 건너뛰지 않은 경우다.
- 0.2·0.6으로 시작하는 표는 CUME_DIST의 값이다.
[게시글]
| 글번호 | 게시판 | 작성자 |
|---|---|---|
| 1 | 공지 | 관리자 |
| 2 | 공지 | 관리자 |
| 3 | 자유 | 민서 |
| 4 | 자유 | 도현 |
| 5 | 자유 | 민서 |
| 6 | 질문 | 하린 |
| 7 | 질문 | 도현 |
| 8 | (null) | 하린 |
㉠ SELECT 게시판, COUNT(*) FROM 게시글 GROUP BY 게시판;
㉡ SELECT 게시판, COUNT(*) OVER (PARTITION BY 게시판)
FROM 게시글;
㉢ SELECT DISTINCT 게시판,
COUNT(*) OVER (PARTITION BY 게시판)
FROM 게시글;GROUP BY는 그룹당 한 행으로 줄이지만 윈도우 함수는 원래 행을 그대로 두고 값만 붙이므로 ㉠ 4행, ㉡ 8행, ㉢ 4행이다.
게시판이 NULL인 글도 GROUP BY와 PARTITION BY에서 하나의 그룹이 되어 공지·자유·질문과 함께 4개로 묶이고, ㉢은 DISTINCT로 같은 행을 줄인다.
- 3으로 센 것은 NULL 게시판을 그룹에서 뺀 경우다.
- ㉡을 4행으로 본 것은 윈도우 함수가 행을 줄인다고 오해한 것이다.
- ㉢을 8행으로 본 것은 DISTINCT의 중복 제거를 놓친 것이다.
[랭킹]
| 선수 | 기록 |
|---|---|
| 하늘 | 95 |
| 바다 | 88 |
| 구름 | 88 |
| 들꽃 | 72 |
| 별빛 | 60 |
㉠ SELECT 선수, 기록 FROM 랭킹
ORDER BY 기록 DESC
OFFSET 0 ROWS FETCH NEXT 3 ROWS ONLY;
㉡ SELECT 선수, 기록 FROM 랭킹
ORDER BY 기록 DESC
FETCH NEXT 3 ROWS ONLY;
㉢ SELECT TOP (3) 선수, 기록 FROM 랭킹
ORDER BY 기록 DESC;
㉣ SELECT 선수, 기록 FROM 랭킹
ORDER BY 2 DESC
OFFSET 1 ROW FETCH FIRST 2 ROWS ONLY;SQL Server 에서 FETCH 는 ORDER BY 뒤의 OFFSET 절에 딸린 선택 구문이라, OFFSET 없이 FETCH 만 쓰면 구문 오류가 난다.
처음부터 n 건을 가져오려면 OFFSET 0 ROWS 를 함께 적거나 TOP 을 쓴다.
- OFFSET 0 ROWS 뒤에 FETCH NEXT 를 붙인 구문은 상위 3건을 가져온다.
- TOP (3) 과 ORDER BY 를 함께 쓴 구문은 정상 실행된다.
- FIRST·NEXT 와 ROW·ROWS 는 같은 뜻이라 열 번호 정렬과 함께 써도 정상이다.
[실적]
| 사번 | 지점 | 매출 |
|---|---|---|
| 1 | 강남 | 500 |
| 2 | 강남 | 700 |
| 3 | 강남 | 500 |
| 4 | 강남 | 300 |
| 5 | 판교 | 400 |
| 6 | 판교 | 900 |
| 7 | 판교 | 650 |
SELECT 지점, 사번, 매출
FROM (SELECT 지점, 사번, 매출,
ROW_NUMBER() OVER (PARTITION BY 지점
ORDER BY 매출 DESC, 사번) AS RN
FROM 실적) T
WHERE RN <= 2
ORDER BY 지점, RN;| 지점 | 사번 | 매출 |
|---|---|---|
| 강남 | 2 | 700 |
| 판교 | 6 | 900 |
| 지점 | 사번 | 매출 |
|---|---|---|
| 강남 | 2 | 700 |
| 강남 | 1 | 500 |
| 강남 | 3 | 500 |
| 판교 | 6 | 900 |
| 판교 | 7 | 650 |
| 지점 | 사번 | 매출 |
|---|---|---|
| 강남 | 4 | 300 |
| 강남 | 1 | 500 |
| 판교 | 5 | 400 |
| 판교 | 7 | 650 |
| 지점 | 사번 | 매출 |
|---|---|---|
| 강남 | 2 | 700 |
| 강남 | 1 | 500 |
| 판교 | 6 | 900 |
| 판교 | 7 | 650 |
ROW_NUMBER는 동점이어도 1, 2, 3처럼 고유 순번을 주므로 지점별로 정확히 2행씩, 강남 사번 2·1과 판교 사번 6·7이 나온다.
강남의 500 동점 두 명은 ORDER BY의 두 번째 기준인 사번 순으로 사번 1이 2위, 사번 3이 3위가 되어 사번 3은 빠진다.
- 강남 사번 3까지 나온 표는 동점을 같은 순위로 주는 RANK를 쓴 경우다.
- 매출이 낮은 사원이 나온 표는 오름차순으로 정렬한 경우다.
- 2행뿐인 표는 PARTITION BY 없이 전체 상위 2명을 뽑은 경우다.
SYS_CONNECT_BY_PATH(열, 구분자)는 루트에서 현재 행까지 거쳐 온 값을 구분자로 이어 붙인 경로 문자열을 돌려준다.
예를 들어 /본사/개발본부/데이터 처럼 계층 위치를 한 열로 보여 줄 때 쓰며 CONNECT BY 절과 함께만 쓸 수 있다.
- CONNECT_BY_ROOT는 현재 행이 속한 루트 행의 값을 돌려준다.
- CONNECT_BY_ISCYCLE은 NOCYCLE 사용 시 순환 여부를 1과 0으로 표시한다.
- LISTAGG는 그룹 안의 값을 이어 붙이는 집계 함수로 계층 경로와 무관하다.
[부서]
| 부서코드 | 부서명 | 상위부서 |
|---|---|---|
| D1 | 본사 | (null) |
| D2 | 영업본부 | D1 |
| D3 | 개발본부 | D1 |
| D4 | 국내영업 | D2 |
| D5 | 해외영업 | D2 |
| D6 | 백엔드 | D3 |
| D7 | 데이터 | D3 |
SELECT LEVEL, 부서명 FROM 부서 START WITH 상위부서 IS NULL CONNECT BY PRIOR 부서코드 = 상위부서 ORDER SIBLINGS BY 부서명;
| LEVEL | 부서명 |
|---|---|
| 2 | 개발본부 |
| 3 | 국내영업 |
| 3 | 데이터 |
| 3 | 백엔드 |
| 1 | 본사 |
| 2 | 영업본부 |
| 3 | 해외영업 |
| LEVEL | 부서명 |
|---|---|
| 1 | 본사 |
| 2 | 영업본부 |
| 3 | 국내영업 |
| 3 | 해외영업 |
| 2 | 개발본부 |
| 3 | 백엔드 |
| 3 | 데이터 |
| LEVEL | 부서명 |
|---|---|
| 1 | 본사 |
| 2 | 개발본부 |
| 2 | 영업본부 |
| 3 | 국내영업 |
| 3 | 데이터 |
| 3 | 백엔드 |
| 3 | 해외영업 |
| LEVEL | 부서명 |
|---|---|
| 1 | 본사 |
| 2 | 개발본부 |
| 3 | 데이터 |
| 3 | 백엔드 |
| 2 | 영업본부 |
| 3 | 국내영업 |
| 3 | 해외영업 |
ORDER SIBLINGS BY는 부모 아래 자식이 바로 이어지는 계층 순서를 지키면서 같은 부모를 둔 형제끼리만 부서명 순으로 정렬한다.
본사 아래 개발본부가 영업본부보다 앞서고, 개발본부의 자식은 데이터·백엔드, 영업본부의 자식은 국내영업·해외영업 순으로 따라온다.
- 부서명만으로 전부 섞인 표는 일반 ORDER BY 부서명의 결과다.
- LEVEL별로 모은 표는 너비 우선 순서로 계층 전개를 오해한 것이다.
- 영업본부가 먼저인 표는 형제 정렬을 무시하고 부서코드 순으로 본 것이다.
[분기실적]
| 지점 | Q1 | Q2 | Q3 |
|---|---|---|---|
| 강남 | 10 | 20 | (null) |
| 판교 | (null) | (null) | (null) |
| 분당 | 5 | (null) | 15 |
| 일산 | 7 | 8 | 9 |
SELECT 지점, 분기, 실적 FROM 분기실적 UNPIVOT (실적 FOR 분기 IN (Q1, Q2, Q3)) ORDER BY 지점, 분기;
UNPIVOT은 열 Q1~Q3을 행으로 펼치되 기본값이 EXCLUDE NULLS라서 실적이 NULL인 칸은 행으로 만들지 않아 2+0+2+3=7행이다.
세 분기가 모두 NULL인 판교는 결과에서 아예 사라진다.
- 4행은 원본 행 수로, 열을 행으로 펼친다는 점을 놓친 값이다.
- 9행은 판교만 빼고 나머지 NULL 칸은 남긴다고 본 값이다.
- 12행은 INCLUDE NULLS를 지정했을 때의 행 수다.
[회원]
| 회원ID | 연락처 |
|---|---|
| m01 | 010-1234-5678 |
| m02 | 02-987-6543 |
| m03 | 031-55-0909 |
SELECT 회원ID,
REGEXP_SUBSTR(연락처, '[0-9]+', 1, 2) AS 중간,
REGEXP_INSTR(연락처, '-', 1, 2) AS 위치
FROM 회원
ORDER BY 회원ID;| 회원ID | 중간 | 위치 |
|---|---|---|
| m01 | 1234 | 9 |
| m02 | 987 | 7 |
| m03 | 55 | 7 |
| 회원ID | 중간 | 위치 |
|---|---|---|
| m01 | 1234 | 8 |
| m02 | 987 | 6 |
| m03 | 55 | 6 |
| 회원ID | 중간 | 위치 |
|---|---|---|
| m01 | 1234 | 4 |
| m02 | 987 | 3 |
| m03 | 55 | 4 |
| 회원ID | 중간 | 위치 |
|---|---|---|
| m01 | 10 | 9 |
| m02 | 2 | 7 |
| m03 | 31 | 7 |
세 번째 인수 1은 시작 위치, 네 번째 인수 2는 두 번째 일치를 뜻하므로 숫자 묶음은 1234·987·55, 두 번째 하이픈 위치는 9·7·7이다.
REGEXP_INSTR는 문자열 첫 글자를 1로 세며, 010-1234-5678에서 두 번째 하이픈은 9번째 글자다.
- 10·2·31이 나온 표는 2를 시작 위치로 오해해 둘째 글자부터 첫 묶음을 자른 것이다.
- 8·6·6은 위치를 0부터 센 값이다.
- 4·3·4는 첫 번째 하이픈의 위치다.
TRUNCATE는 테이블의 모든 행을 지우고 저장 공간을 정리하지만 테이블 정의(구조)는 남겨 바로 다시 입력할 수 있다.
Oracle 에서 TRUNCATE·DROP은 DDL이라 자동 커밋되어 ROLLBACK으로 되돌릴 수 없고, DELETE는 DML이라 커밋 전 되돌릴 수 있다.
- DELETE는 행만 지우고 구조는 그대로 둔다.
- DROP은 테이블 자체를 없애며 WHERE 절을 쓸 수 없다.
- TRUNCATE는 실행과 동시에 커밋돼 롤백 대상이 아니다.
-- 대출 테이블에는 COMMIT 된 행이 2개 있다 INSERT INTO 대출 VALUES (3, 'B2', SYSDATE); CREATE TABLE 대출백업 (대출번호 NUMBER); INSERT INTO 대출 VALUES (4, 'B3', SYSDATE); ROLLBACK; SELECT COUNT(*) FROM 대출;
Oracle 은 DDL을 실행하면 그 앞의 트랜잭션을 암시적으로 COMMIT하므로 대출번호 3 INSERT는 CREATE TABLE 시점에 확정된다.
그 뒤 대출번호 4 INSERT만 새 트랜잭션에 남아 ROLLBACK으로 취소되어 행 수는 3이다.
- 2는 DDL의 자동 커밋을 모르고 두 INSERT가 모두 취소된다고 본 값이다.
- 4는 ROLLBACK이 아무것도 되돌리지 못한다고 본 값이다.
- 0은 ROLLBACK이 이미 커밋된 행까지 지운다고 오해한 값이다.
[도서] 제목 VARCHAR2(50)
| 도서번호 | 제목 | 저자 | 쪽수 |
|---|---|---|---|
| B1 | 데이터베이스 입문 | 오세린 | 320 |
| B2 | SQL 활용 | 문태오 | 280 |
| B3 | 모델링 기초 | 오세린 | (null) |
이미 행이 있는 테이블에 DEFAULT 없이 NOT NULL 열을 추가하면 기존 행의 새 열이 NULL이 되어 제약을 어기므로 오류가 난다.
이때는 DEFAULT 값을 함께 주거나 NULL 허용으로 추가한 뒤 값을 채우고 제약을 건다.
- 열 길이를 늘리는 MODIFY는 기존 데이터와 충돌하지 않는다.
- RENAME COLUMN은 열 이름만 바꾸는 정상 구문이다.
- DROP COLUMN은 NULL이 섞인 열도 문제없이 삭제한다.
CREATE TABLE 환자 (환자번호 INT PRIMARY KEY, 이름 VARCHAR(10));
CREATE TABLE 진료 (
진료번호 INT PRIMARY KEY,
환자번호 INT REFERENCES 환자(환자번호)
ON DELETE SET NULL,
진료과 VARCHAR(10));
-- 환자: 1, 2, 3 / 진료(진료번호, 환자번호): (1,1) (2,2) (3,2) (4,3) (5,NULL)
DELETE FROM 환자 WHERE 환자번호 = 2;ON DELETE SET NULL은 부모 행이 지워질 때 참조하던 자식 행을 지우지 않고 외래키 값만 NULL로 바꾸므로 진료는 5행 그대로다.
환자 2를 참조하던 진료번호 2·3이 NULL이 되고 원래 NULL이던 진료번호 5까지 더해 NULL 3행이다.
- NULL 2행은 원래 NULL이던 진료번호 5를 빠뜨린 값이다.
- 3행·NULL 1행은 자식까지 지우는 ON DELETE CASCADE의 결과다.
- 5행·NULL 1행은 아무 변화가 없는 경우로, RESTRICT라면 DELETE 자체가 오류다.
롤(ROLE)은 여러 권한을 하나로 묶은 이름으로, GRANT 롤 TO 사용자 한 번으로 묶인 권한을 모두 줄 수 있어 권한 관리가 쉬워진다.
롤에는 CREATE SESSION 같은 시스템 권한과 객체 권한을 모두 담을 수 있다.
- CONNECT·RESOURCE 롤처럼 시스템 권한을 담는 것이 일반적이다.
- 롤은 다른 롤에 부여해 계층적으로 묶을 수 있다.
- 롤을 REVOKE하면 그 롤로 받은 권한도 함께 사라진다.