9osari.log
← 목록으로

[DevBid - 실시간통신] Redis 도입 후 마주한 문제와 해결

📚 실시간 경매 시스템 구축기 시리즈

  1. WebSocket으로 실시간 경매 구현하기
  2. WebSocket 메시지 동기화 문제와 Redis 선택
  3. Redis 분산락과 Pub/Sub으로 멀티서버 동시성 제어
  4. Redis 도입 후 마주한 문제와 해결 ← 현재 글
  5. 멀티서버 환경에서 Pub/Sub와 분산락 검증

개요

이전 글에서 Redis Pub/Sub, 분산락, 캐시 전략을 도입한 과정을 정리했는데, 이번 글에서는 실제 구현하며 겪었던 문제들과 해결과정을 정리해보려 합니다.

WebSocket으로 실시간 경매를 구현할 때는 ‘생각보다 쉬운데?’ 라고 생각했지만 완전 우물안 개구리.. 멀티 서버 도입을 가정하고 Redis를 도입했더니 예상치 못한 문제들이 쏟아졌다.

특히 다음과 같은 문제들을 마주했습니다.

이 글에서는 이러한 문제를 어떻게 발견하고 어떻게 해결했는지 구체적인 코드화 함께 정리하겠습니다.


트랜잭션과 분산락 순서 문제

문제상황

이전 글에서 언급한 분산락 + 트랜잭션 구조를 처음 구현할 때는 직관적으로 @Transcational안에 락 로직을 넣었다.

Jmeter로 동시성 테스트를 해보았더니 같은 금액으로 입찰이 중복 저장되는 현상이 발생

09:49:21.281 [exec-4] user4 INSERT bid (22000)
09:49:21.327 [exec-4] UPDATE auction (bid_count=17, current_bidder_id=10000009)

09:49:21.340 [exec-2] user5 INSERT bid (22000)  ← 같은 금액
09:49:21.350 [exec-2] UPDATE auction (bid_count=17, current_bidder_id=10000011)  ← bid_count도 17

원인분석

기존 코드는 트랜잭션 안에서 락을 획득하는 구조

// 잘못된 구조
@Transactional  // 트랜잭션이 먼저 시작
public BidPlacedEvent placeBid(Long auctionId, Long bidderId, BigDecimal bidAmount) {
    return auctionLockManager.executeWithLock(  // 그 다음 락 획득
        auctionId,
        () -> bidApplicationService.placeBid(auctionId, bidderId, bidAmount)
    );
}

실행흐름

AuctionApplicationService.placeBid()
@Transactional 시작 (DB 커넥션 획득)

executeWithLock()

tryLock() → 락 획득

비즈니스 로직 실행

finally → lock.unlock() → 락 해제

하지만 트랜잭션은 아직 진행 중

트랜잭션 commit (실제 DB 반영)

이 구조의 문제점

락 해제와 커밋 사이의 간극

데드락 발생 가능성

불필요하게 긴 트랜잭션

@Transactional
public void process() {
   executeWithLock(() -> {
       // 외부 API 호출
       // Redis Pub/Sub 발행
       // 파일 I/O
       // 복잡한 도메인 로직
       // 이 모든 게 트랜잭션 + 락 안에 있음
   });
}

왜 이런 구조를 선택했을까? 초기에는 다음과 같이 생각했다

하지만 실제로는

결론: 락을 먼저 획득하고 그 안에서 트랜잭션을 실행해야 한다.

해결 방법

락을 트랜잭션보다 먼저 획득 하도록 변경

AuctionFacade - 락 관리

public BidPlacedEvent placeBid(Long auctionId, Long bidderId, BigDecimal bidAmount) {
  return auctionLockManager.executeWithLock(
    auctionId,
    () -> auctionApplicationService.placeBid(auctionId, bidderId, bidAmount)
  );
}

AuctionApplicationService - 비즈니스 로직 및 트랜잭션

@Override
@Transactional
@CacheEvict(value = "auctionDetail", key = "#auctionId")
public BidPlacedEvent placeBid(Long auctionId, Long bidderId, BigDecimal bidAmount) {
    Auction auction = auctionRepository.findById(auctionId);
    User bidder = userRepository.findById(bidderId);

    Bid bid = auction.placeBid(bidder, new BidAmount(bidAmount)); //엔티티에 비즈니스 로직 위임

    bidRepository.save(bid);

    return BidPlacedEvent.of(auctionId,
            bidderId,
            bid.getBidder().getNickname().getValue(),
            bidAmount,
            auction.getBidCount()
    );
}

개선된 실행 흐름

락 획득

@Transactional 시작

