이 글은 2025 구름톤 유니브 시즌톤 해커톤에서 직접 구현한 6가지 기술적 트러블슈팅 경험을 코드베이스 수치와 함께 정리한 회고입니다.
GitHub 링크
https://github.com/9oormthon-univ/2025_SEASONTHON_TEAM_42_FE
1. Axios Interceptor & 중앙화된 인증 처리
문제
각 API 호출 함수마다 Authorization: Bearer ${token} 헤더 설정과 401 처리 로직이 중복 작성되고 있었다. jobApi.ts 단일 파일만 해도 1,177줄에 달했고, 에러 처리 보일러플레이트가 반복되었다.
설상가상으로 동시에 여러 요청이 401을 받을 경우 refresh 요청이 중복 발생하는 Race Condition이 발생했다. 예를 들어 페이지 진입 시 3개의 API가 동시에 호출되면, 3개가 모두 401을 받아 각자 refresh를 시도하는 상황이 생긴다.
원인
초기 구조에서 각 기능별로 개별 fetch() 호출 방식을 사용했기 때문에, 토큰 갱신 로직이 각 API 파일에 분산되었다. 공통 요청/응답 처리 레이어가 없으니 인증 코드를 파일마다 복붙할 수밖에 없었고, 토큰 만료 시 어느 요청이 실패했는지 추적도 어려웠다.
해결
src/lib/api/axios.ts에 Axios 인터셉터 기반 인스턴스를 구성했다. 핵심은 refreshPromise 변수를 이용한 deduplicate 패턴이다. 여러 요청이 동시에 401을 받아도 실제 refresh 요청은 단 1번만 실행되고, 나머지는 같은 Promise를 기다린다.
// src/lib/api/axios.ts
import axios, { AxiosError, AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios';
interface CustomRequestConfig extends InternalAxiosRequestConfig {
_retry?: boolean;
}
// refresh 중복 방지 - 여러 요청이 동시에 401을 받아도 refresh는 1번만 실행
let refreshPromise: Promise<void> | null = null;
export const axiosInstance = axios.create({
baseURL: '',
withCredentials: true,
});
axiosInstance.interceptors.response.use(
(res) => res,
async (error: AxiosError) => {
const originalRequest = error.config as CustomRequestConfig;
if (!error.response) return Promise.reject(error);
if (error.response.status === 401 && !originalRequest._retry) {
originalRequest._retry = true; // 무한 재시도 방지
if (!refreshPromise) {
refreshPromise = fetch('/api/auth/refresh-tokens', {
method: 'POST',
credentials: 'include',
})
.then((res) => {
if (!res.ok) throw new Error('Token refresh failed');
})
.catch((err) => {
if (typeof window !== 'undefined') {
const currentPath = window.location.pathname;
if (!currentPath.startsWith('/ai-chat/')) {
window.location.href = '/';
}
}
throw err;
})
.finally(() => {
refreshPromise = null; // 완료 후 초기화
});
}
await refreshPromise;
return axiosInstance(originalRequest); // 원래 요청 재시도
}
return Promise.reject(error);
}
);
export const api = {
get: <T = unknown>(url: string, config?: AxiosRequestConfig) =>
axiosInstance.get<T>(url, config),
post: <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
axiosInstance.post<T>(url, data, config),
// ... put, delete, patch
};
fetch() 기반 코드와의 호환성을 위해 fetchWithAuth.ts에도 동일 패턴을 구현했다.
// src/lib/api/fetchWithAuth.ts
let isRefreshing = false;
let refreshPromise: Promise<boolean> | null = null;
async function refreshTokens(): Promise<boolean> {
try {
const response = await fetch('/api/auth/refresh-tokens', {
method: 'POST',
credentials: 'include',
});
const result = await response.json();
return result.result === 'SUCCESS';
} catch {
return false;
}
}
export async function fetchWithAuth(
url: string,
options: RequestInit & { skipRefresh?: boolean } = {}
): Promise<Response> {
const { skipRefresh = false, ...fetchOptions } = options;
const requestOptions = { ...fetchOptions, credentials: 'include' as const };
let response = await fetch(url, requestOptions);
if (response.status !== 401 || skipRefresh) return response;
// 동시 401 처리 deduplicate
if (isRefreshing && refreshPromise) {
const success = await refreshPromise;
return success ? fetch(url, requestOptions) : response;
}
isRefreshing = true;
refreshPromise = refreshTokens();
try {
const refreshSuccess = await refreshPromise;
if (refreshSuccess) {
response = await fetch(url, requestOptions);
}
return response;
} finally {
isRefreshing = false;
refreshPromise = null;
}
}
결과
인증 처리 코드가 2개 파일(axios.ts 101줄, fetchWithAuth.ts 113줄)로 완전 통합되었다. 프로젝트 전체에서 axiosInstance / fetchWithAuth 사용 호출이 33곳으로, 기존 각 파일에 흩어져 있던 중복 401 처리 분기문이 모두 제거되었다. 신규 API 추가 시 인증 코드 없이 엔드포인트만 작성하면 된다.
2. Zustand 기반 전역 상태 관리
문제
커리어 로드맵 페이지에서 "로드맵 생성 여부(hasRoadmap)" 상태를 Header, CareerRoadmapSection, AICoachRoadmapSection 등 여러 컴포넌트가 공통으로 참조해야 했다. 그런데 상위 컴포넌트에서 props로 내려보내는 구조로 3단계 이상의 Prop Drilling이 발생했다.
원인
Next.js App Router 환경에서 Server Component와 Client Component가 혼재하고, 페이지 단위 레이아웃 분리로 인해 공통 부모에서 상태를 관리하기 어려운 구조였다. React Context API는 Provider 위치 제약으로 App Router에서 적용이 복잡했다.
해결
src/stores/roadmapStore.ts에 Zustand store를 구성했다. Zustand는 Provider 래핑 없이 동작하므로 App Router 구조에 그대로 적합하다.
// src/stores/roadmapStore.ts
import { create } from 'zustand';
interface RoadmapState {
hasRoadmap: boolean;
setHasRoadmap: (hasRoadmap: boolean) => void;
}
export const useRoadmapStore = create<RoadmapState>((set) => ({
hasRoadmap: true,
setHasRoadmap: (hasRoadmap) => set({ hasRoadmap }),
}));
// 컴포넌트에서 사용 - props 없이 어디서든 1줄로 구독
import { useRoadmapStore } from '@/stores/roadmapStore';
function CareerRoadmapSection() {
const { hasRoadmap, setHasRoadmap } = useRoadmapStore();
// ...
}
결과
로드맵 상태 관련 Props 전달 체인이 완전히 제거되었다. useRoadmapStore를 사용하는 컴포넌트들이 각자 독립적으로 상태를 구독하게 되어, 상태 변경 시 관련 컴포넌트만 선택적으로 리렌더링된다. Redux 대비 보일러플레이트 코드가 적어 빠르게 적용할 수 있었다.
3. Sentry 에러 모니터링
문제
프로덕션 환경에서 발생하는 런타임 에러를 재현하기 어려웠다. 특히 네트워크 요청 실패 에러들이 Sentry에 "Network Error"로만 기록되어, "어느 엔드포인트에서, 어떤 HTTP 상태로 실패했는지" 구분이 불가능했다.
원인
Axios 에러는 error.message가 "Network Error"처럼 추상화된 문자열로 동일하게 표시된다. Sentry 이슈 목록에서 서로 다른 API 실패들이 하나의 이슈로 묶이게 되어 우선순위 파악이 불가능했다. 클라이언트/서버/엣지 환경별 설정도 분리되지 않았다.
해결
src/lib/sentry/SentryNetworkError.ts에 커스텀 에러 클래스를 구현했다. [401 Error] - /api/auth/user 형태로 HTTP 상태 코드와 경로를 에러 이름에 포함시키고, URL 경로의 동적 파라미터(/123/)를 /{id}/로 정규화해 같은 종류의 에러가 1개 이슈로 집계되도록 처리했다.
// src/lib/sentry/SentryNetworkError.ts
type AxiosLikeError = {
message?: string;
config?: { baseURL?: string; url?: string } | Record<string, unknown>;
response?: { status?: number } | unknown;
code?: string | number | null;
request?: unknown;
isAxiosError?: boolean;
};
export class SentryNetworkError extends Error {
name: string;
// "[401 Error] - /api/auth/user" 형태로 이름 생성
private static generateName(error: AxiosLikeError): string {
const status =
(error.response && (error.response as { status?: number }).status) || 'Unknown';
const baseURL = (error.config && (error.config as { baseURL?: string }).baseURL) || '';
const url = (error.config && (error.config as { url?: string }).url) || '';
const path = String(url).split('?')[0];
// /user/123/profile → /user/{id}/profile 로 정규화
const replacePathParams = path.replace(/\/\d+(?=\/|$)/g, '/{id}');
return `[${status} Error] - ${baseURL}${replacePathParams}`;
}
constructor(error: AxiosLikeError) {
super(error.message || 'Network Error');
this.name = SentryNetworkError.generateName(error);
Object.setPrototypeOf(this, SentryNetworkError.prototype);
}
}
Sentry 설정은 sentry.client.config.ts, sentry.server.config.ts, sentry.edge.config.ts 3개 환경으로 분리하고, tracesSampleRate: 1.0으로 전수 추적을 설정했다.
// sentry.client.config.ts
import * as Sentry from '@sentry/nextjs';
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
tracesSampleRate: 1.0, // 전수 추적
});
// Web Vitals를 Sentry에 함께 전송
if (typeof window !== 'undefined') {
import('web-vitals').then(({ onCLS, onFCP, onLCP, onTTFB, onINP }) => {
const handleMetric = (metric: {
name: string;
value: number;
rating: string;
delta: number;
id: string;
}) => {
Sentry.setMeasurement(metric.name, metric.value, 'millisecond');
Sentry.setContext('web-vitals', metric);
};
onCLS(handleMetric);
onFCP(handleMetric);
onLCP(handleMetric);
onTTFB(handleMetric);
onINP(handleMetric);
});
}
결과
네트워크 에러가 엔드포인트 + 상태코드 조합으로 분류되어 동일 에러를 빠르게 식별할 수 있게 되었다. Web Vitals(CLS, FCP, LCP, TTFB, INP) 5개 지표를 Sentry.setMeasurement로 자동 전송해, 성능 이슈와 에러를 같은 대시보드에서 연계 분석할 수 있게 되었다.
4. Google Analytics & Web Vitals
문제
서비스 기획 개선 시 "실제 사용자가 어떤 페이지에서 이탈하는지", "로딩이 느리다고 느끼는 구간이 어디인지"에 대한 데이터 없이 주관적 판단에 의존하고 있었다. PWA 환경에서 모바일 접속 비중이 높을 것으로 예상되었으나, 실제 디바이스별 성능 편차를 측정할 수단이 없었다.
원인
별도의 사용자 행동 수집 도구가 미도입 상태였다. 특히 Core Web Vitals 지표를 실사용자 기준으로 수집하는 RUM(Real User Monitoring) 파이프라인이 없었다.
해결
src/app/_components/WebVitals.tsx 컴포넌트에서 web-vitals 라이브러리의 5개 메트릭을 수집해 Sentry로 동시 전송한다.
// src/app/_components/WebVitals.tsx
'use client';
import { useEffect } from 'react';
import * as Sentry from '@sentry/nextjs';
export function WebVitals() {
useEffect(() => {
const reportWebVitals = async () => {
const { onCLS, onFCP, onLCP, onTTFB, onINP } = await import('web-vitals');
const handleMetric = (metric: {
name: string;
value: number;
rating: string;
delta: number;
id: string;
}) => {
Sentry.setMeasurement(metric.name, metric.value, 'millisecond');
Sentry.setContext('web-vitals', metric);
if (process.env.NODE_ENV === 'development') {
console.log(`[Web Vitals] ${metric.name}:`, metric);
}
};
onCLS(handleMetric);
onFCP(handleMetric);
onLCP(handleMetric);
onTTFB(handleMetric);
onINP(handleMetric);
};
reportWebVitals();
}, []);
return null; // UI 없는 측정 전용 컴포넌트
}
src/app/layout.tsx에서는 환경변수 기반 조건부 삽입으로 개발 환경 오염을 방지했다. LCP 대상 이미지도 <link rel="preload">로 사전 로딩했다.
// src/app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
const gaId = process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID;
return (
<html lang="ko">
<head>
{/* GA_ID가 있을 때만 스크립트 삽입 (개발 환경 오염 방지) */}
{gaId && (
<>
<script async src={`https://www.googletagmanager.com/gtag/js?id=${gaId}`} />
<script
dangerouslySetInnerHTML={{
__html: `
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${gaId}');
`,
}}
/>
</>
)}
{/* LCP 대상 이미지 preload */}
<link rel="preload" href="/assets/Icons/character_hi.webp" as="image" type="image/webp" />
</head>
<body>
<QueryProvider>
<ToastProvider />
<ConditionalHeaderClient />
{children}
</QueryProvider>
</body>
</html>
);
}
결과
LCP(최대 콘텐츠 렌더링), CLS(누적 레이아웃 이동), INP(다음 페인트까지 상호작용) 등 Core Web Vitals 5개 지표를 실사용자 기준으로 수집하게 되었다. 페이지뷰·이벤트 데이터(GA4)와 성능 지표(Sentry)를 연계해 UX 개선 우선순위를 데이터 기반으로 결정할 수 있게 되었다.
5. CI/CD 자동화 & Husky
문제
팀 협업 시 개인 로컬 환경에 따라 lint 미통과 코드가 main 브랜치에 머지되거나, 배포 전 수동 빌드 검증 누락으로 런타임 오류가 프로덕션에 유입되는 사례가 발생했다.
원인
코드 품질 검사와 빌드 검증이 개발자 재량에 맡겨져 있고, 배포 프로세스가 자동화되지 않아 휴먼 에러 개입 가능성이 높았다.
해결
.github/workflows/ci.yml에 main · develop 브랜치 push/PR 시 4단계 파이프라인을 구성했다.
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
lint-and-type-check:
name: Code Quality Check
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'npm'
- run: npm ci
- run: npm run lint # ESLint
- run: npx prettier --check . # Prettier
- run: npx tsc --noEmit # TypeScript 타입 체크
build:
name: Build Application
runs-on: ubuntu-latest
needs: lint-and-type-check # lint 통과 후에만 빌드
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20.x, cache: 'npm' }
- run: npm ci
- run: npm run build
env:
NEXT_PUBLIC_BACKEND_URL: ${{ env.NEXT_PUBLIC_BACKEND_URL }}
- uses: actions/upload-artifact@v4
with:
name: build-files-${{ github.sha }}
path: .next/
retention-days: 7
Husky pre-commit 훅에 lint-staged를 연결해 커밋 대상 파일에만 ESLint + Prettier를 선택적 실행했다.
# .husky/pre-commit
npx lint-staged
// package.json - lint-staged 설정
{
"lint-staged": {
"**/*.{js,jsx,ts,tsx}": [
"eslint --fix",
"prettier --write"
],
"**/*.{json,css,md}": [
"prettier --write"
]
}
}
결과
PR 머지 전 코드 품질 게이트가 자동 적용된다. Husky로 인해 커밋 단계에서 lint 에러가 있으면 커밋 자체가 차단되어 "lint는 나중에"로 미루는 습관을 방지했다. 전체 파일 대신 변경 파일만 검사하므로 pre-commit 실행 시간이 최소화된다.
6. PWA & Lottie 애니메이션
문제
서비스 대상인 중장년층은 앱 설치에 익숙하고 웹 브라우저 재방문율이 낮다. 또한 단순 페이지 전환 시 시각적 피드백이 없어 로딩 중 사용자가 이탈하거나 중복 탭을 다시 시도하는 패턴이 발생했다.
원인
일반 웹사이트로 배포되어 홈 화면 추가·오프라인 캐싱 등의 네이티브 앱 경험을 제공할 수 없었다. 로딩/전환 구간에 정적 스피너만 사용해 사용자 주의를 붙잡을 동적 요소가 부재했다.
해결
next-pwa + Service Worker + manifest.json으로 PWA를 설정했다. 개발 환경에서는 자동 비활성화된다.
// next.config.ts
const withPWA = require('next-pwa')({
dest: 'public', // sw.js, workbox 파일 생성 위치
register: true, // Service Worker 자동 등록
skipWaiting: true, // 새 SW 즉시 활성화
disable: process.env.NODE_ENV === 'development', // 개발 환경 비활성화
});
// public/manifest.json
{
"name": "Next Career App",
"short_name": "Next-Career",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#000000",
"lang": "ko",
"orientation": "portrait",
"icons": [
{
"src": "/icons/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
}
]
}
Lottie는 @lottiefiles/dotlottie-react를 사용해 AI 채팅, 로드맵 완성 등 주요 인터랙션 구간에 적용했다. 애니메이션 종료 후 콜백으로 다음 화면 전환과 자연스럽게 연결된다.
// src/app/_components/ui/CompletionAnimation.tsx
'use client';
import { DotLottieReact } from '@lottiefiles/dotlottie-react';
interface CompletionAnimationProps {
isVisible: boolean;
onComplete?: () => void;
duration?: number; // 기본 2000ms 후 자동 종료
lottieUrl?: string;
}
const CompletionAnimation: React.FC<CompletionAnimationProps> = ({
isVisible,
onComplete,
duration = 2000,
lottieUrl = 'https://lottiefiles.com/free-animation/confetti-3ofTs67sBx',
}) => {
const [shouldRender, setShouldRender] = useState(false);
useEffect(() => {
if (isVisible) {
setShouldRender(true);
const timer = setTimeout(() => {
setShouldRender(false);
onComplete?.(); // 애니메이션 종료 후 콜백 (다음 화면 전환 등)
}, duration);
return () => clearTimeout(timer);
}
}, [isVisible, duration, onComplete]);
if (!shouldRender) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center pointer-events-none">
<DotLottieReact
src={lottieUrl}
loop={false}
autoplay={true}
style={{ width: '100vw', height: '100vh' }}
/>
</div>
);
};
iOS 독립 실행 모드 지원을 위해 layout.tsx에 메타태그도 추가했다.
// src/app/layout.tsx - iOS PWA 메타태그
<head>
<link rel="manifest" href="/manifest.json" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="NextCareer service" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
</head>
결과
홈 화면 추가(Add to Home Screen) 및 iOS Safari 독립 실행 모드 지원으로, 중장년 사용자에게 앱과 동일한 진입 경험을 제공할 수 있게 되었다. Service Worker 캐싱으로 반복 방문 시 정적 자산 네트워크 요청이 감소하고, 핵심 이미지 <link rel="preload"> 적용으로 초기 로딩 시 LCP 개선 효과를 기대할 수 있다.
마치며
이번 프로젝트에서 6가지 기술적 의사결정을 통해 공통적으로 얻은 교훈은 하나다. "나중에 고치면 되지"는 없다. 인증 중복 코드도, lint 미통과 커밋도, 모니터링 부재도 - 모두 초기에 구조를 잡지 않으면 기능이 쌓일수록 비용이 기하급수적으로 늘어난다.
코드베이스 수치를 근거로 의사결정을 내리고, 자동화로 사람의 실수 가능성을 줄이고, 모니터링으로 프로덕션을 실시간 감지하는 것 - 이 세 가지가 앞으로의 프로젝트에서도 지키고 싶은 원칙이다.
