Write Concern과 Read Preference

MongoDB에서 Read Preference와 Write Concern은 데이터의 일관성, 가용성, 성능 간의 트레이드오프를 조절하는 핵심 설정이다. 특히 레플리카 셋 환경에서 이 설정들은 트랜잭션과 데이터 처리에 직접적인 영향을 미친다

Write Concern – 쓰기 확인 수준

Write Concern은 쓰기 작업이 얼마나 많은 노드에 반영되었는지 확인하는 설정이다. 데이터의 내구성과 성능 간의 균형을 조절한다

Write Concern 옵션 상세

Acknowledged (기본값)
  • 동작 방식: Primary 노드가 쓰기 작업을 메모리에 적용하고 즉시 성공 응답을 반환한다
  • 장점: 응답 속도가 빠르며 대부분의 경우에 적합한 기본 설정이다
  • 단점: Secondary 노드로 복제되기 전 Primary 장애 시 데이터 손실 가능성이 있으며 네트워크 장애 시 데이터 유실 위험이 존재한다
  • 사용 사례: 일반적인 CRUD 작업, 성능과 신뢰성이 균형이 필요한 경우
@Configuration
public class MongoConfig {
    
    @Bean
    public MongoTemplate mongoTemplate(MongoDatabaseFactory mongoDbFactory) {
        MongoTemplate template = new MongoTemplate(mongoDbFactory);
        template.setWriteConcern(WriteConcern.ACKNOWLEDGED);
        return template;
    }
}
Unacknowledged (Fire and Forget)
  • 동작 방식: 요청 전송 즉시 성공을 응답하며 실제 저장 여부와 무관하게 성공을 반환한다
  • 장점: 가장 빠른 쓰기 성능
  • 단점: 데이터 저장 실패 여부를 알 수 없고 데이터 손실 위험이 매우 높다. 실무 서비스에는 적합하지 않다
  • 사용 사례: 손실되어도 무방한 로그 데이터나 임시 캐시 데이터로 실무에서는 많이 사용하지 않는 편이다
mongoTemplate.setWriteConcern(WriteConcern.UNACKNOWLEDGED);
Majority (권장)
  • 동작 방식: 레플리카 셋의 과반수 노드에 데이터 반영을 대기하며 과반수 합의 후 성공을 응답한다
  • 장점: 높은 데이터 일관성을 보장, Primary 장애 시에도 데이터 보존이 가능하며 분산 시스템에서 안정적이다
  • 단점: 복제 대기로 인해 레이턴시가 증가하며 네트워크 상태에 따른 성능 변동성이 있다
  • 사용 사례: 금융 거래, 중요한 비즈니스 데이터, 데이터 무결성과 일관성이 최우선인 경우
@Service
public class CriticalDataService {
    
    @Autowired
    private MongoTemplate mongoTemplate;
    
    public void saveCriticalData(Transaction transaction) {
        // Majority 설정으로 안전성 보장
        mongoTemplate.setWriteConcern(WriteConcern.MAJORITY);
        mongoTemplate.save(transaction);
    }
}
레플리카 셋 구성 예시
Primary + 2 Secondary (총 3개 노드)
→ Majority = 2개 노드에 반영 필요

Primary + 4 Secondary (총 5개 노드)
→ Majority = 3개 노드에 반영 필요
W1, W2, W3 (숫자 지정)
  • 동작 방식: 지정한 N개의 노드에 쓰기 반영을 확인한다. Majority와 유사하지만 노드 수를 직접 지정한다
  • 장점: 세밀한 제어가 가능하며 비즈니스 요구사항에 맞춤 설정이 가능하다
  • 단점: 노드 수 변경 시 설정 재조정이 필요하며 잘못된 설정 시 성능 저하 될 수 있다
  • 사용 사례: 특정 개수의 백업이 필요한 경우나 커스텀 복제 정책이 필요한 경우
// 2개 노드에 반영될 때까지 대기
WriteConcern w2 = WriteConcern.W2;
mongoTemplate.setWriteConcern(w2);

// 3개 노드에 반영될 때까지 대기
WriteConcern w3 = WriteConcern.W3;
mongoTemplate.setWriteConcern(w3);
Journaled
  • 동작 방식: 쓰기 작업이 디스크의 저널(Journal) 파일에 기록될 때까지 대기한다
    • 저널: 장애 복구를 위한 트랜잭션 로그
  • 장점: 서버 장애 시에도 데이터 복구가 가능하며 최고 수준의 내구성을 보장한다
  • 단점: 디스크 I/O로 인한 성능 저하와 가능 느린 응답 시간
  • 사용 사례: 절대 손실되어선 안 되는 데이터와 감사 로그, 금융 거래 기록등이 있다. 실무에서는 Majority + Journal 조합을 사용한다
