Skip to content

feat(broker): KIS 모의주문 연동 1차#7

Open
yooondaeun wants to merge 3 commits intotrade-mill:developfrom
yooondaeun:feat/kis-broker
Open

feat(broker): KIS 모의주문 연동 1차#7
yooondaeun wants to merge 3 commits intotrade-mill:developfrom
yooondaeun:feat/kis-broker

Conversation

@yooondaeun
Copy link

@yooondaeun yooondaeun commented Aug 25, 2025

Summary

  • KIS 모의주문 연동 1차 완료 (매수/매도, 시장가/지정가)
  • kis 프로필 분리 및 환경변수 주입

Changes

  • Broker: KisBrokerGateway, KisTokenService 추가
  • DTO: KisResponse, OrderCashOutput
  • Config: KisConfig, KisProperties, application-kis.yml
  • FakeBrokerGateway 프로필 정리(local/default)
  • H2 runtimeOnly 추가 (로컬 부팅)
  • Demo 엔티티 컬럼명 예약어 충돌 회피

How to run

export KIS_BASE_URL="https://openapivts.koreainvestment.com:29443"
export KIS_APP_KEY="<APP_KEY>"
export KIS_APP_SECRET="<APP_SECRET>"
export KIS_ACCOUNT_NO="<계좌번호-상품코드 예: 50151652-01>"
SPRING_PROFILES_ACTIVE=kis ./gradlew bootRun

How to test (curl)

시장가 매수

curl -X POST "http://localhost:8080/api/v1/orders/buy"
-H "Content-Type: application/json"
-d '{"userId":1,"symbol":"005930","orderType":"MARKET","quantity":1}'

지정가 매도

curl -X POST "http://localhost:8080/api/v1/orders/sell"
-H "Content-Type: application/json"
-d '{"userId":1,"symbol":"005930","orderType":"LIMIT","quantity":1,"price":70000}'

  • 장외 시간에는 모의투자에서 40580000(장종료) 응답 확인됨.
  • TR_ID: 모의 VTTC0802U/VTTC0801U, 실전 TTTC0802U/TTTC0801U

Summary by CodeRabbit

  • 새 기능
    • 주문 API 추가: 매수/매도 생성(시장가/지정가) 및 사용자별 주문 목록 페이지네이션 조회.
    • 브로커 연동: KIS 프로필에서 실제 주문 전송, 로컬/기본 프로필에서 시뮬레이션 응답 제공.
  • 작업
    • WebFlux 및 H2 도입으로 경량 실행·개발 환경 개선.
    • KIS 관련 값을 외부 설정으로 관리하고 프로필별 애플리케이션 구성 추가.

윤다은 added 3 commits August 25, 2025 18:13
- kis profile + env 주입 (KIS_* 환경변수)
- Token/HashKey/현금주문 호출 플로우
- 설정/가짜브로커/프로퍼티 정리
- Demo 예약어 충돌 수정
@yooondaeun yooondaeun requested a review from k-kbk August 25, 2025 11:02
@coderabbitai
Copy link

coderabbitai bot commented Aug 25, 2025

Walkthrough

WebFlux와 H2 의존성을 추가하고, KIS 관련 프로퍼티/빈 구성을 도입했다. 브로커 포트(BrokerGateway)와 로컬용 구현(FakeBrokerGateway), KIS 연동 구현(KisBrokerGateway, 토큰 서비스, DTO, 설정)을 추가했다. 주문 엔터티/리포지토리/서비스/컨트롤러와 DTO를 신설하고, Demo 엔터티의 컬럼 매핑을 수정했다.

Changes

