CQRS 패턴 – 읽기/쓰기 분리하기

핵심 요약

  • CRUD의 한계 극복: 단순한 데이터 생성/조회 구조(CRUD)는 복잡한 도메인에서 데이터 불일치잠금 경합(Lock Contention)을 유발하여 성능 저하의 원인이 됩니다.
  • 책임의 분리: 명령(Command)은 상태를 변경하는 비즈니스 행위에, 조회(Query)는 데이터 반환에만 집중하여 각각 최적화된 모델을 설계합니다.
  • 폴리글랏 저장소(Polyglot Persistence): 쓰기 저장소는 RDBMS(무결성), 읽기 저장소는 NoSQL/Cache(성능)로 물리적으로 분리하여 확장성을 극대화할 수 있습니다.
  • 최종 일관성(Eventual Consistency): 물리적 저장소 분리 시, 데이터 동기화 과정에서 발생하는 지연을 고려해야 하며, 이를 위한 이벤트 기반 아키텍처가 동반되어야 합니다.

목차

우리가 흔히 구축하는 웹 애플리케이션은 대부분 CRUD(Create, Read, Update, Delete) 기반으로 시작합니다. 하지만 사용자가 늘어나고 비즈니스 로직이 복잡해질수록, 하나의 모델이 읽기와 쓰기를 모두 감당하기 버거워지는 시점이 옵니다. 오늘은 Microsoft Azure 아키텍처 센터에서 제시하는 가이드를 바탕으로, 시스템의 성능과 확장성을 획기적으로 개선할 수 있는 CQRS 패턴에 대해 깊이 있게 파헤쳐 보겠습니다.

1. CRUD 아키텍처의 한계와 병목 현상

전통적인 레이어드 아키텍처에서는 데이터베이스의 테이블과 1:1로 매핑되는 엔티티(Entity) 객체가 읽기와 쓰기 작업을 모두 수행합니다. 이 방식은 초기 개발 속도가 빠르다는 장점이 있지만, 트래픽이 증가하면 다음과 같은 치명적인 문제에 직면합니다.

데이터 표현의 불일치 (Data Mismatch)

데이터를 저장할 때 필요한 필드와 조회할 때 필요한 필드는 다를 때가 많습니다. 예를 들어, ‘상품 상세 조회’ 화면에서는 상품 정보뿐만 아니라 재고, 리뷰, 연관 상품 등을 한 번에 보여줘야 합니다. 이를 위해 단일 엔티티에 @OneToMany와 같은 복잡한 연관 관계를 맺다 보면, 단순 업데이트 시에도 불필요한 데이터를 로딩하거나 N+1 문제가 발생하게 됩니다.

성능 비대칭과 잠금 경합 (Lock Contention)

일반적인 웹 서비스의 읽기(Read)와 쓰기(Write) 비율은 8:2 혹은 9:1 정도로 읽기 비중이 압도적으로 높습니다. 하지만 단일 모델을 사용하면 쓰기 작업이 수행되는 동안 해당 테이블이나 행(Row)에 락(Lock)이 걸려, 단순 조회 작업까지 대기해야 하는 상황이 발생합니다. 즉, 쓰기 모델의 복잡성이 읽기 성능을 저하시키는 원인이 됩니다.

2. CQRS 패턴의 핵심: 명령과 조회의 철저한 분리

CQRS(Command and Query Responsibility Segregation)는 이 문제를 해결하기 위해 시스템을 명령(Command)조회(Query)라는 두 개의 독립적인 축으로 나눕니다.

명령(Command): 비즈니스 의도의 캡슐화

명령은 시스템의 상태를 변경하는 작업입니다. 단순히 데이터를 업데이트하는 것이 아니라, 명확한 비즈니스 의도를 담아야 합니다. 또한, 명령은 데이터를 반환하지 않아야 합니다(void). 성공/실패 여부만 알리면 충분합니다.

  • 특징: 도메인 로직 포함, 데이터 무결성 보장, 트랜잭션 처리
  • 네이밍: updateStatus(RESERVED) (X) -> bookHotelRoom() (O)

