처음 Clean Architecture 책을 읽었을 때 "이거 프론트엔드에도 적용해야겠다" 싶었다. 그래서 도전해봤다. UseCase 클래스, Repository 인터페이스, Entity 계층... 정성껏 만들었다. 그런데 실제로 써보니 간단한 CRUD 화면 하나를 만드는 데 파일이 15개씩 생겼다.
Clean Architecture를 너무 문자 그대로 따랐던 거다.
Uncle Bob이 만든 클린 아키텍처는 대규모 엔터프라이즈 백엔드를 위한 설계 원칙이다. 프론트엔드는 맥락이 다르다 — UI 상태, 사용자 이벤트, 렌더링 사이클이 핵심이다. 백엔드처럼 하면 안 된다.
이 글은 클린 아키텍처의 핵심 원칙을 취하되, 프론트엔드 현실에 맞게 적용하는 방법을 다룬다.
Clean Architecture의 핵심 원칙
먼저 Uncle Bob의 원래 아이디어에서 반드시 가져와야 하는 핵심만 추리자:
1. 의존성 규칙 (Dependency Rule)
외부 레이어는 내부 레이어에 의존할 수 있지만,
내부 레이어는 외부 레이어를 알면 안 된다.
쉽게 말하면: 비즈니스 로직은 UI 프레임워크를, DB를, API 클라이언트를 알면 안 된다.
UI 컴포넌트 → 애플리케이션 로직 → 도메인 규칙
↑ ↑ ↑
(외부) (중간) (내부)
화살표 방향 = 의존 방향
내부는 외부를 모른다
2. 관심사 분리 (Separation of Concerns)
- 도메인: 비즈니스 규칙, 순수 데이터 타입
- 애플리케이션: 유즈케이스, 오케스트레이션
- 인프라: 외부 세계 (API, localStorage, 라우터)
- 프레젠테이션: UI, 컴포넌트, 스타일
3. 프레임워크 독립 (Framework Independence)
핵심 비즈니스 로직이 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/ # 페이지 컴포넌트
각 레이어를 실제 코드로 살펴보자. 예제는 간단한 장바구니 기능이다.
레이어 1: 도메인
도메인 레이어는 비즈니스의 언어로 작성한다. 여기엔 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[]>;
}
도메인 레이어의 특징:
- 외부 의존성 없음 (React, axios, 브라우저 API 없음)
- 순수 TypeScript
- 테스트가 극도로 쉬움 (의존성 없으니 mock 불필요)
레이어 2: 애플리케이션
애플리케이션 레이어는 유즈케이스를 구현한다. "사용자가 장바구니에 상품을 추가한다" 같은 시나리오.
// 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 }]
};
}
애플리케이션 레이어의 특징:
- 도메인 레이어에만 의존 (인프라/UI 모름)
- Repository는 인터페이스로 사용 (구현체 모름)
- 유즈케이스 단위로 테스트 가능
// 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();
});
});
레이어 3: 인프라
인프라 레이어는 외부 세계와 통신한다. 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));
}
}
인프라 레이어의 특징:
- 도메인 인터페이스를 구현
- 외부 기술(axios, localStorage, 브라우저 API)에 의존
- 교체 가능 (나중에 IndexedDB로 바꾸고 싶으면 여기만 바꾸면 됨)
레이어 4: 프레젠테이션
프레젠테이션 레이어는 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) 설정
레이어가 나뉘면 "그래서 실제로 어떻게 연결하지?"가 문제다. 간단한 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,
});
});
언제 Clean Architecture가 필요하고, 언제 오버킬인가
솔직히 말하면, 이 구조가 항상 맞는 건 아니다.
필요한 경우
✓ 팀 규모 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 vs After
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를 적용하는 방법은 하나가 아니다. 중요한 건 왜 이렇게 나누는가를 이해하는 것이다:
- 비즈니스 로직이 UI에 의존하면 → 비즈니스 로직이 UI와 함께 죽는다
- 인프라가 도메인 안에 들어오면 → DB 바꿀 때 도메인도 다 바꿔야 한다
- 컴포넌트에 모든 게 섞이면 → 테스트 불가능, 재사용 불가능
이 문제들을 피하기 위한 방법이 레이어 분리다. 프레임워크가 뭐든, 팀 규모가 어떻든, 이 원칙은 유효하다.
처음부터 완벽한 구조를 만들 필요는 없다. 오늘 당장 할 수 있는 것: API 호출 코드를 컴포넌트 밖으로 빼내는 것. 거기서부터 시작하면 된다.