WriteConcern journaled = WriteConcern.JOURNALED;
mongoTemplate.setWriteConcern(journaled);
저널(Journal) 메커니즘
1. 쓰기 요청 수신
2. 메모리에 변경사항 적용
3. 저널 파일에 기록 (디스크)
4. 실제 데이터 파일에 기록
5. 클라이언트에 응답

Write Concern 비교표

옵션응답 시점성능안정성데이터 손실 위험
Unacknowledged요청 즉시최고최하매우 높음
AcknowledgedPrimary 메모리높음중간중간
Majority과반수 노드중간높음낮음
W2/W3N개 노드중간높음낮음
Journaled디스크 저널낮음최고최소
Write Concern 선택 가이드
@Service
public class DataService {
    
    @Autowired
    private MongoTemplate mongoTemplate;
    
    // 일반 데이터: Acknowledged (기본값)
    public void saveGeneralData(User user) {
        mongoTemplate.save(user);
    }
    
    // 중요 데이터: Majority
    public void saveCriticalData(Order order) {
        mongoTemplate.setWriteConcern(WriteConcern.MAJORITY);
        mongoTemplate.save(order);
    }
    
    // 초중요 데이터: Majority + Journal
    public void saveVitalData(Payment payment) {
        WriteConcern vital = WriteConcern.MAJORITY.withJournal(true);
        mongoTemplate.setWriteConcern(vital);
        mongoTemplate.save(payment);
    }
    
    // 로그 데이터: Acknowledged (빠른 처리)
    public void saveLog(LogEntry log) {
        mongoTemplate.setWriteConcern(WriteConcern.ACKNOWLEDGED);
        mongoTemplate.save(log);
    }
}

레플리카 셋 필수

  • 고급 Write Concern 옵션들(w>1, majority 등)은 레플리카 셋 환경에서 진정한 의미를 갖는다. Standalone에서는 대부분의 고급 옵션이 제한적으로만 동작하거나 무시된다
  • Standalone 노드에서는 대부분의 옵션이 무시된다
  • 프로덕션 환경에서는 반드시 레플리카 셋 구성이 필요하다

실무 적용

  • 대부분의 경우 기본값 (Acknowledged)을 사용한다
  • 쓰기보다 읽기 트래픽이 많은 시스템에서는 Write Concern 조정 효과가 제한적이다
  • 서비스의 성격에 따라 중요한 쓰기 경로에만 Majority를 선택적으로 적용

Read Preference – 읽기 노드 선택

Read Preference는 레플리카 셋 환경에서 읽기 작업이 어떤 노드로 라우팅될지 결정하는 설정이다

Read Preference가 중요한 이유

데이터 일관성 (Consistency)
  • 사용자에게 얼마나 최신 데이터를 제공할 것인가
  • 복제 지연(Replication Lag)으로 인한 불일치 허용
고가용성 (Availability)
  • Primary 노드 장애 시에도 데이터 읽기 가능 여부
  • 서비스 무중단 전략
성능 (Performance)
  • 노드 간 부하 분산과 네트워크 지연 최소화
글로벌 서비스 (Global Distribution)
  • 지역별 분산 노드 활용
  • 사용자와 가까운 노드에서 데이터 제공 (CDN 개념)

Read Preference 옵션 상세

Primary (기본값)
  • 동작 방식: 모든 읽기 요청을 Primary 노드로 전달하며 항상 최신 데이터를 보장한다
  • 장점: 강력한 데이터 일관성과 쓰기 직후 즉시 읽기가 가능하며 예측 가능한 동작을 한다
  • 단점: Primary 장애 시 읽기가 불가하여 가용성이 저하되고 Primary에 모든 부하가 집중된다. 스케일 아웃 효과가 없다
  • 사용 사례: 은행 시스템, 결제 처리, 이커머스 주문, 실시간 재고 관리
@Service
public class OrderService {
    
    @Autowired
    private MongoTemplate mongoTemplate;
    
