gi_dor

[Refactor] 카페인 캐싱으로 성능개선 , Ngrinder 본문

Back_End/SpringBoot

[Refactor] 카페인 캐싱으로 성능개선 , Ngrinder

기돌 2024. 6. 11. 00:55
728x90

Cache와 Caffeine Cache

캐시(cache) : 데이터나 값을 미리 복사해 놓는 임시 장소

Local Cache

  • 서버마다 캐시를 따로 저장
  • 다른 서버의 캐시를 참조하기 어려움
  • 속도 빠름
  • 로컬 서버 장비의 Resource를 이용한다. (Memory, Disk)

Global Cache

  • 여러 서버에서 캐시 서버 접근 및 참조 가능
  • 별도의 캐시 서버 이용 → 서버 간 데이터 공유가 쉬움
  • 네트워크 트래픽을 사용해야 해서 로컬 캐시보다는 느리다.
  • 데이터를 분산하여 저장 가능

Caffeine Cache

  • Spring Boot 3부터 @Bean으로 org.springframework.cache.CacheManager의 EhCacheCacheManager 구현은 더 이상 지원되지 않는다고 한다....... 인강에서는 ehCache 쓰는데.. 
  • 빠르게 적용하기 쉬운 Local cache caffeine 선택

 

1. 의존성 추가

implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation 'com.github.ben-manes.caffeine:caffeine'

 

2. 구조

3. 캐시 설정 CacheConfig

  • TimeUnit.SECONDS 부분에 코드를 통해 캐시 만료 데이터 기간을 초,분,시단위로 설정가능.
@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public List<CaffeineCache> caffeineCaches() {
        return Arrays.stream(CacheType.values())
                .map(cache -> new CaffeineCache(cache.getCacheName(), Caffeine.newBuilder().recordStats()
                        .expireAfterWrite(cache.getExpiredAfterWrite(), TimeUnit.SECONDS)
                        .maximumSize(cache.getMaximumSize())
                        .build()))
                .toList();
    }
    @Bean
    public CacheManager cacheManager(List<CaffeineCache> caffeineCaches) {
        SimpleCacheManager cacheManager = new SimpleCacheManager();
        cacheManager.setCaches(caffeineCaches);

        return cacheManager;
    }
}

 

CacheType

package com.example.bookhub.common;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum CacheType {

 INQUIRIES("MyPageMapper.cacheInquiries" , 10,10000),
 NOTICE_FINDALL("NoticeMapper.findAll" , 10 ,10000);

    private final String cacheName;
    private final int expiredAfterWrite;
    private final int maximumSize;
}

 

 

4. 사용하기

  • 캐시 사용 전 , 캐시 사용 후 
@Transactional(readOnly = true)
    public PageListDTO<InquiryListDTO> getInquiryListByIdPage(String id , int page) {
        // 사용자 정보조회
        User user = userMapper.selectUserById(id);

        // 사용자의 아이디로 전체 작성된 1:1문의 갯수 조회
        int totalRows = myPageMapper.countInquiry(user.getId());

        // 페이징 정보
        UserPagination userPagination =  new UserPagination(page , totalRows);

        int offset = userPagination.getBegin() -1;

        // 페이징된 결과 조회
        List<InquiryListDTO> inquiryListDTO = myPageMapper.selectInquiryListPaging(user.getId(), offset);

        PageListDTO<InquiryListDTO> pageListDTO = new PageListDTO<>();
        pageListDTO.setItems(inquiryListDTO);
        pageListDTO.setUserPagination(userPagination);

        // PageListDTO<InquiryListDTO> pageListDTO = new PageListDTO<>(inquiryListDTO,userPagination);

        return pageListDTO;

    }


    @Cacheable(value = "MyPageMapper.cacheInquiries" , key = "#page", condition = "#page <= 4")
    public PageListDTO<InquiryListDTO> getCacheInquiriesList( int page) {

        // 사용자의 아이디로 전체 작성된 1:1문의 갯수 조회
        int totalRows = myPageMapper.countInquiriesAll();

        // 페이징 정보
        UserPagination userPagination =  new UserPagination(page , totalRows);

        int offset = userPagination.getBegin() -1;

        // 페이징된 결과 조회 - 캐싱 Mapper
        List<InquiryListDTO> inquiryListDTO = myPageMapper.cacheInquiries(offset);

        PageListDTO<InquiryListDTO> pageListDTO = new PageListDTO<>();
        pageListDTO.setItems(inquiryListDTO);
        pageListDTO.setUserPagination(userPagination);

        // PageListDTO<InquiryListDTO> pageListDTO = new PageListDTO<>(inquiryListDTO,userPagination);

        return pageListDTO;

    }
    <!-- 캐싱 1:1 문의  -->
    <select id="cacheInquiries" resultType="com.example.bookhub.user.dto.InquiryListDTO">
        select
            i.INDIVIDUAL_INQUIRY_NO             as no,
            i.INQUIRY_CATEGORY_NO               as "faqCategory.no",
            f.FAQ_CATEGORY_NAME                 as "faqCategory.name",
            i.INQUIRY_USER_NO                   as "user.no",
            u.USER_ID                           as "user.id",
            i.INDIVIDUAL_INQUIRY_TITLE          as title,
            i.INDIVIDUAL_INQUIRY_CONTENT        as content,
            i.INDIVIDUAL_INQUIRY_ANSWER_YN      as answerYn,
            i.INDIVIDUAL_INQUIRY_DELETE_YN      as deleteYn,
            i.INDIVIDUAL_INQUIRY_CREATE_DATE    as createdDate ,
            i.INDIVIDUAL_INQUIRY_UPDATE_DATE    as updatedDate,
            u.USER_NAME                         as "user.name"
        from INDIVIDUAL_INQUIRIES i , USER u , FAQ_CATEGORIES f
        where i.INQUIRY_USER_NO = u.USER_NO
          and i.INQUIRY_CATEGORY_NO = f.FAQ_CATEGORY_NO
        order by  i.INDIVIDUAL_INQUIRY_CREATE_DATE DESC
            LIMIT #{offset} ,10
    </select>
