[주식 배당금]
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
- 서버 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"의 요청을 보냈음에도 하나의 인자만 받고 동작하는 생성자가 없었기 때문에!