날씨 앱 실전 프로젝트: OpenWeatherMap API를 연동하고 위치 기반 날씨를 표시하는 앱을 React Router, TanStack Query, Tailwind로 완성합니다.
🌤️ 날씨 앱 프로젝트 개요
이 강의는 시리즈 전체의 종합 프로젝트입니다. 지금까지 배운 모든 것을 하나의 실전 앱으로 연결합니다. 완성할 기능:
- 현재 위치 기반 날씨 조회 (Geolocation API)
- 도시 이름으로 날씨 검색
- 5일 예보 표시
- 최근 검색 도시 목록 (localStorage)
- 로딩 스켈레톤 + 에러 처리
사용 기술: TanStack Query(데이터 패칭) + React Router(라우팅) + Tailwind(스타일) + Zustand(검색 기록)
🔑 API 키 설정
# .env.local
VITE_WEATHER_API_KEY=your_api_key_here
# OpenWeatherMap 무료 플랜으로 충분합니다
# https://openweathermap.org/api 에서 발급
🗃️ API 함수 정의
// api/weather.ts
const API_KEY = import.meta.env.VITE_WEATHER_API_KEY
const BASE_URL = 'https://api.openweathermap.org/data/2.5'
export interface WeatherData {
name: string // 도시명
main: { temp: number; feels_like: number; humidity: number }
weather: [{ main: string; description: string; icon: string }]
wind: { speed: number }
sys: { country: string }
}
export interface ForecastData {
list: Array<{
dt: number
main: { temp_min: number; temp_max: number }
weather: [{ main: string; icon: string }]
dt_txt: string
}>
}
export async function fetchWeatherByCity(city: string): Promise<WeatherData> {
const res = await fetch(
`${BASE_URL}/weather?q=${encodeURIComponent(city)}&appid=${API_KEY}&units=metric&lang=kr`
)
if (!res.ok) {
if (res.status === 404) throw new Error(`"${city}"를 찾을 수 없습니다`)
throw new Error('날씨 정보를 불러오지 못했습니다')
}
return res.json()
}
export async function fetchWeatherByCoords(lat: number, lon: number): Promise<WeatherData> {
const res = await fetch(
`${BASE_URL}/weather?lat=${lat}&lon=${lon}&appid=${API_KEY}&units=metric&lang=kr`
)
if (!res.ok) throw new Error('날씨 정보를 불러오지 못했습니다')
return res.json()
}
export async function fetchForecast(city: string): Promise<ForecastData> {
const res = await fetch(
`${BASE_URL}/forecast?q=${encodeURIComponent(city)}&appid=${API_KEY}&units=metric&lang=kr`
)
if (!res.ok) throw new Error('예보를 불러오지 못했습니다')
return res.json()
}
🏪 Zustand 스토어 — 검색 기록
// store/useWeatherStore.ts
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface WeatherStore {
recentCities: string[]
currentCity: string
addRecentCity: (city: string) => void
setCurrentCity: (city: string) => void
}
export const useWeatherStore = create<WeatherStore>()(
persist(
(set) => ({
recentCities: [],
currentCity: '',
addRecentCity: (city) => set((state) => ({
recentCities: [city, ...state.recentCities.filter((c) => c !== city)].slice(0, 5),
})),
setCurrentCity: (city) => set({ currentCity: city }),
}),
{ name: 'weather-store' } // localStorage 키
)
)
🌡️ WeatherCard 컴포넌트
// components/WeatherCard.tsx
function WeatherCard({ weather }: { weather: WeatherData }) {
const temp = Math.round(weather.main.temp)
const feelsLike = Math.round(weather.main.feels_like)
const iconUrl = `https://openweathermap.org/img/wn/${weather.weather[0].icon}@2x.png`
return (
<div className="bg-gradient-to-br from-blue-500 to-indigo-600 rounded-3xl p-8 text-white shadow-xl">
<div className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-black">{weather.name}, {weather.sys.country}</h2>
<p className="text-blue-200 mt-1 capitalize">{weather.weather[0].description}</p>
</div>
<img src={iconUrl} alt={weather.weather[0].main} className="w-20 h-20" />
</div>
<div className="mt-6">
<p className="text-8xl font-black">{temp}°</p>
<p className="text-blue-200 mt-2">체감 {feelsLike}° · 습도 {weather.main.humidity}% · 풍속 {weather.wind.speed}m/s</p>
</div>
</div>
)
}
🏠 메인 페이지 조립
// pages/WeatherPage.tsx
export default function WeatherPage() {
const { currentCity, addRecentCity, setCurrentCity, recentCities } = useWeatherStore()
const [searchInput, setSearchInput] = useState('')
const { data: weather, isLoading, error } = useQuery({
queryKey: ['weather', currentCity],
queryFn: () => fetchWeatherByCity(currentCity),
enabled: !!currentCity,
})
function handleSearch(city: string) {
setCurrentCity(city)
addRecentCity(city)
setSearchInput('')
}
function handleGeolocate() {
navigator.geolocation.getCurrentPosition(
async ({ coords }) => {
const data = await fetchWeatherByCoords(coords.latitude, coords.longitude)
handleSearch(data.name)
},
() => alert('위치 권한을 허용해주세요')
)
}
return (
<div className="min-h-screen bg-gray-50 p-4">
<div className="max-w-lg mx-auto space-y-4">
<div className="flex gap-2">
<input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && searchInput.trim()) handleSearch(searchInput.trim()) }}
placeholder="도시명 입력 (예: Seoul)"
className="flex-1 border rounded-xl px-4 py-3"
/>
<button onClick={handleGeolocate} className="px-4 py-3 bg-white border rounded-xl">📍</button>
</div>
{recentCities.length > 0 && (
<div className="flex gap-2 flex-wrap">
{recentCities.map((city) => (
<button key={city} onClick={() => handleSearch(city)}
className="text-xs px-3 py-1.5 bg-white border rounded-full hover:bg-gray-50">
{city}
</button>
))}
</div>
)}
{isLoading && <WeatherSkeleton />}
{error && <div className="text-red-500 text-center p-8">{error.message}</div>}
{weather && <WeatherCard weather={weather} />}
{!currentCity && !isLoading && (
<div className="text-center text-gray-400 py-16">도시를 검색하거나 현재 위치를 사용하세요</div>
)}
</div>
</div>
)
}
✅ 정리
- 실제 외부 API(OpenWeatherMap)를 TanStack Query로 패칭했습니다.
- Zustand
persist미들웨어로 검색 기록을 localStorage에 자동 저장합니다. - Geolocation API로 브라우저 위치 정보를 비동기로 가져왔습니다.
- 환경변수로 API 키를 코드 밖에서 관리합니다.
관련 주제
- OpenWeatherMap API
- Geolocation API
- TanStack Query
- React Router
- Zustand
- 5일 예보
- 로딩 스켈레톤
- API 키 관리
- 개발·프로그래밍
- 개발·프로그래밍 강의
- 리액트 입문 — 컴포넌트로 만드는 웹
- 무료강의
- 무료 온라인 강의
- NUGUNA
- 누구나
📚 시리즈 전체 공유
리액트 입문 — 컴포넌트로 만드는 웹
이 강의가 속한 시리즈는 총 30강, 모두 무료입니다. 처음부터 배우려는 동료에게 시리즈 전체를 알려 주세요.
댓글
0/1000
불러오는 중...
