
Clean Architecture 실전: 프론트엔드에서의 레이어 분리
Uncle Bob의 Clean Architecture를 프론트엔드에 그대로 적용하면 과도한 복잡도가 생긴다. 실전에서 통하는 수준으로 다듬은 프론트엔드 레이어 분리 전략을 정리했다.

Uncle Bob의 Clean Architecture를 프론트엔드에 그대로 적용하면 과도한 복잡도가 생긴다. 실전에서 통하는 수준으로 다듬은 프론트엔드 레이어 분리 전략을 정리했다.
로버트 C. 마틴(Uncle Bob)이 제안한 클린 아키텍처의 핵심은 무엇일까요? 양파 껍질 같은 계층 구조와 의존성 규칙(Dependency Rule)을 통해 프레임워크와 UI로부터 독립적인 소프트웨어를 만드는 방법을 정리합니다.

서버 컴포넌트를 클라이언트 컴포넌트 안에 import 했더니, DB 연결이 끊기고 에러가 폭발했습니다. Next.js App Router의 핵심인 'Composition Pattern'을 구멍 뚫린 도넛에 비유해 설명하고, Context Provider를 올바르게 분리하는 방법을 정리해봤습니다.

습관적으로 모든 변수에 `useMemo`를 감싸고 있나요? 그게 오히려 성능을 망치고 있습니다. 메모이제이션 비용과 올바른 최적화 타이밍.

React나 Vue 프로젝트를 빌드해서 배포했는데, 홈 화면은 잘 나오지만 새로고침만 하면 'Page Not Found'가 뜨나요? CSR의 원리와 서버 설정(Nginx, Apache, S3)을 통해 이를 해결하는 완벽 가이드.

