RDBMS, MongoDB와 트랜잭션

양성준·2025년 6월 22일

스프링

목록 보기
41/49

@Transactional에서 RDBMS 작업과 MongoDB 작업이 있을 때, 예외가 던져지면 RDMBS 작업만 롤백되는 이유

: Spring의 기본 @Transactional은 RDBMS 트랜잭션만 관리하고, MongoDB 트랜잭션은 별도로 관리하지 않기 때문

  • @Transactional은 기본적으로 PlatformTransactionManager를 통해 동작
    • RDBMS 사용 시 → JpaTransactionManager (자동 등록)
    • MongoDB 사용 시 → MongoTransactionManager (별도 Bean 등록 필요)
  • 하나의 @Transactional에 두 가지 트랜잭션 매니저를 동시에 묶어 관리할 수 없다.
@Transactional // 트랜잭션 매니저를 명시하지 않으면 기본적으로 JpaTransactionManager만 적용됨
public void doSomething() {
  rdbmsRepository.save(...);     // 트랜잭션 관리됨
  mongoRepository.save(...);     // 트랜잭션 관리 안됨 (그냥 일반 커밋됨)
  throw new RuntimeException();  // → RDBMS만 롤백됨
}
  • RDBMS의 경우 트랜잭션 매니저나 @Transactional로 flush, commit을 해줘야 DB에 반영되지만, mongoDB의 경우 flush/commit의 개념이 없어 단일 문서 조작에서는 바로 DB에 반영됨 (롤백 불가능)
  • 예외가 발생했을 때, RDBMS는 롤백됐는데 MongoDB는 롤백되지 않아 둘의 데이터가 맞지 않는 상황 발생
  • 위는 예시일 뿐, 이러한 이유로 RDBMS와 MongoDB 작업을 같은 @Transactional 메서드에 두는 것은 매우 위험하다.

=> 그러므로 예외가 발생해서 던져지면, 트랜잭션 매니저에 의해 관리되는 RDBMS 작업만 롤백되는 것.

MongoDB에서의 트랜잭션

MongoDB에서 단일 문서에 대한 작업은 원자적으로 이루어집니다. 임베디드 문서와 배열을 사용하면 여러 문서와 컬렉션에 걸쳐 정규화하는 대신 단일 문서 구조에서 데이터 간의 관계를 캡처할 수 있으므로 이러한 단일 문서 원자성은 많은 실제 사용 사례에서 분산 트랜잭션의 필요성을 없애줍니다.
여러 문서 (단일 또는 여러 컬렉션)에 대한 읽기 및 쓰기의 원자성이 필요한 상황의 경우, MongoDB는 분산 트랜잭션을 지원합니다. 분산 트랜잭션을 사용하면 여러 작업, 컬렉션, 데이터베이스, 문서 및 샤드에서 트랜잭션을 사용할 수 있습니다.
출처 - https://www.mongodb.com/ko-kr/docs/manual/core/transactions/ MongoDB 공식문서

  • MongoDB는 단일 문서에 대한 모든 연산(삽입/수정/삭제)을 원자적(atomic)으로 처리
    • 한 문서 내 여러 필드 변경도 전체 성공 또는 전체 실패
      => 단일 문서에 대해 트랜잭션을 사용할 필요가 없다.
  • 만약 하나의 논리로 연결된 여러 문서에 대한 작업이 필요하다면, 그 때 MongoDB의 트랜잭션을 사용하면 된다.
    • standAlone mongoDB에서는 사용 불가능 (단일 인스턴스로 동작하는 MongoDB)
    • MongoDB 4.0 버전 이상 + Replica Set 또는 Sharded Cluster 환경에서만 사용 가능
      • 복수 개의 MongoDB 인스턴스(Primary + Secondary)가 클러스터로 묶여 있는 구조
        • Primary: 클라이언트의 쓰기/읽기 요청 처리
        • Secondary: Primary를 복제하여 읽기 가능 (쓰기 X)
          • Secondary는 oplog(operation log)를 기반으로 Primary 데이터 실시간 복제
      • Oplog 의존성: 트랜잭션의 롤백/복제를 위해 변경 이력 추적이 반드시 필요.
        • Oplog는 복제본 간 데이터 동기화를 위한 변경 이력 저장소로, Replica Set에서만 제공
      • 고가용성 요구: Primary 장애 시 Secondary가 트랜잭션 이어가려면 복제본이 필수.
        => 이러한 이유로, MongoDB Atlas 측에서는 트랜잭션을 Replica set 환경 이상으로 제한하고 있다.
  • 즉, MongoDB 트랜잭션을 Spring에서 사용하려면, MongoTransactionManager 별도 빈 등록 및 명시 + Replica set 환경 구축이 필수

