Redis-MySQL 정합성 보장: 고민, 측정, 그리고 선택

2026. 1. 4. 05:50·SPRING

들어가며: 축제 현장의 실시간 대기열, 한 명도 놓칠 수 없다

Festin은 대학 축제 부스의 대기열을 실시간으로 관리하는 시스템입니다. 사용자들은 모바일로 대기 등록을 하고, 부스 운영자는 "다음 사람 호출" 버튼으로 순서대로 손님을 받습니다.

성능과 이력 관리를 위해 다음과 같은 아키텍처를 선택했습니다.

왜 이렇게 설계했을까?

대기 등록 시점: Redis만 사용

1. 부스 정보 조회 (Redis)
2. Lua Script로 원자적 등록 처리:
              중복 체크 + enqueue + 활성 부스 추가를 단일 원자 연산으로
3. 결과 반환
4. MySQL 저장 없음

이유:

  • 성능: 1,000 TPS 목표 - MySQL 트랜잭션은 병목
  • 실시간성: Redis는 메모리 기반, 응답 속도 < 5ms
  • 원자성 보장: Lua Script로 중복 체크부터 등록까지 단일 원자 연산
    • MySQL 트랜잭션으로는 Race Condition 방지 어려움
    • Redis Lua Script는 단일 스레드 → 완벽한 원자성
  • 순서 보장: Sorted Set의 score(등록 시간) 기반 정렬

"대기 등록"은 자주 일어나고(초당 수백 건) 빨라야 하며, 동시성 제어가 필수입니다.

 

다음 사람 호출 시점: Redis + MySQL

// 부스 운영자가 "다음 사람 호출" 버튼 클릭
@Transactional
public CallResult callNext(Long boothId) {
    // 1. Redis 대기열에서 다음 사용자 dequeue
    QueueCachePort.QueueItem queueItem = queueCachePort.dequeue(boothId)
        .orElseThrow(QueueEmptyException::new);
    
    // 2. DB에 영구 저장
    Waiting waiting = Waiting.ofCalled(userId, boothId, ...);
    Waiting savedWaiting = waitingRepositoryPort.save(waiting);
    
   	,,,
}

이유: 

  • 이력 관리: 누가 언제 호출됐는지 기록 필요
  • 통계: 대기 시간, 호출 패턴 분석
  • 책임 추적: 문제 발생 시 로그 조회

"호출"은 상대적으로 적게 일어나고(등록의 1/10), 기록이 중요합니다.

 

Problem: 분산 시스템의 근본적 딜레마

문제 발견: "어? 사용자가 증발할 수 있잖아?"

@Transactional  // MySQL 트랜잭션
public CallResult callNext(Long boothId) {
    // 1. Redis dequeue (즉시 반영, 롤백 불가)
    QueueItem item = queueCachePort.dequeue(boothId);
    
    // 2. MySQL save (트랜잭션 관리, 롤백 가능)
    waitingRepositoryPort.save(waiting);  // ← 여기서 예외 발생하면?
    
    // 2번 실패시
    // Redis: 이미 제거됨 ❌
    // MySQL: 롤백됨 ❌
    // → 사용자가 시스템에서 증발!
}

@Transactional은 MySQL만 관리합니다. Redis 작업은 트랜잭션 범위 밖에서 즉시 실행됩니다. 그렇기에 MySQL이 실패해도 Redis 변경은 되돌릴 수 없습니다.

실패 시나리오:

  1. Redis dequeue 성공 → 대기열에서 제거됨 ✅
  2. MySQL save 실패 → 예외 발생, 트랜잭션 롤백 ❌
  3. 결과:
    • Redis: 해당 사용자 없음
    • MySQL: 호출 기록 없음
    • → 사용자가 시스템에서 증발!

사용자는 "제 차례가 사라졌어요!" 라고 느낄 것이며, 이는 비즈니스면에서 심각한 문제라고 생각했습니다.


해결 방법 탐색: AI와 함께 고민하다

첫 번째 시도: "순서를 바꾸면 되지 않을까?"

내 생각:

"Redis dequeue 후 MySQL 실패 시 롤백이 안 되는 게 문제라면, 순서를 바꾸면 되지 않을까? MySQL을 먼저 저장하고 Redis를 나중에 변경하자!"

 