// 캐싱 1:1 문의

List<InquiryListDTO>cacheInquiries( @Param("offset")int offset);
@Slf4j
@Controller
@RequiredArgsConstructor
@RequestMapping("/mypage/list")
public class UserMyPageListController {

    private final UserService userService;
    private final MyPageService myPageService;
    private final ReturnService returnService;
    
    @PreAuthorize("isAuthenticated()")
     @GetMapping("/inquiryListCache")
    public String inquiryListPageCache(@RequestParam(name="page" , required = false ,defaultValue="1") int page,
                                       Model model) {
        System.out.println(" :: 캐싱처리 했음      :: ");


        // 로그인한 사용자의 1:1 문의 목록 , 페이징 정보 조회
        PageListDTO<InquiryListDTO> inquiryList = myPageService.getCacheInquiriesList( page);


        // 로그인한 사용자가 작성한 글의 갯수 조회
        int totalRows = myPageService.countInquiriesAll();

        model.addAttribute("totalRows",totalRows);
        model.addAttribute("inquiryList",inquiryList.getItems());
        model.addAttribute("page",inquiryList.getUserPagination());


        return "user/list/inquiryList";
    }
}

 

5. Ngrinder Test

  •  랜덤페이지 1이상 4이하로 설정 
     int randomPage = new Random().nextInt(4) + 1;
     String url = "http://127.0.0.1:8080/mypage/list/inquiryList?page=" + randomPage
import static net.grinder.script.Grinder.grinder
import static org.junit.Assert.*
import static org.hamcrest.Matchers.*
import net.grinder.script.GTest
import net.grinder.script.Grinder
import net.grinder.scriptengine.groovy.junit.GrinderRunner
import net.grinder.scriptengine.groovy.junit.annotation.BeforeProcess
import net.grinder.scriptengine.groovy.junit.annotation.BeforeThread
import static net.grinder.util.GrinderUtils.* // You can use this if you're using nGrinder after 3.2.3
import org.junit.Before
import org.junit.BeforeClass
import org.junit.Test
import org.junit.runner.RunWith

import org.ngrinder.http.HTTPRequest
import org.ngrinder.http.HTTPRequestControl
import org.ngrinder.http.HTTPResponse
import org.ngrinder.http.cookie.Cookie
import org.ngrinder.http.cookie.CookieManager

/**
* A simple example using the HTTP plugin that shows the retrieval of a single page via HTTP.
*
* This script is automatically generated by ngrinder.
*
* @author admin
*/
@RunWith(GrinderRunner)
class TestRunner {

	public static GTest test
	public static HTTPRequest request
	public static Map<String, String> headers = [:]
	public static Map<String, Object> params = [:]
	public static List<Cookie> cookies = []

	@BeforeProcess
	public static void beforeProcess() {
		HTTPRequestControl.setConnectionTimeout(300000)
		test = new GTest(1, "127.0.0.1")
		request = new HTTPRequest()
		grinder.logger.info("before process.")
	}

	@BeforeThread
	public void beforeThread() {
		test.record(this, "test")
		grinder.statistics.delayReports = true
		grinder.logger.info("before thread.")
	}

	@Before
	public void before() {
		request.setHeaders(headers)
		CookieManager.addCookies(cookies)
		grinder.logger.info("before. init headers and cookies")
	}

	@Test
	public void test() {
		// 랜덤페이지 1이상 4이하
		 int randomPage = new Random().nextInt(4) + 1;
		 
		 String url = "http://127.0.0.1:8080/mypage/list/inquiryList?page=" + randomPage

	
		HTTPResponse response = request.GET(url, params)

		if (response.statusCode == 301 || response.statusCode == 302) {
			grinder.logger.warn("Warning. The response may not be correct. The response code was {}.", response.statusCode)
		} else {
			assertThat(response.statusCode, is(200))
		}
	}
}

 

 

 

200이 아닌 302가 왜뜨는데 ?

사용자 인증이 되지 않아서 Test 실패

@PreAuthorize("isAuthenticated()")
  • 스프링 시큐리티에서 제공하는 어노테이션 
  • 메서드를 호출하기전에 사용자가 인증되는지 검사한다

인증 어노테이션 제거후 Test


캐싱 비교 최종 결과 

 

결과 TPS 31.9 → 43.8 약 27% 성능 개선

 

728x90