Cohort / File(s) Summary
빌드 설정
build.gradle
WebFlux 구현 의존성과 H2 runtimeOnly 의존성 추가
애플리케이션 프로퍼티 활성화
src/main/java/trademill/apiserver/TrademillApiApplication.java
@EnableConfigurationProperties(KisProperties.class) 추가
브로커 포트 및 로컬 구현
src/main/java/trademill/apiserver/broker/BrokerGateway.java, src/main/java/trademill/apiserver/broker/FakeBrokerGateway.java
브로커 포트 인터페이스 생성; 로컬/디폴트 프로파일에서 동작하는 가짜 구현 추가(주문 ID 시뮬레이션)
KIS 브로커 연동
src/main/java/trademill/apiserver/broker/kis/KisBrokerGateway.java, src/main/java/trademill/apiserver/broker/kis/KisTokenService.java, src/main/java/trademill/apiserver/broker/kis/dto/*, src/main/java/trademill/apiserver/config/KisConfig.java, src/main/java/trademill/apiserver/config/KisProperties.java, src/main/resources/application-kis.yml
KIS 토큰 발급/캐시, 주문 API 호출(WebClient), 응답 DTO, 프로파일 기반 WebClient/프로퍼티 설정, kis 프로파일용 애플리케이션 설정 추가
주문 도메인 및 API
src/main/java/trademill/apiserver/order/Order.java, src/main/java/trademill/apiserver/order/OrderRepository.java, src/main/java/trademill/apiserver/order/OrderService.java, src/main/java/trademill/apiserver/order/OrderController.java, src/main/java/trademill/apiserver/order/OrderSide.java, src/main/java/trademill/apiserver/order/OrderStatus.java, src/main/java/trademill/apiserver/order/OrderType.java, src/main/java/trademill/apiserver/order/dto/*
주문 엔터티/상태/유형/사이드 정의, 리포지토리/서비스/컨트롤러 추가(매수/매도/목록), 요청/응답 DTO 추가
데모 엔터티 수정
src/main/java/trademill/apiserver/demo/domain/Demo.java
value 컬럼 매핑을 @Column(name = "val")로 변경(널 제약 해제)

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Tip

🔌 Remote MCP (Model Context Protocol) integration is now available!

Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats.

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbit in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbit in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbit gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbit read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbit help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbit ignore or @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbit summary or @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbit or @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@github-actions github-actions bot added the feat label Aug 25, 2025
@k-kbk
Copy link
Contributor

k-kbk commented Aug 28, 2025

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Aug 28, 2025

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 11

🧹 Nitpick comments (18)
src/main/java/trademill/apiserver/demo/domain/Demo.java (1)

12-24: 대안: 전역 식별자 인용 또는 명시적 DDL로 예약어 이슈의 재발 방지

유사 이슈 재발을 막으려면:

  • Hibernate: hibernate.globally_quoted_identifiers=true 설정 검토.
  • 마이그레이션 도구(Flyway/Liquibase)로 명시적 DDL 관리.

운영 DB와 H2 모두에서 동일하게 동작하는지 확인 바랍니다.

src/main/java/trademill/apiserver/order/OrderType.java (1)

1-3: enum 도입은 적절. 외부 입력(대소문자·별칭) 바인딩만 확인해 주세요.

  • 클라이언트가 "market"/"limit" 등 소문자나 별칭을 보낼 가능성에 대비해 컨트롤러/DTO 레벨에서 대소문자 무시 또는 커스텀 디시리얼라이저 설정 여부를 점검해 주세요.
  • 추후 STOP/STOP_LIMIT 등 확장 케이스가 있을지 로드맵만 명확히 해두면 좋습니다.
build.gradle (2)

30-30: spring-boot-starter-web + webflux 동시 사용: MVC 우선 구동됨. 의도 확인 및 명시 설정 권장

  • 두 스타터 동시 포함 시 Spring Boot는 기본적으로 MVC(서블릿/Tomcat)로 기동됩니다. WebClient 사용 목적이라면 현재 구성은 동작하지만, 의도를 명확히 하려면 프로필에 다음을 지정하는 것을 권장합니다:
# application-kis.yml (예시)
spring:
  main:
    web-application-type: servlet
  • 리액티브 서버가 불필요한지(단지 WebClient만 쓰는지) 확인해 주세요. 필요 시 reactor-test 추가를 고려해 테스트에서 StepVerifier 등을 사용할 수 있습니다:
testImplementation 'io.projectreactor:reactor-test'

36-36: H2 runtimeOnly 추가 OK. 프로덕션 프로필에서의 누출만 차단 확인

  • H2는 로컬/테스트 전용으로 보이며 runtimeOnly 스코프는 적절합니다. 다만 프로덕션 프로필에서 H2가 활성화되지 않도록 데이터소스/프로필 분리를 확인해 주세요(예: application-kis.yml만에서 H2 설정 유지).
  • 필요 시 의존성도 테스트 전용으로 분리해 두는 선택지(testRuntimeOnly)도 있습니다.
src/main/java/trademill/apiserver/order/OrderStatus.java (1)

1-3: 상태 enum 구성이 직관적입니다. KIS 코드→내부 상태 매핑표만 고정화 권장

  • KIS 응답 코드/메시지(접수/거부/체결/부분체결/취소 등)와 본 enum 간 일대일 매핑 규칙을 문서화/유닛테스트로 고정해 두면 회귀 방지에 유리합니다.
  • 영속 계층에서 @Enumerated(EnumType.STRING) 사용 중이라면 현재 이름 변경이 스키마 호환성에 직접 영향 주므로 주의해 주세요.
src/main/java/trademill/apiserver/TrademillApiApplication.java (1)

5-10: @EnableConfigurationProperties 위치를 kis 프로필로 한정하세요

비-kis 프로필에서도 KisProperties 빈이 생성될 수 있어(특히 @validated 제약 사용 시) 부트 실패 가능성이 있습니다. KisConfig(@Profile("kis"))로 이동을 권장합니다.

다음 diff로 본 파일에서 제거하고, KisConfig에 추가해 주세요.

-import org.springframework.boot.context.properties.EnableConfigurationProperties;
-import trademill.apiserver.config.KisProperties;
-
-@EnableConfigurationProperties(KisProperties.class)

KisConfig 예시(참고, 별도 파일 수정 필요):

@Configuration
@Profile("kis")
@EnableConfigurationProperties(KisProperties.class)
public class KisConfig { /* 기존 구성 유지 */ }
src/main/java/trademill/apiserver/order/OrderRepository.java (1)

