전체 스택/프론트엔드 프로젝트를 위한 나만의 필수 패턴

발행: (2026년 1월 15일 오후 08:50 GMT+9)
11 min read
원문: Dev.to

Source: Dev.to

Source:

거의 모든 프로젝트에서 사용하는 패턴

프론트엔드와 풀스택 프로젝트를 꽤 많이 작업하면서(React + TypeScript와 다양한 서버/백엔드 조합) 같은 몇 가지 패턴에 계속 돌아오게 되었습니다. 이 패턴들은 구조를 잡아 주고, 정신적 부하를 줄이며, 코드베이스가 성장해도 유지보수가 쉽도록 만들어 줍니다.

혁신적인 것은 아니지만, 다양한 앱에서 실용적으로 잘 작동했던 선택들입니다. 아래는 제가 거의 매번 사용하는 현재의 패턴 집합입니다.

1. TanStack Query (React Query) – Query‑Key 팩토리

왜: 쿼리 키를 일관되고 가독성 있게 유지하며, 리팩터링에 친화적입니다.

팩토리(단일 진실 원천):

// lib/query-keys.ts
export const bookingKeys = {
  all: ['bookings'] as const,
  detail: (id: string) => [...bookingKeys.all, id] as const,
  upcoming: (filters: { patientId?: string; page: number }) => [
    ...bookingKeys.all,
    'upcoming',
    filters,
  ] as const,
};

컴포넌트에서 사용:

useQuery({
  queryKey: bookingKeys.detail(bookingId),
  queryFn: () => getBooking(bookingId),
});

중앙 집중식 무효화:

// Same file or a companion invalidations.ts
export const invalidateOnBookingChange = (queryClient: QueryClient) => {
  // 예약과 관련된 모든 것을 무효화
  queryClient.invalidateQueries({ queryKey: bookingKeys.all });

  // 혹은 더 세분화된 방식:
  // queryClient.invalidateQueries({ queryKey: bookingKeys.upcoming(...) });
};

무효화를 한 곳에 모아 두면 대시보드, 리스트 페이지, 상세 페이지 등에서 데이터 신선도를 추적하고 관리할 수 있어, 컴포넌트나 뮤테이션을 뒤져다닐 필요가 없습니다. 한 번의 변경이 전체에 일관되게 퍼집니다.

2. 전통적인 API 라우트 대신 서버‑사이드 액션 / 함수

제가 사용하는 것:

  • Next.js Server Actions
  • Astro Actions
  • TanStack Start server functions

이들은 여전히 내부적으로는 API와 비슷한 엔드포인트이며, fetch나 폼 POST를 통해 직접 호출할 수 있습니다. 따라서 다음과 같은 보안 조치를 반드시 적용해야 합니다.

  • 인증
  • 속도 제한(Rate limiting)
  • CSRF 토큰(해당되는 경우)
  • 입력 검증

큰 장점:

이점설명
보일러플레이트 감소클라이언트에서 직접 함수 호출 → 별도 엔드포인트 정의 필요 없음
자동 타입 안전성클라이언트와 서버 사이에 타입이 흐름
오류 처리 및 재검증이 쉬움오류가 호출된 위치에서 바로 드러남
로직이 함께 위치form → action → DB → response가 한 곳에 존재
React와의 통합이 우수Suspense와 트랜지션과 잘 동작

마법은 아니지만, 절차를 없애면서 보안 책임은 그대로 유지해 줍니다.

3. CASL을 이용한 세밀한 권한 관리

능력(abilities)을 한 번 정의(보통 사용자/세션당):

import { AbilityBuilder, createMongoAbility } from '@casl/ability';

export const defineAbilitiesFor = (user: User | null) => {
  const { can, cannot, build } = new AbilityBuilder(createMongoAbility);

  if (user?.role === 'admin') {
    can('manage', 'all');
  } else if (user) {
    can('read', 'Booking', { patientId: user.id });
    can('create', 'Booking');
    can('update', 'Booking', { patientId: user.id });
    cannot('delete', 'Booking'); // 명시적 거부 예시
  }

  return build();
};

서비스에서 사용:

