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
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,13 @@ 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'
implementation 'org.springframework.boot:spring-boot-starter-webflux'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
runtimeOnly 'com.h2database:h2'
}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import trademill.apiserver.config.KisProperties;


@EnableConfigurationProperties(KisProperties.class)
@SpringBootApplication
public class TrademillApiApplication {

Expand Down
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);
}
Comment on lines +6 to +12
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

포트 인터페이스에 Bean Validation 추가(심볼 공백 금지, 수량/가격 양수)

  • 구현체에 @validated를 붙이면 인터페이스 파라미터 제약이 런타임 검증됩니다. 조기 입력 검증으로 방어선을 올려주세요.
 package trademill.apiserver.broker;

 import java.math.BigDecimal;

-// BrokerGateway : '증권사와 어떻게 대화할지' 계약서(포트) 역할
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Positive;
+
+// 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);
+    String placeMarketBuy(@NotBlank String symbol, @Positive BigDecimal quantity);
+    String placeMarketSell(@NotBlank String symbol, @Positive BigDecimal quantity);
+    String placeLimitBuy(@NotBlank String symbol, @Positive BigDecimal quantity, @Positive BigDecimal price);
+    String placeLimitSell(@NotBlank String symbol, @Positive BigDecimal quantity, @Positive BigDecimal price);
 }
  • 향후 확장성을 고려하면 반환값을 String 대신 도메인 타입(예: BrokerOrderResult{orderId,status,raw...})으로 승격하는 것도 추천합니다.
📝 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
// 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);
}
package trademill.apiserver.broker;
import java.math.BigDecimal;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;
// BrokerGateway : '증권사와 어떻게 대화할지' 계약서(포트) 역할
public interface BrokerGateway {
String placeMarketBuy(@NotBlank String symbol, @Positive BigDecimal quantity);
String placeMarketSell(@NotBlank String symbol, @Positive BigDecimal quantity);
String placeLimitBuy(@NotBlank String symbol, @Positive BigDecimal quantity, @Positive BigDecimal price);
String placeLimitSell(@NotBlank String symbol, @Positive BigDecimal quantity, @Positive BigDecimal price);
}
🤖 Prompt for AI Agents
In src/main/java/trademill/apiserver/broker/BrokerGateway.java around lines 6 to
12, the port interface lacks Bean Validation on parameters (symbol must not be
blank, quantity/price must be positive); annotate method parameters with
javax.validation constraints: use @NotBlank on symbol and @Positive on quantity
and price, and ensure any Spring implementation class is annotated with
@org.springframework.validation.annotation.Validated so the interface-level
constraints are enforced at runtime; optionally consider evolving the String
return type to a domain result object (e.g., BrokerOrderResult) in a follow-up.

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","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(); }
}
136 changes: 136 additions & 0 deletions src/main/java/trademill/apiserver/broker/kis/KisBrokerGateway.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package trademill.apiserver.broker.kis;

import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Profile;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import trademill.apiserver.broker.BrokerGateway;
import trademill.apiserver.config.KisProperties;
import trademill.apiserver.order.OrderSide;
import trademill.apiserver.broker.kis.dto.KisResponse;
import trademill.apiserver.broker.kis.dto.OrderCashOutput;

import java.math.BigDecimal;
import java.util.LinkedHashMap;
import java.util.Map;

@Component
@Profile("kis")
@RequiredArgsConstructor
public class KisBrokerGateway implements BrokerGateway {

private static final Logger log = LoggerFactory.getLogger(KisBrokerGateway.class);

private final WebClient kisWebClient;
private final KisTokenService tokenService;
private final KisProperties props;

@Override
public String placeMarketBuy(String symbol, BigDecimal quantity) {
return place(OrderSide.BUY, "MARKET", symbol, quantity, null);
}

@Override
public String placeLimitBuy(String symbol, BigDecimal quantity, BigDecimal price) {
return place(OrderSide.BUY, "LIMIT", symbol, quantity, price);
}

@Override
public String placeMarketSell(String symbol, BigDecimal quantity) {
return place(OrderSide.SELL, "MARKET", symbol, quantity, null);
}

@Override
public String placeLimitSell(String symbol, BigDecimal quantity, BigDecimal price) {
return place(OrderSide.SELL, "LIMIT", symbol, quantity, price);
}
Comment on lines +31 to +49
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

String 기반 orderType 제거 → Enum 사용 및 수량/응답 유효성 강화.