    public Order getOrder(ObjectId orderId) {
        // Primary에서 최신 데이터 조회
        Query query = Query.query(Criteria.where("_id").is(orderId));
        query.withReadPreference(ReadPreference.primary());
        
        return mongoTemplate.findOne(query, Order.class);
    }
}
Secondary
  • 동작 방식: 모든 읽기 요청을 Secondary 노드로 분산하고 여러 Secondary 중 랜덤하게 선택한다
  • 장점: Primary 부하 분산, 읽기 스케일 아웃 가능, 대용량 조회에 유리하며 일부 노드 장애 시에도 작동하는 높은 가용성
  • 단점: 복제 지연으로 인한 약한 일관성과 최신 데이터 보장이 불확실하다. 노드마다 다른 데이터 상태가 가능하다
  • 사용 사례: 토계 데이터 조회, 리포트 생성, 대용량 배치 작업, 실시간성이 덜 중요한 데이터
@Service
public class StatisticsService {
    
    @Autowired
    private MongoTemplate mongoTemplate;
    
    public List<Statistics> getDailyStatistics(Date date) {
        // Secondary에서 통계 데이터 조회 (Primary 부하 방지)
        Query query = Query.query(
            Criteria.where("date").is(date)
        );
        query.withReadPreference(ReadPreference.secondary());
        
        return mongoTemplate.find(query, Statistics.class);
    }
    
    // 대용량 리포트 생성
    public void generateMonthlyReport(int year, int month) {
        Query query = Query.query(
            Criteria.where("year").is(year)
                .and("month").is(month)
        );
        query.withReadPreference(ReadPreference.secondary());
        
        // 많은 데이터를 Secondary에서 처리
        List<Transaction> transactions = mongoTemplate.find(
            query, Transaction.class
        );
        
        // 리포트 생성 로직...
    }
}
Primary Preferred
  • 동작 방식: 기본적으로 Primary에서 읽으며 Primary 장애 시 Secondary로 폴백한다
  • 장점: 일반적으로 최신 데이터를 제공하고 장애 상황에서도 서비스가 자속되며 유지보수 시에도 읽기가 가능하다
  • 단점: 폴백 시 데이터 일관성이 저하되고 Primary → Secondary 전환 시 데이터 불일치가 될 수 있다. 그리고 디버깅이 복잡하다
주의사항
// 잘못된 사용 예시
@Transactional
public void processData(ObjectId id) {
    // Primary에 저장
    Data data = new Data();
    mongoTemplate.save(data);
    
    // Primary Preferred로 조회
    // Primary 장애 시 Secondary에서 조회
    // → 방금 저장한 데이터가 아직 복제 안 됨
    Query query = Query.query(Criteria.where("_id").is(data.getId()));
    query.withReadPreference(ReadPreference.primaryPreferred());
    Data found = mongoTemplate.findOne(query, Data.class);
    // found가 null일 수 있음
}
  • 사용 사례
    • 글로벌 서비스 (다중 지역 배포), 무중단 서비스가 최우선
    • Primary 장애 시 최소 기능이라도 유지가 필요한 경우, 약간의 데이터 불일치를 허용한는 경우
@Service
public class UserProfileService {
    
    @Autowired
    private MongoTemplate mongoTemplate;
    
    // 사용자 프로필 조회 (Primary Preferred)
    public UserProfile getUserProfile(ObjectId userId) {
        Query query = Query.query(Criteria.where("_id").is(userId));
        query.withReadPreference(ReadPreference.primaryPreferred());
        
        return mongoTemplate.findOne(query, UserProfile.class);
    }
}
Secondary Preferred
  • 동작 방식: 기본적으로 Secondary에서 읽으며 모든 Secondary 장애 시 Primary로 볼팩한다
  • 장점: Primary 부하 최소화, Secondary 활용 극대화, 일부 Secondary 장애에도 계속 작동한다
  • 단점: 복제 지연 (Replication Lag)이 항상 존재하며 데이터 일관성이 가장 약하다
  • 사용 사례: 대규모 통계 처리, 로그 분석, 모니터링 시스템, 실시간성이 매우 낮은 데이터
@Service
public class AnalyticsService {
    
    @Autowired
    private MongoTemplate mongoTemplate;
    
