소프트웨어에서 변하지 않는 유일한 것은 모든 것이 변한다는 사실뿐이다. 고객의 니즈와 요구사항이 변하는 것은 물론, 우리의 문제에 대한 이해도도 변한다. 그리고 이러한 변화는 필연적으로 소프트웨어의 변화를 초래한다. 따라서, 개발자는 변화에 대응해야 하며, 이것이 바로 개발자의 존재 이유이다.

 

하지만 현실은 어떠한가? 2001년 애자일 매니페스토 이후 20년이 넘었지만, 변화에 대응할 수 있는 역량을 갖춘 개발자는 여전히 매우 소수에 불과하다. 그리고 나는 이러한 이유가 변화에 대응하는 것의 중요성에도 불구하고, 많은 개발자들이 가장 성장하는 초기 단계에서 변화에 대응하는 것을 직접 경험하고 훈련하지 않기 때문이라고 생각한다.

 

개발을 시작할 때, 많은 개발자들이 프로젝트를 학교 과제나 부트캠프를 통해 접하게 된다. 이러한 프로젝트는 마치 과제처럼 결과물을 만들어 내는 것에 목적을 두고 있으며, 이를 지속적으로 관리하거나 개선하는 경우는 매우 드물다. 이러한 프로젝트의 특징은 마감 기간이 지나면 코드를 다시 보는 일이 없고, 실제로는 아무도 사용하지 않는다는 점이다. 소프트웨어 개발은 정해진 도착지와 코스를 전력질주 하는 F1보다는, 정확한 위치와 코스가 나와있지 않은 상태에서 방향을 가늠하며 나아가는 오프로드 레이싱과 비슷하다. 그렇기 때문에 이런 프로젝트가 전혀 의미가 없다는 것은 아니지만, 소프트웨어의 속성과 부합하지 않는다는 점은 부정할 수 없다.

 

더 나은 개발자가 되려면, 단지 동작하는 코드를 작성하는 것 이상으로, 변화에 대응할 수 있는 변경 가능한 코드를 작성하는 능력을 훈련하고 경험해야 한다. 이를 위해 과제 형식의 프로젝트가 아닌, 다양한 분야의 사람들과 실제 문제를 해결하는 창의적이고 자율적인 프로젝트를 진행하는 것이 중요하다.

 

그러나 "백문이 불여일견"이라는 말이 있듯이, 이를 아무리 강조하더라도 개발 초창기에는 경험이 부족하기 때문에 종종 이 중요성을 인식하는 데 어려움을 겪는다. 나 또한 실제로 경험을 하기 전에는 이 중요성을 체감하기 어려웠다. 그래서 항상 교과서 수준의 예시보다는 실제 사례를 기반으로, 변화에 대응하는 것의 중요성을 설명하는 글이 있으면 좋겠다는 생각을 항상 해왔다.

 

감사하게도, 이번에 변화에 대응하기 위해 유연한 코드를 작성하는 실제 사례를 경험하게 되었고, 이를 공유하고자 한다. 실제로 경험하는 것만큼은 아니지만, 이 글이 독자들의 방향성을 잡는데 도움이 되길 바라며 글을 작성한다. 이 글에는 가상의 개발자 김oo이 등장하는데, '만약 내가 김oo라면 어떻게 할까?'라는 생각을 하며 읽어보면 더 재미있게 읽을 수 있을 것이다.

 

 

1. 기능이 추가되어야 할 것 같습니다..!

다른 어느 날과 똑같은 평범한 평일 아침, 개발자 김oo이 침대에서 기지개를 켜며 미지근한 잠에서 깨어났다. 김oo는 현재 금융 데이터를 시각화하고 분석할 수 있는 프로그램 개발 프로젝트에 참여하고 있다. 곧 베타 버전 배포를 앞에 두고 있는 상태라 매일 초과근무를 하고 있어 피곤함을 느끼고 있다.

 

아직 잠에서 덜 깬 김oo는 비몽사몽 한 상태로 지으며, 자는 동안 무슨 일이 있었는지 확인하기 위해 습관적으로 핸드폰을 확인한다. 핸드폰에서 메신저를 확인한 김oo는 현재 진행 중인 프로젝트의 비즈니스 관계자로부터 연락이 온 것을 확인한다. 뭔가 불길한 예감에 정신이 번쩍 든 김oo는 일단 침착하게 메시지를 확인한다.

비즈니스 관계자로부터 온 의문의 카톡

 

"기능을 추가해야 할 것 같습니다..!" 역시나 불길한 예감은 틀리지 않았다. 다음으로 "바쁘신 와중에 말씀드려 마음이 좋지 않네요"라는 문구가 눈에 띈다. 맞다, 개발자는 항상 바쁘다. 기능 추가는 절대 개발자가 한가할 때 발생하지 않는다.

 