  • 내부 메서드에 String 상수를 넘기지 말고 OrderType enum을 사용하세요.
  • ORD_QTY는 정수만 허용되므로(BigDecimal 소수 금지) toBigIntegerExact로 강제하십시오.
  • 응답에 ORD_NO가 없으면 실패로 간주하고 예외를 던지세요(임의의 플레이스홀더 반환 금지).
 import trademill.apiserver.config.KisProperties;
 import trademill.apiserver.order.OrderSide;
+import trademill.apiserver.order.OrderType;
@@
     @Override
     public String placeMarketBuy(String symbol, BigDecimal quantity) {
-        return place(OrderSide.BUY, "MARKET", symbol, quantity, null);
+        return place(OrderSide.BUY, OrderType.MARKET, symbol, quantity, null);
     }
@@
     @Override
     public String placeLimitBuy(String symbol, BigDecimal quantity, BigDecimal price) {
-        return place(OrderSide.BUY, "LIMIT", symbol, quantity, price);
+        return place(OrderSide.BUY, OrderType.LIMIT, symbol, quantity, price);
     }
@@
     @Override
     public String placeMarketSell(String symbol, BigDecimal quantity) {
-        return place(OrderSide.SELL, "MARKET", symbol, quantity, null);
+        return place(OrderSide.SELL, OrderType.MARKET, symbol, quantity, null);
     }
@@
     @Override
     public String placeLimitSell(String symbol, BigDecimal quantity, BigDecimal price) {
-        return place(OrderSide.SELL, "LIMIT", symbol, quantity, price);
+        return place(OrderSide.SELL, OrderType.LIMIT, symbol, quantity, price);
     }
@@
-    private String place(OrderSide side, String orderType, String symbol, BigDecimal qty, BigDecimal price) {
+    private String place(OrderSide side, OrderType orderType, String symbol, BigDecimal qty, BigDecimal price) {
@@
-        //    ORD_DVSN: 00=지정가, 01=시장가
-        final String ordDvsn = "LIMIT".equals(orderType) ? "00" : "01";
-        final String ordUnpr = "MARKET".equals(orderType) ? "0" : price.toPlainString();
+        //    ORD_DVSN: 00=지정가, 01=시장가
+        final String ordDvsn = (orderType == OrderType.LIMIT) ? "00" : "01";
+        final String ordUnpr = (orderType == OrderType.MARKET) ? "0" : price.toPlainString();
@@
-        body.put("ORD_QTY", qty.toPlainString());
+        // 주식 수량은 정수만 허용됨. 소수일 경우 예외 발생시켜 조기 실패.
+        final String ordQty = qty.toBigIntegerExact().toString();
+        body.put("ORD_QTY", ordQty);
@@
-        String ordNo = resp.getOutput() != null ? resp.getOutput().getORD_NO() : null;
-        log.info("[KIS] rt_cd=0, ORD_NO={}", ordNo);
-        return ordNo != null ? ordNo : "KIS-NO-ORDER-NO";
+        String ordNo = resp.getOutput() != null ? resp.getOutput().getORD_NO() : null;
+        if (ordNo == null || ordNo.isBlank()) {
+            throw new IllegalStateException("KIS success response but missing ORD_NO");
+        }
+        log.info("[KIS] rt_cd=0, ORD_NO={}", ordNo);
+        return ordNo;

검증 요청: KIS 현금주문 API 스펙에서 ORD_QTY가 정수만 허용되는지(소수 허용 여부)를 확인해 주세요.

Also applies to: 51-61, 67-69, 96-99


🌐 Web query:

KIS 현금주문 API ORD_QTY 형식이 정수만 허용되는지?

💡 Result:

간단히: ORD_QTY는 정수(주 단위, 전수)로 입력해야 합니다 — 한국투자증권 오픈API 문서와 샘플은 주문수량을 정수형(주 수)으로 취급하고 있으며, 서드파티 SDK도 ord_qty를 int로 정의합니다. [1][2]

참고자료

  • 한국투자증권 오픈API 주문 샘플 / 필드 설명(ORD_QTY: 주문수량). [1]
  • PyQQQ SDK 문서(국내주식 주문 파라미터 ord_qty: int). [2]

(원하시면 국내/해외 주식별 소수주(부분주) 지원 여부도 확인해 드리겠습니다.)


OrderType enum 사용·수량/응답 검증 강화 필요

  • 내부 메서드에 String 상수 대신 OrderType enum 전달
  • KIS API는 ORD_QTY를 정수(주 단위)로만 허용하므로 qty.toBigIntegerExact()로 강제 변환 (docs.pyqqq.net, occam-khs.github.io)
  • getORD_NO() 결과가 null 또는 blank면 IllegalStateException 던지도록 예외 처리
🤖 Prompt for AI Agents
In src/main/java/trademill/apiserver/broker/kis/KisBrokerGateway.java around
lines 31 to 49, replace the String literals "MARKET"/"LIMIT" with the
appropriate OrderType enum values when calling the internal place(...) method,
ensure quantities are converted to integer shares via
quantity.toBigIntegerExact() before passing to the API (since KIS expects
integer ORD_QTY), and after the place call validate the returned getORD_NO()
result: if it is null or blank throw an IllegalStateException with a clear
message; adjust method signatures/calls as needed to accept OrderType and
integer quantity and propagate these checks to both buy and sell market/limit
methods.


private String place(OrderSide side, String orderType, String symbol, BigDecimal qty, BigDecimal price) {
log.info("[KIS:{} {}] symbol={}, qty={}, price={}", side, orderType, symbol, qty, price);

// 0) 토큰
String token = tokenService.getAccessToken();

// 1) KIS 주문 바디 구성
// ORD_DVSN: 00=지정가, 01=시장가
final String ordDvsn = "LIMIT".equals(orderType) ? "00" : "01";
final String ordUnpr = "MARKET".equals(orderType) ? "0" : price.toPlainString();

Map<String, Object> body = new LinkedHashMap<>();
body.put("CANO", props.getCano()); // 계좌번호(앞 8자리)
body.put("ACNT_PRDT_CD", props.getAcntPrdtCd()); // 상품코드(보통 01)
body.put("PDNO", symbol); // 종목코드
body.put("ORD_DVSN", ordDvsn);
body.put("ORD_QTY", qty.toPlainString());
body.put("ORD_UNPR", ordUnpr);

// 2) 해시키 요청
String hashKey = requestHashKey(token, body);

// 3) 주문 요청
String trId = mapTrId(side, props.isVirtual()); // KIS는 현금주문 TR이 매수/매도로 구분됨
KisResponse<OrderCashOutput> resp = kisWebClient.post()
.uri("/uapi/domestic-stock/v1/trading/order-cash")
.contentType(MediaType.APPLICATION_JSON)
.headers(h -> {
h.add("authorization", "Bearer " + token);
h.add("appkey", props.getAppKey());
h.add("appsecret", props.getAppSecret());
h.add("tr_id", trId);
h.add("custtype", "P"); // 개인
h.add("hashkey", hashKey);
})
.bodyValue(body)
.retrieve()
.bodyToMono(ParameterizedTypes.orderCashResponse())
.block();

if (resp == null) throw new IllegalStateException("KIS response is null");
if (!"0".equals(resp.getRt_cd())) {
throw new IllegalStateException("KIS error: " + resp.getMsg_cd() + " - " + resp.getMsg1());
}

String ordNo = resp.getOutput() != null ? resp.getOutput().getORD_NO() : null;
log.info("[KIS] rt_cd=0, ORD_NO={}", ordNo);
return ordNo != null ? ordNo : "KIS-NO-ORDER-NO";
}

private String requestHashKey(String token, Map<String, Object> body) {
Map<String, Object> resp = kisWebClient.post()
.uri("/uapi/hashkey")
.contentType(MediaType.APPLICATION_JSON)
.headers(h -> {
h.add("authorization", "Bearer " + token);
h.add("appkey", props.getAppKey());
h.add("appsecret", props.getAppSecret());
})
.bodyValue(body)
.retrieve()
.bodyToMono(Map.class)
.block();

Object hk = resp != null ? resp.get("HASH") : null;
if (hk == null) throw new IllegalStateException("Failed to get hashkey from KIS");
return hk.toString();
}

/** KIS 현금주문 TR 매핑 (모의/실전, 매수/매도) */
private String mapTrId(OrderSide side, boolean virtual) {
boolean buy = side == OrderSide.BUY;
if (virtual) {
return buy ? "VTTC0802U" : "VTTC0801U"; // 모의: 매수/매도
} else {
return buy ? "TTTC0802U" : "TTTC0801U"; // 실전: 매수/매도
}
}

/** WebClient 제네릭 응답 타입 헬퍼 */
static class ParameterizedTypes {
static org.springframework.core.ParameterizedTypeReference<KisResponse<OrderCashOutput>> orderCashResponse() {
return new org.springframework.core.ParameterizedTypeReference<>() {};
}
}
}
50 changes: 50 additions & 0 deletions src/main/java/trademill/apiserver/broker/kis/KisTokenService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package trademill.apiserver.broker.kis;

import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Profile;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import trademill.apiserver.config.KisProperties;

import java.time.Instant;
import java.util.Map;

@Service
@Profile("kis")
@RequiredArgsConstructor
public class KisTokenService {
private static final Logger log = LoggerFactory.getLogger(KisTokenService.class);

private final WebClient kisWebClient;
private final KisProperties props;

private volatile String cachedToken;
private volatile Instant expiresAt = Instant.EPOCH;

public String getAccessToken() {
if (cachedToken != null && Instant.now().isBefore(expiresAt.minusSeconds(30))) {
return cachedToken;
}
Map<String, Object> resp = kisWebClient.post()
.uri("/oauth2/tokenP")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of(
"grant_type", "client_credentials",
"appkey", props.getAppKey(),
"appsecret", props.getAppSecret()
))
.retrieve()
.bodyToMono(Map.class)
.block();

String token = (String) resp.get("access_token");
Number exp = (Number) resp.getOrDefault("expires_in", 0);
this.cachedToken = token;
this.expiresAt = Instant.now().plusSeconds(exp.longValue());
log.info("[KIS] access_token issued, expires_in={}s", exp);
return token;
}
Comment on lines +27 to +49
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

토큰 발급 동시성·에러처리·널가드 보강 필요

현재는 동시 다발 호출 시 중복 발급 가능성이 있고, HTTP 에러/비정상 응답 시 NPE가 발생할 수 있습니다. 동기화와 상태코드 처리, 빈 토큰 가드를 추가하세요.

-    public String getAccessToken() {
+    public synchronized String getAccessToken() {
         if (cachedToken != null && Instant.now().isBefore(expiresAt.minusSeconds(30))) {
             return cachedToken;
         }
-        Map<String, Object> resp = kisWebClient.post()
+        Map<String, Object> resp = kisWebClient.post()
                 .uri("/oauth2/tokenP")
                 .contentType(MediaType.APPLICATION_JSON)
                 .bodyValue(Map.of(
                         "grant_type", "client_credentials",
                         "appkey", props.getAppKey(),
                         "appsecret", props.getAppSecret()
                 ))
-                .retrieve()
+                .retrieve()
+                .onStatus(org.springframework.http.HttpStatusCode::isError,
+                          clientResponse -> clientResponse.createException())
                 .bodyToMono(Map.class)
                 .block();
 
-        String token = (String) resp.get("access_token");
-        Number exp = (Number) resp.getOrDefault("expires_in", 0);
+        if (resp == null) {
+            throw new IllegalStateException("[KIS] token response is null");
+        }
+        String token = (String) resp.get("access_token");
+        if (token == null || token.isBlank()) {
+            throw new IllegalStateException("[KIS] access_token missing in response: " + resp);
+        }
+        Number exp = (Number) resp.getOrDefault("expires_in", 0);
         this.cachedToken = token;
         this.expiresAt = Instant.now().plusSeconds(exp.longValue());
         log.info("[KIS] access_token issued, expires_in={}s", exp);
         return token;
     }

추가로, Map 대신 DTO 매핑(예: record TokenResp(String access_token, long expires_in) {})을 사용하면 캐스팅 오류를 줄일 수 있습니다.

📝 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
public String getAccessToken() {
if (cachedToken != null && Instant.now().isBefore(expiresAt.minusSeconds(30))) {
return cachedToken;
}
Map<String, Object> resp = kisWebClient.post()
.uri("/oauth2/tokenP")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of(
"grant_type", "client_credentials",
"appkey", props.getAppKey(),
"appsecret", props.getAppSecret()
))
.retrieve()
.bodyToMono(Map.class)
.block();
String token = (String) resp.get("access_token");
Number exp = (Number) resp.getOrDefault("expires_in", 0);
this.cachedToken = token;
this.expiresAt = Instant.now().plusSeconds(exp.longValue());
log.info("[KIS] access_token issued, expires_in={}s", exp);
return token;
}
public synchronized String getAccessToken() {
if (cachedToken != null && Instant.now().isBefore(expiresAt.minusSeconds(30))) {
return cachedToken;
}
Map<String, Object> resp = kisWebClient.post()
.uri("/oauth2/tokenP")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of(
"grant_type", "client_credentials",
"appkey", props.getAppKey(),
"appsecret", props.getAppSecret()
))
.retrieve()
.onStatus(org.springframework.http.HttpStatusCode::isError,
clientResponse -> clientResponse.createException())
.bodyToMono(Map.class)
.block();
if (resp == null) {
throw new IllegalStateException("[KIS] token response is null");
}
String token = (String) resp.get("access_token");
if (token == null || token.isBlank()) {
throw new IllegalStateException("[KIS] access_token missing in response: " + resp);
}
Number exp = (Number) resp.getOrDefault("expires_in", 0);
this.cachedToken = token;
this.expiresAt = Instant.now().plusSeconds(exp.longValue());
log.info("[KIS] access_token issued, expires_in={}s", exp);
return token;
}
🤖 Prompt for AI Agents
In src/main/java/trademill/apiserver/broker/kis/KisTokenService.java around
lines 27 to 49, the method must be hardened for concurrent calls, HTTP errors,
and null responses: serialize token refresh (e.g. synchronize on a private lock
or use double-checked locking) so only one request issues a new token when
cachedToken is expired; validate HTTP response status and handle WebClient
errors (use onStatus/try-catch around block()) to log and propagate a clear
exception instead of letting NPEs surface; map the response to a typed
DTO/record (e.g. TokenResp) instead of raw Map to avoid casting issues; after
receiving the DTO, null-guard both access_token and expires_in (reject/throw if
missing or non-positive), set cachedToken and expiresAt only after validation,
and ensure callers receive a meaningful exception when token retrieval fails.

}
11 changes: 11 additions & 0 deletions src/main/java/trademill/apiserver/broker/kis/dto/KisResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package trademill.apiserver.broker.kis.dto;

