물리적 모델링은 특정 DBMS 의 특성을 반영해 테이블·인덱스·파티션·저장 공간처럼 실제 구현에 필요한 구조를 설계하는 단계이다.
개념적 모델링은 핵심 엔터티와 관계를 추상화하고, 논리적 모델링은 키·속성·관계를 정의하며 정규화를 수행한다.
- 핵심 엔터티와 관계 도출은 개념적 모델링의 작업이다.
- 정규화로 속성을 정제하는 것은 논리적 모델링의 작업이다.
- 업무 범위를 추상화해 정의하는 것은 개념적 모델링에 가깝다.
ERD 는 엔터티 도출·배치 → 관계 설정 → 관계명 기술 → 관계 참여도 기술 → 관계 필수 여부 기술 순서로 작성하므로 참여도는 관계명 다음에 정한다.
배치할 때는 업무의 중심 엔터티를 왼쪽 상단에 두고, 엔터티 이름은 현업 용어의 단수 명사로 붙인다.
- 중심 엔터티를 왼쪽 상단에 두는 것은 권장 배치 방법이다.
- 관계를 먼저 연결한 뒤 관계명을 적는 것이 작성 순서에 맞다.
- 환자·진료처럼 단수형 업무 용어로 이름 붙이는 것은 명명 원칙에 맞다.
속성 이름은 해당 업무에서 쓰는 용어를 짧은 명사형으로 붙이는 것이 원칙이다.
서술식 이름과 약어는 의미 전달을 흐리고, 같은 이름이 여러 엔터티에 반복되면 혼동되므로 모델 전체에서 가급적 유일한 이름을 쓴다.
- 서술식 이름은 속성명으로 쓰지 않는다.
- 뜻을 알기 어려운 약어는 가능한 한 피한다.
- 모든 엔터티에 같은 이름을 쓰면 의미가 구분되지 않는다.
M:N 관계는 교차 엔터티를 두어 1:M 관계 두 개로 바꾸며, 교차 엔터티는 양쪽 부모와의 관계에서 모두 M 쪽(자식)이 된다.
진료는 환자번호와 의사번호를 외래키로 받아 한 환자의 여러 진료, 한 의사의 여러 진료를 각각 표현한다.
- 해소 후 환자와 진료는 1:M 관계가 된다.
- 한 의사가 여러 진료를 맡으므로 1:1 이 아니라 1:M 이다.
- 진료는 자체 속성을 가진 독립 엔터티로 남는다.
처방순번이 진료마다 1부터 매겨지면 (진료번호, 처방순번)만으로 처방 한 건이 유일하게 식별되므로 약품코드까지 넣은 주식별자는 최소성을 어긴다.
약품과의 관계는 비식별 관계로 바꿔 약품코드를 일반 외래키 속성으로 두는 편이 적절하다.
- 세 속성 조합은 당연히 유일하므로 유일성은 만족한다.
- 약품코드는 유일 식별에 필요 없으므로 빼야 한다.
- 진료번호는 진료에서 상속받은 외부식별자다.
담당의사는 진료과를 결정하는 결정자이지만 후보키가 아니므로 BCNF 를 위반하고, 종속되는 진료과가 키에 속한 주요 속성이라 3정규형은 만족한다.
후보키는 (환자번호, 진료과)와 (환자번호, 담당의사) 둘이며 모든 속성이 키에 포함되어 부분·이행 종속 대상이 없다.
- 키가 아닌 일반 속성이 없어 부분 종속으로 2정규형을 깨지 않는다.
- 이행 종속 대상인 일반 속성이 없어 3정규형은 성립한다.
- 담당의사가 후보키가 아닌 결정자라 BCNF 가 아니다.
같은 엔터티를 참조하는 자기참조(순환) 관계에서 지도받지 않는 최상위 의사는 참조할 대상이 없으므로 지도전문의번호가 NULL 이다.
이 모델은 의사 테이블에 별칭을 두 번 붙인 셀프 조인으로 의사와 지도전문의를 함께 조회한다.
- 한 테이블 안의 외래키만으로 계층을 표현할 수 있다.
- 조인 대상은 같은 의사 테이블 하나다.
- 지도전문의번호는 의사 엔터티 자신의 PK 를 참조한다.
양쪽이 모두 필수 관계이면 처방상세가 없는 처방전은 존재할 수 없으므로 둘을 하나의 트랜잭션으로 묶어 함께 커밋해야 한다.
처방전만 커밋하는 순간 상세 없는 처방전이 저장되어 모델이 표현한 업무 규칙이 깨진다.
- 자식인 처방상세만 먼저 커밋하면 부모 없는 자식이 생긴다.
- 별도 트랜잭션으로 나누면 한쪽만 반영되는 상태가 생긴다.
- 상세를 나중에 추가하면 그 사이 필수 관계가 깨진다.
숫자 0 은 감면이 없다는 사실이 확정된 값이고, NULL 은 값이 아직 정해지지 않았거나 알 수 없는 상태를 뜻한다.
NULL 은 어떤 값과도 같지 않아 = 비교 결과가 UNKNOWN 이므로 IS NULL 로 찾아야 한다.
- NULL 은 0 으로 바뀌어 저장되지 않는다.
- NULL 끼리 = 로 비교해도 참이 되지 않는다.
- Oracle 에서 NULL 로 처리되는 것은 빈 문자열이고 공백 한 칸은 문자다.
[진료예약]
| 예약ID | 환자번호 | 의사번호 | 예약일시 |
|---|---|---|---|
| 1 | P01 | D1 | 2026-03-02 09:00 |
| 2 | P02 | D1 | 2026-03-02 09:30 |
| 3 | P03 | D2 | 2026-03-02 09:00 |
| 4 | P04 | D1 | 2026-03-02 09:30 |
| 5 | P01 | D2 | 2026-03-03 10:00 |
| 6 | P02 | D1 | 2026-03-03 10:00 |
예약ID 2와 4는 같은 의사 D1 의 같은 일시 03-02 09:30 예약이라 규칙을 어기지만, 예약ID 가 서로 달라 인조식별자 기본키로는 막히지 않는다.
이런 업무 중복은 (의사번호, 예약일시)에 UNIQUE 제약을 따로 걸어야 방지할 수 있다.
- 1과 3은 같은 일시이지만 의사가 달라 허용된다.
- 5와 6도 같은 일시이지만 의사가 D2·D1 로 다르다.
- 1과 5는 같은 환자의 다른 일시 예약이다.
데이터의 정의·조작·제어와 동시성 제어·장애 복구를 맡는 소프트웨어는 DBMS(Database Management System)이다.
데이터베이스는 DBMS 가 관리하는 통합·저장된 데이터의 집합 자체이며, Oracle·SQL Server 같은 제품이 DBMS 에 해당한다.
- 데이터베이스는 관리 대상인 데이터 집합이지 관리 소프트웨어가 아니다.
- 스키마는 데이터의 구조와 제약을 정의한 명세다.
- SQL 은 DBMS 에 명령을 내리는 언어일 뿐 소프트웨어가 아니다.
Oracle 의 연결 연산자 || 는 NULL 을 빈 문자열처럼 다루므로 NULL || '외래' 의 결과는 '외래' 이다.
반면 산술 연산은 피연산자에 NULL 이 하나라도 있으면 결과가 NULL 이며, 0 을 곱하거나 0 으로 나눠도 오류 없이 NULL 이 된다.
- NULL 에 10 을 더해도 NULL 이다.
- 0 에 NULL 을 곱해도 0 이 아니라 NULL 이다.
- NULL 을 0 으로 나누면 오류가 아니라 NULL 이다.
[처방]
| 처방번호 | 약품코드 | 투약량 | 횟수 | 일수 |
|---|---|---|---|---|
| 1 | M01 | 2 | 3 | 2 |
| 2 | M01 | 2 | 3 | 2 |
| 3 | M01 | 1 | 3 | 4 |
| 4 | M02 | 1 | 2 | 5 |
| 5 | M02 | 2 | 1 | 5 |
| 6 | M03 | (null) | 2 | 3 |
SELECT DISTINCT 약품코드 || '(' || 일수 || '일)' AS 표기,
투약량 * 횟수 + 일수 AS 값
FROM 처방
ORDER BY 표기, 값;| 표기 | 값 |
|---|---|
| M01(2일) | 8 |
| M01(4일) | 7 |
| M02(5일) | 7 |
| M03(3일) | (null) |
| 표기 | 값 |
|---|---|
| M01(2일) | 8 |
| M01(4일) | 7 |
| M02(5일) | 7 |
| M03(3일) | 3 |
| 표기 | 값 |
|---|---|
| M01(2일) | 10 |
| M01(4일) | 7 |
| M02(5일) | 7 |
| M03(3일) | (null) |
| 표기 | 값 |
|---|---|
| M01(2일) | 8 |
| M01(4일) | 7 |
| M02(5일) | 7 |
| M02(5일) | 7 |
| M03(3일) | (null) |
DISTINCT 는 SELECT 결과 행 전체를 비교하므로 원본이 다른 처방 4·5도 표기와 값이 모두 M02(5일)·7 로 같아 한 행만 남는다.
값은 곱셈이 먼저 계산되어 M01(2일)은 2×3+2=8 이고, 투약량이 NULL 인 M03 은 결과가 NULL 이다.
- NULL 이 섞인 산술은 0 으로 바뀌지 않고 NULL 이다.
- 덧셈을 먼저 하면 10 이 나오지만 연산 우선순위와 맞지 않는다.
- 계산 결과가 같은 M02 두 행은 DISTINCT 로 합쳐진다.
[수납]
| 수납번호 | 진료비 | 감면액 |
|---|---|---|
| 1 | 12000 | 2000 |
| 2 | 8000 | (null) |
| 3 | 15000 | 0 |
| 4 | 5000 | 5000 |
| 5 | 9000 | (null) |
SELECT 수납번호, 진료비 - ( ㉠ ) AS 청구액 FROM 수납 ORDER BY 수납번호;
[원하는 결과]
| 수납번호 | 청구액 |
|---|---|
| 1 | 10000 |
| 2 | 8000 |
| 3 | 15000 |
| 4 | 0 |
| 5 | 9000 |
COALESCE(감면액, 0) 은 감면액이 NULL 이면 0 을 돌려주므로 수납 2·5는 진료비 전액이 청구액이 되고 나머지는 감면액을 뺀 값이 나온다.
NULL 을 그대로 빼면 NULL 전파로 청구액이 NULL 이 되므로 대체값 처리가 필요하다.
- NULLIF 는 0 을 NULL 로 바꿔 수납 2·3·5 의 청구액이 NULL 이 된다.
- SUM 은 집계 함수라 GROUP BY 없이 일반 열과 함께 쓰면 오류가 난다.
- 단순 CASE 의 WHEN NULL 은 어떤 행과도 일치하지 않아 모든 청구액이 NULL 이 된다.
[입원]
| 입원번호 | 입원일 | 퇴원일 |
|---|---|---|
| 1 | 2026-02-26 | 2026-03-02 |
| 2 | 2026-03-10 | 2026-03-10 |
| 3 | 2026-03-15 | (null) |
| 4 | 2026-03-28 | 2026-04-03 |
SELECT 입원번호, TO_CHAR(입원일, 'MM-DD') AS 입원,
퇴원일 - 입원일 AS 재원일수
FROM 입원
ORDER BY 입원번호;| 입원번호 | 입원 | 재원일수 |
|---|---|---|
| 1 | 02-26 | 4 |
| 2 | 03-10 | 0 |
| 3 | 03-15 | (null) |
| 4 | 03-28 | 6 |
| 입원번호 | 입원 | 재원일수 |
|---|---|---|
| 1 | 02-26 | 6 |
| 2 | 03-10 | 0 |
| 3 | 03-15 | (null) |
| 4 | 03-28 | 6 |
| 입원번호 | 입원 | 재원일수 |
|---|---|---|
| 1 | 02-26 | 4 |
| 2 | 03-10 | 1 |
| 3 | 03-15 | (null) |
| 4 | 03-28 | 6 |
| 입원번호 | 입원 | 재원일수 |
|---|---|---|
| 1 | 02-26 | 4 |
| 2 | 03-10 | 0 |
| 3 | 03-15 | 0 |
| 4 | 03-28 | 6 |
Oracle 에서 날짜 - 날짜는 두 날짜 사이의 일수를 숫자로 돌려주므로 2026-02-26 부터 03-02 까지는 2월 28일을 거쳐 4, 같은 날은 0 이다.
퇴원일이 NULL 인 입원번호 3 은 연산 결과가 NULL 이고, TO_CHAR 의 MM-DD 서식은 월·일만 문자로 보여 준다.
- NULL 과의 날짜 연산은 0 이 아니라 NULL 이다.
- 2026년 2월은 28일까지라 6 이 아니라 4 이다.
- 같은 날 빼기는 시작일을 포함해 세지 않으므로 0 이다.
DECODE(진료구분, 'O', '외래', '기타')
[보기 식]
㉠ CASE 진료구분 WHEN 'O' THEN '외래' ELSE '기타' END ㉡ CASE WHEN 진료구분 = 'O' THEN '기타' ELSE '외래' END ㉢ CASE 진료구분 WHEN 'O' THEN '외래' END ㉣ CASE 진료구분 WHEN '외래' THEN 'O' ELSE '기타' END
DECODE(열, 비교값, 결과, 기본값)은 열이 비교값과 같으면 결과를, 아니면 기본값을 돌려주므로 단순 CASE 에 ELSE '기타' 를 둔 식과 같다.
진료구분이 'O' 이면 '외래', 그 밖의 값이나 NULL 이면 '기타' 가 나온다.
- THEN 과 ELSE 결과를 서로 바꾸면 '외래' 와 '기타' 가 뒤집힌다.
- ELSE 를 빼면 'O' 가 아닌 행은 NULL 이 된다.
- 비교값과 결과를 맞바꾸면 진료구분이 '외래' 인 행만 'O' 가 된다.
SELECT * FROM 진료 WHERE 진료과 <> '내과';
진료과가 NULL 인 행은 <> '내과' 비교 결과가 UNKNOWN 이므로 WHERE 를 통과하지 못하고, 그 밖의 진료과 4행만 남는다.
NULL 행까지 포함하려면 OR 진료과 IS NULL 조건을 따로 붙여야 한다.
- NULL 인 2행만 남는 것이 아니다.
- NULL 2행은 부정 조건에서도 포함되지 않아 6행이 아니다.
- WHERE 조건이 있으므로 전체 10행이 나오지 않는다.
[진료]
| 진료번호 | 진료과 | 금액 | 응급 |
|---|---|---|---|
| 1 | 내과 | 8000 | N |
| 2 | 외과 | 6000 | N |
| 3 | 외과 | 3000 | Y |
| 4 | 내과 | 2000 | Y |
| 5 | 소아과 | 5000 | N |
| 6 | 소아과 | 4000 | N |
| 7 | (null) | 9000 | N |
SELECT 진료번호, 진료과
FROM 진료
WHERE NOT 진료과 = '내과' AND 금액 >= 5000
OR 응급 = 'Y'
ORDER BY 진료번호;| 진료번호 | 진료과 |
|---|---|
| 2 | 외과 |
| 3 | 외과 |
| 4 | 내과 |
| 5 | 소아과 |
| 진료번호 | 진료과 |
|---|---|
| 2 | 외과 |
| 3 | 외과 |
| 4 | 내과 |
| 5 | 소아과 |
| 7 | (null) |
| 진료번호 | 진료과 |
|---|---|
| 2 | 외과 |
| 3 | 외과 |
| 4 | 내과 |
| 5 | 소아과 |
| 6 | 소아과 |
| 진료번호 | 진료과 |
|---|---|
| 2 | 외과 |
| 3 | 외과 |
| 5 | 소아과 |
우선순위가 NOT > AND > OR 이므로 조건은 ((NOT 진료과 = '내과') AND 금액 >= 5000) OR 응급 = 'Y' 로 묶여 2·5는 앞쪽, 3·4는 응급 조건으로 남는다.
진료과가 NULL 인 7은 NOT 을 거쳐도 UNKNOWN 이라 금액이 커도 빠지고, 금액 4000 인 6도 제외된다.
- NOT 이 AND 전체에 걸린다고 보면 6까지 잘못 포함된다.
- NULL 을 내과가 아닌 값으로 보면 7이 잘못 포함된다.
- OR 를 먼저 묶으면 응급인 내과 4가 빠진다.
[진료]
| 진료번호 | 진료과 | 진료비 |
|---|---|---|
| 1 | 내과 | 12000 |
| 2 | 외과 | 30000 |
| 3 | 내과 | 8000 |
| 4 | 안과 | (null) |
| 5 | 외과 | 15000 |
㉠ SELECT COUNT(*), MAX(진료비)
FROM 진료;
㉡ SELECT 진료과, SUM(진료비)
FROM 진료;
㉢ SELECT '전체' AS 구분, SUM(진료비)
FROM 진료;
㉣ SELECT SUM(진료비) / COUNT(*)
FROM 진료
WHERE 진료비 > 0;GROUP BY 가 없으면 테이블 전체가 하나의 그룹이 되어 결과가 1행이므로, 행마다 값이 다른 진료과를 집계 함수 없이 함께 SELECT 하면 오류가 난다.
문자 상수처럼 그룹 안에서 값이 하나로 정해지는 식은 집계 함수와 함께 써도 된다.
- ㉠은 전체 행 수와 최댓값을 1행으로 돌려준다.
- ㉢의 '전체' 는 상수라 집계 결과와 함께 출력된다.
- ㉣은 WHERE 로 거른 행에 대해 집계끼리 나눈 식이라 정상이다.
[처방]
| 처방번호 | 의사 | 약품비 |
|---|---|---|
| 1 | D1 | 5000 |
| 2 | D1 | 2000 |
| 3 | D1 | 4000 |
| 4 | D2 | 3000 |
| 5 | D2 | 1000 |
| 6 | D3 | 6000 |
| 7 | D3 | 3500 |
| 8 | D3 | (null) |
| 9 | D4 | 2500 |
SELECT 의사, COUNT(*) AS 건수, SUM(약품비) AS 합계 FROM 처방 WHERE 약품비 >= 3000 GROUP BY 의사 HAVING COUNT(*) >= 2 ORDER BY 의사;
| 의사 | 건수 | 합계 |
|---|---|---|
| D1 | 3 | 11000 |
| D2 | 2 | 4000 |
| D3 | 3 | 9500 |
| 의사 | 건수 | 합계 |
|---|---|---|
| D1 | 2 | 9000 |
| D2 | 1 | 3000 |
| D3 | 2 | 9500 |
| 의사 | 건수 | 합계 |
|---|---|---|
| D1 | 2 | 9000 |
| D3 | 2 | 9500 |
| 의사 | 건수 | 합계 |
|---|---|---|
| D1 | 3 | 9000 |
| D3 | 3 | 9500 |
WHERE 가 그룹화 전에 실행되어 약품비 3000 이상인 행만 남으므로 D1 은 5000·4000, D2 는 3000, D3 는 6000·3500 이 남고 D4 와 NULL 행은 빠진다.
그 뒤 HAVING 이 남은 행 기준 COUNT(*) 2 이상인 그룹만 고르므로 D1(2건, 9000)과 D3(2건, 9500)만 출력된다.
- COUNT(*) 는 WHERE 에서 걸러진 행을 세지 않는다.
- WHERE 를 무시하고 전체 행으로 집계한 결과가 아니다.
- 건수 1인 D2 는 HAVING 에서 제외된다.
[처방상세]
| 상세번호 | 약품코드 | 용법 | 수량 |
|---|---|---|---|
| 1 | A | 식후 | 5 |
| 2 | A | 식후 | 6 |
| 3 | A | 식전 | 4 |
| 4 | A | (null) | 10 |
| 5 | A | (null) | (null) |
| 6 | B | 식후 | 12 |
| 7 | B | (null) | (null) |
| 8 | B | (null) | (null) |
| 9 | C | 식전 | 3 |
| 10 | C | 식전 | 7 |
SELECT 약품코드, 용법, COUNT(*) AS 건수 FROM 처방상세 GROUP BY 약품코드, 용법 HAVING SUM(수량) >= 10 ORDER BY 약품코드, 용법;
그룹은 (A,식후)·(A,식전)·(A,NULL)·(B,식후)·(B,NULL)·(C,식전) 6개이고, HAVING SUM(수량) >= 10 을 만족하는 그룹은 합계 11·10·12·10 인 4개다.
(A,NULL)은 NULL 수량을 빼고 10 이라 남지만, 수량이 모두 NULL 인 (B,NULL)은 SUM 이 NULL 이라 비교가 UNKNOWN 이 되어 빠지고 (A,식전)은 4 라 빠진다.
- 용법이 NULL 인 행도 하나의 그룹이 되므로 3이 아니다.
- (B,NULL)의 합계는 0 이 아니라 NULL 이라 5가 아니다.
- HAVING 으로 걸러지는 그룹이 있어 6이 아니다.
[접수]
| 접수번호 | 진료과 | 대기번호 |
|---|---|---|
| 1 | 내과 | 3 |
| 2 | (null) | 1 |
| 3 | 외과 | 2 |
| 4 | 내과 | (null) |
| 5 | 내과 | 1 |
| 6 | (null) | 4 |
SELECT 접수번호, 진료과, 대기번호 FROM 접수 ORDER BY 진료과 NULLS FIRST, 대기번호 DESC;
| 접수번호 | 진료과 | 대기번호 |
|---|---|---|
| 2 | (null) | 1 |
| 6 | (null) | 4 |
| 4 | 내과 | (null) |
| 1 | 내과 | 3 |
| 5 | 내과 | 1 |
| 3 | 외과 | 2 |
| 접수번호 | 진료과 | 대기번호 |
|---|---|---|
| 4 | 내과 | (null) |
| 1 | 내과 | 3 |
| 5 | 내과 | 1 |
| 3 | 외과 | 2 |
| 6 | (null) | 4 |
| 2 | (null) | 1 |
| 접수번호 | 진료과 | 대기번호 |
|---|---|---|
| 6 | (null) | 4 |
| 2 | (null) | 1 |
| 1 | 내과 | 3 |
| 5 | 내과 | 1 |
| 4 | 내과 | (null) |
| 3 | 외과 | 2 |
| 접수번호 | 진료과 | 대기번호 |
|---|---|---|
| 6 | (null) | 4 |
| 2 | (null) | 1 |
| 4 | 내과 | (null) |
| 1 | 내과 | 3 |
| 5 | 내과 | 1 |
| 3 | 외과 | 2 |
진료과에 NULLS FIRST 를 주었으므로 진료과 NULL 인 6·2가 맨 앞에 오고, 그 안은 대기번호 DESC 라 4인 6이 먼저다.
Oracle 은 NULL 을 가장 큰 값으로 보아 DESC 정렬에서 NULL 이 맨 앞이므로 내과 안에서는 대기번호 NULL 인 4가 3·1보다 앞선다.
- DESC 에서 NULL 을 맨 뒤로 보내는 것은 SQL Server 방식이다.
- 진료과 NULL 그룹도 두 번째 키 DESC 로 정렬된다.
- NULLS FIRST 를 지정했으므로 NULL 진료과가 뒤로 가지 않는다.
SELECT 의사번호, 의사명, 진료일 FROM 진료 J, 의사 D WHERE J.의사번호 = D.의사번호;
두 테이블에 모두 있는 의사번호를 SELECT 절에서 한정자 없이 쓰면 어느 테이블 열인지 정할 수 없어 모호성 오류가 난다.
조인 조건처럼 J.의사번호 또는 D.의사번호 로 별칭을 붙여야 하며, 한쪽에만 있는 의사명·진료일은 한정자를 생략해도 된다.
- DBMS 가 FROM 절 첫 테이블의 열을 골라 주는 규칙은 없다.
- 조인 조건의 오른쪽 열을 대신 쓰는 규칙도 없다.
- SELECT 목록에 한 번 적은 열은 오류가 없어도 한 번만 출력된다.
[환자]
| 환자번호 | 환자명 |
|---|---|
| 1 | 한서준 |
| 2 | 오다인 |
| 3 | 문가람 |
| 4 | 배하율 |
[의사]
| 의사번호 | 의사명 |
|---|---|
| 101 | 권태오 |
| 102 | 남시아 |
| 103 | 류지안 |
[진료]
| 진료번호 | 환자번호 | 의사번호 |
|---|---|---|
| 1 | 1 | 101 |
| 2 | 1 | 102 |
| 3 | 2 | (null) |
| 4 | 3 | 103 |
| 5 | 5 | 101 |
| 6 | 2 | 101 |
SELECT P.환자명, D.의사명 FROM 환자 P, 진료 J, 의사 D WHERE P.환자번호 = J.환자번호 AND J.의사번호 = D.의사번호 ORDER BY J.진료번호;
세 테이블을 N-1=2개의 등가 조인 조건으로 모두 연결했으므로 환자와 의사 양쪽에 짝이 있는 진료 1·2·4·6의 4행만 남는다.
진료 3은 의사번호가 NULL 이라 의사와 비교가 성립하지 않고, 진료 5는 환자번호 5가 환자 테이블에 없어 빠진다.
- 의사번호 NULL 행은 등가 조인에서 제외되므로 5가 아니다.
- 짝이 없는 진료 행이 빠지므로 진료 전체 6이 아니다.
- 조인 조건이 두 개 모두 있어 카티션 곱 15가 생기지 않는다.
[환자]
| 환자번호 | 환자명 |
|---|---|
| P1 | 윤채아 |
| P2 | 길도현 |
| P3 | 성예서 |
| P4 | 탁민규 |
| P5 | 현소율 |
[진료]
| 진료번호 | 환자번호 | 진료월 |
|---|---|---|
| 1 | P1 | 3 |
| 2 | P1 | 4 |
| 3 | P2 | 4 |
| 4 | P3 | 3 |
| 5 | P5 | (null) |
SELECT P.환자번호, P.환자명
FROM 환자 P LEFT OUTER JOIN 진료 J
ON P.환자번호 = J.환자번호
( 빈칸 )
ORDER BY P.환자번호;[빈칸 후보]
㉠ AND J.진료월 = 3 WHERE J.진료번호 IS NULL ㉡ WHERE J.진료월 = 3 AND J.진료번호 IS NULL ㉢ AND J.진료월 = 3 AND J.진료번호 IS NULL ㉣ WHERE J.진료월 = 3 OR J.진료번호 IS NULL
[원하는 결과]
| 환자번호 | 환자명 |
|---|---|
| P2 | 길도현 |
| P4 | 탁민규 |
| P5 | 현소율 |
3월 조건을 ON 절에 두면 3월 진료만 조인되고 나머지 환자는 J 열이 NULL 로 보존되므로, WHERE J.진료번호 IS NULL 로 짝이 없는 P2·P4·P5 만 고를 수 있다.
P2 는 4월 진료만, P5 는 진료월이 NULL 인 진료만 있어 ON 조건을 통과하지 못하고 P4 는 진료가 아예 없다.
- 월 조건을 WHERE 로 옮기면 IS NULL 과 동시에 참일 수 없어 0행이다.
- 두 조건을 모두 ON 에 두면 아무 행도 거르지 않아 환자 5명이 다 나온다.
- WHERE 의 OR 조건은 3월 진료가 있는 P1·P3 와 P4 를 고른다.
[진료과]
| 과코드 | 과명 |
|---|---|
| K1 | 내과 |
| K2 | 외과 |
| K3 | 소아과 |
[의사]
| 의사번호 | 의사명 | 과코드 |
|---|---|---|
| 1 | 윤하 | K1 |
| 2 | 서진 | K1 |
| 3 | 도윤 | K2 |
| 4 | 채원 | K9 |
SELECT 과코드, 과명, 의사명 FROM 진료과 FULL OUTER JOIN 의사 USING (과코드) ORDER BY 과코드, 의사명;
| 과코드 | 과명 | 의사명 |
|---|---|---|
| K1 | 내과 | 서진 |
| K1 | 내과 | 윤하 |
| K2 | 외과 | 도윤 |
| K3 | 소아과 | (null) |
| (null) | (null) | 채원 |
| 과코드 | 과명 | 의사명 |
|---|---|---|
| K1 | 내과 | 서진 |
| K1 | 내과 | 윤하 |
| K2 | 외과 | 도윤 |
| K9 | (null) | 채원 |
| 과코드 | 과명 | 의사명 |
|---|---|---|
| K1 | 내과 | 서진 |
| K1 | 내과 | 윤하 |
| K2 | 외과 | 도윤 |
| K3 | 소아과 | (null) |
| K9 | (null) | 채원 |
| 과코드 | 과명 | 의사명 |
|---|---|---|
| K1 | 내과 | 서진 |
| K1 | 내과 | 윤하 |
| K2 | 외과 | 도윤 |
| K3 | 소아과 | (null) |
USING 으로 조인한 공통 열 과코드는 결과에 한 열로만 나타나며, FULL OUTER JOIN 에서는 어느 쪽 행이든 존재하는 값으로 채워진다.
K1 두 행과 K2 한 행이 짝을 이루고, 의사가 없는 K3 소아과는 의사명이 NULL, 진료과가 없는 채원은 과명이 NULL 이면서 과코드는 K9 로 남는다.
- 채원 행의 과코드를 NULL 로 본 결과는 왼쪽 테이블 값만 쓴다고 오해한 것이다.
- K9 행이 빠진 결과는 LEFT OUTER JOIN 에 해당한다.
- K3 행이 빠진 결과는 RIGHT OUTER JOIN 에 해당한다.
[병동]
| 병동코드 | 병동명 |
|---|---|
| W1 | 본관3 |
| W2 | 별관5 |
| W3 | 신관7 |
[입원]
| 입원번호 | 환자명 | 병동코드 | 퇴원일 |
|---|---|---|---|
| 1 | 강민 | W1 | (null) |
| 2 | 오라 | W1 | 2026-03-04 |
| 3 | 남궁솔 | W2 | (null) |
| 4 | 표지안 | W8 | (null) |
| 5 | 하율 | (null) | (null) |
SELECT B.환자명, A.병동명 FROM 병동 A, 입원 B WHERE A.병동코드(+) = B.병동코드 AND B.퇴원일 IS NULL ORDER BY B.입원번호;
| 환자명 | 병동명 |
|---|---|
| 강민 | 본관3 |
| 남궁솔 | 별관5 |
| (null) | 신관7 |
| 환자명 | 병동명 |
|---|---|
| 강민 | 본관3 |
| 남궁솔 | 별관5 |
| 환자명 | 병동명 |
|---|---|
| 강민 | 본관3 |
| 오라 | 본관3 |
| 남궁솔 | 별관5 |
| 표지안 | (null) |
| 하율 | (null) |
| 환자명 | 병동명 |
|---|---|
| 강민 | 본관3 |
| 남궁솔 | 별관5 |
| 표지안 | (null) |
| 하율 | (null) |
(+) 는 값이 없을 수 있는 병동 쪽에 붙었으므로 입원 LEFT OUTER JOIN 병동 과 같고, 퇴원일 조건은 기준 테이블 열이라 WHERE 필터로 적용된다.
퇴원한 오라는 걸러지고, 없는 병동코드 W8 인 표지안과 병동코드가 NULL 인 하율은 병동명이 NULL 인 채 남아 4행이 된다.
- 2행 결과는 짝 없는 입원 행을 버린 INNER JOIN 이다.
- 신관7 이 나온 결과는 병동을 기준 테이블로 잘못 본 것이다.
- 오라가 포함된 결과는 퇴원일 조건을 무시한 것이다.
SELECT P.진료과, P.평균진료비
FROM (SELECT 진료과, AVG(진료비) AS 평균진료비
FROM 진료
GROUP BY 진료과) P
WHERE P.평균진료비 >= 30000;FROM 절에 괄호로 넣고 별칭을 붙여 테이블처럼 조회하는 서브쿼리를 인라인 뷰(Inline View)라 한다.
SQL 실행 시점에만 만들어지는 일회성 뷰라 따로 저장되지 않으며, 바깥에서는 별칭 P 와 열 별칭 평균진료비로 참조한다.
- 스칼라 서브쿼리는 주로 SELECT 절에서 한 행 한 열을 돌려준다.
- 연관 서브쿼리는 메인쿼리 열을 참조하는데 이 서브쿼리는 그렇지 않다.
- 다중 열 서브쿼리는 WHERE 절에서 여러 열을 한꺼번에 비교하는 형태다.
[의사]
| 의사번호 | 의사명 |
|---|---|
| 1 | 윤하 |
| 2 | 서진 |
| 3 | 도윤 |
[진료]
| 진료번호 | 의사번호 | 진료비 |
|---|---|---|
| 1 | 1 | 12000 |
| 2 | 1 | (null) |
| 3 | 2 | 8000 |
| 4 | 2 | 3000 |
| 5 | 9 | 5000 |
SELECT D.의사명,
(SELECT COUNT(*) FROM 진료 J
WHERE J.의사번호 = D.의사번호) AS 건수,
(SELECT SUM(J.진료비) FROM 진료 J
WHERE J.의사번호 = D.의사번호) AS 합계
FROM 의사 D
ORDER BY D.의사번호;| 의사명 | 건수 | 합계 |
|---|---|---|
| 윤하 | 2 | 12000 |
| 서진 | 2 | 11000 |
| 도윤 | 0 | (null) |
| 의사명 | 건수 | 합계 |
|---|---|---|
| 윤하 | 1 | 12000 |
| 서진 | 2 | 11000 |
| 도윤 | 0 | (null) |
| 의사명 | 건수 | 합계 |
|---|---|---|
| 윤하 | 2 | 12000 |
| 서진 | 2 | 11000 |
| 의사명 | 건수 | 합계 |
|---|---|---|
| 윤하 | 2 | 12000 |
| 서진 | 2 | 11000 |
| 도윤 | 0 | 0 |
스칼라 서브쿼리는 메인쿼리 의사 행마다 값 하나를 계산하므로 진료가 없는 도윤도 결과에 남고, 대상 행이 없으면 COUNT(*) 는 0, SUM 은 NULL 이 된다.
윤하는 진료비가 NULL 인 행도 COUNT(*) 에 세어 2건이고 SUM 은 NULL 을 제외해 12000이며, 의사번호 9 진료는 어느 의사와도 연결되지 않는다.
- 도윤의 합계를 0 으로 본 결과는 빈 집합의 SUM 을 0 으로 오해한 것이다.
- 도윤 행이 빠진 결과는 INNER JOIN 처럼 생각한 것이다.
- 윤하 건수를 1로 본 결과는 COUNT(*) 가 NULL 행을 뺀다고 오해한 것이다.
[환자]
| 환자번호 | 환자명 |
|---|---|
| 1 | 강민 |
| 2 | 오라 |
| 3 | 하율 |
| 4 | 표지안 |
[처방]
| 처방번호 | 환자번호 | 처방일 |
|---|---|---|
| 1 | 1 | 2026-03-02 |
| 2 | 1 | 2026-03-03 |
| 3 | 3 | 2026-03-03 |
| 4 | (null) | 2026-03-05 |
SELECT P.환자명
FROM 환자 P
WHERE P.환자번호 NOT IN (SELECT 환자번호
FROM 처방
( ㉠ ))
ORDER BY P.환자명;[결과]
| 환자명 |
|---|
| 오라 |
| 표지안 |
서브쿼리 결과에 NULL 이 섞이면 NOT IN 은 NULL 과의 비교가 UNKNOWN 이 되어 한 행도 남지 않으므로, IS NOT NULL 로 NULL 을 먼저 걸러야 한다.
NULL 을 뺀 서브쿼리 결과는 1, 1, 3 이고, 여기에 없는 오라와 표지안만 조회된다.
- 처방일이 NULL 인 행은 없어 서브쿼리가 비므로 네 명 모두 조회된다.
- GROUP BY 는 NULL 도 한 그룹으로 남겨 결과가 0행이 된다.
- 처방번호 조건은 환자번호 NULL 행을 그대로 남겨 0행이 된다.
집합 연산에 참여하는 각 SELECT 는 독립된 질의라 저마다 WHERE·GROUP BY·HAVING 을 가질 수 있으므로 HAVING 을 쓸 수 없다는 설명은 틀리다.
제약은 열 개수와 대응 열 타입이 맞아야 한다는 점과 ORDER BY 를 맨 끝에 한 번만 쓴다는 점이다.
- WHERE 는 각 SELECT 안에서 그 SELECT 의 행만 거른다.
- NULL 이나 상수 열을 넣어 열 개수를 맞추는 것은 흔한 방법이다.
- 끝의 ORDER BY 에서는 열 이름 대신 위치 번호를 써도 된다.
[외래처방]
| 약품코드 | 용량 |
|---|---|
| A10 | 1 |
| A10 | 1 |
| B20 | 2 |
| C30 | 1 |
[입원처방]
| 약품코드 | 용량 |
|---|---|
| A10 | 2 |
| B20 | 2 |
| D40 | 1 |
SELECT 약품코드, 용량 FROM 외래처방 UNION SELECT 약품코드, 용량 FROM 입원처방 ORDER BY 약품코드, 용량;
| 약품코드 | 용량 |
|---|---|
| A10 | 1 |
| A10 | 1 |
| A10 | 2 |
| B20 | 2 |
| B20 | 2 |
| C30 | 1 |
| D40 | 1 |
| 약품코드 | 용량 |
|---|---|
| A10 | 1 |
| A10 | 2 |
| B20 | 2 |
| C30 | 1 |
| D40 | 1 |
| 약품코드 | 용량 |
|---|---|
| A10 | 1 |
| B20 | 2 |
| C30 | 1 |
| D40 | 1 |
| 약품코드 | 용량 |
|---|---|
| A10 | 1 |
| A10 | 1 |
| A10 | 2 |
| B20 | 2 |
| C30 | 1 |
| D40 | 1 |
UNION 은 두 결과를 합친 뒤 행 전체가 같은 것만 하나로 줄이므로, 약품코드가 같아도 용량이 다른 A10 1 과 A10 2 는 따로 남는다.
외래 안에서 반복된 A10 1 과 양쪽에 모두 있는 B20 2 는 각각 한 행으로 합쳐져 5행이 된다.
- 4행 결과는 첫 열만 보고 중복을 제거한 것이다.
- 7행 결과는 중복을 남기는 UNION ALL 의 결과이다.
- 6행 결과는 한 테이블 안의 중복은 남는다고 오해한 것이다.
[진료내역]
| 진료과 | 유형 | 진료비 |
|---|---|---|
| 내과 | 외래 | 10 |
| 내과 | 입원 | 30 |
| 외과 | 외래 | 20 |
| 외과 | 외래 | 5 |
SELECT 진료과, 유형, SUM(진료비) AS 합계 FROM 진료내역 GROUP BY ( ㉠ ) ORDER BY 진료과, 유형;
[결과]
| 진료과 | 유형 | 합계 |
|---|---|---|
| 내과 | 외래 | 10 |
| 내과 | 입원 | 30 |
| 내과 | (null) | 40 |
| 외과 | 외래 | 25 |
| 외과 | (null) | 25 |
| (null) | 외래 | 35 |
| (null) | 입원 | 30 |
| (null) | (null) | 65 |
결과에 진료과별 소계와 유형별 소계, 총계가 모두 있으므로 가능한 모든 조합을 만드는 CUBE 이며, 인수가 2개면 2²=4가지 그룹이 생긴다.
상세 3행, 진료과 소계 2행, 유형 소계 2행, 총계 1행을 더해 8행이 된다.
- ROLLUP(진료과, 유형) 은 유형별 소계가 없어 6행이다.
- GROUPING SETS 는 각 열별 소계만 만들고 상세와 총계가 없다.
- 진료과, 유형만 쓰면 소계·총계 없이 상세 3행만 나온다.
[입원현황]
| 입원번호 | 병동 | 등급 |
|---|---|---|
| 1 | A | 일반 |
| 2 | A | 일반 |
| 3 | A | (null) |
| 4 | B | 특실 |
| 5 | B | 일반 |
SELECT 병동, 등급, COUNT(*) AS 환자수,
GROUPING(등급) AS G
FROM 입원현황
GROUP BY GROUPING SETS ((병동, 등급), (병동))
ORDER BY 병동, G, 등급;| 병동 | 등급 | 환자수 | G |
|---|---|---|---|
| A | 일반 | 2 | 0 |
| A | (null) | 3 | 1 |
| B | 일반 | 1 | 0 |
| B | 특실 | 1 | 0 |
| B | (null) | 2 | 1 |
| 병동 | 등급 | 환자수 | G |
|---|---|---|---|
| A | 일반 | 2 | 0 |
| A | (null) | 1 | 0 |
| A | (null) | 3 | 1 |
| B | 일반 | 1 | 0 |
| B | 특실 | 1 | 0 |
| B | (null) | 2 | 1 |
| (null) | (null) | 5 | 1 |
| 병동 | 등급 | 환자수 | G |
|---|---|---|---|
| A | 일반 | 2 | 0 |
| A | (null) | 1 | 1 |
| A | (null) | 3 | 1 |
| B | 일반 | 1 | 0 |
| B | 특실 | 1 | 0 |
| B | (null) | 2 | 1 |
| 병동 | 등급 | 환자수 | G |
|---|---|---|---|
| A | 일반 | 2 | 0 |
| A | (null) | 1 | 0 |
| A | (null) | 3 | 1 |
| B | 일반 | 1 | 0 |
| B | 특실 | 1 | 0 |
| B | (null) | 2 | 1 |
GROUPING SETS ((병동, 등급), (병동)) 은 병동·등급별 상세와 병동별 소계만 만들고 총계는 만들지 않으며, GROUPING(등급) 은 집계로 생긴 NULL 에만 1 이다.
입원번호 3 의 등급은 원래 NULL 이라 A·NULL·1 은 G 가 0 인 상세 행이고, A·NULL·3 과 B·NULL·2 가 G 가 1 인 소계 행이다.
- NULL 데이터 행의 G 를 1 로 본 결과는 원래 NULL 과 소계 NULL 을 구분하지 못한 것이다.
- 총계 행이 있는 결과는 ()가 없는 GROUPING SETS 에 총계를 더한 것이다.
- A·NULL·1 행이 빠진 결과는 NULL 등급이 그룹에서 빠진다고 오해한 것이다.
[처방실적]
| 의사명 | 진료과 | 금액 |
|---|---|---|
| 윤하 | 내과 | 100 |
| 윤하 | 내과 | 50 |
| 서진 | 내과 | 70 |
| 도윤 | 외과 | 40 |
SELECT 의사명, 진료과, SUM(금액) AS 합계 FROM 처방실적 GROUP BY ROLLUP(의사명, 진료과) ORDER BY 의사명, 진료과;
| 의사명 | 진료과 | 합계 |
|---|---|---|
| 도윤 | 외과 | 40 |
| 도윤 | (null) | 40 |
| 서진 | 내과 | 70 |
| 서진 | (null) | 70 |
| 윤하 | 내과 | 150 |
| 윤하 | (null) | 150 |
| (null) | 내과 | 220 |
| (null) | 외과 | 40 |
| (null) | (null) | 260 |
| 의사명 | 진료과 | 합계 |
|---|---|---|
| 도윤 | 외과 | 40 |
| 도윤 | (null) | 40 |
| 서진 | 내과 | 70 |
| 서진 | (null) | 70 |
| 윤하 | 내과 | 150 |
| 윤하 | (null) | 150 |
| (null) | (null) | 260 |
| 의사명 | 진료과 | 합계 |
|---|---|---|
| 도윤 | 외과 | 40 |
| 도윤 | (null) | 40 |
| 서진 | 내과 | 70 |
| 서진 | (null) | 70 |
| 윤하 | 내과 | 150 |
| 윤하 | (null) | 150 |
| 의사명 | 진료과 | 합계 |
|---|---|---|
| 도윤 | 외과 | 40 |
| 서진 | 내과 | 70 |
| 윤하 | 내과 | 150 |
| (null) | 내과 | 220 |
| (null) | 외과 | 40 |
| (null) | (null) | 260 |
ROLLUP 은 인수 순서대로 계층을 만들어 (의사명, 진료과), (의사명), 총계를 집계하므로 의사명별 소계만 생기고 진료과별 소계는 없다.
의사마다 한 진료과에만 속해 있어 의사별 소계 값이 상세 값과 같고, 마지막에 총계 260 이 붙어 7행이 된다.
- 내과 220·외과 40 소계가 있는 6행은 인수 순서를 바꾼 ROLLUP(진료과, 의사명) 결과이다.
- 총계가 없는 결과는 ROLLUP 이 총계도 만든다는 점을 놓친 것이다.
- 두 방향 소계가 모두 있는 결과는 CUBE 의 결과이다.
[접수]
| 환자명 | 대기분 |
|---|---|
| 강민 | 30 |
| 오라 | (null) |
| 하율 | 30 |
| 표지안 | 20 |
| 남궁솔 | 10 |
SELECT 환자명, 대기분,
RANK() OVER (ORDER BY 대기분 DESC) AS RK,
DENSE_RANK() OVER (ORDER BY 대기분 DESC) AS DRK
FROM 접수
ORDER BY RK, 환자명;| 환자명 | 대기분 | RK | DRK |
|---|---|---|---|
| 오라 | (null) | 1 | 1 |
| 강민 | 30 | 2 | 2 |
| 하율 | 30 | 3 | 2 |
| 표지안 | 20 | 4 | 3 |
| 남궁솔 | 10 | 5 | 4 |
| 환자명 | 대기분 | RK | DRK |
|---|---|---|---|
| 오라 | (null) | 1 | 1 |
| 강민 | 30 | 2 | 2 |
| 하율 | 30 | 2 | 2 |
| 표지안 | 20 | 3 | 3 |
| 남궁솔 | 10 | 4 | 4 |
| 환자명 | 대기분 | RK | DRK |
|---|---|---|---|
| 오라 | (null) | 1 | 1 |
| 강민 | 30 | 2 | 2 |
| 하율 | 30 | 2 | 2 |
| 표지안 | 20 | 4 | 3 |
| 남궁솔 | 10 | 5 | 4 |
| 환자명 | 대기분 | RK | DRK |
|---|---|---|---|
| 강민 | 30 | 1 | 1 |
| 하율 | 30 | 1 | 1 |
| 표지안 | 20 | 3 | 2 |
| 남궁솔 | 10 | 4 | 3 |
| 오라 | (null) | 5 | 4 |
Oracle 은 정렬 시 NULL 을 가장 큰 값으로 취급하므로 DESC 순위에서 대기분이 NULL 인 오라가 1위가 된다.
30분 동점 두 명은 둘 다 2위이고, 다음 행은 RANK 가 4위로 건너뛰고 DENSE_RANK 는 3위로 이어진다.
- 오라가 마지막인 결과는 NULLS LAST 로 정렬한 경우이다.
- 동점 두 명에 2위와 3위를 준 결과는 ROW_NUMBER 처럼 본 것이다.
- 표지안 RK 를 3으로 본 결과는 RANK 를 DENSE_RANK 처럼 계산한 것이다.
[투약]
| 환자번호 | 투약일 | 용량 |
|---|---|---|
| 1 | 2026-03-01 | 2 |
| 1 | 2026-03-02 | 3 |
| 1 | 2026-03-04 | 1 |
| 2 | 2026-03-01 | 4 |
| 2 | 2026-03-03 | 2 |
SELECT 환자번호, 투약일, 용량,
SUM(용량) OVER ( ㉠ ) AS 누적
FROM 투약
ORDER BY 환자번호, 투약일;[결과]
| 환자번호 | 투약일 | 용량 | 누적 |
|---|---|---|---|
| 1 | 2026-03-01 | 2 | 2 |
| 1 | 2026-03-02 | 3 | 5 |
| 1 | 2026-03-04 | 1 | 6 |
| 2 | 2026-03-01 | 4 | 4 |
| 2 | 2026-03-03 | 2 | 6 |
환자가 바뀔 때 누적을 다시 시작하려면 PARTITION BY 환자번호, 날짜 순으로 더해 가려면 ORDER BY 투약일 이 함께 있어야 한다.
환자 1 은 2, 5, 6 으로, 환자 2 는 다시 4, 6 으로 누적된다.
- 파티션 없이 정렬만 하면 환자 2 행이 10, 12 로 이어서 누적된다.
- PARTITION BY 만 쓰면 모든 행이 환자별 전체 합을 받는다.
- 용량만으로 정렬하면 환자 구분 없이 용량 순으로 누적되고 같은 용량은 같은 값을 받는다.
[혈압측정]
| 회차 | 수축기 |
|---|---|
| 1 | 130 |
| 2 | (null) |
| 3 | 125 |
| 4 | 140 |
SELECT 회차, 수축기,
LAG(수축기, 1, 0) OVER (ORDER BY 회차) AS 이전,
LAST_VALUE(수축기) OVER (ORDER BY 회차
ROWS BETWEEN UNBOUNDED PRECEDING
AND UNBOUNDED FOLLOWING) AS 마지막
FROM 혈압측정
ORDER BY 회차;| 회차 | 수축기 | 이전 | 마지막 |
|---|---|---|---|
| 1 | 130 | 0 | 130 |
| 2 | (null) | 130 | (null) |
| 3 | 125 | (null) | 125 |
| 4 | 140 | 125 | 140 |
| 회차 | 수축기 | 이전 | 마지막 |
|---|---|---|---|
| 1 | 130 | 0 | 140 |
| 2 | (null) | 130 | 140 |
| 3 | 125 | (null) | 140 |
| 4 | 140 | 125 | 140 |
| 회차 | 수축기 | 이전 | 마지막 |
|---|---|---|---|
| 1 | 130 | 0 | 140 |
| 2 | (null) | 130 | 140 |
| 3 | 125 | 0 | 140 |
| 4 | 140 | 125 | 140 |
| 회차 | 수축기 | 이전 | 마지막 |
|---|---|---|---|
| 1 | 130 | (null) | 140 |
| 2 | (null) | 130 | 140 |
| 3 | 125 | (null) | 140 |
| 4 | 140 | 125 | 140 |
LAG(수축기, 1, 0) 의 0 은 이전 행이 아예 없는 첫 행에만 쓰이며, 이전 행의 값이 NULL 이면 그대로 NULL 을 가져온다.
프레임을 처음부터 끝까지로 지정한 LAST_VALUE 는 모든 행에서 마지막 회차 값 140 을 돌려준다.
- 마지막이 각 행 자신의 값인 결과는 기본 프레임(현재 행까지)으로 계산한 것이다.
- 3회차 이전을 0으로 본 결과는 기본값이 NULL 값도 대체한다고 오해한 것이다.
- 1회차 이전이 NULL 인 결과는 세 번째 인수를 무시한 것이다.
[약품사용]
| 약품 | 진료과 | 수량 |
|---|---|---|
| A | 내과 | 20 |
| B | 내과 | 30 |
| C | 내과 | (null) |
| D | 외과 | 50 |
SELECT 약품, 진료과, 수량,
ROUND(RATIO_TO_REPORT(수량)
OVER (PARTITION BY 진료과), 2) AS 비율
FROM 약품사용
ORDER BY 약품;| 약품 | 진료과 | 수량 | 비율 |
|---|---|---|---|
| A | 내과 | 20 | 0.2 |
| B | 내과 | 30 | 0.3 |
| C | 내과 | (null) | 0 |
| D | 외과 | 50 | 0.5 |
| 약품 | 진료과 | 수량 | 비율 |
|---|---|---|---|
| A | 내과 | 20 | 0.2 |
| B | 내과 | 30 | 0.3 |
| C | 내과 | (null) | (null) |
| D | 외과 | 50 | 0.5 |
| 약품 | 진료과 | 수량 | 비율 |
|---|---|---|---|
| A | 내과 | 20 | 0.4 |
| B | 내과 | 30 | 0.6 |
| C | 내과 | (null) | (null) |
| D | 외과 | 50 | 1 |
| 약품 | 진료과 | 수량 | 비율 |
|---|---|---|---|
| A | 내과 | 20 | 0.4 |
| B | 내과 | 30 | 0.6 |
| C | 내과 | (null) | 0 |
| D | 외과 | 50 | 1 |
RATIO_TO_REPORT 는 값을 파티션 합계로 나눈 비율로, 내과 합계는 NULL 을 뺀 50 이라 A 0.4, B 0.6 이고 외과 D 는 1 이다.
수량이 NULL 인 C 는 나눌 값이 없으므로 비율도 NULL 로 남는다.
- 0.2·0.3·0.5 인 결과는 PARTITION BY 를 무시하고 전체 합 100 으로 나눈 것이다.
- C 의 비율을 0 으로 본 결과는 NULL 을 0 으로 바꿔 계산한 것이다.
- 두 오류를 합친 결과는 전체 합으로 나누고 NULL 도 0 으로 본 것이다.
[진료]
| 진료번호 | 과코드 | 환자명 | 진료비 |
|---|---|---|---|
| 1 | K1 | 강민 | 30000 |
| 2 | K1 | 오라 | 52000 |
| 3 | K2 | 하율 | 18000 |
| 4 | K2 | 표지안 | 41000 |
㉠
SELECT 환자명, 진료비
FROM (SELECT 환자명, 진료비,
ROW_NUMBER() OVER (ORDER BY 진료비 DESC) AS RN
FROM 진료) V
WHERE RN <= 3;㉡
SELECT 환자명, 진료비,
ROW_NUMBER() OVER (ORDER BY 진료비 DESC) AS RN
FROM 진료
WHERE RN <= 3;㉢
SELECT 환자명, 진료비 FROM 진료 ORDER BY 진료비 DESC FETCH FIRST 3 ROWS ONLY;
㉣
SELECT 과코드, 환자명
FROM (SELECT 과코드, 환자명,
ROW_NUMBER() OVER (PARTITION BY 과코드
ORDER BY 진료비 DESC) AS RN
FROM 진료) V
WHERE RN = 1;WHERE 절은 SELECT 절보다 먼저 처리되므로 SELECT 에서 만든 윈도우 함수 별칭 RN 을 같은 쿼리의 WHERE 에서 참조하면 오류가 난다.
순번으로 거르려면 순번을 매긴 쿼리를 인라인 뷰로 감싼 뒤 바깥 WHERE 에서 RN 을 비교해야 한다.
- ㉠은 인라인 뷰에서 순번을 만든 뒤 바깥에서 거르는 올바른 방식이다.
- ㉢은 정렬 후 3행만 가져오는 표준 FETCH FIRST 구문이다.
- ㉣은 과코드별로 순번을 매겨 과마다 최고 진료비 환자를 구한다.
[진료]
| 진료번호 | 환자명 | 진료비 |
|---|---|---|
| 1 | 강민 | 30000 |
| 2 | 오라 | 52000 |
| 3 | 하율 | 18000 |
| 4 | 표지안 | 41000 |
| 5 | 남궁솔 | 30000 |
| 6 | 채아 | 9000 |
SELECT ROWNUM AS RN, 환자명, 진료비
FROM (SELECT 환자명, 진료비
FROM 진료
ORDER BY 진료비 DESC, 진료번호)
WHERE ROWNUM <= 3;| RN | 환자명 | 진료비 |
|---|---|---|
| 1 | 오라 | 52000 |
| 2 | 강민 | 30000 |
| 3 | 하율 | 18000 |
| RN | 환자명 | 진료비 |
|---|---|---|
| 1 | 채아 | 9000 |
| 2 | 하율 | 18000 |
| 3 | 강민 | 30000 |
| RN | 환자명 | 진료비 |
|---|---|---|
| 1 | 오라 | 52000 |
| 2 | 표지안 | 41000 |
| 3 | 강민 | 30000 |
| 4 | 남궁솔 | 30000 |
| RN | 환자명 | 진료비 |
|---|---|---|
| 1 | 오라 | 52000 |
| 2 | 표지안 | 41000 |
| 3 | 강민 | 30000 |
정렬이 인라인 뷰 안에서 먼저 끝나므로 바깥의 ROWNUM 은 정렬된 순서대로 1, 2, 3 을 붙이고 3행에서 멈춘다.
30000 동점인 강민과 남궁솔은 진료번호 순으로 강민이 앞서므로 강민까지만 포함된다.
- 강민·하율이 나온 결과는 정렬 전 앞 3행에 ROWNUM 을 매겼다고 본 것이다.
- 4행 결과는 ROWNUM 이 동점 행까지 포함한다고 오해한 것이다.
- 진료비가 작은 순 결과는 DESC 를 무시한 것이다.
[조직]
| 조직코드 | 조직명 | 상위코드 |
|---|---|---|
| H0 | 병원장실 | (null) |
| H1 | 진료부 | H0 |
| H2 | 간호부 | H0 |
| H3 | 내과 | H1 |
| H4 | 외과 | H1 |
SELECT 조직명, LEVEL AS LV,
SYS_CONNECT_BY_PATH(조직명, '/') AS 경로,
CONNECT_BY_ISLEAF AS 말단,
CONNECT_BY_ROOT 조직명 AS 최상위
FROM 조직
START WITH 상위코드 IS NULL
CONNECT BY PRIOR 조직코드 = 상위코드;SYS_CONNECT_BY_PATH 는 루트부터 현재 행까지 조직명을 구분자로 이어 붙이고 구분자로 시작하므로, 내과의 경로는 /병원장실/진료부/내과이다.
내과는 병원장실, 진료부 다음 세 번째 단계이고 하위 조직이 없어 CONNECT_BY_ISLEAF 가 1 이다.
- 내과의 LEVEL 은 루트가 1 이므로 3이다.
- 자식이 없는 행이므로 말단 값은 1이다.
- CONNECT_BY_ROOT 는 시작 행인 병원장실을 돌려준다.
[의사]
| 의사ID | 의사명 | 지도의사ID |
|---|---|---|
| 1 | 윤하 | 3 |
| 2 | 서진 | 1 |
| 3 | 도윤 | 2 |
| 4 | 채원 | 1 |
SELECT 의사명, LEVEL AS LV,
CONNECT_BY_ISCYCLE AS CYC
FROM 의사
START WITH 의사ID = 1
CONNECT BY NOCYCLE PRIOR 의사ID = 지도의사ID
ORDER SIBLINGS BY 의사명;| 의사명 | LV | CYC |
|---|---|---|
| 윤하 | 1 | 1 |
| 서진 | 2 | 0 |
| 도윤 | 3 | 0 |
| 채원 | 2 | 0 |
| 의사명 | LV | CYC |
|---|---|---|
| 윤하 | 1 | 0 |
| 서진 | 2 | 0 |
| 도윤 | 3 | 1 |
| 채원 | 2 | 0 |
| 의사명 | LV | CYC |
|---|---|---|
| 윤하 | 1 | 0 |
| 서진 | 2 | 0 |
| 도윤 | 3 | 0 |
| 윤하 | 4 | 1 |
| 채원 | 2 | 0 |
| 의사명 | LV | CYC |
|---|---|---|
| 윤하 | 1 | 0 |
| 서진 | 2 | 0 |
| 채원 | 2 | 0 |
| 도윤 | 3 | 1 |
도윤의 지도 대상이 다시 윤하라 순환이 생기지만 NOCYCLE 이 이미 경로에 있는 윤하로의 전개를 멈추고, 그 순환을 일으킨 도윤 행에 CONNECT_BY_ISCYCLE 1 을 준다.
계층은 깊이 우선으로 전개되어 윤하 아래 서진, 서진 아래 도윤이 먼저 나온 뒤 형제인 채원이 나온다.
- 윤하에 1 을 준 결과는 순환의 시작점을 표시한다고 오해한 것이다.
- 채원이 도윤보다 먼저인 결과는 너비 우선으로 전개한 것이다.
- 윤하가 4레벨로 다시 나온 결과는 NOCYCLE 이 순환 행을 출력한다고 본 것이다.
[진료실적]
| 의사명 | 구분 | 진료비 |
|---|---|---|
| 윤하 | 외래 | 100 |
| 윤하 | 외래 | 40 |
| 윤하 | 입원 | 300 |
| 서진 | 외래 | 50 |
| 도윤 | 입원 | 200 |
SELECT 의사명,
SUM(CASE WHEN 구분 = '외래' THEN 진료비 ELSE 0 END) AS 외래,
SUM(CASE WHEN 구분 = '입원' THEN 진료비 END) AS 입원
FROM 진료실적
GROUP BY 의사명, 구분
ORDER BY 의사명, 구분;| 의사명 | 외래 | 입원 |
|---|---|---|
| 도윤 | 0 | 200 |
| 서진 | 50 | (null) |
| 윤하 | 140 | (null) |
| 윤하 | 0 | 300 |
| 의사명 | 외래 | 입원 |
|---|---|---|
| 도윤 | (null) | 200 |
| 서진 | 50 | (null) |
| 윤하 | 140 | (null) |
| 윤하 | (null) | 300 |
| 의사명 | 외래 | 입원 |
|---|---|---|
| 도윤 | 0 | 200 |
| 서진 | 50 | (null) |
| 윤하 | 140 | 300 |
| 의사명 | 외래 | 입원 |
|---|---|---|
| 도윤 | 0 | 200 |
| 서진 | 50 | 0 |
| 윤하 | 140 | 0 |
| 윤하 | 0 | 300 |
GROUP BY 에 피벗 대상 열 구분까지 넣었으므로 의사별 한 행으로 모이지 않고 (의사명, 구분) 조합마다 한 행이 나와 윤하가 두 행으로 나뉜다.
각 행에서 외래 열은 ELSE 0 이라 해당 값이 없으면 0, 입원 열은 ELSE 가 없어 NULL 이 된다.
- 3행 결과는 GROUP BY 를 의사명만으로 한 올바른 피벗이다.
- 입원 칸이 0 인 결과는 ELSE 없는 CASE 의 SUM 을 0 으로 본 것이다.
- 외래 칸이 NULL 인 결과는 ELSE 0 의 효과를 놓친 것이다.
[처방메모]
| 번호 | 메모 |
|---|---|
| 1 | 아침 2정 저녁 1정 |
| 2 | 1일 3회 10mL |
| 3 | 필요시 복용 |
| 4 | (null) |
SELECT 번호,
REGEXP_COUNT(메모, '[[:digit:]]+') AS 개수,
REGEXP_REPLACE(메모, '[[:space:]]', '') AS 정리
FROM 처방메모
ORDER BY 번호;| 번호 | 개수 | 정리 |
|---|---|---|
| 1 | 2 | 아침2정저녁1정 |
| 2 | 3 | 1일3회10mL |
| 3 | 0 | 필요시복용 |
| 4 | 0 | (null) |
| 번호 | 개수 | 정리 |
|---|---|---|
| 1 | 2 | 아침2정저녁1정 |
| 2 | 4 | 1일3회10mL |
| 3 | 0 | 필요시복용 |
| 4 | (null) | (null) |
| 번호 | 개수 | 정리 |
|---|---|---|
| 1 | 2 | 아침2정저녁1정 |
| 2 | 3 | 1일3회10mL |
| 3 | 0 | 필요시복용 |
| 4 | (null) | (null) |
| 번호 | 개수 | 정리 |
|---|---|---|
| 1 | 2 | 아침2정 저녁 1정 |
| 2 | 3 | 1일3회 10mL |
| 3 | 0 | 필요시복용 |
| 4 | (null) | (null) |
[[:digit:]]+ 는 숫자가 한 번 이상 이어진 덩어리를 하나로 세므로 1일 3회 10mL 는 1, 3, 10 으로 3개이고, 숫자가 없는 메모는 0 이다.
Oracle REGEXP_REPLACE 는 기본적으로 일치하는 모든 공백을 지우며, 메모가 NULL 인 행은 두 함수 모두 NULL 을 돌려준다.
- 첫 공백만 지운 결과는 바꾸기가 한 번만 일어난다고 오해한 것이다.
- 2번째 메모를 4개로 센 결과는 + 를 무시하고 숫자 한 글자씩 센 것이다.
- NULL 메모의 개수를 0 으로 본 결과는 NULL 입력이 NULL 을 낸다는 점을 놓친 것이다.
[약품재고]
| 약품코드 | 수량 |
|---|---|
| A | 10 |
| B | 5 |
| C | 0 |
[입고]
| 약품코드 | 수량 |
|---|---|
| B | 3 |
| D | 7 |
| E | 2 |
MERGE INTO 약품재고 T USING 입고 S ON (T.약품코드 = S.약품코드) WHEN MATCHED THEN UPDATE SET 수량 = S.수량 WHEN NOT MATCHED THEN INSERT (약품코드, 수량) VALUES (S.약품코드, S.수량);
B 는 양쪽에 있어 WHEN MATCHED 로 수량이 3 으로 바뀌고, 재고에 없는 D·E 는 WHEN NOT MATCHED 로 새로 들어간다.
입고에 없는 A·C 는 그대로이므로 10+3+0+7+2 로 합계는 22 이다.
- 13 은 D·E 가 삽입되지 않는다고 본 값이다.
- 24 는 B 의 수량이 바뀌지 않는다고 본 값이다.
- 27 은 B 수량을 더해 8 로 만든다고 오해한 값이다.
T1: SET TRANSACTION ISOLATION LEVEL REPEATABLE READ; T1: SELECT COUNT(*) FROM 입원 WHERE 병동 = 'W1'; -- 3 T2: INSERT INTO 입원 VALUES (9, '오라', 'W1'); T2: COMMIT; T1: SELECT COUNT(*) FROM 입원 WHERE 병동 = 'W1'; -- 4
같은 조건으로 다시 조회했을 때 다른 트랜잭션이 삽입하고 커밋한 새 행이 나타나 건수가 늘어난 것은 Phantom Read 이다.
ANSI 기준 REPEATABLE READ 는 이미 읽은 행이 바뀌는 것만 막고 새 행의 출현은 허용하며, 이를 막으려면 SERIALIZABLE 이 필요하다.
- Dirty Read 는 커밋되지 않은 데이터를 읽는 현상이다.
- Non-Repeatable Read 는 이미 읽은 행의 값이 수정·삭제되어 달라지는 현상이다.
- Lost Update 는 두 트랜잭션의 갱신 중 하나가 덮여 사라지는 현상이다.
CREATE TABLE 진료과 ( 진료과코드 VARCHAR2(5) PRIMARY KEY, 진료과명 VARCHAR2(20) ); CREATE TABLE 의사 ( 의사번호 NUMBER PRIMARY KEY, 의사명 VARCHAR2(20), 연락처 VARCHAR2(20), 진료과코드 VARCHAR2(5) REFERENCES 진료과(진료과코드) );
진료과는 의사 테이블의 외래키(Foreign Key)가 참조하는 부모 테이블이라, 데이터가 없어도 그대로 DROP 하면 오류가 난다.
PURGE 는 휴지통을 거치지 않는 옵션일 뿐이며, 지우려면 CASCADE CONSTRAINTS 로 참조 제약을 함께 없애거나 FK 를 먼저 삭제해야 한다.
- FK 가 아닌 일반 열 연락처는 DROP COLUMN 으로 삭제된다.
- RENAME TO 로 자식 테이블 이름을 바꾸는 것은 허용된다.
- 의사는 참조받지 않는 자식 테이블이라 TRUNCATE 가 된다.
CREATE TABLE 진료 ( 진료번호 INT PRIMARY KEY, 환자번호 INT REFERENCES 환자(환자번호) ON DELETE CASCADE ); CREATE TABLE 처방 ( 처방번호 INT PRIMARY KEY, 진료번호 INT REFERENCES 진료(진료번호) ON DELETE CASCADE ); DELETE FROM 환자 WHERE 환자번호 = 1;
[진료] (진료번호, 환자번호) : (10, 1) (11, 1) (12, 2) (13, 3)
[처방] (처방번호, 진료번호) : (100, 10) (101, 10) (102, 11) (103, 12) (104, 13) (105, 13)
환자 1 이 삭제되면 ON DELETE CASCADE 로 그 환자의 진료 10·11 이 삭제되고, 처방의 외래키에도 CASCADE 가 있어 연쇄 삭제가 처방까지 이어진다.
진료 10·11 을 참조하던 처방 100·101·102 가 지워져 103·104·105 의 3행만 남는다.
- 4 는 진료 10 의 처방만 지워진다고 본 값이다.
- 5 는 처방 한 행만 지워진다고 본 값이다.
- 6 은 CASCADE 가 한 단계만 적용된다고 오해한 값이다.
GRANT CREATE TABLE TO 간호1 ( ㉠ );
CREATE TABLE 은 시스템 권한이므로 받은 사용자가 다시 부여할 수 있게 하려면 WITH ADMIN OPTION 을 붙인다.
특정 테이블에 대한 SELECT·UPDATE 같은 오브젝트 권한의 재부여는 WITH GRANT OPTION 으로 지정한다.
- CASCADE 는 권한 회수나 객체 삭제 시 연쇄 처리에 쓰는 키워드이다.
- WITH CHECK OPTION 은 뷰 조건을 벗어나는 변경을 막는 옵션이다.
- WITH READ ONLY 는 뷰를 읽기 전용으로 만드는 옵션이다.