6-8: 메서드 시그니처 적절 — 테이블/컬럼 예약어 충돌 및 인덱스 확인 권장

  • Order는 SQL 예약어와 충돌합니다. 엔티티에 @Table(name = "orders") 등으로 명시해주세요.
  • userId 조회가 빈번하므로 인덱스 추가를 권장합니다.

엔티티 예시(참고, 별도 파일 수정 필요):

@Entity
@Table(name = "orders", indexes = {
  @Index(name = "idx_orders_user_id", columnList = "user_id")
})
class Order { /* ... */ }
src/main/java/trademill/apiserver/broker/kis/dto/KisResponse.java (1)

3-11: JSON 호환성과 자바 관례 개선: snake_case 매핑 + 확장성 보강

자바 관례(camelCase)로 필드명을 정리하고, JSON 필드와는 @JsonProperty로 매핑하면 유지보수가 좋아집니다. 알 수 없는 필드 무시 옵션도 추가하세요.

 package trademill.apiserver.broker.kis.dto;
 
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
 import lombok.Data;
 
 @Data
-public class KisResponse<T> {
-    private String rt_cd;   // "0" 성공, "1" 실패
-    private String msg_cd;
-    private String msg1;
-    private T output;
-}
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class KisResponse<T> {
+    @JsonProperty("rt_cd")
+    private String rtCd;   // "0" 성공, "1" 실패
+    @JsonProperty("msg_cd")
+    private String msgCd;
+    @JsonProperty("msg1")
+    private String msg1;
+    @JsonProperty("output")
+    private T output;
+
+    @JsonIgnore
+    public boolean isOk() { return "0".equals(rtCd); }
+}
src/main/java/trademill/apiserver/config/KisProperties.java (2)

