Spring과 Firebase를 이용한 주식 알리미 구현

스프링 | 2021.02.11 19:22

작년 코로나19 이후 주식시장은 이제껏 겪어보지못한 요동을 겪었고 우리같은 서민들에겐 또 하나의 가능성을 확인할 수 있는 계기가 되었던거 같다.
주식은 개인적으로 할 얘기도 많지만 그 보단 금융투자를 역사적인 관점에서 접근하면 더 재미를 느낄 수 있다고 본다.
주식에 관한 얘기는 다음에 써보기로 하고 오늘은 주식 단타에 도움을 줄 수있는 프로그램을 구현해보자.

전체적인 흐름은 아래와 같다.

1. Server에서 장중 특정시간별로 현재가, 거래량을 수집한다 (웹크롤링)
2. 수집한 정보는 DB로 저장하며 지정된 패턴에 매칭되는지 연산한다 (패턴 매칭 - 업무로직)
3. 매칭이 되면 Server에서 FCM(Firebase Console Messaging) Server로 push 문자열을 전송한다 (FCM)
4. FCM Server에서 App으로 push 메시지를 발송한다 (push)
 

1. 웹크롤링
Spring에서 웹 크롤링을 위한 라이브러리로 jsoup (https://jsoup.org/) 를 많이 사용한다.
pom.xml에서 아래를 추가한다.

<!-- https://mvnrepository.com/artifact/org.jsoup/jsoup -->
<dependency>
    <groupId>org.jsoup</groupId>
    <artifactId>jsoup</artifactId>
    <version>1.13.1</version>
</dependency>

일반적으로 정적 html 크롤링엔 jsoup를 사용하고 동적 html 크롤링엔 Selenium이 많이 쓰인다.
Selenium의 경우 동적인(ajax 등) 처리 후 결과 html을 가져와 파싱하기 때문에 속도 이슈가 민감한 경우 사용하기가 어려울 수 있다.
네이버 증권의 경우 각 종목별 정적인 html을 획득할 수 있기때문에 jsoup 라이브러리를 사용할 수 있다.

예를들어, 현대차 정보를 가져오려면 클라이언트에서 https://finance.naver.com/item/main.nhn?code=005380 로 요청한 결과값을 받아서 필요한 정보(현재가, 거래량, 거래대금, 시가, 고가..)를 저장할 수 있다.
005380은 현대차의 종목코드이며 코스피, 코스닥의 종목코드를 모두 등록 후 특정 시간마다 웹페이지를 호출해 결과값을 DB에 담으면 자료추출은 완료된다.
웹크롤링 주요 코드는 아래와 같다.

public class StockDaoImpl implements StockDao {
	
	@Autowired
	SqlSessionTemplate sqlSessionTemplate;
	
	private static final Logger logger = LoggerFactory.getLogger(StockDaoImpl.class);
	
	@Override
	public void crollDao() throws Exception {
		// TODO Auto-generated method stub
		
		logger.info(new SimpleDateFormat ( "yyyy.MM.dd HH:mm:ss").format(new Date()) + " : crollDao 스케줄러 실행 ");
		
		// 휴일판단
		String dt = new SimpleDateFormat ( "yyyy-MM-dd" ).format(new Date());
		
		HashMap<String, String> map = new HashMap<String, String>();
		map.put("dt", dt);
		if ( "N".equals( this.sqlSessionTemplate.selectOne("mapper.stockGetHoliday", map) ) ) {
			// 휴일
			
		} else {
			
			// 로그용 문자열
			StringBuffer sb = new StringBuffer();
			
			// 로그기록(시작)
			sb.append( new SimpleDateFormat ( "yyyy.MM.dd HH:mm:ss").format(new Date()) + " : (dt) " + dt + "\n");
			
			String sibun = new SimpleDateFormat ( "HH:mm" ).format(new Date());
			
			// 업체리스트 가져오기
			List<HashMap> cd_list = this.sqlSessionTemplate.selectList("mapper.stockGetCd");
			
			// 차수(MAX값) 구하기
			map = new HashMap<String, String>();
			map.put("dt", dt);
			int new_chasu = ((Integer)this.sqlSessionTemplate.selectOne("mapper.stockGetNewChasu", map)).intValue();
			
			//logger.info("-- new_chasu -- : " + new_chasu);
			sb.append( new SimpleDateFormat ( "yyyy.MM.dd HH:mm:ss").format(new Date()) + " : (new_chasu) " + new_chasu + "\n");
			
			//////////////////////////////////////////////////////////////////////////////////////////////////////////////
			////////////////////////////////////////////// 크롤링 시작 ////////////////////////////////////////////////////
			//////////////////////////////////////////////////////////////////////////////////////////////////////////////
			// 크롤링(loop)
			sb.append( new SimpleDateFormat ( "yyyy.MM.dd HH:mm:ss").format(new Date()) + " : 크롤링 시작 \n");
			
			String Url = "";
			StPerMinDto stPerMinDto = null;
			ArrayList<StPerMinDto> stock_row = new ArrayList<StPerMinDto>();
			
			for ( HashMap m : cd_list ) {
				Url = "http://finance.naver.com/item/main.nhn?code=" + m.get("cd");
				
				Document doc = Jsoup.connect(Url).get();
				Element elem = doc.select(".new_totalinfo .blind").first();
				
				if ( elem == null ) continue;
				
				Elements els = elem.getElementsByTag("dd");
				
				if ( els == null ) continue;
								
				// 전일자
				String info = els.get(4).text();
				String[] arr = info.split(" ");
				String yest_p = UtilStr.removeComma(arr[1]);
				
				// 현재가
				info = els.get(3).text();
				arr = info.split(" ");
				String curr_p = UtilStr.removeComma(arr[1]);
				
				// 거래량
				info = els.get(10).text();
				arr = info.split(" ");
				String trade_c = UtilStr.removeComma(arr[1]);
				
				// 거래대금
				info = els.get(11).text();
				arr = info.split(" ");
				String trade_s = arr[1].replaceAll("[^0-9]", "");
				
				stPerMinDto = new StPerMinDto();
				stPerMinDto.setChasu( new_chasu );
				stPerMinDto.setDt( dt );
				stPerMinDto.setCd( (String)m.get("cd") );
				stPerMinDto.setSibun(sibun);
				stPerMinDto.setYest_p( Integer.parseInt(yest_p) );
				stPerMinDto.setCurr_p( Integer.parseInt(curr_p) );
				stPerMinDto.setTrade_c( Integer.parseInt(trade_c) );
				stPerMinDto.setTrade_s( Integer.parseInt(trade_s) );
				stPerMinDto.setUpdown_p( Integer.parseInt(curr_p)-Integer.parseInt(yest_p) );
				stPerMinDto.setUpdown_r( Integer.parseInt(yest_p) == 0 ? 0 : (float)(Integer.parseInt(curr_p)-Integer.parseInt(yest_p))/Integer.parseInt(yest_p)*100  );
				
				stock_row.add(stPerMinDto);
			}

			sb.append( new SimpleDateFormat ( "yyyy.MM.dd HH:mm:ss").format(new Date()) + " : 크롤링 종료 \n");
			// 로그기록(크롤링 끝)
			//////////////////////////////////////////////////////////////////////////////////////////////////////////////
			////////////////////////////////////////////// 크롤링 끝 ////////////////////////////////////////////////////
			//////////////////////////////////////////////////////////////////////////////////////////////////////////////
			
			// StPerMin insert
			HashMap<String, Object> stock_map = new HashMap<String, Object>();
			stock_map.put("stock_row", stock_row);
			this.sqlSessionTemplate.insert("mapper.stPerMinInsertMulti", stock_map);
			
			//logger.info("-- 크롤링 insert 끝 -- : " + new SimpleDateFormat ( "yyyy.MM.dd HH:mm:ss").format(new Date()) );
			sb.append( new SimpleDateFormat ( "yyyy.MM.dd HH:mm:ss").format(new Date()) + " : db insert 종료 \n");
			
			// 차수가 1 이면 StDaily.siga_p 업데이트
			// 전일 고가-저가 차액이 오늘 시가+차액 큰 경우 매수인데 필요없을거 같음.
			
			// 리포터 생성(차수 6이상인 경우 부터)
			// 조건 : 전 차수보다 10배 거래량 증가 AND 오르면
			if ( new_chasu >= 6 ) {
				//HashMap<String, Integer> chasu_map = new HashMap<String, Integer>();
				//chasu_map.put("chasu", new_chasu);
				//this.sqlSessionTemplate.insert("mapper.stPerMinReport", chasu_map);
				
				this.reportDao(new_chasu);
				sb.append( new SimpleDateFormat ( "yyyy.MM.dd HH:mm:ss").format(new Date()) + " : 리포트 종료 \n");
			}
			
			// push
			List<StReportDto> pDto = getPushInfoDao(dt, new_chasu);
			int push_cnt = 0;
			String push_title = "Stock Alarm";
			String push_msg = null;
			for ( StReportDto dto : pDto ) {
				push_msg = dto.getSibun() + " : [" + dto.getCd() + "] " + dto.getCd_nm() + ", " + 
						UtilStr.addComma( Integer.toString(dto.getCurr_p()) ) + " ("+ dto.getGubun() + ", " + UtilStr.addComma( Integer.toString(dto.getUpdown_p()) ) +")";
				push_cnt++;
				break;
			}
			
			if ( push_cnt > 0 ) {
				List<HashMap> token_list = getTokenDao();
				FCM.sendPush(token_list, push_title, push_msg);
			}
			
			sb.append( new SimpleDateFormat ( "yyyy.MM.dd HH:mm:ss").format(new Date()) + " : push 종료 \n");
			
			// 파일쓰기
			sb.append("------------------------------------------\n");
			StockLogFile.log( sb.toString() );			
		}
		
		logger.info(new SimpleDateFormat ( "yyyy.MM.dd HH:mm:ss").format(new Date()) + " : crollDao 스케줄러 실행 끝 ");
	}
	
	// 이하 생략
}

crollDao는 본 프로젝트의 가장 중요한 코드로 웹 크롤링을 통해 추출된 정보를 DB에 저장하며 특정 추출 패턴에 매칭되는 종목의 정보를 FCM Server에 보내서 app으로 push 하는 코드가 들어있다.
종목별 관리테이블에서 전체 loop를 돌며 target 페이지를 크롤링한다.
종목관리 테이블은 매일밤 Batch 작업을 통해 다음날 장중에 크롤링할지 말지 정한다(투자유의 종목이나 삼성전자같은 종목은 배제한다)
크롤링한 정보는 StPerMinDto 에 담아서 DB에 저장한다.

public class StPerMinDto {
	private int chasu;		// 차수
	private String dt;		// 날짜
	private String cd;		// 종목코드
	private String sibun;	// 시분
	private int yest_p;		// 전일종가
	private int curr_p;		// 현재가
	private int trade_c;	// 거래량
	private int trade_s;	// 거래대금
	private int updown_p;	// 등락액
	private float updown_r;	// 등락율

	// getter, setter 생략
}

실제 매칭될지 여부는 각자의 업무로직에 의해 결정된다.
위 코드는 차수 6 이상일 경우 리포트를 생성하는데 여기서 말하는 차수란 장시작부터 크롤링을 한 회수를 의미한다.
6이상일 경우 리포트를 생성하는 이유는 최소 5회의 데이터를 통해서 앞으로의 값을 예측하기 위함이다.
크롤링 횟수는 job scheduler에 의해 정해진다.

@Component
public class StockScheduler {
	@Autowired
	StockService stockService;
	
	/*
	 * 월~금 09:00 ~ 14:59 까지 3분단위로 실행
	 * 국경일은 dao에서 한번 더 걸러줌.
	 */
	@Scheduled(cron = "0 0/3 9-14 ? * MON-FRI")
	public void getStockCroll() throws Exception {
		this.stockService.croll();
	}
	
	// 이하 생략
}

 

2. 패턴 매칭
이렇게 수집된 데이터를 토대로 가격과 거래량이 특정한 형태를 보인다면 분명 무슨 일이 생겼을 가능성이 있다.
이를테면, 전 차수에 비해 거래량이 20배로 급등했다면 당분간 그 종목을 주시할 필요성이 있는것과 마찬가지다.
이 부분은 프로그램 보다는 업무로직에 해당하는 부분으로 본인의 입맛에 맞게 적용하면 된다.
본인은 다음과 같은 조건에 해당할때 매칭된다.

1) 전일종가보다 상승한 가격이어야 한다.
2) 현재가가 1000원 이상이어야 한다.
3) 상승률이 20% 이하인 종목만 해당된다.
4) 전 차수의 거래량보다 7배 초과 또는 전전차수 거래량의 5배초과, 전차수의 0.2배 초과일때 