    // 대용량 로그 분석 (Secondary Preferred)
    public AnalyticsResult analyzeUserBehavior(Date startDate, Date endDate) {
        Query query = Query.query(
            Criteria.where("timestamp")
                .gte(startDate)
                .lte(endDate)
        );
        
        // Secondary에서 처리하여 Primary 부하 방지
        query.withReadPreference(ReadPreference.secondaryPreferred());
        
        List<UserLog> logs = mongoTemplate.find(query, UserLog.class);
        
        // 분석 로직...
        return new AnalyticsResult(logs);
    }
}
Nearest
  • 동작 방식: 네트워크 레이턴시가 가장 낮은 노드를 선택하고 그 노드가 Primary인 수도, Secondary일 수도 있다. MongoDB 드라이버가 주기적으로 핑 테스트를 하여 결정한다
  • 장점: 최소 응답 시간을 보장하고 글로벌 분산 환경에서 효과적이다. 지역별 최적의 성능을 보장한다
  • 단점: 어떤 노드에 접근할지 예측이 불가하고 데이터 일관성 보장이 어렵다. 노드마다 다른 상태의 데이터 반환 가능성이 있다
  • 사용 사례: 글로벌 CDN 서비스, 지역별 콘텐츠 서비스, 레이턴시가 최우선인 경우
@Service
public class ContentDeliveryService {
    
    @Autowired
    private MongoTemplate mongoTemplate;
    
    // 사용자와 가장 가까운 노드에서 콘텐츠 제공
    public Content getContent(ObjectId contentId) {
        Query query = Query.query(Criteria.where("_id").is(contentId));
        
        // 가장 빠른 응답을 제공하는 노드 선택
        query.withReadPreference(ReadPreference.nearest());
        
        return mongoTemplate.findOne(query, Content.class);
    }
}
글로벌 배포 예시
서울 리전: Primary + Secondary
도쿄 리전: Secondary
싱가포르 리전: Secondary

한국 사용자 → 서울 노드 (레이턴시 10ms)
일본 사용자 → 도쿄 노드 (레이턴시 5ms)
싱가포르 사용자 → 싱가포르 노드 (레이턴시 3ms)

Read Preference 비교표

옵션읽기 노드일관성가용성부하분산
PrimaryPrimary만강함낮음없음
SecondarySecondary만약함높음우수
Primary PreferredPrimary 우선중간높음제한적
Secondary PreferredSecondary 우선약함매우 높음우수
Nearest가장 가까운 노드약함높음우수

Replication Lag

Write Concern과 Read Preference 부적절한 조합으로 인해 실무에서 데이터 불일치 문제가 발생할 수 있다

@Service
public class RequestService {
    
    @Autowired
    private MongoTemplate mongoTemplate;
    
    @Transactional("mongoTransactionManager")
    public ResponseDto addRequest(RequestDto requestDto) {
        // 1. ACKNOWLEDGED로 저장 (Primary까지만 반영)
        mongoTemplate.setWriteConcern(WriteConcern.ACKNOWLEDGED);
        Request savedRequest = mongoTemplate.save(requestDto.toEntity());
        
        // 2. Secondary에서 조회 시도
        Query query = Query.query(
            Criteria.where("_id").is(savedRequest.getId())
        );
        
        // 3. Replication Lag 문제 발생 지점
        ReadPreference readPreference = ReadPreference.secondaryPreferred();
        
        Document doc = mongoTemplate.getDb()
            .getCollection("requests")
            .withReadPreference(readPreference)
            .find(new Document("_id", savedRequest.getId()))
            .first();
        
        if (doc == null) {
            // Replication Lag 발생!
            // Primary에는 저장되었지만 Secondary에는 아직 복제 안 됨
            throw new DataNotFoundException(
                "Secondary에서 데이터 조회 실패 - Replication Lag"
            );
        }
        
        return new ResponseDto(savedRequest.getId());
    }
}

문제 발생 시나리오

1. ACKNOWLEDGED로 데이터 저장
   → Primary 메모리에만 반영
   
2. secondaryPreferred()로 즉시 조회
   → Secondary는 아직 복제 전
   
3. 데이터를 찾을 수 없음
   → Replication Lag 발생

해결 방법 1 – Write Concern을 Majority로 변경

@Service
public class RequestService {
    
    @Autowired
    private MongoTemplate mongoTemplate;
    
