전체 스택/프론트엔드 프로젝트를 위한 나만의 필수 패턴
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 도구는 훨씬 더 정확해집니다. 이 도구들은 패턴을 즉시 파악하고 실제로 맞는 코드를 생성해 주며, 올바른 폴더와 형식에 넣으라고 열 번씩 다시 요청할 필요가 없습니다.
고전적인 엔지니어링 이점 모두를 복잡하게 만들지 않고 제공합니다.