import lombok.Data;

@Data
public class KisResponse<T> {
private String rt_cd; // "0" 성공, "1" 실패
private String msg_cd;
private String msg1;
private T output;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package trademill.apiserver.broker.kis.dto;

import lombok.Data;

@Data
public class OrderCashOutput {
// KIS 주문응답의 주문번호 필드 (문서 기준)
private String ORD_NO;
}
Comment on lines +5 to +9
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Jackson 매핑 강화: 자바 관례 필드명 + @JsonProperty로 매핑, 알 수 없는 필드 무시

  • 현재 ORD_NO처럼 대문자+언더스코어 필드명은 자바 관례와 달라 IDE/정적분석 경고가 발생할 수 있습니다.
  • JSON 키는 유지하되, 자바 필드는 관례대로 바꾸고 알 수 없는 필드 무시는 호환성에 유리합니다.

적용 diff:

 package trademill.apiserver.broker.kis.dto;

-import lombok.Data;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;

 @Data
-public class OrderCashOutput {
-    // KIS 주문응답의 주문번호 필드 (문서 기준)
-    private String ORD_NO;
-}
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class OrderCashOutput {
+    // KIS 주문응답의 주문번호 필드 (문서 기준)
+    @JsonProperty("ORD_NO")
+    private String ordNo;
+}
📝 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
@Data
public class OrderCashOutput {
// KIS 주문응답의 주문번호 필드 (문서 기준)
private String ORD_NO;
}
package trademill.apiserver.broker.kis.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class OrderCashOutput {
// KIS 주문응답의 주문번호 필드 (문서 기준)
@JsonProperty("ORD_NO")
private String ordNo;
}
🤖 Prompt for AI Agents
In src/main/java/trademill/apiserver/broker/kis/dto/OrderCashOutput.java around
lines 5 to 9, rename the non‑conventional field ORD_NO to a Java‑style ordNo,
annotate the field with @JsonProperty("ORD_NO") to preserve JSON mapping, and
add @JsonIgnoreProperties(ignoreUnknown = true) to the class so unknown JSON
fields are ignored; keep the existing Lombok @Data and add the necessary Jackson
imports.

27 changes: 27 additions & 0 deletions src/main/java/trademill/apiserver/config/KisConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package trademill.apiserver.config;

import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.http.MediaType;
import org.springframework.web.reactive.function.client.ExchangeStrategies;
import org.springframework.web.reactive.function.client.WebClient;

@Configuration
@EnableConfigurationProperties(KisProperties.class)
@Profile("kis")
public class KisConfig {

@Bean
public WebClient kisWebClient(KisProperties props) {
return WebClient.builder()
.baseUrl(props.getBaseUrl())
.defaultHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE + "; charset=UTF-8")
.defaultHeader("Accept", MediaType.APPLICATION_JSON_VALUE)
.exchangeStrategies(ExchangeStrategies.builder()
.codecs(c -> c.defaultCodecs().maxInMemorySize(4 * 1024 * 1024))
.build())
.build();
}
}
31 changes: 31 additions & 0 deletions src/main/java/trademill/apiserver/config/KisProperties.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package trademill.apiserver.config;

