프론트엔드 Business Logic의 분리

silverj-kim·2023년 7월 16일
0
post-custom-banner

프로젝트가 점점 복잡해지고 오래될수록 코드를 유지하는데 비용을 최소화하고 싶은 니즈가 커지게 된다. 따라서 복잡하고 이해가 안되는 코드를 개선하고 싶은 마음이 든다.
CSR이 핫해지면서 프론트엔드 프로젝트도 점점 복잡해지기 시작했고 잘 설계하기 위해 여러가지 프론트엔드 아키텍처가 나오고 있다. 자바스크립트와 React로 개발을 처음 시작한 나에게는 유명한 아키텍처들도 생소한 패턴이었다. 하지만 직접 React로 2년정도 프로젝트 코드를 작성하다보니 코드가 점점 복잡해지고 비즈니스로직과 뷰로직을 분리하고 싶다는 니즈가 생겨났다.

왜 Business Logic를 분리해야 하나

View와 Business Logic은 맡은 임무, 책임이 다르다. 따라서 각자의 수정 이유가 다르다.
이 로직들이 분리되지 않고 섞여있다면 해당 로직이 어디에 있는 지 찾기 위해 두 로직이 섞여있는 컴포넌트 코드를 모두 살펴봐야 한다. 간단한 변경이어도 이 모든 걸 파악해야 한다.

View logic

우리가 만든 소프트웨어에 사용자가 접근하는 방식을 의미한다. 사용자가 보는 UI, 사용자 인터페이스

View는 어떻게 동작하나? View는 우리가 전달하고 하는 정보를 전달하고 필요하다면 사용자로부터 행동을 입력받고 상호작용한다. 우리는 그 행동을 이벤트를 통해 알 수 있다.

function Component() {
  const [showBanner, setShowBanner] = useState(false);
  
  const changeAgreementHandler = (e) => {
  	const { target: { checked }} = e;
    const lastDateOfEvent = new Date(...);
    const now = new Date();
    
    setShowBanner(now < lastDateOfEvent && checked);
  }
  
  return (
  	<div>
    	{showBanner && <Banner />)
        <input type="checkbox" onChange={changeAgreementHandler} />
    </div>
  )
}

유저로부터 Event를 전달받고 State를 통해 HTML을 업데이트 하게 된다. 위 코드에서 View logic은?
View logic

	const changeAgreementHandler = (e) => {
   		...
      setShowBanner(...);
    }

사용자로부터 전달받은 이벤트를 통해 UI를 업데이트하는 부분, 배너의 노출여부(showBanner)를 업데이트하는 showBanner의 변경 코드는 뷰로직이라고 할 수 있다.

const lastDateOfEvent = new Date(...);
const now = new Date();
...
now < lastDateOfEvent && checked

그럼 여기가 비즈니스 로직인가?

Business Logic

내가 참고한 이문기님의 글에 의하면 Business Logic은 현실 세계의 비지니스 규칙을 프로그램으로 표현한 부분 이라고 정리해주셨다. 우리 프로젝트만의 비지니스 규칙이 들어간 로직이라고 이해하면 좀 더 쉽다.
배너를 노출하고 말지는 비지니스 규칙이 아니지만 이벤트 기간이 아직 끝나지 않았을 때 배너를 노출하는 건 비지니스 규칙이니까!

두 로직이 섞여 거기서 오는 혼란을 최소화하기 위해서는 View와 Business Logic이 명확하게 구분되는 아키텍처로 코드가 작성되어야 한다.

Business Logic을 분리하는 방법들

1. Business Logic을 위한 함수를 만든다.

function canShowEventBannerIf(agreeWithTerm, lastDateOfEvent) {
  if (agreeWithTerm) {
    const now = new Date();
    
    return now < lastDateOfEvent;
  }
  return false;
}

const changeTermAgreementHandler = (event) => {
    const { target: { checked } } = event;
    const lastDateOfEvent = new Date(...);
    const canShow = canShowEventBannerIf(checked, lastDateOfEvent);

    setShowEventBanner(canShow);
  };

이전보다는 비즈니스 로직 구분이 명확해졌다. 하지만 문제점이 있다.
canShowEventBannerIf 함수만으로는 자신의 역할에 책임을 다 할 수 없기 때문에 책임을 다하려면 값에 대해 외부에 의존적이게 된다. 또는 책임을 다하기 위해 값을 갖게되면 함수 자체가 변화에 유연하지 않다. 결국 외부의 환경이 달라지면 함수는 그 요구에 맞춰 힘겹게 변화해야 한다.

2. Class 를 활용한다.

