개념: 데이터 변경(Command)을 담당하는 모델과 조회(Query)를 담당하는 모델을 물리적으로 분리.
장점: 조회 성능 최적화, DB 부하 분산, 읽기/쓰기 스케일링 개별 가능.
활용: 읽기 모델은 도메인 이벤트를 구독하여 데이터를 미리 구성해두는 방식(Projection)을 주로 사용.
개념: API Gateway나 별도의 BFF 서비스가 여러 마이크로서비스에 데이터를 요청한 후, 이를 조합(Merge)하여 프론트엔드에 반환.
장점: 프론트엔드 복잡도 감소, 불필요한 네트워크 호출(Under-fetching) 최소화.
개념: CQRS와 함께 사용되어, 각 서비스의 이벤트를 수집해 조회 전용 저장소(Read DB)를 별도로 구성.
장점: 조회를 위해 여러 서비스에 분산된 데이터를 실시간으로 조인할 필요가 없어 성능 향상.
Querydsl은 하이버네이트(JPA)의 쿼리 메서드나 JPQL의 한계를 극복하기 위한 정적 타입 SQL 빌더이다.
BooleanBuilder와 BooleanExpression을 제공하는 도구이다.MSA(Microservices Architecture) 환경에서는 서비스가 DB를 공유하지 않고 분리되는 구조이다.
// 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();
가장 유연하고 객체지향적인 확장 구조이다.
public interface UserRepositoryCustom {
Page<User> searchUsers(UserSearchCondition condition, Pageable pageable);
}
클래스 이름 끝에 Impl을 붙이는 것이 스프링 데이터 JPA의 규칙이다.
@RequiredArgsConstructor
public class UserRepositoryImpl implements UserRepositoryCustom {
private final JPAQueryFactory queryFactory;
@Override
public Page<User> searchUsers(...) {
// Querydsl 로직을 작성하는 영역이다
}
}
public interface UserRepository extends JpaRepository<User, UUID>, UserRepositoryCustom {
// JpaRepository 기본 기능과 Querydsl 기능을 모두 사용할 수 있는 구조이다
}
스프링 데이터 JPA는 인터페이스 이름 + Impl 또는 Custom 인터페이스 이름 + Impl 규칙을 기반으로 자동으로 Bean을 연결하는 구조이다.
UserQueryRepositoryImpl과 같은 형태는 이 표준을 정확히 따르는 좋은 예시이다.
필요하다면 BooleanExpression을 활용한 동적 쿼리 패턴도 추가로 정리해 줄 수 있다.
CQRS(Command Query Responsibility Segregation)는 명령(Command)과 조회(Query)의 책임을 분리하는 아키텍처 패턴이다.
기존 CRUD 기반 구조는 하나의 모델이 모든 역할을 담당하기 때문에 복잡도가 증가하는 문제가 있다. CQRS는 이를 해결하기 위한 패턴이다.
[ Client ]
│
├── Command 요청 (생성/수정/삭제)
│ ↓
│ Command Service
│ ↓
│ DB (Write Model)
│
└── Query 요청 (조회)
↓
Query Service
↓
DB or 별도 Read Model
MSA 환경에서 CQRS는 매우 자연스럽게 적용되는 패턴이다.
@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);
}
}
@Service
@RequiredArgsConstructor
public class UserQueryService {
private final UserQueryRepository userQueryRepository;
public List<UserDto> getUsers(UserSearchCondition condition) {
return userQueryRepository.search(condition);
}
}
무조건 사용하는 패턴은 아니며, 아래와 같은 상황에서 효과적인 구조이다.
반대로 단순 CRUD 수준의 프로젝트에서는 오히려 과한 설계가 될 수 있는 구조이다.
조회에 필요한 조건(DTO)과 레포지토리의 형태(Interface)를 정의합니다. 인프라 기술(Querydsl)에 의존하지 않도록 인터페이스로 추상화하는 것이 핵심입니다.
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)
}
}
응용 계층(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);
}
도메인 영역에서 정의한 인터페이스를 Querydsl을 사용하여 실제로 구현하는 영역입니다.
동적 쿼리(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;
}
}
도메인 레포지토리를 통해 데이터를 조회하고, 이를 클라이언트가 원하는 형태의 DTO로 변환하여 반환하는 조회 전용 서비스입니다.
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();
}
}
조회까지 와서 목록 조회 필터링 때문에 현재 엔티티 정의가 잘못되어있음을 알았다.
