back-end/프로젝트

[주식 배당금]

림가이드 2023. 5. 2. 17:29

1. 요구사항(기능)

1) 회사 저장/삭제

- 회사 정보 저장 API

- 저장된 회사 정보 삭제 API

- 회사 정보 관리를 위한 DB table 설계

2) 회사에 해당하는 배당금 정보 제공

- 회사의 배당금 정보 조회 API

- 회사 배당금 정보를 스크래핑(:data 가져오기)

- 회사 배당금 정보 관리를 위한 DB table 서계

3) 회원 기능

- 회원가입 및 로그인/로그아웃 API

- 사용자 인증

 

2. 환경설정

- java11

- gradle 7.2

- intelliJ(community edition)

- spring boot 2.5.6

- h2 db

- jsoup

 

초기설정에서 설정못한 것들은 또는 수정해야하는 것(gradle버전, spring 버전 등)들은 build.gradle 파일에서 수정하고 새로고침하면 됨

plugins {
    id 'java'
    id 'org.springframework.boot' version '2.5.6'
    id 'io.spring.dependency-management' version '1.0.15.RELEASE'
}

group 'org.example'
version '1.0-SNAPSHOT'

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-security'

    implementation group: 'com.h2database', name: 'h2', version: '1.4.200'
    implementation group: 'org.jsoup', name: 'jsoup', version: '1.7.2'

    compileOnly group: 'org.projectlombok', name: 'lombok', version: '1.18.22'
    annotationProcessor group: 'org.projectlombok', name: 'lombok', version: '1.18.22'

    testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.1'
    testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.1'
}

test {
    useJUnitPlatform()
}

- 추가한 lombok library를 사용하기 위해 아래와 같이 설정하고 intelliJ를 껐다가 다시 켜기

 

application.yml도 설정에 맞게 구현~ (h2 db)

 

설정 끄읕!

 


< 데이터 가져오기 : 스크래핑 >

- 웹 스크래핑 : html문서를 받고 그것을 parsing하여 필요한 데이터 추출 (python언어 주로 사용)

(※ 마음대로 아무 사이트에서 스크래핑 하면 안됨! - 법)

        → 해당사이트/robots.txt에 접속하여 스크래핑 가능 여부 정보가 나옴!

(https://jsoup.org/apidocs/ 참고하여 개발)

 

(이전에 python으로 사이트 크롤링한 경험이 있는데 자바로는 어떻게 할지 궁금하다!!!)

 

- 이용할 사이트 : https://finance.yahoo.com

 

Yahoo Finance - Stock Market Live, Quotes, Business & Finance News

At Yahoo Finance, you get free stock quotes, up-to-date news, portfolio management resources, international market data, social interaction and mortgage rates that help you manage your financial life.

finance.yahoo.com

- 스크래핑 가능 여부 : https://finance.yahoo.com/robots.txt

 

아래의 코드 이해 가능!!!

: 사이트의 data-test의 element의 값이 historical-prices를 가진 결과를 Elements(여러 값을 반환할 수 있기에)에 받아오고 그 결과의 0번째(하나의 데이터만 가져왔기 때문에)의 element 결과를 가져옴

try {
    Connection connection = Jsoup.connect("https://finance.yahoo.com/quote/COKE/history?period1=99100800&period2=1682985600&interval=1mo&filter=history&frequency=1mo&includeAdjustedClose=true");
    Document document = connection.get();

    Elements elements = document.getElementsByAttributeValue("data-test", "historical-prices");
    Element element = elements.get(0);

    System.out.println(element);
} catch (IOException e) {
    e.printStackTrace();
}

출력결과

이런 식으로 스크래핑 하면 된다!

 

조금 더! 

: 가져온 tbody의 정보에서 각 element마다 text로만 된 것들을 다 뽑아 dividend로 끝나는 element를 뽑아내기

try {
    Connection connection = Jsoup.connect("https://finance.yahoo.com/quote/COKE/history?period1=99100800&period2=1682985600&interval=1mo&filter=history&frequency=1mo&includeAdjustedClose=true");
    Document document = connection.get();

    Elements elements = document.getElementsByAttributeValue("data-test", "historical-prices");
    Element element = elements.get(0);          // table 전체

    Element tbody = element.children().get(1);      // tbody 전체

    // tbody에서 원하는 값만 가져오기
    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);
    }

} catch (IOException e) {
    e.printStackTrace();
}

