📘 OEA

운영 중인 시스템을 보다 보면, 서로 다른 계층의 동작을 하나의 문제로 묶어서 해석하게 되는 순간이 있습니다. PostgreSQL의 VACUUM과 애플리케이션의 DB ConnectionPool도 딱 그렇습니다.

처음에는 이렇게 생각하기 쉽습니다.

“DB에서 VACUUM이 돌고 있었고, 우리 애플리케이션은 ConnectionPool을 쓰고 있다.

그렇다면 DB 작업이 오래 걸리거나 대기 상태가 생기면 ConnectionPool timeout과도 바로 연결되어야 하는 것 아닌가?”

저도 처음에는 비슷하게 봤습니다.

그런데 실제 구조를 다시 따라가 보니, 문제는 timeout 값이 이상한 게 아니라 대기가 발생한 위치를 잘못 보고 있었던 것에 가까웠습니다.

이번 글은 그 지점을 정리한 기록입니다.

전제는 분명합니다.

  • OEA는 DB에 데이터를 적재하는 프로그램입니다.
  • VACUUM은 OEA가 수행한 작업이 아닙니다.
  • VACUUM은 다른 곳에서, 다른 세션으로 별도로 수행되고 있었습니다.
  • 그 시점에 OEA는 평소처럼 ConnectionPool을 통해 DB에 데이터를 넣고 있었습니다.

즉 이 글의 질문은 “OEA가 VACUUM을 어떻게 실행하느냐”가 아닙니다.

정확한 질문은 이것입니다.

“외부에서 VACUUM이 진행되는 동안, OEA의 ConnectionPool과 DB 내부 대기는 어떤 관계로 이해해야 하는가?”


1. 먼저 역할을 분리해서 봐야 한다

이 문제를 이해하려면 가장 먼저 OEA의 역할VACUUM의 역할을 분리해야 합니다.

OEA의 역할은 단순합니다.

메시지를 받아서 DB에 INSERT하거나 필요한 적재 작업을 수행합니다.

반면 VACUUM의 역할은 전혀 다릅니다.

PostgreSQL 내부에서 dead tuple을 정리하고, 재사용 가능한 공간을 확보하고, 테이블이 계속 비대해지는 것을 완화하는 유지보수 작업에 가깝습니다.

즉 둘은 같은 DB를 바라보지만, 애초에 목적이 다릅니다.

  • OEA: 데이터를 넣는 쪽
  • VACUUM: 저장소를 정리하고 유지하는 쪽

이걸 분리하지 않으면, INSERT 지연 문제, lock 대기, pool timeout, VACUUM 실행 상태를 한 덩어리로 묶어서 해석하게 됩니다. 운영에서 원인을 틀리게 잡는 출발점이 보통 여기입니다.


2. OEA는 커넥션을 어떻게 쓰고 있었는가

OEA 쪽 구조를 보면, 핵심은 ConnectionPool 기반의 재사용입니다.

애플리케이션은 HikariCP 같은 ConnectionPool을 통해 DB에 접근하고, 실제 SQL 실행은 JdbcTemplate 계열을 통해 수행됩니다. 이 구조에서 중요한 것은 커넥션을 매번 새로 열고 닫는 것이 아니라, pool 안에 이미 존재하는 물리 커넥션을 재사용한다는 점입니다.

흐름을 단순화하면 이렇습니다.

메시지 수신
→ OEA가 DB 작업 호출
→ ConnectionPool에서 커넥션 획득
→ INSERT 또는 batch INSERT 수행
→ 작업 종료
→ close()
→ 물리 연결 종료가 아니라 Pool 반납

여기서 많은 분들이 한 번씩 헷갈립니다.

코드에서 close()가 호출되면 DB 연결이 완전히 끊겼다고 생각하기 쉽습니다. 하지만 ConnectionPool을 쓰는 환경에서 close()는 대부분 실제 연결 종료가 아니라 반납입니다.