일단 진정하고 어떤 기능인지 천천히 살펴보도록 하자. 먼저, 추가되어야 하는 첫 번째 기능은 현재 차트로 시각화하고 있는 데이터를 엑셀 파일로 추출하는 기능이다. ui가 조금 변경되어야 할 것 같긴 하지만 엑셀 파일로 추출하는 일은 관련 라이브러리가 충분히 있을 것 같아서 그나마 쉽게 구현할 수 있을 것 같다.

 

문제는 두 번째 기능이다. 사용자는 데이터를 차트에 표현할 때, 데이터의 스케일(이를 'unit'라고 부른다)을 조정할 수 있다. 현재 사용자가 선택할 수 있는 유닛은 원래의 데이터 순수값(default)과, 데이터의 최대와 최소 값을 기준으로 한 상대값(index)이다. 여기에 전년 대비 상승률(change Year over Year), 전월 대비 상승률(change Month over Month)이라는 2개의 유닛을 추가해 달라는 것으로 보인다.

데이터 스케일을 순수값에서 전월 대비 상승률로 변경했다

즉, 위 처럼 데이터 순수값을 가진 지표를 두 번째 차트처럼 전년 대비 상승률로 데이터 스케일을 변경하여 차트에 표현할 수 있어야 한다.

 

음.. 안타깝지만 바로 해결책이 떠오르지 않는다. 아마도 관련 코드를 확인해 봐야 할 것 같다. 아침 시작이 그다지 상쾌하진 않지만, 김oo는 일단 침대에서 일어나서 신속하게 작업실로 이동할 준비를 한다.

 

 

2. 변경할 위치 찾기

작업실에 가는 길에 아이스 아메리카노 한잔을 사 온 김oo은 도착하자마자 자리를 간단히 정리하고 맥북 노트북 전원을 켠다. 기능을 수정하거나 추가할 때 가장 먼저 해야 할 일은 기존 코드에서 변경되어야 하는 위치를 찾는 것이다. 가독성이 좋고 캡슐화가 잘 되어 있는 코드가 좋은 코드인 이유가 여기에 있다. 김oo는 이 프로젝트에 대한 이해도가 높았기 때문에 다행히 unit type으로 데이터 스케일을 계산하는 관련 class들을 찾아냈다. 먼저 슈퍼클래스부터 살펴보자.

type UnitType = 'index' | 'default';

// super class
export abstract class IndicatorValue {
  public id: string;
  protected maxValue: number;
  protected minValue: number;

  constructor(id: string, maxValue: number, minValue: number) {
    this.id = id;
    this.maxValue = maxValue;
    this.minValue = minValue;
  }

  abstract formatItemsByDate({ unitType }: { unitType: UnitType }): FormattedItem;

  calculateValue(item: IndicatorValueItem, unitType: UnitType) {
    return unitType === 'index' ? item.calcuateIndexValue(this.maxValue, this.minValue) : item.parseValueToInt;
  }
}

// value의 계산 로직을 가지고 있는 IndicatorValueItem class
export class IndicatorValueItem {
  readonly date: string;
  readonly value: number | string;
  constructor({ date, value }: IndicatorValueItemResponse) {
    this.date = date;
    this.value = value;
  }

  calcuateIndexValue(maxValue: number, minValue: number) {
    return ((this.parseValueToInt(item.value) - minValue) / (maxValue - minValue)) * 100;
  }

  get parseValueToInt() {
    return typeof this.value === 'number' ? this.value : parseInt(this.value);
  }
}

슈퍼 클래스인 IndicatorValue 클래스는 formatItemsByDate() 메서드를 통해 지표 데이터를 차트 형식에 맞게 포매팅하는 역할을 수행하는 것으로 보인다. 포매팅 과정에서 데이터 스케일을 unit type에 맞게 변경하기 위해, calculateValue() 메서드에서 데이터 스케일 계산 로직을 호출한다. 그 과정에서 index unit type의 계산을 호출하기 위해 maxValue와 minValue를 필드로 가지고 있다.

 

IndicatorValueItem 클래스는 value object로 보이며, calcuateIndexValue() 메서드와 같이 unit type에 따른 계산 로직은 여기서 작성되고 있다.

// sub class, Actual이라는 네이밍이 붙은 이유는 실제 지표와, 사용자가 생성한 커스텀 지표룰 구분하기 위해서이다.
export class ActualIndicatorValue extends IndicatorValue {
  readonly indicatorId: string;
  // ...
  readonly values: IndicatorValueItem[];

