4/5(일) 사용자 서비스 CQRS 조회 구현 : 도메인 정의와 VO의 정의, CQRS 조회와 권한 체크 위치

dev_joo·2026년 4월 5일
post-thumbnail

1. 배경

사용자 서비스에서 CQRS 기반 조회를 구현하던 중, 도메인 정의 자체가 잘못되어 있었다는 사실을 깨달았다.

문제의 시작은 기획 단계에서 작성한 도메인 정의 문서였다.

허브에 직접 귀속되지 않는 역할임에도 불구하고, 허브 배송 담당자(HUB_DELIVERY_MANAGER)가 hubId를 가지도록 정의되어 있었다.

실제 비즈니스 구조는 다음과 같다.

  • HUB_DELIVERY_MANAGER: hubId와 무관
  • COMPANY_DELIVERY_MANAGER: company를 통해 hub와 연결됨

즉, 초기 도메인 정의가 잘못되었고, 그 정의를 기반으로 구현이 진행되면서 문제는 더 커지게 되었다.


2. 문제의 핵심: 잘못된 도메인 정의

User가 가지는 역할은 다음과 같다.

@Getter
@RequiredArgsConstructor
public enum UserRole {

  MASTER("마스터 관리자"),

  HUB_MANAGER("허브 관리자"),
  HUB_DELIVERY_MANAGER("허브 배송 담당자"),

  COMPANY_MANAGER("업체 담당자"),
  COMPANY_DELIVERY_MANAGER("업체 배송 담당자");

  private final String description;

  public String toRole() {
      return "ROLE_" + this.name();
  }


}

문제는 역할별로 가져야 할 속성이 명확히 다름에도 불구하고,
초기 도메인 정의 단계에서 잘못된 속성이 포함된 채 설계되었다는 점이다.

그 결과 다음과 같은 문제가 발생했다.

  • HUB_DELIVERY_MANAGER가 hubId를 가지는 구조가 됨
  • 실제 비즈니스 위계와 맞지 않음
  • 도메인 모델과 데이터 구조가 어긋남

3. 올바른 도메인 관점

비즈니스 위계를 기준으로 보면 구조는 명확하다.

  • HUB_MANAGER → hubId 필요
  • HUB_DELIVERY_MANAGER → hubId 필요 없음
  • COMPANY_MANAGER → companyId 필요
  • COMPANY_DELIVERY_MANAGER → companyId 필요

그리고 중요한 관계는 다음과 같다.

Company는 Hub에 소속된다.

즉,

CompanyManager / CompanyDeliveryManager → Company → Hub

이 흐름이기 때문에, 하위 객체가 hubId를 직접 가지는 것은 도메인 위계를 깨는 설계이다.


4. VO로 책임 명확히 하기

이 문제를 해결하기 위해 역할별로 VO를 분리하는 것이 필요했다.

예를 들어 CompanyManager는 다음과 같이 정의할 수 있다.

@Embeddable
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class CompanyManager {

@Column(name = "company_id")
private UUID companyId;

  private CompanyManager(UUID companyId) {
      this.companyId = companyId;
  }

  public static CompanyManager create(UUID companyId) {
      if (companyId == null) {
          throw new InvalidUserException("업체 ID는 필수이다.");
      }
      return new CompanyManager(companyId);
  }
}

핵심은 다음과 같다.

  • 필요한 속성만 가진다
  • 상위 관계(hubId)를 포함하지 않는다
  • 도메인 위계를 그대로 반영한다

5. 조회 로직과 도메인은 분리해야 한다

도메인에 없는 정보를 조회를 위해 억지로 포함시키는 방식은 점점 구조를 복잡하게 만든다.

이번 경우에도 hubId를 직접 들고 가는 대신, 다음과 같이 풀 수 있다.

  • companyId를 기준으로 CompanyService 조회
  • 필요한 경우 hubId를 외부에서 가져와 조합
  • CQRS 조회 모델에서 별도로 최적화

도메인은 책임만 가지고, 조회는 조회 방식대로 해결하는 구조가 더 자연스럽다.


