앞 시간에서 Cache란 무엇이고, Springboot에서 어떠한 Cache가 있는지 알아봤습니다.  이번에는 Local-Memory-Cache를 사용하는 방법에 대해서 알아보겠습니다.

Local-Memory-Cache

1. Local Memory에 Cache 데이터를 저장하고, 조회한다.

2. 서버가 꺼지면 Cache 데이터들은 삭제된다.

3. 기본 전략으로 @EnableCache만 붙여주거나, 간단하게 Custom CacheManager를 등록해서 사용 가능하다.


1. 프로젝트 세팅

Cache의 사용법을 알아보는 포스팅입니다. 밑에 진행하는 예제가 Cache로 사용하기 적합하지 않은 데이터일 수 있습니다. 

[디펜더 시 추가]

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-cache'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'com.h2database:h2'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

[application.yml 수정]

spring:
  datasource:
    url:
    username: sa
    password:
    driver-class-name: org.h2.Driver

  jpa:
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
        format_sql: true
logging:
  level:
    org.hibernate.SQL: debug

[Member Entity]

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Member {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "member_id")
    private Long id;
    
    @Column(name = "name")
    private String name;
    
    @Column(name = "age")
    private int age;
    
    @Column(name = "city")
    private String city;
    
    // 정적 메서드를 사용해서 생성
    public static Member createMember(String name, int age, String city){
        Member member = new Member();
        member.name=name;
        member.age=age;
        member.city=city;
        return member;
    }
    
    public void changeAge(int age){
        this.age=age;
    }
    
}

1. 이름, 나이, 사는 도시를 칼럼으로 가지고 있습니다.

2. 나이를 변경할 수 있도록 changeAge를 만들어줍니다.

[MemberRepository]

public interface MemberRepository extends JpaRepository<Member, Long> {
}

[MemberService]

@Service
@Transactional
@RequiredArgsConstructor
@Slf4j
public class MemberService {

    private final MemberRepository memberRepository;

    public Long createMember(String name, int age, String city){
        log.info("createMember implement!!");
        Member member = Member.createMember(name, age, city);
        return memberRepository.save(member).getId();
    }

    public List<Dto> findAll(){
        log.info("findAll implement!!");
        return memberRepository.findAll().stream().map(a -> new Dto(a.getName(),a.getAge(),a.getCity()))
                .collect(Collectors.toList());
    }

    public Dto findOne(long id){
        log.info("findOne implement!!");
        return memberRepository.findById(id).map(a -> new Dto(a.getName(),a.getAge(),a.getCity())).get();
    }

    public void deleteMember(long id){
        memberRepository.deleteById(id);
    }

    public Dto changeAge(long id, int age){
        log.info("changeAge implement!!");
        Member member = memberRepository.findById(id).get();
        member.changeAge(age);
    	return new Dto(member.getName(),member.getAge(),member.getCity());
    }
}

1. 모든 메서드에 대해서 실행될 때 log를 찍어주겠습니다. 

2. 멤버를 생성해서 저장하는 메서드

3. 모든 멤버를 찾는 메서드

4. id를 통해서 한 멤버를 찾는 메서드

5. id를 통해서 멤버를 찾고 나이를 변경하는 메서드

6. id를 통해서 멤버를 삭제하는 메서드

[MemberController]

(Cache에 대한 내용이므로, Controller에 대한 개념 설명은 생략하겠습니다.)

@RestController
@RequiredArgsConstructor
public class MemberController {

    private final MemberService memberService;

    @GetMapping("/members")
    public List<Dto> findAll(){
        return memberService.findAll();
    }

    @PostMapping("/members")
    public Long createMember(@RequestBody Dto dto){
        return memberService.createMember(dto.getName(),dto.getAge(),dto.getCity());
    }

    @GetMapping("/members/{id}")
    public Dto findOne(@PathVariable long id){
        return memberService.findOne(id);
    }

