관리 메뉴

피터의 개발이야기

PostgreSQL의 캐싱이란, cached plan must not change result type 에러분석 본문

Database/PostgreSQL

PostgreSQL의 캐싱이란, cached plan must not change result type 에러분석

기록하는 백앤드개발자 2025. 12. 24. 02:18
반응형

ㅁ 들어가며

  PostgreSQL을 사용하면서 cached plan must not change result type 에러를 마주했다면,
이는 단순한 JDBC 오류가 아니라 PostgreSQL과 JDBC 드라이버가 사용하는 캐싱 전략을 이해하지 못해 발생하는 문제다.

이번 글에서는 내가 몰랐던 PostgreSQL과 pgjdbc의 캐싱 기능을 중심으로,  캐싱의 목적, 작동 방식, 그리고 대표적인 에러와 원인을 간단히 정리한다. 

update....

  나중에 새롭게 안 사실인데 실제 오류 상황은 Select가 아닌 insert와 update 상황에서 발생하였다. 하단에 보충하여 정리하였다.

 

ㅁ PostgreSQL과 JDBC의 캐싱은 무엇을 위한 것인가

ㅇ 캐싱의 목적은 디스크 I/O와 쿼리 준비 비용을 줄여 성능을 높이기 위함이다.

  • PostgreSQL은 데이터를
    “행(row)”이 아니라 페이지(page) 단위로 캐시한다.
  • JDBC 드라이버(pgjdbc)는
    반복 실행되는 SQL에 대해 Prepared Statement 실행 계획과 결과 타입 정보를 캐시한다.

즉, 서버와 클라이언트 모두
“이미 계산한 것은 다시 계산하지 않는다”는 전략을 취한다.

 

ㅁ PostgreSQL 캐싱의 작동 방식

ㅇ PostgreSQL의 핵심 캐시는 데이터 페이지 캐시다.

  • 디스크에서 한 번 읽은 데이터 페이지는
    PostgreSQL 내부의 shared_buffers에 저장된다.
  • 같은 데이터가 다시 필요하면 디스크를 읽지 않고 메모리에서 바로 사용한다.
  • shared_buffers에 없을 경우, OS의 page cache를 확인하고
    거기에도 없을 때만 실제 디스크 I/O가 발생한다.
  • 이 구조 덕분에 PostgreSQL은 디스크 기반 DB임에도
    메모리 DB에 가까운 성능 특성을 보인다.

 

ㅁ pgjdbc(Prepared Statement) 캐싱의 작동 방식

ㅇ JDBC 드라이버도 성능을 위해 캐싱을 사용한다.

  • 동일한 SQL이 반복 실행되면, pgjdbc는 동일한 SQL을 Key로 하여 서버 측 Prepared Statement를 생성한다.
  • 이 Prepared Statement에는 다음 정보가 고정된다.
    • 실행 계획
    • 결과 컬럼 개수
    • 결과 컬럼 타입(OID)
  • 이후 같은 커넥션에서 동일 SQL이 실행되면, 이 cached plan을 그대로 재사용한다.

즉, 드라이버와 서버는
  “이 SQL은 항상 같은 형태의 결과를 반환한다”고 가정한다.

 

ㅁ 대표적인 에러: cached plan must not change result type

ㅇ 이 에러는 캐시된 실행 계획의 가정이 깨졌을 때 발생한다.

  • Prepared Statement가 만들어질 당시의 결과 타입과
    실제 쿼리 실행 결과의 타입이 달라지면 에러가 발생한다.
  • 대표적인 원인은 다음과 같다.
    • 컬럼 타입 변경
    • 컬럼 추가/삭제
    • Projection / DTO 쿼리 변경
  • Spring Boot + JPA + HikariCP 환경에서는
    커넥션이 오래 유지되기 때문에,
    스키마 변경 후에도 이전 prepared plan이 남아 있는 커넥션이 문제를 일으킨다.

ㅇ 결과적으로
“옛 스키마 기준으로 캐시된 plan”과
“새 스키마 기준 쿼리”가 충돌하면서 에러가 발생한다.

 

ㅁ 실무적인 대응 전략

ㅇ 이 에러는 캐싱 자체의 문제가 아니라, 캐시된 실행 계획과 스키마 변경이 어긋났을 때 발생한다.
ㅇ 따라서 대응 전략은

    “캐시를 없애거나”,

    “캐시가 남지 않도록 관리하거나”,

    “애초에 충돌이 나지 않게 설계하는 것”으로 나뉜다.

 

드라이버 옵션 활용

prepareThreshold=0
 - 서버 측 Prepared Statement 캐싱을 비활성화한다.

 - 실행 계획과 결과 타입이 고정되지 않으므로,
    cached plan must not change result type 문제를 원천적으로 제거할 수 있다.

 - 매 실행마다 새 plan이 생성되어 성능은 다소 손해를 보지만,
    마이그레이션이 잦은 환경에서는 안정성이 훨씬 중요한 선택이다.

autosave=conservative

 - 스키마 변경 등으로 오류가 발생했을 때,
    드라이버가 내부 savepoint 기반으로 재실행을 시도하며
    문제가 된 prepared plan을 폐기하도록 돕는다.
 - 근본 해결책은 아니지만,
    운영 중 예외 상황에 대한 완충 장치로 의미가 있다.

 

