Kotlin으로 Spring Boot Cache Caffeine 만들기

개요

Kotlin 언어를 이용하여 간단한 Spring Boot Cache를 만들어 보겠다.

Spring Boot에서 공식으로 소개하고 있는 Cache의 방법은 여러가지고 있다. 그 중에서도 Caffeine이 도입 비용이 낮다.

  • Caffeine 로컬 캐시 이다.
  • 로컬 캐시이므로 서버가 따로 필요하지 않는다.
  • TTL(Timeout)을 설정 가능하다.

흔하게 DB에서 매번 동일한 결과를 가져오는 쿼리를 던져서 가져와야 하는 경우라면 캐시를 이용해 보면 부하를 줄일 수 있다.

프로젝트 생성

아래와 같이 curl 명령어을 사용하여 Spring Boot 초기 프로젝트를 생성한다.

curl https://start.spring.io/starter.tgz \
-d bootVersion=2.5.6 \
-d dependencies=cache \
-d baseDir=kotlin-spring-cache-caffeine \
-d groupId=com.devkuma \
-d artifactId=kotlin-spring-cache-caffeine \
-d packageName=com.devkuma.cache.caffeine \
-d applicationName=CaffeineApplication \
-d packaging=jar \
-d language=kotlin \
-d jvmVersion=11 \
-d type=gradle-project | tar -xzvf -

생성된 프로젝트 확인

프로젝트 파일 구조

제대로 생성이 되었다면 프로젝트의 파일 구조는 아래와 같이 구성된다.

.
├── HELP.md
├── build.gradle.kts
├── gradle
│   └── wrapper
│       ├── gradle-wrapper.jar
│       └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── settings.gradle.kts
└── src
    ├── main
    │   ├── kotlin
    │   │   └── com
    │   │       └── devkuma
    │   │           └── cache
    │   │               └── caffeine
    │   │                   └── CaffeineApplication.kt
    │   └── resources
    │       └── application.properties
    └── test
        └── kotlin
            └── com
                └── devkuma
                    └── cache
                        └── caffeine
                            └── CaffeineApplicationTests.kt

빌드 스크립트

/build.gradle.kts

// ... 생략 ...

dependencies {
	implementation("org.springframework.boot:spring-boot-starter-cache")
	implementation("org.jetbrains.kotlin:kotlin-reflect")
	implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
	testImplementation("org.springframework.boot:spring-boot-starter-test")
}

// ... 생략 ...

의존성 라이브러리에 스프링 캐시 라이브러리(spring-boot-starter-cache)가 포함된 것을 볼 수 있다.

Caffeine cache 설정

Caffein 라이브러리 추가

