[Mybatis] 로컬 캐시 문제: 프로시저 호출 시 발생하는 캐시 이슈

2025. 7. 21. 00:51·Spring

서론

진행 중인 프로젝트에서 `sequence` 테이블을 별도로 두고 프로시저를 통해 PK를 채번하는 시스템에서 예상치 못한 캐시 문제를 마주했던 내용을 정리했다.

 

Mybatis 로컬 캐시는 기본적으로 활성화되어 있는데, 이게 별도 트랜잭션에서 실행되는 프로시저와 만나면서 예상치 못한 동작을 하는 바람에 원인을 찾기까지 꽤 많은 삽질을 했다. 특히 이 프로젝트는 Mybatis 캐시 정책에 대한 제대로 된 이해 없이 "JPA와 비슷하겠지"라는 안일한 생각으로 임했던 것이 화근이었다.

 

하지만 막상 파보니 JPA의 영속성 컨텍스트와는 완전히 다른 캐시 정책을 가지고 있어서 더욱 혼란스러웠고, 결국 근본적인 차이점부터 다시 공부해야 했다.

 

실제 발생한 문제 상황

  • 프로시저를 통해 채번한 `sequence` 값이 캐시되어 다음 호출에서 같은 값을 반환
  • update 쿼리 실행 후에도 select 캐시가 유지되어 이전 데이터를 조회
  • 한 세션 내에서 여러 번 같은 쿼리 실행 시 첫 번째 결과만 계속 반환

이런 문제들을 해결하면서 Mybatis 로컬 캐시의 동작 원리를 깊이 파보게 되었다.

 

Mybatis 로컬 캐시 vs JPA 영속성 컨텍스트

Mybatis 로컬 캐시의 특징

// Mybatis 기본 설정
<configuration>
    <settings>
        <setting name="localCacheScope" value="SESSION"/> <!-- 기본값 -->
    </settings>
</configuration>

Mybatis 로컬 캐시는 `SESSION` 레벨에서 동작한다. 이는 `SqlSession` 하나당 하나의 캐시를 의미한다.

@Mapper
public interface UserMapper {
    User selectUserById(@Param("id") Long id);
    void updateUser(User user);
}

// 문제 상황 재현
public class UserService {
    @Autowired
    private UserMapper userMapper;
    
    @Transactional
    public void problematicMethod() {
        User user1 = userMapper.selectUserById(1L); // DB 조회
        System.out.println("첫번째: " + user1.getName());
        
        // 다른 세션에서 해당 user의 name을 변경했다고 가정
        
        User user2 = userMapper.selectUserById(1L); // 캐시에서 조회
        System.out.println("두번째: " + user2.getName()); // 여전히 이전 값
    }
}

JPA 영속성 컨텍스트와의 차이점

// JPA의 경우
@Entity
public class User {
    @Id
    private Long id;
    private String name;
    // ...
}

@Service
public class UserService {
    @PersistenceContext
    private EntityManager em;
    
    @Transactional
    public void jpaMethod() {
        User user1 = em.find(User.class, 1L); // DB 조회
        
        // JPA는 더티체킹으로 변경사항을 추적
        user1.setName("새로운 이름");
        
        User user2 = em.find(User.class, 1L); // 영속성 컨텍스트에서 조회
        // user2는 user1과 같은 인스턴스이며, 변경된 값을 가짐
    }
}

주요 차이점:

  • Mybatis: 쿼리 기반 캐시, 같은 SQL과 파라미터면 캐시된 결과 반환
  • JPA: 엔티티 기반 캐시, 더티체킹과 변경사항 추적 지원
  • Mybatis: 캐시 무효화가 제한적 (INSERT, UPDATE, DELETE 시에만 flush)
  • JPA: 엔티티 상태 변화를 실시간으로 추적

Mybatis Select와 Update 캐시 정책 차이

Select 쿼리의 캐시 정책

// Mapper 인터페이스
@Select("SELECT * FROM users WHERE id = #{id}")
User selectUserById(@Param("id") Long id);

@Select("SELECT next_seq FROM sequence_table WHERE table_name = #{tableName}")
Long getNextSequence(@Param("tableName") String tableName);
// 서비스에서의 문제 상황
@Service
public class SequenceService {
    
