
이번 주차는 동시성 제어에서 한단계 더 나아가 Redis를 활용한 분산락에 대하여 학습하고, 프로젝트에 필요한 부분을 식별하여 적용하는 과정이었습니다.
또한, RedissonCacheManager 및 @Cacheable를 적용하여 캐싱을 통한 무거운 쿼리를 갖는 조회 로직에 적용하는 방법을 습득했습니다.
먼저, 분산락(Distributed Lock)이란, 서로 다른 서버 인스턴스에 대하여 일관된 락을 제공하기 위한 장치들을 뜻하며, 이번 과정에서는 Redis로 이를 구현했습니다.
분산락을 적용하기 전 주문에 대하여 여러 요청에 대한 동시성 테스트를 작성합니다.
상품 10개, 각 재고 10개, 총 100개의 충분한 상품을 설정한다.
사용자 10명, 주문 1건 당 상품 5가지를 골라, 각각 2개씩 구매한다.
총 10 5 2 = 100개로 재고와 딱 맞아 떨어지는 주문을 동시에 수행한다.
사용자 별 초과되지 않게 상품 5개를 고르는 로직
@Test
@DisplayName("재고가 10인 10개의 상품을 10명이서 5종류 씩 순서에 상관없이 2개씩 구매할때, 10개의 상품이 모두 정상적으로 판매된다.")
void placeOrders_concurrently() throws InterruptedException {
/* ... */
Map<Long, List<OrderItemCommand>> carts = new LinkedHashMap<>();
for (int u = 0; u < userCount; u++) {
Long userId = users.get(u).getId();
List<OrderItemCommand> original = new ArrayList<>();
for (int k = 0; k < picksPerUser; k++) {
int pIndex = (u + k) % productCount; // id 기준으로 1개씩 밀어서 장바구니 담기
Long productId = products.get(pIndex).getId();
original.add(new OrderItemCommand(productId, qtyPerPick));
}
List<OrderItemCommand> shuffled = new ArrayList<>(original);
Collections.shuffle(shuffled, new Random(userId)); // 랜덤으로 셔플
System.out.printf("사용자: %d,\t원본: [%s],\t랜덤: [%s]%n", userId, formatCart(original), formatCart(shuffled));
carts.put(userId, shuffled);
}
/* ... */
}
주문 시 상품의 순서는 무작위로 담는다.
쿠폰과 잔액으로 인한 실패는 배제한다.
OrderFacade 에서 그대로 처리sorted로 productId 기준 정렬을 하는 코드가 있어, 주석 후 실행
OrderFacade에서 productId로 sorted 수행(주석 해제)
@Transactional
public Order placeOrder(Long userId, List<OrderItemCommand> orderItems, Long couponId) {
User user = userService.findById(userId);
List<OrderItem> items = orderItems.stream()
.sorted(Comparator.comparing(OrderItemCommand::getProductId)) // 상품 순서 정렬
.map(command -> {
Product product = productService.verifyAndDecreaseStock(command.getProductId(), command.getQuantity());
return OrderItem.of(product, product.getPrice(), command.getQuantity(), 0);
})
.toList();
}

