9osari.log
← 목록으로

[DevBid - 실시간통신] Redis 분산락과 Pub/Sub 으로 멀티서버 동시성 제어

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

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

멀티 서버 환경에서의 메시지 동기화

이전 글에서 WebSocket을 이용한 실시간 입찰 시스템을 구축했습니다. 단일 서버 환경에서는 완벽하게 동작했지만, 트래픽이 증가하여 서버를 스케일 아웃하면 문제가 발생합니다.

무엇이 문제 인가?

현재 구조에서는 각 서버가 독립적으로 WebSocket 세션을 관리합니다.

결국 사용자들이 서로 다른 서버에 분산 연결되어 있으면 실시간 동기화가 깨지는 문제가 발생합니다.


해결 방법: Redis Pub/Sub 도입

이 문제를 해결하기 위해 모든 서버가 동일한 이벤트 스트림을 공유하도록 Redis Pub/Sub을 도입했습니다.

[클라이언트A] ─ WebSocket ─┐
                            ├─ [Server 1] ─┐
[클라이언트B] ─ WebSocket ─┘              │
                                          ├─ Redis Pub/Sub
[클라이언트C] ─ WebSocket ─┐              │  (auction.events 채널)
                            ├─ [Server 2] ─┘
[클라이언트D] ─ WebSocket ─┘

이제 어떤 서버에서 입찰/즉시구매 가 발생하든, Redis 채널을 통해 모든 서버에 전파되고, 각 서버는 자신에게 연결된 WebSocket 클라이언트들에게 브로드캐스트 됩니다.


Redis 설치 및 설정

Mac: brew install redis

Windows: https://github.com/microsoftarchive/redis/releases

설치 후 redis-server 명령으로 실행하면 기본 포트 6379로 실행

PING - PONG 확인


의존성 추가

implementation("org.springframework.boot:spring-boot-starter-data-redis")
implementation("org.springframework.boot:spring-boot-starter-cache")
implementation("org.redisson:redisson-spring-boot-starter:3.25.0")
data:
  redis:
    host: localhost
    port: 6379
    password: ${REDIS_PASSWORD}

Redis 클라이언트 선택

DevBid에서는 Redis를 3가지 용도로 사용하며, 각 용도에 맞는 클라이언트를 선택했습니다.

용도클라이언트선택 이유
캐싱LettuceSpring Boot 기본 클라이언트, 비동기 지원
Pub/SubLettuceSpring 기본 재공 RedisMessageListenerContainer 활용
분산락Redisson검증된 분산락 구현, Watch Dog 등 고급 기능

Lettuce만 사용할 수도 있지만

Redisson을 추가한 이유

캐싱/Pub/Sub 은 Lettuce로 충분하지만, 분산락을 Redisson의 검증된 구현을 사용하는 것이 더 안전합니다.


Lettuce 설정 - 캐싱 & Pub/Sub

Spring Boot는 기본적으로 Lettuce를 사용하며, 이를 통해 캐싱과 Pub/Sub 기능을 모두 처리합니다.

Lettuce의 장점

Jedis와의 비교

RedisTemplate 설정 코드

@Bean
public RedisTemplate redisTemplate() {
    RedisTemplate template = new RedisTemplate<>();
    template.setConnectionFactory(redisConnectionFactory());

    // Key는 String으로 직렬화
    template.setKeySerializer(new StringRedisSerializer());
    template.setHashKeySerializer(new StringRedisSerializer());

    // Value는 JSON으로 직렬화
    ObjectMapper objectMapper = new ObjectMapper();
    objectMapper.registerModule(new JavaTimeModule());
    objectMapper.disable(SerializationFeature.WRITE_DATE_KEYS_AS_TIMESTAMPS);

    GenericJackson2JsonRedisSerializer serializer =
            new GenericJackson2JsonRedisSerializer(objectMapper);

    template.setValueSerializer(serializer);
    template.setHashValueSerializer(serializer);

    return template;
}

왜 커스텀 설정이 필요할까 ?

기본 설정으로 Redis에 값을 저장하면 바이너리 형태로 보이기 때문에 사람이 읽기 어렵습니다.


Redisson 설정 - 분산락 전용

Lettuce로도 분산락을 구현할 수 있지만, Redisson이 제공하는 검증된 분산락 구현을 사용하기로 결정

@Bean
public RedissonClient redissonClient() {
    Config config = new Config();
    config.useSingleServer()
        .setAddress("redis://" + redisHost + ":" + redisPort)
        .setPassword(redisPassword);
    return Redisson.create(config);
}