    @DeleteMapping("/members/{id}")
    public String deleteMember(@PathVariable long id){
        memberService.deleteMember(id);
        return "ok";
    }

    @PatchMapping("/members/{id}")
    public Dto changeAge(@PathVariable long id, @RequestParam int age){
        return memberService.changeAge(id,age);
    }
}

1. Service에서 사용되는 기능들을 호출합니다. 

2. Cache 설정하기

[첫 번째 방법]

Application에 @EnableCaching을 붙여줍니다. 이것은 Springboot의 Default 값으로 Cache를 만들어줍니다. 

@SpringBootApplication
@EnableCaching // 추가
public class CacheApplication {

    public static void main(String[] args) {
        SpringApplication.run(CacheApplication.class, args);
    }

}

[두 번째 방법]

Configuration에 CacheManager를 등록해서 사용합니다. 여기서 CacheManager는 SimpleCacheManager를 구현체로 사용하며, 컬렉션에 대해 작동합니다. 

ConcurrentMapCache는 SimpleCacheManager 혹은 ConcurrentMapCacheManager 통해서 동작되는 컬렉션 형태의 Cache입니다. 

@Configuration
@EnableCaching
public class CacheConfig {
    
    @Bean
    public CacheManager cacheManager(){
        SimpleCacheManager simpleCacheManager = new SimpleCacheManager();
        simpleCacheManager.setCaches(List.of(new ConcurrentMapCache("memberList"),
                new ConcurrentMapCache("memberOne")));
        return simpleCacheManager;
    }
}

 

Test를 위해서 Member에 대한 더미 데이터를 넣습니다.

@PostConstruct
public void createMember(){
    for(int i=0; i<10; i++){
        Member member = Member.createMember("m" + i, i + 1, "s" + i);
        memberRepository.save(member);
    }
}

 

3. @Cacheable, @CacheEvict, @CachePut 속성 살펴보기

@Cacheable: Cache를 사용할 수 있게 해 주며, 메서드에 붙이면 됩니다. 

@CacheEvict: 설정에 따라서 Cache의 값을 삭제합니다. 

@CachePut: Cache를 갱신해주는 역할을 합니다. 

[value, cacheNames]

두 개는 alias로서 Cache의 이름을 칭하며, Cache를 구분하는 역할을 합니다. 

@AliasFor("cacheNames")
String[] value() default {};

@AliasFor("value")
String[] cacheNames() default {};

[key]

Cache 내에서 데이터를 구분하는 역할을 합니다. (keyGenerartor과 같이 사용 불가능) SpEL을 통해서도 설정할 수 있습니다. 설정법은 아래에서 알아보겠습니다. 

String key() default "";

위의 그림은 memberCache안에  key와 데이터가 담겨 있습니다. key값으로 데이터를 구분합니다. key값은 단일 값으로도 사용할 수 있고, 섞어 복합적으로 만들 수 있습니다.

[keyGenerator]

특정 로직을 통해서 key를 만들고자 할 때 사용됩니다. KeyGenerator를 상속받고 구현하면 됩니다.

String keyGenerator() default "";
public class SimpleKeyGenerator implements KeyGenerator {
    @Override
    public Object generate(Object target, Method method, Object... params) {
        return makeKey(params);
    }

    public static Object makeKey(Object... params){
        if(params.length==0){
            return SimpleKey.EMPTY;
        }
        else{
            return params[0];
        }
    }
}

1. 넘어오는 파라미터가 없을 경우 SimpleKey.EMPTY를 key로 설정

2. 넘오는 파라미터가 하나 이상일 경우 첫 번째로 넘어오는 것을 key로 설정

[cacheManager]

Custom 한 CacheManager를 사용하도록 설정하는 속성입니다. (cacheResolver와 같이 사용할 수 없습니다.)

String cacheManager() default "";

[cacheResolver]

Cache 키에 대한 값을 반환해주는 Resolver역할을 합니다. (cacheManager와는 같이 사용할 수 없습니다.)