    @Transactional("mongoTransactionManager")
    public ResponseDto addRequestSolution1(RequestDto requestDto) {
        // Majority로 변경: 과반수 노드에 반영될 때까지 대기
        mongoTemplate.setWriteConcern(WriteConcern.MAJORITY);
        Request savedRequest = mongoTemplate.save(requestDto.toEntity());
        
        // 이제 Secondary에 어느 정도 복제가 보장됨
        Query query = Query.query(
            Criteria.where("_id").is(savedRequest.getId())
        );
        
        ReadPreference readPreference = ReadPreference.secondaryPreferred();
        Document doc = mongoTemplate.getDb()
            .getCollection("requests")
            .withReadPreference(readPreference)
            .find(new Document("_id", savedRequest.getId()))
            .first();
        
        if (doc != null) {
            return new ResponseDto(savedRequest.getId());
        }
        
        throw new DataNotFoundException("데이터 조회 실패");
    }
}
  • 장점: 데이터 일관성을 보장하고 Secondary에서 안전하게 읽기 가능
  • 단점: 모든 요청에 과반수 합의 대기로 인한 오버헤드와 레이턴시 증가

해결 방법 2 – Read Preference를 Primary Preferred로 변경

@Service
public class RequestService {
    
    @Autowired
    private MongoTemplate mongoTemplate;
    
    @Transactional("mongoTransactionManager")
    public ResponseDto addRequestSolution2(RequestDto requestDto) {
        // ACKNOWLEDGED 유지 (빠른 쓰기)
        mongoTemplate.setWriteConcern(WriteConcern.ACKNOWLEDGED);
        Request savedRequest = mongoTemplate.save(requestDto.toEntity());
        
        // primaryPreferred()로 변경: Primary에서 읽기
        Query query = Query.query(
            Criteria.where("_id").is(savedRequest.getId())
        );
        
        ReadPreference readPreference = ReadPreference.primaryPreferred();
        Document doc = mongoTemplate.getDb()
            .getCollection("requests")
            .withReadPreference(readPreference)
            .find(new Document("_id", savedRequest.getId()))
            .first();
        
        if (doc != null) {
            return new ResponseDto(savedRequest.getId());
        }
        
        throw new DataNotFoundException("데이터 조회 실패");
    }
}
  • 장점: 쓰기 성능유 유지되며 방금 저장한 데이터 즉시 조회 가능
  • 단점: Primary에 부하가 집중된다

해결 방법 3 – 트랜잭션 설정 활용 (권장)

@Configuration
public class MongoTransactionConfig {
    
    @Bean
    public MongoTransactionManager mongoTransactionManager(
            MongoDatabaseFactory dbFactory) {
        TransactionOptions options = TransactionOptions.builder()
            .readPreference(ReadPreference.primary())  // 트랜잭션 내 읽기는 Primary
            .readConcern(ReadConcern.MAJORITY)         // 읽기 일관성 보장
            .writeConcern(WriteConcern.MAJORITY)       // 쓰기 일관성 보장
            .build();
        
        MongoTransactionManager manager = new MongoTransactionManager(dbFactory);
        manager.setOptions(options);
        return manager;
    }
}

@Service
public class RequestService {
    
    @Autowired
    private MongoTemplate mongoTemplate;
    
    // 트랜잭션 사용 시 자동으로 Primary에서 읽기
    @Transactional("mongoTransactionManager")
    public ResponseDto addRequestSolution3(RequestDto requestDto) {
        // 저장
        Request savedRequest = mongoTemplate.save(requestDto.toEntity());
        
        // 트랜잭션 내에서는 자동으로 Primary에서 조회
        Query query = Query.query(
            Criteria.where("_id").is(savedRequest.getId())
        );
        
        Request found = mongoTemplate.findOne(query, Request.class);
        
        if (found != null) {
            return new ResponseDto(found.getId());
        }
        
        throw new DataNotFoundException("데이터 조회 실패");
    }
}
  • 장점: 범용적인 해결쳑이며 트랜잭션의 ACID 속성을 활용한다. 그리고 명시적 설정이 필요하지 않다
  • 단점: 트랜잭션 오버헤드

해결 방법 비교

방법쓰기 성능읽기 일관성복잡도권장도
Majority Write낮음높음낮음⭐⭐⭐
Primary Preferred높음높음낮음⭐⭐⭐⭐
Transaction Config중간매우 높음중간⭐⭐⭐⭐⭐

핵심 정리

Write Concern:

  • 데이터 안정성과 성능의 트레이드 오프
  • 기본값 (Acknowledged)이 대부분의 경우 적합
  • 중요 데이터는 Majority 사용
  • 초중요 데이터는 Majority + Journal 조합

Read Preference

  • 일관성, 가용성, 성능의 균형 조절
  • 기본값 (Primary)이 안전
  • 통계/분석은 Secondary 활용
  • Replication Lag 항상 고려

출처 – 가장 쉽고 깊게 알려주는 MongoDB 완벽 가이드 [ By. 비전공자 & Kakao 개발자 ]