Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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') {
Expand Down
31 changes: 31 additions & 0 deletions src/main/java/com/werp/sero/common/logging/P6SpySqlFormatter.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
46 changes: 46 additions & 0 deletions src/main/java/com/werp/sero/common/logging/QueryCounter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.werp.sero.common.logging;

/**
* API 요청별 쿼리 실행 횟수 및 총 소요 시간을 추적하는 카운터
* ThreadLocal을 사용하여 각 요청(스레드)별로 독립적으로 카운트
*/
public class QueryCounter {

private static final ThreadLocal<Integer> queryCount = ThreadLocal.withInitial(() -> 0);
private static final ThreadLocal<Long> totalTimeMs = ThreadLocal.withInitial(() -> 0L);
private static final ThreadLocal<Long> 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();
}
}
21 changes: 21 additions & 0 deletions src/main/java/com/werp/sero/config/WebMvcConfig.java
Original file line number Diff line number Diff line change
@@ -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/**");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;

/**
Expand All @@ -14,4 +15,9 @@ public interface MaterialRepository extends JpaRepository<Material, Integer> {
boolean existsByMaterialCode(String materialCode);

Optional<Material> findByMaterialCode(String materialCode);

/**
* 자재 코드 목록으로 자재 일괄 조회 (N+1 최적화)
*/
List<Material> findByMaterialCodeIn(List<String> materialCodes);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<DeliveryOrderItem> deliveryOrderItems = deliveryOrderItemRepository.findByDeliveryOrderId(deliveryOrder.getId());
// 4. 납품서 품목 조회 (Fetch Join으로 SalesOrderItem, SalesOrder 함께 로딩)
List<DeliveryOrderItem> deliveryOrderItems = deliveryOrderItemRepository.findByDeliveryOrderIdWithSalesOrderItem(deliveryOrder.getId());

// 5. 주문 조회 (첫 번째 품목의 SalesOrderItem에서 SalesOrder 가져오기)
if (deliveryOrderItems.isEmpty()) {
Expand Down Expand Up @@ -122,19 +125,39 @@ public GICreateResponseDTO createGoodsIssue(GICreateRequestDTO requestDTO, Emplo
List<GoodsIssueItem> goodsIssueItems = new ArrayList<>();
List<WarehouseStock> stocksToUpdate = new ArrayList<>();

// 9-1. 품목 코드 목록 추출 후 자재 일괄 조회 (N+1 최적화)
List<String> itemCodes = deliveryOrderItems.stream()
.map(doItem -> doItem.getSalesOrderItem().getItemCode())
.collect(Collectors.toList());

List<Material> materials = materialRepository.findByMaterialCodeIn(itemCodes);
Map<String, Material> materialMap = materials.stream()
.collect(Collectors.toMap(Material::getMaterialCode, Function.identity()));

// 9-2. 자재 ID 목록으로 재고 일괄 조회 (N+1 최적화)
List<Integer> materialIds = materials.stream()
.map(Material::getId)
.collect(Collectors.toList());

List<WarehouseStock> stocks = warehouseStockRepository
.findByWarehouseIdAndMaterialIdIn(warehouse.getId(), materialIds);
Map<Integer, WarehouseStock> 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) {
Expand Down Expand Up @@ -203,10 +226,30 @@ public GICompleteResponseDTO completeGoodsIssue(String giCode) {
throw new BusinessException(ErrorCode.INVALID_GOODS_ISSUE_STATUS);
}

// 2. 출고지시 품목 조회
List<GoodsIssueItem> goodsIssueItems = goodsIssueItemRepository.findByGoodsIssueId(goodsIssue.getId());
// 2. 출고지시 품목 조회 (Fetch Join으로 SalesOrderItem 함께 로딩)
List<GoodsIssueItem> goodsIssueItems = goodsIssueItemRepository.findByGoodsIssueIdWithSalesOrderItem(goodsIssue.getId());

// 3. 자재 및 재고 일괄 조회 (N+1 최적화)
// 3-1. 품목 코드 목록 추출 후 자재 일괄 조회
List<String> itemCodes = goodsIssueItems.stream()
.map(giItem -> giItem.getSalesOrderItem().getItemCode())
.collect(Collectors.toList());

// 3. 실제 재고 차감 및 이력 기록
List<Material> materials = materialRepository.findByMaterialCodeIn(itemCodes);
Map<String, Material> materialMap = materials.stream()
.collect(Collectors.toMap(Material::getMaterialCode, Function.identity()));

// 3-2. 자재 ID 목록으로 재고 일괄 조회
List<Integer> materialIds = materials.stream()
.map(Material::getId)
.collect(Collectors.toList());

List<WarehouseStock> stocks = warehouseStockRepository
.findByWarehouseIdAndMaterialIdIn(goodsIssue.getWarehouse().getId(), materialIds);
Map<Integer, WarehouseStock> 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<WarehouseStock> stocksToUpdate = new ArrayList<>();
List<WarehouseStockHistory> historiesToSave = new ArrayList<>();
Expand All @@ -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);
Expand Down Expand Up @@ -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())
Expand All @@ -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())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<DeliveryOrderItem, Integer> {
List<DeliveryOrderItem> 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<DeliveryOrderItem> findByDeliveryOrderIdWithSalesOrderItem(
@Param("deliveryOrderId") int deliveryOrderId
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,15 @@ public interface GoodsIssueItemRepository extends JpaRepository<GoodsIssueItem,
*/
@Query("SELECT gii FROM GoodsIssueItem gii WHERE gii.goodsIssue.salesOrder.id = :salesOrderId")
List<GoodsIssueItem> 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<GoodsIssueItem> findByGoodsIssueIdWithSalesOrderItem(
@Param("goodsIssueId") int goodsIssueId
);
}
Loading