@Transactional
public CallResult callNext(Long boothId) {
    // 1. MySQL에 PENDING 상태로 먼저 저장
    Waiting waiting = Waiting.ofPending(userId, boothId);
    waitingRepositoryPort.save(waiting);
    
    // 2. Redis dequeue
    queueCachePort.dequeue(boothId);
    
    // 3. 상태를 CALLED로 변경
    waiting.markAsCalled();
    
    // Redis 실패하면? MySQL 롤백하면 되지!
}

AI의 반론:

"하지만 세 가지 문제가 있습니다:

  1. Source of Truth 역전: Redis가 대기열의 주인(순서 보장, 실시간 조회)인데 MySQL이 먼저 움직입니다
  2. PENDING 상태의 고아 문제: Redis는 PENDING을 모르고, callNext 로직도 DB를 보지 않습니다. PENDING 레코드는 누가 관리하나요?
  3. 롤백의 함정: Redis dequeue가 성공했는데 네트워크 타임아웃으로 응답만 유실되면 어떻게 되나요?"

 

1. Source of Truth 역전

또, 핵심 비즈니스 로직인 대기열 시스템의 역할 분리를 보면,

  • Redis -> 대기 순서 / 다음 대상 : 결론 핵심 비즈니스 로직인 대기열 다룸 (Source of Truth)
  • MySQL -> 이력 / 기록

Redis가 Source of Truth인데 MySQL이 먼저 저장하는 건,

Redis에는 아무 일도 일어나지 않았는데 DB에는 "무언가 진행된 것처럼" 기록되므로 이는 역할의 침범입니다.

 

2. PENDING 상태의 고아 문제

  • Redis는 PENDING 상태를 모름
  • Queue 로직은 DB를 보지 않음
  • callNext도 Redis 기준

결과:

  • PENDING 레코드는 누구도 관리하지 않음
  • 재시도 시: 중복 dequeue, 중복 저장 가능성

즉, PENDING은 구조적으로 고아 상태가 되게 됩니다.

 

3. “rollback 하면 되지 않나?”의 함정

Redis의 실패가 무엇인지 구분 할 수 없습니다.

  • 진짜 실패
  • 네트워크 타임 아웃
  • dequeue는 성공했지만 응답만 유실

최악의 시나리오
1. Redis dequeue 성공
2. 응답 전에 네트워크 끊김
3. 애플리케이션은 실패로 판단
4. MySQL rollback 수행

 

이렇게 된다면, Redis에 유저는 제거되고 MySQL 기록도 존재하지 않으며 "유저 증발" 상태가 됩니다.


두 번째 시도: "Saga 패턴으로 보상하면?"

AI의 제안:

"Saga 패턴은 어떨까요? MySQL 실패 시 Redis 상태를 되돌리는 보상 트랜잭션을 구현하면 됩니다."

 

try {
    userId = queueCachePort.dequeue(boothId);
    waitingRepositoryPort.save(waiting);
} catch (Exception e) {
    // MySQL 실패 시 Redis 상태를 되돌림 (보상)
    queueCachePort.enqueue(boothId, userId, registeredAt);
    throw e;
}

내 반응:

"오, 이거 괜찮은데? 실패하면 다시 Redis에 넣으면 되잖아!"

 

처음에는 실패하면 다시 Redis에 넣으면 된다고 생각했기에, 좋은 방법이라고 생각했습니다. 왜냐하면 SAGA 패턴은 MSA환경에서(비록 내 서비스는 아니지만) 분산트랜잭션 관리 해결법입니다.

 

하지만 Redis Sorted Set에서 정확한 rank 보장이 어렵습니다. 시간 기반 score를 사용하면 순서가 왜곡 될 수 있고, 보상 트랜잭션 실행 중 서버가 다운되면 이 역시 유실 위험이 있습니다. 

 

여기서 고민 포인트는 "실제 MSA환경에서 SAGA 패턴을 쓸 때는 보상 트랜잭션 실패까지 고려하고 쓰지않을까? 그렇다면 이것에 대해서도 해결법이 있지않을까?" 였습니다.

 

