회고록
NEXT STEP
Carculator

계산기 미션 회고

들어가며

2월부터 4월까지 진행되었던 NEXT STEP - TDD, 클린 코드 with React 과정을 참여했었다. 과정 자체는 끝났지만 여전히 미션을 진행하고 있다(포기하지 않고 굼벵이 같이 하는 중이다. 🥲). 이미 시간이 많이 흐르긴 했지만 더 흐르기 전에 그리고 머릿 속에서 기억이 사라지기 전에 미션을 진행하면서 느꼈던 점을 문서화하고자 한다.

이 NEXT STEP 회고 시리즈는 총 4개의 포스팅으로 이루어질 예정이며, 포스팅엔 미션을 진행하며 발생했던 내 코드의 문제점 그리고 문제점을 개선하며 느꼈던 바가 정리될 것이다.

부디 남은 미션을 수행하여 이 시리즈를 완성시킬 수 있기를 .. 🫠
그럼 바로 들어가보자. 🚀

let's go

요구사항

계산기 미션은 온보딩 미션이었기에 요구사항 자체는 간단했다. 모든게 가능한 슈퍼 계산기를 구현한다기 보다 본 미션에 들어가기 전에 몸 풀기로 미션을 하나 진행하는 것이었다. 간단하게 덧셈, 뺄셈, 곱셈, 나눗셈, 합계 그리고 초기화가 가능하도록 만드는 것이었는데 테스트 코드가 필수는 아니었지만 테스트 코드를 구현하는 것까지 목표로 잡았었다.

내가 이루고자 했던 목표들은 다음과 같다.

  • 기본적인 계산기 기능 (ex. 덧셈, 뺄셈 등)
  • 각 기능에 대한 테스트 코드
  • 오직 React만을 이용해 구현
  • 될 수 있다면 기능에 한해서 TDD를 진행

미션 자체는 간단했기 때문에 첫 구현에서 많은 시간이 소요되진 않았다. 각 버튼을 누를 때 event가 발생하는 것처럼 로직이 돌아가길 원했기 때문에 reducer를 사용해 action을 dispatch 했고 각 기능들에 대한 테스트 코드를 작성했다. TDD를 하겠다고 목표를 설정했지만 막상 구현할 때는 기능을 구현하고 테스트 코드를 붙였던 것 같다. TDD를 하진 못했지만 나중에 코드를 리팩토링할 때 한번 작성해놓은 테스트 코드들이 많이 도움이 됐는데 특히 어느 부분에서 문제가 발생하는지 빠르게 피드백을 받을 수 있다 보니 리팩토링을 더 안전하게 진행할 수 있었다.

이 정도를 진행한 뒤에 코드 리뷰를 받기 위해 PR을 통한 리뷰 요청을 진행했다.

여러 가지 문제점

가득한 문제점

역시나 코드에 여러 가지 문제점들이 있었다. 담당 리뷰어는 황준일님이셨는데 준일님이 언급해주시기 전까지 이상함을 못 느꼈다는 게 놀라울 따름이다. 😇
여러 가지 문제가 있었는데 각 문제점을 가볍게 짚고 넘어가보자(아래는 준일님의 1차 피드백 개요다).

준일님 1차 피드백

언젠가 준일님 같은 분과 커피챗 하는 날이 오겠지 ..? 🥹

뒤섞여버린 도메인 로직과 UI 관련 로직

위 사진에도 언급이 되어 있듯이 내가 처음 구현한 코드의 가장 큰 문제점은 UI와 관련된 로직과 도메인 로직이 reducer 내에 모두 다 위치하고 있다는 점이었다. 준일님 피드백을 보고 난 후 내 코드를 보며 스스로에게 되물어봤다.

'지금 이 reducer 로직을 내가 아닌 다른 사람이 보았을 때 이해하기 쉬울까?'

확실히 내가 아닌 다른 사람이 코드를 본다고 하면 파악하는데 시간이 상당히 소요될 것 같다. 코드를 돌려보지 않는 이상 이해하기 어려울 것 같았다. 당시 뒤섞여 있던 나의 코드는 다음과 같다.