  constructor({ indicatorId, values }: IndicatorValueResponse) {
    const valueItems = values.map((item) => new IndicatorValueItem(item));
    super(
      indicatorId,
      Math.max(...valueItems.map((item) => item.parseValueToInt)),
      Math.min(...valueItems.map((item) => item.parseValueToInt)),
    );
    this.indicatorId = indicatorId;
    this.values = values.map((item) => new IndicatorValueItem(item));
  }

  formatItemsByDate({ unitType }: { unitType: UnitType }): FormattedItem {
    return this.values.reduce<FormattedItem>((acc, item) => {
      return {
        ...acc,
        [item.date]: {
          [this.ticker]: {
            value: this.calculateValue(item, unitType),
            pureValue: item.parseValueToInt,
          },
        },
      };
    }, {});
  }
}

ActualIndicatorValue 클래스는 IndicatorValue 클래스를 상속받아 formatItemsByDate() 메서드를 오버라이드하여 구현한다. 이 메서드는 외부에서 unit type을 인자로 받아 부모 클래스에서 작성된 unit type별 데이터 스케일을 계산하는 calculateValue() 메서드를 호출한다.

 

코드를 살펴보는 김oo는 오늘은 일찍 집에 갈 수 없겠다는 생각이 들었다. "내가 이 부분을 개발할 때 잠깐 졸았었나?"라는 생각도 든다. 뭐가 문제일까?

 

문제점 진단하기

만약 현재 코드 상태에서 새 기능을 추가한다고 해보자. 먼저 기존 코드인 calculateValue() 메서드에 조건문이 추가되고 IndicatorValueItem에 관련 로직이 추가될 것이다.

export abstract class IndicatorValue {
  // ...
  calculateValue(item: IndicatorValueItem, unitType: UnitType) {
    if (unitType === 'index') return item.calcuateIndexValue(this.maxValue, this.minValue);
    // 조건문이 추가되어야 한다.
    if (unitType === 'MoM') return item.calcualteMoMValue(arg);
    if (unitType === 'YoY') return item.calcualteYoYValue(arg);
  }
}

export class IndicatorValueItem {
  // ...
  calcuateIndexValue(maxValue: number, minValue: number) {
    return ((this.parseValueToInt(item.value) - minValue) / (maxValue - minValue)) * 100;
  }

  calcuateMoMValue(m) {
    // ... 추가 되어야 한다
  }

  calcuateYoYValue(m) {
    // ... 추가 되어야 한다.
  }
}

기존 코드가 변경되어야 하기에 이미 만족스럽지 않지만, 더 큰 문제는 IndicatorValue 클래스가 값을 계산하기 위한 컨텍스트를 필드로 가져야 한다는 점이다. 이미 index를 계산하기 위해 minValue와 maxValue를 가지고 있는 것이 불편한데, calcuateMoMValue와 calcuateYoYValue를 계산하기 위한 컨텍스트까지 가져야 한다면, 클래스는 무거워질 뿐만 아니라, 다른 개발자가 봤을 때 그 역할이 명확하게 드러나지 않을 것이다.

export abstract class IndicatorValue {
  public id: string;
  protected maxValue: number;
  protected minValue: number;
  // ...계산을 위한 필드가 추가되어야 한다.

  // ...
}

마지막으로, 이번 기능 변경 사항을 어떻게든 수용한다 해도, 앞으로 unit type이 더 추가되지 않을 것이라는 보장이 있을까? 실제로 금융 데이터 스케일은 수십 가지가 넘기에, 앞으로도 추가될 확률이 매우 높다(도메인에 대한 이해는 이러한 의사결정을 내리는데 도움이 된다.)

 

문제점을 진단한 김oo은 코드를 변경 가능한 상태로 리팩터링 하고 기능 추가를 진행하기로 결정했다. 리팩토링의 주요 목적은 데이터 스케일 계산에 필요한 데이터와 로직을 외부로 분리하는 것이다. 물론 이 기능을 추가한 후에도 해야 할 일들이 산더미처럼 쌓여 있지만, 방향성을 설정했다는 것에 대해 김oo은 약간은 안심하며, 아까 사 온 아메리카노를 들이켜고 다음 작업을 준비한다.

 

여기서 김oo의 결정은 아주 현명했다. 당장은 느려 보일 수 있지만 리팩터링 하고 기능을 추가하는 것이 결국 가장 빠른 길이기 때문이다

 

 

3. 테스트 코드부터 작성하자