String cacheResolver() default "";

[condition]

SpEL을 통해서 특정 조건에 부합한 경우만 Cache를 적용합니다. 비교, 조건 연산자를 사용할 수 있습니다. 

String condition() default "";

[unless]

SpEL을 통해서 특정 조건에 부합하지 않은 경우만 Cache를 적용합니다. 비교, 조건 연산자를 사용할 수 있습니다. 

String unless() default "";

[sync]

Cache의 구현체가 Thread Safe 하지 않은 경우에 Cache에 동기화를 설정해줍니다. default는 false입니다.

boolean sync() default false;

4. Cache 적용하기

[Cache 적용x]

먼저 cache를 적용하지 않았을 때를 보겠습니다. (postman으로 테스트를 진행했습니다.)

localhost:8080/member 호출 쿼리가 실행됩니다.

한 번 더 호출 

마찬가지로 한 번 더 쿼리가 실행됩니다. 

[Cache 적용]

위에서 CacheManager를 등록할 때 memberList Cache를 만들었습니다. MemberService의 전체 리스트를 반환하는 데 사용하겠습니다. 

@Cacheable(value = "memberList")
public List<Dto> findAll(){
    log.info("findAll implement!!");
    return memberRepository.findAll().stream().map(a -> new Dto(a.getName(),a.getAge(),a.getCity()))
            .collect(Collectors.toList());
}

위에와 똑같이 호출해보겠습니다. 첫 번째 호출 시 쿼리가 수행됩니다.

두 번째 호출 시 쿼리도 실행되지 않고 메서드 안에 log도 찍히지 않는 것을 확인하실 수 있을 겁니다.

Cache를 이용 시 Cache에 있는 데이터를 반환하므로 메서드에 접근하지 않는 것을 알 수 있습니다. 

마찬가지로 Member의 id를 이용해서 Member를 찾는 메서드에도 cache를 적용하겠습니다. 

이전에 생성한 cache인 membeOne에 담고, key값은 id로 사용했습니다. 이때 id는 메서드로 넘어오는 인자입니다.

@Cacheable(value = "memberOne",key = "#id")
public Dto findOne(long id){
    log.info("findOne implement!!");
    return memberRepository.findById(id).map(a -> new Dto(a.getName(),a.getAge(),a.getCity())).get();
}

만약 객체가 넘어온다면 아래와 같이 객체. 필드명으로 설정해주시면 됩니다. 

@Cacheable(value = "memberOne",key = "#memberDto.id")
public Dto findOne(MemberDto memberDto){
    log.info("findOne implement!!");
    return memberRepository.findById(memberDto.getid()).map(a -> new Dto(a.getName(),a.getAge(),a.getCity())).get();
}

저는 위의 #id로 진행하겠습니다. 

먼저 localhost:8080/member/1로 요청을 보내겠습니다. 쿼리가 수행되고, 결과를 받으실 수 있을 겁니다. 

다시 한번 요청을 보냈을 때 cache가 적용되기 때문에  쿼리도 수행되지 않고, 메서드를 실행시키지 않아 log도 나오지 않을 겁니다. 

그렇다면 DB의 데이터를 변경했을 때 cache에 담겨 있는 데이터가 동기화되는지 확인해보겠습니다. 

id가 1인 멤버를 찾는 요청을 보냈을 때 아래와 같이 결과를 받습니다.

이제 id가 1인 멤버의 나이를 100살로 변경해보겠습니다. (성공적으로 변경했습니다.)

다시 한번 id가 1인 멤버를 찾는 요청을 보내겠습니다.

 

나이를 100으로 변경했으므로 cache에 있는 데이터 또한 변경되기를 바랐지만, 변경되지 않은 것을 볼 수 있습니다. 따라서 @CachePut을 사용해야 합니다.

@CachePut

Cache를 갱신해주는 역할을 합니다. 

나이를 변경하는 메서드에 @CachePut을 붙여줍니다.

