Build Spring Boot Cache Caffeine with Kotlin

Overview

This article creates a simple Spring Boot Cache with Kotlin.

Spring Boot officially introduces several caching approaches. Among them, Caffeine has a low adoption cost.

  • Caffeine is a local cache.
  • Because it is a local cache, no separate server is required.
  • TTL (timeout) can be configured.

If you frequently need to run queries that fetch the same result from the database every time, using a cache can reduce the load.

Create the Project

Create the initial Spring Boot project with the following curl command.

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 -

Check the Generated Project

Project File Structure

If it was generated correctly, the project file structure is as follows.

.
├── 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 Script

/build.gradle.kts

// ... omitted ...

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")
}

// ... omitted ...

You can see that the Spring Cache library (spring-boot-starter-cache) is included in the dependencies.

Caffeine Cache Configuration

Add the Caffeine Library

First, add the Caffeine library (com.github.ben-manes.caffeine:caffeine) to Gradle.

/build.gradle.kts

// ... omitted ...

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")
}

// ... omitted ...

The logging library (io.github.microutils:kotlin-logging) was also added to check the results.

Add the Caffeine Configuration Class

Create a new CacheConfig configuration class and write it as follows.

/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

Add @EnableCaching to the class to use caching.

Add Application Configuration

Add cache-related settings to the application configuration file (application.yml).
When the initial project is generated, there should be an application.properties file. You can simply change its extension to .yml.

/src/main/resources/application.yml

spring:
  cache:
    cache-names: category
    caffeine:
      spec: maximumSize=100, expireAfterAccess=60s
  • cache-names is the list of managed cache names. expireAfterWrite
  • maximumSize is the maximum size of the cache. When the cache size approaches the maximum value, the cache removes entries that are less likely to be used again. If the size is 0, entries are removed immediately after being loaded into the cache.
  • expireAfterWrite expires entries after a period has passed since the last write.
    • And more.

Add the Repository Class to Apply Cache To

/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")
    }
}

Add the Service Class That Calls the Cached Repository

/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)
    }
}

Run the Application

Create the Application Runner Class

/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.
    }
}

Check Application Execution

Now run it.
If you are using a tool, run 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

You can confirm that every time the findById(id) method is executed by catagoryId in the Service class, category[${id}] cache is not used. is executed and printed from the Repository class.

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.

When the findById(id) method in the Service class is executed for the second time, you can confirm that the log in the Repository class is not executed.

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)

Check with Test Code

Add Test Libraries

Add the mockk libraries (io.mockk:mockk, com.ninja-squad:springmockk) for testing.

/build.gradle.kts

// ... omitted ...

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")
}

// ... omitted ...

Write the Test Code

/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()) }
    }
}

Run it and check the logs.

... omitted ...

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로 조회시 캐시 사용 여부 테스트"'.

As with the application execution, you can confirm that the log indicating the cache was used (category[$id] cache is not used.) is printed twice and is not printed afterward.

The test code also predicted that the execution check would run twice, and the test succeeded because it actually ran twice.

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

Other

Detailed Configuration

For a detailed explanation of the configuration, see the following.
http://static.javadoc.io/com.github.ben-manes.caffeine/caffeine/2.2.0/com/github/benmanes/caffeine/cache/Caffeine.html

Cache Expiration Strategies

There are three cache expiration strategies.

  • Expire after access (expireAfterAccess): Entries expire after a period has passed since the last read or write.
  • Expire after write (expireAfterWrite): Entries expire after a period has passed since the last write.
  • Custom policy (expireAfter): The expiration time is calculated individually for each entry by the expiration implementation.

References