프론트엔드 아키텍처: 비지니스 로직과 사례

이문기
32 min readApr 30, 2023

--

Photo by Alice Dietrich on Unsplash

시작하면서

이 글은 비지니스 로직과 관련해 지난 일년이 넘는 시간 동안 작업하고 피드백을 주고 받으면서 쌓아온 경험을 정리하고 공유합니다. 이전 글과 겹치는 내용도 많지만 이후의 경험을 토대로 조금 더 정리하고 구체적으로 전달하려고 노력했습니다. 그 때에도 그렇고 지금도 그렇지만 저는 “좋은 기술을 찾고 사용해도 프론트엔드 개발을 할 때 이 복잡하고 지저분한 느낌은 왜 지워지지 않을까?”, “왜 문제를 푸는 데, 기술이라는 방법만 찾게 될까?”하는 고민의 답을 계속해서 찾는 중입니다.

우리가 사용하는 기술들은 우리가 부딪치며 관리하는 서비스만을 위한 기술이 아닙니다. 그렇기 때문에 우리 서비스의 고유한 문제는 그 문제에 맞는 답을 찾아야 합니다. “이 서비스 규칙은 다시 사용될까?”, “이 규칙과 다른 규칙 사이에 관계는 어떻게 될까?”, “컴포넌트가 자주 바뀔까? 다른 곳에서 사용 될까?” 등등. 하지만 프론트엔드와 관련된 조언은 “렌더링 퍼포먼스를 개선하는 방법”, “좋은 리액트 프로젝트 폴더 구조”, “훅을 잘 사용하는 방법”과 같은 기술을 잘 사용하는 방법이 주를 이룹니다. 어떻게 하면 잘 유지할 수 있는 프로젝트를 만들 수 있는지에 대한 글은 찾기가 어려워서 가끔 나오기라도 하면 애지중지 읽기도 합니다.

글을 시작하기 전에 또 한 가지 적어보려고 합니다. 부족한 경험과 실력에도 이렇게 글을 쓰는 이유는 프론트엔드 프로젝트를 더욱 잘 만들기 위해 맞고 틀리고를 떠나 많은 양의 글이 있어야 한다는 생각 때문입니다. 시니어 엔지니어가 아니더라도, 이제 막 프로젝트를 만들고 관리해본 엔지니어라도 블로그에 자신의 의견을 하나씩은 갖고 있는 환경이 되면 더 나은 방법을 더 빨리 찾을 수 있지 않을까요?

이 글에선 가장 먼저 습관에 대해 살펴봅니다. 저도 그렇지만, 개발을 하다보면 무의식중에 따르는 흐름이 있습니다. 그리고 이 패턴은 저 뿐만 아니라 많은 개발자에게서 발견되곤 합니다. 이 패턴을 간단하게 살펴보면서 해당 방법의 잠재적인 문제점에 대해 살펴봅니다.

그 다음 View 로직과 비지니스 로직에 대해 지난 번 글보다 조금 더 구체적으로 살펴봅니다. 저도 어떻게 하면 로직을 잘 구분 하고 설명할 수 있을지 항상 고민하다보니 이번에 더 잘 정리하고 싶은 마음이 있습니다.

그리고 가장 자주 마주치고, 단순하면서 전달력이 강한 예시와 함께 설명한 내용들을 짚어봅니다. 이 예시는 input과 에러 메세지가 있는 예시로 피드백을 주고 받으면서 가장 많이 공감을 얻었던 예시 입니다. 물론 동의하지 않을 수도 있지만 이게 제 최선의 선택이라고 생각했습니다.

그렇다면, 비지니스 로직을 분리할 필요성을 느꼈고 적용하려고 할 때, 어디서부터 어떤 기준으로, 그리고 어떻게 적용하면 좋을지 아이디어를 공유합니다.

그리고 마지막으로 이렇게 했을 때 얻는 효과, 장점에 대해 공유합니다. 실제 업무를 하면서 경험했던 내용도 다소 포함시켜 신뢰도를 높이려고 노력했습니다.

이 글에선 프론트엔드에서 사용하는 주요 기술을 리액트로 가정했습니다. 나중에 언급하겠지만 대표격으로 리액트를 사용하는 것이지 리액트만을 위한 내용은 아닙니다. 또한 피드백 그리고 개인 프로젝트 내용을 주로 담고 있고 실무 내용은 일부만을 포함하고 있습니다.

습관

