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
8 changes: 7 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,15 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'

compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
annotationProcessor 'org.projectlombok:lombok'

developmentOnly 'org.springframework.boot:spring-boot-devtools'

runtimeOnly 'org.postgresql:postgresql'
runtimeOnly 'com.h2database:h2'

testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
Expand Down
13 changes: 13 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
services:
db:
image: postgres:16
environment:
POSTGRES_DB: trademill
POSTGRES_USER: tm
POSTGRES_PASSWORD: tm
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata: {}
12 changes: 12 additions & 0 deletions src/main/java/trademill/apiserver/broker/BrokerGateway.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package trademill.apiserver.broker;

import java.math.BigDecimal;


// BrokerGateway : '증권사와 어떻게 대화할지' 계약서(포트) 역할
public interface BrokerGateway {
String placeMarketBuy(String symbol, BigDecimal quantity);
String placeMarketSell(String symbol, BigDecimal quantity);
String placeLimitBuy(String symbol, BigDecimal quantity, BigDecimal price);
String placeLimitSell(String symbol, BigDecimal quantity, BigDecimal price);
}
20 changes: 20 additions & 0 deletions src/main/java/trademill/apiserver/broker/FakeBrokerGateway.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package trademill.apiserver.broker;

import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;

import java.math.BigDecimal;
import java.util.UUID;

@Profile({"local","dev","default"})
@Component

// FakeBrokerGateway : 실제 증권사 호출 대신 로컬 개발/테스트용으로 응답을 꾸며주는 구현체(어댑터)
public class FakeBrokerGateway implements BrokerGateway {
private String id(){ return "SIM-" + UUID.randomUUID(); }

public String placeMarketBuy(String s, BigDecimal q){ return id(); }
public String placeMarketSell(String s, BigDecimal q){ return id(); }
public String placeLimitBuy(String s, BigDecimal q, BigDecimal p){ return id(); }
public String placeLimitSell(String s, BigDecimal q, BigDecimal p){ return id(); }
}
42 changes: 42 additions & 0 deletions src/main/java/trademill/apiserver/order/Order.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package trademill.apiserver.order;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import java.math.BigDecimal;
import java.time.OffsetDateTime;

@Entity
@Table(name = "orders")
@Getter @Setter @NoArgsConstructor
public class Order {

@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

private Long userId;
private String symbol;

@Enumerated(EnumType.STRING) private OrderSide side;
@Enumerated(EnumType.STRING) private OrderType orderType;

private BigDecimal quantity;
private BigDecimal price; // MARKET이면 null 가능

@Enumerated(EnumType.STRING) private OrderStatus status;
private String brokerOrderId;

private OffsetDateTime createdAt;
private OffsetDateTime updatedAt;

Comment on lines +11 to +33
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

엔티티 제약 조건(Nullable/Precision/Index/Version) 보강으로 데이터 무결성과 조회 성능 개선

