네이버 D2 「CDC 복제 이후 오라클이 느려졌다? child cursor 폭증이 만든 예상치 못한 문제」 리뷰 — 같은 SQL인데 바인딩 타입이 다르면 Oracle은 다른 쿼리로 본다
원문: CDC 복제 이후 오라클이 느려졌다? child cursor 폭증이 만든 예상치 못한 문제 — NAVER D2, 김태성, 2025-04-08
한 줄 요약
네이버페이 주문 DB를 Oracle에서 사내 분산 DB(nBase-T)로 옮기고 Oracle을 복제본으로 돌리자, 복제용 UPDATE가 느려져 커넥션 풀이 말랐다. 원인은 Oracle이 같은 SQL 문장이라도 바인딩 값의 타입·길이가 다르면 별도의 child cursor를 만든다는 점이었다. 150개 컬럼짜리 UPDATE에 문자열 길이, NULL, 타임스탬프 자릿수가 제각각 들어오니 한 쿼리에 child cursor가 22,000개 넘게 생겼고, 응답이 0.003초에서 0.308초로 100배 느려졌다. 여덟 가지를 시도해 넷을 채택했고 child cursor가 99.8% 줄었다.
배경
Plasma 프로젝트로 주문 메인 DB가 nBase-T로 바뀌었고, 옛 Oracle은 CDC로 비동기 복제를 받는 쪽이 됐다. 호출량이 늘자 nBase-T → Oracle 동기화가 밀렸다. Pinpoint로 보니 Oracle의 UPDATE가 slow query가 되어 복제 서비스의 커넥션 풀을 고갈시키고 타임아웃을 냈다.
DBA와 로그를 보니 특정 Query ID 하나에 child cursor가 22,000개 이상이었다. 정상은 100개 이하다. 라이브러리 캐시 잠금, 뮤텍스, 커서 대기가 늘어 성능이 떨어졌다.
Oracle은 쿼리를 어떻게 실행하나
이 문제를 이해하려면 parent cursor와 child cursor를 알아야 한다.
- SQL이 오면 라이브러리 캐시에서 같은 문장이 있는지 찾는다.
- 같은 문장(parent cursor)이 있으면 그 밑의 child cursor를 본다. 없으면 하드 파싱(실행 계획을 처음부터 만드는 비싼 작업).
- 바인딩 값의 타입이 맞는 child cursor가 있으면 재사용(소프트 파싱). 없으면 새 child cursor를 만든다(부분 하드 파싱).
child cursor를 만드는 것 자체는 정상이다. 문제는 각 cursor에 동시성 제어용 뮤텍스가 있고, 새 child cursor를 만들 때 잠금이 걸린다는 것이다. 같은 쿼리가 동시에 많이 들어오면 잠금 경쟁으로 대기가 길어진다. 22,000개면 찾는 것도, 만드는 것도 느리다.
왜 child cursor를 재사용 못 했나
문장은 같은데 바인딩 타입이 안 맞는 세 가지 경우다.
케이스 1. 가변 길이 VARCHAR. Oracle은 바인딩된 문자열 길이에 따라 타입을 VARCHAR(32) → 128 → 2000 → 4000 단계로 자동 조정한다. 짧은 값이 오면 VARCHAR(32) child cursor, 긴 값이 오면 VARCHAR(2000) child cursor다. 컬럼 4개에 각각 다른 길이가 오면 최대 4⁴ = 256개 조합이다. 게다가 한 글자를 최대 4바이트(유니코드)로 계산하므로 테이블 정의가 VARCHAR(20)이라도 바인딩은 VARCHAR(128)로 잡힐 수 있다.
케이스 2. NULL 바인딩. 바인딩 시점에 Oracle은 테이블의 컬럼 타입을 보지 않고 입력값만으로 타입을 정한다. NULL이 오면 타입을 모르니 VARCHAR(32)로 잡는다. 숫자 컬럼이라도 NULL이면 VARCHAR, 값이 오면 NUMBER로 잡혀 child cursor가 둘이 된다.
케이스 3. NUMBER·TIMESTAMP의 자릿수. 소수점 이하 자릿수(scale)가 다르면 다른 타입으로 본다. 12:00:00은 TIMESTAMP(0), .123은 TIMESTAMP(3), .123456은 TIMESTAMP(6). 각각 child cursor다.
테스트로 재현했다. 같은 UPDATE를 다섯 번 실행하면서 col_1 길이를 늘리고(32→128→2000), col_3 타임스탬프 자릿수를 바꾸고(0→9), NULL이던 col_2에 숫자를 넣으니 child cursor가 정확히 1, 2, 3, 4, 5개로 늘었다. 상품 주문 테이블은 컬럼이 매우 많아 조합이 수십만 개까지 나올 수 있었다.
여덟 가지 시도
| 번호 | 시도 | 결과 |
|---|---|---|
| 1 | pstm.setNull()로 NULL 바인딩 타입 고정 | 이것만으로는 부족 |
| 2 | SQL에서 CAST로 VARCHAR(4000) 강제 | 효과 없음. 드라이버가 길이를 다시 계산 |
| 3 | pstm.setObject(scale)로 타입·자릿수 지정 | VARCHAR는 효과 없음, NUMBER에는 일부 가능성 |
| 4 | UPDATE 대상 필드 150개 → 100개 | 줄었지만 정상(10~100)까지는 아님 |
| 5 | 호출마다 랜덤 주석으로 parent cursor 분산 | 뮤텍스 경쟁은 줄었으나 전체 cursor는 늘어 캐시 eviction 증가 |
| 6 | 세션 이벤트 10503으로 VARCHAR 바인딩을 4000으로 고정 | 고정은 되지만 평소 성능 저하 우려, 풀 전체에 영향 |
| 7 | 큰 UPDATE를 둘로 분리(256 → 32 조합) | 줄었지만 DBA 비권장이라 미채택 |
| 8 | TIMESTAMP를 밀리초로 truncate 후 바인딩 | 확연한 개선 |
8번이 흥미롭다. nBase-T는 타임스탬프를 밀리초로 만드는데 옛 Oracle은 초 단위(DATE)였다. 복제 과정에서 자릿수가 섞인 것이다. 원래 Oracle 저장 방식대로 잘라서 넣으니 케이스 3이 사라졌다.
최종 선택과 결과
채택은 1, 3, 4, 8이다. 기준은 명확하다. 5·6·7은 뮤텍스 경쟁을 완화할 뿐 BIND_MISMATCH의 원인을 없애지 못한다. child cursor가 계속 생기면 장기적으로 DB 부하가 된다. 그래서 cursor가 계속 생기는 케이스 2(NULL)와 케이스 3(자릿수)을 없애는 데 집중했다. 케이스 1(VARCHAR 길이)은 완전히 못 잡았지만, 2·3이 잡히면 재사용이 어느 정도 되므로 알려진 이슈로 남겼다.
결과는 쿼리 성능이 안정되면서 child cursor 99.8% 감소.
왜 어려웠나
원문이 스스로 짚은 점 두 가지가 정확하다. 애플리케이션에서는 평소 감지가 안 되고 DB 수준 분석이 필요하다. 그리고 쿼리 튜닝만으로는 안 되고 서비스 코드(드라이버 바인딩, 복제 로직)를 같이 고쳐야 한다. 상품 주문 테이블은 컬럼이 많아 문제가 드러났지만, 이미 전환된 작은 테이블들에서도 같은 일이 조용히 있었을 것이고 이번 조치로 같이 해결됐을 것이라고 본다.
또 하나. 점진적 전환의 10% 트래픽 단계에서 발견했다. 100%였으면 장애였다.
읽고 남는 질문
- 케이스 1을 근본적으로 잡는 방법이 없는지. 드라이버가 길이를 다시 계산한다면, 애초에 복제 측에서 컬럼 정의 길이에 맞춰 패딩하거나
setObject에 명시적 길이를 주는 확장 API가 있는지 궁금하다. - 시도 6(세션 이벤트)의 “평소 성능 저하 우려”가 실측인지 우려인지 구분이 안 된다. 복제 전용 풀이라면 풀 전체 영향도 감수할 만하지 않았을까.
- 22,000개라는 수치는 문제 시점의 것이고, 99.8% 감소 후 절대 수가 얼마인지(정상 범위 100 이하에 들어갔는지)가 적혀 있지 않다.
한 줄로 가져가기
Oracle에 바인드 변수를 쓴다고 실행 계획이 재사용되는 것이 아니다. 값의 타입·길이·자릿수까지 같아야 하고, NULL과 타임스탬프 정밀도는 그것을 조용히 깨뜨린다.
댓글
아직 댓글이 없습니다