import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;

@Getter @Setter
@ConfigurationProperties(prefix = "kis")
public class KisProperties {
private String baseUrl;
private String appKey;
private String appSecret;
private String accountNo; // 예: 50151652-01
Comment on lines +3 to +13
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

환경설정 필수값 검증 추가 (@validated + 제약조건 권장)

런타임에 누락/오타를 조기에 발견하려면 @Validated와 필드 제약을 걸어두는 편이 안전합니다. 계좌번호 포맷도 명시적으로 제한해 주세요.

아래처럼 적용 제안드립니다(계좌번호 포맷은 서비스 정책에 맞춰 조정 가능).

 import lombok.Getter;
 import lombok.Setter;
 import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.validation.annotation.Validated;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;

-@Getter @Setter
-@ConfigurationProperties(prefix = "kis")
+@Getter @Setter
+@Validated
+@ConfigurationProperties(prefix = "kis")
 public class KisProperties {
-    private String baseUrl;
-    private String appKey;
-    private String appSecret;
-    private String accountNo; // 예: 50151652-01
+    @NotBlank private String baseUrl;
+    @NotBlank private String appKey;
+    @NotBlank private String appSecret;
+    @NotBlank
+    @Pattern(regexp = "\\d{8}-\\d{2}", message = "계좌번호는 8자리-2자리 형식이어야 합니다. 예: 50151652-01")
+    private String accountNo; // 예: 50151652-01

🏁 Script executed:

#!/usr/bin/env bash
# Spring Boot 버전 확인 (pom.xml 또는 build.gradle)
if [ -f pom.xml ]; then
  echo "=== pom.xml 부모 정보 ==="
  grep -n "<parent>" pom.xml -A3
  echo
  grep -n "spring-boot-starter-parent" pom.xml -A1
  echo
  grep -n "<spring-boot.version>" pom.xml
fi
if [ -f build.gradle ]; then
  echo "=== build.gradle Spring Boot 플러그인/버전 정보 ==="
  grep -n "org.springframework.boot" build.gradle -A1
  echo
  grep -n "springBootVersion" build.gradle
fi

# @ConfigurationProperties 빈 등록 확인
rg -n "@EnableConfigurationProperties" -t java || echo "@EnableConfigurationProperties 미발견"
rg -n "@ConfigurationPropertiesScan" -t java || echo "@ConfigurationPropertiesScan 미발견"

Length of output: 1378


@validated 및 필드 제약조건 추가 적용
Spring Boot 3.5.4 환경에 이미 spring-boot-starter-validation가 포함되어 있으므로, @ConfigurationProperties 클래스에 @Validated와 JSR-380 제약조건을 적용해 런타임 초기화 시점에 필수값 누락·오타를 검증할 수 있습니다. 아래 제안대로 반영해 주세요.