출력결과

 

 

하지만! 우리는 client가 어떤 회사에 대한 데이터를 요청하여 그 요청값을 받을 수 있도록, 그 요청값을 DB에 바로 넣을 수 있는 API를 설계해야 함!

 

 


< API 구현 >

> 어떤 API?

1. 특정 회사 배당금 조회

- get형식으로 요청(request 파라미터로)

- 반환값은 json형식

2. 회사이름으로 배당금 검색(자동완성)

- get형식으로 요청

- 반환값은 json형식

3. 회사 리스트 조회

- get형식으로 요청

- 반환값은 json형식

4. 배당금 저장 - 관리자 역할

- post형식으로 요청(DB에 저장)

- 반환값은 json형식

5. 배당금 삭제 - 관리자 역할

- 파라미터 형식으로 요청(/company?ticker=GOOD)

6. 회원

- 회원가입

- 로그인

- 로그아웃

 

★ GET, POST, PUT, DELETE의 차이점과 특징들 중요!

 

 

API 구현 예시!!

// 회사 리스트 조회 API
@GetMapping("/company")
public ResponseEntity<?> searchCompany() {
    return null;
}

// 배당금 저장 API
@PostMapping("/company")
public ResponseEntity<?> addCompany() {
    return null;
}

// 배당금 삭제 API
@DeleteMapping("/company")
public ResponseEntity<?> deleteCompany() {
    return null;
}

 


< DB 설계 >

> 고려사항?

- data 타입

- data 규모

- data 저장 주기

- data의 읽기/쓰기 비율

- 속도 vs 정확도 (ex.게시글 읽기-속도↑정확도↓, 송금-속도↓정확도↑)

- read 연산시 어떤 컬럼 기준으로?

- key는 어떻게 생성?

- 예상 traffic?

 

 

1. 회사 Table

- id : long (autoIncrement)

- name : string

- ticker : string (unique)

2. 배당금 Table

- id : long (autoIncrement)

- company_id : long

- date : localDateTime

- dividend : string

 

 

예시)

- CompanyEntity.java

 

@Entity(name = "COMPANY")
@Getter
@ToString
@NoArgsConstructor
public class CompanyEntity {

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

    @Column(unique = true)
    private String ticker;

    private String name;

    public CompanyEntity(Company company) {
        this.ticker = company.getTicker();
        this.name = company.getName();
    }
}

- CompanyRepository.Interface

 

// CompanyEntity 받고 이의 type인 Long
@Repository
public interface CompanyRepository extends JpaRepository<CompanyEntity, Long> {
    boolean existsByTicker(String ticker);      // CompanyEntity에서 자동으로 ticker에 해당하는 row를 찾아 boolean값으로 반환
}

 Entity는 DB와 직접적으로 매핑되기 위한 클래스이기 때문에, entity instance를 서비스 내 데이터를 주고받기 위한 용도로 쓰거나 이 과정에서 data 내용을 변경하는 용도로는 class의 원래 역할 범위를 벗어나는 행위!

 

=> 그래서 Model을 따로 생성해줌!! == DTO

 


< 동작 >

client는 controller를 통해 요청, controller은 service에, service는 dto 접근.

 

- in ~.web.CompanyController.java

private final CompanyService companyService;

// 배당금 저장 API
@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);
}

- in ~.service.CompanyService.java

private final Scraper yahooFinanceScraper;

private final CompanyRepository companyRepository;
private final DividendRepository dividendRepository;

// 외부에서 접근하여 저장되도록
public Company save(String ticker) {
    boolean exists = this.companyRepository.existsByTicker(ticker);
    if (exists) {
        throw new RuntimeException("already exists ticker");
    }

    return this.storeCompanyAndDividend(ticker);
}