우리는 문제를 습관처럼 풉니다. 즉, 휴리스틱을 사용해서 풀게되는데, 매번 새로운 방법을 고민하면서 문제를 풀 수는 없기 때문입니다. 하지만 이런 방법은 문제가 발생하거나 새로운 방법을 통해 문제를 해결해야 할 땐 도움이 되지 않습니다. 예를 들어, 내가 갖고 있는 좋은 습관이 다른 환경에선 좋지 못한 습관이 될 수 있습니다. 즉, 상황에 맞는 판단을 하기 위해선 종종 휴리스틱을 꺼야 합니다.

프론트엔드를 개발할 때에도 습관이 있습니다. 그 중 이 글의 주제와 관련되어있는 대표적인 습관은 상태 관리 입니다. 지난 글에서도 살펴봤지만 우린 큰 고민 없이 상태를 사용합니다. 물론 도움이 될 때가 대부분이지만 전혀 예상하지 못한 문제를 만들어내곤 합니다. 그 중 가장 큰 문제는 ‘유지보수 하기 어려운 코드’ 입니다. 물론 그 과정에서 렌더링 성능이나 가독성 이슈 등이 있을 수 있지만 결국 유지보수를 어렵게 하는 종착지에 도착하게 됩니다. 이 문제의 가장 근본적인 원인은 상태에 대한 이해에 있습니다.

흔히 리액트에서 다루는 상태란 View의 상태입니다. 즉, 값을 변경하면 View를 업데이트 하는 걸로 간주하여 렌더링을 하게 됩니다. 하지만 우리가 값을 사용할 때 반드시 View와 관련있는 값을 사용하지만은 않습니다. 물론 대부분 View와 관련되어 있고, 그렇기 때문에 ‘습관’적으로 개발해도 큰 문제가 없는 경우가 많고, 그렇기 때문에 ‘습관’이 됐습니다.

화면에 보여주는 r이라는 상태가 있다고 하겠습니다. 예를 들어, 제품의 상세 페이지에서 지불해야 하는 최종 가격이 r이 될 수 있습니다. 그리고 r을 계산하기 위해 사용되는 값을 c1, c2, c3라고 하겠습니다. 이 값들은 화면에 표시되지 않지만 페이지에서 사용되는 값입니다. 우린 이런 상황을 보통 아래와 같이 개발하고 있습니다.

function ProductPage(...) {
const [r, setR] = useState(...);
const [c1, setC1] = useState(...);
const [c2, setC2] = useState(...);
const [c3, setC3] = useState(...);

...

return (
<>
...
<p>${r}원</p>
</>
);
}

그리고 만약 어떤 버튼을 클릭했을 때 c2 조건을 변경하지만 r에는 반영되지 않는다고 가정해보겠습니다. 예를 들어, 추가 상품 3개 이상 구매해야 1,000원 할인일 때 c2가 추가 상품의 갯수라면 2개까지 c2에 반영이 되더라도 r은 변경하지 않습니다.

function ProductPage(...) {
const [r, setR] = useState(...);
const [c1, setC1] = useState(...);
const [c2, setC2] = useState(...);
const [c3, setC3] = useState(...);

...

return (
<>
...
<button onClick={() => {
setC2(c2 + 1);
}}>선택</button>
...
<p>${c2 >= 3 ? r - 1000 : r}원</p>
</>
);
}

이렇게 습관처럼 개발했을 때 우린 문제를 마주하게 됩니다. 가장 먼저 화면엔 아무런 변화가 없는데 렌더링을 시키면서 불필요한 렌더링이 발생합니다. 그리고 만약 이러저러 해서 아래와 같은 코드가 됐다면 다른 문제도 발생합니다.

function ProductPage(...) {
const [r, setR] = useState(...);
const [c1, setC1] = useState(...);
const [c2, setC2] = useState(...);
const [c3, setC3] = useState(...);

...

useEffect(() => {
... -> F1

if (c2 >= 3) {
setR(r - 1000);
} else {
setR(r + 1000);
}

... -> F2
}, [c1, c2, c3, r]);

return (
<>
...
<button onClick={() => {
setC2(c2 + 1);
}}>선택</button>
...
<p>${r}원</p>
</>
);
}

c2를 바꾸게 되면 렌더링 뿐만 아니라 다른 문제를 발생시킵니다. 렌더링 흐름에 F1F2가 포함되고 예측할 수 없는 부수효과를 일으킬 가능성에 노출됩니다. 결국, 렌더링 퍼포먼스와 예측 불가능한 부수효과 등이 생기고, 프로젝트의 유지보수가 점점 더 어려워집니다.

이 예시는 다소 극단적인 문제긴 하지만 프론트엔드 개발 과정을 상당부분 포함한다고 생각합니다. 그건 값을 다루는 데 있어 상태에 값을 넣고 문제를 풀어나가는 방법입니다. 즉, 페이지를 구성하는 값들 중 상태는 무엇인지 잘 구분하고 관리하는 것만으로도 상당히 개선할 수 있습니다.