MongoDB 트랜잭션 도입 실전 가이드 참고자료 (replica set 설정 방법 등)
https://jh2021.tistory.com/24
https://oliveyoung.tech/2024-12-17/catalog-mongo-transaction-2/

만약 MongoDB 작업이 실패했을 때, RDBMS를 롤백시키고 싶다면?

SAGA 패턴 (보상 트랜잭션)

  • SAGA 패턴은 분산 시스템(특히 마이크로서비스 아키텍처)에서 데이터 일관성을 유지하기 위해 고안된 분산 트랜잭션 관리 패턴이다.
    • 각 서비스는 로컬 트랜잭션을 독립적으로 수행하고, 전체 트랜잭션의 일관성은 보상 트랜잭션(Compensating Transaction)을 통해 관리함
    • 앞선 트랜잭션이 성공했을 때만 다음 트랜잭션을 실행하고, 실패 시 앞선 트랜잭션을 보상하는 트랜잭션(compensating transaction)을 수행하는 패턴"
    • 여러 독립적인 데이터 저장소에 걸친 비즈니스 트랜잭션을 관리하는 패턴
      • SAGA 패턴은 아키텍처(모놀리스/마이크로서비스)가 아닌 문제 유형(분산 트랜잭션) 에 초점
        => 모놀리스 구조 - 분산 데이터베이스 트랜잭션 환경에서도 사용 가능
  • SAGA 패턴의 전제는 앞선 트랜잭션이 성공 -> 다음 트랜잭션 실패 시 앞선 트랜잭션을 보상
 // SubscriptionService
  @Transactional
  public SubscriptionDto create(Long interestId, Long userId) {
    Subscription subscription = new Subscription(user, updatedInterest);
    subscriptionRepository.save(subscription);

    SubscriptionDto subscriptionDto = subscriptionMapper.toDto(subscription);

    eventPublisher.publishEvent(SubscriptionCreateEvent.builder()
        .subscriptionDto(subscriptionDto)
        .userId(userId)
        .build());

    return subscriptionDto;
  }
  
  // SubscriptionEventListener
  @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
  @Async
  public void handleSubscriptionCreateEvent(SubscriptionCreateEvent subscriptionCreateEvent) {
        Query query = Query.query(Criteria.where("_id").is(subscriptionCreateEvent.userId()));
        Update update = new Update()
            .push("subscriptions", subscriptionCreateEvent.subscriptionDto())
            .set("updatedAt", LocalDateTime.now());
        mongoTemplate.updateFirst(query, update, SubscriptionActivity.class);
  }
  • Transactionphase.AFTER_COMMIT으로 트랜잭션이 분리되어 있음
    (MongoDB 트랜잭션을 사용하지 않더라도, RDBMS 트랜잭션이 커밋된 후 독립적으로 동작)
    -> SAGA 패턴으로 보상 트랜잭션 설계
