이번 주 학습 내용
- Redis를 이용한 분산락
- Redis를 이용한 캐싱
Spring에서 사용하는 Redis 명령어
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
@Service
public class RedisService {
private final StringRedisTemplate stringRedisTemplate;
// 생성자 주입
public RedisService(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
// ==================== 저장 메서드 ====================
// 1. String (문자열) 데이터 저장
public void saveString(String key, String value) {
stringRedisTemplate.opsForValue().set(key, value);
}
// 1-1. 만료 시간과 함께 String 데이터 저장
public void saveStringWithExpiry(String key, String value, long timeoutInSeconds) {
stringRedisTemplate.opsForValue().set(key, value, timeoutInSeconds, TimeUnit.SECONDS);
}
// 2. List 데이터 저장
public void saveToList(String key, String value) {
stringRedisTemplate.opsForList().rightPush(key, value);
}
public void saveAllToList(String key, List<String> values) {
stringRedisTemplate.opsForList().rightPushAll(key, values);
}
// 3. Hash 데이터 저장
public void saveToHash(String key, String field, String value) {
stringRedisTemplate.opsForHash().put(key, field, value);
}
public void saveAllToHash(String key, Map<String, String> data) {
stringRedisTemplate.opsForHash().putAll(key, data);
}
// 4. Set 데이터 저장
public void saveToSet(String key, String... values) {
stringRedisTemplate.opsForSet().add(key, values);
}
// 5. Sorted Set 데이터 저장
public void saveToSortedSet(String key, String value, double score) {
stringRedisTemplate.opsForZSet().add(key, value, score);
}
// ==================== 조회 메서드 ====================
// 1-2. String (문자열) 데이터 조회
public String getString(String key) {
return stringRedisTemplate.opsForValue().get(key);
}
// 2-1. List 데이터 조회 (전체)
public List<String> getListRange(String key) {
// 0부터 -1까지 조회하면 리스트의 모든 원소를 가져옵니다.
return stringRedisTemplate.opsForList().range(key, 0, -1);
}
// 3-1. Hash의 특정 필드 조회
public String getFromHash(String key, String field) {
// get() 메서드는 Object를 반환하므로 적절한 타입으로 캐스팅해야 합니다.
return (String) stringRedisTemplate.opsForHash().get(key, field);
}
// 3-2. Hash의 모든 필드와 값 조회
public Map<Object, Object> getAllFromHash(String key) {
return stringRedisTemplate.opsForHash().entries(key);
}
// 4-1. Set 데이터 조회 (모든 멤버)
public Set<String> getAllFromSet(String key) {
return stringRedisTemplate.opsForSet().members(key);
}
// 5-1. Sorted Set 데이터 조회 (랭킹 순)
public Set<String> getSortedSetRange(String key, long start, long end) {
// start ~ end 범위의 멤버들을 스코어 순(오름차순)으로 조회합니다.
return stringRedisTemplate.opsForZSet().range(key, start, end);
}
// 5-2. Sorted Set 데이터 조회 (역순, 스코어 높은 순)
public Set<String> getSortedSetReverseRange(String key, long start, long end) {
// start ~ end 범위의 멤버들을 스코어 역순(내림차순)으로 조회합니다. (랭킹보드 TOP 10 등에 사용)
return stringRedisTemplate.opsForZSet().reverseRange(key, start, end);
}
}
분산락
@Service
public class StockService {
private final RedissonClient redissonClient;
private int stock = 100; // 예제를 위한 가상 재고
public StockService(RedissonClient redissonClient) {
this.redissonClient = redissonClient;
}
public void decreaseStock(String stockKey) throws InterruptedException {
// 1. 락 객체 가져오기
RLock lock = redissonClient.getLock(stockKey);
try {
// 2. 락 획득 시도 (최대 10초 대기, 락 획득 후 1초 뒤 자동 해제)
// waitTime: 락을 얻기 위해 기다리는 시간
// leaseTime: 락을 점유하는 시간. 이 시간이 지나면 자동으로 해제됨.
boolean isLocked = lock.tryLock(10, 1, TimeUnit.SECONDS);
if (!isLocked) {
System.out.println("락 획득 실패!");
return;
}
// 3. 락 획득 성공 시 비즈니스 로직 수행
if (stock > 0) {
stock--;
System.out.println(Thread.currentThread().getName() + " -> 재고 감소 성공! 현재 재고: " + stock);
} else {
System.out.println(Thread.currentThread().getName() + " -> 재고 없음.");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw e;
} finally {
// 4. 락 해제 (반드시 finally 절에서 수행)
if (lock != null && lock.isLocked() && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
- redissonClient.getLock(key): 동일한 key에 대한 락 객체를 가져옵니다.
- lock.tryLock(): 락 획득을 시도합니다. 성공하면 true, 실패하면 false를 반환합니다.
- try-finally 구문: 비즈니스 로직에서 예외가 발생하더라도 finally 블록에서 락이 반드시 해제되도록 보장하는 것이 매우 중요합니다.
- lock.unlock(): 로직이 끝나면 락을 해제하여 다른 스레드/서버가 락을 획득할 수 있도록 합니다.
예시는 재고 감소로 했지만, 저는 유저의 주문 중복에 분산락을 걸어두었습니다. 하지만 생각해보니 주문도 분산락을 걸어야 할 것 같은데, 어떻게 설계해야 락을 최소화하며 좋은 서비스가 될지 고민해봐야겠습니다.
캐싱
@Service
public class ProductService {
/**
* @Cacheable: 캐시가 있으면 DB를 조회하지 않고 캐시에서 바로 반환. 없으면 메서드를 실행하고 결과를 캐시에 저장.
* - value(cacheNames): 캐시의 이름을 지정. Redis에서는 key의 prefix가 됨. (e.g., products::1)
* - key: 캐시의 key를 지정. SpEL(스프링 표현 언어)을 사용해 동적으로 생성. '#id'는 파라미터 id를 의미.
*/
@Cacheable(value = "products", key = "#id")
public ProductDto findProductById(String id) {
// DB 조회에 2초가 걸린다고 가정
try {
System.out.println("DB에서 상품 정보를 조회합니다: " + id);
Thread.sleep(2000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return new ProductDto(id, "상품 " + id, 10000);
}
/**
* @CachePut: 메서드를 항상 실행하고, 그 결과를 캐시에 업데이트(덮어쓰기).
* 주로 데이터 '수정' 시 사용.
*/
@CachePut(value = "products", key = "#id")
public ProductDto updateProduct(String id, String name, long price) {
System.out.println("DB에서 상품 정보를 수정합니다: " + id);
// ... DB 업데이트 로직 수행 ...
return new ProductDto(id, name, price);
}
/**
* @CacheEvict: 지정된 키의 캐시를 삭제.
* 주로 데이터 '삭제' 시 사용.
*/
@CacheEvict(value = "products", key = "#id")
public void deleteProduct(String id) {
System.out.println("DB와 캐시에서 상품 정보를 삭제합니다: " + id);
// ... DB 삭제 로직 수행 ...
}
}
또는
@Slf4j
@Component
@RequiredArgsConstructor
public class OrderEventListener {
private final CacheManager cacheManager;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleCacheEviction(OrderCompletedEvent event) {
log.info("주문 완료, 캐시 제거를 시작합니다.");
// 캐시
Cache productCache = cacheManager.getCache("products");
if (productCache == null) {
log.warn("products 캐시를 찾을 수 없습니다. 캐시 제거를 건너뜁니다.");
return;
}
event.productIds().forEach(productId -> {
try {
productCache.evict(productId);
log.info("products::{} 캐시 제거 완료", productId);
} catch (Exception e) {
log.warn("products::{} 캐시 제거 실패", productId);
}
});
}
}
회고
이번 주는 Redis를 도커로 띄웠지만 NoAuth 에러가 자꾸 발생하여 시간을 많이 빼았기는데다가, 인텔리제이에서 프로젝트를 빌드 못하는 이슈가 있어서 시간을 너무 많이 빼았겼습니다. NoAuth의 경우 local docker를 다른 포트에 띄우니 잘 되었고, 인텔리제이 프로젝트 빌드 못하는 것은 경로에 한글이 포함되어도 잘 되다가 갑자기 안되어 프로젝트를 다시 pull하여 build하니 잘 되었습니다.
그래도 과제를 하루 늦게 제출하여, 분산락과 캐싱에대해 학습을 할 수 있어 다행이었고, 항해 10 Pass를 못한 것은 아쉽지만, 꾸준하게 제가 챙겨갈 수 있는 몫을 챙겨가도록 하려합니다.
'IT > WIL' 카테고리의 다른 글
| 항해99 플러스 Lite 백엔드 과정 후기 (3) | 2025.08.10 |
|---|---|
| [항해 플러스 Lite] 7주차 회고 - Redis를 사용한 대기열 및 랭킹 조회 (4) | 2025.07.06 |
| [항해 플러스 Lite] 5주차 회고 - 서버 구축 DB Lock (0) | 2025.06.22 |
| [항해 플러스 Lite] 4주차 회고 - 서버 구축 DB 설계 (1) | 2025.06.15 |
| [항해 플러스 Lite] 3주차 회고 - 클린 아키텍처 (1) | 2025.06.08 |