6. 가장 크게 깨달은 점

도메인 정의가 잘못되니, 그 위에 쌓인 구현도 자연스럽게 함께 잘못된 방향으로 흘러갔다.

hubId 하나를 잘못 들고 시작했을 뿐인데,
엔티티, 서비스, 조회 로직까지 전부 영향을 받게 되었다.

결국 문제는 구현이 아니라, 처음에 정의한 도메인 자체였다.


7. 확장 가능한 설계의 중요성

이번 경험을 통해 느낀 점은 단순하다.

  • 도메인 정의가 기준이 된다
  • 한 번 틀어지면 수정 비용이 커진다
  • 처음부터 위계를 정확히 반영해야 한다

특히 VO를 활용하면 역할별 책임을 명확하게 나눌 수 있어서
변경이 생겨도 영향을 최소화할 수 있다.


도메인 모델

(확정 26.04.05)
User (Aggregate Root)
 ├── id (UUID)
 ├── username
 ├── email
 ├── slackId
 │
 ├── requestedRole (ENUM)
 │    └── MASTER | HUB_MANAGER | HUB_DELIVERY_MANAGER | COMPANY_DELIVERY_MANGER | COMPANY_MANAGER
 ├── role (ENUM)
 │    └── MASTER | HUB_MANAGER | HUB_DELIVERY_MANAGER | COMPANY_DELIVERY_MANGER | COMPANY_MANAGER
 └── status (Value Object) // Role 승인 여부에 따른 유저의 상태 APPROVED 만 로그인 가능
 │    └── PENDING | APPROVED | REJECTED
 ├──  associateName // COMPANY_MANAGER, HUB_MANAGER 만 필수 (스냅샷)
 │
 ├── hubManager (Value Object)
 │    ├── hubId // HUB_MANAGER 만 필수
 ├── deliveryManager (Value Object)
 │    ├── deliverySequence // 배송 순번
 ├── companyManager (Value Object)
      ├── companyId // COMPANY_MANAGER 만 필수 (COMPANY_DELIVERY_MANGER는 허브 소속)
 

1.VO

// 1. 허브 관리자 VO
@Embeddable
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class HubManager {
    @Column(name = "manage_hub_id")
    private UUID hubId;

    private HubManager(UUID hubId) {
        this.hubId = hubId;
    }

    public static HubManager create(UUID hubId) {
        if (hubId == null) throw new InvalidUserException("허브 ID는 필수입니다.");
        return new HubManager(hubId);
    }
}

// 2. 배송 담당자 VO (허브/업체 배송 담당자 공용)
@Embeddable
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class DeliveryManager {
    @Column(name = "delivery_hub_id")
    private UUID hubId; // COMPANY_DELIVERY_MANAGER는 필수, HUB_DELIVERY_MANAGER는 선택(확장성)

    @Column(name = "delivery_sequence")
    private int sequence;

    private DeliveryManager(UUID hubId, int sequence) {
        this.hubId = hubId;
        this.sequence = sequence;
    }

    public static DeliveryManager create(UserRole role, UUID hubId, int sequence) {
        // 업체 배송 담당자는 소속 허브가 필수
        if (role == UserRole.COMPANY_DELIVERY_MANAGER && hubId == null) {
            throw new InvalidUserException("업체 배송 담당자는 허브 ID가 필수입니다.");
        }
        return new DeliveryManager(hubId, sequence);
    }
}

// 3. 업체 관리자 VO
@Embeddable
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class CompanyManager {
    @Column(name = "manage_company_id")
    private UUID companyId;

    private CompanyManager(UUID companyId) {
        this.companyId = companyId;
    }

    public static CompanyManager create(UUID companyId) {
        if (companyId == null) throw new InvalidUserException("업체 ID는 필수입니다.");
        return new CompanyManager(companyId);
    }
}

2.Entity