15-25: 계좌 파싱 로직 경계조건/공백 처리 보강

좌측/우측 공백, -가 끝/앞에 오는 이상치 등 경계조건을 보완해 주세요.

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

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

27-30: 가상환경 판별 시 대소문자 무시

baseUrl의 대소문자 및 포맷 차이를 흡수하는 편이 안전합니다.

-        return baseUrl != null && baseUrl.contains("openapivts");
+        return baseUrl != null && baseUrl.toLowerCase(java.util.Locale.ROOT).contains("openapivts");
src/main/java/trademill/apiserver/order/dto/PlaceOrderRequest.java (1)

13-13: 심볼 포맷 제약(선택): 국내 6자리 종목코드만 허용

국내 주식만 대상으로 시작한다면 6자리 숫자 패턴을 권장합니다. 멀티마켓을 고려하면 이 코멘트는 무시해도 됩니다.

-    @NotBlank private String symbol;
+    @NotBlank
+    @jakarta.validation.constraints.Pattern(regexp = "\\d{6}", message = "국내 종목코드는 6자리 숫자여야 합니다.")
+    private String symbol;
src/main/java/trademill/apiserver/config/KisConfig.java (2)

20-21: 기본 Content-Type 헤더 전역 설정 지양

GET 등 바디가 없는 요청에도 전역 Content-Type이 실릴 수 있습니다. 요청 단위로 설정하는 편이 안전합니다(토큰 요청처럼 JSON 바디가 있는 경우에만).

-                .defaultHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE + "; charset=UTF-8")
                 .defaultHeader("Accept", MediaType.APPLICATION_JSON_VALUE)

16-25: 네트워크 타임아웃/커넥터 설정 권장

외부 API 연동 특성상 커넥트/응답 타임아웃과 압축/로깅 필터를 구성하는 편이 운영 안정성에 유리합니다.

예시(참고용, 별도 적용 파일 외 코드):

@Bean
public WebClient kisWebClient(KisProperties props) {
    var httpClient = reactor.netty.http.client.HttpClient.create()
        .responseTimeout(java.time.Duration.ofSeconds(5));
    return WebClient.builder()
        .baseUrl(props.getBaseUrl())
        .clientConnector(new org.springframework.http.client.reactive.ReactorClientHttpConnector(httpClient))
        .defaultHeader("Accept", MediaType.APPLICATION_JSON_VALUE)
        .exchangeStrategies(ExchangeStrategies.builder()
            .codecs(c -> c.defaultCodecs().maxInMemorySize(4 * 1024 * 1024))
            .build())
        .build();
}
src/main/java/trademill/apiserver/order/dto/OrderResponse.java (2)

3-11: API 응답 DTO는 불변으로 유지 권장 (@DaTa → @Value/@builder).

외부로 노출되는 응답 객체는 setter 노출을 최소화하는 것이 안전합니다. 불변 DTO로 전환하고 생성은 빌더를 사용하세요.

-import lombok.Data;
+import lombok.Value;
+import lombok.Builder;

