본문 바로가기

FrontEnd

[번역] 자바스크립트 리팩토링과 추상화

반응형

키워드 :

자바스크립트(javascript), 타입스크립트(typescript), 리팩토링(refactoring), 추상화(abstraction), 관심사의분리(seperation of concern)

 

원문 : https://refactor-like-a-superhero.vercel.app/en/chapters/08-abstraction

 

Abstraction | Refactor Like a Superhero

Book about how to refactor code efficiently and without pain.

refactor-like-a-superhero.vercel.app

추상화

특정 데이터에 대해 로직을 적용하는 코드의 중복을 줄이는 방법은, 액션을 따로 파라미터화 하는 것이다.

이처럼 같은것을 묶고, 변하는 부분과 변하지 않는 부분을 잘 발라내어 코드의 중복을 줄이는 방법을 추상화라 한다.

// The generalized `applyDiscount` function will take
// the absolute discount value:

function applyDiscount(order, discount) {
  return {...order, discount}
}

// Differences in the calculation (percentages, “discount of the day,” etc.)
// are collected as a separate set of functions:

const discountOptions = {
  percent: (order, percent)  => order.total * percent / 100
  daily: daysDiscount()
}

// As a result, we get a generalized action for applying a discount
// and a dictionary with discounts of different kinds.
// Then, the application of any discount will now become uniform:

const a = applyDiscount(order, discountOptions.daily)
const b = applyDiscount(order, discountOptions.percent(order, 40))

추상화가 왜 필요한가?

코드는 짧을수록 이해하기 쉽다.

코드가 비즈니스 프로세스에 가까울 수록 이해하기 쉽다.

추상화는 무관한 것을 제거하고 본질적인 것을 증폭시키는 행위입니다.

의도와 구현

양식을 제출하는 함수를 handleForm이라 명명하고,

해당 함수 내에 로직을 캡슐화 할 수 있다.

이와 같은 방법은 디테일을 숨기고 함수의 핵심적인 목적을 나타낸다.

이는 1depth, 2depth로 로직을 추상화하는 것이라 볼 수 있다.

더 높은 depth는 의도를 나타내며, 더 낮은 depth는 해당 의도의 구현이라 볼 수 있다.

  • 의도는 우리가 할 일을 설명한다.
  • 구현은 일을 하는 방법을 설명한다.
async function handleOrderSubmit(event) {
  const formData = serializeForm(event.target);
  const validData = validateFormData(formData);
  const order = createOrder(validData);
  await sendOrder(order);
}

함수 명은 의도를 나타내며, 본문은 구현을 나타낸 다 볼 수 있다.

또한 해당 함수와 다른 함수를 사용할 때, 우리는 함수의 의도에 관심을 두지 구현에 관심을 두지 않는다.

// Name and signature reflect the intention...
function isChild(user) {
  // ...And function body reflects the implementation.
  return user.age < 18;
}

// When we use the function with others,
// we care about its purpose and goals,
// not about its implementation details:
if (isChild(user)) toggleParentControl();

우리의 뇌는 제한적인 정보만 처리할 수 있다.

추싱화는 중요한 정보에 집중할 수 있도록 한다.

다른 디테일에서 핵심을 분리해준다.

 

이메일의 유효성을 검사하는 함수가 있다.

function subscribeToFeed(email) {
  if (!email.includes("@") || !email.includes(".")) return false;

  const recipients = addRecipient(email);
  confirmFeedSubscription(recipients);
}

다른 내부 호출 함수들에 비해 validation 로직이 너무 원시적임을 알 수 있다.

다른 함수들은 feed, subsciption 등의 자연어로 이야기 할 수 있다.

유효성 검사는 @, .를 포함해야 이메일임을 알려준다.

subscribeToFeed 함수가 하는 역할이 무엇일까?

  • 이메일이 유효하지 않으면 구독하지 않는다
  • 구독자의 메일을 리스트에 추가한다.
  • 구독자의 구독을 승인한다.

이 세 가지가 구체적으로 어뗗게 수행되는지는 관심없으며, 단지 순서에 맞게 함수를 호출할 뿐이다.

따라서 1depth가 2depth가 하는 일을 모르게 하자.

이제 isValidEmail은 검증 작업의 집합이 된다.

검증 작업은 해당 함수 내에만 위치하면 된다.

function isValidEmail(email) {
  return email.includes("@") && email.includes(".");
}

function subscribeToFeed(email) {
  if (!isValidEmail(email)) return false;

  const recipients = addRecipient(email);
  confirmFeedSubscription(recipients);
}

 

또한 isValidEmail를 호출할 경우 주변 함수는, 이제 이메일이 유효하지 않으면 어떻게 동작해야 하는지에 집중하게 된다.
이처럼 추상화는 복잡한 개념과 프로세스를 점진적으로 "파악"하는 데 도움이 된다.

  • 시스템에 대한 정보를 청크 단위로 제공한다.
  • 각 "세부 수준"에 해당 수준에서 시스템을 이해하는 데 필요한 정보만 포함한다.

Mark Seemann은 이것을 프랙탈 아키텍처라고 부른다.

Fractal Architecture

리팩토링을 할 때 우리는 모든 것을 기억하기 얼마나 어려운지에 집중해야 한다,
모든 세부 사항을 기억하기 어렵다면 코드에 문제가 있는 것이다.

좋은 코드에는 주어진 순간에 독자가 필요로 하는 만큼의 정보만 졵대한다.
Mark Seemann은 각 "세부 수준"의 구성 요소의 수가 특정 제한을 초과하지 않도록 프로그램을 작성하는 것을 제안한다.
이 휴리스틱을 사용하여 코드가 지나치게 자세한지 확인할 수 있다.
 
대충 사람은 한 번에 7+=2 개 정도만 기억할 수 있으므로, 세부 depth를 각 7개 단위로 포함하자는 것이다.

So the program breaks down into chunks, which break down into chunks, which break down into chunks...

위 논리를 적용하여 리팩토링을 시도해보자.

로그인 한 사용자의 정보를 보여주는 대시보드가 있다고 가정한다.

 

이 코드는 사람이 기억하기 어렵다. 세부 사항이 7개 이상이기 때문이다.

const App = () => {
  const user = currentUser();
  const isManager = hasManagerRole(user);
  const isPromoAccount = checkPromoAccount(location);

  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const handleSubmit = () => {
    /*...*/
  };

  return isManager || isPromoAccount ? (
    <Dashboard />
  ) : (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        value={email}
        onChange={({ target }) => setEmail(target.value)}
      />
      <input
        type="password"
        password={password}
        onChange={({ target }) => setPassword(target.value)}
      />
      <button>Login</button>
    </form>
  );
};

Objects and functions reflected on hex tiles; one of them doesn't fit

촤상위 수준에서 독자를 위한 추상화를 제공하고, 컴포넌트가 주생하는 세부 사항을 그 다음 수준으로 제공하면 코드를 이해하기 더 쉽다.

변수와 서브 컴포넌트는 의도 및 스토리를 나타낸다.

const App = () => {
  const hasAccess = useHasAccess();
  return hasAccess ? <Dashboard /> : <Login />;
};

/**
 * If a user has access (`hasAccess`) to the control panel,
 * the application will show them the panel component (`Dashboard`).
 * If not, they will be prompted to log in (`Login`).
 */
해당 햄수 및 컴포넌트의 구현은 "스토리"의 세부 사항을 나타낸다.
예를 들어 useHasAccess 훅 구현을 통해 사용자가 제어판에 액세스할 수 있는지 여부를 확인하는 방법을 설명활 수 있다.
즉 세부 사항이 어떻게 협력하는지를 나타낸다.
function useHasAccess() {
  const user = currentUser();
  const isManager = hasManagerRole(user);
  const isPromoAccount = checkPromoAccount(location);
  return isManager || isPromoAccount;
}

/**
 * We'll check if the current user (`currentUser`)
 * is a manager (`hasManagerRole`).
 * We'll also check if the application is running under a promo account,
 * in which the control panel is available to everyone (`checkPromoAccount`).
 */

이제 최상위 세부 수준에서는 hasAccess, Dashboard 및 Login의 세 부분만 볼 수 있다.

각 이야기의 세부 사항이 궁금하면 한 단계 내려가면 된다.

Top layer of application detail in the form of hex tiles

예를 들어 useHasAccess에서 네 부분이 함께 작동하는 방식을 볼 수 있다.
이 수준에서는 useHasAccess의 구조에 초점을 맞추기 때문에 "위 수준"에서 무슨 일이 일어나는지는 중요하지 않다.
를 자세히 설명해야 하는 경우 셀 중 하나를 "확대"하여 그 구조를 조사할 수 있도록 한다.

Detailed tiling of the useHasAccess hook

하나의 세부 수준을 다른 수준에 중첩할 수 있기 때문에 프랙탈 아키텍처라고 한다.
Levels of detail are nested in on another like a Russian doll

그리고 언제늗지 레벨 간에 전환할 수 있다.

Switching attention between levels

각 레벨은 우리가 처리할 수 있는 정보만 처리하며, 언제든지 zoom in, zoom out 할 수 있다.


관심사의 분리

