프로젝트 개요

RealWorld는 'Medium.com'과 유사한 블로그 플랫폼인 Conduit을 구현하는 프로젝트이다. 이 프로젝트는 프론트엔드 기술에 집중할 수 있도록 html과 css 그리고 백엔드 API가 제공되어 있어, Next.js의 다양한 기능을 실험하고 학습하기에 최적화된 환경이다.

초기 프로젝트는 리액트로 구성하였고 지금 프로젝트는 해당 코드들을 기반으로 nextjs로 마이그레이션을 했다.

현재는 백엔드 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. 페이지네이션 개선

nextjs 페이지네이션
react 페이지네이션

// 페이지네이션 컴포넌트
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. 최적화된 사용자 아바타 컴포넌트

nextjs 설정 페이지
react 설정 페이지

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로 올릴 수 있도록 함

기술적 도전과 해결 방법

  1. SSR과 클라이언트 상태 관리 통합: SWR의 fallback 메커니즘을 활용하여 서버에서 렌더링된 데이터를 클라이언트에서 원활하게 사용할 수 있도록 구현했습니다.
  2. 낙관적 업데이트와 에러 처리: 사용자 경험을 향상시키기 위해 낙관적 업데이트를 구현하면서도, 에러 발생 시 자동 롤백 기능을 통해 데이터 일관성을 유지했습니다. 내부 요청을 완료한후 재검증을 하지 않아 불필요한 요청을 줄였습니다, 캐시를 바로 업데이트 하여 캐시 데이터와 현재 데이터가 일치하지 않는 불안정성을 줄였습니다.
  3. 인증 흐름 최적화: JWT 토큰과 HTTP-only 쿠키를 조합하여 보안성과 사용자 경험 간의 균형을 맞추는 인증 시스템을 구축했습니다.
  4. 다양한 환경 대응: 개발 및 프로덕션 환경에 따른 최적화 설정과 보안 강화 조치를 구현했습니다.

배운 점과 개선 사항

  • Next.js 서버 컴포넌트: 서버 컴포넌트와 클라이언트 컴포넌트의 적절한 분리를 통한 성능 최적화의 중요성
  • Server Actions: 기존 API 라우트보다 간결한 폼 처리 구현 방법 학습
    • 응답, 요청 통일화를 통한 에러 관리와 처리의 편함을 학습
  • 데이터 페칭 전략: SWR을 활용한 효율적인 데이터 페칭과 캐싱 전략 개발
  • 캐시들에 대한 이해: nextjs의 캐싱, swr의 캐싱, fetch의 캐싱 이해

향후 개선 가능한 영역으로는 리프레시 토큰 구현을 통한 인증 유지 기능 강화가 있습니다.

중간에 supabase로 backend를 구성해보려다 실패하고 nextjs에서 cookie를 발행하여 사용할 수 있다는 점을 몰라 고생하고, swr의 캐싱과, nextjs의 캐싱을 잘 몰라 고생하고, nextjs에서 useActoionState의 에러 핸들링을 몰라 고생하고 갖가지 어려움이 있었지만 끝까지 해내어 원하는 기능들을 모두 완성할 수 있었다는데 큰 만족감을 느낍니다.

+ Recent posts