4/3(금) 조회, 수정, 삭제

dev_joo·2026년 4월 3일

MSA 조회 패턴

CQRS (Command Query Responsibility Segregation):

개념: 데이터 변경(Command)을 담당하는 모델과 조회(Query)를 담당하는 모델을 물리적으로 분리.
장점: 조회 성능 최적화, DB 부하 분산, 읽기/쓰기 스케일링 개별 가능.
활용: 읽기 모델은 도메인 이벤트를 구독하여 데이터를 미리 구성해두는 방식(Projection)을 주로 사용.

API Composition / BFF (Backend For Frontend):

개념: API Gateway나 별도의 BFF 서비스가 여러 마이크로서비스에 데이터를 요청한 후, 이를 조합(Merge)하여 프론트엔드에 반환.
장점: 프론트엔드 복잡도 감소, 불필요한 네트워크 호출(Under-fetching) 최소화.

데이터 프로젝션 (Data Projection):

개념: CQRS와 함께 사용되어, 각 서비스의 이벤트를 수집해 조회 전용 저장소(Read DB)를 별도로 구성.
장점: 조회를 위해 여러 서비스에 분산된 데이터를 실시간으로 조인할 필요가 없어 성능 향상.


🔍 Querydsl 핵심 요약 가이드

1. Querydsl 소개

Querydsl은 하이버네이트(JPA)의 쿼리 메서드나 JPQL의 한계를 극복하기 위한 정적 타입 SQL 빌더이다.

  • Type-Safe: 문자열이 아닌 자바 코드로 쿼리를 작성하므로 컴파일 시점에 오류를 발견할 수 있는 구조이다.
  • 동적 쿼리: 조건에 따라 쿼리를 유연하게 생성할 수 있도록 BooleanBuilderBooleanExpression을 제공하는 도구이다.
  • 가독성: SQL과 유사한 문법을 사용하므로 복잡한 조인(Join)과 페이징을 직관적으로 작성할 수 있는 장점이 있다.

2. Querydsl과 MSA의 관계

MSA(Microservices Architecture) 환경에서는 서비스가 DB를 공유하지 않고 분리되는 구조이다.

  • 복잡한 필터링: 서비스 내부에서 상태, 기간, 담당자 등 다양한 조건으로 조회해야 하는 경우 동적 쿼리 기능이 필수적인 요소이다.
  • CQRS 패턴 지원: 명령(CUD)과 조회(R)를 분리하는 구조에서, 조회 서비스의 다양한 조건 처리에 최적화된 기술이다.
  • API 응답 최적화: 필요한 필드만 선택하는 프로젝션(Projection)을 통해 서비스 간 네트워크 트래픽을 줄일 수 있는 구조이다.

3. 기초 문법 Quick Start

// 1. 기본 조회 및 조건
List<User> users = queryFactory
    .selectFrom(user)
    .where(user.name.eq("sj"), user.role.eq(UserRole.MANAGER)) // ,는 AND 조건이다
    .fetch();

// 2. 검색 (Like)
user.email.contains("gmail.com")      // %gmail.com%
user.username.startsWith("kim")      // kim%

// 3. 정렬 및 페이징
List<User> pagedUsers = queryFactory
    .selectFrom(user)
    .orderBy(user.createdAt.desc())
    .offset(0)
    .limit(10)
    .fetch();

4. 권장 아키텍처 (Spring Data JPA + Querydsl)

가장 유연하고 객체지향적인 확장 구조이다.

① Custom 인터페이스 (사용자 정의)

public interface UserRepositoryCustom {
    Page<User> searchUsers(UserSearchCondition condition, Pageable pageable);
}

② Impl 클래스 (실제 구현)

클래스 이름 끝에 Impl을 붙이는 것이 스프링 데이터 JPA의 규칙이다.

@RequiredArgsConstructor
public class UserRepositoryImpl implements UserRepositoryCustom {
    private final JPAQueryFactory queryFactory;

    @Override
    public Page<User> searchUsers(...) {
        // Querydsl 로직을 작성하는 영역이다
    }
}

③ Repository 인터페이스 (상속)

public interface UserRepository extends JpaRepository<User, UUID>, UserRepositoryCustom {
    // JpaRepository 기본 기능과 Querydsl 기능을 모두 사용할 수 있는 구조이다
}

💡 팁: Impl 클래스 이름 규칙