이제 해야 할 것이 명확해졌으니 바로 리팩터링부터 하면 될까? 아직 한 단계가 남아있다. 바로 테스트 코드를 작성하는 일이다. 자동화된 테스트 코드는 변경에도 코드가 의도대로 작동함을 보장하는 유일한 방법이다. 리팩토링은 작은 단계를 순차적으로 진행하며 이뤄진다. 이때 각 단계에서 코드가 정상적으로 작동한다는 것을 보장할 수 없다면, 리팩터링은 운에 맡기는 야바위 게임과 다를 바가 없어진다. 따라서 테스트 코드 없는 리팩토링은 불가능하다.

 

다행히 김oo도 이 사실을 알고 있는 듯하다. 리팩터링 전에 테스트 코드를 작성해 보자

describe('ActualIndicatorValue', () => {
  it('formatItemsByDate should return formatted items by date', () => {
    const indicatorValue = mockDB.getIndicatorValue('1');
    const actualIndicatorValue = new ActualIndicatorValue(indicatorValue!);

    const formattedItems = actualIndicatorValue.formatItemsByDate({ unitType: 'default' });

    expect(formattedItems['2024-01-01']).toEqual({
      AAPL: {
        value: 10000,
        pureValue: 10000,
      },
    });
    // 이후 생략
  });

  it('formatItemsByDate should return formatted items by date with unitType as index', () => {
    const indicatorValue = mockDB.getIndicatorValue('1');
    const actualIndicatorValue = new ActualIndicatorValue(indicatorValue!);

    const formattedItems = actualIndicatorValue.formatItemsByDate({ unitType: 'index' });

    expect(formattedItems['2024-01-01']).toEqual({
      AAPL: {
        value: 0,
        pureValue: 10000,
      },
    });
    // 이후 생략
  });
  
  // ...
});
}

테스트 코드의 유일한 단점은 작성하기 귀찮다는 것이다. 이를 극복하기 위해 테스트 코드를 구현 전에 먼저 작성하는 TDD(테스트 주도 개발) 기법을 사용하기도 한다. 지금 상황과 같이 이미 개발이 완료된 코드에 대한 테스트 코드를 작성하려는 경우, 미세하지만 팁이 있다. 테스트를 일부러 실패하게 실행한 후, 테스트 도구가 제공하는 로그 값을 사용하여 테스트를 작성하면 테스트 코드 작성을 조금이나마 더 수월하게 할 수 있다.

 

 

4. 기존 코드 리팩터링

 

1. 단계 분할하기

이제 테스트 코드가 준비되었으므로 본격적으로 리팩터링을 진행할 수 있다. 먼저 할 일은 formatItemsByDate() 메서드를 분할하는 것이다. 현재 formatItemsByDate() 메서드는 날짜를 기준으로 데이터를 포맷하는 역할과 데이터 스케일을 계산하는 두 가지 역할을 수행하고 있다. 우리의 목표는 데이터 스케일을 계산하는 부분을 개선하는 것인데, 메서드가 두 가지 역할을 수행하고 있기에 리팩터링 하기가 어렵다. 따라서 데이터 포맷과 스케일 계산을 분리하여 단계를 분할해 보자.

// before: 데이터 포멧과 스케일 계산이 하나의 메서드에서 이뤄지고 있다.
export class ActualIndicatorValue extends IndicatorValue {
	// ...
  formatItemsByDate({ unitType }: { unitType: UnitType }): FormattedItem {
    return this.values.reduce<FormattedItem>((acc, item) => {
      return {
        ...acc,
        [item.date]: {
          [this.ticker]: {
            value: this.calculateValue(item, unitType),
            pureValue: item.parseValueToInt,
          },
        },
      };
    }, {});
  }
}

// after: 데이터 스케일 계산을 calculateItemsValue() 메서드로 분리했다.
export class ActualIndicatorValue extends IndicatorValue {
	// ...
	calculateItemsValue({ unitType }: { unitType: UnitType }) {
	    return this.values.map((item) => {
	      return {
	        date: item.date,
	        value: this.calculateValue(item, unitType),
	        pureValue: item.parseValueToInt,
	      };
	    });
	  }
	
	  formatItemsByDate({ unitType }: { unitType: UnitType }): FormattedItem {
	    return this.calculateItemsValue({ unitType }).reduce<FormattedItem>((acc, item) => {
	      return {
	        ...acc,
	        [item.date]: {
	          [this.ticker]: {
	            value: item.value,
	            pureValue: item.pureValue,
	          },
	        },
	      };
	    }, {});
	  }
}

코드를 수정한 후에는 테스트를 실행한다 모든 테스트가 통과되면 다음 단계로 넘어가자.

 

2. 외부 로직으로 교체하기