-@Data
-public class OrderResponse {
+@Value
+@Builder
+public class OrderResponse {
@@
-    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;
-    }
+    public static OrderResponse from(Order o) {
+        return OrderResponse.builder()
+                .id(o.getId())
+                .userId(o.getUserId())
+                .symbol(o.getSymbol())
+                .side(o.getSide())
+                .orderType(o.getOrderType())
+                .quantity(o.getQuantity())
+                .price(o.getPrice())
+                .status(o.getStatus())
+                .brokerOrderId(o.getBrokerOrderId())
+                .createdAt(o.getCreatedAt())
+                .updatedAt(o.getUpdatedAt())
+                .build();
+    }

Also applies to: 23-37


6-10: null 필드 숨김으로 응답 간결화 (예: MARKET 주문 시 price=null).

null 값을 응답에서 제외하면 클라이언트 해석이 단순해집니다.

+import com.fasterxml.jackson.annotation.JsonInclude;
@@
+@JsonInclude(JsonInclude.Include.NON_NULL)
 public class OrderResponse {

Also applies to: 20-21

src/main/java/trademill/apiserver/broker/FakeBrokerGateway.java (1)

16-19: @OverRide 누락.

인터페이스 구현 메서드에 @OverRide를 추가해 시그니처 변경 시 컴파일 타임 검증을 받으세요.

-    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(); }
+    @Override public String placeMarketBuy(String s, BigDecimal q){ return id(); }
+    @Override public String placeMarketSell(String s, BigDecimal q){ return id(); }
+    @Override public String placeLimitBuy(String s, BigDecimal q, BigDecimal p){ return id(); }
+    @Override public String placeLimitSell(String s, BigDecimal q, BigDecimal p){ return id(); }
src/main/java/trademill/apiserver/order/OrderService.java (1)

41-43: 조회 트랜잭션에 readOnly 플래그 추가.

읽기 전용 힌트로 불필요한 더티체킹을 방지합니다.

-    public Page<Order> list(Long userId, Pageable pageable){
+    @Transactional(readOnly = true)
+    public Page<Order> list(Long userId, Pageable pageable){
         return orders.findByUserId(userId, pageable);
     }
src/main/java/trademill/apiserver/broker/kis/KisBrokerGateway.java (1)

70-89: WebClient 예외 처리/타임아웃 보강 고려.

현재 retrieve().block() 사용은 4xx/5xx에서 WebClientResponseException을 던지지만, 타임아웃/재시도/에러 바디 로깅이 부족합니다. KisConfig의 WebClient에 타임아웃/재시도를 넣거나 여기서 onStatus로 상세 메시지를 포함하세요.

Also applies to: 101-118

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 19d64d8 and a75b83a.

📒 Files selected for processing (21)
  • build.gradle (1 hunks)
  • src/main/java/trademill/apiserver/TrademillApiApplication.java (1 hunks)
  • src/main/java/trademill/apiserver/broker/BrokerGateway.java (1 hunks)
  • src/main/java/trademill/apiserver/broker/FakeBrokerGateway.java (1 hunks)
  • src/main/java/trademill/apiserver/broker/kis/KisBrokerGateway.java (1 hunks)
  • src/main/java/trademill/apiserver/broker/kis/KisTokenService.java (1 hunks)
  • src/main/java/trademill/apiserver/broker/kis/dto/KisResponse.java (1 hunks)
  • src/main/java/trademill/apiserver/broker/kis/dto/OrderCashOutput.java (1 hunks)
  • src/main/java/trademill/apiserver/config/KisConfig.java (1 hunks)
  • src/main/java/trademill/apiserver/config/KisProperties.java (1 hunks)
  • src/main/java/trademill/apiserver/demo/domain/Demo.java (1 hunks)
  • src/main/java/trademill/apiserver/order/Order.java (1 hunks)
  • src/main/java/trademill/apiserver/order/OrderController.java (1 hunks)
  • src/main/java/trademill/apiserver/order/OrderRepository.java (1 hunks)
  • src/main/java/trademill/apiserver/order/OrderService.java (1 hunks)
  • src/main/java/trademill/apiserver/order/OrderSide.java (1 hunks)
  • src/main/java/trademill/apiserver/order/OrderStatus.java (1 hunks)
  • src/main/java/trademill/apiserver/order/OrderType.java (1 hunks)
  • src/main/java/trademill/apiserver/order/dto/OrderResponse.java (1 hunks)
  • src/main/java/trademill/apiserver/order/dto/PlaceOrderRequest.java (1 hunks)
  • src/main/resources/application-kis.yml (1 hunks)
🔇 Additional comments (7)
src/main/java/trademill/apiserver/demo/domain/Demo.java (2)

21-22: 예약어 충돌 회피를 위한 컬럼명 변경 적절

H2 등에서 VALUE는 예약어라 매핑을 val로 바꾼 결정은 합리적입니다. 로컬 부팅/DDL 생성 시 충돌을 줄여줍니다.


21-22: @Column(nullable=false) 삭제로 null 허용 상태 변경 — 의도 확인 및 검증 필요
현재 @Column(name="val")nullable=false가 빠져 있어 JPA 자동 생성 시 해당 컬럼이 null 허용 상태가 됩니다. 마이그레이션 파일이나 자동 생성 스키마 설정에서 NOT NULL 제약이 유지되는지 확인하고, 필요 시 아래 수정안을 적용하세요.

-    @Column(name = "val")
+    @Column(name = "val", nullable = false)
  • resources/db/migration 등에서 val 컬럼 NOT NULL 제약 여부 확인
  • 서비스/컨트롤러 로직에서 Demo.getValue() null 처리 검증
src/main/java/trademill/apiserver/order/OrderSide.java (1)

3-3: 심플한 enum 도입 적절 — LGTM

도메인 모델 가독성 및 타입 안정성에 도움이 됩니다.

src/main/resources/application-kis.yml (1)

19-23: ExchangeFunctions DEBUG 레벨은 헤더 값을 기록하지 않습니다 — 헤더 세부 로깅은 TRACE + spring.codec.log-request-details 활성화 시에만 발생
DEBUG 로깅은 요청 시작 및 상태만 출력하며, 헤더 값은 노출되지 않습니다 (TRACE 레벨과 spring.codec.log-request-details=true 또는 spring.http.log-request-details=true 설정 시에만 헤더가 기록됨) (stackoverflow.com, runebook.dev)
운영 환경에서는 TRACE/DEBUG 로깅 비권장하며, 민감정보 로깅이 필요한 경우에만 헤더 마스킹 필터를 적용하세요.

Likely an incorrect or invalid review comment.

src/main/java/trademill/apiserver/order/Order.java (1)

34-41: 감사/기본값 로직은 적절합니다.

생성/수정 시각 설정과 기본 상태값(PENDING) 지정이 잘 되어 있습니다.

src/main/java/trademill/apiserver/broker/FakeBrokerGateway.java (1)

9-19: 로컬/기본 프로파일 바인딩 적절.

kis 프로파일과 충돌 없이 기본/로컬에서만 활성화되도록 잘 구성되어 있습니다.

src/main/java/trademill/apiserver/broker/kis/KisBrokerGateway.java (1)

52-55: 토큰/민감정보 로깅 없음 확인.

민감 정보가 로그에 노출되지 않도록 잘 처리되었습니다. 유지하세요.

Comment on lines +6 to +12
// 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);
}
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.

Comment on lines +5 to +9
@Data
public class OrderCashOutput {
// KIS 주문응답의 주문번호 필드 (문서 기준)
private String ORD_NO;
}
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.

Comment on lines +31 to +49
@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);
}
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.

