diff --git a/build.gradle b/build.gradle index d3675823..15a1a01b 100644 --- a/build.gradle +++ b/build.gradle @@ -39,8 +39,8 @@ dependencies { annotationProcessor 'org.projectlombok:lombok' /* Database */ -// runtimeOnly 'com.mysql:mysql-connector-j' - runtimeOnly 'org.mariadb.jdbc:mariadb-java-client' + runtimeOnly 'com.mysql:mysql-connector-j' +// runtimeOnly 'org.mariadb.jdbc:mariadb-java-client' /* Swagger OpenAPI */ implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.14' @@ -72,6 +72,9 @@ dependencies { /* OpenHTMLtoPDF */ implementation 'com.openhtmltopdf:openhtmltopdf-pdfbox:1.0.10' + + /* P6Spry*/ + implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.0' } tasks.named('test') { diff --git a/src/main/java/com/werp/sero/common/logging/P6SpySqlFormatter.java b/src/main/java/com/werp/sero/common/logging/P6SpySqlFormatter.java new file mode 100644 index 00000000..be78a291 --- /dev/null +++ b/src/main/java/com/werp/sero/common/logging/P6SpySqlFormatter.java @@ -0,0 +1,31 @@ +package com.werp.sero.common.logging; + +import com.p6spy.engine.spy.appender.MessageFormattingStrategy; +import org.hibernate.engine.jdbc.internal.FormatStyle; +import org.springframework.util.StringUtils; + +public class P6SpySqlFormatter implements MessageFormattingStrategy { + + @Override + public String formatMessage(int connectionId, String now, long elapsed, String category, String prepared, String sql, String url) { + // statement 카테고리만 쿼리 카운트에 포함 + if ("statement".equals(category) && StringUtils.hasText(sql)) { + QueryCounter.increment(); + QueryCounter.addTime(elapsed); + } + + sql = formatSql(category, sql); + return String.format("[%s] | %d ms | %s", category, elapsed, sql); + } + + private String formatSql(String category, String sql) { + if (StringUtils.hasText(sql) && "statement".equals(category)) { + String trimmedSql = sql.trim().toLowerCase(); + if (trimmedSql.startsWith("create") || trimmedSql.startsWith("alter") || trimmedSql.startsWith("comment")) { + return FormatStyle.DDL.getFormatter().format(sql); + } + return FormatStyle.BASIC.getFormatter().format(sql); + } + return sql; + } +} diff --git a/src/main/java/com/werp/sero/common/logging/QueryCountInterceptor.java b/src/main/java/com/werp/sero/common/logging/QueryCountInterceptor.java new file mode 100644 index 00000000..b59a421d --- /dev/null +++ b/src/main/java/com/werp/sero/common/logging/QueryCountInterceptor.java @@ -0,0 +1,38 @@ +package com.werp.sero.common.logging; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +/** + * API 요청 완료 시 총 쿼리 수와 DB 소요 시간을 로깅하는 인터셉터 + */ +@Component +public class QueryCountInterceptor implements HandlerInterceptor { + + private static final Logger log = LoggerFactory.getLogger(QueryCountInterceptor.class); + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + QueryCounter.clear(); + QueryCounter.start(); + return true; + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { + int count = QueryCounter.getCount(); + long dbTime = QueryCounter.getTotalTimeMs(); + double apiTime = QueryCounter.getApiElapsedMs(); + String method = request.getMethod(); + String uri = request.getRequestURI(); + + log.info("========== [쿼리 통계] {} {} | 총 쿼리: {}개 | DB 소요시간: {}ms | API 총 소요시간: {}ms ==========", + method, uri, count, dbTime, String.format("%.3f", apiTime)); + + QueryCounter.clear(); + } +} diff --git a/src/main/java/com/werp/sero/common/logging/QueryCounter.java b/src/main/java/com/werp/sero/common/logging/QueryCounter.java new file mode 100644 index 00000000..30c8fdfb --- /dev/null +++ b/src/main/java/com/werp/sero/common/logging/QueryCounter.java @@ -0,0 +1,46 @@ +package com.werp.sero.common.logging; + +/** + * API 요청별 쿼리 실행 횟수 및 총 소요 시간을 추적하는 카운터 + * ThreadLocal을 사용하여 각 요청(스레드)별로 독립적으로 카운트 + */ +public class QueryCounter { + + private static final ThreadLocal queryCount = ThreadLocal.withInitial(() -> 0); + private static final ThreadLocal totalTimeMs = ThreadLocal.withInitial(() -> 0L); + private static final ThreadLocal startTimeNano = ThreadLocal.withInitial(() -> 0L); + + public static void start() { + startTimeNano.set(System.nanoTime()); + } + + public static void increment() { + queryCount.set(queryCount.get() + 1); + } + + public static void addTime(long elapsedMs) { + totalTimeMs.set(totalTimeMs.get() + elapsedMs); + } + + public static int getCount() { + return queryCount.get(); + } + + public static long getTotalTimeMs() { + return totalTimeMs.get(); + } + + /** + * API 전체 소요시간 (나노초 → 밀리초, 소수점 3자리) + */ + public static double getApiElapsedMs() { + long elapsedNano = System.nanoTime() - startTimeNano.get(); + return elapsedNano / 1_000_000.0; + } + + public static void clear() { + queryCount.remove(); + totalTimeMs.remove(); + startTimeNano.remove(); + } +} diff --git a/src/main/java/com/werp/sero/config/WebMvcConfig.java b/src/main/java/com/werp/sero/config/WebMvcConfig.java new file mode 100644 index 00000000..c711070e --- /dev/null +++ b/src/main/java/com/werp/sero/config/WebMvcConfig.java @@ -0,0 +1,21 @@ +package com.werp.sero.config; + +import com.werp.sero.common.logging.QueryCountInterceptor; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +@RequiredArgsConstructor +public class WebMvcConfig implements WebMvcConfigurer { + + private final QueryCountInterceptor queryCountInterceptor; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(queryCountInterceptor) + .addPathPatterns("/**") + .excludePathPatterns("/swagger-ui/**", "/v3/api-docs/**"); + } +} diff --git a/src/main/java/com/werp/sero/material/command/domain/repository/MaterialRepository.java b/src/main/java/com/werp/sero/material/command/domain/repository/MaterialRepository.java index fb32cea7..b9e0faf0 100644 --- a/src/main/java/com/werp/sero/material/command/domain/repository/MaterialRepository.java +++ b/src/main/java/com/werp/sero/material/command/domain/repository/MaterialRepository.java @@ -4,6 +4,7 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; /** @@ -14,4 +15,9 @@ public interface MaterialRepository extends JpaRepository { boolean existsByMaterialCode(String materialCode); Optional findByMaterialCode(String materialCode); + + /** + * 자재 코드 목록으로 자재 일괄 조회 (N+1 최적화) + */ + List findByMaterialCodeIn(List materialCodes); } diff --git a/src/main/java/com/werp/sero/shipping/command/application/service/GoodsIssueCommandServiceImpl.java b/src/main/java/com/werp/sero/shipping/command/application/service/GoodsIssueCommandServiceImpl.java index 1242f535..1ce2db2f 100644 --- a/src/main/java/com/werp/sero/shipping/command/application/service/GoodsIssueCommandServiceImpl.java +++ b/src/main/java/com/werp/sero/shipping/command/application/service/GoodsIssueCommandServiceImpl.java @@ -46,6 +46,9 @@ import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -84,8 +87,8 @@ public GICreateResponseDTO createGoodsIssue(GICreateRequestDTO requestDTO, Emplo Warehouse warehouse = warehouseRepository.findById(requestDTO.getWarehouseId()) .orElseThrow(() -> new BusinessException(ErrorCode.WAREHOUSE_NOT_FOUND)); - // 4. 납품서 품목 조회 - List deliveryOrderItems = deliveryOrderItemRepository.findByDeliveryOrderId(deliveryOrder.getId()); + // 4. 납품서 품목 조회 (Fetch Join으로 SalesOrderItem, SalesOrder 함께 로딩) + List deliveryOrderItems = deliveryOrderItemRepository.findByDeliveryOrderIdWithSalesOrderItem(deliveryOrder.getId()); // 5. 주문 조회 (첫 번째 품목의 SalesOrderItem에서 SalesOrder 가져오기) if (deliveryOrderItems.isEmpty()) { @@ -122,19 +125,39 @@ public GICreateResponseDTO createGoodsIssue(GICreateRequestDTO requestDTO, Emplo List goodsIssueItems = new ArrayList<>(); List stocksToUpdate = new ArrayList<>(); + // 9-1. 품목 코드 목록 추출 후 자재 일괄 조회 (N+1 최적화) + List itemCodes = deliveryOrderItems.stream() + .map(doItem -> doItem.getSalesOrderItem().getItemCode()) + .collect(Collectors.toList()); + + List materials = materialRepository.findByMaterialCodeIn(itemCodes); + Map materialMap = materials.stream() + .collect(Collectors.toMap(Material::getMaterialCode, Function.identity())); + + // 9-2. 자재 ID 목록으로 재고 일괄 조회 (N+1 최적화) + List materialIds = materials.stream() + .map(Material::getId) + .collect(Collectors.toList()); + + List stocks = warehouseStockRepository + .findByWarehouseIdAndMaterialIdIn(warehouse.getId(), materialIds); + Map stockMap = stocks.stream() + .collect(Collectors.toMap(stock -> stock.getMaterial().getId(), Function.identity())); + + // 9-3. 품목별 재고 검증 및 할당 for (DeliveryOrderItem doItem : deliveryOrderItems) { String itemCode = doItem.getSalesOrderItem().getItemCode(); String itemName = doItem.getSalesOrderItem().getItemName(); int requiredQuantity = doItem.getDoQuantity(); - // 자재 조회 - Material material = materialRepository.findByMaterialCode(itemCode) - .orElseThrow(() -> new BusinessException(ErrorCode.MATERIAL_NOT_FOUND)); + // 자재 조회 (Map에서 O(1)) + Material material = materialMap.get(itemCode); + if (material == null) { + throw new BusinessException(ErrorCode.MATERIAL_NOT_FOUND); + } - // 창고 재고 조회 - WarehouseStock stock = warehouseStockRepository - .findByWarehouseIdAndMaterialId(warehouse.getId(), material.getId()) - .orElse(null); + // 창고 재고 조회 (Map에서 O(1)) + WarehouseStock stock = stockMap.get(material.getId()); // 재고 검증 if (stock == null || stock.getAvailableStock() < requiredQuantity) { @@ -203,10 +226,30 @@ public GICompleteResponseDTO completeGoodsIssue(String giCode) { throw new BusinessException(ErrorCode.INVALID_GOODS_ISSUE_STATUS); } - // 2. 출고지시 품목 조회 - List goodsIssueItems = goodsIssueItemRepository.findByGoodsIssueId(goodsIssue.getId()); + // 2. 출고지시 품목 조회 (Fetch Join으로 SalesOrderItem 함께 로딩) + List goodsIssueItems = goodsIssueItemRepository.findByGoodsIssueIdWithSalesOrderItem(goodsIssue.getId()); + + // 3. 자재 및 재고 일괄 조회 (N+1 최적화) + // 3-1. 품목 코드 목록 추출 후 자재 일괄 조회 + List itemCodes = goodsIssueItems.stream() + .map(giItem -> giItem.getSalesOrderItem().getItemCode()) + .collect(Collectors.toList()); - // 3. 실제 재고 차감 및 이력 기록 + List materials = materialRepository.findByMaterialCodeIn(itemCodes); + Map materialMap = materials.stream() + .collect(Collectors.toMap(Material::getMaterialCode, Function.identity())); + + // 3-2. 자재 ID 목록으로 재고 일괄 조회 + List materialIds = materials.stream() + .map(Material::getId) + .collect(Collectors.toList()); + + List stocks = warehouseStockRepository + .findByWarehouseIdAndMaterialIdIn(goodsIssue.getWarehouse().getId(), materialIds); + Map stockMap = stocks.stream() + .collect(Collectors.toMap(stock -> stock.getMaterial().getId(), Function.identity())); + + // 4. 실제 재고 차감 및 이력 기록 String createdAt = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")); List stocksToUpdate = new ArrayList<>(); List historiesToSave = new ArrayList<>(); @@ -220,14 +263,17 @@ public GICompleteResponseDTO completeGoodsIssue(String giCode) { String unit = giItem.getSalesOrderItem().getUnit(); int quantity = giItem.getQuantity(); - // 자재 조회 - Material material = materialRepository.findByMaterialCode(itemCode) - .orElseThrow(() -> new BusinessException(ErrorCode.MATERIAL_NOT_FOUND)); + // 자재 조회 (Map에서 O(1)) + Material material = materialMap.get(itemCode); + if (material == null) { + throw new BusinessException(ErrorCode.MATERIAL_NOT_FOUND); + } - // 창고 재고 조회 - WarehouseStock stock = warehouseStockRepository - .findByWarehouseIdAndMaterialId(goodsIssue.getWarehouse().getId(), material.getId()) - .orElseThrow(() -> new BusinessException(ErrorCode.WAREHOUSE_STOCK_NOT_FOUND)); + // 창고 재고 조회 (Map에서 O(1)) + WarehouseStock stock = stockMap.get(material.getId()); + if (stock == null) { + throw new BusinessException(ErrorCode.WAREHOUSE_STOCK_NOT_FOUND); + } // 실제 재고 차감 (current_stock 감소) stock.deductStock(quantity); @@ -266,26 +312,26 @@ public GICompleteResponseDTO completeGoodsIssue(String giCode) { responseItems.add(itemDTO); } - // 4. 배치 저장 + // 5. 배치 저장 warehouseStockRepository.saveAll(stocksToUpdate); warehouseStockHistoryRepository.saveAll(historiesToSave); salesOrderItemHistoryRepository.saveAll(salesHistoriesToSave); - // 5. 출고지시 상태를 출고완료(GI_ISSUED)로 변경 + // 6. 출고지시 상태를 출고완료(GI_ISSUED)로 변경 goodsIssue.updateApprovalInfo(goodsIssue.getApprovalCode(), "GI_ISSUED"); goodsIssueRepository.save(goodsIssue); - // 6. 배송 정보 생성 - // 6-1. 운송장 번호 생성 (SERO-20251222-D001 형식) + // 7. 배송 정보 생성 + // 7-1. 운송장 번호 생성 (SERO-20251222-D001 형식) String today = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); int dailyCount = deliveryRepository.countByDate(today); String trackingNumber = String.format("SERO-%s-D%03d", today, dailyCount + 1); - // 6-2. 배송기사 조회 (ID=23, 김기사) + // 7-2. 배송기사 조회 (ID=23, 김기사) Employee driver = employeeRepository.findById(23) .orElseThrow(() -> new BusinessException(ErrorCode.EMPLOYEE_NOT_FOUND)); - // 6-3. 배송 데이터 생성 + // 7-3. 배송 데이터 생성 Delivery delivery = Delivery.builder() .trackingNumber(trackingNumber) .driverName(driver.getName()) @@ -299,14 +345,14 @@ public GICompleteResponseDTO completeGoodsIssue(String giCode) { deliveryRepository.save(delivery); - // 7. 주문 상태를 배송중(ORD_SHIPPING)으로 변경 + // 8. 주문 상태를 배송중(ORD_SHIPPING)으로 변경 SalesOrder salesOrder = goodsIssue.getSalesOrder(); if ("ORD_SHIP_READY".equals(salesOrder.getStatus())) { salesOrder.updateApprovalInfo(salesOrder.getApprovalCode(), "ORD_SHIPPING"); soRepository.save(salesOrder); } - // 8. 응답 DTO 생성 및 반환 + // 9. 응답 DTO 생성 및 반환 return GICompleteResponseDTO.builder() .giCode(giCode) .warehouseName(goodsIssue.getWarehouse().getName()) diff --git a/src/main/java/com/werp/sero/shipping/command/domain/repository/DeliveryOrderItemRepository.java b/src/main/java/com/werp/sero/shipping/command/domain/repository/DeliveryOrderItemRepository.java index 052e47dd..20279af7 100644 --- a/src/main/java/com/werp/sero/shipping/command/domain/repository/DeliveryOrderItemRepository.java +++ b/src/main/java/com/werp/sero/shipping/command/domain/repository/DeliveryOrderItemRepository.java @@ -2,9 +2,23 @@ import com.werp.sero.shipping.command.domain.aggregate.DeliveryOrderItem; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; public interface DeliveryOrderItemRepository extends JpaRepository { List findByDeliveryOrderId(int deliveryOrderId); + + /** + * 납품서 품목 조회 시 SalesOrderItem, SalesOrder를 함께 조회 (N+1 최적화) + * Fetch Join을 사용하여 1번의 쿼리로 연관 엔티티까지 로딩 + */ + @Query("SELECT doi FROM DeliveryOrderItem doi " + + "JOIN FETCH doi.salesOrderItem soi " + + "JOIN FETCH soi.salesOrder " + + "WHERE doi.deliveryOrder.id = :deliveryOrderId") + List findByDeliveryOrderIdWithSalesOrderItem( + @Param("deliveryOrderId") int deliveryOrderId + ); } diff --git a/src/main/java/com/werp/sero/shipping/command/domain/repository/GoodsIssueItemRepository.java b/src/main/java/com/werp/sero/shipping/command/domain/repository/GoodsIssueItemRepository.java index 22be05d6..e56935f7 100644 --- a/src/main/java/com/werp/sero/shipping/command/domain/repository/GoodsIssueItemRepository.java +++ b/src/main/java/com/werp/sero/shipping/command/domain/repository/GoodsIssueItemRepository.java @@ -15,4 +15,15 @@ public interface GoodsIssueItemRepository extends JpaRepository findByGoodsIssueSalesOrderId(@Param("salesOrderId") int salesOrderId); + + /** + * 출고지시 품목 조회 시 SalesOrderItem을 함께 조회 (N+1 최적화) + * Fetch Join을 사용하여 1번의 쿼리로 연관 엔티티까지 로딩 + */ + @Query("SELECT gii FROM GoodsIssueItem gii " + + "JOIN FETCH gii.salesOrderItem " + + "WHERE gii.goodsIssue.id = :goodsIssueId") + List findByGoodsIssueIdWithSalesOrderItem( + @Param("goodsIssueId") int goodsIssueId + ); } diff --git a/src/main/java/com/werp/sero/warehouse/command/domain/repository/WarehouseStockRepository.java b/src/main/java/com/werp/sero/warehouse/command/domain/repository/WarehouseStockRepository.java index 52ea4c85..8cdaedbf 100644 --- a/src/main/java/com/werp/sero/warehouse/command/domain/repository/WarehouseStockRepository.java +++ b/src/main/java/com/werp/sero/warehouse/command/domain/repository/WarehouseStockRepository.java @@ -5,6 +5,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.util.List; import java.util.Optional; public interface WarehouseStockRepository extends JpaRepository { @@ -16,4 +17,15 @@ Optional findByWarehouseIdAndMaterialId( @Param("warehouseId") int warehouseId, @Param("materialId") int materialId ); + + /** + * 창고ID와 자재ID 목록으로 재고 일괄 조회 (N+1 최적화) + */ + @Query("SELECT A FROM WarehouseStock A " + + "WHERE A.warehouse.id = :warehouseId " + + "AND A.material.id IN :materialIds") + List findByWarehouseIdAndMaterialIdIn( + @Param("warehouseId") int warehouseId, + @Param("materialIds") List materialIds + ); } diff --git a/src/main/resources/spy.properties b/src/main/resources/spy.properties new file mode 100644 index 00000000..692f6c3d --- /dev/null +++ b/src/main/resources/spy.properties @@ -0,0 +1,3 @@ +appender=com.p6spy.engine.spy.appender.Slf4JLogger +logMessageFormat=com.werp.sero.common.logging.P6SpySqlFormatter +excludecategories=info,debug,result,resultset