들어가며
스프링을 기반으로 한 팀 프로젝트를 개발하면서, 성능 최적화를 해보고싶었습니다. 그 와중에 떠오른게 바로 캐시입니다. 개념만 살짝 알고있었는데 이번 포스팅을 통해서 글로벌 캐시가 아닌 로컬 캐시를 선택한 이유 그리고 가장 중요한 캐시의 검증 을 로그를 통해 알아보겠습니다.
로컬 캐시를 선택한 이유
캐시 전략에는 크게 두 가지 유형이 있습니다
- 글로벌 캐시:
- 여러 대의 서버를 사용하는 환경에서 별도의 캐시 서버를 구축하여 사용합니다.
- 장점: 분산 서버 환경에서 효율적입니다.
- 단점: 외부 캐시 서버와의 네트워크 비용이 발생하며, 별도의 캐시 서버 구성이 필요합니다.
- 로컬 캐시:
- 각 서버 인스턴스의 자원을 사용하여 캐시를 구성합니다.
- 장점: 구현이 간단하고, 하나의 서버 인스턴스에서 운영될 때 글로벌 캐시에 비해 성능상 이점이 있습니다.
- 단점: 분산 서버 환경에서는 데이터 정합성 문제가 발생할 수 있습니다.
저의 사이드 프로젝트는 캐시 기능을 통한 성능 개선이 크게 필요하지 않으며, 여건상 하나의 서버 인스턴스를 사용할 것이므로 로컬 캐시를 선택했습니다.
스프링에서 로컬 캐시 구현을 위해 여러 옵션이 있으며, 여기에는 ConcurrentHashMap, Caffeine, ehcache, Guava 등이 포함됩니다. 이 중에서 Caffeine은 간단한 기능과 우수한 벤치마크 성능으로 인해 로컬 캐시의 구현체로 선택되었습니다.
아래 그래프는 Caffeine cache 깃헙 위키에서 제공하는 성능 비교 자료입니다.
읽기와 쓰기 성능 테스트에서 Caffeine Cache 가 2등을 따돌리는 압도적인 성능을 보여주고 있습니다.
쓰기 성능 테스트에서도 역시 Caffeine Cache 가 압도적인 성능으로 논란을 잠재우고 있습니다.
카페인이라는 이름처럼 아드레날린이 솟아나는 빠름입니다.
스프링 부트에 캐시를 적용해보자
https://wave1994.tistory.com/182
https://loosie.tistory.com/806
를 참고하여 코드를 작성해보겠습니다.
의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation 'com.github.ben-manes.caffeine:caffeine'
캐시 설정
이제 캐시를 정의합니다.
프로젝트에서 dto 는 record 로 구현을 했는데, CacheType 같은 경우는 한정된 값을 정의해줄 것이기에 Enum을 사용합니다.
@Getter
public enum CacheType {
SHOWS(
"shows", // 캐시 이름: shows
10 * 60, // 만료 시간: 10 분
5000 // 최대 갯수: 5000
);
CacheType(
String cacheName,
int expireSecondsAfterWrite,
int maximumSize
) {
this.cacheName = cacheName;
this.expireAfterWrite = expireSecondsAfterWrite;
this.maximumSize = maximumSize;
}
private final String cacheName;
private final int expireAfterWrite;
private final int maximumSize;
}
캐시는 캐시 이름, 만료 시간, 최대 갯수를 갖고있도록 합니다.
@EnableCaching 과 @Configuration 으로 CacheConfig 를 만들어주어
스프링 application 이 캐시를 사용할 수 있도록 합니다.
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
List<CaffeineCache> caches = Arrays.stream(CacheType.values())
.map(cache -> new CaffeineCache(
cache.getCacheName(),
Caffeine.newBuilder()
.recordStats()
.expireAfterWrite(cache.getExpireAfterWrite(), TimeUnit.SECONDS)
.maximumSize(cache.getMaximumSize())
.build()
)
)
.collect(Collectors.toList());
SimpleCacheManager cacheManager = new SimpleCacheManager();
cacheManager.setCaches(caches);
return cacheManager;
}
}
CacheType 에 등록한 캐시들을 (위의 예제에서는 shows 캐시 하나)
CaffeineCache 객체로 Builder 생성 후 SimpleCacheManager 객체에 등록하겠습니다.
Show 엔티티
@Entity
@Getter
@Table(name = "show_table")
@NoArgsConstructor
public class Show extends TimeBaseEntity {
@Id
@GeneratedValue(strategy = IDENTITY)
@Column(name = "show_id")
private Long id;
@Column(name = "show_name", nullable = false)
private String name;
@Enumerated(STRING)
@Column(name = "show_category", nullable = false)
private ShowCategory category;
@Embedded
private ShowPeriod showPeriod;
@Embedded
private ShowTime showTime;
@Column(name = "show_age_limit", nullable = false)
private String showAgeLimit;
@Column(name = "show_total_seats")
private int totalSeats;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "show_place_id", nullable = false, foreignKey = @ForeignKey(value = NO_CONSTRAINT))
private Place place;
public Show(
String name,
ShowCategory category,
ShowPeriod showPeriod,
ShowTime showTime,
String showAgeLimit,
int totalSeats,
Place place
) {
this.name = name;
this.category = category;
this.showPeriod = showPeriod;
this.showTime = showTime;
this.showAgeLimit = showAgeLimit;
this.totalSeats = totalSeats;
this.place = place;
}
}
테스트
ShowsApiController.java
@Slf4j
@Tag(name = "Show API")
@RestController
@RequiredArgsConstructor
public class ShowsApiController {
private final ShowService showService;
@NoAuth
@GetMapping("/api/shows")
@Operation(summary = "카테고리별로 공연 전체조회 API", description = "선택한 카테고리의 전체 공연의 공연 이름, 장소 이름을 조회한다")
@ApiResponse(responseCode = "200", useReturnTypeSchema = true)
@Cacheable(cacheNames = "shows")
public ResponseEntity<PagedResponse<ShowInfoResponse>> getShowsByCategory(
@RequestParam("page") @Min(0) int page,
@RequestParam("size") @Min(1) int size,
@RequestParam("category") @NotBlank String category
) {
log.info("/api/shows 호출");
PagedResponse<ShowInfoResponse> shows = showService.getShowsByCategory(page, size, category);
shows.content().forEach(
show -> log.info("Shows fetching from DB: " + show.showName())
);
return ResponseEntity.ok(shows);
}
}
api 를 호출한다는 내용과
카테고리에 해당하는 공연들을 모두 DB 에서 조회해오는 내용을 로그로 찍어줍니다.
ShowService.java
public PagedResponse<ShowInfoResponse> getShowsByCategory(int page, int size, String category) {
log.info("ShowService 에서 getShowsByCategory 호출함");
ShowCategory showCategory = ShowCategory.of(category);
PageRequest pageRequest = PageRequest.of(page, size);
Page<Show> showsPage = showRepository.findByCategoryOrderByIdDesc(showCategory, pageRequest);
List<ShowInfoResponse> content = showsPage.map(ShowMapper::toShowInfoResponse).getContent();
return new PagedResponse<>(
content,
showsPage.getTotalPages(),
showsPage.getNumberOfElements(),
showsPage.getNumber(),
showsPage.getSize()
);
}
서비스에서도 로그를 한 번 찍어줍니다.
생명주기를 테스트하듯이 중간 과정을 살피기 위함입니다.
ShowsApiControllerTest.java
@Slf4j
@DisplayName("[ShowsApiController API 테스트]")
class ShowsApiControllerTest extends ApiTestSupport {
@Autowired
private ShowRepository showRepository;
@Autowired
private PlaceRepository placeRepository;
private String placeName;
@BeforeEach
void setUp() {
Place place = TestFixture.getPlace();
placeRepository.save(place);
placeName = place.getName();
Show show1 = TestFixture.getShow(place, "레미제라블", ShowCategory.MUSICAL);
Show show2 = TestFixture.getShow(place, "서울의 봄", ShowCategory.PLAY);
Show show3 = TestFixture.getShow(place, "노량", ShowCategory.PLAY);
showRepository.save(show1);
showRepository.save(show2);
showRepository.save(show3);
}
@DisplayName("[공연 전체조회 캐시 테스트]")
@Test
void cacheTest() throws Exception {
log.info("첫 번째 요청");
ResultActions resultActions1 = mockMvc.perform(MockMvcRequestBuilders.get("/api/shows")
.param("page", "0")
.param("size", "3")
.param("category", "PLAY"))
.andExpect(status().isOk());
// 로그 확인 (첫 번째 요청에서만 로그가 출력되어야 함)
// ShowService 호출 여부 확인
resultActions1.andExpectAll(
status().isOk(),
jsonPath("$.content.size()").value(2),
jsonPath("$.content[0].showName").value("노량"),
jsonPath("$.content[0].placeName").value(placeName),
jsonPath("$.content[1].showName").value("서울의 봄"),
jsonPath("$.content[1].placeName").value(placeName),
jsonPath("$.totalPages").value(1),
jsonPath("$.totalItems").value(2),
jsonPath("$.currentPage").value(0),
jsonPath("$.pageSize").value(3)
);
log.info("두 번째 요청");
ResultActions resultActions2 = mockMvc.perform(MockMvcRequestBuilders.get("/api/shows")
.param("page", "0")
.param("size", "3")
.param("category", "PLAY"))
.andExpect(status().isOk());
// 로그 확인 (두 번째 요청에서는 로그가 출력되지 않아야 함)
// ShowService 호출 여부 확인
resultActions2.andExpectAll(
status().isOk(),
jsonPath("$.content.size()").value(2),
jsonPath("$.content[0].showName").value("노량"),
jsonPath("$.content[0].placeName").value(placeName),
jsonPath("$.content[1].showName").value("서울의 봄"),
jsonPath("$.content[1].placeName").value(placeName),
jsonPath("$.totalPages").value(1),
jsonPath("$.totalItems").value(2),
jsonPath("$.currentPage").value(0),
jsonPath("$.pageSize").value(3)
);
log.info("테스트메서드 종료");
}
}
테스트코드를 실행하고 로그를 살펴보겠습니다.
첫 번째 요청에서는 api 를 정상적으로 호출하고, ShosService 에서 getShowsByCategory를 호출합니다.
그 후 해당 repository 에서 쿼리를 날린 후
'노량' 과 '서울의 봄' 을 DB 에서 가져왔습니다.
두 번째 요청에서는
같은 요청값임을 확인 후
api 호출조차 되지 않고, 로컬 캐시 메모리에서 응답을 가져온 후 api는 바로 종료되어버립니다.
마치며
로컬 캐시를 직접 세팅하고 사용해봤습니다. 로그로 테스트하면서 어떻게 작동하는지 파악할 수 있었습니다. 다음 포스팅에서는 데이터를 더 많이 넣어서 시간을 통해서 성능 테스트를 해보고, Update 와 Delete 쿼리에 따른 캐시의 조작을 실행해보겠습니다.
더하여, 캐시 속성의 만료 시간과 최대 갯수에 대한 테스트도 설계해보겠습니다.
읽어주셔서 감사합니다 !
'서버 > Spring' 카테고리의 다른 글
[Spring] DB를 사용한 JWT 인증에서 로그아웃을 구현해보자 (0) | 2024.01.26 |
---|---|
[백엔드] 스프링 핵심 원리 기본편 정리 (0) | 2023.04.27 |
[스프링 핵심 원리 - 기본편] 객체 지향 설계와 스프링 (1) | 2022.08.22 |
[스프링 핵심 원리 - 기본편] 좋은 객체 지향 설계의 5가지 원칙(SOLID) (0) | 2022.07.15 |
[스프링 핵심 원리 - 기본편] 좋은 객체 지향 프로그래밍이란 (0) | 2022.07.15 |