import { OPERATION, DIGIT, ERROR_MESSAGE } from 'constant';
import { calculate } from 'utils';
 
function reducer(state: ICalculator, action: Action) {
  const { operation, addend, augend } = state;
 
  switch (action.type) {
    case ACTION_TYPE.OPERAND:
      if (!operation) {
        const newAugend = Number('' + augend + action.payload);
        const newAccumulator = String(newAugend);
 
        return {
          ...state,
          augend: newAugend,
          accumulator: newAccumulator,
        };
      } else {
        const newAddend = Number('' + addend + action.payload);
        const newAccumulator = augend + operation + newAddend;
 
        return {
          ...state,
          addend: newAddend,
          accumulator: newAccumulator,
        };
      }
    case ACTION_TYPE.OPERATION: {
      const newAccumulator = augend + action.payload;
 
      return {
        ...state,
        operation: action.payload,
        accumulator: newAccumulator,
      };
    }
    case ACTION_TYPE.CALCULATE:
      if (!operation) {
        throw new Error('Missing Operation');
      }
 
      const total = calculate(augend, addend, operation);
      const isInfinity = total === Infinity;
 
      return {
        ...state,
        total,
        operation: null,
        augend: isInfinity ? 0 : total,
        addend: 0,
        accumulator: isInfinity ? ERROR_MESSAGE.INIFINITY : String(total),
      };
    case ACTION_TYPE.CLEAR:
      return {
        ...initialState,
      };
    default:
      throw new Error('Unhandled Action type');
  }
}

아주 간단한 계산기여서 이정도지 조금 더 복잡한 무언가를 구현할 때 이런 식이면 코드가 상당히 정신없을 것이다.

불분명한 reducer의 역할

두 번째 문제는 첫 번째 문제점이 발생하게 된 근본적인 원인이라고 생각한다. 기능을 구현하면서 reducer의 역할에 대한 고민이 없었기 때문에 reducer에 이런 저런 로직이 다 들어가게 됐고 이러다 보니 생각보다 더 많은 일을 하고 있게 됐다.

피드백을 받고 나니 'reducer 내부에 도메인 로직이 직접적으로 존재할 필요가 있을까'라는 생각이 들었다. UI를 그리기 위한 컴포넌트에서 UI를 뿌려주기 위한 데이터를 가공하는 로직이 해당 컴포넌트의 관심사가 아니듯이 reducer도 값을 핸들링하는 데에 초점을 맞추면 그걸로 reducer의 역할은 충분한게 아닐까 ..

이런 생각이 들다 보니 redcuer 내에 존재하던 도메인 로직을 순수 함수로 분리하고 해당 함수들에 대한 테스트 코드를 추가하여 reducer는 내부에 계산 로직이 직접적으로 있는 것이 아닌 해당 순수 함수들이 반환해주는 값을 가지고 값을 핸들링하는 쪽으로 코드를 수정하게 됐다.

언제나 그렇듯 네이밍

이건 내 개발 인생에서 벗어날 수 있긴 할까 싶은 부분이다. 🤯

이벤트 핸들러의 네이밍 .. 최근에 안 사실인데 개편된 React 공식 문서에서도 알려주는 부분이다. 🥲 나만 몰랐다.

  • handleDigitButton => handleClickDigit ( digit를 클릭했을 때 처리하는 함수 )
  • handleOperationButton => handleClickOperation ( operation을 click 했을 때 처리하는 함수 )

테스트 코드의 방향성