다음으로는 IndicatorValueItem에 작성된 unit type index 계산 로직을 제거하는 것이다. 그전에 먼저 외부에 계산 로직을 작성하자

type ValueItem = {
  date: string;
  value: number | string;
};

// 데이터 스케일 계산을 담당하는 class를 새롭게 만들었다.
export class IndexUnitCalculator {
  private _valueItems: ValueItem[];
  private _max: number;
  private _min: number;
  // JS/TS가 덕타이핑 언어인 점을 이용해 IndicatorValueItem 문맥을 제거 했다.
  constructor(valueItems: ValueItem[]) {
    this._valueItems = valueItems;
    this._max = this.max;
    this._min = this.min;
  }

  get max() {
    return Math.max(...this._valueItems.map((item) => this.parseValueToInt(item.value)));
  }

  get min() {
    return Math.min(...this._valueItems.map((item) => this.parseValueToInt(item.value)));
  }

  calculate() {
    return this._valueItems.map((item) => {
      return {
        date: item.date,
        value: this.calculateItem(item),
        pureValue: this.parseValueToInt(item.value),
      };
    });
  }

  calculateItem(item: ValueItem) {
    return ((this.parseValueToInt(item.value) - this._min) / (this._max - this._min)) * 100;
  }

  parseValueToInt(value: number | string) {
    return typeof value === 'number' ? value : parseInt(value);
  }
}
export class DefaultUnitCalculator {
  private _valueItems: ValueItem[];
  constructor(valueItems: ValueItem[]) {
    this._valueItems = valueItems;
  }

  calculate() {
    return this._valueItems.map((item) => {
      return {
        date: item.date,
        value: this.parseValueToInt(item.value),
        pureValue: this.parseValueToInt(item.value),
      };
    });
  }

  parseValueToInt(value: number | string) {
    return typeof value === 'number' ? value : parseInt(value);
  }
}

그리고 기존의 calculateItemsValue() 메서드를 외부에서 작성한 로직으로 교체한다.

export class ActualIndicatorValue extends IndicatorValue {
	// ...
	
	// before
	calculateItemsValue({ unitType }: { unitType: UnitType }) {
	    return this.values.map((item) => {
	      return {
	        date: item.date,
	        value: this.calculateValue(item, unitType),
	        pureValue: item.parseValueToInt,
	      };
	    });
	  }
	  
	// after
  calculateItemsValue({ unitType }: { unitType: UnitType }) {
    const calculator =
      unitType === 'index' ? new IndexUnitCalculator(this.values) : new DefaultUnitCalculator(this.values);

    return calculator.calculate();
  }
	// ...
}

여기서 주목해야 할 점은 IndexUnitCalculator 클래스를 작성할 때 IndicatorValueItem의 문맥을 제거했다는 것이다. 더 넓은 컨텍스트에서 코드를 재사용할 수 있다. JS/TS는 덕 타이핑 언어이므로, 기존 코드와의 호환성도 문제가 없다.

// IndicatorValueItem에서 계산 로직을 제거했다.
export class IndicatorValueItem {
  readonly date: string;
  readonly value: number | string;
  constructor({ date, value }: IndicatorValueItemResponse) {
    this.date = date;
    this.value = value;
  }
}

외부로 이동된 계산 로직으로 인해 IndicatorValueItem에는 기본 코드만 남아있다. 테스트를 실행한 후에 모든 테스트가 통과되면 다음 단계로 넘어가자.

 

3. 불필요한 속성들을 제거한다

이제 로직을 외부로 옮겼으니 이로 인해 불필요해진 속성들을 제거해 보자.

export abstract class IndicatorValue {
  readonly id: string;
  protected _unitType: UnitType = 'default';
  // 더 이상 maxValue, minValue와 같은 필드를 가지고 있지 않아도 된다.

  constructor(id: string) {
    this.id = id;
  }

  set unitType(unitType: UnitType) {
    this._unitType = unitType;
  }

  // calculateItemsValue(), formatItemsByDate()를 접근자 get을 이용해 클라이언트가 필드처럼
  // 사용하도록 만들었다.
  abstract get valuesByUnit(): CalculatedItem[];

  abstract get itemsByDate(): FormattedItem;
}

export class ActualIndicatorValue extends IndicatorValue {
  readonly indicatorId: string;
  // ...
  readonly values: IndicatorValueItem[];
  constructor({ indicatorId, values }: IndicatorValueResponse) {
    super(indicatorId);
    this.indicatorId = indicatorId;
    this.values = values.map((item) => new IndicatorValueItem(item));
  }

  get valuesByUnit() {
    const calculator =
      this._unitType === 'index' ? new IndexUnitCalculator(this.values) : new DefaultUnitCalculator(this.values);

    return calculator.calculate();
  }

