Spring Projcect/배당금 프로젝트

Chapter 04. 서비스 구현

계란💕 2022. 9. 14. 17:23

4.1 배당금 저장

구현 계획

  1. input으로 저장할 회사의 ticker(증권 거래소에서 변동하는 시세를 통보, 수신하는 주식 가격 표시기)를 받는다.
  2. 이미 저장된 회사의 ticker 인 경우, 오류 처리
  3. 받은 ticker 의 데이터를 야후 파이낸스에서 스크래핑한다.
  4. 스크래핑한 데이터가 야후 파이낸스에서 조회되지 않는 경우, 오류 처리
  5. 스크래핑한 회사의 메타 정보와 배당금 정보를 각각 DB에 저장한다.
  6. 저장한 회사의 메타 정보를 응답으로 내려준다.

 

  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를 상속 받았기 때문이다
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을 방지한다.
      • 값이 없는 경우에 대한 처리도 가능하다.
<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분

cron 표현식

 

 

 

  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 성능에 영향을 줄 것이다.
      • (인덱스를 건다고 조회 성능이 항상 빨라지는 것은 아니다.)
<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 노드가 되면서 서버가 정상적으로 운영된다.
    • 복제본 하나만 있어도 정상으로 돌아간다.

출처 - zerobase 백엔드 스쿨

 

 

 

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() 을 이용하면 모든 데이터에 대해 설정 가능
    • 특정 키에 대해 설정할 수도 있다.

 

 

출처 - https://zero-base.co.kr/

 

제로베이스 - 누구나 취업하는 가장 합리적인 취업 스쿨

코딩 부트 캠프 개발자, 데이터 사이언티스트, 마케터, 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