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
4,211 changes: 2,466 additions & 1,745 deletions keycloak/shipflow-export.json

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions order-service/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-amqp'

// 6. OpenFeign + Retry
implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
implementation 'org.springframework.retry:spring-retry'
implementation 'org.springframework.boot:spring-boot-starter-aop'

// 5. Redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.retry.annotation.EnableRetry;

@SpringBootApplication(scanBasePackages = "com.shipflow")
@EnableFeignClients(basePackages = "com.shipflow.orderservice.infrastructure.client")
@EnableRetry
public class OrderserviceApplication {

public static void main(String[] args) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,18 @@

public record CreateOrderCommand(
UUID ordererId,
String ordererName,
UUID productId,
String productName,
UUID supplierCompanyId,
String supplierCompanyName,
UUID receiverCompanyId,
String receiverCompanyName,
UUID departureHubId,
UUID arrivalHubId,
int quantity,
LocalDateTime requestDeadline,
String requestNote
String requestNote,
String deliveryAddress
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public record OrderResult(
String cancelReason,
LocalDateTime requestDeadline,
String requestNote,
String deliveryAddress,
UUID createdBy,
LocalDateTime createdAt,
UUID updatedBy,
Expand All @@ -40,6 +41,7 @@ public static OrderResult from(Order order) {
order.getCancelReason(),
order.getRequestDeadline(),
order.getRequestNote(),
order.getDeliveryAddress(),
order.getCreatedBy(),
order.getCreatedAt(),
order.getUpdatedBy(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.shipflow.orderservice.application.dto;

import com.shipflow.orderservice.domain.model.OrderStatus;

import java.time.LocalDateTime;
import java.util.UUID;

public record OrderSearchCondition(
OrderStatus status,
UUID ordererId,
UUID productId,
UUID supplierCompanyId,
UUID receiverCompanyId,
LocalDateTime createdFrom,
LocalDateTime createdTo
) {
public OrderSearchCondition withOrdererId(UUID forcedOrdererId) {
return new OrderSearchCondition(status, forcedOrdererId, productId,
supplierCompanyId, receiverCompanyId, createdFrom, createdTo);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.shipflow.orderservice.application.dto.CreateOrderCommand;
import com.shipflow.orderservice.application.dto.OrderResult;
import com.shipflow.orderservice.application.dto.UpdateOrderCommand;
import com.shipflow.orderservice.presentation.dto.CreateOrderRequest;
import com.shipflow.common.messaging.publisher.EventPublisher;
import com.shipflow.orderservice.domain.event.*;
import com.shipflow.orderservice.domain.model.ShipmentStatus;
Expand All @@ -29,8 +30,14 @@ public class OrderCommandService {
private final OrderRepository orderRepository;
private final EventPublisher rabbitPublisher;
private final ApplicationEventPublisher domainEventPublisher;
private final OrderFetchService orderFetchService;

public OrderResult createOrder(CreateOrderRequest request, UUID ordererId) {
CreateOrderCommand cmd = orderFetchService.fetchAndBuild(
ordererId, request.productId(), request.quantity(),
request.requestDeadline(), request.requestNote()
);
Comment on lines +35 to +39

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In Spring, does class-level @transactional wrap the entire public method body, including Feign/HTTP calls made before the repository save, and are external network calls inside a transaction considered bad practice?

💡 Result:

Yes—if the call enters through the Spring transactional proxy, a class-level @Transactional applies to each eligible method (typically public methods) and the transaction advice runs around the entire method invocation (begin before the method body executes, commit/rollback after it returns/throws). In Spring’s default proxy mode, only external calls through the proxy are intercepted (self-invocation won’t trigger the interceptor), but once you’re inside the proxied method, everything you do in that method (including Feign/HTTP calls made before repository.save(...)) executes while the transaction is active. [1]

That said, the Feign/HTTP call is not part of the database transaction (it can’t be atomically committed/rolled back with your DB work), and Spring explicitly notes it does not propagate transaction contexts across remote calls. [2]

Putting external network calls inside a DB transaction is generally considered bad practice because it can make transactions long-running (holding connections/locks longer, increasing contention/timeouts/deadlock risk). Common guidance is to keep transactions short and avoid I/O/remote calls inside them. [3]

Sources:
[1] Spring Framework Reference (Transaction Management) – proxy mode / external calls intercepted
[2] Spring Framework Data Access reference – no transaction context propagation across remote calls
[3] Best-practices guidance (keep transactions short; avoid I/O/remote calls inside transactions)


🏁 Script executed:

find . -type f -name "OrderCommandService.java" | head -5

Repository: Ship-Flow/ship-flow

Length of output: 163


🏁 Script executed:

cat -n ./order-service/src/main/java/com/shipflow/orderservice/application/service/OrderCommandService.java | head -60

Repository: Ship-Flow/ship-flow

Length of output: 3318


🏁 Script executed:

grep -n "class OrderFetchService" ./order-service/src/main/java/com/shipflow/orderservice/application/service/OrderFetchService.java | head -5

Repository: Ship-Flow/ship-flow

Length of output: 98


🏁 Script executed:

cat -n ./order-service/src/main/java/com/shipflow/orderservice/application/service/OrderFetchService.java | head -100

Repository: Ship-Flow/ship-flow

Length of output: 2809


외부 조회를 트랜잭션 범위 밖으로 이동하세요.

클래스 레벨 @Transactional으로 인해 createOrder() 메서드 전체가 하나의 데이터베이스 트랜잭션으로 실행됩니다. 현재 코드는 orderFetchService.fetchAndBuild() 메서드에서 Product, User, Company 서비스로의 Feign 호출 3회를 모두 트랜잭션 내에서 수행한 후 저장하므로, 네트워크 대기 시간이 트랜잭션을 장시간 점유합니다. 이로 인해 데이터베이스 연결이 오래 유지되어 잠금 경합, 타임아웃, 데드락 위험이 증가합니다.

외부 조회는 트랜잭션 밖에서 완료하고, 데이터베이스 저장과 이벤트 발행만 별도 트랜잭션으로 감싸도록 구조를 개선하세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@order-service/src/main/java/com/shipflow/orderservice/application/service/OrderCommandService.java`
around lines 35 - 39, 현재 클래스 레벨 `@Transactional` 때문에 createOrder() 전체가 트랜잭션 안에서
실행되어 orderFetchService.fetchAndBuild(...)의 외부 Feign 호출들이 트랜잭션을 장시간 점유하고 있습니다; 수정
방법은 외부 조회(orderFetchService.fetchAndBuild)를 트랜잭션 밖으로 이동시켜 먼저
Product/User/Company를 조회하고 CreateOrderCommand를 구성한 뒤, 실제 DB 저장과 이벤트 발행만 별도의 짧은
트랜잭션(예: 메서드 레벨 `@Transactional` 또는 TransactionTemplate을 사용하는 saveAndPublish 같은 새로운
메서드)으로 감싸서 처리하도록 변경하세요; 관련 심볼: createOrder, orderFetchService.fetchAndBuild, 클래스
레벨 `@Transactional`, 저장/이벤트 발행 로직(예: saveOrder, publishEvents)을 찾아 분리 구현하세요.


public OrderResult createOrder(CreateOrderCommand cmd, UUID requesterId) {
Order order = Order.create(
cmd.ordererId(),
cmd.productId(),
Expand All @@ -39,14 +46,16 @@ public OrderResult createOrder(CreateOrderCommand cmd, UUID requesterId) {
new Quantity(cmd.quantity()),
cmd.requestDeadline(),
cmd.requestNote(),
requesterId
cmd.deliveryAddress(),
ordererId
);
Order saved = orderRepository.save(order);

domainEventPublisher.publishEvent(new OrderCreatingEvent(
saved.getId(), saved.getOrdererId(), saved.getProductId(),
saved.getCompanyInfo().getSupplierCompanyId(),
saved.getCompanyInfo().getReceiverCompanyId(),
saved.getId(), saved.getOrdererId(), cmd.ordererName(),
saved.getProductId(), cmd.productName(),
saved.getCompanyInfo().getSupplierCompanyId(), cmd.supplierCompanyName(),
saved.getCompanyInfo().getReceiverCompanyId(), cmd.receiverCompanyName(),
saved.getHubInfo().getDepartureHubId(),
saved.getHubInfo().getArrivalHubId(),
saved.getQuantity().getValue(),
Expand All @@ -71,13 +80,16 @@ public void confirmCreation(UUID orderId, String productName) {
rabbitPublisher.publish(
new OrderCreatedEvent(
saved.getId(),
saved.getOrdererId(),
saved.getCompanyInfo().getSupplierCompanyId(),
saved.getCompanyInfo().getReceiverCompanyId(),
saved.getProductId(),
saved.getQuantity().getValue(),
saved.getHubInfo().getDepartureHubId(),
saved.getHubInfo().getArrivalHubId(),
saved.getRequestDeadline()
saved.getRequestDeadline(),
saved.getRequestNote(),
saved.getDeliveryAddress()
)
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package com.shipflow.orderservice.application.service;

import com.shipflow.orderservice.application.dto.CreateOrderCommand;
import com.shipflow.orderservice.infrastructure.client.adapter.CompanyClientAdapter;
import com.shipflow.orderservice.infrastructure.client.adapter.ProductClientAdapter;
import com.shipflow.orderservice.infrastructure.client.adapter.UserClientAdapter;
import com.shipflow.orderservice.infrastructure.client.dto.ProductInfo;
import com.shipflow.orderservice.infrastructure.client.dto.ReceiverCompanyInfo;
import com.shipflow.orderservice.infrastructure.client.dto.UserInfo;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;

@Service
@RequiredArgsConstructor
public class OrderFetchService {

private final ProductClientAdapter productAdapter;
private final UserClientAdapter userAdapter;
private final CompanyClientAdapter companyAdapter;

public CreateOrderCommand fetchAndBuild(UUID ordererId, UUID productId,
int quantity, LocalDateTime deadline, String note) {
// Step 1: product, user 병렬 호출
CompletableFuture<ProductInfo> productFuture = CompletableFuture.supplyAsync(
() -> productAdapter.fetch(ordererId.toString(), productId, quantity));
CompletableFuture<UserInfo> userFuture = CompletableFuture.supplyAsync(
() -> userAdapter.fetch(ordererId));

try {
CompletableFuture.allOf(productFuture, userFuture).join();
} catch (CompletionException e) {
Throwable cause = e.getCause();
if (cause instanceof RuntimeException re) {
throw re;
}
if (cause instanceof Error err) {
throw err;
}
throw e;
}

ProductInfo product = productFuture.join();
UserInfo user = userFuture.join();
Comment thread
kim-jun-won marked this conversation as resolved.

// Step 2: receiverCompanyId 확보 후 company 호출
ReceiverCompanyInfo company = companyAdapter.fetch(user.receiverCompanyId());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

user.receiverCompanyId()가 null일 경우에 대한 방어 로직이 없습니다.

UserInfo.receiverCompanyId()가 외부 User 서비스에서 null로 반환될 경우, companyAdapter.fetch()에 null이 전달되어 NPE 또는 잘못된 HTTP 요청이 발생할 수 있습니다.

🛡️ null 검증 추가 제안
         ProductInfo product = productFuture.join();
         UserInfo user = userFuture.join();

         // Step 2: receiverCompanyId 확보 후 company 호출
+        UUID receiverCompanyId = user.receiverCompanyId();
+        if (receiverCompanyId == null) {
+            throw new IllegalStateException("User의 receiverCompanyId가 설정되지 않았습니다: " + ordererId);
+        }
-        ReceiverCompanyInfo company = companyAdapter.fetch(user.receiverCompanyId());
+        ReceiverCompanyInfo company = companyAdapter.fetch(receiverCompanyId);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@order-service/src/main/java/com/shipflow/orderservice/application/service/OrderFetchService.java`
at line 51, OrderFetchService calls
companyAdapter.fetch(user.receiverCompanyId()) without guarding against a null
receiverCompanyId; add a null-check on UserInfo.receiverCompanyId() before
calling companyAdapter.fetch (in the method that performs the fetch in
OrderFetchService), and handle the null case by either throwing a clear domain
exception (e.g., InvalidRequestException / MissingReceiverCompanyId) or
returning a safe default/result and logging the situation; ensure the check
references UserInfo.receiverCompanyId() and companyAdapter.fetch(...) so the fix
is easy to locate and unit-test.


return new CreateOrderCommand(
ordererId,
user.ordererName(),
productId,
product.productName(),
product.supplierCompanyId(),
product.supplierCompanyName(),
user.receiverCompanyId(),
company.companyName(),
product.departureHubId(),
company.hubId(),
quantity,
deadline,
note,
company.address()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,13 @@ public void on(OrderCreatingEvent e) {
.orderId(e.orderId())
.orderStatus(OrderStatus.CREATING)
.ordererId(e.ordererId())
.ordererName(e.ordererName())
.productId(e.productId())
.productName(e.productName())
.supplierCompanyId(e.supplierCompanyId())
.supplierCompanyName(e.supplierCompanyName())
.receiverCompanyId(e.receiverCompanyId())
.receiverCompanyName(e.receiverCompanyName())
Comment thread
kim-jun-won marked this conversation as resolved.
.departureHubId(e.departureHubId())
.arrivalHubId(e.arrivalHubId())
.quantity(e.quantity())
Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,32 @@
package com.shipflow.orderservice.application.service;

import com.shipflow.orderservice.application.dto.OrderResult;
import com.shipflow.orderservice.application.dto.OrderSearchCondition;
import com.shipflow.orderservice.domain.exception.OrderNotFoundException;
import com.shipflow.orderservice.domain.exception.UnauthorizedException;
import com.shipflow.orderservice.domain.model.Order;
import com.shipflow.orderservice.domain.model.OrderReadModel;
import com.shipflow.orderservice.domain.model.UserRole;
import com.shipflow.orderservice.domain.repository.OrderReadModelRepository;
import com.shipflow.orderservice.domain.repository.OrderRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.Set;
import java.util.UUID;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class OrderQueryService {

private static final Set<Integer> ALLOWED_PAGE_SIZES = Set.of(10, 30, 50);
private static final Sort DEFAULT_SORT = Sort.by(Sort.Direction.DESC, "createdAt");
private static final Set<String> ALLOWED_SORT_FIELDS = Set.of("createdAt", "updatedAt");

private final OrderRepository orderRepository;
private final OrderReadModelRepository orderReadModelRepository;

Expand All @@ -31,6 +40,14 @@ public OrderResult getOrder(UUID orderId) {
return OrderResult.from(order);
}

public OrderResult getOrder(UUID orderId, UUID requesterId, UserRole role) {
OrderResult result = getOrder(orderId);
if (role.isRestrictedToOwnOrders() && !result.ordererId().equals(requesterId)) {
throw new UnauthorizedException();
}
Comment thread
kim-jun-won marked this conversation as resolved.
return result;
}

/**
* 전체 주문 목록을 조회합니다.
* 데이터 양이 적거나, 시스템 내부의 배치 작업/동기화 작업 시 사용합니다.
Expand All @@ -49,4 +66,23 @@ public OrderReadModel getReadModel(UUID orderId) {
return orderReadModelRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
}

/**
* 검색 조건, 정렬, 페이지네이션을 적용하여 주문 목록을 조회합니다.
* 허용된 페이지 크기(10·30·50) 이외의 값은 10으로 정규화되며,
* 허용된 정렬 필드(createdAt·updatedAt) 이외의 값은 createdAt DESC로 폴백됩니다.
*/
public Slice<OrderReadModel> searchOrders(OrderSearchCondition condition, Pageable pageable) {
int pageSize = ALLOWED_PAGE_SIZES.contains(pageable.getPageSize())
? pageable.getPageSize()
: 10;

Sort sort = pageable.getSort().isSorted()
&& pageable.getSort().stream().allMatch(o -> ALLOWED_SORT_FIELDS.contains(o.getProperty()))
? pageable.getSort()
: DEFAULT_SORT;

Pageable normalized = PageRequest.of(pageable.getPageNumber(), pageSize, sort);
return orderReadModelRepository.search(condition, normalized);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,13 @@
public record OrderCreatingEvent(
UUID orderId,
UUID ordererId,
String ordererName,
UUID productId,
String productName,
UUID supplierCompanyId,
String supplierCompanyName,
UUID receiverCompanyId,
String receiverCompanyName,
UUID departureHubId,
UUID arrivalHubId,
int quantity,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.shipflow.orderservice.domain.exception;

import com.shipflow.common.exception.BusinessException;

public class CompanyNotFoundException extends BusinessException {

public CompanyNotFoundException() {
super(OrderErrorCode.COMPANY_NOT_FOUND);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.shipflow.orderservice.domain.exception;

import com.shipflow.common.exception.BusinessException;

public class ExternalServiceException extends BusinessException {

public ExternalServiceException(Throwable cause) {
super(OrderErrorCode.EXTERNAL_SERVICE_ERROR, cause);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.shipflow.orderservice.domain.exception;

import com.shipflow.common.exception.BusinessException;

public class InsufficientStockException extends BusinessException {

public InsufficientStockException() {
super(OrderErrorCode.PRODUCT_INSUFFICIENT_STOCK);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,14 @@

public enum OrderErrorCode implements ErrorCode {

UNAUTHORIZED("UNAUTHORIZED", HttpStatus.FORBIDDEN, "접근 권한이 없습니다."),
ORDER_NOT_FOUND("ORDER_NOT_FOUND", HttpStatus.NOT_FOUND, "주문을 찾을 수 없습니다."),
INVALID_ORDER_STATE("INVALID_ORDER_STATE", HttpStatus.CONFLICT, "유효하지 않은 주문 상태입니다.");
INVALID_ORDER_STATE("INVALID_ORDER_STATE", HttpStatus.CONFLICT, "유효하지 않은 주문 상태입니다."),
PRODUCT_NOT_FOUND("PRODUCT_NOT_FOUND", HttpStatus.NOT_FOUND, "상품을 찾을 수 없습니다."),
PRODUCT_INSUFFICIENT_STOCK("PRODUCT_INSUFFICIENT_STOCK", HttpStatus.CONFLICT, "재고가 부족합니다."),
USER_NOT_FOUND("USER_NOT_FOUND", HttpStatus.NOT_FOUND, "사용자를 찾을 수 없습니다."),
COMPANY_NOT_FOUND("COMPANY_NOT_FOUND", HttpStatus.NOT_FOUND, "업체를 찾을 수 없습니다."),
EXTERNAL_SERVICE_ERROR("EXTERNAL_SERVICE_ERROR", HttpStatus.SERVICE_UNAVAILABLE, "외부 서비스 오류가 발생했습니다.");

private final String code;
private final HttpStatus status;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.shipflow.orderservice.domain.exception;

import com.shipflow.common.exception.BusinessException;

public class ProductNotFoundException extends BusinessException {

public ProductNotFoundException() {
super(OrderErrorCode.PRODUCT_NOT_FOUND);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.shipflow.orderservice.domain.exception;

import com.shipflow.common.exception.BusinessException;

public class UnauthorizedException extends BusinessException {

public UnauthorizedException() {
super(OrderErrorCode.UNAUTHORIZED);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.shipflow.orderservice.domain.exception;

import com.shipflow.common.exception.BusinessException;

public class UserNotFoundException extends BusinessException {

public UserNotFoundException() {
super(OrderErrorCode.USER_NOT_FOUND);
}
}
Loading
Loading