먼저 gradle에 Caffein 라이브러리(com.github.ben-manes.caffeine:caffeine를 추가한다.

/build.gradle.kts

// ... 생략 ...

dependencies {
	implementation("org.springframework.boot:spring-boot-starter-cache")
	implementation("org.jetbrains.kotlin:kotlin-reflect")
	implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
	implementation("com.github.ben-manes.caffeine:caffeine")
    implementation("io.github.microutils:kotlin-logging:2.0.11")
    
	testImplementation("org.springframework.boot:spring-boot-starter-test")
}

// ... 생략 ...

추가로 결과를 확인해 보기 위해 로그 라이브러리(io.github.microutils:kotlin-logging)도 추가하였다.

설정 클래스 추가

신규로 CacheConfig 설정 클래스을 생성해서 아래와 같이 작성한다.

/src/main/kotlin/com/devkuma/cache/caffeine/config/CacheConfig.kt

package com.devkuma.cache.caffeine.config

import org.springframework.cache.annotation.EnableCaching
import org.springframework.context.annotation.Configuration

@Configuration
@EnableCaching
class CacheConfig

캐시를 사용하기 위해 클래스에 @EnableCaching을 추가한다.

어플리케이션 설정 추가

어플리케이션 설정 파일(application.yml)에 캐시 관련 추가한다.
(초기 프로젝트를 생성하면, application.properties 파일이 있을 것이다. 이 파일에 확장자만 .yml으로 변경하면 된다.)

/src/main/resources/application.yml

spring:
  cache:
    cache-names: category
    caffeine:
      spec: maximumSize=100, expireAfterAccess=60s
  • cache-names는 관리되는 캐시 이름 목록이다. expireAfterWrite
  • maximumSize는 캐시의 최대 사이즈이다. 캐시 크기가 최대값에 가까워지면 캐시는 다시 사용할 가능성이 낮은 항목을 제거한다. 크기사 0이면 캐시에 로드된 직후에 제거된다.
  • expireAfterWrite는 마지막 쓰기가 발생한 후에 기간이 지나면 항목이 만료된다.
    • 그밖에…

Cache 적용 관련 클래스 생성

Cache를 적용하려는 Repository 클래스 추가

/src/main/kotlin/com/devkuma/cache/caffeine/repository/CategoryRepository.kt

package com.devkuma.cache.caffeine.repository

import com.devkuma.cache.caffeine.dto.Category
import mu.KotlinLogging
import org.springframework.cache.annotation.CacheConfig
import org.springframework.cache.annotation.Cacheable
import org.springframework.stereotype.Repository

private val log = KotlinLogging.logger {}

@Repository
@CacheConfig
class CategoryRepository {

    @Cacheable("category")
    fun findById(id: Long): Category {
        log.info("category[${id}] cache is not used.")
        return Category(id,"Book")
    }
}

Cache를 적용된 Repository를 호출하는 Service 클래스 추가

/src/main/kotlin/com/devkuma/cache/caffeine/service/CategoryService.kt

package com.devkuma.cache.caffeine.service

import com.devkuma.cache.caffeine.dto.Category
import com.devkuma.cache.caffeine.repository.CategoryRepository
import mu.KotlinLogging
import org.springframework.stereotype.Service

private val log = KotlinLogging.logger {}

@Service
class CategoryService(
    private val categoryRepository: CategoryRepository
) {

    fun getById(id: Long): Category {
        log.info("Call function categoryRepository.findAll(${id})")
        return categoryRepository.findById(id)
    }
}

어플리케이션 실행

어플리케이션 실행 클래스 생성

/src/main/kotlin/com/devkuma/cache/caffeine/service/CategoryService.kt

package com.devkuma.cache.caffeine

import com.devkuma.cache.caffeine.service.CategoryService
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.CommandLineRunner
import org.springframework.stereotype.Component

@Component
class CaffeineRunner: CommandLineRunner {

    @Autowired
    private lateinit var categoryService: CategoryService

    @Throws(Exception::class)
    override fun run(vararg args: String?) {
        categoryService.getById(1) // No hit , since this is the first request.
        categoryService.getById(2) // No hit , since this is the first request.
        categoryService.getById(1) // hit , since it is already in the cache.
        categoryService.getById(1) // hit , since it is already in the cache.
    }
}

어플리케이션 실행 확인

이제 실행을 시켜 보자.
(툴를 사용하고 있다면, CaffeineApplication를 실행하면 된다.)


  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v2.5.6)