즉 OEA는 INSERT를 처리할 때마다:

  • DB에 매번 새로 TCP 연결을 맺고 끊는 것이 아니라
  • pool에 이미 열려 있는 커넥션을 잠깐 빌려 쓰고
  • 작업이 끝나면 다시 pool에 돌려놓습니다

이 구조를 정확히 이해해야 뒤에 나오는 timeout과 lock 대기 이야기가 자연스럽게 이어집니다.


3. ConnectionPool은 “커넥션 획득”을 관리하지, “DB 내부 lock 대기”를 관리하지 않는다

이번 주제에서 가장 중요한 문장은 이겁니다.

ConnectionPool은 커넥션을 빌리고 반납하는 계층이고, lock 대기는 커넥션을 이미 확보한 뒤 PostgreSQL 내부에서 발생하는 계층입니다.

이 차이를 놓치면, DB에서 생긴 모든 대기를 ConnectionPool 문제처럼 보게 됩니다.

예를 들어 connectionTimeout=30000이라는 설정을 보면 직관적으로는 이렇게 읽히기 쉽습니다.

“DB 작업이 30초 넘으면 터지는 값인가 보다.”

그런데 실제 의미는 대체로 그렇지 않습니다.

이 값은 보통 Pool에서 사용 가능한 커넥션을 얻기 위해 얼마나 기다릴 것인가를 뜻합니다.

즉 이런 상황에서만 직접 의미가 있습니다.

  • 동시에 너무 많은 요청이 몰렸고
  • pool 안의 커넥션이 모두 사용 중이며
  • 새로운 작업이 커넥션을 빌리려고 하는데
  • 일정 시간 안에 반환된 커넥션이 없을 때

이럴 때 connectionTimeout 예외가 납니다.

반대로, 이미 커넥션을 하나 빌려서 SQL을 실행하고 있다면 그 시점부터는 이야기가 달라집니다. 그 다음 단계는 pool 계층이 아니라 DB 내부 실행 계층입니다. 이제 영향을 주는 것은 다음과 같은 것들입니다.

  • SQL 자체의 실행 시간
  • PostgreSQL 내부 lock 획득 대기
  • statement timeout
  • lock timeout
  • 디스크 I/O, WAL, 체크포인트, autovacuum 부하
  • 드라이버/네트워크 상태

커넥션을 못 빌려 기다리는 것커넥션은 이미 빌렸지만 DB 내부에서 기다리는 것은 전혀 다른 문제입니다.


4. 이번 상황에서 connection pool timeout 오류가 안 난 이유

이번 케이스를 다시 보면, OEA는 DB에 새 연결을 시도하다가 막힌 것이 아닙니다.

이미 pool 안에 열려 있는 물리 커넥션을 빌려서 쓰고 있었고, 작업 후에는 끊는 것이 아니라 반납하는 구조였습니다.

그렇다면 외부에서 VACUUM이 진행될 때 대기가 생겼다고 하더라도, 그 대기를 곧바로 “커넥션 획득 대기”라고 보면 안 됩니다. 더 자연스러운 해석은 이쪽입니다.

OEA는 이미 커넥션을 확보한 상태였고, 그 이후 PostgreSQL 내부에서 lock을 얻거나 SQL을 실행하는 단계에서 대기했을 가능성이 높다.

이 관점으로 보면 왜 connectionTimeout 오류가 나지 않았는지도 설명이 됩니다.

connectionTimeout이 감시하는 것은 pool에서 커넥션을 못 빌려 기다리는 시간입니다.

하지만 이번 상황에서 문제의 지점은 그 단계가 아니었습니다. 이미 커넥션을 확보한 뒤의 단계였기 때문에, pool timeout이 개입할 일이 없었던 것입니다.

즉 이번 상황은 이렇게 표현하는 게 더 정확합니다.

“ConnectionPool에서 커넥션을 못 얻어서 기다린 게 아니라, 이미 확보한 DB 세션 안에서 VACUUM과 관련된 lock 또는 실행 대기가 발생한 상황이었다.”