function ProductPage(...) {
const [r, setR] = useState(...);
const [c1, setC1] = useState(...);
const [isC2, setIsC2] = useState(false); // boolean으로 변경
const [c3, setC3] = useState(...);

const c2Count = useRef(...);

...

return (
<>
...
<button onClick={() => {
const newCount = c2Count.current + 1;
c2Count.current = newCount;

if (newCount >= 3) {
setIsC2(true);
}
}}>선택</button>
...
<p>${isC2 ? r - 1000 : r}원</p>
</>
);
}

View 로직과 비지니스 로직이란

Photo by charlesdeluvio on Unsplash

구체적인 예시와 방법을 보기 전 다시 한 번 각 로직을 구분해보려고 합니다. 지난 글에서 비지니스 로직을 구분할 때 쓰는 방법을 공유 했습니다. 파악하기 좋은 View 로직을 먼저 살펴보고 나머지를 비지니스 로직으로 분리했습니다. 이번 글에선 협업 관점에서 각 로직, 특히 비지니스 로직을 알아보겠습니다.

그 전에 제가 자주 받는 질문을 언급하려고 합니다. 종종 비지니스 로직을 if문이나 for문 처럼 연산하는 과정의 집합이라고 생각하는 분들이 있습니다. 그리고 연산 과정을 하나의 함수로 합쳐서 분리하는 걸 비지니스 로직의 분리로 정의합니다. 하지만 그렇지 않습니다. 우리가 생각하는 것보다 View 로직과 비지니스 로직은 명확하게 구분됩니다.

먼저 비지니스 로직은 도메인 로직이라고 불리기도 합니다.¹ 여기에서 도메인이란 우리가 만드는 애플리케이션 서비스가 사용되고 적용되는 영역입니다.²

A sphere of knowledge, influence, or activity. The subject area to which the user applies a program is the domain of the software. — Eric Evans³

전 이 정의를 이렇게 활용합니다.

“우리가 만드는 서비스와 관련된 이야기를 할 때, 어떻게 보여줄지 논의하는 걸 제외하면 모두 도메인, 즉 비지니스 로직이다.”

예를 들어, ‘추가 상품을 3개 이상 구매하면 최종 결제 금액에서 1,000원을 제(할인)한다. 보여줄 땐 할인 전 가격과 할인 후 가격을 노출하고, 할인 전 가격은 작고 흐릿하게, 할인 후 가격은 크고 굵게 노출한다.’라고 논의를 진행했다고 하면, 이 문장은 아래와 같이 두 문장으로 분리할 수 있습니다.

B: 추가 상품을 3개 이상 구매하면 최종 결제 금액에서 1,000원을 제(할인)한다.
V: 할인 전 가격과 할인 후 가격을 노출하고, 할인 전 가격은 작고 흐릿하게, 할인 후 가격은 크고 굵게 노출한다.

B는 우리가 만드는 애플리케이션이 사용되는 영역입니다. 즉, 애플리케이션이 아니라 다른 애플리케이션 또는 방법을 갖고 있더라도 성립하는 사업 규칙(비지니스 로직)입니다. 그에 반해 V는 애플리케이션 그 자체 입니다. 사업 규칙에 강하게 의존하고 변경 가능성이 높습니다. 만약 사업 규칙에 할인이 없다면 할인을 노출할 이유도 없습니다. 또한 디자이너나 기획자, 개발자, 임원 등은 보여주는 방법을 바꾸는 게 사업 규칙을 바꾸는 것보다 쉽다는 사실을 누구나 알고 있기 때문에 변경의 빈도수가 아주 높을 수도 있습니다.

예시) input과 에러 메세지

로직 분리와 관련해서 가장 많은 대화와 피드백을 주고 받은 건 input과 에러 메세지 입니다. 페이지에 input이 많지 않음에도 값을 다루는 데 어려움을 겪는 경우를 많이 봤고, 그때마다 비슷한 피드백을 하고 있다는 사실을 알게 됐습니다. 그래서 이번엔 지금까지 내용을 토대로 input과 에러 메세지를 다루는 간단한 예시를 살펴보겠습니다.

비밀번호를 받는 간단한 input이 있습니다.

export default function Page() {
const [password, setPassword] = useState('');
const [isValid, setIsValid] = useState(false);
const onChangeHandler = (event) => {
setPassword(event.target.value);
setIsValid(event.target.value.length >= 8);
};

return (
<>
<h1>어떤 페이지 입니다.</h1>
...
<label htmlFor="password">비밀번호</label>
<input
id="password"
type="password"
value={password}
onChange={onChangeHandler}
/>
<p>{isValid ? '' : '비밀번호는 8자 이상 입력해야 합니다.'}</p>
...
</>
);
}

