
이번 주의 핵심 목표는 외부 인프라인 Redis를 활용하여, 다중 인스턴스 환경에서 발생할 수 있는 DB 병목 문제를 방지하는 구조를 설계하고 구현하는 것이었다.
이를 위해 적용했던 내용 중 다음 두 가지를 정리해보고자 한다.
/api/v1/products/popular API는 캐시가 없을 경우 매 요청마다 DB 조회가 발생기존의 주문 생성 로직은 하나의 메서드에 모든 책임이 집중되어 있었다:
@Transactional
public OrderResult createOrder(CreateOrderCommand command) {
for (CreateOrderCommand.OrderItemCommand item : command.items()) {
stockService.decrease(...);
productService.getProductDetail(...);
// ...
}
ApplyCouponResult result = couponUseCase.applyCoupon(...);
Order order = orderService.createOrder(...);
orderEventService.recordPaymentCompletedEvent(order);
return OrderResult.from(order);
}
이 구조는 다음과 같은 문제를 초래했다:
public OrderResult createOrder(CreateOrderCommand command) {
Order order = null;
try {
List<OrderItem> orderItems = orderItemCreator.createOrderItems(command.items());
Money discountedTotal = couponUseCase.calculateDiscountedTotal(command, orderItems);
order = orderService.createOrder(command.userId(), orderItems, discountedTotal);
orderEventService.recordPaymentCompletedEvent(order);
return OrderResult.from(order);
} catch (Exception e) {
compensationService.compensateStock(command.items());
if (order != null) {
compensationService.markOrderAsFailed(order.getId());
}
throw e;
}
}
@DistributedLock 어노테이션 기반 분산락 적용@Transactional로 분리, 보상은 REQUIRES_NEW로 안전하게 분리@DistributedLock(
prefix = "stock:decrease:",
key = "#command.productId + ':' + #command.size",
waitTime = 5,
leaseTime = 3
)
public void decrease(DecreaseStockCommand command) {
ProductStock stock = productStockRepository.findByProductIdAndSize(...);
if (stock.getStockQuantity() < command.quantity()) throw new ...
stock.decreaseStock(command.quantity());
productStockRepository.save(stock);
}
@Transactional(propagation = REQUIRES_NEW)
public void compensateStock(...) { ... }
@Transactional(propagation = REQUIRES_NEW)
public void markOrderAsFailed(String orderId) { ... }
이 설계로 다음과 같은 장점을 얻었다:
@Cacheable(sync = true)로 Cache Stampede 방지"popular:{days}:{limit}" 형태로 Key 구성@Transactional(readOnly = true)로 트랜잭션 오버헤드 제거@Cacheable(
value = "popularProducts",
key = "'popular:' + #criteria.days() + ':' + #criteria.limit()",
sync = true
)
public List<PopularProductResult> getPopularProducts(PopularProductCriteria criteria)
@Scheduled(cron = "0 0 3 * * *")
public void warmUpPopularProducts() {
List.of(new PopularProductCriteria(3, 5))
.forEach(productFacade::getPopularProducts);
}
| 항목 | 캐시 미적용 | 캐시 적용 | 비고 |
|---|---|---|---|
| 평균 응답시간 | 805.4ms | 7.17ms | 99% 감소 |
| RPS | 24.4 | 2734 | 112배 증가 |
| 요청 총합 | 253 | 27,369 | 108배 |
| DB 커넥션 | 10~11개 | 0에 수렴 | DB 부하 제거 |
| 실패율 | 0% | 0% | 안정적 |

