기상청 단기예보 API 활용 신청하기
회원 가입후 활용신청 가능 합니다.
기상청_단기예보 ((구)_동네예보) 조회서비스 | 공공데이터포털
기상청_단기예보 ((구)_동네예보) 조회서비스
초단기실황, 초단기예보, 단기((구)동네)예보, 예보버전 정보를 조회하는 서비스입니다. 초단기실황정보는 예보 구역에 대한 대표 AWS 관측값을, 초단기예보는 예보시점부터 6시간까지의 예보를,
www.data.go.kr
프로젝트 구조
Android, Windows, Web 만 적용합니다.iOS는 나중에..
Flutter Dependencies 추가
pubspec.yaml
dependencies:
flutter:
sdk: flutter
intl: '^0.20.2'
flutter_compass: '^0.8.1'
permission_handler: '^11.4.0'
http: '^1.3.0'
geolocator: '^13.0.3'
- 날짜, 시간, 숫자, 통화 등의 국제화/지역화(i18n) 기능 제공
- Dart의 DateFormat, NumberFormat 등의 클래스 포함
- initializeDateFormatting()을 통해 로케일 데이터를 초기화해야 요일 등 한글로 표시 가능
DateFormat('yyyy년 MM월 dd일 (E)', 'ko_KR').format(DateTime.now());
- 기기의 나침반 센서에서 방위(degree) 값을 실시간으로 가져올 수 있음
- 기기 방향(0~359도)을 감지해서 동서남북 출력 가능
- Android 6.0+에서는 위치 권한이 필수
- 에뮬레이터에서는 센서가 없어서 작동 안 됨
FlutterCompass.events?.listen((event) {
print('Heading: ${event.heading}');
});
3. permission_handler: ^11.4.0
- Flutter에서 런타임 권한 요청을 손쉽게 처리하는 패키지
- 위치, 카메라, 마이크, 저장소 등 다양한 퍼미션을 제어
- Android 13+부터는 블루투스, 알림 등도 별도 퍼미션 필요
- iOS는 Info.plist에 이유 설명 문구를 추가해야 함
var status = await Permission.location.request();
if (status.isGranted) {
// 권한 허용됨
}
4. http: ^1.3.0
- Flutter/Dart에서 REST API 호출을 위한 HTTP 클라이언트
- GET, POST 등 다양한 요청 가능
- JSON 파싱을 위해 dart:convert의 jsonDecode() 사용
- 네트워크 호출은 항상 async/await 사용
final response = await http.get(Uri.parse('https://example.com/api'));
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
}
- 현재 위치 정보, 거리 계산, 위치 스트리밍 등 위치 기반 서비스를 제공
- Android/iOS에서 모두 동작
- ACCESS_FINE_LOCATION 권한이 필수
- Android에서는 Google Play Services 필요
- iOS에서는 NSLocationWhenInUseUsageDescription 설정 필요
Position position = await Geolocator.getCurrentPosition();
print(position.latitude);
안드로이드 권한 부여
app/src/main/AndroidManifest.xml
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
1. <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
- 설명: 대략적인 위치 (Wi-Fi, 셀룰러 타워 기반)를 얻기 위한 권한
- 정확도: 수백 미터 ~ 1km 정도
- 사용 예:
- 날씨 정보 요청 시, 정밀한 위치가 필요하지 않을 때
- 실내 위치 기반 서비스
- 기타: Android 10 이상에서는 ACCESS_FINE_LOCATION과 함께 쓰는 게 일반적
2. <uses-permission android:name="android.permission.BLUETOOTH" />
- 설명: 블루투스 기능 사용을 위한 기본 권한
- 사용 예:
- 기기 간 블루투스 통신
- 주의: Android 12(API 31)+에서는 BLUETOOTH_CONNECT 같은 세분화된 권한이 추가되었기 때문에 이 권한만으론 부족할 수 있음
3. <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
- 설명: 블루투스 설정 변경 등 고급 기능 제어 권한
- 사용 예:
- 블루투스 ON/OFF
- 페어링 제어 등
- 추가: Android 12부터는 대부분의 기능이 BLUETOOTH_CONNECT로 대체됨
4. <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
- Android 12 이상 필수
- 설명: 블루투스 장치와 연결하거나 이름, MAC 주소 등을 식별하는 데 필요한 권한
- 사용 예:
- 블루투스 기기 연결 (예: 웨어러블, Beacon)
- 중요: Android 12(API 31)+에서는 반드시 명시적 사용자 허용이 필요함
5. <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
- 설명: 정확한 위치(GPS 기반) 를 얻기 위한 권한
- 정확도: 수 미터 이내
- 사용 예:
- 현재 위치 기반 날씨, 지도, 방향 감지 (나침반 포함)
- Geolocator 또는 flutter_compass 사용 시 필수
전체 코드
1. services/ClockService.dart
import 'dart:async'; // Timer 사용을 위한 import
/// 시계 기능을 분리하여 제공하는 서비스 클래스.
/// 1초마다 현재 시간을 콜백 함수로 전달함.
class ClockService {
/// startClock()
/// - 1초마다 현재 시간을 전달하는 타이머를 생성함
/// - [onTick]은 매초 현재 시간을 전달받는 콜백 함수
/// - 반환값은 [Timer] 객체로, 필요시 타이머를 종료할 수 있음
Timer startClock(void Function(DateTime) onTick) {
return Timer.periodic(
const Duration(seconds: 1), // 1초마다 반복
(_) {
// 현재 시간(DateTime.now())을 콜백 함수로 전달
onTick(DateTime.now());
},
);
}
}
2. services/CompassService.dart
import 'package:flutter/material.dart';
import 'package:flutter_compass/flutter_compass.dart'; // 나침반 센서 패키지
import 'package:permission_handler/permission_handler.dart'; // 권한 요청을 위한 패키지
/// 기기의 나침반 센서를 초기화하고 방위각(heading)을 감지하는 서비스 클래스
class CompassService {
/// initCompass()
/// - 위치 권한을 요청하고, 허용되면 나침반 센서를 구독 시작
/// - [onHeadingChanged]는 방위각(0~359.9도)을 콜백으로 전달받음
/// - 방위각은 북쪽 기준 (0도: 북, 90도: 동, 180도: 남, 270도: 서)
void initCompass(void Function(double?) onHeadingChanged) async {
// 위치 권한 요청
var status = await Permission.locationWhenInUse.request();
// 권한이 허용되었을 경우
if (status.isGranted) {
// FlutterCompass의 스트림을 구독하여 heading 값 수신
FlutterCompass.events?.listen((event) {
// heading 값이 null일 수도 있으므로 안전하게 전달
onHeadingChanged(event.heading);
});
} else {
// 권한이 거부된 경우 디버그 메시지 출력
debugPrint('위치 권한 거부됨.');
}
}
}
3. services/WeatherService.dart
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; // 날짜 형식화
import 'package:geolocator/geolocator.dart'; // 위치 정보
import 'package:http/http.dart' as http; // API 요청용
/// 기상청 초단기실황 API를 통해 현재 위치 기반 날씨 정보를 가져오는 서비스
class WeatherService {
/// 공공데이터포털에서 발급받은 기상청 API 서비스 키 (URL 인코딩됨)
final String _serviceKey =
'H5ONUP4Pu4N7SA9ayhHIgJYVKRKKv6IwqXKr8M9%2F%2B%2F9L8eM5NAH%2BwJMY8sH1jk1Mn15FO%2BkzjVlA22XDvE%2FUMQ%3D%3D';
/// 현재 위치 기반으로 날씨 정보(온도, 습도, 바람)를 가져오는 메서드
/// 반환: 온도(temp), 습도(humid), 바람(wind)를 담은 Map (또는 null)
Future<Map<String, dynamic>?> fetchWeather() async {
// 위치 권한 확인 및 요청
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) {
await Geolocator.requestPermission();
}
// 현재 GPS 위치 가져오기
final position = await Geolocator.getCurrentPosition();
// 위도, 경도를 기상청 격자 좌표(nx, ny)로 변환
final grid = _convertToGrid(position.latitude, position.longitude);
// 현재 날짜와 시간을 기준으로 기상청에 요청할 base_date, base_time 설정
final now = DateTime.now();
final baseDate = DateFormat('yyyyMMdd').format(now); // ex: 20250325
final baseTime = DateFormat('HH00').format(now.subtract(const Duration(hours: 1))); // ex: 0900
// 초단기실황 API 요청 URL 구성
final url = Uri.parse(
'https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0/getUltraSrtNcst'
'?serviceKey=$_serviceKey&numOfRows=100&dataType=JSON'
'&base_date=$baseDate&base_time=$baseTime&nx=${grid.x}&ny=${grid.y}'
);
// HTTP GET 요청 전송
final response = await http.get(url);
// 응답 성공 시(JSON 파싱 후 필요한 값 추출)
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
final items = data['response']['body']['items']['item'];
// 필요한 날씨 데이터 초기화
double? temp, humid, wind;
// 항목을 순회하며 T1H(기온), REH(습도), WSD(풍속) 추출
for (var item in items) {
switch (item['category']) {
case 'T1H': // 기온
temp = double.tryParse(item['obsrValue'].toString());
break;
case 'REH': // 습도
humid = double.tryParse(item['obsrValue'].toString());
break;
case 'WSD': // 풍속
wind = double.tryParse(item['obsrValue'].toString());
break;
}
}
// 추출한 데이터 반환
return {'temp': temp, 'humid': humid, 'wind': wind};
} else {
// 응답 실패 시 로그 출력
debugPrint('기상청 API 오류: ${response.body}');
return null;
}
}
/// 위도, 경도를 기상청 격자 좌표(nx, ny)로 변환하는 함수
GridXY _convertToGrid(double lat, double lon) {
// 기상청에서 정의한 변환 상수
const double RE = 6371.00877; // 지구 반지름 (km)
const double GRID = 5.0; // 격자 간격 (km)
const double SLAT1 = 30.0; // 표준 위도 1
const double SLAT2 = 60.0; // 표준 위도 2
const double OLON = 126.0; // 기준점 경도
const double OLAT = 38.0; // 기준점 위도
const double XO = 43; // 기준점 X 좌표
const double YO = 136; // 기준점 Y 좌표
// 변환 수식 계산
double DEGRAD = pi / 180.0;
double re = RE / GRID;
double slat1 = SLAT1 * DEGRAD;
double slat2 = SLAT2 * DEGRAD;
double olon = OLON * DEGRAD;
double olat = OLAT * DEGRAD;
double sn = tan(pi * 0.25 + slat2 * 0.5) / tan(pi * 0.25 + slat1 * 0.5);
sn = log(cos(slat1) / cos(slat2)) / log(sn);
double sf = tan(pi * 0.25 + slat1 * 0.5);
sf = pow(sf, sn) * cos(slat1) / sn;
double ro = tan(pi * 0.25 + olat * 0.5);
ro = re * sf / pow(ro, sn);
double ra = tan(pi * 0.25 + lat * DEGRAD * 0.5);
ra = re * sf / pow(ra, sn);
double theta = lon * DEGRAD - olon;
if (theta > pi) theta -= 2.0 * pi;
if (theta < -pi) theta += 2.0 * pi;
theta *= sn;
// 최종 격자 좌표 계산
int x = (ra * sin(theta) + XO + 0.5).floor();
int y = (ro - ra * cos(theta) + YO + 0.5).floor();
return GridXY(x, y);
}
}
/// 기상청 API에 사용되는 격자 좌표 (nx, ny)
class GridXY {
final int x;
final int y;
GridXY(this.x, this.y);
}
4. screen/DigitalClockScreen.dart
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:digital_clock/services/ClockService.dart'; // 시계 타이머 서비스
import 'package:digital_clock/services/CompassService.dart'; // 나침반 센서 서비스
import 'package:digital_clock/services/WeatherService.dart'; // 기상청 날씨 서비스
/// 디지털 시계 + 날씨 + 나침반을 보여주는 화면
class DigitalClockScreen extends StatefulWidget {
const DigitalClockScreen({super.key});
@override
State<DigitalClockScreen> createState() => _DigitalClockScreenState();
}
class _DigitalClockScreenState extends State<DigitalClockScreen> {
// 서비스 객체 선언
final ClockService _clockService = ClockService();
final CompassService _compassService = CompassService();
final WeatherService _weatherService = WeatherService();
// 상태 변수들
DateTime _now = DateTime.now(); // 현재 시각
double? _heading; // 나침반 각도
Map<String, dynamic>? _weather; // 날씨 데이터
Timer? _timer; // 시계 타이머
@override
void initState() {
super.initState();
_startClock(); // 시계 시작
_initCompass(); // 나침반 시작
_fetchWeather(); // 날씨 정보 불러오기
}
/// 1초마다 현재 시간을 갱신하는 타이머 시작
void _startClock() {
_timer = _clockService.startClock((now) {
setState(() {
_now = now;
});
});
}
/// 나침반 센서 초기화 및 heading 값 수신
void _initCompass() {
_compassService.initCompass((heading) {
setState(() {
_heading = heading;
});
});
}
/// 현재 위치 기반으로 날씨 정보 가져오기
void _fetchWeather() async {
final weather = await _weatherService.fetchWeather();
if (weather != null) {
setState(() {
_weather = weather;
});
}
}
/// 타이머 해제 (리소스 정리)
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
/// heading(각도)을 방향 문자열로 변환 (N, NE, E, ... 등)
String _getDirection(double? degree) {
if (degree == null) return "방향 알 수 없음";
if (degree >= 337.5 || degree < 22.5) return "북 (N)";
if (degree >= 22.5 && degree < 67.5) return "북동 (NE)";
if (degree >= 67.5 && degree < 112.5) return "동 (E)";
if (degree >= 112.5 && degree < 157.5) return "남동 (SE)";
if (degree >= 157.5 && degree < 202.5) return "남 (S)";
if (degree >= 202.5 && degree < 247.5) return "남서 (SW)";
if (degree >= 247.5 && degree < 292.5) return "서 (W)";
if (degree >= 292.5 && degree < 337.5) return "북서 (NW)";
return "알 수 없음";
}
@override
Widget build(BuildContext context) {
final direction = _getDirection(_heading);
return Scaffold(
appBar: AppBar(
title: const Text('Andrew 나침반'), // 앱 상단 타이틀
centerTitle: true,
),
body: Padding(
padding: const EdgeInsets.all(24),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
// 📅 날짜 표시
Text(
DateFormat('yyyy년 MM월 dd일 (E)', 'ko_KR').format(_now),
style: const TextStyle(fontSize: 24),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
// 🕐 현재 시간 표시 (시:분:초)
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
DateFormat('HH:mm:ss').format(_now),
style: const TextStyle(
fontSize: 100,
fontWeight: FontWeight.bold,
letterSpacing: 4,
),
),
),
const SizedBox(height: 30),
// 🌤 날씨 정보 표시 (온도, 습도, 바람)
if (_weather != null)
Center(
child: Column(
children: [
Text('🌡️ 온도: ${_weather!["temp"]}°C',
style: const TextStyle(fontSize: 18)),
Text('💧 습도: ${_weather!["humid"]}%',
style: const TextStyle(fontSize: 18)),
Text('🌬️ 바람: ${_weather!["wind"]} m/s',
style: const TextStyle(fontSize: 18)),
],
),
)
else
const CircularProgressIndicator(), // 날씨 로딩 중
const SizedBox(height: 30),
// 🧭 나침반 정보 표시
if (_heading != null)
Column(
children: [
// 방향에 따라 회전하는 나침반 아이콘
Transform.rotate(
angle: (_heading! * pi / 180 * -1),
child: const Icon(Icons.navigation,
size: 60, color: Colors.blueAccent),
),
const SizedBox(height: 12),
// 방향 텍스트 + 각도
Text(
'$direction\n${_heading!.toStringAsFixed(1)}°',
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 20),
),
],
)
else
const Text(
'나침반 센서를 찾을 수 없습니다.',
style: TextStyle(fontSize: 16),
),
],
),
),
),
);
}
}
5. main.dart
import 'package:flutter/material.dart';
import 'package:intl/date_symbol_data_local.dart'; // 날짜 포맷의 로케일(언어/문화권) 초기화를 위한 패키지
import 'package:digital_clock/screen/DigitalClockScreen.dart'; // 디지털 시계 화면 (UI) import
/// 앱 실행의 진입점 (entry point)
void main() async {
WidgetsFlutterBinding.ensureInitialized(); // Flutter 바인딩을 미리 초기화 (비동기 작업을 위해 필요)
// intl 패키지 사용 시 한국어 요일, 날짜 포맷 등을 위해 로케일 데이터를 초기화
await initializeDateFormatting('ko_KR', null);
// 앱 실행
runApp(const DigitalClockApp());
}
/// 앱 루트 위젯 (MaterialApp 설정 및 첫 화면 정의)
class DigitalClockApp extends StatelessWidget {
const DigitalClockApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Andrew 나침반', // 앱 타이틀 (Android 태스크 스위처나 iOS에서 보일 수 있음)
theme: ThemeData.dark(), // 다크 테마 사용
home: const DigitalClockScreen(), // 첫 화면은 DigitalClockScreen
);
}
}
안드로이드 앱에 아이콘을 등록하고 빌드
1단계: 앱 아이콘 준비
- 필수 조건:
- 정사각형 PNG 이미지
- 권장 사이즈: 512×512, 배경이 투명하거나 원하는 색상
- 파일 이름 예: app_icon.png
2단계: flutter_launcher_icons 패키지 추가
pubspec.yaml 파일에 아래 내용 추가:
dev_dependencies:
flutter_launcher_icons: ^0.13.1
flutter_icons:
android: true
ios: false
image_path: "assets/app_icon.png"
아이콘 이미지 경로는 assets/app_icon.png로 가정하고 있어요. 다르면 맞춰 수정하세요.
3단계: 아이콘 이미지 넣기
- assets 폴더가 없다면 생성
- 아이콘 이미지(app_icon.png)를 그 안에 넣기
/my_flutter_app
└─ /assets
└─ app_icon.png
4단계: 아이콘 생성 실행
터미널에서 아래 명령어 입력:
flutter pub get
flutter pub run flutter_launcher_icons:main
성공하면 android/app/src/main/res/ 경로 아래에 해상도별 아이콘들이 자동으로 생성됩니다.
5단계: APK 빌드하기
아래 명령어로 릴리즈용 APK를 빌드하세요:
flutter build apk --release
build/app/outputs/flutter-apk/app-release.apk
선택 옵션 (앱 이름도 바꾸고 싶다면)
android/app/src/main/AndroidManifest.xml 안의 label 수정:
<application
android:label="내 앱 이름"
... >
'Flutter for Beginners' 카테고리의 다른 글
Flutter PageView을 이용한 자동 슬라이드 이미지 겔러리 (0) | 2025.03.24 |
---|---|
Flutter WebViewController를 이용한 블로그 앱 만들기 (0) | 2025.03.21 |
Flutter의 상태 관리 기본 개념 (setState vs. Provider) (0) | 2025.03.14 |
Flutter의 배치(Layout) 관련 위젯 (0) | 2025.03.14 |
Flutter의 디자인 관련 위젯 (0) | 2025.03.13 |