지금 큰 문제는 없어보이지만 아이디, 이름 등등 input이 늘어나면 password가 업데이트 되면 페이지를 렌더링 하면서 퍼포먼스가 떨어집니다. 그래서 아래와 같이 별도의 컴포넌트로 분리합니다.

export default function Page() {
const [password, setPassword] = useState('');
const [isValid, setIsValid] = useState(false);
const onChangeHandler = (event) => {
setPassword(event.target.value);
setIsValid(event.target.value.length >= 8);
};

return (
<>
<h1>어떤 페이지 입니다.</h1>
...
<form onSubmit={...}>
...
<InputPassword />
...
</form>
...
</>
);
}

이렇게 하면 비밀번호 컴포넌트로 분리를 해도 제출할 때 password 값을 Page에서 가져올 수 있습니다. 이렇게 하는 이유는 password가 업데이트 돼도 InputPassword 컴포넌트만 렌더링 되고 Page는 다시 렌더링 되지 않기 때문입니다. 그리고 InputPassword 컴포넌트는 아래와 같이 작성합니다.

export default function InputPassword() {
const [isValid, setIsValid] = useState(false);
const onChangeHandler = (event) => {
setIsValid(event.target.value.length >= 8);
};

return (
<>
<label htmlFor="password">비밀번호</label>
<input
id="password"
type="password"
onChange={onChangeHandler}
/>
<p>{isValid ? '' : '비밀번호는 8자 이상 입력해야 합니다.'}</p>
</>
);
}

이렇게 할 수 있는 이유는 보여주는 것과 관련된건 isValid이기 때문입니다. 이렇게 해도 동작하는 데 아무런 문제가 없습니다. 그리고 ‘비밀번호가 8자 이상이어야 한다.’는 비지니스 로직 입니다. 그렇기 때문에 아래와 같이 분리합니다.

const isValidPassword = (password) => {
if (password.length < 8) {
return false;
}

return true;
};

export default function InputPassword() {
const [isValid, setIsValid] = useState(false);
const onChangeHandler = (event) => {
setIsValid(isValidPassword(event.target.value));
};
...
}

이제 isValidPassword의 인터페이스가 바뀌지 않는 이상, 비지니스 로직와 관련된 변경 사항은 isValidPassword만 수정하면 됩니다. 예를 들어, 비밀번호의 최소 자릿수가 8자리에서 12자리로 바뀐다면 아래와 같이 isValidPassword만 수정합니다.

const isValidPassword = (password) => {
if (password.length < 12) {
return false;
}
...
};

물론 인터페이스가 바뀐다면 View도 같이 수정해야 합니다. 하지만 이건 어떤 소프트웨어든 피해갈 수 없는 과정입니다. 마치 API 명세가 바뀌면 관련된 내용이 변경되면서 소프트웨어 전체가 출렁이는 듯한 느낌을 주는 것과 같습니다.

const PasswordValidator = {
VALIDATIONS: {
NOT_VALID_EMPTY: { isValid: false, message: '비밀번호를 입력해주세요.' },
NOT_VALID_LENGTH: { isValid: false, message: '비밀번호는 12자 이상 입력해야 합니다.' },
VALID: { isValid: true }
},

validate: function(password) {
if (password.length === 0) {
return this.VALIDATIONS.NOT_VALID_EMPTY;
}
if (password.length < 12) {
return this.VALIDATIONS.NOT_VALID_LENGTH;
}

return this.VALIDATIONS.VALID;
},
};

export default function InputPassword() {
const [isValid, setIsValid] = useState(PasswordValidator.VALIDATIONS.NOT_VALID_EMPTY);
const onChangeHandler = (event) => {
setIsValid(PasswordValidator.validate(event.target.value));
};

return (
<>
<label htmlFor="password">비밀번호</label>
<input
id="password"
type="password"
onChange={onChangeHandler}
/>
<p>{isValid.message || ''}</p>
</>
);
}

그렇기 때문에 인터페이스는 API 명세를 만드는 것처럼 쉽게 바뀌지 않게 디자인 하도록 노력해야 합니다.

이렇게 input과 에러 메세지를 활용한 비지니스 로직 분리의 예시를 간단하게 살펴봤습니다. 이렇게 하면 비지니스 로직과 View 로직이 분리되어 변경의 전파력이 떨어지고 테스트를 작성하기도 수월하며 로직의 재사용도 개선됩니다. 이 부분은 나중에 더 살펴보겠습니다.

비지니스 로직의 관리

Photo by Iewek Gnos on Unsplash