비즈니스 로직 실행 (50ms)

트랜잭션 커밋

캐시 무효화

락 해제

개선효과

이제 다른 스레드가 락을 획득할 때는 이미 트랜잭션이 커밋된 상태이므로 최신 데이터를 읽을 수 있다..

항목이전개선 후
락 보유 시간락 대기 + 비즈니스 로직 + 커밋비즈니스 로직 + 커밋만
데이터 일관성❌ 락 해제 후 커밋으로 불일치 발생✅ 커밋 후 락 해제로 보장
데드락 위험⚠️ 트랜잭션 + 락 대기로 높음✅ 락 먼저 획득으로 낮음
커넥션 점유🔴 불필요하게 길음🟢 필요한 만큼만
처리량🔴 낮음🟢 향상

분산락과 캐시 무효화 순서

@CacheEvict와 분산락 함께 사용하기

@CacheEvict를 분산락과 함께 안전하게 사용하려면 TransactionAwareCacheManagerProxy 설정이 필요합니다.

CacheConfig 설정

@Bean
public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
    RedisCacheManager redisCacheManager = RedisCacheManager.builder(connectionFactory)
            .cacheDefaults(redisCacheConfiguration())
            .build();
    
    return new TransactionAwareCacheManagerProxy(redisCacheManager);
}

이 설정이 있으면 @CacheEvict트랜잭션 커밋 후에 실행되고, 락 범위 안에서 캐시가 삭제됩니다.

실행 흐름:

1. 락 획득
2. 트랜잭션 시작
3. 비즈니스 로직
4. 트랜잭션 커밋
5. 캐시 삭제 (after-commit)
6. 락 해제

장점:

@CacheEvict의 동작 원리

Spring의 트랜잭션 인식 캐시

@CacheEvict@Transactional과 함께 사용될 때 자동으로 커밋 후에 실행됩니다.

Spring의 TransactionAwareCacheDecorator

@Override
public void evict(final Object key) {
    if (TransactionSynchronizationManager.isSynchronizationActive()) {
        // 트랜잭션이 활성화되어 있으면
        TransactionSynchronizationManager.registerSynchronization(
            new TransactionSynchronization() {
                @Override
                public void afterCommit() {
                    // 커밋 후에만 캐시 삭제 실행
                    TransactionAwareCacheDecorator.this.targetCache.evict(key);
                }
            }
        );
    } else {
        // 트랜잭션이 없으면 즉시 실행
        this.targetCache.evict(key);
    }
}

실행 시나리오

@Transactional
@CacheEvict(value = "auctionDetail", key = "#auctionId")
public BidPlacedEvent placeBid(...) {
    // 1. 트랜잭션 시작
    // 2. CacheEvict가 TransactionSynchronization에 등록만 함
    // 3. 비즈니스 로직 실행
    // 4. 트랜잭션 커밋
    // 5. 커밋 성공 → 캐시 삭제 실행
    //    롤백 시 → 캐시 삭제 취소
}

장점:

단점:

결론

TransactionAwareCacheManagerProxy 설정으로 @CacheEvict를 분산락과 함께 안전하게 사용할 수 있습니다.


Redis 연결 끊김

Redis가 다운되면 입찰은 완료되지만 다른 서버로 알림이 전파되지 않는다.

redisTemplate.convertAndSend("auction.events", dto);
// Redis 에러 발생 → 입찰 자체가 실패

해결방법

try {
    redisTemplate.convertAndSend("auction.events", dto);
} catch (Exception e) {
    log.error("Redis 발행 실패 - 해당 서버 클라이언트만 알림 수신", e);
    // 입찰은 이미 완료됨
    // 해당 서버의 WebSocket 클라이언트들은 알림을 받음
    // 다른 서버의 클라이언트들만 알림 못 받음 (새로고침으로 확인 가능)
}

개선사항

더 안정적이 메시징이 필요하면 Spring Retry, Kafka 를 사용해보자

Kafka


분산락 타임아웃 조정

// 3초 대기, 5초 유지
lock.tryLock(3000, 5000, TimeUnit.MILLISECONDS);

Watch Dog 활성화

// leaseTime을 -1로 설정하면 Watch Dog 활성화
lock.tryLock(3000, -1, TimeUnit.MILLISECONDS);

Watch Dog의 동작

RLock lock = redissonClient.getLock("auction:lock:" + auctionId);
try {
    lock.tryLock(3000, -1, TimeUnit.MILLISECONDS);
    // 긴 비즈니스 로직...
} finally {
    if (lock.isHeldByCurrentThread()) {
        lock.unlock();  // 필수
    }
}