 import lombok.Getter;
 import lombok.Setter;
 import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.validation.annotation.Validated;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;

-@Getter @Setter
-@ConfigurationProperties(prefix = "kis")
+@Getter @Setter
+@Validated
+@ConfigurationProperties(prefix = "kis")
 public class KisProperties {
-    private String baseUrl;
-    private String appKey;
-    private String appSecret;
-    private String accountNo; // 예: 50151652-01
+    @NotBlank
+    private String baseUrl;
+
+    @NotBlank
+    private String appKey;
+
+    @NotBlank
+    private String appSecret;
+
+    @NotBlank
+    @Pattern(regexp = "\\d{8}-\\d{2}", message = "계좌번호는 8자리-2자리 형식이어야 합니다. 예: 50151652-01")
+    private String accountNo; // 예: 50151652-01
📝 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
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
@Getter @Setter
@ConfigurationProperties(prefix = "kis")
public class KisProperties {
private String baseUrl;
private String appKey;
private String appSecret;
private String accountNo; // 예: 50151652-01
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
@Getter
@Setter
@Validated
@ConfigurationProperties(prefix = "kis")
public class KisProperties {
@NotBlank
private String baseUrl;
@NotBlank
private String appKey;
@NotBlank
private String appSecret;
@NotBlank
@Pattern(
regexp = "\\d{8}-\\d{2}",
message = "계좌번호는 8자리-2자리 형식이어야 합니다. 예: 50151652-01"
)
private String accountNo; // 예: 50151652-01
}
🤖 Prompt for AI Agents
In src/main/java/trademill/apiserver/config/KisProperties.java around lines 3 to
13, add runtime validation by annotating the class with @Validated and applying
JSR-380 constraints (e.g. @NotBlank) to required fields (baseUrl, appKey,
appSecret, accountNo). Update imports accordingly (javax.validation.constraints
or jakarta.validation.constraints depending on project), and keep the class as a
@ConfigurationProperties so Spring's binder will validate values on startup.


public String getCano() {
if (accountNo == null) return null;
int idx = accountNo.indexOf('-');
return idx > 0 ? accountNo.substring(0, idx) : accountNo;
}

public String getAcntPrdtCd() {
if (accountNo == null) return null;
int idx = accountNo.indexOf('-');
return idx > 0 ? accountNo.substring(idx + 1) : "01";
}

public boolean isVirtual() {
// 모의투자 여부 (base-url 에 vts 포함 시 true)
return baseUrl != null && baseUrl.contains("openapivts");
}
}
2 changes: 1 addition & 1 deletion src/main/java/trademill/apiserver/demo/domain/Demo.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public class Demo {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(nullable = false)
@Column(name = "val")
private String value;

}
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;

Comment on lines +3 to +7
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

엔티티 제약 강화: NOT NULL/Index/낙관적 락 추가.

DB 일관성과 조회 성능, 동시성 안전성을 위해 최소한의 제약을 추가하세요.