  get itemsByDate(): FormattedItem {
    return this.valuesByUnit.reduce<FormattedItem>((acc, item) => {
      return {
        ...acc,
        [item.date]: {
          [this.ticker]: {
            value: item.value,
            pureValue: item.pureValue,
          },
        },
      };
    }, {});
  }
}

불필요한 속성을 제거함과 동시에, 몇 가지 추가적인 리팩토링을 진행했다. 우선, unitType을 IndicatorValue 클래스의 필드로 이동시켜 formatItemsByDate() 및 calculateItemsValue() 메서드에서 사용되던 unitType 인자를 제거했다. unitType을 외부에서 받는 것보다 format을 수행하는 IndicatorValue 클래스의 필드로 가지는 것이 적절하다고 판단했기 때문이다.

 

formatItemsByDate(), calculateItemsValue() 메서드의 인자가 제거되었으므로, 자바스크립트의 접근자인 get 문법을 사용 하여 클라이언트는 해당 필드가 계산된 값인지 아니면 실제 필드인지 구분할 수 없도록 했다. 데이터에 접근하는 방식을 단일화 하여 캡슐화를 더욱 강화한 것이다. 이로 인해 메서드명이 valuesByUnit, itemsByDate로 바뀌어 더 가볍고 직관적으로 변한 것은 덤이다. 역시 테스트를 실행한 후에 모든 테스트가 통과되면 다음 단계로 넘어가자.

 

4. 다형성으로 바꾸기

다음 리팩토링 대상은 valuesByUnit() 메서드 내의 조건문으로 calculator를 지정하는 부분이다. IndicatorValue 클래스가 계산에 어떤 calculator를 사용하는지 굳이 알 필요가 있을까? 이 부분을 다형성을 이용하여 캡슐화해 보자.

먼저, IndexUnitCalculator와 defaultUnitCalculator의 공통 로직을 추출하여 부모 추상 클래스인 UnitCalculator 클래스를 생성하자

// super class
export abstract class UnitCalculator {
  protected _valueItems: ValueItem[];
  constructor(valueItems: ValueItem[]) {
    this._valueItems = valueItems;
  }
  
  calculate(): calculatedValueItem[] {
    return this._valueItems.map((item) => {
      return {
        date: item.date,
        value: this.calculateItem(item),
        pureValue: this.parseValueToInt(item.value),
      };
    });
  }

  parseValueToInt(value: number | string) {
    return typeof value === 'number' ? value : parseInt(value);
  }
}

export class IndexUnitCalculator extends UnitCalculator {
  private _max: number;
  private _min: number;
  
  constructor(valueItems: ValueItem[]) {
    super(valueItems);
    this._max = this.max;
    this._min = this.min;
  }
  
  get max() {
    return Math.max(...this._valueItems.map((item) => this.parseValueToInt(item.value)));
  }

  get min() {
    return Math.min(...this._valueItems.map((item) => this.parseValueToInt(item.value)));
  }

  calculateItem(item: ValueItem) {
    return ((this.parseValueToInt(item.value) - this._min) / (this._max - this._min)) * 100;
  }
}

export class DefaultUnitCalculator extends UnitCalculator {
  caculateItem(item: ValueItem) {
    return this.parseValueToInt(item.value)
  }
}

그다음에는 팩토리 함수를 생성하여 조건문을 캡슐화하고, 이 팩토리 함수를 기존에 조건문과 교체한다.

export function createUnitCalculator(valueItems: ValueItem[], unitType: UnitType): UnitCalculator {
  switch (unitType) {
    case 'index':
      return new IndexUnitCalculator(valueItems);
    case 'default':
      return new DefaultUnitCalculator(valueItems);
  }
}

export class ActualIndicatorValue extends IndicatorValue {
  // ...
  get valuesByUnit() {
    // 조건문을 캡슐화했다.
    return createUnitCalculator(this.values, this._unitType).caculate();
  }
}

이제 IndicatorValue 클래스는 어떤 UnitCalculator를 사용하는지 알지 못해도 여전히 기능을 실행할 수 있다. 테스트를 실행하고 모든 테스트를 통과함을 확인했다. 이제 리팩토링을 멈춰도 좋을 것 같다. 이러한 결정을 내린 이유는 리팩토링이 새 기능을 추가하는 데 문제가 없을 만큼 진행되었기 때문이다. 이제 본격적으로 새로운 기능을 추가해 보자.

 

리팩토링을 마치고 보니, 김oo는 자신도 모르게 아침에 가져온 아이스 아메리카노를 모두 마신 것을 깨달았다. 잠시 스트레칭도 할 겸, 자판기로 가서 다이어트 콜라를 뽑아 오자.

 

 

