본문 바로가기

FrontEnd

프론트엔드 클린 아키텍처 with React

반응형

원문 : https://bespoyasov.me/blog/clean-architecture-on-frontend/

 

Clean Architecture on Frontend

In this post, I describe what the clean architecture is, how to use it with JS/TS code bases and if it's even worth it.

bespoyasov.me

백엔드에서 주로 사용하는 클린 아키텍처를 프론트엔드에 어떻게 적용할 수 있을까?

레이어의 분리

먼저 의존성의 방향을 정립하기 위해 레이어를 분리한다.

 

가장 순수한 우리의 코드, 애플리케이션인 Domain은 다른 어떤 것에도 의존해선 안된다.

Domain 계층에는 도메인 객체, 도메인 서비스만 존재할 수 있다.

 

그 다음 애플리케이션 서비스 레이어는 각 사용 사례의 구현과 "포트"라는 인터페이스를 포함한다.

해당 포트는 애플리케이션 서비스가 기능을 만들기 위해 필요로 하는 어댑터의 기능을 정의한다.

 

어댑터는 유즈케이스가 필요로 하는, 포트로 정의된 규약을 준수하는 구현체다.

어댑터 레이어는 해당 사용사례를 위한 구현체를 인터페이스 기반으로 제공하기 때문에, 인터페이스만 같으면 여러 구현체를 갈아끼울 수 있다.

레이어의 분리

위 그림을 분석하면

  • 가운데로 길수록 잘 변하지 않으며, 변화가 미치는 영향은 피할 수 없다.
  • 밖으로 갈수록 자주 변하며, 변화가 미치는 범위가 작아야 한다.

우리가 주의깊게 봐야 할 부분은 UI 부분이 어댑터라는 점이다.

즉, 우리가 주로 다루는 UI 프레임워크(리액트, Vue3) 등은 우리가 구현하는 애플리케이션의 핵심 파트가 아니다.

따라서 애플리케이션 서비스, 도메인 모델링이 우선되어야 한다.

 

해당 게시물 에서는 위 구조를 준수한 프로젝트 구조를 다음과 같이 보여준다.

application 사용사례는 모든 도메인 객체를 사용할 수 있으므로 별도의 폴더로 구분된다.

domain은 도메인 서비스, 도메인 객체, 도메인 레포지토리 인터페이스를 포함하고 있다.

도메인 레포지토리 인터페이스는 도메인 객체를 참조할 수 있기 때문이다.

서로 참조되는 범위를 줄이면서, 참조하는 것들 끼리만 같은 패키지에 포함하는 것이 패키지 디자인의 핵심이다.

└── com
    └── linecorp
        └── sally
            ├── application
            │   ├── impl
            │   │   └── TotalRentalServiceImpl.java
            │   ├── InventoryService.java
            │   └── TotalRentalService.java
            ├── domain
            │   ├── item
            │   │   ├── Item.java
            │   │   └── ItemRepository.java
            │   └── member
            │       ├── MembershipService.java
            │       ├── User.java
            │       └── UserRepository.java
            └── interfaces
                ├── common
                │   ├── StoredItemDto.java
                │   └── UserDto.java
                ├── member
                │   ├── MembershipController.java
                │   ├── RegisterRequest.java
                │   └── RegisterResponse.java
                └── store
                    ├── StoreController.java
                    └── StoreRequest.java

포트와 어댑터

클린 아키텍처의 핵심은 어댑터가 포트를 준수해야 한다는 점이다.

이를 리액트에서 어떻게 구현할 지 알아보자.

프로젝트 구조는 다음과 같이 표현된다.

src/
|_domain/
  |_user.ts
  |_product.ts
  |_order.ts
  |_cart.ts
|_application/
  |_addToCart.ts
  |_authenticate.ts
  |_orderProducts.ts
  |_ports.ts
|_services/
  |_authAdapter.ts
  |_notificationAdapter.ts
  |_paymentAdapter.ts
  |_storageAdapter.ts
  |_api.ts
  |_store.tsx
|_lib/
|_ui/
  • lib에는 서드파티 라이브러리를 포함한 유틸성 모듈이 포함된다.
  • ui 부분에 리액트 프로젝트가 들어간다.
리액트 컴포넌트 자체를 재사용 가능하게 개발하는 것은 해당 글에서 말하는 클린 아키텍처와 다른 이야기이다.
이는 쿼리를 잘짜는 방법 및 데이터 모델링을 잘하는 방법을 연구하는 것과 비슷한 문제다.

애플리케이션 서비스 클래스의 구현체를 생각해보면

  • 부포트(인터페이스)를 이용해 데이터를 가져오고
  • 순수한 도메인 로직을 실행허고
  • 부포트(인터페이스)를 이용해 데이터를 저장한 뒤 결과를 리턴한다

이와 같이 순수한 로직이 가운데에 감싸져 있는 구조로 로직을 작성하며,

부수효과를 담당하는 부분이 애플리케이션 서비스 클래스이다.

imperative shell

애플리케이션 서비스 클래스는 포트를 준수하는 어댑터를 주입받아서 수행한다. 

그럼 코드가 실제로 어떻게 동작하는지 살펴보자


실제 구현

애플리케이션 레이어

도메인은 순수 함수와 순수 객체만 포함하므로 설명을 생략하도록 하겠다.

애플리케이션 레이어의 포트는 도메인 객체를 사용해 사용 사례를 정의한다.

 

src/application/ports.ts

import { Cart } from "../domain/cart";
import { Order } from "../domain/order";
import { User, UserName } from "../domain/user";

export interface UserStorageService {
  user?: User;
  updateUser(user: User): void;
}

export interface CartStorageService {
  cart: Cart;
  updateCart(cart: Cart): void;
  emptyCart(): void;
}

export interface OrdersStorageService {
  orders: Order[];
  updateOrders(orders: Order[]): void;
}

export interface AuthenticationService {
  auth(name: UserName, email: Email): Promise<User>;
}

export interface NotificationService {
  notify(message: string): void;
}

export interface PaymentService {
  tryPay(amount: PriceCents): Promise<boolean>;
}

애플리케이션 서비스의 클래스는 어댑터를 주입받아 (DI 대신 훅과 같은 형태를 사용하고 있음) 실제 기능을 구현한다.

어댑터는 리액트 훅일 수도 있고 그냥 부수효과를 유발하는 함수일 수도 있다.

 

src/application/orderProducts.ts

import { User } from "../domain/user";
import { Cart } from "../domain/cart";
import { createOrder } from "../domain/order";

// Note that the port interfaces are in the _application layer_,
// but their implementation is in the _adapter_ layer.
import { usePayment } from "../services/paymentAdapter";
import { useNotifier } from "../services/notificationAdapter";
import { useCartStorage, useOrdersStorage } from "../services/storageAdapter";

export function useOrderProducts() {
  // Usually, we access services through Dependency Injection.
  // Here we can use hooks as a crooked “DI-container”.
  const notifier = useNotifier();
  const payment = usePayment();
  const orderStorage = useOrdersStorage();
  const cartStorage = useCartStorage();

  // We can also get `user` and `cart` right here through the corresponding hooks
  // and not pass them as arguments to a function.

  // Ideally, we would pass a command as an argument,
  // which would encapsulate all input data.
  async function orderProducts(user: User, cart: Cart) {
    // Here we can validate the data before creating the order.

    const order = createOrder(user, cart);

    // The use case function doesn't call third-party services directly,
    // instead, it relies on the interfaces we declared earlier.
    const paid = await payment.tryPay(order.total);
    if (!paid) return notifier.notify("The payment wasn't successful 🤷");

    // And here we can save the order on the server, if necessary.

    const { orders } = orderStorage;
    orderStorage.updateOrders([...orders, order]);
    cartStorage.emptyCart();
  }

  return { orderProducts };
}

자바처럼 생성자로 의존성을 주입받지 않고, 어댑터를 제공하는 함수를 임포트해 사용한다는 점이 다르다.

패키지 간 독립성이 깨진게 아니냐라고 말할 수 있으나, (애플리케이션이 어댑터를 알게 됨)

리액트의 맥락에서 컨테이너 컴포넌트, 즉 스프링의 개별 컨테이너를 직접 개발자가 세팅해준 것이라고 생각할 수 있다.

(함수형 프로그래밍은 실제 이런 식으로 구현함. 어떻게 보면 레이어 간 독립성보다 기능 간 독립성을 강조한 구조라 생각할 수도 있음.)

또한 굳이 DI 프레임워크에 대해 공부할 필요도 없으며, 빠르게 변경하고 읽을 수 있다는 장점이 있다.

어댑터 레이어(Service)

주의할 점은 service 구현체는 Port를 준수해야 한다는 것이다.

따라서 훅의 형태로 주입받는 객체는 인터페이스를 준수해야 한다.

 

src/services/notificationAdapter.ts

import { NotificationService } from "../application/ports";

export function useNotifier(): NotificationService {
  return {
    notify: (message: string) => window.alert(message),
  };
}

리액트 훅에 관련된 어댑터의 경우 아래와 같이 useContext를 사용해 해당 provider에서 의존성을 주입해준다.

 

src/services/store.tsx

import React, { useState } from "react";
import { useContext } from "react";
import { cookies } from "./fakeData";

const StoreContext = React.createContext<any>({});
export const useStore = () => useContext(StoreContext);

export const Provider: React.FC = ({ children }) => {
  const [user, setUser] = useState();
  const [cart, setCart] = useState({ products: [] });
  const [orders, setOrders] = useState([]);

  const value = {
    user,
    cart,
    cookies,
    orders,
    updateUser: setUser,
    updateCart: setCart,
    updateOrders: setOrders,
    emptyCart: () => setCart({ products: [] }),
  };

  return (
    <StoreContext.Provider value={value}>{children}</StoreContext.Provider>
  );
};

그리고 리액트 훅과 관련된 어댑터를 훅으로 임포트해서 사용한다.

 

src/services/storageAdapter.ts

import {
  CartStorageService,
  OrdersStorageService,
  UserStorageService,
} from "../application/ports";
import { useStore } from "./store";

// It's also possible to split the whole storage into atomic stores.
// Inside corresponding hooks we can apply memoization, optimizations, selectors...
// Well, you get the idea.

export function useUserStorage(): UserStorageService {
  return useStore();
}

export function useCartStorage(): CartStorageService {
  return useStore();
}

export function useOrdersStorage(): OrdersStorageService {
  return useStore();
}

어댑터 레이어(UI)

이제 이와 같은 서비스를 UI에서 가져다 활용한다.

UI 컴포넌트는 레이어 가장 외부에 존재하는 어댑터 레이어에 속하기에, 어댑터들을 인터페이스와 무관하게 이것저것 가져다 쓸 수 있다.

import React, { useState } from "react";
import { Redirect } from "react-router";

import { UserName } from "../../domain/user";
import { useAuthenticate } from "../../application/authenticate";
import styles from "./Auth.module.css";

export function Auth() {
  const [name, setName] = useState<UserName>("");
  const [email, setEmail] = useState<Email>("");
  const [loading, setLoading] = useState(false);

  const { user, authenticate } = useAuthenticate();
  if (!!user) return <Redirect to="/" />;

  async function handleSubmit(e: React.FormEvent) {
    setLoading(true);
    e.preventDefault();

    await authenticate(name, email);
    setLoading(false);
  }

  return (
    <form className={styles.form} onSubmit={handleSubmit}>
      <label>
        <span>Name</span>
        <input
          type="text"
          name="name"
          value={name}
          onChange={(e) => setName(e.target.value)}
          autoFocus
        />
      </label>
      <label>
        <span>Email</span>
        <input
          type="email"
          name="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
        />
      </label>

      <button type="submit" disabled={loading}>
        {loading ? "Trying to login..." : "Login"}
      </button>
    </form>
  );
}

전체 코드는 아래 주소에서 볼 수 있다.

https://github.com/bespoyasov/frontend-clean-architecture

 

GitHub - bespoyasov/frontend-clean-architecture: React + TypeScript app built using the clean architecture principles in a more

React + TypeScript app built using the clean architecture principles in a more functional way · 🧼 🏛 🍪 - GitHub - bespoyasov/frontend-clean-architecture: React + TypeScript app built using the clean...

github.com

마치며

컴포넌트를 잘 짜는 것은 UI와 리액트에 대한 이해도가 필요하지만,

리액트와 별개로 움직이는 부분을 잘 짜는 방법에 대해서도 생각이 꽤 필요한 것 같다.

 

 

 

 

반응형