Spring Boot 기반 개인 투자자 트랜드 분석 서비스 개발기

sein lee·2025년 2월 18일
0

project

목록 보기
5/7
post-thumbnail

개인투자자들의 트랜드 분석 서비스

개요

개별 투자자들의 투자 트랜드를 분석하여 "최근 개인이 많이 매수한 종목, 매매동향, 관심종목, 관련 뉴스 등"을 보여주는 서비스

주요 기능

  1. 최근 1주일/1개월 개인 투자자 순매수 상위 종목 조회
  2. 기관 & 외국인 투자잦와 비교하여 매매 동향 분석
  3. AI 기반 투자자 관심 종목 추천
  4. 관심종목 저장 및 조회
  5. 사용자의 관심 종목 기반 뉴스 크롤링

활용 데이터

한국 거래소 - 투자자별 매매동향 , 네이버 금융 크롤링

개발 스택

  • 언어: Java 17+
  • 프레임워크: Spring Boot 3.x
  • 데이터베이스: MySQL
  • API 호출: RestTemplate 또는 WebClient (KRX 데이터 가져오기)
  • 배포: 로컬 서버 실행

개발

프로젝트 초기 설정

Spring Boot를 사용한 RESTful API 형태로 개발 진행

  1. 프로젝트 생성 및 세팅

vscode 에서 프로젝트 열고 src/main/resources/application.properties 수정

# MySQL 데이터베이스 설정
spring.datasource.url=jdbc:mysql://localhost:3306/stock_trend?useSSL=false&serverTimezone=Asia/Seoul
spring.datasource.username=root
spring.datasource.password=yourpassword
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

# JPA 설정
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.database-platform=org.hibernate.dialect.MySQL8Dialect

mysql 접속 후

CREATE DATABASE stock_trend;

터미널에

.\mvnw.cmd spring-boot:run

test 코드

<Controller/TrendContrller.java>

package com.example.personal_investor_trend;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/api/trend")
public class TrendController {

    @GetMapping("/top-buyers")
    public List<Map<String, String>> getTopBuyers() {
        return List.of(
                Map.of("ticker", "005930", "name", "삼성전자", "net_buy", "150억"),
                Map.of("ticker", "000660", "name", "SK하이닉스", "net_buy", "120억")
        );
    }

    @GetMapping("/comparison")
    public Map<String, String> getComparison() {
        return Map.of(
                "개인 투자자", "삼성전자 +150억",
                "기관 투자자", "삼성전자 -80억",
                "외국인 투자자", "삼성전자 -70억"
        );
    }
}

KRX 데이터 가져오기 & 저장하기

1. KRX 투자자별 매매 동향 데이터 가져오기

  • http://openapi.krx.co.kr/contents/OPP/MAIN/main/index.cmd 에서 인증키 받고 원하는 데이터 api 신청

  • <KrxService.java>

          package com.example.personal_investor_trend.service;
    
           import org.springframework.http.*;
           import org.springframework.stereotype.Service;
           import org.springframework.web.client.RestTemplate;
    
           import java.util.Collections;
           import java.util.Map;
    
           @Service
           public class KrxService {
    
               private final RestTemplate restTemplate = new RestTemplate();
               private static final String API_URL = "http://data-dbg.krx.co.kr/svc/apis/sto/ksq_bydd_trd"; // KRX OpenAPI URL
    		public String getKrxData(String basDd) {
                   //요청 데이터(JSON)
                   Map<String, String> requestBody = Collections.singletonMap("basDd", basDd);
    
                   //HTTP 헤더
                   HttpHeaders headers = new HttpHeaders();
                   headers.setContentType(MediaType.APPLICATION_JSON);
                    headers.set("Authorization", "Bearer API인증키");
    
                   //HTTP 요청 객체 
                   HttpEntity<Map<String, String>> requestEntity = new HttpEntity<>(requestBody, headers);
    
                   //API 호출
                   ResponseEntity<String> response = restTemplate.exchange(API_URL, HttpMethod.POST, requestEntity, String.class);
    
                   return response.getBody();
    
               }
           }
    
  • <TrendController.java>

package com.example.personal_investor_trend.controller;

import org.springframework.web.bind.annotation.*;

import com.example.personal_investor_trend.service.KrxService;


import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/api/trend")
public class TrendController {

   private final KrxService krxService;

   public TrendController(KrxService krxService){
    this.krxService = krxService;
   }

