0. 들어가며
마이크로서비스 아키텍처의 가장 큰 장점 중 하나는 확장성(Scalability)이다. 트래픽이 증가하는 특정 서비스만 선택적으로 확장할 수 있어 리소스를 효율적으로 사용할 수 있다. 하지만 확장성은 단순히 서비스 인스턴스를 늘리는 것만으로 해결되지 않는다. 데이터베이스, 캐시, 세션 등 상태(State)를 어떻게 관리하느냐에 따라 확장의 효과가 크게 달라진다.
이번 글에서는 확장성의 기본 개념부터 시작하여 분산 캐시(Distributed Cache)의 필요성, Redis를 활용한 다양한 캐싱 전략, 그리고 실제 이커머스에서 많이 사용되는 장바구니 처리 예제를 통해 확장 가능한 시스템 설계 방법을 살펴본다.
1. Scalability 개요
1.1. 확장성의 두 가지 방식
수직 확장(Scale Up):
- 더 좋은 성능의 서버로 업그레이드 (CPU, RAM, 디스크)
- 장점: 애플리케이션 변경 불필요, 관리 단순
- 단점: 물리적 한계, 비용이 기하급수적으로 증가
수평 확장(Scale Out):
- 여러 대의 서버를 추가하여 처리 능력 향상
- 장점: 이론상 무한 확장 가능, 비용 효율적
- 단점: 아키텍처 복잡도 증가, 상태 관리 어려움
[수직 확장]
┌─────────────────────┐
│ 단일 서버 │
│ ┌───────────────┐ │
│ │ 더 강력한 │ │
│ │ 서버로 │ │
│ │ 교체 │ │
│ └───────────────┘ │
└─────────────────────┘
[수평 확장]
┌─────────────────────────────────┐
│ ┌────────┐ ┌────────┐ ┌────────┐
│ │ 서버 1 │ │ 서버 2 │ │ 서버 3 │
│ └────────┘ └────────┘ └────────┘
│ ▲ ▲ ▲
│ └───────────┼───────────┘
│ [로드 밸런서]
└─────────────────────────────────┘
1.2. 무상태(Stateless) vs 상태(Stateful) 설계
무상태 설계의 중요성:
// ❌ 상태 유지 - 확장 어려움
@SessionAttributes
public class CartController {
@GetMapping("/cart")
public String viewCart(HttpSession session) {
// 세션에 저장된 장바구니 - 이 서버에서만 유지됨
Cart cart = (Cart) session.getAttribute("cart");
return "cart";
}
}
// ✅ 무상태 설계 - 확장 용이
@RestController
public class CartController {
private final RedisTemplate<String, Cart> redisTemplate;
@GetMapping("/cart/{userId}")
public Cart viewCart(@PathVariable String userId) {
// Redis에 저장된 장바구니 - 모든 서버에서 접근 가능
return redisTemplate.opsForValue().get("cart:" + userId);
}
}
무상태 설계 원칙:
- 세션 데이터는 외부 저장소(Redis, DB)에 저장
- 요청 간 공유되는 상태 최소화
- 요청 자체에 필요한 모든 정보 포함
2. Distributed Cache
2.1. 캐싱의 필요성
데이터베이스는 디스크 I/O가 발생하므로 응답 속도에 한계가 있다. 캐시를 사용하면 다음과 같은 이점이 있다.
- 응답 속도 향상: 인메모리 저장소 사용으로 밀리초 단위 응답
- 데이터베이스 부하 감소: 반복적인 쿼리를 캐시에서 처리
- 비용 절감: 데이터베이스 확장보다 저렴
캐시 적용이 효과적인 데이터:
- 자주 조회되지만 자주 변경되지 않는 데이터
- 계산 비용이 높은 데이터
- 사용자 세션과 같은 임시 데이터
2.2. 캐싱 전략
1. Cache-Aside 패턴 (Lazy Loading):
[애플리케이션]
1. 데이터 요청 → [캐시]
2. 캐시 미스 → [데이터베이스]
3. 데이터 조회 → [데이터베이스]
4. 캐시 저장 → [캐시]
5. 데이터 반환 → [애플리케이션]
@Service
public class ProductService {
@Autowired
private RedisTemplate<String, Product> redisTemplate;
@Autowired
private ProductRepository productRepository;
public Product getProduct(String productId) {
String cacheKey = "product:" + productId;
// 1. 캐시 확인
Product cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return cached; // 캐시 히트
}
// 2. 캐시 미스 - DB 조회
Product product = productRepository.findById(productId)
.orElseThrow(() -> new ProductNotFoundException(productId));
// 3. 캐시 저장
redisTemplate.opsForValue().set(cacheKey, product, 1, TimeUnit.HOURS);
return product;
}
}
2. Write-Through 패턴:
[애플리케이션]
1. 데이터 저장 요청 → [캐시]
2. 캐시 저장 → [캐시]
3. 데이터베이스 저장 → [데이터베이스]
4. 완료 응답 → [애플리케이션]
@Service
public class ProductService {
@Transactional
public Product updateProduct(String productId, ProductUpdateRequest request) {
// 1. DB 업데이트
Product product = productRepository.findById(productId)
.orElseThrow(() -> new ProductNotFoundException(productId));
product.setName(request.getName());
product.setPrice(request.getPrice());
productRepository.save(product);
// 2. 캐시 업데이트 (Write-Through)
String cacheKey = "product:" + productId;
redisTemplate.opsForValue().set(cacheKey, product, 1, TimeUnit.HOURS);
return product;
}
}
3. Write-Behind (Write-Back) 패턴:
[애플리케이션]
1. 데이터 저장 요청 → [캐시] (즉시 응답)
2. 비동기 → [데이터베이스] (나중에 일괄 처리)
@Component
public class WriteBehindProcessor {
@Autowired
private RedisTemplate<String, Product> redisTemplate;
@Autowired
private ProductRepository productRepository;
@Scheduled(fixedDelay = 10000) // 10초마다 실행
@Transactional
public void flushDirtyProducts() {
// 변경된 데이터 목록 조회
Set<String> dirtyKeys = redisTemplate.keys("dirty:product:*");
for (String dirtyKey : dirtyKeys) {
String productId = dirtyKey.replace("dirty:product:", "");
String cacheKey = "product:" + productId;
Product product = redisTemplate.opsForValue().get(cacheKey);
if (product != null) {
// DB에 저장
productRepository.save(product);
// dirty 마크 제거
redisTemplate.delete(dirtyKey);
}
}
}
}
3. Database 확장과 캐시
3.1. 읽기 부하 분산
Master-Slave 복제:
[Master DB]
(쓰기 전용)
/ | \\
▼ ▼ ▼
[Slave 1] [Slave 2] [Slave 3]
(읽기) (읽기) (읽기)
@Configuration
public class DatabaseRoutingConfig {
@Bean
@Primary
@ConfigurationProperties(prefix = "spring.datasource.master")
public DataSource masterDataSource() {
return DataSourceBuilder.create().build();
}
@Bean
@ConfigurationProperties(prefix = "spring.datasource.slave1")
public DataSource slave1DataSource() {
return DataSourceBuilder.create().build();
}
@Bean
@ConfigurationProperties(prefix = "spring.datasource.slave2")
public DataSource slave2DataSource() {
return DataSourceBuilder.create().build();
}
@Bean
public DataSource routingDataSource() {
Map<Object, Object> targetDataSources = new HashMap<>();
targetDataSources.put("master", masterDataSource());
targetDataSources.put("slave1", slave1DataSource());
targetDataSources.put("slave2", slave2DataSource());
RoutingDataSource routingDataSource = new RoutingDataSource();
routingDataSource.setDefaultTargetDataSource(masterDataSource());
routingDataSource.setTargetDataSources(targetDataSources);
return routingDataSource;
}
static class RoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return TransactionSynchronizationManager.isCurrentTransactionReadOnly()
? getRandomSlave() : "master";
}
private String getRandomSlave() {
List<String> slaves = Arrays.asList("slave1", "slave2");
return slaves.get(new Random().nextInt(slaves.size()));
}
}
}
3.2. 캐시 계층 구조
다중 계층 캐시 (L1/L2 캐시):
[애플리케이션]
│
├─ [L1 캐시] - 로컬 캐시 (Caffeine) - 초고속, 서버 내부
│
├─ [L2 캐시] - 분산 캐시 (Redis) - 빠름, 서버 간 공유
│
└─ [데이터베이스] - 느림, 영구 저장
@Configuration
@EnableCaching
public class MultiLevelCacheConfig {
// L1 캐시 - 로컬 캐시 (Caffeine)
@Bean
@Primary
public CacheManager localCacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
cacheManager.setCaffeine(Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.recordStats());
return cacheManager;
}
// L2 캐시 - 분산 캐시 (Redis)
@Bean
public CacheManager redisCacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofHours(1))
.serializeKeysWith(
RedisSerializationContext.SerializationPair.fromSerializer(
new StringRedisSerializer()))
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(
new GenericJackson2JsonRedisSerializer()));
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(config)
.build();
}
}
@Service
public class ProductService {
@Autowired
@Qualifier("localCacheManager")
private CacheManager localCacheManager;
@Autowired
@Qualifier("redisCacheManager")
private CacheManager redisCacheManager;
public Product getProduct(String productId) {
Cache localCache = localCacheManager.getCache("products");
Cache redisCache = redisCacheManager.getCache("products");
// L1 캐시 확인
Product product = localCache.get(productId, Product.class);
if (product != null) {
return product;
}
// L2 캐시 확인
product = redisCache.get(productId, Product.class);
if (product != null) {
// L2 히트 시 L1에도 저장
localCache.put(productId, product);
return product;
}
// DB 조회
product = productRepository.findById(productId)
.orElseThrow(() -> new ProductNotFoundException(productId));
// 양쪽 캐시에 저장
redisCache.put(productId, product);
localCache.put(productId, product);
return product;
}
}
3.3. 캐시 무효화 전략
1. TTL(Time-To-Live) 기반:
@Cacheable(value = "products", key = "#productId", unless = "#result == null")
@CacheEvict(value = "products", key = "#productId")
public Product updateProduct(String productId, ProductUpdateRequest request) {
// 업데이트 시 캐시 자동 삭제
}
2. 버전 기반 무효화:
@Service
public class ProductService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
public Product getProduct(String productId) {
String versionKey = "product:version";
String dataKey = "product:" + productId;
// 현재 버전 확인
Long currentVersion = redisTemplate.opsForValue()
.increment(versionKey, 0); // 값만 조회
// 버전과 함께 데이터 저장
Map<String, Object> cached = redisTemplate.opsForHash()
.entries(dataKey);
if (!cached.isEmpty() &&
currentVersion.equals(cached.get("version"))) {
return convertToProduct(cached); // 유효한 캐시
}
// 캐시 미스 - DB 조회
Product product = loadFromDatabase(productId);
// 새 버전으로 캐시 저장
Map<String, Object> newCache = new HashMap<>();
newCache.put("data", product);
newCache.put("version", currentVersion);
redisTemplate.opsForHash().putAll(dataKey, newCache);
return product;
}
}
4. [실습 27~28] Redis 기반 분산 캐시 장바구니 처리
4.1. 장바구니 도메인 설계
장바구니 엔티티:
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Cart {
private String cartId;
private String userId;
private List<CartItem> items;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private int totalItems;
private Money totalPrice;
public void addItem(CartItem newItem) {
if (items == null) {
items = new ArrayList<>();
}
// 동일 상품이 있으면 수량 증가
for (CartItem item : items) {
if (item.getProductId().equals(newItem.getProductId())) {
item.setQuantity(item.getQuantity() + newItem.getQuantity());
calculateTotals();
return;
}
}
items.add(newItem);
calculateTotals();
}
public void removeItem(String productId) {
items.removeIf(item -> item.getProductId().equals(productId));
calculateTotals();
}
public void updateQuantity(String productId, int quantity) {
for (CartItem item : items) {
if (item.getProductId().equals(productId)) {
item.setQuantity(quantity);
calculateTotals();
return;
}
}
}
private void calculateTotals() {
totalItems = items.stream()
.mapToInt(CartItem::getQuantity)
.sum();
totalPrice = items.stream()
.map(CartItem::getSubtotal)
.reduce(Money.ZERO, Money::add);
}
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CartItem {
private String productId;
private String productName;
private String imageUrl;
private int quantity;
private Money price;
public Money getSubtotal() {
return price.multiply(quantity);
}
}
@Data
public class Money {
private BigDecimal amount;
private String currency;
public static final Money ZERO = new Money(BigDecimal.ZERO, "KRW");
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("Currency mismatch");
}
return new Money(this.amount.add(other.amount), this.currency);
}
public Money multiply(int multiplier) {
return new Money(this.amount.multiply(BigDecimal.valueOf(multiplier)), currency);
}
}
4.2. Redis 설정
@Configuration
@EnableRedisRepositories
public class RedisConfig {
@Value("${spring.redis.host:localhost}")
private String redisHost;
@Value("${spring.redis.port:6379}")
private int redisPort;
@Value("${spring.redis.password:}")
private String redisPassword;
@Bean
public LettuceConnectionFactory redisConnectionFactory() {
RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(redisHost, redisPort);
if (StringUtils.hasText(redisPassword)) {
config.setPassword(redisPassword);
}
LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder()
.commandTimeout(Duration.ofSeconds(2))
.shutdownTimeout(Duration.ofMillis(100))
.build();
return new LettuceConnectionFactory(config, clientConfig);
}
@Bean
public RedisTemplate<String, Cart> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Cart> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
// 키는 문자열로 직렬화
template.setKeySerializer(new StringRedisSerializer());
// 값은 JSON으로 직렬화
Jackson2JsonRedisSerializer<Cart> serializer = new Jackson2JsonRedisSerializer<>(Cart.class);
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
mapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
serializer.setObjectMapper(mapper);
template.setValueSerializer(serializer);
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(serializer);
template.afterPropertiesSet();
return template;
}
@Bean
public RedisTemplate<String, Object> genericRedisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
return template;
}
}
4.3. 장바구니 서비스 구현
@Service
@Slf4j
@RequiredArgsConstructor
public class CartService {
private final RedisTemplate<String, Cart> redisTemplate;
private final ProductServiceClient productClient;
private static final String CART_KEY_PREFIX = "cart:";
private static final long CART_TTL_HOURS = 24; // 24시간
/**
* 장바구니 조회
*/
public Cart getCart(String userId) {
String cartKey = CART_KEY_PREFIX + userId;
Cart cart = redisTemplate.opsForValue().get(cartKey);
if (cart == null) {
// 새 장바구니 생성
cart = Cart.builder()
.cartId(UUID.randomUUID().toString())
.userId(userId)
.items(new ArrayList<>())
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.totalItems(0)
.totalPrice(Money.ZERO)
.build();
// Redis에 저장
redisTemplate.opsForValue().set(
cartKey,
cart,
CART_TTL_HOURS,
TimeUnit.HOURS
);
log.info("Created new cart for user: {}", userId);
}
return cart;
}
/**
* 장바구니에 상품 추가
*/
@Transactional
public Cart addToCart(String userId, AddToCartRequest request) {
String cartKey = CART_KEY_PREFIX + userId;
// Redis에서 장바구니 조회
Cart cart = redisTemplate.opsForValue().get(cartKey);
if (cart == null) {
cart = createNewCart(userId);
}
// 상품 정보 조회 (Product Service 호출)
ProductDto product = productClient.getProduct(request.getProductId());
// 장바구니 아이템 생성
CartItem item = CartItem.builder()
.productId(product.getId())
.productName(product.getName())
.imageUrl(product.getImageUrl())
.quantity(request.getQuantity())
.price(product.getPrice())
.build();
// 아이템 추가
cart.addItem(item);
cart.setUpdatedAt(LocalDateTime.now());
// Redis에 저장 (TTL 갱신)
redisTemplate.opsForValue().set(cartKey, cart, CART_TTL_HOURS, TimeUnit.HOURS);
log.info("Added item to cart. userId: {}, productId: {}, quantity: {}",
userId, request.getProductId(), request.getQuantity());
return cart;
}
/**
* 장바구니 상품 수량 변경
*/
@Transactional
public Cart updateCartItem(String userId, String productId, int quantity) {
String cartKey = CART_KEY_PREFIX + userId;
Cart cart = redisTemplate.opsForValue().get(cartKey);
if (cart == null) {
throw new CartNotFoundException("Cart not found for user: " + userId);
}
if (quantity <= 0) {
// 수량이 0 이하면 아이템 제거
cart.removeItem(productId);
} else {
cart.updateQuantity(productId, quantity);
}
cart.setUpdatedAt(LocalDateTime.now());
redisTemplate.opsForValue().set(cartKey, cart, CART_TTL_HOURS, TimeUnit.HOURS);
log.info("Updated cart item. userId: {}, productId: {}, quantity: {}",
userId, productId, quantity);
return cart;
}
/**
* 장바구니에서 상품 제거
*/
@Transactional
public Cart removeFromCart(String userId, String productId) {
String cartKey = CART_KEY_PREFIX + userId;
Cart cart = redisTemplate.opsForValue().get(cartKey);
if (cart == null) {
throw new CartNotFoundException("Cart not found for user: " + userId);
}
cart.removeItem(productId);
cart.setUpdatedAt(LocalDateTime.now());
if (cart.getItems().isEmpty()) {
// 장바구니가 비었으면 삭제
redisTemplate.delete(cartKey);
log.info("Cart emptied and removed. userId: {}", userId);
return null;
} else {
redisTemplate.opsForValue().set(cartKey, cart, CART_TTL_HOURS, TimeUnit.HOURS);
return cart;
}
}
/**
* 장바구니 비우기
*/
@Transactional
public void clearCart(String userId) {
String cartKey = CART_KEY_PREFIX + userId;
redisTemplate.delete(cartKey);
log.info("Cart cleared. userId: {}", userId);
}
/**
* 장바구니를 주문으로 변환 (주문 생성 시 호출)
*/
@Transactional
public CheckoutResult checkout(String userId) {
String cartKey = CART_KEY_PREFIX + userId;
Cart cart = redisTemplate.opsForValue().get(cartKey);
if (cart == null || cart.getItems().isEmpty()) {
throw new EmptyCartException("Cart is empty");
}
// 재고 확인 (Inventory Service 호출)
CheckStockRequest stockRequest = new CheckStockRequest(cart.getItems());
StockCheckResult stockResult = productClient.checkStock(stockRequest);
if (!stockResult.isAllAvailable()) {
throw new OutOfStockException("Some items are out of stock");
}
// 주문 생성 (Order Service 호출)
CreateOrderRequest orderRequest = CreateOrderRequest.builder()
.userId(userId)
.items(cart.getItems())
.totalAmount(cart.getTotalPrice())
.build();
OrderResult orderResult = orderClient.createOrder(orderRequest);
// 장바구니 비우기
redisTemplate.delete(cartKey);
return CheckoutResult.builder()
.success(true)
.orderId(orderResult.getOrderId())
.message("Checkout completed successfully")
.build();
}
private Cart createNewCart(String userId) {
return Cart.builder()
.cartId(UUID.randomUUID().toString())
.userId(userId)
.items(new ArrayList<>())
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.totalItems(0)
.totalPrice(Money.ZERO)
.build();
}
}
4.4. 장바구니 컨트롤러
@RestController
@RequestMapping("/api/cart")
@Slf4j
@RequiredArgsConstructor
public class CartController {
private final CartService cartService;
/**
* 장바구니 조회
*/
@GetMapping
public ResponseEntity<CartResponse> getCart(@RequestHeader("X-User-Id") String userId) {
Cart cart = cartService.getCart(userId);
return ResponseEntity.ok(CartResponse.from(cart));
}
/**
* 장바구니에 상품 추가
*/
@PostMapping("/items")
public ResponseEntity<CartResponse> addToCart(
@RequestHeader("X-User-Id") String userId,
@Valid @RequestBody AddToCartRequest request) {
Cart cart = cartService.addToCart(userId, request);
return ResponseEntity.ok(CartResponse.from(cart));
}
/**
* 장바구니 상품 수량 변경
*/
@PutMapping("/items/{productId}")
public ResponseEntity<CartResponse> updateCartItem(
@RequestHeader("X-User-Id") String userId,
@PathVariable String productId,
@Valid @RequestBody UpdateCartItemRequest request) {
Cart cart = cartService.updateCartItem(userId, productId, request.getQuantity());
return ResponseEntity.ok(CartResponse.from(cart));
}
/**
* 장바구니에서 상품 제거
*/
@DeleteMapping("/items/{productId}")
public ResponseEntity<CartResponse> removeFromCart(
@RequestHeader("X-User-Id") String userId,
@PathVariable String productId) {
Cart cart = cartService.removeFromCart(userId, productId);
if (cart == null) {
return ResponseEntity.noContent().build();
}
return ResponseEntity.ok(CartResponse.from(cart));
}
/**
* 장바구니 비우기
*/
@DeleteMapping
public ResponseEntity<Void> clearCart(@RequestHeader("X-User-Id") String userId) {
cartService.clearCart(userId);
return ResponseEntity.noContent().build();
}
/**
* 장바구니에서 주문 생성 (체크아웃)
*/
@PostMapping("/checkout")
public ResponseEntity<CheckoutResponse> checkout(
@RequestHeader("X-User-Id") String userId) {
CheckoutResult result = cartService.checkout(userId);
return ResponseEntity.ok(CheckoutResponse.from(result));
}
}
4.5. Redis 모니터링 및 통계
@Component
@Slf4j
@RequiredArgsConstructor
public class RedisMonitoringService {
private final RedisTemplate<String, Object> redisTemplate;
private final MeterRegistry meterRegistry;
@Scheduled(fixedDelay = 60000) // 1분마다 실행
public void collectRedisStats() {
try {
// Redis 서버 정보 조회
Properties info = redisTemplate.getRequiredConnectionFactory()
.getConnection()
.info("stats");
if (info != null) {
// 캐시 히트율
String hits = info.getProperty("keyspace_hits");
String misses = info.getProperty("keyspace_misses");
if (hits != null && misses != null) {
long totalOps = Long.parseLong(hits) + Long.parseLong(misses);
double hitRate = totalOps > 0
? (double) Long.parseLong(hits) / totalOps
: 0.0;
meterRegistry.gauge("redis.cache.hit.rate", hitRate);
log.info("Redis cache hit rate: {}", hitRate);
}
// 캐시 크기
Set<String> keys = redisTemplate.keys("cart:*");
meterRegistry.gauge("redis.cart.count", keys.size());
log.info("Redis cart count: {}", keys.size());
}
} catch (Exception e) {
log.error("Failed to collect Redis stats", e);
}
}
@EventListener
public void handleCacheEvent(CacheEvent event) {
if (event.getType() == CacheEvent.Type.EVICTED) {
log.info("Cache evicted: {}", event.getKey());
meterRegistry.counter("redis.cache.eviction").increment();
}
}
}
5. 확장성과 캐시 모범 사례
5.1. 캐시 도입 체크리스트
- 캐시할 데이터의 TTL을 적절히 설정했는가?
- 캐시 무효화 전략이 수립되었는가?
- 캐시와 DB 간 일관성 문제를 고려했는가?
- 캐시 서버 장애 시 대비책(Fallback)이 있는가?
- 캐시 히트율을 모니터링하고 있는가?
5.2. 성능 최적화 팁
1. 파이프라인 처리:
List<Object> results = redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
for (String productId : productIds) {
connection.get(("product:" + productId).getBytes());
}
return null;
});
2. 배치 처리:
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30))
.disableCachingNullValues();
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(config)
.transactionAware() // 트랜잭션 인식
.build();
}
3. 압축 적용:
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
// 압축 직렬화 사용
JdkSerializationRedisSerializer serializer = new JdkSerializationRedisSerializer();
CompressorPostProcessor compressor = new CompressorPostProcessor();
template.setValueSerializer(serializer);
template.setHashValueSerializer(serializer);
return template;
}
6. 정리
6.1. 확장성 원칙 요약
- 무상태 설계: 세션 등 상태 정보는 외부 저장소에
- 수평 확장 우선: Scale Out을 기본 전략으로
- 캐시 계층화: L1/L2 캐시로 성능 최적화
- 데이터베이스 부하 분산: 읽기/쓰기 분리
- 모니터링: 캐시 히트율, 응답 시간 지속 관찰
6.2. Redis 활용 패턴
| 패턴 | 설명 | 사용 사례 |
| Cache-Aside | Lazy Loading 캐싱 | 상품 정보, 사용자 프로필 |
| Write-Through | 즉시 캐시/DB 동기화 | 중요 데이터, 일관성 필요 |
| Write-Behind | 비동기 DB 저장 | 로그, 조회수, 임시 데이터 |
| Pub/Sub | 이벤트 발행/구독 | 실시간 알림, 캐시 무효화 |
| Leaderboard | Sorted Set | 랭킹, 인기 상품 |
| Rate Limiting | 카운터 | API 요청 제한 |
6.3. 다음 글 예고
다음 글에서는 마이크로서비스 아키텍처 시리즈의 마지막으로 배포 전략과 운영 패턴에 대해 다룰 예정이다. 컨테이너 가상화, 오케스트레이션, 서비스 메시, 블루-그린/카나리 배포, 서버리스 아키텍처까지 MSA
'MSA > MSA 아키텍처' 카테고리의 다른 글
| [ADVANCED #4] MSA 보안과 테스트 전략 (0) | 2026.03.02 |
|---|---|
| [ADVANCED #3] MSA 회복성, 관측성, 모니터링 전략 (0) | 2026.03.02 |
| [ADVANCED #2] 분산 트랜잭션과 SAGA 패턴 완전 정리 (0) | 2026.03.02 |
| [ADVANCED #1] MSA 데이터 관리 전략 (DB per Service, CQRS, Sharding) (0) | 2026.03.02 |
| [BASIC #4] 비동기 통신과 Event-Driven Architecture (0) | 2026.03.02 |