Comment on lines +27 to +49
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;
}
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.

Comment on lines +3 to +13
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
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.

Comment on lines +15 to +16
@NotNull @DecimalMin("0.00000001") private BigDecimal quantity;
@DecimalMin("0.0") private BigDecimal price; // LIMIT일 때만 필요
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

수량은 정수, 가격은 양수(>0) 강제 권장

KIS 현물 주문 수량은 정수이며, 지정가 price는 0 초과가 타당합니다. 도메인 규칙에 맞게 제약을 강화해 주세요.

국내 주식만 지원한다면 아래처럼 권장합니다(미국/분할주 등 확장 계획이 있다면 별도 논의):

-    @NotNull @DecimalMin("0.00000001") private BigDecimal quantity;
-    @DecimalMin("0.0") private BigDecimal price;     // LIMIT일 때만 필요
+    @NotNull
+    @jakarta.validation.constraints.Digits(integer = 12, fraction = 0)
+    @DecimalMin(value = "1", inclusive = true)
+    private BigDecimal quantity;                     // 정수 수량
+
+    @DecimalMin(value = "0.0", inclusive = false)
+    private BigDecimal price;                        // LIMIT일 때만 필요

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the DTO file
FILE=$(find src -type f -name PlaceOrderRequest.java)
echo "Found file: $FILE"