(1) 처리량 비교 - popular vs without-cache
그래프 좌상단
/api/v1/products/popular)/api/v1/products/popular/without-cache)해석:
즉: 캐시 미적용은 DB 병목으로 RPS 제한 발생, 캐시는 병목 없이 처리량이 상승.
(2) 응답 시간 평균 (Latency 평균)
그래프 우상단
해석:
즉: 캐시 유무에 따른 latency 차이가 1,000ms 이상. 실사용자 체감으로는 "느림 vs 즉시 응답".
(3) 요청 합계 (Total Requests)
그래프 좌하단
해석:
즉: 캐시 없으면 일정 트래픽 이상부터 아예 요청 처리가 불가능(서비스 한계 도달).
(4) DB 활성 커넥션 수 (Active Connections)
그래프 우하단
해석:
즉: 캐시가 없으면 커넥션 풀 포화로 요청 대기/실패 가능성이 매우 높아짐.
sync = true + Pre-warming 조합이 실제 트래픽 환경에서 견고하게 작동함을 검증| 구분 | 기술 | 목적 | 설계 포인트 |
|---|---|---|---|
| 동시성 제어 | Redisson 기반 AOP 락 | 재고 차감 동시성 방지 | @DistributedLock, 트랜잭션 분리 |
| 보상 트랜잭션 | REQUIRES_NEW | 실패 복구 | OrderCompensationService 분리 |
| 캐싱 | @Cacheable(sync = true) | DB 병목 완화 | Redis + 캐시 키 전략 |
| 사전 캐시 | Scheduled Pre-warming | Cold Start 방지 | 새벽 정기 로딩 |
@Cacheable 선언이 아니라, TTL 관리, 키 전략, 예외 처리, preload 전략까지 포함한 종합 설계여야 한다이번 주는 단순한 과제 수행을 넘어서, 실제 프로덕션 환경에서 어떻게 동시성과 트래픽 이슈를 처리할 수 있을지 깊이 고민해본 시간이었다. 특히 트래픽이 급증할 때 시스템을 어떻게 안정적으로 운영할 수 있을지, 락과 트랜잭션의 경계는 어디까지 설정해야 하는지를 고민하다 보니 자연스럽게 SOLID 원칙, OOP, DDD, Testable Code 같은 개념들이 설계 관점에서 따라왔다.
처음엔 어려워 보였던 개념들이 사실은 "각자의 책임을 명확히 나누는 유연한 구조를 만들기 위한 생각의 확장"이라는 걸 체감할 수 있었다. 도메인이나 컴포넌트뿐 아니라 작은 클래스, 하나의 메서드, 심지어 파일 단위까지도 역할과 책임에 맞게 설계하는 것이 왜 중요한지 몸으로 느꼈다.
또한, 락과 트랜잭션 범위를 최소화하기 위한 고민 속에서 "실패했을 때 어떻게 처리할 것인가?"라는 질문에 자연스럽게 도달했고, 그 해답을 보상 트랜잭션이라는 형태로 도출했다. 외부 인프라에 의존하지 않고, 우선은 애플리케이션 레벨에서 try-catch 구조를 활용해 하나의 작업 단위를 구성하고, 실패 시에는 catch 블록에서 재고 복원, 상태 롤백과 같은 보상 로직을 명시적으로 실행했다.
또한 이벤트 기반 처리를 도입하여 정합성은 중요하나 락의 범위 밖으로 분리해도 되는 작업들(ex. 상태 변경, 기록 저장 등)은 @TransactionalEventListener를 통해 비동기적으로 처리함으로써 전체 트랜잭션의 부담을 줄이고자 했다. 현 시점에서 내가 가진 지식과 경험으로 적용할 수 있는 최선의 구조라고 판단했고, 실제로도 구조적으로 꽤 설득력 있는 결과를 도출해냈다.
무엇보다 인상 깊었던 점은, 단순히 코드가 돌아가게 만드는 것이 아니라 “운영 가능한 구조”로 만드는 것이 진짜 개발이라는 감각을 이번 주에 확실히 체득했다는 점이다.
결과적으로 이번 주는 실습 내용 이상의 인사이트를 얻은 주차였다. 설령 제출한 과제가 Fail을 받더라도 전혀 아쉬움이 없을 만큼 많은 것을 고민했고, 배웠고, 구현했다.