이 차이를 이해하면, “왜 timeout 30초인데 안 죽었지?”라는 질문도 자연스럽게 풀립니다. timeout이 동작하지 않은 것이 아니라, 애초에 그 timeout이 담당하는 단계의 문제가 아니었던 것입니다.


5. 일반 VACUUM과 lock 대기를 같이 봐야 하는 이유

실무에서는 “VACUUM이 도는 중이었다”라는 문장만으로는 부족합니다.

중요한 것은 그 시점에 DB 내부에서 어떤 lock 경합이 있었는지까지 같이 봐야 한다는 점입니다.

일반적인 VACUUM은 PostgreSQL에서 평소에도 돌아가는 유지보수 작업이고, 보통 일반적인 INSERT와 완전히 배타적으로 충돌하는 작업은 아닙니다. 그래서 원칙적으로는 “VACUUM이 돈다 = INSERT가 무조건 멈춘다”는 식으로 보면 안 됩니다.

하지만 그렇다고 해서 lock 대기와 완전히 무관한 것도 아닙니다.

실제 운영에서는 다음과 같은 식으로 대기가 생길 수 있습니다.

  • VACUUM이 자기 작업을 위해 필요한 lock을 잡으려는데, 이미 더 강한 lock이 선행되어 있어서 기다리는 경우
  • 반대로 OEA 쪽 SQL이 실행되는 시점에 다른 트랜잭션이나 DDL, 유지보수 작업 때문에 내부적으로 대기하는 경우
  • 같은 테이블, 인덱스, 카탈로그, 메타데이터 작업이 겹치면서 직접적인 쿼리 충돌이 아니라도 체감 지연이 생기는 경우

즉 포인트는 “VACUUM이 있어서 무조건 막혔다”가 아니라, 이미 세션은 연결된 상태에서 DB 내부 lock 획득 또는 실행 단계의 대기가 발생했을 수 있다는 것입니다.

이 문장을 넣어야 이번 상황이 훨씬 현실적으로 설명됩니다.


6. 그래서 이번 상황을 가장 정확하게 표현하면

이번 케이스를 가장 정확하게 풀어쓰면 이렇습니다.

OEA는 ConnectionPool을 통해 이미 열려 있는 물리 커넥션을 재사용하고 있었고, INSERT 작업 후 close()는 실제 연결 종료가 아니라 pool 반납의 의미였습니다. 따라서 외부에서 VACUUM이 진행되는 시점에 대기가 발생했다고 하더라도, 그것은 “새 DB 연결을 시도했는데 connection pool에서 막힌 상황”이라기보다, 이미 커넥션을 확보한 상태에서 PostgreSQL 내부의 lock 획득 또는 SQL 실행 단계에서 기다린 상황으로 보는 편이 더 자연스럽습니다.

즉 이번 상황을 해석할 때 중요한 것은 “VACUUM 때문에 pool timeout이 왜 안 났지?”가 아니라, 대기가 어느 계층에서 발생했는가를 구분하는 것입니다.

  • Pool에서 커넥션을 못 빌려 기다리는가
  • 이미 커넥션은 확보했고 DB 내부에서 lock을 기다리는가
  • SQL 자체가 느린가
  • VACUUM/DDL/다른 트랜잭션과 자원을 두고 경쟁하는가

이걸 구분하지 않으면 원인을 항상 한 단계 앞이나 뒤에서 찾게 됩니다.


7. VACUUM과 ConnectionPool의 연관 관계는 “직접”이 아니라 “간접”이다

여기까지 정리하면 둘의 관계를 이렇게 설명할 수 있습니다.

VACUUM과 ConnectionPool은 서로를 직접 제어하는 관계는 아닙니다.

  • ConnectionPool은 애플리케이션의 커넥션 획득/반납을 관리합니다.
  • VACUUM은 PostgreSQL 내부에서 별도 세션으로 저장소를 정리합니다.

하지만 같은 DB 자원을 공유하기 때문에, 운영 성능 측면에서는 분명히 간접적으로 연결됩니다.