<!-- 리포트 -->
<insert id="stPerMinReport" parameterType="java.util.Map">
	<![CDATA[
	insert into stReport (gubun, chasu, dt, cd, sibun, cd_nm, cd_kind, curr_p, trade_1x, trade_2x, trade_3x, updown_p, updown_r)
	select case when t.trade_1x > 7 then 'A'
				when t.trade_1x > 0.2 and t.trade_2x > 5 then 'B'
				else 'X'
				end gubun,
			t.chasu, t.dt, t.cd, t.sibun, t.cd_nm, t.cd_kind, t.curr_p, 
			t.trade_1x, t.trade_2x, t.trade_3x, t.updown_p, t.updown_r
	  from (
			select a.chasu, a.dt, a.cd, a.sibun, b.cd_nm, b.cd_kind, a.curr_p, 
				   if( x.cnt2=0, 0, (x.cnt1-x.cnt2)/x.cnt2 ) trade_1x,
				   if( x.cnt3=0, 0, (x.cnt2-x.cnt3)/x.cnt3 ) trade_2x,
				   if( x.cnt4=0, 0, (x.cnt3-x.cnt4)/x.cnt4 ) trade_3x,
				   a.updown_p, a.updown_r
			  from stPerMin a, stCom b,
				   ( select z.cd, z.aa-z.bb cnt1, z.bb-z.cc cnt2, z.cc-z.dd cnt3, z.dd-z.ee cnt4
					   from (         
							select y.cd, y.chasu,
									y.trade_c aa,
									lag(y.trade_c, 1) over(partition by y.cd order by y.chasu) bb,
									lag(y.trade_c, 2) over(partition by y.cd order by y.chasu) cc,
									lag(y.trade_c, 3) over(partition by y.cd order by y.chasu) dd,
									lag(y.trade_c, 4) over(partition by y.cd order by y.chasu) ee
							  from ( select chasu, cd, trade_c from stPerMin where dt = date_format(now(),'%Y-%m-%d') and chasu between #{chasu}-4 and #{chasu} ) y
							) z
					  where z.chasu= #{chasu}
				  ) x
			where a.cd = b.cd
			  and a.cd = x.cd
			  and a.chasu = #{chasu}
			  and a.dt = date_format(now(),'%Y-%m-%d')
			  and x.cnt1 > 10000
		   ) t
	where t.updown_p > 0
	  and t.curr_p > 1000
	  and t.updown_r < 0.2
	  and ( 
			(t.trade_1x > 7) or 
			(t.trade_1x > 0.2 and t.trade_2x > 5)
		  )
	]]>	
</insert>

 

3. FCM(Firebase Cloud Messaging)

https://console.firebase.google.com/

예전에 GCM(Google Cloud Messaging)에서 한차례 진화한게 FCM이라는데 간단히 얘기하면 서버에서 클라이언트로 메시지를 보내는 것을 FCM 서버에서 대신 해주는것이라 보면 편하다.
이를 위해서는 서비스신청과 이후 몇가지 절차를 거쳐야 하는데 자세한 것은 google에서 fcm push로 검색하면 어렵지않게 구현할 수 있다.

우리는 패턴에서 매칭된 종목정보를 문자열 형태로 push 할것이다.

crollDao 메소드에서 호출한 FCM.sendPush.. 부분이 FCM 서버에 push 할 메시지를 전송하는 부분이다.
FCM 클래스는 아래와 같다.

public class FCM {
	private static final Logger logger = LoggerFactory.getLogger(FCM.class);

	public static void sendPush(List<HashMap> token_list, String title, String msg) throws IOException, FirebaseMessagingException {
		FirebaseApp firebaseApp = null;
		List<FirebaseApp> firebaseApps = FirebaseApp.getApps();
		
		// 초기화
		if (firebaseApps != null && !firebaseApps.isEmpty()) {
		    for(FirebaseApp app : firebaseApps){
		        if(app.getName().equals(FirebaseApp.DEFAULT_APP_NAME)) {
		            firebaseApp = app;
		        }
		    }
		} else {
			FirebaseOptions options = FirebaseOptions.builder()
				    .setCredentials(GoogleCredentials.getApplicationDefault())
				    .setDatabaseUrl("https://firebase에서 제공한 자신의 정보.firebaseio.com/")
				    .build();
			firebaseApp = FirebaseApp.initializeApp(options);				              
		}
		
		// 메시지 생성
		List<Message> messages = new ArrayList<Message>();
		
		for ( HashMap m : token_list ) {
			messages.add(
					Message.builder()
		            .setNotification(Notification.builder()
		                .setTitle(title)
		                .setBody(msg)
		                .build())
		            .setToken((String)m.get("token"))
		            .build()
			);
		}
		
		BatchResponse response = FirebaseMessaging.getInstance().sendAll(messages);
		logger.info("response.getSuccessCount() : " + response.getSuccessCount() + " messages were sent successfully");	
	}
}

 

4. push

나는 안드로이드 환경에서 push를 구현하기로 했다.
안드로이드 앱은 거창할 것도 없이 Activity 하나에 WebView 하나 띄워두고 push 메시지가 오면 앱을 활성화시키는 것이다.
WebView에 들어갈 웹페이지는 게시판 형태 그날의 매칭된 종목을 보여주는 정도로 구현하면 되겠다.
게시판 부분은 생략하고 android 구현 코드만 살펴보겠다.

AndroidManifest.xml은 다음과 같다.

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="패키지명">
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.VIBRATE" />

    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
    <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
    <uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
    <uses-permission android:name="android.permission.READ_PHONE_STATE" />

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:usesCleartextTraffic="true"
        android:theme="@style/AppTheme">
        <activity android:name=".MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" /
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

        <service
            android:name=".MyFirebaseMessagingService"
            android:exported="false">
            <intent-filter>
                <action android:name="com.google.firebase.MESSAGING_EVENT" />
            </intent-filter>
        </service>
    </application>
</manifest>

MainActivity는 아래와 같은데 url은 웹뷰에서 보여줄 페이지이다.
웹뷰 호출이 전부다.

public class MainActivity extends AppCompatActivity {
    private static final String TAG = "MyStock";

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        String url = "웹뷰에서 보여줄 url";
        WebView webView = (WebView)findViewById(R.id.webView);
        webView.getSettings().setJavaScriptEnabled(true);
        webView.setWebChromeClient(new WebChromeClient());
        webView.setWebViewClient(new WebViewClient());
        webView.loadUrl(url);

        // 안드로이드 id
        String STOCK_ANDROID_ID = Settings.Secure.getString(getApplicationContext().getContentResolver(), Settings.Secure.ANDROID_ID);
        Log.d(TAG, "STOCK_ANDROID_ID:" + STOCK_ANDROID_ID);
    }
}