// 클래스 내부에서 호출되어 회사 정보 저장
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.getDividendEntities().stream()
                                    .map(e -> new DividendEntity(companyEntity.getId(), e))
                                    .collect(Collectors.toList());
    this.dividendRepository.saveAll(dividendEntityList);

    return company;
}

 


< 자동완성 >

1. Trie : 자료구조 (apache에서 제공해줌)

- 속도↑ 메모리 사용↑

 

2. Like : DB query 연산에서

- 방대한 data가 있을 때, 사용 안하는게 좋음!!


< 스케줄러 >

: 일정 주기마다 반복하여 작업하도록 하는 것

 

- why? : 배당금을 수동으로 매일 가져온다면 매우 비효율적!

그래서 배당금을 일정 주기마다 새로 가져오도록 하는 것

 

+ unique key : 중복 데이터 저장을 방지하는 제약 조건 (단일 컬럼 or 복합 컬럼으로)

 

이미 존재하는 unique key의 값이 또 들어갈 때 처리 방법?

- insert ignore into table ~ values ~

- insert into table ~ values ~ on duplicate key update (...)

 

 

- ScraperScheduler.java

// 일정 주기마다 수행 (매일 정각)
@Scheduled(cron = "${scheduler.scrap.yahoo}")
public void yahooFinanceScheduling() {
    log.info("Scraping scheduler is started.");

    // 1. 저장된 회사 목록을 조회
    List<CompanyEntity> companies = this.companyRepository.findAll();

    // 2. 회사마다 배당금 정보를 새로 스크래핑
    for (var company: companies) {
        ScrapedResult scrapedResult = yahooFinanceScraper.scrap(Company.builder()
                                    .name(company.getName())
                                    .ticker(company.getTicker())
                                    .build());

        // 3. 스크래핑한 배당금 정보 중 DB에 없는 값은 저장
        scrapedResult.getDividendEntities().stream()
                // dividend model을 dividendEntity로 매핑
                .map(e -> new DividendEntity(company.getId(), e))
                // element를 하나씩 dividend repository에 삽입
                .forEach(e -> {
                    boolean exists = this.dividendRepository.existsByCompanyIdAndDate(e.getCompanyId(), e.getDate());
                    if (!exists) {
                        this.dividendRepository.save(e);
                    }
                });

        // 연속적인 스크래핑 때문에 서버에 과부하되지 않도록 처리
        try {
            Thread.sleep(3000);     // 3 seconds
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

}

- DividendEntity.java

@Table(
        uniqueConstraints = {
                @UniqueConstraint(
                        columnNames = {"companyId", "date"}
                )
        }
)
...

- DividendRepository.interface

// unique key로 존재여부 확인
boolean existsByCompanyIdAndDate(Long companyId, LocalDateTime date);

- application.yml (스케줄러를 코드에 직접 넣으면 바꿀 때마다 빌드/배포 과정을 매번 갱신해야하기 때문에 configure로 따로 설정)

...
scheduler:
  scrap:
    yahoo:  "0/5 * * * * *"

< 캐시 >

: 임시로 데이터를 저장하는 공간

 

for 성능 향상(빠른 처리 속도; 한번 요청된 것이 캐시에 저장이 돼 DB에 다시 접근 안해도 됨)

 

 

> Redis server(저장소)

- 인메모리 데이터 스토어(↔ 관계형 DB; 디스크에 데이터 저장)

- 다양한 형태의 데이터 타입 지원 

 

1. 사용

- redis 설치 및 시작/종료

% brew install redis

% redis-server
or
% brew services start redis
% brew services stop redis

시작되면 6379 port에 연결완료

- 서버 connect

% redis-cli

2. 명령어

$> set myKey myValue : 해당 key,value 저장 됨

$> get myKey : key로 조회하기

$> del myKey : 지우기

$> keys * : Redis의 모든 key를 조회 (서비스 운영 환경에서는 사용X 위험)

3. 설정

% vi /usr/local/etc/redis.conf
/requirepass
# requirepass foobared -> requirepass 비번생성

실행할 때, % redis-server /usr/local/etc/redis.conf

 

 

- build.gradle

...
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
...

- application.yml

spring:
  redis:
    host: localhost
    port: 6379
  ...

- CacheConfig.java

@Configuration
@RequiredArgsConstructor
public class CacheConfig {

    @Value("${spring.redis.host}")
    private String host;

    @Value("${spring.redis.port}")
    private String port;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
//        RedisClusterConfiguration : cluster일 경우

        RedisStandaloneConfiguration configuration = new RedisStandaloneConfiguration();
        configuration.setHostName(this.host);
        configuration.setPort(Integer.parseInt(this.port));
//        configuration.setPassword();          : 서버 접속시 비번이 있을 때

        return new LettuceConnectionFactory(configuration);
    }

    @Bean
    public CacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory) {
        RedisCacheConfiguration configuration = RedisCacheConfiguration.defaultCacheConfig()
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));

        return RedisCacheManager.RedisCacheManagerBuilder
                                .fromConnectionFactory(redisConnectionFactory)
                                .cacheDefaults(configuration)
                                .build();
    }
}

 