운영 / 배포 패턴 조정

ㅇ 이 에러는 대부분 “스키마는 바뀌었지만, 커넥션은 그대로인 상태”에서 발생한다.

ㅇ 마이그레이션 직후 애플리케이션 재기동 또는 커넥션 풀 플러시를 통해 오래된 prepared statement가 남아 있는 커넥션을 제거한다.

ㅇ DDL 변경과 애플리케이션 배포를 가능한 한 함께 진행해, “새 스키마 + 오래된 plan” 상태가 공존하는 시간을 최소화한다.

 

쿼리 / 스키마 설계 관점

ㅇ 컬럼 타입을 직접 변경하기보다는

   - 새 컬럼 추가 
   - 애플리케이션 코드에서 새 컬럼 사용으로 전환
   - 충분한 시간 후 기존 컬럼 제거와 같은 단계적 마이그레이션 패턴을 적용한다.

 

ㅁ 마무리

cached plan must not change result type 에러는 PostgreSQL이 까다로워서 생기는 문제가 아니다.

  • PostgreSQL의 데이터 페이지 캐시
  • pgjdbc의 Prepared Statement 캐싱
  • 커넥션 풀의 재사용
  • 운영 중 스키마 변경

이 네 가지가 동시에 맞물리며 발생하는 전형적인 캐싱 부작용이다.

이 구조를 이해하고 나면,
  왜 마이그레이션 후 재기동이 권장되는지,
  왜 드라이버 옵션 하나가 안정성에 큰 영향을 주는지도 자연스럽게 이해할 수 있다.

 

결국 이 에러는
  “캐싱이 잘 동작하고 있다는 증거”이자,
  캐싱을 고려하지 않은 스키마 변경의 비용을 보여주는 사례라고 볼 수 있다.

 


 

ㅁ 왜 insert/update에서 캐시(result type) 충돌이 생기는가?

  PostgreSQL에서 cached plan must not change result type 오류SELECT가 아닌 insert/update에서 발생하였다.

 특히 “결과를 쓰지 않는 DML”이라 생각한 쿼리가, 드라이버/프레임워크 설정에 의해 예상치 못한 반환(result)을 갖게 되면서,    커넥션에 묶인 prepared statement 캐시와 충돌하여 오류가 발생하였다. insert/update는 보통 “결과가 없다”고 생각하지만, 실제로는 프레임워크/드라이버가 RETURNING을 붙이면 결과가 생긴다. 이때 result type이 생기면서 cached plan의 고정 조건을 건드리게 된다. 

 

 동일한 SQL이 반복 실행되면, 동일한 SQL을 Key로 하여 서버 측 Prepared Statement(cached plan)를 생성한다. Prepared Statement는

    “커넥션 단위”로 유지되며, 준비(prepare) 시점에 결과 컬럼의 개수/타입이 고정되는데,

    실행 결과의 컬럼 구조나 타입이 달라지면 서버는 cached plan must not change result type 오류를 발생 시킨다.

 

 

ㅁ “RETURNING”이 암묵적으로 붙는 순간, DML도 ‘결과 타입’을 갖는다

configuration.setUseGeneratedKeys(true)

MyBatis에서 위와 같은 설정이 켜져 있으면, DB/드라이버 구현에 따라 insert/update에 대해 생성 키 조회를 위한 동작이 붙는다.

ㅇ 특히 PostgreSQL 계열에서는 명시적으로 RETURNING을 사용하지 않았더라도, RETURNING *처럼 모든 컬럼을 반환하는 형태로 실행된다.

결과적으로
  “단순 DML”이 아니라 “DML + 반환 컬럼”이 되고,

  이 반환 컬럼의 구조가 곧 cached plan의 result type으로 고정된다.

 

ㅁ Primary Key가 없는 레거시 테이블에서 위험이 커지는 이유

ㅇ PK가 있으면 “생성된 키 1개”로 반환 구조가 단순해지기 쉽다.

ㅇ 하지만 PK가 없거나, 생성 키를 특정할 수 없거나, 프레임워크가 명확한 반환 컬럼을 지정하지 않으면,
“전체 컬럼 반환(예: RETURNING *)” 으로 반환하게 된다.

ㅇ 이때 “모든 컬럼의 수와 타입이 cached plan에 고정된다.

 

ㅁ insert/update에서만 에러가 발생한 이유는?

ㅇ DB에 컴럼을 추가하였지만, 운영 쿼리들이 컬럼을 지정하여 사용하여 결과가 달라지지 않았다. 

ㅇ 하지만 insert/update 구문에서 자동으로  RETURNING * 가 붙게 되면 결과는 달라지게 된다.

ㅇ 재현 시나리오를 정리하면

커넥션 A에서 DML이 RETURNING * 형태로 준비되어 cached plan에 등록됨
    → 이후 테이블 컬럼 추가
    → 커넥션 A가 재사용되어 같은 DML 실행
    → cached plan이 기대한 result type과 실제 result type 불일치
    → 오류 발생”

 

반응형

'Database > PostgreSQL' 카테고리의 다른 글

PostgreSQL Docker 설치  (0) 2024.04.18
Comments