스프링 데이터 JPA는 인터페이스 이름 + Impl 또는 Custom 인터페이스 이름 + Impl 규칙을 기반으로 자동으로 Bean을 연결하는 구조이다.
UserQueryRepositoryImpl과 같은 형태는 이 표준을 정확히 따르는 좋은 예시이다.


필요하다면 BooleanExpression을 활용한 동적 쿼리 패턴도 추가로 정리해 줄 수 있다.


🔍 CQRS 핵심 요약 가이드

1. CQRS 소개

CQRS(Command Query Responsibility Segregation)는 명령(Command)과 조회(Query)의 책임을 분리하는 아키텍처 패턴이다.

  • Command (CUD): 데이터의 생성(Create), 수정(Update), 삭제(Delete)를 담당하는 영역이다.
  • Query (R): 데이터를 조회(Read)하는 역할만 수행하는 영역이다.
  • 핵심 개념: 상태를 변경하는 로직과 데이터를 조회하는 로직을 분리하여 각각의 목적에 맞게 최적화하는 구조이다.

2. CQRS를 사용하는 이유

기존 CRUD 기반 구조는 하나의 모델이 모든 역할을 담당하기 때문에 복잡도가 증가하는 문제가 있다. CQRS는 이를 해결하기 위한 패턴이다.

  • 관심사 분리: 쓰기와 읽기 로직을 분리하여 코드의 책임이 명확해지는 구조이다.
  • 성능 최적화: 조회는 조회에 맞게, 저장은 저장에 맞게 각각 독립적으로 튜닝할 수 있는 구조이다.
  • 확장성: 읽기 트래픽이 많은 시스템에서 조회 모델만 별도로 확장할 수 있는 장점이 있다.

3. 동작 구조 (개념 흐름)

[ Client ]
   │
   ├── Command 요청 (생성/수정/삭제)
   │        ↓
   │   Command Service
   │        ↓
   │     DB (Write Model)
   │
   └── Query 요청 (조회)
            ↓
      Query Service
            ↓
       DB or 별도 Read Model
  • Command와 Query가 서로 다른 서비스 또는 계층으로 분리되는 구조이다.
  • 필요에 따라 조회 전용 DB(Read Model)를 따로 둘 수도 있는 구조이다.

4. CQRS + MSA 관계

MSA 환경에서 CQRS는 매우 자연스럽게 적용되는 패턴이다.

  • 서비스 분리: Command 서비스와 Query 서비스를 서로 다른 마이크로서비스로 분리할 수 있는 구조이다.
  • 독립 배포: 조회 서비스와 저장 서비스를 각각 독립적으로 배포 및 확장할 수 있는 장점이 있다.
  • 데이터 최적화: 조회 전용 DB를 구성하여 API 응답 속도를 극대화할 수 있는 구조이다.

5. 간단한 코드 구조 예시

① Command Service (쓰기 전용)

@Service
@RequiredArgsConstructor
public class UserCommandService {

    private final UserRepository userRepository;

    public void createUser(CreateUserRequest request) {
        User user = new User(request.getName(), request.getEmail());
        userRepository.save(user);
    }
}

② Query Service (조회 전용)

@Service
@RequiredArgsConstructor
public class UserQueryService {

    private final UserQueryRepository userQueryRepository;

    public List<UserDto> getUsers(UserSearchCondition condition) {
        return userQueryRepository.search(condition);
    }
}

6. 언제 CQRS를 써야 하는가

무조건 사용하는 패턴은 아니며, 아래와 같은 상황에서 효과적인 구조이다.

  • 조회 조건이 복잡하고 동적 쿼리가 많은 경우
  • 읽기 트래픽이 쓰기보다 훨씬 많은 경우
  • 서비스 규모가 커지고 도메인 복잡도가 증가하는 경우

반대로 단순 CRUD 수준의 프로젝트에서는 오히려 과한 설계가 될 수 있는 구조이다.


유저 조회에 적용하기 (진행중)

도메인 (Domain Layer)

조회에 필요한 조건(DTO)과 레포지토리의 형태(Interface)를 정의합니다. 인프라 기술(Querydsl)에 의존하지 않도록 인터페이스로 추상화하는 것이 핵심입니다.

1. QueryDTO (UserQueryDto.java)

클라이언트로부터 전달받을 다양한 검색 조건을 담는 객체입니다.

package org.iimsa.userservice.domain.query;

