/** * OpenAPI 프록시 컨트롤러 * - 외부 API(날씨, 환율 등)를 프록시하여 API 키를 안전하게 관리 */ import { Request, Response } from 'express'; import axios from 'axios'; export class OpenApiProxyController { /** * 날씨 정보 조회 (기상청 API Hub) * GET /api/open-api/weather?city=Seoul */ async getWeather(req: Request, res: Response): Promise { try { const { city = '서울' } = req.query; console.log(`🌤️ 날씨 조회 요청: ${city}`); // 1순위: OpenWeatherMap API (실시간에 가까움, 10분마다 업데이트) const openWeatherKey = process.env.OPENWEATHER_API_KEY; if (openWeatherKey) { try { console.log(`🌍 OpenWeatherMap API 호출: ${city}`); const response = await axios.get('https://api.openweathermap.org/data/2.5/weather', { params: { q: `${city},KR`, appid: openWeatherKey, units: 'metric', lang: 'kr', }, timeout: 10000, }); const data = response.data; const weatherData = { city: data.name, country: data.sys.country, temperature: Math.round(data.main.temp), feelsLike: Math.round(data.main.feels_like), humidity: data.main.humidity, pressure: data.main.pressure, weatherMain: data.weather[0].main, weatherDescription: data.weather[0].description, weatherIcon: data.weather[0].icon, windSpeed: Math.round(data.wind.speed * 10) / 10, clouds: data.clouds.all, timestamp: new Date().toISOString(), }; console.log(`✅ OpenWeatherMap 날씨 조회 성공: ${weatherData.city} ${weatherData.temperature}°C`); res.json({ success: true, data: weatherData }); return; } catch (error) { console.warn('⚠️ OpenWeatherMap API 실패, 기상청 API로 폴백:', error instanceof Error ? error.message : error); } } // 2순위: 기상청 API Hub (매시간 정시 데이터) const apiKey = process.env.KMA_API_KEY; // API 키가 없으면 오류 반환 if (!apiKey) { console.log('⚠️ 기상청 API 키가 설정되지 않았습니다.'); res.status(503).json({ success: false, message: '기상청 API 키가 설정되지 않았습니다. 관리자에게 문의하세요.', }); return; } // 도시명 → 기상청 지역 코드 매핑 const regionCode = getKMARegionCode(city as string); if (!regionCode) { res.status(404).json({ success: false, message: `지원하지 않는 지역입니다: ${city}`, }); return; } // 기상청 API Hub 사용 (apihub.kma.go.kr) const now = new Date(); // 한국 시간(KST = UTC+9)으로 변환 const kstOffset = 9 * 60 * 60 * 1000; // 9시간을 밀리초로 const kstNow = new Date(now.getTime() + kstOffset); // 기상청 지상관측 데이터는 매시간 정시(XX:00)에 발표 // 가장 최근의 정시 데이터를 가져오기 위해 현재 시간의 정시로 설정 const targetTime = new Date(kstNow); // tm 파라미터: YYYYMMDDHH00 형식 (정시만 조회) const year = targetTime.getUTCFullYear(); const month = String(targetTime.getUTCMonth() + 1).padStart(2, '0'); const day = String(targetTime.getUTCDate()).padStart(2, '0'); const hour = String(targetTime.getUTCHours()).padStart(2, '0'); const tm = `${year}${month}${day}${hour}00`; console.log(`🕐 현재 시각(KST): ${kstNow.toISOString().slice(0, 16).replace('T', ' ')}, 조회 시각: ${tm}`); // 기상청 API Hub - 지상관측시간자료 (시간 범위 조회로 최신 데이터 확보) // sfctm3: 시간 범위 조회 가능 (tm1~tm2) const url = 'https://apihub.kma.go.kr/api/typ01/url/kma_sfctm3.php'; // 최근 1시간 범위 조회 (현재 시간 - 1시간 ~ 현재 시간) - KST 기준 const tm1Time = new Date(kstNow.getTime() - 60 * 60 * 1000); // 1시간 전 const tm1 = `${tm1Time.getUTCFullYear()}${String(tm1Time.getUTCMonth() + 1).padStart(2, '0')}${String(tm1Time.getUTCDate()).padStart(2, '0')}${String(tm1Time.getUTCHours()).padStart(2, '0')}00`; const tm2 = tm; // 현재 시간 console.log(`📡 기상청 API Hub 호출: ${regionCode.name} (관측소: ${regionCode.stnId}, 기간: ${tm1}~${tm2})`); const response = await axios.get(url, { params: { tm1: tm1, tm2: tm2, stn: regionCode.stnId, // 특정 관측소만 조회 authKey: apiKey, help: 0, disp: 1, }, timeout: 10000, }); console.log('📊 기상청 API Hub 응답:', response.data); // 기상청 API Hub 응답은 텍스트 형식이므로 파싱 필요 const weatherData = parseKMAHubWeatherData(response.data, regionCode); console.log(`✅ 날씨 조회 성공: ${weatherData.city} ${weatherData.temperature}°C`); res.json({ success: true, data: weatherData, }); } catch (error: unknown) { console.error('❌ 날씨 조회 실패:', error); // API 호출 실패 시 명확한 오류 메시지 반환 if (axios.isAxiosError(error)) { const status = error.response?.status; if (status === 401 || status === 403) { res.status(401).json({ success: false, message: '기상청 API 인증에 실패했습니다. API 키를 확인하세요.', }); } else if (status === 404) { res.status(404).json({ success: false, message: '기상청 API에서 데이터를 찾을 수 없습니다.', }); } else if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') { res.status(504).json({ success: false, message: '기상청 API 연결 시간이 초과되었습니다. 잠시 후 다시 시도하세요.', }); } else { res.status(500).json({ success: false, message: '기상청 API 호출 중 오류가 발생했습니다.', error: error.message, }); } } else { res.status(500).json({ success: false, message: '날씨 정보를 가져오는 중 예상치 못한 오류가 발생했습니다.', }); } } } /** * 환율 정보 조회 (한국은행 API) * GET /api/open-api/exchange-rate?base=KRW&target=USD */ async getExchangeRate(req: Request, res: Response): Promise { try { const { base = 'KRW', target = 'USD' } = req.query; console.log(`💱 환율 조회 요청: ${base} -> ${target}`); // ExchangeRate-API.com 사용 (무료, API 키 불필요) const url = `https://open.er-api.com/v6/latest/${base}`; console.log(`📡 ExchangeRate-API 호출: ${base} -> ${target}`); const response = await axios.get(url, { timeout: 10000, }); console.log('📊 ExchangeRate-API 응답:', response.data); // 환율 데이터 추출 const rates = response.data?.rates; if (!rates || !rates[target as string]) { throw new Error(`환율 데이터를 찾을 수 없습니다: ${base} -> ${target}`); } const rate = rates[target as string]; const exchangeData = { base: base as string, target: target as string, rate: rate, timestamp: new Date().toISOString(), source: 'ExchangeRate-API.com', }; console.log(`✅ 환율 조회 성공: 1 ${base} = ${rate} ${target}`); res.json({ success: true, data: exchangeData, }); } catch (error: unknown) { console.error('❌ 환율 조회 실패:', error); // API 호출 실패 시 명확한 오류 메시지 반환 if (axios.isAxiosError(error)) { res.status(500).json({ success: false, message: '환율 정보를 가져오는 중 오류가 발생했습니다.', error: error.message, }); } else { res.status(500).json({ success: false, message: '환율 정보를 가져오는 중 예상치 못한 오류가 발생했습니다.', }); } } } /** * Geocoding (주소 → 좌표 변환) * POST /api/open-api/geocode * Body: { address: "서울특별시 강남구 테헤란로 123" } */ async geocode(req: Request, res: Response): Promise { try { const { address } = req.body; if (!address) { res.status(400).json({ success: false, message: '주소가 필요합니다.', }); return; } console.log(`📍 Geocoding 요청: ${address}`); // Kakao Geocoding API 호출 const apiKey = process.env.KAKAO_REST_API_KEY || 'demo'; // TODO: 실제 API 키 필요 const url = 'https://dapi.kakao.com/v2/local/search/address.json'; const response = await axios.get(url, { params: { query: address }, headers: { Authorization: `KakaoAK ${apiKey}`, }, timeout: 5000, }); if (response.data.documents && response.data.documents.length > 0) { const result = response.data.documents[0]; console.log(`✅ Geocoding 성공: ${result.address_name}`); res.json({ success: true, data: { address: result.address_name, lat: parseFloat(result.y), lng: parseFloat(result.x), }, }); } else { console.log(`❌ 주소를 찾을 수 없음: ${address}`); res.status(404).json({ success: false, message: '주소를 찾을 수 없습니다.', }); } } catch (error: unknown) { console.error('❌ Geocoding 실패:', error); if (axios.isAxiosError(error)) { res.status(500).json({ success: false, message: 'Geocoding 중 오류가 발생했습니다.', error: error.message, }); } else { res.status(500).json({ success: false, message: 'Geocoding 중 오류가 발생했습니다.', }); } } } } /** * 기상청 지역 코드 매핑 (전국 시/군/구 단위) */ function getKMARegionCode(city: string): { name: string; stnId: string } | null { const regions: Record = { // 영어 도시명 (국제 표준) 'Seoul': { name: '서울', stnId: '108' }, 'Busan': { name: '부산', stnId: '159' }, 'Incheon': { name: '인천', stnId: '112' }, 'Daegu': { name: '대구', stnId: '143' }, 'Gwangju': { name: '광주', stnId: '156' }, 'Daejeon': { name: '대전', stnId: '133' }, 'Ulsan': { name: '울산', stnId: '152' }, 'Sejong': { name: '세종', stnId: '239' }, 'Jeju': { name: '제주', stnId: '184' }, // 서울특별시 '서울': { name: '서울', stnId: '108' }, '종로구': { name: '서울 종로구', stnId: '108' }, '중구': { name: '서울 중구', stnId: '108' }, '용산구': { name: '서울 용산구', stnId: '108' }, '성동구': { name: '서울 성동구', stnId: '108' }, '광진구': { name: '서울 광진구', stnId: '108' }, '동대문구': { name: '서울 동대문구', stnId: '108' }, '중랑구': { name: '서울 중랑구', stnId: '108' }, '성북구': { name: '서울 성북구', stnId: '108' }, '강북구': { name: '서울 강북구', stnId: '108' }, '도봉구': { name: '서울 도봉구', stnId: '108' }, '노원구': { name: '서울 노원구', stnId: '108' }, '은평구': { name: '서울 은평구', stnId: '108' }, '서대문구': { name: '서울 서대문구', stnId: '108' }, '마포구': { name: '서울 마포구', stnId: '108' }, '양천구': { name: '서울 양천구', stnId: '108' }, '강서구': { name: '서울 강서구', stnId: '108' }, '구로구': { name: '서울 구로구', stnId: '108' }, '금천구': { name: '서울 금천구', stnId: '108' }, '영등포구': { name: '서울 영등포구', stnId: '108' }, '동작구': { name: '서울 동작구', stnId: '108' }, '관악구': { name: '서울 관악구', stnId: '108' }, '서초구': { name: '서울 서초구', stnId: '108' }, '강남구': { name: '서울 강남구', stnId: '108' }, '송파구': { name: '서울 송파구', stnId: '108' }, '강동구': { name: '서울 강동구', stnId: '108' }, // 부산광역시 '부산': { name: '부산', stnId: '159' }, '중구(부산)': { name: '부산 중구', stnId: '159' }, '서구(부산)': { name: '부산 서구', stnId: '159' }, '동구(부산)': { name: '부산 동구', stnId: '159' }, '영도구': { name: '부산 영도구', stnId: '159' }, '부산진구': { name: '부산 부산진구', stnId: '159' }, '동래구': { name: '부산 동래구', stnId: '159' }, '남구(부산)': { name: '부산 남구', stnId: '159' }, '북구(부산)': { name: '부산 북구', stnId: '159' }, '해운대구': { name: '부산 해운대구', stnId: '159' }, '사하구': { name: '부산 사하구', stnId: '159' }, '금정구': { name: '부산 금정구', stnId: '159' }, '강서구(부산)': { name: '부산 강서구', stnId: '159' }, '연제구': { name: '부산 연제구', stnId: '159' }, '수영구': { name: '부산 수영구', stnId: '159' }, '사상구': { name: '부산 사상구', stnId: '159' }, '기장군': { name: '부산 기장군', stnId: '159' }, // 인천광역시 '인천': { name: '인천', stnId: '112' }, '중구(인천)': { name: '인천 중구', stnId: '112' }, '동구(인천)': { name: '인천 동구', stnId: '112' }, '미추홀구': { name: '인천 미추홀구', stnId: '112' }, '연수구': { name: '인천 연수구', stnId: '112' }, '남동구': { name: '인천 남동구', stnId: '112' }, '부평구': { name: '인천 부평구', stnId: '112' }, '계양구': { name: '인천 계양구', stnId: '112' }, '서구(인천)': { name: '인천 서구', stnId: '112' }, '강화군': { name: '인천 강화군', stnId: '201' }, '옹진군': { name: '인천 옹진군', stnId: '112' }, // 대구광역시 '대구': { name: '대구', stnId: '143' }, '중구(대구)': { name: '대구 중구', stnId: '143' }, '동구(대구)': { name: '대구 동구', stnId: '143' }, '서구(대구)': { name: '대구 서구', stnId: '143' }, '남구(대구)': { name: '대구 남구', stnId: '143' }, '북구(대구)': { name: '대구 북구', stnId: '143' }, '수성구': { name: '대구 수성구', stnId: '143' }, '달서구': { name: '대구 달서구', stnId: '143' }, '달성군': { name: '대구 달성군', stnId: '143' }, // 광주광역시 '광주': { name: '광주', stnId: '156' }, '동구(광주)': { name: '광주 동구', stnId: '156' }, '서구(광주)': { name: '광주 서구', stnId: '156' }, '남구(광주)': { name: '광주 남구', stnId: '156' }, '북구(광주)': { name: '광주 북구', stnId: '156' }, '광산구': { name: '광주 광산구', stnId: '156' }, // 대전광역시 '대전': { name: '대전', stnId: '133' }, '동구(대전)': { name: '대전 동구', stnId: '133' }, '중구(대전)': { name: '대전 중구', stnId: '133' }, '서구(대전)': { name: '대전 서구', stnId: '133' }, '유성구': { name: '대전 유성구', stnId: '133' }, '대덕구': { name: '대전 대덕구', stnId: '133' }, // 울산광역시 '울산': { name: '울산', stnId: '152' }, '중구(울산)': { name: '울산 중구', stnId: '152' }, '남구(울산)': { name: '울산 남구', stnId: '152' }, '동구(울산)': { name: '울산 동구', stnId: '152' }, '북구(울산)': { name: '울산 북구', stnId: '152' }, '울주군': { name: '울산 울주군', stnId: '152' }, // 세종특별자치시 '세종': { name: '세종', stnId: '239' }, // 경기도 '수원': { name: '수원', stnId: '119' }, '성남': { name: '성남', stnId: '119' }, '의정부': { name: '의정부', stnId: '108' }, '안양': { name: '안양', stnId: '119' }, '부천': { name: '부천', stnId: '112' }, '광명': { name: '광명', stnId: '119' }, '평택': { name: '평택', stnId: '119' }, '동두천': { name: '동두천', stnId: '98' }, '안산': { name: '안산', stnId: '119' }, '고양': { name: '고양', stnId: '108' }, '과천': { name: '과천', stnId: '119' }, '구리': { name: '구리', stnId: '108' }, '남양주': { name: '남양주', stnId: '108' }, '오산': { name: '오산', stnId: '119' }, '시흥': { name: '시흥', stnId: '119' }, '군포': { name: '군포', stnId: '119' }, '의왕': { name: '의왕', stnId: '119' }, '하남': { name: '하남', stnId: '108' }, '용인': { name: '용인', stnId: '119' }, '파주': { name: '파주', stnId: '98' }, '이천': { name: '이천', stnId: '203' }, '안성': { name: '안성', stnId: '119' }, '김포': { name: '김포', stnId: '112' }, '화성': { name: '화성', stnId: '119' }, '광주(경기)': { name: '광주(경기)', stnId: '119' }, '양주': { name: '양주', stnId: '98' }, '포천': { name: '포천', stnId: '98' }, '여주': { name: '여주', stnId: '203' }, '연천군': { name: '연천', stnId: '98' }, '가평군': { name: '가평', stnId: '201' }, '양평군': { name: '양평', stnId: '119' }, // 강원도 '춘천': { name: '춘천', stnId: '101' }, '원주': { name: '원주', stnId: '114' }, '강릉': { name: '강릉', stnId: '105' }, '동해': { name: '동해', stnId: '106' }, '태백': { name: '태백', stnId: '216' }, '속초': { name: '속초', stnId: '90' }, '삼척': { name: '삼척', stnId: '106' }, '홍천': { name: '홍천', stnId: '212' }, '횡성': { name: '횡성', stnId: '114' }, '영월': { name: '영월', stnId: '121' }, '평창': { name: '평창', stnId: '100' }, '정선': { name: '정선', stnId: '217' }, '철원': { name: '철원', stnId: '95' }, '화천': { name: '화천', stnId: '212' }, '양구': { name: '양구', stnId: '212' }, '인제': { name: '인제', stnId: '211' }, '고성(강원)': { name: '고성(강원)', stnId: '90' }, '양양': { name: '양양', stnId: '90' }, // 충청북도 '청주': { name: '청주', stnId: '131' }, '충주': { name: '충주', stnId: '127' }, '제천': { name: '제천', stnId: '221' }, '보은': { name: '보은', stnId: '226' }, '옥천': { name: '옥천', stnId: '226' }, '영동': { name: '영동', stnId: '135' }, '증평': { name: '증평', stnId: '131' }, '진천': { name: '진천', stnId: '131' }, '괴산': { name: '괴산', stnId: '127' }, '음성': { name: '음성', stnId: '127' }, '단양': { name: '단양', stnId: '221' }, // 충청남도 '천안': { name: '천안', stnId: '232' }, '공주': { name: '공주', stnId: '236' }, '보령': { name: '보령', stnId: '235' }, '아산': { name: '아산', stnId: '232' }, '서산': { name: '서산', stnId: '129' }, '논산': { name: '논산', stnId: '238' }, '계룡': { name: '계룡', stnId: '238' }, '당진': { name: '당진', stnId: '129' }, '금산': { name: '금산', stnId: '238' }, '부여': { name: '부여', stnId: '236' }, '서천': { name: '서천', stnId: '235' }, '청양': { name: '청양', stnId: '235' }, '홍성': { name: '홍성', stnId: '177' }, '예산': { name: '예산', stnId: '232' }, '태안': { name: '태안', stnId: '129' }, // 전라북도 '전주': { name: '전주', stnId: '146' }, '군산': { name: '군산', stnId: '140' }, '익산': { name: '익산', stnId: '146' }, '정읍': { name: '정읍', stnId: '251' }, '남원': { name: '남원', stnId: '247' }, '김제': { name: '김제', stnId: '140' }, '완주': { name: '완주', stnId: '146' }, '진안': { name: '진안', stnId: '248' }, '무주': { name: '무주', stnId: '248' }, '장수': { name: '장수', stnId: '248' }, '임실': { name: '임실', stnId: '247' }, '순창': { name: '순창', stnId: '254' }, '고창': { name: '고창', stnId: '172' }, '부안': { name: '부안', stnId: '140' }, // 전라남도 '목포': { name: '목포', stnId: '165' }, '여수': { name: '여수', stnId: '168' }, '순천': { name: '순천', stnId: '174' }, '나주': { name: '나주', stnId: '170' }, '광양': { name: '광양', stnId: '168' }, '담양': { name: '담양', stnId: '156' }, '곡성': { name: '곡성', stnId: '252' }, '구례': { name: '구례', stnId: '252' }, '고흥': { name: '고흥', stnId: '262' }, '보성': { name: '보성', stnId: '258' }, '화순': { name: '화순', stnId: '262' }, '장흥': { name: '장흥', stnId: '260' }, '강진': { name: '강진', stnId: '259' }, '해남': { name: '해남', stnId: '261' }, '영암': { name: '영암', stnId: '170' }, '무안': { name: '무안', stnId: '165' }, '함평': { name: '함평', stnId: '172' }, '영광': { name: '영광', stnId: '252' }, '장성': { name: '장성', stnId: '172' }, '완도': { name: '완도', stnId: '261' }, '진도': { name: '진도', stnId: '175' }, '신안': { name: '신안', stnId: '165' }, // 경상북도 '포항': { name: '포항', stnId: '138' }, '경주': { name: '경주', stnId: '283' }, '김천': { name: '김천', stnId: '279' }, '안동': { name: '안동', stnId: '136' }, '구미': { name: '구미', stnId: '279' }, '영주': { name: '영주', stnId: '272' }, '영천': { name: '영천', stnId: '281' }, '상주': { name: '상주', stnId: '137' }, '문경': { name: '문경', stnId: '273' }, '경산': { name: '경산', stnId: '283' }, '군위': { name: '군위', stnId: '278' }, '의성': { name: '의성', stnId: '278' }, '청송': { name: '청송', stnId: '276' }, '영양': { name: '영양', stnId: '277' }, '영덕': { name: '영덕', stnId: '277' }, '청도': { name: '청도', stnId: '284' }, '고령': { name: '고령', stnId: '279' }, '성주': { name: '성주', stnId: '279' }, '칠곡': { name: '칠곡', stnId: '279' }, '예천': { name: '예천', stnId: '273' }, '봉화': { name: '봉화', stnId: '271' }, '울진': { name: '울진', stnId: '130' }, '울릉도': { name: '울릉도', stnId: '115' }, '독도': { name: '독도', stnId: '115' }, // 경상남도 '창원': { name: '창원', stnId: '155' }, '진주': { name: '진주', stnId: '192' }, '통영': { name: '통영', stnId: '162' }, '사천': { name: '사천', stnId: '192' }, '김해': { name: '김해', stnId: '159' }, '밀양': { name: '밀양', stnId: '288' }, '거제': { name: '거제', stnId: '162' }, '양산': { name: '양산', stnId: '288' }, '의령': { name: '의령', stnId: '192' }, '함안': { name: '함안', stnId: '155' }, '창녕': { name: '창녕', stnId: '288' }, '고성(경남)': { name: '고성(경남)', stnId: '162' }, '남해': { name: '남해', stnId: '295' }, '하동': { name: '하동', stnId: '295' }, '산청': { name: '산청', stnId: '289' }, '함양': { name: '함양', stnId: '289' }, '거창': { name: '거창', stnId: '284' }, '합천': { name: '합천', stnId: '285' }, // 제주특별자치도 '제주': { name: '제주', stnId: '184' }, '서귀포': { name: '서귀포', stnId: '189' }, '성산': { name: '제주 성산', stnId: '188' }, '고산': { name: '제주 고산', stnId: '185' }, }; return regions[city] || null; } /** * 관측소 ID를 격자 좌표로 변환 */ function convertStationToGrid(stnId: string): { nx: number; ny: number } { const gridMap: Record = { '108': { nx: 60, ny: 127 }, // 서울 '159': { nx: 98, ny: 76 }, // 부산 '112': { nx: 55, ny: 124 }, // 인천 '143': { nx: 89, ny: 90 }, // 대구 '156': { nx: 58, ny: 74 }, // 광주 '133': { nx: 67, ny: 100 }, // 대전 '152': { nx: 102, ny: 84 }, // 울산 '239': { nx: 66, ny: 103 }, // 세종 '119': { nx: 60, ny: 121 }, // 수원 '101': { nx: 73, ny: 134 }, // 춘천 '184': { nx: 52, ny: 38 }, // 제주 '105': { nx: 92, ny: 131 }, // 강릉 }; return gridMap[stnId] || { nx: 60, ny: 127 }; // 기본값: 서울 } /** * 날짜 포맷 (YYYYMMDD) */ function formatDate(date: Date): string { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); return `${year}${month}${day}`; } /** * 기상청 발표 시각 계산 * 매시간 40분에 발표, 10분 후 조회 가능 */ function getBaseTime(date: Date): string { const hour = date.getHours(); const minute = date.getMinutes(); // 현재 시각이 40분 이전이면 이전 시간 데이터 조회 let baseHour = minute < 40 ? hour - 1 : hour; // 0시 이전이면 전날 23시 if (baseHour < 0) { baseHour = 23; } return String(baseHour).padStart(2, '0') + '00'; } /** * 기상청 API Hub 데이터 파싱 */ function parseKMAHubWeatherData(data: any, regionCode: { name: string; stnId: string }): any { // API Hub 응답 형식: #을 구분자로 사용하는 텍스트 const lines = data.split('\n').filter((line: string) => line.trim() && !line.startsWith('#')); if (lines.length === 0) { throw new Error('날씨 데이터를 파싱할 수 없습니다.'); } // 요청한 관측소(stnId)의 모든 데이터 찾기 (시간 범위 조회 시 여러 줄 반환됨) const targetLines = lines.filter((line: string) => { const cols = line.trim().split(/\s+/); return cols[1] === regionCode.stnId; // STN 컬럼 (인덱스 1) }); if (targetLines.length === 0) { throw new Error(`${regionCode.name} 관측소 데이터를 찾을 수 없습니다.`); } // 가장 최근 데이터 선택 (마지막 줄) const targetLine = targetLines[targetLines.length - 1]; // 데이터 라인 파싱 (공백으로 구분) const values = targetLine.trim().split(/\s+/); // 관측 시각 로깅 const obsTime = values[0]; // YYMMDDHHMI console.log(`🕐 관측 시각: ${obsTime} (${regionCode.name})`); // 기상청 API Hub 데이터 형식 (실제 응답 기준): // [0]YYMMDDHHMI [1]STN [2]WD [3]WS [4]GST_WD [5]GST_WS [6]GST_TM [7]PA [8]PS [9]PT [10]PR [11]TA [12]TD [13]HM [14]PV [15]RN ... const temperature = parseFloat(values[11]) || 0; // TA: 기온 (인덱스 11) const humidity = parseFloat(values[13]) || 0; // HM: 습도 (인덱스 13) const pressure = parseFloat(values[7]) || 1013; // PA: 현지기압 (인덱스 7) const windSpeed = parseFloat(values[3]) || 0; // WS: 풍속 (인덱스 3) const rainfall = parseFloat(values[15]) || 0; // RN: 강수량 (인덱스 15) console.log(`📊 기상 데이터: ${regionCode.name} - 기온 ${temperature}°C, 습도 ${humidity}%, 강수량 ${rainfall}mm`); // 날씨 상태 추정 (강수량 + 습도 기반) let weatherMain = 'Clear'; let weatherDescription = '맑음'; let weatherIcon = '01d'; let clouds = 10; // 강수량이 있으면 비 if (rainfall > 0) { if (rainfall >= 10) { weatherMain = 'Rain'; weatherDescription = '비 (강수)'; weatherIcon = '10d'; clouds = 100; } else { weatherMain = 'Rain'; weatherDescription = '비 (약간)'; weatherIcon = '09d'; clouds = 90; } } // 습도 기반 날씨 추정 else if (humidity > 80) { weatherMain = 'Clouds'; weatherDescription = '흐림'; weatherIcon = '04d'; clouds = 90; } else if (humidity > 60) { weatherMain = 'Clouds'; weatherDescription = '구름 많음'; weatherIcon = '03d'; clouds = 60; } else if (humidity > 40) { weatherMain = 'Clouds'; weatherDescription = '구름 조금'; weatherIcon = '02d'; clouds = 30; } return { city: regionCode.name, country: 'KR', temperature: Math.round(temperature), feelsLike: Math.round(temperature - 2), humidity: Math.round(humidity), pressure: Math.round(pressure), weatherMain, weatherDescription, weatherIcon, windSpeed: Math.round(windSpeed * 10) / 10, clouds, timestamp: new Date().toISOString(), }; } /** * 기상청 데이터 파싱 (구버전 - 호환성 유지) */ function parseKMAWeatherData(items: any[], cityName: string): any { const data: Record = { city: cityName, country: 'KR', temperature: 0, feelsLike: 0, humidity: 0, pressure: 1013, weatherMain: 'Clear', weatherDescription: '맑음', weatherIcon: '01d', windSpeed: 0, clouds: 0, timestamp: new Date().toISOString(), }; items.forEach((item: any) => { const category = item.category; const value = parseFloat(item.obsrValue); switch (category) { case 'T1H': // 기온 (°C) data.temperature = Math.round(value); data.feelsLike = Math.round(value - 2); // 체감온도 근사값 break; case 'RN1': // 1시간 강수량 (mm) if (value > 0) { data.weatherMain = 'Rain'; data.weatherDescription = '비'; data.weatherIcon = '10d'; } break; case 'REH': // 습도 (%) data.humidity = Math.round(value); break; case 'WSD': // 풍속 (m/s) data.windSpeed = value; break; case 'SKY': // 하늘상태 (1:맑음, 3:구름많음, 4:흐림) if (value === 1) { data.weatherMain = 'Clear'; data.weatherDescription = '맑음'; data.weatherIcon = '01d'; data.clouds = 10; } else if (value === 3) { data.weatherMain = 'Clouds'; data.weatherDescription = '구름 많음'; data.weatherIcon = '02d'; data.clouds = 60; } else if (value === 4) { data.weatherMain = 'Clouds'; data.weatherDescription = '흐림'; data.weatherIcon = '04d'; data.clouds = 90; } break; case 'PTY': // 강수형태 (0:없음, 1:비, 2:비/눈, 3:눈, 4:소나기) if (value === 1 || value === 4) { data.weatherMain = 'Rain'; data.weatherDescription = value === 4 ? '소나기' : '비'; data.weatherIcon = value === 4 ? '09d' : '10d'; } else if (value === 2) { data.weatherMain = 'Rain'; data.weatherDescription = '진눈깨비'; data.weatherIcon = '13d'; } else if (value === 3) { data.weatherMain = 'Snow'; data.weatherDescription = '눈'; data.weatherIcon = '13d'; } break; } }); return data; } /** * 실시간처럼 보이는 테스트 날씨 데이터 생성 * (API 키 발급 전까지 임시 사용) */ function generateRealisticWeatherData(cityName: string): any { const now = new Date(); const hour = now.getHours(); const month = now.getMonth() + 1; // 시간대별 기온 변화 let baseTemp = 15; if (hour >= 6 && hour < 9) baseTemp = 12; // 새벽 else if (hour >= 9 && hour < 12) baseTemp = 18; // 오전 else if (hour >= 12 && hour < 15) baseTemp = 22; // 낮 else if (hour >= 15 && hour < 18) baseTemp = 20; // 오후 else if (hour >= 18 && hour < 21) baseTemp = 16; // 저녁 else baseTemp = 13; // 밤 // 계절별 보정 if (month >= 12 || month <= 2) baseTemp -= 8; // 겨울 else if (month >= 3 && month <= 5) baseTemp += 2; // 봄 else if (month >= 6 && month <= 8) baseTemp += 8; // 여름 else baseTemp += 3; // 가을 // 랜덤 변동 (-2 ~ +2) const tempVariation = Math.floor(Math.random() * 5) - 2; const temperature = baseTemp + tempVariation; // 날씨 상태 (확률 기반) const weatherRandom = Math.random(); let weatherMain = 'Clear'; let weatherDescription = '맑음'; let weatherIcon = '01d'; let clouds = 10; if (weatherRandom < 0.1) { // 10% 비 weatherMain = 'Rain'; weatherDescription = '비'; weatherIcon = '10d'; clouds = 80; } else if (weatherRandom < 0.25) { // 15% 흐림 weatherMain = 'Clouds'; weatherDescription = '흐림'; weatherIcon = '04d'; clouds = 90; } else if (weatherRandom < 0.5) { // 25% 구름 많음 weatherMain = 'Clouds'; weatherDescription = '구름 많음'; weatherIcon = '02d'; clouds = 60; } // 50% 맑음 (기본값) return { city: cityName, country: 'KR', temperature: Math.round(temperature), feelsLike: Math.round(temperature - 2), humidity: Math.floor(Math.random() * 30) + 50, // 50-80% pressure: Math.floor(Math.random() * 15) + 1008, // 1008-1023 hPa weatherMain, weatherDescription, weatherIcon, windSpeed: Math.random() * 4 + 1, // 1-5 m/s clouds, timestamp: now.toISOString(), }; } /** * 공공데이터포털 초단기실황 데이터 파싱 */ function parseKMADataWeatherData(data: any, gridCoord: { name: string; nx: number; ny: number }): any { const header = data.response?.header; const body = data.response?.body; if (header?.resultCode !== '00') { throw new Error(`API 오류: ${header?.resultMsg || '알 수 없는 오류'}`); } const items = body?.items?.item || []; if (items.length === 0) { throw new Error('날씨 데이터가 없습니다.'); } // 초단기실황 응답 항목: // T1H: 기온, RN1: 1시간 강수량, REH: 습도, PTY: 강수형태, WSD: 풍속 const weatherMap: Record = {}; items.forEach((item: any) => { weatherMap[item.category] = item.obsrValue; }); const temperature = parseFloat(weatherMap['T1H']) || 0; // 기온 const humidity = parseFloat(weatherMap['REH']) || 0; // 습도 const rainfall = parseFloat(weatherMap['RN1']) || 0; // 1시간 강수량 const precipType = parseInt(weatherMap['PTY']) || 0; // 강수형태 (0: 없음, 1: 비, 2: 비/눈, 3: 눈, 4: 소나기) const windSpeed = parseFloat(weatherMap['WSD']) || 0; // 풍속 console.log(`📊 기상 데이터: ${gridCoord.name} - 기온 ${temperature}°C, 습도 ${humidity}%, 강수 ${rainfall}mm`); // 날씨 상태 결정 let weatherMain = 'Clear'; let weatherDescription = '맑음'; let weatherIcon = '01d'; let clouds = 10; // 강수형태 기반 if (precipType > 0) { if (precipType === 1 || precipType === 4) { weatherMain = 'Rain'; weatherDescription = rainfall >= 10 ? '비 (강수)' : '비'; weatherIcon = rainfall >= 10 ? '10d' : '09d'; clouds = 100; } else if (precipType === 2) { weatherMain = 'Snow'; weatherDescription = '눈/비'; weatherIcon = '13d'; clouds = 100; } else if (precipType === 3) { weatherMain = 'Snow'; weatherDescription = '눈'; weatherIcon = '13d'; clouds = 100; } } // 습도 기반 else if (humidity > 80) { weatherMain = 'Clouds'; weatherDescription = '흐림'; weatherIcon = '04d'; clouds = 90; } else if (humidity > 60) { weatherMain = 'Clouds'; weatherDescription = '구름 많음'; weatherIcon = '03d'; clouds = 60; } else if (humidity > 40) { weatherMain = 'Clouds'; weatherDescription = '구름 조금'; weatherIcon = '02d'; clouds = 30; } return { city: gridCoord.name, country: 'KR', temperature: Math.round(temperature), feelsLike: Math.round(temperature - 2), humidity: Math.round(humidity), pressure: 1013, // 초단기실황에는 기압 정보 없음 weatherMain, weatherDescription, weatherIcon, windSpeed: Math.round(windSpeed * 10) / 10, clouds, timestamp: new Date().toISOString(), }; } /** * 도시명 → 격자 좌표 매핑 * 공공데이터포털 초단기실황 API는 격자(nx, ny) 기반 */ function getGridCoordinates(city: string): { name: string; nx: number; ny: number } | null { const grids: Record = { // 영어 도시명 'Seoul': { name: '서울', nx: 60, ny: 127 }, 'Busan': { name: '부산', nx: 98, ny: 76 }, 'Incheon': { name: '인천', nx: 55, ny: 124 }, 'Daegu': { name: '대구', nx: 89, ny: 90 }, 'Gwangju': { name: '광주', nx: 58, ny: 74 }, 'Daejeon': { name: '대전', nx: 67, ny: 100 }, 'Ulsan': { name: '울산', nx: 102, ny: 84 }, 'Sejong': { name: '세종', nx: 66, ny: 103 }, 'Jeju': { name: '제주', nx: 52, ny: 38 }, // 서울 (중구 기준) '서울': { name: '서울', nx: 60, ny: 127 }, '종로구': { name: '서울 종로구', nx: 60, ny: 127 }, '중구': { name: '서울 중구', nx: 60, ny: 127 }, '용산구': { name: '서울 용산구', nx: 60, ny: 126 }, '성동구': { name: '서울 성동구', nx: 61, ny: 127 }, '광진구': { name: '서울 광진구', nx: 62, ny: 126 }, '동대문구': { name: '서울 동대문구', nx: 61, ny: 127 }, '중랑구': { name: '서울 중랑구', nx: 62, ny: 128 }, '성북구': { name: '서울 성북구', nx: 61, ny: 127 }, '강북구': { name: '서울 강북구', nx: 61, ny: 128 }, '도봉구': { name: '서울 도봉구', nx: 61, ny: 129 }, '노원구': { name: '서울 노원구', nx: 61, ny: 129 }, '은평구': { name: '서울 은평구', nx: 59, ny: 127 }, '서대문구': { name: '서울 서대문구', nx: 59, ny: 127 }, '마포구': { name: '서울 마포구', nx: 59, ny: 127 }, '양천구': { name: '서울 양천구', nx: 58, ny: 126 }, '강서구': { name: '서울 강서구', nx: 58, ny: 126 }, '구로구': { name: '서울 구로구', nx: 58, ny: 125 }, '금천구': { name: '서울 금천구', nx: 59, ny: 124 }, '영등포구': { name: '서울 영등포구', nx: 58, ny: 126 }, '동작구': { name: '서울 동작구', nx: 59, ny: 125 }, '관악구': { name: '서울 관악구', nx: 59, ny: 125 }, '서초구': { name: '서울 서초구', nx: 61, ny: 125 }, '강남구': { name: '서울 강남구', nx: 61, ny: 126 }, '송파구': { name: '서울 송파구', nx: 62, ny: 126 }, '강동구': { name: '서울 강동구', nx: 62, ny: 126 }, // 부산 '부산': { name: '부산', nx: 98, ny: 76 }, '해운대': { name: '부산 해운대구', nx: 99, ny: 75 }, '해운대구': { name: '부산 해운대구', nx: 99, ny: 75 }, // 인천 '인천': { name: '인천', nx: 55, ny: 124 }, // 대구 '대구': { name: '대구', nx: 89, ny: 90 }, // 광주 '광주': { name: '광주', nx: 58, ny: 74 }, // 대전 '대전': { name: '대전', nx: 67, ny: 100 }, // 울산 '울산': { name: '울산', nx: 102, ny: 84 }, // 세종 '세종': { name: '세종', nx: 66, ny: 103 }, // 경기도 주요 도시 '수원': { name: '수원', nx: 60, ny: 121 }, '성남': { name: '성남', nx: 62, ny: 123 }, '고양': { name: '고양', nx: 57, ny: 128 }, '용인': { name: '용인', nx: 64, ny: 119 }, '화성': { name: '화성', nx: 57, ny: 119 }, '부천': { name: '부천', nx: 56, ny: 125 }, // 화성시 읍/면/동 '영천동': { name: '화성 영천동', nx: 57, ny: 119 }, '화성 영천동': { name: '화성 영천동', nx: 57, ny: 119 }, '봉담읍': { name: '화성 봉담읍', nx: 57, ny: 120 }, '화성 봉담읍': { name: '화성 봉담읍', nx: 57, ny: 120 }, '동탄': { name: '화성 동탄', nx: 61, ny: 120 }, '화성 동탄': { name: '화성 동탄', nx: 61, ny: 120 }, // 성남시 구/동 '분당구': { name: '성남 분당구', nx: 62, ny: 123 }, '성남 분당구': { name: '성남 분당구', nx: 62, ny: 123 }, '분당': { name: '성남 분당구', nx: 62, ny: 123 }, '판교': { name: '성남 판교', nx: 62, ny: 124 }, '성남 판교': { name: '성남 판교', nx: 62, ny: 124 }, // 용인시 '수지구': { name: '용인 수지구', nx: 62, ny: 121 }, '용인 수지구': { name: '용인 수지구', nx: 62, ny: 121 }, '기흥구': { name: '용인 기흥구', nx: 61, ny: 120 }, '용인 기흥구': { name: '용인 기흥구', nx: 61, ny: 120 }, '처인구': { name: '용인 처인구', nx: 64, ny: 119 }, '용인 처인구': { name: '용인 처인구', nx: 64, ny: 119 }, // 강원도 '춘천': { name: '춘천', nx: 73, ny: 134 }, '강릉': { name: '강릉', nx: 92, ny: 131 }, '원주': { name: '원주', nx: 76, ny: 122 }, // 충청도 '청주': { name: '청주', nx: 69, ny: 107 }, '천안': { name: '천안', nx: 63, ny: 110 }, // 전라도 '전주': { name: '전주', nx: 63, ny: 89 }, '목포': { name: '목포', nx: 50, ny: 67 }, '여수': { name: '여수', nx: 73, ny: 66 }, // 경상도 '포항': { name: '포항', nx: 102, ny: 94 }, '창원': { name: '창원', nx: 90, ny: 77 }, '진주': { name: '진주', nx: 90, ny: 75 }, // 제주도 '제주': { name: '제주', nx: 52, ny: 38 }, '서귀포': { name: '서귀포', nx: 52, ny: 33 }, }; return grids[city] || null; } /** * 공공데이터포털 초단기실황 응답 파싱 * @param apiResponse - 공공데이터포털 API 응답 데이터 * @param gridInfo - 격자 좌표 정보 * @returns 표준화된 날씨 데이터 */ function parseDataPortalWeatherData(apiResponse: any, gridInfo: { name: string; nx: number; ny: number }) { try { const response = apiResponse.response; const header = response.header; const body = response.body; // API 응답 코드 확인 if (header.resultCode !== '00') { console.error('❌ 공공데이터포털 API 오류:', header.resultMsg); throw new Error(`API Error: ${header.resultMsg}`); } const items = body.items.item; if (!items || items.length === 0) { throw new Error('날씨 데이터가 없습니다.'); } // 카테고리별 데이터 추출 const dataMap: Record = {}; items.forEach((item: any) => { dataMap[item.category] = item.obsrValue; }); // T1H: 기온(°C) // RN1: 1시간 강수량(mm) // REH: 습도(%) // WSD: 풍속(m/s) // VEC: 풍향(deg) // PTY: 강수형태 (0:없음, 1:비, 2:비/눈, 3:눈, 5:빗방울, 6:빗방울눈날림, 7:눈날림) const temperature = parseFloat(dataMap['T1H'] || '0'); const humidity = parseFloat(dataMap['REH'] || '0'); const windSpeed = parseFloat(dataMap['WSD'] || '0'); const precipitation = parseFloat(dataMap['RN1'] || '0'); const ptyCode = dataMap['PTY'] || '0'; // 강수형태 → 날씨 상태 매핑 let weatherStatus = 'clear'; let weatherDescription = '맑음'; if (ptyCode === '1' || ptyCode === '5') { weatherStatus = 'rain'; weatherDescription = '비'; } else if (ptyCode === '2' || ptyCode === '6') { weatherStatus = 'rain'; weatherDescription = '비/눈'; } else if (ptyCode === '3' || ptyCode === '7') { weatherStatus = 'snow'; weatherDescription = '눈'; } else if (temperature > 25) { weatherDescription = '맑음'; } else if (temperature < 5) { weatherDescription = '추움'; } return { city: gridInfo.name, temperature: Math.round(temperature * 10) / 10, humidity: Math.round(humidity), windSpeed: Math.round(windSpeed * 10) / 10, precipitation: Math.round(precipitation * 10) / 10, weather: weatherStatus, description: weatherDescription, timestamp: new Date().toISOString(), }; } catch (error) { console.error('❌ 공공데이터포털 응답 파싱 실패:', error); throw error; } } /** * 통화 코드 → 한국은행 통계코드 매핑 * @param currencyCode - ISO 통화 코드 (USD, EUR, JPY 등) * @returns 한국은행 통계코드 */ function getBOKStatCode(currencyCode: string): string | null { const statCodes: Record = { // 주요 통화 (매일 고시) 'USD': '0000001', // 미국 달러 'JPY': '0000002', // 일본 엔 (100엔) 'EUR': '0000003', // 유럽연합 유로 'CNY': '0000053', // 중국 위안 'GBP': '0000002', // 영국 파운드 'AUD': '0000007', // 호주 달러 'CAD': '0000009', // 캐나다 달러 'CHF': '0000017', // 스위스 프랑 'HKD': '0000019', // 홍콩 달러 'SEK': '0000020', // 스웨덴 크로나 'NOK': '0000021', // 노르웨이 크로네 'DKK': '0000022', // 덴마크 크로네 'SGD': '0000024', // 싱가포르 달러 'NZD': '0000026', // 뉴질랜드 달러 'THB': '0000028', // 태국 바트 'MYR': '0000029', // 말레이시아 링깃 'IDR': '0000030', // 인도네시아 루피아 (100루피아) 'PHP': '0000031', // 필리핀 페소 'INR': '0000032', // 인도 루피 'BRL': '0000034', // 브라질 레알 }; return statCodes[currencyCode] || null; } /** * 한국은행 API 응답 파싱 * @param apiResponse - 한국은행 API 응답 데이터 * @param base - 기준 통화 * @param target - 대상 통화 * @param isReverse - 역수 계산 필요 여부 (KRW → USD) * @returns 표준화된 환율 데이터 */ function parseBOKExchangeData( apiResponse: any, base: string, target: string, isReverse: boolean ) { try { // API 응답 구조: { StatisticSearch: { list_total_count, row: [...] } } const result = apiResponse.StatisticSearch; if (!result || result.list_total_count === '0') { throw new Error('환율 데이터가 없습니다. (주말/공휴일일 수 있습니다)'); } const row = result.row[0]; const rate = parseFloat(row.DATA_VALUE); // KRW → USD (역수 계산) const finalRate = isReverse ? (1 / rate) : rate; return { base, target, rate: Math.round(finalRate * 100) / 100, // 소수점 2자리 timestamp: new Date().toISOString(), source: '한국은행 (BOK)', }; } catch (error) { console.error('❌ 한국은행 API 응답 파싱 실패:', error); throw error; } } /** * 테스트 환율 데이터 생성 * @param base - 기준 통화 * @param target - 대상 통화 * @returns 테스트 환율 데이터 */ function generateTestExchangeRate(base: string, target: string) { // 실제 환율 기준 (2024년 평균) const baseRates: Record = { 'USD': 1300, // 1 USD = 1300 KRW 'EUR': 1400, // 1 EUR = 1400 KRW 'JPY': 9.0, // 1 JPY = 9 KRW (100엔 기준) 'CNY': 180, // 1 CNY = 180 KRW 'GBP': 1650, // 1 GBP = 1650 KRW }; let rate: number; if (base === 'KRW' && baseRates[target]) { // KRW → USD: 1/1300 = 0.00077 rate = 1 / baseRates[target]; } else if (target === 'KRW' && baseRates[base]) { // USD → KRW: 1300 rate = baseRates[base]; } else if (base === 'KRW' && target === 'KRW') { rate = 1; } else { // 지원하지 않는 통화 쌍 rate = 1.0; } // 약간의 랜덤 변동 (±1%) const variation = 0.98 + Math.random() * 0.04; rate = rate * variation; return { base, target, rate: Math.round(rate * 100) / 100, timestamp: new Date().toISOString(), source: 'TEST_DATA', }; } /** * 실제 근사값 환율 데이터 생성 */ function generateRealisticExchangeRate(base: string, target: string) { const baseRates: Record = { 'USD': 1380, 'EUR': 1500, 'JPY': 9.2, 'CNY': 195, 'GBP': 1790, }; let rate = 1; if (base === 'KRW' && baseRates[target]) { rate = 1 / baseRates[target]; } else if (target === 'KRW' && baseRates[base]) { rate = baseRates[base]; } else if (baseRates[base] && baseRates[target]) { rate = baseRates[target] / baseRates[base]; } return { base, target, rate, timestamp: new Date().toISOString(), source: 'ExchangeRate-API (Cache)' }; }