Redisson의 효율적인 락 대기 방식

분산 락을 구현할 때, 락을 얻지 못한 스레드가 어떻게 대기할지가 중요한 문제입니다.

두 가지 대기 방식

1. 스핀락 (Spin Lock)

while (!tryLock()) {
    Thread.sleep(50);  // 계속 재시도
}

2. Pub/Sub 방식

[Thread A]         [Redis]           [Thread B]
    |                 |                   |
    |--tryLock()----> |                   |
    |<--락 획득 성공--  |                   |
    |                 | <--tryLock()------| (락 대기)
    |                 | --채널 구독------->| (폴링 없음!)
    |--unlock()-----> |                   |
    |                 | --PUBLISH-------->|
    |                 | <--락 재시도------|
    |                 | --락 획득 성공---->|

Redisson의 RLock은 내부적으로 Pub/Sub 방식을 사용합니다. 대기 중인 스레드들이 Redis 채널을 구독하고, 락이 해제되면 PUBLISH 메시지를 받아 깨어나는 방식으로 동작합니다.


Pub/Sub 설정 - 실시간 알림 전파

Publisher (발행자): 메시지를 Redis 채널에 발행

Subscriber (구독자): Redis 채널을 구독하고, 메시지 받으면 처리

Channel (채널): 주제별로 나눔 (예: auction.bid, auction.buyout)

@Bean
public ChannelTopic auctionTopic() {
    return new ChannelTopic("auction.events");   //Topic 생성
}

@Bean
RedisMessageListenerContainer redisMessageListenerContainer(
//메시지 리스너 등록
    RedisConnectionFactory connectionFactory,
    AuctionRedisSubscriber subscriber,
    ChannelTopic auctionTopic) {

    RedisMessageListenerContainer container = new RedisMessageListenerContainer();
    container.setConnectionFactory(connectionFactory);
    container.addMessageListener(subscriber, auctionTopic);
    return container;
}

Publisher - 이벤트 발행

입찰이 완료되면 Controller에서 이벤트를 발행하고, EventHandlerRedis 채널로 전파합니다.

// AuctionController
@PostMapping("/{auctionId}/bid")
public ResponseEntity<BidPlacedEvent> placeBid(
    @PathVariable Long auctionId,
    @AuthenticationPrincipal CustomUserDetails userDetails,
    @RequestBody @Valid BidRequest request) {

    // Service는 순수 비즈니스 로직만 처리
    BidPlacedEvent event = auctionApplicationService.placeBid(auctionId, authUser.getId(), bidAmount);

    // Controller가 이벤트 발행 책임
    eventPublisher.publishEvent(event);

    return ResponseEntity.ok(event);
}

Controller에서 발행할까?

EventHandler에서 Redis Pub/Sub 전파

@EventListener  // 트랜잭션 밖에서 이미 발행되므로 단순 @EventListener 사용
public void handleBidPlacedEvent(BidPlacedEvent event) {
    log.info("입찰 이벤트 수신: {}", event);

    BidEventDto dto = BidEventDto.fromBid(
        event.getAuctionId(),
        event.getCurrentPrice(),
        event.getBidderNickname(),
        event.getBidderId(),
        event.getBidCount()
    );

    // Redis에 최신 입찰 정보 저장
    auctionCacheManager.setLatestBid(event.getAuctionId(), dto);

    try {
        // Redis Pub/Sub로 전파
        redisTemplate.convertAndSend("auction.events", dto);
        log.info("Redis 발행 완료: {}", event.getAuctionId());
    } catch (Exception e) {
        log.error("Redis 발행 실패", e);
    }
}

Redis-cli 확인


Subscriber - 메시지 수신 → WebSocket 전달

public class AuctionRedisSubscriber implements MessageListener {

    private final SimpMessagingTemplate messagingTemplate;
    private final ObjectMapper objectMapper;

    @Override
    public void onMessage(Message message, byte[] pattern) {
        try {
            //Redis에서 받은 메세지 파싱
            String body = new String(message.getBody());
            log.info("원본 메세지: {}", body);

            BidEventDto dto = objectMapper.readValue(body, BidEventDto.class);
            log.info("파싱 성공: {}", dto);

            //WebSocket으로 전송
            messagingTemplate.convertAndSend("/topic/auctions/" + dto.getAuctionId(), dto);
            log.info("WebSocket 전송 완료: auctionId: {}", dto.getAuctionId());
        } catch (Exception e) {
            log.error("Redis 메세지 처리 실패", e);
        }
    }
}