보통은 이벤트 기반으로 처리하고, 재시도 메커니즘을 둡니다. 여기서 SAGA 패턴을 "보상 트랜잭션만"으로 단순하게 설명한 이유는

SAGA를 제대로 구현하면 결국 Outbox 패턴으로 수렴

  • 보상 이벤트도 신뢰성 있게 발행하려면 -> Outbox 필요
  • 재시도, 멱등성 보장 -> 복잡도 급증
  • 그럼 "3단계: Outbox"와 중복됨

세 번째 고민: "완벽한 정합성이 필요한가?"

AI의 설명:

"완벽한 정합성을 원한다면 Outbox 패턴을 사용할 수 있습니다:

  1. MySQL 트랜잭션 내에서 이벤트를 outbox_events 테이블에 저장
  2. 별도 Worker가 주기적으로 이벤트를 폴링
  3. Redis 작업 수행 후 이벤트 상태를 COMPLETED로 변경
    MySQL 트랜잭션과 이벤트 저장이 같은 트랜잭션에서 일어나므로 완벽한 정합성을 보장합니다."

 

그렇다면 필요한 것들:

  • outbox_events 테이블 (event_type, payload, status, created_at)
  • Worker 프로세스 (폴링, 재시도, Dead Letter Queue)
  • 모니터링 시스템
"이론적으론 완벽하긴 한데... Festin에 이 정도가 필요한가?"

 

비즈니스 맥락을 고려해보았습니다.

  1. 금전 거래가 있는가?
  2. 트래픽 규모는 얼마 정도인가?
  3. 허용 가능한 지연이 최대 얼마인가?

금전 거래가 없다는 것, 즉 정산 금액이 틀리면 큰 문제가 되지만 대기 순서는 그렇지 않습니다.

트래픽 규모는 초당 수십 ~ 수백 건으로 금융권 수준도 아닙니다.

허용 가능한 지연은 최대 1분, 사실상 축제 현장에서 30분, 20분을 기다리는 흐름에서 단 몇초가 중요하지는 않습니다.

 

복잡도 vs 효과

Outbox는 완벽한 정합성을 추구하지만, 테이블 추가와 Worker의 구현, 배치 지연, 운영의 부담으로 이어집니다. 사실 Festin에 필요한 것은 "최종 일관성(Eventual Consistency), 낮은 복잡도" 이기에 과도하다는 결론을 내렸습니다.


 

네 번째 시도: "TransactionalEventListener는 어떨까?"

AI의 제안:

"Spring의 @TransactionalEventListener를 사용하면 MySQL 커밋이 성공한 후에만 Redis 작업을 실행할 수 있습니다."

@Transactional
public CallResult callNext(Long boothId) {
    // 1. Peek: 조회만 하고 제거하지 않음
    QueueItem item = queueCachePort.peekFirst(boothId);
    
    // 2. MySQL에 저장
    Waiting waiting = Waiting.ofCalled(userId, boothId, 1, registeredAt, now);
    waitingRepositoryPort.save(waiting);
    
    // 3. 이벤트 발행 (AFTER_COMMIT에서 Redis 처리)
    eventPublisher.publishEvent(new CallNextEvent(boothId, userId));
    
}

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleCallNextEvent(CallNextEvent event) {
    // MySQL 커밋 성공 후에만 실행됨!
    queueCachePort.dequeue(event.boothId());
    queueCachePort.removeUserActiveBooth(event.userId(), event.boothId());
}

내 반응:

"오! 이거면 되겠는데? MySQL 실패하면 AFTER_COMMIT 자체가 실행 안 되니까 Redis는 그대로고, 데이터 유실도 없고!"

구현 시작... 그런데

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleCallNextEvent(CallNextEvent event) {
    try {
        queueCachePort.dequeue(event.boothId());  // ← 여기서 Redis 장애 발생!
    } catch (Exception e) {
        log.error("Redis 작업 실패", e);
        // MySQL은 이미 커밋됨! 롤백 불가!
    }
}

문제 발견:

AFTER_COMMIT도 트랜잭션 밖입니다. Redis 장애나 네트워크 오류로 실패할 수 있습니다.

 