import java.util.List;
import java.util.UUID;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class UserQueryDto {
    @Getter
    @Builder
    public static class Search {
        private List<UUID> ids; // 회원 아이디 복수 검색
        private List<UUID> hubIds; // 허브 아이디 복수 검색
        private List<UUID> companyIds; // 업체 아이디 복수 검색
        private String name; // 회원명
        private List<String> email; // 이메일
        private String hubName; // 허브명
        private String companyName; // 업체명
        private String keyword; // 통합 키워드 (name + email + hubId)
    }
}

2. Repository Interface (UserQueryRepository.java)

응용 계층(Service)에서 호출할 조회 전용 메서드들을 정의합니다.

package org.iimsa.userservice.domain.query;

import java.util.Optional;
import java.util.UUID;
import org.iimsa.userservice.domain.model.User;
import org.iimsa.userservice.domain.model.UserRole;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

public interface UserQueryRepository {
    Optional<User> findById(UUID id);
    Page<User> findAllByHubId(UUID hubId, UserQueryDto.Search search, Pageable pageable);
    Page<User> findAllByCompanyId(UUID companyId, UserQueryDto.Search search, Pageable pageable);
    Page<User> findAllByRole(UserRole role, UserQueryDto.Search search, Pageable pageable);
    Page<User> findAll(UserQueryDto.Search search, Pageable pageable);
}

인프라 (Infrastructure Layer)

도메인 영역에서 정의한 인터페이스를 Querydsl을 사용하여 실제로 구현하는 영역입니다.

1. QueryRepositoryImpl (UserQueryRepositoryImpl.java)

동적 쿼리(BooleanBuilder)를 활용하여 다중 검색 조건을 처리하고, PageableExecutionUtils를 사용해 페이징 처리를 최적화합니다.

package org.iimsa.userservice.infrastructure.persistence.jpa;

import static org.iimsa.userservice.domain.model.QUser.user;

import com.querydsl.core.BooleanBuilder;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.core.types.dsl.Expressions;
import com.querydsl.core.types.dsl.StringPath;
import com.querydsl.jpa.impl.JPAQuery;
import com.querydsl.jpa.impl.JPAQueryFactory;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.iimsa.userservice.domain.model.User;
import org.iimsa.userservice.domain.model.UserRole;
import org.iimsa.userservice.domain.query.UserQueryDto;
import org.iimsa.userservice.domain.query.UserQueryRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.support.PageableExecutionUtils;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;

@Repository
@RequiredArgsConstructor
public class UserQueryRepositoryImpl implements UserQueryRepository {

    private final JPAQueryFactory queryFactory;

    @Override
    public Optional<User> findById(UUID id) {
        return Optional.ofNullable(
                queryFactory
                        .selectFrom(user)
                        .where(user.id.eq(id))
                        .fetchOne()
        );
    }

    @Override
    public Page<User> findAllByHubId(UUID hubId, UserQueryDto.Search search, Pageable pageable) {
        // 기본 조건(hubId 일치) + 동적 검색 조건
        return getPage(search, pageable, user.deliveryManager.hubId.eq(hubId));
    }

    @Override
    public Page<User> findAllByCompanyId(UUID companyId, UserQueryDto.Search search, Pageable pageable) {
        // TODO: 현재 User 엔티티 구조상 companyId 필드가 누락되어 있어 임시로 null 처리
        // 추후 CompanyManager 임베디드 객체 등이 추가되면 해당 필드로 조건을 수정해야 합니다.
        return getPage(search, pageable, null); 
    }

    @Override
    public Page<User> findAllByRole(UserRole role, UserQueryDto.Search search, Pageable pageable) {
        return getPage(search, pageable, user.userRole.eq(role));
    }

    @Override
    public Page<User> findAll(UserQueryDto.Search search, Pageable pageable) {
        return getPage(search, pageable, null);
    }

    // 공통 페이징 처리 로직
    private Page<User> getPage(UserQueryDto.Search search, Pageable pageable, BooleanExpression baseCondition) {
        BooleanBuilder builder = createSearchCondition(search);
        if (baseCondition != null) {
            builder.and(baseCondition);
        }

        // 실제 데이터 조회 쿼리
        List<User> content = queryFactory
                .selectFrom(user)
                .where(builder)
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .orderBy(user.createdAt.desc()) // BaseEntity의 createdAt 사용
                .fetch();

        // 카운트 쿼리
        JPAQuery<Long> countQuery = queryFactory
                .select(user.count())
                .from(user)
                .where(builder);

        // PageableExecutionUtils를 사용하면 첫 페이지이거나 마지막 페이지일 때 불필요한 카운트 쿼리를 생략하여 성능이 향상됨
        return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
    }