순서에 의한 데드락은 발생하지 않는 것으로 확인
하지만, 여전히 재고 차감은 기대치 만큼 발생하지 않음
경쟁 조건이 발생하여 재고 업데이트가 누락되는 것으로 추정
DistributedLock.java: 분산락 적용을 위한 커스텀 어노테이션
prefix: 도메인 별 lock을 획득하기 위한 key (LockKey.java)ids: 멀티 키를 위한 id배열, 주로 PK를 사용DistributedLockAspect.java: 커스텀 어노테이션 기반 AOP 구현 클래스
@Order(Ordered.HIGHEST_PRECEDENCE): 최우선 순위 보장
@Transactional은 LOWEST_PRECEDENCE로 등록됨lock함수: @DistributedLock 어노테이션이 붙은 메서드를 실행하기 전 후로, 분산락 처리
prefix와 ids를 기반으로 멀티 키를 획득, 문자열 사전 순 정렬
List<String> lockKeys = toKeys(prefixStr, idsVal).stream().sorted().toList(); // 키 목록 생성, 정렬
정렬 기반 순차 락(Sequential Sorted Locking)
@Around("@annotation(kr.hhplus.be.server.common.lock.DistributedLock)")
public Object lock(ProceedingJoinPoint joinPoint) throws Throwable {
/* ... */
List<RLock> acquiredLocks = new ArrayList<>();
try {
for (String key : lockKeys) {
RLock lock = redissonClient.getLock(key); // 정렬된 순서대로 락획득
boolean locked = lock.tryLock(
distributedLock.waitTimeoutMillis(),
distributedLock.ttlMillis(),
TimeUnit.MILLISECONDS
);
if (!locked) {
for (RLock l : acquiredLocks) {
try { l.unlock(); } catch (Exception ignore) {}
}
throw new IllegalStateException("Failed to acquire lock: " + key);
}
acquiredLocks.add(lock);
}
return joinPoint.proceed(); // 비즈니스 로직 실행
} finally {
Collections.reverse(acquiredLocks);
for (RLock l : acquiredLocks) {
try { l.unlock(); } catch (IllegalMonitorStateException e) {
log.info("Lock already unlocked: {}", l.getName());
}
}
}
}
멀티락(RedissonMultiLock)
@Around("@annotation(kr.hhplus.be.server.common.lock.DistributedLock)")
public Object lock(ProceedingJoinPoint joinPoint) throws Throwable {
/* ... */
RedissonMultiLock multiLock = new RedissonMultiLock(lockList.toArray(new RLock[0]));
try {
boolean locked = multiLock.tryLock(
distributedLock.waitTimeoutMillis(),
distributedLock.ttlMillis(),
TimeUnit.MILLISECONDS
);
if (!locked) {
throw new IllegalStateException("Failed to acquire multi lock: " + lockKeys);
}
return joinPoint.proceed();
} finally {
try {
multiLock.unlock();
} catch (IllegalMonitorStateException e) {
log.info("MultiLock already unlocked: keys={}", lockKeys);
}
}
}
toKeys함수: DistributedLock.ids() 값(단일값, 배열, Iterable)을 모두 List<String> 락 키 목록으로 변환
OrderFacade에서 사용되는 Product, Coupon, Balance, Order 중 다른 사용자와 경합이 많이 발생하는 Product로 분산락 적용함
Coupon, Balance, Order 기존 DB락으로 유지
로직 변경없이 @DistributedLock 적용
@Order(Ordered.HIGHEST_PRECEDENCE)로 구현되어 있어 @Transactional보다 먼저 진입, 나중에 해제됨
@Component
@RequiredArgsConstructor
public class OrderFacade {
/* ... */
@Transactional
@DistributedLock(prefix = LockKey.PRODUCT, ids = "#orderItems.![productId]")
public Order placeOrder(Long userId, List<OrderItemCommand> orderItems, Long couponId) {
/* ... */
List<OrderItem> items = orderItems.stream()
.sorted(Comparator.comparing(OrderItemCommand::getProductId)) // 상품 순서 정렬
.map(command -> {
Product product = productService.verifyAndDecreaseStock(command.getProductId(), command.getQuantity());
return OrderItem.of(product, product.getPrice(), command.getQuantity(), 0);
})
.toList();
/* ... */
}
}
비관적 락에서 조건부 업데이트 쿼리로 수정
분산락에서 productId 단위로 직렬화를 보장하기 때문에, DB 업데이트 시 원자적 차감만을 이용하여 성능적 이점을 확보함
public interface ProductRepository extends JpaRepository<Product, Long> {
/* 코드는 남아 있으나 미사용 */
@Query("""
UPDATE Product p
SET p.stock = p.stock - :qty
WHERE p.id = :id AND p.stock >= :qty
""")
@Modifying
int decreaseStockIfAvailable(@Param("id") Long id, @Param("qty") int qty);
}
verifyAndDecreaseStock 내부 로직 변경
@Service
@RequiredArgsConstructor
public class ProductService {
/* ... */
@Transactional
public Product verifyAndDecreaseStock(Long productId, int requiredQuantity) {
int updatedRows = productRepository.decreaseStockIfAvailable(productId, requiredQuantity);
if (updatedRows == 0) {
throw new IllegalStateException("상품 재고가 부족하거나 상품을 찾을 수 없습니다.");
}
return productRepository.findById(productId)
.orElseThrow(() -> new ProductNotFoundException("상품을 찾을 수 없습니다."));
}
}

