5장. 저장 구조 — 테이블스페이스와 블록
CREATE TABLE 한 줄이 디스크 위에서는 테이블스페이스 → 세그먼트 → 익스텐트 → 블록이라는 4단 계층으로 펼쳐집니다. 블록 안을 들여다보면 row migration과 "지웠는데 왜 느리죠?"의 답이 보입니다.
① 논리 저장 계층(tablespace → segment → extent → block)과 물리 파일의 대응 ② 블록 내부 구조(헤더 · ITL · row data · free space) ③ PCTFREE와 row migration / row chaining ④ High Water Mark가 full scan 성능을 좌우하는 이유
5.1 논리 계층 — 테이블은 4단 서랍장에 담긴다
Oracle은 데이터를 논리 구조와 물리 구조로 나눠서 관리합니다. SQL을 쓰는 우리는 논리 구조(테이블스페이스, 세그먼트)를 보고, OS는 물리 구조(datafile)만 봅니다. 이 분리 덕분에 "테이블을 어느 디스크 파일에 둘까"를 SQL 세계와 독립적으로 바꿀 수 있습니다.
논리 계층은 큰 것부터 작은 것 순서로 이렇습니다.
| 계층 | 무엇인가 | 비유 |
|---|---|---|
| Tablespace | 세그먼트를 담는 논리적 저장 공간. 하나 이상의 datafile로 구성 | 서랍장 전체 |
| Segment | 테이블 하나, 인덱스 하나처럼 저장 공간을 차지하는 객체 1개 | 서랍 한 칸 |
| Extent | 세그먼트에 할당되는 연속된 블록 묶음. 공간이 부족하면 extent 단위로 확장 | 서랍 속 칸막이 박스 |
| Data Block | Oracle I/O의 최소 단위. 기본 8KB (DB_BLOCK_SIZE) | 박스 속 종이 한 장 |
Tablespace는 가장 큰 논리 단위입니다. USERS, SYSTEM, UNDOTBS1처럼 용도별로 나뉩니다.
CREATE TABLE emp ...을 실행하면 그 테이블스페이스 안에 segment 하나가 생깁니다. 테이블 1개 = 세그먼트 1개 (파티션·LOB은 각각 별도 세그먼트).
세그먼트에 공간이 할당되는 단위가 extent — 연속된 블록 묶음입니다. 데이터가 차서 공간이 모자라면 extent를 하나 더 받아 늘어납니다.
extent를 쪼개면 data block(기본 8KB)이 나옵니다. Oracle이 읽고 쓰는 I/O의 최소 단위입니다. Buffer Cache에 올라가는 단위도 블록이죠.
이 모든 논리 구조는 결국 datafile이라는 물리 파일의 구간에 대응됩니다. 논리와 물리를 분리했기에 datafile을 추가·이동해도 SQL은 그대로입니다.
실제로 눈으로 확인해 봅시다. 딕셔너리 뷰 세 개면 계층 전체가 보입니다.
-- 테이블이 어느 테이블스페이스에, 얼마나 크게 자리 잡았나
SELECT segment_name, tablespace_name, extents, blocks, bytes/1024/1024 AS mb
FROM user_segments
WHERE segment_name = 'EMP';
-- extent 하나하나가 어느 파일의 몇 번 블록부터인지
SELECT extent_id, file_id, block_id, blocks
FROM dba_extents
WHERE segment_name = 'EMP'
ORDER BY extent_id;
-- 테이블스페이스를 구성하는 물리 파일
SELECT file_name, bytes/1024/1024 AS mb, autoextensible
FROM dba_data_files
WHERE tablespace_name = 'USERS';
블록 크기는 DB 생성 시 DB_BLOCK_SIZE로 정하며 기본값이자 가장 무난한 값이 8KB입니다(2K~32K 지원). 한 번 만들면 바꿀 수 없고, PostgreSQL의 8KB 페이지와 같은 크기라 두 DB를 비교 학습하기에도 좋습니다.
5.2 블록 내부 해부 — 8KB 안에 무엇이 들어있나
블록은 단순한 데이터 덩어리가 아니라 잘 짜인 문서 양식입니다. 위에서 아래로 이렇게 배치됩니다.
- 블록 헤더 — 블록 주소, 담긴 세그먼트 종류(테이블/인덱스), 최근 변경 SCN 등 관리 정보.
- ITL(Interested Transaction List) — "지금 이 블록의 행을 고치는 중인 트랜잭션" 명단. Oracle 행 락의 실체가 바로 여기 있습니다. 8장에서 집중적으로 다룹니다.
- Row Directory — 각 행이 블록 안 어디서 시작하는지 가리키는 목차. ROWID의 마지막 조각(행 번호)이 이 목차의 인덱스입니다.
- Free Space — 새 행 INSERT와 기존 행이 커지는 UPDATE를 위해 남겨둔 빈 공간.
- Row Data — 실제 행. 블록의 아래쪽 끝에서 위로 채워 올라옵니다.
5.3 PCTFREE — "UPDATE를 위해 자리를 비워둬라"
행은 INSERT 이후에도 UPDATE로 커질 수 있습니다. NULL이던 컬럼에 긴 문자열이 들어오는 순간이 대표적이죠. 그래서 Oracle은 블록마다 PCTFREE(기본값 10, 단위 %)만큼을 INSERT용으로 쓰지 않고 남겨둡니다.
-- 블록의 20%를 UPDATE 대비 여유 공간으로 남긴다
CREATE TABLE emp (
empno NUMBER(6),
ename VARCHAR2(50),
bio VARCHAR2(4000) -- 나중에 채워질 큰 컬럼
) PCTFREE 20 TABLESPACE users;
규칙은 간단합니다. 블록의 빈 공간이 PCTFREE 밑으로 내려가면 그 블록에는 더 이상 새 행을 INSERT하지 않습니다. 남은 공간은 오직 기존 행이 커질 때를 위해 예약됩니다.
UPDATE가 잦고 행이 자주 커지는 테이블은 PCTFREE를 키워야 row migration을 막을 수 있습니다. 반대로 INSERT 후 거의 수정하지 않는 로그성 테이블에 큰 PCTFREE를 주면 블록의 그만큼이 영원히 놀게 되어 저장 공간과 캐시를 낭비합니다.
5.4 Row Migration vs Row Chaining — 행이 블록을 넘칠 때
여유 공간을 남겨도 한계는 옵니다. UPDATE로 커진 행이 블록에 도저히 안 들어가면 어떻게 될까요? Oracle은 두 가지 다른 상황을 구분합니다.
🚚 Row Migration (행 이주)
UPDATE로 커진 행이 원래 블록에 안 들어가서 행 전체가 다른 블록으로 이사. 원래 자리에는 새 주소를 가리키는 포인터만 남습니다. ROWID는 그대로 → 인덱스는 수정 안 해도 되지만, 조회 때마다 블록을 2번 읽습니다.
🔗 Row Chaining (행 사슬)
행이 애초에 블록 하나보다 커서(예: 8KB 블록에 10KB짜리 행) 여러 블록에 걸쳐 쪼개져 저장되는 것. PCTFREE를 아무리 조절해도 못 막고, 블록 크기를 키우거나 행 설계를 바꿔야 합니다.
Block #7에 행 3개가 살고 있습니다. row 2의 bio 컬럼은 아직 NULL이라 행이 작고, 블록의 free space도 얼마 남지 않았습니다.
UPDATE emp SET bio='아주 긴 자기소개...' 실행! row 2가 확 커졌는데 블록에 남은 공간으로는 부족합니다.
Oracle은 행 전체를 여유 있는 Block #12로 이사시키고, 원래 자리엔 새 주소를 가리키는 포인터만 남깁니다. ROWID가 안 바뀌므로 인덱스를 고칠 필요는 없습니다. 이것이 row migration.
대가는 조회 성능입니다. 인덱스로 이 행을 찾으면 Block #7 → 포인터 → Block #12, 블록을 2번 읽습니다. 통계 table fetch continued row가 치솟으면 이 현상을 의심하세요. 예방책은 넉넉한 PCTFREE입니다.
migration/chaining이 실제로 있는지 이렇게 진단합니다.
-- 방법 1: 통계로 의심하기 — 포인터를 따라간 횟수
SELECT name, value FROM v$sysstat
WHERE name = 'table fetch continued row';
-- 방법 2: 테이블 통계 수집 후 확인
EXEC DBMS_STATS.GATHER_TABLE_STATS('SCOTT', 'EMP');
SELECT chain_cnt, num_rows FROM user_tables WHERE table_name = 'EMP';
-- 해결: migration된 행은 테이블 재구성으로 정리
ALTER TABLE emp MOVE; -- 주의: ROWID가 바뀌므로 인덱스 리빌드 필요
ALTER INDEX emp_pk REBUILD;
5.5 High Water Mark — "지웠는데 왜 full scan이 느리죠?"
High Water Mark(HWM)는 세그먼트에서 "여기까지는 데이터가 채워진 적이 있다"를 표시하는 수위선입니다. 홍수가 지나간 강둑의 물자국과 같습니다 — 물이 빠져도 자국은 남죠.
핵심 규칙 두 가지:
- Full table scan은 HWM 아래의 모든 블록을 읽습니다. 행이 있든 없든.
- DELETE는 HWM을 내리지 않습니다. 1억 건을 지워도 수위선은 그대로입니다.
1억 건을 INSERT해 블록 8개가 찼습니다. HWM(수위선)은 8번째 블록 뒤에 있습니다.
대청소! DELETE로 대부분을 지웠습니다. 블록들은 텅 비었지만 HWM은 1mm도 내려가지 않습니다. 물자국은 그대로죠.
이제 SELECT COUNT(*) 같은 full scan을 하면? Oracle은 HWM 아래 블록을 전부 읽습니다. 빈 블록도 "행이 있는지 없는지 열어봐야" 알기 때문입니다. 데이터 100건 남은 테이블의 조회가 여전히 느린 이유입니다.
해결은 수위선 자체를 내리는 것 — TRUNCATE(전체 비우기) 또는 ALTER TABLE ... SHRINK SPACE(온라인 축소). 이후 full scan은 진짜 데이터가 있는 만큼만 읽습니다.
-- 대량 DELETE 후 HWM 내리기 (온라인, 세그먼트 축소)
ALTER TABLE emp ENABLE ROW MOVEMENT; -- 행 이동 허용 (ROWID 변경 허용)
ALTER TABLE emp SHRINK SPACE; -- 행을 앞쪽으로 모으고 HWM 하향
-- 전부 비울 거라면 DELETE 대신 이것 — HWM이 즉시 리셋된다
TRUNCATE TABLE emp;
TRUNCATE는 DDL이라 undo를 거의 남기지 않고 ROLLBACK이 불가능합니다. HWM 리셋 효과만 보고 무심코 실행하면 안 됩니다. 6장에서 배울 flashback으로도 TRUNCATE된 데이터는 undo 기반으로는 복구할 수 없습니다.
✍️ 이해도 체크
SELECT COUNT(*)가 여전히 느립니다. 가장 가능성 높은 원인은?ALTER TABLE ... SHRINK SPACE나 테이블 재구성(MOVE), 전량 삭제라면 TRUNCATE로 HWM을 내려야 합니다.Oracle Database 19c Concepts Guide: Logical Storage Structures · Physical Storage Structures — 블록 포맷, extent 할당 알고리즘, ASSM(Automatic Segment Space Management) 등 심화 내용이 있습니다.