# Show the first 50 lines to verify current annotations
sed -n '1,50p' "$FILE"

# Search for any references to supported markets or currencies
echo "Searching for market/currency references across codebase..."
rg -nP '\b(market|currency|exchange)\b' -C3 src || echo "No matches found"

Length of output: 1144


수량은 정수(>=1), price는 0 초과로 엄격히 검증
아래처럼 제약을 강화해 주세요.

--- a/src/main/java/trademill/apiserver/order/dto/PlaceOrderRequest.java
+++ b/src/main/java/trademill/apiserver/order/dto/PlaceOrderRequest.java
@@ Lines 15-16
-    @NotNull @DecimalMin("0.00000001") private BigDecimal quantity;
-    @DecimalMin("0.0") private BigDecimal price;     // LIMIT일 때만 필요
+    @NotNull
+    @jakarta.validation.constraints.Digits(integer = 12, fraction = 0)
+    @DecimalMin(value = "1", inclusive = true)
+    private BigDecimal quantity;                     // 정수 수량
+
+    @DecimalMin(value = "0.0", inclusive = false)
+    private BigDecimal price;                        // LIMIT일 때만 필요
📝 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
@NotNull @DecimalMin("0.00000001") private BigDecimal quantity;
@DecimalMin("0.0") private BigDecimal price; // LIMIT일 때만 필요
@NotNull
@jakarta.validation.constraints.Digits(integer = 12, fraction = 0)
@DecimalMin(value = "1", inclusive = true)
private BigDecimal quantity; // 정수 수량
@DecimalMin(value = "0.0", inclusive = false)
private BigDecimal price; // LIMIT일 때만 필요
🤖 Prompt for AI Agents
In src/main/java/trademill/apiserver/order/dto/PlaceOrderRequest.java around
lines 15-16, strengthen validations: make quantity an integer >=1 by either
changing its type to Integer/Long with @NotNull and @Min(1) or, if keeping
BigDecimal, add @NotNull, @DecimalMin("1"), and enforce scale 0 (e.g., a custom
validator or @Digits(integer=..., fraction=0)) so fractional quantities are
rejected; for price require strictly greater than zero by replacing
@DecimalMin("0.0") with @DecimalMin(value = "0.0", inclusive = false) and keep
any existing note about LIMIT-only usage (conditional enforcement can be handled
in service/validator logic).

Comment on lines +3 to +7
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

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

Comment on lines +16 to +24
@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));
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

신원 검증 없이 userId를 바디로 받는 것은 수평 권한 상승 위험

클라이언트가 임의의 userId로 타인의 계정에 주문을 생성할 수 있습니다. 인증 컨텍스트에서 사용자 식별자를 추출하고, 바디의 userId는 제거/서버 측에서 오버라이드 하세요.

예시(보안 맥락에 맞게 변형 필요):

@PostMapping("/buy")
public OrderResponse buy(@Valid @RequestBody PlaceOrderRequest req/*, Authentication auth */){
    // Long userId = ((CustomPrincipal) auth.getPrincipal()).getId();
    // req.setUserId(userId);
    return OrderResponse.from(service.placeBuy(req));
}

원하시면 관련 보안 구성/테스트 케이스까지 PR 보완안 제시하겠습니다.

🤖 Prompt for AI Agents
In src/main/java/trademill/apiserver/order/OrderController.java around lines
16-24, the controller currently trusts a userId from the request body which
allows horizontal privilege escalation; instead extract the authenticated user's
id from the security context (e.g., Authentication/CustomPrincipal via
SecurityContextHolder), override or remove any userId in PlaceOrderRequest
before calling service.placeBuy/placeSell, and ensure the request cannot set a
different userId (server-side override and/or remove setter/ignore field in
mapping). Also add minimal validation/assertion that the authenticated id is
present and used for order creation.