   @PostMapping("/krx")
    public String getKrxData(@RequestBody Map<String, String> request) {
        return krxService.getKrxData(request.get("basDd"));
    }
}
  • 결과 확인

2. 데이터 적재

<StockTransaction.java>

package com.example.personal_investor_trend.model;

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

@Entity
@Getter
@Setter
@NoArgsConstructor
@Table(name = "stock_transactions")
public class StockTransaction {

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

    private String basDd;   // 기준일자
    private String isuCd;   // 종목코드
    private String isuNm;   // 종목명
    private String mktNm;   // 시장 구분
    private Long accTrdVol; // 거래량
    private Long mktCap;    // 시가총액
}

<StockTransactionRepository.java>

package com.example.personal_investor_trend.repository;

import com.example.personal_investor_trend.model.StockTransaction;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface StockTransactionRepository extends JpaRepository<StockTransaction, Long> {
    List<StockTransaction> findByBasDd(String basDd);
}

<KrxService.java>

package com.example.personal_investor_trend.service;

import com.example.personal_investor_trend.model.StockTransaction;
import com.example.personal_investor_trend.repository.StockTransactionRepository;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.transaction.annotation.Transactional;

import java.util.Collections;
import java.util.List;
import java.util.Map;

@Service
public class KrxService {

    private final RestTemplate restTemplate = new RestTemplate();
    private static final String API_URL = "http://data-dbg.krx.co.kr/svc/apis/sto/ksq_bydd_trd"; // KRX OpenAPI URL

    private final StockTransactionRepository repository;

    public KrxService(StockTransactionRepository repository){
        this.repository = repository;
    }

    public String getKrxData(String basDd) {
        //요청 데이터(JSON)
        Map<String, String> requestBody = Collections.singletonMap("basDd", basDd);

        //HTTP 헤더
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);
         headers.set("Authorization", "Bearer D6274193FC6A4043B50121146C47226E67C50DB5");

        //HTTP 요청 객체 
        HttpEntity<Map<String, String>> requestEntity = new HttpEntity<>(requestBody, headers);

        //API 호출
        ResponseEntity<String> response = restTemplate.exchange(API_URL, HttpMethod.POST, requestEntity, String.class);

        // 데이터 저장 로직 실행
        return saveKrxData(response.getBody(), basDd);
        
    }

    @Transactional
    public String saveKrxData(String jsonResponse, String basDd){
        try{
            ObjectMapper objectMapper = new ObjectMapper();
            JsonNode rootNode = objectMapper.readTree(jsonResponse).get("OutBlock_1");

            for (JsonNode node: rootNode){
                StockTransaction transaction = new StockTransaction();
                transaction.setBasDd(basDd);
                // transaction.setBasDd(node.get("BAS_DD").asText());
                transaction.setIsuCd(node.get("ISU_CD").asText());
                transaction.setIsuNm(node.get("ISU_NM").asText());
                transaction.setMktNm(node.get("MKT_NM").asText());
                transaction.setAccTrdVol(node.get("ACC_TRDVOL").asLong());
                transaction.setMktCap(node.get("MKTCAP").asLong());

                repository.save(transaction);
            }
            return "🍩🍩🍩 KRX 데이터 저장 완료";
        }catch (Exception e) {
            e.printStackTrace();
            return "💥💥💥 KRX 데이터 저장 실패!";
        }
    }
}

<TrenController.java>

package com.example.personal_investor_trend.controller;

import com.example.personal_investor_trend.model.StockTransaction;
import com.example.personal_investor_trend.repository.StockTransactionRepository;
import com.example.personal_investor_trend.service.KrxService;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/api/trend")
public class TrendController {

   private final KrxService krxService;
   private final StockTransactionRepository repository;

   public TrendController(KrxService krxService, StockTransactionRepository repository){
    this.krxService = krxService;
    this.repository = repository;
   }

   @PostMapping("/krx")
    public String getKrxData(@RequestBody Map<String, String> request) {
        return krxService.getKrxData(request.get("basDd"));
    }

    @GetMapping("/stored-data")
    public List<StockTransaction> getStoredData(@RequestParam String basDd){
        return repository.findByBasDd(basDd);
    }
}
  • 결과


부가 기능 개발

1. 최근 1주일/1개월 개인 투자자 순매수 상위 종목 조회

엔드포인트 : /api/trend/top-buyers
HTTP 메서드 : GET
요청 파라미터 : period = week / month (type:String)

  • <TrendController.java> - /top-buyers 추가
