본문 바로가기

Project/기록

매일 아침 9시의 ACU 스파이크: PostgreSQL 최적화에서 CloudFront 오프로딩까지

서비스를 운영하다 보면 종종 '당연하게 여겼던 아키텍처'가 시스템의 발목을 잡는 순간을 마주하게 됩니다.

저희 팀은 사용자의 IP를 기반으로 국가 및 지역 정보를 판별하기 위해 MaxMind(GeoLite2) 데이터베이스를 활용하고 있었습니다. 하지만 이로 인해 매일 아침 9시마다 데이터베이스(Aurora PostgreSQL)의 ACU(Aurora Capacity Unit)가 9 이상으로 치솟는 스파이크 현상을 겪어야 했습니다.

이번 글에서는 이 불필요한 스파이크를 해결하기 위해 PostgreSQL의 내부 메커니즘을 파헤치며 최적화를 진행했던 과정과, 궁극적으로 CloudFront 인프라로 역할을 오프로딩하여 문제를 원천 차단한 아키텍처 전환기를 공유하고자 합니다.


발단: 매일 아침 9시, 튀어 오르는 ACU 스파이크

사용자의 위치 정보를 파악하기 위해 시스템은 매일 최신 IP 대역 데이터를 갱신해야 했습니다. 매일 아침 9시에 동작하는 배치(Batch) 서버는 수백만 건의 IPv4/IPv6 데이터를 GeoIpNetwork 테이블에서 DELETE 하고 새롭게 INSERT 하는 작업을 수행했습니다.

문제는 이 과정에서 Read/Write ACU가 9 이상으로 급증하며 데이터베이스에 심각한 부하를 준다는 점이었습니다. 매일 같이 반복되는 이 쓸데없는 스파이크는 모니터링 과정에서 상당한 시스템 불안정성을 유발했습니다.


PostgreSQL 딥다이브와 Vacuum 최적화

초기에는 이 문제를 '소프트웨어와 데이터베이스의 최적화' 관점에서 접근했습니다.

ACU 스파이크와 테이블 비대화(Bloat) 현상을 분석한 결과, NestJS의 트랜잭션 관리 도구(nestjs-cls)가 컨텍스트를 유지하면서 180만 건의 데이터 삭제/삽입을 하나의 긴 트랜잭션으로 묶어버린 것이 원인이었습니다. 이로 인해 PostgreSQL의 MVCC(Multi-Version Concurrency Control) 메커니즘 상 Autovacuum이 Dead Tuple을 청소하지 못하고 가시성 차단(Visibility Blocker)이 발생하고 있었습니다.

최적화 과정

  • 트랜잭션 격리: tx() 대신 의존성을 우회하는 raw() 클라이언트를 사용하여 DELETE 쿼리가 즉시 커밋(Auto-Commit) 되도록 분리했습니다.
  • 명시적 청소: 오토베큠의 불확실성을 배제하고, INSERT 전에 코드 레벨에서 명시적으로 VACUUM 명령을 실행하여 빈 공간을 강제로 확보했습니다.
  • 부하 분산: 데이터를 Chunk 단위로 나누고, 짧은 딜레이를 주어 I/O 병목을 해소했습니다.

이 최적화를 통해 막대하게 불어나던 테이블 크기를 1/4 수준으로 감량하고 트랜잭션 고립 문제를 완벽하게 해결하며 데이터베이스를 최적화 상태로 되돌릴 수 있었습니다. 하지만, DB의 성능은 개선되었음에도 마음 한구석에는 "본질적인 의문"이 남아있었습니다.


전환점: "이걸 굳이 DB에서 해야 해?"