@Entity
@Table(name = "p_user")
@Getter
@Builder(access = AccessLevel.PRIVATE)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User extends BaseEntity {

    @Id
    private UUID id;
    private String username;
    private String email;
    private String slackId;

    @Enumerated(EnumType.STRING)
    private Role role;

    @Enumerated(EnumType.STRING)
    private Status status; // PENDING, APPROVED, REJECTED

    // [스냅샷] 허브명 혹은 업체명
    private String associateName;

    // --- 역할별 VO (희소 컬럼 패턴) ---
    @Embedded
    private HubManager hubManager;

    @Embedded
    private DeliveryManager deliveryManager;

    @Embedded
    private CompanyManager companyManager;

    // =================== 정적 팩토리 메서드 (생성 통제) ===================

    // 1. 마스터 생성
    public static User createMaster(UUID id, String username, String email) {
        return User.builder()
                .id(id).username(username).email(email)
                .role(Role.MASTER)
                .status(Status.APPROVED)
                .build();
    }

    // 2. 허브 관리자 생성 (hubId, hubName 필수)
    public static User createHubManager(UUID id, String username, String email, UUID hubId, String hubName) {
        return User.builder()
                .id(id).username(username).email(email)
                .role(Role.HUB_MANAGER)
                .status(Status.PENDING)
                .associateName(hubName)
                .hubManager(HubManager.create(hubId))
                .build();
    }

    // 3. 배송 담당자 생성 (허브/업체 공용)
    public static User createDeliveryManager(UUID id, String username, String email, Role role, UUID hubId, int sequence) {
        return User.builder()
                .id(id).username(username).email(email)
                .role(role) // HUB_DELIVERY_MANAGER or COMPANY_DELIVERY_MANAGER
                .status(Status.PENDING)
                .deliveryManager(DeliveryManager.create(role, hubId, sequence))
                .build();
    }

    // 4. 업체 관리자 생성 (companyId, companyName 필수)
    public static User createCompanyManager(UUID id, String username, String email, UUID companyId, String companyName) {
        return User.builder()
                .id(id).username(username).email(email)
                .role(Role.COMPANY_MANAGER)
                .status(Status.PENDING)
                .associateName(companyName)
                .companyManager(CompanyManager.create(companyId))
                .build();
    }

    // =================== 비즈니스 로직 (역할 전환 및 클렌징) ===================

    public void approve() {
        this.status = Status.APPROVED;
    }

    // 배송 담당자로 역할 전환 (기존 데이터 클렌징)
    public void promoteToDelivery(Role newRole, UUID hubId, int sequence) {
        this.role = newRole;
        this.deliveryManager = DeliveryManager.create(newRole, hubId, sequence);
        
        // 타 역할 데이터 클렌징
        this.hubManager = null;
        this.companyManager = null;
        this.associateName = null;
    }
}

3.느낀 점

이 경험을 통해 도메인을 먼저 제대로 잡는 것이 얼마나 중요한지 알게 되었다.

처음 설계가 이후 모든 구현 방향을 결정한다는 것을 직접 겪어본 소중한 경험이었다. (공부 많이 된다!!!)

+ VO -> Entity 로 설계해야 할 때:


우선 발제문을 참고하면, 소속은 한가지로만 이루어짐을 알 수 있지만, (소속 업체명을 받는다.)
소속이 여러개로 늘어날 때, (예를 들어: 허브 매니저가 관리하는 허브 개수가 다수일 때)
별도의 매핑 테이블이 물리적으로 필요하게 되고, 결국 각 Role을 VO로 관리할 수 없게 된다.

물론 테이블을 늘리지 않고 manage_hub_ids라는 컬럼에 UUID, UUID, UUID 식으로 문자열로 박아넣는 방식으로 구현할 순 있지만, 특정 허브 ID로 유저를 검색(WHERE hub_id = ?)하는 속도가 처참해지고, Querydsl 작성도 복잡해진다.

이번 프로젝트에서는 시간상 "허브 관리자는 여러 명일 수 있지만, 한 명의 관리자는 반드시 하나의 '메인 허브'에 소속된다"라는 비즈니스 규칙을 두어 그대로 단일테이블+VO 방식으로 확정할 것이다.

