항해 백엔드 6주차 회고(WIL): 다중 인스턴스 환경에서 동시성 제어와 캐싱 최적화

JUNYOUNG·2025년 5월 9일

항해 플러스 백엔드

목록 보기
11/14
post-thumbnail

이번 주 목표

이번 주의 핵심 목표는 외부 인프라인 Redis를 활용하여, 다중 인스턴스 환경에서 발생할 수 있는 DB 병목 문제를 방지하는 구조를 설계하고 구현하는 것이었다.

이를 위해 적용했던 내용 중 다음 두 가지를 정리해보고자 한다.

  • Redisson 기반 분산락을 적용한 주문 생성 등 로직 리팩토링
  • @Cacheable(sync = true) 기반의 캐시 적용 + Pre-warming 전략 도입

문제 정의

1. 재고 차감 로직에서의 경쟁 조건

  • 기존 구조는 단일 인스턴스, 단일 트랜잭션을 전제로 작성되어 있어 다중 인스턴스 환경에서 Race Condition 발생 가능성이 있었다.
  • 예: 두 사용자가 동시에 동일 상품을 주문할 경우 재고 차감이 중복되어 음수 재고 발생

2. 인기 상품 조회에서 발생하는 성능 병목

  • /api/v1/products/popular API는 캐시가 없을 경우 매 요청마다 DB 조회가 발생
  • 트래픽 증가 시 DB 병목으로 응답 시간 급증처리량 한계 발생

해결 전략 및 설계 방식


STEP11 - Redisson 기반 분산락 + 보상 트랜잭션 설계

기존의 주문 생성 로직은 하나의 메서드에 모든 책임이 집중되어 있었다:

@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 어노테이션 기반 분산락 적용
  • 락 범위를 stockService.decrease() 수준으로 최소화
  • 트랜잭션은 @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);
}

보상 트랜잭션 처리(OrderCompensationService)

@Transactional(propagation = REQUIRES_NEW)
public void compensateStock(...) { ... }

@Transactional(propagation = REQUIRES_NEW)
public void markOrderAsFailed(String orderId) { ... }

이 설계로 다음과 같은 장점을 얻었다:

  • 락 보유 시간 최소화 (딱 필요한 재고 차감 로직에만 적용)
  • 각 책임이 응용 컴포넌트에 따라 명확히 분리
  • 장애 발생 시 재고 보상 및 주문 상태 전이로 정합성 유지

STEP12 - Cache 성능 개선 보고서


문제 인식

  • 인기 상품 API는 조건이 거의 동일한 반복 호출이 많지만, 캐시가 없으면 DB Full Scan
  • 캐시가 없을 경우 800ms 이상의 평균 응답시간
  • 부하 테스트 시 DB 커넥션 10~11개까지 소모 → 병목 집중

캐싱 전략 설계

  • @Cacheable(sync = true)Cache Stampede 방지
  • "popular:{days}:{limit}" 형태로 Key 구성
  • Redis 기반 캐시 적용
  • @Transactional(readOnly = true)로 트랜잭션 오버헤드 제거
@Cacheable(
    value = "popularProducts",
    key = "'popular:' + #criteria.days() + ':' + #criteria.limit()",
    sync = true
)
public List<PopularProductResult> getPopularProducts(PopularProductCriteria criteria)

Pre-warming 전략

@Scheduled(cron = "0 0 3 * * *")
public void warmUpPopularProducts() {
    List.of(new PopularProductCriteria(3, 5))
        .forEach(productFacade::getPopularProducts);
}
  • 새벽 3시에 인기 상품 캐시를 사전 로딩
  • 사용자가 접근하는 시점엔 이미 캐시가 채워져 있음
  • Cold Start latency 제거 + Stampede 방지 보완

k6 + Prometheus + Grafana 기반 성능 측정

항목캐시 미적용캐시 적용비고
평균 응답시간805.4ms7.17ms99% 감소
RPS24.42734112배 증가
요청 총합25327,369108배
DB 커넥션10~11개0에 수렴DB 부하 제거
실패율0%0%안정적

(1) 처리량 비교 - popular vs without-cache

그래프 좌상단

  • 파란색: 캐시 적용 (/api/v1/products/popular)
  • 빨간색: 캐시 미적용 (/api/v1/products/popular/without-cache)

해석:

  • 캐시 미적용 상태는 RPS가 거의 0~20 수준. 요청 처리량이 매우 낮음.
  • 캐시 적용 상태는 구간에 따라 최대 4,000 RPS까지 도달.
  • 명백하게 캐시 사용 시 처리량이 수십~수백 배 증가. DB 병목 해소됨.
  • 시간대별로 부하 주기가 있어서 피크 때 성능 차이가 극명하게 드러남.

즉: 캐시 미적용은 DB 병목으로 RPS 제한 발생, 캐시는 병목 없이 처리량이 상승.


(2) 응답 시간 평균 (Latency 평균)

그래프 우상단

  • 빨간색: 캐시 미적용
  • 파란색: 캐시 적용

해석:

  • 캐시 미적용 요청은 평균 1.6~1.8초(!) → 심각한 지연.
  • 캐시 적용 요청은 거의 0초(밀리초 단위) → 응답 시간 극히 짧음.
  • 부하가 올라가도 캐시 적용 쪽은 응답 시간 유지, 캐시 미적용은 부하가 커질수록 지연 심화.

