-
CQS / CQRS 패턴 적용기🧑🏻💻 프로젝트 2025. 5. 25. 18:17
⚠️ 작업 배경 및 문제 상황
public class AuthService { private final UserService userService; @Transactional public User loginFromOAuth2(OAuth2UserInfo oAuth2UserInfo, String registrationId) { return userService.existsByEmail(oAuth2UserInfo.getEmail()) ? updateExistingUser(oAuth2UserInfo) : registerNewUser(oAuth2UserInfo, registrationId); } }
🌟 팀원의 피드백..!
💡 CQS와 CQRS
📌 CQS(Command Query Separation)란?
CQS는 "명령(Command)"과 "질의(Query)"를 명확히 분리하는 메서드 단위의 객체지향 설계 원칙입니다.
- Command - 시스템의 상태를 변경하는 메서드, 값을 반환하지 않습니다.
- Query - 시스템의 상태를 변경하지 않고 정보를 반환하는 메서드
➡️ 하나의 메서드는 오직 Command 또는 Query 역할 중 하나만 수행해야 합니다.
🖥️ CQS를 적용한 코드
public class AuthService { private final UserService userService; @Transactional public void loginFromOAuth2(OAuth2UserInfo oAuth2UserInfo, String registrationId) { if (userService.existsByEmail(oAuth2UserInfo.getEmail())) { userService.updateUserFromOAuth(oAuth2UserInfo); } else { userService.registerUserFromOAuth(oAuth2UserInfo, registrationId); } } }
✅ UserService 안에서 Query / Command를 메서드 수준으로 구분했으므로 CQS 패턴 적용
✅ CQS 적용 효과
- Command와 Query가 분리되어 코드 가독성/유지보수성이 높아짐
- 각각의 책임이 명확해져 예측 가능성이 높아짐
📌 CQRS(Command Query Responsibility Segregation)란?
CQRS는 CQS 원칙을 확장해 읽기(Command)과 쓰기(Query)의 책임을 아예 다른 서비스, 모델, 데이터 저장소 등으로 책임 분리하는 패턴
➡️ CQRS는 대규모 시스템에서 성능, 확장성, 복잡성 관리에 유리하며 읽기/ 쓰기 요구사항이 크게 다를 때 효과적입니다.
- 많은 사용자가 동시에 데이터를 조회하고 상태 변경을 처리해야 하는 경우 CQRS는 커맨드와 쿼리를 분리하여 각각의 책임을 명확히 하고 독립적으로 확장할 수 있어 시스템의 성능, 확장성등을 충족할 수 있습니다.
👋 장 단점
장점
- 성능 최적화: 읽기와 쓰기 작업에 맞게 각각 최적화된 모델을 사용할 수 있습니다.
- 확장성: 읽기와 쓰기 작업을 독립적으로 확장할 수 있습니다.
- 복잡한 도메인 관리: 복잡한 비즈니스 로직이 있는 시스템에서 명령과 질의를 분리하여 관리하기 용이합니다.
- 책임 분리: 데이터 수정과 조회에 대한 권한을 분리하여 관리할 수 있습니다.
단점
- 구현 복잡성: 두 개의 모델을 유지해야 하므로 코드가 복잡
- 데이터 일관성 문제: 물리적으로 분리된 경우 읽기 모델과 쓰기 모델 간의 데이터 일관성을 보장하기 위한 추가 작업이 필요
- 오버엔지니어링: 단순한 애플리케이션에 적용할 경우 불필요한 복잡성 초래
💡 구현 방식
방식 설명 논리적 분리 같은 데이터 저장소(DB)를 쓰되 Command / Query 모델을 코드상 분리 물리적 분리 명령과 질의에 대해 서로 다른 데이터 저장소를 사용
ex) Command용 RDB, Query용 NoSQL 등 서로 다른 저장소 사용이벤트 소싱 명령과 질의를 처리하는 서비스 자체를 분리
- 도메인에서 발생한 모든 이벤트를 기록하고 이벤트 스토어에 저장
- 데이터 이력 관리 및 복원 용이🖥️ CQRS를 적용한 코드
public class AuthService { private final UserCommandService userCommandService; private final UserQueryService userQueryService; @Transactional public void loginFromOAuth2(OAuth2UserInfo oAuth2UserInfo, String registrationId) { if (userQueryService.existsByEmail(oAuth2UserInfo.getEmail())) { userCommandService.updateUserFromOAuth(oAuth2UserInfo); } else { userCommandService.registerUserFromOAuth(oAuth2UserInfo, registrationId); } } }
✅ Command / Query 용 서비스 단위로 분리 → CQRS 패턴의 논리적 분리 방식 적용
🌱 CQS / CQRS 차이 요약
항목 CQS CQRS 구분 기준 메서드 단위 아키텍처 단위 목적 메서드의 단일 책임 원칙 강화 시스템의 복잡성 분리 및 확장성 확보 코드 구조 서비스 분리 ❌ ( Query / Command 메서드 분리) Command / Query 별 서비스, 리포지토리, 모델까지 분리 적용 범위 소규모 / 단순한 애플리케이션 대규모 / 복잡한 비즈니스 도메인 데이터 저장소 동일 DB 사용 논리 또는 물리적으로 분리된 저장소도 가능 장점 가독성, 단순성 확장성, 성능 최적화, 책임 분리 단점 복잡한 도메인일 경우 어려워짐
- 책임 분리가 코드 수준에 머무름
- 확장성과 성능 최적화의 어려움구현 복잡성 증가, 데이터 일관성 이슈 예시 하나의 서비스 내에서 updateX() / findX() UserCommandService / UserQueryService ✊ CQRS 논리적 분리 방식!
저희 프로젝트에서는 CQRS의 논리적 분리 방식을 사용하도록 했습니다.
CQRS 논리적 분리 방식을 채택한 이유는 다음과 같습니다
기존 CQS 패턴은 메서드 수준에서 명령(Command)과 질의(Query)를 분리할 수는 있지만 복잡한 도메인 요구사항을 구조적으로 분리하기에는 한계가 존재합니다.
반면, CQRS는 명령과 질의의 ****책임을 서비스 레벨까지 분리함으로써 도메인의 복잡도를 더 잘 다룰 수 있는 구조적 유연성을 제공합니다.
다만, 현재 프로젝트의 시스템은 아직 대규모 트래픽이나 확장성 요구가 있는 상황은 아니기 때문에 DB 및 모델을 완전히 분리하는 물리적 CQRS까지는 도입하지 않고 이벤트 소싱 또한 구현 복잡도와 운영 난이도를 고려해 채택하지 않았습니다.
따라서 명령과 질의의 책임을 각각의 서비스로 분리하는 논리적 CQRS 구조를 도입하여 유지보수성과 확장성을 높이고 복잡한 기술 도입 없이 개발 및 운영의 부담을 줄이는 방향으로 설계했습니다.
🚀 적용 효과
항목 설명 가독성 향상 Command / Query를 명확히 분리함으로 역할을 빠르게 파악할 수 있음 안정성 증대 부수 효과(side effect) 없는 Query와 상태 변경이 수반되는 Command를 구분해 예측 가능한 코드 작성 가능 테스트 용이성 각 핸들러가 단일 책임을 가져 기능 단위의 독립적인 테스트가 용이 유지보수성 향상 책임 분리로 변경 시 영향 범위를 쉽게 파악할 수 있어 유지보수가 수월함 👀 CQRS 적용 후 코드 품질 향상
Before After "이 메서드가 뭘 하는지 직접 봐야겠어요" "서비스 명만으로 Query/Command 구분이 돼요!"
🔭 향후 발전 방향
📌 현재: CQRS - 논리적 분리
@Service public class UserQueryService { public User findById(Long id) { ... } } @Service public class UserCommandService { public void updateProfile(UpdateUserCommand command) { ... } }
⏭️ 고려해볼 다음 단계 1: Command / Query에 따른 DB 분리
내용 예시 목적 Query의 읽기 부하 분산 및 Command에 적합한 데이터 구조 유지 예시 Command → RDB / Query → NoSQL 👍 장점
- 읽기 / 쓰기 각각 최적화된 저장소 사용
- 성능 향상 및 확장성 증가
⚠️ 고려사항
- 데이터 동기화 방식 필요 ⭐
- 일관성 보장 위해 이벤트 기반 설계 필요
⏭️ 고려해볼 다음 단계 2: 이벤트 소싱 + CQRS
@Service public class UserCommandHandler { public void handle(UpdateUserProfileCommand command) { UserProfileUpdatedEvent event = new UserProfileUpdatedEvent(...); eventStore.save(event); // 이벤트 저장소 } } @Service public class UserProjectionService { @EventHandler public void on(UserProfileUpdatedEvent event) { updateUserReadModel(event); // 읽기 모델 업데이트 } }
🏄♂️ Command의 결과를 이벤트로 저장 후 → 읽기 모델에 반영
⚙️ Event-Driven Architecture 고려사항
👍 장점
- 서비스 간 느슨한 결합 (loose coupling)
- 비동기 처리 → 성능 향상
- 장애 격리 및 확장성 확보
⚠️ 단점
- 운영 복잡도 증가
- 디버깅 어려움 (이벤트 흐름 추적 필요)
- 설계 및 테스트 복잡성 상승
😎 정리
CQRS 패턴은 예전에 DDD를 공부하면서 '명령과 조회를 분리한다'는 개념이 흥미롭게 다가왔고 실습 차원에서 간단한 예제를 구현해보며 감을 잡았던 기억이 있습니다.
하지만 이번에 실제 프로젝트에서 다시 CQRS를 적용해보게 되면서 내가 알고 있다고 생각했던 것들을 조금 더 구조적으로 정리하고 CQS(Command Query Separation) 패턴과 어떤 점에서 차이가 있는지 어떤 CQRS 적용방식이 있고 어떤 방식을 선택해야 하는지를 명확히 하고 싶어 정리하게 되었습니다.
📚 Ref.
https://learn.microsoft.com/ko-kr/azure/architecture/patterns/cqrs
https://f-lab.kr/insight/cqs-and-cqrs-in-object-oriented-design-20241227
'🧑🏻💻 프로젝트' 카테고리의 다른 글
🚐 Kafka 메시지 발행 최적화: 동기 방식에서 코루틴까지의 여정 (0) 2025.03.24