[DevBid - 실시간통신] Redis 도입 후 마주한 문제와 해결
📚 실시간 경매 시스템 구축기 시리즈
- WebSocket으로 실시간 경매 구현하기
- WebSocket 메시지 동기화 문제와 Redis 선택
- Redis 분산락과 Pub/Sub으로 멀티서버 동시성 제어
- Redis 도입 후 마주한 문제와 해결 ← 현재 글
- 멀티서버 환경에서 Pub/Sub와 분산락 검증
개요
이전 글에서 Redis Pub/Sub, 분산락, 캐시 전략을 도입한 과정을 정리했는데, 이번 글에서는 실제 구현하며 겪었던 문제들과 해결과정을 정리해보려 합니다.
WebSocket으로 실시간 경매를 구현할 때는 ‘생각보다 쉬운데?’ 라고 생각했지만 완전 우물안 개구리.. 멀티 서버 도입을 가정하고 Redis를 도입했더니 예상치 못한 문제들이 쏟아졌다.
특히 다음과 같은 문제들을 마주했습니다.
- 트랜잭션과 분산락의 순서 문제
- @CacheEvict와 트랜잭션의 실행 순서
- Redis 장애 시 핵심 비즈니스 로직 영향도
- 직렬화 이슈와 메모리 관리
이 글에서는 이러한 문제를 어떻게 발견하고 어떻게 해결했는지 구체적인 코드화 함께 정리하겠습니다.
트랜잭션과 분산락 순서 문제
문제상황
이전 글에서 언급한 분산락 + 트랜잭션 구조를 처음 구현할 때는 직관적으로 @Transcational안에 락 로직을 넣었다.
Jmeter로 동시성 테스트를 해보았더니 같은 금액으로 입찰이 중복 저장되는 현상이 발생
1
2
3
4
5
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
- user4, 5 가 같은 금액이 입찰되고, 경매 횟수(bid_count)도 17로 똑같았다.
원인분석
기존 코드는 트랜잭션 안에서 락을 획득하는 구조
1
2
3
4
5
6
7
8
// 잘못된 구조
@Transactional // 트랜잭션이 먼저 시작
public BidPlacedEvent placeBid(Long auctionId, Long bidderId, BigDecimal bidAmount) {
return auctionLockManager.executeWithLock( // 그 다음 락 획득
auctionId,
() -> bidApplicationService.placeBid(auctionId, bidderId, bidAmount)
);
}
- 문제는 락과 트랜잭션의 순서 였다.
실행흐름
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
AuctionApplicationService.placeBid()
@Transactional 시작 (DB 커넥션 획득)
↓
executeWithLock()
↓
tryLock() → 락 획득
↓
비즈니스 로직 실행
↓
finally → lock.unlock() → 락 해제
↓
하지만 트랜잭션은 아직 진행 중
↓
트랜잭션 commit (실제 DB 반영)
이 구조의 문제점
락 해제와 커밋 사이의 간극
- 락이 해제된 후에도 트랜잭션 커밋이 남아있음
- 다른 스레드가 락을 획득해도 이전 트랜잭션이 아직 커밋 안 된 상태
- 최신 데이터를 읽지 못하는 일관성 문제 발생
데드락 발생 가능성
- ThreadA: 트랜잭션 시작 => 락 대기
- ThreadB: 트랜잭션 시작 => 락 대기
- 두 스레드 모두 DB 커넥션 점유 + 락 대기
- 서로를 기다리며 교착 상태 발생
불필요하게 긴 트랜잭션
- 트랜잭션이 락 획득 대기 시간까지 포함
- DB 커넥션을 오래 점유
- 커넥션 풀 고갈 위험
1
2
3
4
5
6
7
8
9
10
@Transactional
public void process() {
executeWithLock(() -> {
// 외부 API 호출
// Redis Pub/Sub 발행
// 파일 I/O
// 복잡한 도메인 로직
// 이 모든 게 트랜잭션 + 락 안에 있음
});
}
왜 이런 구조를 선택했을까? 초기에는 다음과 같이 생각했다
- “트랜잭션 안에서 모든 걸 처리하면 안전하지 않을까?”
- “락도 트랜잭션의 일부로 관리되면 편하지 않을까?”
하지만 실제로는
- 락 보유 시간이 불필요하게 길어짐 (DB 커밋 대기 시간까지)
- 다른 요청들의 응답 시간 증가
- 최악의 경우 데드락 발생 가능성
결론: 락을 먼저 획득하고 그 안에서 트랜잭션을 실행해야 한다.
해결 방법
락을 트랜잭션보다 먼저 획득 하도록 변경
AuctionFacade - 락 관리
1
2
3
4
5
6
public BidPlacedEvent placeBid(Long auctionId, Long bidderId, BigDecimal bidAmount) {
return auctionLockManager.executeWithLock(
auctionId,
() -> auctionApplicationService.placeBid(auctionId, bidderId, bidAmount)
);
}
- 퍼사드 패턴으로 락 관리, 캐시 관리, 비즈니스 로직이라는 복잡한 서브시스템을 단순한 인터페이스로 통합
- 탬플릿 콜백 패턴을 적용해 락 획득/해제는
LockManager가 처리하고, 비즈니스 로직은 콜백으로 전달하여 관심사 분리
AuctionApplicationService - 비즈니스 로직 및 트랜잭션
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()
);
}
- 서비스는 비즈니스 로직에 집중
개선된 실행 흐름
1
2
3
4
5
6
7
8
9
10
11
락 획득
↓
@Transactional 시작
↓
비즈니스 로직 실행 (50ms)
↓
트랜잭션 커밋
↓
캐시 무효화
↓
락 해제
개선효과
이제 다른 스레드가 락을 획득할 때는 이미 트랜잭션이 커밋된 상태이므로 최신 데이터를 읽을 수 있다..
| 항목 | 이전 | 개선 후 |
|---|---|---|
| 락 보유 시간 | 락 대기 + 비즈니스 로직 + 커밋 | 비즈니스 로직 + 커밋만 |
| 데이터 일관성 | ❌ 락 해제 후 커밋으로 불일치 발생 | ✅ 커밋 후 락 해제로 보장 |
| 데드락 위험 | ⚠️ 트랜잭션 + 락 대기로 높음 | ✅ 락 먼저 획득으로 낮음 |
| 커넥션 점유 | 🔴 불필요하게 길음 | 🟢 필요한 만큼만 |
| 처리량 | 🔴 낮음 | 🟢 향상 |
분산락과 캐시 무효화 순서
@CacheEvict와 분산락 함께 사용하기
@CacheEvict를 분산락과 함께 안전하게 사용하려면 TransactionAwareCacheManagerProxy 설정이 필요합니다.
CacheConfig 설정
1
2
3
4
5
6
7
8
@Bean
public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheManager redisCacheManager = RedisCacheManager.builder(connectionFactory)
.cacheDefaults(redisCacheConfiguration())
.build();
return new TransactionAwareCacheManagerProxy(redisCacheManager);
}
이 설정이 있으면 @CacheEvict가 트랜잭션 커밋 후에 실행되고, 락 범위 안에서 캐시가 삭제됩니다.
실행 흐름:
1
2
3
4
5
6
1. 락 획득
2. 트랜잭션 시작
3. 비즈니스 로직
4. 트랜잭션 커밋
5. 캐시 삭제 (after-commit)
6. 락 해제
장점:
- 트랜잭션 롤백 시 캐시 삭제도 취소
- 어노테이션만으로 캐시 관리 가능
- 비즈니스 로직에 집중 가능
@CacheEvict의 동작 원리
Spring의 트랜잭션 인식 캐시
@CacheEvict는 @Transactional과 함께 사용될 때 자동으로 커밋 후에 실행됩니다.
Spring의 TransactionAwareCacheDecorator
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@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);
}
}
실행 시나리오
1
2
3
4
5
6
7
8
9
10
@Transactional
@CacheEvict(value = "auctionDetail", key = "#auctionId")
public BidPlacedEvent placeBid(...) {
// 1. 트랜잭션 시작
// 2. CacheEvict가 TransactionSynchronization에 등록만 함
// 3. 비즈니스 로직 실행
// 4. 트랜잭션 커밋
// 5. 커밋 성공 → 캐시 삭제 실행
// 롤백 시 → 캐시 삭제 취소
}
장점:
- 트랜잭션 롤백 시 캐시도 유지 → 데이터 일관성
- 수동으로 커밋 여부를 체크할 필요 없음
- 어노테이션만으로 캐시 관리
단점:
- 분산 락과 함께 사용 시 캐시 불일치 발생 가능
- 락 해제 후 캐시 삭제로 타이밍 이슈
결론
TransactionAwareCacheManagerProxy 설정으로 @CacheEvict를 분산락과 함께 안전하게 사용할 수 있습니다.
- 트랜잭션 커밋 → 캐시 삭제 → 락 해제 순서 보장
- 롤백 시 캐시 삭제 자동 취소
- 어노테이션 기반으로 코드 간결
Redis 연결 끊김
Redis가 다운되면 입찰은 완료되지만 다른 서버로 알림이 전파되지 않는다.
1
2
redisTemplate.convertAndSend("auction.events", dto);
// Redis 에러 발생 → 입찰 자체가 실패
해결방법
1
2
3
4
5
6
7
8
try {
redisTemplate.convertAndSend("auction.events", dto);
} catch (Exception e) {
log.error("Redis 발행 실패 - 해당 서버 클라이언트만 알림 수신", e);
// 입찰은 이미 완료됨
// 해당 서버의 WebSocket 클라이언트들은 알림을 받음
// 다른 서버의 클라이언트들만 알림 못 받음 (새로고침으로 확인 가능)
}
- Redis Pub/Sub 실패를 격리하여 핵심 비즈니스 로직에 영향을 주지 않도록 했다.
개선사항
더 안정적이 메시징이 필요하면 Spring Retry, Kafka 를 사용해보자
Kafka
- 메시지 영속성 보장
- at-least-once 전달 보장
- 더 강력한 장애 복구
분산락 타임아웃 조정
1
2
// 3초 대기, 5초 유지
lock.tryLock(3000, 5000, TimeUnit.MILLISECONDS);
- 대기 시간을 늘려 락 획득 실패를 줄인다.
Watch Dog 활성화
1
2
// leaseTime을 -1로 설정하면 Watch Dog 활성화
lock.tryLock(3000, -1, TimeUnit.MILLISECONDS);
- 비즈니스 로직이 복잡해 실행 시간이 불확실하면 Watch Dog를 사용
Watch Dog의 동작
1
2
3
4
5
6
7
8
9
RLock lock = redissonClient.getLock("auction:lock:" + auctionId);
try {
lock.tryLock(3000, -1, TimeUnit.MILLISECONDS);
// 긴 비즈니스 로직...
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock(); // 필수
}
}
- 처리가 끝날 때까지 자동으로 락을 갱신
- finally 에서 반드시 unlock() 호출 필요!
Redis 메모리 관리
문제 상황
Redis는 인메모리 DB라서 메모리 관리가 중요하다
경매 서비스 특성상
- 경매가 진행되는 동안만 캐시가 필요
- 경매 종료 후에는 조회 빈도가 급감
- 불필요한 데이터가 계속 쌓이면 메모리 부족
초기에는 TTL 없이 저장해서 종료된 경매 데이터가 계속 남아있었습니다.
캐시 종류별 메모리 관리
프로젝트에서 사용하는 캐시는 크게 두 종류
경매 상세 정보 캐시
용도: 경매 상세 조회 성능 향상
Redis 키: auctionDetail::{auctionId}
관리 방식: Spring Cache 추상화
1
2
3
4
5
6
7
8
// CacheConfig.java
@Bean
public RedisCacheConfiguration redisCacheConfiguration() {
return RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(5)) // 5분 TTL
.disableCachingNullValues();
}
메모리 관리 전략
경매 상세 캐시 - 이벤트 기반 무효화
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 조회 시 캐싱
@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) {
// 트랜잭션 커밋 후 자동으로 캐시 삭제
}
- 입찰/즉시구매 시 @CacheEvict로 캐시 삭제 → 즉시 무효화
- 무효화 실패 시 대비를 위한 5분 후 자동 삭제 → TTL 백업
- TransactionAwareCacheDecorator가 트랜잭션 커밋 후 삭제제
TransactionAwareCacheDecorator 란?
Spring이 제공하는 캐시 데코레이터로, @Transactional과 함께 사용 시 트랜잭션 커밋 후에 캐시 작업을 수행하고, 트랜잭션이 롤백되면 캐시 삭제도 취소되어 데이터 일관성을 보장
캐시 무효화 시나리오
1
2
3
4
5
6
7
8
9
10
11
12
13
14
캐시 무효화 시나리오:
1. 사용자 A가 경매 상세 조회 → 캐시 저장
2. 사용자 B가 입찰 → @CacheEvict 실행
├─ 트랜잭션 시작
├─ DB 업데이트
├─ 트랜잭션 커밋
└─ 캐시 삭제 (TransactionAwareCacheDecorator)
3. 사용자 C가 조회 → DB에서 최신 데이터 조회 후 재캐싱
트랜잭션 롤백 시
1. 입찰 시도
2. 비즈니스 로직 검증 실패 (예: 현재가보다 낮은 금액)
3. 트랜잭션 롤백
4. 캐시 삭제도 자동 취소 → 기존 캐시 유지
Pub/Sub 메시지 손실
Redis Pub/Sub은 메시지를 저장하지 않습니다.
- 발행 시점에 구독 중인 서버만 메시지를 받음
- 서버 재시작 중이면 메시지 유실
- 네트워크 지연으로 인한 손실 가능성
DevBid 프로젝트 에서는
메시지가 유실되어도 치명적이지 않은 상황이였습니다.
- 새로고침하면 최신 정보 확인 가능
- 실시간성이 중요하지만 정확성이 더 중요한 데이터는 DB 저장
- 간단한 Pub/Sub 으로 충분하다 판단
안정적인 메시징이 필요 하다면
Redis Streams
- 메시지 영속성 보장
- Consumer Group으로 메시지 추적
- 재처리 가능
Kafka
- 강력한 메시지 영속성
- 파티셔닝과 리플리케이션
- 대용량 처리에 적합
직렬화 이슈
에러상황
1
InvalidDefinitionException: Cannot construct instance of BidEventDto
원인
Jackson이 JSON을 역직렬화 할 때 기본생성자가 필요해 발생한 오류
해결
1
2
3
4
5
6
@Getter
@NoArgsConstructor //Jackson이 객체 만들 때 사용
@AllArgsConstructor // @Builder가 내부적으로 필요함 둘 다 있어야 @Builder + Jackson 역직렬화 가능
public class BidEventDto {
...
}
@NoArgsConstructor: Jackson이 객체를 생성할 때 사용@AllArgsConstructor:@Builder가 내부적으로 필요- 둘 다 있어야
@Builder+ Jackson 역직렬화 가능
마무리
이전 글에서 예고했던 “왜 이렇게 구현했는지”를 실제 문제 상황과 함께 정리해보았다.
핵심 교훈
1. 분산 환경의 복잡성은 예상보다 크다
- 단일 서버에서 잘 작동하던 로직도 멀티 서버에서는 문제 발생
- 트랜잭션, 락, 캐시의 실행 순서가 성능과 안정성을 좌우
2. Spring의 추상화를 이해하면 안전하게 사용할 수 있다
@CacheEvict가 트랜잭션 커밋 후 실행된다는 사실@TransactionalEventListener의 phase 옵션TransactionAwareCacheDecorator의 역할
3. 장애는 격리되어야 한다
- Redis 실패가 핵심 비즈니스를 멈춰서는 안 됨
- try-catch로 부가 기능(Pub/Sub, 캐시)의 실패를 격리
- 메시지 손실 가능성을 비즈니스 요구사항과 함께 고려
Redis Pub/Sub의 한계
DevBid에서는 Redis Pub/Sub으로 충분했지만, 프로덕션 수준의 안정성이 필요하다면..?
Redis Pub/Sub의 근본적 한계
- 메시지 휘발성 (재시작 시 손실)
- at-most-once 전달 (최대 한 번)
- 순서 보장 제한적
-
Consumer 상태를 추적할 수 없고, 백프레셔(back pressure)도 없음
→ 실시간 알림에는 충분하지만, 핵심 비즈니스 이벤트 처리에는 취약
그래서 Kafka를 고민하는 이유
- 메시지 영속성 보장 (데이터가 사라지지 않음)
- at-least-once 전달 보장
- Consumer Group 기반으로 확장성·내결함성 우수
- 메시지 순서를 파티션 단위로 일관되게 유지
- 장애 시에도 복구가 용이
앞으로의 학습 방향
이번 경험을 통해 “작동하는 것”과 “안정적으로 작동하는 것”의 차이를 배웠다.
다음 학습 목표
- Kafka를 활용한 이벤트 기반 아키텍처
- 분산 환경에서의 eventual consistency 패턴
- 더 정교한 장애 복구 전략 (Circuit Breaker, Retry)
- 대규모 트래픽 처리를 위한 성능 최적화
개선하고 싶은 부분
- Redis Pub/Sub → Kafka 마이그레이션 검토
- 분산 캐시 일관성 전략 개선
- 더 정교한 락 타임아웃 및 재시도 전략
- 장애 상황별 fallback 시나리오 구체화
- 테스트를 위한 Docker 기반 테스트 환경 구축
완벽하지는 않지만, 이 과정을 통해 분산 시스템의 복잡성과 실시간 통신 구현의 어려움을 체감할 수 있었습니다.
다음 포스팅에서는 AWS EC2 Redis 를 세팅 한 후 구현한 분산락과 Pub/Sub을 테스트할 예정입니다.