PostgreSQL Autovacuum 장애 및 테이블 비대화

PostgreSQL에서는 데이터를 쓰고 지우는 과정에서 Dead Tuple이 발생됩니다. 이때 이 Dead Tuple을 제거해주는 존재가 Autovaccum입니다. 만약 이 과정을 제대로 거치지 않는다면 테이블을 계속해서 비대해지고, 느려지고, 마지막엔 멈출 수 도 있습니다. 해당 글에서는 특정 테이블에서 발생한 Autovaccum 장애 사례를 통해, 장기 트랜잭션이 어떻게 데이터베이스를 망가뜨리는지 파악하겠습니다. 아래 글은 시간 순서대로 이어집니다.
2026.02.07
LOG: automatic vacuum of table "public.GeoIpNetwork": index scans: 1
pages: 0 removed, 22377 remain, 20723 scanned (92.61% of total)
tuples: 607009 removed, 1334672 remain, 0 are dead but not yet removable
removable cutoff: 115248236, which was 1051 XIDs old when operation ended
new relfrozenxid: 114747266, which is 382002 XIDs ahead of previous value
frozen: 0 pages from table (0.00% of total) had 0 tuples frozen
index scan needed: 6519 pages from table (29.13% of total) had 607009 dead item identifiers removed
index "GeoIpNetwork_pkey": pages: 8596 in total, 1664 newly deleted, 5058 currently deleted, 3394 reusable
index "GeoIpNetwork_ipVersion_idx": pages: 4596 in total, 913 newly deleted, 2638 currently deleted, 1725 reusable
index "GeoIpNetwork_geonameId_idx": pages: 4128 in total, 686 newly deleted, 1997 currently deleted, 1311 reusable
index "GeoIpNetwork_network_idx": pages: 11679 in total, 2 newly deleted, 2 currently deleted, 0 reusable
index "idx_geoipnetwork_network_cast": pages: 28940 in total, 94 newly deleted, 3699 currently deleted, 3605 reusable
I/O timings: read: 47617.057 ms, write: 0.000 ms
avg read rate: 2.954 MB/s, avg write rate: 0.000 MB/s
buffer usage: 129475 hits, 18346 misses, 0 dirtied
WAL usage: 0 records, 0 full page images, 0 bytes
system usage: CPU: user: 1.36 s, system: 0.50 s, elapsed: 48.51 s
Autovaccum은 제 역할을 하고 있는 중이었습니다.
automatic vacuum of table "public.GeoIpNetwork"
- autovaccum worker가 실제로 테이블을 스캔해서 vaccum을 시도
- dead tuple 제거 성공 / 인덱스 정리 성공
tuples: 607009 removed, 1334672 remain, 0 are dead but not yet removable
- 607,009 removed
- vacuum이 실제로 heap tuple 제거를 했습니다.
- 0 are dead but not yet removable
- visibility blocker가 없어, 모든 dead tuple이 제거가 가능한 상태
index scan needed: 6519 pages from table (29.13% of total) had 607009 dead item identifiers removed
- heap에서 제거한 tuple 수와 정확하게 일치(607009)
- 인덱스에서도 dead entry 제거
- 인덱스가 reusable 상태로 전환
2월 7일까지만 해도 정상적인 MVCC lifecycle 범주 안에 위치한 상황이었음을 보여주는 로그. 하지만, Read, Write DB Node는 계속해서 치닫던 상황이었지만, 2월 7일이 주말이었기 때문에 모니터링을 하지 않고 있던 상황이었습니다.
26.02.08
LOG: automatic vacuum of table "public.GeoIpNetwork": index scans: 0
pages: 0 removed, 48319 remain, 41624 scanned (86.14% of total)
tuples: 0 removed, 4389367 remain, 1810524 are dead but not yet removable
removable cutoff: 115788503, which was 1046 XIDs old when operation ended
frozen: 0 pages from table (0.00% of total) had 0 tuples frozen
index scan not needed: 0 pages from table (0.00% of total) had 0 dead item identifiers removed
index "GeoIpNetwork_pkey": pages: 13802 in total, 0 newly deleted, 1788 currently deleted, 1788 reusable
index "GeoIpNetwork_ipVersion_idx": pages: 6075 in total, 0 newly deleted, 972 currently deleted, 972 reusable
index "GeoIpNetwork_geonameId_idx": pages: 5788 in total, 0 newly deleted, 755 currently deleted, 755 reusable
index "idx_geoipnetwork_network_cast": pages: 44701 in total, 0 newly deleted, 3033 currently deleted, 3033 reusable
I/O timings: read: 45945.261 ms, write: 0.025 ms
avg read rate: 4.443 MB/s, avg write rate: 0.000 MB/s
buffer usage: 139576 hits, 27113 misses, 0 dirtied
WAL usage: 0 records, 0 full page images, 0 bytes
system usage: CPU: user: 1.14 s, system: 0.48 s, elapsed: 47.67 s
문제가 발생한 시점은 해당 시점부터였습니다. 가장 큰 원인은 장기 트랜잭션(Long-running Transaction)'입니다. 특정 세션이 아주 오래된 데이터를 붙잡고 놓아주지 않고 있었습니다. PostgreSQL의 MVCC 모델에서는 "누군가 보고 있을지도 모르는 데이터"는 절대 지우지 않습니다. 이를 Visibility Blocker이라고 합니다.
tuples: 0 removed, 4389367 remain, 1810524 are dead but not yet removable
- dead tuple이 1,810,524개의 dead tuple이 존재하는 상황
- 문제는 removed가 0으로 vaccum이 단 하나도 제거를 못한 상황입니다.
- postgre는 해당 tuple들이 아직 트랜잭션상 누군가가 접근 할 수 있다는 것을 염두에 두고 있습니다.
removable cutoff: 115788503, which was 1046 XIDs old
- vaccum은 xmin < cutoff 인 tuple만 제거가 가능합니다.
- 하지만, 트랜잭션이 오래 살아 있어서 cutoff가 더 이상 진행이 안되는 상황입니다.
- 이것은 오래 열린 transaction/snapshot의 문제가 됩니다.
index scan not needed: 0 pages ... 0 dead item identifiers removed
- heap에서 tuple 제거가 0이며, 인덱스 또한 건들 수 없는 상황입니다.
하루만에 페이지 수가 22377 에서 48319으로 2배 이상 증가했고, delete 된 row는 남아있고, insert된 row만 추가가 되어있는 상황입니다. 전형적인 dead tuple과 live tuple의 공존 상태입니다. vaccum은 돌았으나, MVCC visibility 때문에 아무것도 진행하지 못한 상황입니다.
26.02.09
LOG: automatic vacuum of table "public.GeoIpNetwork": index scans: 0
pages: 0 removed, 59315 remain, 34591 scanned (58.32% of total)
tuples: 0 removed, 4730900 remain, 1810524 are dead but not yet removable
removable cutoff: 116323391, which was 1379 XIDs old when operation ended
frozen: 0 pages from table (0.00% of total) had 0 tuples frozen
index scan not needed: 0 pages from table (0.00% of total) had 0 dead item identifiers removed
index "idx_geoipnetwork_network_cast": pages: 53544 in total, 0 newly deleted, 11152 currently deleted, 11152 reusable
I/O timings: read: 60163.257 ms, write: 0.000 ms
avg read rate: 4.661 MB/s, avg write rate: 0.000 MB/s
buffer usage: 102965 hits, 36708 misses, 0 dirtied
WAL usage: 0 records, 0 full page images, 0 bytes
system usage: CPU: user: 1.06 s, system: 0.59 s, elapsed: 61.52 s
tuples: 0 removed, 4730900 remain, 1810524 are dead but not yet removable
- dead tuple 수 변화가 없고, 제거 불가 상태가 지속되는 상황 (1810524개)
scanned (58.32% of total)
- vaccum이 테이블이 너무 커져, cost-based 일부만 스캔되지만 제거를 할 수 없으니 의미가 없는 상황입니다.
idx_geoipnetwork_network_cast pages: 28940 → 44701 → 53544
- index page가 기하급수적으로 증가가 되었지만, dead index entry 제거가 불가합니다.
- 인덱스가 현재 놀리적으로 비어있으나, 물리적으로 계속해서 커지는 상황입니다.
elapsed: 48s → 47s → 61s
- vaccum이 더 큰 테이블, 더 큰 인덱스를 계속해서 스캔하여, I/O 낭비가 심한 상태입니다.
해당 로그들이 말하는 공통된 사실은 PostgreSQL은 이 테이블에서 지워도 되는 데이터가 뭔지 확신이 없어 계속해서 늘어나고 있는 상태입니다. MVCC로 인한 오래 살아있는 transaction으로 인해, 문제가 되며 commit 이 한번 일어나야되는 상황으로 생각됩니다.
3일간의 변화 요약
| 2월 7일 (정상) | 2월 8일(차단) | 2월 9일(악화) | |
| 테이블 크기 (Pages) | 22,377 | 48,319 | 59,315 |
| 인덱스 크기 (Pages) | 28,940 | 44,701 | 53,544 |
| 지워지지 않은 쓰레기 | 0 | 1,810,524 | 1,810,524 |
| 청소 시간 | 48.51초 | 47.67초 | 61.52초 |
1. 해결방안
1-1 .GeoIP 배치 프로세스 최적화
현재 GeoIP 업데이트 배치 작업 시 발생하는 리소스(ACU) 급증 및 데이터베이스 성능 저하 문제를 파악했습니다. 해당 문제는 현 상황을 단순한 데이터 증가가 아닌, 애플리케이션의 트랜잭션 관리 전략이 PostgreSQL의 MVCC(Multi-Version Concurrency Control)의 문제로 파악됩니다.
1-2. 로그 분석 결과
최근 3일간의 Autovacuum 로그를 분석한 결과, 시스템은 이미 자정 배치 작업 시마다 물리적 한계에 봉착하고 있습니다. 아래 데이터는 "GeoIpNetwork" 테이블의 급격한 성능 문제를 보여줍니다.
| 날짜 | 작업 결과(Tuples Removed) | 테이블 크기(Pages) |
| 2월 7일 | 607,009건 삭제 성공 | 22,377 Pages |
| 2월 8일 | 0건 삭제 (실패) | 48,319 Pages |
| 2월 9일 | 0건 삭제 (실패) | 59,315 Pages |
핵심 지표: 로그에 기록된 1,810,524 dead tuples are not yet removable 수치는 약 180만 건의 데이터가 삭제되었음에도 불구하고, 특정 프로세스가 이를 점유하여 청소가 불가능함을 의미합니다.
2. MVCC 메커니즘과 안티 패턴의 충돌
PostgreSQL에서 DELETE는 물리적 삭제가 아닌 xmax 시스템 컬럼을 마킹하는 Dead Tuple 생성 작업입니다. 이 쓰레기를 수거해야 할 Autovacuum이 작동하지 못하는 이유는 현재 코드의 장기 트랜잭션인 안티 패턴 때문입니다.
- Snapshot 유지와 Removable Cutoff: 현재 코드는 withTransaction({ timeout: 300_000 }, ...)를 통해 약 5분간 트랜잭션을 유지합니다. 이 장기 트랜잭션의 xmin이 'Removable Cutoff' 지점을 고정(Pinning)함으로써, DB는 "이 데이터가 여전히 필요할 수 있다"고 판단하여 1,810,524건의 Dead Tuple을 청소 대상에서 제외합니다.
- Table Bloat(비대화): 청소가 차단된 상태에서 대량의 INSERT가 이어지면, DB는 기존 빈 공간을 재사용하지 못하고 새로운 페이지를 생성하여 테이블 뒤에 계속 붙입니다. 이로 인해 3일 만에 물리적 크기가 3배 가까이 팽창했습니다.
- Index Bloat와 ACU 폭발: skipDuplicates: true 옵션은 매 삽입 시마다 인덱스를 탐색합니다. 비대해진 B-Tree 인덱스 깊이와 페이지 수로 인해 I/O와 CPU 소모량이 기하급수적으로 증가하며, 이것이 곧 ACU의 폭발적 상승으로 직결됩니다.
2-1 .트랜잭션 격리 및 로직 최적화
서비스 가용성을 즉시 확보하기 위해 트랜잭션 범위를 최소화하여 Autovacuum의 개입 통로를 열어주어야 합니다.
기존 쿼리
async insertBlocks(blocks: any[], ipVersion: number) {
const data: Prisma.GeoIpNetworkCreateManyInput[] = []
await this.databaseService
.host()
.withTransaction({ timeout: 300_000 }, async () => {
const tx = this.databaseService.tx()
await tx.geoIpNetwork.deleteMany({ where: { ipVersion } })
const batchSize = 5000
for (let i = 0; i < data.length; i += batchSize) {
await tx.geoIpNetwork.createMany({
data: data.slice(i, i + batchSize),
skipDuplicates: true,
})
}
})
}
개선 쿼리
async insertBlocks(blocks: any[], ipVersion: number) {
const data: Prisma.GeoIpNetworkCreateManyInput[] = []
await this.databaseService.tx().geoIpNetwork.deleteMany({
where: { ipVersion }
})
await wait(3000)
const batchSize = 5000
let totalInserted = 0
for (let i = 0; i < data.length; i += batchSize) {
const batchData = data.slice(i, i + batchSize)
await this.databaseService.tx().geoIpNetwork.createMany({
data: batchData,
skipDuplicates: true,
})
totalInserted += batchData.length
await wait(50)
}
}
2-2. 해피해피케이스
- Delete 트랜잭션 독립: deleteMany를 실행한 직후 즉시 트랜잭션을 Commit 합니다. 이 순간 180만 건의 데이터는 'Removable' 상태로 전환됩니다.
- Sleep: 커밋 직후 약 2~5초간의 지연 시간을 삽입하여 Autovacuum이 즉시 가동될 수 있는 스케줄링 여유를 제공합니다.
- Insert 배치 분할: createMany를 별도의 짧은 트랜잭션으로 분리하거나, 작은 단위로 쪼개어(Batching) 순차적으로 커밋합니다.
이 전략으로 PostgreSQL의 스냅샷 유지 시간을 단축시켜 Removable Cutoff 정책을 충족시킵니다. 트랜잭션이 종료됨에 따라 Autovacuum은 이전 상태가 더 이상 필요하지 않음을 즉시 인지하고 Dead Tuple을 수거하여 공간을 재활용할 수 있게 될 것으로 추측했습니다.
2-3. 언해피케이스