    // 동적 검색 조건 생성 로직
    private BooleanBuilder createSearchCondition(UserQueryDto.Search search) {
        BooleanBuilder builder = new BooleanBuilder();

        if (search == null) return builder;

        if (search.getIds() != null && !search.getIds().isEmpty()) {
            builder.and(user.id.in(search.getIds()));
        }

        if (search.getHubIds() != null && !search.getHubIds().isEmpty()) {
            builder.and(user.deliveryManager.hubId.in(search.getHubIds()));
        }

        if (search.getEmail() != null && !search.getEmail().isEmpty()) {
            builder.and(user.email.in(search.getEmail()));
        }

        if (StringUtils.hasText(search.getName())) {
            builder.and(user.username.containsIgnoreCase(search.getName()));
        }

        // 통합 키워드 검색 (UUID 타입인 hubId를 StringPath로 강제 형변환하여 검색 지원)
        if (StringUtils.hasText(search.getKeyword())) {
            String keyword = search.getKeyword();
            StringPath hubIdStringPath = Expressions.stringPath(user.deliveryManager.hubId, "hubId");

            builder.and(
                    user.username.containsIgnoreCase(keyword)
                            .or(user.email.containsIgnoreCase(keyword))
                            .or(hubIdStringPath.containsIgnoreCase(keyword))
            );
        }

        return builder;
    }
}

응용 계층 (Application Layer)

도메인 레포지토리를 통해 데이터를 조회하고, 이를 클라이언트가 원하는 형태의 DTO로 변환하여 반환하는 조회 전용 서비스입니다.

UserQueryService (UserQueryService.java)

package org.iimsa.userservice.application;

import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.iimsa.userservice.domain.model.User;
import org.iimsa.userservice.domain.query.UserQueryDto;
import org.iimsa.userservice.domain.query.UserQueryRepository;
import org.iimsa.userservice.presentation.dto.UserResponse;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true) // 💡 핵심: 변경 감지(Dirty Checking)를 꺼서 조회 성능을 최적화합니다.
public class UserQueryService {

    private final UserQueryRepository userQueryRepository;

    // 1. 단건 조회
    public UserResponse.Info getUser(UUID id) {
        User user = userQueryRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다. ID: " + id)); // 실무에서는 Custom Exception 권장
        
        return mapToInfoDto(user);
    }

    // 2. 전체 조건 검색 (페이징)
    public Page<UserResponse.Info> searchUsers(UserQueryDto.Search search, Pageable pageable) {
        // Page 객체의 map() 메서드를 사용하여 Page<User>를 Page<UserResponse.Info>로 우아하게 변환합니다.
        return userQueryRepository.findAll(search, pageable)
                .map(this::mapToInfoDto); 
    }

    // 3. 허브별 사용자 검색
    public Page<UserResponse.Info> searchUsersByHub(UUID hubId, UserQueryDto.Search search, Pageable pageable) {
        return userQueryRepository.findAllByHubId(hubId, search, pageable)
                .map(this::mapToInfoDto);
    }

    // ==============================================================================
    // 💡 Entity -> DTO 변환 메서드 (Mapper)
    // ==============================================================================
    
    
    private UserResponse.Info mapToInfoDto(User user) {
        UUID hubId = null;
        Integer sequence = null;

        // DeliveryManager(임베디드 타입)가 Null이 아닐 경우에만 데이터 안전하게 추출
        if (user.getDeliveryManager() != null) {
            hubId = user.getDeliveryManager().getHubId();
            sequence = user.getDeliveryManager().getDeliverySequence();
        }

        return UserResponse.Info.builder()
                .id(user.getId())
                // 엔티티의 필드명(username, userRole)과 DTO의 필드명(name, role) 매핑
                .name(user.getUsername()) 
                .email(user.getEmail())
                .role(user.getUserRole()) 
                .slackId(user.getSlackId())
                .hubId(hubId)
                .deliveryRotationOrder(sequence)
                .companyId(null) // 추후 엔티티에 companyId 추가되면 매핑: user.getCompanyId()
                .storeName(null)
                .hubName(null)
                .status(user.getUserStatus()) 

               	// TODO :
                // 허브 이름(hubName)이나 업체명(companyName)은 User DB에 없어 일단 null로 처리   
                .hubName(null) 
                .companyId(null) 
                .companyName(null)
   				.build();
    }
}

Bug fix

조회까지 와서 목록 조회 필터링 때문에 현재 엔티티 정의가 잘못되어있음을 알았다.

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

0개의 댓글