DB 최적화에 성공했지만, 아키텍처 자체의 유지보수 비용은 여전히 존재했습니다. 기존에 csv 다운로드 후 DB에 Hard delete, insert하는 대신 docker image로 다운 받아 인메모리에 올려서 사용하는 방법을 고려했습니다. 하지만, 아래와 같은 문제가 있었습니다.

  • 운영 리소스의 낭비: MaxMind DB를 최신 상태로 유지하려면 1주일에 한 번씩 롤링(Rolling) 업데이트를 해줘야 했습니다.
  • 오버엔지니어링의 유혹: 물론 이 업데이트 과정을 Kubernetes(K8s)의 CronJob을 활용해 자동화 파이프라인으로 구성할 수도 있었습니다. 하지만 단지 'IP로 국가를 판별한다'는 목적 하나를 위해 K8s Job, DB 테이블 유지, 배치 서버를 모두 관리하는 것이 과연 효율적일까? 고민이 되었습니다.

결론은 "굳이 그럴 필요 없다"였습니다. 우리는 방향을 틀어, 애플리케이션 계층(DB/Backend)에서 수행하던 무거운 처리를 인프라 계층으로 완전히 걷어내기로 판단했습니다.


아키텍처 전환: AWS CloudFront의 도입

AWS CloudFront는 이미 전 세계의 엣지 로케이션을 통해 클라이언트의 요청을 받아내고 있습니다.

CloudFront는 유저의 IP를 기반으로 한 위치 정보와 디바이스 정보를 판별하는 기능을 자체적으로 제공합니다. 기존의 복잡했던 DB 아키텍처를 모두 접고, CloudFront를 앞단에 붙이는 작업에 착수했습니다.

백엔드 로직의 단순화가 되었습니다. 이제 백엔드로 들어오는 HTTP 요청 헤더(headers)에는 우리가 필요로 하는 모든 데이터가 담겨옵니다.

{
  "cloudfront-viewer-country": "KR",
  "cloudfront-viewer-city": "Gangnam-gu",
  "cloudfront-is-mobile-viewer": "true",
  "cloudfront-viewer-latitude": "37.52450",
  "cloudfront-viewer-longitude": "127.03540"
}

기존처럼 IP를 CIDR로 캐스팅하여 데이터베이스에서 '<<' 연산자로 무겁게 검색(WHERE $1::inet << network::inet)할 필요가 없어졌습니다. 단지 Request Header를 파싱하여 값을 꺼내 쓰기만 하면 위치 판별과 디바이스 체크가 완료됩니다.


결과 및 회고: 최고의 코드는 작성하지 않은 코드

이 아키텍처 전환을 통해 얻은 결과는 명확했습니다.

  • ACU 스파이크 0: 매일 아침 9시마다 우리를 괴롭히던 부하 그래프는 완전히 평온해졌습니다. 더 이상 스파이크는 보이지 않습니다.
  • 유지보수 비용 제로: GeoIP 갱신을 위한 배치 서버, K8s Rolling Job, 그리고 수백만 건의 데이터가 쌓이던 PostgreSQL 테이블을 모두 삭제했습니다.
  • 성능 향상: DB I/O가 제거되었을 뿐만 아니라, Edge 단에서 처리된 정보를 헤더로 즉시 받아보므로 응답 속도(Latency)가 크게 개선되었습니다.

이번 경험으로 PostgreSQL의 내부 동작(MVCC, Vacuum, Transaction Isolation)을 깊이 있게 파헤치고 튜닝하여 문제를 해결해 내는 기술적 깊이(Deep Dive)도 중요했지만, 시야를 넓혀 인프라 레벨의 기능을 활용해 문제 자체를 소멸시키는 아키텍처적 사고가 얼마나 강력한지 다시금 깨달을 수 있었습니다. 때로는 치열하게 코드를 튜닝하는 것보다, 한 발짝 물러서서 "이 책임을 다른 계층으로 넘길 수 없을까?"라고 질문하는 것도 필요하다고 생각됩니다. 그리고 이로 인해서 마케팅, 전략, 기획팀이 원하는 유저의 접근 경로 파악하기에 유용했기에 개발팀과 비개발팀간의 니즈도 올라간 경험이라고 생각합니다.

코드 단에서의 고민을 바탕으로 작성한 글 입니다.