조회(Query): 화면 중심의 데이터 반환

조회는 시스템의 상태를 반환만 하며, 상태를 절대 변경하지 않습니다(Side-effect Free). 복잡한 비즈니스 로직 없이, UI가 필요로 하는 형태의 DTO(Data Transfer Object)를 즉시 반환하도록 설계합니다.

Java 코드 예시: 명령과 조회의 분리

아래는 호텔 예약 시스템을 CQRS 스타일로 분리한 코드입니다. Java 16의 record를 활용해 불변 객체로 명령과 DTO를 정의했습니다.

// [Command] 비즈니스 의도가 명확한 명령 객체
public record BookRoomCommand(
    UUID userId,
    UUID roomId,
    LocalDate checkIn,
    LocalDate checkOut
) {
    // 생성자 레벨에서 기본적인 유효성 검증 수행
    public BookRoomCommand {
        if (checkIn.isAfter(checkOut)) {
            throw new IllegalArgumentException("체크인 날짜 오류");
        }
    }
}

// [Command Handler] 도메인 로직 및 트랜잭션 처리
@Service
@Transactional
@RequiredArgsConstructor
public class RoomCommandHandler {
    private final RoomRepository roomRepository;

    public void handle(BookRoomCommand command) {
        Room room = roomRepository.findById(command.roomId())
            .orElseThrow(() -> new EntityNotFoundException());
            
        // 쓰기 모델은 도메인 규칙(재고 확인 등)에 집중
        room.book(command.userId(), command.checkIn(), command.checkOut());
        // 반환값 없음 (void)
    }
}
// [Query Model] 조회 전용 DTO (엔티티 아님)
public record RoomDisplayDto(
    String roomName,
    BigDecimal price,
    boolean isAvailable,
    int reviewCount
) {}

// [Query Handler] 읽기 최적화 로직
@Repository
@RequiredArgsConstructor
public class RoomQueryRepository {
    private final JdbcTemplate jdbcTemplate;

    // JPA가 아닌 JDBC나 MyBatis 등을 사용하여 조회 성능 최적화
    // 복잡한 연산 없이 화면에 필요한 데이터를 바로 SELECT
    public List<RoomDisplayDto> findAvailableRooms(LocalDate date) {
        String sql = """
            SELECT r.name, r.price, 
                   CASE WHEN res.id IS NULL THEN true ELSE false END as is_available,
                   (SELECT COUNT(*) FROM reviews rv WHERE rv.room_id = r.id) as review_count
            FROM rooms r
            LEFT JOIN reservations res ON r.id = res.room_id AND res.date = ?
        """;
        return jdbcTemplate.query(sql, rowMapper, date);
    }
}

3. 구현 전략: 논리적 분리 vs 물리적 분리

CQRS를 도입한다고 해서 반드시 데이터베이스를 쪼개야 하는 것은 아닙니다. 프로젝트 규모에 따라 단계를 나눌 수 있습니다.

Step 1. 단일 데이터 저장소 내 논리적 분리

데이터베이스는 하나(RDBMS)를 공유하되, 코드 레벨에서만 모델을 분리하는 방식입니다.

  • 쓰기: JPA 엔티티와 Repository 패턴을 사용하여 도메인 로직 처리.
  • 읽기: 화면에 최적화된 View 전용 테이블을 만들거나, QueryDSL/JDBC 등을 사용하여 DTO로 바로 매핑.
  • 장점: 구현이 비교적 간단하며 데이터 일관성 관리가 쉽습니다.

Step 2. 물리적 데이터 저장소 분리 (Polyglot Persistence)