3. ACU는 줄었으나, 테이블이 계속 커지는 현상은 여전
로그 모니터링 결과, wait() 로직 적용 후 ACU가 (Writer: 17 → 8)로 50% 이상 감소하여 문제가 해결된 것으로 보였습니다. 하지만 로그를 뜯어보니, 심각한 문제는 여전히 진행 중이었습니다.
2026-02-10 00:01:24
UTC::@:[7183]:LOG: automatic vacuum of table "public.GeoIpNetwork": index scans: 0
pages: 0 removed, 61556 remain, 34384 scanned (55.86% of total)
tuples: 0 removed, 4779490 remain, 1810524 are dead but not yet removable
removable cutoff: 116731816, which was 304 XIDs old when operation ended
frozen: 0 pages from table (0.00% of total) had 0 tuples frozen
index scan not needed: 0 pages from table (0.00% of total) had 0 dead item identifiers removed
index "GeoIpNetwork_pkey": pages: 17303 in total, 0 newly deleted, 6971 currently deleted, 6971 reusable
index "GeoIpNetwork_ipVersion_idx": pages: 8243 in total, 0 newly deleted, 2795 currently deleted, 2795 reusable
index "GeoIpNetwork_geonameId_idx": pages: 8368 in total, 0 newly deleted, 2661 currently deleted, 2661 reusable
index "idx_geoipnetwork_network_cast": pages: 55619 in total, 0 newly deleted, 11547 currently deleted, 11547 reusable
I/O timings: read: 63411.130 ms, write: 0.528 ms
avg read rate: 6.221 MB/s, avg write rate: 0.000 MB/s
buffer usage: 134488 hits, 51829 misses, 0 dirtied
WAL usage: 0 records, 0 full page images, 0 bytes
system usage: CPU: user: 1.98 s, system: 1.20 s, elapsed: 65.08 s
tuples: 0 removed
- 청소차(Vacuum)가 돌았지만, 쓰레기(Dead Tuple)를 하나도 못 치웠습니다.
1810524 are dead but not yet removable
- 약 180만 개의 지워진 데이터가 "누군가 붙잡고 있어서" 삭제되지 못하는 상태입니다.
pages: 0 removed, 61556 remain
- 빈 공간을 재활용 못 하니, DB는 계속 새 페이지를 만들어 데이터를 썼고, 물리적 파일 크기는 계속 증가했습니다. (59315 > 61556)
3-1. ACU의 감소 원인
async insertBlocks(blocks: any[], ipVersion: number) {
...
await wait(3000)
...
}
async insertLocations(locations: any[]) {
...
await wait(2000)
...
}
중간중간 적용한 await wait(50)은 트래픽을 인위적으로 조절하는 Throttling(스로틀링) 역할을 수행했습니다.
개선전은 180만번의 I/O 요청을 쉬지 않고 DB에 쏟아붓는 상황이 발생되어 ACU 스파이크가 발생됩니다. 개선 후에는 요청 간격을 벌려주어 DB가 디스크에 기록할 시간을 확보해주고, 초당 I/O 부하를 낮춰줘 ACU 그래프를 안정화 시키게 되었지만, 결과적으로 진짜 근본 원인 파악을 하지 못했다고 판단되어, 진짜 원인을 파악하고자 했습니다. 그 원인은 tx()에 있었습니다.
4. nestjs-cls와 tx()
코드를 분리했음에도 왜 트랜잭션은 안 끊겼을까요? 범인은 nestjs-cls의 Context Propagation(전파) 특성에 있었습니다
// DatabaseService
constructor(
@InjectTransactionHost(...) private readonly txHost: TransactionHost<...>
) {}
// 이 녀석이 문제입니다.
tx = () => this.txHost.tx
tx() 메서드는 호출될 때 현재 요청의 컨텍스트(CLS Context)를 확인합니다.
- Sticky Transaction
- 만약 상위 레벨(스케줄러 진입점, 미들웨어 등)에서 컨텍스트가 생성되었다면, tx()는 매번 동일한 DB Connection(Session)을 반환합니다.
- Wait의 배신
- 부하를 줄이려고 넣은 await deleteMany() 후 await wait(3000) 코드가 오히려 독이 되었습니다. 트랜잭션이 끊기지 않은 상태에서 3초간 대기하다 보니, DB 커넥션을 계속 물고 있는 상태(Transaction Open)가 유지된 것입니다.
- Vacuum 차단
- DB 입장에서는 "아직 작업이 안 끝난 트랜잭션이 데이터를 보고 있네?"라고 판단합니다. 데이터 일관성(MVCC)을 위해 삭제된 데이터의 청소를 막아버리는 Removable Cutoff 현상이 발생했습니다.
4-1. 트랜잭션의 고리를 끊자
이 문제를 해결하기 위해 가장 먼저 해야 할 일은 nestjs-cls가 관리하는 트랜잭션 컨텍스트에서 탈출하는 것이었습니다. DatabaseService 내부에 트랜잭션 매니저를 거치지 않는 순수한 Prisma Client 인스턴스를 생성했습니다.
@Injectable()
export class DatabaseService {
private readonly rawClient = new PrismaClient({ ... })
raw = () => this.rawClient
}
그리고 Repository 코드에서 tx() 대신 raw()를 사용하도록 변경했습니다. 이제 deleteMany가 실행되면 즉시 커밋(Commit) 되고 연결이 해제됩니다.
await this.databaseService.raw().geoIpNetwork.deleteMany(...)
await wait(3000)
await this.databaseService.raw().geoIpNetwork.createMany(...)
2026.2.11
LOG: automatic vacuum of table "public.GeoIpNetwork": index scans: 0
pages: 0 removed, 19133 remain, 9096 scanned (47.54% of total)
tuples: 0 removed, 1489799 remain, 603508 are dead but not yet removable
removable cutoff: 117111674, which was 246 XIDs old when operation ended
frozen: 0 pages from table (0.00% of total) had 0 tuples frozen
index scan not needed: 0 pages from table (0.00% of total) had 0 dead item identifiers removed
index "idx_geoipnetwork_network_cast": pages: 55619 in total, 0 newly deleted, 33542 currently deleted, 33542 reusable
I/O timings: read: 62455.042 ms, write: 0.000 ms
avg read rate: 6.495 MB/s, avg write rate: 0.000 MB/s
buffer usage: 58558 hits, 52712 misses, 0 dirtied
WAL usage: 0 records, 0 full page images, 0 bytes
system usage: CPU: user: 1.61 s, system: 0.94 s, elapsed: 63.40 s
pages: 19133 remain
- 테이블 크기가 19,133 pages로 대폭 줄었습니다. 트랜잭션이 분리되면서 배치 작업 이후에라도 Vacuum이 동작하여 공간을 회수한 것입니다. 하지만,
603,508 are dead but not yet removable
- INSERT 작업이 진행되는 도중에 오토베큠이 실행되면서, 락 경합이나 타이밍 이슈로 완벽한 청소를 하지 못했음을 의미했습니다.
5. 확실한 마무리를 위한 명시적 Vacuum
대량의 데이터를 지운 직후, 새 데이터를 넣기 전에 "vaccum부터 확실히 하고" 가구를 들여놓기로 결정했습니다. 코드 레벨에서 명시적으로 VACUUM 쿼리를 실행하는 로직을 추가했습니다.
async insertBlocks(blocks: GeoIpNetwork[], ipVersion: number) {
await this.databaseService.raw().geoIpNetwork.deleteMany({
where: { ipVersion },
})
await this.databaseService.raw().$executeRawUnsafe(`VACUUM "GeoIpNetwork";`)
const batchSize = 5000
for (let i = 0; i < data.length; i += batchSize) {
const batchData = data.slice(i, i + batchSize)
await this.databaseService.raw().geoIpNetwork.createMany({
data: batchData,
skipDuplicates: true,
})
await wait(50)
}
}
async insertLocations(locations: GeoIPLocation[]) {
await this.databaseService.raw().geoIpLocation.deleteMany({})
await this.databaseService
.raw()
.$executeRawUnsafe(`VACUUM "GeoIpLocation";`)
const batchSize = 5000
for (let i = 0; i < data.length; i += batchSize) {
const batchData = data.slice(i, i + batchSize)
await this.databaseService.raw().geoIpLocation.createMany({
data: batchData,
skipDuplicates: true,
})
await wait(50)
}
}
2026.2.12
LOG: automatic vacuum of table "public.GeoIpNetwork": index scans: 1
pages: 0 removed, 14101 remain, 6495 scanned (46.06% of total)
tuples: 604822 removed, 355050 remain, 0 are dead but not yet removable
removable cutoff: 117448642, which was 168 XIDs old when operation ended
frozen: 0 pages from table (0.00% of total) had 0 tuples frozen
index scan needed: 6494 pages from table (46.05% of total) had 604822 dead item identifiers removed
index "GeoIpNetwork_pkey": pages: 17303 in total, 1658 newly deleted, 15500 currently deleted, 13842 reusable
index "GeoIpNetwork_ipVersion_idx": pages: 8243 in total, 888 newly deleted, 7245 currently deleted, 6357 reusable
index "GeoIpNetwork_geonameId_idx": pages: 8368 in total, 464 newly deleted, 6624 currently deleted, 6160 reusable
index "GeoIpNetwork_network_idx": pages: 15960 in total, 5440 newly deleted, 9070 currently deleted, 3630 reusable
index "idx_geoipnetwork_network_cast": pages: 55619 in total, 2426 newly deleted, 37500 currently deleted, 35074 reusable
I/O timings: read: 42309.114 ms, write: 0.000 ms
avg read rate: 6.744 MB/s, avg write rate: 0.000 MB/s
buffer usage: 228453 hits, 35212 misses, 0 dirtied
WAL usage: 0 records, 0 full page images, 0 bytes
system usage: CPU: user: 1.68 s, system: 0.65 s, elapsed: 40.79 s
- Not Yet Removable: 0 (완벽한 해결)
- 이전: 180만 개의 데이터가 트랜잭션에 묶여 삭제되지 않음.
- 현재: 0. 트랜잭션 격리(raw())가 완벽하게 작동하여, 그 어떤 트랜잭션도 삭제된 데이터를 붙잡고 있지 않습니다. 오토베큠이 눈치 보지 않고 청소할 수 있게 되었습니다.
- Removed 수치의 정상화
- 이전: 0 removed. 청소하러 왔다가 허탕 침.
- 현재: 604,822 removed. 죽은 데이터를 메모리와 디스크에서 실제로 제거했습니다.
- 테이블 크기 75% 감량 (다이어트 성공)
- 최악의 시기: 61,556 pages
- 현재: 14,101 pages
- 불필요한 비대화(Bloat)가 사라지고, 초기 대비 1/4 수준으로 가벼워졌습니다. 이는 I/O 효율 증가와 쿼리 성능 향상으로 직결됩니다.
마치며
이번 트러블슈팅을 통해 프레임워크나 라이브러리가 제공하는 편의 기능(nestjs-cls) 뒤에 숨겨진 동작 원리를 이해하는 것이 얼마나 중요한지 깨달았습니다.
- Context Propagation의 양면성: 편리하지만, 의도치 않게 DB 커넥션을 오래 점유할 수 있습니다.
- DB Internals의 이해: PostgreSQL의 MVCC와 Vacuum 동작 원리를 모르면 "왜 데이터가 안 지워지지?"라는 미궁에 빠지기 쉽습니다.
- 적극적인 개입: 때로는 오토(Autovacuum)에 의존하기보다, 비즈니스 로직에 맞춰 명시적인 명령(VACUUM)을 내리는 것이 최적의 성능을 보장합니다.
- 추가적으로 워낙 많은 양의 데이터를 한번에 추가하기 때문에 ACU를 추가적으로 줄이진 못했고, 이후 완전한 변경이 필요 하다고 생각됩니다.
'Project > 기록' 카테고리의 다른 글
| 소켓 서버 지탱하기: OS 커널부터 K8s 오케스트레이션까지의 선제적 최적화 (0) | 2026.02.20 |
|---|---|
| 매일 아침 9시의 ACU 스파이크: PostgreSQL 최적화에서 CloudFront 오프로딩까지 (0) | 2026.02.20 |
| 10분마다 치는 spike 평탄화 작업기(feat. ACU 45% 절감) (0) | 2026.02.08 |
| CS 이론으로 풀어낸 EKS 소켓 서버 최적화: DNS, 커널, 그리고 오토스케일링의 함정 (0) | 2026.02.01 |
| ACU 48GB를 8GB로 84%비용 절감 (0) | 2026.01.27 |