4.1 배당금 저장
구현 계획
- input으로 저장할 회사의 ticker(증권 거래소에서 변동하는 시세를 통보, 수신하는 주식 가격 표시기)를 받는다.
- 이미 저장된 회사의 ticker 인 경우, 오류 처리
- 받은 ticker 의 데이터를 야후 파이낸스에서 스크래핑한다.
- 스크래핑한 데이터가 야후 파이낸스에서 조회되지 않는 경우, 오류 처리
- 스크래핑한 회사의 메타 정보와 배당금 정보를 각각 DB에 저장한다.
- 저장한 회사의 메타 정보를 응답으로 내려준다.
Ex) 엔티티, result 클래스 생성
- model 패키지 아래에 company 클래스를 생성한다.
- 엔티티와 다르게 id 없이 ticker, name만 있다.
- 엔티티와 모델의 역할을 구분한다.
<hide/>
package com.dayone.model;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class Company {
private String ticker;
private String name;
}
- 스크랩 결과 클래스
<hide/>
package com.dayone.model;
import java.util.ArrayList;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class ScrapResult {
private Company company;
private List<Dividend> dividendEntities;
public ScrapResult(){
this.dividendEntities = new ArrayList<>();
}
}
Garbage Collection (가비지 컬렉션)
- 메모리 할당 후 해제하지 않으면 메모리 누수(leak)가 발생한다.
- 프로그램 성능에 큰 영향을 주는 작업이다.
- 수동으로 개발자가 직접 해제할 경우(C언어), 올바르게 해제되지 않아 버그 발생 가능성이 높다.
- 가비지 컬렉터는 동적으로 할당된 메모리 영역 중, 더 이상 쓰지 않을 영역을 찾아내서 삭제한다.
- Java에서는 가비지 컬렉터가 가비지 컬렉션을 수행한다. 자주 수행할수록 프로그램이 느려진다.
- 모든 멤버 변수를 전역 변수로 만들면?
- 힙(Heap) 영역은 공간이 줄어들고 가비지 컬렉션이 더 자주 수행된다. 성능이 떨어진다.
Ex) Scraper 클래스
- Scraper 클래스 만들기
- url을 멤버 변수로 빼는 이유는? 가비지 컬렉션과 관련 있다.
- StringFormatting
- 회사명 / 시작 시간 / 끝 시간
<hide/>
package com.dayone.scraper;
import com.dayone.model.Company;
import com.dayone.model.Dividend;
import com.dayone.model.ScrapResult;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.jsoup.Connection;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
public class YahooFinanceScraper {
// 스켈레톤 코드
private static final String URL = "https://finance.yahoo.com/quote/%s/history?period1=%d&period2=%d&interval=1mo";
// private static final String SUMMARY_URL = "https://finance.yahoo.com/quote/%s?p=%s";
public ScrapResult scrap(Company company){
var scrapResult = new ScrapResult();
scrapResult.setCompany(company);
try {
long start = 0;
long end = 0;
String url = String.format(URL, company.getTicker(), start, end);
Connection connection = Jsoup.connect(url);
Document document = connection.get();
Elements parsingDivs = document.getElementsByAttributeValue("data-test", "historical-prices");
Element tableEle = parsingDivs.get(0); // table 전체
Element tbody = tableEle.children().get(1);
List<Dividend> dividends = new ArrayList<>();
for (Element e : tbody.children()) {
String txt = e.text();
if(!txt.endsWith("Dividend")){
continue;
}
String[] splits = txt.split(" ");
String month = splits[0];
int day = Integer.valueOf(splits[1].replace(",", ""));
int year = Integer.valueOf(splits[2]);
String dividend = splits[3];
System.out.println(year + "/" + month + "/" + day + " -> " + dividend);
}
scrapResult.setDividendEntities(dividends);
} catch (IOException e) {
throw new RuntimeException(e);
}
return scrapResult;
}
}
Ex) String 형태의 날짜를 LocalDateTime으로 바꾸기
- LocalDateTime.of(년, 월, 일, 시, 분)을 넣으면 LocalDateTime 형태로 반환한다.
- 월(영문) <= > 월(숫자) 변환하기 위한 클래스를 만든다.
- 열거형 클래스 Month를 만든다.
- 문자로 된 월을 매개변수로 주면 해당 월이 반환되도록 하는 메서드 strToNumber () 구현한다.
- 월이 유효하지 않은 경우에 -1을 반환한다.
<hide/>
package com.dayone.model.constants;
public enum Month {
JAN("Jan", 1),
FEB("Feb", 2),
MAR("Mar", 3),
APR("Apr", 4),
MAY("May", 5),
JUN("Jun", 6),
JUL("Jul", 7),
AUG("Aug", 8),
SEP("Sep", 9),
OCT("Oct", 10),
NOV("Nov", 11),
DEC("Dec", 12);
private String s;
private int number;
Month(String s, int n){
this.s = s;
this.number = n;
}
public static int strToNumber(String s){
for(var m : Month.values()){
if(m.s.equals(s)){
return m.number;
}
}
return -1;
}
}
- 위에서 만든 Month를 스크래퍼에 적용한다.
- 기존의 String month를 아래와 같이 바꾼다.
- strToNum을 static으로 생성했기 때문에 Month의 객체를 생성하지 않고 아래와 같이 사용가능하다.
int month = Month.strToNumber(splits[0]);
- System.currentTimeMillis(): 1970년 기준으로 현재 까지 경과한 시간을 milli second로 반환한다.
- 따라서, 이 결과에 1000을 나눠서 초 단위로 반환한다.
<hide/>
package com.dayone.scraper;
import com.dayone.model.Company;
import com.dayone.model.Dividend;
import com.dayone.model.ScrapResult;
import com.dayone.model.constants.Month;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import org.jsoup.Connection;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
public class YahooFinanceScraper {
// 스켈레톤 코드
private static final String URL = "https://finance.yahoo.com/quote/%s/history?period1=%d&period2=%d&interval=1mo";
private static final long START_TIME = 86400; // 60 * 60 * 24 = 하루 (초)
// private static final String SUMMARY_URL = "https://finance.yahoo.com/quote/%s?p=%s";
public ScrapResult scrap(Company company){
var scrapResult = new ScrapResult();
scrapResult.setCompany(company);
try {
long start = 0; // 시작 날짜
long end = System.currentTimeMillis() / 1000; // 끝 날짜
String url = String.format(URL, company.getTicker(), start, end);
Connection connection = Jsoup.connect(url);
Document document = connection.get();
Elements parsingDivs = document.getElementsByAttributeValue("data-test", "historical-prices");
Element tableEle = parsingDivs.get(0); // table 전체
Element tbody = tableEle.children().get(1);
List<Dividend> dividends = new ArrayList<>();
for (Element e : tbody.children()) {
String txt = e.text();
if(!txt.endsWith("Dividend")){
continue;
}
String[] splits = txt.split(" ");
int month = Month.strToNumber(splits[0]);
int day = Integer.valueOf(splits[1].replace(",", ""));
int year = Integer.valueOf(splits[2]);
String dividend = splits[3];
if(month < 0){
throw new RuntimeException("Unexpected Month enum value -> "+ splits[0]);
}
// 배당금 리스트에 추가
dividends.add(Dividend.builder()
.date(LocalDateTime.of(year, month, day, 0, 0))
.dividend(dividend)
.build());
}
scrapResult.setDividendEntities(dividends); // 리스트가
} catch (IOException e) {
throw new RuntimeException(e);
}
return scrapResult;
}
}
- Application
<hide/>
package com.dayone;
import com.dayone.model.Company;
import com.dayone.scraper.YahooFinanceScraper;
//@SpringBootApplication
public class Application {
public static void main(String[] args) {
//SpringApplication.run(SampleApplication.class, args);
YahooFinanceScraper scraper = new YahooFinanceScraper();
var result = scraper.scrap(Company.builder().ticker("O").build());
System.out.println(result);
}
}
Note) 실행 결과
<hide/>
ScrapResult(company=Company(ticker=O, name=null), dividendEntities=[Dividend(date=2022-08-31T00:00, dividend=0.248), Dividend(date=2022-07-29T00:00, dividend=0.248), Dividend(date=2022-06-30T00:00, dividend=0.248), Dividend(date=2022-05-31T00:00, dividend=0.247), Dividend(date=2022-04-29T00:00, dividend=0.247), Dividend(date=2022-03-31T00:00, dividend=0.247), Dividend(date=2022-02-28T00:00, dividend=0.247), Dividend(date=2022-01-31T00:00, dividend=0.247), Dividend(date=2021-12-31T00:00, dividend=0.247), Dividend(date=2021-11-30T00:00, dividend=0.246), Dividend(date=2021-11-01T00:00, dividend=0.228682), Dividend(date=2021-09-30T00:00, dividend=0.228682), Dividend(date=2021-08-31T00:00, dividend=0.228682), Dividend(date=2021-07-30T00:00, dividend=0.228682), Dividend(date=2021-06-30T00:00, dividend=0.228682), Dividend(date=2021-05-28T00:00, dividend=0.227713), Dividend(date=2021-04-30T00:00, dividend=0.227713), Dividend(date=2021-03-31T00:00, dividend=0.227713), Dividend(date=2021-02-26T00:00, dividend=0.227713), Dividend(date=2021-01-29T00:00, dividend=0.227713), Dividend(date=2020-12-31T00:00, dividend=0.227713), Dividend(date=2020-11-30T00:00, dividend=0.226744), Dividend(date=2020-10-30T00:00, dividend=0.226744), Dividend(date=2020-09-30T00:00, dividend=0.226744), Dividend(date=2020-08-31T00:00, dividend=0.226744), Dividend(date=2020-07-31T00:00, dividend=0.226744), Dividend(date=2020-06-30T00:00, dividend=0.226744), Dividend(date=2020-05-29T00:00, dividend=0.225775), Dividend(date=2020-04-30T00:00, dividend=0.225775), Dividend(date=2020-03-31T00:00, dividend=0.225775), Dividend(date=2020-02-28T00:00, dividend=0.225775), Dividend(date=2020-01-31T00:00, dividend=0.225775), Dividend(date=2019-12-31T00:00, dividend=0.22093), Dividend(date=2019-11-29T00:00, dividend=0.219961), Dividend(date=2019-10-31T00:00, dividend=0.219961), Dividend(date=2019-09-30T00:00, dividend=0.219961), Dividend(date=2019-08-30T00:00, dividend=0.219961), Dividend(date=2019-07-31T00:00, dividend=0.219961), Dividend(date=2019-06-28T00:00, dividend=0.219961), Dividend(date=2019-05-31T00:00, dividend=0.218992), Dividend(date=2019-04-30T00:00, dividend=0.218992), Dividend(date=2019-03-29T00:00, dividend=0.218992), Dividend(date=2019-02-28T00:00, dividend=0.218992), Dividend(date=2019-01-31T00:00, dividend=0.218992), Dividend(date=2018-12-31T00:00, dividend=0.214147), Dividend(date=2018-11-30T00:00, dividend=0.214147), Dividend(date=2018-10-31T00:00, dividend=0.214147), Dividend(date=2018-09-28T00:00, dividend=0.214147), Dividend(date=2018-08-31T00:00, dividend=0.213178)])
Ex) Ticker로 company 조회하기
- 스크래퍼 클래스
- SUMMARY_URL: 회사명, 회사의 ticker명이 뒤에 들어간다.
- STATISTICS_URL : 배당금 조회 URL
- html 문서를 가져오면 회사명이 대시바(-)로 구분되어 있어서 split()으로 잘라서 가져온다.
- scrapCompanyByTicker() 메서드 만들기
- 메서드 시그니처 구현
- ticker를 매개변수로 넣으면 회사에 대한 메타 정보를 출력하도록 하는 메서드
<hide/>
package com.dayone.scraper;
import com.dayone.model.Company;
import com.dayone.model.Dividend;
import com.dayone.model.ScrapResult;
import com.dayone.model.constants.Month;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import org.jsoup.Connection;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
public class YahooFinanceScraper {
// 스켈레톤 코드
private static final String STATISTICS_URL = "https://finance.yahoo.com/quote/%s/history?period1=%d&period2=%d&interval=1mo";
private static final String SUMMARY_URL = "https://finance.yahoo.com/quote/%s?p=%s";
private static final long START_TIME = 86400; // 60 * 60 * 24 = 하루 (초)
public ScrapResult scrap(Company company){
var scrapResult = new ScrapResult();
scrapResult.setCompany(company);
try {
long start = 0; // 시작 날짜
long end = System.currentTimeMillis() / 1000; // 끝 날짜
String url = String.format(STATISTICS_URL, company.getTicker(), start, end);
Connection connection = Jsoup.connect(url);
Document document = connection.get();
Elements parsingDivs = document.getElementsByAttributeValue("data-test", "historical-prices");
Element tableEle = parsingDivs.get(0); // table 전체
Element tbody = tableEle.children().get(1);
List<Dividend> dividends = new ArrayList<>();
for (Element e : tbody.children()) {
String txt = e.text();
if(!txt.endsWith("Dividend")){
continue;
}
String[] splits = txt.split(" ");
int month = Month.strToNumber(splits[0]);
int day = Integer.valueOf(splits[1].replace(",", ""));
int year = Integer.valueOf(splits[2]);
String dividend = splits[3];
if(month < 0){
throw new RuntimeException("Unexpected Month enum value -> "+ splits[0]);
}
// 배당금 리스트에 추가
dividends.add(Dividend.builder()
.date(LocalDateTime.of(year, month, day, 0, 0))
.dividend(dividend)
.build());
}
scrapResult.setDividendEntities(dividends); // 리스트가
} catch (IOException e) {
// 정상으로 스크래핑되지 않은 경우
throw new RuntimeException(e);
}
return scrapResult;
}
public Company scrapCompanyByTicker(String ticker){
String url = String.format(SUMMARY_URL, ticker, ticker);
try {
Document document = Jsoup.connect(url).get();
Element titleEle = document.getElementsByTag("h1").get(0); // 회사명이 들어 있음
String title = titleEle.text().split(" - ")[1].trim();
return Company.builder()
.ticker(ticker)
.name(title)
.build();
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
}
- Application
- 방금 만든 메서드 동작 확인하기 위해 다음과 같이 추가한다.
<hide/>
package com.dayone;
import com.dayone.scraper.YahooFinanceScraper;
//@SpringBootApplication
public class Application {
public static void main(String[] args) {
//SpringApplication.run(SampleApplication.class, args);
YahooFinanceScraper scraper = new YahooFinanceScraper();
var result = scraper.scrapCompanyByTicker("MMM");
System.out.println(result);
}
}
Note) 실행 결과
Company(ticker=MMM, name=3M Company)
Ex) 스크래퍼 인터페이스
- 스크래퍼 인터페이스를 만들고 나면 이를 구현하는 네이버 스크래퍼, 야후 파이낸스 스크래퍼 등 여러 가지를 만들어서 인터페이스를 확장할 수 있다.
<hide/>
package com.dayone.scraper;
import com.dayone.model.Company;
import com.dayone.model.ScrapedResult;
public interface Scraper {
Company scrapCompanyByTicker(String ticker);
ScrapedResult scrap(Company company);
}
Ex) 서비스 클래스에서 스크래핑 코드 사용하기
- Service 클래스
- 스크래퍼를 yahooFinanceScraper를 멤버 변수로 저장한다.
- 빈으로 선언해서 사용한다. YahooFinanceScraper에 @Component 붙인다.
- CompanyRepository에 company를 저장할 때, company가 아닌 entity 타입이 저장되어야한다.
- 따라서, CompanyEntity에 다음과 같이 생성자를 추가한다.
- company => entity
<hide/>
public CompanyEntity(Company company){
this.ticker = company.getTicker();
this.name = company.getName();
}
- DividendEntity
- 생성자를 추가한다.
<hide/>
public DividendEntity(Long companyId, Dividend dividend){
this.companyId = companyId;
this.date = dividend.getDate();
this.dividend = dividend.getDividend();
}
cf) stream() 뒤에 쓸 수 있는 메서드
- map(): 컬렉션의 원소 하나하나에 대해 적용한다.
- sort(): 정렬
- filter(): 조건에 따라 특정 원소를 포함하거나 제외시킨다.
- map(e -> ): 컬렉션의 모든 원소들을 다른 값으로 매핑하기 위해 사용한다. for Each 문과 비슷하다.
- getDividends()의 결과로 나온 배당금 리스트의 하나하나의 배당금(e)에 대해 매핑한다.
scrapedResult.getDividends().stream().map( e -> new DividendEntity(companyEntity.getId(), e));
- Service의 메서드
- 이를 400에러로 바꾸는 것이 더 적절하다. (뒤에 chapter 07에서 예외 클래스를 이용해서 커스터마이징)
<hide/>
// 정상으로 저장되면 회사의 Company 인스턴스 반환
private Company storeCompanyAndDividend(String ticker){
// ticker 기준으로 회사를 스크래핑
Company company = this.yahooFinanceScraper.scrapCompanyByTicker(ticker);
if(!ObjectUtils.isEmpty(company)){ // 회사 정보가 존재하지 않는 경우
throw new RuntimeException("Failed to scrap ticker -> " + ticker);
}
// 해당 회사가 존재할 경우, 회사의 배당금 정보를 스크래핑
ScrapedResult scrapedResult = this.yahooFinanceScraper.scrap(company);
// 스크래핑 결과
CompanyEntity companyEntity = this.companyRepository.save(new CompanyEntity(company));
List<DividendEntity> dividendEntityList= scrapedResult.getDividends().stream()
.map( e -> new DividendEntity(companyEntity.getId(), e))
.collect(Collectors.toList()); // 결괏값을 리스트로 반환
this.dividendRepository.saveAll(dividendEntityList);
return company;
}
- CompanyRepo
- existsByTicker() - 회사의 존재 여부를 확인하는 메서드
- 아래처럼 구현부 없이 선언부만 있는데도 사용할 수 있는 이유?
- SpringBoot의 Repository에 정해진 규칙이 있다.
- 규칙에 맞게 이름만 선언하면 SpringBoot가 알아서 코드를 생성해준다.
- 따라서, CompanyEntity에서 ticker에 해당하는 컬럼을 찾으면 ticker에 해당하는 인스턴스가 존재하는지 찾아서 boolean 형태로 반환하는 "rule base"의 코드를 생성한다.
<hide/>
package com.dayone.persist.entity.repository;
import com.dayone.persist.entity.CompanyEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface CompanyRepository extends JpaRepository<CompanyEntity, Long> {
Boolean existsByTicker(String ticker); // 존재 여부
}
- Service의 메서드 save()
- 이 부분도 나중에 뒷부분에서 예외 클래스를 만들어서 400 에러로 처리한다.
<hide/>
// 스크랩한 데이터 저장
// 외부에서 호출할 수 있는 메서드
public Company save(String ticker){
boolean exists = this.companyRepository.existsByTicker(ticker);
if(exists){
throw new RuntimeException("Already exists ticker -> " + ticker);
}
return this.storeCompanyAndDividend(ticker); // 존재하지 않는 경우
}
- Controller
<hide/>
package com.dayone.web;
import com.dayone.model.Company;
import com.dayone.service.CompanyService;
import lombok.AllArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.util.ObjectUtils;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/company")
@AllArgsConstructor
public class CompanyController {
private final CompanyService companyService;
@GetMapping("/autocomplete")
public ResponseEntity<?> autocomplete(@RequestParam String keyword){
return null;
}
@GetMapping
public ResponseEntity<?> searchCompany(){
return null;
}
// 저장
@PostMapping
public ResponseEntity<?> addCompany(@RequestBody Company request) {
String ticker = request.getTicker().trim();
if(ObjectUtils.isEmpty(ticker)){
throw new RuntimeException("ticker is empty");
}
Company company = this.companyService.save(ticker);
return ResponseEntity.ok(company); // 회사의 정보를 반환
}
// 회사 삭제
@DeleteMapping
public ResponseEntity<?> deleteCompany(){
return null;
}
}
Note) 애플리케이션 실행 결과 - 서버가 정상으로 실행된다.
Ex) 포스트맨
- localhost:8080/company
Note) 실행 결과
=============================== 오류 ===============================
- 오류: 414 에러, "Unsupported Media Type"
- 원인: Headers의 Content-type이 plain text라고 설정되어 있는데 이를 application/json으로 변경해야 한다.
- 그리고 변경하고나서는 400 bad request 에러가 나는데 post man을 재시작하면 해결된다.
- 강사님 화면
- 서버에도 INSERT 쿼리가 날아간다.
- 오류 화면
- 변경 후 화면
=============================== 오류 ===============================
- 오류: h2 데이터베이스에 데이터가 안 들어간다.
- 원인: h2 데이터베이스가 두 개 띄워져 있어서 실행이 안됐다. 모두 종료하고 다시 하나만 띄우면 정상으로 실행된다.
4.2 회사 조회
Ex)
- Service에 getAllCompany() 메서드
- 그런데, CompanyRepo에서 findAll()을 구현하지 않았는데도 쓸 수 있는 이유는?
- JpaRepository를 상속 받았기 때문이다
- 그런데, CompanyRepo에서 findAll()을 구현하지 않았는데도 쓸 수 있는 이유는?
public List<CompanyEntity> getAllCompany(){
return this.companyRepository.findAll();
}
- 컨트롤러 searchCompany()
- Pageable 인터페이스 - 페이지 기능을 지원한다.
<hide/>
@GetMapping
public ResponseEntity<?> searchCompany(final Pageable pageable){
Page<CompanyEntity> companies = this.companyService.getAllCompany(pageable);
return ResponseEntity.ok(companies);
}
<hide/>
public Page<CompanyEntity> getAllCompany(Pageable pageable){
return this.companyRepository.findAll(pageable);
}
- POST로 회사 정보 넣은 다음 GET 화면
Note) 실행 결과 - http://localhost:8080/company
- pagesize가 디폴트로 20이 설정되어 있어서 20개씩 나온다
- params에 size를 5로 설정하면 5개만 나온다.
<hide/>
{
"content": [
{
"id": 1,
"ticker": "DIA",
"name": "SPDR Dow Jones Industrial Average ETF Trust"
},
{
"id": 2,
"ticker": "O",
"name": "Realty Income Corporation"
},
{
"id": 3,
"ticker": "MMM",
"name": "3M Company"
},
{
"id": 4,
"ticker": "COKE",
"name": "Coca-Cola Consolidated, Inc."
},
{
"id": 5,
"ticker": "AAPL",
"name": "Apple Inc."
},
{
"id": 6,
"ticker": "QQQ",
"name": "Invesco QQQ Trust"
},
{
"id": 7,
"ticker": "SPY",
"name": "SPDR S&P 500 ETF Trust"
}
],
"pageable": {
"sort": {
"sorted": false,
"unsorted": true,
"empty": true
},
"pageNumber": 0,
"pageSize": 20,
"offset": 0,
"unpaged": false,
"paged": true
},
"last": true,
"totalElements": 7,
"totalPages": 1,
"number": 0,
"sort": {
"sorted": false,
"unsorted": true,
"empty": true
},
"first": true,
"numberOfElements": 7,
"size": 20,
"empty": false
}
- page = 1을 넣으면 다음과 같이
- 인덱스는 0부터 시작하니까 1번 인덱스의 페이지가 나온다.
4.3 배당금 조회
Ex) 특정 회사의 정보와 배당금 조회하기
- 파이낸스 서비스
- orElseThrow()에 매개변수로 예외를 넣으면 값이 없는 경우에 반환할 예외를 지정 가능하다.
<hide/>
package com.dayone.service;
import com.dayone.model.Company;
import com.dayone.model.Dividend;
import com.dayone.model.ScrapedResult;
import com.dayone.persist.entity.CompanyEntity;
import com.dayone.persist.entity.DividendEntity;
import com.dayone.persist.entity.repository.CompanyRepository;
import com.dayone.persist.entity.repository.DividendRepository;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@AllArgsConstructor
public class FinanceService {
private final CompanyRepository companyRepository;
private final DividendRepository dividendRepository;
public ScrapedResult getDividendByCompanyName(String companyName){
// 1. 회사명으로 회사 조회
CompanyEntity company = this.companyRepository.findByName(companyName)
.orElseThrow(() -> new RuntimeException("존재하지 않는 회사명입니다."));
// 2. 조회된 회사의 id로 배당금 조회
List<DividendEntity> dividendEntities = this.dividendRepository.findAllByCompanyId(company.getId());
// 3. 회사와 배당금 정보를 반환
/*
for (var entity : dividendEntities) {
dividends.add(Dividend.builder()
.date(entity.getDate())
.dividend(entity.getDividend())
.build());
}
*/
// 위의 for문 말고 스트림을 이용할 수도 있음
List<Dividend> dividends = dividendEntities.stream()
.map(e -> Dividend.builder()
.date(e.getDate())
.dividend(e.getDividend())
.build())
.collect(Collectors.toList());
return new ScrapedResult(Company.builder()
.ticker(company.getTicker())
.name(company.getName())
.build()
, dividends
); // 엔티티를 모델로 바꾼다.
}
}
- CompanyRepo에 메서드 추가
- Optional<>을 쓰는 이유는?
- NullPointerException을 방지한다.
- 값이 없는 경우에 대한 처리도 가능하다.
- Optional<>을 쓰는 이유는?
<hide/>
package com.dayone.persist.entity.repository;
import com.dayone.persist.entity.CompanyEntity;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface CompanyRepository extends JpaRepository<CompanyEntity, Long> {
boolean existsByTicker(String ticker); // 컴퍼니 엔티티에 티커 존재 여부
Optional<CompanyEntity> findByName(String name);
}
- DvidendRepository 메서드 추가
<hide/>
package com.dayone.persist.entity.repository;
import com.dayone.persist.entity.DividendEntity;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface DividendRepository extends JpaRepository<DividendEntity, Long> {
List<DividendEntity> findAllByCompanyId(Long companyId);
}
- finance controller
<hide/>
package com.dayone.web;
import com.dayone.service.FinanceService;
import lombok.AllArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/finance")
@AllArgsConstructor
public class FinanceController {
private final FinanceService financeService;
@GetMapping("/dividend/{companyName}")
public ResponseEntity<?> searchFinance(@PathVariable String companyName){
var result = this.financeService.getDividendByCompanyName(companyName);
return ResponseEntity.ok(result);
}
}
Note) 실행 결과 - localhost:8080/finance/company/3M company
- 저장되지 않은 회사명을 입력하면 500 에러 발생한다.
<hide/>
{
"company": {
"ticker": "MMM",
"name": "3M Company"
},
"dividendEntities": [
{
"date": "2022-08-19T00:00:00",
"dividend": "1.49"
},
{
"date": "2022-05-19T00:00:00",
"dividend": "1.49"
},
{
"date": "2022-02-17T00:00:00",
"dividend": "1.49"
},
{
"date": "2021-11-18T00:00:00",
"dividend": "1.48"
},
{
"date": "2021-08-20T00:00:00",
"dividend": "1.48"
},
{
"date": "2021-05-20T00:00:00",
"dividend": "1.48"
},
{
"date": "2021-02-11T00:00:00",
"dividend": "1.48"
},
{
"date": "2020-11-19T00:00:00",
"dividend": "1.47"
},
{
"date": "2020-08-21T00:00:00",
"dividend": "1.47"
},
{
"date": "2020-05-21T00:00:00",
"dividend": "1.47"
},
{
"date": "2020-02-13T00:00:00",
"dividend": "1.47"
},
{
"date": "2019-11-21T00:00:00",
"dividend": "1.44"
},
{
"date": "2019-08-15T00:00:00",
"dividend": "1.44"
},
{
"date": "2019-05-23T00:00:00",
"dividend": "1.44"
},
{
"date": "2019-02-14T00:00:00",
"dividend": "1.44"
},
{
"date": "2018-11-21T00:00:00",
"dividend": "1.36"
},
{
"date": "2018-08-23T00:00:00",
"dividend": "1.36"
},
{
"date": "2018-05-17T00:00:00",
"dividend": "1.36"
},
{
"date": "2018-02-15T00:00:00",
"dividend": "1.36"
},
{
"date": "2017-11-22T00:00:00",
"dividend": "1.175"
},
{
"date": "2017-08-23T00:00:00",
"dividend": "1.175"
},
{
"date": "2017-05-17T00:00:00",
"dividend": "1.175"
},
{
"date": "2017-02-15T00:00:00",
"dividend": "1.175"
},
{
"date": "2016-11-16T00:00:00",
"dividend": "1.11"
},
{
"date": "2016-08-17T00:00:00",
"dividend": "1.11"
}
]
}
- 에러 발생 화면
<hide/>
2022-09-17 15:05:45.567 ERROR 7824 --- [io-8080-exec-10] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.RuntimeException: 존재하지 않는 회사명입니다.] with root cause
java.lang.RuntimeException: 존재하지 않는 회사명입니다.
4.4 자동 완성
Trie - 트라이
- 트리형 자료 구조
- 시간 복잡도: O(L), (L: 문자열의 길이)
- List의 경우 복잡도는 L * N, (N: 단어의 개수)
- 문자열 탐색을 효율적으로 할 수 있다.
- 중복해서 단어를 저장할 필요가 없다.
- 자연어 처리, 자동 완성에 최적화되어 있다.
- 문자열 저장하는데 성능이 좋고 검색 속도가 빠르다.
- ex) CAT, CALM, CAP, CON, CONE
- 단어의 끝 글자를 나타내는 노드는 회색으로 flag 표시
- 단점: 메모리를 많이 차지한다.
- cf) 아마존 코테로 자주 나온다.
Trie - 저장
- 삽입하고자 하는 문자열을 앞에서부터 한 글자씩 가져온다.
- 트리의 루트부터 적합한 노드 위치를 찾아가면서 저장한다.
- 마지막 글자까지 삽입되면 isEnd 플래그로 단어의 끝을 표시한다.
Trie -검색
- input으로 받은 문자열을 한 글자씩 parsing
- parsing된 문자를 앞에서부터 비교한다.
- 해당 문자 노드가 존재하지 않거나 leaf 노드에 도달할 때까지 탐색한다.
Ex)
- gradle 파일에 디펜던시 apache Trie를 추가한다
- 기본 Trie에서 응용된 형태라서 key - value를 함께 저장 가능하다.
<hide/>
plugins {
id 'org.springframework.boot' version '2.5.6'
id 'io.spring.dependency-management' version '1.0.13.RELEASE'
id 'java'
// id 'war'
}
group = 'com.example'
version = '1.0-SNAPSHOT'
sourceCompatibility = '11'
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-mail'
// implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation group: 'com.h2database', name: 'h2', version: '1.4.200'
implementation group: 'org.jsoup', name: 'jsoup', version: '1.7.2'
implementation group: 'org.apache.commons', name: 'commons-collections4', version: '4.3'
compileOnly group:'org.projectlombok', name: 'lombok', version: '1.18.22'
annotationProcessor group: 'org.projectlombok', name: 'lombok', version: '1.18.22'
// runtimeOnly 'com.h2database:h2'
// providedRuntime 'org.springframework.boot:spring-boot-starter-tomcat'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
}
tasks.named('test') {
useJUnitPlatform()
}
- AutoComplete 클래스
<hide/>
package com.dayone;
import org.apache.commons.collections4.Trie;
public class AutoComplete {
private Trie trie = new PatriciaTrie();
public void add(String s){
this.trie.put(s, "world");
}
public Object get(String s){
return this.trie.get(s);
}
}
- Application
<hide/>
public class Application {
public static void main(String[] args) {
// SpringApplication.run(Application.class, args);
AutoComplete autoComplete = new AutoComplete();
AutoComplete autoComplete1 = new AutoComplete();
autoComplete.add("hello");
System.out.println(autoComplete.get("hello"));
System.out.println(autoComplete1.get("hello"));
}
}
Note) 실행 결과 - 두 인스턴스 중에 하나는add() 하고 하나는 add()하지 않았기 때문에 결과가 다르다.
- Service와 다르게 AutoComplete는 싱글톤으로 관리되지 않는다.
<hide/>
world
null
- service
- CompanyService는 스프링 컨테이너의 빈이므로 싱글톤으로 관리된다.
Ex) 싱글톤이 아닌 autoComplete에 대해 trie를 공유하려면?
- 자동 완성 생성자의 매개변수로 Trie를 넣어준다.
<hide/>
private Trie trie;
public AutoComplete(Trie trie){
this.trie = trie;
}
- 애플리케이션
- 다음과 같이 넣어주면 같은 Trie 인스턴스를 공유할 수 있다.
Trie trie = new PatriciaTrie();
AutoComplete autoComplete = new AutoComplete(trie);
AutoComplete autoComplete1 = new AutoComplete(trie);
Ex) 트라이를 싱글톤으로 만들기
- AppConfig 클래스를 만든다.
<hide/>
package com.dayone.config;
import org.apache.commons.collections4.Trie;
import org.apache.commons.collections4.trie.PatriciaTrie;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class AppConfig {
@Bean
public Trie<String, String> trie(){
return new PatriciaTrie<>();
}
}
- 트라이에 회사명 저장하는 메서드를 Service클래스에 추가한다.
- 자동 완성 기능만 구현하므로 value에는 null 을 넣는다.
<hide/>
public void addAutoCompleteKeyword(String keyword){
this.trie.put(keyword, null);
}
- 트라이 회사명 조회 메서드
- 리스트 형태로 형변환
<hide/>
public List<String> autoComplete(String keyword){
return (List<String>) this.trie.prefixMap(keyword).keySet().stream().collect(Collectors.toList());
}
- 트라이 회사명 삭제
<hide/>
public void deleteAutoCompleteKeyword(String keyword){
this.trie.remove(keyword);
}
- 컨트롤러에서 자동완성 기능 사용하기
<hide/>
@PostMapping
public ResponseEntity<?> addCompany(@RequestBody Company request) {
String ticker = request.getTicker().trim();
if(ObjectUtils.isEmpty(ticker)){
throw new RuntimeException("ticker is empty");
}
Company company = this.companyService.save(ticker);
this.companyService.addAutoCompleteKeyword(company.getName()); //회사 저장할 때마다 트라이에 저장
return ResponseEntity.ok(company); // 회사의 정보를 반환
}
- 실제 저장된 트라이에서 데이터 가져온다
<hide/>
@GetMapping("/autocomplete")
public ResponseEntity<?> autocomplete(@RequestParam String keyword){
var result = this.companyService.autoComplete(keyword);
return ResponseEntity.ok(result);
}
Note) 실행 결과 - http://localhost:8080/company/autocomplete?keyword
- 키워드를 넣지 않으면 저장된 회사가 모두 나온다.
<hide/>
[
"3M Company",
"AT&T Inc.",
"Apple Inc.",
"Intel Corporation",
"International Business Machines Corporation",
"Invesco QQQ Trust",
"NIKE, Inc.",
"SPDR S&P 500 ETF Trust"
]
Note) 실행 결과 - http://localhost:8080/company/autocomplete?keyword=A
- 키워드를 넣으면 저장된 회사 중에서 키워드 A로 시작하는 회사 목록이 나온다.
<hide/>
[
"AT&T Inc.",
"Apple Inc."
]
Ex) 회사의 개수가 많은 경우에 제한하기 위해 LIMIT를 추가할 수 있다.
<hide/>
public List<String> autoComplete(String keyword){
return (List<String>) this.trie.prefixMap(keyword)
.keySet()
.stream()
.limit(5) // 개수 제한
.collect(Collectors.toList());
}
LIKE 연산자
- %: 모든 문자
- _(언더바): 한 글자
- NOT LIKE: 부정문으로 사용
- LIKE IN: 여러 개중 하나인 경우
Ex) LIKE를 이용한 자동 완성
- Repo
List<CompanyEntity> findByNameStartingWithIgnoreCase(String id);
- Service
- List<CompanyEntity> => List<String> 으로 바꿔준다.
<hide/>
public List<String> getCompanyNamesByKeyword(String keyword){
Pageable limit = PageRequest.of(0, 10);
Page<CompanyEntity> companyEntities = this.companyRepository.findByNameStartingWithIgnoreCase(keyword, limit);
return companyEntities.stream()
.map(e -> e.getName())
.collect(Collectors.toList());
}
- Repo의 메서드 findByNameStartingWithIgnoreCase() 수정
Page<CompanyEntity> findByNameStartingWithIgnoreCase(String s, Pageable pageable);
- 컨트롤러
- autocomplete() => getCompanyNamesByKeyword() 로 바꾼다.
- 이렇게 하면 따로, 트라이에 저장할 필요 없어진다.
- 회사명 저장할 때, 트라이
<hide/>
@GetMapping("/autocomplete")
public ResponseEntity<?> autocomplete(@RequestParam String keyword){
var result = this.companyService.getCompanyNamesByKeyword(keyword);
return ResponseEntity.ok(result);
}
Note) 실행 결과 - 트라이 또는 like 연산을 이용해서 자동 완성을 사용 가능하다.
- 트라이: 데이터를 DB가 아닌 메모리에 넣는다. 데이터를 저장하고 조회하는 것 모두 서버가 실행한다.
- LIKE: 데이터 조회가 DB에서 이뤄진다. 트라이보다 DB에 큰 부하가 간다.
4.5 스케줄러 (scheduler)
Scheduler - 스케줄러
- 일정 주기마다 특정 작업을 수행한다.
- DB에서 값을 저장하거나 조회하거나 하는 작업을 말한다.
- 스레드를 이용해서 직접 컨트롤할 수도 있다.
- 실행 주기 설정 방법
- fixedDelay: 이전에 수행했던 작업의 종료 시점을 기준으로 일정 시간 후에 다음 작업을 시작한다.
- fixedRate: 이전에 수행했던 작업의 시작 시점을 기준으로 일정 시간 후에 다음 작업을 시작한다.
- 작업에 소요되는 실제 시간이 설정된 시간보다 길어지는 경우?
- 두 작업이 겹쳐 이슈 발생 가능하므로 유의한다.
- cron
- cron 표현식: 초 / 분 / 시 / 일 / 월 / 요일 / 년도(생략 가능)
- ex)
- 0 5 * * * * : 매시 5분 마다
- 0 0/10 * * * * : 10분에 한 번씩 스케줄링을 실행한다. => 1: 10, 1: 20, 1:30, ....
- 0 0 14 * * * : 매일 오후 2시 마다
- 0 0 0 1 * * : 매달 1일 0시
- 0 5 1 ? 7 MON-WEB: 매년 7월 월 - 수 1시 5분
Ex) 스프링 부트에서 스케줄링 이용하기
- TestScheduler
- 5초마다 실행
<hide/>
package com.dayone.scheduler;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
public class TestScheduler {
@Scheduled(cron = "0/5 * * * * * ")
public void test(){
System.out.println("now => " + System.currentTimeMillis());
}
}
Note) 실행 결과 -- 아래와 같이 5초 마다 출력된다.
Unique Key
- 중복 데이터 저장을 방지하는 제약 조건
- 단일 컬럼 뿐만아니라 복합 컬럼(2개 이상)으로 지정 가능하다.
- 테이블이나 인덱스에 같은 값이 두 개 지정될 수 없다.
INSERT IGNORE
- INSERT IGNORE INTO [TABLE] (column1, column2)
- VALUES (value1, value2)
- insert 쿼리에 중복 값이 들어오는 경우, 해당 레코드를 무시하고 처리한다.
on duplicate key update
- INSERT INTO [TABLE] (column1, column2)
- VALUES (value1, value2)
- ON DUPLICATE KEY UPDATE (...)
- insert 쿼리에 중복 값이 들어오는 경우, UPDATE 뒤에 정의된 컬럼 값에 대해서만 UPDATE를 실행한다.
DataBase index
- 테이블에 대한 동작의 속도를 높여 주는 자료 구조를 말한다.
- cardinality - 중복 데이터가 많을수록 cardinality가 낮고 중복이 적을수록 cardinality가 높다.
- cardinality가 높을수록 index를 거는 것이 효과적이다.
- 성별 - 중복이 많으니까 cardinality가 낮은 데이터 => index를 걸어도 효과적이지 않다.
- 주민등록번호 - 중복이 적으니까 cardinality가 높은 데이터 => index가 효과적이다.
Ex) 데이터 주기적으로 스크래핑하려면?
- 새로운 배당 정보가 추가될 때마다 추가되록 자동화시켜야한다.
- Dividend entity에서 복합 unique key 설정한다. (배당금 데이터가 중복으로 저장되는 것을 막기 위해)
- unique key는 인덱스이자 제약 조건이라고 볼 수 있다.
- DB의 index와 List의 index는 다름
- @Table(uniqueConstraints = {@UniqueConstraint( columnNames = {"companyId", "date"})})
- => companyId, date를 기준으로 하는 복합 유니크 키가 설정된다.
- 그 다음, DividendRepo에 메서드를 생성해서 데이터 존재 여부를 확인하고 존재하지 않는 경우에만 데이터를 삽입하도록 한다.
- 그런데, 여기서 회사의 데이터보다는 배당금의 데이터가 훨씬 클 것이다.
- 따라서, 인덱스를 걸어주지 않으면 DB 성능에 영향을 줄 것이다.
- (인덱스를 건다고 조회 성능이 항상 빨라지는 것은 아니다.)
- unique key는 인덱스이자 제약 조건이라고 볼 수 있다.
<hide/>
@Entity(name = "DIVIDEND")
@Getter
@ToString
@NoArgsConstructor
@Table(
uniqueConstraints = {
@UniqueConstraint(
columnNames = { "companyId", "date" }
)
}
)
public class DividendEntity {
...
}
- 배당금 Repo에 existsByCompanyIdAndDate() 메서드를 만든다.
boolean existsByCompanyIdAndDate(Long companyId, LocalDateTime date);
cf) Thread.sleep(n)
- InterruptedException: interrupt를 받는 스레드가 blocking될 수 있는 메서드를 실행할 때 발생한다.
- 처리하지 않으면 스레드가 정상적으로 종료하지 않는다.
- Thread.sleep(n): 실행 중인 스레드를 잠시 멈춘다. n millisecond동안 정지한다.
- sleep() 을 쓰면 필수로 InterruptedException 처리를 해줘야한다.
- ex) 1000을 넣으면 1초에 한 번씩 실행된다.
cf) sleep() vs wait()
- sleep()는 일정 시간이 지나면 자동으로 작업이 재개된다. - 단순 일시 정지
- wait()는 스레드를 대기 상태에 빠뜨린다. - 스레드 간 통신
- 대기 상태에 빠진 스레드는 notify()나 notifyAll()메서드를 호출할 때까지 자동으로 깨지지 않는다.
- 다른 스레드에서 notify()를 호출 가능하다.
- 스케줄러 클래스
- 만약 dividendRepository.saveAll()을 통해 스크랩 결과를 한 번에 저장한다면?
- 현재 디비든엔티티에 중복으로 값이 들어가지 못하게 막은 unique column이 있다. 따라서 에러가 발생해서 모든 데이터를 처리하지 않게 된다.
- 따라서, 하나씩 저장해야한다.
- 연속해서 요청을 날리면 서버에 부하가 갈 수 있다.
<hide/>
package com.dayone.scheduler;
import com.dayone.model.Company;
import com.dayone.model.ScrapedResult;
import com.dayone.persist.entity.CompanyEntity;
import com.dayone.persist.entity.DividendEntity;
import com.dayone.persist.entity.repository.CompanyRepository;
import com.dayone.persist.entity.repository.DividendRepository;
import com.dayone.scraper.YahooFinanceScraper;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Slf4j // 스크랩이 실행될 때마다 로깅 남기기
@Component
@AllArgsConstructor
public class ScraperScheduler {
private final CompanyRepository companyRepository;
private final YahooFinanceScraper yahooFinanceScraper;
private final DividendRepository dividendRepository;
@Scheduled(cron = "0 0 0 * * *") // 정각 마다 실행
public void yahooFinanceScheduling(){
// 저장된 회사의 목록 조회
List<CompanyEntity> companies = this.companyRepository.findAll();
// 회사 마다 배당금 정보를 새로 스크래핑
for(var company : companies){
log.info("== Scraping scheduler started ==");
ScrapedResult scrapedResult = this.yahooFinanceScraper.scrap(Company.builder()
.name(company.getName())
.ticker(company.getTicker())
.build());
// 스크래핑한 배당금 정보 중 DB에 없는 값은 저장한다.
scrapedResult.getDividends().stream()
.map(e -> new DividendEntity(company.getId(), e))
// dividen 엔티티 하나하나를 repo에 삽입
.forEach(e -> {
boolean exists = this.dividendRepository.existsByCompanyIdAndDate(e.getCompanyId(), e.getDate());
if(!exists){
this.dividendRepository.save(e);
}
});
// 연속적으로 스크래핑 대상 사이트 서버에 요청을 날리지 않도록 일시정지
try{
Thread.sleep(3000);
}catch (InterruptedException e){
e.printStackTrace();
Thread.currentThread().interrupt();
}
}
}
}
Ex) 스케줄링 테스트
- yml 파일
scheduler:
scrap:
yahoo: "0/5 * * * * *"
- cron 스케줄링은 서비스 제공 중에 변경될 여지가 있다. 따라서, config 파일을 만들어서 따로 설정하는 게 좋다.
<hide/>
package com.dayone.scheduler;
import com.dayone.model.Company;
import com.dayone.model.ScrapedResult;
import com.dayone.persist.entity.CompanyEntity;
import com.dayone.persist.entity.DividendEntity;
import com.dayone.persist.entity.repository.CompanyRepository;
import com.dayone.persist.entity.repository.DividendRepository;
import com.dayone.scraper.YahooFinanceScraper;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Slf4j // 스크랩이 실행될 때마다 로깅 남기기
@Component
@AllArgsConstructor
public class ScraperScheduler {
private final CompanyRepository companyRepository;
private final YahooFinanceScraper yahooFinanceScraper;
private final DividendRepository dividendRepository;
@Scheduled(cron = "${scheduler.scrap.yahoo}") // 정각 마다 실행
public void yahooFinanceScheduling(){
log.info("== Scheduler started ==");
// 저장된 회사의 목록 조회
List<CompanyEntity> companies = this.companyRepository.findAll();
// 회사 마다 배당금 정보를 새로 스크래핑
for(var company : companies){
log.info("회사 명 : " + company.getName());
ScrapedResult scrapedResult = this.yahooFinanceScraper.scrap(Company.builder()
.name(company.getName())
.ticker(company.getTicker())
.build());
// 스크래핑한 배당금 정보 중 DB에 없는 값은 저장한다.
scrapedResult.getDividends().stream()
.map(e -> new DividendEntity(company.getId(), e))
// dividend 엔티티 하나하나를 repo에 삽입
.forEach(e -> {
boolean exists = this.dividendRepository.existsByCompanyIdAndDate(e.getCompanyId(), e.getDate());
if(!exists){
this.dividendRepository.save(e);
}
});
// 연속적으로 스크래핑 대상 사이트 서버에 요청을 날리지 않도록 일시정지
try{
Thread.sleep(3000);
}catch (InterruptedException e){
e.printStackTrace();
Thread.currentThread().interrupt();
}
}
}
}
Note) 실행 결과
- 5초에 한 번씩 다음과 같이 출력된다.
- 회사는 등록해준 게 없어서 안 나옴
Ex) 스케줄러 테스트 - 스레드는 한 개의 스레드로 동작한다.
<hide/>
@Scheduled(fixedDelay = 1000) // 1초마다 실행된다.
public void test1() throws InterruptedException{
Thread.sleep(10000); // 10초가 일시 정지
System.out.println("테스트 1 : " + LocalDateTime.now());
}
@Scheduled(fixedDelay = 1000)
public void test2(){
System.out.println("테스트 2 : " + LocalDateTime.now());
}
- 코드를 보면 test1은 10초마다 , test2는 1초마다 실행되어야한다
Note) 실행 결과
- 그런데 결과를 보면 테스트1이 끝난 다음에야 테스트2가 실행된다.
- 따라서, 스케줄러로 두 개 이상의 작업을 돌리면 돌아야할 다른 작업을 돌아가지 않는다.
- 스케줄러는 1개의 스레드로 동작하기 때문이다.
Thread Pool
- 여러 개의 스레드를 유지 / 관리하기 위해 사용한다.
- 스레드를 생성하고 제거하는 것은 리소스 소모가 크다.
- 스레드 풀은 설정된 크기의 스레드를 만들고 해당 스레드를 재사용할 수 있도록 관리한다.
Thread Pool 적정 사이즈는 ?
- CPU 처리가 많은 경우(core 개수가 n인 경우): n + 1개의 스레드를 생성하면 좋다.
- I / O 작업이 많은 경우(core 개수가 n인 경우): 2 * n
Ex) 스레드 풀이 필요한 이유
- 스케줄러
- Thread.currentThread().getName()
<hide/>
@Scheduled(fixedDelay = 1000) // 1초마다 실행된다.
public void test1() throws InterruptedException{
Thread.sleep(10000); // 10초가 일시 정지
System.out.println( Thread.currentThread().getName() + " -> 테스트 1 : " + LocalDateTime.now());
}
@Scheduled(fixedDelay = 1000)
public void test2(){
System.out.println(Thread.currentThread().getName() + " -> 테스트 2 : " + LocalDateTime.now());
}
- Main
<hide/>
@SpringBootApplication
@EnableScheduling
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
System.out.println("Main -> " + Thread.currentThread().getName());
}
}
Note) 실행 결과
- Main 스레드가 있고
- 스케줄러는 별도의 스케줄러에서 실행된다.
- 테스트1과 테스트2는 하나의 스레드가 처리한다.
- 하고 있는 작업을 먼저 처리해야 다음 작업을 처리 가능하다.
Ex) 스레드 풀 생성
- SchedulerConfig
- availableProcessors(): 코어 개수를 가져온다.
- 코어 개수에 맞춰서 스레드의 개수를 설정한다.
<hide/>
package com.dayone.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.SchedulingConfigurer;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.scheduling.config.ScheduledTaskRegistrar;
@Configuration
public class SchedulerConfig implements SchedulingConfigurer {
@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
ThreadPoolTaskScheduler threadPool = new ThreadPoolTaskScheduler();
int n = Runtime.getRuntime().availableProcessors(); // 코어 개수
threadPool.setPoolSize(n); // 스레드의 개수 설정
threadPool.initialize();
taskRegistrar.setTaskScheduler(threadPool); // 스케줄러에서 방금 생성한 스레드 풀을 사용한다.
}
}
Note) 실행 결과
- 아까와는 다르게 테스트2가 10번 실행되고 테스트1이 1번 실행된다. - 정상
- 스레드도 여러개를 사용한다.
<hide/>
Main -> main
ThreadPoolTaskScheduler-1 -> 테스트 2 : 2022-09-18T15:54:02.321118800
ThreadPoolTaskScheduler-3 -> 테스트 2 : 2022-09-18T15:54:03.327042300
ThreadPoolTaskScheduler-1 -> 테스트 2 : 2022-09-18T15:54:04.335090800
ThreadPoolTaskScheduler-4 -> 테스트 2 : 2022-09-18T15:54:05.340586500
ThreadPoolTaskScheduler-3 -> 테스트 2 : 2022-09-18T15:54:06.348621500
ThreadPoolTaskScheduler-5 -> 테스트 2 : 2022-09-18T15:54:07.359465700
ThreadPoolTaskScheduler-1 -> 테스트 2 : 2022-09-18T15:54:08.369080800
ThreadPoolTaskScheduler-6 -> 테스트 2 : 2022-09-18T15:54:09.378430300
ThreadPoolTaskScheduler-4 -> 테스트 2 : 2022-09-18T15:54:10.388724200
ThreadPoolTaskScheduler-2 -> 테스트 1 : 2022-09-18T15:54:11.317949900
ThreadPoolTaskScheduler-7 -> 테스트 2 : 2022-09-18T15:54:11.396297700
ThreadPoolTaskScheduler-8 -> 테스트 2 : 2022-09-18T15:54:12.399055900
ThreadPoolTaskScheduler-8 -> 테스트 2 : 2022-09-18T15:54:13.411630100
ThreadPoolTaskScheduler-8 -> 테스트 2 : 2022-09-18T15:54:14.420686100
ThreadPoolTaskScheduler-8 -> 테스트 2 : 2022-09-18T15:54:15.430489900
ThreadPoolTaskScheduler-8 -> 테스트 2 : 2022-09-18T15:54:16.440264
ThreadPoolTaskScheduler-8 -> 테스트 2 : 2022-09-18T15:54:17.450122200
ThreadPoolTaskScheduler-8 -> 테스트 2 : 2022-09-18T15:54:18.459616900
ThreadPoolTaskScheduler-8 -> 테스트 2 : 2022-09-18T15:54:19.467405900
ThreadPoolTaskScheduler-8 -> 테스트 2 : 2022-09-18T15:54:20.477663900
ThreadPoolTaskScheduler-8 -> 테스트 2 : 2022-09-18T15:54:21.489701100
ThreadPoolTaskScheduler-3 -> 테스트 1 : 2022-09-18T15:54:22.333357300
4.6 캐시 (cache)
캐시 (cache)
- 임시로 데이터를 저장하는 공간을 말한다. 데이터가 실제로 저장되는 공간은 따로 있다.
- 같은 데이터를 굳이 두 군데 저장하는 이유는?
- 성능을 향상시키기 위해서 (빠른 속도를 위해) 캐시를 쓴다.
Redis(Remote Dictionary Server, 레디스)
- redis는 캐시 서버를 구축하기 위해 가장 많이 쓰이는 스토어이다.
- key - value 구조의 비정형 데이터를 저장하고 관리하기 위한 오픈 소스 기반의 비관계형 데이터베이스 관리 시스템(DBMS)이다.
- 인메모리 데이터 스토어
- 인메모리: 메모리 안에 데이터를 저장하기 때문에 서버 종료되면 날아간다. 휘발성이 있다. - 주로 캐시 서버 용도로 많이 사용하고 중요한 내용을 저장하는 용도로는 쓰지 않는다.
- redis는 인메모리 기반이지만 영속성도 지원하는 특징이 있다.
- 다양한 형태의 데이터 타입 지원 - List, Hash 같은 자료형도 지원한다.
- 레디스는 주로 캐시 서버 용도로 많이 사용한다.
- 레디스는 MySQL과 데이터 저장 방식, 저장 위치, 저장하는 데이터 종류도 다르다.
- 관계형 데이터베이스는 디스크에 접근해서 데이터를 쓰고 읽는다.
Redis Server 구축 방법
- Single Instance 방식 - 단일 노드로 구성한다.
- Sentinel - Master-slave 구조
- Cluster(클러스터) - Master-slave 구조
- 서버 한 대가 죽더라도 나머지가 서비스를 운영하도록 서버 이중화 (HA , High Availability)를 지원하려면?
- 여러 Instance로 클러스터를 구성하면 된다.
Redis Cluster
- 서버 인스턴스가 3개 있고 여기에 redis 클러스터를 구성한다고 가정한다.
- 서버 한 대는 redis 노드를 각각 3 개씩 가진다.
- Master, Slave 노드로 구성한다.
- 데이터를 입력/ 수정 => Master 노드에 입력된다.
- Slave 노드: Master 노드의 데이터를 복사해서 다른 서버에 복제본을 동일하게 가지고 있는다.
- 아래 그림에서 M1의 복제본은 1번 서버가 아닌 2번, 3번 서버의 S5, S6에 복제된다.
- 만약 1번이 죽는다면? S5 또는 S6가 Master 노드가 되면서 서버가 정상적으로 운영된다.
- 복제본 하나만 있어도 정상으로 돌아간다.
4.7 레디스 설치
Ex) 레디스 설치 - 윈도우
- 깃허브에 접속해서 .msi 파일 다운 받는다. https://github.com/microsoftarchive/redis/releases
- redis-cli.exe 파일을 실행한다.
- 사진과 같이 set {key} {value}를 입력하면 쌍으로 저장된다. get 으로 조회 가능하다.
Redis 명령어
- set {key} {value} => OK
- get {key} => "value"
- del {key} => (interger) 1
- get {key} => (nil)
- nil을 자바의 null과 동일하다.
- keys * : 모든 key를 조회한다. 개발이나 테스트 과정에서는 쓰지만 성능에 지장을 줄 수 있다. 서비스 운영 환경에서는 쓰지 않는다.
- exit: redis를 빠져나온다.
4.8 레디스 캐시 구현
Ex) redis cache관련 기본 bean 생성하기
- build.gradle 파일에 추가한다.
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
- yml 파일에 redis 포트를 추가한다.
redis:
host: localhost
port: 6379
- 애플리케이션에 @EnableCaching 붙인다.
- 무슨 용도?
- @EnableCaching: 캐시 기능을 사용하고 싶은 프로젝트의 애플리케이션에 붙인다.
- @Cacheable: 캐시하고 싶은 메서드에 붙인다.
- @CacheEvict: 캐시를 제거하고자 하는 메서드에 붙인다.
cf) Serialization - 직렬화
- Java에서 Serialization는 Java 프로그램 내부의 데이터 또는 Object 같은 값들을 바이트 형태로 변환하는 것을 말한다.
- 왜 굳이 바꿀까? Java 외부의 다른 프로그램에서도 데이터를 사용할 수 있도록 하기 위해서이다.
- 바이트를 다시 Java 내부에서 쓸 수 있는 데이터 형태로 만들어주는 것은 '역직렬화'라고 한다.
- CacheConfig 클래스
- 다음처럼 @Value 에너테이션을 붙이면 서비스가 초기화되는 과정에서 값들이 변수 host, port로 각각 매핑된다.
- 메서드에 @Bean을 붙여서 redis 커넥션 빈을 초기화한다.
- 레디스 서버를 싱글 인스턴스로 띄울 것이므로 생성한다.
- 메서드 redisConnectionFactory()를 만든다. => 레디스 커넥션 팩토리만 생성된 상태이다.
- 이를 캐시에 적용시켜서 사용하려면? 캐시 매니저 빈을 추가로 생성해야한다.
- redis CacheManager를 만든다.
- 직렬화를 지정?
- key를 직렬화, value를 직렬화할 때의 Serialization를 다르게 지정 가능하다
<hide/>
package com.dayone.config;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.CacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
@RequiredArgsConstructor
public class CacheConfig {
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.port}")
private int port;
@Bean
public CacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory){
RedisCacheConfiguration conf = RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
// 위에 생성된 인스턴스를 캐시 메니저로 빌드한다.
return RedisCacheManager.RedisCacheManagerBuilder
.fromConnectionFactory(redisConnectionFactory) // 아래에서 생성한 빈이 들어간다.
.cacheDefaults(conf)
.build();
}
@Bean
public RedisConnectionFactory redisConnectionFactory(){
RedisStandaloneConfiguration conf = new RedisStandaloneConfiguration();
conf.setHostName(this.host);
conf.setPort(this.port);
return new LettuceConnectionFactory(conf); // 위 설정대로 레디스 커낵션 빈을 사용하게 된다.
}
}
Ex) 캐시 사용 테스트
- 주식 정보는 특정 회사에 대한 동일한 데이터 요청이 많이 들어오는 편이다.
- 따라서, 이 데이터를 캐싱해 놓으면 빠르게 응답을 해줄 수 있다.
- 자주 변경되는 데이터인가?
- 배당금 특성 상 과거의 배당금 정보가 바뀌거나 회사명이 바뀌는 일은 흔하지않다.
- 따라서, 캐시해놓으면 좋다.
- FinanceService
- 캐시 대상 메서드에 애너테이션을 붙인다.
<hide/>
@Cacheable(key = "companyName", value = "finance")
public ScrapedResult getDividendByCompanyName(String companyName){
..
}
- http://localhost:8080/finance/dividend/3M Company
- 실행한다.
Note) 실행 결과 - InvalidException 에러가 뜬다. LocalDateTime을 직렬화하는 과정에서 문제가 있기 때문이다.
====================== 오류 ======================
- 오류: InvalidException
- 원인: FinanceService의 getDividendByCompanyName() 메서드에 @Cacheable(key = "#companyName")을 넣는데 '#'을 안 넣어서 에러가 났다. 넣으면 해결된다.
- dividend 클래스
- 직렬화 애너테이션을 추가한다.
- 둘다 LocalDateTime으로 설정해야 오류가 안 난다.
<hide/>
package com.dayone.model;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import java.time.LocalDateTime;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import lombok.Builder;
import lombok.Data;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
@Data
@Builder
public class Dividend {
@JsonSerialize(using = LocalDateTimeSerializer.class)
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
private LocalDateTime date;
private String dividend; // 금액?을 왜 string으로?
}
- 포스트맨을 이용해서 "MMM" 을 저장한다.
- 저장하고 나면 Redis로 데이터를 확인 가능하다.
Note) 실행 결과 - get 하면 직렬화된 상태로 데이터가 반환된다.
- finance 값을 조회하는 API를 호출하면
- getDividendByCompanyName()의 배당금 repo나 회사 repo를 조회하지 않고 바로 레디스 캐시에서 값을 찾아 응답할 수 있다.
- 여기서 다시 get 을 실행하면?
- 다음과 같이 에러가 난다.
- http://locallocalhost:8080/finance/dividend/3M Companyhost:8080/finance/dividend/3M Company
- Company 클래스에서 deserialize할 수 없다는 에러가 난다.
- 역직렬화하는 과정에서 default 생성자가 필요한데 없어서 에러가 난 것이다.
<hide/>
Hibernate: select companyent0_.id as id1_0_, companyent0_.name as name2_0_, companyent0_.ticker as ticker3_0_ from company companyent0_ limit ?
2022-09-18 22:47:47.756 ERROR 7656 --- [io-8080-exec-10] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is org.springframework.data.redis.serializer.SerializationException: Could not read JSON: Cannot construct instance of `com.dayone.model.Dividend` (no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator)
at [Source: (byte[])"{"@class":"com.dayone.model.ScrapedResult","company":{"@class":"com.dayone.model.Company","ticker":"MMM","name":"3M Company"},"dividends":["java.util.ArrayList",[{"@class":"com.dayone.model.Dividend","date":[2016,8,17,0,0],"dividend":"1.11"},{"@class":"com.dayone.model.Dividend","date":[2016,11,16,0,0],"dividend":"1.11"},{"@class":"com.dayone.model.Dividend","date":[2017,2,15,0,0],"dividend":"1.175"},{"@class":"com.dayone.model.Dividend","date":[2017,5,17,0,0],"dividend":"1.175"},{"@class":"com."[truncated 1674 bytes]; line: 1, column: 201] (through reference chain: com.dayone.model.ScrapedResult["dividends"]->java.util.ArrayList[0]); nested exception is com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of `com.dayone.model.Dividend` (no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator)
at [Source: (byte[])"{"@class":"com.dayone.model.ScrapedResult","company":{"@class":"com.dayone.model.Company","ticker":"MMM","name":"3M Company"},"dividends":["java.util.ArrayList",[{"@class":"com.dayone.model.Dividend","date":[2016,8,17,0,0],"dividend":"1.11"},{"@class":"com.dayone.model.Dividend","date":[2016,11,16,0,0],"dividend":"1.11"},{"@class":"com.dayone.model.Dividend","date":[2017,2,15,0,0],"dividend":"1.175"},{"@class":"com.dayone.model.Dividend","date":[2017,5,17,0,0],"dividend":"1.175"},{"@class":"com."[truncated 1674 bytes]; line: 1, column: 201] (through reference chain: com.dayone.model.ScrapedResult["dividends"]->java.util.ArrayList[0])] with root cause
Ex) 위 에러 해결하기 - 캐시
- Company 클래스에서 @Builder 애너테이션을 지운다.
- Dividend 클래스도 마찬가지로 redis에 저장되어 있고 역직렬화되는 개체이다.
- 빌더를 지우고 NoArgs, AllArgs 를 붙인다.
- 파이낸스 서비스의 getDividendByCompanyName() 메서드에서 쓰던 빌더 관련 코드를 수정한다.
Note) 실행 결과 - 애플리케이션 정상 실행된다.
- http://localhost:8080/finance/dividend/Apple%20Inc. 를 실행하면 이전에 DB에서 데이터를 가져올 때보다 시간이 더 빨라진다. 캐시에서 데이터를 가져오기 때문이다.
Ex) @Cacheable
- log.info()를 추가한다.
- 레디스는 스트링 부트와 별개이기 때문에 스프링 부트가 종료되더라고 데이터는 그대로 있다.
- @Cacheable: 메서드에 붙이면 캐시에 키가 없는 경우에 메서드를 실행시키고 반환 값을 캐시에 추가한다. 캐시에 key가 있는 경우는 메서드를 실행하지 않고 캐시에 있는 값을 바로 반환한다.
- key value
<hide/>
@Cacheable(key = "#companyName", value = "finance")
public ScrapedResult getDividendByCompanyName(String companyName){
log.info("search company => " + companyName);
..
}
Note) 실행 결과
- 데이터베이스에서 조회해오기 때문에 로그가 찍힌다.
4.9 레디스 캐시에서 데이터 삭제
캐시에서 데이터를 삭제하는 이유?
- 캐시에 데이터가 계속 있으면 DB에 데이터를 업데이트한 상황에서 클라이언트의 요청이 서버까지 가지 않고 캐시에 있는 업데이트 되기 전의 데이터를 내려준다. 따라서 업데이트된 상황에는 캐시를 비워주거나 캐시 내용까지 업데이트하는 과정이 필요하다.
- 캐시에 데이터를 계속 저장하면 자원 낭비가 있으니 지워주도록 한다.
Ex)
- @CacheEvict: value에 해당하는 부분이 레디스 서버의 key의 프리 픽스로 사용된다. 따라서 "finance"
- allEntries = true: 레디스 캐시의 finance에 해당하는 부분을 모두 지운다는 뜻이다.
- 만약 특정 키의 값을 지우려면 key ="" 형태로 값을 넣는다.
- @EnableCaching: 스케줄러가 실행될 때마다 cachingEvict 어노테이션이 같이 실행된다. 캐시의 데이터가 비워진다.
- 배당금을 조회하는 시점에 다시 캐시에 데이터가 올라간다.
- 참고로 이렇게 evict을 붙이지 않아도 시간이 지나면 비워지도록 캐시를 설정 가능하다.
<hide/>
package com.dayone.scheduler;
import com.dayone.model.Company;
import com.dayone.model.ScrapedResult;
import com.dayone.persist.entity.CompanyEntity;
import com.dayone.persist.entity.DividendEntity;
import com.dayone.persist.entity.repository.CompanyRepository;
import com.dayone.persist.entity.repository.DividendRepository;
import com.dayone.scraper.YahooFinanceScraper;
import java.time.LocalDateTime;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Slf4j // 스크랩이 실행될 때마다 로깅 남기기
@Component
@AllArgsConstructor
@EnableCaching
public class ScraperScheduler {
private final CompanyRepository companyRepository;
private final YahooFinanceScraper yahooFinanceScraper;
private final DividendRepository dividendRepository;
// @Scheduled(cron = "${scheduler.scrap.yahoo}") // 정각 마다 실행
@CacheEvict(value = "finance", allEntries = true)
@Scheduled(cron = "${scheduler.scrap.yahoo}")
public void yahooFinanceScheduling(){
log.info("== Scheduler started ==");
// 저장된 회사의 목록 조회
List<CompanyEntity> companies = this.companyRepository.findAll();
// 회사 마다 배당금 정보를 새로 스크래핑
for(var company : companies){
log.info("회사 명 : " + company.getName());
ScrapedResult scrapedResult = this.yahooFinanceScraper.scrap(new Company(company.getName(), company.getName()));
// 스크래핑한 배당금 정보 중 DB에 없는 값은 저장한다.
scrapedResult.getDividends().stream()
.map(e -> new DividendEntity(company.getId(), e))
// dividend 엔티티 하나하나를 repo에 삽입
.forEach(e -> {
boolean exists = this.dividendRepository.existsByCompanyIdAndDate(e.getCompanyId(), e.getDate());
if(!exists){
this.dividendRepository.save(e);
}
});
// 연속적으로 스크래핑 대상 사이트 서버에 요청을 날리지 않도록 일시정지
try{
Thread.sleep(3000);
}catch (InterruptedException e){
e.printStackTrace();
Thread.currentThread().interrupt();
}
}
}
}
- TTL(Time To Live): 데이터의 유효 기간을 뜻한다. 캐시에 설정하면 데이터의 유효 기간을 설정해서 자동 삭제되도록 설정 가능하다.
- cache config의 레디스 캐시 매니저에서 entryTtl() 을 이용하면 모든 데이터에 대해 설정 가능
- 특정 키에 대해 설정할 수도 있다.
제로베이스 - 누구나 취업하는 가장 합리적인 취업 스쿨
코딩 부트 캠프 개발자, 데이터 사이언티스트, 마케터, PM, 디자이너 등 제대로 공부하고 확실하게 취업하세요. 당신의 삶의 전환점이 될 제로베이스 스쿨입니다.
zero-base.co.kr
'Spring Projcect > 배당금 프로젝트' 카테고리의 다른 글
Chapter 06. 완성도 높이기 (0) | 2022.09.20 |
---|---|
Chapter 05. 회원 관리 (0) | 2022.09.19 |
Chapter 03. 서비스 설계 (0) | 2022.09.13 |
Chapter 02. 스크래핑(Scraping) (0) | 2022.09.12 |
Chapter 01. 프로젝트 환경 설정 (0) | 2022.09.12 |