class BookingService {
  static async updateBooking(
    user: User,
    bookingId: string,
    data: Partial<any>
  ) {
    const ability = defineAbilitiesFor(user);

    const booking = await getBookingDetails(bookingId); // from queries
    if (!ability.can('update', booking)) {
      throw new Error('Not authorized to update this booking');
    }

    // 업데이트 진행…
    await updateBooking(bookingId, data);
  }
}

또는 인라인 체크:

if (ability.can('read', subject('Booking', { ownerId: user.id }))) {
  // 민감한 데이터 표시
}

권한 로직을 선언적이고 테스트 가능하게, 비즈니스 흐름 코드와 분리해 두면 관리가 훨씬 쉬워집니다.

Source:

4. 얇은 데이터‑액세스 레이어 (queries/ 폴더)

구조: 순수한 DB 문장만 포함하는 일반 async 함수들 — 그 외는 없습니다.

// queries/bookings.ts
export async function getBookingDetails(id: string): Promise<any> {
  // Drizzle/Prisma/etc. query only
  return db.select().from(bookings).where(eq(bookings.id, id)).limit(1);
}

export async function updateBooking(
  id: string,
  data: Partial<any>
): Promise<void> {
  // Pure update, no side effects
  await db.update(bookings).set(data).where(eq(bookings.id, id));
}

이 함수들에 대한 엄격한 규칙:

  • 데이터 접근(SELECT, INSERT, UPDATE, DELETE)만 수행
  • 비즈니스 로직 금지
  • 인가 검사 금지
  • 이메일, 큐, 외부 호출, 기타 부수 효과 금지
  • 어떤 서비스에서도 재사용 가능

이 얇은 DAL은 ORM 교체를 간단하게 만들어 줍니다(queries/ 파일만 수정하면 됨). 또한 서비스가 오케스트레이션에만 집중하도록 합니다.

5. SSR/SSG – initialData 로 하이드레이트

패턴:

useQuery({
  queryKey: bookingKeys.upcoming({ page: 1 }),
  queryFn: () => actions.bookings.getUpcomingBookings({ page: 1 }),
  initialData: page === 1 ? initialUpcoming : undefined,
});

왜 중요한가:

  • SSR은 오늘날 선택이 아닙니다. 순수 CRA SPA 시대는 끝났습니다.
  • React는 2025년 초에 새로운 앱에 대해 CRA 사용을 공식적으로 폐기하고, SSR/SSG가 내장된 프레임워크(Next.js, TanStack Start, Astro 등)를 권장했습니다.
  • 서버에서 미리 가져온 데이터를 활용하면 인지된 성능이 향상되고 레이아웃 이동이 감소하며, 첫 번째 페인트 시에 의미 있는 내용을 사용자에게 제공할 수 있습니다.

6. 클래식 분리: 프레젠테이셔널 vs. 컨테이너 컴포넌트

유형특징
프레젠테이셔널 (dumb)props만 받으며, 훅/상태/데이터 fetch가 없음. 순수 UI이며, 유닛 테스트와 이해가 매우 쉬움.
컨테이너 (smart)데이터, 상태, 오케스트레이션을 담당하고, props를 하위에 전달함.

예시 – 프레젠테이셔널 컴포넌트:

// Presentational – great for snapshot/visual testing
function BookingListView({
  bookings,
  isLoading,
  page,
  totalPages,
  onPageChange,
}) {
  if (isLoading) return null;

  return (
    <>
      {/* Render bookings */}
      {bookings.map((b) => (
        <div key={b.id}>{/* booking UI */}</div>
      ))}
      {/* Pagination controls */}
    </>
  );
}

이에 대응하는 컨테이너 컴포넌트는 데이터를 fetch하고, 페이지네이션 상태를 관리한 뒤 <BookingListView …/> 를 렌더링합니다.

Source:

TL;DR

  • Query‑key factories → TanStack Query의 단일 진실 원천.
  • Server actions → 보일러플레이트 API 라우트를 대체하고 타입 안전성을 유지.
  • CASL → 선언적이며 중앙 집중식 권한 처리.
  • Thin DAL (queries/) → 순수 DB 함수, ORM 교체가 용이.
  • SSR/SSG + initialData → 로딩 플래시를 없애고 첫 화면 렌더링 경험을 개선.
  • Presentational vs. Container → 깔끔한 분리, 테스트와 유지보수가 쉬워짐.

이 패턴들은 코드베이스를 확장 가능하고 예측 가능하며 작업하기 즐거운 상태로 유지하는 데 도움이 되었습니다. 스택에 맞는 부분만 자유롭게 적용해 보세요!

// BookingListView.tsx
function BookingListView({
  bookings,
  isLoading,
  page,
  totalPages,
  onPageChange,
}) {
  return (
    <>
      {isLoading ? null : bookings.map((b) => <div key={b.id}>{/* … */}</div>)}
    </>
  );
}

// Container
function BookingList() {
  const {
    bookings,
    isLoading,
    page,
    setPage,
    totalPages,
  } = useBookings();

  return <BookingListView
    bookings={bookings}
    isLoading={isLoading}
    page={page}
    totalPages={totalPages}
    onPageChange={setPage}
  />;
}

Rule of thumb:
컴포넌트가 상태 + 데이터 패칭 + 페이지네이션 + 에러 핸들링으로 비대해지는 경우 → 해당 로직을 커스텀 훅으로 추출하세요.

Before vs. After

Before: 컴포넌트 내부에 useQuery / useState / 세션 로직이 50줄 이상 섞여 있음.

After: 컴포넌트는 렌더링만 담당하고, 무거운 로직은 훅에 존재함.

// PatientDashboard.tsx
function PatientDashboard({
  initialUpcoming,
  initialPast,
  initialNext,
}) {
  const {
    upcoming,
    past,
    nextAppointment,
    isLoadingUpcoming,
    upcomingPage,
    setUpcomingPage,
    // …
  } = useDashboard({
    initialUpcoming,
    initialPast,
    initialNext,
  });

  return (
    <>
      {/* Render dashboard UI */}
    </>
  );
}

Extraction Guideline

If you see useState, useEffect, useQuery (or similar) clustered together for one clear purpose → extract to a custom hook.
컴포넌트는 렌더링에 집중합니다.

Provider‑agnostic Services

프로바이더를 교체할 가능성이 있을 때(Zoom → Google Meet → 기타), 구현을 통합 인터페이스 뒤에 숨겨두세요.

// services/meeting.ts
class MeetingService {
  static async createMeeting(input: CreateMeetingInput) {
    // strategy selected by config / env
    return activeMeetingProvider.create(input);
  }
}

서비스를 깔끔하고 미래에도 견고하게 유지할 수 있습니다.

이러한 패턴의 장점

  • 읽기 쉽고 잘 정리된 코드
  • 이상한 논리 버그 감소 – 모든 것이 제자리에 있습니다
  • 유지보수 비용 감소 – 테스트가 쉬워지고, 놀라움이 적습니다
  • 빠른 기능 개발 – 구조와 싸우는 시간이 줄어듭니다

모든 것이 명확한 규칙을 따를 때(ARCHITECTURE.md와 같은 단일 파일에 문서화된 경우), Cursor나 Copilot 같은 AI 도구는 훨씬 더 정확해집니다. 이 도구들은 패턴을 즉시 파악하고 실제로 맞는 코드를 생성해 주며, 올바른 폴더와 형식에 넣으라고 열 번씩 다시 요청할 필요가 없습니다.

고전적인 엔지니어링 이점 모두를 복잡하게 만들지 않고 제공합니다.

Back to Blog

관련 글

더 보기 »

React 컴포넌트에서 TypeScript Generics

소개 제네릭은 React 컴포넌트에서 매일 사용하는 것은 아니지만, 특정 경우에는 유연하고 타입‑안전한 컴포넌트를 작성할 수 있게 해줍니다.

InkRows 뒤의 tech stack

InkRows https://www.inkrows.com/ 은 웹과 모바일 플랫폼에서 원활하게 작동하도록 설계된 현대적인 노트‑테이킹 앱입니다. 깔끔하고 직관적인 …