project 실행시킬 때 항상 redis를 start하고 실행시켜야 한다!

 

 

EX) Using Caching

- FinanceService.java

@Cacheable(key = "#companyName", value = "finance")
// 이 메서드는 요청이 자주 들어오고, data가 자주 변경되므로 캐싱
public ScrapedResult getDividendByCompanyName(String companyName) {
    // 1. 회사명을 기준으로 회사정보 조회
    CompanyEntity companyEntity = this.companyRepository.findByName(companyName)
            .orElseThrow(() -> new RuntimeException("존재하지 않는 회사명입니다."));

    // 2. 조회된 회사 id로 배당금 조회
    List<DividendEntity> dividendEntities = this.dividendRepository
            .findAllByCompanyId(companyEntity.getId());

    // 3. 결과 반환

    List<Dividend> dividends = dividendEntities.stream()
                            .map(e -> Dividend.builder()
                                    .dividend(e.getDividend())
                                    .date(e.getDate())
                                    .build())
                            .collect(Collectors.toList());
    return new ScrapedResult(Company.builder()
                            .ticker(companyEntity.getTicker())
                            .name(companyEntity.getName())
                            .build(),
                            dividends);
}

- FinanceController.java

// 특정 회사 배당금 조회 API
@GetMapping("/dividend/{companyName}")
public ResponseEntity<?> searchFinance(@PathVariable String companyName) {
    ScrapedResult result = this.financeService.getDividendByCompanyName(companyName);
    return ResponseEntity.ok(result);
}

- result

127.0.0.1:6379> keys *
1) "finance::3M Company"

 

127.0.0.1:6379> get "finance::3M Company"

↓ result - 직렬화(:자바에서 외부에 접근하려면 객체->바이트 data로 변경하는 것)된 data0

 

즉, 특정 회사 배당금을 조회하는 GET http://localhost:8080/finance/dividend/3M Company 에 접속하면 repository에 접근하지 않고 캐시에 접근하여 성능을 높일 수 있다.

 

자!!! 이렇게 되면, 내가 프로젝트를 다시 실행시켜도, redis 서버를 종료시켰다가 다시 실행시켜도 캐시에는 해당 data가 존재할 수 있기 때문에 response를 에러없이 받을 수 있다!! 


< JWT : Json Web Token >

: 사용자 인증 및 식별에 사용되는 토큰(사용자 정보를 포함)

 

- 토큰(=JWT) : 사용자가 로그인을 하면 서버에서 발행해주는 토큰을 가지고 브라우저 저장소에 토큰을 유지시킴

 

 

1. 구조 (점으로 구분)

- header : token의 type(jwt), 어떤 암호화 알고리즘이 적용됐는지 정보

- payload : 사용자, token에 대한 특성(로그인한 사용자 이름, 토큰 만료시간)

- signature : header+payload+secret key(64byte)를 인코딩한 값

 