  • 비null 필드에 @column(nullable=false) 누락.
  • 수량/가격 BigDecimal precision/scale 미지정 → 금액/수량 반올림/절삭 이슈 가능.
  • 조회 패턴상 userId, createdAt 조합 인덱스, brokerOrderId 유니크 제약 권장.
  • 낙관적 락(@Version) 추가로 동시 수정 방지.

적용 diff:

-@Table(name = "orders")
+@Table(name = "orders", indexes = {
+        @Index(name = "idx_orders_user_created", columnList = "userId,createdAt"),
+        @Index(name = "uk_orders_broker_order_id", columnList = "brokerOrderId", unique = true)
+})
 @Getter @Setter @NoArgsConstructor
 public class Order {
@@
-    private Long userId;
-    private String symbol;
+    @Column(nullable = false)
+    private Long userId;
+    @Column(length = 20, nullable = false)
+    private String symbol;
@@
-    @Enumerated(EnumType.STRING) private OrderSide side;
-    @Enumerated(EnumType.STRING) private OrderType orderType;
+    @Enumerated(EnumType.STRING) @Column(nullable = false)
+    private OrderSide side;
+    @Enumerated(EnumType.STRING) @Column(nullable = false)
+    private OrderType orderType;
@@
-    private BigDecimal quantity;
-    private BigDecimal price; // MARKET이면 null 가능
+    @Column(precision = 19, scale = 4, nullable = false)
+    private BigDecimal quantity;
+    @Column(precision = 19, scale = 4) // MARKET이면 null 가능
+    private BigDecimal price;
@@
-    @Enumerated(EnumType.STRING) private OrderStatus status;
-    private String brokerOrderId;
+    @Enumerated(EnumType.STRING) @Column(nullable = false)
+    private OrderStatus status;
+    @Column(length = 64)
+    private String brokerOrderId;
@@
-    private OffsetDateTime createdAt;
-    private OffsetDateTime updatedAt;
+    @Column(nullable = false, updatable = false)
+    private OffsetDateTime createdAt;
+    @Column(nullable = false)
+    private OffsetDateTime updatedAt;
+
+    @Version
+    private Long version;

추가 import 필요:

import java.time.ZoneOffset;
🤖 Prompt for AI Agents
In src/main/java/trademill/apiserver/order/Order.java around lines 11-33,
several JPA constraints and indexes are missing: mark non-nullable fields with
@Column(nullable = false) (at least userId, symbol, side, orderType, quantity,
status, createdAt), specify BigDecimal precision/scale for quantity and price
via @Column(precision=18, scale=8) (adjust numbers to your domain), add
table-level indexes/constraints with @Table(indexes =
{@Index(name="idx_user_created", columnList="userId,createdAt")},
uniqueConstraints = {@UniqueConstraint(columnNames = "brokerOrderId")}), and add
an optimistic lock field annotated @Version (e.g., private Long version) plus
the required imports; update getters/setters/constructors accordingly.

@PrePersist
void onCreate() {
createdAt = OffsetDateTime.now();
updatedAt = createdAt;
if (status == null) status = OrderStatus.PENDING;
}
@PreUpdate
void onUpdate() { updatedAt = OffsetDateTime.now(); }
}
33 changes: 33 additions & 0 deletions src/main/java/trademill/apiserver/order/OrderController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package trademill.apiserver.order;

import jakarta.validation.Valid;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.web.bind.annotation.*;
import trademill.apiserver.order.dto.*;

@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {
private final OrderService service;
public OrderController(OrderService service){ this.service = service; }

@PostMapping("/buy")
public OrderResponse buy(@Valid @RequestBody PlaceOrderRequest req){
return OrderResponse.from(service.placeBuy(req));
}

@PostMapping("/sell")
public OrderResponse sell(@Valid @RequestBody PlaceOrderRequest req){
return OrderResponse.from(service.placeSell(req));
}

@GetMapping
public Page<OrderResponse> list(@RequestParam Long userId,
@RequestParam(defaultValue="0") int page,
@RequestParam(defaultValue="20") int size){
return service.list(userId, PageRequest.of(page, size, Sort.by("id").descending()))
.map(OrderResponse::from);
Comment on lines +26 to +31
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

페이지 파라미터 검증 추가 + 정렬 컬럼을 생성시각으로 변경 권장

  • page/size에 대한 최소/최대값 검증이 없어 오남용(대용량 조회) 가능성이 있습니다.
  • 정렬 컬럼으로 id 대신 createdAt 사용을 권장합니다. 실제 시간 역순이 의도에 더 부합하며, RDBMS별 ID 시퀀싱 정책/샤딩에 덜 의존적입니다.

적용 diff:

-    public Page<OrderResponse> list(@RequestParam Long userId,
-                                    @RequestParam(defaultValue="0") int page,
-                                    @RequestParam(defaultValue="20") int size){
-        return service.list(userId, PageRequest.of(page, size, Sort.by("id").descending()))
+    public Page<OrderResponse> list(@RequestParam @NotNull Long userId,
+                                    @RequestParam(defaultValue="0") @PositiveOrZero int page,
+                                    @RequestParam(defaultValue="20") @Positive @Max(200) int size){
+        return service.list(userId, PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")))
                 .map(OrderResponse::from);

추가 필요(파일 상단 import 및 클래스 애노테이션):

// import
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;

// 클래스 상단(예: @RestController 아래 줄)
@Validated
🤖 Prompt for AI Agents
In src/main/java/trademill/apiserver/order/OrderController.java around lines
26-31, the controller method lacks validation for page/size and uses id for
sorting; add parameter validation annotations (@NotNull for userId,
@PositiveOrZero for page, @Positive and @Max(100) for size) on the method
signature, change the PageRequest sort from Sort.by("id").descending() to
Sort.by("createdAt").descending(), and add the required imports
(jakarta.validation.constraints.*) plus the @Validated annotation on the
controller class to enable method-level validation. Ensure the defaultValue
attributes remain but use the annotations to enforce limits and return
appropriate validation responses from Spring.

}
}
8 changes: 8 additions & 0 deletions src/main/java/trademill/apiserver/order/OrderRepository.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package trademill.apiserver.order;

import org.springframework.data.domain.*;
import org.springframework.data.jpa.repository.JpaRepository;

public interface OrderRepository extends JpaRepository<Order, Long> {
Page<Order> findByUserId(Long userId, Pageable pageable);
}
56 changes: 56 additions & 0 deletions src/main/java/trademill/apiserver/order/OrderService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package trademill.apiserver.order;

import org.springframework.data.domain.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import trademill.apiserver.broker.BrokerGateway;
import trademill.apiserver.order.dto.PlaceOrderRequest;

@Service
public class OrderService {
private final OrderRepository orders;
private final BrokerGateway broker;

public OrderService(OrderRepository orders, BrokerGateway broker) {
this.orders = orders;
this.broker = broker;
}

@Transactional
public Order placeBuy(PlaceOrderRequest req){
Order o = baseOf(req, OrderSide.BUY);
String id = switch (req.getOrderType()){
case MARKET -> broker.placeMarketBuy(req.getSymbol(), req.getQuantity());
case LIMIT -> broker.placeLimitBuy(req.getSymbol(), req.getQuantity(), req.getPrice());
};
o.setBrokerOrderId(id); o.setStatus(OrderStatus.ACCEPTED);
return orders.save(o);
}
Comment on lines +19 to +28
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

외부 브로커 호출을 트랜잭션 내부에서 수행 — 롱 락/롤백 전파로 인한 리스크

외부 I/O(브로커 호출)를 @transactional 경계 안에서 처리하면 연결 지연/예외 시 DB 트랜잭션이 장시간 유지되며 롤백 시 전체 작업이 무효화됩니다. 최소한 예외를 포착하여 주문 레코드가 남도록 처리하고, 가능하면 외부 호출은 트랜잭션 밖에서 수행하거나 2단계로 분리(REQUIRES_NEW)하세요.

안전망 추가를 위한 최소 변경 diff:

 @Transactional
 public Order placeBuy(PlaceOrderRequest req){
     Order o = baseOf(req, OrderSide.BUY);
-    String id = switch (req.getOrderType()){
-        case MARKET -> broker.placeMarketBuy(req.getSymbol(), req.getQuantity());
-        case LIMIT  -> broker.placeLimitBuy(req.getSymbol(), req.getQuantity(), req.getPrice());
-    };
-    o.setBrokerOrderId(id); o.setStatus(OrderStatus.ACCEPTED);
-    return orders.save(o);
+    try {
+        String id = switch (req.getOrderType()){
+            case MARKET -> broker.placeMarketBuy(req.getSymbol(), req.getQuantity());
+            case LIMIT  -> broker.placeLimitBuy(req.getSymbol(), req.getQuantity(), req.getPrice());
+        };
+        o.setBrokerOrderId(id);
+        o.setStatus(OrderStatus.ACCEPTED);
+    } catch (Exception e) {
+        // TODO: 실패 상태 도입 시 교체(e.g. FAILED/REJECTED)
+        o.setStatus(OrderStatus.PENDING);
+        // TODO: 로깅 추가
+    }
+    return orders.save(o);
 }

권장 구조(개요):

  • Tx1: 주문 PENDING 생성/저장
  • Tx 바깥: 브로커 호출
  • Tx2(REQUIRES_NEW): brokerOrderId/상태 업데이트
  • 실패 시 재시도/보상 로직 또는 outbox/event 기반 비동기화
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Transactional
public Order placeBuy(PlaceOrderRequest req){
Order o = baseOf(req, OrderSide.BUY);
String id = switch (req.getOrderType()){
case MARKET -> broker.placeMarketBuy(req.getSymbol(), req.getQuantity());
case LIMIT -> broker.placeLimitBuy(req.getSymbol(), req.getQuantity(), req.getPrice());
};
o.setBrokerOrderId(id); o.setStatus(OrderStatus.ACCEPTED);
return orders.save(o);
}
@Transactional
public Order placeBuy(PlaceOrderRequest req){
Order o = baseOf(req, OrderSide.BUY);
try {
String id = switch (req.getOrderType()) {
case MARKET -> broker.placeMarketBuy(req.getSymbol(), req.getQuantity());
case LIMIT -> broker.placeLimitBuy(req.getSymbol(), req.getQuantity(), req.getPrice());
};
o.setBrokerOrderId(id);
o.setStatus(OrderStatus.ACCEPTED);
} catch (Exception e) {
// TODO: 실패 상태 도입 시 교체(e.g. FAILED/REJECTED)
o.setStatus(OrderStatus.PENDING);
// TODO: 로깅 추가
}
return orders.save(o);
}


@Transactional
public Order placeSell(PlaceOrderRequest req){
Order o = baseOf(req, OrderSide.SELL);
String id = switch (req.getOrderType()){
case MARKET -> broker.placeMarketSell(req.getSymbol(), req.getQuantity());
case LIMIT -> broker.placeLimitSell(req.getSymbol(), req.getQuantity(), req.getPrice());
};
o.setBrokerOrderId(id); o.setStatus(OrderStatus.ACCEPTED);
return orders.save(o);
}
Comment on lines +30 to +39
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

매도 로직도 동일한 예외 처리 및 트랜잭션 리스크

위 매수와 동일하게 try/catch 및 저장 경로 보장 필요.

적용 diff:

 @Transactional
 public Order placeSell(PlaceOrderRequest req){
     Order o = baseOf(req, OrderSide.SELL);
-    String id = switch (req.getOrderType()){
-        case MARKET -> broker.placeMarketSell(req.getSymbol(), req.getQuantity());
-        case LIMIT  -> broker.placeLimitSell(req.getSymbol(), req.getQuantity(), req.getPrice());
-    };
-    o.setBrokerOrderId(id); o.setStatus(OrderStatus.ACCEPTED);
-    return orders.save(o);
+    try {
+        String id = switch (req.getOrderType()){
+            case MARKET -> broker.placeMarketSell(req.getSymbol(), req.getQuantity());
+            case LIMIT  -> broker.placeLimitSell(req.getSymbol(), req.getQuantity(), req.getPrice());
+        };
+        o.setBrokerOrderId(id);
+        o.setStatus(OrderStatus.ACCEPTED);
+    } catch (Exception e) {
+        // TODO: 실패 상태 도입 시 교체(e.g. FAILED/REJECTED)
+        o.setStatus(OrderStatus.PENDING);
+        // TODO: 로깅 추가
+    }
+    return orders.save(o);
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Transactional
public Order placeSell(PlaceOrderRequest req){
Order o = baseOf(req, OrderSide.SELL);
String id = switch (req.getOrderType()){
case MARKET -> broker.placeMarketSell(req.getSymbol(), req.getQuantity());
case LIMIT -> broker.placeLimitSell(req.getSymbol(), req.getQuantity(), req.getPrice());
};
o.setBrokerOrderId(id); o.setStatus(OrderStatus.ACCEPTED);
return orders.save(o);
}
@Transactional
public Order placeSell(PlaceOrderRequest req){
Order o = baseOf(req, OrderSide.SELL);
try {
String id = switch (req.getOrderType()){
case MARKET -> broker.placeMarketSell(req.getSymbol(), req.getQuantity());
case LIMIT -> broker.placeLimitSell(req.getSymbol(), req.getQuantity(), req.getPrice());
};
o.setBrokerOrderId(id);
o.setStatus(OrderStatus.ACCEPTED);
} catch (Exception e) {
// TODO: 실패 상태 도입 시 교체(e.g. FAILED/REJECTED)
o.setStatus(OrderStatus.PENDING);
// TODO: 로깅 추가
}
return orders.save(o);
}
🤖 Prompt for AI Agents
In src/main/java/trademill/apiserver/order/OrderService.java around lines 30 to
39, the sell flow lacks the same try/catch and guaranteed persistence path as
the buy flow, so wrap the broker calls and subsequent state updates in a
try/catch that mirrors the buy logic: create and persist the initial Order
before external broker call if your buy flow does that, call broker inside try,
on success set brokerOrderId and ACCEPTED and save, on broker exception set
order status to REJECTED (or appropriate failure state), record the exception
details, save the order in the catch to guarantee persistence, and rethrow or
handle transaction boundaries as the buy path does to avoid leaving the system
in an inconsistent state.


public Page<Order> list(Long userId, Pageable pageable){
return orders.findByUserId(userId, pageable);
}

private Order baseOf(PlaceOrderRequest req, OrderSide side){
Order o = new Order();
o.setUserId(req.getUserId());
o.setSymbol(req.getSymbol());
o.setSide(side);
o.setOrderType(req.getOrderType());
o.setQuantity(req.getQuantity());
o.setPrice(req.getPrice());
o.setStatus(OrderStatus.PENDING);
return o;
}
Comment on lines +45 to +55
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

비즈니스 규칙 검증(수량/가격) 강화 — LIMIT/​MARKET별 가격 유효성

서비스 레벨에서 최소 검증을 추가하여 잘못된 입력이 브로커까지 전달되지 않도록 하세요. 특히 LIMIT는 price 필수/양수, MARKET은 price 무시(null).

적용 diff:

 private Order baseOf(PlaceOrderRequest req, OrderSide side){
     Order o = new Order();
     o.setUserId(req.getUserId());
     o.setSymbol(req.getSymbol());
     o.setSide(side);
     o.setOrderType(req.getOrderType());
-    o.setQuantity(req.getQuantity());
-    o.setPrice(req.getPrice());
+    if (req.getQuantity() == null || req.getQuantity().signum() <= 0) {
+        throw new IllegalArgumentException("quantity must be positive");
+    }
+    o.setQuantity(req.getQuantity());
+    switch (req.getOrderType()) {
+        case MARKET -> o.setPrice(null);
+        case LIMIT -> {
+            if (req.getPrice() == null || req.getPrice().signum() <= 0) {
+                throw new IllegalArgumentException("price must be positive for LIMIT order");
+            }
+            o.setPrice(req.getPrice());
+        }
+    }
     o.setStatus(OrderStatus.PENDING);
     return o;
 }

참고: DTO(PlaceOrderRequest)에도 Bean Validation(@NotNull, @positive 등)으로 1차 방어를 추가하면 더 견고해집니다.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private Order baseOf(PlaceOrderRequest req, OrderSide side){
Order o = new Order();
o.setUserId(req.getUserId());
o.setSymbol(req.getSymbol());
o.setSide(side);
o.setOrderType(req.getOrderType());
o.setQuantity(req.getQuantity());
o.setPrice(req.getPrice());
o.setStatus(OrderStatus.PENDING);
return o;
}
private Order baseOf(PlaceOrderRequest req, OrderSide side){
Order o = new Order();
o.setUserId(req.getUserId());
o.setSymbol(req.getSymbol());
o.setSide(side);
o.setOrderType(req.getOrderType());
- o.setQuantity(req.getQuantity());
if (req.getQuantity() == null || req.getQuantity().signum() <= 0) {
throw new IllegalArgumentException("quantity must be positive");
}
o.setQuantity(req.getQuantity());
switch (req.getOrderType()) {
case MARKET -> o.setPrice(null);
case LIMIT -> {
if (req.getPrice() == null || req.getPrice().signum() <= 0) {
throw new IllegalArgumentException("price must be positive for LIMIT order");
}
o.setPrice(req.getPrice());
}
}
o.setStatus(OrderStatus.PENDING);
return o;
}
🤖 Prompt for AI Agents
In src/main/java/trademill/apiserver/order/OrderService.java around lines 45 to
55, strengthen business-rule validation in baseOf: for LIMIT orders ensure
req.getPrice() is non-null and > 0 (throw IllegalArgumentException or a
domain-specific ValidationException if violated); for MARKET orders ignore any
supplied price and set Order.price to null; also validate req.getQuantity() is >
0 and fail fast on invalid values; keep setting status to PENDING. Optionally
add or complement DTO Bean Validation annotations (@NotNull, @Positive) on
PlaceOrderRequest as a first-line defense.

}
3 changes: 3 additions & 0 deletions src/main/java/trademill/apiserver/order/OrderSide.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package trademill.apiserver.order;

public enum OrderSide {BUY, SELL}
3 changes: 3 additions & 0 deletions src/main/java/trademill/apiserver/order/OrderStatus.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package trademill.apiserver.order;

public enum OrderStatus {PENDING, ACCEPTED, PARTIALLY_FILLED, FILLED, REJECTED, CANCELED}
3 changes: 3 additions & 0 deletions src/main/java/trademill/apiserver/order/OrderType.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package trademill.apiserver.order;

public enum OrderType {MARKET, LIMIT}
38 changes: 38 additions & 0 deletions src/main/java/trademill/apiserver/order/dto/OrderResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package trademill.apiserver.order.dto;

import lombok.Data;
import trademill.apiserver.order.*;

import java.math.BigDecimal;
import java.time.OffsetDateTime;

@Data
public class OrderResponse {
private Long id;
private Long userId;
private String symbol;
private OrderSide side;
private OrderType orderType;
private BigDecimal quantity;
private BigDecimal price;
private OrderStatus status;
private String brokerOrderId;
private OffsetDateTime createdAt;
private OffsetDateTime updatedAt;

public static OrderResponse from(Order o) {
OrderResponse r = new OrderResponse();
r.setId(o.getId());
r.setUserId(o.getUserId());
r.setSymbol(o.getSymbol());
r.setSide(o.getSide());
r.setOrderType(o.getOrderType());
r.setQuantity(o.getQuantity());
r.setPrice(o.getPrice());
r.setStatus(o.getStatus());
r.setBrokerOrderId(o.getBrokerOrderId());
r.setCreatedAt(o.getCreatedAt());
r.setUpdatedAt(o.getUpdatedAt());
return r;
}
}
17 changes: 17 additions & 0 deletions src/main/java/trademill/apiserver/order/dto/PlaceOrderRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package trademill.apiserver.order.dto;

import jakarta.validation.constraints.*;
import lombok.Getter;
import lombok.Setter;
import trademill.apiserver.order.OrderType;

import java.math.BigDecimal;

@Getter @Setter
public class PlaceOrderRequest {
@NotNull private Long userId;
@NotBlank private String symbol;
@NotNull private OrderType orderType; // MARKET or LIMIT
@NotNull @DecimalMin("0.00000001") private BigDecimal quantity;
@DecimalMin("0.0") private BigDecimal price; // LIMIT일 때만 필요
}
Comment on lines +10 to +17
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

치명적 입력 검증 누락: LIMIT일 때 price 필수, MARKET일 때 price 금지

현재 Bean Validation만으로는 orderType에 따른 조건부 검증이 되지 않습니다. 서비스에서 수동 체크하지 않으면 잘못된 주문이 저장/전송되거나 NPE가 발생할 수 있습니다. 클래스 레벨 제약으로 조건부 검증을 추가하세요.

권장 변경(클래스에 커스텀 제약 추가 + 금액/수량 스케일 기본 제한 예시):

 package trademill.apiserver.order.dto;
 
 import jakarta.validation.constraints.*;
 import lombok.Getter;
 import lombok.Setter;
 import trademill.apiserver.order.OrderType;
+import trademill.apiserver.order.dto.validation.ValidPlaceOrder;
 
 import java.math.BigDecimal;
 
-@Getter @Setter
+@Getter @Setter
+@ValidPlaceOrder
 public class PlaceOrderRequest {
     @NotNull private Long userId;
     @NotBlank private String symbol;
     @NotNull private OrderType orderType;            // MARKET or LIMIT
-    @NotNull @DecimalMin("0.00000001") private BigDecimal quantity;
-    @DecimalMin("0.0") private BigDecimal price;     // LIMIT일 때만 필요
+    @NotNull @DecimalMin("0.00000001") private BigDecimal quantity;
+    @DecimalMin("0.0") private BigDecimal price;     // LIMIT일 때만 필요
 }

새 제약 어노테이션:

// src/main/java/trademill/apiserver/order/dto/validation/ValidPlaceOrder.java
package trademill.apiserver.order.dto.validation;

import jakarta.validation.Constraint;
import jakarta.validation.Payload;

import java.lang.annotation.*;

@Documented
@Constraint(validatedBy = PlaceOrderRequestValidator.class)
@Target({ ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidPlaceOrder {
    String message() default "LIMIT는 price가 필수이고, MARKET은 price를 허용하지 않습니다.";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

검증기:

// src/main/java/trademill/apiserver/order/dto/validation/PlaceOrderRequestValidator.java
package trademill.apiserver.order.dto.validation;

import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import trademill.apiserver.order.OrderType;
import trademill.apiserver.order.dto.PlaceOrderRequest;

import java.math.BigDecimal;

public class PlaceOrderRequestValidator implements ConstraintValidator<ValidPlaceOrder, PlaceOrderRequest> {
    @Override
    public boolean isValid(PlaceOrderRequest req, ConstraintValidatorContext ctx) {
        if (req == null || req.getOrderType() == null) return true; // @NotNull이 따로 처리
        BigDecimal price = req.getPrice();

        if (req.getOrderType() == OrderType.LIMIT) {
            return price != null && price.compareTo(BigDecimal.ZERO) > 0;
        } else { // MARKET
            return price == null;
        }
    }
}

검증 스크립트(서비스/컨트롤러에서 중복 체크 있는지 탐색):

원하시면 위 변경으로 인한 단위/통합 테스트 케이스도 함께 만들어 드리겠습니다.


🏁 Script executed:

#!/bin/bash
# LIMIT 시 price 필수, MARKET 시 금지 로직이 서비스/컨트롤러에 수동 구현돼 있는지 확인
rg -n -C3 'OrderType\.LIMIT|OrderType\.MARKET|\.getPrice\(\)' src/main/java --glob '!**/build/**'

Length of output: 3125


LIMIT 요청 시 price 필수, MARKET 요청 시 price 금지 유효성 검증 추가 필요

서비스 계층에서는 PlaceOrderRequestprice 값을 무조건 Order 엔티티에 세팅하고(o.setPrice(req.getPrice())), 브로커 호출 시에도 LIMIT/MARKET 분기만 할 뿐 price 유무를 검증하지 않습니다. 이대로 두면

  • LIMIT 주문에 price 누락 시 NPE 발생
  • MARKET 주문에 잘못된 price 제공 시 불필요한 금액 정보가 DB에 저장

되므로, DTO 단계에서 orderType에 따른 조건부 검증을 추가해야 합니다.

코드상 주요 위치

  • src/main/java/trademill/apiserver/order/OrderService.java
    • baseOf(...) 메서드에서 o.setPrice(req.getPrice())가 무조건 호출됨
    • switch (req.getOrderType()) 분기에서 LIMIT 시 broker.placeLimit*(..., req.getPrice()), MARKET 시 price 인자를 사용하지 않음

제안된 변경사항(클래스 레벨 커스텀 제약 추가 + 검증 로직)

 package trademill.apiserver.order.dto;
 
 import jakarta.validation.constraints.*;
 import lombok.Getter;
 import lombok.Setter;
 import trademill.apiserver.order.OrderType;
+import trademill.apiserver.order.dto.validation.ValidPlaceOrder;
 
 @Getter @Setter
+@ValidPlaceOrder
 public class PlaceOrderRequest {
     @NotNull private Long userId;
     @NotBlank private String symbol;
     @NotNull private OrderType orderType;            // MARKET or LIMIT
     @NotNull @DecimalMin("0.00000001") private BigDecimal quantity;
     @DecimalMin("0.0") private BigDecimal price;     // LIMIT일 때만 필요
 }

추가할 커스텀 제약 어노테이션 & 검증기

// src/main/java/trademill/apiserver/order/dto/validation/ValidPlaceOrder.java
@Documented
@Constraint(validatedBy = PlaceOrderRequestValidator.class)
@Target({ ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidPlaceOrder {
    String message() default "LIMIT는 price가 필수이고, MARKET은 price를 입력할 수 없습니다.";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

// src/main/java/trademill/apiserver/order/dto/validation/PlaceOrderRequestValidator.java
public class PlaceOrderRequestValidator
    implements ConstraintValidator<ValidPlaceOrder, PlaceOrderRequest> {
    @Override
    public boolean isValid(PlaceOrderRequest req, ConstraintValidatorContext ctx) {
        if (req == null || req.getOrderType() == null) return true; // @NotNull 처리
        BigDecimal price = req.getPrice();

        if (req.getOrderType() == OrderType.LIMIT) {
            return price != null && price.compareTo(BigDecimal.ZERO) > 0;
        } else { // MARKET
            return price == null;
        }
    }
}

테스트 케이스

  • LIMIT 주문에 price=null → 검증 실패
  • LIMIT 주문에 price≤0 → 검증 실패
  • MARKET 주문에 price!=null → 검증 실패
  • MARKET 주문에 price=null → 검증 성공
🤖 Prompt for AI Agents
In src/main/java/trademill/apiserver/order/dto/PlaceOrderRequest.java around
lines 10-17, add class-level conditional validation so LIMIT orders require a
positive non-null price and MARKET orders forbid any price; implement a custom
@ValidPlaceOrder annotation and a ConstraintValidator that checks if
req.getOrderType()==LIMIT then price!=null and price>0, else if MARKET then
price==null, annotate PlaceOrderRequest with @ValidPlaceOrder, and ensure
controller/validation pipeline triggers bean validation so invalid requests are
rejected before OrderService runs.

11 changes: 11 additions & 0 deletions src/main/resources/application-local.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
spring:
datasource:
url: jdbc:h2:mem:tm;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1
username: sa
password:
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: update
server:
port: 8080