 import jakarta.persistence.*;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Positive;
 import lombok.Getter;
 import lombok.NoArgsConstructor;
 import lombok.Setter;
 
 import java.math.BigDecimal;
 import java.time.OffsetDateTime;
 
 @Entity
-@Table(name = "orders")
+@Table(name = "orders", indexes = {
+        @Index(name = "idx_orders_user_created", columnList = "userId,createdAt")
+})
 @Getter @Setter @NoArgsConstructor
 public class Order {
 
-    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
+    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
     private Long id;
 
-    private Long userId;
-    private String symbol;
+    @NotNull
+    @Column(nullable = false)
+    private Long userId;
+    @NotBlank
+    @Column(nullable = false, length = 16)
+    private String symbol;
 
-    @Enumerated(EnumType.STRING) private OrderSide side;
-    @Enumerated(EnumType.STRING) private OrderType orderType;
+    @Enumerated(EnumType.STRING) @NotNull @Column(nullable = false)
+    private OrderSide side;
+    @Enumerated(EnumType.STRING) @NotNull @Column(nullable = false)
+    private OrderType orderType;
 
-    private BigDecimal quantity;
-    private BigDecimal price; // MARKET이면 null 가능
+    @Positive @NotNull
+    @Column(nullable = false, precision = 19, scale = 0) // 주식 수량: 정수
+    private BigDecimal quantity;
+    @Column(precision = 19, scale = 2) // LIMIT일 때만 사용
+    private BigDecimal price; // MARKET이면 null 가능
 
-    @Enumerated(EnumType.STRING) private OrderStatus status;
+    @Enumerated(EnumType.STRING) @NotNull @Column(nullable = false)
+    private OrderStatus status;
     private String brokerOrderId;
 
-    private OffsetDateTime createdAt;
-    private OffsetDateTime updatedAt;
+    @Column(nullable = false)
+    private OffsetDateTime createdAt;
+    @Column(nullable = false)
+    private OffsetDateTime updatedAt;
+
+    @Version
+    private Long version;

Also applies to: 11-14, 16-33

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;

@PrePersist
void onCreate() {
createdAt = OffsetDateTime.now();
updatedAt = createdAt;
if (status == null) status = OrderStatus.PENDING;
}
@PreUpdate
void onUpdate() { updatedAt = OffsetDateTime.now(); }
}
Loading