@CachePut(value = "memberOne", key = "#id")
public Dto changeAge(long id, int age){
    log.info("changeAge implement!!");
    Member member = memberRepository.findById(id).get();
    member.changeAge(age);
    return new Dto(member.getName(),member.getAge(),member.getCity());
}

다시 한번 위의 과정을 수행하겠습니다.

첫 호출 시 쿼리가 수행되며, 결과를 반환받습니다.

이제 id가 1인 멤버의 나이를 100살로 변경해보겠습니다. (성공적으로 변경했습니다.)

다시 한번 id가 1인 멤버를 찾는 요청을 보내겠습니다.

 

이처럼 cache에 있는 데이터에 영향을 줄 수 있는 메서드에는 @CachePut을 이용해서 동기화를 진행해주어야 합니다. 

memberList 데이터도 동기화를 해줘야 합니다. 하지만 memberList에는 key값을 설정해두지 않았기 때문에

@CacheEvict를 이용해야 합니다.

@CacheEvict

설정에 따라서 Cache의 값을 삭제합니다. 현재 memberList는 key값이 존재하지 않아서 전체를 삭제했다가 다시 만들어야 합니다. 이때 @CacheEvict를 사용합니다.

 

Id를 통해서 member를 삭제했을 때 memberList에 반영이 되는지 test 해보겠습니다.

member 삭제를 진행합니다.

다시 한번 memberList를 요청합니다.

 

예상과 다르게 cache에는 삭제되지 않을 것을 볼 수 있습니다. 그래서 @CacheEvict를 사용하겠습니다. 

key값이 있다면 key값을 줘서 삭제할 수 있지만, 현재 key값이 없는 메서드이므로 allentires를 사용해서 전체 캐시를 날리겠습니다. 

@CacheEvict(value = "memberList",allEntries = true)
public void deleteMember(long id){
    memberRepository.deleteById(id);
}

위의 과정을 반복했을 때 cache에도 멤버가 삭제된 것을 볼 수 있습니다. key값이 있을 경우 전체 엔트리가 아닌 key값을 통해서 삭제가 가능합니다. 


정리하기

cache 속성

1. value, cacheName: 두 개는 alias로서 Cache의 이름을 칭하며, Cache를 구분하는 역할

2. key:Cache 내에서 데이터를 구분하는 역할 (keyGenerartor과 같이 사용 불가능)

3. keyGenerator: 특정 로직을 통해서 key를 만들고자 할 때 사용 (KeyGenerator를 상속받고 구현)

4. cacheManager: Custom 한 CacheManager를 사용하도록 설정하는 속성

5. keyResolver: Custom 한 CacheResolver를 사용하도록 설정

6. condition: SpEL을 통해서 특정 조건에 부합한 경우만 Cache를 적용

7. unless: SpEL을 통해서 특정 조건에 부합하지 않은 경우만 Cache를 적용

8. sync: Cache의 구현체가 Thread Safe 하지 않은 경우에 Cache에 동기화 설정

 

cache가 만들어진 메서드에 대해서는 메서드가 호출되는 것이 아닌 cache의 데이터가 반환된다.

 

@Cacheable: Cache를 사용할 수 있게 해 주며, 메서드에 붙이면 됩니다. 

@CacheEvict: 설정에 따라서 Cache의 값을 삭제합니다. 

@CachePut: Cache를 갱신해주는 역할을 합니다. 

 

Cache는 동기화가 자동으로 이루어지지 않으므로 어노테이션들을 활용해서 동기화를 진행해줘야 한다.

 

읽어주셔서 감사합니다. 다음은 이러한 SimpleCacheManager가 어떻게 동작하는지에 대해서 알아보겠습니다.

 

모든 코드는 아래 링크에서 확인하실 수 있습니다.

https://github.com/rlaehdals/springbootCache

 

GitHub - rlaehdals/springbootCache

Contribute to rlaehdals/springbootCache development by creating an account on GitHub.

github.com