제일 중요한 FirebaseMessagingService 에 대한 내용이다.
onNewToken 메소드는 token 정보가 바뀌거나 처음 등록할때 호출된다.
token은 push 메시지를 받을 기기을 구분하는 유일한 구분자이다.
token 정보는 무한하지 않기때문에 변경되면 onNewToken을 구현하여 변경된 token 값을 서버의 DB나 File 형태로 저장하고 있어야 한다.
실제 Notification 정보가 오면(FCM에서 메시지를 전송하면) onMessageReceived를 구현한다.

public class MyFirebaseMessagingService extends FirebaseMessagingService {
    private static final String TAG = "MyStock";

    @Override
    public void onNewToken(@NonNull String token) {
        //super.onNewToken(token);
        sendRegistrationToServer(token);
    }

    private void sendRegistrationToServer(String token) {
        // Add custom implementation, as needed.

        String android_id =
                Settings.Secure.getString(getApplicationContext().getContentResolver(), Settings.Secure.ANDROID_ID);

        Log.d(TAG, "android_id:" + android_id + " ,token:" + token);

        // OKHTTP를 이용해 웹서버로 토큰값을 날려준다.
        OkHttpClient client = new OkHttpClient();
        RequestBody body = new FormBody.Builder()
                .add("token", token)
                .add("android_id", android_id)
                .build();

        //request
        Request request = new Request.Builder()
                .url("토큰정보를 업데이트할 url")
                .post(body)
                .build();

        try {
            client.newCall(request).execute();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    @Override
    public void onMessageReceived(@NonNull RemoteMessage remoteMessage) {
        //super.onMessageReceived(remoteMessage);
        Log.d(TAG, "onMessageReceived 시작");

        String title = remoteMessage.getNotification().getTitle();
        String msg = remoteMessage.getNotification().getBody();

        Intent intent = new Intent(this, MainActivity.class);
        intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
        PendingIntent contentIntent = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_ONE_SHOT);
        String channelId = "stock channel";
        Uri defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION);
        NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(this, channelId)
                .setSmallIcon(R.mipmap.ic_launcher)
                .setContentTitle(title)
                .setContentText(msg)
                .setAutoCancel(true)
                .setSound(defaultSoundUri)
                .setVibrate(new long[]{1, 1000});

        Log.d(TAG, "msg:" + msg);

        NotificationManager notificationManager = (NotificationManager)getSystemService(Context.NOTIFICATION_SERVICE);
        notificationManager.notify(0, mBuilder.build());
        mBuilder.setContentIntent(contentIntent);
    }
}

 

실제로 장중에 아래와 같은 push 메시지를 확인할 수 있다.

그 외..

firebase 에서 push 메시지 테스트 하는 과정에서 여러차례 삽질이 있었다.
특히, push 메시지에 한글처리하는 부분이었는데 테스트를 위해 웹페이지에서 문자열을 입력 후 push 하면 한글이 깨졌다. 이 부분을 수정해보려고 했는데 원하는 결과가 나오지 않았다.
그런데 실제로 앱에서 push 메시지 발송하면 한글이 깨지지 않았다.
firebase console에서 메시지를 전송할때는 전송 즉시 메시지가 오는데 서버에서 FCM으로 전송해서 push 하면 늦게 오는 경우가 간혹 있었다.
사실상 이것은 firebase 관계자가 아닌한 원인을 파악하기 힘들다고 본다.

 


"스프링" 카테고리의 다른 글

댓글쓰기

"Spring과 Firebase를 이용한 주식 알리미 구현" 의 댓글 (0)