FE 1 / BE 2 / Designer 1
사용자의 개인 정보를 기반으로 청약 공고와의 적격 여부를 AI가 분석해주는 웹 플랫폼입니다.

청약은 복잡한 자격 요건과 점수 산정 기준으로 인해 많은 사람들이 자신의 적격 여부를 파악하기 어려워합니다. BlueFill은 사용자가 입력한 개인 정보(나이, 소득, 자산, 거주지역 등)와 청약 공고를 AI 임베딩 기술로 매칭하여 적격/부적격 판정과 함께 맞춤형 컨설팅을 제공합니다.
| 분류 | 기술 |
|---|---|
| Framework | React 19.1.1, TypeScript 5.8.3, Vite 7.1.2 |
| 상태관리 | TanStack React Query 5.87.1, Zustand 5.0.8 |
| 스타일링 | Tailwind CSS 4.1.13, SCSS Modules, CVA 0.7.1 |
| 폼 관리 | React Hook Form 7.62.0, Zod 4.1.5 |
| 라우팅 | React Router 7.8.2 |
| HTTP 클라이언트 | Axios 1.11.0 |
| 테스트 | Vitest 3.2.4, MSW 2.11.2, Playwright 1.55.0 |
Feature-Sliced Design (FSD) 아키텍처를 적용하여 레이어 간 의존성을 명확하게 관리합니다.
src/
├── app/ # 엔트리포인트, 라우터
├── pages/ # 페이지 컴포넌트
│ ├── home/ # 메인 페이지 (공고 리스트)
│ ├── blue-detail/ # 공고 상세 + 리포트
│ ├── login/ # 로그인/회원가입
│ └── user-init/ # 개인정보 입력
├── entities/ # 도메인 모델 + API
│ ├── announcements/ # 청약 공고
│ ├── auth/ # 인증
│ ├── reports/ # 리포트
│ └── home/ # 홈
└── shared/ # 공통 유틸, UI, 스토어
├── ui/ # Navbar, Layout, Input 등
├── lib/ # axios 인스턴스, cn 유틸
└── store/ # 포인트 스토어
app > pages > features > entities > shared 순으로 import 가능문제 상황
청약 공고 리스트 페이지에서 스크롤 이벤트 기반 무한 스크롤을 구현했는데, 스크롤할 때마다 API가 중복 호출되는 현상이 발생했다. 네트워크 탭을 확인해보니 같은 페이지 요청이 3-4번씩 연속으로 나가고 있었다.
원인 분석
스크롤 이벤트는 초당 수십 번 발생하는데, 이전 요청의 완료 여부를 체크하지 않고 매번 fetchNextPage를 호출하고 있었다. 또한 debounce/throttle 없이 raw scroll event를 그대로 사용하고 있었다.
해결 방법
스크롤 이벤트 대신 IntersectionObserver를 사용하여 특정 요소가 뷰포트에 진입했을 때만 트리거되도록 변경했다. 추가로 TanStack Query의 isFetchingNextPage 상태를 조건문에 추가하여 이전 요청이 진행 중일 때는 새 요청을 막았다.
const handleObserver = useCallback(
(entries: IntersectionObserverEntry[]) => {
const [entry] = entries;
if (entry.isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
[fetchNextPage, hasNextPage, isFetchingNextPage]
);
결과
페이지당 API 호출이 정확히 1회로 최적화되었고, 불필요한 네트워크 요청이 제거되어 서버 부하가 감소했다.
문제 상황
로그인 후 페이지를 새로고침하면 로그인 상태가 풀려버리는 문제가 발생했다. 사용자가 매번 다시 로그인해야 해서 UX가 좋지 않았다.
원인 분석
Zustand store가 메모리에만 상태를 저장하고 있어서, 페이지 새로고침 시 JavaScript 컨텍스트가 초기화되면서 인증 정보도 함께 사라지고 있었다.
해결 방법
Zustand의 persist 미들웨어를 적용하여 인증 상태를 localStorage에 자동 동기화하도록 구현했다. 추가로 axios 인터셉터에서 사용하는 토큰도 동시에 localStorage에 저장하여 API 요청 시에도 일관성을 유지했다.
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
setAuth: (token, user) => {
localStorage.setItem("token", token);
set({ token, user, isAuthenticated: true });
},
}),
{ name: "auth-storage", storage: createJSONStorage(() => localStorage) }
)
);
결과
새로고침 후에도 로그인 상태가 유지되어 세션 지속성 100%를 달성했고, 사용자 재로그인 필요 횟수가 0건으로 감소했다.
문제 상황
네비게이션 바에 알림 버튼을 클릭하면 팝업이 열리는데, 팝업 바깥 영역을 클릭해도 닫히지 않았다. 다른 메뉴를 클릭하거나 ESC를 눌러야만 닫혀서 사용자 경험이 불편했다.
원인 분석
팝업 컴포넌트가 자신의 영역 외부에서 발생하는 클릭 이벤트를 감지하는 로직이 없었다. 단순히 버튼 토글로만 열고 닫는 상태였다.
해결 방법
useRef로 팝업 DOM 요소를 참조하고, document에 mousedown 이벤트 리스너를 등록하여 클릭 대상이 팝업 영역 내부인지 contains()로 확인했다. 팝업이 열려있을 때만 리스너를 등록하고, cleanup에서 제거하여 메모리 누수도 방지했다.
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (alarmRef.current && !alarmRef.current.contains(e.target as Node)) {
setIsAlarmOpen(false);
}
};
if (isAlarmOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isAlarmOpen]);
결과
팝업 외부 클릭 시 즉시 닫히도록 개선되어 일반적인 UI 패턴과 동일한 사용자 경험을 제공하게 되었다.
문제 상황
청약 적격 분석 리포트 컴포넌트 하나에서 적격/부적격 두 가지 케이스를 모두 처리하다 보니, 조건문이 난무하고 코드가 400줄이 넘어가면서 가독성이 매우 떨어졌다. 적격일 때는 파란색 테마에 축하 메시지, 부적격일 때는 빨간색 테마에 개선 방안을 보여줘야 하는데 로직이 뒤섞여 있었다.
원인 분석
하나의 컴포넌트가 두 가지 완전히 다른 UI와 로직을 담당하고 있어서 단일 책임 원칙(SRP)을 위반하고 있었다.
해결 방법
BlueReportEligible(적격)과 BlueReportIneligible(부적격) 두 개의 독립적인 컴포넌트로 분리했다. 상위 컴포넌트에서 eligible 여부만 판단하여 조건부 렌더링으로 적절한 컴포넌트를 보여주도록 변경했다.
{reportData.eligible ? (
<BlueReportEligible {...reportData} />
) : (
<BlueReportIneligible {...reportData} />
)}
결과
컴포넌트당 160~230줄로 분리되어 가독성이 향상되었고, 적격/부적격 각각의 UI 수정이 독립적으로 가능해져 유지보수성이 개선되었다.