
DB를 직접 만들어보면서 내부적으로 optimizing을 어떻게 하는지 궁금했습니다.
레코드를 삭제하고 삭제된 데이터는 어떤 흐름으로 처리되는지가 궁금했고
VACUUM과 GC의 개념에 대해 알게되었습니다.
VACUUM 은 파일을 재작성하거나 페이지를 재배치하여 내부의 쓸모없는 공간(삭제 처리된 레코드 등)을 물리적으로 회수(축소)하는 작업을 말한다. 반면, GC(가비지 콜렉션)은 DB 내에서 더 이상 참조되지 않는(예시: 과거 버전 트랜잭션) 레코드나 페이지를 논리적으로 해제하는 과정을 의미한다.(특히, 멀티 버전 방식 DB에서 오래된 버전은 아무도 안 쓰면 제거 가능이라는 식의 로직을 ‘가비지 컬렉션’이라 부를 때가 많다.)
즉, 둘 다 ‘사용하지 않는 공간을 정리한다”는 취지는 같지만, 구체적 매커니즘과 시점에서 차이가 있다.
많은 DB들이 실제로, 내부 GC로 ‘공간을 논리적으로 재활용’만 하고, 파일 크기 자체는 VACUUM 같은 별도 작업으로 축소한다.
H2 에서의 구현 개념
- MVSTORE 기반
- 가비지 컬렉션(GC)
- H2에서는 ‘사용하지 않는 old chunk나 old version’ 을 배경 스레드가 자동으로 GC 해주도록 설정되어 있다.
- 예시: 한 트랜잭션에서 데이터를 삭제해도, 다른 트랜잭션이 그 버전을 보고 있으면 물리 공간이 즉시 해제되지 않는다. 모든 트랜잭션이 그 버전을 참조하지 않을 때, MVSTORE가 GC하여 재사용하거나 폐기한다.
- VACUUM
- H2에서 ‘파일 크기를 줄이는 작업’은 SHUTDOWN COMPACT 명령을 통해 수행할 수 있다.
- 내부적으로 파일을 새로 작성하여 불필요한 부분(삭제된 데이터)는 건너뛰고, 유효한 레코드만 압축해서 담는다.
- 결과적으로 db 용량이 줄어든다.
- 가비지 컬렉션(GC)
- h2는 기본 스토리지 엔진으로 MVSTORE(멀티비전 + Copy-on-Write) 구조를 사용한다.
이 기능을 구현하게 알게된 DB에서 Delete 시 내부 동작 흐름
Delete 시점에는 인덱스에서 해당 키를 제거해서 “논리적으로는 접근할 수 없도록” 만들고, 실제 레코드(파일 속 바이트)는 그대로 남겨 둔 상태(소프트 삭제).
그리고 Vacuum 단계에서 “더 이상 참조되지 않는(인덱스와 연결이 끊긴) 레코드”를 실제로 파일에서 제거함으로써 디스크 공간을 회수합니다.
이 로직이 일반적인 DB(예: InnoDB, PostgreSQL 등)의 “delete → (논리 삭제) → vacuum → (물리 삭제)” 흐름.
Vaccum 기능 구현 코드
/**
* VACUUM 기능 구현
* 1. 임시 파일(data_temp.db)를 만들고, 헤더를 기록
* 2. 원본 파일(data.db)을 열어서, 헤더는 건너뛴 뒤 레코드들을 순회.
* 3. deletedFlag가 false인 레코드만 임시 파일에 다시 쓰기.
* 4. 작업이 끝나면 원본 파일을 삭제하거나 이름을 변경. 임시 파일 이름을 data.db로 rename
* 5. indexMap을 다시 로드(loadIndex())해서 새 파일 기준으로 인덱스를 재구축.
*/
public class VacuumService {
private final String originalFilePath;
public VacuumService(String originalFilePath) {
this.originalFilePath = originalFilePath;
}
public void vacuum() throws IOException {
File originalFile = new File(originalFilePath);
if (!originalFile.exists() || originalFile.length() == 0) {
// 파일이 비어 있거나 없으면 압축할 필요가 없다.
return;
}
File tempFile = new File("data_temp.db");
if (tempFile.exists()) {
tempFile.deleteOnExit();
}
// 임시 파일 헤더 기록
try (RandomAccessFile rafTemp = new RandomAccessFile(tempFile, "rw")) {
rafTemp.writeInt(GlobalVariables.MAGIC_NUMBER.getValue());
rafTemp.writeInt(GlobalVariables.VERSION.getValue());
}
// 원본에서 레코드 복사
try (RandomAccessFile rafOrigin = new RandomAccessFile(originalFile, "r");
RandomAccessFile rafTemp = new RandomAccessFile(tempFile, "rw")) {
// 헤더 건너띄기
int magicNumber = rafOrigin.readInt();
int version = rafOrigin.readInt();
rafTemp.seek(rafTemp.length());
long fileLength = rafOrigin.length();
long offset = rafOrigin.getFilePointer();
while (offset < fileLength) {
rafOrigin.seek(offset);
boolean deleted = rafOrigin.readBoolean();
int keySize = rafOrigin.readInt();
String key = rafOrigin.readUTF();
int valueSize = rafOrigin.readInt();
String value = rafOrigin.readUTF();
long recordEnd = rafOrigin.getFilePointer();
if (!deleted) {
rafTemp.writeBoolean(false);
rafTemp.writeInt(keySize);
rafTemp.writeUTF(key);
rafTemp.writeInt(valueSize);
rafTemp.writeUTF(value);
}
offset = recordEnd;
}
}
originalFile.delete();
tempFile.renameTo(originalFile);
}
}
*CoW(Copy on Write) 이란?
Copy On Write란 말 그대로 작성시 이전의 내용을 Copy한다는 내용을 담고 있고, 보통 Optimization 기술중 하나로 쓰이고 있다
'DB' 카테고리의 다른 글
| MySQL의 Using filesort와 Using temporary (0) | 2025.11.23 |
|---|---|
| [이음] 프로젝트에 쿼리의 실행계획을 분석하며 최적화 해보기 (1) | 2025.11.22 |
| [DB] B+Tree 란? 그리고 자바 구현 (0) | 2025.04.12 |