// CompensationService (신규 생성)
@Service
@RequiredArgsConstructor
public class SubscriptionCompensationService {
    private final SubscriptionRepository subscriptionRepository;
    private final InterestRepository interestRepository;

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void rollbackSubscription(Long subscriptionId, Long interestId) {
        // 1. 구독 삭제 (멱등성 보장)
        if (subscriptionRepository.existsById(subscriptionId)) {
            subscriptionRepository.deleteById(subscriptionId);
        }
        
        // 2. 관심사 카운트 복구
        interestRepository.decrementSubscriberCount(interestId);
    }
}

// SubscriptionEventListener.java
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Async
public void handleSubscriptionCreateEvent(SubscriptionCreateEvent event) {
    try {
            // MongoDB 활동 내역 저장
            Query query = Query.query(Criteria.where("_id").is(event.userId()));
            Update update = new Update()
                .push("subscriptions", event.subscriptionDto())
                .set("updatedAt", LocalDateTime.now());
            mongoTemplate.updateFirst(query, update, SubscriptionActivity.class);
    } catch (Exception e) {
        // MongoDB 작업이 실패했다면 -> SAGA 보상 트랜잭션 실행
        compensationService.rollbackSubscription(
            event.subscriptionId(), 
            event.interestId()
        );
        
        log.error("활동 내역 생성 실패 및 구독 롤백 - userId={}, subscriptionId={}", 
            event.userId(), event.subscriptionId(), e);
    }
}
  • 이렇게 보상 트랜잭션으로, MongoDB 작업이 실패했을 경우 이전 트랜잭션에서 성공한 RDBMS 작업을 철회할 수 있다.

프로젝트 예시

 // SubscriptionService
  @Transactional
  public SubscriptionDto create(Long interestId, Long userId) {
    Subscription subscription = new Subscription(user, updatedInterest);
    subscriptionRepository.save(subscription);

    SubscriptionDto subscriptionDto = subscriptionMapper.toDto(subscription);

    eventPublisher.publishEvent(SubscriptionCreateEvent.builder()
        .subscriptionDto(subscriptionDto)
        .userId(userId)
        .build());

    return subscriptionDto;
  }
  
  // SubscriptionEventListener
  @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
  @Async
  public void handleSubscriptionCreateEvent(SubscriptionCreateEvent subscriptionCreateEvent) {
        Query query = Query.query(Criteria.where("_id").is(subscriptionCreateEvent.userId()));
        Update update = new Update()
            .push("subscriptions", subscriptionCreateEvent.subscriptionDto())
            .set("updatedAt", LocalDateTime.now());
        mongoTemplate.updateFirst(query, update, SubscriptionActivity.class);
  }
  • 프로젝트에서 @Async에 @Transactional 안 붙인 이유: 발행하는 쪽의 트랜잭션은 RDBMS고, 이벤트를 받는 쪽에서는 MongoDB를 썼기 때문이다.
    • 단일 문서 수정 시: 트랜잭션 불필요 (MongoDB 단일 문서 연산은 원자적)
    • @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)로 RDBMS의 트랜잭션이 끝난 뒤 이벤트가 실행되게끔 했고, MongoDB의 경우 @Transactional로 관리하지 않아도 write()하면 flush없이 바로 DB에 반영됨
  • MongoDB 작업이 실패할 경우 보상 트랜잭션을 따로 만들어주지 않았는데, 이는 비즈니스적으로 "활동 내역 저장 실패"는 사용자 구독 상태와 직접적인 정합성 문제가 없기 때문이다.
    • 즉, 구독 자체(RDBMS의 Subscription 엔티티)는 이미 성공적으로 완료된 사실이며, 이후 MongoDB에 저장하려던 활동 내역은 단순 이력성 데이터이기 때문에 필수 보상 처리가 필요하지 않다고 판단했다.
    • 그렇게 되면, RDBMS와 MongoDB 간의 정합성 문제가 일시적으로 발생할 수 있는데,
      이는 정기적인 스케줄러 배치 작업을 통해 후처리 방식으로 동기화하여 해결하였다.
    • 예: 구독된 사용자 목록과 MongoDB의 활동 내역(subscriptions)이 불일치할 경우,
      RDBMS 데이터를 기준으로 MongoDB를 보정(sync)하는 배치 프로세스를 주기적으로 실행