Redis 메모리 관리

문제 상황

Redis는 인메모리 DB라서 메모리 관리가 중요하다

경매 서비스 특성상

초기에는 TTL 없이 저장해서 종료된 경매 데이터가 계속 남아있었습니다.

캐시 종류별 메모리 관리

프로젝트에서 사용하는 캐시는 크게 두 종류

경매 상세 정보 캐시

용도: 경매 상세 조회 성능 향상 Redis 키: auctionDetail::{auctionId}

관리 방식: Spring Cache 추상화

// CacheConfig.java
@Bean
public RedisCacheConfiguration redisCacheConfiguration() {
    return RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(5))  // 5분 TTL
            .disableCachingNullValues();
}

메모리 관리 전략

경매 상세 캐시 - 이벤트 기반 무효화

// 조회 시 캐싱
@Cacheable(value = "auctionDetail", key = "#auctionId")
public AuctionListResponse getAuctionDetail(Long auctionId) {
    Auction auction = getAuctionById(auctionId);
    return convertToResponse(auction);
}

// 입찰/즉시구매 시 즉시 무효화
@Transactional
@CacheEvict(value = "auctionDetail", key = "#auctionId")
public BidPlacedEvent placeBid(Long auctionId, Long bidderId, BigDecimal bidAmount) {
    // 트랜잭션 커밋 후 자동으로 캐시 삭제
}

TransactionAwareCacheDecorator 란?

Spring이 제공하는 캐시 데코레이터로, @Transactional과 함께 사용 시 트랜잭션 커밋 후에 캐시 작업을 수행하고, 트랜잭션이 롤백되면 캐시 삭제도 취소되어 데이터 일관성을 보장

캐시 무효화 시나리오

  캐시 무효화 시나리오:
  1. 사용자 A가 경매 상세 조회 → 캐시 저장
  2. 사용자 B가 입찰 → @CacheEvict 실행
     ├─ 트랜잭션 시작
     ├─ DB 업데이트
     ├─ 트랜잭션 커밋
     └─ 캐시 삭제 (TransactionAwareCacheDecorator)
  3. 사용자 C가 조회 → DB에서 최신 데이터 조회 후 재캐싱

  트랜잭션 롤백 시
  1. 입찰 시도
  2. 비즈니스 로직 검증 실패 (예: 현재가보다 낮은 금액)
  3. 트랜잭션 롤백
  4. 캐시 삭제도 자동 취소 → 기존 캐시 유지

Pub/Sub 메시지 손실

Redis Pub/Sub은 메시지를 저장하지 않습니다.

DevBid 프로젝트 에서는

메시지가 유실되어도 치명적이지 않은 상황이였습니다.

안정적인 메시징이 필요 하다면

Redis Streams

Kafka


직렬화 이슈

에러상황

InvalidDefinitionException: Cannot construct instance of BidEventDto

원인

Jackson이 JSON을 역직렬화 할 때 기본생성자가 필요해 발생한 오류

해결

@Getter
@NoArgsConstructor  //Jackson이 객체 만들 때 사용
@AllArgsConstructor // @Builder가 내부적으로 필요함 둘 다 있어야 @Builder + Jackson 역직렬화 가능
public class BidEventDto {
    ...
}

마무리

이전 글에서 예고했던 “왜 이렇게 구현했는지”를 실제 문제 상황과 함께 정리해보았다.

핵심 교훈

1. 분산 환경의 복잡성은 예상보다 크다

2. Spring의 추상화를 이해하면 안전하게 사용할 수 있다

3. 장애는 격리되어야 한다

Redis Pub/Sub의 한계

DevBid에서는 Redis Pub/Sub으로 충분했지만, 프로덕션 수준의 안정성이 필요하다면..?

Redis Pub/Sub의 근본적 한계

그래서 Kafka를 고민하는 이유

앞으로의 학습 방향

이번 경험을 통해 “작동하는 것”과 “안정적으로 작동하는 것”의 차이를 배웠다.

다음 학습 목표

개선하고 싶은 부분

완벽하지는 않지만, 이 과정을 통해 분산 시스템의 복잡성과 실시간 통신 구현의 어려움을 체감할 수 있었습니다.
다음 포스팅에서는 AWS EC2 Redis 를 세팅 한 후 구현한 분산락과 Pub/Sub을 테스트할 예정입니다.

···
← PREV [DevBid - 실시간통신] Redis 분산락과 Pub/Sub 으로 멀티서버 동시성 제어 NEXT → [DevBid - 실시간통신] AWS EC2 Redis 환경에서 Pub/Sub와 분산락 검증