이 글에선 이 주제가 가장 중요하다고 생각합니다. 위에 있는 글들은 이전 글의 정리, 그리고 약간의 경험이 추가됐다고 하면 이 주제는 지금까지 계속 고민하고 그려본 내용입니다. 비지니스 로직은 어떤 수준에서 어떻게 관리되어야 할까요?

가장 먼저 컴포넌트 수준에서 사용되는 건 불가능 하진 않지만 어려운 점이 많습니다. 예를 들어,

export const Component = () => {
const businessLogicInstance = BusinessLogic();
...
};

처럼 사용되면 아래 그림처럼 됩니다.

문제는 한 페이지에서 다루는 비지니스 로직은 컴포넌트 단위로 움직이지 않는 다는 사실입니다. 예를 들어, 추가 상품의 구매 내역이 컴포넌트 A, 최종 결제 금액을 컴포넌트 B에서 다룬다고 해보겠습니다. 개발자는 어떤 이유로 두 비지니스 로직을 컴포넌트 수준에서 관리하지만 비지니스 로직은 View를 관리하는 방법과 다른 맥락을 가지므로 문제가 발생할 수 있습니다. 만약 다른 컴포넌트에서 추가 구매와 관련된 비지니스 로직의 상태를 가져와야 한다면, 상위 컴포넌트를 통해 전달하고 전달 받는 방식이 되어야 하고, 단순히 상태를 공유한다는 사실만으로 페이지의 코드가 상당부분 바뀔 수 있습니다. 그렇기 때문에 비지니스 로직은 하위 컴포넌트의 변경에 영향을 받지 않는 페이지 수준에서 관리 되어야 합니다.

하지만, 페이지의 비지니스 로직의 규모가 작고 특정 컴포넌트 영역에만 영향을 준다면, 페이지 단위의 비지니스 로직을 구축하는 비용을 절감하기 위해 위와 같은 방법을 사용하는 것도 아주 효과적이고 효율적인 방법입니다. 마치 컴포넌트를 설계하는 것 처럼, 하위 수준에서 사용하고 재사용 가능성이 올라감에 따라 상위 수준으로 올리는 것과 같습니다.

페이지 수준에서 비지니스 로직을 다뤄야 한다면, 어떻게 다루면 좋을까요? 위 예시처럼 객체 하나로 분리해서 export 하는 것도 한 가지 방법입니다.

// .../SomeBusinessLogic.js
export const SomeBusinessLogic = {...};

하지만 비지니스 로직에서 관리하는 상태를 담아두기엔 부족합니다. 예를 들어,

export const SomeBusinessLogic = {
businessLogicState: 값,

logicFunction: function() {
this.businessLogicState 사용
},
};

와 같은 방법으로 관리하면 비지니스 로직의 상태가 언제 초기화 되고 어떻게 관리해야 하는지 매번 고민해야 하는 문제가 있을 수 있습니다. 그렇기 때문에 페이지의 세션⁴이 유지되는 동안 비지니스 로직의 상태가 유지되도록 해야 합니다. 리액트의 경우 대표적으로 Context API가 있습니다.

class BusinessLogic {
count;

constructor() {
this.count = 0;
}

increase() {
this.count = this.count + 1;
}
...
}

const BusinessLogicContext = React.createContext(new BusinessLogic());

// 페이지 컴포넌트
const Page = () => {
const logic = React.useMemo(() => new BusinessLogic(), []);

return (
<Context.Provider value={logic}>
<Counter></Counter>
</Context.Provider>
);
};

// 페이지를 구성하는 하위 컴포넌트
const Counter = () => {
const businessLogic = React.useContext(BusinessLogicContext);
const [count, setCount] = React.useState(counter.count);

return (
<div>
<button type="button" onClick={() => {
businessLogic.increase();
if (businessLogic.count % 2 === 0) {
setCount(businessLogic.count);
}
}}>
increase
</button>
<div>
<div>count in context : {businessLogic.count}</div>
<div>count in state : {count}</div>
</div>
</div>
);
};

이 코드는 Context API를 활용해 비지니스 로직의 상태 counter.count와 View의 상태 count를 분리한 예시 입니다. 실제론 이렇게 단순하진 않고 counter.count를 잘 보여주기 위한 처리를 해서 count를 만들거나 counter.increase가 복잡한 로직일 경우가 많습니다.

만약 비지니스 로직의 상태에서 View 상태로 전달하는 과정을 패턴화 하고 싶거나 비지니스와 View의 관계를 캡슐화 하고 싶다면 아래와 같이 커스텀 훅을 만들 수도 있습니다.