2021-11-12 12:46:49.034  INFO 12757 --- [           main] c.d.c.caffeine.CaffeineApplicationKt     : Starting CaffeineApplicationKt using Java 15.0.4 on 2021090004.local with PID 12757 (/Users/we/develop/tutorial/kotlin-tutorial/kotlin-spring-cache-caffeine/build/classes/kotlin/main started by we in /Users/we/develop/tutorial/kotlin-tutorial/kotlin-spring-cache-caffeine)
2021-11-12 12:46:49.036  INFO 12757 --- [           main] c.d.c.caffeine.CaffeineApplicationKt     : No active profile set, falling back to default profiles: default
2021-11-12 12:46:50.037  INFO 12757 --- [           main] c.d.c.caffeine.CaffeineApplicationKt     : Started CaffeineApplicationKt in 6.481 seconds (JVM running for 6.898)
2021-11-12 12:46:50.047  INFO 12757 --- [           main] c.d.c.caffeine.service.CategoryService   : Call function categoryRepository.findById(1)
2021-11-12 12:46:50.075  INFO 12757 --- [           main] c.d.c.c.repository.CategoryRepository    : category[1] cache is not used.
2021-11-12 12:46:50.077  INFO 12757 --- [           main] c.d.c.caffeine.service.CategoryService   : Call function categoryRepository.findById(2)
2021-11-12 12:46:50.077  INFO 12757 --- [           main] c.d.c.c.repository.CategoryRepository    : category[2] cache is not used.
2021-11-12 12:46:50.078  INFO 12757 --- [           main] c.d.c.caffeine.service.CategoryService   : Call function categoryRepository.findById(1)
2021-11-12 12:46:50.080  INFO 12757 --- [           main] c.d.c.caffeine.service.CategoryService   : Call function categoryRepository.findById(1)

Process finished with exit code 0

Service 클래스에서 catagoryId 별로 findById(id) 메소드가 실행 될때 마다, Repository 클래스에서 category[${id}] cache is not used.가 실행되어 출력된 것을 확인 할수 있고,

2021-11-12 12:46:50.047  INFO 12757 --- [           main] c.d.c.caffeine.service.CategoryService   : Call function categoryRepository.findById(1)
2021-11-12 12:46:50.075  INFO 12757 --- [           main] c.d.c.c.repository.CategoryRepository    : category[1] cache is not used.
2021-11-12 12:46:50.077  INFO 12757 --- [           main] c.d.c.caffeine.service.CategoryService   : Call function categoryRepository.findById(2)
2021-11-12 12:46:50.077  INFO 12757 --- [           main] c.d.c.c.repository.CategoryRepository    : category[2] cache is not used.

두번째로 Service 클래스의 findById(id) 메소드가 실행 될때는 Repository 클래스에서 로그는 실행되지 않는 것을 확인할 있다.

2021-11-12 12:46:50.078  INFO 12757 --- [           main] c.d.c.caffeine.service.CategoryService   : Call function categoryRepository.findById(1)
2021-11-12 12:46:50.080  INFO 12757 --- [           main] c.d.c.caffeine.service.CategoryService   : Call function categoryRepository.findById(1)

테스트 코드로 확인

테스트 라이브러리 추가

테스트를 하기 위해 mockk 라이브러리(io.mockk:mockk, com.ninja-squad:springmockk)를 추가한다.

/build.gradle.kts

// ... 생략 ...

dependencies {
	implementation("org.springframework.boot:spring-boot-starter-cache")
	implementation("org.jetbrains.kotlin:kotlin-reflect")
	implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
	implementation("com.github.ben-manes.caffeine:caffeine")
    implementation("io.github.microutils:kotlin-logging:2.0.11")
    
	testImplementation("org.springframework.boot:spring-boot-starter-test")
    testImplementation("io.mockk:mockk:1.12.0")
	testImplementation("com.ninja-squad:springmockk:3.0.1")
	testImplementation("org.awaitility:awaitility-kotlin:4.1.1")
}

// ... 생략 ...

테스트 코드 작성

/src/test/kotlin/com/devkuma/cache/caffeine/service/CategoryServiceTests.kt

package com.devkuma.cache.caffeine.service

import com.devkuma.cache.caffeine.config.CacheConfig
import com.devkuma.cache.caffeine.repository.CategoryRepository
import com.ninjasquad.springmockk.SpykBean
import io.mockk.verify
import mu.KotlinLogging
import org.junit.jupiter.api.Test

import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest

private val log = KotlinLogging.logger {}

@SpringBootTest(classes = [CategoryService::class, CategoryRepository::class, CacheConfig::class])
class CategoryServiceTests {

    @Autowired
    private lateinit var categoryService: CategoryService

    @SpykBean
    private lateinit var categoryRepository: CategoryRepository