@GetMapping("/top-buyers")
    public List<Map<String, Object>> getTopBuyers(@RequestParam String period) {
        // 조회 기간 설정 (7일 or 30일)
        String startDate = period.equals("week") 
                ? LocalDate.now().minusDays(7).toString()
                : LocalDate.now().minusDays(30).toString();

        // JPA Repository 호출
        List<Object[]> results = repository.findTopBuyers(startDate);

        // 응답 데이터를 JSON 형태로 가공
        return results.stream().map(result -> Map.of(
                "종목코드", result[0],
                "종목명", result[1],
                "순매수량", result[2]
        )).toList();
    }
  • <StockTransactionRepository.java> - 상위 10개 조회 및 findTopBuyers 정의
/ 최근 7일 또는 30일 동안 개인 투자자 순매수 상위 10개 조회
    @Query("SELECT s.isuCd, s.isuNm, SUM(s.accTrdVol) AS totalTrdVol " +
           "FROM StockTransaction s " +
           "WHERE s.basDd >= :startDate " +
           "GROUP BY s.isuCd, s.isuNm " +
           "ORDER BY totalTrdVol DESC")
           
    List<Object[]> findTopBuyers(@Param("startDate") String startDate);
  • 결과

2. 기관 & 외국인 투자자와 비교하여 매매 동향 분석

  • 최근 7일 / 30일 기준으로 동안 개인 vs 기관 vs 외국인의 매수/매도 흐름을 비교
    엔드포인트 : /api/trend/comparison
    HTTP 메서드 : GET
    요청 파라미터 : period = week / month (type:String)
  • 기관(instTrdVol) & 외국인(foreignTrdVol) 거래량 컬럼 생성
private Long instTrdVol; // 기관 거래량 
private Long foreignTrdVol; // 외국인 거래량 
  • 기관(instTrdVol) & 외국인(foreignTrdVol) 거래량 저장
transaction.setInstTrdVol(node.get("INST_TRDVOL").asLong()); 
transaction.setForeignTrdVol(node.get("FOREIGN_TRDVOL").asLong()); 
  • 기관 & 외국인과 비교하여 매매 동향 조회하는 쿼리 생성
@Query("SELECT s.isuCd, s.isuNm, SUM(s.accTrdVol) AS 개인, SUM(s.instTrdVol) AS 기관, SUM(s.foreignTrdVol) AS 외국인 " +
       "FROM StockTransaction s " +
       "WHERE s.basDd >= :startDate " +
       "GROUP BY s.isuCd, s.isuNm")
List<Object[]> compareInvestorTrends(@Param("startDate") String startDate);
  • 엔드포인트 추가
@GetMapping("/comparison")
    public List<Map<String, Object>> getComparison(@RequestParam String period){
        String startDate = period.equals("week") 
                ? LocalDate.now().minusDays(7).toString()
                : LocalDate.now().minusDays(30).toString();
        
        List<Object[]> results = repository.compareInvestorTrends(startDate);

        return results.stream().map(result -> Map.of(
            "종목코드", result[0],
            "종목명", result[1],
            "개인 순매수량", result[2],
            "기관 순매수량", result[3],
            "외국인 순매수량", result[4]
        )).toList();
    }
  • 결과

3. AI 기반 투자자 관심 종목 추천

최근 1개월 동안 개인 투자자가 많이 매수한 종목을 분석하여 비슷한 매매 패턴을 보이는 종목을 찾아 연관 종목 추천하는 기능

  • 단순 추천 로직을 사용해 기본적인 종목 추천 기능 개발
  • 추후 AI 모델을 추가할 수 있도록 확장 가능하게 설계
  • 최근 1개월 개인 순매수 상위10개 조회
@Query("SELECT s.isuCd, s.isuNm, SUM(s.accTrdVol) AS totalTrdVol " +
       "FROM StockTransaction s " +
       "WHERE s.basDd >= :startDate " +
       "GROUP BY s.isuCd, s.isuNm " +
       "ORDER BY totalTrdVol DESC")
        List<Object[]> findTopPersonalStocks(@Param("startDate") String startDate, Pageable pageable);
* 처음에 LIMIT 10 으로 쿼리 줬다니 ERROR -> JPA/HQL 에서는 LIMIT 은 직접 사용이 불가, 대신 Spring Data JPA의 Pageable 사용
  • RecommendationService 생성