Comment on lines +26 to +32
@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);
}
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

❓ Verification inconclusive

리스트 조회도 동일한 취약점 + 페이지 사이즈 상한 설정 권장

userId 쿼리파라미터 신뢰 금지, 그리고 size에 상한(예: 100)과 page 음수 방지를 적용하세요.

-    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 Long userId,
+                                    @RequestParam(defaultValue="0") int page,
+                                    @RequestParam(defaultValue="20") int size){
+        page = Math.max(page, 0);
+        size = Math.max(1, Math.min(size, 100));
+        return service.list(userId, PageRequest.of(page, size, Sort.by("id").descending()))
                 .map(OrderResponse::from);
     }

보안 적용 시에는 userId 또한 인증 정보로 대체해 주세요.


인증된 사용자 ID 사용 및 페이지 파라미터 유효성 검증 적용
userId를 쿼리 파라미터가 아닌 인증 정보(Principal)에서 가져오도록 변경하고, page는 0 이상, size는 1~100 범위로 제한하세요.

🤖 Prompt for AI Agents
In src/main/java/trademill/apiserver/order/OrderController.java around lines 26
to 32, switch from reading userId as a @RequestParam to using the authenticated
principal (e.g., method parameter Principal or Authentication) and extract the
current user's ID from it; remove the userId request parameter. Add validation
for page and size: ensure page >= 0 and size between 1 and 100 (either via javax
validation annotations with @Validated on the controller or by checking manually
and throwing a ResponseStatusException(HttpStatus.BAD_REQUEST) with a clear
message). Keep the rest of the logic the same, passing the extracted userId and
a PageRequest.of(page, size, Sort.by("id").descending()) to the service and
mapping results to OrderResponse.

Comment on lines +19 to +28
@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);
}
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

입력값 검증(수량/가격/심볼) 추가 필요.

Broker 호출 전 검증으로 예측 가능한 오류를 조기에 차단하세요. MARKET/LIMIT 규칙도 강제하세요.

     @Transactional
     public Order placeBuy(PlaceOrderRequest req){
+        validate(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 placeSell(PlaceOrderRequest req){
+        validate(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);
     }
@@
+    private void validate(PlaceOrderRequest req) {
+        if (req.getUserId() == null) throw new IllegalArgumentException("userId is required");
+        if (req.getSymbol() == null || req.getSymbol().isBlank()) throw new IllegalArgumentException("symbol is required");
+        if (req.getQuantity() == null || req.getQuantity().compareTo(java.math.BigDecimal.ZERO) <= 0)
+            throw new IllegalArgumentException("quantity must be positive");
+        switch (req.getOrderType()) {
+            case MARKET -> {
+                if (req.getPrice() != null) throw new IllegalArgumentException("price must be null for MARKET");
+            }
+            case LIMIT -> {
+                if (req.getPrice() == null || req.getPrice().compareTo(java.math.BigDecimal.ZERO) <= 0)
+                    throw new IllegalArgumentException("price must be positive for LIMIT");
+            }
+        }
+    }

Also applies to: 30-39

🤖 Prompt for AI Agents
In src/main/java/trademill/apiserver/order/OrderService.java around lines 19-28
(also apply same changes for lines 30-39), the method places orders without
validating request fields; add pre-checks for req.getSymbol() (non-null, matches
allowed pattern/list), req.getQuantity() (positive, within min/max limits), and
for LIMIT orders validate req.getPrice() (non-null, >0 and within allowed
bounds); enforce that MARKET orders do not accept a price and LIMIT orders
require one; if validation fails throw a clear custom/appropriate exception
(e.g., InvalidOrderException or IllegalArgumentException) before calling broker,
and return consistent error/status handling so broker is only invoked with
validated inputs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants