
Next.js로 개발한 개인 포트폴리오 겸 블로그 프론트엔드
기술 스택
프론트엔드 개발에 사용한 주요 기술 스택은 다음과 같다:
- Next.js 15.0.3
- React 19.0.0-rc
- TypeScript
- Tailwind CSS
- TipTap 에디터
- Zustand (상태 관리)
- React Query (서버 상태 관리)
- Supabase (인증 및 스토리지)
- React Hook Form + Zod (폼 관리 및 유효성 검증)
주요 기능 및 구현 내용
1. 인증 시스템 구현
백엔드에서 구현한 JWT + 리프레시 토큰 로테이션 시스템과 연동하기 위해 프론트엔드에서도 관련 로직을 구현했다.
// 토큰 갱신이 필요한지 확인하는 함수
export function shouldRefreshToken(): boolean {
const tokenExpiry = useAuthStore.getState().tokenExpiry
if (!tokenExpiry) return false
// 만료 10분 전부터 갱신 허용
return Date.now() >= tokenExpiry - 10 * 60 * 1000
}
특히 토큰 갱신 과정에서 여러 동시 요청이 발생했을 때 하나의 요청만 처리하고 나머지는 대기시키는 메커니즘을 구현했다:
export async function handleTokenRefresh() {
// 이미 진행 중인 갱신 요청이 있다면 해당 Promise 반환
if (refreshPromise) {
return refreshPromise;
}
isRefreshing = true;
refreshPromise = (async () => {
try {
const newToken = await refreshTokenRequest();
await setAuthToken(newToken);
// 대기 중인 요청들 처리
refreshSubscribers.forEach(callback => callback(newToken));
return newToken;
} catch (error) {
throw error;
} finally {
isRefreshing = false;
refreshSubscribers = [];
refreshPromise = null;
}
})();
return refreshPromise;
}
또한 크로스탭 인증을 구현하여 여러 탭에서 일관된 로그인 상태를 유지할 수 있게 했다.
인증 시스템의 핵심인 authInterceptor.ts에서는 토큰 갱신의 복잡한 시나리오를 모두 처리했다. 특히 인증 인터셉터 모듈은 다음과 같은 핵심 기능을 제공한다:
/**
* 인증 인터셉터 모듈
*
* 이 모듈은 JWT 토큰 기반의 인증 시스템을 관리하며, 다음과 같은 주요 기능을 제공:
* - 토큰 갱신 관리
* - 인증 상태 관리
* - 요청 큐 관리
* - 다중 탭 동기화
*/
이 모듈을 통해 다음과 같은 시나리오를 모두 처리할 수 있게 됐다:
앱 로드 시 인터셉터 초기화 전에 요청이 발생하는 경우
여러 API 요청이 동시에 발생하여 다수의 토큰 갱신 시도가 발생하는 경우
토큰 만료로 인해 API 요청이 실패한 경우
사용자가 여러 탭에서 동시에 앱을 사용하는 경우
토큰 갱신 요청 자체가 실패하는 경우
특히 API 요청이 초기화 전에 발생하는 경우를 처리하기 위한 큐 시스템을 구현했다:
export function queueRequest(request: () => Promise<any>) {
if (!isInitialized) {
// 초기화 완료 전 요청이 들어오면 큐에 추가
requestQueue.push(request)
return new Promise((resolve, reject) => {
// 큐에 있는 요청이 처리될 때까지 대기
const checkQueue = setInterval(() => {
if (isInitialized) {
clearInterval(checkQueue)
request()
.then(resolve)
.catch(reject)
}
}, 100)
})
}
return request()
}
또한 토큰 갱신 후 대기 중인 모든 API 요청에게 새 토큰을 전달하는 구독 패턴도 구현했다:
export function addRefreshSubscriber(callback: (token: string) => void) {
refreshSubscribers.push(callback)
}
이를 통해 사용자는 인증 과정에서 어떤 불편함도 느끼지 않고 앱을 사용할 수 있다.
2. TipTap 에디터 구현
블로그 포스팅을 위한 에디터로 TipTap을 선택하고 커스터마이징했다. 스티키 툴바, 이미지 리사이징, 다양한 텍스트 스타일링 기능을 추가했다.

// TiptapWithStickyToolbar.tsx에서 스티키 툴바 구현
export function TiptapWithStickyToolbar({
content,
onChange,
placeholder,
editable = true,
className
}: TiptapWithStickyToolbarProps) {
const [isSticky, setIsSticky] = useState(false);
const toolbarRef = useRef<HTMLDivElement>(null);
const editorRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleScroll = () => {
if (toolbarRef.current && editorRef.current) {
const editorRect = editorRef.current.getBoundingClientRect();
setIsSticky(editorRect.top < 0);
}
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
return (
<div className={className} ref={editorRef}>
<div
ref={toolbarRef}
className={`${isSticky ? 'sticky top-0 z-10 bg-white shadow-md' : ''}
transition-all duration-200`}
>
<TiptapToolbar editor={editor} />
</div>
<Tiptap
content={content}
onChange={onChange}
placeholder={placeholder}
editable={editable}
/>
</div>
);
}
에디터에서 작성된 콘텐츠는 JSON 형태로 저장되며, 이미지는 백엔드를 거치지 않고 Supabase 스토리지에 직접 업로드된다.
3. 커스텀 Fetch 클라이언트
API 요청을 효율적으로 관리하기 위해 인터셉터 패턴을 적용한 커스텀 fetch 클라이언트를 구현했다:
// fetchClient.ts의 일부
export async function fetchWithAuth<T>(
url: string,
options: FetchOptions = {}
): Promise<T> {
try {
// 토큰 갱신이 필요한지 확인
if (shouldRefreshToken()) {
await handleTokenRefresh();
}
// 요청 전송
const response = await fetch(url, {
...options,
headers: {
...getAuthHeaders(),
...options.headers,
},
});
// 401 에러 처리 (토큰 만료)
if (response.status === 401) {
try {
const newToken = await handleTokenRefresh();
// 새 토큰으로 재요청
return fetchWithAuth<T>(url, options);
} catch (refreshError) {
// 리프레시 실패 시 로그아웃
handleLogout();
throw new TokenError('인증이 만료되었습니다. 다시 로그인해주세요.');
}
}
// 응답 처리
const data = await response.json();
return data as T;
} catch (error) {
// 에러 처리
if (error instanceof TokenError) {
throw error;
}
throw new Error('API 요청 중 오류가 발생했습니다.');
}
}
이 커스텀 클라이언트는 요청 전 토큰 상태를 확인하고, 필요한 경우 갱신한 후 요청을 처리한다. 또한 401 에러가 발생하면 자동으로 토큰을 갱신하고 재요청하는 메커니즘을 구현했다. 이렇게 함으로써 사용자는 토큰 만료 같은 인증 문제를 전혀 인식하지 못하고 앱을 사용할 수 있다.
이러한 인증 관련 로직은 인터셉터 패턴으로 구현되어 애플리케이션의 다른 부분과 완전히 분리됐다. 따라서 개발 중 인증 로직이 변경되어도 다른 코드를 수정할 필요가 없다.
4. 상태 관리 전략
Zustand를 사용하여 전역 상태를 관리하고, React Query를 활용하여 서버 상태를 효율적으로 관리했다:
// 인증 상태 관리를 위한 Zustand 스토어
export const useAuthStore = create<AuthStore>((set) => ({
isAuthenticated: false,
profile: null,
tokenExpiry: null,
isLoading: true,
setAuthState: (state) => set(state),
setProfile: (profile) => set({ profile }),
setLoading: (isLoading) => set({ isLoading }),
resetAuthState: () => set({
isAuthenticated: false,
tokenExpiry: null
}),
resetProfile: () => set({ profile: null }),
}));
// React Query를 사용한 포스트 목록 조회 훅
export function useGetPostList(params: PostListParams) {
return useQuery({
queryKey: ['posts', params],
queryFn: () => fetchPosts(params),
staleTime: 5 * 60 * 1000, // 5분 동안 캐시 유지
});
}
5. 반응형 디자인 구현
Tailwind CSS를 활용하여 모든 화면 크기에 최적화된 반응형 UI를 구현했다:
// 메인 페이지의 반응형 그리드 레이아웃
<section className={`mb-12 ${theme.card.border} rounded-lg p-0 overflow-hidden`}>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-0">
{/* 메인 뉴스 영역 - 큰 화면에서는 8칸 차지 */}
<div className="lg:col-span-8 lg:border-r lg:border-black p-6">
{/* 콘텐츠 */}
</div>
{/* 우측 서브 뉴스 영역 - 큰 화면에서는 4칸 차지 */}
<div className="lg:col-span-4 flex flex-col">
{/* 콘텐츠 */}
</div>
</div>
</section>
{/* 프로젝트 섹션 - 모바일에서는 1열, 테블릿 이상에서는 3열 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{/* 프로젝트 카드들 */}
}
모든 요소는 모바일 퍼스트 방식으로 설계되었으며, 디바이스 크기에 따라 최적화된 레이아웃을 제공한다.
6. SEO 최적화
Next.js의 메타데이터 API를 활용하여 각 페이지별 SEO를 최적화했다:
// 동적 메타데이터 생성
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slugAndId } = params;
const [slug, id] = slugAndId.split('-');
try {
const post = await getPostDetail(id);
return {
title: `${post.title} | 내 블로그`,
description: post.excerpt || post.description,
openGraph: {
title: post.title,
description: post.excerpt || post.description,
images: post.thumbnail ? [post.thumbnail] : [],
},
};
} catch (error) {
return {
title: '포스트를 찾을 수 없습니다 | 내 블로그',
};
}
}
7. 성능 최적화
로딩 상태 표시, 코드 스플리팅, 이미지 최적화 등 다양한 기법을 활용하여 사용자 경험을 개선했다:
// 스켈레톤 UI로 로딩 상태 표시
if (isLatestPostsLoading || isFeaturedPostLoading || isRecentPostsLoading || isProjectsLoading) {
return <HomePageSkeleton />;
}
// Next.js Image 컴포넌트로 이미지 최적화
<Image
src={post.thumbnail}
alt={post.title}
width={400}
height={250}
className={themeClasses.image}
/>
개발 과정에서의 어려움과 해결책
1. 인증 시스템 구현의 복잡성
JWT 토큰과 리프레시 토큰 로테이션을 구현하면서 여러 동시 요청이 있을 때 토큰 갱신을 어떻게 처리할지가 주요 과제였다. 이를 해결하기 위해 프로미스 기반의 큐 시스템을 구현했다.
authInterceptor.ts에서는 복잡한 토큰 갱신 시나리오를 모두 처리했는데, 특히 다음과 같은 문제들을 해결했다:
레이스 컨디션 방지: 여러 API 요청이 동시에 토큰 갱신을 시도하는 경우, 첫 번째 요청만 실제 갱신을 수행하고 나머지는 그 결과를 기다리도록 했다.
let refreshPromise: Promise<string> | null = null
export async function handleTokenRefresh() {
if (refreshPromise) {
return refreshPromise;
}
// 새로운 갱신 요청 생성
refreshPromise = (async () => {
try {
// 갱신 로직...
} finally {
refreshPromise = null; // Promise 초기화
}
})();
return refreshPromise;
}
토큰 페이로드 디코딩과 상태 업데이트: 토큰에서 사용자 정보와 만료 시간을 추출하여 상태를 업데이트했다.
export async function setAuthToken(token: string | null) {
if (!token) {
useAuthStore.getState().resetAuthState();
return;
}
const payload = getTokenPayload(token);
if (!payload) {
throw new Error('Invalid token');
}
// 상태 업데이트
await new Promise<void>((resolve) => {
useAuthStore.getState().updateAuthState({
accessToken: token,
tokenExpiry: payload.exp * 1000,
role: payload.role,
sub: payload.sub,
tokenVersion: payload.tokenVersion,
keepLoggedIn: payload.keepLoggedIn,
isAuthenticated: true
});
resolve();
});
}
요청 큐 관리: 인터셉터가 초기화되기 전에 발생한 모든 요청을 큐에 저장하고, 초기화 후에 순차적으로 처리했다.
export function markInterceptorInitialized() {
isInitialized = true;
processQueuedRequests();
}
async function processQueuedRequests() {
while (requestQueue.length > 0) {
const request = requestQueue.shift();
if (request) {
try {
await request();
} catch (error) {
logger.error('[processQueuedRequests] 큐 요청 처리 실패:', error);
}
}
}
}
이러한 복잡한 인증 관련 로직을 구현함으로써 사용자는 인증 문제를 전혀 경험하지 않는 매끄러운 경험을 얻을 수 있었다.
2. TipTap 에디터 커스터마이징
TipTap 에디터를 프로젝트에 맞게 커스터마이징하는 과정이 도전적이었다. 특히 이미지 리사이징 기능과 스티키 툴바를 구현하면서 많은 시행착오를 겪었지만, 결국 사용하기 편리한 에디터를 완성할 수 있었다.
3. 상태 관리 전략
여러 페이지와 컴포넌트에서 일관된 상태를 유지하는 것이 중요했다. Zustand와 React Query를 적절히 조합하여 전역 상태와 서버 상태를 효율적으로 관리했고, 특히 인증 상태를 여러 탭에서 동기화하는 메커니즘을 구현했다.
배운 점 및 개선할 점
배운 점
복잡한 인증 흐름을 프론트엔드에서 처리하는 방법
에디터 컴포넌트 최적화 및 커스터마이징 기법
React Query를 활용한 효율적인 서버 상태 관리
타입스크립트를 활용한 엄격한 타입 시스템의 중요성
개선할 점
테스트 코드 작성 - 현재는 수동 테스트 위주로 진행했으나, 향후 단위 테스트와 E2E 테스트를 추가할 예정이다.
성능 모니터링 시스템 구축 - 실사용자 환경에서의 성능을 측정하고 개선하는 시스템이 필요하다.
접근성 개선 - 시맨틱 HTML과 ARIA 속성을 더 철저히 적용하여 접근성을 높일 계획이다.
국제화(i18n) 지원 - 여러 언어를 지원할 수 있도록 시스템을 개선할 예정이다.