아래는 내가 온보딩 미션에서 테스트 코드가 필수가 아님에도 구현을 하고 테스트 코드를 작성하는 방식을 보며 준일님이 앞으로 테스트 코드를 어떤 식으로 작성하면 좋을지 본인의 생각을 담아 조언을 해주신 부분이다. 아무래도 내 코드에서 테스트 코드를 어떻게 작성할지 몰라서 방황하는 모습이 티가 났던 것 같다.


  1. 테스트 코드를 작성하다보면 "관심사의 분리"에 대한 필요성을 느낄 수 있다.
  2. 내가 작성하는 테스트가 순수하게 "도메인 로직"에 대한 테스트인지, "UI"에 대한 테스트인지 고민이 필요하다.
  3. 리액트는 MVVM 패턴을 따른다.
    1. Model의 내용이 ViewModel에 반영되고
    2. ViewModel을 토대로 View를 구성한다.
    3. Model에서 작성된 함수들은 View에서 이벤트 핸들러를 통해 호출될 것이다.
    4. 즉, Model을 검증하게 된다면 자연스럽게 View에 대한 내용도 검증할 수 있게 된다.
  4. 위의 내용을 토대로 결론을 내려보자면, 잘 작성된 MVVM에서는 Model(서비스 로직, 비즈니스 로직)에 대한 테스트 코드를 작성한다면, 자연스럽게 View에 대해서도 검증할 수 있게 되는 것이다.
  5. 반대로 말해서, Model을 통해 View의 내용을 검증하기가 어렵다면, 무언가 코드를 잘못 작성한게 아닐까? 관심사 분리가 잘 안 된게 아닐까?
  6. 리액트에서 가성비 있는 테스트를 작성하는 방법은, Component에 있는 도메인 로직을 custom hook으로 분리하여 해당 hook에 대해 테스트하는 것이다.
  7. custom hook에도 분명 hook과 무관한 로직들이 있을 것이고, 그러한 로직을 "순수 함수"로 분리한다면 테스트하기가 더 쉬워진다.

길지 않은 내용이었지만 가려운 부분을 긁어준 너무나 소중한 조언이었다.

피드백 반영

앞에서 어느 정도 언급이 된 부분들이긴 하지만 반영된 피드백 사항을 정리하면 다음과 같다.

  • 도메인 로직과 UI 관련 로직을 분리
  • 기존 도메인 로직을 순수 함수로 분리하고 해당 함수에 대한 테스트 코드 추가
  • co-location, 특정 컴포넌트에 종속적인 hooks는 컴포넌트 하위 폴더에 hooks 폴더를 만들어서 구성
  • 도메인에 종속적인 함수는 utils가 아닌 도메인 관련 함수들이 모여있는 곳에 위치
  • 어떤 "행위"에 대한 이벤트 핸들링인지 표현

그리고 피드백이 반영된 reducer 코드는 다음과 같다.

import {
  ICalculator,
  calculate,
  updateOperation,
  updateOperand,
  updateAccumulator,
  isOperandMaxLength,
} from 'components/Calculator/CalculatorModel';
 
function reducer(state: ICalculator, action: Action) {
  switch (action.type) {
    case ACTION_TYPE.OPERAND:
      return {
        ...state,
        ...updateAccumulator(updateOperand(state, action.payload)),
      };
    case ACTION_TYPE.OPERATION: {
      return {
        ...state,
        ...updateAccumulator(updateOperation(state, action.payload)),
      };
    }
    case ACTION_TYPE.CALCULATE:
      return {
        ...state,
        ...updateAccumulator(calculate(state)),
      };
    case ACTION_TYPE.CLEAR:
      return {
        ...initialState,
      };
    default:
      throw new Error('Unhandled Action type');
  }
}

https://github.com/taeyoungs/react-calculator (opens in a new tab)

마치며

뚱이

리뷰어랑 의견을 주고 받으며 코드를 개선하는 과정은 정말 재밌었다. 다만, 몸 풀기 미션인 온보딩 미션임에도 부족함을 많이 느꼈다. 아직 갈 길이 너무나도 멀구나 싶긴 하지만 중요한 건 꺾이지 않는 마음이니 피드백을 곱씹으며 (아직도) 진행 중인 남은 미션을 해결하러 가보자.

글을 작성하며 피드백 내용을 돌아보니 준일님이 PR 병합하시면서 주신 추가 피드백을 적용하지 않은 것 같다. 🤦🏻‍♂️
이것부터 반영해보기로 하며 다음 미션 회고로 돌아오자.

Last updated on May 29, 2023