CQRS 설계 - KimGyuBek/Threadly GitHub Wiki

CQRS 설계

개요

Command와 Query 분리 철학

Command (상태 변경)     Query (상태 조회)
        ↓                      ↓
  CommandUseCase          QueryUseCase
        ↓                      ↓
   CommandPort            QueryPort
        ↓                      ↓
 CommandAdapter         QueryAdapter
  • CQRS(CommandQuery Responsibility Segregation) 패턴을 적용하여 상태 변경(Command)과 상태 조회(Query)를 명확히 분리
  • 각 도메인마다 CommandQuery를 별도 계층으로 정의하여 책임을 구분하고, 구조적 일관성 확보
  • 책임 분리, 확장성 확보

CQRS 설계 구조

CQRS

1. 명확한 책임 분리

구분 Command Query
목적 상태 변경 상태 조회
반환 타입 void, Response DTO 데이터 객체, Projection
트랜잭션 @Transactional @Transactional(readOnly = true)

2. UseCase 수준의 CQRS 분리

PostCommandUseCase          PostQueryUseCase
       ↓                           ↓
createPost()                  getPost()
updatePost()                  getUserVisiblePostListByCursor()
softDeletePost()              getPostEngagement()
...                            ...
  • 목적: 비즈니스 유즈케이스 정의
  • 특징: Command/Query 책임 명확히 분리

3. Port 수준의 CQRS 분리

PostCommandPort             PostQueryPort
       ↓                           ↓
savePost()                    fetchById()
updatePost()                  fetchPostDetailsByPostIdAndUserId()
changeStatus()                fetchUserVisiblePostListByCursor()
increaseViewCount()           existsById()
...                            ...
  • 목적: 외부 인프라 추상화
  • 특징: 기술 중립적

4. 세분화된 도메인별 분리

각 하위 도메인도 Command/Query 분리:

  • PostLike: PostLikeCommandUseCase / PostLikeQueryUseCase
  • PostComment: PostCommentCommandUseCase / PostCommendQueryUseCase
  • PostCommentLike: PostCommentLikeQueryUseCase / `PostCommentLikeCommandUseCase'

장점 및 효과

책임 분리 명확화

쓰기(Command)와 읽기(Query)를 분리함으로써 각 계층이 담당하는 역할이 명확해지고 코드 복잡성이 줄어든다.

변경 영향 최소화

조회 요구사항 변경 시 쓰기 로직에 영향을 주지 않으며 반대의 경우도 동일하다. 따라서 유지 보수가 용이해진다.

테스트 용이성 확보

Command는 상태 변경 검증에 집중하고, Query는 데이터 조회 결과 검증에 집중하여 테스트 가능하다.

확장성 강화

현재는 Command, Query 같은 DB를 사용지만 추후 전용 DB 분리로 성능 최적화 및 확장이 용이하다.


예시

Follow 기능

1. 디렉토리 구조

follow
├─ in
│   ├─ command
│   │   ├─ FollowCommandUseCase.java
│   │   └─ dto
│   └─ query
│       ├─ FollowQueryUseCase.java
│       └─ dto
└─ out
    ├─ FollowCommandPort.java
    ├─ FollowQueryPort.java
    └─ projection

2. 코드 예시

1. Port.in(UseCase)

public interface FollowCommandUseCase {

  FollowUserApiResponse followUser(FollowUserCommand command);
//  ...

}

public interface FollowQueryUseCase {
  CursorPageApiResponse getFollowRequestsByCursor(GetFollowRequestsQuery query);
//  ...
}

1-1. 구현체(core-service)

@Service
public class FollowCommandService implements FollowCommandUseCase {

  @Transactional
  @Override
  public FollowUserApiResponse followUser(FollowUserCommand command) {...}
//  ...
}

@Service
public class FollowQueryService implements FollowQueryUseCase {
  CursorPageApiResponse getFollowRequestsByCursor(GetFollowRequestsQuery query){...}
//  ...
}

1-2. DTO

public record FollowRelationCommand(...) {...}

public record GetFollowersQuery(...) {...}

2. Port.out(Port)

public interface FollowCommandPort {
  void createFollow(Follow follow);
//  ...
}

public interface FollowQueryPort {
  boolean isFollowing(String followerId, String followingId);
//  ...
}

2-1. 구현체(adapter-persistence)

@Repository
public class FollowPersistenceAdapter implements FollowCommandPort, FollowQueryPort {
  private final FollowJpaRepository followJpaRepository;
  
  @Override
  public void createFollow(Follow follow) {...}

  @Override
  public boolean isFollowing(String followerId, String followingId) {...}
//  ...
  }

2-2. DTO

public interface FollowerProjection {
  String getFollowerId();
//  ...
}

설계 변화 히스토리

1. 액션별 분리

  • 예시: LoginUserUseCase, DeActivateUserUserCase, WithdrawUserUseCase
  • 장점: 의도 명확, 파일 네임으로 "무슨 일을 하는지" 바로 보임
  • 한계: 액션이 늘어날 수록 인터페이스가 증가

2. CRUD 중심 fetch, save, update 분리

  • 예시: FetchUserUseCase, UpdateUserUseCase, SaveUserUsecase
  • 장점: CRUD 기준으로 묶어서 파일 수를 줄일 수 있어서 구조가 깔끔해짐
  • 한계:
    • 의미가 뭉개짐: fetch안에 상세 조회/목록/통계 등 이질적 쿼리가 섞임
    • 쓰기 로직과 조회 로직이 같은 서비스에 들어와서 책임 분리 실패
    • 결과적으로 코드가 다시 방대해지고 응집도가 떨어짐

3. CQRS 분리(최종 구조)

  • 예시: UserQueryUseCase, UserCommandUseCase
  • 장점:
    • 읽기, 쓰기 책임을 명확히 분리해 응집도 상승
      • 트랜잭션 관리 최적화: Command@Transactional, Query@Transactional(readOnly = true)
      • 도메인 단위로 일관된 구조를 가지게 되어 확장 시에도 일관성 유지 가능
      • Command/Query를 별도의 단위로 테스트 가능해 검증이 단순해짐

고려사항

  • 현재는 CommandQuery가 동일한 DB를 사용하여 강한 일관성을 보장하고 있음
  • 추후 시스템 규모가 커지면 읽기/쓰기 저장소 분리를 고려할 수 있다.
⚠️ **GitHub.com Fallback** ⚠️