Backend/SpringBoot

Main DB (Postgresql) + Cache (Redis) 사용하기

Dean83 2025. 9. 3. 15:07

소규모의 경우에는 이렇게 하지 않아도 될 수 있으나, 데이터가 많고 읽는 경우가 많을때 이 방식을 많이 쓴다. 

메인DB로 Postgresql 을 사용하는 이유는 성능도 좋지만 무료이고, 검증되었기 때문이라고 본다. 

SpringBoot 설정에서 캐시 설정을 Redis로 해 놓으면 캐시에서 조회하는것도, 캐시를 갱신하는것도 다 자동으로 해 준다.

이론적인 부분은 여기를 참조 (https://dean83.tistory.com/399). 이 중 Cache-aside 방식으로 동작한다.

 

이 글에서는 외부 글로벌 캐싱 기준으로 설명한다. 만일 인메모리 캐싱이 필요하다면, caffein을 추가로 알아보는것이 좋다. (ConcurrentMap 이용으로 빠름)

1. Gradle 추가

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

 

2. yaml 설정

  • db 접속 정보는 대부분 env 파일로 따로 빼 낸 다음 불러오거나 AWS를 이용하므로 참고. (https://dean83.tistory.com/322 여기 하단부 참고)
spring:
  application:
    name: my-service

  # =========================
  # PostgreSQL (영속 DB)
  # =========================
  datasource:
    driver-class-name: org.postgresql.Driver
    url: jdbc:postgresql://db.mycompany.com:5432/mydb
    username: myuser
    password: mypassword
    hikari:
      maximum-pool-size: 10
      minimum-idle: 5
      connection-timeout: 30000

  jpa:
    hibernate:
      ddl-auto: validate
    show-sql: false
    properties:
      hibernate:
        format_sql: true
        dialect: org.hibernate.dialect.PostgreSQLDialect

  # =========================
  # Redis (Cache 전용)
  # =========================
  data:
    redis:
      host: redis.mycompany.com     # 외부 Redis
      port: 6379
      password: redispassword       # 없으면 제거
      timeout: 2s
      lettuce:
        pool:
          max-active: 8
          max-idle: 8
          min-idle: 2
          max-wait: 1s

  # =========================
  # Spring Cache
  # =========================
  cache:
    type: redis   # ★ 중요: 캐시 구현체를 Redis로 명시

 

3. Redis Cache Manager 커스텀 설정

@Configuration
@EnableCaching  // 캐시 기능 활성화
public class RedisConfig {

    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        ObjectMapper mapper = objectMapper.copy();
        mapper.registerModule(new JavaTimeModule());
        mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);

        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(5))
                .serializeValuesWith(
                        RedisSerializationContext.SerializationPair.fromSerializer(
                                new GenericJackson2JsonRedisSerializer(mapper))
                ).prefixCacheNameWith("이름")
                .disableCachingNullValues();

        Map<String, RedisCacheConfiguration> configs = new HashMap<>();
        configs.put("users", config.entryTtl(Duration.ofMinutes(3)));
        configs.put("channels", config);
        configs.put("notifications", config);


        return RedisCacheManager.builder(redisConnectionFactory)
                .cacheDefaults(config)
                .withInitialCacheConfigurations(configs)
                .build();

    }
}
  • 미리 cache 이름을 지정해 줄 수 있고, 이때 각 캐시별로 유효시간 등을 지정할 수 있다.
  • 여기서 주의할점, 위에서 별도의 ObjectMapper 설정을 해주었는데, 이는 List 같은 제너릭 타입이 아닌, 클래스 타입은 가능하다.
  • 그러나 List 타입을 사용하게 되면 Deserialize 할때 터진다. 이 경우, ObjectMapper 관련 설정은 지우고, 아래 코드도 고친다.
    • 그후, 별도의 캐싱 전용 서비스 클래스를 생성하고, 직접 ObjectMapper 를 통해 List 를 Serialize 하여 String 형태로 변환하고 리턴한다.
    • 기존에 사용하던 비즈니스 로직 서비스는 (캐싱을 하지 않는) 리턴받은 json 문자열을 수동으로 Deserialize 하여 컨트롤러에 리턴한다.
