CustomerQueryServiceImpl.java
package com.deveagles.be15_deveagles_be.features.customers.query.infrastructure.service;
import static com.deveagles.be15_deveagles_be.features.customers.command.domain.aggregate.QCustomer.customer;
import static com.deveagles.be15_deveagles_be.features.customers.command.domain.aggregate.QCustomerGrade.customerGrade;
import static com.deveagles.be15_deveagles_be.features.customers.command.domain.aggregate.QTag.tag;
import static com.deveagles.be15_deveagles_be.features.customers.command.domain.aggregate.QTagByCustomer.tagByCustomer;
import com.deveagles.be15_deveagles_be.common.dto.PagedResult;
import com.deveagles.be15_deveagles_be.common.dto.Pagination;
import com.deveagles.be15_deveagles_be.common.exception.BusinessException;
import com.deveagles.be15_deveagles_be.common.exception.ErrorCode;
import com.deveagles.be15_deveagles_be.features.customers.command.application.dto.response.TagResponse;
import com.deveagles.be15_deveagles_be.features.customers.command.domain.aggregate.Customer;
import com.deveagles.be15_deveagles_be.features.customers.command.domain.repository.CustomerRepository;
import com.deveagles.be15_deveagles_be.features.customers.command.infrastructure.repository.CustomerElasticsearchRepository;
import com.deveagles.be15_deveagles_be.features.customers.command.infrastructure.repository.CustomerJpaRepository;
import com.deveagles.be15_deveagles_be.features.customers.query.dto.request.CustomerSearchQuery;
import com.deveagles.be15_deveagles_be.features.customers.query.dto.response.*;
import com.deveagles.be15_deveagles_be.features.customers.query.repository.CustomerDetailQueryRepository;
import com.deveagles.be15_deveagles_be.features.customers.query.repository.CustomerListQueryRepository;
import com.deveagles.be15_deveagles_be.features.customers.query.service.CustomerQueryService;
import com.querydsl.jpa.impl.JPAQuery;
import com.querydsl.jpa.impl.JPAQueryFactory;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Slf4j
public class CustomerQueryServiceImpl implements CustomerQueryService {
private final CustomerJpaRepository customerJpaRepository;
private final CustomerRepository customerRepository;
private final CustomerElasticsearchRepository elasticsearchRepository;
private final CustomerDetailQueryRepository customerDetailQueryRepository;
private final CustomerListQueryRepository customerListQueryRepository;
private final JPAQueryFactory queryFactory;
// 기본 조회
@Override
public Optional<CustomerResponse> getCustomerByPhoneNumber(String phoneNumber, Long shopId) {
return customerJpaRepository
.findByPhoneNumberAndShopIdAndDeletedAtIsNull(phoneNumber, shopId)
.map(CustomerResponse::from);
}
@Override
public List<CustomerResponse> getCustomersByShopId(Long shopId) {
return customerJpaRepository.findByShopIdAndDeletedAtIsNull(shopId).stream()
.map(CustomerResponse::from)
.toList();
}
@Override
public long getCustomerCountByShopId(Long shopId) {
return customerJpaRepository.countByShopIdAndDeletedAtIsNull(shopId);
}
@Override
public boolean existsByPhoneNumber(String phoneNumber, Long shopId) {
return customerJpaRepository.existsByPhoneNumberAndShopIdAndDeletedAtIsNull(
phoneNumber, shopId);
}
// 상세 조회 (조인)
@Override
public Optional<CustomerDetailResponse> getCustomerDetail(Long customerId, Long shopId) {
return customerDetailQueryRepository.findCustomerDetailById(customerId, shopId);
}
// 목록 조회 (조인 + 페이징)
@Override
public List<CustomerListResponse> getCustomerList(Long shopId) {
return customerListQueryRepository.findCustomerListByShopId(shopId);
}
@Override
public Page<CustomerListResponse> getCustomerListPaged(Long shopId, Pageable pageable) {
return customerListQueryRepository.findCustomerListByShopId(shopId, pageable);
}
// 검색 (Elasticsearch + JPA 폴백)
@Override
public List<CustomerSearchResult> searchByKeyword(String keyword, Long shopId) {
try {
List<CustomerDocument> documents =
elasticsearchRepository.searchByNameOrPhoneNumber(shopId, keyword);
return documents.stream().map(CustomerSearchResult::from).collect(Collectors.toList());
} catch (Exception e) {
log.warn("Elasticsearch 검색 실패, JPA로 폴백: {}", e.getMessage());
return fallbackToJpaSearch(keyword, shopId);
}
}
@Override
public PagedResult<CustomerSearchResult> advancedSearch(CustomerSearchQuery query) {
try {
Sort sort =
Sort.by(
"DESC".equalsIgnoreCase(query.sortDirection())
? Sort.Direction.DESC
: Sort.Direction.ASC,
mapSortField(query.sortBy()));
Pageable pageable = PageRequest.of(query.page(), query.size(), sort);
// 기본 쿼리 생성
JPAQuery<Customer> jpaQuery =
queryFactory
.selectFrom(customer)
.leftJoin(customerGrade)
.on(customer.customerGradeId.eq(customerGrade.id))
.where(customer.shopId.eq(query.shopId()).and(customer.deletedAt.isNull()));
// 키워드 검색
if (query.keyword() != null && !query.keyword().trim().isEmpty()) {
jpaQuery.where(
customer
.customerName
.containsIgnoreCase(query.keyword())
.or(customer.phoneNumber.containsIgnoreCase(query.keyword())));
}
// 고객 등급 필터링
if (query.customerGradeIds() != null && !query.customerGradeIds().isEmpty()) {
jpaQuery.where(customer.customerGradeId.in(query.customerGradeIds()));
}
// 태그 필터링
if (query.tagIds() != null && !query.tagIds().isEmpty()) {
jpaQuery
.innerJoin(tagByCustomer)
.on(customer.id.eq(tagByCustomer.customerId))
.where(tagByCustomer.tagId.in(query.tagIds().stream().map(Long::valueOf).toList()));
}
// 성별 필터링
if (query.gender() != null) {
jpaQuery.where(customer.gender.eq(Customer.Gender.valueOf(query.gender())));
}
// 마케팅 동의 필터링
if (query.marketingConsent() != null) {
jpaQuery.where(customer.marketingConsent.eq(query.marketingConsent()));
}
// 알림 동의 필터링
if (query.notificationConsent() != null) {
jpaQuery.where(customer.notificationConsent.eq(query.notificationConsent()));
}
// 휴면 고객 제외
if (query.excludeDormant() != null && query.excludeDormant()) {
LocalDateTime dormantDate =
LocalDateTime.now()
.minusMonths(query.dormantMonths() != null ? query.dormantMonths() : 6);
jpaQuery.where(customer.recentVisitDate.after(dormantDate.toLocalDate()));
}
// 최근 메시지 수신자 제외
if (query.excludeRecentMessage() != null && query.excludeRecentMessage()) {
LocalDateTime recentMessageDate =
LocalDateTime.now()
.minusDays(query.recentMessageDays() != null ? query.recentMessageDays() : 30);
jpaQuery.where(
customer
.lastMessageSentAt
.before(recentMessageDate)
.or(customer.lastMessageSentAt.isNull()));
}
// 페이징 적용
List<Customer> countResult = jpaQuery.fetch();
long total = countResult.size();
List<Customer> customers =
jpaQuery.offset(pageable.getOffset()).limit(pageable.getPageSize()).fetch();
// 결과 변환
List<CustomerSearchResult> responses =
customers.stream()
.map(
c ->
CustomerSearchResult.of(
c.getId(),
c.getCustomerName(),
c.getPhoneNumber(),
c.getCustomerGradeId(),
null, // customerGradeName은 나중에 조인으로 가져오도록 수정
c.getGender()))
.toList();
Pagination pagination =
Pagination.builder()
.currentPage(query.page())
.totalPages((int) Math.ceil((double) total / query.size()))
.totalItems(total)
.build();
return new PagedResult<>(responses, pagination);
} catch (Exception e) {
log.error("JPA 고급 검색 실패: {}", e.getMessage(), e);
throw new BusinessException(ErrorCode.INTERNAL_SERVER_ERROR);
}
}
@Override
public List<String> autocomplete(String prefix, Long shopId) {
try {
List<CustomerDocument> documents = elasticsearchRepository.autocomplete(shopId, prefix);
return documents.stream()
.map(doc -> doc.getCustomerName() + " (" + doc.getPhoneNumber() + ")")
.distinct()
.limit(10)
.collect(Collectors.toList());
} catch (Exception e) {
log.warn("Elasticsearch 자동완성 실패: {}", e.getMessage());
return List.of();
}
}
@Override
public long countByKeyword(String keyword, Long shopId) {
try {
return elasticsearchRepository.searchByNameOrPhoneNumber(shopId, keyword).size();
} catch (Exception e) {
log.warn("Elasticsearch 개수 조회 실패: {}", e.getMessage());
return 0;
}
}
// 태그 조회
@Override
public List<TagResponse> getCustomerTags(Long customerId, Long shopId) {
validateCustomerExists(customerId, shopId);
return queryFactory
.select(tag.id, tag.shopId, tag.tagName, tag.colorCode)
.from(tagByCustomer)
.innerJoin(tag)
.on(tagByCustomer.tagId.eq(tag.id))
.where(tagByCustomer.customerId.eq(customerId))
.fetch()
.stream()
.map(
tuple ->
TagResponse.builder()
.tagId(tuple.get(tag.id))
.shopId(tuple.get(tag.shopId))
.tagName(tuple.get(tag.tagName))
.colorCode(tuple.get(tag.colorCode))
.build())
.toList();
}
// Elasticsearch 동기화
@Override
@Transactional
public void syncCustomerToElasticsearch(Long customerId) {
try {
customerJpaRepository
.findById(customerId)
.ifPresent(
cust -> {
CustomerDocument document = createCustomerDocumentWithGradeName(cust);
elasticsearchRepository.save(document);
log.info("고객 Elasticsearch 동기화 완료: ID={}", customerId);
});
} catch (Exception e) {
log.error("고객 Elasticsearch 동기화 실패: ID={}, error={}", customerId, e.getMessage());
}
}
@Override
@Transactional
public void reindexAllCustomers(Long shopId) {
try {
List<Customer> customers = customerJpaRepository.findByShopIdAndDeletedAtIsNull(shopId);
List<CustomerDocument> documents =
customers.stream()
.map(this::createCustomerDocumentWithGradeName)
.collect(Collectors.toList());
elasticsearchRepository.saveAll(documents);
log.info("매장 {} 고객 데이터 재인덱싱 완료: {}건", shopId, documents.size());
} catch (Exception e) {
log.error("고객 데이터 재인덱싱 실패: shopId={}, error={}", shopId, e.getMessage());
}
}
@Override
@Transactional
public void reindexAllCustomersWithReset(Long shopId) {
try {
log.info("매장 {} 고객 데이터 리셋 후 재인덱싱 시작", shopId);
// 1. 해당 매장의 기존 문서 삭제
deleteCustomerDocumentsByShopId(shopId);
// 2. 배치 처리로 재인덱싱
reindexCustomersByBatch(shopId);
log.info("매장 {} 고객 데이터 리셋 후 재인덱싱 완료", shopId);
} catch (Exception e) {
log.error("매장 {} 고객 데이터 리셋 후 재인덱싱 실패: {}", shopId, e.getMessage());
throw new RuntimeException("재인덱싱 실패", e);
}
}
@Override
@Transactional
public void reindexAllShopsCustomers() {
try {
log.info("전체 매장 고객 데이터 재인덱싱 시작");
List<Long> shopIds = customerJpaRepository.findDistinctShopIds();
for (Long shopId : shopIds) {
try {
reindexCustomersByBatch(shopId);
log.info("매장 {} 재인덱싱 완료", shopId);
} catch (Exception e) {
log.error("매장 {} 재인덱싱 실패: {}", shopId, e.getMessage());
// 다른 매장은 계속 처리
}
}
log.info("전체 매장 고객 데이터 재인덱싱 완료");
} catch (Exception e) {
log.error("전체 매장 고객 데이터 재인덱싱 실패: {}", e.getMessage());
throw new RuntimeException("전체 재인덱싱 실패", e);
}
}
@Override
@Transactional
public void reindexAllShopsCustomersWithReset() {
try {
log.info("전체 매장 고객 데이터 리셋 후 재인덱싱 시작");
// 1. 모든 고객 문서 삭제
elasticsearchRepository.deleteAll();
log.info("기존 고객 문서 전체 삭제 완료");
// 2. 전체 매장 재인덱싱
reindexAllShopsCustomers();
log.info("전체 매장 고객 데이터 리셋 후 재인덱싱 완료");
} catch (Exception e) {
log.error("전체 매장 고객 데이터 리셋 후 재인덱싱 실패: {}", e.getMessage());
throw new RuntimeException("전체 리셋 후 재인덱싱 실패", e);
}
}
@Override
public ReindexStatus getReindexStatus(String taskId) {
return ReindexStatus.createCompleted(taskId, 0L, 0L, LocalDateTime.now(), null);
}
// 배치 처리를 위한 private 메서드들
private void deleteCustomerDocumentsByShopId(Long shopId) {
try {
List<CustomerDocument> existingDocs =
elasticsearchRepository
.findByShopIdAndDeletedAtIsNull(shopId, PageRequest.of(0, Integer.MAX_VALUE))
.getContent();
if (!existingDocs.isEmpty()) {
elasticsearchRepository.deleteAll(existingDocs);
log.info("매장 {} 기존 고객 문서 {}건 삭제 완료", shopId, existingDocs.size());
}
} catch (Exception e) {
log.error("매장 {} 기존 고객 문서 삭제 실패: {}", shopId, e.getMessage());
}
}
private void reindexCustomersByBatch(Long shopId) {
final int BATCH_SIZE = 1000;
int page = 0;
while (true) {
Pageable pageable = PageRequest.of(page, BATCH_SIZE);
List<Customer> customers =
customerJpaRepository.findByShopIdAndDeletedAtIsNull(shopId, pageable);
if (customers.isEmpty()) {
break;
}
Set<Long> gradeIds =
customers.stream()
.map(Customer::getCustomerGradeId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
Map<Long, String> gradeNameMap = loadGradeNames(gradeIds);
List<CustomerDocument> documents =
customers.stream()
.map(cust -> createCustomerDocumentWithGradeMap(cust, gradeNameMap))
.collect(Collectors.toList());
elasticsearchRepository.saveAll(documents);
log.info("매장 {} 배치 {} 처리 완료: {}건", shopId, page + 1, documents.size());
page++;
if (customers.size() < BATCH_SIZE) {
break;
}
}
}
// Grade 정보를 배치로 로드하는 최적화된 메서드
private Map<Long, String> loadGradeNames(Set<Long> gradeIds) {
if (gradeIds.isEmpty()) {
return Collections.emptyMap();
}
return queryFactory
.select(customerGrade.id, customerGrade.customerGradeName)
.from(customerGrade)
.where(customerGrade.id.in(gradeIds))
.fetch()
.stream()
.collect(
Collectors.toMap(
tuple -> tuple.get(customerGrade.id),
tuple -> tuple.get(customerGrade.customerGradeName)));
}
// 최적화된 CustomerDocument 생성 메서드
private CustomerDocument createCustomerDocumentWithGradeMap(
Customer cust, Map<Long, String> gradeNameMap) {
String gradeName = gradeNameMap.get(cust.getCustomerGradeId());
return CustomerDocument.builder()
.id(cust.getShopId() + "_" + cust.getId())
.customerId(cust.getId())
.shopId(cust.getShopId())
.customerName(cust.getCustomerName())
.phoneNumber(cust.getPhoneNumber())
.customerGradeId(cust.getCustomerGradeId())
.customerGradeName(gradeName)
.gender(cust.getGender() != null ? cust.getGender().name() : null)
.deletedAt(cust.getDeletedAt())
.build();
}
@Override
public Optional<CustomerIdResponse> findCustomerIdByPhoneNumber(String phoneNumber, Long shopId) {
return customerJpaRepository
.findByPhoneNumberAndShopIdAndDeletedAtIsNull(phoneNumber, shopId)
.map(cust -> new CustomerIdResponse(cust.getId()));
}
// 기존 메서드는 개별 동기화에서만 사용
private CustomerDocument createCustomerDocumentWithGradeName(Customer cust) {
String gradeName =
queryFactory
.select(customerGrade.customerGradeName)
.from(customerGrade)
.where(customerGrade.id.eq(cust.getCustomerGradeId()))
.fetchOne();
return CustomerDocument.builder()
.id(cust.getShopId() + "_" + cust.getId())
.customerId(cust.getId())
.shopId(cust.getShopId())
.customerName(cust.getCustomerName())
.phoneNumber(cust.getPhoneNumber())
.customerGradeId(cust.getCustomerGradeId())
.customerGradeName(gradeName)
.gender(cust.getGender() != null ? cust.getGender().name() : null)
.deletedAt(cust.getDeletedAt())
.build();
}
// Private helper methods
private void validateCustomerExists(Long customerId, Long shopId) {
if (customerRepository.findByIdAndShopId(customerId, shopId).isEmpty()) {
throw new BusinessException(ErrorCode.CUSTOMER_NOT_FOUND, "고객을 찾을 수 없습니다.");
}
}
private List<CustomerSearchResult> fallbackToJpaSearch(String keyword, Long shopId) {
return queryFactory
.select(
customer.id,
customer.customerName,
customer.phoneNumber,
customer.customerGradeId,
customerGrade.customerGradeName,
customer.gender)
.from(customer)
.leftJoin(customerGrade)
.on(customer.customerGradeId.eq(customerGrade.id))
.where(
customer
.shopId
.eq(shopId)
.and(customer.deletedAt.isNull())
.and(
customer
.customerName
.contains(keyword)
.or(customer.phoneNumber.contains(keyword))))
.fetch()
.stream()
.map(
tuple ->
CustomerSearchResult.of(
tuple.get(customer.id),
tuple.get(customer.customerName),
tuple.get(customer.phoneNumber),
tuple.get(customer.customerGradeId),
tuple.get(customerGrade.customerGradeName),
tuple.get(customer.gender)))
.collect(Collectors.toList());
}
private String mapSortField(String sortBy) {
return switch (sortBy) {
case "customerName" -> "customerName.keyword";
case "phoneNumber" -> "phoneNumber";
default -> "customerName.keyword";
};
}
@Override
public List<String> getCustomerPhoneNumbers(List<Long> customerIds) {
List<Customer> customers = customerJpaRepository.findAllById(customerIds);
if (customers.size() != customerIds.size()) {
throw new BusinessException(ErrorCode.CUSTOMER_NOT_FOUND);
}
return customers.stream().map(Customer::getPhoneNumber).toList();
}
}