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=spring-cache-caffeine \
-d groupId=com.devkuma \
-d artifactId=spring-cache-caffeine \
-d packageName=com.devkuma.cache.caffeine \
-d applicationName=CaffeineApplication \
-d packaging=jar \
-d language=kotlin \
-d javaVersion=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)도 추가하였다.
Caffein 설정 클래스 추가
신규로 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는 관리되는 캐시 이름 목록이다.expireAfterWritemaximumSize는 캐시의 최대 사이즈이다. 캐시 크기가 최대값에 가까워지면 캐시는 다시 사용할 가능성이 낮은 항목을 제거한다. 크기사 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/spring-tutorial/spring-cache-caffeine/build/classes/kotlin/main started by we in /Users/we/develop/tutorial/spring-tutorial/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): 만료 시간은 만료 구현에 의해 각 항목에 대해 개별적으로 계산된다.