추상화는 우리가 코드를 여러 부분으로 나누도록 강요하지만 이를 수행하는 방법이 항상 명확한 것은 아니다.
이 작업을 더 쉽게 하기 위해 관심사 분리, SoC 원칙을 사용할 수 있다.

"책임"과 "작업(task)"은 다소 모호한 용어지만,
다른 데이터 및 작업보다 서로 더 강력하게 관련된 제한된 데이터 및 작업 집합으로 이해할 수 있다.

잘 분할된 코드는 같은 작업을 반복하지 않는다.
리팩토링 도중 복잡한 작업을 간단한 작업으로 분리해 이를 수행할 수 있다.
 

작업 나누기

해당 코드에 얼마나 많은 데이터와 작업이 있는지를 파악한다.

async function submitLoginForm(event) {
  const form = event.target;
  const data = {};

  if (!form.email.value || !form.password.value) return;
  data.email = form.email.value;
  data.password = form.password.value;

  const response = await fetch("/api/login", {
    method: "POST",
    body: JSON.stringify(data),
  });
  return response.json();
}
13줄로 매우 간결하지만 세 가지 작업이 존재한다.
  • 양식 데이터 추출
  • 데이터 유효성 검사
  • 네트워크 요청.

작업의 조건(데이터)을 변경하여 어떤 코드가 변경되는지를 파악해 작업의 갯수를 판단할 수도 있다.
예를 들어 양식에 체크박스를 추가하면 데이터 추출 코드가 변경된다.

async function submitLoginForm(event) {
  // ...

  data.email = form.email.value;
  data.password = form.password.value;

  // New field will appear in the object:
  data.rememberMe = form.rememberMe.checked;

  // ...
}
API 체계를 변경하면 네트워크 부분만 변경된다.
async function submitLoginForm(event) {
  // ...

  // Argument for `fetch` will change:
  const response = await fetch("/api/v2/login", {
    method: "POST",
    body: JSON.stringify(data),
  });

  // ...
}

이러한 것들은 코드 조각들을 연관짓는데 도움이 된다.

물론 큰 코드에 항상 많은 작업들이 있는것은 아니다.

알고리즘 문제 풀이와 같은 경우 많은 코드들이 하나의 작업만 한다.


단일 책임 원칙

다른 이유로 변경되는 코드는 별도 보관한다.

같은 이유로 변경되는 코드는 함께 보관한다.

 

아래 코드에서 데이터를 추출해보자

// Move data extraction to a separate function.
// Now it all is gathered here, and we know exactly
// where to look if we need to know its details.
function extractLoginData(form) {
  const data = {};

  data.email = form.email.value;
  data.password = form.password.value;

  return data;
}

async function submitLoginForm(event) {
  const form = event.target;

  // Inside `submitLoginForm` we now focus
  // only on using the extracted data.
  const data = extractLoginData(form);

  if (!form.email.value || !form.password.value) return;

  const response = await fetch("/api/login", {
    method: "POST",
    body: JSON.stringify(data),
  });
  return response.json();
}
다음으로 유효성 검사에 대해 생각해보자.
이제 우리는 DOM 객체에서 유효성을 검사할 필요가 없다.
즉 데이터가 어디에서 오는지는 중요하지 않다.
이러한 방식으로 책임을 분리함으로써 작업 간의 원치 않는 결합을 제거할 수 있다.
// All validation is now gathered in the `isValidLogin` function.
// Inside it, we check the data, not the properties of the DOM object:
function isValidLogin({ email, password }) {
  return !!email && !!password;
}

async function submitLoginForm(event) {
  const form = event.target;
  const data = extractLoginData(form);
  if (!isValidLogin(data)) return;

  const response = await fetch("/api/login", {
    method: "POST",
    body: JSON.stringify(data),
  });
  return response.json();
}

API 로직도 함수로 분리할 수 있다.

// All the networking now is in the `loginUser` function.
async function loginUser(data) {
  const method = "POST";
  const body = JSON.stringify(data);

  const response = await fetch("/api/login", { method, body });
  return await response.json();
}

async function submitLoginForm(event) {
  const form = event.target;
  const data = extractLoginData(form);
  if (!isValidLogin(data)) return;

  return await loginUser(data);
}
추출된 함수는 작업 간의 "책임 범위"를 제한하기 때문에 코드를 수정하기 더 쉽게 해준다.
한 함수의 변경이 다른 함수의 변경을 유발할 가능성이 매우 적다.
유효성 검사 규칙을 업데이트할 때는 isValidLogin 함수 코드만 변경하면 된다.
function isValidLogin({ email, password }) {
  // Now checking that the email contains `@` character:
  return email.includes("@") && !!password;
}

