https://www.koreaexim.go.kr/ir/HPHKIR020M01?apino=2&viewtype=C&searchselect=&searchword= 여기서 제공하는 API를 사용하기로 한다.
응답은 이렇다. prettyJson으로 보기 좋게 바꾼것이다.
[
{
"result": 1,
"cur_unit": "AED",
"ttb": "366.42",
"tts": "373.83",
"deal_bas_r": "370.13",
"bkpr": "370",
"yy_efee_r": "0",
"ten_dd_efee_r": "0",
"kftc_bkpr": "370",
"kftc_deal_bas_r": "370.13",
"cur_nm": "아랍에미리트 디르함"
},
{
"result": 1,
"cur_unit": "AUD",
"ttb": "894.96",
"tts": "913.04",
"deal_bas_r": "904",
"bkpr": "904",
"yy_efee_r": "0",
"ten_dd_efee_r": "0",
"kftc_bkpr": "904",
"kftc_deal_bas_r": "904",
"cur_nm": "호주 달러"
},
{
"result": 1,
"cur_unit": "BHD",
"ttb": "3,570.03",
"tts": "3,642.16",
"deal_bas_r": "3,606.1",
"bkpr": "3,606",
"yy_efee_r": "0",
"ten_dd_efee_r": "0",
"kftc_bkpr": "3,606",
"kftc_deal_bas_r": "3,606.1",
"cur_nm": "바레인 디나르"
},
{
"result": 1,
"cur_unit": "BND",
"ttb": "997.99",
"tts": "1,018.16",
"deal_bas_r": "1,008.08",
"bkpr": "1,008",
"yy_efee_r": "0",
"ten_dd_efee_r": "0",
"kftc_bkpr": "1,008",
"kftc_deal_bas_r": "1,008.08",
"cur_nm": "브루나이 달러"
},
{
"result": 1,
"cur_unit": "CAD",
"ttb": "986.26",
"tts": "1,006.19",
"deal_bas_r": "996.23",
"bkpr": "996",
"yy_efee_r": "0",
"ten_dd_efee_r": "0",
"kftc_bkpr": "996",
"kftc_deal_bas_r": "996.23",
"cur_nm": "캐나다 달러"
},
{
"result": 1,
"cur_unit": "CHF",
"ttb": "1,475.2",
"tts": "1,505.01",
"deal_bas_r": "1,490.11",
"bkpr": "1,490",
"yy_efee_r": "0",
"ten_dd_efee_r": "0",
"kftc_bkpr": "1,490",
"kftc_deal_bas_r": "1,490.11",
"cur_nm": "스위스 프랑"
},
{
"result": 1,
"cur_unit": "CNH",
"ttb": "185.33",
"tts": "189.08",
"deal_bas_r": "187.21",
"bkpr": "187",
"yy_efee_r": "0",
"ten_dd_efee_r": "0",
"kftc_bkpr": "187",
"kftc_deal_bas_r": "187.21",
"cur_nm": "위안화"
},
{
"result": 1,
"cur_unit": "DKK",
"ttb": "195.83",
"tts": "199.78",
"deal_bas_r": "197.81",
"bkpr": "197",
"yy_efee_r": "0",
"ten_dd_efee_r": "0",
"kftc_bkpr": "197",
"kftc_deal_bas_r": "197.81",
"cur_nm": "덴마아크 크로네"
},
{
"result": 1,
"cur_unit": "EUR",
"ttb": "1,461.05",
"tts": "1,490.56",
"deal_bas_r": "1,475.81",
"bkpr": "1,475",
"yy_efee_r": "0",
"ten_dd_efee_r": "0",
"kftc_bkpr": "1,475",
"kftc_deal_bas_r": "1,475.81",
"cur_nm": "유로"
},
{
"result": 1,
"cur_unit": "GBP",
"ttb": "1,717.24",
"tts": "1,751.93",
"deal_bas_r": "1,734.59",
"bkpr": "1,734",
"yy_efee_r": "0",
"ten_dd_efee_r": "0",
"kftc_bkpr": "1,734",
"kftc_deal_bas_r": "1,734.59",
"cur_nm": "영국 파운드"
},
{
"result": 1,
"cur_unit": "HKD",
"ttb": "172.3",
"tts": "175.79",
"deal_bas_r": "174.05",
"bkpr": "174",
"yy_efee_r": "0",
"ten_dd_efee_r": "0",
"kftc_bkpr": "174",
"kftc_deal_bas_r": "174.05",
"cur_nm": "홍콩 달러"
},
{
"result": 1,
"cur_unit": "IDR(100)",
"ttb": "8.36",
"tts": "8.53",
"deal_bas_r": "8.45",
"bkpr": "8",
"yy_efee_r": "0",
"ten_dd_efee_r": "0",
"kftc_bkpr": "8",
"kftc_deal_bas_r": "8.45",
"cur_nm": "인도네시아 루피아"
},
{
"result": 1,
"cur_unit": "JPY(100)",
"ttb": "856.06",
"tts": "873.35",
"deal_bas_r": "864.71",
"bkpr": "864",
"yy_efee_r": "0",
"ten_dd_efee_r": "0",
"kftc_bkpr": "864",
"kftc_deal_bas_r": "864.71",
"cur_nm": "일본 옌"
},
{
"result": 1,
"cur_unit": "KRW",
"ttb": "0",
"tts": "0",
"deal_bas_r": "1",
"bkpr": "1",
"yy_efee_r": "0",
"ten_dd_efee_r": "0",
"kftc_bkpr": "1",
"kftc_deal_bas_r": "1",
"cur_nm": "한국 원"
},
{
"result": 1,
"cur_unit": "KWD",
"ttb": "4,388.2",
"tts": "4,476.85",
"deal_bas_r": "4,432.53",
"bkpr": "4,432",
"yy_efee_r": "0",
"ten_dd_efee_r": "0",
"kftc_bkpr": "4,432",
"kftc_deal_bas_r": "4,432.53",
"cur_nm": "쿠웨이트 디나르"
},
{
"result": 1,
"cur_unit": "MYR",
"ttb": "286.74",
"tts": "292.53",
"deal_bas_r": "289.64",
"bkpr": "289",
"yy_efee_r": "0",
"ten_dd_efee_r": "0",
"kftc_bkpr": "289",
"kftc_deal_bas_r": "289.64",
"cur_nm": "말레이지아 링기트"
},
{
"result": 1,
"cur_unit": "NOK",
"ttb": "128.02",
"tts": "130.61",
"deal_bas_r": "129.32",
"bkpr": "129",
"yy_efee_r": "0",
"ten_dd_efee_r": "0",
"kftc_bkpr": "129",
"kftc_deal_bas_r": "129.32",
"cur_nm": "노르웨이 크로네"
},
{
"result": 1,
"cur_unit": "NZD",
"ttb": "826.85",
"tts": "843.56",
"deal_bas_r": "835.21",
"bkpr": "835",
"yy_efee_r": "0",
"ten_dd_efee_r": "0",
"kftc_bkpr": "835",
"kftc_deal_bas_r": "835.21",
"cur_nm": "뉴질랜드 달러"
},
{
"result": 1,
"cur_unit": "SAR",
"ttb": "358.86",
"tts": "366.11",
"deal_bas_r": "362.49",
"bkpr": "362",
"yy_efee_r": "0",
"ten_dd_efee_r": "0",
"kftc_bkpr": "362",
"kftc_deal_bas_r": "362.49",
"cur_nm": "사우디 리얄"
},
{
"result": 1,
"cur_unit": "SEK",
"ttb": "127.22",
"tts": "129.79",
"deal_bas_r": "128.51",
"bkpr": "128",
"yy_efee_r": "0",
"ten_dd_efee_r": "0",
"kftc_bkpr": "128",
"kftc_deal_bas_r": "128.51",
"cur_nm": "스웨덴 크로나"
},
{
"result": 1,
"cur_unit": "SGD",
"ttb": "997.99",
"tts": "1,018.16",
"deal_bas_r": "1,008.08",
"bkpr": "1,008",
"yy_efee_r": "0",
"ten_dd_efee_r": "0",
"kftc_bkpr": "1,008",
"kftc_deal_bas_r": "1,008.08",
"cur_nm": "싱가포르 달러"
},
{
"result": 1,
"cur_unit": "THB",
"ttb": "36.73",
"tts": "37.48",
"deal_bas_r": "37.11",
"bkpr": "37",
"yy_efee_r": "0",
"ten_dd_efee_r": "0",
"kftc_bkpr": "37",
"kftc_deal_bas_r": "37.11",
"cur_nm": "태국 바트"
},
{
"result": 1,
"cur_unit": "USD",
"ttb": "1,345.9",
"tts": "1,373.09",
"deal_bas_r": "1,359.5",
"bkpr": "1,359",
"yy_efee_r": "0",
"ten_dd_efee_r": "0",
"kftc_bkpr": "1,359",
"kftc_deal_bas_r": "1,359.5",
"cur_nm": "미국 달러"
}
]
인증에러가 나면 이렇다. (result가 1 : 성공, 2 : DATA코드 오류, 3 : 인증코드 오류, 4 : 일일제한횟수 마감)
[{"result":3,"cur_unit":null,"ttb":null,"tts":null,"deal_bas_r":null,"bkpr":null,"yy_efee_r":null,"ten_dd_efee_r":null,"kftc_bkpr":null,"kftc_deal_bas_r":null,"cur_nm":null}]
하루 1000번의 API 호출 개수 제한이 있기 때문에 앱에서 조회했다가는 하루에 1번 호출한다고 해도 1000명의 유저만 사용할 수 있다. 따라서 서버에서 매 시간 호출해서 값을 저장해 두었다가 별도의 API로 응답하는 것이 올바는 접근방식이다. (서버에서 하루 24번만 호출하므로 갯수 제한에 걸리지 않는다.)
https://docs.nestjs.com/techniques/task-scheduling 를 이용하면 되겠다. 구현은 간단하다. 접근 방식은 월요일 부터 금요일까지 1시간 간격으로 호출해서 정상적인 응답이 있으면 메모리 캐시와 파일 캐시를 업데이트하는 식이다. 서버가 시작하면 파일 캐시에서 불러온다. Cron 설정은 https://crontab.cronhub.io/ 를 참고하면 편하다.
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import axios from 'axios';
import { DISCORD_WEBHOOK_URL, IS_PRODUCTION } from './app.const';
import { winstonLogger } from './logger.utils';
var fs = require('fs');
@Injectable()
export class TaskService {
// 월에서 금요일까지 매 시간 30분 마다
@Cron('30 * * * 1-5', {
name: 'exchangeRateApi',
timeZone: 'Asia/Seoul',
})
exchangeRateApi() {
fetchFromAPI();
}
}
// API에서 응답할 캐시된 환욜 정보
export let exchangeRateResult = {};
const cachePath = __dirname + '/../../exchangeRate.json';
function fetchFromLocal() {
fs.readFile(cachePath, 'utf8', (err, data) => {
if (err){
console.log(err);
fetchFromAPI();
} else {
const json = JSON.parse(data);
if (typeof json.USD == "string") {
exchangeRateResult = json;
//console.log(json);
winstonLogger.log(`load from exchange ratio cache USD: ${json.USD}`);
}
}
});
}
function fetchFromAPI() {
const API_KEY = "___API__AUTH__KEY____";
const apiUrl = `https://www.koreaexim.go.kr/site/program/financial/exchangeJSON?authkey=${API_KEY}&data=AP01`;
fetch(apiUrl)
.then((response) => response.json())//읽어온 데이터를 json으로 변환
.then((json) => {
const result = {};
json.forEach(item => {
const code = item.result;
if (code == 1) { // 1 : 성공, 2 : DATA코드 오류, 3 : 인증코드 오류, 4 : 일일제한횟수 마감
const cur_unit = item.cur_unit; //"USD"
const deal_bas_r = item.deal_bas_r;
result [cur_unit] = deal_bas_r;
}
if (IS_PRODUCTION && code != 1) {
// KEY 인증오류
axios.post(DISCORD_WEBHOOK_URL, {
content: "Exchage API error code:" + code,
});
return false; // break forEach
}
});
if (typeof result.USD == "string") {
//update cache
exchangeRateResult = result;
// write to cache file
const content = JSON.stringify(result);
fs.writeFileSync(cachePath, content, 'utf8');
winstonLogger.log(`write exchange ratio USD:${result.USD}`)
}
});
}
fetchFromLocal();
fetch API에 error 핸들러를 빠트렸다. 이러면 서버가 죽는다. 마지막에 이것을 추가하자..
})
.catch((error) => {
winstonLogger.warn(`fetch exchange error:${error.message}`)
});
=== 6/26일 추가 ===
connection 오류가 자주 발생한다. fetch API 때문인가 싶어서, axios로 바꿔도 동일한 오류가 뜬다.
The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()
하도 이상해서 curl 로 콘솔에서 테스트 했더니..
curl: (60) SSL certificate problem: unable to get local issuer certificate
해결책은 구글링하니 나오는데 서버의 ssl 인증서가 공인된 기관에서 인증받지 않아서라는데..
https://velog.io/@hyojinnnnnan/%EB%A6%AC%EB%88%85%EC%8A%A4-curl-60-SSL-certificate-problem-unable-to-get-local-issuer-certificate
이렇게 해도 안 되네. 다른 에러 메시지가
cause: UNABLE_TO_VERIFY_LEAF_SIGNATURE: unable to verify the first certificate
구글링해보니 보안적으로 위험하기는 하나 코드 마지막 줄에 아래 1줄을 추가..
process.env['NODE_TLS_REJECT_UNAUTHORIZED']="0";
를 추가해서 동작은 하기는 했다.
그런데 또다시 이런게 뜨는데 이건 API 서버의 문제 같다. 뭐지??? curl로 로그를 보니 302 리다이렉트인데, 같은 URL로 계속 리다이렉트한다. .
TooManyRedirects: The response redirected too many times.
애초에 웹브라우저로 하면 아무 문제 없는 URL인데 js, curl 에서 호출하면 에러라니.. 골치아프구나. 느낌적으로는 쿠키 문제 같은데 애초에 API에 쿠키가 동작해야 된다는게 이상한데..
Bun js 런타임이라서 생기는 문제인가 싶어서. node 20으로 다시 테스트..
var apiUrl = `https://www.koreaexim.go.kr/site/program/financial/exchangeJSON?authkey=${API_KEY}&data=AP01`;
console.log(`fetch ${apiUrl}`);
fetch(apiUrl, { verbose: true })
.then((response) => response.json())
.then((json) => {
console.log(json);
})
.catch((error) => {
console.log(error);
});
이렇게 해도 에러
dajkim76@Kims-Mac-mini ~ % node test.js
fetch https://www.koreaexim.go.kr/site/program/financial/exchangeJSON?authkey=
TypeError: fetch failed
at node:internal/deps/undici/undici:12502:13
at process.processTicksAndRejections (node:internal/process/task_queues:95:5) {
[cause]: Error: unable to verify the first certificate
at TLSSocket.onConnectSecure (node:_tls_wrap:1674:34)
at TLSSocket.emit (node:events:519:28)
at TLSSocket._finishInit (node:_tls_wrap:1085:8)
at ssl.onhandshakedone (node:_tls_wrap:871:12) {
code: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE'
}
}
fetch하기전에 아래 한 줄 추가했더니
process.env['NODE_TLS_REJECT_UNAUTHORIZED']="0";
역시 너무 많은 리다이렉트 에러.
dajkim76@Kims-Mac-mini ~ % node test.js
fetch https://www.koreaexim.go.kr/site/program/financial/exchangeJSON?authkey=
(node:17022) Warning: Setting the NODE_TLS_REJECT_UNAUTHORIZED environment variable to '0' makes TLS connections and HTTPS requests insecure by disabling certificate verification.
(Use `node --trace-warnings ...` to show where the warning was created)
TypeError: fetch failed
at node:internal/deps/undici/undici:12502:13
at process.processTicksAndRejections (node:internal/process/task_queues:95:5) {
[cause]: Error: redirect count exceeded
at makeNetworkError (node:internal/deps/undici/undici:4563:35)
at httpRedirectFetch (node:internal/deps/undici/undici:10156:32)
at httpFetch (node:internal/deps/undici/undici:10128:28)
at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
at async node:internal/deps/undici/undici:9868:20
at async mainFetch (node:internal/deps/undici/undici:9858:20)
at async httpFetch (node:internal/deps/undici/undici:10128:22)
at async node:internal/deps/undici/undici:9868:20
at async mainFetch (node:internal/deps/undici/undici:9858:20)
at async httpFetch (node:internal/deps/undici/undici:10128:22)
}
스크랩 방식으로 고쳐본다.
npm init
npm install axios cheerio
vi index.js
const axios = require("axios");
const cheerio = require("cheerio");
const requestExchangeList = async () => {
try {
const response = await axios.get("https://m.stock.naver.com/marketindex/home/exchangeRate/exchange");
const html = response.data;
const $ = cheerio.load(html);
const exchangeList = [];
//<strong class="MainListItem_name__2Nl6J">유로/달러</strong>
//<span class="MainListItem_price__dP8R6">1.0711</span>
$("strong.MainListItem_name__2Nl6J").each((index, element) => {
const name = $(element).text();
const nextElement = $(element).next();
if (nextElement.hasClass("MainListItem_price__dP8R6")) {
const price = nextElement.text();
const [country, code] = name.split(' ');
if (code !== undefined && code.length == 3) {
const exchange = {name, price, code};
exchangeList.push(exchange);
}
}
});
return exchangeList;
} catch (error) {
throw error;
}
};
requestExchangeList().then(exchangeList => console.log(exchangeList));
/*
--output--
dajkim76@Kims-Mac-mini ~/test/exchange % node index.js
[
{ name: '미국 USD', price: '1,392.50', code: 'USD' },
{ name: '유럽 EUR', price: '1,488.16', code: 'EUR' },
{ name: '일본 JPY', price: '866.66', code: 'JPY' },
{ name: '중국 CNY', price: '190.72', code: 'CNY' }
]
*/