PostgreSQL과 Redis 정합성
프로젝트에 Redis 캐시를 적용하면서 조회 성능보다 더 오래 고민한 부분은 데이터 정합성이었습니다. Redis를 사용하면 조회 속도를 높일 수 있지만, PostgreSQL과 Redis에 서로 다른 데이터가 남을 가능성이 생깁니다.
예를 들어 PostgreSQL에서 데이터를 soft delete했는데 Redis에 기존 데이터가 그대로 남아 있다면, 사용자는 삭제된 데이터를 계속 조회할 수 있습니다.
이 문제를 겪으며 단순히 데이터가 변경되면 캐시도 함께 삭제하는 처리만으로는 부족하다는 것을 알게 되었습니다.
데이터 정합성과 캐싱 문제를 겪으며 고민한 과정과 판단 근거를 정리했습니다.
PostgreSQL과 Redis는 하나의 트랜잭션으로 움직이지 않는다
처음에는 PostgreSQL 데이터가 변경될 때 Redis 캐시도 함께 제거하면 문제가 해결될 것이라고 생각했습니다. 하지만 PostgreSQL과 Redis는 별개의 저장소이기 때문에 Spring의 @Transactional로 묶이지 않습니다.
문제 상황은 다음과 같습니다.
- PostgreSQL에서 Hub와 연결된 HubRoute를 삭제 상태로 변경한다.
- Redis에서 관련 캐시를 제거한다.
- 이후 PostgreSQL 트랜잭션이 실패해 rollback된다.
이 경우 RDB에는 기존 데이터가 남아 있지만 Redis 캐시는 지워집니다. 원본 데이터에는 문제가 없지만 불필요한 캐시 미스와 DB 조회가 발생합니다.
반대의 경우는 더 위험합니다.
- PostgreSQL 트랜잭션이 정상적으로 commit된다.
- Redis 캐시 제거를 시도한다.
- Redis 장애로 캐시 제거가 실패한다.
이 경우 RDB에서는 데이터가 삭제되었지만 Redis에는 이전 정보가 남아 있습니다. 사용자는 캐시가 만료될 때까지 삭제된 데이터를 조회할 수 있습니다.
결국 두 저장소를 완전히 동시에 수정하기 어렵기 때문에 어떤 저장소를 최종 기준으로 삼을지 정해야 했습니다.
PostgreSQL을 SSoT로 두기
PostgreSQL을 SSoT로 두는 방향으로 가닥을 잡았습니다. Redis는 조회 성능을 높이기 위한 보조 저장소로 사용하므로, 최종적으로 신뢰할 데이터는 RDB에서 관리하는 게 맞다고 생각했습니다. Redis가 비어 있거나 장애가 발생하더라도 PostgreSQL에서 정확한 데이터를 조회하고, 이를 바탕으로 캐시를 복구할 수 있기 때문입니다.
원칙을 정하고 나니 우선순위를 명확하게 정리할 수 있었습니다.
- PostgreSQL 작업이 실패하면 전체 비즈니스 작업은 실패한다.
- Redis 조회가 실패하면 PostgreSQL에서 다시 조회한다.
- Redis 저장이나 삭제가 실패하더라도 PostgreSQL의 변경까지 무조건 취소할 필요는 없다.
- Redis에 남은 오래된 데이터는 TTL이나 재처리 방식을 통해 복구한다.
flowchart LR
Request[조회 요청] --> Cache{Redis 조회}
Cache -->|hit| Response[응답]
Cache -->|miss 또는 장애| DB[(PostgreSQL)]
DB -->|활성 데이터| Save[Redis에 TTL과 함께 저장]
Save --> Response
DB -->|없음 또는 삭제됨| NotFound[Not Found]
classDef source fill:#1f6feb,color:#fff,stroke:#1f6feb;
classDef cache fill:#d97706,color:#fff,stroke:#d97706;
class DB source;
class Cache,Save cache;
Redis는 응답 속도를 높이는 역할을 하지만, 데이터의 존재 여부를 결정하는 곳은 PostgreSQL입니다.
Cache Aside 전략에서 생긴 고민
조회에는 Cache Aside 전략을 사용했습니다.
- Redis에서 데이터를 조회한다.
- 캐시에 데이터가 있으면 바로 반환한다.
- 캐시에 데이터가 없으면 PostgreSQL에서 조회한다.
- 조회한 데이터를 Redis에 저장한 뒤 반환한다.
@Slf4j
@Service
@RequiredArgsConstructor
public class HubRouteQueryService {
private static final Duration CACHE_TTL = Duration.ofHours(1);
private final HubRouteRepository hubRouteRepository;
private final RedisTemplate<String, HubRouteResponse> redisTemplate;
public HubRouteResponse getRoute(UUID routeId) {
String cacheKey = "hubRouteById:" + routeId;
try {
HubRouteResponse cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return cached;
}
} catch (RuntimeException exception) {
// Redis 조회 실패가 원본 데이터 조회까지 막지 않도록 fallback
log.warn("Redis cache read failed. key={}", cacheKey, exception);
}
HubRoute route = hubRouteRepository.findActiveById(routeId)
.orElseThrow(HubRouteNotFoundException::new);
HubRouteResponse response = HubRouteResponse.from(route);
try {
redisTemplate.opsForValue().set(cacheKey, response, CACHE_TTL);
} catch (RuntimeException exception) {
// 캐시 저장에 실패해도 PostgreSQL에서 조회한 값은 반환합니다.
log.warn("Redis cache write failed. key={}", cacheKey, exception);
}
return response;
}
}
구조는 단순했지만 데이터가 변경되는 순간에는 race condition이 발생할 수 있습니다.
sequenceDiagram
autonumber
participant A as 요청 A: 삭제
participant DB as PostgreSQL
participant B as 요청 B: 조회
participant R as Redis
A->>DB: soft delete 시작
B->>R: 캐시 조회
R-->>B: cache miss
B->>DB: 아직 commit 전인 기존 데이터 조회
DB-->>B: 기존 데이터 반환
A->>DB: commit
A->>R: AFTER_COMMIT 캐시 제거
B->>R: 이전 데이터를 뒤늦게 저장
Note over R: 삭제된 데이터가 다시 캐싱됨
예를 들어 요청 A가 RDB에서 데이터를 삭제하는 동안 요청 B가 같은 데이터를 조회하면, 삭제 트랜잭션이 커밋되기 전에 기존 데이터를 가져올 수 있습니다.
그 뒤 요청 A가 트랜잭션을 커밋하고 캐시를 제거하더라도, 요청 B가 앞서 조회한 데이터를 Redis에 뒤늦게 저장하면 삭제된 데이터가 다시 캐싱될 수 있습니다.
따라서 캐시를 언제 제거할지뿐만 아니라 조회 요청으로 인해 데이터가 다시 캐싱될 가능성도 고려해야 했습니다.
AFTER_COMMIT으로 캐시 무효화 시점 조정하기
캐시를 DB 변경보다 먼저 제거하면 이후 DB 트랜잭션이 롤백되었을 때 정상 데이터의 캐시만 사라지는 문제가 발생합니다. 따라서 DB 변경이 확정된 이후 캐시를 무효화하도록 AFTER_COMMIT 방식을 고려했습니다.
데이터 조회 및 잠금 -> soft delete -> TX commit -> 캐시 제거
Spring에서는 트랜잭션 안에서 이벤트를 발행하고 해당 이벤트를 AFTER_COMMIT 단계에서 처리할 수 있습니다.
public record HubRoutesDeletedEvent(List<UUID> routeIds) {
}
@Service
@RequiredArgsConstructor
public class HubCommandService {
private final HubRepository hubRepository;
private final HubRouteRepository hubRouteRepository;
private final ApplicationEventPublisher eventPublisher;
@Transactional
public void deleteHub(UUID hubId, UUID deletedBy) {
Hub hub = hubRepository.findActiveByIdForUpdate(hubId)
.orElseThrow(HubNotFoundException::new);
List<HubRoute> routes = hubRouteRepository.findAllConnectedTo(hubId);
routes.forEach(route -> route.softDelete(deletedBy));
hub.softDelete(deletedBy);
List<UUID> deletedRouteIds = routes.stream()
.map(HubRoute::getId)
.toList();
eventPublisher.publishEvent(new HubRoutesDeletedEvent(deletedRouteIds));
}
}
@Component
@RequiredArgsConstructor
public class HubRouteCacheEvictionListener {
private final HubRouteCacheEvictor cacheEvictor;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void evictDeletedRoutes(HubRoutesDeletedEvent event) {
event.routeIds().forEach(cacheEvictor::evictRouteById);
cacheEvictor.evictAllRoutePaths();
}
}
이 방식에서는 DB 트랜잭션이 롤백되면 AFTER_COMMIT 리스너가 실행되지 않습니다. 정상 데이터의 캐시를 불필요하게 제거하는 문제를 줄일 수 있습니다.
다만 AFTER_COMMIT이 DB와 Redis의 정합성을 완전히 보장하는 것은 아닙니다. 커밋 이후 Redis에서 캐시를 삭제하는 데 실패할 수도 있고, 동시에 실행된 조회 요청이 캐시 무효화 이후에 이전 데이터를 다시 저장할 수도 있습니다.
AFTER_COMMIT은 데이터 정합성을 보장하는 방법이라기보다 DB 변경이 확정된 뒤 캐시를 무효화하도록 처리 시점을 조정하는 방법에 가깝습니다.
또한 이벤트 리스너에서 예외가 발생해도 이미 완료된 DB 커밋은 되돌릴 수 없으므로, 실패를 기록하고 재처리하는 방법을 별도로 고민해야 합니다.
레디스 장애를 PostgreSQL 장애로 볼 것인가
레디스에서 오류가 발생했을 때 DB 트랜잭션을 실패시킬 것인지도 중요한 선택이었습니다. 캐시 제거 실패를 이유로 삭제 자체를 실패시키면 레디스 장애가 핵심 비즈니스 장애로 확대됩니다. 이렇게 되면 사용자는 PostgreSQL이 정상이어도 삭제가 실패하게 됩니다. 반대로 레디스 오류를 무시하면 DB 작업이 성공하지만 오래된 캐시가 남을 수 있습니다.
PostgreSQL을 SSoT로 두기로 했기 때문에 레디스 오류가 DB 작업을 막지 않도록 하는 fail-open 방식이 적절하다고 생각했습니다.
- Redis 오류를 로그로 남긴다.
- 캐시에 TTL을 적용한다.
- 필요한 경우 캐시 제거를 재시도한다.
- 반복적인 실패를 모니터링한다.
- 중요한 cache invalidation은 Outbox 기반 재처리를 검토한다.
Spring Cache를 사용한다면 CacheErrorHandler에서 캐시 오류가 비즈니스 예외로 전파되지 않도록 처리할 수 있습니다.
@Slf4j
public class LoggingCacheErrorHandler implements CacheErrorHandler {
@Override
public void handleCacheGetError(RuntimeException exception, Cache cache, Object key) {
log.warn("Cache get failed. cache={}, key={}", cache.getName(), key, exception);
}
@Override
public void handleCachePutError(
RuntimeException exception,
Cache cache,
Object key,
Object value
) {
log.warn("Cache put failed. cache={}, key={}", cache.getName(), key, exception);
}
@Override
public void handleCacheEvictError(RuntimeException exception, Cache cache, Object key) {
log.warn("Cache evict failed. cache={}, key={}", cache.getName(), key, exception);
}
@Override
public void handleCacheClearError(RuntimeException exception, Cache cache) {
log.warn("Cache clear failed. cache={}", cache.getName(), exception);
}
}
CacheErrorHandler는 Spring Cache 추상화를 통해 실행된 작업에만 적용됩니다. RedisTemplate을 직접 사용한다면 예외 처리와 fallback을 직접 구현해야 합니다.
fail-open은 레디스의 오류를 무시하는 방식이 아닙니다. 사용자의 요청을 우선적으로 처리하고 캐시 정합성 문제를 별도의 복구 대상으로 다루는 방식입니다.
비관적 락과 분산 락
데이터를 삭제하는 동안 다른 요청이 같은 데이터를 변경하면 동시성 문제가 발생할 수 있습니다. 이 문제를 해결하기 위해 비관적 락을 적용하는 방안을 고민했습니다.
처음에는 여러 서버 인스턴스에서 동시에 요청이 들어오면 분산 락이 필요하겠다고 생각했습니다. 하지만 비관적 락은 애플리케이션 서버가 아닌 PostgreSQL에서 관리하므로, 여러 서버가 같은 PostgreSQL을 사용하더라도 동일한 데이터에 대한 동시 변경을 제어할 수 있었습니다.
Spring Data JPA에서는 다음과 같이 삭제 대상을 락을 걸고 조회할 수 있습니다.
public interface HubRepository extends JpaRepository<Hub, UUID> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
select h
from Hub h
where h.id = :hubId
and h.deletedAt is null
""")
Optional<Hub> findActiveByIdForUpdate(@Param("hubId") UUID hubId);
}
PostgreSQL에서는 코드에 설정한 비관적 락에 대응하는 SELECT FOR UPDATE가 실행됩니다.
SELECT *
FROM hubs
WHERE hub_id = :hubId
AND deleted_at IS NULL
FOR UPDATE;
sequenceDiagram
participant A as 서버 1의 요청 A
participant DB as PostgreSQL
participant B as 서버 2의 요청 B
A->>DB: Hub 행 SELECT FOR UPDATE
DB-->>A: 잠금 획득
B->>DB: 같은 Hub 수정 요청
Note over B,DB: 요청 B는 A의 transaction 종료까지 대기
A->>DB: Hub와 HubRoute soft delete
A->>DB: commit 후 잠금 해제
DB-->>B: 잠금 획득 또는 삭제 상태 확인
프로젝트에서는 여러 서비스가 단일 PostgreSQL 인스턴스를 사용하고 스키마만 분리한 구조였습니다. 따라서 동일한 DB의 데이터를 대상으로 하는 경쟁은 PostgreSQL이 제공하는 비관적 락으로 제어할 수 있었습니다.
따라서 두 문제를 분리해서 접근했습니다.
- 동일한 데이터의 동시 수정 및 삭제 → PostgreSQL 비관적 락
- DB 변경 이후 레디스 정합성 →
AFTER_COMMIT을 통한 캐시 무효화 및 TTL
분산 락은 서버가 여러 대라는 이유만으로 사용하는 것은 아닙니다. DB 트랜잭션만으로 보호하기 어려운 작업을 여러 서버가 동시에 수행하지 못하도록 제어해야 할 때 적합합니다. 분산 락도 고려했지만, 현재 문제는 PostgreSQL 내부의 동시 변경 제어가 핵심이므로 비관적 락으로 충분하다고 판단했습니다.
어떤 락을 선택할지는 서버 수보다 보호해야 할 작업의 범위를 기준으로 판단하는 것이 중요하다고 생각했습니다.
마무리
처음에는 데이터가 변경될 때 캐시를 함께 제거하면 정합성에 문제가 없을 거라고 생각했습니다. 하지만 실제로는 트랜잭션의 성공 여부, 캐시 제거 시점, Redis 장애와 동시 요청까지 함께 고려해야 할 요소가 많았습니다.
이번 경험을 통해 어떤 데이터를 신뢰할지, 불일치가 발생했을 때 어떻게 복구할지를 명확히 정하는 것이 중요하다는 사실을 배웠습니다.
정합성을 유지하려면 이처럼 다양한 개념과 기술을 검토하고 여러 판단을 내려야 한다는 사실을 알게 되었습니다. Outbox, retry 등 앞으로 더 살펴볼 주제가 남아 있지만, 추가로 공부한 뒤 다음 글에서 정리해 보려고 합니다.