// Functions `extractLoginData`, `loginUser`, and `submitLoginForm`
// are not changed.​

이러한 분리를 통해 서로 격리된 상태에서 기능을 테스트하고 개발하기가 더 쉬워진다.
격리도가 높을수록 코드를 업데이트할 때 우발적인 실수를 할 가능성이 낮아진다.


캡슐화

단일 책임 원칙은 코드 부분(함수, 모듈, 객체)을 애플리케이션의 독립적인 부분으로 생각하는 데 도움이 된다.
각 부분은 API(프로토콜, 계약, 인터페이스)를 통해 통신하며 서로의 내부 세부 사항을 침범하지 않는다.
이러한 내부가 무관한 엔터티 간의 관계를 캡슐화라고 할 수 있습니다.
캡슐화는 종종 데이터를 숨기거나 데이터에 대한 액세스를 제한하는 것으로 설명된다.
[캡슐화의] 가장 중요한 개념은 객체가 유효하지 않은 상태가 불가능하도록 보장해야 한다.
[캡슐화된] 객체는 "유효한"것이 무엇을 의미하는지, 그리고 이를 보장하는 방법을 가장 잘 알고 있다.
잘못된 캡슐화는 잘못된 데이터를 포함한 코드 및 오류를 유발한다.
이는 추상화의 누출 및 모듈간의 높은 결합도로 진단할 수 있다.
여기에서는 추상화의 누출에 대해 이야기해보자.
아래 예에서 makePurchase 함수에 무엇이 잘못되었는지 확인해보자.
// purchase.js
import { createOrder } from "./order";

async function makePurchase(user, cart, coupon) {
  if (!cart.products.length) throw new Error("Cart is empty!");

  const order = createOrder(user, cart);
  order.discount = coupon === "HAPPY_FRIDAY" ? order.total * 0.2 : 0;

  await sendOrder(order);
}

문제점은 sendOrder 함수가 휴효한 order를 받을 것이라는 보장이 없다는 것이다.

makePurchase 함수는 다른 함수에서 가져온 order의 데이터를 변경한다.

이는 order의 내부 상태를 너무 잘 알고 있음을 의미한다.

데이터가 유효한지 확인하는 것은 특정 모듈의 내부 작업입니다.

따라서, 데이터 유효성 검사는 order에 할인을 적용하는 방법을 아는 모듈이 해야 한다.
우리의 경우 이것은 order.js다. 즉, 주문이 생성될 때 할인도 적용되어야 한다.

// order.js
export function createOrder() {
  /*...*/
}

export function applyDiscount(order, coupon) {
  const discount = coupon === "HAPPY_FRIDAY" ? order.total * 0.2 : 0;
  return { ...order, discount };
}

// purchase.js
import { createOrder, applyDiscount } from "./order";

async function makePurchase(user, cart, coupon) {
  if (!cart.products.length) throw new Error("Cart is empty!");

  const order = createOrder(user, cart);
  const discounted = applyDiscount(order, coupon);
  await sendOrder(discounted);
}

// Now making sure the order data is valid
// is an internal task of the `order` module,
// not the responsibility of the code that calls it.

혹은 생성한 데이터를 절대 변경하지 못하도록 하는 방법도 있다.(불변)

 

또한 카드 모듈도 마찬가지다.

어떤 카트 상태가 유효한지 계속 아는 것처롬 동작한다.

카트를 생성하는 책임과 함께 카트를 유효하게 유지하는 방법을 알고 있는 모듈에 비움 검사를 맡기는 것이 좋다.
// cart.js
export function isEmpty(cart) {
  return !cart.products.length;
}

// purchase.js
import { isEmpty } from "./cart";
import { createOrder, applyDiscount } from "./order";

async function makePurchase(user, cart, coupon) {
  if (isEmpty(cart)) throw new Error("Cart is empty!");

  const order = createOrder(user, cart);
  const discounted = applyDiscount(order, coupon);
  await sendOrder(discounted);
}
  • 업데이트된 코드에서 makePurchase 함수는 주문 상태를 직접 변경하지 않으며 장바구니가 유효한지 여부를 결정하지 않는다.
    • 대신 다른 모듈의 공개 API를 호출한다.
  • 또한 코드의 변경 범위를 제한했다.
    • 모듈의 공개 API는 변경되지 않는다.
    • 해당 모듈의 업데이트 및 수정은 해당 경계 내부로 제한되며 외부로 이동하지 않는다.
  • 이 변경으로 오류가 사라지는 것은 아니다.
    • 하지만 모듈 간 책임 분리로 어떤 오류가 발생하면 어떤 모듈을 수정해야 하는지 쉽게 알 수 있다.
 

 

반응형