프로그램을 만든 뒤 데이터 저장소를 바꾸거나 캐시 구현체를 교체해야 할 때가 있다. 이때 사용처마다 특정 구현 API를 직접 호출하고 있다면 수정할 곳이 많아진다. Spring이 중요하게 여기는 PSA(Portable Service Abstraction)는 개발 코드와 특정 기술 사이에 일관된 추상화를 두어 기술 교체의 영향 범위를 줄이는 설계 방향이다.
애플리케이션 서비스 코드
↓
Spring 추상화
↙ ↘
구현체 A 구현체 B
상위 코드는 공통 API에 의존하고, 실제 구현은 설정과 의존성에 따라 선택할 수 있다. 예를 들어 Spring의 트랜잭션 추상화는 PlatformTransactionManager, 캐시 추상화는 CacheManager 인터페이스를 사용한다.
중요한 점은 추상화가 적용되는 범위다. 캐시 저장소가 Redis에서 로컬 캐시로 바뀌어도 @Cacheable을 사용하는 서비스 코드가 그대로일 수 있지만, 캐시 일관성·TTL·분산 환경 동작까지 같다고 보장되지는 않는다.
상품 정보를 조회할 때 동일한 상품 ID의 조회가 반복될 수 있다. 메서드 결과를 캐시에 저장하면 이미 계산한 값을 재사용할 수 있다.
Spring Cache를 활성화하려면 @EnableCaching과 사용할 CacheManager를 구성한다.
package com.example.shop.config;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
return new ConcurrentMapCacheManager("catalog");
}
}
여기서는 이해를 위해 메모리 기반 ConcurrentMapCacheManager를 사용한다. 서버 재시작 시 캐시가 유지되지 않고, 여러 서버 인스턴스 사이에서도 공유되지 않는다. 실제로 다른 캐시 제공자(Redis, Caffeine 등)를 쓴다면 별도 의존성과 설정이 필요하다.
package com.example.shop.catalog;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
@Service
public class CatalogService {
@Cacheable(cacheNames = "catalog", key = "#productId")
public String getProductName(long productId) {
System.out.println("원본 조회 실행: " + productId);
return "상품-" + productId;
}
}
다른 Spring 빈에서 getProductName(10)을 연속으로 두 번 호출하면, 기본적인 캐시 조건이 충족된 경우 첫 호출에만 원본 조회 메시지가 출력된다. 두 번째 호출은 같은 key에 대한 캐시 결과를 사용하기 때문이다.
첫 호출 → 원본 조회 실행: 10 → "상품-10"
두 번째 → 캐시에서 결과 반환 → "상품-10"
@Cacheable은 메서드를 매번 실행하게 만드는 애노테이션이 아니라, 캐시 적중 시 메서드 실행을 건너뛰도록 할 수 있는 애노테이션이다.
캐시에 남아 있는 데이터가 실제 DB와 달라지면 사용자는 오래된 정보를 받게 된다. 다음과 같이 캐시 무효화 정책을 명시할 수 있다.
@CacheEvict(cacheNames = "catalog", key = "#productId")
public void updateProductName(long productId, String newName) {
repository.updateName(productId, newName);
}
이 코드는 기존 CatalogService에 DB 저장용 repository 의존성이 추가되어 있다고 가정한 메서드 예시다. 업데이트가 정상 완료되면 해당 key의 캐시 값을 제거하도록 선언한다. 기본적으로 @CacheEvict는 메서드가 성공적으로 끝난 다음 제거한다.
@CachePut은 메서드를 실행하고 결과를 캐시에 반영할 때 사용한다. 두 방식 중 어느 것이 맞는지는 업데이트 직후 데이터를 어떻게 보여줘야 하는지에 따라 달라진다.
주의: 캐시 기능도 보통 프록시를 통해 동작하므로 같은 클래스 내부의 this.getProductName() 같은 호출에서는 @Cacheable이 적용되지 않을 수 있다.
HTTP 요청을 처리하는 코드도 구현 세부사항을 추상화할 수 있다.
package com.example.shop.api;
import com.example.shop.catalog.CatalogService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final CatalogService catalogService;
public ProductController(CatalogService catalogService) {
this.catalogService = catalogService;
}
@GetMapping("/{productId}")
public String product(@PathVariable long productId) {
return catalogService.getProductName(productId);
}
}
GET /api/products/10 요청 시 응답 본문에 상품-10이 반환된다. 이 컨트롤러는 저수준 서블릿 API를 직접 다루지 않지만, HTTP 경로와 응답 처리 방법을 명확하게 정의할 수 있다. Spring Web MVC는 이 요청을 서블릿 기반 웹 스택에서 처리한다.
Spring에는 서블릿 기반의 Spring MVC와 리액티브 기반의 Spring WebFlux라는 별도 웹 스택이 존재한다. 양쪽 모두 @RestController, @GetMapping과 같은 애노테이션 모델을 지원할 수 있지만, 요청 처리 방식·반환 타입·서버 런타임·블로킹 처리의 의미가 동일하지는 않다.
따라서 애노테이션이 비슷하다는 이유만으로 MVC 프로젝트를 WebFlux로 바꿔도 모든 코드가 수정 없이 동작한다고 가정해서는 안 된다. 특히 Servlet API 직접 의존, 블로킹 JPA 호출, 필터·보안·테스트 설정은 점검해야 한다.
좋은 추상화는 변경 비용을 줄이지만, 구현체 사이의 모든 차이를 없애는 마법은 아니다.
CacheManager를 통해 캐시 구현체를 연결한다.@Cacheable은 캐시 적중 시 메서드 실행을 생략할 수 있다.@CacheEvict로 데이터 변경 이후 오래된 캐시를 무효화할 수 있다.