public static final GenericContainer<?> REDIS = new GenericContainer<>(DockerImageName.parse("redis:7.2"))
.withExposedPorts(6379);RedissonClient을 Bean으로 등록 @Bean(destroyMethod = "shutdown")
public RedissonClient redissonClient() {
String addr = "redis://" + REDIS.getHost() + ":" + REDIS.getFirstMappedPort();
Config cfg = new Config();
cfg.useSingleServer()
.setAddress(addr)
.setConnectTimeout(10_000)
.setTimeout(3_000)
.setRetryAttempts(3)
.setRetryInterval(1_500)
.setPingConnectionInterval(1_000)
.setKeepAlive(true)
.setTcpNoDelay(true);
return Redisson.create(cfg);
}MySQL과 동일하게 종료 처리함@PreDestroy
public void shutdown() {
if (REDIS.isRunning()) REDIS.stop();
if (MYSQL.isRunning()) MYSQL.stop();
}@ActiveProfiles("test")로 동작함TopProductQueryServiceTest.java
테스트 흐름
더미 상품을 6가지 생성 후 3일 치 주문 아이템 생성 저장
웜업 1회 수행 후 20회 조회하여 요청 시간을 측정
반환된 결과 순위가 설정한 순위와 같은 지 확인
5개만 가져왔는 지 확인
@DisplayName("최근 3일(어제 ~ 그저께) Top5 상품을 Native Repository를 통해 조회한다")
@Test
void top5InLast3Days() {
queryService.top5InLast3Days(); // 1회 수행
// 쿼리 수행 20회 기록
StopWatch sw = new StopWatch();
sw.start("top5-query-x20");
List<TopProductView> result = new ArrayList<>();
for (int i = 0; i < 20; i++) {
result = queryService.top5InLast3Days();
}
sw.stop();
System.out.println(sw.prettyPrint());
// 순위 검증
assertThat(result).hasSize(5);
assertThat(result.get(0).soldQty()).isGreaterThanOrEqualTo(result.get(1).soldQty());
assertThat(result.get(1).soldQty()).isGreaterThanOrEqualTo(result.get(2).soldQty());
assertThat(result.get(2).soldQty()).isGreaterThanOrEqualTo(result.get(3).soldQty());
assertThat(result.get(3).soldQty()).isGreaterThanOrEqualTo(result.get(4).soldQty());
List<Long> ids = result.stream().map(TopProductView::productId).toList();
assertThat(ids).containsExactlyInAnyOrderElementsOf(
productRepository.findAll().stream()
.filter(p -> List.of("p1", "p2", "p3", "p4", "p5").contains(p.getName()))
.map(Product::getId)
.toList()
);
}
seedOrderItems함수: 상품과 총량을 역으로 3일에 나눠서 주문 아이템 생성
persistItems 함수: 상품, 수량, 생성 일자(orderedDate), 생성 시간(orderedAt)으로 1일치 주문 아이템을 횟수만큼 생성
조회용 도메인 분리 analytics
Product 도메인에 있었던 ProductStatistics를 제거하고, 상위 판매 상품 조회용 도메인을 분리함.JPA repository가 아닌 Native repository로 조회용 쿼리만 수행.레이어 배치만 수행한 후 테스트 코드 컴파일만 통과하도록 임시 함수로 구현함(빈 배열 반환)

TopProductNativeRepository.java
시작일자, 종료일자, 획득할 상품수를 받아 상위 상품 목록을 반환하는 함수 구현



@CacheConfig(cacheNames = CacheNames.TOP_PRODUCTS) 클래스 레벨 지정하여, 각 함수에서 생략
기본 기능 top5InLast3Days 는 확장 가능한 topNLastNDays함수를 호출하되, 클래스 내부에서 호출하므로, 각각 @Cacheable을 적용
top5InLast3Days는 3일 5개로 고정된 키 발행, topNLastNDays는 파라미터에 의해 발행
@EnableCaching 으로 캐싱 활성화, Bean으로 등록된 RedissonCacheConfig > cacheManager 로 TTL 적용
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
@CacheConfig(cacheNames = CacheNames.TOP_PRODUCTS)
public class TopProductQueryService {
private final TopProductNativeRepository repository;
@Cacheable(
key = "T(kr.hhplus.be.server.common.cache.CacheKey).TOP_PRODUCTS"
+ ".key('LAST_N_DAYS', 3, 'TOP', 5)",
sync = true
)
public List<TopProductView> top5InLast3Days() {
return topNLastNDays(3, 5);
}
@Cacheable(
key = "T(kr.hhplus.be.server.common.cache.CacheKey).TOP_PRODUCTS"
+ ".key('LAST_N_DAYS', #days, 'TOP', #limit)",
sync = true
)
public List<TopProductView> topNLastNDays(int days, int limit) {
if (days <= 0) throw new IllegalArgumentException("days 는 1 이상이어야 합니다.");
if (limit <= 0) throw new IllegalArgumentException("limit 는 1 이상이어야 합니다.");
LocalDate today = LocalDate.now(); // 오늘을 제외하고,
LocalDate from = today.minusDays(days);
LocalDate to = today.minusDays(1);
return repository.findTopSoldBetween(from, to, limit);
}
}



테스트 수행 결과