읽기 모델과 쓰기 모델의 저장소 자체를 분리하는 고급 전략입니다. Azure 아키텍처 센터에서 강조하는 확장의 핵심입니다.

  • 쓰기 저장소(Write DB): 관계형 데이터베이스(MySQL, PostgreSQL)를 사용하여 ACID 트랜잭션과 데이터 무결성을 보장합니다. 정규화된 스키마를 사용합니다.
  • 읽기 저장소(Read DB): 조회 성능이 뛰어난 NoSQL(MongoDB, Elasticsearch)이나 인메모리 캐시(Redis)를 사용합니다. 역정규화(Denormalization)된 데이터를 저장하여 조인(Join) 연산을 제거합니다.
  • 장점: 읽기/쓰기 부하를 독립적으로 스케일링(Scale-out) 할 수 있습니다.

4. 데이터 동기화와 최종 일관성 문제

저장소를 물리적으로 분리했을 때 가장 큰 난관은 “쓰기 DB의 변경 사항을 어떻게 읽기 DB에 반영할 것인가?”입니다. 여기서 최종 일관성(Eventual Consistency) 개념이 등장합니다.

사용자가 데이터를 변경(Command)한 직후, 읽기 DB에 반영되기까지 미세한 시차가 발생할 수 있습니다. 시스템은 즉각적인 일관성 대신, “언젠가는 데이터가 일치해진다”는 것을 보장하는 방식으로 설계됩니다.

이벤트 기반 동기화 구현 예시

주로 Kafka나 RabbitMQ 같은 메시지 브로커를 활용하여 비동기로 데이터를 동기화합니다.

// 1. 쓰기 완료 후 이벤트 발행
public void bookRoom(BookRoomCommand command) {
    room.book(...);
    roomRepository.save(room);
    
    // 트랜잭션 커밋 후 이벤트 발행 (Transactional Outbox 패턴 고려 필요)
    eventPublisher.publish(new RoomBookedEvent(room.getId()));
}

// 2. 이벤트 리스너가 읽기 DB(예: Elasticsearch) 업데이트
@Component
public class RoomEventListener {
    private final RoomSearchRepository searchRepository;

    @Async
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handle(RoomBookedEvent event) {
        // 읽기 전용 저장소의 데이터를 갱신하여 검색 결과에 반영
        // 이 과정에서 수 밀리초~수 초의 지연이 발생할 수 있음 (Eventual Consistency)
        searchRepository.markAsBooked(event.roomId());
    }
}

이 방식은 시스템의 복잡도를 높이지만, 마이크로서비스 아키텍처(MSA)에서 서비스 간 결합도를 낮추고 장애 격리(Fault Isolation)를 가능하게 합니다.

5. 언제 도입해야 하는가?

CQRS는 강력하지만 ‘은탄환’은 아닙니다. 단순한 CRUD 앱에 도입하면 복잡성만 가중되어 유지보수 비용이 폭발할 수 있습니다. 다음 기준에 해당할 때 도입을 고려해 보세요.

  • 협업 도메인: 여러 사용자가 동시에 동일한 데이터를 수정하여 충돌 가능성이 높은 경우.
  • 복잡한 UI: 사용자의 작업 흐름이 단순 데이터 입력이 아니라 복잡한 프로세스를 따르는 경우.
  • 극단적 트래픽 차이: 조회 요청이 쓰기 요청보다 수백 배 이상 많아, 읽기 성능만 별도로 튜닝해야 하는 경우.
  • 이벤트 소싱(Event Sourcing): 데이터의 현재 상태뿐만 아니라 변경 이력 전체를 저장하고 재생해야 하는 경우 CQRS는 필수적인 짝꿍 패턴입니다.

CQRS는 기술적인 패턴을 넘어, 비즈니스를 바라보는 관점을 ‘데이터’에서 ‘행동’으로 전환하는 아키텍처입니다. 여러분의 프로젝트가 CRUD의 한계에 부딪혔다면, CQRS가 훌륭한 돌파구가 될 수 있습니다!

이 글은 어떠셨나요? 자유롭게 의견을 남겨주세요! 💬