@Slf4j
@Service
@AllArgsConstructor
public class CommentActivityBatchService {

    private final UserRepository userRepository;
    private final CommentRepository commentRepository;
    private final MongoTemplate mongoTemplate;
    private final RetryTemplate retryTemplate;

    @Transactional
    public void syncAll() {
        log.info("[배치 시작] 작성한 댓글 MongoDB 동기화");

        List<User> users = userRepository.findAll();
        List<WriteModel<Document>> operations = new ArrayList<>();
        List<Long> failedUserIds = new ArrayList<>();

        for (User user : users) {
            try {
                List<Comment> comments = commentRepository.findByUser_IdAndIsDeletedFalseOrderByCreatedAtDesc(user.getId());

                List<CommentActivityDto> commentDtos = comments.stream()
                    .map(comment -> CommentActivityDto.builder()
                        .id(comment.getId())
                        .articleId(comment.getArticle().getId())
                        .articleTitle(comment.getArticle().getTitle())
                        .userId(user.getId())
                        .userNickname(user.getNickname())
                        .content(comment.getContent())
                        .likeCount(comment.getLikeCount())
                        .createdAt(comment.getCreatedAt())
                        .build())
                    .toList();

                Document document = new Document();
                document.put("_id", user.getId());
                document.put("comments", commentDtos);
                LocalDateTime now = LocalDateTime.now();
                document.put("createdAt", now);
                document.put("updatedAt", now);

                Query query = Query.query(Criteria.where("_id").is(user.getId()));
                ReplaceOneModel<Document> replaceModel = new ReplaceOneModel<>(
                    query.getQueryObject(),
                    document,
                    new ReplaceOptions().upsert(true)
                );

                operations.add(replaceModel);
            } catch (Exception e) {
                log.error("작성한 댓글 활동 동기화 실패 - userId: {}, reason: {}", user.getId(), e.getMessage(), e);
                failedUserIds.add(user.getId());
            }
        }

        if (!operations.isEmpty()) {
            try {
                retryTemplate.execute(context -> {
                    mongoTemplate.getCollection("comment_activities")
                        .bulkWrite(operations, new BulkWriteOptions().ordered(false));
                    log.info("총 {}건의 CommentActivity 문서가 bulkWrite 되었습니다.", operations.size());
                    return null;
                });
            } catch (Exception e) {
                log.error("bulkWrite 재시도 실패 - 최대 재시도 횟수 초과", e);
                throw new RestException(
                    MAX_RETRY_EXCEEDED,
                    Map.of(
                        "detail", "댓글 활동 내역 bulkWrite 최종 실패",
                        "skippedUserIds", failedUserIds.toString()
                    )
                );
            }
        }

        log.info("[배치 종료] 작성한 댓글 MongoDB 동기화 완료");
        log.info("[요약] 전체 사용자 수: {}, 처리 성공 수: {}, 실패 사용자 수: {}",
            users.size(), operations.size(), failedUserIds.size());
    }
}
  • 현재 작업에서 RDBMS 조회 -> MongoDB 단일 문서 수정 반복 작업이기 때문에, 굳이 @Transactional을 붙여줄 이유가 없다.
    • MongoDB 단일 문서 연산의 경우, 원자적으로 처리됨 -> 트랜잭션 사용 불필요 -> 단일 문서가 실패할 경우, 그 문서만 DB에 적용 X
    • 실패한 문서는 다음 스케줄러 배치 작업에서 재처리되기 때문에, 전체적으로 데이터 정합성이 유지된다.
profile
백엔드 개발자를 꿈꿉니다.

0개의 댓글