Overview

Kotlin 에서 엔티티를 정의할 때 @Id 값을 정의하는 방법에는 여러가지가 있습니다.

Java 에서는 기본적으로 모든 변수가 nullable 하기 때문에 딱히 의견이 갈릴 일이 없었는데요.

Kotlin 에서 많이 사용하는 대표적인 @Id 정의 스타일과 장단점을 알아보겠습니다.


1. val + nullable 정의

@MappedSuperclass
abstract class BaseEntity1 {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null
}

아마 Kotlin + JPA 를 사용하면서 가장 많이 보이는 스타일일 것 같습니다.

특징

  • 엔티티의 특성상 DB 에 저장되기 전까지는 null 값이므로 nullable 타입을 사용

이 스타일의 단점이라고 하면 id 가 nullable 이기 때문에 도메인 로직에서 사용할 때는 id!!requireNotNull(id) 등을 사용해야 할 수 있습니다.


2. id 기본값을 0L 으로 지정

@MappedSuperclass
abstract class BaseEntity2 {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0L
}

이 스타일 역시 Kotlin + JPA 에서 많이 보이는 스타일입니다.

특징

  • Notnull 타입으로 정의했기 때문에 매번 !! 을 붙이지 않아도 됨 (NPE 방지)
  • DDD 관점에서는 도메인 객체가 "불완전한 상태(null ID)"를 가지는 것이 자연스럽지 않기 때문에 이를 해결

가장 큰 특징으로는 nullable 을 지양하는 Kotlin 의 철학을 지킬 수 있다는 점입니다.

엔티티의 id 는 DB 에 저장되기 전까지 null 인데 어떻게 0L 을 사용할 수 있는걸까요?


2.1. Repository#save

@Override
@Transactional
public <S extends T> S save(S entity) {

    Assert.notNull(entity, ENTITY_MUST_NOT_BE_NULL);

    if (entityInformation.isNew(entity)) {
        entityManager.persist(entity);
        return entity;
    } else {
        return entityManager.merge(entity);
    }
}

JPA 의 Repository saveisNew 라는 함수를 사용해서 엔티티가 새로 생성된건지 기존 데이터인지 검사합니다.

새로 생성된 데이터라면 persist 를 진행해서 데이터를 저장, id 에 값을 주입하고 기존 데이터라면 merge 를 사용해서 업데이트 합니다.


2.2. isNew

@Override
public boolean isNew(T entity) {

    ID id = getId(entity);
    Class<ID> idType = getIdType();

    if (!idType.isPrimitive()) {
        return id == null;
    }

    if (id instanceof Number n) {
        return n.longValue() == 0L;
    }

    throw new IllegalArgumentException(String.format("Unsupported primitive id type %s", idType));
}

Spring Data JPA 내부의 Repository 구현을 보면 isNew 함수에서 id == null 뿐만 아니라 id == 0L 또한 "새로운 객체" 라고 판단해줍니다.

그래서 null 대신 0L 을 입력해도 JPA 가 정상적으로 동작하는 겁니다.

하지만 JPA 의 구현체로 Hibernate 가 아닌 다른 걸 사용한다면 0L 을 저장되지 않은 구현체로 보장하지 않기 때문에 문제가 발생할 수 있습니다.

그리고 id 를 0L 로 정의한다는 것 자체가 어색하거나 불편하게 느껴질 수도 있습니다.


3. var + nullable 정의

@MappedSuperclass
abstract class BaseEntity3 {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null
}

Java 와 가장 유사하게 동작하는 스타일입니다.

Jetbrain 이나 Spring 에서 제공하는 예제 코드에서 등장하는데 오픈소스 예제에서는 테스트 시 직접 id 값을 설정하거나 유연하게 다루기 위해 때문에 사용하는 것 같습니다.


4. 개인적인 사용 방식

저는 위의 스타일들 중에서 마음에 안드는 부분이 하나씩 있었습니다.

  • 저장되지 않은 id0L 을 넣는 것
  • nullable 하게 정의하면 호출할 때 id!! 를 사용해야 하는 것
  • 그렇다고 별도의 id() 메서드를 정의하는 것도 마음에 안듬

그래서 Backing Field 를 사용했습니다.


