프로젝트를 진행하면서 가장 고민했던 부분 중 하나는 검색어 입력을 어떻게 처리할지였습니다.
OpenWeatherMap API에는 도시 이름과 국가 코드를 함께 입력하거나, 위도와 경도 좌표, 도시 ID, 우편번호로 검색할 수 있는 방법이 있지만, 사용자는 이런 복잡한 방법으로 검색을 하지 않기 때문입니다.
그러다가 OpenWeatherMap의 Direct Geocoding API를 찾았고, 이 API에선 검색어가 포함된 여러 도시와 그 좌표가 함께 반환되었습니다.
이를 활용해 검색어에 맞는 도시 리스트를 보여주고, 선택된 도시의 위도와 경도로 Recoil에 저장된 location 값을 변경하는 방식으로 구현했습니다.
그리고 시간대별 날씨를 보여주는 HourlyForecast 컴포넌트를 추가했고, 기존에는 단일 온도만 표시되던 Forecast컴포넌트에서 최고온도와 최저온도를 함께 보여주도록 변경했습니다.
이번 포스팅에서는 위 내용들을 어떻게 구현했는지 작성하겠습니다.
weatherAPI
WeatherAPI에는 위에서 설명한 Direct Geocoding API 요청을 추가했습니다.
async getCityCoords(query: string) {
const response = await axios.get("https://api.openweathermap.org/geo/1.0/direct", {
params: {
q: query,
limit: 5,
appid: process.env.REACT_APP_WEATHER_API_KEY,
},
});
return response.data;
}
Header 컴포넌트
interface CityResult {
name: string;
country: string;
lat: number;
lon: number;
local_names: {
ko: string;
};
}
export default function Header() {
const [location, setLocation] = useRecoilState(locationState);
const [text, setText] = useState("");
const [isFocused, setIsFocused] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const [searchResults, setSearchResults] = useState<CityResult[]>([]);
const defaultLocation = { lat: 37.5145, lon: 127.0495 };
const [debouncedText, setDebouncedText] = useState(text);
const [isLoading, setIsLoading] = useState(false);
const { mutate: searchCities } = useMutation({
mutationFn: async (query: string) => {
const response = await WeatherApi.getCityCoords(query);
return response;
},
onSuccess: (data: CityResult[]) => {
setSearchResults(data);
setIsLoading(false);
},
onError: (error: Error) => {
console.error("검색 중 오류 발생:", error);
setIsLoading(false);
},
});
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value.trim();
setText(e.target.value);
setIsExpanded(value.length > 0);
setIsLoading(value.length > 0);
};
useEffect(() => {
if (debouncedText) {
searchCities(debouncedText);
}
}, [debouncedText]);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedText(text);
}, 500);
return () => {
clearTimeout(handler);
};
}, [text]);
const handleFocus = () => {
setIsFocused(true);
};
const handleBlur = () => {
setIsFocused(false);
};
// 기존에 있던 getLocation 함수 정의 생략
const handleResultClick = (lat: number, lon: number) => {
setLocation({ lat: lat, lon: lon });
setText('');
setIsExpanded(false);
};
return (
<header className={styles.header}>
<div className={styles.container}>
{/* 로고 생략 */}
<form
className={`${styles.form} ${isLoading ? styles.searching : ""} ${(isFocused && isExpanded) ? styles.expanded : ''}`}
onSubmit={handleSubmit}
>
<div>
<input
className={styles.input}
type="search"
name="search"
placeholder="검색"
autoComplete="off"
value={text}
onChange={handleInputChange}
onFocus={handleFocus}
onBlur={handleBlur}
/>
<button className={`${styles["search-icon"]} ${styles.icon}`}>
<IoIosSearch />
</button>
</div>
<div className={`${styles.result} ${isFocused && isExpanded ? styles.active : ""}`}>
<ul className={styles.list}>
{searchResults.map((result, index) => {
const { lat, lon } = result;
const name = result?.local_names?.ko || result.name;
return (
<li key={index} className={styles.item} onMouseDown={() => handleResultClick(lat, lon)}>
<span className={styles.icon}>
<CiLocationOn />
</span>
<div>
<p className={styles.title}>{name}</p>
<p className={styles.label}>{result.country}</p>
</div>
</li>
);
})}
</ul>
</div>
</form>
<div className={styles.right}>
{/* 생략 */}
</div>
</div>
</header>
);
}
디바운스(Debounce) 기능
처음에는 input의 onChange 이벤트가 발생할 때마다 서버에 요청을 보내는 방식으로 구현했었습니다.
하지만 이렇게 하면, 예를 들어 "부산"이라는 도시를 입력할 때 각 글자가 입력될 때마다 서버 요청이 발생하여, 총 5번의 요청이 이루어졌습니다.
이러한 불필요한 서버 요청을 줄이기 위해 디바운스 기능을 추가했습니다.
디바운스는 연속적으로 발생하는 이벤트를 하나로 묶어 처리하여, 중복된 요청을 방지하는 역할을 합니다.
이를 통해, 불필요한 서버 요청을 줄이고 사용자가 입력을 멈추고 500ms 동안 추가 입력이 없을 때에만 요청이 발생하도록 구현했습니다.
useEffect(() => {
if (debouncedText) {
searchCities(debouncedText); // debouncedText가 업데이트되면 서버 요청
}
}, [debouncedText]);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedText(text); // 500ms 후에 debouncedText에 text 값 적용
}, 500);
return () => {
clearTimeout(handler); // 새로운 입력이 있을 경우 이전 타이머를 취소
};
}, [text]);
text가 변화할 때마다 바로 서버 요청을 보내는 대신, debouncedText가 일정 시간이 지난 후에만 서버 요청을 보내도록 구현했습니다.
useMutation 사용
처음에는 useQuery를 사용하려 했지만, useQuery는 주로 페이지 로드 시점이나 특정 시점에서 데이터를 가져오고 캐싱하는 데 적합합니다.
그러나 input의 onChange 이벤트처럼 연속적으로 서버 요청을 보내는 경우에는 적합하지 않았습니다.
그래서, 이런 경우에 더 적합한 useMutation을 선택했습니다.
useMutation은 주로 서버에 데이터를 보내는 작업(POST, PUT, DELETE)에 사용됩니다.
하지만 여기에서는 사용자가 입력할 때마다 서버에 데이터를 요청하고, 그에 따라 검색 결과를 리스트로 보여주기 위해 useMutation을 사용했습니다.
const { mutate: searchCities } = useMutation({
mutationFn: async (query: string) => {
const response = await WeatherApi.getCityCoords(query);
return response;
},
onSuccess: (data: CityResult[]) => {
setSearchResults(data); // 요청 성공 시 결과를 상태에 저장
setIsLoading(false); // 로딩 상태 해제
},
onError: (error: Error) => {
console.error("검색 중 오류 발생:", error); // 요청 실패 시 에러 처리
setIsLoading(false); // 로딩 상태 해제
},
});
mutate:
여기서 mutate는 useMutation에서 서버 요청을 트리거하는 함수입니다. mutate가 실행되면, mutationFn에 정의된 비동기 함수가 실행되어 서버로 도시 좌표 검색 요청이 전송됩니다.
mutationFn:
이 함수는 서버로 요청을 보내는 비동기 함수입니다.
여기서는 WeatherApi.getCityCoords(query)를 사용해 도시 이름으로 좌표를 검색하는 API 호출을 수행합니다.
onSuccess:
요청이 성공하면 실행되는 함수입니다. 여기서는 받아온 검색 결과 데이터를 setSearchResults를 통해 상태에 저장하고, 로딩 상태를 해제합니다.
onError:
요청이 실패하면 실행되는 함수입니다. 요청 중 에러가 발생할 경우 콘솔에 에러 메시지를 출력하고, 로딩 상태를 해제합니다.
handleResultClick
const handleResultClick = (lat: number, lon: number) => {
setLocation({ lat: lat, lon: lon });
setText('');
setIsExpanded(false);
};
사용자가 리스트에서 항목을 클릭하면, Recoil의 locationState가 해당 도시의 위도와 경도로 변경됩니다.
이렇게 되면, MainContent 컴포넌트에서도 동일한 locationState를 사용하므로 리렌더링이 발생합니다.
onClick 대신 onMouseDown을 사용한 이유는, CSS로 리스트가 focus-within 상태가 아닐 때 보이지 않도록 설정했기 때문입니다. onClick을 사용하면 클릭 이벤트가 발생하기 전에 포커스가 먼저 사라지기 때문에, 클릭이 제대로 인식되지 않았습니다.
onMouseDown은 포커스가 풀리기 전에 이벤트가 발생하므로 문제를 해결할 수 있었습니다.
HourlyForecast 컴포넌트
export default function HourlyForecast({ forecastData }: { forecastData: IForeCast }) {
const filteredData = forecastData.list.filter((_, index) => index < 8);
return (
<section className={styles.section}>
<h2 className={styles.title}>시간대별 날씨</h2>
<div className={styles.container}>
<ul className={styles.list}>
{filteredData.map((forecast) => {
const {
dt,
main: { temp },
weather,
} = forecast;
const [{ icon, description }] = weather;
return (
<li key={dt} className={styles.item}>
<Card size="small" className={styles["forecast-card"]}>
<p className={styles.text}>{getHours(dt)}</p>
<img
src={`http://openweathermap.org/img/wn/${icon}.png`}
width="48"
height="48"
alt={description}
className={styles.img}
title={description}
/>
<p className={styles.text}>{`${Math.round(temp)}°`}</p>
</Card>
</li>
);
})}
</ul>
<ul className={styles.list}>
{filteredData.map((forecast) => {
const {
dt,
weather,
wind: { deg, speed },
} = forecast;
const [{ description }] = weather;
return (
<li key={dt} className={styles.item}>
<Card size="small" className={styles["forecast-card"]}>
<p className={styles.text}>{getHours(dt)}</p>
<img
src={directionImg}
width="48"
height="48"
alt={description}
className={styles.img}
title={description}
style={{ transform: `rotate(${deg - 180}deg)` }}
/>
<p className={styles.text}>{Math.round(speed * 3600 / 1000)} km/h</p>
</Card>
</li>
);
})}
</ul>
</div>
</section>
);
}
getHours 함수
export const getHours = (timeUnix: number) => {
const date = new Date(timeUnix * 1000);
const hours = date.getHours();
const period = hours >= 12 ? "PM" : "AM";
const formattedHours = hours % 12 === 0 ? 12 : hours % 12;
return `${formattedHours} ${period}`;
};
이전 포스팅, 전전 포스팅과 동일한 방식입니다.
timeUnix를 밀리초 단위로 변환하여 Date 객체로 만듭니다. 그 후, 이를 AM/PM 형식으로 시간만 추출해 포맷팅하여 반환합니다.
풍향 표시 및 풍속 계산
{filteredData.map((forecast) => {
const {
dt,
weather,
wind: { deg, speed },
} = forecast;
const [{ description }] = weather;
return (
<li key={dt} className={styles.item}>
<Card size="small" className={styles["forecast-card"]}>
<p className={styles.text}>{getHours(dt)}</p>
<img
src={directionImg}
width="48"
height="48"
alt={description}
className={styles.img}
title={description}
style={{ transform: `rotate(${deg - 180}deg)` }}
/>
<p className={styles.text}>{Math.round(speed * 3600 / 1000)} km/h</p>
</Card>
</li>
);
})}
풍향 이미지 회전:
바람의 방향을 시각적으로 보여주기 위해 transform: rotate 속성을 사용해 바람의 각도에 따라 이미지를 회전시킵니다.
풍속 계산 :
풍속은 m/s 단위로 제공되는데, 이를 km/h로 변환하기 위해 아래와 같은 계산식을 사용합니다.
Math.round(speed * 3600 / 1000)
Forecast 컴포넌트 최고 온도 / 최저 온도 표시
forecastData 안에 temp_max와 temp_min이 이미 정의되어 있지만, 일정 인덱스 이후부터는 temp_max와 temp_min이 동일한 값으로 반환되는 문제가 있었습니다.
그래서 날짜별로 그날의 최고 온도와 최저 온도를 추출해 표시해 주었습니다.
const dailyForecast: Array<{
date: string;
temp_min: number;
temp_max: number;
weather: { icon: string; description: string };
}> = [];
const { city: { timezone } } = forecastData;
const now = new Date();
const today = new Date(now.getTime() - now.getTimezoneOffset() * 60000)
.toISOString()
.split("T")[0];
forecastData.list.forEach((forecast) => {
const { dt } = forecast;
const newDate = new Date((dt + timezone) * 1000);
const localTime = new Date(newDate.getTime());
const date = localTime.toISOString().split("T")[0];
if (date === today) return;
const existingDay = dailyForecast.find((item) => item.date === date);
if (!existingDay) {
dailyForecast.push({
date,
temp_min: forecast.main.temp_min,
temp_max: forecast.main.temp_max,
weather: forecast.weather[0],
});
} else {
existingDay.temp_min = Math.min(existingDay.temp_min, forecast.main.temp_min);
existingDay.temp_max = Math.max(existingDay.temp_max, forecast.main.temp_max);
}
});
timezone은 API에서 제공하는 값으로, UTC와의 시간 차이를 초 단위로 나타냅니다.
단순히 toISOString을 사용하여 날짜를 "YYYY-MM-DD" 형식으로 출력하기 위해 사용했으며, timezone 사용 없이 getFullYear, getMonth, getDate 메서드를 활용해 동일한 결과를 얻을 수도 있습니다.
forecastData.list의 3시간 간격 데이터를 날짜별로 그룹화해, 각 날짜의 최고 및 최저 온도를 dailyForecast 배열에 저장합니다.
오늘을 제외한 이후 날짜만 처리하며, 각 날짜에 대해 온도가 갱신됩니다.
Math.max와 Math.min을 사용해 최고, 최저 온도를 업데이트하고, 이를 기반으로 날짜별 날씨 정보를 요약해 표시합니다.
'React > Weather' 카테고리의 다른 글
OpenWeatherMap API로 날씨 Web 만들기 - 마무리 (0) | 2024.09.13 |
---|---|
OpenWeatherMap API로 날씨 Web 만들기 - 2 (0) | 2024.09.10 |
OpenWeatherMap API로 날씨 Web 만들기 - 1 (0) | 2024.09.09 |