/** ... */
class EventDate {
  #lastDateOfEvent;
  #offset = 0;

  constructor(lastDateOfEvent, offset = 0) {
    this.lastDateOfEvent = lastDateOfEvent;

    // lastDateOfEvent보다 offset만큼 과거를 계산할 때 사용합니다.
    this.offset = offset;
  }

  /** ... */
  canShowEventBannerIf(agreeWithTerm) {
    if (agreeWithTerm) {
      const now = new Date();
      const lastDateOfEventPastByOffset =
        this.lastDateOfEvent - this.offset;
 
      return now < lastDateOfEventPastByOffset;
    }

    return false;
  }

  /** ... */
  get() {
    return this.lastDateOfEvent;
  }

  /**
   * @description 값을 비교할 때 사용합니다.
   * @param {EventDate} anotherEventDate
   * @returns {boolean} 
   */
  isEqual(anotherEventDate) {
    ...
  }
}

function Component() {
  const [showEventBanner, setShowEventBanner] = useState(false);
  const [eventDate, setEventDate] = useState(new EventDate(...) /* 이벤트 Date 입니다 */);
  const changeTermAgreementHandler = (event) => {
    const { target: { checked } } = event;
    const canShow = eventDate.canShowEventBannerIf(checked);

    setShowEventBanner(canShow);
  };
  ...
}

갑자기 eventDate를 state로 관리하게 된 이유는 Component의 렌더링에 개입하기 위해서다.
비즈니스 로직에 필요한 값을 전역 상태로 관리하는 게 익숙하다보니 값이 바뀌면 자연스레 렌더링이 되길 바랬고 그 때 마다 새 인스턴스를 만들어야 했다. 그 전 값을 유지하기 위해 constructor에 더 많은 파라미터가 필요해졌고 더 복잡한 클래스를 작성하게 되었다. 또, 비즈니스 로직을 수정할 때 뷰 로직까지 모두 살펴봐야 했다. (요 부분은 나는 잘 이해가 가지 않는다.) 무튼 그러다 보니 비즈니스로직만 따로 떼어서 테스트하기가 불안해지고, 뷰와 함께 테스트하려니 테스트 비용도 올라갔다.

그 원인은

지금까지 한 시도는 비즈니스로직과 뷰로직을 분리한 게 아니고, 비즈니스로직은 뷰 안에서 뷰의 상태를 관리하도록 분리했기 때문이다.

우리가 원하는 건 View와 API Server 처럼 동작하는 게 아닐까?
API 서버에 요청하고 응답하는 구조를 봤을 때, 그 곳엔 View의 상태가 존재하지 않는다. View에선 응답으로 받은 값을 어떻게 활용할지만 결정한다. API 서버 입장에선 View가 어떤 로직으로 움직이는지 신경쓰지 않는다. 따라서 비즈니스 로직이 이렇게 동작하려면 우선 View의 상태에서 분리할 필요가 있다.

3. useRef의 활용

  const eventDate = useRef(new EventDate(...) /* 이벤트 Date 입니다 */).current;

useRef를 활용해 비즈니스 로직이 더이상 상태에 관여하지 않게 작성한다.
비즈니스로직에 요청하는 건 View고 비즈니스 로직의 응답을 활용하는 권한도 View에 있다. 비즈니스 로직은 자신의 책임만 다할 뿐 나머지는 View가 담당한다. 다만 익숙하지않은 패턴임은 분명하다.

분리했을 때의 효과: 관심사의 분리

제시한 방법은 방법일 뿐 핵심은 두 로직을 분리하자. 이다. View와 Business logic을 분리하면 각 관심사를 분리할 수 있다.
관심사를 분리하면 책임이 명확해지고 그에 따라 유지보수도 쉬워진다. 또 테스트 작성도 쉬워진다.
View와 Business logic이 결합되어 있다면 간단한 조건 수정을 할 때 View에 잘 반영되는지까지 확인해야하기 때문이다.

뷰 로직과 비즈니스 로직을 분리하고자 하는 생각은 최근에 나온 건 아니다. 이미 한번은 들어봤을 프론트엔드 아키텍처도 존재할 거다. 더 좋은 설계를 하기위한 고민들은 항상 있어왔다.

프론트엔드 아키텍처

MVC

Model - View - Controller

Model: Data model, DB에서 받아온 데이터 혹은 API 등으로 받아온 데이터 Ajax로 받아온 데이터
View : HTML과 CSS로 렌더링되는 화면, UI
Controller : Model의 데이터를 받아 View가 화면에 그리고 다시 사용자의 동작을 받아 Model을 변경하게 해주는 것, Model과 View 사이의 중간 역할 서버의 데이터를 받아 화면을 바꾸고 이벤트를 처리하여 서버에 데이터를 전달하는 Javascript

MVVM 데이터 바인딩 + 템플릿

Model - View - View Model

Model : Data Model, 도메인 특화 데이터를 처리
View : UI
View Model : View를 그리는 Model만 다룸, state를 변경하면 즉시 View에 반영

템플릿과 같은 선언적인 방식으로 개발을 할 수 있게 됨

Container - Presenter

Presenter : 데이터를 받아서 보여주기만 하는 Component
Container : 데이터 조작을 주로 다루는 Component

로직을 한 군데에 모두고 View는 재사용하는 형태의 아키텍처

FLUX : Redux

컴포넌트의 독립과 재사용성을 위해 컴포넌트를 분리했지만 오히려 상위와 하위 컴포넌트 간의 결합도가 올라가는 Props drilling 문제를 해결하기 위한 방안
비즈니스로직을 굳이 컴포넌트 계층구조로 만들 필요가 없다는 것을 깨닫고 데이터를 조작하는 로직은 별개로 두고 Action이라는 매개체를 통해서 데이터를 변경, 이를 컴포넌트에 직접 연결하는 방법이 나오게 됨
View와 비즈니스 로직을 분리하고 단방향 데이터 구조를 가지는 FLUX 패턴

hooks & Context

Redux의 경우 너무나 많은 보일러플레이트가 필요하고 복잡하며 러닝커브가 너무 높다는 등의 문제점이 제기되었다. 간단한 프로젝트의 경우 오버엔지니어링되는 경향이 있었다.
hooks를 통해 외부 비즈니스 로직을 쉽게 연결할 수 있고 Context를 통해 Props Driliing 없이도 상위 props를 하위로 전달할 수 있게 되었따.

더 나아가 전역 상태 관리 라이브러리도 등장 Recoil, Zustands, jotai
Atom이라고 불리는 전역객체를 이용해 데이터를 기록하고 변경감지를 통해 View로 전달하는 방식

서버 API를 통한 Global state management: React Query

브라우저의 경우 데이터를 로컬에 잘 보관하지 않기 때문에 프론트엔드 대부분의 전역상태관리가 필요한 이유는 서버 API이다. 비즈니스 로직이 대부분 백엔드에 보관되고 있기 때문이다.

백엔드와 직접 연동하면서 필요한 로딩, 캐싱, 무효화, 업데이트 등 기존 상태관리에 복잡하게 진행되어야했던 로직을 단순하게 만들어주는 방식도 생겨났다.

MVI

위 그림을 통해 비즈니스 로직을 두가지 레이러로 나눌 수 있다.

1) 사용자가 View를 통해서 전달한 UI Event로 어떠한 데이터 변화를 하게 할 지 전달하는 역할 => intent(의도)
2) 전달받은 요청에 따라서 적절히 데이터를 변화하는 역할 =>model(데이터)

전체적으로 데이터의 방향성이 단방향 이다. 또 데이터가 전역적으로 구성된다.
View 는 Model에 의존적이지만 Business logic은 View와 의존성이 없기 때문에 화면변화에 유연하다. 따라서 유지보수도, 테스트 작성도 쉽다.
뷰는 비즈니스 로직에 의존적이지만 뷰끼리는 느슨하게 결합되어 UI 요구사항에 대응할 수 있다.

뷰에서는 의도만 전달하고 의도에 맞는 데이터 변환은 모델에서만 처리할 수 있도록 데이터를 ㅂ녀화하는 코드를 Model 모듈에 모으게 되면 응집도가 높아지고, UI와 비즈니스 로직 간의 결합도를 낮출 수 있다.

참고

https://medium.com/@shinbaek89/%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-business-logic%EC%9D%98-%EB%B6%84%EB%A6%AC-adc10ae881ab

https://medium.com/@shinbaek89/%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%EB%B9%84%EC%A7%80%EB%8B%88%EC%8A%A4-%EB%A1%9C%EC%A7%81%EA%B3%BC-%EC%82%AC%EB%A1%80-f09774f53a3b

https://velog.io/@teo/%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C%EC%97%90%EC%84%9C-MV-%EC%95%84%ED%82%A4%ED%85%8D%EC%B3%90%EB%9E%80-%EB%AC%B4%EC%97%87%EC%9D%B8%EA%B0%80%EC%9A%94

https://velog.io/@teo/MVI-Architecture?utm_source=oneoneone

profile
Front-end developer
post-custom-banner

0개의 댓글