const useMapCount = () => {
const businessLogic = React.useContext(BusinessLogicContext);
const [count, setCount] = React.useState(counter.count);

const setCountIfEven = () => {
if (businessLogic.count % 2 === 0) {
setCount(businessLogic.count);
}
};

return {
count,
increase: businessLogic.increase,
setCountIfEven,
};
}

const Counter = () => {
const { count, increase, setCountIfEven } = useMapCount();

return (
<div>
<button type="button" onClick={() => {
increase()
setCountIfEven();
}}>
increase
</button>
<div>
<div>count in state : {count}</div>
</div>
</div>
);
};

다음으로 고려해야 하는 것은 인터페이스 입니다. 이 페이지에서 어떤 비지니스 로직을 사용하는지, 만든 커스텀 훅은 어떤 입력과 출력을 갖는지 관리하는 건 페이지, 그리고 프로젝트 코드를 안정적으로 관리하는 데 중요합니다.

class PageLogic {
constructor(
businessLogicR1,
businessLogicR2,
) {
if (!businessLogicR1.selectExtraProduct) {
throw new Error('businessLogicR1은 selectExtraProduct 메서드를 가져야 합니다.');
}
...
}

selectExtraProduct(productId) {
businessLogicR1.selectExtraProduct(productId);
}
...
}

const BusinessLogicContext = React.createContext(new PageLogic());

타입스크립트 환경이라면 인터페이스를 훨씬 수월하게 관리할 수 있습니다.

// interface를 PageLogic과 함께 여기에서 관리하는 건 생각보다 더 중요할 수 있습니다 !
interface BusinessLogicR1 {
selectExtraProduct: (productId: number) => void;
...
}

class PageLogic {
constructor(
businessLogicR1: BusinessLogicR1,
businessLogicR2: BusinessLogicR2,
) {
...
}

selectExtraProduct(productId: number) {
businessLogicR1.selectExtraProduct(productId);
}
...
}
...

그리고 Context API 환경에서 비지니스 로직과 관계가 있는 훅을 관리할 땐 아래와 같이 관리하는 것도 좋은 방법입니다.⁵

const useMapCount = () => {
const businessLogic = React.useContext(BusinessLogicContext);

if (!businessLogic) {
throw new Error('useMapCount 훅은 PageLogic Context API 환경에서 사용되어야 합니다.');
}
...
}

또는 아래와 같이 비지니스 로직 businessLogic를 주입 받는 방식도 좋습니다.

const useMapCount = (businessLogic) => {
if (!businessLogic) {
throw new Error('useMapCount 훅은 PageLogic Context API 환경에서 사용되어야 합니다.');
}
...
}

인터페이스를 활용하는 건 생각 이상의 장점을 갖습니다. 어떤 페이지가 어떤 로직들을 갖고 운영되는지 빠르게 살펴볼 수 있고, 같은 인터페이스를 가진 다른 로직으로 변경할 수도 있습니다. 조금 더 극단적인 경우이긴 하지만 페이지가 보여지고 있는 런타임에 비지니스 로직을 변경할 수도 있어 유연성이 높아집니다.

이렇게 비지니스 로직을 관리하면 예상치 못한 문제가 있는데, 바로 페이지 렌더링 입니다. Context API를 비지니스 로직을 관리하기 위해 사용하다보니 로직의 상태를 업데이트해도 페이지가 렌더링 되지 않아 이벤트가 발생한 컴포넌트를 제외하고 다른 컴포넌트를 렌더링 할 수 없는 문제가 발생하기도 합니다. 이럴 땐 조금 억지스럽긴 하지만 refresher와 같은 상태를 관리하는 것도 방법입니다.

const RefresherContext = createContext({
refreshCount: 0,
refresh: () => {},
});

export default function Page() {
const [refreshCount, setRefreshCount] = useState(0);

return (
<RefresherContext.Provider value={{
refreshCount,
refresh: () => setRefreshCount(refreshCount + 1)
}}>
...
</RefresherContext.Provider>
);
}

페이지 전체를 렌더링 하는 것이 부담스럽다면 다른 상태관리 도구를 사용해서 특정 컴포넌트 그룹만 렌더링 할 수도 있습니다. 이렇게 하는 게 조금 억지스러워보이지만 비지니스 상태의 업데이트와 View 상태의 업데이트의 결합을 피할 수 있다는 장점이 있습니다. 이 것은 복잡한 구조를 가진 페이지일 수록 더욱 힘을 발휘합니다. 비지니스 로직과 View 로직을 분리하면 비지니스 상태에 따른 렌더링 흐름을 제어할 수 있습니다. 마치 이 글의 초반부에 언급한 것 처럼 특정 조건을 충족해야 렌더링 하는 것과 같은 것입니다. 또한 비지니스 상태가 렌더링 흐름에 포함되어 있지 않기 때문에 비지니스 로직의 사용을 수정해도 렌더링 퍼포먼스나, 부수효과 등에 적은 영향, 즉 View에 제한적인 영향을 줍니다.

이런 구조가 낯설어 보이는 건 리액트 때문일 수 있습니다. 아래와 같은 이벤트 리스너는 지금까지 한 작업이 어떤 의미를 갖는지 보다 잘 보여줍니다.

const pageLogic = PageLogic(
businessLogicR1,
businessLogicR1,
);

const updateServeralView = (...) => {
// View를 업데이트 합니다.
};

$button.addEventListener('click', (event) => {
const productId = event.target.dataset.id;

// 비지니스 로직 활용
pageLogic.selectExtraProduct(productId);

// 조건 확인
if (pageLogic.isSatisfySale()) {
// View 업데이트
updateServeralView(...);
}
});

그리고 비지니스 로직과 API 요청을 함께 관리하는 방법은 이전 글인 프론트엔드 아키텍처: API 요청 관리를 참고하면 도움이 되리라 생각합니다.

추가적으로 비지니스 로직을 관리하는 데 참고하면 좋은 건, 로직의 분리는 환경을 구분하지 않는 다는 사실 입니다. 이렇게 분리를 하는 건 리액트 프로젝트를 잘 운영하는 것보다 더 근본적인 이유를 갖습니다. 어떤 환경에서든 조금 더 장수할 수 있는 프로젝트를 만드는 것, 프로젝트를 갈아엎거나 기술을 변경하는 것 이외에 문제를 해결할 수 있는 방법을 추가적으로 갖는 것이 더 큰 목적입니다. 그렇기 때문에 리액트를 다루는 환경이든 아니든, 프론트엔드가 아닌 유틸성 모듈이나 필요에 의한 다른 분야의 개발을 할 때에도 항상 고민하고 고려해야 합니다. 그리고 바로 위 예시에서 보았듯이 비지니스 로직의 분리는 리액트보다 HTML과 JS 그리고 이벤트 리스너를 직접 다루는 환경에서 더 쉽게 할 수 있습니다.

제가 비지니스 로직을 분리할 때 종종 그리는 모습은 우리 서비스만을 위한 npm 패키지를 만드는 것입니다. 누군가 내가 개발하는 환경만을 위해 npm 패키지를 만들었다고 상상해보면, 또는 내가 그런 패키지를 만들고 있다고 상상해보면 여러 고민들이 해소되는 느낌을 받을 때가 있습니다. 내가 사용하는 패키지가 특정 컴포넌트에서만 사용해야 한다면, 내가 알지 못하는 부수효과가 생겨서 도무지 사용할 수 없다면, 그 패키지는 사용할 수 없습니다. 그리고 사용했을 때 효용이 크다면 조금의 어색함은 무모한 도전이 아닌 학습과 발전으로 여겨집니다. 만약 로직을 분리하는 데 막막함이 있다면, 환경이 생각보다 더욱 거칠다면 이런 이미지를 그려보며 만들어보는 것도 도움이 됩니다.

효과

이렇게 했을 때 가장 큰 효과는 관심사의 분리 입니다. View와 비지니스를 분리하면 서비스를 운영하는 구성원들과 소통할 때 커뮤니케이션에 명확함이 생깁니다. 그리고 코드에 반영할 때에도 View와 비지니스 로직을 따로 적용하고 결합하는 부분에 신경 쓰는 등 단계적으로 작업을 진행할 수 있습니다.

실제 업무를 할 때에도 빈번하게 하는 경험 중 하나도 이런 명확성, 즉 관심사의 분리 입니다. 위 예시인 추가 상품을 3개 이상 구매하면 할인하는 경우, 처음엔 최종 결제금액을 보여주는 영역에만 영향을 줍니다. 그러다 관련 안내 문구를 추가 상품 리스트 영역에 추가하는 경우가 생기는 등 관련 로직이 점점 View 이곳저곳에 침투하게 됩니다.

const ExtraProducts = (...) => {
...
return (
...
<p>추가 상품을 ${C}개 이상 구매하면 추가 할인 ${P}원 제공!</p>
...
);
};

const PaymentInfoPanel = (...) => {
...
return (
...
<p>${C >= 3 ? ...}원</p>
...
);
};

