블로그를 운영하며 단순한 기능 구현을 넘어 "어떻게 하면 더 쾌적한 사용자 경험(UX)을 줄 수 있을까?"에 대한 고민은 끝이 없었습니다. 로컬 마크다운 방식에서 시작해 현재의 하이브리드 전략에 도달하기까지, 렌더링 방식의 변화와 그 과정에서 마주한 트러블슈팅 기록을 공유합니다.
1. 정적 블로그의 한계와 에디터 도입
가장 처음 블로그는 로컬 마크다운(.md) 기반의 SSG 형태였습니다. 하지만 운영하다 보니 치명적인 단점들이 눈에 띄었습니다.
높은 수정 비용: 게시글 하나를 고치려 해도 코드를 수정하고 다시 빌드/배포해야 했습니다.
위지윅(WYSIWYG)의 부재: 로컬 에디터에서 작성한 결과물과 실제 웹사이트상의 스타일이 미묘하게 상이해 이를 맞추는 데 개발 피로도가 상당했습니다.
이를 해결하기 위해 Tiptap 에디터를 도입했습니다. 웹상에서 직접 글을 작성하고 올리는 편리함을 얻었지만, 이는 곧 새로운 고민의 시작이었습니다.
2. SSR의 함정과 하이브리드 전략의 실무 적용
에디터 도입 후 초기에는 SSR(Server Side Rendering) 방식을 택했습니다. 구현은 성공적이었으나, 사용자 경험 측면에서는 SSG를 따라갈 수 없었습니다. 사용자가 요청할 때마다 서버에서 새로 화면을 그려야 했기 때문입니다.
해당 부분을 개선하기 위해 로딩시 스켈레톤을 표시하도록 하기도 했지만 사용자가 느끼는 ‘느린’ 불편함은 해소할 수 없었습니다.
결국, 성능과 편의성의 타협점을 찾기 위해 SSG, ISR, SSR을 혼용하는 하이브리드 전략으로 선회했습니다. 게시글 리스트와 상세 페이지를 미리 정적으로 생성하여 속도를 확보했습니다.
🛠️ 실제 구현 코드: ISR과 SSG의 혼용
게시글 리스트 페이지(posts/[page])는 빌드 타임에 모든 페이지를 미리 생성(SSG)하고, 1시간마다 백그라운드에서 갱신(ISR)하도록 설정했습니다.
// app/posts/[page]/page.tsx
import { EXTERNAL_URL_IN_NODE } from '@/constants/node/url';
import { PostsDataResponse } from '@/types/post';
import { requestHttp } from '@/utils/http/request';
import PostCards from './_components/post-cards';
import PostPagination from './_components/post-pagination';
const LIMIT_POST = 10;
const POSTS = async ({ params }: PostProps) => {
const page = Number(params.page || 1);
// 서버사이드 페칭: 빌드 타임 혹은 요청 시점에 외부 API 호출
const res = await requestHttp.get<PostsDataResponse>(
`${EXTERNAL_URL_IN_NODE.POSTS}?page=${page}&pageSize=${LIMIT_POST}`,
);
return (
<div className="mx-auto flex w-full max-w-5xl flex-col justify-between">
<PostCards initialData={res} page={page} pageSize={LIMIT_POST} />
<PostPagination initialData={res} page={page} pageSize={LIMIT_POST} />
</div>
);
};
// SSG: 빌드 타임에 생성할 경로 정의
export async function generateStaticParams() {
try {
const res = await requestHttp.get<PostsDataResponse>(
`${EXTERNAL_URL_IN_NODE.POSTS}?page=1&pageSize=${LIMIT_POST}`
);
const totalPage = res.data?.totalPage || 1;
return Array.from({ length: totalPage }, (_, i) => ({
page: String(i + 1),
}));
} catch {
return []; // 빌드 실패 방지
}
}
export const revalidate = 3600; // ISR: 1시간마다 갱신
export const dynamicParams = true; // 빌드 이후 추가된 페이지도 지원
interface PostProps {
params: { page?: string };
}
export default POSTS;3. LCP 개선과 로딩 스켈레톤의 삭제
인가 유무와 상관없이 서버에서는 우선 공통 데이터를 페치하여 정적 페이지를 즉시 보여주도록 변경했습니다. 이후 클라이언트에서 인가 유무를 판단해 데이터를 덮어씌우는 형태를 취했습니다.
이 과정에서 React Query를 도입했습니다. 정적 페이지가 즉시 뜨기 때문에 불필요한 로딩 스켈레톤 코드를 전부 삭제할 수 있었고, 크롬 Lighthouse 측정 시 1000ms가 넘던 LCP 점수를 획기적으로 방어했습니다.
4. 레이아웃 딜레이 개선과 사파리를 괴롭힌 FOC(Flash of Content) 트러블슈팅
초기 설계에서는 페이지별로 레이아웃이 유사함에도 불구하고 폴더 구조가 파편화되어 있었습니다. 이로 인해 라우트 이동 시 매번 레이아웃을 새로 렌더링하면서 미세한 딜레이가 발생했습니다.
개선 사항: 공통된 디자인 가이드라인을 공유하는 페이지들을 하나의 공유 레이아웃(
layout.tsx) 아래로 통합했습니다.결과: 페이지 전환 시 불필요한 레이아웃 리렌더링이 제거되어, 마치 싱글 페이지 애플리케이션(SPA)처럼 매끄러운 전환 효과를 얻었습니다.
하지만 리다이렉트 로직에서 예상치 못한 문제가 발생했습니다. /posts/나 /post/ 접근 시 특정 페이지로 리다이렉트하는 로직이 크롬에서는 멀쩡했으나, 사파리에서는 끔찍한 FOC(화면 깜빡임)를 발생시켰습니다. 이를 해결하기 위해 페이지 레벨이 아닌 next.config.js의 redirects 설정을 사용했습니다.
// next.config.js
module.exports = {
async redirects() {
return [
{
source: '/posts',
destination: '/posts/1',
permanent: true, // 301 리다이렉트로 SEO 최적화 및 FOC 방지
},
{
source: '/post',
destination: '/not-found', // post가 1이 아닌 경우 등 예외 처리
permanent: true,
},
];
},
}인프라와 렌더링 전략을 정비하며 정적 페이지의 속도와 동적 에디터의 편리함을 결합했습니다. 이 과정에서 Lighthouse 점수를 복구하고 사파리 브라우저의 렌더링 이슈까지 해결할 수 있었습니다.
이번에는 데이터 흐름의 보안을 강화하고 사용자 흐름이 끊기지 않는 인가(Authorization) 시스템을 구축한 과정을 공유합니다.
5. 보안을 위한 API 캡슐화: Route Handler 도입
초기에는 브라우저에서 외부 API 서버로 직접 요청을 보냈습니다. 하지만 이는 API 엔드포인트와 보안 토큰이 클라이언트 사이드에 그대로 노출되는 문제를 야기했습니다.
이를 해결하기 위해 Next.js Route Handler를 도입하여 클라이언트와 실제 API 서버 사이의 '프록시' 역할을 하게 했습니다.
SSR 단계: 노드 환경에서 직접 외부 API를 호출하여 성능을 확보합니다.
클라이언트 단계: 브라우저는 오직 내부
/api/*경로로만 요청을 보냅니다. 실제 외부 API 주소는 서버 환경변수로 숨겨져 브라우저에 노출되지 않습니다.
6. React Query와 초기 데이터 주입 (Hydration)
SSR에서 받아온 데이터를 클라이언트의 React Query와 동기화하여, 사용자가 페이지에 진입하자마자 최신 상태를 유지하면서도 즉각적인 인터랙션이 가능하도록 설계했습니다.
🛠️ 실제 구현 코드: 클라이언트 쿼리 연동
// _components/post-cards.tsx (Client Component)
'use client';
import usePostsQuery from '@/hooks/queries/post/use-posts.query';
import { ApiResponse } from '@/types/api';
import { PostsDataResponse } from '@/types/post';
import Post from './post';
const PostCards = ({ page, pageSize, initialData }: PostListProps) => {
// 서버에서 넘겨받은 initialData를 초기값으로 설정
// 이 덕분에 클라이언트에서 페칭이 시작되기 전에도 화면이 비어있지 않습니다.
const { data } = usePostsQuery(page, pageSize, { initialData });
return (
<div className="flex flex-wrap gap-2 leading-loose">
{data?.data?.posts.map(post => <Post key={post.id} post={post} />)}
</div>
);
};7. Query Key 관리의 체계화: query-key-factory
클라이언트 사이드에서 React Query를 사용할 때, 문자열로 관리하던 쿼리 키를 체계적으로 관리하기 위해 createQueryKeyStore를 도입했습니다. 이를 통해 오타로 인한 버그를 방지하고 데이터 의존성을 명확히 했습니다.
// hooks/queries/post/postQueryKey.ts
export const postQueryKey = createQueryKeyStore({
post: {
one: (payload: { postId: string }) => ({
queryKey: [payload],
queryFn: () => requestHttp.get<PostDataResponse>(`${INTERNAL_URL_IN_CLIENT.POSTS}/${payload.postId}`),
}),
list: (payload: { page: number; pageSize: number }) => ({
queryKey: [payload],
queryFn: () => requestHttp.get<PostsDataResponse>(
`${INTERNAL_URL_IN_CLIENT.POSTS}?page=${payload.page}&pageSize=${payload.pageSize}`
),
}),
},
});8. 토큰 재발행 로직의 혁신: 리프레시 페이지 탈출
가장 까다로웠던 부분은 AccessToken 만료 대응이었습니다. 이전에는 토큰 만료 시 별도의 refresh 페이지로 리다이렉트하여 클라이언트에서 처리하게 했습니다. 하지만 이 방식은 두 가지 치명적인 문제가 있었습니다.
사용자 경험 단절: 글을 작성하거나 수정하던 중 토큰이 만료되면 페이지가 이동되어 작업 내용이 유실될 위험이 있었습니다.
복잡한 예외 처리: 모든 클라이언트 페칭(
useQuery)마다 리다이렉트 로직을 심어야 했습니다.
개선 방안: Route Handler 내부 자동 재발행
이제 모든 클라이언트 요청은 내부 Route Handler를 거치므로, 서버 측에서 토큰 만료를 감지하고 자동으로 재발행한 뒤 원래의 요청을 완수하는 구조로 개편했습니다.
단순히 재발행하는 것을 넘어, 여러 API가 동시에 호출될 때 발생하는 중복 재발행 문제를 Promise 공유(refreshPromise)와 대기 큐(stack)를 통해 해결했습니다.
// utils/http/server-fetch.ts
let refreshPromise: Promise<ReIssueTokenResponseData | null> | null = null;
const refreshToken = async () => {
// 1. 이미 토큰 재발행이 진행 중이라면 새 요청을 보내지 않고 기존 Promise를 반환합니다.
if (refreshPromise) return refreshPromise;
refreshPromise = fetchServer<ReIssueTokenResponseData>(EXTERNAL_URL_IN_NODE.REFRESH, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: await getCookie(COOKIE_KEYS.REFRESH_TOKEN) }),
}).then(result => {
refreshPromise = null; // 완료 후 초기화하여 다음 만료 시 다시 동작하게 합니다.
return result.data ?? null;
});
return refreshPromise;
};
export const fetchServerWithAuth = async <ResponseData>(
input: RequestInfo,
init?: RequestInit,
): Promise<ApiResponse<ResponseData>> => {
// ... 생략 (재발행 중인 경우 큐에 적재하여 대기하는 로직)
const accessToken = await getCookie(COOKIE_KEYS.ACCESS_TOKEN);
// 2. 일차적으로 인증 헤더와 함께 요청을 보냅니다.
const result = await fetchServer<ResponseData>(input, {
...init,
headers: { ...init?.headers, ...(accessToken && { Authorization: `Bearer ${accessToken}` }) },
});
// 3. 서버 사이드에서 401 에러(만료)가 발생한 경우 자동 재발행을 시도합니다.
if (typeof window === 'undefined' && result.statusCode === 401) {
const newTokenData = await refreshToken();
if (!newTokenData) {
// 리프레시 토큰까지 만료된 경우 쿠키를 삭제하고 실패를 반환합니다.
deleteCookie(COOKIE_KEYS.ACCESS_TOKEN, COOKIE_OPTIONS);
deleteCookie(COOKIE_KEYS.REFRESH_TOKEN, COOKIE_OPTIONS);
return result;
}
// 4. 새 토큰을 쿠키에 셋팅하고, 실패했던 원래 요청을 새 토큰으로 재시도합니다.
setCookie(COOKIE_KEYS.ACCESS_TOKEN, newTokenData.accessToken, COOKIE_OPTIONS);
setCookie(COOKIE_KEYS.REFRESH_TOKEN, newTokenData.refreshToken, COOKIE_OPTIONS);
return fetchServer<ResponseData>(input, {
...init,
headers: { ...init?.headers, Authorization: `Bearer ${newTokenData.accessToken}` },
});
}
return result;
};이 로직의 장점:
중복 요청 방지:
refreshPromise변수를 통해 여러 API가 동시에 401을 뱉더라도 서버에 리프레시 요청은 단 한 번만 전송됩니다.사용자 경험 단절 방지: 사용자는 토큰 만료를 전혀 인지하지 못합니다. 리다이렉트 없이 즉시 새 토큰으로 데이터를 받아오기 때문입니다.
서버 사이드 보안: 토큰 갱신 과정이 노드 서버 환경에서 이루어지므로 클라이언트 환경보다 훨씬 안전하게 인증 정보를 갱신할 수 있습니다.
결과: 사용자는 토큰이 만료되었는지조차 모른 채 중단 없는 서비스를 이용할 수 있게 되었습니다. 더 이상 무분별한 리프레시 페이지 이동은 발생하지 않습니다.
마치며: "개발자가 편해야 사용자도 편하다"
로컬 마크다운 블로그에서 시작한 이 여정은 단순히 기술 스택을 바꾸는 과정이 아니었습니다. SSR의 안정성, SSG의 속도, 그리고 ISR의 신선함을 적재적소에 배치하기 위한 치열한 고민의 결과였습니다.
특히 보안을 위해 도입한 Route Handler와 토큰 자동 재발행 로직은 개발 피로도를 낮추는 동시에 사용자에게는 "끊김 없는 경험"을 선사했습니다. 앞으로도 서비스 품질을 유지한 채 꾸준히 아키텍처를 고도화해 나갈 예정입니다.