[quantHelper] 개발 - API3

JUJU·2024년 4월 14일
0

프로젝트

목록 보기
7/26

■ 구현 내용

  1. KisAccessToken 클래스
@Component
@Slf4j
@Getter
public class KisAccessToken {
    private String accessToken;

    /**
     * 한국 투자 증권 API에 접근하기 위한 Access Token를 받아와서 빈 내부에 저장
     * webClient 빈, baseUrl, appKey, appSecretKey는 스프링이 주입해 준다.
     * @return String AccessToken
     */
    public KisAccessToken(WebClient webClient,
                          @Value("${spring.kis-api.endpoint-url}") String baseUrl,
                          @Value("${spring.kis-api.app-key}")String appKey,
                          @Value("${spring.kis-api.app-secret-key}") String appSecretKey) 
                          throws JsonProcessingException {
        Map<String, String> bodyMap = new HashMap<>();
        bodyMap.put("grant_type", "client_credentials");
        bodyMap.put("appkey", appKey);
        bodyMap.put("appsecret", appSecretKey);

        String fullUrl = baseUrl + "/oauth2/tokenP"; // 호스트와 경로를 조합
        Mono<String> monoAccess = webClient.post()
                .uri(fullUrl) // 전체 URL을 명시적으로 지정
                .contentType(MediaType.APPLICATION_JSON)
                .body(BodyInserters.fromValue(bodyMap))
                .retrieve()
                .bodyToMono(String.class);

        String accessTokenJson = monoAccess.block();
        ObjectMapper objectMapper = new ObjectMapper();
        Map<String, String> AccessTokenMap = objectMapper.readValue(accessTokenJson, Map.class);
        accessToken = AccessTokenMap.get("access_token");
        log.info(accessToken);
    }
}

  1. StockPriceService, StockPriceRepository, StockPriceDTO, StockPrice 클래스
// StockPrice 클래스
// 나머지 클래스는 생략
@Entity
@Getter
public class StockPrice {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "stock_price_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "stockId", referencedColumnName = "stockId")
    private Stock stock;
    private LocalDate date;
    private Long maxPriceDay;
    private Long minPriceDay;
    private Long openPrice;
    private Long closePrice;
    private Long tradingVolume;

    @Builder
    public StockPrice(LocalDate date, Long maxPriceDay, Long minPriceDay, Long openPrice, Long closePrice, Long tradingVolume) {
        this.date = date;
        this.maxPriceDay = maxPriceDay;
        this.minPriceDay = minPriceDay;
        this.openPrice = openPrice;
        this.closePrice = closePrice;
        this.tradingVolume = tradingVolume;
    }

    public StockPrice() {
    }

    public void changeStock(Stock stock){
        this.stock = stock;
        stock.getStockPriceList().add(this);
    }
}

  1. GetStockPriceRequest 클래스
    • 요청 파라미터를 바인딩하기 위한 클래스이다.
    • 요청 파라미터의 이름과 클래스의 필드 명이 일치해야 한다.
    • 반드시 기본 생성자가 존재해야 한다.
@Getter
@Setter
@NoArgsConstructor
public class GetStockPriceRequest {
    @NotBlank(message = "{not_blank}")
    @Schema(
            name = "stockName",
            description = "stock name",
            type = "String",
            requiredMode = Schema.RequiredMode.REQUIRED,
            example = "SK하이닉스"
    )
    private String stockName;

    @NotBlank(message = "{not_blank}")
    @Schema(
            name = "startDate",
            description = "가격 조회 시작 일자",
            type = "String",
            requiredMode = Schema.RequiredMode.REQUIRED,
            example = "20240415"
    )
    private String startDate;

    @NotBlank(message = "{not_blank}")
    @Schema(
            name = "endDate",
            description = "가격 조회 종료 일자",
            type = "String",
            requiredMode = Schema.RequiredMode.REQUIRED,
            example = "20240415"
    )
    private String endDate;

}

  1. StockController의 stockPrice 메소드
    • 주식 이름, 조회할 시작 날짜, 조회할 끝 날짜를 쿼리로 전달하면 해당 날짜에 맞는 주식의 가격을 DB에 저장하는 메소드이다.
    • 원래는 시작~종료 기간의 모든 주식 가격 정보를 가져올 수 있어야 하지만, 이 메소드에서는 특정한 날의 가격 정보만 가져올 수 있게 제한했다.
      예를 들어, 시작날짜가 20240415면, 종료날짜도 반드시 20240415를 입력해야 잘 작동한다.
