오늘은 프론트엔드에서도 DDD를 왜 적용해야하는지에 대해서 기록을 하고 정리를 하는 글을 작성할 것이다.
프론트엔드에서 DDD를 적용한다고 하면, 흔히 백엔드 개념 아닌가?라는 반응이 나오곤 한다. 하지만 프론트엔드도 점점 복잡한 도메인을 다루게 되면서, 백엔드처럼 체계적으로 도메인 중심 설계를 적용하는 것이 중요해졌다.
프론트엔드에서 DDD를 적용해야 하는 이유
- 도메인 로직이 복잡해진다. → 단순한 UI만 처리하는 것이 아니라, 상태 관리, 도메인 규칙, 비즈니스 로직까지 포함되기 때문이다.
- 백엔드와의 협업이 중요하다. → 백엔드 DDD와 정합성을 맞추어야 하기 때문이다.
- 프론트엔드 규모가 커진다. → 컴포넌트와 상태 관리를 효율적으로 분리해야 하기 때문이다.
- 유지보수성이 증가한다. → 도메인 단위로 설계하면 변경에 강한 구조가 될 수 있기 때문이다.
프론트엔드에서 DDD 적용 핵심 개념
1. 도메인 모델링: Entity와 Value Object
프론트엔드에서도 도메인 모델을 정의해야 한다.
- Entity: 식별자가 있는 객체를 의미한다. (예: User, Order, Product)
- Value Object: 변경 불가능하고 식별자가 없는 객체를 의미한다. (예: Money, Address, DateRange)
적용 예시
// Value Object: 가격을 다룰 때
class Money {
constructor(private readonly amount: number, private readonly currency: string) {}
add(other: Money): Money {
if (this.currency !== other.currency) {
throw new Error("Different currencies cannot be added.");
}
return new Money(this.amount + other.amount, this.currency);
}
}
// Entity: 제품 정보
class Product {
constructor(public readonly id: string, public name: string, public price: Money) {}
}
const apple = new Product("1", "Apple", new Money(1000, "KRW"));
이렇게 하면?
- 가격 계산 로직이 여기저기 흩어지지 않고 Money 안에 집중될 수 있다.
- Product의 가격을 직접 수정하는 대신 Money 객체를 활용하여 유지보수성이 높아진다.
2. 애그리게이트(Aggregate) 패턴 적용
애그리게이트는 도메인의 일관성을 유지하는 그룹을 의미한다.
- 핵심 개념은 **하나의 애그리게이트 루트(Aggregate Root)**를 통해서만 내부 데이터를 변경할 수 있다는 점이다.
- 예를 들어, Order가 OrderItem을 포함하지만, OrderItem을 직접 수정할 수 없다.
적용 예시
class OrderItem {
constructor(public readonly id: string, public readonly product: Product, public readonly quantity: number) {}
}
class Order {
private items: OrderItem[] = [];
constructor(public readonly id: string, public readonly userId: string) {}
addItem(product: Product, quantity: number) {
this.items.push(new OrderItem(`${product.id}-${quantity}`, product, quantity));
}
getTotalPrice(): Money {
return this.items.reduce((total, item) => total.add(item.product.price), new Money(0, "KRW"));
}
}
const order = new Order("order-123", "user-456");
order.addItem(apple, 2);
console.log(order.getTotalPrice()); // Money { amount: 2000, currency: 'KRW' }
이렇게 하면?
- OrderItem을 Order 내부에서만 관리할 수 있어 도메인의 일관성이 유지된다.
- 총 가격 계산 로직이 Order 내부에서만 관리될 수 있다.
3. 리포지토리 패턴 적용
프론트엔드에서도 데이터를 효율적으로 관리하기 위해 리포지토리 패턴을 적용할 수 있다.
- 서버 API를 호출하는 역할을 리포지토리로 분리하면 좋다.
- 상태 관리(store)와 직접 결합하지 않도록 설계해야 한다.
적용 예시
interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: string): Promise<Order | null>;
}
class HttpOrderRepository implements OrderRepository {
async save(order: Order): Promise<void> {
await fetch(`/api/orders`, {
method: "POST",
body: JSON.stringify(order),
});
}
async findById(id: string): Promise<Order | null> {
const res = await fetch(`/api/orders/${id}`);
if (!res.ok) return null;
const data = await res.json();
return new Order(data.id, data.userId);
}
}
const orderRepo = new HttpOrderRepository();
await orderRepo.save(order);
이렇게 하면?
- Order 도메인을 직접 API 호출과 결합하지 않아서 유연한 구조가 될 수 있다.
- API 변경이 있어도 OrderRepository만 수정하면 되기 때문에 유지보수성이 높아진다.
4. 프론트엔드 상태 관리와 DDD의 조합
프론트엔드에서는 상태 관리 라이브러리(Redux, Recoil, Zustand 등)와 DDD를 조합할 수 있다.
- Entity를 상태로 관리하면 도메인 중심의 설계를 유지할 수 있다.
- 도메인 서비스와 리포지토리를 분리하면 상태 관리와 비즈니스 로직을 분리할 수 있다.
- 비즈니스 로직을 도메인 모델 안에 캡슐화하면 유지보수가 쉬워진다.
적용 예시 (Zustand + DDD)
import { create } from "zustand";
interface OrderState {
orders: Order[];
addOrder: (order: Order) => void;
}
const useOrderStore = create<OrderState>((set) => ({
orders: [],
addOrder: (order) => set((state) => ({ orders: [...state.orders, order] })),
}));
// 사용 예시
useOrderStore.getState().addOrder(new Order("order-001", "user-123"));
이렇게 하면?
- Zustand 상태 관리와 DDD 구조를 자연스럽게 조합할 수 있다.
- Redux를 사용할 경우 Entity Adapter를 활용하여 관리할 수도 있다.
결론: 프론트엔드에서 DDD를 적용하면?
비즈니스 로직이 명확하게 분리된다.
도메인 모델이 명확해지고 재사용 가능해진다.
API 변경에 유연하게 대응할 수 있다.
프론트엔드에서도 백엔드와 일관성 있는 구조를 유지할 수 있다.
이런 방식으로 프론트엔드를 설계하면, 단순히 UI를 만드는 개발자가 아니라 도메인을 깊이 이해하는 프론트엔드 엔지니어로 인정받을 수 있을 것이다.
'실무경험(트러블슈팅)' 카테고리의 다른 글
전산팀과 협업 정리 (0) | 2025.02.13 |
---|---|
인터넷 속도가 느린 환경에서 속도개선 작업 (1) | 2025.02.13 |