실패 시나리오:

  1. MySQL save 성공 → 트랜잭션 커밋 ✅
  2. AFTER_COMMIT 실행 → Redis dequeue 시도
  3. Redis 장애 발생! ❌
  4. 결과:
    • MySQL: CALLED 상태로 저장됨
    • Redis: 여전히 대기열에 존재
    • → 불일치 발생!

결국 AFTER_COMMIT이 실패한 케이스를 처리하려면 배치가 필요합니다. 그럼 TransactionalEventListener의 의미가 퇴색되는거 아닐까? 생각했습니다. 

 

추가 문제: Peek-Save Gap

// 1. Peek: 조회만 (제거 안 함)
QueueItem item = queueCachePort.peekFirst(boothId);

// ⚠️ Gap: 이 사이에 다른 트랜잭션이 끼어들 수 있음
// → 분산 락 필요

// 2. MySQL Save
waitingRepositoryPort.save(waiting);

 

또 다른 문제는, "Peek만 한다면, 이 사이에 다른 트랜잭션이 끼어들 수 있고, 결국 분산 락이 필요"합니다.

다섯 번째 고민: "Soft Lock은 어떨까?"

내 아이디어:

"그럼 기존 방식(Redis → MySQL)을 유지하되, 실패 지점을 명시적으로 마킹하면 어떨까? 임시 키를 만들어서 '이 사용자는 지금 처리 중'임을 표시하고, 실패하면 그 키를 보고 롤백하는 거지."

@Transactional
public CallResult callNext(Long boothId) {
    // 1. Redis dequeue (즉시 제거, 원자적)
    QueueItem item = queueCachePort.dequeue(boothId);
    
    // 2. Soft Lock 생성 (실패 지점 마킹)
    String key = "temp:calling:" + boothId + ":" + userId;
    redis.hset(key, Map.of(
        "userId", String.valueOf(userId),
        "boothId", String.valueOf(boothId),
        "timestamp", String.valueOf(item.registeredAt().toEpochSecond())  // 원래 순서 보존!
    ));
    
    try {
    	// 3. 사용자 활성 부스 목록에서 제거
        // 4. 호출 순번 계산
        
        // 5. DB에 영구 저장
        Waiting waiting = Waiting.ofCalled(userId, boothId, 1, registeredAt, now);
        Waiting savedWaiting = waitingRepositoryPort.save(waiting);
        
        // 6. 성공시 Soft Lock 삭제
        redis.del(key);
        
        // 7. 푸시 알림 발송
        // 8. 결과 반환
        
        
    } catch (Exception e) {
        // MySQL 실패: Soft Lock 남김 (배치가 롤백)
        // Soft Lock은 삭제하지 않음 → SoftLockRecoveryBatch가 감지하여 Redis 롤백
        throw e;
    }
}

배치 보정:

@Scheduled(fixedRate = 60_000)  // 1분마다
public void recoverSoftLockFailures() {
    // 1. Soft Lock 스캔 (실패 건수만큼만)
    Set<String> keys = redis.scan("temp:calling:*");
    
    for (String key : keys) {
        Map<String, String> data = redis.hgetAll(key);
        Long userId = Long.parseLong(data.get("userId"));
        Long boothId = Long.parseLong(data.get("boothId"));
        
        // 2. MySQL 확인
        boolean exists = waitingRepository.existsByUserIdAndBoothIdAndStatus(
            userId, boothId, WaitingStatus.CALLED
        );
        
        if (!exists) {
            // 3. MySQL에 없으면 Redis 롤백
            long timestamp = Long.parseLong(data.get("timestamp"));
            redis.zadd("queue:booth:" + boothId, timestamp, String.valueOf(userId));
        }
        
        // 4. Soft Lock 삭제
        redis.del(key);
    }
}

 

이렇게 하게 된다면, Redis가 주도권을 가지고, Soft Lock으로 실패 지점을 명확히 표시하고, timestamp를 보존해서 정확한 위치로 복구 할 수 있게 됩니다.

 

근데, 그렇다면 "TransactionalEventListener와 뭐가 다를까요? 둘 다 결국 배치가 필요한거 아닐까요?"


 

측정으로 증명하기: "말로만 하지 말고 직접 재보자"