그러나 다음번 구현에서는 Join이 싫다고 무조건 단일 테이블 방식을 사용하지 말고, 역할별 테이블을 나누거나, 매핑 테이블을 구현해 유연성과 확장성을 챙기는 방안도 생각해봐야겠다.

테이블 설계 방식 비교

비교 항목역할별 테이블단일 테이블 (VO)단일 테이블 + 매핑
확장성매우 좋음 (역할 추가 자유로움)불가능 (구조 고정)좋음 (필요한 부분만 확장 가능)
조회 성능보통 (JOIN 필요)매우 빠름 (단일 쿼리)좋음 (필요할 때만 JOIN)
설계 복잡도높음 (테이블 많음)매우 낮음 (단일 테이블)보통
역할 변경복잡함 (데이터 이동 필요)쉬움 (값 변경만 하면 됨)보통
추천 상황역할마다 데이터 구조가 완전히 다를 때역할이 거의 고정이고 단순할 때단순함 유지하면서 유연성도 필요한 경우

===========================================================================

도메인 권한과 조회 권한의 분리 설계

1. 권한 체크의 분류

권한 체크는 성격에 따라 '도메인 비즈니스 권한''시스템 접근 권한'으로 나뉜다.

① 도메인 권한 (Data Modification Authority)

정의: 데이터의 상태를 변경(CUD)할 때 적용되는 비즈니스 규칙.

예시: "본인 또는 MASTER만 비밀번호를 변경할 수 있다", "MASTER만 가입 승인을 할 수 있다".

위치: 엔티티(Entity) 내부. 객체 스스로가 자신의 상태를 바꿀 자격이 있는지 검증해야 한다.

장점: 어떤 경로(API, 배치, 테스트 코드)로 접근하든 비즈니스 루틴이 항상 보호된다.

② 시스템/조회 권한 (Data Access & Filtering)

정의: 데이터를 조회(R)하거나 특정 메뉴에 접근할 때 적용되는 필터링 규칙.

예시: "허브 매니저는 본인 소속 허브의 사용자 목록만 볼 수 있다".

위치: 서비스(Service) 또는 인프라(Security/Repository) 계층.

이유: 성능 최적화를 위해 DB에서 데이터를 꺼내오기 전(WHERE 절)에 미리 걸러야 하기 때문이다.

2. 왜 조회의 권한 체크는 엔티티에서 할 수 없는가?

성능 문제 (Performance): 10,000명의 유저 중 내가 볼 수 있는 10명을 찾기 위해 10,000명을 다 메모리에 올려서 엔티티 로직으로 체크하면 서버 부하가 발생한다.

시점의 차이 (Timing): 엔티티 로직은 데이터가 객체화된 이후에 동작한다. 하지만 조회 권한은 객체화하기 전에 DB 단계에서 차단하는 것이 목적이다.

3. 실무적인 설계 타협점: 통합 검색 API

여러 개의 엔드포인트(권한별 API)를 만드는 대신, 하나의 검색 API에서 서비스 레이어가 검색 조건을 강제(Enforcement)하는 방식을 채택한다.

작동 방식:

1) 컨트롤러는 하나의 GET /api/v1/users만 노출한다.

2) 서비스 레이어에서 @AuthenticationPrincipal로 현재 사용자 정보를 확인한다.

3) 사용자가 MASTER가 아니라면, UserQueryDto.Search 객체의 필터 조건을 유저의 권한에 맞게 강제 세팅한다. (예: search.setHubIds(List.of(user.getHubId())))

4) 수정된 검색 조건을 Querydsl 레포지토리에 넘겨 안전한 결과만 조회한다.

4.정리

"데이터를 바꿀 때(CUD)의 권한은 도메인 엔티티가 스스로를 보호하게 하고,
데이터를 찾을 때(R)의 권한은 서비스/인프라가 검색 범위를 제한하게 한다."

이 원칙을 지키면 객체지향적인 응집도도 챙기면서 시스템의 성능과 보안을 모두 잡을 수 있다.

profile
풀스택 연습생. 끈기있는 삽질로 무대에서 화려하게 데뷔할 예정 ❤️🔥

0개의 댓글