예를 들어:

  • OEA가 INSERT를 많이 수행하면 WAL, buffer, I/O, CPU 사용량이 커집니다.
  • VACUUM도 동일한 DB 자원을 사용합니다.
  • 둘이 같은 시점에 돌면 체감 지연이 늘 수 있습니다.
  • 그 결과 쿼리 수행 시간이 길어지고, 커넥션 점유 시간이 길어져, 2차적으로 pool 압박이 생길 수도 있습니다.

여기서도 순서가 중요합니다.

VACUUM이 ConnectionPool timeout을 직접 발생시키는 것이 아니라, DB 내부 부하나 lock 대기가 먼저 생기고, 그 결과 커넥션이 오래 점유되어 pool 사용 압력이 커질 수 있는 것입니다.

즉 직접 원인과 간접 결과를 섞어서 보면 안 됩니다.


8. 운영에서 진짜로 봐야 할 포인트

이번 케이스를 통해 남는 교훈은 분명합니다.

DB 관련 timeout이나 대기를 볼 때는 숫자 하나보다 문제가 발생한 계층을 먼저 봐야 합니다.

같은 30초라도 의미가 다릅니다.

  • connection timeout 30초 → Pool에서 커넥션을 빌리기 위해 기다리는 시간
  • statement timeout 30초 → SQL 실행 허용 시간
  • lock timeout 30초 → lock을 기다리는 시간

이번 상황에서 중요한 것은 첫 번째가 아니라 두 번째, 세 번째 가능성이었습니다.

왜냐하면 OEA는 이미 pool 안의 커넥션을 재사용하고 있었고, 대기가 생겼다면 그건 커넥션 획득 단계가 아니라 DB 내부 실행 단계일 가능성이 더 높았기 때문입니다.

운영에서는 결국 이런 질문을 해야 정확해집니다.

  • Pool에 실제로 빈 커넥션이 없었는가
  • 아니면 커넥션은 있었는데 SQL이 내부에서 대기했는가
  • lock 대기가 있었는가
  • 대기 주체는 OEA였는가, VACUUM이었는가, 다른 세션이었는가
  • 일반 VACUUM이었는가, VACUUM FULL이었는가
  • 장기 트랜잭션이나 DDL이 같이 있었는가

이 정도로 분해해서 봐야 원인을 제대로 잡을 수 있습니다.


결론

VACUUM과 DB ConnectionPool의 연관 관계를 한 문장으로 정리하면 이렇습니다.

둘은 같은 DB를 공유하지만 서로를 직접 제어하는 관계는 아니다. ConnectionPool은 커넥션 획득과 반납을 관리하고, VACUUM은 별도 세션에서 DB 저장소를 정리한다. 다만 실제 운영에서는 같은 DB 자원과 lock 환경을 공유하기 때문에 간접적으로 충분히 영향을 주고받는다.

그리고 이번 상황을 조금 더 구체적으로 정리하면 이렇게 말하는 것이 가장 정확합니다.

OEA는 ConnectionPool을 통해 이미 열려 있는 물리 커넥션을 재사용하고 있었고, INSERT 후 close()는 연결 종료가 아니라 pool 반납이었다. 따라서 외부에서 VACUUM이 진행되는 동안 대기가 발생했다면, 그것은 connection pool에서 커넥션을 얻지 못해 기다린 상황이 아니라, 이미 확보한 DB 세션에서 PostgreSQL 내부 lock 또는 실행 단계의 대기가 발생한 상황으로 보는 편이 더 정확하다. 그래서 connection pool timeout 오류가 나지 않은 것이다.

처음에는 “왜 timeout이 안 걸렸지?”라고 생각했지만, 실제로는 timeout이 이상했던 것이 아닙니다.

애초에 timeout이 적용되는 계층과, 실제 대기가 발생한 계층이 달랐던 것입니다.

운영에서는 이 경계를 정확히 이해하는 순간, 문제를 훨씬 빨리 좁힐 수 있습니다.

태그:

카테고리:

업데이트: