전개연산자는 왜 샌드위치를 망쳤을까?

햄햄·2023년 10월 19일
9
post-thumbnail

객체 변경 어떻게 하세요?

나는 다음과 같이 객체나 배열의 요소를 직접 업데이트하는건 죄악으로 여겼다. 사이드 이펙트가 어디까지 퍼질지 예측할 수 없기 때문이다.

const obj = { a: 1 };
obj.a = 2; // 😱😱

참조타입의 변수는 무조건 깊은 복사를 한 후 변경하는 것이 마음 편했다. 깊은 복사를 하는 방법은 여러가지가 있는데, 개인적으로는 전개 연산자(...)를 사용하는 것이 가장 간편하여 애용했다. 복사와 변경을 동시에 할 수 있기 때문이다.

어느 새 참조타입을 업데이트할 때 전개 연산자를 쓰는 것은 습관이 되었다. 리액트로 개발할 때 객체 혹은 변수인 상태를 변경하여 setState에 전달할 때는 무조건 전개 연산자를 썼다.

const [state, setState] = useState({ a: 1, b: 2 });
setState(prev => ({ ...prev, b: 1 }));

실제로 내가 주로 개발하던 프론트엔드 프로젝트에서는 다음과 같이 전개 연산자를 이용한 상태 변경이 난무하였다. 한 눈에 봐도 가독성이 나쁜 것을 알 수 있다.

const handleToggleTaxFree = (e: ChangeEvent<HTMLInputElement>) => {
    const vat = e.target.checked
	    ? 0 
	    : (Number(removeNonNumeric(e.target.value)) / 11).toFixed();
    setProduct((product) => ({
      ...product,
      [e.target.name]: e.target.checked,
      vat,
    }));
};

각각의 이벤트 핸들러에는 각종 결정 및 계산 로직이 뒤섞여 있다. 로직을 분리하여 테스트 코드까지 작성할 수 있을 것 같은 느낌이 들었지만, 오래 매여있던 습관에서 벗어나지 못해 어떻게 해야할지 도통 감이 잡히지 않았다.

스터디에서 받은 영감으로 리팩토링

그러던 어느 날 프론트엔드 스터디에 참관하였다가 눈을 번쩍 뜨게 하는 말을 듣게 되었다.

전개 연산자로 객체를 변경하니 리팩토링이 어려운 것 같아요

아, 전개 연산자가 범인이었구나! '전개 연산자로 업데이트 하기'는 나에게 걷기, 숨쉬기와 같은 당연한 행동이었기 때문에 차마 문제가 될거란 생각을 하지 못했다. 스터디가 끝나자마자 들뜬 상태로 바로 리팩토링을 했다. 리팩토링 후에는 순수함수를 추출할 수 있었다. 추출된 순수함수에 대해 단위 테스트도 추가할 수 있었다.

정말 전개연산자가 범인이었을까?

그런데 사실, 전개 연산자가 진범은 아니다. 진짜 문제는 복사와 변경을 동시에 한 것이었다. 전개 연산자의 잘못이라면 복사와 변경을 동시에 할 수 있게 만드는 유연성을 제공한 것이다.

함수는 한 가지를 해야 한다. 그 한 가지를 잘 해야 한다. 그 한 가지만을 해야 한다. (클린코드 中)

전개 연산자를 사용하여 복사와 변경을 동시에 한 코드는 아무리 함수를 잘게 나누어도 절대 한 가지만 하는 함수로 리팩토링 할 수 없다. 동작이 뒤섞인 코드로 인해 가독성은 나빠지고 테스트 코드를 작성할 수 없게 된다. 그런데 이렇게 개념적으로는 알고 있는 원칙도 실전에 적용하려면 헷갈리는 부분이 있다. "복사와 변경이 한 가지 일이 아니라는 건 어떻게 알지?" 라는 의문을 가진다면 훌륭한 탐구 정신을 가진 개발자일 것이다. 복사와 변경이 다른 일이라는 것을 알 수 있도록 도와주는 패턴이 있다. 바로 Functional Core & Imperative Shell 패턴이다.

Functional Core & Imperative Shell

Functional Core & Imperative Shell은 순수하지 않은(impure) 동작이 순수한 로직을 감싼 코드 구조를 말한다. 개인적으로는 impure-pure-impure 샌드위치 메타포를 좋아한다.
sandwich

샌드위치로 메소드를 구성하면 다음과 같은 흐름을 따를 수 있다.

1. 데이터를 불러온다.
2. 데이터를 계산한다.(비즈니스 규칙을 결정한다)
3. 데이터를 저장한다.

샌드위치의 가운데에 있는 로직은 비즈니스 규칙을 결정하는 로직이다. 샌드위치의 가장 맛있는 고기 부분이라고 할 수 있다.😋 샌드위치의 위, 아래에 있는 로직은 비즈니스 로직이 어플리케이션에서 작동되기 위해 발생할 수 밖에 없는 side effect 로직으로, 손에 기름을 묻히지 않고 맛있는 고기를 먹을 수 있게 해주는 빵이 된다. 샌드위치 구조로 코드를 작성하면 impure 로직과 pure 로직을 쉽게 분리할 수 있다. 따라서 pure 로직만을 추출하여 테스트 코드를 작성하기도 수월해진다.

이 패턴을 적용해서 내가 쓴 코드를 의사코드로 다시 구성해보겠다.

// 빵
1. 사용자가 체크한 면세 여부를 불러온다.
2. 상품 데이터를 복사한다.

// 고기
3. 면세 상품이면 매입가의 1/11이 부가세가 된다.
4. 면세 상품이 아니면 부가세는 면제된다.

// 빵
5. 변경된 상품을 저장한다.

이렇게 샌드위치를 적용시켜보면 복사와 변경의 차이를 명확히 알 수 있다. 복사는 '빵'이고 변경은 '고기'에 해당된다. 그러므로 복사와 변경은 한 가지 일이 아닌 두 가지 일이 된다. 단순한 리팩토링이었지만, 많은 것을 깨달을 수 있었던 재밌는 경험이었다.

그럼 다시, 전개 연산자는 왜 샌드위치를 망쳤을까? 위 내용을 처음 접해봤다면 이 질문에 스스로 답해볼 수 있도록 해보자.

참고

Impureim sandwich

리팩토링한 코드

// 이벤트 핸들러
  const handleToggleTaxFree = (e: ChangeEvent<HTMLInputElement>) => {
    const isTaxFree = e.target.checked;
    const updated = productWithUpdatedTaxFree(product, isTaxFree);
    setProduct(updated);
  };

// 순수 함수
export const productWithUpdatedTaxFree = (product: Product, isTaxFree: boolean) => {
  const clone = deepClone(product);
  clone.vat = isTaxFree ? 0 : vatOn(cost);
  clone.isTaxFree = isTaxFree;
  return clone;
};

const vatOn = (cost: string) => (Number(removeNonNumeric(cost)) / 11).toFixed();

추가한 단위 테스트

  describe('면세 상품 여부 업데이트', () => {
    it('면세 상품이면 부가세가 면제된다', () => {
      const product = { vat: '1000' } as any;

      const updated = productWithUpdatedTaxFree(product, true);

      expect(updated).toStrictEqual({ vat: 0, isTaxFree: true });
    });

    it('면세 상품이 아니면 매입가의 1/11이 부가세가 된다', () => {
      const product = { cost: '1000' } as any;

      const updated = productWithUpdatedTaxFree(product, false);

      expect(updated).toStrictEqual({ ...product, isTaxFree: false, vat: '91' });
    });
  });
profile
@Ktown4u 개발자

3개의 댓글

comment-user-thumbnail
2023년 10월 19일

비유가 너무 찰떡이네요. 순수 함수와 비순수 함수를 분리하는 것도 아주 좋은 방향인 것 같습니다.

한 가지 궁금한 점은 빵 부분에서 "2. 상품 데이터를 복사한다." 라고 적어주셨는데, 리팩토링한 코드에서는 deepClone이 순수 함수 안에 있어요. 의도하신 바대로라면 deepClone이 handleToggleChangeTaxFree에 있어야 할 거 같은데 맞을까요?

1개의 답글
comment-user-thumbnail
2023년 11월 2일

immer 같은걸 써보는것도 좋을거같아요~
아니면 단순히 코드 스타일을 변경하는것만으로도 큰 효과를 볼 수 있을것 같습니다.

setState({ ...draft }) => {
  draft.vat = isTaxFree ? 0 : vatOn(cost);
  draft.isTaxFree = isTaxFree;
  return draft;
})
답글 달기