- 토큰 생성할 때 64byte의 비밀키를 가져야 함

% echo '긴 문자열 키 설정' | base64

의 결과값을 

spring:
  jst:
    secret: 이곳에 복사

- 비밀키 가져오기

@Value("{spring.jwt.secret}")
private String secretKey;

- 토큰 생성하기

public String generateToken(String username, List<String> roles) {
    // 사용자 권한 정보 저장하는 claim
    Claims claims = Jwts.claims().setSubject(username);
    claims.put(KEY_ROLES, roles);

    var now = new Date();
    var expiredDate = new Date(now.getTime() + TOKEN_EXPIRE_TIME);

    return Jwts.builder()
            .setClaims(claims)
            .setIssuedAt(now)           // 토큰 생성 시간
            .setExpiration(expiredDate) // 토큰 만료 시간
            .signWith(SignatureAlgorithm.HS512, this.secretKey)    // 사용할 알고리즘, 비밀키
            .compact();
}

- parsing(구문분석) token

// 토큰으로부터 username 받기
public String getUsername(String token) {
    return this.parseClaims(token).getSubject();
}

public boolean validateToken(String token) {
    // token이 빈 값일 때
    if (!StringUtils.hasText(token)) return false;

    var claims = this.parseClaims(token);
    // 현재 시간과 비교
    return claims.getExpiration().before(new Date());
}

// 토큰으로부터 claim을 가져오는
private Claims parseClaims(String token) {
    try {
        return Jwts.parser().setSigningKey(this.secretKey).parseClaimsJws(token).getBody();
    } catch (ExpiredJwtException e) {
        return e.getClaims();
    }

}

< log >

 

1. 레벨 for 중요도

 - debug : 컴퓨터 프로그램 개발 단계 중에 발생하는 시스템의 논리적인 오류나 비정상적 연산(버그)을 찾아내고 그 원인을 밝히고 수정하는 작업 과정

- info : 실제 서비스에 필요한 내용들을 파악하기 위한

- warn : 에러가 발생하지 않지만 문제가 될 수 있는 부분에 대해

- error

 

 

2. 방법

- console

- file

- 중앙화

 

 

- logback-spring.xml

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <!--로그 저장할 폴더-->
    <property name="LOG_DIR" value="./"/>
    <!--파일명-->
    <property name="LOG_FILE_NAME" value="mylog"/>

    <!--콘솔에 출력할 메시지-->
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
      <encoder>
        <pattern> %d{yyyy-MM-dd HH:mm:ss.SSS} %highlight(%-5level) %magenta(%-4relative) --- [ %thread{10} ] %cyan(%logger{20}) : %msg%n </pattern>
      </encoder>
    </appender>

    <!--파일에 출력할 메시지-->
    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
      <file>${LOG_DIR}/${LOG_FILE_NAME}.log</file>
      <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
        <fileNamePattern>${LOG_DIR}/${LOG_FILE_NAME}-%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
        <!-- each archived file's size will be max 10MB -->
        <maxFileSize>10MB</maxFileSize>
        <!-- 30 days to keep -->
        <maxHistory>30</maxHistory>
      </rollingPolicy>
      <encoder>
        <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
      </encoder>
    </appender>

    <!--info부터 warn, error까지 log를 찍는다-->
    <logger name="org.springframework" level="info"/>
    <logger name="org.hibernate" level="info"/>
    <root level="info">
      <appender-ref ref="CONSOLE"/>
      <appender-ref ref="FILE"/>
    </root>
</configuration>

 


< 오류모음 >

 

HTTP Error

- 400번대 : client에서 잘못된 요청을 했을 때

- 500번대 : server의 로직에러

 

 

 

"status": 500, "error": "Internal Server Error",

&&

'com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of `com.example.bonus.model.Company` (no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator)'

=====> Company.java에서 @NoArgsConstructor, @AllArgsConstructor를 안붙임...!

              "ticker": "O"의 요청을 보냈음에도 하나의 인자만 받고 동작하는 생성자가 없었기 때문에!