@MappedSuperclass
abstract class BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    protected val _id: Long? = null

    val id: Long = _id ?: throw IllegalStateException("엔티티의 ID 값이 존재하지 않습니다.")
}

id 정의하는데 이렇게까지 해야하나? 하는 생각이 들수도 있지만 BaseEntity 에만 정의해두면 다시 건들 일이 없긴 합니다.

  • @Id 가 사용되는 _id 변수는 nullable 이라 저장되지 않았을 때 null
  • protected 로 정의하여 상속 후 프록시객체로 필드 주입 가능
  • 외부에서 id 호출시에도 !! 를 붙이거나 함수처럼 id() 형태로 사용하지 않아도 됨

Conclusion

그래서 뭘 써야하나?

정답은 없습니다.

팀 컨벤션이나 개인적인 선호도에 따라 1번을 쓰기도 하고 2번을 쓰기도 합니다.

현재 대중화된 Spring Boot + JPA Hibernate 기준으로는 어떤 스타일을 해도 정상 동작합니다.

가장 많이 쓰는 방식은 1번 (val id: Long? = null) 으로 알고 있습니다.

Overview

Kotlin 에서 제공하는 유용한 기능 중에 JPA 에서는 권장되지 않거나 사용하면 안되는 기능들이 있습니다.


1. data class

Kotlin 에서 제공하는 data class는 JPA의 @Entity에서는 사용하지 않는 것이 좋습니다.

data class 는 불변성과 값 기반 비교를 지향하지만, JPA의 엔티티는 보통 가변 상태를 가지며 식별자(ID) 기반으로 관리되기 때문에 둘의 지향점이 다릅니다.

또한, data class 가 자동 생성하는 equals, hashCode 구현은 모든 필드를 기반으로 비교하기 때문에 id 가 아직 할당되지 않은 상태에서는 동일한 엔티티임에도 불구하고 서로 다른 객체로 인식될 수 있습니다.

그래서 Kotlin 에서 엔티티 객체를 정의할 때는 기본 class 를 사용해야 합니다.


2. init

Kotlin 에서는 init 블럭을 사용할 수 있습니다.

init 블럭은 "객체 생성 직후" 실행되지만, JPA 는 리플렉션을 통해 엔티티를 "빈 객체로 생성 -> 필드 값을 주입" 하는 순서로 동작합니다.

따라서 init 블럭에서 필드 값을 참조하면 아직 값이 설정되지 않아 NullPointerException 또는 예상치 못한 로직 오류가 발생할 수 있습니다.

엔티티 객체 생성 이후에 뭔가를 처리하고 싶다면 외부에서 처리하거나, 내부에서 처리해야 한다면 @PostLoad 등을 활용해야 합니다.


Conclusion

Kotlin 은 여러 편의 기능을 제공하지만, JPA 와 함께 사용할 때는 특정 Kotlin 기능이 JPA 의 동작 방식과 충돌할 수 있습니다.

그 중 대표적인 예로 data classinit 블럭 사용을 지양해야 하는 이유를 알아보았습니다.

Overview

Kotlin 으로 Spring Boot 를 만들다보면 JPA 를 함께 사용하는 일이 많습니다.

JPA 는 대표적인 Spring Boot 의 ORM 이지만 Kotlin 과 함께 사용하려면 몇가지 불편한 점이 존재합니다.

이런 불편한 점들을 해결하기 위해 Kotlin 측에서는 JPA 에서 사용하기 적합한 몇가지 플러그인을 제공합니다.


1. Kotlin JPA plugin

Kotlin 에서 제공하는 plugin.jpa 에는 다음 두 가지 플러그인이 포함되어 있습니다.

  • plugin.allopen
  • plugin.noarg

1.1. all-open plugin

Kotlin 에서는 기본적으로 모든 클래스와 메서드가 final 입니다.

이 말은 클래스나 메서드를 상속/오버라이드 할 수 없다는 뜻입니다.

Java 에서는 기본적으로 open 이라 상속에 열려있는 반면에 Kotlin 은 의도된 상속 구조를 사용하기 위해 기본적으로 final 이고 상속 가능한 클래스만 명시적으로 open 을 선언합니다.

JPA 에서는 지연 로딩 (lazy loading), 더티 체킹 (dirty checking) 등에 사용되는 프록시 객체를 만들기 위해서는 클래스가 상속 가능해야 합니다.