public ResponseEntity<StockPriceDTO> stockPrice(@ModelAttribute GetStockPriceRequest request) throws JsonProcessingException {
        StockDTO byStockName = stockService.findByStockName(request.getStockName());
        String stockCode = byStockName.getStockCode();
        if (stockCode.length() < 6){
            System.out.println("stock code should be longer than 6");
            return ResponseEntity.badRequest().body(null);
        }
        Mono<String> stockPriceByCodeAndDate = kisService.getStockPriceByCodeAndDate(stockCode, request.getStartDate(), request.getEndDate()); // 주식 코드로 주식 정보 조회
        String stockPriceResponse = stockPriceByCodeAndDate.block();

        ObjectMapper objectMapper = new ObjectMapper();
        List<Map<String,String>> stockPriceJson = (List<Map<String, String>>) objectMapper.readValue(stockPriceResponse, Map.class).get("output2");

        Map<String, String> stockPriceMap = stockPriceJson.get(0);


        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMdd");

        StockPriceDTO stockPriceDTO = StockPriceDTO.builder()
                .date(LocalDate.parse(request.getStartDate(), formatter))
                .stockId(stockService.findStockIdByStockCode(stockCode))
                .maxPriceDay(Long.valueOf(stockPriceMap.get("stck_hgpr")))
                .minPriceDay(Long.valueOf(stockPriceMap.get("stck_lwpr")))
                .openPrice(Long.valueOf(stockPriceMap.get("stck_oprc")))
                .closePrice(Long.valueOf(stockPriceMap.get("stck_clpr")))
                .tradingVolume(Long.valueOf(stockPriceMap.get("acml_vol"))).
                build();

        stockPriceService.saveStockPrice(stockPriceDTO);
        return ResponseEntity.ok().body(stockPriceDTO);
    }

응답받는 JSON의 형태가 복잡했다.
startDate와 endDate를 다르게 준 경우 이런식으로 응답이 왔다.
물론, 위의 프로젝트에서는 startDate와 endDate가 같아야 한다.

{
    "output1": {
        "prdy_vrss": "-2000",
        "prdy_vrss_sign": "5",
        "prdy_ctrt": "-2.39",
    },
    "output2": [
        {
            "stck_bsop_date": "20240402",
            "stck_clpr": "85000",
            "stck_oprc": "82900",
            "stck_hgpr": "85000",
        },
        {
            "stck_bsop_date": "20240401",
            "stck_clpr": "82000",
            "stck_oprc": "83200",
        }
    ],
    "rt_cd": "0",
    "msg_cd": "MCA00000",
    "msg1": "정상처리 되었습니다."
}

필요한 것은 output2 내부에 있는 정보였기 때문에, ObjectMapper를 사용해서 key:output2에 해당되는 value를 ArrayList<Map<String,String>> 형식으로 받아왔다.




■ 배운점

  1. AccessToken 같이, 어플리케이션을 동작시키기 위해 반드시 필요한 요소는 스프링 빈으로 만들어서 실행과 동시에 저장하도록 하자.

  2. 스프링 빈에서 다른 빈을 의존하는 경우, 생성자의 매개변수를 통해 의존관계를 주입받아야 한다.
    AccessToken에서 baseUrl, appKey, appSecretKey를 주입받아야 했다.
    이 값들은 application-dev.yml에 저장되어 있기 때문에 스프링을 사용해서 의존관계를 주입받아야 했다.

@Component
public class KisAccessToken {
	@Value("${spring.kis-api.endpoint-url}") String baseUrl,
	@Value("${spring.kis-api.app-key}")String appKey,
	@Value("${spring.kis-api.app-secret-key}") String appSecretKey
...

처음에는 위와 같은 방식으로 필드에서 주입받으려 했다. 하지만, KisAccessToken 빈이 생성될 때는 "생성자"를 통해서 만들어진다. 생성자를 실행할 때 저 값들을 주입해주지 않고, 생성자 내부에서 저 필드들을 사용한다면 당연히 NullPointerException이 터질 것이다.
따라서, 생성자의 매개변수로 설정하여 의존관계를 주입받아야 한다.

public class KisAccessToken {
    private String accessToken;

