들어가며
1월 서류 합격 후 받게 된 기술 구현 과제를 완료했습니다. 이 글에서는 과제를 진행하며 마주했던 기술적 도전과 해결 과정 그리고 배운 점들을 정리해보려 합니다.

과제 요구사항
과제의 핵심 요구사항은 다음과 같았습니다.
기능 요구사항
- Open API를 활용한 날씨 정보 조회
- 사용자 장소 검색 기능
- 즐겨찾기 추가/삭제 기능
기술 스택 및 제약사항
- React + TypeScript 기반 개발
- FSD(Feature Sliced Design) 아키텍처 적용
- 제공된 대한민국 행정구역 JSON 파일 활용
- Tailwind CSS를 이용한 UI 디자인
처음 접해보는 FSD 아키텍처와 JSON 기반 검색 구현 등 새로운 시도가 많아 배울 점이 많았던 과제였습니다.
핵심 성과
사용자 경험 개선
검색 최적화
- 디바운싱 적용으로 불필요한 검색 연산 감소
- 무한 스크롤 구현으로 다수의 검색 결과 효율적 표시
- 가중치 기반 정렬로 검색 정확도 향상
성능 최적화
- 날씨 데이터 특성(10분 단위 업데이트)을 고려한 캐싱 전략 수립
- React Query의 staleTime 10분, gcTime 30분 설정
- 동일 지역 재조회 시 API 호출 없이 즉시 응답 가능
기술적 도전과 해결
1. 검색창 최적화
1-1. 무한 스크롤 구현