이 코드만 보더라도, 비지니스 로직이 분리되어 있지 않으면 요청하는 사람 입장에서 간단한 요청이 개발자 입장에선 그리 간단하지 않게 됩니다. 관련 로직이 어느 컴포넌트에 위치하고 있는지, 어떻게 적용됐는지, 해당 컴포넌트의 View 상태 흐름에 어떻게 결합되어 있는 지 등을 고려해야 합니다. 하지만 만약 비지니스 로직과 상태가 분리되어 있다면 아래와 같이 간단한 수정만으로 대응이 가능합니다. 그리고 다른 View에 관련 로직을 삽입할 때에도 일관적인 방법으로 삽입 가능합니다. 무엇보다 가장 큰 장점은 View에 어떤 영향을 줄지 크게 걱정하지 않게 됩니다.

class BusinessLogic {
MIN_EXTRA_PRODUCT_COUNT_FOR_SALE = 3; // 이 값만 수정
...
isSatisfySale() {
return this.getSelectedExtraProducts().length >= MIN_EXTRA_PRODUCT_COUNT_FOR_SALE
}
...
}

결과적으로 이전 발표에서 공유드렸던 것처럼 View와 비지니스 로직의 변경 빈도수가 독립적으로 유지되어서 코드 전반의 유지보수가 더 수월해집니다.

그리고 이전 예시에서 봤듯이 인터페이스를 활용해 비지니스 로직을 관리하면 재사용 그리고 유연성이 크게 증가합니다. 실제 업무를 할 때에도 특정 로직을 페이지 안에서 뿐만 아니라 다른 페이지에서도 사용해야하는 요청을 다수 받습니다. 이럴 때 요청하는 사람은 개발자와 프로젝트 일정을 고려해서 같은 로직을 단기간에 적용하길 기대하지만, 실제 상황이 그렇지 않다면 프로젝트 예상 기간에 영향을 주게 되고, 향후 유지보수에도 비용이 상승하게 됩니다.

또한 로직을 이렇게 분리하면 OOP, FP 등을 특정 프레임워크에 구애받지 않고 적용할 수 있는 환경을 갖추게 됩니다. 수많은 경험을 통해 쌓인 지식을 활용할 수 있다는 건 프로젝트 코드를 운영하는 데 있어 큰 자원입니다. 특히 다른 분야의 개발자와도 코드 개선을 위해 소통 할 수 있다는 건 분명한 강점입니다.

로직의 분리가 가져다주는 또 다른 효과는 테스트 입니다. 분리 하기 이전엔 테스트를 작성하다보면 View까지 테스트를 작성해야 했습니다. 왜냐하면 테스트 하려는 로직과 View가 강하게 결합되어 있어서, 로직의 출력을 테스트하려면 View를 테스트해야 했기 때문입니다. 그리고 참고할만한 아티클이나 도서, 강의도 찾기 어려웠습니다. 하지만 이렇게 로직을 분리하면 View와 독립적으로 테스트를 작성할 수 있고, 참고할 좋은 레퍼런스가 충분히 많습니다. 또한 View도 독립적으로 테스트가 가능해서, View 테스트 전략을 로직과 별도로 준비 할 수 있습니다.

마무리

Photo by Guilherme Stecanella on Unsplash

처음엔 어색했던 과정들이 이젠 습관으로 자리잡아가고 있습니다. 특히 수정 요청 대응을 비지니스 로직에서만 하고 브라우저를 열었을 때, 그리고 모두 반영된 걸 봤을 때 뿌듯함은 정말 중독적입니다. 그리고 프론트엔드 개발자 이면서 OOP, 리팩토링, 테스트 등 관련 공부를 실무와 연관지어 할 수 있다는 것도 큰 변화 중 하나 입니다. 여전히 테스트를 작성할 시간은 충분치 않지만, 종종 테스트를 작성하고 돌려볼 때면 뿌듯할 때가 있습니다.

사실 실무에서 사용하는 분리와 관련된 코드는 이 글에서 처럼 극단적으로 보이지 않습니다. 왜냐하면 이런 흥미와 재미, 그리고 관심은 개인적이지만 일이라는 건 개인적이지 않기 때문입니다. 매번 토론과 논의를 하며 개발을 할 순 없습니다. 하지만 그렇게 작은 변화에서도 흥미를 유지할 수 있다는 건, 변화의 과정을 지켜볼 수 있다는 건 분명 개발자로서 큰 복입니다. 이런 경험을 공유하기 위해 글을 쓰는 과정도 즐겁고 재밌습니다.

코드를 조금씩 더 좋은 방향으로 개선하는 데, 그리고 그 효과를 맛보았을 때 느껴지는 즐거움을 모두가 느끼면 좋겠습니다. 그리고 그 경험을 더 많은 사람이 공유하면 좋겠습니다.

--

--

이문기

사용자를 생각하고 개발자를 생각하는 프런트엔드를 만드는 데 관심이 많습니다. 표준, 접근성, 아키텍처, 테스트 등을 꾸준히 훈련하고 적용하려고 노력합니다.