    @Transactional
    public void demonstrateCacheProblem() {
        Long seq1 = sequenceMapper.getNextSequence("user_seq"); // 예: 100
        Long seq2 = sequenceMapper.getNextSequence("user_seq"); // 캐시에서 100 반환함
        
        // 실제로는 sequence가 증가해야 하는데 캐시 때문에 같은 값이 됨
        assert seq1.equals(seq2); // true (❌ 예외 발생)
    }
}

Update 쿼리의 캐시 무효화

@Update("UPDATE users SET name = #{name} WHERE id = #{id}")
void updateUserName(@Param("id") Long id, @Param("name") String name);
// Update 후 캐시 무효화 확인
@Transactional
public void updateAndSelect() {
    User before = userMapper.selectUserById(1L); // DB 조회, 캐시에 저장
    
    userMapper.updateUserName(1L, "김이박"); // UPDATE 실행 시 캐시 무효화
    
    User after = userMapper.selectUserById(1L); // 다시 DB 조회 (캐시 무효화됨)
}
  • 중요한 점: Mybatis는 `INSERT`, `UPDATE`, `DELETE` 쿼리가 실행될 때 해당 Mapper의 모든 캐시를 무효화한다.

2차 캐시(Second Level Cache) 설정과 주의사항

Mybatis에서는 SqlSession 레벨의 1차 캐시 외에도 2차 캐시를 지원한다. 하지만 이 2차 캐시는 꽤 까다로운 특성을 가지고 있다.

아래 예시는 공식 문서 (link)에 있는 예시를 활용했다.

<!-- MyBatis 2차 캐시 설정 -->
<mapper namespace="com.example.UserMapper">
    <cache 
        eviction="LRU"
        flushInterval="60000" 
        size="512" 
        readOnly="false"/>
    
    <!-- flushCache 속성으로 개별 제어 -->
    <select id="selectUser" flushCache="false" useCache="true">
        SELECT * FROM users WHERE id = #{id}
    </select>
    
    <update id="updateUser" flushCache="true">
        UPDATE users SET name = #{name} WHERE id = #{id}
    </update>
</mapper>

2차 캐시 옵션들:

  • LRU (기본값): 가장 오래 사용되지 않은 객체부터 제거
  • FIFO: 먼저 들어온 객체부터 제거
  • SOFT: GC의 Soft Reference 정책에 따라 제거
  • WEAK: GC의 Weak Reference 정책에 따라 더 적극적으로 제거

(Soft Reference, Weak Reference 내용은 Naver D2를 참고한 예전 글에 정리했다.)

 

XML과 Annotation 혼용 시 주의사항:

공식 문서에 따르면, XML 매핑 파일에 <cache> 태그가 있어도 Java 인터페이스의 애너테이션 기반 쿼리들은 기본적으로 캐시되지 않는다고 한다.

// 이런 상황에서 문제 발생 가능
@Mapper
public interface UserMapper {
    
    // XML에 cache 설정이 있어도 이 메서드는 캐시 안됨
    @Select("SELECT * FROM users WHERE id = #{id}")
    User selectUserById(@Param("id") Long id);
}

이런 경우엔 `@CacheNamespaceRef` 애너테이션을 사용해야 한다:

@Mapper
@CacheNamespaceRef(UserMapper.class) // 명시적으로 캐시 참조
public interface UserMapper {
    
    @Select("SELECT * FROM users WHERE id = #{id}")
    User selectUserById(@Param("id") Long id);
}

 

readOnly 설정의 성능 임팩트:

<!-- readOnly=true: 성능 우선, 같은 인스턴스 반환 -->
<cache readOnly="true"/>

<!-- readOnly=false: (기본값) 안전성 우선, 직렬화를 통한 복사본 반환 -->  
<cache readOnly="false"/>
// readOnly=true일 때의 위험성
@Transactional
public void dangerousMethod() {
    User user1 = userMapper.selectUserById(1L); // 캐시에서 가져옴
    User user2 = userMapper.selectUserById(1L); // 같은 인스턴스!
    
    user1.setName("변경된 이름"); 
    // user2.getName()도 "변경된 이름"이 됨 (같은 객체이므로)
    // ⚡️ 다른 스레드에서도 영향 받을 수 있음
}

 

트랜잭션과 2차 캐시:

2차 캐시는 트랜잭션과 연동된다. SqlSession이 커밋되거나 롤백될 때만 캐시가 업데이트되고, `flushCache=true`인 INSERT/UPDATE/DELETE가 실행되면 즉시 무효화된다.

별도 채번 테이블과 프로시저 호출 시 캐시 문제

실제 운영 환경에서 만난 문제

프로젝트에서는 PK 채번을 위해 별도의 `sequence` 테이블을 운영하고 있었다.

-- sequence 테이블 구조
CREATE TABLE sequence_table (
    table_name VARCHAR(50) PRIMARY KEY,
    current_value BIGINT NOT NULL,
    increment_by INT DEFAULT 1
);

-- 채번용 프로시저
DELIMITER $$
CREATE PROCEDURE get_next_sequence(
    IN p_table_name VARCHAR(50),
    OUT p_next_value BIGINT
)
BEGIN
    -- 별도 트랜잭션으로 실행 (AUTONOMOUS TRANSACTION과 유사)
    START TRANSACTION;
    
    UPDATE sequence_table 
    SET current_value = current_value + increment_by 
    WHERE table_name = p_table_name;
    
    SELECT current_value INTO p_next_value 
    FROM sequence_table 
    WHERE table_name = p_table_name;
    
    COMMIT;
END$$
DELIMITER ;

Mapper에서 프로시저 호출

@Mapper
public interface SequenceMapper {
    
    @Select("CALL get_next_sequence(#{tableName}, #{nextValue, mode=OUT, jdbcType=BIGINT})")
    @Options(statementType = StatementType.CALLABLE)
    void getNextSequence(@Param("tableName") String tableName, 
                         @Param("nextValue") ParameterHolder<Long> nextValue);
    
    // 또는 직접 조회 방식
    @Select("SELECT current_value FROM sequence_table WHERE table_name = #{tableName}")
    Long getCurrentSequence(@Param("tableName") String tableName);
}

문제 발생 코드 (예시)

@Service
public class UserService {
    
    @Transactional
    public List<User> createMultipleUsers(List<String> names) {
        List<User> users = new ArrayList<>();
        
        for (String name : names) {
            // ❌ 매번 같은 sequence 값을 받아옴 (캐시 문제)
            ParameterHolder<Long> holder = new ParameterHolder<>();
            sequenceMapper.getNextSequence("user_seq", holder);
            Long userId = holder.getValue(); // ❌ 계속 같은 값
            
            User user = new User(userId, name);
            userMapper.insertUser(user);
            users.add(user);
        }
        
        return users; // ❌ 여기서 PK 중복으로 인한 에러 발생
    }
}

왜 이런 문제가 발생했을까?

  1. 프로시저 내부의 별도 트랜잭션: 프로시저 안에서 `START TRANSACTION; ... COMMIT;`을 실행하지만, Mybatis는 이를 단순한 SELECT 쿼리로 인식
  2. 캐시 키 생성 방식: Mybatis는 SQL문과 파라미터를 조합해서 캐시 키를 생성하는데, 같은 프로시저 호출은 같은 캐시 키를 가짐
  3. 프로시저 결과의 캐시: 프로시저 호출 결과도 SELECT와 동일하게 캐시됨
// 내부적으로 Mybatis가 생성하는 캐시 키 (pseudo 코드)
String cacheKey = "CALL get_next_sequence" + "user_seq"; 
// 매번 같은 키가 생성되어 같은 결과를 반환

해결 방법들

1. 캐시 완전 비활성화 (✅ 채택한 방법)

// Mapper별 캐시 비활성화
@CacheNamespace(flushInterval = 0)
public interface SequenceMapper {
    // ...
}

또는