"주관적으로 '이게 더 나아 보여'라고 하지 말고, 둘 다 구현해서 실제로 측정해보자. 숫자로 증명하자."

테스트 환경 구축

@SpringBootTest
@Testcontainers
class RecoveryApproachComparisonTest {
    
    @Container
    static MySQLContainer mysql = new MySQLContainer("mysql:8.0");
    
    @Container
    static GenericContainer redis = new GenericContainer("redis:7.2");
    
    // Port 레벨 Failure Injection
    @SpyBean
    private WaitingRepositoryPort waitingRepositoryPort;
    
    @Test
    void 실제_인프라에서_실패_시나리오_재현() {
        // MySQL 장애 주입
        doThrow(new RuntimeException("DB error"))
            .when(waitingRepositoryPort).save(any());
        
        // 실제 실행 및 측정
    }
}

포인트:

  • Real MySQL, Real Redis (Mockito Mock 아님)
  • Port 레벨에서 Failure Injection
  • 실제 운영과 동일한 환경

측정 1: 배치 스캔 효율성

시나리오:

  • 100명이 최근 3분 내에 callNext 성공 (CALLED 상태, MySQL 저장 완료)
  • 1명이 callNext 실패 (Soft Lock 생성)
  • 각 배치 실행 및 시간 측정

측정 코드:

@Test
void 배치_스캔_효율성_비교() {
    // Given: 100명 성공 처리
    for (int i = 0; i < 100; i++) {
        callNextService.callNext(boothId);
    }
    
    // 1명 실패 (MySQL 장애 주입)
    doThrow(new RuntimeException("DB error"))
        .when(waitingRepositoryPort).save(any());
    
    assertThatThrownBy(() -> callNextService.callNext(boothId));
    
    // When: 배치 실행 및 시간 측정
    long start = System.currentTimeMillis();
    recoveryBatch.recover();
    long elapsed = System.currentTimeMillis() - start;
    
    // Then: 시간 기록
    System.out.println("배치 소요 시간: " + elapsed + "ms");
}

결과:

방식 스캔 대상 스캔 건수 소요 시간 시간 복잡도

TransactionalEventListener MySQL CALLED 전체 100건 64ms O(최근 CALLED 수)
Soft Lock Redis SCAN temp:calling:* 1건 5ms O(실패 건수)

차이: 12.8배!

 

왜 이런 차이가 나는가?

TransactionalEventListener:

-- MySQL 전체 조회
SELECT * FROM waiting 
WHERE status = 'CALLED' 
  AND called_at > DATE_SUB(NOW(), INTERVAL 5 MINUTE)
-- → 100건 조회 (정상 케이스도 포함)

-- 각 건마다 Redis 확인
ZSCORE queue:booth:{boothId} {userId}

Soft Lock:

# Redis SCAN (실패만)
SCAN 0 MATCH temp:calling:* COUNT 100
# → 1건만 반환

# MySQL 확인 (1건만)
SELECT EXISTS(
    SELECT 1 FROM waiting 
    WHERE user_id=? AND booth_id=? AND status='CALLED'
)

 

Soft Lock은 실패한 케이스만 스캔하니까 정상 상황에서는 배치가 일을 하지않게 되는 반면, TransactionalEventListener는 정상 케이스도 전부 조회해야합니다.


측정 2: 복구 정확성 (Timestamp 보존)

시나리오:

  • 대기열에 5명 등록 (rank: 1, 2, 3, 4, 5)
  • user1, user2 callNext 성공 (제거)
  • user3가 이제 rank 1 (원래는 rank 3)
  • user3 callNext 실패 → MySQL 장애
  • 배치 복구 실행
  • 질문: user3가 어느 위치로 복구되는가?

측정 코드:

@Test
void 복구_정확성_timestamp_보존() {
    // Given: 초기 대기열
    registerUsers(boothId, user1, user2, user3, user4, user5);
    
    // user1, user2 호출 성공
    callNextService.callNext(boothId);
    callNextService.callNext(boothId);
    
    // 현재 대기열: [user3(rank1), user4(rank2), user5(rank3)]
    
    // user3 callNext 실패
    doThrow(new RuntimeException()).when(waitingRepositoryPort).save(any());
    assertThatThrownBy(() -> callNextService.callNext(boothId));
    
    // When: 배치 복구
    softLockRecoveryBatch.recover();
    
    // Then: user3가 rank 1로 복구되었는가?
    Long rank = redis.zrank("queue:booth:" + boothId, String.valueOf(user3Id));
    assertThat(rank).isEqualTo(0);  // rank 1 (0-indexed)
}

Soft Lock: Timestamp 보존

// Dequeue 시:
QueueItem item = redis.zpopmin("queue:booth:" + boothId);
// → userId=123, score=1704096000 (원래 등록 시간)

// Soft Lock 저장:
redis.hset("temp:calling:123:456", Map.of(
    "userId", "123",
    "boothId", "456",
    "timestamp", "1704096000"  // ← 이게 핵심!
));

// 배치 롤백:
redis.zadd("queue:booth:456", 1704096000, "123");
// → 정확히 원래 위치로 복구!

"dequeue할 때 score를 함께 가져와서 Soft Lock에 저장하기 때문에, 롤백할 때 정확히 원래 위치로 복구가 가능합니다!"

 

반면, TransactionalEventListener는?

  • Peek만 하므로 Redis 변경 없음
  • 복구 불필요 (MySQL만 롤백)
  • 하지만 Peek-Save gap에 Race Condition 가능

측정 3: 아키텍처 일관성

Source of Truth 흐름:

[Soft Lock]
Redis dequeue → Soft Lock 마킹 → MySQL save
(주인)          (실패 마킹)      (기록)
✅ 자연스러운 흐름, Redis가 주도권

[TransactionalEventListener]
MySQL save → Redis peek → AFTER_COMMIT → Redis dequeue
(기록)       (조회)       (커밋 후)      (실제 제거)
❌ 역할 전도, MySQL이 먼저 움직임

필요한 추가 구현:

항목 TransactionalEventListener Soft Lock
Peek 메서드 peekFirst() 구현 필요 불필요
분산 락 범위 Peek → Save 전체 callNext 전체 (동일)
실패 마킹 MySQL CALLED로 추론 Soft Lock 명시적 표시
"TransactionalEventListener는 결국 Peek 메서드도 새로 만들어야 하고, MySQL이 먼저 저장하는 게 구조적으로 어색합니다. Soft Lock은 기존 흐름을 유지하면서 실패만 명시적으로 마킹하니까 자연스럽습니다"

최종 선택: Soft Lock, 그 이유는?

측정 결과 종합

평가 항목 TransactionalEventListener Soft Lock 승자

평가항목 TransactionalEventListener Soft Lock  승자
배치 스캔 효율 64ms (100건) 5ms (1건) Soft Lock (12.8배)
복구 정확성 Peek이라 복구 불필요(but Race Condition 가능) Timestamp 보존(정확한 위치 복구) Soft Lock
아키텍처 일관성 MySQL → Redis(역할 전도) Redis → MySQL(자연스러운 흐름) Soft Lock
구현 복잡도 ~286 lines, Peek 추가 필요 ~300 lines, Soft Lock 관리 비슷함

선택 근거

1. 자연스러운 흐름 유지

Redis가 대기열의 Source of Truth입니다. Redis가 주도권을 가져야 아키텍처가 일관됩니다.

✅ Soft Lock:     Redis → MySQL (주인이 먼저)
❌ EventListener: MySQL → Redis (기록이 먼저?)

2. 명시적 실패 마킹

# TransactionalEventListener: 실패를 추론
"MySQL에 CALLED 있는데 Redis에도 있네? 실패인가?"
→ 전체 조회 필요

# Soft Lock: 실패가 명시적
temp:calling:{boothId}:{userId} 존재 = 실패
→ SCAN만으로 즉시 감지

3. 배치 효율성 (12.8배 차이)

정상 상황: Soft Lock 0건 vs CALLED 100건

// Soft Lock: 실패만 스캔
SCAN 0 MATCH temp:calling:* COUNT 100
→ 0건 (정상 상황)

// EventListener: 전체 스캔 후 대조
SELECT * FROM waiting WHERE status='CALLED' AND calledAt > now-5min
→ 100건 조회 후 각각 Redis 확인