즉: 캐시 유무에 따른 latency 차이가 1,000ms 이상. 실사용자 체감으로는 "느림 vs 즉시 응답".


(3) 요청 합계 (Total Requests)

그래프 좌하단

  • 파란색: 캐시 적용
  • 빨간색: 캐시 미적용

해석:

  • 캐시 적용은 최대 260,000건 요청 소화.
  • 캐시 미적용은 20,000건 이하 수준으로 정체.
  • 캐시 미적용 상태는 부하가 걸릴수록 처리 가능한 요청 수가 급격히 줄어드는 현상 발생.

즉: 캐시 없으면 일정 트래픽 이상부터 아예 요청 처리가 불가능(서비스 한계 도달).


(4) DB 활성 커넥션 수 (Active Connections)

그래프 우하단

  • 녹색: 활성 DB 커넥션

해석:

  • 캐시 미적용 요청에서만 활성 커넥션이 10~11개로 증가 → DB에 병목이 집중됨.
  • 캐시 적용 요청에서는 커넥션 증가 거의 없음 → 캐시만으로 응답 처리.
  • DB 풀 한계치 근처까지 활성 커넥션 사용 → 초과 시 커넥션 풀 exhaust 가능.

즉: 캐시가 없으면 커넥션 풀 포화로 요청 대기/실패 가능성이 매우 높아짐.

성능 결론

  • 처리량 증가, latency 감소, DB 부하 완화 → 캐시 전략 효과 실증 완료
  • sync = true + Pre-warming 조합이 실제 트래픽 환경에서 견고하게 작동함을 검증

요약 정리

구분기술목적설계 포인트
동시성 제어Redisson 기반 AOP 락재고 차감 동시성 방지@DistributedLock, 트랜잭션 분리
보상 트랜잭션REQUIRES_NEW실패 복구OrderCompensationService 분리
캐싱@Cacheable(sync = true)DB 병목 완화Redis + 캐시 키 전략
사전 캐시Scheduled Pre-warmingCold Start 방지새벽 정기 로딩

인사이트

  • 분산 환경에서의 동시성은 단순 트랜잭션만으로 방어 불가 → 분산락 + 구조적 분리 필요
  • 락은 보유 범위가 짧을수록 좋고, 트랜잭션과 분리해서 책임을 명확히 해야 유지보수 가능
  • 캐시 전략은 단순 @Cacheable 선언이 아니라, TTL 관리, 키 전략, 예외 처리, preload 전략까지 포함한 종합 설계여야 한다
  • 성능 개선은 “감”이 아니라 “측정 기반”으로 접근해야 확실한 개선이 가능하다

총평

이번 주는 단순한 과제 수행을 넘어서, 실제 프로덕션 환경에서 어떻게 동시성과 트래픽 이슈를 처리할 수 있을지 깊이 고민해본 시간이었다. 특히 트래픽이 급증할 때 시스템을 어떻게 안정적으로 운영할 수 있을지, 락과 트랜잭션의 경계는 어디까지 설정해야 하는지를 고민하다 보니 자연스럽게 SOLID 원칙, OOP, DDD, Testable Code 같은 개념들이 설계 관점에서 따라왔다.

처음엔 어려워 보였던 개념들이 사실은 "각자의 책임을 명확히 나누는 유연한 구조를 만들기 위한 생각의 확장"이라는 걸 체감할 수 있었다. 도메인이나 컴포넌트뿐 아니라 작은 클래스, 하나의 메서드, 심지어 파일 단위까지도 역할과 책임에 맞게 설계하는 것이 왜 중요한지 몸으로 느꼈다.

또한, 락과 트랜잭션 범위를 최소화하기 위한 고민 속에서 "실패했을 때 어떻게 처리할 것인가?"라는 질문에 자연스럽게 도달했고, 그 해답을 보상 트랜잭션이라는 형태로 도출했다. 외부 인프라에 의존하지 않고, 우선은 애플리케이션 레벨에서 try-catch 구조를 활용해 하나의 작업 단위를 구성하고, 실패 시에는 catch 블록에서 재고 복원, 상태 롤백과 같은 보상 로직을 명시적으로 실행했다.

또한 이벤트 기반 처리를 도입하여 정합성은 중요하나 락의 범위 밖으로 분리해도 되는 작업들(ex. 상태 변경, 기록 저장 등)은 @TransactionalEventListener를 통해 비동기적으로 처리함으로써 전체 트랜잭션의 부담을 줄이고자 했다. 현 시점에서 내가 가진 지식과 경험으로 적용할 수 있는 최선의 구조라고 판단했고, 실제로도 구조적으로 꽤 설득력 있는 결과를 도출해냈다.

무엇보다 인상 깊었던 점은, 단순히 코드가 돌아가게 만드는 것이 아니라 “운영 가능한 구조”로 만드는 것이 진짜 개발이라는 감각을 이번 주에 확실히 체득했다는 점이다.

결과적으로 이번 주는 실습 내용 이상의 인사이트를 얻은 주차였다. 설령 제출한 과제가 Fail을 받더라도 전혀 아쉬움이 없을 만큼 많은 것을 고민했고, 배웠고, 구현했다.

profile
Onward, Always Upward - 기록은 성장의 증거

0개의 댓글