[DevBid - 실시간통신] Redis 분산락과 Pub/Sub 으로 멀티서버 동시성 제어
📚 실시간 경매 시스템 구축기 시리즈
- WebSocket으로 실시간 경매 구현하기
- WebSocket 메시지 동기화 문제와 Redis 선택
- Redis 분산락과 Pub/Sub으로 멀티서버 동시성 제어 ← 현재 글
- Redis 도입 후 마주한 문제와 해결
- 멀티서버 환경에서 Pub/Sub와 분산락 검증
멀티 서버 환경에서의 메시지 동기화
이전 글에서 WebSocket을 이용한 실시간 입찰 시스템을 구축했습니다. 단일 서버 환경에서는 완벽하게 동작했지만, 트래픽이 증가하여 서버를 스케일 아웃하면 문제가 발생합니다.
무엇이 문제 인가?
현재 구조에서는 각 서버가 독립적으로 WebSocket 세션을 관리합니다.
- Server 1에 연결된 사용자A가 입찰을 진행
- Server 2에 연결된 사용자B는 같은 경매를 보고 있지만 입찰가격이 바뀌지 않음
- 각 서버의 메모리에서만 메시지가 전파되고, 서버 간 통신이 없기 때문
결국 사용자들이 서로 다른 서버에 분산 연결되어 있으면 실시간 동기화가 깨지는 문제가 발생합니다.
해결 방법: Redis Pub/Sub 도입
이 문제를 해결하기 위해 모든 서버가 동일한 이벤트 스트림을 공유하도록 Redis Pub/Sub을 도입했습니다.
1
2
3
4
5
6
7
8
[클라이언트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 확인
의존성 추가
1
2
3
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")
1
2
3
4
5
data:
redis:
host: localhost
port: 6379
password: ${REDIS_PASSWORD}
- 로컬에서는 localhost:6379를 사용
- 실무에서는 보통 AWS ElastiCache 같은 관리형 Redis 사용
Redis 클라이언트 선택
DevBid에서는 Redis를 3가지 용도로 사용하며, 각 용도에 맞는 클라이언트를 선택했습니다.
| 용도 | 클라이언트 | 선택 이유 |
|---|---|---|
| 캐싱 | Lettuce | Spring Boot 기본 클라이언트, 비동기 지원 |
| Pub/Sub | Lettuce | Spring 기본 재공 RedisMessageListenerContainer 활용 |
| 분산락 | Redisson | 검증된 분산락 구현, Watch Dog 등 고급 기능 |
Lettuce만 사용할 수도 있지만
- 분산락을 직접 구현하려면
SETNX, Lua 스크립트, 타임아웃 처리 등 복잡한 로직 필요 - 락 자동 갱신(Watch Dog), 효율적인 대기 방식(Pub/Sub) 등을 모두 직접 구현해야 함
Redisson을 추가한 이유
- 분산락이 필요한 모든 기능이 검증된 형태로 내장
- 복잡한 인프라 코드 대신 비즈니스 로직에 집중 가능
- 프로덕션 환경에서의 안정성 확보
캐싱/Pub/Sub 은 Lettuce로 충분하지만, 분산락을 Redisson의 검증된 구현을 사용하는 것이 더 안전합니다.
Lettuce 설정 - 캐싱 & Pub/Sub
Spring Boot는 기본적으로 Lettuce를 사용하며, 이를 통해 캐싱과 Pub/Sub 기능을 모두 처리합니다.
Lettuce의 장점
- Spring Boot 2.0 부터 기본 클라이언트
- 비동기/논블로킹 지원으로 동시성 처리에 유리
- Netty 기반으로 성능 우수
Jedis와의 비교
- Jedis는 동기/블로킹 방식
- Thread-safe를 위해 Connection Pool 필요
- 레거시 프로젝트에서 주로 사용
RedisTemplate 설정 코드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@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에 값을 저장하면 바이너리 형태로 보이기 때문에 사람이 읽기 어렵습니다.
- Key는 String 으로 → Redis CLI 에서 확인 가능
- Value는 JSON으로 → 다른 시스템과 호환, 가독성 확보
Redisson 설정 - 분산락 전용
Lettuce로도 분산락을 구현할 수 있지만, Redisson이 제공하는 검증된 분산락 구현을 사용하기로 결정
1
2
3
4
5
6
7
8
9
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://" + redisHost + ":" + redisPort)
.setPassword(redisPassword);
return Redisson.create(config);
}
Redisson의 효율적인 락 대기 방식
분산 락을 구현할 때, 락을 얻지 못한 스레드가 어떻게 대기할지가 중요한 문제입니다.
두 가지 대기 방식
1. 스핀락 (Spin Lock)
1
2
3
while (!tryLock()) {
Thread.sleep(50); // 계속 재시도
}
- 락 획득 실패 시 반복적으로 재시도 (폴링)
- 구현은 단순하지만 CPU 부하가 높음
- 짧은 대기 시간에 적합
2. Pub/Sub 방식
1
2
3
4
5
6
7
8
9
10
11
[Thread A] [Redis] [Thread B]
| | |
|--tryLock()----> | |
|<--락 획득 성공-- | |
| | <--tryLock()------| (락 대기)
| | --채널 구독------->| (폴링 없음!)
|--unlock()-----> | |
| | --PUBLISH-------->|
| | <--락 재시도------|
| | --락 획득 성공---->|
- 락 해제 시 메시지로 알림 받아 재시도
- 대기 중에는 블로킹되어 CPU 효율적
- 구현은 복잡하지만 성능 우수
Redisson의 RLock은 내부적으로 Pub/Sub 방식을 사용합니다.
대기 중인 스레드들이 Redis 채널을 구독하고, 락이 해제되면 PUBLISH 메시지를 받아 깨어나는 방식으로 동작합니다.
Pub/Sub 설정 - 실시간 알림 전파
Publisher (발행자): 메시지를 Redis 채널에 발행
Subscriber (구독자): Redis 채널을 구독하고, 메시지 받으면 처리
Channel (채널): 주제별로 나눔 (예: auction.bid, auction.buyout)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@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;
}
- 어떤 서버에서 입찰 이벤트 발생
- Redis
auction.events채널에 발행(Publish) - 모든 서버의
AuctionRedisSubscriber가 메시지 수신(Subscribe) - 각 서버가 자기한테 연결된 WebSocket 클라이언트들에게 브로드캐스트
Publisher - 이벤트 발행
입찰이 완료되면 Controller에서 이벤트를 발행하고, EventHandler가 Redis 채널로 전파합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 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);
}
- 입찰 발생 → Redis에 저장 → 이벤트 발행 → Redis Pub/Sub 발행
왜 Controller에서 발행할까?
Service는 순수한 비즈니스 로직만 담당 (입찰 처리 + 저장)- Controller가 웹 계층 관심사 담당 (HTTP 응답 + 이벤트 발행)
- 이벤트 발행이 트랜잭션 밖에서 실행되어
@EventListener만으로 충분
EventHandler에서 Redis Pub/Sub 전파
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@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);
}
}
Service는 이벤트 객체를 생성만 하고, 발행은Controller가 담당한다, 즉 책임분리가 명확해진다.
Redis-cli 확인
Subscriber - 메시지 수신 → WebSocket 전달
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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);
}
}
}
- Redis에서 메시지를 받으면 JSON을 파싱해서
WebSocket으로 최종 전달
분산락으로 동시성 제어 시나리오
3명이 동시에 입찰한다고 가정하면
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
현재가: 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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@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)
);
}
}
- 퍼사드 패턴으로 락 관리, 캐시 관리, 비즈니스 로직이라는 복잡한 서브시스템을 단순한 인터페이스로 통합
- 탬플릿 콜백 패턴을 적용해 락 획득/해제는
LockManager가 처리하고, 비즈니스 로직은 콜백으로 전달하여 관심사 분리 - 캐시 무효화를 락 안에서 실행하여 캐시 불일치 방지
AuctionApplicationService.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@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 는 Service 즉 비즈니스 계층에 위치하여 락 => 트랜잭션 순서를 보장
finally에서 안전한 unlock
AuctionLockManager.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
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 으로 트랜잭션 커밋 후 캐시를 삭제합니다.
1
2
3
4
5
6
// 조회 시 자동 캐싱
@Cacheable(value = "auctionDetail", key = "#auctionId")
public AuctionListResponse getAuctionDetail(Long auctionId) {
Auction auction = getAuctionById(auctionId);
return convertToResponse(auction);
}
Spring Cache의 장점
- 트랜잭션이 실패하면 캐시 작업도 실행되지 않음
- 구현 간편 코드 간결
캐시 설정 (CacheConfig.java)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@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는 실시간으로 변하는 최신 입찰 정보를 별도로 저장합니다.
1
2
3
4
5
6
7
8
9
10
@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으로 즉시 전파 |
캐시 무효화는 언제? 입찰이 발생하면
- 경매 상세 정보: 락 안에서 삭제 (다음 조회 시 DB에서 재생성)
- 최신 입찰 정보: 이벤트 핸들러에서 갱신 후 Pub/Sub으로 전파
마무리
DevBid 프로젝트를 멀티 서버 환경으로 확장하면서 Redis를 도입한 과정을 정리해보았습니다.
- Redis Pub/Sub으로 서버 간 실시간 메시지 동기화
- Redisson 분산락으로 동시 입찰 경합 제어
- Spring Cache + Redis로 조회 성능 최적화
@TransactionalEventListener로 데이터 일관성 보장
멀티서버 확장을 가정하고 Redis를 도입하면서 캐싱으로 성능 개선을 할 수 있었고, Spring의 추상화 레이어 덕분에 복잡한 인프라 코드 대신 비즈니스 로직에 집중할 수 있었던 것이 인상깊었습니다. 코드가 눈에 띄게 깔끔해졌습니다.
참고: 현재 Redis 관련 기능은 통합 테스트 없이 로컬 환경에서만 검증했습니다. Testcontainers를 활용한 Docker 기반 테스트 환경 구축은 추후 별도 포스팅으로 다룰 예정입니다.
- Lettuce vs Jedis vs Redisson 선택 과정
- 스핀락 대신 Pub/Sub 방식을 선택한 이유
- 캐시 무효화 전략 (Update vs Delete)
@TransactionalEventListener를 알게 된 계기- 락 범위 설정과 데드락 방지 전략
이번 글에서는 “무엇을, 어떻게” 구현했는지 정리했다면, 다음 포스팅에서는 “왜 이렇게 구현했는지” 에 집중하려고 합니다.

