들어가며
최근에 SpringBoot + MyBatis로 구성된 서버 개발 중 동료가 예상치 못한 문제를 겪은 것을 보았다.
반복문 내에서 똑같은 SELECT 쿼리를 여러 번 호출했는데, 첫 번째 호출에서는 결과가 정상적으로 나왔지만, 이후 호출에서는 결과가 0건으로 나타난 문제가 바로 그것이다.
처음에는 쿼리 문법이나 전달된 파라미터가 잘못됐는지 의심했으나 문제가 동일하게 발생했다. 그러다가 MyBatis의 캐시 설정 때문이라는 것을 알게 되었다.
나도 나중에 같은 일을 겪게 될 수도 있을 것이라는 생각이 들어서 이번 기회를 통해 아래와 같은 내용을 정리해 보는 시간을 갖고자 한다.
- MyBatis의 캐시 종류와 내부 동작 방식
- useCache, flushCache 옵션의 정확한 역할과 사용법
- 캐시 관련 옵션을 설정할 때 주의할 점과 문제 발생 사례
문제 상황
아래는 문제를 발생시킨 Java 코드를 재현한 일부분이다.
for (int i = 0; i < 4; i++) {
List<ArticleBean> articleList
= sqlSession.selectList("selectArticleList", runVO);
System.out.println("articleList size: " + articleList.size());
}
- 첫 번째 호출: 결과가 정상적으로 n건 출력됨
- 두 번째 호출부터: 결과가 0건으로 출력됨
- 쿼리와 파라미터(runVO)는 모두 동일한 상태
결론적으로 해당 문제는 Mapper XML에서 다음과 같은 옵션을 추가해 해결했다.
<select id="selectGeneralArticleList" useCache="false" flushCache="true">
useCache="false" | 해당 SELECT 실행 시 2차 캐시를 사용하지 않는다. 즉, 항상 DB에서 직접 데이터를 조회한다. | true (캐시 사용함) |
flushCache="true" | 쿼리를 실행할 때마다 SqlSession의 1차 캐시를 초기화한다. 다음 쿼리 호출 시 항상 DB에서 새로 데이터를 가져온다. SELECT 쿼리는 기본값이 false, INSERT/UPDATE/DELETE는 기본값이 true다. | SELECT(false), 그 외(true) |
즉, 위 두 옵션을 설정하면 항상 최신의 DB 데이터를 직접 조회하게 된다.
왜 문제가 발생하였을까?
MyBatis는 다음 두 가지 종류의 캐시를 제공한다.
① 1차 캐시(Local Cache)
- 기본적으로 SqlSession 단위로 관리됨
- 같은 쿼리와 파라미터를 동일 SqlSession에서 호출하면, DB에 다시 접근하지 않고 캐시에 저장된 결과를 반환한다.
② 2차 캐시(Second-Level Cache)
- 옵션으로 Mapper 단위로 관리됨 (기본적으로는 비활성화)
- 서로 다른 SqlSession 사이에서도 캐시 데이터를 공유할 수 있게 한다.
이번 문제의 경우는 1차 캐시 때문에 발생한 케이스다.
MyBatis는 기본적으로 쿼리 문자열과 파라미터가 동일하면 캐시에 저장된 결과를 재사용하는데, 다음과 같은 상황에서는 문제가 발생할 수 있다.
상황 1: 동적 쿼리 조건으로 인해 DB 상태가 다를 때
- 원인: 조건이 if, choose 등 동적으로 구성된 경우, 내부적으론 같은 쿼리로 판단될 수 있다. 이때 실제 DB 상태는 변경됐지만 MyBatis는 캐시에 있는 과거의 결과를 그대로 반환한다.
- 결과: 실제 최신 데이터가 아닌 오래된 정보가 조회된다.
상황 2: JOIN, GROUP BY와 같은 복잡한 쿼리 결과가 변경됐을 때
- 원인: JOIN 또는 GROUP BY가 포함된 쿼리 결과는 다른 테이블의 데이터가 변경되면 결과가 바뀌어야 한다. 하지만 MyBatis가 캐시된 과거 결과를 반환하면, 변경된 최신 데이터를 조회하지 못한다.
- 결과: 정확하지 않은 오래된 결과 데이터가 조회된다.
상황 3: SELECT 후 데이터 변경(INSERT/UPDATE) 뒤 다시 SELECT할 때
- 원인: 같은 SqlSession 내에서 데이터를 변경(INSERT 또는 UPDATE)한 뒤 SELECT를 다시 실행하면, MyBatis는 캐시된 기존 SELECT 결과를 그대로 반환한다.
- 결과: 변경된 최신 데이터가 아닌 이전 데이터를 조회하는 심각한 문제가 발생한다.
상황 4: 트랜잭션 내에서 동일 SqlSession을 반복 사용할 때
- 원인: 트랜잭션은 데이터의 일관성을 유지해야 하는데, 중간에 데이터 변경이 발생했음에도 캐시가 비워지지 않아 오래된 데이터가 반환될 수 있다.
- 결과: 트랜잭션 내에서 부정확한 결과를 얻게 되어 데이터의 무결성 문제로 이어질 수 있다.
상황 5: 중첩 SELECT (association, collection 등 resultMap 포함)를 사용할 때
- 원인: association이나 collection 같은 중첩된 SELECT는 별도 SELECT 쿼리를 내부적으로 호출한다. 이 내부 SELECT 결과 역시 MyBatis 캐시 영향을 받는다.
- 결과: 외부 SELECT는 최신 데이터를 조회했지만, 내부 SELECT 결과가 이전 결과로 남아 데이터가 서로 불일치할 수 있다.
위 상황들에서는 flushCache="true"를 사용하면 명확히 DB로부터 새로 조회를 해 해결할 수 있다.
캐시에 최초 결과가 저장됐으니까 이후 호출에서도 최초와 같은 값이 나와야 정상 아닌가?
MyBatis의 1차 캐시(Local Cache)는 쿼리의 문자열과 파라미터가 완전히 일치하면 캐시된 결과를 그대로 사용하게 된다. 즉, 조건이 완벽히 동일한 경우엔 동일한 결과가 나온다. 그런데도 결과가 다르게 나오는 이유는 아래와 같은 상황들인 경우이다.
1. 실제 DB 데이터가 중간에 변경된 경우
- 최초 호출 후 '같은 SqlSession 내에서 DB 데이터를 변경(INSERT/UPDATE/DELETE)'하는 로직이 중간에 끼어 있다면, DB 상태는 변했지만 MyBatis는 여전히 캐시된 결과를 사용하려 할 수 있다.
- 하지만 MyBatis의 캐시는 SELECT 결과를 저장한 것이지 DB 변경을 자동으로 감지하지는 못하기 때문에, DB와 캐시 사이에 불일치가 발생할 수 있다.
- 이런 경우 캐시가 만료(Invalidate)될 수 있는데, 특정 조건이나 설정에 따라 DB와 캐시가 맞지 않으면 빈 결과가 나오는 등 비정상적인 현상이 나타날 수 있다.
2. SqlSession의 commit, rollback, close 시점이 영향을 줄 수 있음
- MyBatis의 1차 캐시는 SqlSession이 commit, rollback, close될 때 초기화(clearCache)된다.
- 그런데 트랜잭션 설정이나 SqlSession의 라이프 사이클에 따라 캐시가 예상보다 빨리 지워질 수 있다. 이때, 다시 같은 쿼리를 호출하면 캐시가 비워진 상태라 DB에서 재조회를 하는데, 이 시점의 DB 데이터 상태가 달라져 있으면 최초 호출과 결과가 다를 수 있다.
3. 동적 쿼리 조건의 미묘한 차이
- 겉보기에는 같은 파라미터로 보이지만, if 문이나 choose 문 등으로 쿼리가 동적으로 구성된 경우, 조건이 아주 조금이라도 다르게 처리돼 다른 결과를 가져올 수도 있다.
- 만약 조건에 따라 다른 SQL 구문이 생성되거나 파라미터가 미묘하게 달라지면, 캐시는 다른 쿼리로 판단해 캐시가 아닌 DB를 조회하게 돼서, 이전 호출과 다른 결과를 가져올 수 있다. 즉 SQL 구문이 다른 경우이다.
4. 객체나 파라미터가 실제로 변경된 경우
- 같은 runVO 객체를 반복문에서 계속 사용한다고 하더라도, 객체의 내부 상태가 반복문 안에서 변경될 가능성도 있다. 예를 들어 반복문 중간에 객체 필드를 수정하거나 상태를 바꿨다면, 캐시가 아닌 DB를 조회해 다른 결과가 나올 수 있다.
이번 케이스에서는 아래와 같은 상황이였기 때문에 flushCache="true"를 추가했을 때 정상 작동하였다.
- 최초 SELECT 후에 같은 SqlSession 내에서 데이터 변경(INSERT, UPDATE, DELETE)이 발생하는 로직이 포함되어 있었다.
- 그런데 중간에 데이터 변경 생겼기 때문에, MyBatis가 내부적으로 1차 캐시와 실제 DB가 서로 불일치하는 걸 감지하고 캐시를 초기화 하였다. 그래서 다음 SELECT 호출 시 캐시가 비워진 상태로 다시 DB를 조회하는데 변경된 DB 상태로 인해 결과가 0건이 되었다.
- 그래서 flushCache="true" 설정을 명시적으로 넣어 SELECT 호출 시 항상 캐시를 초기화하고 DB에서 직접 조회하게 하여, DB와 캐시가 불일치하는 현상이 없어지면서 정상적인 결과를 받게 될 수 있었다.
결론
MyBatis 캐시는 쿼리 성능을 크게 향상시킬 수 있는 유용한 기능이지만, 때로는 의도치 않은 결과를 초래할 수 있다. 특히 같은 SqlSession 내에서 반복된 SELECT 호출 시, 예상하지 못한 캐시 문제가 발생할 수 있다.
쿼리 결과가 이상하게 0건으로 나오거나 최신 데이터가 조회되지 않는다면 가장 먼저 MyBatis의 캐시 설정을 의심하고, 필요에 따라 명시적으로 캐시를 비워주는 설정을 추가하는 것이 좋을 것이다.
MyBatis 캐시의 동작 원리를 정확히 이해하고 적절한 옵션을 설정한다면 정확하고 안정적인 애플리케이션 개발이 가능해질 것이다.
이와 비슷한 문제를 겪은 모든 사람들에게 도움이 되었으면 한다.
'DB' 카테고리의 다른 글
NL조인 기반 인덱스 설계 (0) | 2025.04.23 |
---|---|
Index Skip Scan과 In-List 튜닝 (0) | 2025.02.16 |
My SQL 최적화 가이드 - 1. 최적화 (1) | 2024.11.28 |
MySQL) OPTIMIZE TABLE Statement (0) | 2024.09.20 |
쿼리 성능 최적화(EXISTS, JOIN) (0) | 2024.08.20 |