그래서 Kotlin 에서는 JPA 클래스를 open 으로 변경시켜주는 "all-open" 플러그인을 제공합니다.


1.2. no-arg plugin

JPA 에서는 엔티티 객체를 리플렉션 (Reflection) 으로 생성햐는데 구현상 반드시 파라미터 없는 기본 생성자가 필요합니다.

Kotlin 에서는 정의된 생성자를 통해 모든 필드를 초기화하도록 요구하므로, 별도로 기본생성자를 만들어주지 않으면 JPA 에서 인스턴스를 생성할 수 없습니다.

사실 기본생성자가 없는 문제는 Kotlin 만의 문제는 아니고 Java 에서도 @Entity 객체는 항상 기본생성자를 직접 만들어주거나 Lombok 의 @NoArgConstructor 를 붙여줘야 합니다.

Kotlin 에서는 JPA 클래스에 기본생성자를 붙여주는 "no-arg" 플러그인을 제공합니다.


2. 적용 방법

plugins {
    val kotlinVersion = "2.1.20"
    kotlin("plugin.jpa") version kotlinVersion
}

Kotlin 2.1.20 버전 사용 기준으로 위와 같이 추가해주시면 됩니다.

allopen, noarg 플러그인을 별도 사용시 원래는 allOpen, noArg 블럭을 추가하여 적용 대상을 지정해야 했는데 plugin.jpa 에는 해당 설정 또한 내장되어 있기 때문에 따로 추가할 필요가 없습니다.


Conclusion

Spring Initializr 에서는 Kotlin + JPA 선택 시 plugin.jpa 를 자동으로 추가해줍니다.

그래서 Spring Initializr 나 Gradle 설정을 통해 무심코 적용되는 경우가 많지만 실제로 Kotlin 에서 JPA 를 안정적으로 사용하기 위해 중요한 역할을 하는 플러그인들을 정리해봤습니다.

Overview

베이스 코드로 Spring Boot Cache 적용에 있던 코드들을 재활용할 예정이라 앞의 글을 먼저 읽어보는걸 추천합니다.

단순하게 Redis Cache 설정만 알고 싶다면 상관 없습니다.

코드를 직접 실행해보려면 로컬에 Redis 를 설치해야 합니다.

만약 별도의 Redis 서버를 운영 중이라면 해당 서버를 사용해도 됩니다.


1. Dependency 추가

implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-cache'

spring-boot-starter-cache 에 이어 spring-boot-starter-data-redis 설정을 추가합니다.

Spring Data Redis 설정을 추가하면 자동으로 기본 캐시가 ConcurrentMapCache 에서 RedisCache 로 설정됩니다.


2. Redis Configuration

spring:
  data:
    redis:
      host: 127.0.0.1
      port: 6379

@Configuration
public class RedisConfig {

    @Value("${spring.data.redis.host}")
    private String host;

    @Value("${spring.data.redis.port}")
    private int port;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(
                new RedisStandaloneConfiguration(host, port)
        );
    }
}

Redis 연결을 위한 기본 설정을 추가합니다.


3. Redis Cache Configuration

@EnableCaching
@Configuration
public class CacheConfig {

    /**
     * Spring Boot 가 기본적으로 RedisCacheManager 를 자동 설정해줘서 RedisCacheConfiguration 없어도 사용 가능
     * Bean 을 새로 선언하면 직접 설정한 RedisCacheConfiguration 이 적용됨
     */
    @Bean
    public RedisCacheConfiguration redisCacheConfiguration() {
        return RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofSeconds(60))
                .disableCachingNullValues()
                .serializeKeysWith(
                        RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())
                )
                .serializeValuesWith(
                        RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())
                );
    }

    /**
     * 여러 Redis Cache 에 관한 설정을 하고 싶다면 RedisCacheManagerBuilderCustomizer 를 사용할 수 있음
     */
    @Bean
    public RedisCacheManagerBuilderCustomizer redisCacheManagerBuilderCustomizer() {
        return (builder) -> builder
                .withCacheConfiguration("cache1",
                        RedisCacheConfiguration.defaultCacheConfig()
                                .computePrefixWith(cacheName -> "prefix::" + cacheName + "::")
                                .entryTtl(Duration.ofSeconds(120))
                                .disableCachingNullValues()
                                .serializeKeysWith(
                                        RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())
                                )
                                .serializeValuesWith(
                                        RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())
                                ))
                .withCacheConfiguration("cache2",
                        RedisCacheConfiguration.defaultCacheConfig()
                                .entryTtl(Duration.ofHours(2))
                                .disableCachingNullValues());
    }
}

