모든 강의를 무료로 볼 수 있어요. 회원가입 없이도 학습 가능합니다.

29강 / 전체 30강

미니 프로젝트 — 날씨 앱

8분 읽기 조회 13

날씨 앱 실전 프로젝트: 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

불러오는 중...