package com.example.personal_investor_trend.service;

import com.example.personal_investor_trend.repository.StockTransactionRepository;
import org.springframework.stereotype.Service;
import org.springframework.data.domain.PageRequest; 

import java.time.LocalDate;
import java.util.*;

@Service
public class RecommendationService {

    private final StockTransactionRepository repository;

    public RecommendationService(StockTransactionRepository repository) {
        this.repository = repository;
    }

    public List<Map<String, String>> getRecommendedStocks() {
        String startDate = LocalDate.now().minusDays(30).toString();

        List<Object[]> topStocks = repository.findTopPersonalStocks(startDate, PageRequest.of(0, 10));

        // 연관 종목 ex
        Map<String, String> similarStocks = Map.of(
            "090710", "056080",  
            "027040", "080220",  
            "466100", "056080",  
            "452450", "108860"  
        );

        List<Map<String, String>> recommendations = new ArrayList<>();

        for (Object[] stock : topStocks) {
            String isuCd = (String) stock[0];
            String isuNm = (String) stock[1];

            if (similarStocks.containsKey(isuCd)) {
                recommendations.add(Map.of(
                    "추천 종목코드", similarStocks.get(isuCd),
                    "추천 종목명", getStockName(similarStocks.get(isuCd)),
                    "추천 이유", isuNm + "과 비슷한 매매 패턴을 보임"
                ));
            }
        }

        return recommendations;
    }

    private String getStockName(String isuCd) {
        // 종목 코드 → 종목명 매핑 (추후 DB 조회 가능)
        Map<String, String> stockNames = Map.of(
            "090710", "휴림로봇",
            "056080", "유진로봇",
            "027040", "서울전자통신",
            "080220", "제주반도체",
            "466100", "클로봇",
            "108860", "셀바스AI",
            "452450", "피아이이"
        );
        return stockNames.getOrDefault(isuCd, "알 수 없음");
    }
}
  • 엔드포인트 생성
@GetMapping("/recommendations")
    public List<Map<String, String>> getRecommendations(){
        return RecommendationService.getRecommendedStocks();
    }
  • 결과

4. 관심종목 저장 및 조회

  • 사용자가 관심있는 종목을 선택해서 저장
    엔드포인트 : /api/user/watchlist
    HTTP 메서드 : POST
    Req Body : {
    "userId": 1,
    "stockCode": "005930",
    "stockName": "삼성전자"
    }
    Res : {"message": "삼성전자 (005930) 관심 종목으로 저장 완료!"}
  • 사용자 괌심종목 테이블 생성
package com.example.personal_investor_trend.model;

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

@Entity
@Getter
@Setter
@NoArgsConstructor
@Table(name = "user_watchlist")
public class UserWatchlist {

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

    private Long userId;  // 사용자 ID
    private String stockCode;  // 종목 코드
    private String stockName;  // 종목명
}
  • Repository 생성
package com.example.personal_investor_trend.repository;

import com.example.personal_investor_trend.model.UserWatchlist;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface UserWatchlistRepository extends JpaRepository<UserWatchlist, Long> {
    List<UserWatchlist> findByUserId(Long userId);
}
  • Service 생성
package com.example.personal_investor_trend.service;

import com.example.personal_investor_trend.model.UserWatchlist;
import com.example.personal_investor_trend.repository.UserWatchlistRepository;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class UserWatchlistService {

    private final UserWatchlistRepository repository;

    public UserWatchlistService(UserWatchlistRepository repository) {
        this.repository = repository;
    }

    public UserWatchlist addWatchlist(Long userId, String stockCode, String stockName) {
        UserWatchlist watchlist = new UserWatchlist();
        watchlist.setUserId(userId);
        watchlist.setStockCode(stockCode);
        watchlist.setStockName(stockName);
        return repository.save(watchlist);
    }

    public List<UserWatchlist> getUserWatchlist(Long userId) {
        return repository.findByUserId(userId);
    }
}
  • Controller 생성
package com.example.personal_investor_trend.controller;

import com.example.personal_investor_trend.model.UserWatchlist;
import com.example.personal_investor_trend.service.UserWatchlistService;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/api/user")
public class UserController {

    private final UserWatchlistService watchlistService;

    public UserController(UserWatchlistService watchlistService) {
        this.watchlistService = watchlistService;
    }