<!-- XML 설정 -->
<select id="getNextSequence" useCache="false" flushCache="true">
    CALL get_next_sequence(#{tableName}, #{nextValue, mode=OUT, jdbcType=BIGINT})
</select>

2. SqlSession 직접 제어

@Service
public class SequenceService {
    
    @Autowired
    private SqlSessionFactory sqlSessionFactory;
    
    public Long getNextSequence(String tableName) {
        // 매번 새로운 SqlSession 사용
        try (SqlSession session = sqlSessionFactory.openSession()) {
            SequenceMapper mapper = session.getMapper(SequenceMapper.class);
            ParameterHolder<Long> holder = new ParameterHolder<>();
            mapper.getNextSequence(tableName, holder);
            return holder.getValue();
        }
    }
}

3. Redis를 활용한 분산 채번

@Service
public class RedisSequenceService {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    public Long getNextSequence(String tableName) {
        String key = "sequence:" + tableName;
        return redisTemplate.opsForValue().increment(key);
    }
}

✅ 현재 채택한 해결책

프로젝트는 DB가 이중화되어 있긴 하지만 failover만 설정된 상태라 실질적으로는 read-write 모두 하나의 DB에서 처리하고 있다. 게다가 분산 환경을 고려할 필요가 없는 단일 인스턴스 아키텍처라서, 복잡한 분산 캐시를 사용할 이유가 없었다.

결국 가장 간단하면서도 확실한 첫 번째 방법인 캐시 무효화 방식이 실무에 가장 적합하다고 판단했다. 개별 쿼리마다 `flushCache=true`를 설정해서 매번 캐시를 무효화하는 방식으로 해결했다.

@Mapper
public interface SequenceMapper {
    
    // flushCache=true로 매번 캐시 무효화
    @Select("SELECT get_next_sequence_func(#{tableName})")
    @Options(flushCache = true, useCache = false)
    Long getNextSequenceValue(@Param("tableName") String tableName);
}

마무리

Mybatis 로컬 캐시는 성능 향상을 위한 좋은 기능이지만, 다음과 같은 상황에서는 예상치 못한 문제를 일으킬 수 있다:

  1. 별도 트랜잭션을 가진 프로시저/함수 호출
  2. 외부에서 데이터가 변경될 수 있는 상황
  3. 실시간성이 중요한 채번이나 상태 조회

특히 JPA에만 익숙한 개발자라면 Mybatis의 캐시 정책이 생소할 수 있다. JPA는 엔티티 단위의 더티체킹을 지원하지만, Mybatis는 순수하게 쿼리 기반으로만 캐시를 관리한다는 점을 배웠다.

참고

    • MyBatis Official Documentation - Local Cache
    • MyBatis Configuration - localCacheScope

'Spring' 카테고리의 다른 글

[Mybatis] Generic 기반 TypeHandler를 자동 등록하기  (0) 2025.07.20
Spring Event Deep Dive  (2) 2025.01.16
[Spring] MockMvc 사용 시 Page 인터페이스의 직렬화 문제  (0) 2024.12.31
[Spring] 회원탈퇴 시 Kakao OAuth2 연결끊기: REST API로 연결끊기 (OpenFeign)  (2) 2024.10.30
[Spring] 직렬화/역직렬화 시 'is' prefix 가 안붙는 이유  (2) 2024.10.30
'Spring' 카테고리의 다른 글
  • [Mybatis] Generic 기반 TypeHandler를 자동 등록하기
  • Spring Event Deep Dive
  • [Spring] MockMvc 사용 시 Page 인터페이스의 직렬화 문제
  • [Spring] 회원탈퇴 시 Kakao OAuth2 연결끊기: REST API로 연결끊기 (OpenFeign)
옐리yelly
옐리yelly
  • 옐리yelly
    개발 갤러리
    옐리yelly
  • 전체
    오늘
    어제
    • 모든 글 보기 (84)
      • Project (22)
      • Java (4)
      • Spring (8)
      • Kubernetes (6)
      • Docker (2)
      • JPA (2)
      • Querydsl (2)
      • MySQL (9)
      • ElasticSearch (7)
      • DevOps (4)
      • Message Broker (3)
      • Git & GitHub (2)
      • Svelte (1)
      • Python (8)
        • Python Distilled (4)
        • Anaconda (1)
        • Django (0)
        • pandas (3)
      • Algorithm (1)
      • Computer Science (0)
      • 내 생각 (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    k8s
    argocd
    blue-green 배포
    Project
    데드락
    리팩토링
    gitops
    docker
    RabbitMQ
    elasticsearch
    mybatis
    OOP
    querydsl
    Python
    ncloud
    MySQL
    JPA
    svelte
    비사이드
    포텐데이
    Message Broker
    pandas
    예약 시스템
    프로젝트
    커넥션 풀
    Spring
    성능 테스트
    nks
    pymysql
    devops
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
옐리yelly
[Mybatis] 로컬 캐시 문제: 프로시저 호출 시 발생하는 캐시 이슈
상단으로

티스토리툴바