프로젝트 개요
RealWorld는 'Medium.com'과 유사한 블로그 플랫폼인 Conduit을 구현하는 프로젝트이다. 이 프로젝트는 프론트엔드 기술에 집중할 수 있도록 html과 css 그리고 백엔드 API가 제공되어 있어, Next.js의 다양한 기능을 실험하고 학습하기에 최적화된 환경이다.
- 깃허브 주소: https://github.com/LeeHyoGeun96/nextjs-typescript-realworld
- 배포 주소: https://nextjs-typescript-realworld.vercel.app/
초기 프로젝트는 리액트로 구성하였고 지금 프로젝트는 해당 코드들을 기반으로 nextjs로 마이그레이션을 했다.
- 리액트 깃허브 주소: https://github.com/LeeHyoGeun96/react-typescript-realworld
- 리액트 배포 주소: https://realworld3397.netlify.app/
현재는 백엔드 api가 운영을 중단된 상태이다.
https://github.com/gothinkster/node-express-prisma-v1-official-app
에 접속해서 fork 한후 사용을 하면 된다
이 레파지토리는 heroku에 최적화 되어있는데
heroku는 지금 무료 서비스를 제공하지 않으므로
railway같은 서비스를 이용하면 좋다.
heroku 모르고 썼다가 4만원 정도 날렸다.
또 주의해야할 점은 내가 뭔가 실수해서 그럴수도 있는데
배포가 원할히 진행이 안되어 ai를 동원하여 많이 고쳐서 겨우 배포했다.
https://github.com/LeeHyoGeun96/node-express-prisma-v1-official-app_old
railway 용도로 수정한 깃허브이다.
기술 스택
- 프론트엔드: Next.js 15, TypeScript
- 상태 관리: SWR, Zustand
- 인증: JWT 토큰 (백엔드-Next.js 서버), HTTP-only/secure 쿠키 (서버-클라이언트)
- 스타일링: Tailwind CSS, react-icons, react-responsive
- 폼 관리: Server Actions
- 스토리지: Supabase
- 이미지 처리: react-easy-crop
- 유효성 검증: Zod
- UX 향상: bprogress/next, sonner
핵심 기술 구현 전략
1. SWR을 활용한 효율적인 상태 관리
SWR은 Next.js와의 높은 호환성으로 선택했으며, 다음과 같은 이점을 제공했습니다:
// SWR 설정 컴포넌트
export default function SWRProvider({ children, fallback }: SWRProviderProps) {
return (
<SWRConfig
value={{
fetcher: async (url) => {
const response = await fetch(url, {
method: "GET",
credentials: "include",
cache: "no-store",
});
return response.json();
},
fallback,
revalidateOnFocus: false,
provider: () => new Map(),
}}
>
{children}
</SWRConfig>
);
}
- 클라이언트 컴포넌트에서의 SSR 효과: SWR Config와 fallback 데이터를 통해 서버에서 렌더링된 데이터를 클라이언트에서 원활하게 사용
- 캐싱 및 요청 최적화: 중복 요청 방지와 캐시 관리를 통한 성능 최적화
await mutateProfile(
async () => {
// 실제 요청 코드
},
{
optimisticData: () => {
if (following) {
return {
...profileResponse,
profile: {
...profileResponse.profile,
following: false,
},
};
} else {
return {
...profileResponse,
profile: {
...profileResponse.profile,
following: true,
},
};
}
},
rollbackOnError: true,
revalidate: false,
populateCache: true,
}
);
};
- 낙관적 업데이트: 사용자 경험 향상을 위한 즉각적인 UI 반응과 실패 시 롤백 기능
- roobackOnError: 오류 발생 시 자동 롤백으로 일관된 상태 유지
- revalidate: false: 불필요한 재검증 방지
- populateCache: 값을 캐시에 저장해 새로고침시에 이전 데이터가 잠깐 보이는 모습 방지
2. 토큰 기반 인증 구현
JWT 토큰과 HTTP-only 쿠키를 활용하여 보안성이 높은 인증 시스템을 구현했습니다:
"use server";
import { jwtDecode } from "jwt-decode";
import { cookies } from "next/headers";
interface JwtPayload {
id: string;
email: string;
username: string;
iat: number;
exp: number;
}
const DEFAULT_MAX_AGE = 60 * 60 * 24 * 60; // 60일
/**
* JWT 토큰을 저장하고 쿠키를 설정하는 함수
* @param token JWT 토큰
*/
export async function setAuthToken(token: string): Promise<void> {
try {
// 토큰 디코딩
const decoded = jwtDecode<JwtPayload>(token);
// 만료 시간 계산 (초 단위)
const now = Math.floor(Date.now() / 1000);
const expiryInSeconds = decoded.exp - now;
// 쿠키 설정
const cookieStore = await cookies();
cookieStore.set("token", token, {
maxAge: expiryInSeconds > 0 ? expiryInSeconds : DEFAULT_MAX_AGE,
httpOnly: process.env.NODE_ENV === "production",
secure: process.env.NODE_ENV === "production",
path: "/",
sameSite: "strict",
});
} catch (error) {
console.error("토큰 설정 실패:", error);
// 토큰 디코딩에 실패해도 기본 설정으로 쿠키 저장
const cookieStore = await cookies();
cookieStore.set("token", token, {
httpOnly: process.env.NODE_ENV === "production",
secure: process.env.NODE_ENV === "production",
path: "/",
sameSite: "strict",
maxAge: DEFAULT_MAX_AGE,
});
}
}
/**
* 인증 토큰 제거 함수
*/
export async function removeAuthToken(): Promise<void> {
(await cookies()).delete("token");
}
- 서버-클라이언트 인증: 백엔드에서 발급받은 JWT 토큰을 Next.js 서버에서 안전한 쿠키로 변환
- 토큰 만료 관리: JWT 디코딩을 통한 동적 쿠키 만료 시간 설정
- 보안 강화: 프로덕션 환경에서 httpOnly, secure 옵션 활성화
3. 사용자 중심의 에러 핸들링 전략
다양한 에러 상황에 대응하는 체계적인 에러 처리 시스템을 구축했습니다:
// utils/errorHandlers.ts
import { ApiError } from "@/types/error";
import { toast } from "sonner";
const ERROR_NAME = "ApiResponseError";
export class ApiResponseError extends Error {
constructor(message: string) {
super(message);
this.name = ERROR_NAME;
}
}
export const isApiResponseError = (
error: unknown
): error is ApiResponseError => {
return error instanceof ApiResponseError && error.name === ERROR_NAME;
};
export const handleApiError = <T extends { error?: ApiError }>(
response: T,
defaultMessage: string,
setError?: (message: string) => void
) => {
if (response.error) {
const errorMessage = response.error?.message || defaultMessage;
if (setError) {
setError(errorMessage);
} else {
toast.error(errorMessage);
}
const apiError = new ApiResponseError(errorMessage);
throw apiError;
}
return response;
};
export const handleUnexpectedError = (
error: unknown,
operation: string,
setUnExpectedError?: (message: string) => void
) => {
if (isApiResponseError(error)) {
throw error;
}
const message =
error instanceof Error
? error.message
: `예상치 못한 에러로 ${operation}에 실패했습니다.`;
if (setUnExpectedError) {
setUnExpectedError(message);
}
throw error;
};
async () => {
try {
const response = await serverAction();
handleApiError(response, "언팔로우 처리에 실패했습니다.");
} catch (error) {
handleUnexpectedError(error, "언팔로우 처리", setUnExpectedError);
}
} else {
try {
const response = await serverAction();
handleApiError(response, "팔로우 처리에 실패했습니다.");
} catch (error) {
handleUnexpectedError(error, "팔로우 처리", setUnExpectedError);
}
}
}
- 직관적인 에러 메시지: 사용자가 이해하기 쉬운 한글 에러 메시지 제공
- 상황별 에러 표시 방식:
- 중요 폼 검증 에러: 인라인 ErrorDisplay 컴포넌트로 표시
- 일반 작업 에러: 에러가 발생할 시 기존 상태로 돌아가지만 명확한 표시를 위하여 Toast 알림으로 간결하게 표시 성공 했을 때는 바로 결과 보이므로 표시 하지 않음
- 예상치 못한 에러 처리: error.tsx 페이지로 리디렉션하여 적절한 안내 제공
interface ErrorMessages {
[key: string]: {
[key: string]: string;
};
}
const errorMessages: ErrorMessages = {
"email or password": {
"is invalid": "이메일 또는 비밀번호가 올바르지 않습니다",
},
user: {
"can't be blank": "필수 입력 항목입니다",
"has already been taken": "이미 사용중입니다",
"is invalid": "올바르지 않은 형식입니다",
"or password is invalid": "이메일 또는 비밀번호가 올바르지 않습니다",
},
... 기타 다른 메시지들
};
/**
* 에러 메시지를 한글로 변환하여 문자열로 반환
* @param errors - 에러 객체
* @returns 번역된 에러 메시지 (여러 개인 경우 줄바꿈으로 구분)
*/
export const translateError = (
errors: Record<string, string[]>
): string | undefined => {
const messages: string[] = [];
for (const [field, fieldErrors] of Object.entries(errors)) {
const translatedErrors = fieldErrors
.map((error) => {
return errorMessages[field]?.[error];
})
.filter((msg): msg is string => msg !== undefined);
messages.push(...translatedErrors);
}
// 번역된 메시지가 없으면 undefined 반환
return messages.length > 0 ? [...new Set(messages)].join("\n") : undefined;
};
- 사용자 경험 향상: 기술적 에러 메시지가 아닌 이해하기 쉬운 안내 제공
- 다국어 지원 기반식: 향후 다국어 지원으로 확장 가능한 구조
- 체계적인 에러 관리: 에러 메시지의 중앙 집중식 관리로 일관성 유지
4. 모달 기반 이미지 크롭 기능
컴포지트 패턴을 활용한 모달 시스템과 이미지 크롭 기능을 구현했습니다:
// 컴포지트 패턴을 사용한 모달 구현
export default function Modal({ children, onClose, className }: ModalProps) {
// ... 구현 코드
return createPortal(modalContent, document.getElementById("modal-root")!);
}
// 서브 컴포넌트 연결
Modal.Header = ModalHeader;
Modal.Content = ModalContent;
Modal.Footer = ModalFooter;
- 컴포지트 패턴: 재사용 가능한 모달 컴포넌트 구성
- Context API 활용: 이미지 데이터 상태 관리를 위한 지역적 컨텍스트 구현
- 포탈 활용: DOM 구조와 독립적인 모달 렌더링으로 스타일 충돌 방지
- react-easy-crop 활용: 해당 라이브러리를 사용한 빠른 구현
5. Server Actions를 활용한 get을 제외한 요청 처리
Server Actions를 활용하여 서버 측 폼 검증 및 처리 및 get을 제외한 요청을 구현했습니다:
export interface ActionState<T> {
success: boolean;
error?: ApiError | null;
value?: T;
}
export type SignupState = ActionState<{
inputData: {
email: string;
username: string;
password: string;
passwordConfirm: string;
};
}>;
... 기타 다른 상태들
export async function updateProfile(
formData: FormData
): Promise<UpdateProfileState> {
try {
// 쿠키에서 토큰 검증
const cookieStore = await cookies();
const token = cookieStore.get("token")?.value;
if (!token) {
throw new Error("인증되지 않은 접근입니다.");
}
// 입력 데이터 추출 및 검증
const inputData = {
username: formData.get("username")?.toString() || "",
bio: formData.get("bio")?.toString() || "",
};
if (!inputData.username) {
return {
success: false,
error: new Error("사용자 이름을 입력해주세요."),
value: { inputData },
};
}
// API 요청 처리
// ... 구현 코드
return {
success: true,
value: { inputData },
};
} catch (error) {
// 에러 처리
}
}
- 서버 측 검증: 보안 강화를 위한 서버 측 입력값 검증
- 일관된 응답 형식: 성공/실패 여부와 관련 데이터를 포함한 표준화된 응답 구조
- 사용자 친화적 피드백: 검증 실패 시 폼 데이터 유지 및 명확한 오류 메시지 제공
일관된 응답 형식을 한 덕분에 에러처리나 사용자 데이터 처리가 원활해져 개발에 속도를 박찰 수 있었습니다.
6. Next.js API 라우트와 SWR을 활용한 데이터 페칭 최적화 경험
Next.js API 라우트와 SWR을 결합한 효율적인 데이터 통신 아키텍처:
const apiKeys = {
profileKey,
articlesKey,
} as const;
// 병렬로 데이터 가져옴
const [profileResponse, articlesResponse] = await Promise.all([
fetch(`${process.env.NEXT_PUBLIC_API_URL}${apiKeys.profileKey}`, {
headers: header,
}).then((res) => res.json() as Promise<ProfileResponse>),
fetch(`${process.env.NEXT_PUBLIC_API_URL}${apiKeys.articlesKey}`, {
headers: header,
}).then((res) => res.json() as Promise<ArticlesResponse>),
]);
const profileData = profileResponse.profile;
const articlesCount = articlesResponse.articlesCount;
const fallback = {
[apiKeys.profileKey]: profileResponse,
[apiKeys.articlesKey]: articlesResponse,
};
// fetcher 구현
<SWRConfig
value={{
fetcher: async (url) => {
const response = await fetch(url, {
method: "GET",
credentials: "include",
cache: "no-store",
});
return response.json();
},
fallback,
revalidateOnFocus: false,
provider: () => new Map(),
}}
>
{children}
</SWRConfig>
// API 라우트 구현
export async function GET(
request: NextRequest,
{ params }: { params: Params }
) {
const { slug } = await params;
const token = request.cookies.get("token")?.value;
// ... 구현 로직
}
// 클라이언트 측 사용
const { data: articleResponse, mutate: mutateArticle } =
useSWR<ArticleResponse>(apiKeys.article);
이 아키텍처는:
- 보안 강화: 민감한 API 키와 토큰을 클라이언트에 노출하지 않음
- 에러 처리 중앙화: 서버 측에서 초기 에러 처리 및 포맷팅
- 캐싱 최적화: 서버와 클라이언트 양쪽에서의 캐싱 전략 구현
- 캐시 키와 api url 통일화와 fetcher 사용: 두 값의 통일화로 요청 로직 간소화3. URL 쿼리 파라미터 관리 시스템
페이지네이션, 필터링 등의 쿼리 파라미터 처리를 위한 체계적인 접근 방식:
import { SearchParams } from "@/types/global";
import { initializeParams } from "./params";
interface ParseQueryParamsProps {
searchParams: SearchParams;
addParams?: Record<string, string | undefined>;
limit?: number;
}
export const parseQueryParams = async ({
searchParams,
addParams,
limit,
}: ParseQueryParamsProps) => {
const params = await initializeParams(searchParams, {
limit: limit?.toString(),
});
const page = Number(params.page) || 1;
const limitValue = Number(params.limit) || limit || 10;
const offset = (page - 1) * limitValue;
const tab = params.tab || "global";
const tag = params.tag || "";
const author = params.author || "";
const favorited = params.favorited || "";
const queryString = new URLSearchParams({
offset: offset.toString(),
limit: limitValue.toString(),
...(author && { author }),
...(favorited && { favorited }),
...(params.tag && { tag: params.tag }),
});
if (addParams) {
Object.entries(addParams).forEach(([key, value]) => {
if (value) {
queryString.set(key, value);
}
});
}
const apiQueryString = queryString.toString();
return {
apiQueryString,
tab,
tag,
};
};
- URL 처리 일관성: 모든 페이지에서 동일한 방식으로 쿼리 파라미터 처리
- 유연한 확장성: 추가 파라미터 지원을 위한 addParams 옵션
- 타입 안전성: TypeScript를 활용한 파라미터 타입 검증
7. 최적화된 사용자 아바타 컴포넌트
Next.js의 Image 컴포넌트를 활용하여 최적화된 아바타 시스템을 구현했습니다:
"use client";
import { CurrentUserType } from "@/types/authTypes";
import Image from "next/image";
import { useState } from "react";
export const AVATAR_SIZE = {
sm: { class: "w-6 h-6", size: 24 },
md: { class: "w-8 h-8", size: 32 },
lg: { class: "w-12 h-12", size: 48 },
xl: { class: "w-16 h-16", size: 64 },
xxl: { class: "w-20 h-20", size: 80 },
xxxl: { class: "w-24 h-24", size: 96 },
xxxxl: { class: "w-32 h-32", size: 128 },
} as const;
export type AvatarSize = keyof typeof AVATAR_SIZE;
interface AvatarProps {
user: Pick<CurrentUserType, "image" | "username"> | null | undefined;
size?: AvatarSize;
className?: string;
}
const Avatar = ({ user, size = "md", className = "" }: AvatarProps) => {
const [imgError, setImgError] = useState(false);
const handleError = () => {
setImgError(true);
};
const { username, image } = user || {};
const defaultImage = `https://ui-avatars.com/api/?name=${username}&format=png`;
const imageUrl = !image ? defaultImage : image;
return (
<div
className={`relative rounded-full overflow-hidden ${AVATAR_SIZE[size].class} ${className}`}
>
<Image
src={imageUrl}
alt={`${username || "User"}'s avatar`}
fill
sizes={`${AVATAR_SIZE[size].size}px`}
className="object-cover"
onError={handleError}
quality={75}
/>
{imgError && image && (
<Image
src={defaultImage}
alt={`${username || "User"}'s fallback avatar`}
fill
sizes={`${AVATAR_SIZE[size].size}px`}
className="object-cover"
quality={75}
/>
)}
</div>
);
};
export default Avatar;
- 이미지 최적화: Next.js의 Image 컴포넌트를 활용한 자동 최적화
- 다양한 크기 지원: 미리 정의된 크기를 통한 일관된 디자인
- 오류 대응: 이미지 로드 실패 시 UI-Avatars API를 활용한 대체 이미지 제공
- 하이드레이션 에러를 위한 처리: 서버와 클라이언트에서 렌더링 되는 것이 달라져 생기는 에러를 방지
마이그레이션을 하며 발전 시킨 것
1. 페이지네이션 개선
// 페이지네이션 컴포넌트
function Pagination({ total, limit }) {
// 1. 현재 페이지 정보 가져오기
const currentPage = getCurrentPageFromURL();
const totalPages = Math.ceil(total / limit);
// 2. 보여줄 페이지 번호 범위 계산
function calculatePageRange() {
// 최대 5개 페이지 버튼 표시
// 현재 페이지 중심으로 좌우 균형있게 배치
// 처음/끝 부분에서는 5개 연속 표시
return [페이지번호배열];
}
// 3. 페이지 이동 처리
function navigateToPage(pageNumber) {
// 유효성 검사
// URL 파라미터 업데이트하여 페이지 이동
}
// 4. 렌더링
return (
<nav>
{/* 첫 페이지 버튼 */}
<Button onClick={() => navigateToPage(1)} />
{/* 이전 페이지 버튼 */}
<Button onClick={() => navigateToPage(currentPage - 1)} />
{/* 페이지 번호 버튼들 */}
{calculatePageRange().map(pageNum => (
<Button
active={pageNum === currentPage}
onClick={() => navigateToPage(pageNum)}
/>
))}
{/* 다음 페이지 버튼 */}
<Button onClick={() => navigateToPage(currentPage + 1)} />
{/* 마지막 페이지 버튼 */}
<Button onClick={() => navigateToPage(totalPages)} />
{/* 직접 페이지 입력 */}
<Form onSubmit={pageNumber => navigateToPage(pageNumber)} />
</nav>
);
}
- 원하는 페이지로 바로 이동: 사용자가 편하게 이동할 수 있게 구성
- 현재 페이지 기준으로 좌우 2개씩 표시
2. 최적화된 사용자 아바타 컴포넌트
export default function SettingsPage() {
return (
<>
<h1 className="text-5xl text-center text-gray-800 dark:text-gray-100 mb-8">
설정
</h1>
<div className="flex gap-8 flex-col">
<section className="border-2 border-brand-primary p-8 rounded-lg shadow-lg">
<h2 className="text-2xl font-bold text-gray-800 dark:text-gray-100 mb-4">
프로필 수정
</h2>
<ChangeAvata />
<ProfileForm />
</section>
<section className="border-2 border-brand-primary p-8 rounded-lg shadow-lg">
<h2 className="text-2xl font-bold text-gray-800 dark:text-gray-100 mb-4">
보안 설정
</h2>
<SecurityForm />
</section>
</div>
</>
);
}
- 백엔드 코드 수정: password, avata, userInfo별로 요청을 나눔
- 아바타 개선: url 말고 image로 올릴 수 있도록 함
기술적 도전과 해결 방법
- SSR과 클라이언트 상태 관리 통합: SWR의 fallback 메커니즘을 활용하여 서버에서 렌더링된 데이터를 클라이언트에서 원활하게 사용할 수 있도록 구현했습니다.
- 낙관적 업데이트와 에러 처리: 사용자 경험을 향상시키기 위해 낙관적 업데이트를 구현하면서도, 에러 발생 시 자동 롤백 기능을 통해 데이터 일관성을 유지했습니다. 내부 요청을 완료한후 재검증을 하지 않아 불필요한 요청을 줄였습니다, 캐시를 바로 업데이트 하여 캐시 데이터와 현재 데이터가 일치하지 않는 불안정성을 줄였습니다.
- 인증 흐름 최적화: JWT 토큰과 HTTP-only 쿠키를 조합하여 보안성과 사용자 경험 간의 균형을 맞추는 인증 시스템을 구축했습니다.
- 다양한 환경 대응: 개발 및 프로덕션 환경에 따른 최적화 설정과 보안 강화 조치를 구현했습니다.
배운 점과 개선 사항
- Next.js 서버 컴포넌트: 서버 컴포넌트와 클라이언트 컴포넌트의 적절한 분리를 통한 성능 최적화의 중요성
- Server Actions: 기존 API 라우트보다 간결한 폼 처리 구현 방법 학습
- 응답, 요청 통일화를 통한 에러 관리와 처리의 편함을 학습
- 데이터 페칭 전략: SWR을 활용한 효율적인 데이터 페칭과 캐싱 전략 개발
- 캐시들에 대한 이해: nextjs의 캐싱, swr의 캐싱, fetch의 캐싱 이해
향후 개선 가능한 영역으로는 리프레시 토큰 구현을 통한 인증 유지 기능 강화가 있습니다.
중간에 supabase로 backend를 구성해보려다 실패하고 nextjs에서 cookie를 발행하여 사용할 수 있다는 점을 몰라 고생하고, swr의 캐싱과, nextjs의 캐싱을 잘 몰라 고생하고, nextjs에서 useActoionState의 에러 핸들링을 몰라 고생하고 갖가지 어려움이 있었지만 끝까지 해내어 원하는 기능들을 모두 완성할 수 있었다는데 큰 만족감을 느낍니다.
'후기' 카테고리의 다른 글
blender ui(레이아웃, 창 설정) 초기화 하는 법 (5) | 2024.03.07 |
---|---|
202402 정보처리기사 필기 합격 후기 (1) | 2024.02.17 |
키움 증권 툴팁 보이지 않을 때 해결 방법 (0) | 2023.09.21 |
키움증권 8080 수익 원 말고 퍼센트로 보는 법 (0) | 2023.09.17 |
쿠팡 파트너스 (해당 상품은 법적으로 또는 판매자의 요청에 따라 파트너스 링크 생성이 제한된 상품입니다.) 해결법 (0) | 2023.09.12 |