    // 관심 종목 저장 (POST)
    @PostMapping("/watchlist")
    public Map<String, String> addWatchlist(@RequestBody Map<String, String> request) {
        Long userId = Long.parseLong(request.get("userId"));
        String stockCode = request.get("stockCode");
        String stockName = request.get("stockName");

        watchlistService.addWatchlist(userId, stockCode, stockName);
        return Map.of("message", stockName + " (" + stockCode + ") 관심 종목으로 저장 완료!");
    }

    // 사용자의 관심 종목 조회 (GET)
    @GetMapping("/watchlist")
    public List<UserWatchlist> getUserWatchlist(@RequestParam Long userId) {
        return watchlistService.getUserWatchlist(userId);
    }
}
  • 결과
    • 관심종목 저장
    • 데이터 적재
    • 관심종목 조회

5. 사용자의 관심 종목 기반 뉴스 크롤링 - 웹크롤링 설정 필요

  • 사용자의 관심 종목(user_watchlist 테이블)에서 stockCode 목록을 가져와 뉴스 크롤링
    엔드포인트 :
    HTTP 메서드 : POST
  • repository
List<UserWatchlist> findStockCodeByUserId(Long userId);
  • <pom.xml> - dependency 추가
<dependency>
  <groupId>org.jsoup</groupId>
  <artifactId>jsoup</artifactId>
  <version>1.16.1</version>
</dependency>
  • <NewsService.java>
package com.example.personal_investor_trend.service;

import com.example.personal_investor_trend.model.UserWatchList;
import com.example.personal_investor_trend.repository.UserWatchListRepository;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import org.springframework.stereotype.Service;

import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.*;

@Service
public class NewsService {

    private final UserWatchListRepository watchlistRepository;

    public NewsService(UserWatchListRepository watchlistRepository) {
        this.watchlistRepository = watchlistRepository;
    }

    public List<Map<String, Object>> getNewsForUser(Long userId) {
        List<UserWatchList> watchlist = watchlistRepository.findByUserId(userId);
        List<Map<String, Object>> newsData = new ArrayList<>();

        for (UserWatchList stock : watchlist) {
            String stockCode = stock.getStockCode();
            String stockName = stock.getStockName();

            List<Map<String, String>> newsList = getStockNews(stockName);

            newsData.add(Map.of("종목코드", stockCode, "종목명", stockName, "관련 뉴스", newsList));
        }

        return newsData;
    }

    // 크롤링 메서드
    private List<Map<String, String>> getStockNews(String stockName) {
        List<Map<String, String>> newsList = new ArrayList<>();
        try {
            String encodedQuery = URLEncoder.encode(stockName, StandardCharsets.UTF_8);
            //네이버 뉴스 
            String url = "https://search.naver.com/search.naver?where=news&ie=utf8&sm=nws_hty&query=" + encodedQuery;

            Document doc = Jsoup.connect(url).get();
            Elements newsElements = doc.select(".list_news .news_area"); 

            for (Element news : newsElements) {
                Element titleElement = news.selectFirst(".news_tit"); //제목
                if (titleElement == null) continue;

                String title = titleElement.text();
                String link = titleElement.attr("href");

                newsList.add(Map.of("제목", title, "링크", link));

                if (newsList.size() >= 5) break; // 최대 5개 
            }
        } catch (IOException e) {
            e.printStackTrace();
        }

        return newsList;
    }
}
  • <NewsController.java>
package com.example.personal_investor_trend.controller;

import com.example.personal_investor_trend.service.NewsService;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/api/user")
public class NewsController {

    private final NewsService newsService;

    public NewsController(NewsService newsService) {
        this.newsService = newsService;
    }

    // 사용자 관심 종목 뉴스 조회 
    @PostMapping("/news")
    public List<Map<String, Object>> getUserNews(@RequestBody Map<String, String> request) {
        Long userId = Long.parseLong(request.get("userId"));
        return newsService.getNewsForUser(userId);
    }
}
  • 결과

  • 한국거래소(KRX)의 OpenAPI데이터를 활용해서 REST API 호출
  • JSOUP -> 실시간 웹 크롤링 사용

위의 두가지를 제일 신경 쓴 간단한 프로젝트였다!

추후에 딥하게 들어간다면 실시간으로 주식 시세나 알림을 사용자에게 제공하고 AI기반으로 투자를 추천해준다거나 더 많은 공공데이터를 함께 사용해서 종목에 대한 분석을 강화할 수 있지 않을까?!

profile
개발감자

0개의 댓글

관련 채용 정보