분산락으로 동시성 제어 시나리오

3명이 동시에 입찰한다고 가정하면

현재가: 10,000원

[User A]                    [User B]                    [User C]
11,000원 입찰 →            11,000원 입찰 →              11,000원 입찰 →
  ↓                            ↓                          ↓
락 획득 ✓ (0ms)             락 대기 중...                락 대기 중... 

캐시 삭제

DB 조회: 10,000원          

검증 통과 ✓

저장 + 커밋 (106ms)

락 해제 (106ms) ──────────> 락 획득 ✓ (106ms 대기)     (계속 대기 중... )

                           캐시 삭제

                           DB 조회: 11,000원

                           검증 실패
                        "현재가보다 높게"

                           락 해제 (212ms) ──────────> 2초 경과

                                                       락 획득 실패

                                                예외: "다른 사용자가 처리 중입니다.
                                                잠시 후 다시 시도해주세요."

Redisson RLock 적용

AuctionFacade.java

@Slf4j
@Component
@RequiredArgsConstructor
public class AuctionFacade {
    private final AuctionApplicationService auctionApplicationService;
    private final AuctionLockManager auctionLockManager;
  
    public BidPlacedEvent placeBid(Long auctionId, Long bidderId, BigDecimal bidAmount) {
        return auctionLockManager.executeWithLock(
          auctionId,
          () -> auctionApplicationService.placeBid(auctionId, bidderId, bidAmount)
        );
    }
}

AuctionApplicationService.java

@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()
    );
}

finally에서 안전한 unlock

AuctionLockManager.java

@Component
@RequiredArgsConstructor
public class AuctionLockManager {
  private final RedissonClient redissonClient;

  public <T> T executeWithLock(Long key, Callable<T> task) {
      RLock lock = getLock(key);
      try {
          acquireLockOrThrow(lock);
          return task.call();

      } catch (InterruptedException e) {
          Thread.currentThread().interrupt();
          throw new IllegalStateException(e);

      } catch (IllegalStateException  e) {
          throw e;   // 비즈니스 로직 예외는 그대로 전파

      } catch (Exception e) {
          throw new IllegalStateException("락 실행 중 오류", e);

      }  finally {
          if(lock.isHeldByCurrentThread()) { //현재 스레드가 락을 가지고있는지 확인
              lock.unlock();
          }
      }
  }

  private RLock getLock(Long auctionId) {
      return redissonClient.getLock("auction:lock:" + auctionId); //락을 가져옴
  }

  private static void acquireLockOrThrow(RLock lock) throws InterruptedException {
			// waitTime(1초): 락 획득 대기 시간 - 입찰은 빠른 응답이 중요하므로 짧게 설정
	    // leaseTime(3초): 락 유지 시간 - 일반 입찰 처리(100~500ms)보다 여유있게 설정
	    // 서버 장애 시에도 3초 후 자동 해제되어 다른 요청 처리 가능
      boolean acquired = lock.tryLock(1000, 3000, TimeUnit.MILLISECONDS);
      if(!acquired) {
          throw new IllegalStateException("다른 사용자가 처리 중입니다. 잠시 후 다시 시도해주세요.");
      }
  }
}

왜 락 소유 확인이 필요할까?

락 획득 실패했거나 타임아웃으로 자동 해제된 경우에 unlock() 호출하면 예외가 발생하니까, isHeldByCurrentThread()로 체크하도록 구현했습니다. 특히 멀티 쓰레드 환경에서는 lock이 이미 자동 해제된 상태일 가능성이 있어, 이 검증이 없다면 unlock()에서 예외가 발생할 수 있습니다.

입찰 처리 + 분산락 전체 흐름

입찰 요청이 서버에 도착한 시점부터 WebSocket으로 전파되기까지의 전체 흐름입니다.

1. 입찰 요청
   └─> AuctionController.bid()
       └─> AuctionFacade.placeBid()

2. 분산락 획득
   └─> RedissonClient.getLock("auction:lock:{auctionId}")
       └─> tryLock(2초 대기, 3초 유지)
       └─> 획득 실패 시 예외 처리

3. 트랜잭션 시작 및 비즈니스 로직 처리
   └─> AuctionApplicationService.placeBid() (@Transactional + @CacheEvict)

4. 트랜잭션 커밋

5. 캐시 삭제 (after-commit, TransactionAwareCacheManagerProxy)