5. 기능 추가하기

드디어 기능 추가 준비를 마쳤다. 우리가 추가할 기능은 기존 유닛(default, index)에 '전년 대비 상승률(change Year over Year)'과 '전월 대비 상승률(change Month over Month)' 두 가지 유닛을 추가하는 것이다. 여기서는 지면 관계상 '전월 대비 상승률'만 다룰 예정이다. 어쨌든, 리팩터링이 잘 되어 있다면 두 가지 기능을 추가하는 원리는 동일하기 때문이다.

 

1. 테스트 코드 작성하기

기능을 추가할 때는 테스트 코드를 먼저 작성한다. 이런 접근 방식에는 여러 가지 장점이 있는데, 그중에서도 개인적으로 가장 크게 체감하는 장점은 '개발 완료'를 명확하게 정의할 수 있다는 것이다. 이제 개발 완료는 개발자의 직감에 의존하는 것이 아니라, 테스트 코드를 통과 여부로 결정된다.

 

테스트 코드를 작성할 때는, 코드가 의도대로 동작하는 정상 경로뿐만 아니라 엣지 케이스에 대한 테스트 코드도 작성해야 한다.

describe('ActualIndicatorValue', () => {
  // ...이전 테스트 코드  
  
  it('formatItemsByDate should return formatted items by date with unitType as MoM', () => {
    const indicatorValue = mockDB.getIndicatorValue('1');
    const actualIndicatorValue = new ActualIndicatorValue(indicatorValue!);

    const formattedItems = actualIndicatorValue.formatItemsByDate({ unitType: 'MoM' });

    expect(formattedItems['2024-01-04']).toEqual({
      AAPL: {
        value: 0,
        pureValue: 20000,
      },
    });
    expect(formattedItems['2024-02-04']).toEqual({
      AAPL: {
        value: 80,
        pureValue: 36000,
      },
    });
  });

  it('주말로 인해 한달 전과 동일한 날짜가 없는 경우', () => {
    const indicatorValue = mockDB.getIndicatorValue('1');
    const actualIndicatorValue = new ActualIndicatorValue(indicatorValue!);

    const formattedItems = actualIndicatorValue.formatItemsByDate({ unitType: 'MoM' });

    expect(formattedItems['2024-01-05']).toEqual({
      AAPL: {
        value: 0,
        pureValue: 42000,
      },
    });
    expect(formattedItems['2024-01-07']).toBeUndefined();
    expect(formattedItems['2024-02-07']).toEqual({
      AAPL: {
        value: -7.14,
        pureValue: 39000,
      },
    });
  });

  it('월의 마지막 날이 달라서 동일한 날짜가 없는 경우', () => {
    const indicatorValue = mockDB.getIndicatorValue('1');
    const actualIndicatorValue = new ActualIndicatorValue(indicatorValue!);

    const formattedItems = actualIndicatorValue.formatItemsByDate({ unitType: 'MoM' });

    expect(formattedItems['2024-02-29']).toEqual({
      AAPL: {
        value: 0,
        pureValue: 49000,
      },
    });
    expect(formattedItems['2024-02-31']).toBeUndefined();
    expect(formattedItems['2024-03-31']).toEqual({
      AAPL: {
        value: -12.65,
        pureValue: 42800,
      },
    });
  });
}

 

2. 로직을 추가한다

기능 추가의 마지막 단계다. 먼저 IndexUnitCalulator처럼 MoM을 계산하는 MoMUnitCalulator 클래스를 만든다.

export class MoMUnitCalulator extends UnitCalculator {
  private _cachedValue: { [date: string]: number } = {};

  caculateItemValue(item: ValueItem) {
    const targetValue = this.parseValueToInt(item.value);

    this._cachedValue[item.date] = targetValue;

    const previousValue = this.getPreviousValue(item.date);
    
    return previousValue ? this.caculateMoM(targetValue, previousValue) : 0
  }

  getPreviousValue(targetDate: string) {
    const previoutDate = this.getPreviousDate(targetDate);

    return previoutDate ? this._cachedValue[previoutDate] : undefined;
  }

  getPreviousDate(targetDate: string) {
    // ..
  }

  caculateMoM(targetValue: number, previousValue: number) {
    return this.parseValueFixed(((targetValue - previousValue) / previousValue) * 100, 2);
  }

  parseValueFixed(value: number, fractionDigits: number) {
    return parseFloat(value.toFixed(fractionDigits));
  }
}

마지막으로 팩토리 함수에 만든 클래스를 추가한다

export type UnitType = 'MoM' | 'index' | 'default';

export function createUnitCalculator(valueItems: ValueItem[], unitType: UnitType): UnitCalculator {
  switch (unitType) {
    case 'index':
      return new IndexUnitCalculator(valueItems);
    case 'MoM':
      return new MoMUnitCalulator(valueItems);
    default:
      return new DefaultUnitCalculator(valueItems);
  }
}

기능 추가가 끝났다. 놀랍도록 간단하지 않은가? 이것이 리팩터링의 힘이며, 때때로 프로그래밍이 마법처럼 묘사되는 이유이다. 같은 기능을 구현하더라도, 그 구현 방법과 결과는 정말로 천차만별이다. 만약 리팩터링 없이 우리가 가능 추가를 했다고 해보자. 여기저기 클래스를 옮기며 로직과 데이터를 기존 코드에 추가해야 했을 겁니다. 그 결과 클래스는 점점 무거워져서 원래 어떤 역할을 담당했는지 알 수 없을 정도로 복잡해졌을 것이다.

 

이뿐만일까? 다음으로 YoY 유닛을 추가해야 한다고 생각해 보자. 그리고 추가로 또 다른 유닛 추가가 요구사항으로 전달되었다고 가정해 보자. 이럴 경우 개발 속도는 점점 느려지고, 직업 만족도와 열정은 점점 떨어지며, 집에 도착하는 시간은 점점 늦어질 것이다. 그 뒤에 이어질 상황은 상상만 해도 끔찍하다.

 

그러나 김oo이 리팩터링을 잘 수행한 덕분에 당분간은 그런 미래를 피할 수 있게 되었다. YoY뿐만 아니라 추가 유닛이 어떤 것이든, 기존 코드를 수정하지 않고 부모 클래스를 상속받는 서브 클래스를 작성하고 팩토리 함수에 추가하기만 하면 기능 추가가 끝난다. 멋진 일이다. 김oo가 오늘은 일찍 집에 가서 시원한 맥주라도 한잔 마실 수 있기를 바란다

 

 

마무리하며

내가 알고 있는 김oo의 이야기는 여기서 끝난다. 앞으로 김oo은 어떻게 될까? 아마 그가 개발자라는 직업을 그만두지 않는다면, 머지않아 찾아올 또 다른 변화에 대응해야 할 것이다. 개발자는 매주, 매일, 매 순간 크고 작은 변화를 맞이하며 이에 대응해야 한다. 나는 이것이 우리 직업의 불변하는 속성이라고 생각한다.

 

변화는 때때로 달갑게 느껴지지 않을 수 있다. 특히, 마감일이 임박하고 매우 바쁜 상황에서의 변화는 더욱 그렇다. 하지만 이것이 우리 직업의 불가피한 특성이라면? 그리고 이 직업을 선택하고 계속하기로 결심했다면? 이런 상황에서 불평하기보다는 변화에 대응하는 방법을 연습하고 훈련하여, 준비된 마음가짐과 역량을 갖추는 것이 더 바람직한 방향일 것이다.

 

변화에 대응하는 것은 결코 쉽지 않다. 단순히 동작하는 코드를 넘어서, 겉보기에는 동작이 같아 보이지만, 변경 가능한 코드를 만드는 것은 완전히 다른 수준의 일이며, 더 많은 고민, 훈련, 그리고 연습을 필요로 한다. 또한 이 글에서는 코드 레벨에서의 변화에 대응하는 방법을 설명했지만, 변화에 대응하는 것은 코드 레벨에서만 발생하는 것은 아니다. 소프트웨어 개발 프로세스의 모든 단계에서 변화가 발생한다. 내가 알고 있는 이에 대응하는 가장 효과적인 원칙은 큰 작업을 작은 단위로 나누고, 이를 반복하면서 피드백 주기를 증폭하는 것이다.

 

앞에서 밝혔 듯, 변화에 대응하는 것의 그 중요성에 비해 많은 개발자들이 이에 대응하는 연습을 크게 신경 쓰지 않거나 하지 않는다. 내 생각에는 경험이 많지 않다면 이것이 왜 중요한지 체감하기 어렵기 때문이라고 생각한다. 이 글이 조금이나 감을 잡고 방향을 설정하는데 도움이 되었으면 하는 바람이다.

 

앞서 언급했듯이, 변화에 대응하는 것의 중요성에 비해 많은 개발자들이 이에 대한 연습에 큰 신경을 쓰지 않는다. 내 생각에는 경험이 부족하면 이것이 왜 중요한지 체감하기 어렵기 때문인 것 같다. 이 글이 방향성을 잡는데 조금이라도 도움이 되길 바라며 글을 마친다.