    public KisAccessToken(WebClient webClient,
                          @Value("${spring.kis-api.endpoint-url}") String baseUrl,
                          @Value("${spring.kis-api.app-key}")String appKey,
                          @Value("${spring.kis-api.app-secret-key}") String appSecretKey) throws JsonProcessingException {
			...

  1. Controller의 쿼리 파라미터, 즉 클라이언트와 연결되는 Web계층의 인터페이스는 직관적이어야 한다.
    StockCode를 알고 있는 사람보다는 StockName을 알고 있는 사람이 많다.
    SK하이닉스의 종목 번호가 000660인 것을 아는사람은 얼마 없을 것이다.
    따라서, API를 호출할 때 StockCode가 필요하다고 해도, 클라이언트로 부터는 StockName을 받아야 한다. 서버 내부에서 StockName으로 StockCode를 조회해야 API를 호출하도록 해야 한다.



■ 오류

  • Error creating bean with name 'entityManagerFactory' defined in class path resource

    해결: @GeneratedValue(strategy = GenerationType.IDENTITY)
    postgresql은 sequence 객체를 사용해서 id 자동 증가를 구현한다.
    따라서, strategy = GenerationType.AUTO 말고, GenerationType.IDENTITY를 사용해줘야 한다.

  • query did not return a unique result: 2 results were returned
    말 그대로 중복되는 값이 있을 때 발생하는 오류이다.
    findByStockName() 메소드를 호출 했는데, 똑같은 이름이 2개 존재한다면 위와 같은 오류가 발생한다.

    해결: findByStockName()의 리턴 타입을 ArrayList로 변경한다. 또는 DB에서 중복되는 값을 찾아서 하나를 삭제한다.

  • ModelAttribute 바인딩 오류

    해결: "파라미터 키 이름" == "바인딩하는 객체의 필드 이름"
    예시) 파라미터가 stockName=SK&startDate=20240415로 넘어옴
    그럼, 바인딩 할 객체에서 필드 이름은 stockName과 startDate로 존재해야함.

update 상황 발생

python을 활용해서 DB에 미리 stockId, stockCode, stockName, corpCode를 저장해두었다.

Stock 엔티티에 존재하는 필드는 추가로 몇가지 컬럼이 더 존재한다.
만약, StockController에서 StockName으로 증권사 서버에서 정보를 받아온다면?

1. stockName으로 stockCode를 받아옴
2. 해당 stockCode로 API를 날림
3. API 결과를 DTO에 담음.
4. DTO를 Entity로 변환해서 DB에 저장

여기서 문제가 발생한다.
똑같은 주식 정보가 2개 생겨버리는 것이다.
DTO를 새로 생성해서 저장하면, id 값만 다르고 나머지 값은 똑같은 레코드가 하나 더 생겨버린다.
따라서, stockId, stockCode, stockName, corpCode은 냅두고 나머지 컬럼만 추가하는 update 쿼리를 보낼 필요가 있다.

//StockRepository에 다음과 같은 메소드를 추가한다.
@Modifying
    @Query("UPDATE Stock s SET s.stockCode = :stockCode, s.stockPriceIndex = :stockPriceIndex, s.price = :price, s.theme = :theme, s.status = :status, s.corpCode = :corpCode WHERE s.stockName = :stockName")
    int updateStockByStockName(@Param("stockName") String stockName,
                               @Param("stockCode") String stockCode,
                               @Param("stockPriceIndex") String stockPriceIndex,
                               @Param("price") Long price,
                               @Param("theme") String theme,
                               @Param("status") String status,
                               @Param("corpCode") String corpCode);

위의 메소드를 사용하면, stockName으로 레코드를 하나 가져와서 거기에 새로운 값을 넣고 update 쿼리를 보낸다.

내일 할것:

stockService 버그 수정.
ArrayList<>
2개 조회되는 버그

참고할만 한 사이트

profile
개발자 지망생

0개의 댓글

관련 채용 정보