오늘은 특정 쿼리에 WHERE 조건을 추가했는데 오히려 조회 속도가 크게 느려졌던 경험과, 일정상 근본적인 해결을 기다릴 수 없어 우회했던 방법을 공유해보려고 합니다.
일반적으로 WHERE 조건을 추가하면 조회 대상이 줄어들기 때문에 쿼리가 더 빨라질 것이라고 생각했습니다.
하지만 WHERE 조건은 반환할 데이터의 수를 줄이지만, 데이터베이스가 쿼리를 실행하는 비용까지 항상 줄여주는 것은 아니었습니다.
문제 상황
Spring Batch를 개발하던 중, 외부 기관에서 제공한 Oracle View를 조회하고 데이터를 가공하여 내부 DB에 저장하는 배치가 실행되지 않고 멈추는 문제가 발생했습니다. 별도의 오류는 발생하지 않았지만, 일정 시간이 지나도 Job이 종료되지 않았습니다.
처음에는 당연히 간단한 쿼리로 조회만 하는 Reader보다는 Processor나 Writer 쪽을 의심했습니다. 특히 Writer에서 데이터를 저장할 때 서브쿼리를 사용하는 부분이 있었기 때문에, 이 쿼리 때문에 INSERT 속도가 느려져 배치가 멈춘 것처럼 보인다고 판단했습니다. 그래서 Writer 쿼리를 여러 번 수정하며 테스트했지만 결과는 달라지지 않았습니다.
혹시나 싶어 Reader, Processor, Writer 각 단계에 로그를 하나씩 찍어봤는데, Processor 진입 로그조차 단 한 번도 출력되지 않았습니다.
1
2
3
4
5
@Override
public TargetRecord process(SourceRecord item) {
log.info("Processor 진입: sourceId={}", item.getSourceId());
return convert(item);
}
이를 확실히 하기 위해 Processor의 로직을 걷어내고, Writer도 아무 작업을 하지 않는 no-op 형태로 바꿔서 실행해봤습니다.
1
2
3
4
@Bean
public ItemWriter<SourceRecord> noOpWriter() {
return chunk -> log.info("읽은 데이터 수={}", chunk.size());
}
하지만 여전히 배치는 Reader 구간에서 더 이상 진행되지 않았습니다. 즉, Writer 문제도 Processor 문제도 아니고, Reader 또는 Reader가 실행하는 DB 조회 구간의 문제라는 것이 확실해졌습니다.
애플리케이션에서만 느린가?
우선 이 쿼리가 애플리케이션 내부에서만 느린 건지, DB 툴에서 직접 날려도 느린 건지부터 확인했습니다. 신기하게도 DB 툴에서 직접 실행하면 생각보다 빠르게 결과가 나왔습니다. 다만 같은 쿼리를 반복해서 열 번쯤 실행해보니 결과가 들쭉날쭉했습니다. 대략 서너 번은 금방 끝났지만, 나머지는 300초가 넘도록 응답이 없었습니다.
1
2
일부 실행: 빠르게 결과 반환
일부 실행: 300초 이상 지연
이 불규칙함 때문에 처음에는 쿼리의 바인드 변수나 MyBatis 파라미터 타입 때문에 실행계획이 달라지는 것은 아닌지 의심했습니다. 그래서 DB 툴에서는 바인드 변수를 사용해서, MyBatis Mapper에서는 값을 하드코딩해서 각각 실행해봤습니다.
1
2
3
4
5
6
-- 바인드 변수 사용
VAR code VARCHAR2(10);
EXEC :code := '1234';
SELECT V.A, V.B, V.C
FROM SOME_VIEW V
WHERE V.A = :code;
1
2
-- Mapper에 하드코딩
WHERE V.A = '1234'
하지만 두 경우 모두 똑같이 느렸습니다. 단순한 바인드 타입이나 파라미터 변환 문제는 아니라는 뜻이었습니다.
그럼 한 건만 조회하면 어떨까 싶어 ROWNUM <= 1을 붙여서도 테스트해봤습니다. 하지만 이 쿼리도 매우 느렸고, 적어도 “결과 건수가 많아서 느린 것”은 아니라는 정황을 얻을 수 있었습니다.
Reader 구현체 문제인가?
혹시나 해서 Reader 구현체도 바꿔봤습니다. MyBatisCursorItemReader 대신 selectList()로 전체를 가져와 ListItemReader에 넘기도록 바꿨는데, 이번에도 selectList() 호출 자체가 끝나지 않았습니다.
1
2
3
4
5
6
List<SourceRecord> records = sourceSqlSessionTemplate.selectList(
"com.example.batch.SourceMapper.selectRecords",
Map.of("code", code)
);
return new ListItemReader<>(records);
마지막으로 MyBatis 자체를 걷어내고 순수 JDBC로도 테스트했습니다.
1
2
3
4
5
6
7
8
9
10
11
12
try (
Connection connection = sourceDataSource.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)
) {
statement.setQueryTimeout(60);
try (ResultSet resultSet = statement.executeQuery()) {
while (resultSet.next()) {
// 순수 조회 성능 확인용
}
}
}
결과는 똑같았습니다. 60초 뒤 Query Timeout으로 ORA-01013이 발생하며 쿼리가 취소됐습니다. 동일한 DataSource와 SQL을 사용한 순수 JDBC에서도 문제가 재현됐기 때문에, Spring Batch와 MyBatis, Reader 구현체는 주요 원인 후보에서 제외할 수 있었습니다. 지연은 Oracle에서 SQL을 실행하고 최초 결과를 반환하는 구간에서 발생하고 있었습니다.
실제 실행계획을 확인하고 싶었지만, 해당 View는 외부 기관에서 만든 객체였고 저희 계정에는 View 조회 권한만 있었습니다. EXPLAIN PLAN을 시도하면 아래 오류만 반환됐습니다.
1
ORA-01039: insufficient privileges on underlying objects of the view
View가 잘못 만들어진건가?
View 내부 구조, 인덱스, 조인 방식 어느 것도 직접 들여다볼 수 없었기 때문에, 결국 외부 기관에 View 성능 개선을 요청했습니다.
외부 기관에서는 튜닝했다는 답변을 줬지만, 다시 테스트해보니 여전히 느렸습니다. 반면 외부 기관측에서는 해당 쿼리의 속도가 매우 빠르다고 했습니다. 답답한 마음에 이것저것 테스트하다가, 혹시나 하는 마음으로 WHERE 조건을 통째로 빼고 조회해봤습니다.
1
2
3
4
5
6
7
8
-- WHERE 있음 → 몇 분간 응답 없음
SELECT V.A, V.B, V.C
FROM SOME_VIEW V
WHERE V.A = '1234';
-- WHERE 없음 → 즉시 반환
SELECT V.A, V.B, V.C
FROM SOME_VIEW V;
결과는 정반대였습니다. 조건을 빼니까 오히려 빨랐습니다. 이 시점에서 확인할 수 있었던 것은, Spring Batch나 MyBatis가 느린 게 아니라 특정 WHERE 조건과 이 View의 구조가 결합될 때 비효율적인 실행이 발생한다는 사실이었습니다. (다만 실행계획과 View 정의를 직접 보지 못했기 때문에, 인덱스나 조인 방식이 정확히 어떻게 문제였는지까지는 단정할 수 없었습니다.)
해결 방법
외부 기관에 이 내용을 다시 전달하고 View 재수정을 요청했지만, 기능 적용 일정은 이미 정해져 있었고 View가 언제 고쳐질지는 알 수 없는 상황이었습니다. 그래서 근본적인 수정을 기다리는 대신, 애플리케이션에서 임시로 우회하는 방법을 선택하기로 했습니다.
처음에는 그냥 WHERE 없이 전체를 selectList()로 가져온 다음, Java에서 스트림으로 필터링하는 방법 또는 Processor에서 필터링하는 방법을 생각했습니다.
하지만 당장은 데이터가 수천 건 수준이라 문제가 없겠지만, 이후 데이터가 몇만, 몇십만 건으로 늘어나면 전체 결과를 Heap에 한 번에 올리는 구조라 OOM 위험이 크다고 판단했습니다.
따라서 대신 MyBatisPagingItemReader로 페이지 단위로 끊어 읽고, Processor에서 필요한 데이터들만 걸러내는 방식으로 구성했습니다.
1
2
3
4
5
6
7
8
9
10
11
12
@Bean
@StepScope
public MyBatisPagingItemReader<SourceRecord> sourcePagingReader(
@Qualifier("sourceSqlSessionFactory") SqlSessionFactory sourceSqlSessionFactory
) {
return new MyBatisPagingItemReaderBuilder<SourceRecord>()
.name("sourcePagingReader")
.sqlSessionFactory(sourceSqlSessionFactory)
.queryId("com.example.batch.SourceMapper.selectPage")
.pageSize(500)
.build();
}
1
2
3
4
5
6
7
8
9
10
<select id="selectPage" resultType="com.example.batch.SourceRecord">
SELECT
V.A
, V.B
, V.C
FROM SOME_VIEW V
ORDER BY V.A
OFFSET #{_skiprows} ROWS
FETCH NEXT #{_pagesize} ROWS ONLY
</select>
1
2
3
4
5
6
return item -> {
if (filter()) {
return null; // Writer로 전달되지 않고 필터링됩니다
}
return TargetRecord.from(item);
};
이렇게 구성하고 나니 배치가 정상적으로 완료됐습니다. WHERE 조건을 뺐기 때문에 불필요한 데이터까지 DB에서 읽어 네트워크로 넘기는 비용은 있지만, 최소한 전체 결과를 한 번에 메모리에 올리지 않으면서 일정 안에 기능을 적용할 수 있었습니다.
DB가 해야 할 필터링을 애플리케이션 Processor가 대신 떠맡은 구조로 근본적인 해결책은 아니기에 나중에 외부 기관에서 View를 정상적으로 고쳐주면, 다시 View에 직접 WHERE 조건을 거는 방식으로 되돌릴 계획입니다.
왜 WHERE 조건을 제거해볼 생각을 늦게 했을까?
당시에는 다음 흐름을 너무 당연하게 여기고 있었습니다.
1
WHERE 조건 추가 → 대상 데이터 감소 → 더 빠르거나 최소한 비슷한 성능
하지만 여기엔 잘못된 전제가 있었습니다.
1
최종 조회 결과가 적다 ≠ DB 내부 처리량도 적다
데이터베이스는 SQL을 쓴 순서대로 단순 실행하지 않습니다. 옵티마이저는 통계정보를 바탕으로 여러 실행 방법의 비용을 계산해서 실행계획을 선택합니다. 같은 SQL도 Full Table Scan, Index Scan, Nested Loops, Hash Join 등 여러 방식으로 실행될 수 있고, 어떤 계획이 선택되느냐에 따라 수행 시간이 크게 달라집니다.
조건이 없을 때는 이런 계획이 선택될 수 있습니다.
1
Full Table Scan → Hash Join → 순차적으로 데이터 반환
조건을 추가하면 이렇게 달라질 수 있습니다.
1
Index Range Scan → Table Access By ROWID → Nested Loops → 반복적인 랜덤 접근
필터 결과가 아주 적다면 두 번째 방식이 유리하지만, 조건과 일치하는 데이터가 전체의 상당 부분을 차지한다면 인덱스를 반복해서 타는 것보다 그냥 전체를 순차적으로 읽는 게 더 빠를 수 있습니다. 이번 문제도 정확한 실행계획은 확인하지 못했지만, 결과적으로는 WHERE 조건과 View 구조가 맞물리면서 비효율적인 실행 경로가 선택된 것으로 보입니다.
느낀 점
- WHERE는 반환 건수를 줄이지, 실행 비용을 반드시 줄이지는 않는다.
- 조회 결과가 적다고 해서 DB 내부 처리량도 적은 건 아니었습니다. 조건이 하나 추가됐을 뿐인데 접근 경로, 조인 순서, View 변환 방식까지 함께 달라질 수 있다는 걸 가장 크게 느꼈습니다.
- 당연하다고 생각한 조건일수록 반대로도 테스트해봐야 한다.
- 가장 늦게 시도한 테스트가 하필
WHERE제거였습니다. “조건이 있으니 당연히 더 빠를 것”이라는 전제를 너무 강하게 갖고 있었기 때문입니다.
- 가장 늦게 시도한 테스트가 하필