...
.serializeValuesWith(
            RedisSerializationContext.SerializationPair.fromSerializer(
                        new GenericJackson2JsonRedisSerializer(mapper))
                                
위의 코드를 아래처럼 고친다.

.serializeValuesWith(
			RedisSerializationContext.SerializationPair.fromSerializer(
            new StringRedisSerializer())

 

 

 

4. 기본 활용 예

  • 기본적으로 @Cacheable, @CacheEvict, @CachePut 3개가 있다.
//엔티티
@Entity
@Table(name = "users")
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor
public class User {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
}

....

//리파지토리
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
}

...
//서비스
@Service
@RequiredArgsConstructor
public class UserService {
    private final UserRepository userRepository;

    @Cacheable(value = "userCache", key = "#id")
    public User getUser(Long id) {
        // Redis에 캐시가 없을 때만 DB 조회
        System.out.println("DB 조회 발생 for id=" + id);
        return userRepository.findById(id).orElseThrow();
    }

    @CacheEvict(value = "userCache", key = "#user.id")
    public User updateUser(User user) {
        // DB 갱신 + 캐시 무효화
        return userRepository.save(user);
    }
    
    @Cacheable(
        value = "userCache",
        key = "#id",
        condition = "#id < 1000",               // 실행 전 조건
        unless = "#result.status == 'DELETED'" // 실행 후 조건
    )
    public User getUserWithCondition(Long id) {

        System.out.println("DB 조회 발생 for id=" + id);

        return userRepository.findById(id)
            .orElseThrow();
    }

}

...

//컨트롤러
@RestController
@RequiredArgsConstructor
@RequestMapping("/users")
public class UserController {
    private final UserService userService;

    @GetMapping("/{id}")
    public User getUser(@PathVariable Long id) {
        return userService.getUser(id);
    }

    @PutMapping("/{id}")
    public User updateUser(@PathVariable Long id, @RequestBody User user) {
        user.setId(id);
        return userService.updateUser(user);
    }
}
  • @Cacheable의 value 값 : 캐시의 이름으로, Redis 에서 논리적으로 공간을 구분하기 위해 사용 (DB 테이블과 유사)
  • SpEL 중, condition, unless
    • condition : 해당 조건일때 캐시에서 찾아오는것을 시도.
    • unless : 해당 조건일 때에는 캐시하지 않음.
  • key는 복합키로도 사용이 가능하다
    • 예 : key : "#category" + "_" + "#page" : 카테고리와 페이지가 모두 하나의 키로 의미있는 경우.

  • 개발자가 따로 뭘 하지 않아도 서비스의 메서드가 호출될 때 @Cachable 로 인해 자동으로 캐시에서 내용을 찾고, 없을경우만 쿼리 조회 한다.
  • 캐시 갱신 등 관리도 자동으로 한다.
  • 쓰기 작업을 할때 @CacheEvict (해당 캐시 키 삭제) 나 CachePut을 반드시 해야한다. 내용이 변경되었기 때문에 무효화를 해주어야 한다.
    • @CachePut (value = "userCache", key = "#user.id") 를 통해 갱신.
    • @CacheEvict 사용시, allEntries = true 속성을 주면, 해당 value의 모든 캐싱을 지운다. 

 

5. 복합 캐싱 예

  • 하나의 메서드에서 여러 캐싱을 동시에 활용할때 사용한다.
@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;

    @Caching(
        cacheable = {
            @Cacheable(value = "userById", key = "#id"),
            @Cacheable(value = "userByEmail", key = "#result.email")
        }
    )
    public User getUser(Long id) {
        System.out.println("DB 조회 발생 id=" + id);
        return userRepository.findById(id)
            .orElseThrow();
    }
    
    @Caching(
    evict = {
        @CacheEvict(value = "userById", key = "#user.id"),
        @CacheEvict(value = "userByEmail", key = "#user.email")
    }
    )
    public User updateUser(User user) {
        return userRepository.save(user);
    }
    
    @Caching(
    put = {
        @CachePut(value = "userById", key = "#result.id"),
        @CachePut(value = "userByEmail", key = "#result.email")
    }
    )
    public User updateAndRefresh(User user) {
        return userRepository.save(user);
    }
}

 

 

캐싱의 다양한 문제점을 방지하고 해결하기 위해 아래를 참고 하여 설정할 필요가 있다. (condition, unless 사용 등)
https://dean83.tistory.com/399

https://dean83.tistory.com/404

 

Cache

캐싱은 DB 정보를 매번 조회하지 않아도 되므로, 응답속도에도 잇점이 있고, DB 부하를 줄일 수 있는 잇점도 있다. 보통은 MemoryDB 인 Redis를 많이 사용하고, 외부 Redis를 통해 다수의 서버가 캐싱을

dean83.tistory.com