6. 분산락 해제

7. Controller에서 이벤트 발행
   └─> eventPublisher.publishEvent(event)
   └─> **트랜잭션 커밋 + 락 해제 후 발행으로 안전성 보장**

8. EventHandler에서 이벤트 수신 (@EventListener)
    └─> AuctionEventHandler.handleBidPlacedEvent()
        ├─ BidEventDto 생성 (현재가, 입찰자, 입찰횟수)
        ├─ auctionCacheManager.setLatestBid()
        │   └─> Redis에 최신 입찰 정보 캐싱 (1시간 TTL)
        └─> redisTemplate.convertAndSend("auction.events", dto)

9. Redis Pub/Sub 발행
    └─> "auction.events" 채널로 메시지 전파
    └─> 멀티 서버 환경에서 모든 서버로 동시 전달

10. 모든 서버에서 구독
    └─> AuctionRedisSubscriber.onMessage()
        └─> 각 서버가 메시지 수신
        └─> messagingTemplate.convertAndSend("/topic/auctions/{id}", dto)

11. WebSocket 브로드캐스트
    └─> 각 서버에 연결된 WebSocket 클라이언트들에게 실시간 전송
    └─> 브라우저에서 즉시 입찰 정보 업데이트

캐싱 전략

자주 조회되는 데이터는 Redis에 캐싱해 DB 부하를 줄였습니다.

경매 상세 정보 캐싱

조회 시에는 Spring의 @Cacheable 로 자동캐싱하고, 입찰/즉시구매 시에는 @CacheEvict 으로 트랜잭션 커밋 후 캐시를 삭제합니다.

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

Spring Cache의 장점

캐시 설정 (CacheConfig.java)

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

    return new TransactionAwareCacheManagerProxy(redisCacheManager);
}

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

AuctionCacheManager - 최신 입찰 정보 캐싱

Spring Cache는 경매 상세 정보를 캐싱하고, AuctionCacheManager는 실시간으로 변하는 최신 입찰 정보를 별도로 저장합니다.

@Component
public class AuctionCacheManager {

    // 최신 입찰 정보 저장 (1시간 TTL)
    public void setLatestBid(Long auctionId, BidEventDto bidEvent) {
        String redisKey = "auction:" + auctionId + ":latestBid";
        redisTemplate.opsForValue().set(redisKey, bidEvent, 1, TimeUnit.HOURS);
    }
}

왜 별도로 관리할까? 경매 상세 정보와 최신 입찰 정보는 업데이트 패턴이 달라 서로 다른 캐싱 전략이 필요합니다.

구분경매 상세 정보최신 입찰 정보
내용제목, 설명, 이미지현재가, 입찰자, 횟수
변경 빈도거의 안 바뀜매우 자주 바뀜
캐싱 방식Spring Cache (5분 TTL)Redis 직접 저장 (1시간 TTL)
무효화입찰 시 삭제 (락 안)입찰 시 갱신 (이벤트)
전파 방식다음 조회 시 재생성Redis Pub/Sub으로 즉시 전파

캐시 무효화는 언제? 입찰이 발생하면

  1. 경매 상세 정보: 락 안에서 삭제 (다음 조회 시 DB에서 재생성)
  2. 최신 입찰 정보: 이벤트 핸들러에서 갱신 후 Pub/Sub으로 전파

마무리

DevBid 프로젝트를 멀티 서버 환경으로 확장하면서 Redis를 도입한 과정을 정리해보았습니다.

멀티서버 확장을 가정하고 Redis를 도입하면서 캐싱으로 성능 개선을 할 수 있었고, Spring의 추상화 레이어 덕분에 복잡한 인프라 코드 대신 비즈니스 로직에 집중할 수 있었던 것이 인상깊었습니다. 코드가 눈에 띄게 깔끔해졌습니다.

참고: 현재 Redis 관련 기능은 통합 테스트 없이 로컬 환경에서만 검증했습니다. Testcontainers를 활용한 Docker 기반 테스트 환경 구축은 추후 별도 포스팅으로 다룰 예정입니다.

이번 글에서는 “무엇을, 어떻게” 구현했는지 정리했다면, 다음 포스팅에서는 “왜 이렇게 구현했는지” 에 집중하려고 합니다.

···
← PREV [DevBid - 실시간통신] 멀티 서버 환경에서 Redis Pub/Sub이 필요한 이유 NEXT → [DevBid - 실시간통신] Redis 도입 후 마주한 문제와 해결