문제 상황
검색 결과가 많을 경우 한 화면에 모든 결과를 표시하기 어려웠습니다. 특히 광역 단위로 검색했을 때 수십~수백 개의 결과가 나올 수 있었기 때문에 사용자 경험을 개선할 방법이 필요했습니다.
고민 과정
두 가지 방법을 고려했습니다.
- 무한 스크롤: 스크롤하며 점진적으로 결과를 더 보여주기
- 페이지네이션: 페이지 단위로 결과 분할
모바일 환경에서는 무한 스크롤이 자연스럽고 편리한 사용자 경험을 제공합니다. 검색이라는 행위 자체가 빠르게 원하는 결과를 찾는 것이 목적이므로 페이지를 넘기는 추가 액션보다는 자연스러운 스크롤이 더 적합하다고 판단했습니다.
해결 방법
const ITEMS_PER_PAGE = 10;
const [displayCount, setDisplayCount] = useState(ITEMS_PER_PAGE);
const scrollRef = useRef<HTMLDivElement>(null);
const handleScroll = useCallback(() => {
if (!scrollRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
// 스크롤이 하단 80% 지점에 도달하면 추가 로드
if (scrollHeight - scrollTop <= clientHeight * 1.2) {
setDisplayCount((prev) => {
const next = prev + ITEMS_PER_PAGE;
return next > searchResults.length ? searchResults.length : next;
});
}
}, [searchResults.length]);
const displayedResults = searchResults.slice(0, displayCount);
const hasMore = displayCount < searchResults.length;
UI 구현에서는 현재 표시 중인 결과 개수와 전체 결과 개수를 함께 보여주어 사용자가 검색 진행 상황을 파악할 수 있도록 했습니다.
배운 점
모바일 관점에서 UX를 설계하는 것의 중요성을 배웠습니다. 데스크톱에서는 큰 차이가 없어 보이는 기능도 모바일에서는 사용자 경험에 큰 영향을 미칠 수 있다는 것을 다시 한번 확인할 수 있었습니다.
1-2. 디바운싱 적용
문제 상황
검색어를 입력할 때마다 즉시 검색이 실행되어 불필요한 연산이 과도하게 발생했습니다. 예를 들어 "서울특별시"를 입력하면 "ㅅ", "서", "서ㅇ", "서우", "서울" ... 총 15번의 검색이 실행되는 문제가 있었습니다.
[todo: 문제상황 gif로 확인해보기]
고민 과정
두 가지 최적화 기법을 검토했습니다.

디바운싱(Debouncing)
- 연이어 호출되는 함수 중 마지막 함수만 실행
- 사용자가 입력을 멈춘 후 일정 시간이 지나면 검색 실행
- 검색과 같이 "완성된 입력"에 대해 반응해야 하는 경우에 적합
쓰로틀링(Throttling)
- 일정 시간 동안 함수를 한 번만 실행
- 지정된 시간 이내의 이벤트는 무시하고 첫 번째 이벤트만 처리
- 스크롤 이벤트처럼 지속적인 이벤트 발생 시 일정 간격으로 처리할 때 적합
검색은 사용자가 입력을 완료한 시점에 결과를 보여주는 것이 목적이므로 디바운싱이 더 적합하다고 판단했습니다.
해결 방법
import debounce from "lodash/debounce";
// 디바운싱된 검색 함수 (300ms 지연)
const debouncedSearch = useMemo(
() =>
debounce((query: string) => {
if (query.trim() === "") {
setSearchResults([]);
setIsOpen(false);
return;
}
const results = koreaDistricts.filter((district) =>
district.includes(query)
);
setSearchResults(results);
setIsOpen(true);
}, 300),
[]
);
const handleSearch = useCallback(
(query: string) => {
setSearchQuery(query);
debouncedSearch(query);
},
[debouncedSearch]
);
// 컴포넌트 언마운트 시 대기 중인 디바운스 취소
useEffect(() => {
return () => debouncedSearch.cancel();
}, [debouncedSearch]);
300ms의 지연 시간을 설정하여 사용자가 입력을 멈춘 후 0.3초 뒤에 검색이 실행되도록 했습니다. 이는 너무 짧으면 여전히 불필요한 호출이 많고, 너무 길면 반응이 느리게 느껴지는 지점을 고려한 값입니다.
배운 점
디바운싱과 쓰로틀링은 프론트엔드 성능 최적화의 기본이지만, 각 상황에 맞게 적절히 선택하는 것이 중요하다는 것을 알게 되었습니다.
2. 즐겨찾기 저장 방식 선택
문제 상황
즐겨찾기 데이터를 어떻게 저장할지 고민이 필요했습니다. 로컬 스토리지를 사용할지, 백엔드 API를 구현하여 서버에 저장할지 결정해야 했습니다.
고민 과정
각 방식의 장단점을 비교했습니다.
| 구분 | 로컬 스토리지 | 백엔드 + 로그인 |
| 구현 난이도 | 낮음 | 높음 (인증/인가 구현 필요) |
| 개발 시간 | 짧음 | 김 (백엔드 API, 로그인 로직) |
| 기기 간 동기화 | 불가능 | 가능 |
| 브라우저 의존성 | 브라우저 변경 시 데이터 손실 | 계정 기반으로 어디서나 접근 |
| 확장성 | 제한적 | 개인화 기능 확장 용이 |
선택한 방법: 로컬 스토리지
다음과 같은 이유로 로컬 스토리지를 선택했습니다.
- 기능 특성 분석: 날씨 즐겨찾기는 개인의 관심 지역을 저장하는 기능으로, 기기 간 동기화가 필수적이지 않음
- 과제 범위 고려: 요구사항에 즐겨찾기 외 다른 개인화 기능이 없어 확장성의 우선순위가 낮음
- 사용자 경험: 로그인 없이도 즉시 사용 가능한 것이 더 가벼운 UX 제공
전역 상태 관리의 필요성
즐겨찾기 기능은 여러 컴포넌트에서 사용됩니다.
- 검색 결과 컴포넌트: 검색 결과에서 즐겨찾기 추가/해제
- 특정 지역 날씨 상세페이지: 즐겨찾기 상태 표시 및 해제
- 즐겨찾기 목록 컴포넌트: 전체 목록 조회 및 관리
여러 곳에서 동일한 데이터를 참조하고 수정하므로, 전역 상태 관리가 필요했습니다. Zustand를 선택한 이유는 다음과 같습니다.
- 간결한 API: Redux에 비해 보일러플레이트가 적음
- Persist 미들웨어: 로컬 스토리지 동기화가 내장되어 별도 구현 불필요
- 타입 안정성: TypeScript와의 호환성 우수
구현 코드
import { create } from "zustand";
import { persist } from "zustand/middleware";
export interface Favorite {
id: string;
name: string;
alias: string;
lat: number;
lon: number;
}
export type AddFavoriteResult =
| { success: true }
| { success: false; reason: "max_reached" | "duplicate" };
interface FavoritesState {
favorites: Favorite[];
maxFavorites: number;
addFavorite: (favorite: Favorite) => AddFavoriteResult;
removeFavorite: (id: string) => void;
updateAlias: (id: string, alias: string) => void;
isFavorite: (id: string) => boolean;
getFavorite: (id: string) => Favorite | undefined;
}
const MAX_FAVORITES = 6;
export const useFavoritesStore = create<FavoritesState>()(
persist(
(set, get) => ({
favorites: [],
maxFavorites: MAX_FAVORITES,
addFavorite: (favorite) => {
const { favorites } = get();
if (favorites.length >= MAX_FAVORITES) {
return { success: false, reason: "max_reached" };
}
if (favorites.some((f) => f.id === favorite.id)) {
return { success: false, reason: "duplicate" };
}
set({ favorites: [...favorites, favorite] });
return { success: true };
},
removeFavorite: (id) => {
set((state) => ({
favorites: state.favorites.filter((f) => f.id !== id),
}));
},
updateAlias: (id, alias) => {
set((state) => ({
favorites: state.favorites.map((f) =>
f.id === id ? { ...f, alias } : f
),
}));
},
isFavorite: (id) => get().favorites.some((f) => f.id === id),
getFavorite: (id) => get().favorites.find((f) => f.id === id),
}),
{
name: "favorites-storage", // 로컬 스토리지 키
}
)
);
컴포넌트에서는 다음과 같이 간단하게 사용합니다.
// 즐겨찾기 추가 버튼 컴포넌트
const addFavorite = useFavoritesStore((state) => state.addFavorite);
const isFavorite = useFavoritesStore((state) => state.isFavorite);
const handleClick = () => {
const result = addFavorite({
id: location.id,
name: location.name,
alias: location.name,
lat: location.lat,
lon: location.lon,
});
if (!result.success) {
if (result.reason === "max_reached") {
alert("최대 6개까지만 등록 가능합니다.");
} else {
alert("이미 등록된 지역입니다.");
}
}
};
배운 점
사실 처음엔 "기업 과제니까 백엔드 연동하는게 더 좋아보이지 않을까?"라는 생각도 했어요. 근데 요구사항을 다시 보니 로그인 기능은 없는것을 확인했습니다. 괜히 과하게 구현하려다가 정작 중요한 부분을 놓칠 뻔 했습니다. 주어진 요구사항에 집중하는 것도 중요한 능력이라는 걸 느꼈어요.
3. API 검색 실패 대응 (Fallback 전략)
문제 상황
OpenWeatherMap API는 한국의 모든 지역을 도시명으로 지원하지 않습니다. 특히 광역시/특별시가 아닌 시·군 단위나 동 단위 검색 시 조회가 실패하는 경우가 빈번했습니다.
예를 들어 "서울특별시"는 "Seoul"로 검색 가능하지만, "경기도 수원시"나 "서울특별시 강남구"는 직접 지원하지 않아 오류가 발생했습니다.
해결 방안: 이중 검색 전략
1차로 도시명 기반 검색을 시도하고, 실패 시 2차로 좌표 기반 검색으로 전환하는 폴백(Fallback) 전략을 구현했습니다.

1단계: 도시명 기반 조회
export const convertToEnglishCity = (koreanLocation: string): string => {
const parts = koreanLocation.split("-");
// 광역시/특별시 직접 매핑
const sido = parts[0];
if (sido.includes("특별시") || sido.includes("광역시")) {
return SIDO_MAP[sido] || sido;
}
// 시도-시군 조합 매핑 (예: "경기도-수원시" → "Suwon")
if (parts.length >= 2) {
const sidoSigungu = `${parts[0]}-${parts[1]}`;
if (SIDO_MAP[sidoSigungu]) {
return SIDO_MAP[sidoSigungu];
}
}
// 기본값: 시도명 반환
return SIDO_MAP[sido] || sido;
};
2단계: 실패 시 좌표 기반 조회
const shouldUseCoords = useMemo(() => {
if (!selectedLocation) return true; // 선택 안 했으면 사용자 현재 위치 사용
const hasError = weatherCityError || forecastCityError;
const canUseCoords = !!getCityCoordinates(selectedLocation);
return hasError && canUseCoords;
}, [selectedLocation, weatherCityError, forecastCityError]);
// 최종 좌표 결정
const targetCoords = useMemo(() => {
if (!selectedLocation || !shouldUseCoords) {
return { lat: userLat, lon: userLon };
}
return getCityCoordinates(selectedLocation) || { lat: userLat, lon: userLon };
}, [selectedLocation, shouldUseCoords, userLat, userLon]);
좌표 매핑 데이터 구조
const CITY_COORDINATES: Record<string, { lat: number; lon: number }> = {
"서울특별시": { lat: 37.5665, lon: 126.9780 },
"경기도-수원시": { lat: 37.2636, lon: 127.0286 },
"경기도-성남시": { lat: 37.4201, lon: 127.1262 },
// ... 주요 도시 좌표
};
export const getCityCoordinates = (koreanLocation: string) => {
const parts = koreanLocation.split("-");
// 1단계: "시도-시군" 조합으로 찾기
if (parts.length >= 2) {
const sidoSigungu = `${parts[0]}-${parts[1]}`;
if (CITY_COORDINATES[sidoSigungu]) {
return CITY_COORDINATES[sidoSigungu];
}
}
// 2단계: 시군명만으로 찾기
if (parts.length >= 2) {
const sigungu = parts[1];
const match = Object.entries(CITY_COORDINATES).find(([key]) =>
key.endsWith(`-${sigungu}`)
);
if (match) return match[1];
}
// 3단계: 시도명으로 찾기
return CITY_COORDINATES[parts[0]];
};
개선 결과
| 구분 | 개선 전 | 개선 후 |
| 지원 지역 | 광역시/특별시만 | 대부분의 시·군 지역 지원 |
| 검색 실패 시 | 에러 메시지 표시 | 좌표 기반으로 자동 전환 |
| 사용자 경험 | 자주 검색 실패 | 대부분 성공적으로 조회 |
남은 과제
현재 구조에서는 동 단위 검색 시 상위 개념인 시 단위로 매핑됩니다. 예를 들어 "서울특별시-강남구-역삼동"을 검색해도 "서울특별시"의 좌표를 사용합니다.
더 정확한 날씨 정보를 제공하려면 다음을 고려해야 합니다.
- OpenWeatherMap API가 동 단위 검색을 지원하는지 확인
- 지원한다면 더 세밀한 좌표 데이터 구축
- 지원하지 않는다면 동 단위는 시·군 단위로 안내
배운 점
OpenWeatherMap API 문서를 처음 봤을 때 도시이름으로 날씨 검색이 가능한 것을 보고 "편하겠네"라고 생각해서 선택했는데, 모든 도시가 지원이 되는건 아니더라고요. 실제로 검색해보니 안 나오는 지역이 많아서 당황했습니다. 외부 API를 쓸 때는 미리 제약사항을 파악하고 대응책을 준비해야 한다는 걸 배웠습니다. 그리고 완벽한 해결은 아니어도 "최선의 차선책"을 제공하는 것도 중요하다는 걸 알게 됐습니다.
FSD 아키텍처를 통해 배운 점
처음 접한 FSD(Feature Sliced Design) 아키텍처는 새로운 경험이었습니다.
이전 방식의 문제점
src/
components/
WeatherCard.tsx
SearchBar.tsx
utils/
api.ts
helpers.ts
pages/
MainPage.tsx
이전에는 단순히 UI와 로직을 분리하는 방식으로 파일을 관리했습니다. 프로젝트가 커지면 다음과 같은 문제가 발생했습니다.
- 파일명을 어떻게 정할지 매번 고민
- 비슷한 역할의 파일이 여러 폴더에 분산
- 특정 기능을 찾기 위해 여러 폴더를 뒤져야 함
FSD 아키텍처의 장점
src/
app/
entities/
weather/
model/
api/
ui/
features/
search-location/
model/
ui/
favorites/
model/
ui/
shared/
ui/
api/
widgets/
weather-widget/
FSD를 적용하면서 다음과 같은 장점을 경험했습니다.
- 명확한 파일 위치: 기능별로 묶여있어 어디에 무엇이 있는지 예측 가능
- 의사결정 피로 감소: 파일명과 위치에 대한 고민 시간 단축
- 코드 응집도 향상: 관련된 코드가 한 곳에 모여 유지보수 용이
- 확장성: 새로운 기능 추가 시 독립적으로 개발 가능
실제 적용 사례
즐겨찾기 기능을 예로 들면, 관련된 모든 코드가 features/favorites/ 아래에 정리됩니다.
features/
favorites/
model/
store.ts # Zustand 스토어
types.ts # 타입 정의
ui/
FavoriteButton.tsx # 즐겨찾기 버튼
FavoriteList.tsx # 목록 컴포넌트
index.ts # Public API
이렇게 구조화하니 같이 협업을 할 때, 다른 개발자가 코드를 볼 때도 쉽게 이해할 수 있고,기능 단위로 테스트하거나 리팩토링하기도 편할 것 같다고 느꼈습니다.
아쉬운 점과 다음 목표
성능 측정의 부재
개선 사항을 정량적으로 측정하지 못한 점도 아쉬움으로 남습니다.
측정하지 못한 지표
- 디바운싱 적용 전후 API 호출 횟수 정확한 감소율
- 캐싱 적용 후 응답 시간 개선 정도
- 번들 크기와 로딩 속도
다음 프로젝트에서는
- Chrome DevTools Performance 탭 적극 활용
- React DevTools Profiler로 렌더링 최적화 측정
- Lighthouse를 통한 성능 점수 추적
- 개선 전후를 수치로 비교하는 습관 들이기
테스트 코드 부재
시간에 쫓겨 테스트 코드를 작성하지 못했습니다. 특히 검색 로직이나 즐겨찾기 상태 관리 같은 핵심 기능은 테스트가 있었다면 리팩토링할 때 훨씬 자신감 있게 진행할 수 있었을 것 같습니다.
마치며
이번 과제를 통해 가장 크게 배운 건 "왜?"를 끊임없이 물어야 한다는 거예요.
왜 무한 스크롤을 선택했는지, 왜 디바운싱이 쓰로틀링보다 적합한지, 왜 로컬 스토리지를 선택했는지. 모든 결정에는 이유가 있어야 하고, 그걸 명확하게 설명할 수 있어야 한다는 걸 실감했습니다.
FSD 아키텍처도 처음엔 "폴더 구조 하나 바꾸는게 뭐가 대수야" 싶었는데, 막상 써보니 코드 찾는 시간이 확 줄어들더라고요. 좋은 구조는 개발 경험을 정말 많이 바꿔준다는 걸 느꼈어요.
아직 부족한 점도 많지만, 이런 과제들을 하나씩 해결해나가면서 성장하는 게 느껴져서 뿌듯합니다.




이 글이 비슷한 과제를 준비하거나 프론트엔드 개발을 공부하는 분들에게 도움이 되기를 바랍니다.
'Study > 프로젝트 회고' 카테고리의 다른 글
| Gallery Hub 프로젝트 회고 (0) | 2026.01.22 |
|---|