Redis Cache 설정을 추가합니다.

CacheManager 를 사용했던 ConcurrentMapCache 와는 다르게 Redis 는 간단하게 Redis Cache 설정을 적용할 수 있습니다.

우선 Spring Data Redis 를 사용한다면 Spring Boot 가 RedisCacheManager 를 자동으로 설정해줍니다.

하지만 Redis 는 직렬화/역직렬화 때문에 별도의 캐시 설정이 필요하고 이 때 사용하는게 RedisCacheConfiguration 입니다.

RedisCacheConfiguration 설정은 Redis 기본 설정을 오버라이드 한다고 생각하면 됩니다.

  • computePrefixWith: Cache Key prefix 설정
  • entryTtl: 캐시 만료 시간
  • disableCachingNullValues: 캐싱할 때 null 값을 허용하지 않음 (#result == null 과 함께 사용해야 함)
  • serializeKeysWith: Key 를 직렬화할 때 사용하는 규칙. 보통은 String 형태로 저장
  • serializeValuesWith: Value 를 직렬화할 때 사용하는 규칙. Jackson2 를 많이 사용함

만약 캐시이름 별로 여러 세팅을 하고 싶다면 RedisCacheManagerBuilderCustomizer 를 선언해서 사용할 수 있습니다.

위 코드에서는 cache1, cache2 두 가지 캐시를 설정했으며 만약 다른 이름의 캐시를 사용하려 한다면 기본 설정인 RedisCacheConfiguration 를 따라갑니다.

GenericJackson2JsonRedisSerializer 를 사용할 때 주의할 점은 여러개의 데이터를 한번에 저장할 때 List 를 사용하지 말고 별도의 일급 컬렉션을 선언해서 사용해야 합니다.

자세한 이슈는 Spring Boot 에서 Redis Cache 사용 시 List 역직렬화 에러 (GenericJackson2JsonRedisSerializer) 글에 정리해두었습니다.


4. Redis 데이터 확인

API 호출 후 Redis CLI 에서 직접 저장된 데이터를 확인해봅니다.


4.1. cache1 데이터 확인

cache1 은 설정한 대로 prefix 가 붙은 key 값이 사용됩니다.


4.2. members 데이터 확인

별도의 이름을 설정하지 않은 members 캐시는 그냥 일반 키값으로 저장됩니다.


Conclusion

Redis Cache 는 일반적으로 가장 많이 사용되는 글로벌 캐시입니다.

직접 RedisTemplate 을 호출해서 구현할 수도 있지만 Spring Boot 에서 제공하는 설정을 알아두면 나중에 유용하게 사용할 일이 있을 겁니다.


Reference

Overview

Spring Boot 에서 Cache 를 적용하는 방법에 대해 알아봅니다.

원래 Cache 를 사용할 때 Redis 같은 별도의 글로벌 저장소를 활용하는게 일반적이지만 이번에는 간단하게 기본 캐시인 ConcurrentMapCache 를 사용합니다.


1. Dependency 추가

implementation 'org.springframework.boot:spring-boot-starter-cache'

사실 spring-boot-starter-cache 를 추가하지 않아도 캐시 기능을 사용할 수 있습니다.

spring-boot-starter-web 같은 스타터 모듈에 자동으로 포함되어 있는 spring-context 라는 모듈 덕분인데요.

Spring Boot 3.0.5 가이드를 보면 spring-boot-starter-cache 모듈을 추가해야 spring-context-support 모듈을 가져와서 캐시 관련된 여러 기능을 제공하기 때문에 캐시 관련 의존성을 추가해준다고 합니다.


2. Configuration

@EnableCaching
@Configuration
public class CachingConfig {

    @Bean
    public CacheManager cacheManager() {
        ConcurrentMapCacheManager cacheManager = new ConcurrentMapCacheManager();
        cacheManager.setAllowNullValues(false);
        cacheManager.setCacheNames(List.of("members"));
        return cacheManager;
    }
}

다음은 Cache 관련 설정을 추가합니다.

@EnableCachingSpringBootApplication 에 추가해도 되지만 어차피 캐시 설정을 위해 Config 클래스를 추가할 거라면 여기에 추가해도 됩니다.


2.1. Cache Customizer

@Component
public class SimpleCacheCustomizer implements CacheManagerCustomizer<ConcurrentMapCacheManager> {

    @Override
    public void customize(ConcurrentMapCacheManager cacheManager) {
        cacheManager.setAllowNullValues(false);
    }
}

Cache Customizer 를 따로 추가하는 방법도 있습니다.


3. Cache 어노테이션

설정을 마쳤으니 Cache 관련 어노테이션을 사용하면 손쉽게 캐시 기능을 사용할 수 있습니다.

캐시 어노테이션들은 기본적으로 AOP 로 동작하기 때문에 내부 호출 같은 이슈를 주의해야 합니다.

  • @Cacheable
    • 데이터를 캐시에 저장
    • 메서드를 호출할 때 캐시의 이름 (value) 과 키 (key) 를 확인하여 이미 저장된 데이터가 있으면 해당 데이터를 리턴
    • 만약 데이터가 없다면 메서드를 수행 후 결과값을 저장
  • @CachePut
    • @Cacheable 과 비슷하게 데이터를 캐시에 저장
    • 차이점은 @Cacheable 은 캐시에 데이터가 이미 존재하면 메서드를 수행하지 않지만 @CachePut 은 항상 메서드를 수행
    • 그래서 주로 캐시 데이터를 갱신할 때 많이 사용
  • @CacheEvict
    • 캐시에 있는 데이터를 삭제
  • @CacheConfig
    • 메서드가 아닌 클래스에 붙여서 공통된 캐시 기능을 모을 수 있음
    • 예를 들면 cacheNames, cacheManager 등등
  • @Caching
    • Cacheable, CachePut, CacheEvict 를 여러 개 사용할 때 묶어주는 기능

일반적으로 @Cacheable 을 사용해서 캐싱하고 데이터를 갱신할 때 @CachePut, @CacheEvict 중 하나를 선택해서 갱신합니다.

@CachePut 을 사용하면 @Cacheable 데이터 조회 시 캐시에 새로운 데이터가 존재하기 때문에 DB 조회를 하지 않아도 된다는 장점이 있습니다.


4. Domain 정의

이제 캐시를 적용하기 전에 간단하게 필요한 클래스들을 정의해봅니다.


4.1. Member

@Getter
@Setter
@ToString
@NoArgsConstructor
public class Member {

    private Long id;
    private String name;
    private Integer age;

    public Member(String name, Integer age) {
        this.name = name;
        this.age = age;
    }
}

캐싱 대상인 Member 클래스입니다.

Lombok 을 사용했고 복잡한 데이터 없이 간단하게 만들었습니다.


4.2. Members (List)

@Getter
@ToString
@NoArgsConstructor
public class Members {
    private List<Member> members = new ArrayList<>();

    public Members(List<Member> members) {
        this.members = members;
    }
}

마찬가지로 캐시 대상인 Members 입니다.

List<Member> 를 담은 일급 컬렉션이고 특별한 건 없습니다.


5. MemberRepository

@Slf4j
@Repository
@CacheConfig(cacheNames = "members")
public class MemberRepository {

    private final Map<Long, Member> store = new LinkedHashMap<>();

    @Cacheable(key = "'all'")
    public Members findAll() {
        List<Member> members = store.values().stream().toList();
        log.info("Repository findAll {}", members);
        return new Members(members);
    }

    @Cacheable(key = "#memberId", unless = "#result == null")
    public Member findById(Long memberId) {
        Member member = store.get(memberId);
        log.info("Repository find {}", member);
        return member;
    }

    @CachePut(key = "#member.id")
    @CacheEvict(key = "'all'")
    public Member save(Member member) {
        Long newId = calculateId();
        member.setId(newId);

        log.info("Repository save {}", member);

        store.put(member.getId(), member);
        return member;
    }

    private Long calculateId() {
        if (store.isEmpty()) {
            return 1L;
        }

        int lastIndex = store.size() - 1;
        return (Long) store.keySet().toArray()[lastIndex] + 1;
    }

    @CachePut(key = "#member.id")
    @CacheEvict(key = "'all'")
    public Member update(Member member) {
        log.info("Repository update {}", member);
        store.put(member.getId(), member);
        return member;
    }

    @Caching(evict = {
            @CacheEvict(key = "'all'"),
            @CacheEvict(key = "#member.id")
    })
    public void delete(Member member) {
        log.info("Repository delete {}", member);
        store.remove(member.getId());
    }
}

캐시 어노테이션을 적용한 MemberRepository 코드입니다.

데이터는 실제 DB 대신 간단하게 LinkedHashMap 을 사용했습니다.

우선 전체 코드를 보고 하나씩 살펴봅니다.


5.1. @CacheConfig

@CacheConfig 를 클래스에 붙여서 members 라는 공통 캐시 이름을 설정합니다.


5.2. 복수 조회 (findAll)

@Cacheable(key = "'all'")
public Members findAll() {
    List<Member> members = store.values().stream().toList();
    log.info("Repository findAll {}", members);
    return new Members(members);
}

전체 데이터를 조회하는 메서드입니다.

key 를 all 로 설정했기 때문에 members::all 이라는 key 값에 Members 데이터가 저장됩니다.

이후에 한번 더 조회를 하면 members::all 을 확인하고 데이터가 있다면 그 값을 그대로 리턴합니다.


5.3. 단건 조회 (findById)

@Cacheable(key = "#memberId", unless = "#result == null")
public Member findById(Long memberId) {
    Member member = store.get(memberId);
    log.info("Repository find {}", member);
    return member;
}

Member 데이터를 저장하는 단건 조회 메서드입니다.

memberId 를 키값으로 설정하며 unless = "#result == null" 조건을 추가하여 DB 에 없는 데이터인 경우 캐싱하지 않도록 했습니다.

만약 이 조건을 추가하지 않으면 null 값도 캐싱 대상이 됩니다.

우리는 캐시 설정에서 cacheManager.setAllowNullValues(false); 를 추가했기 때문에 null 값을 캐싱하려고 하면 에러가 발생하니 꼭 위 조건을 함께 추가해줘야 합니다.


5.4. 생성 및 변경 (save & update)

@CachePut(key = "#member.id")
@CacheEvict(key = "'all'")
public Member save(Member member) {
    Long newId = calculateId();
    member.setId(newId);

    log.info("Repository save {}", member);

    store.put(member.getId(), member);
    return member;
}


@CachePut(key = "#member.id")
@CacheEvict(key = "'all'")
public Member update(Member member) {
    log.info("Repository update {}", member);
    store.put(member.getId(), member);
    return member;
}

새로운 데이터를 저장합니다.

여기에는 두가지 어노테이션이 붙어 있는데 @CachePut 은 새로운 데이터를 저장하면 해당 데이터를 바로 캐싱하기 위해 추가했습니다.

여기서 캐싱하지 않아도 조회할 때 캐싱되기 때문에 반드시 필요한 설정은 아닙니다.

@CacheEvict 는 전체 조회 데이터를 삭제합니다.

2개의 데이터가 캐싱되어 있고 새로운 데이터가 추가되었는데 캐시를 갱신하거나 비워주지 않으면 만료될 때까지 이전 데이터를 보고 있기 때문에 한번 삭제해줘야 합니다.

단건 조회라면 @CachePut 을 사용해서 갱신할 수 있지만 복수 조회라면 갱신하는 일이 더 귀찮기 때문에 그냥 캐시를 비워주고 findAll 을 호출할 때 새로 캐싱합니다.

@CachePut 을 사용할 때 한가지 주의할 점이라면 반환값을 캐싱하기 때문에 void update 처럼 리턴값을 제대로 지정하지 않는 경우 제대로 동작하지 않을 수 있습니다.


5.5. 삭제 (delete)

@Caching(evict = {
        @CacheEvict(key = "'all'"),
        @CacheEvict(key = "#member.id")
})
public void delete(Member member) {
    log.info("Repository delete {}", member);
    store.remove(member.getId());
}

Member 데이터를 삭제합니다.

삭제는 Member 데이터와 Members 데이터의 캐싱을 모두 없애줘야 하기 때문에 @CacheEvict 을 두개 사용했습니다.

메서드에는 중복된 어노테이션을 두개 붙일 수 없기 때문에 @Caching 을 사용해서 묶어주면 동일한 어노테이션 두개를 전부 적용할 수 있습니다.


6. MemberController

@Slf4j
@RestController
@RequiredArgsConstructor
public class MemberController {

    private final MemberRepository memberRepository;

    @GetMapping("/members")
    public Members findAll() {
        Members members = memberRepository.findAll();
        log.info("Controller findAll {}", members);
        return members;
    }

    @GetMapping("/members/{memberId}")
    public Member findById(@PathVariable Long memberId) {
        Member member = memberRepository.findById(memberId);
        log.info("Controller find {}", member);
        return member;
    }

    @PostMapping("/members")
    public Member save(@RequestBody MemberDto memberDto) {
        Member member = new Member(memberDto.getName(), memberDto.getAge());
        Member savedMember = memberRepository.save(member);
        log.info("Controller save {}", savedMember);
        return savedMember;
    }

    @PutMapping("/members/{memberId}")
    public Member update(@PathVariable Long memberId, @RequestBody MemberDto memberDto) {
        Member member = new Member(memberDto.getName(), memberDto.getAge());
        member.setId(memberId);
        return memberRepository.update(member);
    }

    @DeleteMapping("/members/{memberId}")
    public void delete(@PathVariable Long memberId) {
        Member member = memberRepository.findById(memberId);
        log.info("Controller delete {}", member);
        memberRepository.delete(member);
    }
}

간단한 CRUD REST API 를 만들었습니다.


7. API Test

로컬에서 실제 테스트를 진행해봅니다.


7.1. 빈 List 조회

우선 아무것도 추가하지 않은 상태로 데이터를 조회해봅니다.

처음에는 Repository 로그까지 남지만 똑같은 요청을 반복하면 캐싱된 데이터를 가져오므로 Controller 까지만 로그가 남습니다.


7.2. 새로운 데이터 추가

새로운 데이터를 추가합니다.

데이터 추가나 변경은 @CachePut 을 사용하기 때문에 매번 Repository 로그를 남깁니다.


7.3. Members 새로운 데이터 조회

다시 findAll 을 호출합니다.

새로운 데이터를 추가할 때마다 members::all 은 evict 되어 다시 Repository 조회까지 수행합니다.

하지만 한번 조회한 이후에는 여전히 Controller 로그까지만 남깁니다.


7.4. Member 단건 조회

단건 조회를 해도 @CachePut 으로 members::2 가 이미 캐싱되어 있기 때문에 Repository 로그는 남지 않습니다.


Conclusion

Cache 는 서버 개발을 하는데 굉장히 중요한 기능입니다.

대부분의 성능 개선을 캐시 추가로 할 수 있으며 설정도 다양하고 캐시 전략도 다양합니다.

Spring Boot 에서는 Cache 를 사용하기 쉽게 AOP 로 제공하고 있으니 사용법을 알아두면 필요할 때 유용하게 사용할 수 있습니다.


Reference

상황

Redis Cache 를 사용해서 List<?> 를 저장하려고 했습니다.

직렬화해서 데이터 저장까지는 잘 되었는데 다시 역직렬화 하려고 하니 에러가 발생하며 실패했습니다.


@Cacheable(cacheNames = "members", key = "'all'")
public List<Member> findAll() {
    List<Member> members = store.values().stream().toList();
    return members;
}

캐싱한 데이터는 위와 같습니다.

List<Member> 를 응답으로 내려주고 members 라는 캐시의 all 이라는 키값으로 저장됩니다.

Redis 설정으로 Value 는 GenericJackson2JsonRedisSerializer 를 사용하여 직렬화했습니다.


Redis 를 확인해보면 제대로 저장된 것을 확인할 수 있습니다.


에러 로그

com.fasterxml.jackson.databind.exc.MismatchedInputException: Unexpected token (START_OBJECT), expected VALUE_STRING: need JSON String, Number of Boolean that contains type id (for subtype of java.lang.Object)
 at [Source: (byte[])"[{"@class":"com.example.springbootcache.model.Member","id":1,"name":"ChulSoo","age":50}]"; line: 1, column: 2]

원인

List<?> 를 그대로 저장해서 그렇습니다.

사실 정확한 원인은 저도 모릅니다.

그래도 확신은 없지만 나름대로 추측을 해보겠습니다.

GenericJackson2JsonRedisSerializer 는 직렬화 할 때 @class 라는 Key 값에 클래스의 패키지 정보까지 전부 저장됩니다.

그런데 List 를 통째로 저장하면 위 사진과 같이 { "@class": "..." } 이 아니라 [{ "@class": "..."}] 로 저장되어 찾지 못해서 발생하는 이슈 같습니다.


해결

List 를 감싸는 Wrapper 클래스를 만들어 주면 해결됩니다.


Members 클래스 정의

@Getter
@NoArgsConstructor
public class Members {
    private List<Member> members = new ArrayList<>();

    public Members(List<Member> members) {
        this.members = members;
    }
}

캐싱 대상 변경

@Cacheable(cacheNames = "members", key = "'all'")
public Members findAll() {
    List<Member> members = store.values().stream().toList();
    return new Members(members);
}

Redis 저장 확인

Overview

Docker 공식 홈페이지에 있는 Ubuntu 설치를 보고 따라하면 됩니다


1. Docker 설치

$ sudo apt-get update
$ sudo apt-get install \
    ca-certificates \
    curl \
    gnupg

2. Docker 공식 GPG 키 추가

$ sudo mkdir -m 0755 -p /etc/apt/keyrings
$ curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg

3. Docker Repository 설치

$ echo \
  "deb [arch="$(dpkg --print-architecture)" signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
  "$(. /etc/os-release && echo "$VERSION_CODENAME")" stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

4. Docker 설치

$ sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

5. Docker 실행 테스트

$ sudo docker run hello-world

# 실행된 도커 컨테이너 확인
$ sudo docker ps

# 이미지 확인
$ sudo docker images

테스트 용으로 hello-world 라는 이미지를 실행합니다.

docker image 를 따로 받지 않아도 없으면 자동으로 pull 을 먼저 땡깁니다.

Problem


숫자만 존재하는 오름차순 배열이 주어집니다.

모든 값들은 2개씩 존재하고 단 하나의 값만 1개 존재합니다.

1개만 존재하는 값을 찾는 문제입니다.



Solution

그냥 Map 을 사용해도 되는 문제지만 추가 조건으로 O(log n) time and O(1) space 의 복잡도를 요구합니다.

이 조건을 만족하기 위해선 이분탐색이 필요합니다.

하지만 이 문제는 일반적인 이분탐색과 다르게 찾아야 하는 값이 따로 주어지지 않습니다.

그럼 범위를 절반으로 나누었을 때 왼쪽과 오른쪽 중 찾으려는 값이 있는 곳을 알 수 있을까요?

힌트는 "모든 숫자는 반드시 두개씩 존재한다" 입니다.

반드시 두개씩 연달아 존재하기 때문에 인덱스 위치를 파악해서 숫자를 비교하면 단 하나만 있는 값의 위치를 알 수 있습니다.

대신 현재 index 가 홀수인지 짝수인지에 따라 비교해야 하는 대상이 바뀌기 때문에 그 부분만 체크해주면 됩니다.

image



Java Code

class Solution {
    public int singleNonDuplicate(int[] nums) {
        int left = 0;
        int right = nums.length - 1;
        int mid = 0;

        while (left < right) {
            mid = (left + right) / 2;

            if (mid % 2 == 0 && nums[mid] == nums[mid + 1]) {
                // mid 위치가 짝수면 오른쪽 값이랑 비교하고 같으면 single 은 오른쪽에 있음
                left = mid + 2;
            } else if (mid % 2 == 1 && nums[mid] == nums[mid - 1]) {
                // mid 위치가 홀수면 왼쪽 값이랑 확인하고 같으면 single 은 오른쪽에 있음
                left = mid + 1;
            } else {
                // 위에 전부 해당 안되면 single 은 왼쪽에 있음
                right = mid;
            }

        }

        return nums[left];
    }
}

+ Recent posts