    @Test
    fun `카테고리 ID로 조회시 캐시 사용 여부 테스트`() {

        // When
        val category11 = categoryService.getById(1)
        val category21 = categoryService.getById(2)
        val category12 = categoryService.getById(1)
        val category22 = categoryService.getById(2)

        log.info { "category1=$category11" }
        log.info { "category2=$category21" }
        log.info { "category3=$category12" }
        log.info { "category4=$category22" }

        // Then
        verify(exactly = 2) { categoryRepository.findById(any()) }
    }
}

실행을 시켜서 로그를 확인하자.

... 생략 ...

2021-11-12 14:11:25.528  INFO 14035 --- [    Test worker] c.d.c.c.service.CategoryServiceTests     : Started CategoryServiceTests in 7.305 seconds (JVM running for 8.881)
2021-11-12 14:11:26.010  INFO 14035 --- [    Test worker] c.d.c.caffeine.service.CategoryService   : Call function categoryRepository.findById(1)
2021-11-12 14:11:26.143  INFO 14035 --- [    Test worker] c.d.c.c.repository.CategoryRepository    : category[1] cache is not used.
2021-11-12 14:11:26.148  INFO 14035 --- [    Test worker] c.d.c.caffeine.service.CategoryService   : Call function categoryRepository.findById(2)
2021-11-12 14:11:26.149  INFO 14035 --- [    Test worker] c.d.c.c.repository.CategoryRepository    : category[2] cache is not used.
2021-11-12 14:11:26.149  INFO 14035 --- [    Test worker] c.d.c.caffeine.service.CategoryService   : Call function categoryRepository.findById(1)
2021-11-12 14:11:26.157  INFO 14035 --- [    Test worker] c.d.c.caffeine.service.CategoryService   : Call function categoryRepository.findById(2)
2021-11-12 14:11:26.165  INFO 14035 --- [    Test worker] c.d.c.c.service.CategoryServiceTests     : category1=Category(id=1, name=Book)
2021-11-12 14:11:26.167  INFO 14035 --- [    Test worker] c.d.c.c.service.CategoryServiceTests     : category2=Category(id=2, name=Book)
2021-11-12 14:11:26.170  INFO 14035 --- [    Test worker] c.d.c.c.service.CategoryServiceTests     : category3=Category(id=1, name=Book)
2021-11-12 14:11:26.172  INFO 14035 --- [    Test worker] c.d.c.c.service.CategoryServiceTests     : category4=Category(id=2, name=Book)
BUILD SUCCESSFUL in 10s
4 actionable tasks: 2 executed, 2 up-to-date
2:11:26 오후: Task execution finished ':test --tests "com.devkuma.cache.caffeine.service.CategoryServiceTests.카테고리 ID로 조회시 캐시 사용 여부 테스트"'.

어플리케이션 실행과 동일하게 캐시가 사용되었다는 로그(category[$id] cache is not used.)가 2번 출력이 되고, 그 이후로는 출력이 되지 않는 것을 확인 할 수 있다.

테스트 코드에서도 실행여부를 체크하는 곳에서 2번 실행 될것이라고 예측하였고, 실제 2번이 실행되므로서 테스트를 성공하였다.

verify(exactly = 2) { categoryRepository.findById(any()) }

그밖에

상세 설정

설정에 대한 자세한 설명은 아래에서 확인 바란다. http://static.javadoc.io/com.github.ben-manes.caffeine/caffeine/2.2.0/com/github/benmanes/caffeine/cache/Caffeine.html

캐시 만료 전략

캐시의 만료 전략은 3가지가 있다.

  • 액세스 후 만료(expireAfterAccess): 마지막 읽기 또는 쓰기가 발생한 후 기간이 지나면 항목이 만료된다.
  • 쓰기 후 만료(expireAfterWrite): 마지막 쓰기가 발생한 후 기간이 지나면 항목이 만료된다.
  • 사용자 지정 정책(expireAfter): 만료 시간은 만료 구현에 의해 각 항목에 대해 개별적으로 계산된다.

참조