4. Timestamp 보존으로 정확한 복구

// dequeue 시점의 score를 Soft Lock에 저장
// → 롤백 시 정확한 위치로 복구
redis.zadd("queue:booth:" + boothId, originalTimestamp, userId);

배운 점: 측정 가능한 기술 선택

완벽함보다 맥락에 맞는 선택

1. Outbox 패턴: 완벽한 정합성, 높은 복잡도

  • 금융권, 결제 시스템에 적합
  • Festin에는 과도함

2. TransactionalEventListener: AFTER_COMMIT 활용

  • MySQL 커밋 후 Redis 변경
  • 하지만 AFTER_COMMIT도 실패 가능
  • 결국 배치 필요 → 의미 퇴색

3. Soft Lock: 최종 일관성, 낮은 복잡도

  • 자연스러운 흐름 유지
  • 실패 지점 명시적 마킹
  • 배치 효율성 12.8배
  • 비즈니스 맥락에 적합

AI와의 협업: 제안 → 검증 → 측정

  1. AI 제안: "TransactionalEventListener는 어떨까요?"
  2. 내 검증: "AFTER_COMMIT도 실패하는데?"
  3. 함께 탐색: "그럼 Soft Lock은?"
  4. 측정으로 증명: "직접 구현해서 비교해보자"

숫자로 말하는 기술 선택

"어느 게 더 나은가?"는 주관이 아닌 측정으로 답해야 합니다.

  • 배치 스캔: 64ms vs 5ms (12.8배)
  • 복구 정확성: Timestamp 보존 여부
  • 아키텍처: Source of Truth 흐름

코드 14줄 차이가 아니라, 아키텍처 일관성과 배치 효율성 12.8배 차이로 기술을 선택했습니다.


마치며

솔직히 처음에는 "Redis dequeue 후 MySQL 실패하면 롤백하면 되지 않나?"였습니다. 하지만, 실제로 코드를 작성하고, AI와 대화하고, 여러 방법을 시도하면서 "분산 시스템에서 완벽한 정합성이란 건 생각보다 훨씬 어렵구나"를 체감했습니다. 또한, "완벽한 정합성"을 집착하게 되기도 했는데요,, "Festin에는 1분 복구면 충분하다"는 비즈니스 맥락을 명확하게 잡는 법 또한 배우게 된것 같습니다.

 

AI 에게 "Mysql - Redis 정합성 문제를 해결하려면 어떻게 해야해?" 라고 물었다면, 이러한 결론이 나오지 않았을 것이라고 생각합니다.

비즈니스 맥락을 고려하고, 여러 접근법을 탐색하고, 실제로 측정해서 최적의 선택을 했습니다. (사실 아직 최적이 아닐 수도 있죠!) Soft Lock + 1분 배치로 복잡도를 낮추고 최종 일관성을 보장하는 것 - 이것이 Festin에 맞는 답이었습니다:)

'SPRING' 카테고리의 다른 글

[Spring] JPA save(), saveAll(), deleteAll(), deleteAllInBatch(), @Query 비교  (3) 2024.11.10
[Spring] gradle로 멀티모듈설계 해보기  (3) 2024.11.05
'SPRING' 카테고리의 다른 글
  • [Spring] JPA save(), saveAll(), deleteAll(), deleteAllInBatch(), @Query 비교
  • [Spring] gradle로 멀티모듈설계 해보기
jiixon
jiixon
  • jiixon
    Dev:elop
    jiixon
  • 전체
    오늘
    어제
    • 분류 전체보기 (26)
      • JAVA (0)
      • SPRING (3)
      • SERVER (2)
      • 공부 (20)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

    • POST
    • SETTING
  • 공지사항

  • 인기 글

  • 태그

    tdd
    테스트
    알고리즘
    배포
    자바
    Bdd
    프로그래머스
    Kotlin
    spring jpa
    spring
    Elastic Beanstalk
    AssertJ
    AWS
    springboot
    java
    서버
    junit
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
jiixon
Redis-MySQL 정합성 보장: 고민, 측정, 그리고 선택
상단으로

티스토리툴바