처음 Clean Architecture 책을 읽었을 때 "이거 프론트엔드에도 적용해야겠다" 싶었다. 그래서 도전해봤다. UseCase 클래스, Repository 인터페이스, Entity 계층... 정성껏 만들었다. 그런데 실제로 써보니 간단한 CRUD 화면 하나를 만드는 데 파일이 15개씩 생겼다.
Clean Architecture를 너무 문자 그대로 따랐던 거다.
Uncle Bob이 만든 클린 아키텍처는 대규모 엔터프라이즈 백엔드를 위한 설계 원칙이다. 프론트엔드는 맥락이 다르다 — UI 상태, 사용자 이벤트, 렌더링 사이클이 핵심이다. 백엔드처럼 하면 안 된다.
이 글은 클린 아키텍처의 핵심 원칙을 취하되, 프론트엔드 현실에 맞게 적용하는 방법을 다룬다.
먼저 Uncle Bob의 원래 아이디어에서 반드시 가져와야 하는 핵심만 추리자:
외부 레이어는 내부 레이어에 의존할 수 있지만,
내부 레이어는 외부 레이어를 알면 안 된다.
쉽게 말하면: 비즈니스 로직은 UI 프레임워크를, DB를, API 클라이언트를 알면 안 된다.
UI 컴포넌트 → 애플리케이션 로직 → 도메인 규칙
↑ ↑ ↑
(외부) (중간) (내부)
화살표 방향 = 의존 방향
내부는 외부를 모른다
핵심 비즈니스 로직이 React에 의존하면 안 된다. 내일 Vue로 바꿔도 비즈니스 로직은 그대로 써야 한다.
이 원칙들을 실용적인 프론트엔드 구조로 변환하면:
src/
├── domain/ # 1. 도메인 레이어 (가장 내부)
│ ├── entities/ # 비즈니스 엔티티 타입
│ ├── usecases/ # 유즈케이스 인터페이스
│ └── repositories/# 레포지토리 인터페이스
│
├── application/ # 2. 애플리케이션 레이어
│ ├── usecases/ # 유즈케이스 구현
│ └── services/ # 애플리케이션 서비스
│
├── infrastructure/ # 3. 인프라 레이어 (외부 세계)
│ ├── api/ # HTTP 클라이언트, API 연동
│ ├── storage/ # localStorage, sessionStorage
│ └── repositories/# 레포지토리 구현체
│
└── presentation/ # 4. 프레젠테이션 레이어 (가장 외부)
├── components/ # React 컴포넌트
├── hooks/ # 커스텀 훅 (애플리케이션 레이어 연결)
└── pages/ # 페이지 컴포넌트
각 레이어를 실제 코드로 살펴보자. 예제는 간단한 장바구니 기능이다.
도메인 레이어는 비즈니스의 언어로 작성한다. 여기엔 React가 없고, API 호출이 없다. 순수한 비즈니스 규칙만.
// domain/entities/product.ts
export interface Product {
id: string;
name: string;
price: number;
stock: number;
}
// domain/entities/cart.ts
export interface CartItem {
product: Product;
quantity: number;
}
export interface Cart {
items: CartItem[];
}
// 도메인 규칙 — 순수 함수들
export function calculateCartTotal(cart: Cart): number {
return cart.items.reduce(
(total, item) => total + item.product.price * item.quantity,
0
);
}
export function isProductInStock(product: Product, requestedQuantity: number): boolean {
return product.stock >= requestedQuantity;
}
export function findCartItem(cart: Cart, productId: string): CartItem | undefined {
return cart.items.find(item => item.product.id === productId);
}
// domain/repositories/cart.repository.ts
// 인터페이스만 정의 (구현은 인프라 레이어에서)
export interface CartRepository {
getCart(): Promise<Cart>;
saveCart(cart: Cart): Promise<void>;
}
// domain/repositories/product.repository.ts
export interface ProductRepository {
findById(id: string): Promise<Product | null>;
findAll(): Promise<Product[]>;
}
도메인 레이어의 특징:
애플리케이션 레이어는 유즈케이스를 구현한다. "사용자가 장바구니에 상품을 추가한다" 같은 시나리오.
// application/usecases/addToCart.ts
import { Cart, CartItem, findCartItem } from '../../domain/entities/cart';
import { isProductInStock } from '../../domain/entities/cart';
import { CartRepository } from '../../domain/repositories/cart.repository';
import { ProductRepository } from '../../domain/repositories/product.repository';
export interface AddToCartInput {
productId: string;
quantity: number;
}
export interface AddToCartResult {
success: boolean;
error?: 'PRODUCT_NOT_FOUND' | 'OUT_OF_STOCK' | 'QUANTITY_EXCEEDED';
cart?: Cart;
}
export class AddToCartUseCase {
constructor(
private cartRepo: CartRepository,
private productRepo: ProductRepository
) {}
async execute(input: AddToCartInput): Promise<AddToCartResult> {
// 1. 상품 조회
const product = await this.productRepo.findById(input.productId);
if (!product) {
return { success: false, error: 'PRODUCT_NOT_FOUND' };
}
// 2. 장바구니 조회
const cart = await this.cartRepo.getCart();
// 3. 재고 확인
const existingItem = findCartItem(cart, input.productId);
const totalRequestedQuantity = (existingItem?.quantity ?? 0) + input.quantity;
if (!isProductInStock(product, totalRequestedQuantity)) {
return { success: false, error: 'OUT_OF_STOCK' };
}
// 4. 장바구니 업데이트
const updatedCart = addItemToCart(cart, product, input.quantity);
// 5. 저장
await this.cartRepo.saveCart(updatedCart);
return { success: true, cart: updatedCart };
}
}
function addItemToCart(cart: Cart, product: Product, quantity: number): Cart {
const existingItem = findCartItem(cart, product.id);
if (existingItem) {
return {
items: cart.items.map(item =>
item.product.id === product.id
? { ...item, quantity: item.quantity + quantity }
: item
)
};
}
return {
items: [...cart.items, { product, quantity }]
};
}
애플리케이션 레이어의 특징:
// application/usecases/__tests__/addToCart.test.ts
describe('AddToCartUseCase', () => {
const mockProduct = { id: '1', name: 'Laptop', price: 1000000, stock: 5 };
it('재고가 있으면 장바구니에 추가 성공', async () => {
// Arrange
const mockCartRepo = {
getCart: vi.fn().mockResolvedValue({ items: [] }),
saveCart: vi.fn().mockResolvedValue(undefined),
};
const mockProductRepo = {
findById: vi.fn().mockResolvedValue(mockProduct),
};
const useCase = new AddToCartUseCase(mockCartRepo, mockProductRepo);
// Act
const result = await useCase.execute({ productId: '1', quantity: 2 });
// Assert
expect(result.success).toBe(true);
expect(result.cart?.items).toHaveLength(1);
expect(result.cart?.items[0].quantity).toBe(2);
expect(mockCartRepo.saveCart).toHaveBeenCalledTimes(1);
});
it('재고 부족 시 OUT_OF_STOCK 에러', async () => {
const outOfStockProduct = { ...mockProduct, stock: 1 };
const cartWithItem = {
items: [{ product: outOfStockProduct, quantity: 1 }]
};
const mockCartRepo = {
getCart: vi.fn().mockResolvedValue(cartWithItem),
saveCart: vi.fn(),
};
const mockProductRepo = {
findById: vi.fn().mockResolvedValue(outOfStockProduct),
};
const useCase = new AddToCartUseCase(mockCartRepo, mockProductRepo);
const result = await useCase.execute({ productId: '1', quantity: 1 });
expect(result.success).toBe(false);
expect(result.error).toBe('OUT_OF_STOCK');
expect(mockCartRepo.saveCart).not.toHaveBeenCalled();
});
});
인프라 레이어는 외부 세계와 통신한다. Repository 인터페이스의 실제 구현체들이 여기 있다.
// infrastructure/api/productApi.ts
import axios from 'axios';
import { Product } from '../../domain/entities/product';
export class ProductApiClient {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
async getProduct(id: string): Promise<Product | null> {
try {
const response = await axios.get<Product>(`${this.baseUrl}/products/${id}`);
return response.data;
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 404) {
return null;
}
throw error;
}
}
async getAllProducts(): Promise<Product[]> {
const response = await axios.get<Product[]>(`${this.baseUrl}/products`);
return response.data;
}
}
// infrastructure/repositories/productRepository.ts
import { Product } from '../../domain/entities/product';
import { ProductRepository } from '../../domain/repositories/product.repository';
import { ProductApiClient } from '../api/productApi';
// 도메인 인터페이스를 구현하는 실제 클래스
export class ApiProductRepository implements ProductRepository {
constructor(private apiClient: ProductApiClient) {}
async findById(id: string): Promise<Product | null> {
return this.apiClient.getProduct(id);
}
async findAll(): Promise<Product[]> {
return this.apiClient.getAllProducts();
}
}
// infrastructure/repositories/cartRepository.ts
import { Cart } from '../../domain/entities/cart';
import { CartRepository } from '../../domain/repositories/cart.repository';
// localStorage를 쓰는 장바구니 레포지토리
export class LocalStorageCartRepository implements CartRepository {
private readonly STORAGE_KEY = 'shopping_cart';
async getCart(): Promise<Cart> {
try {
const stored = localStorage.getItem(this.STORAGE_KEY);
if (!stored) return { items: [] };
return JSON.parse(stored) as Cart;
} catch {
return { items: [] };
}
}
async saveCart(cart: Cart): Promise<void> {
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(cart));
}
}
인프라 레이어의 특징:
프레젠테이션 레이어는 UI를 담당한다. React 컴포넌트와 커스텀 훅이 여기에 있다.
커스텀 훅이 애플리케이션 레이어와 UI의 연결점 역할을 한다:
// presentation/hooks/useAddToCart.ts
import { useState } from 'react';
import { AddToCartUseCase } from '../../application/usecases/addToCart';
import { Cart } from '../../domain/entities/cart';
// 의존성 주입 컨테이너에서 가져오거나 직접 생성
function createAddToCartUseCase(): AddToCartUseCase {
const { cartRepo, productRepo } = getDependencies(); // DI 컨테이너
return new AddToCartUseCase(cartRepo, productRepo);
}
interface UseAddToCartState {
isLoading: boolean;
error: string | null;
cart: Cart | null;
}
export function useAddToCart() {
const [state, setState] = useState<UseAddToCartState>({
isLoading: false,
error: null,
cart: null,
});
const addToCart = async (productId: string, quantity: number) => {
setState(prev => ({ ...prev, isLoading: true, error: null }));
try {
const useCase = createAddToCartUseCase();
const result = await useCase.execute({ productId, quantity });
if (result.success) {
setState({ isLoading: false, error: null, cart: result.cart ?? null });
} else {
const errorMessage = {
PRODUCT_NOT_FOUND: '상품을 찾을 수 없습니다',
OUT_OF_STOCK: '재고가 부족합니다',
QUANTITY_EXCEEDED: '수량이 초과되었습니다',
}[result.error!];
setState({ isLoading: false, error: errorMessage, cart: null });
}
} catch {
setState({ isLoading: false, error: '알 수 없는 오류가 발생했습니다', cart: null });
}
};
return { ...state, addToCart };
}
// presentation/components/ProductCard.tsx
import { useAddToCart } from '../hooks/useAddToCart';
import { Product } from '../../domain/entities/product';
interface ProductCardProps {
product: Product;
}
export function ProductCard({ product }: ProductCardProps) {
const { isLoading, error, addToCart } = useAddToCart();
return (
<div className="product-card">
<h3>{product.name}</h3>
<p>{product.price.toLocaleString()}원</p>
<p>재고: {product.stock}개</p>
{error && (
<p className="error-message">{error}</p>
)}
<button
onClick={() => addToCart(product.id, 1)}
disabled={isLoading || product.stock === 0}
>
{isLoading ? '추가 중...' : '장바구니 담기'}
</button>
</div>
);
}
컴포넌트는 비즈니스 로직을 전혀 모른다. "장바구니에 담기 버튼을 눌렀을 때 addToCart 호출한다" — 그게 전부다.
레이어가 나뉘면 "그래서 실제로 어떻게 연결하지?"가 문제다. 간단한 DI 컨테이너를 만들면 된다:
// infrastructure/di/container.ts
import { ProductApiClient } from '../api/productApi';
import { ApiProductRepository } from '../repositories/productRepository';
import { LocalStorageCartRepository } from '../repositories/cartRepository';
// 환경 변수에서 API URL 가져오기
const API_BASE_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3001';
// 싱글턴으로 의존성 생성
let _dependencies: ReturnType<typeof createDependencies> | null = null;
function createDependencies() {
const productApiClient = new ProductApiClient(API_BASE_URL);
return {
productRepo: new ApiProductRepository(productApiClient),
cartRepo: new LocalStorageCartRepository(),
};
}
export function getDependencies() {
if (!_dependencies) {
_dependencies = createDependencies();
}
return _dependencies;
}
// 테스트에서 의존성 교체를 위한 함수
export function setDependencies(deps: ReturnType<typeof createDependencies>) {
_dependencies = deps;
}
// 테스트에서 의존성 교체
import { setDependencies } from '../../infrastructure/di/container';
beforeEach(() => {
setDependencies({
productRepo: mockProductRepo,
cartRepo: mockCartRepo,
});
});
솔직히 말하면, 이 구조가 항상 맞는 건 아니다.
✓ 팀 규모 5명 이상
✓ 프로젝트 수명이 2년 이상 예상
✓ 복잡한 비즈니스 로직 (결제, 권한, 계산 등)
✓ 여러 플랫폼 지원 예정 (웹, 모바일 앱)
✓ 테스트가 중요한 도메인
✓ 백엔드 API가 자주 바뀌는 환경
✗ 1~2인 팀의 스타트업 MVP
✗ 단순 CRUD 게시판 수준
✗ 3개월 안에 폐기 예정인 프로젝트
✗ 비즈니스 로직이 거의 없는 랜딩 페이지
✗ 팀원 전체가 이 구조에 익숙하지 않음
작은 프로젝트에서 억지로 쓰면 파일이 15개씩 생기고, 개발 속도만 느려진다.
모든 걸 다 안 써도 핵심 원칙만 취할 수 있다:
// 최소한의 레이어 분리 (소규모 프로젝트)
src/
├── api/ # API 호출 함수들 (인프라)
├── hooks/ # 비즈니스 로직 + 상태 (애플리케이션)
└── components/ # UI만 담당 (프레젠테이션)
// api/cartApi.ts — API 로직만
export async function fetchCart(): Promise<Cart> { ... }
export async function updateCart(cart: Cart): Promise<void> { ... }
// hooks/useCart.ts — 비즈니스 로직
export function useCart() {
// 장바구니 상태 관리
// 할인 계산
// 재고 검증
// API 호출
}
// components/CartPage.tsx — UI만
export function CartPage() {
const { cart, addItem, removeItem } = useCart();
return <div>...</div>;
}
이것만 해도 "컴포넌트에 모든 게 섞인" 스파게티 코드보다는 훨씬 낫다.
Before — 모든 게 컴포넌트에 섞인 경우:
// ❌ 모든 게 섞인 컴포넌트
function ProductPage() {
const [products, setProducts] = useState([]);
const [cart, setCart] = useState({ items: [] });
useEffect(() => {
// API 호출이 컴포넌트에
fetch('/api/products')
.then(r => r.json())
.then(setProducts);
}, []);
function addToCart(product) {
// 비즈니스 로직이 컴포넌트에
const existing = cart.items.find(i => i.id === product.id);
if (existing) {
setCart({
items: cart.items.map(i =>
i.id === product.id ? { ...i, qty: i.qty + 1 } : i
)
});
} else {
setCart({ items: [...cart.items, { ...product, qty: 1 }] });
}
// localStorage 저장도 컴포넌트에
localStorage.setItem('cart', JSON.stringify(cart));
}
return (
<div>
{products.map(p => (
<button key={p.id} onClick={() => addToCart(p)}>
{p.name}
</button>
))}
</div>
);
}
After — 레이어 분리 후:
// ✓ 컴포넌트는 UI만 담당
function ProductPage() {
const { products } = useProducts();
const { addToCart, isLoading, error } = useAddToCart();
return (
<div>
{products.map(product => (
<ProductCard
key={product.id}
product={product}
onAddToCart={(qty) => addToCart(product.id, qty)}
isLoading={isLoading}
/>
))}
{error && <ErrorBanner message={error} />}
</div>
);
}
컴포넌트가 비즈니스 로직을 전혀 모른다. 테스트하기 쉽고, 교체하기 쉽고, 읽기 쉽다.
Clean Architecture를 적용하는 방법은 하나가 아니다. 중요한 건 왜 이렇게 나누는가를 이해하는 것이다:
이 문제들을 피하기 위한 방법이 레이어 분리다. 프레임워크가 뭐든, 팀 규모가 어떻든, 이 원칙은 유효하다.
처음부터 완벽한 구조를 만들 필요는 없다. 오늘 당장 할 수 있는 것: API 호출 코드를 컴포넌트 밖으로 빼내는 것. 거기서부터 시작하면 된다.