ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • MyBatis 의도치 않은 캐싱
    SPRING 공부/기타 2023. 9. 5. 23:35

    1. 발견

    실무에서 복잡한 비즈니스를 다루는 로직에서 도저히 이해가 안가는 상황이 나오게 되었다.

     

    상황은 이렇다.

    첫 번째로 조회한 객체에는 객체 내부에 컬렉션을 가지고 있는데, 다음 메서드가 실행되기 전에는

    비어있는 컬렉션인 것을 확인하였으나 메서드 실행 직후 내부 컬렉션에 객체들이 존재하는 것을 확인하였다. 

    분명 해당 객체를 어디선가 수정메서드를 통해 변경하는일이 없는데도 말이다.

     

    도저히 납득이 가지 않아서 객체를 파라미터로 넘겨주고 복잡한 비즈니스를 파고파고 들어가서 해당 상태가 바뀌는 시점을

    체크해보았으나 해당 메서드에서 바뀌는 이유를 종 잡을 수 없었다.

     

    그때 혹시나 스쳐가는 생각이 JPA에서 캐시 기능을 사용하여 동일성을 보장해주는 것이 생각났다.

    위에서 사용한 비즈니스에 조건의 범위가 다른 쿼리문이지만 동일 결과를 줄 수 있는 쿼리가 존재하기 때문에 혹시나

    캐시 때문인가 ?? 생각했는데. MyBatis에서도 캐시가 존재한다는 것을 처음 알게되었다.

     

     


     

    2. 이론

     

    1) MyBatis의 1차 레벨 캐시

     

    사실 공식 문서에 1차 레벨 캐시에 대한 설명이 자세히 나와있지 않아서 Chat GPT를 통해 학습하였다.

    후에 실습을 통해 해당 내용이 일부분을 테스트해보면서 사실인지 확인해보려한다.

     

    MyBatis도 여타 ORM 프레임워크와 마찬가지로 캐싱 메커니즘이 포함되어있다.

    Local Cache는 레벨1(L1) 캐시라고도 불리며 세션 레벨에서 작동하는 캐시이다.

    Local Cache는 Default로 세션 수준으로 설정되어있다.

    상세한 설명은 다음과 같다.

     

    [1] 1차 레벨 캐시의 범위

    localCacheScope 속성을 이용해서 설정을 할 수 있는데. 설정할 수 있는 Scope는 다음과 같다.

     

    세션(SESSION) 수준 (기본값) - 데이터 베이스에서 검색된 개체가 세션기간동안 캐시된다.

     

    설명(STATEMENT) 수준 - 캐시가 명령문 수준에서 작동하도록 한다. 각 문에서 검색된 개체는 후속 문에

    대해 캐시되지않는다. 특정 명령문에 대하여 명시적으로 캐싱을 활성화하지 않는 이상 1차 레벨 캐시를 우회한다.

     

    명령문 수준(쿼리문 수준) - 특정 SQL문에만 캐싱 동작을 제어할 수 있다. select문의 id값으로 캐싱한다.

    <select id="selectUser" resultType="User" useCache="true">
        <!-- Your SQL statement -->
    </select>

    selectUser문에 대하여 1차 레벨 캐시를 명시적으로 활성화하는 방법이다.

     

    [2] 객체 ID

    MyBatis는 객체를 캐시하기 위해 객체의 ID(기본키)를 사용한다.

    MyBatis에서는 JPA처럼 ID 어노테이션을 붙이지 않는데 무슨 원리일까 찾아보니

    아래와 같은 resultMap에서 Id 태그를 사용시에 객체 기본키로 인식해서 캐싱해주는 원리라고 한다.

    <resultMap id="userResultMap" type="User">
        <id property="userId" column="user_id"/>
        <result property="username" column="username"/>
        <result property="email" column="email"/>
    </resultMap>

    만약 Id태그를 사용해서 캐싱해주지 않는다면, 기본키값 기반의 캐싱을 지원해주지 않는다.

     

     

    [3] 캐시 키

    1차 레벨 캐시에 있는 객체의 캐시 키는 일반적으로 명령문ID(객체 가져오는 데 사용되는 SQL문) 과 매개변수의 조합이다.

    쿼리문 ID, 매개변수, 결과 유형으로 구성 될 수 있다.

    이 키는 캐시에 있는 개체를 고유하게 식별한다.

     

    [4] 캐시 항목

    1차 레벨  캐시의 각 항목은 캐시 키와 데이터베이스에서 가져온 관련 개체로 구성된다.

     

    [5] 캐시 제거

    1차 레벨 캐시는 크기 제한이 있으며 제거 전략이 없다. 캐시가 가득차도 자동으로 제거하지 않으며, 세션이나 트랜잭션 종료후 제거된다.

     

    [6] 캐시 지우기

    트랜잭션 커밋, 롤백 , 세션을 닫으면 1차 레벨 캐시가 지워진다.

     

    [7] 1차 레벨  캐시 비활성화

    1. 위에서 설명한 localCacheScope 속성을 "STATEMENT" 로 설정하여 특정 SQL문에 대한 1차 레벨  캐시를 비활성화 할 수 있다.

    2. 코드수준에서 sqlSession.clearCache() 호출

    3. mapper.xml 파일에서 select문 속성에 flushCache=true 설정

    <select id="selectUser" parameterType="int" resultType="User" flushCache="true">
        <!-- Your SQL statement here -->
    </select>

     

    작동 예는 다음과 같다.

    1. MyBatis에서 세션을 열거나 트랜잭션을 시작한다.

    2. SQL문을 실행하여 데이터베이스에서 검색하면 MyBatis가 LocalCache에 저장한다.

    3. 동일한 세션 혹은 트랜잭션에서 동일한 매개변수를 사용하여 동일한 SQL문을 다시 실행하면 MyBatis는 데이터베이스

    쿼리를 피하고 로컬캐시에서 개체를 반환한다.

    4. 세션을 닫거나 트랜잭션을 커밋하면 로컬 캐시가 지워지고 개체가 모두 해제된다.

     

     

     

    2) MyBatis의 2차 캐시

     

    MyBatis는 로컬 캐시보다 더 넓은 범위에서 동작하는 "두 번 째 레벨 캐시"(보조캐시) 를 제공하는데

    여러 세션과 트랜잭션에서 캐시된 개체를 저장하고 공유하도록 설계되어있다.

     

    상세한 설명은 다음과 같다.

     

    [1] 범위(Scope)

    애플리케이션 수준에서 작동하며 여러 세션과 트랜잭션에서 공유 될 수 있다.

    자주 엑세스하는 데이터를 캐시하는데 사용된다.

     

    [2] 캐시 제공자

    MyBatis에서 다양한 캐시 공급자를 구성할 수 있는데, Ehcache, Redis등을 포함한다.

     

    [3] 구성

    XML 또는 Java 코드를 통해 구성을 해야한다.

    만약 Ehcache를 캐시 공급자로 사용하려면

    <cache type="org.mybatis.caches.ehcache.EhcacheCache"/>

    으로 설정할 수 있다.

     

    [4] 캐싱 전략

    MyBatis의 보조 캐시는 SQL 쿼리 결과 또는 개별 개체 캐싱을 기반으로 하는 캐싱 전략을 따른다.

     

    [5] 캐시 식별

    보조 캐시의 개체는 SQL 문과 해당 매개변수를 기반으로 생성된 캐시 키를 사용하여 식별된다.

    각 고유 쿼리는 고유한 캐시 키를 생성한다.

     

    [6] 캐시 제거 및 만료

    보조 캐시는 일반적으로 시간 기반 만료 또는 LRU(Least Recent Used)와 같은 캐시 제거 정책을 지원하여

    캐시 크기 관리 및 최신  상태로 유지한다. (캐시 제공자에 따라 만료 정책을 구성할 수 있다.)

     

    [7] 캐시 주석

    @CacheNamespace 혹은 @CacheNamespaceRef와 같은 주석을 제공하는데 네임스페이스나 매퍼 인터페이스에 대해

    캐싱을 지정할 수 있다.

     

    [8] 캐시 플러시

    필요한 경우 보조 캐시를 수동으로 플러시하거나 지울 수 있다. (EX)데이터 업데이트 수행시

     

     

    [9] 구성 유연성

    보조 캐시는 캐시 크기, 제거 정책 등을 포함하여 애플리케이션 요구사항에 맞게 동작을 조정할 수 있다.

     

    [10] 캐시 비활성화

    1. mapper.xml에 select문 속성으로 useCache=false로 둔다

    <select id="selectUser" parameterType="int" resultType="User" useCache="false">
        <!-- Your SQL statement here -->
    </select>

    2. mybatis-configuration에서 2차 캐싱 활성화 제거

     

     

     

     

    ※ 짤막 지식

    스프링 프레임워크와 데이터베이스의 세션, 트랜잭션 관계 및 원리는 1:1관계인지 알았는데,

    스프링 프레임워크에서 관리하는 세션, 트랜잭션은 데이터베이스와 조금 다르다.

    아래내용은 GPT가 답변한 내용이다.

     

    1) 데이터베이스 세션

     

    데이터베이스 세션은 애플리케이션과 데이터베이스 간의 연결 및 상호 작용을 의미하며 

    데이터베이스 트랜잭션은 데이터 일관성과 무결성을 보장하는 방법으로 ACID속성을 지닌다.

    또한, 데이터베이스 작업을 단일 원자 작업단위로 처리할 수 있게한다. 

     

    대부분의 데이터베이스는 단일 세션 - 단일 트랜잭션을 가진다. (MySQL도)

     

     

    2) 스프링 프레임 워크 세션

     

    스프링에서 세션과 트랜잭션은 데이터베이스 작업시 자주 사용되는 추상화의미로 쓰이지 동일한 의미는 아니다.

    Hibernate, JPA, JDBC같은 데이터 엑세스 기술을 통해 세션이라는 개념을 제공하는데

    데이터베이스와의 상호 작용을 관리하는데 사용된다.

    스프링 프레임워크에서 트랜잭션은 데이터베이스에 구애받지 않는 방식으로 데이터베이스 트랜잭션을 관리한다.

     

    즉, 데이터베이스가 단일 세션 - 단일 트랜잭션을 가지는 관계가아닌

    다중세션 - 다중 트랜잭션, 단일 세션 - 다중 트랜잭션 관계를 가질 수 있다.

     

     

     


     

    3. 실전

     

    3-1.  1차 레벨 캐싱 확인하기

    ■ Result Type으로 진행

     

    사용한 mapper.xml의 SELECT문은 다음과 같다.

        <select id="findMemberById" resultType="com.codingdreamtree.mybatis.member.domain.Member">
            SELECT seq_member,
                   name,
                   weight
              FROM member
             WHERE seq_member = #{seqMember}
        </select>

     

     

    여러가지 시나리오로 진행해보자

     

    1) 단순조회를 통한 캐싱확인

     

    1-1. 1개의 레코드를 여러번 조회하는 경우 쿼리가 한번만 나가고 같은 인스턴스를 공유하는지 확인해보았다.

     

        @Transactional(readOnly = true)
        public Member findMember(long id) {
            Member findMember1 = memberRepository.findMemberById(id);
            Member findMember2 = memberRepository.findMemberById(id);
            Member findMember3 = memberRepository.findMemberById(id);
    
            log.info("findMember1  address: {}", findMember1.toString());
            log.info("findMember2  address: {}", findMember2.toString());
            log.info("findMember3  address: {}", findMember3.toString());
            return findMember1;
        }

     

     

    캐시가 작동되어서 쿼리가 1번만 발생하고 같은 인스턴스를 공유하는 것을 확인할 수 있다.

     

     

    1-2.중간에 파라미터를 1개 변경하여 테스트한 경우

    같은 쿼리여도 파라미터가 다르면 쿼리가 한번 더 발생하고 인스턴스가 독립적으로 생성된다.

     

    2) 단순조회지만 격리수준을 uncommitted로 수정해서 진행

     

        @Transactional(readOnly = true, isolation = Isolation.READ_UNCOMMITTED)
        public Member findMemberReadUncommitted(long id) {
            Member findMember1 = memberRepository.findMemberById(id);
            Member findMember2 = memberRepository.findMemberById(id);
            Member findMember3 = memberRepository.findMemberById(id);
    
            log.info("findMember1  address: {}", findMember1.toString());
            log.info("findMember2  address: {}", findMember2.toString());
            log.info("findMember3  address: {}", findMember3.toString());
            return findMember1;
        }

     

     

    결과는 위와 동일하다.

     

     

    3) 단순 조회지만 Transactional로 선언되지 않은 메서드에서 Transaction Service를 여러번 호출

        @Test
        void findMember() {
            Member member = memberService.findMember(1L);
            Member member2 = memberService.findMember(1L);
            Member member3 = memberService.findMember(1L);
        }

     

    캐싱이 작동되지 않는것을 확인할 수 있다.

     

     

    4) 단순 조회로 Transactional을 선언한 메서드에서 다른 Transactional 메서드들을  호출

     

    4-1.  Propagation_required 인경우 (트랜잭션이 부모의 트랜잭션을 따라가게됨)

    // Propagation.Required
    	@Transactional(readOnly = true)
        public Member multiCallFindMember(Long id) {
            Member member1 = memberService.findMember(id);
            Member member2 = memberService.findMember(id);
    
            return member1;
        }

     

    4-1.  Propagation_required 인경우 (트랜잭션이 부모의 트랜잭션을 따라가게됨)

    // Propagation.Requires_NEW
        @Transactional(readOnly = true)
        public Member multiCallFindMemberRequiresNew(Long id) {
            Member member1 = memberService.findMemberRequiresNew(id);
            Member member2 = memberService.findMemberRequiresNew(id);
    
            return member1;
        }

     

    Propagation으로 트랜잭션을 분리하여도 결과는 동일하다.

     

     

    5) select 태그에  useCache=false 사용, flushCache=true 사용

     

    5-1.useCache=false

     

        <select id="findMemberById" resultType="com.codingdreamtree.mybatis.member.domain.Member" useCache="false">
            SELECT seq_member,
                   name,
                   weight
              FROM member
             WHERE seq_member = #{seqMember}
        </select>

     

    위에 나온 내용 대로 캐시 비활성화가 되지 않았다. 2차 레벨에서만 적용되는것이 맞는것 같다.

     

     

    5-2. flushCache=true

        <select id="findMemberById" resultType="com.codingdreamtree.mybatis.member.domain.Member" flushCache="true">
            SELECT seq_member,
                   name,
                   weight
              FROM member
             WHERE seq_member = #{seqMember}
        </select>

    캐싱 내용이 초기화되어서 쿼리를 한번 더 호출하고, 인스턴스가 나뉜 것을 확실히 알 수 있다.

     

     

    6) 같은 쿼리문이지만 다른 id를 가진 SELECT 태그 실행

     

        @Transactional(readOnly = true)
        public Member findMemberDiffId(long id) {
            Member findMember1 = memberRepository.findMemberById(id);
            Member findMember2 = memberRepository.findMemberById2(id);
    
            log.info("findMember1  address: {}", findMember1.toString());
            log.info("findMember2  address: {}", findMember2.toString());
            return findMember1;
        }

     

    캐싱이 되지 않는다.

     

     

     

    7) localCacheScope를 STATEMENT로 변경

     

    application.yml에서

    mybatis:
      configuration:
        map-underscore-to-camel-case: true
        local-cache-scope: statement

    로 변경

     

    캐싱이 비활성화 된것을 확인 할 수 있었다.

     

     

     

     

    결론:  1차 레벨 캐싱은 쿼리문과 파라미터 변수로 진행되며,

    해당 캐싱을 초기화하려면 SELECT 태그에 flushCache=ture 옵션을 주어야한다.

    또한 , 비활성화를 진행하려면 localCacheScope 을 STATEMENT로 변경한다.

     

     

     

     


     

     

    ■ Result Map으로 진행

     

    조금 더 복잡함을 주기 위해서  DB테이블을 추가하고 컬렉션으로 받을 수 있게 하였다.

     

     

    1) Place의 레코드 Order 레코드가 각각 같은 seqMember를 가진 member레코드를 쿼리를 통해 연관관계로 가져온 후

    같은 인스턴스를 참조하고 있는지 비교해보았다.

     

    Order 조회

        <select id="findOrderById" resultMap="orderMap">
            SELECT od.seq_order,
                   od.order_name,
                   m.seq_member AS m_seq_member,
                   m.name AS m_name
              FROM `order` od
              JOIN member m
                ON m.seq_member = od.seq_member
             WHERE od.seq_order = #{seqOrder}
        </select>
    
        <resultMap id="orderMap" type="com.codingdreamtree.mybatis.order.domain.Order">
            <id column="seq_order" property="seqOrder"/>
            <result column="order_name" property="orderName"/>
            <association property="member" columnPrefix="m_" resultMap="memberMap" />
        </resultMap>
    
    
        <resultMap id="memberMap" type="com.codingdreamtree.mybatis.member.domain.Member">
            <id column="seq_member" property="seqMember"/>
            <result column="name" property="name"/>
        </resultMap>

     

    Place 조회

    	<select id="findPlaceById" resultMap="placeMap">
            SELECT p.seq_place,
                   p.place_name,
                   m.seq_member AS m_seq_member,
                   m.name AS m_name
              FROM place p
              JOIN member m
                ON m.seq_member = p.seq_member
             WHERE p.seq_place = #{seqPlace}
        </select>
    
        <resultMap id="placeMap" type="com.codingdreamtree.mybatis.place.domain.Place">
            <id column="seq_place" property="seqPlace"/>
            <result column="place_name" property="placeName"/>
            <association property="member" columnPrefix="m_" resultMap="memberMap" />
        </resultMap>
    
    
        <resultMap id="memberMap" type="com.codingdreamtree.mybatis.member.domain.Member">
            <id column="seq_member" property="seqMember"/>
            <result column="name" property="name"/>
        </resultMap>

     

    실행 코드

     

        @Transactional(readOnly = true)
        public void executeResultMap() {
            Order order = orderRepository.findOrderById(1);
            Place place = placeRepository.findPlaceById(1);
    
            log.info("order's Member : {}  seqMember: {}", order.getMember().toString(), order.getMember().getSeqMember());
            log.info("place's Member : {}  seqMember: {}", place.getMember().toString(), place.getMember().getSeqMember());
        }

     

    같은 seq_member를 가진 것을 가져왔음에도 레퍼런스가 다른 것을 확인할 수 있었다.

     

     

    2) 같은 쿼리이면서 매개변수가 다르지만 조인되는 member가 동일한 경우

     

    이 경우에도 캐싱이 되지 않고 멤버 인스턴스가 다른 것을 확인 할수 있었다.

     

    3) 같은쿼리, 범위값 안에 교집합으로 존재하는 경우

     

    이 경우도 인스턴스를 공유하지 않는다...

     

     

    4) 같은 쿼리, 같은 매개변수 조회

     

     

    단일 매개변수이든, 범위형 매개변수이든 같은 쿼리, 매개변수이면 캐싱이 동작되고 내부 연관관계 객체들도

    캐싱되어 같은 레퍼런스참조하고 있다.

     

     

    결론: 연관관계의 id값이 같은 인스턴스여도 동일한(매개변수 포함) 쿼리를 사용하지 않으면

    해당 인스턴스를 공유하지 않았다. 이는 컬렉션과 단일 연관관계 모두 동일하게 작동하였다.

    'SPRING 공부 > 기타' 카테고리의 다른 글

    아파치 카프카 문서보고 공부하기 #2  (0) 2023.01.04
    아파치 카프카 문서보고 공부하기 #1  (0) 2022.12.26
    RFC 문서  (0) 2022.12.03
    템플릿 엔진  (0) 2022.02.01
Designed by Tistory.